From 294f5d8369cea2b0c93d85ff22977b4356adb9c8 Mon Sep 17 00:00:00 2001 From: kirillius Date: Fri, 14 Aug 2026 23:33:48 +0300 Subject: [PATCH] WIP --- .../pf/sdn/api/flow/ExecutionGraph.java | 22 +- .../pf/sdn/api/flow/StartCondition.java | 34 +- .../sdn/api/properties/StringConstraint.java | 2 + .../sdn/api/properties/ValuesConstraint.java | 2 + .../pf/sdn/config/DataInitializer.java | 1 - .../dto/FlowFunctionDescriptorResponse.java | 6 +- .../kirillius/pf/sdn/entity/FlowConfig.java | 7 +- .../kirillius/pf/sdn/service/FlowService.java | 10 +- webapp/src/pages/FlowEditor.jsx | 292 +++++++++++++++++- webapp/src/styles/global.css | 47 +++ 10 files changed, 395 insertions(+), 28 deletions(-) diff --git a/src/main/java/ru/kirillius/pf/sdn/api/flow/ExecutionGraph.java b/src/main/java/ru/kirillius/pf/sdn/api/flow/ExecutionGraph.java index b4ee19b..edceaef 100644 --- a/src/main/java/ru/kirillius/pf/sdn/api/flow/ExecutionGraph.java +++ b/src/main/java/ru/kirillius/pf/sdn/api/flow/ExecutionGraph.java @@ -11,6 +11,7 @@ import ru.kirillius.pf.sdn.entity.ActionConfig; import ru.kirillius.pf.sdn.entity.ActionConnectionConfig; import java.util.*; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Function; @Slf4j @@ -21,6 +22,8 @@ public class ExecutionGraph { return nodes.size(); } + private final AtomicBoolean interrupted = new AtomicBoolean(false); + @Getter private final EventHandler startExecutingEvent = new ConcurrentEventHandler<>(); private final Map nodes = new HashMap<>(); @@ -74,14 +77,25 @@ public class ExecutionGraph { final var configs = new HashSet<>(nodes.keySet()); return new Iterator<>() { + private ExecutionGraph.Node nodeToExecute; + @Override public boolean hasNext() { - return executed.size() != nodes.size(); + if (executed.size() == nodes.size()) { + nodeToExecute = null; + return false; + } + + nodeToExecute = findNodeToExecute(configs); + + return nodeToExecute != null; } @Override public ExecutionResult next() { - var nodeToExecute = findNodeToExecute(configs); + if (nodeToExecute == null) { + throw new IllegalStateException("Nothing to execute"); + } var action = nodeToExecute.action(); try { startExecutingEvent.invoke(action); @@ -108,12 +122,12 @@ public class ExecutionGraph { for (var index : indices) { 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; } } - throw new NoSuchElementException("There is No node to execute"); + return null; } @Builder diff --git a/src/main/java/ru/kirillius/pf/sdn/api/flow/StartCondition.java b/src/main/java/ru/kirillius/pf/sdn/api/flow/StartCondition.java index afbb561..469dd36 100644 --- a/src/main/java/ru/kirillius/pf/sdn/api/flow/StartCondition.java +++ b/src/main/java/ru/kirillius/pf/sdn/api/flow/StartCondition.java @@ -1,34 +1,44 @@ package ru.kirillius.pf.sdn.api.flow; import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import lombok.Getter; -import lombok.Setter; +import jakarta.persistence.Embeddable; +import lombok.*; import org.hibernate.annotations.JdbcTypeCode; import org.hibernate.type.SqlTypes; import java.util.HashMap; import java.util.Map; +import java.util.Objects; @Getter @Setter -@Entity +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Embeddable public class StartCondition { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - @Column - private boolean dontStartIfRunning; + @Builder.Default + private boolean dontStartIfRunning = false; @Column(length = 100) private TriggerType trigger; @JdbcTypeCode(SqlTypes.JSON) @Column(name = "properties") + @Builder.Default private Map 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); + } } diff --git a/src/main/java/ru/kirillius/pf/sdn/api/properties/StringConstraint.java b/src/main/java/ru/kirillius/pf/sdn/api/properties/StringConstraint.java index 1f2134d..78ca951 100644 --- a/src/main/java/ru/kirillius/pf/sdn/api/properties/StringConstraint.java +++ b/src/main/java/ru/kirillius/pf/sdn/api/properties/StringConstraint.java @@ -1,7 +1,9 @@ package ru.kirillius.pf.sdn.api.properties; +import lombok.Getter; import lombok.RequiredArgsConstructor; +@Getter @RequiredArgsConstructor public class StringConstraint extends PropertyConstraint { private final boolean canBeEmpty; diff --git a/src/main/java/ru/kirillius/pf/sdn/api/properties/ValuesConstraint.java b/src/main/java/ru/kirillius/pf/sdn/api/properties/ValuesConstraint.java index 6fa2306..5c8e874 100644 --- a/src/main/java/ru/kirillius/pf/sdn/api/properties/ValuesConstraint.java +++ b/src/main/java/ru/kirillius/pf/sdn/api/properties/ValuesConstraint.java @@ -1,7 +1,9 @@ package ru.kirillius.pf.sdn.api.properties; +import lombok.Getter; import lombok.RequiredArgsConstructor; +@Getter @RequiredArgsConstructor public class ValuesConstraint extends PropertyConstraint { private final Object[] values; diff --git a/src/main/java/ru/kirillius/pf/sdn/config/DataInitializer.java b/src/main/java/ru/kirillius/pf/sdn/config/DataInitializer.java index 0d67e0e..445c723 100644 --- a/src/main/java/ru/kirillius/pf/sdn/config/DataInitializer.java +++ b/src/main/java/ru/kirillius/pf/sdn/config/DataInitializer.java @@ -23,7 +23,6 @@ public class DataInitializer { @EventListener(ApplicationReadyEvent.class) public void init() { authService.createDefaultUserIfAbsent(); - } @EventListener(ApplicationStartedEvent.class) diff --git a/src/main/java/ru/kirillius/pf/sdn/dto/FlowFunctionDescriptorResponse.java b/src/main/java/ru/kirillius/pf/sdn/dto/FlowFunctionDescriptorResponse.java index 4a93107..6ca636c 100644 --- a/src/main/java/ru/kirillius/pf/sdn/dto/FlowFunctionDescriptorResponse.java +++ b/src/main/java/ru/kirillius/pf/sdn/dto/FlowFunctionDescriptorResponse.java @@ -1,6 +1,9 @@ package ru.kirillius.pf.sdn.dto; +import ru.kirillius.pf.sdn.api.properties.PropertyDescriptor; + import java.util.List; +import java.util.Map; public record FlowFunctionDescriptorResponse( String id, @@ -9,6 +12,7 @@ public record FlowFunctionDescriptorResponse( List inputNames, List outputNames, int inputCount, - int outputCount + int outputCount, + Map properties ) { } diff --git a/src/main/java/ru/kirillius/pf/sdn/entity/FlowConfig.java b/src/main/java/ru/kirillius/pf/sdn/entity/FlowConfig.java index 67a3081..c6bd3da 100644 --- a/src/main/java/ru/kirillius/pf/sdn/entity/FlowConfig.java +++ b/src/main/java/ru/kirillius/pf/sdn/entity/FlowConfig.java @@ -36,7 +36,12 @@ public class FlowConfig { 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 private List startConditions = new ArrayList<>(); diff --git a/src/main/java/ru/kirillius/pf/sdn/service/FlowService.java b/src/main/java/ru/kirillius/pf/sdn/service/FlowService.java index ff46101..ce60e06 100644 --- a/src/main/java/ru/kirillius/pf/sdn/service/FlowService.java +++ b/src/main/java/ru/kirillius/pf/sdn/service/FlowService.java @@ -5,6 +5,7 @@ import jakarta.annotation.PreDestroy; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.config.AutowireCapableBeanFactory; +import org.springframework.boot.context.event.ApplicationReadyEvent; import org.springframework.stereotype.Service; import org.springframework.transaction.event.TransactionPhase; import org.springframework.transaction.event.TransactionalEventListener; @@ -78,7 +79,7 @@ public class FlowService { group = e.getKey().substring(0, idx); 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)) .toList(); @@ -207,7 +208,14 @@ public class FlowService { subscriptionSetUpdateListener = subscriptionService.getSetUpdateEvent().add(this::subscriptionSetUpdate); executedListener = flowExecutorService.getOnExecuted().add(this::checkForIntervalExecution); registerFunction(DummyFunction.class, FALLBACK); + + } + + //TODO переделать всё на Spring events + @org.springframework.context.event.EventListener(ApplicationReadyEvent.class) + private void onStart(){ configRepository.findAll().forEach(this::reloadFlowInternal); + flows.values() .stream() .filter(p -> p diff --git a/webapp/src/pages/FlowEditor.jsx b/webapp/src/pages/FlowEditor.jsx index 8911a09..383be97 100644 --- a/webapp/src/pages/FlowEditor.jsx +++ b/webapp/src/pages/FlowEditor.jsx @@ -14,12 +14,13 @@ import { useReactFlow, } from '@xyflow/react'; 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 { ArrowLeftOutlined, DeleteOutlined, SaveOutlined, PlusOutlined, + SettingOutlined, ThunderboltOutlined, PauseCircleFilled, PauseCircleOutlined, @@ -45,8 +46,7 @@ function normalizeConditions(list) { .filter(Boolean); } return { - key: c.id ?? `loaded-${i}`, - id: c.id ?? null, + key: `loaded-${i}`, trigger: c.trigger, dontStartIfRunning: !!c.dontStartIfRunning, properties: { ...props, names: Array.isArray(names) ? names : [] }, @@ -67,17 +67,255 @@ function buildConditionsPayload(startConditions) { ) { 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 ; + } + + if (array) { + if (type === 'SUBSCRIPTION') { + return ( + + ); + } + if (type === 'INTEGER' || type === 'DOUBLE') { + return ( + ({ value: String(v), label: String(v) }))} + style={{ width: '100%' }} + /> + ); + } + return ( + + ); + } + if (type === 'INTEGER' || type === 'DOUBLE') { + return ( + onChange(v ?? '')} + style={{ width: '100%' }} + /> + ); + } + if (valuesConstraint) { + return ( + 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 ( + onOk(buildStoredFromDraft(draft, descriptorMap))} + onCancel={onCancel} + okText="Сохранить" + cancelText="Отмена" + width={680} + > + {entries.length === 0 ? ( + + ) : ( +
+
+
Параметр
+
Значение
+
+ {entries.map(([key, descriptor]) => ( +
+
+ {key} + + {descriptor.required && обязательно} + {descriptor.array && массив} + +
+
+ update(key, v)} + subscriptionOptions={subscriptionOptions} + /> +
+
+ ))} +
+ )} +
+ ); +} + +function FlowNode({ id, data, selected }) { const fn = data.fn; const inputCount = fn?.inputCount || 0; const outputCount = fn?.outputCount || 0; return (
+ + + {(() => { + const editingNode = nodes.find((n) => n.id === editingNodeId); + if (!editingNode) return null; + return ( + setEditingNodeId(null)} + /> + ); + })()}
); } diff --git a/webapp/src/styles/global.css b/webapp/src/styles/global.css index f03ffb4..7e50ff6 100644 --- a/webapp/src/styles/global.css +++ b/webapp/src/styles/global.css @@ -275,6 +275,53 @@ body { 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 { font-weight: 600; white-space: nowrap;