mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-01 07:03:02 +00:00
refactor(ui): move admin and settings text onto i18n
Route the page-authored English on the admin panel, caching, access groups, tag management, logging and alerts, and router settings pages through react-i18next, with 1,183 messages added to en.json. The rendered English is unchanged
This commit is contained in:
parent
901b7f762f
commit
bfe84f4dee
89 changed files with 4214 additions and 1588 deletions
|
|
@ -1,4 +1,5 @@
|
|||
import { useAccessGroupDetails } from "@/app/(dashboard)/hooks/accessGroups/useAccessGroupDetails";
|
||||
import type { TFunction } from "i18next";
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
BotIcon,
|
||||
|
|
@ -10,6 +11,7 @@ import {
|
|||
UsersIcon,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import DefaultProxyAdminTag from "@/components/common_components/DefaultProxyAdminTag";
|
||||
import { BadgeLink } from "@/components/shared/BadgeLink";
|
||||
import CopyButton from "@/components/shared/CopyButton";
|
||||
|
|
@ -34,11 +36,11 @@ const MAX_PREVIEW = 5;
|
|||
|
||||
const shortId = (id: string) => (id.length > 20 ? `${id.slice(0, 10)}...${id.slice(-6)}` : id);
|
||||
|
||||
const describeAllowedTools = (tools: readonly string[] | undefined): string => {
|
||||
const describeAllowedTools = (tools: readonly string[] | undefined, t: TFunction): string => {
|
||||
if (tools === undefined) {
|
||||
return "All tools";
|
||||
return t("accessGroups.detail.toolPermissions.allTools");
|
||||
}
|
||||
return tools.length === 0 ? "No tools" : tools.join(", ");
|
||||
return tools.length === 0 ? t("accessGroups.detail.toolPermissions.noTools") : tools.join(", ");
|
||||
};
|
||||
|
||||
function MCPToolPermissionList({
|
||||
|
|
@ -48,16 +50,17 @@ function MCPToolPermissionList({
|
|||
servers: readonly AccessGroupResource[];
|
||||
toolPermissions: Readonly<Record<string, string[]>> | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (toolPermissions === null || servers.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<section className="mt-6 space-y-2" aria-label="MCP tool permissions">
|
||||
<section className="mt-6 space-y-2" aria-label={t("accessGroups.detail.toolPermissions.label")}>
|
||||
{servers.map(({ id, name }) => {
|
||||
return (
|
||||
<div key={id} className="flex flex-wrap items-baseline gap-2 text-sm">
|
||||
<span className="font-medium">{name ?? id}</span>
|
||||
<span className="text-muted-foreground">{describeAllowedTools(toolPermissions[id])}</span>
|
||||
<span className="text-muted-foreground">{describeAllowedTools(toolPermissions[id], t)}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
|
@ -106,6 +109,7 @@ function ResourceBadge({
|
|||
}
|
||||
|
||||
export function AccessGroupDetail({ accessGroupId, onBack }: AccessGroupDetailProps) {
|
||||
const { t } = useTranslation();
|
||||
const { data: accessGroup, isLoading } = useAccessGroupDetails(accessGroupId);
|
||||
const [isEditModalVisible, setIsEditModalVisible] = useState(false);
|
||||
const [showAllKeys, setShowAllKeys] = useState(false);
|
||||
|
|
@ -124,10 +128,16 @@ export function AccessGroupDetail({ accessGroupId, onBack }: AccessGroupDetailPr
|
|||
if (!accessGroup) {
|
||||
return (
|
||||
<div className="p-6 px-12">
|
||||
<Button variant="ghost" size="icon" aria-label="Back" onClick={onBack} className="mb-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t("accessGroups.detail.back")}
|
||||
onClick={onBack}
|
||||
className="mb-4"
|
||||
>
|
||||
<ArrowLeftIcon className="size-4" />
|
||||
</Button>
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">Access group not found</p>
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">{t("accessGroups.detail.notFound")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -146,47 +156,50 @@ export function AccessGroupDetail({ accessGroupId, onBack }: AccessGroupDetailPr
|
|||
<div className="p-6 px-12">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="icon" aria-label="Back" onClick={onBack}>
|
||||
<Button variant="ghost" size="icon" aria-label={t("accessGroups.detail.back")} onClick={onBack}>
|
||||
<ArrowLeftIcon className="size-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold tracking-tight text-foreground">{accessGroup.access_group_name}</h1>
|
||||
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<span>ID: {accessGroup.access_group_id}</span>
|
||||
<CopyButton value={accessGroup.access_group_id} label="Copy access group ID" />
|
||||
<span>
|
||||
{t("accessGroups.detail.idLabel")}
|
||||
{accessGroup.access_group_id}
|
||||
</span>
|
||||
<CopyButton value={accessGroup.access_group_id} label={t("accessGroups.detail.copyId")} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={() => setIsEditModalVisible(true)}>
|
||||
<EditIcon className="size-4" />
|
||||
Edit Access Group
|
||||
{t("accessGroups.detail.edit")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Group Details</CardTitle>
|
||||
<CardTitle>{t("accessGroups.detail.groupDetails")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="grid grid-cols-[max-content_1fr] gap-x-4 gap-y-2 text-sm">
|
||||
<dt className="text-muted-foreground">Description</dt>
|
||||
<dt className="text-muted-foreground">{t("accessGroups.detail.description")}</dt>
|
||||
<dd className="text-foreground">{accessGroup.description || "—"}</dd>
|
||||
<dt className="text-muted-foreground">Created</dt>
|
||||
<dt className="text-muted-foreground">{t("accessGroups.detail.created")}</dt>
|
||||
<dd className="flex items-center gap-1 text-foreground">
|
||||
{new Date(accessGroup.created_at).toLocaleString()}
|
||||
{accessGroup.created_by && (
|
||||
<>
|
||||
<span>by</span>
|
||||
<span>{t("accessGroups.detail.by")}</span>
|
||||
<DefaultProxyAdminTag userId={accessGroup.created_by} />
|
||||
</>
|
||||
)}
|
||||
</dd>
|
||||
<dt className="text-muted-foreground">Last Updated</dt>
|
||||
<dt className="text-muted-foreground">{t("accessGroups.detail.lastUpdated")}</dt>
|
||||
<dd className="flex items-center gap-1 text-foreground">
|
||||
{new Date(accessGroup.updated_at).toLocaleString()}
|
||||
{accessGroup.updated_by && (
|
||||
<>
|
||||
<span>by</span>
|
||||
<span>{t("accessGroups.detail.by")}</span>
|
||||
<DefaultProxyAdminTag userId={accessGroup.updated_by} />
|
||||
</>
|
||||
)}
|
||||
|
|
@ -200,13 +213,15 @@ export function AccessGroupDetail({ accessGroupId, onBack }: AccessGroupDetailPr
|
|||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<KeyIcon className="size-4" />
|
||||
Attached Keys
|
||||
{t("accessGroups.detail.attachedKeys")}
|
||||
<Badge variant="secondary">{keys.length}</Badge>
|
||||
</CardTitle>
|
||||
{keys.length > MAX_PREVIEW && (
|
||||
<CardAction>
|
||||
<Button variant="link" size="sm" onClick={() => setShowAllKeys(!showAllKeys)}>
|
||||
{showAllKeys ? "Show Less" : `View All (${keys.length})`}
|
||||
{showAllKeys
|
||||
? t("accessGroups.detail.showLess")
|
||||
: t("accessGroups.detail.viewAll", { count: keys.length })}
|
||||
</Button>
|
||||
</CardAction>
|
||||
)}
|
||||
|
|
@ -219,7 +234,7 @@ export function AccessGroupDetail({ accessGroupId, onBack }: AccessGroupDetailPr
|
|||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No keys attached</p>
|
||||
<p className="text-sm text-muted-foreground">{t("accessGroups.detail.noKeys")}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -228,13 +243,15 @@ export function AccessGroupDetail({ accessGroupId, onBack }: AccessGroupDetailPr
|
|||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<UsersIcon className="size-4" />
|
||||
Attached Teams
|
||||
{t("accessGroups.detail.attachedTeams")}
|
||||
<Badge variant="secondary">{teams.length}</Badge>
|
||||
</CardTitle>
|
||||
{teams.length > MAX_PREVIEW && (
|
||||
<CardAction>
|
||||
<Button variant="link" size="sm" onClick={() => setShowAllTeams(!showAllTeams)}>
|
||||
{showAllTeams ? "Show Less" : `View All (${teams.length})`}
|
||||
{showAllTeams
|
||||
? t("accessGroups.detail.showLess")
|
||||
: t("accessGroups.detail.viewAll", { count: teams.length })}
|
||||
</Button>
|
||||
</CardAction>
|
||||
)}
|
||||
|
|
@ -247,7 +264,7 @@ export function AccessGroupDetail({ accessGroupId, onBack }: AccessGroupDetailPr
|
|||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No teams attached</p>
|
||||
<p className="text-sm text-muted-foreground">{t("accessGroups.detail.noTeams")}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -259,37 +276,37 @@ export function AccessGroupDetail({ accessGroupId, onBack }: AccessGroupDetailPr
|
|||
<TabsList variant="line" className="h-auto w-full justify-start rounded-none border-b p-0">
|
||||
<TabsTrigger value="models" className="flex-none gap-2 rounded-none px-4 py-2">
|
||||
<LayersIcon className="size-4" />
|
||||
Models
|
||||
{t("accessGroups.detail.tabs.models")}
|
||||
<Badge variant="secondary">{models.length}</Badge>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="mcp" className="flex-none gap-2 rounded-none px-4 py-2">
|
||||
<ServerIcon className="size-4" />
|
||||
MCP Servers
|
||||
{t("accessGroups.detail.tabs.mcpServers")}
|
||||
<Badge variant="secondary">{mcpServers.length}</Badge>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="agents" className="flex-none gap-2 rounded-none px-4 py-2">
|
||||
<BotIcon className="size-4" />
|
||||
Agents
|
||||
{t("accessGroups.detail.tabs.agents")}
|
||||
<Badge variant="secondary">{agents.length}</Badge>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="skills" className="flex-none gap-2 rounded-none px-4 py-2">
|
||||
<SparklesIcon className="size-4" />
|
||||
Skills
|
||||
{t("accessGroups.detail.tabs.skills")}
|
||||
<Badge variant="secondary">{skills.length}</Badge>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="models" className="pt-4">
|
||||
<ResourceList items={models} emptyMessage="No models assigned to this group" />
|
||||
<ResourceList items={models} emptyMessage={t("accessGroups.detail.empty.models")} />
|
||||
</TabsContent>
|
||||
<TabsContent value="mcp" className="pt-4">
|
||||
<ResourceList items={mcpServers} emptyMessage="No MCP servers assigned to this group" />
|
||||
<ResourceList items={mcpServers} emptyMessage={t("accessGroups.detail.empty.mcpServers")} />
|
||||
<MCPToolPermissionList servers={mcpServers} toolPermissions={accessGroup.mcp_tool_permissions ?? null} />
|
||||
</TabsContent>
|
||||
<TabsContent value="agents" className="pt-4">
|
||||
<ResourceList items={agents} emptyMessage="No agents assigned to this group" />
|
||||
<ResourceList items={agents} emptyMessage={t("accessGroups.detail.empty.agents")} />
|
||||
</TabsContent>
|
||||
<TabsContent value="skills" className="pt-4">
|
||||
<ResourceList items={skills} emptyMessage="No skills assigned to this group" />
|
||||
<ResourceList items={skills} emptyMessage={t("accessGroups.detail.empty.skills")} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { BotIcon, InfoIcon, LayersIcon, ServerIcon, SparklesIcon } from "lucide-react";
|
||||
import type { UseFormReturn } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { z } from "zod";
|
||||
|
||||
import { useAgents } from "@/app/(dashboard)/hooks/agents/useAgents";
|
||||
|
|
@ -16,9 +17,10 @@ import SkillSelector from "@/components/skills/SkillSelector";
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import i18n from "@/i18n/i18n";
|
||||
|
||||
export const accessGroupFormSchema = z.object({
|
||||
name: z.string().min(1, "Please enter the access group name"),
|
||||
name: z.string().min(1, { error: () => i18n.t("accessGroups.form.name.required") }),
|
||||
description: z.string(),
|
||||
modelIds: z.array(z.string()),
|
||||
mcpServerIds: z.array(z.string()),
|
||||
|
|
@ -80,6 +82,7 @@ export function AccessGroupBaseForm({
|
|||
onTabChange,
|
||||
initialServerIds,
|
||||
}: AccessGroupBaseFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const { accessToken } = useAuthorized();
|
||||
const { data: agentsData } = useAgents();
|
||||
const { data: mcpServersData } = useMCPServers();
|
||||
|
|
@ -98,56 +101,61 @@ export function AccessGroupBaseForm({
|
|||
<TabsList className="w-full">
|
||||
<TabsTrigger value={GENERAL_TAB}>
|
||||
<InfoIcon size={16} />
|
||||
General Info
|
||||
{t("accessGroups.form.tabs.general")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value={MODELS_TAB}>
|
||||
<LayersIcon size={16} />
|
||||
Models
|
||||
{t("accessGroups.form.tabs.models")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value={MCP_SERVERS_TAB}>
|
||||
<ServerIcon size={16} />
|
||||
MCP Servers
|
||||
{t("accessGroups.form.tabs.mcpServers")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value={AGENTS_TAB}>
|
||||
<BotIcon size={16} />
|
||||
Agents
|
||||
{t("accessGroups.form.tabs.agents")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value={SKILLS_TAB}>
|
||||
<SparklesIcon size={16} />
|
||||
Skills
|
||||
{t("accessGroups.form.tabs.skills")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value={GENERAL_TAB} className="pt-4">
|
||||
<FieldGroup>
|
||||
<FormField control={form.control} name="name" label="Group Name">
|
||||
<FormField control={form.control} name="name" label={t("accessGroups.form.name.label")}>
|
||||
{({ ref, ...field }) => (
|
||||
<Input {...field} ref={ref} placeholder="e.g. Engineering Team" disabled={isNameDisabled} />
|
||||
<Input
|
||||
{...field}
|
||||
ref={ref}
|
||||
placeholder={t("accessGroups.form.name.placeholder")}
|
||||
disabled={isNameDisabled}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
<FormField control={form.control} name="description" label="Description">
|
||||
<FormField control={form.control} name="description" label={t("accessGroups.form.description.label")}>
|
||||
{({ ref, ...field }) => (
|
||||
<Textarea {...field} ref={ref} rows={4} placeholder="Describe the purpose of this access group..." />
|
||||
<Textarea {...field} ref={ref} rows={4} placeholder={t("accessGroups.form.description.placeholder")} />
|
||||
)}
|
||||
</FormField>
|
||||
</FieldGroup>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value={MODELS_TAB} className="pt-4">
|
||||
<FormField control={form.control} name="modelIds" label="Allowed Models">
|
||||
<FormField control={form.control} name="modelIds" label={t("accessGroups.form.models.label")}>
|
||||
{(field) => <ModelSelect context="global" value={field.value} onChange={field.onChange} />}
|
||||
</FormField>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value={MCP_SERVERS_TAB} className="pt-4">
|
||||
<FormField control={form.control} name="mcpServerIds" label="Allowed MCP Servers">
|
||||
<FormField control={form.control} name="mcpServerIds" label={t("accessGroups.form.mcpServers.label")}>
|
||||
{({ id, value, onChange }) => (
|
||||
<MultiSelect
|
||||
id={id}
|
||||
value={value}
|
||||
onValueChange={onChange}
|
||||
options={mcpServerOptions}
|
||||
placeholder="Select MCP servers"
|
||||
placeholder={t("accessGroups.form.mcpServers.placeholder")}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
|
|
@ -160,14 +168,14 @@ export function AccessGroupBaseForm({
|
|||
</TabsContent>
|
||||
|
||||
<TabsContent value={AGENTS_TAB} className="pt-4">
|
||||
<FormField control={form.control} name="agentIds" label="Allowed Agents">
|
||||
<FormField control={form.control} name="agentIds" label={t("accessGroups.form.agents.label")}>
|
||||
{({ id, value, onChange }) => (
|
||||
<MultiSelect
|
||||
id={id}
|
||||
value={value}
|
||||
onValueChange={onChange}
|
||||
options={agentOptions}
|
||||
placeholder="Select agents"
|
||||
placeholder={t("accessGroups.form.agents.placeholder")}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
|
|
@ -177,15 +185,15 @@ export function AccessGroupBaseForm({
|
|||
<FormField
|
||||
control={form.control}
|
||||
name="skillNames"
|
||||
label="Allowed Skills"
|
||||
description="Enabled skills are visible to everyone. Grant disabled (private) Claude Code plugins to the teams and keys in this group here."
|
||||
label={t("accessGroups.form.skills.label")}
|
||||
description={t("accessGroups.form.skills.description")}
|
||||
>
|
||||
{({ value, onChange }) => (
|
||||
<SkillSelector
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
accessToken={accessToken || ""}
|
||||
placeholder="Select skills (optional)"
|
||||
placeholder={t("accessGroups.form.skills.placeholder")}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import React, { useState } from "react";
|
||||
import { useFormState } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { toast } from "@/lib/toast";
|
||||
import { useZodForm } from "@/lib/forms/useZodForm";
|
||||
|
|
@ -39,6 +40,7 @@ const toFormValues = (accessGroup: AccessGroupResponse): AccessGroupFormValues =
|
|||
});
|
||||
|
||||
function AccessGroupEditForm({ accessGroup, onCancel, onSuccess }: Omit<AccessGroupEditModalProps, "visible">) {
|
||||
const { t } = useTranslation();
|
||||
const form = useZodForm(accessGroupFormSchema, { defaultValues: toFormValues(accessGroup) });
|
||||
const { dirtyFields } = useFormState({ control: form.control });
|
||||
const editMutation = useEditAccessGroup();
|
||||
|
|
@ -66,7 +68,7 @@ function AccessGroupEditForm({ accessGroup, onCancel, onSuccess }: Omit<AccessGr
|
|||
{ accessGroupId: accessGroup.access_group_id, params },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success("Access group updated successfully");
|
||||
toast.success(t("accessGroups.edit.success"));
|
||||
onSuccess?.();
|
||||
onCancel();
|
||||
},
|
||||
|
|
@ -87,10 +89,10 @@ function AccessGroupEditForm({ accessGroup, onCancel, onSuccess }: Omit<AccessGr
|
|||
|
||||
<div className="mt-6 flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={onCancel} disabled={editMutation.isPending}>
|
||||
Cancel
|
||||
{t("accessGroups.edit.cancel")}
|
||||
</Button>
|
||||
<Button type="button" onClick={() => void handleOk()} disabled={editMutation.isPending}>
|
||||
Save Changes
|
||||
{t("accessGroups.edit.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
|
@ -98,11 +100,12 @@ function AccessGroupEditForm({ accessGroup, onCancel, onSuccess }: Omit<AccessGr
|
|||
}
|
||||
|
||||
export function AccessGroupEditModal({ visible, accessGroup, onCancel, onSuccess }: AccessGroupEditModalProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Dialog open={visible} onOpenChange={(open) => !open && onCancel()}>
|
||||
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[700px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Access Group</DialogTitle>
|
||||
<DialogTitle>{t("accessGroups.edit.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<AccessGroupEditForm
|
||||
key={accessGroup.access_group_id}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { AccessGroupResponse, useAccessGroups } from "@/app/(dashboard)/hooks/ac
|
|||
import { useDeleteAccessGroup } from "@/app/(dashboard)/hooks/accessGroups/useDeleteAccessGroup";
|
||||
import { Boxes, Plus, SearchIcon, X } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import DeleteResourceModal from "@/components/common_components/DeleteResourceModal";
|
||||
import { PageHeader, PageHeaderControls, PageHeaderDescription, PageHeaderTitle } from "@/components/shared/PageHeader";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -33,6 +34,7 @@ function mapResponseToAccessGroup(r: AccessGroupResponse): AccessGroup {
|
|||
}
|
||||
|
||||
export function AccessGroupsPage() {
|
||||
const { t } = useTranslation();
|
||||
const { userRole } = useAuthorized();
|
||||
// Admin Viewer follows the read-parity rule: see access groups, no writes.
|
||||
const canModify = isProxyAdminRole(userRole ?? "");
|
||||
|
|
@ -65,14 +67,14 @@ export function AccessGroupsPage() {
|
|||
<PageHeader>
|
||||
<PageHeaderTitle>
|
||||
<Boxes />
|
||||
Access Groups
|
||||
{t("accessGroups.page.title")}
|
||||
</PageHeaderTitle>
|
||||
<PageHeaderDescription>Manage resource permissions for your organization</PageHeaderDescription>
|
||||
<PageHeaderDescription>{t("accessGroups.page.description")}</PageHeaderDescription>
|
||||
{canModify && (
|
||||
<PageHeaderControls>
|
||||
<Button onClick={() => setIsCreateModalVisible(true)}>
|
||||
<Plus className="size-4" />
|
||||
Create Access Group
|
||||
{t("accessGroups.page.create")}
|
||||
</Button>
|
||||
</PageHeaderControls>
|
||||
)}
|
||||
|
|
@ -85,13 +87,17 @@ export function AccessGroupsPage() {
|
|||
<SearchIcon className="size-4 text-muted-foreground" />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
placeholder="Search groups by name, ID, or description..."
|
||||
placeholder={t("accessGroups.page.searchPlaceholder")}
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
/>
|
||||
{searchText && (
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupButton size="icon-xs" aria-label="Clear search" onClick={() => setSearchText("")}>
|
||||
<InputGroupButton
|
||||
size="icon-xs"
|
||||
aria-label={t("accessGroups.page.clearSearch")}
|
||||
onClick={() => setSearchText("")}
|
||||
>
|
||||
<X />
|
||||
</InputGroupButton>
|
||||
</InputGroupAddon>
|
||||
|
|
@ -113,13 +119,13 @@ export function AccessGroupsPage() {
|
|||
|
||||
<DeleteResourceModal
|
||||
isOpen={!!groupToDelete}
|
||||
title="Delete Access Group"
|
||||
message="Are you sure you want to delete this access group? This action cannot be undone."
|
||||
resourceInformationTitle="Access Group Information"
|
||||
title={t("accessGroups.page.deleteModal.title")}
|
||||
message={t("accessGroups.page.deleteModal.message")}
|
||||
resourceInformationTitle={t("accessGroups.page.deleteModal.informationTitle")}
|
||||
resourceInformation={[
|
||||
{ label: "ID", value: groupToDelete?.id, code: true },
|
||||
{ label: "Name", value: groupToDelete?.name },
|
||||
{ label: "Description", value: groupToDelete?.description || "—" },
|
||||
{ label: t("accessGroups.page.deleteModal.id"), value: groupToDelete?.id, code: true },
|
||||
{ label: t("accessGroups.page.deleteModal.name"), value: groupToDelete?.name },
|
||||
{ label: t("accessGroups.page.deleteModal.description"), value: groupToDelete?.description || "—" },
|
||||
]}
|
||||
onCancel={() => setGroupToDelete(null)}
|
||||
onOk={() => {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { SortingState } from "@tanstack/react-table";
|
||||
import { Layers } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { DataTable } from "@/components/shared/DataTable";
|
||||
|
||||
|
|
@ -21,18 +22,17 @@ interface AccessGroupsTableProps {
|
|||
const PAGE_SIZE_OPTIONS = [10, 25, 50];
|
||||
|
||||
function EmptyState({ isFiltered }: { isFiltered: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1 py-6">
|
||||
<div className="mb-1 flex size-10 items-center justify-center rounded-lg bg-muted">
|
||||
<Layers className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{isFiltered ? "No matching access groups" : "No access groups yet"}
|
||||
{isFiltered ? t("accessGroups.table.empty.filteredTitle") : t("accessGroups.table.empty.title")}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{isFiltered
|
||||
? "Try a different search term."
|
||||
: "Create an access group to manage resource permissions for your organization."}
|
||||
{isFiltered ? t("accessGroups.table.empty.filteredDescription") : t("accessGroups.table.empty.description")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -46,12 +46,13 @@ export function AccessGroupsTable({
|
|||
onGroupClick,
|
||||
onDeleteClick,
|
||||
}: AccessGroupsTableProps) {
|
||||
const { t } = useTranslation();
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
|
||||
const columns = useMemo(() => {
|
||||
const deps = { canModify, onGroupClick, onDeleteClick };
|
||||
const deps = { canModify, onGroupClick, onDeleteClick, t };
|
||||
return getAccessGroupsTableColumns(deps);
|
||||
}, [canModify, onGroupClick, onDeleteClick]);
|
||||
}, [canModify, onGroupClick, onDeleteClick, t]);
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
|
|
@ -64,7 +65,7 @@ export function AccessGroupsTable({
|
|||
paginationMode="client"
|
||||
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
||||
isLoading={isLoading}
|
||||
loadingMessage="Loading access groups…"
|
||||
loadingMessage={t("accessGroups.table.loading")}
|
||||
noDataMessage={<EmptyState isFiltered={isFiltered} />}
|
||||
size="compact"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import type { TFunction } from "i18next";
|
||||
import { Bot, Layers, MoreHorizontal, Server, Sparkles, Trash2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { DataTableSortHeader } from "@/components/shared/DataTable";
|
||||
import { DateCell, IdentityCell } from "@/components/shared/table_cells";
|
||||
|
|
@ -37,12 +39,13 @@ const RESOURCE_TONES: Record<"models" | "mcpServers" | "agents" | "skills", Reso
|
|||
};
|
||||
|
||||
function ResourcesCell({ group }: { group: AccessGroup }) {
|
||||
const { t } = useTranslation();
|
||||
const items = [
|
||||
{ key: "models" as const, label: "Models", count: group.modelIds.length },
|
||||
{ key: "mcpServers" as const, label: "MCP Servers", count: group.mcpServerIds.length },
|
||||
{ key: "agents" as const, label: "Agents", count: group.agentIds.length },
|
||||
{ key: "skills" as const, label: "Skills", count: group.skillNames.length },
|
||||
];
|
||||
{ key: "models" as const, label: "accessGroups.table.resources.models", count: group.modelIds.length },
|
||||
{ key: "mcpServers" as const, label: "accessGroups.table.resources.mcpServers", count: group.mcpServerIds.length },
|
||||
{ key: "agents" as const, label: "accessGroups.table.resources.agents", count: group.agentIds.length },
|
||||
{ key: "skills" as const, label: "accessGroups.table.resources.skills", count: group.skillNames.length },
|
||||
] as const;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
|
|
@ -52,7 +55,7 @@ function ResourcesCell({ group }: { group: AccessGroup }) {
|
|||
return (
|
||||
<span
|
||||
key={item.key}
|
||||
title={`${item.count} ${item.label}`}
|
||||
title={t(item.label, { count: item.count })}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium ring-1 ring-inset [&_svg]:size-3.5",
|
||||
tone.className,
|
||||
|
|
@ -74,10 +77,11 @@ function AccessGroupRowActions({
|
|||
group: AccessGroup;
|
||||
onDeleteClick: (group: AccessGroup) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
aria-label="Open access group actions"
|
||||
aria-label={t("accessGroups.table.rowActions.open")}
|
||||
data-testid={`access-group-actions-${group.id}`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }), "text-muted-foreground")}
|
||||
>
|
||||
|
|
@ -90,7 +94,7 @@ function AccessGroupRowActions({
|
|||
onClick={() => onDeleteClick(group)}
|
||||
>
|
||||
<Trash2 />
|
||||
Delete access group
|
||||
{t("accessGroups.table.rowActions.delete")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
|
@ -101,19 +105,21 @@ interface AccessGroupsTableColumnsDeps {
|
|||
canModify: boolean;
|
||||
onGroupClick: (id: string) => void;
|
||||
onDeleteClick: (group: AccessGroup) => void;
|
||||
t: TFunction;
|
||||
}
|
||||
|
||||
export const getAccessGroupsTableColumns = ({
|
||||
canModify,
|
||||
onGroupClick,
|
||||
onDeleteClick,
|
||||
t,
|
||||
}: AccessGroupsTableColumnsDeps): ColumnDef<AccessGroup>[] => {
|
||||
const columns: ColumnDef<AccessGroup>[] = [
|
||||
{
|
||||
id: "id",
|
||||
accessorKey: "id",
|
||||
meta: { title: "ID" },
|
||||
header: "ID",
|
||||
meta: { title: t("accessGroups.table.columns.id") },
|
||||
header: t("accessGroups.table.columns.id"),
|
||||
size: 200,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
|
|
@ -127,8 +133,8 @@ export const getAccessGroupsTableColumns = ({
|
|||
{
|
||||
id: "name",
|
||||
accessorKey: "name",
|
||||
meta: { title: "Name" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Name" />,
|
||||
meta: { title: t("accessGroups.table.columns.name") },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title={t("accessGroups.table.columns.name")} />,
|
||||
size: 220,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => {
|
||||
|
|
@ -142,8 +148,8 @@ export const getAccessGroupsTableColumns = ({
|
|||
},
|
||||
{
|
||||
id: "resources",
|
||||
meta: { title: "Resources" },
|
||||
header: "Resources",
|
||||
meta: { title: t("accessGroups.table.columns.resources") },
|
||||
header: t("accessGroups.table.columns.resources"),
|
||||
size: 220,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <ResourcesCell group={row.original} />,
|
||||
|
|
@ -151,8 +157,8 @@ export const getAccessGroupsTableColumns = ({
|
|||
{
|
||||
id: "createdAt",
|
||||
accessorKey: "createdAt",
|
||||
meta: { title: "Created" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Created" />,
|
||||
meta: { title: t("accessGroups.table.columns.created") },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title={t("accessGroups.table.columns.created")} />,
|
||||
size: 150,
|
||||
enableSorting: true,
|
||||
sortingFn: "datetime",
|
||||
|
|
@ -161,8 +167,8 @@ export const getAccessGroupsTableColumns = ({
|
|||
{
|
||||
id: "updatedAt",
|
||||
accessorKey: "updatedAt",
|
||||
meta: { title: "Updated" },
|
||||
header: "Updated",
|
||||
meta: { title: t("accessGroups.table.columns.updated") },
|
||||
header: t("accessGroups.table.columns.updated"),
|
||||
size: 150,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <DateCell value={row.original.updatedAt} precision="date" />,
|
||||
|
|
@ -178,7 +184,7 @@ export const getAccessGroupsTableColumns = ({
|
|||
{
|
||||
id: "actions",
|
||||
meta: { className: "text-right", headerClassName: "text-right" },
|
||||
header: () => <span className="sr-only">Actions</span>,
|
||||
header: () => <span className="sr-only">{t("accessGroups.table.columns.actions")}</span>,
|
||||
size: 64,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { BotIcon, InfoIcon, LayersIcon, ServerIcon, SparklesIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { accessGroupKeys } from "@/app/(dashboard)/hooks/accessGroups/useAccessGroups";
|
||||
import { useAgents } from "@/app/(dashboard)/hooks/agents/useAgents";
|
||||
|
|
@ -44,6 +45,7 @@ export const AccessGroupCreateDialog = ({
|
|||
onOpenChange,
|
||||
createAccessGroup = defaultCreateAccessGroup,
|
||||
}: AccessGroupCreateDialogProps) => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const form = useZodForm(accessGroupCreateSchema, { defaultValues: emptyAccessGroupFormValues });
|
||||
const [activeTab, setActiveTab] = React.useState(GENERAL_TAB);
|
||||
|
|
@ -70,12 +72,12 @@ export const AccessGroupCreateDialog = ({
|
|||
const mutation = useMutation({
|
||||
mutationFn: (body: AccessGroupCreateBody) => createAccessGroup(body),
|
||||
onSuccess: () => {
|
||||
toast.success("Access group created successfully");
|
||||
toast.success(t("accessGroups.create.success"));
|
||||
queryClient.invalidateQueries({ queryKey: accessGroupKeys.all });
|
||||
closeAndReset();
|
||||
},
|
||||
onError: (error: unknown) =>
|
||||
toast.fromError(error instanceof Error ? error.message : "Failed to create access group"),
|
||||
toast.fromError(error instanceof Error ? error.message : t("accessGroups.create.failed")),
|
||||
});
|
||||
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
|
|
@ -100,7 +102,7 @@ export const AccessGroupCreateDialog = ({
|
|||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Access Group</DialogTitle>
|
||||
<DialogTitle>{t("accessGroups.create.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={onSubmit} noValidate>
|
||||
|
|
@ -108,38 +110,40 @@ export const AccessGroupCreateDialog = ({
|
|||
<TabsList className="w-full">
|
||||
<TabsTrigger value={GENERAL_TAB}>
|
||||
<InfoIcon />
|
||||
General Info
|
||||
{t("accessGroups.form.tabs.general")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="models">
|
||||
<LayersIcon />
|
||||
Models
|
||||
{t("accessGroups.form.tabs.models")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="mcp-servers">
|
||||
<ServerIcon />
|
||||
MCP Servers
|
||||
{t("accessGroups.form.tabs.mcpServers")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="agents">
|
||||
<BotIcon />
|
||||
Agents
|
||||
{t("accessGroups.form.tabs.agents")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="skills">
|
||||
<SparklesIcon />
|
||||
Skills
|
||||
{t("accessGroups.form.tabs.skills")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value={GENERAL_TAB} className="pt-4">
|
||||
<FieldGroup>
|
||||
<FormField control={form.control} name="name" label="Group Name">
|
||||
{({ ref, ...field }) => <Input {...field} ref={ref} placeholder="e.g. Engineering Team" />}
|
||||
<FormField control={form.control} name="name" label={t("accessGroups.form.name.label")}>
|
||||
{({ ref, ...field }) => (
|
||||
<Input {...field} ref={ref} placeholder={t("accessGroups.form.name.placeholder")} />
|
||||
)}
|
||||
</FormField>
|
||||
<FormField control={form.control} name="description" label="Description">
|
||||
<FormField control={form.control} name="description" label={t("accessGroups.form.description.label")}>
|
||||
{({ ref, ...field }) => (
|
||||
<Textarea
|
||||
{...field}
|
||||
ref={ref}
|
||||
rows={4}
|
||||
placeholder="Describe the purpose of this access group..."
|
||||
placeholder={t("accessGroups.form.description.placeholder")}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
|
|
@ -147,20 +151,20 @@ export const AccessGroupCreateDialog = ({
|
|||
</TabsContent>
|
||||
|
||||
<TabsContent value="models" className="pt-4">
|
||||
<FormField control={form.control} name="modelIds" label="Allowed Models">
|
||||
<FormField control={form.control} name="modelIds" label={t("accessGroups.form.models.label")}>
|
||||
{(field) => <ModelSelect context="global" value={field.value} onChange={field.onChange} />}
|
||||
</FormField>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="mcp-servers" className="pt-4">
|
||||
<FormField control={form.control} name="mcpServerIds" label="Allowed MCP Servers">
|
||||
<FormField control={form.control} name="mcpServerIds" label={t("accessGroups.form.mcpServers.label")}>
|
||||
{({ id, value, onChange }) => (
|
||||
<MultiSelect
|
||||
id={id}
|
||||
value={value}
|
||||
onValueChange={onChange}
|
||||
options={mcpServerOptions}
|
||||
placeholder="Select MCP servers"
|
||||
placeholder={t("accessGroups.form.mcpServers.placeholder")}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
|
|
@ -172,14 +176,14 @@ export const AccessGroupCreateDialog = ({
|
|||
</TabsContent>
|
||||
|
||||
<TabsContent value="agents" className="pt-4">
|
||||
<FormField control={form.control} name="agentIds" label="Allowed Agents">
|
||||
<FormField control={form.control} name="agentIds" label={t("accessGroups.form.agents.label")}>
|
||||
{({ id, value, onChange }) => (
|
||||
<MultiSelect
|
||||
id={id}
|
||||
value={value}
|
||||
onValueChange={onChange}
|
||||
options={agentOptions}
|
||||
placeholder="Select agents"
|
||||
placeholder={t("accessGroups.form.agents.placeholder")}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
|
|
@ -189,15 +193,15 @@ export const AccessGroupCreateDialog = ({
|
|||
<FormField
|
||||
control={form.control}
|
||||
name="skillNames"
|
||||
label="Allowed Skills"
|
||||
description="Enabled skills are visible to everyone. Grant disabled (private) Claude Code plugins to the teams and keys in this group here."
|
||||
label={t("accessGroups.form.skills.label")}
|
||||
description={t("accessGroups.form.skills.description")}
|
||||
>
|
||||
{({ value, onChange }) => (
|
||||
<SkillSelector
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
accessToken={accessToken || ""}
|
||||
placeholder="Select skills (optional)"
|
||||
placeholder={t("accessGroups.form.skills.placeholder")}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
|
|
@ -211,10 +215,10 @@ export const AccessGroupCreateDialog = ({
|
|||
onClick={() => handleOpenChange(false)}
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
Cancel
|
||||
{t("accessGroups.create.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? "Creating..." : "Create Group"}
|
||||
{mutation.isPending ? t("accessGroups.create.submitting") : t("accessGroups.create.submit")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,9 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
|
|||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Info, TriangleAlert } from "lucide-react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { useBaseUrl } from "@/components/constants";
|
||||
import i18n from "@/i18n/i18n";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { addAllowedIP, deleteAllowedIP, getAllowedIPs, getSSOSettings } from "@/components/networking";
|
||||
import SCIMConfig from "@/components/SCIM";
|
||||
|
|
@ -38,22 +40,23 @@ import { useZodForm } from "@/lib/forms/useZodForm";
|
|||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
|
||||
const allowedIPSchema = z.object({
|
||||
ip: z.string().min(1, "Please enter an IP address"),
|
||||
ip: z.string().min(1, { error: () => i18n.t("adminPanel.page.addIp.required") }),
|
||||
});
|
||||
|
||||
type AllowedIPFormValues = z.infer<typeof allowedIPSchema>;
|
||||
|
||||
const AddAllowedIPForm = ({ onSubmit }: { onSubmit: (values: AllowedIPFormValues) => Promise<void> }) => {
|
||||
const { t } = useTranslation();
|
||||
const form = useZodForm(allowedIPSchema, { defaultValues: { ip: "" } });
|
||||
|
||||
return (
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<FieldGroup>
|
||||
<FormField control={form.control} name="ip">
|
||||
{({ ref, ...field }) => <Input ref={ref} placeholder="Enter IP address" {...field} />}
|
||||
{({ ref, ...field }) => <Input ref={ref} placeholder={t("adminPanel.page.addIp.placeholder")} {...field} />}
|
||||
</FormField>
|
||||
<div>
|
||||
<Button type="submit">Add IP Address</Button>
|
||||
<Button type="submit">{t("adminPanel.page.addIp.submit")}</Button>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
|
|
@ -65,6 +68,7 @@ interface AdminPanelProps {
|
|||
}
|
||||
|
||||
const AdminPanel: React.FC<AdminPanelProps> = ({ proxySettings }) => {
|
||||
const { t } = useTranslation();
|
||||
const { premiumUser, accessToken, userId: userID } = useAuthorized();
|
||||
const form = useSSOSettingsForm("admin-panel");
|
||||
const [isAddSSOModalVisible, setIsAddSSOModalVisible] = useState(false);
|
||||
|
|
@ -107,7 +111,7 @@ const AdminPanel: React.FC<AdminPanelProps> = ({ proxySettings }) => {
|
|||
const handleShowAllowedIPs = async () => {
|
||||
try {
|
||||
if (premiumUser !== true) {
|
||||
toast.fromError("This feature is only available for premium users. Please upgrade your account.");
|
||||
toast.fromError(t("adminPanel.page.toasts.premiumRequired"));
|
||||
return;
|
||||
}
|
||||
if (accessToken) {
|
||||
|
|
@ -118,7 +122,7 @@ const AdminPanel: React.FC<AdminPanelProps> = ({ proxySettings }) => {
|
|||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching allowed IPs:", error);
|
||||
toast.fromError(`Failed to fetch allowed IPs ${error}`);
|
||||
toast.fromError(t("adminPanel.page.toasts.fetchIpsFailed", { error: String(error) }));
|
||||
setAllowedIPs([all_ip_address_allowed]);
|
||||
} finally {
|
||||
if (premiumUser === true) {
|
||||
|
|
@ -134,11 +138,11 @@ const AdminPanel: React.FC<AdminPanelProps> = ({ proxySettings }) => {
|
|||
// Fetch the updated list of IPs
|
||||
const updatedIPs = await getAllowedIPs(accessToken);
|
||||
setAllowedIPs(updatedIPs);
|
||||
toast.success("IP address added successfully");
|
||||
toast.success(t("adminPanel.page.toasts.addIpSuccess"));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error adding IP:", error);
|
||||
toast.fromError(`Failed to add IP address ${error}`);
|
||||
toast.fromError(t("adminPanel.page.toasts.addIpFailed", { error: String(error) }));
|
||||
} finally {
|
||||
setIsAddIPModalVisible(false);
|
||||
}
|
||||
|
|
@ -156,10 +160,10 @@ const AdminPanel: React.FC<AdminPanelProps> = ({ proxySettings }) => {
|
|||
// Fetch the updated list of IPs
|
||||
const updatedIPs = await getAllowedIPs(accessToken);
|
||||
setAllowedIPs(updatedIPs.length > 0 ? updatedIPs : [all_ip_address_allowed]);
|
||||
toast.success("IP address deleted successfully");
|
||||
toast.success(t("adminPanel.page.toasts.deleteIpSuccess"));
|
||||
} catch (error) {
|
||||
console.error("Error deleting IP:", error);
|
||||
toast.fromError(`Failed to delete IP address ${error}`);
|
||||
toast.fromError(t("adminPanel.page.toasts.deleteIpFailed", { error: String(error) }));
|
||||
} finally {
|
||||
setIsDeleteIPModalVisible(false);
|
||||
setIPToDelete(null);
|
||||
|
|
@ -214,23 +218,20 @@ const AdminPanel: React.FC<AdminPanelProps> = ({ proxySettings }) => {
|
|||
const tabItems = [
|
||||
{
|
||||
key: "sso-settings",
|
||||
label: "SSO Settings",
|
||||
label: t("adminPanel.page.tabs.ssoSettings"),
|
||||
children: <SSOSettings />,
|
||||
},
|
||||
{
|
||||
key: "security-settings",
|
||||
label: "Security Settings",
|
||||
label: t("adminPanel.page.tabs.securitySettings"),
|
||||
children: (
|
||||
<>
|
||||
<Card className="block p-6">
|
||||
<h3 className="mb-2 text-base font-semibold text-foreground">✨ Security Settings</h3>
|
||||
<h3 className="mb-2 text-base font-semibold text-foreground">{t("adminPanel.page.security.title")}</h3>
|
||||
<Alert variant="warning">
|
||||
<TriangleAlert />
|
||||
<AlertTitle>SSO Configuration Deprecated</AlertTitle>
|
||||
<AlertDescription>
|
||||
Editing SSO Settings on this page is deprecated and will be removed in a future version. Please use the
|
||||
SSO Settings tab for SSO configuration.
|
||||
</AlertDescription>
|
||||
<AlertTitle>{t("adminPanel.page.security.deprecatedTitle")}</AlertTitle>
|
||||
<AlertDescription>{t("adminPanel.page.security.deprecatedDescription")}</AlertDescription>
|
||||
</Alert>
|
||||
<div
|
||||
style={{
|
||||
|
|
@ -243,12 +244,12 @@ const AdminPanel: React.FC<AdminPanelProps> = ({ proxySettings }) => {
|
|||
>
|
||||
<div>
|
||||
<Button style={{ width: "150px" }} onClick={() => setIsAddSSOModalVisible(true)}>
|
||||
{ssoConfigured ? "Edit SSO Settings" : "Add SSO"}
|
||||
{ssoConfigured ? t("adminPanel.page.security.editSso") : t("adminPanel.page.security.addSso")}
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<Button style={{ width: "150px" }} onClick={handleShowAllowedIPs}>
|
||||
Allowed IPs
|
||||
{t("adminPanel.page.security.allowedIps")}
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
|
|
@ -257,10 +258,10 @@ const AdminPanel: React.FC<AdminPanelProps> = ({ proxySettings }) => {
|
|||
onClick={() =>
|
||||
premiumUser === true
|
||||
? setIsUIAccessControlModalVisible(true)
|
||||
: toast.fromError("Only premium users can configure UI access control")
|
||||
: toast.fromError(t("adminPanel.page.toasts.uiAccessControlPremiumRequired"))
|
||||
}
|
||||
>
|
||||
UI Access Control
|
||||
{t("adminPanel.page.security.uiAccessControl")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -282,23 +283,25 @@ const AdminPanel: React.FC<AdminPanelProps> = ({ proxySettings }) => {
|
|||
<Dialog open={isAllowedIPModalVisible} onOpenChange={(open) => !open && setIsAllowedIPModalVisible(false)}>
|
||||
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[800px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Manage Allowed IP Addresses</DialogTitle>
|
||||
<DialogTitle>{t("adminPanel.page.allowedIps.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>IP Address</TableHead>
|
||||
<TableHead className="text-right">Action</TableHead>
|
||||
<TableHead>{t("adminPanel.page.allowedIps.columns.ipAddress")}</TableHead>
|
||||
<TableHead className="text-right">{t("adminPanel.page.allowedIps.columns.action")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{allowedIPs.map((ip, index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell>{ip}</TableCell>
|
||||
<TableCell>
|
||||
{ip === all_ip_address_allowed ? t("adminPanel.page.allowedIps.allAllowed") : ip}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{ip !== all_ip_address_allowed && (
|
||||
<Button onClick={() => handleDeleteIP(ip)} variant="destructive" size="sm">
|
||||
Delete
|
||||
{t("adminPanel.page.allowedIps.delete")}
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
|
|
@ -308,9 +311,11 @@ const AdminPanel: React.FC<AdminPanelProps> = ({ proxySettings }) => {
|
|||
</Table>
|
||||
<DialogFooter>
|
||||
<Button className="mx-1" onClick={() => setIsAddIPModalVisible(true)}>
|
||||
Add IP Address
|
||||
{t("adminPanel.page.allowedIps.addIp")}
|
||||
</Button>
|
||||
<Button onClick={() => setIsAllowedIPModalVisible(false)}>
|
||||
{t("adminPanel.page.allowedIps.close")}
|
||||
</Button>
|
||||
<Button onClick={() => setIsAllowedIPModalVisible(false)}>Close</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
|
@ -318,7 +323,7 @@ const AdminPanel: React.FC<AdminPanelProps> = ({ proxySettings }) => {
|
|||
<Dialog open={isAddIPModalVisible} onOpenChange={(open) => !open && setIsAddIPModalVisible(false)}>
|
||||
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Allowed IP Address</DialogTitle>
|
||||
<DialogTitle>{t("adminPanel.page.addIp.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<AddAllowedIPForm onSubmit={handleAddIP} />
|
||||
</DialogContent>
|
||||
|
|
@ -327,16 +332,18 @@ const AdminPanel: React.FC<AdminPanelProps> = ({ proxySettings }) => {
|
|||
<Dialog open={isDeleteIPModalVisible} onOpenChange={(open) => !open && setIsDeleteIPModalVisible(false)}>
|
||||
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Confirm Delete</DialogTitle>
|
||||
<DialogTitle>{t("adminPanel.page.deleteIp.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<span className="text-sm text-foreground">
|
||||
Are you sure you want to delete the IP address: {ipToDelete}?
|
||||
<Trans i18nKey="adminPanel.page.deleteIp.message" components={{ ip: <>{ipToDelete}</> }} />
|
||||
</span>
|
||||
<DialogFooter>
|
||||
<Button className="mx-1" onClick={() => confirmDeleteIP()}>
|
||||
Yes
|
||||
{t("adminPanel.page.deleteIp.confirm")}
|
||||
</Button>
|
||||
<Button onClick={() => setIsDeleteIPModalVisible(false)}>
|
||||
{t("adminPanel.page.deleteIp.close")}
|
||||
</Button>
|
||||
<Button onClick={() => setIsDeleteIPModalVisible(false)}>Close</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
|
@ -348,13 +355,13 @@ const AdminPanel: React.FC<AdminPanelProps> = ({ proxySettings }) => {
|
|||
>
|
||||
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>UI Access Control Settings</DialogTitle>
|
||||
<DialogTitle>{t("adminPanel.page.uiAccessControlModal.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<UIAccessControlForm
|
||||
accessToken={accessToken}
|
||||
onSuccess={() => {
|
||||
handleUIAccessControlOk();
|
||||
toast.success("UI Access Control settings updated successfully");
|
||||
toast.success(t("adminPanel.page.toasts.uiAccessControlUpdated"));
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
|
|
@ -362,12 +369,18 @@ const AdminPanel: React.FC<AdminPanelProps> = ({ proxySettings }) => {
|
|||
</div>
|
||||
<Alert variant="info">
|
||||
<Info />
|
||||
<AlertTitle>Login without SSO</AlertTitle>
|
||||
<AlertTitle>{t("adminPanel.page.security.loginWithoutSsoTitle")}</AlertTitle>
|
||||
<AlertDescription>
|
||||
If you need to login without sso, you can access{" "}
|
||||
<a href={nonSssoUrl} target="_blank" rel="noopener noreferrer">
|
||||
<b>{nonSssoUrl}</b>{" "}
|
||||
</a>
|
||||
<Trans
|
||||
i18nKey="adminPanel.page.security.loginWithoutSso"
|
||||
components={{
|
||||
url: (
|
||||
<a href={nonSssoUrl} target="_blank" rel="noopener noreferrer">
|
||||
<b>{nonSssoUrl}</b>{" "}
|
||||
</a>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</>
|
||||
|
|
@ -380,7 +393,7 @@ const AdminPanel: React.FC<AdminPanelProps> = ({ proxySettings }) => {
|
|||
},
|
||||
{
|
||||
key: "ui-settings",
|
||||
label: "UI Settings",
|
||||
label: t("adminPanel.page.tabs.uiSettings"),
|
||||
children: (
|
||||
<div className="flex flex-col gap-4">
|
||||
<UISettings />
|
||||
|
|
@ -391,7 +404,7 @@ const AdminPanel: React.FC<AdminPanelProps> = ({ proxySettings }) => {
|
|||
},
|
||||
{
|
||||
key: "logging-settings",
|
||||
label: "Logging Settings",
|
||||
label: t("adminPanel.page.tabs.loggingSettings"),
|
||||
children: <LoggingSettings />,
|
||||
},
|
||||
{
|
||||
|
|
@ -406,20 +419,20 @@ const AdminPanel: React.FC<AdminPanelProps> = ({ proxySettings }) => {
|
|||
},
|
||||
{
|
||||
key: "plugins",
|
||||
label: "Plugins",
|
||||
label: t("adminPanel.page.tabs.plugins"),
|
||||
children: <PluginSettings />,
|
||||
},
|
||||
{
|
||||
key: "web-search-interception",
|
||||
label: "Web Search Interception",
|
||||
label: t("adminPanel.page.tabs.webSearchInterception"),
|
||||
children: <WebSearchInterceptionSettings />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="w-full m-2 mt-2 p-8">
|
||||
<h2 className="mb-2 text-base font-semibold text-foreground">Admin Access</h2>
|
||||
<p className="mb-4 text-sm text-foreground">Go to 'Internal Users' page to add other admins.</p>
|
||||
<h2 className="mb-2 text-base font-semibold text-foreground">{t("adminPanel.page.title")}</h2>
|
||||
<p className="mb-4 text-sm text-foreground">{t("adminPanel.page.subtitle")}</p>
|
||||
<Tabs defaultValue={tabItems[0].key}>
|
||||
<TabsList variant="line" className="mb-4 h-auto flex-wrap">
|
||||
{tabItems.map((item) => (
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import React from "react";
|
||||
import { X } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { BarChart } from "@/components/shared/charts";
|
||||
import type { ChartTooltipProps } from "@/components/shared/charts/chart_tooltip";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -40,6 +41,7 @@ export const groupErrorBuckets = (buckets: readonly CacheActivityErrorBucket[],
|
|||
};
|
||||
|
||||
export const ErrorCodeTooltip = ({ active, payload, label }: ChartTooltipProps) => {
|
||||
const { t } = useTranslation();
|
||||
if (!active || !payload || payload.length === 0) return null;
|
||||
const datum = payload[0]?.payload as ErrorCodeDatum | undefined;
|
||||
if (!datum) return null;
|
||||
|
|
@ -47,7 +49,10 @@ export const ErrorCodeTooltip = ({ active, payload, label }: ChartTooltipProps)
|
|||
return (
|
||||
<div className="min-w-40 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl">
|
||||
<p className="mb-1.5 font-medium text-foreground">
|
||||
Error code {String(label)}: {datum[FAILED_REQUESTS_SERIES].toLocaleString()} failed
|
||||
{t("caching.errorDrilldown.tooltipTitle", {
|
||||
code: String(label),
|
||||
total: datum[FAILED_REQUESTS_SERIES].toLocaleString(),
|
||||
})}
|
||||
</p>
|
||||
<div className="grid gap-1.5">
|
||||
{datum.classes.map((errorClass) => (
|
||||
|
|
@ -70,27 +75,33 @@ interface ErrorDrilldownCardProps {
|
|||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const ErrorDrilldownCard = ({ callType, buckets, valueFormatter, onClose }: ErrorDrilldownCardProps) => (
|
||||
<Card className="mt-4">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-base font-semibold">Failed requests by error code: {callType}</CardTitle>
|
||||
<Button variant="outline" size="icon-sm" onClick={onClose} aria-label="Close error breakdown">
|
||||
<X />
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">Hover a bar to see the error classes behind that code.</p>
|
||||
<BarChart
|
||||
data={groupErrorBuckets(buckets, callType)}
|
||||
index="error_code"
|
||||
categories={[FAILED_REQUESTS_SERIES]}
|
||||
colors={["red"]}
|
||||
valueFormatter={valueFormatter}
|
||||
showLegend={false}
|
||||
customTooltip={ErrorCodeTooltip}
|
||||
yAxisWidth={48}
|
||||
className="mt-2"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
export const ErrorDrilldownCard = ({ callType, buckets, valueFormatter, onClose }: ErrorDrilldownCardProps) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Card className="mt-4">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-base font-semibold">
|
||||
{t("caching.errorDrilldown.title")}
|
||||
{callType}
|
||||
</CardTitle>
|
||||
<Button variant="outline" size="icon-sm" onClick={onClose} aria-label={t("caching.errorDrilldown.close")}>
|
||||
<X />
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">{t("caching.errorDrilldown.hint")}</p>
|
||||
<BarChart
|
||||
data={groupErrorBuckets(buckets, callType)}
|
||||
index="error_code"
|
||||
categories={[FAILED_REQUESTS_SERIES]}
|
||||
colors={["red"]}
|
||||
valueFormatter={valueFormatter}
|
||||
showLegend={false}
|
||||
customTooltip={ErrorCodeTooltip}
|
||||
yAxisWidth={48}
|
||||
className="mt-2"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import type { DateRangePickerValue } from "@/components/shared/date_picker_types";
|
||||
import type { TFunction } from "i18next";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { toast } from "@/lib/toast";
|
||||
import AdvancedDatePicker from "@/components/shared/advanced_date_picker";
|
||||
import { BarChart } from "@/components/shared/charts";
|
||||
|
|
@ -32,23 +34,22 @@ import CoordinationRedisSettings from "./coordination_redis_settings";
|
|||
import { ErrorDrilldownCard } from "./ErrorDrilldown";
|
||||
|
||||
const REQUEST_SERIES = {
|
||||
apiRequests: "LLM API requests",
|
||||
cacheHits: "Cache hit",
|
||||
failed: "Failed requests",
|
||||
apiRequests: "caching.dashboard.series.apiRequests",
|
||||
cacheHits: "caching.dashboard.series.cacheHits",
|
||||
failed: "caching.dashboard.series.failed",
|
||||
} as const;
|
||||
|
||||
const UNKNOWN_CALL_TYPE = "Unknown";
|
||||
|
||||
const UNKNOWN_CALL_TYPE_NOTE =
|
||||
"Unknown groups spend logs that recorded no endpoint. Older proxy versions wrote those for requests rejected before routing, so they are not necessarily LLM API requests.";
|
||||
const UNKNOWN_CALL_TYPE_NOTE = "caching.dashboard.requestsChart.unknownCallTypeNote";
|
||||
|
||||
const toChartDatum = (group: CacheActivityGroup) => ({
|
||||
const toChartDatum = (group: CacheActivityGroup, t: TFunction) => ({
|
||||
name: group.call_type,
|
||||
[REQUEST_SERIES.apiRequests]: group.api_requests,
|
||||
[REQUEST_SERIES.cacheHits]: group.cache_hits,
|
||||
[REQUEST_SERIES.failed]: group.failed_requests,
|
||||
"Cached Completion Tokens": group.cached_completion_tokens,
|
||||
"Generated Completion Tokens": group.generated_completion_tokens,
|
||||
[t(REQUEST_SERIES.apiRequests)]: group.api_requests,
|
||||
[t(REQUEST_SERIES.cacheHits)]: group.cache_hits,
|
||||
[t(REQUEST_SERIES.failed)]: group.failed_requests,
|
||||
[t("caching.dashboard.series.cachedCompletionTokens")]: group.cached_completion_tokens,
|
||||
[t("caching.dashboard.series.generatedCompletionTokens")]: group.generated_completion_tokens,
|
||||
});
|
||||
|
||||
const formatDateWithoutTZ = (date: Date | undefined) => {
|
||||
|
|
@ -82,6 +83,7 @@ interface CachePageProps {
|
|||
// Helper function to deep-parse a JSON string if possible
|
||||
|
||||
const CacheDashboard: React.FC<CachePageProps> = ({ accessToken, token, userRole, userID, premiumUser }) => {
|
||||
const { t } = useTranslation();
|
||||
const anchor1 = useComboboxAnchor();
|
||||
const anchor2 = useComboboxAnchor();
|
||||
const [selectedApiKeys, setSelectedApiKeys] = useState<string[]>([]);
|
||||
|
|
@ -114,7 +116,7 @@ const CacheDashboard: React.FC<CachePageProps> = ({ accessToken, token, userRole
|
|||
|
||||
const uniqueApiKeys = activity?.filter_options.key_aliases ?? [];
|
||||
const uniqueModels = activity?.filter_options.models ?? [];
|
||||
const chartData = (activity?.groups ?? []).map(toChartDatum);
|
||||
const chartData = (activity?.groups ?? []).map((group) => toChartDatum(group, t));
|
||||
const hasUnknownGroup = (activity?.groups ?? []).some((group) => group.call_type === UNKNOWN_CALL_TYPE);
|
||||
const activeDrilldownCallType = resolveDrilldownCallType(errorDrilldownCallType, activity?.groups ?? []);
|
||||
|
||||
|
|
@ -125,7 +127,7 @@ const CacheDashboard: React.FC<CachePageProps> = ({ accessToken, token, userRole
|
|||
|
||||
const runCachingHealthCheck = async () => {
|
||||
try {
|
||||
toast.info("Running cache health check...");
|
||||
toast.info(t("caching.dashboard.healthCheck.running"));
|
||||
setHealthCheckResponse("");
|
||||
const response = await cachingHealthCheckCall(accessToken !== null ? accessToken : "");
|
||||
setHealthCheckResponse(response);
|
||||
|
|
@ -145,7 +147,7 @@ const CacheDashboard: React.FC<CachePageProps> = ({ accessToken, token, userRole
|
|||
errorData = { message: error.message };
|
||||
}
|
||||
} else {
|
||||
errorData = { message: "Unknown error occurred" };
|
||||
errorData = { message: t("caching.dashboard.healthCheck.unknownError") };
|
||||
}
|
||||
setHealthCheckResponse({ error: errorData });
|
||||
}
|
||||
|
|
@ -154,9 +156,15 @@ const CacheDashboard: React.FC<CachePageProps> = ({ accessToken, token, userRole
|
|||
const totals = activity?.totals;
|
||||
const hasRequests = totals != null && totals.api_requests + totals.cache_hits + totals.failed_requests > 0;
|
||||
const statCards = [
|
||||
{ label: "Cache Hit Ratio", value: `${hasRequests ? totals.cache_hit_ratio.toFixed(2) : "0"}%` },
|
||||
{ label: "Cache Hits", value: valueFormatterNumbers(totals?.cache_hits ?? 0) },
|
||||
{ label: "Cached Completion Tokens", value: valueFormatterNumbers(totals?.cached_completion_tokens ?? 0) },
|
||||
{
|
||||
label: t("caching.dashboard.stats.cacheHitRatio"),
|
||||
value: `${hasRequests ? totals.cache_hit_ratio.toFixed(2) : "0"}%`,
|
||||
},
|
||||
{ label: t("caching.dashboard.stats.cacheHits"), value: valueFormatterNumbers(totals?.cache_hits ?? 0) },
|
||||
{
|
||||
label: t("caching.dashboard.stats.cachedCompletionTokens"),
|
||||
value: valueFormatterNumbers(totals?.cached_completion_tokens ?? 0),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
|
|
@ -164,22 +172,32 @@ const CacheDashboard: React.FC<CachePageProps> = ({ accessToken, token, userRole
|
|||
<div className="mt-2 flex w-full items-center justify-between border-b">
|
||||
<TabsList variant="line" className="h-auto rounded-none p-0">
|
||||
<TabsTrigger value="analytics" className="flex-none rounded-none px-4 py-2">
|
||||
Cache Analytics
|
||||
{t("caching.dashboard.tabs.analytics")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="health" className="flex-none rounded-none px-4 py-2">
|
||||
Cache Health
|
||||
{t("caching.dashboard.tabs.health")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="settings" className="flex-none rounded-none px-4 py-2">
|
||||
Cache Settings
|
||||
{t("caching.dashboard.tabs.settings")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="coordination" className="flex-none rounded-none px-4 py-2">
|
||||
Coordination Redis
|
||||
{t("caching.dashboard.tabs.coordination")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
{lastRefreshed && <p className="text-sm text-muted-foreground">Last Refreshed: {lastRefreshed}</p>}
|
||||
<Button variant="outline" size="icon-sm" onClick={handleRefreshClick} aria-label="Refresh">
|
||||
{lastRefreshed && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("caching.dashboard.lastRefreshed")}
|
||||
{lastRefreshed}
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
onClick={handleRefreshClick}
|
||||
aria-label={t("caching.dashboard.refresh")}
|
||||
>
|
||||
<RefreshCw />
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -189,26 +207,27 @@ const CacheDashboard: React.FC<CachePageProps> = ({ accessToken, token, userRole
|
|||
<Card>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Analytics for LiteLLM's{" "}
|
||||
<a
|
||||
href="https://docs.litellm.ai/docs/proxy/caching"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
response cache
|
||||
</a>{" "}
|
||||
(e.g. Redis / in-memory): requests answered from cache without calling the LLM provider. Provider-side{" "}
|
||||
<a
|
||||
href="https://docs.litellm.ai/docs/completion/prompt_caching"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
prompt caching
|
||||
</a>{" "}
|
||||
(cached input tokens from Anthropic, OpenAI, etc.) is not shown here; see "Prompt Caching
|
||||
Metrics" on the Usage page or individual requests in the Logs page.
|
||||
<Trans
|
||||
i18nKey="caching.dashboard.description"
|
||||
components={{
|
||||
cacheDocs: (
|
||||
<a
|
||||
href="https://docs.litellm.ai/docs/proxy/caching"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="underline"
|
||||
/>
|
||||
),
|
||||
promptDocs: (
|
||||
<a
|
||||
href="https://docs.litellm.ai/docs/completion/prompt_caching"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="underline"
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
|
||||
<div className="mt-4 grid grid-cols-1 items-center gap-4 md:grid-cols-[1fr_1fr_auto]">
|
||||
|
|
@ -228,10 +247,10 @@ const CacheDashboard: React.FC<CachePageProps> = ({ accessToken, token, userRole
|
|||
))
|
||||
}
|
||||
</ComboboxValue>
|
||||
<ComboboxChipsInput placeholder="Select Virtual Keys" />
|
||||
<ComboboxChipsInput placeholder={t("caching.dashboard.filters.virtualKeysPlaceholder")} />
|
||||
</ComboboxChips>
|
||||
<ComboboxContent anchor={anchor1}>
|
||||
<ComboboxEmpty>No virtual keys found</ComboboxEmpty>
|
||||
<ComboboxEmpty>{t("caching.dashboard.filters.noVirtualKeys")}</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(key: string) => (
|
||||
<ComboboxItem key={key} value={key}>
|
||||
|
|
@ -258,10 +277,10 @@ const CacheDashboard: React.FC<CachePageProps> = ({ accessToken, token, userRole
|
|||
))
|
||||
}
|
||||
</ComboboxValue>
|
||||
<ComboboxChipsInput placeholder="Select Models" />
|
||||
<ComboboxChipsInput placeholder={t("caching.dashboard.filters.modelsPlaceholder")} />
|
||||
</ComboboxChips>
|
||||
<ComboboxContent anchor={anchor2}>
|
||||
<ComboboxEmpty>No models found</ComboboxEmpty>
|
||||
<ComboboxEmpty>{t("caching.dashboard.filters.noModels")}</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(model: string) => (
|
||||
<ComboboxItem key={model} value={model}>
|
||||
|
|
@ -295,16 +314,14 @@ const CacheDashboard: React.FC<CachePageProps> = ({ accessToken, token, userRole
|
|||
|
||||
<Card className="mt-4">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base font-semibold">Cache Hits vs API Requests</CardTitle>
|
||||
<CardTitle className="text-base font-semibold">{t("caching.dashboard.requestsChart.title")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Click a red failed-requests segment to see which error codes caused those failures.
|
||||
</p>
|
||||
{hasUnknownGroup && <p className="mt-1 text-sm text-muted-foreground">{UNKNOWN_CALL_TYPE_NOTE}</p>}
|
||||
<p className="text-sm text-muted-foreground">{t("caching.dashboard.requestsChart.hint")}</p>
|
||||
{hasUnknownGroup && <p className="mt-1 text-sm text-muted-foreground">{t(UNKNOWN_CALL_TYPE_NOTE)}</p>}
|
||||
{activityFailed ? (
|
||||
<ErrorState
|
||||
title="Failed to load cache activity"
|
||||
title={t("caching.dashboard.requestsChart.loadFailed")}
|
||||
message={deriveErrorMessage(activityError)}
|
||||
onRetry={() => void refetch()}
|
||||
/>
|
||||
|
|
@ -314,12 +331,12 @@ const CacheDashboard: React.FC<CachePageProps> = ({ accessToken, token, userRole
|
|||
stack={true}
|
||||
index="name"
|
||||
valueFormatter={valueFormatterNumbers}
|
||||
categories={[REQUEST_SERIES.apiRequests, REQUEST_SERIES.cacheHits, REQUEST_SERIES.failed]}
|
||||
categories={[t(REQUEST_SERIES.apiRequests), t(REQUEST_SERIES.cacheHits), t(REQUEST_SERIES.failed)]}
|
||||
colors={["sky", "teal", "red"]}
|
||||
yAxisWidth={48}
|
||||
className="mt-2"
|
||||
onValueChange={(item) => {
|
||||
if (item.categoryClicked === REQUEST_SERIES.failed) setErrorDrilldownCallType(item.name);
|
||||
if (item.categoryClicked === t(REQUEST_SERIES.failed)) setErrorDrilldownCallType(item.name);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -337,9 +354,7 @@ const CacheDashboard: React.FC<CachePageProps> = ({ accessToken, token, userRole
|
|||
|
||||
<Card className="mt-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base font-semibold">
|
||||
Cached Completion Tokens vs Generated Completion Tokens
|
||||
</CardTitle>
|
||||
<CardTitle className="text-base font-semibold">{t("caching.dashboard.tokensChart.title")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<BarChart
|
||||
|
|
@ -347,7 +362,10 @@ const CacheDashboard: React.FC<CachePageProps> = ({ accessToken, token, userRole
|
|||
stack={true}
|
||||
index="name"
|
||||
valueFormatter={valueFormatterNumbers}
|
||||
categories={["Generated Completion Tokens", "Cached Completion Tokens"]}
|
||||
categories={[
|
||||
t("caching.dashboard.series.generatedCompletionTokens"),
|
||||
t("caching.dashboard.series.cachedCompletionTokens"),
|
||||
]}
|
||||
colors={["sky", "teal"]}
|
||||
yAxisWidth={48}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { CheckCircle2, XCircle, ClipboardCopy } from "lucide-react";
|
||||
|
|
@ -19,8 +20,9 @@ const deepParse = (input: any) => {
|
|||
|
||||
// TableClickableErrorField component with copy-to-clipboard functionality
|
||||
const TableClickableErrorField: React.FC<{ label: string; value: string | null | undefined }> = ({ label, value }) => {
|
||||
const { t } = useTranslation();
|
||||
const [isExpanded, setIsExpanded] = React.useState(false);
|
||||
const safeValue = value?.toString() || "N/A";
|
||||
const safeValue = value?.toString() || t("caching.health.notAvailable");
|
||||
const truncated = safeValue.length > 50 ? safeValue.substring(0, 50) + "..." : safeValue;
|
||||
|
||||
const handleCopy = () => {
|
||||
|
|
@ -74,6 +76,7 @@ interface ErrorDetails {
|
|||
|
||||
// Update HealthCheckDetails component to handle errors
|
||||
const HealthCheckDetails: React.FC<{ response: any }> = ({ response }) => {
|
||||
const { t } = useTranslation();
|
||||
// Initialize with safe default values
|
||||
let errorDetails: ErrorDetails | null = null;
|
||||
let parsedLitellmParams: any = {};
|
||||
|
|
@ -86,8 +89,8 @@ const HealthCheckDetails: React.FC<{ response: any }> = ({ response }) => {
|
|||
typeof response.error.message === "string" ? JSON.parse(response.error.message) : response.error.message;
|
||||
|
||||
errorDetails = {
|
||||
message: errorMessage?.message || "Unknown error",
|
||||
traceback: errorMessage?.traceback || "No traceback available",
|
||||
message: errorMessage?.message || t("caching.health.unknownError"),
|
||||
traceback: errorMessage?.traceback || t("caching.health.noTraceback"),
|
||||
litellm_params: errorMessage?.litellm_cache_params || {},
|
||||
health_check_cache_params: errorMessage?.health_check_cache_params || {},
|
||||
};
|
||||
|
|
@ -97,8 +100,8 @@ const HealthCheckDetails: React.FC<{ response: any }> = ({ response }) => {
|
|||
} catch (e) {
|
||||
console.warn("Error parsing error details:", e);
|
||||
errorDetails = {
|
||||
message: String(response.error.message || "Unknown error"),
|
||||
traceback: "Error parsing details",
|
||||
message: String(response.error.message || t("caching.health.unknownError")),
|
||||
traceback: t("caching.health.parseFailed"),
|
||||
litellm_params: {},
|
||||
health_check_cache_params: {},
|
||||
};
|
||||
|
|
@ -121,16 +124,16 @@ const HealthCheckDetails: React.FC<{ response: any }> = ({ response }) => {
|
|||
parsedRedisParams?.redis_async_client?.connection_pool?.connection_kwargs?.host ||
|
||||
parsedRedisParams?.connection_kwargs?.host ||
|
||||
parsedRedisParams?.host ||
|
||||
"N/A",
|
||||
t("caching.health.notAvailable"),
|
||||
|
||||
redis_port:
|
||||
parsedRedisParams?.redis_client?.connection_pool?.connection_kwargs?.port ||
|
||||
parsedRedisParams?.redis_async_client?.connection_pool?.connection_kwargs?.port ||
|
||||
parsedRedisParams?.connection_kwargs?.port ||
|
||||
parsedRedisParams?.port ||
|
||||
"N/A",
|
||||
t("caching.health.notAvailable"),
|
||||
|
||||
redis_version: parsedRedisParams?.redis_version || "N/A",
|
||||
redis_version: parsedRedisParams?.redis_version || t("caching.health.notAvailable"),
|
||||
|
||||
startup_nodes: (() => {
|
||||
try {
|
||||
|
|
@ -143,13 +146,13 @@ const HealthCheckDetails: React.FC<{ response: any }> = ({ response }) => {
|
|||
const port =
|
||||
parsedRedisParams?.redis_client?.connection_pool?.connection_kwargs?.port ||
|
||||
parsedRedisParams?.redis_async_client?.connection_pool?.connection_kwargs?.port;
|
||||
return host && port ? JSON.stringify([{ host, port }]) : "N/A";
|
||||
return host && port ? JSON.stringify([{ host, port }]) : t("caching.health.notAvailable");
|
||||
} catch (e) {
|
||||
return "N/A";
|
||||
return t("caching.health.notAvailable");
|
||||
}
|
||||
})(),
|
||||
|
||||
namespace: parsedRedisParams?.namespace || "N/A",
|
||||
namespace: parsedRedisParams?.namespace || t("caching.health.notAvailable"),
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -157,10 +160,10 @@ const HealthCheckDetails: React.FC<{ response: any }> = ({ response }) => {
|
|||
<Tabs defaultValue="summary">
|
||||
<TabsList className="border-b border-border px-4">
|
||||
<TabsTrigger value="summary" className="flex-none">
|
||||
Summary
|
||||
{t("caching.health.tabs.summary")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="raw" className="flex-none">
|
||||
Raw Response
|
||||
{t("caching.health.tabs.raw")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
|
|
@ -175,7 +178,8 @@ const HealthCheckDetails: React.FC<{ response: any }> = ({ response }) => {
|
|||
<p
|
||||
className={`text-sm font-medium ${response?.status === "healthy" ? "text-success" : "text-destructive"}`}
|
||||
>
|
||||
Cache Status: {response?.status || "unhealthy"}
|
||||
{t("caching.health.cacheStatus")}
|
||||
{response?.status || "unhealthy"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -186,23 +190,32 @@ const HealthCheckDetails: React.FC<{ response: any }> = ({ response }) => {
|
|||
<>
|
||||
<tr>
|
||||
<td colSpan={2} className="pt-4 pb-2 font-semibold text-destructive">
|
||||
Error Details
|
||||
{t("caching.health.errorDetails")}
|
||||
</td>
|
||||
</tr>
|
||||
<TableClickableErrorField label="Error Message" value={errorDetails.message} />
|
||||
<TableClickableErrorField label="Traceback" value={errorDetails.traceback} />
|
||||
<TableClickableErrorField label={t("caching.health.errorMessage")} value={errorDetails.message} />
|
||||
<TableClickableErrorField label={t("caching.health.traceback")} value={errorDetails.traceback} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Always show cache details, regardless of error state */}
|
||||
<tr>
|
||||
<td colSpan={2} className="pt-4 pb-2 font-semibold">
|
||||
Cache Details
|
||||
{t("caching.health.cacheDetails")}
|
||||
</td>
|
||||
</tr>
|
||||
<TableClickableErrorField label="Cache Configuration" value={String(parsedLitellmParams?.type)} />
|
||||
<TableClickableErrorField label="Ping Response" value={String(response.ping_response)} />
|
||||
<TableClickableErrorField label="Set Cache Response" value={response.set_cache_response || "N/A"} />
|
||||
<TableClickableErrorField
|
||||
label={t("caching.health.cacheConfiguration")}
|
||||
value={String(parsedLitellmParams?.type)}
|
||||
/>
|
||||
<TableClickableErrorField
|
||||
label={t("caching.health.pingResponse")}
|
||||
value={String(response.ping_response)}
|
||||
/>
|
||||
<TableClickableErrorField
|
||||
label={t("caching.health.setCacheResponse")}
|
||||
value={response.set_cache_response || t("caching.health.notAvailable")}
|
||||
/>
|
||||
<TableClickableErrorField
|
||||
label="litellm_settings.cache_params"
|
||||
value={JSON.stringify(parsedLitellmParams, null, 2)}
|
||||
|
|
@ -213,14 +226,29 @@ const HealthCheckDetails: React.FC<{ response: any }> = ({ response }) => {
|
|||
<>
|
||||
<tr>
|
||||
<td colSpan={2} className="pt-4 pb-2 font-semibold">
|
||||
Redis Details
|
||||
{t("caching.health.redisDetails")}
|
||||
</td>
|
||||
</tr>
|
||||
<TableClickableErrorField label="Redis Host" value={redisDetails.redis_host || "N/A"} />
|
||||
<TableClickableErrorField label="Redis Port" value={redisDetails.redis_port || "N/A"} />
|
||||
<TableClickableErrorField label="Redis Version" value={redisDetails.redis_version || "N/A"} />
|
||||
<TableClickableErrorField label="Startup Nodes" value={redisDetails.startup_nodes || "N/A"} />
|
||||
<TableClickableErrorField label="Namespace" value={redisDetails.namespace || "N/A"} />
|
||||
<TableClickableErrorField
|
||||
label={t("caching.health.redisHost")}
|
||||
value={redisDetails.redis_host || t("caching.health.notAvailable")}
|
||||
/>
|
||||
<TableClickableErrorField
|
||||
label={t("caching.health.redisPort")}
|
||||
value={redisDetails.redis_port || t("caching.health.notAvailable")}
|
||||
/>
|
||||
<TableClickableErrorField
|
||||
label={t("caching.health.redisVersion")}
|
||||
value={redisDetails.redis_version || t("caching.health.notAvailable")}
|
||||
/>
|
||||
<TableClickableErrorField
|
||||
label={t("caching.health.startupNodes")}
|
||||
value={redisDetails.startup_nodes || t("caching.health.notAvailable")}
|
||||
/>
|
||||
<TableClickableErrorField
|
||||
label={t("caching.health.namespace")}
|
||||
value={redisDetails.namespace || t("caching.health.notAvailable")}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</tbody>
|
||||
|
|
@ -254,7 +282,7 @@ const HealthCheckDetails: React.FC<{ response: any }> = ({ response }) => {
|
|||
// Then stringify with proper formatting
|
||||
return JSON.stringify(prettyData, null, 2);
|
||||
} catch (e) {
|
||||
return "Error formatting JSON: " + (e as Error).message;
|
||||
return t("caching.health.formatFailed", { message: (e as Error).message });
|
||||
}
|
||||
})()}
|
||||
</pre>
|
||||
|
|
@ -271,6 +299,7 @@ export const CacheHealthTab: React.FC<{
|
|||
runCachingHealthCheck: () => void;
|
||||
responseTimeMs?: number | null;
|
||||
}> = ({ accessToken, healthCheckResponse, runCachingHealthCheck, responseTimeMs }) => {
|
||||
const { t } = useTranslation();
|
||||
const [localResponseTimeMs, setLocalResponseTimeMs] = React.useState<number | null>(null);
|
||||
const [isLoading, setIsLoading] = React.useState<boolean>(false);
|
||||
|
||||
|
|
@ -287,7 +316,7 @@ export const CacheHealthTab: React.FC<{
|
|||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Button onClick={handleHealthCheck} disabled={isLoading}>
|
||||
{isLoading ? "Running Health Check..." : "Run Health Check"}
|
||||
{isLoading ? t("caching.health.running") : t("caching.health.runButton")}
|
||||
</Button>
|
||||
<ResponseTimeIndicator responseTimeMs={localResponseTimeMs} />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React from "react";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FormField } from "@/components/shared/form/FormField";
|
||||
import { PasswordInput } from "@/components/shared/PasswordInput";
|
||||
import {
|
||||
|
|
@ -23,7 +24,7 @@ export interface EmbeddingModelOption {
|
|||
label: string;
|
||||
}
|
||||
|
||||
export const SECRET_ALREADY_SET_PLACEHOLDER = "Already set. Enter a new value to replace it.";
|
||||
export const SECRET_ALREADY_SET_PLACEHOLDER = "caching.settings.field.secretAlreadySet";
|
||||
|
||||
interface CacheFormFieldProps {
|
||||
field: CacheField;
|
||||
|
|
@ -32,11 +33,12 @@ interface CacheFormFieldProps {
|
|||
}
|
||||
|
||||
const CacheFormField: React.FC<CacheFormFieldProps> = ({ field, embeddingModels, isSecretConfigured = false }) => {
|
||||
const { t } = useTranslation();
|
||||
const form = useFormContext<CacheFormValues>();
|
||||
const placeholder = isSecretConfigured ? SECRET_ALREADY_SET_PLACEHOLDER : field.helpText;
|
||||
const placeholder = isSecretConfigured ? t(SECRET_ALREADY_SET_PLACEHOLDER) : t(field.helpText);
|
||||
|
||||
return (
|
||||
<FormField control={form.control} name={field.name} label={field.label} description={field.helpText}>
|
||||
<FormField control={form.control} name={field.name} label={t(field.label)} description={t(field.helpText)}>
|
||||
{({ ref, value, onChange, ...rest }) => {
|
||||
if (field.type === "boolean") {
|
||||
return <Switch {...rest} checked={value === true} onCheckedChange={(checked) => onChange(checked)} />;
|
||||
|
|
@ -70,7 +72,7 @@ const CacheFormField: React.FC<CacheFormFieldProps> = ({ field, embeddingModels,
|
|||
const { id, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy, name, onBlur, disabled } = rest;
|
||||
return (
|
||||
<Select
|
||||
items={options.map((option) => ({ label: option.label, value: option.value }))}
|
||||
items={options.map((option) => ({ label: t(option.label), value: option.value }))}
|
||||
name={name}
|
||||
disabled={disabled}
|
||||
value={typeof value === "string" && value !== "" ? value : null}
|
||||
|
|
@ -83,12 +85,12 @@ const CacheFormField: React.FC<CacheFormFieldProps> = ({ field, embeddingModels,
|
|||
onBlur={onBlur}
|
||||
className="w-full"
|
||||
>
|
||||
<SelectValue placeholder="Select an option" />
|
||||
<SelectValue placeholder={t("caching.settings.field.selectPlaceholder")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
{t(option.label)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
|
@ -107,11 +109,15 @@ const CacheFormField: React.FC<CacheFormFieldProps> = ({ field, embeddingModels,
|
|||
model.value === other.value
|
||||
}
|
||||
>
|
||||
<ComboboxInput {...rest} placeholder="Search and select a model..." className="w-full">
|
||||
<ComboboxInput
|
||||
{...rest}
|
||||
placeholder={t("caching.settings.field.modelSearchPlaceholder")}
|
||||
className="w-full"
|
||||
>
|
||||
<ComboboxClear />
|
||||
</ComboboxInput>
|
||||
<ComboboxContent>
|
||||
<ComboboxEmpty>No models found</ComboboxEmpty>
|
||||
<ComboboxEmpty>{t("caching.settings.field.noModels")}</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(model: EmbeddingModelOption) => (
|
||||
<ComboboxItem key={model.value} value={model} title={model.label}>
|
||||
|
|
|
|||
|
|
@ -1,37 +1,40 @@
|
|||
import type { ParseKeys } from "i18next";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
|
||||
interface RedisTypeSelectorProps {
|
||||
redisType: string;
|
||||
redisTypeDescriptions: Readonly<Record<string, string>>;
|
||||
redisTypeDescriptions: Readonly<Record<string, ParseKeys>>;
|
||||
onTypeChange: (type: string) => void;
|
||||
}
|
||||
|
||||
const REDIS_TYPE_LABELS: Readonly<Record<string, string>> = {
|
||||
node: "Node (Single Instance)",
|
||||
cluster: "Cluster",
|
||||
sentinel: "Sentinel",
|
||||
semantic: "Semantic",
|
||||
const REDIS_TYPE_LABELS: Readonly<Record<string, ParseKeys>> = {
|
||||
node: "caching.settings.redisType.options.node",
|
||||
cluster: "caching.settings.redisType.options.cluster",
|
||||
sentinel: "caching.settings.redisType.options.sentinel",
|
||||
semantic: "caching.settings.redisType.options.semantic",
|
||||
};
|
||||
|
||||
const RedisTypeSelector: React.FC<RedisTypeSelectorProps> = ({ redisType, redisTypeDescriptions, onTypeChange }) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Redis Type</label>
|
||||
<label className="text-sm font-medium">{t("caching.settings.redisType.label")}</label>
|
||||
<Select value={redisType} onValueChange={(value) => value !== null && onTypeChange(value)}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue>{REDIS_TYPE_LABELS[redisType] ?? redisType}</SelectValue>
|
||||
<SelectValue>{REDIS_TYPE_LABELS[redisType] ? t(REDIS_TYPE_LABELS[redisType]) : redisType}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(REDIS_TYPE_LABELS).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{label}
|
||||
{t(label)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{redisTypeDescriptions[redisType] || "Select the type of Redis deployment you're using"}
|
||||
{t(redisTypeDescriptions[redisType] || "caching.settings.redisType.fallbackDescription")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import type { ParseKeys } from "i18next";
|
||||
|
||||
export type CacheFieldType =
|
||||
| "string"
|
||||
| "password"
|
||||
|
|
@ -10,14 +12,14 @@ export type CacheFieldType =
|
|||
|
||||
export interface CacheFieldOption {
|
||||
readonly value: string;
|
||||
readonly label: string;
|
||||
readonly label: ParseKeys;
|
||||
}
|
||||
|
||||
export type RedisType = "node" | "cluster" | "sentinel" | "semantic";
|
||||
|
||||
export type CacheSection = "connection" | "cluster" | "sentinel" | "semantic" | "ssl" | "cacheManagement" | "gcp";
|
||||
|
||||
export type CacheFieldRule = (value: unknown) => string | null;
|
||||
export type CacheFieldRule = (value: unknown) => ParseKeys | null;
|
||||
|
||||
// Marker the backend returns for a configured credential and maps back to the
|
||||
// stored secret on save, so the plaintext never round-trips through the form.
|
||||
|
|
@ -25,10 +27,10 @@ export const REDACTED_VALUE = "***REDACTED***";
|
|||
|
||||
export interface CacheField {
|
||||
readonly name: string;
|
||||
readonly label: string;
|
||||
readonly label: ParseKeys;
|
||||
readonly type: CacheFieldType;
|
||||
readonly section: CacheSection;
|
||||
readonly helpText: string;
|
||||
readonly helpText: ParseKeys;
|
||||
readonly redisType: RedisType | null;
|
||||
readonly defaultValue?: string | number | boolean;
|
||||
readonly options?: readonly CacheFieldOption[];
|
||||
|
|
@ -40,11 +42,11 @@ export interface CacheField {
|
|||
|
||||
export const REDIS_TYPES: readonly RedisType[] = ["node", "cluster", "sentinel", "semantic"];
|
||||
|
||||
export const REDIS_TYPE_DESCRIPTIONS: Readonly<Record<RedisType, string>> = {
|
||||
node: "Standard Redis node/single instance",
|
||||
cluster: "Redis Cluster mode for high availability and horizontal scaling",
|
||||
sentinel: "Redis Sentinel mode for high availability with automatic failover",
|
||||
semantic: "Semantic caching that reuses responses for similar prompts",
|
||||
export const REDIS_TYPE_DESCRIPTIONS: Readonly<Record<RedisType, ParseKeys>> = {
|
||||
node: "caching.settings.redisType.descriptions.node",
|
||||
cluster: "caching.settings.redisType.descriptions.cluster",
|
||||
sentinel: "caching.settings.redisType.descriptions.sentinel",
|
||||
semantic: "caching.settings.redisType.descriptions.semantic",
|
||||
};
|
||||
|
||||
const isBlank = (value: unknown): boolean => value === undefined || value === null || String(value).trim() === "";
|
||||
|
|
@ -54,7 +56,7 @@ const portRule: CacheFieldRule = (value) => {
|
|||
return null;
|
||||
}
|
||||
const port = Number(value);
|
||||
return Number.isInteger(port) && port >= 1 && port <= 65535 ? null : "Port must be an integer between 1 and 65535";
|
||||
return Number.isInteger(port) && port >= 1 && port <= 65535 ? null : "caching.settings.validation.port";
|
||||
};
|
||||
|
||||
const jsonListRule: CacheFieldRule = (value) => {
|
||||
|
|
@ -65,9 +67,9 @@ const jsonListRule: CacheFieldRule = (value) => {
|
|||
try {
|
||||
parsed = JSON.parse(String(value));
|
||||
} catch {
|
||||
return "Must be a valid JSON array (use double quotes)";
|
||||
return "caching.settings.validation.jsonArrayInvalid";
|
||||
}
|
||||
return Array.isArray(parsed) ? null : "Must be a JSON array";
|
||||
return Array.isArray(parsed) ? null : "caching.settings.validation.jsonArrayRequired";
|
||||
};
|
||||
|
||||
const nonNegativeIntegerRule: CacheFieldRule = (value) => {
|
||||
|
|
@ -75,205 +77,202 @@ const nonNegativeIntegerRule: CacheFieldRule = (value) => {
|
|||
return null;
|
||||
}
|
||||
const parsed = Number(value);
|
||||
return Number.isInteger(parsed) && parsed >= 0 ? null : "Must be a non-negative integer";
|
||||
return Number.isInteger(parsed) && parsed >= 0 ? null : "caching.settings.validation.nonNegativeInteger";
|
||||
};
|
||||
|
||||
const numberRule: CacheFieldRule = (value) => {
|
||||
if (isBlank(value)) {
|
||||
return null;
|
||||
}
|
||||
return Number.isNaN(Number(value)) ? "Must be a number" : null;
|
||||
return Number.isNaN(Number(value)) ? "caching.settings.validation.number" : null;
|
||||
};
|
||||
|
||||
export const CACHE_FIELDS: readonly CacheField[] = [
|
||||
{
|
||||
name: "url",
|
||||
label: "Redis URL",
|
||||
label: "caching.settings.fields.url.label",
|
||||
type: "string",
|
||||
section: "connection",
|
||||
helpText:
|
||||
"Full Redis/Valkey connection URL (e.g. redis://:password@host:6379/1). When set, it takes precedence over Host, Port, Password, and Database Index.",
|
||||
helpText: "caching.settings.fields.url.helpText",
|
||||
redisType: null,
|
||||
secret: true,
|
||||
},
|
||||
{
|
||||
name: "host",
|
||||
label: "Host",
|
||||
label: "caching.settings.fields.host.label",
|
||||
type: "string",
|
||||
section: "connection",
|
||||
helpText: "Redis server hostname or IP address",
|
||||
helpText: "caching.settings.fields.host.helpText",
|
||||
redisType: null,
|
||||
},
|
||||
{
|
||||
name: "port",
|
||||
label: "Port",
|
||||
label: "caching.settings.fields.port.label",
|
||||
type: "string",
|
||||
section: "connection",
|
||||
helpText: "Redis server port number",
|
||||
helpText: "caching.settings.fields.port.helpText",
|
||||
redisType: null,
|
||||
defaultValue: "6379",
|
||||
rules: [portRule],
|
||||
},
|
||||
{
|
||||
name: "db",
|
||||
label: "Database Index",
|
||||
label: "caching.settings.fields.db.label",
|
||||
type: "integer",
|
||||
section: "connection",
|
||||
helpText: "Logical database index to isolate the cache (e.g. 1 for redis://host:6379/1)",
|
||||
helpText: "caching.settings.fields.db.helpText",
|
||||
redisType: null,
|
||||
rules: [nonNegativeIntegerRule],
|
||||
},
|
||||
{
|
||||
name: "password",
|
||||
label: "Password",
|
||||
label: "caching.settings.fields.password.label",
|
||||
type: "password",
|
||||
section: "connection",
|
||||
helpText: "Redis server password",
|
||||
helpText: "caching.settings.fields.password.helpText",
|
||||
redisType: null,
|
||||
secret: true,
|
||||
},
|
||||
{
|
||||
name: "username",
|
||||
label: "Username",
|
||||
label: "caching.settings.fields.username.label",
|
||||
type: "string",
|
||||
section: "connection",
|
||||
helpText: "Redis server username (if required)",
|
||||
helpText: "caching.settings.fields.username.helpText",
|
||||
redisType: null,
|
||||
},
|
||||
{
|
||||
name: "redis_startup_nodes",
|
||||
label: "Startup Nodes",
|
||||
label: "caching.settings.fields.redisStartupNodes.label",
|
||||
type: "list",
|
||||
section: "cluster",
|
||||
helpText: 'List of startup nodes for Redis Cluster (e.g., [{"host": "127.0.0.1", "port": "7001"}])',
|
||||
helpText: "caching.settings.fields.redisStartupNodes.helpText",
|
||||
redisType: "cluster",
|
||||
rules: [jsonListRule],
|
||||
},
|
||||
{
|
||||
name: "sentinel_nodes",
|
||||
label: "Sentinel Nodes",
|
||||
label: "caching.settings.fields.sentinelNodes.label",
|
||||
type: "list",
|
||||
section: "sentinel",
|
||||
helpText: 'List of Sentinel nodes (e.g., [["localhost", 26379]])',
|
||||
helpText: "caching.settings.fields.sentinelNodes.helpText",
|
||||
redisType: "sentinel",
|
||||
rules: [jsonListRule],
|
||||
},
|
||||
{
|
||||
name: "service_name",
|
||||
label: "Service Name",
|
||||
label: "caching.settings.fields.serviceName.label",
|
||||
type: "string",
|
||||
section: "sentinel",
|
||||
helpText: "Master service name for Redis Sentinel",
|
||||
helpText: "caching.settings.fields.serviceName.helpText",
|
||||
redisType: "sentinel",
|
||||
},
|
||||
{
|
||||
name: "sentinel_password",
|
||||
label: "Sentinel Password",
|
||||
label: "caching.settings.fields.sentinelPassword.label",
|
||||
type: "password",
|
||||
section: "sentinel",
|
||||
helpText: "Password for Redis Sentinel authentication",
|
||||
helpText: "caching.settings.fields.sentinelPassword.helpText",
|
||||
redisType: "sentinel",
|
||||
secret: true,
|
||||
},
|
||||
{
|
||||
name: "similarity_threshold",
|
||||
label: "Similarity Threshold",
|
||||
label: "caching.settings.fields.similarityThreshold.label",
|
||||
type: "float",
|
||||
section: "semantic",
|
||||
helpText: "Similarity threshold for semantic cache",
|
||||
helpText: "caching.settings.fields.similarityThreshold.helpText",
|
||||
redisType: "semantic",
|
||||
defaultValue: 0.8,
|
||||
rules: [numberRule],
|
||||
},
|
||||
{
|
||||
name: "redis_semantic_cache_embedding_model",
|
||||
label: "Embedding Model",
|
||||
label: "caching.settings.fields.embeddingModel.label",
|
||||
type: "model-select",
|
||||
section: "semantic",
|
||||
helpText: "Embedding model for semantic cache",
|
||||
helpText: "caching.settings.fields.embeddingModel.helpText",
|
||||
redisType: "semantic",
|
||||
},
|
||||
{
|
||||
name: "semantic_cache_scope",
|
||||
label: "Semantic Cache Scope",
|
||||
label: "caching.settings.fields.semanticCacheScope.label",
|
||||
type: "select",
|
||||
section: "semantic",
|
||||
helpText:
|
||||
"Who can share a semantic cache hit. Key shares hits between all end users of a key/team/org. End user also isolates per end user; requests without an end user fall back to the key scope.",
|
||||
helpText: "caching.settings.fields.semanticCacheScope.helpText",
|
||||
redisType: "semantic",
|
||||
defaultValue: "key",
|
||||
options: [
|
||||
{ value: "key", label: "Key (shared by all end users of the key/team/org)" },
|
||||
{ value: "end_user", label: "End user (isolated per end user)" },
|
||||
{ value: "key", label: "caching.settings.scopeOptions.key" },
|
||||
{ value: "end_user", label: "caching.settings.scopeOptions.endUser" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "ssl",
|
||||
label: "SSL",
|
||||
label: "caching.settings.fields.ssl.label",
|
||||
type: "boolean",
|
||||
section: "ssl",
|
||||
helpText: "Enable SSL/TLS connection",
|
||||
helpText: "caching.settings.fields.ssl.helpText",
|
||||
redisType: null,
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
name: "ssl_cert_reqs",
|
||||
label: "SSL Cert Reqs",
|
||||
label: "caching.settings.fields.sslCertReqs.label",
|
||||
type: "string",
|
||||
section: "ssl",
|
||||
helpText: "SSL certificate requirements (None, CERT_REQUIRED, CERT_OPTIONAL)",
|
||||
helpText: "caching.settings.fields.sslCertReqs.helpText",
|
||||
redisType: null,
|
||||
},
|
||||
{
|
||||
name: "ssl_check_hostname",
|
||||
label: "SSL Check Hostname",
|
||||
label: "caching.settings.fields.sslCheckHostname.label",
|
||||
type: "boolean",
|
||||
section: "ssl",
|
||||
helpText: "Enable SSL hostname verification",
|
||||
helpText: "caching.settings.fields.sslCheckHostname.helpText",
|
||||
redisType: null,
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
name: "namespace",
|
||||
label: "Namespace",
|
||||
label: "caching.settings.fields.namespace.label",
|
||||
type: "string",
|
||||
section: "cacheManagement",
|
||||
helpText: "Namespace prefix for cache keys",
|
||||
helpText: "caching.settings.fields.namespace.helpText",
|
||||
redisType: null,
|
||||
},
|
||||
{
|
||||
name: "ttl",
|
||||
label: "TTL (seconds)",
|
||||
label: "caching.settings.fields.ttl.label",
|
||||
type: "float",
|
||||
section: "cacheManagement",
|
||||
helpText: "Time-to-live for cached items in seconds",
|
||||
helpText: "caching.settings.fields.ttl.helpText",
|
||||
redisType: null,
|
||||
rules: [numberRule],
|
||||
},
|
||||
{
|
||||
name: "max_connections",
|
||||
label: "Max Connections",
|
||||
label: "caching.settings.fields.maxConnections.label",
|
||||
type: "integer",
|
||||
section: "cacheManagement",
|
||||
helpText: "Maximum number of connections in the connection pool",
|
||||
helpText: "caching.settings.fields.maxConnections.helpText",
|
||||
redisType: null,
|
||||
rules: [nonNegativeIntegerRule],
|
||||
},
|
||||
{
|
||||
name: "gcp_service_account",
|
||||
label: "GCP Service Account",
|
||||
label: "caching.settings.fields.gcpServiceAccount.label",
|
||||
type: "string",
|
||||
section: "gcp",
|
||||
helpText:
|
||||
"GCP service account for IAM authentication (e.g., projects/-/serviceAccounts/your-sa@project.iam.gserviceaccount.com)",
|
||||
helpText: "caching.settings.fields.gcpServiceAccount.helpText",
|
||||
redisType: null,
|
||||
},
|
||||
{
|
||||
name: "gcp_ssl_ca_certs",
|
||||
label: "GCP SSL CA Certs",
|
||||
label: "caching.settings.fields.gcpSslCaCerts.label",
|
||||
type: "string",
|
||||
section: "gcp",
|
||||
helpText: "Path to SSL CA certificate file for GCP Memorystore Redis",
|
||||
helpText: "caching.settings.fields.gcpSslCaCerts.helpText",
|
||||
redisType: null,
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { getCacheSettingsCall, testCacheConnectionCall, updateCacheSettingsCall } from "@/components/networking";
|
||||
|
|
@ -30,6 +31,7 @@ const toRedisType = (value: unknown): RedisType =>
|
|||
REDIS_TYPES.includes(value as RedisType) ? (value as RedisType) : "node";
|
||||
|
||||
const CacheSettings: React.FC<CacheSettingsProps> = ({ accessToken }) => {
|
||||
const { t } = useTranslation();
|
||||
const form = useForm<CacheFormValues>({ defaultValues: buildInitialValues({}) });
|
||||
const [redisType, setRedisType] = useState<RedisType>("node");
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||
|
|
@ -50,9 +52,9 @@ const CacheSettings: React.FC<CacheSettingsProps> = ({ accessToken }) => {
|
|||
setRedisType(toRedisType(currentValues.redis_type));
|
||||
} catch (error) {
|
||||
console.error("Failed to load cache settings:", error);
|
||||
toast.fromError("Failed to load cache settings");
|
||||
toast.fromError(t("caching.settings.toasts.loadFailed"));
|
||||
}
|
||||
}, [accessToken, form]);
|
||||
}, [accessToken, form, t]);
|
||||
|
||||
useEffect(() => {
|
||||
loadCacheSettings();
|
||||
|
|
@ -85,7 +87,7 @@ const CacheSettings: React.FC<CacheSettingsProps> = ({ accessToken }) => {
|
|||
});
|
||||
|
||||
form.clearErrors();
|
||||
failures.forEach(([name, message]) => form.setError(name, { message }));
|
||||
failures.forEach(([name, message]) => form.setError(name, { message: t(message) }));
|
||||
return failures.length > 0 ? null : values;
|
||||
};
|
||||
|
||||
|
|
@ -105,13 +107,17 @@ const CacheSettings: React.FC<CacheSettingsProps> = ({ accessToken }) => {
|
|||
buildCachePayload(redisType, values, { forTesting: true }),
|
||||
);
|
||||
if (result.status === "success") {
|
||||
toast.success("Cache connection test successful!");
|
||||
toast.success(t("caching.settings.toasts.testSucceeded"));
|
||||
} else {
|
||||
toast.fromError(`Connection test failed: ${result.message || result.error}`);
|
||||
toast.fromError(t("caching.settings.toasts.testFailed", { error: String(result.message || result.error) }));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Test connection error:", error);
|
||||
toast.fromError(`Connection test failed: ${error instanceof Error ? error.message : "Unknown error"}`);
|
||||
toast.fromError(
|
||||
t("caching.settings.toasts.testFailed", {
|
||||
error: error instanceof Error ? error.message : t("caching.settings.toasts.unknownError"),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
setIsTesting(false);
|
||||
}
|
||||
|
|
@ -129,11 +135,11 @@ const CacheSettings: React.FC<CacheSettingsProps> = ({ accessToken }) => {
|
|||
setIsSaving(true);
|
||||
try {
|
||||
await updateCacheSettingsCall(accessToken, buildCachePayload(redisType, values, { forTesting: false }));
|
||||
toast.success("Cache settings updated successfully");
|
||||
toast.success(t("caching.settings.toasts.saved"));
|
||||
await loadCacheSettings();
|
||||
} catch (error) {
|
||||
console.error("Failed to save cache settings:", error);
|
||||
toast.fromError("Failed to update cache settings");
|
||||
toast.fromError(t("caching.settings.toasts.saveFailed"));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
|
|
@ -148,8 +154,8 @@ const CacheSettings: React.FC<CacheSettingsProps> = ({ accessToken }) => {
|
|||
<FormProvider {...form}>
|
||||
<form onSubmit={(event) => event.preventDefault()} className="space-y-6">
|
||||
<div className="max-w-3xl">
|
||||
<h3 className="text-sm font-medium text-foreground">Cache Settings</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1">Configure Redis cache for LiteLLM</p>
|
||||
<h3 className="text-sm font-medium text-foreground">{t("caching.settings.title")}</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1">{t("caching.settings.subtitle")}</p>
|
||||
</div>
|
||||
|
||||
<RedisTypeSelector
|
||||
|
|
@ -160,7 +166,7 @@ const CacheSettings: React.FC<CacheSettingsProps> = ({ accessToken }) => {
|
|||
|
||||
<div className="pt-4 border-t border-border">
|
||||
<CacheFieldSection
|
||||
title="Connection Settings"
|
||||
title={t("caching.settings.sections.connection")}
|
||||
section="connection"
|
||||
redisType={redisType}
|
||||
embeddingModels={embeddingModels}
|
||||
|
|
@ -171,7 +177,7 @@ const CacheSettings: React.FC<CacheSettingsProps> = ({ accessToken }) => {
|
|||
{redisType === "cluster" && (
|
||||
<div className="pt-4 border-t border-border">
|
||||
<CacheFieldSection
|
||||
title="Cluster Configuration"
|
||||
title={t("caching.settings.sections.cluster")}
|
||||
section="cluster"
|
||||
redisType={redisType}
|
||||
embeddingModels={embeddingModels}
|
||||
|
|
@ -183,7 +189,7 @@ const CacheSettings: React.FC<CacheSettingsProps> = ({ accessToken }) => {
|
|||
{redisType === "sentinel" && (
|
||||
<div className="pt-4 border-t border-border">
|
||||
<CacheFieldSection
|
||||
title="Sentinel Configuration"
|
||||
title={t("caching.settings.sections.sentinel")}
|
||||
section="sentinel"
|
||||
redisType={redisType}
|
||||
embeddingModels={embeddingModels}
|
||||
|
|
@ -195,7 +201,7 @@ const CacheSettings: React.FC<CacheSettingsProps> = ({ accessToken }) => {
|
|||
{redisType === "semantic" && (
|
||||
<div className="pt-4 border-t border-border">
|
||||
<CacheFieldSection
|
||||
title="Semantic Configuration"
|
||||
title={t("caching.settings.sections.semantic")}
|
||||
section="semantic"
|
||||
redisType={redisType}
|
||||
embeddingModels={embeddingModels}
|
||||
|
|
@ -205,27 +211,27 @@ const CacheSettings: React.FC<CacheSettingsProps> = ({ accessToken }) => {
|
|||
|
||||
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen} className="mt-4">
|
||||
<CollapsibleTrigger className="group flex w-full items-center justify-between py-2 text-left">
|
||||
<span className="text-sm font-medium text-foreground">Advanced Settings</span>
|
||||
<span className="text-sm font-medium text-foreground">{t("caching.settings.advanced")}</span>
|
||||
<ChevronRight className="size-4 text-muted-foreground transition-transform group-data-panel-open:rotate-90" />
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="space-y-6">
|
||||
<CacheFieldSection
|
||||
title="SSL Settings"
|
||||
title={t("caching.settings.sections.ssl")}
|
||||
section="ssl"
|
||||
redisType={redisType}
|
||||
embeddingModels={embeddingModels}
|
||||
headingLevel="h5"
|
||||
/>
|
||||
<CacheFieldSection
|
||||
title="Cache Management"
|
||||
title={t("caching.settings.sections.cacheManagement")}
|
||||
section="cacheManagement"
|
||||
redisType={redisType}
|
||||
embeddingModels={embeddingModels}
|
||||
headingLevel="h5"
|
||||
/>
|
||||
<CacheFieldSection
|
||||
title="GCP Authentication"
|
||||
title={t("caching.settings.sections.gcp")}
|
||||
section="gcp"
|
||||
redisType={redisType}
|
||||
embeddingModels={embeddingModels}
|
||||
|
|
@ -239,10 +245,10 @@ const CacheSettings: React.FC<CacheSettingsProps> = ({ accessToken }) => {
|
|||
|
||||
<div className="border-t border-border pt-6 flex justify-end gap-3">
|
||||
<Button variant="secondary" size="sm" onClick={handleTestConnection} disabled={isTesting} className="text-sm">
|
||||
{isTesting ? "Testing..." : "Test Connection"}
|
||||
{isTesting ? t("caching.settings.testing") : t("caching.settings.testConnection")}
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSaveChanges} disabled={isSaving} className="text-sm font-medium">
|
||||
{isSaving ? "Saving..." : "Save Changes"}
|
||||
{isSaving ? t("caching.settings.saving") : t("caching.settings.saveChanges")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React from "react";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FormField } from "@/components/shared/form/FormField";
|
||||
import { PasswordInput } from "@/components/shared/PasswordInput";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
|
@ -8,7 +9,7 @@ import { Textarea } from "@/components/ui/textarea";
|
|||
import { CoordinationField } from "./coordinationRedisFields";
|
||||
import type { CoordinationFormValues } from "./coordinationRedisUtils";
|
||||
|
||||
export const SECRET_ALREADY_SET_PLACEHOLDER = "Already set. Enter a new value to replace it.";
|
||||
export const SECRET_ALREADY_SET_PLACEHOLDER = "caching.coordinationRedis.field.secretAlreadySet";
|
||||
|
||||
interface CoordinationRedisFormFieldProps {
|
||||
field: CoordinationField;
|
||||
|
|
@ -16,11 +17,12 @@ interface CoordinationRedisFormFieldProps {
|
|||
}
|
||||
|
||||
const CoordinationRedisFormField: React.FC<CoordinationRedisFormFieldProps> = ({ field, isSecretConfigured }) => {
|
||||
const { t } = useTranslation();
|
||||
const form = useFormContext<CoordinationFormValues>();
|
||||
const placeholder = isSecretConfigured ? SECRET_ALREADY_SET_PLACEHOLDER : field.helpText;
|
||||
const placeholder = isSecretConfigured ? t(SECRET_ALREADY_SET_PLACEHOLDER) : t(field.helpText);
|
||||
|
||||
return (
|
||||
<FormField control={form.control} name={field.name} label={field.label} description={field.helpText}>
|
||||
<FormField control={form.control} name={field.name} label={t(field.label)} description={t(field.helpText)}>
|
||||
{({ ref, value, onChange, ...rest }) => {
|
||||
if (field.type === "boolean") {
|
||||
return <Switch {...rest} checked={value === true} onCheckedChange={(checked) => onChange(checked)} />;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import {
|
||||
COORDINATION_REDIS_TYPES,
|
||||
|
|
@ -12,25 +13,28 @@ interface CoordinationRedisTypeSelectorProps {
|
|||
onTypeChange: (type: CoordinationRedisType) => void;
|
||||
}
|
||||
|
||||
const CoordinationRedisTypeSelector: React.FC<CoordinationRedisTypeSelectorProps> = ({ redisType, onTypeChange }) => (
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="coordination-redis-type" className="text-sm font-medium">
|
||||
Redis Type
|
||||
</label>
|
||||
<Select value={redisType} onValueChange={(value) => value !== null && onTypeChange(value)}>
|
||||
<SelectTrigger id="coordination-redis-type" className="w-full">
|
||||
<SelectValue>{COORDINATION_REDIS_TYPE_LABELS[redisType]}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{COORDINATION_REDIS_TYPES.map((type) => (
|
||||
<SelectItem key={type} value={type}>
|
||||
{COORDINATION_REDIS_TYPE_LABELS[type]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">{COORDINATION_REDIS_TYPE_DESCRIPTIONS[redisType]}</p>
|
||||
</div>
|
||||
);
|
||||
const CoordinationRedisTypeSelector: React.FC<CoordinationRedisTypeSelectorProps> = ({ redisType, onTypeChange }) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="coordination-redis-type" className="text-sm font-medium">
|
||||
{t("caching.coordinationRedis.redisType.label")}
|
||||
</label>
|
||||
<Select value={redisType} onValueChange={(value) => value !== null && onTypeChange(value)}>
|
||||
<SelectTrigger id="coordination-redis-type" className="w-full">
|
||||
<SelectValue>{t(COORDINATION_REDIS_TYPE_LABELS[redisType])}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{COORDINATION_REDIS_TYPES.map((type) => (
|
||||
<SelectItem key={type} value={type}>
|
||||
{t(COORDINATION_REDIS_TYPE_LABELS[type])}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">{COORDINATION_REDIS_TYPE_DESCRIPTIONS[redisType]}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CoordinationRedisTypeSelector;
|
||||
|
|
|
|||
|
|
@ -1,17 +1,21 @@
|
|||
import type { ParseKeys } from "i18next";
|
||||
|
||||
import i18n from "@/i18n/i18n";
|
||||
|
||||
export type CoordinationFieldType = "string" | "password" | "integer" | "boolean" | "list";
|
||||
|
||||
export type CoordinationRedisType = "node" | "cluster" | "sentinel";
|
||||
|
||||
export type CoordinationSection = "connection" | "cluster" | "sentinel" | "ssl";
|
||||
|
||||
export type CoordinationFieldRule = (value: unknown) => string | null;
|
||||
export type CoordinationFieldRule = (value: unknown) => ParseKeys | null;
|
||||
|
||||
export interface CoordinationField {
|
||||
readonly name: string;
|
||||
readonly label: string;
|
||||
readonly label: ParseKeys;
|
||||
readonly type: CoordinationFieldType;
|
||||
readonly section: CoordinationSection;
|
||||
readonly helpText: string;
|
||||
readonly helpText: ParseKeys;
|
||||
readonly redisType: CoordinationRedisType | null;
|
||||
readonly secret: boolean;
|
||||
readonly defaultValue?: string | number | boolean;
|
||||
|
|
@ -21,15 +25,21 @@ export interface CoordinationField {
|
|||
export const COORDINATION_REDIS_TYPES: readonly CoordinationRedisType[] = ["node", "cluster", "sentinel"];
|
||||
|
||||
export const COORDINATION_REDIS_TYPE_DESCRIPTIONS: Readonly<Record<CoordinationRedisType, string>> = {
|
||||
node: "Standard Redis node/single instance",
|
||||
cluster: "Redis Cluster mode for high availability and horizontal scaling",
|
||||
sentinel: "Redis Sentinel mode for high availability with automatic failover",
|
||||
get node() {
|
||||
return i18n.t("caching.coordinationRedis.redisType.descriptions.node");
|
||||
},
|
||||
get cluster() {
|
||||
return i18n.t("caching.coordinationRedis.redisType.descriptions.cluster");
|
||||
},
|
||||
get sentinel() {
|
||||
return i18n.t("caching.coordinationRedis.redisType.descriptions.sentinel");
|
||||
},
|
||||
};
|
||||
|
||||
export const COORDINATION_REDIS_TYPE_LABELS: Readonly<Record<CoordinationRedisType, string>> = {
|
||||
node: "Node (Single Instance)",
|
||||
cluster: "Cluster",
|
||||
sentinel: "Sentinel",
|
||||
export const COORDINATION_REDIS_TYPE_LABELS: Readonly<Record<CoordinationRedisType, ParseKeys>> = {
|
||||
node: "caching.coordinationRedis.redisType.options.node",
|
||||
cluster: "caching.coordinationRedis.redisType.options.cluster",
|
||||
sentinel: "caching.coordinationRedis.redisType.options.sentinel",
|
||||
};
|
||||
|
||||
const isBlank = (value: unknown): boolean => value === undefined || value === null || String(value).trim() === "";
|
||||
|
|
@ -39,7 +49,7 @@ const portRule: CoordinationFieldRule = (value) => {
|
|||
return null;
|
||||
}
|
||||
const port = Number(value);
|
||||
return Number.isInteger(port) && port >= 1 && port <= 65535 ? null : "Port must be an integer between 1 and 65535";
|
||||
return Number.isInteger(port) && port >= 1 && port <= 65535 ? null : "caching.coordinationRedis.validation.port";
|
||||
};
|
||||
|
||||
const jsonListRule: CoordinationFieldRule = (value) => {
|
||||
|
|
@ -50,37 +60,36 @@ const jsonListRule: CoordinationFieldRule = (value) => {
|
|||
try {
|
||||
parsed = JSON.parse(String(value));
|
||||
} catch {
|
||||
return "Must be a valid JSON array (use double quotes)";
|
||||
return "caching.coordinationRedis.validation.jsonArrayInvalid";
|
||||
}
|
||||
return Array.isArray(parsed) ? null : "Must be a JSON array";
|
||||
return Array.isArray(parsed) ? null : "caching.coordinationRedis.validation.jsonArrayRequired";
|
||||
};
|
||||
|
||||
export const COORDINATION_FIELDS: readonly CoordinationField[] = [
|
||||
{
|
||||
name: "url",
|
||||
label: "Redis URL",
|
||||
label: "caching.coordinationRedis.fields.url.label",
|
||||
type: "password",
|
||||
section: "connection",
|
||||
helpText:
|
||||
"Full Redis/Valkey connection URL (e.g. redis://:password@host:6379/1). When set, it takes precedence over Host, Port, Username, and Password.",
|
||||
helpText: "caching.coordinationRedis.fields.url.helpText",
|
||||
redisType: null,
|
||||
secret: true,
|
||||
},
|
||||
{
|
||||
name: "host",
|
||||
label: "Host",
|
||||
label: "caching.coordinationRedis.fields.host.label",
|
||||
type: "string",
|
||||
section: "connection",
|
||||
helpText: "Redis server hostname or IP address",
|
||||
helpText: "caching.coordinationRedis.fields.host.helpText",
|
||||
redisType: null,
|
||||
secret: false,
|
||||
},
|
||||
{
|
||||
name: "port",
|
||||
label: "Port",
|
||||
label: "caching.coordinationRedis.fields.port.label",
|
||||
type: "integer",
|
||||
section: "connection",
|
||||
helpText: "Redis server port number",
|
||||
helpText: "caching.coordinationRedis.fields.port.helpText",
|
||||
redisType: null,
|
||||
secret: false,
|
||||
defaultValue: "6379",
|
||||
|
|
@ -88,66 +97,66 @@ export const COORDINATION_FIELDS: readonly CoordinationField[] = [
|
|||
},
|
||||
{
|
||||
name: "username",
|
||||
label: "Username",
|
||||
label: "caching.coordinationRedis.fields.username.label",
|
||||
type: "string",
|
||||
section: "connection",
|
||||
helpText: "Redis server username (if required)",
|
||||
helpText: "caching.coordinationRedis.fields.username.helpText",
|
||||
redisType: null,
|
||||
secret: false,
|
||||
},
|
||||
{
|
||||
name: "password",
|
||||
label: "Password",
|
||||
label: "caching.coordinationRedis.fields.password.label",
|
||||
type: "password",
|
||||
section: "connection",
|
||||
helpText: "Redis server password",
|
||||
helpText: "caching.coordinationRedis.fields.password.helpText",
|
||||
redisType: null,
|
||||
secret: true,
|
||||
},
|
||||
{
|
||||
name: "startup_nodes",
|
||||
label: "Startup Nodes",
|
||||
label: "caching.coordinationRedis.fields.startupNodes.label",
|
||||
type: "list",
|
||||
section: "cluster",
|
||||
helpText: 'List of startup nodes for Redis Cluster (e.g., [{"host": "127.0.0.1", "port": 7001}])',
|
||||
helpText: "caching.coordinationRedis.fields.startupNodes.helpText",
|
||||
redisType: "cluster",
|
||||
secret: false,
|
||||
rules: [jsonListRule],
|
||||
},
|
||||
{
|
||||
name: "sentinel_nodes",
|
||||
label: "Sentinel Nodes",
|
||||
label: "caching.coordinationRedis.fields.sentinelNodes.label",
|
||||
type: "list",
|
||||
section: "sentinel",
|
||||
helpText: 'List of Sentinel nodes (e.g., [["localhost", 26379]])',
|
||||
helpText: "caching.coordinationRedis.fields.sentinelNodes.helpText",
|
||||
redisType: "sentinel",
|
||||
secret: false,
|
||||
rules: [jsonListRule],
|
||||
},
|
||||
{
|
||||
name: "service_name",
|
||||
label: "Service Name",
|
||||
label: "caching.coordinationRedis.fields.serviceName.label",
|
||||
type: "string",
|
||||
section: "sentinel",
|
||||
helpText: "Master service name for Redis Sentinel",
|
||||
helpText: "caching.coordinationRedis.fields.serviceName.helpText",
|
||||
redisType: "sentinel",
|
||||
secret: false,
|
||||
},
|
||||
{
|
||||
name: "sentinel_password",
|
||||
label: "Sentinel Password",
|
||||
label: "caching.coordinationRedis.fields.sentinelPassword.label",
|
||||
type: "password",
|
||||
section: "sentinel",
|
||||
helpText: "Password for Redis Sentinel authentication",
|
||||
helpText: "caching.coordinationRedis.fields.sentinelPassword.helpText",
|
||||
redisType: "sentinel",
|
||||
secret: true,
|
||||
},
|
||||
{
|
||||
name: "ssl",
|
||||
label: "SSL",
|
||||
label: "caching.coordinationRedis.fields.ssl.label",
|
||||
type: "boolean",
|
||||
section: "ssl",
|
||||
helpText: "Enable SSL/TLS connection",
|
||||
helpText: "caching.coordinationRedis.fields.ssl.helpText",
|
||||
redisType: null,
|
||||
secret: false,
|
||||
defaultValue: false,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
import type { ParseKeys, TFunction } from "i18next";
|
||||
|
||||
import type { StatusTone } from "@/components/shared/table_cells/status_badge";
|
||||
import i18n from "@/i18n/i18n";
|
||||
import {
|
||||
COORDINATION_FIELDS,
|
||||
CoordinationField,
|
||||
|
|
@ -118,31 +121,41 @@ export interface SourceBadgeDescriptor {
|
|||
readonly tooltip: string;
|
||||
}
|
||||
|
||||
const SOURCE_BADGES: Readonly<Record<CoordinationRedisSource, SourceBadgeDescriptor>> = {
|
||||
interface SourceBadgeKeys {
|
||||
readonly tone: StatusTone;
|
||||
readonly label: ParseKeys;
|
||||
readonly tooltip: ParseKeys;
|
||||
}
|
||||
|
||||
const SOURCE_BADGES: Readonly<Record<CoordinationRedisSource, SourceBadgeKeys>> = {
|
||||
coordination_redis: {
|
||||
tone: "success",
|
||||
label: "Configured here",
|
||||
tooltip: "general_settings.coordination_redis is set, so coordination uses its own Redis connection.",
|
||||
label: "caching.coordinationRedis.source.coordinationRedis.label",
|
||||
tooltip: "caching.coordinationRedis.source.coordinationRedis.tooltip",
|
||||
},
|
||||
cache_backend: {
|
||||
tone: "info",
|
||||
label: "Borrowed from response cache",
|
||||
tooltip: "No coordination Redis is configured; the proxy reuses the response cache's Redis connection.",
|
||||
label: "caching.coordinationRedis.source.cacheBackend.label",
|
||||
tooltip: "caching.coordinationRedis.source.cacheBackend.tooltip",
|
||||
},
|
||||
environment: {
|
||||
tone: "info",
|
||||
label: "From REDIS_* environment",
|
||||
tooltip: "No coordination Redis is configured; the proxy falls back to the REDIS_* environment variables.",
|
||||
label: "caching.coordinationRedis.source.environment.label",
|
||||
tooltip: "caching.coordinationRedis.source.environment.tooltip",
|
||||
},
|
||||
};
|
||||
|
||||
const NOT_CONFIGURED_BADGE: SourceBadgeDescriptor = {
|
||||
const NOT_CONFIGURED_BADGE: SourceBadgeKeys = {
|
||||
tone: "neutral",
|
||||
label: "Not configured",
|
||||
tooltip: "Cross-pod rate limits, spend tracking, and the pod lock manager have no Redis to coordinate through.",
|
||||
label: "caching.coordinationRedis.source.notConfigured.label",
|
||||
tooltip: "caching.coordinationRedis.source.notConfigured.tooltip",
|
||||
};
|
||||
|
||||
export const sourceBadge = (source: string | null | undefined): SourceBadgeDescriptor => {
|
||||
const known: Readonly<Record<string, SourceBadgeDescriptor>> = SOURCE_BADGES;
|
||||
return (source && known[source]) || NOT_CONFIGURED_BADGE;
|
||||
export const sourceBadge = (
|
||||
source: string | null | undefined,
|
||||
t: TFunction = i18n.t.bind(i18n),
|
||||
): SourceBadgeDescriptor => {
|
||||
const known: Readonly<Record<string, SourceBadgeKeys>> = SOURCE_BADGES;
|
||||
const badge = (source && known[source]) || NOT_CONFIGURED_BADGE;
|
||||
return { tone: badge.tone, label: t(badge.label), tooltip: t(badge.tooltip) };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
|
@ -23,6 +24,7 @@ import {
|
|||
} from "./coordinationRedisUtils";
|
||||
|
||||
const CoordinationRedisSettings: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const form = useForm<CoordinationFormValues>({ defaultValues: buildInitialValues({}) });
|
||||
const [selectedRedisType, setSelectedRedisType] = useState<CoordinationRedisType | null>(null);
|
||||
|
||||
|
|
@ -40,9 +42,9 @@ const CoordinationRedisSettings: React.FC = () => {
|
|||
|
||||
useEffect(() => {
|
||||
if (isError) {
|
||||
toast.fromError("Failed to load coordination Redis settings");
|
||||
toast.fromError(t("caching.coordinationRedis.toasts.loadFailed"));
|
||||
}
|
||||
}, [isError]);
|
||||
}, [isError, t]);
|
||||
|
||||
const validate = (): CoordinationFormValues | null => {
|
||||
const values = form.getValues();
|
||||
|
|
@ -52,7 +54,7 @@ const CoordinationRedisSettings: React.FC = () => {
|
|||
});
|
||||
|
||||
form.clearErrors();
|
||||
failures.forEach(([name, message]) => form.setError(name, { message }));
|
||||
failures.forEach(([name, message]) => form.setError(name, { message: t(message) }));
|
||||
return failures.length > 0 ? null : values;
|
||||
};
|
||||
|
||||
|
|
@ -65,12 +67,20 @@ const CoordinationRedisSettings: React.FC = () => {
|
|||
try {
|
||||
const result = await testConnection.mutateAsync(buildCoordinationPayload(redisType, values));
|
||||
if (result.status === "healthy") {
|
||||
toast.success("Coordination Redis connection test successful!");
|
||||
toast.success(t("caching.coordinationRedis.toasts.testSucceeded"));
|
||||
} else {
|
||||
toast.fromError(`Connection test failed: ${result.error ?? "Unknown error"}`);
|
||||
toast.fromError(
|
||||
t("caching.coordinationRedis.toasts.testFailed", {
|
||||
error: result.error ?? t("caching.coordinationRedis.toasts.unknownError"),
|
||||
}),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.fromError(`Connection test failed: ${error instanceof Error ? error.message : "Unknown error"}`);
|
||||
toast.fromError(
|
||||
t("caching.coordinationRedis.toasts.testFailed", {
|
||||
error: error instanceof Error ? error.message : t("caching.coordinationRedis.toasts.unknownError"),
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -82,13 +92,13 @@ const CoordinationRedisSettings: React.FC = () => {
|
|||
|
||||
try {
|
||||
await updateSettings.mutateAsync(buildCoordinationPayload(redisType, values));
|
||||
toast.success("Coordination Redis settings saved. Restart the proxy to apply them.");
|
||||
toast.success(t("caching.coordinationRedis.toasts.saved"));
|
||||
} catch {
|
||||
toast.fromError("Failed to update coordination Redis settings");
|
||||
toast.fromError(t("caching.coordinationRedis.toasts.saveFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
const badge = sourceBadge(data?.source);
|
||||
const badge = sourceBadge(data?.source, t);
|
||||
const configuredSecrets = useMemo(() => configuredSecretFields(data?.values ?? {}), [data]);
|
||||
|
||||
return (
|
||||
|
|
@ -97,24 +107,21 @@ const CoordinationRedisSettings: React.FC = () => {
|
|||
<form onSubmit={(event) => event.preventDefault()} className="space-y-6">
|
||||
<div className="max-w-3xl space-y-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="text-sm font-medium text-foreground">Coordination Redis</h3>
|
||||
<h3 className="text-sm font-medium text-foreground">{t("caching.coordinationRedis.title")}</h3>
|
||||
{!isLoading && (
|
||||
<StatusBadge tone={badge.tone} label={badge.label} dataTestId="coordination-redis-source" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Redis used to coordinate work across proxy pods: cross-pod rate limits, spend tracking, and the pod lock
|
||||
manager. It is configured independently of the response cache.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{t("caching.coordinationRedis.description")}</p>
|
||||
<p className="text-xs text-muted-foreground">{badge.tooltip}</p>
|
||||
<p className="text-xs text-warning">Saved changes take effect on proxy restart.</p>
|
||||
<p className="text-xs text-warning">{t("caching.coordinationRedis.restartNote")}</p>
|
||||
</div>
|
||||
|
||||
<CoordinationRedisTypeSelector redisType={redisType} onTypeChange={setSelectedRedisType} />
|
||||
|
||||
<div className="pt-4 border-t border-border">
|
||||
<CoordinationRedisFieldSection
|
||||
title="Connection Settings"
|
||||
title={t("caching.coordinationRedis.sections.connection")}
|
||||
section="connection"
|
||||
redisType={redisType}
|
||||
configuredSecrets={configuredSecrets}
|
||||
|
|
@ -124,7 +131,7 @@ const CoordinationRedisSettings: React.FC = () => {
|
|||
{redisType === "cluster" && (
|
||||
<div className="pt-4 border-t border-border">
|
||||
<CoordinationRedisFieldSection
|
||||
title="Cluster Configuration"
|
||||
title={t("caching.coordinationRedis.sections.cluster")}
|
||||
section="cluster"
|
||||
redisType={redisType}
|
||||
configuredSecrets={configuredSecrets}
|
||||
|
|
@ -136,7 +143,7 @@ const CoordinationRedisSettings: React.FC = () => {
|
|||
{redisType === "sentinel" && (
|
||||
<div className="pt-4 border-t border-border">
|
||||
<CoordinationRedisFieldSection
|
||||
title="Sentinel Configuration"
|
||||
title={t("caching.coordinationRedis.sections.sentinel")}
|
||||
section="sentinel"
|
||||
redisType={redisType}
|
||||
configuredSecrets={configuredSecrets}
|
||||
|
|
@ -146,7 +153,7 @@ const CoordinationRedisSettings: React.FC = () => {
|
|||
|
||||
<div className="pt-4 border-t border-border">
|
||||
<CoordinationRedisFieldSection
|
||||
title="SSL Settings"
|
||||
title={t("caching.coordinationRedis.sections.ssl")}
|
||||
section="ssl"
|
||||
redisType={redisType}
|
||||
configuredSecrets={configuredSecrets}
|
||||
|
|
@ -158,11 +165,15 @@ const CoordinationRedisSettings: React.FC = () => {
|
|||
<div className="border-t border-border pt-6 flex justify-end gap-3">
|
||||
<Button variant="outline" onClick={handleTestConnection} disabled={testConnection.isPending}>
|
||||
{testConnection.isPending && <UiLoadingSpinner className="size-4" />}
|
||||
{testConnection.isPending ? "Testing..." : "Test Connection"}
|
||||
{testConnection.isPending
|
||||
? t("caching.coordinationRedis.testing")
|
||||
: t("caching.coordinationRedis.testConnection")}
|
||||
</Button>
|
||||
<Button onClick={handleSaveChanges} disabled={updateSettings.isPending}>
|
||||
{updateSettings.isPending && <UiLoadingSpinner className="size-4" />}
|
||||
{updateSettings.isPending ? "Saving..." : "Save Changes"}
|
||||
{updateSettings.isPending
|
||||
? t("caching.coordinationRedis.saving")
|
||||
: t("caching.coordinationRedis.saveChanges")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface ResponseTimeIndicatorProps {
|
||||
responseTimeMs: number | null;
|
||||
}
|
||||
|
||||
export const ResponseTimeIndicator: React.FC<ResponseTimeIndicatorProps> = ({ responseTimeMs }) => {
|
||||
const { t } = useTranslation();
|
||||
if (responseTimeMs === null || responseTimeMs === undefined) return null;
|
||||
|
||||
return (
|
||||
|
|
@ -18,7 +20,7 @@ export const ResponseTimeIndicator: React.FC<ResponseTimeIndicatorProps> = ({ re
|
|||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
<span>{responseTimeMs.toFixed(0)}ms</span>
|
||||
<span>{t("caching.responseTime", { value: responseTimeMs.toFixed(0) })}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@ import { Button } from "@/components/ui/button";
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import type { ParseKeys } from "i18next";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export interface RequestTagMapping {
|
||||
header: "baggage";
|
||||
|
|
@ -25,56 +27,55 @@ const isMapping = (value: unknown): value is RequestTagMapping => {
|
|||
return "key" in value && typeof value.key === "string";
|
||||
};
|
||||
|
||||
const MODES = [
|
||||
{ value: "exact", label: "Exact value" },
|
||||
{ value: "any", label: "Any value" },
|
||||
const MODES: { value: string; label: ParseKeys }[] = [
|
||||
{ value: "exact", label: "routerSettings.requestTagMappings.modes.exact" },
|
||||
{ value: "any", label: "routerSettings.requestTagMappings.modes.any" },
|
||||
];
|
||||
|
||||
const BAGGAGE_KEY_PATTERN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
||||
|
||||
const isExact = (rule: RequestTagMapping) => rule.value !== undefined && rule.value !== null;
|
||||
|
||||
const mappingProblem = (rule: RequestTagMapping): string | null => {
|
||||
if (!rule.key) return "Enter a baggage key";
|
||||
if (!BAGGAGE_KEY_PATTERN.test(rule.key)) return "Use only letters, digits, and !#$%&'*+-.^_`|~ in the baggage key";
|
||||
const mappingProblem = (rule: RequestTagMapping): ParseKeys | null => {
|
||||
if (!rule.key) return "routerSettings.requestTagMappings.problems.keyRequired";
|
||||
if (!BAGGAGE_KEY_PATTERN.test(rule.key)) return "routerSettings.requestTagMappings.problems.keyInvalid";
|
||||
if (isExact(rule)) {
|
||||
if (!rule.value) return "Enter a match value";
|
||||
return rule.tag ? null : "Enter a tag";
|
||||
if (!rule.value) return "routerSettings.requestTagMappings.problems.valueRequired";
|
||||
return rule.tag ? null : "routerSettings.requestTagMappings.problems.tagRequired";
|
||||
}
|
||||
return rule.tag_prefix ? null : "Enter a tag prefix";
|
||||
return rule.tag_prefix ? null : "routerSettings.requestTagMappings.problems.tagPrefixRequired";
|
||||
};
|
||||
|
||||
const matchIdentity = (rule: RequestTagMapping) => JSON.stringify([rule.key, isExact(rule) ? rule.value : null]);
|
||||
|
||||
const problemAt = (rules: RequestTagMapping[], index: number): string | null => {
|
||||
const problemAt = (rules: RequestTagMapping[], index: number): ParseKeys | null => {
|
||||
const identity = matchIdentity(rules[index]);
|
||||
const duplicated = rules.slice(0, index).some((earlier) => matchIdentity(earlier) === identity);
|
||||
return mappingProblem(rules[index]) ?? (duplicated ? "Another mapping already matches this key and value" : null);
|
||||
return mappingProblem(rules[index]) ?? (duplicated ? "routerSettings.requestTagMappings.problems.duplicate" : null);
|
||||
};
|
||||
|
||||
export const requestTagMappingsAreValid = (value: unknown) =>
|
||||
Array.isArray(value) && value.every(isMapping) && value.every((_, index) => problemAt(value, index) === null);
|
||||
|
||||
export function RequestTagMappingsEditor({ value, onChange, disabled = false }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const rules = Array.isArray(value) ? value.filter(isMapping) : [];
|
||||
const update = (index: number, patch: Partial<RequestTagMapping>) =>
|
||||
onChange(rules.map((rule, position) => (position === index ? { ...rule, ...patch } : rule)));
|
||||
|
||||
return (
|
||||
<div className="min-w-80 space-y-4 whitespace-normal">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Exact value mappings take priority over any-value rules for the same key.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{t("routerSettings.requestTagMappings.priorityHint")}</p>
|
||||
{rules.map((rule, index) => {
|
||||
const exact = isExact(rule);
|
||||
const problem = problemAt(rules, index);
|
||||
const label = `Mapping ${index + 1}`;
|
||||
const label = t("routerSettings.requestTagMappings.mappingLabel", { number: index + 1 });
|
||||
return (
|
||||
<fieldset key={index} disabled={disabled} className="space-y-3 rounded-lg border p-3 disabled:opacity-60">
|
||||
<legend className="px-1 text-xs font-medium">{label}</legend>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
items={MODES}
|
||||
items={MODES.map((mode) => ({ ...mode, label: t(mode.label) }))}
|
||||
value={exact ? "exact" : "any"}
|
||||
onValueChange={(mode) =>
|
||||
update(
|
||||
|
|
@ -85,13 +86,13 @@ export function RequestTagMappingsEditor({ value, onChange, disabled = false }:
|
|||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger aria-label={`${label} mode`}>
|
||||
<SelectTrigger aria-label={t("routerSettings.requestTagMappings.modeAriaLabel", { number: index + 1 })}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MODES.map((mode) => (
|
||||
<SelectItem key={mode.value} value={mode.value}>
|
||||
{mode.label}
|
||||
{t(mode.label)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
|
@ -100,16 +101,16 @@ export function RequestTagMappingsEditor({ value, onChange, disabled = false }:
|
|||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={`Delete ${label.toLowerCase()}`}
|
||||
aria-label={t("routerSettings.requestTagMappings.deleteAriaLabel", { number: index + 1 })}
|
||||
onClick={() => onChange(rules.filter((_, position) => position !== index))}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<label className="flex flex-col gap-1 text-xs">
|
||||
Baggage key
|
||||
{t("routerSettings.requestTagMappings.baggageKey")}
|
||||
<Input
|
||||
aria-label={`${label} baggage key`}
|
||||
aria-label={t("routerSettings.requestTagMappings.baggageKeyAriaLabel", { number: index + 1 })}
|
||||
placeholder="entrypoint"
|
||||
value={rule.key}
|
||||
onChange={(event) => update(index, { key: event.target.value })}
|
||||
|
|
@ -118,31 +119,32 @@ export function RequestTagMappingsEditor({ value, onChange, disabled = false }:
|
|||
{exact ? (
|
||||
<>
|
||||
<label className="flex flex-col gap-1 text-xs">
|
||||
Match value
|
||||
{t("routerSettings.requestTagMappings.matchValue")}
|
||||
<Input
|
||||
aria-label={`${label} match value`}
|
||||
aria-label={t("routerSettings.requestTagMappings.matchValueAriaLabel", { number: index + 1 })}
|
||||
value={rule.value ?? ""}
|
||||
onChange={(event) => update(index, { value: event.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 text-xs">
|
||||
Tag
|
||||
{t("routerSettings.requestTagMappings.tag")}
|
||||
<Input
|
||||
aria-label={`${label} tag`}
|
||||
aria-label={t("routerSettings.requestTagMappings.tagAriaLabel", { number: index + 1 })}
|
||||
value={rule.tag ?? ""}
|
||||
onChange={(event) => update(index, { tag: event.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{rule.key}={rule.value || "value"} → {rule.tag || "tag"}
|
||||
{rule.key}={rule.value || t("routerSettings.requestTagMappings.previewValue")} →{" "}
|
||||
{rule.tag || t("routerSettings.requestTagMappings.previewTag")}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<label className="flex flex-col gap-1 text-xs">
|
||||
Tag prefix
|
||||
{t("routerSettings.requestTagMappings.tagPrefix")}
|
||||
<Input
|
||||
aria-label={`${label} tag prefix`}
|
||||
aria-label={t("routerSettings.requestTagMappings.tagPrefixAriaLabel", { number: index + 1 })}
|
||||
placeholder="usecase:"
|
||||
value={rule.tag_prefix ?? ""}
|
||||
onChange={(event) => update(index, { tag_prefix: event.target.value })}
|
||||
|
|
@ -153,19 +155,19 @@ export function RequestTagMappingsEditor({ value, onChange, disabled = false }:
|
|||
</p>
|
||||
</>
|
||||
)}
|
||||
{problem ? <p className="text-xs text-destructive">{problem}</p> : null}
|
||||
{problem ? <p className="text-xs text-destructive">{t(problem)}</p> : null}
|
||||
<label className="flex items-center justify-between gap-3 text-xs">
|
||||
Require a mapped value
|
||||
{t("routerSettings.requestTagMappings.required")}
|
||||
<Switch
|
||||
aria-label={`${label} required`}
|
||||
aria-label={t("routerSettings.requestTagMappings.requiredAriaLabel", { number: index + 1 })}
|
||||
checked={rule.required ?? false}
|
||||
onCheckedChange={(checked) => update(index, { required: checked })}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center justify-between gap-3 text-xs">
|
||||
Require an existing tag budget
|
||||
{t("routerSettings.requestTagMappings.requireBudget")}
|
||||
<Switch
|
||||
aria-label={`${label} require budget`}
|
||||
aria-label={t("routerSettings.requestTagMappings.requireBudgetAriaLabel", { number: index + 1 })}
|
||||
checked={rule.require_budgeted_tag ?? false}
|
||||
onCheckedChange={(checked) => update(index, { require_budgeted_tag: checked })}
|
||||
/>
|
||||
|
|
@ -174,7 +176,7 @@ export function RequestTagMappingsEditor({ value, onChange, disabled = false }:
|
|||
);
|
||||
})}
|
||||
{disabled ? (
|
||||
<p className="text-xs text-muted-foreground">Managed in config.yaml. Edit the YAML to change these rules.</p>
|
||||
<p className="text-xs text-muted-foreground">{t("routerSettings.requestTagMappings.configManaged")}</p>
|
||||
) : null}
|
||||
<Button
|
||||
disabled={disabled}
|
||||
|
|
@ -182,7 +184,7 @@ export function RequestTagMappingsEditor({ value, onChange, disabled = false }:
|
|||
variant="outline"
|
||||
onClick={() => onChange([...rules, { header: "baggage", key: "", value: "", tag: "" }])}
|
||||
>
|
||||
Add mapping
|
||||
{t("routerSettings.requestTagMappings.addMapping")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
|
@ -59,6 +60,7 @@ const SettingValueEditor: React.FC<{
|
|||
setting: generalSettingsItem;
|
||||
onChange: (fieldName: string, newValue: any) => void;
|
||||
}> = ({ setting, onChange }) => {
|
||||
const { t } = useTranslation();
|
||||
if (setting.field_name === "request_tag_mappings") {
|
||||
return (
|
||||
<RequestTagMappingsEditor
|
||||
|
|
@ -128,7 +130,7 @@ const SettingValueEditor: React.FC<{
|
|||
<Input
|
||||
key={String(setting.stored_in_db)}
|
||||
aria-label={setting.field_name}
|
||||
placeholder="Comma-separated values"
|
||||
placeholder={t("routerSettings.generalSettings.listPlaceholder")}
|
||||
defaultValue={fromListValue(setting.field_value)}
|
||||
onChange={(event) => onChange(setting.field_name, toListValue(event.target.value))}
|
||||
/>
|
||||
|
|
@ -138,10 +140,10 @@ const SettingValueEditor: React.FC<{
|
|||
return (
|
||||
<Select value={setting.field_value ?? null} onValueChange={(newValue) => onChange(setting.field_name, newValue)}>
|
||||
<SelectTrigger className="min-w-32">
|
||||
<SelectValue placeholder="Default" />
|
||||
<SelectValue placeholder={t("routerSettings.generalSettings.selectDefault")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={null}>Default</SelectItem>
|
||||
<SelectItem value={null}>{t("routerSettings.generalSettings.selectDefault")}</SelectItem>
|
||||
{(setting.field_options ?? []).map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{option}
|
||||
|
|
@ -159,6 +161,7 @@ export const PromptCachingPanel: React.FC<{
|
|||
settings: generalSettingsItem[];
|
||||
onChange: (fieldName: string, newValue: any) => void;
|
||||
}> = ({ accessToken, settings, onChange }) => {
|
||||
const { t } = useTranslation();
|
||||
const enableSetting = settings.find((s) => s.field_name === ENABLE_ANTHROPIC_PROMPT_CACHING);
|
||||
const ttlSetting = settings.find((s) => s.field_name === ANTHROPIC_PROMPT_CACHING_TTL);
|
||||
const systemFirstSetting = settings.find((s) => s.field_name === OPENAI_SYSTEM_MESSAGES_FIRST);
|
||||
|
|
@ -185,11 +188,11 @@ export const PromptCachingPanel: React.FC<{
|
|||
return (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<CardTitle>Prompt Caching</CardTitle>
|
||||
<CardTitle>{t("routerSettings.promptCaching.title")}</CardTitle>
|
||||
|
||||
<div className="mt-6 flex items-start justify-between gap-8">
|
||||
<div className="min-w-0 max-w-2xl">
|
||||
<p className="font-medium">Automatic Anthropic prompt caching</p>
|
||||
<p className="font-medium">{t("routerSettings.promptCaching.anthropicCaching")}</p>
|
||||
<p className="mt-1 break-words text-xs text-muted-foreground">{enableSetting.field_description}</p>
|
||||
</div>
|
||||
<Switch checked={enabled} onCheckedChange={(checked) => persist(ENABLE_ANTHROPIC_PROMPT_CACHING, checked)} />
|
||||
|
|
@ -198,7 +201,9 @@ export const PromptCachingPanel: React.FC<{
|
|||
{ttlSetting && (
|
||||
<div className="mt-6 flex items-start justify-between gap-8">
|
||||
<div className="min-w-0 max-w-2xl">
|
||||
<p className={`font-medium ${enabled ? "" : "text-muted-foreground"}`}>Cache lifetime (TTL)</p>
|
||||
<p className={`font-medium ${enabled ? "" : "text-muted-foreground"}`}>
|
||||
{t("routerSettings.promptCaching.cacheLifetime")}
|
||||
</p>
|
||||
<p className="mt-1 break-words text-xs text-muted-foreground">{ttlSetting.field_description}</p>
|
||||
</div>
|
||||
<Select
|
||||
|
|
@ -207,10 +212,10 @@ export const PromptCachingPanel: React.FC<{
|
|||
onValueChange={(newValue) => persist(ANTHROPIC_PROMPT_CACHING_TTL, newValue)}
|
||||
>
|
||||
<SelectTrigger className="min-w-40">
|
||||
<SelectValue placeholder="5m (default)" />
|
||||
<SelectValue placeholder={t("routerSettings.promptCaching.ttlDefault")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={null}>5m (default)</SelectItem>
|
||||
<SelectItem value={null}>{t("routerSettings.promptCaching.ttlDefault")}</SelectItem>
|
||||
{(ttlSetting.field_options ?? []).map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{option}
|
||||
|
|
@ -224,11 +229,11 @@ export const PromptCachingPanel: React.FC<{
|
|||
{systemFirstSetting && (
|
||||
<div className="mt-6 flex items-start justify-between gap-8">
|
||||
<div className="min-w-0 max-w-2xl">
|
||||
<p className="font-medium">System messages first for OpenAI</p>
|
||||
<p className="font-medium">{t("routerSettings.promptCaching.systemMessagesFirst")}</p>
|
||||
<p className="mt-1 break-words text-xs text-muted-foreground">{systemFirstSetting.field_description}</p>
|
||||
</div>
|
||||
<Switch
|
||||
aria-label="System messages first for OpenAI"
|
||||
aria-label={t("routerSettings.promptCaching.systemMessagesFirst")}
|
||||
checked={isOn(systemFirstSetting.field_value)}
|
||||
onCheckedChange={(checked) => persist(OPENAI_SYSTEM_MESSAGES_FIRST, checked)}
|
||||
/>
|
||||
|
|
@ -240,6 +245,7 @@ export const PromptCachingPanel: React.FC<{
|
|||
};
|
||||
|
||||
const GeneralSettings: React.FC<GeneralSettingsPageProps> = ({ accessToken, userRole, userID }) => {
|
||||
const { t } = useTranslation();
|
||||
const [generalSettings, setGeneralSettings] = useState<generalSettingsItem[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -313,11 +319,11 @@ const GeneralSettings: React.FC<GeneralSettingsPageProps> = ({ accessToken, user
|
|||
<div className="w-full">
|
||||
<Tabs defaultValue="loadbalancing" className="h-[75vh] w-full">
|
||||
<TabsList variant="line" className="mx-8 mt-4">
|
||||
<TabsTrigger value="loadbalancing">Loadbalancing</TabsTrigger>
|
||||
<TabsTrigger value="routing-groups">Routing Groups</TabsTrigger>
|
||||
<TabsTrigger value="fallbacks">Fallbacks</TabsTrigger>
|
||||
<TabsTrigger value="prompt-caching">Prompt Caching</TabsTrigger>
|
||||
<TabsTrigger value="general">General</TabsTrigger>
|
||||
<TabsTrigger value="loadbalancing">{t("routerSettings.generalSettings.tabs.loadbalancing")}</TabsTrigger>
|
||||
<TabsTrigger value="routing-groups">{t("routerSettings.generalSettings.tabs.routingGroups")}</TabsTrigger>
|
||||
<TabsTrigger value="fallbacks">{t("routerSettings.generalSettings.tabs.fallbacks")}</TabsTrigger>
|
||||
<TabsTrigger value="prompt-caching">{t("routerSettings.generalSettings.tabs.promptCaching")}</TabsTrigger>
|
||||
<TabsTrigger value="general">{t("routerSettings.generalSettings.tabs.general")}</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="loadbalancing" className="px-8 py-6" keepMounted>
|
||||
<RouterSettings accessToken={accessToken} userRole={userRole} userID={userID} />
|
||||
|
|
@ -337,10 +343,10 @@ const GeneralSettings: React.FC<GeneralSettingsPageProps> = ({ accessToken, user
|
|||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Setting</TableHead>
|
||||
<TableHead>Value</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Action</TableHead>
|
||||
<TableHead>{t("routerSettings.generalSettings.columns.setting")}</TableHead>
|
||||
<TableHead>{t("routerSettings.generalSettings.columns.value")}</TableHead>
|
||||
<TableHead>{t("routerSettings.generalSettings.columns.status")}</TableHead>
|
||||
<TableHead>{t("routerSettings.generalSettings.columns.action")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
|
|
@ -366,11 +372,11 @@ const GeneralSettings: React.FC<GeneralSettingsPageProps> = ({ accessToken, user
|
|||
</TableCell>
|
||||
<TableCell>
|
||||
{value.stored_in_db == true ? (
|
||||
<StatusBadge tone="success" label="In DB" />
|
||||
<StatusBadge tone="success" label={t("routerSettings.generalSettings.status.inDb")} />
|
||||
) : value.stored_in_db == false ? (
|
||||
<StatusBadge tone="neutral" label="In Config" />
|
||||
<StatusBadge tone="neutral" label={t("routerSettings.generalSettings.status.inConfig")} />
|
||||
) : (
|
||||
<StatusBadge tone="neutral" label="Not Set" />
|
||||
<StatusBadge tone="neutral" label={t("routerSettings.generalSettings.status.notSet")} />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
|
|
@ -381,7 +387,7 @@ const GeneralSettings: React.FC<GeneralSettingsPageProps> = ({ accessToken, user
|
|||
}
|
||||
onClick={() => handleUpdateField(value.field_name)}
|
||||
>
|
||||
Update
|
||||
{t("routerSettings.generalSettings.update")}
|
||||
</Button>
|
||||
{value.field_name === "request_tag_mappings" && value.editable === false ? null : (
|
||||
<span
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { SortingState, Table } from "@tanstack/react-table";
|
||||
import { Inbox, SearchX } from "lucide-react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { DataTable } from "@/components/shared/DataTable";
|
||||
import { Tag } from "@/components/tag_management/types";
|
||||
|
|
@ -21,42 +22,45 @@ interface TagTableProps {
|
|||
const DEFAULT_SORTING: SortingState = [{ id: "created_at", desc: true }];
|
||||
|
||||
function EmptyState() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1 py-6">
|
||||
<div className="mb-1 flex size-10 items-center justify-center rounded-lg bg-muted">
|
||||
<Inbox className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="text-sm font-medium text-foreground">No tags yet</div>
|
||||
<div className="text-sm text-muted-foreground">Create a tag to start routing and restricting model usage.</div>
|
||||
<div className="text-sm font-medium text-foreground">{t("tagManagement.table.empty.title")}</div>
|
||||
<div className="text-sm text-muted-foreground">{t("tagManagement.table.empty.description")}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NoMatchingTags() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1 py-6">
|
||||
<div className="mb-1 flex size-10 items-center justify-center rounded-lg bg-muted">
|
||||
<SearchX className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="text-sm font-medium text-foreground">No matching tags</div>
|
||||
<div className="text-sm text-muted-foreground">Try a different tag name or description.</div>
|
||||
<div className="text-sm font-medium text-foreground">{t("tagManagement.table.noMatch.title")}</div>
|
||||
<div className="text-sm text-muted-foreground">{t("tagManagement.table.noMatch.description")}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TagFilters({ table }: { table: Table<Tag> }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
aria-label="Filter by tag name"
|
||||
placeholder="Filter by tag name…"
|
||||
aria-label={t("tagManagement.table.filters.name")}
|
||||
placeholder={t("tagManagement.table.filters.namePlaceholder")}
|
||||
className="h-8 w-56"
|
||||
value={(table.getColumn("name")?.getFilterValue() as string | undefined) ?? ""}
|
||||
onChange={(event) => table.getColumn("name")?.setFilterValue(event.target.value)}
|
||||
/>
|
||||
<Input
|
||||
aria-label="Filter by description"
|
||||
placeholder="Filter by description…"
|
||||
aria-label={t("tagManagement.table.filters.description")}
|
||||
placeholder={t("tagManagement.table.filters.descriptionPlaceholder")}
|
||||
className="h-8 w-56"
|
||||
value={(table.getColumn("description")?.getFilterValue() as string | undefined) ?? ""}
|
||||
onChange={(event) => table.getColumn("description")?.setFilterValue(event.target.value)}
|
||||
|
|
@ -66,9 +70,13 @@ function TagFilters({ table }: { table: Table<Tag> }) {
|
|||
}
|
||||
|
||||
const TagTable: React.FC<TagTableProps> = ({ data, onEdit, onDelete, onSelectTag, isLoading = false }) => {
|
||||
const { t } = useTranslation();
|
||||
const [sorting, setSorting] = useState<SortingState>(DEFAULT_SORTING);
|
||||
|
||||
const columns = useMemo(() => getTagTableColumns({ onSelectTag, onEdit, onDelete }), [onSelectTag, onEdit, onDelete]);
|
||||
const columns = useMemo(
|
||||
() => getTagTableColumns({ onSelectTag, onEdit, onDelete }, t),
|
||||
[onSelectTag, onEdit, onDelete, t],
|
||||
);
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
|
|
@ -81,7 +89,7 @@ const TagTable: React.FC<TagTableProps> = ({ data, onEdit, onDelete, onSelectTag
|
|||
sorting={sorting}
|
||||
onSortingChange={setSorting}
|
||||
isLoading={isLoading}
|
||||
loadingMessage="Loading tags…"
|
||||
loadingMessage={t("tagManagement.table.loading")}
|
||||
filterMode="client"
|
||||
toolbar={(table) => <TagFilters table={table} />}
|
||||
noDataMessage={data.length === 0 ? <EmptyState /> : <NoMatchingTags />}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { ChevronRight, CircleHelp } from "lucide-react";
|
||||
import React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { z } from "zod";
|
||||
import BudgetDurationDropdown from "@/components/common_components/budget_duration_dropdown";
|
||||
import { FieldGroup } from "@/components/ui/field";
|
||||
|
|
@ -13,6 +14,7 @@ import { Button } from "@/components/ui/button";
|
|||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import i18n from "@/i18n/i18n";
|
||||
import { useZodForm } from "@/lib/forms/useZodForm";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
|
||||
|
|
@ -37,7 +39,7 @@ interface ModelInfo {
|
|||
}
|
||||
|
||||
const createTagShape = {
|
||||
tag_name: z.string().min(1, "Please input a tag name"),
|
||||
tag_name: z.string().min(1, { error: () => i18n.t("tagManagement.createModal.validation.nameRequired") }),
|
||||
description: z.string().optional(),
|
||||
allowed_llms: z.array(z.string()).optional(),
|
||||
max_budget: z.string().optional(),
|
||||
|
|
@ -56,6 +58,7 @@ interface CreateTagModalProps {
|
|||
}
|
||||
|
||||
const CreateTagModal: React.FC<CreateTagModalProps> = ({ visible, onCancel, onSubmit, availableModels }) => {
|
||||
const { t } = useTranslation();
|
||||
const [budgetSectionOpen, setBudgetSectionOpen] = React.useState(false);
|
||||
const form = useZodForm(createTagSchema, { defaultValues: { tag_name: "" } });
|
||||
|
||||
|
|
@ -80,16 +83,20 @@ const CreateTagModal: React.FC<CreateTagModalProps> = ({ visible, onCancel, onSu
|
|||
<Dialog open={visible} onOpenChange={(open) => !open && handleCancel()}>
|
||||
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[800px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Tag</DialogTitle>
|
||||
<DialogTitle>{t("tagManagement.createModal.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={form.handleSubmit(handleFinish)} noValidate>
|
||||
<TooltipProvider>
|
||||
<FieldGroup>
|
||||
<FormField control={form.control} name="tag_name" label="Tag Name">
|
||||
<FormField control={form.control} name="tag_name" label={t("tagManagement.createModal.fields.name")}>
|
||||
{({ ref, ...field }) => <Input {...field} ref={ref} />}
|
||||
</FormField>
|
||||
|
||||
<FormField control={form.control} name="description" label="Description">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
label={t("tagManagement.createModal.fields.description")}
|
||||
>
|
||||
{({ ref, value, ...field }) => <Textarea {...field} ref={ref} value={value ?? ""} rows={4} />}
|
||||
</FormField>
|
||||
|
||||
|
|
@ -97,8 +104,8 @@ const CreateTagModal: React.FC<CreateTagModalProps> = ({ visible, onCancel, onSu
|
|||
control={form.control}
|
||||
name="allowed_llms"
|
||||
label={labelWithHint(
|
||||
"Allowed Models",
|
||||
"Select which models are allowed to process requests from this tag",
|
||||
t("tagManagement.createModal.fields.models"),
|
||||
t("tagManagement.createModal.fields.modelsHint"),
|
||||
)}
|
||||
>
|
||||
{({ value, onChange }) => (
|
||||
|
|
@ -106,7 +113,7 @@ const CreateTagModal: React.FC<CreateTagModalProps> = ({ visible, onCancel, onSu
|
|||
options={modelOptions}
|
||||
value={value}
|
||||
onValueChange={onChange}
|
||||
placeholder="Select Models"
|
||||
placeholder={t("tagManagement.createModal.fields.modelsPlaceholder")}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
|
|
@ -118,7 +125,7 @@ const CreateTagModal: React.FC<CreateTagModalProps> = ({ visible, onCancel, onSu
|
|||
className="mt-4 mb-4 rounded-md border border-border"
|
||||
>
|
||||
<CollapsibleTrigger className="group flex w-full items-center justify-between px-4 py-3 text-base font-medium text-foreground">
|
||||
Budget & Rate Limits (Optional)
|
||||
{t("tagManagement.createModal.budgetSection")}
|
||||
<ChevronRight className="size-4 text-muted-foreground transition-transform group-data-panel-open:rotate-90" />
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="px-4 pb-4">
|
||||
|
|
@ -127,8 +134,8 @@ const CreateTagModal: React.FC<CreateTagModalProps> = ({ visible, onCancel, onSu
|
|||
control={form.control}
|
||||
name="max_budget"
|
||||
label={labelWithHint(
|
||||
"Max Budget (USD)",
|
||||
"Maximum amount in USD this tag can spend. When reached, requests with this tag will be blocked",
|
||||
t("tagManagement.createModal.fields.maxBudget"),
|
||||
t("tagManagement.createModal.fields.maxBudgetHint"),
|
||||
)}
|
||||
>
|
||||
{({ ref, value, ...field }) => <NumericalInput {...field} value={value ?? ""} step={0.01} />}
|
||||
|
|
@ -138,8 +145,8 @@ const CreateTagModal: React.FC<CreateTagModalProps> = ({ visible, onCancel, onSu
|
|||
control={form.control}
|
||||
name="budget_duration"
|
||||
label={labelWithHint(
|
||||
"Reset Budget",
|
||||
"How often the budget should reset. For example, setting 'daily' will reset the budget every 24 hours",
|
||||
t("tagManagement.createModal.fields.resetBudget"),
|
||||
t("tagManagement.createModal.fields.resetBudgetHint"),
|
||||
)}
|
||||
>
|
||||
{({ id, value, onChange }) => (
|
||||
|
|
@ -154,23 +161,26 @@ const CreateTagModal: React.FC<CreateTagModalProps> = ({ visible, onCancel, onSu
|
|||
|
||||
<div className="mt-4 rounded-md border border-border bg-muted p-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
TPM/RPM limits for tags are not currently supported. If you need this feature, please{" "}
|
||||
<a
|
||||
href="https://github.com/BerriAI/litellm/issues/new"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-info underline hover:text-info/80"
|
||||
>
|
||||
create a GitHub issue
|
||||
</a>
|
||||
.
|
||||
<Trans
|
||||
i18nKey="tagManagement.createModal.rateLimitsNotice"
|
||||
components={{
|
||||
issue: (
|
||||
<a
|
||||
href="https://github.com/BerriAI/litellm/issues/new"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-info underline hover:text-info/80"
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
<div className="mt-2.5 text-right">
|
||||
<Button type="submit">Create Tag</Button>
|
||||
<Button type="submit">{t("tagManagement.createModal.submit")}</Button>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import TagInfoView from "./tag_info";
|
||||
import { modelInfoCall } from "@/components/networking";
|
||||
|
|
@ -27,6 +28,7 @@ interface TagProps {
|
|||
}
|
||||
|
||||
const TagManagement: React.FC<TagProps> = ({ accessToken, userID, userRole }) => {
|
||||
const { t } = useTranslation();
|
||||
const [tags, setTags] = useState<Tag[]>([]);
|
||||
const [isLoadingTags, setIsLoadingTags] = useState(true);
|
||||
const [isCreateModalVisible, setIsCreateModalVisible] = useState(false);
|
||||
|
|
@ -48,7 +50,7 @@ const TagManagement: React.FC<TagProps> = ({ accessToken, userID, userRole }) =>
|
|||
setTags(Object.values(response));
|
||||
} catch (error) {
|
||||
console.error("Error fetching tags:", error);
|
||||
toast.fromError("Error fetching tags: " + error);
|
||||
toast.fromError(t("tagManagement.page.toasts.fetchTagsFailed", { error: String(error) }));
|
||||
} finally {
|
||||
setIsLoadingTags(false);
|
||||
}
|
||||
|
|
@ -73,12 +75,12 @@ const TagManagement: React.FC<TagProps> = ({ accessToken, userID, userRole }) =>
|
|||
rpm_limit: formValues.rpm_limit,
|
||||
budget_duration: formValues.budget_duration,
|
||||
});
|
||||
toast.success("Tag created successfully");
|
||||
toast.success(t("tagManagement.page.toasts.created"));
|
||||
setIsCreateModalVisible(false);
|
||||
fetchTags();
|
||||
} catch (error) {
|
||||
console.error("Error creating tag:", error);
|
||||
toast.fromError("Error creating tag: " + error);
|
||||
toast.fromError(t("tagManagement.page.toasts.createFailed", { error: String(error) }));
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -92,11 +94,11 @@ const TagManagement: React.FC<TagProps> = ({ accessToken, userID, userRole }) =>
|
|||
setIsDeleting(true);
|
||||
try {
|
||||
await tagDeleteCall(accessToken, tagToDelete);
|
||||
toast.success("Tag deleted successfully");
|
||||
toast.success(t("tagManagement.page.toasts.deleted"));
|
||||
fetchTags();
|
||||
} catch (error) {
|
||||
console.error("Error deleting tag:", error);
|
||||
toast.fromError("Error deleting tag: " + error);
|
||||
toast.fromError(t("tagManagement.page.toasts.deleteFailed", { error: String(error) }));
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
setIsDeleteModalOpen(false);
|
||||
|
|
@ -114,12 +116,12 @@ const TagManagement: React.FC<TagProps> = ({ accessToken, userID, userRole }) =>
|
|||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching models:", error);
|
||||
toast.fromError("Error fetching models: " + error);
|
||||
toast.fromError(t("tagManagement.page.toasts.fetchModelsFailed", { error: String(error) }));
|
||||
}
|
||||
};
|
||||
fetchModels();
|
||||
}
|
||||
}, [accessToken, userID, userRole]);
|
||||
}, [accessToken, userID, userRole, t]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTags();
|
||||
|
|
@ -141,29 +143,45 @@ const TagManagement: React.FC<TagProps> = ({ accessToken, userID, userRole }) =>
|
|||
) : (
|
||||
<div className="flex h-full w-full flex-col p-8 pt-10">
|
||||
<div className="mt-2 mb-4 flex w-full items-center justify-between">
|
||||
<h1>Tag Management</h1>
|
||||
<h1>{t("tagManagement.page.title")}</h1>
|
||||
<div className="flex items-center space-x-2">
|
||||
{lastRefreshed && <p className="text-sm">Last Refreshed: {lastRefreshed}</p>}
|
||||
<Button variant="outline" size="icon-sm" aria-label="Refresh tags" onClick={handleRefreshClick}>
|
||||
{lastRefreshed && (
|
||||
<p className="text-sm">
|
||||
{t("tagManagement.page.lastRefreshedPrefix")}
|
||||
{lastRefreshed}
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
aria-label={t("tagManagement.page.refresh")}
|
||||
onClick={handleRefreshClick}
|
||||
>
|
||||
<RefreshCw />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 text-sm">
|
||||
Click on a tag name to view and edit its details.
|
||||
{t("tagManagement.page.intro")}
|
||||
<p>
|
||||
You can use tags to restrict the usage of certain LLMs based on tags passed in the request. Read more
|
||||
about tag routing{" "}
|
||||
<a href="https://docs.litellm.ai/docs/proxy/tag_routing" target="_blank" rel="noopener noreferrer">
|
||||
here
|
||||
</a>
|
||||
.
|
||||
<Trans
|
||||
i18nKey="tagManagement.page.routingHelp"
|
||||
components={{
|
||||
docs: (
|
||||
<a
|
||||
href="https://docs.litellm.ai/docs/proxy/tag_routing"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button className="mb-4 self-start" onClick={() => setIsCreateModalVisible(true)}>
|
||||
+ Create New Tag
|
||||
{t("tagManagement.page.create")}
|
||||
</Button>
|
||||
|
||||
<div className="mt-2 flex min-h-0 flex-1 flex-col">
|
||||
|
|
@ -190,10 +208,12 @@ const TagManagement: React.FC<TagProps> = ({ accessToken, userID, userRole }) =>
|
|||
{/* Delete Confirmation Modal */}
|
||||
<DeleteResourceModal
|
||||
isOpen={isDeleteModalOpen}
|
||||
title="Delete Tag"
|
||||
message="Are you sure you want to delete this tag? This action cannot be undone."
|
||||
resourceInformationTitle="Tag Information"
|
||||
resourceInformation={[{ label: "Tag Name", value: tagToDelete, code: true }]}
|
||||
title={t("tagManagement.page.deleteDialog.title")}
|
||||
message={t("tagManagement.page.deleteDialog.message")}
|
||||
resourceInformationTitle={t("tagManagement.page.deleteDialog.infoTitle")}
|
||||
resourceInformation={[
|
||||
{ label: t("tagManagement.page.deleteDialog.tagName"), value: tagToDelete, code: true },
|
||||
]}
|
||||
onCancel={() => {
|
||||
setIsDeleteModalOpen(false);
|
||||
setTagToDelete(null);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import type { TFunction } from "i18next";
|
||||
import { MoreHorizontal, Pencil, Trash2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { DataTableSortHeader } from "@/components/shared/DataTable";
|
||||
import { CellTooltip, DateCell, IdentityCell } from "@/components/shared/table_cells";
|
||||
|
|
@ -22,10 +24,11 @@ export const DYNAMIC_SPEND_TAG_DESCRIPTION =
|
|||
const isDynamicSpendTag = (tag: Tag) => tag.description === DYNAMIC_SPEND_TAG_DESCRIPTION;
|
||||
|
||||
function TagNameCell({ tag, onSelectTag }: { tag: Tag; onSelectTag: (tagName: string) => void }) {
|
||||
const { t } = useTranslation();
|
||||
if (isDynamicSpendTag(tag)) {
|
||||
return (
|
||||
<CellTooltip
|
||||
content="You cannot view the information of a dynamically generated spend tag"
|
||||
content={t("tagManagement.table.dynamicTagHint")}
|
||||
trigger={<span className="block max-w-60 truncate font-mono text-xs text-muted-foreground">{tag.name}</span>}
|
||||
/>
|
||||
);
|
||||
|
|
@ -41,16 +44,17 @@ function TagNameCell({ tag, onSelectTag }: { tag: Tag; onSelectTag: (tagName: st
|
|||
}
|
||||
|
||||
function TagModelsCell({ tag }: { tag: Tag }) {
|
||||
const { t } = useTranslation();
|
||||
const models = tag.models ?? [];
|
||||
if (models.length === 0) {
|
||||
return <Badge variant="secondary">All Models</Badge>;
|
||||
return <Badge variant="secondary">{t("tagManagement.table.allModels")}</Badge>;
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{models.map((modelId) => (
|
||||
<CellTooltip
|
||||
key={modelId}
|
||||
content={`ID: ${modelId}`}
|
||||
content={t("tagManagement.table.modelId", { id: modelId })}
|
||||
trigger={
|
||||
<Badge variant="outline" className="cursor-default">
|
||||
{tag.model_info?.[modelId] || modelId}
|
||||
|
|
@ -69,12 +73,13 @@ interface TagRowActionsProps {
|
|||
}
|
||||
|
||||
function TagRowActions({ tag, onEdit, onDelete }: TagRowActionsProps) {
|
||||
const { t } = useTranslation();
|
||||
const isDynamic = isDynamicSpendTag(tag);
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
aria-label="Open tag actions"
|
||||
aria-label={t("tagManagement.table.actions.open")}
|
||||
data-testid={`tag-actions-${tag.name}`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }), "text-muted-foreground")}
|
||||
>
|
||||
|
|
@ -84,21 +89,21 @@ function TagRowActions({ tag, onEdit, onDelete }: TagRowActionsProps) {
|
|||
<DropdownMenuItem
|
||||
disabled={isDynamic}
|
||||
data-testid="tag-action-edit"
|
||||
title={isDynamic ? "Dynamically generated spend tags cannot be edited" : undefined}
|
||||
title={isDynamic ? t("tagManagement.table.actions.editDisabled") : undefined}
|
||||
onClick={() => onEdit(tag)}
|
||||
>
|
||||
<Pencil />
|
||||
Edit
|
||||
{t("tagManagement.table.actions.edit")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
disabled={isDynamic}
|
||||
data-testid="tag-action-delete"
|
||||
title={isDynamic ? "Dynamically generated spend tags cannot be deleted" : undefined}
|
||||
title={isDynamic ? t("tagManagement.table.actions.deleteDisabled") : undefined}
|
||||
onClick={() => onDelete(tag.name)}
|
||||
>
|
||||
<Trash2 />
|
||||
Delete
|
||||
{t("tagManagement.table.actions.delete")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
|
@ -111,12 +116,15 @@ interface TagTableColumnsDeps {
|
|||
onDelete: (tagName: string) => void;
|
||||
}
|
||||
|
||||
export const getTagTableColumns = ({ onSelectTag, onEdit, onDelete }: TagTableColumnsDeps): ColumnDef<Tag>[] => [
|
||||
export const getTagTableColumns = (
|
||||
{ onSelectTag, onEdit, onDelete }: TagTableColumnsDeps,
|
||||
t: TFunction,
|
||||
): ColumnDef<Tag>[] => [
|
||||
{
|
||||
id: "name",
|
||||
accessorKey: "name",
|
||||
meta: { title: "Tag Name" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Tag Name" />,
|
||||
meta: { title: t("tagManagement.table.columns.name") },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title={t("tagManagement.table.columns.name")} />,
|
||||
size: 260,
|
||||
enableSorting: true,
|
||||
filterFn: "includesString",
|
||||
|
|
@ -125,8 +133,8 @@ export const getTagTableColumns = ({ onSelectTag, onEdit, onDelete }: TagTableCo
|
|||
{
|
||||
id: "description",
|
||||
accessorKey: "description",
|
||||
meta: { title: "Description" },
|
||||
header: "Description",
|
||||
meta: { title: t("tagManagement.table.columns.description") },
|
||||
header: t("tagManagement.table.columns.description"),
|
||||
size: 300,
|
||||
enableSorting: false,
|
||||
filterFn: "includesString",
|
||||
|
|
@ -141,8 +149,8 @@ export const getTagTableColumns = ({ onSelectTag, onEdit, onDelete }: TagTableCo
|
|||
},
|
||||
{
|
||||
id: "models",
|
||||
meta: { title: "Allowed Models", skeleton: "chips" },
|
||||
header: "Allowed Models",
|
||||
meta: { title: t("tagManagement.table.columns.models"), skeleton: "chips" },
|
||||
header: t("tagManagement.table.columns.models"),
|
||||
size: 240,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <TagModelsCell tag={row.original} />,
|
||||
|
|
@ -151,8 +159,8 @@ export const getTagTableColumns = ({ onSelectTag, onEdit, onDelete }: TagTableCo
|
|||
id: "created_at",
|
||||
accessorKey: "created_at",
|
||||
sortingFn: "datetime",
|
||||
meta: { title: "Created" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Created" />,
|
||||
meta: { title: t("tagManagement.table.columns.created") },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title={t("tagManagement.table.columns.created")} />,
|
||||
size: 150,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => <DateCell value={row.original.created_at} precision="date" />,
|
||||
|
|
@ -160,7 +168,7 @@ export const getTagTableColumns = ({ onSelectTag, onEdit, onDelete }: TagTableCo
|
|||
{
|
||||
id: "actions",
|
||||
meta: { className: "text-right", headerClassName: "text-right" },
|
||||
header: () => <span className="sr-only">Actions</span>,
|
||||
header: () => <span className="sr-only">{t("tagManagement.table.columns.actions")}</span>,
|
||||
size: 64,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { SimpleTooltip } from "@/components/ui/tooltip";
|
||||
import { z } from "zod";
|
||||
import { fetchUserModels } from "@/components/organisms/create_key_button";
|
||||
|
|
@ -19,20 +20,20 @@ import { Card, CardContent, CardTitle } from "@/components/ui/card";
|
|||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import i18n from "@/i18n/i18n";
|
||||
import { useZodForm } from "@/lib/forms/useZodForm";
|
||||
import { copyToClipboard as utilCopyToClipboard } from "@/utils/dataUtils";
|
||||
import { CheckIcon, ChevronRight, CopyIcon } from "lucide-react";
|
||||
|
||||
const tagEditShape = {
|
||||
name: z.string().min(1, "Please input a tag name"),
|
||||
name: z.string().min(1, { error: () => i18n.t("tagManagement.editForm.validation.nameRequired") }),
|
||||
description: z.string().optional(),
|
||||
models: z.array(z.string()).optional(),
|
||||
max_budget: z
|
||||
.union([z.string(), z.number()])
|
||||
.refine(
|
||||
(value) => value === "" || (Number.isFinite(Number(value)) && Number(value) >= 0),
|
||||
"Enter a nonnegative budget",
|
||||
)
|
||||
.refine((value) => value === "" || (Number.isFinite(Number(value)) && Number(value) >= 0), {
|
||||
error: () => i18n.t("tagManagement.editForm.validation.budgetNonnegative"),
|
||||
})
|
||||
.optional(),
|
||||
budget_duration: z.string().nullish(),
|
||||
};
|
||||
|
|
@ -56,6 +57,7 @@ interface TagEditFormProps {
|
|||
}
|
||||
|
||||
const TagEditForm: React.FC<TagEditFormProps> = ({ tag, seedBudgetFields, userModels, onCancel, onSave }) => {
|
||||
const { t } = useTranslation();
|
||||
const [budgetSectionOpen, setBudgetSectionOpen] = useState(false);
|
||||
const form = useZodForm(tagEditSchema, {
|
||||
defaultValues: {
|
||||
|
|
@ -75,22 +77,27 @@ const TagEditForm: React.FC<TagEditFormProps> = ({ tag, seedBudgetFields, userMo
|
|||
return (
|
||||
<form onSubmit={form.handleSubmit(submitVisibleValues)} noValidate>
|
||||
<FieldGroup>
|
||||
<FormField control={form.control} name="name" label="Tag Name">
|
||||
<FormField control={form.control} name="name" label={t("tagManagement.editForm.fields.name")}>
|
||||
{({ ref, ...field }) => <Input {...field} ref={ref} />}
|
||||
</FormField>
|
||||
|
||||
<FormField control={form.control} name="description" label="Description">
|
||||
<FormField control={form.control} name="description" label={t("tagManagement.editForm.fields.description")}>
|
||||
{({ ref, value, ...field }) => <Textarea {...field} ref={ref} value={value ?? ""} rows={4} />}
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="models"
|
||||
label="Allowed Models"
|
||||
description="Select which models are allowed to process this type of data"
|
||||
label={t("tagManagement.editForm.fields.models")}
|
||||
description={t("tagManagement.editForm.fields.modelsDescription")}
|
||||
>
|
||||
{({ value, onChange }) => (
|
||||
<MultiSelect options={modelOptions} value={value} onValueChange={onChange} placeholder="Select Models" />
|
||||
<MultiSelect
|
||||
options={modelOptions}
|
||||
value={value}
|
||||
onValueChange={onChange}
|
||||
placeholder={t("tagManagement.editForm.fields.modelsPlaceholder")}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
</FieldGroup>
|
||||
|
|
@ -101,7 +108,7 @@ const TagEditForm: React.FC<TagEditFormProps> = ({ tag, seedBudgetFields, userMo
|
|||
className="mt-4 mb-4 rounded-md border border-border"
|
||||
>
|
||||
<CollapsibleTrigger className="group flex w-full items-center justify-between px-4 py-3 text-base font-medium text-foreground">
|
||||
Budget & Rate Limits
|
||||
{t("tagManagement.editForm.budgetSection")}
|
||||
<ChevronRight className="size-4 text-muted-foreground transition-transform group-data-panel-open:rotate-90" />
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="px-4 pb-4">
|
||||
|
|
@ -109,8 +116,8 @@ const TagEditForm: React.FC<TagEditFormProps> = ({ tag, seedBudgetFields, userMo
|
|||
<FormField
|
||||
control={form.control}
|
||||
name="max_budget"
|
||||
label="Max Budget (USD)"
|
||||
description="Maximum amount in USD this tag can spend"
|
||||
label={t("tagManagement.editForm.fields.maxBudget")}
|
||||
description={t("tagManagement.editForm.fields.maxBudgetDescription")}
|
||||
>
|
||||
{({ ref, value, ...field }) => <NumericalInput {...field} value={value ?? ""} step={0.01} />}
|
||||
</FormField>
|
||||
|
|
@ -118,8 +125,8 @@ const TagEditForm: React.FC<TagEditFormProps> = ({ tag, seedBudgetFields, userMo
|
|||
<FormField
|
||||
control={form.control}
|
||||
name="budget_duration"
|
||||
label="Reset Budget"
|
||||
description="How often the budget should reset"
|
||||
label={t("tagManagement.editForm.fields.resetBudget")}
|
||||
description={t("tagManagement.editForm.fields.resetBudgetDescription")}
|
||||
>
|
||||
{({ id, value, onChange }) => (
|
||||
<BudgetDurationDropdown id={id} value={value ?? null} onChange={onChange} />
|
||||
|
|
@ -129,16 +136,19 @@ const TagEditForm: React.FC<TagEditFormProps> = ({ tag, seedBudgetFields, userMo
|
|||
|
||||
<div className="mt-4 rounded-md border border-border bg-muted p-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
TPM/RPM limits for tags are not currently supported. If you need this feature, please{" "}
|
||||
<a
|
||||
href="https://github.com/BerriAI/litellm/issues/new"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-info underline hover:text-info/80"
|
||||
>
|
||||
create a GitHub issue
|
||||
</a>
|
||||
.
|
||||
<Trans
|
||||
i18nKey="tagManagement.editForm.rateLimitsNotice"
|
||||
components={{
|
||||
issue: (
|
||||
<a
|
||||
href="https://github.com/BerriAI/litellm/issues/new"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-info underline hover:text-info/80"
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
|
|
@ -146,9 +156,9 @@ const TagEditForm: React.FC<TagEditFormProps> = ({ tag, seedBudgetFields, userMo
|
|||
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
Cancel
|
||||
{t("tagManagement.editForm.cancel")}
|
||||
</Button>
|
||||
<Button type="submit">Save Changes</Button>
|
||||
<Button type="submit">{t("tagManagement.editForm.save")}</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
|
|
@ -163,6 +173,7 @@ interface TagInfoViewProps {
|
|||
}
|
||||
|
||||
const TagInfoView: React.FC<TagInfoViewProps> = ({ tagId, onClose, accessToken, is_admin, editTag }) => {
|
||||
const { t } = useTranslation();
|
||||
const [tagDetails, setTagDetails] = useState<Tag | null>(null);
|
||||
const [isEditing, setIsEditing] = useState<boolean>(editTag);
|
||||
const [userModels, setUserModels] = useState<string[]>([]);
|
||||
|
|
@ -188,7 +199,7 @@ const TagInfoView: React.FC<TagInfoViewProps> = ({ tagId, onClose, accessToken,
|
|||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching tag details:", error);
|
||||
toast.fromError("Error fetching tag details: " + error);
|
||||
toast.fromError(t("tagManagement.info.toasts.fetchFailed", { error: String(error) }));
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -216,17 +227,17 @@ const TagInfoView: React.FC<TagInfoViewProps> = ({ tagId, onClose, accessToken,
|
|||
rpm_limit: undefined,
|
||||
budget_duration: values.budget_duration,
|
||||
});
|
||||
toast.success("Tag updated successfully");
|
||||
toast.success(t("tagManagement.info.toasts.updated"));
|
||||
setIsEditing(false);
|
||||
fetchTagDetails();
|
||||
} catch (error) {
|
||||
console.error("Error updating tag:", error);
|
||||
toast.fromError("Error updating tag: " + error);
|
||||
toast.fromError(t("tagManagement.info.toasts.updateFailed", { error: String(error) }));
|
||||
}
|
||||
};
|
||||
|
||||
if (!tagDetails) {
|
||||
return <div>Loading...</div>;
|
||||
return <div>{t("tagManagement.info.loading")}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -234,10 +245,10 @@ const TagInfoView: React.FC<TagInfoViewProps> = ({ tagId, onClose, accessToken,
|
|||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<Button onClick={onClose} className="mb-4">
|
||||
← Back to Tags
|
||||
{t("tagManagement.info.back")}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">Tag Name:</span>
|
||||
<span className="text-sm font-medium">{t("tagManagement.info.tagNameLabel")}</span>
|
||||
<span className="font-mono px-2 py-1 bg-muted rounded-sm text-sm border border-border">
|
||||
{tagDetails.name}
|
||||
</span>
|
||||
|
|
@ -254,9 +265,13 @@ const TagInfoView: React.FC<TagInfoViewProps> = ({ tagId, onClose, accessToken,
|
|||
{copiedStates["tag-name"] ? <CheckIcon size={12} /> : <CopyIcon size={12} />}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{tagDetails.description || "No description"}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{tagDetails.description || t("tagManagement.info.noDescription")}
|
||||
</p>
|
||||
</div>
|
||||
{is_admin && !isEditing && <Button onClick={() => setIsEditing(true)}>Edit Tag</Button>}
|
||||
{is_admin && !isEditing && (
|
||||
<Button onClick={() => setIsEditing(true)}>{t("tagManagement.info.editTag")}</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isEditing ? (
|
||||
|
|
@ -275,25 +290,25 @@ const TagInfoView: React.FC<TagInfoViewProps> = ({ tagId, onClose, accessToken,
|
|||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardContent>
|
||||
<CardTitle>Tag Details</CardTitle>
|
||||
<CardTitle>{t("tagManagement.info.details.title")}</CardTitle>
|
||||
<div className="space-y-4 mt-4">
|
||||
<div>
|
||||
<p className="font-medium">Name</p>
|
||||
<p className="font-medium">{t("tagManagement.info.details.name")}</p>
|
||||
<p>{tagDetails.name}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Description</p>
|
||||
<p className="font-medium">{t("tagManagement.info.details.description")}</p>
|
||||
<p>{tagDetails.description || "-"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Allowed Models</p>
|
||||
<p className="font-medium">{t("tagManagement.info.details.models")}</p>
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{!tagDetails.models || tagDetails.models.length === 0 ? (
|
||||
<Badge variant="secondary">All Models</Badge>
|
||||
<Badge variant="secondary">{t("tagManagement.info.details.allModels")}</Badge>
|
||||
) : (
|
||||
tagDetails.models.map((modelId) => (
|
||||
<Badge key={modelId} variant="secondary">
|
||||
<SimpleTooltip content={`ID: ${modelId}`}>
|
||||
<SimpleTooltip content={t("tagManagement.info.details.modelId", { id: modelId })}>
|
||||
{tagDetails.model_info?.[modelId] || modelId}
|
||||
</SimpleTooltip>
|
||||
</Badge>
|
||||
|
|
@ -302,11 +317,11 @@ const TagInfoView: React.FC<TagInfoViewProps> = ({ tagId, onClose, accessToken,
|
|||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Created</p>
|
||||
<p className="font-medium">{t("tagManagement.info.details.created")}</p>
|
||||
<p>{tagDetails.created_at ? new Date(tagDetails.created_at).toLocaleString() : "-"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Last Updated</p>
|
||||
<p className="font-medium">{t("tagManagement.info.details.lastUpdated")}</p>
|
||||
<p>{tagDetails.updated_at ? new Date(tagDetails.updated_at).toLocaleString() : "-"}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -316,32 +331,32 @@ const TagInfoView: React.FC<TagInfoViewProps> = ({ tagId, onClose, accessToken,
|
|||
{tagDetails.litellm_budget_table && (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<CardTitle>Budget & Rate Limits</CardTitle>
|
||||
<CardTitle>{t("tagManagement.info.budget.title")}</CardTitle>
|
||||
<div className="space-y-4 mt-4">
|
||||
{tagDetails.litellm_budget_table.max_budget !== undefined &&
|
||||
tagDetails.litellm_budget_table.max_budget !== null && (
|
||||
<div>
|
||||
<p className="font-medium">Max Budget</p>
|
||||
<p className="font-medium">{t("tagManagement.info.budget.maxBudget")}</p>
|
||||
<p>${tagDetails.litellm_budget_table.max_budget}</p>
|
||||
</div>
|
||||
)}
|
||||
{tagDetails.litellm_budget_table.budget_duration && (
|
||||
<div>
|
||||
<p className="font-medium">Budget Duration</p>
|
||||
<p className="font-medium">{t("tagManagement.info.budget.duration")}</p>
|
||||
<p>{tagDetails.litellm_budget_table.budget_duration}</p>
|
||||
</div>
|
||||
)}
|
||||
{tagDetails.litellm_budget_table.tpm_limit !== undefined &&
|
||||
tagDetails.litellm_budget_table.tpm_limit !== null && (
|
||||
<div>
|
||||
<p className="font-medium">TPM Limit</p>
|
||||
<p className="font-medium">{t("tagManagement.info.budget.tpmLimit")}</p>
|
||||
<p>{tagDetails.litellm_budget_table.tpm_limit.toLocaleString()}</p>
|
||||
</div>
|
||||
)}
|
||||
{tagDetails.litellm_budget_table.rpm_limit !== undefined &&
|
||||
tagDetails.litellm_budget_table.rpm_limit !== null && (
|
||||
<div>
|
||||
<p className="font-medium">RPM Limit</p>
|
||||
<p className="font-medium">{t("tagManagement.info.budget.rpmLimit")}</p>
|
||||
<p>{tagDetails.litellm_budget_table.rpm_limit.toLocaleString()}</p>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -3,12 +3,14 @@ import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
|||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import CloudZeroEmptyPlaceholder from "./CloudZeroEmptyPlaceholder";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import CloudZeroCreationModal from "./CloudZeroCreateModal";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { createQueryKeys } from "@/app/(dashboard)/hooks/common/queryKeysFactory";
|
||||
import { CloudZeroIntegrationSettings } from "./CloudZeroIntegrationSettings";
|
||||
|
||||
export default function CloudZeroCostTracking() {
|
||||
const { t } = useTranslation();
|
||||
const { accessToken } = useAuthorized();
|
||||
const { data: settings, isLoading, error } = useCloudZeroSettings(accessToken);
|
||||
const queryClient = useQueryClient();
|
||||
|
|
@ -29,7 +31,7 @@ export default function CloudZeroCostTracking() {
|
|||
return (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">Loading CloudZero settings...</p>
|
||||
<p className="text-sm text-muted-foreground">{t("loggingAndAlerts.cloudZero.page.loading")}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
|
@ -40,7 +42,8 @@ export default function CloudZeroCostTracking() {
|
|||
<Card>
|
||||
<CardContent>
|
||||
<p className="text-sm text-destructive">
|
||||
Error loading CloudZero settings: {error instanceof Error ? error.message : String(error)}
|
||||
{t("loggingAndAlerts.cloudZero.page.loadErrorPrefix")}
|
||||
{error instanceof Error ? error.message : String(error)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { z } from "zod";
|
||||
|
||||
import { useCloudZeroCreate } from "@/app/(dashboard)/hooks/cloudzero/useCloudZeroCreate";
|
||||
|
|
@ -7,6 +8,7 @@ import { FieldGroup } from "@/components/ui/field";
|
|||
import { FormField } from "@/components/shared/form/FormField";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import i18n from "@/i18n/i18n";
|
||||
import { useZodForm } from "@/lib/forms/useZodForm";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
||||
|
|
@ -22,12 +24,13 @@ interface CloudZeroCreationModalProps {
|
|||
}
|
||||
|
||||
const createSchema = z.object({
|
||||
api_key: z.string().min(1, "Please enter your CloudZero API key"),
|
||||
connection_id: z.string().min(1, "Please enter your CloudZero connection ID"),
|
||||
api_key: z.string().min(1, { error: () => i18n.t("loggingAndAlerts.cloudZero.form.apiKeyRequired") }),
|
||||
connection_id: z.string().min(1, { error: () => i18n.t("loggingAndAlerts.cloudZero.form.connectionIdRequired") }),
|
||||
timezone: z.string(),
|
||||
});
|
||||
|
||||
export default function CloudZeroCreationModal({ open, onOk, onCancel }: CloudZeroCreationModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { accessToken } = useAuthorized();
|
||||
const form = useZodForm(createSchema, { defaultValues: EMPTY_CLOUDZERO_FORM_VALUES });
|
||||
const createMutation = useCloudZeroCreate(accessToken || "");
|
||||
|
|
@ -41,12 +44,12 @@ export default function CloudZeroCreationModal({ open, onOk, onCancel }: CloudZe
|
|||
const handleSubmit = (values: CloudZeroFormValues) => {
|
||||
createMutation.mutate(buildCloudZeroPayload(values), {
|
||||
onSuccess: () => {
|
||||
toast.success("CloudZero integration created successfully");
|
||||
toast.success(t("loggingAndAlerts.cloudZero.createModal.toasts.created"));
|
||||
form.reset(EMPTY_CLOUDZERO_FORM_VALUES);
|
||||
onOk();
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "Failed to create CloudZero integration");
|
||||
toast.error(error.message || t("loggingAndAlerts.cloudZero.createModal.toasts.createFailed"));
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -60,23 +63,40 @@ export default function CloudZeroCreationModal({ open, onOk, onCancel }: CloudZe
|
|||
<Dialog open={open} onOpenChange={(open) => !open && handleCancel()}>
|
||||
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create CloudZero Integration</DialogTitle>
|
||||
<DialogTitle>{t("loggingAndAlerts.cloudZero.createModal.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<TooltipProvider>
|
||||
<form onSubmit={(event) => event.preventDefault()} noValidate>
|
||||
<FieldGroup>
|
||||
<FormField control={form.control} name="api_key" label="CloudZero API Key">
|
||||
<FormField control={form.control} name="api_key" label={t("loggingAndAlerts.cloudZero.form.apiKey")}>
|
||||
{({ ref, ...field }) => (
|
||||
<CloudZeroApiKeyInput {...field} ref={ref} placeholder="Enter your CloudZero API key" />
|
||||
<CloudZeroApiKeyInput
|
||||
{...field}
|
||||
ref={ref}
|
||||
placeholder={t("loggingAndAlerts.cloudZero.form.apiKeyPlaceholder")}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
<FormField control={form.control} name="connection_id" label="Connection ID">
|
||||
{({ ref, ...field }) => <Input {...field} ref={ref} placeholder="Enter your CloudZero connection ID" />}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="connection_id"
|
||||
label={t("loggingAndAlerts.cloudZero.form.connectionId")}
|
||||
>
|
||||
{({ ref, ...field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
ref={ref}
|
||||
placeholder={t("loggingAndAlerts.cloudZero.form.connectionIdPlaceholder")}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="timezone"
|
||||
label={labelWithHint("Timezone", "Timezone for date handling (defaults to UTC if not provided)")}
|
||||
label={labelWithHint(
|
||||
t("loggingAndAlerts.cloudZero.form.timezone"),
|
||||
t("loggingAndAlerts.cloudZero.form.timezoneHint"),
|
||||
)}
|
||||
>
|
||||
{({ ref, ...field }) => <Input {...field} ref={ref} placeholder="UTC" />}
|
||||
</FormField>
|
||||
|
|
@ -85,14 +105,16 @@ export default function CloudZeroCreationModal({ open, onOk, onCancel }: CloudZe
|
|||
</TooltipProvider>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleCancel} disabled={createMutation.isPending}>
|
||||
Cancel
|
||||
{t("loggingAndAlerts.cloudZero.createModal.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => void form.handleSubmit(handleSubmit)()}
|
||||
disabled={createMutation.isPending}
|
||||
aria-busy={createMutation.isPending}
|
||||
>
|
||||
{createMutation.isPending ? "Creating..." : "Create"}
|
||||
{createMutation.isPending
|
||||
? t("loggingAndAlerts.cloudZero.createModal.submitting")
|
||||
: t("loggingAndAlerts.cloudZero.createModal.submit")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
|
|
|||
|
|
@ -1,21 +1,23 @@
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Inbox } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface CloudZeroEmptyPlaceholderProps {
|
||||
startCreation: () => void;
|
||||
}
|
||||
|
||||
export default function CloudZeroEmptyPlaceholder({ startCreation }: CloudZeroEmptyPlaceholderProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="mx-auto mt-8 max-w-2xl rounded-lg border border-dashed border-border bg-card p-12 text-center">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Inbox className="size-10 text-muted-foreground" aria-hidden />
|
||||
<h4 className="text-base font-semibold">No CloudZero Integration Found</h4>
|
||||
<h4 className="text-base font-semibold">{t("loggingAndAlerts.cloudZero.emptyPlaceholder.title")}</h4>
|
||||
<p className="mx-auto max-w-md text-sm text-muted-foreground">
|
||||
Connect your CloudZero account to start tracking and analyzing your cloud costs directly from LiteLLM.
|
||||
{t("loggingAndAlerts.cloudZero.emptyPlaceholder.description")}
|
||||
</p>
|
||||
<Button size="lg" onClick={startCreation} className="mt-4">
|
||||
Add CloudZero Integration
|
||||
{t("loggingAndAlerts.cloudZero.emptyPlaceholder.add")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { CircleHelp, Eye, EyeOff } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
|
@ -20,6 +21,7 @@ export const CloudZeroApiKeyInput = React.forwardRef<
|
|||
HTMLInputElement,
|
||||
Omit<React.ComponentPropsWithoutRef<"input">, "type">
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const [revealed, setRevealed] = React.useState(false);
|
||||
|
||||
return (
|
||||
|
|
@ -29,7 +31,9 @@ export const CloudZeroApiKeyInput = React.forwardRef<
|
|||
<InputGroupButton
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
aria-label={revealed ? "Hide API key" : "Show API key"}
|
||||
aria-label={
|
||||
revealed ? t("loggingAndAlerts.cloudZero.form.hideApiKey") : t("loggingAndAlerts.cloudZero.form.showApiKey")
|
||||
}
|
||||
onClick={() => setRevealed((current) => !current)}
|
||||
>
|
||||
{revealed ? <EyeOff /> : <Eye />}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import { Separator } from "@/components/ui/separator";
|
|||
import { toast } from "@/lib/toast";
|
||||
import { CheckCircle, Pencil, Play, Trash2, Upload } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import CloudZeroUpdateModal from "./CloudZeroUpdateModal";
|
||||
import { CloudZeroSettings } from "./types";
|
||||
|
||||
|
|
@ -40,9 +41,13 @@ const DetailRow = ({ label, children }: DetailRowProps) => (
|
|||
</div>
|
||||
);
|
||||
|
||||
const NotConfigured = () => <span className="text-muted-foreground italic">Not configured</span>;
|
||||
const NotConfigured = () => {
|
||||
const { t } = useTranslation();
|
||||
return <span className="text-muted-foreground italic">{t("loggingAndAlerts.cloudZero.settings.notConfigured")}</span>;
|
||||
};
|
||||
|
||||
export function CloudZeroIntegrationSettings({ settings, onSettingsUpdated }: CloudZeroIntegrationSettingsProps) {
|
||||
const { t } = useTranslation();
|
||||
const { accessToken } = useAuthorized();
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
|
|
@ -59,10 +64,10 @@ export function CloudZeroIntegrationSettings({ settings, onSettingsUpdated }: Cl
|
|||
{ limit: 10 },
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
toast.success("Dry run completed successfully");
|
||||
toast.success(t("loggingAndAlerts.cloudZero.settings.toasts.dryRunSucceeded"));
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error?.message || "Failed to perform dry run");
|
||||
toast.error(error?.message || t("loggingAndAlerts.cloudZero.settings.toasts.dryRunFailed"));
|
||||
},
|
||||
},
|
||||
);
|
||||
|
|
@ -77,11 +82,11 @@ export function CloudZeroIntegrationSettings({ settings, onSettingsUpdated }: Cl
|
|||
{ operation: "replace_hourly" },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success("Data successfully exported to CloudZero");
|
||||
toast.success(t("loggingAndAlerts.cloudZero.settings.toasts.exported"));
|
||||
setIsExportConfirmOpen(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error?.message || "Failed to export data");
|
||||
toast.error(error?.message || t("loggingAndAlerts.cloudZero.settings.toasts.exportFailed"));
|
||||
},
|
||||
},
|
||||
);
|
||||
|
|
@ -109,12 +114,12 @@ export function CloudZeroIntegrationSettings({ settings, onSettingsUpdated }: Cl
|
|||
|
||||
deleteMutation.mutate(undefined, {
|
||||
onSuccess: () => {
|
||||
toast.success("CloudZero integration deleted successfully");
|
||||
toast.success(t("loggingAndAlerts.cloudZero.settings.toasts.deleted"));
|
||||
setIsDeleteModalOpen(false);
|
||||
onSettingsUpdated();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error?.message || "Failed to delete CloudZero integration");
|
||||
toast.error(error?.message || t("loggingAndAlerts.cloudZero.settings.toasts.deleteFailed"));
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -129,59 +134,66 @@ export function CloudZeroIntegrationSettings({ settings, onSettingsUpdated }: Cl
|
|||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
CloudZero Configuration
|
||||
{t("loggingAndAlerts.cloudZero.settings.title")}
|
||||
<Badge variant="secondary" className="capitalize">
|
||||
{settings.status || "Active"}
|
||||
{settings.status || t("loggingAndAlerts.cloudZero.settings.statusFallback")}
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
<CardAction className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleEdit}>
|
||||
<Pencil />
|
||||
Edit
|
||||
{t("loggingAndAlerts.cloudZero.settings.edit")}
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDeleteClick}>
|
||||
<Trash2 />
|
||||
Delete
|
||||
{t("loggingAndAlerts.cloudZero.settings.delete")}
|
||||
</Button>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<dl className="rounded-md border border-border">
|
||||
<DetailRow label="API Key (Redacted)">
|
||||
<DetailRow label={t("loggingAndAlerts.cloudZero.settings.fields.apiKey")}>
|
||||
<span className="font-mono">{settings.api_key_masked || <NotConfigured />}</span>
|
||||
</DetailRow>
|
||||
<DetailRow label="Connection ID">
|
||||
<DetailRow label={t("loggingAndAlerts.cloudZero.settings.fields.connectionId")}>
|
||||
<span className="font-mono">{settings.connection_id || <NotConfigured />}</span>
|
||||
</DetailRow>
|
||||
<DetailRow label="Timezone">
|
||||
{settings.timezone || <span className="text-muted-foreground italic">Default (UTC)</span>}
|
||||
<DetailRow label={t("loggingAndAlerts.cloudZero.settings.fields.timezone")}>
|
||||
{settings.timezone || (
|
||||
<span className="text-muted-foreground italic">
|
||||
{t("loggingAndAlerts.cloudZero.settings.defaultTimezone")}
|
||||
</span>
|
||||
)}
|
||||
</DetailRow>
|
||||
</dl>
|
||||
|
||||
<div className="mt-6 flex items-center gap-3">
|
||||
<span className="text-sm text-muted-foreground">Actions</span>
|
||||
<span className="text-sm text-muted-foreground">{t("loggingAndAlerts.cloudZero.settings.actions")}</span>
|
||||
<Separator className="flex-1" />
|
||||
</div>
|
||||
|
||||
<div className="mt-4 mb-6 flex flex-wrap gap-4">
|
||||
<Button variant="outline" onClick={handleDryRun} disabled={dryRunMutation.isPending}>
|
||||
<Play />
|
||||
Run Dry Run Simulation
|
||||
{t("loggingAndAlerts.cloudZero.settings.dryRun")}
|
||||
</Button>
|
||||
|
||||
<Button onClick={() => setIsExportConfirmOpen(true)} disabled={exportMutation.isPending}>
|
||||
<Upload />
|
||||
Export Data Now
|
||||
{t("loggingAndAlerts.cloudZero.settings.exportNow")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{dryRunResult && (
|
||||
<Alert>
|
||||
<CheckCircle />
|
||||
<AlertTitle>Dry Run Results</AlertTitle>
|
||||
<AlertTitle>{t("loggingAndAlerts.cloudZero.settings.dryRunResults")}</AlertTitle>
|
||||
<AlertDescription>
|
||||
<p>Simulation output for connection: {settings.connection_id}</p>
|
||||
<p>
|
||||
{t("loggingAndAlerts.cloudZero.settings.simulationOutputPrefix")}
|
||||
{settings.connection_id}
|
||||
</p>
|
||||
<pre className="overflow-x-auto rounded-md border border-border bg-muted p-4 font-mono text-xs text-foreground">
|
||||
{dryRunResult}
|
||||
</pre>
|
||||
|
|
@ -195,15 +207,17 @@ export function CloudZeroIntegrationSettings({ settings, onSettingsUpdated }: Cl
|
|||
<AlertDialog open={isExportConfirmOpen} onOpenChange={setIsExportConfirmOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Export Data to CloudZero</AlertDialogTitle>
|
||||
<AlertDialogTitle>{t("loggingAndAlerts.cloudZero.settings.exportDialog.title")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will push the current accumulated cost data to CloudZero. Continue?
|
||||
{t("loggingAndAlerts.cloudZero.settings.exportDialog.description")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={exportMutation.isPending}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogCancel disabled={exportMutation.isPending}>
|
||||
{t("loggingAndAlerts.cloudZero.settings.exportDialog.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<Button onClick={handleExport} disabled={exportMutation.isPending}>
|
||||
Export
|
||||
{t("loggingAndAlerts.cloudZero.settings.exportDialog.confirm")}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
|
@ -218,18 +232,18 @@ export function CloudZeroIntegrationSettings({ settings, onSettingsUpdated }: Cl
|
|||
|
||||
<DeleteResourceModal
|
||||
isOpen={isDeleteModalOpen}
|
||||
title="Delete CloudZero Integration?"
|
||||
message="Are you sure you want to delete this CloudZero integration? All associated settings and configurations will be permanently removed."
|
||||
resourceInformationTitle="Integration Details"
|
||||
title={t("loggingAndAlerts.cloudZero.settings.deleteDialog.title")}
|
||||
message={t("loggingAndAlerts.cloudZero.settings.deleteDialog.message")}
|
||||
resourceInformationTitle={t("loggingAndAlerts.cloudZero.settings.deleteDialog.infoTitle")}
|
||||
resourceInformation={[
|
||||
{
|
||||
label: "Connection ID",
|
||||
label: t("loggingAndAlerts.cloudZero.settings.deleteDialog.connectionId"),
|
||||
value: settings.connection_id,
|
||||
code: true,
|
||||
},
|
||||
{
|
||||
label: "Timezone",
|
||||
value: settings.timezone || "Default (UTC)",
|
||||
label: t("loggingAndAlerts.cloudZero.settings.deleteDialog.timezone"),
|
||||
value: settings.timezone || t("loggingAndAlerts.cloudZero.settings.defaultTimezone"),
|
||||
},
|
||||
]}
|
||||
onCancel={handleDeleteCancel}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { z } from "zod";
|
||||
|
||||
import { useCloudZeroUpdateSettings } from "@/app/(dashboard)/hooks/cloudzero/useCloudZeroSettings";
|
||||
|
|
@ -7,6 +8,7 @@ import { FieldGroup } from "@/components/ui/field";
|
|||
import { FormField } from "@/components/shared/form/FormField";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import i18n from "@/i18n/i18n";
|
||||
import { useZodForm } from "@/lib/forms/useZodForm";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
||||
|
|
@ -25,11 +27,12 @@ interface CloudZeroUpdateModalProps {
|
|||
|
||||
const updateSchema = z.object({
|
||||
api_key: z.string(),
|
||||
connection_id: z.string().min(1, "Please enter your CloudZero connection ID"),
|
||||
connection_id: z.string().min(1, { error: () => i18n.t("loggingAndAlerts.cloudZero.form.connectionIdRequired") }),
|
||||
timezone: z.string(),
|
||||
});
|
||||
|
||||
export default function CloudZeroUpdateModal({ open, onOk, onCancel, settings }: CloudZeroUpdateModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { accessToken } = useAuthorized();
|
||||
const form = useZodForm(updateSchema, { defaultValues: EMPTY_CLOUDZERO_FORM_VALUES });
|
||||
const updateMutation = useCloudZeroUpdateSettings(accessToken || "");
|
||||
|
|
@ -49,12 +52,12 @@ export default function CloudZeroUpdateModal({ open, onOk, onCancel, settings }:
|
|||
const handleSubmit = (values: CloudZeroFormValues) => {
|
||||
updateMutation.mutate(buildCloudZeroPayload(values), {
|
||||
onSuccess: () => {
|
||||
toast.success("CloudZero integration updated successfully");
|
||||
toast.success(t("loggingAndAlerts.cloudZero.updateModal.toasts.updated"));
|
||||
form.reset(EMPTY_CLOUDZERO_FORM_VALUES);
|
||||
onOk();
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "Failed to update CloudZero integration");
|
||||
toast.error(error.message || t("loggingAndAlerts.cloudZero.updateModal.toasts.updateFailed"));
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -68,7 +71,7 @@ export default function CloudZeroUpdateModal({ open, onOk, onCancel, settings }:
|
|||
<Dialog open={open} onOpenChange={(open) => !open && handleCancel()}>
|
||||
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit CloudZero Integration</DialogTitle>
|
||||
<DialogTitle>{t("loggingAndAlerts.cloudZero.updateModal.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<TooltipProvider>
|
||||
<form onSubmit={(event) => event.preventDefault()} noValidate>
|
||||
|
|
@ -76,19 +79,39 @@ export default function CloudZeroUpdateModal({ open, onOk, onCancel, settings }:
|
|||
<FormField
|
||||
control={form.control}
|
||||
name="api_key"
|
||||
label={labelWithHint("CloudZero API Key", "Leave empty to keep the existing API key")}
|
||||
label={labelWithHint(
|
||||
t("loggingAndAlerts.cloudZero.form.apiKey"),
|
||||
t("loggingAndAlerts.cloudZero.updateModal.apiKeyHint"),
|
||||
)}
|
||||
>
|
||||
{({ ref, ...field }) => (
|
||||
<CloudZeroApiKeyInput {...field} ref={ref} placeholder="Leave empty to keep existing" />
|
||||
<CloudZeroApiKeyInput
|
||||
{...field}
|
||||
ref={ref}
|
||||
placeholder={t("loggingAndAlerts.cloudZero.updateModal.apiKeyPlaceholder")}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
<FormField control={form.control} name="connection_id" label="Connection ID">
|
||||
{({ ref, ...field }) => <Input {...field} ref={ref} placeholder="Enter your CloudZero connection ID" />}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="connection_id"
|
||||
label={t("loggingAndAlerts.cloudZero.form.connectionId")}
|
||||
>
|
||||
{({ ref, ...field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
ref={ref}
|
||||
placeholder={t("loggingAndAlerts.cloudZero.form.connectionIdPlaceholder")}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="timezone"
|
||||
label={labelWithHint("Timezone", "Timezone for date handling (defaults to UTC if not provided)")}
|
||||
label={labelWithHint(
|
||||
t("loggingAndAlerts.cloudZero.form.timezone"),
|
||||
t("loggingAndAlerts.cloudZero.form.timezoneHint"),
|
||||
)}
|
||||
>
|
||||
{({ ref, ...field }) => <Input {...field} ref={ref} placeholder="UTC" />}
|
||||
</FormField>
|
||||
|
|
@ -97,14 +120,16 @@ export default function CloudZeroUpdateModal({ open, onOk, onCancel, settings }:
|
|||
</TooltipProvider>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleCancel} disabled={updateMutation.isPending}>
|
||||
Cancel
|
||||
{t("loggingAndAlerts.cloudZero.updateModal.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => void form.handleSubmit(handleSubmit)()}
|
||||
disabled={updateMutation.isPending}
|
||||
aria-busy={updateMutation.isPending}
|
||||
>
|
||||
{updateMutation.isPending ? "Updating..." : "Update"}
|
||||
{updateMutation.isPending
|
||||
? t("loggingAndAlerts.cloudZero.updateModal.submitting")
|
||||
: t("loggingAndAlerts.cloudZero.updateModal.submit")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React, { useState } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group";
|
||||
|
|
@ -21,8 +22,10 @@ interface MSTeamsSettingsProps {
|
|||
const FIELD_HELP: Record<string, React.ReactNode> = {
|
||||
MS_TEAMS_WEBHOOK_URL: (
|
||||
<>
|
||||
Incoming webhook URL for your Teams channel (Workflows or incoming webhook connector)
|
||||
<span className="text-destructive"> Required * </span>
|
||||
<Trans i18nKey="loggingAndAlerts.msTeams.help.webhookUrl" />
|
||||
<span className="text-destructive">
|
||||
<Trans i18nKey="loggingAndAlerts.msTeams.required" />
|
||||
</span>
|
||||
</>
|
||||
),
|
||||
};
|
||||
|
|
@ -30,6 +33,7 @@ const FIELD_HELP: Record<string, React.ReactNode> = {
|
|||
const SENSITIVE_FIELD_PATTERN = /(PASSWORD|SECRET|KEY|TOKEN|URL)/i;
|
||||
|
||||
const MSTeamsSettings: React.FC<MSTeamsSettingsProps> = ({ accessToken, userID, userRole, alerts }) => {
|
||||
const { t } = useTranslation();
|
||||
const [visibleFields, setVisibleFields] = useState<Record<string, boolean>>({});
|
||||
|
||||
const toggleFieldVisibility = (key: string) => {
|
||||
|
|
@ -77,7 +81,7 @@ const MSTeamsSettings: React.FC<MSTeamsSettingsProps> = ({ accessToken, userID,
|
|||
environment_variables: updatedVariables,
|
||||
};
|
||||
await setCallbacksCall(accessToken, payload);
|
||||
toast.success("MS Teams settings updated successfully");
|
||||
toast.success(t("loggingAndAlerts.msTeams.toasts.saved"));
|
||||
} catch (error) {
|
||||
toast.fromError(error);
|
||||
}
|
||||
|
|
@ -86,17 +90,21 @@ const MSTeamsSettings: React.FC<MSTeamsSettingsProps> = ({ accessToken, userID,
|
|||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Microsoft Teams Alerting Settings</CardTitle>
|
||||
<CardTitle className="text-base">{t("loggingAndAlerts.msTeams.title")}</CardTitle>
|
||||
<p className="text-sm">
|
||||
Send LiteLLM alerts to a Microsoft Teams channel via an incoming webhook. Create one from{" "}
|
||||
<a
|
||||
href="https://learn.microsoft.com/en-us/microsoftteams/platform/webhooks-and-connectors/how-to/add-incoming-webhook"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-primary underline underline-offset-4"
|
||||
>
|
||||
Microsoft Docs: incoming webhooks
|
||||
</a>
|
||||
<Trans
|
||||
i18nKey="loggingAndAlerts.msTeams.intro"
|
||||
components={{
|
||||
docs: (
|
||||
<a
|
||||
href="https://learn.microsoft.com/en-us/microsoftteams/platform/webhooks-and-connectors/how-to/add-incoming-webhook"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-primary underline underline-offset-4"
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
</CardHeader>
|
||||
|
||||
|
|
@ -122,7 +130,11 @@ const MSTeamsSettings: React.FC<MSTeamsSettingsProps> = ({ accessToken, userID,
|
|||
<InputGroupButton
|
||||
size="icon-xs"
|
||||
onClick={() => toggleFieldVisibility(key)}
|
||||
aria-label={isVisible ? "Hide credential" : "Show credential"}
|
||||
aria-label={
|
||||
isVisible
|
||||
? t("loggingAndAlerts.msTeams.hideCredential")
|
||||
: t("loggingAndAlerts.msTeams.showCredential")
|
||||
}
|
||||
>
|
||||
{isVisible ? <EyeOff /> : <Eye />}
|
||||
</InputGroupButton>
|
||||
|
|
@ -137,20 +149,20 @@ const MSTeamsSettings: React.FC<MSTeamsSettingsProps> = ({ accessToken, userID,
|
|||
))}
|
||||
|
||||
<div className="mt-6 flex gap-2">
|
||||
<Button onClick={() => handleSaveMSTeamsSettings()}>Save Changes</Button>
|
||||
<Button onClick={() => handleSaveMSTeamsSettings()}>{t("loggingAndAlerts.msTeams.saveChanges")}</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={async () => {
|
||||
if (!accessToken) return;
|
||||
try {
|
||||
await serviceHealthCheck(accessToken, "ms_teams");
|
||||
toast.success("MS Teams test alert triggered. Check your Teams channel.");
|
||||
toast.success(t("loggingAndAlerts.msTeams.toasts.testTriggered"));
|
||||
} catch (error) {
|
||||
toast.fromError(error);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Test MS Teams Alerts
|
||||
{t("loggingAndAlerts.msTeams.testAlerts")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { z } from "zod";
|
||||
import { keyCreateCall } from "./networking";
|
||||
import { CopyToClipboard } from "react-copy-to-clipboard";
|
||||
import { CircleAlert, CirclePlus, Copy, Info, KeyRound, Link } from "lucide-react";
|
||||
import { parseErrorMessage } from "./shared/errorUtils";
|
||||
import i18n from "@/i18n/i18n";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert";
|
||||
import { FieldGroup } from "@/components/ui/field";
|
||||
|
|
@ -22,12 +24,13 @@ interface SCIMConfigProps {
|
|||
}
|
||||
|
||||
const scimTokenSchema = z.object({
|
||||
key_alias: z.string().min(1, "Please enter a name for your token"),
|
||||
key_alias: z.string().min(1, { error: () => i18n.t("adminPanel.scim.token.nameRequired") }),
|
||||
});
|
||||
|
||||
type SCIMTokenFormValues = z.infer<typeof scimTokenSchema>;
|
||||
|
||||
const SCIMConfig: React.FC<SCIMConfigProps> = ({ accessToken, userID, proxySettings }) => {
|
||||
const { t } = useTranslation();
|
||||
const form = useZodForm(scimTokenSchema, { defaultValues: { key_alias: "" } });
|
||||
const [isCreatingToken, setIsCreatingToken] = useState(false);
|
||||
const [tokenData, setTokenData] = useState<any>(null);
|
||||
|
|
@ -50,7 +53,7 @@ const SCIMConfig: React.FC<SCIMConfigProps> = ({ accessToken, userID, proxySetti
|
|||
|
||||
const handleCreateSCIMToken = async (values: SCIMTokenFormValues) => {
|
||||
if (!accessToken || !userID) {
|
||||
toast.fromError("You need to be logged in to create a SCIM token");
|
||||
toast.fromError(t("adminPanel.scim.toasts.notLoggedIn"));
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -66,10 +69,10 @@ const SCIMConfig: React.FC<SCIMConfigProps> = ({ accessToken, userID, proxySetti
|
|||
|
||||
const response = await keyCreateCall(accessToken, userID, formData);
|
||||
setTokenData(response);
|
||||
toast.success("SCIM token created successfully");
|
||||
toast.success(t("adminPanel.scim.toasts.created"));
|
||||
} catch (error: any) {
|
||||
console.error("Error creating SCIM token:", error);
|
||||
toast.fromError("Failed to create SCIM token: " + parseErrorMessage(error));
|
||||
toast.fromError(t("adminPanel.scim.toasts.createFailed", { error: parseErrorMessage(error) }));
|
||||
} finally {
|
||||
setIsCreatingToken(false);
|
||||
}
|
||||
|
|
@ -80,12 +83,9 @@ const SCIMConfig: React.FC<SCIMConfigProps> = ({ accessToken, userID, proxySetti
|
|||
<Card>
|
||||
<CardContent>
|
||||
<div className="flex items-center mb-4">
|
||||
<CardTitle>SCIM Configuration</CardTitle>
|
||||
<CardTitle>{t("adminPanel.scim.title")}</CardTitle>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
System for Cross-domain Identity Management (SCIM) allows you to automatically provision and manage users
|
||||
and groups in LiteLLM.
|
||||
</p>
|
||||
<p className="text-muted-foreground">{t("adminPanel.scim.description")}</p>
|
||||
|
||||
<Separator className="my-6" />
|
||||
|
||||
|
|
@ -96,18 +96,16 @@ const SCIMConfig: React.FC<SCIMConfigProps> = ({ accessToken, userID, proxySetti
|
|||
<div className="flex items-center justify-center w-6 h-6 rounded-full bg-info/15 text-info mr-2">1</div>
|
||||
<h3 className="text-lg font-medium flex items-center">
|
||||
<Link className="h-5 w-5 mr-2" />
|
||||
SCIM Tenant URL
|
||||
{t("adminPanel.scim.tenantUrl.title")}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-muted-foreground mb-3">
|
||||
Use this URL in your identity provider SCIM integration settings.
|
||||
</p>
|
||||
<p className="text-muted-foreground mb-3">{t("adminPanel.scim.tenantUrl.description")}</p>
|
||||
<div className="flex items-center">
|
||||
<Input value={scimBaseUrl} disabled={true} readOnly className="grow" />
|
||||
<CopyToClipboard text={scimBaseUrl} onCopy={() => toast.success("URL copied to clipboard")}>
|
||||
<CopyToClipboard text={scimBaseUrl} onCopy={() => toast.success(t("adminPanel.scim.toasts.urlCopied"))}>
|
||||
<Button type="button" className="ml-2 flex items-center">
|
||||
<Copy />
|
||||
Copy
|
||||
{t("adminPanel.scim.tenantUrl.copy")}
|
||||
</Button>
|
||||
</CopyToClipboard>
|
||||
</div>
|
||||
|
|
@ -119,25 +117,24 @@ const SCIMConfig: React.FC<SCIMConfigProps> = ({ accessToken, userID, proxySetti
|
|||
<div className="flex items-center justify-center w-6 h-6 rounded-full bg-info/15 text-info mr-2">2</div>
|
||||
<h3 className="text-lg font-medium flex items-center">
|
||||
<KeyRound className="h-5 w-5 mr-2" />
|
||||
Authentication Token
|
||||
{t("adminPanel.scim.token.title")}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<Alert variant="info" className="mb-4">
|
||||
<Info />
|
||||
<AlertTitle>Using SCIM</AlertTitle>
|
||||
<AlertDescription>
|
||||
You need a SCIM token to authenticate with the SCIM API. Create one below and use it in your SCIM
|
||||
provider configuration.
|
||||
</AlertDescription>
|
||||
<AlertTitle>{t("adminPanel.scim.token.alertTitle")}</AlertTitle>
|
||||
<AlertDescription>{t("adminPanel.scim.token.alertDescription")}</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{!tokenData ? (
|
||||
<div className="bg-muted p-4 rounded-lg">
|
||||
<form onSubmit={form.handleSubmit(handleCreateSCIMToken)}>
|
||||
<FieldGroup>
|
||||
<FormField control={form.control} name="key_alias" label="Token Name">
|
||||
{({ ref, ...field }) => <Input {...field} ref={ref} placeholder="SCIM Access Token" />}
|
||||
<FormField control={form.control} name="key_alias" label={t("adminPanel.scim.token.nameLabel")}>
|
||||
{({ ref, ...field }) => (
|
||||
<Input {...field} ref={ref} placeholder={t("adminPanel.scim.token.namePlaceholder")} />
|
||||
)}
|
||||
</FormField>
|
||||
<div>
|
||||
<Button
|
||||
|
|
@ -147,7 +144,7 @@ const SCIMConfig: React.FC<SCIMConfigProps> = ({ accessToken, userID, proxySetti
|
|||
className="flex items-center"
|
||||
>
|
||||
{isCreatingToken ? <UiLoadingSpinner className="size-4" /> : <KeyRound />}
|
||||
Create SCIM Token
|
||||
{t("adminPanel.scim.token.create")}
|
||||
</Button>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
|
|
@ -157,17 +154,18 @@ const SCIMConfig: React.FC<SCIMConfigProps> = ({ accessToken, userID, proxySetti
|
|||
<Card className="block p-6 border border-warning/30 bg-warning/10">
|
||||
<div className="flex items-center mb-2 text-warning">
|
||||
<CircleAlert className="h-5 w-5 mr-2" />
|
||||
<h4 className="text-lg font-medium text-warning">Your SCIM Token</h4>
|
||||
<h4 className="text-lg font-medium text-warning">{t("adminPanel.scim.created.title")}</h4>
|
||||
</div>
|
||||
<p className="text-warning mb-4 font-medium">
|
||||
Make sure to copy this token now. You will not be able to see it again.
|
||||
</p>
|
||||
<p className="text-warning mb-4 font-medium">{t("adminPanel.scim.created.warning")}</p>
|
||||
<div className="flex items-center">
|
||||
<Input value={tokenData.key} className="grow mr-2" type="password" disabled={true} readOnly />
|
||||
<CopyToClipboard text={tokenData.key} onCopy={() => toast.success("Token copied to clipboard")}>
|
||||
<CopyToClipboard
|
||||
text={tokenData.key}
|
||||
onCopy={() => toast.success(t("adminPanel.scim.toasts.tokenCopied"))}
|
||||
>
|
||||
<Button type="button" className="flex items-center">
|
||||
<Copy />
|
||||
Copy
|
||||
{t("adminPanel.scim.created.copy")}
|
||||
</Button>
|
||||
</CopyToClipboard>
|
||||
</div>
|
||||
|
|
@ -178,7 +176,7 @@ const SCIMConfig: React.FC<SCIMConfigProps> = ({ accessToken, userID, proxySetti
|
|||
onClick={() => setTokenData(null)}
|
||||
>
|
||||
<CirclePlus />
|
||||
Create Another Token
|
||||
{t("adminPanel.scim.created.createAnother")}
|
||||
</Button>
|
||||
</Card>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import { FormProvider, useWatch, type UseFormReturn } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getSSOSettings, updateSSOSettings } from "./networking";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { parseErrorMessage } from "./shared/errorUtils";
|
||||
|
|
@ -55,6 +56,7 @@ const SSOModals: React.FC<SSOModalsProps> = ({
|
|||
accessToken,
|
||||
ssoConfigured = false, // Default to false if not provided
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [isClearConfirmModalVisible, setIsClearConfirmModalVisible] = useState(false);
|
||||
const provider = useWatch({ control: form.control, name: "sso_provider" });
|
||||
const useRoleMappings = useWatch({ control: form.control, name: "use_role_mappings" });
|
||||
|
|
@ -129,7 +131,7 @@ const SSOModals: React.FC<SSOModalsProps> = ({
|
|||
// Enhanced form submission handler
|
||||
const handleFormSubmit = async (formValues: SSOSettingsFormValues) => {
|
||||
if (!accessToken) {
|
||||
toast.fromError("No access token available");
|
||||
toast.fromError(t("adminPanel.ssoModals.toasts.noAccessToken"));
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -191,14 +193,14 @@ const SSOModals: React.FC<SSOModalsProps> = ({
|
|||
// Continue with the original flow (show instructions)
|
||||
handleShowInstructions(formValues);
|
||||
} catch (error: unknown) {
|
||||
toast.fromError("Failed to save SSO settings: " + parseErrorMessage(error));
|
||||
toast.fromError(t("adminPanel.ssoModals.toasts.saveFailed", { error: parseErrorMessage(error) }));
|
||||
}
|
||||
};
|
||||
|
||||
// Handle clearing SSO settings
|
||||
const handleClearSSO = async () => {
|
||||
if (!accessToken) {
|
||||
toast.fromError("No access token available");
|
||||
toast.fromError(t("adminPanel.ssoModals.toasts.noAccessToken"));
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -237,10 +239,10 @@ const SSOModals: React.FC<SSOModalsProps> = ({
|
|||
// Close the main SSO modal and trigger refresh
|
||||
handleAddSSOOk();
|
||||
|
||||
toast.success("SSO settings cleared successfully");
|
||||
toast.success(t("adminPanel.ssoModals.toasts.clearSuccess"));
|
||||
} catch (error) {
|
||||
console.error("Failed to clear SSO settings:", error);
|
||||
toast.fromError("Failed to clear SSO settings");
|
||||
toast.fromError(t("adminPanel.ssoModals.toasts.clearFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -250,7 +252,9 @@ const SSOModals: React.FC<SSOModalsProps> = ({
|
|||
<Dialog open={isAddSSOModalVisible} onOpenChange={(open) => !open && handleAddSSOCancel()}>
|
||||
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[800px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{ssoConfigured ? "Edit SSO Settings" : "Add SSO"}</DialogTitle>
|
||||
<DialogTitle>
|
||||
{ssoConfigured ? t("adminPanel.ssoModals.titleEdit") : t("adminPanel.ssoModals.titleAdd")}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<FormProvider {...form}>
|
||||
<form
|
||||
|
|
@ -264,7 +268,9 @@ const SSOModals: React.FC<SSOModalsProps> = ({
|
|||
{provider ? renderProviderFields(provider) : null}
|
||||
<ProxyAdminEmailField />
|
||||
<ProxyBaseUrlField />
|
||||
{showRoleMappingToggle && <MappingToggleField name="use_role_mappings" label="Use Role Mappings" />}
|
||||
{showRoleMappingToggle && (
|
||||
<MappingToggleField name="use_role_mappings" label={t("adminPanel.ssoModals.useRoleMappings")} />
|
||||
)}
|
||||
{useRoleMappings && (
|
||||
<>
|
||||
<GroupClaimField />
|
||||
|
|
@ -275,10 +281,10 @@ const SSOModals: React.FC<SSOModalsProps> = ({
|
|||
<div className="mt-4 flex items-center justify-end gap-2">
|
||||
{ssoConfigured && (
|
||||
<Button type="button" variant="secondary" onClick={() => setIsClearConfirmModalVisible(true)}>
|
||||
Clear
|
||||
{t("adminPanel.ssoModals.clear")}
|
||||
</Button>
|
||||
)}
|
||||
<Button type="submit">Save</Button>
|
||||
<Button type="submit">{t("adminPanel.ssoModals.save")}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
|
|
@ -289,16 +295,16 @@ const SSOModals: React.FC<SSOModalsProps> = ({
|
|||
<Dialog open={isClearConfirmModalVisible} onOpenChange={(open) => !open && setIsClearConfirmModalVisible(false)}>
|
||||
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Confirm Clear SSO Settings</DialogTitle>
|
||||
<DialogTitle>{t("adminPanel.ssoModals.clearConfirm.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p>Are you sure you want to clear all SSO settings? This action cannot be undone.</p>
|
||||
<p>Users will no longer be able to login using SSO after this change.</p>
|
||||
<p>{t("adminPanel.ssoModals.clearConfirm.message")}</p>
|
||||
<p>{t("adminPanel.ssoModals.clearConfirm.warning")}</p>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsClearConfirmModalVisible(false)}>
|
||||
Cancel
|
||||
{t("adminPanel.ssoModals.clearConfirm.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleClearSSO} variant="destructive">
|
||||
Yes, Clear
|
||||
{t("adminPanel.ssoModals.clearConfirm.confirm")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
|
@ -307,16 +313,16 @@ const SSOModals: React.FC<SSOModalsProps> = ({
|
|||
<Dialog open={isInstructionsModalVisible} onOpenChange={(open) => !open && handleInstructionsCancel()}>
|
||||
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[800px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>SSO Setup Instructions</DialogTitle>
|
||||
<DialogTitle>{t("adminPanel.ssoModals.instructions.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p>Follow these steps to complete the SSO setup:</p>
|
||||
<p className="text-sm mt-2">1. DO NOT Exit this TAB</p>
|
||||
<p className="text-sm mt-2">2. Open a new tab, visit your proxy base url</p>
|
||||
<p className="text-sm mt-2">3. Confirm your SSO is configured correctly and you can login on the new Tab</p>
|
||||
<p className="text-sm mt-2">4. If Step 3 is successful, you can close this tab</p>
|
||||
<p>{t("adminPanel.ssoModals.instructions.intro")}</p>
|
||||
<p className="text-sm mt-2">{t("adminPanel.ssoModals.instructions.step1")}</p>
|
||||
<p className="text-sm mt-2">{t("adminPanel.ssoModals.instructions.step2")}</p>
|
||||
<p className="text-sm mt-2">{t("adminPanel.ssoModals.instructions.step3")}</p>
|
||||
<p className="text-sm mt-2">{t("adminPanel.ssoModals.instructions.step4")}</p>
|
||||
<div style={{ textAlign: "right", marginTop: "10px" }}>
|
||||
<Button type="button" onClick={handleInstructionsOk}>
|
||||
Done
|
||||
{t("adminPanel.ssoModals.instructions.done")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
"use client";
|
||||
|
||||
import { Edit, ExternalLink, Info, KeyRound, PlugZap, Trash2 } from "lucide-react";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { testCyberArkConnection } from "@/app/(dashboard)/hooks/configOverrides/cyberArkApi";
|
||||
import { useCyberArkConfig } from "@/app/(dashboard)/hooks/configOverrides/useCyberArkConfig";
|
||||
|
|
@ -19,10 +21,10 @@ import CyberArkEmptyPlaceholder from "./CyberArkEmptyPlaceholder";
|
|||
import EditCyberArkModal from "./EditCyberArkModal";
|
||||
import { FIELD_LABELS, SENSITIVE_FIELDS } from "./constants";
|
||||
|
||||
function detectAuthMethod(values: Record<string, unknown>): string {
|
||||
if (values.cyberark_api_key) return "API Key";
|
||||
if (values.client_cert && values.client_key) return "TLS Certificate";
|
||||
return "None";
|
||||
function detectAuthMethod(values: Record<string, unknown>, t: TFunction): string {
|
||||
if (values.cyberark_api_key) return t("adminPanel.cyberArk.authMethods.apiKey");
|
||||
if (values.client_cert && values.client_key) return t("adminPanel.cyberArk.authMethods.tlsCertificate");
|
||||
return t("adminPanel.cyberArk.authMethods.none");
|
||||
}
|
||||
|
||||
function DetailRow({ children, label }: { children: React.ReactNode; label: string }) {
|
||||
|
|
@ -35,6 +37,7 @@ function DetailRow({ children, label }: { children: React.ReactNode; label: stri
|
|||
}
|
||||
|
||||
export default function CyberArk() {
|
||||
const { t } = useTranslation();
|
||||
const { accessToken } = useAuthorized();
|
||||
const { data, isLoading, isError, error } = useCyberArkConfig();
|
||||
const { mutate: deleteConfig, isPending: isDeleting } = useDeleteCyberArkConfig(accessToken);
|
||||
|
|
@ -51,7 +54,7 @@ export default function CyberArk() {
|
|||
setIsTesting(true);
|
||||
try {
|
||||
const result = await testCyberArkConnection(accessToken);
|
||||
toast.success(result.message || "Connection to CyberArk Conjur successful!");
|
||||
toast.success(result.message || t("adminPanel.cyberArk.toasts.connectionSuccess"));
|
||||
} catch (err) {
|
||||
toast.fromError(err);
|
||||
} finally {
|
||||
|
|
@ -62,7 +65,7 @@ export default function CyberArk() {
|
|||
const handleDelete = () => {
|
||||
deleteConfig(undefined, {
|
||||
onSuccess: () => {
|
||||
toast.success("CyberArk configuration deleted");
|
||||
toast.success(t("adminPanel.cyberArk.toasts.deleted"));
|
||||
setIsDeleteModalOpen(false);
|
||||
},
|
||||
onError: (err) => toast.fromError(err),
|
||||
|
|
@ -75,7 +78,11 @@ export default function CyberArk() {
|
|||
{ [clearingField]: "" },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(`${FIELD_LABELS[clearingField] ?? clearingField} cleared`);
|
||||
toast.success(
|
||||
t("adminPanel.cyberArk.toasts.fieldCleared", {
|
||||
field: (FIELD_LABELS[clearingField] && t(FIELD_LABELS[clearingField])) ?? clearingField,
|
||||
}),
|
||||
);
|
||||
setClearingField(null);
|
||||
},
|
||||
onError: (err) => toast.fromError(err),
|
||||
|
|
@ -85,7 +92,7 @@ export default function CyberArk() {
|
|||
|
||||
const renderValue = (key: string) => {
|
||||
const value = rawValues[key];
|
||||
if (!value) return <span className="text-muted-foreground italic">Not configured</span>;
|
||||
if (!value) return <span className="text-muted-foreground italic">{t("adminPanel.cyberArk.notConfigured")}</span>;
|
||||
if (!SENSITIVE_FIELDS.has(key)) return <span className="font-mono text-muted-foreground">{value}</span>;
|
||||
|
||||
return (
|
||||
|
|
@ -95,7 +102,9 @@ export default function CyberArk() {
|
|||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Clear ${FIELD_LABELS[key] ?? key}`}
|
||||
aria-label={t("adminPanel.cyberArk.actions.clearField", {
|
||||
field: (FIELD_LABELS[key] && t(FIELD_LABELS[key])) ?? key,
|
||||
})}
|
||||
onClick={() => setClearingField(key)}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
|
|
@ -109,7 +118,7 @@ export default function CyberArk() {
|
|||
const renderCard = () => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card role="status" aria-label="Loading CyberArk configuration">
|
||||
<Card role="status" aria-label={t("adminPanel.cyberArk.loadingAriaLabel")}>
|
||||
<CardContent className="space-y-3">
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<Skeleton className="h-40 w-full" />
|
||||
|
|
@ -122,7 +131,7 @@ export default function CyberArk() {
|
|||
<Card>
|
||||
<CardContent>
|
||||
<Alert variant="error">
|
||||
<AlertTitle>Could not load CyberArk configuration</AlertTitle>
|
||||
<AlertTitle>{t("adminPanel.cyberArk.errors.loadFailed")}</AlertTitle>
|
||||
{error instanceof Error && <AlertDescription>{error.message}</AlertDescription>}
|
||||
</Alert>
|
||||
</CardContent>
|
||||
|
|
@ -138,22 +147,22 @@ export default function CyberArk() {
|
|||
<CardTitle>
|
||||
<h3>CyberArk Conjur</h3>
|
||||
</CardTitle>
|
||||
<CardDescription>Manage secret manager configuration</CardDescription>
|
||||
<CardDescription>{t("adminPanel.cyberArk.description")}</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
{isConfigured && (
|
||||
<CardAction className="flex flex-wrap gap-2">
|
||||
<Button type="button" variant="outline" disabled={isTesting} onClick={handleTestConnection}>
|
||||
<PlugZap />
|
||||
{isTesting ? "Testing..." : "Test Connection"}
|
||||
{isTesting ? t("adminPanel.cyberArk.actions.testing") : t("adminPanel.cyberArk.actions.testConnection")}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={() => setIsEditModalVisible(true)}>
|
||||
<Edit />
|
||||
Edit Configuration
|
||||
{t("adminPanel.cyberArk.actions.edit")}
|
||||
</Button>
|
||||
<Button type="button" variant="destructive" onClick={() => setIsDeleteModalOpen(true)}>
|
||||
<Trash2 />
|
||||
Delete Configuration
|
||||
{t("adminPanel.cyberArk.actions.delete")}
|
||||
</Button>
|
||||
</CardAction>
|
||||
)}
|
||||
|
|
@ -162,7 +171,7 @@ export default function CyberArk() {
|
|||
{isConfigured && (
|
||||
<Alert variant="info">
|
||||
<Info />
|
||||
<AlertTitle>Configuration changes are hot-reloaded across all proxy instances</AlertTitle>
|
||||
<AlertTitle>{t("adminPanel.cyberArk.hotReloadInfo.title")}</AlertTitle>
|
||||
<AlertDescription>
|
||||
<a
|
||||
href="https://docs.litellm.ai/docs/secret_managers/cyberark"
|
||||
|
|
@ -170,7 +179,7 @@ export default function CyberArk() {
|
|||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1"
|
||||
>
|
||||
View documentation
|
||||
{t("adminPanel.cyberArk.hotReloadInfo.docsLink")}
|
||||
<ExternalLink className="size-3" />
|
||||
</a>
|
||||
</AlertDescription>
|
||||
|
|
@ -180,9 +189,9 @@ export default function CyberArk() {
|
|||
{isConfigured ? (
|
||||
fieldsToShow.length > 0 && (
|
||||
<dl className="divide-y divide-border overflow-hidden rounded-md border border-border">
|
||||
<DetailRow label="Auth Method">{detectAuthMethod(rawValues)}</DetailRow>
|
||||
<DetailRow label={t("adminPanel.cyberArk.authMethodLabel")}>{detectAuthMethod(rawValues, t)}</DetailRow>
|
||||
{fieldsToShow.map(([key]) => (
|
||||
<DetailRow key={key} label={FIELD_LABELS[key] ?? key}>
|
||||
<DetailRow key={key} label={(FIELD_LABELS[key] && t(FIELD_LABELS[key])) ?? key}>
|
||||
{renderValue(key)}
|
||||
</DetailRow>
|
||||
))}
|
||||
|
|
@ -207,21 +216,30 @@ export default function CyberArk() {
|
|||
/>
|
||||
<DeleteResourceModal
|
||||
isOpen={isDeleteModalOpen}
|
||||
title="Delete CyberArk Configuration?"
|
||||
message="Models using CyberArk secrets will lose access to their API keys until a new configuration is saved."
|
||||
resourceInformationTitle="CyberArk Configuration"
|
||||
resourceInformation={[{ label: "Conjur Server URL", value: rawValues.cyberark_api_base }]}
|
||||
title={t("adminPanel.cyberArk.deleteModal.title")}
|
||||
message={t("adminPanel.cyberArk.deleteModal.message")}
|
||||
resourceInformationTitle={t("adminPanel.cyberArk.deleteModal.resourceTitle")}
|
||||
resourceInformation={[
|
||||
{ label: t("adminPanel.cyberArk.deleteModal.serverUrl"), value: rawValues.cyberark_api_base },
|
||||
]}
|
||||
onCancel={() => setIsDeleteModalOpen(false)}
|
||||
onOk={handleDelete}
|
||||
confirmLoading={isDeleting}
|
||||
/>
|
||||
<DeleteResourceModal
|
||||
isOpen={clearingField !== null}
|
||||
title={`Clear ${clearingField ? FIELD_LABELS[clearingField] ?? clearingField : ""}?`}
|
||||
message="This will remove the stored value."
|
||||
resourceInformationTitle="Field"
|
||||
title={t("adminPanel.cyberArk.clearModal.title", {
|
||||
field: clearingField ? (FIELD_LABELS[clearingField] && t(FIELD_LABELS[clearingField])) ?? clearingField : "",
|
||||
})}
|
||||
message={t("adminPanel.cyberArk.clearModal.message")}
|
||||
resourceInformationTitle={t("adminPanel.cyberArk.clearModal.resourceTitle")}
|
||||
resourceInformation={[
|
||||
{ label: "Field", value: clearingField ? FIELD_LABELS[clearingField] ?? clearingField : "" },
|
||||
{
|
||||
label: t("adminPanel.cyberArk.clearModal.fieldLabel"),
|
||||
value: clearingField
|
||||
? (FIELD_LABELS[clearingField] && t(FIELD_LABELS[clearingField])) ?? clearingField
|
||||
: "",
|
||||
},
|
||||
]}
|
||||
onCancel={() => setClearingField(null)}
|
||||
onOk={handleClearField}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { KeyRound } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
|
|
@ -7,17 +8,18 @@ interface CyberArkEmptyPlaceholderProps {
|
|||
}
|
||||
|
||||
export default function CyberArkEmptyPlaceholder({ onAdd }: CyberArkEmptyPlaceholderProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex w-full flex-col items-center rounded-lg border border-dashed border-border bg-card p-12 text-center">
|
||||
<div className="mb-4 flex size-12 items-center justify-center rounded-full bg-muted">
|
||||
<KeyRound className="size-6 text-muted-foreground" />
|
||||
</div>
|
||||
<h4 className="text-base font-semibold text-foreground">No CyberArk Configuration Found</h4>
|
||||
<h4 className="text-base font-semibold text-foreground">{t("adminPanel.cyberArk.emptyPlaceholder.title")}</h4>
|
||||
<p className="mx-auto mt-2 max-w-md text-sm text-muted-foreground">
|
||||
Configure CyberArk Conjur to securely manage provider API keys and secrets for your LiteLLM deployment.
|
||||
{t("adminPanel.cyberArk.emptyPlaceholder.description")}
|
||||
</p>
|
||||
<Button size="lg" onClick={onAdd} className="mt-4">
|
||||
Configure CyberArk
|
||||
{t("adminPanel.cyberArk.emptyPlaceholder.configure")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@ import { useCyberArkConfig } from "@/app/(dashboard)/hooks/configOverrides/useCy
|
|||
import { useUpdateCyberArkConfig } from "@/app/(dashboard)/hooks/configOverrides/useUpdateCyberArkConfig";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { toast } from "@/lib/toast";
|
||||
import type { ParseKeys } from "i18next";
|
||||
import React, { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { z } from "zod";
|
||||
import { FieldGroup } from "@/components/ui/field";
|
||||
import { FormField } from "@/components/shared/form/FormField";
|
||||
|
|
@ -14,33 +16,34 @@ import { Input } from "@/components/ui/input";
|
|||
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { useZodForm } from "@/lib/forms/useZodForm";
|
||||
import i18n from "@/i18n/i18n";
|
||||
import { SENSITIVE_FIELDS, FIELD_LABELS } from "./constants";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
|
||||
interface CyberArkFieldGroup {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
title: ParseKeys;
|
||||
subtitle?: ParseKeys;
|
||||
fields: string[];
|
||||
}
|
||||
|
||||
const FIELD_GROUPS: CyberArkFieldGroup[] = [
|
||||
{
|
||||
title: "Connection",
|
||||
title: "adminPanel.cyberArk.editModal.groups.connection.title",
|
||||
fields: ["cyberark_api_base", "cyberark_account", "cyberark_username"],
|
||||
},
|
||||
{
|
||||
title: "API Key Authentication",
|
||||
subtitle: "Use a Conjur API key to authenticate. Only one auth method is required.",
|
||||
title: "adminPanel.cyberArk.editModal.groups.apiKey.title",
|
||||
subtitle: "adminPanel.cyberArk.editModal.groups.apiKey.subtitle",
|
||||
fields: ["cyberark_api_key"],
|
||||
},
|
||||
{
|
||||
title: "Certificate Authentication",
|
||||
subtitle: "Use a client TLS certificate and key to authenticate. Only one auth method is required.",
|
||||
title: "adminPanel.cyberArk.editModal.groups.certificate.title",
|
||||
subtitle: "adminPanel.cyberArk.editModal.groups.certificate.subtitle",
|
||||
fields: ["client_cert", "client_key"],
|
||||
},
|
||||
{
|
||||
title: "Advanced",
|
||||
subtitle: "Optional TLS and token caching settings.",
|
||||
title: "adminPanel.cyberArk.editModal.groups.advanced.title",
|
||||
subtitle: "adminPanel.cyberArk.editModal.groups.advanced.subtitle",
|
||||
fields: ["ssl_verify", "refresh_interval"],
|
||||
},
|
||||
];
|
||||
|
|
@ -54,7 +57,7 @@ const buildSchema = (fields: readonly string[]): z.ZodType<CyberArkFormValues, C
|
|||
name,
|
||||
name === "cyberark_api_base"
|
||||
? z.string().refine((value) => value.length === 0 || /^https?:\/\/.+/.test(value), {
|
||||
message: "Must start with http:// or https://",
|
||||
error: () => i18n.t("adminPanel.cyberArk.editModal.errors.invalidUrl"),
|
||||
})
|
||||
: z.string(),
|
||||
]),
|
||||
|
|
@ -68,6 +71,7 @@ interface EditCyberArkModalProps {
|
|||
}
|
||||
|
||||
const EditCyberArkModal: React.FC<EditCyberArkModalProps> = ({ isVisible, onCancel, onSuccess }) => {
|
||||
const { t } = useTranslation();
|
||||
const { accessToken } = useAuthorized();
|
||||
const { data } = useCyberArkConfig();
|
||||
const { mutate, isPending } = useUpdateCyberArkConfig(accessToken);
|
||||
|
|
@ -105,7 +109,7 @@ const EditCyberArkModal: React.FC<EditCyberArkModalProps> = ({ isVisible, onCanc
|
|||
|
||||
mutate(config, {
|
||||
onSuccess: () => {
|
||||
toast.success("CyberArk configuration updated successfully");
|
||||
toast.success(t("adminPanel.cyberArk.toasts.updated"));
|
||||
onSuccess();
|
||||
},
|
||||
onError: (err) => {
|
||||
|
|
@ -126,10 +130,17 @@ const EditCyberArkModal: React.FC<EditCyberArkModalProps> = ({ isVisible, onCanc
|
|||
const isSensitive = SENSITIVE_FIELDS.has(fieldName);
|
||||
const existingValue = rawValues[fieldName];
|
||||
const hasExistingValue = isSensitive && existingValue != null && existingValue !== "";
|
||||
const placeholder = hasExistingValue ? `Leave blank to keep existing (${existingValue})` : fieldSchema?.description;
|
||||
const placeholder = hasExistingValue
|
||||
? t("adminPanel.cyberArk.editModal.keepExistingPlaceholder", { value: existingValue })
|
||||
: fieldSchema?.description;
|
||||
|
||||
return (
|
||||
<FormField key={fieldName} control={form.control} name={fieldName} label={FIELD_LABELS[fieldName] ?? fieldName}>
|
||||
<FormField
|
||||
key={fieldName}
|
||||
control={form.control}
|
||||
name={fieldName}
|
||||
label={(FIELD_LABELS[fieldName] && t(FIELD_LABELS[fieldName])) ?? fieldName}
|
||||
>
|
||||
{({ ref, ...field }) =>
|
||||
isSensitive ? (
|
||||
<PasswordInput ref={ref} placeholder={placeholder} {...field} />
|
||||
|
|
@ -145,14 +156,14 @@ const EditCyberArkModal: React.FC<EditCyberArkModalProps> = ({ isVisible, onCanc
|
|||
<Dialog open={isVisible} onOpenChange={(open) => !open && handleCancel()}>
|
||||
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[700px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit CyberArk Configuration</DialogTitle>
|
||||
<DialogTitle>{t("adminPanel.cyberArk.editModal.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={form.handleSubmit(handleSubmit)}>
|
||||
{FIELD_GROUPS.map((group, index) => (
|
||||
<div key={group.title}>
|
||||
{index > 0 && <Separator className="my-6" />}
|
||||
<h5 className="mb-1 text-base font-semibold text-foreground">{group.title}</h5>
|
||||
{group.subtitle && <p className="mb-4 text-sm text-muted-foreground">{group.subtitle}</p>}
|
||||
<h5 className="mb-1 text-base font-semibold text-foreground">{t(group.title)}</h5>
|
||||
{group.subtitle && <p className="mb-4 text-sm text-muted-foreground">{t(group.subtitle)}</p>}
|
||||
<FieldGroup>{group.fields.map(renderField)}</FieldGroup>
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -160,11 +171,11 @@ const EditCyberArkModal: React.FC<EditCyberArkModalProps> = ({ isVisible, onCanc
|
|||
<DialogFooter>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={handleCancel} disabled={isPending}>
|
||||
Cancel
|
||||
{t("adminPanel.cyberArk.editModal.cancel")}
|
||||
</Button>
|
||||
<Button type="button" disabled={isPending} onClick={() => void form.handleSubmit(handleSubmit)()}>
|
||||
{isPending && <UiLoadingSpinner className="size-4 mr-1" />}
|
||||
{isPending ? "Saving..." : "Save"}
|
||||
{isPending ? t("adminPanel.cyberArk.editModal.saving") : t("adminPanel.cyberArk.editModal.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
import type { ParseKeys } from "i18next";
|
||||
|
||||
export const SENSITIVE_FIELDS = new Set(["cyberark_api_key", "client_key"]);
|
||||
|
||||
export const FIELD_LABELS: Record<string, string> = {
|
||||
cyberark_api_base: "Conjur Server URL",
|
||||
cyberark_account: "Account",
|
||||
cyberark_username: "Username",
|
||||
cyberark_api_key: "API Key",
|
||||
client_cert: "Client Certificate",
|
||||
client_key: "Client Key",
|
||||
ssl_verify: "SSL Verification",
|
||||
refresh_interval: "Token Refresh Interval (seconds)",
|
||||
export const FIELD_LABELS: Record<string, ParseKeys> = {
|
||||
cyberark_api_base: "adminPanel.cyberArk.fields.apiBase",
|
||||
cyberark_account: "adminPanel.cyberArk.fields.account",
|
||||
cyberark_username: "adminPanel.cyberArk.fields.username",
|
||||
cyberark_api_key: "adminPanel.cyberArk.fields.apiKey",
|
||||
client_cert: "adminPanel.cyberArk.fields.clientCert",
|
||||
client_key: "adminPanel.cyberArk.fields.clientKey",
|
||||
ssl_verify: "adminPanel.cyberArk.fields.sslVerify",
|
||||
refresh_interval: "adminPanel.cyberArk.fields.refreshInterval",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@ import { useHashicorpVaultConfig } from "@/app/(dashboard)/hooks/configOverrides
|
|||
import { useUpdateHashicorpVaultConfig } from "@/app/(dashboard)/hooks/configOverrides/useUpdateHashicorpVaultConfig";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { toast } from "@/lib/toast";
|
||||
import type { ParseKeys } from "i18next";
|
||||
import React, { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { z } from "zod";
|
||||
import { FieldGroup } from "@/components/ui/field";
|
||||
import { FormField } from "@/components/shared/form/FormField";
|
||||
|
|
@ -14,18 +16,19 @@ import { Input } from "@/components/ui/input";
|
|||
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { useZodForm } from "@/lib/forms/useZodForm";
|
||||
import i18n from "@/i18n/i18n";
|
||||
import { SENSITIVE_FIELDS, FIELD_LABELS } from "./constants";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
|
||||
interface VaultFieldGroup {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
title: ParseKeys;
|
||||
subtitle?: ParseKeys;
|
||||
fields: string[];
|
||||
}
|
||||
|
||||
const FIELD_GROUPS: VaultFieldGroup[] = [
|
||||
{
|
||||
title: "Connection",
|
||||
title: "adminPanel.hashicorpVault.editModal.groups.connection.title",
|
||||
fields: [
|
||||
"vault_addr",
|
||||
"vault_namespace",
|
||||
|
|
@ -36,18 +39,18 @@ const FIELD_GROUPS: VaultFieldGroup[] = [
|
|||
],
|
||||
},
|
||||
{
|
||||
title: "Token Authentication",
|
||||
subtitle: "Use a Vault token to authenticate. Only one auth method is required.",
|
||||
title: "adminPanel.hashicorpVault.editModal.groups.token.title",
|
||||
subtitle: "adminPanel.hashicorpVault.editModal.groups.token.subtitle",
|
||||
fields: ["vault_token"],
|
||||
},
|
||||
{
|
||||
title: "AppRole Authentication",
|
||||
subtitle: "Use AppRole credentials to authenticate. Only one auth method is required.",
|
||||
title: "adminPanel.hashicorpVault.editModal.groups.appRole.title",
|
||||
subtitle: "adminPanel.hashicorpVault.editModal.groups.appRole.subtitle",
|
||||
fields: ["approle_role_id", "approle_secret_id", "approle_mount_path"],
|
||||
},
|
||||
{
|
||||
title: "TLS",
|
||||
subtitle: "Optional client certificate for mTLS.",
|
||||
title: "adminPanel.hashicorpVault.editModal.groups.tls.title",
|
||||
subtitle: "adminPanel.hashicorpVault.editModal.groups.tls.subtitle",
|
||||
fields: ["client_cert", "client_key", "vault_cert_role"],
|
||||
},
|
||||
];
|
||||
|
|
@ -61,7 +64,7 @@ const buildSchema = (fields: readonly string[]): z.ZodType<VaultFormValues, Vaul
|
|||
name,
|
||||
name === "vault_addr"
|
||||
? z.string().refine((value) => value.length === 0 || /^https?:\/\/.+/.test(value), {
|
||||
message: "Must start with http:// or https://",
|
||||
error: () => i18n.t("adminPanel.hashicorpVault.editModal.errors.invalidUrl"),
|
||||
})
|
||||
: z.string(),
|
||||
]),
|
||||
|
|
@ -75,6 +78,7 @@ interface EditHashicorpVaultModalProps {
|
|||
}
|
||||
|
||||
const EditHashicorpVaultModal: React.FC<EditHashicorpVaultModalProps> = ({ isVisible, onCancel, onSuccess }) => {
|
||||
const { t } = useTranslation();
|
||||
const { accessToken } = useAuthorized();
|
||||
const { data } = useHashicorpVaultConfig();
|
||||
const { mutate, isPending } = useUpdateHashicorpVaultConfig(accessToken);
|
||||
|
|
@ -112,7 +116,7 @@ const EditHashicorpVaultModal: React.FC<EditHashicorpVaultModalProps> = ({ isVis
|
|||
|
||||
mutate(config, {
|
||||
onSuccess: () => {
|
||||
toast.success("Hashicorp Vault configuration updated successfully");
|
||||
toast.success(t("adminPanel.hashicorpVault.toasts.updated"));
|
||||
onSuccess();
|
||||
},
|
||||
onError: (err) => {
|
||||
|
|
@ -133,10 +137,17 @@ const EditHashicorpVaultModal: React.FC<EditHashicorpVaultModalProps> = ({ isVis
|
|||
const isSensitive = SENSITIVE_FIELDS.has(fieldName);
|
||||
const existingValue = rawValues[fieldName];
|
||||
const hasExistingValue = isSensitive && existingValue != null && existingValue !== "";
|
||||
const placeholder = hasExistingValue ? `Leave blank to keep existing (${existingValue})` : fieldSchema?.description;
|
||||
const placeholder = hasExistingValue
|
||||
? t("adminPanel.hashicorpVault.editModal.keepExistingPlaceholder", { value: existingValue })
|
||||
: fieldSchema?.description;
|
||||
|
||||
return (
|
||||
<FormField key={fieldName} control={form.control} name={fieldName} label={FIELD_LABELS[fieldName] ?? fieldName}>
|
||||
<FormField
|
||||
key={fieldName}
|
||||
control={form.control}
|
||||
name={fieldName}
|
||||
label={(FIELD_LABELS[fieldName] && t(FIELD_LABELS[fieldName])) ?? fieldName}
|
||||
>
|
||||
{({ ref, ...field }) =>
|
||||
isSensitive ? (
|
||||
<PasswordInput ref={ref} placeholder={placeholder} {...field} />
|
||||
|
|
@ -152,14 +163,14 @@ const EditHashicorpVaultModal: React.FC<EditHashicorpVaultModalProps> = ({ isVis
|
|||
<Dialog open={isVisible} onOpenChange={(open) => !open && handleCancel()}>
|
||||
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[700px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Hashicorp Vault Configuration</DialogTitle>
|
||||
<DialogTitle>{t("adminPanel.hashicorpVault.editModal.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={form.handleSubmit(handleSubmit)}>
|
||||
{FIELD_GROUPS.map((group, index) => (
|
||||
<div key={group.title}>
|
||||
{index > 0 && <Separator className="my-6" />}
|
||||
<h5 className="mb-1 text-base font-semibold text-foreground">{group.title}</h5>
|
||||
{group.subtitle && <p className="mb-4 text-sm text-muted-foreground">{group.subtitle}</p>}
|
||||
<h5 className="mb-1 text-base font-semibold text-foreground">{t(group.title)}</h5>
|
||||
{group.subtitle && <p className="mb-4 text-sm text-muted-foreground">{t(group.subtitle)}</p>}
|
||||
<FieldGroup>{group.fields.map(renderField)}</FieldGroup>
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -167,11 +178,13 @@ const EditHashicorpVaultModal: React.FC<EditHashicorpVaultModalProps> = ({ isVis
|
|||
<DialogFooter>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={handleCancel} disabled={isPending}>
|
||||
Cancel
|
||||
{t("adminPanel.hashicorpVault.editModal.cancel")}
|
||||
</Button>
|
||||
<Button type="button" disabled={isPending} onClick={() => void form.handleSubmit(handleSubmit)()}>
|
||||
{isPending && <UiLoadingSpinner className="size-4 mr-1" />}
|
||||
{isPending ? "Saving..." : "Save"}
|
||||
{isPending
|
||||
? t("adminPanel.hashicorpVault.editModal.saving")
|
||||
: t("adminPanel.hashicorpVault.editModal.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
"use client";
|
||||
|
||||
import { Edit, ExternalLink, Info, KeyRound, PlugZap, Trash2 } from "lucide-react";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { testHashicorpVaultConnection } from "@/app/(dashboard)/hooks/configOverrides/hashicorpVaultApi";
|
||||
import { useDeleteHashicorpVaultConfig } from "@/app/(dashboard)/hooks/configOverrides/useDeleteHashicorpVaultConfig";
|
||||
|
|
@ -19,11 +21,11 @@ import EditHashicorpVaultModal from "./EditHashicorpVaultModal";
|
|||
import HashicorpVaultEmptyPlaceholder from "./HashicorpVaultEmptyPlaceholder";
|
||||
import { FIELD_LABELS, SENSITIVE_FIELDS } from "./constants";
|
||||
|
||||
function detectAuthMethod(values: Record<string, unknown>): string {
|
||||
function detectAuthMethod(values: Record<string, unknown>, t: TFunction): string {
|
||||
if (values.approle_role_id || values.approle_secret_id) return "AppRole";
|
||||
if (values.client_cert && values.client_key) return "TLS Certificate";
|
||||
if (values.vault_token) return "Token";
|
||||
return "None";
|
||||
if (values.client_cert && values.client_key) return t("adminPanel.hashicorpVault.authMethods.tlsCertificate");
|
||||
if (values.vault_token) return t("adminPanel.hashicorpVault.authMethods.token");
|
||||
return t("adminPanel.hashicorpVault.authMethods.none");
|
||||
}
|
||||
|
||||
function DetailRow({ children, label }: { children: React.ReactNode; label: string }) {
|
||||
|
|
@ -36,6 +38,7 @@ function DetailRow({ children, label }: { children: React.ReactNode; label: stri
|
|||
}
|
||||
|
||||
export default function HashicorpVault() {
|
||||
const { t } = useTranslation();
|
||||
const { accessToken } = useAuthorized();
|
||||
const { data, isLoading, isError, error } = useHashicorpVaultConfig();
|
||||
const { mutate: deleteConfig, isPending: isDeleting } = useDeleteHashicorpVaultConfig(accessToken);
|
||||
|
|
@ -52,7 +55,7 @@ export default function HashicorpVault() {
|
|||
setIsTesting(true);
|
||||
try {
|
||||
const result = await testHashicorpVaultConnection(accessToken);
|
||||
toast.success(result.message || "Connection to Vault successful!");
|
||||
toast.success(result.message || t("adminPanel.hashicorpVault.toasts.connectionSuccess"));
|
||||
} catch (err) {
|
||||
toast.fromError(err);
|
||||
} finally {
|
||||
|
|
@ -63,7 +66,7 @@ export default function HashicorpVault() {
|
|||
const handleDelete = () => {
|
||||
deleteConfig(undefined, {
|
||||
onSuccess: () => {
|
||||
toast.success("Hashicorp Vault configuration deleted");
|
||||
toast.success(t("adminPanel.hashicorpVault.toasts.deleted"));
|
||||
setIsDeleteModalOpen(false);
|
||||
},
|
||||
onError: (err) => toast.fromError(err),
|
||||
|
|
@ -76,7 +79,11 @@ export default function HashicorpVault() {
|
|||
{ [clearingField]: "" },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(`${FIELD_LABELS[clearingField] ?? clearingField} cleared`);
|
||||
toast.success(
|
||||
t("adminPanel.hashicorpVault.toasts.fieldCleared", {
|
||||
field: (FIELD_LABELS[clearingField] && t(FIELD_LABELS[clearingField])) ?? clearingField,
|
||||
}),
|
||||
);
|
||||
setClearingField(null);
|
||||
},
|
||||
onError: (err) => toast.fromError(err),
|
||||
|
|
@ -86,7 +93,8 @@ export default function HashicorpVault() {
|
|||
|
||||
const renderValue = (key: string) => {
|
||||
const value = rawValues[key];
|
||||
if (!value) return <span className="text-muted-foreground italic">Not configured</span>;
|
||||
if (!value)
|
||||
return <span className="text-muted-foreground italic">{t("adminPanel.hashicorpVault.notConfigured")}</span>;
|
||||
if (!SENSITIVE_FIELDS.has(key)) return <span className="font-mono text-muted-foreground">{value}</span>;
|
||||
|
||||
return (
|
||||
|
|
@ -96,7 +104,9 @@ export default function HashicorpVault() {
|
|||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Clear ${FIELD_LABELS[key] ?? key}`}
|
||||
aria-label={t("adminPanel.hashicorpVault.actions.clearField", {
|
||||
field: (FIELD_LABELS[key] && t(FIELD_LABELS[key])) ?? key,
|
||||
})}
|
||||
onClick={() => setClearingField(key)}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
|
|
@ -110,7 +120,7 @@ export default function HashicorpVault() {
|
|||
return (
|
||||
<>
|
||||
{isLoading ? (
|
||||
<Card role="status" aria-label="Loading Hashicorp Vault configuration">
|
||||
<Card role="status" aria-label={t("adminPanel.hashicorpVault.loadingAriaLabel")}>
|
||||
<CardContent className="space-y-3">
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<Skeleton className="h-40 w-full" />
|
||||
|
|
@ -120,7 +130,7 @@ export default function HashicorpVault() {
|
|||
<Card>
|
||||
<CardContent>
|
||||
<Alert variant="error">
|
||||
<AlertTitle>Could not load Hashicorp Vault configuration</AlertTitle>
|
||||
<AlertTitle>{t("adminPanel.hashicorpVault.errors.loadFailed")}</AlertTitle>
|
||||
{error instanceof Error && <AlertDescription>{error.message}</AlertDescription>}
|
||||
</Alert>
|
||||
</CardContent>
|
||||
|
|
@ -134,22 +144,24 @@ export default function HashicorpVault() {
|
|||
<CardTitle>
|
||||
<h3>Hashicorp Vault</h3>
|
||||
</CardTitle>
|
||||
<CardDescription>Manage secret manager configuration</CardDescription>
|
||||
<CardDescription>{t("adminPanel.hashicorpVault.description")}</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
{isConfigured && (
|
||||
<CardAction className="flex flex-wrap gap-2">
|
||||
<Button type="button" variant="outline" disabled={isTesting} onClick={handleTestConnection}>
|
||||
<PlugZap />
|
||||
{isTesting ? "Testing..." : "Test Connection"}
|
||||
{isTesting
|
||||
? t("adminPanel.hashicorpVault.actions.testing")
|
||||
: t("adminPanel.hashicorpVault.actions.testConnection")}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={() => setIsEditModalVisible(true)}>
|
||||
<Edit />
|
||||
Edit Configuration
|
||||
{t("adminPanel.hashicorpVault.actions.edit")}
|
||||
</Button>
|
||||
<Button type="button" variant="destructive" onClick={() => setIsDeleteModalOpen(true)}>
|
||||
<Trash2 />
|
||||
Delete Configuration
|
||||
{t("adminPanel.hashicorpVault.actions.delete")}
|
||||
</Button>
|
||||
</CardAction>
|
||||
)}
|
||||
|
|
@ -158,7 +170,7 @@ export default function HashicorpVault() {
|
|||
{isConfigured && (
|
||||
<Alert variant="info">
|
||||
<Info />
|
||||
<AlertTitle>Secrets must be stored with the field name "key"</AlertTitle>
|
||||
<AlertTitle>{t("adminPanel.hashicorpVault.secretsInfo.title")}</AlertTitle>
|
||||
<AlertDescription>
|
||||
<code className="block font-mono">vault kv put secret/SECRET_NAME key=secret_value</code>
|
||||
<a
|
||||
|
|
@ -167,7 +179,7 @@ export default function HashicorpVault() {
|
|||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1"
|
||||
>
|
||||
View documentation
|
||||
{t("adminPanel.hashicorpVault.secretsInfo.docsLink")}
|
||||
<ExternalLink className="size-3" />
|
||||
</a>
|
||||
</AlertDescription>
|
||||
|
|
@ -177,9 +189,11 @@ export default function HashicorpVault() {
|
|||
{isConfigured ? (
|
||||
fieldsToShow.length > 0 && (
|
||||
<dl className="divide-y divide-border overflow-hidden rounded-md border border-border">
|
||||
<DetailRow label="Auth Method">{detectAuthMethod(rawValues)}</DetailRow>
|
||||
<DetailRow label={t("adminPanel.hashicorpVault.authMethodLabel")}>
|
||||
{detectAuthMethod(rawValues, t)}
|
||||
</DetailRow>
|
||||
{fieldsToShow.map(([key]) => (
|
||||
<DetailRow key={key} label={FIELD_LABELS[key] ?? key}>
|
||||
<DetailRow key={key} label={(FIELD_LABELS[key] && t(FIELD_LABELS[key])) ?? key}>
|
||||
{renderValue(key)}
|
||||
</DetailRow>
|
||||
))}
|
||||
|
|
@ -199,21 +213,30 @@ export default function HashicorpVault() {
|
|||
/>
|
||||
<DeleteResourceModal
|
||||
isOpen={isDeleteModalOpen}
|
||||
title="Delete Hashicorp Vault Configuration?"
|
||||
message="Models using Vault secrets will lose access to their API keys until a new configuration is saved."
|
||||
resourceInformationTitle="Vault Configuration"
|
||||
resourceInformation={[{ label: "Vault Address", value: rawValues.vault_addr }]}
|
||||
title={t("adminPanel.hashicorpVault.deleteModal.title")}
|
||||
message={t("adminPanel.hashicorpVault.deleteModal.message")}
|
||||
resourceInformationTitle={t("adminPanel.hashicorpVault.deleteModal.resourceTitle")}
|
||||
resourceInformation={[
|
||||
{ label: t("adminPanel.hashicorpVault.deleteModal.vaultAddress"), value: rawValues.vault_addr },
|
||||
]}
|
||||
onCancel={() => setIsDeleteModalOpen(false)}
|
||||
onOk={handleDelete}
|
||||
confirmLoading={isDeleting}
|
||||
/>
|
||||
<DeleteResourceModal
|
||||
isOpen={clearingField !== null}
|
||||
title={`Clear ${clearingField ? FIELD_LABELS[clearingField] ?? clearingField : ""}?`}
|
||||
message="This will remove the stored value."
|
||||
resourceInformationTitle="Field"
|
||||
title={t("adminPanel.hashicorpVault.clearModal.title", {
|
||||
field: clearingField ? (FIELD_LABELS[clearingField] && t(FIELD_LABELS[clearingField])) ?? clearingField : "",
|
||||
})}
|
||||
message={t("adminPanel.hashicorpVault.clearModal.message")}
|
||||
resourceInformationTitle={t("adminPanel.hashicorpVault.clearModal.resourceTitle")}
|
||||
resourceInformation={[
|
||||
{ label: "Field", value: clearingField ? FIELD_LABELS[clearingField] ?? clearingField : "" },
|
||||
{
|
||||
label: t("adminPanel.hashicorpVault.clearModal.fieldLabel"),
|
||||
value: clearingField
|
||||
? (FIELD_LABELS[clearingField] && t(FIELD_LABELS[clearingField])) ?? clearingField
|
||||
: "",
|
||||
},
|
||||
]}
|
||||
onCancel={() => setClearingField(null)}
|
||||
onOk={handleClearField}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { KeyRound } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
|
|
@ -7,17 +8,20 @@ interface HashicorpVaultEmptyPlaceholderProps {
|
|||
}
|
||||
|
||||
export default function HashicorpVaultEmptyPlaceholder({ onAdd }: HashicorpVaultEmptyPlaceholderProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex w-full flex-col items-center rounded-lg border border-dashed border-border bg-card p-12 text-center">
|
||||
<div className="mb-4 flex size-12 items-center justify-center rounded-full bg-muted">
|
||||
<KeyRound className="size-6 text-muted-foreground" />
|
||||
</div>
|
||||
<h4 className="text-base font-semibold text-foreground">No Vault Configuration Found</h4>
|
||||
<h4 className="text-base font-semibold text-foreground">
|
||||
{t("adminPanel.hashicorpVault.emptyPlaceholder.title")}
|
||||
</h4>
|
||||
<p className="mx-auto mt-2 max-w-md text-sm text-muted-foreground">
|
||||
Configure Hashicorp Vault to securely manage provider API keys and secrets for your LiteLLM deployment.
|
||||
{t("adminPanel.hashicorpVault.emptyPlaceholder.description")}
|
||||
</p>
|
||||
<Button size="lg" onClick={onAdd} className="mt-4">
|
||||
Configure Vault
|
||||
{t("adminPanel.hashicorpVault.emptyPlaceholder.configure")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,17 +1,19 @@
|
|||
import type { ParseKeys } from "i18next";
|
||||
|
||||
export const SENSITIVE_FIELDS = new Set(["vault_token", "approle_secret_id", "client_key"]);
|
||||
|
||||
export const FIELD_LABELS: Record<string, string> = {
|
||||
vault_addr: "Vault Address",
|
||||
vault_namespace: "Namespace",
|
||||
vault_login_namespace: "Login Namespace",
|
||||
vault_secret_namespace: "Secret Namespace",
|
||||
vault_mount_name: "KV Mount Name",
|
||||
vault_path_prefix: "Path Prefix",
|
||||
vault_token: "Token",
|
||||
approle_role_id: "Role ID",
|
||||
approle_secret_id: "Secret ID",
|
||||
approle_mount_path: "Mount Path",
|
||||
client_cert: "Client Certificate",
|
||||
client_key: "Client Key",
|
||||
vault_cert_role: "Certificate Role",
|
||||
export const FIELD_LABELS: Record<string, ParseKeys> = {
|
||||
vault_addr: "adminPanel.hashicorpVault.fields.vaultAddr",
|
||||
vault_namespace: "adminPanel.hashicorpVault.fields.vaultNamespace",
|
||||
vault_login_namespace: "adminPanel.hashicorpVault.fields.vaultLoginNamespace",
|
||||
vault_secret_namespace: "adminPanel.hashicorpVault.fields.vaultSecretNamespace",
|
||||
vault_mount_name: "adminPanel.hashicorpVault.fields.vaultMountName",
|
||||
vault_path_prefix: "adminPanel.hashicorpVault.fields.vaultPathPrefix",
|
||||
vault_token: "adminPanel.hashicorpVault.fields.vaultToken",
|
||||
approle_role_id: "adminPanel.hashicorpVault.fields.approleRoleId",
|
||||
approle_secret_id: "adminPanel.hashicorpVault.fields.approleSecretId",
|
||||
approle_mount_path: "adminPanel.hashicorpVault.fields.approleMountPath",
|
||||
client_cert: "adminPanel.hashicorpVault.fields.clientCert",
|
||||
client_key: "adminPanel.hashicorpVault.fields.clientKey",
|
||||
vault_cert_role: "adminPanel.hashicorpVault.fields.vaultCertRole",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -23,58 +23,56 @@ import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
|
|||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { CircleHelp, Clock } from "lucide-react";
|
||||
import type { ParseKeys } from "i18next";
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const STORE_PROMPTS_FIELD_NAME = "store_prompts_in_spend_logs";
|
||||
|
||||
interface OptionalField {
|
||||
readonly name: GeneralSettingsFieldName;
|
||||
readonly kind: "duration" | "count";
|
||||
readonly label: string;
|
||||
readonly placeholder: string;
|
||||
readonly fallbackTooltip: string;
|
||||
readonly label: ParseKeys;
|
||||
readonly placeholder: ParseKeys;
|
||||
readonly fallbackTooltip: ParseKeys;
|
||||
}
|
||||
|
||||
const OPTIONAL_FIELDS: readonly OptionalField[] = [
|
||||
{
|
||||
name: GeneralSettingsFieldName.MAXIMUM_SPEND_LOGS_RETENTION_PERIOD,
|
||||
kind: "duration",
|
||||
label: "Maximum Spend Logs Retention Period (Optional)",
|
||||
placeholder: "e.g., 7d, 30d",
|
||||
fallbackTooltip:
|
||||
"Set the maximum retention period for spend logs (e.g., '7d' for 7 days, '30d' for 30 days). Leave empty for no limit.",
|
||||
label: "adminPanel.loggingSettings.fields.retentionPeriod.label",
|
||||
placeholder: "adminPanel.loggingSettings.fields.retentionPeriod.placeholder",
|
||||
fallbackTooltip: "adminPanel.loggingSettings.fields.retentionPeriod.hint",
|
||||
},
|
||||
{
|
||||
name: GeneralSettingsFieldName.MAXIMUM_SPEND_LOGS_CLEANUP_BATCH_SIZE,
|
||||
kind: "count",
|
||||
label: "Spend Logs Cleanup Batch Size (Optional)",
|
||||
placeholder: "e.g., 1000",
|
||||
fallbackTooltip: "Rows deleted per DELETE statement during cleanup. Leave empty to use the default of 1000.",
|
||||
label: "adminPanel.loggingSettings.fields.cleanupBatchSize.label",
|
||||
placeholder: "adminPanel.loggingSettings.fields.cleanupBatchSize.placeholder",
|
||||
fallbackTooltip: "adminPanel.loggingSettings.fields.cleanupBatchSize.hint",
|
||||
},
|
||||
{
|
||||
name: GeneralSettingsFieldName.MAXIMUM_SPEND_LOGS_CLEANUP_MAX_BATCHES,
|
||||
kind: "count",
|
||||
label: "Spend Logs Cleanup Max Batches (Optional)",
|
||||
placeholder: "e.g., 500",
|
||||
fallbackTooltip:
|
||||
"Maximum number of DELETE statements run per table per cleanup run. Leave empty to use the default of 500.",
|
||||
label: "adminPanel.loggingSettings.fields.cleanupMaxBatches.label",
|
||||
placeholder: "adminPanel.loggingSettings.fields.cleanupMaxBatches.placeholder",
|
||||
fallbackTooltip: "adminPanel.loggingSettings.fields.cleanupMaxBatches.hint",
|
||||
},
|
||||
{
|
||||
name: GeneralSettingsFieldName.MAXIMUM_SPEND_LOGS_CLEANUP_RUN_BUDGET,
|
||||
kind: "duration",
|
||||
label: "Spend Logs Cleanup Run Budget (Optional)",
|
||||
placeholder: "e.g., 5m",
|
||||
fallbackTooltip:
|
||||
"Wall-clock budget for a whole cleanup run, shared across every table it cleans (e.g., '5m'). Leave empty to use the default of 5m.",
|
||||
label: "adminPanel.loggingSettings.fields.cleanupRunBudget.label",
|
||||
placeholder: "adminPanel.loggingSettings.fields.cleanupRunBudget.placeholder",
|
||||
fallbackTooltip: "adminPanel.loggingSettings.fields.cleanupRunBudget.hint",
|
||||
},
|
||||
{
|
||||
name: GeneralSettingsFieldName.MAXIMUM_SPEND_LOGS_CLEANUP_BATCH_TIMEOUT,
|
||||
kind: "duration",
|
||||
label: "Spend Logs Cleanup Batch Timeout (Optional)",
|
||||
placeholder: "e.g., 30s",
|
||||
fallbackTooltip:
|
||||
"Postgres statement and lock timeout applied to each cleanup batch, so cleanup never monopolizes a connection (e.g., '30s'). Leave empty to use the default of 30s.",
|
||||
label: "adminPanel.loggingSettings.fields.cleanupBatchTimeout.label",
|
||||
placeholder: "adminPanel.loggingSettings.fields.cleanupBatchTimeout.placeholder",
|
||||
fallbackTooltip: "adminPanel.loggingSettings.fields.cleanupBatchTimeout.hint",
|
||||
},
|
||||
];
|
||||
|
||||
|
|
@ -158,6 +156,7 @@ const LoggingSettingsForm: React.FC<LoggingSettingsFormProps> = ({
|
|||
isSaving,
|
||||
onSubmit,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const form = useForm<LoggingSettingsFormValues>({ defaultValues: initialValues });
|
||||
|
||||
return (
|
||||
|
|
@ -168,11 +167,8 @@ const LoggingSettingsForm: React.FC<LoggingSettingsFormProps> = ({
|
|||
control={form.control}
|
||||
name={STORE_PROMPTS_FIELD_NAME}
|
||||
label={labelWithHint(
|
||||
"Store Prompts in Spend Logs",
|
||||
describeField(
|
||||
STORE_PROMPTS_FIELD_NAME,
|
||||
"When enabled, prompts will be stored in spend logs for tracking and analysis purposes.",
|
||||
),
|
||||
t("adminPanel.loggingSettings.fields.storePrompts.label"),
|
||||
describeField(STORE_PROMPTS_FIELD_NAME, t("adminPanel.loggingSettings.fields.storePrompts.hint")),
|
||||
)}
|
||||
>
|
||||
{({ id, value, onChange, onBlur }) => (
|
||||
|
|
@ -185,7 +181,7 @@ const LoggingSettingsForm: React.FC<LoggingSettingsFormProps> = ({
|
|||
key={field.name}
|
||||
control={form.control}
|
||||
name={field.name}
|
||||
label={labelWithHint(field.label, describeField(field.name, field.fallbackTooltip))}
|
||||
label={labelWithHint(t(field.label), describeField(field.name, t(field.fallbackTooltip)))}
|
||||
>
|
||||
{({ ref, onChange, onBlur, ...control }) =>
|
||||
field.kind === "duration" ? (
|
||||
|
|
@ -195,7 +191,7 @@ const LoggingSettingsForm: React.FC<LoggingSettingsFormProps> = ({
|
|||
ref={ref}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
onBlur={onBlur}
|
||||
placeholder={field.placeholder}
|
||||
placeholder={t(field.placeholder)}
|
||||
/>
|
||||
<InputGroupAddon>
|
||||
<Clock />
|
||||
|
|
@ -211,7 +207,7 @@ const LoggingSettingsForm: React.FC<LoggingSettingsFormProps> = ({
|
|||
onChange(countDisplay(event.target.value));
|
||||
onBlur();
|
||||
}}
|
||||
placeholder={field.placeholder}
|
||||
placeholder={t(field.placeholder)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -220,8 +216,14 @@ const LoggingSettingsForm: React.FC<LoggingSettingsFormProps> = ({
|
|||
</FieldGroup>
|
||||
|
||||
<Button type="submit" className="mt-6" disabled={isSaving}>
|
||||
{isSaving && <UiLoadingSpinner role="img" aria-label="loading" className="size-4" />}
|
||||
{isSaving ? "Saving..." : "Save Settings"}
|
||||
{isSaving && (
|
||||
<UiLoadingSpinner
|
||||
role="img"
|
||||
aria-label={t("adminPanel.loggingSettings.savingAriaLabel")}
|
||||
className="size-4"
|
||||
/>
|
||||
)}
|
||||
{isSaving ? t("adminPanel.loggingSettings.saving") : t("adminPanel.loggingSettings.save")}
|
||||
</Button>
|
||||
</form>
|
||||
</TooltipProvider>
|
||||
|
|
@ -229,6 +231,7 @@ const LoggingSettingsForm: React.FC<LoggingSettingsFormProps> = ({
|
|||
};
|
||||
|
||||
const LoggingSettings: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { mutate, isPending } = useStoreRequestInSpendLogs();
|
||||
const { mutate: deleteField, isPending: isDeletingField } = useDeleteProxyConfigField();
|
||||
const { data: proxyConfigData, isLoading: isLoadingConfig } = useProxyConfig(ConfigType.GENERAL_SETTINGS);
|
||||
|
|
@ -295,8 +298,9 @@ const LoggingSettings: React.FC = () => {
|
|||
const updateParams = buildUpdateParams(formValues);
|
||||
const submitUpdate = () =>
|
||||
mutate(updateParams, {
|
||||
onSuccess: () => toast.success("Spend logs settings updated successfully"),
|
||||
onError: (error) => toast.fromError("Failed to save spend logs settings: " + parseErrorMessage(error)),
|
||||
onSuccess: () => toast.success(t("adminPanel.loggingSettings.toasts.updated")),
|
||||
onError: (error) =>
|
||||
toast.fromError(t("adminPanel.loggingSettings.toasts.saveFailed", { error: parseErrorMessage(error) })),
|
||||
});
|
||||
|
||||
const fieldsToClear = omittedFieldNames(updateParams, isStored);
|
||||
|
|
@ -309,7 +313,7 @@ const LoggingSettings: React.FC = () => {
|
|||
if (failed.length > 0) {
|
||||
// Reporting an unqualified success here would tell the admin a setting
|
||||
// was reset to its default while the old value is still in force.
|
||||
toast.fromError(`Failed to clear saved value for: ${failed.join(", ")}`);
|
||||
toast.fromError(t("adminPanel.loggingSettings.toasts.clearFailed", { fields: failed.join(", ") }));
|
||||
return;
|
||||
}
|
||||
submitUpdate();
|
||||
|
|
@ -319,13 +323,11 @@ const LoggingSettings: React.FC = () => {
|
|||
return (
|
||||
<Card>
|
||||
<CardHeader className="border-b">
|
||||
<CardTitle>Logging Settings</CardTitle>
|
||||
<CardTitle>{t("adminPanel.loggingSettings.title")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
<p className="mb-0 text-muted-foreground">
|
||||
Proxy-wide settings that control how request and response data are written to spend logs.
|
||||
</p>
|
||||
<p className="mb-0 text-muted-foreground">{t("adminPanel.loggingSettings.description")}</p>
|
||||
|
||||
{isLoadingConfig ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Eye, EyeOff, Pencil, Plus, Trash2 } from "lucide-react";
|
||||
import { getConfigFieldSetting, updateConfigFieldSetting } from "@/components/networking";
|
||||
|
|
@ -28,6 +29,7 @@ interface Plugin {
|
|||
const BLANK_PLUGIN: PluginFormValues = { name: "", display_name: "", url: "", plugin_key: undefined };
|
||||
|
||||
export default function PluginSettings() {
|
||||
const { t } = useTranslation();
|
||||
const { accessToken } = useAuthorized();
|
||||
const [plugins, setPlugins] = useState<Plugin[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
|
@ -102,7 +104,7 @@ export default function PluginSettings() {
|
|||
return (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="py-6 text-center text-sm text-muted-foreground">
|
||||
No data
|
||||
{t("adminPanel.pluginSettings.noData")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
|
|
@ -128,13 +130,18 @@ export default function PluginSettings() {
|
|||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="icon-sm" aria-label={`Edit ${plugin.name}`} onClick={() => openEdit(idx)}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
aria-label={t("adminPanel.pluginSettings.editAriaLabel", { name: plugin.name })}
|
||||
onClick={() => openEdit(idx)}
|
||||
>
|
||||
<Pencil />
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="icon-sm"
|
||||
aria-label={`Delete ${plugin.name}`}
|
||||
aria-label={t("adminPanel.pluginSettings.deleteAriaLabel", { name: plugin.name })}
|
||||
onClick={() => handleDelete(idx)}
|
||||
>
|
||||
<Trash2 />
|
||||
|
|
@ -148,30 +155,30 @@ export default function PluginSettings() {
|
|||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h4 className="text-base font-semibold text-foreground">Plugins</h4>
|
||||
<p className="text-sm text-foreground">
|
||||
Register external services as plugins. Once added, users can toggle to the plugin from the mode switcher in
|
||||
the top-left of the sidebar.
|
||||
</p>
|
||||
<h4 className="text-base font-semibold text-foreground">{t("adminPanel.pluginSettings.title")}</h4>
|
||||
<p className="text-sm text-foreground">{t("adminPanel.pluginSettings.description")}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Each plugin must expose <code className={INLINE_CODE_CLASS}>GET /api/plugin-manifest</code> returning nav
|
||||
items and capabilities.
|
||||
<Trans
|
||||
i18nKey="adminPanel.pluginSettings.manifestHint"
|
||||
values={{ endpoint: "GET /api/plugin-manifest" }}
|
||||
components={{ code: <code className={INLINE_CODE_CLASS} /> }}
|
||||
/>
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button className="mb-4" onClick={openAdd}>
|
||||
<Plus />
|
||||
Add Plugin
|
||||
{t("adminPanel.pluginSettings.addPlugin")}
|
||||
</Button>
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Display Name</TableHead>
|
||||
<TableHead>URL</TableHead>
|
||||
<TableHead>Plugin Key</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
<TableHead>{t("adminPanel.pluginSettings.columns.name")}</TableHead>
|
||||
<TableHead>{t("adminPanel.pluginSettings.columns.displayName")}</TableHead>
|
||||
<TableHead>{t("adminPanel.pluginSettings.columns.url")}</TableHead>
|
||||
<TableHead>{t("adminPanel.pluginSettings.columns.pluginKey")}</TableHead>
|
||||
<TableHead>{t("adminPanel.pluginSettings.columns.actions")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>{renderRows()}</TableBody>
|
||||
|
|
@ -181,29 +188,42 @@ export default function PluginSettings() {
|
|||
<Dialog open={modalOpen} onOpenChange={(open) => !open && setModalOpen(false)}>
|
||||
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingIndex !== null ? "Edit Plugin" : "Add Plugin"}</DialogTitle>
|
||||
<DialogTitle>
|
||||
{editingIndex !== null
|
||||
? t("adminPanel.pluginSettings.modal.editTitle")
|
||||
: t("adminPanel.pluginSettings.modal.addTitle")}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={(event) => event.preventDefault()} noValidate style={{ marginTop: 16 }}>
|
||||
<FieldGroup>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
label="Name (identifier)"
|
||||
description="Used in URLs and config. No spaces. E.g. litellm-platform-plugin"
|
||||
label={t("adminPanel.pluginSettings.modal.fields.name.label")}
|
||||
description={t("adminPanel.pluginSettings.modal.fields.name.description")}
|
||||
>
|
||||
{({ ref, ...field }) => <Input {...field} ref={ref} placeholder="litellm-platform-plugin" />}
|
||||
</FormField>
|
||||
<FormField control={form.control} name="display_name" label="Display Name">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="display_name"
|
||||
label={t("adminPanel.pluginSettings.modal.fields.displayName.label")}
|
||||
>
|
||||
{({ ref, ...field }) => <Input {...field} ref={ref} placeholder="Agent Control Plane" />}
|
||||
</FormField>
|
||||
<FormField control={form.control} name="url" label="URL" description="Base URL of the plugin service">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="url"
|
||||
label={t("adminPanel.pluginSettings.modal.fields.url.label")}
|
||||
description={t("adminPanel.pluginSettings.modal.fields.url.description")}
|
||||
>
|
||||
{({ ref, ...field }) => <Input {...field} ref={ref} placeholder="https://your-plugin.example.com" />}
|
||||
</FormField>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="plugin_key"
|
||||
label="Plugin Key"
|
||||
description="Optional. The plugin's own credential, injected as Authorization: Bearer <key> only when litellm reverse-proxies API calls to the plugin's backend (/plugin-proxy/<name>/*). Leave blank for plugins that use the forwarded litellm user token (e.g. iframe plugins) — that path uses the user's token, not this key."
|
||||
label={t("adminPanel.pluginSettings.modal.fields.pluginKey.label")}
|
||||
description={t("adminPanel.pluginSettings.modal.fields.pluginKey.description")}
|
||||
>
|
||||
{({ ref, ...field }) => (
|
||||
<InputGroup>
|
||||
|
|
@ -212,13 +232,21 @@ export default function PluginSettings() {
|
|||
ref={ref}
|
||||
type={keyVisible ? "text" : "password"}
|
||||
value={field.value ?? ""}
|
||||
placeholder={editingIndex !== null ? "Leave blank to keep current key" : "sk-... (optional)"}
|
||||
placeholder={
|
||||
editingIndex !== null
|
||||
? t("adminPanel.pluginSettings.modal.fields.pluginKey.keepCurrentPlaceholder")
|
||||
: t("adminPanel.pluginSettings.modal.fields.pluginKey.newPlaceholder")
|
||||
}
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupButton
|
||||
size="icon-xs"
|
||||
onClick={() => setKeyVisible(!keyVisible)}
|
||||
aria-label={keyVisible ? "Hide plugin key" : "Show plugin key"}
|
||||
aria-label={
|
||||
keyVisible
|
||||
? t("adminPanel.pluginSettings.modal.fields.pluginKey.hide")
|
||||
: t("adminPanel.pluginSettings.modal.fields.pluginKey.show")
|
||||
}
|
||||
>
|
||||
{keyVisible ? <EyeOff /> : <Eye />}
|
||||
</InputGroupButton>
|
||||
|
|
@ -230,10 +258,10 @@ export default function PluginSettings() {
|
|||
</form>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setModalOpen(false)}>
|
||||
Cancel
|
||||
{t("adminPanel.pluginSettings.modal.cancel")}
|
||||
</Button>
|
||||
<Button onClick={form.handleSubmit(handleOk)} disabled={saving} aria-busy={saving}>
|
||||
Save
|
||||
{t("adminPanel.pluginSettings.modal.save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { z } from "zod";
|
||||
|
||||
import i18n from "@/i18n/i18n";
|
||||
|
||||
const IPV4 = "(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)(?:\\.(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)){3}";
|
||||
const SEG = "[a-fA-F\\d]{1,4}";
|
||||
const IPV6 =
|
||||
|
|
@ -25,12 +27,14 @@ const URL_RULE_PATTERN = new RegExp(
|
|||
const isUrl = (value: string): boolean => value.length <= 2048 && URL_RULE_PATTERN.test(value);
|
||||
|
||||
const pluginShape = {
|
||||
name: z.string().min(1, "Required"),
|
||||
display_name: z.string().min(1, "Required"),
|
||||
name: z.string().min(1, { error: () => i18n.t("adminPanel.pluginSettings.errors.required") }),
|
||||
display_name: z.string().min(1, { error: () => i18n.t("adminPanel.pluginSettings.errors.required") }),
|
||||
url: z
|
||||
.string()
|
||||
.min(1, "Required")
|
||||
.refine((value) => value === "" || isUrl(value), "Must be a valid URL"),
|
||||
.min(1, { error: () => i18n.t("adminPanel.pluginSettings.errors.required") })
|
||||
.refine((value) => value === "" || isUrl(value), {
|
||||
error: () => i18n.t("adminPanel.pluginSettings.errors.invalidUrl"),
|
||||
}),
|
||||
plugin_key: z.string().optional(),
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { toast } from "@/lib/toast";
|
||||
import { parseErrorMessage } from "@/components/shared/errorUtils";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import BaseSSOSettingsForm, {
|
||||
emptySSOSettingsFormValues,
|
||||
submitMountedSSOValues,
|
||||
|
|
@ -22,6 +23,7 @@ interface AddSSOSettingsModalProps {
|
|||
}
|
||||
|
||||
const AddSSOSettingsModal: React.FC<AddSSOSettingsModalProps> = ({ isVisible, onCancel, onSuccess }) => {
|
||||
const { t } = useTranslation();
|
||||
const form = useSSOSettingsForm("sso-settings");
|
||||
const { mutateAsync, isPending } = useEditSSOSettings();
|
||||
|
||||
|
|
@ -30,11 +32,11 @@ const AddSSOSettingsModal: React.FC<AddSSOSettingsModalProps> = ({ isVisible, on
|
|||
|
||||
await mutateAsync(payload, {
|
||||
onSuccess: () => {
|
||||
toast.success("SSO settings added successfully");
|
||||
toast.success(t("adminPanel.ssoSettings.addModal.toasts.success"));
|
||||
onSuccess();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.fromError("Failed to save SSO settings: " + parseErrorMessage(error));
|
||||
toast.fromError(t("adminPanel.ssoSettings.addModal.toasts.saveFailed", { error: parseErrorMessage(error) }));
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -48,13 +50,13 @@ const AddSSOSettingsModal: React.FC<AddSSOSettingsModalProps> = ({ isVisible, on
|
|||
<Dialog open={isVisible} onOpenChange={(open) => !open && handleCancel()}>
|
||||
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[800px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add SSO</DialogTitle>
|
||||
<DialogTitle>{t("adminPanel.ssoSettings.addModal.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<BaseSSOSettingsForm form={form} onFormSubmit={handleFormSubmit} />
|
||||
<DialogFooter>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={handleCancel} disabled={isPending}>
|
||||
Cancel
|
||||
{t("adminPanel.ssoSettings.addModal.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
|
|
@ -62,7 +64,9 @@ const AddSSOSettingsModal: React.FC<AddSSOSettingsModalProps> = ({ isVisible, on
|
|||
onClick={submitMountedSSOValues(form, "sso-settings", handleFormSubmit)}
|
||||
>
|
||||
{isPending && <UiLoadingSpinner className="size-4 mr-1" />}
|
||||
{isPending ? "Adding..." : "Add SSO"}
|
||||
{isPending
|
||||
? t("adminPanel.ssoSettings.addModal.submitting")
|
||||
: t("adminPanel.ssoSettings.addModal.submit")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import type { ParseKeys, TFunction } from "i18next";
|
||||
import { FormProvider, useFormContext, useWatch, type UseFormReturn } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { z } from "zod";
|
||||
import { ssoProviderLogoMap, ssoProviderDisplayNames } from "../constants";
|
||||
import { Logo } from "@/components/molecules/logo/Logo";
|
||||
|
|
@ -13,6 +15,7 @@ import { Input } from "@/components/ui/input";
|
|||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useZodForm } from "@/lib/forms/useZodForm";
|
||||
import i18n from "@/i18n/i18n";
|
||||
|
||||
export interface SSOSettingsFormValues {
|
||||
sso_provider?: string;
|
||||
|
|
@ -52,9 +55,10 @@ export interface BaseSSOSettingsFormProps {
|
|||
export interface SSOProviderConfig {
|
||||
envVarMap: Record<string, string>;
|
||||
fields: Array<{
|
||||
label: string;
|
||||
label: ParseKeys;
|
||||
name: keyof SSOSettingsFormValues;
|
||||
placeholder?: string;
|
||||
placeholderKey?: ParseKeys;
|
||||
required?: boolean;
|
||||
type?: "password" | "textarea" | "checkbox";
|
||||
}>;
|
||||
|
|
@ -67,8 +71,8 @@ export const ssoProviderConfigs: Record<string, SSOProviderConfig> = {
|
|||
google_client_secret: "GOOGLE_CLIENT_SECRET",
|
||||
},
|
||||
fields: [
|
||||
{ label: "Google Client ID", name: "google_client_id" },
|
||||
{ label: "Google Client Secret", name: "google_client_secret" },
|
||||
{ label: "adminPanel.ssoSettings.form.fields.googleClientId", name: "google_client_id" },
|
||||
{ label: "adminPanel.ssoSettings.form.fields.googleClientSecret", name: "google_client_secret" },
|
||||
],
|
||||
},
|
||||
microsoft: {
|
||||
|
|
@ -78,9 +82,9 @@ export const ssoProviderConfigs: Record<string, SSOProviderConfig> = {
|
|||
microsoft_tenant: "MICROSOFT_TENANT",
|
||||
},
|
||||
fields: [
|
||||
{ label: "Microsoft Client ID", name: "microsoft_client_id" },
|
||||
{ label: "Microsoft Client Secret", name: "microsoft_client_secret" },
|
||||
{ label: "Microsoft Tenant", name: "microsoft_tenant" },
|
||||
{ label: "adminPanel.ssoSettings.form.fields.microsoftClientId", name: "microsoft_client_id" },
|
||||
{ label: "adminPanel.ssoSettings.form.fields.microsoftClientSecret", name: "microsoft_client_secret" },
|
||||
{ label: "adminPanel.ssoSettings.form.fields.microsoftTenant", name: "microsoft_tenant" },
|
||||
],
|
||||
},
|
||||
okta: {
|
||||
|
|
@ -93,20 +97,29 @@ export const ssoProviderConfigs: Record<string, SSOProviderConfig> = {
|
|||
generic_scope: "GENERIC_SCOPE",
|
||||
},
|
||||
fields: [
|
||||
{ label: "Generic Client ID", name: "generic_client_id" },
|
||||
{ label: "Generic Client Secret", name: "generic_client_secret" },
|
||||
{ label: "adminPanel.ssoSettings.form.fields.genericClientId", name: "generic_client_id" },
|
||||
{ label: "adminPanel.ssoSettings.form.fields.genericClientSecret", name: "generic_client_secret" },
|
||||
{
|
||||
label: "Authorization Endpoint",
|
||||
label: "adminPanel.ssoSettings.form.fields.authorizationEndpoint",
|
||||
name: "generic_authorization_endpoint",
|
||||
placeholder: "https://your-domain/authorize",
|
||||
},
|
||||
{ label: "Token Endpoint", name: "generic_token_endpoint", placeholder: "https://your-domain/token" },
|
||||
{
|
||||
label: "Userinfo Endpoint",
|
||||
label: "adminPanel.ssoSettings.form.fields.tokenEndpoint",
|
||||
name: "generic_token_endpoint",
|
||||
placeholder: "https://your-domain/token",
|
||||
},
|
||||
{
|
||||
label: "adminPanel.ssoSettings.form.fields.userinfoEndpoint",
|
||||
name: "generic_userinfo_endpoint",
|
||||
placeholder: "https://your-domain/userinfo",
|
||||
},
|
||||
{ label: "Scopes", name: "generic_scope", placeholder: "openid email profile", required: false },
|
||||
{
|
||||
label: "adminPanel.ssoSettings.form.fields.scopes",
|
||||
name: "generic_scope",
|
||||
placeholder: "openid email profile",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
generic: {
|
||||
|
|
@ -119,12 +132,17 @@ export const ssoProviderConfigs: Record<string, SSOProviderConfig> = {
|
|||
generic_scope: "GENERIC_SCOPE",
|
||||
},
|
||||
fields: [
|
||||
{ label: "Generic Client ID", name: "generic_client_id" },
|
||||
{ label: "Generic Client Secret", name: "generic_client_secret" },
|
||||
{ label: "Authorization Endpoint", name: "generic_authorization_endpoint" },
|
||||
{ label: "Token Endpoint", name: "generic_token_endpoint" },
|
||||
{ label: "Userinfo Endpoint", name: "generic_userinfo_endpoint" },
|
||||
{ label: "Scopes", name: "generic_scope", placeholder: "openid email profile", required: false },
|
||||
{ label: "adminPanel.ssoSettings.form.fields.genericClientId", name: "generic_client_id" },
|
||||
{ label: "adminPanel.ssoSettings.form.fields.genericClientSecret", name: "generic_client_secret" },
|
||||
{ label: "adminPanel.ssoSettings.form.fields.authorizationEndpoint", name: "generic_authorization_endpoint" },
|
||||
{ label: "adminPanel.ssoSettings.form.fields.tokenEndpoint", name: "generic_token_endpoint" },
|
||||
{ label: "adminPanel.ssoSettings.form.fields.userinfoEndpoint", name: "generic_userinfo_endpoint" },
|
||||
{
|
||||
label: "adminPanel.ssoSettings.form.fields.scopes",
|
||||
name: "generic_scope",
|
||||
placeholder: "openid email profile",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
saml: {
|
||||
|
|
@ -136,26 +154,26 @@ export const ssoProviderConfigs: Record<string, SSOProviderConfig> = {
|
|||
},
|
||||
fields: [
|
||||
{
|
||||
label: "IdP Metadata URL",
|
||||
label: "adminPanel.ssoSettings.form.fields.samlIdpMetadataUrl",
|
||||
name: "saml_idp_metadata_url",
|
||||
required: false,
|
||||
placeholder: "https://idp.example.com/metadata (use this or the metadata XML below)",
|
||||
placeholderKey: "adminPanel.ssoSettings.form.placeholders.samlIdpMetadataUrl",
|
||||
},
|
||||
{
|
||||
label: "IdP Metadata XML",
|
||||
label: "adminPanel.ssoSettings.form.fields.samlIdpMetadataXml",
|
||||
name: "saml_idp_metadata_xml",
|
||||
required: false,
|
||||
type: "textarea",
|
||||
placeholder: "Paste the IdP metadata XML here if you do not have a metadata URL",
|
||||
placeholderKey: "adminPanel.ssoSettings.form.placeholders.samlIdpMetadataXml",
|
||||
},
|
||||
{
|
||||
label: "SP Entity ID",
|
||||
label: "adminPanel.ssoSettings.form.fields.samlSpEntityId",
|
||||
name: "saml_sp_entity_id",
|
||||
required: false,
|
||||
placeholder: "Defaults to <proxy base url>/sso/saml/metadata",
|
||||
placeholderKey: "adminPanel.ssoSettings.form.placeholders.samlSpEntityId",
|
||||
},
|
||||
{
|
||||
label: "Allow IdP-initiated (unsolicited) responses",
|
||||
label: "adminPanel.ssoSettings.form.fields.samlAllowUnsolicited",
|
||||
name: "saml_allow_unsolicited",
|
||||
required: false,
|
||||
type: "checkbox",
|
||||
|
|
@ -213,12 +231,12 @@ export const submitMountedSSOValues =
|
|||
() =>
|
||||
void form.handleSubmit((values) => onFormSubmit(pickMountedSSOValues(values, variant)))();
|
||||
|
||||
const REQUIRED_MESSAGES: Record<string, string> = {
|
||||
sso_provider: "Please select an SSO provider",
|
||||
user_email: "Please enter the email of the proxy admin",
|
||||
proxy_base_url: "Please enter the proxy base url",
|
||||
group_claim: "Please enter the group claim",
|
||||
team_ids_jwt_field: "Please enter the team IDs JWT field",
|
||||
const REQUIRED_MESSAGES: Record<string, ParseKeys> = {
|
||||
sso_provider: "adminPanel.ssoSettings.form.errors.providerRequired",
|
||||
user_email: "adminPanel.ssoSettings.form.errors.userEmailRequired",
|
||||
proxy_base_url: "adminPanel.ssoSettings.form.errors.proxyBaseUrlRequired",
|
||||
group_claim: "adminPanel.ssoSettings.form.errors.groupClaimRequired",
|
||||
team_ids_jwt_field: "adminPanel.ssoSettings.form.errors.teamIdsJwtFieldRequired",
|
||||
};
|
||||
|
||||
const isBlank = (value: unknown): boolean => value === undefined || value === null || value === "";
|
||||
|
|
@ -229,7 +247,7 @@ export const buildSSOSettingsSchema = (variant: SSOFormVariant) =>
|
|||
|
||||
const requireField = (name: string) => {
|
||||
if (mounted.has(name) && isBlank(values[name as keyof SSOSettingsFormValues])) {
|
||||
ctx.addIssue({ code: "custom", path: [name], message: REQUIRED_MESSAGES[name] });
|
||||
ctx.addIssue({ code: "custom", path: [name], message: i18n.t(REQUIRED_MESSAGES[name]) });
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -245,20 +263,22 @@ export const buildSSOSettingsSchema = (variant: SSOFormVariant) =>
|
|||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: [field.name],
|
||||
message: `Please enter the ${field.label.toLowerCase()}`,
|
||||
message: i18n.t("adminPanel.ssoSettings.form.errors.fieldRequired", {
|
||||
field: i18n.t(field.label).toLowerCase(),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
const proxyBaseUrl = values.proxy_base_url;
|
||||
if (isBlank(proxyBaseUrl)) {
|
||||
ctx.addIssue({ code: "custom", path: ["proxy_base_url"], message: REQUIRED_MESSAGES.proxy_base_url });
|
||||
ctx.addIssue({ code: "custom", path: ["proxy_base_url"], message: i18n.t(REQUIRED_MESSAGES.proxy_base_url) });
|
||||
return;
|
||||
}
|
||||
if (!/^https?:\/\/.+/.test(proxyBaseUrl as string)) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["proxy_base_url"],
|
||||
message: "URL must start with http:// or https://",
|
||||
message: i18n.t("adminPanel.ssoSettings.form.errors.urlScheme"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
|
@ -266,7 +286,7 @@ export const buildSSOSettingsSchema = (variant: SSOFormVariant) =>
|
|||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["proxy_base_url"],
|
||||
message: "URL must not end with a trailing slash",
|
||||
message: i18n.t("adminPanel.ssoSettings.form.errors.urlTrailingSlash"),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
@ -299,11 +319,12 @@ export const useSSOSettingsForm = (
|
|||
});
|
||||
|
||||
const SSOProviderField = ({ field }: { field: SSOProviderConfig["fields"][number] }) => {
|
||||
const { t } = useTranslation();
|
||||
const { control } = useFormContext<SSOSettingsFormValues>();
|
||||
|
||||
if (field.type === "checkbox") {
|
||||
return (
|
||||
<FormField control={control} name={field.name} label={field.label} orientation="horizontal">
|
||||
<FormField control={control} name={field.name} label={t(field.label)} orientation="horizontal">
|
||||
{({ value, onChange, onBlur, id, ...rest }) => (
|
||||
<Checkbox
|
||||
id={id}
|
||||
|
|
@ -319,9 +340,13 @@ const SSOProviderField = ({ field }: { field: SSOProviderConfig["fields"][number
|
|||
}
|
||||
|
||||
return (
|
||||
<FormField control={control} name={field.name} label={field.label}>
|
||||
<FormField control={control} name={field.name} label={t(field.label)}>
|
||||
{({ ref, value, ...rest }) => {
|
||||
const shared = { placeholder: field.placeholder, value: (value as string) ?? "", ...rest };
|
||||
const shared = {
|
||||
placeholder: field.placeholderKey ? t(field.placeholderKey) : field.placeholder,
|
||||
value: (value as string) ?? "",
|
||||
...rest,
|
||||
};
|
||||
if (field.type === "textarea") return <Textarea ref={ref} rows={4} {...shared} />;
|
||||
if (field.type === "password" || field.name.includes("client")) return <PasswordInput ref={ref} {...shared} />;
|
||||
return <Input ref={ref} {...shared} />;
|
||||
|
|
@ -338,10 +363,11 @@ export const renderProviderFields = (provider: string) => {
|
|||
};
|
||||
|
||||
export const SSOProviderSelectField = () => {
|
||||
const { t } = useTranslation();
|
||||
const { control } = useFormContext<SSOSettingsFormValues>();
|
||||
|
||||
return (
|
||||
<FormField control={control} name="sso_provider" label="SSO Provider">
|
||||
<FormField control={control} name="sso_provider" label={t("adminPanel.ssoSettings.form.fields.ssoProvider")}>
|
||||
{({ value, onChange, onBlur, id, ...rest }) => (
|
||||
<Select value={(value as string) ?? ""} onValueChange={onChange}>
|
||||
<SelectTrigger
|
||||
|
|
@ -351,7 +377,7 @@ export const SSOProviderSelectField = () => {
|
|||
aria-describedby={rest["aria-describedby"]}
|
||||
className="w-full"
|
||||
>
|
||||
<SelectValue>{(provider: string) => (provider ? providerOptionLabel(provider) : "")}</SelectValue>
|
||||
<SelectValue>{(provider: string) => (provider ? providerOptionLabel(provider, t) : "")}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(ssoProviderLogoMap).map(([optionValue, logo]) => (
|
||||
|
|
@ -360,11 +386,13 @@ export const SSOProviderSelectField = () => {
|
|||
{logo && (
|
||||
<Logo
|
||||
src={logo}
|
||||
label={ssoProviderDisplayNames[optionValue] || optionValue}
|
||||
label={
|
||||
ssoProviderDisplayNames[optionValue] ? t(ssoProviderDisplayNames[optionValue]) : optionValue
|
||||
}
|
||||
className="h-6 w-6 mr-3 object-contain"
|
||||
/>
|
||||
)}
|
||||
<span>{providerOptionLabel(optionValue)}</span>
|
||||
<span>{providerOptionLabel(optionValue, t)}</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
|
|
@ -376,20 +404,22 @@ export const SSOProviderSelectField = () => {
|
|||
};
|
||||
|
||||
export const ProxyAdminEmailField = () => {
|
||||
const { t } = useTranslation();
|
||||
const { control } = useFormContext<SSOSettingsFormValues>();
|
||||
|
||||
return (
|
||||
<FormField control={control} name="user_email" label="Proxy Admin Email">
|
||||
<FormField control={control} name="user_email" label={t("adminPanel.ssoSettings.form.fields.proxyAdminEmail")}>
|
||||
{({ ref, value, ...rest }) => <Input ref={ref} value={(value as string) ?? ""} {...rest} />}
|
||||
</FormField>
|
||||
);
|
||||
};
|
||||
|
||||
export const ProxyBaseUrlField = () => {
|
||||
const { t } = useTranslation();
|
||||
const { control } = useFormContext<SSOSettingsFormValues>();
|
||||
|
||||
return (
|
||||
<FormField control={control} name="proxy_base_url" label="Proxy Base URL">
|
||||
<FormField control={control} name="proxy_base_url" label={t("adminPanel.ssoSettings.form.fields.proxyBaseUrl")}>
|
||||
{({ ref, value, onChange, ...rest }) => (
|
||||
<Input
|
||||
ref={ref}
|
||||
|
|
@ -429,31 +459,35 @@ export const MappingToggleField = ({
|
|||
};
|
||||
|
||||
export const GroupClaimField = () => {
|
||||
const { t } = useTranslation();
|
||||
const { control } = useFormContext<SSOSettingsFormValues>();
|
||||
|
||||
return (
|
||||
<FormField control={control} name="group_claim" label="Group Claim">
|
||||
<FormField control={control} name="group_claim" label={t("adminPanel.ssoSettings.form.fields.groupClaim")}>
|
||||
{({ ref, value, ...rest }) => <Input ref={ref} value={(value as string) ?? ""} {...rest} />}
|
||||
</FormField>
|
||||
);
|
||||
};
|
||||
|
||||
const DEFAULT_ROLE_OPTIONS: ReadonlyArray<{ value: string; label: string }> = [
|
||||
{ value: "internal_user_viewer", label: "Internal Viewer" },
|
||||
{ value: "internal_user", label: "Internal User" },
|
||||
{ value: "proxy_admin_viewer", label: "Admin Viewer" },
|
||||
{ value: "proxy_admin", label: "Proxy Admin" },
|
||||
const DEFAULT_ROLE_OPTIONS: ReadonlyArray<{ value: string; label: ParseKeys }> = [
|
||||
{ value: "internal_user_viewer", label: "adminPanel.ssoSettings.form.defaultRoles.internalUserViewer" },
|
||||
{ value: "internal_user", label: "adminPanel.ssoSettings.form.defaultRoles.internalUser" },
|
||||
{ value: "proxy_admin_viewer", label: "adminPanel.ssoSettings.form.defaultRoles.proxyAdminViewer" },
|
||||
{ value: "proxy_admin", label: "adminPanel.ssoSettings.form.defaultRoles.proxyAdmin" },
|
||||
];
|
||||
|
||||
const providerOptionLabel = (value: string) =>
|
||||
ssoProviderDisplayNames[value] || value.charAt(0).toUpperCase() + value.slice(1) + " SSO";
|
||||
const providerOptionLabel = (value: string, t: TFunction) =>
|
||||
ssoProviderDisplayNames[value]
|
||||
? t(ssoProviderDisplayNames[value])
|
||||
: t("adminPanel.ssoSettings.providers.fallback", { name: value.charAt(0).toUpperCase() + value.slice(1) });
|
||||
|
||||
export const RoleMappingTeamFields = () => {
|
||||
const { t } = useTranslation();
|
||||
const { control } = useFormContext<SSOSettingsFormValues>();
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormField control={control} name="default_role" label="Default Role">
|
||||
<FormField control={control} name="default_role" label={t("adminPanel.ssoSettings.form.fields.defaultRole")}>
|
||||
{({ value, onChange, onBlur, id, ...rest }) => (
|
||||
<Select value={(value as string) ?? ""} onValueChange={onChange}>
|
||||
<SelectTrigger
|
||||
|
|
@ -464,13 +498,16 @@ export const RoleMappingTeamFields = () => {
|
|||
className="w-full"
|
||||
>
|
||||
<SelectValue>
|
||||
{(role: string) => DEFAULT_ROLE_OPTIONS.find((option) => option.value === role)?.label ?? role}
|
||||
{(role: string) => {
|
||||
const label = DEFAULT_ROLE_OPTIONS.find((option) => option.value === role)?.label;
|
||||
return label ? t(label) : role;
|
||||
}}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DEFAULT_ROLE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
{t(option.label)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
|
@ -478,19 +515,35 @@ export const RoleMappingTeamFields = () => {
|
|||
)}
|
||||
</FormField>
|
||||
|
||||
<FormField control={control} name="proxy_admin_teams" label="Proxy Admin Teams">
|
||||
<FormField
|
||||
control={control}
|
||||
name="proxy_admin_teams"
|
||||
label={t("adminPanel.ssoSettings.form.fields.proxyAdminTeams")}
|
||||
>
|
||||
{({ ref, value, ...rest }) => <Input ref={ref} value={(value as string) ?? ""} {...rest} />}
|
||||
</FormField>
|
||||
|
||||
<FormField control={control} name="admin_viewer_teams" label="Admin Viewer Teams">
|
||||
<FormField
|
||||
control={control}
|
||||
name="admin_viewer_teams"
|
||||
label={t("adminPanel.ssoSettings.form.fields.adminViewerTeams")}
|
||||
>
|
||||
{({ ref, value, ...rest }) => <Input ref={ref} value={(value as string) ?? ""} {...rest} />}
|
||||
</FormField>
|
||||
|
||||
<FormField control={control} name="internal_user_teams" label="Internal User Teams">
|
||||
<FormField
|
||||
control={control}
|
||||
name="internal_user_teams"
|
||||
label={t("adminPanel.ssoSettings.form.fields.internalUserTeams")}
|
||||
>
|
||||
{({ ref, value, ...rest }) => <Input ref={ref} value={(value as string) ?? ""} {...rest} />}
|
||||
</FormField>
|
||||
|
||||
<FormField control={control} name="internal_viewer_teams" label="Internal Viewer Teams">
|
||||
<FormField
|
||||
control={control}
|
||||
name="internal_viewer_teams"
|
||||
label={t("adminPanel.ssoSettings.form.fields.internalViewerTeams")}
|
||||
>
|
||||
{({ ref, value, ...rest }) => <Input ref={ref} value={(value as string) ?? ""} {...rest} />}
|
||||
</FormField>
|
||||
</>
|
||||
|
|
@ -498,16 +551,22 @@ export const RoleMappingTeamFields = () => {
|
|||
};
|
||||
|
||||
export const TeamIdsJwtFieldField = () => {
|
||||
const { t } = useTranslation();
|
||||
const { control } = useFormContext<SSOSettingsFormValues>();
|
||||
|
||||
return (
|
||||
<FormField control={control} name="team_ids_jwt_field" label="Team IDs JWT Field">
|
||||
<FormField
|
||||
control={control}
|
||||
name="team_ids_jwt_field"
|
||||
label={t("adminPanel.ssoSettings.form.fields.teamIdsJwtField")}
|
||||
>
|
||||
{({ ref, value, ...rest }) => <Input ref={ref} value={(value as string) ?? ""} {...rest} />}
|
||||
</FormField>
|
||||
);
|
||||
};
|
||||
|
||||
const BaseSSOSettingsForm: React.FC<BaseSSOSettingsFormProps> = ({ form, onFormSubmit }) => {
|
||||
const { t } = useTranslation();
|
||||
const provider = useWatch({ control: form.control, name: "sso_provider" });
|
||||
const useRoleMappings = useWatch({ control: form.control, name: "use_role_mappings" });
|
||||
const useTeamMappings = useWatch({ control: form.control, name: "use_team_mappings" });
|
||||
|
|
@ -527,14 +586,24 @@ const BaseSSOSettingsForm: React.FC<BaseSSOSettingsFormProps> = ({ form, onFormS
|
|||
{provider ? renderProviderFields(provider) : null}
|
||||
<ProxyAdminEmailField />
|
||||
<ProxyBaseUrlField />
|
||||
{showMappingToggles && <MappingToggleField name="use_role_mappings" label="Use Role Mappings" />}
|
||||
{showMappingToggles && (
|
||||
<MappingToggleField
|
||||
name="use_role_mappings"
|
||||
label={t("adminPanel.ssoSettings.form.fields.useRoleMappings")}
|
||||
/>
|
||||
)}
|
||||
{useRoleMappings && showMappingToggles && (
|
||||
<>
|
||||
<GroupClaimField />
|
||||
<RoleMappingTeamFields />
|
||||
</>
|
||||
)}
|
||||
{showMappingToggles && <MappingToggleField name="use_team_mappings" label="Use Team Mappings" />}
|
||||
{showMappingToggles && (
|
||||
<MappingToggleField
|
||||
name="use_team_mappings"
|
||||
label={t("adminPanel.ssoSettings.form.fields.useTeamMappings")}
|
||||
/>
|
||||
)}
|
||||
{useTeamMappings && showMappingToggles && <TeamIdsJwtFieldField />}
|
||||
</FieldGroup>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useEditSSOSettings } from "@/app/(dashboard)/hooks/sso/useEditSSOSettings";
|
||||
import { useSSOSettings } from "@/app/(dashboard)/hooks/sso/useSSOSettings";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import DeleteResourceModal from "../../../../common_components/DeleteResourceModal";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { parseErrorMessage } from "../../../../shared/errorUtils";
|
||||
|
|
@ -13,6 +14,7 @@ interface DeleteSSOSettingsModalProps {
|
|||
}
|
||||
|
||||
const DeleteSSOSettingsModal: React.FC<DeleteSSOSettingsModalProps> = ({ isVisible, onCancel, onSuccess }) => {
|
||||
const { t } = useTranslation();
|
||||
const { data: ssoSettings } = useSSOSettings();
|
||||
const { mutateAsync: editSSOSettings, isPending: isEditingSSOSettings } = useEditSSOSettings();
|
||||
|
||||
|
|
@ -42,12 +44,12 @@ const DeleteSSOSettingsModal: React.FC<DeleteSSOSettingsModalProps> = ({ isVisib
|
|||
|
||||
await editSSOSettings(clearSettings, {
|
||||
onSuccess: () => {
|
||||
toast.success("SSO settings cleared successfully");
|
||||
toast.success(t("adminPanel.ssoSettings.deleteModal.toasts.success"));
|
||||
onCancel();
|
||||
onSuccess();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.fromError("Failed to clear SSO settings: " + parseErrorMessage(error));
|
||||
toast.fromError(t("adminPanel.ssoSettings.deleteModal.toasts.failed", { error: parseErrorMessage(error) }));
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -55,12 +57,17 @@ const DeleteSSOSettingsModal: React.FC<DeleteSSOSettingsModalProps> = ({ isVisib
|
|||
return (
|
||||
<DeleteResourceModal
|
||||
isOpen={isVisible}
|
||||
title="Confirm Clear SSO Settings"
|
||||
alertMessage="This action cannot be undone."
|
||||
message="Are you sure you want to clear all SSO settings? Users will no longer be able to login using SSO after this change."
|
||||
resourceInformationTitle="SSO Settings"
|
||||
title={t("adminPanel.ssoSettings.deleteModal.title")}
|
||||
alertMessage={t("adminPanel.ssoSettings.deleteModal.alert")}
|
||||
message={t("adminPanel.ssoSettings.deleteModal.message")}
|
||||
resourceInformationTitle={t("adminPanel.ssoSettings.deleteModal.resourceTitle")}
|
||||
resourceInformation={[
|
||||
{ label: "Provider", value: (ssoSettings?.values && detectSSOProvider(ssoSettings?.values)) || "Generic" },
|
||||
{
|
||||
label: t("adminPanel.ssoSettings.deleteModal.provider"),
|
||||
value:
|
||||
(ssoSettings?.values && detectSSOProvider(ssoSettings?.values)) ||
|
||||
t("adminPanel.ssoSettings.deleteModal.providerFallback"),
|
||||
},
|
||||
]}
|
||||
onCancel={onCancel}
|
||||
onOk={handleClearSSO}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import BaseSSOSettingsForm, {
|
||||
emptySSOSettingsFormValues,
|
||||
submitMountedSSOValues,
|
||||
|
|
@ -74,6 +75,7 @@ export const toSSOFormValues = (values: SSOSettingsValues): SSOSettingsFormValue
|
|||
};
|
||||
|
||||
const EditSSOSettingsModal: React.FC<EditSSOSettingsModalProps> = ({ isVisible, onCancel, onSuccess }) => {
|
||||
const { t } = useTranslation();
|
||||
const ssoSettings = useSSOSettings();
|
||||
const { mutateAsync, isPending } = useEditSSOSettings();
|
||||
|
||||
|
|
@ -90,15 +92,17 @@ const EditSSOSettingsModal: React.FC<EditSSOSettingsModalProps> = ({ isVisible,
|
|||
|
||||
await mutateAsync(payload, {
|
||||
onSuccess: () => {
|
||||
toast.success("SSO settings updated successfully");
|
||||
toast.success(t("adminPanel.ssoSettings.editModal.toasts.success"));
|
||||
onSuccess();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.fromError("Failed to save SSO settings: " + parseErrorMessage(error));
|
||||
toast.fromError(
|
||||
t("adminPanel.ssoSettings.editModal.toasts.saveFailed", { error: String(parseErrorMessage(error)) }),
|
||||
);
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
toast.fromError("Failed to process SSO settings: " + parseErrorMessage(error));
|
||||
toast.fromError(t("adminPanel.ssoSettings.editModal.toasts.processFailed", { error: parseErrorMessage(error) }));
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -111,13 +115,13 @@ const EditSSOSettingsModal: React.FC<EditSSOSettingsModalProps> = ({ isVisible,
|
|||
<Dialog open={isVisible} onOpenChange={(open) => !open && handleCancel()}>
|
||||
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[800px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit SSO Settings</DialogTitle>
|
||||
<DialogTitle>{t("adminPanel.ssoSettings.editModal.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<BaseSSOSettingsForm form={form} onFormSubmit={handleFormSubmit} />
|
||||
<DialogFooter>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={handleCancel} disabled={isPending}>
|
||||
Cancel
|
||||
{t("adminPanel.ssoSettings.editModal.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
|
|
@ -125,7 +129,7 @@ const EditSSOSettingsModal: React.FC<EditSSOSettingsModalProps> = ({ isVisible,
|
|||
onClick={submitMountedSSOValues(form, "sso-settings", handleFormSubmit)}
|
||||
>
|
||||
{isPending && <UiLoadingSpinner className="size-4 mr-1" />}
|
||||
{isPending ? "Saving..." : "Save"}
|
||||
{isPending ? t("adminPanel.ssoSettings.editModal.saving") : t("adminPanel.ssoSettings.editModal.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useState } from "react";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
|
|
@ -10,6 +11,7 @@ export default function RedactableField({
|
|||
defaultHidden?: boolean;
|
||||
value: string | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [isHidden, setIsHidden] = useState(defaultHidden);
|
||||
|
||||
return (
|
||||
|
|
@ -22,7 +24,9 @@ export default function RedactableField({
|
|||
value
|
||||
)
|
||||
) : (
|
||||
<span className="text-muted-foreground italic">Not configured</span>
|
||||
<span className="text-muted-foreground italic">
|
||||
{t("adminPanel.ssoSettings.redactableField.notConfigured")}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{value && (
|
||||
|
|
@ -30,7 +34,11 @@ export default function RedactableField({
|
|||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={isHidden ? "Show value" : "Hide value"}
|
||||
aria-label={
|
||||
isHidden
|
||||
? t("adminPanel.ssoSettings.redactableField.showValue")
|
||||
: t("adminPanel.ssoSettings.redactableField.hideValue")
|
||||
}
|
||||
onClick={() => setIsHidden(!isHidden)}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { StatusBadge } from "@/components/shared/table_cells/status_badge";
|
|||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Users } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { defaultRoleDisplayNames } from "./constants";
|
||||
|
||||
const inlineCodeClass = "rounded-sm border border-border bg-muted px-1 py-0.5 font-mono text-xs";
|
||||
|
|
@ -15,6 +16,8 @@ interface RoleMappingRow {
|
|||
}
|
||||
|
||||
export default function RoleMappings({ roleMappings }: { roleMappings: RoleMappingsType | undefined }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!roleMappings) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -23,13 +26,13 @@ export default function RoleMappings({ roleMappings }: { roleMappings: RoleMappi
|
|||
{
|
||||
id: "role",
|
||||
accessorKey: "role",
|
||||
header: "Role",
|
||||
cell: ({ row }) => <strong className="font-semibold">{defaultRoleDisplayNames[row.original.role]}</strong>,
|
||||
header: t("adminPanel.ssoSettings.roleMappings.columns.role"),
|
||||
cell: ({ row }) => <strong className="font-semibold">{t(defaultRoleDisplayNames[row.original.role])}</strong>,
|
||||
},
|
||||
{
|
||||
id: "groups",
|
||||
accessorKey: "groups",
|
||||
header: "Mapped Groups",
|
||||
header: t("adminPanel.ssoSettings.roleMappings.columns.mappedGroups"),
|
||||
cell: ({ row }) =>
|
||||
row.original.groups.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
|
|
@ -38,7 +41,7 @@ export default function RoleMappings({ roleMappings }: { roleMappings: RoleMappi
|
|||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground italic">No groups mapped</span>
|
||||
<span className="text-muted-foreground italic">{t("adminPanel.ssoSettings.roleMappings.noGroups")}</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
|
@ -47,20 +50,26 @@ export default function RoleMappings({ roleMappings }: { roleMappings: RoleMappi
|
|||
<CardContent>
|
||||
<div className="flex items-center gap-3">
|
||||
<Users className="w-6 h-6 text-muted-foreground mb-2" />
|
||||
<h3 className="mb-2 text-2xl font-semibold text-foreground">Role Mappings</h3>
|
||||
<h3 className="mb-2 text-2xl font-semibold text-foreground">
|
||||
{t("adminPanel.ssoSettings.roleMappings.title")}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="space-y-8">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<h5 className="mb-2 text-base font-semibold text-foreground">Group Claim</h5>
|
||||
<h5 className="mb-2 text-base font-semibold text-foreground">
|
||||
{t("adminPanel.ssoSettings.roleMappings.groupClaim")}
|
||||
</h5>
|
||||
<div>
|
||||
<code className={inlineCodeClass}>{roleMappings.group_claim}</code>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h5 className="mb-2 text-base font-semibold text-foreground">Default Role</h5>
|
||||
<h5 className="mb-2 text-base font-semibold text-foreground">
|
||||
{t("adminPanel.ssoSettings.roleMappings.defaultRole")}
|
||||
</h5>
|
||||
<div>
|
||||
<strong className="font-semibold">{defaultRoleDisplayNames[roleMappings.default_role]}</strong>
|
||||
<strong className="font-semibold">{t(defaultRoleDisplayNames[roleMappings.default_role])}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { Copy, Edit, Shield, Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useSSOSettings, type SSOSettingsValues } from "@/app/(dashboard)/hooks/sso/useSSOSettings";
|
||||
import { Logo } from "@/components/molecules/logo/Logo";
|
||||
|
|
@ -21,7 +22,8 @@ import { ssoProviderDisplayNames, ssoProviderLogoMap } from "./constants";
|
|||
import { detectSSOProvider } from "./utils";
|
||||
|
||||
function NotConfigured() {
|
||||
return <span className="text-muted-foreground italic">Not configured</span>;
|
||||
const { t } = useTranslation();
|
||||
return <span className="text-muted-foreground italic">{t("adminPanel.ssoSettings.details.notConfigured")}</span>;
|
||||
}
|
||||
|
||||
function DetailRow({ children, label }: { children: React.ReactNode; label: string }) {
|
||||
|
|
@ -34,6 +36,7 @@ function DetailRow({ children, label }: { children: React.ReactNode; label: stri
|
|||
}
|
||||
|
||||
function EndpointValue({ value }: { value?: string | null }) {
|
||||
const { t } = useTranslation();
|
||||
if (!value) return <span className="font-mono text-muted-foreground">-</span>;
|
||||
|
||||
return (
|
||||
|
|
@ -43,8 +46,8 @@ function EndpointValue({ value }: { value?: string | null }) {
|
|||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="Copy value"
|
||||
onClick={() => void copyToClipboard(value, "Copied to clipboard")}
|
||||
aria-label={t("adminPanel.ssoSettings.details.copyValue")}
|
||||
onClick={() => void copyToClipboard(value, t("adminPanel.ssoSettings.details.copied"))}
|
||||
>
|
||||
<Copy className="size-3.5" />
|
||||
</Button>
|
||||
|
|
@ -53,6 +56,7 @@ function EndpointValue({ value }: { value?: string | null }) {
|
|||
}
|
||||
|
||||
export default function SSOSettings() {
|
||||
const { t } = useTranslation();
|
||||
const { data: ssoSettings, refetch, isLoading } = useSSOSettings();
|
||||
const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false);
|
||||
const [isAddModalVisible, setIsAddModalVisible] = useState(false);
|
||||
|
|
@ -78,119 +82,155 @@ export default function SSOSettings() {
|
|||
|
||||
const providerConfigs = {
|
||||
google: {
|
||||
providerText: ssoProviderDisplayNames.google,
|
||||
providerText: t(ssoProviderDisplayNames.google),
|
||||
fields: [
|
||||
{
|
||||
label: "Client ID",
|
||||
label: t("adminPanel.ssoSettings.details.clientId"),
|
||||
render: (values: SSOSettingsValues) => <RedactableField value={values.google_client_id} />,
|
||||
},
|
||||
{
|
||||
label: "Client Secret",
|
||||
label: t("adminPanel.ssoSettings.details.clientSecret"),
|
||||
render: (values: SSOSettingsValues) => <RedactableField value={values.google_client_secret} />,
|
||||
},
|
||||
{ label: "Proxy Base URL", render: (values: SSOSettingsValues) => renderSimpleValue(values.proxy_base_url) },
|
||||
{
|
||||
label: t("adminPanel.ssoSettings.details.proxyBaseUrl"),
|
||||
render: (values: SSOSettingsValues) => renderSimpleValue(values.proxy_base_url),
|
||||
},
|
||||
],
|
||||
},
|
||||
microsoft: {
|
||||
providerText: ssoProviderDisplayNames.microsoft,
|
||||
providerText: t(ssoProviderDisplayNames.microsoft),
|
||||
fields: [
|
||||
{
|
||||
label: "Client ID",
|
||||
label: t("adminPanel.ssoSettings.details.clientId"),
|
||||
render: (values: SSOSettingsValues) => <RedactableField value={values.microsoft_client_id} />,
|
||||
},
|
||||
{
|
||||
label: "Client Secret",
|
||||
label: t("adminPanel.ssoSettings.details.clientSecret"),
|
||||
render: (values: SSOSettingsValues) => <RedactableField value={values.microsoft_client_secret} />,
|
||||
},
|
||||
{ label: "Tenant", render: (values: SSOSettingsValues) => renderSimpleValue(values.microsoft_tenant) },
|
||||
{ label: "Proxy Base URL", render: (values: SSOSettingsValues) => renderSimpleValue(values.proxy_base_url) },
|
||||
{
|
||||
label: t("adminPanel.ssoSettings.details.tenant"),
|
||||
render: (values: SSOSettingsValues) => renderSimpleValue(values.microsoft_tenant),
|
||||
},
|
||||
{
|
||||
label: t("adminPanel.ssoSettings.details.proxyBaseUrl"),
|
||||
render: (values: SSOSettingsValues) => renderSimpleValue(values.proxy_base_url),
|
||||
},
|
||||
],
|
||||
},
|
||||
okta: {
|
||||
providerText: ssoProviderDisplayNames.okta,
|
||||
providerText: t(ssoProviderDisplayNames.okta),
|
||||
fields: [
|
||||
{
|
||||
label: "Client ID",
|
||||
label: t("adminPanel.ssoSettings.details.clientId"),
|
||||
render: (values: SSOSettingsValues) => <RedactableField value={values.generic_client_id} />,
|
||||
},
|
||||
{
|
||||
label: "Client Secret",
|
||||
label: t("adminPanel.ssoSettings.details.clientSecret"),
|
||||
render: (values: SSOSettingsValues) => <RedactableField value={values.generic_client_secret} />,
|
||||
},
|
||||
{
|
||||
label: "Authorization Endpoint",
|
||||
label: t("adminPanel.ssoSettings.details.authorizationEndpoint"),
|
||||
render: (values: SSOSettingsValues) => <EndpointValue value={values.generic_authorization_endpoint} />,
|
||||
},
|
||||
{
|
||||
label: "Token Endpoint",
|
||||
label: t("adminPanel.ssoSettings.details.tokenEndpoint"),
|
||||
render: (values: SSOSettingsValues) => <EndpointValue value={values.generic_token_endpoint} />,
|
||||
},
|
||||
{
|
||||
label: "User Info Endpoint",
|
||||
label: t("adminPanel.ssoSettings.details.userInfoEndpoint"),
|
||||
render: (values: SSOSettingsValues) => <EndpointValue value={values.generic_userinfo_endpoint} />,
|
||||
},
|
||||
{ label: "Scopes", render: (values: SSOSettingsValues) => renderSimpleValue(values.generic_scope) },
|
||||
{ label: "Proxy Base URL", render: (values: SSOSettingsValues) => renderSimpleValue(values.proxy_base_url) },
|
||||
{
|
||||
label: t("adminPanel.ssoSettings.details.scopes"),
|
||||
render: (values: SSOSettingsValues) => renderSimpleValue(values.generic_scope),
|
||||
},
|
||||
{
|
||||
label: t("adminPanel.ssoSettings.details.proxyBaseUrl"),
|
||||
render: (values: SSOSettingsValues) => renderSimpleValue(values.proxy_base_url),
|
||||
},
|
||||
isTeamMappingsEnabled
|
||||
? { label: "Team IDs JWT Field", render: (values: SSOSettingsValues) => renderTeamMappingsField(values) }
|
||||
? {
|
||||
label: t("adminPanel.ssoSettings.details.teamIdsJwtField"),
|
||||
render: (values: SSOSettingsValues) => renderTeamMappingsField(values),
|
||||
}
|
||||
: null,
|
||||
],
|
||||
},
|
||||
generic: {
|
||||
providerText: ssoProviderDisplayNames.generic,
|
||||
providerText: t(ssoProviderDisplayNames.generic),
|
||||
fields: [
|
||||
{
|
||||
label: "Client ID",
|
||||
label: t("adminPanel.ssoSettings.details.clientId"),
|
||||
render: (values: SSOSettingsValues) => <RedactableField value={values.generic_client_id} />,
|
||||
},
|
||||
{
|
||||
label: "Client Secret",
|
||||
label: t("adminPanel.ssoSettings.details.clientSecret"),
|
||||
render: (values: SSOSettingsValues) => <RedactableField value={values.generic_client_secret} />,
|
||||
},
|
||||
{
|
||||
label: "Authorization Endpoint",
|
||||
label: t("adminPanel.ssoSettings.details.authorizationEndpoint"),
|
||||
render: (values: SSOSettingsValues) => <EndpointValue value={values.generic_authorization_endpoint} />,
|
||||
},
|
||||
{
|
||||
label: "Token Endpoint",
|
||||
label: t("adminPanel.ssoSettings.details.tokenEndpoint"),
|
||||
render: (values: SSOSettingsValues) => <EndpointValue value={values.generic_token_endpoint} />,
|
||||
},
|
||||
{
|
||||
label: "User Info Endpoint",
|
||||
label: t("adminPanel.ssoSettings.details.userInfoEndpoint"),
|
||||
render: (values: SSOSettingsValues) => <EndpointValue value={values.generic_userinfo_endpoint} />,
|
||||
},
|
||||
{ label: "Scopes", render: (values: SSOSettingsValues) => renderSimpleValue(values.generic_scope) },
|
||||
{ label: "Proxy Base URL", render: (values: SSOSettingsValues) => renderSimpleValue(values.proxy_base_url) },
|
||||
{
|
||||
label: t("adminPanel.ssoSettings.details.scopes"),
|
||||
render: (values: SSOSettingsValues) => renderSimpleValue(values.generic_scope),
|
||||
},
|
||||
{
|
||||
label: t("adminPanel.ssoSettings.details.proxyBaseUrl"),
|
||||
render: (values: SSOSettingsValues) => renderSimpleValue(values.proxy_base_url),
|
||||
},
|
||||
isTeamMappingsEnabled
|
||||
? { label: "Team IDs JWT Field", render: (values: SSOSettingsValues) => renderTeamMappingsField(values) }
|
||||
? {
|
||||
label: t("adminPanel.ssoSettings.details.teamIdsJwtField"),
|
||||
render: (values: SSOSettingsValues) => renderTeamMappingsField(values),
|
||||
}
|
||||
: null,
|
||||
],
|
||||
},
|
||||
saml: {
|
||||
providerText: ssoProviderDisplayNames.saml,
|
||||
providerText: t(ssoProviderDisplayNames.saml),
|
||||
fields: [
|
||||
{
|
||||
label: "IdP Metadata URL",
|
||||
label: t("adminPanel.ssoSettings.details.samlIdpMetadataUrl"),
|
||||
render: (values: SSOSettingsValues) => <EndpointValue value={values.saml_idp_metadata_url} />,
|
||||
},
|
||||
{
|
||||
label: "IdP Metadata XML",
|
||||
label: t("adminPanel.ssoSettings.details.samlIdpMetadataXml"),
|
||||
render: (values: SSOSettingsValues) =>
|
||||
values.saml_idp_metadata_xml ? <Badge variant="secondary">Provided</Badge> : <NotConfigured />,
|
||||
values.saml_idp_metadata_xml ? (
|
||||
<Badge variant="secondary">{t("adminPanel.ssoSettings.details.provided")}</Badge>
|
||||
) : (
|
||||
<NotConfigured />
|
||||
),
|
||||
},
|
||||
{
|
||||
label: "SP Entity ID",
|
||||
label: t("adminPanel.ssoSettings.details.samlSpEntityId"),
|
||||
render: (values: SSOSettingsValues) => <EndpointValue value={values.saml_sp_entity_id} />,
|
||||
},
|
||||
{
|
||||
label: "Allow IdP-initiated (unsolicited) responses",
|
||||
label: t("adminPanel.ssoSettings.details.samlAllowUnsolicited"),
|
||||
render: (values: SSOSettingsValues) => (
|
||||
<Badge variant={values.saml_allow_unsolicited === "true" ? "default" : "secondary"}>
|
||||
{values.saml_allow_unsolicited === "true" ? "Enabled" : "Disabled"}
|
||||
{values.saml_allow_unsolicited === "true"
|
||||
? t("adminPanel.ssoSettings.details.enabled")
|
||||
: t("adminPanel.ssoSettings.details.disabled")}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{ label: "Proxy Base URL", render: (values: SSOSettingsValues) => renderSimpleValue(values.proxy_base_url) },
|
||||
{
|
||||
label: t("adminPanel.ssoSettings.details.proxyBaseUrl"),
|
||||
render: (values: SSOSettingsValues) => renderSimpleValue(values.proxy_base_url),
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
|
@ -202,12 +242,16 @@ export default function SSOSettings() {
|
|||
|
||||
return (
|
||||
<dl className="divide-y divide-border overflow-hidden rounded-md border border-border">
|
||||
<DetailRow label="Provider">
|
||||
<DetailRow label={t("adminPanel.ssoSettings.details.provider")}>
|
||||
<div className="flex items-center gap-2">
|
||||
{ssoProviderLogoMap[selectedProvider] && (
|
||||
<Logo
|
||||
src={ssoProviderLogoMap[selectedProvider]}
|
||||
label={ssoProviderDisplayNames[selectedProvider] || selectedProvider}
|
||||
label={
|
||||
ssoProviderDisplayNames[selectedProvider]
|
||||
? t(ssoProviderDisplayNames[selectedProvider])
|
||||
: selectedProvider
|
||||
}
|
||||
className="size-6 object-contain"
|
||||
/>
|
||||
)}
|
||||
|
|
@ -238,20 +282,20 @@ export default function SSOSettings() {
|
|||
<Shield className="size-6 text-muted-foreground" />
|
||||
<div>
|
||||
<CardTitle>
|
||||
<h3>SSO Configuration</h3>
|
||||
<h3>{t("adminPanel.ssoSettings.header.title")}</h3>
|
||||
</CardTitle>
|
||||
<CardDescription>Manage Single Sign-On authentication settings</CardDescription>
|
||||
<CardDescription>{t("adminPanel.ssoSettings.header.description")}</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
{isSSOConfigured && (
|
||||
<CardAction className="flex gap-2">
|
||||
<Button type="button" variant="outline" onClick={() => setIsEditModalVisible(true)}>
|
||||
<Edit />
|
||||
Edit SSO Settings
|
||||
{t("adminPanel.ssoSettings.actions.edit")}
|
||||
</Button>
|
||||
<Button type="button" variant="destructive" onClick={() => setIsDeleteModalVisible(true)}>
|
||||
<Trash2 />
|
||||
Delete SSO Settings
|
||||
{t("adminPanel.ssoSettings.actions.delete")}
|
||||
</Button>
|
||||
</CardAction>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { Shield } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
|
|
@ -7,18 +8,18 @@ interface SSOSettingsEmptyPlaceholderProps {
|
|||
}
|
||||
|
||||
export default function SSOSettingsEmptyPlaceholder({ onAdd }: SSOSettingsEmptyPlaceholderProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex w-full flex-col items-center rounded-lg border border-dashed border-border bg-card p-12 text-center">
|
||||
<div className="mb-4 flex size-12 items-center justify-center rounded-full bg-muted">
|
||||
<Shield className="size-6 text-muted-foreground" />
|
||||
</div>
|
||||
<h4 className="text-base font-semibold text-foreground">No SSO Configuration Found</h4>
|
||||
<h4 className="text-base font-semibold text-foreground">{t("adminPanel.ssoSettings.emptyPlaceholder.title")}</h4>
|
||||
<p className="mx-auto mt-2 max-w-md text-sm text-muted-foreground">
|
||||
Configure Single Sign-On (SSO) to enable seamless authentication for your team members using your identity
|
||||
provider.
|
||||
{t("adminPanel.ssoSettings.emptyPlaceholder.description")}
|
||||
</p>
|
||||
<Button size="lg" onClick={onAdd} className="mt-4">
|
||||
Configure SSO
|
||||
{t("adminPanel.ssoSettings.emptyPlaceholder.configure")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { Shield } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
|
@ -8,14 +9,15 @@ import { Skeleton } from "@/components/ui/skeleton";
|
|||
const CONTENT_WIDTHS = ["w-24", "w-48", "w-60", "w-44", "w-52"];
|
||||
|
||||
export default function SSOSettingsLoadingSkeleton() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Card role="status" aria-label="Loading SSO configuration">
|
||||
<Card role="status" aria-label={t("adminPanel.ssoSettings.header.loading")}>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Shield className="size-6 text-muted-foreground" />
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-foreground">SSO Configuration</h3>
|
||||
<p className="text-sm text-muted-foreground">Manage Single Sign-On authentication settings</p>
|
||||
<h3 className="text-lg font-semibold text-foreground">{t("adminPanel.ssoSettings.header.title")}</h3>
|
||||
<p className="text-sm text-muted-foreground">{t("adminPanel.ssoSettings.header.description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import type { ParseKeys } from "i18next";
|
||||
import googleLogo from "../../../../../public/assets/logos/google.svg";
|
||||
import microsoftAzureLogo from "../../../../../public/assets/logos/microsoft_azure.svg";
|
||||
|
||||
|
|
@ -11,17 +12,17 @@ export const ssoProviderLogoMap: Record<string, string> = {
|
|||
};
|
||||
|
||||
// SSO Provider display names (consistent between select dropdown and table)
|
||||
export const ssoProviderDisplayNames: Record<string, string> = {
|
||||
google: "Google SSO",
|
||||
microsoft: "Microsoft SSO",
|
||||
okta: "Okta / Auth0 SSO",
|
||||
generic: "Generic SSO",
|
||||
saml: "SAML SSO",
|
||||
export const ssoProviderDisplayNames: Record<string, ParseKeys> = {
|
||||
google: "adminPanel.ssoSettings.providers.google",
|
||||
microsoft: "adminPanel.ssoSettings.providers.microsoft",
|
||||
okta: "adminPanel.ssoSettings.providers.okta",
|
||||
generic: "adminPanel.ssoSettings.providers.generic",
|
||||
saml: "adminPanel.ssoSettings.providers.saml",
|
||||
};
|
||||
|
||||
export const defaultRoleDisplayNames: Record<string, string> = {
|
||||
internal_user_viewer: "Internal Viewer",
|
||||
internal_user: "Internal User",
|
||||
proxy_admin_viewer: "Proxy Admin Viewer",
|
||||
proxy_admin: "Proxy Admin",
|
||||
export const defaultRoleDisplayNames: Record<string, ParseKeys> = {
|
||||
internal_user_viewer: "adminPanel.ssoSettings.roles.internalUserViewer",
|
||||
internal_user: "adminPanel.ssoSettings.roles.internalUser",
|
||||
proxy_admin_viewer: "adminPanel.ssoSettings.roles.proxyAdminViewer",
|
||||
proxy_admin: "adminPanel.ssoSettings.roles.proxyAdmin",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { Trans, useTranslation } from "react-i18next";
|
||||
|
||||
import { $api } from "@/lib/http/api";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -6,11 +8,12 @@ import { Switch } from "@/components/ui/switch";
|
|||
import { uiHref } from "@/utils/uiHref";
|
||||
|
||||
export default function BackgroundChatSettings() {
|
||||
const { t } = useTranslation();
|
||||
const query = $api.useQuery("get", "/get/background_chat_settings");
|
||||
const update = $api.useMutation("patch", "/update/background_chat_settings", {
|
||||
onSuccess: () => {
|
||||
void query.refetch();
|
||||
toast.success("Background chat settings saved.");
|
||||
toast.success(t("adminPanel.backgroundChat.toasts.saved"));
|
||||
},
|
||||
onError: (error) => toast.fromError(error),
|
||||
});
|
||||
|
|
@ -19,9 +22,9 @@ export default function BackgroundChatSettings() {
|
|||
if (query.isError || !query.data)
|
||||
return (
|
||||
<div role="alert" className="text-sm text-destructive">
|
||||
Could not load background chat settings.
|
||||
{t("adminPanel.backgroundChat.errors.loadFailed")}
|
||||
<Button variant="ghost" size="sm" onClick={() => void query.refetch()}>
|
||||
Retry
|
||||
{t("adminPanel.backgroundChat.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -37,36 +40,29 @@ export default function BackgroundChatSettings() {
|
|||
checked={settings.enabled}
|
||||
disabled={disabled}
|
||||
onCheckedChange={(enabled) => update.mutate({ body: { enabled } })}
|
||||
aria-label="Continue chats in the background"
|
||||
aria-label={t("adminPanel.backgroundChat.label")}
|
||||
aria-describedby="background-chat-description"
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-foreground">Continue chats in the background</p>
|
||||
<p className="text-sm font-medium text-foreground">{t("adminPanel.backgroundChat.label")}</p>
|
||||
<p id="background-chat-description" className="text-sm text-muted-foreground">
|
||||
Keep replies and tool runs going after users close the browser. Return to the same browser to see the result.
|
||||
{t("adminPanel.backgroundChat.description")}
|
||||
</p>
|
||||
{!settings.redis_configured && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Requires Redis.{" "}
|
||||
<a className="text-primary underline" href={uiHref("/caching")}>
|
||||
Configure it in Caching → Coordination Redis
|
||||
</a>
|
||||
, then restart the proxy.
|
||||
<Trans
|
||||
i18nKey="adminPanel.backgroundChat.requiresRedis"
|
||||
components={{ cachingLink: <a className="text-primary underline" href={uiHref("/caching")} /> }}
|
||||
/>
|
||||
</p>
|
||||
)}
|
||||
{settings.managed_by_config && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Managed by your config file. Update its background polling setting to change this.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{t("adminPanel.backgroundChat.managedByConfig")}</p>
|
||||
)}
|
||||
{!settings.can_save && !settings.managed_by_config && (
|
||||
<p className="text-sm text-muted-foreground">Enable database-backed configuration to save this setting.</p>
|
||||
)}
|
||||
{!readOnly && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Changes apply automatically when the proxy refreshes its configuration.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{t("adminPanel.backgroundChat.dbConfigRequired")}</p>
|
||||
)}
|
||||
{!readOnly && <p className="text-sm text-muted-foreground">{t("adminPanel.backgroundChat.autoApply")}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { useTerminology } from "@/contexts/TerminologyContext";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { getAvailablePages } from "@/components/page_utils";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
|
@ -23,6 +24,7 @@ export default function PageVisibilitySettings({
|
|||
isUpdating,
|
||||
onUpdate,
|
||||
}: PageVisibilitySettingsProps) {
|
||||
const { t } = useTranslation();
|
||||
const term = useTerminology();
|
||||
const isPageVisibilitySet = enabledPagesInternalUsers !== null && enabledPagesInternalUsers !== undefined;
|
||||
const availablePages = useMemo(() => getAvailablePages(), []);
|
||||
|
|
@ -59,28 +61,23 @@ export default function PageVisibilitySettings({
|
|||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium text-foreground">Internal User Page Visibility</p>
|
||||
<p className="text-sm font-medium text-foreground">{t("adminPanel.pageVisibility.title")}</p>
|
||||
<Badge variant={isPageVisibilitySet ? "secondary" : "outline"}>
|
||||
{isPageVisibilitySet
|
||||
? `${selectedPages.length} page${selectedPages.length !== 1 ? "s" : ""} selected`
|
||||
: "Not set (all pages visible)"}
|
||||
? t("adminPanel.pageVisibility.selectedCount", { count: selectedPages.length })
|
||||
: t("adminPanel.pageVisibility.notSet")}
|
||||
</Badge>
|
||||
</div>
|
||||
{enabledPagesPropertyDescription && (
|
||||
<p className="text-sm text-muted-foreground">{enabledPagesPropertyDescription}</p>
|
||||
)}
|
||||
<p className="text-xs italic text-muted-foreground">
|
||||
By default, all pages are visible to internal users. Select specific pages to restrict visibility.
|
||||
</p>
|
||||
<p className="text-xs text-primary">
|
||||
Note: Only pages accessible to internal user roles are shown here. Admin-only pages are excluded as they
|
||||
cannot be made visible to internal users regardless of this setting.
|
||||
</p>
|
||||
<p className="text-xs italic text-muted-foreground">{t("adminPanel.pageVisibility.defaultHint")}</p>
|
||||
<p className="text-xs text-primary">{t("adminPanel.pageVisibility.adminOnlyNote")}</p>
|
||||
</div>
|
||||
|
||||
<Collapsible className="rounded-lg border border-border">
|
||||
<CollapsibleTrigger className="group flex w-full items-center justify-between rounded-lg px-3 py-2 text-sm font-medium hover:bg-muted">
|
||||
Configure Page Visibility
|
||||
{t("adminPanel.pageVisibility.configure")}
|
||||
<ChevronDown className="size-4 transition-transform group-data-[panel-open]:rotate-180" />
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="border-t border-border p-4">
|
||||
|
|
@ -113,11 +110,11 @@ export default function PageVisibilitySettings({
|
|||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" onClick={handleSavePageVisibility} disabled={isUpdating}>
|
||||
Save Page Visibility Settings
|
||||
{t("adminPanel.pageVisibility.save")}
|
||||
</Button>
|
||||
{isPageVisibilitySet && (
|
||||
<Button type="button" variant="outline" onClick={handleResetToDefault} disabled={isUpdating}>
|
||||
Reset to Default (All Pages)
|
||||
{t("adminPanel.pageVisibility.reset")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
"use client";
|
||||
|
||||
import type { ParseKeys } from "i18next";
|
||||
import { CircleHelp } from "lucide-react";
|
||||
import { Fragment, type ReactNode } from "react";
|
||||
import { Controller } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { z } from "zod";
|
||||
|
||||
import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings";
|
||||
|
|
@ -32,12 +34,10 @@ type SaveEditableFields = ReturnType<typeof useUpdateUISettings>["mutate"];
|
|||
|
||||
const MAX_BUDGET_FIELD: TeamAdminSettingsField = "max_budget";
|
||||
|
||||
const RAISE_MAX_BUDGET_HELP_LABEL = "About raising the team's max budget";
|
||||
const RAISE_MAX_BUDGET_TOOLTIP =
|
||||
"Lets team admins raise the budget too, capped by the organization's budget when the team has one. Only proxy admins can remove it.";
|
||||
const METADATA_HELP_LABEL = "About editing team metadata";
|
||||
const METADATA_TOOLTIP =
|
||||
"Lets team admins add, change and remove their own metadata keys. Keys the proxy reads as settings stay with proxy admins, and keys declared in the team metadata schema can be changed but not removed.";
|
||||
const RAISE_MAX_BUDGET_HELP_LABEL: ParseKeys = "adminPanel.teamAdminEditableFields.raiseMaxBudgetHelp.ariaLabel";
|
||||
const RAISE_MAX_BUDGET_TOOLTIP: ParseKeys = "adminPanel.teamAdminEditableFields.raiseMaxBudgetHelp.tooltip";
|
||||
const METADATA_HELP_LABEL: ParseKeys = "adminPanel.teamAdminEditableFields.metadataHelp.ariaLabel";
|
||||
const METADATA_TOOLTIP: ParseKeys = "adminPanel.teamAdminEditableFields.metadataHelp.tooltip";
|
||||
const TEAM_ADMIN_FIELDS_DOCS_URL =
|
||||
"https://docs.litellm.ai/docs/proxy/access_control#choosing-what-team-admins-can-edit";
|
||||
|
||||
|
|
@ -54,6 +54,7 @@ const toggleEditableField = (
|
|||
};
|
||||
|
||||
export default function TeamAdminEditableFieldsSettings() {
|
||||
const { t } = useTranslation();
|
||||
const { accessToken } = useAuthorized();
|
||||
const { data, isLoading } = useUISettings();
|
||||
const { mutate: saveSettings, isPending } = useUpdateUISettings(accessToken);
|
||||
|
|
@ -65,16 +66,16 @@ export default function TeamAdminEditableFieldsSettings() {
|
|||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<CardTitle>Team admin editable fields</CardTitle>
|
||||
<CardTitle>{t("adminPanel.teamAdminEditableFields.title")}</CardTitle>
|
||||
<Badge variant={enabledFields.length > 0 ? "secondary" : "outline"}>
|
||||
{enabledFields.length > 0
|
||||
? `${enabledFields.length} field${enabledFields.length !== 1 ? "s" : ""} enabled`
|
||||
: "Team admins cannot edit team settings"}
|
||||
? t("adminPanel.teamAdminEditableFields.enabledCount", { count: enabledFields.length })
|
||||
: t("adminPanel.teamAdminEditableFields.noneEnabled")}
|
||||
</Badge>
|
||||
</div>
|
||||
<CardDescription>
|
||||
{data?.field_schema?.properties?.team_admin_editable_team_fields?.description ??
|
||||
"Team settings fields a team admin may change on the teams they administer."}
|
||||
t("adminPanel.teamAdminEditableFields.defaultDescription")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
|
|
@ -107,6 +108,7 @@ function TeamAdminEditableFieldsForm({
|
|||
isPending,
|
||||
saveSettings,
|
||||
}: TeamAdminEditableFieldsFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const form = useZodForm(editableFieldsSchema, {
|
||||
defaultValues: { team_admin_editable_team_fields: [...enabledFields] },
|
||||
});
|
||||
|
|
@ -114,7 +116,7 @@ function TeamAdminEditableFieldsForm({
|
|||
saveSettings(values, {
|
||||
onSuccess: () => {
|
||||
form.reset(values);
|
||||
toast.success("Team admin editable fields updated successfully");
|
||||
toast.success(t("adminPanel.teamAdminEditableFields.toasts.updated"));
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.fromError(error);
|
||||
|
|
@ -124,9 +126,7 @@ function TeamAdminEditableFieldsForm({
|
|||
|
||||
if (supportedFields.length === 0) {
|
||||
return (
|
||||
<p className="text-sm italic text-muted-foreground">
|
||||
This proxy version does not support enabling any team settings fields for team admins yet.
|
||||
</p>
|
||||
<p className="text-sm italic text-muted-foreground">{t("adminPanel.teamAdminEditableFields.unsupported")}</p>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -152,7 +152,7 @@ function TeamAdminEditableFieldsForm({
|
|||
onCheckedChange={toggle(name)}
|
||||
help={
|
||||
name === TEAM_ADMIN_METADATA_FIELD ? (
|
||||
<FieldHelp label={METADATA_HELP_LABEL} text={METADATA_TOOLTIP} />
|
||||
<FieldHelp label={t(METADATA_HELP_LABEL)} text={t(METADATA_TOOLTIP)} />
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
|
@ -163,7 +163,7 @@ function TeamAdminEditableFieldsForm({
|
|||
disabled={isPending || !field.value.includes(MAX_BUDGET_FIELD)}
|
||||
onCheckedChange={toggle(TEAM_ADMIN_RAISE_MAX_BUDGET_PERMISSION)}
|
||||
className="ml-6"
|
||||
help={<FieldHelp label={RAISE_MAX_BUDGET_HELP_LABEL} text={RAISE_MAX_BUDGET_TOOLTIP} />}
|
||||
help={<FieldHelp label={t(RAISE_MAX_BUDGET_HELP_LABEL)} text={t(RAISE_MAX_BUDGET_TOOLTIP)} />}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
|
|
@ -174,7 +174,7 @@ function TeamAdminEditableFieldsForm({
|
|||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={isPending || !form.formState.isDirty}>
|
||||
{isPending ? "Saving..." : "Save"}
|
||||
{isPending ? t("adminPanel.teamAdminEditableFields.saving") : t("adminPanel.teamAdminEditableFields.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
|
@ -216,6 +216,7 @@ interface FieldHelpProps {
|
|||
}
|
||||
|
||||
function FieldHelp({ label, text }: FieldHelpProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
|
|
@ -230,7 +231,7 @@ function FieldHelp({ label, text }: FieldHelpProps) {
|
|||
<span>
|
||||
{text}{" "}
|
||||
<a href={TEAM_ADMIN_FIELDS_DOCS_URL} target="_blank" rel="noopener noreferrer" className="underline">
|
||||
Learn more
|
||||
{t("adminPanel.teamAdminEditableFields.learnMore")}
|
||||
</a>
|
||||
</span>
|
||||
</TooltipContent>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTerminology } from "@/contexts/TerminologyContext";
|
||||
import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings";
|
||||
import { useUpdateUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUpdateUISettings";
|
||||
|
|
@ -53,6 +54,7 @@ function SettingRow({
|
|||
}
|
||||
|
||||
export default function UISettings() {
|
||||
const { t } = useTranslation();
|
||||
const term = useTerminology();
|
||||
const { accessToken } = useAuthorized();
|
||||
const { data, isLoading, isError, error } = useUISettings();
|
||||
|
|
@ -89,7 +91,7 @@ export default function UISettings() {
|
|||
{ disable_model_add_for_internal_users: checked },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success("UI settings updated successfully");
|
||||
toast.success(t("adminPanel.uiSettings.toasts.updated"));
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.fromError(error);
|
||||
|
|
@ -103,7 +105,7 @@ export default function UISettings() {
|
|||
{ disable_team_admin_delete_team_user: checked },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success("UI settings updated successfully");
|
||||
toast.success(t("adminPanel.uiSettings.toasts.updated"));
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.fromError(error);
|
||||
|
|
@ -115,7 +117,7 @@ export default function UISettings() {
|
|||
const handleUpdatePageVisibility = (settings: { enabled_ui_pages_internal_users: string[] | null }) => {
|
||||
updateSettings(settings, {
|
||||
onSuccess: () => {
|
||||
toast.success("Page visibility settings updated successfully");
|
||||
toast.success(t("adminPanel.uiSettings.toasts.pageVisibilityUpdated"));
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.fromError(error);
|
||||
|
|
@ -128,7 +130,7 @@ export default function UISettings() {
|
|||
{ forward_client_headers_to_llm_api: checked },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success("UI settings updated successfully");
|
||||
toast.success(t("adminPanel.uiSettings.toasts.updated"));
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.fromError(error);
|
||||
|
|
@ -142,7 +144,7 @@ export default function UISettings() {
|
|||
{ forward_llm_provider_auth_headers: checked },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success("UI settings updated successfully");
|
||||
toast.success(t("adminPanel.uiSettings.toasts.updated"));
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.fromError(error);
|
||||
|
|
@ -156,7 +158,7 @@ export default function UISettings() {
|
|||
{ enable_projects_ui: checked },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success("UI settings updated successfully. Refreshing page...");
|
||||
toast.success(t("adminPanel.uiSettings.toasts.updatedRefreshing"));
|
||||
setTimeout(() => window.location.reload(), 1000);
|
||||
},
|
||||
onError: (error) => {
|
||||
|
|
@ -171,7 +173,7 @@ export default function UISettings() {
|
|||
{ enable_chat_ui: checked },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success("UI settings updated successfully. Refreshing page...");
|
||||
toast.success(t("adminPanel.uiSettings.toasts.updatedRefreshing"));
|
||||
setTimeout(() => window.location.reload(), 1000);
|
||||
},
|
||||
onError: (error) => {
|
||||
|
|
@ -186,7 +188,7 @@ export default function UISettings() {
|
|||
{ require_auth_for_public_ai_hub: checked },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success("UI settings updated successfully");
|
||||
toast.success(t("adminPanel.uiSettings.toasts.updated"));
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.fromError(error);
|
||||
|
|
@ -200,7 +202,7 @@ export default function UISettings() {
|
|||
{ disable_agents_for_internal_users: checked },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success("UI settings updated successfully");
|
||||
toast.success(t("adminPanel.uiSettings.toasts.updated"));
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.fromError(error);
|
||||
|
|
@ -214,7 +216,7 @@ export default function UISettings() {
|
|||
{ allow_agents_for_team_admins: checked },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success("UI settings updated successfully");
|
||||
toast.success(t("adminPanel.uiSettings.toasts.updated"));
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.fromError(error);
|
||||
|
|
@ -228,7 +230,7 @@ export default function UISettings() {
|
|||
{ disable_vector_stores_for_internal_users: checked },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success("UI settings updated successfully");
|
||||
toast.success(t("adminPanel.uiSettings.toasts.updated"));
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.fromError(error);
|
||||
|
|
@ -242,7 +244,7 @@ export default function UISettings() {
|
|||
{ allow_vector_stores_for_team_admins: checked },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success("UI settings updated successfully");
|
||||
toast.success(t("adminPanel.uiSettings.toasts.updated"));
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.fromError(error);
|
||||
|
|
@ -256,7 +258,7 @@ export default function UISettings() {
|
|||
{ scope_user_search_to_org: checked },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success("UI settings updated successfully");
|
||||
toast.success(t("adminPanel.uiSettings.toasts.updated"));
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.fromError(error);
|
||||
|
|
@ -270,7 +272,7 @@ export default function UISettings() {
|
|||
{ moyai_url: null },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success("UI settings updated successfully");
|
||||
toast.success(t("adminPanel.uiSettings.toasts.updated"));
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.fromError(error);
|
||||
|
|
@ -284,7 +286,7 @@ export default function UISettings() {
|
|||
{ disable_custom_api_keys: checked },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success("UI settings updated successfully");
|
||||
toast.success(t("adminPanel.uiSettings.toasts.updated"));
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.fromError(error);
|
||||
|
|
@ -297,19 +299,19 @@ export default function UISettings() {
|
|||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
<h3>UI Settings</h3>
|
||||
<h3>{t("adminPanel.uiSettings.title")}</h3>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div role="status" aria-label="Loading UI settings" className="space-y-3">
|
||||
<div role="status" aria-label={t("adminPanel.uiSettings.loadingAriaLabel")} className="space-y-3">
|
||||
<Skeleton className="h-5 w-72" />
|
||||
<Skeleton className="h-16 w-full" />
|
||||
<Skeleton className="h-16 w-full" />
|
||||
</div>
|
||||
) : isError ? (
|
||||
<Alert variant="error">
|
||||
<AlertTitle>Could not load UI settings</AlertTitle>
|
||||
<AlertTitle>{t("adminPanel.uiSettings.errors.loadFailed")}</AlertTitle>
|
||||
{error instanceof Error && <AlertDescription>{error.message}</AlertDescription>}
|
||||
</Alert>
|
||||
) : (
|
||||
|
|
@ -317,16 +319,15 @@ export default function UISettings() {
|
|||
{schema?.description && <p className="text-sm text-foreground">{schema.description}</p>}
|
||||
{updateError && (
|
||||
<Alert variant="error">
|
||||
<AlertTitle>Could not update UI settings</AlertTitle>
|
||||
<AlertTitle>{t("adminPanel.uiSettings.errors.updateFailed")}</AlertTitle>
|
||||
{updateError instanceof Error && <AlertDescription>{updateError.message}</AlertDescription>}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="usage-default-time-range">Default Usage time range</Label>
|
||||
<Label htmlFor="usage-default-time-range">{t("adminPanel.uiSettings.usageRange.label")}</Label>
|
||||
<p id="usage-default-time-range-help" className="text-sm text-muted-foreground">
|
||||
The starting range for everyone opening Usage. Shared links and manual date selections take precedence.
|
||||
Calendar ranges use your browser’s timezone.
|
||||
{t("adminPanel.uiSettings.usageRange.help")}
|
||||
</p>
|
||||
<Select
|
||||
value={usageRange}
|
||||
|
|
@ -337,7 +338,7 @@ export default function UISettings() {
|
|||
updateSettings(
|
||||
{ usage_default_time_range: value },
|
||||
{
|
||||
onSuccess: () => toast.success("UI settings updated successfully"),
|
||||
onSuccess: () => toast.success(t("adminPanel.uiSettings.toasts.updated")),
|
||||
onError: (error) => toast.fromError(error),
|
||||
},
|
||||
);
|
||||
|
|
@ -359,23 +360,26 @@ export default function UISettings() {
|
|||
</SelectContent>
|
||||
</Select>
|
||||
{usageRangeConfigOwned && (
|
||||
<p className="text-sm text-muted-foreground">Managed by the deployment configuration.</p>
|
||||
<p className="text-sm text-muted-foreground">{t("adminPanel.uiSettings.usageRange.managedByConfig")}</p>
|
||||
)}
|
||||
</div>
|
||||
<SettingRow
|
||||
checked={isDisabledForInternalUsers}
|
||||
disabled={isUpdating}
|
||||
onCheckedChange={handleToggle}
|
||||
ariaLabel={property?.description ?? "Disable model add for internal users"}
|
||||
label="Disable model add for internal users"
|
||||
ariaLabel={property?.description ?? t("adminPanel.uiSettings.settings.disableModelAdd.label")}
|
||||
label={t("adminPanel.uiSettings.settings.disableModelAdd.label")}
|
||||
description={property?.description}
|
||||
/>
|
||||
<SettingRow
|
||||
checked={isDisabledTeamAdminDeleteTeamUser}
|
||||
disabled={isUpdating}
|
||||
onCheckedChange={handleToggleTeamAdminDelete}
|
||||
ariaLabel={disableTeamAdminDeleteProperty?.description ?? "Disable team admin delete team user"}
|
||||
label="Disable team admin delete team user"
|
||||
ariaLabel={
|
||||
disableTeamAdminDeleteProperty?.description ??
|
||||
t("adminPanel.uiSettings.settings.disableTeamAdminDelete.label")
|
||||
}
|
||||
label={t("adminPanel.uiSettings.settings.disableTeamAdminDelete.label")}
|
||||
description={disableTeamAdminDeleteProperty?.description}
|
||||
/>
|
||||
<SettingRow
|
||||
|
|
@ -383,9 +387,10 @@ export default function UISettings() {
|
|||
disabled={isUpdating}
|
||||
onCheckedChange={handleToggleRequireAuthForPublicAIHub}
|
||||
ariaLabel={term(
|
||||
requireAuthForPublicAIHubProperty?.description ?? "Require authentication for public AI Hub",
|
||||
requireAuthForPublicAIHubProperty?.description ??
|
||||
t("adminPanel.uiSettings.settings.requireAuthForPublicAIHub.label"),
|
||||
)}
|
||||
label={term("Require authentication for public AI Hub")}
|
||||
label={term(t("adminPanel.uiSettings.settings.requireAuthForPublicAIHub.label"))}
|
||||
description={
|
||||
requireAuthForPublicAIHubProperty?.description && term(requireAuthForPublicAIHubProperty.description)
|
||||
}
|
||||
|
|
@ -394,22 +399,28 @@ export default function UISettings() {
|
|||
checked={Boolean(values.forward_client_headers_to_llm_api)}
|
||||
disabled={isUpdating}
|
||||
onCheckedChange={handleToggleForwardClientHeaders}
|
||||
ariaLabel={forwardClientHeadersProperty?.description ?? "Forward client headers to LLM API"}
|
||||
label="Forward client headers to LLM API"
|
||||
ariaLabel={
|
||||
forwardClientHeadersProperty?.description ??
|
||||
t("adminPanel.uiSettings.settings.forwardClientHeaders.label")
|
||||
}
|
||||
label={t("adminPanel.uiSettings.settings.forwardClientHeaders.label")}
|
||||
description={
|
||||
forwardClientHeadersProperty?.description ??
|
||||
"Forwards client headers (Authorization, anthropic-beta, and x-* custom headers) to the upstream LLM. Enable for Claude Code with a Max subscription (forwards the OAuth token) or to pass custom/tracing headers through to the provider. Independent of the BYOK toggle — enable only the one(s) you need."
|
||||
t("adminPanel.uiSettings.settings.forwardClientHeaders.description")
|
||||
}
|
||||
/>
|
||||
<SettingRow
|
||||
checked={Boolean(values.forward_llm_provider_auth_headers)}
|
||||
disabled={isUpdating}
|
||||
onCheckedChange={handleToggleForwardLLMProviderAuthHeaders}
|
||||
ariaLabel={forwardLLMProviderAuthHeadersProperty?.description ?? "Forward LLM provider auth headers"}
|
||||
label="Forward LLM provider auth headers"
|
||||
ariaLabel={
|
||||
forwardLLMProviderAuthHeadersProperty?.description ??
|
||||
t("adminPanel.uiSettings.settings.forwardLLMProviderAuthHeaders.label")
|
||||
}
|
||||
label={t("adminPanel.uiSettings.settings.forwardLLMProviderAuthHeaders.label")}
|
||||
description={
|
||||
forwardLLMProviderAuthHeadersProperty?.description ??
|
||||
"Forwards provider auth headers (x-api-key, x-goog-api-key, api-key, ocp-apim-subscription-key) to the upstream LLM, overriding any deployment-configured key for that request. Enable for Claude Code BYOK (clients bring their own API key). Independent of the client-headers toggle — enable only the one(s) you need."
|
||||
t("adminPanel.uiSettings.settings.forwardLLMProviderAuthHeaders.description")
|
||||
}
|
||||
/>
|
||||
{enableProjectsUIProperty && (
|
||||
|
|
@ -417,11 +428,12 @@ export default function UISettings() {
|
|||
checked={Boolean(values.enable_projects_ui)}
|
||||
disabled={isUpdating}
|
||||
onCheckedChange={handleToggleEnableProjectsUI}
|
||||
ariaLabel={enableProjectsUIProperty.description ?? "Enable Projects UI"}
|
||||
label="[BETA] Enable Projects (page will refresh)"
|
||||
ariaLabel={
|
||||
enableProjectsUIProperty.description ?? t("adminPanel.uiSettings.settings.enableProjects.ariaLabel")
|
||||
}
|
||||
label={t("adminPanel.uiSettings.settings.enableProjects.label")}
|
||||
description={
|
||||
enableProjectsUIProperty.description ??
|
||||
"If enabled, shows the Projects feature in the UI sidebar and the project field in key management."
|
||||
enableProjectsUIProperty.description ?? t("adminPanel.uiSettings.settings.enableProjects.description")
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -429,11 +441,10 @@ export default function UISettings() {
|
|||
checked={Boolean(values.enable_chat_ui)}
|
||||
disabled={isUpdating}
|
||||
onCheckedChange={handleToggleEnableChatUI}
|
||||
ariaLabel={enableChatUIProperty?.description ?? "Enable Chat page"}
|
||||
label="Enable Chat page (page will refresh)"
|
||||
ariaLabel={enableChatUIProperty?.description ?? t("adminPanel.uiSettings.settings.enableChat.ariaLabel")}
|
||||
label={t("adminPanel.uiSettings.settings.enableChat.label")}
|
||||
description={
|
||||
enableChatUIProperty?.description ??
|
||||
"If enabled, shows the Chat page in the UI sidebar, letting users chat with an LLM and connect their own MCP server credentials via OAuth."
|
||||
enableChatUIProperty?.description ?? t("adminPanel.uiSettings.settings.enableChat.description")
|
||||
}
|
||||
/>
|
||||
|
||||
|
|
@ -444,16 +455,18 @@ export default function UISettings() {
|
|||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-foreground">Moyai</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{moyaiUrl ? `Connected to ${moyaiUrl}` : "Not connected"}
|
||||
{moyaiUrl
|
||||
? t("adminPanel.uiSettings.moyai.connectedTo", { url: moyaiUrl })
|
||||
: t("adminPanel.uiSettings.moyai.notConnected")}
|
||||
</p>
|
||||
</div>
|
||||
{moyaiUrl ? (
|
||||
<Button variant="outline" size="sm" disabled={isUpdating} onClick={handleDisconnectMoyai}>
|
||||
Disconnect
|
||||
{t("adminPanel.uiSettings.moyai.disconnect")}
|
||||
</Button>
|
||||
) : (
|
||||
<a href={uiHref("moyai")} className="text-sm text-primary underline underline-offset-4">
|
||||
Connect from the Moyai page
|
||||
{t("adminPanel.uiSettings.moyai.connectLink")}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -463,16 +476,19 @@ export default function UISettings() {
|
|||
checked={isAgentsDisabled}
|
||||
disabled={isUpdating}
|
||||
onCheckedChange={handleToggleDisableAgents}
|
||||
ariaLabel={disableAgentsProperty?.description ?? "Disable agents for internal users"}
|
||||
label="Disable agents for internal users"
|
||||
ariaLabel={disableAgentsProperty?.description ?? t("adminPanel.uiSettings.settings.disableAgents.label")}
|
||||
label={t("adminPanel.uiSettings.settings.disableAgents.label")}
|
||||
description={disableAgentsProperty?.description}
|
||||
/>
|
||||
<SettingRow
|
||||
checked={Boolean(values.allow_agents_for_team_admins)}
|
||||
disabled={isUpdating || !isAgentsDisabled}
|
||||
onCheckedChange={handleToggleAllowAgentsTeamAdmins}
|
||||
ariaLabel={allowAgentsTeamAdminsProperty?.description ?? "Allow agents for team admins"}
|
||||
label="Allow agents for team admins"
|
||||
ariaLabel={
|
||||
allowAgentsTeamAdminsProperty?.description ??
|
||||
t("adminPanel.uiSettings.settings.allowAgentsTeamAdmins.label")
|
||||
}
|
||||
label={t("adminPanel.uiSettings.settings.allowAgentsTeamAdmins.label")}
|
||||
description={allowAgentsTeamAdminsProperty?.description}
|
||||
indented
|
||||
muted={!isAgentsDisabled}
|
||||
|
|
@ -483,16 +499,22 @@ export default function UISettings() {
|
|||
checked={isVectorStoresDisabled}
|
||||
disabled={isUpdating}
|
||||
onCheckedChange={handleToggleDisableVectorStores}
|
||||
ariaLabel={disableVectorStoresProperty?.description ?? "Disable vector stores for internal users"}
|
||||
label="Disable vector stores for internal users"
|
||||
ariaLabel={
|
||||
disableVectorStoresProperty?.description ??
|
||||
t("adminPanel.uiSettings.settings.disableVectorStores.label")
|
||||
}
|
||||
label={t("adminPanel.uiSettings.settings.disableVectorStores.label")}
|
||||
description={disableVectorStoresProperty?.description}
|
||||
/>
|
||||
<SettingRow
|
||||
checked={Boolean(values.allow_vector_stores_for_team_admins)}
|
||||
disabled={isUpdating || !isVectorStoresDisabled}
|
||||
onCheckedChange={handleToggleAllowVectorStoresTeamAdmins}
|
||||
ariaLabel={allowVectorStoresTeamAdminsProperty?.description ?? "Allow vector stores for team admins"}
|
||||
label="Allow vector stores for team admins"
|
||||
ariaLabel={
|
||||
allowVectorStoresTeamAdminsProperty?.description ??
|
||||
t("adminPanel.uiSettings.settings.allowVectorStoresTeamAdmins.label")
|
||||
}
|
||||
label={t("adminPanel.uiSettings.settings.allowVectorStoresTeamAdmins.label")}
|
||||
description={allowVectorStoresTeamAdminsProperty?.description}
|
||||
indented
|
||||
muted={!isVectorStoresDisabled}
|
||||
|
|
@ -503,11 +525,12 @@ export default function UISettings() {
|
|||
checked={Boolean(values.scope_user_search_to_org)}
|
||||
disabled={isUpdating}
|
||||
onCheckedChange={handleToggleScopeUserSearch}
|
||||
ariaLabel={scopeUserSearchProperty?.description ?? "Scope user search to organization"}
|
||||
label="Scope user search to organization"
|
||||
ariaLabel={
|
||||
scopeUserSearchProperty?.description ?? t("adminPanel.uiSettings.settings.scopeUserSearch.label")
|
||||
}
|
||||
label={t("adminPanel.uiSettings.settings.scopeUserSearch.label")}
|
||||
description={
|
||||
scopeUserSearchProperty?.description ??
|
||||
"If enabled, the user search endpoint restricts results by organization. When off, any authenticated user can search all users."
|
||||
scopeUserSearchProperty?.description ?? t("adminPanel.uiSettings.settings.scopeUserSearch.description")
|
||||
}
|
||||
/>
|
||||
|
||||
|
|
@ -516,11 +539,14 @@ export default function UISettings() {
|
|||
checked={Boolean(values.disable_custom_api_keys)}
|
||||
disabled={isUpdating}
|
||||
onCheckedChange={handleToggleDisableCustomApiKeys}
|
||||
ariaLabel={disableCustomApiKeysProperty?.description ?? "Disable custom Virtual key values"}
|
||||
label="Disable custom Virtual key values"
|
||||
ariaLabel={
|
||||
disableCustomApiKeysProperty?.description ??
|
||||
t("adminPanel.uiSettings.settings.disableCustomApiKeys.label")
|
||||
}
|
||||
label={t("adminPanel.uiSettings.settings.disableCustomApiKeys.label")}
|
||||
description={
|
||||
disableCustomApiKeysProperty?.description ??
|
||||
"If true, users cannot specify custom key values. All keys must be auto-generated."
|
||||
t("adminPanel.uiSettings.settings.disableCustomApiKeys.description")
|
||||
}
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import type { ParseKeys } from "i18next";
|
||||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { useUpdateUserBanner } from "@/app/(dashboard)/hooks/userBanner/useUpdateUserBanner";
|
||||
import { useUserBanner } from "@/app/(dashboard)/hooks/userBanner/useUserBanner";
|
||||
|
|
@ -16,10 +18,10 @@ import { Textarea } from "@/components/ui/textarea";
|
|||
import { SEVERITY_ICONS, UserBannerMarkdown } from "@/components/UserBanner";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
const SEVERITY_LABELS: Record<UserBannerSeverity, string> = {
|
||||
info: "Info",
|
||||
warning: "Warning",
|
||||
error: "Error",
|
||||
const SEVERITY_LABELS: Record<UserBannerSeverity, ParseKeys> = {
|
||||
info: "adminPanel.userBannerSettings.severity.options.info",
|
||||
warning: "adminPanel.userBannerSettings.severity.options.warning",
|
||||
error: "adminPanel.userBannerSettings.severity.options.error",
|
||||
};
|
||||
|
||||
const SEVERITY_ITEMS = (Object.keys(SEVERITY_LABELS) as UserBannerSeverity[]).map((severity) => ({
|
||||
|
|
@ -54,6 +56,7 @@ interface UserBannerSettingsFormProps {
|
|||
}
|
||||
|
||||
function UserBannerSettingsForm({ persisted, isLoading, isPending, saveBanner }: UserBannerSettingsFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const [draft, setDraft] = useState<UserBannerUpdate>({
|
||||
enabled: persisted.enabled,
|
||||
message: persisted.message,
|
||||
|
|
@ -65,7 +68,7 @@ function UserBannerSettingsForm({ persisted, isLoading, isPending, saveBanner }:
|
|||
const handleSave = () => {
|
||||
saveBanner(draft, {
|
||||
onSuccess: () => {
|
||||
toast.success("User banner updated successfully");
|
||||
toast.success(t("adminPanel.userBannerSettings.toasts.updated"));
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.fromError(error);
|
||||
|
|
@ -76,11 +79,8 @@ function UserBannerSettingsForm({ persisted, isLoading, isPending, saveBanner }:
|
|||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>User Banner</CardTitle>
|
||||
<CardDescription>
|
||||
Publish an announcement to all dashboard users. Markdown is supported; the banner appears below the header on
|
||||
every page until you unpublish it. Users can dismiss it, and it reappears whenever the content changes.
|
||||
</CardDescription>
|
||||
<CardTitle>{t("adminPanel.userBannerSettings.title")}</CardTitle>
|
||||
<CardDescription>{t("adminPanel.userBannerSettings.description")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
|
|
@ -91,42 +91,44 @@ function UserBannerSettingsForm({ persisted, isLoading, isPending, saveBanner }:
|
|||
<Switch
|
||||
checked={draft.enabled}
|
||||
onCheckedChange={(checked: boolean) => setDraft({ ...draft, enabled: checked })}
|
||||
aria-label="Publish user banner"
|
||||
aria-label={t("adminPanel.userBannerSettings.publish")}
|
||||
/>
|
||||
<Label>Publish user banner</Label>
|
||||
<Label>{t("adminPanel.userBannerSettings.publish")}</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="user-banner-message">Message</Label>
|
||||
<Label htmlFor="user-banner-message">{t("adminPanel.userBannerSettings.message.label")}</Label>
|
||||
<Textarea
|
||||
id="user-banner-message"
|
||||
value={draft.message}
|
||||
maxLength={4000}
|
||||
rows={3}
|
||||
placeholder="**Scheduled maintenance** tonight at 10 PM UTC. See [status page](https://example.com)."
|
||||
placeholder={t("adminPanel.userBannerSettings.message.placeholder")}
|
||||
onChange={(event: React.ChangeEvent<HTMLTextAreaElement>) =>
|
||||
setDraft({ ...draft, message: event.target.value })
|
||||
}
|
||||
/>
|
||||
{messageMissing && <p className="text-sm text-destructive">Add a message before publishing.</p>}
|
||||
{messageMissing && (
|
||||
<p className="text-sm text-destructive">{t("adminPanel.userBannerSettings.message.missing")}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Severity</Label>
|
||||
<Label>{t("adminPanel.userBannerSettings.severity.label")}</Label>
|
||||
<Select
|
||||
items={SEVERITY_ITEMS}
|
||||
items={SEVERITY_ITEMS.map((item) => ({ value: item.value, label: t(item.label) }))}
|
||||
value={draft.severity}
|
||||
onValueChange={(value: string | null) =>
|
||||
setDraft({ ...draft, severity: (value ?? "info") as UserBannerSeverity })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-48" aria-label="Banner severity">
|
||||
<SelectValue placeholder="Severity" />
|
||||
<SelectTrigger className="w-48" aria-label={t("adminPanel.userBannerSettings.severity.ariaLabel")}>
|
||||
<SelectValue placeholder={t("adminPanel.userBannerSettings.severity.placeholder")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SEVERITY_ITEMS.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
{t(item.label)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
|
@ -135,7 +137,7 @@ function UserBannerSettingsForm({ persisted, isLoading, isPending, saveBanner }:
|
|||
|
||||
{draft.message.trim() !== "" && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Preview</Label>
|
||||
<Label>{t("adminPanel.userBannerSettings.preview")}</Label>
|
||||
<Alert variant={draft.severity}>
|
||||
{SEVERITY_ICONS[draft.severity]}
|
||||
<AlertDescription>
|
||||
|
|
@ -147,7 +149,7 @@ function UserBannerSettingsForm({ persisted, isLoading, isPending, saveBanner }:
|
|||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleSave} disabled={isPending || messageMissing}>
|
||||
{isPending ? "Saving..." : "Save banner"}
|
||||
{isPending ? t("adminPanel.userBannerSettings.saving") : t("adminPanel.userBannerSettings.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { CircleHelp, Info, Save, TriangleAlert } from "lucide-react";
|
|||
import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FieldGroup } from "@/components/ui/field";
|
||||
import { FormField } from "@/components/shared/form/FormField";
|
||||
import { MultiSelect } from "@/components/shared/MultiSelect";
|
||||
|
|
@ -126,6 +127,7 @@ interface WebSearchInterceptionFormProps {
|
|||
}
|
||||
|
||||
function WebSearchInterceptionForm({ accessToken, initial, schema }: WebSearchInterceptionFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
mutate: updateSettings,
|
||||
isPending: isUpdating,
|
||||
|
|
@ -139,7 +141,7 @@ function WebSearchInterceptionForm({ accessToken, initial, schema }: WebSearchIn
|
|||
updateSettings(formValues, {
|
||||
onSuccess: () => {
|
||||
form.reset(formValues);
|
||||
toast.success("Settings updated successfully. Changes will be applied across all pods within 10 seconds.");
|
||||
toast.success(t("adminPanel.webSearchInterception.toasts.updated"));
|
||||
},
|
||||
onError: (saveError) => {
|
||||
toast.fromError(saveError);
|
||||
|
|
@ -151,7 +153,7 @@ function WebSearchInterceptionForm({ accessToken, initial, schema }: WebSearchIn
|
|||
<>
|
||||
{updateError && (
|
||||
<Alert variant="error" className="mb-4">
|
||||
<AlertTitle>Could not update settings</AlertTitle>
|
||||
<AlertTitle>{t("adminPanel.webSearchInterception.errors.updateFailed")}</AlertTitle>
|
||||
{updateError instanceof Error && <AlertDescription>{updateError.message}</AlertDescription>}
|
||||
</Alert>
|
||||
)}
|
||||
|
|
@ -165,8 +167,8 @@ function WebSearchInterceptionForm({ accessToken, initial, schema }: WebSearchIn
|
|||
control={form.control}
|
||||
name="enabled"
|
||||
label={labelWithHint(
|
||||
"Enable Web Search Interception",
|
||||
"When enabled, web search tool calls are executed server-side through the selected search tool",
|
||||
t("adminPanel.webSearchInterception.fields.enabled.label"),
|
||||
t("adminPanel.webSearchInterception.fields.enabled.hint"),
|
||||
)}
|
||||
description={schema?.properties?.enabled?.description}
|
||||
>
|
||||
|
|
@ -179,8 +181,8 @@ function WebSearchInterceptionForm({ accessToken, initial, schema }: WebSearchIn
|
|||
control={form.control}
|
||||
name="enabled_providers"
|
||||
label={labelWithHint(
|
||||
"Providers",
|
||||
"Which LLM providers to intercept for. Leave empty to intercept Bedrock only.",
|
||||
t("adminPanel.webSearchInterception.fields.providers.label"),
|
||||
t("adminPanel.webSearchInterception.fields.providers.hint"),
|
||||
)}
|
||||
description={schema?.properties?.enabled_providers?.description}
|
||||
>
|
||||
|
|
@ -190,7 +192,7 @@ function WebSearchInterceptionForm({ accessToken, initial, schema }: WebSearchIn
|
|||
options={PROVIDER_OPTIONS}
|
||||
value={value}
|
||||
onValueChange={onChange}
|
||||
placeholder="Select providers (defaults to Bedrock)"
|
||||
placeholder={t("adminPanel.webSearchInterception.fields.providers.placeholder")}
|
||||
allowCustomValues
|
||||
disabled={isUpdating}
|
||||
/>
|
||||
|
|
@ -201,8 +203,8 @@ function WebSearchInterceptionForm({ accessToken, initial, schema }: WebSearchIn
|
|||
control={form.control}
|
||||
name="search_tool_name"
|
||||
label={labelWithHint(
|
||||
"Search Tool",
|
||||
"Which configured search tool runs the searches. Leave empty to use the first one available.",
|
||||
t("adminPanel.webSearchInterception.fields.searchTool.label"),
|
||||
t("adminPanel.webSearchInterception.fields.searchTool.hint"),
|
||||
)}
|
||||
description={schema?.properties?.search_tool_name?.description}
|
||||
>
|
||||
|
|
@ -212,7 +214,7 @@ function WebSearchInterceptionForm({ accessToken, initial, schema }: WebSearchIn
|
|||
options={searchTools.map((name) => ({ label: name, value: name }))}
|
||||
value={value}
|
||||
onValueChange={onChange}
|
||||
placeholder="Select a search tool (defaults to the first available)"
|
||||
placeholder={t("adminPanel.webSearchInterception.fields.searchTool.placeholder")}
|
||||
disabled={isUpdating || loadingSearchTools}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -222,8 +224,8 @@ function WebSearchInterceptionForm({ accessToken, initial, schema }: WebSearchIn
|
|||
control={form.control}
|
||||
name="max_agentic_loops"
|
||||
label={labelWithHint(
|
||||
"Max Agentic Loops",
|
||||
"How many follow-up model calls one intercepted request may chain. Leave empty for the default of 3.",
|
||||
t("adminPanel.webSearchInterception.fields.maxAgenticLoops.label"),
|
||||
t("adminPanel.webSearchInterception.fields.maxAgenticLoops.hint"),
|
||||
)}
|
||||
description={schema?.properties?.max_agentic_loops?.description}
|
||||
>
|
||||
|
|
@ -251,7 +253,7 @@ function WebSearchInterceptionForm({ accessToken, initial, schema }: WebSearchIn
|
|||
disabled={!isDirty || isUpdating}
|
||||
>
|
||||
{isUpdating ? <UiLoadingSpinner className="size-4" /> : <Save />}
|
||||
Save Settings
|
||||
{t("adminPanel.webSearchInterception.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
|
@ -261,14 +263,13 @@ function WebSearchInterceptionForm({ accessToken, initial, schema }: WebSearchIn
|
|||
}
|
||||
|
||||
export default function WebSearchInterceptionSettings() {
|
||||
const { t } = useTranslation();
|
||||
const { accessToken } = useAuthorized();
|
||||
const { data, isLoading, isError, error } = useWebSearchInterceptionSettings();
|
||||
|
||||
if (!accessToken) {
|
||||
return (
|
||||
<div className="p-6 text-center text-muted-foreground">
|
||||
Please log in to configure web search interception settings.
|
||||
</div>
|
||||
<div className="p-6 text-center text-muted-foreground">{t("adminPanel.webSearchInterception.notLoggedIn")}</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -286,7 +287,7 @@ export default function WebSearchInterceptionSettings() {
|
|||
if (isError) {
|
||||
return (
|
||||
<Alert variant="error" className="mb-6">
|
||||
<AlertTitle>Could not load web search interception settings</AlertTitle>
|
||||
<AlertTitle>{t("adminPanel.webSearchInterception.errors.loadFailed")}</AlertTitle>
|
||||
{error instanceof Error && <AlertDescription>{error.message}</AlertDescription>}
|
||||
</Alert>
|
||||
);
|
||||
|
|
@ -300,23 +301,15 @@ export default function WebSearchInterceptionSettings() {
|
|||
{notAppliedHere && (
|
||||
<Alert variant="warning" className="mb-6">
|
||||
<TriangleAlert />
|
||||
<AlertTitle>Not running on the proxy that answered this page</AlertTitle>
|
||||
<AlertDescription>
|
||||
Interception is switched on for the cluster, but the proxy serving this page has not applied it. That is
|
||||
expected for about 10 seconds after a change or a restart. If it persists, check that proxy's logs:
|
||||
requests it handles are not being intercepted.
|
||||
</AlertDescription>
|
||||
<AlertTitle>{t("adminPanel.webSearchInterception.notApplied.title")}</AlertTitle>
|
||||
<AlertDescription>{t("adminPanel.webSearchInterception.notApplied.description")}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Alert variant="info" className="mb-6">
|
||||
<Info />
|
||||
<AlertTitle>Web Search Interception</AlertTitle>
|
||||
<AlertDescription>
|
||||
Serve web search tool calls from a configured search tool instead of passing them upstream, so models without
|
||||
native web search can still answer with fresh results. Click 'Save Settings' to apply changes across
|
||||
all pods (takes effect within 10 seconds).
|
||||
</AlertDescription>
|
||||
<AlertTitle>{t("adminPanel.webSearchInterception.info.title")}</AlertTitle>
|
||||
<AlertDescription>{t("adminPanel.webSearchInterception.info.description")}</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<WebSearchInterceptionForm
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { Inbox, Plus } from "lucide-react";
|
||||
import React, { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { DataTable } from "@/components/shared/DataTable";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -25,14 +26,17 @@ type LoggingCallbacksProps = {
|
|||
};
|
||||
|
||||
function EmptyState() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1 py-6">
|
||||
<div className="mb-1 flex size-10 items-center justify-center rounded-lg bg-muted">
|
||||
<Inbox className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="text-sm font-medium text-foreground">No callbacks configured</div>
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{t("loggingAndAlerts.loggingCallbacks.table.empty.title")}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Add your first callback to start logging data to external services.
|
||||
{t("loggingAndAlerts.loggingCallbacks.table.empty.description")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -47,18 +51,21 @@ export const LoggingCallbacksTable: React.FC<LoggingCallbacksProps> = ({
|
|||
onDelete = () => {},
|
||||
onAdd = () => {},
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const columns = useMemo(() => {
|
||||
const deps = { availableCallbacks, onTest, onEdit, onDelete };
|
||||
return getLoggingCallbacksTableColumns(deps);
|
||||
}, [availableCallbacks, onTest, onEdit, onDelete]);
|
||||
return getLoggingCallbacksTableColumns(deps, t);
|
||||
}, [availableCallbacks, onTest, onEdit, onDelete, t]);
|
||||
|
||||
return (
|
||||
<div className="mt-4 flex w-full flex-col gap-4">
|
||||
<h3 className="text-lg font-semibold tracking-tight text-foreground">Active Logging Callbacks</h3>
|
||||
<h3 className="text-lg font-semibold tracking-tight text-foreground">
|
||||
{t("loggingAndAlerts.loggingCallbacks.table.title")}
|
||||
</h3>
|
||||
<div>
|
||||
<Button onClick={onAdd}>
|
||||
<Plus />
|
||||
Add Callback
|
||||
{t("loggingAndAlerts.loggingCallbacks.table.add")}
|
||||
</Button>
|
||||
</div>
|
||||
<DataTable
|
||||
|
|
@ -66,7 +73,7 @@ export const LoggingCallbacksTable: React.FC<LoggingCallbacksProps> = ({
|
|||
columns={columns}
|
||||
getRowId={(callback, index) => `${callback.name || index}-${callbackRowMode(callback)}`}
|
||||
isLoading={isLoading}
|
||||
loadingMessage="Loading callbacks…"
|
||||
loadingMessage={t("loggingAndAlerts.loggingCallbacks.table.loading")}
|
||||
noDataMessage={<EmptyState />}
|
||||
size="compact"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import type { ParseKeys, TFunction } from "i18next";
|
||||
import { MoreHorizontal, Pencil, Play, Trash2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { StatusBadge, type StatusTone } from "@/components/shared/table_cells";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
|
|
@ -30,10 +32,10 @@ export type AvailableCallbacks = Record<string, AvailableCallbackMeta>;
|
|||
|
||||
export const callbackRowMode = (record: CallbackRow): string => record.type || record.mode || "success";
|
||||
|
||||
const CALLBACK_MODE_LABELS: Record<string, string> = {
|
||||
success: "Success",
|
||||
failure: "Failure",
|
||||
success_and_failure: "Success & Failure",
|
||||
const CALLBACK_MODE_LABELS: Record<string, ParseKeys> = {
|
||||
success: "loggingAndAlerts.loggingCallbacks.table.modes.success",
|
||||
failure: "loggingAndAlerts.loggingCallbacks.table.modes.failure",
|
||||
success_and_failure: "loggingAndAlerts.loggingCallbacks.table.modes.successAndFailure",
|
||||
};
|
||||
|
||||
function callbackModeTone(mode: string): StatusTone {
|
||||
|
|
@ -50,20 +52,18 @@ interface CallbackRowActionsProps {
|
|||
}
|
||||
|
||||
function CallbackRowActions({ callback, onTest, onEdit, onDelete }: CallbackRowActionsProps) {
|
||||
const { t } = useTranslation();
|
||||
if (callback.read_only) {
|
||||
return (
|
||||
<span
|
||||
className="text-xs text-muted-foreground"
|
||||
title="Active callback that was not added through the dashboard. Edit it where it was configured."
|
||||
>
|
||||
Read only
|
||||
<span className="text-xs text-muted-foreground" title={t("loggingAndAlerts.loggingCallbacks.table.readOnlyHint")}>
|
||||
{t("loggingAndAlerts.loggingCallbacks.table.readOnly")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
aria-label="Open callback actions"
|
||||
aria-label={t("loggingAndAlerts.loggingCallbacks.table.actions.open")}
|
||||
data-testid={`callback-actions-${callback.name}-${callbackRowMode(callback)}`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }), "text-muted-foreground")}
|
||||
>
|
||||
|
|
@ -72,16 +72,16 @@ function CallbackRowActions({ callback, onTest, onEdit, onDelete }: CallbackRowA
|
|||
<DropdownMenuContent align="end" className="w-52">
|
||||
<DropdownMenuItem data-testid="callback-action-test" onClick={() => void onTest(callback)}>
|
||||
<Play />
|
||||
Test
|
||||
{t("loggingAndAlerts.loggingCallbacks.table.actions.test")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem data-testid="callback-action-edit" onClick={() => onEdit(callback)}>
|
||||
<Pencil />
|
||||
Edit
|
||||
{t("loggingAndAlerts.loggingCallbacks.table.actions.edit")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive" data-testid="callback-action-delete" onClick={() => onDelete(callback)}>
|
||||
<Trash2 />
|
||||
Delete
|
||||
{t("loggingAndAlerts.loggingCallbacks.table.actions.delete")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
|
@ -95,17 +95,15 @@ interface LoggingCallbacksTableColumnsDeps {
|
|||
onDelete: (callback: AlertingObject) => void;
|
||||
}
|
||||
|
||||
export const getLoggingCallbacksTableColumns = ({
|
||||
availableCallbacks,
|
||||
onTest,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: LoggingCallbacksTableColumnsDeps): ColumnDef<CallbackRow>[] => [
|
||||
export const getLoggingCallbacksTableColumns = (
|
||||
{ availableCallbacks, onTest, onEdit, onDelete }: LoggingCallbacksTableColumnsDeps,
|
||||
t: TFunction,
|
||||
): ColumnDef<CallbackRow>[] => [
|
||||
{
|
||||
id: "name",
|
||||
accessorKey: "name",
|
||||
meta: { title: "Callback Name" },
|
||||
header: "Callback Name",
|
||||
meta: { title: t("loggingAndAlerts.loggingCallbacks.table.columns.name") },
|
||||
header: t("loggingAndAlerts.loggingCallbacks.table.columns.name"),
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const id = row.original.name;
|
||||
|
|
@ -119,19 +117,24 @@ export const getLoggingCallbacksTableColumns = ({
|
|||
},
|
||||
{
|
||||
id: "mode",
|
||||
meta: { title: "Mode", skeleton: "badge" },
|
||||
header: "Mode",
|
||||
meta: { title: t("loggingAndAlerts.loggingCallbacks.table.columns.mode"), skeleton: "badge" },
|
||||
header: t("loggingAndAlerts.loggingCallbacks.table.columns.mode"),
|
||||
size: 240,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const mode = callbackRowMode(row.original);
|
||||
return <StatusBadge tone={callbackModeTone(mode)} label={CALLBACK_MODE_LABELS[mode] || mode} />;
|
||||
return (
|
||||
<StatusBadge
|
||||
tone={callbackModeTone(mode)}
|
||||
label={CALLBACK_MODE_LABELS[mode] ? t(CALLBACK_MODE_LABELS[mode]) : mode}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
meta: { className: "text-right", headerClassName: "text-right" },
|
||||
header: () => <span className="sr-only">Actions</span>,
|
||||
header: () => <span className="sr-only">{t("loggingAndAlerts.loggingCallbacks.table.columns.actions")}</span>,
|
||||
size: 64,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
*/
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
|
||||
|
|
@ -25,6 +26,7 @@ interface AddFallbacksProps {
|
|||
}
|
||||
|
||||
export default function AddFallbacks({ accessToken, value = [], onChange, disabledReason }: AddFallbacksProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||
const [modelInfo, setModelInfo] = useState<ModelGroup[]>([]);
|
||||
const [modalKey, setModalKey] = useState(0); // Key to force remount of form when modal opens
|
||||
|
|
@ -83,7 +85,7 @@ export default function AddFallbacks({ accessToken, value = [], onChange, disabl
|
|||
// Validation
|
||||
const invalidGroups = groups.filter((g) => !g.primaryModel || g.fallbackModels.length === 0);
|
||||
if (invalidGroups.length > 0) {
|
||||
toast.error(`Please complete configuration for all groups. ${invalidGroups.length} group(s) incomplete.`);
|
||||
toast.error(t("routerSettings.addFallbacks.toasts.incomplete", { count: invalidGroups.length }));
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -103,7 +105,7 @@ export default function AddFallbacks({ accessToken, value = [], onChange, disabl
|
|||
setIsSaving(true);
|
||||
try {
|
||||
await onChange(updatedFallbacks);
|
||||
toast.success(`${groups.length} fallback configuration(s) added successfully!`);
|
||||
toast.success(t("routerSettings.addFallbacks.toasts.added", { count: groups.length }));
|
||||
handleCancel();
|
||||
} catch (error) {
|
||||
// Error handling is done in handleFallbacksChange, so we don't need to show another notification here
|
||||
|
|
@ -112,7 +114,7 @@ export default function AddFallbacks({ accessToken, value = [], onChange, disabl
|
|||
setIsSaving(false);
|
||||
}
|
||||
} else {
|
||||
toast.fromError("onChange callback not provided");
|
||||
toast.fromError(t("routerSettings.addFallbacks.toasts.noOnChange"));
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -123,7 +125,7 @@ export default function AddFallbacks({ accessToken, value = [], onChange, disabl
|
|||
<TooltipTrigger render={<span className="inline-flex" tabIndex={0} />}>
|
||||
<Button className="mx-auto" disabled aria-disabled="true">
|
||||
<span>+</span>
|
||||
Add Fallbacks
|
||||
{t("routerSettings.addFallbacks.addButton")}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{disabledReason}</TooltipContent>
|
||||
|
|
@ -131,7 +133,7 @@ export default function AddFallbacks({ accessToken, value = [], onChange, disabl
|
|||
) : (
|
||||
<Button className="mx-auto" onClick={() => setIsModalVisible(true)}>
|
||||
<span>+</span>
|
||||
Add Fallbacks
|
||||
{t("routerSettings.addFallbacks.addButton")}
|
||||
</Button>
|
||||
)}
|
||||
<AddFallbacksModal open={isModalVisible} onCancel={handleCancel}>
|
||||
|
|
@ -147,11 +149,11 @@ export default function AddFallbacks({ accessToken, value = [], onChange, disabl
|
|||
{groups.length > 0 && (
|
||||
<div className="flex items-center justify-end space-x-3 pt-6 mt-6 border-t border-border">
|
||||
<Button variant="outline" onClick={handleCancel} disabled={isSaving}>
|
||||
Cancel
|
||||
{t("routerSettings.addFallbacks.cancel")}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleSaveAll} disabled={groups.length === 0 || isSaving}>
|
||||
{isSaving && <UiLoadingSpinner className="size-4" />}
|
||||
{isSaving ? "Saving Configuration..." : "Save All Configurations"}
|
||||
{isSaving ? t("routerSettings.addFallbacks.saving") : t("routerSettings.addFallbacks.saveAll")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
|
||||
interface AddFallbacksModalProps {
|
||||
|
|
@ -14,6 +15,7 @@ interface AddFallbacksModalProps {
|
|||
}
|
||||
|
||||
export function AddFallbacksModal({ open, onCancel, children }: AddFallbacksModalProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(open) => !open && onCancel()} disablePointerDismissal>
|
||||
<DialogContent className="top-8 max-h-[calc(100dvh-4rem)] translate-y-0 overflow-y-auto sm:max-w-[900px]">
|
||||
|
|
@ -24,9 +26,11 @@ export function AddFallbacksModal({ open, onCancel, children }: AddFallbacksModa
|
|||
<ArrowRight className="w-5 h-5 text-indigo-600 dark:text-indigo-300" />
|
||||
</div>
|
||||
<div>
|
||||
<DialogTitle className="text-lg font-bold m-0">Configure Model Fallbacks</DialogTitle>
|
||||
<DialogTitle className="text-lg font-bold m-0">
|
||||
{t("routerSettings.addFallbacksModal.title")}
|
||||
</DialogTitle>
|
||||
<p className="text-sm text-muted-foreground font-normal m-0">
|
||||
Manage multiple fallback chains for different models (up to 5 groups at a time)
|
||||
{t("routerSettings.addFallbacksModal.description")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button";
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { LoaderCircle, Pencil } from "lucide-react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { fetchAvailableModels } from "@/components/llm_calls/fetch_models";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { AddFallbacksModal } from "./AddFallbacksModal";
|
||||
|
|
@ -42,6 +43,7 @@ export default function EditFallbacks({
|
|||
onClose,
|
||||
maxFallbacks = 10,
|
||||
}: EditFallbacksProps) {
|
||||
const { t } = useTranslation();
|
||||
const [group, setGroup] = useState<FallbackGroup>(() => toGroup(fallbackEntry));
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
|
|
@ -69,7 +71,7 @@ export default function EditFallbacks({
|
|||
setIsSaving(true);
|
||||
try {
|
||||
await onChange(updatedFallbacks);
|
||||
toast.success(`Fallbacks for ${primaryModel} updated successfully!`);
|
||||
toast.success(t("routerSettings.editFallbacks.toasts.updated", { model: primaryModel }));
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Error updating fallbacks:", error);
|
||||
|
|
@ -89,11 +91,11 @@ export default function EditFallbacks({
|
|||
/>
|
||||
<div className="flex items-center justify-end space-x-3 pt-6 mt-6 border-t border-border">
|
||||
<Button variant="outline" onClick={onClose} disabled={isSaving}>
|
||||
Cancel
|
||||
{t("routerSettings.editFallbacks.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={isSaving || group.fallbackModels.length === 0}>
|
||||
{isSaving ? <LoaderCircle className="w-4 h-4 animate-spin" /> : <Pencil className="w-4 h-4" />}
|
||||
{isSaving ? "Saving Changes..." : "Save Changes"}
|
||||
{isSaving ? t("routerSettings.editFallbacks.saving") : t("routerSettings.editFallbacks.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</AddFallbacksModal>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { MultiSelect } from "@/components/shared/MultiSelect";
|
|||
import { SearchSelect } from "@/components/shared/SearchSelect";
|
||||
import { AlertCircle, ArrowDown, X } from "lucide-react";
|
||||
import React, { useId } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export interface FallbackGroup {
|
||||
id: string;
|
||||
|
|
@ -29,6 +30,7 @@ export function FallbackGroupConfig({
|
|||
maxFallbacks,
|
||||
disablePrimaryModel = false,
|
||||
}: FallbackGroupConfigProps) {
|
||||
const { t } = useTranslation();
|
||||
// Filter available options for fallbacks (exclude primary only, allow already selected to be shown for deselection)
|
||||
const availableFallbackOptions = availableModels.filter((m) => m !== group.primaryModel);
|
||||
|
||||
|
|
@ -67,22 +69,22 @@ export function FallbackGroupConfig({
|
|||
{/* Primary Model Section */}
|
||||
<div className="relative">
|
||||
<label htmlFor={primaryModelInputId} className="block text-sm font-semibold text-foreground mb-2">
|
||||
Primary Model <span className="text-destructive">*</span>
|
||||
{t("routerSettings.fallbackGroupConfig.primaryModel")} <span className="text-destructive">*</span>
|
||||
</label>
|
||||
<SearchSelect
|
||||
inputId={primaryModelInputId}
|
||||
options={availableModels.map((m) => ({ label: m, value: m }))}
|
||||
value={group.primaryModel}
|
||||
onValueChange={handlePrimaryChange}
|
||||
placeholder="Select primary model"
|
||||
emptyText="No models found"
|
||||
placeholder={t("routerSettings.fallbackGroupConfig.primaryPlaceholder")}
|
||||
emptyText={t("routerSettings.fallbackGroupConfig.noModelsFound")}
|
||||
disabled={disablePrimaryModel}
|
||||
className="h-12"
|
||||
/>
|
||||
{!disablePrimaryModel && !group.primaryModel && (
|
||||
<div className="mt-2 flex items-center gap-2 text-warning text-xs bg-warning/10 p-2 rounded-sm">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
<span>Select a model to begin configuring fallbacks</span>
|
||||
<span>{t("routerSettings.fallbackGroupConfig.selectPrimaryHint")}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -91,7 +93,7 @@ export function FallbackGroupConfig({
|
|||
<div className="flex items-center justify-center -my-4 z-raised">
|
||||
<div className="bg-indigo-50 text-indigo-500 px-4 py-1 rounded-full text-xs font-bold border border-indigo-100 flex items-center gap-2 shadow-xs dark:bg-indigo-950 dark:text-indigo-300 dark:border-indigo-900">
|
||||
<ArrowDown className="w-4 h-4" />
|
||||
IF FAILS, TRY...
|
||||
{t("routerSettings.fallbackGroupConfig.ifFails")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -100,9 +102,9 @@ export function FallbackGroupConfig({
|
|||
className={`transition-opacity duration-300 ${!group.primaryModel ? "opacity-50 pointer-events-none" : "opacity-100"}`}
|
||||
>
|
||||
<label className="block text-sm font-semibold text-foreground mb-2">
|
||||
Fallback Chain <span className="text-destructive">*</span>
|
||||
{t("routerSettings.fallbackGroupConfig.fallbackChain")} <span className="text-destructive">*</span>
|
||||
<span className="text-xs text-muted-foreground font-normal ml-2">
|
||||
(Max {maxFallbacks} fallbacks at a time)
|
||||
{t("routerSettings.fallbackGroupConfig.maxAtATime", { max: maxFallbacks })}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
|
|
@ -114,16 +116,21 @@ export function FallbackGroupConfig({
|
|||
value={group.fallbackModels}
|
||||
onValueChange={handleFallbackSelect}
|
||||
placeholder={
|
||||
canAddMoreFallbacks ? "Select fallback models to add..." : `Maximum ${maxFallbacks} fallbacks reached`
|
||||
canAddMoreFallbacks
|
||||
? t("routerSettings.fallbackGroupConfig.addPlaceholder")
|
||||
: t("routerSettings.fallbackGroupConfig.maxReached", { max: maxFallbacks })
|
||||
}
|
||||
emptyText="No models found"
|
||||
emptyText={t("routerSettings.fallbackGroupConfig.noModelsFound")}
|
||||
disabled={!group.primaryModel}
|
||||
className="w-full"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1 ml-1">
|
||||
{canAddMoreFallbacks
|
||||
? `Search and select multiple models. Selected models will appear below in order. (${group.fallbackModels.length}/${maxFallbacks} used)`
|
||||
: `Maximum ${maxFallbacks} fallbacks reached. Remove some to add more.`}
|
||||
? t("routerSettings.fallbackGroupConfig.searchHint", {
|
||||
used: group.fallbackModels.length,
|
||||
max: maxFallbacks,
|
||||
})
|
||||
: t("routerSettings.fallbackGroupConfig.maxReachedHint", { max: maxFallbacks })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -131,11 +138,11 @@ export function FallbackGroupConfig({
|
|||
<div className="space-y-2 min-h-[100px]">
|
||||
{group.fallbackModels.length === 0 ? (
|
||||
<div className="h-32 border-2 border-dashed border-border rounded-lg flex flex-col items-center justify-center text-muted-foreground">
|
||||
<span className="text-sm">No fallback models selected</span>
|
||||
<span className="text-xs mt-1">Add models from the dropdown above</span>
|
||||
<span className="text-sm">{t("routerSettings.fallbackGroupConfig.noFallbacksSelected")}</span>
|
||||
<span className="text-xs mt-1">{t("routerSettings.fallbackGroupConfig.addFromDropdown")}</span>
|
||||
</div>
|
||||
) : (
|
||||
<ol aria-label="Fallback chain" className="space-y-2">
|
||||
<ol aria-label={t("routerSettings.fallbackGroupConfig.chainAriaLabel")} className="space-y-2">
|
||||
{group.fallbackModels.map((modelValue, index) => (
|
||||
<li
|
||||
key={`${modelValue}-${index}`}
|
||||
|
|
@ -152,7 +159,7 @@ export function FallbackGroupConfig({
|
|||
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Remove ${modelValue}`}
|
||||
aria-label={t("routerSettings.fallbackGroupConfig.removeModel", { model: modelValue })}
|
||||
onClick={() => removeFallback(index)}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-destructive p-1"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button";
|
|||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Plus, X } from "lucide-react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { FallbackGroup, FallbackGroupConfig } from "./FallbackGroupConfig";
|
||||
|
||||
|
|
@ -26,6 +27,7 @@ export function FallbackSelectionForm({
|
|||
maxFallbacks = 10,
|
||||
maxGroups = 5,
|
||||
}: FallbackSelectionFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const [activeKey, setActiveKey] = useState(groups.length > 0 ? groups[0].id : "1");
|
||||
|
||||
// Reset activeKey when groups change (e.g., when modal reopens)
|
||||
|
|
@ -61,7 +63,7 @@ export function FallbackSelectionForm({
|
|||
|
||||
const handleRemoveGroup = (targetId: string) => {
|
||||
if (groups.length === 1) {
|
||||
toast.warning("At least one group is required");
|
||||
toast.warning(t("routerSettings.fallbackSelectionForm.atLeastOneGroup"));
|
||||
return;
|
||||
}
|
||||
const newGroups = groups.filter((g) => g.id !== targetId);
|
||||
|
|
@ -77,15 +79,17 @@ export function FallbackSelectionForm({
|
|||
};
|
||||
|
||||
const groupLabel = (group: FallbackGroup, index: number) =>
|
||||
group.primaryModel ? group.primaryModel : `Group ${index + 1}`;
|
||||
group.primaryModel
|
||||
? group.primaryModel
|
||||
: t("routerSettings.fallbackSelectionForm.groupLabel", { number: index + 1 });
|
||||
|
||||
if (groups.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12 bg-muted rounded-lg border border-dashed border-border">
|
||||
<p className="text-muted-foreground mb-4">No fallback groups configured</p>
|
||||
<p className="text-muted-foreground mb-4">{t("routerSettings.fallbackSelectionForm.empty")}</p>
|
||||
<Button onClick={handleAddGroup}>
|
||||
<Plus className="w-4 h-4" />
|
||||
Create First Group
|
||||
{t("routerSettings.fallbackSelectionForm.createFirstGroup")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -108,7 +112,9 @@ export function FallbackSelectionForm({
|
|||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="absolute right-1"
|
||||
aria-label={`Remove ${groupLabel(group, index)}`}
|
||||
aria-label={t("routerSettings.fallbackSelectionForm.removeGroup", {
|
||||
group: groupLabel(group, index),
|
||||
})}
|
||||
onClick={() => handleRemoveGroup(group.id)}
|
||||
>
|
||||
<X />
|
||||
|
|
@ -118,7 +124,12 @@ export function FallbackSelectionForm({
|
|||
))}
|
||||
</TabsList>
|
||||
{groups.length < maxGroups && (
|
||||
<Button variant="ghost" size="icon-sm" aria-label="Add fallback group" onClick={handleAddGroup}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={t("routerSettings.fallbackSelectionForm.addGroup")}
|
||||
onClick={handleAddGroup}
|
||||
>
|
||||
<Plus />
|
||||
</Button>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -2,8 +2,10 @@ import { useModelCostMap } from "@/app/(dashboard)/hooks/models/useModelCostMap"
|
|||
import { ArrowRight, Pencil, Play, Trash2 } from "lucide-react";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import type { TFunction } from "i18next";
|
||||
import openai from "openai";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import DeleteResourceModal from "../../../common_components/DeleteResourceModal";
|
||||
import { ProviderLogo } from "../../../molecules/models/ProviderLogo";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
|
@ -81,7 +83,7 @@ interface FallbacksProps {
|
|||
userID: string | null;
|
||||
}
|
||||
|
||||
async function testFallbackModelResponse(selectedModel: string, accessToken: string) {
|
||||
async function testFallbackModelResponse(selectedModel: string, accessToken: string, t: TFunction) {
|
||||
const isLocal = process.env.NODE_ENV === "development";
|
||||
if (isLocal != true) {
|
||||
console.log = function () {};
|
||||
|
|
@ -94,7 +96,7 @@ async function testFallbackModelResponse(selectedModel: string, accessToken: str
|
|||
});
|
||||
|
||||
try {
|
||||
toast.info("Testing fallback model response...");
|
||||
toast.info(t("routerSettings.fallbacks.toasts.testing"));
|
||||
|
||||
const response = await client.chat.completions.create({
|
||||
model: selectedModel,
|
||||
|
|
@ -110,19 +112,24 @@ async function testFallbackModelResponse(selectedModel: string, accessToken: str
|
|||
|
||||
toast.success(
|
||||
<span>
|
||||
Test model=<strong>{selectedModel}</strong>, received model=
|
||||
<strong>{response.model}</strong>. See{" "}
|
||||
<a
|
||||
href="#"
|
||||
onClick={() => window.open("https://docs.litellm.ai/docs/proxy/reliability", "_blank")}
|
||||
style={{ textDecoration: "underline", color: "blue" }}
|
||||
>
|
||||
curl
|
||||
</a>
|
||||
<Trans
|
||||
i18nKey="routerSettings.fallbacks.toasts.testResult"
|
||||
components={{
|
||||
model: <strong>{selectedModel}</strong>,
|
||||
received: <strong>{response.model}</strong>,
|
||||
docs: (
|
||||
<a
|
||||
href="#"
|
||||
onClick={() => window.open("https://docs.litellm.ai/docs/proxy/reliability", "_blank")}
|
||||
style={{ textDecoration: "underline", color: "blue" }}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</span>,
|
||||
);
|
||||
} catch (error) {
|
||||
toast.fromError(`Error occurred while generating model response. Please try again. Error: ${error}`);
|
||||
toast.fromError(t("routerSettings.fallbacks.toasts.testFailed", { error: String(error) }));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -133,6 +140,7 @@ function fallbacksWriteHint(source: RouterSettingsSource | null, ownedByConfig:
|
|||
}
|
||||
|
||||
const Fallbacks: React.FC<FallbacksProps> = ({ accessToken, userRole, userID }) => {
|
||||
const { t } = useTranslation();
|
||||
const [routerSettings, setRouterSettings] = useState<{ [key: string]: any }>({});
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [fallbackToDelete, setFallbackToDelete] = useState<FallbackEntry | null>(null);
|
||||
|
|
@ -210,9 +218,9 @@ const Fallbacks: React.FC<FallbacksProps> = ({ accessToken, userRole, userID })
|
|||
try {
|
||||
await setCallbacksCall(accessToken, payload);
|
||||
setRouterSettings(updatedSettings);
|
||||
toast.success("Router settings updated successfully");
|
||||
toast.success(t("routerSettings.fallbacks.toasts.updated"));
|
||||
} catch (error) {
|
||||
toast.fromError("Failed to update router settings: " + error);
|
||||
toast.fromError(t("routerSettings.fallbacks.toasts.updateFailed", { error: String(error) }));
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
setIsDeleteModalOpen(false);
|
||||
|
|
@ -249,7 +257,7 @@ const Fallbacks: React.FC<FallbacksProps> = ({ accessToken, userRole, userID })
|
|||
setRouterSettings(updatedSettings);
|
||||
} catch (error) {
|
||||
// Revert on error by refetching from server
|
||||
toast.fromError("Failed to update router settings: " + error);
|
||||
toast.fromError(t("routerSettings.fallbacks.toasts.updateFailed", { error: String(error) }));
|
||||
if (accessToken && userRole && userID) {
|
||||
getCallbacksCall(accessToken, userID, userRole).then((data) => {
|
||||
let router_settings = data.router_settings;
|
||||
|
|
@ -288,17 +296,15 @@ const Fallbacks: React.FC<FallbacksProps> = ({ accessToken, userRole, userID })
|
|||
)}
|
||||
{!hasFallbacks ? (
|
||||
<div className="rounded-lg border border-border bg-muted px-4 py-6 text-center">
|
||||
<span className="text-muted-foreground">
|
||||
No fallbacks configured. Add fallbacks to automatically try another model when the primary fails.
|
||||
</span>
|
||||
<span className="text-muted-foreground">{t("routerSettings.fallbacks.empty")}</span>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Model Name</TableHead>
|
||||
<TableHead>Fallbacks</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
<TableHead>{t("routerSettings.fallbacks.columns.modelName")}</TableHead>
|
||||
<TableHead>{t("routerSettings.fallbacks.columns.fallbacks")}</TableHead>
|
||||
<TableHead>{t("routerSettings.fallbacks.columns.actions")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
|
|
@ -319,14 +325,14 @@ const Fallbacks: React.FC<FallbacksProps> = ({ accessToken, userRole, userID })
|
|||
<TooltipTrigger
|
||||
render={
|
||||
<span
|
||||
onClick={() => testFallbackModelResponse(Object.keys(item)[0], accessToken || "")}
|
||||
onClick={() => testFallbackModelResponse(Object.keys(item)[0], accessToken || "", t)}
|
||||
className={`${iconWrapperClass} cursor-pointer hover:text-info`}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Play className="h-5 w-5 shrink-0" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Test fallback</TooltipContent>
|
||||
<TooltipContent>{t("routerSettings.fallbacks.actions.test")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
|
|
@ -344,7 +350,7 @@ const Fallbacks: React.FC<FallbacksProps> = ({ accessToken, userRole, userID })
|
|||
>
|
||||
<Pencil className="h-5 w-5 shrink-0" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{writeHint ?? "Edit fallback"}</TooltipContent>
|
||||
<TooltipContent>{writeHint ?? t("routerSettings.fallbacks.actions.edit")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
|
|
@ -362,7 +368,7 @@ const Fallbacks: React.FC<FallbacksProps> = ({ accessToken, userRole, userID })
|
|||
>
|
||||
<Trash2 className="h-5 w-5 shrink-0" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{writeHint ?? "Delete fallback"}</TooltipContent>
|
||||
<TooltipContent>{writeHint ?? t("routerSettings.fallbacks.actions.delete")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
|
|
@ -385,12 +391,12 @@ const Fallbacks: React.FC<FallbacksProps> = ({ accessToken, userRole, userID })
|
|||
)}
|
||||
<DeleteResourceModal
|
||||
isOpen={isDeleteModalOpen}
|
||||
title="Delete Fallback?"
|
||||
message="Are you sure you want to delete this fallback? This action cannot be undone."
|
||||
resourceInformationTitle="Fallback Information"
|
||||
title={t("routerSettings.fallbacks.deleteModal.title")}
|
||||
message={t("routerSettings.fallbacks.deleteModal.message")}
|
||||
resourceInformationTitle={t("routerSettings.fallbacks.deleteModal.informationTitle")}
|
||||
resourceInformation={[
|
||||
{
|
||||
label: "Model Name",
|
||||
label: t("routerSettings.fallbacks.deleteModal.modelName"),
|
||||
value: fallbackToDelete ? Object.keys(fallbackToDelete)[0] : "",
|
||||
code: true,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { CircleHelp } from "lucide-react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useWatch } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { z } from "zod";
|
||||
|
||||
import { FieldGroup } from "@/components/ui/field";
|
||||
|
|
@ -12,6 +13,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp
|
|||
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
|
||||
import { useZodForm } from "@/lib/forms/useZodForm";
|
||||
|
||||
import i18n from "@/i18n/i18n";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { getSSOSettings, updateSSOSettings } from "./networking";
|
||||
|
||||
|
|
@ -33,15 +35,15 @@ const uiAccessControlSchema = z
|
|||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["restricted_sso_group"],
|
||||
message: "Please enter the restricted SSO group",
|
||||
message: i18n.t("adminPanel.uiAccessControl.errors.restrictedGroupRequired"),
|
||||
});
|
||||
});
|
||||
|
||||
type UIAccessControlFormValues = z.output<typeof uiAccessControlSchema>;
|
||||
|
||||
const UI_ACCESS_MODE_OPTIONS = [
|
||||
{ value: "all_authenticated_users", label: "All Authenticated Users" },
|
||||
{ value: "restricted_sso_group", label: "Restricted SSO Group" },
|
||||
{ value: "all_authenticated_users", label: "adminPanel.uiAccessControl.modes.allAuthenticatedUsers" },
|
||||
{ value: "restricted_sso_group", label: "adminPanel.uiAccessControl.modes.restrictedSsoGroup" },
|
||||
] as const;
|
||||
|
||||
const asRecord = (value: unknown): Record<string, unknown> | null =>
|
||||
|
|
@ -87,6 +89,7 @@ const labelWithHint = (label: string, hint: string): React.ReactNode => (
|
|||
);
|
||||
|
||||
const UIAccessControlForm: React.FC<UIAccessControlFormProps> = ({ accessToken, onSuccess }) => {
|
||||
const { t } = useTranslation();
|
||||
const form = useZodForm(uiAccessControlSchema, { defaultValues: {} });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const uiAccessModeType = useWatch({ control: form.control, name: "ui_access_mode_type" });
|
||||
|
|
@ -112,7 +115,7 @@ const UIAccessControlForm: React.FC<UIAccessControlFormProps> = ({ accessToken,
|
|||
|
||||
const handleUIAccessSubmit = async (formValues: UIAccessControlFormValues) => {
|
||||
if (!accessToken) {
|
||||
toast.fromError("No access token available");
|
||||
toast.fromError(t("adminPanel.uiAccessControl.toasts.noAccessToken"));
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -133,7 +136,7 @@ const UIAccessControlForm: React.FC<UIAccessControlFormProps> = ({ accessToken,
|
|||
onSuccess();
|
||||
} catch (error) {
|
||||
console.error("Failed to save UI access settings:", error);
|
||||
toast.fromError("Failed to save UI access settings");
|
||||
toast.fromError(t("adminPanel.uiAccessControl.toasts.saveFailed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
|
@ -150,9 +153,7 @@ const UIAccessControlForm: React.FC<UIAccessControlFormProps> = ({ accessToken,
|
|||
<TooltipProvider>
|
||||
<div className="p-4">
|
||||
<div className="mb-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Configure who can access the UI interface and how group information is extracted from JWT tokens.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{t("adminPanel.uiAccessControl.description")}</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={form.handleSubmit(submitMountedValues)} noValidate>
|
||||
|
|
@ -160,11 +161,14 @@ const UIAccessControlForm: React.FC<UIAccessControlFormProps> = ({ accessToken,
|
|||
<FormField
|
||||
control={form.control}
|
||||
name="ui_access_mode_type"
|
||||
label={labelWithHint("UI Access Mode", "Controls who can access the UI interface")}
|
||||
label={labelWithHint(
|
||||
t("adminPanel.uiAccessControl.fields.accessMode"),
|
||||
t("adminPanel.uiAccessControl.fields.accessModeHint"),
|
||||
)}
|
||||
>
|
||||
{({ id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy }) => (
|
||||
<Select
|
||||
items={UI_ACCESS_MODE_OPTIONS}
|
||||
items={UI_ACCESS_MODE_OPTIONS.map((option) => ({ value: option.value, label: t(option.label) }))}
|
||||
value={value ?? null}
|
||||
onValueChange={(selected) => onChange(selected ?? undefined)}
|
||||
>
|
||||
|
|
@ -174,12 +178,12 @@ const UIAccessControlForm: React.FC<UIAccessControlFormProps> = ({ accessToken,
|
|||
aria-invalid={ariaInvalid}
|
||||
aria-describedby={ariaDescribedBy}
|
||||
>
|
||||
<SelectValue placeholder="Select access mode" />
|
||||
<SelectValue placeholder={t("adminPanel.uiAccessControl.fields.accessModePlaceholder")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{UI_ACCESS_MODE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
{t(option.label)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
|
@ -188,7 +192,11 @@ const UIAccessControlForm: React.FC<UIAccessControlFormProps> = ({ accessToken,
|
|||
</FormField>
|
||||
|
||||
{uiAccessModeType === "restricted_sso_group" && (
|
||||
<FormField control={form.control} name="restricted_sso_group" label="Restricted SSO Group">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="restricted_sso_group"
|
||||
label={t("adminPanel.uiAccessControl.fields.restrictedGroup")}
|
||||
>
|
||||
{({ ref, value, ...field }) => (
|
||||
<Input {...field} ref={ref} value={value ?? ""} placeholder="ui-access-group" />
|
||||
)}
|
||||
|
|
@ -199,8 +207,8 @@ const UIAccessControlForm: React.FC<UIAccessControlFormProps> = ({ accessToken,
|
|||
control={form.control}
|
||||
name="sso_group_jwt_field"
|
||||
label={labelWithHint(
|
||||
"SSO Group JWT Field",
|
||||
"JWT field name that contains team/group information. Use dot notation to access nested fields.",
|
||||
t("adminPanel.uiAccessControl.fields.jwtField"),
|
||||
t("adminPanel.uiAccessControl.fields.jwtFieldHint"),
|
||||
)}
|
||||
>
|
||||
{({ ref, value, ...field }) => <Input {...field} ref={ref} value={value ?? ""} placeholder="groups" />}
|
||||
|
|
@ -210,7 +218,7 @@ const UIAccessControlForm: React.FC<UIAccessControlFormProps> = ({ accessToken,
|
|||
<div className="mt-4 text-right">
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading && <UiLoadingSpinner className="size-4" />}
|
||||
Update UI Access Control
|
||||
{t("adminPanel.uiAccessControl.submit")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
* UI for controlling slack alerting settings
|
||||
*/
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { alertingSettingsCall, updateConfigFieldSetting } from "../networking";
|
||||
import DynamicForm from "./dynamic_form";
|
||||
|
|
@ -23,6 +24,7 @@ interface AlertingSettingsProps {
|
|||
}
|
||||
|
||||
const AlertingSettings: React.FC<AlertingSettingsProps> = ({ accessToken, premiumUser }) => {
|
||||
const { t } = useTranslation();
|
||||
const [alertingSettings, setAlertingSettings] = useState<alertingSettingsItem[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -74,7 +76,7 @@ const AlertingSettings: React.FC<AlertingSettingsProps> = ({ accessToken, premiu
|
|||
}
|
||||
}
|
||||
// update value in state
|
||||
toast.success("Wait 10s for proxy to update.");
|
||||
toast.success(t("loggingAndAlerts.alerting.toasts.saved"));
|
||||
} catch (error) {
|
||||
toast.error(extractProxyErrorMessage(error));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CircleCheck, Trash2 } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -33,6 +34,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({
|
|||
handleSubmit,
|
||||
premiumUser,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const form = useForm<AlertingFormValues>({ defaultValues: {} });
|
||||
|
||||
const onFinish = (formData: AlertingFormValues) => {
|
||||
|
|
@ -97,7 +99,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({
|
|||
<TableCell>
|
||||
<Button className="flex items-center justify-center">
|
||||
<a href="https://forms.gle/W3U4PZpJGFHWtHyA9" target="_blank">
|
||||
✨ Enterprise Feature
|
||||
{t("loggingAndAlerts.alerting.enterpriseFeature")}
|
||||
</a>
|
||||
</Button>
|
||||
</TableCell>
|
||||
|
|
@ -108,12 +110,12 @@ const DynamicForm: React.FC<DynamicFormProps> = ({
|
|||
{value.stored_in_db == true ? (
|
||||
<Badge variant="secondary">
|
||||
<CircleCheck />
|
||||
In DB
|
||||
{t("loggingAndAlerts.alerting.storage.inDb")}
|
||||
</Badge>
|
||||
) : value.stored_in_db == false ? (
|
||||
<Badge variant="outline">In Config</Badge>
|
||||
<Badge variant="outline">{t("loggingAndAlerts.alerting.storage.inConfig")}</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">Not Set</Badge>
|
||||
<Badge variant="outline">{t("loggingAndAlerts.alerting.storage.notSet")}</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
|
|
@ -121,7 +123,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({
|
|||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Reset ${value.field_name}`}
|
||||
aria-label={t("loggingAndAlerts.alerting.resetField", { field: value.field_name })}
|
||||
onClick={() => handleResetField(value.field_name, index)}
|
||||
className="text-destructive"
|
||||
>
|
||||
|
|
@ -131,7 +133,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({
|
|||
</TableRow>
|
||||
))}
|
||||
<div>
|
||||
<Button type="submit">Update Settings</Button>
|
||||
<Button type="submit">{t("loggingAndAlerts.alerting.submit")}</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { CircleMinus, Plus } from "lucide-react";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
useFieldArray,
|
||||
type Control,
|
||||
|
|
@ -96,11 +97,19 @@ const MetadataRow = <TFieldValues extends FieldValues>({
|
|||
schemaLabel,
|
||||
onRemove,
|
||||
}: MetadataRowProps<TFieldValues>) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="mb-2 flex items-start gap-2">
|
||||
{schemaLabel === undefined ? (
|
||||
<FormField control={control} name={`${name}.${index}.key` as FieldPath<TFieldValues>}>
|
||||
{({ ref, value, ...rest }) => <Input {...rest} ref={ref} value={(value as string) ?? ""} placeholder="Key" />}
|
||||
{({ ref, value, ...rest }) => (
|
||||
<Input
|
||||
{...rest}
|
||||
ref={ref}
|
||||
value={(value as string) ?? ""}
|
||||
placeholder={t("metadataKeyValueFields.keyPlaceholder")}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
) : (
|
||||
<Label
|
||||
|
|
@ -118,7 +127,7 @@ const MetadataRow = <TFieldValues extends FieldValues>({
|
|||
id={schemaLabel === undefined ? id : `${rowId}-value`}
|
||||
ref={ref}
|
||||
value={(value as string) ?? ""}
|
||||
placeholder="Value"
|
||||
placeholder={t("metadataKeyValueFields.valuePlaceholder")}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
|
|
@ -126,7 +135,7 @@ const MetadataRow = <TFieldValues extends FieldValues>({
|
|||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Remove key-value pair"
|
||||
aria-label={t("metadataKeyValueFields.removePair")}
|
||||
className="mt-1 text-destructive"
|
||||
onClick={onRemove}
|
||||
>
|
||||
|
|
@ -144,6 +153,7 @@ const MetadataKeyValueFields = <TFieldValues extends FieldValues>({
|
|||
schemaFields = [],
|
||||
schemaLoading = false,
|
||||
}: MetadataKeyValueFieldsProps<TFieldValues>) => {
|
||||
const { t } = useTranslation();
|
||||
const { fields, append, remove } = useFieldArray({ control, name });
|
||||
const seededRef = useRef(false);
|
||||
const schemaLabelsByKey = new Map(schemaFields.map((field) => [field.key, field.label || field.key]));
|
||||
|
|
@ -208,7 +218,7 @@ const MetadataKeyValueFields = <TFieldValues extends FieldValues>({
|
|||
onClick={() => append({ key: "", value: "" } as never, { shouldFocus: false })}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
Add Key-Value Pair
|
||||
{t("metadataKeyValueFields.addPair")}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
|
|
@ -14,6 +15,7 @@ interface EmailEventSettingsProps {
|
|||
}
|
||||
|
||||
const EmailEventSettings: React.FC<EmailEventSettingsProps> = ({ accessToken }) => {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [eventSettings, setEventSettings] = useState<EmailEventSetting[]>([]);
|
||||
|
||||
|
|
@ -49,7 +51,7 @@ const EmailEventSettings: React.FC<EmailEventSettingsProps> = ({ accessToken })
|
|||
|
||||
try {
|
||||
await updateEmailEventSettings(accessToken, { settings: eventSettings });
|
||||
toast.success("Email event settings updated successfully");
|
||||
toast.success(t("loggingAndAlerts.emailEvents.toasts.saved"));
|
||||
} catch (error) {
|
||||
console.error("Failed to update email event settings:", error);
|
||||
toast.fromError(error);
|
||||
|
|
@ -61,7 +63,7 @@ const EmailEventSettings: React.FC<EmailEventSettingsProps> = ({ accessToken })
|
|||
|
||||
try {
|
||||
await resetEmailEventSettings(accessToken);
|
||||
toast.success("Email event settings reset to defaults");
|
||||
toast.success(t("loggingAndAlerts.emailEvents.toasts.reset"));
|
||||
// Refresh settings after reset
|
||||
fetchEventSettings();
|
||||
} catch (error) {
|
||||
|
|
@ -74,24 +76,24 @@ const EmailEventSettings: React.FC<EmailEventSettingsProps> = ({ accessToken })
|
|||
const getEventDescription = (event: EmailEvent): string => {
|
||||
// Convert event name to a sentence with more context
|
||||
if (event.includes("Virtual Key Created")) {
|
||||
return "An email will be sent to the user when a new virtual key is created with their user ID";
|
||||
return t("loggingAndAlerts.emailEvents.descriptions.virtualKeyCreated");
|
||||
} else if (event.includes("New User Invitation")) {
|
||||
return "An email will be sent to the email address of the user when a new user is created";
|
||||
return t("loggingAndAlerts.emailEvents.descriptions.newUserInvitation");
|
||||
} else {
|
||||
// Handle any other event type from the API
|
||||
const words = event
|
||||
.split(/(?=[A-Z])/)
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
return `Receive an email notification when ${words}`;
|
||||
return t("loggingAndAlerts.emailEvents.descriptions.generic", { event: words });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Email Notifications</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">Select which events should trigger email notifications.</p>
|
||||
<CardTitle className="text-base">{t("loggingAndAlerts.emailEvents.title")}</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">{t("loggingAndAlerts.emailEvents.description")}</p>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
|
|
@ -122,10 +124,10 @@ const EmailEventSettings: React.FC<EmailEventSettingsProps> = ({ accessToken })
|
|||
|
||||
<div className="mt-6 flex gap-4">
|
||||
<Button onClick={handleSaveSettings} disabled={loading}>
|
||||
Save Changes
|
||||
{t("loggingAndAlerts.emailEvents.saveChanges")}
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={handleResetSettings} disabled={loading}>
|
||||
Reset to Defaults
|
||||
{t("loggingAndAlerts.emailEvents.resetToDefaults")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React, { useState } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group";
|
||||
|
|
@ -13,19 +14,46 @@ interface EmailSettingsProps {
|
|||
alerts: any[];
|
||||
}
|
||||
|
||||
const REQUIRED_MARKER = <span className="text-destructive"> Required * </span>;
|
||||
const REQUIRED_MARKER = (
|
||||
<span className="text-destructive">
|
||||
<Trans i18nKey="loggingAndAlerts.emailSettings.required" />
|
||||
</span>
|
||||
);
|
||||
|
||||
const FIELD_HELP: Record<string, React.ReactNode> = {
|
||||
SMTP_HOST: <>Enter the SMTP host address, e.g. `smtp.resend.com`{REQUIRED_MARKER}</>,
|
||||
SMTP_PORT: <>Enter the SMTP port number, e.g. `587`{REQUIRED_MARKER}</>,
|
||||
SMTP_USERNAME: <>Enter the SMTP username, e.g. `username`{REQUIRED_MARKER}</>,
|
||||
SMTP_PASSWORD: REQUIRED_MARKER,
|
||||
SMTP_SENDER_EMAIL: <>Enter the sender email address, e.g. `sender@berri.ai`{REQUIRED_MARKER}</>,
|
||||
TEST_EMAIL_ADDRESS: <>Email Address to send `Test Email Alert` to. example: `info@berri.ai`{REQUIRED_MARKER}</>,
|
||||
EMAIL_LOGO_URL: <>(Optional) Customize the Logo that appears in the email, pass a url to your logo</>,
|
||||
EMAIL_SUPPORT_CONTACT: (
|
||||
<>(Optional) Customize the support email address that appears in the email. Default is support@berri.ai</>
|
||||
SMTP_HOST: (
|
||||
<>
|
||||
<Trans i18nKey="loggingAndAlerts.emailSettings.help.smtpHost" />
|
||||
{REQUIRED_MARKER}
|
||||
</>
|
||||
),
|
||||
SMTP_PORT: (
|
||||
<>
|
||||
<Trans i18nKey="loggingAndAlerts.emailSettings.help.smtpPort" />
|
||||
{REQUIRED_MARKER}
|
||||
</>
|
||||
),
|
||||
SMTP_USERNAME: (
|
||||
<>
|
||||
<Trans i18nKey="loggingAndAlerts.emailSettings.help.smtpUsername" />
|
||||
{REQUIRED_MARKER}
|
||||
</>
|
||||
),
|
||||
SMTP_PASSWORD: REQUIRED_MARKER,
|
||||
SMTP_SENDER_EMAIL: (
|
||||
<>
|
||||
<Trans i18nKey="loggingAndAlerts.emailSettings.help.smtpSenderEmail" />
|
||||
{REQUIRED_MARKER}
|
||||
</>
|
||||
),
|
||||
TEST_EMAIL_ADDRESS: (
|
||||
<>
|
||||
<Trans i18nKey="loggingAndAlerts.emailSettings.help.testEmailAddress" />
|
||||
{REQUIRED_MARKER}
|
||||
</>
|
||||
),
|
||||
EMAIL_LOGO_URL: <Trans i18nKey="loggingAndAlerts.emailSettings.help.emailLogoUrl" />,
|
||||
EMAIL_SUPPORT_CONTACT: <Trans i18nKey="loggingAndAlerts.emailSettings.help.emailSupportContact" />,
|
||||
};
|
||||
|
||||
const PREMIUM_ONLY_FIELDS = ["EMAIL_LOGO_URL", "EMAIL_SUPPORT_CONTACT"];
|
||||
|
|
@ -33,6 +61,7 @@ const PREMIUM_ONLY_FIELDS = ["EMAIL_LOGO_URL", "EMAIL_SUPPORT_CONTACT"];
|
|||
const SENSITIVE_FIELD_PATTERN = /(PASSWORD|SECRET|KEY|TOKEN)/i;
|
||||
|
||||
const EmailSettings: React.FC<EmailSettingsProps> = ({ accessToken, premiumUser, alerts }) => {
|
||||
const { t } = useTranslation();
|
||||
const [visibleFields, setVisibleFields] = useState<Record<string, boolean>>({});
|
||||
|
||||
const toggleFieldVisibility = (key: string) => {
|
||||
|
|
@ -78,7 +107,7 @@ const EmailSettings: React.FC<EmailSettingsProps> = ({ accessToken, premiumUser,
|
|||
};
|
||||
try {
|
||||
await setCallbacksCall(accessToken, payload);
|
||||
toast.success("Email settings updated successfully");
|
||||
toast.success(t("loggingAndAlerts.emailSettings.toasts.saved"));
|
||||
} catch (error) {
|
||||
toast.fromError(error);
|
||||
}
|
||||
|
|
@ -91,7 +120,7 @@ const EmailSettings: React.FC<EmailSettingsProps> = ({ accessToken, premiumUser,
|
|||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Email Server Settings</CardTitle>
|
||||
<CardTitle className="text-base">{t("loggingAndAlerts.emailSettings.title")}</CardTitle>
|
||||
<p className="text-sm">
|
||||
<a
|
||||
href="https://docs.litellm.ai/docs/proxy/email"
|
||||
|
|
@ -99,7 +128,7 @@ const EmailSettings: React.FC<EmailSettingsProps> = ({ accessToken, premiumUser,
|
|||
rel="noreferrer"
|
||||
className="text-primary underline underline-offset-4"
|
||||
>
|
||||
LiteLLM Docs: email alerts
|
||||
{t("loggingAndAlerts.emailSettings.docsLink")}
|
||||
</a>
|
||||
</p>
|
||||
</CardHeader>
|
||||
|
|
@ -139,7 +168,11 @@ const EmailSettings: React.FC<EmailSettingsProps> = ({ accessToken, premiumUser,
|
|||
<InputGroupButton
|
||||
size="icon-xs"
|
||||
onClick={() => toggleFieldVisibility(key)}
|
||||
aria-label={isVisible ? "Hide credential" : "Show credential"}
|
||||
aria-label={
|
||||
isVisible
|
||||
? t("loggingAndAlerts.emailSettings.hideCredential")
|
||||
: t("loggingAndAlerts.emailSettings.showCredential")
|
||||
}
|
||||
>
|
||||
{isVisible ? <EyeOff /> : <Eye />}
|
||||
</InputGroupButton>
|
||||
|
|
@ -154,20 +187,20 @@ const EmailSettings: React.FC<EmailSettingsProps> = ({ accessToken, premiumUser,
|
|||
))}
|
||||
|
||||
<div className="mt-6 flex gap-2">
|
||||
<Button onClick={() => handleSaveEmailSettings()}>Save Changes</Button>
|
||||
<Button onClick={() => handleSaveEmailSettings()}>{t("loggingAndAlerts.emailSettings.saveChanges")}</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={async () => {
|
||||
if (!accessToken) return;
|
||||
try {
|
||||
await serviceHealthCheck(accessToken, "email");
|
||||
toast.success("Email test triggered. Check your configured email inbox/logs.");
|
||||
toast.success(t("loggingAndAlerts.emailSettings.toasts.testTriggered"));
|
||||
} catch (error) {
|
||||
toast.fromError(error);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Test Email Alerts
|
||||
{t("loggingAndAlerts.emailSettings.testEmailAlerts")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import React, { useEffect, useMemo } from "react";
|
||||
import type { ParseKeys } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useWatch } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { FieldGroup } from "@/components/ui/field";
|
||||
|
|
@ -48,8 +50,8 @@ interface RoutingGroupModalProps {
|
|||
saving?: boolean;
|
||||
}
|
||||
|
||||
const ARGS_EXAMPLES: Record<string, string> = {
|
||||
"latency-based-routing": 'Example: { "ttl": 3600, "lowest_latency_buffer": 0 }',
|
||||
const ARGS_EXAMPLES: Record<string, ParseKeys> = {
|
||||
"latency-based-routing": "routingGroups.modal.latencyArgsExample",
|
||||
};
|
||||
|
||||
const RoutingGroupModal: React.FC<RoutingGroupModalProps> = ({
|
||||
|
|
@ -65,13 +67,14 @@ const RoutingGroupModal: React.FC<RoutingGroupModalProps> = ({
|
|||
onSubmit,
|
||||
saving,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const modelsAnchor = useComboboxAnchor();
|
||||
const selectableStrategies =
|
||||
mode === "edit" && initialValue
|
||||
? Array.from(new Set([...availableStrategies, initialValue.routing_strategy]))
|
||||
: availableStrategies;
|
||||
const strategyItems = selectableStrategies.map((strategy) => ({
|
||||
label: strategy === "priority" ? "Priority" : strategy,
|
||||
label: strategy === "priority" ? t("routingGroups.strategies.priority") : strategy,
|
||||
value: strategy,
|
||||
}));
|
||||
|
||||
|
|
@ -85,22 +88,22 @@ const RoutingGroupModal: React.FC<RoutingGroupModalProps> = ({
|
|||
group_name: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "Group name is required")
|
||||
.max(GROUP_NAME_MAX_LENGTH, `Must be ${GROUP_NAME_MAX_LENGTH} characters or fewer`)
|
||||
.refine((value) => !reservedNames.has(value.toLowerCase()), "A group with this name already exists"),
|
||||
models: z.array(z.string()).min(1, "Select at least one model"),
|
||||
routing_strategy: z.string().min(1, "Strategy is required"),
|
||||
.min(1, t("routingGroups.modal.validation.nameRequired"))
|
||||
.max(GROUP_NAME_MAX_LENGTH, t("routingGroups.modal.validation.nameTooLong", { max: GROUP_NAME_MAX_LENGTH }))
|
||||
.refine((value) => !reservedNames.has(value.toLowerCase()), t("routingGroups.modal.validation.nameTaken")),
|
||||
models: z.array(z.string()).min(1, t("routingGroups.modal.validation.modelsRequired")),
|
||||
routing_strategy: z.string().min(1, t("routingGroups.modal.validation.strategyRequired")),
|
||||
routing_strategy_args: z.string(),
|
||||
model_priorities: z.array(z.object({ model: z.string(), priority: z.string() })),
|
||||
};
|
||||
return z.object(shape).superRefine((values, ctx) => {
|
||||
if (values.routing_strategy === "priority") return;
|
||||
const conflict = modelConflictError(values.models, groupNameByModel);
|
||||
const conflict = modelConflictError(values.models, groupNameByModel, t);
|
||||
if (conflict !== null) {
|
||||
ctx.addIssue({ code: "custom", message: conflict, path: ["models"] });
|
||||
}
|
||||
});
|
||||
}, [reservedNames, groupNameByModel]);
|
||||
}, [reservedNames, groupNameByModel, t]);
|
||||
|
||||
const form = useZodForm(schema, { defaultValues: toRoutingGroupFormValues(initialValue, availableStrategies) });
|
||||
|
||||
|
|
@ -112,7 +115,7 @@ const RoutingGroupModal: React.FC<RoutingGroupModalProps> = ({
|
|||
const selectedModels = useWatch({ control: form.control, name: "models" });
|
||||
|
||||
const handleSubmit = async (values: z.infer<typeof schema>) => {
|
||||
const payload = buildRoutingGroupPayload(values);
|
||||
const payload = buildRoutingGroupPayload(values, t);
|
||||
if (!payload.ok) {
|
||||
form.setError(payload.field, { message: payload.message });
|
||||
return;
|
||||
|
|
@ -125,7 +128,9 @@ const RoutingGroupModal: React.FC<RoutingGroupModalProps> = ({
|
|||
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[560px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{mode === "create" ? "Create Routing Group" : `Edit ${initialValue?.group_name ?? ""}`}
|
||||
{mode === "create"
|
||||
? t("routingGroups.modal.createTitle")
|
||||
: t("routingGroups.modal.editTitle", { name: initialValue?.group_name ?? "" })}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={(event) => event.preventDefault()} noValidate>
|
||||
|
|
@ -133,8 +138,8 @@ const RoutingGroupModal: React.FC<RoutingGroupModalProps> = ({
|
|||
<FormField
|
||||
control={form.control}
|
||||
name="group_name"
|
||||
label="Group Name"
|
||||
description="Use this name as the model in API calls. LiteLLM routes the request to one of the group's models."
|
||||
label={t("routingGroups.modal.groupName")}
|
||||
description={t("routingGroups.modal.groupNameDescription")}
|
||||
>
|
||||
{({ ref, ...field }) => <Input {...field} ref={ref} placeholder="fast-chat" disabled={mode === "edit"} />}
|
||||
</FormField>
|
||||
|
|
@ -142,11 +147,11 @@ const RoutingGroupModal: React.FC<RoutingGroupModalProps> = ({
|
|||
<FormField
|
||||
control={form.control}
|
||||
name="models"
|
||||
label="Models"
|
||||
label={t("routingGroups.modal.models")}
|
||||
description={
|
||||
selectedStrategy === "priority"
|
||||
? "Models from your model list that this group routes between. Models can belong to multiple priority groups."
|
||||
: "Models from your model list that this group routes between. A model can belong to one non-priority group."
|
||||
? t("routingGroups.modal.modelsPriorityDescription")
|
||||
: t("routingGroups.modal.modelsDescription")
|
||||
}
|
||||
>
|
||||
{({ id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy }) => (
|
||||
|
|
@ -172,14 +177,14 @@ const RoutingGroupModal: React.FC<RoutingGroupModalProps> = ({
|
|||
id={id}
|
||||
aria-invalid={ariaInvalid}
|
||||
aria-describedby={ariaDescribedBy}
|
||||
placeholder="Select models"
|
||||
placeholder={t("routingGroups.modal.modelsPlaceholder")}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</ComboboxValue>
|
||||
</ComboboxChips>
|
||||
<ComboboxContent anchor={modelsAnchor}>
|
||||
<ComboboxEmpty>No models found</ComboboxEmpty>
|
||||
<ComboboxEmpty>{t("routingGroups.modal.noModelsFound")}</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(model: string) => (
|
||||
<ComboboxItem key={model} value={model}>
|
||||
|
|
@ -195,10 +200,10 @@ const RoutingGroupModal: React.FC<RoutingGroupModalProps> = ({
|
|||
<FormField
|
||||
control={form.control}
|
||||
name="routing_strategy"
|
||||
label="Routing Strategy"
|
||||
label={t("routingGroups.modal.routingStrategy")}
|
||||
description={
|
||||
selectedStrategy === "priority"
|
||||
? "Lower priorities are tried first. Models with the same priority share traffic. Applies only when calling this group."
|
||||
? t("routingGroups.modal.priorityStrategyDescription")
|
||||
: strategyDescriptions[selectedStrategy]
|
||||
}
|
||||
>
|
||||
|
|
@ -215,7 +220,7 @@ const RoutingGroupModal: React.FC<RoutingGroupModalProps> = ({
|
|||
}}
|
||||
>
|
||||
<SelectTrigger id={id} aria-invalid={ariaInvalid} aria-describedby={ariaDescribedBy}>
|
||||
<SelectValue placeholder="Select strategy" />
|
||||
<SelectValue placeholder={t("routingGroups.modal.strategyPlaceholder")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{strategyItems.map((strategy) => (
|
||||
|
|
@ -232,25 +237,34 @@ const RoutingGroupModal: React.FC<RoutingGroupModalProps> = ({
|
|||
<FormField
|
||||
control={form.control}
|
||||
name="model_priorities"
|
||||
label="Model Priorities"
|
||||
description="Use 1 for your first choice, 2 for your next choice, and so on. Unavailable models are skipped."
|
||||
label={t("routingGroups.modal.modelPriorities")}
|
||||
description={t("routingGroups.modal.modelPrioritiesDescription")}
|
||||
>
|
||||
{({ id, value, onChange, "aria-invalid": ariaInvalid, "aria-describedby": ariaDescribedBy }) => (
|
||||
<div id={id} className="space-y-2" role="group" aria-label="Model priorities">
|
||||
<div
|
||||
id={id}
|
||||
className="space-y-2"
|
||||
role="group"
|
||||
aria-label={t("routingGroups.modal.modelPrioritiesAriaLabel")}
|
||||
>
|
||||
{value.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">Select models to set priorities</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("routingGroups.modal.selectModelsForPriorities")}
|
||||
</p>
|
||||
)}
|
||||
{value.map((entry, index) => (
|
||||
<div key={entry.model} className="flex items-center justify-between gap-3">
|
||||
<label htmlFor={`${id}-${index}`} className="min-w-0 flex-1 break-words text-sm">
|
||||
{entry.model}
|
||||
{!selectedModels.includes(entry.model) && (
|
||||
<span className="block text-xs text-destructive">Model is not selected</span>
|
||||
<span className="block text-xs text-destructive">
|
||||
{t("routingGroups.modal.modelNotSelected")}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<Input
|
||||
id={`${id}-${index}`}
|
||||
aria-label={`Priority for ${entry.model}`}
|
||||
aria-label={t("routingGroups.modal.priorityFor", { model: entry.model })}
|
||||
aria-invalid={ariaInvalid}
|
||||
aria-describedby={ariaDescribedBy}
|
||||
inputMode="numeric"
|
||||
|
|
@ -269,10 +283,10 @@ const RoutingGroupModal: React.FC<RoutingGroupModalProps> = ({
|
|||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-label={`Remove priority for ${entry.model}`}
|
||||
aria-label={t("routingGroups.modal.removePriorityFor", { model: entry.model })}
|
||||
onClick={() => onChange(value.filter((_, itemIndex) => itemIndex !== index))}
|
||||
>
|
||||
Remove
|
||||
{t("routingGroups.modal.remove")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -286,8 +300,8 @@ const RoutingGroupModal: React.FC<RoutingGroupModalProps> = ({
|
|||
<FormField
|
||||
control={form.control}
|
||||
name="routing_strategy_args"
|
||||
label="Strategy Arguments (JSON)"
|
||||
description={ARGS_EXAMPLES[selectedStrategy] ?? 'Example: { "ttl": 60 }'}
|
||||
label={t("routingGroups.modal.strategyArgs")}
|
||||
description={t(ARGS_EXAMPLES[selectedStrategy] ?? "routingGroups.modal.defaultArgsExample")}
|
||||
>
|
||||
{({ ref, ...field }) => (
|
||||
<Textarea {...field} ref={ref} rows={4} placeholder='{ "ttl": 3600 }' className="font-mono text-xs" />
|
||||
|
|
@ -297,17 +311,17 @@ const RoutingGroupModal: React.FC<RoutingGroupModalProps> = ({
|
|||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{selectedStrategy === "priority"
|
||||
? "Direct requests to a member model keep their existing routing behavior."
|
||||
: "Models outside non-priority groups use the proxy's top-level routing strategy."}
|
||||
? t("routingGroups.modal.priorityFootnote")
|
||||
: t("routingGroups.modal.strategyFootnote")}
|
||||
</p>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
{t("routingGroups.modal.cancel")}
|
||||
</Button>
|
||||
<Button onClick={() => void form.handleSubmit(handleSubmit)()} disabled={saving} aria-busy={saving}>
|
||||
{mode === "create" ? "Create Group" : "Save Changes"}
|
||||
{mode === "create" ? t("routingGroups.modal.createGroup") : t("routingGroups.modal.saveChanges")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { Code2 } from "lucide-react";
|
||||
import React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
|
||||
import CodeBlock from "@/components/CodeBlock";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
|
|
@ -64,23 +65,28 @@ const SNIPPET_TABS = [
|
|||
] as const;
|
||||
|
||||
export function RoutingGroupUsagePanel({ group, baseUrl }: RoutingGroupUsagePanelProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="border-y bg-muted/40 px-4 py-4">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<Code2 className="size-4 text-primary" />
|
||||
<span className="text-sm font-medium text-foreground">How routing works for this group</span>
|
||||
<span className="text-sm font-medium text-foreground">{t("routingGroups.usagePanel.title")}</span>
|
||||
</div>
|
||||
<p className="mb-3 text-sm text-muted-foreground">
|
||||
{group.routing_strategy === "priority" ? (
|
||||
<>
|
||||
Request <span className="font-medium text-foreground">{group.group_name}</span> to try eligible models in
|
||||
priority order. Direct requests to a member model keep their existing routing behavior.
|
||||
</>
|
||||
<Trans
|
||||
i18nKey="routingGroups.usagePanel.priorityExplanation"
|
||||
components={{ group: <span className="font-medium text-foreground">{group.group_name}</span> }}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
Callers request any model in the group by name; LiteLLM picks a deployment behind the scenes using the{" "}
|
||||
<span className="font-medium text-foreground">{formatStrategyLabel(group.routing_strategy)}</span> strategy.
|
||||
</>
|
||||
<Trans
|
||||
i18nKey="routingGroups.usagePanel.strategyExplanation"
|
||||
components={{
|
||||
strategy: (
|
||||
<span className="font-medium text-foreground">{formatStrategyLabel(group.routing_strategy, t)}</span>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</p>
|
||||
<Tabs defaultValue="curl">
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import type { ExpandedState, SortingState } from "@tanstack/react-table";
|
||||
import { Inbox } from "lucide-react";
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { DataTable } from "@/components/shared/DataTable";
|
||||
|
||||
|
|
@ -25,15 +26,14 @@ const resolveBaseUrl = (proxyBaseUrl?: string): string => {
|
|||
};
|
||||
|
||||
function EmptyState() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1 py-6">
|
||||
<div className="mb-1 flex size-10 items-center justify-center rounded-lg bg-muted">
|
||||
<Inbox className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="text-sm font-medium text-foreground">No routing groups yet</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Create a group to load-balance a set of models behind one name.
|
||||
</div>
|
||||
<div className="text-sm font-medium text-foreground">{t("routingGroups.table.emptyTitle")}</div>
|
||||
<div className="text-sm text-muted-foreground">{t("routingGroups.table.emptyDescription")}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -45,6 +45,7 @@ const RoutingGroupsTable: React.FC<RoutingGroupsTableProps> = ({
|
|||
onDelete,
|
||||
proxyBaseUrl,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [expanded, setExpanded] = useState<ExpandedState>({});
|
||||
const baseUrl = resolveBaseUrl(proxyBaseUrl);
|
||||
|
|
@ -58,8 +59,8 @@ const RoutingGroupsTable: React.FC<RoutingGroupsTableProps> = ({
|
|||
|
||||
const columns = useMemo(() => {
|
||||
const deps = { onEdit, onDelete, onToggleUsage: toggleUsage };
|
||||
return getRoutingGroupsTableColumns(deps);
|
||||
}, [onEdit, onDelete, toggleUsage]);
|
||||
return getRoutingGroupsTableColumns(deps, t);
|
||||
}, [onEdit, onDelete, toggleUsage, t]);
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
|
|
@ -75,7 +76,7 @@ const RoutingGroupsTable: React.FC<RoutingGroupsTableProps> = ({
|
|||
getRowCanExpand={() => true}
|
||||
renderSubComponent={({ row }) => <RoutingGroupUsagePanel group={row.original} baseUrl={baseUrl} />}
|
||||
isLoading={isLoading}
|
||||
loadingMessage="Loading routing groups…"
|
||||
loadingMessage={t("routingGroups.table.loading")}
|
||||
noDataMessage={<EmptyState />}
|
||||
size="compact"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
"use client";
|
||||
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import type { TFunction } from "i18next";
|
||||
import { GitBranch, MoreHorizontal, Pencil, Trash2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { DataTableSortHeader } from "@/components/shared/DataTable";
|
||||
import { IdentityCell, ModelsCell } from "@/components/shared/table_cells";
|
||||
|
|
@ -24,10 +26,11 @@ interface RoutingGroupRowActionsProps {
|
|||
}
|
||||
|
||||
function RoutingGroupRowActions({ group, onEdit, onDelete }: RoutingGroupRowActionsProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
aria-label={`Open actions for ${group.group_name}`}
|
||||
aria-label={t("routingGroups.table.rowActions.openFor", { name: group.group_name })}
|
||||
data-testid={`routing-group-actions-${group.group_name}`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }), "text-muted-foreground")}
|
||||
>
|
||||
|
|
@ -36,7 +39,7 @@ function RoutingGroupRowActions({ group, onEdit, onDelete }: RoutingGroupRowActi
|
|||
<DropdownMenuContent align="end" className="w-44">
|
||||
<DropdownMenuItem data-testid="routing-group-action-edit" onClick={() => onEdit(group)}>
|
||||
<Pencil />
|
||||
Edit
|
||||
{t("routingGroups.table.rowActions.edit")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
|
|
@ -44,7 +47,7 @@ function RoutingGroupRowActions({ group, onEdit, onDelete }: RoutingGroupRowActi
|
|||
onClick={() => onDelete(group)}
|
||||
>
|
||||
<Trash2 />
|
||||
Delete
|
||||
{t("routingGroups.table.rowActions.delete")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
|
@ -57,16 +60,15 @@ interface RoutingGroupsTableColumnsDeps {
|
|||
onToggleUsage: (group: RoutingGroup) => void;
|
||||
}
|
||||
|
||||
export const getRoutingGroupsTableColumns = ({
|
||||
onEdit,
|
||||
onDelete,
|
||||
onToggleUsage,
|
||||
}: RoutingGroupsTableColumnsDeps): ColumnDef<RoutingGroup>[] => [
|
||||
export const getRoutingGroupsTableColumns = (
|
||||
{ onEdit, onDelete, onToggleUsage }: RoutingGroupsTableColumnsDeps,
|
||||
t: TFunction,
|
||||
): ColumnDef<RoutingGroup>[] => [
|
||||
{
|
||||
id: "group_name",
|
||||
accessorKey: "group_name",
|
||||
meta: { title: "Group Name", skeleton: "text" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Group Name" />,
|
||||
meta: { title: t("routingGroups.table.columns.groupName"), skeleton: "text" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title={t("routingGroups.table.columns.groupName")} />,
|
||||
size: 240,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => (
|
||||
|
|
@ -75,8 +77,8 @@ export const getRoutingGroupsTableColumns = ({
|
|||
},
|
||||
{
|
||||
id: "models",
|
||||
meta: { title: "Models", skeleton: "chips" },
|
||||
header: "Models",
|
||||
meta: { title: t("routingGroups.table.columns.models"), skeleton: "chips" },
|
||||
header: t("routingGroups.table.columns.models"),
|
||||
size: 320,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <ModelsCell models={row.original.models} />,
|
||||
|
|
@ -84,21 +86,21 @@ export const getRoutingGroupsTableColumns = ({
|
|||
{
|
||||
id: "routing_strategy",
|
||||
accessorKey: "routing_strategy",
|
||||
meta: { title: "Strategy", skeleton: "text" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Strategy" />,
|
||||
meta: { title: t("routingGroups.table.columns.strategy"), skeleton: "text" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title={t("routingGroups.table.columns.strategy")} />,
|
||||
size: 180,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => (
|
||||
<span className="flex items-center gap-1.5 text-sm">
|
||||
<GitBranch className="size-4 shrink-0 text-muted-foreground" />
|
||||
{formatStrategyLabel(row.original.routing_strategy)}
|
||||
{formatStrategyLabel(row.original.routing_strategy, t)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
meta: { className: "text-right", headerClassName: "text-right" },
|
||||
header: () => <span className="sr-only">Actions</span>,
|
||||
header: () => <span className="sr-only">{t("routingGroups.table.columns.actions")}</span>,
|
||||
size: 64,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group";
|
||||
|
|
@ -18,6 +19,7 @@ import { groupNameByModel } from "./modelOwnership";
|
|||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
|
||||
const RoutingGroups: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading, refetch, isFetching } = useRoutingGroups();
|
||||
const { data: routerFields } = useRouterFields();
|
||||
const { data: modelHub } = useModelHub();
|
||||
|
|
@ -86,12 +88,12 @@ const RoutingGroups: React.FC = () => {
|
|||
await saveMutation.mutateAsync(next);
|
||||
toast.success(
|
||||
drawerMode === "create"
|
||||
? `Created routing group "${incoming.group_name}"`
|
||||
: `Updated routing group "${incoming.group_name}"`,
|
||||
? t("routingGroups.page.toasts.created", { name: incoming.group_name })
|
||||
: t("routingGroups.page.toasts.updated", { name: incoming.group_name }),
|
||||
);
|
||||
setDrawerOpen(false);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to save routing group");
|
||||
toast.error(err instanceof Error ? err.message : t("routingGroups.page.toasts.saveFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -100,10 +102,10 @@ const RoutingGroups: React.FC = () => {
|
|||
const next = groups.filter((g) => g.group_name !== deletingGroup.group_name);
|
||||
try {
|
||||
await saveMutation.mutateAsync(next);
|
||||
toast.success(`Deleted routing group "${deletingGroup.group_name}"`);
|
||||
toast.success(t("routingGroups.page.toasts.deleted", { name: deletingGroup.group_name }));
|
||||
setDeletingGroup(null);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to delete routing group");
|
||||
toast.error(err instanceof Error ? err.message : t("routingGroups.page.toasts.deleteFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -117,13 +119,17 @@ const RoutingGroups: React.FC = () => {
|
|||
<Search className="size-4 text-muted-foreground" />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
placeholder="Search groups..."
|
||||
placeholder={t("routingGroups.page.searchPlaceholder")}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupButton size="icon-xs" aria-label="Clear search" onClick={() => setSearchQuery("")}>
|
||||
<InputGroupButton
|
||||
size="icon-xs"
|
||||
aria-label={t("routingGroups.page.clearSearch")}
|
||||
onClick={() => setSearchQuery("")}
|
||||
>
|
||||
<X />
|
||||
</InputGroupButton>
|
||||
</InputGroupAddon>
|
||||
|
|
@ -137,14 +143,14 @@ const RoutingGroups: React.FC = () => {
|
|||
aria-busy={isFetching && !isLoading}
|
||||
>
|
||||
<RefreshCw />
|
||||
Refresh
|
||||
{t("routingGroups.page.refresh")}
|
||||
</Button>
|
||||
<Button onClick={openCreate}>
|
||||
<Plus />
|
||||
Create Group
|
||||
{t("routingGroups.page.createGroup")}
|
||||
</Button>
|
||||
<span className="text-sm whitespace-nowrap text-muted-foreground">
|
||||
Showing {filteredGroups.length} {filteredGroups.length === 1 ? "result" : "results"}
|
||||
{t("routingGroups.page.showingResults", { count: filteredGroups.length })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -176,24 +182,24 @@ const RoutingGroups: React.FC = () => {
|
|||
<Dialog open={Boolean(deletingGroup)} onOpenChange={(open) => !open && setDeletingGroup(null)}>
|
||||
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete routing group?</DialogTitle>
|
||||
<DialogTitle>{t("routingGroups.page.deleteDialog.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-sm text-foreground">
|
||||
{deletingGroup?.routing_strategy === "priority" ? (
|
||||
<>
|
||||
Calls to <span className="font-medium">{deletingGroup.group_name}</span> will stop working. Direct
|
||||
requests to its member models keep their existing routing behavior. This cannot be undone.
|
||||
</>
|
||||
<Trans
|
||||
i18nKey="routingGroups.page.deleteDialog.priorityBody"
|
||||
components={{ group: <span className="font-medium">{deletingGroup.group_name}</span> }}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
Models in <span className="font-medium">{deletingGroup?.group_name}</span> will fall back to the
|
||||
proxy's top-level routing strategy. This cannot be undone.
|
||||
</>
|
||||
<Trans
|
||||
i18nKey="routingGroups.page.deleteDialog.strategyBody"
|
||||
components={{ group: <span className="font-medium">{deletingGroup?.group_name}</span> }}
|
||||
/>
|
||||
)}
|
||||
</p>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeletingGroup(null)}>
|
||||
Cancel
|
||||
{t("routingGroups.page.deleteDialog.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={confirmDelete}
|
||||
|
|
@ -201,7 +207,7 @@ const RoutingGroups: React.FC = () => {
|
|||
disabled={saveMutation.isPending}
|
||||
aria-busy={saveMutation.isPending}
|
||||
>
|
||||
Delete
|
||||
{t("routingGroups.page.deleteDialog.delete")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import type { TFunction } from "i18next";
|
||||
import i18n from "@/i18n/i18n";
|
||||
import type { RoutingGroup } from "./types";
|
||||
|
||||
export const groupNameByModel = (groups: RoutingGroup[], excludeGroupName?: string): Record<string, string> =>
|
||||
|
|
@ -10,9 +12,12 @@ export const groupNameByModel = (groups: RoutingGroup[], excludeGroupName?: stri
|
|||
export const modelConflictError = (
|
||||
models: string[] | undefined,
|
||||
ownerByModel: Record<string, string>,
|
||||
t: TFunction = i18n.t.bind(i18n),
|
||||
): string | null => {
|
||||
const conflicts = (models ?? []).filter((model) => Object.hasOwn(ownerByModel, model));
|
||||
if (conflicts.length === 0) return null;
|
||||
const detail = conflicts.map((model) => `${model} (in "${ownerByModel[model]}")`).join(", ");
|
||||
return `Each model may belong to at most one non-priority group. Already claimed: ${detail}`;
|
||||
const detail = conflicts
|
||||
.map((model) => t("routingGroups.modelOwnership.claimedBy", { model, group: ownerByModel[model] }))
|
||||
.join(", ");
|
||||
return t("routingGroups.modelOwnership.conflict", { detail });
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import type { TFunction } from "i18next";
|
||||
import i18n from "@/i18n/i18n";
|
||||
import type { RoutingGroup } from "./types";
|
||||
|
||||
export const STRATEGIES_WITH_ARGS = new Set<string>(["latency-based-routing", "usage-based-routing"]);
|
||||
|
|
@ -52,7 +54,10 @@ export const toRoutingGroupFormValues = (
|
|||
export const argsForStrategy = (routingStrategy: string, routingStrategyArgs: string): string =>
|
||||
STRATEGIES_WITH_ARGS.has(routingStrategy) ? routingStrategyArgs : "";
|
||||
|
||||
export const buildRoutingGroupPayload = (values: RoutingGroupFormValues): RoutingGroupPayload => {
|
||||
export const buildRoutingGroupPayload = (
|
||||
values: RoutingGroupFormValues,
|
||||
t: TFunction = i18n.t.bind(i18n),
|
||||
): RoutingGroupPayload => {
|
||||
const base = {
|
||||
group_name: values.group_name.trim(),
|
||||
models: values.models,
|
||||
|
|
@ -69,7 +74,7 @@ export const buildRoutingGroupPayload = (values: RoutingGroupFormValues): Routin
|
|||
return {
|
||||
ok: false,
|
||||
field: "model_priorities",
|
||||
message: "Set a priority for each selected model and remove unused priorities",
|
||||
message: t("routingGroups.payload.prioritiesMismatch"),
|
||||
};
|
||||
}
|
||||
if (
|
||||
|
|
@ -81,7 +86,7 @@ export const buildRoutingGroupPayload = (values: RoutingGroupFormValues): Routin
|
|||
return {
|
||||
ok: false,
|
||||
field: "model_priorities",
|
||||
message: "Priorities must be whole numbers from 1 to 9007199254740991",
|
||||
message: t("routingGroups.payload.prioritiesInvalid"),
|
||||
};
|
||||
}
|
||||
return {
|
||||
|
|
@ -101,6 +106,6 @@ export const buildRoutingGroupPayload = (values: RoutingGroupFormValues): Routin
|
|||
try {
|
||||
return { ok: true, group: { ...base, routing_strategy_args: JSON.parse(args) as Record<string, unknown> } };
|
||||
} catch {
|
||||
return { ok: false, field: "routing_strategy_args", message: "Must be valid JSON" };
|
||||
return { ok: false, field: "routing_strategy_args", message: t("routingGroups.payload.invalidJson") };
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
const STRATEGY_LABELS: Readonly<Record<string, string>> = {
|
||||
"simple-shuffle": "Simple Shuffle",
|
||||
"least-busy": "Least Busy",
|
||||
"usage-based-routing": "Usage Based",
|
||||
"latency-based-routing": "Latency Based",
|
||||
priority: "Priority",
|
||||
import type { ParseKeys, TFunction } from "i18next";
|
||||
|
||||
const STRATEGY_LABELS: Readonly<Record<string, ParseKeys>> = {
|
||||
"simple-shuffle": "routingGroups.strategies.simpleShuffle",
|
||||
"least-busy": "routingGroups.strategies.leastBusy",
|
||||
"usage-based-routing": "routingGroups.strategies.usageBased",
|
||||
"latency-based-routing": "routingGroups.strategies.latencyBased",
|
||||
priority: "routingGroups.strategies.priority",
|
||||
};
|
||||
|
||||
export const formatStrategyLabel = (strategy: string): string => STRATEGY_LABELS[strategy] ?? strategy;
|
||||
export const formatStrategyLabel = (strategy: string, t: TFunction): string =>
|
||||
STRATEGY_LABELS[strategy] ? t(STRATEGY_LABELS[strategy]) : strategy;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import type { ParseKeys } from "i18next";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Controller, FormProvider, useForm, useFormContext } from "react-hook-form";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
|
||||
import { Field, FieldError, FieldLabel } from "@/components/ui/field";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -60,6 +62,7 @@ interface DynamicParamsFieldsProps {
|
|||
}
|
||||
|
||||
const DynamicParamsFields: React.FC<DynamicParamsFieldsProps> = ({ params, callbackConfigs, selectedCallback }) => {
|
||||
const { t } = useTranslation();
|
||||
const { register, control, formState } = useFormContext<CallbackFormValues>();
|
||||
const fieldIdPrefix = React.useId();
|
||||
|
||||
|
|
@ -79,7 +82,9 @@ const DynamicParamsFields: React.FC<DynamicParamsFieldsProps> = ({ params, callb
|
|||
const isSelect = paramType === "select" && selectOptions.length > 0;
|
||||
const isBoolean = paramType === "boolean";
|
||||
const fieldId = `${fieldIdPrefix}-${param}`;
|
||||
const validationRules = isRequired ? { required: `Please enter the ${fieldLabel.toLowerCase()}` } : undefined;
|
||||
const validationRules = isRequired
|
||||
? { required: t("loggingAndAlerts.loggingCallbacks.params.required", { field: fieldLabel.toLowerCase() }) }
|
||||
: undefined;
|
||||
const registration = isSelect || isBoolean ? undefined : register(param, validationRules);
|
||||
|
||||
return (
|
||||
|
|
@ -99,7 +104,11 @@ const DynamicParamsFields: React.FC<DynamicParamsFieldsProps> = ({ params, callb
|
|||
onValueChange={(selected: string | null) => field.onChange(selected ?? "")}
|
||||
>
|
||||
<SelectTrigger id={fieldId} className="w-full" onBlur={field.onBlur}>
|
||||
<SelectValue placeholder={`Select ${fieldLabel.toLowerCase()}`} />
|
||||
<SelectValue
|
||||
placeholder={t("loggingAndAlerts.loggingCallbacks.params.selectPlaceholder", {
|
||||
field: fieldLabel.toLowerCase(),
|
||||
})}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{selectOptions.map((option) => (
|
||||
|
|
@ -132,21 +141,31 @@ const DynamicParamsFields: React.FC<DynamicParamsFieldsProps> = ({ params, callb
|
|||
<Input
|
||||
id={fieldId}
|
||||
type="password"
|
||||
placeholder={`Enter your ${fieldLabel.toLowerCase()}`}
|
||||
placeholder={t("loggingAndAlerts.loggingCallbacks.params.inputPlaceholder", {
|
||||
field: fieldLabel.toLowerCase(),
|
||||
})}
|
||||
{...registration}
|
||||
/>
|
||||
) : paramType === "number" ? (
|
||||
<Input
|
||||
id={fieldId}
|
||||
type="number"
|
||||
placeholder={`Enter ${fieldLabel.toLowerCase()}`}
|
||||
placeholder={t("loggingAndAlerts.loggingCallbacks.params.numberPlaceholder", {
|
||||
field: fieldLabel.toLowerCase(),
|
||||
})}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.1}
|
||||
{...registration}
|
||||
/>
|
||||
) : (
|
||||
<Input id={fieldId} placeholder={`Enter your ${fieldLabel.toLowerCase()}`} {...registration} />
|
||||
<Input
|
||||
id={fieldId}
|
||||
placeholder={t("loggingAndAlerts.loggingCallbacks.params.inputPlaceholder", {
|
||||
field: fieldLabel.toLowerCase(),
|
||||
})}
|
||||
{...registration}
|
||||
/>
|
||||
))}
|
||||
<FieldError errors={[formState.errors[param]]} />
|
||||
</Field>
|
||||
|
|
@ -176,6 +195,7 @@ export const CallbackSelector: React.FC<CallbackSelectorProps> = ({
|
|||
onCallbackChange,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { control } = useFormContext<CallbackFormValues>();
|
||||
const inputId = React.useId();
|
||||
const selectedConfig = findCallbackConfig(callbackConfigs, selectedCallback) ?? null;
|
||||
|
|
@ -184,10 +204,10 @@ export const CallbackSelector: React.FC<CallbackSelectorProps> = ({
|
|||
<Controller
|
||||
control={control}
|
||||
name="callback"
|
||||
rules={disabled ? undefined : { required: "Please select a callback" }}
|
||||
rules={disabled ? undefined : { required: t("loggingAndAlerts.loggingCallbacks.selector.required") }}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field>
|
||||
<FieldLabel htmlFor={inputId}>Callback</FieldLabel>
|
||||
<FieldLabel htmlFor={inputId}>{t("loggingAndAlerts.loggingCallbacks.selector.label")}</FieldLabel>
|
||||
<Combobox
|
||||
items={callbackConfigs}
|
||||
value={selectedConfig}
|
||||
|
|
@ -204,14 +224,14 @@ export const CallbackSelector: React.FC<CallbackSelectorProps> = ({
|
|||
>
|
||||
<ComboboxInput
|
||||
id={inputId}
|
||||
placeholder="Choose a logging callback..."
|
||||
placeholder={t("loggingAndAlerts.loggingCallbacks.selector.placeholder")}
|
||||
className="w-full"
|
||||
disabled={disabled}
|
||||
onBlur={field.onBlur}
|
||||
aria-invalid={fieldState.error !== undefined || undefined}
|
||||
/>
|
||||
<ComboboxContent>
|
||||
<ComboboxEmpty>No results</ComboboxEmpty>
|
||||
<ComboboxEmpty>{t("loggingAndAlerts.loggingCallbacks.selector.noResults")}</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(callbackConfig: CallbackConfigOption) => (
|
||||
<ComboboxItem key={callbackConfig.id} value={callbackConfig}>
|
||||
|
|
@ -293,6 +313,7 @@ const buildCallbackPayload = (formValues: Record<string, any>, callbackName: str
|
|||
};
|
||||
|
||||
const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID, premiumUser }) => {
|
||||
const { t } = useTranslation();
|
||||
const [callbacks, setCallbacks] = useState<AlertingObject[]>([]);
|
||||
const [isLoadingCallbacks, setIsLoadingCallbacks] = useState(true);
|
||||
const [alerts, setAlerts] = useState<any[]>([]);
|
||||
|
|
@ -335,9 +356,11 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
|
|||
setCallbackConfigs(data || []);
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.fromError("Failed to load callback configs: " + parseErrorMessage(error));
|
||||
toast.fromError(
|
||||
t("loggingAndAlerts.loggingCallbacks.toasts.configsLoadFailed", { error: parseErrorMessage(error) }),
|
||||
);
|
||||
});
|
||||
}, [accessToken]);
|
||||
}, [accessToken, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (showEditCallback && selectedEditCallback) {
|
||||
|
|
@ -372,18 +395,18 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
|
|||
setActiveAlerts([...activeAlerts, alertName]);
|
||||
}
|
||||
};
|
||||
const alerts_to_UI_NAME: Record<string, string> = {
|
||||
llm_exceptions: "LLM Exceptions",
|
||||
llm_too_slow: "LLM Responses Too Slow",
|
||||
llm_requests_hanging: "LLM Requests Hanging",
|
||||
budget_alerts: "Budget Alerts (API Keys, Users)",
|
||||
user_spend_thresholds: "User Spend Thresholds (Daily/Monthly)",
|
||||
user_spend_anomalies: "User Spend Anomaly Detection",
|
||||
db_exceptions: "Database Exceptions (Read/Write)",
|
||||
daily_reports: "Weekly/Monthly Spend Reports",
|
||||
outage_alerts: "Outage Alerts",
|
||||
region_outage_alerts: "Region Outage Alerts",
|
||||
model_deprecation_warnings: "Model Deprecation Warnings",
|
||||
const alerts_to_UI_NAME: Record<string, ParseKeys> = {
|
||||
llm_exceptions: "loggingAndAlerts.alertTypes.labels.llmExceptions",
|
||||
llm_too_slow: "loggingAndAlerts.alertTypes.labels.llmTooSlow",
|
||||
llm_requests_hanging: "loggingAndAlerts.alertTypes.labels.llmRequestsHanging",
|
||||
budget_alerts: "loggingAndAlerts.alertTypes.labels.budgetAlerts",
|
||||
user_spend_thresholds: "loggingAndAlerts.alertTypes.labels.userSpendThresholds",
|
||||
user_spend_anomalies: "loggingAndAlerts.alertTypes.labels.userSpendAnomalies",
|
||||
db_exceptions: "loggingAndAlerts.alertTypes.labels.dbExceptions",
|
||||
daily_reports: "loggingAndAlerts.alertTypes.labels.dailyReports",
|
||||
outage_alerts: "loggingAndAlerts.alertTypes.labels.outageAlerts",
|
||||
region_outage_alerts: "loggingAndAlerts.alertTypes.labels.regionOutageAlerts",
|
||||
model_deprecation_warnings: "loggingAndAlerts.alertTypes.labels.modelDeprecationWarnings",
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -438,7 +461,11 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
|
|||
|
||||
try {
|
||||
await setCallbacksCall(accessToken, payload);
|
||||
toast.success(isEdit ? "Callback updated successfully" : `Callback ${callbackName} added successfully`);
|
||||
toast.success(
|
||||
isEdit
|
||||
? t("loggingAndAlerts.loggingCallbacks.toasts.updated")
|
||||
: t("loggingAndAlerts.loggingCallbacks.toasts.added", { name: callbackName }),
|
||||
);
|
||||
|
||||
if (isEdit) {
|
||||
setShowEditCallback(false);
|
||||
|
|
@ -529,7 +556,7 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
|
|||
} catch (error) {
|
||||
toast.fromError(error);
|
||||
}
|
||||
toast.success("Alerts updated successfully");
|
||||
toast.success(t("loggingAndAlerts.alertTypes.toasts.saved"));
|
||||
};
|
||||
|
||||
const handleDeleteCallback = (callback: any) => {
|
||||
|
|
@ -545,7 +572,7 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
|
|||
try {
|
||||
setIsDeletingCallback(true);
|
||||
await deleteCallback(accessToken, callbackToDelete.name);
|
||||
toast.success(`Callback ${callbackToDelete.name} deleted successfully`);
|
||||
toast.success(t("loggingAndAlerts.loggingCallbacks.toasts.deleted", { name: callbackToDelete.name }));
|
||||
|
||||
// Refresh the callbacks list
|
||||
if (userID && userRole) {
|
||||
|
|
@ -572,12 +599,12 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
|
|||
<div className="grid grid-cols-1 gap-2 p-8 w-full mt-2">
|
||||
<Tabs defaultValue="logging-callbacks">
|
||||
<TabsList variant="line">
|
||||
<TabsTrigger value="logging-callbacks">Logging Callbacks</TabsTrigger>
|
||||
<TabsTrigger value="cloudzero-cost-tracking">CloudZero Cost Tracking</TabsTrigger>
|
||||
<TabsTrigger value="alerting-types">Alerting Types</TabsTrigger>
|
||||
<TabsTrigger value="alerting-settings">Alerting Settings</TabsTrigger>
|
||||
<TabsTrigger value="email-alerts">Email Alerts</TabsTrigger>
|
||||
<TabsTrigger value="ms-teams-alerts">MS Teams Alerts</TabsTrigger>
|
||||
<TabsTrigger value="logging-callbacks">{t("loggingAndAlerts.page.tabs.loggingCallbacks")}</TabsTrigger>
|
||||
<TabsTrigger value="cloudzero-cost-tracking">{t("loggingAndAlerts.page.tabs.cloudZero")}</TabsTrigger>
|
||||
<TabsTrigger value="alerting-types">{t("loggingAndAlerts.page.tabs.alertingTypes")}</TabsTrigger>
|
||||
<TabsTrigger value="alerting-settings">{t("loggingAndAlerts.page.tabs.alertingSettings")}</TabsTrigger>
|
||||
<TabsTrigger value="email-alerts">{t("loggingAndAlerts.page.tabs.emailAlerts")}</TabsTrigger>
|
||||
<TabsTrigger value="ms-teams-alerts">{t("loggingAndAlerts.page.tabs.msTeamsAlerts")}</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="logging-callbacks" keepMounted>
|
||||
<LoggingCallbacksTable
|
||||
|
|
@ -593,7 +620,7 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
|
|||
onTest={async (cb) => {
|
||||
try {
|
||||
await serviceHealthCheck(accessToken, cb.name);
|
||||
toast.success("Health check triggered");
|
||||
toast.success(t("loggingAndAlerts.loggingCallbacks.toasts.healthCheckTriggered"));
|
||||
} catch (error) {
|
||||
toast.fromError(parseErrorMessage(error));
|
||||
}
|
||||
|
|
@ -608,18 +635,21 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
|
|||
<TabsContent value="alerting-types" keepMounted>
|
||||
<Card className="p-6">
|
||||
<p className="my-2">
|
||||
Alerts are sent to any Slack-compatible incoming webhook URL (Slack, Rocket.Chat, Mattermost, etc.). Get
|
||||
Slack webhook urls from{" "}
|
||||
<a href="https://api.slack.com/messaging/webhooks" target="_blank" style={{ color: "blue" }}>
|
||||
here
|
||||
</a>
|
||||
<Trans
|
||||
i18nKey="loggingAndAlerts.alertTypes.intro"
|
||||
components={{
|
||||
docs: (
|
||||
<a href="https://api.slack.com/messaging/webhooks" target="_blank" style={{ color: "blue" }} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead></TableHead>
|
||||
<TableHead></TableHead>
|
||||
<TableHead>Webhook URL (Slack-compatible)</TableHead>
|
||||
<TableHead>{t("loggingAndAlerts.alertTypes.columns.webhookUrl")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
|
|
@ -638,7 +668,7 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
|
|||
) : (
|
||||
<Button className="flex items-center justify-center">
|
||||
<a href="https://forms.gle/W3U4PZpJGFHWtHyA9" target="_blank">
|
||||
✨ Enterprise Feature
|
||||
{t("loggingAndAlerts.alertTypes.enterpriseFeature")}
|
||||
</a>
|
||||
</Button>
|
||||
)
|
||||
|
|
@ -652,7 +682,7 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
|
|||
)}
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-normal break-words">
|
||||
<p>{value}</p>
|
||||
<p>{t(value)}</p>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
|
|
@ -670,23 +700,21 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
|
|||
</TableBody>
|
||||
</Table>
|
||||
<Button size="xs" className="mt-2" onClick={handleSaveAlerts}>
|
||||
Save Changes
|
||||
{t("loggingAndAlerts.alertTypes.saveChanges")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={async () => {
|
||||
try {
|
||||
await serviceHealthCheck(accessToken, "slack");
|
||||
toast.success(
|
||||
"Alert test triggered. Test request to slack made - check logs/alerts on slack to verify",
|
||||
);
|
||||
toast.success(t("loggingAndAlerts.alertTypes.toasts.testTriggered"));
|
||||
} catch (error) {
|
||||
toast.fromError(parseErrorMessage(error));
|
||||
}
|
||||
}}
|
||||
className="mx-2"
|
||||
>
|
||||
Test Alerts
|
||||
{t("loggingAndAlerts.alertTypes.testAlerts")}
|
||||
</Button>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
|
@ -705,7 +733,7 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
|
|||
<Dialog open={showAddCallbacksModal} onOpenChange={(open) => !open && closeAddCallbackModal()}>
|
||||
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[800px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Logging Callback</DialogTitle>
|
||||
<DialogTitle>{t("loggingAndAlerts.loggingCallbacks.addDialog.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<a
|
||||
href="https://docs.litellm.ai/docs/proxy/logging"
|
||||
|
|
@ -714,7 +742,7 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
|
|||
style={{ color: "blue" }}
|
||||
>
|
||||
{" "}
|
||||
LiteLLM Docs: Logging
|
||||
{t("loggingAndAlerts.loggingCallbacks.addDialog.docsLink")}
|
||||
</a>
|
||||
|
||||
<FormProvider {...addForm}>
|
||||
|
|
@ -733,10 +761,12 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
|
|||
|
||||
<div className="flex justify-end space-x-3 pt-6 mt-6 border-t border-border">
|
||||
<Button type="button" variant="outline" onClick={cancelAddCallback} disabled={isAddingCallback}>
|
||||
Cancel
|
||||
{t("loggingAndAlerts.loggingCallbacks.addDialog.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={isAddingCallback}>
|
||||
{isAddingCallback ? "Adding..." : "Add Callback"}
|
||||
{isAddingCallback
|
||||
? t("loggingAndAlerts.loggingCallbacks.addDialog.submitting")
|
||||
: t("loggingAndAlerts.loggingCallbacks.addDialog.submit")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
|
@ -747,7 +777,7 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
|
|||
<Dialog open={showEditCallback} onOpenChange={(open) => !open && closeEditCallbackModal()}>
|
||||
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[800px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Callback Settings</DialogTitle>
|
||||
<DialogTitle>{t("loggingAndAlerts.loggingCallbacks.editDialog.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<FormProvider {...editForm}>
|
||||
<form onSubmit={editForm.handleSubmit(updateCallbackCall)}>
|
||||
|
|
@ -774,10 +804,12 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
|
|||
|
||||
<div className="flex justify-end space-x-3 pt-6 mt-6 border-t border-border">
|
||||
<Button type="button" variant="outline" onClick={closeEditCallbackModal} disabled={isUpdatingCallback}>
|
||||
Cancel
|
||||
{t("loggingAndAlerts.loggingCallbacks.editDialog.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={isUpdatingCallback}>
|
||||
{isUpdatingCallback ? "Saving..." : "Save Changes"}
|
||||
{isUpdatingCallback
|
||||
? t("loggingAndAlerts.loggingCallbacks.editDialog.submitting")
|
||||
: t("loggingAndAlerts.loggingCallbacks.editDialog.submit")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
|
@ -787,12 +819,15 @@ const Settings: React.FC<SettingsPageProps> = ({ accessToken, userRole, userID,
|
|||
|
||||
<DeleteResourceModal
|
||||
isOpen={showDeleteConfirmModal}
|
||||
title="Delete Callback"
|
||||
message="Are you sure you want to delete this callback? This action cannot be undone."
|
||||
resourceInformationTitle="Callback Information"
|
||||
title={t("loggingAndAlerts.loggingCallbacks.deleteDialog.title")}
|
||||
message={t("loggingAndAlerts.loggingCallbacks.deleteDialog.message")}
|
||||
resourceInformationTitle={t("loggingAndAlerts.loggingCallbacks.deleteDialog.infoTitle")}
|
||||
resourceInformation={[
|
||||
{ label: "Callback Name", value: callbackToDelete?.name },
|
||||
{ label: "Mode", value: callbackToDelete?.mode || "success" },
|
||||
{ label: t("loggingAndAlerts.loggingCallbacks.deleteDialog.callbackName"), value: callbackToDelete?.name },
|
||||
{
|
||||
label: t("loggingAndAlerts.loggingCallbacks.deleteDialog.mode"),
|
||||
value: callbackToDelete?.mode || "success",
|
||||
},
|
||||
]}
|
||||
onCancel={() => {
|
||||
setShowDeleteConfirmModal(false);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxChip,
|
||||
|
|
@ -45,9 +46,10 @@ const SkillSelector: React.FC<SkillSelectorProps> = ({
|
|||
value,
|
||||
className,
|
||||
accessToken,
|
||||
placeholder = "Select skills (optional)",
|
||||
placeholder,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const anchor = useComboboxAnchor();
|
||||
const [options, setOptions] = useState<SkillOption[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
|
@ -85,18 +87,28 @@ const SkillSelector: React.FC<SkillSelectorProps> = ({
|
|||
))
|
||||
}
|
||||
</ComboboxValue>
|
||||
<ComboboxChipsInput placeholder={placeholder} aria-label={placeholder} disabled={disabled} />
|
||||
{value && value.length > 0 && <ComboboxClear aria-label="Clear all skills" disabled={disabled} />}
|
||||
<ComboboxChipsInput
|
||||
placeholder={placeholder ?? t("skillSelector.placeholder")}
|
||||
aria-label={placeholder ?? t("skillSelector.placeholder")}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{value && value.length > 0 && <ComboboxClear aria-label={t("skillSelector.clearAll")} disabled={disabled} />}
|
||||
</ComboboxChips>
|
||||
<ComboboxContent anchor={anchor}>
|
||||
<ComboboxEmpty>{loading ? "Loading skills…" : "No skills found"}</ComboboxEmpty>
|
||||
<ComboboxEmpty>{loading ? t("skillSelector.loading") : t("skillSelector.empty")}</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(skill: string) => {
|
||||
const isPrivate = options.some((option) => option.name === skill && !option.enabled);
|
||||
return (
|
||||
<ComboboxItem key={skill} value={skill} aria-label={isPrivate ? `${skill} (private)` : skill}>
|
||||
<ComboboxItem
|
||||
key={skill}
|
||||
value={skill}
|
||||
aria-label={isPrivate ? t("skillSelector.privateOptionLabel", { skill }) : skill}
|
||||
>
|
||||
{skill}
|
||||
{isPrivate && <span className="ml-2 text-xs text-muted-foreground">private</span>}
|
||||
{isPrivate && (
|
||||
<span className="ml-2 text-xs text-muted-foreground">{t("skillSelector.privateBadge")}</span>
|
||||
)}
|
||||
</ComboboxItem>
|
||||
);
|
||||
}}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue