1
0
mirror of https://github.com/OpenNebula/one.git synced 2025-03-21 14:50:08 +03:00

F #3951: Add tiers flow graph (#234)

This commit is contained in:
Sergio Betanzos 2020-09-21 18:52:28 +02:00 committed by GitHub
parent f58a7327fa
commit fe8bfcaf0b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 477 additions and 274 deletions

View File

@ -41,6 +41,7 @@
"compression": "^1.7.4",
"concurrently": "^5.2.0",
"cors": "^2.8.5",
"dagre": "^0.8.5",
"express": "^4.17.1",
"fs-extra": "^9.0.1",
"fuse.js": "^6.4.1",

View File

@ -1,98 +0,0 @@
import React from 'react';
import {
makeStyles,
Card,
Button,
CardHeader,
CardActions,
Badge,
Fade
} from '@material-ui/core';
import DesktopWindowsIcon from '@material-ui/icons/DesktopWindows';
import { Tr } from 'client/components/HOC';
const useStyles = makeStyles(theme => ({
root: {
height: '100%',
minHeight: 140,
display: 'flex',
flexDirection: 'column'
},
header: {
overflowX: 'hidden',
flexGrow: 1
},
headerContent: {},
title: {
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'initial',
display: '-webkit-box',
lineClamp: 2,
boxOrient: 'vertical'
}
}));
const RoleCard = React.memo(
({ info, handleEdit, handleClone, handleRemove }) => {
const classes = useStyles();
const {
name = 'Role name',
cardinality,
vm_template = 0,
elasticity_policies,
scheduled_policies
} = info;
return (
<Fade in unmountOnExit={false}>
<Card className={classes.root}>
<CardHeader
avatar={
<Badge
badgeContent={cardinality}
color="primary"
anchorOrigin={{
vertical: 'top',
horizontal: 'left'
}}
>
<DesktopWindowsIcon />
</Badge>
}
className={classes.header}
classes={{ content: classes.headerContent }}
title={name}
titleTypographyProps={{
variant: 'body2',
noWrap: true,
className: classes.title,
title: name
}}
subheader={`Template id: ${vm_template}`}
subheaderTypographyProps={{
variant: 'body2',
noWrap: true,
title: `Template id: ${vm_template}`
}}
/>
<CardActions>
<Button variant="contained" size="small" onClick={handleEdit}>
{Tr('Edit')}
</Button>
<Button variant="contained" size="small" onClick={handleClone}>
{Tr('Clone')}
</Button>
<Button size="small" onClick={handleRemove}>
{Tr('Remove')}
</Button>
</CardActions>
</Card>
</Fade>
);
}
);
export default RoleCard;

View File

@ -0,0 +1,89 @@
import React from 'react';
import {
makeStyles,
Card,
Button,
CardHeader,
CardActions,
Badge
} from '@material-ui/core';
import DesktopWindowsIcon from '@material-ui/icons/DesktopWindows';
import { Tr } from 'client/components/HOC';
const useStyles = makeStyles(() => ({
root: {
height: '100%',
minHeight: 140,
display: 'flex',
flexDirection: 'column'
},
header: {
overflowX: 'hidden',
flexGrow: 1
},
headerContent: {},
title: {
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'initial',
display: '-webkit-box',
lineClamp: 2,
boxOrient: 'vertical'
}
}));
const TierCard = React.memo(
({ values, handleEdit, handleClone, handleRemove, cardProps }) => {
const classes = useStyles();
const { name = 'Tier name', cardinality } = values;
return (
<Card className={classes.root} {...cardProps}>
<CardHeader
avatar={
<Badge
badgeContent={cardinality}
color="primary"
anchorOrigin={{
vertical: 'top',
horizontal: 'left'
}}
>
<DesktopWindowsIcon />
</Badge>
}
className={classes.header}
classes={{ content: classes.headerContent }}
title={name}
titleTypographyProps={{
variant: 'body2',
noWrap: true,
className: classes.title,
title: name
}}
/>
<CardActions>
{handleEdit && (
<Button variant="contained" size="small" onClick={handleEdit}>
{Tr('Edit')}
</Button>
)}
{handleClone && (
<Button variant="contained" size="small" onClick={handleClone}>
{Tr('Clone')}
</Button>
)}
{handleRemove && (
<Button size="small" onClick={handleRemove}>
{Tr('Remove')}
</Button>
)}
</CardActions>
</Card>
);
}
);
export default TierCard;

View File

@ -1,7 +1,7 @@
import ClusterCard from 'client/components/Cards/ClusterCard';
import NetworkCard from 'client/components/Cards/NetworkCard';
import RoleCard from 'client/components/Cards/RoleCard';
import TierCard from 'client/components/Cards/TierCard';
import EmptyCard from 'client/components/Cards/EmptyCard';
import SelectCard from 'client/components/Cards/SelectCard';
export { ClusterCard, NetworkCard, RoleCard, EmptyCard, SelectCard };
export { ClusterCard, NetworkCard, TierCard, EmptyCard, SelectCard };

View File

@ -0,0 +1,72 @@
import React from 'react';
import PropTypes from 'prop-types';
import { SpeedDial, SpeedDialIcon, SpeedDialAction } from '@material-ui/lab';
import { makeStyles } from '@material-ui/core/styles';
const useStyles = makeStyles(theme => ({
root: {
position: 'absolute',
'&.MuiSpeedDial-directionUp, &.MuiSpeedDial-directionLeft': {
bottom: theme.spacing(2),
right: theme.spacing(2)
},
'&.MuiSpeedDial-directionDown, &.MuiSpeedDial-directionRight': {
top: theme.spacing(2),
left: theme.spacing(2)
}
}
}));
const SpeedDials = ({ hidden = false, actions = [] }) => {
const classes = useStyles();
const [open, setOpen] = React.useState(false);
const handleClose = () => {
setOpen(false);
};
const handleOpen = () => {
setOpen(true);
};
return (
<SpeedDial
ariaLabel="SpeedDial"
className={classes.root}
hidden={hidden}
icon={<SpeedDialIcon />}
onClose={handleClose}
onOpen={handleOpen}
open={open}
direction="up"
>
{actions?.map(action => (
<SpeedDialAction
key={action.name}
icon={action.icon}
tooltipTitle={action.name}
onClick={action.handleClick}
/>
))}
</SpeedDial>
);
};
SpeedDials.propTypes = {
hidden: PropTypes.bool,
actions: PropTypes.arrayOf(
PropTypes.shape({
name: PropTypes.string.isRequired,
icon: PropTypes.node.isRequired,
handleClick: PropTypes.func
})
)
};
SpeedDials.defaultProps = {
hidden: false,
actions: []
};
export default SpeedDials;

View File

@ -1,125 +0,0 @@
import React, { useEffect, useState, useCallback } from 'react';
import * as yup from 'yup';
import { useWatch } from 'react-hook-form';
import useListForm from 'client/hooks/useListForm';
import FormStepper from 'client/components/FormStepper';
import { DialogForm } from 'client/components/Dialogs';
import FlowWithFAB from 'client/components/Flows/FlowWithFAB';
import Steps from './Steps';
export const Context = React.createContext({});
export const STEP_ID = 'tiers';
const Roles = () => {
const { steps, defaultValues, resolvers } = Steps();
return {
id: STEP_ID,
label: 'Tier Definition',
DEFAULT_DATA: defaultValues,
resolver: yup
.array()
.of(resolvers)
.min(1)
.required()
.default([]),
content: useCallback(({ data, setFormData }) => {
const [showDialog, setShowDialog] = useState(false);
const [nestedForm, setNestedForm] = useState({});
const form = useWatch({});
const { editingData, handleEdit, handleSave } = useListForm({
key: STEP_ID,
list: data,
setList: setFormData,
defaultValue: defaultValues
});
useEffect(() => {
setNestedForm(form);
}, []);
return (
<>
<div>
<button
onClick={() => {
handleEdit();
setShowDialog(true);
}}
>
Add role
</button>
<div>{JSON.stringify(data)}</div>
</div>
{showDialog && (
<Context.Provider value={{ nestedForm }}>
<DialogForm
open={showDialog}
title={'Tier form'}
resolver={resolvers}
values={editingData.data}
onCancel={() => setShowDialog(false)}
>
<div
style={{
display: 'flex',
flexDirection: 'column',
height: '100%'
}}
>
<FormStepper
steps={steps}
initialValue={editingData.data}
onSubmit={values => {
handleSave(values);
setShowDialog(false);
}}
/>
</div>
</DialogForm>
</Context.Provider>
)}
</>
);
}, [])
/* DialogComponent: ({ values, onSubmit, onCancel, ...props }) => {
const form = useWatch({});
const [nestedForm, setNestedForm] = useState({});
useEffect(() => {
setNestedForm(form);
}, []);
return (
<Context.Provider value={{ nestedForm }}>
<DialogForm
title={'Tier form'}
resolver={resolvers}
values={values}
onCancel={onCancel}
{...props}
>
<div
style={{
display: 'flex',
flexDirection: 'column',
height: '100%'
}}
>
<FormStepper
steps={steps}
initialValue={values}
onSubmit={onSubmit}
/>
</div>
</DialogForm>
</Context.Provider>
); */
};
};
export default Roles;

View File

@ -0,0 +1,46 @@
import React, { memo } from 'react';
import PropTypes from 'prop-types';
import { Handle } from 'react-flow-renderer';
import { TierCard } from 'client/components/Cards';
const CustomNode = memo(({ data }) => {
const { tier, handleEdit } = data;
const isValidConnection = ({ target }) => !tier?.parents?.includes(target);
return (
<>
<TierCard
values={tier}
handleEdit={handleEdit}
cardProps={{
style: { minWidth: 200 }
}}
/>
<Handle
type="source"
position="bottom"
style={{ backgroundColor: 'rgba(0,0,0,0.2)' }}
isValidConnection={isValidConnection}
/>
<Handle
type="target"
position="top"
style={{ backgroundColor: 'rgba(0,0,0,0.2)' }}
isValidConnection={isValidConnection}
/>
</>
);
});
CustomNode.propTypes = {
data: PropTypes.objectOf(PropTypes.any)
};
CustomNode.defaultProps = {
data: {}
};
export default CustomNode;

View File

@ -4,7 +4,7 @@ import FormWithSchema from 'client/components/Forms/FormWithSchema';
import { FORM_FIELDS, STEP_FORM_SCHEMA } from './schema';
export const STEP_ID = 'role';
export const STEP_ID = 'tier';
const BasicConfiguration = () => ({
id: STEP_ID,

View File

@ -5,7 +5,7 @@ import ListCards from 'client/components/List/ListCards';
import { SelectCard } from 'client/components/Cards';
import { STEP_ID as NETWORKING } from 'client/containers/Application/Create/Steps/Networking';
import { Context } from 'client/containers/Application/Create/Steps/Roles';
import { Context } from 'client/containers/Application/Create/Steps/Tiers';
import { STEP_FORM_SCHEMA } from './schema';
export const STEP_ID = 'networks';

View File

@ -58,7 +58,7 @@ const Template = () => ({
useEffect(() => {
if (Object.keys(data).length > 0) {
const currentScreen = Object.keys(data)[0];
setScreen(SCREENS.find(src => src.id === currentScreen.id));
setScreen(SCREENS.find(src => src.id === currentScreen));
}
}, []);

View File

@ -0,0 +1,158 @@
import React, { useEffect, useState, useCallback, createContext } from 'react';
import * as yup from 'yup';
import { useWatch } from 'react-hook-form';
import { Add as AddIcon, Refresh as RefreshIcon } from '@material-ui/icons';
import ReactFlow, {
ReactFlowProvider,
Background,
addEdge
} from 'react-flow-renderer';
import dagre from 'dagre';
import useListForm from 'client/hooks/useListForm';
import FormStepper from 'client/components/FormStepper';
import { DialogForm } from 'client/components/Dialogs';
import { generateFlow } from 'client/utils/flow';
import SpeedDials from 'client/components/SpeedDials';
import Steps from './Steps';
import CustomNode from './Flow/CustomNode';
export const Context = createContext({});
export const STEP_ID = 'tiers';
const Tiers = () => {
const { steps, defaultValues, resolvers } = Steps();
return {
id: STEP_ID,
label: 'Tier Definition',
resolver: yup
.array()
.of(resolvers)
.min(1)
.required()
.default([]),
content: useCallback(({ data, setFormData }) => {
const [flow, setFlow] = useState([]);
const [showDialog, setShowDialog] = useState(false);
const [nestedForm, setNestedForm] = useState({});
const form = useWatch({});
const { editingData, handleEdit, handleSave } = useListForm({
key: STEP_ID,
list: data,
setList: setFormData,
defaultValue: defaultValues
});
const graph = new dagre.graphlib.Graph();
graph.setGraph({});
graph.setDefaultEdgeLabel(() => ({}));
const reDrawFlow = () => {
setFlow(
generateFlow(
graph,
data?.map((item, index) => ({
id: item.tier.name,
type: 'tier',
data: {
...item,
handleEdit: () => {
handleEdit(index);
setShowDialog(true);
}
},
parents: item.tier.parents ?? []
})) ?? []
)
);
};
useEffect(() => {
setNestedForm(form);
}, []);
const actions = [
{
icon: <AddIcon />,
name: 'Add',
handleClick: () => {
handleEdit();
setShowDialog(true);
}
},
{
icon: <RefreshIcon />,
name: 'Refresh',
handleClick: () => reDrawFlow()
}
];
return (
<ReactFlowProvider>
<div style={{ height: '100%', flexGrow: 1 }}>
<ReactFlow
elements={flow}
nodeTypes={{ tier: CustomNode }}
onConnect={({ source, target }) => {
const indexChild = data?.findIndex(
item => item?.tier?.name === target
);
const child = { ...data[indexChild] };
child.tier.parents = [...(child?.tier?.parents ?? []), source];
handleEdit(indexChild);
handleSave(child);
setFlow(prevFlow =>
addEdge({ source, target, animated: true }, prevFlow)
);
}}
onLoad={reactFlowInstance => {
reDrawFlow();
reactFlowInstance.fitView();
}}
>
<SpeedDials actions={actions} />
<Background color="#aaa" gap={16} />
</ReactFlow>
</div>
{showDialog && (
<Context.Provider value={{ nestedForm }}>
<DialogForm
open={showDialog}
title={'Tier form'}
resolver={resolvers}
values={editingData.data}
onCancel={() => setShowDialog(false)}
>
<div
style={{
display: 'flex',
flexDirection: 'column',
height: '100%'
}}
>
<FormStepper
steps={steps}
initialValue={editingData.data}
onSubmit={values => {
handleSave(values);
setShowDialog(false);
reDrawFlow();
}}
/>
</div>
</DialogForm>
</Context.Provider>
)}
</ReactFlowProvider>
);
}, [])
};
};
export default Tiers;

View File

@ -3,21 +3,21 @@ import * as yup from 'yup';
import BasicConfiguration from './BasicConfiguration';
import Clusters from './Clusters';
import Networking from './Networking';
import Roles from './Roles';
import Tiers from './Tiers';
const Steps = () => {
const basic = BasicConfiguration();
const clusters = Clusters();
const networking = Networking();
const roles = Roles();
const tiers = Tiers();
const steps = [basic, clusters, networking, roles];
const steps = [basic, clusters, networking, tiers];
const resolvers = yup.object({
[basic.id]: basic.resolver,
[clusters.id]: clusters.resolver,
[networking.id]: networking.resolver,
[roles.id]: roles.resolver
[tiers.id]: tiers.resolver
});
const defaultValues = resolvers.default();

View File

@ -16,11 +16,9 @@ function useList({ list, initLength }) {
if (list?.length === 0 || shortList.length !== 0) return;
setLoading(true);
fakeDelay(200)
.then(() => setFullList(list))
.then(() => setShortList(list.slice(0, initLength)))
.then(() => setLoading(false));
setFullList(list);
setShortList(list.slice(0, initLength));
setLoading(false);
}, [list]);
useEffect(() => {

View File

@ -1,51 +1,69 @@
import { useState } from 'react';
import { useCallback, useState } from 'react';
const useListSelect = ({ multiple, key, list, setList, defaultValue }) => {
const [editingData, setEditingData] = useState({});
const handleSelect = index =>
setList(prevData => ({
...prevData,
[key]: multiple ? [...(prevData[key] ?? []), index] : [index]
}));
const handleSelect = useCallback(
index =>
setList(prevData => ({
...prevData,
[key]: multiple ? [...(prevData[key] ?? []), index] : [index]
})),
[key, list, multiple]
);
const handleUnselect = indexRemove =>
setList(prevData => ({
...prevData,
[key]: prevData[key]?.filter(index => index !== indexRemove)
}));
const handleUnselect = useCallback(
indexRemove =>
setList(prevData => ({
...prevData,
[key]: prevData[key]?.filter(index => index !== indexRemove)
})),
[key, list]
);
const handleSave = values => {
setList(prevData => ({
...prevData,
[key]: Object.assign(prevData[key], {
[editingData.index]: values
})
}));
};
const handleSave = useCallback(
values => {
setList(prevData => ({
...prevData,
[key]: Object.assign(prevData[key], {
[editingData.index]: values
})
}));
},
[key, list, editingData]
);
const handleEdit = (index = list?.length) => {
const openData = list[index] ?? defaultValue;
const handleEdit = useCallback(
(index = list?.length) => {
const openData = list[index] ?? defaultValue;
setEditingData({ index, data: openData });
};
setEditingData({ index, data: openData });
},
[list, defaultValue]
);
const handleClone = index => {
const item = list[index];
const cloneItem = { ...item, name: `${item?.name}_clone` };
const cloneData = [...list];
cloneData.splice(index + 1, 0, cloneItem);
const handleClone = useCallback(
index => {
const item = list[index];
const cloneItem = { ...item, name: `${item?.name}_clone` };
const cloneData = [...list];
cloneData.splice(index + 1, 0, cloneItem);
setList(prevData => ({ ...prevData, [key]: cloneData }));
};
setList(prevData => ({ ...prevData, [key]: cloneData }));
},
[list]
);
const handleRemove = indexRemove => {
// TODO confirmation??
setList(prevData => ({
...prevData,
[key]: prevData[key]?.filter((_, index) => index !== indexRemove)
}));
};
const handleRemove = useCallback(
indexRemove => {
// TODO confirmation??
setList(prevData => ({
...prevData,
[key]: prevData[key]?.filter((_, index) => index !== indexRemove)
}));
},
[key, list]
);
return {
editingData,

View File

@ -0,0 +1,44 @@
import dagre from 'dagre';
const generateFlow = (graph, elements = []) => {
const NODE_WIDTH = 400;
const NODE_HEIGHT = 200;
elements.forEach(({ id, type, data, parents = [] }) => {
graph.setNode(id, {
data,
type: type ?? 'default',
width: NODE_WIDTH,
height: NODE_HEIGHT
});
parents.forEach(parent => {
graph.setEdge(parent, id);
});
});
dagre.layout(graph);
const nodes = graph.nodes().map(id => {
const node = graph.node(id);
return {
id,
type: node?.type,
data: node?.data,
position: {
x: node.x - node.width / 2,
y: node.y - node.height / 2
}
};
});
const edges = graph.edges().map(({ v: source, w: target }) => ({
id: `__${source}__${target}`,
source,
target,
animated: true
}));
return [...nodes, ...edges];
};
export { generateFlow };