@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,7 +1,8 @@
1
1
  <script setup>
2
2
  import { computed, nextTick, onMounted, provide, ref, watch } from "vue";
3
- import { AddCircle as Plus, TrashBinMinimalistic as Trash2, Copy } from "#solar-icons";
4
- import { ChevronDown, ChevronLeft, ChevronRight, Clock, Database, MessageSquareText, Play, Search, Webhook, X, Zap } from "lucide-vue-next";
3
+ import { AddCircle as Plus, Copy, Magnifier as Search, TrashBinMinimalistic as TrashBin } from "#solar-icons";
4
+ import { triggerGlyph as glyphForTrigger } from "../../data/triggerGlyphs";
5
+ import { ChevronDown, ChevronLeft, ChevronRight, Database, MessageSquareText, X } from "lucide-vue-next";
5
6
  import { toast } from "vue-sonner";
6
7
  import {
7
8
  Button,
@@ -27,9 +28,10 @@ import {
27
28
  ToggleGroupItem
28
29
  } from "@revenexx/studio";
29
30
  import EventTypeCombobox from "./EventTypeCombobox.vue";
30
- import { NODE_CATEGORIES, findManifest } from "../../data/integrationNodeCatalog";
31
+ import { findManifest, nodeCategoryLabel, isVersionExecutable, newestExecutableVersion } from "../../data/integrationNodeCatalog";
31
32
  import {
32
33
  collectUpstreamNodes,
34
+ inputPortsForManifest,
33
35
  nodeOutputExpr,
34
36
  portsForNode,
35
37
  triggerPayloadExpr,
@@ -45,6 +47,7 @@ import {
45
47
  } from "../../data/nodeRunData";
46
48
  import { resolveDisplayName, resolveLocalized } from "../../utils/localize";
47
49
  import { describeRecurrence, parseCron, scheduleTimezone, toCron } from "../../utils/schedule";
50
+ const t = useIntegrationsT();
48
51
  const props = defineProps({
49
52
  selection: { type: [Object, null], required: true },
50
53
  secretKeys: { type: Array, required: true },
@@ -86,35 +89,48 @@ const manifest = computed(() => {
86
89
  const wn = draftNode.value;
87
90
  return wn ? findManifest(wn.nodeSlug, wn.nodeVersion) : null;
88
91
  });
89
- const TRIGGER_GLYPHS = { manual: Play, schedule: Clock, webhook: Webhook, event: Zap };
90
- const TRIGGER_LABELS = { manual: "Manual", schedule: "Schedule", webhook: "Webhook", event: "Event" };
91
- const triggerGlyph = computed(() => TRIGGER_GLYPHS[draftTrigger.value?.type ?? "manual"]);
92
+ const versionExecutable = computed(() => {
93
+ const wn = draftNode.value;
94
+ return wn ? isVersionExecutable(wn.nodeSlug, wn.nodeVersion) : true;
95
+ });
96
+ const readyVersion = computed(() => {
97
+ const wn = draftNode.value;
98
+ return wn ? newestExecutableVersion(wn.nodeSlug) : null;
99
+ });
100
+ const TRIGGER_LABELS = computed(() => ({
101
+ manual: t("integration.nodeInspector.trigger.type.manual"),
102
+ schedule: t("integration.nodeInspector.trigger.type.schedule"),
103
+ webhook: t("integration.nodeInspector.trigger.type.webhook"),
104
+ event: t("integration.nodeInspector.trigger.type.event")
105
+ }));
106
+ const triggerGlyph = computed(() => glyphForTrigger(draftTrigger.value?.type));
92
107
  const namePlaceholder = computed(() => {
93
108
  if (draftNode.value) return resolveLocalized(manifest.value?.name) || draftNode.value.nodeSlug;
94
- return TRIGGER_LABELS[draftTrigger.value?.type ?? "manual"] + " trigger";
109
+ return t("integration.nodeInspector.trigger.placeholder", { type: TRIGGER_LABELS.value[draftTrigger.value?.type ?? "manual"] });
95
110
  });
96
111
  const subtitleText = computed(() => {
97
112
  const wn = draftNode.value;
98
113
  if (wn) return wn.nodeSlug + "@" + wn.nodeVersion;
99
- return TRIGGER_LABELS[draftTrigger.value?.type ?? "manual"] + " trigger";
114
+ return t("integration.nodeInspector.trigger.placeholder", { type: TRIGGER_LABELS.value[draftTrigger.value?.type ?? "manual"] });
100
115
  });
101
116
  const manifestDescription = computed(() => resolveLocalized(manifest.value?.description));
102
117
  const groupLabel = computed(() => {
103
- if (!isNode.value) return "Trigger";
118
+ if (!isNode.value) return t("integration.nodeInspector.header.groupTrigger");
104
119
  const groups = manifest.value?.groups;
105
120
  if (groups?.length) return resolveLocalized(groups[groups.length - 1]);
106
- return NODE_CATEGORIES.find((c) => c.key === manifest.value?.category)?.label ?? "Node";
121
+ return nodeCategoryLabel(manifest.value?.category) || t("integration.nodeInspector.header.groupNode");
107
122
  });
108
123
  const nameValue = computed(() => {
109
124
  const name = draft.value.name;
110
125
  return resolveDisplayName(name);
111
126
  });
127
+ const dialogTitle = computed(() => t("integration.nodeInspector.dialogTitle", { name: nameValue.value || namePlaceholder.value }));
112
128
  function writeName(name) {
113
129
  draft.value = { ...draft.value, name };
114
130
  }
115
131
  const noteOpen = ref(false);
116
132
  const noteText = computed(() => (draftNode.value?.notes ?? "").trim());
117
- const noteLabel = computed(() => noteText.value ? "Note" : "Add note");
133
+ const noteLabel = computed(() => noteText.value ? t("integration.nodeInspector.note.label") : t("integration.nodeInspector.note.addLabel"));
118
134
  function writeNote(v) {
119
135
  const text = String(v ?? "");
120
136
  patchNode({ notes: text.trim() ? text : void 0 });
@@ -122,8 +138,11 @@ function writeNote(v) {
122
138
  const tab = ref("parameters");
123
139
  const showSettingsTab = computed(() => isNode.value);
124
140
  const inspectorTabs = computed(() => [
125
- { key: "parameters", label: "Parameters" },
126
- ...showSettingsTab.value ? [{ key: "settings", label: "Settings" }, { key: "output", label: "Output" }] : []
141
+ { key: "parameters", label: t("integration.nodeInspector.tab.parameters") },
142
+ ...showSettingsTab.value ? [
143
+ { key: "settings", label: t("integration.nodeInspector.tab.settings") },
144
+ { key: "output", label: t("integration.nodeInspector.tab.output") }
145
+ ] : []
127
146
  ]);
128
147
  watch(showSettingsTab, (hasThem) => {
129
148
  if (!hasThem) tab.value = "parameters";
@@ -184,9 +203,9 @@ async function copyConfig() {
184
203
  if (!draftNode.value) return;
185
204
  try {
186
205
  await navigator.clipboard.writeText(JSON.stringify(draftNode.value.config ?? {}, null, 2));
187
- toast.success("Config copied");
206
+ toast.success(t("integration.nodeInspector.toast.configCopied"));
188
207
  } catch {
189
- toast.error("Could not copy config");
208
+ toast.error(t("integration.nodeInspector.toast.configCopyFailed"));
190
209
  }
191
210
  }
192
211
  async function pasteConfig() {
@@ -195,22 +214,22 @@ async function pasteConfig() {
195
214
  try {
196
215
  text = await navigator.clipboard.readText();
197
216
  } catch {
198
- toast.error("Could not read the clipboard");
217
+ toast.error(t("integration.nodeInspector.toast.clipboardReadFailed"));
199
218
  return;
200
219
  }
201
220
  let parsed;
202
221
  try {
203
222
  parsed = JSON.parse(text);
204
223
  } catch {
205
- toast.error("Clipboard is not valid JSON");
224
+ toast.error(t("integration.nodeInspector.toast.clipboardInvalidJson"));
206
225
  return;
207
226
  }
208
227
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
209
- toast.error("Clipboard is not a JSON config object");
228
+ toast.error(t("integration.nodeInspector.toast.clipboardNotConfigObject"));
210
229
  return;
211
230
  }
212
231
  patchNode({ config: parsed });
213
- toast.success("Config pasted");
232
+ toast.success(t("integration.nodeInspector.toast.configPasted"));
214
233
  }
215
234
  function readTriggerConfig(key) {
216
235
  return draftTrigger.value?.config[key];
@@ -223,24 +242,24 @@ function writeTriggerConfig(key, value) {
223
242
  const MINUTE_INTERVALS = [1, 2, 5, 10, 15, 20, 30];
224
243
  const HOUR_INTERVALS = [2, 3, 4, 6, 8, 12];
225
244
  const MINUTES_PAST = [0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55];
226
- const DAY_OPTIONS = [
227
- { value: 1, short: "Mo", name: "Mondays" },
228
- { value: 2, short: "Tu", name: "Tuesdays" },
229
- { value: 3, short: "We", name: "Wednesdays" },
230
- { value: 4, short: "Th", name: "Thursdays" },
231
- { value: 5, short: "Fr", name: "Fridays" },
232
- { value: 6, short: "Sa", name: "Saturdays" },
233
- { value: 0, short: "Su", name: "Sundays" }
234
- ];
235
- const FREQUENCY_OPTIONS = [
236
- { value: "minutes", label: "Every few minutes" },
237
- { value: "hourly", label: "Every hour" },
238
- { value: "hours", label: "Every few hours" },
239
- { value: "times", label: "Several times a day" },
240
- { value: "daily", label: "Every day" },
241
- { value: "weekly", label: "Every week" },
242
- { value: "monthly", label: "Every month" }
243
- ];
245
+ const DAY_OPTIONS = computed(() => [
246
+ { value: 1, short: t("integration.nodeInspector.schedule.day.monday.short"), name: t("integration.nodeInspector.schedule.day.monday") },
247
+ { value: 2, short: t("integration.nodeInspector.schedule.day.tuesday.short"), name: t("integration.nodeInspector.schedule.day.tuesday") },
248
+ { value: 3, short: t("integration.nodeInspector.schedule.day.wednesday.short"), name: t("integration.nodeInspector.schedule.day.wednesday") },
249
+ { value: 4, short: t("integration.nodeInspector.schedule.day.thursday.short"), name: t("integration.nodeInspector.schedule.day.thursday") },
250
+ { value: 5, short: t("integration.nodeInspector.schedule.day.friday.short"), name: t("integration.nodeInspector.schedule.day.friday") },
251
+ { value: 6, short: t("integration.nodeInspector.schedule.day.saturday.short"), name: t("integration.nodeInspector.schedule.day.saturday") },
252
+ { value: 0, short: t("integration.nodeInspector.schedule.day.sunday.short"), name: t("integration.nodeInspector.schedule.day.sunday") }
253
+ ]);
254
+ const FREQUENCY_OPTIONS = computed(() => [
255
+ { value: "minutes", label: t("integration.nodeInspector.schedule.frequency.minutes") },
256
+ { value: "hourly", label: t("integration.nodeInspector.schedule.frequency.hourly") },
257
+ { value: "hours", label: t("integration.nodeInspector.schedule.frequency.hours") },
258
+ { value: "times", label: t("integration.nodeInspector.schedule.frequency.times") },
259
+ { value: "daily", label: t("integration.nodeInspector.schedule.frequency.daily") },
260
+ { value: "weekly", label: t("integration.nodeInspector.schedule.frequency.weekly") },
261
+ { value: "monthly", label: t("integration.nodeInspector.schedule.frequency.monthly") }
262
+ ]);
244
263
  const HOURS_OF_DAY = Array.from({ length: 24 }, (_, h) => h);
245
264
  const FREQUENCY_ADVANCED = "advanced";
246
265
  const cronValue = computed(() => readTriggerConfig("cron") ?? "");
@@ -416,10 +435,10 @@ async function rotateEndpointSecret() {
416
435
  rotatedSecret.value = secret;
417
436
  rotateOpen.value = true;
418
437
  } else {
419
- toast.error("The endpoint did not return a secret.");
438
+ toast.error(t("integration.nodeInspector.toast.rotateNoSecret"));
420
439
  }
421
440
  } catch (e) {
422
- toast.error(e.message || "Could not rotate the secret.");
441
+ toast.error(e.message || t("integration.nodeInspector.toast.rotateFailed"));
423
442
  } finally {
424
443
  rotateBusy.value = false;
425
444
  }
@@ -428,25 +447,25 @@ async function copySecret() {
428
447
  if (!rotatedSecret.value) return;
429
448
  try {
430
449
  await navigator.clipboard.writeText(rotatedSecret.value);
431
- toast.success("Secret copied");
450
+ toast.success(t("integration.nodeInspector.toast.secretCopied"));
432
451
  } catch {
433
- toast.error("Could not copy the secret");
452
+ toast.error(t("integration.nodeInspector.toast.secretCopyFailed"));
434
453
  }
435
454
  }
436
455
  async function copyWebhookUrl() {
437
456
  if (!webhookUrl.value) return;
438
457
  try {
439
458
  await navigator.clipboard.writeText(webhookUrl.value);
440
- toast.success("Webhook URL copied");
459
+ toast.success(t("integration.nodeInspector.toast.webhookUrlCopied"));
441
460
  } catch {
442
- toast.error("Could not copy the URL");
461
+ toast.error(t("integration.nodeInspector.toast.webhookUrlCopyFailed"));
443
462
  }
444
463
  }
445
464
  const definedEvents = typeof useDefinedEvents === "function" ? useDefinedEvents() : { emits: ref([]), load: async () => {
446
465
  } };
447
466
  const eventTopic = computed(() => {
448
- const t = readTriggerConfig("event_type");
449
- return typeof t === "string" && t.trim() !== "" ? t.trim() : null;
467
+ const raw = readTriggerConfig("event_type");
468
+ return typeof raw === "string" && raw.trim() !== "" ? raw.trim() : null;
450
469
  });
451
470
  watch(eventTopic, (topic) => {
452
471
  if (topic) void definedEvents.load();
@@ -490,7 +509,7 @@ async function loadRunData() {
490
509
  const { runs } = await loadForWorkflow(Number(props.workflowId), "", { perPage: 10 });
491
510
  const source = runs.find((r) => r.status === "succeeded") ?? runs[0];
492
511
  if (!source) {
493
- runError.value = "No runs yet. Run the workflow once to sample real values.";
512
+ runError.value = t("integration.nodeInspector.input.runData.empty");
494
513
  return;
495
514
  }
496
515
  const res = await getSteps(props.workflowId, source.id, true);
@@ -505,7 +524,7 @@ async function loadRunData() {
505
524
  runData.value = map;
506
525
  runInfo.value = { id: source.id, startedAt: source.startedAt };
507
526
  } catch (e) {
508
- runError.value = e?.message ?? "Could not load run data.";
527
+ runError.value = e?.message ?? t("integration.nodeInspector.input.runData.loadFailed");
509
528
  } finally {
510
529
  runLoading.value = false;
511
530
  }
@@ -589,19 +608,19 @@ const inputPaneOpen = ref(false);
589
608
  const exprInsertHandler = ref(null);
590
609
  provide("integrationsExpressionInsert", exprInsertHandler);
591
610
  function onCenterFocusIn(e) {
592
- const t = e.target;
593
- if (t instanceof HTMLElement && t.closest(".expr-editor")) {
611
+ const target = e.target;
612
+ if (target instanceof HTMLElement && target.closest(".expr-editor")) {
594
613
  lastFocusKind.value = "editor";
595
614
  inputPaneOpen.value = true;
596
615
  return;
597
616
  }
598
- if (t instanceof HTMLTextAreaElement) {
599
- lastField.value = t;
617
+ if (target instanceof HTMLTextAreaElement) {
618
+ lastField.value = target;
600
619
  lastFocusKind.value = "native";
601
620
  return;
602
621
  }
603
- if (t instanceof HTMLInputElement && !["number", "checkbox", "radio", "range", "file", "color"].includes(t.type)) {
604
- lastField.value = t;
622
+ if (target instanceof HTMLInputElement && !["number", "checkbox", "radio", "range", "file", "color"].includes(target.type)) {
623
+ lastField.value = target;
605
624
  lastFocusKind.value = "native";
606
625
  }
607
626
  }
@@ -623,7 +642,7 @@ function insertExpression(expression) {
623
642
  void navigator.clipboard?.writeText(expression);
624
643
  } catch {
625
644
  }
626
- toast("Expression copied. Click into a field first to insert it directly.");
645
+ toast(t("integration.nodeInspector.toast.expressionCopied"));
627
646
  }
628
647
  const chipClass = "max-w-full cursor-grab truncate rounded-md border border-black/[0.06] bg-slate-50 px-1.5 py-0.5 font-mono text-[11px] text-slate-700 transition hover:border-black/[0.12] hover:bg-slate-100 active:cursor-grabbing";
629
648
  const rowClass = "flex w-full min-w-0 cursor-grab items-center gap-1.5 rounded-md px-1.5 py-[3px] text-left font-mono text-[11px] text-slate-700 transition hover:bg-slate-100 active:cursor-grabbing";
@@ -642,10 +661,10 @@ function onChipDragEnd() {
642
661
  dragActive.value = false;
643
662
  clearDropHover();
644
663
  }
645
- function expressionTarget(t) {
646
- if (t instanceof HTMLTextAreaElement) return t;
647
- if (t instanceof HTMLInputElement && !["number", "checkbox", "radio", "range", "file", "color"].includes(t.type)) return t;
648
- if (t instanceof HTMLElement) return t.closest(".expr-editor");
664
+ function expressionTarget(t2) {
665
+ if (t2 instanceof HTMLTextAreaElement) return t2;
666
+ if (t2 instanceof HTMLInputElement && !["number", "checkbox", "radio", "range", "file", "color"].includes(t2.type)) return t2;
667
+ if (t2 instanceof HTMLElement) return t2.closest(".expr-editor");
649
668
  return null;
650
669
  }
651
670
  function onCenterDragEnter(e) {
@@ -737,7 +756,7 @@ function typeGlyph(dataType) {
737
756
  return dataType ? TYPE_GLYPHS[dataType] ?? null : null;
738
757
  }
739
758
  const sequence = computed(() => [
740
- ...(props.triggers ?? []).map((t) => t.id),
759
+ ...(props.triggers ?? []).map((t2) => t2.id),
741
760
  ...(props.nodes ?? []).map((n) => n.id)
742
761
  ]);
743
762
  const seqIndex = computed(() => sequence.value.indexOf(draft.value.id));
@@ -750,6 +769,7 @@ function navigate(offset) {
750
769
  emit("navigate", target);
751
770
  }
752
771
  const outputView = ref("schema");
772
+ const selfInputs = computed(() => inputPortsForManifest(manifest.value));
753
773
  const selfSample = computed(() => draftNode.value ? sampleFor(draftNode.value.id) : void 0);
754
774
  const selfPorts = computed(() => draftNode.value ? withSampledFields(portsForNode(draftNode.value, manifest.value, props.dynamicOutputs?.[draftNode.value.id]), selfSample.value) : []);
755
775
  const selfSampleJson = computed(() => selfSample.value ? JSON.stringify(selfSample.value, null, 2) : "");
@@ -771,17 +791,17 @@ function savePin() {
771
791
  try {
772
792
  parsed = JSON.parse(pinDraft.value);
773
793
  } catch {
774
- pinError.value = "Not valid JSON.";
794
+ pinError.value = t("integration.nodeInspector.output.pin.invalidJson");
775
795
  return;
776
796
  }
777
797
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
778
- pinError.value = 'Must be an object mapping ports to payloads, e.g. { "default": { \u2026 } }.';
798
+ pinError.value = t("integration.nodeInspector.output.pin.invalidShape");
779
799
  return;
780
800
  }
781
801
  pinnedSamples.value = { ...pinnedSamples.value, [draftNode.value.id]: parsed };
782
802
  persistPins();
783
803
  pinEditorOpen.value = false;
784
- toast.success("Sample output pinned");
804
+ toast.success(t("integration.nodeInspector.toast.samplePinned"));
785
805
  }
786
806
  function removePin() {
787
807
  if (!draftNode.value) return;
@@ -820,12 +840,12 @@ async function runTestStep() {
820
840
  try {
821
841
  const parsed = JSON.parse(testInputDraft.value.trim() || defaultTestInput());
822
842
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
823
- testError.value = "Test input must be a JSON object.";
843
+ testError.value = t("integration.nodeInspector.output.test.inputNotObject");
824
844
  return;
825
845
  }
826
846
  inputs = parsed;
827
847
  } catch {
828
- testError.value = "Test input is not valid JSON.";
848
+ testError.value = t("integration.nodeInspector.output.test.inputInvalidJson");
829
849
  testInputOpen.value = true;
830
850
  return;
831
851
  }
@@ -843,10 +863,10 @@ async function runTestStep() {
843
863
  [wn.id]: res.outputs && typeof res.outputs === "object" ? res.outputs : {}
844
864
  };
845
865
  outputView.value = "json";
846
- toast.success("Test run finished");
866
+ toast.success(t("integration.nodeInspector.toast.testRunFinished"));
847
867
  } catch (e) {
848
868
  const body = e.body;
849
- testError.value = body?.error?.message ?? body?.message ?? e?.message ?? "Test run failed.";
869
+ testError.value = body?.error?.message ?? body?.message ?? e?.message ?? t("integration.nodeInspector.output.test.runFailed");
850
870
  testLogs.value = body?.logs ?? [];
851
871
  } finally {
852
872
  testRunning.value = false;
@@ -858,6 +878,11 @@ async function runTestStep() {
858
878
  <!-- Fills the editor's near-fullscreen config lightbox; the Dialog owns
859
879
  width/height, border and the close control. -->
860
880
  <div class="flex min-h-0 flex-1 flex-col bg-white">
881
+ <!-- The dialog's accessible name. Not drawn: the header below says the same
882
+ thing in the name field and the line under it, and says it better. -->
883
+ <DialogTitle class="sr-only">
884
+ {{ dialogTitle }}
885
+ </DialogTitle>
861
886
  <!-- Header -->
862
887
  <div class="flex shrink-0 items-center gap-3 border-b border-black/[0.06] px-4 py-3">
863
888
  <div class="flex size-9 shrink-0 items-center justify-center rounded-md border border-black/[0.06] bg-slate-50 text-slate-600">
@@ -870,7 +895,7 @@ async function runTestStep() {
870
895
  type, not for the field. -->
871
896
  <input
872
897
  ref="nameInput"
873
- aria-label="Node name"
898
+ :aria-label="t('integration.nodeInspector.header.nameAria')"
874
899
  :value="nameValue"
875
900
  :placeholder="namePlaceholder"
876
901
  class="w-full truncate rounded-sm bg-transparent text-sm font-semibold text-foreground outline-none placeholder:text-muted-foreground/60 focus:ring-2 focus:ring-primary/40"
@@ -895,13 +920,13 @@ async function runTestStep() {
895
920
  the description at all. -->
896
921
  <button
897
922
  type="button"
898
- aria-label="What this node does"
923
+ :aria-label="t('integration.nodeInspector.header.whatThisDoes')"
899
924
  class="relative flex size-4 shrink-0 items-center justify-center rounded-full border border-black/[0.12] text-[10px] font-semibold leading-none transition after:absolute after:-inset-1 after:content-[''] hover:border-black/25 hover:bg-black/[0.04] hover:text-foreground"
900
925
  >
901
926
  ?
902
927
  </button>
903
928
  </PopoverTrigger>
904
- <PopoverContent aria-label="What this node does" align="start" class="w-96 rounded-lg border border-black/[0.06] bg-white p-4 shadow-lg">
929
+ <PopoverContent :aria-label="t('integration.nodeInspector.header.whatThisDoes')" align="start" class="w-96 rounded-lg border border-black/[0.06] bg-white p-4 shadow-lg">
905
930
  <div class="flex items-center gap-2.5">
906
931
  <span class="flex size-8 shrink-0 items-center justify-center rounded-md border border-black/[0.06] bg-slate-50 text-slate-600">
907
932
  <IntegrationsNodeMark :manifest="manifest" :size="18" />
@@ -943,13 +968,13 @@ async function runTestStep() {
943
968
  {{ noteLabel }}
944
969
  </button>
945
970
  </PopoverTrigger>
946
- <PopoverContent aria-label="Note" align="end" class="w-80 space-y-1.5 rounded-lg border border-black/[0.06] bg-white p-3 shadow-lg">
947
- <Label class="text-mono text-muted-foreground">Note</Label>
971
+ <PopoverContent :aria-label="t('integration.nodeInspector.note.label')" align="end" class="w-80 space-y-1.5 rounded-lg border border-black/[0.06] bg-white p-3 shadow-lg">
972
+ <Label class="text-mono text-muted-foreground">{{ t("integration.nodeInspector.note.label") }}</Label>
948
973
  <Textarea
949
- aria-label="Note"
974
+ :aria-label="t('integration.nodeInspector.note.label')"
950
975
  :model-value="draftNode.notes ?? ''"
951
976
  :rows="4"
952
- placeholder="Why this node is here…"
977
+ :placeholder="t('integration.nodeInspector.note.placeholder')"
953
978
  class="rounded-md border border-black/[0.08] ring-0 shadow-none text-sm bg-white"
954
979
  @update:model-value="writeNote"
955
980
  />
@@ -972,7 +997,7 @@ async function runTestStep() {
972
997
  @click="inputPaneOpen = !inputPaneOpen"
973
998
  >
974
999
  <Database :size="12" />
975
- Input data
1000
+ {{ t("integration.nodeInspector.header.inputData") }}
976
1001
  </button>
977
1002
  <!-- Icon-only controls: `title` alone leaves them nameless to a screen
978
1003
  reader (and unaddressable to a test without a CSS selector), so each
@@ -981,8 +1006,8 @@ async function runTestStep() {
981
1006
  <button
982
1007
  type="button"
983
1008
  :disabled="!hasPrev"
984
- aria-label="Previous step (saves changes)"
985
- title="Previous step (saves changes)"
1009
+ :aria-label="t('integration.nodeInspector.header.previousStep')"
1010
+ :title="t('integration.nodeInspector.header.previousStep')"
986
1011
  class="flex size-7 items-center justify-center rounded-md text-muted-foreground transition hover:bg-black/[0.04] hover:text-foreground disabled:pointer-events-none disabled:opacity-30"
987
1012
  @click="navigate(-1)"
988
1013
  >
@@ -991,8 +1016,8 @@ async function runTestStep() {
991
1016
  <button
992
1017
  type="button"
993
1018
  :disabled="!hasNext"
994
- aria-label="Next step (saves changes)"
995
- title="Next step (saves changes)"
1019
+ :aria-label="t('integration.nodeInspector.header.nextStep')"
1020
+ :title="t('integration.nodeInspector.header.nextStep')"
996
1021
  class="flex size-7 items-center justify-center rounded-md text-muted-foreground transition hover:bg-black/[0.04] hover:text-foreground disabled:pointer-events-none disabled:opacity-30"
997
1022
  @click="navigate(1)"
998
1023
  >
@@ -1003,8 +1028,8 @@ async function runTestStep() {
1003
1028
  (the DialogContent built-in X floats at its own offset). -->
1004
1029
  <button
1005
1030
  type="button"
1006
- aria-label="Close"
1007
- title="Close"
1031
+ :aria-label="t('integration.common.action.close')"
1032
+ :title="t('integration.common.action.close')"
1008
1033
  class="-mr-1.5 flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground transition hover:bg-black/[0.04] hover:text-foreground"
1009
1034
  @click="emit('close')"
1010
1035
  >
@@ -1018,18 +1043,48 @@ async function runTestStep() {
1018
1043
  <aside v-if="inputPaneOpen" class="flex w-[300px] shrink-0 flex-col border-r border-black/[0.06] bg-muted/10" @dragend="onChipDragEnd">
1019
1044
  <div class="shrink-0 border-b border-black/[0.06] px-3 py-2.5">
1020
1045
  <div class="text-mono text-muted-foreground">
1021
- Input
1046
+ {{ t("integration.nodeInspector.input.title") }}
1022
1047
  </div>
1023
1048
  <p class="mt-0.5 text-xs leading-snug text-muted-foreground">
1024
- Available data. Click a value to insert it into the focused field, or drag it onto one.
1049
+ {{ t("integration.nodeInspector.input.description") }}
1025
1050
  </p>
1051
+ <!-- What this node accepts, as opposed to the list below, which is what
1052
+ other nodes offer it. It sits in the header because somebody
1053
+ choosing a value is already reading here, and because the port
1054
+ belongs to the node being configured rather than to any row in the
1055
+ list (AC-20). -->
1056
+ <div v-if="isNode && selfInputs.length" class="mt-2 space-y-1 border-t border-black/[0.06] pt-2">
1057
+ <div v-for="p in selfInputs" :key="p.name" class="space-y-0.5">
1058
+ <div class="flex items-baseline gap-1.5">
1059
+ <!-- Not the uppercase treatment the section labels below use, and
1060
+ that is the point: NODES / TRIGGER / VARIABLES each head a list
1061
+ of values a click inserts, so they are a series of *sources*.
1062
+ This heads nothing and is not a source — it is a statement about
1063
+ the node being configured. Dressed as a fourth member of that
1064
+ series it told the reader there was a fourth place to take data
1065
+ from. -->
1066
+ <span
1067
+ class="text-xs font-medium text-muted-foreground"
1068
+ :class="selfInputs.length > 1 ? 'font-mono' : ''"
1069
+ >
1070
+ {{ selfInputs.length > 1 ? `${p.name}:` : t("integration.nodeInspector.input.expects.heading") }}
1071
+ </span>
1072
+ <span class="font-mono text-[10px] text-slate-500">
1073
+ {{ p.dataType }}<template v-if="p.dataType"> · </template>{{ p.required ? t("integration.nodeInspector.input.expects.required") : t("integration.nodeInspector.input.expects.optional") }}
1074
+ </span>
1075
+ </div>
1076
+ <p v-if="resolveLocalized(p.description)" class="text-xs leading-snug text-muted-foreground">
1077
+ {{ resolveLocalized(p.description) }}
1078
+ </p>
1079
+ </div>
1080
+ </div>
1026
1081
  <div class="relative mt-2">
1027
1082
  <Search :size="12" class="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
1028
1083
  <input
1029
1084
  v-model="inputSearch"
1030
1085
  type="text"
1031
- aria-label="Search data"
1032
- placeholder="Search data"
1086
+ :aria-label="t('integration.nodeInspector.input.searchAria')"
1087
+ :placeholder="t('integration.nodeInspector.input.searchAria')"
1033
1088
  class="h-7 w-full rounded-md border border-black/[0.08] bg-white pl-6.5 pr-2 text-xs outline-none transition placeholder:text-muted-foreground/60 focus:border-black/[0.16] focus:ring-2 focus:ring-primary/40"
1034
1089
  >
1035
1090
  </div>
@@ -1041,15 +1096,15 @@ async function runTestStep() {
1041
1096
  class="rounded-md border border-black/[0.08] bg-white px-2 py-1 text-[11px] font-medium text-muted-foreground transition hover:bg-muted/30 hover:text-foreground disabled:opacity-50"
1042
1097
  @click="loadRunData"
1043
1098
  >
1044
- {{ runLoading ? "Loading run data\u2026" : "Use last run data" }}
1099
+ {{ runLoading ? t("integration.nodeInspector.input.runData.loading") : t("integration.nodeInspector.input.runData.use") }}
1045
1100
  </button>
1046
1101
  <template v-else>
1047
1102
  <span class="min-w-0 flex-1 truncate text-[11px] text-emerald-700">
1048
- Values from run #{{ runInfo.id }}
1103
+ {{ t("integration.nodeInspector.input.runData.from", { id: runInfo.id }) }}
1049
1104
  </span>
1050
1105
  <button
1051
1106
  type="button"
1052
- title="Clear run data"
1107
+ :title="t('integration.nodeInspector.input.runData.clear')"
1053
1108
  class="flex size-5 shrink-0 items-center justify-center rounded text-muted-foreground transition hover:text-foreground"
1054
1109
  @click="clearRunData"
1055
1110
  >
@@ -1066,10 +1121,10 @@ async function runTestStep() {
1066
1121
  <!-- Upstream nodes -->
1067
1122
  <div v-if="!searchQuery || visibleSections.length" class="space-y-3">
1068
1123
  <div class="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
1069
- Nodes
1124
+ {{ t("integration.nodeInspector.input.nodes.heading") }}
1070
1125
  </div>
1071
1126
  <p v-if="!upstreamSections.length" class="text-xs leading-snug text-muted-foreground">
1072
- No upstream nodes yet. Connect a node into this one to reference its output here.
1127
+ {{ t("integration.nodeInspector.input.nodes.empty") }}
1073
1128
  </p>
1074
1129
  <div v-for="s in visibleSections" :key="s.id" class="rounded-md border border-black/[0.06]">
1075
1130
  <button
@@ -1083,13 +1138,36 @@ async function runTestStep() {
1083
1138
  <span
1084
1139
  v-if="!s.direct"
1085
1140
  class="shrink-0 rounded bg-slate-100 px-1 py-px text-[9px] font-medium uppercase tracking-wider text-slate-500"
1086
- title="Not directly connected; reachable further upstream"
1141
+ :title="t('integration.nodeInspector.input.nodes.indirectTitle')"
1087
1142
  >
1088
- upstream
1143
+ {{ t("integration.nodeInspector.input.nodes.indirectBadge") }}
1089
1144
  </span>
1090
1145
  </button>
1091
1146
  <div v-show="!sectionClosed(s.id)" class="space-y-2 border-t border-black/[0.06] px-2 py-2">
1092
- <div v-for="p in s.ports" :key="p.name" class="space-y-1 pl-0.5">
1147
+ <!-- These ports belong to an UPSTREAM node, so the sentence is that
1148
+ node's. On a 300px column it is a tooltip rather than text: the
1149
+ values below are what this panel is for, and a sentence per port
1150
+ pushes them out of view. The readable copy is the Output tab
1151
+ (AC-19).
1152
+
1153
+ The title hangs on the port's whole block, not on its name row:
1154
+ that row is only drawn for a node with several ports or a
1155
+ branch/error one, so a single `default` port — which is most
1156
+ nodes, No Operation among them — would have had nowhere to carry
1157
+ it and the tooltip would have been dead in the commonest case.
1158
+
1159
+ What that buys is uneven, and it is the reason this is a
1160
+ convenience rather than a promise: each field row carries its own
1161
+ title (the expression to copy), and a child title wins, so the
1162
+ sentence is read by resting on the port's name or the space around
1163
+ its rows rather than on a row itself. Where a reader is owed the
1164
+ text outright, that is the Output tab. -->
1165
+ <div
1166
+ v-for="p in s.ports"
1167
+ :key="p.name"
1168
+ class="space-y-1 pl-0.5"
1169
+ :title="resolveLocalized(p.description) || void 0"
1170
+ >
1093
1171
  <div v-if="s.ports.length > 1 || p.kind !== 'default'" class="flex items-center gap-1.5">
1094
1172
  <span class="size-1.5 rounded-full" :class="portDotClass(p.kind, p.name)" />
1095
1173
  <span class="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">{{ p.name }}</span>
@@ -1134,7 +1212,7 @@ async function runTestStep() {
1134
1212
  <!-- Trigger payload -->
1135
1213
  <div v-if="showTriggerChip" class="space-y-1.5">
1136
1214
  <div class="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
1137
- Trigger
1215
+ {{ t("integration.nodeInspector.input.trigger.heading") }}
1138
1216
  </div>
1139
1217
  <div class="flex flex-wrap gap-1">
1140
1218
  <button
@@ -1150,19 +1228,19 @@ async function runTestStep() {
1150
1228
  </button>
1151
1229
  </div>
1152
1230
  <p class="text-xs leading-snug text-muted-foreground">
1153
- The payload the run started with. Append a path for a single value, e.g. trigger.payload.orderId.
1231
+ {{ t("integration.nodeInspector.input.trigger.hint") }}
1154
1232
  </p>
1155
1233
  </div>
1156
1234
  </template>
1157
1235
 
1158
1236
  <p v-else class="text-xs leading-snug text-muted-foreground">
1159
- Triggers start the workflow, so there is no upstream data. Workflow variables are available everywhere.
1237
+ {{ t("integration.nodeInspector.input.noUpstreamForTrigger") }}
1160
1238
  </p>
1161
1239
 
1162
1240
  <!-- Workflow variables -->
1163
1241
  <div v-if="!searchQuery || visibleVariableNames.length" class="space-y-1.5">
1164
1242
  <div class="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
1165
- Variables
1243
+ {{ t("integration.nodeInspector.input.variables.heading") }}
1166
1244
  </div>
1167
1245
  <!-- Schema rows with the declared default as the value. -->
1168
1246
  <div v-if="variableRows.length" class="space-y-px">
@@ -1183,12 +1261,12 @@ async function runTestStep() {
1183
1261
  </button>
1184
1262
  </div>
1185
1263
  <p v-else class="text-xs leading-snug text-muted-foreground">
1186
- No workflow variables defined.
1264
+ {{ t("integration.nodeInspector.input.variables.empty") }}
1187
1265
  </p>
1188
1266
  </div>
1189
1267
 
1190
1268
  <p v-if="searchEmpty" class="text-xs leading-snug text-muted-foreground">
1191
- Nothing matches "{{ inputSearch.trim() }}". Clear the search to see all available data.
1269
+ {{ t("integration.nodeInspector.input.search.empty", { query: inputSearch.trim() }) }}
1192
1270
  </p>
1193
1271
  </div>
1194
1272
  </aside>
@@ -1198,6 +1276,19 @@ async function runTestStep() {
1198
1276
  <!-- Same TabBar as the workflow page — one tab look everywhere. -->
1199
1277
  <TabBar v-model="tab" :tabs="inspectorTabs" class="shrink-0 px-4" />
1200
1278
 
1279
+ <!-- The pinned version has no built code, so nothing here can be tested
1280
+ or resolved. Above the tabs' content rather than inside one of them:
1281
+ the state belongs to the node, not to a tab, and a reader who opens
1282
+ this node on Parameters would otherwise meet the card's mark outside
1283
+ and no word about it inside. Outside the scrolling area so it cannot
1284
+ be scrolled away from. -->
1285
+ <p
1286
+ v-if="isNode && !versionExecutable"
1287
+ class="mx-4 mb-1 shrink-0 rounded-md border border-rose-200 bg-rose-50 px-2.5 py-1.5 text-sm text-rose-700"
1288
+ >
1289
+ {{ readyVersion ? t("integration.nodeInspector.notice.versionNotReady.upgrade", { version: draftNode?.nodeVersion ?? "", ready: readyVersion }) : t("integration.nodeInspector.notice.versionNotReady.none", { version: draftNode?.nodeVersion ?? "" }) }}
1290
+ </p>
1291
+
1201
1292
  <div
1202
1293
  class="min-h-0 flex-1 overflow-y-auto p-4"
1203
1294
  :class="dragActive ? 'chip-drag-live' : ''"
@@ -1215,15 +1306,15 @@ async function runTestStep() {
1215
1306
  <!-- The name lives in the text next to the switch, which is not
1216
1307
  a <label> — so the switch says it itself. -->
1217
1308
  <Switch
1218
- aria-label="Active"
1309
+ :aria-label="t('integration.nodeInspector.trigger.active.label')"
1219
1310
  class="data-[state=unchecked]:bg-slate-300"
1220
1311
  :model-value="draftTrigger.active !== false"
1221
1312
  @update:model-value="(v) => patchTrigger({ active: v })"
1222
1313
  />
1223
1314
  <div>
1224
- <div class="text-sm font-medium">Active</div>
1315
+ <div class="text-sm font-medium">{{ t("integration.nodeInspector.trigger.active.label") }}</div>
1225
1316
  <p class="text-xs text-muted-foreground">
1226
- A run needs at least one active trigger.
1317
+ {{ t("integration.nodeInspector.trigger.active.hint") }}
1227
1318
  </p>
1228
1319
  </div>
1229
1320
  </div>
@@ -1237,29 +1328,29 @@ async function runTestStep() {
1237
1328
  <div class="space-y-1.5">
1238
1329
  <!-- "Frequency", not "Runs": a run is an execution everywhere
1239
1330
  else in the studio, and there is a whole page of them. -->
1240
- <Label for="ni-frequency" class="text-mono text-muted-foreground">Frequency</Label>
1331
+ <Label for="ni-frequency" class="text-mono text-muted-foreground">{{ t("integration.nodeInspector.schedule.frequency.label") }}</Label>
1241
1332
  <Select :model-value="frequency" @update:model-value="applyFrequency">
1242
- <SelectTrigger id="ni-frequency" aria-label="Frequency" size="sm" class="h-8 w-full rounded-md border border-black/[0.08] ring-0 shadow-none text-sm bg-white">
1333
+ <SelectTrigger id="ni-frequency" :aria-label="t('integration.nodeInspector.schedule.frequency.label')" size="sm" class="h-8 w-full rounded-md border border-black/[0.08] ring-0 shadow-none text-sm bg-white">
1243
1334
  <SelectValue />
1244
1335
  </SelectTrigger>
1245
1336
  <SelectContent>
1246
1337
  <SelectItem v-for="f in FREQUENCY_OPTIONS" :key="f.value" :value="f.value">{{ f.label }}</SelectItem>
1247
1338
  <SelectSeparator />
1248
- <SelectItem :value="FREQUENCY_ADVANCED">Advanced (cron)</SelectItem>
1339
+ <SelectItem :value="FREQUENCY_ADVANCED">{{ t("integration.nodeInspector.schedule.frequency.advanced") }}</SelectItem>
1249
1340
  </SelectContent>
1250
1341
  </Select>
1251
1342
  </div>
1252
1343
 
1253
1344
  <!-- Every few minutes: the gap between firings. -->
1254
1345
  <div v-if="frequency === 'minutes'" class="space-y-1.5">
1255
- <Label for="ni-minute-interval" class="text-mono text-muted-foreground">Interval</Label>
1346
+ <Label for="ni-minute-interval" class="text-mono text-muted-foreground">{{ t("integration.nodeInspector.schedule.interval.label") }}</Label>
1256
1347
  <Select :model-value="String(carry.minuteInterval)" @update:model-value="applyMinuteInterval">
1257
- <SelectTrigger id="ni-minute-interval" aria-label="Interval" size="sm" class="h-8 w-full rounded-md border border-black/[0.08] ring-0 shadow-none text-sm bg-white">
1348
+ <SelectTrigger id="ni-minute-interval" :aria-label="t('integration.nodeInspector.schedule.interval.label')" size="sm" class="h-8 w-full rounded-md border border-black/[0.08] ring-0 shadow-none text-sm bg-white">
1258
1349
  <SelectValue />
1259
1350
  </SelectTrigger>
1260
1351
  <SelectContent>
1261
1352
  <SelectItem v-for="n in minuteIntervalOptions" :key="n" :value="String(n)">
1262
- {{ n === 1 ? "Every minute" : `Every ${n} minutes` }}
1353
+ {{ t("integration.nodeInspector.schedule.minuteInterval.option", n) }}
1263
1354
  </SelectItem>
1264
1355
  </SelectContent>
1265
1356
  </Select>
@@ -1267,21 +1358,21 @@ async function runTestStep() {
1267
1358
 
1268
1359
  <!-- Every few hours: the gap, plus where in the hour it lands. -->
1269
1360
  <div v-if="frequency === 'hours'" class="space-y-1.5">
1270
- <Label for="ni-hour-interval" class="text-mono text-muted-foreground">Interval</Label>
1361
+ <Label for="ni-hour-interval" class="text-mono text-muted-foreground">{{ t("integration.nodeInspector.schedule.interval.label") }}</Label>
1271
1362
  <Select :model-value="String(carry.hourInterval)" @update:model-value="applyHourInterval">
1272
- <SelectTrigger id="ni-hour-interval" aria-label="Interval" size="sm" class="h-8 w-full rounded-md border border-black/[0.08] ring-0 shadow-none text-sm bg-white">
1363
+ <SelectTrigger id="ni-hour-interval" :aria-label="t('integration.nodeInspector.schedule.interval.label')" size="sm" class="h-8 w-full rounded-md border border-black/[0.08] ring-0 shadow-none text-sm bg-white">
1273
1364
  <SelectValue />
1274
1365
  </SelectTrigger>
1275
1366
  <SelectContent>
1276
- <SelectItem v-for="n in HOUR_INTERVALS" :key="n" :value="String(n)">Every {{ n }} hours</SelectItem>
1367
+ <SelectItem v-for="n in HOUR_INTERVALS" :key="n" :value="String(n)">{{ t("integration.nodeInspector.schedule.hourInterval.option", { n }) }}</SelectItem>
1277
1368
  </SelectContent>
1278
1369
  </Select>
1279
1370
  </div>
1280
1371
 
1281
1372
  <div v-if="frequency === 'hourly' || frequency === 'hours' || frequency === 'times'" class="space-y-1.5">
1282
- <Label for="ni-minute-past" class="text-mono text-muted-foreground">Minutes past the hour</Label>
1373
+ <Label for="ni-minute-past" class="text-mono text-muted-foreground">{{ t("integration.nodeInspector.schedule.minutePast.label") }}</Label>
1283
1374
  <Select :model-value="String(carry.minute)" @update:model-value="applyMinutePast">
1284
- <SelectTrigger id="ni-minute-past" aria-label="Minutes past the hour" size="sm" class="h-8 w-full rounded-md border border-black/[0.08] ring-0 shadow-none text-sm bg-white">
1375
+ <SelectTrigger id="ni-minute-past" :aria-label="t('integration.nodeInspector.schedule.minutePast.label')" size="sm" class="h-8 w-full rounded-md border border-black/[0.08] ring-0 shadow-none text-sm bg-white">
1285
1376
  <SelectValue />
1286
1377
  </SelectTrigger>
1287
1378
  <SelectContent>
@@ -1294,11 +1385,11 @@ async function runTestStep() {
1294
1385
  rhythms share, which is why it survives a change of
1295
1386
  frequency. -->
1296
1387
  <div v-if="frequency === 'daily' || frequency === 'weekly' || frequency === 'monthly'" class="space-y-1.5">
1297
- <Label for="ni-time" class="text-mono text-muted-foreground">Time</Label>
1388
+ <Label for="ni-time" class="text-mono text-muted-foreground">{{ t("integration.nodeInspector.schedule.time.label") }}</Label>
1298
1389
  <Input
1299
1390
  id="ni-time"
1300
1391
  type="time"
1301
- aria-label="Time"
1392
+ :aria-label="t('integration.nodeInspector.schedule.time.label')"
1302
1393
  :model-value="timeValue"
1303
1394
  class="h-8 rounded-md border border-black/[0.08] ring-0 shadow-none text-sm bg-white tabular-nums"
1304
1395
  @update:model-value="applyTime"
@@ -1306,9 +1397,9 @@ async function runTestStep() {
1306
1397
  </div>
1307
1398
 
1308
1399
  <div v-if="frequency === 'monthly'" class="space-y-1.5">
1309
- <Label for="ni-day-of-month" class="text-mono text-muted-foreground">Day of month</Label>
1400
+ <Label for="ni-day-of-month" class="text-mono text-muted-foreground">{{ t("integration.nodeInspector.schedule.dayOfMonth.label") }}</Label>
1310
1401
  <Select :model-value="String(carry.day)" @update:model-value="applyDayOfMonth">
1311
- <SelectTrigger id="ni-day-of-month" aria-label="Day of month" size="sm" class="h-8 w-full rounded-md border border-black/[0.08] ring-0 shadow-none text-sm bg-white">
1402
+ <SelectTrigger id="ni-day-of-month" :aria-label="t('integration.nodeInspector.schedule.dayOfMonth.label')" size="sm" class="h-8 w-full rounded-md border border-black/[0.08] ring-0 shadow-none text-sm bg-white">
1312
1403
  <SelectValue />
1313
1404
  </SelectTrigger>
1314
1405
  <SelectContent>
@@ -1319,10 +1410,10 @@ async function runTestStep() {
1319
1410
 
1320
1411
  <!-- Advanced: the expression itself, the only place it is shown. -->
1321
1412
  <div v-if="frequency === FREQUENCY_ADVANCED" class="space-y-1.5">
1322
- <Label for="ni-cron" class="text-mono text-muted-foreground">Cron expression</Label>
1413
+ <Label for="ni-cron" class="text-mono text-muted-foreground">{{ t("integration.nodeInspector.schedule.cron.label") }}</Label>
1323
1414
  <Input
1324
1415
  id="ni-cron"
1325
- aria-label="Cron expression"
1416
+ :aria-label="t('integration.nodeInspector.schedule.cron.label')"
1326
1417
  :model-value="cronValue"
1327
1418
  placeholder="0 9-17 * * 1-5"
1328
1419
  class="h-8 rounded-md border border-black/[0.08] ring-0 shadow-none text-sm bg-white font-mono"
@@ -1338,10 +1429,10 @@ async function runTestStep() {
1338
1429
  schedule triggers rather than one field per row.
1339
1430
  -->
1340
1431
  <div v-if="frequency === 'times'" class="space-y-1.5">
1341
- <Label class="text-mono text-muted-foreground">Hours</Label>
1432
+ <Label class="text-mono text-muted-foreground">{{ t("integration.nodeInspector.schedule.hours.label") }}</Label>
1342
1433
  <ToggleGroup
1343
1434
  type="multiple"
1344
- aria-label="Hours"
1435
+ :aria-label="t('integration.nodeInspector.schedule.hours.label')"
1345
1436
  :model-value="selectedHours"
1346
1437
  class="grid w-fit grid-cols-12 gap-1"
1347
1438
  @update:model-value="applyHours"
@@ -1361,10 +1452,10 @@ async function runTestStep() {
1361
1452
  <!-- Weekdays: full width, because seven toggles in half of the
1362
1453
  grid would wrap. Serves both rhythms that fall on days. -->
1363
1454
  <div v-if="frequency === 'weekly' || frequency === 'times'" class="space-y-1.5">
1364
- <Label class="text-mono text-muted-foreground">Days</Label>
1455
+ <Label class="text-mono text-muted-foreground">{{ t("integration.nodeInspector.schedule.days.label") }}</Label>
1365
1456
  <ToggleGroup
1366
1457
  type="multiple"
1367
- aria-label="Days"
1458
+ :aria-label="t('integration.nodeInspector.schedule.days.label')"
1368
1459
  :model-value="selectedDays"
1369
1460
  class="justify-start gap-1"
1370
1461
  @update:model-value="applyDays"
@@ -1386,25 +1477,25 @@ async function runTestStep() {
1386
1477
  the expression's numbers as wall-clock time in that zone, so a time
1387
1478
  without its zone would be half an answer.
1388
1479
  -->
1389
- <p v-if="scheduleSentence" aria-label="When it fires" class="text-xs text-emerald-700">{{ scheduleSentence }}</p>
1480
+ <p v-if="scheduleSentence" :aria-label="t('integration.nodeInspector.schedule.whenItFiresAria')" class="text-xs text-emerald-700">{{ scheduleSentence }}</p>
1390
1481
  <!-- No expression at all is its own answer, and the same one the
1391
1482
  Schedules page gives for this trigger: it is not written as
1392
1483
  anything, and it does not run. A trigger placed from the palette
1393
1484
  carries an expression, so this is for one that arrived without
1394
1485
  one. -->
1395
- <p v-else-if="!hasCron" aria-label="When it fires" class="text-xs text-muted-foreground">
1396
- No schedule set — pick a frequency, or write an expression.
1486
+ <p v-else-if="!hasCron" :aria-label="t('integration.nodeInspector.schedule.whenItFiresAria')" class="text-xs text-muted-foreground">
1487
+ {{ t("integration.nodeInspector.schedule.noneSet") }}
1397
1488
  </p>
1398
1489
  <p v-else class="text-xs text-muted-foreground">
1399
- This schedule is written as an expression these settings cannot show. It runs exactly as written.
1490
+ {{ t("integration.nodeInspector.schedule.writtenAsExpression") }}
1400
1491
  </p>
1401
1492
  <p v-if="frequency === 'monthly' && carry.day >= 29" class="text-xs text-muted-foreground">
1402
- Months without this day are skipped.
1493
+ {{ t("integration.nodeInspector.schedule.monthlySkipHint") }}
1403
1494
  </p>
1404
1495
  </template>
1405
1496
  <template v-else-if="draftTrigger.type === 'webhook'">
1406
1497
  <div class="space-y-1.5">
1407
- <Label for="ni-webhook-url" class="text-mono text-muted-foreground">Webhook URL</Label>
1498
+ <Label for="ni-webhook-url" class="text-mono text-muted-foreground">{{ t("integration.nodeInspector.trigger.webhook.url.label") }}</Label>
1408
1499
  <div v-if="webhookUrl" class="flex items-center gap-1.5">
1409
1500
  <Input
1410
1501
  id="ni-webhook-url"
@@ -1415,7 +1506,7 @@ async function runTestStep() {
1415
1506
  />
1416
1507
  <button
1417
1508
  type="button"
1418
- title="Copy URL"
1509
+ :title="t('integration.nodeInspector.trigger.webhook.url.copyTitle')"
1419
1510
  class="flex size-8 shrink-0 items-center justify-center rounded-md border border-black/[0.08] bg-white text-muted-foreground transition hover:text-foreground"
1420
1511
  @click="copyWebhookUrl"
1421
1512
  >
@@ -1423,27 +1514,27 @@ async function runTestStep() {
1423
1514
  </button>
1424
1515
  </div>
1425
1516
  <p class="text-xs text-muted-foreground">
1426
- <template v-if="webhookIsPublic">Public ingress endpoint — callers authenticate with the endpoint secret issued when the trigger was exposed.</template>
1427
- <template v-else>Authenticated endpoint: callers need a platform API token (Bearer). Enable the public endpoint for external systems.</template>
1517
+ <template v-if="webhookIsPublic">{{ t("integration.nodeInspector.trigger.webhook.url.publicHint") }}</template>
1518
+ <template v-else>{{ t("integration.nodeInspector.trigger.webhook.url.privateHint") }}</template>
1428
1519
  </p>
1429
1520
  </div>
1430
1521
  <div class="flex items-center gap-3 rounded-lg border border-black/[0.06] bg-muted/20 px-3 py-2">
1431
1522
  <Switch
1432
- aria-label="Public endpoint"
1523
+ :aria-label="t('integration.nodeInspector.trigger.webhook.public.label')"
1433
1524
  class="data-[state=unchecked]:bg-slate-300"
1434
1525
  :model-value="webhookIsPublic"
1435
1526
  @update:model-value="(v) => writeTriggerConfigPatch(v ? { public: true, ...draftTrigger.config.authProfile ? {} : configPatchForAuthChoice('token') } : { public: void 0 })"
1436
1527
  />
1437
1528
  <div>
1438
- <div class="text-sm font-medium">Public endpoint</div>
1529
+ <div class="text-sm font-medium">{{ t("integration.nodeInspector.trigger.webhook.public.label") }}</div>
1439
1530
  <p class="text-xs text-muted-foreground">
1440
- Saving provisions a public URL on the webhook ingress (with its own endpoint secret) — for external systems without a platform token.
1531
+ {{ t("integration.nodeInspector.trigger.webhook.public.hint") }}
1441
1532
  </p>
1442
1533
  </div>
1443
1534
  </div>
1444
1535
  <div class="grid grid-cols-2 gap-3">
1445
1536
  <div class="space-y-1.5">
1446
- <Label for="ni-webhook-method" class="text-mono text-muted-foreground">Method</Label>
1537
+ <Label for="ni-webhook-method" class="text-mono text-muted-foreground">{{ t("integration.nodeInspector.trigger.webhook.method.label") }}</Label>
1447
1538
  <Select
1448
1539
  :model-value="readTriggerConfig('method') ?? 'POST'"
1449
1540
  @update:model-value="(v) => writeTriggerConfig('method', v)"
@@ -1461,23 +1552,23 @@ async function runTestStep() {
1461
1552
  </Select>
1462
1553
  </div>
1463
1554
  <div v-if="webhookIsPublic" class="space-y-1.5">
1464
- <Label for="ni-webhook-protection" class="text-mono text-muted-foreground">Protection</Label>
1555
+ <Label for="ni-webhook-protection" class="text-mono text-muted-foreground">{{ t("integration.nodeInspector.trigger.webhook.protection.label") }}</Label>
1465
1556
  <Select :model-value="authChoice" @update:model-value="applyAuthChoice">
1466
1557
  <SelectTrigger id="ni-webhook-protection" size="sm" class="h-8 w-full rounded-md border border-black/[0.08] ring-0 shadow-none text-sm bg-white">
1467
1558
  <SelectValue />
1468
1559
  </SelectTrigger>
1469
1560
  <SelectContent>
1470
- <SelectItem value="token">API key / token header</SelectItem>
1471
- <SelectItem value="bearer">Bearer token</SelectItem>
1472
- <SelectItem value="basic">Basic auth</SelectItem>
1473
- <SelectItem value="none">None (capture everything)</SelectItem>
1561
+ <SelectItem value="token">{{ t("integration.nodeInspector.trigger.webhook.protection.token") }}</SelectItem>
1562
+ <SelectItem value="bearer">{{ t("integration.nodeInspector.trigger.webhook.protection.bearer") }}</SelectItem>
1563
+ <SelectItem value="basic">{{ t("integration.nodeInspector.trigger.webhook.protection.basic") }}</SelectItem>
1564
+ <SelectItem value="none">{{ t("integration.nodeInspector.trigger.webhook.protection.none") }}</SelectItem>
1474
1565
  </SelectContent>
1475
1566
  </Select>
1476
- <p class="text-xs text-muted-foreground">Verified by the webhook ingress before the workflow runs.</p>
1567
+ <p class="text-xs text-muted-foreground">{{ t("integration.nodeInspector.trigger.webhook.protection.hint") }}</p>
1477
1568
  </div>
1478
1569
  </div>
1479
1570
  <div v-if="webhookIsPublic && authChoice === 'token'" class="space-y-1.5">
1480
- <Label for="ni-auth-header" class="text-mono text-muted-foreground">Header name</Label>
1571
+ <Label for="ni-auth-header" class="text-mono text-muted-foreground">{{ t("integration.nodeInspector.trigger.webhook.header.label") }}</Label>
1481
1572
  <Input
1482
1573
  id="ni-auth-header"
1483
1574
  :model-value="draftTrigger.config.authConfig?.header ?? ''"
@@ -1485,33 +1576,34 @@ async function runTestStep() {
1485
1576
  class="h-8 rounded-md border border-black/[0.08] ring-0 shadow-none bg-white text-sm"
1486
1577
  @update:model-value="(v) => writeAuthConfigKey('header', String(v))"
1487
1578
  />
1488
- <p class="text-xs text-muted-foreground">The header carrying the endpoint secret (e.g. X-Api-Key). Empty uses X-Webhook-Token.</p>
1579
+ <p class="text-xs text-muted-foreground">{{ t("integration.nodeInspector.trigger.webhook.header.hint") }}</p>
1489
1580
  </div>
1490
1581
  <div v-if="webhookIsPublic && authChoice === 'basic'" class="space-y-1.5">
1491
- <Label for="ni-auth-username" class="text-mono text-muted-foreground">Username</Label>
1582
+ <Label for="ni-auth-username" class="text-mono text-muted-foreground">{{ t("integration.nodeInspector.trigger.webhook.username.label") }}</Label>
1492
1583
  <Input
1493
1584
  id="ni-auth-username"
1494
1585
  :model-value="draftTrigger.config.authConfig?.username ?? ''"
1495
- placeholder="Any username"
1586
+ :placeholder="t('integration.nodeInspector.trigger.webhook.username.placeholder')"
1496
1587
  class="h-8 rounded-md border border-black/[0.08] ring-0 shadow-none bg-white text-sm"
1497
1588
  @update:model-value="(v) => writeAuthConfigKey('username', String(v))"
1498
1589
  />
1499
- <p class="text-xs text-muted-foreground">Optional — pins the Basic-auth username. The password is the endpoint secret.</p>
1590
+ <p class="text-xs text-muted-foreground">{{ t("integration.nodeInspector.trigger.webhook.username.hint") }}</p>
1500
1591
  </div>
1501
1592
  <div v-if="webhookExposed && authChoice !== 'none'" class="flex items-center gap-2">
1502
1593
  <Button type="button" variant="outline" size="sm" class="h-7 text-sm" :disabled="rotateBusy" @click="rotateEndpointSecret">
1503
- {{ rotateBusy ? "Rotating\u2026" : "Rotate endpoint secret" }}
1594
+ {{ rotateBusy ? t("integration.nodeInspector.trigger.webhook.rotate.busy") : t("integration.nodeInspector.trigger.webhook.rotate.action") }}
1504
1595
  </Button>
1505
- <p class="text-xs text-muted-foreground">Issues a new secret (shown once); the previous one keeps working briefly.</p>
1596
+ <p class="text-xs text-muted-foreground">{{ t("integration.nodeInspector.trigger.webhook.rotate.hint") }}</p>
1506
1597
  </div>
1507
1598
 
1508
1599
  <Dialog v-model:open="rotateOpen">
1509
1600
  <DialogContent class="sm:max-w-md">
1510
1601
  <DialogHeader>
1511
- <DialogTitle>New endpoint secret</DialogTitle>
1602
+ <DialogTitle>{{ t("integration.nodeInspector.trigger.webhook.rotate.dialogTitle") }}</DialogTitle>
1512
1603
  <DialogDescription>
1513
- Copy it now — it is shown only this once. Give it to the calling system
1514
- ({{ authChoice === "basic" ? "as the Basic-auth password" : authChoice === "bearer" ? "as the Bearer token" : "in the token header" }}).
1604
+ {{ t("integration.nodeInspector.trigger.webhook.rotate.dialogHint", {
1605
+ where: authChoice === "basic" ? t("integration.nodeInspector.trigger.webhook.rotate.whereBasic") : authChoice === "bearer" ? t("integration.nodeInspector.trigger.webhook.rotate.whereBearer") : t("integration.nodeInspector.trigger.webhook.rotate.whereHeader")
1606
+ }) }}
1515
1607
  </DialogDescription>
1516
1608
  </DialogHeader>
1517
1609
  <div class="flex items-center gap-1.5">
@@ -1523,7 +1615,7 @@ async function runTestStep() {
1523
1615
  />
1524
1616
  <button
1525
1617
  type="button"
1526
- title="Copy secret"
1618
+ :title="t('integration.nodeInspector.trigger.webhook.rotate.copyTitle')"
1527
1619
  class="flex size-8 shrink-0 items-center justify-center rounded-md border border-black/[0.08] bg-white text-muted-foreground transition hover:text-foreground"
1528
1620
  @click="copySecret"
1529
1621
  >
@@ -1535,36 +1627,36 @@ async function runTestStep() {
1535
1627
  </template>
1536
1628
  <template v-else-if="draftTrigger.type === 'event'">
1537
1629
  <div class="space-y-1.5">
1538
- <Label for="ni-event-type" class="text-mono text-muted-foreground">Event</Label>
1630
+ <Label for="ni-event-type" class="text-mono text-muted-foreground">{{ t("integration.nodeInspector.trigger.event.label") }}</Label>
1539
1631
  <EventTypeCombobox
1540
1632
  input-id="ni-event-type"
1541
1633
  :model-value="readTriggerConfig('event_type') ?? ''"
1542
1634
  @update:model-value="(v) => writeTriggerConfig('event_type', v)"
1543
1635
  />
1544
1636
  <p class="text-xs text-muted-foreground">
1545
- Runs whenever this platform event fires (e.g. <code class="font-mono">customer.created</code>, <code class="font-mono">order.placed</code>). The event payload is available to the workflow as <code class="font-mono">trigger.payload</code>.
1637
+ {{ t("integration.nodeInspector.trigger.event.hint.intro") }} <code class="font-mono">customer.created</code>, <code class="font-mono">order.placed</code>{{ t("integration.nodeInspector.trigger.event.hint.mid") }} <code class="font-mono">trigger.payload</code>.
1546
1638
  </p>
1547
1639
  </div>
1548
1640
  <div v-if="eventSample" class="space-y-1.5">
1549
1641
  <!-- A caption, not a field label: it heads a read-only block,
1550
1642
  so it stays a plain line rather than an orphan <label>. -->
1551
- <div class="text-mono text-muted-foreground">Sample payload</div>
1643
+ <div class="text-mono text-muted-foreground">{{ t("integration.nodeInspector.trigger.event.sample.heading") }}</div>
1552
1644
  <pre class="max-h-72 overflow-auto rounded-md border border-black/[0.06] bg-muted/20 p-2.5 font-mono text-[11px] leading-relaxed text-slate-700">{{ eventSample }}</pre>
1553
- <p class="text-xs text-muted-foreground">From the event catalogue — the shape this event delivers as <code class="font-mono">trigger.payload</code>.</p>
1645
+ <p class="text-xs text-muted-foreground">{{ t("integration.nodeInspector.trigger.event.sample.hint") }} <code class="font-mono">trigger.payload</code>.</p>
1554
1646
  </div>
1555
1647
  <p v-else-if="eventTopic" class="text-xs text-muted-foreground">
1556
- No sample payload in the event catalogue for this topic.
1648
+ {{ t("integration.nodeInspector.trigger.event.sample.empty") }}
1557
1649
  </p>
1558
1650
  </template>
1559
1651
  <template v-else>
1560
1652
  <div class="space-y-1.5">
1561
1653
  <!-- Caption for the field list below, not a label for one
1562
1654
  control — the row's own controls are named individually. -->
1563
- <div class="text-mono text-muted-foreground">Run payload fields</div>
1655
+ <div class="text-mono text-muted-foreground">{{ t("integration.nodeInspector.trigger.manual.heading") }}</div>
1564
1656
  <p class="text-xs text-muted-foreground">
1565
- What the Run dialog asks for when this workflow is started by hand. Each field lands in
1566
- <code class="font-mono">trigger.payload.&lt;key&gt;</code>; a File field becomes a manual upload.
1567
- Fields referenced by nodes via <code class="font-mono">trigger.payload.*</code> are added automatically.
1657
+ {{ t("integration.nodeInspector.trigger.manual.hint.intro") }}
1658
+ <code class="font-mono">trigger.payload.&lt;key&gt;</code>{{ t("integration.nodeInspector.trigger.manual.hint.mid") }}
1659
+ <code class="font-mono">trigger.payload.*</code> {{ t("integration.nodeInspector.trigger.manual.hint.outro") }}
1568
1660
  </p>
1569
1661
  </div>
1570
1662
  <div v-if="manualFields.length" class="space-y-1.5">
@@ -1572,41 +1664,41 @@ async function runTestStep() {
1572
1664
  <!-- A repeating row has no room for visible labels, so each
1573
1665
  control carries its own name and says which row it is. -->
1574
1666
  <Select :model-value="mf.kind ?? 'text'" @update:model-value="(v) => setManualField(idx, { kind: v })">
1575
- <SelectTrigger :aria-label="`Kind of payload field ${idx + 1}`" size="sm" class="h-8 w-24 shrink-0 rounded-md border border-black/[0.08] ring-0 shadow-none text-sm bg-white">
1667
+ <SelectTrigger :aria-label="t('integration.nodeInspector.trigger.manual.field.kindAria', { n: idx + 1 })" size="sm" class="h-8 w-24 shrink-0 rounded-md border border-black/[0.08] ring-0 shadow-none text-sm bg-white">
1576
1668
  <SelectValue />
1577
1669
  </SelectTrigger>
1578
1670
  <SelectContent>
1579
- <SelectItem value="text">Text</SelectItem>
1580
- <SelectItem value="file">File</SelectItem>
1671
+ <SelectItem value="text">{{ t("integration.nodeInspector.trigger.manual.kind.text") }}</SelectItem>
1672
+ <SelectItem value="file">{{ t("integration.nodeInspector.trigger.manual.kind.file") }}</SelectItem>
1581
1673
  </SelectContent>
1582
1674
  </Select>
1583
1675
  <Input
1584
- :aria-label="`Key of payload field ${idx + 1}`"
1676
+ :aria-label="t('integration.nodeInspector.trigger.manual.field.keyAria', { n: idx + 1 })"
1585
1677
  :model-value="mf.path"
1586
- placeholder="key (e.g. orderId or import.file)"
1678
+ :placeholder="t('integration.nodeInspector.trigger.manual.field.keyPlaceholder')"
1587
1679
  class="h-8 w-[38%] rounded-md border border-black/[0.08] ring-0 shadow-none bg-white font-mono text-sm"
1588
1680
  @update:model-value="(v) => setManualField(idx, { path: String(v) })"
1589
1681
  />
1590
1682
  <Input
1591
- :aria-label="`Label of payload field ${idx + 1}`"
1683
+ :aria-label="t('integration.nodeInspector.trigger.manual.field.labelAria', { n: idx + 1 })"
1592
1684
  :model-value="mf.label ?? ''"
1593
- placeholder="Label shown in the Run dialog"
1685
+ :placeholder="t('integration.nodeInspector.trigger.manual.field.labelPlaceholder')"
1594
1686
  class="h-8 min-w-0 flex-1 rounded-md border border-black/[0.08] ring-0 shadow-none bg-white text-sm"
1595
1687
  @update:model-value="(v) => setManualField(idx, { label: String(v) })"
1596
1688
  />
1597
1689
  <button
1598
1690
  type="button"
1599
- :aria-label="`Remove payload field ${idx + 1}`"
1691
+ :aria-label="t('integration.nodeInspector.trigger.manual.field.removeAria', { n: idx + 1 })"
1600
1692
  class="flex size-7 shrink-0 items-center justify-center rounded text-muted-foreground transition hover:text-rose-600"
1601
1693
  @click="removeManualField(idx)"
1602
1694
  >
1603
- <Trash2 :size="13" />
1695
+ <TrashBin :size="13" />
1604
1696
  </button>
1605
1697
  </div>
1606
1698
  </div>
1607
1699
  <Button type="button" variant="outline" size="sm" class="h-7 text-sm" @click="addManualField">
1608
1700
  <Plus :size="12" />
1609
- Add field
1701
+ {{ t("integration.nodeInspector.trigger.manual.action.addField") }}
1610
1702
  </Button>
1611
1703
  </template>
1612
1704
  </div>
@@ -1615,23 +1707,23 @@ async function runTestStep() {
1615
1707
  <div v-else-if="tab === 'parameters' && draftNode" class="mx-auto w-full max-w-5xl">
1616
1708
  <div v-if="manifest?.config?.length" class="space-y-3">
1617
1709
  <div class="flex items-center justify-between">
1618
- <span class="text-mono text-muted-foreground">Configuration</span>
1710
+ <span class="text-mono text-muted-foreground">{{ t("integration.nodeInspector.parameters.heading") }}</span>
1619
1711
  <div class="flex items-center gap-2">
1620
1712
  <button
1621
1713
  type="button"
1622
1714
  class="flex items-center gap-1 rounded px-1 py-0.5 text-sm text-muted-foreground transition hover:bg-muted/50 hover:text-foreground"
1623
- title="Copy this node's config as JSON"
1715
+ :title="t('integration.nodeInspector.parameters.copyTitle')"
1624
1716
  @click="copyConfig"
1625
1717
  >
1626
- <Copy :size="12" /> Copy
1718
+ <Copy :size="12" /> {{ t("integration.common.action.copy") }}
1627
1719
  </button>
1628
1720
  <button
1629
1721
  type="button"
1630
1722
  class="rounded px-1 py-0.5 text-sm text-muted-foreground transition hover:bg-muted/50 hover:text-foreground"
1631
- title="Replace this node's config from a JSON object in the clipboard"
1723
+ :title="t('integration.nodeInspector.parameters.pasteTitle')"
1632
1724
  @click="pasteConfig"
1633
1725
  >
1634
- Paste
1726
+ {{ t("integration.common.action.paste") }}
1635
1727
  </button>
1636
1728
  </div>
1637
1729
  </div>
@@ -1644,6 +1736,7 @@ async function runTestStep() {
1644
1736
  :version="draftNode.nodeVersion"
1645
1737
  :secret-keys="secretKeys"
1646
1738
  :static-keys="(manifest.config ?? []).map((f2) => f2.key)"
1739
+ :sibling-fields="manifest.config ?? []"
1647
1740
  :depth="0"
1648
1741
  :variables="variables"
1649
1742
  :upstream-outputs="upstreamOutputs"
@@ -1662,7 +1755,7 @@ async function runTestStep() {
1662
1755
  @click="optionalOpen = true"
1663
1756
  >
1664
1757
  <ChevronRight :size="13" />
1665
- Show {{ optionalFields.length }} optional setting{{ optionalFields.length === 1 ? "" : "s" }}
1758
+ {{ t("integration.nodeInspector.parameters.optional.show", optionalFields.length) }}
1666
1759
  </button>
1667
1760
  <template v-else>
1668
1761
  <button
@@ -1672,7 +1765,7 @@ async function runTestStep() {
1672
1765
  @click="optionalOpen = false"
1673
1766
  >
1674
1767
  <ChevronDown :size="13" />
1675
- Hide {{ optionalFields.length }} optional setting{{ optionalFields.length === 1 ? "" : "s" }}
1768
+ {{ t("integration.nodeInspector.parameters.optional.hide", optionalFields.length) }}
1676
1769
  </button>
1677
1770
  <IntegrationsNodeConfigField
1678
1771
  v-for="field in optionalFields"
@@ -1683,6 +1776,7 @@ async function runTestStep() {
1683
1776
  :version="draftNode.nodeVersion"
1684
1777
  :secret-keys="secretKeys"
1685
1778
  :static-keys="(manifest.config ?? []).map((f2) => f2.key)"
1779
+ :sibling-fields="manifest.config ?? []"
1686
1780
  :depth="0"
1687
1781
  :variables="variables"
1688
1782
  :upstream-outputs="upstreamOutputs"
@@ -1693,11 +1787,11 @@ async function runTestStep() {
1693
1787
  </template>
1694
1788
 
1695
1789
  <p v-if="!parameterFields.length" class="text-sm text-muted-foreground italic">
1696
- All of this node's options live on the Settings tab.
1790
+ {{ t("integration.nodeInspector.parameters.allOnSettingsTab") }}
1697
1791
  </p>
1698
1792
  </div>
1699
1793
  <div v-else class="text-sm text-muted-foreground italic">
1700
- This node has no configuration.
1794
+ {{ t("integration.nodeInspector.parameters.noConfiguration") }}
1701
1795
  </div>
1702
1796
  </div>
1703
1797
 
@@ -1710,9 +1804,9 @@ async function runTestStep() {
1710
1804
  identical-looking numbers with nothing to tell them apart. -->
1711
1805
  <div v-if="settingsFields.length" class="space-y-3">
1712
1806
  <div>
1713
- <span class="text-mono text-muted-foreground">Options of this node</span>
1807
+ <span class="text-mono text-muted-foreground">{{ t("integration.nodeInspector.settings.ownOptions.heading") }}</span>
1714
1808
  <p class="text-xs text-muted-foreground">
1715
- Settings this node type defines for itself.
1809
+ {{ t("integration.nodeInspector.settings.ownOptions.hint") }}
1716
1810
  </p>
1717
1811
  </div>
1718
1812
  <IntegrationsNodeConfigField
@@ -1724,6 +1818,7 @@ async function runTestStep() {
1724
1818
  :version="draftNode.nodeVersion"
1725
1819
  :secret-keys="secretKeys"
1726
1820
  :static-keys="(manifest?.config ?? []).map((f2) => f2.key)"
1821
+ :sibling-fields="manifest?.config ?? []"
1727
1822
  :depth="0"
1728
1823
  :variables="variables"
1729
1824
  :upstream-outputs="upstreamOutputs"
@@ -1734,9 +1829,9 @@ async function runTestStep() {
1734
1829
  <!-- The rule only earns its place when there is something above to
1735
1830
  divide from. -->
1736
1831
  <div :class="settingsFields.length ? 'border-t border-black/[0.06] pt-5' : ''">
1737
- <span class="text-mono text-muted-foreground">How the engine runs this step</span>
1832
+ <span class="text-mono text-muted-foreground">{{ t("integration.nodeInspector.settings.engine.heading") }}</span>
1738
1833
  <p class="text-xs text-muted-foreground">
1739
- The same settings on every node type, applied by the engine around whatever the node does.
1834
+ {{ t("integration.nodeInspector.settings.engine.hint") }}
1740
1835
  </p>
1741
1836
  </div>
1742
1837
 
@@ -1746,21 +1841,21 @@ async function runTestStep() {
1746
1841
  <section class="rounded-lg border border-black/[0.06] bg-muted/20">
1747
1842
  <div class="flex items-center gap-3 px-3 py-2">
1748
1843
  <Switch
1749
- aria-label="Retry on failure"
1844
+ :aria-label="t('integration.nodeInspector.settings.retry.label')"
1750
1845
  class="data-[state=unchecked]:bg-slate-300"
1751
1846
  :model-value="!!draftNode.retry"
1752
1847
  @update:model-value="(v) => patchNode({ retry: v ? { maxAttempts: 3, backoffSeconds: 1, strategy: 'exponential' } : void 0 })"
1753
1848
  />
1754
1849
  <div>
1755
- <div class="text-sm font-medium">Retry on failure</div>
1850
+ <div class="text-sm font-medium">{{ t("integration.nodeInspector.settings.retry.label") }}</div>
1756
1851
  <p class="text-xs text-muted-foreground">
1757
- Re-run this node automatically when an attempt fails.
1852
+ {{ t("integration.nodeInspector.settings.retry.hint") }}
1758
1853
  </p>
1759
1854
  </div>
1760
1855
  </div>
1761
1856
  <div v-if="draftNode.retry" class="grid grid-cols-4 gap-3 border-t border-black/[0.06] p-3">
1762
1857
  <div class="space-y-1.5">
1763
- <Label for="ni-retry-attempts" class="text-mono text-muted-foreground">Max attempts</Label>
1858
+ <Label for="ni-retry-attempts" class="text-mono text-muted-foreground">{{ t("integration.nodeInspector.settings.retry.maxAttempts") }}</Label>
1764
1859
  <Input
1765
1860
  id="ni-retry-attempts"
1766
1861
  type="number"
@@ -1771,7 +1866,7 @@ async function runTestStep() {
1771
1866
  />
1772
1867
  </div>
1773
1868
  <div class="space-y-1.5">
1774
- <Label for="ni-retry-backoff" class="text-mono text-muted-foreground">Backoff seconds</Label>
1869
+ <Label for="ni-retry-backoff" class="text-mono text-muted-foreground">{{ t("integration.nodeInspector.settings.retry.backoffSeconds") }}</Label>
1775
1870
  <Input
1776
1871
  id="ni-retry-backoff"
1777
1872
  type="number"
@@ -1782,7 +1877,7 @@ async function runTestStep() {
1782
1877
  />
1783
1878
  </div>
1784
1879
  <div class="col-span-2 space-y-1.5">
1785
- <Label for="ni-retry-strategy" class="text-mono text-muted-foreground">Strategy</Label>
1880
+ <Label for="ni-retry-strategy" class="text-mono text-muted-foreground">{{ t("integration.nodeInspector.settings.retry.strategy") }}</Label>
1786
1881
  <Select
1787
1882
  :model-value="draftNode.retry?.strategy ?? 'exponential'"
1788
1883
  @update:model-value="(v) => patchNode({ retry: { ...draftNode.retry, strategy: v } })"
@@ -1791,8 +1886,8 @@ async function runTestStep() {
1791
1886
  <SelectValue />
1792
1887
  </SelectTrigger>
1793
1888
  <SelectContent>
1794
- <SelectItem value="linear">linear backoff</SelectItem>
1795
- <SelectItem value="exponential">exponential backoff</SelectItem>
1889
+ <SelectItem value="linear">{{ t("integration.nodeInspector.settings.retry.strategyLinear") }}</SelectItem>
1890
+ <SelectItem value="exponential">{{ t("integration.nodeInspector.settings.retry.strategyExponential") }}</SelectItem>
1796
1891
  </SelectContent>
1797
1892
  </Select>
1798
1893
  </div>
@@ -1801,7 +1896,7 @@ async function runTestStep() {
1801
1896
 
1802
1897
  <!-- Timeout: engine start-to-close ceiling for one attempt -->
1803
1898
  <section class="space-y-1.5">
1804
- <Label for="ni-timeout" class="text-mono text-muted-foreground">Timeout</Label>
1899
+ <Label for="ni-timeout" class="text-mono text-muted-foreground">{{ t("integration.nodeInspector.settings.timeout.label") }}</Label>
1805
1900
  <Input
1806
1901
  id="ni-timeout"
1807
1902
  type="number"
@@ -1813,13 +1908,13 @@ async function runTestStep() {
1813
1908
  @update:model-value="(v) => patchNode({ timeoutSeconds: Number(v) > 0 ? Math.min(Math.trunc(Number(v)), 7200) : void 0 })"
1814
1909
  />
1815
1910
  <p class="text-xs text-muted-foreground">
1816
- Seconds per attempt before the engine cancels the node. Empty uses the default (10 minutes); maximum 2 hours.
1911
+ {{ t("integration.nodeInspector.settings.timeout.hint") }}
1817
1912
  </p>
1818
1913
  </section>
1819
1914
 
1820
1915
  <!-- On error: what happens once the retries are exhausted -->
1821
1916
  <section class="space-y-1.5">
1822
- <Label for="ni-on-error" class="text-mono text-muted-foreground">On error</Label>
1917
+ <Label for="ni-on-error" class="text-mono text-muted-foreground">{{ t("integration.nodeInspector.settings.onError.label") }}</Label>
1823
1918
  <Select
1824
1919
  :model-value="draftNode.onError ?? 'fail'"
1825
1920
  @update:model-value="(v) => patchNode({ onError: v === 'route' ? 'route' : void 0 })"
@@ -1828,12 +1923,12 @@ async function runTestStep() {
1828
1923
  <SelectValue />
1829
1924
  </SelectTrigger>
1830
1925
  <SelectContent>
1831
- <SelectItem value="fail">Fail the run</SelectItem>
1832
- <SelectItem value="route">Route to error output</SelectItem>
1926
+ <SelectItem value="fail">{{ t("integration.nodeInspector.settings.onError.fail") }}</SelectItem>
1927
+ <SelectItem value="route">{{ t("integration.nodeInspector.settings.onError.route") }}</SelectItem>
1833
1928
  </SelectContent>
1834
1929
  </Select>
1835
1930
  <p class="text-xs text-muted-foreground">
1836
- Applies after the retry policy is exhausted. A Stop and Error node always fails the run.
1931
+ {{ t("integration.nodeInspector.settings.onError.hint") }}
1837
1932
  </p>
1838
1933
  </section>
1839
1934
  </div>
@@ -1845,15 +1940,14 @@ async function runTestStep() {
1845
1940
  <section class="space-y-2">
1846
1941
  <div class="flex items-center justify-between gap-2">
1847
1942
  <p class="text-xs text-muted-foreground">
1848
- What downstream nodes receive from this one. Declared by the node type;
1849
- filled with real values once the step has run.
1943
+ {{ t("integration.nodeInspector.output.description") }}
1850
1944
  </p>
1851
1945
  <div class="flex shrink-0 items-center rounded-md border border-black/[0.06] bg-white p-0.5">
1852
1946
  <button type="button" :class="outputView === 'schema' ? segActive : segIdle" @click="outputView = 'schema'">
1853
- Schema
1947
+ {{ t("integration.nodeInspector.output.view.schema") }}
1854
1948
  </button>
1855
1949
  <button type="button" :disabled="!selfSample" :class="outputView === 'json' ? segActive : segIdle" @click="outputView = 'json'">
1856
- JSON
1950
+ {{ t("integration.nodeInspector.output.view.json") }}
1857
1951
  </button>
1858
1952
  </div>
1859
1953
  </div>
@@ -1864,32 +1958,32 @@ async function runTestStep() {
1864
1958
  </section>
1865
1959
 
1866
1960
  <section class="space-y-2 border-t border-black/[0.06] pt-4">
1867
- <span class="text-mono text-muted-foreground">Real values</span>
1961
+ <span class="text-mono text-muted-foreground">{{ t("integration.nodeInspector.output.realValues.heading") }}</span>
1868
1962
  <div class="flex items-center gap-2">
1869
1963
  <Button
1870
1964
  size="sm"
1871
1965
  class="h-8"
1872
- :disabled="testRunning"
1873
- title="Runs this node once with the current draft config"
1966
+ :disabled="testRunning || !versionExecutable"
1967
+ :title="versionExecutable ? t('integration.nodeInspector.output.test.runTitle') : void 0"
1874
1968
  @click="runTestStep"
1875
1969
  >
1876
- {{ testRunning ? "Testing\u2026" : "Test step" }}
1970
+ {{ testRunning ? t("integration.nodeInspector.output.test.running") : t("integration.nodeInspector.output.test.action") }}
1877
1971
  </Button>
1878
1972
  <button
1879
1973
  type="button"
1880
1974
  class="rounded px-1 py-1 text-sm font-medium text-muted-foreground transition hover:text-foreground"
1881
1975
  @click="toggleTestInput"
1882
1976
  >
1883
- {{ testInputOpen ? "Hide input" : "Edit input" }}
1977
+ {{ testInputOpen ? t("integration.nodeInspector.output.test.hideInput") : t("integration.nodeInspector.output.test.editInput") }}
1884
1978
  </button>
1885
1979
  <span class="text-xs text-muted-foreground">
1886
- Runs this step alone, against the draft config — the workflow is not started.
1980
+ {{ versionExecutable ? t("integration.nodeInspector.output.test.runHint") : "" }}
1887
1981
  </span>
1888
1982
  </div>
1889
1983
  <Textarea
1890
1984
  v-if="testInputOpen"
1891
1985
  v-model="testInputDraft"
1892
- aria-label="Test input"
1986
+ :aria-label="t('integration.nodeInspector.output.test.inputAria')"
1893
1987
  :rows="8"
1894
1988
  spellcheck="false"
1895
1989
  class="rounded-md border border-black/[0.08] ring-0 shadow-none bg-white font-mono text-xs"
@@ -1913,7 +2007,7 @@ async function runTestStep() {
1913
2007
  <template v-if="!pinEditorOpen">
1914
2008
  <div class="flex items-center gap-2">
1915
2009
  <Button variant="outline" size="sm" class="h-8" @click="openPinEditor">
1916
- {{ selfPinned ? "Edit pinned sample" : "Pin sample output" }}
2010
+ {{ selfPinned ? t("integration.nodeInspector.output.pin.editAction") : t("integration.nodeInspector.output.pin.pinAction") }}
1917
2011
  </Button>
1918
2012
  <button
1919
2013
  v-if="selfPinned"
@@ -1921,17 +2015,17 @@ async function runTestStep() {
1921
2015
  class="text-sm font-medium text-rose-600 transition hover:text-rose-700"
1922
2016
  @click="removePin"
1923
2017
  >
1924
- Unpin
2018
+ {{ t("integration.nodeInspector.output.pin.unpinAction") }}
1925
2019
  </button>
1926
2020
  <span class="text-xs text-muted-foreground">
1927
- A pinned sample stands in for run data while you map downstream fields.
2021
+ {{ t("integration.nodeInspector.output.pin.hint") }}
1928
2022
  </span>
1929
2023
  </div>
1930
2024
  </template>
1931
2025
  <template v-else>
1932
2026
  <Textarea
1933
2027
  v-model="pinDraft"
1934
- aria-label="Pinned sample output"
2028
+ :aria-label="t('integration.nodeInspector.output.pin.editorAria')"
1935
2029
  :rows="10"
1936
2030
  spellcheck="false"
1937
2031
  class="rounded-md border border-black/[0.08] ring-0 shadow-none bg-white font-mono text-xs"
@@ -1941,10 +2035,10 @@ async function runTestStep() {
1941
2035
  </p>
1942
2036
  <div class="flex items-center gap-2">
1943
2037
  <Button size="sm" class="h-8" @click="savePin">
1944
- Pin
2038
+ {{ t("integration.nodeInspector.output.pin.saveAction") }}
1945
2039
  </Button>
1946
2040
  <Button size="sm" variant="outline" class="h-8" @click="pinEditorOpen = false">
1947
- Cancel
2041
+ {{ t("integration.common.action.cancel") }}
1948
2042
  </Button>
1949
2043
  </div>
1950
2044
  </template>
@@ -1973,24 +2067,24 @@ async function runTestStep() {
1973
2067
  <Button
1974
2068
  variant="ghost"
1975
2069
  size="sm"
1976
- aria-label="Remove from workflow"
2070
+ :aria-label="t('integration.nodeInspector.footer.removeFromWorkflow')"
1977
2071
  class="group h-8 gap-0 text-sm text-muted-foreground transition-colors hover:bg-rose-500/10 hover:text-rose-700 focus-visible:bg-rose-500/10 focus-visible:text-rose-700"
1978
2072
  @click="emit('remove')"
1979
2073
  >
1980
- <Trash2 :size="13" />
2074
+ <TrashBin :size="13" />
1981
2075
  <span
1982
2076
  class="max-w-0 overflow-hidden whitespace-nowrap opacity-0 transition-all duration-150 group-hover:ml-1.5 group-hover:max-w-48 group-hover:opacity-100 group-focus-visible:ml-1.5 group-focus-visible:max-w-48 group-focus-visible:opacity-100"
1983
2077
  >
1984
- Remove from workflow
2078
+ {{ t("integration.nodeInspector.footer.removeFromWorkflow") }}
1985
2079
  </span>
1986
2080
  </Button>
1987
2081
  <div class="flex items-center gap-2">
1988
- <span v-if="dirty" class="text-xs text-muted-foreground">Unsaved changes</span>
2082
+ <span v-if="dirty" class="text-xs text-muted-foreground">{{ t("integration.nodeInspector.footer.unsavedChanges") }}</span>
1989
2083
  <Button variant="outline" size="sm" class="h-8" @click="emit('close')">
1990
- Cancel
2084
+ {{ t("integration.common.action.cancel") }}
1991
2085
  </Button>
1992
2086
  <Button size="sm" class="h-8" @click="save">
1993
- Save
2087
+ {{ t("integration.nodeInspector.footer.done") }}
1994
2088
  </Button>
1995
2089
  </div>
1996
2090
  </div>