@revenexx/studio-integrations 2.8.0 → 2.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/dist/module.json +1 -1
  3. package/dist/runtime/components/Integrations/ExpressionInput.vue +31 -0
  4. package/dist/runtime/components/Integrations/NodeConfigDynamicField.vue +44 -11
  5. package/dist/runtime/components/Integrations/NodeConfigField.vue +5 -1
  6. package/dist/runtime/components/Integrations/NodePalette.vue +72 -64
  7. package/dist/runtime/components/Integrations/RunDetailPanel.vue +11 -5
  8. package/dist/runtime/components/Integrations/TemplateInstallStep.vue +2 -2
  9. package/dist/runtime/components/Integrations/WorkflowEdgeView.d.vue.ts +6 -0
  10. package/dist/runtime/components/Integrations/WorkflowEdgeView.vue +4 -2
  11. package/dist/runtime/components/Integrations/WorkflowEdgeView.vue.d.ts +6 -0
  12. package/dist/runtime/components/Integrations/WorkflowEditor.vue +147 -27
  13. package/dist/runtime/components/Integrations/WorkflowHistoryPanel.vue +36 -12
  14. package/dist/runtime/components/Integrations/WorkflowNodeView.d.vue.ts +14 -0
  15. package/dist/runtime/components/Integrations/WorkflowNodeView.vue +162 -13
  16. package/dist/runtime/components/Integrations/WorkflowNodeView.vue.d.ts +14 -0
  17. package/dist/runtime/components/Integrations/WorkflowRunReplayPanel.vue +34 -12
  18. package/dist/runtime/components/Integrations/WorkflowTriggerView.d.vue.ts +8 -1
  19. package/dist/runtime/components/Integrations/WorkflowTriggerView.vue +3 -1
  20. package/dist/runtime/components/Integrations/WorkflowTriggerView.vue.d.ts +8 -1
  21. package/dist/runtime/components/Integrations/canvasSides.d.ts +27 -0
  22. package/dist/runtime/components/Integrations/canvasSides.js +22 -0
  23. package/dist/runtime/composables/useIntegrationsCredentials.d.ts +4 -1
  24. package/dist/runtime/composables/useIntegrationsCredentials.js +8 -3
  25. package/dist/runtime/data/canvasShapes.d.ts +139 -0
  26. package/dist/runtime/data/canvasShapes.js +130 -0
  27. package/dist/runtime/data/connectionRules.d.ts +110 -0
  28. package/dist/runtime/data/connectionRules.js +110 -0
  29. package/dist/runtime/data/graphShapes.d.ts +175 -0
  30. package/dist/runtime/data/graphShapes.js +359 -0
  31. package/dist/runtime/data/handleId.d.ts +12 -0
  32. package/dist/runtime/data/handleId.js +3 -0
  33. package/dist/runtime/data/refusalMessage.d.ts +32 -0
  34. package/dist/runtime/data/refusalMessage.js +42 -0
  35. package/dist/runtime/data/saveRefusal.d.ts +31 -0
  36. package/dist/runtime/data/saveRefusal.js +25 -0
  37. package/dist/runtime/i18n/messages/de.json +36 -1
  38. package/dist/runtime/i18n/messages/en.json +36 -1
  39. package/dist/runtime/pages/integrations/schedules.vue +1 -1
  40. package/dist/runtime/pages/integrations/workflows/[id].vue +38 -3
  41. package/dist/runtime/types/integrations.d.ts +13 -4
  42. package/dist/runtime/utils/skippedStep.d.ts +39 -0
  43. package/dist/runtime/utils/skippedStep.js +22 -0
  44. package/package.json +2 -2
@@ -1,15 +1,17 @@
1
1
  <script setup>
2
- import { computed, inject, nextTick, ref, watch } from "vue";
2
+ import { computed, inject, nextTick, onBeforeUnmount, ref, watch } from "vue";
3
3
  import { Handle, Position, useVueFlow } from "@vue-flow/core";
4
4
  import { NodeToolbar } from "@vue-flow/node-toolbar";
5
5
  import * as Lucide from "lucide-vue-next";
6
6
  import { Copy, DangerTriangle as AlertTriangle, RefreshCircle as Loader2, Repeat, TrashBinMinimalistic as TrashBin } from "#solar-icons";
7
7
  import { NODE_CATEGORIES, findManifest, isVersionExecutable } from "../../data/integrationNodeCatalog";
8
8
  import { inputHandleId, outputHandleId } from "../../data/handleId";
9
+ import { branchLineText } from "../../data/canvasShapes";
10
+ import { joinRefusalText } from "../../data/refusalMessage";
9
11
  import { isTerminalManifest } from "../../data/nodeInputData";
10
12
  import { resolveLocalized } from "../../utils/localize";
11
- import { SIDE_POSITION, distributedStyle } from "./canvasSides";
12
- import { fieldDisplayLabel } from "../../utils/validationFindings";
13
+ import { SIDE_POSITION, distributedStyle, spacedByLabel } from "./canvasSides";
14
+ import { fieldDisplayLabel, nodeDisplayLabel } from "../../utils/validationFindings";
13
15
  import { nodeDisplayName } from "../../utils/nodeDisplayName";
14
16
  const props = defineProps({
15
17
  id: { type: String, required: true },
@@ -116,6 +118,94 @@ function portTone(p) {
116
118
  const showPortLabels = computed(() => resolvePending.value || outputs.value.length > 1 || outputs.value.some((p) => p.kind !== "default"));
117
119
  const IN_SIDES = ["top"];
118
120
  const OUT_SIDES = ["bottom"];
121
+ const splitPorts = computed(() => new Set(props.data.__shape?.splitPorts ?? []));
122
+ const isMerger = computed(() => props.data.__shape?.merger === true);
123
+ const isRefused = computed(() => props.data.__shape?.refused !== void 0);
124
+ const inputTitle = computed(() => {
125
+ const shape = props.data.__shape;
126
+ if (shape?.refused) {
127
+ const text = joinRefusalText(shape.refused, nodeDisplayLabel(props.data));
128
+ return [t(text.title), t(text.description, text.params)].join("\n");
129
+ }
130
+ const lines = (shape?.inputBranches ?? []).map(branchLineText);
131
+ if (isMerger.value) return [t("integration.canvas.node.handle.merger"), ...lines].join("\n");
132
+ if (shape?.converging) {
133
+ const keyed = shape.inputBranches?.some((b) => b.address !== "in");
134
+ return [
135
+ t("integration.canvas.node.handle.converging"),
136
+ ...keyed ? [t("integration.canvas.node.handle.convergingKeyed")] : [],
137
+ ...lines
138
+ ].join("\n");
139
+ }
140
+ return t("integration.canvas.node.handle.input");
141
+ });
142
+ const DOT_CLASS = "!size-3 !rounded-full";
143
+ const BOX_CLASS = "!h-3 !w-6 !rounded-[3px] hover:!w-7";
144
+ function outputTitle(p) {
145
+ return splitPorts.value.has(p.name) ? `${handleTitle(p)} \u2014 ${t("integration.canvas.node.handle.splitter")}` : handleTitle(p);
146
+ }
147
+ const VM_STEP = 44;
148
+ const VM_SPREAD = 56;
149
+ const virtualMergers = computed(() => {
150
+ const list = props.data.__shape?.virtualMergers ?? [];
151
+ return list.map((vm) => ({
152
+ ...vm,
153
+ x: vm.offset * VM_SPREAD,
154
+ y: -VM_STEP * vm.level,
155
+ toX: vm.parent === null ? 0 : list[vm.parent].offset * VM_SPREAD,
156
+ toY: -VM_STEP * (vm.level - 1),
157
+ title: [
158
+ t(vm.kind === "merger" ? "integration.canvas.node.handle.virtualMerger" : "integration.canvas.node.handle.virtualConverging"),
159
+ ...vm.branches.map(branchLineText)
160
+ ].join("\n")
161
+ }));
162
+ });
163
+ const CARD_WIDTH = 220;
164
+ const LABEL_GAP = 8;
165
+ const DOT_ROOM = 14;
166
+ const BOX_ROOM = 28;
167
+ const labelEls = /* @__PURE__ */ new Map();
168
+ const labelWidths = ref({});
169
+ const labelObserver = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(measureLabels);
170
+ onBeforeUnmount(() => labelObserver?.disconnect());
171
+ function measureLabels() {
172
+ const next = { ...labelWidths.value };
173
+ let changed = false;
174
+ for (const [name, el] of labelEls) {
175
+ if (next[name] !== el.offsetWidth) {
176
+ next[name] = el.offsetWidth;
177
+ changed = true;
178
+ }
179
+ }
180
+ if (changed) labelWidths.value = next;
181
+ }
182
+ function setLabelEl(el, name) {
183
+ const node = el instanceof HTMLElement ? el : null;
184
+ const known = labelEls.get(name);
185
+ if (known === node) return;
186
+ if (known) labelObserver?.unobserve(known);
187
+ if (node) {
188
+ labelEls.set(name, node);
189
+ labelObserver?.observe(node);
190
+ } else {
191
+ labelEls.delete(name);
192
+ }
193
+ }
194
+ const portLayout = computed(() => {
195
+ if (!showPortLabels.value || resolvePending.value) return null;
196
+ const widths = outputs.value.map((p) => {
197
+ const w = labelWidths.value[p.name];
198
+ return w === void 0 || !splitPorts.value.has(p.name) ? w : Math.max(w, BOX_ROOM);
199
+ });
200
+ if (widths.includes(void 0)) return null;
201
+ return spacedByLabel(widths, CARD_WIDTH, LABEL_GAP, DOT_ROOM);
202
+ });
203
+ function outputStyle(side, i) {
204
+ const centre = portLayout.value?.centres[i];
205
+ if (centre === void 0 || side !== "bottom" && side !== "top") return distributedStyle(side, i, outputs.value.length);
206
+ return { left: `${centre}px` };
207
+ }
208
+ watch(() => JSON.stringify(portLayout.value), () => nextTick(() => flow.updateNodeInternals([props.id])));
119
209
  const flow = useVueFlow();
120
210
  const hovered = ref(false);
121
211
  let hoverTimer = null;
@@ -240,12 +330,37 @@ function remove() {
240
330
  :key="`in-${side}`"
241
331
  type="target"
242
332
  :position="SIDE_POSITION[side]"
243
- :title="t('integration.canvas.node.handle.input')"
244
- class="!size-3 !rounded-full !border-2 !border-white !bg-slate-500 !shadow-sm transition-all hover:!size-3.5 hover:!bg-slate-700"
333
+ :title="inputTitle"
334
+ class="!border-2 !border-white !shadow-sm transition-all"
335
+ :class="[
336
+ isMerger ? BOX_CLASS : `${DOT_CLASS} hover:!size-3.5`,
337
+ isRefused ? '!bg-rose-600 hover:!bg-rose-700' : '!bg-slate-500 hover:!bg-slate-700'
338
+ ]"
339
+ :data-refused="isRefused ? 'true' : void 0"
340
+ />
341
+
342
+ <!-- Virtual mergers (PO-750): drawn, never stored. Each is a dot of this card's
343
+ own — a connection of its level arrives here instead of at the input — so it
344
+ moves with the card, cannot be selected, dragged or deleted on its own, and
345
+ nothing can be started from it. Dropping a connection on one connects to
346
+ this node, exactly as the input dot would. -->
347
+ <Handle
348
+ v-for="vm in virtualMergers"
349
+ :id="vm.handleId"
350
+ :key="vm.handleId"
351
+ type="target"
352
+ :position="Position.Top"
353
+ :connectable-start="false"
354
+ :title="vm.title"
355
+ :style="{ top: `${vm.y}px`, left: `calc(50% + ${vm.x}px)` }"
356
+ class="!h-3 !w-6 !rounded-[3px] !border-2 !border-dashed !border-slate-500 !bg-background dark:!border-slate-400"
245
357
  />
246
358
 
247
359
  <!-- Output handles: right + bottom, distributed per side. Color encodes the
248
- port semantics (true/false/error/branch/default), matching the edges. -->
360
+ port semantics (true/false/error/branch/default), matching the edges.
361
+ Only the hover size is animated, never the place: a dot gliding to where
362
+ its name moved it (PO-790) is measured by the canvas on its way there, and
363
+ its connection is left behind at the old spot. -->
249
364
  <template v-for="side in OUT_SIDES" :key="`side-${side}`">
250
365
  <Handle
251
366
  v-for="(p, i) in outputs"
@@ -253,17 +368,18 @@ function remove() {
253
368
  :key="`${p.name}-${side}`"
254
369
  type="source"
255
370
  :position="SIDE_POSITION[side]"
256
- :title="handleTitle(p)"
257
- :style="distributedStyle(side, i, outputs.length)"
258
- class="!size-3 !rounded-full !border-2 !border-white !shadow-sm transition-all hover:!size-3.5"
259
- :class="portTone(p).handle"
371
+ :title="outputTitle(p)"
372
+ :style="outputStyle(side, i)"
373
+ class="!border-2 !border-white !shadow-sm transition-[width,height]"
374
+ :class="[portTone(p).handle, splitPorts.has(p.name) ? BOX_CLASS : `${DOT_CLASS} hover:!size-3.5`]"
260
375
  />
261
376
  </template>
262
377
 
263
378
  <!-- The card itself — deliberately minimal: icon, name, what it's set to.
264
379
  Everything else lives in the config lightbox. -->
265
380
  <div
266
- class="relative w-[220px] rounded-lg border bg-white transition-all duration-150"
381
+ class="relative rounded-lg border bg-white transition-[box-shadow,border-color] duration-150"
382
+ :style="{ width: `${portLayout?.width ?? CARD_WIDTH}px` }"
267
383
  :class="[
268
384
  selected ? 'border-emerald-500/50 shadow-[0_0_0_2px_rgba(16,185,129,0.18)]' : invalidFields.length ? 'border-amber-500/70 shadow-[0_0_0_2px_rgba(245,158,11,0.22)] hover:shadow-md' : inSelectedLoop ? 'border-slate-400/80 shadow-[0_0_0_2px_rgba(100,116,139,0.20)]' : 'border-black/[0.08] shadow-sm hover:shadow-md hover:border-black/[0.16]',
269
385
  showPortLabels ? 'pb-3.5' : ''
@@ -271,6 +387,27 @@ function remove() {
271
387
  @mouseenter="onHoverEnter"
272
388
  @mouseleave="onHoverLeave"
273
389
  >
390
+ <!-- The lines a virtual merger stands on, down to what it feeds. Inside the
391
+ card rather than beside it, so the card stays the wrapper's one child that
392
+ is not a port; it claims no layout and takes no pointer. -->
393
+ <svg
394
+ v-if="virtualMergers.length"
395
+ class="pointer-events-none absolute left-1/2 top-0 overflow-visible text-slate-400 dark:text-slate-500"
396
+ width="1"
397
+ height="1"
398
+ aria-hidden="true"
399
+ >
400
+ <line
401
+ v-for="vm in virtualMergers"
402
+ :key="`vm-line-${vm.handleId}`"
403
+ :x1="vm.x"
404
+ :y1="vm.y"
405
+ :x2="vm.toX"
406
+ :y2="vm.toY"
407
+ stroke="currentColor"
408
+ stroke-width="2"
409
+ />
410
+ </svg>
274
411
  <!-- The card's marks, in one row at the corner the input handles leave free.
275
412
  A row rather than two absolutely-placed badges: the amber one's width
276
413
  follows its count, so any fixed offset for a second badge overlaps it as
@@ -278,7 +415,7 @@ function remove() {
278
415
  a bare count at a corner is a riddle, so each carries its own title;
279
416
  `nodrag` keeps that hover from starting a card drag. -->
280
417
  <span
281
- v-if="invalidFields.length || unbuildableVersion || insideLoops.length || bodySize !== void 0"
418
+ v-if="invalidFields.length || unbuildableVersion || insideLoops.length || bodySize !== void 0 || data.__skipped"
282
419
  class="nodrag absolute -left-2 -top-2 z-10 flex items-center gap-1"
283
420
  >
284
421
  <span
@@ -324,6 +461,17 @@ function remove() {
324
461
  <Repeat :size="9" weight="Bold" />
325
462
  <template v-if="insideLoops.length > 1">{{ insideLoops.length }}</template>
326
463
  </span>
464
+ <!-- A step the run skipped (PO-750), with the reason as its name and title.
465
+ Slate and outlined: it is a statement about the run, not a defect. -->
466
+ <span
467
+ v-if="data.__skipped"
468
+ role="img"
469
+ :aria-label="data.__skipped"
470
+ :title="data.__skipped"
471
+ class="flex cursor-help items-center rounded-full border border-slate-400 bg-white px-1.5 py-0.5 text-[9px] font-bold leading-none text-slate-600 shadow-sm"
472
+ >
473
+ {{ data.__skipped }}
474
+ </span>
327
475
  <!-- The loop itself, and how much of the canvas it repeats. Outlined
328
476
  rather than filled: it names the same fact from the other end, and two
329
477
  solid slate pills side by side on a nested iterator would read as one. -->
@@ -400,9 +548,10 @@ function remove() {
400
548
  <span
401
549
  v-for="(p, i) in outputs"
402
550
  :key="`plabel-${p.name}`"
551
+ :ref="(el) => setLabelEl(el, p.name)"
403
552
  class="pointer-events-none absolute bottom-1 select-none whitespace-nowrap text-[9px] font-semibold uppercase tracking-wide"
404
553
  :class="portTone(p).label"
405
- :style="{ left: `${(i + 1) / (outputs.length + 1) * 100}%`, transform: 'translateX(-50%)' }"
554
+ :style="{ ...outputStyle('bottom', i), transform: 'translateX(-50%)' }"
406
555
  >
407
556
  {{ p.label }}
408
557
  </span>
@@ -1,3 +1,4 @@
1
+ import type { NodeShapeData } from '../../data/canvasShapes.js';
1
2
  import type { NodeManifestOutput, WorkflowNode } from '../../types/integrations.js';
2
3
  interface NodeData extends WorkflowNode {
3
4
  __selected?: boolean;
@@ -22,6 +23,19 @@ interface NodeData extends WorkflowNode {
22
23
  /** Set on an iterator itself: how many nodes hang off its `item` port. */
23
24
  bodySize?: number;
24
25
  };
26
+ /**
27
+ * Run replay only (PO-750): the node's step was skipped, and this says why —
28
+ * "Skipped: dead end in branch 2", or a plain "Skipped" for a reason the studio
29
+ * does not name. Injected like the markers above; no other canvas passes it.
30
+ */
31
+ __skipped?: string;
32
+ /**
33
+ * Where the workflow splits and merges at this node (PO-750), computed per sync
34
+ * from the blob's edges (`data/canvasShapes.ts`) and injected into all three
35
+ * canvases like `__loop`: a split port and a merging input are drawn as boxes,
36
+ * and the levels of a nested merge as small boxes in front of the input.
37
+ */
38
+ __shape?: NodeShapeData;
25
39
  }
26
40
  type __VLS_Props = {
27
41
  id: string;
@@ -26,9 +26,11 @@ import { findManifest } from "../../data/integrationNodeCatalog";
26
26
  import { OPPOSITE_SIDE } from "./canvasSides";
27
27
  import { inputHandleId, outputHandleId } from "../../data/handleId";
28
28
  import { nodeLoopData } from "../../data/loopBody";
29
+ import { canvasShapes } from "../../data/canvasShapes";
29
30
  import { formatDuration } from "../../utils/formatDuration";
30
31
  import { SAFETY_POLL_MS } from "../../utils/pollBudget";
31
32
  import { runStatusClass, runStatusLabel } from "../../utils/statusStyle";
33
+ import { executedNodeIds, isSkippedStep, skipReasonMessage, stepStatusLabel } from "../../utils/skippedStep";
32
34
  import { errorReason } from "../../utils/apiError";
33
35
  const props = defineProps({
34
36
  workflow: { type: Object, required: true },
@@ -139,6 +141,10 @@ preview.onPaneReady(() => {
139
141
  void preview.fitView({ padding: 0.2, maxZoom: 1 });
140
142
  });
141
143
  provide("workflow-node-actions", null);
144
+ function memberSize(id) {
145
+ const d = preview.findNode(id)?.dimensions;
146
+ return d && d.width > 0 ? d : { width: 220, height: 64 };
147
+ }
142
148
  const dynamicOutputs = ref({});
143
149
  const dynamicOutputNodes = computed(() => props.workflow.blob.nodes.filter((n) => {
144
150
  const m = findManifest(n.nodeSlug, n.nodeVersion);
@@ -161,14 +167,14 @@ const finalStatus = computed(() => {
161
167
  for (const s of steps.value) m.set(s.node_id, s.status);
162
168
  return m;
163
169
  });
164
- const runNodeIds = computed(() => new Set(steps.value.map((s) => s.node_id)));
170
+ const runNodeIds = computed(() => executedNodeIds(steps.value));
165
171
  const triggerIds = computed(() => new Set(props.workflow.blob.triggers.map((t2) => t2.id)));
166
172
  function edgeColorByKind(kind) {
167
173
  return kind === "error" ? "#cbd5e1" : "#94a3b8";
168
174
  }
169
175
  const GROUP_PAD = 24;
170
176
  const GROUP_LABEL_ROOM = 26;
171
- function previewGroupNodes(blob, nodePos) {
177
+ function previewGroupNodes(blob, nodePos, nodeSize) {
172
178
  return (blob.groups ?? []).map((g, i) => {
173
179
  let minX = Number.POSITIVE_INFINITY;
174
180
  let minY = Number.POSITIVE_INFINITY;
@@ -176,10 +182,11 @@ function previewGroupNodes(blob, nodePos) {
176
182
  let maxY = Number.NEGATIVE_INFINITY;
177
183
  for (const id of g.nodeIds) {
178
184
  const pos = nodePos(id);
185
+ const size = nodeSize(id);
179
186
  minX = Math.min(minX, pos.x);
180
187
  minY = Math.min(minY, pos.y);
181
- maxX = Math.max(maxX, pos.x + 220);
182
- maxY = Math.max(maxY, pos.y + 64);
188
+ maxX = Math.max(maxX, pos.x + size.width);
189
+ maxY = Math.max(maxY, pos.y + size.height);
183
190
  }
184
191
  if (!Number.isFinite(minX)) return null;
185
192
  return {
@@ -203,17 +210,28 @@ function previewGroupNodes(blob, nodePos) {
203
210
  function stickyArea(s) {
204
211
  return (s.size?.width ?? 240) * (s.size?.height ?? 160);
205
212
  }
213
+ function skippedLabel(nodeId) {
214
+ const step = steps.value.findLast((s) => s.node_id === nodeId);
215
+ if (!step || !isSkippedStep(step)) return void 0;
216
+ return stepSkipText(step);
217
+ }
218
+ function stepSkipText(step) {
219
+ const m = skipReasonMessage(step);
220
+ return m ? t(m.key, m.params) : t("integration.runs.skip.plain");
221
+ }
206
222
  function replayNodeClass(id, type) {
207
223
  if (type === "sticky") return "replay-dim";
208
224
  if (type === "trigger") return steps.value.length ? "replay-node-ok" : "";
209
225
  const final = finalStatus.value.get(id);
210
226
  if (!final) return "replay-dim";
211
227
  const reached = reachedStatus.value.has(id);
228
+ if (isSkippedStep({ status: final })) return `replay-node-skipped${activeStep.value?.node_id === id ? " replay-node-active" : ""}`;
212
229
  const tone = final === "failed" ? "replay-node-failed" : final === "running" ? "replay-node-running" : "replay-node-ok";
213
230
  const active = activeStep.value?.node_id === id ? " replay-node-active" : "";
214
231
  return `${tone}${reached ? "" : " replay-unreached"}${active}`;
215
232
  }
216
233
  const loops = computed(() => nodeLoopData(props.workflow.blob.nodes, props.workflow.blob.edges));
234
+ const shapes = computed(() => canvasShapes(props.workflow.blob.nodes, props.workflow.blob.edges, props.workflow.blob.triggers));
217
235
  const vfNodes = computed(() => {
218
236
  const blob = props.workflow.blob;
219
237
  const triggerPos = (id, i) => blob.triggerPositions?.[id] ?? { x: -40, y: 60 + i * 140 };
@@ -226,7 +244,7 @@ const vfNodes = computed(() => {
226
244
  };
227
245
  const stickies = [...blob.stickies ?? []].sort((a, b) => stickyArea(b) - stickyArea(a));
228
246
  return [
229
- ...previewGroupNodes(blob, posOf),
247
+ ...previewGroupNodes(blob, posOf, memberSize),
230
248
  ...stickies.map((s, i) => ({
231
249
  id: s.id,
232
250
  type: "sticky",
@@ -244,7 +262,7 @@ const vfNodes = computed(() => {
244
262
  position: triggerPos(t2.id, i),
245
263
  draggable: false,
246
264
  class: replayNodeClass(t2.id, "trigger"),
247
- data: t2
265
+ data: { ...t2, __split: shapes.value.splitTriggers.has(t2.id) }
248
266
  })),
249
267
  ...blob.nodes.map((n) => ({
250
268
  id: n.id,
@@ -252,7 +270,7 @@ const vfNodes = computed(() => {
252
270
  position: n.position ?? { x: 240, y: 80 },
253
271
  draggable: false,
254
272
  class: replayNodeClass(n.id, "workflow"),
255
- data: { ...n, __resolvedOutputs: dynamicOutputs.value[n.id], __loop: loops.value.get(n.id) }
273
+ data: { ...n, __resolvedOutputs: dynamicOutputs.value[n.id], __loop: loops.value.get(n.id), __skipped: skippedLabel(n.id), __shape: shapes.value.byNode.get(n.id) }
256
274
  }))
257
275
  ];
258
276
  });
@@ -300,7 +318,7 @@ const vfEdges = computed(() => {
300
318
  source: e.from.nodeId,
301
319
  target: e.to.nodeId,
302
320
  sourceHandle: outputHandleId(e.from.output, srcSide),
303
- targetHandle: inputHandleId(OPPOSITE_SIDE[srcSide]),
321
+ targetHandle: shapes.value.arrivalHandle(e) ?? inputHandleId(OPPOSITE_SIDE[srcSide]),
304
322
  type: "flow",
305
323
  selectable: false,
306
324
  animated: traversed && !targetReached,
@@ -365,7 +383,7 @@ function pretty(v) {
365
383
  const activeStepTitle = computed(() => {
366
384
  const s = activeStep.value;
367
385
  if (!s) return "";
368
- return `${s.node_slug || s.node_id}${s.attempt > 0 ? ` (#${s.attempt + 1})` : ""}`;
386
+ return `${s.node_slug || s.node_id}${(s.attempt ?? 0) > 0 ? ` (#${(s.attempt ?? 0) + 1})` : ""}`;
369
387
  });
370
388
  onMounted(() => {
371
389
  if (props.initialRunId) selectRun(props.initialRunId);
@@ -478,7 +496,7 @@ watch(() => props.initialRunId, (id) => {
478
496
  </div>
479
497
  </div>
480
498
  <Badge v-if="activeStep" variant="outline" class="shrink-0 border text-xs font-medium uppercase" :class="runStatusClass(activeStep.status)">
481
- {{ runStatusLabel(activeStep.status) }}
499
+ {{ stepStatusLabel(activeStep.status) }}
482
500
  </Badge>
483
501
  </div>
484
502
 
@@ -486,7 +504,7 @@ watch(() => props.initialRunId, (id) => {
486
504
  <div class="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
487
505
  <div>
488
506
  <div class="text-mono text-muted-foreground">{{ t("integration.replay.debug.field.attempt") }}</div>
489
- <div class="tabular-nums">{{ activeStep.attempt + 1 }}</div>
507
+ <div class="tabular-nums">{{ activeStep.attempt == null ? "\u2014" : activeStep.attempt + 1 }}</div>
490
508
  </div>
491
509
  <div>
492
510
  <div class="text-mono text-muted-foreground">{{ t("integration.replay.debug.field.duration") }}</div>
@@ -502,6 +520,10 @@ watch(() => props.initialRunId, (id) => {
502
520
  </div>
503
521
  </div>
504
522
 
523
+ <div v-if="isSkippedStep(activeStep) && skipReasonMessage(activeStep)" class="text-sm" data-testid="replay-skip-reason">
524
+ {{ stepSkipText(activeStep) }}
525
+ </div>
526
+
505
527
  <div v-if="activeStep.branch" class="text-sm">
506
528
  <div class="text-mono text-muted-foreground mb-1">{{ t("integration.replay.debug.field.branch") }}</div>
507
529
  <code class="rounded bg-black/[0.05] px-1.5 py-0.5 font-mono">{{ activeStep.branch }}</code>
@@ -536,5 +558,5 @@ watch(() => props.initialRunId, (id) => {
536
558
  </template>
537
559
 
538
560
  <style>
539
- .vue-flow__node.replay-dim{opacity:.35}.vue-flow__node.replay-unreached{opacity:.45}.vue-flow__node.replay-node-ok{filter:drop-shadow(0 0 5px rgba(16,185,129,.45))}.vue-flow__node.replay-node-failed{filter:drop-shadow(0 0 5px rgba(244,63,94,.5))}.vue-flow__node.replay-node-running{filter:drop-shadow(0 0 5px rgba(14,165,233,.5))}.vue-flow__node.replay-node-active{filter:drop-shadow(0 0 10px rgba(59,130,246,.65))}
561
+ .vue-flow__node.replay-dim{opacity:.35}.vue-flow__node.replay-unreached{opacity:.45}.vue-flow__node.replay-node-skipped{border-radius:12px;filter:grayscale(1);opacity:.55;outline:2px dashed #94a3b8;outline-offset:3px}.vue-flow__node.replay-node-ok{filter:drop-shadow(0 0 5px rgba(16,185,129,.45))}.vue-flow__node.replay-node-failed{filter:drop-shadow(0 0 5px rgba(244,63,94,.5))}.vue-flow__node.replay-node-running{filter:drop-shadow(0 0 5px rgba(14,165,233,.5))}.vue-flow__node.replay-node-active{filter:drop-shadow(0 0 10px rgba(59,130,246,.65))}
540
562
  </style>
@@ -1,7 +1,14 @@
1
1
  import type { WorkflowTrigger } from '../../types/integrations.js';
2
2
  type __VLS_Props = {
3
3
  id: string;
4
- data: WorkflowTrigger;
4
+ /**
5
+ * `__split`: the trigger's one output has more than one connection leaving it,
6
+ * so it is a splitter and drawn as a box (PO-750). Injected per sync by every
7
+ * canvas, never stored.
8
+ */
9
+ data: WorkflowTrigger & {
10
+ __split?: boolean;
11
+ };
5
12
  selected?: boolean;
6
13
  };
7
14
  declare const _default: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, any, string, import("vue").PublicProps, any, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
@@ -105,6 +105,8 @@ const OUT_SIDES = ["bottom"];
105
105
  :key="side"
106
106
  type="source"
107
107
  :position="SIDE_POSITION[side]"
108
- class="!size-3 !rounded-full !border-2 !border-white !bg-emerald-500 !shadow-sm transition-all hover:!size-3.5 hover:!bg-emerald-600"
108
+ :title="data.__split ? t('integration.canvas.node.handle.splitter') : void 0"
109
+ class="!border-2 !border-white !bg-emerald-500 !shadow-sm transition-all hover:!bg-emerald-600"
110
+ :class="data.__split ? '!h-3 !w-6 !rounded-[3px] hover:!w-7' : '!size-3 !rounded-full hover:!size-3.5'"
109
111
  />
110
112
  </template>
@@ -1,7 +1,14 @@
1
1
  import type { WorkflowTrigger } from '../../types/integrations.js';
2
2
  type __VLS_Props = {
3
3
  id: string;
4
- data: WorkflowTrigger;
4
+ /**
5
+ * `__split`: the trigger's one output has more than one connection leaving it,
6
+ * so it is a splitter and drawn as a box (PO-750). Injected per sync by every
7
+ * canvas, never stored.
8
+ */
9
+ data: WorkflowTrigger & {
10
+ __split?: boolean;
11
+ };
5
12
  selected?: boolean;
6
13
  };
7
14
  declare const _default: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, any, string, import("vue").PublicProps, any, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
@@ -46,3 +46,30 @@ export declare const OPPOSITE_SIDE: Record<Side, Side>;
46
46
  * output lands in the middle.
47
47
  */
48
48
  export declare function distributedStyle(side: Side, i: number, total: number): Record<string, string>;
49
+ /**
50
+ * Where the output dots go when their names would not fit evenly spaced (PO-790), or
51
+ * `null` when they do — and then {@link distributedStyle} places them as it always has.
52
+ *
53
+ * Each name is centred over its dot, so evenly spaced dots give every name the same
54
+ * room whatever its length: three outputs on a 220px card stand 55px apart, and
55
+ * "NICHT GEFUNDEN" alone is 81px. Spaced instead with equal gaps *between the names*,
56
+ * the same three fit with room to spare, and each dot moves under the centre of its
57
+ * name. Only where even that does not fit does the card grow, to exactly the width
58
+ * the names need.
59
+ *
60
+ * Returning `null` for the case that fits is deliberate: a card that reads well today
61
+ * keeps its dots exactly where they were, and so do the connections leaving them.
62
+ *
63
+ * A name is never narrower than its dot, though: an empty switch case, or a one-letter
64
+ * one, still has a 12px dot under it, and spacing names that take no room would put
65
+ * two dots on top of each other — one of them no longer reachable.
66
+ *
67
+ * @param measured the rendered width of each name, in port order
68
+ * @param width the card's width
69
+ * @param gap the least space between two names, and between a name and the edge
70
+ * @param least the least room a name takes: its dot's, at its hover size
71
+ */
72
+ export declare function spacedByLabel(measured: readonly number[], width: number, gap: number, least: number): {
73
+ width: number;
74
+ centres: number[];
75
+ } | null;
@@ -20,3 +20,25 @@ const SIDE_SPREAD_AXIS = {
20
20
  export function distributedStyle(side, i, total) {
21
21
  return { [SIDE_SPREAD_AXIS[side]]: `${(i + 1) / (total + 1) * 100}%` };
22
22
  }
23
+ export function spacedByLabel(measured, width, gap, least) {
24
+ const n = measured.length;
25
+ if (n === 0) return null;
26
+ const widths = measured.map((w) => Math.max(w, least));
27
+ const even = widths.map((_, i) => width * (i + 1) / (n + 1));
28
+ const fitsEvenly = widths.every(
29
+ (w, i) => i === 0 ? even[0] - w / 2 >= gap : even[i] - even[i - 1] >= (widths[i - 1] + w) / 2 + gap
30
+ ) && width - even[n - 1] - widths[n - 1] / 2 >= gap;
31
+ if (fitsEvenly) return null;
32
+ const total = widths.reduce((a, b) => a + b, 0);
33
+ const needed = total + gap * (n + 1);
34
+ const cardWidth = Math.max(width, needed);
35
+ const space = (cardWidth - total) / (n + 1);
36
+ const centres = [];
37
+ let x = 0;
38
+ for (const w of widths) {
39
+ x += space;
40
+ centres.push(x + w / 2);
41
+ x += w;
42
+ }
43
+ return { width: cardWidth, centres };
44
+ }
@@ -40,7 +40,10 @@ export declare function useIntegrationsCredentials(): {
40
40
  loaded: any;
41
41
  total: import("vue").ComputedRef<any>;
42
42
  needsReauth: import("vue").ComputedRef<any>;
43
- load: (force?: boolean) => Promise<Credential[]>;
43
+ load: (force?: boolean, { quiet }?: {
44
+ quiet?: boolean | undefined;
45
+ }) => Promise<Credential[]>;
46
+ refresh: () => Promise<Credential[]>;
44
47
  ofType: (slug: string) => Credential[];
45
48
  get: (id: string) => Promise<Credential>;
46
49
  create: (input: {
@@ -12,19 +12,23 @@ export function useIntegrationsCredentials() {
12
12
  const gate = useLoadGate("integrations.credentials", { loading, loaded });
13
13
  const api = useIntegrationsApi();
14
14
  const t = useIntegrationsT();
15
- async function load(force = false) {
15
+ async function load(force = false, { quiet = false } = {}) {
16
16
  if (!await gate.claim(force)) return credentials.value;
17
- error.value = null;
17
+ if (!quiet) error.value = null;
18
18
  try {
19
19
  credentials.value = unwrapList(await api.get("/credentials"));
20
20
  loaded.value = true;
21
+ error.value = null;
21
22
  } catch (e) {
22
- error.value = errorReason(e, t("integration.credentials.error.loadFailed"));
23
+ if (!quiet) error.value = errorReason(e, t("integration.credentials.error.loadFailed"));
23
24
  } finally {
24
25
  gate.release();
25
26
  }
26
27
  return credentials.value;
27
28
  }
29
+ function refresh() {
30
+ return load(true, { quiet: loaded.value });
31
+ }
28
32
  function ofType(slug) {
29
33
  return credentials.value.filter((c) => c.credential_type_slug === slug);
30
34
  }
@@ -80,6 +84,7 @@ export function useIntegrationsCredentials() {
80
84
  total,
81
85
  needsReauth,
82
86
  load,
87
+ refresh,
83
88
  ofType,
84
89
  get,
85
90
  create,