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
@@ -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>