diff --git a/src/components/IOC/CreateIOC/CreateIOC.js b/src/components/IOC/CreateIOC/CreateIOC.js index 67b04503d9b2af994029ad3325acb9c69cd3f87d..150cef1a42b96708977367940b242d04699904c9 100644 --- a/src/components/IOC/CreateIOC/CreateIOC.js +++ b/src/components/IOC/CreateIOC/CreateIOC.js @@ -14,18 +14,7 @@ import { } from "@mui/material"; import { useAPIMethod } from "@ess-ics/ce-ui-common"; import { apiContext } from "../../../api/DeployApi"; - -const renderErrorMessage = (error) => { - const { response, status: requestStatus, message: requestMessage } = error; - const { - body: { description }, - status: httpStatus - } = response; - - return `${httpStatus ?? requestStatus ?? "unknown"}: ${ - description ?? requestMessage ?? "An unknown error has occurred" - }`; -}; +import { getErrorMessage } from "../../common/Helper"; const createRequestParams = (query) => { return { @@ -211,7 +200,7 @@ export function CreateIOC() { autoSelect /> {error ? ( - <Alert severity="error">{renderErrorMessage(error)}</Alert> + <Alert severity="error">{getErrorMessage(error)}</Alert> ) : ( <></> )} diff --git a/src/components/Job/JobTable/JobTable.js b/src/components/Job/JobTable/JobTable.js index 8674a56460655502696f52eb2250867ea7aa8c0d..546525a6ef11325568f84d2dcf4032582d115593 100644 --- a/src/components/Job/JobTable/JobTable.js +++ b/src/components/Job/JobTable/JobTable.js @@ -1,18 +1,19 @@ -import React from "react"; +import React, { useState, useEffect } from "react"; import { Table } from "@ess-ics/ce-ui-common"; import { JobStatusColumn } from "./JobStatusColumn"; import { JobSummaryColumn } from "./JobSummaryColumn"; import { UserAvatar } from "../../common/User/UserAvatar"; import { JobHostColumn } from "./JobHostColumn"; -const jobLogColumns = [ +const defaultColumns = [ { field: "status", headerName: "Status" }, { field: "job", - headerName: "Job" + headerName: "Job", + width: "100%" }, { field: "host", @@ -26,46 +27,50 @@ const jobLogColumns = [ } ]; -const userPageJobLogColumns = [ - ...jobLogColumns.filter((it) => it.field !== "user") -]; - -function createTableRow(job, colorStyle) { - return { - id: job.id, - status: <JobStatusColumn {...{ job }} />, - job: <JobSummaryColumn {...{ job, colorStyle }} />, - host: <JobHostColumn {...{ job }} />, - user: <UserAvatar username={job.createdBy} /> - }; -} +const shapeColumns = (customColumns) => { + return customColumns.map((column) => { + const mappedCol = defaultColumns.find((col) => col.field === column.field); + return mappedCol ? { ...mappedCol, ...column } : null; + }); +}; -function createTableRowJobLog(job) { - return createTableRow(job, "black"); -} +const createTableRow = (job, colorStyle) => ({ + id: job.id, + status: <JobStatusColumn {...{ job }} />, + job: <JobSummaryColumn {...{ job, colorStyle }} />, + host: <JobHostColumn {...{ job }} />, + user: <UserAvatar username={job.createdBy} /> +}); -export function JobTable({ +export const JobTable = ({ jobs, - rowType = "jobLog", + customColumns, pagination, onPage, loading -}) { - const tableTypeSpecifics = { - jobLog: [jobLogColumns, createTableRowJobLog], - userPageJobLog: [userPageJobLogColumns, createTableRowJobLog] - }; +}) => { + const [columns, setColumns] = useState(null); - const [columns, createRow] = tableTypeSpecifics[rowType]; + useEffect(() => { + if (customColumns) { + setColumns(shapeColumns(customColumns)); + } else { + setColumns(defaultColumns); + } + }, [customColumns, setColumns]); return ( - <Table - columns={columns} - rows={jobs?.map((job) => createRow(job))} - pagination={pagination} - onPage={onPage} - loading={loading} - rowHeight={90} - /> + <> + {columns ? ( + <Table + columns={columns} + rows={columns && jobs?.map((job) => createTableRow(job))} + pagination={pagination} + onPage={onPage} + loading={loading} + rowHeight={90} + /> + ) : null} + </> ); -} +}; diff --git a/src/components/common/Helper.js b/src/components/common/Helper.js index feda071b423fc83d9853e636c6a9897d4be981b3..e075163ba03fb7edf1f6f45dedd606df09ac34cf 100644 --- a/src/components/common/Helper.js +++ b/src/components/common/Helper.js @@ -101,6 +101,21 @@ export function initRequestParams(lazyParams, filter, columnSort) { return requestParams; } +export const getErrorMessage = (error) => { + const { response, status: requestStatus, message: requestMessage } = error; + if (response && response?.body) { + const { + body: { description }, + status: httpStatus + } = response; + + return `${httpStatus ?? requestStatus ?? "unknown"}: ${ + description ?? requestMessage ?? "An unknown error has occurred" + }`; + } + return `${requestStatus}: ${requestMessage}`; +}; + export const compareArrays = (a1, a2) => a1.length === a2.length && a1.every((element, index) => element === a2[index]); diff --git a/src/components/common/User/UserOperationList.js b/src/components/common/User/UserOperationList.js index 233cc9dd18a1b9744eaf5bb617cad4722e183349..c02c4a84fa14ee7807702235da4419e6186f1577 100644 --- a/src/components/common/User/UserOperationList.js +++ b/src/components/common/User/UserOperationList.js @@ -67,6 +67,11 @@ export function UserOperationList({ userName }) { /> <JobTable jobs={operations?.operations} + customColumns={[ + { field: "status" }, + { field: "job" }, + { field: "host" } + ]} loading={loading || !dataReady} pagination={pagination} onPage={onPage} diff --git a/src/components/host/HostTable.js b/src/components/host/HostTable.js index e569289bfe6a78224eb410e93843528b98f5b9af..e44ff6a35e7413f66a89cdcf958496b3be3d4a8b 100644 --- a/src/components/host/HostTable.js +++ b/src/components/host/HostTable.js @@ -45,7 +45,7 @@ export function HostTable({ hosts, pagination, onPage, loading }) { return ( <Table columns={columns} - rows={hosts.map((host) => createRow(host))} + rows={hosts?.map((host) => createRow(host))} pagination={pagination} onPage={onPage} loading={loading} diff --git a/src/views/host/HostListView.js b/src/views/host/HostListView.js index 8803a63d0314043f3e57c8db3089770562d5a4f5..fb6828a5b95523212a95e7573b61048d1452732a 100644 --- a/src/views/host/HostListView.js +++ b/src/views/host/HostListView.js @@ -33,6 +33,7 @@ export function HostListView() { const { value: hosts, wrapper: getHosts, + dataReady, loading, abort } = useAPIMethod({ @@ -157,7 +158,7 @@ export function HostListView() { > <HostTable hosts={hosts?.netBoxHosts ?? []} - loading={loading} + loading={loading || !dataReady} pagination={pagination} onPage={onPage} /> diff --git a/src/views/host/details/HostDetailsView.js b/src/views/host/details/HostDetailsView.js index a98a58bf6719550c8ca5b97df9b315fd9a3988ef..69abc38ec7637459c8479a5661fe420337bd87f3 100644 --- a/src/views/host/details/HostDetailsView.js +++ b/src/views/host/details/HostDetailsView.js @@ -1,4 +1,5 @@ -import React, { useEffect, useCallback, useContext, useMemo } from "react"; +import React, { useEffect, useContext } from "react"; +import useUrlState from "@ahooksjs/use-url-state"; import { Box, IconButton, Typography, Stack } from "@mui/material"; import ArrowBackIcon from "@mui/icons-material/ArrowBack"; import { HostBadge } from "../../../components/host/HostBadge"; @@ -6,130 +7,54 @@ import { KeyValueTable, SimpleAccordion, GlobalAppBarContext, - useAPIMethod, AlertBannerList, - usePagination, ExternalLink } from "@ess-ics/ce-ui-common"; import { LokiPanel } from "../../../components/common/Loki/LokiPanel"; import { useNavigate } from "react-router-dom"; -import { - applicationTitle, - initRequestParams -} from "../../../components/common/Helper"; +import { applicationTitle } from "../../../components/common/Helper"; import AccessControl from "../../../components/auth/AccessControl"; -import useUrlState from "@ahooksjs/use-url-state"; import { serialize, deserialize } from "../../../components/common/URLState/URLState"; -import { apiContext } from "../../../api/DeployApi"; -import IOCTable from "../../../components/IOC/IOCTable"; import { HostDetailsTable } from "./HostDetailsTable"; +import { HostJobsSection } from "./HostJobsSection"; +import { HostIocSection } from "./HostIocSection"; export function HostDetailsView({ hostId, host }) { const { setTitle } = useContext(GlobalAppBarContext); + const navigate = useNavigate(); + const [urlState, setUrlState] = useUrlState(); + useEffect(() => { if (host && host.netBoxHost) { setTitle(applicationTitle("Host Details: " + host.netBoxHost.name)); } }, [host, setTitle]); - const client = useContext(apiContext); - - const { - value: iocs, - wrapper: getIocs, - loading, - dataReady, - abort: abortGetIocs - } = useAPIMethod({ - fcn: client.apis.Hosts.findAssociatedIocsByHostId, - call: false - }); - - const [urlState, setUrlState] = useUrlState( - { - iocs_rows: "20", - iocs_page: "0", - iocs_open: "true", - syslog_open: "true", - details_open: "false" - }, - { navigateMode: "replace" } - ); - - const navigate = useNavigate(); - - const urlPagination = useMemo(() => { - return { - rows: deserialize(urlState.iocs_rows), - page: deserialize(urlState.iocs_page) - }; - }, [urlState]); - - const setUrlPagination = useCallback( - ({ rows, page }) => { - setUrlState({ - iocs_rows: serialize(rows), - iocs_page: serialize(page) - }); - }, - [setUrlState] - ); - - const rowsPerPage = [20, 50]; - - const { pagination, setPagination } = usePagination({ - rowsPerPageOptions: rowsPerPage, - initLimit: urlPagination.iocs_rows ?? rowsPerPage[0], - initPage: urlPagination.iocs_page ?? 0 - }); - - // update pagination whenever search result total pages change - useEffect(() => { - setPagination({ totalCount: iocs?.totalCount ?? 0 }); - }, [setPagination, iocs?.totalCount]); - - // whenever url state changes, update pagination useEffect(() => { - setPagination({ ...urlPagination }); - }, [setPagination, urlPagination]); - - // whenever table pagination internally changes (user clicks next page etc) - // update the row params - useEffect(() => { - setUrlPagination(pagination); - }, [pagination, setUrlPagination]); - - // Invoked by Table on change to pagination - const onPage = (params) => { - setPagination(params); - abortGetIocs(); - }; - - useEffect(() => { - let requestParams = initRequestParams(urlPagination, null); - - requestParams.host_id = hostId; - - getIocs(requestParams); - - return () => { - abortGetIocs(); - }; - }, [getIocs, urlPagination, hostId, abortGetIocs]); - - const handleClick = (event) => { - navigate(-1); - }; + if (Object.keys(urlState).length === 0) { + setUrlState( + { + iocs_rows: 20, + iocs_page: 0, + details_open: false, + job_log_open: false, + job_log_rows: 20, + job_log_page: 0 + }, + { navigateMode: "replace" } + ); + } + }, [setUrlState, urlState]); return ( <Stack gap={2}> <Box> <IconButton color="inherit" - onClick={handleClick} + onClick={() => navigate(-1)} size="large" > <ArrowBackIcon /> @@ -137,80 +62,92 @@ export function HostDetailsView({ hostId, host }) { </Box> {host ? ( <> - <AlertBannerList alerts={host?.alerts ?? []} /> + <AlertBannerList alerts={host.alerts ?? []} /> <HostBadge host={host} /> - </> - ) : null} - <Stack gap={2}> - <Typography variant="h3">IOCs</Typography> - <IOCTable - iocs={iocs?.deployedIocs} - loading={loading || !dataReady} - rowType="host" - pagination={pagination} - onPage={onPage} - /> - </Stack> - <AccessControl - allowedRoles={["DeploymentToolAdmin", "DeploymentToolIntegrator"]} - renderNoAccess={() => <></>} - > - {host ? ( - <SimpleAccordion - summary="Host details" - expanded={deserialize(urlState.details_open)} - onChange={(event, expanded) => - setUrlState({ details_open: serialize(expanded) }) - } - sx={{ marginTop: "0 !important" }} - > - <HostDetailsTable host={host} /> - </SimpleAccordion> - ) : null} - </AccessControl> - <AccessControl - allowedRoles={["DeploymentToolAdmin", "DeploymentToolIntegrator"]} - renderNoAccess={() => <></>} - > - <Stack gap={2}> - <Typography variant="h3">Host log stream</Typography> - {host ? ( - <LokiPanel - host={host} - isSyslog - isDeployed + <Stack gap={2}> + <HostIocSection + hostId={hostId} + rows={deserialize(urlState.iocs_rows)} + page={deserialize(urlState.iocs_page)} + onUrlStateChange={(params) => setUrlState(params)} /> - ) : null} - </Stack> - </AccessControl> - <KeyValueTable - obj={{ - "Host Configuration": ( - <ExternalLink - href={ - host?.netBoxHost.vm - ? `${window.NETBOX_ADDRESS}/virtualization/virtual-machines/${host?.netBoxHost.id}` - : `${window.NETBOX_ADDRESS}/dcim/devices/${host?.netBoxHost.id}` + </Stack> + <HostJobsSection + hostId={hostId} + rows={deserialize(urlState.job_log_rows)} + page={deserialize(urlState.job_log_page)} + expanded={deserialize(urlState.job_log_open)} + onUrlStateChange={(params) => setUrlState(params)} + /> + + <AccessControl + allowedRoles={["DeploymentToolAdmin", "DeploymentToolIntegrator"]} + renderNoAccess={() => <></>} + > + <SimpleAccordion + summary={ + <Typography + variant="h3" + component="h2" + > + Host details + </Typography> + } + expanded={deserialize(urlState.details_open)} + onChange={(_, expanded) => + setUrlState({ details_open: serialize(expanded) }) } - aria-label="Host Configuration" > - {" "} - {host?.netBoxHost.vm - ? `${window.NETBOX_ADDRESS}/virtualization/virtual-machines/${host?.netBoxHost.id}` - : `${window.NETBOX_ADDRESS}/dcim/devices/${host?.netBoxHost.id}`} - </ExternalLink> - ), - "Host Metrics": ( - <ExternalLink - href={`https://grafana.tn.esss.lu.se/d/5zJT23xWz/node-exporter-full?orgId=1&var-node=${host?.netBoxHost.fqdn}`} - aria-label="Host Metrics" + <HostDetailsTable host={host} /> + </SimpleAccordion> + + <Stack + gap={2} + sx={{ marginTop: "10px" }} > - {`https://grafana.tn.esss.lu.se/d/5zJT23xWz/node-exporter-full?orgId=1&var-node=${host?.netBoxHost.fqdn}`} - </ExternalLink> - ) - }} - variant="overline" - /> + <Typography + variant="h3" + component="h2" + > + Host log stream + </Typography> + <LokiPanel + host={host} + isSyslog + isDeployed + /> + </Stack> + </AccessControl> + <KeyValueTable + obj={{ + "Host Configuration": ( + <ExternalLink + href={ + host?.netBoxHost.vm + ? `${window.NETBOX_ADDRESS}/virtualization/virtual-machines/${host?.netBoxHost.id}` + : `${window.NETBOX_ADDRESS}/dcim/devices/${host?.netBoxHost.id}` + } + aria-label="Host Configuration" + > + {" "} + {host?.netBoxHost.vm + ? `${window.NETBOX_ADDRESS}/virtualization/virtual-machines/${host?.netBoxHost.id}` + : `${window.NETBOX_ADDRESS}/dcim/devices/${host?.netBoxHost.id}`} + </ExternalLink> + ), + "Host Metrics": ( + <ExternalLink + href={`https://grafana.tn.esss.lu.se/d/5zJT23xWz/node-exporter-full?orgId=1&var-node=${host?.netBoxHost.fqdn}`} + aria-label="Host Metrics" + > + {`https://grafana.tn.esss.lu.se/d/5zJT23xWz/node-exporter-full?orgId=1&var-node=${host?.netBoxHost.fqdn}`} + </ExternalLink> + ) + }} + variant="overline" + /> + </> + ) : null} </Stack> ); } diff --git a/src/views/host/details/HostIocSection.js b/src/views/host/details/HostIocSection.js new file mode 100644 index 0000000000000000000000000000000000000000..5f70193cd49f7b6311dbabf29538191b32874dfd --- /dev/null +++ b/src/views/host/details/HostIocSection.js @@ -0,0 +1,85 @@ +import React, { useEffect, useContext, useCallback } from "react"; +import IOCTable from "../../../components/IOC/IOCTable"; +import { string, number, func } from "prop-types"; +import { apiContext } from "../../../api/DeployApi"; +import { Typography } from "@mui/material"; +import { useAPIMethod, usePagination } from "@ess-ics/ce-ui-common"; +import { initRequestParams } from "../../../components/common/Helper"; +import { serialize } from "../../../components/common/URLState/URLState"; + +const propTypes = { + hostId: string, + row: number, + page: number, + onUrlStateChange: func +}; + +export const HostIocSection = ({ hostId, rows, page, onUrlStateChange }) => { + const client = useContext(apiContext); + const { pagination, setPagination, setTotalCount } = usePagination({ + initLimit: rows, + initPage: page + }); + + const { + value: iocs, + wrapper: callgetIocs, + loading, + dataReady, + abort: abortGetIocs + } = useAPIMethod({ + fcn: client.apis.Hosts.findAssociatedIocsByHostId, + call: false + }); + + const onPage = useCallback( + (params) => { + setPagination(params); + abortGetIocs(); + onUrlStateChange({ + iocs_rows: serialize(params.limit), + iocs_page: serialize(params.page) + }); + }, + [setPagination, abortGetIocs, onUrlStateChange] + ); + + const getIocs = useCallback(() => { + let requestParams = initRequestParams({ page, rows }, null); + requestParams.host_id = hostId; + callgetIocs(requestParams); + }, [callgetIocs, hostId, page, rows]); + + // update pagination whenever search result total pages change + useEffect(() => { + setTotalCount(iocs?.totalCount ?? 0); + }, [setTotalCount, iocs?.totalCount]); + + useEffect(() => { + getIocs(); + + return () => { + abortGetIocs(); + }; + }, [rows, page, abortGetIocs, getIocs]); + + return ( + <> + <Typography + variant="h3" + component="h2" + > + IOCs + </Typography> + <IOCTable + iocs={iocs?.deployedIocs} + loading={loading || !dataReady} + rowType="host" + pagination={pagination} + onPage={onPage} + /> + </> + ); +}; + +HostIocSection.propTypes = propTypes; diff --git a/src/views/host/details/HostJobsSection.js b/src/views/host/details/HostJobsSection.js new file mode 100644 index 0000000000000000000000000000000000000000..277f96728d3355be272992be92818b34013a7839 --- /dev/null +++ b/src/views/host/details/HostJobsSection.js @@ -0,0 +1,115 @@ +import React, { useContext, useEffect, useMemo, useCallback } from "react"; +import { string, number, boolean, func } from "prop-types"; +import { serialize } from "../../../components/common/URLState/URLState"; +import { getErrorMessage } from "../../../components/common/Helper"; +import { apiContext } from "../../../api/DeployApi"; +import { + SimpleAccordion, + useAPIMethod, + usePagination +} from "@ess-ics/ce-ui-common"; +import { JobTable } from "../../../components/Job"; +import { Alert, Typography } from "@mui/material"; + +const propTypes = { + hostId: string.isRequired, + rows: number, + page: number, + expanded: boolean, + onUrlStateChange: func +}; + +const rowsPerPage = [20, 50]; + +export const HostJobsSection = ({ + hostId, + rows, + page, + expanded, + onUrlStateChange +}) => { + const client = useContext(apiContext); + + const { pagination, setPagination } = usePagination({ + rowsPerPageOptions: rowsPerPage, + initLimit: rows, + initPage: page + }); + + const { + value: hostLog, + wrapper: getHostLog, + dataReady, + error, + loading, + abort: abortGetHostLog + } = useAPIMethod({ + fcn: client.apis.Deployments.listOperations, + call: false, + params: useMemo( + () => ({ host_id: hostId, ...pagination }), + [hostId, pagination] + ) + }); + + const onPage = useCallback( + (params) => { + setPagination(params); + abortGetHostLog(); + onUrlStateChange({ + job_log_rows: serialize(params.limit), + job_log_page: serialize(params.page) + }); + }, + [setPagination, abortGetHostLog, onUrlStateChange] + ); + + useEffect(() => { + setPagination({ totalCount: hostLog?.totalCount ?? 0 }); + }, [setPagination, hostLog]); + + useEffect(() => { + if (expanded) { + getHostLog(); + } + }, [expanded, pagination, getHostLog]); + + useEffect(() => { + return () => { + abortGetHostLog(); + }; + }, [abortGetHostLog]); + + return ( + <SimpleAccordion + summary={ + <Typography + variant="h3" + component="h2" + > + Job log + </Typography> + } + onChange={(_, isExpanded) => + onUrlStateChange({ job_log_open: serialize(isExpanded) }) + } + > + {error ? <Alert severity="error">{getErrorMessage(error)}</Alert> : null} + {hostLog ? ( + <JobTable + jobs={!error && hostLog ? hostLog?.operations : null} + customColumns={[ + { field: "status" }, + { field: "job" }, + { field: "user" } + ]} + loading={loading || !dataReady} + pagination={pagination} + onPage={onPage} + /> + ) : null} + </SimpleAccordion> + ); +}; + +HostJobsSection.propTypes = propTypes;