add edit camera page

This commit is contained in:
NlightN22 2024-03-23 01:18:54 +07:00
parent 6de8e3ecd6
commit 72139e4519
21 changed files with 722 additions and 54 deletions

View File

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

View File

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

View File

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

View File

@ -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: 'Пожалуйста выберите роль',

View File

@ -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<MaskItem>()
const [points, setPoints] = useState<Point[]>()
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: <IconCircleCheck />
})
},
onError: (e) => {
notifications.show({
id: e.message,
withCloseButton: true,
autoClose: false,
title: "Error",
message: e.message,
color: 'red',
icon: <IconAlertCircle />,
})
},
})
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 <CenterLoader />
if (!isAdmin) return <Forbidden />
if (isError) return <RetryErrorPage onRetry={refetch} />
const hostName = mapHostToHostname(camera.frigateHost)
if (!hostName) return (
<Center>
<Text>{t('editCameraPage.notFrigateCamera')}</Text>
</Center>
)
if (!camera.config) return (
<Center>
<Text>{t('editCameraPage.cameraConfigNotExist')}</Text>
</Center>
)
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 (
<Flex w='100%' h='100%' direction='column'>
<CameraPageHeader camera={camera} configButton/>
{!camera.config ? null :
<Flex w='100%' justify='center' mb='1rem'>
<MaskSelect
miw='50%'
cameraConfig={camera.config}
onSelect={handleSelectMask}
/>
</Flex>
}
{!points ? null :
<Flex justify='center' align='center' mb='1rem'>
<Text mr='1rem'>{t('editCameraPage.points')}: {points.map(point => `(x: ${point.x}, y: ${point.y}) `)}</Text>
<Flex>
<Button onClick={handleSave} ml='0.5rem'>Save</Button>
<Button onClick={handleReset} ml='0.5rem'>Reset</Button>
<Button onClick={handleClear} ml='0.5rem'>Clear</Button>
</Flex>
</Flex>
}
{!points || !camera.config ? null :
<CameraMaskDrawer
cameraWidth={camera.config.detect.width}
cameraHeight={camera.config.detect.height}
imageUrl={proxyApi.cameraImageURL(hostName, camera.name)}
inPoints={points}
onChange={handleChangePoints} />
}
</Flex>
);
};
export default observer(EditCameraPage);

View File

@ -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<GetFrigateHost | undefined>()
const { sideBarsStore } = useContext(Context)
@ -82,7 +92,7 @@ const HostConfigPage = () => {
message: e.message,
color: 'red',
icon: <IconAlertCircle />,
})
})
}
})
@ -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 <CenterLoader />
if (configPending || adminLoading) return <CenterLoader />
if (configError) return <RetryErrorPage onRetry={refetch} />
if (!isAdmin) return <Forbidden />
@ -148,13 +169,13 @@ const HostConfigPage = () => {
<Flex direction='column' h='100%' w='100%' justify='stretch'>
<Flex w='100%' justify='center' wrap='nowrap'>
<Button size="sm" onClick={handleCopyConfig}>
Copy Config
{t('frigateConfigPage.copyConfig')}
</Button>
<Button ml='1rem' size="sm" onClick={(_) => onHandleSaveConfig(SaveOption.SaveRestart)}>
Save & Restart
{t('frigateConfigPage.saveAndRestart')}
</Button>
<Button ml='1rem' size="sm" onClick={(_) => onHandleSaveConfig(SaveOption.SaveOnly)}>
Save Only
{t('frigateConfigPage.saveOnly')}
</Button>
</Flex>
<Flex h='100%' mt='1rem'>

View File

@ -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 <RetryErrorPage onRetry={refetch} />
const handleOpenRecordings = () => {
if (camera.frigateHost) {
const url = `${routesPath.RECORDINGS_PATH}?${recordingsPageQuery.hostId}=${camera.frigateHost.id}&${recordingsPageQuery.cameraId}=${camera.id}`
navigate(url)
}
}
return (
<Flex w='100%' h='100%' justify='center' align='center' direction='column'>
<Flex w='100%' justify='center' align='baseline' mb='1rem'>
<Text mr='1rem'>{t('camera')}: {camera.name} {camera.frigateHost ? `/ ${camera.frigateHost.name}` : ''}</Text>
{!camera.frigateHost ? <></> :
<Button onClick={handleOpenRecordings}>{t('recordings')}</Button>
}
</Flex>
<CameraPageHeader camera={camera} editButton />
<Player camera={camera} />
</Flex>
);

View File

@ -86,9 +86,7 @@ const MainPage = () => {
<Flex direction='column' h='100%' w='100%' >
<Flex justify='space-between' align='center' w='100%'>
<Flex w='100%'
style={{
justifyContent: 'center',
}}
justify='center'
>
<ClearableTextInput
clerable

View File

@ -9,6 +9,7 @@ export const routesPath = {
HOST_STORAGE_PATH: '/hosts/:id/storage',
ACCESS_PATH: '/access',
LIVE_PATH: '/cameras/:id/',
EDIT_PATH: '/cameras/:id/edit',
PLAYER_PATH: '/player',
THANKS_PATH: '/thanks',
USER_DETAILED_PATH: '/user',

View File

@ -13,6 +13,7 @@ import LiveCameraPage from "../pages/LiveCameraPage";
import RecordingsPage from "../pages/RecordingsPage";
import AccessSettings from "../pages/AccessSettingsPage";
import PlayRecordPage from "../pages/PlayRecordPage";
import EditCameraPage from "../pages/EditCameraPage";
interface IRoute {
path: string,
@ -52,6 +53,10 @@ export const routes: IRoute[] = [
path: routesPath.LIVE_PATH,
component: <LiveCameraPage />,
},
{
path: routesPath.EDIT_PATH,
component: <EditCameraPage />,
},
{
path: routesPath.PLAYER_PATH,
component: <PlayRecordPage />,

View File

@ -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<GetHostStorage>(`proxy/${hostName}/api/recordings/storage`).then(res => res.data),
putMotionMask: (hostName: string, cameraName: string, index: number, points: string) =>
instanceApi
.put<PutMask>(`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<PutMask>(`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<PutMask>(`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[] => {

View File

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

View File

@ -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<MaskSelectProps> = ({
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 (
<Select
placeholder='Select Mask'
data={items}
onChange={handleSelect}
{...styleProps}
/>
)
}
export default MaskSelect

View File

@ -4,10 +4,11 @@ import CloseWithTooltip from '../buttons/CloseWithTooltip';
export interface OneSelectItem {
value: string;
label: string;
selected?: boolean;
disabled?: boolean;
value: string
label: string
group?: string
selected?: boolean
disabled?: boolean
}
export interface OneSelectFilterProps extends SelectProps {

View File

@ -55,15 +55,7 @@ const AutoUpdatedImage = ({
}
}, [imageBlob])
if (isPending) return <CogwheelLoader />
if (isError) return (
<Flex direction="column" justify="center" h="100%">
<RetryError onRetry={refetch}/>
</Flex>
)
if (!imageSrc) return null
if (isPending || !imageSrc) return <CogwheelLoader />
return (
<Flex direction="column" justify="center" h="100%">

View File

@ -0,0 +1,35 @@
export class Point {
constructor(public x: number, public y: number, public id: string) { }
toString(): string {
return `(x: ${this.x}, y: ${this.y})`;
}
static arrayToRequest(points: Point[]): string {
return points.map(point => `${point.x},${point.y}`).join(',')
}
static parseCoordinates = (coordString: string): Point[] => {
const numbers = coordString.split(',').map(Number);
const points: Point[] = [];
for (let i = 0; i < numbers.length; i += 2) {
points.push(new Point(numbers[i], numbers[i + 1], `point_${i + 1}`))
}
return points;
}
static arrayToString(points: Point[]): string {
return points.map(point => point.toString()).join(', ');
}
}
export const extractMaskNumber = (str: string): number | null => {
const match = str.match(/\d+$/);
if (match) {
return parseInt(match[0], 10);
} else {
return null;
}
}

4
src/types/mask.ts Normal file
View File

@ -0,0 +1,4 @@
export interface PutMask {
message: string
success: boolean
}

View File

@ -8,6 +8,7 @@ import { mapHostToHostname, proxyApi } from '../services/frigate.proxy/frigate.a
import { GetCameraWHostWConfig } from '../services/frigate.proxy/frigate.schema';
import AutoUpdatedImage from '../shared/components/images/AutoUpdatedImage';
import { useTranslation } from 'react-i18next';
import { useAdminRole } from '../hooks/useAdminRole';
const useStyles = createStyles((theme) => ({
mainCard: {
@ -48,6 +49,8 @@ const CameraCard = ({
const navigate = useNavigate()
const hostName = mapHostToHostname(camera.frigateHost)
const imageUrl = hostName ? proxyApi.cameraImageURL(hostName, camera.name) : '' //todo implement get URL from live cameras
const { isAdmin } = useAdminRole()
useEffect(() => {
if (entry?.isIntersecting)
@ -62,6 +65,14 @@ const CameraCard = ({
const url = `${routesPath.RECORDINGS_PATH}?${recordingsPageQuery.hostId}=${camera.frigateHost?.id}&${recordingsPageQuery.cameraId}=${camera.id}`
navigate(url)
}
const handleOpenEditCamera = () => {
if (camera.frigateHost) {
const url = routesPath.EDIT_PATH.replace(':id', camera.id)
navigate(url)
}
}
return (
<Grid.Col md={6} lg={3} p='0.2rem'>
<Card ref={ref} h='100%' radius="lg" padding='0.5rem' className={classes.mainCard}>
@ -73,6 +84,7 @@ const CameraCard = ({
className={classes.bottomGroup}>
<Flex justify='space-evenly' mt='0.5rem' w='100%'>
<Button size='sm' onClick={handleOpenRecordings}>{t('recordings')}</Button>
{!isAdmin ? null : <Button size='sm' onClick={handleOpenEditCamera}>{t('edit')}</Button>}
</Flex>
</Group>
</Card>

View File

@ -0,0 +1,170 @@
import { Button, Center, Flex, Text } from '@mantine/core'
import { useViewportSize } from '@mantine/hooks'
import { IconMinus, IconPlus } from '@tabler/icons-react'
import { useQuery } from '@tanstack/react-query'
import Konva from 'konva'
import React, { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Circle, Image, Layer, Line, Stage } from 'react-konva'
import { proxyApi } from '../services/frigate.proxy/frigate.api'
import RetryError from '../shared/components/RetryError'
import CogwheelLoader from '../shared/components/loaders/CogwheelLoader'
import { Point } from '../shared/utils/maskPoint'
interface CameraMaskDrawerProps {
cameraWidth: number
cameraHeight: number
imageUrl: string
inPoints: Point[]
onChange: (points: Point[]) => void
}
const CameraMaskDrawer: React.FC<CameraMaskDrawerProps> = ({
cameraWidth,
cameraHeight,
imageUrl,
inPoints,
onChange
}) => {
const { height, width } = useViewportSize()
const scaleStep = 0.1
const [stageScale, setStageScale] = useState<number>(1)
const [imageSrc, setImageSrc] = useState<HTMLImageElement | null>(null)
const { data: imageBlob, refetch, isPending, isError } = useQuery({
queryKey: [imageUrl],
queryFn: () => proxyApi.getImageFrigate(imageUrl),
});
const [points, setPoints] = useState<Point[]>(inPoints)
useEffect(() => {
setPoints(inPoints)
}, [inPoints])
useEffect(() => {
onChange(points)
}, [points])
const handleMouseDown = (e: Konva.KonvaEventObject<MouseEvent>) => {
if (e.target instanceof Konva.Circle) {
return
}
const stage = e.target.getStage();
const point = stage?.getPointerPosition();
if (point) {
const x = point.x / stageScale
const y = point.y / stageScale
const normX = x > cameraWidth ? cameraWidth : x < 0 ? 0 : x
const normY = y > cameraHeight ? cameraHeight : y < 0 ? 0 : y
const newPoint = {
x: Math.round(normX),
y: Math.round(normY),
id: `point_${points.length}`,
}
setPoints([...points, newPoint])
}
}
const handlePointClick = (id: string) => {
setPoints(points.filter(point => point.id !== id))
}
const setFitScale = () => {
const scale = parseFloat(((width * 0.90) / cameraWidth).toFixed(2))
setStageScale(scale)
}
const handleDragEnd = (e: Konva.KonvaEventObject<DragEvent>, id: string) => {
const updatedPoints = points.map(point => {
if (point.id === id) {
const x = e.target.x()
const y = e.target.y()
const normX = x > cameraWidth ? cameraWidth : x < 0 ? 0 : x
const normY = y > cameraHeight ? cameraHeight : y < 0 ? 0 : y
return {
...point,
x: Math.round(normX),
y: Math.round(normY),
};
}
return point;
});
setPoints(updatedPoints);
};
const handlePlusScale = () => setStageScale(stageScale + scaleStep)
const handleMinusScale = () => setStageScale(stageScale - scaleStep)
const handleResetScale = () => setStageScale(1)
useEffect(() => {
if (imageBlob && imageBlob instanceof Blob) {
const objectURL = URL.createObjectURL(imageBlob);
const img = new window.Image();
img.src = objectURL;
img.onload = () => {
setImageSrc(img);
return () => {
URL.revokeObjectURL(objectURL);
}
};
}
}, [imageBlob])
if (isPending || !imageSrc) return <CogwheelLoader />
if (isError) return <RetryError onRetry={refetch} />
if (!imageUrl) return <Center>Image url does not exist</Center>
return (
<Flex direction='column' w='100%' h='100%'>
<Flex justify='center' align='center' mb='1rem'>
<Text mr='1rem'>{(stageScale * 100).toFixed(0)}%</Text>
<Button size='xs' variant='outline' mr='0.5rem' onClick={handlePlusScale}><IconPlus size='1.2rem' /></Button>
<Button size='xs' variant='outline' mr='0.5rem' onClick={handleMinusScale}><IconMinus size='1.2rem' /></Button>
<Button size='xs' variant='outline' mr='0.5rem' onClick={handleResetScale}>100%</Button>
<Button size='xs' variant='outline' mr='0.5rem' onClick={setFitScale}>Fit</Button>
</Flex>
<Flex>
<Stage
width={cameraWidth * stageScale}
height={cameraHeight * stageScale}
scaleX={stageScale}
scaleY={stageScale}
onMouseDown={handleMouseDown}
>
<Layer>
<Image
image={imageSrc}
width={cameraWidth}
height={cameraHeight}
/>
<Line
points={points.flatMap(point => [point.x, point.y])}
fill="rgba(0, 0, 255, 0.5)"
closed={points.length > 2}
stroke="black"
/>
{points.map((point, index) => (
<Circle
key={point.id}
x={point.x}
y={point.y}
radius={8 / stageScale}
fill="red"
draggable
onDragEnd={(e) => handleDragEnd(e, point.id)}
onClick={() => handlePointClick(point.id)}
onTap={() => handlePointClick(point.id)}
/>
))}
</Layer>
</Stage>
</Flex>
</Flex>
)
}
export default CameraMaskDrawer

View File

@ -0,0 +1,62 @@
import { Flex, Button, Text } from '@mantine/core';
import React from 'react';
import { recordingsPageQuery } from '../../pages/RecordingsPage';
import { routesPath } from '../../router/routes.path';
import { GetCameraWHostWConfig } from '../../services/frigate.proxy/frigate.schema';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useAdminRole } from '../../hooks/useAdminRole';
import { hostConfigPageQuery } from '../../pages/HostConfigPage';
interface CameraPageHeaderProps {
camera: GetCameraWHostWConfig
editButton?: boolean,
configButton?: boolean,
}
const CameraPageHeader: React.FC<CameraPageHeaderProps> = ({
camera,
editButton,
configButton,
}) => {
const { t } = useTranslation()
const navigate = useNavigate()
const { isAdmin } = useAdminRole()
const handleOpenRecordings = () => {
if (camera.frigateHost) {
const url = `${routesPath.RECORDINGS_PATH}?${recordingsPageQuery.hostId}=${camera.frigateHost.id}&${recordingsPageQuery.cameraId}=${camera.id}`
navigate(url)
}
}
const handleOpenEditCamera = () => {
if (camera.frigateHost) {
const url = routesPath.EDIT_PATH.replace(':id', camera.id)
navigate(url)
}
}
const hanleOpenConfig = () => {
if (!camera.frigateHost) return
const url = routesPath.HOST_CONFIG_PATH.replace(':id', camera.frigateHost.id) + '?' + hostConfigPageQuery.searchWord + '=' + camera.name
navigate(url)
}
return (
<Flex w='100%' justify='center' align='baseline' mb='0.5rem'>
<Text mr='1rem'>{t('camera')}: {camera.name} {camera.frigateHost ? `/ ${camera.frigateHost.name}` : ''}</Text>
{!camera.frigateHost ? null :
<Button mr='0.5rem' onClick={handleOpenRecordings}>{t('recordings')}</Button>
}
{!isAdmin || !editButton ? null :
<Button mr='0.5rem' onClick={handleOpenEditCamera} >{t('edit')}</Button>
}
{!isAdmin || !configButton ? null :
<Button mr='0.5rem' onClick={hanleOpenConfig} >{t('config')}</Button>
}
</Flex>
);
};
export default CameraPageHeader;

View File

@ -2551,6 +2551,13 @@
dependencies:
"@types/react" "*"
"@types/react-reconciler@^0.28.0", "@types/react-reconciler@^0.28.2":
version "0.28.8"
resolved "https://registry.yarnpkg.com/@types/react-reconciler/-/react-reconciler-0.28.8.tgz#e51710572bcccf214306833c2438575d310b3e98"
integrity sha512-SN9c4kxXZonFhbX4hJrZy37yw9e7EIxcpHCxQv5JUS18wDE5ovkQKlqQEkufdJCCMfuI9BnjUJvhYeJ9x5Ra7g==
dependencies:
"@types/react" "*"
"@types/react@*", "@types/react@^18.0.0":
version "18.2.61"
resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.61.tgz#5607308495037436779939ec0348a5816c08799d"
@ -6278,6 +6285,13 @@ iterator.prototype@^1.1.2:
reflect.getprototypeof "^1.0.4"
set-function-name "^2.0.1"
its-fine@^1.1.1:
version "1.1.2"
resolved "https://registry.yarnpkg.com/its-fine/-/its-fine-1.1.2.tgz#cadb71437868e049e3bab83a3bdfec4c3189a822"
integrity sha512-2KLlHDx31ZYloReZ8/zfV1mmHAPKtTFYNIdOkZ4H5jIL2+HjU8XIfWPqhTyWtnCYuVrO+uT/v977ISgnBxP1fw==
dependencies:
"@types/react-reconciler" "^0.28.0"
jackspeak@^2.3.5:
version "2.3.6"
resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-2.3.6.tgz#647ecc472238aee4b06ac0e461acc21a8c505ca8"
@ -7024,6 +7038,11 @@ klona@^2.0.4, klona@^2.0.5:
resolved "https://registry.yarnpkg.com/klona/-/klona-2.0.6.tgz#85bffbf819c03b2f53270412420a4555ef882e22"
integrity sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==
konva@^9.3.6:
version "9.3.6"
resolved "https://registry.yarnpkg.com/konva/-/konva-9.3.6.tgz#62b36292dbe06c56eb161d5ead221c2b5c5a8926"
integrity sha512-dqR8EbcM0hjuilZCBP6xauQ5V3kH3m9kBcsDkqPypQuRgsXbcXUrxqYxhNbdvKZpYNW8Amq94jAD/C0NY3qfBQ==
language-subtag-registry@^0.3.20:
version "0.3.22"
resolved "https://registry.yarnpkg.com/language-subtag-registry/-/language-subtag-registry-0.3.22.tgz#2e1500861b2e457eba7e7ae86877cbd08fa1fd1d"
@ -8731,6 +8750,24 @@ react-is@^18.0.0:
resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.2.0.tgz#199431eeaaa2e09f86427efbb4f1473edb47609b"
integrity sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==
react-konva@^18.2.10:
version "18.2.10"
resolved "https://registry.yarnpkg.com/react-konva/-/react-konva-18.2.10.tgz#5b5edc5e9ed452755d21babc353747828868decc"
integrity sha512-ohcX1BJINL43m4ynjZ24MxFI1syjBdrXhqVxYVDw2rKgr3yuS0x/6m1Y2Z4sl4T/gKhfreBx8KHisd0XC6OT1g==
dependencies:
"@types/react-reconciler" "^0.28.2"
its-fine "^1.1.1"
react-reconciler "~0.29.0"
scheduler "^0.23.0"
react-reconciler@~0.29.0:
version "0.29.0"
resolved "https://registry.yarnpkg.com/react-reconciler/-/react-reconciler-0.29.0.tgz#ee769bd362915076753f3845822f2d1046603de7"
integrity sha512-wa0fGj7Zht1EYMRhKWwoo1H9GApxYLBuhoAuXN0TlltESAjDssB+Apf0T/DngVqaMyPypDmabL37vw/2aRM98Q==
dependencies:
loose-envify "^1.1.0"
scheduler "^0.23.0"
react-refresh@^0.11.0:
version "0.11.0"
resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.11.0.tgz#77198b944733f0f1f1a90e791de4541f9f074046"