feat(ui): migrate caching (cache_settings) section to shadcn

- index.tsx (CacheSettings): @tremor Button/Accordion \u2192 shadcn Button/
  Accordion. Added a renderField helper to dedupe the per-category map.
  Section h4/h5 headers use semantic foreground tokens.
- CacheFieldRenderer: @tremor NumberInput/TextInput + antd Select \u2192
  shadcn Input/Textarea/Select with an inline searchable embedding-model
  Select (Search input inside SelectContent; stopPropagation on keydown
  so typing in the search box doesn't fight the Select's keyboard nav).
- RedisTypeSelector: @tremor Select \u2192 shadcn Select + Label.

Gates: TS \u2713 | Lint \u2713 | Vitest 9/9 \u2713 | Build \u2713.

Co-authored-by: yuneng-jiang <yuneng-berri@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-04-23 07:52:55 +00:00
parent ad60a746f8
commit 3b540f0ec2
No known key found for this signature in database
4 changed files with 266 additions and 188 deletions

View file

@ -100,6 +100,16 @@ Layer abbreviations: **TS** (tsc --noEmit), **Lint** (eslint), **Vitest**
section (CostTrackingSettings/*) is pending in a follow-up commit.
- Tests updated for required-field label regex matching.
## 26. Caching (caching)
- Cycles used: 1 / 7
- Layer outcomes per cycle:
- cycle 1: TS ✓ | Lint ✓ | Vitest ✓ (9/9) | Build ✓ | Parity ⏭ | Snap ⏭
- Final status: **done**
- Scope: cache_settings/index.tsx (top-level CacheSettings), CacheFieldRenderer
(per-type input renderer), RedisTypeSelector. Inline searchable Select for
embedding models replaces antd Select with showSearch.
## 24. Guardrails Monitor (guardrails-monitor)
- Cycles used: 1 / 7

View file

@ -1,53 +1,83 @@
"use client";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { NumberInput, TextInput } from "@tremor/react";
import { Select } from "antd";
import React, { useEffect, useState } from "react";
import { fetchAvailableModels, ModelGroup } from "../playground/llm_calls/fetch_models";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import React, { useEffect, useMemo, useState } from "react";
import {
fetchAvailableModels,
ModelGroup,
} from "../playground/llm_calls/fetch_models";
import NumericalInput from "../shared/numerical_input";
interface CacheFieldRendererProps {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
field: any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
currentValue: any;
}
const CacheFieldRenderer: React.FC<CacheFieldRendererProps> = ({ field, currentValue }) => {
const CacheFieldRenderer: React.FC<CacheFieldRendererProps> = ({
field,
currentValue,
}) => {
const [modelInfo, setModelInfo] = useState<ModelGroup[]>([]);
const [selectedModel, setSelectedModel] = useState<string>(currentValue || "");
const [selectedModel, setSelectedModel] = useState<string>(
currentValue || "",
);
const [modelQuery, setModelQuery] = useState("");
const { accessToken } = useAuthorized();
useEffect(() => {
if (!accessToken) return;
const loadModels = async () => {
try {
const uniqueModels = await fetchAvailableModels(accessToken);
console.log("Fetched models for selector:", uniqueModels);
if (uniqueModels.length > 0) {
setModelInfo(uniqueModels);
}
if (uniqueModels.length > 0) setModelInfo(uniqueModels);
} catch (error) {
console.error("Error fetching model info:", error);
}
};
loadModels();
}, [accessToken]);
const embeddingModels = useMemo(
() =>
modelInfo.filter((option: ModelGroup) => option.mode === "embedding"),
[modelInfo],
);
const filteredEmbeddingModels = useMemo(
() =>
embeddingModels.filter((m) =>
modelQuery
? m.model_group.toLowerCase().includes(modelQuery.toLowerCase())
: true,
),
[embeddingModels, modelQuery],
);
if (field.field_type === "Boolean") {
return (
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700">{field.ui_field_name}</label>
<Label className="text-sm font-medium">{field.ui_field_name}</Label>
<div className="flex items-center">
<input
type="checkbox"
name={field.field_name}
defaultChecked={currentValue === true || currentValue === "true"}
className="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded"
className="h-4 w-4 text-primary focus:ring-ring border-input rounded"
/>
<span className="ml-2 text-sm text-gray-500">{field.field_description}</span>
<span className="ml-2 text-sm text-muted-foreground">
{field.field_description}
</span>
</div>
</div>
);
@ -56,14 +86,17 @@ const CacheFieldRenderer: React.FC<CacheFieldRendererProps> = ({ field, currentV
if (field.field_type === "Integer" || field.field_type === "Float") {
return (
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700">{field.ui_field_name}</label>
<Label className="text-sm font-medium">{field.ui_field_name}</Label>
<NumericalInput
name={field.field_name}
type="number"
step={field.field_type === "Float" ? 0.01 : 1}
defaultValue={currentValue}
placeholder={field.field_description}
/>
<p className="text-xs text-gray-500">{field.field_description}</p>
<p className="text-xs text-muted-foreground">
{field.field_description}
</p>
</div>
);
}
@ -71,77 +104,85 @@ const CacheFieldRenderer: React.FC<CacheFieldRendererProps> = ({ field, currentV
if (field.field_type === "List") {
return (
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700">{field.ui_field_name}</label>
<textarea
<Label className="text-sm font-medium">{field.ui_field_name}</Label>
<Textarea
name={field.field_name}
defaultValue={typeof currentValue === "object" ? JSON.stringify(currentValue, null, 2) : currentValue}
defaultValue={
typeof currentValue === "object"
? JSON.stringify(currentValue, null, 2)
: currentValue
}
placeholder={field.field_description}
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
rows={4}
/>
<p className="text-xs text-gray-500">{field.field_description}</p>
<p className="text-xs text-muted-foreground">
{field.field_description}
</p>
</div>
);
}
if (field.field_type === "Models_Select") {
const embeddingModels = modelInfo
.filter((option: ModelGroup) => option.mode === "embedding")
.map((option: ModelGroup) => ({
value: option.model_group,
label: option.model_group,
}));
return (
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700">{field.ui_field_name}</label>
<Select
value={selectedModel}
onChange={setSelectedModel}
showSearch={true}
placeholder="Search and select a model..."
options={embeddingModels}
style={{ width: "100%" }}
className="rounded-md"
filterOption={(input, option) => (option?.label ?? "").toLowerCase().includes(input.toLowerCase())}
/>
<Label className="text-sm font-medium">{field.ui_field_name}</Label>
<Select value={selectedModel} onValueChange={setSelectedModel}>
<SelectTrigger>
<SelectValue placeholder="Search and select a model..." />
</SelectTrigger>
<SelectContent>
<div className="px-2 pt-1 pb-2">
<Input
placeholder="Search…"
value={modelQuery}
onChange={(e) => setModelQuery(e.target.value)}
className="h-8"
onKeyDown={(e) => e.stopPropagation()}
/>
</div>
{filteredEmbeddingModels.length === 0 ? (
<div className="py-2 px-3 text-sm text-muted-foreground">
No matches
</div>
) : (
filteredEmbeddingModels.map((m) => (
<SelectItem key={m.model_group} value={m.model_group}>
{m.model_group}
</SelectItem>
))
)}
</SelectContent>
</Select>
{/* Hidden input to capture the value for form submission */}
<input type="hidden" name={field.field_name} value={selectedModel} />
{field.field_description && <p className="text-xs text-gray-500">{field.field_description}</p>}
{field.field_description && (
<p className="text-xs text-muted-foreground">
{field.field_description}
</p>
)}
</div>
);
}
// Render number input for numeric fields
if (field.field_type === "Integer" || field.field_type === "Float") {
return (
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700">{field.ui_field_name}</label>
<NumberInput
name={field.field_name}
defaultValue={currentValue}
placeholder={field.field_description}
step={field.field_type === "Float" ? 0.01 : 1}
/>
{field.field_description && <p className="text-xs text-gray-500">{field.field_description}</p>}
</div>
);
}
// Determine input type for text-based fields
const inputType: "text" | "password" | "email" | "url" | undefined =
field.field_name === "password" || field.field_name.includes("password") ? "password" : "text";
const inputType: "text" | "password" | "email" | "url" =
field.field_name === "password" || field.field_name.includes("password")
? "password"
: "text";
return (
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700">{field.ui_field_name}</label>
<TextInput
<Label className="text-sm font-medium">{field.ui_field_name}</Label>
<Input
name={field.field_name}
type={inputType}
defaultValue={currentValue}
placeholder={field.field_description}
/>
{field.field_description && <p className="text-xs text-gray-500">{field.field_description}</p>}
{field.field_description && (
<p className="text-xs text-muted-foreground">
{field.field_description}
</p>
)}
</div>
);
};

View file

@ -1,5 +1,12 @@
import React from "react";
import { Select, SelectItem } from "@tremor/react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Label } from "@/components/ui/label";
interface RedisTypeSelectorProps {
redisType: string;
@ -7,18 +14,28 @@ interface RedisTypeSelectorProps {
onTypeChange: (type: string) => void;
}
const RedisTypeSelector: React.FC<RedisTypeSelectorProps> = ({ redisType, redisTypeDescriptions, onTypeChange }) => {
const RedisTypeSelector: React.FC<RedisTypeSelectorProps> = ({
redisType,
redisTypeDescriptions,
onTypeChange,
}) => {
return (
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700">Redis Type</label>
<Label className="text-sm font-medium">Redis Type</Label>
<Select value={redisType} onValueChange={onTypeChange}>
<SelectItem value="node">Node (Single Instance)</SelectItem>
<SelectItem value="cluster">Cluster</SelectItem>
<SelectItem value="sentinel">Sentinel</SelectItem>
<SelectItem value="semantic">Semantic</SelectItem>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="node">Node (Single Instance)</SelectItem>
<SelectItem value="cluster">Cluster</SelectItem>
<SelectItem value="sentinel">Sentinel</SelectItem>
<SelectItem value="semantic">Semantic</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-gray-500">
{redisTypeDescriptions[redisType] || "Select the type of Redis deployment you're using"}
<p className="text-xs text-muted-foreground">
{redisTypeDescriptions[redisType] ||
"Select the type of Redis deployment you're using"}
</p>
</div>
);

View file

@ -1,6 +1,16 @@
import React, { useState, useEffect, useCallback } from "react";
import { Button, Accordion, AccordionHeader, AccordionBody } from "@tremor/react";
import { getCacheSettingsCall, testCacheConnectionCall, updateCacheSettingsCall } from "../networking";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Button } from "@/components/ui/button";
import {
getCacheSettingsCall,
testCacheConnectionCall,
updateCacheSettingsCall,
} from "../networking";
import NotificationsManager from "../molecules/notifications_manager";
import RedisTypeSelector from "./RedisTypeSelector";
import CacheFieldRenderer from "./CacheFieldRenderer";
@ -12,10 +22,14 @@ interface CacheSettingsProps {
userID: string | null;
}
const CacheSettings: React.FC<CacheSettingsProps> = ({ accessToken, userRole, userID }) => {
const CacheSettings: React.FC<CacheSettingsProps> = ({ accessToken }) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const [cacheSettings, setCacheSettings] = useState<{ [key: string]: any }>({});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const [fields, setFields] = useState<any[]>([]);
const [redisTypeDescriptions, setRedisTypeDescriptions] = useState<{ [key: string]: string }>({});
const [redisTypeDescriptions, setRedisTypeDescriptions] = useState<{
[key: string]: string;
}>({});
const [redisType, setRedisType] = useState<string>("node");
const [isTesting, setIsTesting] = useState<boolean>(false);
const [isSaving, setIsSaving] = useState<boolean>(false);
@ -23,21 +37,13 @@ const CacheSettings: React.FC<CacheSettingsProps> = ({ accessToken, userRole, us
const loadCacheSettings = useCallback(async () => {
try {
const data = await getCacheSettingsCall(accessToken!);
console.log("cache settings from API", data);
if (data.fields) {
setFields(data.fields);
}
// Set current values
if (data.fields) setFields(data.fields);
if (data.current_values) {
setCacheSettings(data.current_values);
if (data.current_values.redis_type) {
setRedisType(data.current_values.redis_type);
}
}
// Store Redis type descriptions
if (data.redis_type_descriptions) {
setRedisTypeDescriptions(data.redis_type_descriptions);
}
@ -48,49 +54,42 @@ const CacheSettings: React.FC<CacheSettingsProps> = ({ accessToken, userRole, us
}, [accessToken]);
useEffect(() => {
if (!accessToken) {
return;
}
if (!accessToken) return;
loadCacheSettings();
}, [accessToken, loadCacheSettings]);
const handleTestConnection = async () => {
if (!accessToken) {
return;
}
if (!accessToken) return;
setIsTesting(true);
try {
const testSettings = gatherFormValues(fields, redisType);
const result = await testCacheConnectionCall(accessToken, testSettings);
if (result.status === "success") {
NotificationsManager.success("Cache connection test successful!");
} else {
NotificationsManager.fromBackend(`Connection test failed: ${result.message || result.error}`);
NotificationsManager.fromBackend(
`Connection test failed: ${result.message || result.error}`,
);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
console.error("Test connection error:", error);
NotificationsManager.fromBackend(`Connection test failed: ${error.message || "Unknown error"}`);
NotificationsManager.fromBackend(
`Connection test failed: ${error.message || "Unknown error"}`,
);
} finally {
setIsTesting(false);
}
};
const handleSaveChanges = async () => {
if (!accessToken) {
return;
}
if (!accessToken) return;
setIsSaving(true);
try {
const settingsToSave = gatherFormValues(fields, redisType);
if (redisType === "semantic") {
settingsToSave.type = "redis-semantic";
}
if (redisType === "semantic") settingsToSave.type = "redis-semantic";
await updateCacheSettingsCall(accessToken, settingsToSave);
NotificationsManager.success("Cache settings updated successfully");
// Reload settings to reflect saved values
await loadCacheSettings();
} catch (error) {
console.error("Failed to save cache settings:", error);
@ -100,136 +99,147 @@ const CacheSettings: React.FC<CacheSettingsProps> = ({ accessToken, userRole, us
}
};
if (!accessToken) {
return null;
}
if (!accessToken) return null;
const { basicFields, sslFields, cacheManagementFields, gcpFields, clusterFields, sentinelFields, semanticFields } =
groupFieldsByCategory(fields, redisType);
const {
basicFields,
sslFields,
cacheManagementFields,
gcpFields,
clusterFields,
sentinelFields,
semanticFields,
} = groupFieldsByCategory(fields, redisType);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const renderField = (field: any) => {
if (!field) return null;
const currentValue =
cacheSettings[field.field_name] ?? field.field_default ?? "";
return (
<CacheFieldRenderer
key={field.field_name}
field={field}
currentValue={currentValue}
/>
);
};
return (
<div className="w-full space-y-8 py-2">
<div className="space-y-6">
<div className="max-w-3xl">
<h3 className="text-sm font-medium text-gray-900">Cache Settings</h3>
<p className="text-xs text-gray-500 mt-1">Configure Redis cache for LiteLLM</p>
<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>
</div>
{/* Redis Type Selector */}
<RedisTypeSelector
redisType={redisType}
redisTypeDescriptions={redisTypeDescriptions}
onTypeChange={setRedisType}
/>
{/* Basic Fields */}
<div className="space-y-6 pt-4 border-t border-gray-200">
<h4 className="text-sm font-medium text-gray-900">Connection Settings</h4>
<div className="space-y-6 pt-4 border-t border-border">
<h4 className="text-sm font-medium text-foreground">
Connection Settings
</h4>
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
{basicFields.map((field: any) => {
if (!field) return null;
const currentValue = cacheSettings[field.field_name] ?? field.field_default ?? "";
return <CacheFieldRenderer key={field.field_name} field={field} currentValue={currentValue} />;
})}
{basicFields.map(renderField)}
</div>
</div>
{/* Redis Type-Specific Fields */}
{redisType === "cluster" && clusterFields.length > 0 && (
<div className="space-y-6 pt-4 border-t border-gray-200">
<h4 className="text-sm font-medium text-gray-900">Cluster Configuration</h4>
<div className="space-y-6 pt-4 border-t border-border">
<h4 className="text-sm font-medium text-foreground">
Cluster Configuration
</h4>
<div className="grid grid-cols-1 gap-6">
{clusterFields.map((field: any) => {
const currentValue = cacheSettings[field.field_name] ?? field.field_default ?? "";
return <CacheFieldRenderer key={field.field_name} field={field} currentValue={currentValue} />;
})}
{clusterFields.map(renderField)}
</div>
</div>
)}
{redisType === "sentinel" && sentinelFields.length > 0 && (
<div className="space-y-6 pt-4 border-t border-gray-200">
<h4 className="text-sm font-medium text-gray-900">Sentinel Configuration</h4>
<div className="space-y-6 pt-4 border-t border-border">
<h4 className="text-sm font-medium text-foreground">
Sentinel Configuration
</h4>
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
{sentinelFields.map((field: any) => {
const currentValue = cacheSettings[field.field_name] ?? field.field_default ?? "";
return <CacheFieldRenderer key={field.field_name} field={field} currentValue={currentValue} />;
})}
{sentinelFields.map(renderField)}
</div>
</div>
)}
{redisType === "semantic" && semanticFields.length > 0 && (
<div className="space-y-6 pt-4 border-t border-gray-200">
<h4 className="text-sm font-medium text-gray-900">Semantic Configuration</h4>
<div className="space-y-6 pt-4 border-t border-border">
<h4 className="text-sm font-medium text-foreground">
Semantic Configuration
</h4>
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
{semanticFields.map((field: any) => {
const currentValue = cacheSettings[field.field_name] ?? field.field_default ?? "";
return <CacheFieldRenderer key={field.field_name} field={field} currentValue={currentValue} />;
})}
{semanticFields.map(renderField)}
</div>
</div>
)}
{/* Advanced Settings Accordion */}
<Accordion className="mt-4">
<AccordionHeader>
<span className="text-sm font-medium text-gray-900">Advanced Settings</span>
</AccordionHeader>
<AccordionBody>
<div className="space-y-6">
{/* SSL Settings */}
{sslFields.length > 0 && (
<div className="space-y-4">
<h5 className="text-sm font-medium text-gray-700">SSL Settings</h5>
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
{sslFields.map((field: any) => {
if (!field) return null;
const currentValue = cacheSettings[field.field_name] ?? field.field_default ?? "";
return <CacheFieldRenderer key={field.field_name} field={field} currentValue={currentValue} />;
})}
<Accordion type="single" collapsible className="mt-4">
<AccordionItem value="advanced">
<AccordionTrigger className="text-sm font-medium">
Advanced Settings
</AccordionTrigger>
<AccordionContent>
<div className="space-y-6">
{sslFields.length > 0 && (
<div className="space-y-4">
<h5 className="text-sm font-medium text-foreground">
SSL Settings
</h5>
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
{sslFields.map(renderField)}
</div>
</div>
</div>
)}
)}
{/* Cache Management */}
{cacheManagementFields.length > 0 && (
<div className="space-y-4 pt-4 border-t border-gray-200">
<h5 className="text-sm font-medium text-gray-700">Cache Management</h5>
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
{cacheManagementFields.map((field: any) => {
if (!field) return null;
const currentValue = cacheSettings[field.field_name] ?? field.field_default ?? "";
return <CacheFieldRenderer key={field.field_name} field={field} currentValue={currentValue} />;
})}
{cacheManagementFields.length > 0 && (
<div className="space-y-4 pt-4 border-t border-border">
<h5 className="text-sm font-medium text-foreground">
Cache Management
</h5>
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
{cacheManagementFields.map(renderField)}
</div>
</div>
</div>
)}
)}
{/* GCP Authentication */}
{gcpFields.length > 0 && (
<div className="space-y-4 pt-4 border-t border-gray-200">
<h5 className="text-sm font-medium text-gray-700">GCP Authentication</h5>
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
{gcpFields.map((field: any) => {
if (!field) return null;
const currentValue = cacheSettings[field.field_name] ?? field.field_default ?? "";
return <CacheFieldRenderer key={field.field_name} field={field} currentValue={currentValue} />;
})}
{gcpFields.length > 0 && (
<div className="space-y-4 pt-4 border-t border-border">
<h5 className="text-sm font-medium text-foreground">
GCP Authentication
</h5>
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
{gcpFields.map(renderField)}
</div>
</div>
</div>
)}
</div>
</AccordionBody>
)}
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
{/* Actions */}
<div className="border-t border-gray-200 pt-6 flex justify-end gap-3">
<Button variant="secondary" size="sm" onClick={handleTestConnection} disabled={isTesting} className="text-sm">
<div className="border-t border-border pt-6 flex justify-end gap-3">
<Button
variant="secondary"
size="sm"
onClick={handleTestConnection}
disabled={isTesting}
>
{isTesting ? "Testing..." : "Test Connection"}
</Button>
<Button size="sm" onClick={handleSaveChanges} disabled={isSaving} className="text-sm font-medium">
<Button size="sm" onClick={handleSaveChanges} disabled={isSaving}>
{isSaving ? "Saving..." : "Save Changes"}
</Button>
</div>