mirror of
https://github.com/UNITRONIX/BetterDesk.git
synced 2026-09-11 13:49:03 +00:00
dd0889e3d6
Introduce a SessionManager for relay-based remote sessions in the Tauri MGMT client: new SessionCommand API, start/stop/session input routing, clipboard/recording/quality controls, and notification read/dismiss state. Wire AppState with new mutexes and show main window on startup. CI: add SBOM generation (anchore) and Trivy vulnerability scan steps. Misc: change console Docker DB path, large README/CHANGELOG updates (chat E2E, unattended access/WOL, i18n expansion, CDAP/SDK docs), and many web-nodejs assets/locales/routes/views/services and server-side changes.
563 lines
23 KiB
JavaScript
563 lines
23 KiB
JavaScript
/* fleet-builder.js — Visual workflow builder (SVG node editor) */
|
|
'use strict';
|
|
(function () {
|
|
const _ = window._ || (k => k);
|
|
|
|
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
const NODE_W = 180;
|
|
const NODE_H = 56;
|
|
const PORT_R = 6;
|
|
|
|
let _svg = null;
|
|
let _group = null; // top-level <g> for zoom/pan
|
|
let _zoom = 1;
|
|
let _panX = 0, _panY = 0;
|
|
|
|
let _nodes = []; // { id, type, category, label, x, y, config: {} }
|
|
let _connections = []; // { id, from, to }
|
|
let _nextId = 1;
|
|
let _selected = null;
|
|
let _dragging = null;
|
|
let _connecting = null; // { fromId, startX, startY, line }
|
|
let _panning = false;
|
|
let _panStart = null;
|
|
|
|
let _workflowId = null;
|
|
let _workflowName = 'Untitled';
|
|
|
|
// Category → color mapping
|
|
const CATS = {
|
|
trigger: { color: '#58a6ff', headerFill: 'rgba(88,166,255,.15)' },
|
|
condition: { color: '#f0b429', headerFill: 'rgba(240,180,41,.15)' },
|
|
action: { color: '#3fb950', headerFill: 'rgba(63,185,80,.15)' },
|
|
result: { color: '#f85149', headerFill: 'rgba(248,81,73,.15)' },
|
|
transform: { color: '#bc8cff', headerFill: 'rgba(188,140,255,.15)' }
|
|
};
|
|
|
|
// Node type → category + default label
|
|
const NODE_DEFS = {
|
|
trigger_manual: { cat: 'trigger', label: 'Manual Trigger' },
|
|
trigger_schedule: { cat: 'trigger', label: 'Schedule' },
|
|
trigger_event: { cat: 'trigger', label: 'Event Trigger' },
|
|
cond_os: { cat: 'condition', label: 'OS Check' },
|
|
cond_threshold: { cat: 'condition', label: 'Threshold' },
|
|
cond_time_window: { cat: 'condition', label: 'Time Window' },
|
|
action_script: { cat: 'action', label: 'Run Script' },
|
|
action_install: { cat: 'action', label: 'Install Software' },
|
|
action_restart: { cat: 'action', label: 'Restart Service' },
|
|
action_file_deploy:{ cat: 'action', label: 'Deploy File' },
|
|
action_message: { cat: 'action', label: 'Send Message' },
|
|
result_log: { cat: 'result', label: 'Log Output' },
|
|
result_alert: { cat: 'result', label: 'Send Alert' },
|
|
result_chain: { cat: 'result', label: 'Chain Workflow' },
|
|
transform_parse: { cat: 'transform', label: 'Parse JSON' },
|
|
transform_filter: { cat: 'transform', label: 'Filter' },
|
|
transform_delay: { cat: 'transform', label: 'Delay' }
|
|
};
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Init
|
|
// -----------------------------------------------------------------------
|
|
function init() {
|
|
_svg = document.getElementById('builder-canvas');
|
|
if (!_svg) return;
|
|
|
|
// Marker for arrowheads
|
|
const defs = svgEl('defs');
|
|
const marker = svgEl('marker', { id: 'arrowhead', markerWidth: '8', markerHeight: '6', refX: '8', refY: '3', orient: 'auto' });
|
|
const poly = svgEl('polygon', { points: '0 0, 8 3, 0 6', fill: '#8b949e' });
|
|
marker.appendChild(poly);
|
|
defs.appendChild(marker);
|
|
_svg.appendChild(defs);
|
|
|
|
_group = svgEl('g');
|
|
_svg.appendChild(_group);
|
|
|
|
// Events
|
|
_svg.addEventListener('mousedown', onCanvasMouseDown);
|
|
_svg.addEventListener('mousemove', onCanvasMouseMove);
|
|
_svg.addEventListener('mouseup', onCanvasMouseUp);
|
|
_svg.addEventListener('wheel', onWheel, { passive: false });
|
|
|
|
// Palette drag & drop
|
|
document.querySelectorAll('.palette-node[draggable]').forEach(el => {
|
|
el.addEventListener('dragstart', onPaletteDragStart);
|
|
});
|
|
_svg.parentElement.addEventListener('dragover', e => e.preventDefault());
|
|
_svg.parentElement.addEventListener('drop', onPaletteDrop);
|
|
|
|
// Keyboard
|
|
document.addEventListener('keydown', onKeyDown);
|
|
|
|
// Load workflow list
|
|
if (window.Fleet) Fleet.loadWorkflowList();
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// SVG helpers
|
|
// -----------------------------------------------------------------------
|
|
function svgEl(tag, attrs) {
|
|
const el = document.createElementNS(SVG_NS, tag);
|
|
if (attrs) Object.entries(attrs).forEach(([k, v]) => el.setAttribute(k, v));
|
|
return el;
|
|
}
|
|
|
|
function updateTransform() {
|
|
_group.setAttribute('transform', `translate(${_panX},${_panY}) scale(${_zoom})`);
|
|
const zoomEl = document.getElementById('builder-zoom-level');
|
|
if (zoomEl) zoomEl.textContent = Math.round(_zoom * 100) + '%';
|
|
}
|
|
|
|
function svgPoint(e) {
|
|
const rect = _svg.getBoundingClientRect();
|
|
return {
|
|
x: (e.clientX - rect.left - _panX) / _zoom,
|
|
y: (e.clientY - rect.top - _panY) / _zoom
|
|
};
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Node creation
|
|
// -----------------------------------------------------------------------
|
|
function addNode(type, x, y) {
|
|
const def = NODE_DEFS[type];
|
|
if (!def) return null;
|
|
const id = 'n' + (_nextId++);
|
|
const node = { id, type, category: def.cat, label: def.label, x, y, config: {} };
|
|
_nodes.push(node);
|
|
renderNode(node);
|
|
return node;
|
|
}
|
|
|
|
function renderNode(node) {
|
|
const cat = CATS[node.category] || CATS.action;
|
|
const g = svgEl('g', { class: 'node-group', 'data-id': node.id, transform: `translate(${node.x},${node.y})` });
|
|
|
|
// Body
|
|
const body = svgEl('rect', { class: 'node-body', x: '0', y: '0', width: NODE_W, height: NODE_H });
|
|
g.appendChild(body);
|
|
|
|
// Header band
|
|
const header = svgEl('rect', { class: 'node-header ' + node.category, x: '0', y: '0', width: NODE_W, height: '22' });
|
|
g.appendChild(header);
|
|
|
|
// Category dot
|
|
const dot = svgEl('circle', { cx: '12', cy: '11', r: '4', fill: cat.color });
|
|
g.appendChild(dot);
|
|
|
|
// Title
|
|
const title = svgEl('text', { class: 'node-title', x: '22', y: '11' });
|
|
title.textContent = node.label.length > 20 ? node.label.slice(0, 18) + '…' : node.label;
|
|
g.appendChild(title);
|
|
|
|
// Input port (left center)
|
|
if (node.category !== 'trigger') {
|
|
const inPort = svgEl('circle', {
|
|
class: 'node-port port-in', cx: '0', cy: String(NODE_H / 2), r: PORT_R,
|
|
'data-node': node.id, 'data-dir': 'in'
|
|
});
|
|
g.appendChild(inPort);
|
|
}
|
|
|
|
// Output port (right center)
|
|
const outPort = svgEl('circle', {
|
|
class: 'node-port port-out', cx: String(NODE_W), cy: String(NODE_H / 2), r: PORT_R,
|
|
'data-node': node.id, 'data-dir': 'out'
|
|
});
|
|
g.appendChild(outPort);
|
|
|
|
// Drag events on the group
|
|
g.addEventListener('mousedown', onNodeMouseDown);
|
|
|
|
_group.appendChild(g);
|
|
}
|
|
|
|
function removeNode(id) {
|
|
_nodes = _nodes.filter(n => n.id !== id);
|
|
_connections = _connections.filter(c => c.from !== id && c.to !== id);
|
|
const el = _group.querySelector(`[data-id="${id}"]`);
|
|
if (el) el.remove();
|
|
redrawConnections();
|
|
if (_selected === id) { _selected = null; hideProps(); }
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Connections
|
|
// -----------------------------------------------------------------------
|
|
function addConnection(fromId, toId) {
|
|
if (fromId === toId) return;
|
|
if (_connections.find(c => c.from === fromId && c.to === toId)) return;
|
|
const id = 'c' + (_nextId++);
|
|
_connections.push({ id, from: fromId, to: toId });
|
|
redrawConnections();
|
|
}
|
|
|
|
function redrawConnections() {
|
|
_group.querySelectorAll('.connection-line').forEach(el => el.remove());
|
|
_connections.forEach(c => {
|
|
const fromNode = _nodes.find(n => n.id === c.from);
|
|
const toNode = _nodes.find(n => n.id === c.to);
|
|
if (!fromNode || !toNode) return;
|
|
const x1 = fromNode.x + NODE_W;
|
|
const y1 = fromNode.y + NODE_H / 2;
|
|
const x2 = toNode.x;
|
|
const y2 = toNode.y + NODE_H / 2;
|
|
const cpx = Math.abs(x2 - x1) * 0.4;
|
|
const d = `M${x1},${y1} C${x1 + cpx},${y1} ${x2 - cpx},${y2} ${x2},${y2}`;
|
|
const path = svgEl('path', { class: 'connection-line', d, 'data-conn': c.id });
|
|
path.addEventListener('dblclick', () => {
|
|
_connections = _connections.filter(cc => cc.id !== c.id);
|
|
path.remove();
|
|
});
|
|
// Insert connections below nodes
|
|
_group.insertBefore(path, _group.firstChild);
|
|
});
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Palette drag & drop
|
|
// -----------------------------------------------------------------------
|
|
function onPaletteDragStart(e) {
|
|
e.dataTransfer.setData('text/plain', e.currentTarget.dataset.type);
|
|
}
|
|
|
|
function onPaletteDrop(e) {
|
|
e.preventDefault();
|
|
const type = e.dataTransfer.getData('text/plain');
|
|
if (!type || !NODE_DEFS[type]) return;
|
|
const pt = svgPoint(e);
|
|
addNode(type, pt.x - NODE_W / 2, pt.y - NODE_H / 2);
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Mouse events
|
|
// -----------------------------------------------------------------------
|
|
function onNodeMouseDown(e) {
|
|
const g = e.currentTarget;
|
|
const nodeId = g.dataset.id;
|
|
const target = e.target;
|
|
|
|
// Port click → start connection
|
|
if (target.classList.contains('node-port')) {
|
|
e.stopPropagation();
|
|
if (target.dataset.dir === 'out') {
|
|
const node = _nodes.find(n => n.id === nodeId);
|
|
if (!node) return;
|
|
const sx = node.x + NODE_W;
|
|
const sy = node.y + NODE_H / 2;
|
|
const line = svgEl('path', { class: 'connection-line', d: `M${sx},${sy} L${sx},${sy}` });
|
|
_group.appendChild(line);
|
|
_connecting = { fromId: nodeId, sx, sy, line };
|
|
}
|
|
return;
|
|
}
|
|
|
|
e.stopPropagation();
|
|
selectNode(nodeId);
|
|
|
|
// Start drag
|
|
const pt = svgPoint(e);
|
|
const node = _nodes.find(n => n.id === nodeId);
|
|
if (!node) return;
|
|
_dragging = { id: nodeId, offX: pt.x - node.x, offY: pt.y - node.y };
|
|
}
|
|
|
|
function onCanvasMouseDown(e) {
|
|
if (e.target === _svg || e.target === _group) {
|
|
selectNode(null);
|
|
// Start pan
|
|
_panning = true;
|
|
_panStart = { x: e.clientX - _panX, y: e.clientY - _panY };
|
|
}
|
|
}
|
|
|
|
function onCanvasMouseMove(e) {
|
|
if (_dragging) {
|
|
const pt = svgPoint(e);
|
|
const node = _nodes.find(n => n.id === _dragging.id);
|
|
if (!node) return;
|
|
node.x = pt.x - _dragging.offX;
|
|
node.y = pt.y - _dragging.offY;
|
|
const g = _group.querySelector(`[data-id="${_dragging.id}"]`);
|
|
if (g) g.setAttribute('transform', `translate(${node.x},${node.y})`);
|
|
redrawConnections();
|
|
}
|
|
if (_connecting) {
|
|
const pt = svgPoint(e);
|
|
const cpx = Math.abs(pt.x - _connecting.sx) * 0.4;
|
|
_connecting.line.setAttribute('d',
|
|
`M${_connecting.sx},${_connecting.sy} C${_connecting.sx + cpx},${_connecting.sy} ${pt.x - cpx},${pt.y} ${pt.x},${pt.y}`
|
|
);
|
|
}
|
|
if (_panning && _panStart) {
|
|
_panX = e.clientX - _panStart.x;
|
|
_panY = e.clientY - _panStart.y;
|
|
updateTransform();
|
|
}
|
|
}
|
|
|
|
function onCanvasMouseUp(e) {
|
|
if (_connecting) {
|
|
_connecting.line.remove();
|
|
// Check if dropped on an input port
|
|
const target = e.target;
|
|
if (target.classList.contains('node-port') && target.dataset.dir === 'in') {
|
|
addConnection(_connecting.fromId, target.dataset.node);
|
|
}
|
|
_connecting = null;
|
|
}
|
|
_dragging = null;
|
|
_panning = false;
|
|
_panStart = null;
|
|
}
|
|
|
|
function onWheel(e) {
|
|
e.preventDefault();
|
|
const delta = e.deltaY > 0 ? -0.08 : 0.08;
|
|
_zoom = Math.max(0.3, Math.min(3, _zoom + delta));
|
|
updateTransform();
|
|
}
|
|
|
|
function onKeyDown(e) {
|
|
if (e.key === 'Delete' || e.key === 'Backspace') {
|
|
if (_selected && document.activeElement?.tagName !== 'INPUT' && document.activeElement?.tagName !== 'TEXTAREA') {
|
|
removeNode(_selected);
|
|
}
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Selection & properties
|
|
// -----------------------------------------------------------------------
|
|
function selectNode(id) {
|
|
_group.querySelectorAll('.node-selected').forEach(g => g.classList.remove('node-selected'));
|
|
_selected = id;
|
|
if (id) {
|
|
const g = _group.querySelector(`[data-id="${id}"]`);
|
|
if (g) g.classList.add('node-selected');
|
|
showProps(id);
|
|
} else {
|
|
hideProps();
|
|
}
|
|
}
|
|
|
|
function showProps(id) {
|
|
const node = _nodes.find(n => n.id === id);
|
|
if (!node) return;
|
|
const panel = document.getElementById('builder-props');
|
|
const body = document.getElementById('props-body');
|
|
const title = document.getElementById('props-title');
|
|
if (!panel || !body) return;
|
|
panel.style.display = 'block';
|
|
title.textContent = node.label;
|
|
body.innerHTML = buildPropsForm(node);
|
|
}
|
|
|
|
function hideProps() {
|
|
const panel = document.getElementById('builder-props');
|
|
if (panel) panel.style.display = 'none';
|
|
}
|
|
|
|
function closeProps() { hideProps(); selectNode(null); }
|
|
|
|
function buildPropsForm(node) {
|
|
let html = `
|
|
<div class="form-group">
|
|
<label>Label</label>
|
|
<input class="form-control" value="${esc(node.label)}" onchange="FleetBuilder.updateNodeProp('${node.id}','label',this.value)">
|
|
</div>`;
|
|
|
|
// Type-specific fields
|
|
if (node.type === 'trigger_schedule') {
|
|
html += field(node, 'cron', 'Cron Expression', '*/5 * * * *');
|
|
}
|
|
if (node.type === 'trigger_event') {
|
|
html += selectField(node, 'event', 'Event Type', ['disk_full', 'cpu_high', 'user_login', 'service_down']);
|
|
html += field(node, 'threshold', 'Threshold (%)', '90');
|
|
}
|
|
if (node.type === 'cond_os') {
|
|
html += selectField(node, 'os', 'Target OS', ['windows', 'linux', 'macos', 'any']);
|
|
}
|
|
if (node.type === 'cond_threshold') {
|
|
html += selectField(node, 'metric', 'Metric', ['cpu', 'ram', 'disk', 'network']);
|
|
html += selectField(node, 'operator', 'Operator', ['>', '<', '>=', '<=', '==']);
|
|
html += field(node, 'value', 'Value', '80');
|
|
}
|
|
if (node.type === 'cond_time_window') {
|
|
html += field(node, 'start_time', 'Start Time', '08:00');
|
|
html += field(node, 'end_time', 'End Time', '18:00');
|
|
html += field(node, 'days', 'Days (0=Sun..6=Sat)', '1,2,3,4,5');
|
|
}
|
|
if (node.type === 'action_script') {
|
|
html += `<div class="form-group"><label>Script</label>
|
|
<textarea class="form-control code-textarea" rows="5" onchange="FleetBuilder.updateNodeProp('${node.id}','script',this.value)">${esc(node.config.script || '')}</textarea></div>`;
|
|
html += selectField(node, 'shell', 'Shell', ['bash', 'powershell', 'cmd', 'python']);
|
|
}
|
|
if (node.type === 'action_install') {
|
|
html += field(node, 'package', 'Package/URL', '');
|
|
html += selectField(node, 'method', 'Method', ['msi', 'deb', 'rpm', 'apt', 'chocolatey', 'winget']);
|
|
}
|
|
if (node.type === 'action_restart') {
|
|
html += field(node, 'service', 'Service Name', '');
|
|
}
|
|
if (node.type === 'action_file_deploy') {
|
|
html += field(node, 'source', 'Source Path/URL', '');
|
|
html += field(node, 'destination', 'Destination Path', '');
|
|
}
|
|
if (node.type === 'action_message') {
|
|
html += field(node, 'message', 'Message', '');
|
|
}
|
|
if (node.type === 'result_alert') {
|
|
html += selectField(node, 'channel', 'Channel', ['console', 'email', 'webhook']);
|
|
html += field(node, 'recipient', 'Recipient', '');
|
|
}
|
|
if (node.type === 'result_chain') {
|
|
html += field(node, 'workflow_id', 'Workflow ID', '');
|
|
}
|
|
if (node.type === 'transform_delay') {
|
|
html += field(node, 'seconds', 'Delay (seconds)', '10');
|
|
}
|
|
if (node.type === 'transform_filter') {
|
|
html += field(node, 'expression', 'Filter Expression', '');
|
|
}
|
|
|
|
html += `<div class="form-group" style="margin-top:16px;">
|
|
<button class="btn btn-secondary btn-sm" onclick="FleetBuilder.removeSelected()" style="width:100%">
|
|
<span class="material-icons" style="font-size:14px;vertical-align:middle;">delete</span> Delete Node
|
|
</button></div>`;
|
|
return html;
|
|
}
|
|
|
|
function field(node, key, label, placeholder) {
|
|
return `<div class="form-group"><label>${esc(label)}</label>
|
|
<input class="form-control" placeholder="${esc(placeholder)}" value="${esc(node.config[key] || '')}"
|
|
onchange="FleetBuilder.updateNodeProp('${node.id}','${key}',this.value)">
|
|
</div>`;
|
|
}
|
|
|
|
function selectField(node, key, label, opts) {
|
|
const val = node.config[key] || '';
|
|
return `<div class="form-group"><label>${esc(label)}</label>
|
|
<select class="form-control" onchange="FleetBuilder.updateNodeProp('${node.id}','${key}',this.value)">
|
|
${opts.map(o => `<option value="${esc(o)}" ${val === o ? 'selected' : ''}>${esc(o)}</option>`).join('')}
|
|
</select></div>`;
|
|
}
|
|
|
|
function updateNodeProp(nodeId, key, value) {
|
|
const node = _nodes.find(n => n.id === nodeId);
|
|
if (!node) return;
|
|
if (key === 'label') {
|
|
node.label = value;
|
|
const g = _group.querySelector(`[data-id="${nodeId}"]`);
|
|
const txt = g?.querySelector('.node-title');
|
|
if (txt) txt.textContent = value.length > 20 ? value.slice(0, 18) + '…' : value;
|
|
} else {
|
|
node.config[key] = value;
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Workflow operations
|
|
// -----------------------------------------------------------------------
|
|
function newWorkflow() {
|
|
_nodes = []; _connections = []; _nextId = 1; _selected = null; _workflowId = null;
|
|
_workflowName = 'Untitled';
|
|
_group.innerHTML = '';
|
|
hideProps();
|
|
// Add default trigger
|
|
addNode('trigger_manual', 40, 200);
|
|
}
|
|
|
|
async function saveWorkflow() {
|
|
const name = prompt(_('fleet.workflow_name') || 'Workflow name:', _workflowName);
|
|
if (!name) return;
|
|
_workflowName = name;
|
|
const payload = {
|
|
name, nodes: _nodes, connections: _connections
|
|
};
|
|
try {
|
|
const url = _workflowId
|
|
? `/api/panel/fleet/workflows/${encodeURIComponent(_workflowId)}`
|
|
: '/api/panel/fleet/workflows';
|
|
const resp = await fetch(url, {
|
|
method: _workflowId ? 'PUT' : 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload)
|
|
});
|
|
const data = await resp.json();
|
|
if (data.id) _workflowId = data.id;
|
|
if (window.Fleet) Fleet.loadWorkflowList();
|
|
} catch (e) { console.error('saveWorkflow', e); }
|
|
}
|
|
|
|
function exportWorkflow() {
|
|
const payload = JSON.stringify({ name: _workflowName, nodes: _nodes, connections: _connections }, null, 2);
|
|
const blob = new Blob([payload], { type: 'application/json' });
|
|
const a = document.createElement('a');
|
|
a.href = URL.createObjectURL(blob);
|
|
a.download = (_workflowName || 'workflow') + '.json';
|
|
a.click();
|
|
URL.revokeObjectURL(a.href);
|
|
}
|
|
|
|
async function loadWorkflow(id) {
|
|
const sel = document.getElementById('workflow-select');
|
|
const wid = id || sel?.value;
|
|
if (!wid) return;
|
|
try {
|
|
const resp = await fetch(`/api/panel/fleet/workflows/${encodeURIComponent(wid)}`);
|
|
const data = await resp.json();
|
|
const wf = data.data || data;
|
|
_workflowId = wf.id || wid;
|
|
_workflowName = wf.name || 'Untitled';
|
|
_nodes = wf.nodes || [];
|
|
_connections = wf.connections || [];
|
|
_nextId = Math.max(..._nodes.map(n => parseInt(n.id?.replace('n', '') || '0')), ..._connections.map(c => parseInt(c.id?.replace('c', '') || '0'))) + 1;
|
|
_group.innerHTML = '';
|
|
_nodes.forEach(n => renderNode(n));
|
|
redrawConnections();
|
|
} catch (e) { console.error('loadWorkflow', e); }
|
|
}
|
|
|
|
async function execute(dryRun) {
|
|
if (!_workflowId && !_nodes.length) return;
|
|
try {
|
|
const id = _workflowId || 'draft';
|
|
const resp = await fetch(`/api/panel/fleet/workflows/${encodeURIComponent(id)}/execute`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
dry_run: !!dryRun,
|
|
nodes: _nodes,
|
|
connections: _connections
|
|
})
|
|
});
|
|
const data = await resp.json();
|
|
if (data.output || data.log) {
|
|
alert((dryRun ? '[DRY RUN]\n' : '') + (data.output || data.log || 'Workflow executed'));
|
|
}
|
|
} catch (e) { console.error('execute', e); }
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Zoom
|
|
// -----------------------------------------------------------------------
|
|
function zoomIn() { _zoom = Math.min(3, _zoom + 0.15); updateTransform(); }
|
|
function zoomOut() { _zoom = Math.max(0.3, _zoom - 0.15); updateTransform(); }
|
|
function zoomReset() { _zoom = 1; _panX = 0; _panY = 0; updateTransform(); }
|
|
|
|
// Helpers
|
|
function esc(s) { if (!s) return ''; const d = document.createElement('div'); d.textContent = String(s); return d.innerHTML; }
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Public API
|
|
// -----------------------------------------------------------------------
|
|
window.FleetBuilder = {
|
|
init, newWorkflow, saveWorkflow, exportWorkflow, loadWorkflow, execute,
|
|
zoomIn, zoomOut, zoomReset, closeProps,
|
|
updateNodeProp, removeSelected() { if (_selected) removeNode(_selected); }
|
|
};
|
|
|
|
document.addEventListener('DOMContentLoaded', init);
|
|
})();
|