WIP
This commit is contained in:
parent
b469fe193c
commit
294f5d8369
|
|
@ -11,6 +11,7 @@ import ru.kirillius.pf.sdn.entity.ActionConfig;
|
||||||
import ru.kirillius.pf.sdn.entity.ActionConnectionConfig;
|
import ru.kirillius.pf.sdn.entity.ActionConnectionConfig;
|
||||||
|
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
import java.util.function.Function;
|
import java.util.function.Function;
|
||||||
|
|
||||||
@Slf4j
|
@Slf4j
|
||||||
|
|
@ -21,6 +22,8 @@ public class ExecutionGraph {
|
||||||
return nodes.size();
|
return nodes.size();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private final AtomicBoolean interrupted = new AtomicBoolean(false);
|
||||||
|
|
||||||
@Getter
|
@Getter
|
||||||
private final EventHandler<FlowAction> startExecutingEvent = new ConcurrentEventHandler<>();
|
private final EventHandler<FlowAction> startExecutingEvent = new ConcurrentEventHandler<>();
|
||||||
private final Map<Integer, Node> nodes = new HashMap<>();
|
private final Map<Integer, Node> nodes = new HashMap<>();
|
||||||
|
|
@ -74,14 +77,25 @@ public class ExecutionGraph {
|
||||||
final var configs = new HashSet<>(nodes.keySet());
|
final var configs = new HashSet<>(nodes.keySet());
|
||||||
|
|
||||||
return new Iterator<>() {
|
return new Iterator<>() {
|
||||||
|
private ExecutionGraph.Node nodeToExecute;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean hasNext() {
|
public boolean hasNext() {
|
||||||
return executed.size() != nodes.size();
|
if (executed.size() == nodes.size()) {
|
||||||
|
nodeToExecute = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
nodeToExecute = findNodeToExecute(configs);
|
||||||
|
|
||||||
|
return nodeToExecute != null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public ExecutionResult next() {
|
public ExecutionResult next() {
|
||||||
var nodeToExecute = findNodeToExecute(configs);
|
if (nodeToExecute == null) {
|
||||||
|
throw new IllegalStateException("Nothing to execute");
|
||||||
|
}
|
||||||
var action = nodeToExecute.action();
|
var action = nodeToExecute.action();
|
||||||
try {
|
try {
|
||||||
startExecutingEvent.invoke(action);
|
startExecutingEvent.invoke(action);
|
||||||
|
|
@ -108,12 +122,12 @@ public class ExecutionGraph {
|
||||||
for (var index : indices) {
|
for (var index : indices) {
|
||||||
var node = nodes.get(index);
|
var node = nodes.get(index);
|
||||||
//проверяем что все на всех входах есть данные
|
//проверяем что все на всех входах есть данные
|
||||||
if (node.inputs().stream().allMatch(connection -> executed.containsKey(connection.from()))) {
|
if (node.inputs().stream().allMatch(connection -> executed.containsKey(connection.from()) && executed.get(connection.from()) != null)) {
|
||||||
return node;
|
return node;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new NoSuchElementException("There is No node to execute");
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Builder
|
@Builder
|
||||||
|
|
|
||||||
|
|
@ -1,34 +1,44 @@
|
||||||
package ru.kirillius.pf.sdn.api.flow;
|
package ru.kirillius.pf.sdn.api.flow;
|
||||||
|
|
||||||
import jakarta.persistence.Column;
|
import jakarta.persistence.Column;
|
||||||
import jakarta.persistence.Entity;
|
import jakarta.persistence.Embeddable;
|
||||||
import jakarta.persistence.GeneratedValue;
|
import lombok.*;
|
||||||
import jakarta.persistence.GenerationType;
|
|
||||||
import jakarta.persistence.Id;
|
|
||||||
import lombok.Getter;
|
|
||||||
import lombok.Setter;
|
|
||||||
import org.hibernate.annotations.JdbcTypeCode;
|
import org.hibernate.annotations.JdbcTypeCode;
|
||||||
import org.hibernate.type.SqlTypes;
|
import org.hibernate.type.SqlTypes;
|
||||||
|
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
@Getter
|
@Getter
|
||||||
@Setter
|
@Setter
|
||||||
@Entity
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Embeddable
|
||||||
public class StartCondition {
|
public class StartCondition {
|
||||||
@Id
|
|
||||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
|
||||||
private Long id;
|
|
||||||
|
|
||||||
@Column
|
@Builder.Default
|
||||||
private boolean dontStartIfRunning;
|
private boolean dontStartIfRunning = false;
|
||||||
|
|
||||||
@Column(length = 100)
|
@Column(length = 100)
|
||||||
private TriggerType trigger;
|
private TriggerType trigger;
|
||||||
|
|
||||||
@JdbcTypeCode(SqlTypes.JSON)
|
@JdbcTypeCode(SqlTypes.JSON)
|
||||||
@Column(name = "properties")
|
@Column(name = "properties")
|
||||||
|
@Builder.Default
|
||||||
private Map<String, Object> properties = new HashMap<>();
|
private Map<String, Object> properties = new HashMap<>();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean equals(Object o) {
|
||||||
|
if (!(o instanceof StartCondition that)) return false;
|
||||||
|
return dontStartIfRunning == that.dontStartIfRunning
|
||||||
|
&& Objects.equals(trigger, that.trigger)
|
||||||
|
&& Objects.equals(properties, that.properties);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int hashCode() {
|
||||||
|
return Objects.hash(dontStartIfRunning, trigger, properties);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
package ru.kirillius.pf.sdn.api.properties;
|
package ru.kirillius.pf.sdn.api.properties;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
|
||||||
|
@Getter
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class StringConstraint extends PropertyConstraint {
|
public class StringConstraint extends PropertyConstraint {
|
||||||
private final boolean canBeEmpty;
|
private final boolean canBeEmpty;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
package ru.kirillius.pf.sdn.api.properties;
|
package ru.kirillius.pf.sdn.api.properties;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
|
||||||
|
@Getter
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class ValuesConstraint extends PropertyConstraint {
|
public class ValuesConstraint extends PropertyConstraint {
|
||||||
private final Object[] values;
|
private final Object[] values;
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,6 @@ public class DataInitializer {
|
||||||
@EventListener(ApplicationReadyEvent.class)
|
@EventListener(ApplicationReadyEvent.class)
|
||||||
public void init() {
|
public void init() {
|
||||||
authService.createDefaultUserIfAbsent();
|
authService.createDefaultUserIfAbsent();
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@EventListener(ApplicationStartedEvent.class)
|
@EventListener(ApplicationStartedEvent.class)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
package ru.kirillius.pf.sdn.dto;
|
package ru.kirillius.pf.sdn.dto;
|
||||||
|
|
||||||
|
import ru.kirillius.pf.sdn.api.properties.PropertyDescriptor;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
public record FlowFunctionDescriptorResponse(
|
public record FlowFunctionDescriptorResponse(
|
||||||
String id,
|
String id,
|
||||||
|
|
@ -9,6 +12,7 @@ public record FlowFunctionDescriptorResponse(
|
||||||
List<String> inputNames,
|
List<String> inputNames,
|
||||||
List<String> outputNames,
|
List<String> outputNames,
|
||||||
int inputCount,
|
int inputCount,
|
||||||
int outputCount
|
int outputCount,
|
||||||
|
Map<String, PropertyDescriptor> properties
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,12 @@ public class FlowConfig {
|
||||||
return Objects.hash(id, name);
|
return Objects.hash(id, name);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ManyToMany(fetch = FetchType.EAGER, cascade = CascadeType.ALL)
|
@ElementCollection(fetch = FetchType.EAGER)
|
||||||
|
@CollectionTable(
|
||||||
|
name = "flow_config_start_conditions",
|
||||||
|
joinColumns = @JoinColumn(name = "flow_config_id")
|
||||||
|
)
|
||||||
|
@OrderColumn(name = "condition_index")
|
||||||
@Builder.Default
|
@Builder.Default
|
||||||
private List<StartCondition> startConditions = new ArrayList<>();
|
private List<StartCondition> startConditions = new ArrayList<>();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import jakarta.annotation.PreDestroy;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.factory.config.AutowireCapableBeanFactory;
|
import org.springframework.beans.factory.config.AutowireCapableBeanFactory;
|
||||||
|
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.event.TransactionPhase;
|
import org.springframework.transaction.event.TransactionPhase;
|
||||||
import org.springframework.transaction.event.TransactionalEventListener;
|
import org.springframework.transaction.event.TransactionalEventListener;
|
||||||
|
|
@ -78,7 +79,7 @@ public class FlowService {
|
||||||
group = e.getKey().substring(0, idx);
|
group = e.getKey().substring(0, idx);
|
||||||
name = e.getKey().substring(idx + 1);
|
name = e.getKey().substring(idx + 1);
|
||||||
}
|
}
|
||||||
return new FlowFunctionDescriptorResponse(e.getKey(), group, name, fn.getInputNames(), fn.getOutputNames(), fn.getInputCount(), fn.getOutputCount());
|
return new FlowFunctionDescriptorResponse(e.getKey(), group, name, fn.getInputNames(), fn.getOutputNames(), fn.getInputCount(), fn.getOutputCount(), fn.getProperties());
|
||||||
})
|
})
|
||||||
.sorted(java.util.Comparator.comparing(FlowFunctionDescriptorResponse::id))
|
.sorted(java.util.Comparator.comparing(FlowFunctionDescriptorResponse::id))
|
||||||
.toList();
|
.toList();
|
||||||
|
|
@ -207,7 +208,14 @@ public class FlowService {
|
||||||
subscriptionSetUpdateListener = subscriptionService.getSetUpdateEvent().add(this::subscriptionSetUpdate);
|
subscriptionSetUpdateListener = subscriptionService.getSetUpdateEvent().add(this::subscriptionSetUpdate);
|
||||||
executedListener = flowExecutorService.getOnExecuted().add(this::checkForIntervalExecution);
|
executedListener = flowExecutorService.getOnExecuted().add(this::checkForIntervalExecution);
|
||||||
registerFunction(DummyFunction.class, FALLBACK);
|
registerFunction(DummyFunction.class, FALLBACK);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
//TODO переделать всё на Spring events
|
||||||
|
@org.springframework.context.event.EventListener(ApplicationReadyEvent.class)
|
||||||
|
private void onStart(){
|
||||||
configRepository.findAll().forEach(this::reloadFlowInternal);
|
configRepository.findAll().forEach(this::reloadFlowInternal);
|
||||||
|
|
||||||
flows.values()
|
flows.values()
|
||||||
.stream()
|
.stream()
|
||||||
.filter(p -> p
|
.filter(p -> p
|
||||||
|
|
|
||||||
|
|
@ -14,12 +14,13 @@ import {
|
||||||
useReactFlow,
|
useReactFlow,
|
||||||
} from '@xyflow/react';
|
} from '@xyflow/react';
|
||||||
import '@xyflow/react/dist/style.css';
|
import '@xyflow/react/dist/style.css';
|
||||||
import { App, Button, Empty, Input, InputNumber, Modal, Popconfirm, Select, Space, Spin, Tooltip, Typography } from 'antd';
|
import { App, Button, Empty, Input, InputNumber, Modal, Popconfirm, Select, Space, Spin, Switch, Tag, Tooltip, Typography } from 'antd';
|
||||||
import {
|
import {
|
||||||
ArrowLeftOutlined,
|
ArrowLeftOutlined,
|
||||||
DeleteOutlined,
|
DeleteOutlined,
|
||||||
SaveOutlined,
|
SaveOutlined,
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
|
SettingOutlined,
|
||||||
ThunderboltOutlined,
|
ThunderboltOutlined,
|
||||||
PauseCircleFilled,
|
PauseCircleFilled,
|
||||||
PauseCircleOutlined,
|
PauseCircleOutlined,
|
||||||
|
|
@ -45,8 +46,7 @@ function normalizeConditions(list) {
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
key: c.id ?? `loaded-${i}`,
|
key: `loaded-${i}`,
|
||||||
id: c.id ?? null,
|
|
||||||
trigger: c.trigger,
|
trigger: c.trigger,
|
||||||
dontStartIfRunning: !!c.dontStartIfRunning,
|
dontStartIfRunning: !!c.dontStartIfRunning,
|
||||||
properties: { ...props, names: Array.isArray(names) ? names : [] },
|
properties: { ...props, names: Array.isArray(names) ? names : [] },
|
||||||
|
|
@ -67,17 +67,255 @@ function buildConditionsPayload(startConditions) {
|
||||||
) {
|
) {
|
||||||
props.names = c.properties.names;
|
props.names = c.properties.names;
|
||||||
}
|
}
|
||||||
return { id: c.id, trigger: c.trigger, dontStartIfRunning: c.dontStartIfRunning, properties: props };
|
return { trigger: c.trigger, dontStartIfRunning: c.dontStartIfRunning, properties: props };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function FlowNode({ data, selected }) {
|
function splitArrayValue(value) {
|
||||||
|
if (value == null || value === '') return [];
|
||||||
|
return String(value)
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function valueFromStored(stored, descriptor) {
|
||||||
|
if (descriptor.array) {
|
||||||
|
return splitArrayValue(stored);
|
||||||
|
}
|
||||||
|
if (descriptor.type === 'BOOLEAN') {
|
||||||
|
if (stored == null || stored === '') return descriptor.defaultValue === true;
|
||||||
|
return stored === 'true';
|
||||||
|
}
|
||||||
|
if (stored != null && stored !== '') {
|
||||||
|
if (descriptor.type === 'INTEGER' || descriptor.type === 'DOUBLE') {
|
||||||
|
const num = Number(stored);
|
||||||
|
return Number.isNaN(num) ? stored : num;
|
||||||
|
}
|
||||||
|
return stored;
|
||||||
|
}
|
||||||
|
if (descriptor.type === 'INTEGER' || descriptor.type === 'DOUBLE') {
|
||||||
|
return descriptor.defaultValue ?? undefined;
|
||||||
|
}
|
||||||
|
return descriptor.defaultValue ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDraftFromStored(stored, descriptorMap) {
|
||||||
|
const out = {};
|
||||||
|
Object.entries(descriptorMap || {}).forEach(([key, descriptor]) => {
|
||||||
|
out[key] = valueFromStored(stored?.[key], descriptor);
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function valueToStored(value, descriptor) {
|
||||||
|
if (descriptor.array) {
|
||||||
|
return (Array.isArray(value) ? value : [])
|
||||||
|
.map((v) => String(v).trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(',');
|
||||||
|
}
|
||||||
|
if (descriptor.type === 'BOOLEAN') return value ? 'true' : 'false';
|
||||||
|
if (value == null || value === '') return '';
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildStoredFromDraft(draft, descriptorMap) {
|
||||||
|
const out = {};
|
||||||
|
Object.entries(descriptorMap || {}).forEach(([key, descriptor]) => {
|
||||||
|
const stored = valueToStored(draft[key], descriptor);
|
||||||
|
if (stored !== '') out[key] = stored;
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PropertyInput({ descriptor, value, onChange, subscriptionOptions }) {
|
||||||
|
const { type, array, constraints } = descriptor;
|
||||||
|
const valuesConstraint = constraints?.find((c) => c.name === 'ValuesConstraint');
|
||||||
|
const intConstraint = constraints?.find((c) => c.name === 'IntegerConstraint');
|
||||||
|
const stringConstraint = constraints?.find((c) => c.name === 'StringConstraint');
|
||||||
|
|
||||||
|
if (type === 'BOOLEAN' && !array) {
|
||||||
|
return <Switch checked={!!value} onChange={onChange} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array) {
|
||||||
|
if (type === 'SUBSCRIPTION') {
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
mode="multiple"
|
||||||
|
allowClear
|
||||||
|
showSearch
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
options={subscriptionOptions}
|
||||||
|
placeholder="Выберите подписки"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (type === 'SUBNET') {
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
mode="tags"
|
||||||
|
allowClear
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
placeholder="Введите подсети"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (type === 'INTEGER' || type === 'DOUBLE') {
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
mode="tags"
|
||||||
|
allowClear
|
||||||
|
value={(value || []).map(String)}
|
||||||
|
onChange={onChange}
|
||||||
|
placeholder="Введите числа"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (valuesConstraint) {
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
mode="multiple"
|
||||||
|
allowClear
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
options={valuesConstraint.values.map((v) => ({ value: String(v), label: String(v) }))}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
mode="tags"
|
||||||
|
allowClear
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
placeholder="Введите значения"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'SUBSCRIPTION') {
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
showSearch
|
||||||
|
value={value || undefined}
|
||||||
|
onChange={onChange}
|
||||||
|
options={subscriptionOptions}
|
||||||
|
placeholder="Выберите подписку"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (type === 'INTEGER' || type === 'DOUBLE') {
|
||||||
|
return (
|
||||||
|
<InputNumber
|
||||||
|
min={intConstraint?.min}
|
||||||
|
max={intConstraint?.max}
|
||||||
|
value={value}
|
||||||
|
onChange={(v) => onChange(v ?? '')}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (valuesConstraint) {
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
value={value || undefined}
|
||||||
|
onChange={onChange}
|
||||||
|
options={valuesConstraint.values.map((v) => ({ value: String(v), label: String(v) }))}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
value={value || ''}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
maxLength={stringConstraint?.maxLength}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NodeSettingsModal({ node, descriptorMap, subscriptionOptions, onOk, onCancel }) {
|
||||||
|
const [draft, setDraft] = useState(() => buildDraftFromStored(node.data.properties, descriptorMap));
|
||||||
|
const entries = Object.entries(descriptorMap || {});
|
||||||
|
|
||||||
|
const update = useCallback((key, value) => {
|
||||||
|
setDraft((prev) => ({ ...prev, [key]: value }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={`Настройки: ${node.data.label}`}
|
||||||
|
open
|
||||||
|
onOk={() => onOk(buildStoredFromDraft(draft, descriptorMap))}
|
||||||
|
onCancel={onCancel}
|
||||||
|
okText="Сохранить"
|
||||||
|
cancelText="Отмена"
|
||||||
|
width={680}
|
||||||
|
>
|
||||||
|
{entries.length === 0 ? (
|
||||||
|
<Empty description="У функции нет настраиваемых параметров" image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||||
|
) : (
|
||||||
|
<div className="flow-settings__table">
|
||||||
|
<div className="flow-settings__row flow-settings__row--head">
|
||||||
|
<div>Параметр</div>
|
||||||
|
<div>Значение</div>
|
||||||
|
</div>
|
||||||
|
{entries.map(([key, descriptor]) => (
|
||||||
|
<div key={key} className="flow-settings__row">
|
||||||
|
<div className="flow-settings__param">
|
||||||
|
<span>{key}</span>
|
||||||
|
<Space size={4}>
|
||||||
|
{descriptor.required && <Tag color="red">обязательно</Tag>}
|
||||||
|
{descriptor.array && <Tag>массив</Tag>}
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<PropertyInput
|
||||||
|
descriptor={descriptor}
|
||||||
|
value={draft[key]}
|
||||||
|
onChange={(v) => update(key, v)}
|
||||||
|
subscriptionOptions={subscriptionOptions}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FlowNode({ id, data, selected }) {
|
||||||
const fn = data.fn;
|
const fn = data.fn;
|
||||||
const inputCount = fn?.inputCount || 0;
|
const inputCount = fn?.inputCount || 0;
|
||||||
const outputCount = fn?.outputCount || 0;
|
const outputCount = fn?.outputCount || 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`flow-node ${selected ? 'flow-node--selected' : ''}`}>
|
<div className={`flow-node ${selected ? 'flow-node--selected' : ''}`}>
|
||||||
|
<Button
|
||||||
|
className="flow-node__settings"
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={<SettingOutlined />}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
data.onOpenSettings?.(id);
|
||||||
|
}}
|
||||||
|
aria-label="Настройки"
|
||||||
|
/>
|
||||||
{inputCount > 0 && (
|
{inputCount > 0 && (
|
||||||
<div className="flow-node__ports flow-node__ports--in">
|
<div className="flow-node__ports flow-node__ports--in">
|
||||||
{fn.inputNames.map((name, i) => (
|
{fn.inputNames.map((name, i) => (
|
||||||
|
|
@ -163,10 +401,24 @@ function FlowEditorInner() {
|
||||||
const [subscriptions, setSubscriptions] = useState([]);
|
const [subscriptions, setSubscriptions] = useState([]);
|
||||||
const [subscriptionGroups, setSubscriptionGroups] = useState([]);
|
const [subscriptionGroups, setSubscriptionGroups] = useState([]);
|
||||||
const [conditionsOpen, setConditionsOpen] = useState(false);
|
const [conditionsOpen, setConditionsOpen] = useState(false);
|
||||||
|
const [editingNodeId, setEditingNodeId] = useState(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const nextKeyRef = useRef(0);
|
const nextKeyRef = useRef(0);
|
||||||
|
|
||||||
|
const openNodeSettings = useCallback((nodeId) => setEditingNodeId(nodeId), []);
|
||||||
|
|
||||||
|
const applyNodeSettings = useCallback(
|
||||||
|
(stored) => {
|
||||||
|
const nodeId = editingNodeId;
|
||||||
|
setEditingNodeId(null);
|
||||||
|
if (nodeId != null) {
|
||||||
|
setNodes((nds) => nds.map((n) => (n.id === nodeId ? { ...n, data: { ...n.data, properties: stored } } : n)));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[editingNodeId]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
(async () => {
|
(async () => {
|
||||||
|
|
@ -201,6 +453,8 @@ function FlowEditorInner() {
|
||||||
fn: fnMap[a.functionId],
|
fn: fnMap[a.functionId],
|
||||||
label: fnMap[a.functionId]?.name || a.functionId,
|
label: fnMap[a.functionId]?.name || a.functionId,
|
||||||
group: fnMap[a.functionId]?.group || '—',
|
group: fnMap[a.functionId]?.group || '—',
|
||||||
|
properties: a.properties || {},
|
||||||
|
onOpenSettings: openNodeSettings,
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
@ -299,7 +553,14 @@ function FlowEditorInner() {
|
||||||
id: String(nds.length),
|
id: String(nds.length),
|
||||||
type: 'flow',
|
type: 'flow',
|
||||||
position: pos,
|
position: pos,
|
||||||
data: { functionId, fn, label: fn.name, group: fn.group },
|
data: {
|
||||||
|
functionId,
|
||||||
|
fn,
|
||||||
|
label: fn.name,
|
||||||
|
group: fn.group,
|
||||||
|
properties: {},
|
||||||
|
onOpenSettings: openNodeSettings,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
},
|
},
|
||||||
|
|
@ -326,7 +587,6 @@ function FlowEditorInner() {
|
||||||
...prev,
|
...prev,
|
||||||
{
|
{
|
||||||
key: `new-${nextKeyRef.current++}`,
|
key: `new-${nextKeyRef.current++}`,
|
||||||
id: null,
|
|
||||||
trigger: 'Manual',
|
trigger: 'Manual',
|
||||||
dontStartIfRunning: false,
|
dontStartIfRunning: false,
|
||||||
properties: {},
|
properties: {},
|
||||||
|
|
@ -358,7 +618,7 @@ function FlowEditorInner() {
|
||||||
const onSave = async () => {
|
const onSave = async () => {
|
||||||
const actions = nodes.map((n) => ({
|
const actions = nodes.map((n) => ({
|
||||||
functionId: n.data.functionId,
|
functionId: n.data.functionId,
|
||||||
properties: {},
|
properties: n.data.properties || {},
|
||||||
x: Math.round(n.position.x),
|
x: Math.round(n.position.x),
|
||||||
y: Math.round(n.position.y),
|
y: Math.round(n.position.y),
|
||||||
}));
|
}));
|
||||||
|
|
@ -489,6 +749,7 @@ function FlowEditorInner() {
|
||||||
onEdgesChange={onEdgesChange}
|
onEdgesChange={onEdgesChange}
|
||||||
onConnect={onConnect}
|
onConnect={onConnect}
|
||||||
isValidConnection={isValidConnection}
|
isValidConnection={isValidConnection}
|
||||||
|
onNodeDoubleClick={(_event, node) => openNodeSettings(node.id)}
|
||||||
connectionMode="strict"
|
connectionMode="strict"
|
||||||
fitView
|
fitView
|
||||||
fitViewOptions={{ padding: 0.2 }}
|
fitViewOptions={{ padding: 0.2 }}
|
||||||
|
|
@ -575,6 +836,21 @@ function FlowEditorInner() {
|
||||||
</Button>
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{(() => {
|
||||||
|
const editingNode = nodes.find((n) => n.id === editingNodeId);
|
||||||
|
if (!editingNode) return null;
|
||||||
|
return (
|
||||||
|
<NodeSettingsModal
|
||||||
|
key={editingNode.id}
|
||||||
|
node={editingNode}
|
||||||
|
descriptorMap={editingNode.data.fn?.properties}
|
||||||
|
subscriptionOptions={subscriptionOptions}
|
||||||
|
onOk={applyNodeSettings}
|
||||||
|
onCancel={() => setEditingNodeId(null)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -275,6 +275,53 @@ body {
|
||||||
box-shadow: 0 0 0 2px rgba(22, 104, 220, 0.2);
|
box-shadow: 0 0 0 2px rgba(22, 104, 220, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.flow-node__settings {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
right: 2px;
|
||||||
|
color: #bfbfbf;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow-node__settings:hover {
|
||||||
|
color: #1668dc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow-settings__table {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow-settings__row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 240px 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 0;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow-settings__row--head {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #8c8c8c;
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow-settings__row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow-settings__param {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow-settings__param > span {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
.flow-node__title {
|
.flow-node__title {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue