mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-28 01:32:17 +00:00
feat(ui): freeze config.yaml owned settings in the dashboard
Router, fallback, alerting and UI settings controls now read the source map returned by GET /router/settings, GET /alerting/settings and GET /get/ui_settings and render a control as disabled with a tooltip when its source is config. Every other source stays editable Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
094a60bb9c
commit
8d6c6867e6
18 changed files with 475 additions and 88 deletions
|
|
@ -4,6 +4,7 @@ import { ChevronDown } from "lucide-react";
|
|||
import { useMemo, useState } from "react";
|
||||
|
||||
import { getAvailablePages } from "@/components/page_utils";
|
||||
import { ConfigOwnedField } from "@/components/shared/ConfigOwnedField";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
|
|
@ -13,6 +14,7 @@ interface PageVisibilitySettingsProps {
|
|||
enabledPagesInternalUsers: string[] | null | undefined;
|
||||
enabledPagesPropertyDescription?: string;
|
||||
isUpdating: boolean;
|
||||
configOwned?: boolean;
|
||||
onUpdate: (settings: { enabled_ui_pages_internal_users: string[] | null }) => void;
|
||||
}
|
||||
|
||||
|
|
@ -20,6 +22,7 @@ export default function PageVisibilitySettings({
|
|||
enabledPagesInternalUsers,
|
||||
enabledPagesPropertyDescription,
|
||||
isUpdating,
|
||||
configOwned = false,
|
||||
onUpdate,
|
||||
}: PageVisibilitySettingsProps) {
|
||||
const isPageVisibilitySet = enabledPagesInternalUsers !== null && enabledPagesInternalUsers !== undefined;
|
||||
|
|
@ -93,11 +96,14 @@ export default function PageVisibilitySettings({
|
|||
const checkboxId = `page-visibility-${page.page}`;
|
||||
return (
|
||||
<label key={page.page} htmlFor={checkboxId} className="flex cursor-pointer items-start gap-2">
|
||||
<Checkbox
|
||||
id={checkboxId}
|
||||
checked={selectedPages.includes(page.page)}
|
||||
onCheckedChange={(checked) => togglePage(page.page, checked === true)}
|
||||
/>
|
||||
<ConfigOwnedField frozen={configOwned} className="inline-flex">
|
||||
<Checkbox
|
||||
id={checkboxId}
|
||||
checked={selectedPages.includes(page.page)}
|
||||
disabled={configOwned}
|
||||
onCheckedChange={(checked) => togglePage(page.page, checked === true)}
|
||||
/>
|
||||
</ConfigOwnedField>
|
||||
<span className="space-y-0.5">
|
||||
<span className="block text-sm text-foreground">{page.label}</span>
|
||||
<span className="block text-xs text-muted-foreground">{page.description}</span>
|
||||
|
|
@ -110,13 +116,22 @@ export default function PageVisibilitySettings({
|
|||
))}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" onClick={handleSavePageVisibility} disabled={isUpdating}>
|
||||
Save Page Visibility Settings
|
||||
</Button>
|
||||
{isPageVisibilitySet && (
|
||||
<Button type="button" variant="outline" onClick={handleResetToDefault} disabled={isUpdating}>
|
||||
Reset to Default (All Pages)
|
||||
<ConfigOwnedField frozen={configOwned} className="inline-flex">
|
||||
<Button type="button" onClick={handleSavePageVisibility} disabled={isUpdating || configOwned}>
|
||||
Save Page Visibility Settings
|
||||
</Button>
|
||||
</ConfigOwnedField>
|
||||
{isPageVisibilitySet && (
|
||||
<ConfigOwnedField frozen={configOwned} className="inline-flex">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleResetToDefault}
|
||||
disabled={isUpdating || configOwned}
|
||||
>
|
||||
Reset to Default (All Pages)
|
||||
</Button>
|
||||
</ConfigOwnedField>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
parseTeamAdminEditableFields,
|
||||
teamAdminFieldLabel,
|
||||
} from "@/components/team/teamAdminEditAccess";
|
||||
import { ConfigOwnedField, isConfigOwned } from "@/components/shared/ConfigOwnedField";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
|
@ -30,6 +31,7 @@ export default function TeamAdminEditableFieldsSettings() {
|
|||
const supportedFields = parseSupportedTeamAdminEditableFields(data?.field_schema);
|
||||
const savedFields = parseTeamAdminEditableFields(data?.values);
|
||||
const enabledFields = supportedFields.filter((field) => savedFields.includes(field));
|
||||
const configOwned = isConfigOwned(data?.source, "team_admin_editable_team_fields");
|
||||
|
||||
return (
|
||||
<Card>
|
||||
|
|
@ -56,6 +58,7 @@ export default function TeamAdminEditableFieldsSettings() {
|
|||
enabledFields={enabledFields}
|
||||
supportedFields={supportedFields}
|
||||
isPending={isPending}
|
||||
configOwned={configOwned}
|
||||
saveSettings={saveSettings}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -68,6 +71,7 @@ interface TeamAdminEditableFieldsFormProps {
|
|||
enabledFields: readonly string[];
|
||||
supportedFields: readonly string[];
|
||||
isPending: boolean;
|
||||
configOwned: boolean;
|
||||
saveSettings: SaveEditableFields;
|
||||
}
|
||||
|
||||
|
|
@ -75,6 +79,7 @@ function TeamAdminEditableFieldsForm({
|
|||
enabledFields,
|
||||
supportedFields,
|
||||
isPending,
|
||||
configOwned,
|
||||
saveSettings,
|
||||
}: TeamAdminEditableFieldsFormProps) {
|
||||
const form = useZodForm(editableFieldsSchema, {
|
||||
|
|
@ -111,16 +116,18 @@ function TeamAdminEditableFieldsForm({
|
|||
const checkboxId = `team-admin-editable-${name}`;
|
||||
return (
|
||||
<label key={name} htmlFor={checkboxId} className="flex cursor-pointer items-center gap-2">
|
||||
<Checkbox
|
||||
id={checkboxId}
|
||||
checked={field.value.includes(name)}
|
||||
disabled={isPending}
|
||||
onCheckedChange={(checked) =>
|
||||
field.onChange(
|
||||
supportedFields.filter((item) => (item === name ? checked : field.value.includes(item))),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<ConfigOwnedField frozen={configOwned} className="inline-flex">
|
||||
<Checkbox
|
||||
id={checkboxId}
|
||||
checked={field.value.includes(name)}
|
||||
disabled={isPending || configOwned}
|
||||
onCheckedChange={(checked) =>
|
||||
field.onChange(
|
||||
supportedFields.filter((item) => (item === name ? checked : field.value.includes(item))),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</ConfigOwnedField>
|
||||
<span className="text-sm text-foreground">{teamAdminFieldLabel(name)}</span>
|
||||
</label>
|
||||
);
|
||||
|
|
@ -129,9 +136,11 @@ function TeamAdminEditableFieldsForm({
|
|||
)}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={isPending || !form.formState.isDirty}>
|
||||
{isPending ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
<ConfigOwnedField frozen={configOwned} className="inline-flex">
|
||||
<Button type="submit" disabled={isPending || configOwned || !form.formState.isDirty}>
|
||||
{isPending ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</ConfigOwnedField>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ vi.mock("@/app/(dashboard)/hooks/uiSettings/useUpdateUISettings", () => ({
|
|||
useUpdateUISettings: mockUseUpdateUISettings,
|
||||
}));
|
||||
|
||||
const buildSettingsResponse = (overrides?: Partial<Record<string, unknown>>) => ({
|
||||
const buildSettingsResponse = (overrides?: Partial<Record<string, unknown>>, source: Record<string, string> = {}) => ({
|
||||
data: {
|
||||
field_schema: {
|
||||
description: "UI settings description",
|
||||
|
|
@ -41,6 +41,7 @@ const buildSettingsResponse = (overrides?: Partial<Record<string, unknown>>) =>
|
|||
disable_team_admin_delete_team_user: false,
|
||||
require_auth_for_public_ai_hub: false,
|
||||
},
|
||||
source,
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
|
|
@ -155,4 +156,65 @@ describe("UISettings", () => {
|
|||
);
|
||||
expect(toast.success).toHaveBeenCalledWith("UI settings updated successfully");
|
||||
});
|
||||
|
||||
describe("config.yaml owned settings", () => {
|
||||
it("freezes a switch whose source is config and does not call update", () => {
|
||||
const mutateMock = vi.fn();
|
||||
mockUseUpdateUISettings.mockReturnValue({ mutate: mutateMock, isPending: false, error: null });
|
||||
mockUseUISettings.mockReturnValue(
|
||||
buildSettingsResponse(undefined, {
|
||||
disable_model_add_for_internal_users: "config",
|
||||
disable_team_admin_delete_team_user: "db",
|
||||
}),
|
||||
);
|
||||
|
||||
render(<UISettings />);
|
||||
|
||||
const frozen = screen.getByRole("switch", { name: "Disable model add for internal users" });
|
||||
expect(frozen).toHaveAttribute("data-disabled");
|
||||
expect(frozen).toHaveAttribute("aria-disabled", "true");
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(frozen);
|
||||
});
|
||||
|
||||
expect(mutateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["env", "default", "db", "unset"])("keeps a switch editable when its source is %s", (source) => {
|
||||
const mutateMock = vi.fn();
|
||||
mockUseUpdateUISettings.mockReturnValue({ mutate: mutateMock, isPending: false, error: null });
|
||||
mockUseUISettings.mockReturnValue(
|
||||
buildSettingsResponse(undefined, { disable_model_add_for_internal_users: source }),
|
||||
);
|
||||
|
||||
render(<UISettings />);
|
||||
|
||||
const toggle = screen.getByRole("switch", { name: "Disable model add for internal users" });
|
||||
expect(toggle).not.toHaveAttribute("data-disabled");
|
||||
expect(toggle).not.toHaveAttribute("aria-disabled", "true");
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(toggle);
|
||||
});
|
||||
|
||||
expect(mutateMock).toHaveBeenCalledWith({ disable_model_add_for_internal_users: true }, expect.anything());
|
||||
});
|
||||
|
||||
it("freezes only the config owned switch and leaves siblings editable", () => {
|
||||
mockUseUISettings.mockReturnValue(buildSettingsResponse(undefined, { require_auth_for_public_ai_hub: "config" }));
|
||||
|
||||
render(<UISettings />);
|
||||
|
||||
expect(screen.getByRole("switch", { name: "Require authentication for public AI Hub" })).toHaveAttribute(
|
||||
"data-disabled",
|
||||
);
|
||||
expect(screen.getByRole("switch", { name: "Disable model add for internal users" })).not.toHaveAttribute(
|
||||
"data-disabled",
|
||||
);
|
||||
expect(screen.getByRole("switch", { name: "Disable team admin delete team user" })).not.toHaveAttribute(
|
||||
"data-disabled",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { useUpdateUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUpdat
|
|||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert";
|
||||
import { ConfigOwnedField, isConfigOwned } from "@/components/shared/ConfigOwnedField";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
|
@ -14,6 +15,7 @@ import PageVisibilitySettings from "./PageVisibilitySettings";
|
|||
interface SettingRowProps {
|
||||
ariaLabel: string;
|
||||
checked: boolean;
|
||||
configOwned?: boolean;
|
||||
description?: string;
|
||||
disabled: boolean;
|
||||
indented?: boolean;
|
||||
|
|
@ -25,6 +27,7 @@ interface SettingRowProps {
|
|||
function SettingRow({
|
||||
ariaLabel,
|
||||
checked,
|
||||
configOwned = false,
|
||||
description,
|
||||
disabled,
|
||||
indented = false,
|
||||
|
|
@ -34,7 +37,14 @@ function SettingRow({
|
|||
}: SettingRowProps) {
|
||||
return (
|
||||
<div className={indented ? "ml-8 flex items-start gap-3" : "flex items-start gap-3"}>
|
||||
<Switch checked={checked} disabled={disabled} onCheckedChange={onCheckedChange} aria-label={ariaLabel} />
|
||||
<ConfigOwnedField frozen={configOwned} className="inline-flex">
|
||||
<Switch
|
||||
checked={checked}
|
||||
disabled={disabled || configOwned}
|
||||
onCheckedChange={onCheckedChange}
|
||||
aria-label={ariaLabel}
|
||||
/>
|
||||
</ConfigOwnedField>
|
||||
<div className="space-y-1">
|
||||
<p className={muted ? "text-sm font-medium text-muted-foreground" : "text-sm font-medium text-foreground"}>
|
||||
{label}
|
||||
|
|
@ -66,6 +76,8 @@ export default function UISettings() {
|
|||
const scopeUserSearchProperty = schema?.properties?.scope_user_search_to_org;
|
||||
const disableCustomApiKeysProperty = schema?.properties?.disable_custom_api_keys;
|
||||
const values = data?.values ?? {};
|
||||
const sources = data?.source ?? {};
|
||||
const frozen = (key: string) => isConfigOwned(sources, key);
|
||||
const isDisabledForInternalUsers = Boolean(values.disable_model_add_for_internal_users);
|
||||
const isDisabledTeamAdminDeleteTeamUser = Boolean(values.disable_team_admin_delete_team_user);
|
||||
const isAgentsDisabled = Boolean(values.disable_agents_for_internal_users);
|
||||
|
|
@ -299,6 +311,7 @@ export default function UISettings() {
|
|||
checked={isDisabledForInternalUsers}
|
||||
disabled={isUpdating}
|
||||
onCheckedChange={handleToggle}
|
||||
configOwned={frozen("disable_model_add_for_internal_users")}
|
||||
ariaLabel={property?.description ?? "Disable model add for internal users"}
|
||||
label="Disable model add for internal users"
|
||||
description={property?.description}
|
||||
|
|
@ -307,6 +320,7 @@ export default function UISettings() {
|
|||
checked={isDisabledTeamAdminDeleteTeamUser}
|
||||
disabled={isUpdating}
|
||||
onCheckedChange={handleToggleTeamAdminDelete}
|
||||
configOwned={frozen("disable_team_admin_delete_team_user")}
|
||||
ariaLabel={disableTeamAdminDeleteProperty?.description ?? "Disable team admin delete team user"}
|
||||
label="Disable team admin delete team user"
|
||||
description={disableTeamAdminDeleteProperty?.description}
|
||||
|
|
@ -315,6 +329,7 @@ export default function UISettings() {
|
|||
checked={Boolean(values.require_auth_for_public_ai_hub)}
|
||||
disabled={isUpdating}
|
||||
onCheckedChange={handleToggleRequireAuthForPublicAIHub}
|
||||
configOwned={frozen("require_auth_for_public_ai_hub")}
|
||||
ariaLabel={requireAuthForPublicAIHubProperty?.description ?? "Require authentication for public AI Hub"}
|
||||
label="Require authentication for public AI Hub"
|
||||
description={requireAuthForPublicAIHubProperty?.description}
|
||||
|
|
@ -323,6 +338,7 @@ export default function UISettings() {
|
|||
checked={Boolean(values.forward_client_headers_to_llm_api)}
|
||||
disabled={isUpdating}
|
||||
onCheckedChange={handleToggleForwardClientHeaders}
|
||||
configOwned={frozen("forward_client_headers_to_llm_api")}
|
||||
ariaLabel={forwardClientHeadersProperty?.description ?? "Forward client headers to LLM API"}
|
||||
label="Forward client headers to LLM API"
|
||||
description={
|
||||
|
|
@ -334,6 +350,7 @@ export default function UISettings() {
|
|||
checked={Boolean(values.forward_llm_provider_auth_headers)}
|
||||
disabled={isUpdating}
|
||||
onCheckedChange={handleToggleForwardLLMProviderAuthHeaders}
|
||||
configOwned={frozen("forward_llm_provider_auth_headers")}
|
||||
ariaLabel={forwardLLMProviderAuthHeadersProperty?.description ?? "Forward LLM provider auth headers"}
|
||||
label="Forward LLM provider auth headers"
|
||||
description={
|
||||
|
|
@ -346,6 +363,7 @@ export default function UISettings() {
|
|||
checked={Boolean(values.enable_projects_ui)}
|
||||
disabled={isUpdating}
|
||||
onCheckedChange={handleToggleEnableProjectsUI}
|
||||
configOwned={frozen("enable_projects_ui")}
|
||||
ariaLabel={enableProjectsUIProperty.description ?? "Enable Projects UI"}
|
||||
label="[BETA] Enable Projects (page will refresh)"
|
||||
description={
|
||||
|
|
@ -358,6 +376,7 @@ export default function UISettings() {
|
|||
checked={Boolean(values.enable_chat_ui)}
|
||||
disabled={isUpdating}
|
||||
onCheckedChange={handleToggleEnableChatUI}
|
||||
configOwned={frozen("enable_chat_ui")}
|
||||
ariaLabel={enableChatUIProperty?.description ?? "Enable Chat page"}
|
||||
label="[BETA] Enable Chat page (page will refresh)"
|
||||
description={
|
||||
|
|
@ -371,6 +390,7 @@ export default function UISettings() {
|
|||
checked={isAgentsDisabled}
|
||||
disabled={isUpdating}
|
||||
onCheckedChange={handleToggleDisableAgents}
|
||||
configOwned={frozen("disable_agents_for_internal_users")}
|
||||
ariaLabel={disableAgentsProperty?.description ?? "Disable agents for internal users"}
|
||||
label="Disable agents for internal users"
|
||||
description={disableAgentsProperty?.description}
|
||||
|
|
@ -379,6 +399,7 @@ export default function UISettings() {
|
|||
checked={Boolean(values.allow_agents_for_team_admins)}
|
||||
disabled={isUpdating || !isAgentsDisabled}
|
||||
onCheckedChange={handleToggleAllowAgentsTeamAdmins}
|
||||
configOwned={frozen("allow_agents_for_team_admins")}
|
||||
ariaLabel={allowAgentsTeamAdminsProperty?.description ?? "Allow agents for team admins"}
|
||||
label="Allow agents for team admins"
|
||||
description={allowAgentsTeamAdminsProperty?.description}
|
||||
|
|
@ -391,6 +412,7 @@ export default function UISettings() {
|
|||
checked={isVectorStoresDisabled}
|
||||
disabled={isUpdating}
|
||||
onCheckedChange={handleToggleDisableVectorStores}
|
||||
configOwned={frozen("disable_vector_stores_for_internal_users")}
|
||||
ariaLabel={disableVectorStoresProperty?.description ?? "Disable vector stores for internal users"}
|
||||
label="Disable vector stores for internal users"
|
||||
description={disableVectorStoresProperty?.description}
|
||||
|
|
@ -399,6 +421,7 @@ export default function UISettings() {
|
|||
checked={Boolean(values.allow_vector_stores_for_team_admins)}
|
||||
disabled={isUpdating || !isVectorStoresDisabled}
|
||||
onCheckedChange={handleToggleAllowVectorStoresTeamAdmins}
|
||||
configOwned={frozen("allow_vector_stores_for_team_admins")}
|
||||
ariaLabel={allowVectorStoresTeamAdminsProperty?.description ?? "Allow vector stores for team admins"}
|
||||
label="Allow vector stores for team admins"
|
||||
description={allowVectorStoresTeamAdminsProperty?.description}
|
||||
|
|
@ -411,6 +434,7 @@ export default function UISettings() {
|
|||
checked={Boolean(values.scope_user_search_to_org)}
|
||||
disabled={isUpdating}
|
||||
onCheckedChange={handleToggleScopeUserSearch}
|
||||
configOwned={frozen("scope_user_search_to_org")}
|
||||
ariaLabel={scopeUserSearchProperty?.description ?? "Scope user search to organization"}
|
||||
label="Scope user search to organization"
|
||||
description={
|
||||
|
|
@ -424,6 +448,7 @@ export default function UISettings() {
|
|||
checked={Boolean(values.disable_custom_api_keys)}
|
||||
disabled={isUpdating}
|
||||
onCheckedChange={handleToggleDisableCustomApiKeys}
|
||||
configOwned={frozen("disable_custom_api_keys")}
|
||||
ariaLabel={disableCustomApiKeysProperty?.description ?? "Disable custom Virtual key values"}
|
||||
label="Disable custom Virtual key values"
|
||||
description={
|
||||
|
|
@ -437,6 +462,7 @@ export default function UISettings() {
|
|||
enabledPagesInternalUsers={values.enabled_ui_pages_internal_users}
|
||||
enabledPagesPropertyDescription={enabledPagesProperty?.description}
|
||||
isUpdating={isUpdating}
|
||||
configOwned={frozen("enabled_ui_pages_internal_users")}
|
||||
onUpdate={handleUpdatePageVisibility}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
*/
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { ConfigOwnedField } from "@/components/shared/ConfigOwnedField";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
|
@ -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
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function AddFallbacks({ accessToken, value = [], onChange }: AddFallbacksProps) {
|
||||
export default function AddFallbacks({ accessToken, value = [], onChange, disabled = false }: 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,12 @@ export default function AddFallbacks({ accessToken, value = [], onChange }: AddF
|
|||
|
||||
return (
|
||||
<div>
|
||||
<Button className="mx-auto" onClick={() => setIsModalVisible(true)}>
|
||||
<span>+</span>
|
||||
Add Fallbacks
|
||||
</Button>
|
||||
<ConfigOwnedField frozen={disabled} className="inline-flex">
|
||||
<Button className="mx-auto" disabled={disabled} onClick={() => setIsModalVisible(true)}>
|
||||
<span>+</span>
|
||||
Add Fallbacks
|
||||
</Button>
|
||||
</ConfigOwnedField>
|
||||
<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, disabled }: 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} disabled={disabled} data-testid="add-fallbacks-button">
|
||||
Add Fallbacks
|
||||
</button>
|
||||
);
|
||||
|
|
@ -107,6 +108,7 @@ describe("Fallbacks", () => {
|
|||
vi.mocked(networkingModule.getCallbacksCall).mockResolvedValue({
|
||||
router_settings: mockRouterSettings,
|
||||
});
|
||||
vi.mocked(networkingModule.getRouterSettingsCall).mockResolvedValue({ fields: [], source: {} });
|
||||
vi.mocked(networkingModule.setCallbacksCall).mockResolvedValue(undefined);
|
||||
vi.mocked(fetchModelsModule.fetchAvailableModels).mockResolvedValue([
|
||||
{ model_group: "gpt-4", mode: "chat" },
|
||||
|
|
@ -389,4 +391,53 @@ describe("Fallbacks", () => {
|
|||
{ timeout: 3000 },
|
||||
);
|
||||
});
|
||||
|
||||
describe("config.yaml owned fallbacks", () => {
|
||||
it("freezes add, edit and delete when fallbacks source is config", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(networkingModule.getRouterSettingsCall).mockResolvedValue({
|
||||
fields: [],
|
||||
source: { fallbacks: "config" },
|
||||
});
|
||||
renderWithQueryClient(<Fallbacks {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("add-fallbacks-button")).toBeDisabled();
|
||||
});
|
||||
|
||||
const editButton = screen.getAllByTestId("edit-fallback-button")[0];
|
||||
const deleteButton = getFirstRowDeleteButton();
|
||||
expect(editButton).toHaveAttribute("aria-disabled", "true");
|
||||
expect(deleteButton).toHaveAttribute("aria-disabled", "true");
|
||||
|
||||
await user.click(editButton);
|
||||
await user.click(deleteButton!);
|
||||
|
||||
expect(screen.queryByText("Configure Model Fallbacks")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("delete-modal")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each(["env", "default", "db"])("keeps fallbacks editable when source is %s", async (source) => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(networkingModule.getRouterSettingsCall).mockResolvedValue({
|
||||
fields: [],
|
||||
source: { fallbacks: source },
|
||||
});
|
||||
renderWithQueryClient(<Fallbacks {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText("gpt-4").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("add-fallbacks-button")).toBeEnabled();
|
||||
const editButton = screen.getAllByTestId("edit-fallback-button")[0];
|
||||
expect(editButton).toHaveAttribute("aria-disabled", "false");
|
||||
|
||||
await user.click(editButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Configure Model Fallbacks")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@ 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_MESSAGE, type FieldSourceMap, isConfigOwned } from "@/components/shared/ConfigOwnedField";
|
||||
import { isProxyAdminRole } from "@/utils/roles";
|
||||
import AddFallbacks from "./AddFallbacks";
|
||||
import EditFallbacks from "./EditFallbacks";
|
||||
|
|
@ -121,6 +122,7 @@ async function testFallbackModelResponse(selectedModel: string, accessToken: str
|
|||
|
||||
const Fallbacks: React.FC<FallbacksProps> = ({ accessToken, userRole, userID }) => {
|
||||
const [routerSettings, setRouterSettings] = useState<{ [key: string]: any }>({});
|
||||
const [routerSources, setRouterSources] = useState<FieldSourceMap>({});
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [fallbackToDelete, setFallbackToDelete] = useState<FallbackEntry | null>(null);
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
|
|
@ -145,6 +147,9 @@ const Fallbacks: React.FC<FallbacksProps> = ({ accessToken, userRole, userID })
|
|||
}
|
||||
setRouterSettings(router_settings);
|
||||
});
|
||||
getRouterSettingsCall(accessToken).then((data) => {
|
||||
setRouterSources(data.source ?? {});
|
||||
});
|
||||
}, [accessToken, userRole, userID]);
|
||||
|
||||
const handleDeleteClick = (fallbackEntry: FallbackEntry) => {
|
||||
|
|
@ -250,6 +255,8 @@ 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 fallbacksFrozen = isConfigOwned(routerSources, "fallbacks");
|
||||
const frozenActionClass = `${iconWrapperClass} cursor-not-allowed opacity-50`;
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
|
|
@ -258,6 +265,7 @@ const Fallbacks: React.FC<FallbacksProps> = ({ accessToken, userRole, userID })
|
|||
accessToken={accessToken || ""}
|
||||
value={routerSettings.fallbacks || []}
|
||||
onChange={handleFallbacksChange}
|
||||
disabled={fallbacksFrozen}
|
||||
/>
|
||||
)}
|
||||
{!hasFallbacks ? (
|
||||
|
|
@ -309,15 +317,20 @@ 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={fallbacksFrozen}
|
||||
onClick={() => !fallbacksFrozen && handleEditClick(item)}
|
||||
onKeyDown={(e) => e.key === "Enter" && !fallbacksFrozen && handleEditClick(item)}
|
||||
className={
|
||||
fallbacksFrozen
|
||||
? frozenActionClass
|
||||
: `${iconWrapperClass} cursor-pointer hover:text-info`
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Pencil className="h-5 w-5 shrink-0" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Edit fallback</TooltipContent>
|
||||
<TooltipContent>{fallbacksFrozen ? CONFIG_OWNED_MESSAGE : "Edit fallback"}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
|
|
@ -326,15 +339,20 @@ 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={fallbacksFrozen}
|
||||
onClick={() => !fallbacksFrozen && handleDeleteClick(item)}
|
||||
onKeyDown={(e) => e.key === "Enter" && !fallbacksFrozen && handleDeleteClick(item)}
|
||||
className={
|
||||
fallbacksFrozen
|
||||
? frozenActionClass
|
||||
: `${iconWrapperClass} cursor-pointer hover:text-destructive`
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-5 w-5 shrink-0" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Delete fallback</TooltipContent>
|
||||
<TooltipContent>{fallbacksFrozen ? CONFIG_OWNED_MESSAGE : "Delete fallback"}</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { alertingSettingsCall, updateConfigFieldSetting } from "../networking";
|
|||
import DynamicForm from "./dynamic_form";
|
||||
import { extractProxyErrorMessage } from "@/lib/http/client";
|
||||
import { toast } from "@/lib/toast";
|
||||
import type { FieldSource } from "@/components/shared/ConfigOwnedField";
|
||||
interface alertingSettingsItem {
|
||||
field_name: string;
|
||||
field_type: string;
|
||||
|
|
@ -15,6 +16,7 @@ interface alertingSettingsItem {
|
|||
field_description: string;
|
||||
stored_in_db: boolean | null;
|
||||
premium_field: boolean;
|
||||
source?: FieldSource;
|
||||
}
|
||||
|
||||
interface AlertingSettingsProps {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ interface Setting {
|
|||
field_value: unknown;
|
||||
stored_in_db: boolean | null;
|
||||
premium_field: boolean;
|
||||
source?: "config" | "db" | "env" | "default" | "unset";
|
||||
}
|
||||
|
||||
const SETTINGS: Setting[] = [
|
||||
|
|
@ -242,3 +243,59 @@ describe("DynamicForm presentation", () => {
|
|||
expect(screen.getByText("Not Set")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("DynamicForm config.yaml owned fields", () => {
|
||||
const withSource = (source: Setting["source"]) => SETTINGS.map((setting) => ({ ...setting, source }));
|
||||
|
||||
it("disables number, text and switch controls plus reset when source is config", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { handleSubmit, handleResetField } = renderForm({ settings: withSource("config") });
|
||||
|
||||
expect(screen.getByDisplayValue("12")).toBeDisabled();
|
||||
expect(screen.getByDisplayValue("us-east")).toBeDisabled();
|
||||
const toggle = screen.getByRole("switch");
|
||||
expect(toggle).toHaveAttribute("data-disabled");
|
||||
expect(toggle).toHaveAttribute("aria-disabled", "true");
|
||||
expect(screen.getByRole("button", { name: "Reset daily_report_frequency" })).toBeDisabled();
|
||||
|
||||
await user.click(toggle);
|
||||
await user.click(screen.getByRole("button", { name: "Reset daily_report_frequency" }));
|
||||
await submit(user);
|
||||
|
||||
expect(handleResetField).not.toHaveBeenCalled();
|
||||
expect(handleSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["env", "default", "db", "unset"] as const)("keeps fields editable when source is %s", async (source) => {
|
||||
const user = userEvent.setup();
|
||||
const { handleSubmit } = renderForm({ settings: withSource(source) });
|
||||
|
||||
expect(screen.getByDisplayValue("us-east")).toBeEnabled();
|
||||
const toggle = screen.getByRole("switch");
|
||||
expect(toggle).not.toHaveAttribute("data-disabled");
|
||||
|
||||
await user.type(screen.getByDisplayValue("us-east"), "Z");
|
||||
await user.click(toggle);
|
||||
await submit(user);
|
||||
|
||||
expect(handleSubmit).toHaveBeenCalledWith({ region_name: "us-eastZ", slack_alerting: true });
|
||||
});
|
||||
|
||||
it("freezes only the config owned field", async () => {
|
||||
const user = userEvent.setup();
|
||||
const settings = SETTINGS.map((setting) =>
|
||||
setting.field_name === "region_name"
|
||||
? { ...setting, source: "config" as const }
|
||||
: { ...setting, source: "db" as const },
|
||||
);
|
||||
const { handleSubmit } = renderForm({ settings });
|
||||
|
||||
expect(screen.getByDisplayValue("us-east")).toBeDisabled();
|
||||
expect(screen.getByDisplayValue("12")).toBeEnabled();
|
||||
|
||||
await user.type(screen.getByDisplayValue("12"), "7");
|
||||
await submit(user);
|
||||
|
||||
expect(handleSubmit).toHaveBeenCalledWith({ daily_report_frequency: "127" });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import React from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { CircleCheck, Trash2 } from "lucide-react";
|
||||
import { ConfigOwnedField, type FieldSource } from "@/components/shared/ConfigOwnedField";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
|
@ -14,6 +15,7 @@ interface AlertingSetting {
|
|||
field_value: any;
|
||||
stored_in_db: boolean | null;
|
||||
premium_field: boolean;
|
||||
source?: FieldSource;
|
||||
}
|
||||
|
||||
interface DynamicFormProps {
|
||||
|
|
@ -62,27 +64,44 @@ const DynamicForm: React.FC<DynamicFormProps> = ({
|
|||
handleInputChange(setting.field_name, checked);
|
||||
};
|
||||
|
||||
const isFrozen = (setting: AlertingSetting) => setting.source === "config";
|
||||
|
||||
const renderControl = (setting: AlertingSetting) => {
|
||||
const frozen = isFrozen(setting);
|
||||
if (setting.field_type === "Integer" || setting.field_type === "Float") {
|
||||
return (
|
||||
<Input
|
||||
type="number"
|
||||
step={setting.field_type === "Integer" ? 1 : "any"}
|
||||
value={setting.field_value ?? ""}
|
||||
onChange={(event) => handleNumericChange(setting, event.target.value)}
|
||||
/>
|
||||
<ConfigOwnedField frozen={frozen}>
|
||||
<Input
|
||||
type="number"
|
||||
step={setting.field_type === "Integer" ? 1 : "any"}
|
||||
value={setting.field_value ?? ""}
|
||||
disabled={frozen}
|
||||
onChange={(event) => handleNumericChange(setting, event.target.value)}
|
||||
/>
|
||||
</ConfigOwnedField>
|
||||
);
|
||||
}
|
||||
if (setting.field_type === "Boolean") {
|
||||
return (
|
||||
<Switch
|
||||
aria-label={setting.field_name}
|
||||
checked={setting.field_value}
|
||||
onCheckedChange={(checked) => handleToggle(setting, checked)}
|
||||
/>
|
||||
<ConfigOwnedField frozen={frozen} className="inline-flex">
|
||||
<Switch
|
||||
aria-label={setting.field_name}
|
||||
checked={setting.field_value}
|
||||
disabled={frozen}
|
||||
onCheckedChange={(checked) => handleToggle(setting, checked)}
|
||||
/>
|
||||
</ConfigOwnedField>
|
||||
);
|
||||
}
|
||||
return <Input value={setting.field_value ?? ""} onChange={(event) => handleTextChange(setting, event)} />;
|
||||
return (
|
||||
<ConfigOwnedField frozen={frozen}>
|
||||
<Input
|
||||
value={setting.field_value ?? ""}
|
||||
disabled={frozen}
|
||||
onChange={(event) => handleTextChange(setting, event)}
|
||||
/>
|
||||
</ConfigOwnedField>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -117,16 +136,19 @@ const DynamicForm: React.FC<DynamicFormProps> = ({
|
|||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Reset ${value.field_name}`}
|
||||
onClick={() => handleResetField(value.field_name, index)}
|
||||
className="text-destructive"
|
||||
>
|
||||
<Trash2 className="size-5" />
|
||||
</Button>
|
||||
<ConfigOwnedField frozen={isFrozen(value)} className="inline-flex">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Reset ${value.field_name}`}
|
||||
disabled={isFrozen(value)}
|
||||
onClick={() => handleResetField(value.field_name, index)}
|
||||
className="text-destructive"
|
||||
>
|
||||
<Trash2 className="size-5" />
|
||||
</Button>
|
||||
</ConfigOwnedField>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React from "react";
|
||||
import { ConfigOwnedField } from "@/components/shared/ConfigOwnedField";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
interface routingStrategyArgs {
|
||||
|
|
@ -13,9 +14,13 @@ const defaultLowestLatencyArgs: routingStrategyArgs = {
|
|||
|
||||
interface LatencyBasedConfigurationProps {
|
||||
routingStrategyArgs: { [key: string]: any };
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const LatencyBasedConfiguration: React.FC<LatencyBasedConfigurationProps> = ({ routingStrategyArgs }) => {
|
||||
const LatencyBasedConfiguration: React.FC<LatencyBasedConfigurationProps> = ({
|
||||
routingStrategyArgs,
|
||||
disabled = 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:
|
||||
|
|
@ -38,11 +43,14 @@ const LatencyBasedConfiguration: React.FC<LatencyBasedConfigurationProps> = ({ r
|
|||
{param.replace(/_/g, " ")}
|
||||
</span>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 mb-2">{paramExplanation[param] || ""}</p>
|
||||
<Input
|
||||
name={param}
|
||||
defaultValue={typeof value === "object" ? JSON.stringify(value, null, 2) : value?.toString()}
|
||||
className="font-mono text-sm w-full"
|
||||
/>
|
||||
<ConfigOwnedField frozen={disabled}>
|
||||
<Input
|
||||
name={param}
|
||||
disabled={disabled}
|
||||
defaultValue={typeof value === "object" ? JSON.stringify(value, null, 2) : value?.toString()}
|
||||
className="font-mono text-sm w-full"
|
||||
/>
|
||||
</ConfigOwnedField>
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,17 @@
|
|||
import React from "react";
|
||||
import { ConfigOwnedField, type FieldSourceMap, isConfigOwned } from "@/components/shared/ConfigOwnedField";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
interface ReliabilityRetriesSectionProps {
|
||||
routerSettings: { [key: string]: any };
|
||||
routerFieldsMetadata: { [key: string]: any };
|
||||
routerSources?: FieldSourceMap;
|
||||
}
|
||||
|
||||
const ReliabilityRetriesSection: React.FC<ReliabilityRetriesSectionProps> = ({
|
||||
routerSettings,
|
||||
routerFieldsMetadata,
|
||||
routerSources = {},
|
||||
}) => {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
|
@ -39,18 +42,21 @@ const ReliabilityRetriesSection: React.FC<ReliabilityRetriesSectionProps> = ({
|
|||
<p className="text-xs text-muted-foreground mt-0.5 mb-2">
|
||||
{routerFieldsMetadata[param]?.field_description || ""}
|
||||
</p>
|
||||
<Input
|
||||
name={param}
|
||||
defaultValue={
|
||||
value === null || value === undefined || value === "null"
|
||||
? ""
|
||||
: typeof value === "object"
|
||||
? JSON.stringify(value, null, 2)
|
||||
: value?.toString() || ""
|
||||
}
|
||||
placeholder="—"
|
||||
className="font-mono text-sm w-full"
|
||||
/>
|
||||
<ConfigOwnedField frozen={isConfigOwned(routerSources, param)}>
|
||||
<Input
|
||||
name={param}
|
||||
disabled={isConfigOwned(routerSources, param)}
|
||||
defaultValue={
|
||||
value === null || value === undefined || value === "null"
|
||||
? ""
|
||||
: typeof value === "object"
|
||||
? JSON.stringify(value, null, 2)
|
||||
: value?.toString() || ""
|
||||
}
|
||||
placeholder="—"
|
||||
className="font-mono text-sm w-full"
|
||||
/>
|
||||
</ConfigOwnedField>
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React from "react";
|
||||
import { type FieldSourceMap, isConfigOwned } from "@/components/shared/ConfigOwnedField";
|
||||
import LatencyBasedConfiguration from "./LatencyBasedConfiguration";
|
||||
import ReliabilityRetriesSection from "./ReliabilityRetriesSection";
|
||||
import RoutingStrategySelector from "./RoutingStrategySelector";
|
||||
|
|
@ -14,6 +15,7 @@ interface RouterSettingsFormProps {
|
|||
value: RouterSettingsFormValue;
|
||||
onChange: (value: RouterSettingsFormValue) => void;
|
||||
routerFieldsMetadata: { [key: string]: any };
|
||||
routerSources?: FieldSourceMap;
|
||||
availableRoutingStrategies: string[];
|
||||
routingStrategyDescriptions: { [key: string]: string };
|
||||
}
|
||||
|
|
@ -22,6 +24,7 @@ const RouterSettingsForm: React.FC<RouterSettingsFormProps> = ({
|
|||
value,
|
||||
onChange,
|
||||
routerFieldsMetadata,
|
||||
routerSources = {},
|
||||
availableRoutingStrategies,
|
||||
routingStrategyDescriptions,
|
||||
}) => {
|
||||
|
|
@ -55,6 +58,7 @@ const RouterSettingsForm: React.FC<RouterSettingsFormProps> = ({
|
|||
availableStrategies={availableRoutingStrategies}
|
||||
routingStrategyDescriptions={routingStrategyDescriptions}
|
||||
routerFieldsMetadata={routerFieldsMetadata}
|
||||
disabled={isConfigOwned(routerSources, "routing_strategy")}
|
||||
onStrategyChange={handleStrategyChange}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -63,6 +67,7 @@ const RouterSettingsForm: React.FC<RouterSettingsFormProps> = ({
|
|||
<TagFilteringToggle
|
||||
enabled={value.enableTagFiltering}
|
||||
routerFieldsMetadata={routerFieldsMetadata}
|
||||
disabled={isConfigOwned(routerSources, "enable_tag_filtering")}
|
||||
onToggle={handleTagFilteringToggle}
|
||||
/>
|
||||
</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"]}
|
||||
disabled={isConfigOwned(routerSources, "routing_strategy_args")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Other Settings */}
|
||||
<ReliabilityRetriesSection routerSettings={value.routerSettings} routerFieldsMetadata={routerFieldsMetadata} />
|
||||
<ReliabilityRetriesSection
|
||||
routerSettings={value.routerSettings}
|
||||
routerFieldsMetadata={routerFieldsMetadata}
|
||||
routerSources={routerSources}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React from "react";
|
||||
import { ConfigOwnedField } from "@/components/shared/ConfigOwnedField";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
|
||||
interface RoutingStrategySelectorProps {
|
||||
|
|
@ -6,6 +7,7 @@ interface RoutingStrategySelectorProps {
|
|||
availableStrategies: string[];
|
||||
routingStrategyDescriptions: { [key: string]: string };
|
||||
routerFieldsMetadata: { [key: string]: any };
|
||||
disabled?: boolean;
|
||||
onStrategyChange: (strategy: string) => void;
|
||||
}
|
||||
|
||||
|
|
@ -14,6 +16,7 @@ const RoutingStrategySelector: React.FC<RoutingStrategySelectorProps> = ({
|
|||
availableStrategies,
|
||||
routingStrategyDescriptions,
|
||||
routerFieldsMetadata,
|
||||
disabled = false,
|
||||
onStrategyChange,
|
||||
}) => {
|
||||
return (
|
||||
|
|
@ -26,9 +29,10 @@ const RoutingStrategySelector: React.FC<RoutingStrategySelectorProps> = ({
|
|||
{routerFieldsMetadata["routing_strategy"]?.field_description || ""}
|
||||
</p>
|
||||
</div>
|
||||
<div className="routing-strategy-select max-w-3xl">
|
||||
<ConfigOwnedField frozen={disabled} className="block max-w-3xl">
|
||||
<Select
|
||||
value={selectedStrategy}
|
||||
disabled={disabled}
|
||||
onValueChange={(strategy: string | null) => strategy && onStrategyChange(strategy)}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
|
|
@ -49,7 +53,7 @@ const RoutingStrategySelector: React.FC<RoutingStrategySelectorProps> = ({
|
|||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</ConfigOwnedField>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,13 +1,20 @@
|
|||
import React, { useId } from "react";
|
||||
import { ConfigOwnedField } from "@/components/shared/ConfigOwnedField";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
|
||||
interface TagFilteringToggleProps {
|
||||
enabled: boolean;
|
||||
routerFieldsMetadata: { [key: string]: any };
|
||||
disabled?: boolean;
|
||||
onToggle: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
const TagFilteringToggle: React.FC<TagFilteringToggleProps> = ({ enabled, routerFieldsMetadata, onToggle }) => {
|
||||
const TagFilteringToggle: React.FC<TagFilteringToggleProps> = ({
|
||||
enabled,
|
||||
routerFieldsMetadata,
|
||||
disabled = false,
|
||||
onToggle,
|
||||
}) => {
|
||||
const toggleId = useId();
|
||||
|
||||
return (
|
||||
|
|
@ -34,7 +41,9 @@ const TagFilteringToggle: React.FC<TagFilteringToggleProps> = ({ enabled, router
|
|||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Switch id={toggleId} checked={enabled} onCheckedChange={onToggle} className="ml-4" />
|
||||
<ConfigOwnedField frozen={disabled} className="ml-4 inline-flex">
|
||||
<Switch id={toggleId} checked={enabled} disabled={disabled} onCheckedChange={onToggle} />
|
||||
</ConfigOwnedField>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -183,4 +183,50 @@ describe("RouterSettings", () => {
|
|||
});
|
||||
expect(toast.success).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("config.yaml owned fields", () => {
|
||||
it("freezes routing strategy, tag filtering and reliability inputs whose source is config", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(getRouterSettingsCall).mockResolvedValue({
|
||||
...mockRouterSettingsResponse,
|
||||
source: { routing_strategy: "config", enable_tag_filtering: "config", num_retries: "config", timeout: "db" },
|
||||
});
|
||||
renderWithProviders(<RouterSettings {...defaultProps} />);
|
||||
|
||||
const strategySelect = await findStrategySelect();
|
||||
expect(strategySelect).toHaveAttribute("data-disabled");
|
||||
expect(screen.getByRole("switch")).toHaveAttribute("data-disabled");
|
||||
expect(await screen.findByRole("textbox", { name: /num_retries/i })).toBeDisabled();
|
||||
expect(screen.getByRole("textbox", { name: /timeout/i })).toBeEnabled();
|
||||
|
||||
await user.hover(strategySelect);
|
||||
expect(await screen.findByText("Set in config.yaml and cannot be changed here")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each(["env", "default", "db"])("keeps fields editable when source is %s", async (source) => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(getRouterSettingsCall).mockResolvedValue({
|
||||
...mockRouterSettingsResponse,
|
||||
source: { routing_strategy: source, enable_tag_filtering: source, num_retries: source },
|
||||
});
|
||||
renderWithProviders(<RouterSettings {...defaultProps} />);
|
||||
|
||||
const strategySelect = await findStrategySelect();
|
||||
expect(strategySelect).not.toHaveAttribute("data-disabled");
|
||||
expect(screen.getByRole("switch")).not.toHaveAttribute("data-disabled");
|
||||
const numRetries = await screen.findByRole("textbox", { name: /num_retries/i });
|
||||
expect(numRetries).toBeEnabled();
|
||||
expect(screen.queryByText("Set in config.yaml and cannot be changed here")).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.change(numRetries, { target: { value: "7" } });
|
||||
await user.click(screen.getByRole("button", { name: /save changes/i }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(setCallbacksCall).toHaveBeenCalledWith(
|
||||
"test-token",
|
||||
expect.objectContaining({ router_settings: expect.objectContaining({ num_retries: 7 }) }),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { FieldSourceMap } from "@/components/shared/ConfigOwnedField";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { getCallbacksCall, getRouterSettingsCall, setCallbacksCall } from "../networking";
|
||||
import RouterSettingsForm, { RouterSettingsFormValue } from "./RouterSettingsForm";
|
||||
|
|
@ -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 [routerSources, setRouterSources] = useState<FieldSourceMap>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (!accessToken || !userRole || !userID) {
|
||||
|
|
@ -43,6 +45,7 @@ const RouterSettings: React.FC<RouterSettingsProps> = ({ accessToken, userRole,
|
|||
}));
|
||||
});
|
||||
getRouterSettingsCall(accessToken).then((data) => {
|
||||
setRouterSources(data.source ?? {});
|
||||
if (data.fields) {
|
||||
// Build metadata map for easy lookup
|
||||
const fieldsMap: { [key: string]: any } = {};
|
||||
|
|
@ -184,6 +187,7 @@ const RouterSettings: React.FC<RouterSettingsProps> = ({ accessToken, userRole,
|
|||
value={formValue}
|
||||
onChange={setFormValue}
|
||||
routerFieldsMetadata={routerFieldsMetadata}
|
||||
routerSources={routerSources}
|
||||
availableRoutingStrategies={availableRoutingStrategies}
|
||||
routingStrategyDescriptions={routingStrategyDescriptions}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
import React from "react";
|
||||
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import type { components } from "@/lib/http/schema";
|
||||
|
||||
export type FieldSource = components["schemas"]["RouterSettingsResponse"]["source"][string];
|
||||
export type FieldSourceMap = Partial<Record<string, FieldSource>>;
|
||||
|
||||
export const CONFIG_OWNED_MESSAGE = "Set in config.yaml and cannot be changed here";
|
||||
|
||||
export const isConfigOwned = (sources: FieldSourceMap | null | undefined, fieldName: string): boolean =>
|
||||
sources?.[fieldName] === "config";
|
||||
|
||||
interface ConfigOwnedFieldProps {
|
||||
frozen: boolean;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ConfigOwnedField({ frozen, children, className = "inline-flex w-full" }: ConfigOwnedFieldProps) {
|
||||
if (!frozen) {
|
||||
return <span className={className}>{children}</span>;
|
||||
}
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<span className={className} data-config-owned="true" />}>{children}</TooltipTrigger>
|
||||
<TooltipContent>{CONFIG_OWNED_MESSAGE}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue