This commit is contained in:
Yassin Kortam 2026-10-11 16:18:58 +00:00 • committed by GitHub
commit 3b08d66e65
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 328 additions and 62 deletions

View file

@ -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}

View file

@ -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} />);

View file

@ -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>
</>
)}

View file

@ -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>
);
}

View file

@ -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>

View file

@ -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>

View file

@ -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();

View file

@ -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>
);
};

View file

@ -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>

View file

@ -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>
);

View file

@ -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} />);

View file

@ -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>
);