diff --git a/src/api/UserProvider.jsx b/src/api/UserProvider.jsx index 25b74335aadec1c14013d1416c71324b41375680..2382ba98afab4692ba0422208c17e8de30f8215a 100644 --- a/src/api/UserProvider.jsx +++ b/src/api/UserProvider.jsx @@ -1,125 +1,69 @@ -import { useCallback, useEffect, useState, useContext } from "react"; -import { userContext, useAPIMethod } from "@ess-ics/ce-ui-common"; -import { apiContext } from "./DeployApi"; - -function loginRequest(username, password) { - return { - userName: username, // fyi on template it is username, but on deploy it is userName - password: password - }; -} - -function unpackUser(user) { - if (user?.length > 0) { - return { ...user[0] }; - } else { - return {}; - } -} +import { useCallback, useEffect, useState } from "react"; +import { userContext } from "@ess-ics/ce-ui-common"; +import { + useLoginMutation, + useLogoutMutation, + useInfoFromUserNameQuery, + useGetUserRolesQuery +} from "../store/deployApi"; export function UserProvider({ children }) { - const [initialized, setInitialized] = useState(false); const [user, setUser] = useState(null); const [userRoles, setUserRoles] = useState([]); - const [loginErrorMsg, setLoginErrorMsg] = useState(); - const client = useContext(apiContext); - const { - error: loginError, - wrapper: callLoginAPI, - value: loginResponse, - loading: loginLoading - } = useAPIMethod({ fcn: client.apis.Authentication.login, call: false }); - const { - wrapper: callUserAPI, - value: userResponse, - loading: userLoading - } = useAPIMethod({ - fcn: client.apis.Git.infoFromUserName, - call: true, - unpacker: unpackUser - }); - const { wrapper: callLogoutAPI, loading: logoutLoading } = useAPIMethod({ - fcn: client.apis.Authentication.logout, - call: false - }); - const { - wrapper: callUserRolesAPI, - value: userRolesResponse, - loading: userRolesLoading - } = useAPIMethod({ - fcn: client.apis.Authentication.getUserRoles, - call: true - }); + const [ + callLogin, + { + data: loginResponse, + error: loginError, + isLoading: loginLoading, + reset: resetLogin + } + ] = useLoginMutation(); + const [callLogout, { isLoading: logoutLoading }] = useLogoutMutation(); + const { data: userResponse, isLoading: userLoading } = + useInfoFromUserNameQuery({ + skip: !loginResponse + }); + const { data: userRolesResponse, isLoading: userRolesLoading } = + useGetUserRolesQuery({ skip: !loginResponse }); + + const initialized = Boolean( + !loginLoading || !logoutLoading || !userLoading || !userRolesLoading + ); const login = useCallback( (username, password) => { - callLoginAPI({}, { requestBody: loginRequest(username, password) }); + callLogin({ login: { userName: username, password } }); }, - [callLoginAPI] + [callLogin] ); - const logout = useCallback(() => { - callLogoutAPI({}, {}); - setUser(null); - }, [callLogoutAPI]); - - useEffect(() => { - if (loginResponse) { - callUserAPI(); - callUserRolesAPI(); - } - }, [loginResponse, callUserAPI, callUserRolesAPI]); - useEffect(() => { setUser(userResponse); - }, [userResponse, setUser]); - - useEffect(() => { setUserRoles(userRolesResponse); - }, [userRolesResponse, setUserRoles]); + }, [userRolesResponse, userResponse]); - const loading = Boolean( - loginLoading || userLoading || userRolesLoading || logoutLoading - ); - - useEffect(() => { - setLoginErrorMsg(loginError?.response?.body?.description); - }, [loginError]); - - const resetLoginError = useCallback( - () => setLoginErrorMsg(null), - [setLoginErrorMsg] - ); - - const createValue = useCallback(() => { - return { - user: user, - userRoles: userRoles, - login, - loginError: loginErrorMsg, - logout, - resetLoginError - }; - }, [user, userRoles, login, loginErrorMsg, logout, resetLoginError]); - - const [value, setValue] = useState(createValue()); - - useEffect(() => { - if (!loading) { - setInitialized(true); - } - }, [loading]); - - useEffect(() => { - if (!loading) { - setValue(createValue()); - } - }, [loading, setValue, createValue]); + const logout = useCallback(() => { + callLogout(); + setUser(null); + setUserRoles([]); + }, [callLogout, setUser, setUserRoles]); return ( initialized && ( - <userContext.Provider value={value}>{children}</userContext.Provider> + <userContext.Provider + value={{ + user, + userRoles, + login, + loginError: loginError?.data?.description, + logout, + resetLoginError: resetLogin + }} + > + {children} + </userContext.Provider> ) ); } diff --git a/src/components/IOC/CreateIOC/CreateIOC.jsx b/src/components/IOC/CreateIOC/CreateIOC.jsx index 7485950ba99d055448f2677e070b90f5fae4eb22..16514a7032db10fabc9102278367b3fa605616dd 100644 --- a/src/components/IOC/CreateIOC/CreateIOC.jsx +++ b/src/components/IOC/CreateIOC/CreateIOC.jsx @@ -1,8 +1,7 @@ -import { useMemo, useEffect, useState, useContext } from "react"; +import { useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; -import { RootPaper, useAPIMethod } from "@ess-ics/ce-ui-common"; +import { RootPaper } from "@ess-ics/ce-ui-common"; import { - Alert, Autocomplete, Button, CircularProgress, @@ -16,15 +15,12 @@ import { WITHOUT_REPO } from "./RepositoryType"; import { RepositoryName } from "./RepositoryName"; import { useTypingTimer } from "../../common/SearchBoxFilter/TypingTimer"; import { useCustomSnackbar } from "../../common/snackbar"; - -import { apiContext } from "../../../api/DeployApi"; -import { getErrorMessage } from "../../common/Helper"; - -const createRequestParams = (query) => { - return { - query: query - }; -}; +import { + useCreateIocMutation, + useLazyFetchIocByNameQuery, + useLazyListProjectsQuery +} from "../../../store/deployApi"; +import { ApiAlertError } from "../../common/Alerts/ApiAlertError"; export function CreateIOC() { const navigate = useNavigate(); @@ -36,55 +32,26 @@ export function CreateIOC() { const [selectedRepoOption, setSelectedRepoOption] = useState(WITHOUT_REPO); const [repoName, setRepoName] = useState(""); - const client = useContext(apiContext); + const [ + getAllowedGitProjects, + { data: allowedGitProjects, isLoading: loadingAllowedGitProjects } + ] = useLazyListProjectsQuery(); - const requestParams = useMemo( - () => createRequestParams(repoQuery), - [repoQuery] - ); - - const { - value: allowedGitProjects, - wrapper: getAllowedGitProjects, - loading: loadingAllowedGitProjects - } = useAPIMethod({ - fcn: client.apis.Git.listProjects, - params: requestParams, - call: false - }); + const [createIoc, { data: ioc, isLoading, error }] = useCreateIocMutation(); - const { - value: ioc, - wrapper: createIoc, - loading, - error - } = useAPIMethod({ - fcn: client.apis.IOCs.createIoc, - call: false - }); - - const { - value: names, - wrapper: getNames, - loading: loadingNames - } = useAPIMethod({ - fcn: client.apis.Names.fetchIOCByName, - call: false - }); + const [getNames, { data: names, isLoading: loadingNames }] = + useLazyFetchIocByNameQuery(); // create the ioc on form submit const onSubmit = (event) => { event.preventDefault(); - createIoc( - {}, - { - requestBody: { - gitProjectId: gitProject.id, - namingUuid: namingEntity ? namingEntity.uuid : undefined, - repository_name: repoName ? repoName : undefined - } + createIoc({ + createIoc: { + gitProjectId: gitProject.id, + namingUuid: namingEntity ? namingEntity.uuid : undefined, + repository_name: repoName ? repoName : undefined } - ); + }); }; const handleSelectRepoOption = (option) => { @@ -102,7 +69,7 @@ export function CreateIOC() { useEffect(() => { if (repoQuery && repoQuery.length > 2) { - getAllowedGitProjects(); + getAllowedGitProjects({ query: repoQuery }); } }, [repoQuery, getAllowedGitProjects]); @@ -163,7 +130,7 @@ export function CreateIOC() { </> ) }} - disabled={loading} + disabled={isLoading} helperText={namingEntity ? namingEntity.description : ""} /> )} @@ -205,7 +172,7 @@ export function CreateIOC() { </> ) }} - disabled={loading} + disabled={isLoading} /> )} autoSelect @@ -217,10 +184,8 @@ export function CreateIOC() { /> )} - {error ? ( - <Alert severity="error">{getErrorMessage(error)}</Alert> - ) : null} - {loading ? ( + {error && <ApiAlertError error={error} />} + {isLoading ? ( <LinearProgress aria-busy="true" aria-label="Creating IOC, please wait" @@ -233,7 +198,7 @@ export function CreateIOC() { <Button onClick={() => navigate("/")} color="primary" - disabled={loading} + disabled={isLoading} > Cancel </Button> @@ -242,7 +207,7 @@ export function CreateIOC() { variant="contained" type="submit" disabled={ - loading || !namingEntity || selectedRepoOption === WITHOUT_REPO + isLoading || !namingEntity || selectedRepoOption === WITHOUT_REPO ? !gitProject : !repoName } diff --git a/src/components/IOC/IOCDetails/IOCDetails.jsx b/src/components/IOC/IOCDetails/IOCDetails.jsx index 788df40cafbad417dde4aabaacf794e653f7d40d..e972ffa5d39e617f62be4046d0992e8d885bac19 100644 --- a/src/components/IOC/IOCDetails/IOCDetails.jsx +++ b/src/components/IOC/IOCDetails/IOCDetails.jsx @@ -1,13 +1,8 @@ -import { useMemo, useContext } from "react"; import { Grid, Box, Stack, Typography } from "@mui/material"; -import { - KeyValueTable, - useAPIMethod, - AlertBannerList -} from "@ess-ics/ce-ui-common"; -import { apiContext } from "../../../api/DeployApi"; +import { KeyValueTable, AlertBannerList } from "@ess-ics/ce-ui-common"; import { IOCStatus } from "../IOCStatus"; import { AccessControl } from "../../auth/AccessControl"; +import { useFetchIocAlertsQuery } from "../../../store/deployApi"; const defaultSubset = ({ name, @@ -21,19 +16,8 @@ const defaultSubset = ({ export function IOCDetails({ ioc, getSubset = defaultSubset, buttons }) { const subset = getSubset(ioc); - const client = useContext(apiContext); - const alertParams = useMemo( - () => ({ - ioc_id: ioc.id - }), - [ioc.id] - ); - - const { value: alert } = useAPIMethod({ - fcn: client.apis.IOCs.fetchIocAlerts, - params: alertParams - }); + const { data: alert } = useFetchIocAlertsQuery({ iocId: ioc.id }); return ( <> diff --git a/src/components/IOC/IOCStatus/IOCStatus.jsx b/src/components/IOC/IOCStatus/IOCStatus.jsx index b961ca4eccdcf7cd7458360c5400888f6c881637..d7515c7c5e07d1d35104529dfa90f38ea76586c5 100644 --- a/src/components/IOC/IOCStatus/IOCStatus.jsx +++ b/src/components/IOC/IOCStatus/IOCStatus.jsx @@ -1,45 +1,22 @@ -import { useContext, useMemo, useEffect } from "react"; +import { useEffect } from "react"; import { Grid } from "@mui/material"; -import { useAPIMethod } from "@ess-ics/ce-ui-common"; import { getIOCStatus } from "./IOCStatusData"; import { Status } from "../../common/Status"; -import { apiContext } from "../../../api/DeployApi"; - -function createRequest(id) { - return { - ioc_id: id - }; -} +import { + useFetchIocStatusQuery, + useLazyFetchIocAlertsQuery +} from "../../../store/deployApi"; export const IOCStatus = ({ id, hideAlerts }) => { - const client = useContext(apiContext); - - const params = useMemo(() => createRequest(id), [id]); + const [callFetchIocAlerts, { data: iocAlert }] = useLazyFetchIocAlertsQuery(); - const { - wrapper: callFetchIocAlerts, - value: iocAlert, - abort: abortCallFetchIocAlerts - } = useAPIMethod({ - fcn: client.apis.IOCs.fetchIocAlerts, - params, - call: false - }); - - const { value: iocStateStatus } = useAPIMethod({ - fcn: client.apis.IOCs.fetchIocStatus, - params - }); + const { data: iocStateStatus } = useFetchIocStatusQuery({ iocId: id }); useEffect(() => { if (!hideAlerts) { - callFetchIocAlerts(); + callFetchIocAlerts({ iocId: id }); } - - return () => { - abortCallFetchIocAlerts(); - }; - }, [hideAlerts, abortCallFetchIocAlerts, callFetchIocAlerts]); + }, [callFetchIocAlerts, hideAlerts, id]); return ( <Grid diff --git a/src/components/IOC/IOCTable/IOCDescription.jsx b/src/components/IOC/IOCTable/IOCDescription.jsx index b19e331b6f789e1db425f4bed4b264199f691e3a..d6fd1feb3bdcf5972e073d24a799cf61877fd1b5 100644 --- a/src/components/IOC/IOCTable/IOCDescription.jsx +++ b/src/components/IOC/IOCTable/IOCDescription.jsx @@ -1,9 +1,17 @@ -import { useContext, useMemo } from "react"; import { Skeleton } from "@mui/material"; -import { useAPIMethod, EllipsisText, EmptyValue } from "@ess-ics/ce-ui-common"; -import { apiContext } from "../../../api/DeployApi"; +import { EllipsisText, EmptyValue } from "@ess-ics/ce-ui-common"; +import { useGetIocDescriptionQuery } from "../../../store/deployApi"; + +export const IOCDescription = ({ id }) => { + const { data: iocDescriptionResponse, isLoading } = useGetIocDescriptionQuery( + { iocId: id } + ); + let description = iocDescriptionResponse?.description; + + if (isLoading || !iocDescriptionResponse) { + return <Skeleton width="100%" />; + } -function createIocDescription(description) { return ( <> {description ? ( @@ -13,30 +21,4 @@ function createIocDescription(description) { )} </> ); -} - -export const IOCDescription = ({ id }) => { - const client = useContext(apiContext); - - const params = useMemo( - () => ({ - ioc_id: id - }), - [id] - ); - - const { - value: iocDescriptionResponse, - loading, - dataReady - } = useAPIMethod({ - fcn: client.apis.IOCs.getIocDescription, - params - }); - - if (loading || !dataReady) { - return <Skeleton width="100%" />; - } - - return createIocDescription(iocDescriptionResponse?.description); }; diff --git a/src/components/auth/TokenRenew/TokenRenew.jsx b/src/components/auth/TokenRenew/TokenRenew.jsx index 8b79b7880b13396540c1d87d2a9d5f84a4e7ff3e..845e79eeb08f4d27919b70c43f69d5c3d4c40571 100644 --- a/src/components/auth/TokenRenew/TokenRenew.jsx +++ b/src/components/auth/TokenRenew/TokenRenew.jsx @@ -1,32 +1,22 @@ -import { useContext, useCallback } from "react"; -import { userContext, useAPIMethod, usePolling } from "@ess-ics/ce-ui-common"; -import { apiContext } from "../../../api/DeployApi"; +import { useContext, useEffect } from "react"; +import { userContext } from "@ess-ics/ce-ui-common"; import env from "../../../config/env"; +import { useTokenRenewMutation } from "../../../store/deployApi"; export const TokenRenew = () => { const { user } = useContext(userContext); - const client = useContext(apiContext); - const { - wrapper: renewToken, - loading, - abort - } = useAPIMethod({ - fcn: client.apis.Authentication.tokenRenew, - call: false - }); + const [renewToken] = useTokenRenewMutation(); - const renewTokenWhenLoggedIn = useCallback(() => { + useEffect(() => { if (user) { - renewToken(); + const intervalId = setInterval(() => { + renewToken(); + }, env.TOKEN_RENEW_INTERVAL); - return () => { - abort(); - }; + return () => clearInterval(intervalId); } - }, [user, renewToken, abort]); - - usePolling(renewTokenWhenLoggedIn, loading, env.TOKEN_RENEW_INTERVAL, abort); + }, [user, renewToken]); return null; }; diff --git a/src/components/common/Helper.jsx b/src/components/common/Helper.jsx index 97cbb18763c99d978ea568113f34a63f66751c79..24f0431342975f83f13b299235f2b2e17596863b 100644 --- a/src/components/common/Helper.jsx +++ b/src/components/common/Helper.jsx @@ -84,9 +84,9 @@ export function initRequestParams(lazyParams, filter, columnSort) { if (columnSort) { if (columnSort.sortOrder === 1) { - requestParams.order_asc = true; + requestParams.orderAsc = true; } else { - requestParams.order_asc = false; + requestParams.orderAsc = false; } } diff --git a/src/components/common/User/UserAvatar.jsx b/src/components/common/User/UserAvatar.jsx index 14d47ced787520dde6f1484222a45f13ba92cabe..c9e7d3f8ae0b5aaedce0b569cf77701bdd3f9b0f 100644 --- a/src/components/common/User/UserAvatar.jsx +++ b/src/components/common/User/UserAvatar.jsx @@ -1,65 +1,13 @@ -import { useAPIMethod, userContext } from "@ess-ics/ce-ui-common"; import { Avatar, Tooltip, styled } from "@mui/material"; -import { useContext, useEffect, useMemo } from "react"; import { Link } from "react-router-dom"; -import { apiContext } from "../../../api/DeployApi"; - -const unpacker = (data) => { - if (data) { - return data[0]; - } else { - return null; - } -}; +import { useInfoFromUserNameQuery } from "../../../store/deployApi"; export const UserAvatar = styled(({ username, size = 48 }) => { - const { user: userChanged } = useContext(userContext); - - const client = useContext(apiContext); - - const params = useMemo( - () => ({ - user_name: username - }), - [username] - ); - const { - value: user, - wrapper: getUser, - loading, + data: user, + isLoading, error - } = useAPIMethod({ - fcn: client.apis.Git.infoFromUserName, - params, - unpacker - }); - - useEffect(() => { - getUser(); - }, [userChanged, getUser]); - - const renderedAvatar = (() => { - if (error || loading || !user?.avatar) { - return ( - <Avatar - sx={{ width: size, height: size }} - alt={username} - variant="circle" - > - {username.toUpperCase().slice(0, 2)} - </Avatar> - ); - } - return ( - <Avatar - sx={{ width: size, height: size }} - src={user?.avatar} - alt={username} - variant="circle" - /> - ); - })(); + } = useInfoFromUserNameQuery({ userName: username }); return ( <Tooltip title={username}> @@ -68,7 +16,22 @@ export const UserAvatar = styled(({ username, size = 48 }) => { style={{ textDecoration: "none" }} aria-label={`User Profile ${username}`} > - {renderedAvatar} + {error || isLoading || !user?.avatar ? ( + <Avatar + sx={{ width: size, height: size }} + alt={username} + variant="circle" + > + {username.toUpperCase().slice(0, 2)} + </Avatar> + ) : ( + <Avatar + sx={{ width: size, height: size }} + src={user?.avatar} + alt={username} + variant="circle" + /> + )} </Link> </Tooltip> ); diff --git a/src/components/common/User/UserOperationList.jsx b/src/components/common/User/UserOperationList.jsx index 7dcd062f3721e02924f60aa562952694c34f4d84..2302c5499991e4b1fa7a3e1c17cbdecd3feb1786 100644 --- a/src/components/common/User/UserOperationList.jsx +++ b/src/components/common/User/UserOperationList.jsx @@ -1,23 +1,17 @@ -import { useCallback, useContext, useEffect } from "react"; +import { useCallback, useEffect } from "react"; import { Card, CardHeader } from "@mui/material"; -import { useAPIMethod, usePagination, usePolling } from "@ess-ics/ce-ui-common"; +import { usePagination } from "@ess-ics/ce-ui-common"; import { initRequestParams } from "../Helper"; import { JobTable } from "../../Job"; -import { apiContext } from "../../../api/DeployApi"; -import { ROWS_PER_PAGE } from "../../../constants"; +import { + DEFAULT_POLLING_INTERVAL_MILLIS, + ROWS_PER_PAGE +} from "../../../constants"; +import { useLazyListJobsQuery } from "../../../store/deployApi"; export function UserOperationList({ userName }) { - const client = useContext(apiContext); - - const { - value: jobs, - wrapper: getJobs, - loading, - dataReady, - abort - } = useAPIMethod({ - fcn: client.apis.Jobs.listJobs, - call: false + const [getJobs, { data: jobs, isFetching }] = useLazyListJobsQuery({ + pollingInterval: DEFAULT_POLLING_INTERVAL_MILLIS }); const { pagination, setPagination } = usePagination({ @@ -41,18 +35,11 @@ export function UserOperationList({ userName }) { useEffect(() => { callGetjobs(); - - return () => { - abort(); - }; - }, [callGetjobs, abort]); - - usePolling(callGetjobs, loading, 30000, abort); + }, [callGetjobs]); // Invoked by Table on change to pagination const onPage = (params) => { setPagination(params); - abort(); }; return ( @@ -67,7 +54,7 @@ export function UserOperationList({ userName }) { <JobTable jobs={jobs?.jobs} customColumns={[{ field: "status" }, { field: "job" }]} - loading={loading || !dataReady} + loading={isFetching || !jobs} pagination={pagination} onPage={onPage} rowType="userPageJobLog" diff --git a/src/components/host/HostStatus/HostStatus.jsx b/src/components/host/HostStatus/HostStatus.jsx index d3e5d052f3024ca1249f77157ea23ebcf1181ed3..990ce26e2788ce54ae8189b2b6c0d4a62b59f53d 100644 --- a/src/components/host/HostStatus/HostStatus.jsx +++ b/src/components/host/HostStatus/HostStatus.jsx @@ -1,43 +1,22 @@ -import { useContext, useMemo, useEffect } from "react"; -import { useAPIMethod } from "@ess-ics/ce-ui-common"; +import { useEffect } from "react"; import { getHostStatus } from "./HostStatusData"; -import { apiContext } from "../../../api/DeployApi"; import { Status } from "../../common/Status"; - -function createRequest(hostId) { - return { - host_id: hostId - }; -} +import { + useFindHostStatusQuery, + useLazyFindHostAlertsQuery +} from "../../../store/deployApi"; export const HostStatus = ({ hostId, hideAlerts }) => { - const client = useContext(apiContext); - const params = useMemo(() => createRequest(hostId), [hostId]); - - const { - wrapper: callFetchHostAlerts, - value: hostAlert, - abort: abortCallFetchHostAlerts - } = useAPIMethod({ - fcn: client.apis.Hosts.findHostAlerts, - params, - call: false - }); + const [callFindHostAlerts, { data: hostAlert }] = + useLazyFindHostAlertsQuery(); - const { value: hostStateStatus } = useAPIMethod({ - fcn: client.apis.Hosts.findHostStatus, - params - }); + const { data: hostStateStatus } = useFindHostStatusQuery({ hostId }); useEffect(() => { if (!hideAlerts) { - callFetchHostAlerts(); + callFindHostAlerts({ hostId }); } - - return () => { - abortCallFetchHostAlerts(); - }; - }, [hideAlerts, callFetchHostAlerts, abortCallFetchHostAlerts]); + }, [callFindHostAlerts, hideAlerts, hostId]); return ( <> diff --git a/src/components/navigation/NavigationMenu/NavigationMenu.jsx b/src/components/navigation/NavigationMenu/NavigationMenu.jsx index 49930428927520777660e4c5c95639c10401a33e..64aca3d435ac5285d244b2e8e302f7f9d1b28829 100644 --- a/src/components/navigation/NavigationMenu/NavigationMenu.jsx +++ b/src/components/navigation/NavigationMenu/NavigationMenu.jsx @@ -1,11 +1,10 @@ -import { Fragment, useState, useContext, useEffect } from "react"; +import { Fragment, useState } from "react"; import { GlobalAppBar, IconMenuButton, EssIconLogo, BodyBox, useUniqueKeys, - useAPIMethod, MaintenanceModeBanner } from "@ess-ics/ce-ui-common"; import { @@ -30,7 +29,7 @@ import { CreateIOCButton } from "./CreateIOCButton"; import { applicationTitle } from "../../common/Helper"; import { CCCEControlSymbol } from "../../../icons/CCCEControlSymbol"; import { theme } from "../../../style/Theme"; -import { apiContext } from "../../../api/DeployApi"; +import { useGetCurrentModeQuery } from "../../../store/deployApi"; function MenuListItem({ url, icon, text, tooltip }) { const currentUrl = `${window.location}`; @@ -109,22 +108,7 @@ export const NavigationMenu = ({ children }) => { ] }; - const client = useContext(apiContext); - const { - value: maintenanceMode, - wrapper: getMode, - abort: abortGetMode - } = useAPIMethod({ - fcn: client.apis.Maintenance.getCurrentMode, - call: false - }); - - useEffect(() => { - getMode(); - return () => { - abortGetMode(); - }; - }, [abortGetMode, getMode]); + const { data: maintenanceMode } = useGetCurrentModeQuery(); const args = { defaultHomeButton: ( diff --git a/src/components/records/RecordHostLink.jsx b/src/components/records/RecordHostLink.jsx index 339544a3dd3640118af42ba1c3aff99259ccdb3e..d2d80c10720c91d24284cb71d92817d73473e16d 100644 --- a/src/components/records/RecordHostLink.jsx +++ b/src/components/records/RecordHostLink.jsx @@ -1,18 +1,6 @@ -import { useContext, useMemo } from "react"; import { Grid, Skeleton, Typography } from "@mui/material"; -import { - useAPIMethod, - InternalLink, - EllipsisText, - EmptyValue -} from "@ess-ics/ce-ui-common"; -import { apiContext } from "../../api/DeployApi"; - -function createRequest(fqdn) { - return { - fqdn: fqdn - }; -} +import { InternalLink, EllipsisText, EmptyValue } from "@ess-ics/ce-ui-common"; +import { useFindNetBoxHostByFqdnQuery } from "../../store/deployApi"; function shortenFqdn(fqdn) { return fqdn.split(".")[0]; @@ -41,18 +29,7 @@ function createHostLink(fqdn, hostInfo) { } export const RecordHostLink = ({ fqdn }) => { - const client = useContext(apiContext); - - const params = useMemo(() => createRequest(fqdn), [fqdn]); - - const { - value: hostInfo, - loading, - dataReady - } = useAPIMethod({ - fcn: client.apis.Hosts.findNetBoxHostByFqdn, - params - }); + const { data: hostInfo, isLoading } = useFindNetBoxHostByFqdnQuery({ fqdn }); return ( <Grid @@ -61,7 +38,7 @@ export const RecordHostLink = ({ fqdn }) => { justifyContent="center" alignItems="center" > - {loading || !dataReady ? ( + {isLoading || !hostInfo ? ( <Skeleton width="100%" /> ) : ( createHostLink(fqdn, hostInfo) diff --git a/src/constants/index.ts b/src/constants/index.ts index 8de6913d72fe11b847a3b8302e83aed3017b2a46..26ae5f5ab0a6e590edc6a267fa03172e6b2e80a6 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -1 +1,2 @@ export const ROWS_PER_PAGE = [20, 50]; +export const DEFAULT_POLLING_INTERVAL_MILLIS = 3000; diff --git a/src/views/UserPage/UserDetailsContainer.jsx b/src/views/UserPage/UserDetailsContainer.jsx index c913353c7b7bf636bd35a9d4ed444a3b1c63d0c2..9fdaf9e9617496dfa2ee5c58387f1267ec7a3af7 100644 --- a/src/views/UserPage/UserDetailsContainer.jsx +++ b/src/views/UserPage/UserDetailsContainer.jsx @@ -1,43 +1,20 @@ -import { useContext, useEffect, useMemo, useState } from "react"; +import { useContext, useEffect, useState } from "react"; import { LinearProgress } from "@mui/material"; import { useParams } from "react-router-dom"; -import { userContext, useAPIMethod } from "@ess-ics/ce-ui-common"; +import { userContext } from "@ess-ics/ce-ui-common"; import { UserPageView } from "./UserPageView"; import { NotFoundView } from "../../components/navigation/NotFoundView/NotFoundView"; -import { apiContext } from "../../api/DeployApi"; - -function unpackUser(user) { - if (user?.length > 0) { - return { ...user[0] }; - } else { - return {}; - } -} +import { useLazyInfoFromUserNameQuery } from "../../store/deployApi"; export function UserDetailsContainer() { const { userName } = useParams(); const { user } = useContext(userContext); const [error, setError] = useState(null); - const client = useContext(apiContext); - - const params = useMemo( - () => ({ - user_name: userName - }), - [userName] - ); - - const { - value: userInfo, - wrapper: getUserInfo, - error: userInfoResponseError - } = useAPIMethod({ - fcn: client.apis.Git.infoFromUserName, - call: true, - params, - unpacker: unpackUser - }); + const [ + getUserInfo, + { data: userInfo, isLoading, error: userInfoResponseError } + ] = useLazyInfoFromUserNameQuery(); useEffect(() => { if (userInfoResponseError) { @@ -57,10 +34,14 @@ export function UserDetailsContainer() { // user logs in => clear error message, and try to re-request userInfo if (user) { setError(null); - getUserInfo(); + getUserInfo({ userName }); } }, [user, userName, getUserInfo]); + if (isLoading || !userInfo) { + return <LinearProgress color="primary" />; + } + // If there is an error, show it; highest priority if (error) { return ( @@ -82,7 +63,4 @@ export function UserDetailsContainer() { /> ); } - - // Otherwise assume loading (smoothest experience / least flickering) - return <LinearProgress color="primary" />; } diff --git a/src/views/host/HostListView.jsx b/src/views/host/HostListView.jsx index 7327096168919d7b27939859500afd02b5ae818b..f3a3af7630beb8fa41e93a804b21cf4d5f9b9202 100644 --- a/src/views/host/HostListView.jsx +++ b/src/views/host/HostListView.jsx @@ -3,7 +3,6 @@ import { Container, Grid, Tabs, Tab } from "@mui/material"; import { GlobalAppBarContext, RootPaper, - useAPIMethod, usePagination, SearchBar } from "@ess-ics/ce-ui-common"; @@ -13,24 +12,15 @@ import { applicationTitle, initRequestParams } from "../../components/common/Helper"; -import { apiContext } from "../../api/DeployApi"; import { ROWS_PER_PAGE } from "../../constants"; +import { useLazyListHostsQuery } from "../../store/deployApi"; export function HostListView() { const { setTitle } = useContext(GlobalAppBarContext); useEffect(() => setTitle(applicationTitle("IOC hosts")), [setTitle]); - const client = useContext(apiContext); - const { - value: hosts, - wrapper: getHosts, - dataReady, - loading, - abort - } = useAPIMethod({ - fcn: client.apis.Hosts.listHosts, - call: false - }); + const [callListHostsQuery, { data: hosts, isFetching }] = + useLazyListHostsQuery(); const [searchParams, setSearchParams] = useSearchParams({ query: "" }); const [tabIndex, setTabIndex] = useState(0); @@ -63,12 +53,8 @@ export function HostListView() { let requestParams = initRequestParams(pagination); requestParams.filter = hostFilter; requestParams.text = searchParams.get("query"); - getHosts(requestParams); - - return () => { - abort(); - }; - }, [getHosts, hostFilter, searchParams, pagination, abort]); + callListHostsQuery(requestParams); + }, [callListHostsQuery, hostFilter, searchParams, pagination]); // Callback for searchbar, called whenever user updates search const setSearch = useCallback( @@ -81,18 +67,17 @@ export function HostListView() { // Invoked by Table on change to pagination const onPage = (params) => { setPagination(params); - abort(); }; const content = ( <SearchBar search={setSearch} query={searchParams.get("query")} - loading={loading} + loading={isFetching} > <HostTable hosts={hosts?.netBoxHosts ?? []} - loading={loading || !dataReady} + loading={isFetching || !hosts} pagination={pagination} onPage={onPage} /> diff --git a/src/views/host/details/HostDetailsContainer.jsx b/src/views/host/details/HostDetailsContainer.jsx index 2c9e0903c5d31edcf6ec1f7d66261c6c4b5db3d3..98b45b61c436a5798b014b37cb86eeafdc679865 100644 --- a/src/views/host/details/HostDetailsContainer.jsx +++ b/src/views/host/details/HostDetailsContainer.jsx @@ -1,47 +1,26 @@ -import { useState, useMemo, useContext, useEffect } from "react"; +import { useState, useEffect } from "react"; import { LinearProgress } from "@mui/material"; -import { useAPIMethod } from "@ess-ics/ce-ui-common"; import { HostDetailsView } from "./HostDetailsView"; import { onFetchEntityError } from "../../../components/common/Helper"; import { NotFoundView } from "../../../components/navigation/NotFoundView/NotFoundView"; -import { apiContext } from "../../../api/DeployApi"; +import { + useFindHostAlertsQuery, + useFindNetBoxHostByHostIdQuery +} from "../../../store/deployApi"; export function HostDetailsContainer({ hostId }) { const [error, setError] = useState(null); - const client = useContext(apiContext); - const alertParams = useMemo( - () => ({ - host_id: hostId - }), - [hostId] - ); - - const hostParams = useMemo( - () => ({ - host_id: hostId - }), - [hostId] - ); - - const { value: host, error: fetchError } = useAPIMethod({ - fcn: client.apis.Hosts.findNetBoxHostByHostId, - params: hostParams - }); - - const { value: alert, error: alertError } = useAPIMethod({ - fcn: client.apis.Hosts.findHostAlerts, - params: alertParams + const { data: host, error: fetchError } = useFindNetBoxHostByHostIdQuery({ + hostId }); + const { data: alert, error: alertError } = useFindHostAlertsQuery({ hostId }); useEffect(() => { if (fetchError || alertError) { - const message = fetchError?.message - ? fetchError.message - : alertError.message; - const status = fetchError?.status - ? fetchError.status - : alertError?.status; + const message = + fetchError?.data?.description || alertError?.data?.description; + const status = fetchError?.status || alertError?.status; onFetchEntityError(message, status, setError); } }, [fetchError, alertError]); diff --git a/src/views/host/details/HostIocSection.jsx b/src/views/host/details/HostIocSection.jsx index 3566587bab2df49699de618aa5d07ac5fca9048d..5dea79438760b562a66b6c23c88b828af15e5013 100644 --- a/src/views/host/details/HostIocSection.jsx +++ b/src/views/host/details/HostIocSection.jsx @@ -1,48 +1,37 @@ -import { useEffect, useContext, useCallback } from "react"; +import { useEffect, useCallback } from "react"; import { string } from "prop-types"; import { Typography } from "@mui/material"; -import { useAPIMethod, usePagination } from "@ess-ics/ce-ui-common"; -import { apiContext } from "../../../api/DeployApi"; +import { usePagination } from "@ess-ics/ce-ui-common"; import { IOCTable } from "../../../components/IOC/IOCTable"; import { initRequestParams } from "../../../components/common/Helper"; import { ROWS_PER_PAGE } from "../../../constants"; +import { useLazyFindAssociatedIocsByHostIdQuery } from "../../../store/deployApi"; const propTypes = { hostId: string }; export const HostIocSection = ({ hostId }) => { - const client = useContext(apiContext); - const { pagination, setPagination, setTotalCount } = usePagination({ initLimit: ROWS_PER_PAGE[0], initPage: 0 }); - const { - value: iocs, - wrapper: callgetIocs, - loading, - dataReady, - abort: abortGetIocs - } = useAPIMethod({ - fcn: client.apis.Hosts.findAssociatedIocsByHostId, - call: false - }); + const [callGetIocs, { data: iocs, isLoading }] = + useLazyFindAssociatedIocsByHostIdQuery(); const onPage = useCallback( (params) => { setPagination(params); - abortGetIocs(); }, - [setPagination, abortGetIocs] + [setPagination] ); const getIocs = useCallback(() => { let requestParams = initRequestParams(pagination, null); - requestParams.host_id = hostId; - callgetIocs(requestParams); - }, [callgetIocs, hostId, pagination]); + requestParams.hostId = hostId; + callGetIocs(requestParams); + }, [callGetIocs, hostId, pagination]); // update pagination whenever search result total pages change useEffect(() => { @@ -51,11 +40,7 @@ export const HostIocSection = ({ hostId }) => { useEffect(() => { getIocs(); - - return () => { - abortGetIocs(); - }; - }, [abortGetIocs, getIocs]); + }, [getIocs]); return ( <> @@ -67,7 +52,7 @@ export const HostIocSection = ({ hostId }) => { </Typography> <IOCTable iocs={iocs?.deployedIocs || []} - loading={loading || !dataReady} + loading={isLoading || !iocs} rowType="host" pagination={pagination} onPage={onPage} diff --git a/src/views/host/details/HostJobsSection.jsx b/src/views/host/details/HostJobsSection.jsx index 7f6dd88613c47862a5aae8b982a81e9e7810800e..4dd305693ada73830a5d221c4503370c5809e449 100644 --- a/src/views/host/details/HostJobsSection.jsx +++ b/src/views/host/details/HostJobsSection.jsx @@ -1,23 +1,17 @@ -import { useContext, useEffect, useMemo, useCallback, useState } from "react"; +import { useEffect, useMemo, useCallback, useState } from "react"; import { string } from "prop-types"; -import { - SimpleAccordion, - useAPIMethod, - usePagination -} from "@ess-ics/ce-ui-common"; -import { Alert, Typography } from "@mui/material"; -import { getErrorMessage } from "../../../components/common/Helper"; -import { apiContext } from "../../../api/DeployApi"; +import { SimpleAccordion, usePagination } from "@ess-ics/ce-ui-common"; +import { Typography } from "@mui/material"; import { JobTable } from "../../../components/Job"; import { ROWS_PER_PAGE } from "../../../constants"; +import { useLazyListJobsQuery } from "../../../store/deployApi"; +import { ApiAlertError } from "../../../components/common/Alerts/ApiAlertError"; const propTypes = { hostId: string.isRequired }; export const HostJobsSection = ({ hostId }) => { - const client = useContext(apiContext); - const { pagination, setPagination } = usePagination({ rowsPerPageOptions: ROWS_PER_PAGE, initLimit: ROWS_PER_PAGE[0], @@ -25,46 +19,31 @@ export const HostJobsSection = ({ hostId }) => { }); const [expanded, setExpanded] = useState(false); - const { - value: hostLog, - wrapper: getHostLog, - dataReady, - error, - loading, - abort: abortGetHostLog - } = useAPIMethod({ - fcn: client.apis.Jobs.listJobs, - call: false, - params: useMemo( - () => ({ host_id: hostId, ...pagination }), - [hostId, pagination] - ) - }); + const [getHostLog, { data: hostLog, error, isLoading }] = + useLazyListJobsQuery(); + + const params = useMemo( + () => ({ hostId, ...pagination }), + [hostId, pagination] + ); + + useEffect(() => { + if (expanded) { + getHostLog(params); + } + }, [expanded, pagination, getHostLog, params]); const onPage = useCallback( (params) => { setPagination(params); - abortGetHostLog(); }, - [setPagination, abortGetHostLog] + [setPagination] ); useEffect(() => { setPagination({ totalCount: hostLog?.totalCount ?? 0 }); }, [setPagination, hostLog]); - useEffect(() => { - if (expanded) { - getHostLog(); - } - }, [expanded, pagination, getHostLog]); - - useEffect(() => { - return () => { - abortGetHostLog(); - }; - }, [abortGetHostLog]); - return ( <SimpleAccordion expanded={expanded} @@ -78,7 +57,7 @@ export const HostJobsSection = ({ hostId }) => { } onChange={(_, expanded) => setExpanded(expanded)} > - {error ? <Alert severity="error">{getErrorMessage(error)}</Alert> : null} + {error && <ApiAlertError error={error} />} {hostLog ? ( <JobTable jobs={!error && hostLog ? hostLog?.jobs : null} @@ -87,7 +66,7 @@ export const HostJobsSection = ({ hostId }) => { { field: "job" }, { field: "user" } ]} - loading={loading || !dataReady} + loading={isLoading || !hostLog} pagination={pagination} onPage={onPage} /> diff --git a/src/views/jobs/JobDetailsContainer.jsx b/src/views/jobs/JobDetailsContainer.jsx index 9f73f8897d2939f76a87fee8070f787d3d533407..6953ade83f21129b79656b7ff5457c13652f3c60 100644 --- a/src/views/jobs/JobDetailsContainer.jsx +++ b/src/views/jobs/JobDetailsContainer.jsx @@ -1,41 +1,42 @@ -import { useState, useContext, useMemo, useEffect } from "react"; +import { useState, useEffect } from "react"; import { LinearProgress } from "@mui/material"; -import { useAPIMethod, usePolling } from "@ess-ics/ce-ui-common"; import { JobDetailsView } from "./JobDetailsView"; import { NotFoundView } from "../../components/navigation/NotFoundView/NotFoundView"; import { onFetchEntityError } from "../../components/common/Helper"; -import { apiContext } from "../../api/DeployApi"; +import { useFetchJobQuery } from "../../store/deployApi"; const POLL_DEPLOYMENT_INTERVAL = 5000; export function JobDetailsContainer({ id }) { - const [notFoundError, setNotFoundError] = useState(); + const [notFoundError, setNotFoundError] = useState(null); + const [jobFinished, setJobFinished] = useState(false); - const client = useContext(apiContext); - - const jobDetailParams = useMemo( - () => ({ - job_id: id - }), - [id] - ); const { - value: job, - wrapper: getJob, + data: job, + isLoading, error: jobError - } = useAPIMethod({ - fcn: client.apis.Jobs.fetchJob, - call: false, - params: jobDetailParams - }); + } = useFetchJobQuery( + { jobId: id }, + { + pollingInterval: !jobFinished && POLL_DEPLOYMENT_INTERVAL + } + ); + + useEffect(() => { + if (job?.finishedAt) { + setJobFinished(true); + } + }, [job?.finishedAt]); useEffect(() => { if (jobError) { - onFetchEntityError(jobError?.message, jobError?.status, setNotFoundError); + onFetchEntityError( + jobError?.data?.description, + jobError?.status, + setNotFoundError + ); } }, [jobError]); - usePolling(getJob, job?.finishedAt, POLL_DEPLOYMENT_INTERVAL); - if (notFoundError) { return ( <NotFoundView @@ -45,7 +46,7 @@ export function JobDetailsContainer({ id }) { ); } - if (!job) { + if (isLoading || !job) { return <LinearProgress color="primary" />; } diff --git a/src/views/jobs/JobListView.jsx b/src/views/jobs/JobListView.jsx index 3f5e5fef22ceaf6bd8f7a72f1722eef4a955b7f0..2f2155ccafb4941f02f5ef6850bbb4d83adcd326 100644 --- a/src/views/jobs/JobListView.jsx +++ b/src/views/jobs/JobListView.jsx @@ -1,29 +1,13 @@ -import { useContext, useCallback, useEffect } from "react"; +import { useCallback, useEffect } from "react"; import { Box } from "@mui/material"; -import { - RootPaper, - useAPIMethod, - usePagination, - usePolling -} from "@ess-ics/ce-ui-common"; +import { RootPaper, usePagination } from "@ess-ics/ce-ui-common"; import { initRequestParams } from "../../components/common/Helper"; import { JobTable } from "../../components/Job/JobTable"; -import { apiContext } from "../../api/DeployApi"; import { ROWS_PER_PAGE } from "../../constants"; +import { useLazyListJobsQuery } from "../../store/deployApi"; export function JobListView() { - const client = useContext(apiContext); - - const { - value: jobs, - wrapper: getJobs, - loading, - dataReady, - abort - } = useAPIMethod({ - fcn: client.apis.Jobs.listJobs, - call: false - }); + const [getJobs, { data: jobs, isLoading }] = useLazyListJobsQuery(); const { pagination, setPagination } = usePagination({ rowsPerPageOptions: ROWS_PER_PAGE, @@ -44,18 +28,11 @@ export function JobListView() { // Request new search results whenever search or pagination changes useEffect(() => { callGetOperations(); - - return () => { - abort(); - }; - }, [abort, callGetOperations, pagination]); - - usePolling(callGetOperations, loading, 30000, abort, false); + }, [callGetOperations, pagination]); // Invoked by Table on change to pagination const onPage = (params) => { setPagination(params); - abort(); }; return ( @@ -65,7 +42,7 @@ export function JobListView() { jobs={jobs?.jobs} pagination={pagination} onPage={onPage} - loading={loading || !dataReady} + loading={isLoading || !jobs} /> </Box> </RootPaper> diff --git a/src/views/records/RecordDetailsView.jsx b/src/views/records/RecordDetailsView.jsx index e83906fa408449039ef29c2970773cf4cff6df0c..fd71b905c0da3dcc0c725c49579546698b58afe9 100644 --- a/src/views/records/RecordDetailsView.jsx +++ b/src/views/records/RecordDetailsView.jsx @@ -1,4 +1,4 @@ -import { useEffect, useCallback, useState, useContext, useMemo } from "react"; +import { useEffect, useCallback, useState, useContext } from "react"; import { IconButton, Typography, LinearProgress } from "@mui/material"; import ArrowBackIcon from "@mui/icons-material/ArrowBack"; import { @@ -6,7 +6,6 @@ import { KeyValueTable, GlobalAppBarContext, InternalLink, - useAPIMethod, formatDateAndTime, EmptyValue } from "@ess-ics/ce-ui-common"; @@ -19,35 +18,28 @@ import { } from "../../components/common/Helper"; import { NotFoundView } from "../../components/navigation/NotFoundView/NotFoundView"; -import { apiContext } from "../../api/DeployApi"; +import { useGetRecordQuery } from "../../store/deployApi"; export function RecordDetailsView() { const { name } = useParams(); const decodedName = decodeURIComponent(name); const [error, setError] = useState(null); - const client = useContext(apiContext); - - const params = useMemo( - () => ({ - name: decodedName - }), - [decodedName] - ); - const { - value: record, + data: record, error: fetchError, - loading: recordLoading, - dataReady - } = useAPIMethod({ - fcn: client.apis.Records.getRecord, - params + isLoading: recordLoading + } = useGetRecordQuery({ + name: decodedName }); useEffect(() => { if (fetchError) { - onFetchEntityError(fetchError?.message, fetchError?.status, setError); + onFetchEntityError( + fetchError?.data?.description, + fetchError?.status, + setError + ); } }, [fetchError]); @@ -112,7 +104,7 @@ export function RecordDetailsView() { return <NotFoundView />; } - if (recordLoading || !dataReady) { + if (recordLoading || !record) { return ( <RootPaper> <LinearProgress color="primary" /> diff --git a/src/views/records/RecordListView.jsx b/src/views/records/RecordListView.jsx index 5c5f5767d62bf606136cd2391a2e32215bb36c54..824aa6b775315132cdcc1c13386ad19c28f2d6ef 100644 --- a/src/views/records/RecordListView.jsx +++ b/src/views/records/RecordListView.jsx @@ -4,7 +4,6 @@ import { Container, Grid, Tabs, Tab } from "@mui/material"; import { GlobalAppBarContext, RootPaper, - useAPIMethod, usePagination, SearchBar } from "@ess-ics/ce-ui-common"; @@ -13,34 +12,24 @@ import { initRequestParams } from "../../components/common/Helper"; import { RecordTable } from "../../components/records/RecordTable"; -import { apiContext } from "../../api/DeployApi"; import { ROWS_PER_PAGE } from "../../constants"; +import { useLazyFindAllRecordsQuery } from "../../store/deployApi"; export function RecordListView() { const { setTitle } = useContext(GlobalAppBarContext); useEffect(() => setTitle(applicationTitle("Records")), [setTitle]); - const client = useContext(apiContext); - - const { - value: records, - wrapper: getRecords, - loading, - dataReady, - abort - } = useAPIMethod({ - fcn: client.apis.Records.findAllRecords, - call: false - }); + const [getRecords, { data: records, isFetching }] = + useLazyFindAllRecordsQuery(); const [searchParams, setSearchParams] = useSearchParams({ query: "" }); const [tabIndex, setTabIndex] = useState(0); - const [recordFilter, setRecordFilter] = useState(null); + const [recordFilter, setRecordFilter] = useState(""); // used to request record list again when tab is switched, but request it only once! (totalRecord is a random number that is generated by ChannelFinder) const handleTabChange = (tab) => { if (tab === 0) { - setRecordFilter(null); + setRecordFilter(""); } else if (tab === 1) { setRecordFilter("ACTIVE"); } else if (tab === 2) { @@ -63,14 +52,10 @@ export function RecordListView() { // Request new search results whenever search or pagination changes useEffect(() => { let requestParams = initRequestParams(pagination); - requestParams.pv_status = recordFilter; + requestParams.pvStatus = recordFilter; requestParams.text = searchParams.get("query"); getRecords(requestParams); - - return () => { - abort(); - }; - }, [getRecords, recordFilter, pagination, abort, searchParams]); + }, [getRecords, recordFilter, pagination, searchParams]); // Callback for searchbar, called whenever user updates search const setSearch = useCallback( @@ -83,18 +68,17 @@ export function RecordListView() { // Invoked by Table on change to pagination const onPage = (params) => { setPagination(params); - abort(); }; let content = ( <SearchBar search={setSearch} query={searchParams.get("query")} - loading={loading || !dataReady} + loading={isFetching || !records} > <RecordTable records={records} - loading={loading || !dataReady} + loading={isFetching || !records} pagination={pagination} onPage={onPage} />