diff --git a/react-ui/src/components/view/device/deivce.view.tabs.tsx b/react-ui/src/components/view/device/deivce.view.tabs.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a49c7616baf74fb12991ede6ee410ff533a54fa6 --- /dev/null +++ b/react-ui/src/components/view/device/deivce.view.tabs.tsx @@ -0,0 +1,28 @@ + +export enum DeviceViewTabValues { + METADATA = 'metadata', + YANGMODEL = 'yang_model' +} + +export const DeviceViewTabs = (activeTab: DeviceViewTabValues) => { + + const metadataTab = () => { + return ( + <div>test</div> + ) + } + + const yangModelTab = () => { + return ( + <div>asdf</div> + ) + } + + + return ( + <> + {(activeTab === DeviceViewTabValues.METADATA) && metadataTab()} + {(activeTab === DeviceViewTabValues.YANGMODEL) && yangModelTab()} + </> + ); +} diff --git a/react-ui/src/components/view/device/device.scss b/react-ui/src/components/view/device/device.scss index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..826b7c293df80909a7944c943e079508e9f2a624 100644 --- a/react-ui/src/components/view/device/device.scss +++ b/react-ui/src/components/view/device/device.scss @@ -0,0 +1,13 @@ +@import '../../../style/colors.scss'; + +thead { + font-size: 0.9em; +} + +tr:hover > td { + background-color: lighten(map-get($theme-colors, primary), 30%) !important; +} + +tr:nth-child(2n+1) > td { + background-color: lighten(map-get($theme-colors, primary) , 38%) +} \ No newline at end of file diff --git a/react-ui/src/components/view/device/device.view.table.tsx b/react-ui/src/components/view/device/device.view.table.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e2e6c4db5707086618c23bee8f985cc9245d3411 --- /dev/null +++ b/react-ui/src/components/view/device/device.view.table.tsx @@ -0,0 +1,58 @@ +import { useAppSelector } from "@hooks"; +import { useDeviceTableViewModel } from "@viewmodel/device.table.viewmodel"; +import { MutableRefObject, useCallback } from "react"; +import { OverlayTrigger, Table, Tooltip } from "react-bootstrap"; +import { useTranslation } from "react-i18next"; + +export const DeviceViewTable = (searchRef: MutableRefObject<HTMLInputElement>) => { + const { devices, pnds } = useAppSelector(state => state.device); + const { t } = useTranslation('common'); + const { searchTerm } = useDeviceTableViewModel(searchRef); + + const cropUUID = (uuid: string): string => { + return uuid.substring(0, 3) + "..." + uuid.substring(uuid.length - 3, uuid.length); + } + + const getDeviceTable = useCallback(() => { + return devices.filter((device) => { + if (!searchRef.current?.value) { + return true; + } + + const searchInput = searchRef.current!.value; + const user = pnds.find(pnd => pnd.id === device.pid); + + return device.id.includes(searchInput) || device.name.includes(searchInput) || user?.name.includes(searchInput); + }).map((device, index) => { + const user = pnds.find(pnd => pnd.id === device.pid); + + return ( + <tr key={index}> + <td>{device.name}</td> + <OverlayTrigger overlay={<Tooltip id={device.id}>{device.id}</Tooltip>}> + <td>{cropUUID(device.id)}</td> + </OverlayTrigger> + <td>{user?.name || ''}</td> + <td></td> + </tr> + ) + }) + }, [searchTerm, devices, pnds]); + + + return ( + <Table striped responsive> + <thead> + <tr> + <th>{t('device.table.header.name')}</th> + <th>{t('device.table.header.uuid')}</th> + <th>{t('device.table.header.user')}</th> + <th>{t('device.table.header.last_updated')}</th> + </tr> + </thead> + <tbody> + {getDeviceTable()} + </tbody> + </Table> + ) +} \ No newline at end of file diff --git a/react-ui/src/components/view/device/device.view.tsx b/react-ui/src/components/view/device/device.view.tsx index 8a664c4d83ec9044710745022c625a5c8ca677d6..64a5cb93f9e3bcfa0855101d5323e3238cbc46ed 100644 --- a/react-ui/src/components/view/device/device.view.tsx +++ b/react-ui/src/components/view/device/device.view.tsx @@ -1,45 +1,52 @@ -import { useAppSelector } from '@hooks'; -import { Col, Container, Row, Table } from 'react-bootstrap'; -import './device.scss'; import { useDeviceViewModel } from '@viewmodel/device.viewmodel'; +import { useRef } from 'react'; +import { Button, Col, Container, FloatingLabel, Form, Nav, NavLink, Row } from 'react-bootstrap'; +import { useTranslation } from 'react-i18next'; +import { DeviceViewTabs, DeviceViewTabValues } from './deivce.view.tabs'; +import './device.scss'; +import { DeviceViewTable } from './device.view.table'; +import { useParams } from 'react-router-dom'; function DeviceView() { - const { devices } = useAppSelector(state => state.device); - useDeviceViewModel(); - - - const getDeviceTable = () => { - return devices.map((device, index) => ( - <tr key={index}> - <td>{device.name}</td> - <td>{device.id}</td> - <td>{device.pid}</td> - </tr> - )) - } + const { t } = useTranslation('common'); + const searchRef = useRef<HTMLInputElement>(null); + const { activeTab, setActiveTab, handleActiveTabLink } = useDeviceViewModel(); return ( <div className='m-4 pt-4'> - <Container className="bg-white rounded c-box"> - <Row > - <Col><h3>Device list</h3></Col> + <Container className="bg-white rounded c-box" fluid> + <Row className='my-2'> + <Col sm={7}><h3 className='text-black-50'>{t('device.title')}</h3></Col> + <Col sm={5}> + <Nav className='justify-content-around'> + <NavLink className={handleActiveTabLink(DeviceViewTabValues.METADATA)} onClick={() => setActiveTab(DeviceViewTabValues.METADATA)}>{t('device.tabs.metadata.title')}</NavLink> + <NavLink className={handleActiveTabLink(DeviceViewTabValues.YANGMODEL)} onClick={() => setActiveTab(DeviceViewTabValues.YANGMODEL)}>{t('device.tabs.yang_model.title')}</NavLink> + </Nav> + </Col> + + </Row> + + <Row> + <Col sm={3}> + <FloatingLabel + controlId="device.search" + label={t('device.search.placeholder')} + className='p-0 mx-2' + > + <Form.Control type="text" placeholder="name@example.com" ref={searchRef} /> + </FloatingLabel> + </Col> + <Col sm={{ span: 2, offset: 2 }}> + <Button variant='primary' className='w-100 my-auto'>{t('device.add_device_button')}</Button> + </Col> </Row> <Row className='mt-2'> - <Col> - <Table striped bordered hover className='table-primary'> - <thead> - <tr> - <th>Name</th> - <th>UUID</th> - <th>User</th> - <th>Last updated</th> - </tr> - </thead> - <tbody> - {getDeviceTable()} - </tbody> - </Table> + <Col sm={7}> + {DeviceViewTable(searchRef)} + </Col> + <Col sm={5} className='border-left border-primary'> + {DeviceViewTabs(activeTab)} </Col> </Row> </Container> diff --git a/react-ui/src/components/view_model/device.table.viewmodel.ts b/react-ui/src/components/view_model/device.table.viewmodel.ts new file mode 100644 index 0000000000000000000000000000000000000000..224904699c3a26ccc3e6f1498035c27efd41ca72 --- /dev/null +++ b/react-ui/src/components/view_model/device.table.viewmodel.ts @@ -0,0 +1,29 @@ +import { useEffect, useState } from "react"; + +export const useDeviceTableViewModel = (searchRef) => { + const [searchTerm, setSearchTerm] = useState(''); + + useEffect(() => { + const handleSearchChange = () => { + if (searchRef.current) { + setSearchTerm(searchRef.current.value); + } + }; + + if (searchRef.current) { + searchRef.current.addEventListener('input', handleSearchChange); + } + + return () => { + if (searchRef.current) { + searchRef.current.removeEventListener('input', handleSearchChange); + } + }; + }, []); + + + + return { + searchTerm + } +} \ No newline at end of file diff --git a/react-ui/src/components/view_model/device.viewmodel.ts b/react-ui/src/components/view_model/device.viewmodel.ts index 7ceb788091a2fe5f14167e00536d9eadd87d69fe..b7b46cd6aa7b2e044b68c497cc24111d881b7514 100644 --- a/react-ui/src/components/view_model/device.viewmodel.ts +++ b/react-ui/src/components/view_model/device.viewmodel.ts @@ -1,48 +1,23 @@ -import { api, NetworkelementFlattenedManagedNetworkElement, NetworkelementGetAllFlattenedResponse, NetworkElementServiceGetAllFlattenedApiArg } from "@api/api"; -import { useAppSelector } from "@hooks"; -import { setDevices } from "@reducer/device.reducer"; -import { QueryActionCreatorResult } from "@reduxjs/toolkit/query"; -import { useEffect } from "react"; -import { useDispatch } from "react-redux"; -import { AppDispatch } from "src/stores"; - -const FETCH_DEVICES_INTERVAL = 15000; // in ms - +import { useAppDispatch, useAppSelector } from "@hooks"; +import { setActiveTab as setActiveTabState } from "@reducer/device.reducer"; +import { DeviceViewTabValues } from "@view/device/deivce.view.tabs"; export const useDeviceViewModel = () => { - const { user } = useAppSelector(state => state.user); - const [triggerFetchDevices] = api.endpoints.networkElementServiceGetAllFlattened.useLazyQuerySubscription({ - pollingInterval: FETCH_DEVICES_INTERVAL, - skipPollingIfUnfocused: true - }); - const dispatch = useDispatch<AppDispatch>(); - - - // TODO figure out how we get the proper response type here - let fetchDevicesSubscription: QueryActionCreatorResult<any> | undefined; - - - useEffect(() => { - fetchDevices(); - - return () => { - fetchDevicesSubscription?.unsubscribe(); - } - }, []) - - const fetchDevices = () => { - const payload: NetworkElementServiceGetAllFlattenedApiArg = { - pid: Object.keys(user?.roles)[0], - timestamp: new Date().getTime().toString(), - } + const {activeTab} = useAppSelector(state => state.device); + const dispatch = useAppDispatch(); + + const handleActiveTabLink = (tabLink: DeviceViewTabValues) => { + return activeTab === tabLink ? 'active' : ''; + } - fetchDevicesSubscription = triggerFetchDevices(payload); - fetchDevicesSubscription.then((response) => { - const { mne } = response.data as NetworkelementGetAllFlattenedResponse; - dispatch(setDevices(mne)); - }); + const setActiveTab = (tab: DeviceViewTabValues) => { + dispatch(setActiveTabState(tab)); } + return { + activeTab, + setActiveTab, + handleActiveTabLink } } \ No newline at end of file diff --git a/react-ui/src/i18n/locales/en/translations.json b/react-ui/src/i18n/locales/en/translations.json index d46896a8c4036b102f7fa843b7b8ec6f91cc9aa2..b58b299b170e536241dfa985c85ab4dd57cc3470 100644 --- a/react-ui/src/i18n/locales/en/translations.json +++ b/react-ui/src/i18n/locales/en/translations.json @@ -18,10 +18,33 @@ } } }, + "device": { + "title": "Device list", + "table": { + "header": { + "name": "Name", + "uuid": "UUID", + "user": "User", + "last_updated": "Last updated" + } + }, + "search": { + "placeholder": "Search" + }, + "add_device_button": "Add device", + "tabs": { + "metadata": { + "title": "Metadata" + }, + "yang_model": { + "title": "YANG Model" + } + } + }, "protected": { "link": { "device_list": "Device List", - "map": "Map", + "map": "Map", "configuration_mgmt": "Configuration Management", "settings": "Settings" } diff --git a/react-ui/src/routes.tsx b/react-ui/src/routes.tsx index 28a06cbfbfd99232aece84be5837b96df9c417da..b273246d9ca6d5067e2b38185036443a05a65676 100644 --- a/react-ui/src/routes.tsx +++ b/react-ui/src/routes.tsx @@ -2,14 +2,19 @@ import { BasicLayout } from "@layout/basic.layout" import { LoginLayout } from "@layout/login.layout" import { ProtectedLayout } from "@layout/protected.layout/protected.layout" import DeviceView from "@view/device/device.view" -import { createBrowserRouter, createRoutesFromElements, Route } from "react-router-dom" +import { createBrowserRouter, createRoutesFromElements, Navigate, Route } from "react-router-dom" + +export const DEVICE_URL = '/device/'; +export const LOGIN_URL = '/login'; + export const router = createBrowserRouter( createRoutesFromElements( <Route element={<BasicLayout />}> - <Route path="/login" element={<LoginLayout />} /> + <Route path={LOGIN_URL} element={<LoginLayout />} /> <Route element={<ProtectedLayout />}> - <Route path="/" element={<DeviceView />} /> + <Route path={DEVICE_URL} element={<DeviceView />} /> + <Route path="/" element={<Navigate to={DEVICE_URL} replace={true} />} /> </Route> </Route> ) diff --git a/react-ui/src/stores/reducer/device.reducer.ts b/react-ui/src/stores/reducer/device.reducer.ts index ec47ff1f852ecc1c8acfa3ea2236c6a68559f5e5..3252fd8c792d6a2929878ebd5e348976edbdcd82 100644 --- a/react-ui/src/stores/reducer/device.reducer.ts +++ b/react-ui/src/stores/reducer/device.reducer.ts @@ -1,40 +1,46 @@ -import { api, NetworkelementFlattenedManagedNetworkElement, NetworkElementServiceGetAllFlattenedApiArg } from '@api/api'; -import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { api, NetworkelementFlattenedManagedNetworkElement, NetworkElementServiceGetAllFlattenedApiArg, PndPrincipalNetworkDomain, PndServiceGetPndListApiArg } from '@api/api'; +import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit'; import { QueryActionCreatorResult } from '@reduxjs/toolkit/query'; import { RootState } from '..'; import { startListening } from '../middleware/listener.middleware'; import { setUser } from './user.reducer'; +import { DeviceViewTabValues } from '@view/device/deivce.view.tabs'; type Device = NetworkelementFlattenedManagedNetworkElement; export interface DeviceSliceState { devices: Device[], + pnds: PndPrincipalNetworkDomain[], + + activeTab: DeviceViewTabValues } const initialState: DeviceSliceState = { devices: [], + pnds: [], + activeTab: DeviceViewTabValues.METADATA } - - - const deviceSlice = createSlice({ name: 'device', initialState, reducers: { setDevices: (state, action: PayloadAction<Device[]>) => { state.devices = action.payload }, + setPnds: (state, action: PayloadAction<PndPrincipalNetworkDomain[]>) => { state.pnds = action.payload }, + setActiveTab: (state, action: PayloadAction<DeviceViewTabValues>) => { state.activeTab = action.payload }, }, }) -export const { setDevices } = deviceSlice.actions +export const { setDevices, setActiveTab } = deviceSlice.actions +const { setPnds } = deviceSlice.actions export default deviceSlice.reducer export const deviceReducerPath = deviceSlice.reducerPath; -let fetchSubscription: QueryActionCreatorResult<any>[] = []; +let fetchSubscription: QueryActionCreatorResult<any>[] = []; export const abortFetching = () => { fetchSubscription.forEach((subscription) => { subscription.unsubscribe(); @@ -43,7 +49,7 @@ export const abortFetching = () => { } // continously fetch devices -const FETCH_DEVICES_INTERVAL = 5000; // in ms +const FETCH_DEVICES_INTERVAL = 15000; // in ms startListening({ actionCreator: setUser, effect: async (_, listenerApi) => { @@ -71,4 +77,15 @@ startListening({ effect: async (action, listenerApi) => { listenerApi.dispatch(setDevices(action.payload.mne)); }, -}) \ No newline at end of file +}) + +export const fetchPnds = createAsyncThunk('device/fetchPnds', (_, thunkApi) => { + const payload: PndServiceGetPndListApiArg = { + timestamp: new Date().getTime().toString(), + } + + const subscription = thunkApi.dispatch(api.endpoints.pndServiceGetPndList.initiate(payload)); + subscription.unwrap().then((response) => { + thunkApi.dispatch(setPnds(response.pnd)); + }); +}); diff --git a/react-ui/src/stores/reducer/user.reducer.ts b/react-ui/src/stores/reducer/user.reducer.ts index 797626db0dcbea6c15a29c4060240dc7bf34fd26..ce9f2b5020a05727d7582b832355ea4620c724c2 100644 --- a/react-ui/src/stores/reducer/user.reducer.ts +++ b/react-ui/src/stores/reducer/user.reducer.ts @@ -1,8 +1,7 @@ import { api, RbacUser, UserServiceGetUsersApiArg } from '@api/api'; +import { setCookieValue } from '@helper/coookie'; import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit'; import { RootState } from '..'; -import { startListening } from '../middleware/listener.middleware'; -import { getCookieValue, setCookieValue } from '@helper/coookie'; export interface UserSliceState { // defined by the frontend user input. This value is getting compared with the backend response diff --git a/react-ui/src/style/box.scss b/react-ui/src/style/box.scss index e7a3ca7d39c2c5363d62b7379ae2358a5d0693a1..bd75fb00a6c1574584c85afafe6a91d8b858deb4 100644 --- a/react-ui/src/style/box.scss +++ b/react-ui/src/style/box.scss @@ -1,7 +1,7 @@ @import './colors.scss'; $box-padding: 10px; -$border-radius: 10px; +$border-radius: 20px; .c-box { diff --git a/react-ui/src/style/utils.scss b/react-ui/src/style/utils.scss index 9dd71c4b8ef5ab7830205e905e09e44b4fc9c411..d8be654f7e67fc11d9626c2a52f5744af0dfbb79 100644 --- a/react-ui/src/style/utils.scss +++ b/react-ui/src/style/utils.scss @@ -10,4 +10,4 @@ .icon { font-size: 1.75em; -} \ No newline at end of file +} diff --git a/react-ui/src/utils/helper/coookie.ts b/react-ui/src/utils/helper/coookie.ts index 75cba2dc8be12a5408cffd739d0f2d4a005e5d67..d15e2702c13e2c14195cc8711b3efac0f884318a 100644 --- a/react-ui/src/utils/helper/coookie.ts +++ b/react-ui/src/utils/helper/coookie.ts @@ -10,5 +10,5 @@ export const getCookieValue = (name: string): string => { } export const setCookieValue = (key: string, value: string): void => { - document.cookie = `${key}=${value}; Secure; SameSite=Lax`; + document.cookie = `${key}=${value}; Secure; SameSite=Lax; path=/`; } \ No newline at end of file diff --git a/react-ui/src/utils/layouts/protected.layout/protected.layout.tsx b/react-ui/src/utils/layouts/protected.layout/protected.layout.tsx index 834b2ac5f52ee4a6d4d966560facf6e4ec624ff1..588491230158d7053f51fa1a28e2151c8d23b8cc 100644 --- a/react-ui/src/utils/layouts/protected.layout/protected.layout.tsx +++ b/react-ui/src/utils/layouts/protected.layout/protected.layout.tsx @@ -9,6 +9,8 @@ import { Link, Outlet, useNavigate } from "react-router-dom"; import "./protected.layout.scss"; import { useAppDispatch, useAppSelector } from '@hooks'; import { fetchUser } from '@reducer/user.reducer'; +import { fetchPnds } from '@reducer/device.reducer'; +import { DEVICE_URL, LOGIN_URL } from '@routes'; export const ProtectedLayout = () => { @@ -20,11 +22,12 @@ export const ProtectedLayout = () => { useEffect(() => { if (!isAuthenticated()) { - navigate('/login') + navigate(LOGIN_URL) return; } dispatch(fetchUser()); + dispatch(fetchPnds()); }, []); /** @@ -78,7 +81,7 @@ export const ProtectedLayout = () => { return ( <nav className="bg-white border-bottom border-dark py-2 d-flex align-items-center"> <Link to="/"><img src={logo} className="mx-4 me-5" width={25} alt="logo" /></Link> - <Link className={"head-links" + handleActiveLink('/')} to="/">{t('protected.link.device_list')}</Link> + <Link className={"head-links" + handleActiveLink(DEVICE_URL)} to="/">{t('protected.link.device_list')}</Link> <Link className={"head-links" + handleActiveLink('/map')} to="/">{t('protected.link.map')}</Link> <Link className={"head-links" + handleActiveLink('/configuration_management')} to="/">{t('protected.link.configuration_mgmt')}</Link> diff --git a/react-ui/src/utils/provider/auth.provider.tsx b/react-ui/src/utils/provider/auth.provider.tsx index 638d9b4088169519351b5ced03cac627860969fe..e3a90d7118edeeb558523116e2771570e5049190 100644 --- a/react-ui/src/utils/provider/auth.provider.tsx +++ b/react-ui/src/utils/provider/auth.provider.tsx @@ -3,6 +3,7 @@ import { getCookieValue } from "@helper/coookie"; import { useAppDispatch, useAppSelector } from "@hooks"; import { abortFetching } from "@reducer/device.reducer"; import { setToken } from "@reducer/user.reducer"; +import { DEVICE_URL, LOGIN_URL } from "@routes"; import { jwtDecode } from "jwt-decode"; import { createContext, useContext, useEffect, useMemo } from "react"; import { useNavigate } from "react-router-dom"; @@ -38,9 +39,9 @@ export const AuthProvider = ({ children }) => { const token = getCookieValue('token'); if (token) { - navigate('/') + navigate(DEVICE_URL) } else { - navigate('/login') + navigate(LOGIN_URL) } }, [username]); @@ -75,7 +76,7 @@ export const AuthProvider = ({ children }) => { } dispatch(setToken({ token: response.token, username })); - navigate('/'); + navigate(DEVICE_URL); }).catch((error) => { // determine whether 500 or 401 err }); diff --git a/react-ui/tsconfig.json b/react-ui/tsconfig.json index cc5d30818ecf42b1e33916305f847fe94ea62eb4..32b67b0d1754aa4b4f5e592231a532dc52fdf60f 100644 --- a/react-ui/tsconfig.json +++ b/react-ui/tsconfig.json @@ -30,6 +30,7 @@ "@provider/*": ["src/utils/provider/*"], "@layout/*": ["src/utils/layouts/*"], "@hooks": ["src/hooks"], + "@routes": ["src/routes.tsx"], "@task/*": ["src/utils/tasks/*"], "@helper/*": ["src/utils/helper/*"], } diff --git a/react-ui/vite.config.mjs b/react-ui/vite.config.mjs index dee436d2a13d20ea06d8b46747a7473c9b127a01..c5073c14e64ba0065869955c523069418953c1cc 100644 --- a/react-ui/vite.config.mjs +++ b/react-ui/vite.config.mjs @@ -38,6 +38,7 @@ export default defineConfig({ "@hooks": "/src/hooks.ts", "@task": "/src/utils/tasks", "@helper": "/src/utils/helper", + "@routes": "/src/routes.tsx", }, },