diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 35620eb99..6d58a9998 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,6 +3,7 @@ import { lazy } from "react"; import { Navigate, useRoutes } from "react-router-dom"; const Cameras = lazy(() => import("pages/Cameras")); +const CamerasSettings = lazy(() => import("pages/settings/Cameras")); const Tuning = lazy(() => import("pages/Tuning")); const CameraRecordings = lazy( () => import("pages/recordings/CameraRecordings"), @@ -92,6 +93,10 @@ function App() { path: "/settings/configuration", element: , }, + { + path: "/settings/cameras", + element: , + }, { path: "/settings/users", element: , diff --git a/frontend/src/components/camera/AddCameraDialog.tsx b/frontend/src/components/camera/AddCameraDialog.tsx new file mode 100644 index 000000000..ec2f1f96a --- /dev/null +++ b/frontend/src/components/camera/AddCameraDialog.tsx @@ -0,0 +1,352 @@ +import { Add, Checkmark, Close } from "@carbon/icons-react"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import Checkbox from "@mui/material/Checkbox"; +import Dialog from "@mui/material/Dialog"; +import DialogActions from "@mui/material/DialogActions"; +import DialogContent from "@mui/material/DialogContent"; +import DialogTitle from "@mui/material/DialogTitle"; +import FormControl from "@mui/material/FormControl"; +import FormControlLabel from "@mui/material/FormControlLabel"; +import Grid from "@mui/material/Grid"; +import InputLabel from "@mui/material/InputLabel"; +import MenuItem from "@mui/material/MenuItem"; +import Select from "@mui/material/Select"; +import TextField from "@mui/material/TextField"; +import { useState } from "react"; + +import { LoadingButton } from "components/buttons/LoadingButton"; +import { + AddCameraConfigPayload, + useAddCameraConfig, +} from "lib/api/cameras"; + +type AddCameraDialogProps = { + open: boolean; + onClose: () => void; +}; + +const PATH_PRESETS = [ + { + label: "Mobotix HTTP live", + path: "/control/faststream.jpg?stream=full&fps=10", + port: 80, + stream_format: "mjpeg" as const, + }, + { + label: "Hikvision main", + path: "/Streaming/Channels/101/", + port: 554, + stream_format: "rtsp" as const, + }, + { + label: "Hikvision sub", + path: "/Streaming/Channels/102/", + port: 554, + stream_format: "rtsp" as const, + }, + { + label: "Dahua main", + path: "/cam/realmonitor?channel=1&subtype=0", + port: 554, + stream_format: "rtsp" as const, + }, + { + label: "Dahua sub", + path: "/cam/realmonitor?channel=1&subtype=1", + port: 554, + stream_format: "rtsp" as const, + }, + { label: "Generic", path: "/", port: 554, stream_format: "rtsp" as const }, +]; + +const identifierFromName = (name: string) => + name + .trim() + .toLowerCase() + .replace(/[^a-z0-9_]+/g, "_") + .replace(/^_+|_+$/g, "") + .replace(/_+/g, "_"); + +const initialForm: AddCameraConfigPayload = { + identifier: "", + name: "", + host: "", + port: 554, + path: "/Streaming/Channels/101/", + stream_format: "rtsp", + username: "", + password: "", + substream_path: "", + substream_port: 554, + substream_stream_format: "rtsp", + idle_timeout: 5, + enable_recorder: true, + enable_nvr: true, + reload: true, +}; + +export function AddCameraDialog({ open, onClose }: AddCameraDialogProps) { + const [form, setForm] = useState(initialForm); + const [identifierEdited, setIdentifierEdited] = useState(false); + + const addCamera = useAddCameraConfig({ + onSuccess: () => { + setForm(initialForm); + setIdentifierEdited(false); + onClose(); + }, + }); + + const updateForm = ( + key: K, + value: AddCameraConfigPayload[K], + ) => { + setForm((current) => ({ ...current, [key]: value })); + }; + + const close = () => { + if (!addCamera.isPending) { + onClose(); + } + }; + + const submit = () => { + addCamera.mutate({ + ...form, + identifier: form.identifier.trim(), + name: form.name.trim(), + host: form.host.trim(), + path: form.path.trim(), + username: form.username?.trim() || null, + password: form.password || null, + substream_path: form.substream_path?.trim() || null, + }); + }; + + const valid = + /^[A-Za-z0-9_]+$/.test(form.identifier) && + form.name.trim().length > 0 && + form.host.trim().length > 0 && + form.path.trim().length > 0; + + return ( + + Add Camera + + + + { + const value = event.target.value; + updateForm("name", value); + if (!identifierEdited) { + updateForm("identifier", identifierFromName(value)); + } + }} + /> + + + 0 && + !/^[A-Za-z0-9_]+$/.test(form.identifier) + } + onChange={(event) => { + setIdentifierEdited(true); + updateForm("identifier", event.target.value); + }} + /> + + + updateForm("host", event.target.value)} + /> + + + + updateForm("port", Number(event.target.value)) + } + /> + + + + Preset + + + + + updateForm("path", event.target.value)} + /> + + + + + Stream format + + + + + + updateForm("username", event.target.value)} + /> + + + updateForm("password", event.target.value)} + /> + + + + updateForm("substream_path", event.target.value) + } + /> + + + + updateForm("substream_port", Number(event.target.value)) + } + /> + + + + + updateForm("enable_recorder", event.target.checked) + } + /> + } + label="Recorder" + /> + + updateForm("enable_nvr", event.target.checked) + } + /> + } + label="NVR" + /> + + updateForm("reload", event.target.checked) + } + /> + } + label="Reload" + /> + + + + + + + : } + text="Add Camera" + state={ + addCamera.isPending + ? "loading" + : addCamera.isError + ? "error" + : addCamera.isSuccess + ? "success" + : "normal" + } + variant="contained" + onClick={valid ? submit : undefined} + /> + + + ); +} diff --git a/frontend/src/components/camera/CameraEditDialog.tsx b/frontend/src/components/camera/CameraEditDialog.tsx new file mode 100644 index 000000000..c6aad261a --- /dev/null +++ b/frontend/src/components/camera/CameraEditDialog.tsx @@ -0,0 +1,614 @@ +import { Checkmark, Close, Save, TrashCan } from "@carbon/icons-react"; +import Alert from "@mui/material/Alert"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import Checkbox from "@mui/material/Checkbox"; +import Dialog from "@mui/material/Dialog"; +import DialogActions from "@mui/material/DialogActions"; +import DialogContent from "@mui/material/DialogContent"; +import DialogTitle from "@mui/material/DialogTitle"; +import FormControl from "@mui/material/FormControl"; +import FormControlLabel from "@mui/material/FormControlLabel"; +import Grid from "@mui/material/Grid"; +import InputLabel from "@mui/material/InputLabel"; +import ListItemText from "@mui/material/ListItemText"; +import MenuItem from "@mui/material/MenuItem"; +import Select from "@mui/material/Select"; +import TextField from "@mui/material/TextField"; +import Typography from "@mui/material/Typography"; +import { useEffect, useMemo, useState } from "react"; + +import { LoadingButton } from "components/buttons/LoadingButton"; +import { ErrorMessage } from "components/error/ErrorMessage"; +import { Loading } from "components/loading/Loading"; +import { + useCameraAccessConfig, + useSaveCameraAccessConfig, +} from "lib/api/cameraAccess"; +import { + CameraConfigPayload, + useCameraConfig, + useDeleteCameraConfig, + useUpdateCameraConfig, +} from "lib/api/cameras"; +import * as types from "lib/types"; + +type CameraEditDialogProps = { + camera: types.Camera | types.FailedCamera; + onClose: () => void; +}; + +const PATH_PRESETS = [ + { + label: "Mobotix HTTP live", + path: "/control/faststream.jpg?stream=full&fps=10", + port: 80, + stream_format: "mjpeg" as const, + }, + { + label: "Mobotix snapshot", + path: "/record/current.jpg", + port: 80, + stream_format: "mjpeg" as const, + }, + { + label: "Hikvision main", + path: "/Streaming/Channels/101/", + port: 554, + stream_format: "rtsp" as const, + }, + { + label: "Hikvision sub", + path: "/Streaming/Channels/102/", + port: 554, + stream_format: "rtsp" as const, + }, + { + label: "Dahua main", + path: "/cam/realmonitor?channel=1&subtype=0", + port: 554, + stream_format: "rtsp" as const, + }, + { + label: "Dahua sub", + path: "/cam/realmonitor?channel=1&subtype=1", + port: 554, + stream_format: "rtsp" as const, + }, +]; + +const EMPTY_FORM: CameraConfigPayload = { + name: "", + host: "", + port: 554, + path: "", + stream_format: "rtsp", + username: "", + password: "", + substream_path: "", + substream_port: 554, + substream_stream_format: "rtsp", + fps: null, + idle_timeout: 5, + enable_recorder: true, + enable_nvr: true, + record_only: false, + width: null, + height: null, + video_filters: [], + reload: true, +}; + +function videoFiltersToText(videoFilters: string[] | undefined) { + return (videoFilters || []).join("\n"); +} + +function textToVideoFilters(value: string) { + return value + .split("\n") + .map((videoFilter) => videoFilter.trim()) + .filter(Boolean); +} + +function numberOrNull(value: string) { + if (value.trim() === "") { + return null; + } + return Number(value); +} + +function updateCameraGroups( + config: types.CameraAccessConfig, + cameraIdentifier: string, + selectedGroupIds: string[], +) { + const selected = new Set(selectedGroupIds); + return { + ...config, + camera_groups: config.camera_groups.map((group) => { + const cameras = new Set(group.cameras); + if (selected.has(group.id)) { + cameras.add(cameraIdentifier); + } else { + cameras.delete(cameraIdentifier); + } + return { ...group, cameras: Array.from(cameras).sort() }; + }), + }; +} + +export function CameraEditDialog({ camera, onClose }: CameraEditDialogProps) { + const cameraConfig = useCameraConfig(camera.identifier); + const cameraAccessConfig = useCameraAccessConfig(); + const updateCameraConfig = useUpdateCameraConfig(); + const deleteCameraConfig = useDeleteCameraConfig(); + const saveCameraAccessConfig = useSaveCameraAccessConfig(); + const [form, setForm] = useState(EMPTY_FORM); + const [selectedGroupIds, setSelectedGroupIds] = useState([]); + const [videoFiltersText, setVideoFiltersText] = useState(""); + const cameraGroups = cameraAccessConfig.data?.config.camera_groups || []; + + useEffect(() => { + if (!cameraConfig.data?.config) { + return; + } + const { + identifier: _identifier, + password_set: _passwordSet, + ...config + } = cameraConfig.data.config; + // eslint-disable-next-line react-hooks/set-state-in-effect + setForm({ + ...EMPTY_FORM, + ...config, + password: "", + }); + setVideoFiltersText(videoFiltersToText(config.video_filters)); + }, [cameraConfig.data]); + + useEffect(() => { + if (!cameraAccessConfig.data?.config) { + return; + } + // eslint-disable-next-line react-hooks/set-state-in-effect + setSelectedGroupIds( + cameraAccessConfig.data.config.camera_groups + .filter((group) => group.cameras.includes(camera.identifier)) + .map((group) => group.id), + ); + }, [camera.identifier, cameraAccessConfig.data]); + + const updateForm = ( + key: K, + value: CameraConfigPayload[K], + ) => { + setForm((current) => ({ ...current, [key]: value })); + }; + + const selectedPreset = useMemo( + () => + PATH_PRESETS.find( + (preset) => + preset.path === form.path && + preset.port === form.port && + preset.stream_format === form.stream_format, + )?.label || "", + [form.path, form.port, form.stream_format], + ); + + const valid = + form.name.trim().length > 0 && + form.host.trim().length > 0 && + form.path.trim().length > 0 && + form.port >= 1 && + form.port <= 65535; + + const isSaving = + updateCameraConfig.isPending || + saveCameraAccessConfig.isPending || + deleteCameraConfig.isPending; + + const save = async () => { + const payload: CameraConfigPayload = { + ...form, + name: form.name.trim(), + host: form.host.trim(), + path: form.path.trim(), + username: form.username?.trim() || null, + password: form.password || null, + substream_path: form.substream_path?.trim() || null, + video_filters: textToVideoFilters(videoFiltersText), + }; + + await updateCameraConfig.mutateAsync({ + cameraIdentifier: camera.identifier, + payload, + }); + + if (cameraAccessConfig.data?.config) { + await saveCameraAccessConfig.mutateAsync( + updateCameraGroups( + cameraAccessConfig.data.config, + camera.identifier, + selectedGroupIds, + ), + ); + } + + onClose(); + }; + + const deleteCamera = async () => { + // eslint-disable-next-line no-alert + if (!window.confirm(`Delete camera ${camera.name}?`)) { + return; + } + await deleteCameraConfig.mutateAsync({ + cameraIdentifier: camera.identifier, + reload: form.reload, + }); + onClose(); + }; + + const close = () => { + if (!isSaving) { + onClose(); + } + }; + + if (cameraConfig.isLoading) { + return ( + + + + + + ); + } + + if (cameraConfig.isError || !cameraConfig.data) { + return ( + + + + + + + + + ); + } + + return ( + + {camera.name} + + + + updateForm("name", event.target.value)} + /> + + + + + + updateForm("host", event.target.value)} + /> + + + + updateForm("port", Number(event.target.value)) + } + /> + + + + Preset + + + + + updateForm("path", event.target.value)} + /> + + + + + Stream format + + + + + + + updateForm("fps", numberOrNull(event.target.value)) + } + /> + + + + updateForm("idle_timeout", Number(event.target.value)) + } + /> + + + updateForm("username", event.target.value)} + /> + + + updateForm("password", event.target.value)} + /> + + + + updateForm("substream_path", event.target.value) + } + /> + + + + updateForm("substream_port", Number(event.target.value)) + } + /> + + + + updateForm("width", numberOrNull(event.target.value)) + } + /> + + + + updateForm("height", numberOrNull(event.target.value)) + } + /> + + + setVideoFiltersText(event.target.value)} + /> + + + + + updateForm("enable_recorder", event.target.checked) + } + /> + } + label="Recorder" + /> + + updateForm("enable_nvr", event.target.checked) + } + /> + } + label="NVR" + /> + + updateForm("record_only", event.target.checked) + } + /> + } + label="Record only" + /> + + updateForm("reload", event.target.checked) + } + /> + } + label="Reload" + /> + + + + + + Camera groups + + {cameraAccessConfig.isError && ( + + Camera group assignments could not be loaded. + + )} + {!cameraAccessConfig.isError && cameraGroups.length === 0 && ( + No camera groups configured. + )} + {!cameraAccessConfig.isError && cameraGroups.length > 0 && ( + + Groups + + + )} + + + + + + + + : } + text="Save" + state={ + isSaving + ? "loading" + : updateCameraConfig.isError || saveCameraAccessConfig.isError + ? "error" + : updateCameraConfig.isSuccess + ? "success" + : "normal" + } + variant="contained" + onClick={valid ? save : undefined} + /> + + + ); +} diff --git a/frontend/src/components/camera/NoCamerasConfigured.tsx b/frontend/src/components/camera/NoCamerasConfigured.tsx index dccc24008..a69043f2c 100644 --- a/frontend/src/components/camera/NoCamerasConfigured.tsx +++ b/frontend/src/components/camera/NoCamerasConfigured.tsx @@ -1,72 +1,82 @@ +import { Add } from "@carbon/icons-react"; import Box from "@mui/material/Box"; import Button from "@mui/material/Button"; import Grid from "@mui/material/Grid"; import Typography from "@mui/material/Typography"; +import { useState } from "react"; import { useNavigate } from "react-router-dom"; import ViseronLogo from "svg/viseron-logo.svg?react"; +import { AddCameraDialog } from "components/camera/AddCameraDialog"; + export function NoCamerasConfigured() { const navigate = useNavigate(); + const [addCameraOpen, setAddCameraOpen] = useState(false); return ( - - - - - - - - - No cameras configured - - - - - Add a camera component to your config.yaml{" "} - to get started. - - - - - - - + + + + + + No cameras configured + + + + + Add a camera component to your config.yaml{" "} + to get started. + + + + + + + + - + setAddCameraOpen(false)} + /> + ); } diff --git a/frontend/src/lib/api/cameras.ts b/frontend/src/lib/api/cameras.ts index 4f8d708a7..ae9528514 100644 --- a/frontend/src/lib/api/cameras.ts +++ b/frontend/src/lib/api/cameras.ts @@ -1,6 +1,13 @@ -import { UseQueryOptions, useQuery } from "@tanstack/react-query"; +import { + UseMutationOptions, + UseQueryOptions, + useMutation, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; import { useMemo } from "react"; +import { useToast } from "hooks/UseToast"; import { viseronAPI } from "lib/api/client"; import { useInvalidateQueryOnEvent } from "lib/api/utils"; import * as types from "lib/types"; @@ -16,6 +23,213 @@ async function cameras() { return response.data; } +export type AddCameraConfigPayload = { + identifier: string; + name: string; + host: string; + port: number; + path: string; + stream_format: "rtsp" | "mjpeg"; + username?: string | null; + password?: string | null; + substream_path?: string | null; + substream_port?: number; + substream_stream_format?: "rtsp" | "mjpeg"; + fps?: number | null; + idle_timeout: number; + enable_recorder: boolean; + enable_nvr: boolean; + record_only?: boolean; + width?: number | null; + height?: number | null; + video_filters?: string[]; + reload: boolean; +}; + +export type CameraConfigPayload = Omit; + +export type CameraConfigResponse = { + config: AddCameraConfigPayload & { + password_set: boolean; + }; +}; + +export type CameraConfigSaveResponse = { + message: string; + reloaded: boolean; + restart_required?: boolean; +}; + +async function addCameraConfig(payload: AddCameraConfigPayload) { + const response = await viseronAPI.post( + "cameras", + payload, + ); + return response.data; +} + +export const useAddCameraConfig = ( + mutationOptions?: Omit< + UseMutationOptions< + CameraConfigSaveResponse, + types.APIErrorResponse, + AddCameraConfigPayload + >, + "mutationFn" + >, +) => { + const queryClient = useQueryClient(); + const toast = useToast(); + + return useMutation({ + ...mutationOptions, + mutationFn: addCameraConfig, + onSuccess: async (data, variables, onMutateResult, context) => { + toast.success(data.message); + await queryClient.invalidateQueries({ queryKey: ["cameras"] }); + await queryClient.invalidateQueries({ queryKey: ["cameras", "failed"] }); + await mutationOptions?.onSuccess?.( + data, + variables, + onMutateResult, + context, + ); + }, + onError: (error, variables, onMutateResult, context) => { + toast.error( + error.response && error.response.data.error + ? `Error adding camera: ${error.response.data.error}` + : `An error occurred: ${error.message}`, + ); + mutationOptions?.onError?.(error, variables, onMutateResult, context); + }, + }); +}; + +async function cameraConfig(cameraIdentifier: string) { + const response = await viseronAPI.get( + `cameras/${cameraIdentifier}/config`, + ); + return response.data; +} + +export const useCameraConfig = (cameraIdentifier: string, enabled = true) => + useQuery({ + queryKey: ["cameras", "config", cameraIdentifier], + queryFn: async () => cameraConfig(cameraIdentifier), + enabled, + }); + +async function updateCameraConfig({ + cameraIdentifier, + payload, +}: { + cameraIdentifier: string; + payload: CameraConfigPayload; +}) { + const response = await viseronAPI.put( + `cameras/${cameraIdentifier}/config`, + payload, + ); + return response.data; +} + +export const useUpdateCameraConfig = ( + mutationOptions?: Omit< + UseMutationOptions< + CameraConfigSaveResponse, + types.APIErrorResponse, + { cameraIdentifier: string; payload: CameraConfigPayload } + >, + "mutationFn" + >, +) => { + const queryClient = useQueryClient(); + const toast = useToast(); + + return useMutation({ + ...mutationOptions, + mutationFn: updateCameraConfig, + onSuccess: async (data, variables, onMutateResult, context) => { + toast.success(data.message); + await queryClient.invalidateQueries({ queryKey: ["cameras"] }); + await queryClient.invalidateQueries({ queryKey: ["cameras", "failed"] }); + await queryClient.invalidateQueries({ + queryKey: ["cameras", "config", variables.cameraIdentifier], + }); + await mutationOptions?.onSuccess?.( + data, + variables, + onMutateResult, + context, + ); + }, + onError: (error, variables, onMutateResult, context) => { + toast.error( + error.response && error.response.data.error + ? `Error saving camera: ${error.response.data.error}` + : `An error occurred: ${error.message}`, + ); + mutationOptions?.onError?.(error, variables, onMutateResult, context); + }, + }); +}; + +async function deleteCameraConfig({ + cameraIdentifier, + reload, +}: { + cameraIdentifier: string; + reload: boolean; +}) { + const response = await viseronAPI.delete( + `cameras/${cameraIdentifier}`, + { data: { reload } }, + ); + return response.data; +} + +export const useDeleteCameraConfig = ( + mutationOptions?: Omit< + UseMutationOptions< + CameraConfigSaveResponse, + types.APIErrorResponse, + { cameraIdentifier: string; reload: boolean } + >, + "mutationFn" + >, +) => { + const queryClient = useQueryClient(); + const toast = useToast(); + + return useMutation({ + ...mutationOptions, + mutationFn: deleteCameraConfig, + onSuccess: async (data, variables, onMutateResult, context) => { + toast.success(data.message); + await queryClient.invalidateQueries({ queryKey: ["cameras"] }); + await queryClient.invalidateQueries({ queryKey: ["cameras", "failed"] }); + await queryClient.invalidateQueries({ + queryKey: ["cameraaccess", "config"], + }); + await mutationOptions?.onSuccess?.( + data, + variables, + onMutateResult, + context, + ); + }, + onError: (error, variables, onMutateResult, context) => { + toast.error( + error.response && error.response.data.error + ? `Error deleting camera: ${error.response.data.error}` + : `An error occurred: ${error.message}`, + ); + mutationOptions?.onError?.(error, variables, onMutateResult, context); + }, + }); +}; + export const useCameras = ({ configOptions }: CamerasVariables) => { useInvalidateQueryOnEvent([ { diff --git a/frontend/src/pages/settings/Cameras.tsx b/frontend/src/pages/settings/Cameras.tsx new file mode 100644 index 000000000..665e115eb --- /dev/null +++ b/frontend/src/pages/settings/Cameras.tsx @@ -0,0 +1,119 @@ +import { Add, Video } from "@carbon/icons-react"; +import Avatar from "@mui/material/Avatar"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import Container from "@mui/material/Container"; +import List from "@mui/material/List"; +import ListItemAvatar from "@mui/material/ListItemAvatar"; +import ListItemButton from "@mui/material/ListItemButton"; +import ListItemText from "@mui/material/ListItemText"; +import Paper from "@mui/material/Paper"; +import Typography from "@mui/material/Typography"; +import { useState } from "react"; + +import { AddCameraDialog } from "components/camera/AddCameraDialog"; +import { CameraEditDialog } from "components/camera/CameraEditDialog"; +import { Loading } from "components/loading/Loading"; +import { useTitle } from "hooks/UseTitle"; +import { useCamerasAll } from "lib/api/cameras"; +import * as types from "lib/types"; + +function cameraSecondaryText(camera: types.Camera | types.FailedCamera) { + if (camera.failed) { + return camera.error; + } + + if (!camera.connected) { + return "Disconnected"; + } + + return camera.is_recording ? "Recording" : "Connected"; +} + +function CamerasSettings() { + useTitle("Cameras"); + const [addCameraOpen, setAddCameraOpen] = useState(false); + const [selectedCamera, setSelectedCamera] = useState< + types.Camera | types.FailedCamera | null + >(null); + const cameras = useCamerasAll(); + const cameraEntries = Object.values(cameras.combinedData); + + if (cameras.isLoading) { + return ; + } + + return ( + + + Cameras + + + + + + {cameraEntries.length === 0 ? ( + + ) : ( + cameraEntries.map((camera) => ( + setSelectedCamera(camera)} + sx={{ + alignItems: "center", + display: "flex", + px: 2, + py: 1, + }} + > + + + + + + + )) + )} + + + setAddCameraOpen(false)} + /> + {selectedCamera && ( + setSelectedCamera(null)} + /> + )} + + ); +} + +export default CamerasSettings; diff --git a/frontend/src/pages/settings/index.tsx b/frontend/src/pages/settings/index.tsx index 5b5a1f7f9..e13a38557 100644 --- a/frontend/src/pages/settings/index.tsx +++ b/frontend/src/pages/settings/index.tsx @@ -5,6 +5,7 @@ import { SettingsEdit, Trigger, UserMultiple, + Video, } from "@carbon/icons-react"; import { ListItemButton } from "@mui/material"; import Avatar from "@mui/material/Avatar"; @@ -37,6 +38,15 @@ function Settings() { disabled: false, disabledReason: null, }, + { + name: "Cameras", + description: "Add and review cameras", + path: "/settings/cameras", + icon: