@revenexx/studio-integrations 3.7.1 → 3.8.1

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 (41) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/module.json +1 -1
  3. package/dist/runtime/components/Integrations/CredentialCreateDialog.vue +3 -1
  4. package/dist/runtime/components/Integrations/DeadLettersPanel.vue +2 -1
  5. package/dist/runtime/components/Integrations/GroupFrameView.vue +17 -6
  6. package/dist/runtime/components/Integrations/NodeConfigDynamicField.vue +37 -17
  7. package/dist/runtime/components/Integrations/NodeConfigDynamicSchema.d.vue.ts +2 -0
  8. package/dist/runtime/components/Integrations/NodeConfigDynamicSchema.vue +5 -2
  9. package/dist/runtime/components/Integrations/NodeConfigDynamicSchema.vue.d.ts +2 -0
  10. package/dist/runtime/components/Integrations/NodeConfigField.vue +1 -0
  11. package/dist/runtime/components/Integrations/RunResponseModal.d.vue.ts +6 -0
  12. package/dist/runtime/components/Integrations/RunResponseModal.vue +36 -5
  13. package/dist/runtime/components/Integrations/RunResponseModal.vue.d.ts +6 -0
  14. package/dist/runtime/components/Integrations/Sidebar.vue +22 -5
  15. package/dist/runtime/components/Integrations/StickyNoteView.vue +22 -9
  16. package/dist/runtime/components/Integrations/TemplateFlowPreview.vue +3 -3
  17. package/dist/runtime/components/Integrations/WorkflowEdgeView.vue +3 -2
  18. package/dist/runtime/components/Integrations/WorkflowEditor.vue +32 -27
  19. package/dist/runtime/components/Integrations/WorkflowTriggerView.vue +1 -1
  20. package/dist/runtime/components/Integrations/WorkflowsTable.vue +9 -14
  21. package/dist/runtime/composables/useCanvasTarget.d.ts +15 -0
  22. package/dist/runtime/composables/useCanvasTarget.js +10 -0
  23. package/dist/runtime/composables/useIntegrationsSummary.d.ts +29 -0
  24. package/dist/runtime/composables/useIntegrationsSummary.js +37 -0
  25. package/dist/runtime/composables/useNodeConfigResolve.d.ts +25 -2
  26. package/dist/runtime/composables/useNodeConfigResolve.js +11 -2
  27. package/dist/runtime/composables/useOAuthConnect.d.ts +5 -0
  28. package/dist/runtime/composables/useOAuthConnect.js +3 -1
  29. package/dist/runtime/i18n/messages/de.json +4 -3
  30. package/dist/runtime/i18n/messages/en.json +4 -3
  31. package/dist/runtime/pages/integrations/runs.vue +2 -1
  32. package/dist/runtime/pages/integrations/workflows/[id].vue +39 -2
  33. package/dist/runtime/utils/canvasFit.d.ts +59 -0
  34. package/dist/runtime/utils/canvasFit.js +78 -0
  35. package/dist/runtime/utils/executionMode.d.ts +18 -0
  36. package/dist/runtime/utils/executionMode.js +17 -0
  37. package/dist/runtime/utils/optionSearch.d.ts +24 -0
  38. package/dist/runtime/utils/optionSearch.js +31 -0
  39. package/dist/runtime/utils/runFailure.d.ts +37 -0
  40. package/dist/runtime/utils/runFailure.js +16 -0
  41. package/package.json +1 -1
@@ -6,6 +6,7 @@ import {
6
6
  MarkerType,
7
7
  SelectionMode,
8
8
  VueFlow,
9
+ getRectOfNodes,
9
10
  useVueFlow
10
11
  } from "@vue-flow/core";
11
12
  import { Background } from "@vue-flow/background";
@@ -58,6 +59,7 @@ import { copiedRefusalText, joinRefusalText, refusalText } from "../../data/refu
58
59
  import { inputHandleId, outputHandleId, portFromHandleId } from "../../data/handleId";
59
60
  import { OPPOSITE_SIDE } from "./canvasSides";
60
61
  import { deepClone } from "../../utils/deepClone";
62
+ import { fitArea, paddingFor } from "../../utils/canvasFit";
61
63
  import { copyToClipboard } from "../../utils/clipboard";
62
64
  import { groupFindingsByNode, findingsByNodeField, nodeDisplayLabel } from "../../utils/validationFindings";
63
65
  import { reorderBranchSubtrees } from "../../data/branchReorder";
@@ -403,32 +405,10 @@ function edgeColorByKind(kind) {
403
405
  syncFromBlob();
404
406
  const paneReady = ref(false);
405
407
  const pendingReveal = ref(null);
406
- function liftAboveControlPane() {
407
- const bar = controlBar.value?.getBoundingClientRect();
408
- const wrap = canvasWrapRef.value?.getBoundingClientRect();
409
- const vp = flow.viewport.value;
410
- if (!bar || !wrap || !vp) return;
411
- let top = Number.POSITIVE_INFINITY;
412
- let bottom = Number.NEGATIVE_INFINITY;
413
- for (const n of vfNodes.value) {
414
- const height = flow.findNode(n.id)?.dimensions?.height ?? 76;
415
- top = Math.min(top, n.position.y);
416
- bottom = Math.max(bottom, n.position.y + height);
417
- }
418
- if (!Number.isFinite(top) || !Number.isFinite(bottom)) return;
419
- const CLEARANCE = 8;
420
- const overlap = wrap.top + vp.y + bottom * vp.zoom - bar.top + CLEARANCE;
421
- if (overlap <= 0) return;
422
- const roomAtTop = wrap.top + vp.y + top * vp.zoom - wrap.top - CLEARANCE;
423
- const lift = Math.min(overlap, Math.max(roomAtTop, 0));
424
- if (lift <= 0) return;
425
- flow.setViewport({ ...vp, y: vp.y - lift });
426
- }
427
408
  flow.onPaneReady(async () => {
428
409
  await nextTick();
429
410
  void canvasWrapRef.value?.getBoundingClientRect();
430
- await flow.fitView({ padding: { x: 0.2, top: 0.2, bottom: "96px" }, maxZoom: 1 });
431
- liftAboveControlPane();
411
+ await fitClearOfPanels({ duration: 0 });
432
412
  paneReady.value = true;
433
413
  if (pendingReveal.value) {
434
414
  centerOnNode(pendingReveal.value);
@@ -864,7 +844,7 @@ function onMenuAction(id) {
864
844
  void tidyUp();
865
845
  break;
866
846
  case "fit-view":
867
- flow.fitView({ padding: 0.2, duration: 400, maxZoom: 1 });
847
+ void fitClearOfPanels();
868
848
  break;
869
849
  case "add-node":
870
850
  void focusPalette();
@@ -1385,7 +1365,7 @@ async function tidyUp() {
1385
1365
  syncFromBlob();
1386
1366
  push();
1387
1367
  await nextTick();
1388
- flow.fitView({ padding: 0.2, duration: 400, maxZoom: 1 });
1368
+ await fitClearOfPanels();
1389
1369
  }
1390
1370
  const POINTER_MODE_KEY = "integrations.editor.pointerMode";
1391
1371
  const pointerMode = ref("select");
@@ -1445,6 +1425,29 @@ const MINIMAP_CORNER_CLASS = {
1445
1425
  };
1446
1426
  const minimapPlacementClass = computed(() => minimapLayout.mode.value === "floating" ? "" : MINIMAP_CORNER_CLASS[minimapLayout.mode.value]);
1447
1427
  const minimapPreviewClass = computed(() => minimapLayout.dockPreview.value ? MINIMAP_CORNER_CLASS[minimapLayout.dockPreview.value] : "");
1428
+ async function fitClearOfPanels({ duration = 400 } = {}) {
1429
+ const wrap = canvasWrapRef.value?.getBoundingClientRect();
1430
+ const visible = flow.getNodes.value.filter((n) => !n.hidden && n.dimensions.width && n.dimensions.height);
1431
+ if (!wrap || wrap.width === 0 || !visible.length) {
1432
+ await flow.fitView({ padding: 0.2, duration, maxZoom: 1 });
1433
+ return;
1434
+ }
1435
+ const canvas = { left: wrap.left, top: wrap.top, right: wrap.right, bottom: wrap.bottom };
1436
+ const panels = [];
1437
+ for (const el of [
1438
+ palette.collapsed.value ? null : paletteShellRef.value,
1439
+ minimapLayout.collapsed.value ? null : minimapShellRef.value,
1440
+ controlBar.value
1441
+ ]) {
1442
+ const box = el?.getBoundingClientRect();
1443
+ if (box && box.width > 0 && box.height > 0) {
1444
+ panels.push({ left: box.left, top: box.top, right: box.right, bottom: box.bottom });
1445
+ }
1446
+ }
1447
+ const bounds = getRectOfNodes(visible);
1448
+ const area = fitArea(canvas, panels, { width: bounds.width, height: bounds.height }, flow.minZoom.value, 1);
1449
+ await flow.fitView({ padding: paddingFor(canvas, area), duration, maxZoom: 1 });
1450
+ }
1448
1451
  async function focusPalette() {
1449
1452
  if (palette.collapsed.value) {
1450
1453
  palette.setCollapsed(false);
@@ -1519,7 +1522,9 @@ useCanvasShortcuts(() => ({
1519
1522
  void flow.zoomIn({ duration: 150 });
1520
1523
  },
1521
1524
  [shortcutKeys("zoomReset")]: () => setZoomPct(100),
1522
- [shortcutKeys("fitView")]: () => flow.fitView({ padding: 0.2, duration: 400, maxZoom: 1 }),
1525
+ [shortcutKeys("fitView")]: () => {
1526
+ void fitClearOfPanels();
1527
+ },
1523
1528
  [shortcutKeys("addNode")]: () => {
1524
1529
  void focusPalette();
1525
1530
  },
@@ -1878,7 +1883,7 @@ const edgeTypes = {
1878
1883
  <button
1879
1884
  type="button"
1880
1885
  class="flex h-8 items-center gap-1.5 rounded-lg px-2.5 text-sm font-medium text-foreground transition hover:bg-black/[0.04]"
1881
- @click="flow.fitView({ padding: 0.2, duration: 400, maxZoom: 1 })"
1886
+ @click="fitClearOfPanels()"
1882
1887
  >
1883
1888
  <Sparkles :size="13" />
1884
1889
  {{ t("integration.editor.tool.fitView.label") }}
@@ -92,7 +92,7 @@ const OUT_SIDES = ["bottom"];
92
92
  </div>
93
93
  </div>
94
94
  <div class="flex items-center justify-between border-t border-emerald-500/15 bg-emerald-500/[0.04] rounded-b-md px-3 py-1 text-mono text-emerald-700/80 font-semibold">
95
- <span>{{ t("integration.canvas.badge.trigger") }} · {{ data.type }}</span>
95
+ <span>{{ t("integration.canvas.badge.trigger") }} · {{ triggerTypeLabel(data.type) }}</span>
96
96
  <span v-if="data.active === false" class="text-amber-600">{{ t("integration.canvas.trigger.badge.inactive") }}</span>
97
97
  </div>
98
98
  </div>
@@ -19,6 +19,8 @@ import {
19
19
  Switch
20
20
  } from "@revenexx/studio";
21
21
  import { formatShortDateTime } from "../../utils/formatDateTime";
22
+ import { executionModeLabel } from "../../utils/executionMode";
23
+ import { triggerTypeLabel } from "../../data/integrationNodeCatalog";
22
24
  defineProps({
23
25
  workflows: { type: Array, required: true },
24
26
  loading: { type: Boolean, required: false, default: false }
@@ -125,16 +127,7 @@ function buildState(wf) {
125
127
  }
126
128
  }
127
129
  function modeLabel(m) {
128
- switch (m) {
129
- case "sync_only":
130
- return t("integration.workflows.table.mode.sync");
131
- case "caller_decides":
132
- return t("integration.workflows.table.mode.caller");
133
- case "async_only":
134
- return t("integration.workflows.table.mode.async");
135
- default:
136
- return "\u2014";
137
- }
130
+ return executionModeLabel(m);
138
131
  }
139
132
  const LIFECYCLE = ["active", "draft", "paused"];
140
133
  const BUILD_STATES = ["pending", "building", "ready", "failed"];
@@ -187,16 +180,18 @@ const columns = computed(() => [
187
180
  {
188
181
  key: "mode",
189
182
  label: t("integration.workflows.table.column.mode"),
190
- width: "120px",
183
+ // Wide enough for the longest word it shows, set in capitals: German
184
+ // "Aufrufer entscheidet" is about 160px and ran into the Trigger column at 120.
185
+ width: "170px",
191
186
  sortable: true,
192
- sortGetter: (w) => w.execution_mode ?? ""
187
+ sortGetter: (w) => w.execution_mode ? modeLabel(w.execution_mode) : ""
193
188
  },
194
189
  {
195
190
  key: "triggers",
196
191
  label: t("integration.workflows.table.column.triggers"),
197
192
  width: "140px",
198
193
  sortable: true,
199
- sortGetter: (w) => w.blob.triggers.map((tr) => tr.type).join(",")
194
+ sortGetter: (w) => w.blob.triggers.map((tr) => triggerTypeLabel(tr.type)).join(",")
200
195
  },
201
196
  {
202
197
  key: "lastRun",
@@ -321,7 +316,7 @@ const columns = computed(() => [
321
316
  :key="trigger.id"
322
317
  class="inline-flex items-center whitespace-nowrap rounded-md border border-black/[0.06] bg-muted/50 px-1.5 py-0.5 text-xs font-medium uppercase text-muted-foreground"
323
318
  >
324
- {{ trigger.type }}
319
+ {{ triggerTypeLabel(trigger.type) }}
325
320
  </span>
326
321
  </div>
327
322
  </template>
@@ -0,0 +1,15 @@
1
+ import { type ComputedRef } from 'vue';
2
+ /**
3
+ * The smallest a pointer target may be on screen — WCAG 2.2 SC 2.5.8, promised in
4
+ * `specs/accessibility.md` AC-7.
5
+ */
6
+ export declare const MIN_TARGET_PX = 24;
7
+ /**
8
+ * The canvas-pixel size a control drawn **inside** the zoom transform needs so that it
9
+ * still measures `MIN_TARGET_PX` on screen, and never less than the `drawn` size it
10
+ * has at full zoom. Zoomed out to 50 %, a target has to be 48 canvas pixels to be hit
11
+ * as 24 — which is the whole problem with a control the canvas scales (PO-388).
12
+ */
13
+ export declare function targetSize(drawn: number, zoom: number): number;
14
+ /** `targetSize` against the live viewport of the canvas this component sits on. */
15
+ export declare function useCanvasTarget(drawn: number): ComputedRef<number>;
@@ -0,0 +1,10 @@
1
+ import { computed } from "vue";
2
+ import { useVueFlow } from "@vue-flow/core";
3
+ export const MIN_TARGET_PX = 24;
4
+ export function targetSize(drawn, zoom) {
5
+ return Math.max(drawn, MIN_TARGET_PX / (zoom || 1));
6
+ }
7
+ export function useCanvasTarget(drawn) {
8
+ const flow = useVueFlow();
9
+ return computed(() => targetSize(drawn, flow.viewport.value.zoom));
10
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * How much the tenant has, counted over the whole tenant (`GET /summary`) — the
3
+ * figures the navigation shows beside its entries (PO-617).
4
+ *
5
+ * Kept apart from the pages' own state on purpose. The badges used to be the
6
+ * length of whatever the last page had loaded: the editor puts the one workflow
7
+ * it opened into the workflows list, so a fresh visit to a workflow read "1"
8
+ * beside Workflows, and a page nobody had opened yet read nothing at all. These
9
+ * counts are asked for on their own and no page writes to them, so opening a
10
+ * page cannot change what a badge says.
11
+ *
12
+ * Each count means what its page counts: `runsInFlight` is the runs summary's
13
+ * `in_flight`, `schedulesActive` the schedules list's `live_count`.
14
+ *
15
+ * Exported through the module's auto-imports, so the cockpit's own sidebar can
16
+ * read the same figures (IN-85).
17
+ */
18
+ export interface IntegrationsSummary {
19
+ workflows: number;
20
+ runsInFlight: number;
21
+ schedulesActive: number;
22
+ credentials: number;
23
+ secrets: number;
24
+ }
25
+ export declare function toIntegrationsSummary(res: unknown): IntegrationsSummary | null;
26
+ export declare function useIntegrationsSummary(): {
27
+ summary: any;
28
+ load: () => Promise<IntegrationsSummary | null>;
29
+ };
@@ -0,0 +1,37 @@
1
+ import { useIntegrationsApi } from "./integrationsApi.js";
2
+ function count(value) {
3
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
4
+ }
5
+ export function toIntegrationsSummary(res) {
6
+ const data = res?.data;
7
+ if (!data || typeof data !== "object" || Array.isArray(data)) return null;
8
+ const d = data;
9
+ return {
10
+ workflows: count(d.workflows),
11
+ runsInFlight: count(d.runs_in_flight),
12
+ schedulesActive: count(d.schedules_active),
13
+ credentials: count(d.credentials),
14
+ secrets: count(d.secrets)
15
+ };
16
+ }
17
+ export function useIntegrationsSummary() {
18
+ const api = useIntegrationsApi();
19
+ const summary = useState("integrations.summary", () => null);
20
+ const turn = useState("integrations.summary.turn", () => 0);
21
+ const forTenant = useState("integrations.summary.tenant", () => "");
22
+ async function load() {
23
+ const mine = ++turn.value;
24
+ const tenant = api.tenant.value;
25
+ if (tenant !== forTenant.value) {
26
+ summary.value = null;
27
+ forTenant.value = tenant;
28
+ }
29
+ try {
30
+ const res = await api.get("/summary");
31
+ if (mine === turn.value) summary.value = toIntegrationsSummary(res);
32
+ } catch {
33
+ }
34
+ return summary.value;
35
+ }
36
+ return { summary, load };
37
+ }
@@ -8,8 +8,11 @@
8
8
  * body `{ target, config, locale? }`. `target` is a config field key
9
9
  * (→ `{ options }`), `'*'` (→ `{ fields }`) or `'outputs'` (→ `{ outputs }`).
10
10
  *
11
- * `dependsOn` given (options / dynamic-schema): those keys both gate the query
12
- * (must be set as literals) and form the cache-key subset sent as `config`.
11
+ * `dependsOn` given (options / dynamic-schema): those keys form the cache-key
12
+ * subset sent as `config`, and — except the ones named in `optional` — gate the
13
+ * query (must be set as literals). An optional setting the node reads when it is
14
+ * there, a credential above all, is sent along without holding the list back
15
+ * (IN-80).
13
16
  * `dependsOn` omitted (outputs): no gate, and the whole `config` keys the query
14
17
  * and is sent — so any config edit re-resolves.
15
18
  */
@@ -22,12 +25,25 @@ import type { NodeConfigResolveError, NodeConfigResolveResult } from '../types/i
22
25
  * dependency still holding an expression can't drive resolution yet.
23
26
  */
24
27
  export declare function isResolvableValue(v: unknown): boolean;
28
+ /**
29
+ * The settings of `dependsOn` that may stay empty: each one a sibling field the
30
+ * node declares and does not require. They are sent with the question but do
31
+ * not hold it back — a WSDL behind a login is read with the credential when one
32
+ * is chosen, and a public one is read without (IN-80). A key no sibling declares
33
+ * keeps gating, as before: nothing says it may be left out.
34
+ */
35
+ export declare function optionalDependencies(dependsOn: string[] | undefined, siblings: Array<{
36
+ key: string;
37
+ required?: boolean;
38
+ }> | undefined): string[];
25
39
  export declare function useNodeConfigResolve(opts: {
26
40
  slug: MaybeRefOrGetter<string>;
27
41
  version: MaybeRefOrGetter<string>;
28
42
  target: MaybeRefOrGetter<string>;
29
43
  config: MaybeRefOrGetter<Record<string, unknown>>;
30
44
  dependsOn?: MaybeRefOrGetter<string[] | undefined>;
45
+ /** Keys of `dependsOn` that are sent but do not gate: the settings that may stay empty. */
46
+ optional?: MaybeRefOrGetter<string[] | undefined>;
31
47
  enabled?: MaybeRefOrGetter<boolean>;
32
48
  /** Type-to-search term, forwarded to loadOptions (PE-334). Keys the query. */
33
49
  search?: MaybeRefOrGetter<string | undefined>;
@@ -35,6 +51,13 @@ export declare function useNodeConfigResolve(opts: {
35
51
  data: Ref<NodeConfigResolveResult | null>;
36
52
  error: Ref<NodeConfigResolveError | null>;
37
53
  isFetching: Ref<boolean>;
54
+ /**
55
+ * The answer on screen is not yet the answer to what was last asked: a request
56
+ * is out, or a change (a typed term above all) is still inside the debounce.
57
+ * `isFetching` alone is false for those 300 ms, so a list narrowed locally
58
+ * would say "nothing matches" before the platform had even been asked.
59
+ */
60
+ isPending: Ref<boolean>;
38
61
  hasData: Ref<boolean>;
39
62
  gated: Ref<boolean>;
40
63
  unsatisfied: Ref<string[]>;
@@ -41,6 +41,13 @@ function classifyError(err) {
41
41
  function isAbortError(err) {
42
42
  return err instanceof DOMException ? err.name === "AbortError" : err?.name === "AbortError";
43
43
  }
44
+ export function optionalDependencies(dependsOn, siblings) {
45
+ if (!dependsOn?.length || !siblings?.length) return [];
46
+ return dependsOn.filter((key) => {
47
+ const sibling = siblings.find((f) => f.key === key);
48
+ return sibling !== void 0 && sibling.required !== true;
49
+ });
50
+ }
44
51
  const DEBOUNCE_MS = 300;
45
52
  const STALE_MS = 3e4;
46
53
  export function useNodeConfigResolve(opts) {
@@ -51,7 +58,8 @@ export function useNodeConfigResolve(opts) {
51
58
  const hasData = ref(false);
52
59
  const unsatisfied = computed(() => {
53
60
  const cfg = toValue(opts.config);
54
- const gateKeys = toValue(opts.dependsOn) ?? [];
61
+ const optional = new Set(toValue(opts.optional) ?? []);
62
+ const gateKeys = (toValue(opts.dependsOn) ?? []).filter((k) => !optional.has(k));
55
63
  return gateKeys.filter((k) => !isResolvableValue(cfg[k]));
56
64
  });
57
65
  const gated = computed(() => unsatisfied.value.length > 0);
@@ -77,6 +85,7 @@ export function useNodeConfigResolve(opts) {
77
85
  debounceTimer = null;
78
86
  }, DEBOUNCE_MS);
79
87
  });
88
+ const isPending = computed(() => isFetching.value || isEnabled.value && subsetKey.value !== debouncedKey.value);
80
89
  let controller = null;
81
90
  let lastFetchedKey = null;
82
91
  let lastFetchedAt = 0;
@@ -147,5 +156,5 @@ export function useNodeConfigResolve(opts) {
147
156
  if (debounceTimer) clearTimeout(debounceTimer);
148
157
  if (controller) controller.abort();
149
158
  });
150
- return { data, error, isFetching, hasData, gated, unsatisfied, isEnabled, refetch };
159
+ return { data, error, isFetching, isPending, hasData, gated, unsatisfied, isEnabled, refetch };
151
160
  }
@@ -35,6 +35,11 @@ export type OAuthConnectOutcome = 'completed' | 'closed';
35
35
  * genuinely differ: the list refreshes the list, the detail dialog refreshes one
36
36
  * credential and tells its parent, and the create dialog says nothing at all because the
37
37
  * operator is still looking at the form. Only the waiting is shared.
38
+ *
39
+ * **`open` throws when the browser refuses the window** (a popup blocker, a hardened
40
+ * profile). The error's message is already the operator-facing sentence — what
41
+ * happened and what to do — so every caller's existing `catch` that toasts
42
+ * `errorReason(e, …)` shows it as it stands (PO-399).
38
43
  */
39
44
  export declare function useOAuthConnect(onSettled: (outcome: OAuthConnectOutcome) => void | Promise<void>): {
40
45
  open: (authorizeUrl: string) => void;
@@ -1,5 +1,7 @@
1
1
  import { onUnmounted } from "vue";
2
+ import { useIntegrationsT } from "./useIntegrationsT.js";
2
3
  export function useOAuthConnect(onSettled) {
4
+ const t = useIntegrationsT();
3
5
  let release = null;
4
6
  function settle(outcome) {
5
7
  if (!release) return;
@@ -10,7 +12,7 @@ export function useOAuthConnect(onSettled) {
10
12
  function open(authorizeUrl) {
11
13
  release?.();
12
14
  const popup = window.open(authorizeUrl, "oauth-connect", "width=600,height=720");
13
- if (!popup) return;
15
+ if (!popup) throw new Error(t("integration.credentials.error.popupBlocked"));
14
16
  const onMessage = (ev) => {
15
17
  if (ev.origin !== window.location.origin) return;
16
18
  if (ev.data?.type === "integrations-oauth-complete") settle("completed");
@@ -203,6 +203,7 @@
203
203
  "integration.credentials.error.loadTypes": "Zugangsdatentypen konnten nicht geladen werden.",
204
204
  "integration.credentials.error.noAuthorizeUrl": "Keine Autorisierungs-URL erhalten.",
205
205
  "integration.credentials.error.oauthConnect": "OAuth-Verbindung konnte nicht gestartet werden.",
206
+ "integration.credentials.error.popupBlocked": "Der Browser hat das Fenster für die Anmeldung beim Anbieter blockiert. Erlauben Sie Pop-ups für diese Seite und versuchen Sie es erneut.",
206
207
  "integration.credentials.error.save": "Zugangsdaten konnten nicht gespeichert werden.",
207
208
  "integration.credentials.error.testFailed": "Verbindungstest fehlgeschlagen.",
208
209
  "integration.credentials.list.action.connect": "Verbinden",
@@ -1031,6 +1032,9 @@
1031
1032
  "integration.runs.response.async.startedWithId": "Run #{id} wurde gestartet.",
1032
1033
  "integration.runs.response.description.async": "Im Hintergrund gestartet",
1033
1034
  "integration.runs.response.description.sync": "Synchrones Run-Ergebnis",
1035
+ "integration.runs.response.failure.run": "Run ist fehlgeschlagen.",
1036
+ "integration.runs.response.failure.runWithId": "Run #{id} ist fehlgeschlagen.",
1037
+ "integration.runs.response.failure.step": "Fehlgeschlagen bei Schritt „{step}“.",
1034
1038
  "integration.runs.response.following": "Warten, bis der Run fertig ist…",
1035
1039
  "integration.runs.response.loading.async": "Wird gestartet…",
1036
1040
  "integration.runs.response.loading.sync": "Wird ausgeführt…",
@@ -1415,9 +1419,6 @@
1415
1419
  "integration.workflows.table.menu.action.pause": "Pausieren",
1416
1420
  "integration.workflows.table.menu.actions.aria": "Aktionen für {name}",
1417
1421
  "integration.workflows.table.menu.actions.label": "Aktionen",
1418
- "integration.workflows.table.mode.async": "async",
1419
- "integration.workflows.table.mode.caller": "Aufrufer",
1420
- "integration.workflows.table.mode.sync": "sync",
1421
1422
  "integration.workflows.table.search.placeholder": "Workflows durchsuchen…",
1422
1423
  "integration.workflows.table.state.empty": "Noch keine Workflows.",
1423
1424
  "integration.workflows.table.status.build.building": "Wird erstellt…",
@@ -203,6 +203,7 @@
203
203
  "integration.credentials.error.loadTypes": "Could not load credential types.",
204
204
  "integration.credentials.error.noAuthorizeUrl": "No authorize URL returned.",
205
205
  "integration.credentials.error.oauthConnect": "Could not start OAuth connect.",
206
+ "integration.credentials.error.popupBlocked": "The browser blocked the window for signing in at the provider. Allow pop-ups for this site, then try again.",
206
207
  "integration.credentials.error.save": "Could not save credential.",
207
208
  "integration.credentials.error.testFailed": "Connection test failed.",
208
209
  "integration.credentials.list.action.connect": "Connect",
@@ -1031,6 +1032,9 @@
1031
1032
  "integration.runs.response.async.startedWithId": "Run #{id} started.",
1032
1033
  "integration.runs.response.description.async": "Started in the background",
1033
1034
  "integration.runs.response.description.sync": "Synchronous run result",
1035
+ "integration.runs.response.failure.run": "Run failed.",
1036
+ "integration.runs.response.failure.runWithId": "Run #{id} failed.",
1037
+ "integration.runs.response.failure.step": "Failed at step “{step}”.",
1034
1038
  "integration.runs.response.following": "Waiting for the run to finish…",
1035
1039
  "integration.runs.response.loading.async": "Starting…",
1036
1040
  "integration.runs.response.loading.sync": "Running…",
@@ -1415,9 +1419,6 @@
1415
1419
  "integration.workflows.table.menu.action.pause": "Pause",
1416
1420
  "integration.workflows.table.menu.actions.aria": "Actions for {name}",
1417
1421
  "integration.workflows.table.menu.actions.label": "Actions",
1418
- "integration.workflows.table.mode.async": "async",
1419
- "integration.workflows.table.mode.caller": "caller",
1420
- "integration.workflows.table.mode.sync": "sync",
1421
1422
  "integration.workflows.table.search.placeholder": "Search workflows…",
1422
1423
  "integration.workflows.table.state.empty": "No workflows yet.",
1423
1424
  "integration.workflows.table.status.build.building": "Building…",
@@ -8,6 +8,7 @@ import { createPollBudget, SAFETY_POLL_MS } from "../../utils/pollBudget";
8
8
  import { deadLetterStatusClass, deadLetterStatusLabel } from "../../utils/statusStyle";
9
9
  import { errorReason } from "../../utils/apiError";
10
10
  import { formatDateTime } from "../../utils/formatDateTime";
11
+ import { triggerTypeLabel } from "../../data/integrationNodeCatalog";
11
12
  const t = useIntegrationsT();
12
13
  useHead({ title: () => t("integration.runs.list.head.title") });
13
14
  const route = useRoute();
@@ -225,7 +226,7 @@ function workflowNameOf(dl) {
225
226
  <div class="text-sm break-words">{{ dl.reason || t("integration.deadLetters.reason.fallback") }}</div>
226
227
  <div class="mt-0.5 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs text-muted-foreground font-mono">
227
228
  <span>#{{ dl.id }}</span>
228
- <span v-if="dl.source">{{ dl.source }}</span>
229
+ <span v-if="dl.source">{{ triggerTypeLabel(dl.source) }}</span>
229
230
  <span v-if="dl.trigger_handle">↳ {{ dl.trigger_handle }}</span>
230
231
  <span>{{ formatDateTime(dl.created_at) }}</span>
231
232
  </div>
@@ -42,6 +42,7 @@ import { createPollBudget, SAFETY_POLL_MS } from "../../../utils/pollBudget";
42
42
  import { BUILD_SETTLE_POLL_MS, BUILD_SETTLE_TIMEOUT_MS, createBuildSettle, isBuildUnsettled } from "../../../utils/buildSettle";
43
43
  import { splitRunOutputs } from "../../../utils/runOutputs";
44
44
  import { isTerminalRun, unwrapRun } from "../../../utils/runEnvelope";
45
+ import { isFailedRun, reasonText, runFailureFromSteps } from "../../../utils/runFailure";
45
46
  import { findingsSummary, groupFindingsByNode, nodeDisplayLabel } from "../../../utils/validationFindings";
46
47
  import { payloadFieldsFromBlob } from "../../../data/payloadForm";
47
48
  import { eventTopicOf } from "../../../data/eventTrigger";
@@ -49,6 +50,7 @@ import { isShapeIssue, saveRefusalLines } from "../../../data/saveRefusal";
49
50
  import WorkflowIconView from "../../../components/Integrations/WorkflowIconView.vue";
50
51
  import { DEFAULT_RUN_DIRECTION, DEFAULT_RUN_SORT } from "../../../utils/runFeedQuery";
51
52
  import { keepOpenBeside } from "../../../utils/dialog";
53
+ import { executionModeLabel } from "../../../utils/executionMode";
52
54
  const t = useIntegrationsT();
53
55
  const route = useRoute();
54
56
  const router = useRouter();
@@ -323,7 +325,7 @@ async function onRevisionRestored() {
323
325
  draft.value = null;
324
326
  settleBuildStatus();
325
327
  }
326
- const { trigger: triggerRun, loadForWorkflow, fetchRun, getRun, retryRun } = useIntegrationsRuns();
328
+ const { trigger: triggerRun, loadForWorkflow, fetchRun, getRun, getSteps, retryRun } = useIntegrationsRuns();
327
329
  const running = ref(false);
328
330
  const waitingMode = ref(null);
329
331
  const payloadText = ref("{}");
@@ -338,6 +340,35 @@ const followingRun = ref(false);
338
340
  const runModalMode = ref("sync");
339
341
  const runRefused = ref(false);
340
342
  const runModalRunId = ref(null);
343
+ const runFailure = ref(null);
344
+ let runFailureFor = null;
345
+ const RUN_FAILURE_REREAD_MS = 1500;
346
+ async function loadRunFailure(runId, fallbackCause) {
347
+ if (!workflow.value || runFailureFor === runId) return;
348
+ runFailureFor = runId;
349
+ runFailure.value = { step: null, cause: fallbackCause };
350
+ for (let attempt = 0; attempt < 2; attempt++) {
351
+ if (attempt > 0) await new Promise((resolve) => setTimeout(resolve, RUN_FAILURE_REREAD_MS));
352
+ if (runFailureFor !== runId || !workflow.value) return;
353
+ try {
354
+ const res = await getSteps(workflow.value.id, runId, false);
355
+ if (runFailureFor !== runId) return;
356
+ const found = runFailureFromSteps(
357
+ res?.steps,
358
+ shownBlob.value?.nodes ?? [],
359
+ fallbackCause
360
+ );
361
+ runFailure.value = found;
362
+ if (found.step) return;
363
+ } catch {
364
+ }
365
+ }
366
+ }
367
+ watch(runObject, (run2) => {
368
+ const id2 = extractRunId(run2);
369
+ if (!id2 || !isTerminalRun(run2) || !isFailedRun(run2)) return;
370
+ void loadRunFailure(id2, reasonText(run2?.error));
371
+ });
341
372
  function extractRunId(res) {
342
373
  if (res && typeof res === "object" && "id" in res) {
343
374
  const id2 = res.id;
@@ -421,6 +452,8 @@ async function run(mode) {
421
452
  syncResponse.value = null;
422
453
  syncError.value = null;
423
454
  runRefused.value = false;
455
+ runFailure.value = null;
456
+ runFailureFor = null;
424
457
  if (mode === "sync") {
425
458
  try {
426
459
  syncResponse.value = await triggerRun(workflow.value.id, payload, "sync");
@@ -429,6 +462,9 @@ async function run(mode) {
429
462
  } catch (e) {
430
463
  await reportRunFailure(e, syncErrorMessage);
431
464
  runModalRunId.value = bodyRunId(e);
465
+ if (runModalRunId.value && e instanceof IntegrationsApiError && e.status === 422) {
466
+ void loadRunFailure(runModalRunId.value, reasonText(e.body?.error));
467
+ }
432
468
  } finally {
433
469
  syncLoading.value = false;
434
470
  void loadRuns(1);
@@ -755,7 +791,7 @@ const EXECUTION_MODES = computed(() => [
755
791
  { value: "caller_decides", label: t("integration.editor.executionMode.callerDecides.label"), hint: t("integration.editor.executionMode.callerDecides.hint") }
756
792
  ]);
757
793
  function modeLabel(m) {
758
- return m === "sync_only" ? t("integration.editor.executionMode.sync.short") : m === "caller_decides" ? t("integration.editor.executionMode.callerDecides.label") : t("integration.editor.executionMode.async.short");
794
+ return executionModeLabel(m, executionModeLabel("async_only"));
759
795
  }
760
796
  const tagSuggestions = computed(() => {
761
797
  const all = /* @__PURE__ */ new Set();
@@ -1318,6 +1354,7 @@ async function confirmDeactivate() {
1318
1354
  :error="syncError"
1319
1355
  :run-id="runModalRunId"
1320
1356
  :refused="runRefused"
1357
+ :failure="runFailure"
1321
1358
  @update:open="(v) => syncOpen = v"
1322
1359
  @view-run="viewRunFromModal"
1323
1360
  />
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Where on the canvas a fit may put the drawing, given what floats over it.
3
+ *
4
+ * The Map, *Add node* and the control pane float **over** the canvas rather than
5
+ * beside it, and a plain `fitView` centres the drawing in the whole canvas — so a
6
+ * card can land under a panel, where it takes no pointer (PO-660). This works out
7
+ * the part of the canvas no shown panel covers and turns it into the per-side
8
+ * padding `fitView` takes, so the canvas library still does the fit and the
9
+ * animation; it is only told which rectangle to fit into.
10
+ *
11
+ * Pure on purpose: everything is a rectangle in screen pixels, measured by the
12
+ * caller, so the geometry is provable without a browser (`canvasFit.test.ts`).
13
+ */
14
+ /** A rectangle in screen pixels, edges rather than a size. */
15
+ export interface Box {
16
+ left: number;
17
+ top: number;
18
+ right: number;
19
+ bottom: number;
20
+ }
21
+ /** The drawing's extent in the workflow's own coordinates. */
22
+ export interface DrawingSize {
23
+ width: number;
24
+ height: number;
25
+ }
26
+ /** The per-side padding `fitView` accepts, as pixel strings. */
27
+ export interface FitPadding {
28
+ top: `${number}px`;
29
+ right: `${number}px`;
30
+ bottom: `${number}px`;
31
+ left: `${number}px`;
32
+ }
33
+ /**
34
+ * Every maximal rectangle of `canvas` that none of `panels` covers.
35
+ *
36
+ * Each panel splits each free rectangle it overlaps into what lies left, right,
37
+ * above and below it; a rectangle another one contains is dropped. A panel
38
+ * outside the canvas, or one with no area, changes nothing.
39
+ */
40
+ export declare function freeRectangles(canvas: Box, panels: Box[]): Box[];
41
+ /**
42
+ * The free rectangle the drawing is fitted into: of those that show it nearly as
43
+ * large as the best one does (`CENTRAL_SHARE`), the one whose centre lies nearest
44
+ * the canvas's centre — so the drawing sits in the middle of the canvas where the
45
+ * panels allow it, rather than tucked into a strip beside one of them.
46
+ *
47
+ * A rectangle narrower or lower than `MIN_SIDE` is passed over while a larger
48
+ * one exists. When the panels leave nothing larger, the drawing still goes into
49
+ * the best of what is left and is shown smaller — the answer the ticket chose
50
+ * over collapsing a panel the author opened. Only when no free room exists at
51
+ * all is the whole canvas used.
52
+ */
53
+ export declare function fitArea(canvas: Box, panels: Box[], drawing: DrawingSize, minZoom: number, maxZoom: number): Box;
54
+ /**
55
+ * The padding that makes `fitView` fit into `area` rather than into `canvas`:
56
+ * the distance from each canvas edge to the area's edge, plus the editor's usual
57
+ * margin inside the area.
58
+ */
59
+ export declare function paddingFor(canvas: Box, area: Box): FitPadding;