Compare commits

...

11 Commits

Author SHA1 Message Date
gwmullin
34f5be5a36
Merge c575fb223b into ec44398b1c 2026-05-24 13:30:41 -05:00
Josh Hawkins
ec44398b1c
Miscellaneous fixes (#23295)
Some checks are pending
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
* filter motion review by allowed cameras

* filter alertCameras by allowed cameras so the recent alerts query for restricted roles doesn't reference cameras they can't access

* skip data streams in chapter exports to avoid ffmpeg segfault

* formatting

* restrict debug replay UI entry points to admin users

* Adjust default iGPU name when it can't be found

* Fix when model tries to request an invalid camera

* Improve prompt

* add collapsible main nav items in settings

---------

Co-authored-by: Nicolas Mowen <nickmowen213@gmail.com>
2026-05-24 06:48:52 -06:00
Josh Hawkins
d556ff8df2
Tweaks (#23292)
Some checks failed
CI / AMD64 Build (push) Has been cancelled
CI / ARM Build (push) Has been cancelled
CI / Jetson Jetpack 6 (push) Has been cancelled
CI / AMD64 Extra Build (push) Has been cancelled
CI / ARM Extra Build (push) Has been cancelled
CI / Synaptics Build (push) Has been cancelled
CI / Assemble and push default build (push) Has been cancelled
* add review padding to explore debug replay api calls

* add semantic search model size widget

disables model_size select with n/a text when an embeddings genai provider is selected

* regenerate zone contours and per-zone filter masks on detect resolution change

* treat null as a clear sentinel in buildOverrides so nullable field edits don't snap back

* extract replay config sheet to new component

* add validation and messages for detect settings
2026-05-22 14:41:07 -05:00
Greg
c575fb223b Editor fail, re-ruff format. 2026-05-18 14:18:17 -07:00
Greg
9fa345f192 Remove 2x unnecessary index on reviewsegment, remove reference to prior code implementation in comment in event.py 2026-05-18 14:16:43 -07:00
Gdub
7b55c4b758 Rerun ruff formatting. 2026-05-18 13:39:12 -07:00
Gdub
570e2e3f76 Slightly simplify review logic and avoid duplicating the json response for empty review IDs. 2026-05-18 13:30:46 -07:00
Greg
39fba9b0a7 Use peewee instead of rw sql for the CTE query. 2026-05-11 16:46:43 -07:00
Greg
328a26b169 Collapse a few sequential queries into a single one. 2026-05-11 15:45:35 -07:00
Greg
311fb1bd19 Rewrite to use a CTE to leverage speedups by using sqllite internal optimization to do a single query instead of a starter query to get distinct labels and a subsequent loop of querys per distinct event labels.
Frigate is currently shipping sqlite 3.46.1, which is above the minimum version 3.25 needed for CTEs.
2026-05-08 16:18:37 -07:00
Greg
48b1426891 Add additional indicies on event and review tables. Every events or timeline endpoint filters on event start time and camera, this should speed things up by avoiding a range scan on the table. 2026-05-08 15:59:23 -07:00
31 changed files with 826 additions and 413 deletions

View File

@ -770,6 +770,13 @@ def _config_set_in_memory(request: Request, body: AppConfigSetBody) -> JSONRespo
),
cam_cfg.objects,
)
if cam_cfg.zones:
request.app.config_publisher.publish_update(
CameraConfigUpdateTopic(
CameraConfigUpdateEnum.zones, camera
),
cam_cfg.zones,
)
request.app.config_publisher.publish_update(
CameraConfigUpdateTopic(
CameraConfigUpdateEnum.refresh, camera

View File

@ -547,9 +547,21 @@ async def _execute_get_live_context(
camera: str,
allowed_cameras: List[str],
) -> Dict[str, Any]:
# Reject wildcards explicitly so models retry with a real camera name
# instead of silently fanning out across every camera.
if camera in ("*", "all"):
return {
"error": (
"get_live_context requires a single camera name; wildcards "
"are not supported. Call this tool once per camera."
),
"available_cameras": allowed_cameras,
}
if camera not in allowed_cameras:
return {
"error": f"Camera '{camera}' not found or access denied",
"available_cameras": allowed_cameras,
}
if camera not in request.app.frigate_config.cameras:
@ -721,7 +733,14 @@ async def _execute_tool_internal(
"Arguments: %s",
json.dumps(arguments),
)
return {"error": "Camera parameter is required"}
return {
"error": (
"get_live_context requires a single camera name; "
"wildcards and empty values are not supported. "
"Call this tool once per camera."
),
"available_cameras": allowed_cameras,
}
return await _execute_get_live_context(request, camera, allowed_cameras)
elif tool_name == "start_camera_watch":
return await _execute_start_camera_watch(request, arguments)

View File

@ -389,82 +389,106 @@ def events_explore(
limit: int = 10,
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
):
# get distinct labels for all events
distinct_labels = (
Event.select(Event.label)
.where(Event.camera << allowed_cameras)
.distinct()
.order_by(Event.label)
if not allowed_cameras:
return JSONResponse(content=[])
explore_columns = (
Event.id,
Event.camera,
Event.label,
Event.sub_label,
Event.zones,
Event.start_time,
Event.end_time,
Event.has_clip,
Event.has_snapshot,
Event.plus_id,
Event.retain_indefinitely,
Event.top_score,
Event.false_positive,
Event.box,
Event.data,
)
label_counts = {}
def event_generator():
for label_obj in distinct_labels.iterator():
label = label_obj.label
# get most recent events for this label
label_events = (
Event.select()
.where((Event.label == label) & (Event.camera << allowed_cameras))
.order_by(Event.start_time.desc())
.limit(limit)
.iterator()
)
# count total events for this label
label_counts[label] = (
Event.select()
.where((Event.label == label) & (Event.camera << allowed_cameras))
.count()
)
yield from label_events
def process_events():
for event in event_generator():
processed_event = {
"id": event.id,
"camera": event.camera,
"label": event.label,
"zones": event.zones,
"start_time": event.start_time,
"end_time": event.end_time,
"has_clip": event.has_clip,
"has_snapshot": event.has_snapshot,
"plus_id": event.plus_id,
"retain_indefinitely": event.retain_indefinitely,
"sub_label": event.sub_label,
"top_score": event.top_score,
"false_positive": event.false_positive,
"box": event.box,
"data": {
k: v
for k, v in event.data.items()
if k
in [
"type",
"score",
"top_score",
"description",
"sub_label_score",
"average_estimated_speed",
"velocity_angle",
"path_data",
"recognized_license_plate",
"recognized_license_plate_score",
]
},
"event_count": label_counts[event.label],
}
yield processed_event
# convert iterator to list and sort
processed_events = sorted(
process_events(),
key=lambda x: (x["event_count"], x["start_time"]),
reverse=True,
# Single query: per-label COUNT and top-N ranking by start_time computed
# via window functions in a CTE, then filtered to rn <= limit
event_count = (
fn.COUNT(Event.id).over(partition_by=[Event.label]).alias("event_count")
)
rn = (
fn.ROW_NUMBER()
.over(partition_by=[Event.label], order_by=[Event.start_time.desc()])
.alias("rn")
)
base_query = Event.select(
*explore_columns,
event_count,
rn,
).where(Event.camera << allowed_cameras)
ranked = base_query.cte("ranked")
query = (
Event.select(
ranked.c.id,
ranked.c.camera,
ranked.c.label,
ranked.c.sub_label,
ranked.c.zones,
ranked.c.start_time,
ranked.c.end_time,
ranked.c.has_clip,
ranked.c.has_snapshot,
ranked.c.plus_id,
ranked.c.retain_indefinitely,
ranked.c.top_score,
ranked.c.false_positive,
ranked.c.box,
ranked.c.data,
ranked.c.event_count,
)
.from_(ranked)
.with_cte(ranked)
.where(ranked.c.rn <= limit)
.order_by(ranked.c.event_count.desc(), ranked.c.start_time.desc())
.objects()
)
allowed_data_keys = {
"type",
"score",
"top_score",
"description",
"sub_label_score",
"average_estimated_speed",
"velocity_angle",
"path_data",
"recognized_license_plate",
"recognized_license_plate_score",
}
processed_events = [
{
"id": event.id,
"camera": event.camera,
"label": event.label,
"zones": event.zones,
"start_time": event.start_time,
"end_time": event.end_time,
"has_clip": event.has_clip,
"has_snapshot": event.has_snapshot,
"plus_id": event.plus_id,
"retain_indefinitely": event.retain_indefinitely,
"sub_label": event.sub_label,
"top_score": event.top_score,
"false_positive": event.false_positive,
"box": event.box,
"data": {
k: v for k, v in (event.data or {}).items() if k in allowed_data_keys
},
"event_count": event.event_count,
}
for event in query
]
return JSONResponse(content=processed_events)
@ -487,22 +511,18 @@ async def event_ids(ids: str, request: Request):
status_code=400,
)
for event_id in ids:
try:
event = Event.get(Event.id == event_id)
await require_camera_access(event.camera, request=request)
except DoesNotExist:
# we should not fail the entire request if an event is not found
continue
try:
events = Event.select().where(Event.id << ids).dicts().iterator()
return JSONResponse(list(events))
events = list(Event.select().where(Event.id << ids).dicts().iterator())
except Exception:
return JSONResponse(
content=({"success": False, "message": "Events not found"}), status_code=400
)
for event in events:
await require_camera_access(event["camera"], request=request)
return JSONResponse(events)
@router.get(
"/events/search",

View File

@ -10,7 +10,7 @@ import pandas as pd
from fastapi import APIRouter, Request
from fastapi.params import Depends
from fastapi.responses import JSONResponse
from peewee import Case, DoesNotExist, IntegrityError, fn, operator
from peewee import Case, DoesNotExist, fn, operator
from playhouse.shortcuts import model_to_dict
from frigate.api.auth import (
@ -172,11 +172,19 @@ async def review_ids(request: Request, ids: str):
status_code=400,
)
try:
reviews = list(
ReviewSegment.select().where(ReviewSegment.id << ids).dicts().iterator()
)
except Exception:
return JSONResponse(
content=({"success": False, "message": "Review segments not found"}),
status_code=400,
)
found_ids = {r["id"] for r in reviews}
for review_id in ids:
try:
review = ReviewSegment.get(ReviewSegment.id == review_id)
await require_camera_access(review.camera, request=request)
except DoesNotExist:
if review_id not in found_ids:
return JSONResponse(
content=(
{"success": False, "message": f"Review {review_id} not found"}
@ -184,16 +192,10 @@ async def review_ids(request: Request, ids: str):
status_code=404,
)
try:
reviews = (
ReviewSegment.select().where(ReviewSegment.id << ids).dicts().iterator()
)
return JSONResponse(list(reviews))
except Exception:
return JSONResponse(
content=({"success": False, "message": "Review segments not found"}),
status_code=400,
)
for review in reviews:
await require_camera_access(review["camera"], request=request)
return JSONResponse(reviews)
@router.get(
@ -490,27 +492,52 @@ async def set_multiple_reviewed(
user_id = current_user["username"]
for review_id in body.ids:
try:
review = ReviewSegment.get(ReviewSegment.id == review_id)
await require_camera_access(review.camera, request=request)
review_status = UserReviewStatus.get(
UserReviewStatus.user_id == user_id,
UserReviewStatus.review_segment == review_id,
reviews = list(
ReviewSegment.select(ReviewSegment.id, ReviewSegment.camera).where(
ReviewSegment.id << body.ids
)
)
for review in reviews:
await require_camera_access(review.camera, request=request)
found_ids = [r.id for r in reviews]
if found_ids:
existing_statuses = list(
UserReviewStatus.select().where(
(UserReviewStatus.user_id == user_id)
& (UserReviewStatus.review_segment << found_ids)
)
# Update based on the reviewed parameter
if review_status.has_been_reviewed != body.reviewed:
review_status.has_been_reviewed = body.reviewed
review_status.save()
except DoesNotExist:
try:
UserReviewStatus.create(
user_id=user_id,
review_segment=ReviewSegment.get(id=review_id),
has_been_reviewed=body.reviewed,
)
status_by_review = {s.review_segment_id: s for s in existing_statuses}
to_update = []
to_create = []
for review_id in found_ids:
if review_id in status_by_review:
status = status_by_review[review_id]
if status.has_been_reviewed != body.reviewed:
status.has_been_reviewed = body.reviewed
to_update.append(status)
else:
to_create.append(
{
"user_id": user_id,
"review_segment_id": review_id,
"has_been_reviewed": body.reviewed,
}
)
except (DoesNotExist, IntegrityError):
pass
if to_update:
UserReviewStatus.bulk_update(
to_update, fields=[UserReviewStatus.has_been_reviewed], batch_size=100
)
if to_create:
UserReviewStatus.insert_many(to_create).execute()
return JSONResponse(
content=(

View File

@ -518,16 +518,21 @@ def get_tool_definitions(
"function": {
"name": "get_live_context",
"description": (
"Get the current live image and detection information for a camera: objects being tracked, "
"Get the current live image and detection information for a single camera: objects being tracked, "
"zones, timestamps. Use this to understand what is visible in the live view. "
"Call this when answering questions about what is happening right now on a specific camera."
"Call this when answering questions about what is happening right now on a specific camera. "
"Operates on one camera at a time; call the tool again for each additional camera. "
"Wildcards and empty values are not accepted."
),
"parameters": {
"type": "object",
"properties": {
"camera": {
"type": "string",
"description": "Camera name to get live context for.",
"description": (
"Exact name of a single camera to get live context for. "
"Wildcards (e.g. '*', 'all') and empty strings are not accepted."
),
},
},
"required": ["camera"],

View File

@ -579,7 +579,9 @@ class RecordingExporter(threading.Thread):
else:
chapters_path = self._build_chapter_metadata_file(recordings)
chapter_args = (
f" -i {chapters_path} -map 0 -map_metadata 1" if chapters_path else ""
f" -i {chapters_path} -map 0 -dn -map_metadata 1"
if chapters_path
else ""
)
ffmpeg_cmd = (
f"{self.config.ffmpeg.ffmpeg_path} -hide_banner {ffmpeg_input}{chapter_args} -c copy -movflags +faststart"

View File

@ -816,6 +816,17 @@ def apply_section_update(camera_config, section: str, update: dict) -> Optional[
**filt.model_dump(exclude_unset=True, exclude={"mask", "raw_mask"}),
)
# Regenerate zone contours and per-zone filter masks at the new
# frame_shape so zone outlines and membership stay relative
for zone in camera_config.zones.values():
if zone.filters:
for zone_obj_name, zone_filter in zone.filters.items():
zone.filters[zone_obj_name] = RuntimeFilterConfig(
frame_shape=new_frame_shape,
**zone_filter.model_dump(exclude_unset=True),
)
zone.generate_contour(new_frame_shape)
else:
merged = deep_merge(current.model_dump(), update, override=True)
setattr(camera_config, section, current.__class__.model_validate(merged))

View File

@ -478,7 +478,7 @@ def get_intel_gpu_stats(
overall_pct = min(100.0, compute_pct + dec_pct)
entry: dict[str, Any] = {
"name": names.get(pdev) or f"Intel GPU {pdev}",
"name": names.get(pdev) or "Intel iGPU",
"vendor": "intel",
"gpu": f"{round(overall_pct, 2)}%",
"mem": "-%",

View File

@ -0,0 +1,27 @@
"""Peewee migrations -- 036_add_perf_indexes.py.
Adds composite/single-column indexes to speed up the most common queries
issued by the web UI on initial page load:
- event(camera, start_time DESC): /events list filtered by camera + time range
- reviewsegment(camera, start_time DESC): /api/review filtered by camera + time range
- reviewsegment(end_time): supports the end_time > after half of /api/review's range
The existing event(label, start_time DESC) index from migration 027 already
covers /events/explore, so it is intentionally not duplicated here.
"""
import peewee as pw
SQL = pw.SQL
def migrate(migrator, database, fake=False, **kwargs):
migrator.sql(
'CREATE INDEX IF NOT EXISTS "event_camera_start_time" '
'ON "event" ("camera", "start_time" DESC)'
)
def rollback(migrator, database, fake=False, **kwargs):
migrator.sql('DROP INDEX IF EXISTS "event_camera_start_time"')

View File

@ -28,5 +28,8 @@
"detectRequired": "At least one input stream must be assigned the 'detect' role.",
"hwaccelDetectOnly": "Only the input stream with the detect role can define hardware acceleration arguments."
}
},
"detect": {
"dimensionMustBeEven": "Must be an even number."
}
}

View File

@ -1543,6 +1543,9 @@
"builtIn": "Built-in Models",
"genaiProviders": "GenAI Providers"
},
"semanticSearchModelSize": {
"notApplicable": "Not applicable for GenAI providers"
},
"review": {
"title": "Review Settings"
},
@ -1791,7 +1794,9 @@
},
"detect": {
"fpsGreaterThanFive": "Setting the detect FPS higher than 5 is not recommended. Higher values may cause performance issues and will not provide any benefit.",
"disabled": "Object detection is disabled. Snapshots, review items, and enrichments such as face recognition, license plate recognition, and Generative AI will not function."
"disabled": "Object detection is disabled. Snapshots, review items, and enrichments such as face recognition, license plate recognition, and Generative AI will not function.",
"resolutionShouldBeMultipleOfFour": "For best results, detect width and height should be multiples of 4. Other even values may produce visual artifacts or slight distortion in the detect stream.",
"aspectRatioMismatch": "The width and height you've entered don't match the aspect ratio of your current detect resolution. This may produce a stretched or distorted image."
},
"objects": {
"genaiNoDescriptionsProvider": "You must configure a GenAI provider with the 'descriptions' role for descriptions to be generated."
@ -1820,8 +1825,7 @@
"mixedTypesSuggestion": "All detectors must use the same type. Remove existing detectors or select {{type}}."
},
"semanticSearch": {
"jinav2SmallModelSize": "The 'small' size with the Jina V2 model has high RAM and inference cost. The 'large' model with a discrete GPU is recommended.",
"modelSizeIgnoredForProvider": "Model size only applies to the built-in Jina models. This value will be ignored when using a GenAI embedding provider."
"jinav2SmallModelSize": "The 'small' size with the Jina V2 model has high RAM and inference cost. The 'large' model with a discrete GPU is recommended."
}
}
}

View File

@ -0,0 +1,13 @@
import { createContext } from "react";
import type { ConfigSectionData } from "@/types/configForm";
// Mirrors the current section's in-flight form data so widgets can react
// to changes that RJSF wouldn't otherwise re-render them for. RJSF's
// Form memoizes SchemaField via deep equality and, in some transitions
// (notably reverting a field to its saved value), can skip re-rendering
// a widget even though the form data it depends on changed. useContext
// re-runs consumers directly on every provider value update, sidestepping
// that.
export const LiveFormDataContext = createContext<ConfigSectionData | null>(
null,
);

View File

@ -11,6 +11,50 @@ const detect: SectionConfigOverrides = {
condition: (ctx) =>
ctx.level === "camera" && ctx.formData?.enabled === false,
},
{
key: "detect-resolution-not-multiple-of-four",
messageKey: "configMessages.detect.resolutionShouldBeMultipleOfFour",
severity: "warning",
condition: (ctx) => {
const width = ctx.formData?.width as number | null | undefined;
const height = ctx.formData?.height as number | null | undefined;
const isEvenButNotFour = (v: unknown) =>
typeof v === "number" && v % 2 === 0 && v % 4 !== 0;
return isEvenButNotFour(width) || isEvenButNotFour(height);
},
},
{
key: "detect-aspect-ratio-mismatch",
messageKey: "configMessages.detect.aspectRatioMismatch",
severity: "warning",
condition: (ctx) => {
const newWidth = ctx.formData?.width as number | null | undefined;
const newHeight = ctx.formData?.height as number | null | undefined;
if (typeof newWidth !== "number" || typeof newHeight !== "number") {
return false;
}
const saved =
ctx.level === "camera"
? ctx.fullCameraConfig?.detect
: ctx.fullConfig?.detect;
const savedWidth = saved?.width;
const savedHeight = saved?.height;
if (
typeof savedWidth !== "number" ||
typeof savedHeight !== "number" ||
savedWidth <= 0 ||
savedHeight <= 0
) {
return false;
}
if (newWidth === savedWidth && newHeight === savedHeight) {
return false;
}
const newRatio = newWidth / newHeight;
const savedRatio = savedWidth / savedHeight;
return Math.abs(newRatio - savedRatio) > 0.01;
},
},
],
fieldMessages: [
{

View File

@ -29,28 +29,13 @@ const semanticSearch: SectionConfigOverrides = {
ctx.formData?.model === "jinav2" &&
ctx.formData?.model_size === "small",
},
{
key: "model-size-ignored-for-provider",
field: "model_size",
messageKey: "configMessages.semanticSearch.modelSizeIgnoredForProvider",
severity: "info",
position: "after",
condition: (ctx) => {
const model = ctx.formData?.model;
return (
typeof model === "string" &&
model !== "" &&
model !== "jinav1" &&
model !== "jinav2"
);
},
},
],
uiSchema: {
model: {
"ui:widget": "semanticSearchModel",
},
model_size: {
"ui:widget": "semanticSearchModelSize",
"ui:options": { size: "xs", enumI18nPrefix: "modelSize" },
},
},

View File

@ -0,0 +1,36 @@
import type { FormValidation } from "@rjsf/utils";
import type { TFunction } from "i18next";
import { isJsonObject } from "@/lib/utils";
import type { JsonObject } from "@/types/configForm";
export function validateDetectDimensions(
formData: unknown,
errors: FormValidation,
t: TFunction,
): FormValidation {
if (!isJsonObject(formData as JsonObject)) {
return errors;
}
const data = formData as JsonObject;
const width = data.width;
const height = data.height;
const widthErrors = errors.width as
| { addError?: (message: string) => void }
| undefined;
const heightErrors = errors.height as
| { addError?: (message: string) => void }
| undefined;
const message = t("detect.dimensionMustBeEven", { ns: "config/validation" });
if (typeof width === "number" && width % 2 !== 0) {
widthErrors?.addError?.(message);
}
if (typeof height === "number" && height % 2 !== 0) {
heightErrors?.addError?.(message);
}
return errors;
}

View File

@ -1,5 +1,6 @@
import type { FormValidation } from "@rjsf/utils";
import type { TFunction } from "i18next";
import { validateDetectDimensions } from "./detect";
import { validateFfmpegInputRoles } from "./ffmpeg";
import { validateProxyRoleHeader } from "./proxy";
@ -19,6 +20,10 @@ export function getSectionValidation({
level,
t,
}: SectionValidationOptions): SectionValidation | undefined {
if (sectionPath === "detect") {
return (formData, errors) => validateDetectDimensions(formData, errors, t);
}
if (sectionPath === "ffmpeg" && level === "camera") {
return (formData, errors) => validateFfmpegInputRoles(formData, errors, t);
}

View File

@ -87,6 +87,7 @@ import type {
import { useConfigMessages } from "@/hooks/use-config-messages";
import { ConfigMessageBanner } from "../ConfigMessageBanner";
import { FieldMessagesContext } from "../FieldMessagesContext";
import { LiveFormDataContext } from "../LiveFormDataContext";
export interface SectionConfig {
/** Field ordering within the section */
@ -998,59 +999,63 @@ export function ConfigSection({
<div className="space-y-6">
<ConfigMessageBanner messages={activeMessages} />
<FieldMessagesContext.Provider value={activeFieldMessages}>
<ConfigForm
key={formKey}
schema={modifiedSchema}
formData={currentFormData}
onChange={handleChange}
onValidationChange={setHasValidationErrors}
fieldOrder={sectionConfig.fieldOrder}
fieldGroups={sectionConfig.fieldGroups}
hiddenFields={effectiveHiddenFields}
advancedFields={sectionConfig.advancedFields}
liveValidate={sectionConfig.liveValidate}
uiSchema={sectionConfig.uiSchema}
disabled={disabled || isSaving}
readonly={readonly}
showSubmit={false}
i18nNamespace={configNamespace}
customValidate={customValidate}
formContext={{
level: effectiveLevel,
cameraName,
globalValue,
cameraValue,
hasChanges,
extraHasChanges,
setExtraHasChanges,
overrides: uiOverrides as JsonValue | undefined,
formData: currentFormData as ConfigSectionData,
baselineFormData: effectiveBaselineFormData as ConfigSectionData,
pendingDataBySection,
onPendingDataChange,
onFormDataChange: (data: ConfigSectionData) => handleChange(data),
// For widgets that need access to full camera config (e.g., zone names)
fullCameraConfig:
effectiveLevel === "camera" && cameraName
? config?.cameras?.[cameraName]
: undefined,
fullConfig: config,
// When rendering camera-level sections, provide the section path so
// field templates can look up keys under the `config/cameras` namespace
// When using a consolidated global namespace, keys are nested
// under the section name (e.g., `audio.label`) so provide the
// section prefix to templates so they can attempt `${section}.${field}` lookups.
sectionI18nPrefix: sectionPath,
t,
renderers: wrappedRenderers,
sectionDocs: sectionConfig.sectionDocs,
fieldDocs: sectionConfig.fieldDocs,
hiddenFields: effectiveHiddenFields,
restartRequired: sectionConfig.restartRequired,
requiresRestart,
isProfile: !!profileName,
}}
/>
<LiveFormDataContext.Provider
value={(currentFormData as ConfigSectionData | null) ?? null}
>
<ConfigForm
key={formKey}
schema={modifiedSchema}
formData={currentFormData}
onChange={handleChange}
onValidationChange={setHasValidationErrors}
fieldOrder={sectionConfig.fieldOrder}
fieldGroups={sectionConfig.fieldGroups}
hiddenFields={effectiveHiddenFields}
advancedFields={sectionConfig.advancedFields}
liveValidate={sectionConfig.liveValidate}
uiSchema={sectionConfig.uiSchema}
disabled={disabled || isSaving}
readonly={readonly}
showSubmit={false}
i18nNamespace={configNamespace}
customValidate={customValidate}
formContext={{
level: effectiveLevel,
cameraName,
globalValue,
cameraValue,
hasChanges,
extraHasChanges,
setExtraHasChanges,
overrides: uiOverrides as JsonValue | undefined,
formData: currentFormData as ConfigSectionData,
baselineFormData: effectiveBaselineFormData as ConfigSectionData,
pendingDataBySection,
onPendingDataChange,
onFormDataChange: (data: ConfigSectionData) => handleChange(data),
// For widgets that need access to full camera config (e.g., zone names)
fullCameraConfig:
effectiveLevel === "camera" && cameraName
? config?.cameras?.[cameraName]
: undefined,
fullConfig: config,
// When rendering camera-level sections, provide the section path so
// field templates can look up keys under the `config/cameras` namespace
// When using a consolidated global namespace, keys are nested
// under the section name (e.g., `audio.label`) so provide the
// section prefix to templates so they can attempt `${section}.${field}` lookups.
sectionI18nPrefix: sectionPath,
t,
renderers: wrappedRenderers,
sectionDocs: sectionConfig.sectionDocs,
fieldDocs: sectionConfig.fieldDocs,
hiddenFields: effectiveHiddenFields,
restartRequired: sectionConfig.restartRequired,
requiresRestart,
isProfile: !!profileName,
}}
/>
</LiveFormDataContext.Provider>
</FieldMessagesContext.Provider>
{!embedded && (

View File

@ -31,6 +31,7 @@ import { TimezoneSelectWidget } from "./widgets/TimezoneSelectWidget";
import { CameraPathWidget } from "./widgets/CameraPathWidget";
import { OptionalFieldWidget } from "./widgets/OptionalFieldWidget";
import { SemanticSearchModelWidget } from "./widgets/SemanticSearchModelWidget";
import { SemanticSearchModelSizeWidget } from "./widgets/SemanticSearchModelSizeWidget";
import { OnvifProfileWidget } from "./widgets/OnvifProfileWidget";
import { FieldTemplate } from "./templates/FieldTemplate";
@ -86,6 +87,7 @@ export const frigateTheme: FrigateTheme = {
timezoneSelect: TimezoneSelectWidget,
optionalField: OptionalFieldWidget,
semanticSearchModel: SemanticSearchModelWidget,
semanticSearchModelSize: SemanticSearchModelSizeWidget,
onvifProfile: OnvifProfileWidget,
},
templates: {

View File

@ -0,0 +1,57 @@
// Disables model_size and shows "N/A" when a GenAI provider is selected.
// Reads model via LiveFormDataContext so it re-runs even when RJSF's
// SchemaField memoization would skip this widget.
import type { WidgetProps } from "@rjsf/utils";
import { useContext, useEffect } from "react";
import { useTranslation } from "react-i18next";
import {
Select,
SelectContent,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { LiveFormDataContext } from "../../LiveFormDataContext";
import { getSizedFieldClassName } from "../utils";
import { SelectWidget } from "./SelectWidget";
export function SemanticSearchModelSizeWidget(props: WidgetProps) {
const { t } = useTranslation(["views/settings"]);
const liveFormData = useContext(LiveFormDataContext);
const model = liveFormData?.model;
const isProvider =
typeof model === "string" &&
model !== "" &&
model !== "jinav1" &&
model !== "jinav2";
// Clear model_size while on a provider (buildOverrides converts to ""
// which the backend treats as "remove"). Restore the schema default
// when returning to a Jina model so the field isn't left empty.
const { value, onChange, schema } = props;
const schemaDefault = schema?.default as string | undefined;
useEffect(() => {
if (isProvider && value !== undefined) {
onChange(undefined);
} else if (!isProvider && value === undefined && schemaDefault) {
onChange(schemaDefault);
}
}, [isProvider, value, onChange, schemaDefault]);
if (isProvider) {
const fieldClassName = getSizedFieldClassName(props.options ?? {}, "sm");
return (
<Select value="" disabled>
<SelectTrigger className={fieldClassName}>
<SelectValue
placeholder={t("configForm.semanticSearchModelSize.notApplicable", {
defaultValue: "Not applicable for GenAI providers",
})}
/>
</SelectTrigger>
<SelectContent />
</Select>
);
}
return <SelectWidget {...props} />;
}

View File

@ -1,5 +1,6 @@
import { useState, ReactNode, useCallback } from "react";
import { SearchResult } from "@/types/search";
import { REVIEW_PADDING } from "@/types/review";
import { FrigateConfig } from "@/types/frigateConfig";
import { baseUrl } from "@/api/baseUrl";
import { toast } from "sonner";
@ -94,8 +95,8 @@ export default function SearchResultActions({
axios
.post("debug_replay/start", {
camera: event.camera,
start_time: event.start_time,
end_time: event.end_time,
start_time: (event.start_time ?? 0) - REVIEW_PADDING,
end_time: (event.end_time ?? Date.now() / 1000) + REVIEW_PADDING,
})
.then((response) => {
if (response.status === 202 || response.status === 200) {
@ -129,9 +130,15 @@ export default function SearchResultActions({
},
);
} else {
toast.error(t("dialog.toast.error", { error: errorMessage }), {
position: "top-center",
});
toast.error(
t("dialog.toast.error", {
ns: "views/replay",
error: errorMessage,
}),
{
position: "top-center",
},
);
}
})
.finally(() => {
@ -205,7 +212,7 @@ export default function SearchResultActions({
<span>{t("itemMenu.addTrigger.label")}</span>
</MenuItem>
)}
{searchResult.has_clip && (
{isAdmin && searchResult.has_clip && (
<MenuItem
className="cursor-pointer"
aria-label={t("itemMenu.debugReplay.aria")}

View File

@ -9,7 +9,7 @@ import { useTranslation } from "react-i18next";
import { FaFilm } from "react-icons/fa6";
type ActionsDropdownProps = {
onDebugReplayClick: () => void;
onDebugReplayClick?: () => void;
onExportClick: () => void;
onShareTimestampClick: () => void;
};
@ -42,9 +42,11 @@ export default function ActionsDropdown({
<DropdownMenuItem onClick={onShareTimestampClick}>
{t("recording.shareTimestamp.label", { ns: "components/dialog" })}
</DropdownMenuItem>
<DropdownMenuItem onClick={onDebugReplayClick}>
{t("title", { ns: "views/replay" })}
</DropdownMenuItem>
{onDebugReplayClick && (
<DropdownMenuItem onClick={onDebugReplayClick}>
{t("title", { ns: "views/replay" })}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
);

View File

@ -0,0 +1,120 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { LuSettings } from "react-icons/lu";
import useSWR from "swr";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { ConfigSectionTemplate } from "@/components/config-form/sections/ConfigSectionTemplate";
import { Button } from "@/components/ui/button";
import { PlatformAwareSheet } from "@/components/overlay/dialog/PlatformAwareDialog";
import { useConfigSchema } from "@/hooks/use-config-schema";
import type { FrigateConfig } from "@/types/frigateConfig";
type DebugReplayConfigSheetProps = {
replayCamera: string | undefined;
};
export function DebugReplayConfigSheet({
replayCamera,
}: DebugReplayConfigSheetProps) {
const { t } = useTranslation(["views/replay"]);
const configSchema = useConfigSchema();
const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false,
});
const [open, setOpen] = useState(false);
return (
<PlatformAwareSheet
trigger={
<Button variant="outline" size="sm" className="flex items-center gap-2">
<LuSettings className="size-4" />
<span className="hidden md:inline">{t("page.configuration")}</span>
</Button>
}
title={t("page.configuration")}
titleClassName="text-lg font-semibold"
contentClassName="scrollbar-container flex flex-col gap-0 overflow-y-auto px-6 pb-6 sm:max-w-xl md:max-w-2xl xl:max-w-3xl"
content={
<>
<p className="mb-5 text-sm text-muted-foreground">
{t("page.configurationDesc")}
</p>
{configSchema == null ? (
<div className="flex h-40 items-center justify-center">
<ActivityIndicator />
</div>
) : (
<div className="space-y-6">
<ConfigSectionTemplate
sectionKey="detect"
level="replay"
cameraName={replayCamera}
skipSave
noStickyButtons
requiresRestart={false}
collapsible
defaultCollapsed={false}
showTitle
showOverrideIndicator={false}
/>
<ConfigSectionTemplate
sectionKey="motion"
level="replay"
cameraName={replayCamera}
skipSave
noStickyButtons
requiresRestart={false}
collapsible
defaultCollapsed={false}
showTitle
showOverrideIndicator={false}
/>
<ConfigSectionTemplate
sectionKey="objects"
level="replay"
cameraName={replayCamera}
skipSave
noStickyButtons
requiresRestart={false}
collapsible
defaultCollapsed={false}
showTitle
showOverrideIndicator={false}
/>
{config?.face_recognition?.enabled && (
<ConfigSectionTemplate
sectionKey="face_recognition"
level="replay"
cameraName={replayCamera}
skipSave
noStickyButtons
requiresRestart={false}
collapsible
defaultCollapsed={false}
showTitle
showOverrideIndicator={false}
/>
)}
{config?.lpr?.enabled && (
<ConfigSectionTemplate
sectionKey="lpr"
level="replay"
cameraName={replayCamera}
skipSave
noStickyButtons
requiresRestart={false}
collapsible
defaultCollapsed={false}
showTitle
showOverrideIndicator={false}
/>
)}
</div>
)}
</>
}
open={open}
onOpenChange={setOpen}
/>
);
}

View File

@ -29,6 +29,7 @@ import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { StartExportResponse } from "@/types/export";
import { ShareTimestampContent } from "./ShareTimestampDialog";
import { useIsAdmin } from "@/hooks/use-is-admin";
type DrawerMode =
| "none"
@ -109,6 +110,7 @@ export default function MobileReviewSettingsDrawer({
"views/replay",
"common",
]);
const isAdmin = useIsAdmin();
const navigate = useNavigate();
const [drawerMode, setDrawerMode] = useState<DrawerMode>("none");
const [exportTab, setExportTab] = useState<ExportTab>("export");
@ -388,7 +390,7 @@ export default function MobileReviewSettingsDrawer({
{t("filter")}
</Button>
)}
{features.includes("debug-replay") && (
{isAdmin && features.includes("debug-replay") && (
<Button
className="flex w-full items-center justify-center gap-2"
aria-label={t("title", { ns: "views/replay" })}

View File

@ -63,8 +63,8 @@ export default function DetailActionsMenu({
axios
.post("debug_replay/start", {
camera: search.camera,
start_time: search.start_time,
end_time: search.end_time,
start_time: (search.start_time ?? 0) - REVIEW_PADDING,
end_time: (search.end_time ?? Date.now() / 1000) + REVIEW_PADDING,
})
.then((response) => {
if (response.status === 202 || response.status === 200) {
@ -95,9 +95,15 @@ export default function DetailActionsMenu({
),
});
} else {
toast.error(t("dialog.toast.error", { error: errorMessage }), {
position: "top-center",
});
toast.error(
t("dialog.toast.error", {
ns: "views/replay",
error: errorMessage,
}),
{
position: "top-center",
},
);
}
})
.finally(() => {
@ -229,7 +235,7 @@ export default function DetailActionsMenu({
</DropdownMenuItem>
)}
{search.has_clip && (
{isAdmin && search.has_clip && (
<DropdownMenuItem
className="cursor-pointer"
aria-label={t("itemMenu.debugReplay.aria")}

View File

@ -12,6 +12,7 @@ import { baseUrl } from "@/api/baseUrl";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Event } from "@/types/event";
import { REVIEW_PADDING } from "@/types/review";
import { FrigateConfig } from "@/types/frigateConfig";
import { useCallback, useState } from "react";
import { useIsAdmin } from "@/hooks/use-is-admin";
@ -58,8 +59,8 @@ export default function EventMenu({
axios
.post("debug_replay/start", {
camera: event.camera,
start_time: event.start_time,
end_time: event.end_time,
start_time: (event.start_time ?? 0) - REVIEW_PADDING,
end_time: (event.end_time ?? Date.now() / 1000) + REVIEW_PADDING,
})
.then((response) => {
if (response.status === 202 || response.status === 200) {
@ -93,9 +94,15 @@ export default function EventMenu({
},
);
} else {
toast.error(t("dialog.toast.error", { error: errorMessage }), {
position: "top-center",
});
toast.error(
t("dialog.toast.error", {
ns: "views/replay",
error: errorMessage,
}),
{
position: "top-center",
},
);
}
})
.finally(() => {
@ -176,7 +183,7 @@ export default function EventMenu({
{t("itemMenu.findSimilar.label")}
</DropdownMenuItem>
)}
{event.has_clip && (
{isAdmin && event.has_clip && (
<DropdownMenuItem
className="cursor-pointer"
disabled={isStarting}

View File

@ -27,7 +27,7 @@ import {
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { PlatformAwareSheet } from "@/components/overlay/dialog/PlatformAwareDialog";
import { DebugReplayConfigSheet } from "@/components/overlay/DebugReplayConfigSheet";
import { useCameraActivity } from "@/hooks/use-camera-activity";
import { cn } from "@/lib/utils";
import Heading from "@/components/ui/heading";
@ -40,16 +40,14 @@ import { Progress } from "@/components/ui/progress";
import { ObjectType } from "@/types/ws";
import { useJobStatus } from "@/api/ws";
import WsMessageFeed from "@/components/ws/WsMessageFeed";
import { ConfigSectionTemplate } from "@/components/config-form/sections/ConfigSectionTemplate";
import { LuExternalLink, LuInfo, LuSettings } from "react-icons/lu";
import { LuExternalLink, LuInfo } from "react-icons/lu";
import { LuSquare } from "react-icons/lu";
import { MdReplay } from "react-icons/md";
import { isDesktop, isMobile } from "react-device-detect";
import Logo from "@/components/Logo";
import { Separator } from "@/components/ui/separator";
import { useDocDomain } from "@/hooks/use-doc-domain";
import { useConfigSchema } from "@/hooks/use-config-schema";
import DebugDrawingLayer from "@/components/overlay/DebugDrawingLayer";
import { IoMdArrowRoundBack } from "react-icons/io";
@ -125,7 +123,6 @@ export default function Replay() {
});
const { payload: replayJob } =
useJobStatus<DebugReplayJobResults>("debug_replay");
const configSchema = useConfigSchema();
const [isInitializing, setIsInitializing] = useState(true);
// Refresh status immediately on mount to avoid showing "no session" briefly
@ -139,7 +136,6 @@ 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();
@ -327,103 +323,8 @@ export default function Replay() {
)}
</Button>
<div className="flex items-center gap-2">
<PlatformAwareSheet
trigger={
<Button
variant="outline"
size="sm"
className="flex items-center gap-2"
>
<LuSettings className="size-4" />
<span className="hidden md:inline">
{t("page.configuration")}
</span>
</Button>
}
title={t("page.configuration")}
titleClassName="text-lg font-semibold"
contentClassName="scrollbar-container flex flex-col gap-0 overflow-y-auto px-6 pb-6 sm:max-w-xl md:max-w-2xl xl:max-w-3xl"
content={
<>
<p className="mb-5 text-sm text-muted-foreground">
{t("page.configurationDesc")}
</p>
{configSchema == null ? (
<div className="flex h-40 items-center justify-center">
<ActivityIndicator />
</div>
) : (
<div className="space-y-6">
<ConfigSectionTemplate
sectionKey="detect"
level="replay"
cameraName={status.replay_camera ?? undefined}
skipSave
noStickyButtons
requiresRestart={false}
collapsible
defaultCollapsed={false}
showTitle
showOverrideIndicator={false}
/>
<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}
/>
{config?.face_recognition?.enabled && (
<ConfigSectionTemplate
sectionKey="face_recognition"
level="replay"
cameraName={status.replay_camera ?? undefined}
skipSave
noStickyButtons
requiresRestart={false}
collapsible
defaultCollapsed={false}
showTitle
showOverrideIndicator={false}
/>
)}
{config?.lpr?.enabled && (
<ConfigSectionTemplate
sectionKey="lpr"
level="replay"
cameraName={status.replay_camera ?? undefined}
skipSave
noStickyButtons
requiresRestart={false}
collapsible
defaultCollapsed={false}
showTitle
showOverrideIndicator={false}
/>
)}
</div>
)}
</>
}
open={configDialogOpen}
onOpenChange={setConfigDialogOpen}
<DebugReplayConfigSheet
replayCamera={status.replay_camera ?? undefined}
/>
<AlertDialog>

View File

@ -16,6 +16,11 @@ import {
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { Button } from "@/components/ui/button";
import {
useCallback,
@ -589,7 +594,7 @@ function MobileMenuItem({
return (
<div
className={cn(
"inline-flex h-10 w-full cursor-pointer items-center justify-between whitespace-nowrap rounded-md px-4 py-2 pr-2 text-sm font-medium text-primary-variant disabled:pointer-events-none disabled:opacity-50",
"inline-flex h-10 w-full cursor-pointer items-center whitespace-nowrap rounded-md px-4 py-2 text-sm font-medium text-primary-variant disabled:pointer-events-none disabled:opacity-50",
className,
)}
onClick={() => {
@ -600,7 +605,6 @@ function MobileMenuItem({
<div className="w-full">
{label ?? <div>{t("menu." + item.key)}</div>}
</div>
<LuChevronRight className="size-4" />
</div>
);
}
@ -613,6 +617,39 @@ export default function Settings() {
const [sectionStatusByKey, setSectionStatusByKey] = useState<
Partial<Record<SettingsType, SectionStatus>>
>({});
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(
() =>
// all collapsed by default
new Set(
settingsGroups.filter((g) => g.items.length > 1).map((g) => g.label),
),
);
const toggleGroupCollapsed = useCallback((label: string) => {
setCollapsedGroups((prev) => {
const next = new Set(prev);
if (next.has(label)) {
next.delete(label);
} else {
next.add(label);
}
return next;
});
}, []);
// Auto-expand the group containing the active page whenever pageToggle changes
useEffect(() => {
const containingGroup = settingsGroups.find((group) =>
group.items.some((item) => item.key === pageToggle),
);
if (!containingGroup) return;
setCollapsedGroups((prev) => {
if (!prev.has(containingGroup.label)) return prev;
const next = new Set(prev);
next.delete(containingGroup.label);
return next;
});
}, [pageToggle]);
const { data: config } = useSWR<FrigateConfig>("config");
const { data: profilesData } = useSWR<ProfilesApiResponse>("profiles");
@ -1611,34 +1648,49 @@ export default function Settings() {
visibleSettingsViews.includes(item.key as SettingsType),
);
if (filteredItems.length === 0) return null;
const isMultiItem = filteredItems.length > 1;
const renderedExpanded =
!isMultiItem || !collapsedGroups.has(group.label);
const items = filteredItems.map((item) => (
<MobileMenuItem
key={item.key}
item={item}
className={cn(filteredItems.length == 1 && "pl-2")}
label={renderMenuItemLabel(item.key as SettingsType)}
onSelect={(key) => {
if (
!isAdmin &&
!ALLOWED_VIEWS_FOR_VIEWER.includes(key as SettingsType)
) {
setPageToggle("uiSettings");
} else {
setPageToggle(key as SettingsType);
}
setContentMobileOpen(true);
}}
/>
));
return (
<div key={group.label} className="mb-3">
{filteredItems.length > 1 && (
<h3 className="mb-2 ml-2 text-sm font-medium text-secondary-foreground">
<div>{t("menu." + group.label)}</div>
</h3>
{isMultiItem ? (
<Collapsible
open={renderedExpanded}
onOpenChange={() => toggleGroupCollapsed(group.label)}
>
<CollapsibleTrigger className="flex min-h-10 w-full items-center justify-between rounded-md py-2 pl-2 pr-2 text-sm font-medium text-secondary-foreground">
<div>{t("menu." + group.label)}</div>
<LuChevronRight
className={cn(
"size-4 shrink-0 transition-transform duration-200",
renderedExpanded && "rotate-90",
)}
/>
</CollapsibleTrigger>
<CollapsibleContent>{items}</CollapsibleContent>
</Collapsible>
) : (
items
)}
{filteredItems.map((item) => (
<MobileMenuItem
key={item.key}
item={item}
className={cn(filteredItems.length == 1 && "pl-2")}
label={renderMenuItemLabel(item.key as SettingsType)}
onSelect={(key) => {
if (
!isAdmin &&
!ALLOWED_VIEWS_FOR_VIEWER.includes(
key as SettingsType,
)
) {
setPageToggle("uiSettings");
} else {
setPageToggle(key as SettingsType);
}
setContentMobileOpen(true);
}}
/>
))}
</div>
);
})}
@ -1940,48 +1992,74 @@ export default function Settings() {
</SidebarMenuItem>
</SidebarMenu>
) : (
<>
<SidebarGroupLabel
className={cn(
"ml-2 cursor-default pl-0 text-sm",
filteredItems.some(
(item) => pageToggle === item.key,
)
? "text-primary"
: "text-sidebar-foreground/80",
)}
>
<div>{t("menu." + group.label)}</div>
</SidebarGroupLabel>
<SidebarMenuSub className="mx-2 border-0">
{filteredItems.map((item) => (
<SidebarMenuSubItem key={item.key}>
<SidebarMenuSubButton
className="h-auto w-full py-1.5"
isActive={pageToggle === item.key}
onClick={() => {
if (
!isAdmin &&
!ALLOWED_VIEWS_FOR_VIEWER.includes(
item.key as SettingsType,
)
) {
setPageToggle("uiSettings");
} else {
setPageToggle(item.key as SettingsType);
}
}}
>
<div className="w-full cursor-pointer">
{renderMenuItemLabel(
item.key as SettingsType,
(() => {
const hasActiveItem = filteredItems.some(
(item) => pageToggle === item.key,
);
const renderedExpanded = !collapsedGroups.has(
group.label,
);
return (
<Collapsible
open={renderedExpanded}
onOpenChange={() =>
toggleGroupCollapsed(group.label)
}
>
<SidebarGroupLabel
asChild
className={cn(
"ml-2 pl-0 text-sm",
hasActiveItem
? "text-primary"
: "text-sidebar-foreground/80",
)}
>
<CollapsibleTrigger className="flex w-full items-center justify-between">
<div>{t("menu." + group.label)}</div>
<LuChevronRight
className={cn(
"size-4 shrink-0 transition-transform duration-200",
renderedExpanded && "rotate-90",
)}
</div>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</>
/>
</CollapsibleTrigger>
</SidebarGroupLabel>
<CollapsibleContent>
<SidebarMenuSub className="mx-2 border-0 md:mx-0">
{filteredItems.map((item) => (
<SidebarMenuSubItem key={item.key}>
<SidebarMenuSubButton
className="h-auto w-full py-1.5"
isActive={pageToggle === item.key}
onClick={() => {
if (
!isAdmin &&
!ALLOWED_VIEWS_FOR_VIEWER.includes(
item.key as SettingsType,
)
) {
setPageToggle("uiSettings");
} else {
setPageToggle(
item.key as SettingsType,
);
}
}}
>
<div className="w-full cursor-pointer">
{renderMenuItemLabel(
item.key as SettingsType,
)}
</div>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
</Collapsible>
);
})()
)}
</SidebarGroup>
);

View File

@ -229,7 +229,12 @@ export function buildOverrides(
const result: JsonObject = {};
for (const [key, value] of Object.entries(currentObj)) {
if (value === undefined && baseObj && baseObj[key] !== undefined) {
if (
(value === undefined || value === null) &&
baseObj &&
baseObj[key] !== undefined &&
baseObj[key] !== null
) {
result[key] = "";
continue;
}

View File

@ -66,6 +66,7 @@ import SummaryTimeline from "@/components/timeline/SummaryTimeline";
import { RecordingStartingPoint } from "@/types/record";
import VideoControls from "@/components/player/VideoControls";
import { TimeRange } from "@/types/timeline";
import { useAllowedCameras } from "@/hooks/use-allowed-cameras";
import {
useCameraMotionNextTimestamp,
useCameraMotionOnlyRanges,
@ -1008,27 +1009,29 @@ function MotionReview({
const { t } = useTranslation(["views/events", "common"]);
const segmentDuration = 30;
const { data: config } = useSWR<FrigateConfig>("config");
const allowedCameras = useAllowedCameras();
const reviewCameras = useMemo(() => {
if (!config) {
return [];
}
let cameras;
if (!filter || !filter.cameras) {
cameras = Object.values(config.cameras).filter(
(cam) => !isReplayCamera(cam.name),
);
} else {
const filteredCams = filter.cameras;
cameras = Object.values(config.cameras).filter(
(cam) => filteredCams.includes(cam.name) && !isReplayCamera(cam.name),
);
}
const selectedCams = filter?.cameras;
const cameras = Object.values(config.cameras).filter((cam) => {
if (isReplayCamera(cam.name)) {
return false;
}
if (!allowedCameras.includes(cam.name)) {
return false;
}
if (selectedCams && !selectedCams.includes(cam.name)) {
return false;
}
return true;
});
return cameras.sort((a, b) => a.ui.order - b.ui.order);
}, [config, filter]);
}, [config, filter, allowedCameras]);
const videoPlayersRef = useRef<{ [camera: string]: PreviewController }>({});

View File

@ -13,6 +13,7 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useAllowedCameras } from "@/hooks/use-allowed-cameras";
import { useUserPersistence } from "@/hooks/use-user-persistence";
import {
AllGroupsStreamingSettings,
@ -90,6 +91,7 @@ export default function LiveDashboardView({
// recent events
const eventUpdate = useFrigateReviews();
const allowedCameras = useAllowedCameras();
const alertCameras = useMemo(() => {
if (!config) {
@ -98,14 +100,16 @@ export default function LiveDashboardView({
if (cameraGroup == "default") {
return Object.values(config.cameras)
.filter((cam) => cam.ui.dashboard)
.filter((cam) => cam.ui.dashboard && allowedCameras.includes(cam.name))
.map((cam) => cam.name)
.join(",");
}
if (includeBirdseye && cameras.length == 0) {
return Object.values(config.cameras)
.filter((cam) => cam.birdseye.enabled)
.filter(
(cam) => cam.birdseye.enabled && allowedCameras.includes(cam.name),
)
.map((cam) => cam.name)
.join(",");
}
@ -114,7 +118,7 @@ export default function LiveDashboardView({
.map((cam) => cam.name)
.filter((cam) => config.camera_groups[cameraGroup]?.cameras.includes(cam))
.join(",");
}, [cameras, cameraGroup, config, includeBirdseye]);
}, [cameras, cameraGroup, config, includeBirdseye, allowedCameras]);
const { data: allEvents, mutate: updateEvents } = useSWR<ReviewSegment[]>([
"review",

View File

@ -44,6 +44,7 @@ import {
import { IoMdArrowRoundBack } from "react-icons/io";
import { useLocation, useNavigate } from "react-router-dom";
import { Toaster } from "@/components/ui/sonner";
import { useIsAdmin } from "@/hooks/use-is-admin";
import useSWR from "swr";
import { TimeRange, TimelineType } from "@/types/timeline";
import MobileCameraDrawer from "@/components/overlay/MobileCameraDrawer";
@ -109,6 +110,7 @@ export function RecordingView({
}: RecordingViewProps) {
const { t } = useTranslation(["views/events", "components/dialog"]);
const { data: config } = useSWR<FrigateConfig>("config");
const isAdmin = useIsAdmin();
const navigate = useNavigate();
const location = useLocation();
const contentRef = useRef<HTMLDivElement | null>(null);
@ -723,13 +725,17 @@ export function RecordingView({
setCustomShareTimestamp(initialTimestamp);
setShareTimestampOpen(true);
}}
onDebugReplayClick={() => {
setDebugReplayRange({
after: timeRange.before - 60,
before: timeRange.before,
});
setDebugReplayMode("select");
}}
onDebugReplayClick={
isAdmin
? () => {
setDebugReplayRange({
after: timeRange.before - 60,
before: timeRange.before,
});
setDebugReplayMode("select");
}
: undefined
}
onExportClick={() => {
const now = new Date(timeRange.before * 1000);
now.setHours(now.getHours() - 1);