Compare commits

...
3 Commits
Author SHA1 Message Date
Nicolas MowenandGitHub 01c82d6921 Improve language around prompt restrictions (#23274)
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
2026-05-20 20:38:00 -05:00
Josh HawkinsandGitHub 68e8afd35c Improve credential redaction handling (#23265)
* redact credentials in config endpoint with sentinel

* backend test

* frontend

* apply widget for credential fields

* i18n
2026-05-20 15:59:01 -06:00
Josh HawkinsandGitHub 5ef8b9b924 Debug replay fixes (#23270)
* ensure motion masks from source camera are copied to replay

* stop polling debug_replay/status after live_ready

* use vod for constructing replay clips
2026-05-20 16:37:02 -05:00
18 changed files with 205 additions and 47 deletions
+26 -12
View File
@@ -43,6 +43,7 @@ from frigate.config.camera.updater import (
CameraConfigUpdateEnum, CameraConfigUpdateEnum,
CameraConfigUpdateTopic, CameraConfigUpdateTopic,
) )
from frigate.const import REDACTED_CREDENTIAL_SENTINEL
from frigate.ffmpeg_presets import FFMPEG_HWACCEL_VAAPI, _gpu_selector from frigate.ffmpeg_presets import FFMPEG_HWACCEL_VAAPI, _gpu_selector
from frigate.genai import PROVIDERS, load_providers from frigate.genai import PROVIDERS, load_providers
from frigate.jobs.media_sync import ( from frigate.jobs.media_sync import (
@@ -61,7 +62,11 @@ from frigate.util.builtin import (
process_config_query_string, process_config_query_string,
update_yaml_file_bulk, 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.schema import get_config_schema
from frigate.util.services import ( from frigate.util.services import (
get_nvidia_driver_info, get_nvidia_driver_info,
@@ -284,26 +289,24 @@ def config(request: Request):
if request.headers.get("remote-role") != "admin": if request.headers.get("remote-role") != "admin":
config.pop("environment_vars", None) config.pop("environment_vars", None)
# remove mqtt credentials # redact mqtt credentials
config["mqtt"].pop("password", None) redact_credential(config["mqtt"], "password")
config["mqtt"].pop("user", None)
# remove the proxy secret # redact proxy secret
config["proxy"].pop("auth_secret", None) redact_credential(config["proxy"], "auth_secret")
# remove genai api keys # redact genai api keys
for genai_name, genai_cfg in config.get("genai", {}).items(): for _genai_name, genai_cfg in config.get("genai", {}).items():
if isinstance(genai_cfg, dict): 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(): for camera_name, camera in request.app.frigate_config.cameras.items():
camera_dict = config["cameras"][camera_name] camera_dict = config["cameras"][camera_name]
# remove onvif credentials # redact onvif credentials
onvif_dict = camera_dict.get("onvif", {}) onvif_dict = camera_dict.get("onvif", {})
if onvif_dict: if onvif_dict:
onvif_dict.pop("user", None) redact_credential(onvif_dict, "password")
onvif_dict.pop("password", None)
# clean paths # clean paths
for input in camera_dict.get("ffmpeg", {}).get("inputs", []): 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) _restore_masked_camera_paths(body.config_data, request.app.frigate_config)
updates = flatten_config_data(body.config_data) updates = flatten_config_data(body.config_data)
updates = {k: ("" if v is None else v) for k, v in updates.items()} 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: if not updates:
return JSONResponse( return JSONResponse(
@@ -790,6 +797,13 @@ def config_set(request: Request, body: AppConfigSetBody):
updates = flatten_config_data(body.config_data) updates = flatten_config_data(body.config_data)
# Convert None values to empty strings for deletion (e.g., when deleting masks) # 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()} 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: if not updates:
return JSONResponse( return JSONResponse(
+5
View File
@@ -86,10 +86,15 @@ class DebugReplayStopResponse(BaseModel):
async def start_debug_replay(request: Request, body: DebugReplayStartBody): async def start_debug_replay(request: Request, body: DebugReplayStartBody):
"""Start a debug replay session asynchronously.""" """Start a debug replay session asynchronously."""
replay_manager = request.app.replay_manager 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 = RecordingDebugReplaySource(
source_camera=body.camera, source_camera=body.camera,
start_ts=body.start_time, start_ts=body.start_time,
end_ts=body.end_time, end_ts=body.end_time,
internal_port=internal_port,
) )
try: try:
+2
View File
@@ -21,6 +21,8 @@ PLUS_API_HOST = "https://api.frigate.video"
SHM_FRAMES_VAR = "SHM_MAX_FRAMES" SHM_FRAMES_VAR = "SHM_MAX_FRAMES"
REDACTED_CREDENTIAL_SENTINEL = "__FRIGATE_SAVED_CREDENTIAL__"
# Attribute & Object constants # Attribute & Object constants
DEFAULT_ATTRIBUTE_LABEL_MAP = { DEFAULT_ATTRIBUTE_LABEL_MAP = {
+13 -1
View File
@@ -245,11 +245,23 @@ class DebugReplayManager:
"frame_shape", "frame_shape",
"raw_mask", "raw_mask",
"mask", "mask",
"improved_contrast_enabled", "enabled_in_config",
"rasterized_mask", "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 { return {
"enabled": True, "enabled": True,
"ffmpeg": { "ffmpeg": {
+2 -2
View File
@@ -63,8 +63,8 @@ Describe the scene based on observable actions and movements, evaluate the activ
## Analysis Guidelines ## Analysis Guidelines
When forming your description: 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. - **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.
- **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. - **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). - 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. - 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. - Consider the full sequence chronologically: what happens from start to finish, how duration and actions relate to the location and objects involved.
+22 -17
View File
@@ -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 The runner orchestrates the async portion of starting a debug replay
session. The DebugReplayManager (in frigate.debug_replay) owns session session. The DebugReplayManager (in frigate.debug_replay) owns session
@@ -153,15 +153,22 @@ class DebugReplaySource(ABC):
class RecordingDebugReplaySource(DebugReplaySource): class RecordingDebugReplaySource(DebugReplaySource):
"""Replay source backed by the Recordings table. """Replay source backed by the Recordings table.
Builds a concat playlist of recording files covering the time range Feeds ffmpeg the internal VOD endpoint so segments with mismatched
and feeds it to ffmpeg's concat demuxer. 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._camera = source_camera
self._start_ts = start_ts self._start_ts = start_ts
self._end_ts = end_ts self._end_ts = end_ts
self._concat_file: Optional[str] = None self._internal_port = internal_port
@property @property
def source_camera(self) -> str: def source_camera(self) -> str:
@@ -185,18 +192,16 @@ class RecordingDebugReplaySource(DebugReplaySource):
) )
def ffmpeg_input_args(self, working_dir: str) -> list[str]: def ffmpeg_input_args(self, working_dir: str) -> list[str]:
replay_name = f"{REPLAY_CAMERA_PREFIX}{self._camera}" playlist_url = (
concat_file = os.path.join(working_dir, f"{replay_name}_concat.txt") f"http://127.0.0.1:{self._internal_port}/vod/{self._camera}"
recordings = query_recordings(self._camera, self._start_ts, self._end_ts) f"/start/{self._start_ts}/end/{self._end_ts}/index.m3u8"
with open(concat_file, "w") as f: )
for recording in recordings: return [
f.write(f"file '{recording.path}'\n") "-protocol_whitelist",
self._concat_file = concat_file "pipe,file,http,tcp",
return ["-f", "concat", "-safe", "0", "-i", concat_file] "-i",
playlist_url,
def cleanup(self, working_dir: str) -> None: ]
if self._concat_file:
_remove_silent(self._concat_file)
class ExportDebugReplaySource(DebugReplaySource): class ExportDebugReplaySource(DebugReplaySource):
+15
View File
@@ -2,6 +2,7 @@ from unittest.mock import Mock, patch
import frigate.genai import frigate.genai
from frigate.config import GenAIProviderEnum from frigate.config import GenAIProviderEnum
from frigate.const import REDACTED_CREDENTIAL_SENTINEL
from frigate.genai import GenAIClient from frigate.genai import GenAIClient
from frigate.models import Event, Recordings, ReviewSegment from frigate.models import Event, Recordings, ReviewSegment
from frigate.stats.emitter import StatsEmitter from frigate.stats.emitter import StatsEmitter
@@ -75,6 +76,20 @@ class TestHttpApp(BaseTestHttp):
assert response.status_code == 200 assert response.status_code == 200
assert app.frigate_config.cameras["front_door"].objects.track == ["person"] 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 ################################################## ################################### POST /genai/probe Endpoint ##################################################
#################################################################################################################### ####################################################################################################################
+36 -9
View File
@@ -101,7 +101,10 @@ class TestStartDebugReplayJob(unittest.TestCase):
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
start_debug_replay_job( start_debug_replay_job(
source=RecordingDebugReplaySource( 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, frigate_config=self.frigate_config,
config_publisher=self.publisher, config_publisher=self.publisher,
@@ -112,7 +115,10 @@ class TestStartDebugReplayJob(unittest.TestCase):
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
start_debug_replay_job( start_debug_replay_job(
source=RecordingDebugReplaySource( 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, frigate_config=self.frigate_config,
config_publisher=self.publisher, config_publisher=self.publisher,
@@ -126,7 +132,10 @@ class TestStartDebugReplayJob(unittest.TestCase):
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
start_debug_replay_job( start_debug_replay_job(
source=RecordingDebugReplaySource( 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, frigate_config=self.frigate_config,
config_publisher=self.publisher, config_publisher=self.publisher,
@@ -156,7 +165,10 @@ class TestStartDebugReplayJob(unittest.TestCase):
): ):
job_id = start_debug_replay_job( job_id = start_debug_replay_job(
source=RecordingDebugReplaySource( 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, frigate_config=self.frigate_config,
config_publisher=self.publisher, config_publisher=self.publisher,
@@ -193,7 +205,10 @@ class TestStartDebugReplayJob(unittest.TestCase):
): ):
start_debug_replay_job( start_debug_replay_job(
source=RecordingDebugReplaySource( 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, frigate_config=self.frigate_config,
config_publisher=self.publisher, config_publisher=self.publisher,
@@ -203,7 +218,10 @@ class TestStartDebugReplayJob(unittest.TestCase):
with self.assertRaises(RuntimeError): with self.assertRaises(RuntimeError):
start_debug_replay_job( start_debug_replay_job(
source=RecordingDebugReplaySource( 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, frigate_config=self.frigate_config,
config_publisher=self.publisher, config_publisher=self.publisher,
@@ -271,7 +289,10 @@ class TestRunnerHappyPath(unittest.TestCase):
): ):
start_debug_replay_job( start_debug_replay_job(
source=RecordingDebugReplaySource( 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, frigate_config=self.frigate_config,
config_publisher=self.publisher, config_publisher=self.publisher,
@@ -342,7 +363,10 @@ class TestRunnerFailurePath(unittest.TestCase):
): ):
start_debug_replay_job( start_debug_replay_job(
source=RecordingDebugReplaySource( 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, frigate_config=self.frigate_config,
config_publisher=self.publisher, config_publisher=self.publisher,
@@ -420,7 +444,10 @@ class TestRunnerCancellation(unittest.TestCase):
): ):
start_debug_replay_job( start_debug_replay_job(
source=RecordingDebugReplaySource( 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, frigate_config=self.frigate_config,
config_publisher=self.publisher, config_publisher=self.publisher,
+16 -1
View File
@@ -8,7 +8,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, REDACTED_CREDENTIAL_SENTINEL
from frigate.util.builtin import deep_merge from frigate.util.builtin import deep_merge
from frigate.util.services import get_video_properties 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") 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: def find_config_file() -> str:
config_path = os.environ.get("CONFIG_FILE", DEFAULT_CONFIG_FILE) config_path = os.environ.get("CONFIG_FILE", DEFAULT_CONFIG_FILE)
+4 -1
View File
@@ -316,5 +316,8 @@
"pixels": "{{area}}px" "pixels": "{{area}}px"
}, },
"no_items": "No items", "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", "ui:widget": "genaiRoles",
}, },
"*.api_key": { "*.api_key": {
"ui:widget": "password",
"ui:options": { size: "lg" }, "ui:options": { size: "lg" },
}, },
"*.base_url": { "*.base_url": {
@@ -64,6 +64,7 @@ const mqtt: SectionConfigOverrides = {
liveValidate: true, liveValidate: true,
uiSchema: { uiSchema: {
password: { password: {
"ui:widget": "password",
"ui:options": { size: "xs" }, "ui:options": { size: "xs" },
}, },
}, },
@@ -29,6 +29,9 @@ const onvif: SectionConfigOverrides = {
host: { host: {
"ui:options": { size: "sm" }, "ui:options": { size: "sm" },
}, },
password: {
"ui:widget": "password",
},
profile: { profile: {
"ui:widget": "onvifProfile", "ui:widget": "onvifProfile",
}, },
@@ -18,6 +18,7 @@ const proxy: SectionConfigOverrides = {
"ui:options": { size: "lg" }, "ui:options": { size: "lg" },
}, },
auth_secret: { auth_secret: {
"ui:widget": "password",
"ui:options": { size: "md" }, "ui:options": { size: "md" },
}, },
header_map: { header_map: {
@@ -3,8 +3,10 @@ import type { WidgetProps } from "@rjsf/utils";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next";
import { LuEye, LuEyeOff } from "react-icons/lu"; import { LuEye, LuEyeOff } from "react-icons/lu";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { REDACTED_CREDENTIAL_SENTINEL } from "@/lib/const";
import { getSizedFieldClassName } from "../utils"; import { getSizedFieldClassName } from "../utils";
export function PasswordWidget(props: WidgetProps) { export function PasswordWidget(props: WidgetProps) {
@@ -21,17 +23,31 @@ export function PasswordWidget(props: WidgetProps) {
options, options,
} = props; } = props;
const { t } = useTranslation(["common"]);
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const fieldClassName = getSizedFieldClassName(options, "sm"); 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 ( return (
<div className={cn("relative", fieldClassName)}> <div className={cn("relative", fieldClassName)}>
<Input <Input
id={id} id={id}
type={showPassword ? "text" : "password"} type={showPassword ? "text" : "password"}
value={value ?? ""} value={displayValue}
disabled={disabled || readonly} disabled={disabled || readonly}
placeholder={placeholder || ""} placeholder={effectivePlaceholder}
onChange={(e) => onChange={(e) =>
onChange(e.target.value === "" ? undefined : e.target.value) onChange(e.target.value === "" ? undefined : e.target.value)
} }
@@ -46,7 +62,7 @@ export function PasswordWidget(props: WidgetProps) {
size="sm" size="sm"
className="absolute right-0 top-0 h-full px-3 hover:bg-transparent" className="absolute right-0 top-0 h-full px-3 hover:bg-transparent"
onClick={() => setShowPassword(!showPassword)} onClick={() => setShowPassword(!showPassword)}
disabled={disabled} disabled={disabled || isRedacted}
> >
{showPassword ? ( {showPassword ? (
<LuEyeOff className="h-4 w-4" /> <LuEyeOff className="h-4 w-4" />
+10
View File
@@ -1,6 +1,16 @@
/** ONNX embedding models that require local model downloads. GenAI providers are not in this list. */ /** ONNX embedding models that require local model downloads. GenAI providers are not in this list. */
export const JINA_EMBEDDING_MODELS = ["jinav1", "jinav2"] as const; 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_MIN = -10000;
export const ANNOTATION_OFFSET_MAX = 5000; export const ANNOTATION_OFFSET_MAX = 5000;
export const ANNOTATION_OFFSET_STEP = 50; export const ANNOTATION_OFFSET_STEP = 50;
+1 -1
View File
@@ -121,7 +121,7 @@ export default function Replay() {
mutate: refreshStatus, mutate: refreshStatus,
isLoading, isLoading,
} = useSWR<DebugReplayStatus>("debug_replay/status", { } = useSWR<DebugReplayStatus>("debug_replay/status", {
refreshInterval: 1000, refreshInterval: (latestData) => (latestData?.live_ready ? 0 : 1000),
}); });
const { payload: replayJob } = const { payload: replayJob } =
useJobStatus<DebugReplayJobResults>("debug_replay"); useJobStatus<DebugReplayJobResults>("debug_replay");
+28
View File
@@ -11,6 +11,7 @@ import isEqual from "lodash/isEqual";
import mergeWith from "lodash/mergeWith"; import mergeWith from "lodash/mergeWith";
import set from "lodash/set"; import set from "lodash/set";
import { isJsonObject } from "@/lib/utils"; import { isJsonObject } from "@/lib/utils";
import { REDACTED_CREDENTIAL_SENTINEL } from "@/lib/const";
import { applySchemaDefaults } from "@/lib/config-schema"; import { applySchemaDefaults } from "@/lib/config-schema";
import { normalizeConfigValue } from "@/hooks/use-config-override"; import { normalizeConfigValue } from "@/hooks/use-config-override";
import { import {
@@ -29,6 +30,33 @@ import type {
import type { SectionConfig } from "../components/config-form/sections/BaseSection"; import type { SectionConfig } from "../components/config-form/sections/BaseSection";
import { sectionConfigs } from "../components/config-form/sectionConfigs"; 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 // cameraUpdateTopicMap — maps config section paths to MQTT/WS update topics
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------