add frontend profile types, color utility, and config save support

This commit is contained in:
Josh Hawkins 2026-03-09 15:00:25 -05:00
parent 60930e50c2
commit 72b4a4ddad
4 changed files with 164 additions and 15 deletions

View File

@ -305,8 +305,23 @@ export interface CameraConfig {
friendly_name?: string;
};
};
profiles?: Record<string, CameraProfileConfig>;
}
export type CameraProfileConfig = {
enabled?: boolean;
audio?: Partial<CameraConfig["audio"]>;
birdseye?: Partial<CameraConfig["birdseye"]>;
detect?: Partial<CameraConfig["detect"]>;
motion?: Partial<CameraConfig["motion"]>;
notifications?: Partial<CameraConfig["notifications"]>;
objects?: Partial<CameraConfig["objects"]>;
record?: Partial<CameraConfig["record"]>;
review?: Partial<CameraConfig["review"]>;
snapshots?: Partial<CameraConfig["snapshots"]>;
zones?: Partial<CameraConfig["zones"]>;
};
export type CameraGroupConfig = {
cameras: string[];
icon: IconName;

23
web/src/types/profile.ts Normal file
View File

@ -0,0 +1,23 @@
export type ProfileColor = {
bg: string;
text: string;
dot: string;
bgMuted: string;
};
export type ProfileState = {
editingProfile: Record<string, string | null>;
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;
};

View File

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

View File

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