refactor(ui): drive cache settings form from a typed frontend schema (#31939)

* refactor(ui): drive cache settings form from a typed frontend schema

The Cache Settings form was dynamically generated from field metadata
shipped by the backend, and read its values back out of the DOM with
document.querySelector. That loses type safety and makes client-side
validation awkward, which is a poor fit for a form whose shape only
changes when a developer edits code.

Move the field definitions (name, label, type, default, help text, which
redis type they apply to, section, and validation rules) into a typed
frontend module and render them through antd Form with controlled state.
The GET /cache/settings endpoint is still used to populate current values,
and the save/test payload shape sent to POST /cache/settings and
/cache/settings/test is unchanged. Per-field validation now lives on each
field's antd rules, so an inline error can surface before and on submit;
this is where the upcoming Redis URL validation will slot in.

The backend's fields output in GET /cache/settings is no longer consumed
by the UI, but is left in place since removing it is a separate backend
change.

* refactor(ui): validate list-field JSON inline so bad input blocks save

sentinel_nodes and redis_startup_nodes had no validation rule, so
malformed JSON passed validateFields, was caught while building the save
payload, and the field was silently omitted; the user's cluster/sentinel
config was discarded with no feedback. Add a jsonListRule (same shape as
portRule) to both list fields so an invalid value surfaces inline and
blocks save.

* fix(ui): show valid-JSON examples for cache list fields and clarify the error

The Startup Nodes and Sentinel Nodes help text showed Python-style
single-quoted examples (e.g. [{'host': '127.0.0.1', 'port': '7001'}]),
which the JSON validator correctly rejects, so pasting the example we
display failed. Switch both examples to valid JSON with double quotes and
change the parse-error message to "Must be a valid JSON array (use double
quotes)" so the hint points at the fix. Also add a regression test
asserting a numeric field (Database Index) is included in the save payload.

* fix(ui): validate numeric cache fields as text so bad input blocks save

Numeric fields (Database Index, TTL, Max Connections, Similarity
Threshold) rendered as antd InputNumber, which silently coerces
non-numeric input to empty. Because the fields are optional, an invalid
entry like a full connection URL pasted into Database Index passed
validation and was silently dropped from the save payload.

Render numeric fields as text inputs with a validation rule (non-negative
integer for Database Index and Max Connections, number for TTL and
Similarity Threshold), mirroring how Port already works, so invalid input
is preserved, flagged inline, and blocks submit instead of vanishing. The
save payload still coerces these to real numbers. Adds a regression test
for a non-numeric value entered into a numeric field.
This commit is contained in:
yuneng-jiang 2026-07-02 14:09:39 -07:00 committed by GitHub
parent c370503091
commit bea8c9380b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 780 additions and 696 deletions

View file

@ -1,5 +1,5 @@
{
"@typescript-eslint/no-explicit-any": 2013,
"@typescript-eslint/no-explicit-any": 1991,
"complexity": 126,
"max-depth": 61
"max-depth": 59
}

View file

@ -834,11 +834,6 @@
"count": 1
}
},
"src/app/(dashboard)/caching/components/cache_settings/CacheFieldRenderer.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/caching/components/cache_settings/RedisTypeSelector.tsx": {
"no-restricted-imports": {
"count": 1

View file

@ -1,138 +0,0 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { render, screen, cleanup } from "@testing-library/react";
import CacheFieldGroup from "./CacheFieldGroup";
describe("CacheFieldGroup", () => {
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
default: () => ({
token: "mock-token",
accessToken: "mock-access-token",
userId: "mock-user-id",
userEmail: "test@example.com",
userRole: "Admin",
premiumUser: false,
disabledPersonalKeyCreation: false,
showSSOBanner: false,
}),
}));
beforeEach(() => {
cleanup();
});
it("should filter and render fields based on redisType", () => {
/**
* Tests that CacheFieldGroup filters fields based on redis_type and redisType prop.
* This is the core functionality that shows/hides fields based on Redis deployment type.
*/
const fields = [
{
field_name: "host",
field_type: "String",
ui_field_name: "Host",
redis_type: null, // Applies to all types
},
{
field_name: "redis_startup_nodes",
field_type: "List",
ui_field_name: "Startup Nodes",
redis_type: "cluster", // Only for cluster
},
{
field_name: "sentinel_nodes",
field_type: "List",
ui_field_name: "Sentinel Nodes",
redis_type: "sentinel", // Only for sentinel
},
];
const cacheSettings = {
host: "localhost",
redis_startup_nodes: [],
};
// Test with cluster type - should show host and redis_startup_nodes
const { rerender } = render(
<CacheFieldGroup title="Cluster Settings" fields={fields} cacheSettings={cacheSettings} redisType="cluster" />,
);
expect(screen.getByText("Cluster Settings")).toBeInTheDocument();
expect(screen.getAllByText("Host")).toHaveLength(1);
expect(screen.getByText("Startup Nodes")).toBeInTheDocument();
expect(screen.queryByText("Sentinel Nodes")).not.toBeInTheDocument();
// Test with sentinel type - should show host and sentinel_nodes
rerender(
<CacheFieldGroup title="Sentinel Settings" fields={fields} cacheSettings={cacheSettings} redisType="sentinel" />,
);
expect(screen.getByText("Sentinel Settings")).toBeInTheDocument();
expect(screen.getAllByText("Host")).toHaveLength(1);
expect(screen.getByText("Sentinel Nodes")).toBeInTheDocument();
expect(screen.queryByText("Startup Nodes")).not.toBeInTheDocument();
// Test with node type - should only show host
rerender(<CacheFieldGroup title="Node Settings" fields={fields} cacheSettings={cacheSettings} redisType="node" />);
expect(screen.getByText("Node Settings")).toBeInTheDocument();
expect(screen.getAllByText("Host")).toHaveLength(1);
expect(screen.queryByText("Startup Nodes")).not.toBeInTheDocument();
expect(screen.queryByText("Sentinel Nodes")).not.toBeInTheDocument();
});
it("should return null when no fields are visible", () => {
/**
* Tests that CacheFieldGroup returns null when no fields match the redisType.
* This prevents rendering empty sections in the UI.
*/
const fields = [
{
field_name: "redis_startup_nodes",
field_type: "List",
ui_field_name: "Startup Nodes",
redis_type: "cluster", // Only for cluster
},
];
const cacheSettings = {};
const { container } = render(
<CacheFieldGroup
title="Cluster Settings"
fields={fields}
cacheSettings={cacheSettings}
redisType="node" // No fields match this type
/>,
);
// Component should return null, so container should be empty
expect(container.firstChild).toBeNull();
});
it("should use field_default when currentValue is not available", () => {
/**
* Tests that CacheFieldGroup falls back to field_default when currentValue is missing.
* This ensures fields display default values when cache settings are not set.
*/
const fields = [
{
field_name: "port",
field_type: "Integer",
ui_field_name: "Port",
field_default: 6379,
redis_type: null,
},
];
const cacheSettings = {}; // No port value set
render(
<CacheFieldGroup title="Connection Settings" fields={fields} cacheSettings={cacheSettings} redisType="node" />,
);
const input = screen.getByRole("spinbutton", { name: "" });
expect(input).toBeInTheDocument();
expect(input).toHaveAttribute("name", "port");
expect(input).toHaveValue(6379);
});
});

View file

@ -1,47 +0,0 @@
import React from "react";
import CacheFieldRenderer from "./CacheFieldRenderer";
interface CacheFieldGroupProps {
title: string;
fields: any[];
cacheSettings: { [key: string]: any };
redisType: string;
gridCols?: string;
}
const CacheFieldGroup: React.FC<CacheFieldGroupProps> = ({
title,
fields,
cacheSettings,
redisType,
gridCols = "grid-cols-1 gap-6 sm:grid-cols-2",
}) => {
const shouldShowField = (field: any): boolean => {
// Show field if it applies to all types (redis_type is null/undefined) or to current selected type
if (field.redis_type === null || field.redis_type === undefined) {
return true;
}
return field.redis_type === redisType;
};
const visibleFields = fields.filter(shouldShowField);
if (visibleFields.length === 0) {
return null;
}
return (
<div className="space-y-6 pt-4 border-t border-gray-200">
<h4 className="text-sm font-medium text-gray-900">{title}</h4>
<div className={`grid ${gridCols}`}>
{visibleFields.map((field) => {
const currentValue = cacheSettings[field.field_name] ?? field.field_default ?? "";
return <CacheFieldRenderer key={field.field_name} field={field} currentValue={currentValue} />;
})}
</div>
</div>
);
};
export default CacheFieldGroup;

View file

@ -1,126 +0,0 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import CacheFieldRenderer from "./CacheFieldRenderer";
// Mock the useAuthorized hook to avoid Next.js router dependency
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
default: () => ({
token: "mock-token",
accessToken: "mock-access-token",
userId: "mock-user-id",
userEmail: "test@example.com",
userRole: "Admin",
premiumUser: false,
disabledPersonalKeyCreation: false,
showSSOBanner: false,
}),
}));
describe("CacheFieldRenderer", () => {
it("should render a checkbox for Boolean field type", () => {
/**
* Tests that Boolean fields render as checkboxes with proper defaultChecked value.
* This is the core functionality for boolean cache settings.
*/
const field = {
field_name: "ssl",
field_type: "Boolean",
ui_field_name: "Enable SSL",
field_description: "Enable SSL encryption",
};
render(<CacheFieldRenderer field={field} currentValue={true} />);
const checkbox = screen.getByRole("checkbox", { name: "" });
expect(checkbox).toBeInTheDocument();
expect(checkbox).toBeChecked();
expect(screen.getByText("Enable SSL")).toBeInTheDocument();
expect(screen.getByText("Enable SSL encryption")).toBeInTheDocument();
});
it("should render a textarea for List field type", () => {
/**
* Tests that List fields render as textareas with JSON stringified values.
* This handles array/list cache settings like redis_startup_nodes.
*/
const field = {
field_name: "redis_startup_nodes",
field_type: "List",
ui_field_name: "Redis Startup Nodes",
field_description: "List of Redis cluster nodes",
};
const currentValue = [
{ host: "localhost", port: 6379 },
{ host: "localhost", port: 6380 },
];
render(<CacheFieldRenderer field={field} currentValue={currentValue} />);
const textarea = screen.getByRole("textbox");
expect(textarea).toBeInTheDocument();
expect(textarea.tagName).toBe("TEXTAREA");
expect(textarea).toHaveValue(JSON.stringify(currentValue, null, 2));
expect(screen.getByText("Redis Startup Nodes")).toBeInTheDocument();
});
it("should render a password input for password field", () => {
/**
* Tests that password fields render as password inputs.
* This ensures sensitive data is masked in the UI.
*/
const field = {
field_name: "password",
field_type: "String",
ui_field_name: "Password",
field_description: "Redis password",
};
render(<CacheFieldRenderer field={field} currentValue="secret123" />);
const input = screen.getByPlaceholderText("Redis password");
expect(input).toBeInTheDocument();
expect(input).toHaveAttribute("type", "password");
expect(input).toHaveValue("secret123");
});
it("should render a number input for Integer field type", () => {
/**
* Tests that Integer fields render as number inputs.
* This ensures proper validation for numeric cache settings.
*/
const field = {
field_name: "port",
field_type: "Integer",
ui_field_name: "Port",
field_description: "Redis port number",
};
render(<CacheFieldRenderer field={field} currentValue={6379} />);
const input = screen.getByPlaceholderText("Redis port number");
expect(input).toBeInTheDocument();
expect(input).toHaveAttribute("type", "number");
expect(input).toHaveValue(6379);
});
it("should render a text input for String field type", () => {
/**
* Tests that String fields render as text inputs.
* This is the default rendering for text-based cache settings.
*/
const field = {
field_name: "host",
field_type: "String",
ui_field_name: "Host",
field_description: "Redis host address",
};
render(<CacheFieldRenderer field={field} currentValue="localhost" />);
const input = screen.getByPlaceholderText("Redis host address");
expect(input).toBeInTheDocument();
expect(input).toHaveAttribute("type", "text");
expect(input).toHaveValue("localhost");
});
});

View file

@ -1,149 +0,0 @@
"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 "@/components/llm_calls/fetch_models";
import NumericalInput from "@/components/shared/numerical_input";
interface CacheFieldRendererProps {
field: any;
currentValue: any;
}
const CacheFieldRenderer: React.FC<CacheFieldRendererProps> = ({ field, currentValue }) => {
const [modelInfo, setModelInfo] = useState<ModelGroup[]>([]);
const [selectedModel, setSelectedModel] = useState<string>(currentValue || "");
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);
}
} catch (error) {
console.error("Error fetching model info:", error);
}
};
loadModels();
}, [accessToken]);
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>
<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"
/>
<span className="ml-2 text-sm text-gray-500">{field.field_description}</span>
</div>
</div>
);
}
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>
<NumericalInput
name={field.field_name}
type="number"
defaultValue={currentValue}
placeholder={field.field_description}
/>
<p className="text-xs text-gray-500">{field.field_description}</p>
</div>
);
}
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
name={field.field_name}
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>
</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())}
/>
{/* 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>}
</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";
return (
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700">{field.ui_field_name}</label>
<TextInput
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>}
</div>
);
};
export default CacheFieldRenderer;

View file

@ -0,0 +1,42 @@
import React from "react";
import CacheFormField, { EmbeddingModelOption } from "./CacheFormField";
import { fieldsForSection } from "./cacheSettingsUtils";
import { CacheSection, RedisType } from "./cacheSettingsFields";
interface CacheFieldSectionProps {
title: string;
section: CacheSection;
redisType: RedisType;
embeddingModels: EmbeddingModelOption[];
gridCols?: string;
headingLevel?: "h4" | "h5";
}
const CacheFieldSection: React.FC<CacheFieldSectionProps> = ({
title,
section,
redisType,
embeddingModels,
gridCols = "grid-cols-1 gap-6 sm:grid-cols-2",
headingLevel = "h4",
}) => {
const fields = fieldsForSection(section, redisType);
if (fields.length === 0) {
return null;
}
const Heading = headingLevel;
return (
<div className="space-y-6">
<Heading className="text-sm font-medium text-gray-900">{title}</Heading>
<div className={`grid ${gridCols}`}>
{fields.map((field) => (
<CacheFormField key={field.name} field={field} embeddingModels={embeddingModels} />
))}
</div>
</div>
);
};
export default CacheFieldSection;

View file

@ -0,0 +1,54 @@
import { Form, Input, Select, Switch } from "antd";
import React from "react";
import { CacheField } from "./cacheSettingsFields";
export interface EmbeddingModelOption {
value: string;
label: string;
}
interface CacheFormFieldProps {
field: CacheField;
embeddingModels: EmbeddingModelOption[];
}
const renderControl = (field: CacheField, embeddingModels: EmbeddingModelOption[]): React.ReactNode => {
switch (field.type) {
case "boolean":
return <Switch />;
case "password":
return <Input.Password placeholder={field.helpText} autoComplete="new-password" />;
case "integer":
case "float":
return <Input inputMode="decimal" placeholder={field.helpText} />;
case "list":
return <Input.TextArea rows={4} placeholder={field.helpText} />;
case "model-select":
return (
<Select
showSearch
allowClear
placeholder="Search and select a model..."
options={embeddingModels}
optionFilterProp="label"
style={{ width: "100%" }}
/>
);
default:
return <Input placeholder={field.helpText} />;
}
};
const CacheFormField: React.FC<CacheFormFieldProps> = ({ field, embeddingModels }) => (
<Form.Item
name={field.name}
label={field.label}
extra={field.helpText}
rules={field.rules}
valuePropName={field.type === "boolean" ? "checked" : "value"}
>
{renderControl(field, embeddingModels)}
</Form.Item>
);
export default CacheFormField;

View file

@ -3,7 +3,7 @@ import { Select, SelectItem } from "@tremor/react";
interface RedisTypeSelectorProps {
redisType: string;
redisTypeDescriptions: { [key: string]: string };
redisTypeDescriptions: Readonly<Record<string, string>>;
onTypeChange: (type: string) => void;
}

View file

@ -0,0 +1,261 @@
import type { FormItemProps } from "antd";
export type CacheFieldType = "string" | "password" | "integer" | "float" | "boolean" | "list" | "model-select";
export type RedisType = "node" | "cluster" | "sentinel" | "semantic";
export type CacheSection = "connection" | "cluster" | "sentinel" | "semantic" | "ssl" | "cacheManagement" | "gcp";
export type CacheFieldRule = NonNullable<FormItemProps["rules"]>[number];
export interface CacheField {
readonly name: string;
readonly label: string;
readonly type: CacheFieldType;
readonly section: CacheSection;
readonly helpText: string;
readonly redisType: RedisType | null;
readonly defaultValue?: string | number | boolean;
readonly rules?: CacheFieldRule[];
}
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",
};
const portRule: CacheFieldRule = {
validator: (_rule, value) => {
if (value === undefined || value === null || String(value).trim() === "") {
return Promise.resolve();
}
const port = Number(value);
if (!Number.isInteger(port) || port < 1 || port > 65535) {
return Promise.reject(new Error("Port must be an integer between 1 and 65535"));
}
return Promise.resolve();
},
};
const jsonListRule: CacheFieldRule = {
validator: (_rule, value) => {
if (value === undefined || value === null || String(value).trim() === "") {
return Promise.resolve();
}
let parsed: unknown;
try {
parsed = JSON.parse(String(value));
} catch {
return Promise.reject(new Error("Must be a valid JSON array (use double quotes)"));
}
if (!Array.isArray(parsed)) {
return Promise.reject(new Error("Must be a JSON array"));
}
return Promise.resolve();
},
};
const nonNegativeIntegerRule: CacheFieldRule = {
validator: (_rule, value) => {
if (value === undefined || value === null || String(value).trim() === "") {
return Promise.resolve();
}
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed < 0) {
return Promise.reject(new Error("Must be a non-negative integer"));
}
return Promise.resolve();
},
};
const numberRule: CacheFieldRule = {
validator: (_rule, value) => {
if (value === undefined || value === null || String(value).trim() === "") {
return Promise.resolve();
}
if (Number.isNaN(Number(value))) {
return Promise.reject(new Error("Must be a number"));
}
return Promise.resolve();
},
};
export const CACHE_FIELDS: readonly CacheField[] = [
{
name: "url",
label: "Redis URL",
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.",
redisType: null,
},
{
name: "host",
label: "Host",
type: "string",
section: "connection",
helpText: "Redis server hostname or IP address",
redisType: null,
},
{
name: "port",
label: "Port",
type: "string",
section: "connection",
helpText: "Redis server port number",
redisType: null,
defaultValue: "6379",
rules: [portRule],
},
{
name: "db",
label: "Database Index",
type: "integer",
section: "connection",
helpText: "Logical database index to isolate the cache (e.g. 1 for redis://host:6379/1)",
redisType: null,
rules: [nonNegativeIntegerRule],
},
{
name: "password",
label: "Password",
type: "password",
section: "connection",
helpText: "Redis server password",
redisType: null,
},
{
name: "username",
label: "Username",
type: "string",
section: "connection",
helpText: "Redis server username (if required)",
redisType: null,
},
{
name: "redis_startup_nodes",
label: "Startup Nodes",
type: "list",
section: "cluster",
helpText: 'List of startup nodes for Redis Cluster (e.g., [{"host": "127.0.0.1", "port": "7001"}])',
redisType: "cluster",
rules: [jsonListRule],
},
{
name: "sentinel_nodes",
label: "Sentinel Nodes",
type: "list",
section: "sentinel",
helpText: 'List of Sentinel nodes (e.g., [["localhost", 26379]])',
redisType: "sentinel",
rules: [jsonListRule],
},
{
name: "service_name",
label: "Service Name",
type: "string",
section: "sentinel",
helpText: "Master service name for Redis Sentinel",
redisType: "sentinel",
},
{
name: "sentinel_password",
label: "Sentinel Password",
type: "password",
section: "sentinel",
helpText: "Password for Redis Sentinel authentication",
redisType: "sentinel",
},
{
name: "similarity_threshold",
label: "Similarity Threshold",
type: "float",
section: "semantic",
helpText: "Similarity threshold for semantic cache",
redisType: "semantic",
defaultValue: 0.8,
rules: [numberRule],
},
{
name: "redis_semantic_cache_embedding_model",
label: "Embedding Model",
type: "model-select",
section: "semantic",
helpText: "Embedding model for semantic cache",
redisType: "semantic",
},
{
name: "ssl",
label: "SSL",
type: "boolean",
section: "ssl",
helpText: "Enable SSL/TLS connection",
redisType: null,
defaultValue: false,
},
{
name: "ssl_cert_reqs",
label: "SSL Cert Reqs",
type: "string",
section: "ssl",
helpText: "SSL certificate requirements (None, CERT_REQUIRED, CERT_OPTIONAL)",
redisType: null,
},
{
name: "ssl_check_hostname",
label: "SSL Check Hostname",
type: "boolean",
section: "ssl",
helpText: "Enable SSL hostname verification",
redisType: null,
defaultValue: false,
},
{
name: "namespace",
label: "Namespace",
type: "string",
section: "cacheManagement",
helpText: "Namespace prefix for cache keys",
redisType: null,
},
{
name: "ttl",
label: "TTL (seconds)",
type: "float",
section: "cacheManagement",
helpText: "Time-to-live for cached items in seconds",
redisType: null,
rules: [numberRule],
},
{
name: "max_connections",
label: "Max Connections",
type: "integer",
section: "cacheManagement",
helpText: "Maximum number of connections in the connection pool",
redisType: null,
rules: [nonNegativeIntegerRule],
},
{
name: "gcp_service_account",
label: "GCP Service Account",
type: "string",
section: "gcp",
helpText:
"GCP service account for IAM authentication (e.g., projects/-/serviceAccounts/your-sa@project.iam.gserviceaccount.com)",
redisType: null,
},
{
name: "gcp_ssl_ca_certs",
label: "GCP SSL CA Certs",
type: "string",
section: "gcp",
helpText: "Path to SSL CA certificate file for GCP Memorystore Redis",
redisType: null,
},
];

View file

@ -0,0 +1,86 @@
import { describe, it, expect } from "vitest";
import { buildCachePayload, buildInitialValues, fieldsForSection } from "./cacheSettingsUtils";
describe("fieldsForSection", () => {
it("should only include a redis-type-specific field when that type is selected", () => {
expect(fieldsForSection("cluster", "cluster").map((f) => f.name)).toEqual(["redis_startup_nodes"]);
expect(fieldsForSection("cluster", "node")).toEqual([]);
});
it("should include connection fields for every redis type in schema order", () => {
expect(fieldsForSection("connection", "node").map((f) => f.name)).toEqual([
"url",
"host",
"port",
"db",
"password",
"username",
]);
});
});
describe("buildInitialValues", () => {
it("should apply defaults as strings for text inputs and coerce booleans", () => {
const values = buildInitialValues({});
expect(values.port).toBe("6379");
expect(values.similarity_threshold).toBe("0.8");
expect(values.ssl).toBe(false);
expect(values.db).toBe("");
});
it("should stringify list values so they render in a textarea", () => {
const nodes = [{ host: "127.0.0.1", port: "7001" }];
const values = buildInitialValues({ redis_startup_nodes: nodes });
expect(values.redis_startup_nodes).toBe(JSON.stringify(nodes, null, 2));
});
it("should render numeric current values as strings for their text inputs", () => {
const values = buildInitialValues({ max_connections: 10 });
expect(values.max_connections).toBe("10");
});
});
describe("buildCachePayload", () => {
it("should tag the payload as redis and drop empty fields and the UI-only redis_type", () => {
const payload = buildCachePayload("node", { host: "localhost", port: "6379", username: "" }, { forTesting: false });
expect(payload).toEqual({
type: "redis",
host: "localhost",
port: "6379",
ssl: false,
ssl_check_hostname: false,
});
expect(payload).not.toHaveProperty("redis_type");
expect(payload).not.toHaveProperty("username");
});
it("should parse list fields from their textarea string into arrays", () => {
const payload = buildCachePayload(
"cluster",
{ redis_startup_nodes: '[{"host":"127.0.0.1","port":"7001"}]' },
{ forTesting: false },
);
expect(payload.redis_startup_nodes).toEqual([{ host: "127.0.0.1", port: "7001" }]);
});
it("should omit a list field whose textarea holds invalid JSON", () => {
const payload = buildCachePayload("cluster", { redis_startup_nodes: "not json" }, { forTesting: false });
expect(payload).not.toHaveProperty("redis_startup_nodes");
});
it("should send type redis-semantic when saving a semantic cache", () => {
const payload = buildCachePayload("semantic", { similarity_threshold: 0.9 }, { forTesting: false });
expect(payload.type).toBe("redis-semantic");
expect(payload.similarity_threshold).toBe(0.9);
});
it("should keep type redis when testing a semantic cache so the test endpoint accepts it", () => {
const payload = buildCachePayload("semantic", { similarity_threshold: 0.9 }, { forTesting: true });
expect(payload.type).toBe("redis");
});
it("should exclude fields that do not belong to the selected redis type", () => {
const payload = buildCachePayload("node", { sentinel_nodes: '[["localhost",26379]]' }, { forTesting: false });
expect(payload).not.toHaveProperty("sentinel_nodes");
});
});

View file

@ -1,110 +1,81 @@
/**
* Utility functions for cache settings form handling
*/
import { CACHE_FIELDS, CacheField, CacheSection, RedisType } from "./cacheSettingsFields";
export const shouldShowField = (field: any, redisType: string): boolean => {
// Show field if it applies to all types (redis_type is null/undefined) or to current selected type
if (field.redis_type === null || field.redis_type === undefined) {
return true;
export type CacheFormValue = string | number | boolean | undefined;
export type CacheFormValues = Record<string, CacheFormValue>;
export type CacheSavePayloadValue = string | number | boolean | unknown[];
export type CacheSavePayload = Record<string, CacheSavePayloadValue>;
export const isFieldVisible = (field: CacheField, redisType: RedisType): boolean =>
field.redisType === null || field.redisType === redisType;
export const fieldsForSection = (section: CacheSection, redisType: RedisType): CacheField[] =>
CACHE_FIELDS.filter((field) => field.section === section && isFieldVisible(field, redisType));
const initialValueForField = (field: CacheField, raw: unknown): CacheFormValue => {
const source = raw ?? field.defaultValue;
if (field.type === "boolean") {
return source === true || source === "true";
}
return field.redis_type === redisType;
if (field.type === "list") {
if (source === undefined || source === null || source === "") {
return "";
}
return typeof source === "string" ? source : JSON.stringify(source, null, 2);
}
if (source === undefined || source === null) {
return "";
}
return String(source);
};
export const getFieldByName = (fields: any[], fieldName: string) => {
return fields.find((f) => f.field_name === fieldName);
export const buildInitialValues = (currentValues: Record<string, unknown>): CacheFormValues =>
Object.fromEntries(CACHE_FIELDS.map((field) => [field.name, initialValueForField(field, currentValues[field.name])]));
const saveValueForField = (field: CacheField, raw: CacheFormValue): CacheSavePayloadValue | undefined => {
if (field.type === "boolean") {
return Boolean(raw);
}
if (field.type === "list") {
if (typeof raw !== "string" || raw.trim() === "") {
return undefined;
}
try {
return JSON.parse(raw) as unknown[];
} catch {
return undefined;
}
}
if (field.type === "integer" || field.type === "float") {
if (raw === undefined || raw === null || raw === "") {
return undefined;
}
const parsed = Number(raw);
return Number.isNaN(parsed) ? undefined : parsed;
}
if (typeof raw !== "string") {
return raw === undefined ? undefined : String(raw);
}
const trimmed = raw.trim();
return trimmed === "" ? undefined : trimmed;
};
export const groupFieldsByCategory = (fields: any[], redisType: string) => {
// Basic fields that are always shown
const basicFieldNames = ["host", "port", "password", "username"];
const basicFields = basicFieldNames.map((name) => getFieldByName(fields, name)).filter(Boolean);
export const buildCachePayload = (
redisType: RedisType,
values: CacheFormValues,
{ forTesting }: { forTesting: boolean },
): CacheSavePayload => {
const type = !forTesting && redisType === "semantic" ? "redis-semantic" : "redis";
// Advanced field groups
const sslFields = ["ssl", "ssl_cert_reqs", "ssl_check_hostname"]
.map((name) => getFieldByName(fields, name))
.filter(Boolean);
const cacheManagementFields = ["namespace", "ttl", "max_connections"]
.map((name) => getFieldByName(fields, name))
.filter(Boolean);
const gcpFields = ["gcp_service_account", "gcp_ssl_ca_certs"]
.map((name) => getFieldByName(fields, name))
.filter(Boolean);
// Redis type-specific fields
const clusterFields = fields.filter((f) => f.redis_type === "cluster");
const sentinelFields = fields.filter((f) => f.redis_type === "sentinel");
const semanticFields = fields.filter((f) => f.redis_type === "semantic");
return {
basicFields,
sslFields,
cacheManagementFields,
gcpFields,
clusterFields,
sentinelFields,
semanticFields,
};
};
export const gatherFormValues = (fields: any[], redisType: string): { [key: string]: any } => {
const values: { [key: string]: any } = {
type: "redis", // Cache class accepts 'type' parameter (LiteLLMCacheType enum)
};
// Iterate through all fields from backend
fields.forEach((field) => {
// Skip redis_type - it's UI-only, not sent to backend
if (field.field_name === "redis_type") {
return;
}
// Check if field should be shown for current redis type
if (!shouldShowField(field, redisType)) {
return;
}
const fieldName = field.field_name;
let value: any = null;
if (field.field_type === "Boolean") {
const checkboxEl = document.querySelector(`input[name="${fieldName}"]`) as HTMLInputElement | null;
if (checkboxEl?.checked !== undefined) {
value = checkboxEl.checked;
}
} else if (field.field_type === "List") {
const textareaEl = document.querySelector(`textarea[name="${fieldName}"]`) as HTMLTextAreaElement | null;
if (textareaEl?.value) {
try {
value = JSON.parse(textareaEl.value);
} catch (e) {
console.error(`Invalid JSON for ${fieldName}:`, e);
}
}
} else {
const inputEl = document.querySelector(`input[name="${fieldName}"]`) as HTMLInputElement | null;
if (inputEl?.value) {
const trimmedValue = inputEl.value.trim();
if (trimmedValue !== "") {
if (field.field_type === "Integer") {
const num = Number(trimmedValue);
if (!isNaN(num)) value = num;
} else if (field.field_type === "Float") {
const num = Number(trimmedValue);
if (!isNaN(num)) value = num;
} else {
value = trimmedValue;
}
}
}
}
if (value !== null && value !== undefined) {
values[fieldName] = value;
}
const entries = CACHE_FIELDS.filter((field) => isFieldVisible(field, redisType)).flatMap((field) => {
const value = saveValueForField(field, values[field.name]);
return value === undefined ? [] : [[field.name, value] as const];
});
return values;
return { type, ...Object.fromEntries(entries) };
};

View file

@ -0,0 +1,147 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import CacheSettings from "./index";
const { getCacheSettingsCall, testCacheConnectionCall, updateCacheSettingsCall } = vi.hoisted(() => ({
getCacheSettingsCall: vi.fn(),
testCacheConnectionCall: vi.fn(),
updateCacheSettingsCall: vi.fn(),
}));
vi.mock("@/components/networking", () => ({
getCacheSettingsCall,
testCacheConnectionCall,
updateCacheSettingsCall,
}));
vi.mock("@/components/llm_calls/fetch_models", () => ({
fetchAvailableModels: vi.fn().mockResolvedValue([]),
}));
const renderSettings = () => render(<CacheSettings accessToken="sk-test" userRole="Admin" userID="u1" />);
describe("CacheSettings", () => {
beforeEach(() => {
vi.clearAllMocks();
getCacheSettingsCall.mockResolvedValue({ current_values: {} });
updateCacheSettingsCall.mockResolvedValue({ status: "success" });
testCacheConnectionCall.mockResolvedValue({ status: "success" });
});
it("should render the connection fields once current values load", async () => {
renderSettings();
expect(await screen.findByText("Connection Settings")).toBeInTheDocument();
});
describe("when the redis type is node", () => {
it("should show the connection fields and hide cluster/sentinel/semantic fields", async () => {
renderSettings();
expect(await screen.findByText("Redis URL")).toBeInTheDocument();
expect(screen.getByText("Database Index")).toBeInTheDocument();
expect(screen.queryByText("Startup Nodes")).not.toBeInTheDocument();
expect(screen.queryByText("Sentinel Nodes")).not.toBeInTheDocument();
expect(screen.queryByText("Embedding Model")).not.toBeInTheDocument();
});
});
describe("when the redis type is cluster", () => {
it("should reveal the cluster startup nodes field", async () => {
getCacheSettingsCall.mockResolvedValue({ current_values: { redis_type: "cluster" } });
renderSettings();
expect(await screen.findByText("Startup Nodes")).toBeInTheDocument();
});
});
describe("when the redis type is sentinel", () => {
it("should reveal the sentinel fields", async () => {
getCacheSettingsCall.mockResolvedValue({ current_values: { redis_type: "sentinel" } });
renderSettings();
expect(await screen.findByText("Sentinel Nodes")).toBeInTheDocument();
expect(screen.getByText("Service Name")).toBeInTheDocument();
});
});
describe("when the redis type is semantic", () => {
it("should reveal the semantic fields", async () => {
getCacheSettingsCall.mockResolvedValue({ current_values: { redis_type: "semantic" } });
renderSettings();
expect(await screen.findByText("Similarity Threshold")).toBeInTheDocument();
expect(screen.getByText("Embedding Model")).toBeInTheDocument();
});
});
describe("when a field fails inline validation", () => {
it("should block save and surface the validation message", async () => {
const user = userEvent.setup();
renderSettings();
const port = await screen.findByLabelText("Port");
await user.clear(port);
await user.type(port, "99999");
await user.click(screen.getByRole("button", { name: /save changes/i }));
expect(await screen.findByText(/Port must be an integer between 1 and 65535/i)).toBeInTheDocument();
expect(updateCacheSettingsCall).not.toHaveBeenCalled();
});
it("should block save when a list field holds malformed JSON instead of silently dropping it", async () => {
const user = userEvent.setup();
getCacheSettingsCall.mockResolvedValue({ current_values: { redis_type: "cluster" } });
renderSettings();
const startupNodes = await screen.findByLabelText("Startup Nodes");
await user.type(startupNodes, "not json");
await user.click(screen.getByRole("button", { name: /save changes/i }));
expect(await screen.findByText(/Must be a valid JSON array/i)).toBeInTheDocument();
expect(updateCacheSettingsCall).not.toHaveBeenCalled();
});
it("should block save with an error when a non-numeric value is entered into a numeric field", async () => {
const user = userEvent.setup();
renderSettings();
const db = await screen.findByLabelText("Database Index");
await user.type(db, "redis://host:6379/1");
await user.click(screen.getByRole("button", { name: /save changes/i }));
expect(await screen.findByText(/Must be a non-negative integer/i)).toBeInTheDocument();
expect(updateCacheSettingsCall).not.toHaveBeenCalled();
});
});
describe("when saving a valid node configuration", () => {
it("should send the backend payload shape with type redis and no UI-only fields", async () => {
const user = userEvent.setup();
renderSettings();
const host = await screen.findByLabelText("Host");
await user.type(host, "localhost");
await user.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() =>
expect(updateCacheSettingsCall).toHaveBeenCalledWith("sk-test", {
type: "redis",
host: "localhost",
port: "6379",
ssl: false,
ssl_check_hostname: false,
}),
);
});
it("should include a numeric field like Database Index in the save payload", async () => {
const user = userEvent.setup();
renderSettings();
await user.type(await screen.findByLabelText("Redis URL"), "redis://host:6379/1");
await user.type(await screen.findByLabelText("Database Index"), "2");
await user.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => expect(updateCacheSettingsCall).toHaveBeenCalled());
expect(updateCacheSettingsCall.mock.calls[0][1]).toMatchObject({ db: 2, url: "redis://host:6379/1" });
});
});
});

View file

@ -1,10 +1,14 @@
import React, { useState, useEffect, useCallback } from "react";
import { Button, Accordion, AccordionHeader, AccordionBody } from "@tremor/react";
import { Form } from "antd";
import { getCacheSettingsCall, testCacheConnectionCall, updateCacheSettingsCall } from "@/components/networking";
import { fetchAvailableModels, ModelGroup } from "@/components/llm_calls/fetch_models";
import NotificationsManager from "@/components/molecules/notifications_manager";
import RedisTypeSelector from "./RedisTypeSelector";
import CacheFieldRenderer from "./CacheFieldRenderer";
import { gatherFormValues, groupFieldsByCategory } from "./cacheSettingsUtils";
import CacheFieldSection from "./CacheFieldSection";
import { EmbeddingModelOption } from "./CacheFormField";
import { REDIS_TYPES, REDIS_TYPE_DESCRIPTIONS, RedisType } from "./cacheSettingsFields";
import { buildCachePayload, buildInitialValues, CacheFormValues } from "./cacheSettingsUtils";
interface CacheSettingsProps {
accessToken: string | null;
@ -12,66 +16,83 @@ interface CacheSettingsProps {
userID: string | null;
}
const CacheSettings: React.FC<CacheSettingsProps> = ({ accessToken, userRole, userID }) => {
const [cacheSettings, setCacheSettings] = useState<{ [key: string]: any }>({});
const [fields, setFields] = useState<any[]>([]);
const [redisTypeDescriptions, setRedisTypeDescriptions] = useState<{ [key: string]: string }>({});
const [redisType, setRedisType] = useState<string>("node");
const toRedisType = (value: unknown): RedisType =>
REDIS_TYPES.includes(value as RedisType) ? (value as RedisType) : "node";
const CacheSettings: React.FC<CacheSettingsProps> = ({ accessToken }) => {
const [form] = Form.useForm<CacheFormValues>();
const [redisType, setRedisType] = useState<RedisType>("node");
const [embeddingModels, setEmbeddingModels] = useState<EmbeddingModelOption[]>([]);
const [isTesting, setIsTesting] = useState<boolean>(false);
const [isSaving, setIsSaving] = useState<boolean>(false);
const loadCacheSettings = useCallback(async () => {
if (!accessToken) {
return;
}
try {
const data = await getCacheSettingsCall(accessToken!);
console.log("cache settings from API", data);
if (data.fields) {
setFields(data.fields);
}
// Set current values
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);
}
const data = (await getCacheSettingsCall(accessToken)) as { current_values?: Record<string, unknown> };
const currentValues = data.current_values ?? {};
form.setFieldsValue(buildInitialValues(currentValues));
setRedisType(toRedisType(currentValues.redis_type));
} catch (error) {
console.error("Failed to load cache settings:", error);
NotificationsManager.fromBackend("Failed to load cache settings");
}
}, [accessToken]);
}, [accessToken, form]);
useEffect(() => {
loadCacheSettings();
}, [loadCacheSettings]);
useEffect(() => {
if (!accessToken) {
return;
}
loadCacheSettings();
}, [accessToken, loadCacheSettings]);
fetchAvailableModels(accessToken)
.then((models: ModelGroup[]) =>
setEmbeddingModels(
models
.filter((model) => model.mode === "embedding")
.map((model) => ({ value: model.model_group, label: model.model_group })),
),
)
.catch((error) => console.error("Error fetching embedding models:", error));
}, [accessToken]);
const validate = async (): Promise<CacheFormValues | null> => {
try {
return await form.validateFields();
} catch {
return null;
}
};
const handleTestConnection = async () => {
if (!accessToken) {
return;
}
const values = await validate();
if (values === null) {
return;
}
setIsTesting(true);
try {
const testSettings = gatherFormValues(fields, redisType);
const result = await testCacheConnectionCall(accessToken, testSettings);
const result = await testCacheConnectionCall(
accessToken,
buildCachePayload(redisType, values, { forTesting: true }),
);
if (result.status === "success") {
NotificationsManager.success("Cache connection test successful!");
} else {
NotificationsManager.fromBackend(`Connection test failed: ${result.message || result.error}`);
}
} catch (error: any) {
} catch (error) {
console.error("Test connection error:", error);
NotificationsManager.fromBackend(`Connection test failed: ${error.message || "Unknown error"}`);
NotificationsManager.fromBackend(
`Connection test failed: ${error instanceof Error ? error.message : "Unknown error"}`,
);
} finally {
setIsTesting(false);
}
@ -81,16 +102,15 @@ const CacheSettings: React.FC<CacheSettingsProps> = ({ accessToken, userRole, us
if (!accessToken) {
return;
}
const values = await validate();
if (values === null) {
return;
}
setIsSaving(true);
try {
const settingsToSave = gatherFormValues(fields, redisType);
if (redisType === "semantic") {
settingsToSave.type = "redis-semantic";
}
await updateCacheSettingsCall(accessToken, settingsToSave);
await updateCacheSettingsCall(accessToken, buildCachePayload(redisType, values, { forTesting: false }));
NotificationsManager.success("Cache settings updated successfully");
// Reload settings to reflect saved values
await loadCacheSettings();
} catch (error) {
console.error("Failed to save cache settings:", error);
@ -104,127 +124,95 @@ const CacheSettings: React.FC<CacheSettingsProps> = ({ accessToken, userRole, us
return null;
}
const { basicFields, sslFields, cacheManagementFields, gcpFields, clusterFields, sentinelFields, semanticFields } =
groupFieldsByCategory(fields, redisType);
return (
<div className="w-full space-y-8 py-2">
<div className="space-y-6">
<Form form={form} layout="vertical" requiredMark={false} 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>
</div>
{/* Redis Type Selector */}
<RedisTypeSelector
redisType={redisType}
redisTypeDescriptions={redisTypeDescriptions}
onTypeChange={setRedisType}
redisTypeDescriptions={REDIS_TYPE_DESCRIPTIONS}
onTypeChange={(type) => setRedisType(toRedisType(type))}
/>
{/* 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="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} />;
})}
</div>
<div className="pt-4 border-t border-gray-200">
<CacheFieldSection
title="Connection Settings"
section="connection"
redisType={redisType}
embeddingModels={embeddingModels}
/>
</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="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} />;
})}
</div>
{redisType === "cluster" && (
<div className="pt-4 border-t border-gray-200">
<CacheFieldSection
title="Cluster Configuration"
section="cluster"
redisType={redisType}
embeddingModels={embeddingModels}
gridCols="grid-cols-1 gap-6"
/>
</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="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} />;
})}
</div>
{redisType === "sentinel" && (
<div className="pt-4 border-t border-gray-200">
<CacheFieldSection
title="Sentinel Configuration"
section="sentinel"
redisType={redisType}
embeddingModels={embeddingModels}
/>
</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="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} />;
})}
</div>
{redisType === "semantic" && (
<div className="pt-4 border-t border-gray-200">
<CacheFieldSection
title="Semantic Configuration"
section="semantic"
redisType={redisType}
embeddingModels={embeddingModels}
/>
</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} />;
})}
</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} />;
})}
</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} />;
})}
</div>
</div>
)}
<CacheFieldSection
title="SSL Settings"
section="ssl"
redisType={redisType}
embeddingModels={embeddingModels}
headingLevel="h5"
/>
<CacheFieldSection
title="Cache Management"
section="cacheManagement"
redisType={redisType}
embeddingModels={embeddingModels}
headingLevel="h5"
/>
<CacheFieldSection
title="GCP Authentication"
section="gcp"
redisType={redisType}
embeddingModels={embeddingModels}
headingLevel="h5"
/>
</div>
</AccordionBody>
</Accordion>
</div>
</Form>
{/* 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">
{isTesting ? "Testing..." : "Test Connection"}