Add ability to hide cameras from review UI (#23387)
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

* Add field to control if cameras show in review

* i18n

* Add config to UI
This commit is contained in:
Nicolas Mowen 2026-06-02 15:11:42 -06:00 committed by GitHub
parent c25a522fcc
commit 3f0ebb3577
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 136 additions and 20 deletions

View File

@ -16,3 +16,8 @@ class CameraUiConfig(FrigateBaseModel):
title="Show in UI", title="Show in UI",
description="Toggle whether this camera is visible everywhere in the Frigate UI. Disabling this will require manually editing the config to view this camera in the UI again.", description="Toggle whether this camera is visible everywhere in the Frigate UI. Disabling this will require manually editing the config to view this camera in the UI again.",
) )
review: bool = Field(
default=True,
title="Show in review",
description="Toggle whether this camera is visible in review (the review page and its camera filter, motion review, and the history view).",
)

View File

@ -862,6 +862,10 @@
"dashboard": { "dashboard": {
"label": "Show in UI", "label": "Show in UI",
"description": "Toggle whether this camera is visible everywhere in the Frigate UI. Disabling this will require manually editing the config to view this camera in the UI again." "description": "Toggle whether this camera is visible everywhere in the Frigate UI. Disabling this will require manually editing the config to view this camera in the UI again."
},
"review": {
"label": "Show in review",
"description": "Toggle whether this camera is visible in review (the review page and its camera filter, motion review, and the history view)."
} }
}, },
"webui_url": { "webui_url": {

View File

@ -1546,6 +1546,10 @@
"dashboard": { "dashboard": {
"label": "Show in UI", "label": "Show in UI",
"description": "Toggle whether this camera is visible everywhere in the Frigate UI. Disabling this will require manually editing the config to view this camera in the UI again." "description": "Toggle whether this camera is visible everywhere in the Frigate UI. Disabling this will require manually editing the config to view this camera in the UI again."
},
"review": {
"label": "Show in review",
"description": "Toggle whether this camera is visible in review (the review page and its camera filter, motion review, and the history view)."
} }
}, },
"onvif": { "onvif": {

View File

@ -492,12 +492,16 @@
"details": { "details": {
"edit": "Edit camera details", "edit": "Edit camera details",
"title": "Edit Camera Details", "title": "Edit Camera Details",
"description": "Update the display name and external URL used for this camera throughout the Frigate UI.", "description": "Update the display name, external URL, and visibility used for this camera throughout the Frigate UI.",
"friendlyNameLabel": "Display Name", "friendlyNameLabel": "Display Name",
"friendlyNameHelp": "Friendly name shown for this camera throughout the Frigate UI. Leave blank to use the camera ID.", "friendlyNameHelp": "Friendly name shown for this camera throughout the Frigate UI. Leave blank to use the camera ID.",
"webuiUrlLabel": "Camera Web UI URL", "webuiUrlLabel": "Camera Web UI URL",
"webuiUrlHelp": "URL to visit the camera's web UI directly from the Debug view. Leave blank to disable the link.", "webuiUrlHelp": "URL to visit the camera's web UI directly from the Debug view. Leave blank to disable the link.",
"webuiUrlInvalid": "Must be a valid URL (e.g., https://example.com)." "webuiUrlInvalid": "Must be a valid URL (e.g., https://example.com).",
"dashboardLabel": "Show on Live dashboard",
"dashboardHelp": "Show this camera on the Live dashboard.",
"reviewLabel": "Show in Review",
"reviewHelp": "Show this camera in Review, including the camera filter, motion review, and the history view."
} }
}, },
"cameraConfig": { "cameraConfig": {

View File

@ -144,11 +144,13 @@ export default function ReviewFilterGroup({
const filterValues = useMemo( const filterValues = useMemo(
() => ({ () => ({
cameras: allowedCameras.sort( cameras: allowedCameras
(a, b) => .filter((cam) => config?.cameras[cam]?.ui?.review !== false)
(config?.cameras[a]?.ui?.order ?? 0) - .sort(
(config?.cameras[b]?.ui?.order ?? 0), (a, b) =>
), (config?.cameras[a]?.ui?.order ?? 0) -
(config?.cameras[b]?.ui?.order ?? 0),
),
labels: Object.values(allLabels || {}), labels: Object.values(allLabels || {}),
zones: Object.values(allZones || {}), zones: Object.values(allZones || {}),
}), }),

View File

@ -70,13 +70,15 @@ export default function Events() {
undefined, undefined,
); );
const motionSearchCameras = useMemo(() => { const reviewCameras = useMemo(() => {
if (!config?.cameras) { if (!config?.cameras) {
return [] as string[]; return [] as string[];
} }
return Object.keys(config.cameras).filter((cam) => return Object.keys(config.cameras).filter(
allowedCameras.includes(cam), (cam) =>
allowedCameras.includes(cam) &&
config.cameras[cam]?.ui?.review !== false,
); );
}, [allowedCameras, config?.cameras]); }, [allowedCameras, config?.cameras]);
@ -85,12 +87,12 @@ export default function Events() {
return null; return null;
} }
if (motionSearchCameras.includes(motionSearchCamera)) { if (reviewCameras.includes(motionSearchCamera)) {
return motionSearchCamera; return motionSearchCamera;
} }
return motionSearchCameras[0] ?? null; return reviewCameras[0] ?? null;
}, [motionSearchCamera, motionSearchCameras]); }, [motionSearchCamera, reviewCameras]);
const motionSearchTimeRange = useMemo(() => { const motionSearchTimeRange = useMemo(() => {
if (motionSearchDay) { if (motionSearchDay) {
@ -357,6 +359,10 @@ export default function Events() {
const motion: ReviewSegment[] = []; const motion: ReviewSegment[] = [];
reviews?.forEach((segment) => { reviews?.forEach((segment) => {
if (config?.cameras[segment.camera]?.ui?.review === false) {
return;
}
all.push(segment); all.push(segment);
switch (segment.severity) { switch (segment.severity) {
@ -378,7 +384,7 @@ export default function Events() {
detection: detections, detection: detections,
significant_motion: motion, significant_motion: motion,
}; };
}, [reviews]); }, [reviews, config?.cameras]);
// update review items in place when a review segment ends // update review items in place when a review segment ends
const reviewUpdate = useFrigateReviews(); const reviewUpdate = useFrigateReviews();
@ -635,7 +641,7 @@ export default function Events() {
} }
setStartTime(recording.startTime); setStartTime(recording.startTime);
const allCameras = reviewFilter?.cameras ?? allowedCameras; const allCameras = reviewFilter?.cameras ?? reviewCameras;
return { return {
camera: recording.camera, camera: recording.camera,
@ -680,7 +686,7 @@ export default function Events() {
) : ( ) : (
<MotionSearchView <MotionSearchView
config={config} config={config}
cameras={motionSearchCameras} cameras={reviewCameras}
selectedCamera={selectedMotionSearchCamera} selectedCamera={selectedMotionSearchCamera}
onCameraSelect={handleMotionSearchCameraSelect} onCameraSelect={handleMotionSearchCameraSelect}
cameraLocked={true} cameraLocked={true}

View File

@ -5,6 +5,7 @@ export interface UiConfig {
timezone?: string; timezone?: string;
time_format?: "browser" | "12hour" | "24hour"; time_format?: "browser" | "12hour" | "24hour";
dashboard: boolean; dashboard: boolean;
review: boolean;
order: number; order: number;
unit_system?: "metric" | "imperial"; unit_system?: "metric" | "imperial";
} }

View File

@ -123,8 +123,13 @@ export function RecordingView({
const allowedCameras = useAllowedCameras(); const allowedCameras = useAllowedCameras();
const effectiveCameras = useMemo( const effectiveCameras = useMemo(
() => allCameras.filter((camera) => allowedCameras.includes(camera)), () =>
[allCameras, allowedCameras], allCameras.filter(
(camera) =>
allowedCameras.includes(camera) &&
config?.cameras[camera]?.ui?.review !== false,
),
[allCameras, allowedCameras, config?.cameras],
); );
const [mainCamera, setMainCamera] = useState(startCamera); const [mainCamera, setMainCamera] = useState(startCamera);

View File

@ -75,6 +75,7 @@ import {
FormLabel, FormLabel,
FormMessage, FormMessage,
} from "@/components/ui/form"; } from "@/components/ui/form";
import { Switch } from "@/components/ui/switch";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod"; import { z } from "zod";
@ -704,6 +705,8 @@ type CameraDetailsEditorProps = {
type CameraDetailsFormValues = { type CameraDetailsFormValues = {
friendlyName: string; friendlyName: string;
webuiUrl: string; webuiUrl: string;
dashboard: boolean;
review: boolean;
}; };
function CameraDetailsEditor({ function CameraDetailsEditor({
@ -717,11 +720,15 @@ function CameraDetailsEditor({
const currentFriendlyName = config?.cameras?.[cameraName]?.friendly_name; const currentFriendlyName = config?.cameras?.[cameraName]?.friendly_name;
const currentWebuiUrl = config?.cameras?.[cameraName]?.webui_url; const currentWebuiUrl = config?.cameras?.[cameraName]?.webui_url;
const currentDashboard = config?.cameras?.[cameraName]?.ui?.dashboard ?? true;
const currentReview = config?.cameras?.[cameraName]?.ui?.review ?? true;
const formSchema = useMemo( const formSchema = useMemo(
() => () =>
z.object({ z.object({
friendlyName: z.string(), friendlyName: z.string(),
dashboard: z.boolean(),
review: z.boolean(),
webuiUrl: z.string().refine( webuiUrl: z.string().refine(
(val) => { (val) => {
const trimmed = val.trim(); const trimmed = val.trim();
@ -748,6 +755,8 @@ function CameraDetailsEditor({
defaultValues: { defaultValues: {
friendlyName: currentFriendlyName ?? "", friendlyName: currentFriendlyName ?? "",
webuiUrl: currentWebuiUrl ?? "", webuiUrl: currentWebuiUrl ?? "",
dashboard: currentDashboard,
review: currentReview,
}, },
}); });
@ -757,9 +766,18 @@ function CameraDetailsEditor({
form.reset({ form.reset({
friendlyName: currentFriendlyName ?? "", friendlyName: currentFriendlyName ?? "",
webuiUrl: currentWebuiUrl ?? "", webuiUrl: currentWebuiUrl ?? "",
dashboard: currentDashboard,
review: currentReview,
}); });
} }
}, [open, currentFriendlyName, currentWebuiUrl, form]); }, [
open,
currentFriendlyName,
currentWebuiUrl,
currentDashboard,
currentReview,
form,
]);
const onSubmit = useCallback( const onSubmit = useCallback(
async (values: CameraDetailsFormValues) => { async (values: CameraDetailsFormValues) => {
@ -768,7 +786,7 @@ function CameraDetailsEditor({
// only send fields the user actually changed // only send fields the user actually changed
const newFriendly = values.friendlyName.trim() || null; const newFriendly = values.friendlyName.trim() || null;
const newWebui = values.webuiUrl.trim() || null; const newWebui = values.webuiUrl.trim() || null;
const cameraUpdate: Record<string, string | null> = {}; const cameraUpdate: Record<string, unknown> = {};
if (newFriendly !== (currentFriendlyName ?? null)) { if (newFriendly !== (currentFriendlyName ?? null)) {
cameraUpdate.friendly_name = newFriendly; cameraUpdate.friendly_name = newFriendly;
} }
@ -776,6 +794,17 @@ function CameraDetailsEditor({
cameraUpdate.webui_url = newWebui; cameraUpdate.webui_url = newWebui;
} }
const uiUpdate: Record<string, boolean> = {};
if (values.dashboard !== currentDashboard) {
uiUpdate.dashboard = values.dashboard;
}
if (values.review !== currentReview) {
uiUpdate.review = values.review;
}
if (Object.keys(uiUpdate).length > 0) {
cameraUpdate.ui = uiUpdate;
}
if (Object.keys(cameraUpdate).length === 0) { if (Object.keys(cameraUpdate).length === 0) {
setOpen(false); setOpen(false);
return; return;
@ -818,6 +847,8 @@ function CameraDetailsEditor({
cameraName, cameraName,
currentFriendlyName, currentFriendlyName,
currentWebuiUrl, currentWebuiUrl,
currentDashboard,
currentReview,
isSaving, isSaving,
onConfigChanged, onConfigChanged,
t, t,
@ -914,6 +945,60 @@ function CameraDetailsEditor({
</FormItem> </FormItem>
)} )}
/> />
<FormField
control={form.control}
name="dashboard"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between gap-3">
<div className="space-y-0.5">
<FormLabel>
{t("cameraManagement.streams.details.dashboardLabel", {
ns: "views/settings",
})}
</FormLabel>
<p className="text-xs text-muted-foreground">
{t("cameraManagement.streams.details.dashboardHelp", {
ns: "views/settings",
})}
</p>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
disabled={isSaving}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="review"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between gap-3">
<div className="space-y-0.5">
<FormLabel>
{t("cameraManagement.streams.details.reviewLabel", {
ns: "views/settings",
})}
</FormLabel>
<p className="text-xs text-muted-foreground">
{t("cameraManagement.streams.details.reviewHelp", {
ns: "views/settings",
})}
</p>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
disabled={isSaving}
/>
</FormControl>
</FormItem>
)}
/>
<DialogFooter className="pt-2"> <DialogFooter className="pt-2">
<Button <Button
type="button" type="button"