From e44cc99e109850a1157f2b4816d63a1b089d2f80 Mon Sep 17 00:00:00 2001
From: Matthias Feyll <matthias.feyll@stud.h-da.de>
Date: Thu, 16 Jan 2025 01:00:04 +0100
Subject: [PATCH] (ui): implement UpdateIndicator

---
 .../devices/reducer/device.reducer.ts         | 15 ++++++
 .../devices/routines/device.routine.ts        | 12 ++++-
 .../components/devices/view/device.view.tsx   | 14 +++--
 .../devices/view_model/device.viewmodel.ts    |  2 +-
 .../src/i18n/locales/en/translations.json     |  3 ++
 .../update-indicator.layout.tsx               | 52 +++++++++++++++++++
 .../update-indicator.viewmodel.tsx            | 39 ++++++++++++++
 .../src/shared/reducer/routine.reducer.ts     | 10 +++-
 react-ui/src/shared/style/colors.scss         |  2 +-
 react-ui/src/shared/types/thunk.type.ts       |  3 +-
 10 files changed, 142 insertions(+), 10 deletions(-)
 create mode 100644 react-ui/src/shared/layouts/grid.layout/update-inidicator.layout/update-indicator.layout.tsx
 create mode 100644 react-ui/src/shared/layouts/grid.layout/update-inidicator.layout/update-indicator.viewmodel.tsx

diff --git a/react-ui/src/components/devices/reducer/device.reducer.ts b/react-ui/src/components/devices/reducer/device.reducer.ts
index cea12fbc9..f211fe024 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 ef92b1c8e..058f65f9f 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 f705aa4eb..6bd702bf7 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 9a0fbe17a..1cce2d59a 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 fb3ca729c..53444b9e0 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 000000000..d71bb6cce
--- /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 000000000..bb91b0b17
--- /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 5e9c3401a..95d5f06e7 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 29c971f86..4469a4a5b 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 9143871f0..ff037d796 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
 }
-- 
GitLab