add ability to edit motion and object config for debug replay

This commit is contained in:
Josh Hawkins 2026-03-01 07:13:14 -06:00
parent 1102a4fe32
commit 119ce4b039
12 changed files with 429 additions and 109 deletions

View File

@ -49,12 +49,13 @@ from frigate.stats.prometheus import get_metrics, update_metrics
from frigate.types import JobStatusTypesEnum from frigate.types import JobStatusTypesEnum
from frigate.util.builtin import ( from frigate.util.builtin import (
clean_camera_user_pass, clean_camera_user_pass,
deep_merge,
flatten_config_data, flatten_config_data,
load_labels, load_labels,
process_config_query_string, process_config_query_string,
update_yaml_file_bulk, update_yaml_file_bulk,
) )
from frigate.util.config import find_config_file from frigate.util.config import apply_section_update, find_config_file
from frigate.util.schema import get_config_schema from frigate.util.schema import get_config_schema
from frigate.util.services import ( from frigate.util.services import (
get_nvidia_driver_info, get_nvidia_driver_info,
@ -422,9 +423,100 @@ def config_save(save_option: str, body: Any = Body(media_type="text/plain")):
) )
def _config_set_in_memory(request: Request, body: AppConfigSetBody) -> JSONResponse:
"""Apply config changes in-memory only, without writing to YAML.
Used for temporary config changes like debug replay camera tuning.
Updates the in-memory Pydantic config and publishes ZMQ updates,
bypassing YAML parsing entirely.
"""
try:
updates = {}
if body.config_data:
updates = flatten_config_data(body.config_data)
updates = {k: ("" if v is None else v) for k, v in updates.items()}
if not updates:
return JSONResponse(
content={"success": False, "message": "No configuration data provided"},
status_code=400,
)
config: FrigateConfig = request.app.frigate_config
# Group flat key paths into nested per-camera, per-section dicts
grouped: dict[str, dict[str, dict]] = {}
for key_path, value in updates.items():
parts = key_path.split(".")
if len(parts) < 3 or parts[0] != "cameras":
continue
cam, section = parts[1], parts[2]
grouped.setdefault(cam, {}).setdefault(section, {})
# Build nested dict from remaining path (e.g. "filters.person.threshold")
target = grouped[cam][section]
for part in parts[3:-1]:
target = target.setdefault(part, {})
if len(parts) > 3:
target[parts[-1]] = value
elif isinstance(value, dict):
grouped[cam][section] = deep_merge(
grouped[cam][section], value, override=True
)
else:
grouped[cam][section] = value
# Apply each section update
for cam_name, sections in grouped.items():
camera_config = config.cameras.get(cam_name)
if not camera_config:
return JSONResponse(
content={
"success": False,
"message": f"Camera '{cam_name}' not found",
},
status_code=400,
)
for section_name, update in sections.items():
err = apply_section_update(camera_config, section_name, update)
if err is not None:
return JSONResponse(
content={"success": False, "message": err},
status_code=400,
)
# Publish ZMQ updates so processing threads pick up changes
if body.update_topic and body.update_topic.startswith("config/cameras/"):
_, _, camera, field = body.update_topic.split("/")
settings = getattr(config.cameras.get(camera, None), field, None)
if settings is not None:
request.app.config_publisher.publish_update(
CameraConfigUpdateTopic(CameraConfigUpdateEnum[field], camera),
settings,
)
return JSONResponse(
content={"success": True, "message": "Config applied in-memory"},
status_code=200,
)
except Exception as e:
logger.error(f"Error applying config in-memory: {e}")
return JSONResponse(
content={"success": False, "message": "Error applying config"},
status_code=500,
)
@router.put("/config/set", dependencies=[Depends(require_role(["admin"]))]) @router.put("/config/set", dependencies=[Depends(require_role(["admin"]))])
def config_set(request: Request, body: AppConfigSetBody): def config_set(request: Request, body: AppConfigSetBody):
config_file = find_config_file() config_file = find_config_file()
if body.skip_save:
return _config_set_in_memory(request, body)
lock = FileLock(f"{config_file}.lock", timeout=5) lock = FileLock(f"{config_file}.lock", timeout=5)
try: try:

View File

@ -7,6 +7,7 @@ class AppConfigSetBody(BaseModel):
requires_restart: int = 1 requires_restart: int = 1
update_topic: str | None = None update_topic: str | None = None
config_data: Optional[Dict[str, Any]] = None config_data: Optional[Dict[str, Any]] = None
skip_save: bool = False
class AppPutPasswordBody(BaseModel): class AppPutPasswordBody(BaseModel):

View File

@ -9,6 +9,7 @@ from typing import Any, Optional, Union
from ruamel.yaml import YAML from ruamel.yaml import YAML
from frigate.const import CONFIG_DIR, EXPORT_DIR from frigate.const import CONFIG_DIR, EXPORT_DIR
from frigate.util.builtin import deep_merge
from frigate.util.services import get_video_properties from frigate.util.services import get_video_properties
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -688,3 +689,78 @@ class StreamInfoRetriever:
info = asyncio.run(get_video_properties(ffmpeg, path)) info = asyncio.run(get_video_properties(ffmpeg, path))
self.stream_cache[path] = info self.stream_cache[path] = info
return info return info
def apply_section_update(camera_config, section: str, update: dict) -> Optional[str]:
"""Merge an update dict into a camera config section and rebuild runtime variants.
For motion and object filter sections, the plain Pydantic models are rebuilt
as RuntimeMotionConfig / RuntimeFilterConfig so that rasterized numpy masks
are recomputed. This mirrors the logic in FrigateConfig.post_validation.
Args:
camera_config: The CameraConfig instance to update.
section: Config section name (e.g. "motion", "objects").
update: Nested dict of field updates to merge.
Returns:
None on success, or an error message string on failure.
"""
from frigate.config.config import RuntimeFilterConfig, RuntimeMotionConfig
current = getattr(camera_config, section, None)
if current is None:
return f"Section '{section}' not found on camera '{camera_config.name}'"
try:
frame_shape = camera_config.frame_shape
if section == "motion":
merged = deep_merge(
current.model_dump(exclude_unset=True, exclude={"rasterized_mask"}),
update,
override=True,
)
camera_config.motion = RuntimeMotionConfig(
frame_shape=frame_shape, **merged
)
elif section == "objects":
merged = deep_merge(
current.model_dump(
exclude={"filters": {"__all__": {"rasterized_mask"}}}
),
update,
override=True,
)
new_objects = current.__class__.model_validate(merged)
# Preserve private _all_objects from original config
try:
new_objects._all_objects = current._all_objects
except AttributeError:
pass
# Rebuild RuntimeFilterConfig with merged global + per-object masks
for obj_name, filt in new_objects.filters.items():
merged_mask = dict(filt.mask)
if new_objects.mask:
for gid, gmask in new_objects.mask.items():
merged_mask[f"global_{gid}"] = gmask
new_objects.filters[obj_name] = RuntimeFilterConfig(
frame_shape=frame_shape,
mask=merged_mask,
**filt.model_dump(exclude_unset=True, exclude={"mask", "raw_mask"}),
)
camera_config.objects = new_objects
else:
merged = deep_merge(current.model_dump(), update, override=True)
setattr(camera_config, section, current.__class__.model_validate(merged))
except Exception:
logger.exception("Config validation error")
return "Validation error. Check logs for details."
return None

View File

@ -117,6 +117,7 @@
"button": { "button": {
"add": "Add", "add": "Add",
"apply": "Apply", "apply": "Apply",
"applying": "Applying…",
"reset": "Reset", "reset": "Reset",
"undo": "Undo", "undo": "Undo",
"done": "Done", "done": "Done",

View File

@ -48,6 +48,7 @@
"noActivity": "No activity detected", "noActivity": "No activity detected",
"activeTracking": "Active tracking", "activeTracking": "Active tracking",
"noActiveTracking": "No active tracking", "noActiveTracking": "No active tracking",
"configuration": "Configuration" "configuration": "Configuration",
"configurationDesc": "Fine tune motion detection and object tracking settings for the debug replay camera. No changes are saved to your Frigate configuration file."
} }
} }

View File

@ -1392,6 +1392,7 @@
}, },
"toast": { "toast": {
"success": "Settings saved successfully", "success": "Settings saved successfully",
"applied": "Settings applied successfully",
"successRestartRequired": "Settings saved successfully. Restart Frigate to apply your changes.", "successRestartRequired": "Settings saved successfully. Restart Frigate to apply your changes.",
"error": "Failed to save settings", "error": "Failed to save settings",
"validationError": "Validation failed: {{message}}", "validationError": "Validation failed: {{message}}",

View File

@ -44,6 +44,30 @@ const motion: SectionConfigOverrides = {
camera: { camera: {
restartRequired: ["frame_height"], restartRequired: ["frame_height"],
}, },
replay: {
restartRequired: [],
fieldOrder: [
"threshold",
"contour_area",
"lightning_threshold",
"improve_contrast",
],
fieldGroups: {
sensitivity: ["threshold", "contour_area"],
algorithm: ["improve_contrast"],
},
hiddenFields: [
"enabled",
"enabled_in_config",
"mask",
"raw_mask",
"mqtt_off_delay",
"delta_alpha",
"frame_alpha",
"frame_height",
],
advancedFields: ["lightning_threshold"],
},
}; };
export default motion; export default motion;

View File

@ -99,6 +99,28 @@ const objects: SectionConfigOverrides = {
camera: { camera: {
restartRequired: [], restartRequired: [],
}, },
replay: {
restartRequired: [],
fieldOrder: ["track", "filters"],
fieldGroups: {
tracking: ["track"],
filtering: ["filters"],
},
hiddenFields: [
"enabled_in_config",
"alert",
"detect",
"mask",
"raw_mask",
"genai",
"genai.enabled_in_config",
"filters.*.mask",
"filters.*.raw_mask",
"filters.mask",
"filters.raw_mask",
],
advancedFields: [],
},
}; };
export default objects; export default objects;

View File

@ -4,4 +4,5 @@ export type SectionConfigOverrides = {
base?: SectionConfig; base?: SectionConfig;
global?: Partial<SectionConfig>; global?: Partial<SectionConfig>;
camera?: Partial<SectionConfig>; camera?: Partial<SectionConfig>;
replay?: Partial<SectionConfig>;
}; };

View File

@ -95,9 +95,9 @@ export interface SectionConfig {
} }
export interface BaseSectionProps { export interface BaseSectionProps {
/** Whether this is at global or camera level */ /** Whether this is at global, camera, or replay level */
level: "global" | "camera"; level: "global" | "camera" | "replay";
/** Camera name (required if level is "camera") */ /** Camera name (required if level is "camera" or "replay") */
cameraName?: string; cameraName?: string;
/** Whether to show override indicator badge */ /** Whether to show override indicator badge */
showOverrideIndicator?: boolean; showOverrideIndicator?: boolean;
@ -117,6 +117,10 @@ export interface BaseSectionProps {
defaultCollapsed?: boolean; defaultCollapsed?: boolean;
/** Whether to show the section title (default: false for global, true for camera) */ /** Whether to show the section title (default: false for global, true for camera) */
showTitle?: boolean; showTitle?: boolean;
/** If true, apply config in-memory only without writing to YAML */
skipSave?: boolean;
/** If true, buttons are not sticky at the bottom */
noStickyButtons?: boolean;
/** Callback when section status changes */ /** Callback when section status changes */
onStatusChange?: (status: { onStatusChange?: (status: {
hasChanges: boolean; hasChanges: boolean;
@ -156,12 +160,16 @@ export function ConfigSection({
collapsible = false, collapsible = false,
defaultCollapsed = true, defaultCollapsed = true,
showTitle, showTitle,
skipSave = false,
noStickyButtons = false,
onStatusChange, onStatusChange,
pendingDataBySection, pendingDataBySection,
onPendingDataChange, onPendingDataChange,
}: ConfigSectionProps) { }: ConfigSectionProps) {
// For replay level, treat as camera-level config access
const effectiveLevel = level === "replay" ? "camera" : level;
const { t, i18n } = useTranslation([ const { t, i18n } = useTranslation([
level === "camera" ? "config/cameras" : "config/global", effectiveLevel === "camera" ? "config/cameras" : "config/global",
"config/cameras", "config/cameras",
"views/settings", "views/settings",
"common", "common",
@ -174,10 +182,10 @@ export function ConfigSection({
// Create a key for this section's pending data // Create a key for this section's pending data
const pendingDataKey = useMemo( const pendingDataKey = useMemo(
() => () =>
level === "camera" && cameraName effectiveLevel === "camera" && cameraName
? `${cameraName}::${sectionPath}` ? `${cameraName}::${sectionPath}`
: sectionPath, : sectionPath,
[level, cameraName, sectionPath], [effectiveLevel, cameraName, sectionPath],
); );
// Use pending data from parent if available, otherwise use local state // Use pending data from parent if available, otherwise use local state
@ -222,20 +230,20 @@ export function ConfigSection({
const lastPendingDataKeyRef = useRef<string | null>(null); const lastPendingDataKeyRef = useRef<string | null>(null);
const updateTopic = const updateTopic =
level === "camera" && cameraName effectiveLevel === "camera" && cameraName
? cameraUpdateTopicMap[sectionPath] ? cameraUpdateTopicMap[sectionPath]
? `config/cameras/${cameraName}/${cameraUpdateTopicMap[sectionPath]}` ? `config/cameras/${cameraName}/${cameraUpdateTopicMap[sectionPath]}`
: undefined : undefined
: `config/${sectionPath}`; : `config/${sectionPath}`;
// Default: show title for camera level (since it might be collapsible), hide for global // Default: show title for camera level (since it might be collapsible), hide for global
const shouldShowTitle = showTitle ?? level === "camera"; const shouldShowTitle = showTitle ?? effectiveLevel === "camera";
// Fetch config // Fetch config
const { data: config, mutate: refreshConfig } = const { data: config, mutate: refreshConfig } =
useSWR<FrigateConfig>("config"); useSWR<FrigateConfig>("config");
// Get section schema using cached hook // Get section schema using cached hook
const sectionSchema = useSectionSchema(sectionPath, level); const sectionSchema = useSectionSchema(sectionPath, effectiveLevel);
// Apply special case handling for sections with problematic schema defaults // Apply special case handling for sections with problematic schema defaults
const modifiedSchema = useMemo( const modifiedSchema = useMemo(
@ -247,7 +255,7 @@ export function ConfigSection({
// Get override status // Get override status
const { isOverridden, globalValue, cameraValue } = useConfigOverride({ const { isOverridden, globalValue, cameraValue } = useConfigOverride({
config, config,
cameraName: level === "camera" ? cameraName : undefined, cameraName: effectiveLevel === "camera" ? cameraName : undefined,
sectionPath, sectionPath,
compareFields: sectionConfig.overrideFields, compareFields: sectionConfig.overrideFields,
}); });
@ -256,12 +264,12 @@ export function ConfigSection({
const rawSectionValue = useMemo(() => { const rawSectionValue = useMemo(() => {
if (!config) return undefined; if (!config) return undefined;
if (level === "camera" && cameraName) { if (effectiveLevel === "camera" && cameraName) {
return get(config.cameras?.[cameraName], sectionPath); return get(config.cameras?.[cameraName], sectionPath);
} }
return get(config, sectionPath); return get(config, sectionPath);
}, [config, level, cameraName, sectionPath]); }, [config, cameraName, sectionPath, effectiveLevel]);
const rawFormData = useMemo(() => { const rawFormData = useMemo(() => {
if (!config) return {}; if (!config) return {};
@ -328,9 +336,10 @@ export function ConfigSection({
[rawFormData, sanitizeSectionData], [rawFormData, sanitizeSectionData],
); );
// Clear pendingData whenever formData changes (e.g., from server refresh) // Clear pendingData whenever the section/camera key changes (e.g., switching
// This prevents RJSF's initial onChange call from being treated as a user edit // cameras) or when there is no pending data yet (initialization).
// Only clear if pendingData is managed locally (not by parent) // This prevents RJSF's initial onChange call from being treated as a user edit.
// Only clear if pendingData is managed locally (not by parent).
useEffect(() => { useEffect(() => {
const pendingKeyChanged = lastPendingDataKeyRef.current !== pendingDataKey; const pendingKeyChanged = lastPendingDataKeyRef.current !== pendingDataKey;
@ -339,15 +348,16 @@ export function ConfigSection({
isInitializingRef.current = true; isInitializingRef.current = true;
setPendingOverrides(undefined); setPendingOverrides(undefined);
setDirtyOverrides(undefined); setDirtyOverrides(undefined);
// Reset local pending data when switching sections/cameras
if (onPendingDataChange === undefined) {
setPendingData(null);
}
} else if (!pendingData) { } else if (!pendingData) {
isInitializingRef.current = true; isInitializingRef.current = true;
setPendingOverrides(undefined); setPendingOverrides(undefined);
setDirtyOverrides(undefined); setDirtyOverrides(undefined);
} }
if (onPendingDataChange === undefined) {
setPendingData(null);
}
}, [ }, [
onPendingDataChange, onPendingDataChange,
pendingData, pendingData,
@ -484,7 +494,7 @@ export function ConfigSection({
setIsSaving(true); setIsSaving(true);
try { try {
const basePath = const basePath =
level === "camera" && cameraName effectiveLevel === "camera" && cameraName
? `cameras.${cameraName}.${sectionPath}` ? `cameras.${cameraName}.${sectionPath}`
: sectionPath; : sectionPath;
const rawData = sanitizeSectionData(rawFormData); const rawData = sanitizeSectionData(rawFormData);
@ -495,7 +505,7 @@ export function ConfigSection({
); );
const sanitizedOverrides = sanitizeOverridesForSection( const sanitizedOverrides = sanitizeOverridesForSection(
sectionPath, sectionPath,
level, effectiveLevel,
overrides, overrides,
); );
@ -508,16 +518,26 @@ export function ConfigSection({
return; return;
} }
const needsRestart = requiresRestartForOverrides(sanitizedOverrides); const needsRestart = skipSave
? false
: requiresRestartForOverrides(sanitizedOverrides);
const configData = buildConfigDataForPath(basePath, sanitizedOverrides); const configData = buildConfigDataForPath(basePath, sanitizedOverrides);
await axios.put("config/set", { await axios.put("config/set", {
requires_restart: needsRestart ? 1 : 0, requires_restart: needsRestart ? 1 : 0,
update_topic: updateTopic, update_topic: updateTopic,
config_data: configData, config_data: configData,
...(skipSave ? { skip_save: true } : {}),
}); });
if (needsRestart) { if (skipSave) {
toast.success(
t("toast.applied", {
ns: "views/settings",
defaultValue: "Settings applied successfully",
}),
);
} else if (needsRestart) {
statusBar?.addMessage( statusBar?.addMessage(
"config_restart_required", "config_restart_required",
t("configForm.restartRequiredFooter", { t("configForm.restartRequiredFooter", {
@ -596,7 +616,7 @@ export function ConfigSection({
}, [ }, [
sectionPath, sectionPath,
pendingData, pendingData,
level, effectiveLevel,
cameraName, cameraName,
t, t,
refreshConfig, refreshConfig,
@ -608,15 +628,16 @@ export function ConfigSection({
updateTopic, updateTopic,
setPendingData, setPendingData,
requiresRestartForOverrides, requiresRestartForOverrides,
skipSave,
]); ]);
// Handle reset to global/defaults - removes camera-level override or resets global to defaults // Handle reset to global/defaults - removes camera-level override or resets global to defaults
const handleResetToGlobal = useCallback(async () => { const handleResetToGlobal = useCallback(async () => {
if (level === "camera" && !cameraName) return; if (effectiveLevel === "camera" && !cameraName) return;
try { try {
const basePath = const basePath =
level === "camera" && cameraName effectiveLevel === "camera" && cameraName
? `cameras.${cameraName}.${sectionPath}` ? `cameras.${cameraName}.${sectionPath}`
: sectionPath; : sectionPath;
@ -632,7 +653,7 @@ export function ConfigSection({
t("toast.resetSuccess", { t("toast.resetSuccess", {
ns: "views/settings", ns: "views/settings",
defaultValue: defaultValue:
level === "global" effectiveLevel === "global"
? "Reset to defaults" ? "Reset to defaults"
: "Reset to global defaults", : "Reset to global defaults",
}), }),
@ -651,7 +672,7 @@ export function ConfigSection({
} }
}, [ }, [
sectionPath, sectionPath,
level, effectiveLevel,
cameraName, cameraName,
requiresRestart, requiresRestart,
t, t,
@ -661,8 +682,8 @@ export function ConfigSection({
]); ]);
const sectionValidation = useMemo( const sectionValidation = useMemo(
() => getSectionValidation({ sectionPath, level, t }), () => getSectionValidation({ sectionPath, level: effectiveLevel, t }),
[sectionPath, level, t], [sectionPath, effectiveLevel, t],
); );
const customValidate = useMemo(() => { const customValidate = useMemo(() => {
@ -733,7 +754,7 @@ export function ConfigSection({
// nested under the section name (e.g., `audio.label`). For global-level // nested under the section name (e.g., `audio.label`). For global-level
// sections, keys are nested under the section name in `config/global`. // sections, keys are nested under the section name in `config/global`.
const configNamespace = const configNamespace =
level === "camera" ? "config/cameras" : "config/global"; effectiveLevel === "camera" ? "config/cameras" : "config/global";
const title = t(`${sectionPath}.label`, { const title = t(`${sectionPath}.label`, {
ns: configNamespace, ns: configNamespace,
defaultValue: defaultTitle, defaultValue: defaultTitle,
@ -769,7 +790,7 @@ export function ConfigSection({
i18nNamespace={configNamespace} i18nNamespace={configNamespace}
customValidate={customValidate} customValidate={customValidate}
formContext={{ formContext={{
level, level: effectiveLevel,
cameraName, cameraName,
globalValue, globalValue,
cameraValue, cameraValue,
@ -784,7 +805,7 @@ export function ConfigSection({
onFormDataChange: (data: ConfigSectionData) => handleChange(data), onFormDataChange: (data: ConfigSectionData) => handleChange(data),
// For widgets that need access to full camera config (e.g., zone names) // For widgets that need access to full camera config (e.g., zone names)
fullCameraConfig: fullCameraConfig:
level === "camera" && cameraName effectiveLevel === "camera" && cameraName
? config?.cameras?.[cameraName] ? config?.cameras?.[cameraName]
: undefined, : undefined,
fullConfig: config, fullConfig: config,
@ -804,7 +825,12 @@ export function ConfigSection({
}} }}
/> />
<div className="sticky bottom-0 z-50 w-full border-t border-secondary bg-background pb-5 pt-0"> <div
className={cn(
"w-full border-t border-secondary bg-background pb-5 pt-0",
!noStickyButtons && "sticky bottom-0 z-50",
)}
>
<div <div
className={cn( className={cn(
"flex flex-col items-center gap-4 pt-2 md:flex-row", "flex flex-col items-center gap-4 pt-2 md:flex-row",
@ -822,15 +848,17 @@ export function ConfigSection({
</div> </div>
)} )}
<div className="flex w-full items-center gap-2 md:w-auto"> <div className="flex w-full items-center gap-2 md:w-auto">
{((level === "camera" && isOverridden) || level === "global") && {((effectiveLevel === "camera" && isOverridden) ||
!hasChanges && ( effectiveLevel === "global") &&
!hasChanges &&
!skipSave && (
<Button <Button
onClick={() => setIsResetDialogOpen(true)} onClick={() => setIsResetDialogOpen(true)}
variant="outline" variant="outline"
disabled={isSaving || disabled} disabled={isSaving || disabled}
className="flex flex-1 gap-2" className="flex flex-1 gap-2"
> >
{level === "global" {effectiveLevel === "global"
? t("button.resetToDefault", { ? t("button.resetToDefault", {
ns: "common", ns: "common",
defaultValue: "Reset to Default", defaultValue: "Reset to Default",
@ -862,11 +890,18 @@ export function ConfigSection({
{isSaving ? ( {isSaving ? (
<> <>
<ActivityIndicator className="h-4 w-4" /> <ActivityIndicator className="h-4 w-4" />
{t("button.saving", { {skipSave
ns: "common", ? t("button.applying", {
defaultValue: "Saving...", ns: "common",
})} defaultValue: "Applying...",
})
: t("button.saving", {
ns: "common",
defaultValue: "Saving...",
})}
</> </>
) : skipSave ? (
t("button.apply", { ns: "common", defaultValue: "Apply" })
) : ( ) : (
t("button.save", { ns: "common", defaultValue: "Save" }) t("button.save", { ns: "common", defaultValue: "Save" })
)} )}
@ -898,7 +933,7 @@ export function ConfigSection({
setIsResetDialogOpen(false); setIsResetDialogOpen(false);
}} }}
> >
{level === "global" {effectiveLevel === "global"
? t("button.resetToDefault", { ns: "common" }) ? t("button.resetToDefault", { ns: "common" })
: t("button.resetToGlobal", { ns: "common" })} : t("button.resetToGlobal", { ns: "common" })}
</AlertDialogAction> </AlertDialogAction>
@ -923,7 +958,7 @@ export function ConfigSection({
)} )}
<Heading as="h4">{title}</Heading> <Heading as="h4">{title}</Heading>
{showOverrideIndicator && {showOverrideIndicator &&
level === "camera" && effectiveLevel === "camera" &&
isOverridden && ( isOverridden && (
<Badge variant="secondary" className="text-xs"> <Badge variant="secondary" className="text-xs">
{t("button.overridden", { {t("button.overridden", {
@ -967,7 +1002,7 @@ export function ConfigSection({
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Heading as="h4">{title}</Heading> <Heading as="h4">{title}</Heading>
{showOverrideIndicator && {showOverrideIndicator &&
level === "camera" && effectiveLevel === "camera" &&
isOverridden && ( isOverridden && (
<Badge <Badge
variant="secondary" variant="secondary"

View File

@ -27,6 +27,13 @@ import {
PopoverContent, PopoverContent,
PopoverTrigger, PopoverTrigger,
} from "@/components/ui/popover"; } from "@/components/ui/popover";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { useCameraActivity } from "@/hooks/use-camera-activity"; import { useCameraActivity } from "@/hooks/use-camera-activity";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import Heading from "@/components/ui/heading"; import Heading from "@/components/ui/heading";
@ -36,9 +43,13 @@ import { getIconForLabel } from "@/utils/iconUtil";
import { getTranslatedLabel } from "@/utils/i18n"; import { getTranslatedLabel } from "@/utils/i18n";
import { ObjectType } from "@/types/ws"; import { ObjectType } from "@/types/ws";
import WsMessageFeed from "@/components/ws/WsMessageFeed"; import WsMessageFeed from "@/components/ws/WsMessageFeed";
import { ConfigSectionTemplate } from "@/components/config-form/sections/ConfigSectionTemplate";
import { LuInfo } from "react-icons/lu"; import { LuInfo, LuSettings } from "react-icons/lu";
import { LuSquare } from "react-icons/lu";
import { MdReplay } from "react-icons/md"; import { MdReplay } from "react-icons/md";
import { isMobile } from "react-device-detect";
import Logo from "@/components/Logo";
type DebugReplayStatus = { type DebugReplayStatus = {
active: boolean; active: boolean;
@ -121,6 +132,7 @@ export default function Replay() {
const [options, setOptions] = useState<DebugOptions>(DEFAULT_OPTIONS); const [options, setOptions] = useState<DebugOptions>(DEFAULT_OPTIONS);
const [isStopping, setIsStopping] = useState(false); const [isStopping, setIsStopping] = useState(false);
const [configDialogOpen, setConfigDialogOpen] = useState(false);
const searchParams = useMemo(() => { const searchParams = useMemo(() => {
const params = new URLSearchParams(); const params = new URLSearchParams();
@ -238,65 +250,66 @@ export default function Replay() {
<Toaster position="top-center" closeButton={true} /> <Toaster position="top-center" closeButton={true} />
{/* Top bar */} {/* Top bar */}
<div className="flex items-center justify-between p-2 md:p-4"> <div className="flex min-h-12 items-end justify-end border-b border-secondary px-2 py-2 md:min-h-16 md:px-3 md:py-3">
<div className="flex flex-col gap-1"> {isMobile && (
<div className="flex items-center gap-2"> <Logo className="absolute inset-x-1/2 h-8 -translate-x-1/2" />
<Heading as="h3">{t("title")}</Heading> )}
</div> <div className="flex items-center gap-2">
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground"> <Button
<span> variant="outline"
{t("page.sourceCamera")}: <strong>{status.source_camera}</strong> size="sm"
</span> className="flex items-center gap-2"
{timeRangeDisplay && ( onClick={() => setConfigDialogOpen(true)}
<> >
<span className="hidden md:inline"></span> <LuSettings className="size-4" />
<span className="hidden md:inline">{timeRangeDisplay}</span> <span className="hidden md:inline">{t("page.configuration")}</span>
</> </Button>
)}
</div>
</div>
<AlertDialog> <AlertDialog>
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<Button <Button
variant="destructive" variant="destructive"
size="sm" size="sm"
className="flex items-center gap-2 text-white" className="flex items-center gap-2 text-white"
disabled={isStopping} disabled={isStopping}
>
{isStopping && <ActivityIndicator className="size-4" />}
{t("page.stopReplay")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("page.confirmStop.title")}</AlertDialogTitle>
<AlertDialogDescription>
{t("page.confirmStop.description")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("page.confirmStop.cancel")}
</AlertDialogCancel>
<AlertDialogAction
onClick={handleStop}
className={cn(
buttonVariants({ variant: "destructive" }),
"text-white",
)}
> >
{t("page.confirmStop.confirm")} {isStopping && <ActivityIndicator className="size-4" />}
</AlertDialogAction> <span className="hidden md:inline">{t("page.stopReplay")}</span>
</AlertDialogFooter> <LuSquare className="size-4 md:hidden" />
</AlertDialogContent> </Button>
</AlertDialog> </AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("page.confirmStop.title")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("page.confirmStop.description")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("page.confirmStop.cancel")}
</AlertDialogCancel>
<AlertDialogAction
onClick={handleStop}
className={cn(
buttonVariants({ variant: "destructive" }),
"text-white",
)}
>
{t("page.confirmStop.confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div> </div>
{/* Main content */} {/* Main content */}
<div className="mt-1 flex flex-1 flex-col overflow-hidden pb-2 md:flex-row"> <div className="flex flex-1 flex-col overflow-hidden pb-2 md:flex-row">
{/* Camera feed */} {/* Camera feed */}
<div className="flex max-h-[40%] px-2 md:h-dvh md:max-h-full md:w-7/12 md:grow md:px-4"> <div className="flex max-h-[40%] px-2 pt-2 md:h-dvh md:max-h-full md:w-7/12 md:grow md:px-4 md:pt-2">
{isStopping ? ( {isStopping ? (
<div className="flex size-full items-center justify-center rounded-lg bg-background_alt"> <div className="flex size-full items-center justify-center rounded-lg bg-background_alt">
<div className="flex flex-col items-center justify-center gap-2"> <div className="flex flex-col items-center justify-center gap-2">
@ -334,11 +347,22 @@ export default function Replay() {
{/* Side panel */} {/* Side panel */}
<div className="scrollbar-container order-last mb-2 mt-2 flex h-full w-full flex-col overflow-y-auto rounded-lg border-[1px] border-secondary-foreground bg-background_alt p-2 md:order-none md:mb-0 md:mr-2 md:mt-0 md:w-4/12"> <div className="scrollbar-container order-last mb-2 mt-2 flex h-full w-full flex-col overflow-y-auto rounded-lg border-[1px] border-secondary-foreground bg-background_alt p-2 md:order-none md:mb-0 md:mr-2 md:mt-0 md:w-4/12">
<Heading as="h4" className="mb-2"> <div className="mb-5 flex flex-col space-y-2">
{t("title")} <Heading as="h3" className="mb-0">
</Heading> {t("title")}
<div className="mb-5 space-y-3 text-sm text-muted-foreground"> </Heading>
<p>{t("description")}</p> <div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span className="smart-capitalize">{status.source_camera}</span>
{timeRangeDisplay && (
<>
<span className="hidden md:inline"></span>
<span className="hidden md:inline">{timeRangeDisplay}</span>
</>
)}
</div>
<div className="mb-5 space-y-3 text-sm text-muted-foreground">
<p>{t("description")}</p>
</div>
</div> </div>
<Tabs defaultValue="debug" className="flex h-full w-full flex-col"> <Tabs defaultValue="debug" className="flex h-full w-full flex-col">
<TabsList className="grid w-full grid-cols-3"> <TabsList className="grid w-full grid-cols-3">
@ -444,7 +468,7 @@ export default function Replay() {
> >
<div className="flex h-full flex-col overflow-hidden rounded-md border border-secondary"> <div className="flex h-full flex-col overflow-hidden rounded-md border border-secondary">
<WsMessageFeed <WsMessageFeed
maxSize={200} maxSize={2000}
lockedCamera={status.replay_camera ?? undefined} lockedCamera={status.replay_camera ?? undefined}
showCameraBadge={false} showCameraBadge={false}
/> />
@ -453,6 +477,43 @@ export default function Replay() {
</Tabs> </Tabs>
</div> </div>
</div> </div>
<Dialog open={configDialogOpen} onOpenChange={setConfigDialogOpen}>
<DialogContent className="scrollbar-container max-h-[90dvh] overflow-y-auto sm:max-w-xl md:max-w-3xl lg:max-w-4xl">
<DialogHeader>
<DialogTitle>{t("page.configuration")}</DialogTitle>
<DialogDescription className="mb-5">
{t("page.configurationDesc")}
</DialogDescription>
</DialogHeader>
<div className="space-y-6">
<ConfigSectionTemplate
sectionKey="motion"
level="replay"
cameraName={status.replay_camera ?? undefined}
skipSave
noStickyButtons
requiresRestart={false}
collapsible
defaultCollapsed={false}
showTitle
showOverrideIndicator={false}
/>
<ConfigSectionTemplate
sectionKey="objects"
level="replay"
cameraName={status.replay_camera ?? undefined}
skipSave
noStickyButtons
requiresRestart={false}
collapsible
defaultCollapsed={false}
showTitle
showOverrideIndicator={false}
/>
</div>
</DialogContent>
</Dialog>
</div> </div>
); );
} }
@ -507,7 +568,7 @@ function ObjectList({ cameraConfig, objects, config }: ObjectListProps) {
: getColorForObjectName(obj.label), : getColorForObjectName(obj.label),
}} }}
> >
{getIconForLabel(obj.label, "size-4 text-white")} {getIconForLabel(obj.label, "object", "size-4 text-white")}
</div> </div>
<div className="text-sm font-medium"> <div className="text-sm font-medium">
{getTranslatedLabel(obj.label)} {getTranslatedLabel(obj.label)}

View File

@ -514,13 +514,18 @@ const mergeSectionConfig = (
export function getSectionConfig( export function getSectionConfig(
sectionKey: string, sectionKey: string,
level: "global" | "camera", level: "global" | "camera" | "replay",
): SectionConfig { ): SectionConfig {
const entry = sectionConfigs[sectionKey]; const entry = sectionConfigs[sectionKey];
if (!entry) { if (!entry) {
return {}; return {};
} }
const overrides = level === "global" ? entry.global : entry.camera; const overrides =
level === "global"
? entry.global
: level === "replay"
? entry.replay
: entry.camera;
return mergeSectionConfig(entry.base, overrides); return mergeSectionConfig(entry.base, overrides);
} }