implement profile friendly names and improve profile UI

- Add ProfileDefinitionConfig type and profiles field to FrigateConfig
- Use ProfilesApiResponse type with friendly_name support throughout
- Replace Record<string, unknown> with proper JsonObject/JsonValue types
- Add profile creation form matching zone pattern (Zod + NameAndIdFields)
- Add pencil icon for renaming profile friendly names in ProfilesView
- Move Profiles menu item to first under Camera Configuration
- Add activity indicators on save/rename/delete buttons
- Display friendly names in CameraManagementView profile selector
- Fix duplicate colored dots in management profile dropdown
- Fix i18n namespace for overridden base config tooltips
- Move profile override deletion from dropdown trash icon to footer
  button with confirmation dialog, matching Reset to Global pattern
- Remove Add Profile from section header dropdown to prevent saving
  camera overrides before top-level profile definition exists
- Clean up newProfiles state after API profile deletion
- Refresh profiles SWR cache after saving profile definitions
This commit is contained in:
Josh Hawkins 2026-03-11 20:55:56 -05:00
parent 0b3c6ed22e
commit 39500b20a0
11 changed files with 519 additions and 418 deletions

View File

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

View File

@ -136,7 +136,7 @@ export interface BaseSectionProps {
hasValidationErrors: boolean;
}) => void;
/** Pending form data keyed by "sectionKey" or "cameraName::sectionKey" */
pendingDataBySection?: Record<string, unknown>;
pendingDataBySection?: Record<string, ConfigSectionData>;
/** 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({
})}
</Button>
)}
{profileName &&
profileOverridesSection &&
!hasChanges &&
!skipSave &&
onDeleteProfileSection && (
<Button
onClick={() => setIsDeleteProfileDialogOpen(true)}
variant="outline"
disabled={isSaving || disabled}
className="flex flex-1 gap-2"
>
{t("profiles.removeOverride", {
ns: "views/settings",
defaultValue: "Remove Profile Override",
})}
</Button>
)}
{hasChanges && (
<Button
onClick={handleReset}
@ -1003,6 +1028,47 @@ export function ConfigSection({
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog
open={isDeleteProfileDialogOpen}
onOpenChange={setIsDeleteProfileDialogOpen}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("profiles.deleteSection", { ns: "views/settings" })}
</AlertDialogTitle>
<AlertDialogDescription>
{t("profiles.deleteSectionConfirm", {
ns: "views/settings",
profile: profileFriendlyName ?? profileName,
section: t(`${sectionPath}.label`, {
ns:
effectiveLevel === "camera"
? "config/cameras"
: "config/global",
defaultValue: sectionPath,
}),
camera: cameraName ?? "",
})}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("button.cancel", { ns: "common" })}
</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-white hover:bg-destructive/90"
onClick={() => {
onDeleteProfileSection?.();
setIsDeleteProfileDialogOpen(false);
}}
>
{t("button.delete", { ns: "common" })}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
@ -1028,7 +1094,7 @@ export function ConfigSection({
<Badge variant="secondary" className="text-xs">
{overrideSource === "profile"
? t("button.overriddenBaseConfig", {
ns: "common",
ns: "views/settings",
defaultValue: "Overridden (Base Config)",
})
: t("button.overriddenGlobal", {
@ -1040,8 +1106,8 @@ export function ConfigSection({
<TooltipContent>
{overrideSource === "profile"
? t("button.overriddenBaseConfigTooltip", {
ns: "common",
profile: profileName,
ns: "views/settings",
profile: profileFriendlyName ?? profileName,
})
: t("button.overriddenGlobalTooltip", {
ns: "views/settings",
@ -1099,7 +1165,7 @@ export function ConfigSection({
>
{overrideSource === "profile"
? t("button.overriddenBaseConfig", {
ns: "common",
ns: "views/settings",
defaultValue: "Overridden (Base Config)",
})
: t("button.overriddenGlobal", {
@ -1111,8 +1177,8 @@ export function ConfigSection({
<TooltipContent>
{overrideSource === "profile"
? t("button.overriddenBaseConfigTooltip", {
ns: "common",
profile: profileName,
ns: "views/settings",
profile: profileFriendlyName ?? profileName,
})
: t("button.overriddenGlobalTooltip", {
ns: "views/settings",

View File

@ -1,9 +1,7 @@
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { Check, ChevronDown, Plus, Trash2 } from "lucide-react";
import { Check, ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { getProfileColor } from "@/utils/profileColors";
import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
import {
DropdownMenu,
DropdownMenuContent,
@ -11,281 +9,100 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
type ProfileSectionDropdownProps = {
cameraName: string;
sectionKey: string;
allProfileNames: string[];
profileFriendlyNames: Map<string, string>;
editingProfile: string | null;
hasProfileData: (profileName: string) => boolean;
onSelectProfile: (profileName: string | null) => void;
onAddProfile: (name: string) => void;
onDeleteProfileSection: (profileName: string) => void;
};
export function ProfileSectionDropdown({
cameraName,
sectionKey,
allProfileNames,
profileFriendlyNames,
editingProfile,
hasProfileData,
onSelectProfile,
onAddProfile,
onDeleteProfileSection,
}: ProfileSectionDropdownProps) {
const { t } = useTranslation(["views/settings", "common"]);
const friendlyCameraName = useCameraFriendlyName(cameraName);
const friendlySectionName = t(`configForm.sections.${sectionKey}`, {
ns: "views/settings",
defaultValue: sectionKey,
});
const [addDialogOpen, setAddDialogOpen] = useState(false);
const [deleteConfirmProfile, setDeleteConfirmProfile] = useState<
string | null
>(null);
const [newProfileName, setNewProfileName] = useState("");
const [nameError, setNameError] = useState<string | null>(null);
const validateName = useCallback(
(name: string): string | null => {
if (!name.trim()) return null;
if (!/^[a-z0-9_]+$/.test(name)) {
return t("profiles.nameInvalid", {
ns: "views/settings",
});
}
if (allProfileNames.includes(name)) {
return t("profiles.nameDuplicate", {
ns: "views/settings",
});
}
return null;
},
[allProfileNames, t],
);
const handleAddSubmit = useCallback(() => {
const name = newProfileName.trim();
if (!name) return;
const error = validateName(name);
if (error) {
setNameError(error);
return;
}
onAddProfile(name);
onSelectProfile(name);
setAddDialogOpen(false);
setNewProfileName("");
setNameError(null);
}, [newProfileName, validateName, onAddProfile, onSelectProfile]);
const handleDeleteConfirm = useCallback(() => {
if (!deleteConfirmProfile) return;
onDeleteProfileSection(deleteConfirmProfile);
if (editingProfile === deleteConfirmProfile) {
onSelectProfile(null);
}
setDeleteConfirmProfile(null);
}, [
deleteConfirmProfile,
editingProfile,
onDeleteProfileSection,
onSelectProfile,
]);
const { t } = useTranslation(["views/settings"]);
const activeColor = editingProfile
? getProfileColor(editingProfile, allProfileNames)
: null;
const editingFriendlyName = editingProfile
? (profileFriendlyNames.get(editingProfile) ?? editingProfile)
: null;
return (
<>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="h-9 gap-2 font-normal">
{editingProfile ? (
<>
<span
className={cn(
"h-2 w-2 shrink-0 rounded-full",
activeColor?.dot,
)}
/>
{editingProfile}
</>
) : (
t("profiles.baseConfig", { ns: "views/settings" })
)}
<ChevronDown className="h-3 w-3 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[180px]">
<DropdownMenuItem onClick={() => onSelectProfile(null)}>
<div className="flex w-full items-center gap-2">
{editingProfile === null && (
<Check className="h-3.5 w-3.5 shrink-0" />
)}
<span className={editingProfile === null ? "" : "pl-[22px]"}>
{t("profiles.baseConfig", { ns: "views/settings" })}
</span>
</div>
</DropdownMenuItem>
{allProfileNames.length > 0 && <DropdownMenuSeparator />}
{allProfileNames.map((profile) => {
const color = getProfileColor(profile, allProfileNames);
const hasData = hasProfileData(profile);
const isActive = editingProfile === profile;
return (
<DropdownMenuItem
key={profile}
className="group flex items-start justify-between gap-2"
onClick={() => onSelectProfile(profile)}
>
<div className="flex flex-col items-center gap-2">
<div className="flex w-full flex-row items-center justify-start gap-2">
{isActive && <Check className="h-3.5 w-3.5 shrink-0" />}
<span
className={cn(
"h-2 w-2 shrink-0 rounded-full",
color.dot,
!isActive && "ml-[22px]",
)}
/>
<span>{profile}</span>
</div>
{!hasData && (
<span className="ml-[22px] text-xs text-muted-foreground">
{t("profiles.noOverrides", { ns: "views/settings" })}
</span>
)}
</div>
{hasData && (
<button
className="invisible rounded p-0.5 text-muted-foreground group-hover:visible hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
setDeleteConfirmProfile(profile);
}}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="h-9 gap-2 font-normal">
{editingProfile ? (
<>
<span
className={cn(
"h-2 w-2 shrink-0 rounded-full",
activeColor?.dot,
)}
</DropdownMenuItem>
);
})}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
setNewProfileName("");
setNameError(null);
setAddDialogOpen(true);
}}
>
<Plus className="mr-2 h-3.5 w-3.5" />
{t("profiles.addProfile", { ns: "views/settings" })}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Dialog open={addDialogOpen} onOpenChange={setAddDialogOpen}>
<DialogContent className="sm:max-w-[360px]">
<DialogHeader>
<DialogTitle>
{t("profiles.newProfile", { ns: "views/settings" })}
</DialogTitle>
</DialogHeader>
<div className="space-y-2 py-2">
<Input
placeholder={t("profiles.profileNamePlaceholder", {
ns: "views/settings",
})}
value={newProfileName}
onChange={(e) => {
setNewProfileName(e.target.value);
setNameError(validateName(e.target.value));
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleAddSubmit();
}
}}
autoFocus
/>
{nameError && (
<p className="text-xs text-destructive">{nameError}</p>
/>
{editingFriendlyName}
</>
) : (
t("profiles.baseConfig", { ns: "views/settings" })
)}
<ChevronDown className="h-3 w-3 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[180px]">
<DropdownMenuItem onClick={() => onSelectProfile(null)}>
<div className="flex w-full items-center gap-2">
{editingProfile === null && (
<Check className="h-3.5 w-3.5 shrink-0" />
)}
<span className={editingProfile === null ? "" : "pl-[22px]"}>
{t("profiles.baseConfig", { ns: "views/settings" })}
</span>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setAddDialogOpen(false)}>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="select"
onClick={handleAddSubmit}
disabled={!newProfileName.trim() || !!nameError}
>
{t("button.create", { ns: "common" })}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</DropdownMenuItem>
<AlertDialog
open={!!deleteConfirmProfile}
onOpenChange={(open) => {
if (!open) setDeleteConfirmProfile(null);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("profiles.deleteSection", { ns: "views/settings" })}
</AlertDialogTitle>
<AlertDialogDescription>
{t("profiles.deleteSectionConfirm", {
ns: "views/settings",
profile: deleteConfirmProfile,
section: friendlySectionName,
camera: friendlyCameraName,
})}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("button.cancel", { ns: "common" })}
</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-white hover:bg-destructive/90"
onClick={handleDeleteConfirm}
{allProfileNames.length > 0 && <DropdownMenuSeparator />}
{allProfileNames.map((profile) => {
const color = getProfileColor(profile, allProfileNames);
const hasData = hasProfileData(profile);
const isActive = editingProfile === profile;
return (
<DropdownMenuItem
key={profile}
className="group flex items-start justify-between gap-2"
onClick={() => onSelectProfile(profile)}
>
{t("button.delete", { ns: "common" })}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
<div className="flex flex-col items-center gap-2">
<div className="flex w-full flex-row items-center justify-start gap-2">
{isActive && <Check className="h-3.5 w-3.5 shrink-0" />}
<span
className={cn(
"h-2 w-2 shrink-0 rounded-full",
color.dot,
!isActive && "ml-[22px]",
)}
/>
<span>{profileFriendlyNames.get(profile) ?? profile}</span>
</div>
{!hasData && (
<span className="ml-[22px] text-xs text-muted-foreground">
{t("profiles.noOverrides", { ns: "views/settings" })}
</span>
)}
</div>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@ -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<string, unknown>);
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<FrigateConfig>("config");
const { data: profilesData } = useSWR<{
profiles: string[];
active_profile: string | null;
}>("profiles");
const { data: profilesData } = useSWR<ProfilesApiResponse>("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<string, unknown>
Record<string, ConfigSectionData>
>({});
// 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<string>();
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<string, string>();
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<string>();
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<string>(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<string, unknown> = {};
const deletePayload: JsonObject = {};
if (profileData.zones !== undefined) {
deletePayload.zones = "";
@ -1218,12 +1265,12 @@ export default function Settings() {
}
if (profileData.objects) {
const objDelete: Record<string, unknown> = {};
const objDelete: JsonObject = {};
if (profileData.objects.mask !== undefined) {
objDelete.mask = "";
}
if (profileData.objects.filters) {
const filtersDelete: Record<string, unknown> = {};
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}
</Badge>
)}
</div>
@ -1607,9 +1656,8 @@ export default function Settings() {
)}
{showProfileDropdown && currentSectionKey && (
<ProfileSectionDropdown
cameraName={selectedCamera}
sectionKey={currentSectionKey}
allProfileNames={allProfileNames}
profileFriendlyNames={profileFriendlyNames}
editingProfile={headerEditingProfile}
hasProfileData={headerHasProfileData}
onSelectProfile={(profile) =>
@ -1619,10 +1667,6 @@ export default function Settings() {
profile,
)
}
onAddProfile={handleAddProfile}
onDeleteProfileSection={
handleDeleteProfileForCurrentSection
}
/>
)}
<CameraSelectButton
@ -1653,6 +1697,9 @@ export default function Settings() {
pendingDataBySection={pendingDataBySection}
onPendingDataChange={handlePendingDataChange}
profileState={profileState}
onDeleteProfileSection={
handleDeleteProfileForCurrentSection
}
profilesUIEnabled={profilesUIEnabled}
setProfilesUIEnabled={setProfilesUIEnabled}
/>
@ -1771,9 +1818,8 @@ export default function Settings() {
)}
{showProfileDropdown && currentSectionKey && (
<ProfileSectionDropdown
cameraName={selectedCamera}
sectionKey={currentSectionKey}
allProfileNames={allProfileNames}
profileFriendlyNames={profileFriendlyNames}
editingProfile={headerEditingProfile}
hasProfileData={headerHasProfileData}
onSelectProfile={(profile) =>
@ -1783,8 +1829,6 @@ export default function Settings() {
profile,
)
}
onAddProfile={handleAddProfile}
onDeleteProfileSection={handleDeleteProfileForCurrentSection}
/>
)}
<CameraSelectButton
@ -1903,6 +1947,7 @@ export default function Settings() {
pendingDataBySection={pendingDataBySection}
onPendingDataChange={handlePendingDataChange}
profileState={profileState}
onDeleteProfileSection={handleDeleteProfileForCurrentSection}
profilesUIEnabled={profilesUIEnabled}
setProfilesUIEnabled={setProfilesUIEnabled}
/>

View File

@ -23,7 +23,7 @@ export type ConfigFormContext = {
extraHasChanges?: boolean;
setExtraHasChanges?: (hasChanges: boolean) => void;
formData?: JsonObject;
pendingDataBySection?: Record<string, unknown>;
pendingDataBySection?: Record<string, ConfigSectionData>;
onPendingDataChange?: (
sectionKey: string,
cameraName: string | undefined,

View File

@ -334,6 +334,10 @@ export type CameraProfileConfig = {
zones?: Partial<CameraConfig["zones"]>;
};
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;
};

View File

@ -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<string, string | null>;
newProfiles: string[];
allProfileNames: string[];
profileFriendlyNames: Map<string, string>;
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,

View File

@ -374,7 +374,7 @@ export function requiresRestartForFieldPath(
export interface SectionSavePayload {
basePath: string;
sanitizedOverrides: Record<string, unknown>;
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<string, unknown>).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<string, unknown>,
sanitizedOverrides: sanitizedOverrides as JsonObject,
updateTopic,
needsRestart,
pendingDataKey,

View File

@ -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({
<div className={`${CONTROL_COLUMN_CLASS_NAME} space-y-4`}>
<Select value={selectedProfile} onValueChange={setSelectedProfile}>
<SelectTrigger className="w-full max-w-[200px]">
<div className="flex items-center gap-2">
{profileColor && (
<span
className={cn(
"h-2 w-2 shrink-0 rounded-full",
profileColor.dot,
)}
/>
)}
<SelectValue />
</div>
<SelectValue />
</SelectTrigger>
<SelectContent>
{profileState.allProfileNames.map((profile) => {
@ -529,7 +515,8 @@ function ProfileCameraEnableSection({
color.dot,
)}
/>
{profile}
{profileState.profileFriendlyNames.get(profile) ??
profile}
</div>
</SelectItem>
);

View File

@ -1,12 +1,16 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useForm, FormProvider } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import useSWR from "swr";
import axios from "axios";
import { toast } from "sonner";
import { Trash2 } from "lucide-react";
import { Pencil, Trash2 } from "lucide-react";
import { LuChevronDown, LuChevronRight, LuPlus } from "react-icons/lu";
import type { FrigateConfig } from "@/types/frigateConfig";
import type { ProfileState } from "@/types/profile";
import type { JsonObject } from "@/types/configForm";
import type { ProfileState, ProfilesApiResponse } from "@/types/profile";
import { getProfileColor } from "@/utils/profileColors";
import { PROFILE_ELIGIBLE_SECTIONS } from "@/utils/configUtil";
import { resolveCameraName } from "@/hooks/use-camera-friendly-name";
@ -15,6 +19,7 @@ import Heading from "@/components/ui/heading";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import NameAndIdFields from "@/components/input/NameAndIdFields";
import {
Select,
SelectContent,
@ -48,11 +53,6 @@ import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import ActivityIndicator from "@/components/indicators/activity-indicator";
type ProfilesApiResponse = {
profiles: string[];
active_profile: string | null;
};
type ProfilesViewProps = {
setUnsavedChanges?: React.Dispatch<React.SetStateAction<boolean>>;
profileState?: ProfileState;
@ -74,12 +74,55 @@ export default function ProfilesView({
const [activating, setActivating] = useState(false);
const [deleteProfile, setDeleteProfile] = useState<string | null>(null);
const [deleting, setDeleting] = useState(false);
const [renameProfile, setRenameProfile] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState("");
const [renaming, setRenaming] = useState(false);
const [expandedProfiles, setExpandedProfiles] = useState<Set<string>>(
new Set(),
);
const [addDialogOpen, setAddDialogOpen] = useState(false);
const [newProfileName, setNewProfileName] = useState("");
const [nameError, setNameError] = useState<string | null>(null);
const allProfileNames = useMemo(
() => profileState?.allProfileNames ?? [],
[profileState?.allProfileNames],
);
const addProfileSchema = useMemo(
() =>
z.object({
name: z
.string()
.min(2, {
message: t("profiles.error.mustBeAtLeastTwoCharacters", {
ns: "views/settings",
}),
})
.refine((value) => !value.includes("."), {
message: t("profiles.error.mustNotContainPeriod", {
ns: "views/settings",
}),
})
.refine((value) => !allProfileNames.includes(value), {
message: t("profiles.error.alreadyExists", {
ns: "views/settings",
}),
}),
friendly_name: z.string().min(2, {
message: t("profiles.error.mustBeAtLeastTwoCharacters", {
ns: "views/settings",
}),
}),
}),
[t, allProfileNames],
);
type AddProfileForm = z.infer<typeof addProfileSchema>;
const addForm = useForm<AddProfileForm>({
resolver: zodResolver(addProfileSchema),
defaultValues: { friendly_name: "", name: "" },
});
const profileFriendlyNames = profileState?.profileFriendlyNames;
useEffect(() => {
document.title = t("documentTitle.profiles", {
@ -87,10 +130,6 @@ export default function ProfilesView({
});
}, [t]);
const allProfileNames = useMemo(
() => profileState?.allProfileNames ?? [],
[profileState?.allProfileNames],
);
const activeProfile = profilesData?.active_profile ?? null;
// Build overview data: for each profile, which cameras have which sections
@ -126,34 +165,18 @@ export default function ProfilesView({
return data;
}, [config, allProfileNames]);
const validateName = useCallback(
(name: string): string | null => {
if (!name.trim()) return null;
if (!/^[a-z0-9_]+$/.test(name)) {
return t("profiles.nameInvalid", { ns: "views/settings" });
}
if (allProfileNames.includes(name)) {
return t("profiles.nameDuplicate", { ns: "views/settings" });
}
return null;
const handleAddSubmit = useCallback(
(data: AddProfileForm) => {
const id = data.name.trim();
const friendlyName = data.friendly_name.trim();
if (!id || !friendlyName) return;
profileState?.onAddProfile(id, friendlyName);
setAddDialogOpen(false);
addForm.reset();
},
[allProfileNames, t],
[profileState, addForm],
);
const handleAddSubmit = useCallback(() => {
const name = newProfileName.trim();
if (!name) return;
const error = validateName(name);
if (error) {
setNameError(error);
return;
}
profileState?.onAddProfile(name);
setAddDialogOpen(false);
setNewProfileName("");
setNameError(null);
}, [newProfileName, validateName, profileState]);
const handleActivateProfile = useCallback(
async (profile: string | null) => {
setActivating(true);
@ -166,7 +189,7 @@ export default function ProfilesView({
profile
? t("profiles.activated", {
ns: "views/settings",
profile,
profile: profileFriendlyNames?.get(profile) ?? profile,
})
: t("profiles.deactivated", { ns: "views/settings" }),
{ position: "top-center" },
@ -184,7 +207,7 @@ export default function ProfilesView({
setActivating(false);
}
},
[updateProfiles, t],
[updateProfiles, profileFriendlyNames, t],
);
const handleDeleteProfile = useCallback(async () => {
@ -206,30 +229,38 @@ export default function ProfilesView({
await axios.put("profile/set", { profile: null });
}
// Remove the profile from all cameras via config/set
const configData: Record<string, unknown> = {};
// Remove the profile from all cameras and the top-level definition
const cameraData: JsonObject = {};
for (const camera of Object.keys(config.cameras)) {
if (config.cameras[camera]?.profiles?.[deleteProfile]) {
configData[camera] = {
cameraData[camera] = {
profiles: { [deleteProfile]: "" },
};
}
}
if (Object.keys(configData).length > 0) {
await axios.put("config/set", {
requires_restart: 0,
config_data: { cameras: configData },
});
const configData: JsonObject = {
profiles: { [deleteProfile]: "" },
};
if (Object.keys(cameraData).length > 0) {
configData.cameras = cameraData;
}
await axios.put("config/set", {
requires_restart: 0,
config_data: configData,
});
await updateConfig();
await updateProfiles();
// Also clean up local newProfiles state if this profile was in it
profileState?.onRemoveNewProfile(deleteProfile);
toast.success(
t("profiles.deleteSuccess", {
ns: "views/settings",
profile: deleteProfile,
profile: profileFriendlyNames?.get(deleteProfile) ?? deleteProfile,
}),
{ position: "top-center" },
);
@ -251,6 +282,7 @@ export default function ProfilesView({
activeProfile,
config,
profileState,
profileFriendlyNames,
updateConfig,
updateProfiles,
t,
@ -268,6 +300,40 @@ export default function ProfilesView({
});
}, []);
const handleRename = useCallback(async () => {
if (!renameProfile || !renameValue.trim()) return;
setRenaming(true);
try {
await axios.put("config/set", {
requires_restart: 0,
config_data: {
profiles: {
[renameProfile]: { friendly_name: renameValue.trim() },
},
},
});
await updateConfig();
await updateProfiles();
toast.success(
t("profiles.renameSuccess", {
ns: "views/settings",
profile: renameValue.trim(),
}),
{ position: "top-center" },
);
} catch {
toast.error(t("toast.save.error.noMessage", { ns: "common" }), {
position: "top-center",
});
} finally {
setRenaming(false);
setRenameProfile(null);
}
}, [renameProfile, renameValue, updateConfig, updateProfiles, t]);
if (!config || !profilesData) {
return null;
}
@ -336,7 +402,7 @@ export default function ProfilesView({
color.dot,
)}
/>
{profile}
{profileFriendlyNames?.get(profile) ?? profile}
</div>
</SelectItem>
);
@ -403,7 +469,23 @@ export default function ProfilesView({
color.dot,
)}
/>
<span className="font-medium">{profile}</span>
<span className="font-medium">
{profileFriendlyNames?.get(profile) ?? profile}
</span>
<Button
variant="ghost"
size="icon"
className="size-6 text-muted-foreground hover:text-primary"
onClick={(e) => {
e.stopPropagation();
setRenameProfile(profile);
setRenameValue(
profileFriendlyNames?.get(profile) ?? profile,
);
}}
>
<Pencil className="size-3" />
</Button>
{isActive && (
<Badge
variant="secondary"
@ -484,51 +566,60 @@ export default function ProfilesView({
onOpenChange={(open) => {
setAddDialogOpen(open);
if (!open) {
setNewProfileName("");
setNameError(null);
addForm.reset();
}
}}
>
<DialogContent className="sm:max-w-[360px]">
<DialogContent className="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle>
{t("profiles.newProfile", { ns: "views/settings" })}
</DialogTitle>
</DialogHeader>
<div className="space-y-2 py-2">
<Input
placeholder={t("profiles.profileNamePlaceholder", {
ns: "views/settings",
})}
value={newProfileName}
onChange={(e) => {
setNewProfileName(e.target.value);
setNameError(validateName(e.target.value));
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleAddSubmit();
}
}}
autoFocus
/>
{nameError && (
<p className="text-xs text-destructive">{nameError}</p>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setAddDialogOpen(false)}>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="select"
onClick={handleAddSubmit}
disabled={!newProfileName.trim() || !!nameError}
<FormProvider {...addForm}>
<form
onSubmit={addForm.handleSubmit(handleAddSubmit)}
className="space-y-4 py-2"
>
{t("button.add", { ns: "common" })}
</Button>
</DialogFooter>
<NameAndIdFields<AddProfileForm>
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",
})}
/>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setAddDialogOpen(false)}
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
type="submit"
variant="select"
disabled={
!addForm.watch("friendly_name").trim() ||
!addForm.watch("name").trim()
}
>
{t("button.add", { ns: "common" })}
</Button>
</DialogFooter>
</form>
</FormProvider>
</DialogContent>
</Dialog>
@ -547,7 +638,9 @@ export default function ProfilesView({
<AlertDialogDescription>
{t("profiles.deleteProfileConfirm", {
ns: "views/settings",
profile: deleteProfile,
profile: deleteProfile
? (profileFriendlyNames?.get(deleteProfile) ?? deleteProfile)
: "",
})}
</AlertDialogDescription>
</AlertDialogHeader>
@ -560,11 +653,54 @@ export default function ProfilesView({
onClick={handleDeleteProfile}
disabled={deleting}
>
{deleting && <ActivityIndicator className="mr-2 size-4" />}
{t("button.delete", { ns: "common" })}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Rename Profile Dialog */}
<Dialog
open={!!renameProfile}
onOpenChange={(open) => {
if (!open) setRenameProfile(null);
}}
>
<DialogContent className="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle>
{t("profiles.renameProfile", { ns: "views/settings" })}
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<Input
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
placeholder={t("profiles.profileNamePlaceholder", {
ns: "views/settings",
})}
/>
<DialogFooter>
<Button
variant="outline"
onClick={() => setRenameProfile(null)}
disabled={renaming}
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="select"
onClick={handleRename}
disabled={renaming || !renameValue.trim()}
>
{renaming && <ActivityIndicator className="mr-2 size-4" />}
{t("button.save", { ns: "common" })}
</Button>
</DialogFooter>
</div>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -28,13 +28,15 @@ export type SettingsPageProps = {
level: "global" | "camera",
status: SectionStatus,
) => void;
pendingDataBySection?: Record<string, unknown>;
pendingDataBySection?: Record<string, ConfigSectionData>;
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<React.SetStateAction<boolean>>;
};
@ -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({
<TooltipContent>
{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
}
/>
</div>
);