diff --git a/web/src/types/frigateConfig.ts b/web/src/types/frigateConfig.ts index dcf3c312f..ffb86217e 100644 --- a/web/src/types/frigateConfig.ts +++ b/web/src/types/frigateConfig.ts @@ -305,8 +305,23 @@ export interface CameraConfig { friendly_name?: string; }; }; + profiles?: Record; } +export type CameraProfileConfig = { + enabled?: boolean; + audio?: Partial; + birdseye?: Partial; + detect?: Partial; + motion?: Partial; + notifications?: Partial; + objects?: Partial; + record?: Partial; + review?: Partial; + snapshots?: Partial; + zones?: Partial; +}; + export type CameraGroupConfig = { cameras: string[]; icon: IconName; diff --git a/web/src/types/profile.ts b/web/src/types/profile.ts new file mode 100644 index 000000000..2c96e51da --- /dev/null +++ b/web/src/types/profile.ts @@ -0,0 +1,23 @@ +export type ProfileColor = { + bg: string; + text: string; + dot: string; + bgMuted: string; +}; + +export type ProfileState = { + editingProfile: Record; + newProfiles: string[]; + allProfileNames: string[]; + onSelectProfile: ( + camera: string, + section: string, + profile: string | null, + ) => void; + onAddProfile: (name: string) => void; + onDeleteProfileSection: ( + camera: string, + section: string, + profile: string, + ) => void; +}; diff --git a/web/src/utils/configUtil.ts b/web/src/utils/configUtil.ts index 216ade9fa..5320fca69 100644 --- a/web/src/utils/configUtil.ts +++ b/web/src/utils/configUtil.ts @@ -68,6 +68,42 @@ export const globalCameraDefaultSections = new Set([ "ffmpeg", ]); +// --------------------------------------------------------------------------- +// Profile helpers +// --------------------------------------------------------------------------- + +/** Sections that can appear inside a camera profile definition. */ +export const PROFILE_ELIGIBLE_SECTIONS = new Set([ + "audio", + "birdseye", + "detect", + "motion", + "notifications", + "objects", + "record", + "review", + "snapshots", +]); + +/** + * Parse a section path that may encode a profile reference. + * + * Examples: + * "detect" → { isProfile: false, actualSection: "detect" } + * "profiles.armed.detect" → { isProfile: true, profileName: "armed", actualSection: "detect" } + */ +export function parseProfileFromSectionPath(sectionPath: string): { + isProfile: boolean; + profileName?: string; + actualSection: string; +} { + const match = sectionPath.match(/^profiles\.([^.]+)\.(.+)$/); + if (match) { + return { isProfile: true, profileName: match[1], actualSection: match[2] }; + } + return { isProfile: false, actualSection: sectionPath }; +} + // --------------------------------------------------------------------------- // buildOverrides — pure recursive diff of current vs stored config & defaults // --------------------------------------------------------------------------- @@ -421,15 +457,19 @@ export function prepareSectionSavePayload(opts: { level = "global"; } - // Resolve section config - const sectionConfig = getSectionConfig(sectionPath, level); + // Detect profile-encoded section paths (e.g., "profiles.armed.detect") + const profileInfo = parseProfileFromSectionPath(sectionPath); + const schemaSection = profileInfo.actualSection; - // Resolve section schema - const sectionSchema = extractSectionSchema(fullSchema, sectionPath, level); + // Resolve section config using the actual section name (not the profile path) + const sectionConfig = getSectionConfig(schemaSection, level); + + // Resolve section schema using the actual section name + const sectionSchema = extractSectionSchema(fullSchema, schemaSection, level); if (!sectionSchema) return null; const modifiedSchema = modifySchemaForSection( - sectionPath, + schemaSection, level, sectionSchema, ); @@ -457,7 +497,7 @@ export function prepareSectionSavePayload(opts: { ? applySchemaDefaults(modifiedSchema, {}) : {}; const effectiveDefaults = getEffectiveDefaultsForSection( - sectionPath, + schemaSection, level, modifiedSchema ?? undefined, schemaDefaults, @@ -466,7 +506,7 @@ export function prepareSectionSavePayload(opts: { // Build overrides const overrides = buildOverrides(pendingData, rawData, effectiveDefaults); const sanitizedOverrides = sanitizeOverridesForSection( - sectionPath, + schemaSection, level, overrides, ); @@ -485,9 +525,11 @@ export function prepareSectionSavePayload(opts: { ? `cameras.${cameraName}.${sectionPath}` : sectionPath; - // Compute updateTopic + // Compute updateTopic — profile definitions don't trigger hot-reload let updateTopic: string | undefined; - if (level === "camera" && cameraName) { + if (profileInfo.isProfile) { + updateTopic = undefined; + } else if (level === "camera" && cameraName) { const topic = cameraUpdateTopicMap[sectionPath]; updateTopic = topic ? `config/cameras/${cameraName}/${topic}` : undefined; } else if (globalCameraDefaultSections.has(sectionPath)) { @@ -497,12 +539,14 @@ export function prepareSectionSavePayload(opts: { updateTopic = `config/${sectionPath}`; } - // Restart detection - const needsRestart = requiresRestartForOverrides( - sanitizedOverrides, - sectionConfig.restartRequired, - true, - ); + // Restart detection — profile definitions never need restart + const needsRestart = profileInfo.isProfile + ? false + : requiresRestartForOverrides( + sanitizedOverrides, + sectionConfig.restartRequired, + true, + ); return { basePath, diff --git a/web/src/utils/profileColors.ts b/web/src/utils/profileColors.ts new file mode 100644 index 000000000..9a374cd02 --- /dev/null +++ b/web/src/utils/profileColors.ts @@ -0,0 +1,67 @@ +import type { ProfileColor } from "@/types/profile"; + +const PROFILE_COLORS: ProfileColor[] = [ + { + bg: "bg-blue-500", + text: "text-blue-500", + dot: "bg-blue-500", + bgMuted: "bg-blue-500/20", + }, + { + bg: "bg-emerald-500", + text: "text-emerald-500", + dot: "bg-emerald-500", + bgMuted: "bg-emerald-500/20", + }, + { + bg: "bg-amber-500", + text: "text-amber-500", + dot: "bg-amber-500", + bgMuted: "bg-amber-500/20", + }, + { + bg: "bg-purple-500", + text: "text-purple-500", + dot: "bg-purple-500", + bgMuted: "bg-purple-500/20", + }, + { + bg: "bg-rose-500", + text: "text-rose-500", + dot: "bg-rose-500", + bgMuted: "bg-rose-500/20", + }, + { + bg: "bg-cyan-500", + text: "text-cyan-500", + dot: "bg-cyan-500", + bgMuted: "bg-cyan-500/20", + }, + { + bg: "bg-orange-500", + text: "text-orange-500", + dot: "bg-orange-500", + bgMuted: "bg-orange-500/20", + }, + { + bg: "bg-teal-500", + text: "text-teal-500", + dot: "bg-teal-500", + bgMuted: "bg-teal-500/20", + }, +]; + +/** + * Get a deterministic color for a profile name. + * + * Colors are assigned based on sorted position among all profile names, + * so the same profile always gets the same color regardless of context. + */ +export function getProfileColor( + profileName: string, + allProfileNames: string[], +): ProfileColor { + const sorted = [...allProfileNames].sort(); + const index = sorted.indexOf(profileName); + return PROFILE_COLORS[(index >= 0 ? index : 0) % PROFILE_COLORS.length]; +}