mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-06-27 06:41:53 +03:00
Compare commits
4 Commits
7a4c165f4e
...
ae866c5c4d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae866c5c4d | ||
|
|
01c82d6921 | ||
|
|
68e8afd35c | ||
|
|
5ef8b9b924 |
@ -43,6 +43,7 @@ from frigate.config.camera.updater import (
|
||||
CameraConfigUpdateEnum,
|
||||
CameraConfigUpdateTopic,
|
||||
)
|
||||
from frigate.const import REDACTED_CREDENTIAL_SENTINEL
|
||||
from frigate.ffmpeg_presets import FFMPEG_HWACCEL_VAAPI, _gpu_selector
|
||||
from frigate.genai import PROVIDERS, load_providers
|
||||
from frigate.jobs.media_sync import (
|
||||
@ -61,7 +62,11 @@ from frigate.util.builtin import (
|
||||
process_config_query_string,
|
||||
update_yaml_file_bulk,
|
||||
)
|
||||
from frigate.util.config import apply_section_update, find_config_file
|
||||
from frigate.util.config import (
|
||||
apply_section_update,
|
||||
find_config_file,
|
||||
redact_credential,
|
||||
)
|
||||
from frigate.util.schema import get_config_schema
|
||||
from frigate.util.services import (
|
||||
get_nvidia_driver_info,
|
||||
@ -284,26 +289,24 @@ def config(request: Request):
|
||||
if request.headers.get("remote-role") != "admin":
|
||||
config.pop("environment_vars", None)
|
||||
|
||||
# remove mqtt credentials
|
||||
config["mqtt"].pop("password", None)
|
||||
config["mqtt"].pop("user", None)
|
||||
# redact mqtt credentials
|
||||
redact_credential(config["mqtt"], "password")
|
||||
|
||||
# remove the proxy secret
|
||||
config["proxy"].pop("auth_secret", None)
|
||||
# redact proxy secret
|
||||
redact_credential(config["proxy"], "auth_secret")
|
||||
|
||||
# remove genai api keys
|
||||
for genai_name, genai_cfg in config.get("genai", {}).items():
|
||||
# redact genai api keys
|
||||
for _genai_name, genai_cfg in config.get("genai", {}).items():
|
||||
if isinstance(genai_cfg, dict):
|
||||
genai_cfg.pop("api_key", None)
|
||||
redact_credential(genai_cfg, "api_key")
|
||||
|
||||
for camera_name, camera in request.app.frigate_config.cameras.items():
|
||||
camera_dict = config["cameras"][camera_name]
|
||||
|
||||
# remove onvif credentials
|
||||
# redact onvif credentials
|
||||
onvif_dict = camera_dict.get("onvif", {})
|
||||
if onvif_dict:
|
||||
onvif_dict.pop("user", None)
|
||||
onvif_dict.pop("password", None)
|
||||
redact_credential(onvif_dict, "password")
|
||||
|
||||
# clean paths
|
||||
for input in camera_dict.get("ffmpeg", {}).get("inputs", []):
|
||||
@ -680,6 +683,10 @@ def _config_set_in_memory(request: Request, body: AppConfigSetBody) -> JSONRespo
|
||||
_restore_masked_camera_paths(body.config_data, request.app.frigate_config)
|
||||
updates = flatten_config_data(body.config_data)
|
||||
updates = {k: ("" if v is None else v) for k, v in updates.items()}
|
||||
# Drop any field whose value is still the redaction sentinel
|
||||
updates = {
|
||||
k: v for k, v in updates.items() if v != REDACTED_CREDENTIAL_SENTINEL
|
||||
}
|
||||
|
||||
if not updates:
|
||||
return JSONResponse(
|
||||
@ -790,6 +797,13 @@ def config_set(request: Request, body: AppConfigSetBody):
|
||||
updates = flatten_config_data(body.config_data)
|
||||
# Convert None values to empty strings for deletion (e.g., when deleting masks)
|
||||
updates = {k: ("" if v is None else v) for k, v in updates.items()}
|
||||
# Drop sentinel-valued fields so untouched credential
|
||||
# placeholders don't clobber the saved YAML value.
|
||||
updates = {
|
||||
k: v
|
||||
for k, v in updates.items()
|
||||
if v != REDACTED_CREDENTIAL_SENTINEL
|
||||
}
|
||||
|
||||
if not updates:
|
||||
return JSONResponse(
|
||||
|
||||
@ -86,10 +86,15 @@ class DebugReplayStopResponse(BaseModel):
|
||||
async def start_debug_replay(request: Request, body: DebugReplayStartBody):
|
||||
"""Start a debug replay session asynchronously."""
|
||||
replay_manager = request.app.replay_manager
|
||||
internal_port = request.app.frigate_config.networking.listen.internal
|
||||
if type(internal_port) is str:
|
||||
internal_port = int(internal_port.split(":")[-1])
|
||||
|
||||
source = RecordingDebugReplaySource(
|
||||
source_camera=body.camera,
|
||||
start_ts=body.start_time,
|
||||
end_ts=body.end_time,
|
||||
internal_port=internal_port,
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
@ -21,6 +21,8 @@ PLUS_API_HOST = "https://api.frigate.video"
|
||||
|
||||
SHM_FRAMES_VAR = "SHM_MAX_FRAMES"
|
||||
|
||||
REDACTED_CREDENTIAL_SENTINEL = "__FRIGATE_SAVED_CREDENTIAL__"
|
||||
|
||||
# Attribute & Object constants
|
||||
|
||||
DEFAULT_ATTRIBUTE_LABEL_MAP = {
|
||||
|
||||
@ -245,11 +245,23 @@ class DebugReplayManager:
|
||||
"frame_shape",
|
||||
"raw_mask",
|
||||
"mask",
|
||||
"improved_contrast_enabled",
|
||||
"enabled_in_config",
|
||||
"rasterized_mask",
|
||||
}
|
||||
)
|
||||
|
||||
if source_config.motion.mask:
|
||||
motion_dict["mask"] = {
|
||||
mask_id: (
|
||||
mask_cfg.model_dump(
|
||||
exclude={"raw_coordinates", "enabled_in_config"}
|
||||
)
|
||||
if mask_cfg is not None
|
||||
else None
|
||||
)
|
||||
for mask_id, mask_cfg in source_config.motion.mask.items()
|
||||
}
|
||||
|
||||
return {
|
||||
"enabled": True,
|
||||
"ffmpeg": {
|
||||
|
||||
@ -63,8 +63,8 @@ Describe the scene based on observable actions and movements, evaluate the activ
|
||||
## Analysis Guidelines
|
||||
|
||||
When forming your description:
|
||||
- **CRITICAL: Only describe objects explicitly listed in "Objects in Scene" below.** Do not infer or mention additional people, vehicles, or objects not present in this list, even if visual patterns suggest them. If only a car is listed, do not describe a person interacting with it unless "person" is also in the objects list.
|
||||
- **Only describe actions actually visible in the frames.** Do not assume or infer actions that you don't observe happening. If someone walks toward furniture but you never see them sit, do not say they sat. Stick to what you can see across the sequence.
|
||||
- **Treat "Objects in Scene" as the list of tracked subjects to describe.** Do not introduce additional people or vehicles that are not present in this list. You may freely reference other items, surfaces, and environmental details visible in the frames when describing what the listed subjects are doing.
|
||||
- **Describe the most likely activity from visible cues across the sequence** — the subject's path, what they are carrying, and what they interact with. Avoid asserting completed outcomes you do not observe; describe in-progress actions rather than results.
|
||||
- Describe what you observe: actions, movements, interactions with objects and the environment. Include any observable environmental changes (e.g., lighting changes triggered by activity).
|
||||
- Note visible details such as clothing, items being carried or placed, tools or equipment present, and how they interact with the property or objects.
|
||||
- Consider the full sequence chronologically: what happens from start to finish, how duration and actions relate to the location and objects involved.
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
"""Debug replay startup job: ffmpeg concat + camera config publish.
|
||||
"""Debug replay startup job: ffmpeg remux + camera config publish.
|
||||
|
||||
The runner orchestrates the async portion of starting a debug replay
|
||||
session. The DebugReplayManager (in frigate.debug_replay) owns session
|
||||
@ -153,15 +153,22 @@ class DebugReplaySource(ABC):
|
||||
class RecordingDebugReplaySource(DebugReplaySource):
|
||||
"""Replay source backed by the Recordings table.
|
||||
|
||||
Builds a concat playlist of recording files covering the time range
|
||||
and feeds it to ffmpeg's concat demuxer.
|
||||
Feeds ffmpeg the internal VOD endpoint so segments with mismatched
|
||||
SPS/PPS (e.g. across day/night transitions) stitch cleanly via HLS
|
||||
discontinuities.
|
||||
"""
|
||||
|
||||
def __init__(self, source_camera: str, start_ts: float, end_ts: float) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
source_camera: str,
|
||||
start_ts: float,
|
||||
end_ts: float,
|
||||
internal_port: int,
|
||||
) -> None:
|
||||
self._camera = source_camera
|
||||
self._start_ts = start_ts
|
||||
self._end_ts = end_ts
|
||||
self._concat_file: Optional[str] = None
|
||||
self._internal_port = internal_port
|
||||
|
||||
@property
|
||||
def source_camera(self) -> str:
|
||||
@ -185,18 +192,16 @@ class RecordingDebugReplaySource(DebugReplaySource):
|
||||
)
|
||||
|
||||
def ffmpeg_input_args(self, working_dir: str) -> list[str]:
|
||||
replay_name = f"{REPLAY_CAMERA_PREFIX}{self._camera}"
|
||||
concat_file = os.path.join(working_dir, f"{replay_name}_concat.txt")
|
||||
recordings = query_recordings(self._camera, self._start_ts, self._end_ts)
|
||||
with open(concat_file, "w") as f:
|
||||
for recording in recordings:
|
||||
f.write(f"file '{recording.path}'\n")
|
||||
self._concat_file = concat_file
|
||||
return ["-f", "concat", "-safe", "0", "-i", concat_file]
|
||||
|
||||
def cleanup(self, working_dir: str) -> None:
|
||||
if self._concat_file:
|
||||
_remove_silent(self._concat_file)
|
||||
playlist_url = (
|
||||
f"http://127.0.0.1:{self._internal_port}/vod/{self._camera}"
|
||||
f"/start/{self._start_ts}/end/{self._end_ts}/index.m3u8"
|
||||
)
|
||||
return [
|
||||
"-protocol_whitelist",
|
||||
"pipe,file,http,tcp",
|
||||
"-i",
|
||||
playlist_url,
|
||||
]
|
||||
|
||||
|
||||
class ExportDebugReplaySource(DebugReplaySource):
|
||||
|
||||
@ -2,6 +2,7 @@ from unittest.mock import Mock, patch
|
||||
|
||||
import frigate.genai
|
||||
from frigate.config import GenAIProviderEnum
|
||||
from frigate.const import REDACTED_CREDENTIAL_SENTINEL
|
||||
from frigate.genai import GenAIClient
|
||||
from frigate.models import Event, Recordings, ReviewSegment
|
||||
from frigate.stats.emitter import StatsEmitter
|
||||
@ -75,6 +76,20 @@ class TestHttpApp(BaseTestHttp):
|
||||
assert response.status_code == 200
|
||||
assert app.frigate_config.cameras["front_door"].objects.track == ["person"]
|
||||
|
||||
####################################################################################################################
|
||||
################################### Credential redaction sentinel ################################################
|
||||
####################################################################################################################
|
||||
def test_config_response_redacts_mqtt_password_with_sentinel(self):
|
||||
self.minimal_config["mqtt"]["user"] = "mqttuser"
|
||||
self.minimal_config["mqtt"]["password"] = "supersecret"
|
||||
app = super().create_app()
|
||||
|
||||
with AuthTestClient(app) as client:
|
||||
response = client.get("/config")
|
||||
assert response.status_code == 200
|
||||
mqtt = response.json()["mqtt"]
|
||||
assert mqtt["password"] == REDACTED_CREDENTIAL_SENTINEL
|
||||
|
||||
####################################################################################################################
|
||||
################################### POST /genai/probe Endpoint ##################################################
|
||||
####################################################################################################################
|
||||
|
||||
@ -101,7 +101,10 @@ class TestStartDebugReplayJob(unittest.TestCase):
|
||||
with self.assertRaises(ValueError):
|
||||
start_debug_replay_job(
|
||||
source=RecordingDebugReplaySource(
|
||||
source_camera="missing", start_ts=100.0, end_ts=200.0
|
||||
source_camera="missing",
|
||||
start_ts=100.0,
|
||||
end_ts=200.0,
|
||||
internal_port=5000,
|
||||
),
|
||||
frigate_config=self.frigate_config,
|
||||
config_publisher=self.publisher,
|
||||
@ -112,7 +115,10 @@ class TestStartDebugReplayJob(unittest.TestCase):
|
||||
with self.assertRaises(ValueError):
|
||||
start_debug_replay_job(
|
||||
source=RecordingDebugReplaySource(
|
||||
source_camera="front", start_ts=200.0, end_ts=100.0
|
||||
source_camera="front",
|
||||
start_ts=200.0,
|
||||
end_ts=100.0,
|
||||
internal_port=5000,
|
||||
),
|
||||
frigate_config=self.frigate_config,
|
||||
config_publisher=self.publisher,
|
||||
@ -126,7 +132,10 @@ class TestStartDebugReplayJob(unittest.TestCase):
|
||||
with self.assertRaises(ValueError):
|
||||
start_debug_replay_job(
|
||||
source=RecordingDebugReplaySource(
|
||||
source_camera="front", start_ts=100.0, end_ts=200.0
|
||||
source_camera="front",
|
||||
start_ts=100.0,
|
||||
end_ts=200.0,
|
||||
internal_port=5000,
|
||||
),
|
||||
frigate_config=self.frigate_config,
|
||||
config_publisher=self.publisher,
|
||||
@ -156,7 +165,10 @@ class TestStartDebugReplayJob(unittest.TestCase):
|
||||
):
|
||||
job_id = start_debug_replay_job(
|
||||
source=RecordingDebugReplaySource(
|
||||
source_camera="front", start_ts=100.0, end_ts=200.0
|
||||
source_camera="front",
|
||||
start_ts=100.0,
|
||||
end_ts=200.0,
|
||||
internal_port=5000,
|
||||
),
|
||||
frigate_config=self.frigate_config,
|
||||
config_publisher=self.publisher,
|
||||
@ -193,7 +205,10 @@ class TestStartDebugReplayJob(unittest.TestCase):
|
||||
):
|
||||
start_debug_replay_job(
|
||||
source=RecordingDebugReplaySource(
|
||||
source_camera="front", start_ts=100.0, end_ts=200.0
|
||||
source_camera="front",
|
||||
start_ts=100.0,
|
||||
end_ts=200.0,
|
||||
internal_port=5000,
|
||||
),
|
||||
frigate_config=self.frigate_config,
|
||||
config_publisher=self.publisher,
|
||||
@ -203,7 +218,10 @@ class TestStartDebugReplayJob(unittest.TestCase):
|
||||
with self.assertRaises(RuntimeError):
|
||||
start_debug_replay_job(
|
||||
source=RecordingDebugReplaySource(
|
||||
source_camera="front", start_ts=100.0, end_ts=200.0
|
||||
source_camera="front",
|
||||
start_ts=100.0,
|
||||
end_ts=200.0,
|
||||
internal_port=5000,
|
||||
),
|
||||
frigate_config=self.frigate_config,
|
||||
config_publisher=self.publisher,
|
||||
@ -271,7 +289,10 @@ class TestRunnerHappyPath(unittest.TestCase):
|
||||
):
|
||||
start_debug_replay_job(
|
||||
source=RecordingDebugReplaySource(
|
||||
source_camera="front", start_ts=100.0, end_ts=200.0
|
||||
source_camera="front",
|
||||
start_ts=100.0,
|
||||
end_ts=200.0,
|
||||
internal_port=5000,
|
||||
),
|
||||
frigate_config=self.frigate_config,
|
||||
config_publisher=self.publisher,
|
||||
@ -342,7 +363,10 @@ class TestRunnerFailurePath(unittest.TestCase):
|
||||
):
|
||||
start_debug_replay_job(
|
||||
source=RecordingDebugReplaySource(
|
||||
source_camera="front", start_ts=100.0, end_ts=200.0
|
||||
source_camera="front",
|
||||
start_ts=100.0,
|
||||
end_ts=200.0,
|
||||
internal_port=5000,
|
||||
),
|
||||
frigate_config=self.frigate_config,
|
||||
config_publisher=self.publisher,
|
||||
@ -420,7 +444,10 @@ class TestRunnerCancellation(unittest.TestCase):
|
||||
):
|
||||
start_debug_replay_job(
|
||||
source=RecordingDebugReplaySource(
|
||||
source_camera="front", start_ts=100.0, end_ts=200.0
|
||||
source_camera="front",
|
||||
start_ts=100.0,
|
||||
end_ts=200.0,
|
||||
internal_port=5000,
|
||||
),
|
||||
frigate_config=self.frigate_config,
|
||||
config_publisher=self.publisher,
|
||||
|
||||
@ -8,7 +8,7 @@ from typing import Any, Optional, Union
|
||||
|
||||
from ruamel.yaml import YAML
|
||||
|
||||
from frigate.const import CONFIG_DIR, EXPORT_DIR
|
||||
from frigate.const import CONFIG_DIR, EXPORT_DIR, REDACTED_CREDENTIAL_SENTINEL
|
||||
from frigate.util.builtin import deep_merge
|
||||
from frigate.util.services import get_video_properties
|
||||
|
||||
@ -18,6 +18,21 @@ CURRENT_CONFIG_VERSION = "0.18-0"
|
||||
DEFAULT_CONFIG_FILE = os.path.join(CONFIG_DIR, "config.yml")
|
||||
|
||||
|
||||
def redact_credential(obj: dict[str, Any], key: str) -> None:
|
||||
"""Replace obj[key] with the redaction sentinel if a value is saved, else drop.
|
||||
|
||||
Used when shaping the /config response so saved credentials never leave
|
||||
the server. The frontend recognizes REDACTED_CREDENTIAL_SENTINEL, renders
|
||||
the field as empty with a "saved — leave blank to keep" placeholder, and
|
||||
/config/set strips it from any incoming payload so the YAML value is
|
||||
preserved when the user doesn't touch the field.
|
||||
"""
|
||||
if obj.get(key):
|
||||
obj[key] = REDACTED_CREDENTIAL_SENTINEL
|
||||
else:
|
||||
obj.pop(key, None)
|
||||
|
||||
|
||||
def find_config_file() -> str:
|
||||
config_path = os.environ.get("CONFIG_FILE", DEFAULT_CONFIG_FILE)
|
||||
|
||||
|
||||
@ -316,5 +316,8 @@
|
||||
"pixels": "{{area}}px"
|
||||
},
|
||||
"no_items": "No items",
|
||||
"validation_errors": "Validation Errors"
|
||||
"validation_errors": "Validation Errors",
|
||||
"credentialField": {
|
||||
"savedPlaceholder": "Saved — leave blank to keep current"
|
||||
}
|
||||
}
|
||||
|
||||
@ -24,6 +24,7 @@ const genai: SectionConfigOverrides = {
|
||||
"ui:widget": "genaiRoles",
|
||||
},
|
||||
"*.api_key": {
|
||||
"ui:widget": "password",
|
||||
"ui:options": { size: "lg" },
|
||||
},
|
||||
"*.base_url": {
|
||||
|
||||
@ -64,6 +64,7 @@ const mqtt: SectionConfigOverrides = {
|
||||
liveValidate: true,
|
||||
uiSchema: {
|
||||
password: {
|
||||
"ui:widget": "password",
|
||||
"ui:options": { size: "xs" },
|
||||
},
|
||||
},
|
||||
|
||||
@ -29,6 +29,9 @@ const onvif: SectionConfigOverrides = {
|
||||
host: {
|
||||
"ui:options": { size: "sm" },
|
||||
},
|
||||
password: {
|
||||
"ui:widget": "password",
|
||||
},
|
||||
profile: {
|
||||
"ui:widget": "onvifProfile",
|
||||
},
|
||||
|
||||
@ -18,6 +18,7 @@ const proxy: SectionConfigOverrides = {
|
||||
"ui:options": { size: "lg" },
|
||||
},
|
||||
auth_secret: {
|
||||
"ui:widget": "password",
|
||||
"ui:options": { size: "md" },
|
||||
},
|
||||
header_map: {
|
||||
|
||||
@ -3,8 +3,10 @@ import type { WidgetProps } from "@rjsf/utils";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuEye, LuEyeOff } from "react-icons/lu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { REDACTED_CREDENTIAL_SENTINEL } from "@/lib/const";
|
||||
import { getSizedFieldClassName } from "../utils";
|
||||
|
||||
export function PasswordWidget(props: WidgetProps) {
|
||||
@ -21,17 +23,31 @@ export function PasswordWidget(props: WidgetProps) {
|
||||
options,
|
||||
} = props;
|
||||
|
||||
const { t } = useTranslation(["common"]);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const fieldClassName = getSizedFieldClassName(options, "sm");
|
||||
|
||||
// When the backend returns the sentinel, hide it visually and prompt the
|
||||
// user that a value is already saved. The value stays as the sentinel in
|
||||
// form state — backend /config/set strips it so the saved YAML is
|
||||
// preserved when the user doesn't touch the field.
|
||||
const isRedacted = value === REDACTED_CREDENTIAL_SENTINEL;
|
||||
const displayValue = isRedacted ? "" : (value ?? "");
|
||||
const effectivePlaceholder = isRedacted
|
||||
? t("credentialField.savedPlaceholder", {
|
||||
ns: "common",
|
||||
defaultValue: "Saved — leave blank to keep current",
|
||||
})
|
||||
: placeholder || "";
|
||||
|
||||
return (
|
||||
<div className={cn("relative", fieldClassName)}>
|
||||
<Input
|
||||
id={id}
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={value ?? ""}
|
||||
value={displayValue}
|
||||
disabled={disabled || readonly}
|
||||
placeholder={placeholder || ""}
|
||||
placeholder={effectivePlaceholder}
|
||||
onChange={(e) =>
|
||||
onChange(e.target.value === "" ? undefined : e.target.value)
|
||||
}
|
||||
@ -46,7 +62,7 @@ export function PasswordWidget(props: WidgetProps) {
|
||||
size="sm"
|
||||
className="absolute right-0 top-0 h-full px-3 hover:bg-transparent"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
disabled={disabled}
|
||||
disabled={disabled || isRedacted}
|
||||
>
|
||||
{showPassword ? (
|
||||
<LuEyeOff className="h-4 w-4" />
|
||||
|
||||
@ -1,6 +1,16 @@
|
||||
/** ONNX embedding models that require local model downloads. GenAI providers are not in this list. */
|
||||
export const JINA_EMBEDDING_MODELS = ["jinav1", "jinav2"] as const;
|
||||
|
||||
/**
|
||||
* Sentinel the backend substitutes for saved credentials (api keys,
|
||||
* passwords, secrets) in /config responses. The credential widget renders
|
||||
* this value as an empty input with a "saved — leave blank to keep" hint,
|
||||
* and stripRedactedCredentials() removes any field still equal to this
|
||||
* value before sending a config/set payload so the saved YAML value is
|
||||
* preserved. Mirror of frigate.const.REDACTED_CREDENTIAL_SENTINEL.
|
||||
*/
|
||||
export const REDACTED_CREDENTIAL_SENTINEL = "__FRIGATE_SAVED_CREDENTIAL__";
|
||||
|
||||
export const ANNOTATION_OFFSET_MIN = -10000;
|
||||
export const ANNOTATION_OFFSET_MAX = 5000;
|
||||
export const ANNOTATION_OFFSET_STEP = 50;
|
||||
|
||||
@ -121,7 +121,7 @@ export default function Replay() {
|
||||
mutate: refreshStatus,
|
||||
isLoading,
|
||||
} = useSWR<DebugReplayStatus>("debug_replay/status", {
|
||||
refreshInterval: 1000,
|
||||
refreshInterval: (latestData) => (latestData?.live_ready ? 0 : 1000),
|
||||
});
|
||||
const { payload: replayJob } =
|
||||
useJobStatus<DebugReplayJobResults>("debug_replay");
|
||||
|
||||
@ -11,6 +11,7 @@ import isEqual from "lodash/isEqual";
|
||||
import mergeWith from "lodash/mergeWith";
|
||||
import set from "lodash/set";
|
||||
import { isJsonObject } from "@/lib/utils";
|
||||
import { REDACTED_CREDENTIAL_SENTINEL } from "@/lib/const";
|
||||
import { applySchemaDefaults } from "@/lib/config-schema";
|
||||
import { normalizeConfigValue } from "@/hooks/use-config-override";
|
||||
import {
|
||||
@ -29,6 +30,33 @@ import type {
|
||||
import type { SectionConfig } from "../components/config-form/sections/BaseSection";
|
||||
import { sectionConfigs } from "../components/config-form/sectionConfigs";
|
||||
|
||||
/**
|
||||
* Recursively strip any key whose value is the redaction sentinel from a
|
||||
* config_data payload. Use just before sending to /config/set so untouched
|
||||
* credential placeholder fields don't clobber the saved YAML value. Mutates
|
||||
* and returns the input.
|
||||
*/
|
||||
export function stripRedactedCredentials<T>(value: T): T {
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
stripRedactedCredentials(item);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
const obj = value as Record<string, unknown>;
|
||||
for (const key of Object.keys(obj)) {
|
||||
const v = obj[key];
|
||||
if (v === REDACTED_CREDENTIAL_SENTINEL) {
|
||||
delete obj[key];
|
||||
} else if (v && typeof v === "object") {
|
||||
stripRedactedCredentials(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// cameraUpdateTopicMap — maps config section paths to MQTT/WS update topics
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Loading…
Reference in New Issue
Block a user