add events page

This commit is contained in:
NlightN22 2024-10-01 02:44:04 +07:00
parent 12b508661a
commit f935de1eeb
29 changed files with 669 additions and 112 deletions

View File

@ -1,14 +1,13 @@
import { AppShell, useMantineTheme, } from "@mantine/core";
import { observer } from 'mobx-react-lite';
import { useContext, useState } from 'react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Context } from '.';
import { useLocation } from "react-router-dom";
import AppRouter from './router/AppRouter';
import { routesPath } from './router/routes.path';
import RightSideBar from "./shared/components/RightSideBar";
import { isProduction } from './shared/env.const';
import { HeaderAction } from './widgets/header/HeaderAction';
import RightSideBar from "./shared/components/RightSideBar";
import { useLocation } from "react-router-dom";
const AppBody = () => {
const { t } = useTranslation()
@ -17,6 +16,7 @@ const AppBody = () => {
{ link: routesPath.MAIN_PATH, label: t('header.home') },
{ link: routesPath.SETTINGS_PATH, label: t('header.settings'), admin: true },
{ link: routesPath.RECORDINGS_PATH, label: t('header.recordings') },
{ link: routesPath.EVENTS_PATH, label: t('header.events') },
{ link: routesPath.HOSTS_PATH, label: t('header.hostsConfig'), admin: true },
{ link: routesPath.ACCESS_PATH, label: t('header.acessSettings'), admin: true },
]
@ -25,7 +25,7 @@ const AppBody = () => {
const location = useLocation()
const pathsWithLeftSidebar: string[] = []
const pathsWithRightSidebar: string[] = [routesPath.MAIN_PATH, routesPath.RECORDINGS_PATH]
const pathsWithRightSidebar: string[] = [routesPath.MAIN_PATH, routesPath.RECORDINGS_PATH, routesPath.EVENTS_PATH]
const [leftSideBar, setLeftSidebar] = useState(pathsWithLeftSidebar.includes(location.pathname))
const [rightSideBar, setRightSidebar] = useState(pathsWithRightSidebar.includes(location.pathname))

View File

@ -1,13 +1,13 @@
import React, { createContext } from 'react';
import { ReactKeycloakProvider } from '@react-keycloak/web';
import { createContext } from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App';
import reportWebVitals from './reportWebVitals';
import RootStore from './shared/stores/root.store';
import { BrowserRouter } from 'react-router-dom';
import './services/i18n';
import { ReactKeycloakProvider } from '@react-keycloak/web';
import keycloak from './services/keycloak-config';
import keycloakInstance from './services/keycloak-config';
import CenterLoader from './shared/components/loaders/CenterLoader';
import RootStore from './shared/stores/root.store';
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
@ -15,7 +15,6 @@ const root = ReactDOM.createRoot(
export const hostURL = new URL(window.location.href)
const rootStore = new RootStore()
export const Context = createContext<RootStore>(rootStore)
@ -25,11 +24,11 @@ const eventLogger = (event: string, error?: any) => {
const tokenLogger = (tokens: any) => {
console.log('onKeycloakTokens', tokens);
};
}
root.render(
<ReactKeycloakProvider
authClient={keycloak}
authClient={keycloakInstance}
LoadingComponent={<CenterLoader />}
onEvent={eventLogger}
onTokens={tokenLogger}

View File

@ -14,6 +14,10 @@ const en = {
height: 'Height',
points: 'Points',
},
eventsPage: {
selectStartTime: 'Select start time:',
selectEndTime: 'Select end time:',
},
frigateConfigPage: {
copyConfig: 'Copy Config',
saveOnly: 'Save Only',
@ -74,6 +78,7 @@ const en = {
home: 'Main',
settings: 'Settings',
recordings: 'Recordings',
events: 'Events',
hostsConfig: 'Frigate servers',
acessSettings: 'Access settings',
},
@ -93,6 +98,7 @@ const en = {
doubleClickToFullHint: 'Double click for fullscreen',
rating: 'Rating',
},
maxRetries: 'Error: Unable to fetch data after {{maxRetries}} retries. Please try again later or change period to smaller.',
config: 'Config',
create: 'Create',
clear: 'Clear',
@ -116,6 +122,7 @@ const en = {
second: 'Second',
events: 'Events',
notHaveEvents: 'No events',
notHaveEventsAtThatPeriod: 'Not have events at that period',
selectHost: 'Select host',
selectCamera: 'Select Camera',
selectRange: 'Select period',

View File

@ -12,6 +12,11 @@ const ru = {
height: 'Высота',
points: 'Точки',
},
eventsPage: {
selectStartTime: 'Выбери время начала:',
selectEndTime: 'Выбери время окончания:',
maxEventsFetches: 'Ошибка: Невозможно получить события после {{maxRetries}} попыток. Пожалуйста попробуйте позже или установите меньший период.',
},
frigateConfigPage: {
copyConfig: 'Копировать Конфиг.',
saveOnly: 'Только Сохранить',
@ -72,6 +77,7 @@ const ru = {
home: 'Главная',
settings: 'Настройки',
recordings: 'Записи',
events: 'События',
hostsConfig: 'Серверы Frigate',
acessSettings: 'Настройка доступа',
},
@ -114,6 +120,7 @@ const ru = {
second: 'Час',
events: 'События',
notHaveEvents: 'Событий нет',
notHaveEventsAtThatPeriod: 'Нет событий за этот период',
selectHost: 'Выбери хост',
selectCamera: 'Выбери камеру',
selectRange: 'Выбери период',

62
src/pages/EventsPage.tsx Normal file
View File

@ -0,0 +1,62 @@
import { Flex, Text } from '@mantine/core';
import { t } from 'i18next';
import { observer } from 'mobx-react-lite';
import { useContext, useEffect } from 'react';
import { Context } from '..';
import EventsBody from '../widgets/EventsBody';
import EventsRightFilters from '../widgets/sidebars/EventsRightFilters';
import { SideBarContext } from '../widgets/sidebars/SideBarContext';
import { isProduction } from '../shared/env.const';
export const eventsPageQuery = {
hostId: 'hostId',
cameraId: 'cameraId',
startDay: 'startDay',
endDay: 'endDay',
hour: 'hour',
}
const EventsPage = () => {
const { setRightChildren } = useContext(SideBarContext)
useEffect(() => {
setRightChildren(<EventsRightFilters />)
return () => setRightChildren(null)
}, [])
const { eventsStore } = useContext(Context)
const { hostId, cameraId, period, startTime, endTime } = eventsStore.filters
if (hostId && cameraId && period && period[0] && period[1]) {
return (
<EventsBody
hostId={hostId}
cameraId={cameraId}
period={[period[0], period[1]]}
startTime={startTime}
endTime={endTime}
/>
)
}
return (
<Flex w='100%' h='100%' direction='column' justify='center' align='center'>
{!hostId ?
<Text size='xl'>{t('pleaseSelectHost')}</Text>
: <></>
}
{hostId && !cameraId ?
<Text size='xl'>{t('pleaseSelectCamera')}</Text>
: <></>
}
{hostId && cameraId && !eventsStore.isPeriodSet() ?
<Text size='xl'>{t('pleaseSelectDate')}</Text>
: <></>
}
</Flex>
);
};
export default observer(EventsPage);

View File

@ -20,7 +20,7 @@ import { CameraTag } from '../types/tags';
const MainPage = () => {
const { t } = useTranslation()
const { mainStore } = useContext(Context)
const { setChildrenComponent } = useContext(SideBarContext)
const { setRightChildren } = useContext(SideBarContext)
const { selectedHostId, selectedTags } = mainStore
const [searchQuery, setSearchQuery] = useState<string>()
const [filteredCameras, setFilteredCameras] = useState<GetCameraWHostWConfig[]>()
@ -35,8 +35,8 @@ const MainPage = () => {
})
useEffect(() => {
setChildrenComponent(<MainFiltersRightSide />);
return () => setChildrenComponent(null);
setRightChildren(<MainFiltersRightSide />);
return () => setRightChildren(null);
}, []);
useEffect(() => {

View File

@ -13,7 +13,6 @@ import SelectedHostList from '../widgets/SelectedHostList';
import { useTranslation } from 'react-i18next';
import { SideBarContext } from '../widgets/sidebars/SideBarContext';
export const recordingsPageQuery = {
hostId: 'hostId',
cameraId: 'cameraId',
@ -41,12 +40,11 @@ const RecordingsPage = () => {
const [cameraId, setCameraId] = useState<string>('')
const [period, setPeriod] = useState<[Date | null, Date | null]>([null, null])
const { setChildrenComponent } = useContext(SideBarContext)
const { setRightChildren } = useContext(SideBarContext)
useEffect(() => {
setChildrenComponent(<RecordingsFiltersRightSide />);
return () => setChildrenComponent(null);
setRightChildren(<RecordingsFiltersRightSide />);
return () => setRightChildren(null);
}, []);

View File

@ -2,6 +2,7 @@ export const routesPath = {
MAIN_PATH: '/',
BIRDSEYE_PATH: '/birdseye',
RECORDINGS_PATH: '/recordings',
EVENTS_PATH: '/events',
SETTINGS_PATH: '/settings',
HOSTS_PATH: '/hosts',
HOST_CONFIG_PATH: '/hosts/:id/config',

View File

@ -14,6 +14,7 @@ import RecordingsPage from "../pages/RecordingsPage";
import AccessSettings from "../pages/AccessSettingsPage";
import PlayRecordPage from "../pages/PlayRecordPage";
import EditCameraPage from "../pages/EditCameraPage";
import EventsPage from "../pages/EventsPage";
interface IRoute {
path: string,
@ -33,6 +34,10 @@ export const routes: IRoute[] = [
path: routesPath.RECORDINGS_PATH,
component: <RecordingsPage />,
},
{
path: routesPath.EVENTS_PATH,
component: <EventsPage />,
},
{
path: routesPath.HOST_CONFIG_PATH,
component: <HostConfigPage />,

View File

@ -15,7 +15,7 @@ import { EventFrigate } from "../../types/event";
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 keycloakInstance from "../keycloak-config";
import { PutMask } from "../../types/mask";
import { GetUserTag, PutUserTag } from "../../types/tags";
@ -26,7 +26,7 @@ const instanceApi = axios.create({
instanceApi.interceptors.request.use(
config => {
const accessToken = keycloak.token;
const accessToken = keycloakInstance.token;
if (accessToken) {
config.headers.Authorization = `Bearer ${accessToken}`
}
@ -44,6 +44,7 @@ export const frigateApi = {
putOIDPConfigTest: (config: OIDPConfig) => instanceApi.put('apiv1/config/oidp/test', config).then(res => res.data),
getHosts: () => instanceApi.get<GetFrigateHost[]>('apiv1/frigate-hosts').then(res => res.data),
getHost: (id: string) => instanceApi.get<GetFrigateHost>(`apiv1/frigate-hosts/${id}`).then(res => res.data),
getCameraById: (cameraId: string) => instanceApi.get<GetCameraWHostWConfig>(`apiv1/cameras/${cameraId}`).then(res => res.data),
getCamerasByHostId: (hostId: string) => instanceApi.get<GetCameraWHostWConfig[]>(`apiv1/cameras/host/${hostId}`).then(res => res.data),
getCamerasWHost: () => instanceApi.get<GetCameraWHostWConfig[]>(`apiv1/cameras`).then(res => res.data),
getCameraWHost: (id: string) => instanceApi.get<GetCameraWHostWConfig>(`apiv1/cameras/${id}`).then(res => res.data),
@ -227,6 +228,7 @@ export const frigateQueryKeys = {
getFrigateHost: 'frigate-host',
getCamerasWHost: 'cameras-frigate-host',
getCameraWHost: 'camera-frigate-host',
getCameraById: 'camera-by-Id',
getCameraByHostId: 'camera-by-hostId',
getHostConfig: 'host-config',
postHostConfig: 'host-config-save',

View File

@ -5,8 +5,8 @@ const keycloakConfig = {
url: oidpSettings.server,
realm: oidpSettings.realm,
clientId: oidpSettings.clientId,
};
};
const keycloak = new Keycloak(keycloakConfig);
const keycloakInstance = new Keycloak(keycloakConfig)
export default keycloak;
export default keycloakInstance;

View File

@ -32,8 +32,6 @@ const RightSideBar = ({ onChangeHidden, children }: RightSideBarProps) => {
const side = 'right'
const hideSizePx = useMantineSize(dimensions.hideSidebarsSize)
const [visible, { open, close }] = useDisclosure(true);
const { classes } = useStyles({ visible })

View File

@ -4,7 +4,7 @@ import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { dimensions } from '../dimensions/dimensions';
import ColorSchemeToggle from './buttons/ColorSchemeToggle';
import keycloak from "../../services/keycloak-config";
import { useKeycloak } from "@react-keycloak/web";
interface UserMenuProps {
user: { name: string; image: string }
@ -14,6 +14,8 @@ const UserMenu = ({ user }: UserMenuProps) => {
const { t, i18n } = useTranslation()
const { keycloak, initialized } = useKeycloak()
const languages = [
{ lng: 'en', name: 'Eng' },
{ lng: 'ru', name: 'Rus' },

View File

@ -2,6 +2,7 @@ import { Accordion, Center, Loader, Text } from '@mantine/core';
import { useQuery } from '@tanstack/react-query';
import { observer } from 'mobx-react-lite';
import { useContext, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Context } from '../../..';
import { frigateQueryKeys, mapHostToHostname, proxyApi } from '../../../services/frigate.proxy/frigate.api';
import { GetCameraWHostWConfig, GetFrigateHost, getEventsQuerySchema } from '../../../services/frigate.proxy/frigate.schema';
@ -16,8 +17,10 @@ import EventsAccordionItem from './EventsAccordionItem';
* @param hostName proxy format, e.g hostName: localhost:4000
*/
interface EventsAccordionProps {
day: string,
hour: string,
startTime?: number,
endTime?: number,
day?: string,
hour?: string,
camera: GetCameraWHostWConfig
host: GetFrigateHost
}
@ -29,6 +32,8 @@ interface EventsAccordionProps {
* @param hostName proxy format, e.g hostName: localhost:4000
*/
const EventsAccordion = ({
startTime,
endTime,
day,
hour,
camera,
@ -37,19 +42,32 @@ const EventsAccordion = ({
const { recordingsStore: recStore } = useContext(Context)
const [openedItem, setOpenedItem] = useState<string>()
const { t } = useTranslation()
const [retryCount, setRetryCount] = useState(0)
const MAX_RETRY_COUNT = 3
const hostName = mapHostToHostname(host)
const isRequiredParams = host && camera
const isRequiredParams = (host && camera) || !(day && hour) || !(startTime && endTime)
const { data, isPending, isError, refetch } = useQuery({
queryKey: [frigateQueryKeys.getEvents, host, camera, day, hour],
queryKey: [frigateQueryKeys.getEvents, host, camera, day, hour, startTime, endTime],
queryFn: () => {
if (!isRequiredParams) return null
const [startTime, endTime] = getUnixTime(day, hour)
let queryStartTime: number
let queryEndTime: number
if (day && hour) {
[queryStartTime, queryEndTime] = getUnixTime(day, hour)
} else if (startTime && endTime) {
queryStartTime = startTime
queryEndTime = endTime
}
else { return null }
const parsed = getEventsQuerySchema.safeParse({
hostName: mapHostToHostname(host),
camerasName: [camera.name],
after: startTime,
before: endTime,
after: queryStartTime,
before: queryEndTime,
hasClip: true,
includeThumnails: false,
})
@ -69,12 +87,26 @@ const EventsAccordion = ({
)
}
return null
},
retry: (failureCount, error) => {
setRetryCount(failureCount);
if (failureCount >= MAX_RETRY_COUNT) return false;
return true;
}
})
if (isPending) return <Center><Loader /></Center>
if (isError && retryCount >= MAX_RETRY_COUNT) {
return (
<Center>
<Text>{t('maxRetries', { maxRetries: MAX_RETRY_COUNT })}</Text>
</Center>
);
}
if (isError) return <RetryError onRetry={refetch} />
if (!data || data.length < 1) return <Center><Text>Not have events at that period</Text></Center>
if (!data || data.length < 1) return <Center><Text>{t('notHaveEventsAtThatPeriod')}</Text></Center>
const handleOpenPlayer = (value: string | undefined) => {
if (value !== recStore.playedItem) {
@ -94,7 +126,7 @@ const EventsAccordion = ({
recStore.playedItem = undefined
}
if (!hostName) throw Error('EventsAccordion hostName must be exist')
if (!hostName) return null
return (
<Accordion

View File

@ -0,0 +1,65 @@
import { Loader, MantineStyleSystemProps, SpacingValue, SystemProp } from '@mantine/core';
import { useQuery } from '@tanstack/react-query';
import { FC, useEffect } from 'react';
import { frigateApi, frigateQueryKeys } from '../../../services/frigate.proxy/frigate.api';
import RetryError from '../RetryError';
import OneSelectFilter, { OneSelectItem } from './OneSelectFilter';
interface CameraSelectProps extends MantineStyleSystemProps {
hostId: string
label?: string
valueId?: string
defaultId?: string
spaceBetween?: SystemProp<SpacingValue>
placeholder?: string
onChange?: (value: string) => void
onSuccess?: () => void
}
const CameraSelect: FC<CameraSelectProps> = ({
hostId,
label,
valueId,
defaultId,
spaceBetween,
placeholder,
onChange,
onSuccess,
...styleProps
}) => {
const { data, isError, isPending, isSuccess, refetch } = useQuery({
queryKey: [frigateQueryKeys.getCameraByHostId, hostId],
queryFn: () => frigateApi.getCamerasByHostId(hostId)
})
useEffect(() => {
if (onSuccess) onSuccess()
}, [isSuccess])
if (isPending) return <Loader />
if (isError) return <RetryError onRetry={refetch} />
if (!data) return null
const camerasItems: OneSelectItem[] = data.map(camera => ({ value: camera.id, label: camera.name }))
const handleSelect = (value: string) => {
if (onChange) onChange(value)
}
return (
<OneSelectFilter
id='frigate-cameras'
label={label}
spaceBetween='1rem'
value={valueId || ''}
defaultValue={defaultId || ''}
data={camerasItems}
onChange={handleSelect}
{...styleProps}
/>
);
};
export default CameraSelect;

View File

@ -1,14 +1,11 @@
import { observer } from 'mobx-react-lite';
import React, { useContext, useEffect } from 'react';
import { Context } from '../../..';
import { useQuery } from '@tanstack/react-query';
import { frigateApi, frigateQueryKeys } from '../../../services/frigate.proxy/frigate.api';
import CogwheelLoader from '../loaders/CogwheelLoader';
import { Center, Loader, Text } from '@mantine/core';
import OneSelectFilter, { OneSelectItem } from './OneSelectFilter';
import RetryError from '../RetryError';
import { isProduction } from '../../env.const';
import { observer } from 'mobx-react-lite';
import { useContext } from 'react';
import { useTranslation } from 'react-i18next';
import { Context } from '../../..';
import { frigateApi, frigateQueryKeys } from '../../../services/frigate.proxy/frigate.api';
import { isProduction } from '../../env.const';
import CameraSelect from './CameraSelect';
interface CameraSelectFilterProps {
selectedHostId: string,
@ -20,28 +17,22 @@ const CameraSelectFilter = ({
const { t } = useTranslation()
const { recordingsStore: recStore } = useContext(Context)
const { data, isError, isPending, isSuccess, refetch } = useQuery({
const { data } = useQuery({
queryKey: [frigateQueryKeys.getCameraByHostId, selectedHostId],
queryFn: () => frigateApi.getCamerasByHostId(selectedHostId)
})
useEffect(() => {
const handleSuccess = () => {
if (!data) return
if (recStore.cameraIdParam) {
if (!isProduction) console.log('change camera by param')
recStore.filteredCamera = data.find( camera => camera.id === recStore.cameraIdParam)
recStore.filteredCamera = data.find(camera => camera.id === recStore.cameraIdParam)
recStore.cameraIdParam = undefined
}
}, [isSuccess])
if (isPending) return <Loader />
if (isError) return <RetryError onRetry={refetch}/>
if (!data) return null
const camerasItems: OneSelectItem[] = data.map(camera => ({ value: camera.id, label: camera.name }))
}
const handleSelect = (value: string) => {
const camera = data.find(camera => camera.id === value)
const camera = data?.find(camera => camera.id === value)
if (!camera) {
recStore.filteredCamera = undefined
return
@ -52,14 +43,15 @@ const CameraSelectFilter = ({
if (!isProduction) console.log('CameraSelectFilter rendered')
return (
<OneSelectFilter
id='frigate-cameras'
< CameraSelect
hostId={selectedHostId}
label={t('selectCamera')}
spaceBetween='1rem'
value={recStore.filteredCamera?.id || ''}
defaultValue={recStore.filteredCamera?.id || ''}
data={camerasItems}
valueId={recStore.filteredCamera?.id || ''}
defaultId={recStore.filteredCamera?.id || ''}
onChange={handleSelect}
onSuccess={handleSuccess}
/>
);
};

View File

@ -1,24 +1,23 @@
import { Box, Flex, Indicator, Text } from '@mantine/core';
import { DatePickerInput } from '@mantine/dates';
import { observer } from 'mobx-react-lite';
import { useContext } from 'react';
import { FC } from 'react';
import { useTranslation } from 'react-i18next';
import { Context } from '../../..';
import { isProduction } from '../../env.const';
interface DateRangeSelectFilterProps {}
const DateRangeSelectFilter = ({
interface DateRangeSelectFilterProps {
onChange?(value: [Date | null, Date | null]): void
value?: [Date | null, Date | null]
}
const DateRangeSelectFilter: FC<DateRangeSelectFilterProps> = ({
onChange,
value,
}: DateRangeSelectFilterProps) => {
const { t } = useTranslation()
const { recordingsStore: recStore } = useContext(Context)
const handlePick = (value: [Date | null, Date | null]) => {
recStore.selectedRange = value
if (onChange) onChange(value)
}
if (!isProduction) console.log('DateRangeSelectFilter rendered')
return (
<Box>
<Flex
@ -35,7 +34,7 @@ const DateRangeSelectFilter = ({
type="range"
mx="auto"
maw={400}
value={recStore.selectedRange}
value={value}
onChange={handlePick}
renderDay={(date) => {
const day = date.getDate();
@ -53,4 +52,4 @@ const DateRangeSelectFilter = ({
export default observer(DateRangeSelectFilter);
export default DateRangeSelectFilter;

View File

@ -0,0 +1,81 @@
import { ActionIcon, Box, Flex, Text } from '@mantine/core';
import { TimeInput } from '@mantine/dates';
import { useDebouncedState, useDebouncedValue } from '@mantine/hooks';
import { IconClock, IconX } from '@tabler/icons-react';
import React, { FC, useEffect, useRef, useState } from 'react';
interface TimePickerProps {
value?: string
defaultValue?: string
label?: string
onChange?(value: string | undefined): void
}
const TimePicker: FC<TimePickerProps> = ({
defaultValue,
label,
onChange
}) => {
const ref = useRef<HTMLInputElement | null>(null)
const [value, setValue] = useState(defaultValue)
const [debounced] = useDebouncedValue(value, 1600)
const [pickerOpened, setPickerOpened] = useState(false)
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const inputValue = e.currentTarget.value
setValue(inputValue)
}
useEffect(() => {
if (onChange) onChange(debounced)
}, [debounced])
const handleClick = () => {
if (!pickerOpened) {
ref.current?.showPicker();
setPickerOpened(true);
} else {
setPickerOpened(false)
}
}
return (
<Box>
<Flex
mt='1rem'
justify='space-between'>
<Text>{label}</Text>
</Flex>
<TimeInput
value={value}
mt='1rem'
ref={ref}
rightSection={
<>
{
!value ? null :
<ActionIcon
onClick={() => setValue('')}
ml='-1.7rem'
>
<IconX size='1rem' />
</ActionIcon>
}
<ActionIcon
onClick={() => ref.current?.showPicker()}>
<IconClock size="1rem" stroke={1.5} />
</ActionIcon>
</>
}
maw={400}
mx="auto"
onChange={handleChange}
onClick={handleClick}
/>
</Box>
);
};
export default TimePicker;

View File

@ -0,0 +1,102 @@
import { makeAutoObservable } from "mobx";
interface Filters {
hostId?: string
cameraId?: string
period?: [Date | null, Date | null]
startTime?: string
endTime?: string
}
export class EventsStore {
filters: Filters = {}
constructor() {
makeAutoObservable(this);
this.loadFiltersFromURL();
}
loadFiltersFromURL() {
const params = new URLSearchParams(window.location.search);
this.filters.hostId = params.get('hostId') || undefined;
this.filters.cameraId = params.get('cameraId') || undefined;
const startDate = params.get('startDate');
const endDate = params.get('endDate');
if (startDate && endDate) {
this.filters.period = [new Date(startDate), new Date(endDate)]
}
this.filters.startTime = params.get('startTime') || undefined
this.filters.endTime = params.get('endTime') || undefined
}
setHostId(hostId: string, navigate: (path: string) => void) {
this.filters.hostId = hostId;
this.updateURL(navigate)
}
setCameraId(cameraId: string, navigate: (path: string) => void) {
this.filters.cameraId = cameraId;
this.updateURL(navigate)
}
setPeriod(period: [Date | null, Date | null], navigate: (path: string) => void) {
this.filters.period = period;
this.updateURL(navigate)
}
setStartTime(startTime: string, navigate: (path: string) => void) {
this.filters.startTime = startTime
this.updateURL(navigate)
}
setEndTime(endTime: string, navigate: (path: string) => void) {
this.filters.endTime = endTime
this.updateURL(navigate)
}
updateURL(navigate: (path: string) => void) {
const params = new URLSearchParams();
if (this.filters.hostId) params.set('hostId', this.filters.hostId);
if (this.filters.cameraId) params.set('cameraId', this.filters.cameraId);
if (this.filters.period) {
const [startDate, endDate] = this.filters.period;
if (startDate instanceof Date && !isNaN(startDate.getTime())) {
params.set('startDate', startDate.toISOString());
}
if (endDate instanceof Date && !isNaN(endDate.getTime())) {
params.set('endDate', endDate.toISOString());
}
}
if (this.filters.startTime) params.set('startTime', this.filters.startTime)
if (this.filters.endTime) params.set('endTime', this.filters.endTime)
navigate(`?${params.toString()}`);
}
isPeriodSet() {
if (this.getStartDay() && this.getEndDay()) return true
return false
}
getStartDay() {
if (this.filters.period) {
const [startDate, endDate] = this.filters.period;
if (startDate instanceof Date && !isNaN(startDate.getTime())) {
return startDate
}
}
return null
}
getEndDay() {
if (this.filters.period) {
const [startDate, endDate] = this.filters.period;
if (endDate instanceof Date && !isNaN(endDate.getTime())) {
return endDate
}
}
return null
}
}

View File

@ -61,13 +61,4 @@ export class RecordingsStore {
public set selectedRange(value: [Date | null, Date | null]) {
this._selectedRange = value
}
// TODO Delete
// private _openedCamera: GetCameraWHostWConfig | undefined
// public get openedCamera(): GetCameraWHostWConfig | undefined {
// return this._openedCamera
// }
// public set openedCamera(value: GetCameraWHostWConfig | undefined) {
// this._openedCamera = value
// }
}

View File

@ -1,3 +1,4 @@
import { EventsStore } from "./events.store";
import { MainStore } from "./main.store";
import { ModalStore } from "./modal.store";
import { RecordingsStore } from "./recordings.store";
@ -8,11 +9,13 @@ class RootStore {
userStore: UserStore
modalStore: ModalStore
recordingsStore: RecordingsStore
eventsStore: EventsStore
constructor() {
this.mainStore = new MainStore()
this.userStore = new UserStore()
this.modalStore = new ModalStore(this)
this.recordingsStore = new RecordingsStore()
this.eventsStore = new EventsStore()
}
}

View File

@ -133,6 +133,41 @@ export const getUnixTime = (day?: string, hour?: number | string) => {
return [unixTimeStart, unixTimeEnd];
}
/**
* @param date JS Date
* @returns unixTime
*/
export const dateToUnixTime = (date: Date) => {
return date.getTime() / 1000
}
/**
* @param period [start: begin of Day, end: end of Day]
* @returns [start: unixTimeStart, end: unixTimeEnd]
*/
export const dayRangeToUnixPeriod = (period: [Date, Date]) => {
const start = period[0]
const end = period[1]
start.setHours(0, 0, 0, 0)
end.setHours(23, 59, 59)
const startTime = dateToUnixTime(start)
const endTime = dateToUnixTime(end)
return [startTime, endTime]
}
export const dayTimeToUnixTime = (day: Date, time: string) => {
const [hours, minutes] = time.split(':').map(Number)
day.setHours(hours);
day.setMinutes(minutes);
day.setSeconds(0);
day.setMilliseconds(0);
return Math.floor(day.getTime() / 1000)
}
/**
* This function takes in a Unix timestamp, configuration options for date/time display, and an optional strftime format string,
* and returns a formatted date/time string.

View File

@ -1,13 +1,13 @@
import { Flex } from '@mantine/core';
import { dataTagSymbol, useMutation, useQueryClient } from '@tanstack/react-query';
import { notifications } from '@mantine/notifications';
import { IconAlertCircle } from '@tabler/icons-react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import React, { useEffect, useState } from 'react';
import { frigateApi, frigateQueryKeys } from '../services/frigate.proxy/frigate.api';
import { GetCameraWHostWConfig } from '../services/frigate.proxy/frigate.schema';
import AddBadge from '../shared/components/AddBadge';
import TagBadge from '../shared/components/TagBadge';
import { CameraTag, PutUserTag } from '../types/tags';
import { notifications } from '@mantine/notifications';
import { IconAlertCircle } from '@tabler/icons-react';
import { CameraTag } from '../types/tags';
interface CameraTagsListProps {

View File

@ -0,0 +1,52 @@
import { useQuery } from '@tanstack/react-query';
import { observer } from 'mobx-react-lite';
import { FC } from 'react';
import { frigateApi, frigateQueryKeys } from '../services/frigate.proxy/frigate.api';
import EventsAccordion from '../shared/components/accordion/EventsAccordion';
import CenterLoader from '../shared/components/loaders/CenterLoader';
import RetryError from '../shared/components/RetryError';
import { dayTimeToUnixTime } from '../shared/utils/dateUtil';
interface EventsBodyProps {
hostId: string,
cameraId: string,
period: [Date, Date],
startTime?: string,
endTime?: string,
}
const EventsBody: FC<EventsBodyProps> = ({
hostId,
cameraId,
period,
startTime,
endTime,
}) => {
const startTimeUnix = dayTimeToUnixTime(period[0], startTime ? startTime : '00:00')
const endTimeUnix = dayTimeToUnixTime(period[1], endTime ? endTime : '23:59')
const { data, isError, isPending, refetch } = useQuery({
queryKey: [frigateQueryKeys.getCameraById, cameraId, frigateQueryKeys.getFrigateHost, hostId],
queryFn: async () => {
const host = await frigateApi.getHost(hostId)
const camera = await frigateApi.getCameraById(cameraId)
return { camera, host }
}
})
if (isPending) return <CenterLoader />
if (isError) return <RetryError onRetry={refetch} />
if (!data) return null
return (
<EventsAccordion
camera={data.camera}
host={data.host}
startTime={startTimeUnix}
endTime={endTimeUnix}
/>
)
};
export default observer(EventsBody);

View File

@ -1,5 +1,5 @@
import { useQuery } from '@tanstack/react-query';
import React, { useContext } from 'react';
import React, { useContext, useState } from 'react';
import { frigateQueryKeys, mapHostToHostname, proxyApi } from '../services/frigate.proxy/frigate.api';
import { dateToQueryString, getResolvedTimeZone } from '../shared/utils/dateUtil';
import { Context } from '..';
@ -9,6 +9,7 @@ import CenterLoader from '../shared/components/loaders/CenterLoader';
import { observer } from 'mobx-react-lite';
import DayAccordion from '../shared/components/accordion/DayAccordion';
import { isProduction } from '../shared/env.const';
import { useTranslation } from 'react-i18next';
interface SelectedDayListProps {
day: Date
@ -21,6 +22,12 @@ const SelectedDayList = ({
const camera = recStore.filteredCamera
const host = recStore.filteredHost
const { t } = useTranslation()
const [retryCount, setRetryCount] = useState(0)
const MAX_RETRY_COUNT = 3
const { data, isPending, isError, refetch } = useQuery({
queryKey: [frigateQueryKeys.getRecordingsSummary, recStore.filteredCamera?.id, day],
queryFn: async () => {
@ -31,6 +38,13 @@ const SelectedDayList = ({
}
}
return null
},
retry: (failureCount, error) => {
setRetryCount(failureCount);
if (failureCount >= MAX_RETRY_COUNT) return false;
return true;
}
})
@ -39,6 +53,15 @@ const SelectedDayList = ({
}
if (isPending) return <CenterLoader />
if (isError && retryCount >= MAX_RETRY_COUNT) {
return (
<Center>
<Text>{t('maxRetries', { maxRetries: MAX_RETRY_COUNT })}</Text>
</Center>
);
}
if (isError) return <RetryErrorPage onRetry={handleRetry} />
if (!camera || !host) return <Center><Text>Please select host or camera</Text></Center>
if (!data) return <Text>Not have response from server</Text>
@ -56,7 +79,7 @@ const SelectedDayList = ({
camera={camera}
recordSummary={recordingsDay} />
</Flex>
);
};
)
}
export default observer(SelectedDayList);
export default observer(SelectedDayList)

View File

@ -6,7 +6,7 @@ import UserMenu from '../../shared/components/UserMenu';
import ColorSchemeToggle from "../../shared/components/buttons/ColorSchemeToggle";
import Logo from "../../shared/components/images/LogoImage";
import DrawerMenu from "../../shared/components/menu/DrawerMenu";
import keycloak from "../../services/keycloak-config";
import { useKeycloak } from "@react-keycloak/web";
const HEADER_HEIGHT = rem(60)
@ -62,6 +62,7 @@ export const HeaderAction = ({ links }: HeaderActionProps) => {
const { classes } = useStyles();
const navigate = useNavigate()
const { isAdmin } = useAdminRole()
const { keycloak, initialized } = useKeycloak()
const handleNavigate = (link: string) => {
navigate(link)

View File

@ -0,0 +1,93 @@
import { observer } from 'mobx-react-lite';
import { useContext } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { Context } from '../..';
import CameraSelect from '../../shared/components/filters/CameraSelect';
import DateRangeSelect from '../../shared/components/filters/DateRangeSelect';
import HostSelect from '../../shared/components/filters/HostSelect';
import TimePicker from '../../shared/components/filters/TimePicker';
import { isProduction } from '../../shared/env.const';
const EventsRightFilters = () => {
const { t } = useTranslation()
const { eventsStore } = useContext(Context)
const navigate = useNavigate()
const handleHostSelect = (hostId: string) => {
eventsStore.setHostId(hostId, navigate)
}
const handleCameraSelect = (cameraId: string) => {
eventsStore.setCameraId(cameraId, navigate)
}
const handlePeriodSelect = (value: [Date | null, Date | null]) => {
eventsStore.setPeriod(value, navigate)
if (!isProduction) console.log('Selected period: ', value)
}
const handleSelectStartTime = (value: string) => {
eventsStore.setStartTime(value, navigate)
if (!isProduction) console.log('Selected start time: ', value)
}
const handleSelectEndTime = (value: string) => {
eventsStore.setEndTime(value, navigate)
if (!isProduction) console.log('Selected end time: ', value)
}
const validatedStartTime = () => {
if (eventsStore.filters.startTime && eventsStore.filters.endTime) {
if (eventsStore.filters.startTime > eventsStore.filters.endTime) {
return eventsStore.filters.endTime
}
}
return eventsStore.filters.startTime
}
return (
<>
<HostSelect
label={t('selectHost')}
valueId={eventsStore.filters.hostId}
onChange={handleHostSelect}
/>
{!eventsStore.filters.hostId ? null :
<CameraSelect
label={t('selectCamera')}
hostId={eventsStore.filters.hostId}
valueId={eventsStore.filters.cameraId}
onChange={handleCameraSelect}
/>
}
{!eventsStore.filters.cameraId ? null :
<DateRangeSelect
onChange={handlePeriodSelect}
value={eventsStore.filters.period}
/>
}
{!eventsStore.isPeriodSet() ? null :
<>
<TimePicker
defaultValue={eventsStore.filters.startTime}
key='startTime'
label={t('eventsPage.selectStartTime')}
onChange={handleSelectStartTime}
/>
<TimePicker
defaultValue={eventsStore.filters.endTime}
key='endTime'
label={t('eventsPage.selectEndTime')}
onChange={handleSelectEndTime}
/>
</>
}
</>
)
}
export default observer(EventsRightFilters);

View File

@ -2,7 +2,7 @@ import React, { useContext } from 'react';
import { observer } from 'mobx-react-lite';
import { Context } from '../..';
import CameraSelectFilter from '../../shared/components/filters/CameraSelectFilter';
import DateRangeSelectFilter from '../../shared/components/filters/DateRangeSelectFilter';
import DateRangeSelectFilter from '../../shared/components/filters/DateRangeSelect';
import RecordingsHostFilter from '../../shared/components/filters/RecordingsHostFilter';
import { isProduction } from '../../shared/env.const';
@ -10,6 +10,10 @@ const RecordingsFiltersRightSide = () => {
const { recordingsStore: recStore } = useContext(Context)
if (!isProduction) console.log('RecordingsFiltersRightSide rendered')
const handleDatePick = (value: [Date | null, Date | null]) => {
recStore.selectedRange = value
}
return (
<>
<RecordingsHostFilter />
@ -19,7 +23,10 @@ const RecordingsFiltersRightSide = () => {
: <></>
}
{recStore.filteredCamera ?
<DateRangeSelectFilter />
<DateRangeSelectFilter
onChange={handleDatePick}
value={recStore.selectedRange}
/>
: <></>
}
</>

View File

@ -2,19 +2,19 @@ import React, { createContext, ReactNode, useState } from 'react';
interface SideBarContextProps {
childrenComponent: ReactNode;
setChildrenComponent: (component: ReactNode) => void;
setRightChildren: (component: ReactNode) => void;
}
export const SideBarContext = createContext<SideBarContextProps>({
childrenComponent: null,
setChildrenComponent: () => {},
setRightChildren: () => {},
});
export const SideBarProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [childrenComponent, setChildrenComponent] = useState<ReactNode>(null);
const [rightChildren, setRightChildren] = useState<ReactNode>(null);
return (
<SideBarContext.Provider value={{ childrenComponent, setChildrenComponent }}>
<SideBarContext.Provider value={{ childrenComponent: rightChildren, setRightChildren }}>
{children}
</SideBarContext.Provider>
);