@revenexx/studio-integrations 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/CHANGELOG.md +227 -0
  2. package/README.md +31 -0
  3. package/dist/module.d.mts +17 -1
  4. package/dist/module.json +1 -1
  5. package/dist/module.mjs +10 -2
  6. package/dist/runtime/components/Integrations/CredentialCreateDialog.vue +29 -28
  7. package/dist/runtime/components/Integrations/CredentialDetailDialog.vue +38 -37
  8. package/dist/runtime/components/Integrations/CredentialRevenexxSource.vue +58 -58
  9. package/dist/runtime/components/Integrations/CredentialTypePicker.vue +10 -9
  10. package/dist/runtime/components/Integrations/DeadLettersPanel.vue +18 -17
  11. package/dist/runtime/components/Integrations/EventTypeCombobox.vue +4 -3
  12. package/dist/runtime/components/Integrations/ExpressionInput.vue +16 -15
  13. package/dist/runtime/components/Integrations/GroupFrameView.vue +29 -19
  14. package/dist/runtime/components/Integrations/Layout.vue +28 -3
  15. package/dist/runtime/components/Integrations/NodeConfigDynamicField.vue +15 -14
  16. package/dist/runtime/components/Integrations/NodeConfigDynamicSchema.vue +9 -8
  17. package/dist/runtime/components/Integrations/NodeConfigField.vue +43 -42
  18. package/dist/runtime/components/Integrations/NodeInspector.vue +225 -216
  19. package/dist/runtime/components/Integrations/NodeOutputSchema.d.vue.ts +1 -1
  20. package/dist/runtime/components/Integrations/NodeOutputSchema.vue +3 -2
  21. package/dist/runtime/components/Integrations/NodeOutputSchema.vue.d.ts +1 -1
  22. package/dist/runtime/components/Integrations/NodePalette.vue +30 -29
  23. package/dist/runtime/components/Integrations/QueryBuilder.vue +44 -43
  24. package/dist/runtime/components/Integrations/RunActionsMenu.vue +36 -12
  25. package/dist/runtime/components/Integrations/RunDetailPanel.vue +114 -62
  26. package/dist/runtime/components/Integrations/RunPayloadInput.vue +14 -13
  27. package/dist/runtime/components/Integrations/RunResponseModal.vue +11 -10
  28. package/dist/runtime/components/Integrations/RunsTable.d.vue.ts +0 -1
  29. package/dist/runtime/components/Integrations/RunsTable.vue +38 -16
  30. package/dist/runtime/components/Integrations/RunsTable.vue.d.ts +0 -1
  31. package/dist/runtime/components/Integrations/SftpAccountImportDialog.vue +8 -7
  32. package/dist/runtime/components/Integrations/Sidebar.vue +14 -10
  33. package/dist/runtime/components/Integrations/StickyNoteView.d.vue.ts +14 -4
  34. package/dist/runtime/components/Integrations/StickyNoteView.vue +37 -23
  35. package/dist/runtime/components/Integrations/StickyNoteView.vue.d.ts +14 -4
  36. package/dist/runtime/components/Integrations/TemplateCard.vue +16 -6
  37. package/dist/runtime/components/Integrations/TemplateFlowPreview.vue +10 -9
  38. package/dist/runtime/components/Integrations/WorkflowContextMenu.vue +34 -33
  39. package/dist/runtime/components/Integrations/WorkflowCreateDialog.vue +36 -35
  40. package/dist/runtime/components/Integrations/WorkflowDuplicateDialog.vue +8 -7
  41. package/dist/runtime/components/Integrations/WorkflowEdgeView.vue +6 -4
  42. package/dist/runtime/components/Integrations/WorkflowEditor.vue +82 -79
  43. package/dist/runtime/components/Integrations/WorkflowHistoryPanel.vue +24 -23
  44. package/dist/runtime/components/Integrations/WorkflowIconPicker.vue +7 -6
  45. package/dist/runtime/components/Integrations/WorkflowNodeView.vue +19 -20
  46. package/dist/runtime/components/Integrations/WorkflowRunReplayPanel.vue +52 -39
  47. package/dist/runtime/components/Integrations/WorkflowSettingsDialog.vue +20 -19
  48. package/dist/runtime/components/Integrations/WorkflowTagsInput.vue +9 -8
  49. package/dist/runtime/components/Integrations/WorkflowTriggerView.vue +12 -9
  50. package/dist/runtime/components/Integrations/WorkflowVariablesDialog.vue +27 -26
  51. package/dist/runtime/components/Integrations/WorkflowsTable.vue +83 -45
  52. package/dist/runtime/composables/integrationsApi.js +4 -2
  53. package/dist/runtime/composables/useCredentialTypes.js +4 -2
  54. package/dist/runtime/composables/useIntegrationsCredentials.js +6 -4
  55. package/dist/runtime/composables/useIntegrationsFocus.d.ts +12 -0
  56. package/dist/runtime/composables/useIntegrationsLocale.d.ts +26 -0
  57. package/dist/runtime/composables/useIntegrationsLocale.js +8 -0
  58. package/dist/runtime/composables/useIntegrationsNodes.js +3 -1
  59. package/dist/runtime/composables/useIntegrationsRealtime.d.ts +149 -0
  60. package/dist/runtime/composables/useIntegrationsRealtime.js +80 -0
  61. package/dist/runtime/composables/useIntegrationsRevisions.js +9 -7
  62. package/dist/runtime/composables/useIntegrationsRuns.d.ts +1 -1
  63. package/dist/runtime/composables/useIntegrationsRuns.js +3 -1
  64. package/dist/runtime/composables/useIntegrationsSecrets.js +4 -2
  65. package/dist/runtime/composables/useIntegrationsT.d.ts +35 -0
  66. package/dist/runtime/composables/useIntegrationsT.js +9 -0
  67. package/dist/runtime/composables/useIntegrationsTemplates.d.ts +3 -3
  68. package/dist/runtime/composables/useIntegrationsTemplates.js +16 -8
  69. package/dist/runtime/composables/useIntegrationsTriggers.js +4 -2
  70. package/dist/runtime/composables/useIntegrationsWorkflows.js +5 -3
  71. package/dist/runtime/composables/useNodeConfigResolve.js +2 -1
  72. package/dist/runtime/data/integrationNodeCatalog.d.ts +41 -0
  73. package/dist/runtime/data/integrationNodeCatalog.js +14 -0
  74. package/dist/runtime/data/integrationTemplates.d.ts +9 -0
  75. package/dist/runtime/data/integrationTemplates.js +7 -1
  76. package/dist/runtime/data/payloadForm.js +2 -1
  77. package/dist/runtime/data/workflowIconCatalog.js +2 -1
  78. package/dist/runtime/i18n/messages/de.json +1207 -0
  79. package/dist/runtime/i18n/messages/en.json +1207 -0
  80. package/dist/runtime/i18n/translate.d.ts +83 -0
  81. package/dist/runtime/i18n/translate.js +41 -0
  82. package/dist/runtime/pages/integrations/audit.vue +52 -34
  83. package/dist/runtime/pages/integrations/credentials/oauth-complete.vue +3 -2
  84. package/dist/runtime/pages/integrations/credentials.vue +70 -52
  85. package/dist/runtime/pages/integrations/index.vue +50 -56
  86. package/dist/runtime/pages/integrations/runs.vue +33 -21
  87. package/dist/runtime/pages/integrations/schedules.vue +49 -28
  88. package/dist/runtime/pages/integrations/secrets.vue +34 -34
  89. package/dist/runtime/pages/integrations/templates/[slug].vue +77 -68
  90. package/dist/runtime/pages/integrations/templates/index.vue +35 -27
  91. package/dist/runtime/pages/integrations/workflows/[id].vue +164 -99
  92. package/dist/runtime/pages/integrations/workflows/index.vue +10 -5
  93. package/dist/runtime/plugins/locale.d.ts +10 -0
  94. package/dist/runtime/plugins/locale.js +6 -0
  95. package/dist/runtime/plugins/realtime.client.d.ts +32 -0
  96. package/dist/runtime/plugins/realtime.client.js +4 -0
  97. package/dist/runtime/utils/credentialState.d.ts +24 -1
  98. package/dist/runtime/utils/credentialState.js +26 -11
  99. package/dist/runtime/utils/localize.d.ts +18 -5
  100. package/dist/runtime/utils/localize.js +32 -9
  101. package/dist/runtime/utils/pollBudget.d.ts +72 -0
  102. package/dist/runtime/utils/pollBudget.js +17 -0
  103. package/dist/runtime/utils/schedule.d.ts +0 -25
  104. package/dist/runtime/utils/schedule.js +37 -25
  105. package/dist/runtime/utils/statusStyle.d.ts +26 -2
  106. package/dist/runtime/utils/statusStyle.js +9 -1
  107. package/dist/runtime/utils/validationFindings.d.ts +2 -2
  108. package/dist/runtime/utils/validationFindings.js +4 -3
  109. package/dist/types.d.mts +2 -6
  110. package/package.json +3 -2
@@ -1,6 +1,6 @@
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";
3
+ import { AddCircle as Plus, TrashBinMinimalistic as TrashBin, Copy } from "#solar-icons";
4
4
  import { ChevronDown, ChevronLeft, ChevronRight, Clock, Database, MessageSquareText, Play, Search, Webhook, X, Zap } from "lucide-vue-next";
5
5
  import { toast } from "vue-sonner";
6
6
  import {
@@ -27,7 +27,7 @@ import {
27
27
  ToggleGroupItem
28
28
  } from "@revenexx/studio";
29
29
  import EventTypeCombobox from "./EventTypeCombobox.vue";
30
- import { NODE_CATEGORIES, findManifest } from "../../data/integrationNodeCatalog";
30
+ import { findManifest, nodeCategoryLabel } from "../../data/integrationNodeCatalog";
31
31
  import {
32
32
  collectUpstreamNodes,
33
33
  nodeOutputExpr,
@@ -45,6 +45,7 @@ import {
45
45
  } from "../../data/nodeRunData";
46
46
  import { resolveDisplayName, resolveLocalized } from "../../utils/localize";
47
47
  import { describeRecurrence, parseCron, scheduleTimezone, toCron } from "../../utils/schedule";
48
+ const t = useIntegrationsT();
48
49
  const props = defineProps({
49
50
  selection: { type: [Object, null], required: true },
50
51
  secretKeys: { type: Array, required: true },
@@ -87,23 +88,28 @@ const manifest = computed(() => {
87
88
  return wn ? findManifest(wn.nodeSlug, wn.nodeVersion) : null;
88
89
  });
89
90
  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 TRIGGER_LABELS = computed(() => ({
92
+ manual: t("integration.nodeInspector.trigger.type.manual"),
93
+ schedule: t("integration.nodeInspector.trigger.type.schedule"),
94
+ webhook: t("integration.nodeInspector.trigger.type.webhook"),
95
+ event: t("integration.nodeInspector.trigger.type.event")
96
+ }));
91
97
  const triggerGlyph = computed(() => TRIGGER_GLYPHS[draftTrigger.value?.type ?? "manual"]);
92
98
  const namePlaceholder = computed(() => {
93
99
  if (draftNode.value) return resolveLocalized(manifest.value?.name) || draftNode.value.nodeSlug;
94
- return TRIGGER_LABELS[draftTrigger.value?.type ?? "manual"] + " trigger";
100
+ return t("integration.nodeInspector.trigger.placeholder", { type: TRIGGER_LABELS.value[draftTrigger.value?.type ?? "manual"] });
95
101
  });
96
102
  const subtitleText = computed(() => {
97
103
  const wn = draftNode.value;
98
104
  if (wn) return wn.nodeSlug + "@" + wn.nodeVersion;
99
- return TRIGGER_LABELS[draftTrigger.value?.type ?? "manual"] + " trigger";
105
+ return t("integration.nodeInspector.trigger.placeholder", { type: TRIGGER_LABELS.value[draftTrigger.value?.type ?? "manual"] });
100
106
  });
101
107
  const manifestDescription = computed(() => resolveLocalized(manifest.value?.description));
102
108
  const groupLabel = computed(() => {
103
- if (!isNode.value) return "Trigger";
109
+ if (!isNode.value) return t("integration.nodeInspector.header.groupTrigger");
104
110
  const groups = manifest.value?.groups;
105
111
  if (groups?.length) return resolveLocalized(groups[groups.length - 1]);
106
- return NODE_CATEGORIES.find((c) => c.key === manifest.value?.category)?.label ?? "Node";
112
+ return nodeCategoryLabel(manifest.value?.category) || t("integration.nodeInspector.header.groupNode");
107
113
  });
108
114
  const nameValue = computed(() => {
109
115
  const name = draft.value.name;
@@ -114,7 +120,7 @@ function writeName(name) {
114
120
  }
115
121
  const noteOpen = ref(false);
116
122
  const noteText = computed(() => (draftNode.value?.notes ?? "").trim());
117
- const noteLabel = computed(() => noteText.value ? "Note" : "Add note");
123
+ const noteLabel = computed(() => noteText.value ? t("integration.nodeInspector.note.label") : t("integration.nodeInspector.note.addLabel"));
118
124
  function writeNote(v) {
119
125
  const text = String(v ?? "");
120
126
  patchNode({ notes: text.trim() ? text : void 0 });
@@ -122,8 +128,11 @@ function writeNote(v) {
122
128
  const tab = ref("parameters");
123
129
  const showSettingsTab = computed(() => isNode.value);
124
130
  const inspectorTabs = computed(() => [
125
- { key: "parameters", label: "Parameters" },
126
- ...showSettingsTab.value ? [{ key: "settings", label: "Settings" }, { key: "output", label: "Output" }] : []
131
+ { key: "parameters", label: t("integration.nodeInspector.tab.parameters") },
132
+ ...showSettingsTab.value ? [
133
+ { key: "settings", label: t("integration.nodeInspector.tab.settings") },
134
+ { key: "output", label: t("integration.nodeInspector.tab.output") }
135
+ ] : []
127
136
  ]);
128
137
  watch(showSettingsTab, (hasThem) => {
129
138
  if (!hasThem) tab.value = "parameters";
@@ -184,9 +193,9 @@ async function copyConfig() {
184
193
  if (!draftNode.value) return;
185
194
  try {
186
195
  await navigator.clipboard.writeText(JSON.stringify(draftNode.value.config ?? {}, null, 2));
187
- toast.success("Config copied");
196
+ toast.success(t("integration.nodeInspector.toast.configCopied"));
188
197
  } catch {
189
- toast.error("Could not copy config");
198
+ toast.error(t("integration.nodeInspector.toast.configCopyFailed"));
190
199
  }
191
200
  }
192
201
  async function pasteConfig() {
@@ -195,22 +204,22 @@ async function pasteConfig() {
195
204
  try {
196
205
  text = await navigator.clipboard.readText();
197
206
  } catch {
198
- toast.error("Could not read the clipboard");
207
+ toast.error(t("integration.nodeInspector.toast.clipboardReadFailed"));
199
208
  return;
200
209
  }
201
210
  let parsed;
202
211
  try {
203
212
  parsed = JSON.parse(text);
204
213
  } catch {
205
- toast.error("Clipboard is not valid JSON");
214
+ toast.error(t("integration.nodeInspector.toast.clipboardInvalidJson"));
206
215
  return;
207
216
  }
208
217
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
209
- toast.error("Clipboard is not a JSON config object");
218
+ toast.error(t("integration.nodeInspector.toast.clipboardNotConfigObject"));
210
219
  return;
211
220
  }
212
221
  patchNode({ config: parsed });
213
- toast.success("Config pasted");
222
+ toast.success(t("integration.nodeInspector.toast.configPasted"));
214
223
  }
215
224
  function readTriggerConfig(key) {
216
225
  return draftTrigger.value?.config[key];
@@ -223,24 +232,24 @@ function writeTriggerConfig(key, value) {
223
232
  const MINUTE_INTERVALS = [1, 2, 5, 10, 15, 20, 30];
224
233
  const HOUR_INTERVALS = [2, 3, 4, 6, 8, 12];
225
234
  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
- ];
235
+ const DAY_OPTIONS = computed(() => [
236
+ { value: 1, short: t("integration.nodeInspector.schedule.day.monday.short"), name: t("integration.nodeInspector.schedule.day.monday") },
237
+ { value: 2, short: t("integration.nodeInspector.schedule.day.tuesday.short"), name: t("integration.nodeInspector.schedule.day.tuesday") },
238
+ { value: 3, short: t("integration.nodeInspector.schedule.day.wednesday.short"), name: t("integration.nodeInspector.schedule.day.wednesday") },
239
+ { value: 4, short: t("integration.nodeInspector.schedule.day.thursday.short"), name: t("integration.nodeInspector.schedule.day.thursday") },
240
+ { value: 5, short: t("integration.nodeInspector.schedule.day.friday.short"), name: t("integration.nodeInspector.schedule.day.friday") },
241
+ { value: 6, short: t("integration.nodeInspector.schedule.day.saturday.short"), name: t("integration.nodeInspector.schedule.day.saturday") },
242
+ { value: 0, short: t("integration.nodeInspector.schedule.day.sunday.short"), name: t("integration.nodeInspector.schedule.day.sunday") }
243
+ ]);
244
+ const FREQUENCY_OPTIONS = computed(() => [
245
+ { value: "minutes", label: t("integration.nodeInspector.schedule.frequency.minutes") },
246
+ { value: "hourly", label: t("integration.nodeInspector.schedule.frequency.hourly") },
247
+ { value: "hours", label: t("integration.nodeInspector.schedule.frequency.hours") },
248
+ { value: "times", label: t("integration.nodeInspector.schedule.frequency.times") },
249
+ { value: "daily", label: t("integration.nodeInspector.schedule.frequency.daily") },
250
+ { value: "weekly", label: t("integration.nodeInspector.schedule.frequency.weekly") },
251
+ { value: "monthly", label: t("integration.nodeInspector.schedule.frequency.monthly") }
252
+ ]);
244
253
  const HOURS_OF_DAY = Array.from({ length: 24 }, (_, h) => h);
245
254
  const FREQUENCY_ADVANCED = "advanced";
246
255
  const cronValue = computed(() => readTriggerConfig("cron") ?? "");
@@ -416,10 +425,10 @@ async function rotateEndpointSecret() {
416
425
  rotatedSecret.value = secret;
417
426
  rotateOpen.value = true;
418
427
  } else {
419
- toast.error("The endpoint did not return a secret.");
428
+ toast.error(t("integration.nodeInspector.toast.rotateNoSecret"));
420
429
  }
421
430
  } catch (e) {
422
- toast.error(e.message || "Could not rotate the secret.");
431
+ toast.error(e.message || t("integration.nodeInspector.toast.rotateFailed"));
423
432
  } finally {
424
433
  rotateBusy.value = false;
425
434
  }
@@ -428,25 +437,25 @@ async function copySecret() {
428
437
  if (!rotatedSecret.value) return;
429
438
  try {
430
439
  await navigator.clipboard.writeText(rotatedSecret.value);
431
- toast.success("Secret copied");
440
+ toast.success(t("integration.nodeInspector.toast.secretCopied"));
432
441
  } catch {
433
- toast.error("Could not copy the secret");
442
+ toast.error(t("integration.nodeInspector.toast.secretCopyFailed"));
434
443
  }
435
444
  }
436
445
  async function copyWebhookUrl() {
437
446
  if (!webhookUrl.value) return;
438
447
  try {
439
448
  await navigator.clipboard.writeText(webhookUrl.value);
440
- toast.success("Webhook URL copied");
449
+ toast.success(t("integration.nodeInspector.toast.webhookUrlCopied"));
441
450
  } catch {
442
- toast.error("Could not copy the URL");
451
+ toast.error(t("integration.nodeInspector.toast.webhookUrlCopyFailed"));
443
452
  }
444
453
  }
445
454
  const definedEvents = typeof useDefinedEvents === "function" ? useDefinedEvents() : { emits: ref([]), load: async () => {
446
455
  } };
447
456
  const eventTopic = computed(() => {
448
- const t = readTriggerConfig("event_type");
449
- return typeof t === "string" && t.trim() !== "" ? t.trim() : null;
457
+ const raw = readTriggerConfig("event_type");
458
+ return typeof raw === "string" && raw.trim() !== "" ? raw.trim() : null;
450
459
  });
451
460
  watch(eventTopic, (topic) => {
452
461
  if (topic) void definedEvents.load();
@@ -490,7 +499,7 @@ async function loadRunData() {
490
499
  const { runs } = await loadForWorkflow(Number(props.workflowId), "", { perPage: 10 });
491
500
  const source = runs.find((r) => r.status === "succeeded") ?? runs[0];
492
501
  if (!source) {
493
- runError.value = "No runs yet. Run the workflow once to sample real values.";
502
+ runError.value = t("integration.nodeInspector.input.runData.empty");
494
503
  return;
495
504
  }
496
505
  const res = await getSteps(props.workflowId, source.id, true);
@@ -505,7 +514,7 @@ async function loadRunData() {
505
514
  runData.value = map;
506
515
  runInfo.value = { id: source.id, startedAt: source.startedAt };
507
516
  } catch (e) {
508
- runError.value = e?.message ?? "Could not load run data.";
517
+ runError.value = e?.message ?? t("integration.nodeInspector.input.runData.loadFailed");
509
518
  } finally {
510
519
  runLoading.value = false;
511
520
  }
@@ -589,19 +598,19 @@ const inputPaneOpen = ref(false);
589
598
  const exprInsertHandler = ref(null);
590
599
  provide("integrationsExpressionInsert", exprInsertHandler);
591
600
  function onCenterFocusIn(e) {
592
- const t = e.target;
593
- if (t instanceof HTMLElement && t.closest(".expr-editor")) {
601
+ const target = e.target;
602
+ if (target instanceof HTMLElement && target.closest(".expr-editor")) {
594
603
  lastFocusKind.value = "editor";
595
604
  inputPaneOpen.value = true;
596
605
  return;
597
606
  }
598
- if (t instanceof HTMLTextAreaElement) {
599
- lastField.value = t;
607
+ if (target instanceof HTMLTextAreaElement) {
608
+ lastField.value = target;
600
609
  lastFocusKind.value = "native";
601
610
  return;
602
611
  }
603
- if (t instanceof HTMLInputElement && !["number", "checkbox", "radio", "range", "file", "color"].includes(t.type)) {
604
- lastField.value = t;
612
+ if (target instanceof HTMLInputElement && !["number", "checkbox", "radio", "range", "file", "color"].includes(target.type)) {
613
+ lastField.value = target;
605
614
  lastFocusKind.value = "native";
606
615
  }
607
616
  }
@@ -623,7 +632,7 @@ function insertExpression(expression) {
623
632
  void navigator.clipboard?.writeText(expression);
624
633
  } catch {
625
634
  }
626
- toast("Expression copied. Click into a field first to insert it directly.");
635
+ toast(t("integration.nodeInspector.toast.expressionCopied"));
627
636
  }
628
637
  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
638
  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 +651,10 @@ function onChipDragEnd() {
642
651
  dragActive.value = false;
643
652
  clearDropHover();
644
653
  }
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");
654
+ function expressionTarget(t2) {
655
+ if (t2 instanceof HTMLTextAreaElement) return t2;
656
+ if (t2 instanceof HTMLInputElement && !["number", "checkbox", "radio", "range", "file", "color"].includes(t2.type)) return t2;
657
+ if (t2 instanceof HTMLElement) return t2.closest(".expr-editor");
649
658
  return null;
650
659
  }
651
660
  function onCenterDragEnter(e) {
@@ -737,7 +746,7 @@ function typeGlyph(dataType) {
737
746
  return dataType ? TYPE_GLYPHS[dataType] ?? null : null;
738
747
  }
739
748
  const sequence = computed(() => [
740
- ...(props.triggers ?? []).map((t) => t.id),
749
+ ...(props.triggers ?? []).map((t2) => t2.id),
741
750
  ...(props.nodes ?? []).map((n) => n.id)
742
751
  ]);
743
752
  const seqIndex = computed(() => sequence.value.indexOf(draft.value.id));
@@ -771,17 +780,17 @@ function savePin() {
771
780
  try {
772
781
  parsed = JSON.parse(pinDraft.value);
773
782
  } catch {
774
- pinError.value = "Not valid JSON.";
783
+ pinError.value = t("integration.nodeInspector.output.pin.invalidJson");
775
784
  return;
776
785
  }
777
786
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
778
- pinError.value = 'Must be an object mapping ports to payloads, e.g. { "default": { \u2026 } }.';
787
+ pinError.value = t("integration.nodeInspector.output.pin.invalidShape");
779
788
  return;
780
789
  }
781
790
  pinnedSamples.value = { ...pinnedSamples.value, [draftNode.value.id]: parsed };
782
791
  persistPins();
783
792
  pinEditorOpen.value = false;
784
- toast.success("Sample output pinned");
793
+ toast.success(t("integration.nodeInspector.toast.samplePinned"));
785
794
  }
786
795
  function removePin() {
787
796
  if (!draftNode.value) return;
@@ -820,12 +829,12 @@ async function runTestStep() {
820
829
  try {
821
830
  const parsed = JSON.parse(testInputDraft.value.trim() || defaultTestInput());
822
831
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
823
- testError.value = "Test input must be a JSON object.";
832
+ testError.value = t("integration.nodeInspector.output.test.inputNotObject");
824
833
  return;
825
834
  }
826
835
  inputs = parsed;
827
836
  } catch {
828
- testError.value = "Test input is not valid JSON.";
837
+ testError.value = t("integration.nodeInspector.output.test.inputInvalidJson");
829
838
  testInputOpen.value = true;
830
839
  return;
831
840
  }
@@ -843,10 +852,10 @@ async function runTestStep() {
843
852
  [wn.id]: res.outputs && typeof res.outputs === "object" ? res.outputs : {}
844
853
  };
845
854
  outputView.value = "json";
846
- toast.success("Test run finished");
855
+ toast.success(t("integration.nodeInspector.toast.testRunFinished"));
847
856
  } catch (e) {
848
857
  const body = e.body;
849
- testError.value = body?.error?.message ?? body?.message ?? e?.message ?? "Test run failed.";
858
+ testError.value = body?.error?.message ?? body?.message ?? e?.message ?? t("integration.nodeInspector.output.test.runFailed");
850
859
  testLogs.value = body?.logs ?? [];
851
860
  } finally {
852
861
  testRunning.value = false;
@@ -870,7 +879,7 @@ async function runTestStep() {
870
879
  type, not for the field. -->
871
880
  <input
872
881
  ref="nameInput"
873
- aria-label="Node name"
882
+ :aria-label="t('integration.nodeInspector.header.nameAria')"
874
883
  :value="nameValue"
875
884
  :placeholder="namePlaceholder"
876
885
  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 +904,13 @@ async function runTestStep() {
895
904
  the description at all. -->
896
905
  <button
897
906
  type="button"
898
- aria-label="What this node does"
907
+ :aria-label="t('integration.nodeInspector.header.whatThisDoes')"
899
908
  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
909
  >
901
910
  ?
902
911
  </button>
903
912
  </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">
913
+ <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
914
  <div class="flex items-center gap-2.5">
906
915
  <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
916
  <IntegrationsNodeMark :manifest="manifest" :size="18" />
@@ -943,13 +952,13 @@ async function runTestStep() {
943
952
  {{ noteLabel }}
944
953
  </button>
945
954
  </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>
955
+ <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">
956
+ <Label class="text-mono text-muted-foreground">{{ t("integration.nodeInspector.note.label") }}</Label>
948
957
  <Textarea
949
- aria-label="Note"
958
+ :aria-label="t('integration.nodeInspector.note.label')"
950
959
  :model-value="draftNode.notes ?? ''"
951
960
  :rows="4"
952
- placeholder="Why this node is here…"
961
+ :placeholder="t('integration.nodeInspector.note.placeholder')"
953
962
  class="rounded-md border border-black/[0.08] ring-0 shadow-none text-sm bg-white"
954
963
  @update:model-value="writeNote"
955
964
  />
@@ -972,7 +981,7 @@ async function runTestStep() {
972
981
  @click="inputPaneOpen = !inputPaneOpen"
973
982
  >
974
983
  <Database :size="12" />
975
- Input data
984
+ {{ t("integration.nodeInspector.header.inputData") }}
976
985
  </button>
977
986
  <!-- Icon-only controls: `title` alone leaves them nameless to a screen
978
987
  reader (and unaddressable to a test without a CSS selector), so each
@@ -981,8 +990,8 @@ async function runTestStep() {
981
990
  <button
982
991
  type="button"
983
992
  :disabled="!hasPrev"
984
- aria-label="Previous step (saves changes)"
985
- title="Previous step (saves changes)"
993
+ :aria-label="t('integration.nodeInspector.header.previousStep')"
994
+ :title="t('integration.nodeInspector.header.previousStep')"
986
995
  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
996
  @click="navigate(-1)"
988
997
  >
@@ -991,8 +1000,8 @@ async function runTestStep() {
991
1000
  <button
992
1001
  type="button"
993
1002
  :disabled="!hasNext"
994
- aria-label="Next step (saves changes)"
995
- title="Next step (saves changes)"
1003
+ :aria-label="t('integration.nodeInspector.header.nextStep')"
1004
+ :title="t('integration.nodeInspector.header.nextStep')"
996
1005
  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
1006
  @click="navigate(1)"
998
1007
  >
@@ -1003,8 +1012,8 @@ async function runTestStep() {
1003
1012
  (the DialogContent built-in X floats at its own offset). -->
1004
1013
  <button
1005
1014
  type="button"
1006
- aria-label="Close"
1007
- title="Close"
1015
+ :aria-label="t('integration.common.action.close')"
1016
+ :title="t('integration.common.action.close')"
1008
1017
  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
1018
  @click="emit('close')"
1010
1019
  >
@@ -1018,18 +1027,18 @@ async function runTestStep() {
1018
1027
  <aside v-if="inputPaneOpen" class="flex w-[300px] shrink-0 flex-col border-r border-black/[0.06] bg-muted/10" @dragend="onChipDragEnd">
1019
1028
  <div class="shrink-0 border-b border-black/[0.06] px-3 py-2.5">
1020
1029
  <div class="text-mono text-muted-foreground">
1021
- Input
1030
+ {{ t("integration.nodeInspector.input.title") }}
1022
1031
  </div>
1023
1032
  <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.
1033
+ {{ t("integration.nodeInspector.input.description") }}
1025
1034
  </p>
1026
1035
  <div class="relative mt-2">
1027
1036
  <Search :size="12" class="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
1028
1037
  <input
1029
1038
  v-model="inputSearch"
1030
1039
  type="text"
1031
- aria-label="Search data"
1032
- placeholder="Search data"
1040
+ :aria-label="t('integration.nodeInspector.input.searchAria')"
1041
+ :placeholder="t('integration.nodeInspector.input.searchAria')"
1033
1042
  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
1043
  >
1035
1044
  </div>
@@ -1041,15 +1050,15 @@ async function runTestStep() {
1041
1050
  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
1051
  @click="loadRunData"
1043
1052
  >
1044
- {{ runLoading ? "Loading run data\u2026" : "Use last run data" }}
1053
+ {{ runLoading ? t("integration.nodeInspector.input.runData.loading") : t("integration.nodeInspector.input.runData.use") }}
1045
1054
  </button>
1046
1055
  <template v-else>
1047
1056
  <span class="min-w-0 flex-1 truncate text-[11px] text-emerald-700">
1048
- Values from run #{{ runInfo.id }}
1057
+ {{ t("integration.nodeInspector.input.runData.from", { id: runInfo.id }) }}
1049
1058
  </span>
1050
1059
  <button
1051
1060
  type="button"
1052
- title="Clear run data"
1061
+ :title="t('integration.nodeInspector.input.runData.clear')"
1053
1062
  class="flex size-5 shrink-0 items-center justify-center rounded text-muted-foreground transition hover:text-foreground"
1054
1063
  @click="clearRunData"
1055
1064
  >
@@ -1066,10 +1075,10 @@ async function runTestStep() {
1066
1075
  <!-- Upstream nodes -->
1067
1076
  <div v-if="!searchQuery || visibleSections.length" class="space-y-3">
1068
1077
  <div class="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
1069
- Nodes
1078
+ {{ t("integration.nodeInspector.input.nodes.heading") }}
1070
1079
  </div>
1071
1080
  <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.
1081
+ {{ t("integration.nodeInspector.input.nodes.empty") }}
1073
1082
  </p>
1074
1083
  <div v-for="s in visibleSections" :key="s.id" class="rounded-md border border-black/[0.06]">
1075
1084
  <button
@@ -1083,9 +1092,9 @@ async function runTestStep() {
1083
1092
  <span
1084
1093
  v-if="!s.direct"
1085
1094
  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"
1095
+ :title="t('integration.nodeInspector.input.nodes.indirectTitle')"
1087
1096
  >
1088
- upstream
1097
+ {{ t("integration.nodeInspector.input.nodes.indirectBadge") }}
1089
1098
  </span>
1090
1099
  </button>
1091
1100
  <div v-show="!sectionClosed(s.id)" class="space-y-2 border-t border-black/[0.06] px-2 py-2">
@@ -1134,7 +1143,7 @@ async function runTestStep() {
1134
1143
  <!-- Trigger payload -->
1135
1144
  <div v-if="showTriggerChip" class="space-y-1.5">
1136
1145
  <div class="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
1137
- Trigger
1146
+ {{ t("integration.nodeInspector.input.trigger.heading") }}
1138
1147
  </div>
1139
1148
  <div class="flex flex-wrap gap-1">
1140
1149
  <button
@@ -1150,19 +1159,19 @@ async function runTestStep() {
1150
1159
  </button>
1151
1160
  </div>
1152
1161
  <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.
1162
+ {{ t("integration.nodeInspector.input.trigger.hint") }}
1154
1163
  </p>
1155
1164
  </div>
1156
1165
  </template>
1157
1166
 
1158
1167
  <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.
1168
+ {{ t("integration.nodeInspector.input.noUpstreamForTrigger") }}
1160
1169
  </p>
1161
1170
 
1162
1171
  <!-- Workflow variables -->
1163
1172
  <div v-if="!searchQuery || visibleVariableNames.length" class="space-y-1.5">
1164
1173
  <div class="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
1165
- Variables
1174
+ {{ t("integration.nodeInspector.input.variables.heading") }}
1166
1175
  </div>
1167
1176
  <!-- Schema rows with the declared default as the value. -->
1168
1177
  <div v-if="variableRows.length" class="space-y-px">
@@ -1183,12 +1192,12 @@ async function runTestStep() {
1183
1192
  </button>
1184
1193
  </div>
1185
1194
  <p v-else class="text-xs leading-snug text-muted-foreground">
1186
- No workflow variables defined.
1195
+ {{ t("integration.nodeInspector.input.variables.empty") }}
1187
1196
  </p>
1188
1197
  </div>
1189
1198
 
1190
1199
  <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.
1200
+ {{ t("integration.nodeInspector.input.search.empty", { query: inputSearch.trim() }) }}
1192
1201
  </p>
1193
1202
  </div>
1194
1203
  </aside>
@@ -1215,15 +1224,15 @@ async function runTestStep() {
1215
1224
  <!-- The name lives in the text next to the switch, which is not
1216
1225
  a <label> — so the switch says it itself. -->
1217
1226
  <Switch
1218
- aria-label="Active"
1227
+ :aria-label="t('integration.nodeInspector.trigger.active.label')"
1219
1228
  class="data-[state=unchecked]:bg-slate-300"
1220
1229
  :model-value="draftTrigger.active !== false"
1221
1230
  @update:model-value="(v) => patchTrigger({ active: v })"
1222
1231
  />
1223
1232
  <div>
1224
- <div class="text-sm font-medium">Active</div>
1233
+ <div class="text-sm font-medium">{{ t("integration.nodeInspector.trigger.active.label") }}</div>
1225
1234
  <p class="text-xs text-muted-foreground">
1226
- A run needs at least one active trigger.
1235
+ {{ t("integration.nodeInspector.trigger.active.hint") }}
1227
1236
  </p>
1228
1237
  </div>
1229
1238
  </div>
@@ -1237,29 +1246,29 @@ async function runTestStep() {
1237
1246
  <div class="space-y-1.5">
1238
1247
  <!-- "Frequency", not "Runs": a run is an execution everywhere
1239
1248
  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>
1249
+ <Label for="ni-frequency" class="text-mono text-muted-foreground">{{ t("integration.nodeInspector.schedule.frequency.label") }}</Label>
1241
1250
  <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">
1251
+ <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
1252
  <SelectValue />
1244
1253
  </SelectTrigger>
1245
1254
  <SelectContent>
1246
1255
  <SelectItem v-for="f in FREQUENCY_OPTIONS" :key="f.value" :value="f.value">{{ f.label }}</SelectItem>
1247
1256
  <SelectSeparator />
1248
- <SelectItem :value="FREQUENCY_ADVANCED">Advanced (cron)</SelectItem>
1257
+ <SelectItem :value="FREQUENCY_ADVANCED">{{ t("integration.nodeInspector.schedule.frequency.advanced") }}</SelectItem>
1249
1258
  </SelectContent>
1250
1259
  </Select>
1251
1260
  </div>
1252
1261
 
1253
1262
  <!-- Every few minutes: the gap between firings. -->
1254
1263
  <div v-if="frequency === 'minutes'" class="space-y-1.5">
1255
- <Label for="ni-minute-interval" class="text-mono text-muted-foreground">Interval</Label>
1264
+ <Label for="ni-minute-interval" class="text-mono text-muted-foreground">{{ t("integration.nodeInspector.schedule.interval.label") }}</Label>
1256
1265
  <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">
1266
+ <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
1267
  <SelectValue />
1259
1268
  </SelectTrigger>
1260
1269
  <SelectContent>
1261
1270
  <SelectItem v-for="n in minuteIntervalOptions" :key="n" :value="String(n)">
1262
- {{ n === 1 ? "Every minute" : `Every ${n} minutes` }}
1271
+ {{ t("integration.nodeInspector.schedule.minuteInterval.option", n) }}
1263
1272
  </SelectItem>
1264
1273
  </SelectContent>
1265
1274
  </Select>
@@ -1267,21 +1276,21 @@ async function runTestStep() {
1267
1276
 
1268
1277
  <!-- Every few hours: the gap, plus where in the hour it lands. -->
1269
1278
  <div v-if="frequency === 'hours'" class="space-y-1.5">
1270
- <Label for="ni-hour-interval" class="text-mono text-muted-foreground">Interval</Label>
1279
+ <Label for="ni-hour-interval" class="text-mono text-muted-foreground">{{ t("integration.nodeInspector.schedule.interval.label") }}</Label>
1271
1280
  <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">
1281
+ <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
1282
  <SelectValue />
1274
1283
  </SelectTrigger>
1275
1284
  <SelectContent>
1276
- <SelectItem v-for="n in HOUR_INTERVALS" :key="n" :value="String(n)">Every {{ n }} hours</SelectItem>
1285
+ <SelectItem v-for="n in HOUR_INTERVALS" :key="n" :value="String(n)">{{ t("integration.nodeInspector.schedule.hourInterval.option", { n }) }}</SelectItem>
1277
1286
  </SelectContent>
1278
1287
  </Select>
1279
1288
  </div>
1280
1289
 
1281
1290
  <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>
1291
+ <Label for="ni-minute-past" class="text-mono text-muted-foreground">{{ t("integration.nodeInspector.schedule.minutePast.label") }}</Label>
1283
1292
  <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">
1293
+ <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
1294
  <SelectValue />
1286
1295
  </SelectTrigger>
1287
1296
  <SelectContent>
@@ -1294,11 +1303,11 @@ async function runTestStep() {
1294
1303
  rhythms share, which is why it survives a change of
1295
1304
  frequency. -->
1296
1305
  <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>
1306
+ <Label for="ni-time" class="text-mono text-muted-foreground">{{ t("integration.nodeInspector.schedule.time.label") }}</Label>
1298
1307
  <Input
1299
1308
  id="ni-time"
1300
1309
  type="time"
1301
- aria-label="Time"
1310
+ :aria-label="t('integration.nodeInspector.schedule.time.label')"
1302
1311
  :model-value="timeValue"
1303
1312
  class="h-8 rounded-md border border-black/[0.08] ring-0 shadow-none text-sm bg-white tabular-nums"
1304
1313
  @update:model-value="applyTime"
@@ -1306,9 +1315,9 @@ async function runTestStep() {
1306
1315
  </div>
1307
1316
 
1308
1317
  <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>
1318
+ <Label for="ni-day-of-month" class="text-mono text-muted-foreground">{{ t("integration.nodeInspector.schedule.dayOfMonth.label") }}</Label>
1310
1319
  <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">
1320
+ <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
1321
  <SelectValue />
1313
1322
  </SelectTrigger>
1314
1323
  <SelectContent>
@@ -1319,10 +1328,10 @@ async function runTestStep() {
1319
1328
 
1320
1329
  <!-- Advanced: the expression itself, the only place it is shown. -->
1321
1330
  <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>
1331
+ <Label for="ni-cron" class="text-mono text-muted-foreground">{{ t("integration.nodeInspector.schedule.cron.label") }}</Label>
1323
1332
  <Input
1324
1333
  id="ni-cron"
1325
- aria-label="Cron expression"
1334
+ :aria-label="t('integration.nodeInspector.schedule.cron.label')"
1326
1335
  :model-value="cronValue"
1327
1336
  placeholder="0 9-17 * * 1-5"
1328
1337
  class="h-8 rounded-md border border-black/[0.08] ring-0 shadow-none text-sm bg-white font-mono"
@@ -1338,10 +1347,10 @@ async function runTestStep() {
1338
1347
  schedule triggers rather than one field per row.
1339
1348
  -->
1340
1349
  <div v-if="frequency === 'times'" class="space-y-1.5">
1341
- <Label class="text-mono text-muted-foreground">Hours</Label>
1350
+ <Label class="text-mono text-muted-foreground">{{ t("integration.nodeInspector.schedule.hours.label") }}</Label>
1342
1351
  <ToggleGroup
1343
1352
  type="multiple"
1344
- aria-label="Hours"
1353
+ :aria-label="t('integration.nodeInspector.schedule.hours.label')"
1345
1354
  :model-value="selectedHours"
1346
1355
  class="grid w-fit grid-cols-12 gap-1"
1347
1356
  @update:model-value="applyHours"
@@ -1361,10 +1370,10 @@ async function runTestStep() {
1361
1370
  <!-- Weekdays: full width, because seven toggles in half of the
1362
1371
  grid would wrap. Serves both rhythms that fall on days. -->
1363
1372
  <div v-if="frequency === 'weekly' || frequency === 'times'" class="space-y-1.5">
1364
- <Label class="text-mono text-muted-foreground">Days</Label>
1373
+ <Label class="text-mono text-muted-foreground">{{ t("integration.nodeInspector.schedule.days.label") }}</Label>
1365
1374
  <ToggleGroup
1366
1375
  type="multiple"
1367
- aria-label="Days"
1376
+ :aria-label="t('integration.nodeInspector.schedule.days.label')"
1368
1377
  :model-value="selectedDays"
1369
1378
  class="justify-start gap-1"
1370
1379
  @update:model-value="applyDays"
@@ -1386,25 +1395,25 @@ async function runTestStep() {
1386
1395
  the expression's numbers as wall-clock time in that zone, so a time
1387
1396
  without its zone would be half an answer.
1388
1397
  -->
1389
- <p v-if="scheduleSentence" aria-label="When it fires" class="text-xs text-emerald-700">{{ scheduleSentence }}</p>
1398
+ <p v-if="scheduleSentence" :aria-label="t('integration.nodeInspector.schedule.whenItFiresAria')" class="text-xs text-emerald-700">{{ scheduleSentence }}</p>
1390
1399
  <!-- No expression at all is its own answer, and the same one the
1391
1400
  Schedules page gives for this trigger: it is not written as
1392
1401
  anything, and it does not run. A trigger placed from the palette
1393
1402
  carries an expression, so this is for one that arrived without
1394
1403
  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.
1404
+ <p v-else-if="!hasCron" :aria-label="t('integration.nodeInspector.schedule.whenItFiresAria')" class="text-xs text-muted-foreground">
1405
+ {{ t("integration.nodeInspector.schedule.noneSet") }}
1397
1406
  </p>
1398
1407
  <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.
1408
+ {{ t("integration.nodeInspector.schedule.writtenAsExpression") }}
1400
1409
  </p>
1401
1410
  <p v-if="frequency === 'monthly' && carry.day >= 29" class="text-xs text-muted-foreground">
1402
- Months without this day are skipped.
1411
+ {{ t("integration.nodeInspector.schedule.monthlySkipHint") }}
1403
1412
  </p>
1404
1413
  </template>
1405
1414
  <template v-else-if="draftTrigger.type === 'webhook'">
1406
1415
  <div class="space-y-1.5">
1407
- <Label for="ni-webhook-url" class="text-mono text-muted-foreground">Webhook URL</Label>
1416
+ <Label for="ni-webhook-url" class="text-mono text-muted-foreground">{{ t("integration.nodeInspector.trigger.webhook.url.label") }}</Label>
1408
1417
  <div v-if="webhookUrl" class="flex items-center gap-1.5">
1409
1418
  <Input
1410
1419
  id="ni-webhook-url"
@@ -1415,7 +1424,7 @@ async function runTestStep() {
1415
1424
  />
1416
1425
  <button
1417
1426
  type="button"
1418
- title="Copy URL"
1427
+ :title="t('integration.nodeInspector.trigger.webhook.url.copyTitle')"
1419
1428
  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
1429
  @click="copyWebhookUrl"
1421
1430
  >
@@ -1423,27 +1432,27 @@ async function runTestStep() {
1423
1432
  </button>
1424
1433
  </div>
1425
1434
  <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>
1435
+ <template v-if="webhookIsPublic">{{ t("integration.nodeInspector.trigger.webhook.url.publicHint") }}</template>
1436
+ <template v-else>{{ t("integration.nodeInspector.trigger.webhook.url.privateHint") }}</template>
1428
1437
  </p>
1429
1438
  </div>
1430
1439
  <div class="flex items-center gap-3 rounded-lg border border-black/[0.06] bg-muted/20 px-3 py-2">
1431
1440
  <Switch
1432
- aria-label="Public endpoint"
1441
+ :aria-label="t('integration.nodeInspector.trigger.webhook.public.label')"
1433
1442
  class="data-[state=unchecked]:bg-slate-300"
1434
1443
  :model-value="webhookIsPublic"
1435
1444
  @update:model-value="(v) => writeTriggerConfigPatch(v ? { public: true, ...draftTrigger.config.authProfile ? {} : configPatchForAuthChoice('token') } : { public: void 0 })"
1436
1445
  />
1437
1446
  <div>
1438
- <div class="text-sm font-medium">Public endpoint</div>
1447
+ <div class="text-sm font-medium">{{ t("integration.nodeInspector.trigger.webhook.public.label") }}</div>
1439
1448
  <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.
1449
+ {{ t("integration.nodeInspector.trigger.webhook.public.hint") }}
1441
1450
  </p>
1442
1451
  </div>
1443
1452
  </div>
1444
1453
  <div class="grid grid-cols-2 gap-3">
1445
1454
  <div class="space-y-1.5">
1446
- <Label for="ni-webhook-method" class="text-mono text-muted-foreground">Method</Label>
1455
+ <Label for="ni-webhook-method" class="text-mono text-muted-foreground">{{ t("integration.nodeInspector.trigger.webhook.method.label") }}</Label>
1447
1456
  <Select
1448
1457
  :model-value="readTriggerConfig('method') ?? 'POST'"
1449
1458
  @update:model-value="(v) => writeTriggerConfig('method', v)"
@@ -1461,23 +1470,23 @@ async function runTestStep() {
1461
1470
  </Select>
1462
1471
  </div>
1463
1472
  <div v-if="webhookIsPublic" class="space-y-1.5">
1464
- <Label for="ni-webhook-protection" class="text-mono text-muted-foreground">Protection</Label>
1473
+ <Label for="ni-webhook-protection" class="text-mono text-muted-foreground">{{ t("integration.nodeInspector.trigger.webhook.protection.label") }}</Label>
1465
1474
  <Select :model-value="authChoice" @update:model-value="applyAuthChoice">
1466
1475
  <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
1476
  <SelectValue />
1468
1477
  </SelectTrigger>
1469
1478
  <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>
1479
+ <SelectItem value="token">{{ t("integration.nodeInspector.trigger.webhook.protection.token") }}</SelectItem>
1480
+ <SelectItem value="bearer">{{ t("integration.nodeInspector.trigger.webhook.protection.bearer") }}</SelectItem>
1481
+ <SelectItem value="basic">{{ t("integration.nodeInspector.trigger.webhook.protection.basic") }}</SelectItem>
1482
+ <SelectItem value="none">{{ t("integration.nodeInspector.trigger.webhook.protection.none") }}</SelectItem>
1474
1483
  </SelectContent>
1475
1484
  </Select>
1476
- <p class="text-xs text-muted-foreground">Verified by the webhook ingress before the workflow runs.</p>
1485
+ <p class="text-xs text-muted-foreground">{{ t("integration.nodeInspector.trigger.webhook.protection.hint") }}</p>
1477
1486
  </div>
1478
1487
  </div>
1479
1488
  <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>
1489
+ <Label for="ni-auth-header" class="text-mono text-muted-foreground">{{ t("integration.nodeInspector.trigger.webhook.header.label") }}</Label>
1481
1490
  <Input
1482
1491
  id="ni-auth-header"
1483
1492
  :model-value="draftTrigger.config.authConfig?.header ?? ''"
@@ -1485,33 +1494,34 @@ async function runTestStep() {
1485
1494
  class="h-8 rounded-md border border-black/[0.08] ring-0 shadow-none bg-white text-sm"
1486
1495
  @update:model-value="(v) => writeAuthConfigKey('header', String(v))"
1487
1496
  />
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>
1497
+ <p class="text-xs text-muted-foreground">{{ t("integration.nodeInspector.trigger.webhook.header.hint") }}</p>
1489
1498
  </div>
1490
1499
  <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>
1500
+ <Label for="ni-auth-username" class="text-mono text-muted-foreground">{{ t("integration.nodeInspector.trigger.webhook.username.label") }}</Label>
1492
1501
  <Input
1493
1502
  id="ni-auth-username"
1494
1503
  :model-value="draftTrigger.config.authConfig?.username ?? ''"
1495
- placeholder="Any username"
1504
+ :placeholder="t('integration.nodeInspector.trigger.webhook.username.placeholder')"
1496
1505
  class="h-8 rounded-md border border-black/[0.08] ring-0 shadow-none bg-white text-sm"
1497
1506
  @update:model-value="(v) => writeAuthConfigKey('username', String(v))"
1498
1507
  />
1499
- <p class="text-xs text-muted-foreground">Optional — pins the Basic-auth username. The password is the endpoint secret.</p>
1508
+ <p class="text-xs text-muted-foreground">{{ t("integration.nodeInspector.trigger.webhook.username.hint") }}</p>
1500
1509
  </div>
1501
1510
  <div v-if="webhookExposed && authChoice !== 'none'" class="flex items-center gap-2">
1502
1511
  <Button type="button" variant="outline" size="sm" class="h-7 text-sm" :disabled="rotateBusy" @click="rotateEndpointSecret">
1503
- {{ rotateBusy ? "Rotating\u2026" : "Rotate endpoint secret" }}
1512
+ {{ rotateBusy ? t("integration.nodeInspector.trigger.webhook.rotate.busy") : t("integration.nodeInspector.trigger.webhook.rotate.action") }}
1504
1513
  </Button>
1505
- <p class="text-xs text-muted-foreground">Issues a new secret (shown once); the previous one keeps working briefly.</p>
1514
+ <p class="text-xs text-muted-foreground">{{ t("integration.nodeInspector.trigger.webhook.rotate.hint") }}</p>
1506
1515
  </div>
1507
1516
 
1508
1517
  <Dialog v-model:open="rotateOpen">
1509
1518
  <DialogContent class="sm:max-w-md">
1510
1519
  <DialogHeader>
1511
- <DialogTitle>New endpoint secret</DialogTitle>
1520
+ <DialogTitle>{{ t("integration.nodeInspector.trigger.webhook.rotate.dialogTitle") }}</DialogTitle>
1512
1521
  <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" }}).
1522
+ {{ t("integration.nodeInspector.trigger.webhook.rotate.dialogHint", {
1523
+ 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")
1524
+ }) }}
1515
1525
  </DialogDescription>
1516
1526
  </DialogHeader>
1517
1527
  <div class="flex items-center gap-1.5">
@@ -1523,7 +1533,7 @@ async function runTestStep() {
1523
1533
  />
1524
1534
  <button
1525
1535
  type="button"
1526
- title="Copy secret"
1536
+ :title="t('integration.nodeInspector.trigger.webhook.rotate.copyTitle')"
1527
1537
  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
1538
  @click="copySecret"
1529
1539
  >
@@ -1535,36 +1545,36 @@ async function runTestStep() {
1535
1545
  </template>
1536
1546
  <template v-else-if="draftTrigger.type === 'event'">
1537
1547
  <div class="space-y-1.5">
1538
- <Label for="ni-event-type" class="text-mono text-muted-foreground">Event</Label>
1548
+ <Label for="ni-event-type" class="text-mono text-muted-foreground">{{ t("integration.nodeInspector.trigger.event.label") }}</Label>
1539
1549
  <EventTypeCombobox
1540
1550
  input-id="ni-event-type"
1541
1551
  :model-value="readTriggerConfig('event_type') ?? ''"
1542
1552
  @update:model-value="(v) => writeTriggerConfig('event_type', v)"
1543
1553
  />
1544
1554
  <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>.
1555
+ {{ 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
1556
  </p>
1547
1557
  </div>
1548
1558
  <div v-if="eventSample" class="space-y-1.5">
1549
1559
  <!-- A caption, not a field label: it heads a read-only block,
1550
1560
  so it stays a plain line rather than an orphan <label>. -->
1551
- <div class="text-mono text-muted-foreground">Sample payload</div>
1561
+ <div class="text-mono text-muted-foreground">{{ t("integration.nodeInspector.trigger.event.sample.heading") }}</div>
1552
1562
  <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>
1563
+ <p class="text-xs text-muted-foreground">{{ t("integration.nodeInspector.trigger.event.sample.hint") }} <code class="font-mono">trigger.payload</code>.</p>
1554
1564
  </div>
1555
1565
  <p v-else-if="eventTopic" class="text-xs text-muted-foreground">
1556
- No sample payload in the event catalogue for this topic.
1566
+ {{ t("integration.nodeInspector.trigger.event.sample.empty") }}
1557
1567
  </p>
1558
1568
  </template>
1559
1569
  <template v-else>
1560
1570
  <div class="space-y-1.5">
1561
1571
  <!-- Caption for the field list below, not a label for one
1562
1572
  control — the row's own controls are named individually. -->
1563
- <div class="text-mono text-muted-foreground">Run payload fields</div>
1573
+ <div class="text-mono text-muted-foreground">{{ t("integration.nodeInspector.trigger.manual.heading") }}</div>
1564
1574
  <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.
1575
+ {{ t("integration.nodeInspector.trigger.manual.hint.intro") }}
1576
+ <code class="font-mono">trigger.payload.&lt;key&gt;</code>{{ t("integration.nodeInspector.trigger.manual.hint.mid") }}
1577
+ <code class="font-mono">trigger.payload.*</code> {{ t("integration.nodeInspector.trigger.manual.hint.outro") }}
1568
1578
  </p>
1569
1579
  </div>
1570
1580
  <div v-if="manualFields.length" class="space-y-1.5">
@@ -1572,41 +1582,41 @@ async function runTestStep() {
1572
1582
  <!-- A repeating row has no room for visible labels, so each
1573
1583
  control carries its own name and says which row it is. -->
1574
1584
  <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">
1585
+ <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
1586
  <SelectValue />
1577
1587
  </SelectTrigger>
1578
1588
  <SelectContent>
1579
- <SelectItem value="text">Text</SelectItem>
1580
- <SelectItem value="file">File</SelectItem>
1589
+ <SelectItem value="text">{{ t("integration.nodeInspector.trigger.manual.kind.text") }}</SelectItem>
1590
+ <SelectItem value="file">{{ t("integration.nodeInspector.trigger.manual.kind.file") }}</SelectItem>
1581
1591
  </SelectContent>
1582
1592
  </Select>
1583
1593
  <Input
1584
- :aria-label="`Key of payload field ${idx + 1}`"
1594
+ :aria-label="t('integration.nodeInspector.trigger.manual.field.keyAria', { n: idx + 1 })"
1585
1595
  :model-value="mf.path"
1586
- placeholder="key (e.g. orderId or import.file)"
1596
+ :placeholder="t('integration.nodeInspector.trigger.manual.field.keyPlaceholder')"
1587
1597
  class="h-8 w-[38%] rounded-md border border-black/[0.08] ring-0 shadow-none bg-white font-mono text-sm"
1588
1598
  @update:model-value="(v) => setManualField(idx, { path: String(v) })"
1589
1599
  />
1590
1600
  <Input
1591
- :aria-label="`Label of payload field ${idx + 1}`"
1601
+ :aria-label="t('integration.nodeInspector.trigger.manual.field.labelAria', { n: idx + 1 })"
1592
1602
  :model-value="mf.label ?? ''"
1593
- placeholder="Label shown in the Run dialog"
1603
+ :placeholder="t('integration.nodeInspector.trigger.manual.field.labelPlaceholder')"
1594
1604
  class="h-8 min-w-0 flex-1 rounded-md border border-black/[0.08] ring-0 shadow-none bg-white text-sm"
1595
1605
  @update:model-value="(v) => setManualField(idx, { label: String(v) })"
1596
1606
  />
1597
1607
  <button
1598
1608
  type="button"
1599
- :aria-label="`Remove payload field ${idx + 1}`"
1609
+ :aria-label="t('integration.nodeInspector.trigger.manual.field.removeAria', { n: idx + 1 })"
1600
1610
  class="flex size-7 shrink-0 items-center justify-center rounded text-muted-foreground transition hover:text-rose-600"
1601
1611
  @click="removeManualField(idx)"
1602
1612
  >
1603
- <Trash2 :size="13" />
1613
+ <TrashBin :size="13" />
1604
1614
  </button>
1605
1615
  </div>
1606
1616
  </div>
1607
1617
  <Button type="button" variant="outline" size="sm" class="h-7 text-sm" @click="addManualField">
1608
1618
  <Plus :size="12" />
1609
- Add field
1619
+ {{ t("integration.nodeInspector.trigger.manual.action.addField") }}
1610
1620
  </Button>
1611
1621
  </template>
1612
1622
  </div>
@@ -1615,23 +1625,23 @@ async function runTestStep() {
1615
1625
  <div v-else-if="tab === 'parameters' && draftNode" class="mx-auto w-full max-w-5xl">
1616
1626
  <div v-if="manifest?.config?.length" class="space-y-3">
1617
1627
  <div class="flex items-center justify-between">
1618
- <span class="text-mono text-muted-foreground">Configuration</span>
1628
+ <span class="text-mono text-muted-foreground">{{ t("integration.nodeInspector.parameters.heading") }}</span>
1619
1629
  <div class="flex items-center gap-2">
1620
1630
  <button
1621
1631
  type="button"
1622
1632
  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"
1633
+ :title="t('integration.nodeInspector.parameters.copyTitle')"
1624
1634
  @click="copyConfig"
1625
1635
  >
1626
- <Copy :size="12" /> Copy
1636
+ <Copy :size="12" /> {{ t("integration.common.action.copy") }}
1627
1637
  </button>
1628
1638
  <button
1629
1639
  type="button"
1630
1640
  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"
1641
+ :title="t('integration.nodeInspector.parameters.pasteTitle')"
1632
1642
  @click="pasteConfig"
1633
1643
  >
1634
- Paste
1644
+ {{ t("integration.common.action.paste") }}
1635
1645
  </button>
1636
1646
  </div>
1637
1647
  </div>
@@ -1662,7 +1672,7 @@ async function runTestStep() {
1662
1672
  @click="optionalOpen = true"
1663
1673
  >
1664
1674
  <ChevronRight :size="13" />
1665
- Show {{ optionalFields.length }} optional setting{{ optionalFields.length === 1 ? "" : "s" }}
1675
+ {{ t("integration.nodeInspector.parameters.optional.show", optionalFields.length) }}
1666
1676
  </button>
1667
1677
  <template v-else>
1668
1678
  <button
@@ -1672,7 +1682,7 @@ async function runTestStep() {
1672
1682
  @click="optionalOpen = false"
1673
1683
  >
1674
1684
  <ChevronDown :size="13" />
1675
- Hide {{ optionalFields.length }} optional setting{{ optionalFields.length === 1 ? "" : "s" }}
1685
+ {{ t("integration.nodeInspector.parameters.optional.hide", optionalFields.length) }}
1676
1686
  </button>
1677
1687
  <IntegrationsNodeConfigField
1678
1688
  v-for="field in optionalFields"
@@ -1693,11 +1703,11 @@ async function runTestStep() {
1693
1703
  </template>
1694
1704
 
1695
1705
  <p v-if="!parameterFields.length" class="text-sm text-muted-foreground italic">
1696
- All of this node's options live on the Settings tab.
1706
+ {{ t("integration.nodeInspector.parameters.allOnSettingsTab") }}
1697
1707
  </p>
1698
1708
  </div>
1699
1709
  <div v-else class="text-sm text-muted-foreground italic">
1700
- This node has no configuration.
1710
+ {{ t("integration.nodeInspector.parameters.noConfiguration") }}
1701
1711
  </div>
1702
1712
  </div>
1703
1713
 
@@ -1710,9 +1720,9 @@ async function runTestStep() {
1710
1720
  identical-looking numbers with nothing to tell them apart. -->
1711
1721
  <div v-if="settingsFields.length" class="space-y-3">
1712
1722
  <div>
1713
- <span class="text-mono text-muted-foreground">Options of this node</span>
1723
+ <span class="text-mono text-muted-foreground">{{ t("integration.nodeInspector.settings.ownOptions.heading") }}</span>
1714
1724
  <p class="text-xs text-muted-foreground">
1715
- Settings this node type defines for itself.
1725
+ {{ t("integration.nodeInspector.settings.ownOptions.hint") }}
1716
1726
  </p>
1717
1727
  </div>
1718
1728
  <IntegrationsNodeConfigField
@@ -1734,9 +1744,9 @@ async function runTestStep() {
1734
1744
  <!-- The rule only earns its place when there is something above to
1735
1745
  divide from. -->
1736
1746
  <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>
1747
+ <span class="text-mono text-muted-foreground">{{ t("integration.nodeInspector.settings.engine.heading") }}</span>
1738
1748
  <p class="text-xs text-muted-foreground">
1739
- The same settings on every node type, applied by the engine around whatever the node does.
1749
+ {{ t("integration.nodeInspector.settings.engine.hint") }}
1740
1750
  </p>
1741
1751
  </div>
1742
1752
 
@@ -1746,21 +1756,21 @@ async function runTestStep() {
1746
1756
  <section class="rounded-lg border border-black/[0.06] bg-muted/20">
1747
1757
  <div class="flex items-center gap-3 px-3 py-2">
1748
1758
  <Switch
1749
- aria-label="Retry on failure"
1759
+ :aria-label="t('integration.nodeInspector.settings.retry.label')"
1750
1760
  class="data-[state=unchecked]:bg-slate-300"
1751
1761
  :model-value="!!draftNode.retry"
1752
1762
  @update:model-value="(v) => patchNode({ retry: v ? { maxAttempts: 3, backoffSeconds: 1, strategy: 'exponential' } : void 0 })"
1753
1763
  />
1754
1764
  <div>
1755
- <div class="text-sm font-medium">Retry on failure</div>
1765
+ <div class="text-sm font-medium">{{ t("integration.nodeInspector.settings.retry.label") }}</div>
1756
1766
  <p class="text-xs text-muted-foreground">
1757
- Re-run this node automatically when an attempt fails.
1767
+ {{ t("integration.nodeInspector.settings.retry.hint") }}
1758
1768
  </p>
1759
1769
  </div>
1760
1770
  </div>
1761
1771
  <div v-if="draftNode.retry" class="grid grid-cols-4 gap-3 border-t border-black/[0.06] p-3">
1762
1772
  <div class="space-y-1.5">
1763
- <Label for="ni-retry-attempts" class="text-mono text-muted-foreground">Max attempts</Label>
1773
+ <Label for="ni-retry-attempts" class="text-mono text-muted-foreground">{{ t("integration.nodeInspector.settings.retry.maxAttempts") }}</Label>
1764
1774
  <Input
1765
1775
  id="ni-retry-attempts"
1766
1776
  type="number"
@@ -1771,7 +1781,7 @@ async function runTestStep() {
1771
1781
  />
1772
1782
  </div>
1773
1783
  <div class="space-y-1.5">
1774
- <Label for="ni-retry-backoff" class="text-mono text-muted-foreground">Backoff seconds</Label>
1784
+ <Label for="ni-retry-backoff" class="text-mono text-muted-foreground">{{ t("integration.nodeInspector.settings.retry.backoffSeconds") }}</Label>
1775
1785
  <Input
1776
1786
  id="ni-retry-backoff"
1777
1787
  type="number"
@@ -1782,7 +1792,7 @@ async function runTestStep() {
1782
1792
  />
1783
1793
  </div>
1784
1794
  <div class="col-span-2 space-y-1.5">
1785
- <Label for="ni-retry-strategy" class="text-mono text-muted-foreground">Strategy</Label>
1795
+ <Label for="ni-retry-strategy" class="text-mono text-muted-foreground">{{ t("integration.nodeInspector.settings.retry.strategy") }}</Label>
1786
1796
  <Select
1787
1797
  :model-value="draftNode.retry?.strategy ?? 'exponential'"
1788
1798
  @update:model-value="(v) => patchNode({ retry: { ...draftNode.retry, strategy: v } })"
@@ -1791,8 +1801,8 @@ async function runTestStep() {
1791
1801
  <SelectValue />
1792
1802
  </SelectTrigger>
1793
1803
  <SelectContent>
1794
- <SelectItem value="linear">linear backoff</SelectItem>
1795
- <SelectItem value="exponential">exponential backoff</SelectItem>
1804
+ <SelectItem value="linear">{{ t("integration.nodeInspector.settings.retry.strategyLinear") }}</SelectItem>
1805
+ <SelectItem value="exponential">{{ t("integration.nodeInspector.settings.retry.strategyExponential") }}</SelectItem>
1796
1806
  </SelectContent>
1797
1807
  </Select>
1798
1808
  </div>
@@ -1801,7 +1811,7 @@ async function runTestStep() {
1801
1811
 
1802
1812
  <!-- Timeout: engine start-to-close ceiling for one attempt -->
1803
1813
  <section class="space-y-1.5">
1804
- <Label for="ni-timeout" class="text-mono text-muted-foreground">Timeout</Label>
1814
+ <Label for="ni-timeout" class="text-mono text-muted-foreground">{{ t("integration.nodeInspector.settings.timeout.label") }}</Label>
1805
1815
  <Input
1806
1816
  id="ni-timeout"
1807
1817
  type="number"
@@ -1813,13 +1823,13 @@ async function runTestStep() {
1813
1823
  @update:model-value="(v) => patchNode({ timeoutSeconds: Number(v) > 0 ? Math.min(Math.trunc(Number(v)), 7200) : void 0 })"
1814
1824
  />
1815
1825
  <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.
1826
+ {{ t("integration.nodeInspector.settings.timeout.hint") }}
1817
1827
  </p>
1818
1828
  </section>
1819
1829
 
1820
1830
  <!-- On error: what happens once the retries are exhausted -->
1821
1831
  <section class="space-y-1.5">
1822
- <Label for="ni-on-error" class="text-mono text-muted-foreground">On error</Label>
1832
+ <Label for="ni-on-error" class="text-mono text-muted-foreground">{{ t("integration.nodeInspector.settings.onError.label") }}</Label>
1823
1833
  <Select
1824
1834
  :model-value="draftNode.onError ?? 'fail'"
1825
1835
  @update:model-value="(v) => patchNode({ onError: v === 'route' ? 'route' : void 0 })"
@@ -1828,12 +1838,12 @@ async function runTestStep() {
1828
1838
  <SelectValue />
1829
1839
  </SelectTrigger>
1830
1840
  <SelectContent>
1831
- <SelectItem value="fail">Fail the run</SelectItem>
1832
- <SelectItem value="route">Route to error output</SelectItem>
1841
+ <SelectItem value="fail">{{ t("integration.nodeInspector.settings.onError.fail") }}</SelectItem>
1842
+ <SelectItem value="route">{{ t("integration.nodeInspector.settings.onError.route") }}</SelectItem>
1833
1843
  </SelectContent>
1834
1844
  </Select>
1835
1845
  <p class="text-xs text-muted-foreground">
1836
- Applies after the retry policy is exhausted. A Stop and Error node always fails the run.
1846
+ {{ t("integration.nodeInspector.settings.onError.hint") }}
1837
1847
  </p>
1838
1848
  </section>
1839
1849
  </div>
@@ -1845,15 +1855,14 @@ async function runTestStep() {
1845
1855
  <section class="space-y-2">
1846
1856
  <div class="flex items-center justify-between gap-2">
1847
1857
  <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.
1858
+ {{ t("integration.nodeInspector.output.description") }}
1850
1859
  </p>
1851
1860
  <div class="flex shrink-0 items-center rounded-md border border-black/[0.06] bg-white p-0.5">
1852
1861
  <button type="button" :class="outputView === 'schema' ? segActive : segIdle" @click="outputView = 'schema'">
1853
- Schema
1862
+ {{ t("integration.nodeInspector.output.view.schema") }}
1854
1863
  </button>
1855
1864
  <button type="button" :disabled="!selfSample" :class="outputView === 'json' ? segActive : segIdle" @click="outputView = 'json'">
1856
- JSON
1865
+ {{ t("integration.nodeInspector.output.view.json") }}
1857
1866
  </button>
1858
1867
  </div>
1859
1868
  </div>
@@ -1864,32 +1873,32 @@ async function runTestStep() {
1864
1873
  </section>
1865
1874
 
1866
1875
  <section class="space-y-2 border-t border-black/[0.06] pt-4">
1867
- <span class="text-mono text-muted-foreground">Real values</span>
1876
+ <span class="text-mono text-muted-foreground">{{ t("integration.nodeInspector.output.realValues.heading") }}</span>
1868
1877
  <div class="flex items-center gap-2">
1869
1878
  <Button
1870
1879
  size="sm"
1871
1880
  class="h-8"
1872
1881
  :disabled="testRunning"
1873
- title="Runs this node once with the current draft config"
1882
+ :title="t('integration.nodeInspector.output.test.runTitle')"
1874
1883
  @click="runTestStep"
1875
1884
  >
1876
- {{ testRunning ? "Testing\u2026" : "Test step" }}
1885
+ {{ testRunning ? t("integration.nodeInspector.output.test.running") : t("integration.nodeInspector.output.test.action") }}
1877
1886
  </Button>
1878
1887
  <button
1879
1888
  type="button"
1880
1889
  class="rounded px-1 py-1 text-sm font-medium text-muted-foreground transition hover:text-foreground"
1881
1890
  @click="toggleTestInput"
1882
1891
  >
1883
- {{ testInputOpen ? "Hide input" : "Edit input" }}
1892
+ {{ testInputOpen ? t("integration.nodeInspector.output.test.hideInput") : t("integration.nodeInspector.output.test.editInput") }}
1884
1893
  </button>
1885
1894
  <span class="text-xs text-muted-foreground">
1886
- Runs this step alone, against the draft config — the workflow is not started.
1895
+ {{ t("integration.nodeInspector.output.test.runHint") }}
1887
1896
  </span>
1888
1897
  </div>
1889
1898
  <Textarea
1890
1899
  v-if="testInputOpen"
1891
1900
  v-model="testInputDraft"
1892
- aria-label="Test input"
1901
+ :aria-label="t('integration.nodeInspector.output.test.inputAria')"
1893
1902
  :rows="8"
1894
1903
  spellcheck="false"
1895
1904
  class="rounded-md border border-black/[0.08] ring-0 shadow-none bg-white font-mono text-xs"
@@ -1913,7 +1922,7 @@ async function runTestStep() {
1913
1922
  <template v-if="!pinEditorOpen">
1914
1923
  <div class="flex items-center gap-2">
1915
1924
  <Button variant="outline" size="sm" class="h-8" @click="openPinEditor">
1916
- {{ selfPinned ? "Edit pinned sample" : "Pin sample output" }}
1925
+ {{ selfPinned ? t("integration.nodeInspector.output.pin.editAction") : t("integration.nodeInspector.output.pin.pinAction") }}
1917
1926
  </Button>
1918
1927
  <button
1919
1928
  v-if="selfPinned"
@@ -1921,17 +1930,17 @@ async function runTestStep() {
1921
1930
  class="text-sm font-medium text-rose-600 transition hover:text-rose-700"
1922
1931
  @click="removePin"
1923
1932
  >
1924
- Unpin
1933
+ {{ t("integration.nodeInspector.output.pin.unpinAction") }}
1925
1934
  </button>
1926
1935
  <span class="text-xs text-muted-foreground">
1927
- A pinned sample stands in for run data while you map downstream fields.
1936
+ {{ t("integration.nodeInspector.output.pin.hint") }}
1928
1937
  </span>
1929
1938
  </div>
1930
1939
  </template>
1931
1940
  <template v-else>
1932
1941
  <Textarea
1933
1942
  v-model="pinDraft"
1934
- aria-label="Pinned sample output"
1943
+ :aria-label="t('integration.nodeInspector.output.pin.editorAria')"
1935
1944
  :rows="10"
1936
1945
  spellcheck="false"
1937
1946
  class="rounded-md border border-black/[0.08] ring-0 shadow-none bg-white font-mono text-xs"
@@ -1941,10 +1950,10 @@ async function runTestStep() {
1941
1950
  </p>
1942
1951
  <div class="flex items-center gap-2">
1943
1952
  <Button size="sm" class="h-8" @click="savePin">
1944
- Pin
1953
+ {{ t("integration.nodeInspector.output.pin.saveAction") }}
1945
1954
  </Button>
1946
1955
  <Button size="sm" variant="outline" class="h-8" @click="pinEditorOpen = false">
1947
- Cancel
1956
+ {{ t("integration.common.action.cancel") }}
1948
1957
  </Button>
1949
1958
  </div>
1950
1959
  </template>
@@ -1973,24 +1982,24 @@ async function runTestStep() {
1973
1982
  <Button
1974
1983
  variant="ghost"
1975
1984
  size="sm"
1976
- aria-label="Remove from workflow"
1985
+ :aria-label="t('integration.nodeInspector.footer.removeFromWorkflow')"
1977
1986
  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
1987
  @click="emit('remove')"
1979
1988
  >
1980
- <Trash2 :size="13" />
1989
+ <TrashBin :size="13" />
1981
1990
  <span
1982
1991
  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
1992
  >
1984
- Remove from workflow
1993
+ {{ t("integration.nodeInspector.footer.removeFromWorkflow") }}
1985
1994
  </span>
1986
1995
  </Button>
1987
1996
  <div class="flex items-center gap-2">
1988
- <span v-if="dirty" class="text-xs text-muted-foreground">Unsaved changes</span>
1997
+ <span v-if="dirty" class="text-xs text-muted-foreground">{{ t("integration.nodeInspector.footer.unsavedChanges") }}</span>
1989
1998
  <Button variant="outline" size="sm" class="h-8" @click="emit('close')">
1990
- Cancel
1999
+ {{ t("integration.common.action.cancel") }}
1991
2000
  </Button>
1992
2001
  <Button size="sm" class="h-8" @click="save">
1993
- Save
2002
+ {{ t("integration.common.action.save") }}
1994
2003
  </Button>
1995
2004
  </div>
1996
2005
  </div>