This commit is contained in:
kirillius 2026-08-14 23:33:48 +03:00
parent b469fe193c
commit 294f5d8369
10 changed files with 395 additions and 28 deletions

View File

@ -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<FlowAction> startExecutingEvent = new ConcurrentEventHandler<>();
private final Map<Integer, Node> 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

View File

@ -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<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);
}
}

View File

@ -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;

View File

@ -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;

View File

@ -23,7 +23,6 @@ public class DataInitializer {
@EventListener(ApplicationReadyEvent.class)
public void init() {
authService.createDefaultUserIfAbsent();
}
@EventListener(ApplicationStartedEvent.class)

View File

@ -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<String> inputNames,
List<String> outputNames,
int inputCount,
int outputCount
int outputCount,
Map<String, PropertyDescriptor> properties
) {
}

View File

@ -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<StartCondition> startConditions = new ArrayList<>();

View File

@ -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

View File

@ -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 <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 inputCount = fn?.inputCount || 0;
const outputCount = fn?.outputCount || 0;
return (
<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 && (
<div className="flow-node__ports flow-node__ports--in">
{fn.inputNames.map((name, i) => (
@ -163,10 +401,24 @@ function FlowEditorInner() {
const [subscriptions, setSubscriptions] = useState([]);
const [subscriptionGroups, setSubscriptionGroups] = useState([]);
const [conditionsOpen, setConditionsOpen] = useState(false);
const [editingNodeId, setEditingNodeId] = useState(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
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(() => {
let cancelled = false;
(async () => {
@ -201,6 +453,8 @@ function FlowEditorInner() {
fn: fnMap[a.functionId],
label: fnMap[a.functionId]?.name || a.functionId,
group: fnMap[a.functionId]?.group || '—',
properties: a.properties || {},
onOpenSettings: openNodeSettings,
},
}))
);
@ -299,7 +553,14 @@ function FlowEditorInner() {
id: String(nds.length),
type: 'flow',
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,
{
key: `new-${nextKeyRef.current++}`,
id: null,
trigger: 'Manual',
dontStartIfRunning: false,
properties: {},
@ -358,7 +618,7 @@ function FlowEditorInner() {
const onSave = async () => {
const actions = nodes.map((n) => ({
functionId: n.data.functionId,
properties: {},
properties: n.data.properties || {},
x: Math.round(n.position.x),
y: Math.round(n.position.y),
}));
@ -489,6 +749,7 @@ function FlowEditorInner() {
onEdgesChange={onEdgesChange}
onConnect={onConnect}
isValidConnection={isValidConnection}
onNodeDoubleClick={(_event, node) => openNodeSettings(node.id)}
connectionMode="strict"
fitView
fitViewOptions={{ padding: 0.2 }}
@ -575,6 +836,21 @@ function FlowEditorInner() {
</Button>
</Space>
</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>
);
}

View File

@ -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;