From f935de1eeb4c06a6637f43b2f0f832434996f6b5 Mon Sep 17 00:00:00 2001 From: NlightN22 Date: Tue, 1 Oct 2024 02:44:04 +0700 Subject: [PATCH] add events page --- src/AppBody.tsx | 10 +- src/index.tsx | 15 ++- src/locales/en.ts | 7 ++ src/locales/ru.ts | 7 ++ src/pages/EventsPage.tsx | 62 +++++++++++ src/pages/MainPage.tsx | 6 +- src/pages/RecordingsPage.tsx | 8 +- src/router/routes.path.ts | 1 + src/router/routes.tsx | 5 + src/services/frigate.proxy/frigate.api.ts | 6 +- src/services/keycloak-config.ts | 16 +-- src/shared/components/RightSideBar.tsx | 2 - src/shared/components/UserMenu.tsx | 4 +- .../components/accordion/EventsAccordion.tsx | 62 ++++++++--- .../components/filters/CameraSelect.tsx | 65 +++++++++++ .../components/filters/CameraSelectFilter.tsx | 42 +++----- ...geSelectFilter.tsx => DateRangeSelect.tsx} | 23 ++-- src/shared/components/filters/TimePicker.tsx | 81 ++++++++++++++ src/shared/stores/events.store.ts | 102 ++++++++++++++++++ src/shared/stores/recordings.store.ts | 9 -- src/shared/stores/root.store.ts | 3 + src/shared/utils/dateUtil.ts | 35 ++++++ src/widgets/CameraTagsList.tsx | 8 +- src/widgets/EventsBody.tsx | 52 +++++++++ src/widgets/SelectedDayList.tsx | 31 +++++- src/widgets/header/HeaderAction.tsx | 3 +- src/widgets/sidebars/EventsRightFilters.tsx | 93 ++++++++++++++++ .../sidebars/RecordingsFiltersRightSide.tsx | 15 ++- src/widgets/sidebars/SideBarContext.tsx | 8 +- 29 files changed, 669 insertions(+), 112 deletions(-) create mode 100644 src/pages/EventsPage.tsx create mode 100644 src/shared/components/filters/CameraSelect.tsx rename src/shared/components/filters/{DateRangeSelectFilter.tsx => DateRangeSelect.tsx} (70%) create mode 100644 src/shared/components/filters/TimePicker.tsx create mode 100644 src/shared/stores/events.store.ts create mode 100644 src/widgets/EventsBody.tsx create mode 100644 src/widgets/sidebars/EventsRightFilters.tsx diff --git a/src/AppBody.tsx b/src/AppBody.tsx index 0230ad1..f527949 100644 --- a/src/AppBody.tsx +++ b/src/AppBody.tsx @@ -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)) diff --git a/src/index.tsx b/src/index.tsx index c4e21ce..38ff78d 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -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) @@ -25,11 +24,11 @@ const eventLogger = (event: string, error?: any) => { const tokenLogger = (tokens: any) => { console.log('onKeycloakTokens', tokens); -}; +} root.render( } onEvent={eventLogger} onTokens={tokenLogger} diff --git a/src/locales/en.ts b/src/locales/en.ts index 9ca9779..f0e8a16 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -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', diff --git a/src/locales/ru.ts b/src/locales/ru.ts index b9ee6e7..2a4222b 100644 --- a/src/locales/ru.ts +++ b/src/locales/ru.ts @@ -11,6 +11,11 @@ const ru = { width: 'Ширина', height: 'Высота', points: 'Точки', + }, + eventsPage: { + selectStartTime: 'Выбери время начала:', + selectEndTime: 'Выбери время окончания:', + maxEventsFetches: 'Ошибка: Невозможно получить события после {{maxRetries}} попыток. Пожалуйста попробуйте позже или установите меньший период.', }, frigateConfigPage: { copyConfig: 'Копировать Конфиг.', @@ -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: 'Выбери период', diff --git a/src/pages/EventsPage.tsx b/src/pages/EventsPage.tsx new file mode 100644 index 0000000..aaa7e41 --- /dev/null +++ b/src/pages/EventsPage.tsx @@ -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() + return () => setRightChildren(null) + }, []) + + const { eventsStore } = useContext(Context) + const { hostId, cameraId, period, startTime, endTime } = eventsStore.filters + + if (hostId && cameraId && period && period[0] && period[1]) { + return ( + + ) + } + + return ( + + {!hostId ? + {t('pleaseSelectHost')} + : <> + } + {hostId && !cameraId ? + {t('pleaseSelectCamera')} + : <> + } + {hostId && cameraId && !eventsStore.isPeriodSet() ? + {t('pleaseSelectDate')} + : <> + } + + ); +}; + + +export default observer(EventsPage); \ No newline at end of file diff --git a/src/pages/MainPage.tsx b/src/pages/MainPage.tsx index 181e5c6..f47ac38 100644 --- a/src/pages/MainPage.tsx +++ b/src/pages/MainPage.tsx @@ -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() const [filteredCameras, setFilteredCameras] = useState() @@ -35,8 +35,8 @@ const MainPage = () => { }) useEffect(() => { - setChildrenComponent(); - return () => setChildrenComponent(null); + setRightChildren(); + return () => setRightChildren(null); }, []); useEffect(() => { diff --git a/src/pages/RecordingsPage.tsx b/src/pages/RecordingsPage.tsx index 91aa214..150de72 100644 --- a/src/pages/RecordingsPage.tsx +++ b/src/pages/RecordingsPage.tsx @@ -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('') const [period, setPeriod] = useState<[Date | null, Date | null]>([null, null]) - const { setChildrenComponent } = useContext(SideBarContext) + const { setRightChildren } = useContext(SideBarContext) useEffect(() => { - setChildrenComponent(); - - return () => setChildrenComponent(null); + setRightChildren(); + return () => setRightChildren(null); }, []); diff --git a/src/router/routes.path.ts b/src/router/routes.path.ts index b82ee05..65dcebd 100644 --- a/src/router/routes.path.ts +++ b/src/router/routes.path.ts @@ -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', diff --git a/src/router/routes.tsx b/src/router/routes.tsx index 7467220..9a7342b 100644 --- a/src/router/routes.tsx +++ b/src/router/routes.tsx @@ -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: , }, + { + path: routesPath.EVENTS_PATH, + component: , + }, { path: routesPath.HOST_CONFIG_PATH, component: , diff --git a/src/services/frigate.proxy/frigate.api.ts b/src/services/frigate.proxy/frigate.api.ts index 892c3ec..3dbbbec 100644 --- a/src/services/frigate.proxy/frigate.api.ts +++ b/src/services/frigate.proxy/frigate.api.ts @@ -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('apiv1/frigate-hosts').then(res => res.data), getHost: (id: string) => instanceApi.get(`apiv1/frigate-hosts/${id}`).then(res => res.data), + getCameraById: (cameraId: string) => instanceApi.get(`apiv1/cameras/${cameraId}`).then(res => res.data), getCamerasByHostId: (hostId: string) => instanceApi.get(`apiv1/cameras/host/${hostId}`).then(res => res.data), getCamerasWHost: () => instanceApi.get(`apiv1/cameras`).then(res => res.data), getCameraWHost: (id: string) => instanceApi.get(`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', diff --git a/src/services/keycloak-config.ts b/src/services/keycloak-config.ts index 4f4c47a..fc072d0 100644 --- a/src/services/keycloak-config.ts +++ b/src/services/keycloak-config.ts @@ -2,11 +2,11 @@ import Keycloak from "keycloak-js"; import { oidpSettings } from "../shared/env.const"; const keycloakConfig = { - url: oidpSettings.server, - realm: oidpSettings.realm, - clientId: oidpSettings.clientId, - }; - - const keycloak = new Keycloak(keycloakConfig); - - export default keycloak; \ No newline at end of file + url: oidpSettings.server, + realm: oidpSettings.realm, + clientId: oidpSettings.clientId, +}; + +const keycloakInstance = new Keycloak(keycloakConfig) + +export default keycloakInstance; \ No newline at end of file diff --git a/src/shared/components/RightSideBar.tsx b/src/shared/components/RightSideBar.tsx index a856938..1701178 100644 --- a/src/shared/components/RightSideBar.tsx +++ b/src/shared/components/RightSideBar.tsx @@ -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 }) diff --git a/src/shared/components/UserMenu.tsx b/src/shared/components/UserMenu.tsx index 6115c39..955734a 100644 --- a/src/shared/components/UserMenu.tsx +++ b/src/shared/components/UserMenu.tsx @@ -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' }, diff --git a/src/shared/components/accordion/EventsAccordion.tsx b/src/shared/components/accordion/EventsAccordion.tsx index a099a44..ee101ac 100644 --- a/src/shared/components/accordion/EventsAccordion.tsx +++ b/src/shared/components/accordion/EventsAccordion.tsx @@ -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() + 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
+ if (isError && retryCount >= MAX_RETRY_COUNT) { + return ( +
+ {t('maxRetries', { maxRetries: MAX_RETRY_COUNT })} +
+ ); + } if (isError) return - if (!data || data.length < 1) return
Not have events at that period
+ if (!data || data.length < 1) return
{t('notHaveEventsAtThatPeriod')}
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 ( {data.map(event => ( - ))} diff --git a/src/shared/components/filters/CameraSelect.tsx b/src/shared/components/filters/CameraSelect.tsx new file mode 100644 index 0000000..fe8e434 --- /dev/null +++ b/src/shared/components/filters/CameraSelect.tsx @@ -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 + placeholder?: string + onChange?: (value: string) => void + onSuccess?: () => void +} + +const CameraSelect: FC = ({ + 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 + if (isError) return + 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 ( + + ); +}; + +export default CameraSelect; \ No newline at end of file diff --git a/src/shared/components/filters/CameraSelectFilter.tsx b/src/shared/components/filters/CameraSelectFilter.tsx index 127665c..6376587 100644 --- a/src/shared/components/filters/CameraSelectFilter.tsx +++ b/src/shared/components/filters/CameraSelectFilter.tsx @@ -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 - if (isError) return - 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 ( - ); }; diff --git a/src/shared/components/filters/DateRangeSelectFilter.tsx b/src/shared/components/filters/DateRangeSelect.tsx similarity index 70% rename from src/shared/components/filters/DateRangeSelectFilter.tsx rename to src/shared/components/filters/DateRangeSelect.tsx index 20268e0..29baa23 100644 --- a/src/shared/components/filters/DateRangeSelectFilter.tsx +++ b/src/shared/components/filters/DateRangeSelect.tsx @@ -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 = ({ + 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 ( { const day = date.getDate(); @@ -53,4 +52,4 @@ const DateRangeSelectFilter = ({ -export default observer(DateRangeSelectFilter); \ No newline at end of file +export default DateRangeSelectFilter; \ No newline at end of file diff --git a/src/shared/components/filters/TimePicker.tsx b/src/shared/components/filters/TimePicker.tsx new file mode 100644 index 0000000..3dca2a9 --- /dev/null +++ b/src/shared/components/filters/TimePicker.tsx @@ -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 = ({ + defaultValue, + label, + onChange +}) => { + + const ref = useRef(null) + const [value, setValue] = useState(defaultValue) + const [debounced] = useDebouncedValue(value, 1600) + + const [pickerOpened, setPickerOpened] = useState(false) + + const handleChange = (e: React.ChangeEvent) => { + 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 ( + + + {label} + + + { + !value ? null : + setValue('')} + ml='-1.7rem' + > + + + } + ref.current?.showPicker()}> + + + + } + maw={400} + mx="auto" + onChange={handleChange} + onClick={handleClick} + /> + + ); +}; + +export default TimePicker; \ No newline at end of file diff --git a/src/shared/stores/events.store.ts b/src/shared/stores/events.store.ts new file mode 100644 index 0000000..8b8be36 --- /dev/null +++ b/src/shared/stores/events.store.ts @@ -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 + } + +} \ No newline at end of file diff --git a/src/shared/stores/recordings.store.ts b/src/shared/stores/recordings.store.ts index 9544fd0..59bf68f 100644 --- a/src/shared/stores/recordings.store.ts +++ b/src/shared/stores/recordings.store.ts @@ -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 - // } } \ No newline at end of file diff --git a/src/shared/stores/root.store.ts b/src/shared/stores/root.store.ts index 89ddb1d..0b4d43f 100644 --- a/src/shared/stores/root.store.ts +++ b/src/shared/stores/root.store.ts @@ -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() } } diff --git a/src/shared/utils/dateUtil.ts b/src/shared/utils/dateUtil.ts index cf8de7a..85cf932 100644 --- a/src/shared/utils/dateUtil.ts +++ b/src/shared/utils/dateUtil.ts @@ -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. diff --git a/src/widgets/CameraTagsList.tsx b/src/widgets/CameraTagsList.tsx index 755abc3..65bcbf0 100644 --- a/src/widgets/CameraTagsList.tsx +++ b/src/widgets/CameraTagsList.tsx @@ -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 { diff --git a/src/widgets/EventsBody.tsx b/src/widgets/EventsBody.tsx new file mode 100644 index 0000000..8f15172 --- /dev/null +++ b/src/widgets/EventsBody.tsx @@ -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 = ({ + 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 + if (isError) return + if (!data) return null + + return ( + + ) +}; + +export default observer(EventsBody); \ No newline at end of file diff --git a/src/widgets/SelectedDayList.tsx b/src/widgets/SelectedDayList.tsx index 2da83ca..9433217 100644 --- a/src/widgets/SelectedDayList.tsx +++ b/src/widgets/SelectedDayList.tsx @@ -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 + + if (isError && retryCount >= MAX_RETRY_COUNT) { + return ( +
+ {t('maxRetries', { maxRetries: MAX_RETRY_COUNT })} +
+ ); + } + if (isError) return if (!camera || !host) return
Please select host or camera
if (!data) return Not have response from server @@ -56,7 +79,7 @@ const SelectedDayList = ({ camera={camera} recordSummary={recordingsDay} />
- ); -}; + ) +} -export default observer(SelectedDayList); \ No newline at end of file +export default observer(SelectedDayList) \ No newline at end of file diff --git a/src/widgets/header/HeaderAction.tsx b/src/widgets/header/HeaderAction.tsx index 601b662..c201073 100644 --- a/src/widgets/header/HeaderAction.tsx +++ b/src/widgets/header/HeaderAction.tsx @@ -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) diff --git a/src/widgets/sidebars/EventsRightFilters.tsx b/src/widgets/sidebars/EventsRightFilters.tsx new file mode 100644 index 0000000..a8fcea3 --- /dev/null +++ b/src/widgets/sidebars/EventsRightFilters.tsx @@ -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 ( + <> + + {!eventsStore.filters.hostId ? null : + + } + {!eventsStore.filters.cameraId ? null : + + } + {!eventsStore.isPeriodSet() ? null : + <> + + + + } + + ) +} + +export default observer(EventsRightFilters); \ No newline at end of file diff --git a/src/widgets/sidebars/RecordingsFiltersRightSide.tsx b/src/widgets/sidebars/RecordingsFiltersRightSide.tsx index 16ce071..fa512ba 100644 --- a/src/widgets/sidebars/RecordingsFiltersRightSide.tsx +++ b/src/widgets/sidebars/RecordingsFiltersRightSide.tsx @@ -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,16 +10,23 @@ const RecordingsFiltersRightSide = () => { const { recordingsStore: recStore } = useContext(Context) if (!isProduction) console.log('RecordingsFiltersRightSide rendered') + + const handleDatePick = (value: [Date | null, Date | null]) => { + recStore.selectedRange = value + } return ( <> {recStore.filteredHost ? - + : <> } {recStore.filteredCamera ? - + : <> } diff --git a/src/widgets/sidebars/SideBarContext.tsx b/src/widgets/sidebars/SideBarContext.tsx index b0c0261..a615910 100644 --- a/src/widgets/sidebars/SideBarContext.tsx +++ b/src/widgets/sidebars/SideBarContext.tsx @@ -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({ childrenComponent: null, - setChildrenComponent: () => {}, + setRightChildren: () => {}, }); export const SideBarProvider: React.FC<{ children: ReactNode }> = ({ children }) => { - const [childrenComponent, setChildrenComponent] = useState(null); + const [rightChildren, setRightChildren] = useState(null); return ( - + {children} );