diff --git a/react-ui/src/components/devices/reducer/device.reducer.ts b/react-ui/src/components/devices/reducer/device.reducer.ts index cea12fbc9c972d2e94469357c566c0ba1f321b51..f211fe0249a5c423d3e90012fdc8cab7284ec6cc 100755 --- a/react-ui/src/components/devices/reducer/device.reducer.ts +++ b/react-ui/src/components/devices/reducer/device.reducer.ts @@ -5,6 +5,8 @@ import { } from '@api/api' import { DeviceViewTabValues } from '@component/devices/view/device.view.tabs' import { createSlice, PayloadAction } from '@reduxjs/toolkit' +import { refreshUpdateTimer } from '@shared/reducer/routine.reducer' +import { Category, CategoryType } from '@shared/types/category.type' import { REHYDRATE } from 'redux-persist' import { RootState } from 'src/stores' import '../routines/index' @@ -129,6 +131,19 @@ startListening({ }, }) +startListening({ + predicate: (action) => setSelectedMne.match(action), + effect: async (action, listenerApi) => { + listenerApi.dispatch(refreshUpdateTimer(Category.TAB as CategoryType)) + }, +}) + +startListening({ + predicate: (action) => setDevices.match(action), + effect: async (action, listenerApi) => { + listenerApi.dispatch(refreshUpdateTimer(Category.DEVICE as CategoryType)) + }, +}) /** * On startup reset the selected device diff --git a/react-ui/src/components/devices/routines/device.routine.ts b/react-ui/src/components/devices/routines/device.routine.ts index ef92b1c8e9141fa922040487149282ab0a861a3a..058f65f9fb7416d1efb55cf7a00e70571b55fb30 100755 --- a/react-ui/src/components/devices/routines/device.routine.ts +++ b/react-ui/src/components/devices/routines/device.routine.ts @@ -1,21 +1,29 @@ import { NetworkElementServiceGetAllFlattenedApiArg, api } from '@api/api' import { setDevices } from '@component/devices/reducer/device.reducer' import { createAsyncThunk } from '@reduxjs/toolkit' +import { addRoutine } from '@shared/reducer/routine.reducer' import { setUser } from '@shared/reducer/user.reducer' +import { Category, CategoryType } from '@shared/types/category.type' import { RootState } from 'src/stores' import { startListening } from '../../../stores/middleware/listener.middleware' export const FETCH_DEVICE_ACTION = 'subscription/device/fetchDevices' // continously fetch devices -const FETCH_DEVICES_INTERVAL = 15000 // in ms startListening({ actionCreator: setUser, effect: async (_, listenerApi) => { - listenerApi.dispatch(fetchDevicesThunk()) + listenerApi.dispatch( + addRoutine({ + thunk: fetchDevicesThunk, + category: Category.DEVICE as CategoryType, + payload: {}, + }) + ) }, }) +const FETCH_DEVICES_INTERVAL = 15000 // in ms export const fetchDevicesThunk = createAsyncThunk(FETCH_DEVICE_ACTION, (_, thunkApi) => { const { user } = thunkApi.getState() as RootState diff --git a/react-ui/src/components/devices/view/device.view.tsx b/react-ui/src/components/devices/view/device.view.tsx index f705aa4eb90c9749c5e4313b3a1547fa15ec9b86..6bd702bf7d895bf58e679c380edb7e5ebb384a8e 100755 --- a/react-ui/src/components/devices/view/device.view.tsx +++ b/react-ui/src/components/devices/view/device.view.tsx @@ -1,13 +1,15 @@ import { faGripVertical } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { GridLayout } from '@layout/grid.layout/grid.layout'; +import UpdateIndicator from '@layout/grid.layout/update-inidicator.layout/update-indicator.layout'; +import { Category, CategoryType } from '@shared/types/category.type'; import { useRef } from 'react'; import { Button, Col, Container, Form, Nav, NavLink, Row } from 'react-bootstrap'; import { useTranslation } from 'react-i18next'; import { useDeviceViewModel } from '../view_model/device.viewmodel'; import './device.scss'; import { DeviceViewTable } from './device.view.table'; -import { DeviceViewTabs, DeviceViewTabValues } from './device.view.tabs'; +import { DeviceViewTabValues, DeviceViewTabs } from './device.view.tabs'; const DeviceView = () => { const { t } = useTranslation('common'); @@ -20,13 +22,16 @@ const DeviceView = () => { <> <div key="device-list"> <Container className='c-box hoverable h-100'> + <UpdateIndicator + category={Category.DEVICE as CategoryType} + updateInterval={15000} + /> <FontAwesomeIcon icon={faGripVertical} className="drag-handle" /> <Row> <Col sm={12} className='mt-4'> <h3 className='text-black-50'>{t('device.title')}</h3> </Col> </Row> - <Row className='align-items-center'> <Col xs={12} sm={6}> <Form.Group controlId='device.search' className='p-0 mx-1 pt-2'> @@ -48,6 +53,10 @@ const DeviceView = () => { <div key="device-details"> <Container className='c-box hoverable h-100'> + <UpdateIndicator + category={Category.TAB as CategoryType} + updateInterval={5000} + /> <FontAwesomeIcon icon={faGripVertical} className="drag-handle" /> <Row> <Col xs={12} className='mt-4'> @@ -67,7 +76,6 @@ const DeviceView = () => { </Nav> </Col> </Row> - <Row className='align-items-start'> <Col xs={12}> {DeviceViewTabs(activeTab)} diff --git a/react-ui/src/components/devices/view_model/device.viewmodel.ts b/react-ui/src/components/devices/view_model/device.viewmodel.ts index 9a0fbe17a0b8f3bf0aa5c00e8ef10a9d56b4a08d..1cce2d59a2e984721d4730b9e98c310191a70937 100755 --- a/react-ui/src/components/devices/view_model/device.viewmodel.ts +++ b/react-ui/src/components/devices/view_model/device.viewmodel.ts @@ -7,7 +7,7 @@ export const useDeviceViewModel = () => { const { activeTab } = useAppSelector((state) => state.device) const dispatch = useAppDispatch() - useEffect(() => {}, []) + useEffect(() => { }, []) const handleActiveTabLink = (tabLink: DeviceViewTabValues) => { return activeTab === tabLink ? 'active' : '' diff --git a/react-ui/src/i18n/locales/en/translations.json b/react-ui/src/i18n/locales/en/translations.json index fb3ca729c6ecc380475dd3c5c567befcc5508668..53444b9e0d6745f364a559342164dca89d7abc0f 100755 --- a/react-ui/src/i18n/locales/en/translations.json +++ b/react-ui/src/i18n/locales/en/translations.json @@ -53,6 +53,9 @@ "yang_model": { "title": "YANG Model" } + }, + "box": { + "lastUpdate": "Last updated {{seconds}} seconds ago" } }, "protected": { diff --git a/react-ui/src/shared/layouts/grid.layout/update-inidicator.layout/update-indicator.layout.tsx b/react-ui/src/shared/layouts/grid.layout/update-inidicator.layout/update-indicator.layout.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d71bb6cce17eefb53d7ddcdcda9db99545bf6b86 --- /dev/null +++ b/react-ui/src/shared/layouts/grid.layout/update-inidicator.layout/update-indicator.layout.tsx @@ -0,0 +1,52 @@ +import { faCircle } from '@fortawesome/free-solid-svg-icons' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import React, { useState } from 'react' +import { Overlay, Tooltip } from 'react-bootstrap' +import { useTranslation } from 'react-i18next' +import { CategoryType } from '../types' +import { useUpdateIndicatorViewModel } from './update-indicator.viewmodel' + +interface UpdateIndicatorProps { + category: CategoryType + updateInterval: number +} + +const UpdateIndicator: React.FC<UpdateIndicatorProps> = ({ category, updateInterval }) => { + const [showTooltip, setShowTooltip] = useState(false) + const { t } = useTranslation('common') + const target = React.useRef(null) + const { secondsSinceUpdate, getStatusColor } = useUpdateIndicatorViewModel(category) + + return ( + <div + className="position-absolute" + style={{ + top: 0, + right: '40px', + padding: '10px', + zIndex: 10 + }} + > + <div + ref={target} + onMouseEnter={() => setShowTooltip(true)} + onMouseLeave={() => setShowTooltip(false)} + style={{ cursor: 'pointer' }} + > + <FontAwesomeIcon + icon={faCircle} + className={getStatusColor(updateInterval)} + size="sm" + /> + </div> + + <Overlay target={target.current} show={showTooltip} placement="bottom"> + <Tooltip id="update-tooltip"> + {t('device.box.lastUpdate', { seconds: secondsSinceUpdate })} + </Tooltip> + </Overlay> + </div> + ) +} + +export default UpdateIndicator \ No newline at end of file diff --git a/react-ui/src/shared/layouts/grid.layout/update-inidicator.layout/update-indicator.viewmodel.tsx b/react-ui/src/shared/layouts/grid.layout/update-inidicator.layout/update-indicator.viewmodel.tsx new file mode 100644 index 0000000000000000000000000000000000000000..bb91b0b17e1c4e815d92d14420f0022ddaf819e2 --- /dev/null +++ b/react-ui/src/shared/layouts/grid.layout/update-inidicator.layout/update-indicator.viewmodel.tsx @@ -0,0 +1,39 @@ +import { useAppSelector } from "@hooks" +import { CategoryType } from "@shared/types/category.type" +import { useEffect, useState } from 'react' + +export const useUpdateIndicatorViewModel = (category: CategoryType) => { + const { thunks } = useAppSelector((state) => state.routine) + const [secondsSinceUpdate, setSecondsSinceUpdate] = useState<number>(-1) + + useEffect(() => { + const updateTimer = () => { + const lastupdate = thunks[category]?.lastupdate + if (lastupdate) { + setSecondsSinceUpdate(Math.round((Date.now() - lastupdate) / 1000)) + } else { + setSecondsSinceUpdate(-1) + } + } + + // Initial update + updateTimer() + + // Set up interval for updates + const intervalId = setInterval(updateTimer, 1000) + + return () => clearInterval(intervalId) + }, [category, thunks]) + + const getStatusColor = (updateInterval: number) => { + const updateIntervalSeconds = updateInterval / 1000 + if (secondsSinceUpdate > updateIntervalSeconds * 0.9) return "text-primary" + if (secondsSinceUpdate > updateIntervalSeconds * 1.3) return "text-danger" + return "text-bg-primary" + } + + return { + secondsSinceUpdate, + getStatusColor + } +} \ No newline at end of file diff --git a/react-ui/src/shared/reducer/routine.reducer.ts b/react-ui/src/shared/reducer/routine.reducer.ts index 5e9c3401a441b2e7718564a9dfc00282ee95783c..95d5f06e7d708965ec65b2960f6f56823a976f6b 100755 --- a/react-ui/src/shared/reducer/routine.reducer.ts +++ b/react-ui/src/shared/reducer/routine.reducer.ts @@ -16,6 +16,7 @@ const initialState: ReducerState = { TABLE: null, TAB: null }, + } @@ -27,19 +28,24 @@ const RoutineSlice = createSlice({ const thunk: ThunkPersist = { category: payload.category, payload: payload.payload, - thunkId: payload.thunk.id + thunkId: payload.thunk.id, + lastupdate: Date.now() } state.thunks[payload.category] = thunk }, + refreshUpdateTimer: (state: any, { payload }: PayloadAction<CategoryType>) => { + state.thunks[payload].lastupdate = Date.now() + }, + removeAll: (state) => { state.thunks = initialState.thunks }, }, }) -export const { addRoutine } = RoutineSlice.actions +export const { addRoutine, refreshUpdateTimer } = RoutineSlice.actions // on logout remove all routine startListening({ diff --git a/react-ui/src/shared/style/colors.scss b/react-ui/src/shared/style/colors.scss index 29c971f863a63613015ad8165fe0ba24278c3a16..4469a4a5b9fc0c32733bcb0afe112f568a5e5492 100755 --- a/react-ui/src/shared/style/colors.scss +++ b/react-ui/src/shared/style/colors.scss @@ -2,7 +2,7 @@ $theme-colors: ( "primary": #b350e0, "primary::hover": #ddaff3af, "bg-primary": #ededed, - "danger": #ffdcdc, + "danger": #ff0000, "warning": #dbd116, "dark": #595959, "black": #000000 diff --git a/react-ui/src/shared/types/thunk.type.ts b/react-ui/src/shared/types/thunk.type.ts index 9143871f0ac8bce1ef58a92ecba24d993a9fbe12..ff037d79655b7596295b387c61de4755dcc2eff4 100644 --- a/react-ui/src/shared/types/thunk.type.ts +++ b/react-ui/src/shared/types/thunk.type.ts @@ -19,5 +19,6 @@ export interface ThunkDTO { export interface ThunkPersist { thunkId: number, payload: Object - category: CategoryType + category: CategoryType, + lastupdate: number }