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 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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -23,7 +23,6 @@ public class DataInitializer {
|
|||
@EventListener(ApplicationReadyEvent.class)
|
||||
public void init() {
|
||||
authService.createDefaultUserIfAbsent();
|
||||
|
||||
}
|
||||
|
||||
@EventListener(ApplicationStartedEvent.class)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
) {
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<>();
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue