diff --git a/react-ui/package.json b/react-ui/package.json index 8bdbbfa50034042e846a40bf3bec7c00b6cd4161..76af9e08b8816b67d32f7057f361cf5e5551c8e6 100755 --- a/react-ui/package.json +++ b/react-ui/package.json @@ -26,6 +26,7 @@ "react-dom": "^18.3.1", "react-error-boundary": "^4.1.2", "react-grid-layout": "^1.5.0", + "react-hook-form": "^7.54.2", "react-i18next": "^15.0.0", "react-loading-skeleton": "^3.5.0", "react-redux": "^9.1.2", diff --git a/react-ui/src/.prettierrc b/react-ui/src/.prettierrc new file mode 100644 index 0000000000000000000000000000000000000000..222861c341540db6785e10c2b6461d5cbc94a6eb --- /dev/null +++ b/react-ui/src/.prettierrc @@ -0,0 +1,4 @@ +{ + "tabWidth": 2, + "useTabs": false +} diff --git a/react-ui/src/components/devices/reducer/plugin.reducer.ts b/react-ui/src/components/devices/reducer/plugin.reducer.ts index 1a09876fe65dce2f919dc58a46c60a37c14f03b4..f11f24477aed8e0673653c81f4f5eaff88c4cd73 100644 --- a/react-ui/src/components/devices/reducer/plugin.reducer.ts +++ b/react-ui/src/components/devices/reducer/plugin.reducer.ts @@ -1,15 +1,10 @@ import { NetworkelementFlattenedManagedNetworkElement, NetworkelementManagedNetworkElement, - PndPrincipalNetworkDomain + PluginRegistryPlugin } from '@api/api' 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' -import { startListening } from '/src/stores/middleware/listener.middleware' export type Device = NetworkelementFlattenedManagedNetworkElement @@ -19,14 +14,11 @@ interface SelectedObject { json: JSON | null } -export interface DeviceSliceState { - devices: Device[] - pnds: PndPrincipalNetworkDomain[] - - selected: SelectedObject | null +export interface PluginSliceState { + plugins: Array<PluginRegistryPlugin> } -const initialState: DeviceSliceState = { +const initialState: PluginSliceState = { plugins: [], } @@ -37,63 +29,18 @@ interface SetSelectedDeviceType { } } -const deviceSlice = createSlice({ +const pluginSlice = createSlice({ name: 'plugins', initialState, reducers: { - setPlugins: (state, action: PayloadAction<Plugin[] | undefined>) => { - state.devices = action.payload || [] + setPlugins: (state, action: PayloadAction<PluginRegistryPlugin[] | undefined>) => { + state.plugins = action.payload || [] }, }, }) -export const { setDevices, setSelectedDevice, setSelectedMne, setSelectedJson, setPnds } = - deviceSlice.actions - -export default deviceSlice.reducer -export const deviceReducerPath = deviceSlice.reducerPath - -// add default selected device if no selected device is set -startListening({ - predicate: (action) => setDevices.match(action), - effect: async (action, listenerApi) => { - const { device: state } = listenerApi.getOriginalState() as RootState - if (state.selected) { - return - } - - // if there are no devices available do set null - const device = action.payload?.[0] || null - listenerApi.dispatch(setSelectedDevice({ device } as SetSelectedDeviceType)) - }, -}) - -startListening({ - predicate: (action) => setSelectedMne.match(action), - effect: async (action, listenerApi) => { - listenerApi.dispatch(refreshUpdateTimer(Category.TAB as CategoryType)) - }, -}) +export const { setPlugins } = + pluginSlice.actions -startListening({ - predicate: (action) => setDevices.match(action), - effect: async (action, listenerApi) => { - listenerApi.dispatch(refreshUpdateTimer(Category.DEVICE as CategoryType)) - }, -}) - -/** - * On startup reset the selected device - */ -startListening({ - predicate: ({ type }: any) => type === REHYDRATE, - effect: async (_, listenerApi) => { - const { device: state } = listenerApi.getState() as RootState - const device = state.selected?.device - if (!device) { - return - } - - listenerApi.dispatch(setSelectedDevice({ device, options: { bypassCheck: true } } as SetSelectedDeviceType)) - }, -}) \ No newline at end of file +export default pluginSlice.reducer +export const pluginReducerPath = pluginSlice.reducerPath diff --git a/react-ui/src/components/devices/routines/mne.routine.ts b/react-ui/src/components/devices/routines/mne.routine.ts index 2928693075a75bf7af1044d44a91488b56e10b23..967da2615e5ccad3a80995d5dc8eebd7e72a0b0a 100755 --- a/react-ui/src/components/devices/routines/mne.routine.ts +++ b/react-ui/src/components/devices/routines/mne.routine.ts @@ -13,7 +13,6 @@ import { startListening } from '../../../stores/middleware/listener.middleware' export const FETCH_MNE_ACTION = 'subscription/device/fetchSelectedMNE' - /** * #0 * Trigger fetch MNE (#1) diff --git a/react-ui/src/components/devices/routines/plugin.routine.ts b/react-ui/src/components/devices/routines/plugin.routine.ts new file mode 100644 index 0000000000000000000000000000000000000000..ece4608f7ba8caf8a0d25558ca37532d25e6a6eb --- /dev/null +++ b/react-ui/src/components/devices/routines/plugin.routine.ts @@ -0,0 +1,36 @@ +import { PluginInternalServiceGetAvailablePluginsApiArg, api } from "@api/api" +import { warnMessage } from "@helper/debug" +import { createAsyncThunk } from "@reduxjs/toolkit" +import { RootState } from "src/stores" +import { setPlugins } from "../reducer/plugin.reducer" + +const FETCH_PLUGIN_ACTION = 'subscription/plugin/fetchPlugins' + + +export const fetchPluginsThunk = createAsyncThunk( + FETCH_PLUGIN_ACTION, + async (_, thunkApi) => { + const { user } = thunkApi.getState() as RootState + + if (!user.user?.roles) { + warnMessage('Plugin fetch was triggered but user data is not presence') + return + } + + const payload: PluginInternalServiceGetAvailablePluginsApiArg = { + timestamp: new Date().getTime().toString(), + } + + + const plugins = await thunkApi.dispatch(api.endpoints.pluginInternalServiceGetAvailablePlugins.initiate(payload)) + + if (plugins.error || !plugins.data?.plugins) { + warnMessage('Plugin fetch returned an error: ' + plugins.error) + return + } + + thunkApi.dispatch( + setPlugins(plugins.data?.plugins) + ) + } +) \ No newline at end of file diff --git a/react-ui/src/components/devices/view/device.view.list.tsx b/react-ui/src/components/devices/view/device.view.list.tsx index 53f1580d13d36784df38242e4be998b2e0ae6590..868f26959038dc2b73ddb4d07c2ced774d3a3fd9 100755 --- a/react-ui/src/components/devices/view/device.view.list.tsx +++ b/react-ui/src/components/devices/view/device.view.list.tsx @@ -1,94 +1,122 @@ import { insertMarkTags } from "@helper/text"; import { useAppSelector } from "@hooks"; -import DOMPurify from 'dompurify'; -import { RefObject, useCallback, useRef } from 'react'; -import { Col, OverlayTrigger, Row, Tooltip } from 'react-bootstrap'; +import DOMPurify from "dompurify"; +import { RefObject, useCallback, useRef } from "react"; +import { Col, OverlayTrigger, Row, Tooltip } from "react-bootstrap"; import { useTranslation } from "react-i18next"; import { Device } from "../reducer/device.reducer"; import { useDeviceTableViewModel } from "../view_model/device.list.viewmodel"; const cropUUID = (uuid: string): string => { - return uuid.substring(0, 3) + "..." + uuid.substring(uuid.length - 3, uuid.length); + return ( + uuid.substring(0, 3) + "..." + uuid.substring(uuid.length - 3, uuid.length) + ); }; -export const DeviceList = ({ searchRef }: { searchRef: RefObject<HTMLInputElement> }) => { - const { devices, pnds, selected: selectedDevice } = useAppSelector(state => state.device); - const { t } = useTranslation('common'); - const listRef = useRef<HTMLDivElement>(null); - const { dispatchDevice } = useDeviceTableViewModel(searchRef, listRef); +export const DeviceList = ({ + searchRef, +}: { + searchRef: RefObject<HTMLInputElement>; +}) => { + const { + devices, + pnds, + selected: selectedDevice, + } = useAppSelector((state) => state.device); + const { t } = useTranslation("common"); + const listRef = useRef<HTMLDivElement>(null); + const { dispatchDevice } = useDeviceTableViewModel(searchRef, listRef); - const handleItemClick = useCallback((device: Device) => { - dispatchDevice(device) - }, []); + const handleItemClick = useCallback((device: Device) => { + dispatchDevice(device); + }, []); - const getDeviceList = useCallback(() => { - const search = searchRef?.current?.value; - let filtered = devices; + const getDeviceList = useCallback(() => { + const search = searchRef?.current?.value; + let filtered = devices; - if (search) { - filtered = devices.filter((device) => { - const user = pnds.find(pnd => pnd.id === device.pid); - return device.id?.includes(search) || - device.name?.includes(search) || - user?.name?.includes(search); - }); - } + if (search) { + filtered = devices.filter((device) => { + const user = pnds.find((pnd) => pnd.id === device.pid); + return ( + device.id?.includes(search) || + device.name?.includes(search) || + user?.name?.includes(search) + ); + }); + } - return filtered.map((device) => { - const user = pnds.find(pnd => pnd.id === device.pid); - const username = user?.name || ''; - const deviceId = device.id!; - const croppedId = cropUUID(deviceId); - const devicename = device.name || ''; - const isSelected = selectedDevice?.device.id === deviceId; + return filtered.map((device) => { + const user = pnds.find((pnd) => pnd.id === device.pid); + const username = user?.name || ""; + const deviceId = device.id!; + const croppedId = cropUUID(deviceId); + const devicename = device.name || ""; + const isSelected = selectedDevice?.device.id === deviceId; - return ( - <div - key={deviceId} - className={`border-bottom border-primary p-2 transitions ${isSelected && 'bg-gradient-fade py-2'} ${!isSelected && 'text-disabled disabled-hover'}`} - onClick={() => handleItemClick(device)} - > - <Row - className="align-items-center clickable" - onClick={() => handleItemClick(device)} - > - <Col xs={12} sm={5} > - <span dangerouslySetInnerHTML={{ - __html: search ? insertMarkTags(devicename, search) : DOMPurify.sanitize(devicename) - }} /> - </Col> - <Col xs={12} sm={3} > - <OverlayTrigger overlay={<Tooltip id={deviceId}>{deviceId}</Tooltip>}> - <span className="text-gray-500" dangerouslySetInnerHTML={{ - __html: search ? insertMarkTags(croppedId, search) : DOMPurify.sanitize(croppedId) - }} /> - </OverlayTrigger> - </Col> - <Col xs={12} sm={4} > - <span className="text-gray-500" dangerouslySetInnerHTML={{ - __html: search ? insertMarkTags(username, search) : DOMPurify.sanitize(username) - }} /> - </Col> - </Row> - </div> - ); - }); - }, [devices, searchRef, pnds, selectedDevice, handleItemClick]); - - return ( - <div className="rounded border border-primary mt-2"> - <Row className="border-bottom border-primary px-2 py-2 mx-0"> - <Col xs={12} sm={5} > - <span className="font-medium">{t('device.table.header.name')}</span> - </Col> - <Col xs={12} sm={3} > - <span className="font-medium">{t('device.table.header.uuid')}</span> - </Col> - <Col xs={12} sm={4} > - <span className="font-medium">{t('device.table.header.user')}</span> - </Col> - </Row> - <div ref={listRef}>{getDeviceList()}</div> + return ( + <div + key={deviceId} + className={`border-bottom border-primary p-2 transitions ${isSelected && "bg-gradient-fade py-2"} ${!isSelected && "text-disabled disabled-hover"}`} + onClick={() => handleItemClick(device)} + > + <Row + className="align-items-center clickable" + onClick={() => handleItemClick(device)} + > + <Col xs={12} sm={5}> + <span + dangerouslySetInnerHTML={{ + __html: search + ? insertMarkTags(devicename, search) + : DOMPurify.sanitize(devicename), + }} + /> + </Col> + <Col xs={12} sm={3}> + <OverlayTrigger + overlay={<Tooltip id={deviceId}>{deviceId}</Tooltip>} + > + <span + className="text-gray-500" + dangerouslySetInnerHTML={{ + __html: search + ? insertMarkTags(croppedId, search) + : DOMPurify.sanitize(croppedId), + }} + /> + </OverlayTrigger> + </Col> + <Col xs={12} sm={4}> + <span + className="text-gray-500" + dangerouslySetInnerHTML={{ + __html: search + ? insertMarkTags(username, search) + : DOMPurify.sanitize(username), + }} + /> + </Col> + </Row> </div> - ); -}; \ No newline at end of file + ); + }); + }, [devices, searchRef, pnds, selectedDevice, handleItemClick]); + + return ( + <div className="rounded border border-primary mt-2"> + <Row className="border-bottom border-primary px-2 py-2 mx-0"> + <Col xs={12} sm={5}> + <span className="font-medium">{t("device.table.header.name")}</span> + </Col> + <Col xs={12} sm={3}> + <span className="font-medium">{t("device.table.header.uuid")}</span> + </Col> + <Col xs={12} sm={4}> + <span className="font-medium">{t("device.table.header.user")}</span> + </Col> + </Row> + <div ref={listRef}>{getDeviceList()}</div> + </div> + ); +}; diff --git a/react-ui/src/components/devices/view/device.view.tsx b/react-ui/src/components/devices/view/device.view.tsx index 290697ac6a4f584bf77e62a8c7a564cf342dd251..814f51db1c77123e5b285131e48e5c94c65f40ed 100755 --- a/react-ui/src/components/devices/view/device.view.tsx +++ b/react-ui/src/components/devices/view/device.view.tsx @@ -1,69 +1,91 @@ -import { faCircleInfo, faPlus, faServer, faSliders } from '@fortawesome/free-solid-svg-icons'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { GridLayout } from '@layout/grid.layout/grid.layout'; -import { GridBox } from '@shared/components/box/gridBox.view'; -import { JsonViewer } from '@shared/components/json_viewer/view/json_viewer.view'; -import { useRef } from 'react'; -import { Button, Col, Form, Row } from 'react-bootstrap'; -import { useTranslation } from 'react-i18next'; -import { useDeviceViewModel } from '../view_model/device.viewmodel'; -import './device.scss'; -import { DeviceList } from './device.view.list'; -import { DeviceListCollapsable } from './subcomponent/device.view.list-detail'; +import { + faCircleInfo, + faPlus, + faServer, + faSliders, +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { GridLayout } from "@layout/grid.layout/grid.layout"; +import { GridBox } from "@shared/components/box/gridBox.view"; +import { JsonViewer } from "@shared/components/json_viewer/view/json_viewer.view"; +import { useRef } from "react"; +import { Button, Col, Form, Row } from "react-bootstrap"; +import { useTranslation } from "react-i18next"; +import { useDeviceViewModel } from "../view_model/device.viewmodel"; +import "./device.scss"; +import { DeviceList } from "./device.view.list"; +import { DeviceListCollapsable } from "./subcomponent/device.view.list-detail"; +import AddDeviceModal from "./subcomponent/modal.view"; const DeviceView = () => { - const { t } = useTranslation('common'); - const searchRef = useRef<HTMLInputElement>(null); - const { jsonYang } = useDeviceViewModel(); + const { t } = useTranslation("common"); + const searchRef = useRef<HTMLInputElement>(null); + const { jsonYang, openAddModal, closeModal, addModal } = useDeviceViewModel(); - return ( - <GridLayout> - <> - <div key="device-list"> - <GridBox title={t("device.box.list.title")} title_icon={faServer}> - <Row className="mb-3 align-items-center"> - <Col xs={12} md={6} lg={8}> - <Form.Group controlId='device.search'> - <Form.Control type="text" placeholder={t('device.search.placeholder')} ref={searchRef} /> - </Form.Group> - </Col> - <Col xs={12} md={6} lg={4} className='mt-3 mt-md-0 text-md-end'> - <Button variant='primary::button' className='btn-primary-button'> - <FontAwesomeIcon icon={faPlus} className='me-2' /> - {t('device.add_device_button')} - </Button> - </Col> - </Row> - <Row> - <Col xs={12} className='h-auto'> - <DeviceList searchRef={searchRef} /> - </Col> - </Row> - </GridBox> - </div> + return ( + <GridLayout> + <> + <div key="device-list"> + <GridBox title={t("device.box.list.title")} title_icon={faServer}> + <Row className="mb-3 align-items-center"> + <Col xs={12} md={6} lg={8}> + <Form.Group controlId="device.search"> + <Form.Control + type="text" + placeholder={t("device.search.placeholder")} + ref={searchRef} + /> + </Form.Group> + </Col> + <Col xs={12} md={6} lg={4} className="mt-3 mt-md-0 text-md-end"> + <Button + variant="primary::button" + className="btn-primary-button" + onClick={() => openAddModal()} + > + <FontAwesomeIcon icon={faPlus} className="me-2" /> + {t("device.add_device_button")} + </Button> - <div key="device-metadata"> - <GridBox title={t("device.box.information.title")} title_icon={faCircleInfo}> - <Row> - <Col xs={12} > - <DeviceListCollapsable search={searchRef.current?.value || ''} /> - </Col> - </Row> - </GridBox> - </div> + <AddDeviceModal show={addModal} onHide={() => closeModal()} /> + </Col> + </Row> + <Row> + <Col xs={12} className="h-auto"> + <DeviceList searchRef={searchRef} /> + </Col> + </Row> + </GridBox> + </div> - <div key="device-details"> - <GridBox title={t('device.box.configuration.title')} title_icon={faSliders}> - <Row> - <Col xs={12}> - {jsonYang && <JsonViewer json={jsonYang} />} - </Col> - </Row> - </GridBox> - </div> - </> - </GridLayout> - ); + <div key="device-metadata"> + <GridBox + title={t("device.box.information.title")} + title_icon={faCircleInfo} + > + <Row> + <Col xs={12}> + <DeviceListCollapsable + search={searchRef.current?.value || ""} + /> + </Col> + </Row> + </GridBox> + </div> + + <div key="device-details"> + <GridBox + title={t("device.box.configuration.title")} + title_icon={faSliders} + > + <Row> + <Col xs={12}>{jsonYang && <JsonViewer json={jsonYang} />}</Col> + </Row> + </GridBox> + </div> + </> + </GridLayout> + ); }; -export default DeviceView; \ No newline at end of file +export default DeviceView; diff --git a/react-ui/src/components/devices/view/subcomponent/modal.view.tsx b/react-ui/src/components/devices/view/subcomponent/modal.view.tsx index d798e7adecd5190a5f7a9f413de0c41a4831e5eb..fd59364126c899baf67da2081de49bff879c8f0a 100644 --- a/react-ui/src/components/devices/view/subcomponent/modal.view.tsx +++ b/react-ui/src/components/devices/view/subcomponent/modal.view.tsx @@ -1,141 +1,162 @@ -import { NetworkelementAddListRequest, useNetworkElementServiceAddListMutation } from '@api/api'; -import React, { useState } from 'react'; -import { Button, Form, Modal } from 'react-bootstrap'; +import { useModalViewModel } from "@component/devices/view_model/modal.viewmodel"; +import { useAppSelector } from "@hooks"; +import React from "react"; +import { Alert, Button, Form, Modal } from "react-bootstrap"; +import { useTranslation } from "react-i18next"; interface AddDeviceModalProps { - show: boolean; - onHide: () => void; -} - -interface FormData { - address: '', - mneName: '', - transportOption: undefined, - gnmiSubscribePaths: [], + show: boolean; + onHide: () => void; } const AddDeviceModal: React.FC<AddDeviceModalProps> = ({ show, onHide }) => { - const [addNetworkElement] = useNetworkElementServiceAddListMutation(); - const [formData, setFormData] = useState<FormData>({ - address: '', - mneName: '', - transportOption: undefined, - gnmiSubscribePaths: [], - }); - - const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { - const { name, value } = e.target; - setFormData(prev => ({ - ...prev, - [name]: value - })); - }; - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); + const { loading, register, handleSubmit, errors } = useModalViewModel({ + hide: onHide, + }); + const { plugins } = useAppSelector((state) => state.plugin); + const { t } = useTranslation("common"); - const request: NetworkelementAddListRequest = { - timestamp: Date.now().toString(), // Convert to nanoseconds if needed - mne: [formData], - pid: formData.pid - }; + return ( + <Modal show={show} onHide={onHide} centered size="lg"> + <Modal.Header closeButton> + <Modal.Title>{t("device.add_device.title")}</Modal.Title> + </Modal.Header> + <Modal.Body> + {errors.root && ( + <Alert variant="danger" className="mt-3"> + {errors.root.message || t("device.add_device.error")} + </Alert> + )} + <Form onSubmit={handleSubmit}> + <Form.Group className="mb-3"> + <Form.Label> + {t("device.add_device.fields.mne_name.label")} + </Form.Label> + <Form.Control + type="text" + placeholder={t("device.add_device.fields.mne_name.placeholder")} + isInvalid={!!errors.mneName} + {...register("mneName", { + required: t("device.add_device.fields.mne_name.required"), + })} + /> + <Form.Control.Feedback type="invalid"> + {errors.mneName?.message} + </Form.Control.Feedback> + </Form.Group> + <Form.Group className="mb-3"> + <Form.Label> + {t("device.add_device.fields.address.label")} + </Form.Label> + <Form.Control + type="text" + placeholder={t("device.add_device.fields.address.placeholder")} + isInvalid={!!errors.address} + {...register("transportOption.address", { + required: t("device.add_device.fields.address.required"), + })} + /> + <Form.Control.Feedback type="invalid"> + {errors.address?.message} + </Form.Control.Feedback> + </Form.Group> - try { - await addNetworkElement({ networkelementAddListRequest: request }); - handleReset(); - // You might want to add a success notification here - } catch (error) { - console.error('Failed to add device:', error); - // You might want to add an error notification here - } - }; + <Form.Group className="mb-3"> + <Form.Label> + {t("device.add_device.fields.plugin.label")} + </Form.Label> + <Form.Control + as="select" + isInvalid={!!errors.pluginId} + {...register("pluginId", { + required: t("device.add_device.fields.plugin.required"), + })} + > + <option value=""> + {t("device.add_device.fields.plugin.placeholder")} + </option> + {plugins.map((plugin) => ( + <option key={plugin.id} value={plugin.id}> + {plugin.manifest?.name} + </option> + ))} + </Form.Control> + <Form.Control.Feedback type="invalid"> + {errors.pluginId?.message} + </Form.Control.Feedback> + </Form.Group> - const handleReset = () => { - setFormData({ - address: '', - pid: '', - pluginId: '', - mneName: '', - transportOption: undefined, - gnmiSubscribePaths: [], - mneId: '' - }); - onHide(); - }; + <Form.Group className="mb-3"> + <Form.Check + type="checkbox" + label={t("device.add_device.fields.tls.label")} + {...register("transportOption.tls")} + /> + </Form.Group> - return ( - <Modal show={show} onHide={handleReset} centered> - <Modal.Header closeButton> - <Modal.Title>Add New Device</Modal.Title> - </Modal.Header> - <Form onSubmit={handleSubmit}> - <Modal.Body> - <Form.Group className="mb-3"> - <Form.Label>Address</Form.Label> - <Form.Control - type="text" - name="address" - value={formData.address} - onChange={handleInputChange} - placeholder="Enter device address" - /> - </Form.Group> + <h5 className="mt-4"> + {t("device.add_device.fields.credentials.title")} + </h5> + <Form.Group className="mb-3"> + <Form.Label> + {t("device.add_device.fields.credentials.username.label")} + </Form.Label> + <Form.Control + type="text" + placeholder={t( + "device.add_device.fields.credentials.username.placeholder", + )} + isInvalid={!!errors.transportOption?.username} + {...register("transportOption.username", { + required: t( + "device.add_device.fields.credentials.username.required", + ), + })} + /> + <Form.Control.Feedback type="invalid"> + {errors.transportOption?.username?.message} + </Form.Control.Feedback> + </Form.Group> - <Form.Group className="mb-3"> - <Form.Label>PID</Form.Label> - <Form.Control - type="text" - name="pid" - value={formData.pid} - onChange={handleInputChange} - placeholder="Enter PID" - /> - </Form.Group> + <Form.Group className="mb-3"> + <Form.Label> + {t("device.add_device.fields.credentials.password.label")} + </Form.Label> + <Form.Control + type="password" + placeholder={t( + "device.add_device.fields.credentials.password.placeholder", + )} + isInvalid={!!errors.transportOption?.password} + {...register("transportOption.password", { + required: t( + "device.add_device.fields.credentials.password.required", + ), + })} + /> + <Form.Control.Feedback type="invalid"> + {errors.transportOption?.password?.message} + </Form.Control.Feedback> + </Form.Group> - <Form.Group className="mb-3"> - <Form.Label>Plugin ID</Form.Label> - <Form.Control - type="text" - name="pluginId" - value={formData.pluginId} - onChange={handleInputChange} - placeholder="Enter plugin ID" - /> - </Form.Group> - - <Form.Group className="mb-3"> - <Form.Label>MNE Name</Form.Label> - <Form.Control - type="text" - name="mneName" - value={formData.mneName} - onChange={handleInputChange} - placeholder="Enter MNE name" - /> - </Form.Group> - - <Form.Group className="mb-3"> - <Form.Label>MNE ID</Form.Label> - <Form.Control - type="text" - name="mneId" - value={formData.mneId} - onChange={handleInputChange} - placeholder="Enter MNE ID" - /> - </Form.Group> - </Modal.Body> - <Modal.Footer> - <Button variant="secondary" onClick={handleReset}> - Cancel - </Button> - <Button variant="primary" type="submit"> - Add Device - </Button> - </Modal.Footer> - </Form> - </Modal> - ); + <div className="d-flex justify-content-end gap-2"> + <Button variant="secondary" onClick={onHide}> + {t("device.add_device.buttons.cancel")} + </Button> + <Button disabled={loading} variant="primary" type="submit"> + {loading ? ( + <> + <span className="spinner-border spinner-border-sm me-2" /> + {t("device.add_device.buttons.loading")} + </> + ) : ( + t("device.add_device.buttons.submit") + )} + </Button> + </div> + </Form> + </Modal.Body> + </Modal> + ); }; - -export default AddDeviceModal; \ No newline at end of file +export default AddDeviceModal; 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 f2fd635533e7de2e4931964ba0333fcdad9a9969..5c4acc6619f1f7156e85ef7b5f393a29dee09276 100755 --- a/react-ui/src/components/devices/view_model/device.viewmodel.ts +++ b/react-ui/src/components/devices/view_model/device.viewmodel.ts @@ -1,8 +1,20 @@ -import { useAppSelector } from '@hooks' -import { useMemo } from 'react' +import { useAppDispatch, useAppSelector } from '@hooks' +import { useMemo, useState } from 'react' +import { fetchPluginsThunk } from '../routines/plugin.routine' export const useDeviceViewModel = () => { + const [addModal, setAddModal] = useState<boolean>(false) const { selected: selectedDevice } = useAppSelector((state) => state.device) + const dispatch = useAppDispatch() + + const openAddModal = () => { + dispatch(fetchPluginsThunk()) + setAddModal(true) + } + + const closeModal = () => { + setAddModal(false) + } const getYangModelJSON = (): JSON | null => { if (!selectedDevice?.json) { @@ -23,6 +35,9 @@ export const useDeviceViewModel = () => { return { jsonYang, - selectedDevice + selectedDevice, + openAddModal, + closeModal, + addModal } } diff --git a/react-ui/src/components/devices/view_model/modal.viewmodel.ts b/react-ui/src/components/devices/view_model/modal.viewmodel.ts index de9bbf68ecba77b9173afd144ac4e36775e22a8e..98742bd3495ba6ced883402a7d8dbbcf4e22f9ba 100644 --- a/react-ui/src/components/devices/view_model/modal.viewmodel.ts +++ b/react-ui/src/components/devices/view_model/modal.viewmodel.ts @@ -1,10 +1,94 @@ -import { useEffect } from "react" +import { NetworkelementAddListRequest, NetworkelementSetMne, useNetworkElementServiceAddListMutation } from "@api/api"; +import { useAppDispatch, useAppSelector } from "@hooks"; +import { fetchUser } from "@shared/routine/user.routine"; +import { useState } from "react"; +import { SubmitHandler, useForm } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import { toast } from "react-toastify"; +interface FormData { + address: string; + mneName: string; + pluginId: string; + transportOption: { + address: string; + username: string; + password: string; + tls: boolean; + gnmiTransportOption: Object; + }; + gnmiSubscribePaths: string[]; +} -export const useModalViewModel = () => { +type ModalViewModelType = { + hide: () => void +} +export const useModalViewModel = ({ hide }: ModalViewModelType) => { + const dispatch = useAppDispatch() + const [addNetworkElement] = useNetworkElementServiceAddListMutation(); + const [loading, setLoading] = useState<boolean>(false) + const { t } = useTranslation('common') - useEffect(() => { + const { + register, + handleSubmit, + setError, + clearErrors, + formState: { errors }, + reset: resetModal + } = useForm<FormData>(); - }, []) + const { user } = useAppSelector(state => state.user); + + const reset = () => { resetModal(); hide(); } + const success = () => { toast.success(t('device.add_device.success')); reset(); dispatch(fetchUser()) } + + + const onSubmit: SubmitHandler<FormData> = async (data) => { + clearErrors() + setLoading(true) + const mne: NetworkelementSetMne = { + ...data, + gnmiSubscribePaths: [], + transportOption: { + ...data.transportOption, + gnmiTransportOption: {}, + } + } + + if (!user?.id) { + toast.error("global.error.missing_user") + return + } + + const request: NetworkelementAddListRequest = { + timestamp: Date.now().toString(), + mne: [mne], + pid: user.id + }; + + try { + const response = await addNetworkElement({ networkelementAddListRequest: request }); + setLoading(false) + + if (response.error) { + const error = response.error as any + setError('root', error.data) + return + } + + success() + } catch (error) { + setError('root', error?.data?.message || undefined) + } + } + + return { + onSubmit, + handleSubmit: handleSubmit(onSubmit), + register, + errors, + loading + } } \ 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 92fb59d85436770697e8f0640d0df683723c67ea..8a81a03e9c7b11ad84263a385ef38bd01c2d3d10 100755 --- a/react-ui/src/i18n/locales/en/translations.json +++ b/react-ui/src/i18n/locales/en/translations.json @@ -14,6 +14,9 @@ }, "box": { "lastUpdate": "Last updated {{seconds}} seconds ago" + }, + "error": { + "missing_user": "Error: User information. Please relogin and try it again" } }, "json_viewer": { @@ -43,6 +46,49 @@ "title": "Configuration" } }, + "add_device": { + "success": "Device successfully added", + "title": "Add New Device", + "error": "An error occurred on save", + "fields": { + "address": { + "label": "IPv4 address", + "placeholder": "172.100.0.1", + "required": "Ipv4 Address is required" + }, + "mne_name": { + "label": "MNE Name", + "placeholder": "Enter MNE name", + "required": "MNE name is required" + }, + "plugin": { + "label": "Plugin", + "placeholder": "Select plugin...", + "required": "Plugin selection is required" + }, + "tls": { + "label": "TLS Enabled" + }, + "credentials": { + "title": "Credentials", + "username": { + "label": "Username", + "placeholder": "Enter username", + "required": "Username is required" + }, + "password": { + "label": "Password", + "placeholder": "Enter password", + "required": "Password is required" + } + } + }, + "buttons": { + "cancel": "Cancel", + "submit": "Add Device", + "loading": "Adding..." + } + }, "table": { "header": { "name": "Name", diff --git a/react-ui/src/shared/api/api.ts b/react-ui/src/shared/api/api.ts index 32969f1686479812253d387868a9350bd41a882c..e0f35f0b7c2db79d9eb066c2d7a1375642292b7d 100755 --- a/react-ui/src/shared/api/api.ts +++ b/react-ui/src/shared/api/api.ts @@ -4,6 +4,7 @@ export const addTagTypes = [ 'ConfigurationManagementService', 'AuthService', 'NetworkElementService', + 'PluginInternalService', 'PndService', 'RoleService', 'RoutingTableService', @@ -214,6 +215,18 @@ const injectedRtkApi = api }), invalidatesTags: ['NetworkElementService'], }), + pluginInternalServiceGetAvailablePlugins: build.query< + PluginInternalServiceGetAvailablePluginsApiResponse, + PluginInternalServiceGetAvailablePluginsApiArg + >({ + query: (queryArg) => ({ + url: `/plugins`, + params: { + timestamp: queryArg.timestamp, + }, + }), + providesTags: ['PluginInternalService'], + }), pndServiceGetPnd: build.query< PndServiceGetPndApiResponse, PndServiceGetPndApiArg @@ -680,6 +693,11 @@ export type NetworkElementServiceUpdateApiResponse = export type NetworkElementServiceUpdateApiArg = { networkelementUpdateNetworkElementRequest: TodoChangeNameToFitTheRest } +export type PluginInternalServiceGetAvailablePluginsApiResponse = + /** status 200 A successful response. */ GosdnpluginRegistryGetResponse +export type PluginInternalServiceGetAvailablePluginsApiArg = { + timestamp?: string +} export type PndServiceGetPndApiResponse = /** status 200 A successful response. */ PndGetPndResponse export type PndServiceGetPndApiArg = { @@ -1183,6 +1201,11 @@ export type TodoChangeNameToFitTheRest = { timestamp?: string networkElement?: NetworkelementManagedNetworkElement } +export type GosdnpluginRegistryGetResponse = { + /** Timestamp in nanoseconds since Epoch. */ + timestamp?: string + plugins?: PluginRegistryPlugin[] +} export type PndGetPndResponse = { /** Timestamp in nanoseconds since Epoch. */ timestamp?: string @@ -1432,6 +1455,7 @@ export const { useNetworkElementServiceGetIntendedPathQuery, useNetworkElementServiceGetPathQuery, useNetworkElementServiceUpdateMutation, + usePluginInternalServiceGetAvailablePluginsQuery, usePndServiceGetPndQuery, usePndServiceGetPndListQuery, usePndServiceCreatePndListMutation, diff --git a/react-ui/src/stores/persist.store.ts b/react-ui/src/stores/persist.store.ts index e12467ed9a3533da0cc1574e4fb9809e3858cb57..91d961cadc0283e2a530138096a7774e967ad6c0 100755 --- a/react-ui/src/stores/persist.store.ts +++ b/react-ui/src/stores/persist.store.ts @@ -1,4 +1,5 @@ import deviceReducer from '@component/devices/reducer/device.reducer' +import pluginReducer from '@component/devices/reducer/plugin.reducer' import jsonViewerReducer from '@shared/components/json_viewer/reducer/json_viewer.reducer' import routineReducer from '@shared/reducer/routine.reducer' import userReducer from '@shared/reducer/user.reducer' @@ -7,6 +8,8 @@ import { persistReducer } from 'redux-persist' import storage from 'redux-persist/es/storage' import { emptySplitApi } from './api.store' + + /** local storage config */ const rootPersistConfig = { key: 'root', @@ -19,6 +22,7 @@ const rootReducer = combineReducers({ device: deviceReducer, routine: routineReducer, json_viwer: jsonViewerReducer, + plugin: pluginReducer, [emptySplitApi.reducerPath]: emptySplitApi.reducer, }) diff --git a/react-ui/yarn.lock b/react-ui/yarn.lock index 224cc0ddd12db6f8a8c6ff139a8f4015d76e03dc..5e4047ff1c1a76982bf2b4c0233b5da1adebda58 100755 --- a/react-ui/yarn.lock +++ b/react-ui/yarn.lock @@ -8887,6 +8887,11 @@ react-grid-layout@^1.5.0: react-resizable "^3.0.5" resize-observer-polyfill "^1.5.1" +react-hook-form@^7.54.2: + version "7.54.2" + resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.54.2.tgz#8c26ed54c71628dff57ccd3c074b1dd377cfb211" + integrity sha512-eHpAUgUjWbZocoQYUHposymRb4ZP6d0uwUnooL2uOybA9/3tPUvoAKqEWK1WaSiTxxOfTpffNZP7QwlnM3/gEg== + react-i18next@^15.0.0: version "15.1.4" resolved "https://registry.yarnpkg.com/react-i18next/-/react-i18next-15.1.4.tgz#65c03c31a5e42202000652e163f22f23a9306a60"