@revenexx/studio-integrations 0.6.0 → 1.0.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 (137) hide show
  1. package/CHANGELOG.md +643 -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 +12 -3
  6. package/dist/runtime/components/Integrations/CredentialCreateDialog.vue +32 -28
  7. package/dist/runtime/components/Integrations/CredentialDetailDialog.vue +45 -38
  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 +21 -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.d.vue.ts +3 -0
  16. package/dist/runtime/components/Integrations/NodeConfigDynamicField.vue +30 -16
  17. package/dist/runtime/components/Integrations/NodeConfigDynamicField.vue.d.ts +3 -0
  18. package/dist/runtime/components/Integrations/NodeConfigDynamicSchema.vue +10 -8
  19. package/dist/runtime/components/Integrations/NodeConfigField.d.vue.ts +3 -0
  20. package/dist/runtime/components/Integrations/NodeConfigField.vue +45 -42
  21. package/dist/runtime/components/Integrations/NodeConfigField.vue.d.ts +3 -0
  22. package/dist/runtime/components/Integrations/NodeInspector.d.vue.ts +2 -2
  23. package/dist/runtime/components/Integrations/NodeInspector.vue +315 -221
  24. package/dist/runtime/components/Integrations/NodeInspector.vue.d.ts +2 -2
  25. package/dist/runtime/components/Integrations/NodeOutputSchema.d.vue.ts +2 -1
  26. package/dist/runtime/components/Integrations/NodeOutputSchema.vue +37 -10
  27. package/dist/runtime/components/Integrations/NodeOutputSchema.vue.d.ts +2 -1
  28. package/dist/runtime/components/Integrations/NodePalette.vue +40 -35
  29. package/dist/runtime/components/Integrations/QueryBuilder.vue +44 -43
  30. package/dist/runtime/components/Integrations/RunActionsMenu.vue +36 -12
  31. package/dist/runtime/components/Integrations/RunDetailPanel.vue +114 -62
  32. package/dist/runtime/components/Integrations/RunPayloadInput.vue +14 -13
  33. package/dist/runtime/components/Integrations/RunResponseModal.vue +11 -10
  34. package/dist/runtime/components/Integrations/RunsTable.d.vue.ts +0 -1
  35. package/dist/runtime/components/Integrations/RunsTable.vue +38 -16
  36. package/dist/runtime/components/Integrations/RunsTable.vue.d.ts +0 -1
  37. package/dist/runtime/components/Integrations/SftpAccountImportDialog.vue +8 -7
  38. package/dist/runtime/components/Integrations/Sidebar.vue +14 -10
  39. package/dist/runtime/components/Integrations/StickyNoteView.d.vue.ts +14 -4
  40. package/dist/runtime/components/Integrations/StickyNoteView.vue +82 -34
  41. package/dist/runtime/components/Integrations/StickyNoteView.vue.d.ts +14 -4
  42. package/dist/runtime/components/Integrations/SurfaceCard.d.vue.ts +50 -0
  43. package/dist/runtime/components/Integrations/SurfaceCard.vue +55 -0
  44. package/dist/runtime/components/Integrations/SurfaceCard.vue.d.ts +50 -0
  45. package/dist/runtime/components/Integrations/TemplateCard.vue +18 -8
  46. package/dist/runtime/components/Integrations/TemplateFlowPreview.vue +22 -12
  47. package/dist/runtime/components/Integrations/WorkflowContextMenu.vue +34 -33
  48. package/dist/runtime/components/Integrations/WorkflowCreateDialog.vue +41 -39
  49. package/dist/runtime/components/Integrations/WorkflowDuplicateDialog.vue +8 -7
  50. package/dist/runtime/components/Integrations/WorkflowEdgeView.vue +6 -4
  51. package/dist/runtime/components/Integrations/WorkflowEditor.d.vue.ts +16 -0
  52. package/dist/runtime/components/Integrations/WorkflowEditor.vue +131 -87
  53. package/dist/runtime/components/Integrations/WorkflowEditor.vue.d.ts +16 -0
  54. package/dist/runtime/components/Integrations/WorkflowHistoryPanel.d.vue.ts +6 -0
  55. package/dist/runtime/components/Integrations/WorkflowHistoryPanel.vue +27 -24
  56. package/dist/runtime/components/Integrations/WorkflowHistoryPanel.vue.d.ts +6 -0
  57. package/dist/runtime/components/Integrations/WorkflowIconPicker.vue +7 -6
  58. package/dist/runtime/components/Integrations/WorkflowNodeView.vue +76 -46
  59. package/dist/runtime/components/Integrations/WorkflowRunReplayPanel.vue +55 -43
  60. package/dist/runtime/components/Integrations/WorkflowSettingsDialog.d.vue.ts +16 -17
  61. package/dist/runtime/components/Integrations/WorkflowSettingsDialog.vue +55 -43
  62. package/dist/runtime/components/Integrations/WorkflowSettingsDialog.vue.d.ts +16 -17
  63. package/dist/runtime/components/Integrations/WorkflowTagsInput.vue +9 -8
  64. package/dist/runtime/components/Integrations/WorkflowTriggerView.vue +15 -13
  65. package/dist/runtime/components/Integrations/WorkflowVariablesDialog.vue +79 -39
  66. package/dist/runtime/components/Integrations/WorkflowsTable.vue +83 -45
  67. package/dist/runtime/composables/integrationsApi.js +4 -2
  68. package/dist/runtime/composables/useCredentialTypes.js +4 -2
  69. package/dist/runtime/composables/useIntegrationsCredentials.d.ts +11 -0
  70. package/dist/runtime/composables/useIntegrationsCredentials.js +8 -6
  71. package/dist/runtime/composables/useIntegrationsFocus.d.ts +12 -0
  72. package/dist/runtime/composables/useIntegrationsLocale.d.ts +26 -0
  73. package/dist/runtime/composables/useIntegrationsLocale.js +8 -0
  74. package/dist/runtime/composables/useIntegrationsNodes.js +14 -2
  75. package/dist/runtime/composables/useIntegrationsRealtime.d.ts +149 -0
  76. package/dist/runtime/composables/useIntegrationsRealtime.js +80 -0
  77. package/dist/runtime/composables/useIntegrationsRevisions.js +9 -7
  78. package/dist/runtime/composables/useIntegrationsRuns.d.ts +1 -1
  79. package/dist/runtime/composables/useIntegrationsRuns.js +3 -1
  80. package/dist/runtime/composables/useIntegrationsSchedules.d.ts +3 -2
  81. package/dist/runtime/composables/useIntegrationsSecrets.js +4 -2
  82. package/dist/runtime/composables/useIntegrationsT.d.ts +35 -0
  83. package/dist/runtime/composables/useIntegrationsT.js +9 -0
  84. package/dist/runtime/composables/useIntegrationsTemplates.d.ts +3 -3
  85. package/dist/runtime/composables/useIntegrationsTemplates.js +16 -8
  86. package/dist/runtime/composables/useIntegrationsTriggers.d.ts +23 -3
  87. package/dist/runtime/composables/useIntegrationsTriggers.js +21 -8
  88. package/dist/runtime/composables/useIntegrationsWorkflows.d.ts +10 -1
  89. package/dist/runtime/composables/useIntegrationsWorkflows.js +30 -7
  90. package/dist/runtime/composables/useNodeConfigResolve.js +4 -2
  91. package/dist/runtime/data/integrationNodeCatalog.d.ts +63 -1
  92. package/dist/runtime/data/integrationNodeCatalog.js +22 -4
  93. package/dist/runtime/data/integrationTemplates.d.ts +9 -0
  94. package/dist/runtime/data/integrationTemplates.js +7 -1
  95. package/dist/runtime/data/nodeInputData.d.ts +58 -1
  96. package/dist/runtime/data/nodeInputData.js +30 -5
  97. package/dist/runtime/data/payloadForm.js +2 -1
  98. package/dist/runtime/data/triggerGlyphs.d.ts +47 -0
  99. package/dist/runtime/data/triggerGlyphs.js +11 -0
  100. package/dist/runtime/data/workflowIconCatalog.js +2 -1
  101. package/dist/runtime/i18n/messages/de.json +1224 -0
  102. package/dist/runtime/i18n/messages/en.json +1224 -0
  103. package/dist/runtime/i18n/translate.d.ts +83 -0
  104. package/dist/runtime/i18n/translate.js +41 -0
  105. package/dist/runtime/pages/integrations/audit.vue +52 -34
  106. package/dist/runtime/pages/integrations/credentials/oauth-complete.vue +3 -2
  107. package/dist/runtime/pages/integrations/credentials.vue +73 -52
  108. package/dist/runtime/pages/integrations/index.vue +66 -73
  109. package/dist/runtime/pages/integrations/runs.vue +33 -21
  110. package/dist/runtime/pages/integrations/schedules.vue +49 -28
  111. package/dist/runtime/pages/integrations/secrets.vue +34 -34
  112. package/dist/runtime/pages/integrations/templates/[slug].vue +89 -72
  113. package/dist/runtime/pages/integrations/templates/index.vue +35 -27
  114. package/dist/runtime/pages/integrations/workflows/[id].vue +351 -197
  115. package/dist/runtime/pages/integrations/workflows/index.vue +10 -5
  116. package/dist/runtime/plugins/locale.d.ts +10 -0
  117. package/dist/runtime/plugins/locale.js +6 -0
  118. package/dist/runtime/plugins/realtime.client.d.ts +32 -0
  119. package/dist/runtime/plugins/realtime.client.js +4 -0
  120. package/dist/runtime/types/integrations.d.ts +25 -1
  121. package/dist/runtime/utils/cockpitSources.d.ts +22 -3
  122. package/dist/runtime/utils/credentialState.d.ts +30 -2
  123. package/dist/runtime/utils/credentialState.js +26 -11
  124. package/dist/runtime/utils/localize.d.ts +52 -5
  125. package/dist/runtime/utils/localize.js +47 -9
  126. package/dist/runtime/utils/pollBudget.d.ts +72 -0
  127. package/dist/runtime/utils/pollBudget.js +17 -0
  128. package/dist/runtime/utils/schedule.d.ts +0 -25
  129. package/dist/runtime/utils/schedule.js +37 -25
  130. package/dist/runtime/utils/servedPaths.d.ts +22 -0
  131. package/dist/runtime/utils/servedPaths.js +3 -0
  132. package/dist/runtime/utils/statusStyle.d.ts +26 -2
  133. package/dist/runtime/utils/statusStyle.js +9 -1
  134. package/dist/runtime/utils/validationFindings.d.ts +2 -2
  135. package/dist/runtime/utils/validationFindings.js +4 -3
  136. package/dist/types.d.mts +2 -6
  137. package/package.json +3 -2
@@ -1,8 +1,9 @@
1
1
  <script setup>
2
2
  import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
3
- import { AltArrowLeft as ArrowLeft, AltArrowRight as Chevron, Copy as CopyIcon, Pause as PauseIcon, RefreshCircle as Loader2, Play, Refresh as RetryIcon, Diskette as Save } from "#solar-icons";
3
+ import { AltArrowLeft as ArrowLeft, AltArrowRight as Chevron, Copy as CopyIcon, RefreshCircle as Loader2, Play, Refresh as RetryIcon, Diskette as Save, SidebarMinimalistic as RailIcon } from "#solar-icons";
4
4
  import {
5
5
  AlertDialog,
6
+ AlertDialogCancel,
6
7
  AlertDialogContent,
7
8
  AlertDialogDescription,
8
9
  AlertDialogFooter,
@@ -20,6 +21,7 @@ import {
20
21
  SelectItem,
21
22
  SelectTrigger,
22
23
  SelectValue,
24
+ Switch,
23
25
  Textarea
24
26
  } from "@revenexx/studio";
25
27
  import WorkflowEditor from "../../../components/Integrations/WorkflowEditor.vue";
@@ -31,26 +33,56 @@ import RunsTable from "../../../components/Integrations/RunsTable.vue";
31
33
  import RunDetailPanel from "../../../components/Integrations/RunDetailPanel.vue";
32
34
  import { toast } from "vue-sonner";
33
35
  import { IntegrationsApiError, apiValidationIssues } from "../../../utils/apiError";
36
+ import { createPollBudget, SAFETY_POLL_MS } from "../../../utils/pollBudget";
34
37
  import { findingsSummary, groupFindingsByNode, nodeDisplayLabel } from "../../../utils/validationFindings";
35
38
  import { payloadFieldsFromBlob } from "../../../data/payloadForm";
36
39
  import WorkflowIconView from "../../../components/Integrations/WorkflowIconView.vue";
40
+ const t = useIntegrationsT();
37
41
  const route = useRoute();
38
42
  const router = useRouter();
39
43
  const { workflows, get, update, toggleActive } = useIntegrationsWorkflows();
40
- const { autoEnable } = useIntegrationsFocus();
41
- autoEnable();
42
44
  const id = computed(() => Number(route.params.id));
43
45
  const workflow = computed(() => workflows.value.find((w) => w.id === id.value) ?? null);
44
- useHead({ title: () => `${workflow.value?.name ?? "Workflow"} \xB7 Revenexx Cockpit` });
45
46
  const tab = ref("editor");
46
- const tabs = [
47
- { key: "editor", label: "Editor" },
48
- { key: "history", label: "History" },
49
- { key: "runs", label: "Runs" },
50
- { key: "dead-letters", label: "Missed runs" }
51
- ];
52
- const dirtyBlob = ref(null);
53
- const isDirty = computed(() => dirtyBlob.value !== null);
47
+ const tabs = computed(() => [
48
+ { key: "editor", label: t("integration.editor.tab.editor") },
49
+ { key: "history", label: t("integration.editor.tab.history") },
50
+ { key: "runs", label: t("integration.editor.tab.runs") },
51
+ { key: "dead-letters", label: t("integration.editor.tab.deadLetters") }
52
+ ]);
53
+ const draft = ref(null);
54
+ const isDirty = computed(() => draft.value !== null);
55
+ function draftFrom() {
56
+ if (draft.value) return draft.value;
57
+ const w = workflow.value;
58
+ if (!w) return null;
59
+ return {
60
+ name: w.name,
61
+ description: w.description ?? null,
62
+ executionMode: w.execution_mode ?? "async_only",
63
+ tags: [...w.blob.tags ?? []],
64
+ icon: w.blob.icon ?? null,
65
+ blob: JSON.parse(JSON.stringify(w.blob))
66
+ };
67
+ }
68
+ function payloadBlob(d) {
69
+ const blob = JSON.parse(JSON.stringify(d.blob));
70
+ blob.name = d.name;
71
+ if (d.description) blob.description = d.description;
72
+ else delete blob.description;
73
+ if (d.tags.length) blob.tags = [...d.tags];
74
+ else delete blob.tags;
75
+ if (d.icon) blob.icon = d.icon;
76
+ else delete blob.icon;
77
+ return blob;
78
+ }
79
+ const shownName = computed(() => draft.value?.name ?? workflow.value?.name ?? "");
80
+ const draftedMode = computed(() => draft.value?.executionMode ?? workflow.value?.execution_mode ?? "async_only");
81
+ const shownIcon = computed(() => draft.value ? draft.value.icon : workflow.value?.blob.icon ?? null);
82
+ const shownTags = computed(() => draft.value ? draft.value.tags : [...workflow.value?.blob.tags ?? []]);
83
+ const shownDescription = computed(() => (draft.value ? draft.value.description : workflow.value?.description) ?? "");
84
+ const shownBlob = computed(() => draft.value?.blob ?? workflow.value?.blob ?? null);
85
+ useHead({ title: () => t("integration.editor.head.title", { name: shownName.value || t("integration.editor.head.untitled") }) });
54
86
  const saving = ref(false);
55
87
  const saveError = ref(null);
56
88
  const validationWarnings = computed(() => workflow.value?.warnings);
@@ -76,7 +108,7 @@ onMounted(() => {
76
108
  void loadCredentials();
77
109
  });
78
110
  const reauthCredentials = computed(() => {
79
- const blob = JSON.stringify(dirtyBlob.value ?? workflow.value?.blob ?? {});
111
+ const blob = JSON.stringify(shownBlob.value ?? {});
80
112
  return needsReauth.value.filter((c) => blob.includes(c.id));
81
113
  });
82
114
  const buildWorking = computed(() => workflow.value?.buildStatus === "building" || workflow.value?.buildStatus === "pending");
@@ -95,12 +127,12 @@ async function waitForBuildReady() {
95
127
  const status = fresh?.buildStatus;
96
128
  if (status === "pending" || status === "building") continue;
97
129
  if (status === "failed") {
98
- toast.error("The workflow bundle failed to build \u2014 the run was not started.");
130
+ toast.error(t("integration.editor.build.toast.failed"));
99
131
  return false;
100
132
  }
101
133
  return true;
102
134
  }
103
- toast.error("The bundle build is taking unusually long \u2014 try the run again once it is ready.");
135
+ toast.error(t("integration.editor.build.toast.timeout"));
104
136
  return false;
105
137
  } finally {
106
138
  waitingForBuild.value = false;
@@ -109,13 +141,13 @@ async function waitForBuildReady() {
109
141
  const buildNotice = computed(() => {
110
142
  switch (workflow.value?.buildStatus) {
111
143
  case "building":
112
- return { tone: "info", text: "The workflow bundle is still building \u2014 runs are unavailable until it is ready." };
144
+ return { tone: "info", text: t("integration.editor.build.notice.building") };
113
145
  case "pending":
114
- return { tone: "info", text: "The workflow bundle build is queued \u2014 runs are unavailable until it is ready." };
146
+ return { tone: "info", text: t("integration.editor.build.notice.pending") };
115
147
  case "failed":
116
148
  return {
117
149
  tone: "error",
118
- text: `The workflow bundle failed to build${workflow.value?.buildError ? `: ${workflow.value.buildError}` : "."} Save the workflow again to retry the build.`
150
+ text: workflow.value?.buildError ? t("integration.editor.build.notice.failedWithError", { error: workflow.value.buildError }) : t("integration.editor.build.notice.failed")
119
151
  };
120
152
  default:
121
153
  return null;
@@ -123,29 +155,67 @@ const buildNotice = computed(() => {
123
155
  });
124
156
  watch(id, async () => {
125
157
  const w = await get(id.value);
126
- if (!w) await router.push("/integrations/workflows");
127
- dirtyBlob.value = null;
158
+ if (!w) await leaveTo("/integrations/workflows");
159
+ draft.value = null;
128
160
  warningsExpanded.value = false;
129
161
  if (tab.value === "runs") void loadRuns(1);
130
162
  }, { immediate: true });
131
163
  function onChange(blob) {
132
- dirtyBlob.value = blob;
164
+ const d = draftFrom();
165
+ if (d) draft.value = { ...d, blob };
133
166
  }
134
167
  const editorResetKey = ref(0);
135
168
  function discardChanges() {
136
- dirtyBlob.value = null;
169
+ draft.value = null;
137
170
  editorResetKey.value++;
138
171
  }
172
+ const leaveOpen = ref(false);
173
+ let leaveDecision = null;
174
+ function askBeforeLeaving() {
175
+ leaveOpen.value = true;
176
+ return new Promise((resolve) => {
177
+ leaveDecision = resolve;
178
+ });
179
+ }
180
+ function answerLeave(leave) {
181
+ leaveOpen.value = false;
182
+ leaveDecision?.(leave);
183
+ leaveDecision = null;
184
+ }
185
+ let leavingOnPurpose = false;
186
+ async function leaveTo(path) {
187
+ leavingOnPurpose = true;
188
+ try {
189
+ await router.push(path);
190
+ } finally {
191
+ leavingOnPurpose = false;
192
+ }
193
+ }
194
+ async function guardTheDraft(to, from) {
195
+ if (to.path === from.path) return true;
196
+ if (leavingOnPurpose) return true;
197
+ if (!isDirty.value) return true;
198
+ return await askBeforeLeaving();
199
+ }
200
+ onBeforeRouteLeave(guardTheDraft);
201
+ onBeforeRouteUpdate(guardTheDraft);
202
+ function warnBeforeUnload(e) {
203
+ if (!isDirty.value) return;
204
+ e.preventDefault();
205
+ e.returnValue = "unsaved";
206
+ }
207
+ onMounted(() => window.addEventListener("beforeunload", warnBeforeUnload));
208
+ onUnmounted(() => window.removeEventListener("beforeunload", warnBeforeUnload));
139
209
  const duplicateOpen = ref(false);
140
210
  const duplicateTarget = computed(() => workflow.value ? { id: workflow.value.id, name: workflow.value.name } : null);
141
211
  async function onDuplicated(copy) {
142
- toast.success(`Duplicated as \u201C${copy.name}\u201D`);
143
- await router.push(`/integrations/workflows/${copy.id}`);
212
+ toast.success(t("integration.editor.toast.duplicated", { name: copy.name }));
213
+ await leaveTo(`/integrations/workflows/${copy.id}`);
144
214
  }
145
215
  async function onRevisionRestored() {
146
216
  if (!workflow.value) return;
147
217
  await get(workflow.value.id);
148
- dirtyBlob.value = null;
218
+ draft.value = null;
149
219
  }
150
220
  const { trigger: triggerRun, loadForWorkflow, fetchRun, retryRun } = useIntegrationsRuns();
151
221
  const running = ref(false);
@@ -172,11 +242,11 @@ function viewRunFromModal(id2) {
172
242
  }
173
243
  function syncErrorMessage(e) {
174
244
  if (e instanceof IntegrationsApiError) {
175
- if (e.status === 504) return "Sync run timed out";
176
- if (e.status === 422 && e.body?.status === "failed") return "Sync run failed";
177
- return e.message || "Sync run failed";
245
+ if (e.status === 504) return t("integration.editor.run.error.syncTimeout");
246
+ if (e.status === 422 && e.body?.status === "failed") return t("integration.editor.run.error.syncFailed");
247
+ return e.message || t("integration.editor.run.error.syncFailed");
178
248
  }
179
- return e?.message || "Sync run failed";
249
+ return e?.message || t("integration.editor.run.error.syncFailed");
180
250
  }
181
251
  function refusedForSettings(e) {
182
252
  const issues = apiValidationIssues(e);
@@ -189,7 +259,7 @@ function refusedForSettings(e) {
189
259
  ...groups.filter((g) => !g.nodeId).flatMap((g) => g.messages)
190
260
  ];
191
261
  const where = parts.length ? ` \u2014 ${parts.join("; ")}` : "";
192
- const line = `Can\u2019t run \u2014 ${summary}${where}`;
262
+ const line = t("integration.editor.run.error.refusedForSettings", { summary, where });
193
263
  return /[.!?]$/.test(line) ? line : `${line}.`;
194
264
  }
195
265
  async function reportRunFailure(e, fallback) {
@@ -207,7 +277,7 @@ async function run(mode) {
207
277
  try {
208
278
  payload = JSON.parse(payloadText.value || "{}");
209
279
  } catch {
210
- payloadError.value = "Trigger payload is not valid JSON.";
280
+ payloadError.value = t("integration.editor.payload.error.invalidJson");
211
281
  return;
212
282
  }
213
283
  payloadError.value = null;
@@ -243,14 +313,14 @@ async function run(mode) {
243
313
  const res = await triggerRun(workflow.value.id, payload, "async");
244
314
  runModalRunId.value = extractRunId(res);
245
315
  } catch (e) {
246
- await reportRunFailure(e, (err) => err?.message || "Could not start the run.");
316
+ await reportRunFailure(e, (err) => err?.message || t("integration.editor.run.error.couldNotStart"));
247
317
  } finally {
248
318
  running.value = false;
249
319
  syncLoading.value = false;
250
320
  void loadRuns(1);
251
321
  }
252
322
  }
253
- const payloadDriven = computed(() => payloadFieldsFromBlob(dirtyBlob.value ?? workflow.value?.blob ?? { nodes: [] }).length > 0);
323
+ const payloadDriven = computed(() => payloadFieldsFromBlob(shownBlob.value ?? { nodes: [] }).length > 0);
254
324
  async function runFromEditor() {
255
325
  if (!workflow.value) return;
256
326
  if (payloadDriven.value) {
@@ -266,7 +336,7 @@ const eventLog = typeof useIntegrationsEvents === "function" ? useIntegrationsEv
266
336
  } };
267
337
  const prefillBusy = ref(null);
268
338
  const eventTriggerTopic = computed(() => {
269
- const trigger = (workflow.value?.blob.triggers ?? []).find((t) => t.type === "event");
339
+ const trigger = (workflow.value?.blob.triggers ?? []).find((t2) => t2.type === "event");
270
340
  const topic = trigger?.config?.event_type;
271
341
  return typeof topic === "string" && topic.trim() !== "" ? topic.trim() : null;
272
342
  });
@@ -301,7 +371,7 @@ async function insertLatestEvent() {
301
371
  await eventLog.loadEvents();
302
372
  const match = eventLog.events.value.filter((e) => e.topic === topic).sort((a, b) => (b.time ?? "").localeCompare(a.time ?? ""))[0];
303
373
  if (!match) {
304
- toast.info(`No received ${topic} event in the log yet.`);
374
+ toast.info(t("integration.editor.event.toast.noneReceived", { topic }));
305
375
  return;
306
376
  }
307
377
  payloadText.value = envelope(topic, match.data, {
@@ -327,6 +397,9 @@ const runsError = ref(null);
327
397
  const runsPage = ref(1);
328
398
  const runsTotal = ref(0);
329
399
  const runsLastPage = ref(1);
400
+ const RUNS_POLL_MS = 3e3;
401
+ const { delivering: realtimeDelivering, runTick, lastRunEvent, buildTick, lastBuildEvent } = useIntegrationsRealtime();
402
+ const runsBudget = createPollBudget({ intervalMs: RUNS_POLL_MS, liveIntervalMs: SAFETY_POLL_MS });
330
403
  let runsPoll = null;
331
404
  function stopRunsPoll() {
332
405
  if (runsPoll) {
@@ -337,12 +410,25 @@ function stopRunsPoll() {
337
410
  function startRunsPoll() {
338
411
  stopRunsPoll();
339
412
  runsPoll = setInterval(() => {
340
- if (runsPage.value === 1) void loadRuns(1, { silent: true });
341
- }, 3e3);
413
+ if (runsPage.value === 1 && runsBudget.due(realtimeDelivering.value)) void loadRuns(1, { silent: true });
414
+ }, RUNS_POLL_MS);
342
415
  }
343
416
  onUnmounted(stopRunsPoll);
417
+ watch(runTick, () => {
418
+ if (tab.value !== "runs" || runsPage.value !== 1) return;
419
+ const eventWorkflow = workflowIdOf(lastRunEvent.value);
420
+ if (eventWorkflow && eventWorkflow !== String(workflow.value?.id ?? "")) return;
421
+ void loadRuns(1, { silent: true });
422
+ });
423
+ watch(buildTick, () => {
424
+ if (!workflow.value) return;
425
+ const eventWorkflow = lastBuildEvent.value?.topicId ?? workflowIdOf(lastBuildEvent.value);
426
+ if (eventWorkflow && eventWorkflow !== String(workflow.value.id)) return;
427
+ void get(workflow.value.id);
428
+ });
344
429
  async function loadRuns(page = runsPage.value, opts = {}) {
345
430
  if (!workflow.value) return;
431
+ runsBudget.spend();
346
432
  if (!opts.silent) runsLoading.value = true;
347
433
  runsError.value = null;
348
434
  try {
@@ -356,7 +442,7 @@ async function loadRuns(page = runsPage.value, opts = {}) {
356
442
  runsLastPage.value = res.lastPage;
357
443
  runsPage.value = res.page;
358
444
  } catch (e) {
359
- runsError.value = e.message || "Could not load runs.";
445
+ runsError.value = e.message || t("integration.editor.runs.error.load");
360
446
  } finally {
361
447
  if (!opts.silent) runsLoading.value = false;
362
448
  }
@@ -365,20 +451,43 @@ function goRunsPage(p) {
365
451
  const next = Math.min(Math.max(1, p), runsLastPage.value);
366
452
  if (next !== runsPage.value) void loadRuns(next);
367
453
  }
368
- watch(tab, (t) => {
369
- if (t === "runs") {
454
+ watch(tab, (t2) => {
455
+ if (t2 === "runs") {
370
456
  void loadRuns(1);
371
457
  startRunsPoll();
372
458
  } else stopRunsPoll();
373
459
  });
374
460
  const settingsOpen = ref(false);
375
461
  const openRunId = computed(() => route.query.run != null ? String(route.query.run) : null);
462
+ const { focused: railFolded, enable, disable } = useIntegrationsFocus();
463
+ const WIDE_CANVAS_KEY = "integrations.editor.wideCanvas";
464
+ const wideCanvasWanted = ref(false);
465
+ try {
466
+ wideCanvasWanted.value = localStorage.getItem(WIDE_CANVAS_KEY) === "1";
467
+ } catch {
468
+ }
469
+ const canvasFillsPage = computed(() => tab.value === "editor" && !openRunId.value);
470
+ const railShouldFold = computed(() => wideCanvasWanted.value && canvasFillsPage.value);
471
+ function applyRail() {
472
+ if (railShouldFold.value) enable();
473
+ else disable();
474
+ }
475
+ onMounted(() => applyRail());
476
+ watch(railShouldFold, () => applyRail());
477
+ onUnmounted(() => disable());
478
+ function toggleRail() {
479
+ wideCanvasWanted.value = !wideCanvasWanted.value;
480
+ try {
481
+ localStorage.setItem(WIDE_CANVAS_KEY, wideCanvasWanted.value ? "1" : "0");
482
+ } catch {
483
+ }
484
+ }
376
485
  const runView = ref("details");
377
- const runViews = [
378
- { key: "details", label: "Details" },
379
- { key: "timeline", label: "Timeline" },
380
- { key: "replay", label: "Replay" }
381
- ];
486
+ const runViews = computed(() => [
487
+ { key: "details", label: t("integration.editor.runView.details") },
488
+ { key: "timeline", label: t("integration.editor.runView.timeline") },
489
+ { key: "replay", label: t("integration.editor.runView.replay") }
490
+ ]);
382
491
  const openRun = ref(null);
383
492
  const openRunError = ref(null);
384
493
  async function loadOpenRun() {
@@ -387,7 +496,7 @@ async function loadOpenRun() {
387
496
  try {
388
497
  openRun.value = await fetchRun(workflow.value.id, openRunId.value, workflow.value.name);
389
498
  } catch (e) {
390
- openRunError.value = e?.message ?? "Could not load the run.";
499
+ openRunError.value = e?.message ?? t("integration.editor.run.error.loadOpenRun");
391
500
  }
392
501
  }
393
502
  watch(openRunId, () => {
@@ -410,91 +519,51 @@ async function retryOpenRun() {
410
519
  try {
411
520
  const res = await retryRun(openRun.value.workflowId, openRun.value.id);
412
521
  const newId = res && typeof res === "object" && "id" in res ? String(res.id) : null;
413
- toast.success(newId && newId !== openRun.value.id ? `Started run #${newId}` : "Retry requested");
522
+ toast.success(newId && newId !== openRun.value.id ? t("integration.editor.retry.toast.started", { id: newId }) : t("integration.editor.retry.toast.requested"));
414
523
  void loadRuns(1, { silent: true });
415
524
  } catch (e) {
416
- toast.error(e?.message ?? "Could not retry the run.");
525
+ toast.error(e?.message ?? t("integration.editor.retry.error.generic"));
417
526
  } finally {
418
527
  retryBusy.value = false;
419
528
  }
420
529
  }
421
- const EXECUTION_MODES = [
422
- { value: "async_only", label: "Async only", hint: "Runs are always queued; the trigger returns immediately." },
423
- { value: "sync_only", label: "Sync only", hint: "Runs block until they finish and return the result inline." },
424
- { value: "caller_decides", label: "Caller decides", hint: "The caller chooses sync or async per run." }
425
- ];
426
- const executionModeDraft = ref("async_only");
427
- watch(workflow, (w) => {
428
- if (w) executionModeDraft.value = w.execution_mode ?? "async_only";
429
- }, { immediate: true });
430
- const savingMode = ref(false);
530
+ const EXECUTION_MODES = computed(() => [
531
+ { value: "async_only", label: t("integration.editor.executionMode.async.label"), hint: t("integration.editor.executionMode.async.hint") },
532
+ { value: "sync_only", label: t("integration.editor.executionMode.sync.label"), hint: t("integration.editor.executionMode.sync.hint") },
533
+ { value: "caller_decides", label: t("integration.editor.executionMode.callerDecides.label"), hint: t("integration.editor.executionMode.callerDecides.hint") }
534
+ ]);
431
535
  function modeLabel(m) {
432
- return m === "sync_only" ? "Sync" : m === "caller_decides" ? "Caller decides" : "Async";
433
- }
434
- async function saveExecutionMode(mode) {
435
- if (!workflow.value || mode === workflow.value.execution_mode) return;
436
- const prev = workflow.value.execution_mode ?? "async_only";
437
- executionModeDraft.value = mode;
438
- savingMode.value = true;
439
- saveError.value = null;
440
- try {
441
- await update(workflow.value.id, {
442
- name: workflow.value.name,
443
- description: workflow.value.description,
444
- blob: workflow.value.blob,
445
- active: workflow.value.active,
446
- executionMode: mode
447
- });
448
- await get(workflow.value.id);
449
- } catch (e) {
450
- executionModeDraft.value = prev;
451
- saveError.value = e.message || "Could not update the execution mode.";
452
- } finally {
453
- savingMode.value = false;
454
- }
536
+ 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");
455
537
  }
456
- const savingMeta = ref(false);
457
538
  const tagSuggestions = computed(() => {
458
539
  const all = /* @__PURE__ */ new Set();
459
- for (const wf of workflows.value) for (const t of wf.blob.tags ?? []) all.add(t);
540
+ for (const wf of workflows.value) for (const t2 of wf.blob.tags ?? []) all.add(t2);
460
541
  return [...all].sort();
461
542
  });
462
- async function saveWorkflowMeta(meta) {
463
- if (!workflow.value) return;
464
- savingMeta.value = true;
465
- saveError.value = null;
466
- try {
467
- const blob = JSON.parse(JSON.stringify(dirtyBlob.value ?? workflow.value.blob));
468
- blob.name = meta.name;
469
- if (meta.description) blob.description = meta.description;
470
- else delete blob.description;
471
- if (meta.tags.length) blob.tags = [...meta.tags];
472
- else delete blob.tags;
473
- if (meta.icon) blob.icon = meta.icon;
474
- else delete blob.icon;
475
- await update(workflow.value.id, {
476
- name: meta.name,
477
- description: meta.description || null,
478
- blob,
479
- active: workflow.value.active,
480
- executionMode: workflow.value.execution_mode
481
- });
482
- await get(workflow.value.id);
483
- dirtyBlob.value = null;
484
- toast.success("Appearance saved");
485
- } catch (e) {
486
- const msg = e.message || "Could not save the appearance.";
487
- saveError.value = msg;
488
- toast.error(msg);
489
- } finally {
490
- savingMeta.value = false;
491
- }
543
+ const settingsValues = computed(() => ({
544
+ name: shownName.value,
545
+ description: shownDescription.value,
546
+ tags: [...shownTags.value],
547
+ icon: shownIcon.value,
548
+ executionMode: draftedMode.value
549
+ }));
550
+ function applySettings(values) {
551
+ const d = draftFrom();
552
+ if (!d) return;
553
+ draft.value = {
554
+ ...d,
555
+ name: values.name,
556
+ description: values.description || null,
557
+ executionMode: values.executionMode,
558
+ tags: [...values.tags],
559
+ icon: values.icon
560
+ };
492
561
  }
493
562
  async function onToggleActive() {
494
563
  const wf = workflow.value;
495
564
  if (!wf) return;
496
565
  if (!wf.active && isInvalid.value) {
497
- toast.error(`Can\u2019t activate \u2014 ${validationWarnings.value?.length} setting${validationWarnings.value?.length === 1 ? "" : "s"} still need${validationWarnings.value?.length === 1 ? "s" : ""} fixing (see the banner above).`);
566
+ toast.error(t("integration.editor.activate.error.needsFixing", validationWarnings.value?.length ?? 0));
498
567
  return;
499
568
  }
500
569
  try {
@@ -503,25 +572,26 @@ async function onToggleActive() {
503
572
  const issues = apiValidationIssues(e);
504
573
  if (issues.length) {
505
574
  await get(wf.id);
506
- toast.error(`Can\u2019t activate \u2014 ${issues.length} validation issue${issues.length === 1 ? "" : "s"} to fix.`);
575
+ toast.error(t("integration.editor.activate.error.validationIssues", issues.length));
507
576
  return;
508
577
  }
509
- toast.error(e.message || "Could not change the workflow status.");
578
+ toast.error(e.message || t("integration.editor.activate.error.generic"));
510
579
  }
511
580
  }
512
581
  async function save() {
513
- if (!workflow.value || !dirtyBlob.value) return;
582
+ const d = draft.value;
583
+ if (!workflow.value || !d) return;
514
584
  saving.value = true;
515
585
  saveError.value = null;
516
586
  try {
517
587
  await update(workflow.value.id, {
518
- name: workflow.value.name,
519
- description: workflow.value.description,
520
- blob: dirtyBlob.value,
588
+ name: d.name,
589
+ description: d.description,
590
+ blob: payloadBlob(d),
521
591
  active: workflow.value.active,
522
- executionMode: workflow.value.execution_mode
592
+ executionMode: d.executionMode
523
593
  });
524
- dirtyBlob.value = null;
594
+ draft.value = null;
525
595
  } catch (e) {
526
596
  const issues = apiValidationIssues(e);
527
597
  if (issues.length && workflow.value.active) {
@@ -533,33 +603,34 @@ async function save() {
533
603
  });
534
604
  deactivationOpen.value = true;
535
605
  } else {
536
- saveError.value = e.message || "Save failed.";
606
+ saveError.value = e.message || t("integration.editor.save.error.generic");
537
607
  }
538
608
  } finally {
539
609
  saving.value = false;
540
610
  }
541
611
  }
542
612
  async function confirmDeactivate() {
543
- if (!workflow.value || !dirtyBlob.value) {
613
+ const d = draft.value;
614
+ if (!workflow.value || !d) {
544
615
  deactivationOpen.value = false;
545
616
  return;
546
617
  }
547
618
  saving.value = true;
548
619
  try {
549
620
  await update(workflow.value.id, {
550
- name: workflow.value.name,
551
- description: workflow.value.description,
552
- blob: dirtyBlob.value,
621
+ name: d.name,
622
+ description: d.description,
623
+ blob: payloadBlob(d),
553
624
  active: false,
554
- executionMode: workflow.value.execution_mode
625
+ executionMode: d.executionMode
555
626
  });
556
627
  await get(workflow.value.id);
557
- dirtyBlob.value = null;
628
+ draft.value = null;
558
629
  deactivationOpen.value = false;
559
- toast.success("Saved as paused \u2014 fix the highlighted settings, then activate again.");
630
+ toast.success(t("integration.editor.deactivate.toast.savedPaused"));
560
631
  } catch (e) {
561
632
  deactivationOpen.value = false;
562
- saveError.value = e.message || "Save failed.";
633
+ saveError.value = e.message || t("integration.editor.save.error.generic");
563
634
  } finally {
564
635
  saving.value = false;
565
636
  }
@@ -580,44 +651,76 @@ function formatDateTime(iso) {
580
651
  remaining height below the page header. -->
581
652
  <div class="flex h-full flex-col">
582
653
  <template v-if="workflow">
583
- <PageHeader :title="workflow.name">
654
+ <PageHeader :title="shownName">
584
655
  <template #title>
585
656
  <span class="inline-flex min-w-0 items-center gap-1.5">
586
- <NuxtLink to="/integrations/workflows" class="font-normal text-muted-foreground/80 transition-colors hover:text-primary">Workflows</NuxtLink>
657
+ <NuxtLink to="/integrations/workflows" class="font-normal text-muted-foreground/80 transition-colors hover:text-primary">{{ t("integration.editor.breadcrumb.workflows") }}</NuxtLink>
587
658
  <Chevron :size="15" weight="Outline" class="text-muted-foreground/50" />
588
659
  <!-- Run open: the breadcrumb extends — name and Runs become crumbs
589
660
  back into the workflow surface. -->
590
661
  <template v-if="openRunId">
591
- <button type="button" class="truncate font-normal text-muted-foreground/80 transition-colors hover:text-primary" @click="closeRun('editor')">{{ workflow.name }}</button>
662
+ <button type="button" class="truncate font-normal text-muted-foreground/80 transition-colors hover:text-primary" @click="closeRun('editor')">{{ shownName }}</button>
592
663
  <Chevron :size="15" weight="Outline" class="text-muted-foreground/50" />
593
- <button type="button" class="font-normal text-muted-foreground/80 transition-colors hover:text-primary" @click="closeRun('runs')">Runs</button>
664
+ <button type="button" class="font-normal text-muted-foreground/80 transition-colors hover:text-primary" @click="closeRun('runs')">{{ t("integration.editor.breadcrumb.runs") }}</button>
594
665
  <Chevron :size="15" weight="Outline" class="text-muted-foreground/50" />
595
666
  <!-- The Temporal run id IS the run's identity; the internal
596
667
  number only fills in until the run is handed off. -->
597
- <span class="truncate font-mono">{{ openRun?.temporalRunId ?? `Run #${openRunId}` }}</span>
668
+ <span class="truncate font-mono">{{ openRun?.temporalRunId ?? t("integration.editor.breadcrumb.runNumber", { id: openRunId }) }}</span>
598
669
  </template>
599
670
  <template v-else>
600
671
  <span class="flex size-7 shrink-0 items-center justify-center rounded-md bg-slate-100 text-slate-500">
601
- <WorkflowIconView :icon="workflow.blob.icon" :size="16" />
672
+ <WorkflowIconView :icon="shownIcon" :size="16" />
602
673
  </span>
603
- <span class="truncate">{{ workflow.name }}</span>
604
- <Badge variant="outline" class="ml-1.5 border text-xs font-medium uppercase text-muted-foreground" :title="`Execution mode: ${modeLabel(workflow.execution_mode)}`">
674
+ <span class="truncate">{{ shownName }}</span>
675
+ <!-- The SAVED mode, beside a drafted name, and the difference is not an
676
+ oversight. A name changes nothing about what happens when Run is
677
+ pressed, so the header may show what the operator just entered. The
678
+ dispatch mode decides what a run does — and a run goes to the
679
+ workflow the platform holds, not to the draft. A badge following the
680
+ draft would read SYNC over a button that fires an async run. -->
681
+ <Badge variant="outline" class="ml-1.5 border text-xs font-medium uppercase text-muted-foreground" :title="t('integration.editor.header.executionMode.title', { mode: modeLabel(workflow.execution_mode) })">
605
682
  {{ modeLabel(workflow.execution_mode) }}
606
683
  </Badge>
607
684
  </template>
608
685
  </span>
609
686
  </template>
610
687
  <template #actions>
688
+ <!-- Folding the rail away, and getting it back (PO-273). It reads as
689
+ chrome rather than as an action on this workflow, so the natural
690
+ place was ahead of the breadcrumb — but the shell's `#title` slot
691
+ *is* the `<h1>`'s content, and a control inside the heading makes
692
+ the page announce itself as "Hide navigation Workflows › Order
693
+ sync". `accessibility.md` AC-4 promises one heading that names what
694
+ the operator is looking at, so this sits in `#actions`, which is
695
+ outside it. Leftmost there is as near the rail as the shared header
696
+ allows without a slot of its own.
697
+
698
+ It carries its words and not just a pictogram: an icon-only control
699
+ here is one nobody finds, and the way back out of a folded rail is
700
+ the one control that cannot afford to be learnt by chance. Rendered
701
+ only where the canvas fills the page, the one surface it acts on. -->
702
+ <Button
703
+ v-if="canvasFillsPage"
704
+ variant="ghost"
705
+ size="sm"
706
+ class="h-7 shrink-0 gap-1.5 px-2 text-sm font-normal text-muted-foreground hover:text-primary"
707
+ :title="railFolded ? t('integration.nav.action.show.title') : t('integration.nav.action.hide.title')"
708
+ @click="toggleRail"
709
+ >
710
+ <RailIcon :size="15" />
711
+ {{ railFolded ? t("integration.nav.action.show") : t("integration.nav.action.hide") }}
712
+ </Button>
713
+
611
714
  <!-- Run open: run-scoped actions replace the workflow Save. -->
612
715
  <template v-if="openRunId">
613
716
  <Button v-if="canRetryOpenRun" variant="outline" class="h-7 text-sm" :disabled="retryBusy" @click="retryOpenRun">
614
717
  <Loader2 v-if="retryBusy" :size="12" class="animate-spin" />
615
718
  <RetryIcon v-else :size="12" />
616
- Retry
719
+ {{ t("integration.editor.action.retry") }}
617
720
  </Button>
618
721
  <Button class="h-7 text-sm" @click="closeRun('editor')">
619
722
  <ArrowLeft :size="12" />
620
- Back to Workflow
723
+ {{ t("integration.editor.action.backToWorkflow") }}
621
724
  </Button>
622
725
  </template>
623
726
  <template v-else>
@@ -627,32 +730,52 @@ function formatDateTime(iso) {
627
730
  :title="buildNotice?.text"
628
731
  >
629
732
  <Loader2 :size="12" class="animate-spin" />
630
- {{ workflow.buildStatus === "building" ? "Building bundle\u2026" : "Build queued\u2026" }}
733
+ {{ workflow.buildStatus === "building" ? t("integration.editor.build.chip.building") : t("integration.editor.build.chip.queued") }}
631
734
  </span>
632
- <span v-if="isDirty" class="text-sm text-amber-600">Unsaved changes</span>
633
- <Button variant="outline" class="h-7 text-sm" :disabled="saving" title="Duplicate this workflow" @click="duplicateOpen = true">
735
+ <span v-if="isDirty" class="text-sm text-amber-600">{{ t("integration.editor.status.unsavedChanges") }}</span>
736
+ <Button variant="outline" class="h-7 text-sm" :disabled="saving" :title="t('integration.editor.action.duplicate.title')" @click="duplicateOpen = true">
634
737
  <CopyIcon :size="12" />
635
- Duplicate
738
+ {{ t("integration.editor.action.duplicate.label") }}
636
739
  </Button>
637
- <Button variant="outline" class="h-7 text-sm" :disabled="!isDirty || saving" title="Discard unsaved changes" @click="discardChanges">
638
- Cancel
740
+ <Button variant="outline" class="h-7 text-sm" :disabled="!isDirty || saving" :title="t('integration.editor.action.discard.title')" @click="discardChanges">
741
+ {{ t("integration.editor.action.discard.label") }}
639
742
  </Button>
640
743
  <Button class="h-7 text-sm" :disabled="!isDirty || saving" @click="save">
641
744
  <Loader2 v-if="saving" :size="12" class="animate-spin" />
642
745
  <Save v-else :size="12" />
643
- {{ saving ? "Saving\u2026" : "Save" }}
746
+ {{ saving ? t("integration.editor.action.save.saving") : t("integration.editor.action.save.label") }}
644
747
  </Button>
645
- <Button
646
- :variant="workflow.active ? 'outline' : 'default'"
647
- class="h-7 text-sm"
648
- :class="workflow.active ? 'text-amber-700 hover:text-amber-800' : 'bg-emerald-600 text-white hover:bg-emerald-700'"
649
- :disabled="!workflow.active && isInvalid"
650
- :title="workflow.active ? 'Pause this workflow' : isInvalid ? `Fix ${validationWarnings?.length} node setting(s) before activating` : 'Activate this workflow'"
651
- @click="onToggleActive"
748
+ <!-- Activation is a standing state, not a transport control. The play
749
+ symbol belongs to Run, 600 px away in the canvas's control pane,
750
+ where pressing it makes one thing happen now; arming a workflow's
751
+ triggers looks like nothing happening at all. So this states the
752
+ state in the words the workflows list uses — Active, Paused — and
753
+ the switch beside it is the flip (PO-254).
754
+
755
+ The blocked reason has two homes because a disabled control shows
756
+ no native tooltip in Chrome: the title sits on the wrapper, which
757
+ is not disabled, and `aria-describedby` puts the same sentence on
758
+ the switch for anyone who never hovers. Sighted users also have
759
+ the amber banner directly below, which says it at length. -->
760
+ <div
761
+ class="flex h-7 items-center gap-2 rounded-md border border-black/[0.08] px-2"
762
+ :title="workflow.active ? t('integration.editor.action.pause.title') : isInvalid ? t('integration.editor.action.activate.title.invalid', validationWarnings?.length ?? 0) : t('integration.editor.action.activate.title')"
652
763
  >
653
- <component :is="workflow.active ? PauseIcon : Play" :size="12" />
654
- {{ workflow.active ? "Pause" : "Activate" }}
655
- </Button>
764
+ <Switch
765
+ :aria-label="t('integration.editor.action.state.label')"
766
+ :aria-describedby="!workflow.active && isInvalid ? 'wf-activation-blocked' : void 0"
767
+ class="data-[state=checked]:bg-emerald-600 data-[state=unchecked]:bg-slate-300"
768
+ :model-value="workflow.active"
769
+ :disabled="!workflow.active && isInvalid"
770
+ @update:model-value="onToggleActive"
771
+ />
772
+ <span class="text-sm" :class="workflow.active ? 'font-medium text-emerald-700' : 'text-muted-foreground'">
773
+ {{ workflow.active ? t("integration.workflows.table.status.lifecycle.active") : t("integration.workflows.table.status.lifecycle.paused") }}
774
+ </span>
775
+ <span v-if="!workflow.active && isInvalid" id="wf-activation-blocked" class="sr-only">
776
+ {{ t("integration.editor.action.activate.title.invalid", validationWarnings?.length ?? 0) }}
777
+ </span>
778
+ </div>
656
779
  </template>
657
780
  </template>
658
781
 
@@ -666,17 +789,16 @@ function formatDateTime(iso) {
666
789
  </div>
667
790
 
668
791
  <div v-if="reauthCredentials.length" class="border-b border-amber-500/20 bg-amber-500/5 px-4 py-2 text-sm text-amber-800">
669
- {{ reauthCredentials.length }} credential{{ reauthCredentials.length === 1 ? "" : "s" }} used here
670
- need{{ reauthCredentials.length === 1 ? "s" : "" }} reconnecting
792
+ {{ t("integration.editor.reauth.banner", reauthCredentials.length) }}
671
793
  ({{ reauthCredentials.map((c) => c.name).join(", ") }}) —
672
794
  <NuxtLink to="/integrations/credentials" class="font-medium underline">
673
- fix in Credentials
795
+ {{ t("integration.editor.reauth.action.fix") }}
674
796
  </NuxtLink>.
675
797
  </div>
676
798
 
677
799
  <!-- Manifest validation (PO-186): an inactive workflow with warnings can't
678
- be activated. The banner names what's incomplete; the Activate button
679
- stays disabled until the next save recomputes an empty warnings set.
800
+ be activated. The banner names what's incomplete; the header's state
801
+ switch stays blocked until the next save recomputes an empty warnings set.
680
802
  Amber (not red) — the work is saved fine, it just isn't runnable yet.
681
803
  One line per NODE, listing its fields (PO-209): the messages behind
682
804
  them ride along as the line's title, and the line leads to the node. -->
@@ -685,7 +807,7 @@ function formatDateTime(iso) {
685
807
  class="border-b border-amber-500/20 bg-amber-500/5 px-4 py-2 text-sm text-amber-800"
686
808
  >
687
809
  <p class="font-medium">
688
- This workflow can’t be activated yet — {{ warningSummary }}:
810
+ {{ t("integration.editor.validation.banner.title", { summary: warningSummary }) }}
689
811
  </p>
690
812
  <ul
691
813
  class="mt-1 space-y-0.5"
@@ -704,7 +826,7 @@ function formatDateTime(iso) {
704
826
  type="button"
705
827
  class="text-left underline decoration-amber-700/30 underline-offset-2 hover:decoration-amber-800"
706
828
  :title="g.messages.join('\n')"
707
- :aria-label="`Fix ${g.fields.length} setting${g.fields.length === 1 ? '' : 's'} in ${g.label}`"
829
+ :aria-label="t('integration.editor.validation.node.ariaFix', g.fields.length, { label: g.label })"
708
830
  @click="revealNode(g.nodeId)"
709
831
  >
710
832
  <span class="font-medium">{{ g.label }}</span> — {{ g.fields.join(", ") }}
@@ -713,7 +835,7 @@ function formatDateTime(iso) {
713
835
  </li>
714
836
  <li v-if="!warningsExpanded && warningGroups.length > WARN_COLLAPSED" class="pl-3">
715
837
  <button type="button" class="text-amber-700/90 underline underline-offset-2 hover:text-amber-900" @click="warningsExpanded = true">
716
- …and {{ warningGroups.length - WARN_COLLAPSED }} more — open a node to fix its config.
838
+ {{ t("integration.editor.validation.banner.more", { n: warningGroups.length - WARN_COLLAPSED }) }}
717
839
  </button>
718
840
  </li>
719
841
  </ul>
@@ -738,7 +860,7 @@ function formatDateTime(iso) {
738
860
  {{ openRunError }}
739
861
  </div>
740
862
  <div v-else class="flex items-center gap-2 p-12 text-sm text-muted-foreground">
741
- <Loader2 :size="16" class="animate-spin" /> Loading run…
863
+ <Loader2 :size="16" class="animate-spin" /> {{ t("integration.editor.run.loading") }}
742
864
  </div>
743
865
  </div>
744
866
  </div>
@@ -759,9 +881,10 @@ function formatDateTime(iso) {
759
881
  :key="editorResetKey"
760
882
  ref="editorRef"
761
883
  :workflow="workflow"
884
+ :draft="draft?.blob ?? null"
762
885
  :run-busy="running || syncLoading || waitingForBuild"
763
886
  :run-waiting="waitingForBuild"
764
- :overlay-open="settingsOpen || duplicateOpen || syncOpen || runPayloadOpen || deactivationOpen"
887
+ :overlay-open="settingsOpen || duplicateOpen || syncOpen || runPayloadOpen || deactivationOpen || leaveOpen"
765
888
  @change="onChange"
766
889
  @run="runFromEditor"
767
890
  @toggle-active="onToggleActive"
@@ -781,19 +904,19 @@ function formatDateTime(iso) {
781
904
  :loading="runsLoading"
782
905
  :pagination="false"
783
906
  :show-workflow="false"
784
- empty-text="No runs yet — hit Run in the editor."
907
+ :empty-text="t('integration.editor.runs.empty')"
785
908
  @changed="() => loadRuns()"
786
909
  />
787
910
 
788
911
  <!-- Server pagination — the endpoint pages, so the whole history is reachable. -->
789
912
  <div v-if="runsLastPage > 1 || runsTotal > wfRuns.length" class="flex items-center justify-between text-sm text-muted-foreground">
790
- <span class="tabular-nums">Page {{ runsPage }} / {{ runsLastPage }} · {{ runsTotal }} run{{ runsTotal === 1 ? "" : "s" }}</span>
913
+ <span class="tabular-nums">{{ t("integration.editor.runs.pagination.page", { page: runsPage, lastPage: runsLastPage }) }} · {{ t("integration.editor.runs.pagination.count", runsTotal) }}</span>
791
914
  <div class="flex gap-2">
792
915
  <Button variant="outline" size="sm" class="h-7 text-sm" :disabled="runsPage <= 1 || runsLoading" @click="goRunsPage(runsPage - 1)">
793
- ← Newer
916
+ {{ t("integration.editor.runs.pagination.newer") }}
794
917
  </Button>
795
918
  <Button variant="outline" size="sm" class="h-7 text-sm" :disabled="runsPage >= runsLastPage || runsLoading" @click="goRunsPage(runsPage + 1)">
796
- Older →
919
+ {{ t("integration.editor.runs.pagination.older") }}
797
920
  </Button>
798
921
  </div>
799
922
  </div>
@@ -806,12 +929,12 @@ function formatDateTime(iso) {
806
929
 
807
930
  <!-- History — n8n-style: read-only canvas preview + revision list. -->
808
931
  <div v-else-if="tab === 'history'" class="flex-1 min-h-0">
809
- <WorkflowHistoryPanel :workflow="workflow" @restored="onRevisionRestored" />
932
+ <WorkflowHistoryPanel :workflow="workflow" :draft-pending="isDirty" @restored="onRevisionRestored" />
810
933
  </div>
811
934
  </template>
812
935
 
813
936
  <div v-else class="p-12 text-center text-sm text-muted-foreground">
814
- Loading…
937
+ {{ t("integration.editor.state.loading") }}
815
938
  </div>
816
939
  </div>
817
940
 
@@ -819,31 +942,31 @@ function formatDateTime(iso) {
819
942
  <Dialog v-model:open="runPayloadOpen">
820
943
  <DialogContent class="sm:max-w-lg">
821
944
  <DialogHeader>
822
- <DialogTitle>Run with payload</DialogTitle>
945
+ <DialogTitle>{{ t("integration.editor.runPayload.dialog.title") }}</DialogTitle>
823
946
  <DialogDescription class="text-sm">
824
- Starts a manual run with this trigger payload. The result lands on the Runs tab.
947
+ {{ t("integration.editor.runPayload.dialog.description") }}
825
948
  </DialogDescription>
826
949
  </DialogHeader>
827
950
  <div v-if="eventTriggerTopic" class="flex flex-wrap items-center gap-1.5">
828
- <span class="text-xs text-muted-foreground">Prefill <code class="font-mono">{{ eventTriggerTopic }}</code>:</span>
951
+ <span class="text-xs text-muted-foreground">{{ t("integration.editor.runPayload.prefill.label") }} <code class="font-mono">{{ eventTriggerTopic }}</code>:</span>
829
952
  <Button
830
953
  variant="outline"
831
954
  class="h-7 text-xs"
832
955
  :disabled="!catalogSample"
833
- :title="catalogSample ? 'Insert the catalog sample payload' : 'The event catalog has no sample for this topic.'"
956
+ :title="catalogSample ? t('integration.editor.runPayload.action.sampleEvent.titleAvailable') : t('integration.editor.runPayload.action.sampleEvent.titleUnavailable')"
834
957
  @click="insertSampleEvent"
835
958
  >
836
- Sample event
959
+ {{ t("integration.editor.runPayload.action.sampleEvent") }}
837
960
  </Button>
838
961
  <Button
839
962
  variant="outline"
840
963
  class="h-7 text-xs"
841
964
  :disabled="prefillBusy !== null"
842
- title="Insert the latest received event of this topic"
965
+ :title="t('integration.editor.runPayload.action.latestEvent.title')"
843
966
  @click="insertLatestEvent"
844
967
  >
845
968
  <Loader2 v-if="prefillBusy === 'latest'" :size="12" class="animate-spin" />
846
- Latest real event
969
+ {{ t("integration.editor.runPayload.action.latestEvent") }}
847
970
  </Button>
848
971
  </div>
849
972
  <div class="space-y-1">
@@ -857,35 +980,35 @@ function formatDateTime(iso) {
857
980
  <Button
858
981
  variant="outline"
859
982
  :disabled="running || syncLoading || waitingForBuild || buildFailed"
860
- :title="buildFailed ? 'The workflow bundle failed to build.' : void 0"
983
+ :title="buildFailed ? t('integration.editor.build.tooltip.failed') : void 0"
861
984
  class="h-8 text-sm"
862
985
  @click="runFromPayloadDialog('sync')"
863
986
  >
864
987
  <Loader2 v-if="syncLoading || waitingMode === 'sync'" :size="14" class="animate-spin" />
865
988
  <Play v-else :size="14" />
866
- {{ waitingMode === "sync" ? "Waiting for build\u2026" : "Run (sync)" }}
989
+ {{ waitingMode === "sync" ? t("integration.editor.run.waitingForBuild") : t("integration.editor.runPayload.action.run", { mode: t("integration.editor.mode.word.sync") }) }}
867
990
  </Button>
868
991
  <Button
869
992
  :disabled="running || syncLoading || waitingForBuild || buildFailed"
870
- :title="buildFailed ? 'The workflow bundle failed to build.' : void 0"
993
+ :title="buildFailed ? t('integration.editor.build.tooltip.failed') : void 0"
871
994
  class="h-8 text-sm"
872
995
  @click="runFromPayloadDialog('async')"
873
996
  >
874
997
  <Loader2 v-if="running || waitingMode === 'async'" :size="14" class="animate-spin" />
875
998
  <Play v-else :size="14" />
876
- {{ waitingMode === "async" ? "Waiting for build\u2026" : "Run (async)" }}
999
+ {{ waitingMode === "async" ? t("integration.editor.run.waitingForBuild") : t("integration.editor.runPayload.action.run", { mode: t("integration.editor.mode.word.async") }) }}
877
1000
  </Button>
878
1001
  </template>
879
1002
  <Button
880
1003
  v-else
881
1004
  :disabled="running || syncLoading || waitingForBuild || buildFailed"
882
- :title="buildFailed ? 'The workflow bundle failed to build.' : void 0"
1005
+ :title="buildFailed ? t('integration.editor.build.tooltip.failed') : void 0"
883
1006
  class="h-8 text-sm"
884
1007
  @click="runFromPayloadDialog(primaryMode)"
885
1008
  >
886
1009
  <Loader2 v-if="running || syncLoading || waitingForBuild" :size="14" class="animate-spin" />
887
1010
  <Play v-else :size="14" />
888
- {{ waitingForBuild ? "Waiting for build\u2026" : `Run (${primaryMode})` }}
1011
+ {{ waitingForBuild ? t("integration.editor.run.waitingForBuild") : t("integration.editor.runPayload.action.run", { mode: t(primaryMode === "sync" ? "integration.editor.mode.word.sync" : "integration.editor.mode.word.async") }) }}
889
1012
  </Button>
890
1013
  </div>
891
1014
  </DialogContent>
@@ -907,16 +1030,46 @@ function formatDateTime(iso) {
907
1030
  v-model:open="settingsOpen"
908
1031
  :workflow="workflow"
909
1032
  :modes="EXECUTION_MODES"
910
- :mode-draft="executionModeDraft"
911
- :saving-mode="savingMode"
912
- :saving-meta="savingMeta"
1033
+ :current="settingsValues"
913
1034
  :tag-suggestions="tagSuggestions"
914
- @change-mode="(m) => saveExecutionMode(m)"
915
- @save-meta="saveWorkflowMeta"
1035
+ @apply="applySettings"
916
1036
  />
917
1037
 
918
1038
  <WorkflowDuplicateDialog v-model:open="duplicateOpen" :workflow="duplicateTarget" @duplicated="onDuplicated" />
919
1039
 
1040
+ <!-- Leaving the workflow with unsaved work.
1041
+ Staying is the default in the only sense that decides anything: it holds the
1042
+ focus when the dialog opens, so Enter or Space keeps the work. That is what
1043
+ `AlertDialogCancel` is for — the dialog focuses its cancel element, and with
1044
+ none declared it focuses the first tabbable button instead, which was
1045
+ "Discard and leave". A breadcrumb click followed by Enter then threw away the
1046
+ afternoon this dialog exists to protect.
1047
+ `as-child` so the studio's own Button is that element rather than a second one
1048
+ beside it. Escape means the same thing, through `update:open`; a click beside
1049
+ the dialog does nothing at all, which an alert dialog is entitled to. -->
1050
+ <AlertDialog :open="leaveOpen" @update:open="(v) => {
1051
+ if (!v) answerLeave(false);
1052
+ }">
1053
+ <AlertDialogContent>
1054
+ <AlertDialogHeader>
1055
+ <AlertDialogTitle>{{ t("integration.editor.leave.dialog.title") }}</AlertDialogTitle>
1056
+ <AlertDialogDescription>
1057
+ {{ t("integration.editor.leave.dialog.description") }}
1058
+ </AlertDialogDescription>
1059
+ </AlertDialogHeader>
1060
+ <AlertDialogFooter>
1061
+ <Button variant="outline" @click="answerLeave(true)">
1062
+ {{ t("integration.editor.leave.dialog.discard") }}
1063
+ </Button>
1064
+ <AlertDialogCancel as-child>
1065
+ <Button @click="answerLeave(false)">
1066
+ {{ t("integration.editor.leave.dialog.stay") }}
1067
+ </Button>
1068
+ </AlertDialogCancel>
1069
+ </AlertDialogFooter>
1070
+ </AlertDialogContent>
1071
+ </AlertDialog>
1072
+
920
1073
  <!-- Active-save blocked by manifest validation: keep the work by saving it
921
1074
  as paused. Cancelling persists nothing (the 422 already rolled back). -->
922
1075
  <AlertDialog :open="deactivationOpen" @update:open="(v) => {
@@ -924,10 +1077,11 @@ function formatDateTime(iso) {
924
1077
  }">
925
1078
  <AlertDialogContent>
926
1079
  <AlertDialogHeader>
927
- <AlertDialogTitle>Can’t keep this workflow active</AlertDialogTitle>
1080
+ <AlertDialogTitle>{{ t("integration.editor.deactivate.dialog.title") }}</AlertDialogTitle>
928
1081
  <AlertDialogDescription>
929
- These node settings must be valid while the workflow is active. Save it as
930
- <span class="font-medium">paused</span> to keep your changes, then fix them and activate again — or cancel to discard this save.
1082
+ {{ t("integration.editor.deactivate.dialog.description.pre") }}
1083
+ <span class="font-medium">{{ t("integration.editor.deactivate.dialog.description.paused") }}</span>
1084
+ {{ t("integration.editor.deactivate.dialog.description.post") }}
931
1085
  </AlertDialogDescription>
932
1086
  </AlertDialogHeader>
933
1087
  <ul class="max-h-48 space-y-0.5 overflow-y-auto rounded-md border border-amber-500/20 bg-amber-500/5 px-3 py-2 text-sm text-amber-800">
@@ -938,11 +1092,11 @@ function formatDateTime(iso) {
938
1092
  </ul>
939
1093
  <AlertDialogFooter>
940
1094
  <Button variant="outline" :disabled="saving" @click="deactivationOpen = false">
941
- Cancel
1095
+ {{ t("integration.editor.deactivate.dialog.cancel") }}
942
1096
  </Button>
943
1097
  <Button class="bg-emerald-600 text-white hover:bg-emerald-700" :disabled="saving" @click="confirmDeactivate">
944
1098
  <Loader2 v-if="saving" :size="12" class="animate-spin" />
945
- Save as paused
1099
+ {{ t("integration.editor.deactivate.dialog.confirm") }}
946
1100
  </Button>
947
1101
  </AlertDialogFooter>
948
1102
  </AlertDialogContent>