@revenexx/studio-integrations 1.1.1 → 1.2.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 (96) hide show
  1. package/CHANGELOG.md +483 -0
  2. package/README.md +3 -1
  3. package/dist/module.json +1 -1
  4. package/dist/runtime/components/Integrations/CredentialCreateDialog.vue +6 -18
  5. package/dist/runtime/components/Integrations/CredentialDetailDialog.vue +13 -23
  6. package/dist/runtime/components/Integrations/CredentialRevenexxSource.vue +7 -6
  7. package/dist/runtime/components/Integrations/DeadLettersPanel.vue +15 -4
  8. package/dist/runtime/components/Integrations/ExpressionInput.d.vue.ts +4 -0
  9. package/dist/runtime/components/Integrations/ExpressionInput.vue +45 -19
  10. package/dist/runtime/components/Integrations/ExpressionInput.vue.d.ts +4 -0
  11. package/dist/runtime/components/Integrations/GroupFrameView.vue +5 -4
  12. package/dist/runtime/components/Integrations/LoadingAnnouncement.d.vue.ts +38 -0
  13. package/dist/runtime/components/Integrations/LoadingAnnouncement.vue +16 -0
  14. package/dist/runtime/components/Integrations/LoadingAnnouncement.vue.d.ts +38 -0
  15. package/dist/runtime/components/Integrations/NodeConfigField.vue +23 -0
  16. package/dist/runtime/components/Integrations/NodeInspector.d.vue.ts +2 -2
  17. package/dist/runtime/components/Integrations/NodeInspector.vue +25 -32
  18. package/dist/runtime/components/Integrations/NodeInspector.vue.d.ts +2 -2
  19. package/dist/runtime/components/Integrations/RunActionsMenu.vue +2 -1
  20. package/dist/runtime/components/Integrations/RunDetailPanel.vue +5 -17
  21. package/dist/runtime/components/Integrations/RunResponseModal.d.vue.ts +30 -8
  22. package/dist/runtime/components/Integrations/RunResponseModal.vue +92 -24
  23. package/dist/runtime/components/Integrations/RunResponseModal.vue.d.ts +30 -8
  24. package/dist/runtime/components/Integrations/RunsTable.d.vue.ts +61 -3
  25. package/dist/runtime/components/Integrations/RunsTable.vue +129 -18
  26. package/dist/runtime/components/Integrations/RunsTable.vue.d.ts +61 -3
  27. package/dist/runtime/components/Integrations/SftpAccountImportDialog.vue +10 -1
  28. package/dist/runtime/components/Integrations/StickyNoteView.vue +1 -1
  29. package/dist/runtime/components/Integrations/WorkflowCreateDialog.vue +18 -3
  30. package/dist/runtime/components/Integrations/WorkflowDeleteDialog.d.vue.ts +18 -0
  31. package/dist/runtime/components/Integrations/WorkflowDeleteDialog.vue +70 -0
  32. package/dist/runtime/components/Integrations/WorkflowDeleteDialog.vue.d.ts +18 -0
  33. package/dist/runtime/components/Integrations/WorkflowDuplicateDialog.vue +2 -1
  34. package/dist/runtime/components/Integrations/WorkflowEdgeView.vue +4 -4
  35. package/dist/runtime/components/Integrations/WorkflowEditor.vue +39 -28
  36. package/dist/runtime/components/Integrations/WorkflowHistoryPanel.vue +17 -6
  37. package/dist/runtime/components/Integrations/WorkflowNodeView.vue +17 -21
  38. package/dist/runtime/components/Integrations/WorkflowRunReplayPanel.vue +8 -11
  39. package/dist/runtime/components/Integrations/WorkflowTriggerView.vue +6 -7
  40. package/dist/runtime/components/Integrations/WorkflowVariablesDialog.vue +7 -7
  41. package/dist/runtime/components/Integrations/WorkflowsTable.vue +10 -48
  42. package/dist/runtime/components/Integrations/canvasSides.d.ts +48 -0
  43. package/dist/runtime/components/Integrations/canvasSides.js +22 -0
  44. package/dist/runtime/composables/useCredentialTypes.js +2 -6
  45. package/dist/runtime/composables/useIntegrationsAudit.js +2 -2
  46. package/dist/runtime/composables/useIntegrationsCredentials.js +4 -8
  47. package/dist/runtime/composables/useIntegrationsDeadLetters.d.ts +4 -0
  48. package/dist/runtime/composables/useIntegrationsDeadLetters.js +16 -1
  49. package/dist/runtime/composables/useIntegrationsNodes.js +2 -2
  50. package/dist/runtime/composables/useIntegrationsRevisions.js +2 -6
  51. package/dist/runtime/composables/useIntegrationsRuns.d.ts +4 -15
  52. package/dist/runtime/composables/useIntegrationsRuns.js +49 -34
  53. package/dist/runtime/composables/useIntegrationsSchedules.js +2 -2
  54. package/dist/runtime/composables/useIntegrationsSecrets.js +2 -6
  55. package/dist/runtime/composables/useIntegrationsTemplates.js +2 -5
  56. package/dist/runtime/composables/useIntegrationsTriggers.js +2 -2
  57. package/dist/runtime/composables/useIntegrationsWorkflows.js +4 -7
  58. package/dist/runtime/composables/useNodeConfigResolve.js +2 -2
  59. package/dist/runtime/composables/useOAuthConnect.d.ts +42 -0
  60. package/dist/runtime/composables/useOAuthConnect.js +33 -0
  61. package/dist/runtime/data/expressionEditor.d.ts +17 -5
  62. package/dist/runtime/data/expressionEditor.js +11 -5
  63. package/dist/runtime/data/handleId.d.ts +61 -1
  64. package/dist/runtime/data/handleId.js +8 -1
  65. package/dist/runtime/data/payloadForm.js +3 -2
  66. package/dist/runtime/i18n/messages/de.json +26 -8
  67. package/dist/runtime/i18n/messages/en.json +26 -8
  68. package/dist/runtime/pages/integrations/audit.vue +7 -6
  69. package/dist/runtime/pages/integrations/credentials.vue +9 -21
  70. package/dist/runtime/pages/integrations/index.vue +55 -8
  71. package/dist/runtime/pages/integrations/runs.vue +116 -7
  72. package/dist/runtime/pages/integrations/secrets.vue +2 -1
  73. package/dist/runtime/pages/integrations/templates/[slug].vue +2 -1
  74. package/dist/runtime/pages/integrations/templates/index.vue +18 -15
  75. package/dist/runtime/pages/integrations/workflows/[id].vue +155 -20
  76. package/dist/runtime/types/integrations.d.ts +5 -0
  77. package/dist/runtime/utils/apiError.d.ts +21 -1
  78. package/dist/runtime/utils/apiError.js +1 -1
  79. package/dist/runtime/utils/clipboard.d.ts +33 -0
  80. package/dist/runtime/utils/clipboard.js +11 -0
  81. package/dist/runtime/utils/deepClone.d.ts +41 -0
  82. package/dist/runtime/utils/deepClone.js +3 -0
  83. package/dist/runtime/utils/formatDuration.d.ts +44 -0
  84. package/dist/runtime/utils/formatDuration.js +13 -0
  85. package/dist/runtime/utils/nodeDisplayName.d.ts +22 -0
  86. package/dist/runtime/utils/nodeDisplayName.js +6 -0
  87. package/dist/runtime/utils/notify.d.ts +1 -1
  88. package/dist/runtime/utils/runEnvelope.d.ts +31 -0
  89. package/dist/runtime/utils/runEnvelope.js +12 -0
  90. package/dist/runtime/utils/runFeedQuery.d.ts +135 -0
  91. package/dist/runtime/utils/runFeedQuery.js +66 -0
  92. package/dist/runtime/utils/runOutputs.d.ts +33 -0
  93. package/dist/runtime/utils/runOutputs.js +10 -0
  94. package/dist/runtime/utils/validationFindings.d.ts +26 -3
  95. package/dist/runtime/utils/validationFindings.js +18 -3
  96. package/package.json +1 -1
@@ -5,8 +5,12 @@ 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
+ import { inputHandleId, outputHandleId } from "../../data/handleId";
8
9
  import { isTerminalManifest } from "../../data/nodeInputData";
9
- import { resolveDisplayName, resolveLocalized } from "../../utils/localize";
10
+ import { resolveLocalized } from "../../utils/localize";
11
+ import { SIDE_POSITION, distributedStyle } from "./canvasSides";
12
+ import { fieldDisplayLabel } from "../../utils/validationFindings";
13
+ import { nodeDisplayName } from "../../utils/nodeDisplayName";
10
14
  const props = defineProps({
11
15
  id: { type: String, required: true },
12
16
  data: { type: Object, required: true },
@@ -17,11 +21,11 @@ defineEmits(["updateNodeInternals"]);
17
21
  const t = useIntegrationsT();
18
22
  const manifest = computed(() => findManifest(props.data.nodeSlug, props.data.nodeVersion));
19
23
  const category = computed(() => NODE_CATEGORIES.find((c) => c.key === manifest.value?.category));
20
- const title = computed(
21
- () => resolveDisplayName(props.data.name) || resolveLocalized(manifest.value?.name) || props.data.nodeSlug
22
- );
24
+ const title = computed(() => nodeDisplayName(props.data));
23
25
  const invalidFields = computed(() => props.data.__invalidFields ?? []);
24
- const invalidLabel = computed(() => t("integration.canvas.node.badge.invalidConfig", invalidFields.value.length, { fields: invalidFields.value.join(", ") }));
26
+ const invalidLabel = computed(() => t("integration.canvas.node.badge.invalidConfig", invalidFields.value.length, {
27
+ fields: invalidFields.value.map((key) => fieldDisplayLabel(props.data, key)).join(", ")
28
+ }));
25
29
  const insideLoops = computed(() => props.data.__loop?.inside ?? []);
26
30
  const repeatsLabel = computed(() => t("integration.canvas.node.badge.repeats", insideLoops.value.length, { iterators: insideLoops.value.join(", ") }));
27
31
  const bodySize = computed(() => props.data.__loop?.bodySize);
@@ -111,14 +115,6 @@ function portTone(p) {
111
115
  const showPortLabels = computed(() => resolvePending.value || outputs.value.length > 1 || outputs.value.some((p) => p.kind !== "default"));
112
116
  const IN_SIDES = ["top"];
113
117
  const OUT_SIDES = ["bottom"];
114
- function sideToPosition(s) {
115
- return s === "top" ? Position.Top : s === "right" ? Position.Right : s === "bottom" ? Position.Bottom : Position.Left;
116
- }
117
- function distributedStyle(side, i, total) {
118
- const pct = `${(i + 1) / (total + 1) * 100}%`;
119
- if (side === "top" || side === "bottom") return { left: pct };
120
- return { top: pct };
121
- }
122
118
  const flow = useVueFlow();
123
119
  const hovered = ref(false);
124
120
  let hoverTimer = null;
@@ -192,22 +188,22 @@ function remove() {
192
188
  <NodeToolbar v-if="nodeActions" :is-visible="hovered || selected" :position="Position.Top" :offset="8" class="flex items-center gap-1 rounded-md border border-black/[0.08] bg-white px-1 py-1 shadow-md" @mouseenter="onHoverEnter" @mouseleave="onHoverLeave">
193
189
  <button
194
190
  type="button"
195
- class="flex h-6 items-center gap-1 rounded px-1.5 text-sm text-foreground/80 hover:bg-muted/50"
191
+ class="flex size-6 items-center justify-center rounded text-sm text-foreground/80 hover:bg-muted/50"
196
192
  :title="t('integration.canvas.node.action.duplicate')"
197
193
  :aria-label="t('integration.canvas.node.action.duplicateAria')"
198
194
  @click="duplicate"
199
195
  >
200
- <Copy :size="11" />
196
+ <Copy :size="13" />
201
197
  </button>
202
198
  <div class="h-4 w-px bg-black/10" />
203
199
  <button
204
200
  type="button"
205
- class="flex h-6 items-center gap-1 rounded px-1.5 text-sm text-rose-600 hover:bg-rose-50"
201
+ class="flex size-6 items-center justify-center rounded text-sm text-rose-600 hover:bg-rose-50"
206
202
  :title="t('integration.common.action.delete')"
207
203
  :aria-label="t('integration.canvas.node.action.deleteAria')"
208
204
  @click="remove"
209
205
  >
210
- <TrashBin :size="11" />
206
+ <TrashBin :size="13" />
211
207
  </button>
212
208
  </NodeToolbar>
213
209
 
@@ -239,10 +235,10 @@ function remove() {
239
235
  dots feed the same `__in` port. Clearly visible, ring + shadow. -->
240
236
  <Handle
241
237
  v-for="side in IN_SIDES"
242
- :id="`__in__${side}`"
238
+ :id="inputHandleId(side)"
243
239
  :key="`in-${side}`"
244
240
  type="target"
245
- :position="sideToPosition(side)"
241
+ :position="SIDE_POSITION[side]"
246
242
  :title="t('integration.canvas.node.handle.input')"
247
243
  class="!size-3 !rounded-full !border-2 !border-white !bg-slate-500 !shadow-sm transition-all hover:!size-3.5 hover:!bg-slate-700"
248
244
  />
@@ -252,10 +248,10 @@ function remove() {
252
248
  <template v-for="side in OUT_SIDES" :key="`side-${side}`">
253
249
  <Handle
254
250
  v-for="(p, i) in outputs"
255
- :id="`${p.name}__${side}`"
251
+ :id="outputHandleId(p.name, side)"
256
252
  :key="`${p.name}-${side}`"
257
253
  type="source"
258
- :position="sideToPosition(side)"
254
+ :position="SIDE_POSITION[side]"
259
255
  :title="handleTitle(p)"
260
256
  :style="distributedStyle(side, i, outputs.length)"
261
257
  class="!size-3 !rounded-full !border-2 !border-white !shadow-sm transition-all hover:!size-3.5"
@@ -24,10 +24,14 @@ import StickyNoteView from "./StickyNoteView.vue";
24
24
  import GroupFrameView from "./GroupFrameView.vue";
25
25
  import WorkflowEdgeView from "./WorkflowEdgeView.vue";
26
26
  import { findManifest } from "../../data/integrationNodeCatalog";
27
+ import { OPPOSITE_SIDE } from "./canvasSides";
28
+ import { inputHandleId, outputHandleId } from "../../data/handleId";
27
29
  import { nodeLoopData } from "../../data/loopBody";
28
30
  import { triggerGlyph } from "../../data/triggerGlyphs";
31
+ import { formatDuration } from "../../utils/formatDuration";
29
32
  import { SAFETY_POLL_MS } from "../../utils/pollBudget";
30
33
  import { runStatusClass, runStatusLabel } from "../../utils/statusStyle";
34
+ import { errorReason } from "../../utils/apiError";
31
35
  const props = defineProps({
32
36
  workflow: { type: Object, required: true },
33
37
  initialRunId: { type: [String, null], required: false },
@@ -59,7 +63,7 @@ async function loadRuns(reset = false) {
59
63
  runsPage.value = res.page;
60
64
  runsLastPage.value = res.lastPage;
61
65
  } catch (e) {
62
- toast.error(e?.message ?? t("integration.replay.toast.loadRunsFailed"));
66
+ toast.error(errorReason(e, t("integration.replay.toast.loadRunsFailed")));
63
67
  } finally {
64
68
  runsLoading.value = false;
65
69
  }
@@ -101,7 +105,7 @@ async function loadSteps(runId, isPoll = false) {
101
105
  }
102
106
  } catch (e) {
103
107
  if (selectedRunId.value === runId) {
104
- stepsError.value = e?.message ?? t("integration.replay.error.loadStepsFailed");
108
+ stepsError.value = errorReason(e, t("integration.replay.error.loadStepsFailed"));
105
109
  }
106
110
  } finally {
107
111
  if (!isPoll) stepsLoading.value = false;
@@ -293,7 +297,6 @@ const vfEdges = computed(() => {
293
297
  return { x: 0, y: 0 };
294
298
  };
295
299
  const pickSourceSide = (_sourceId, _targetId) => "bottom";
296
- const opposite = (s) => s === "top" ? "bottom" : s === "right" ? "left" : s === "bottom" ? "top" : "right";
297
300
  const edgeKind = (sourceNodeId, output) => {
298
301
  const wn = blob.nodes.find((n) => n.id === sourceNodeId);
299
302
  if (!wn) return "default";
@@ -331,8 +334,8 @@ const vfEdges = computed(() => {
331
334
  id: e.id,
332
335
  source: e.from.nodeId,
333
336
  target: e.to.nodeId,
334
- sourceHandle: `${e.from.output}__${srcSide}`,
335
- targetHandle: `__in__${opposite(srcSide)}`,
337
+ sourceHandle: outputHandleId(e.from.output, srcSide),
338
+ targetHandle: inputHandleId(OPPOSITE_SIDE[srcSide]),
336
339
  type: "flow",
337
340
  selectable: false,
338
341
  animated: traversed && !targetReached,
@@ -395,12 +398,6 @@ function formatTime(iso) {
395
398
  return String(iso);
396
399
  }
397
400
  }
398
- function formatDuration(ms) {
399
- if (ms == null) return "\u2014";
400
- if (ms < 1e3) return `${ms} ms`;
401
- if (ms < 6e4) return `${(ms / 1e3).toFixed(1)} s`;
402
- return `${Math.floor(ms / 6e4)}m ${Math.round(ms % 6e4 / 1e3)}s`;
403
- }
404
401
  function pretty(v) {
405
402
  try {
406
403
  return JSON.stringify(v, null, 2);
@@ -3,6 +3,8 @@ import { computed, ref } from "vue";
3
3
  import { Handle, Position, useVueFlow } from "@vue-flow/core";
4
4
  import { NodeToolbar } from "@vue-flow/node-toolbar";
5
5
  import { TrashBinMinimalistic as TrashBin } from "#solar-icons";
6
+ import { SIDE_POSITION } from "./canvasSides";
7
+ import { outputHandleId } from "../../data/handleId";
6
8
  import { triggerGlyph as glyphForTrigger } from "../../data/triggerGlyphs";
7
9
  import { resolveDisplayName } from "../../utils/localize";
8
10
  import { triggerTypeLabel } from "../../data/integrationNodeCatalog";
@@ -45,21 +47,18 @@ function remove() {
45
47
  flow.removeNodes([props.id]);
46
48
  }
47
49
  const OUT_SIDES = ["bottom"];
48
- function sideToPosition(s) {
49
- return s === "top" ? Position.Top : s === "right" ? Position.Right : s === "bottom" ? Position.Bottom : Position.Left;
50
- }
51
50
  </script>
52
51
 
53
52
  <template>
54
53
  <NodeToolbar :is-visible="hovered || selected" :position="Position.Top" :offset="8" class="flex items-center gap-1 rounded-md border border-black/[0.08] bg-white px-1 py-1 shadow-md" @mouseenter="onHoverEnter" @mouseleave="onHoverLeave">
55
54
  <button
56
55
  type="button"
57
- class="flex h-6 items-center gap-1 rounded px-1.5 text-sm text-rose-600 hover:bg-rose-50"
56
+ class="flex size-6 items-center justify-center rounded text-sm text-rose-600 hover:bg-rose-50"
58
57
  :title="t('integration.common.action.delete')"
59
58
  :aria-label="t('integration.canvas.trigger.action.deleteAria')"
60
59
  @click="remove"
61
60
  >
62
- <TrashBin :size="11" />
61
+ <TrashBin :size="13" />
63
62
  </button>
64
63
  </NodeToolbar>
65
64
 
@@ -97,10 +96,10 @@ function sideToPosition(s) {
97
96
  "default"; the side suffix is stripped on persist. -->
98
97
  <Handle
99
98
  v-for="side in OUT_SIDES"
100
- :id="`default__${side}`"
99
+ :id="outputHandleId('default', side)"
101
100
  :key="side"
102
101
  type="source"
103
- :position="sideToPosition(side)"
102
+ :position="SIDE_POSITION[side]"
104
103
  class="!size-3 !rounded-full !border-2 !border-white !bg-emerald-500 !shadow-sm transition-all hover:!size-3.5 hover:!bg-emerald-600"
105
104
  />
106
105
  </template>
@@ -19,6 +19,8 @@ import {
19
19
  SelectValue
20
20
  } from "@revenexx/studio";
21
21
  import { toast } from "vue-sonner";
22
+ import { deepClone } from "../../utils/deepClone";
23
+ import { copyToClipboard } from "../../utils/clipboard";
22
24
  import { languageKeyRead, languageKeyToWrite, resolveDisplayName } from "../../utils/localize";
23
25
  const props = defineProps({
24
26
  open: { type: Boolean, required: true },
@@ -34,7 +36,7 @@ const jsonErrors = reactive({});
34
36
  const descDrafts = reactive({});
35
37
  watch(() => props.open, (o) => {
36
38
  if (!o) return;
37
- working.value = JSON.parse(JSON.stringify(props.variables));
39
+ working.value = deepClone(props.variables);
38
40
  for (const k of Object.keys(jsonDrafts)) delete jsonDrafts[k];
39
41
  for (const k of Object.keys(jsonErrors)) delete jsonErrors[k];
40
42
  for (const k of Object.keys(descDrafts)) delete descDrafts[k];
@@ -146,12 +148,10 @@ function addVariable() {
146
148
  newKey.value = "";
147
149
  }
148
150
  async function copyUsage(key) {
149
- try {
150
- await navigator.clipboard.writeText(`\${{ vars.${key} }}`);
151
- toast.success(t("integration.workflows.variables.toast.copied"));
152
- } catch {
153
- toast.error(t("integration.workflows.variables.toast.copyFailed"));
154
- }
151
+ await copyToClipboard(`\${{ vars.${key} }}`, {
152
+ copied: t("integration.workflows.variables.toast.copied"),
153
+ failed: t("integration.workflows.variables.toast.copyFailed")
154
+ });
155
155
  }
156
156
  </script>
157
157
 
@@ -1,17 +1,12 @@
1
1
  <script setup>
2
- import { RefreshCircle as Loader2, MenuDots as MoreHorizontal } from "#solar-icons";
2
+ import { MenuDots as MoreHorizontal } from "#solar-icons";
3
3
  import { toast } from "vue-sonner";
4
- import { apiValidationIssues } from "../../utils/apiError";
4
+ import { apiValidationIssues, errorReason } from "../../utils/apiError";
5
+ import WorkflowDeleteDialog from "./WorkflowDeleteDialog.vue";
5
6
  import WorkflowDuplicateDialog from "./WorkflowDuplicateDialog.vue";
6
7
  import WorkflowIconView from "./WorkflowIconView.vue";
7
8
  import { Layers } from "#solar-icons";
8
9
  import {
9
- AlertDialog,
10
- AlertDialogContent,
11
- AlertDialogDescription,
12
- AlertDialogFooter,
13
- AlertDialogHeader,
14
- AlertDialogTitle,
15
10
  Badge,
16
11
  Button,
17
12
  DropdownMenu,
@@ -27,7 +22,7 @@ defineProps({
27
22
  });
28
23
  const t = useIntegrationsT();
29
24
  const router = useRouter();
30
- const { remove, toggleActive } = useIntegrationsWorkflows();
25
+ const { toggleActive } = useIntegrationsWorkflows();
31
26
  const duplicateTarget = ref(null);
32
27
  const duplicateOpen = ref(false);
33
28
  function askDuplicate(wf) {
@@ -77,21 +72,11 @@ function runBadge(status) {
77
72
  return { label: t("integration.workflows.table.status.run.cancelled"), class: "bg-slate-100 text-slate-700 border-slate-200" };
78
73
  }
79
74
  }
80
- const pendingDelete = ref(null);
81
- const deleting = ref(false);
75
+ const deleteOpen = ref(false);
76
+ const deleteTarget = ref(null);
82
77
  function askDelete(wf) {
83
- pendingDelete.value = wf;
84
- }
85
- async function confirmDelete() {
86
- const wf = pendingDelete.value;
87
- if (!wf) return;
88
- deleting.value = true;
89
- try {
90
- await remove(wf.id);
91
- pendingDelete.value = null;
92
- } finally {
93
- deleting.value = false;
94
- }
78
+ deleteTarget.value = { id: wf.id, name: wf.name };
79
+ deleteOpen.value = true;
95
80
  }
96
81
  async function onToggleActive(wf) {
97
82
  try {
@@ -102,7 +87,7 @@ async function onToggleActive(wf) {
102
87
  toast.error(t("integration.workflows.table.toast.activateBlocked", issues.length, { name: wf.name }));
103
88
  return;
104
89
  }
105
- toast.error(e?.message ?? t("integration.workflows.table.toast.statusChangeFailed"));
90
+ toast.error(errorReason(e, t("integration.workflows.table.toast.statusChangeFailed")));
106
91
  }
107
92
  }
108
93
  function lifecycleOf(wf) {
@@ -405,30 +390,7 @@ const columns = computed(() => [
405
390
  </template>
406
391
  </DataTable>
407
392
 
408
- <AlertDialog :open="!!pendingDelete" @update:open="(v) => {
409
- if (!v) pendingDelete = null;
410
- }">
411
- <AlertDialogContent>
412
- <AlertDialogHeader>
413
- <AlertDialogTitle>{{ t("integration.workflows.table.deleteDialog.title") }}</AlertDialogTitle>
414
- <AlertDialogDescription>
415
- <span v-if="pendingDelete">
416
- <span class="font-medium text-foreground">{{ pendingDelete.name }}</span>
417
- {{ t("integration.workflows.table.deleteDialog.description") }}
418
- </span>
419
- </AlertDialogDescription>
420
- </AlertDialogHeader>
421
- <AlertDialogFooter>
422
- <Button variant="outline" :disabled="deleting" @click="pendingDelete = null">
423
- {{ t("integration.common.action.cancel") }}
424
- </Button>
425
- <Button class="bg-destructive text-white hover:bg-destructive/90" :disabled="deleting" @click="confirmDelete">
426
- <Loader2 v-if="deleting" :size="14" class="animate-spin" />
427
- {{ deleting ? t("integration.workflows.table.deleteDialog.action.deleting") : t("integration.common.action.delete") }}
428
- </Button>
429
- </AlertDialogFooter>
430
- </AlertDialogContent>
431
- </AlertDialog>
393
+ <WorkflowDeleteDialog v-model:open="deleteOpen" :workflow="deleteTarget" />
432
394
 
433
395
  <WorkflowDuplicateDialog v-model:open="duplicateOpen" :workflow="duplicateTarget" @duplicated="onDuplicated" />
434
396
  </template>
@@ -0,0 +1,48 @@
1
+ import { Position } from '@vue-flow/core';
2
+ import type { Side } from '../../data/handleId.js';
3
+ /**
4
+ * What a connection dot's side means to the canvas: where the library draws it, what
5
+ * faces it, and where along the edge it sits.
6
+ *
7
+ * `data/handleId.ts` owns what a side *is* — the closed set of four, and the id a dot
8
+ * carries. This owns what the canvas does with one. The split is the import: that
9
+ * module has none, so its round trip can be proved without the canvas library, and
10
+ * `Position` is Vue Flow's. Keeping the two apart is what stops a string-shape contract
11
+ * from depending on a rendering library.
12
+ *
13
+ * **Every map here is a `Record<Side, …>`, and that is the point of the module.** Each
14
+ * was a four-branch ternary ending in an `else`, in a component, and two components had
15
+ * their own copy of the first one. An `else` absorbs a fifth side silently: a `centre`
16
+ * dot would have been drawn on the **left**, called `right` its opposite, and spread
17
+ * along the wrong axis — with no type error and nothing on screen to notice, because
18
+ * every one of those is a plausible answer for *some* side.
19
+ *
20
+ * A `Record<Side, …>` cannot do that. Add a name to `SIDES` and all three of these stop
21
+ * compiling until they say what it means. That is weaker than PO-258's "one edit" and
22
+ * better than it: it is still more than one edit, but none of them can be forgotten.
23
+ */
24
+ /**
25
+ * The side a dot is on → the position Vue Flow draws it at.
26
+ *
27
+ * The two vocabularies happen to be the same four strings today — `Position.Top` is
28
+ * `'top'` — so this could be a cast, and is not. A cast would tie us to that
29
+ * coincidence holding in a library we do not own, and it would say nothing when it
30
+ * stopped: the studio's sides are its own, and this is where they are translated.
31
+ */
32
+ export declare const SIDE_POSITION: Record<Side, Position>;
33
+ /**
34
+ * The side facing a given one, which is where an edge arriving from it should land.
35
+ *
36
+ * "Opposite" is only meaningful because the four sides are two axes. A fifth position
37
+ * that is not on an axis — a centre dot — has no opposite at all, and the honest answer
38
+ * then is to change this type rather than to invent one; the compiler will ask.
39
+ */
40
+ export declare const OPPOSITE_SIDE: Record<Side, Side>;
41
+ /**
42
+ * Place the `i`th of `total` output dots evenly along a side.
43
+ *
44
+ * Evenly *between* the corners rather than across them — `(i + 1) / (total + 1)` — so
45
+ * the first and last dot sit inside the card's edge instead of on it, and a single
46
+ * output lands in the middle.
47
+ */
48
+ export declare function distributedStyle(side: Side, i: number, total: number): Record<string, string>;
@@ -0,0 +1,22 @@
1
+ import { Position } from "@vue-flow/core";
2
+ export const SIDE_POSITION = {
3
+ top: Position.Top,
4
+ right: Position.Right,
5
+ bottom: Position.Bottom,
6
+ left: Position.Left
7
+ };
8
+ export const OPPOSITE_SIDE = {
9
+ top: "bottom",
10
+ right: "left",
11
+ bottom: "top",
12
+ left: "right"
13
+ };
14
+ const SIDE_SPREAD_AXIS = {
15
+ top: "left",
16
+ right: "top",
17
+ bottom: "left",
18
+ left: "top"
19
+ };
20
+ export function distributedStyle(side, i, total) {
21
+ return { [SIDE_SPREAD_AXIS[side]]: `${(i + 1) / (total + 1) * 100}%` };
22
+ }
@@ -1,6 +1,6 @@
1
1
  import { useIntegrationsApi } from "./integrationsApi.js";
2
2
  import { useIntegrationsT } from "./useIntegrationsT.js";
3
- import { IntegrationsApiError } from "../utils/apiError.js";
3
+ import { errorReason } from "../utils/apiError.js";
4
4
  import { useLoadGate } from "./useLoadGate.js";
5
5
  export function useCredentialTypes() {
6
6
  const types = useState("integrations.credentialTypes", () => []);
@@ -16,10 +16,6 @@ export function useCredentialTypes() {
16
16
  if (Array.isArray(obj?.data)) return obj.data;
17
17
  return [];
18
18
  }
19
- function captureError(e, fallback) {
20
- if (e instanceof IntegrationsApiError) return e.message;
21
- return e?.message ?? fallback;
22
- }
23
19
  async function load(force = false) {
24
20
  if (!await gate.claim(force)) return types.value;
25
21
  error.value = null;
@@ -27,7 +23,7 @@ export function useCredentialTypes() {
27
23
  types.value = normalize(await api.get("/credential-types"));
28
24
  loaded.value = true;
29
25
  } catch (e) {
30
- error.value = captureError(e, t("integration.credentials.error.loadTypes"));
26
+ error.value = errorReason(e, t("integration.credentials.error.loadTypes"));
31
27
  } finally {
32
28
  gate.release();
33
29
  }
@@ -1,5 +1,5 @@
1
1
  import { useIntegrationsApi } from "./integrationsApi.js";
2
- import { IntegrationsApiError } from "../utils/apiError.js";
2
+ import { errorReason } from "../utils/apiError.js";
3
3
  import { useLoadGate } from "./useLoadGate.js";
4
4
  export function useIntegrationsAudit() {
5
5
  const api = useIntegrationsApi();
@@ -28,7 +28,7 @@ export function useIntegrationsAudit() {
28
28
  entries.value = append ? [...entries.value, ...data] : data;
29
29
  nextCursor.value = res.meta?.next_cursor ?? null;
30
30
  } catch (e) {
31
- error.value = e instanceof IntegrationsApiError ? e.message : e?.message ?? "Could not load the audit log.";
31
+ error.value = errorReason(e, "Could not load the audit log.");
32
32
  } finally {
33
33
  gate.release();
34
34
  }
@@ -1,7 +1,7 @@
1
1
  import { computed } from "vue";
2
2
  import { useIntegrationsApi } from "./integrationsApi.js";
3
3
  import { useIntegrationsT } from "./useIntegrationsT.js";
4
- import { IntegrationsApiError } from "../utils/apiError.js";
4
+ import { IntegrationsApiError, errorReason } from "../utils/apiError.js";
5
5
  import { useLoadGate } from "./useLoadGate.js";
6
6
  export function useIntegrationsCredentials() {
7
7
  const credentials = useState("integrations.credentials", () => []);
@@ -20,10 +20,6 @@ export function useIntegrationsCredentials() {
20
20
  function unwrap(res) {
21
21
  return res?.data ?? res;
22
22
  }
23
- function captureError(e, fallback) {
24
- if (e instanceof IntegrationsApiError) return e.message;
25
- return e?.message ?? fallback;
26
- }
27
23
  async function load(force = false) {
28
24
  if (!await gate.claim(force)) return credentials.value;
29
25
  error.value = null;
@@ -31,7 +27,7 @@ export function useIntegrationsCredentials() {
31
27
  credentials.value = normalize(await api.get("/credentials"));
32
28
  loaded.value = true;
33
29
  } catch (e) {
34
- error.value = captureError(e, t("integration.credentials.error.loadFailed"));
30
+ error.value = errorReason(e, t("integration.credentials.error.loadFailed"));
35
31
  } finally {
36
32
  gate.release();
37
33
  }
@@ -62,7 +58,7 @@ export function useIntegrationsCredentials() {
62
58
  const res = await api.post("/credentials/test", { credential_type_slug: credentialTypeSlug, config });
63
59
  return res?.ok === false ? res : { ok: true, message: res?.message };
64
60
  } catch (e) {
65
- return { ok: false, error: captureError(e, t("integration.credentials.error.testFailed")) };
61
+ return { ok: false, error: errorReason(e, t("integration.credentials.error.testFailed")) };
66
62
  }
67
63
  }
68
64
  async function test(id) {
@@ -70,7 +66,7 @@ export function useIntegrationsCredentials() {
70
66
  const res = await api.post(`/credentials/${encodeURIComponent(id)}/test`);
71
67
  return res?.ok === false ? res : { ok: true, message: res?.message };
72
68
  } catch (e) {
73
- return { ok: false, error: captureError(e, t("integration.credentials.error.testFailed")) };
69
+ return { ok: false, error: errorReason(e, t("integration.credentials.error.testFailed")) };
74
70
  }
75
71
  }
76
72
  async function oauthAuthorizeUrl(id) {
@@ -24,6 +24,10 @@ export declare function useIntegrationsDeadLetters(): {
24
24
  globalLoading: any;
25
25
  globalLoaded: any;
26
26
  globalHasMore: any;
27
+ globalPage: any;
28
+ globalLastPage: import("vue").ComputedRef<number>;
29
+ goGlobalPage: (page: number) => Promise<GlobalDeadLetterRow[]>;
30
+ resetGlobalPage: () => void;
27
31
  summary: any;
28
32
  pendingCount: import("vue").ComputedRef<any>;
29
33
  };
@@ -1,6 +1,7 @@
1
1
  import { computed } from "vue";
2
2
  import { useIntegrationsApi } from "./integrationsApi.js";
3
3
  import { useLoadGate } from "./useLoadGate.js";
4
+ import { runFeedLastPage } from "../utils/runFeedQuery.js";
4
5
  const EMPTY_SUMMARY = { total: 0, by_status: {} };
5
6
  export function useIntegrationsDeadLetters() {
6
7
  const api = useIntegrationsApi();
@@ -25,12 +26,15 @@ export function useIntegrationsDeadLetters() {
25
26
  const gate = useLoadGate("integrations.deadletters.global", { loading: globalLoading, loaded: globalLoaded });
26
27
  const summary = useState("integrations.deadletters.summary", () => ({ ...EMPTY_SUMMARY }));
27
28
  const globalHasMore = useState("integrations.deadletters.global.hasMore", () => false);
29
+ const GLOBAL_PER_PAGE = 25;
30
+ const globalPage = useState("integrations.deadletters.global.page", () => 1);
31
+ const globalLastPage = computed(() => runFeedLastPage(summary.value.total, GLOBAL_PER_PAGE));
28
32
  const pendingCount = computed(() => summary.value.by_status.pending ?? 0);
29
33
  async function loadGlobal(force = false) {
30
34
  if (!await gate.claim(force)) return globalItems.value;
31
35
  try {
32
36
  const [page, counts] = await Promise.all([
33
- api.get("/dead-letters?page=1&per_page=100"),
37
+ api.get(`/dead-letters?page=${globalPage.value}&per_page=${GLOBAL_PER_PAGE}`),
34
38
  api.get("/dead-letters/summary")
35
39
  ]);
36
40
  globalItems.value = page?.data ?? [];
@@ -45,6 +49,13 @@ export function useIntegrationsDeadLetters() {
45
49
  }
46
50
  return globalItems.value;
47
51
  }
52
+ async function goGlobalPage(page) {
53
+ globalPage.value = Math.min(Math.max(1, page), globalLastPage.value);
54
+ return loadGlobal(true);
55
+ }
56
+ function resetGlobalPage() {
57
+ globalPage.value = 1;
58
+ }
48
59
  return {
49
60
  list,
50
61
  replay,
@@ -54,6 +65,10 @@ export function useIntegrationsDeadLetters() {
54
65
  globalLoading,
55
66
  globalLoaded,
56
67
  globalHasMore,
68
+ globalPage,
69
+ globalLastPage,
70
+ goGlobalPage,
71
+ resetGlobalPage,
57
72
  summary,
58
73
  pendingCount
59
74
  };
@@ -2,7 +2,7 @@ import { applyNodeCatalog, findManifest } from "../data/integrationNodeCatalog.j
2
2
  import { resolveLocalized } from "../utils/localize.js";
3
3
  import { useIntegrationsApi } from "./integrationsApi.js";
4
4
  import { useIntegrationsT } from "./useIntegrationsT.js";
5
- import { IntegrationsApiError } from "../utils/apiError.js";
5
+ import { errorReason } from "../utils/apiError.js";
6
6
  import { useLoadGate } from "./useLoadGate.js";
7
7
  const RETRY_COOLDOWN_MS = 3e4;
8
8
  export function useIntegrationsNodes() {
@@ -39,7 +39,7 @@ export function useIntegrationsNodes() {
39
39
  applyNodeCatalog([]);
40
40
  loaded.value = false;
41
41
  failedAt.value = Date.now();
42
- error.value = e instanceof IntegrationsApiError ? e.message : e?.message ?? t("integration.nodes.error.loadFailed");
42
+ error.value = errorReason(e, t("integration.nodes.error.loadFailed"));
43
43
  } finally {
44
44
  gate.release();
45
45
  }
@@ -1,16 +1,12 @@
1
1
  import { ref } from "vue";
2
2
  import { useIntegrationsApi } from "./integrationsApi.js";
3
3
  import { useIntegrationsT } from "./useIntegrationsT.js";
4
- import { IntegrationsApiError } from "../utils/apiError.js";
4
+ import { errorReason } from "../utils/apiError.js";
5
5
  export function useIntegrationsRevisions() {
6
6
  const api = useIntegrationsApi();
7
7
  const t = useIntegrationsT();
8
8
  const loading = ref(false);
9
9
  const error = ref(null);
10
- function captureError(e, fallback) {
11
- if (e instanceof IntegrationsApiError) return e.message;
12
- return e?.message ?? fallback;
13
- }
14
10
  async function list(workflowId, page = 1, perPage = 25) {
15
11
  loading.value = true;
16
12
  error.value = null;
@@ -18,7 +14,7 @@ export function useIntegrationsRevisions() {
18
14
  const res = await api.get(`/workflows/${workflowId}/revisions?page=${page}&per_page=${perPage}`);
19
15
  return { revisions: res.data ?? [], meta: res.meta };
20
16
  } catch (e) {
21
- error.value = captureError(e, t("integration.revisions.error.loadFailed"));
17
+ error.value = errorReason(e, t("integration.revisions.error.loadFailed"));
22
18
  throw e;
23
19
  } finally {
24
20
  loading.value = false;
@@ -1,23 +1,11 @@
1
1
  import type { RunStatus, RunStepRow, WorkflowRun } from '../types/integrations.js';
2
+ import type { RunFeedQuery } from '../utils/runFeedQuery.js';
3
+ export type { RunFeedQuery };
2
4
  interface RunSummary {
3
5
  total: number;
4
6
  in_flight: number;
5
7
  by_status: Record<string, number>;
6
8
  }
7
- /**
8
- * Which page of the tenant's list to read.
9
- *
10
- * Only the page: `/runs` also narrows by status, trigger kind, workflow, window
11
- * and text, and none of that is driven from here yet. The shared DataTable
12
- * narrows and searches what it holds, and it cannot report a filter selection
13
- * back to the page under `serverMode` — so a narrowing sent to the platform
14
- * would have nothing to set it. That is the remaining half of PO-231, and the
15
- * parameters are waiting on the endpoint for it.
16
- */
17
- export interface RunFeedQuery {
18
- page: number;
19
- perPage: number;
20
- }
21
9
  export declare function useIntegrationsRuns(): {
22
10
  runs: any;
23
11
  loading: any;
@@ -29,6 +17,8 @@ export declare function useIntegrationsRuns(): {
29
17
  summary: any;
30
18
  hasMore: any;
31
19
  query: any;
20
+ setQuery: (patch: Partial<RunFeedQuery>) => Promise<WorkflowRun[]>;
21
+ narrowedTotal: any;
32
22
  load: (force?: boolean) => Promise<WorkflowRun[]>;
33
23
  loadSummary: () => Promise<RunSummary>;
34
24
  loadForWorkflow: (workflowId: number, workflowName: string, opts?: {
@@ -77,4 +67,3 @@ export declare function useIntegrationsRuns(): {
77
67
  }>;
78
68
  getStackTrace: (workflowId: number | string, runId: number | string) => Promise<string>;
79
69
  };
80
- export {};