feat: refactor Plugin Orchestration (#1813)

This commit is contained in:
琚致远 2021-05-11 14:36:16 +08:00 committed by GitHub
parent 059b30b3c4
commit 423c9e8996
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
40 changed files with 1901 additions and 1043 deletions

View File

@ -69,11 +69,12 @@ ASFLicenseHeaderLua.txt
# Skip files containing MIT License
web/scripts/verifyCommit.js
web/src/components/HeaderDropdown/index.less
web/src/components/HeaderDropdown/index.tsx
web/src/components/HeaderDropdown
web/src/components/NoticeIcon
web/src/components/PageLoading/index.tsx
web/src/components/RightContent
web/src/components/PluginFlow/components/ConfigPanel
web/src/components/PluginFlow/components/Toolbar
web/src/e2e/__mocks__/antd-pro-merge-less.js
web/src/e2e/baseLayout.e2e.js
web/src/pages/404.tsx

View File

@ -44,6 +44,10 @@ jobs:
with:
go-version: '1.13'
- name: Download dag-to-lua
working-directory: ./
run: make dag-lib
- name: Start manager-api
working-directory: ./api
run: |

View File

@ -216,6 +216,7 @@ The following components are provided under the MIT License. See project link fo
The text of each license is also included at licenses/LICENSE-[project].txt.
files from ant-design-pro: https://github.com/ant-design/ant-design-pro MIT
files from antvis-x6: https://github.com/antvis/X6 MIT
files from json.lua: https://github.com/rxi/json.lua MIT
========================================================================

View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2019 Alipay.inc
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -0,0 +1,132 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* eslint-disable no-undef */
context('Create and delete route with plugin orchestration', () => {
const selector = {
empty: '.ant-empty-normal',
name: '#name',
description: '#desc',
nodes_0_host: '#nodes_0_host',
nodes_0_port: '#nodes_0_port',
nodes_0_weight: '#nodes_0_weight',
groupButton: '.ant-radio-group',
canvas: '.x6-graph-svg',
startNode:
'#stencil > div > div.x6-widget-stencil-content > div:nth-child(1) > div > div > svg > g > g.x6-graph-svg-stage > g:nth-child(1) > g > circle',
notification: '.ant-notification-notice-message',
notificationClose: '.anticon-close',
nodeInput: '.x6-widget-stencil input[type="search"]',
hiddenGroup: '.x6-widget-stencil-group.unmatched',
canvasNode: '#container > svg > g > g.x6-graph-svg-stage > g:nth-child(2) > rect',
canvasContainer: '#container',
drawer: '.ant-drawer-content',
deleteAlert: '.ant-modal-body',
codemirrorScroll: '.CodeMirror-scroll',
};
beforeEach(() => {
cy.login();
});
it('should create route with plugin orchestration', function () {
cy.visit('/');
cy.contains('Route').click();
cy.get(selector.empty).should('be.visible');
cy.contains('Create').click();
cy.contains('Next').click().click();
cy.get(selector.name).type('routeName');
cy.get(selector.description).type('desc');
cy.contains('Next').click();
cy.get(selector.nodes_0_host).type('127.0.0.1');
cy.get(selector.nodes_0_port).clear().type('80');
cy.get(selector.nodes_0_weight).clear().type('1');
cy.contains('Next').click();
cy.get(selector.groupButton).contains('Orchestration').click();
cy.get(selector.canvas).should('be.visible');
// Plugin Orchestration
cy.get(selector.startNode).move({ x: 400, y: 0, force: true, position: 'center' });
cy.contains('Next').click();
cy.get(selector.notification).should('contain', 'Root node not found');
cy.get(selector.notificationClose).click().should('not.be.visible');
cy.get(selector.nodeInput).type('key-auth');
cy.get(selector.hiddenGroup).should('not.be.visible');
cy.contains('key-auth').move({ x: 300, y: 0, force: true, position: 'center' });
cy.contains('Next').click();
cy.get(selector.notification).should('contain', 'Root node not found');
cy.get(selector.notificationClose).click().should('not.be.visible');
// Linking nodes
cy.get(selector.canvasNode)
.click()
.then(() => {
const node2 = cy
.get('#container > svg > g > g.x6-graph-svg-stage > g:nth-child(2) > g > circle')
.eq(0);
const node1 = cy
.get('#container > svg > g > g.x6-graph-svg-stage > g:nth-child(1) > g > circle')
.eq(0);
node1
.trigger('mousedown')
.trigger('mousemove', { x: 0, y: 150, force: true })
.trigger('mouseup', { force: true });
});
cy.contains('Next').click();
cy.get(selector.notification).should('contain', 'Found node without configuration');
cy.get(selector.notificationClose).click().should('not.be.visible');
// Configuration plugins and submit
cy.get(selector.canvasContainer)
.click()
.within(() => {
cy.contains('key-auth').dblclick();
});
cy.contains('Submit').click();
cy.get(selector.drawer).should('not.exist');
cy.contains('Next').click();
cy.contains('Submit').click();
cy.contains('Submit Successfully');
cy.contains('Goto List').click();
cy.url().should('contains', 'routes/list');
});
it('should view and delete the route', function () {
cy.visit('/routes/list');
cy.contains('routeName').siblings().contains('More').click();
cy.contains('View').click();
cy.get(selector.codemirrorScroll).within(() => {
cy.contains('script').should('exist');
});
cy.contains('Cancel').click();
// Delete the route
cy.contains('routeName').siblings().contains('More').click();
cy.contains('Delete').click();
cy.get(selector.deleteAlert)
.should('be.visible')
.within(() => {
cy.contains('OK').click();
});
cy.get(selector.notification).should('contain', 'Delete Route Successfully');
});
});

View File

@ -17,6 +17,7 @@
/* eslint-disable no-undef */
import defaultSettings from '../../config/defaultSettings';
import 'cypress-file-upload';
import '@4tw/cypress-drag-drop';
Cypress.Commands.add('login', () => {
const { SERVE_ENV = 'dev' } = Cypress.env();

View File

@ -52,7 +52,8 @@
"@ant-design/icons": "^4.0.0",
"@ant-design/pro-layout": "^6.0.0",
"@ant-design/pro-table": "2.30.1",
"@mrblenny/react-flow-chart": "^0.0.14",
"@antv/x6": "^1.18.5",
"@antv/x6-react-components": "^1.1.7",
"@rjsf/antd": "2.2.0",
"@rjsf/core": "2.2.0",
"@types/js-yaml": "^4.0.0",
@ -88,6 +89,7 @@
"yaml": "^1.10.0"
},
"devDependencies": {
"@4tw/cypress-drag-drop": "^1.6.0",
"@ant-design/pro-cli": "^2.0.2",
"@cypress/code-coverage": "^3.9.2",
"@types/base-64": "^0.1.3",

View File

@ -53,6 +53,7 @@ type Props = {
readonly?: boolean;
visible: boolean;
maskClosable?: boolean;
isEnabled?: boolean;
onClose?: () => void;
onChange?: (data: any) => void;
};
@ -91,6 +92,7 @@ const PluginDetail: React.FC<Props> = ({
pluginList = [],
readonly = false,
maskClosable = true,
isEnabled = false,
initialData = {},
onClose = () => { },
onChange = () => { },
@ -144,7 +146,7 @@ const PluginDetail: React.FC<Props> = ({
useEffect(() => {
form.setFieldsValue({
disable: initialData[name] && !initialData[name].disable,
disable: isEnabled ? true : (initialData[name] && !initialData[name].disable),
scope: 'global',
});
if (PLUGIN_UI_LIST.includes(name)) {
@ -272,75 +274,77 @@ const PluginDetail: React.FC<Props> = ({
}
};
return (
<>
<Drawer
title={formatMessage({ id: 'component.plugin.editor' })}
visible={visible}
placement="right"
closable={false}
maskClosable={maskClosable}
onClose={onClose}
width={700}
footer={
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
{' '}
<Button onClick={onClose} key={1}>
{formatMessage({ id: 'component.global.cancel' })}
</Button>
<Space>
<Popconfirm
title={formatMessage({ id: 'page.plugin.drawer.popconfirm.title.delete' })}
okText={formatMessage({ id: 'component.global.confirm' })}
cancelText={formatMessage({ id: 'component.global.cancel' })}
disabled={readonly}
onConfirm={() => {
onChange({
formData: form.getFieldsValue(),
codemirrorData: {},
shouldDelete: true,
});
}}
>
{initialData[name] ? (
<Button key={3} type="primary" danger disabled={readonly}>
{formatMessage({ id: 'component.global.delete' })}
</Button>
) : null}
</Popconfirm>
<Button
key={2}
disabled={readonly}
type="primary"
onClick={() => {
try {
let editorData;
if (codeMirrorMode === codeMirrorModeList.JSON) {
editorData = JSON.parse(ref.current?.editor.getValue());
} else if (codeMirrorMode === codeMirrorModeList.YAML) {
editorData = yaml2json(ref.current?.editor.getValue(), false).data;
} else {
editorData = getUIFormData();
}
const isNoConfigurationRequired = pluginType === PluginType.authentication && schemaType !== 'consumer' && (codeMirrorMode !== codeMirrorModeList.UIForm)
validateData(name, editorData).then((value) => {
onChange({ formData: form.getFieldsValue(), codemirrorData: value });
});
} catch (error) {
notification.error({
message: 'Invalid JSON data',
});
return (
<Drawer
title={formatMessage({ id: 'component.plugin.editor' })}
visible={visible}
placement="right"
closable={false}
maskClosable={maskClosable}
destroyOnClose
onClose={onClose}
width={700}
footer={
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
{' '}
<Button onClick={onClose} key={1}>
{formatMessage({ id: 'component.global.cancel' })}
</Button>
<Space>
<Popconfirm
title={formatMessage({ id: 'page.plugin.drawer.popconfirm.title.delete' })}
okText={formatMessage({ id: 'component.global.confirm' })}
cancelText={formatMessage({ id: 'component.global.cancel' })}
disabled={readonly}
onConfirm={() => {
onChange({
formData: form.getFieldsValue(),
codemirrorData: {},
shouldDelete: true,
});
}}
>
{initialData[name] ? (
<Button key={3} type="primary" danger disabled={readonly}>
{formatMessage({ id: 'component.global.delete' })}
</Button>
) : null}
</Popconfirm>
<Button
key={2}
disabled={readonly}
type="primary"
onClick={() => {
try {
let editorData;
if (codeMirrorMode === codeMirrorModeList.JSON) {
editorData = JSON.parse(ref.current?.editor.getValue());
} else if (codeMirrorMode === codeMirrorModeList.YAML) {
editorData = yaml2json(ref.current?.editor.getValue(), false).data;
} else {
editorData = getUIFormData();
}
}}
>
{formatMessage({ id: 'component.global.submit' })}
</Button>
</Space>
</div>
}
>
<style>
{`
validateData(name, editorData).then((value) => {
onChange({ formData: form.getFieldsValue(), codemirrorData: value });
});
} catch (error) {
notification.error({
message: 'Invalid JSON data',
});
}
}}
>
{formatMessage({ id: 'component.global.submit' })}
</Button>
</Space>
</div>
}
>
<style>
{`
.site-page-header {
border: 1px solid rgb(235, 237, 240);
margin-top:10px;
@ -349,89 +353,88 @@ const PluginDetail: React.FC<Props> = ({
color: #000;
}
`}
</style>
</style>
<Form {...FORM_ITEM_LAYOUT} style={{ marginTop: '10px' }} form={form}>
<Form.Item label={formatMessage({ id: 'component.global.name' })}>
<Input value={name} bordered={false} disabled />
<Form {...FORM_ITEM_LAYOUT} style={{ marginTop: '10px' }} form={form}>
<Form.Item label={formatMessage({ id: 'component.global.name' })}>
<Input value={name} bordered={false} disabled />
</Form.Item>
<Form.Item label={formatMessage({ id: 'component.global.enable' })} valuePropName="checked" name="disable">
<Switch
defaultChecked={isEnabled ? true : initialData[name] && !initialData[name].disable}
disabled={readonly || isEnabled}
/>
</Form.Item>
{type === 'global' && (
<Form.Item label={formatMessage({ id: 'component.global.scope' })} name="scope">
<Select disabled>
<Select.Option value="global">{formatMessage({ id: "other.global" })}</Select.Option>
</Select>
</Form.Item>
<Form.Item label={formatMessage({ id: 'component.global.enable' })} valuePropName="checked" name="disable">
<Switch
defaultChecked={initialData[name] && !initialData[name].disable}
disabled={readonly}
/>
</Form.Item>
{type === 'global' && (
<Form.Item label={formatMessage({ id: 'component.global.scope' })} name="scope">
<Select disabled>
<Select.Option value="global">{formatMessage({ id: "other.global" })}</Select.Option>
</Select>
</Form.Item>
)}
</Form>
<Divider orientation="left">{formatMessage({ id: 'component.global.data.editor' })}</Divider>
<PageHeader
title=""
subTitle={
pluginType === PluginType.authentication && schemaType !== 'consumer' && (codeMirrorMode !== codeMirrorModeList.UIForm) ? (
<Alert message={formatMessage({ id: 'component.plugin.noConfigurationRequired' })} type="warning" />
) : null
}
ghost={false}
extra={[
<Select
defaultValue={codeMirrorModeList.JSON}
value={codeMirrorMode}
options={modeOptions}
onChange={(value: PluginComponent.CodeMirrorMode) => {
handleModeChange(value);
}}
data-cy='code-mirror-mode'
key={1}
></Select>,
<Tooltip title={formatMessage({ id: "component.plugin.format-codes.disable" })} key={2}>
<Button type="primary" onClick={formatCodes} disabled={codeMirrorMode === codeMirrorModeList.UIForm}>
{formatMessage({ id: 'component.global.format' })}
</Button>
</Tooltip>,
<Button
type="default"
icon={<LinkOutlined />}
onClick={() => {
if (name.startsWith('serverless')) {
window.open('https://apisix.apache.org/docs/apisix/plugins/serverless');
} else {
window.open(`https://apisix.apache.org/docs/apisix/plugins/${name}`);
}
}}
key={3}
>
{formatMessage({ id: 'component.global.document' })}
)}
</Form>
<Divider orientation="left">{formatMessage({ id: 'component.global.data.editor' })}</Divider>
<PageHeader
title=""
subTitle={
isNoConfigurationRequired ? (
<Alert message={formatMessage({ id: 'component.plugin.noConfigurationRequired' })} type="warning" />
) : null
}
ghost={false}
extra={[
<Select
defaultValue={codeMirrorModeList.JSON}
value={codeMirrorMode}
options={modeOptions}
onChange={(value: PluginComponent.CodeMirrorMode) => {
handleModeChange(value);
}}
data-cy='code-mirror-mode'
key={1}
></Select>,
<Tooltip title={formatMessage({ id: "component.plugin.format-codes.disable" })} key={2}>
<Button type="primary" onClick={formatCodes} disabled={codeMirrorMode === codeMirrorModeList.UIForm}>
{formatMessage({ id: 'component.global.format' })}
</Button>
]}
/>
{Boolean(codeMirrorMode === codeMirrorModeList.UIForm) && <PluginForm name={name} form={UIForm} renderForm={!(pluginType === PluginType.authentication && schemaType !== 'consumer')} />}
<div style={{ display: codeMirrorMode === codeMirrorModeList.UIForm ? 'none' : 'unset' }}><CodeMirror
ref={(codemirror) => {
ref.current = codemirror;
if (codemirror) {
// NOTE: for debug & test
// @ts-ignore
window.codemirror = codemirror.editor;
}
}}
value={JSON.stringify(data, null, 2)}
options={{
mode: codeMirrorMode,
readOnly: readonly ? 'nocursor' : '',
lineWrapping: true,
lineNumbers: true,
showCursorWhenSelecting: true,
autofocus: true,
}} />
</div>
</Drawer>
</>
</Tooltip>,
<Button
type="default"
icon={<LinkOutlined />}
onClick={() => {
if (name.startsWith('serverless')) {
window.open('https://apisix.apache.org/docs/apisix/plugins/serverless');
} else {
window.open(`https://apisix.apache.org/docs/apisix/plugins/${name}`);
}
}}
key={3}
>
{formatMessage({ id: 'component.global.document' })}
</Button>
]}
/>
{Boolean(codeMirrorMode === codeMirrorModeList.UIForm) && <PluginForm name={name} form={UIForm} renderForm={!(pluginType === PluginType.authentication && schemaType !== 'consumer')} />}
<div style={{ display: codeMirrorMode === codeMirrorModeList.UIForm ? 'none' : 'unset' }}><CodeMirror
ref={(codemirror) => {
ref.current = codemirror;
if (codemirror) {
// NOTE: for debug & test
// @ts-ignore
window.codemirror = codemirror.editor;
}
}}
value={JSON.stringify(data, null, 2)}
options={{
mode: codeMirrorMode,
readOnly: (readonly || isNoConfigurationRequired) ? 'nocursor' : '',
lineWrapping: true,
lineNumbers: true,
showCursorWhenSelecting: true,
autofocus: true,
}} />
</div>
</Drawer>
);
};

View File

@ -19,7 +19,17 @@ import { request } from 'umi';
import { PLUGIN_LIST, PluginType } from './data';
const cached: {
list: PluginComponent.Meta[]
} = {
list: []
}
export const fetchList = () => {
if (cached.list.length) {
return Promise.resolve(cached.list)
}
return request<Res<PluginComponent.Meta[]>>('/plugins?all=true').then((data) => {
const typedData = data.data.map(item => ({
...item,
@ -33,6 +43,10 @@ export const fetchList = () => {
finalList = finalList.concat(typedData.filter(item => item.type === type))
})
if (cached.list.length === 0) {
cached.list = finalList
}
return finalList
});
};

View File

@ -0,0 +1,203 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useEffect, useState } from 'react'
import { Modal, Form, Input, Alert } from 'antd'
import { Cell } from '@antv/x6'
import { useIntl } from 'umi'
import FlowGraph from './components/FlowGraph'
import Toolbar from './components/Toolbar'
import { DEFAULT_CONDITION_PROPS, DEFAULT_PLUGIN_PROPS, DEFAULT_STENCIL_WIDTH, DEFAULT_TOOLBAR_HEIGHT, FlowGraphEvent } from './constants'
import styles from './style.less'
import PluginDetail from '../Plugin/PluginDetail'
import { fetchList } from '../Plugin/service'
type Props = {
chart: {
cells: Cell.Properties[];
};
readonly?: boolean;
}
type PluginProps = {
id: string;
name: string;
visible: boolean;
data: any;
}
type ConditionProps = {
id: string;
visible: boolean;
data: string;
}
const PluginFlow: React.FC<Props> = ({ chart, readonly = false }) => {
const { formatMessage } = useIntl()
// NOTE: To prevent from graph is not initialized
const [isReady, setIsReady] = useState(false)
const [plugins, setPlugins] = useState<PluginComponent.Meta[]>([])
const [pluginProps, setPluginProps] = useState<PluginProps>(DEFAULT_PLUGIN_PROPS)
const [conditionProps, setConditionProps] = useState<ConditionProps>(DEFAULT_CONDITION_PROPS)
const getContainerSize = () => {
const leftSidebar = document.querySelector('aside.ant-layout-sider')
const blankSpaceWidth = 24 * 4
const globalHeaderHeight = 48
const pageHeaderHeight = 72
const otherHeight = 191
const width = document.body.offsetWidth - (leftSidebar?.clientWidth || 0) - blankSpaceWidth - DEFAULT_STENCIL_WIDTH
const height = document.body.offsetHeight - globalHeaderHeight - pageHeaderHeight - otherHeight
return {
width,
height: height < 800 ? 800 : height
}
}
useEffect(() => {
if (!plugins.length) {
return
}
const container = document.getElementById("container")
if (!container) {
return
}
const siderbarCollapsedButton = document.querySelector('.ant-pro-sider-collapsed-button')
const graph = FlowGraph.init(container, plugins, chart);
(window as any).graph = FlowGraph
setIsReady(true)
const stencilContainer = document.querySelector('#stencil') as HTMLElement
const handleResize = () => {
const { width, height } = getContainerSize()
graph.resize(width, height)
stencilContainer.style.height = `${height + DEFAULT_TOOLBAR_HEIGHT}px`
stencilContainer.style.width = `${DEFAULT_STENCIL_WIDTH}px`
}
const handleLeftSidebarResize = () => {
setTimeout(() => {
handleResize()
}, 200)
}
handleResize()
graph.on(FlowGraphEvent.PLUGIN_CHANGE, setPluginProps)
graph.on(FlowGraphEvent.CONDITION_CHANGE, (props: ConditionProps) => {
setConditionProps(props)
})
if (readonly) {
graph.disableKeyboard()
}
window.addEventListener("resize", handleResize)
siderbarCollapsedButton?.addEventListener('click', handleLeftSidebarResize)
// eslint-disable-next-line
return () => {
window.removeEventListener("resize", handleResize)
siderbarCollapsedButton?.removeEventListener('click', handleLeftSidebarResize)
}
}, [plugins])
useEffect(() => {
fetchList().then(setPlugins)
}, [])
return (
<React.Fragment>
{readonly && <Alert type="warning" message={formatMessage({ id: 'component.plugin-flow.text.preview.readonly' })} showIcon style={{ marginBottom: 20 }} />}
<div className={styles.container}>
<div id="stencil" className={styles.stencil} style={readonly ? { width: 0, height: 0 } : {}} />
<div className={styles.panel}>
<div className={styles.toolbar}>{isReady && <Toolbar />}</div>
<div id="container" className={styles.flow}></div>
</div>
</div>
{
pluginProps.visible && (
<PluginDetail
readonly={readonly}
schemaType="route"
name={pluginProps.name}
visible={pluginProps.visible}
pluginList={plugins}
isEnabled
initialData={{
// NOTE: We use {PluginName: data} because initialData is all plugins' data
[pluginProps.name]: pluginProps.data
}}
onClose={() => {
setPluginProps(DEFAULT_PLUGIN_PROPS)
}}
onChange={({ formData, codemirrorData, shouldDelete }) => {
if (shouldDelete) {
FlowGraph.graph.removeCell(pluginProps.id)
} else {
const disable = !formData.disable
FlowGraph.setData(pluginProps.id, { ...codemirrorData, disable })
}
setPluginProps(DEFAULT_PLUGIN_PROPS)
}}
/>
)
}
<Modal
visible={conditionProps.visible}
title={formatMessage({ id: 'component.plugin-flow.text.condition.required' })}
onOk={() => {
FlowGraph.setData(conditionProps.id, conditionProps.data);
setConditionProps(DEFAULT_CONDITION_PROPS)
}}
onCancel={() => setConditionProps(DEFAULT_CONDITION_PROPS)}
okText={formatMessage({ id: 'component.global.confirm' })}
cancelText={formatMessage({ id: 'component.global.cancel' })}
okButtonProps={{
disabled: readonly
}}
>
<Form.Item label={formatMessage({ id: 'component.plugin-flow.text.condition' })} style={{ marginBottom: 0 }} tooltip={formatMessage({ id: 'component.plugin-flow.text.condition-rule.tooltip' })}>
<Input
value={conditionProps.data}
disabled={readonly}
placeholder={formatMessage({ id: 'component.plugin-flow.text.condition.placeholder' })}
onChange={e => {
setConditionProps({
...conditionProps,
data: e.target.value
})
}}
/>
</Form.Item>
</Modal>
</React.Fragment>
)
}
export default PluginFlow

View File

@ -0,0 +1,394 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Graph, Addon, FunctionExt } from '@antv/x6'
import type { Model, Cell } from '@antv/x6'
import { formatMessage } from 'umi'
import { notification } from 'antd'
import './shapes'
import { DEFAULT_OPINIONS, DEFAULT_PLUGIN_FLOW_DATA, DEFAULT_STENCIL_OPINIONS, FlowGraphEvent, FlowGraphShape } from '../../constants'
class FlowGraph {
public static graph: Graph
private static stencil: Addon.Stencil
private static pluginTypeList: string[] = []
private static plugins: PluginComponent.Meta[] = []
public static init(container: HTMLElement, plugins: PluginComponent.Meta[] = [], chart: Model.FromJSONData) {
this.graph = new Graph({
container,
...DEFAULT_OPINIONS
})
this.plugins = plugins
this.pluginTypeList = Array.from(new Set(plugins.map(item => item.type)))
this.initStencil()
this.initShape()
this.initGraphShape(chart)
this.initEvent()
return this.graph
}
// NOTE: set cell data according to Cell ID
public static setData(id: string, data: any): void {
const cell = this.graph.getCell(id)
if (cell) {
cell.setData(data, { overwrite: true })
}
}
// NOTE: Generate groups for stencil
private static generateGroups(): Addon.Stencil.Group[] {
const otherGroupList = [{
name: 'basic',
title: formatMessage({ id: 'component.plugin-flow.text.general' }),
graphHeight: 104,
}]
const pluginGroupList = this.pluginTypeList.map(item => {
const count = this.plugins.filter(plugin => plugin.type === item).length
return {
name: item,
title: formatMessage({ id: `component.plugin.${item}` }),
layoutOptions: {
columns: 1,
marginX: 60,
},
graphHeight: count * 82,
}
})
return otherGroupList.concat(pluginGroupList)
}
private static initStencil() {
this.stencil = new Addon.Stencil({
target: this.graph,
...DEFAULT_STENCIL_OPINIONS,
groups: this.generateGroups()
})
const stencilContainer = document.querySelector('#stencil')
stencilContainer?.appendChild(this.stencil.container)
}
private static initShape() {
const { graph } = this
const r1 = graph.createNode({
shape: FlowGraphShape.start,
attrs: {
body: {
rx: 24,
ry: 24,
},
text: {
textWrap: {
text: formatMessage({ id: 'component.plugin-flow.text.start-node' }),
},
},
},
})
const r3 = graph.createNode({
shape: FlowGraphShape.condition,
width: 58,
height: 58,
angle: 45,
attrs: {
text: {
textWrap: {
text: formatMessage({ id: 'component.plugin-flow.text.condition2' }),
},
transform: 'rotate(-45deg)',
},
},
ports: {
groups: {
top: {
position: {
name: 'top',
args: {
dx: -26,
},
},
},
right: {
position: {
name: 'right',
args: {
dy: -26,
},
},
},
bottom: {
position: {
name: 'bottom',
args: {
dx: 26,
},
},
},
left: {
position: {
name: 'left',
args: {
dy: 26,
},
},
},
},
},
})
this.stencil.load([r1, r3], 'basic')
this.pluginTypeList.forEach(type => {
const plugins = this.plugins.filter(plugin => plugin.type === type).map(plugin => {
return graph.createNode({
shape: FlowGraphShape.plugin,
attrs: {
title: {
text: plugin.name
},
text: {
text: plugin.name
}
}
})
})
this.stencil.load(plugins, type)
})
}
private static initGraphShape(chart: Model.FromJSONData) {
if (!chart) {
return
}
this.graph.fromJSON(chart)
}
private static showPorts(ports: NodeListOf<SVGAElement>, show: boolean) {
// eslint-disable-next-line
for (let i = 0, len = ports.length; i < len; i = i + 1) {
// eslint-disable-next-line
ports[i].style.visibility = show ? 'visible' : 'hidden'
}
}
private static initEvent() {
const { graph } = this
const container = document.getElementById('container')!
graph.on(
'node:mouseenter',
FunctionExt.debounce(() => {
const ports = container.querySelectorAll(
'.x6-port-body',
) as NodeListOf<SVGAElement>
this.showPorts(ports, true)
}),
500,
)
graph.on('node:mouseleave', () => {
const ports = container.querySelectorAll(
'.x6-port-body',
) as NodeListOf<SVGAElement>
this.showPorts(ports, false)
})
graph.on('node:dblclick', ({ node }) => {
if (node.shape === FlowGraphShape.plugin) {
const name = node.getAttrByPath('text/text') as string
if (!name) {
return
}
this.graph.trigger(FlowGraphEvent.PLUGIN_CHANGE, {
visible: true,
id: node.id,
name,
data: node.getData()
})
}
if (node.shape === FlowGraphShape.condition) {
this.graph.trigger(FlowGraphEvent.CONDITION_CHANGE, {
id: node.id,
data: node.getData(),
visible: true
})
}
})
graph.bindKey('backspace', () => {
const cells = graph.getSelectedCells()
if (cells.length) {
graph.removeCells(cells)
}
})
}
private static getNextCell(id = '', position = ''): Cell.Properties | undefined {
const { cells = [] } = this.graph.toJSON()
const cell = cells.find(item => item.id === id)
if (!cell) {
return undefined
}
if (!cell.ports) {
return undefined
}
const port = cell.ports.items.find((item: { group: string }) => item.group === position)
if (!port) {
return undefined
}
const targetCellId = cells.find(item => item.source?.port === port.id && item.source?.cell === id)?.target.cell
const targetCell = cells.find(item => item.id === targetCellId)
return targetCell
}
private static getLeafList(currentId = '') {
let ids: string[] = []
const fn = (id: string) => {
const cell = this.getNextCell(id, "right")
if (!cell || !cell.id) {
return
}
ids = ids.concat(cell.id);
fn(cell.id)
}
fn(currentId)
return [currentId].concat(ids)
}
/**
* Convert Graph JSON Data to API Request Body Data
*/
public static convertToData(chart: typeof DEFAULT_PLUGIN_FLOW_DATA.chart | undefined = undefined): {
chart: {
cells: Cell.Properties[];
};
conf: Record<string, any>;
rule: Record<string, any>;
} | undefined {
const data = {
...DEFAULT_PLUGIN_FLOW_DATA,
chart: chart || this.graph.toJSON()
}
const { cells = [] } = data.chart
const edgeCells = cells.filter(cell => cell.shape === 'edge')
const startCell = cells.find(cell => cell.shape === FlowGraphShape.start)
if (!startCell) {
notification.warn({
message: formatMessage({ id: 'component.plugin-flow.text.no-start-node' })
})
return
}
const rootCell = cells.find(cell => cell.shape === 'edge' && cell.source.cell === startCell.id)
if (!rootCell) {
notification.warn({
message: formatMessage({ id: 'component.plugin-flow.text.no-root-node' })
})
return
}
data.rule.root = rootCell.target.cell
// Get the ID associated with each node, the relationship between nodes is in edgeCells.
edgeCells.forEach(edge => {
const sourceId = edge.source.cell
const targetId = edge.target.cell
data.rule[sourceId] = []
this.getLeafList(targetId).forEach(id => {
const cell = cells.find(item => item.id === id)
if (!cell) {
return
}
if (cell.shape === FlowGraphShape.condition) {
const nextCell = this.getNextCell(cell.id, "bottom");
if (!nextCell) {
return
}
data.rule[sourceId].push([cell.data, nextCell.id])
}
if (cell.shape === FlowGraphShape.plugin) {
data.rule[sourceId].push(['', cell.id])
}
})
})
// NOTE: Omit empty array, or API will throw error.
Object.entries(data.rule).forEach(([key, value]) => {
if (value.length === 0) {
delete data.rule[key]
}
if (key === 'root') {
return
}
const cell = cells.find(item => item.id === key)
if (cell?.shape !== FlowGraphShape.plugin) {
delete data.rule[key]
}
})
const invalidPluginCell = cells.find(item => item.shape === FlowGraphShape.plugin && !item.data)
if (invalidPluginCell) {
notification.warn({
message: formatMessage({ id: 'component.plugin-flow.text.without-data' }),
description: `${formatMessage({ id: 'component.plugin-flow.text.plugin-without-data.description' })}${invalidPluginCell.attrs?.text.text}`
})
return
}
const invalidConditionCell = cells.find(item => item.shape === FlowGraphShape.condition && !item.data)
if (invalidConditionCell) {
notification.warn({
message: formatMessage({ id: 'component.plugin-flow.text.without-data' }),
description: `${formatMessage({ id: 'component.plugin-flow.text.condition-without-configuration' })}`
})
return
}
data.conf = {}
cells.filter(item => item.shape === FlowGraphShape.plugin && item.id).forEach(item => {
if (item.id) {
data.conf[item.id] = {
name: item.attrs?.text.text,
conf: item.data
}
}
})
// eslint-disable-next-line
return data
}
}
export default FlowGraph

View File

@ -14,5 +14,4 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './Page';
export * from './SidebarItem';
export { default } from './FlowGraph'

View File

@ -0,0 +1,161 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Graph } from '@antv/x6'
import defaultPluginImg from '../../../../../public/static/default-plugin.png';
import { DEFAULT_SHAPE_RECT_OPINIONS, FlowGraphShape } from '../../constants';
export const FlowChartRect = Graph.registerNode(FlowGraphShape.base, DEFAULT_SHAPE_RECT_OPINIONS)
export const FlowChartConditionRect = Graph.registerNode(FlowGraphShape.condition, {
...DEFAULT_SHAPE_RECT_OPINIONS,
ports: {
...DEFAULT_SHAPE_RECT_OPINIONS.ports,
items: [
{
group: 'top',
},
{
group: 'right',
},
{
group: 'bottom',
},
],
}
})
export const FlowChartStartRect = Graph.registerNode(FlowGraphShape.start, {
...DEFAULT_SHAPE_RECT_OPINIONS,
ports: {
...DEFAULT_SHAPE_RECT_OPINIONS.ports,
items: [
{
group: 'bottom',
},
],
}
})
export const FlowChartEndRect = Graph.registerNode(FlowGraphShape.end, {
...DEFAULT_SHAPE_RECT_OPINIONS,
ports: {
...DEFAULT_SHAPE_RECT_OPINIONS.ports,
items: [
{
group: 'top',
},
],
}
})
export const FlowChartPluginRect = Graph.registerNode(FlowGraphShape.plugin, {
inherit: 'rect',
width: 200,
height: 60,
attrs: {
body: {
stroke: '#5F95FF',
strokeWidth: 1,
fill: 'rgba(95,149,255,0.05)',
},
image: {
'xlink:href':
defaultPluginImg,
width: 16,
height: 16,
x: 12,
y: 12,
},
title: {
text: 'Unknown Plugin',
refX: 40,
refY: 14,
fill: 'rgba(0,0,0,0.85)',
fontSize: 12,
'text-anchor': 'start',
},
text: {
text: '',
refX: 40,
refY: 38,
fontSize: 12,
fill: 'rgba(0,0,0,0.6)',
'text-anchor': 'start',
},
},
markup: [
{
tagName: 'rect',
selector: 'body',
},
{
tagName: 'image',
selector: 'image',
},
{
tagName: 'text',
selector: 'title',
},
{
tagName: 'text',
selector: 'text',
},
],
ports: {
groups: {
top: {
position: 'top',
attrs: {
circle: {
r: 3,
magnet: true,
stroke: '#5F95FF',
strokeWidth: 1,
fill: '#fff',
style: {
visibility: 'hidden',
},
},
},
},
bottom: {
position: 'bottom',
attrs: {
circle: {
r: 3,
magnet: true,
stroke: '#5F95FF',
strokeWidth: 1,
fill: '#fff',
style: {
visibility: 'hidden',
},
},
},
},
},
items: [
{
group: 'top',
},
{
group: 'bottom',
}
],
},
})

View File

@ -0,0 +1,195 @@
/*
* MIT License
* Copyright (c) 2019 Alipay.inc
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React, { useEffect, useState } from 'react'
import { Toolbar } from '@antv/x6-react-components'
import FlowGraph from '../FlowGraph'
import { DataUri } from '@antv/x6'
import {
ClearOutlined,
SaveOutlined,
PrinterOutlined,
UndoOutlined,
RedoOutlined,
CopyOutlined,
ScissorOutlined,
SnippetsOutlined,
} from '@ant-design/icons'
import '@antv/x6-react-components/es/toolbar/style/index.css'
const { Item, Group } = Toolbar
const ToolbarComponent = () => {
const [canUndo, setCanUndo] = useState(false)
const [canRedo, setCanRedo] = useState(false)
const copy = () => {
const { graph } = FlowGraph
const cells = graph.getSelectedCells()
if (cells.length) {
graph.copy(cells)
}
return false
}
const cut = () => {
const { graph } = FlowGraph
const cells = graph.getSelectedCells()
if (cells.length) {
graph.cut(cells)
}
return false
}
const paste = () => {
const { graph } = FlowGraph
if (!graph.isClipboardEmpty()) {
const cells = graph.paste({ offset: 32 })
graph.cleanSelection()
graph.select(cells)
}
return false
}
useEffect(() => {
const { graph } = FlowGraph
const { history } = graph
setCanUndo(history.canUndo())
setCanRedo(history.canRedo())
history.on('change', () => {
setCanUndo(history.canUndo())
setCanRedo(history.canRedo())
})
graph.bindKey(['meta+z', 'ctrl+z'], () => {
if (history.canUndo()) {
history.undo()
}
return false
})
graph.bindKey(['meta+shift+z', 'ctrl+y'], () => {
if (history.canRedo()) {
history.redo()
}
return false
})
graph.bindKey(['meta+d', 'ctrl+d'], () => {
graph.clearCells()
return false
})
graph.bindKey(['meta+s', 'ctrl+s'], () => {
graph.toPNG((datauri: string) => {
DataUri.downloadDataUri(datauri, 'chart.png')
})
return false
})
graph.bindKey(['meta+p', 'ctrl+p'], () => {
graph.printPreview()
return false
})
graph.bindKey(['meta+c', 'ctrl+c'], copy)
graph.bindKey(['meta+v', 'ctrl+v'], paste)
graph.bindKey(['meta+x', 'ctrl+x'], cut)
}, [])
const handleClick = (name: string) => {
const { graph } = FlowGraph
switch (name) {
case 'undo':
graph.history.undo()
break
case 'redo':
graph.history.redo()
break
case 'delete':
graph.clearCells()
break
case 'save':
graph.toPNG((datauri: string) => {
DataUri.downloadDataUri(datauri, 'chart.png')
})
break
case 'print':
graph.printPreview()
break
case 'copy':
copy()
break
case 'cut':
cut()
break
case 'paste':
paste()
break
default:
break
}
}
return (
<div>
<Toolbar hoverEffect={true} size="small" onClick={handleClick}>
<Group>
<Item
name="delete"
icon={<ClearOutlined />}
tooltip="Clear (Cmd + D, Ctrl + D)"
/>
</Group>
<Group>
<Item
name="undo"
tooltip="Undo (Cmd + Z, Ctrl + Z)"
icon={<UndoOutlined />}
disabled={!canUndo}
/>
<Item
name="redo"
tooltip="Redo (Cmd + Shift + Z, Ctrl + Y)"
icon={<RedoOutlined />}
disabled={!canRedo}
/>
</Group>
<Group>
<Item name="copy" tooltip="Copy (Cmd + C, Ctrl + C)" icon={<CopyOutlined />} />
<Item name="cut" tooltip="Cut (Cmd + X, Ctrl + X)" icon={<ScissorOutlined />} />
<Item
name="paste"
tooltip="Paste (Cmd + V, Ctrl + V)"
icon={<SnippetsOutlined />}
/>
</Group>
<Group>
<Item name="save" icon={<SaveOutlined />} tooltip="Save (Cmd + S, Ctrl + S)" />
<Item
name="print"
icon={<PrinterOutlined />}
tooltip="Print (Cmd + P, Ctrl + P)"
/>
</Group>
</Toolbar>
</div>
)
}
export default ToolbarComponent

View File

@ -0,0 +1,294 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Shape, Dom } from '@antv/x6'
import type { Addon, Graph, Cell } from '@antv/x6'
import { formatMessage } from '@/.umi/plugin-locale/localeExports'
export const DEFAULT_STENCIL_WIDTH = 280
export const DEFAULT_TOOLBAR_HEIGHT = 38
export const DEFAULT_SHAPE_TEXT_EDIT_CLASS_NAME = ".flow-graph-shape-text-editor"
export const DEFAULT_OPINIONS: Partial<Graph.Options> = {
scroller: true,
width: 800,
height: 600,
grid: {
size: 10,
visible: true,
},
selecting: {
enabled: true,
multiple: true,
rubberband: true,
movable: true,
showNodeSelectionBox: true,
filter: ['groupNode'],
},
connecting: {
allowBlank: false,
highlight: true,
snap: true,
createEdge() {
return new Shape.Edge({
attrs: {
line: {
stroke: '#5F95FF',
strokeWidth: 1,
targetMarker: {
name: 'classic',
size: 8,
},
},
},
router: {
name: 'manhattan',
},
zIndex: 0,
})
},
validateConnection({
sourceView,
targetView,
sourceMagnet,
targetMagnet,
}) {
if (sourceView === targetView) {
return false
}
if (!sourceMagnet) {
return false
}
if (!targetMagnet) {
return false
}
return true
},
},
highlighting: {
magnetAvailable: {
name: 'stroke',
args: {
padding: 4,
attrs: {
strokeWidth: 4,
stroke: 'rgba(223,234,255)',
},
},
},
},
snapline: true,
history: true,
clipboard: {
enabled: true,
},
keyboard: {
enabled: true,
},
embedding: {
enabled: true,
findParent({ node }) {
const bbox = node.getBBox()
return this.getNodes().filter((item) => {
const data = item.getData<any>()
if (data && data.parent) {
const targetBBox = item.getBBox()
return bbox.isIntersectWithRect(targetBBox)
}
return false
})
},
},
}
export const DEFAULT_STENCIL_OPINIONS: Partial<Addon.Stencil.Options> = {
title: formatMessage({ id: 'component.plugin-flow.text.nodes-area' }),
stencilGraphWidth: DEFAULT_STENCIL_WIDTH,
search: (cell, keyword) => {
if (keyword) {
return (cell as any).label?.indexOf(keyword) !== -1
}
return true
},
notFoundText: formatMessage({ id: 'component.plugin-flow.text.nodes.not-found' }),
placeholder: formatMessage({ id: 'component.plugin-flow.text.search-nodes.placeholder' }),
collapsable: true,
}
export const DEFAULT_SHAPE_RECT_OPINIONS = {
inherit: 'rect',
width: 80,
height: 42,
attrs: {
body: {
stroke: '#5F95FF',
strokeWidth: 1,
fill: 'rgba(95,149,255,0.05)',
},
fo: {
refWidth: '100%',
refHeight: '100%',
},
foBody: {
xmlns: Dom.ns.xhtml,
style: {
width: '100%',
height: '100%',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
},
},
text: {
fontSize: 12,
fill: 'rgba(0,0,0,0.85)',
textWrap: {
text: '',
width: -10,
},
},
},
markup: [
{
tagName: 'rect',
selector: 'body',
},
{
tagName: 'text',
selector: 'text',
}
],
ports: {
groups: {
top: {
position: 'top',
attrs: {
circle: {
r: 3,
magnet: true,
stroke: '#5F95FF',
strokeWidth: 1,
fill: '#fff',
style: {
visibility: 'hidden',
},
},
},
},
right: {
position: 'right',
attrs: {
circle: {
r: 3,
magnet: true,
stroke: '#5F95FF',
strokeWidth: 1,
fill: '#fff',
style: {
visibility: 'hidden',
},
},
},
},
bottom: {
position: 'bottom',
attrs: {
circle: {
r: 3,
magnet: true,
stroke: '#5F95FF',
strokeWidth: 1,
fill: '#fff',
style: {
visibility: 'hidden',
},
},
},
},
left: {
position: 'left',
attrs: {
circle: {
r: 3,
magnet: true,
stroke: '#5F95FF',
strokeWidth: 1,
fill: '#fff',
style: {
visibility: 'hidden',
},
},
},
},
},
items: [
{
group: 'top',
},
{
group: 'right',
},
{
group: 'bottom',
},
{
group: 'left',
},
],
},
}
export enum FlowGraphShape {
base = 'flow-chart-rect',
condition = 'flow-chart-condition-rect',
start = 'flow-chart-start-rect',
end = 'flow-chart-end-rect',
plugin = 'flow-chart-plugin-rect'
}
export enum FlowGraphEvent {
PLUGIN_CHANGE = 'flowgraph:change:plugin',
CONDITION_CHANGE = 'flowgraph:change:condition',
}
export const DEFAULT_PLUGIN_PROPS = {
id: '',
name: '',
visible: false,
data: {}
}
export const DEFAULT_CONDITION_PROPS = {
visible: false,
id: '',
data: ''
}
export const DEFAULT_PLUGIN_FLOW_DATA: {
chart: {
cells: Cell.Properties[];
};
conf: Record<string, any>;
rule: Record<string, any>;
} = {
chart: {
cells: []
},
conf: {},
rule: {
root: ""
}
}

View File

@ -14,7 +14,4 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as React from 'react';
import { SPageContent } from '../DrawPluginStyle';
export const Page: React.FC = (props) => <SPageContent>{props.children}</SPageContent>;
export { default } from './PluginFlow'

View File

@ -0,0 +1,41 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export default {
'component.plugin-flow.text.condition.required': 'Configure Rule',
'component.plugin-flow.text.condition': 'Rule',
'component.plugin-flow.text.condition2': 'Condition',
'component.plugin-flow.text.condition.placeholder': 'Please enter the rule',
'component.plugin-flow.text.without-data': 'Found node without configuration',
'component.plugin-flow.text.plugin-without-data.description': 'Please condigure plugin: ',
'component.plugin-flow.text.no-start-node': 'Please connect the start node',
'component.plugin-flow.text.no-root-node': 'Root node not found',
'component.plugin-flow.text.start-node': 'Start',
'component.plugin-flow.text.general': 'General',
'component.plugin-flow.text.nodes-area': 'Available Nodes',
'component.plugin-flow.text.nodes.not-found': 'Not Found',
'component.plugin-flow.text.search-nodes.placeholder': 'Search plugin by name',
'component.plugin-flow.text.condition-rule.tooltip': 'The judgment condition of the node. e.g: code == 503',
'component.plugin-flow.text.line': 'Line',
'component.plugin-flow.text.grid': 'Grid',
'component.plugin-flow.text.background': 'Background',
'component.plugin-flow.text.node': 'Node',
'component.plugin-flow.text.text': 'Text',
'component.plugin-flow.text.condition-without-configuration': 'Please check all condition nodes\' data',
'component.plugin-flow.text.preview.readonly': 'NOTE: your actions on the following drawer will not be preserved.',
'component.plugin-flow.text.both-modes-exist': 'The orchestration mode configuration will override the normal mode configuration, are you sure to continue?',
'component.plugin-flow.text.both-modes-exist.title': 'Attention'
}

View File

@ -0,0 +1,41 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export default {
'component.plugin-flow.text.condition.required': '配置判断条件',
'component.plugin-flow.text.condition': '判断条件',
'component.plugin-flow.text.condition2': '条件判断',
'component.plugin-flow.text.condition.placeholder': '请输入判断条件',
'component.plugin-flow.text.without-data': '存在未配置的元件',
'component.plugin-flow.text.plugin-without-data.description': '请配置插件:',
'component.plugin-flow.text.no-start-node': '请关联开始节点',
'component.plugin-flow.text.no-root-node': '未找到根节点',
'component.plugin-flow.text.start-node': '开始',
'component.plugin-flow.text.general': '通用',
'component.plugin-flow.text.nodes-area': '元件选择区',
'component.plugin-flow.text.nodes.not-found': '无匹配元件',
'component.plugin-flow.text.search-nodes.placeholder': '请输入插件元件名称',
'component.plugin-flow.text.condition-rule.tooltip': '节点的判断条件。例如code == 503',
'component.plugin-flow.text.line': '线条',
'component.plugin-flow.text.grid': '网格',
'component.plugin-flow.text.background': '背景',
'component.plugin-flow.text.node': '节点',
'component.plugin-flow.text.text': '文本',
'component.plugin-flow.text.condition-without-configuration': '请检查条件判断元件的配置',
'component.plugin-flow.text.preview.readonly': '请注意:在当前页面,您在画布上地操作不会被保留。',
'component.plugin-flow.text.both-modes-exist': '编排模式配置将覆盖普通模式配置,是否继续操作?',
'component.plugin-flow.text.both-modes-exist.title': '配置冲突'
}

View File

@ -14,13 +14,29 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export declare namespace PluginOrchestrationModule {
type Meta = {
name: string;
priority: number;
schema: Record<string, any>;
type: string;
version: number;
consumer_schema?: Record<string, any>;
};
.container {
display: flex;
height: calc(100% - 48px);
}
.stencil {
position: relative;
// NOTE: constants.ts -> DEFAULT_STENCIL_WIDTH
width: 280px;
height: 400px;
border-right: 1px solid rgba(0, 0, 0, 0.08);
}
.panel {
height: 100%;
}
.toolbar {
display: flex;
align-items: center;
// NOTE: DEFAULT_TOOLBAR_HEIGHT
height: 38px;
background-color: #f7f9fb;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
}

View File

@ -1,82 +0,0 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import styled from 'styled-components';
export const SOuter = styled.div`
padding: 30px;
`;
export const SInput = styled.input`
padding: 10px;
border: 1px solid cornflowerblue;
width: 100%;
`;
export const SMessage = styled.div`
margin: 10px;
padding: 10px;
line-height: 1.4em;
`;
export const SButton = styled.div`
padding: 10px 15px;
background: cornflowerblue;
color: white;
border-radius: 3px;
text-align: center;
transition: 0.3s ease all;
cursor: pointer;
&:hover {
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.1);
}
&:active {
background: #5682d2;
}
`;
export const SPortDefaultOuter = styled.div`
width: 24px;
height: 24px;
background: cornflowerblue;
cursor: pointer;
display: flex;
justify-content: center;
align-items: center;
`;
export const SContent = styled.div`
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
`;
export const SSidebar = styled.div`
width: 300px;
background: white;
display: flex;
flex-direction: column;
flex-shrink: 0;
`;
export const SPageContent = styled.div`
display: flex;
flex-direction: row;
flex: 1;
max-width: 100vw;
max-height: 100vh;
`;

View File

@ -1,42 +0,0 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as React from 'react';
import { REACT_FLOW_CHART } from '@mrblenny/react-flow-chart';
import type { INode } from '@mrblenny/react-flow-chart';
import { Button } from 'antd';
import { SOuter } from '../DrawPluginStyle';
export type ISidebarItemProps = {
type: string;
ports: INode['ports'];
properties?: any;
};
export const SidebarItem: React.FC<ISidebarItemProps> = ({ type, ports, properties }) => {
return (
<SOuter
draggable
onDragStart={(event: any) => {
event.dataTransfer.setData(REACT_FLOW_CHART, JSON.stringify({ type, ports, properties }));
}}
style={{ padding: '5px' }}
>
<Button type="dashed">{type}</Button>
</SOuter>
);
};

View File

@ -1,62 +0,0 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export const INIT_CHART = {
offset: { x: 0, y: 0 },
scale: 0.577,
nodes: {},
links: {},
selected: {},
hovered: {},
};
export const PLUGINS_PORTS = {
port1: {
id: 'port1',
type: 'input',
properties: {
custom: 'property',
},
},
port2: {
id: 'port2',
type: 'output',
properties: {
custom: 'property',
},
},
};
export const CONDITION_PORTS = {
port1: {
id: 'port1',
type: 'input',
},
port2: {
id: 'port2',
type: 'output',
properties: {
value: 'no',
},
},
port3: {
id: 'port3',
type: 'output',
properties: {
value: 'yes',
},
},
};

View File

@ -1,77 +0,0 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { useIntl } from 'umi';
import type { INodeInnerDefaultProps, IPortDefaultProps } from '@mrblenny/react-flow-chart';
import { SOuter, SPortDefaultOuter } from './DrawPluginStyle';
import { PanelType } from './index';
export const NodeInnerCustom = ({ node }: INodeInnerDefaultProps) => {
const { formatMessage } = useIntl();
const { customData } = node.properties;
if (customData.type === PanelType.Condition) {
return (
<SOuter>
<p>
{formatMessage({ id: 'page.panel.condition.name' })}
{customData.name || `(${formatMessage({ id: 'page.panel.condition.tips' })})`}
</p>
</SOuter>
);
}
if (customData.type === PanelType.Plugin) {
return (
<SOuter>
<p>
{formatMessage({ id: 'page.panel.plugin.name' })}:{' '}
{customData.name || `(${formatMessage({ id: 'page.panel.plugin.tips' })})`}
</p>
</SOuter>
);
}
return (
<SOuter>
<br />
</SOuter>
);
};
export const PortCustom = (props: IPortDefaultProps) => (
<SPortDefaultOuter>
{props.port.properties && props.port.properties.value === 'yes' && (
<svg style={{ width: '24px', height: '24px' }} viewBox="0 0 24 24">
<path fill="white" d="M21,7L9,19L3.5,13.5L4.91,12.09L9,16.17L19.59,5.59L21,7Z" />
</svg>
)}
{props.port.properties && props.port.properties.value === 'no' && (
<svg style={{ width: '24px', height: '24px' }} viewBox="0 0 24 24">
<path
fill="white"
d="M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z"
/>
</svg>
)}
{!props.port.properties && (
<svg style={{ width: '24px', height: '24px' }} viewBox="0 0 24 24">
<path fill="white" d="M7.41,8.58L12,13.17L16.59,8.58L18,10L12,16L6,10L7.41,8.58Z" />
</svg>
)}
</SPortDefaultOuter>
);

View File

@ -1,289 +0,0 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { Fragment, useState, useEffect } from 'react';
import { cloneDeep } from 'lodash';
import { FlowChart } from '@mrblenny/react-flow-chart';
import type { IFlowChartCallbacks } from '@mrblenny/react-flow-chart';
import * as actions from '@mrblenny/react-flow-chart/src/container/actions';
import { Form, Input, Button, Divider, Card, Select } from 'antd';
import { withTheme } from '@rjsf/core';
import { useIntl } from 'umi';
// @ts-ignore
import { Theme as AntDTheme } from '@rjsf/antd';
import { Page, SidebarItem } from './components';
import { INIT_CHART, PLUGINS_PORTS, CONDITION_PORTS } from './constants';
import { SMessage, SContent, SSidebar } from './DrawPluginStyle';
import { PortCustom, NodeInnerCustom } from './customConfig';
import { fetchList } from './service';
import type { PluginOrchestrationModule } from './typing';
export * from './transform';
export enum PanelType {
Plugin,
Condition,
Default,
}
type Props = {
data: any;
onChange: (data: Record<string, unknown>) => void;
readonly: boolean;
};
const PluginForm = withTheme(AntDTheme);
const LAYOUT = {
labelCol: { span: 8 },
wrapperCol: { span: 16 },
};
const TAIL_LAYOUT = {
wrapperCol: { offset: 8, span: 16 },
};
const SelectedSidebar: React.FC<Props> = ({ data = {}, onChange, readonly = false }) => {
const [form] = Form.useForm();
const [chart, setChart] = useState(cloneDeep(Object.keys(data).length ? data : INIT_CHART));
const [schema, setSchema] = useState<any>();
const [selectedType, setSelectedType] = useState<PanelType>(PanelType.Default);
const [pluginList, setPluginList] = useState<PluginOrchestrationModule.Meta[]>([]);
const [pluginCategory, setPluginCategory] = useState('All');
const [showList, setShowList] = useState<string[]>();
const [typeList, setTypeList] = useState<string[]>([]);
const { formatMessage } = useIntl();
const getCustomDataById = (id = chart.selected.id) => {
if (!id || !chart.nodes[id].properties) {
return {};
}
return chart.nodes[id].properties.customData;
};
const stateActionCallbacks = Object.keys(actions).reduce((obj, key) => {
const clonedObj = cloneDeep(obj);
clonedObj[key] = (...args: any) => {
const action = actions[key];
const newChartTransformer = action(...args);
const newChart = newChartTransformer(chart);
if (
['onLinkMouseEnter', 'onLinkMouseLeave', 'onNodeMouseEnter', 'onNodeMouseLeave'].includes(
key,
)
) {
return newChart;
}
if (key === 'onDragCanvasStop') {
setSelectedType(PanelType.Default);
return newChart;
}
setChart({ ...chart, ...newChart });
if (['onCanvasDrop', 'onNodeClick'].includes(key)) {
const { type, name } = getCustomDataById(args.nodeId);
setSelectedType(type);
if (type === PanelType.Plugin && name) {
const plugin = pluginList.find((item) => item.name === name);
if (plugin) {
setSchema(plugin.schema);
}
}
}
onChange(newChart);
return newChart;
};
return clonedObj;
}, {}) as IFlowChartCallbacks;
const firstUpperCase = ([first, ...rest]: string) => first.toUpperCase() + rest.join('');
useEffect(() => {
fetchList().then((list) => {
const categoryList: string[] = [];
list.forEach((item) => {
if (!categoryList.includes(firstUpperCase(item.type))) {
categoryList.push(firstUpperCase(item.type));
}
});
setTypeList(['All', ...categoryList.sort()]);
setPluginList(list);
setShowList(list.map((item) => item.name).sort());
});
}, []);
const renderSidebar = () => {
if (selectedType === PanelType.Condition) {
form.setFieldsValue({ condition: getCustomDataById().name });
return (
<SMessage>
<Form
{...LAYOUT}
name="basic"
form={form}
onFinish={(values) => {
const clonedChart = cloneDeep(chart);
clonedChart.nodes[chart.selected.id!].properties.customData.name = values.condition;
setChart(clonedChart);
onChange(clonedChart);
setSelectedType(PanelType.Default);
}}
>
<Form.Item
label={formatMessage({ id: 'page.siderBar.form.label.panelType.condition' })}
name="condition"
rules={[
{
required: true,
message: formatMessage({ id: 'page.siderBar.form.rule.panelType.condition' }),
},
]}
>
<Input />
</Form.Item>
<Form.Item {...TAIL_LAYOUT}>
<Button type="primary" htmlType="submit">
{formatMessage({ id: 'page.siderBar.button.submit' })}
</Button>
</Form.Item>
</Form>
</SMessage>
);
}
if (selectedType === PanelType.Plugin && schema) {
return (
<SMessage style={{ overflow: 'scroll' }}>
<PluginForm
schema={schema}
liveValidate
formData={getCustomDataById().data || {}}
showErrorList={false}
onSubmit={({ formData }) => {
const clonedChart = cloneDeep(chart);
clonedChart.nodes[chart.selected.id!].properties.customData.data = formData;
setChart(clonedChart);
onChange(clonedChart);
setSelectedType(PanelType.Default);
}}
>
{/* NOTE: Leave blank to hide the Submit button */}
<Fragment />
<Button type="primary" htmlType="submit">
{formatMessage({ id: 'page.siderBar.button.submit' })}
</Button>
</PluginForm>
</SMessage>
);
}
return (
<SSidebar>
<SMessage style={{ fontSize: '16px', fontWeight: 'bold' }}>
{formatMessage({ id: 'page.siderBar.tips' })}
</SMessage>
<Divider style={{ margin: '0px' }} />
<SidebarItem
type={formatMessage({ id: 'page.siderBar.form.label.panelType.condition' })}
ports={CONDITION_PORTS}
properties={{
customData: {
type: PanelType.Condition,
},
}}
/>
<Divider orientation="left">{formatMessage({ id: 'page.siderBar.plugin' })}</Divider>
<Select
showSearch
placeholder={formatMessage({ id: 'page.siderBar.form.label.panelType.plugin' })}
optionFilterProp="children"
defaultValue={pluginCategory}
onChange={(value) => {
setPluginCategory(value);
if (value === 'All') {
setShowList(pluginList.map((item) => item.name).sort());
return;
}
setShowList(
pluginList
.filter((item) => item.type === value.toLowerCase())
.map((item) => item.name)
.sort(),
);
}}
filterOption={(input, option) =>
option?.children.toLowerCase().indexOf(input.toLowerCase()) >= 0
}
>
{typeList.map((item) => (
<Select.Option value={item}>{item}</Select.Option>
))}
</Select>
<Card size="small" title={pluginCategory} style={{ height: 'unset' }}>
<div
style={{
overflowY: 'scroll',
height: '500px',
display: 'flex',
flexWrap: 'wrap',
alignContent: 'flex-start',
}}
>
{showList &&
showList.map((name) => {
return (
<SidebarItem
key={name}
type={name}
ports={PLUGINS_PORTS}
properties={{
customData: {
type: PanelType.Plugin,
name,
},
}}
/>
);
})}
</div>
</Card>
</SSidebar>
);
};
return (
<Page>
<SContent>
<FlowChart
chart={chart}
callbacks={stateActionCallbacks}
config={{
zoom: { wheel: { disabled: true } },
readonly,
}}
Components={{
Port: PortCustom,
NodeInner: NodeInnerCustom,
}}
/>
</SContent>
{Boolean(!readonly) && <SSidebar>{renderSidebar()}</SSidebar>}
</Page>
);
};
export default SelectedSidebar;

View File

@ -1,30 +0,0 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export default {
'page.siderBar.form.label.panelType.condition': 'Condition',
'page.siderBar.form.rule.panelType.condition': 'Please enter the condition of judgment',
'page.siderBar.form.label.panelType.plugin': 'Plugin Category',
'page.siderBar.button.submit': 'Save',
'page.siderBar.plugin': 'Plugin',
'page.siderBar.tips': 'Drag the required components to the panel',
'page.panel.condition.tips': 'Click here to configure',
'page.panel.condition.name': 'Condition',
'page.panel.plugin.tips': 'Click to configure the plugin',
'page.panel.plugin.name': 'Plugin Name',
};

View File

@ -1,30 +0,0 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export default {
'page.siderBar.form.label.panelType.condition': '判断条件',
'page.siderBar.form.rule.panelType.condition': '请输入判断条件',
'page.siderBar.form.label.panelType.plugin': '插件分类',
'page.siderBar.button.submit': '保存',
'page.siderBar.plugin': '插件',
'page.siderBar.tips': '拖动所需组件至面板',
'page.panel.condition.tips': '点击配置判断条件',
'page.panel.condition.name': '判断条件',
'page.panel.plugin.tips': '点击配置插件',
'page.panel.plugin.name': '插件名称',
};

View File

@ -1,25 +0,0 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { request } from 'umi';
import type { PluginOrchestrationModule } from './typing';
export const fetchList = () => {
return request<Res<PluginOrchestrationModule.Meta[]>>('/plugins?all=true').then((data) => {
return data.data;
});
};

View File

@ -1,122 +0,0 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { PanelType } from '.';
export const transformer = (chart: any) => {
const rule: any = {};
const conf: any = {};
const { links } = chart;
const findStartNode = () => {
const nodeIdFormArr: string[] = [];
const nodeIdToArr: string[] = [];
Object.keys(links).forEach((key) => {
const item = links[key];
nodeIdFormArr.push(item.from.nodeId);
nodeIdToArr.push(item.to.nodeId);
});
return nodeIdFormArr.filter((item) => !nodeIdToArr.includes(item))[0];
};
const findLinkId = (type: string, nodeId: string, port?: string) => {
let returnId;
Object.keys(links).forEach((key) => {
const item = links[key];
// condition
if (port) {
if (port === item[type].portId && item[type].nodeId === nodeId) {
returnId = key;
}
return;
}
// plugin
if (nodeId === item[type].nodeId) {
returnId = key;
}
});
return returnId;
};
const processRule = (id: string) => {
if (!chart.nodes[id]) return;
const link = findLinkId('from', id);
if (!link) return;
const nextNodeId = links[link].to.nodeId;
const nextNodeType = chart.nodes[nextNodeId].properties.customData.type;
if (nextNodeType === PanelType.Plugin) {
rule[id] = [['', nextNodeId]];
processRule(nextNodeId);
}
if (nextNodeType === PanelType.Condition) {
let truePortId;
let falsePortId;
const { ports } = chart.nodes[nextNodeId];
Object.keys(ports).forEach((key) => {
const item = ports[key];
if (item.properties) {
if (item.properties.value === 'yes') {
truePortId = item.id;
}
if (item.properties.value === 'no') {
falsePortId = item.id;
}
}
});
const trueLinkId = findLinkId('from', nextNodeId, truePortId);
const falseLinkId = findLinkId('from', nextNodeId, falsePortId);
const nextTrueNode = trueLinkId ? links[trueLinkId].to.nodeId : undefined;
const nextFalseNode = falseLinkId ? links[falseLinkId].to.nodeId : undefined;
rule[id] = [];
if (nextTrueNode) {
rule[id][0] = [chart.nodes[nextNodeId].properties.customData.name, nextTrueNode];
processRule(nextTrueNode);
}
if (nextFalseNode) {
rule[id][1] = ['', nextFalseNode];
processRule(nextFalseNode);
}
}
};
const startId = findStartNode();
rule.root = startId;
processRule(startId);
// handle conf
Object.keys(chart.nodes).forEach((key) => {
const item = chart.nodes[key];
if (item.properties.customData && item.properties.customData.type === 0) {
conf[key] = {
name: item.properties.customData.name,
conf: item.properties.customData.data,
};
}
});
return { rule, conf };
};

View File

@ -23,8 +23,8 @@ import pwa from './en-US/pwa';
import settingDrawer from './en-US/settingDrawer';
import settings from './en-US/setting';
import other from './en-US/other'
import PluginOrchestration from '../components/PluginOrchestration/locales/en-US';
import Plugin from '../components/Plugin/locales/en-US';
import PluginFlow from '../components/PluginFlow/locales/en-US';
import RawDataEditor from '../components/RawDataEditor/locales/en-US';
import UpstreamComponent from '../components/Upstream/locales/en-US'
@ -42,8 +42,8 @@ export default {
...component,
...other,
...ActionBarEnUS,
...PluginOrchestration,
...Plugin,
...PluginFlow,
...RawDataEditor,
...UpstreamComponent
};

View File

@ -23,8 +23,8 @@ import pwa from './zh-CN/pwa';
import other from './zh-CN/other'
import settingDrawer from './zh-CN/settingDrawer';
import settings from './zh-CN/setting';
import PluginOrchestration from '../components/PluginOrchestration/locales/zh-CN';
import Plugin from '../components/Plugin/locales/zh-CN';
import PluginFlow from '../components/PluginFlow/locales/zh-CN';
import RawDataEditor from '../components/RawDataEditor/locales/zh-CN';
import UpstreamComponent from '../components/Upstream/locales/zh-CN'
@ -42,8 +42,8 @@ export default {
...component,
...other,
...ActionBarZhCN,
...PluginOrchestration,
...Plugin,
...PluginFlow,
...RawDataEditor,
...UpstreamComponent
};

View File

@ -76,5 +76,5 @@ export default {
'component.global.noConfigurationRequired': '无需配置',
'component.global.copy': '复制',
'component.global.copySuccess': '复制成功',
'component.global.copyFail': '复制失败'
'component.global.copyFail': '复制失败',
};

View File

@ -15,21 +15,21 @@
* limitations under the License.
*/
import React, { useState, useEffect, useRef } from 'react';
import { Card, Steps, Form } from 'antd';
import { Card, Steps, Form, Modal } from 'antd';
import { PageHeaderWrapper } from '@ant-design/pro-layout';
import { history, useIntl } from 'umi';
import { isEmpty } from 'lodash';
import ActionBar from '@/components/ActionBar';
import FlowGraph from '@/components/PluginFlow/components/FlowGraph';
import { transformer as chartTransformer } from '@/components/PluginOrchestration';
import { create, fetchItem, update, checkUniqueName, checkHostWithSSL } from './service';
import { transformProxyRewrite2Plugin } from './transform';
import Step1 from './components/Step1';
import Step2 from './components/Step2';
import Step3 from './components/Step3';
import CreateStep4 from './components/CreateStep4';
import { DEFAULT_STEP_1_DATA, DEFAULT_STEP_3_DATA, INIT_CHART } from './constants';
import { DEFAULT_STEP_1_DATA, DEFAULT_STEP_3_DATA } from './constants';
import ResultView from './components/ResultView';
import styles from './Create.less';
@ -68,7 +68,6 @@ const Page: React.FC<Props> = (props) => {
const [step, setStep] = useState(1);
const [stepHeader, setStepHeader] = useState(STEP_HEADER_4);
const [chart, setChart] = useState(INIT_CHART);
const setupRoute = (rid: number) =>
fetchItem(rid).then((data) => {
@ -159,9 +158,8 @@ const Page: React.FC<Props> = (props) => {
data={step3Data}
isForceHttps={form1.getFieldValue('redirectOption') === 'forceHttps'}
isProxyEnable={getProxyRewriteEnable()}
onChange={({ plugins, script = INIT_CHART, plugin_config_id }) => {
onChange={({ plugins, script = {}, plugin_config_id }) => {
setStep3Data({ plugins, script, plugin_config_id });
setChart(script);
}}
/>
);
@ -195,6 +193,39 @@ const Page: React.FC<Props> = (props) => {
return null;
};
const savePlugins = (): boolean => {
const isScriptConfigured = FlowGraph.graph?.toJSON().cells.length
const isPluginsConfigured = Object.keys(step3Data.plugins || {}).length
if (step === 3 && isScriptConfigured && isPluginsConfigured) {
Modal.confirm({
title: formatMessage({ id: 'component.plugin-flow.text.both-modes-exist.title' }),
content: formatMessage({ id: 'component.plugin-flow.text.both-modes-exist' }),
onOk: () => {
const data = FlowGraph.convertToData()
if (data) {
setStep3Data({ script: data, plugins: {} });
setStep(4)
}
},
okText: formatMessage({ id: 'component.global.confirm' }),
cancelText: formatMessage({ id: 'component.global.cancel' }),
})
return false
}
if (isScriptConfigured) {
const data = FlowGraph.convertToData()
if (!data) {
return false
}
setStep3Data({ script: data, plugins: {} });
} else {
setStep3Data({ ...step3Data, script: {} });
}
return true
}
const onStepChange = (nextStep: number) => {
const onUpdateOrCreate = () => {
const routeData = {
@ -214,15 +245,6 @@ const Page: React.FC<Props> = (props) => {
}
};
const savePlugins = () => {
if (Object.keys(chart.nodes || {}).length) {
const transformChart = chartTransformer(chart);
setStep3Data({ script: { ...transformChart, chart }, plugins: {} });
} else {
setStep3Data({ ...step3Data, script: {} });
}
};
if (nextStep === 1) {
setStep(nextStep);
}
@ -242,8 +264,7 @@ const Page: React.FC<Props> = (props) => {
});
});
} else {
savePlugins();
setStep(nextStep);
setStep(nextStep)
}
return;
}
@ -260,7 +281,10 @@ const Page: React.FC<Props> = (props) => {
}
if (nextStep === 4) {
savePlugins();
const result = savePlugins()
if (!result) {
return
}
setStep(nextStep);
}
@ -282,7 +306,6 @@ const Page: React.FC<Props> = (props) => {
))}
</Steps>
{renderStepList()}
{/* NOTE: PluginOrchestration works unexpected when using <renderStepList/> */}
</Card>
</PageHeaderWrapper>
<ActionBar step={step} lastStep={redirect ? 2 : 4} onChange={onStepChange} withResultView />

View File

@ -18,8 +18,8 @@ import React from 'react';
import type { FormInstance } from 'antd/lib/form';
import { useIntl } from 'umi';
import PluginOrchestration from '@/components/PluginOrchestration';
import PluginPage from '@/components/Plugin';
import PluginFlow from '@/components/PluginFlow';
import Step1 from '../Step1';
import Step2 from '../Step2';
@ -39,7 +39,7 @@ const style = {
const CreateStep4: React.FC<Props> = ({ form1, form2, redirect, upstreamRef, ...rest }) => {
const { formatMessage } = useIntl();
const { plugins = {}, script = {}, plugin_config_id = '' } = rest.step3Data;
const { plugins = {}, plugin_config_id = '', script = {} } = rest.step3Data;
return (
<>
@ -59,9 +59,9 @@ const CreateStep4: React.FC<Props> = ({ form1, form2, redirect, upstreamRef, ...
<h2 style={style}>
{formatMessage({ id: 'component.global.steps.stepTitle.pluginConfig' })}
</h2>
{Boolean(Object.keys(plugins).length !== 0 || plugin_config_id !== '') && <PluginPage referPage = 'route' initialData={plugins} plugin_config_id={plugin_config_id} showSelector readonly />}
{Boolean(Object.keys(script).length !== 0) && (
<PluginOrchestration data={rest.step3Data.script.chart} readonly onChange={() => { }} />
{Boolean(Object.keys(plugins).length !== 0 || plugin_config_id !== '') && <PluginPage referPage='route' initialData={plugins} plugin_config_id={plugin_config_id} showSelector readonly />}
{Boolean(Object.keys(script || {}).length !== 0) && (
<PluginFlow chart={script.chart} readonly />
)}
</>
)}

View File

@ -112,9 +112,9 @@ const DebugDrawView: React.FC<RouteModule.DebugDrawProps> = (props) => {
}
}
}
case DEBUG_BODY_TYPE_SUPPORTED[DebugBodyType.RawInput]:{
case DEBUG_BODY_TYPE_SUPPORTED[DebugBodyType.RawInput]: {
let contentType = [''];
switch (bodyCodeMirrorMode){
switch (bodyCodeMirrorMode) {
case DEBUG_BODY_CODEMIRROR_MODE_SUPPORTED[0].mode:
contentType = ['application/json;charset=UTF-8'];
break;
@ -209,7 +209,7 @@ const DebugDrawView: React.FC<RouteModule.DebugDrawProps> = (props) => {
const handleDebug = (url: string) => {
/* eslint-disable no-useless-escape */
if (!urlRegexSafe({exact: true, strict: false}).test(url)) {
if (!urlRegexSafe({ exact: true, strict: false }).test(url)) {
notification.warning({
message: formatMessage({ id: 'page.route.input.placeholder.requestUrl' }),
});
@ -217,7 +217,7 @@ const DebugDrawView: React.FC<RouteModule.DebugDrawProps> = (props) => {
}
const queryFormData = transformHeaderAndQueryParamsFormData(queryForm.getFieldsValue().params);
const bodyFormRelateData = transformBodyParamsFormData();
const {bodyFormData, header: bodyFormHeader} = bodyFormRelateData;
const { bodyFormData, header: bodyFormHeader } = bodyFormRelateData;
const pureHeaderFormData = transformHeaderAndQueryParamsFormData(
headerForm.getFieldsValue().params,
);
@ -234,12 +234,12 @@ const DebugDrawView: React.FC<RouteModule.DebugDrawProps> = (props) => {
}, bodyFormData)
.then((req) => {
setLoading(false);
const resp: RouteModule.debugResponse= req.data;
const resp: RouteModule.debugResponse = req.data;
if (typeof (resp.data) !== 'string') {
resp.data = JSON.stringify(resp.data, null, 2);
}
setResponse(resp);
const contentType=resp.header["Content-Type"];
const contentType = resp.header["Content-Type"];
if (contentType == null || contentType.length !== 1) {
setResponseBodyCodeMirrorMode("TEXT");
} else if (contentType[0].toLowerCase().indexOf("json") !== -1) {
@ -331,13 +331,13 @@ const DebugDrawView: React.FC<RouteModule.DebugDrawProps> = (props) => {
>
<Tabs>
<TabPane data-cy='query' tab={formatMessage({ id: 'page.route.TabPane.queryParams' })} key="query">
<DebugParamsView form={queryForm} name='queryForm'/>
<DebugParamsView form={queryForm} name='queryForm' />
</TabPane>
<TabPane data-cy='auth' tab={formatMessage({ id: 'page.route.TabPane.authentication' })} key="auth">
<AuthenticationView form={authForm} />
</TabPane>
<TabPane data-cy='header' tab={formatMessage({ id: 'page.route.TabPane.headerParams' })} key="header">
<DebugParamsView form={headerForm} name='headerForm' inputType="header"/>
<DebugParamsView form={headerForm} name='headerForm' inputType="header" />
</TabPane>
{showBodyTab && (
<TabPane data-cy='body' tab={formatMessage({ id: 'page.route.TabPane.bodyParams' })} key="body">
@ -371,7 +371,7 @@ const DebugDrawView: React.FC<RouteModule.DebugDrawProps> = (props) => {
)}
<div style={{ marginTop: 16 }}>
{bodyType === DEBUG_BODY_TYPE_SUPPORTED[DebugBodyType.FormUrlencoded] && (
<DebugParamsView form={urlencodedForm} name='urlencodedForm'/>
<DebugParamsView form={urlencodedForm} name='urlencodedForm' />
)}
{bodyType === DEBUG_BODY_TYPE_SUPPORTED[DebugBodyType.FormData] && (
@ -420,7 +420,7 @@ const DebugDrawView: React.FC<RouteModule.DebugDrawProps> = (props) => {
onSelect={(mode) => setResponseBodyCodeMirrorMode(mode as string)}>
{
DEBUG_RESPONSE_BODY_CODEMIRROR_MODE_SUPPORTED.map(mode => {
return <Option value={mode.mode}>{mode.name}</Option>
return <Option value={mode.mode} key={mode.mode}>{mode.name}</Option>
})
}
</Select>
@ -438,10 +438,10 @@ const DebugDrawView: React.FC<RouteModule.DebugDrawProps> = (props) => {
});
}}>
<Button type="text" disabled={!response}>
<CopyOutlined/>
<CopyOutlined />
</Button>
</CopyToClipboard>
<div id='codeMirror-response' style={{marginTop:16}}>
<div id='codeMirror-response' style={{ marginTop: 16 }}>
<CodeMirror
value={response ? response.data : ""}
height={codeMirrorHeight}

View File

@ -20,13 +20,19 @@ import { QuestionCircleOutlined } from '@ant-design/icons';
import { isChrome, isChromium, isEdgeChromium } from 'react-device-detect';
import { useIntl } from 'umi';
import PluginOrchestration from '@/components/PluginOrchestration';
import PluginPage from '@/components/Plugin';
import PluginFlow from '@/components/PluginFlow';
import { DEFAULT_PLUGIN_FLOW_DATA } from '@/components/PluginFlow/constants';
import FlowGraph from '@/components/PluginFlow/components/FlowGraph';
type Props = {
data: {
plugins: PluginComponent.Data;
script: Record<string, any>;
script: {
chart: Record<string, any>;
rule: Record<string, any>;
conf: Record<string, any>;
};
plugin_config_id?: string;
};
onChange: (data: { plugins: PluginComponent.Data; script: any, plugin_config_id?: string; }) => void;
@ -39,15 +45,13 @@ type Mode = 'NORMAL' | 'DRAW';
const Page: React.FC<Props> = ({ data, onChange, readonly = false, isForceHttps = false, isProxyEnable = false }) => {
const { formatMessage } = useIntl();
const { plugins = {}, script = {}, plugin_config_id = '' } = data;
const { plugins = {}, script = DEFAULT_PLUGIN_FLOW_DATA, plugin_config_id = '' } = data;
// NOTE: Currently only compatible with chrome
const useSupportBrowser = isChrome || isEdgeChromium || isChromium;
const disableDraw = !useSupportBrowser || isForceHttps || isProxyEnable;
const type = Object.keys(script || {}).length === 0 || disableDraw ? 'NORMAL' : 'DRAW';
const [mode, setMode] = useState<Mode>(type);
const [mode, setMode] = useState<Mode>(Object.keys(script.chart?.cells || {}).length === 0 || disableDraw ? 'NORMAL' : 'DRAW');
return (
<>
@ -55,15 +59,20 @@ const Page: React.FC<Props> = ({ data, onChange, readonly = false, isForceHttps
<Radio.Group
value={mode}
onChange={(e) => {
if (e.target.value === 'NORMAL') {
// NOTE: current is DRAW
onChange({ ...data, script: { chart: FlowGraph.graph.toJSON() } })
}
setMode(e.target.value);
}}
style={{ marginBottom: 10 }}
>
<Radio.Button value="NORMAL">
{ formatMessage({ id: 'page.route.tabs.normalMode' }) }
{formatMessage({ id: 'page.route.tabs.normalMode' })}
</Radio.Button>
<Radio.Button value="DRAW" disabled={disableDraw}>
{ formatMessage({ id: 'page.route.tabs.orchestration' }) }
{formatMessage({ id: 'page.route.tabs.orchestration' })}
</Radio.Button>
</Radio.Group>
{Boolean(disableDraw) && (
@ -74,13 +83,13 @@ const Page: React.FC<Props> = ({ data, onChange, readonly = false, isForceHttps
// NOTE: forceHttps do not support DRAW mode
const titleArr: string[] = [];
if (!useSupportBrowser) {
titleArr.push(formatMessage({id: 'page.route.tooltip.pluginOrchOnlySuportChrome'}));
titleArr.push(formatMessage({ id: 'page.route.tooltip.pluginOrchOnlySuportChrome' }));
}
if (isForceHttps) {
titleArr.push(formatMessage({id: 'page.route.tooltip.pluginOrchWithoutRedirect'}));
titleArr.push(formatMessage({ id: 'page.route.tooltip.pluginOrchWithoutRedirect' }));
}
if (isProxyEnable) {
titleArr.push(formatMessage({id: 'page.route.tooltip.pluginOrchWithoutProxyRewrite'}));
titleArr.push(formatMessage({ id: 'page.route.tooltip.pluginOrchWithoutProxyRewrite' }));
}
return titleArr.map((item, index) => `${index + 1}.${item}`).join('');
}}
@ -92,23 +101,18 @@ const Page: React.FC<Props> = ({ data, onChange, readonly = false, isForceHttps
</div>
{Boolean(mode === 'NORMAL') && (
<PluginPage
readonly={readonly}
initialData={plugins}
plugin_config_id={plugin_config_id}
schemaType="route"
referPage="route"
showSelector
onChange={(pluginsData, id) => {
onChange({ plugins: pluginsData, script: {}, plugin_config_id: id })
onChange({ ...data, plugins: pluginsData, plugin_config_id: id })
}}
/>
)}
{Boolean(mode === 'DRAW') && (
<PluginOrchestration
data={script?.chart}
onChange={(scriptData) => onChange({ plugins: {}, script: scriptData })}
readonly={readonly}
/>
)}
{Boolean(mode === 'DRAW') && (<PluginFlow chart={script.chart as any} />)}
</>
);
};

View File

@ -76,15 +76,6 @@ export const DEFAULT_STEP_3_DATA: RouteModule.Step3Data = {
plugin_config_id: ""
};
export const INIT_CHART = {
offset: { x: 55.71, y: 21.69 },
scale: 0.329,
nodes: {},
links: {},
selected: {},
hovered: {},
};
export const AUTH_LIST = ['basic-auth', 'jwt-auth', 'key-auth'];
export const HEADER_LIST = [

View File

@ -146,8 +146,8 @@ export default {
'page.route.tooltip.pluginOrchWithoutProxyRewrite': 'Plugin orchestration mode cannot be used when request override is configured in Step 1.',
'page.route.tooltip.pluginOrchWithoutRedirect': 'Plugin orchestration mode cannot be used when Redirect in Step 1 is selected to enable HTTPS.',
'page.route.tabs.normalMode': 'Normal mode',
'page.route.tabs.orchestration': 'Plugin orchestration',
'page.route.tabs.normalMode': 'Normal',
'page.route.tabs.orchestration': 'Orchestration',
'page.route.list.description': 'Route is the entry point of a request, which defines the matching rules between a client request and a service. A route can be associated with a service (Service), an upstream (Upstream), a service can correspond to a set of routes, and a route can correspond to an upstream object (a set of backend service nodes), so each request matching to a route will be proxied by the gateway to the route-bound upstream service.',

View File

@ -146,7 +146,7 @@ export default {
'page.route.tooltip.pluginOrchWithoutRedirect': '当步骤一中 重定向 选择为 启用 HTTPS 时,不可使用插件编排模式。',
'page.route.tabs.normalMode': '普通模式',
'page.route.tabs.orchestration': '插件编排',
'page.route.tabs.orchestration': '编排模式',
'page.route.list.description': '路由Route是请求的入口点它定义了客户端请求与服务之间的匹配规则。路由可以与服务Service、上游Upstream关联一个服务可对应一组路由一个路由可以对应一个上游对象一组后端服务节点因此每个匹配到路由的请求将被网关代理到路由绑定的上游服务中。',

View File

@ -15,8 +15,8 @@
* limitations under the License.
*/
export default {
'page.service.steps.stepTitle.basicInformation': 'Basic Information',
'page.service.steps.stepTitle.pluginConfig': 'Plugin Config',
'page.service.steps.stepTitle.basicInformation': 'Basic',
'page.service.steps.stepTitle.pluginConfig': 'Plugin',
'page.service.steps.stepTitle.preview': 'Preview',
'page.service.list': 'Service List',
'page.service.description': 'A service consists of a combination of public plugin configuration and upstream target information in a route. Services are associated with Routes and Upstreams, and a service can correspond to a set of upstream nodes and can be bound by multiple routes.',

View File

@ -2,6 +2,11 @@
# yarn lockfile v1
"@4tw/cypress-drag-drop@^1.6.0":
version "1.6.0"
resolved "https://registry.yarnpkg.com/@4tw/cypress-drag-drop/-/cypress-drag-drop-1.6.0.tgz#528cf837f16d16e059b2e3cae702d9de2c9e1088"
integrity sha512-B61iPspk2hZuuo3mjmlTqYZXJ9tusc8VyEk+5KMO/FTBrHKDWqYp8ANOJnIkRz6QfYZbx+qBoKBu7MTfvBCKew==
"@ahooksjs/use-request@^2.0.0":
version "2.8.3"
resolved "https://registry.yarnpkg.com/@ahooksjs/use-request/-/use-request-2.8.3.tgz#9b7eff972658497473f61ceb89a268d665e28aeb"
@ -185,6 +190,31 @@
lodash "^4.17.15"
resize-observer-polyfill "^1.5.0"
"@antv/x6-react-components@^1.1.7":
version "1.1.7"
resolved "https://registry.yarnpkg.com/@antv/x6-react-components/-/x6-react-components-1.1.7.tgz#4f7d017b05dd3adb767a359bb0b9b4566b84ef8f"
integrity sha512-2G+J6GmCy6wfHJeDpopC7px6XbFc9zy0EFKAHkgOUS5XVj/n91VzvKViHd8miBRudkZ0ZTkf9NxtIR19hdOq6w==
dependencies:
clamp "^1.0.1"
classnames "^2.2.6"
rc-dropdown "^3.0.0-alpha.0"
rc-util "^4.15.7"
react-color "^2.17.3"
react-resize-detector "^6.6.4"
ua-parser-js "^0.7.20"
"@antv/x6@^1.18.5":
version "1.18.5"
resolved "https://registry.yarnpkg.com/@antv/x6/-/x6-1.18.5.tgz#0215e8abbebd2ee508943aa58e62a88e2760799a"
integrity sha512-Bxn1pl5etaiDmO61Pc8EaqPqjld2vRz4+wF55VqFsGy2SpVYS1mPYZury1v5qYzAsU/BtQCW0j0RHppPR4U+UA==
dependencies:
csstype "^3.0.3"
jquery "^3.5.1"
jquery-mousewheel "^3.1.13"
lodash-es "^4.17.15"
mousetrap "^1.6.5"
utility-types "^3.10.0"
"@babel/code-frame@7.0.0":
version "7.0.0"
resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.0.0.tgz#06e2ab19bdb535385559aabb5ba59729482800f8"
@ -1879,6 +1909,11 @@
dependencies:
"@hapi/hoek" "^9.0.0"
"@icons/material@^0.2.4":
version "0.2.4"
resolved "https://registry.yarnpkg.com/@icons/material/-/material-0.2.4.tgz#e90c9f71768b3736e76d7dd6783fc6c2afa88bc8"
integrity sha512-QPcGmICAPbGLGb6F/yNf/KzKqvFx8z5qx3D1yFqVAjoFmXK35EgyW+cJ57Te3CNsmzblwtzakLGFqHPqrfb4Tw==
"@istanbuljs/load-nyc-config@^1.0.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz#fd3db1d59ecf7cf121e80650bb86712f9b55eced"
@ -2158,17 +2193,6 @@
dependencies:
extend "3.0.2"
"@mrblenny/react-flow-chart@^0.0.14":
version "0.0.14"
resolved "https://registry.yarnpkg.com/@mrblenny/react-flow-chart/-/react-flow-chart-0.0.14.tgz#be11d06345c7222b41f488b38011b109e48a04b3"
integrity sha512-3bFjlmlYuqHpCRCPoA59jok2Vhe59ZKT5g9lb6U5IM+Zk2fIsKmXp8LEcliW0TrHtNMtZw5Gm3/rScrg/DwAFQ==
dependencies:
pathfinding "^0.4.18"
react-draggable "^4.4.3"
react-resize-observer "^1.1.1"
react-zoom-pan-pinch "^1.6.1"
uuid "^3.3.2"
"@mrmlnc/readdir-enhanced@^2.2.1":
version "2.2.1"
resolved "https://registry.yarnpkg.com/@mrmlnc/readdir-enhanced/-/readdir-enhanced-2.2.1.tgz#524af240d1a360527b730475ecfa1344aa540dde"
@ -2899,6 +2923,11 @@
"@types/scheduler" "*"
csstype "^3.0.2"
"@types/resize-observer-browser@^0.1.5":
version "0.1.5"
resolved "https://registry.yarnpkg.com/@types/resize-observer-browser/-/resize-observer-browser-0.1.5.tgz#36d897708172ac2380cd486da7a3daf1161c1e23"
integrity sha512-8k/67Z95Goa6Lznuykxkfhq9YU3l1Qe6LNZmwde1u7802a3x8v44oq0j91DICclxatTr0rNnhXx7+VTIetSrSQ==
"@types/resolve@1.17.1":
version "1.17.1"
resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-1.17.1.tgz#3afd6ad8967c77e4376c598a82ddd58f46ec45d6"
@ -5489,6 +5518,11 @@ cipher-base@^1.0.0, cipher-base@^1.0.1, cipher-base@^1.0.3:
inherits "^2.0.1"
safe-buffer "^5.0.1"
clamp@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/clamp/-/clamp-1.0.1.tgz#66a0e64011816e37196828fdc8c8c147312c8634"
integrity sha1-ZqDmQBGBbjcZaCj9yMjBRzEshjQ=
class-utils@^0.3.5:
version "0.3.6"
resolved "https://registry.yarnpkg.com/class-utils/-/class-utils-0.3.6.tgz#f93369ae8b9a7ce02fd41faad0ca83033190c463"
@ -6352,6 +6386,11 @@ csstype@^3.0.2:
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.0.7.tgz#2a5fb75e1015e84dd15692f71e89a1450290950b"
integrity sha512-KxnUB0ZMlnUWCsx2Z8MUsr6qV6ja1w9ArPErJaJaF8a5SOWoHLIszeCTKGRGRgtLgYrs1E8CHkNSP1VZTTPc9g==
csstype@^3.0.3:
version "3.0.8"
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.0.8.tgz#d2266a792729fb227cd216fb572f43728e1ad340"
integrity sha512-jXKhWqXPmlUeoQnF/EhTtTl4C9SnrxSH/jZUih3jmO6lBKr99rP3/+FmrMj4EFpOXzMtXHAZkd3x0E6h6Fgflw==
currently-unhandled@^0.4.1:
version "0.4.1"
resolved "https://registry.yarnpkg.com/currently-unhandled/-/currently-unhandled-0.4.1.tgz#988df33feab191ef799a61369dd76c17adf957ea"
@ -8757,11 +8796,6 @@ hasha@^5.0.0:
is-stream "^2.0.0"
type-fest "^0.8.0"
heap@0.2.5:
version "0.2.5"
resolved "https://registry.yarnpkg.com/heap/-/heap-0.2.5.tgz#713b65590ebcc40fcbeeaf55e851694092b39af1"
integrity sha1-cTtlWQ68xA/L7q9V6FFpQJKzmvE=
history-with-query@4.10.4:
version "4.10.4"
resolved "https://registry.yarnpkg.com/history-with-query/-/history-with-query-4.10.4.tgz#8161ff3c5044e29dfaeb73e7587eb3d4c1a8090e"
@ -10326,6 +10360,16 @@ joi@^17.3.0:
"@sideway/formula" "^3.0.0"
"@sideway/pinpoint" "^2.0.0"
jquery-mousewheel@^3.1.13:
version "3.1.13"
resolved "https://registry.yarnpkg.com/jquery-mousewheel/-/jquery-mousewheel-3.1.13.tgz#06f0335f16e353a695e7206bf50503cb523a6ee5"
integrity sha1-BvAzXxbjU6aV5yBr9QUDy1I6buU=
jquery@^3.5.1:
version "3.6.0"
resolved "https://registry.yarnpkg.com/jquery/-/jquery-3.6.0.tgz#c72a09f15c1bdce142f49dbf1170bdf8adac2470"
integrity sha512-JVzAR/AjBvVt2BmYhxRCSYysDsPcssdmTFnzyLEts9qNwmjmu4JTAMYubEfwVOSwpQ1I1sKKFcxhZCI2buerfw==
js-beautify@^1.13.0:
version "1.13.5"
resolved "https://registry.yarnpkg.com/js-beautify/-/js-beautify-1.13.5.tgz#a08a97890cae55daf1d758d3f6577bd4a64d7014"
@ -10868,6 +10912,11 @@ locate-path@^5.0.0:
dependencies:
p-locate "^4.1.0"
lodash-es@^4.17.15:
version "4.17.21"
resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.21.tgz#43e626c46e6591b7750beb2b50117390c609e3ee"
integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==
lodash._reinterpolate@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/lodash._reinterpolate/-/lodash._reinterpolate-3.0.0.tgz#0ccf2d89166af03b3663c796538b75ac6e114d9d"
@ -10968,7 +11017,7 @@ lodash@4.17.20:
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.20.tgz#b44a9b6297bcb698f1c51a3545a2b3b368d59c52"
integrity sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==
lodash@^4.15.0, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.17.4:
lodash@^4.0.1, lodash@^4.15.0, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.17.4:
version "4.17.21"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
@ -11168,6 +11217,11 @@ marked@1.2.7:
resolved "https://registry.yarnpkg.com/marked/-/marked-1.2.7.tgz#6e14b595581d2319cdcf033a24caaf41455a01fb"
integrity sha512-No11hFYcXr/zkBvL6qFmAp1z6BKY3zqLMHny/JN/ey+al7qwCM2+CMBL9BOgqMxZU36fz4cCWfn2poWIf7QRXA==
material-colors@^1.2.1:
version "1.2.6"
resolved "https://registry.yarnpkg.com/material-colors/-/material-colors-1.2.6.tgz#6d1958871126992ceecc72f4bcc4d8f010865f46"
integrity sha512-6qE4B9deFBIa9YSpOc9O0Sgc43zTeVYbgDT5veRKSlB2+ZuHNoVVxA1L/ckMUayV9Ay9y7Z/SZCLcGteW9i7bg==
mathml-tag-names@^2.0.1, mathml-tag-names@^2.1.3:
version "2.1.3"
resolved "https://registry.yarnpkg.com/mathml-tag-names/-/mathml-tag-names-2.1.3.tgz#4ddadd67308e780cf16a47685878ee27b736a0a3"
@ -11579,6 +11633,11 @@ moo@^0.5.0:
resolved "https://registry.yarnpkg.com/moo/-/moo-0.5.1.tgz#7aae7f384b9b09f620b6abf6f74ebbcd1b65dbc4"
integrity sha512-I1mnb5xn4fO80BH9BLcF0yLypy2UKl+Cb01Fu0hJRkJjlCRtxZMWkTdAtDd5ZqCOxtCkhmRwyI57vWT+1iZ67w==
mousetrap@^1.6.5:
version "1.6.5"
resolved "https://registry.yarnpkg.com/mousetrap/-/mousetrap-1.6.5.tgz#8a766d8c272b08393d5f56074e0b5ec183485bf9"
integrity sha512-QNo4kEepaIBwiT8CDhP98umTetp+JNfQYBWvC1pc6/OAibuXtRcxZ58Qz8skvEHYvURne/7R8T5VoOI7rDsEUA==
ms@2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"
@ -12565,13 +12624,6 @@ path-type@^4.0.0:
resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b"
integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==
pathfinding@^0.4.18:
version "0.4.18"
resolved "https://registry.yarnpkg.com/pathfinding/-/pathfinding-0.4.18.tgz#a9990f6fa22b7ef196e5651b049165403a045fe8"
integrity sha1-qZkPb6IrfvGW5WUbBJFlQDoEX+g=
dependencies:
heap "0.2.5"
pause-stream@0.0.11:
version "0.0.11"
resolved "https://registry.yarnpkg.com/pause-stream/-/pause-stream-0.0.11.tgz#fe5a34b0cbce12b5aa6a2b403ee2e73b602f1445"
@ -13348,7 +13400,7 @@ prompts@^2.0.1:
kleur "^3.0.3"
sisteransi "^1.0.5"
prop-types@15.x, prop-types@^15.5.10, prop-types@^15.5.4, prop-types@^15.5.6, prop-types@^15.5.8, prop-types@^15.6.0, prop-types@^15.6.2, prop-types@^15.7.2:
prop-types@15.x, prop-types@^15.5.10, prop-types@^15.5.4, prop-types@^15.5.6, prop-types@^15.5.8, prop-types@^15.6.2, prop-types@^15.7.2:
version "15.7.2"
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5"
integrity sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==
@ -13691,7 +13743,7 @@ rc-drawer@~4.3.0:
classnames "^2.2.6"
rc-util "^5.7.0"
rc-dropdown@^3.1.3, rc-dropdown@~3.2.0:
rc-dropdown@^3.0.0-alpha.0, rc-dropdown@^3.1.3, rc-dropdown@~3.2.0:
version "3.2.0"
resolved "https://registry.yarnpkg.com/rc-dropdown/-/rc-dropdown-3.2.0.tgz#da6c2ada403842baee3a9e909a0b1a91ba3e1090"
integrity sha512-j1HSw+/QqlhxyTEF6BArVZnTmezw2LnSmRk6I9W7BCqNCKaRwleRmMMs1PHbuaG8dKHVqP6e21RQ7vPBLVnnNw==
@ -14018,7 +14070,7 @@ rc-upload@~4.2.0-alpha.0:
classnames "^2.2.5"
rc-util "^5.2.0"
rc-util@4.x, rc-util@^4.0.4, rc-util@^4.15.3, rc-util@^4.4.0:
rc-util@4.x, rc-util@^4.0.4, rc-util@^4.15.3, rc-util@^4.15.7, rc-util@^4.4.0:
version "4.21.1"
resolved "https://registry.yarnpkg.com/rc-util/-/rc-util-4.21.1.tgz#88602d0c3185020aa1053d9a1e70eac161becb05"
integrity sha512-Z+vlkSQVc1l8O2UjR3WQ+XdWlhj5q9BMQNLk2iOBch75CqPfrJyGtcWMcnhRlNuDu0Ndtt4kLVO8JI8BrABobg==
@ -14068,6 +14120,19 @@ react-app-polyfill@^1.0.4:
regenerator-runtime "^0.13.3"
whatwg-fetch "^3.0.0"
react-color@^2.17.3:
version "2.19.3"
resolved "https://registry.yarnpkg.com/react-color/-/react-color-2.19.3.tgz#ec6c6b4568312a3c6a18420ab0472e146aa5683d"
integrity sha512-LEeGE/ZzNLIsFWa1TMe8y5VYqr7bibneWmvJwm1pCn/eNmrabWDh659JSPn9BuaMpEfU83WTOJfnCcjDZwNQTA==
dependencies:
"@icons/material" "^0.2.4"
lodash "^4.17.15"
lodash-es "^4.17.15"
material-colors "^1.2.1"
prop-types "^15.5.10"
reactcss "^1.2.0"
tinycolor2 "^1.4.1"
react-copy-to-clipboard@^5.0.3:
version "5.0.3"
resolved "https://registry.yarnpkg.com/react-copy-to-clipboard/-/react-copy-to-clipboard-5.0.3.tgz#2a0623b1115a1d8c84144e9434d3342b5af41ab4"
@ -14124,14 +14189,6 @@ react-dom@16.x, react-dom@^16.8.6:
prop-types "^15.6.2"
scheduler "^0.19.1"
react-draggable@^4.4.3:
version "4.4.3"
resolved "https://registry.yarnpkg.com/react-draggable/-/react-draggable-4.4.3.tgz#0727f2cae5813e36b0e4962bf11b2f9ef2b406f3"
integrity sha512-jV4TE59MBuWm7gb6Ns3Q1mxX8Azffb7oTtDtBgFkxRvhDp38YAARmRplrj0+XGkhOJB5XziArX+4HUUABtyZ0w==
dependencies:
classnames "^2.2.5"
prop-types "^15.6.0"
react-error-overlay@^5.1.6:
version "5.1.6"
resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-5.1.6.tgz#0cd73407c5d141f9638ae1e0c63e7b2bf7e9929d"
@ -14221,10 +14278,15 @@ react-refresh@0.9.0:
resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.9.0.tgz#71863337adc3e5c2f8a6bfddd12ae3bfe32aafbf"
integrity sha512-Gvzk7OZpiqKSkxsQvO/mbTN1poglhmAV7gR/DdIrRrSMXraRQQlfikRJOr3Nb9GTMPC5kof948Zy6jJZIFtDvQ==
react-resize-observer@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/react-resize-observer/-/react-resize-observer-1.1.1.tgz#641dfa2e0f4bd2549a8ab4bbbaf43b68f3dcaf76"
integrity sha512-3R+90Hou90Mr3wJYc+unsySC8Pn91V4nmjO32NKvUvjphRUbq9HisyLg7bDyGBE7xlMrrM6Fax7iNQaFdc/FYA==
react-resize-detector@^6.6.4:
version "6.6.5"
resolved "https://registry.yarnpkg.com/react-resize-detector/-/react-resize-detector-6.6.5.tgz#adde70db9c76da09892134b8f6c4dfd351cdd93c"
integrity sha512-khKS1IpC2cfx5+6G9HkAU/9CGjDV8woE57pVeH8nP5Ji52yXz6MpQEHEzJZ2obGghWrewN4php8ArxB4yWNqZA==
dependencies:
"@types/resize-observer-browser" "^0.1.5"
lodash.debounce "^4.0.8"
lodash.throttle "^4.1.1"
resize-observer-polyfill "^1.5.1"
react-router-config@5.1.1:
version "5.1.1"
@ -14275,11 +14337,6 @@ react-tween-state@^0.1.5:
raf "^3.1.0"
tween-functions "^1.0.1"
react-zoom-pan-pinch@^1.6.1:
version "1.6.1"
resolved "https://registry.yarnpkg.com/react-zoom-pan-pinch/-/react-zoom-pan-pinch-1.6.1.tgz#da16267c258ab37e8ebcdc7c252794a9633e91ec"
integrity sha512-J2eM0gZ04XiUWvmKZrOhSAB2zjyoK7kw2POIeN1X0yTTlmp6HPGV0zYfjnlkhgt8nQwpvXAbsF/oAnkuiwk1kA==
react@16.x, react@^16.8.6:
version "16.14.0"
resolved "https://registry.yarnpkg.com/react/-/react-16.14.0.tgz#94d776ddd0aaa37da3eda8fc5b6b18a4c9a3114d"
@ -14289,6 +14346,13 @@ react@16.x, react@^16.8.6:
object-assign "^4.1.1"
prop-types "^15.6.2"
reactcss@^1.2.0:
version "1.2.3"
resolved "https://registry.yarnpkg.com/reactcss/-/reactcss-1.2.3.tgz#c00013875e557b1cf0dfd9a368a1c3dab3b548dd"
integrity sha512-KiwVUcFu1RErkI97ywr8nvx8dNOpT03rbnma0SSalTYjkrPYaEajR4a/MRt6DZ46K6arDRbWMNHF+xH7G7n/8A==
dependencies:
lodash "^4.0.1"
read-only-stream@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/read-only-stream/-/read-only-stream-2.0.0.tgz#2724fd6a8113d73764ac288d4386270c1dbf17f0"
@ -16577,6 +16641,11 @@ tiny-warning@^1.0.0, tiny-warning@^1.0.3:
resolved "https://registry.yarnpkg.com/tiny-warning/-/tiny-warning-1.0.3.tgz#94a30db453df4c643d0fd566060d60a875d84754"
integrity sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==
tinycolor2@^1.4.1:
version "1.4.2"
resolved "https://registry.yarnpkg.com/tinycolor2/-/tinycolor2-1.4.2.tgz#3f6a4d1071ad07676d7fa472e1fac40a719d8803"
integrity sha512-vJhccZPs965sV/L2sU4oRQVAos0pQXwsvTLkWYdqJ+a8Q5kPFzJTuOFwy7UniPli44NKQGAglksjvOcpo95aZA==
tlds@^1.217.0:
version "1.218.0"
resolved "https://registry.yarnpkg.com/tlds/-/tlds-1.218.0.tgz#f31804891c650c136f88cb8ec2f043577b5f5afd"
@ -16860,6 +16929,11 @@ ua-parser-js@^0.7.18, ua-parser-js@^0.7.24:
resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.24.tgz#8d3ecea46ed4f1f1d63ec25f17d8568105dc027c"
integrity sha512-yo+miGzQx5gakzVK3QFfN0/L9uVhosXBBO7qmnk7c2iw1IhL212wfA3zbnI54B0obGwC/5NWub/iT9sReMx+Fw==
ua-parser-js@^0.7.20:
version "0.7.28"
resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.28.tgz#8ba04e653f35ce210239c64661685bf9121dec31"
integrity sha512-6Gurc1n//gjp9eQNXjD9O3M/sMwVtN5S8Lv9bvOYBfKfDNiIIhqiyi01vMBO45u4zkDE420w/e0se7Vs+sIg+g==
umd@^3.0.0:
version "3.0.3"
resolved "https://registry.yarnpkg.com/umd/-/umd-3.0.3.tgz#aa9fe653c42b9097678489c01000acb69f0b26cf"
@ -17285,6 +17359,11 @@ util@~0.10.1:
dependencies:
inherits "2.0.3"
utility-types@^3.10.0:
version "3.10.0"
resolved "https://registry.yarnpkg.com/utility-types/-/utility-types-3.10.0.tgz#ea4148f9a741015f05ed74fd615e1d20e6bed82b"
integrity sha512-O11mqxmi7wMKCo6HKFt5AhO4BwY3VV68YU07tgxfz8zJTIxr4BpsezN49Ffwy9j3ZpwwJp4fkRwjRzq3uWE6Rg==
utils-merge@1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713"