mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-03-30 11:54:52 +03:00
add ability to edit motion and object config for debug replay
This commit is contained in:
parent
1102a4fe32
commit
119ce4b039
@ -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:
|
||||||
|
|||||||
@ -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):
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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}}",
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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>;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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)}
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user