mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-03-10 10:33:11 +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.util.builtin import (
|
||||
clean_camera_user_pass,
|
||||
deep_merge,
|
||||
flatten_config_data,
|
||||
load_labels,
|
||||
process_config_query_string,
|
||||
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.services import (
|
||||
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"]))])
|
||||
def config_set(request: Request, body: AppConfigSetBody):
|
||||
config_file = find_config_file()
|
||||
|
||||
if body.skip_save:
|
||||
return _config_set_in_memory(request, body)
|
||||
|
||||
lock = FileLock(f"{config_file}.lock", timeout=5)
|
||||
|
||||
try:
|
||||
|
||||
@ -7,6 +7,7 @@ class AppConfigSetBody(BaseModel):
|
||||
requires_restart: int = 1
|
||||
update_topic: str | None = None
|
||||
config_data: Optional[Dict[str, Any]] = None
|
||||
skip_save: bool = False
|
||||
|
||||
|
||||
class AppPutPasswordBody(BaseModel):
|
||||
|
||||
@ -9,6 +9,7 @@ from typing import Any, Optional, Union
|
||||
from ruamel.yaml import YAML
|
||||
|
||||
from frigate.const import CONFIG_DIR, EXPORT_DIR
|
||||
from frigate.util.builtin import deep_merge
|
||||
from frigate.util.services import get_video_properties
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -688,3 +689,78 @@ class StreamInfoRetriever:
|
||||
info = asyncio.run(get_video_properties(ffmpeg, path))
|
||||
self.stream_cache[path] = 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": {
|
||||
"add": "Add",
|
||||
"apply": "Apply",
|
||||
"applying": "Applying…",
|
||||
"reset": "Reset",
|
||||
"undo": "Undo",
|
||||
"done": "Done",
|
||||
|
||||
@ -48,6 +48,7 @@
|
||||
"noActivity": "No activity detected",
|
||||
"activeTracking": "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": {
|
||||
"success": "Settings saved successfully",
|
||||
"applied": "Settings applied successfully",
|
||||
"successRestartRequired": "Settings saved successfully. Restart Frigate to apply your changes.",
|
||||
"error": "Failed to save settings",
|
||||
"validationError": "Validation failed: {{message}}",
|
||||
|
||||
@ -44,6 +44,30 @@ const motion: SectionConfigOverrides = {
|
||||
camera: {
|
||||
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;
|
||||
|
||||
@ -99,6 +99,28 @@ const objects: SectionConfigOverrides = {
|
||||
camera: {
|
||||
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;
|
||||
|
||||
@ -4,4 +4,5 @@ export type SectionConfigOverrides = {
|
||||
base?: SectionConfig;
|
||||
global?: Partial<SectionConfig>;
|
||||
camera?: Partial<SectionConfig>;
|
||||
replay?: Partial<SectionConfig>;
|
||||
};
|
||||
|
||||
@ -95,9 +95,9 @@ export interface SectionConfig {
|
||||
}
|
||||
|
||||
export interface BaseSectionProps {
|
||||
/** Whether this is at global or camera level */
|
||||
level: "global" | "camera";
|
||||
/** Camera name (required if level is "camera") */
|
||||
/** Whether this is at global, camera, or replay level */
|
||||
level: "global" | "camera" | "replay";
|
||||
/** Camera name (required if level is "camera" or "replay") */
|
||||
cameraName?: string;
|
||||
/** Whether to show override indicator badge */
|
||||
showOverrideIndicator?: boolean;
|
||||
@ -117,6 +117,10 @@ export interface BaseSectionProps {
|
||||
defaultCollapsed?: boolean;
|
||||
/** Whether to show the section title (default: false for global, true for camera) */
|
||||
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 */
|
||||
onStatusChange?: (status: {
|
||||
hasChanges: boolean;
|
||||
@ -156,12 +160,16 @@ export function ConfigSection({
|
||||
collapsible = false,
|
||||
defaultCollapsed = true,
|
||||
showTitle,
|
||||
skipSave = false,
|
||||
noStickyButtons = false,
|
||||
onStatusChange,
|
||||
pendingDataBySection,
|
||||
onPendingDataChange,
|
||||
}: ConfigSectionProps) {
|
||||
// For replay level, treat as camera-level config access
|
||||
const effectiveLevel = level === "replay" ? "camera" : level;
|
||||
const { t, i18n } = useTranslation([
|
||||
level === "camera" ? "config/cameras" : "config/global",
|
||||
effectiveLevel === "camera" ? "config/cameras" : "config/global",
|
||||
"config/cameras",
|
||||
"views/settings",
|
||||
"common",
|
||||
@ -174,10 +182,10 @@ export function ConfigSection({
|
||||
// Create a key for this section's pending data
|
||||
const pendingDataKey = useMemo(
|
||||
() =>
|
||||
level === "camera" && cameraName
|
||||
effectiveLevel === "camera" && cameraName
|
||||
? `${cameraName}::${sectionPath}`
|
||||
: sectionPath,
|
||||
[level, cameraName, sectionPath],
|
||||
[effectiveLevel, cameraName, sectionPath],
|
||||
);
|
||||
|
||||
// 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 updateTopic =
|
||||
level === "camera" && cameraName
|
||||
effectiveLevel === "camera" && cameraName
|
||||
? cameraUpdateTopicMap[sectionPath]
|
||||
? `config/cameras/${cameraName}/${cameraUpdateTopicMap[sectionPath]}`
|
||||
: undefined
|
||||
: `config/${sectionPath}`;
|
||||
// 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
|
||||
const { data: config, mutate: refreshConfig } =
|
||||
useSWR<FrigateConfig>("config");
|
||||
|
||||
// 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
|
||||
const modifiedSchema = useMemo(
|
||||
@ -247,7 +255,7 @@ export function ConfigSection({
|
||||
// Get override status
|
||||
const { isOverridden, globalValue, cameraValue } = useConfigOverride({
|
||||
config,
|
||||
cameraName: level === "camera" ? cameraName : undefined,
|
||||
cameraName: effectiveLevel === "camera" ? cameraName : undefined,
|
||||
sectionPath,
|
||||
compareFields: sectionConfig.overrideFields,
|
||||
});
|
||||
@ -256,12 +264,12 @@ export function ConfigSection({
|
||||
const rawSectionValue = useMemo(() => {
|
||||
if (!config) return undefined;
|
||||
|
||||
if (level === "camera" && cameraName) {
|
||||
if (effectiveLevel === "camera" && cameraName) {
|
||||
return get(config.cameras?.[cameraName], sectionPath);
|
||||
}
|
||||
|
||||
return get(config, sectionPath);
|
||||
}, [config, level, cameraName, sectionPath]);
|
||||
}, [config, cameraName, sectionPath, effectiveLevel]);
|
||||
|
||||
const rawFormData = useMemo(() => {
|
||||
if (!config) return {};
|
||||
@ -328,9 +336,10 @@ export function ConfigSection({
|
||||
[rawFormData, sanitizeSectionData],
|
||||
);
|
||||
|
||||
// Clear pendingData whenever formData changes (e.g., from server refresh)
|
||||
// This prevents RJSF's initial onChange call from being treated as a user edit
|
||||
// Only clear if pendingData is managed locally (not by parent)
|
||||
// Clear pendingData whenever the section/camera key changes (e.g., switching
|
||||
// cameras) or when there is no pending data yet (initialization).
|
||||
// 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(() => {
|
||||
const pendingKeyChanged = lastPendingDataKeyRef.current !== pendingDataKey;
|
||||
|
||||
@ -339,15 +348,16 @@ export function ConfigSection({
|
||||
isInitializingRef.current = true;
|
||||
setPendingOverrides(undefined);
|
||||
setDirtyOverrides(undefined);
|
||||
|
||||
// Reset local pending data when switching sections/cameras
|
||||
if (onPendingDataChange === undefined) {
|
||||
setPendingData(null);
|
||||
}
|
||||
} else if (!pendingData) {
|
||||
isInitializingRef.current = true;
|
||||
setPendingOverrides(undefined);
|
||||
setDirtyOverrides(undefined);
|
||||
}
|
||||
|
||||
if (onPendingDataChange === undefined) {
|
||||
setPendingData(null);
|
||||
}
|
||||
}, [
|
||||
onPendingDataChange,
|
||||
pendingData,
|
||||
@ -484,7 +494,7 @@ export function ConfigSection({
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const basePath =
|
||||
level === "camera" && cameraName
|
||||
effectiveLevel === "camera" && cameraName
|
||||
? `cameras.${cameraName}.${sectionPath}`
|
||||
: sectionPath;
|
||||
const rawData = sanitizeSectionData(rawFormData);
|
||||
@ -495,7 +505,7 @@ export function ConfigSection({
|
||||
);
|
||||
const sanitizedOverrides = sanitizeOverridesForSection(
|
||||
sectionPath,
|
||||
level,
|
||||
effectiveLevel,
|
||||
overrides,
|
||||
);
|
||||
|
||||
@ -508,16 +518,26 @@ export function ConfigSection({
|
||||
return;
|
||||
}
|
||||
|
||||
const needsRestart = requiresRestartForOverrides(sanitizedOverrides);
|
||||
const needsRestart = skipSave
|
||||
? false
|
||||
: requiresRestartForOverrides(sanitizedOverrides);
|
||||
|
||||
const configData = buildConfigDataForPath(basePath, sanitizedOverrides);
|
||||
await axios.put("config/set", {
|
||||
requires_restart: needsRestart ? 1 : 0,
|
||||
update_topic: updateTopic,
|
||||
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(
|
||||
"config_restart_required",
|
||||
t("configForm.restartRequiredFooter", {
|
||||
@ -596,7 +616,7 @@ export function ConfigSection({
|
||||
}, [
|
||||
sectionPath,
|
||||
pendingData,
|
||||
level,
|
||||
effectiveLevel,
|
||||
cameraName,
|
||||
t,
|
||||
refreshConfig,
|
||||
@ -608,15 +628,16 @@ export function ConfigSection({
|
||||
updateTopic,
|
||||
setPendingData,
|
||||
requiresRestartForOverrides,
|
||||
skipSave,
|
||||
]);
|
||||
|
||||
// Handle reset to global/defaults - removes camera-level override or resets global to defaults
|
||||
const handleResetToGlobal = useCallback(async () => {
|
||||
if (level === "camera" && !cameraName) return;
|
||||
if (effectiveLevel === "camera" && !cameraName) return;
|
||||
|
||||
try {
|
||||
const basePath =
|
||||
level === "camera" && cameraName
|
||||
effectiveLevel === "camera" && cameraName
|
||||
? `cameras.${cameraName}.${sectionPath}`
|
||||
: sectionPath;
|
||||
|
||||
@ -632,7 +653,7 @@ export function ConfigSection({
|
||||
t("toast.resetSuccess", {
|
||||
ns: "views/settings",
|
||||
defaultValue:
|
||||
level === "global"
|
||||
effectiveLevel === "global"
|
||||
? "Reset to defaults"
|
||||
: "Reset to global defaults",
|
||||
}),
|
||||
@ -651,7 +672,7 @@ export function ConfigSection({
|
||||
}
|
||||
}, [
|
||||
sectionPath,
|
||||
level,
|
||||
effectiveLevel,
|
||||
cameraName,
|
||||
requiresRestart,
|
||||
t,
|
||||
@ -661,8 +682,8 @@ export function ConfigSection({
|
||||
]);
|
||||
|
||||
const sectionValidation = useMemo(
|
||||
() => getSectionValidation({ sectionPath, level, t }),
|
||||
[sectionPath, level, t],
|
||||
() => getSectionValidation({ sectionPath, level: effectiveLevel, t }),
|
||||
[sectionPath, effectiveLevel, t],
|
||||
);
|
||||
|
||||
const customValidate = useMemo(() => {
|
||||
@ -733,7 +754,7 @@ export function ConfigSection({
|
||||
// nested under the section name (e.g., `audio.label`). For global-level
|
||||
// sections, keys are nested under the section name in `config/global`.
|
||||
const configNamespace =
|
||||
level === "camera" ? "config/cameras" : "config/global";
|
||||
effectiveLevel === "camera" ? "config/cameras" : "config/global";
|
||||
const title = t(`${sectionPath}.label`, {
|
||||
ns: configNamespace,
|
||||
defaultValue: defaultTitle,
|
||||
@ -769,7 +790,7 @@ export function ConfigSection({
|
||||
i18nNamespace={configNamespace}
|
||||
customValidate={customValidate}
|
||||
formContext={{
|
||||
level,
|
||||
level: effectiveLevel,
|
||||
cameraName,
|
||||
globalValue,
|
||||
cameraValue,
|
||||
@ -784,7 +805,7 @@ export function ConfigSection({
|
||||
onFormDataChange: (data: ConfigSectionData) => handleChange(data),
|
||||
// For widgets that need access to full camera config (e.g., zone names)
|
||||
fullCameraConfig:
|
||||
level === "camera" && cameraName
|
||||
effectiveLevel === "camera" && cameraName
|
||||
? config?.cameras?.[cameraName]
|
||||
: undefined,
|
||||
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
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-4 pt-2 md:flex-row",
|
||||
@ -822,15 +848,17 @@ export function ConfigSection({
|
||||
</div>
|
||||
)}
|
||||
<div className="flex w-full items-center gap-2 md:w-auto">
|
||||
{((level === "camera" && isOverridden) || level === "global") &&
|
||||
!hasChanges && (
|
||||
{((effectiveLevel === "camera" && isOverridden) ||
|
||||
effectiveLevel === "global") &&
|
||||
!hasChanges &&
|
||||
!skipSave && (
|
||||
<Button
|
||||
onClick={() => setIsResetDialogOpen(true)}
|
||||
variant="outline"
|
||||
disabled={isSaving || disabled}
|
||||
className="flex flex-1 gap-2"
|
||||
>
|
||||
{level === "global"
|
||||
{effectiveLevel === "global"
|
||||
? t("button.resetToDefault", {
|
||||
ns: "common",
|
||||
defaultValue: "Reset to Default",
|
||||
@ -862,11 +890,18 @@ export function ConfigSection({
|
||||
{isSaving ? (
|
||||
<>
|
||||
<ActivityIndicator className="h-4 w-4" />
|
||||
{t("button.saving", {
|
||||
ns: "common",
|
||||
defaultValue: "Saving...",
|
||||
})}
|
||||
{skipSave
|
||||
? t("button.applying", {
|
||||
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" })
|
||||
)}
|
||||
@ -898,7 +933,7 @@ export function ConfigSection({
|
||||
setIsResetDialogOpen(false);
|
||||
}}
|
||||
>
|
||||
{level === "global"
|
||||
{effectiveLevel === "global"
|
||||
? t("button.resetToDefault", { ns: "common" })
|
||||
: t("button.resetToGlobal", { ns: "common" })}
|
||||
</AlertDialogAction>
|
||||
@ -923,7 +958,7 @@ export function ConfigSection({
|
||||
)}
|
||||
<Heading as="h4">{title}</Heading>
|
||||
{showOverrideIndicator &&
|
||||
level === "camera" &&
|
||||
effectiveLevel === "camera" &&
|
||||
isOverridden && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{t("button.overridden", {
|
||||
@ -967,7 +1002,7 @@ export function ConfigSection({
|
||||
<div className="flex items-center gap-3">
|
||||
<Heading as="h4">{title}</Heading>
|
||||
{showOverrideIndicator &&
|
||||
level === "camera" &&
|
||||
effectiveLevel === "camera" &&
|
||||
isOverridden && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
|
||||
@ -27,6 +27,13 @@ import {
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useCameraActivity } from "@/hooks/use-camera-activity";
|
||||
import { cn } from "@/lib/utils";
|
||||
import Heading from "@/components/ui/heading";
|
||||
@ -36,9 +43,13 @@ import { getIconForLabel } from "@/utils/iconUtil";
|
||||
import { getTranslatedLabel } from "@/utils/i18n";
|
||||
import { ObjectType } from "@/types/ws";
|
||||
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 { isMobile } from "react-device-detect";
|
||||
import Logo from "@/components/Logo";
|
||||
|
||||
type DebugReplayStatus = {
|
||||
active: boolean;
|
||||
@ -121,6 +132,7 @@ export default function Replay() {
|
||||
|
||||
const [options, setOptions] = useState<DebugOptions>(DEFAULT_OPTIONS);
|
||||
const [isStopping, setIsStopping] = useState(false);
|
||||
const [configDialogOpen, setConfigDialogOpen] = useState(false);
|
||||
|
||||
const searchParams = useMemo(() => {
|
||||
const params = new URLSearchParams();
|
||||
@ -238,65 +250,66 @@ export default function Replay() {
|
||||
<Toaster position="top-center" closeButton={true} />
|
||||
|
||||
{/* Top bar */}
|
||||
<div className="flex items-center justify-between p-2 md:p-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Heading as="h3">{t("title")}</Heading>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>
|
||||
{t("page.sourceCamera")}: <strong>{status.source_camera}</strong>
|
||||
</span>
|
||||
{timeRangeDisplay && (
|
||||
<>
|
||||
<span className="hidden md:inline">•</span>
|
||||
<span className="hidden md:inline">{timeRangeDisplay}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
{isMobile && (
|
||||
<Logo className="absolute inset-x-1/2 h-8 -translate-x-1/2" />
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => setConfigDialogOpen(true)}
|
||||
>
|
||||
<LuSettings className="size-4" />
|
||||
<span className="hidden md:inline">{t("page.configuration")}</span>
|
||||
</Button>
|
||||
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="flex items-center gap-2 text-white"
|
||||
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",
|
||||
)}
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="flex items-center gap-2 text-white"
|
||||
disabled={isStopping}
|
||||
>
|
||||
{t("page.confirmStop.confirm")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
{isStopping && <ActivityIndicator className="size-4" />}
|
||||
<span className="hidden md:inline">{t("page.stopReplay")}</span>
|
||||
<LuSquare className="size-4 md:hidden" />
|
||||
</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")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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 */}
|
||||
<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 ? (
|
||||
<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">
|
||||
@ -334,11 +347,22 @@ export default function Replay() {
|
||||
|
||||
{/* 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">
|
||||
<Heading as="h4" className="mb-2">
|
||||
{t("title")}
|
||||
</Heading>
|
||||
<div className="mb-5 space-y-3 text-sm text-muted-foreground">
|
||||
<p>{t("description")}</p>
|
||||
<div className="mb-5 flex flex-col space-y-2">
|
||||
<Heading as="h3" className="mb-0">
|
||||
{t("title")}
|
||||
</Heading>
|
||||
<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>
|
||||
<Tabs defaultValue="debug" className="flex h-full w-full flex-col">
|
||||
<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">
|
||||
<WsMessageFeed
|
||||
maxSize={200}
|
||||
maxSize={2000}
|
||||
lockedCamera={status.replay_camera ?? undefined}
|
||||
showCameraBadge={false}
|
||||
/>
|
||||
@ -453,6 +477,43 @@ export default function Replay() {
|
||||
</Tabs>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@ -507,7 +568,7 @@ function ObjectList({ cameraConfig, objects, config }: ObjectListProps) {
|
||||
: getColorForObjectName(obj.label),
|
||||
}}
|
||||
>
|
||||
{getIconForLabel(obj.label, "size-4 text-white")}
|
||||
{getIconForLabel(obj.label, "object", "size-4 text-white")}
|
||||
</div>
|
||||
<div className="text-sm font-medium">
|
||||
{getTranslatedLabel(obj.label)}
|
||||
|
||||
@ -514,13 +514,18 @@ const mergeSectionConfig = (
|
||||
|
||||
export function getSectionConfig(
|
||||
sectionKey: string,
|
||||
level: "global" | "camera",
|
||||
level: "global" | "camera" | "replay",
|
||||
): SectionConfig {
|
||||
const entry = sectionConfigs[sectionKey];
|
||||
if (!entry) {
|
||||
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);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user