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:
yuneng 2026-09-22 00:17:30 +00:00
parent 094a60bb9c
commit 8d6c6867e6
18 changed files with 475 additions and 88 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

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, 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();
});
});
});
});

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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