mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-13 03:59:12 +00:00
Merge 460f72f055 into 780c504e00
This commit is contained in:
commit
3b08d66e65
12 changed files with 328 additions and 62 deletions
|
|
@ -6,6 +6,7 @@
|
|||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { fetchAvailableModels, ModelGroup } from "@/components/llm_calls/fetch_models";
|
||||
|
|
@ -20,9 +21,10 @@ interface AddFallbacksProps {
|
|||
accessToken: string;
|
||||
value?: Fallbacks; // Current fallbacks value from form
|
||||
onChange?: (fallbacks: Fallbacks) => Promise<void>; // Callback to update form value
|
||||
disabledReason?: string; // When set, the button is disabled and this explains why
|
||||
}
|
||||
|
||||
export default function AddFallbacks({ accessToken, value = [], onChange }: AddFallbacksProps) {
|
||||
export default function AddFallbacks({ accessToken, value = [], onChange, disabledReason }: AddFallbacksProps) {
|
||||
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||
const [modelInfo, setModelInfo] = useState<ModelGroup[]>([]);
|
||||
const [modalKey, setModalKey] = useState(0); // Key to force remount of form when modal opens
|
||||
|
|
@ -116,10 +118,22 @@ export default function AddFallbacks({ accessToken, value = [], onChange }: AddF
|
|||
|
||||
return (
|
||||
<div>
|
||||
<Button className="mx-auto" onClick={() => setIsModalVisible(true)}>
|
||||
<span>+</span>
|
||||
Add Fallbacks
|
||||
</Button>
|
||||
{disabledReason ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<span className="inline-flex" tabIndex={0} />}>
|
||||
<Button className="mx-auto" disabled aria-disabled="true">
|
||||
<span>+</span>
|
||||
Add Fallbacks
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{disabledReason}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Button className="mx-auto" onClick={() => setIsModalVisible(true)}>
|
||||
<span>+</span>
|
||||
Add Fallbacks
|
||||
</Button>
|
||||
)}
|
||||
<AddFallbacksModal open={isModalVisible} onCancel={handleCancel}>
|
||||
<FallbackSelectionForm
|
||||
key={modalKey}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import * as fetchModelsModule from "@/components/llm_calls/fetch_models";
|
|||
|
||||
vi.mock("../../../networking", () => ({
|
||||
getCallbacksCall: vi.fn(),
|
||||
getRouterSettingsCall: vi.fn(),
|
||||
setCallbacksCall: vi.fn(),
|
||||
}));
|
||||
|
||||
|
|
@ -57,7 +58,7 @@ vi.mock("../../../common_components/DeleteResourceModal", () => ({
|
|||
|
||||
vi.mock("./AddFallbacks", () => ({
|
||||
__esModule: true,
|
||||
default: ({ value, onChange }: any) => {
|
||||
default: ({ value, onChange, disabledReason }: any) => {
|
||||
const handleClick = async () => {
|
||||
if (onChange) {
|
||||
try {
|
||||
|
|
@ -69,7 +70,7 @@ vi.mock("./AddFallbacks", () => ({
|
|||
}
|
||||
};
|
||||
return (
|
||||
<button onClick={handleClick} data-testid="add-fallbacks-button">
|
||||
<button onClick={handleClick} data-testid="add-fallbacks-button" disabled={Boolean(disabledReason)}>
|
||||
Add Fallbacks
|
||||
</button>
|
||||
);
|
||||
|
|
@ -107,6 +108,7 @@ describe("Fallbacks", () => {
|
|||
vi.mocked(networkingModule.getCallbacksCall).mockResolvedValue({
|
||||
router_settings: mockRouterSettings,
|
||||
});
|
||||
vi.mocked(networkingModule.getRouterSettingsCall).mockResolvedValue({ source: { fallbacks: "db" } });
|
||||
vi.mocked(networkingModule.setCallbacksCall).mockResolvedValue(undefined);
|
||||
vi.mocked(fetchModelsModule.fetchAvailableModels).mockResolvedValue([
|
||||
{ model_group: "gpt-4", mode: "chat" },
|
||||
|
|
@ -175,6 +177,67 @@ describe("Fallbacks", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("when the router settings source cannot be loaded", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(networkingModule.getRouterSettingsCall).mockRejectedValue(new Error("500 Internal Server Error"));
|
||||
});
|
||||
|
||||
it("keeps add, edit and delete grayed out instead of guessing the list is editable", async () => {
|
||||
renderWithQueryClient(<Fallbacks {...defaultProps} />);
|
||||
|
||||
expect((await screen.findAllByText("gpt-4")).length).toBeGreaterThan(0);
|
||||
expect(screen.getByTestId("add-fallbacks-button")).toBeDisabled();
|
||||
for (const button of screen.getAllByTestId("edit-fallback-button")) {
|
||||
expect(button).toHaveAttribute("aria-disabled", "true");
|
||||
}
|
||||
for (const button of screen.getAllByTestId("delete-fallback-button")) {
|
||||
expect(button).toHaveAttribute("aria-disabled", "true");
|
||||
}
|
||||
expect(screen.queryByTestId("config-owned-badge")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("when config.yaml owns fallbacks", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(networkingModule.getRouterSettingsCall).mockResolvedValue({ source: { fallbacks: "config" } });
|
||||
});
|
||||
|
||||
it("shows the config rules but grays out add, edit and delete", async () => {
|
||||
renderWithQueryClient(<Fallbacks {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("config-owned-badge")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getAllByText("gpt-4").length).toBeGreaterThan(0);
|
||||
expect(screen.getByTestId("add-fallbacks-button")).toBeDisabled();
|
||||
for (const button of screen.getAllByTestId("edit-fallback-button")) {
|
||||
expect(button).toHaveAttribute("aria-disabled", "true");
|
||||
}
|
||||
for (const button of screen.getAllByTestId("delete-fallback-button")) {
|
||||
expect(button).toHaveAttribute("aria-disabled", "true");
|
||||
}
|
||||
});
|
||||
|
||||
it("does not open the edit or delete modals from click or keyboard", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithQueryClient(<Fallbacks {...defaultProps} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("config-owned-badge")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getAllByTestId("delete-fallback-button")[0]);
|
||||
screen.getAllByTestId("delete-fallback-button")[0].focus();
|
||||
await user.keyboard("{Enter}");
|
||||
expect(screen.queryByTestId("delete-modal")).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getAllByTestId("edit-fallback-button")[0]);
|
||||
screen.getAllByTestId("edit-fallback-button")[0].focus();
|
||||
await user.keyboard("{Enter}");
|
||||
expect(screen.queryByText("Configure Model Fallbacks")).not.toBeInTheDocument();
|
||||
expect(networkingModule.setCallbacksCall).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("should open delete modal when delete icon is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithQueryClient(<Fallbacks {...defaultProps} />);
|
||||
|
|
|
|||
|
|
@ -7,7 +7,14 @@ import React, { useEffect, useState } from "react";
|
|||
import DeleteResourceModal from "../../../common_components/DeleteResourceModal";
|
||||
import { ProviderLogo } from "../../../molecules/models/ProviderLogo";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { getCallbacksCall, setCallbacksCall } from "../../../networking";
|
||||
import { getCallbacksCall, getRouterSettingsCall, setCallbacksCall } from "../../../networking";
|
||||
import {
|
||||
CONFIG_OWNED_HINT,
|
||||
SOURCE_UNKNOWN_HINT,
|
||||
ConfigOwnedBadge,
|
||||
RouterSettingsSource,
|
||||
isOwnedByConfig,
|
||||
} from "../../../router_settings/ConfigOwned";
|
||||
import { isProxyAdminRole } from "@/utils/roles";
|
||||
import AddFallbacks from "./AddFallbacks";
|
||||
import EditFallbacks from "./EditFallbacks";
|
||||
|
|
@ -119,12 +126,19 @@ async function testFallbackModelResponse(selectedModel: string, accessToken: str
|
|||
}
|
||||
}
|
||||
|
||||
function fallbacksWriteHint(source: RouterSettingsSource | null, ownedByConfig: boolean): string | undefined {
|
||||
if (ownedByConfig) return CONFIG_OWNED_HINT;
|
||||
if (source === null) return SOURCE_UNKNOWN_HINT;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const Fallbacks: React.FC<FallbacksProps> = ({ accessToken, userRole, userID }) => {
|
||||
const [routerSettings, setRouterSettings] = useState<{ [key: string]: any }>({});
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [fallbackToDelete, setFallbackToDelete] = useState<FallbackEntry | null>(null);
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [fallbackToEdit, setFallbackToEdit] = useState<FallbackEntry | null>(null);
|
||||
const [routerSettingsSource, setRouterSettingsSource] = useState<RouterSettingsSource | null>(null);
|
||||
|
||||
const { data: modelCostMapData } = useModelCostMap();
|
||||
const getProviderFromModel = (model: string): string => {
|
||||
|
|
@ -145,6 +159,9 @@ const Fallbacks: React.FC<FallbacksProps> = ({ accessToken, userRole, userID })
|
|||
}
|
||||
setRouterSettings(router_settings);
|
||||
});
|
||||
getRouterSettingsCall(accessToken)
|
||||
.then((data) => setRouterSettingsSource(data.source ?? {}))
|
||||
.catch(() => setRouterSettingsSource(null));
|
||||
}, [accessToken, userRole, userID]);
|
||||
|
||||
const handleDeleteClick = (fallbackEntry: FallbackEntry) => {
|
||||
|
|
@ -250,15 +267,24 @@ const Fallbacks: React.FC<FallbacksProps> = ({ accessToken, userRole, userID })
|
|||
const hasFallbacks = Array.isArray(routerSettings.fallbacks) && routerSettings.fallbacks.length > 0;
|
||||
// Admin Viewer follows the read-parity rule: see fallbacks, no writes.
|
||||
const canModify = isProxyAdminRole(userRole ?? "");
|
||||
const ownedByConfig = routerSettingsSource !== null && isOwnedByConfig(routerSettingsSource, "fallbacks");
|
||||
const writeHint = fallbacksWriteHint(routerSettingsSource, ownedByConfig);
|
||||
const canWrite = canModify && writeHint === undefined;
|
||||
const writeActionClass = (hoverClass: string) =>
|
||||
canWrite ? `${iconWrapperClass} cursor-pointer ${hoverClass}` : `${iconWrapperClass} cursor-not-allowed opacity-50`;
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
{canModify && (
|
||||
<AddFallbacks
|
||||
accessToken={accessToken || ""}
|
||||
value={routerSettings.fallbacks || []}
|
||||
onChange={handleFallbacksChange}
|
||||
/>
|
||||
<div className="flex items-center">
|
||||
<AddFallbacks
|
||||
accessToken={accessToken || ""}
|
||||
value={routerSettings.fallbacks || []}
|
||||
onChange={handleFallbacksChange}
|
||||
disabledReason={writeHint}
|
||||
/>
|
||||
{ownedByConfig && <ConfigOwnedBadge />}
|
||||
</div>
|
||||
)}
|
||||
{!hasFallbacks ? (
|
||||
<div className="rounded-lg border border-border bg-muted px-4 py-6 text-center">
|
||||
|
|
@ -309,15 +335,16 @@ const Fallbacks: React.FC<FallbacksProps> = ({ accessToken, userRole, userID })
|
|||
data-testid="edit-fallback-button"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => handleEditClick(item)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleEditClick(item)}
|
||||
className={`${iconWrapperClass} cursor-pointer hover:text-info`}
|
||||
aria-disabled={!canWrite}
|
||||
onClick={() => canWrite && handleEditClick(item)}
|
||||
onKeyDown={(e) => canWrite && e.key === "Enter" && handleEditClick(item)}
|
||||
className={writeActionClass("hover:text-info")}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Pencil className="h-5 w-5 shrink-0" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Edit fallback</TooltipContent>
|
||||
<TooltipContent>{writeHint ?? "Edit fallback"}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
|
|
@ -326,15 +353,16 @@ const Fallbacks: React.FC<FallbacksProps> = ({ accessToken, userRole, userID })
|
|||
data-testid="delete-fallback-button"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => handleDeleteClick(item)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleDeleteClick(item)}
|
||||
className={`${iconWrapperClass} cursor-pointer hover:text-destructive`}
|
||||
aria-disabled={!canWrite}
|
||||
onClick={() => canWrite && handleDeleteClick(item)}
|
||||
onKeyDown={(e) => canWrite && e.key === "Enter" && handleDeleteClick(item)}
|
||||
className={writeActionClass("hover:text-destructive")}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-5 w-5 shrink-0" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Delete fallback</TooltipContent>
|
||||
<TooltipContent>{writeHint ?? "Delete fallback"}</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
import { Badge } from "@/components/ui/badge";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Lock } from "lucide-react";
|
||||
import React from "react";
|
||||
|
||||
export type RouterSettingSource = "config" | "db" | "env" | "default" | "unset";
|
||||
export type RouterSettingsSource = { [key: string]: RouterSettingSource };
|
||||
|
||||
export const CONFIG_OWNED_HINT = "Set in config.yaml. Edit the file to change it.";
|
||||
export const SOURCE_UNKNOWN_HINT = "Could not load which settings config.yaml owns. Reload the page to make changes.";
|
||||
|
||||
export function isOwnedByConfig(source: RouterSettingsSource, key: string): boolean {
|
||||
return source[key] === "config";
|
||||
}
|
||||
|
||||
export function ConfigOwnedBadge(): React.ReactElement {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={<Badge variant="outline" data-testid="config-owned-badge" className="ml-2 align-middle" />}
|
||||
>
|
||||
<Lock aria-hidden />
|
||||
config.yaml
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{CONFIG_OWNED_HINT}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import React from "react";
|
||||
import { CONFIG_OWNED_HINT, ConfigOwnedBadge } from "./ConfigOwned";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
interface routingStrategyArgs {
|
||||
|
|
@ -13,9 +14,13 @@ const defaultLowestLatencyArgs: routingStrategyArgs = {
|
|||
|
||||
interface LatencyBasedConfigurationProps {
|
||||
routingStrategyArgs: { [key: string]: any };
|
||||
ownedByConfig?: boolean;
|
||||
}
|
||||
|
||||
const LatencyBasedConfiguration: React.FC<LatencyBasedConfigurationProps> = ({ routingStrategyArgs }) => {
|
||||
const LatencyBasedConfiguration: React.FC<LatencyBasedConfigurationProps> = ({
|
||||
routingStrategyArgs,
|
||||
ownedByConfig = false,
|
||||
}) => {
|
||||
const paramExplanation: { [key: string]: string } = {
|
||||
ttl: "Sliding window to look back over when calculating the average latency of a deployment. Default - 1 hour (in seconds).",
|
||||
lowest_latency_buffer:
|
||||
|
|
@ -26,7 +31,10 @@ const LatencyBasedConfiguration: React.FC<LatencyBasedConfigurationProps> = ({ r
|
|||
<>
|
||||
<div className="space-y-6">
|
||||
<div className="max-w-3xl">
|
||||
<h3 className="text-sm font-medium text-foreground">Latency-Based Configuration</h3>
|
||||
<h3 className="text-sm font-medium text-foreground">
|
||||
Latency-Based Configuration
|
||||
{ownedByConfig && <ConfigOwnedBadge />}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1">Fine-tune latency-based routing behavior</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -41,6 +49,8 @@ const LatencyBasedConfiguration: React.FC<LatencyBasedConfigurationProps> = ({ r
|
|||
<Input
|
||||
name={param}
|
||||
defaultValue={typeof value === "object" ? JSON.stringify(value, null, 2) : value?.toString()}
|
||||
disabled={ownedByConfig}
|
||||
title={ownedByConfig ? CONFIG_OWNED_HINT : undefined}
|
||||
className="font-mono text-sm w-full"
|
||||
/>
|
||||
</label>
|
||||
|
|
|
|||
|
|
@ -1,14 +1,17 @@
|
|||
import React from "react";
|
||||
import { CONFIG_OWNED_HINT, ConfigOwnedBadge, RouterSettingsSource, isOwnedByConfig } from "./ConfigOwned";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
interface ReliabilityRetriesSectionProps {
|
||||
routerSettings: { [key: string]: any };
|
||||
routerFieldsMetadata: { [key: string]: any };
|
||||
routerSettingsSource?: RouterSettingsSource;
|
||||
}
|
||||
|
||||
const ReliabilityRetriesSection: React.FC<ReliabilityRetriesSectionProps> = ({
|
||||
routerSettings,
|
||||
routerFieldsMetadata,
|
||||
routerSettingsSource = {},
|
||||
}) => {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
|
@ -36,6 +39,7 @@ const ReliabilityRetriesSection: React.FC<ReliabilityRetriesSectionProps> = ({
|
|||
<span className="text-xs font-medium text-foreground uppercase tracking-wide">
|
||||
{routerFieldsMetadata[param]?.ui_field_name || param}
|
||||
</span>
|
||||
{isOwnedByConfig(routerSettingsSource, param) && <ConfigOwnedBadge />}
|
||||
<p className="text-xs text-muted-foreground mt-0.5 mb-2">
|
||||
{routerFieldsMetadata[param]?.field_description || ""}
|
||||
</p>
|
||||
|
|
@ -49,6 +53,8 @@ const ReliabilityRetriesSection: React.FC<ReliabilityRetriesSectionProps> = ({
|
|||
: value?.toString() || ""
|
||||
}
|
||||
placeholder="—"
|
||||
disabled={isOwnedByConfig(routerSettingsSource, param)}
|
||||
title={isOwnedByConfig(routerSettingsSource, param) ? CONFIG_OWNED_HINT : undefined}
|
||||
className="font-mono text-sm w-full"
|
||||
/>
|
||||
</label>
|
||||
|
|
|
|||
|
|
@ -88,6 +88,38 @@ describe("RouterSettingsForm", () => {
|
|||
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ enableTagFiltering: true }));
|
||||
});
|
||||
|
||||
it("disables every control whose key config.yaml owns and leaves the rest editable", () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
value: {
|
||||
...defaultValue,
|
||||
selectedStrategy: "latency-based-routing",
|
||||
routerSettings: {
|
||||
routing_strategy: "latency-based-routing",
|
||||
routing_strategy_args: { ttl: 3600 },
|
||||
num_retries: 3,
|
||||
timeout: 60,
|
||||
},
|
||||
},
|
||||
availableRoutingStrategies: ["simple-shuffle", "latency-based-routing"],
|
||||
routerSettingsSource: {
|
||||
routing_strategy: "config" as const,
|
||||
routing_strategy_args: "config" as const,
|
||||
enable_tag_filtering: "config" as const,
|
||||
num_retries: "config" as const,
|
||||
timeout: "db" as const,
|
||||
},
|
||||
};
|
||||
render(<RouterSettingsForm {...props} />);
|
||||
|
||||
expect(screen.getByRole("combobox")).toBeDisabled();
|
||||
expect(screen.getByRole("switch")).toHaveAttribute("aria-disabled", "true");
|
||||
expect(document.querySelector('input[name="ttl"]')).toBeDisabled();
|
||||
expect(document.querySelector('input[name="num_retries"]')).toBeDisabled();
|
||||
expect(document.querySelector('input[name="timeout"]')).toBeEnabled();
|
||||
expect(screen.getAllByTestId("config-owned-badge")).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("should show the Reliability & Retries section", () => {
|
||||
render(<RouterSettingsForm {...baseProps} />);
|
||||
expect(screen.getByText("Reliability & Retries")).toBeInTheDocument();
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import React from "react";
|
||||
import LatencyBasedConfiguration from "./LatencyBasedConfiguration";
|
||||
import { RouterSettingsSource, isOwnedByConfig } from "./ConfigOwned";
|
||||
import ReliabilityRetriesSection from "./ReliabilityRetriesSection";
|
||||
import RoutingStrategySelector from "./RoutingStrategySelector";
|
||||
import TagFilteringToggle from "./TagFilteringToggle";
|
||||
|
|
@ -16,6 +17,7 @@ interface RouterSettingsFormProps {
|
|||
routerFieldsMetadata: { [key: string]: any };
|
||||
availableRoutingStrategies: string[];
|
||||
routingStrategyDescriptions: { [key: string]: string };
|
||||
routerSettingsSource?: RouterSettingsSource;
|
||||
}
|
||||
|
||||
const RouterSettingsForm: React.FC<RouterSettingsFormProps> = ({
|
||||
|
|
@ -24,6 +26,7 @@ const RouterSettingsForm: React.FC<RouterSettingsFormProps> = ({
|
|||
routerFieldsMetadata,
|
||||
availableRoutingStrategies,
|
||||
routingStrategyDescriptions,
|
||||
routerSettingsSource = {},
|
||||
}) => {
|
||||
const handleStrategyChange = (strategy: string) => {
|
||||
onChange({
|
||||
|
|
@ -56,6 +59,7 @@ const RouterSettingsForm: React.FC<RouterSettingsFormProps> = ({
|
|||
routingStrategyDescriptions={routingStrategyDescriptions}
|
||||
routerFieldsMetadata={routerFieldsMetadata}
|
||||
onStrategyChange={handleStrategyChange}
|
||||
ownedByConfig={isOwnedByConfig(routerSettingsSource, "routing_strategy")}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
@ -64,6 +68,7 @@ const RouterSettingsForm: React.FC<RouterSettingsFormProps> = ({
|
|||
enabled={value.enableTagFiltering}
|
||||
routerFieldsMetadata={routerFieldsMetadata}
|
||||
onToggle={handleTagFilteringToggle}
|
||||
ownedByConfig={isOwnedByConfig(routerSettingsSource, "enable_tag_filtering")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -72,11 +77,18 @@ const RouterSettingsForm: React.FC<RouterSettingsFormProps> = ({
|
|||
|
||||
{/* Strategy-Specific Args - Show immediately after strategy if latency-based */}
|
||||
{value.selectedStrategy === "latency-based-routing" && (
|
||||
<LatencyBasedConfiguration routingStrategyArgs={value.routerSettings["routing_strategy_args"]} />
|
||||
<LatencyBasedConfiguration
|
||||
routingStrategyArgs={value.routerSettings["routing_strategy_args"]}
|
||||
ownedByConfig={isOwnedByConfig(routerSettingsSource, "routing_strategy_args")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Other Settings */}
|
||||
<ReliabilityRetriesSection routerSettings={value.routerSettings} routerFieldsMetadata={routerFieldsMetadata} />
|
||||
<ReliabilityRetriesSection
|
||||
routerSettings={value.routerSettings}
|
||||
routerFieldsMetadata={routerFieldsMetadata}
|
||||
routerSettingsSource={routerSettingsSource}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React from "react";
|
||||
import { CONFIG_OWNED_HINT, ConfigOwnedBadge } from "./ConfigOwned";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
|
||||
interface RoutingStrategySelectorProps {
|
||||
|
|
@ -7,6 +8,7 @@ interface RoutingStrategySelectorProps {
|
|||
routingStrategyDescriptions: { [key: string]: string };
|
||||
routerFieldsMetadata: { [key: string]: any };
|
||||
onStrategyChange: (strategy: string) => void;
|
||||
ownedByConfig?: boolean;
|
||||
}
|
||||
|
||||
const RoutingStrategySelector: React.FC<RoutingStrategySelectorProps> = ({
|
||||
|
|
@ -15,6 +17,7 @@ const RoutingStrategySelector: React.FC<RoutingStrategySelectorProps> = ({
|
|||
routingStrategyDescriptions,
|
||||
routerFieldsMetadata,
|
||||
onStrategyChange,
|
||||
ownedByConfig = false,
|
||||
}) => {
|
||||
return (
|
||||
<div className="space-y-2 max-w-3xl">
|
||||
|
|
@ -22,6 +25,7 @@ const RoutingStrategySelector: React.FC<RoutingStrategySelectorProps> = ({
|
|||
<label className="text-xs font-medium text-foreground uppercase tracking-wide">
|
||||
{routerFieldsMetadata["routing_strategy"]?.ui_field_name || "Routing Strategy"}
|
||||
</label>
|
||||
{ownedByConfig && <ConfigOwnedBadge />}
|
||||
<p className="text-xs text-muted-foreground mt-0.5 mb-2">
|
||||
{routerFieldsMetadata["routing_strategy"]?.field_description || ""}
|
||||
</p>
|
||||
|
|
@ -30,8 +34,9 @@ const RoutingStrategySelector: React.FC<RoutingStrategySelectorProps> = ({
|
|||
<Select
|
||||
value={selectedStrategy}
|
||||
onValueChange={(strategy: string | null) => strategy && onStrategyChange(strategy)}
|
||||
disabled={ownedByConfig}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectTrigger className="w-full" aria-label={ownedByConfig ? CONFIG_OWNED_HINT : undefined}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
|
|||
|
|
@ -1,13 +1,20 @@
|
|||
import React, { useId } from "react";
|
||||
import { CONFIG_OWNED_HINT, ConfigOwnedBadge } from "./ConfigOwned";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
|
||||
interface TagFilteringToggleProps {
|
||||
enabled: boolean;
|
||||
routerFieldsMetadata: { [key: string]: any };
|
||||
onToggle: (enabled: boolean) => void;
|
||||
ownedByConfig?: boolean;
|
||||
}
|
||||
|
||||
const TagFilteringToggle: React.FC<TagFilteringToggleProps> = ({ enabled, routerFieldsMetadata, onToggle }) => {
|
||||
const TagFilteringToggle: React.FC<TagFilteringToggleProps> = ({
|
||||
enabled,
|
||||
routerFieldsMetadata,
|
||||
onToggle,
|
||||
ownedByConfig = false,
|
||||
}) => {
|
||||
const toggleId = useId();
|
||||
|
||||
return (
|
||||
|
|
@ -17,6 +24,7 @@ const TagFilteringToggle: React.FC<TagFilteringToggleProps> = ({ enabled, router
|
|||
<label htmlFor={toggleId} className="text-xs font-medium text-foreground uppercase tracking-wide">
|
||||
{routerFieldsMetadata["enable_tag_filtering"]?.ui_field_name || "Enable Tag Filtering"}
|
||||
</label>
|
||||
{ownedByConfig && <ConfigOwnedBadge />}
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{routerFieldsMetadata["enable_tag_filtering"]?.field_description || ""}
|
||||
{routerFieldsMetadata["enable_tag_filtering"]?.link && (
|
||||
|
|
@ -34,7 +42,14 @@ const TagFilteringToggle: React.FC<TagFilteringToggleProps> = ({ enabled, router
|
|||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Switch id={toggleId} checked={enabled} onCheckedChange={onToggle} className="ml-4" />
|
||||
<Switch
|
||||
id={toggleId}
|
||||
checked={enabled}
|
||||
onCheckedChange={onToggle}
|
||||
disabled={ownedByConfig}
|
||||
title={ownedByConfig ? CONFIG_OWNED_HINT : undefined}
|
||||
className="ml-4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -137,6 +137,41 @@ describe("RouterSettings", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("keeps Save Changes disabled when the router settings source cannot be loaded", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(getRouterSettingsCall).mockRejectedValue(new Error("500 Internal Server Error"));
|
||||
renderWithProviders(<RouterSettings {...defaultProps} />);
|
||||
|
||||
const save = await screen.findByRole("button", { name: /save changes/i });
|
||||
expect(save).toBeDisabled();
|
||||
expect(save).toHaveAttribute("title", expect.stringContaining("config.yaml"));
|
||||
await user.click(save);
|
||||
expect(setCallbacksCall).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should leave config.yaml owned keys out of the save payload and still send edited ones", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(getRouterSettingsCall).mockResolvedValue({
|
||||
...mockRouterSettingsResponse,
|
||||
source: { num_retries: "config", timeout: "db" },
|
||||
});
|
||||
renderWithProviders(<RouterSettings {...defaultProps} />);
|
||||
|
||||
await findStrategySelect();
|
||||
|
||||
expect(await screen.findByRole("textbox", { name: /num_retries/i })).toBeDisabled();
|
||||
const timeout = screen.getByRole("textbox", { name: /timeout/i });
|
||||
await user.clear(timeout);
|
||||
fireEvent.change(timeout, { target: { value: "42" } });
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /save changes/i }));
|
||||
|
||||
await waitFor(() => expect(setCallbacksCall).toHaveBeenCalledTimes(1));
|
||||
const [, payload] = vi.mocked(setCallbacksCall).mock.calls[0];
|
||||
expect(payload.router_settings).toEqual(expect.objectContaining({ timeout: 42 }));
|
||||
expect(payload.router_settings).not.toHaveProperty("num_retries");
|
||||
});
|
||||
|
||||
it("should show a success notification after saving", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<RouterSettings {...defaultProps} />);
|
||||
|
|
@ -3,6 +3,7 @@ import { Button } from "@/components/ui/button";
|
|||
import { toast } from "@/lib/toast";
|
||||
import { getCallbacksCall, getRouterSettingsCall, setCallbacksCall } from "../networking";
|
||||
import RouterSettingsForm, { RouterSettingsFormValue } from "./RouterSettingsForm";
|
||||
import { RouterSettingsSource, SOURCE_UNKNOWN_HINT, isOwnedByConfig } from "./ConfigOwned";
|
||||
|
||||
interface RouterSettingsProps {
|
||||
accessToken: string | null;
|
||||
|
|
@ -24,6 +25,7 @@ const RouterSettings: React.FC<RouterSettingsProps> = ({ accessToken, userRole,
|
|||
const [availableRoutingStrategies, setAvailableRoutingStrategies] = useState<string[]>([]);
|
||||
const [routerFieldsMetadata, setRouterFieldsMetadata] = useState<{ [key: string]: any }>({});
|
||||
const [routingStrategyDescriptions, setRoutingStrategyDescriptions] = useState<{ [key: string]: string }>({});
|
||||
const [routerSettingsSource, setRouterSettingsSource] = useState<RouterSettingsSource | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!accessToken || !userRole || !userID) {
|
||||
|
|
@ -42,45 +44,54 @@ const RouterSettings: React.FC<RouterSettingsProps> = ({ accessToken, userRole,
|
|||
selectedStrategy: initialStrategy,
|
||||
}));
|
||||
});
|
||||
getRouterSettingsCall(accessToken).then((data) => {
|
||||
if (data.fields) {
|
||||
// Build metadata map for easy lookup
|
||||
const fieldsMap: { [key: string]: any } = {};
|
||||
data.fields.forEach((field: any) => {
|
||||
fieldsMap[field.field_name] = {
|
||||
ui_field_name: field.ui_field_name,
|
||||
field_description: field.field_description,
|
||||
options: field.options,
|
||||
link: field.link,
|
||||
};
|
||||
});
|
||||
setRouterFieldsMetadata(fieldsMap);
|
||||
|
||||
// Extract routing strategies from the routing_strategy field's options
|
||||
const routingStrategyField = data.fields.find((field: any) => field.field_name === "routing_strategy");
|
||||
if (routingStrategyField?.options) {
|
||||
setAvailableRoutingStrategies(routingStrategyField.options);
|
||||
getRouterSettingsCall(accessToken)
|
||||
.catch(() => {
|
||||
setRouterSettingsSource(null);
|
||||
return null;
|
||||
})
|
||||
.then((data) => {
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
setRouterSettingsSource(data.source ?? {});
|
||||
if (data.fields) {
|
||||
// Build metadata map for easy lookup
|
||||
const fieldsMap: { [key: string]: any } = {};
|
||||
data.fields.forEach((field: any) => {
|
||||
fieldsMap[field.field_name] = {
|
||||
ui_field_name: field.ui_field_name,
|
||||
field_description: field.field_description,
|
||||
options: field.options,
|
||||
link: field.link,
|
||||
};
|
||||
});
|
||||
setRouterFieldsMetadata(fieldsMap);
|
||||
|
||||
// Store routing strategy descriptions
|
||||
if (data.routing_strategy_descriptions) {
|
||||
setRoutingStrategyDescriptions(data.routing_strategy_descriptions);
|
||||
}
|
||||
// Extract routing strategies from the routing_strategy field's options
|
||||
const routingStrategyField = data.fields.find((field: any) => field.field_name === "routing_strategy");
|
||||
if (routingStrategyField?.options) {
|
||||
setAvailableRoutingStrategies(routingStrategyField.options);
|
||||
}
|
||||
|
||||
// Set enable_tag_filtering value
|
||||
const tagFilteringField = data.fields.find((field: any) => field.field_name === "enable_tag_filtering");
|
||||
if (tagFilteringField?.field_value !== null && tagFilteringField?.field_value !== undefined) {
|
||||
setFormValue((prev) => ({
|
||||
...prev,
|
||||
enableTagFiltering: tagFilteringField.field_value,
|
||||
}));
|
||||
// Store routing strategy descriptions
|
||||
if (data.routing_strategy_descriptions) {
|
||||
setRoutingStrategyDescriptions(data.routing_strategy_descriptions);
|
||||
}
|
||||
|
||||
// Set enable_tag_filtering value
|
||||
const tagFilteringField = data.fields.find((field: any) => field.field_name === "enable_tag_filtering");
|
||||
if (tagFilteringField?.field_value !== null && tagFilteringField?.field_value !== undefined) {
|
||||
setFormValue((prev) => ({
|
||||
...prev,
|
||||
enableTagFiltering: tagFilteringField.field_value,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}, [accessToken, userRole, userID]);
|
||||
|
||||
const handleSaveChanges = async () => {
|
||||
if (!accessToken) {
|
||||
if (!accessToken || routerSettingsSource === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -128,7 +139,7 @@ const RouterSettings: React.FC<RouterSettingsProps> = ({ accessToken, userRole,
|
|||
const updatedVariables = Object.fromEntries(
|
||||
Object.entries(settingsToUpdate)
|
||||
.map(([key, value]) => {
|
||||
if (tabOwnedKeys.has(key)) {
|
||||
if (tabOwnedKeys.has(key) || isOwnedByConfig(routerSettingsSource, key)) {
|
||||
return null;
|
||||
}
|
||||
if (key !== "routing_strategy_args" && key !== "routing_strategy" && key !== "enable_tag_filtering") {
|
||||
|
|
@ -186,6 +197,7 @@ const RouterSettings: React.FC<RouterSettingsProps> = ({ accessToken, userRole,
|
|||
routerFieldsMetadata={routerFieldsMetadata}
|
||||
availableRoutingStrategies={availableRoutingStrategies}
|
||||
routingStrategyDescriptions={routingStrategyDescriptions}
|
||||
routerSettingsSource={routerSettingsSource ?? {}}
|
||||
/>
|
||||
|
||||
{/* Actions - Sticky at bottom */}
|
||||
|
|
@ -193,7 +205,13 @@ const RouterSettings: React.FC<RouterSettingsProps> = ({ accessToken, userRole,
|
|||
<Button variant="outline" onClick={() => window.location.reload()}>
|
||||
Reset
|
||||
</Button>
|
||||
<Button onClick={handleSaveChanges}>Save Changes</Button>
|
||||
<Button
|
||||
onClick={handleSaveChanges}
|
||||
disabled={routerSettingsSource === null}
|
||||
title={routerSettingsSource === null ? SOURCE_UNKNOWN_HINT : undefined}
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue