diff --git a/package.json b/package.json index 95e2c66..3e5431d 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "idb-keyval": "^6.2.1", "jwt-decode": "^4.0.0", "keycloak-js": "^24.0.1", + "konva": "^9.3.6", "mantine-react-table": "^1.0.0-beta.25", "mobx": "^6.9.0", "mobx-react-lite": "^3.4.3", @@ -47,6 +48,7 @@ "react-device-detect": "^2.2.3", "react-dom": "^18.2.0", "react-i18next": "^14.1.0", + "react-konva": "^18.2.10", "react-router-dom": "^6.14.1", "react-scripts": "5.0.1", "react-use-websocket": "^4.7.0", diff --git a/src/hooks/useRealmAccessRoles.ts b/src/hooks/useRealmAccessRoles.ts index 5206fbd..8eeac8f 100644 --- a/src/hooks/useRealmAccessRoles.ts +++ b/src/hooks/useRealmAccessRoles.ts @@ -9,7 +9,6 @@ export const useRealmAccessRoles = () => { useEffect(() => { const updateRoles = () => { const tokenRoles = keycloak.tokenParsed?.realm_access?.roles; - if (!isProduction) console.log(`tokenRoles:`, tokenRoles); if (tokenRoles) { setRoles(tokenRoles); } else { diff --git a/src/locales/en.ts b/src/locales/en.ts index 5b51769..e583163 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -1,4 +1,19 @@ const en = { + editCameraPage: { + notFrigateCamera: 'Not frigate camera', + errorAtPut: 'Error at sending mask', + cameraIdNotExist: 'Camera id does not exist', + cameraConfigNotExist: 'Camera config does not exist', + width: 'Width', + height: 'Height', + points: 'Points', + }, + frigateConfigPage: { + copyConfig: 'Copy Config', + saveOnly: 'Save Only', + saveAndRestart: 'Save & Restart', + editorNotExist: 'Editor does not exists', + }, systemPage: { cameraStats: 'Cameras stats', storageStats: 'Storages stats', @@ -56,7 +71,9 @@ const en = { doubleClickToFullHint: 'Double click for fullscreen', rating: 'Rating', }, - + config: 'Config', + clear: 'Clear', + edit: 'Edit', version: 'Version', uptime: 'Uptime', pleaseSelectRole: 'Please select Role', diff --git a/src/locales/ru.ts b/src/locales/ru.ts index ec3c7f6..aade65b 100644 --- a/src/locales/ru.ts +++ b/src/locales/ru.ts @@ -1,4 +1,19 @@ const ru = { + editCameraPage: { + notFrigateCamera: 'Не камера Фригата', + errorAtPut: 'Ошибка при отправке маски', + cameraIdNotExist: 'ID камеры не найдено', + cameraConfigNotExist: 'Конфиг. камеры не найден', + width: 'Ширина', + height: 'Высота', + points: 'Точки', + }, + frigateConfigPage: { + copyConfig: 'Копировать Конфиг.', + saveOnly: 'Только Сохранить', + saveAndRestart: 'Сохранить & Перезагрузить', + editorNotExist: 'Редактор не найден', + }, systemPage: { cameraStats: 'Статистика Камер', storageStats: 'Статистика Хранения', @@ -56,6 +71,9 @@ const ru = { doubleClickToFullHint: 'Двойное нажатие мышью для полноэкранного просмотра', rating: 'Рейтинг', }, + config: 'Конфиг.', + clear: 'Очистить', + edit: 'Изменить', version: 'Версия', uptime: 'Время работы', pleaseSelectRole: 'Пожалуйста выберите роль', diff --git a/src/pages/EditCameraPage.tsx b/src/pages/EditCameraPage.tsx new file mode 100644 index 0000000..2c08155 --- /dev/null +++ b/src/pages/EditCameraPage.tsx @@ -0,0 +1,194 @@ +import { Button, Center, Flex, Text } from '@mantine/core'; +import { notifications } from '@mantine/notifications'; +import { IconAlertCircle, IconCircleCheck } from '@tabler/icons-react'; +import { useMutation, useQuery } from '@tanstack/react-query'; +import { observer } from 'mobx-react-lite'; +import { useContext, useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useParams } from 'react-router-dom'; +import { Context } from '..'; +import { useAdminRole } from '../hooks/useAdminRole'; +import { frigateApi, frigateQueryKeys, mapHostToHostname, proxyApi } from '../services/frigate.proxy/frigate.api'; +import MaskSelect, { MaskItem, MaskType } from '../shared/components/filters/MaskSelect'; +import CenterLoader from '../shared/components/loaders/CenterLoader'; +import { Point, extractMaskNumber } from '../shared/utils/maskPoint'; +import CameraMaskDrawer from '../widgets/CameraMaskDrawer'; +import CameraPageHeader from '../widgets/header/CameraPageHeader'; +import Forbidden from './403'; +import RetryErrorPage from './RetryErrorPage'; + +const EditCameraPage = () => { + const { t } = useTranslation() + const executed = useRef(false) + let { id: cameraId } = useParams<'id'>() + if (!cameraId) throw Error(t('editCameraPage.cameraIdNotExist')) + const [selectedMask, setSelectedMask] = useState() + const [points, setPoints] = useState() + + const { data: camera, isPending, isError, refetch } = useQuery({ + queryKey: [frigateQueryKeys.getCameraWHost, cameraId], + queryFn: () => frigateApi.getCameraWHost(cameraId!) + }) + + const { mutate } = useMutation({ + mutationFn: () => { + if (!selectedMask || !points || !camera) return Promise.reject(t('editCameraPage.errorAtPut')) + if (points.length < 3) return Promise.reject(t('editCameraPage.errorAtPut')) + const hostName = mapHostToHostname(camera.frigateHost) + if (!hostName) return Promise.reject(t('editCameraPage.errorAtPut')) + switch (selectedMask.type) { + case MaskType.Motion: { + const index = extractMaskNumber(selectedMask.id) + if (index === null) return Promise.reject(t('editCameraPage.errorAtPut')) + return proxyApi.putMotionMask(hostName, camera.name, index, Point.arrayToRequest(points)) + .catch(error => { + if (error.response && error.response.data) { + return Promise.reject(error.response.data) + } + return Promise.reject(error) + }) + } + case MaskType.Object: { + let maskName = selectedMask.id + if (selectedMask.id.startsWith('add_new_')) { + maskName = selectedMask.id.replace('add_new_', '') + } + return proxyApi.putZoneMask(hostName, camera.name, maskName, Point.arrayToRequest(points)) + .catch(error => { + if (error.response && error.response.data) { + return Promise.reject(error.response.data) + } + return Promise.reject(error) + }) + } + case MaskType.Zone: { + const index = extractMaskNumber(selectedMask.id) + if (index === null) return Promise.reject(t('editCameraPage.errorAtPut')) + let maskName = selectedMask.id + if (selectedMask.id.startsWith('add_new_')) { + maskName = selectedMask.id.replace('add_new_', '') + } + maskName = maskName.split('_')[0] + return proxyApi.putObjectMask(hostName, camera.name, maskName, index, Point.arrayToRequest(points)) + .catch(error => { + if (error.response && error.response.data) { + return Promise.reject(error.response.data) + } + return Promise.reject(error) + }) + } + } + }, + onSuccess: (data) => { + notifications.show({ + id: data?.message, + withCloseButton: true, + autoClose: 5000, + title: `Sucess: ${data?.success}`, + message: data?.message, + color: 'green', + icon: + }) + }, + onError: (e) => { + notifications.show({ + id: e.message, + withCloseButton: true, + autoClose: false, + title: "Error", + message: e.message, + color: 'red', + icon: , + }) + }, + }) + + const { isAdmin, isLoading: adminLoading } = useAdminRole() + + const { sideBarsStore } = useContext(Context) + useEffect(() => { + if (!executed.current) { + sideBarsStore.rightVisible = false + sideBarsStore.setLeftChildren(null) + sideBarsStore.setRightChildren(null) + executed.current = true + } + }, [sideBarsStore]) + + if (isPending || adminLoading) return + if (!isAdmin) return + if (isError) return + + const hostName = mapHostToHostname(camera.frigateHost) + + if (!hostName) return ( +
+ {t('editCameraPage.notFrigateCamera')} +
+ ) + + if (!camera.config) return ( +
+ {t('editCameraPage.cameraConfigNotExist')} +
+ ) + + const handleSelectMask = (mask?: MaskItem) => { + setSelectedMask(mask) + setPoints(mask?.coordinates) + } + + const handleChangePoints = (points: Point[]) => { + setPoints(points) + } + + const handleSave = () => { + if (!selectedMask || !points) return + console.log('type', selectedMask?.type) + console.log('save', points) + mutate() + } + + const handleReset = () => { + setPoints(selectedMask?.coordinates) + } + + const handleClear = () => { + setPoints([]) + } + + return ( + + + {!camera.config ? null : + + + + } + {!points ? null : + + {t('editCameraPage.points')}: {points.map(point => `(x: ${point.x}, y: ${point.y}) `)} + + + + + + + } + {!points || !camera.config ? null : + + } + + ); +}; + +export default observer(EditCameraPage); \ No newline at end of file diff --git a/src/pages/HostConfigPage.tsx b/src/pages/HostConfigPage.tsx index 7b2d22e..8050741 100644 --- a/src/pages/HostConfigPage.tsx +++ b/src/pages/HostConfigPage.tsx @@ -5,8 +5,8 @@ import { useMutation, useQuery } from '@tanstack/react-query'; import { observer } from 'mobx-react-lite'; import * as monaco from "monaco-editor"; import { SchemasSettings, configureMonacoYaml } from 'monaco-yaml'; -import { useCallback, useContext, useEffect, useRef, useState } from 'react'; -import { useParams } from 'react-router-dom'; +import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; +import { useLocation, useParams } from 'react-router-dom'; import { Context } from '..'; import { useAdminRole } from '../hooks/useAdminRole'; import { frigateApi, frigateQueryKeys, mapHostToHostname, proxyApi } from '../services/frigate.proxy/frigate.api'; @@ -18,6 +18,7 @@ import Forbidden from './403'; import RetryErrorPage from './RetryErrorPage'; import { notifications } from '@mantine/notifications'; import { IconAlertCircle, IconCircleCheck } from '@tabler/icons-react'; +import { useTranslation } from 'react-i18next'; window.MonacoEnvironment = { @@ -31,7 +32,16 @@ window.MonacoEnvironment = { } } +export const hostConfigPageQuery = { + searchWord: 'searchWord ', +} + const HostConfigPage = () => { + const { t } = useTranslation() + const location = useLocation() + const queryParams = useMemo(() => { + return new URLSearchParams(location.search); + }, [location.search]) const executed = useRef(false) const host = useRef() const { sideBarsStore } = useContext(Context) @@ -82,7 +92,7 @@ const HostConfigPage = () => { message: e.message, color: 'red', icon: , - }) + }) } }) @@ -119,6 +129,17 @@ const HostConfigPage = () => { const model = monaco.editor.createModel(config, 'yaml', monaco.Uri.parse('monaco-yaml.yaml')) editor.setModel(model) editorRef.current = editor; + const paramSearchWord = queryParams.get(hostConfigPageQuery.searchWord) + + if (paramSearchWord && model) { + const matches = model.findMatches(paramSearchWord, true, false, true, null, true); + + if (matches.length > 0) { + const firstMatch = matches[0].range; + editor.revealPositionInCenter({ lineNumber: firstMatch.startLineNumber, column: firstMatch.startColumn }); + editor.setPosition({ lineNumber: firstMatch.startLineNumber, column: firstMatch.startColumn }); + } + } } const handleCopyConfig = useCallback(async () => { @@ -132,14 +153,14 @@ const HostConfigPage = () => { const onHandleSaveConfig = useCallback( async (saveOption: SaveOption) => { if (!editorRef.current) { - throw Error('Editor does not exists') + throw Error(t('frigateConfigPage.editorNotExist')) } if (!isProduction) console.log('saveOption', saveOption) if (!isProduction) console.log('editorRef.current', editorRef.current.getValue().slice(0, 50)) saveConfig({ saveOption: saveOption, config: editorRef.current.getValue() }) }, [editorRef]) - if (configPending || adminLoading ) return + if (configPending || adminLoading) return if (configError) return if (!isAdmin) return @@ -148,13 +169,13 @@ const HostConfigPage = () => { diff --git a/src/pages/LiveCameraPage.tsx b/src/pages/LiveCameraPage.tsx index 881c604..61a1d13 100644 --- a/src/pages/LiveCameraPage.tsx +++ b/src/pages/LiveCameraPage.tsx @@ -1,21 +1,19 @@ -import React, { useContext, useEffect, useRef } from 'react'; -import { Context } from '..'; -import { observer } from 'mobx-react-lite'; -import { useNavigate, useParams } from 'react-router-dom'; -import { frigateApi, frigateQueryKeys } from '../services/frigate.proxy/frigate.api'; +import { Flex } from '@mantine/core'; import { useQuery } from '@tanstack/react-query'; -import CenterLoader from '../shared/components/loaders/CenterLoader'; -import RetryErrorPage from './RetryErrorPage'; -import Player from '../widgets/Player'; -import { Button, Flex, Text } from '@mantine/core'; -import { routesPath } from '../router/routes.path'; -import { recordingsPageQuery } from './RecordingsPage'; +import { observer } from 'mobx-react-lite'; +import { useContext, useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; +import { useParams } from 'react-router-dom'; +import { Context } from '..'; +import { frigateApi, frigateQueryKeys } from '../services/frigate.proxy/frigate.api'; +import CenterLoader from '../shared/components/loaders/CenterLoader'; +import Player from '../widgets/Player'; +import CameraPageHeader from '../widgets/header/CameraPageHeader'; +import RetryErrorPage from './RetryErrorPage'; const LiveCameraPage = () => { const { t } = useTranslation() const executed = useRef(false) - const navigate = useNavigate() let { id: cameraId } = useParams<'id'>() if (!cameraId) throw Error('Camera id does not exist') @@ -39,21 +37,10 @@ const LiveCameraPage = () => { if (isError) return - const handleOpenRecordings = () => { - if (camera.frigateHost) { - const url = `${routesPath.RECORDINGS_PATH}?${recordingsPageQuery.hostId}=${camera.frigateHost.id}&${recordingsPageQuery.cameraId}=${camera.id}` - navigate(url) - } - } return ( - - {t('camera')}: {camera.name} {camera.frigateHost ? `/ ${camera.frigateHost.name}` : ''} - {!camera.frigateHost ? <> : - - } - + ); diff --git a/src/pages/MainPage.tsx b/src/pages/MainPage.tsx index 0aee8b1..f829056 100644 --- a/src/pages/MainPage.tsx +++ b/src/pages/MainPage.tsx @@ -86,9 +86,7 @@ const MainPage = () => { , }, + { + path: routesPath.EDIT_PATH, + component: , + }, { path: routesPath.PLAYER_PATH, component: , diff --git a/src/services/frigate.proxy/frigate.api.ts b/src/services/frigate.proxy/frigate.api.ts index 8f98829..c8c0cc8 100644 --- a/src/services/frigate.proxy/frigate.api.ts +++ b/src/services/frigate.proxy/frigate.api.ts @@ -12,6 +12,7 @@ import { getResolvedTimeZone } from "../../shared/utils/dateUtil"; import { FrigateStats, GetFfprobe, GetHostStorage, GetVaInfo } from "../../types/frigateStats"; import { PostSaveConfig, SaveOption } from "../../types/saveConfig"; import keycloak from "../keycloak-config"; +import { PutMask } from "../../types/mask"; const instanceApi = axios.create({ baseURL: proxyURL.toString(), @@ -184,6 +185,18 @@ export const proxyApi = { } }).then(res => res.data), getHostStorage: (hostName: string) => instanceApi.get(`proxy/${hostName}/api/recordings/storage`).then(res => res.data), + putMotionMask: (hostName: string, cameraName: string, index: number, points: string) => + instanceApi + .put(`proxy/${hostName}/api/config/set?cameras.${cameraName}.motion.mask.${index}=${points}`) // points format: 257,21,255,43,21,48,21,25 + .then(res => res.data), + putZoneMask: (hostName: string, cameraName: string, zoneName: string, points: string) => + instanceApi + .put(`proxy/${hostName}/api/config/set?cameras.${cameraName}.zones.${zoneName}.coordinates=${points}`) // points format: 257,21,255,43,21,48,21,25 + .then(res => res.data), + putObjectMask: (hostName: string, cameraName: string, filterName: string, index: number, points: string) => + instanceApi + .put(`proxy/${hostName}/api/config/set?cameras.${cameraName}.objects.filters.${filterName}.mask.${index}=${points}`) // points format: 257,21,255,43,21,48,21,25 + .then(res => res.data), } export const mapCamerasFromConfig = (config: FrigateConfig): string[] => { diff --git a/src/shared/components/filters/HostSelect.tsx b/src/shared/components/filters/HostSelect.tsx index a85a24d..5e680e5 100644 --- a/src/shared/components/filters/HostSelect.tsx +++ b/src/shared/components/filters/HostSelect.tsx @@ -1,10 +1,9 @@ +import { Center, Loader, MantineStyleSystemProps, SpacingValue, SystemProp } from '@mantine/core'; import { useQuery } from '@tanstack/react-query'; -import React, { useEffect } from 'react'; -import { frigateQueryKeys, frigateApi } from '../../../services/frigate.proxy/frigate.api'; +import { useEffect } from 'react'; +import { frigateApi, frigateQueryKeys } from '../../../services/frigate.proxy/frigate.api'; import RetryError from '../RetryError'; -import CogwheelLoader from '../loaders/CogwheelLoader'; import OneSelectFilter, { OneSelectItem } from './OneSelectFilter'; -import { SystemProp, SpacingValue, MantineStyleSystemProps, Loader, Center } from '@mantine/core'; interface HostSelectProps extends MantineStyleSystemProps { label?: string diff --git a/src/shared/components/filters/MaskSelect.tsx b/src/shared/components/filters/MaskSelect.tsx new file mode 100644 index 0000000..7962846 --- /dev/null +++ b/src/shared/components/filters/MaskSelect.tsx @@ -0,0 +1,101 @@ +import React, { useMemo } from 'react' +import { CameraConfig } from '../../../types/frigateConfig' +import { Point } from '../../utils/maskPoint' +import { OneSelectItem } from './OneSelectFilter' +import { Flex, Select, Button, MantineStyleSystemProps } from '@mantine/core' + +interface MaskSelectProps extends MantineStyleSystemProps { + cameraConfig: CameraConfig + onSelect: (mask?: MaskItem) => void +} + +export interface MaskItem { + id: string + type: MaskType + coordinates: Point[] +} + +export enum MaskType { + Motion = 'motion', + Zone = 'zone', + Object = 'object', +} + +const createMaskSelectItems = (data: MaskItem[], groupLabel: string): OneSelectItem[] => { + return data.map(({ id, coordinates }, index) => ({ + value: id, + label: `${id} ${Point.arrayToString(coordinates)}`, + group: `${groupLabel} masks`, + })) +} + +const MaskSelect: React.FC = ({ + cameraConfig, + onSelect, + ...styleProps +}) => { + + const { motions, zones, objects, items } = useMemo(() => { + const motions: MaskItem[] = cameraConfig.motion.mask.map((mask, index) => ({ + id: `motion_${index}`, + type: MaskType.Motion, + coordinates: Point.parseCoordinates(mask), + })) + motions.push({ + id: `add_new_motion_${motions.length}`, + type: MaskType.Motion, + coordinates: [] + }) + + const zones: MaskItem[] = Object.entries(cameraConfig.zones).map(([name, params], index) => ({ + id: `${name}_${index}`, + type: MaskType.Zone, + coordinates: Point.parseCoordinates(params.coordinates), + })) + zones.push({ + id: `add_new_zone_${zones.length}`, + type: MaskType.Zone, + coordinates: [] + }) + + const objects: MaskItem[] = Object.entries(cameraConfig.objects.filters) + .filter(([name, params]) => params.mask !== null) + .map(([name, params], index) => ({ + id: `${name}_${index}`, + type: MaskType.Object, + coordinates: Point.parseCoordinates(params.mask!), + })) + objects.push({ + id: `add_new_object_${objects.length}`, + type: MaskType.Object, + coordinates: [] + }) + + const motionItems: OneSelectItem[] = createMaskSelectItems(motions, 'Motion') + const zonesItems: OneSelectItem[] = createMaskSelectItems(zones, 'Zone') + const objectsItems: OneSelectItem[] = createMaskSelectItems(objects, 'Object') + + return { + motions, + zones, + objects, + items: [...motionItems, ...zonesItems, ...objectsItems], + } + }, [cameraConfig]) + + const handleSelect = (id: string) => { + const mask = [...motions, ...zones, ...objects].find(item => item.id === id) + onSelect(mask) + } + + return ( +