@revenexx/studio-integrations 0.6.0 → 0.7.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 (110) hide show
  1. package/CHANGELOG.md +227 -0
  2. package/README.md +31 -0
  3. package/dist/module.d.mts +17 -1
  4. package/dist/module.json +1 -1
  5. package/dist/module.mjs +10 -2
  6. package/dist/runtime/components/Integrations/CredentialCreateDialog.vue +29 -28
  7. package/dist/runtime/components/Integrations/CredentialDetailDialog.vue +38 -37
  8. package/dist/runtime/components/Integrations/CredentialRevenexxSource.vue +58 -58
  9. package/dist/runtime/components/Integrations/CredentialTypePicker.vue +10 -9
  10. package/dist/runtime/components/Integrations/DeadLettersPanel.vue +18 -17
  11. package/dist/runtime/components/Integrations/EventTypeCombobox.vue +4 -3
  12. package/dist/runtime/components/Integrations/ExpressionInput.vue +16 -15
  13. package/dist/runtime/components/Integrations/GroupFrameView.vue +29 -19
  14. package/dist/runtime/components/Integrations/Layout.vue +28 -3
  15. package/dist/runtime/components/Integrations/NodeConfigDynamicField.vue +15 -14
  16. package/dist/runtime/components/Integrations/NodeConfigDynamicSchema.vue +9 -8
  17. package/dist/runtime/components/Integrations/NodeConfigField.vue +43 -42
  18. package/dist/runtime/components/Integrations/NodeInspector.vue +225 -216
  19. package/dist/runtime/components/Integrations/NodeOutputSchema.d.vue.ts +1 -1
  20. package/dist/runtime/components/Integrations/NodeOutputSchema.vue +3 -2
  21. package/dist/runtime/components/Integrations/NodeOutputSchema.vue.d.ts +1 -1
  22. package/dist/runtime/components/Integrations/NodePalette.vue +30 -29
  23. package/dist/runtime/components/Integrations/QueryBuilder.vue +44 -43
  24. package/dist/runtime/components/Integrations/RunActionsMenu.vue +36 -12
  25. package/dist/runtime/components/Integrations/RunDetailPanel.vue +114 -62
  26. package/dist/runtime/components/Integrations/RunPayloadInput.vue +14 -13
  27. package/dist/runtime/components/Integrations/RunResponseModal.vue +11 -10
  28. package/dist/runtime/components/Integrations/RunsTable.d.vue.ts +0 -1
  29. package/dist/runtime/components/Integrations/RunsTable.vue +38 -16
  30. package/dist/runtime/components/Integrations/RunsTable.vue.d.ts +0 -1
  31. package/dist/runtime/components/Integrations/SftpAccountImportDialog.vue +8 -7
  32. package/dist/runtime/components/Integrations/Sidebar.vue +14 -10
  33. package/dist/runtime/components/Integrations/StickyNoteView.d.vue.ts +14 -4
  34. package/dist/runtime/components/Integrations/StickyNoteView.vue +37 -23
  35. package/dist/runtime/components/Integrations/StickyNoteView.vue.d.ts +14 -4
  36. package/dist/runtime/components/Integrations/TemplateCard.vue +16 -6
  37. package/dist/runtime/components/Integrations/TemplateFlowPreview.vue +10 -9
  38. package/dist/runtime/components/Integrations/WorkflowContextMenu.vue +34 -33
  39. package/dist/runtime/components/Integrations/WorkflowCreateDialog.vue +36 -35
  40. package/dist/runtime/components/Integrations/WorkflowDuplicateDialog.vue +8 -7
  41. package/dist/runtime/components/Integrations/WorkflowEdgeView.vue +6 -4
  42. package/dist/runtime/components/Integrations/WorkflowEditor.vue +82 -79
  43. package/dist/runtime/components/Integrations/WorkflowHistoryPanel.vue +24 -23
  44. package/dist/runtime/components/Integrations/WorkflowIconPicker.vue +7 -6
  45. package/dist/runtime/components/Integrations/WorkflowNodeView.vue +19 -20
  46. package/dist/runtime/components/Integrations/WorkflowRunReplayPanel.vue +52 -39
  47. package/dist/runtime/components/Integrations/WorkflowSettingsDialog.vue +20 -19
  48. package/dist/runtime/components/Integrations/WorkflowTagsInput.vue +9 -8
  49. package/dist/runtime/components/Integrations/WorkflowTriggerView.vue +12 -9
  50. package/dist/runtime/components/Integrations/WorkflowVariablesDialog.vue +27 -26
  51. package/dist/runtime/components/Integrations/WorkflowsTable.vue +83 -45
  52. package/dist/runtime/composables/integrationsApi.js +4 -2
  53. package/dist/runtime/composables/useCredentialTypes.js +4 -2
  54. package/dist/runtime/composables/useIntegrationsCredentials.js +6 -4
  55. package/dist/runtime/composables/useIntegrationsFocus.d.ts +12 -0
  56. package/dist/runtime/composables/useIntegrationsLocale.d.ts +26 -0
  57. package/dist/runtime/composables/useIntegrationsLocale.js +8 -0
  58. package/dist/runtime/composables/useIntegrationsNodes.js +3 -1
  59. package/dist/runtime/composables/useIntegrationsRealtime.d.ts +149 -0
  60. package/dist/runtime/composables/useIntegrationsRealtime.js +80 -0
  61. package/dist/runtime/composables/useIntegrationsRevisions.js +9 -7
  62. package/dist/runtime/composables/useIntegrationsRuns.d.ts +1 -1
  63. package/dist/runtime/composables/useIntegrationsRuns.js +3 -1
  64. package/dist/runtime/composables/useIntegrationsSecrets.js +4 -2
  65. package/dist/runtime/composables/useIntegrationsT.d.ts +35 -0
  66. package/dist/runtime/composables/useIntegrationsT.js +9 -0
  67. package/dist/runtime/composables/useIntegrationsTemplates.d.ts +3 -3
  68. package/dist/runtime/composables/useIntegrationsTemplates.js +16 -8
  69. package/dist/runtime/composables/useIntegrationsTriggers.js +4 -2
  70. package/dist/runtime/composables/useIntegrationsWorkflows.js +5 -3
  71. package/dist/runtime/composables/useNodeConfigResolve.js +2 -1
  72. package/dist/runtime/data/integrationNodeCatalog.d.ts +41 -0
  73. package/dist/runtime/data/integrationNodeCatalog.js +14 -0
  74. package/dist/runtime/data/integrationTemplates.d.ts +9 -0
  75. package/dist/runtime/data/integrationTemplates.js +7 -1
  76. package/dist/runtime/data/payloadForm.js +2 -1
  77. package/dist/runtime/data/workflowIconCatalog.js +2 -1
  78. package/dist/runtime/i18n/messages/de.json +1207 -0
  79. package/dist/runtime/i18n/messages/en.json +1207 -0
  80. package/dist/runtime/i18n/translate.d.ts +83 -0
  81. package/dist/runtime/i18n/translate.js +41 -0
  82. package/dist/runtime/pages/integrations/audit.vue +52 -34
  83. package/dist/runtime/pages/integrations/credentials/oauth-complete.vue +3 -2
  84. package/dist/runtime/pages/integrations/credentials.vue +70 -52
  85. package/dist/runtime/pages/integrations/index.vue +50 -56
  86. package/dist/runtime/pages/integrations/runs.vue +33 -21
  87. package/dist/runtime/pages/integrations/schedules.vue +49 -28
  88. package/dist/runtime/pages/integrations/secrets.vue +34 -34
  89. package/dist/runtime/pages/integrations/templates/[slug].vue +77 -68
  90. package/dist/runtime/pages/integrations/templates/index.vue +35 -27
  91. package/dist/runtime/pages/integrations/workflows/[id].vue +164 -99
  92. package/dist/runtime/pages/integrations/workflows/index.vue +10 -5
  93. package/dist/runtime/plugins/locale.d.ts +10 -0
  94. package/dist/runtime/plugins/locale.js +6 -0
  95. package/dist/runtime/plugins/realtime.client.d.ts +32 -0
  96. package/dist/runtime/plugins/realtime.client.js +4 -0
  97. package/dist/runtime/utils/credentialState.d.ts +24 -1
  98. package/dist/runtime/utils/credentialState.js +26 -11
  99. package/dist/runtime/utils/localize.d.ts +18 -5
  100. package/dist/runtime/utils/localize.js +32 -9
  101. package/dist/runtime/utils/pollBudget.d.ts +72 -0
  102. package/dist/runtime/utils/pollBudget.js +17 -0
  103. package/dist/runtime/utils/schedule.d.ts +0 -25
  104. package/dist/runtime/utils/schedule.js +37 -25
  105. package/dist/runtime/utils/statusStyle.d.ts +26 -2
  106. package/dist/runtime/utils/statusStyle.js +9 -1
  107. package/dist/runtime/utils/validationFindings.d.ts +2 -2
  108. package/dist/runtime/utils/validationFindings.js +4 -3
  109. package/dist/types.d.mts +2 -6
  110. package/package.json +3 -2
@@ -24,6 +24,7 @@ const props = defineProps({
24
24
  });
25
25
  const emit = defineEmits(["restored"]);
26
26
  const revisionsApi = useIntegrationsRevisions();
27
+ const t = useIntegrationsT();
27
28
  const entries = ref([]);
28
29
  const page = ref(1);
29
30
  const lastPage = ref(1);
@@ -110,7 +111,7 @@ function buildPreview(blob) {
110
111
  const nodePos = (id) => {
111
112
  const wn = blob.nodes.find((n) => n.id === id);
112
113
  if (wn) return { x: wn.position?.x ?? 0, y: wn.position?.y ?? 0 };
113
- const ti = blob.triggers.findIndex((t) => t.id === id);
114
+ const ti = blob.triggers.findIndex((t2) => t2.id === id);
114
115
  if (ti >= 0) return triggerPos(id, ti);
115
116
  return { x: 0, y: 0 };
116
117
  };
@@ -149,16 +150,16 @@ function buildPreview(blob) {
149
150
  position: { x: s.position.x, y: s.position.y },
150
151
  zIndex: -100 + i,
151
152
  draggable: false,
152
- data: { sticky: s, onUpdate: () => {
153
- }, onRemove: () => {
154
- } }
153
+ // No callbacks: that absence is what makes the note read-only — it then
154
+ // draws no toolbar, no resize handle and does not open for typing (PO-284).
155
+ data: { sticky: s }
155
156
  })),
156
- ...blob.triggers.map((t, i) => ({
157
- id: t.id,
157
+ ...blob.triggers.map((t2, i) => ({
158
+ id: t2.id,
158
159
  type: "trigger",
159
- position: triggerPos(t.id, i),
160
+ position: triggerPos(t2.id, i),
160
161
  draggable: false,
161
- data: t
162
+ data: t2
162
163
  })),
163
164
  ...blob.nodes.map((n) => ({
164
165
  id: n.id,
@@ -218,23 +219,23 @@ async function select(revision) {
218
219
  buildPreview(detail.blob);
219
220
  }
220
221
  } catch (e) {
221
- toast.error(e?.message ?? "Could not load the revision.");
222
+ toast.error(e?.message ?? t("integration.history.toast.loadRevisionFailed"));
222
223
  } finally {
223
224
  detailLoading.value = false;
224
225
  }
225
226
  }
226
227
  async function restoreRevision(rev) {
227
- if (!confirm(`Restore revision ${rev.revision}? The current version is archived first, then the workflow is rebuilt from this snapshot.`)) return;
228
+ if (!confirm(t("integration.history.confirm.restore", { revision: rev.revision }))) return;
228
229
  restoring.value = rev.revision;
229
230
  try {
230
231
  await revisionsApi.restore(props.workflow.id, rev.revision);
231
- toast.success(`Revision ${rev.revision} restored`);
232
+ toast.success(t("integration.history.toast.restored", { revision: rev.revision }));
232
233
  detailCache.clear();
233
234
  emit("restored");
234
235
  await loadList(true);
235
236
  void select(null);
236
237
  } catch (e) {
237
- toast.error(e?.message ?? "Could not restore the revision.");
238
+ toast.error(e?.message ?? t("integration.history.toast.restoreFailed"));
238
239
  } finally {
239
240
  restoring.value = null;
240
241
  }
@@ -259,7 +260,7 @@ const nodeTypes = {
259
260
  const edgeTypes = {
260
261
  flow: markRaw(WorkflowEdgeView)
261
262
  };
262
- const previewTitle = computed(() => selected.value === null ? `Current version \xB7 revision ${props.workflow.revision}` : `Revision ${selected.value} (read-only)`);
263
+ const previewTitle = computed(() => selected.value === null ? t("integration.history.preview.title.current", { revision: props.workflow.revision }) : t("integration.history.preview.title.revision", { revision: selected.value }));
263
264
  onMounted(() => {
264
265
  void loadList(true);
265
266
  void select(null);
@@ -311,9 +312,9 @@ onMounted(() => {
311
312
  <!-- Revision list -->
312
313
  <aside class="flex w-80 shrink-0 flex-col border-l border-black/[0.06] bg-white">
313
314
  <div class="border-b border-black/[0.06] px-3 py-2">
314
- <div class="text-sm font-semibold">Version history</div>
315
+ <div class="text-sm font-semibold">{{ t("integration.history.heading.title") }}</div>
315
316
  <p class="text-sm text-muted-foreground">
316
- Every save archives the previous revision; the newest one is what runs while the workflow is active.
317
+ {{ t("integration.history.heading.description") }}
317
318
  </p>
318
319
  </div>
319
320
 
@@ -327,17 +328,17 @@ onMounted(() => {
327
328
  >
328
329
  <div class="flex items-center justify-between gap-2">
329
330
  <span class="text-sm font-medium tabular-nums">{{ formatDateTime(workflow.updated_at) }}</span>
330
- <Badge variant="outline" class="text-xs font-medium uppercase border-emerald-500/30 text-emerald-700">Current</Badge>
331
+ <Badge variant="outline" class="text-xs font-medium uppercase border-emerald-500/30 text-emerald-700">{{ t("integration.history.badge.current") }}</Badge>
331
332
  <Badge
332
333
  variant="outline"
333
334
  class="text-xs font-medium uppercase"
334
335
  :class="workflow.active ? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700' : 'border-black/[0.08] text-muted-foreground'"
335
- :title="workflow.active ? 'Triggers are armed; this revision is what runs.' : 'Workflow is paused; activating runs this revision.'"
336
+ :title="workflow.active ? t('integration.history.badge.activeHint') : t('integration.history.badge.pausedHint')"
336
337
  >
337
- {{ workflow.active ? "Active" : "Paused" }}
338
+ {{ workflow.active ? t("integration.history.badge.active") : t("integration.history.badge.paused") }}
338
339
  </Badge>
339
340
  </div>
340
- <div class="mt-0.5 text-sm text-muted-foreground">Revision {{ workflow.revision }}</div>
341
+ <div class="mt-0.5 text-sm text-muted-foreground">{{ t("integration.history.list.revisionLabel", { revision: workflow.revision }) }}</div>
341
342
  </button>
342
343
 
343
344
  <!-- Archived revisions -->
@@ -351,7 +352,7 @@ onMounted(() => {
351
352
  <div class="flex items-center justify-between gap-2">
352
353
  <span class="text-sm font-medium tabular-nums">{{ formatDateTime(rev.archived_at) }}</span>
353
354
  </div>
354
- <div class="mt-0.5 text-sm text-muted-foreground">Revision {{ rev.revision }}</div>
355
+ <div class="mt-0.5 text-sm text-muted-foreground">{{ t("integration.history.list.revisionLabel", { revision: rev.revision }) }}</div>
355
356
  </button>
356
357
  <div class="absolute right-2 top-1/2 -translate-y-1/2 opacity-0 transition group-hover:opacity-100">
357
358
  <Button
@@ -362,13 +363,13 @@ onMounted(() => {
362
363
  @click.stop="restoreRevision(rev)"
363
364
  >
364
365
  <Loader2 v-if="restoring === rev.revision" :size="11" class="mr-1 animate-spin" />
365
- Restore
366
+ {{ t("integration.history.action.restore") }}
366
367
  </Button>
367
368
  </div>
368
369
  </div>
369
370
 
370
371
  <p v-if="!entries.length && !listLoading" class="px-2 py-6 text-center text-sm text-muted-foreground">
371
- No archived revisions yet — save a change to create the first snapshot.
372
+ {{ t("integration.history.state.empty") }}
372
373
  </p>
373
374
  <p v-if="revisionsApi.error.value" class="px-2 py-2 text-sm text-rose-600">
374
375
  {{ revisionsApi.error.value }}
@@ -393,7 +394,7 @@ onMounted(() => {
393
394
  class="w-full text-sm"
394
395
  @click="loadMore"
395
396
  >
396
- Load older revisions
397
+ {{ t("integration.history.action.loadMore") }}
397
398
  </Button>
398
399
  </div>
399
400
  </aside>
@@ -8,6 +8,7 @@ const model = defineModel({ type: [Object, null], ...{ default: null } });
8
8
  const props = defineProps({
9
9
  triggerLabelledby: { type: String, required: false }
10
10
  });
11
+ const t = useIntegrationsT();
11
12
  const valueId = useId();
12
13
  const triggerLabelledby = computed(
13
14
  () => props.triggerLabelledby ? `${props.triggerLabelledby} ${valueId}` : void 0
@@ -26,10 +27,10 @@ function isImage(id) {
26
27
  return (imageIdFromSrc(model.value.src) ?? model.value.src) === id;
27
28
  }
28
29
  const selectedLabel = computed(() => {
29
- if (!model.value) return "Default icon";
30
- if (model.value.kind === "solar") return model.value.name ?? "Icon";
30
+ if (!model.value) return t("integration.workflows.iconPicker.label.default");
31
+ if (model.value.kind === "solar") return model.value.name ?? t("integration.workflows.iconPicker.label.iconFallback");
31
32
  const id = imageIdFromSrc(model.value.src) ?? model.value.src;
32
- return logos.value.find((l) => l.id === id)?.label ?? "Image";
33
+ return logos.value.find((l) => l.id === id)?.label ?? t("integration.workflows.iconPicker.label.imageFallback");
33
34
  });
34
35
  </script>
35
36
 
@@ -57,8 +58,8 @@ const selectedLabel = computed(() => {
57
58
  type="button"
58
59
  class="flex size-8 items-center justify-center rounded-md border text-sm text-muted-foreground transition hover:bg-muted/50"
59
60
  :class="!model ? 'border-emerald-500/40 bg-emerald-500/10' : 'border-transparent'"
60
- title="No icon (default glyph)"
61
- aria-label="No icon (default glyph)"
61
+ :title="t('integration.workflows.iconPicker.action.none')"
62
+ :aria-label="t('integration.workflows.iconPicker.action.none')"
62
63
  @click="pick(null)"
63
64
  >
64
65
  —
@@ -77,7 +78,7 @@ const selectedLabel = computed(() => {
77
78
  </div>
78
79
 
79
80
  <template v-if="logos.length">
80
- <div class="text-mono text-muted-foreground">Integrations</div>
81
+ <div class="text-mono text-muted-foreground">{{ t("integration.workflows.iconPicker.section.integrations") }}</div>
81
82
  <div class="flex flex-wrap gap-1">
82
83
  <button
83
84
  v-for="l in logos"
@@ -3,6 +3,7 @@ import { computed, inject, nextTick, 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
+ import { Copy, TrashBinMinimalistic as TrashBin } from "#solar-icons";
6
7
  import { NODE_CATEGORIES, findManifest } from "../../data/integrationNodeCatalog";
7
8
  import { isTerminalManifest } from "../../data/nodeInputData";
8
9
  import { resolveDisplayName, resolveLocalized } from "../../utils/localize";
@@ -13,6 +14,7 @@ const props = defineProps({
13
14
  });
14
15
  defineOptions({ inheritAttrs: false });
15
16
  defineEmits(["updateNodeInternals"]);
17
+ const t = useIntegrationsT();
16
18
  const manifest = computed(() => findManifest(props.data.nodeSlug, props.data.nodeVersion));
17
19
  const category = computed(() => NODE_CATEGORIES.find((c) => c.key === manifest.value?.category));
18
20
  const title = computed(
@@ -20,10 +22,7 @@ const title = computed(
20
22
  );
21
23
  const inputRequired = computed(() => manifest.value?.input?.required !== false);
22
24
  const invalidFields = computed(() => props.data.__invalidFields ?? []);
23
- const invalidLabel = computed(() => {
24
- const n = invalidFields.value.length;
25
- return `Config incomplete \u2014 ${n} setting${n === 1 ? "" : "s"} still need${n === 1 ? "s" : ""} fixing: ${invalidFields.value.join(", ")}`;
26
- });
25
+ const invalidLabel = computed(() => t("integration.canvas.node.badge.invalidConfig", invalidFields.value.length, { fields: invalidFields.value.join(", ") }));
27
26
  const notes = computed(() => (props.data.notes ?? "").trim());
28
27
  const noteOpen = ref(false);
29
28
  const noteReady = ref(false);
@@ -62,12 +61,12 @@ const outputs = computed(() => {
62
61
  const resolved = props.data.__resolvedOutputs;
63
62
  if (Array.isArray(resolved) && resolved.length) {
64
63
  const ports2 = portsFromOutputs(resolved);
65
- if (!ports2.length) ports2.push({ name: "default", kind: "default", label: "out" });
64
+ if (!ports2.length) ports2.push({ name: "default", kind: "default", label: t("integration.canvas.node.port.outputFallback") });
66
65
  return ports2;
67
66
  }
68
- if (!m) return [{ name: "default", kind: "default", label: "out" }];
67
+ if (!m) return [{ name: "default", kind: "default", label: t("integration.canvas.node.port.outputFallback") }];
69
68
  const ports = portsFromOutputs(m.outputs ?? []);
70
- if (!ports.length) ports.push({ name: "default", kind: "default", label: "out" });
69
+ if (!ports.length) ports.push({ name: "default", kind: "default", label: t("integration.canvas.node.port.outputFallback") });
71
70
  return ports;
72
71
  });
73
72
  function portSemantic(p) {
@@ -183,21 +182,21 @@ function remove() {
183
182
  <button
184
183
  type="button"
185
184
  class="flex h-6 items-center gap-1 rounded px-1.5 text-sm text-foreground/80 hover:bg-muted/50"
186
- title="Duplicate"
187
- aria-label="Duplicate node"
185
+ :title="t('integration.canvas.node.action.duplicate')"
186
+ :aria-label="t('integration.canvas.node.action.duplicateAria')"
188
187
  @click="duplicate"
189
188
  >
190
- <Lucide.Copy :size="11" />
189
+ <Copy :size="11" />
191
190
  </button>
192
191
  <div class="h-4 w-px bg-black/10" />
193
192
  <button
194
193
  type="button"
195
194
  class="flex h-6 items-center gap-1 rounded px-1.5 text-sm text-rose-600 hover:bg-rose-50"
196
- title="Delete"
197
- aria-label="Delete node"
195
+ :title="t('integration.common.action.delete')"
196
+ :aria-label="t('integration.canvas.node.action.deleteAria')"
198
197
  @click="remove"
199
198
  >
200
- <Lucide.Trash2 :size="11" />
199
+ <TrashBin :size="11" />
201
200
  </button>
202
201
  </NodeToolbar>
203
202
 
@@ -219,7 +218,7 @@ function remove() {
219
218
  >
220
219
  <div ref="notePanel" role="tooltip">
221
220
  <div class="mb-1 text-[8px] font-semibold uppercase tracking-[0.08em] text-muted-foreground/60">
222
- Note
221
+ {{ t("integration.canvas.node.note.heading") }}
223
222
  </div>
224
223
  <p class="whitespace-pre-wrap break-words text-[11px] leading-snug text-foreground/80">{{ notes }}</p>
225
224
  </div>
@@ -234,7 +233,7 @@ function remove() {
234
233
  :key="`in-${side}`"
235
234
  type="target"
236
235
  :position="sideToPosition(side)"
237
- title="Input"
236
+ :title="t('integration.canvas.node.handle.input')"
238
237
  class="!size-3 !rounded-full !border-2 !border-white !bg-slate-500 !shadow-sm transition-all hover:!size-3.5 hover:!bg-slate-700"
239
238
  />
240
239
  </template>
@@ -288,8 +287,8 @@ function remove() {
288
287
  v-if="canConfigure"
289
288
  type="button"
290
289
  class="nodrag absolute right-1.5 top-1.5 z-10 flex size-5 items-center justify-center rounded text-muted-foreground/50 transition-colors hover:bg-muted hover:text-foreground"
291
- title="Configure"
292
- aria-label="Configure"
290
+ :title="t('integration.canvas.node.action.configure')"
291
+ :aria-label="t('integration.canvas.node.action.configure')"
293
292
  @click.stop="configure"
294
293
  @mousedown.stop
295
294
  @dblclick.stop
@@ -305,7 +304,7 @@ function remove() {
305
304
  type="button"
306
305
  class="nodrag absolute right-1.5 z-10 flex size-5 items-center justify-center rounded text-muted-foreground/45 transition-colors hover:bg-muted hover:text-foreground"
307
306
  :class="showPortLabels ? 'bottom-4' : 'bottom-1.5'"
308
- aria-label="Note"
307
+ :aria-label="t('integration.canvas.node.note.heading')"
309
308
  :aria-describedby="notePanelId"
310
309
  @mouseenter="openNote"
311
310
  @mouseleave="releaseNote"
@@ -332,7 +331,7 @@ function remove() {
332
331
  class="pointer-events-none absolute bottom-1 left-1/2 flex -translate-x-1/2 select-none items-center gap-1 whitespace-nowrap text-[9px] font-semibold uppercase tracking-wide text-muted-foreground"
333
332
  >
334
333
  <Lucide.LoaderCircle :size="9" class="animate-spin" />
335
- Ports
334
+ {{ t("integration.canvas.node.port.resolving") }}
336
335
  </span>
337
336
  <template v-else>
338
337
  <span
@@ -364,7 +363,7 @@ function remove() {
364
363
  <div v-if="data.nodeVersion" class="mt-0.5 flex items-center gap-1.5 text-sm text-muted-foreground">
365
364
  <span
366
365
  class="shrink-0 text-[10px] font-medium leading-none tabular-nums text-muted-foreground/55"
367
- :title="`Version ${data.nodeVersion}`"
366
+ :title="t('integration.canvas.node.meta.versionTitle', { version: data.nodeVersion })"
368
367
  >v{{ data.nodeVersion }}</span>
369
368
  </div>
370
369
  </div>
@@ -26,6 +26,7 @@ import StickyNoteView from "./StickyNoteView.vue";
26
26
  import GroupFrameView from "./GroupFrameView.vue";
27
27
  import WorkflowEdgeView from "./WorkflowEdgeView.vue";
28
28
  import { findManifest } from "../../data/integrationNodeCatalog";
29
+ import { SAFETY_POLL_MS } from "../../utils/pollBudget";
29
30
  import { runStatusLabel, statusClass } from "../../utils/statusStyle";
30
31
  const props = defineProps({
31
32
  workflow: { type: Object, required: true },
@@ -33,9 +34,14 @@ const props = defineProps({
33
34
  single: { type: Boolean, required: false }
34
35
  });
35
36
  const { loadForWorkflow, getSteps } = useIntegrationsRuns();
37
+ const t = useIntegrationsT();
36
38
  const TERMINAL = /* @__PURE__ */ new Set(["completed", "succeeded", "failed", "cancelled", "terminated"]);
37
39
  const STEPS_POLL_MS = 4e3;
38
40
  const PLAY_INTERVAL_MS = 3e3;
41
+ const { delivering: realtimeDelivering, runTick, lastRunEvent } = useIntegrationsRealtime();
42
+ function stepsPollDelay() {
43
+ return realtimeDelivering.value ? SAFETY_POLL_MS : STEPS_POLL_MS;
44
+ }
39
45
  const runs = ref([]);
40
46
  const runsPage = ref(1);
41
47
  const runsLastPage = ref(1);
@@ -53,7 +59,7 @@ async function loadRuns(reset = false) {
53
59
  runsPage.value = res.page;
54
60
  runsLastPage.value = res.lastPage;
55
61
  } catch (e) {
56
- toast.error(e?.message ?? "Could not load the runs.");
62
+ toast.error(e?.message ?? t("integration.replay.toast.loadRunsFailed"));
57
63
  } finally {
58
64
  runsLoading.value = false;
59
65
  }
@@ -91,16 +97,23 @@ async function loadSteps(runId, isPoll = false) {
91
97
  if (runStatus.value && !TERMINAL.has(runStatus.value)) {
92
98
  pollTimer = window.setTimeout(() => {
93
99
  void loadSteps(runId, true);
94
- }, STEPS_POLL_MS);
100
+ }, stepsPollDelay());
95
101
  }
96
102
  } catch (e) {
97
103
  if (selectedRunId.value === runId) {
98
- stepsError.value = e?.message ?? "Could not load the run steps.";
104
+ stepsError.value = e?.message ?? t("integration.replay.error.loadStepsFailed");
99
105
  }
100
106
  } finally {
101
107
  if (!isPoll) stepsLoading.value = false;
102
108
  }
103
109
  }
110
+ watch(runTick, () => {
111
+ const runId = selectedRunId.value;
112
+ if (!runId) return;
113
+ const event = lastRunEvent.value;
114
+ if (event && event.topicId !== runId) return;
115
+ void loadSteps(runId, true);
116
+ });
104
117
  function selectRun(id) {
105
118
  if (selectedRunId.value === id) return;
106
119
  selectedRunId.value = id;
@@ -173,7 +186,7 @@ const finalStatus = computed(() => {
173
186
  return m;
174
187
  });
175
188
  const runNodeIds = computed(() => new Set(steps.value.map((s) => s.node_id)));
176
- const triggerIds = computed(() => new Set(props.workflow.blob.triggers.map((t) => t.id)));
189
+ const triggerIds = computed(() => new Set(props.workflow.blob.triggers.map((t2) => t2.id)));
177
190
  function edgeColorByKind(kind) {
178
191
  return kind === "error" ? "#cbd5e1" : "#94a3b8";
179
192
  }
@@ -230,7 +243,7 @@ const vfNodes = computed(() => {
230
243
  const posOf = (id) => {
231
244
  const wn = blob.nodes.find((n) => n.id === id);
232
245
  if (wn) return { x: wn.position?.x ?? 0, y: wn.position?.y ?? 0 };
233
- const ti = blob.triggers.findIndex((t) => t.id === id);
246
+ const ti = blob.triggers.findIndex((t2) => t2.id === id);
234
247
  if (ti >= 0) return triggerPos(id, ti);
235
248
  return { x: 0, y: 0 };
236
249
  };
@@ -244,17 +257,17 @@ const vfNodes = computed(() => {
244
257
  zIndex: -100 + i,
245
258
  draggable: false,
246
259
  class: "replay-dim",
247
- data: { sticky: s, onUpdate: () => {
248
- }, onRemove: () => {
249
- } }
260
+ // No callbacks: that absence is what makes the note read-only — it then
261
+ // draws no toolbar, no resize handle and does not open for typing (PO-284).
262
+ data: { sticky: s }
250
263
  })),
251
- ...blob.triggers.map((t, i) => ({
252
- id: t.id,
264
+ ...blob.triggers.map((t2, i) => ({
265
+ id: t2.id,
253
266
  type: "trigger",
254
- position: triggerPos(t.id, i),
267
+ position: triggerPos(t2.id, i),
255
268
  draggable: false,
256
- class: replayNodeClass(t.id, "trigger"),
257
- data: t
269
+ class: replayNodeClass(t2.id, "trigger"),
270
+ data: t2
258
271
  })),
259
272
  ...blob.nodes.map((n) => ({
260
273
  id: n.id,
@@ -274,7 +287,7 @@ const vfEdges = computed(() => {
274
287
  const nodePos = (id) => {
275
288
  const wn = blob.nodes.find((n) => n.id === id);
276
289
  if (wn) return { x: wn.position?.x ?? 0, y: wn.position?.y ?? 0 };
277
- const ti = blob.triggers.findIndex((t) => t.id === id);
290
+ const ti = blob.triggers.findIndex((t2) => t2.id === id);
278
291
  if (ti >= 0) return blob.triggerPositions?.[id] ?? { x: -40, y: 60 + ti * 140 };
279
292
  return { x: 0, y: 0 };
280
293
  };
@@ -458,12 +471,12 @@ watch(() => props.initialRunId, (id) => {
458
471
  <!-- Which run is on the canvas -->
459
472
  <div class="absolute top-3 left-3 z-10 flex items-center gap-2 rounded-lg border border-black/[0.08] bg-white/95 px-2.5 py-1.5 text-sm shadow-sm backdrop-blur-sm">
460
473
  <Play :size="13" class="text-muted-foreground" />
461
- <span class="font-medium">{{ selectedRunId ? `Run #${selectedRunId}` : "Pick a run" }}</span>
474
+ <span class="font-medium">{{ selectedRunId ? t("integration.replay.canvas.runLabel", { id: selectedRunId }) : t("integration.replay.canvas.pickRun") }}</span>
462
475
  <Badge v-if="runStatus" variant="outline" class="border text-xs font-medium uppercase" :class="statusClass(runStatus)">
463
476
  {{ runStatusLabel(runStatus) }}
464
477
  </Badge>
465
478
  <Badge v-if="stepsSource" variant="outline" class="border text-xs font-medium uppercase text-muted-foreground">
466
- {{ stepsSource === "temporal" ? "Live" : "Snapshot" }}
479
+ {{ stepsSource === "temporal" ? t("integration.replay.badge.live") : t("integration.replay.badge.snapshot") }}
467
480
  </Badge>
468
481
  <Loader2 v-if="stepsLoading" :size="13" class="animate-spin text-muted-foreground" />
469
482
  </div>
@@ -479,14 +492,14 @@ watch(() => props.initialRunId, (id) => {
479
492
  <div class="flex items-center gap-1">
480
493
  <Button variant="ghost" size="sm" class="h-7" :disabled="safeCursor === 0" @click="seek(safeCursor - 1)">
481
494
  <Prev :size="13" />
482
- Prev
495
+ {{ t("integration.replay.transport.action.prev") }}
483
496
  </Button>
484
497
  <Button size="sm" class="h-7" @click="togglePlay">
485
498
  <component :is="playing ? Pause : Play" :size="13" class="mr-1" />
486
- {{ playing ? "Pause" : "Play" }}
499
+ {{ playing ? t("integration.replay.transport.action.pause") : t("integration.replay.transport.action.play") }}
487
500
  </Button>
488
501
  <Button variant="ghost" size="sm" class="h-7" :disabled="safeCursor >= steps.length - 1" @click="seek(safeCursor + 1)">
489
- Next
502
+ {{ t("integration.replay.transport.action.next") }}
490
503
  <Next :size="13" />
491
504
  </Button>
492
505
  </div>
@@ -497,17 +510,17 @@ watch(() => props.initialRunId, (id) => {
497
510
  :max="steps.length - 1"
498
511
  :value="safeCursor"
499
512
  class="h-1.5 flex-1 cursor-pointer accent-primary"
500
- aria-label="Replay step"
513
+ :aria-label="t('integration.replay.transport.scrubberAria')"
501
514
  @input="seek(Number($event.target.value))"
502
515
  >
503
516
 
504
517
  <span class="shrink-0 text-sm tabular-nums text-muted-foreground">
505
- Step {{ safeCursor + 1 }}/{{ steps.length }}
518
+ {{ t("integration.replay.transport.stepCounter", { current: safeCursor + 1, total: steps.length }) }}
506
519
  <span class="font-mono">· {{ activeStepTitle }}</span>
507
520
  </span>
508
521
  </template>
509
522
  <p v-else-if="!stepsLoading" class="text-sm text-muted-foreground">
510
- {{ selectedRunId ? "No steps recorded for this run." : "Pick a run on the right to replay it." }}
523
+ {{ selectedRunId ? t("integration.replay.state.noSteps") : t("integration.replay.state.pickRunHint") }}
511
524
  </p>
512
525
  </div>
513
526
  </div>
@@ -517,10 +530,10 @@ watch(() => props.initialRunId, (id) => {
517
530
  <aside class="flex w-[32rem] shrink-0 flex-col border-l border-black/[0.06] bg-white">
518
531
  <div class="flex items-center gap-2 border-b border-black/[0.06] px-3 py-2">
519
532
  <div class="min-w-0 flex-1">
520
- <div class="truncate font-mono text-sm font-medium">{{ activeStepTitle || "Debug" }}</div>
533
+ <div class="truncate font-mono text-sm font-medium">{{ activeStepTitle || t("integration.replay.debug.titleFallback") }}</div>
521
534
  <div class="text-xs tabular-nums text-muted-foreground">
522
- <template v-if="steps.length">Step {{ safeCursor + 1 }} / {{ steps.length }}</template>
523
- <template v-else>No step selected</template>
535
+ <template v-if="steps.length">{{ t("integration.replay.debug.stepCounter", { current: safeCursor + 1, total: steps.length }) }}</template>
536
+ <template v-else>{{ t("integration.replay.debug.noStepSelected") }}</template>
524
537
  </div>
525
538
  </div>
526
539
  <Badge v-if="activeStep" variant="outline" class="shrink-0 border text-xs font-medium uppercase" :class="statusClass(activeStep.status)">
@@ -531,59 +544,59 @@ watch(() => props.initialRunId, (id) => {
531
544
  <div v-if="activeStep" class="min-h-0 flex-1 space-y-4 overflow-y-auto p-3">
532
545
  <div class="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
533
546
  <div>
534
- <div class="text-mono text-muted-foreground">Attempt</div>
547
+ <div class="text-mono text-muted-foreground">{{ t("integration.replay.debug.field.attempt") }}</div>
535
548
  <div class="tabular-nums">{{ activeStep.attempt + 1 }}</div>
536
549
  </div>
537
550
  <div>
538
- <div class="text-mono text-muted-foreground">Duration</div>
551
+ <div class="text-mono text-muted-foreground">{{ t("integration.replay.debug.field.duration") }}</div>
539
552
  <div class="tabular-nums">{{ formatDuration(activeStep.duration_ms) }}</div>
540
553
  </div>
541
554
  <div>
542
- <div class="text-mono text-muted-foreground">Started</div>
555
+ <div class="text-mono text-muted-foreground">{{ t("integration.replay.debug.field.started") }}</div>
543
556
  <div class="tabular-nums">{{ formatTime(activeStep.started_at) }}</div>
544
557
  </div>
545
558
  <div>
546
- <div class="text-mono text-muted-foreground">Completed</div>
559
+ <div class="text-mono text-muted-foreground">{{ t("integration.replay.debug.field.completed") }}</div>
547
560
  <div class="tabular-nums">{{ formatTime(activeStep.completed_at) }}</div>
548
561
  </div>
549
562
  </div>
550
563
 
551
564
  <div v-if="activeStep.branch" class="text-sm">
552
- <div class="text-mono text-muted-foreground mb-1">Branch</div>
565
+ <div class="text-mono text-muted-foreground mb-1">{{ t("integration.replay.debug.field.branch") }}</div>
553
566
  <code class="rounded bg-black/[0.05] px-1.5 py-0.5 font-mono">{{ activeStep.branch }}</code>
554
567
  </div>
555
568
 
556
569
  <div v-if="activeStep.error" class="text-sm">
557
- <div class="text-mono text-muted-foreground mb-1">Error</div>
570
+ <div class="text-mono text-muted-foreground mb-1">{{ t("integration.replay.debug.field.error") }}</div>
558
571
  <pre class="max-h-40 overflow-auto rounded-md border border-rose-500/20 bg-rose-500/5 p-2 font-mono text-sm text-rose-700">{{ activeStep.error }}</pre>
559
572
  </div>
560
573
 
561
574
  <div v-if="activeStep.input !== void 0 && activeStep.input !== null" class="text-sm">
562
- <div class="text-mono text-muted-foreground mb-1">Input</div>
575
+ <div class="text-mono text-muted-foreground mb-1">{{ t("integration.replay.debug.field.input") }}</div>
563
576
  <pre class="max-h-56 overflow-auto rounded-md border border-black/[0.06] bg-muted/20 p-2 font-mono text-sm">{{ pretty(activeStep.input) }}</pre>
564
577
  </div>
565
578
 
566
579
  <div v-if="activeStep.config !== void 0 && activeStep.config !== null" class="text-sm">
567
- <div class="text-mono text-muted-foreground mb-1">Config</div>
580
+ <div class="text-mono text-muted-foreground mb-1">{{ t("integration.replay.debug.field.config") }}</div>
568
581
  <pre class="max-h-56 overflow-auto rounded-md border border-black/[0.06] bg-muted/20 p-2 font-mono text-sm">{{ pretty(activeStep.config) }}</pre>
569
582
  </div>
570
583
 
571
584
  <div v-if="activeStep.output !== void 0 && activeStep.output !== null" class="text-sm">
572
- <div class="text-mono text-muted-foreground mb-1">Output</div>
585
+ <div class="text-mono text-muted-foreground mb-1">{{ t("integration.replay.debug.field.output") }}</div>
573
586
  <pre class="max-h-56 overflow-auto rounded-md border border-black/[0.06] bg-muted/20 p-2 font-mono text-sm">{{ pretty(activeStep.output) }}</pre>
574
587
  </div>
575
588
  </div>
576
589
  <p v-else class="p-4 text-sm text-muted-foreground">
577
- The active step's status, timings and payloads land here while the replay walks the run.
590
+ {{ t("integration.replay.debug.emptyHint") }}
578
591
  </p>
579
592
  </aside>
580
593
 
581
594
  <!-- Run list — hidden in single mode (the run detail replays ONE run). -->
582
595
  <aside v-if="!single" class="flex w-80 shrink-0 flex-col border-l border-black/[0.06] bg-white">
583
596
  <div class="border-b border-black/[0.06] px-3 py-2">
584
- <div class="text-sm font-semibold">Runs</div>
597
+ <div class="text-sm font-semibold">{{ t("integration.replay.runsList.heading") }}</div>
585
598
  <p class="text-sm text-muted-foreground">
586
- Click through the executions to replay them on the canvas.
599
+ {{ t("integration.replay.runsList.description") }}
587
600
  </p>
588
601
  </div>
589
602
 
@@ -612,7 +625,7 @@ watch(() => props.initialRunId, (id) => {
612
625
  </button>
613
626
 
614
627
  <p v-if="!runs.length && !runsLoading" class="px-2 py-6 text-center text-sm text-muted-foreground">
615
- No runs yet — trigger one from the editor's Run button.
628
+ {{ t("integration.replay.runsList.empty") }}
616
629
  </p>
617
630
 
618
631
  <div v-if="runsLoading" class="flex justify-center py-3">
@@ -625,7 +638,7 @@ watch(() => props.initialRunId, (id) => {
625
638
  class="w-full text-sm"
626
639
  @click="loadMoreRuns"
627
640
  >
628
- Load older runs
641
+ {{ t("integration.replay.runsList.loadMore") }}
629
642
  </Button>
630
643
  </div>
631
644
  </aside>