Add new node details; update legend.

This commit is contained in:
Kia Lam
2022-08-21 20:10:40 -07:00
committed by Jeff Bradberry
parent 7e627e1d1e
commit 89a6162dcd
3 changed files with 447 additions and 79 deletions

View File

@@ -14,8 +14,12 @@ import {
} from '@patternfly/react-core'; } from '@patternfly/react-core';
import { import {
ExclamationIcon as PFExclamationIcon, ExclamationIcon,
CheckIcon as PFCheckIcon, CheckIcon,
OutlinedClockIcon,
PlusIcon,
MinusIcon,
ResourcesEmptyIcon,
} from '@patternfly/react-icons'; } from '@patternfly/react-icons';
const Wrapper = styled.div` const Wrapper = styled.div`
@@ -27,23 +31,20 @@ const Wrapper = styled.div`
background-color: rgba(255, 255, 255, 0.85); background-color: rgba(255, 255, 255, 0.85);
`; `;
const Button = styled(PFButton)` const Button = styled(PFButton)`
width: 20px; &&& {
height: 20px; width: 20px;
border-radius: 10px; height: 20px;
padding: 0; border-radius: 10px;
font-size: 11px; padding: 0;
font-size: 11px;
background-color: white;
border: 1px solid #ccc;
color: black;
}
`; `;
const DescriptionListDescription = styled(PFDescriptionListDescription)` const DescriptionListDescription = styled(PFDescriptionListDescription)`
font-size: 11px; font-size: 11px;
`; `;
const ExclamationIcon = styled(PFExclamationIcon)`
fill: white;
margin-left: 2px;
`;
const CheckIcon = styled(PFCheckIcon)`
fill: white;
margin-left: 2px;
`;
const DescriptionList = styled(PFDescriptionList)` const DescriptionList = styled(PFDescriptionList)`
gap: 7px; gap: 7px;
`; `;
@@ -70,9 +71,7 @@ function Legend() {
<DescriptionList isHorizontal isFluid> <DescriptionList isHorizontal isFluid>
<DescriptionListGroup> <DescriptionListGroup>
<DescriptionListTerm> <DescriptionListTerm>
<Button variant="primary" isSmall> <Button isSmall>{t`C`}</Button>
{t`C`}
</Button>
</DescriptionListTerm> </DescriptionListTerm>
<DescriptionListDescription>{t`Control node`}</DescriptionListDescription> <DescriptionListDescription>{t`Control node`}</DescriptionListDescription>
</DescriptionListGroup> </DescriptionListGroup>
@@ -110,27 +109,133 @@ function Legend() {
<DescriptionListGroup> <DescriptionListGroup>
<DescriptionListTerm> <DescriptionListTerm>
<Button <Button
icon={<CheckIcon />} icon={
<CheckIcon
style={{ fill: 'white', marginLeft: '2px', marginTop: '3px' }}
/>
}
isSmall isSmall
style={{ border: '1px solid gray', backgroundColor: '#3E8635' }} style={{ backgroundColor: '#3E8635' }}
/> />
</DescriptionListTerm> </DescriptionListTerm>
<DescriptionListDescription>{t`Healthy`}</DescriptionListDescription> <DescriptionListDescription>{t`Ready`}</DescriptionListDescription>
</DescriptionListGroup> </DescriptionListGroup>
<DescriptionListGroup> <DescriptionListGroup>
<DescriptionListTerm> <DescriptionListTerm>
<Button variant="danger" icon={<ExclamationIcon />} isSmall /> <Button
icon={
<OutlinedClockIcon
style={{ fill: 'white', marginLeft: '3px', marginTop: '3px' }}
/>
}
isSmall
style={{ backgroundColor: '#0066CC' }}
/>
</DescriptionListTerm>
<DescriptionListDescription>{t`Installed`}</DescriptionListDescription>
</DescriptionListGroup>
<DescriptionListGroup>
<DescriptionListTerm>
<Button
icon={
<PlusIcon
style={{ fill: 'white', marginLeft: '3px', marginTop: '3px' }}
/>
}
isSmall
style={{ backgroundColor: '#6A6E73' }}
/>
</DescriptionListTerm>
<DescriptionListDescription>{t`Provisioning`}</DescriptionListDescription>
</DescriptionListGroup>
<DescriptionListGroup>
<DescriptionListTerm>
<Button
icon={
<MinusIcon
style={{ fill: 'white', marginLeft: '3px', marginTop: '3px' }}
/>
}
isSmall
style={{ backgroundColor: '#6A6E73' }}
/>
</DescriptionListTerm>
<DescriptionListDescription>{t`Deprovisioning`}</DescriptionListDescription>
</DescriptionListGroup>
<DescriptionListGroup>
<DescriptionListTerm>
<Button
icon={
<ResourcesEmptyIcon
style={{ fill: 'white', marginLeft: '3px', marginTop: '3px' }}
/>
}
isSmall
style={{ backgroundColor: '#F0AB00' }}
/>
</DescriptionListTerm>
<DescriptionListDescription>{t`Unavailable`}</DescriptionListDescription>
</DescriptionListGroup>
<DescriptionListGroup>
<DescriptionListTerm>
<Button
icon={
<ExclamationIcon
style={{ fill: 'white', marginLeft: '3px', marginTop: '3px' }}
/>
}
isSmall
style={{ backgroundColor: '#C9190B' }}
/>
</DescriptionListTerm> </DescriptionListTerm>
<DescriptionListDescription>{t`Error`}</DescriptionListDescription> <DescriptionListDescription>{t`Error`}</DescriptionListDescription>
</DescriptionListGroup> </DescriptionListGroup>
<DescriptionListGroup> <DescriptionListGroup>
<DescriptionListTerm> <DescriptionListTerm>
<Button <svg width="20" height="15" xmlns="http://www.w3.org/2000/svg">
isSmall <line
style={{ border: '1px solid gray', backgroundColor: '#e6e6e6' }} x1="0"
/> y1="9"
x2="20"
y2="9"
stroke="#666"
strokeWidth="4"
/>
</svg>
</DescriptionListTerm> </DescriptionListTerm>
<DescriptionListDescription>{t`Disabled`}</DescriptionListDescription> <DescriptionListDescription>{t`Established`}</DescriptionListDescription>
</DescriptionListGroup>
<DescriptionListGroup>
<DescriptionListTerm>
<svg width="20" height="15" xmlns="http://www.w3.org/2000/svg">
<line
x1="0"
y1="9"
x2="20"
y2="9"
stroke="#666"
strokeWidth="4"
strokeDasharray="6"
/>
</svg>
</DescriptionListTerm>
<DescriptionListDescription>{t`Adding`}</DescriptionListDescription>
</DescriptionListGroup>
<DescriptionListGroup>
<DescriptionListTerm>
<svg width="20" height="15" xmlns="http://www.w3.org/2000/svg">
<line
x1="0"
y1="9"
x2="20"
y2="9"
stroke="#C9190B"
strokeWidth="4"
strokeDasharray="6"
/>
</svg>
</DescriptionListTerm>
<DescriptionListDescription>{t`Removing`}</DescriptionListDescription>
</DescriptionListGroup> </DescriptionListGroup>
</DescriptionList> </DescriptionList>
</Wrapper> </Wrapper>

View File

@@ -1,8 +1,13 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState, useCallback } from 'react';
import { useHistory } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
import { t } from '@lingui/macro';
import styled from 'styled-components'; import styled from 'styled-components';
import debounce from 'util/debounce'; import debounce from 'util/debounce';
import * as d3 from 'd3'; import * as d3 from 'd3';
import { InstancesAPI } from 'api';
import useRequest, { useDismissableError } from 'hooks/useRequest';
import AlertModal from 'components/AlertModal';
import ErrorDetail from 'components/ErrorDetail';
import Legend from './Legend'; import Legend from './Legend';
import Tooltip from './Tooltip'; import Tooltip from './Tooltip';
import ContentLoading from './ContentLoading'; import ContentLoading from './ContentLoading';
@@ -38,9 +43,38 @@ const Loader = styled(ContentLoading)`
function MeshGraph({ data, showLegend, zoom, setShowZoomControls }) { function MeshGraph({ data, showLegend, zoom, setShowZoomControls }) {
const [isNodeSelected, setIsNodeSelected] = useState(false); const [isNodeSelected, setIsNodeSelected] = useState(false);
const [selectedNode, setSelectedNode] = useState(null); const [selectedNode, setSelectedNode] = useState(null);
const [nodeDetail, setNodeDetail] = useState(null);
const [simulationProgress, setSimulationProgress] = useState(null); const [simulationProgress, setSimulationProgress] = useState(null);
const history = useHistory(); const history = useHistory();
const {
result: { instance, instanceGroups },
error: fetchError,
isLoading,
request: fetchDetails,
} = useRequest(
useCallback(async () => {
const { data: instanceData } = await InstancesAPI.readDetail(
selectedNode.id
);
const { data: instanceGroupsData } = await InstancesAPI.readInstanceGroup(
selectedNode.id
);
return {
instance: instanceData,
instanceGroups: instanceGroupsData,
};
}, [selectedNode]),
{
result: {},
}
);
const { error: fetchInstanceError, dismissError } =
useDismissableError(fetchError);
useEffect(() => {
fetchDetails();
}, [selectedNode, fetchDetails]);
const draw = () => { const draw = () => {
let width; let width;
@@ -134,7 +168,9 @@ function MeshGraph({ data, showLegend, zoom, setShowZoomControls }) {
.attr('class', (_, i) => `link-${i}`) .attr('class', (_, i) => `link-${i}`)
.attr('data-cy', (d) => `${d.source.hostname}-${d.target.hostname}`) .attr('data-cy', (d) => `${d.source.hostname}-${d.target.hostname}`)
.style('fill', 'none') .style('fill', 'none')
.style('stroke', '#ccc') .style('stroke', (d) =>
d.link_state === 'removing' ? '#C9190B' : '#CCC'
)
.style('stroke-width', '2px') .style('stroke-width', '2px')
.style('stroke-dasharray', (d) => renderLinkState(d.link_state)) .style('stroke-dasharray', (d) => renderLinkState(d.link_state))
.attr('pointer-events', 'none') .attr('pointer-events', 'none')
@@ -158,7 +194,6 @@ function MeshGraph({ data, showLegend, zoom, setShowZoomControls }) {
deselectSiblings(d); deselectSiblings(d);
}) })
.on('click', (_, d) => { .on('click', (_, d) => {
setNodeDetail(d);
highlightSelected(d); highlightSelected(d);
}); });
@@ -272,7 +307,9 @@ function MeshGraph({ data, showLegend, zoom, setShowZoomControls }) {
.selectAll(`.link-${s.index}`) .selectAll(`.link-${s.index}`)
.transition() .transition()
.duration(50) .duration(50)
.style('stroke', '#ccc') .style('stroke', (d) =>
d.link_state === 'removing' ? '#C9190B' : '#CCC'
)
.style('stroke-width', '2px') .style('stroke-width', '2px')
.attr('marker-end', 'url(#end)'); .attr('marker-end', 'url(#end)');
}); });
@@ -319,14 +356,33 @@ function MeshGraph({ data, showLegend, zoom, setShowZoomControls }) {
return ( return (
<div id="chart" style={{ position: 'relative', height: '100%' }}> <div id="chart" style={{ position: 'relative', height: '100%' }}>
{showLegend && <Legend />} {showLegend && <Legend />}
<Tooltip {instance && (
isNodeSelected={isNodeSelected} <>
renderNodeIcon={renderNodeIcon(selectedNode)} {fetchInstanceError && (
nodeDetail={nodeDetail} <AlertModal
redirectToDetailsPage={() => variant="error"
redirectToDetailsPage(selectedNode, history) title={t`Error!`}
} isOpen
/> onClose={dismissError}
>
{t`Failed to update instance.`}
<ErrorDetail error={fetchInstanceError} />
</AlertModal>
)}
<Tooltip
isNodeSelected={isNodeSelected}
renderNodeIcon={renderNodeIcon(selectedNode)}
selectedNode={selectedNode}
fetchInstance={fetchDetails}
instanceGroups={instanceGroups}
instanceDetail={instance}
isLoading={isLoading}
redirectToDetailsPage={() =>
redirectToDetailsPage(selectedNode, history)
}
/>
</>
)}
<Loader className="simulation-loader" progress={simulationProgress} /> <Loader className="simulation-loader" progress={simulationProgress} />
</div> </div>
); );

View File

@@ -1,6 +1,10 @@
import React from 'react'; import React, { useState, useCallback, useEffect } from 'react';
import { t } from '@lingui/macro'; import { t, Plural } from '@lingui/macro';
import styled from 'styled-components'; import styled from 'styled-components';
import useRequest, { useDismissableError } from 'hooks/useRequest';
import useDebounce from 'hooks/useDebounce';
import { InstancesAPI } from 'api';
import computeForks from 'util/computeForks';
import { import {
Button as PFButton, Button as PFButton,
DescriptionList as PFDescriptionList, DescriptionList as PFDescriptionList,
@@ -8,26 +12,41 @@ import {
DescriptionListGroup as PFDescriptionListGroup, DescriptionListGroup as PFDescriptionListGroup,
DescriptionListDescription, DescriptionListDescription,
Divider, Divider,
Progress,
ProgressMeasureLocation,
ProgressSize,
Slider,
TextContent, TextContent,
Text as PFText, Text as PFText,
TextVariants, TextVariants,
} from '@patternfly/react-core'; } from '@patternfly/react-core';
import { DownloadIcon } from '@patternfly/react-icons';
import ContentLoading from 'components/ContentLoading';
import InstanceToggle from 'components/InstanceToggle';
import StatusLabel from 'components/StatusLabel'; import StatusLabel from 'components/StatusLabel';
import AlertModal from 'components/AlertModal';
import ErrorDetail from 'components/ErrorDetail';
import { formatDateString } from 'util/dates';
const Wrapper = styled.div` const Wrapper = styled.div`
position: absolute; position: absolute;
top: -20px; top: -20px;
right: 0; right: 0;
padding: 10px; padding: 10px;
width: 20%; width: 25%;
background-color: rgba(255, 255, 255, 0.85); background-color: rgba(255, 255, 255, 0.85);
`; `;
const Button = styled(PFButton)` const Button = styled(PFButton)`
width: 20px; &&& {
height: 20px; width: 30px;
border-radius: 10px; height: 30px;
padding: 0; border-radius: 15px;
font-size: 11px; padding: 0;
font-size: 16px;
background-color: white;
border: 1px solid #ccc;
color: black;
}
`; `;
const DescriptionList = styled(PFDescriptionList)` const DescriptionList = styled(PFDescriptionList)`
gap: 0; gap: 0;
@@ -39,12 +58,95 @@ const DescriptionListGroup = styled(PFDescriptionListGroup)`
const Text = styled(PFText)` const Text = styled(PFText)`
margin: 10px 0 5px; margin: 10px 0 5px;
`; `;
const Unavailable = styled.span`
color: var(--pf-global--danger-color--200);
`;
const SliderHolder = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
`;
const SliderForks = styled.div`
flex-grow: 1;
margin-right: 8px;
margin-left: 8px;
text-align: center;
`;
function renderInstanceGroups(instanceGroups) {
return instanceGroups.map((g) => <StatusLabel status={g.name} />);
}
function usedCapacity(instance) {
if (instance.enabled) {
return (
<Progress
value={Math.round(100 - instance.percent_capacity_remaining)}
measureLocation={ProgressMeasureLocation.top}
size={ProgressSize.sm}
title={t`Used capacity`}
/>
);
}
return <Unavailable>{t`Unavailable`}</Unavailable>;
}
function Tooltip({ function Tooltip({
fetchInstance,
isNodeSelected, isNodeSelected,
renderNodeIcon, renderNodeIcon,
nodeDetail, instanceDetail,
instanceGroups,
isLoading,
redirectToDetailsPage, redirectToDetailsPage,
}) { }) {
const [forks, setForks] = useState(
computeForks(
instanceDetail.mem_capacity,
instanceDetail.cpu_capacity,
instanceDetail.capacity_adjustment
)
);
const { error: updateInstanceError, request: updateInstance } = useRequest(
useCallback(
async (values) => {
await InstancesAPI.update(instanceDetail.id, values);
},
[instanceDetail]
)
);
const debounceUpdateInstance = useDebounce(updateInstance, 100);
const { error: updateError, dismissError: dismissUpdateError } =
useDismissableError(updateInstanceError);
const handleChangeValue = (value) => {
const roundedValue = Math.round(value * 100) / 100;
setForks(
computeForks(
instanceDetail.mem_capacity,
instanceDetail.cpu_capacity,
roundedValue
)
);
debounceUpdateInstance({ capacity_adjustment: roundedValue });
};
useEffect(() => {
setForks(
computeForks(
instanceDetail.mem_capacity,
instanceDetail.cpu_capacity,
instanceDetail.capacity_adjustment
)
);
}, [instanceDetail]);
return ( return (
<Wrapper className="tooltip" data-cy="tooltip"> <Wrapper className="tooltip" data-cy="tooltip">
{isNodeSelected === false ? ( {isNodeSelected === false ? (
@@ -62,6 +164,17 @@ function Tooltip({
</TextContent> </TextContent>
) : ( ) : (
<> <>
{updateError && (
<AlertModal
variant="error"
title={t`Error!`}
isOpen
onClose={dismissUpdateError}
>
{t`Failed to update instance.`}
<ErrorDetail error={updateError} />
</AlertModal>
)}
<TextContent> <TextContent>
<Text <Text
component={TextVariants.small} component={TextVariants.small}
@@ -71,36 +184,130 @@ function Tooltip({
</Text> </Text>
<Divider component="div" /> <Divider component="div" />
</TextContent> </TextContent>
<DescriptionList isHorizontal isFluid> {isLoading && <ContentLoading />}
<DescriptionListGroup> {!isLoading && (
<DescriptionListTerm> <DescriptionList>
<Button variant="primary" isSmall> <DescriptionListGroup>
{renderNodeIcon} <DescriptionListDescription>
</Button> <Button>{renderNodeIcon}</Button>{' '}
</DescriptionListTerm> <PFButton
<DescriptionListDescription> variant="link"
<PFButton isInline
variant="link" onClick={redirectToDetailsPage}
isInline >
onClick={redirectToDetailsPage} {instanceDetail.hostname}
> </PFButton>
{nodeDetail.hostname} </DescriptionListDescription>
</PFButton> </DescriptionListGroup>
</DescriptionListDescription> <DescriptionListGroup>
</DescriptionListGroup> <DescriptionListTerm>{t`Instance status`}</DescriptionListTerm>
<DescriptionListGroup> <DescriptionListDescription>
<DescriptionListTerm>{t`Type`}</DescriptionListTerm> <StatusLabel status={instanceDetail.node_state} />
<DescriptionListDescription> </DescriptionListDescription>
{nodeDetail.node_type} {t`node`} </DescriptionListGroup>
</DescriptionListDescription> <DescriptionListGroup>
</DescriptionListGroup> <DescriptionListTerm>{t`Instance type`}</DescriptionListTerm>
<DescriptionListGroup> <DescriptionListDescription>
<DescriptionListTerm>{t`Status`}</DescriptionListTerm> {instanceDetail.node_type}
<DescriptionListDescription> </DescriptionListDescription>
<StatusLabel status={nodeDetail.node_state} /> </DescriptionListGroup>
</DescriptionListDescription> {instanceDetail.related?.install_bundle && (
</DescriptionListGroup> <DescriptionListGroup>
</DescriptionList> <DescriptionListTerm>{t`Download bundle`}</DescriptionListTerm>
<DescriptionListDescription>
<a href={`${instanceDetail.related.install_bundle}`}>
<PFButton
ouiaId="job-output-download-button"
variant="plain"
aria-label={t`Download Bundle`}
>
<DownloadIcon />
</PFButton>
</a>
</DescriptionListDescription>
</DescriptionListGroup>
)}
{instanceDetail.ip_address && (
<DescriptionListGroup>
<DescriptionListTerm>{t`IP address`}</DescriptionListTerm>
<DescriptionListDescription>
{instanceDetail.ip_address}
</DescriptionListDescription>
</DescriptionListGroup>
)}
{instanceGroups && (
<DescriptionListGroup>
<DescriptionListTerm>{t`Instance groups`}</DescriptionListTerm>
<DescriptionListDescription>
{renderInstanceGroups(instanceGroups.results)}
</DescriptionListDescription>
</DescriptionListGroup>
)}
{instanceDetail.node_type !== 'hop' && (
<>
<DescriptionListGroup>
<DescriptionListTerm>{t`Forks`}</DescriptionListTerm>
<DescriptionListDescription>
<SliderHolder data-cy="slider-holder">
<div data-cy="cpu-capacity">{t`CPU ${instanceDetail.cpu_capacity}`}</div>
<SliderForks data-cy="slider-forks">
<div data-cy="number-forks">
<Plural
value={forks}
one="# fork"
other="# forks"
/>
</div>
<Slider
areCustomStepsContinuous
max={1}
min={0}
step={0.1}
value={instanceDetail.capacity_adjustment}
onChange={handleChangeValue}
isDisabled={!instanceDetail.enabled}
// isDisabled={!me?.is_superuser || !instance.enabled}
data-cy="slider"
/>
</SliderForks>
<div data-cy="mem-capacity">{t`RAM ${instanceDetail.mem_capacity}`}</div>
</SliderHolder>
</DescriptionListDescription>
</DescriptionListGroup>
<DescriptionListGroup>
<DescriptionListTerm>{t`Capacity`}</DescriptionListTerm>
<DescriptionListDescription>
{usedCapacity(instanceDetail)}
</DescriptionListDescription>
</DescriptionListGroup>
<DescriptionListGroup>
<DescriptionListDescription>
<InstanceToggle
css="display: inline-flex;"
fetchInstances={fetchInstance}
instance={instanceDetail}
/>
</DescriptionListDescription>
</DescriptionListGroup>
</>
)}
<DescriptionListGroup>
<DescriptionListTerm>{t`Last modified`}</DescriptionListTerm>
<DescriptionListDescription>
{formatDateString(instanceDetail.modified)}
</DescriptionListDescription>
</DescriptionListGroup>
<DescriptionListGroup>
<DescriptionListTerm>{t`Last seen`}</DescriptionListTerm>
<DescriptionListDescription>
{instanceDetail.last_seen
? formatDateString(instanceDetail.last_seen)
: `not found`}
</DescriptionListDescription>
</DescriptionListGroup>
</DescriptionList>
)}
</> </>
)} )}
</Wrapper> </Wrapper>