diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index 2513e58d2..2402e76ea 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -1464,12 +1464,23 @@ "baseConfig": "Base Config", "addProfile": "Add Profile", "newProfile": "New Profile", - "profileNamePlaceholder": "e.g., armed, away, night", + "profileNamePlaceholder": "e.g., Armed, Away, Night Mode", + "friendlyNameLabel": "Profile Name", + "profileIdLabel": "Profile ID", + "profileIdDescription": "Internal identifier used in config and automations", "nameInvalid": "Only lowercase letters, numbers, and underscores allowed", "nameDuplicate": "A profile with this name already exists", + "error": { + "mustBeAtLeastTwoCharacters": "Must be at least 2 characters", + "mustNotContainPeriod": "Must not contain periods", + "alreadyExists": "A profile with this ID already exists" + }, + "renameProfile": "Rename Profile", + "renameSuccess": "Profile renamed to '{{profile}}'", "deleteProfile": "Delete Profile", "deleteProfileConfirm": "Delete profile \"{{profile}}\" from all cameras? This cannot be undone.", "deleteSuccess": "Profile '{{profile}}' deleted", + "removeOverride": "Remove Profile Override", "deleteSection": "Delete Section Overrides", "deleteSectionConfirm": "Remove the {{section}} overrides for profile {{profile}} on {{camera}}?", "deleteSectionSuccess": "Removed {{section}} overrides for {{profile}}", diff --git a/web/src/components/config-form/sections/BaseSection.tsx b/web/src/components/config-form/sections/BaseSection.tsx index ed2c624ae..f8a22b43c 100644 --- a/web/src/components/config-form/sections/BaseSection.tsx +++ b/web/src/components/config-form/sections/BaseSection.tsx @@ -136,7 +136,7 @@ export interface BaseSectionProps { hasValidationErrors: boolean; }) => void; /** Pending form data keyed by "sectionKey" or "cameraName::sectionKey" */ - pendingDataBySection?: Record; + pendingDataBySection?: Record; /** Callback to update pending data for a section */ onPendingDataChange?: ( sectionKey: string, @@ -145,8 +145,12 @@ export interface BaseSectionProps { ) => void; /** When set, editing this profile's overrides instead of the base config */ profileName?: string; + /** Display name for the profile (friendly name) */ + profileFriendlyName?: string; /** Border color class for profile override badge (e.g., "border-amber-500") */ profileBorderColor?: string; + /** Callback to delete the current profile's overrides for this section */ + onDeleteProfileSection?: () => void; } export interface CreateSectionOptions { @@ -178,7 +182,9 @@ export function ConfigSection({ pendingDataBySection, onPendingDataChange, profileName, + profileFriendlyName, profileBorderColor, + onDeleteProfileSection, }: ConfigSectionProps) { // For replay level, treat as camera-level config access const effectiveLevel = level === "replay" ? "camera" : level; @@ -243,6 +249,8 @@ export function ConfigSection({ const [extraHasChanges, setExtraHasChanges] = useState(false); const [formKey, setFormKey] = useState(0); const [isResetDialogOpen, setIsResetDialogOpen] = useState(false); + const [isDeleteProfileDialogOpen, setIsDeleteProfileDialogOpen] = + useState(false); const [restartDialogOpen, setRestartDialogOpen] = useState(false); const isResettingRef = useRef(false); const isInitializingRef = useRef(true); @@ -932,6 +940,23 @@ export function ConfigSection({ })} )} + {profileName && + profileOverridesSection && + !hasChanges && + !skipSave && + onDeleteProfileSection && ( + + )} {hasChanges && ( - - - onSelectProfile(null)}> -
- {editingProfile === null && ( - - )} - - {t("profiles.baseConfig", { ns: "views/settings" })} - -
-
- - {allProfileNames.length > 0 && } - - {allProfileNames.map((profile) => { - const color = getProfileColor(profile, allProfileNames); - const hasData = hasProfileData(profile); - const isActive = editingProfile === profile; - - return ( - onSelectProfile(profile)} - > -
-
- {isActive && } - - {profile} -
- {!hasData && ( - - {t("profiles.noOverrides", { ns: "views/settings" })} - - )} -
- {hasData && ( - + + + + + + onSelectProfile(null)}> +
+ {editingProfile === null && ( + )} + + {t("profiles.baseConfig", { ns: "views/settings" })} +
- - - - - - +
- { - if (!open) setDeleteConfirmProfile(null); - }} - > - - - - {t("profiles.deleteSection", { ns: "views/settings" })} - - - {t("profiles.deleteSectionConfirm", { - ns: "views/settings", - profile: deleteConfirmProfile, - section: friendlySectionName, - camera: friendlyCameraName, - })} - - - - - {t("button.cancel", { ns: "common" })} - - 0 && } + + {allProfileNames.map((profile) => { + const color = getProfileColor(profile, allProfileNames); + const hasData = hasProfileData(profile); + const isActive = editingProfile === profile; + + return ( + onSelectProfile(profile)} > - {t("button.delete", { ns: "common" })} - - - - - +
+
+ {isActive && } + + {profileFriendlyNames.get(profile) ?? profile} +
+ {!hasData && ( + + {t("profiles.noOverrides", { ns: "views/settings" })} + + )} +
+
+ ); + })} +
+ ); } diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index f0f1d66e2..e20037388 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -28,7 +28,11 @@ import useOptimisticState from "@/hooks/use-optimistic-state"; import { isMobile } from "react-device-detect"; import { FaVideo } from "react-icons/fa"; import { CameraConfig, FrigateConfig } from "@/types/frigateConfig"; -import type { ConfigSectionData } from "@/types/configForm"; +import type { + ConfigSectionData, + JsonObject, + JsonValue, +} from "@/types/configForm"; import useSWR from "swr"; import FilterSwitch from "@/components/filter/FilterSwitch"; import { ZoneMaskFilterButton } from "@/components/filter/ZoneMaskFilter"; @@ -92,7 +96,7 @@ import { prepareSectionSavePayload, PROFILE_ELIGIBLE_SECTIONS, } from "@/utils/configUtil"; -import type { ProfileState } from "@/types/profile"; +import type { ProfileState, ProfilesApiResponse } from "@/types/profile"; import { getProfileColor } from "@/utils/profileColors"; import { ProfileSectionDropdown } from "@/components/settings/ProfileSectionDropdown"; import { Badge } from "@/components/ui/badge"; @@ -186,15 +190,15 @@ const parsePendingDataKey = (pendingDataKey: string) => { }; const flattenOverrides = ( - value: unknown, + value: JsonValue | undefined, path: string[] = [], -): Array<{ path: string; value: unknown }> => { +): Array<{ path: string; value: JsonValue }> => { if (value === undefined) return []; if (value === null || typeof value !== "object" || Array.isArray(value)) { return [{ path: path.join("."), value }]; } - const entries = Object.entries(value as Record); + const entries = Object.entries(value); if (entries.length === 0) { return [{ path: path.join("."), value: {} }]; } @@ -316,10 +320,7 @@ const CameraTimestampStyleSettingsPage = createSectionPage( const settingsGroups = [ { label: "general", - items: [ - { key: "uiSettings", component: UiSettingsView }, - { key: "profiles", component: ProfilesView }, - ], + items: [{ key: "uiSettings", component: UiSettingsView }], }, { label: "globalConfig", @@ -345,6 +346,7 @@ const settingsGroups = [ { label: "cameras", items: [ + { key: "profiles", component: ProfilesView }, { key: "cameraManagement", component: CameraManagementView }, { key: "cameraDetect", component: CameraDetectSettingsPage }, { key: "cameraObjects", component: CameraObjectsSettingsPage }, @@ -635,10 +637,7 @@ export default function Settings() { >({}); const { data: config } = useSWR("config"); - const { data: profilesData } = useSWR<{ - profiles: string[]; - active_profile: string | null; - }>("profiles"); + const { data: profilesData } = useSWR("profiles"); const [searchParams] = useSearchParams(); @@ -655,7 +654,7 @@ export default function Settings() { // Store pending form data keyed by "sectionKey" or "cameraName::sectionKey" const [pendingDataBySection, setPendingDataBySection] = useState< - Record + Record >({}); // Profile editing state @@ -666,15 +665,29 @@ export default function Settings() { const [profilesUIEnabled, setProfilesUIEnabled] = useState(false); const allProfileNames = useMemo(() => { - if (!config) return []; const names = new Set(); - Object.values(config.cameras).forEach((cam) => { - Object.keys(cam.profiles ?? {}).forEach((p) => names.add(p)); - }); + if (config?.profiles) { + Object.keys(config.profiles).forEach((p) => names.add(p)); + } newProfiles.forEach((p) => names.add(p)); return [...names].sort(); }, [config, newProfiles]); + const profileFriendlyNames = useMemo(() => { + const map = new Map(); + if (profilesData?.profiles) { + profilesData.profiles.forEach((p) => map.set(p.name, p.friendly_name)); + } + // Include pending (unsaved) profile definitions + for (const [key, data] of Object.entries(pendingDataBySection)) { + if (key.startsWith("__profile_def__.") && data?.friendly_name) { + const id = key.slice("__profile_def__.".length); + map.set(id, String(data.friendly_name)); + } + } + return map; + }, [profilesData, pendingDataBySection]); + const navigate = useNavigate(); const cameras = useMemo(() => { @@ -756,7 +769,9 @@ export default function Settings() { items.push({ scope, cameraName, - profileName: isProfile ? profileName : undefined, + profileName: isProfile + ? (profileFriendlyNames.get(profileName!) ?? profileName) + : undefined, fieldPath, value, }); @@ -773,7 +788,7 @@ export default function Settings() { if (cameraCompare !== 0) return cameraCompare; return left.fieldPath.localeCompare(right.fieldPath); }); - }, [config, fullSchema, pendingDataBySection]); + }, [config, fullSchema, pendingDataBySection, profileFriendlyNames]); // Map a pendingDataKey to SettingsType menu key for clearing section status const pendingKeyToMenuKey = useCallback( @@ -827,6 +842,28 @@ export default function Settings() { for (const key of pendingKeys) { const pendingData = pendingDataBySection[key]; + + // Handle top-level profile definition saves + if (key.startsWith("__profile_def__.")) { + const profileId = key.replace("__profile_def__.", ""); + try { + const configData = { profiles: { [profileId]: pendingData } }; + await axios.put("config/set", { + requires_restart: 0, + config_data: configData, + }); + setPendingDataBySection((prev) => { + const { [key]: _, ...rest } = prev; + return rest; + }); + savedKeys.push(key); + successCount++; + } catch { + failCount++; + } + continue; + } + try { const payload = prepareSectionSavePayload({ pendingDataKey: key, @@ -876,6 +913,11 @@ export default function Settings() { // Refresh config from server once await mutate("config"); + // If any profile definitions were saved, refresh profiles data too + if (savedKeys.some((key) => key.startsWith("__profile_def__."))) { + await mutate("profiles"); + } + // Clear hasChanges in sidebar for all successfully saved sections if (savedKeys.length > 0) { setSectionStatusByKey((prev) => { @@ -954,13 +996,10 @@ export default function Settings() { setUnsavedChanges(false); setEditingProfile({}); - // Clear new profiles that don't exist in saved config + // Clear new profiles that now exist in top-level config if (config) { - const savedNames = new Set(); - Object.values(config.cameras).forEach((cam) => { - Object.keys(cam.profiles ?? {}).forEach((p) => savedNames.add(p)); - }); - setNewProfiles((prev) => prev.filter((p) => savedNames.has(p))); + const savedNames = new Set(Object.keys(config.profiles ?? {})); + setNewProfiles((prev) => prev.filter((p) => !savedNames.has(p))); } setSectionStatusByKey((prev) => { @@ -1062,8 +1101,14 @@ export default function Settings() { [], ); - const handleAddProfile = useCallback((name: string) => { - setNewProfiles((prev) => (prev.includes(name) ? prev : [...prev, name])); + const handleAddProfile = useCallback((id: string, friendlyName: string) => { + setNewProfiles((prev) => (prev.includes(id) ? prev : [...prev, id])); + + // Stage the top-level profile definition for saving + setPendingDataBySection((prev) => ({ + ...prev, + [`__profile_def__.${id}`]: { friendly_name: friendlyName }, + })); }, []); const handleRemoveNewProfile = useCallback((name: string) => { @@ -1120,14 +1165,14 @@ export default function Settings() { ns: "views/settings", defaultValue: section, }), - profile, + profile: profileFriendlyNames.get(profile) ?? profile, }), ); } catch { toast.error(t("toast.save.error.title", { ns: "common" })); } }, - [handleSelectProfile, t], + [handleSelectProfile, profileFriendlyNames, t], ); const profileState: ProfileState = useMemo( @@ -1135,6 +1180,7 @@ export default function Settings() { editingProfile, newProfiles, allProfileNames, + profileFriendlyNames, onSelectProfile: handleSelectProfile, onAddProfile: handleAddProfile, onRemoveNewProfile: handleRemoveNewProfile, @@ -1144,6 +1190,7 @@ export default function Settings() { editingProfile, newProfiles, allProfileNames, + profileFriendlyNames, handleSelectProfile, handleAddProfile, handleRemoveNewProfile, @@ -1207,7 +1254,7 @@ export default function Settings() { // Build a targeted delete payload that only removes mask-related // sub-keys, not the entire motion/objects sections - const deletePayload: Record = {}; + const deletePayload: JsonObject = {}; if (profileData.zones !== undefined) { deletePayload.zones = ""; @@ -1218,12 +1265,12 @@ export default function Settings() { } if (profileData.objects) { - const objDelete: Record = {}; + const objDelete: JsonObject = {}; if (profileData.objects.mask !== undefined) { objDelete.mask = ""; } if (profileData.objects.filters) { - const filtersDelete: Record = {}; + const filtersDelete: JsonObject = {}; for (const [filterName, filterVal] of Object.entries( profileData.objects.filters, )) { @@ -1262,7 +1309,7 @@ export default function Settings() { section: t("configForm.sections.masksAndZones", { ns: "views/settings", }), - profile: profileName, + profile: profileFriendlyNames.get(profileName) ?? profileName, }), ); } catch { @@ -1282,6 +1329,7 @@ export default function Settings() { config, handleSelectProfile, handleDeleteProfileSection, + profileFriendlyNames, t, ], ); @@ -1490,7 +1538,8 @@ export default function Settings() { setContentMobileOpen(true); }} > - {profilesData.active_profile} + {profileFriendlyNames.get(profilesData.active_profile) ?? + profilesData.active_profile} )} @@ -1607,9 +1656,8 @@ export default function Settings() { )} {showProfileDropdown && currentSectionKey && ( @@ -1619,10 +1667,6 @@ export default function Settings() { profile, ) } - onAddProfile={handleAddProfile} - onDeleteProfileSection={ - handleDeleteProfileForCurrentSection - } /> )} @@ -1771,9 +1818,8 @@ export default function Settings() { )} {showProfileDropdown && currentSectionKey && ( @@ -1783,8 +1829,6 @@ export default function Settings() { profile, ) } - onAddProfile={handleAddProfile} - onDeleteProfileSection={handleDeleteProfileForCurrentSection} /> )} diff --git a/web/src/types/configForm.ts b/web/src/types/configForm.ts index 0782d677f..f228de430 100644 --- a/web/src/types/configForm.ts +++ b/web/src/types/configForm.ts @@ -23,7 +23,7 @@ export type ConfigFormContext = { extraHasChanges?: boolean; setExtraHasChanges?: (hasChanges: boolean) => void; formData?: JsonObject; - pendingDataBySection?: Record; + pendingDataBySection?: Record; onPendingDataChange?: ( sectionKey: string, cameraName: string | undefined, diff --git a/web/src/types/frigateConfig.ts b/web/src/types/frigateConfig.ts index f64aa6197..13a7acfe7 100644 --- a/web/src/types/frigateConfig.ts +++ b/web/src/types/frigateConfig.ts @@ -334,6 +334,10 @@ export type CameraProfileConfig = { zones?: Partial; }; +export type ProfileDefinitionConfig = { + friendly_name: string; +}; + export type CameraGroupConfig = { cameras: string[]; icon: IconName; @@ -488,6 +492,8 @@ export interface FrigateConfig { camera_groups: { [groupName: string]: CameraGroupConfig }; + profiles: { [profileName: string]: ProfileDefinitionConfig }; + lpr: { enabled: boolean; }; diff --git a/web/src/types/profile.ts b/web/src/types/profile.ts index c4dc66513..0bcb95032 100644 --- a/web/src/types/profile.ts +++ b/web/src/types/profile.ts @@ -6,16 +6,27 @@ export type ProfileColor = { bgMuted: string; }; +export type ProfileInfo = { + name: string; + friendly_name: string; +}; + +export type ProfilesApiResponse = { + profiles: ProfileInfo[]; + active_profile: string | null; +}; + export type ProfileState = { editingProfile: Record; newProfiles: string[]; allProfileNames: string[]; + profileFriendlyNames: Map; onSelectProfile: ( camera: string, section: string, profile: string | null, ) => void; - onAddProfile: (name: string) => void; + onAddProfile: (id: string, friendlyName: string) => void; onRemoveNewProfile: (name: string) => void; onDeleteProfileSection: ( camera: string, diff --git a/web/src/utils/configUtil.ts b/web/src/utils/configUtil.ts index 3fa4affda..0be14059b 100644 --- a/web/src/utils/configUtil.ts +++ b/web/src/utils/configUtil.ts @@ -374,7 +374,7 @@ export function requiresRestartForFieldPath( export interface SectionSavePayload { basePath: string; - sanitizedOverrides: Record; + sanitizedOverrides: JsonObject; updateTopic: string | undefined; needsRestart: boolean; pendingDataKey: string; @@ -561,7 +561,7 @@ export function prepareSectionSavePayload(opts: { if ( !sanitizedOverrides || typeof sanitizedOverrides !== "object" || - Object.keys(sanitizedOverrides as Record).length === 0 + Object.keys(sanitizedOverrides as JsonObject).length === 0 ) { return null; } @@ -597,7 +597,7 @@ export function prepareSectionSavePayload(opts: { return { basePath, - sanitizedOverrides: sanitizedOverrides as Record, + sanitizedOverrides: sanitizedOverrides as JsonObject, updateTopic, needsRestart, pendingDataKey, diff --git a/web/src/views/settings/CameraManagementView.tsx b/web/src/views/settings/CameraManagementView.tsx index 46c1632f8..8cd13c33b 100644 --- a/web/src/views/settings/CameraManagementView.tsx +++ b/web/src/views/settings/CameraManagementView.tsx @@ -474,10 +474,6 @@ function ProfileCameraEnableSection({ [config, selectedProfile, localOverrides], ); - const profileColor = selectedProfile - ? getProfileColor(selectedProfile, profileState.allProfileNames) - : null; - if (!selectedProfile) return null; return ( @@ -502,17 +498,7 @@ function ProfileCameraEnableSection({
{ - setNewProfileName(e.target.value); - setNameError(validateName(e.target.value)); - }} - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault(); - handleAddSubmit(); - } - }} - autoFocus - /> - {nameError && ( -

{nameError}

- )} -
- - - - + + control={addForm.control} + type="profile" + nameField="friendly_name" + idField="name" + nameLabel={t("profiles.friendlyNameLabel", { + ns: "views/settings", + })} + idLabel={t("profiles.profileIdLabel", { + ns: "views/settings", + })} + idDescription={t("profiles.profileIdDescription", { + ns: "views/settings", + })} + placeholderName={t("profiles.profileNamePlaceholder", { + ns: "views/settings", + })} + /> + + + + + + @@ -547,7 +638,9 @@ export default function ProfilesView({ {t("profiles.deleteProfileConfirm", { ns: "views/settings", - profile: deleteProfile, + profile: deleteProfile + ? (profileFriendlyNames?.get(deleteProfile) ?? deleteProfile) + : "", })} @@ -560,11 +653,54 @@ export default function ProfilesView({ onClick={handleDeleteProfile} disabled={deleting} > + {deleting && } {t("button.delete", { ns: "common" })} + + {/* Rename Profile Dialog */} + { + if (!open) setRenameProfile(null); + }} + > + + + + {t("profiles.renameProfile", { ns: "views/settings" })} + + +
+ setRenameValue(e.target.value)} + placeholder={t("profiles.profileNamePlaceholder", { + ns: "views/settings", + })} + /> + + + + +
+
+
); } diff --git a/web/src/views/settings/SingleSectionPage.tsx b/web/src/views/settings/SingleSectionPage.tsx index abd7dac84..535a5dee6 100644 --- a/web/src/views/settings/SingleSectionPage.tsx +++ b/web/src/views/settings/SingleSectionPage.tsx @@ -28,13 +28,15 @@ export type SettingsPageProps = { level: "global" | "camera", status: SectionStatus, ) => void; - pendingDataBySection?: Record; + pendingDataBySection?: Record; onPendingDataChange?: ( sectionKey: string, cameraName: string | undefined, data: ConfigSectionData | null, ) => void; profileState?: ProfileState; + /** Callback to delete the current profile's overrides for the current section */ + onDeleteProfileSection?: (profileName: string) => void; profilesUIEnabled?: boolean; setProfilesUIEnabled?: React.Dispatch>; }; @@ -70,6 +72,7 @@ export function SingleSectionPage({ pendingDataBySection, onPendingDataChange, profileState, + onDeleteProfileSection, }: SingleSectionPageProps) { const sectionNamespace = level === "camera" ? "config/cameras" : "config/global"; @@ -104,6 +107,12 @@ export function SingleSectionPage({ [currentEditingProfile, profileState?.allProfileNames], ); + const handleDeleteProfileSection = useCallback(() => { + if (currentEditingProfile && onDeleteProfileSection) { + onDeleteProfileSection(currentEditingProfile); + } + }, [currentEditingProfile, onDeleteProfileSection]); + const handleSectionStatusChange = useCallback( (status: SectionStatus) => { setSectionStatus(status); @@ -179,8 +188,12 @@ export function SingleSectionPage({ {sectionStatus.overrideSource === "profile" ? t("button.overriddenBaseConfigTooltip", { - ns: "common", - profile: currentEditingProfile, + ns: "views/settings", + profile: currentEditingProfile + ? (profileState?.profileFriendlyNames.get( + currentEditingProfile, + ) ?? currentEditingProfile) + : "", }) : t("button.overriddenGlobalTooltip", { ns: "views/settings", @@ -212,7 +225,16 @@ export function SingleSectionPage({ requiresRestart={requiresRestart} onStatusChange={handleSectionStatusChange} profileName={currentEditingProfile ?? undefined} + profileFriendlyName={ + currentEditingProfile + ? (profileState?.profileFriendlyNames.get(currentEditingProfile) ?? + currentEditingProfile) + : undefined + } profileBorderColor={profileColor?.border} + onDeleteProfileSection={ + currentEditingProfile ? handleDeleteProfileSection : undefined + } /> );