UI tweaks (#23304)
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

* restructure camera enable/disable pane

* remove obsolete camera edit form

* change terminology to off/on instead of disabled/enabled

* docs

* move menu options and add current camera name badge

* docs

* tweaks
This commit is contained in:
Josh Hawkins
2026-05-24 14:59:56 -06:00
committed by GitHub
parent ec44398b1c
commit 7e0e0635b8
17 changed files with 419 additions and 1083 deletions
+1 -1
View File
@@ -12,7 +12,7 @@
"title": "Stream Offline",
"desc": "No frames have been received on the {{cameraName}} <code>detect</code> stream, check error logs"
},
"cameraDisabled": "Camera is disabled",
"cameraOff": "Camera is off",
"stats": {
"streamType": {
"title": "Stream Type:",
+3 -3
View File
@@ -57,8 +57,8 @@
"presets": "PTZ camera presets"
},
"camera": {
"enable": "Enable Camera",
"disable": "Disable Camera"
"turnOn": "Turn Camera On",
"turnOff": "Turn Camera Off"
},
"muteCameras": {
"enable": "Mute All Cameras",
@@ -153,7 +153,7 @@
},
"cameraSettings": {
"title": "{{camera}} Settings",
"cameraEnabled": "Camera Enabled",
"camera": "Camera",
"objectDetection": "Object Detection",
"recording": "Recording",
"snapshots": "Snapshots",
+16 -11
View File
@@ -103,7 +103,7 @@
"cameraUi": "Camera UI",
"cameraTimestampStyle": "Timestamp style",
"cameraMqtt": "Camera MQTT",
"cameraManagement": "Management",
"cameraManagement": "Camera management",
"cameraReview": "Review",
"masksAndZones": "Masks / Zones",
"motionTuner": "Motion tuner",
@@ -457,7 +457,7 @@
},
"cameraManagement": {
"title": "Manage Cameras",
"description": "Add, edit, and delete cameras, control which cameras are enabled, and configure per-profile and camera type overrides. To configure streams, detection, motion, and other camera-specific settings, choose the specific section under Camera Configuration.",
"description": "Add, edit, and delete cameras, control the state of each camera, and configure per-profile and camera type overrides. To configure streams, detection, motion, and other camera-specific settings, choose the specific section under Camera Configuration.",
"addCamera": "Add New Camera",
"deleteCamera": "Delete Camera",
"deleteCameraDialog": {
@@ -475,12 +475,17 @@
"selectCamera": "Select a Camera",
"backToSettings": "Back to Camera Settings",
"streams": {
"title": "Enable / Disable Cameras",
"enableLabel": "Enabled cameras",
"enableDesc": "Temporarily disable an enabled camera until Frigate restarts. Disabling a camera completely stops Frigate's processing of this camera's streams. Detection, recording, and debugging will be unavailable.<br /> <em>Note: This does not disable go2rtc restreams.</em><br /><br />Drag the handle to reorder the cameras as they appear in the UI. The order of enabled cameras will be reflected throughout the UI including the Live dashboard and camera selection dropdowns.",
"disableLabel": "Disabled cameras",
"disableDesc": "Enable a camera that is currently not visible in the UI and disabled in the configuration. A restart of Frigate is required after enabling.",
"enableSuccess": "Enabled {{cameraName}} in configuration. Restart Frigate to apply the changes.",
"title": "Camera State and Details",
"label": "Camera state",
"description": "Set the operating state for each camera.<br /><br /><strong>On</strong>: streams are processed normally.<br /><strong>Off</strong>: temporarily pauses processing. Does not persist across Frigate restarts.<br /><strong>Disabled</strong>: stops processing and saves the change to your configuration. A restart is required to re-enable a disabled camera.<br /><br /><em>Note: Disabling does not affect go2rtc restreams.</em><br /><br />Drag the handle to reorder active cameras as they appear throughout the UI, including the Live dashboard and camera selection dropdowns.",
"disabledSubheading": "Disabled in configuration",
"status": {
"on": "On",
"off": "Off",
"disabled": "Disabled"
},
"enableSuccess": "Enabled {{cameraName}}. Restart Frigate to apply.",
"disableSuccess": "Disabled {{cameraName}} and saved to configuration.",
"reorderHandle": "Drag to reorder",
"saving": "Saving…",
"saved": "Saved",
@@ -527,10 +532,10 @@
"profiles": {
"title": "Profile Camera Overrides",
"selectLabel": "Select profile",
"description": "Configure which cameras are enabled or disabled when a profile is activated. Cameras set to \"Inherit\" keep their base enabled state.",
"description": "Configure which cameras are turned on or off when a profile is activated. Cameras set to \"Inherit\" keep their default state.",
"inherit": "Inherit",
"enabled": "Enabled",
"disabled": "Disabled"
"on": "On",
"off": "Off"
},
"cameraType": {
"title": "Camera Type",
+1 -1
View File
@@ -320,7 +320,7 @@ export default function LiveContextMenu({
onClick={() => sendEnabled(isEnabled ? "OFF" : "ON")}
>
<div className="text-primary">
{isEnabled ? t("camera.disable") : t("camera.enable")}
{isEnabled ? t("camera.turnOff") : t("camera.turnOn")}
</div>
</div>
</ContextMenuItem>
+1 -1
View File
@@ -488,7 +488,7 @@ export default function LivePlayer({
<div className="flex h-32 flex-col items-center justify-center rounded-lg p-4 md:h-48 md:w-48">
<LuVideoOff className="mb-2 size-8 md:size-10" />
<p className="max-w-32 text-center text-sm md:max-w-40 md:text-base">
{t("cameraDisabled")}
{t("cameraOff")}
</p>
</div>
</div>
@@ -1,755 +0,0 @@
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Card, CardContent } from "@/components/ui/card";
import Heading from "@/components/ui/heading";
import { Separator } from "@/components/ui/separator";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm, useFieldArray } from "react-hook-form";
import { z } from "zod";
import axios from "axios";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { useState, useMemo, useEffect } from "react";
import { LuTrash2, LuPlus } from "react-icons/lu";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { FrigateConfig } from "@/types/frigateConfig";
import useSWR from "swr";
import { processCameraName } from "@/utils/cameraUtil";
import { Label } from "@/components/ui/label";
import { ConfigSetBody } from "@/types/cameraWizard";
import { Toaster } from "../ui/sonner";
const RoleEnum = z.enum(["audio", "detect", "record"]);
type Role = z.infer<typeof RoleEnum>;
type CameraEditFormProps = {
cameraName?: string;
onSave?: () => void;
onCancel?: () => void;
};
export default function CameraEditForm({
cameraName,
onSave,
onCancel,
}: CameraEditFormProps) {
const { t } = useTranslation(["views/settings"]);
const { data: config, mutate: mutateConfig } =
useSWR<FrigateConfig>("config");
const { data: rawPaths, mutate: mutateRawPaths } = useSWR<{
cameras: Record<
string,
{ ffmpeg: { inputs: { path: string; roles: string[] }[] } }
>;
go2rtc: { streams: Record<string, string | string[]> };
}>(cameraName ? "config/raw_paths" : null);
const [isLoading, setIsLoading] = useState(false);
const formSchema = useMemo(
() =>
z.object({
cameraName: z
.string()
.min(1, { message: t("cameraManagement.cameraConfig.nameRequired") }),
enabled: z.boolean(),
ffmpeg: z.object({
inputs: z
.array(
z.object({
path: z.string().min(1, {
message: t(
"cameraManagement.cameraConfig.ffmpeg.pathRequired",
),
}),
roles: z.array(RoleEnum).min(1, {
message: t(
"cameraManagement.cameraConfig.ffmpeg.rolesRequired",
),
}),
}),
)
.min(1, {
message: t("cameraManagement.cameraConfig.ffmpeg.inputsRequired"),
})
.refine(
(inputs) => {
const roleOccurrences = new Map<Role, number>();
inputs.forEach((input) => {
input.roles.forEach((role) => {
roleOccurrences.set(
role,
(roleOccurrences.get(role) || 0) + 1,
);
});
});
return Array.from(roleOccurrences.values()).every(
(count) => count <= 1,
);
},
{
message: t("cameraManagement.cameraConfig.ffmpeg.rolesUnique"),
path: ["inputs"],
},
),
}),
go2rtcStreams: z.record(z.string(), z.array(z.string())).optional(),
}),
[t],
);
type FormValues = z.infer<typeof formSchema>;
const cameraInfo = useMemo(() => {
if (!cameraName || !config?.cameras[cameraName]) {
return {
friendly_name: undefined,
name: cameraName || "",
roles: new Set<Role>(),
go2rtcStreams: {},
};
}
const camera = config.cameras[cameraName];
const roles = new Set<Role>();
camera.ffmpeg?.inputs?.forEach((input) => {
input.roles.forEach((role) => roles.add(role as Role));
});
// Load existing go2rtc streams
const go2rtcStreams = config.go2rtc?.streams || {};
return {
friendly_name: camera?.friendly_name || cameraName,
name: cameraName,
roles,
go2rtcStreams,
};
}, [cameraName, config]);
const defaultValues: FormValues = {
cameraName: cameraInfo?.friendly_name || cameraName || "",
enabled: true,
ffmpeg: {
inputs: [
{
path: "",
roles: cameraInfo.roles.has("detect") ? [] : ["detect"],
},
],
},
go2rtcStreams: {},
};
// Load existing camera config if editing
if (cameraName && config?.cameras[cameraName]) {
const camera = config.cameras[cameraName];
defaultValues.enabled = camera.enabled ?? true;
// Use raw paths from the admin endpoint if available, otherwise fall back to masked paths
const rawCameraData = rawPaths?.cameras?.[cameraName];
defaultValues.ffmpeg.inputs = rawCameraData?.ffmpeg?.inputs?.length
? rawCameraData.ffmpeg.inputs.map((input) => ({
path: input.path,
roles: input.roles as Role[],
}))
: camera.ffmpeg?.inputs?.length
? camera.ffmpeg.inputs.map((input) => ({
path: input.path,
roles: input.roles as Role[],
}))
: defaultValues.ffmpeg.inputs;
const go2rtcStreams =
rawPaths?.go2rtc?.streams || config.go2rtc?.streams || {};
const cameraStreams: Record<string, string[]> = {};
// get candidate stream names for this camera. could be the camera's own name,
// any restream names referenced by this camera, or any keys under live --> streams
const validNames = new Set<string>();
validNames.add(cameraName);
// deduce go2rtc stream names from rtsp restream inputs
camera.ffmpeg?.inputs?.forEach((input) => {
// exclude any query strings or trailing slashes from the stream name
const restreamMatch = input.path.match(
/^rtsp:\/\/127\.0\.0\.1:8554\/([^?#/]+)(?:[?#].*)?$/,
);
if (restreamMatch) {
const streamName = restreamMatch[1];
validNames.add(streamName);
}
});
// Include live --> streams keys
const liveStreams = camera?.live?.streams;
if (liveStreams) {
Object.keys(liveStreams).forEach((key) => {
validNames.add(key);
});
}
// Map only go2rtc entries that match the collected names
Object.entries(go2rtcStreams).forEach(([name, urls]) => {
if (validNames.has(name)) {
cameraStreams[name] = Array.isArray(urls) ? urls : [urls];
}
});
defaultValues.go2rtcStreams = cameraStreams;
}
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues,
mode: "onChange",
});
// Update form values when rawPaths loads
useEffect(() => {
if (
cameraName &&
config?.cameras[cameraName] &&
rawPaths?.cameras?.[cameraName]
) {
const camera = config.cameras[cameraName];
const rawCameraData = rawPaths.cameras[cameraName];
// Update ffmpeg inputs with raw paths
if (rawCameraData.ffmpeg?.inputs?.length) {
form.setValue(
"ffmpeg.inputs",
rawCameraData.ffmpeg.inputs.map((input) => ({
path: input.path,
roles: input.roles as Role[],
})),
);
}
// Update go2rtc streams with raw URLs
if (rawPaths.go2rtc?.streams) {
const validNames = new Set<string>();
validNames.add(cameraName);
camera.ffmpeg?.inputs?.forEach((input) => {
const restreamMatch = input.path.match(
/^rtsp:\/\/127\.0\.0\.1:8554\/([^?#/]+)(?:[?#].*)?$/,
);
if (restreamMatch) {
validNames.add(restreamMatch[1]);
}
});
const liveStreams = camera?.live?.streams;
if (liveStreams) {
Object.keys(liveStreams).forEach((key) => validNames.add(key));
}
const cameraStreams: Record<string, string[]> = {};
Object.entries(rawPaths.go2rtc.streams).forEach(([name, urls]) => {
if (validNames.has(name)) {
cameraStreams[name] = Array.isArray(urls) ? urls : [urls];
}
});
if (Object.keys(cameraStreams).length > 0) {
form.setValue("go2rtcStreams", cameraStreams);
}
}
}
}, [cameraName, config, rawPaths, form]);
const { fields, append, remove } = useFieldArray({
control: form.control,
name: "ffmpeg.inputs",
});
// Watch ffmpeg.inputs to track used roles
const watchedInputs = form.watch("ffmpeg.inputs");
// Watch go2rtc streams
const watchedGo2rtcStreams = form.watch("go2rtcStreams") || {};
const saveCameraConfig = (values: FormValues) => {
setIsLoading(true);
const { finalCameraName, friendlyName } = processCameraName(
values.cameraName,
);
const configData: ConfigSetBody["config_data"] = {
cameras: {
[finalCameraName]: {
enabled: values.enabled,
...(friendlyName && { friendly_name: friendlyName }),
ffmpeg: {
inputs: values.ffmpeg.inputs.map((input) => ({
path: input.path,
roles: input.roles,
})),
},
},
},
};
// Add go2rtc streams if provided
if (values.go2rtcStreams && Object.keys(values.go2rtcStreams).length > 0) {
configData.go2rtc = {
streams: values.go2rtcStreams,
};
}
const requestBody: ConfigSetBody = {
requires_restart: 1,
config_data: configData,
};
// Add update_topic for new cameras
if (!cameraName) {
requestBody.update_topic = `config/cameras/${finalCameraName}/add`;
}
axios
.put("config/set", requestBody)
.then((res) => {
if (res.status === 200) {
// Update running go2rtc instance if streams were configured
if (
values.go2rtcStreams &&
Object.keys(values.go2rtcStreams).length > 0
) {
const updatePromises = Object.entries(values.go2rtcStreams).map(
([streamName, urls]) =>
axios.put(
`go2rtc/streams/${streamName}?src=${encodeURIComponent(urls[0])}`,
),
);
Promise.allSettled(updatePromises).then(() => {
toast.success(
t("cameraManagement.cameraConfig.toast.success", {
cameraName: values.cameraName,
}),
{ position: "top-center" },
);
mutateConfig();
mutateRawPaths();
if (onSave) onSave();
});
} else {
toast.success(
t("cameraManagement.cameraConfig.toast.success", {
cameraName: values.cameraName,
}),
{ position: "top-center" },
);
mutateConfig();
mutateRawPaths();
if (onSave) onSave();
}
} else {
throw new Error(res.statusText);
}
})
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(
t("toast.save.error.title", { errorMessage, ns: "common" }),
{ position: "top-center" },
);
})
.finally(() => {
setIsLoading(false);
});
};
const onSubmit = (values: FormValues) => {
if (
cameraName &&
values.cameraName !== cameraName &&
values.cameraName !== cameraInfo?.friendly_name
) {
// If camera name changed, delete old camera config
const deleteRequestBody = {
requires_restart: 1,
config_data: {
cameras: {
[cameraName]: null,
},
},
update_topic: `config/cameras/${cameraName}/remove`,
};
axios
.put("config/set", deleteRequestBody)
.then(() => saveCameraConfig(values))
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(
t("toast.save.error.title", { errorMessage, ns: "common" }),
{ position: "top-center" },
);
})
.finally(() => {
setIsLoading(false);
});
} else {
saveCameraConfig(values);
}
};
// Determine available roles for new streams
const getAvailableRoles = (): Role[] => {
const used = new Set<Role>();
watchedInputs.forEach((input) => {
input.roles.forEach((role) => used.add(role));
});
return used.has("detect") ? [] : ["detect"];
};
const getUsedRolesExcludingIndex = (excludeIndex: number) => {
const roles = new Set<Role>();
watchedInputs.forEach((input, idx) => {
if (idx !== excludeIndex) {
input.roles.forEach((role) => roles.add(role));
}
});
return roles;
};
return (
<div className="scrollbar-container max-w-4xl overflow-y-auto md:mb-24">
<Toaster position="top-center" closeButton />
<Heading as="h3" className="my-2">
{cameraName
? t("cameraManagement.cameraConfig.edit")
: t("cameraManagement.cameraConfig.add")}
</Heading>
<div className="my-3 text-sm text-muted-foreground">
{t("cameraManagement.cameraConfig.description")}
</div>
<Separator className="my-3 bg-secondary" />
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="cameraName"
render={({ field }) => (
<FormItem>
<FormLabel>{t("cameraManagement.cameraConfig.name")}</FormLabel>
<FormControl>
<Input
placeholder={t(
"cameraManagement.cameraConfig.namePlaceholder",
)}
{...field}
disabled={!!cameraName} // Prevent editing name for existing cameras
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel>
{t("cameraManagement.cameraConfig.enabled")}
</FormLabel>
<FormMessage />
</FormItem>
)}
/>
<div className="space-y-4">
<Label className="text-sm font-medium">
{t("cameraManagement.cameraConfig.ffmpeg.inputs")}
</Label>
{fields.map((field, index) => (
<Card key={field.id} className="bg-secondary text-primary">
<CardContent className="space-y-4 p-4">
<div className="flex items-center justify-between">
<h4 className="font-medium">
{t("cameraWizard.step3.streamTitle", {
number: index + 1,
})}
</h4>
<Button
variant="ghost"
size="sm"
onClick={() => remove(index)}
disabled={fields.length === 1}
className="text-secondary-foreground hover:text-secondary-foreground"
>
<LuTrash2 className="size-5" />
</Button>
</div>
<FormField
control={form.control}
name={`ffmpeg.inputs.${index}.path`}
render={({ field }) => (
<FormItem>
<FormLabel className="text-sm font-medium">
{t("cameraManagement.cameraConfig.ffmpeg.path")}
</FormLabel>
<FormControl>
<Input
className="h-8"
placeholder={t(
"cameraManagement.cameraConfig.ffmpeg.pathPlaceholder",
)}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="space-y-2">
<Label className="text-sm font-medium">
{t("cameraManagement.cameraConfig.ffmpeg.roles")}
</Label>
<div className="rounded-lg bg-background p-3">
<div className="flex flex-wrap gap-2">
{(["detect", "record", "audio"] as const).map(
(role) => {
const isUsedElsewhere =
getUsedRolesExcludingIndex(index).has(role);
const isChecked =
watchedInputs[index]?.roles?.includes(role) ||
false;
return (
<div
key={role}
className="flex w-full items-center justify-between"
>
<span className="text-sm capitalize">
{role}
</span>
<Switch
checked={isChecked}
onCheckedChange={(checked) => {
const currentRoles =
watchedInputs[index]?.roles || [];
const updatedRoles = checked
? [...currentRoles, role]
: currentRoles.filter((r) => r !== role);
form.setValue(
`ffmpeg.inputs.${index}.roles`,
updatedRoles,
);
}}
disabled={!isChecked && isUsedElsewhere}
/>
</div>
);
},
)}
</div>
</div>
</div>
</CardContent>
</Card>
))}
<FormMessage>
{form.formState.errors.ffmpeg?.inputs?.root &&
form.formState.errors.ffmpeg.inputs.root.message}
</FormMessage>
<Button
type="button"
onClick={() => append({ path: "", roles: getAvailableRoles() })}
variant="outline"
className=""
>
<LuPlus className="mr-2 size-4" />
{t("cameraManagement.cameraConfig.ffmpeg.addInput")}
</Button>
</div>
{/* go2rtc Streams Section */}
{Object.keys(watchedGo2rtcStreams).length > 0 && (
<div className="space-y-4">
<Label className="text-sm font-medium">
{t("cameraManagement.cameraConfig.go2rtcStreams")}
</Label>
{Object.entries(watchedGo2rtcStreams).map(
([streamName, urls]) => (
<Card key={streamName} className="bg-secondary text-primary">
<CardContent className="space-y-4 p-4">
<div className="flex items-center justify-between">
<h4 className="font-medium">{streamName}</h4>
<Button
variant="ghost"
size="sm"
onClick={() => {
const updatedStreams = { ...watchedGo2rtcStreams };
delete updatedStreams[streamName];
form.setValue("go2rtcStreams", updatedStreams);
}}
className="text-secondary-foreground hover:text-secondary-foreground"
>
<LuTrash2 className="size-5" />
</Button>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium">
{t("cameraManagement.cameraConfig.streamUrls")}
</Label>
{(Array.isArray(urls) ? urls : [urls]).map(
(url, urlIndex) => (
<div
key={urlIndex}
className="flex items-center gap-2"
>
<Input
className="h-8 flex-1"
value={url}
onChange={(e) => {
const updatedStreams = {
...watchedGo2rtcStreams,
};
const currentUrls = Array.isArray(
updatedStreams[streamName],
)
? updatedStreams[streamName]
: [updatedStreams[streamName]];
currentUrls[urlIndex] = e.target.value;
updatedStreams[streamName] = currentUrls;
form.setValue(
"go2rtcStreams",
updatedStreams,
);
}}
placeholder="rtsp://username:password@host:port/path"
/>
{(Array.isArray(urls) ? urls : [urls]).length >
1 && (
<Button
variant="ghost"
size="sm"
onClick={() => {
const updatedStreams = {
...watchedGo2rtcStreams,
};
const currentUrls = Array.isArray(
updatedStreams[streamName],
)
? updatedStreams[streamName]
: [updatedStreams[streamName]];
currentUrls.splice(urlIndex, 1);
updatedStreams[streamName] = currentUrls;
form.setValue(
"go2rtcStreams",
updatedStreams,
);
}}
className="text-secondary-foreground hover:text-secondary-foreground"
>
<LuTrash2 className="size-4" />
</Button>
)}
</div>
),
)}
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
const updatedStreams = { ...watchedGo2rtcStreams };
const currentUrls = Array.isArray(
updatedStreams[streamName],
)
? updatedStreams[streamName]
: [updatedStreams[streamName]];
currentUrls.push("");
updatedStreams[streamName] = currentUrls;
form.setValue("go2rtcStreams", updatedStreams);
}}
className="w-fit"
>
<LuPlus className="mr-2 size-4" />
{t("cameraManagement.cameraConfig.addUrl")}
</Button>
</div>
</CardContent>
</Card>
),
)}
<Button
type="button"
onClick={() => {
const streamName = `${cameraName}_stream_${Object.keys(watchedGo2rtcStreams).length + 1}`;
const updatedStreams = {
...watchedGo2rtcStreams,
[streamName]: [""],
};
form.setValue("go2rtcStreams", updatedStreams);
}}
variant="outline"
className=""
>
<LuPlus className="mr-2 size-4" />
{t("cameraManagement.cameraConfig.addGo2rtcStream")}
</Button>
</div>
)}
<div className="flex w-full flex-row items-center gap-2 pt-2 md:w-[50%]">
<Button
className="flex flex-1"
aria-label={t("button.cancel", { ns: "common" })}
onClick={onCancel}
type="button"
>
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="select"
disabled={isLoading}
className="flex flex-1"
aria-label={t("button.save", { ns: "common" })}
type="submit"
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator />
<span>{t("button.saving", { ns: "common" })}</span>
</div>
) : (
t("button.save", { ns: "common" })
)}
</Button>
</div>
</form>
</Form>
</div>
);
}
+34 -3
View File
@@ -15,6 +15,7 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer";
import {
Collapsible,
@@ -310,6 +311,8 @@ const settingsGroups = [
{
label: "globalConfig",
items: [
{ key: "profiles", component: ProfilesView },
{ key: "cameraManagement", component: CameraManagementView },
{ key: "globalDetect", component: GlobalDetectSettingsPage },
{ key: "globalObjects", component: GlobalObjectsSettingsPage },
{ key: "globalMotion", component: GlobalMotionSettingsPage },
@@ -331,8 +334,6 @@ const settingsGroups = [
{
label: "cameras",
items: [
{ key: "profiles", component: ProfilesView },
{ key: "cameraManagement", component: CameraManagementView },
{ key: "cameraDetect", component: CameraDetectSettingsPage },
{ key: "cameraObjects", component: CameraObjectsSettingsPage },
{ key: "cameraMotion", component: CameraMotionSettingsPage },
@@ -1651,6 +1652,8 @@ export default function Settings() {
const isMultiItem = filteredItems.length > 1;
const renderedExpanded =
!isMultiItem || !collapsedGroups.has(group.label);
const showCameraBadge =
group.label === "cameras" && !!selectedCamera;
const items = filteredItems.map((item) => (
<MobileMenuItem
key={item.key}
@@ -1686,7 +1689,19 @@ export default function Settings() {
)}
/>
</CollapsibleTrigger>
<CollapsibleContent>{items}</CollapsibleContent>
<CollapsibleContent>
{showCameraBadge && (
<div className="mb-2 ml-4 mr-4 mt-2">
<Badge
variant="outline"
className="max-w-full break-words smart-capitalize"
>
<CameraNameLabel camera={selectedCamera} />
</Badge>
</div>
)}
{items}
</CollapsibleContent>
</Collapsible>
) : (
items
@@ -2027,6 +2042,22 @@ export default function Settings() {
</SidebarGroupLabel>
<CollapsibleContent>
<SidebarMenuSub className="mx-2 border-0 md:mx-0">
{group.label === "cameras" &&
selectedCamera && (
<li
className="mb-1 ml-0 mr-3 mt-0"
aria-hidden="true"
>
<Badge
variant="outline"
className="max-w-full break-words smart-capitalize"
>
<CameraNameLabel
camera={selectedCamera}
/>
</Badge>
</li>
)}
{filteredItems.map((item) => (
<SidebarMenuSubItem key={item.key}>
<SidebarMenuSubButton
+2 -2
View File
@@ -1048,7 +1048,7 @@ function FrigateCameraFeatures({
Icon={enabledState == "ON" ? LuPower : LuPowerOff}
isActive={enabledState == "ON"}
title={
enabledState == "ON" ? t("camera.disable") : t("camera.enable")
enabledState == "ON" ? t("camera.turnOff") : t("camera.turnOn")
}
onClick={() => sendEnabled(enabledState == "ON" ? "OFF" : "ON")}
disabled={debug}
@@ -1489,7 +1489,7 @@ function FrigateCameraFeatures({
{isAdmin && (
<>
<FilterSwitch
label={t("cameraSettings.cameraEnabled")}
label={t("cameraSettings.camera")}
isChecked={enabledState == "ON"}
onCheckedChange={() =>
sendEnabled(enabledState == "ON" ? "OFF" : "ON")
+312 -282
View File
@@ -11,7 +11,6 @@ import { Button } from "@/components/ui/button";
import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig";
import { useTranslation } from "react-i18next";
import CameraEditForm from "@/components/settings/CameraEditForm";
import CameraWizardDialog from "@/components/settings/CameraWizardDialog";
import DeleteCameraDialog from "@/components/overlay/dialog/DeleteCameraDialog";
import {
@@ -20,15 +19,13 @@ import {
LuGripVertical,
LuPencil,
LuPlus,
LuRefreshCcw,
LuTrash2,
} from "react-icons/lu";
import { Reorder, useDragControls } from "framer-motion";
import { IoMdArrowRoundBack } from "react-icons/io";
import { Link } from "react-router-dom";
import { useDocDomain } from "@/hooks/use-doc-domain";
import { isDesktop } from "react-device-detect";
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
import { Switch } from "@/components/ui/switch";
import { Trans } from "react-i18next";
import { useEnabledState, useRestart } from "@/api/ws";
import { Label } from "@/components/ui/label";
@@ -78,12 +75,10 @@ const REORDER_SAVED_INDICATOR_MS = 1500;
type ReorderSaveStatus = "idle" | "saving" | "saved";
type CameraManagementViewProps = {
setUnsavedChanges: React.Dispatch<React.SetStateAction<boolean>>;
profileState?: ProfileState;
};
export default function CameraManagementView({
setUnsavedChanges,
profileState,
}: CameraManagementViewProps) {
const { t } = useTranslation(["views/settings", "common"]);
@@ -91,12 +86,6 @@ export default function CameraManagementView({
const { data: config, mutate: updateConfig } =
useSWR<FrigateConfig>("config");
const [viewMode, setViewMode] = useState<"settings" | "add" | "edit">(
"settings",
); // Control view state
const [editCameraName, setEditCameraName] = useState<string | undefined>(
undefined,
); // Track camera being edited
const [showWizard, setShowWizard] = useState(false);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
@@ -226,14 +215,6 @@ export default function CameraManagementView({
document.title = t("documentTitle.cameraManagement");
}, [t]);
// Handle back navigation from add/edit form
const handleBack = useCallback(() => {
setViewMode("settings");
setEditCameraName(undefined);
setUnsavedChanges(false);
updateConfig();
}, [updateConfig, setUnsavedChanges]);
return (
<>
<Toaster
@@ -244,174 +225,124 @@ export default function CameraManagementView({
/>
<div className="flex size-full space-y-6">
<div className="scrollbar-container flex-1 overflow-y-auto pb-2">
{viewMode === "settings" ? (
<>
<Heading as="h4" className="mb-2">
{t("cameraManagement.title")}
</Heading>
<p className="mb-6 max-w-5xl text-sm text-muted-foreground">
{t("cameraManagement.description")}
</p>
<Heading as="h4" className="mb-2">
{t("cameraManagement.title")}
</Heading>
<p className="mb-6 max-w-5xl text-sm text-muted-foreground">
{t("cameraManagement.description")}
</p>
<div className="w-full max-w-5xl space-y-6">
<div className="flex gap-2">
<Button
variant="select"
onClick={() => setShowWizard(true)}
className="mb-2 flex max-w-48 items-center gap-2"
>
<LuPlus className="h-4 w-4" />
{t("cameraManagement.addCamera")}
</Button>
{enabledCameras.length + disabledCameras.length > 0 && (
<Button
variant="destructive"
onClick={() => setShowDeleteDialog(true)}
className="mb-2 flex max-w-48 items-center gap-2 text-white"
>
<LuTrash2 className="h-4 w-4" />
{t("cameraManagement.deleteCamera")}
</Button>
)}
</div>
<div className="w-full max-w-5xl space-y-6">
<div className="flex gap-2">
<Button
variant="select"
onClick={() => setShowWizard(true)}
className="mb-2 flex max-w-48 items-center gap-2"
>
<LuPlus className="h-4 w-4" />
{t("cameraManagement.addCamera")}
</Button>
{enabledCameras.length + disabledCameras.length > 0 && (
<Button
variant="destructive"
onClick={() => setShowDeleteDialog(true)}
className="mb-2 flex max-w-48 items-center gap-2 text-white"
>
<LuTrash2 className="h-4 w-4" />
{t("cameraManagement.deleteCamera")}
</Button>
)}
</div>
{enabledCameras.length > 0 && (
<SettingsGroupCard
title={
{(enabledCameras.length > 0 || disabledCameras.length > 0) && (
<SettingsGroupCard
title={
<Trans ns="views/settings">
cameraManagement.streams.title
</Trans>
}
>
<div className={SPLIT_ROW_CLASS_NAME}>
<div className="space-y-1.5">
<Label>{t("cameraManagement.streams.label")}</Label>
<p className="hidden text-sm text-muted-foreground md:block">
<Trans ns="views/settings">
cameraManagement.streams.title
cameraManagement.streams.description
</Trans>
}
>
<div className={SPLIT_ROW_CLASS_NAME}>
<div className="space-y-1.5">
<Label
className="cursor-pointer"
htmlFor={"enabled-cameras-switch"}
>
{t("cameraManagement.streams.enableLabel")}
<p className="hidden text-sm text-muted-foreground md:block">
<Trans ns="views/settings">
cameraManagement.streams.enableDesc
</Trans>
</p>
</Label>
</div>
<div className="max-w-md space-y-1.5">
</p>
</div>
<div className="max-w-md space-y-1.5">
<div className="space-y-3 rounded-lg bg-secondary p-4">
{orderedCameras.length > 0 && (
<Reorder.Group
as="div"
axis="y"
values={orderedCameras}
onReorder={setOrderedCameras}
className="space-y-2 rounded-lg bg-secondary p-4"
className="space-y-2"
>
{orderedCameras.map((camera) => (
<EnabledCameraRow
<ActiveCameraRow
key={camera}
camera={camera}
onConfigChanged={updateConfig}
onDragEnd={handleReorderDragEnd}
setRestartDialogOpen={setRestartDialogOpen}
/>
))}
</Reorder.Group>
<ReorderSaveStatusIndicator
status={reorderSaveStatus}
/>
</div>
<p className="text-sm text-muted-foreground md:hidden">
<Trans ns="views/settings">
cameraManagement.streams.enableDesc
</Trans>
</p>
</div>
{disabledCameras.length > 0 && (
<div className={SPLIT_ROW_CLASS_NAME}>
<div className="space-y-1.5">
<Label
className="cursor-pointer"
htmlFor={"disabled-cameras-switch"}
>
{t("cameraManagement.streams.disableLabel")}
<RestartRequiredIndicator className="ml-1" />
</Label>
<p className="hidden text-sm text-muted-foreground md:block">
{t("cameraManagement.streams.disableDesc")}
)}
{orderedCameras.length > 0 &&
disabledCameras.length > 0 && (
<div className="border-t border-border/40" />
)}
{disabledCameras.length > 0 && (
<div className="space-y-2">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{t("cameraManagement.streams.disabledSubheading")}
</p>
{disabledCameras.map((camera) => (
<DisabledCameraRow
key={camera}
camera={camera}
onConfigChanged={updateConfig}
setRestartDialogOpen={setRestartDialogOpen}
/>
))}
</div>
<div
className={`${CONTROL_COLUMN_CLASS_NAME} space-y-1.5`}
>
<div className="max-w-md space-y-2 rounded-lg bg-secondary p-4">
{disabledCameras.map((camera) => (
<div
key={camera}
className="flex flex-row items-center justify-between"
>
<CameraNameLabel camera={camera} />
<CameraConfigEnableSwitch
cameraName={camera}
onConfigChanged={updateConfig}
setRestartDialogOpen={setRestartDialogOpen}
/>
</div>
))}
</div>
<p className="text-sm text-muted-foreground md:hidden">
{t("cameraManagement.streams.disableDesc")}
</p>
</div>
</div>
)}
</SettingsGroupCard>
)}
{profileState &&
profileState.allProfileNames.length > 0 &&
enabledCameras.length > 0 && (
<ProfileCameraEnableSection
profileState={profileState}
cameras={enabledCameras}
config={config}
onConfigChanged={updateConfig}
/>
)}
{config?.lpr?.enabled && allCameras.length > 0 && (
<CameraTypeSection
cameras={allCameras}
config={config}
onConfigChanged={updateConfig}
setRestartDialogOpen={setRestartDialogOpen}
/>
)}
</div>
</>
) : (
<>
<div className="mb-4 flex items-center gap-2">
<Button
className={`flex items-center gap-2.5 rounded-lg`}
aria-label={t("label.back", { ns: "common" })}
size="sm"
onClick={handleBack}
>
<IoMdArrowRoundBack className="size-5 text-secondary-foreground" />
{isDesktop && (
<div className="text-primary">
{t("button.back", { ns: "common" })}
)}
</div>
)}
</Button>
</div>
<div className="md:max-w-5xl">
<CameraEditForm
cameraName={viewMode === "edit" ? editCameraName : undefined}
onSave={handleBack}
onCancel={handleBack}
<ReorderSaveStatusIndicator status={reorderSaveStatus} />
</div>
<p className="text-sm text-muted-foreground md:hidden">
<Trans ns="views/settings">
cameraManagement.streams.description
</Trans>
</p>
</div>
</SettingsGroupCard>
)}
{profileState &&
profileState.allProfileNames.length > 0 &&
enabledCameras.length > 0 && (
<ProfileCameraEnableSection
profileState={profileState}
cameras={enabledCameras}
config={config}
onConfigChanged={updateConfig}
/>
</div>
</>
)}
)}
{config?.lpr?.enabled && allCameras.length > 0 && (
<CameraTypeSection
cameras={allCameras}
config={config}
onConfigChanged={updateConfig}
setRestartDialogOpen={setRestartDialogOpen}
/>
)}
</div>
</div>
</div>
@@ -468,17 +399,19 @@ function ReorderSaveStatusIndicator({
);
}
type EnabledCameraRowProps = {
type ActiveCameraRowProps = {
camera: string;
onConfigChanged: () => Promise<unknown>;
onDragEnd: () => void;
setRestartDialogOpen: React.Dispatch<React.SetStateAction<boolean>>;
};
function EnabledCameraRow({
function ActiveCameraRow({
camera,
onConfigChanged,
onDragEnd,
}: EnabledCameraRowProps) {
setRestartDialogOpen,
}: ActiveCameraRowProps) {
const { t } = useTranslation(["views/settings"]);
const controls = useDragControls();
@@ -506,38 +439,226 @@ function EnabledCameraRow({
onConfigChanged={onConfigChanged}
/>
</div>
<CameraEnableSwitch cameraName={camera} />
<CameraStatusSelect
cameraName={camera}
isDisabledInConfig={false}
onConfigChanged={onConfigChanged}
setRestartDialogOpen={setRestartDialogOpen}
/>
</Reorder.Item>
);
}
type CameraEnableSwitchProps = {
cameraName: string;
type DisabledCameraRowProps = {
camera: string;
onConfigChanged: () => Promise<unknown>;
setRestartDialogOpen: React.Dispatch<React.SetStateAction<boolean>>;
};
function CameraEnableSwitch({ cameraName }: CameraEnableSwitchProps) {
const { payload: enabledState, send: sendEnabled } =
useEnabledState(cameraName);
const { data: config } = useSWR<FrigateConfig>("config");
const isChecked =
enabledState === "ON" || enabledState === "OFF"
? enabledState === "ON"
: (config?.cameras?.[cameraName]?.enabled ?? false);
function DisabledCameraRow({
camera,
onConfigChanged,
setRestartDialogOpen,
}: DisabledCameraRowProps) {
return (
<div className="flex flex-row items-center">
<Switch
id={`camera-enabled-${cameraName}`}
checked={isChecked}
onCheckedChange={(isChecked) => {
sendEnabled(isChecked ? "ON" : "OFF");
}}
<div className="flex flex-row items-center justify-between">
<div className="flex items-center gap-1">
<CameraNameLabel camera={camera} className="text-muted-foreground" />
<CameraDetailsEditor
cameraName={camera}
onConfigChanged={onConfigChanged}
/>
</div>
<CameraStatusSelect
cameraName={camera}
isDisabledInConfig={true}
onConfigChanged={onConfigChanged}
setRestartDialogOpen={setRestartDialogOpen}
/>
</div>
);
}
type CameraStatus = "on" | "off" | "disabled";
type CameraStatusSelectProps = {
cameraName: string;
isDisabledInConfig: boolean;
onConfigChanged: () => Promise<unknown>;
setRestartDialogOpen: React.Dispatch<React.SetStateAction<boolean>>;
};
function CameraStatusSelect({
cameraName,
isDisabledInConfig,
onConfigChanged,
setRestartDialogOpen,
}: CameraStatusSelectProps) {
const { t } = useTranslation([
"views/settings",
"components/dialog",
"common",
]);
const { payload: enabledState, send: sendEnabled } =
useEnabledState(cameraName);
const [isSaving, setIsSaving] = useState(false);
const currentStatus: CameraStatus = isDisabledInConfig
? "disabled"
: enabledState === "OFF"
? "off"
: "on";
const restartLabel = t("configForm.restartRequiredField", {
ns: "views/settings",
defaultValue: "Restart required",
});
const handleChange = useCallback(
async (newStatus: string) => {
if (newStatus === currentStatus || isSaving) {
return;
}
if (newStatus === "on" && !isDisabledInConfig) {
sendEnabled("ON");
return;
}
if (newStatus === "off" && !isDisabledInConfig) {
sendEnabled("OFF");
return;
}
if (newStatus === "on" && isDisabledInConfig) {
setIsSaving(true);
try {
await axios.put("config/set", {
requires_restart: 1,
config_data: {
cameras: { [cameraName]: { enabled: true } },
},
});
await onConfigChanged();
toast.success(
t("cameraManagement.streams.enableSuccess", {
ns: "views/settings",
cameraName,
}),
{
position: "top-center",
action: (
<a onClick={() => setRestartDialogOpen(true)}>
<Button>
{t("restart.button", { ns: "components/dialog" })}
</Button>
</a>
),
},
);
} catch (error) {
const errorMessage =
axios.isAxiosError(error) &&
(error.response?.data?.message || error.response?.data?.detail)
? error.response?.data?.message || error.response?.data?.detail
: t("toast.save.error.noMessage", { ns: "common" });
toast.error(
t("toast.save.error.title", { errorMessage, ns: "common" }),
{ position: "top-center" },
);
} finally {
setIsSaving(false);
}
return;
}
if (newStatus === "disabled" && !isDisabledInConfig) {
setIsSaving(true);
try {
// Stop runtime processing immediately before persisting the
// disable so the camera stops working without waiting for
// a restart. The config write below makes the change durable.
sendEnabled("OFF");
await axios.put("config/set", {
requires_restart: 0,
config_data: {
cameras: { [cameraName]: { enabled: false } },
},
});
await onConfigChanged();
toast.success(
t("cameraManagement.streams.disableSuccess", {
ns: "views/settings",
cameraName,
}),
{ position: "top-center" },
);
} catch (error) {
const errorMessage =
axios.isAxiosError(error) &&
(error.response?.data?.message || error.response?.data?.detail)
? error.response?.data?.message || error.response?.data?.detail
: t("toast.save.error.noMessage", { ns: "common" });
toast.error(
t("toast.save.error.title", { errorMessage, ns: "common" }),
{ position: "top-center" },
);
} finally {
setIsSaving(false);
}
return;
}
},
[
cameraName,
currentStatus,
isDisabledInConfig,
isSaving,
onConfigChanged,
sendEnabled,
setRestartDialogOpen,
t,
],
);
if (isSaving) {
return (
<div className="flex h-7 w-[110px] flex-row items-center justify-end">
<ActivityIndicator className="size-4" size={16} />
</div>
);
}
return (
<Select value={currentStatus} onValueChange={handleChange}>
<SelectTrigger className="h-7 w-[110px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="on">
<div className="flex items-center gap-1.5">
{t("cameraManagement.streams.status.on")}
{isDisabledInConfig && (
<LuRefreshCcw
className="size-3 text-muted-foreground"
aria-label={restartLabel}
/>
)}
</div>
</SelectItem>
{!isDisabledInConfig && (
<SelectItem value="off">
{t("cameraManagement.streams.status.off")}
</SelectItem>
)}
<SelectItem value="disabled">
{t("cameraManagement.streams.status.disabled")}
</SelectItem>
</SelectContent>
</Select>
);
}
type CameraDetailsEditorProps = {
cameraName: string;
onConfigChanged: () => Promise<unknown>;
@@ -783,97 +904,6 @@ function CameraDetailsEditor({
);
}
type CameraConfigEnableSwitchProps = {
cameraName: string;
setRestartDialogOpen: React.Dispatch<React.SetStateAction<boolean>>;
onConfigChanged: () => Promise<unknown>;
};
function CameraConfigEnableSwitch({
cameraName,
onConfigChanged,
setRestartDialogOpen,
}: CameraConfigEnableSwitchProps) {
const { t } = useTranslation([
"common",
"views/settings",
"components/dialog",
]);
const [isSaving, setIsSaving] = useState(false);
const onCheckedChange = useCallback(
async (isChecked: boolean) => {
if (!isChecked || isSaving) {
return;
}
setIsSaving(true);
try {
await axios.put("config/set", {
requires_restart: 1,
config_data: {
cameras: {
[cameraName]: {
enabled: true,
},
},
},
});
await onConfigChanged();
toast.success(
t("cameraManagement.streams.enableSuccess", {
ns: "views/settings",
cameraName,
}),
{
position: "top-center",
action: (
<a onClick={() => setRestartDialogOpen(true)}>
<Button>
{t("restart.button", { ns: "components/dialog" })}
</Button>
</a>
),
},
);
} catch (error) {
const errorMessage =
axios.isAxiosError(error) &&
(error.response?.data?.message || error.response?.data?.detail)
? error.response?.data?.message || error.response?.data?.detail
: t("toast.save.error.noMessage", { ns: "common" });
toast.error(
t("toast.save.error.title", { errorMessage, ns: "common" }),
{
position: "top-center",
},
);
} finally {
setIsSaving(false);
}
},
[cameraName, isSaving, onConfigChanged, setRestartDialogOpen, t],
);
return (
<div className="flex flex-row items-center">
{isSaving ? (
<ActivityIndicator className="h-5 w-8" size={16} />
) : (
<Switch
id={`camera-enabled-${cameraName}`}
checked={false}
onCheckedChange={onCheckedChange}
/>
)}
</div>
);
}
type CameraTypeSectionProps = {
cameras: string[];
config: FrigateConfig | undefined;
@@ -1231,12 +1261,12 @@ function ProfileCameraEnableSection({
})}
</SelectItem>
<SelectItem value="enabled">
{t("cameraManagement.profiles.enabled", {
{t("cameraManagement.profiles.on", {
ns: "views/settings",
})}
</SelectItem>
<SelectItem value="disabled">
{t("cameraManagement.profiles.disabled", {
{t("cameraManagement.profiles.off", {
ns: "views/settings",
})}
</SelectItem>