@revenexx/studio-integrations 0.5.1 → 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 (171) hide show
  1. package/CHANGELOG.md +428 -0
  2. package/README.md +48 -4
  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 +43 -41
  7. package/dist/runtime/components/Integrations/CredentialDetailDialog.vue +123 -50
  8. package/dist/runtime/components/Integrations/CredentialRevenexxSource.vue +108 -71
  9. package/dist/runtime/components/Integrations/CredentialTypePicker.vue +12 -15
  10. package/dist/runtime/components/Integrations/DeadLettersPanel.vue +18 -17
  11. package/dist/runtime/components/Integrations/EventTypeCombobox.d.vue.ts +1 -0
  12. package/dist/runtime/components/Integrations/EventTypeCombobox.vue +7 -4
  13. package/dist/runtime/components/Integrations/EventTypeCombobox.vue.d.ts +1 -0
  14. package/dist/runtime/components/Integrations/ExpressionInput.d.vue.ts +4 -0
  15. package/dist/runtime/components/Integrations/ExpressionInput.vue +67 -37
  16. package/dist/runtime/components/Integrations/ExpressionInput.vue.d.ts +4 -0
  17. package/dist/runtime/components/Integrations/GroupFrameView.vue +30 -20
  18. package/dist/runtime/components/Integrations/Layout.vue +48 -4
  19. package/dist/runtime/components/Integrations/NodeConfigDynamicField.vue +23 -23
  20. package/dist/runtime/components/Integrations/NodeConfigDynamicSchema.vue +11 -13
  21. package/dist/runtime/components/Integrations/NodeConfigField.d.vue.ts +2 -0
  22. package/dist/runtime/components/Integrations/NodeConfigField.vue +187 -93
  23. package/dist/runtime/components/Integrations/NodeConfigField.vue.d.ts +2 -0
  24. package/dist/runtime/components/Integrations/NodeInspector.d.vue.ts +6 -0
  25. package/dist/runtime/components/Integrations/NodeInspector.vue +1157 -376
  26. package/dist/runtime/components/Integrations/NodeInspector.vue.d.ts +6 -0
  27. package/dist/runtime/components/Integrations/NodeLogo.d.vue.ts +12 -0
  28. package/dist/runtime/components/Integrations/NodeLogo.vue +2 -30
  29. package/dist/runtime/components/Integrations/NodeLogo.vue.d.ts +12 -0
  30. package/dist/runtime/components/Integrations/NodeMark.d.vue.ts +15 -0
  31. package/dist/runtime/components/Integrations/NodeMark.vue +79 -0
  32. package/dist/runtime/components/Integrations/NodeMark.vue.d.ts +15 -0
  33. package/dist/runtime/components/Integrations/NodeOutputSchema.d.vue.ts +21 -0
  34. package/dist/runtime/components/Integrations/NodeOutputSchema.vue +43 -0
  35. package/dist/runtime/components/Integrations/NodeOutputSchema.vue.d.ts +21 -0
  36. package/dist/runtime/components/Integrations/NodePalette.vue +122 -55
  37. package/dist/runtime/components/Integrations/QueryBuilder.vue +46 -38
  38. package/dist/runtime/components/Integrations/RunActionsMenu.vue +38 -12
  39. package/dist/runtime/components/Integrations/RunDetailPanel.vue +114 -62
  40. package/dist/runtime/components/Integrations/RunPayloadInput.vue +27 -13
  41. package/dist/runtime/components/Integrations/RunResponseModal.vue +11 -10
  42. package/dist/runtime/components/Integrations/RunsTable.d.vue.ts +0 -1
  43. package/dist/runtime/components/Integrations/RunsTable.vue +38 -16
  44. package/dist/runtime/components/Integrations/RunsTable.vue.d.ts +0 -1
  45. package/dist/runtime/components/Integrations/SftpAccountImportDialog.vue +8 -7
  46. package/dist/runtime/components/Integrations/Sidebar.vue +14 -10
  47. package/dist/runtime/components/Integrations/StickyNoteView.d.vue.ts +14 -4
  48. package/dist/runtime/components/Integrations/StickyNoteView.vue +38 -23
  49. package/dist/runtime/components/Integrations/StickyNoteView.vue.d.ts +14 -4
  50. package/dist/runtime/components/Integrations/TemplateCard.d.vue.ts +18 -0
  51. package/dist/runtime/components/Integrations/TemplateCard.vue +119 -0
  52. package/dist/runtime/components/Integrations/TemplateCard.vue.d.ts +18 -0
  53. package/dist/runtime/components/Integrations/TemplateFlowPreview.d.vue.ts +2 -2
  54. package/dist/runtime/components/Integrations/TemplateFlowPreview.vue +16 -18
  55. package/dist/runtime/components/Integrations/TemplateFlowPreview.vue.d.ts +2 -2
  56. package/dist/runtime/components/Integrations/WorkflowContextMenu.vue +34 -33
  57. package/dist/runtime/components/Integrations/WorkflowCreateDialog.vue +52 -67
  58. package/dist/runtime/components/Integrations/WorkflowDuplicateDialog.vue +8 -7
  59. package/dist/runtime/components/Integrations/WorkflowEdgeView.d.vue.ts +18 -4
  60. package/dist/runtime/components/Integrations/WorkflowEdgeView.vue +29 -11
  61. package/dist/runtime/components/Integrations/WorkflowEdgeView.vue.d.ts +18 -4
  62. package/dist/runtime/components/Integrations/WorkflowEditor.d.vue.ts +20 -1
  63. package/dist/runtime/components/Integrations/WorkflowEditor.vue +240 -116
  64. package/dist/runtime/components/Integrations/WorkflowEditor.vue.d.ts +20 -1
  65. package/dist/runtime/components/Integrations/WorkflowHistoryPanel.vue +28 -43
  66. package/dist/runtime/components/Integrations/WorkflowIconPicker.d.vue.ts +19 -1
  67. package/dist/runtime/components/Integrations/WorkflowIconPicker.vue +17 -7
  68. package/dist/runtime/components/Integrations/WorkflowIconPicker.vue.d.ts +19 -1
  69. package/dist/runtime/components/Integrations/WorkflowNodeView.d.vue.ts +4 -0
  70. package/dist/runtime/components/Integrations/WorkflowNodeView.vue +198 -56
  71. package/dist/runtime/components/Integrations/WorkflowNodeView.vue.d.ts +4 -0
  72. package/dist/runtime/components/Integrations/WorkflowRunReplayPanel.vue +56 -59
  73. package/dist/runtime/components/Integrations/WorkflowSettingsDialog.vue +28 -21
  74. package/dist/runtime/components/Integrations/WorkflowTagsInput.d.vue.ts +1 -0
  75. package/dist/runtime/components/Integrations/WorkflowTagsInput.vue +15 -9
  76. package/dist/runtime/components/Integrations/WorkflowTagsInput.vue.d.ts +1 -0
  77. package/dist/runtime/components/Integrations/WorkflowTriggerView.vue +15 -10
  78. package/dist/runtime/components/Integrations/WorkflowVariablesDialog.vue +50 -36
  79. package/dist/runtime/components/Integrations/WorkflowsTable.vue +126 -48
  80. package/dist/runtime/composables/integrationsApi.js +4 -2
  81. package/dist/runtime/composables/useCredentialTypes.js +8 -6
  82. package/dist/runtime/composables/useIntegrationsAudit.js +4 -3
  83. package/dist/runtime/composables/useIntegrationsCredentials.js +10 -8
  84. package/dist/runtime/composables/useIntegrationsDeadLetters.js +7 -6
  85. package/dist/runtime/composables/useIntegrationsFocus.d.ts +12 -0
  86. package/dist/runtime/composables/useIntegrationsLocale.d.ts +26 -0
  87. package/dist/runtime/composables/useIntegrationsLocale.js +8 -0
  88. package/dist/runtime/composables/useIntegrationsNodes.js +7 -5
  89. package/dist/runtime/composables/useIntegrationsRealtime.d.ts +149 -0
  90. package/dist/runtime/composables/useIntegrationsRealtime.js +80 -0
  91. package/dist/runtime/composables/useIntegrationsRevisions.js +9 -7
  92. package/dist/runtime/composables/useIntegrationsRuns.d.ts +1 -1
  93. package/dist/runtime/composables/useIntegrationsRuns.js +14 -7
  94. package/dist/runtime/composables/useIntegrationsSchedules.d.ts +4 -0
  95. package/dist/runtime/composables/useIntegrationsSchedules.js +10 -3
  96. package/dist/runtime/composables/useIntegrationsSecrets.js +14 -9
  97. package/dist/runtime/composables/useIntegrationsT.d.ts +35 -0
  98. package/dist/runtime/composables/useIntegrationsT.js +9 -0
  99. package/dist/runtime/composables/useIntegrationsTemplates.d.ts +3 -3
  100. package/dist/runtime/composables/useIntegrationsTemplates.js +28 -16
  101. package/dist/runtime/composables/useIntegrationsTriggers.js +10 -8
  102. package/dist/runtime/composables/useIntegrationsWorkflows.js +13 -7
  103. package/dist/runtime/composables/useLoadGate.d.ts +17 -0
  104. package/dist/runtime/composables/useLoadGate.js +8 -0
  105. package/dist/runtime/composables/useNodeConfigResolve.js +2 -1
  106. package/dist/runtime/data/branchReorder.d.ts +37 -0
  107. package/dist/runtime/data/branchReorder.js +71 -0
  108. package/dist/runtime/data/handleId.d.ts +23 -0
  109. package/dist/runtime/data/handleId.js +6 -0
  110. package/dist/runtime/data/integrationNodeCatalog.d.ts +41 -0
  111. package/dist/runtime/data/integrationNodeCatalog.js +14 -0
  112. package/dist/runtime/data/integrationTemplates.d.ts +48 -0
  113. package/dist/runtime/data/integrationTemplates.js +21 -0
  114. package/dist/runtime/data/integrationWorkflowSamples.js +1 -1
  115. package/dist/runtime/data/nodeIcons.d.ts +2 -0
  116. package/dist/runtime/data/nodeIcons.js +73 -0
  117. package/dist/runtime/data/nodeInputData.d.ts +16 -4
  118. package/dist/runtime/data/nodeInputData.js +4 -0
  119. package/dist/runtime/data/payloadForm.d.ts +9 -2
  120. package/dist/runtime/data/payloadForm.js +29 -4
  121. package/dist/runtime/data/portTone.d.ts +17 -0
  122. package/dist/runtime/data/portTone.js +7 -0
  123. package/dist/runtime/data/webhookAuth.d.ts +18 -0
  124. package/dist/runtime/data/webhookAuth.js +19 -0
  125. package/dist/runtime/data/workflowIconCatalog.js +4 -6
  126. package/dist/runtime/i18n/messages/de.json +1207 -0
  127. package/dist/runtime/i18n/messages/en.json +1207 -0
  128. package/dist/runtime/i18n/translate.d.ts +83 -0
  129. package/dist/runtime/i18n/translate.js +41 -0
  130. package/dist/runtime/pages/integrations/audit.vue +140 -37
  131. package/dist/runtime/pages/integrations/credentials/oauth-complete.vue +9 -6
  132. package/dist/runtime/pages/integrations/credentials.vue +180 -64
  133. package/dist/runtime/pages/integrations/index.vue +91 -92
  134. package/dist/runtime/pages/integrations/runs.vue +33 -21
  135. package/dist/runtime/pages/integrations/schedules.vue +88 -23
  136. package/dist/runtime/pages/integrations/secrets.vue +57 -35
  137. package/dist/runtime/pages/integrations/templates/[slug].vue +119 -88
  138. package/dist/runtime/pages/integrations/templates/index.vue +82 -103
  139. package/dist/runtime/pages/integrations/workflows/[id].vue +344 -91
  140. package/dist/runtime/pages/integrations/workflows/index.vue +23 -5
  141. package/dist/runtime/plugins/locale.d.ts +10 -0
  142. package/dist/runtime/plugins/locale.js +6 -0
  143. package/dist/runtime/plugins/realtime.client.d.ts +32 -0
  144. package/dist/runtime/plugins/realtime.client.js +4 -0
  145. package/dist/runtime/types/integrations.d.ts +65 -6
  146. package/dist/runtime/utils/apiError.d.ts +47 -0
  147. package/dist/runtime/utils/apiError.js +32 -0
  148. package/dist/runtime/utils/cockpitSources.d.ts +153 -0
  149. package/dist/runtime/utils/cockpitSources.js +14 -0
  150. package/dist/runtime/utils/credentialState.d.ts +24 -1
  151. package/dist/runtime/utils/credentialState.js +26 -11
  152. package/dist/runtime/utils/loadGate.d.ts +61 -0
  153. package/dist/runtime/utils/loadGate.js +22 -0
  154. package/dist/runtime/utils/localize.d.ts +46 -4
  155. package/dist/runtime/utils/localize.js +34 -7
  156. package/dist/runtime/utils/notify.d.ts +59 -0
  157. package/dist/runtime/utils/notify.js +21 -0
  158. package/dist/runtime/utils/pollBudget.d.ts +72 -0
  159. package/dist/runtime/utils/pollBudget.js +17 -0
  160. package/dist/runtime/utils/revenexxCredentialSource.d.ts +26 -0
  161. package/dist/runtime/utils/revenexxCredentialSource.js +8 -0
  162. package/dist/runtime/utils/schedule.d.ts +116 -0
  163. package/dist/runtime/utils/schedule.js +219 -0
  164. package/dist/runtime/utils/statusStyle.d.ts +26 -2
  165. package/dist/runtime/utils/statusStyle.js +9 -1
  166. package/dist/runtime/utils/validationFindings.d.ts +101 -0
  167. package/dist/runtime/utils/validationFindings.js +54 -0
  168. package/dist/runtime/utils/whenIdle.d.ts +42 -0
  169. package/dist/runtime/utils/whenIdle.js +12 -0
  170. package/dist/types.d.mts +2 -6
  171. package/package.json +10 -3
@@ -1,22 +1,33 @@
1
1
  <script setup>
2
2
  import { computed, nextTick, onMounted, provide, ref, watch } from "vue";
3
- import { TrashBinMinimalistic as Trash2, Copy } from "#solar-icons";
4
- import { ChevronLeft, ChevronRight, Clock, Play, Search, Webhook, X, Zap } from "lucide-vue-next";
3
+ import { AddCircle as Plus, TrashBinMinimalistic as TrashBin, Copy } from "#solar-icons";
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 {
7
7
  Button,
8
+ Dialog,
9
+ DialogContent,
10
+ DialogDescription,
11
+ DialogHeader,
12
+ DialogTitle,
8
13
  Input,
9
14
  Label,
15
+ Popover,
16
+ PopoverContent,
17
+ PopoverTrigger,
10
18
  Select,
11
19
  SelectContent,
12
20
  SelectItem,
21
+ SelectSeparator,
13
22
  SelectTrigger,
14
23
  SelectValue,
15
24
  Switch,
16
- Textarea
25
+ Textarea,
26
+ ToggleGroup,
27
+ ToggleGroupItem
17
28
  } from "@revenexx/studio";
18
29
  import EventTypeCombobox from "./EventTypeCombobox.vue";
19
- import { NODE_CATEGORIES, findManifest } from "../../data/integrationNodeCatalog";
30
+ import { findManifest, nodeCategoryLabel } from "../../data/integrationNodeCatalog";
20
31
  import {
21
32
  collectUpstreamNodes,
22
33
  nodeOutputExpr,
@@ -24,12 +35,17 @@ import {
24
35
  triggerPayloadExpr,
25
36
  variableExpr
26
37
  } from "../../data/nodeInputData";
38
+ import { portDotClass } from "../../data/portTone";
39
+ import { authChoiceFromConfig, configPatchForAuthChoice } from "../../data/webhookAuth";
27
40
  import {
28
41
  dataTypeOf,
29
42
  portValuesFromStepOutput,
30
43
  previewValue,
31
44
  valueAtPath
32
45
  } from "../../data/nodeRunData";
46
+ import { resolveDisplayName, resolveLocalized } from "../../utils/localize";
47
+ import { describeRecurrence, parseCron, scheduleTimezone, toCron } from "../../utils/schedule";
48
+ const t = useIntegrationsT();
33
49
  const props = defineProps({
34
50
  selection: { type: [Object, null], required: true },
35
51
  secretKeys: { type: Array, required: true },
@@ -40,7 +56,8 @@ const props = defineProps({
40
56
  edges: { type: Array, required: false },
41
57
  triggers: { type: Array, required: false },
42
58
  dynamicOutputs: { type: Object, required: false },
43
- workflowId: { type: [Number, String], required: false }
59
+ workflowId: { type: [Number, String], required: false },
60
+ refusedFields: { type: Object, required: false }
44
61
  });
45
62
  const emit = defineEmits(["update", "close", "remove", "navigate"]);
46
63
  function clone(v) {
@@ -70,36 +87,69 @@ const manifest = computed(() => {
70
87
  const wn = draftNode.value;
71
88
  return wn ? findManifest(wn.nodeSlug, wn.nodeVersion) : null;
72
89
  });
73
- function labelText(v) {
74
- if (!v) return "";
75
- return typeof v === "string" ? v : v.en ?? Object.values(v)[0] ?? "";
76
- }
77
90
  const TRIGGER_GLYPHS = { manual: Play, schedule: Clock, webhook: Webhook, event: Zap };
78
- 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
+ }));
79
97
  const triggerGlyph = computed(() => TRIGGER_GLYPHS[draftTrigger.value?.type ?? "manual"]);
80
98
  const namePlaceholder = computed(() => {
81
- if (draftNode.value) return labelText(manifest.value?.name) || draftNode.value.nodeSlug;
82
- return TRIGGER_LABELS[draftTrigger.value?.type ?? "manual"] + " trigger";
99
+ if (draftNode.value) return resolveLocalized(manifest.value?.name) || draftNode.value.nodeSlug;
100
+ return t("integration.nodeInspector.trigger.placeholder", { type: TRIGGER_LABELS.value[draftTrigger.value?.type ?? "manual"] });
83
101
  });
84
102
  const subtitleText = computed(() => {
85
103
  const wn = draftNode.value;
86
104
  if (wn) return wn.nodeSlug + "@" + wn.nodeVersion;
87
- return TRIGGER_LABELS[draftTrigger.value?.type ?? "manual"] + " trigger";
105
+ return t("integration.nodeInspector.trigger.placeholder", { type: TRIGGER_LABELS.value[draftTrigger.value?.type ?? "manual"] });
88
106
  });
107
+ const manifestDescription = computed(() => resolveLocalized(manifest.value?.description));
89
108
  const groupLabel = computed(() => {
90
- if (!isNode.value) return "Trigger";
109
+ if (!isNode.value) return t("integration.nodeInspector.header.groupTrigger");
91
110
  const groups = manifest.value?.groups;
92
- if (groups?.length) return labelText(groups[groups.length - 1]);
93
- return NODE_CATEGORIES.find((c) => c.key === manifest.value?.category)?.label ?? "Node";
111
+ if (groups?.length) return resolveLocalized(groups[groups.length - 1]);
112
+ return nodeCategoryLabel(manifest.value?.category) || t("integration.nodeInspector.header.groupNode");
113
+ });
114
+ const nameValue = computed(() => {
115
+ const name = draft.value.name;
116
+ return resolveDisplayName(name);
94
117
  });
95
118
  function writeName(name) {
96
119
  draft.value = { ...draft.value, name };
97
120
  }
121
+ const noteOpen = ref(false);
122
+ const noteText = computed(() => (draftNode.value?.notes ?? "").trim());
123
+ const noteLabel = computed(() => noteText.value ? t("integration.nodeInspector.note.label") : t("integration.nodeInspector.note.addLabel"));
124
+ function writeNote(v) {
125
+ const text = String(v ?? "");
126
+ patchNode({ notes: text.trim() ? text : void 0 });
127
+ }
98
128
  const tab = ref("parameters");
99
129
  const showSettingsTab = computed(() => isNode.value);
130
+ const inspectorTabs = computed(() => [
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
+ ] : []
136
+ ]);
137
+ watch(showSettingsTab, (hasThem) => {
138
+ if (!hasThem) tab.value = "parameters";
139
+ });
100
140
  const SETTINGS_FIELD_KEY = /(^|_)(timeout|retry|retries|backoff)|(timeout|retry|retries|backoff)(ms|sec|seconds|attempts)?$/i;
101
141
  const parameterFields = computed(() => (manifest.value?.config ?? []).filter((f) => !SETTINGS_FIELD_KEY.test(f.key)));
102
142
  const settingsFields = computed(() => (manifest.value?.config ?? []).filter((f) => SETTINGS_FIELD_KEY.test(f.key)));
143
+ const requiredFields = computed(() => parameterFields.value.filter((f) => f.required));
144
+ const optionalFields = computed(() => parameterFields.value.filter((f) => !f.required));
145
+ function optionalShouldOpen() {
146
+ if (!requiredFields.value.length) return true;
147
+ return optionalFields.value.some((f) => props.refusedFields?.[f.key] !== void 0);
148
+ }
149
+ const optionalOpen = ref(optionalShouldOpen());
150
+ watch([() => props.selection?.id, manifest], () => {
151
+ optionalOpen.value = optionalShouldOpen();
152
+ });
103
153
  function dependentKeys(changedKey) {
104
154
  const fields = manifest.value?.config ?? [];
105
155
  const out = /* @__PURE__ */ new Set();
@@ -139,19 +189,13 @@ function patchTrigger(patch) {
139
189
  if (!draftTrigger.value) return;
140
190
  draft.value = { ...draftTrigger.value, ...patch };
141
191
  }
142
- const requiredMissingCount = computed(() => {
143
- if (!draftNode.value || !manifest.value?.config) return 0;
144
- const cfg = draftNode.value.config ?? {};
145
- const empty = (v) => v === void 0 || v === null || v === "" || Array.isArray(v) && v.length === 0;
146
- return manifest.value.config.filter((f) => f.required && empty(cfg[f.key])).length;
147
- });
148
192
  async function copyConfig() {
149
193
  if (!draftNode.value) return;
150
194
  try {
151
195
  await navigator.clipboard.writeText(JSON.stringify(draftNode.value.config ?? {}, null, 2));
152
- toast.success("Config copied");
196
+ toast.success(t("integration.nodeInspector.toast.configCopied"));
153
197
  } catch {
154
- toast.error("Could not copy config");
198
+ toast.error(t("integration.nodeInspector.toast.configCopyFailed"));
155
199
  }
156
200
  }
157
201
  async function pasteConfig() {
@@ -160,22 +204,22 @@ async function pasteConfig() {
160
204
  try {
161
205
  text = await navigator.clipboard.readText();
162
206
  } catch {
163
- toast.error("Could not read the clipboard");
207
+ toast.error(t("integration.nodeInspector.toast.clipboardReadFailed"));
164
208
  return;
165
209
  }
166
210
  let parsed;
167
211
  try {
168
212
  parsed = JSON.parse(text);
169
213
  } catch {
170
- toast.error("Clipboard is not valid JSON");
214
+ toast.error(t("integration.nodeInspector.toast.clipboardInvalidJson"));
171
215
  return;
172
216
  }
173
217
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
174
- toast.error("Clipboard is not a JSON config object");
218
+ toast.error(t("integration.nodeInspector.toast.clipboardNotConfigObject"));
175
219
  return;
176
220
  }
177
221
  patchNode({ config: parsed });
178
- toast.success("Config pasted");
222
+ toast.success(t("integration.nodeInspector.toast.configPasted"));
179
223
  }
180
224
  function readTriggerConfig(key) {
181
225
  return draftTrigger.value?.config[key];
@@ -185,6 +229,263 @@ function writeTriggerConfig(key, value) {
185
229
  if (!wt) return;
186
230
  patchTrigger({ config: { ...wt.config, [key]: value } });
187
231
  }
232
+ const MINUTE_INTERVALS = [1, 2, 5, 10, 15, 20, 30];
233
+ const HOUR_INTERVALS = [2, 3, 4, 6, 8, 12];
234
+ const MINUTES_PAST = [0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55];
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
+ ]);
253
+ const HOURS_OF_DAY = Array.from({ length: 24 }, (_, h) => h);
254
+ const FREQUENCY_ADVANCED = "advanced";
255
+ const cronValue = computed(() => readTriggerConfig("cron") ?? "");
256
+ const hasCron = computed(() => cronValue.value.trim() !== "");
257
+ const recurrence = computed(() => parseCron(cronValue.value));
258
+ const advancedOpen = ref(false);
259
+ watch(() => draftTrigger.value?.id, () => {
260
+ advancedOpen.value = false;
261
+ });
262
+ const frequency = computed(() => advancedOpen.value ? FREQUENCY_ADVANCED : recurrence.value?.kind ?? FREQUENCY_ADVANCED);
263
+ const ALL_DAYS = [0, 1, 2, 3, 4, 5, 6];
264
+ const carry = ref({
265
+ hour: 8,
266
+ minute: 0,
267
+ days: [1],
268
+ day: 1,
269
+ minuteInterval: 15,
270
+ hourInterval: 4,
271
+ // "Several times a day" carries its own two sets: its days start at every day,
272
+ // where a weekly schedule's start at one, and its hours start empty so the
273
+ // first pair is built from the time already on screen rather than from a
274
+ // working hour nobody chose.
275
+ hours: [],
276
+ timeDays: ALL_DAYS
277
+ });
278
+ watch(recurrence, (r) => {
279
+ if (!r) return;
280
+ if ("hour" in r) carry.value.hour = r.hour;
281
+ if ("minute" in r) carry.value.minute = r.minute;
282
+ if (r.kind === "weekly") carry.value.days = r.days;
283
+ if (r.kind === "monthly") carry.value.day = r.day;
284
+ if (r.kind === "minutes") carry.value.minuteInterval = r.interval;
285
+ if (r.kind === "hours") carry.value.hourInterval = r.interval;
286
+ if (r.kind === "times") {
287
+ carry.value.hours = r.hours;
288
+ carry.value.timeDays = r.days ?? ALL_DAYS;
289
+ }
290
+ }, { immediate: true });
291
+ function recurrenceFor(kind) {
292
+ const c = carry.value;
293
+ switch (kind) {
294
+ case "minutes":
295
+ return { kind: "minutes", interval: c.minuteInterval };
296
+ case "hourly":
297
+ return { kind: "hourly", minute: c.minute };
298
+ case "hours":
299
+ return { kind: "hours", interval: c.hourInterval, minute: c.minute };
300
+ case "daily":
301
+ return { kind: "daily", hour: c.hour, minute: c.minute };
302
+ case "times":
303
+ return {
304
+ kind: "times",
305
+ minute: c.minute,
306
+ // Two is the smallest set that is "several"; one hour is the daily rhythm.
307
+ // With none carried yet, the rhythm starts from the hour that was on screen
308
+ // and the one after it — a schedule set for 02:00 becomes 02:00 and 03:00
309
+ // rather than losing the time the author had already chosen.
310
+ hours: c.hours.length >= 2 ? c.hours : [c.hour, (c.hour + 1) % 24].sort((a, b) => a - b),
311
+ days: c.timeDays.length ? c.timeDays : null
312
+ };
313
+ case "weekly":
314
+ return { kind: "weekly", days: c.days.length ? c.days : [1], hour: c.hour, minute: c.minute };
315
+ case "monthly":
316
+ return { kind: "monthly", day: c.day, hour: c.hour, minute: c.minute };
317
+ }
318
+ }
319
+ function writeRecurrence(kind) {
320
+ writeTriggerConfig("cron", toCron(recurrenceFor(kind)));
321
+ }
322
+ function applyFrequency(v) {
323
+ const next = v;
324
+ advancedOpen.value = next === FREQUENCY_ADVANCED;
325
+ if (next !== FREQUENCY_ADVANCED) writeRecurrence(next);
326
+ }
327
+ function applyToCurrent() {
328
+ const f = frequency.value;
329
+ if (f !== FREQUENCY_ADVANCED) writeRecurrence(f);
330
+ }
331
+ function pad2(n) {
332
+ return String(n).padStart(2, "0");
333
+ }
334
+ function withCurrent(options, current) {
335
+ return options.includes(current) ? options : [...options, current].sort((a, b) => a - b);
336
+ }
337
+ const minuteIntervalOptions = computed(() => withCurrent(MINUTE_INTERVALS, carry.value.minuteInterval));
338
+ const minutePastOptions = computed(() => withCurrent(MINUTES_PAST, carry.value.minute));
339
+ const timeValue = computed(() => `${pad2(carry.value.hour)}:${pad2(carry.value.minute)}`);
340
+ function applyTime(v) {
341
+ const m = /^(\d{1,2}):(\d{2})/.exec(String(v ?? ""));
342
+ if (!m) return;
343
+ carry.value.hour = Math.min(23, Number(m[1]));
344
+ carry.value.minute = Math.min(59, Number(m[2]));
345
+ applyToCurrent();
346
+ }
347
+ const selectedDays = computed(() => (frequency.value === "times" ? carry.value.timeDays : carry.value.days).map(String));
348
+ function applyDays(v) {
349
+ const picked = (Array.isArray(v) ? v : []).map(Number).filter((n) => Number.isInteger(n) && n >= 0 && n <= 6);
350
+ const forTimes = frequency.value === "times";
351
+ const previous = forTimes ? carry.value.timeDays : carry.value.days;
352
+ const days = [...new Set(picked.length ? picked : previous)].sort((a, b) => a - b);
353
+ if (forTimes) {
354
+ carry.value.timeDays = days;
355
+ writeRecurrence("times");
356
+ return;
357
+ }
358
+ carry.value.days = days;
359
+ writeRecurrence("weekly");
360
+ }
361
+ const selectedHours = computed(() => carry.value.hours.map(String));
362
+ function applyHours(v) {
363
+ const picked = (Array.isArray(v) ? v : []).map(Number).filter((n) => Number.isInteger(n) && n >= 0 && n <= 23);
364
+ const hours = [...new Set(picked)].sort((a, b) => a - b);
365
+ carry.value.hours = hours.length >= 2 ? hours : carry.value.hours;
366
+ writeRecurrence("times");
367
+ }
368
+ function applyMinuteInterval(v) {
369
+ carry.value.minuteInterval = Number(v);
370
+ writeRecurrence("minutes");
371
+ }
372
+ function applyHourInterval(v) {
373
+ carry.value.hourInterval = Number(v);
374
+ writeRecurrence("hours");
375
+ }
376
+ function applyMinutePast(v) {
377
+ carry.value.minute = Number(v);
378
+ applyToCurrent();
379
+ }
380
+ function applyDayOfMonth(v) {
381
+ carry.value.day = Number(v);
382
+ writeRecurrence("monthly");
383
+ }
384
+ const scheduleSentence = computed(() => {
385
+ const r = recurrence.value;
386
+ if (!r) return null;
387
+ return `${describeRecurrence(r)} \xB7 ${scheduleTimezone(readTriggerConfig("timezone"))}`;
388
+ });
389
+ const webhookUrl = computed(() => {
390
+ const wt = draftTrigger.value;
391
+ if (!wt || wt.type !== "webhook") return null;
392
+ const inbound = wt.config.inbound;
393
+ if (inbound?.url) return inbound.url;
394
+ const base = String(useRuntimeConfig().public.integrationsApi ?? "").replace(/\/+$/, "");
395
+ return base ? `${base}/webhooks/${wt.id}` : null;
396
+ });
397
+ const webhookIsPublic = computed(() => !!draftTrigger.value?.config?.public);
398
+ const authChoice = computed(() => authChoiceFromConfig(draftTrigger.value?.config));
399
+ function writeTriggerConfigPatch(patch) {
400
+ const wt = draftTrigger.value;
401
+ if (!wt) return;
402
+ patchTrigger({ config: { ...wt.config, ...patch } });
403
+ }
404
+ function applyAuthChoice(v) {
405
+ writeTriggerConfigPatch(configPatchForAuthChoice(v));
406
+ }
407
+ function writeAuthConfigKey(key, value) {
408
+ const cfg = draftTrigger.value?.config;
409
+ writeTriggerConfigPatch({ authConfig: { ...cfg?.authConfig, [key]: value || void 0 } });
410
+ }
411
+ const webhookExposed = computed(() => !!draftTrigger.value?.config?.inbound?.url);
412
+ const rotateBusy = ref(false);
413
+ const rotatedSecret = ref(null);
414
+ const rotateOpen = ref(false);
415
+ async function rotateEndpointSecret() {
416
+ const wt = draftTrigger.value;
417
+ if (!wt || !props.workflowId) return;
418
+ rotateBusy.value = true;
419
+ try {
420
+ const api2 = useIntegrationsApi();
421
+ const res = await api2.post(`/workflows/${props.workflowId}/triggers/${wt.id}/rotate-secret`, {});
422
+ const body = res ?? {};
423
+ const secret = body.inbound_secret ?? body.data?.inbound_secret ?? null;
424
+ if (secret) {
425
+ rotatedSecret.value = secret;
426
+ rotateOpen.value = true;
427
+ } else {
428
+ toast.error(t("integration.nodeInspector.toast.rotateNoSecret"));
429
+ }
430
+ } catch (e) {
431
+ toast.error(e.message || t("integration.nodeInspector.toast.rotateFailed"));
432
+ } finally {
433
+ rotateBusy.value = false;
434
+ }
435
+ }
436
+ async function copySecret() {
437
+ if (!rotatedSecret.value) return;
438
+ try {
439
+ await navigator.clipboard.writeText(rotatedSecret.value);
440
+ toast.success(t("integration.nodeInspector.toast.secretCopied"));
441
+ } catch {
442
+ toast.error(t("integration.nodeInspector.toast.secretCopyFailed"));
443
+ }
444
+ }
445
+ async function copyWebhookUrl() {
446
+ if (!webhookUrl.value) return;
447
+ try {
448
+ await navigator.clipboard.writeText(webhookUrl.value);
449
+ toast.success(t("integration.nodeInspector.toast.webhookUrlCopied"));
450
+ } catch {
451
+ toast.error(t("integration.nodeInspector.toast.webhookUrlCopyFailed"));
452
+ }
453
+ }
454
+ const definedEvents = typeof useDefinedEvents === "function" ? useDefinedEvents() : { emits: ref([]), load: async () => {
455
+ } };
456
+ const eventTopic = computed(() => {
457
+ const raw = readTriggerConfig("event_type");
458
+ return typeof raw === "string" && raw.trim() !== "" ? raw.trim() : null;
459
+ });
460
+ watch(eventTopic, (topic) => {
461
+ if (topic) void definedEvents.load();
462
+ }, { immediate: true });
463
+ const eventSample = computed(() => {
464
+ if (!eventTopic.value) return null;
465
+ const sample = definedEvents.emits.value.find((e) => e.topic === eventTopic.value)?.sample;
466
+ return sample ? JSON.stringify(sample, null, 2) : null;
467
+ });
468
+ const manualFields = computed(() => readTriggerConfig("payloadFields") ?? []);
469
+ function writeManualFields(next) {
470
+ writeTriggerConfig("payloadFields", next.length ? next : void 0);
471
+ }
472
+ function setManualField(idx, patch) {
473
+ writeManualFields(manualFields.value.map((f, i) => i === idx ? { ...f, ...patch } : f));
474
+ }
475
+ function addManualField() {
476
+ writeManualFields([...manualFields.value, { path: "", label: "", kind: "text" }]);
477
+ }
478
+ function removeManualField(idx) {
479
+ manualFieldUids.value.splice(idx, 1);
480
+ writeManualFields(manualFields.value.filter((_, i) => i !== idx));
481
+ }
482
+ let nextManualFieldUid = 0;
483
+ const manualFieldUids = ref([]);
484
+ watch(() => manualFields.value.length, (n) => {
485
+ const uids = manualFieldUids.value;
486
+ while (uids.length < n) uids.push(nextManualFieldUid++);
487
+ if (uids.length > n) uids.splice(n);
488
+ }, { immediate: true });
188
489
  const { loadForWorkflow, getSteps } = useIntegrationsRuns();
189
490
  const runData = ref(null);
190
491
  const runInfo = ref(null);
@@ -198,7 +499,7 @@ async function loadRunData() {
198
499
  const { runs } = await loadForWorkflow(Number(props.workflowId), "", { perPage: 10 });
199
500
  const source = runs.find((r) => r.status === "succeeded") ?? runs[0];
200
501
  if (!source) {
201
- runError.value = "No runs yet. Run the workflow once to sample real values.";
502
+ runError.value = t("integration.nodeInspector.input.runData.empty");
202
503
  return;
203
504
  }
204
505
  const res = await getSteps(props.workflowId, source.id, true);
@@ -213,7 +514,7 @@ async function loadRunData() {
213
514
  runData.value = map;
214
515
  runInfo.value = { id: source.id, startedAt: source.startedAt };
215
516
  } catch (e) {
216
- runError.value = e?.message ?? "Could not load run data.";
517
+ runError.value = e?.message ?? t("integration.nodeInspector.input.runData.loadFailed");
217
518
  } finally {
218
519
  runLoading.value = false;
219
520
  }
@@ -275,7 +576,7 @@ const sampleContext = computed(() => {
275
576
  });
276
577
  provide("integrationsSampleContext", sampleContext);
277
578
  provide("integrationsNodeNames", computed(
278
- () => Object.fromEntries((props.nodes ?? []).map((n) => [n.id, labelText(n.name) || n.nodeSlug]))
579
+ () => Object.fromEntries((props.nodes ?? []).map((n) => [n.id, resolveDisplayName(n.name) || n.nodeSlug]))
279
580
  ));
280
581
  const upstreamSections = computed(() => {
281
582
  if (!isNode.value) return [];
@@ -283,7 +584,7 @@ const upstreamSections = computed(() => {
283
584
  const m = findManifest(node.nodeSlug, node.nodeVersion);
284
585
  return {
285
586
  id: node.id,
286
- name: labelText(node.name) || node.nodeSlug,
587
+ name: resolveDisplayName(node.name) || node.nodeSlug,
287
588
  manifest: m,
288
589
  direct,
289
590
  ports: withSampledFields(portsForNode(node, m, props.dynamicOutputs?.[node.id]), sampleFor(node.id))
@@ -291,30 +592,25 @@ const upstreamSections = computed(() => {
291
592
  });
292
593
  });
293
594
  const variableNames = computed(() => Object.keys(props.variables ?? {}));
294
- function portDotClass(kind, name) {
295
- if (name === "true") return "bg-emerald-500";
296
- if (name === "false") return "bg-amber-400";
297
- if (kind === "error") return "bg-rose-500";
298
- if (kind === "branch") return "bg-violet-500";
299
- return "bg-slate-400";
300
- }
301
595
  const lastField = ref(null);
302
596
  const lastFocusKind = ref(null);
597
+ const inputPaneOpen = ref(false);
303
598
  const exprInsertHandler = ref(null);
304
599
  provide("integrationsExpressionInsert", exprInsertHandler);
305
600
  function onCenterFocusIn(e) {
306
- const t = e.target;
307
- if (t instanceof HTMLElement && t.closest(".expr-editor")) {
601
+ const target = e.target;
602
+ if (target instanceof HTMLElement && target.closest(".expr-editor")) {
308
603
  lastFocusKind.value = "editor";
604
+ inputPaneOpen.value = true;
309
605
  return;
310
606
  }
311
- if (t instanceof HTMLTextAreaElement) {
312
- lastField.value = t;
607
+ if (target instanceof HTMLTextAreaElement) {
608
+ lastField.value = target;
313
609
  lastFocusKind.value = "native";
314
610
  return;
315
611
  }
316
- if (t instanceof HTMLInputElement && !["number", "checkbox", "radio", "range", "file", "color"].includes(t.type)) {
317
- lastField.value = t;
612
+ if (target instanceof HTMLInputElement && !["number", "checkbox", "radio", "range", "file", "color"].includes(target.type)) {
613
+ lastField.value = target;
318
614
  lastFocusKind.value = "native";
319
615
  }
320
616
  }
@@ -336,9 +632,10 @@ function insertExpression(expression) {
336
632
  void navigator.clipboard?.writeText(expression);
337
633
  } catch {
338
634
  }
339
- toast("Expression copied. Click into a field first to insert it directly.");
635
+ toast(t("integration.nodeInspector.toast.expressionCopied"));
340
636
  }
341
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";
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";
342
639
  function onChipDragStart(e, expression) {
343
640
  e.dataTransfer?.setData("text/plain", expression);
344
641
  if (e.dataTransfer) e.dataTransfer.effectAllowed = "copy";
@@ -354,10 +651,10 @@ function onChipDragEnd() {
354
651
  dragActive.value = false;
355
652
  clearDropHover();
356
653
  }
357
- function expressionTarget(t) {
358
- if (t instanceof HTMLTextAreaElement) return t;
359
- if (t instanceof HTMLInputElement && !["number", "checkbox", "radio", "range", "file", "color"].includes(t.type)) return t;
360
- 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");
361
658
  return null;
362
659
  }
363
660
  function onCenterDragEnter(e) {
@@ -408,6 +705,14 @@ const visibleVariableNames = computed(() => {
408
705
  const q = searchQuery.value;
409
706
  return q ? variableNames.value.filter((n) => n.toLowerCase().includes(q)) : variableNames.value;
410
707
  });
708
+ const variableRows = computed(() => visibleVariableNames.value.map((name) => {
709
+ const def = props.variables?.[name]?.default;
710
+ return {
711
+ name,
712
+ glyph: typeGlyph(dataTypeOf(def)),
713
+ value: def === void 0 || def === "" ? null : previewValue(def)
714
+ };
715
+ }));
411
716
  const searchEmpty = computed(
412
717
  () => !!searchQuery.value && !visibleSections.value.length && !showTriggerChip.value && !visibleVariableNames.value.length
413
718
  );
@@ -441,7 +746,7 @@ function typeGlyph(dataType) {
441
746
  return dataType ? TYPE_GLYPHS[dataType] ?? null : null;
442
747
  }
443
748
  const sequence = computed(() => [
444
- ...(props.triggers ?? []).map((t) => t.id),
749
+ ...(props.triggers ?? []).map((t2) => t2.id),
445
750
  ...(props.nodes ?? []).map((n) => n.id)
446
751
  ]);
447
752
  const seqIndex = computed(() => sequence.value.indexOf(draft.value.id));
@@ -475,17 +780,17 @@ function savePin() {
475
780
  try {
476
781
  parsed = JSON.parse(pinDraft.value);
477
782
  } catch {
478
- pinError.value = "Not valid JSON.";
783
+ pinError.value = t("integration.nodeInspector.output.pin.invalidJson");
479
784
  return;
480
785
  }
481
786
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
482
- pinError.value = 'Must be an object mapping ports to payloads, e.g. { "default": { \u2026 } }.';
787
+ pinError.value = t("integration.nodeInspector.output.pin.invalidShape");
483
788
  return;
484
789
  }
485
790
  pinnedSamples.value = { ...pinnedSamples.value, [draftNode.value.id]: parsed };
486
791
  persistPins();
487
792
  pinEditorOpen.value = false;
488
- toast.success("Sample output pinned");
793
+ toast.success(t("integration.nodeInspector.toast.samplePinned"));
489
794
  }
490
795
  function removePin() {
491
796
  if (!draftNode.value) return;
@@ -524,12 +829,12 @@ async function runTestStep() {
524
829
  try {
525
830
  const parsed = JSON.parse(testInputDraft.value.trim() || defaultTestInput());
526
831
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
527
- testError.value = "Test input must be a JSON object.";
832
+ testError.value = t("integration.nodeInspector.output.test.inputNotObject");
528
833
  return;
529
834
  }
530
835
  inputs = parsed;
531
836
  } catch {
532
- testError.value = "Test input is not valid JSON.";
837
+ testError.value = t("integration.nodeInspector.output.test.inputInvalidJson");
533
838
  testInputOpen.value = true;
534
839
  return;
535
840
  }
@@ -547,10 +852,10 @@ async function runTestStep() {
547
852
  [wn.id]: res.outputs && typeof res.outputs === "object" ? res.outputs : {}
548
853
  };
549
854
  outputView.value = "json";
550
- toast.success("Test run finished");
855
+ toast.success(t("integration.nodeInspector.toast.testRunFinished"));
551
856
  } catch (e) {
552
857
  const body = e.body;
553
- 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");
554
859
  testLogs.value = body?.logs ?? [];
555
860
  } finally {
556
861
  testRunning.value = false;
@@ -563,31 +868,130 @@ async function runTestStep() {
563
868
  width/height, border and the close control. -->
564
869
  <div class="flex min-h-0 flex-1 flex-col bg-white">
565
870
  <!-- Header -->
566
- <div class="flex shrink-0 items-center gap-3 border-b border-black/[0.06] px-4 py-3 pr-14">
871
+ <div class="flex shrink-0 items-center gap-3 border-b border-black/[0.06] px-4 py-3">
567
872
  <div class="flex size-9 shrink-0 items-center justify-center rounded-md border border-black/[0.06] bg-slate-50 text-slate-600">
568
- <IntegrationsNodeLogo v-if="isNode" :manifest="manifest" :size="20" />
873
+ <IntegrationsNodeMark v-if="isNode" :manifest="manifest" :size="20" />
569
874
  <component :is="triggerGlyph" v-else :size="18" />
570
875
  </div>
571
876
  <div class="min-w-0 flex-1">
877
+ <!-- Named explicitly: the placeholder is the node's slug, which left
878
+ this field accessibly called `revenexx:sftp-list` — a name for the
879
+ type, not for the field. -->
572
880
  <input
573
881
  ref="nameInput"
574
- :value="labelText(draft.name)"
882
+ :aria-label="t('integration.nodeInspector.header.nameAria')"
883
+ :value="nameValue"
575
884
  :placeholder="namePlaceholder"
576
- class="w-full truncate bg-transparent text-sm font-semibold text-foreground outline-none placeholder:text-muted-foreground/60"
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"
577
886
  @input="writeName($event.target.value)"
578
887
  >
579
- <div class="truncate font-mono text-[11px] text-muted-foreground">
580
- {{ subtitleText }}
888
+ <div class="flex items-center gap-1.5 text-xs text-muted-foreground">
889
+ <span class="truncate">{{ subtitleText }}</span>
890
+ <!-- What the node TYPE is for, one click behind the line that names
891
+ the type. Not printed into the header: it is read once, when the
892
+ node is new to someone, and a manifest that writes three
893
+ paragraphs would otherwise push the form off the screen every
894
+ time it is opened by someone who already knows. No description in
895
+ the manifest, no question mark — nothing to ask. -->
896
+ <Popover v-if="manifestDescription">
897
+ <PopoverTrigger as-child>
898
+ <!-- The ring stays 16px because that is what a `?` in a subtitle
899
+ line should look like; the TARGET is grown to 24px by the
900
+ `after` overlay. A 16px target inline under the full-width
901
+ name input misses WCAG 2.5.8 and cannot claim the spacing
902
+ exception — a 24px circle centred here reaches into the input,
903
+ which is a target of its own. And this is the only route to
904
+ the description at all. -->
905
+ <button
906
+ type="button"
907
+ :aria-label="t('integration.nodeInspector.header.whatThisDoes')"
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"
909
+ >
910
+ ?
911
+ </button>
912
+ </PopoverTrigger>
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">
914
+ <div class="flex items-center gap-2.5">
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">
916
+ <IntegrationsNodeMark :manifest="manifest" :size="18" />
917
+ </span>
918
+ <div class="min-w-0">
919
+ <div class="truncate text-sm font-semibold text-foreground">
920
+ {{ resolveLocalized(manifest?.name) }}
921
+ </div>
922
+ <div class="truncate text-xs text-muted-foreground">
923
+ {{ subtitleText }}
924
+ </div>
925
+ </div>
926
+ </div>
927
+ <!-- Reading matter, so it gets reading measure and scrolls when
928
+ the node package is generous. -->
929
+ <p class="mt-3 max-h-64 overflow-y-auto text-sm leading-relaxed text-foreground/80">
930
+ {{ manifestDescription }}
931
+ </p>
932
+ </PopoverContent>
933
+ </Popover>
581
934
  </div>
582
935
  </div>
583
936
  <span class="shrink-0 rounded bg-slate-100 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wider text-slate-600">
584
937
  {{ groupLabel }}
585
938
  </span>
939
+ <!-- The user's own note. A control of its own, deliberately not a second
940
+ paragraph under the name: the line above is what the node TYPE does
941
+ and cannot be edited, this is what one person wrote about this one
942
+ node. Named like the badge on the card, so the canvas and the dialog
943
+ agree on what the thing is called. -->
944
+ <Popover v-if="draftNode" v-model:open="noteOpen">
945
+ <PopoverTrigger as-child>
946
+ <button
947
+ type="button"
948
+ class="flex shrink-0 items-center gap-1.5 rounded-md border px-2 py-1 text-xs font-medium transition"
949
+ :class="noteText ? 'border-amber-500/40 bg-amber-500/10 text-amber-700' : 'border-black/[0.08] text-muted-foreground hover:bg-black/[0.04] hover:text-foreground'"
950
+ >
951
+ <MessageSquareText :size="12" />
952
+ {{ noteLabel }}
953
+ </button>
954
+ </PopoverTrigger>
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>
957
+ <Textarea
958
+ :aria-label="t('integration.nodeInspector.note.label')"
959
+ :model-value="draftNode.notes ?? ''"
960
+ :rows="4"
961
+ :placeholder="t('integration.nodeInspector.note.placeholder')"
962
+ class="rounded-md border border-black/[0.08] ring-0 shadow-none text-sm bg-white"
963
+ @update:model-value="writeNote"
964
+ />
965
+ </PopoverContent>
966
+ </Popover>
967
+ <!-- The Input pane on request. Also opens by itself when the cursor lands
968
+ in an expression field — this is for reading it, or for dragging out
969
+ of it, before a field is chosen.
970
+
971
+ Not offered on a trigger: a trigger starts the workflow, so there is no
972
+ upstream data, and none of its settings takes an expression — the pane
973
+ would open onto its own explanation of why it is empty, in a dialog
974
+ sized for the few controls a trigger has. -->
975
+ <button
976
+ v-if="isNode"
977
+ type="button"
978
+ class="flex shrink-0 items-center gap-1.5 rounded-md border px-2 py-1 text-xs font-medium transition"
979
+ :class="inputPaneOpen ? 'border-emerald-500/40 bg-emerald-500/10 text-emerald-700' : 'border-black/[0.08] text-muted-foreground hover:bg-black/[0.04] hover:text-foreground'"
980
+ :aria-pressed="inputPaneOpen"
981
+ @click="inputPaneOpen = !inputPaneOpen"
982
+ >
983
+ <Database :size="12" />
984
+ {{ t("integration.nodeInspector.header.inputData") }}
985
+ </button>
986
+ <!-- Icon-only controls: `title` alone leaves them nameless to a screen
987
+ reader (and unaddressable to a test without a CSS selector), so each
988
+ carries the same sentence as an aria-label. -->
586
989
  <div v-if="sequence.length > 1" class="flex shrink-0 items-center gap-0.5 border-l border-black/[0.06] pl-2">
587
990
  <button
588
991
  type="button"
589
992
  :disabled="!hasPrev"
590
- title="Previous step (saves changes)"
993
+ :aria-label="t('integration.nodeInspector.header.previousStep')"
994
+ :title="t('integration.nodeInspector.header.previousStep')"
591
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"
592
996
  @click="navigate(-1)"
593
997
  >
@@ -596,33 +1000,46 @@ async function runTestStep() {
596
1000
  <button
597
1001
  type="button"
598
1002
  :disabled="!hasNext"
599
- title="Next step (saves changes)"
1003
+ :aria-label="t('integration.nodeInspector.header.nextStep')"
1004
+ :title="t('integration.nodeInspector.header.nextStep')"
600
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"
601
1006
  @click="navigate(1)"
602
1007
  >
603
1008
  <ChevronRight :size="14" />
604
1009
  </button>
605
1010
  </div>
1011
+ <!-- Close lives in the header row so it lines up with the chevrons
1012
+ (the DialogContent built-in X floats at its own offset). -->
1013
+ <button
1014
+ type="button"
1015
+ :aria-label="t('integration.common.action.close')"
1016
+ :title="t('integration.common.action.close')"
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"
1018
+ @click="emit('close')"
1019
+ >
1020
+ <X :size="15" />
1021
+ </button>
606
1022
  </div>
607
1023
 
608
1024
  <!-- Body: Input pane + tabbed center -->
609
1025
  <div class="flex min-h-0 flex-1">
610
1026
  <!-- Input pane -->
611
- <aside class="flex w-[300px] shrink-0 flex-col border-r border-black/[0.06] bg-muted/10" @dragend="onChipDragEnd">
1027
+ <aside v-if="inputPaneOpen" class="flex w-[300px] shrink-0 flex-col border-r border-black/[0.06] bg-muted/10" @dragend="onChipDragEnd">
612
1028
  <div class="shrink-0 border-b border-black/[0.06] px-3 py-2.5">
613
- <div class="text-sm font-semibold text-foreground">
614
- Input
1029
+ <div class="text-mono text-muted-foreground">
1030
+ {{ t("integration.nodeInspector.input.title") }}
615
1031
  </div>
616
1032
  <p class="mt-0.5 text-xs leading-snug text-muted-foreground">
617
- Available data. Click a value to insert it into the focused field, or drag it onto one.
1033
+ {{ t("integration.nodeInspector.input.description") }}
618
1034
  </p>
619
1035
  <div class="relative mt-2">
620
1036
  <Search :size="12" class="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
621
1037
  <input
622
1038
  v-model="inputSearch"
623
1039
  type="text"
624
- placeholder="Search data"
625
- 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]"
1040
+ :aria-label="t('integration.nodeInspector.input.searchAria')"
1041
+ :placeholder="t('integration.nodeInspector.input.searchAria')"
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"
626
1043
  >
627
1044
  </div>
628
1045
  <div v-if="workflowId" class="mt-2 flex items-center gap-1.5">
@@ -633,15 +1050,15 @@ async function runTestStep() {
633
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"
634
1051
  @click="loadRunData"
635
1052
  >
636
- {{ runLoading ? "Loading run data\u2026" : "Use last run data" }}
1053
+ {{ runLoading ? t("integration.nodeInspector.input.runData.loading") : t("integration.nodeInspector.input.runData.use") }}
637
1054
  </button>
638
1055
  <template v-else>
639
1056
  <span class="min-w-0 flex-1 truncate text-[11px] text-emerald-700">
640
- Values from run #{{ runInfo.id }}
1057
+ {{ t("integration.nodeInspector.input.runData.from", { id: runInfo.id }) }}
641
1058
  </span>
642
1059
  <button
643
1060
  type="button"
644
- title="Clear run data"
1061
+ :title="t('integration.nodeInspector.input.runData.clear')"
645
1062
  class="flex size-5 shrink-0 items-center justify-center rounded text-muted-foreground transition hover:text-foreground"
646
1063
  @click="clearRunData"
647
1064
  >
@@ -658,10 +1075,10 @@ async function runTestStep() {
658
1075
  <!-- Upstream nodes -->
659
1076
  <div v-if="!searchQuery || visibleSections.length" class="space-y-3">
660
1077
  <div class="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
661
- Nodes
1078
+ {{ t("integration.nodeInspector.input.nodes.heading") }}
662
1079
  </div>
663
1080
  <p v-if="!upstreamSections.length" class="text-xs leading-snug text-muted-foreground">
664
- No upstream nodes yet. Connect a node into this one to reference its output here.
1081
+ {{ t("integration.nodeInspector.input.nodes.empty") }}
665
1082
  </p>
666
1083
  <div v-for="s in visibleSections" :key="s.id" class="rounded-md border border-black/[0.06]">
667
1084
  <button
@@ -670,14 +1087,14 @@ async function runTestStep() {
670
1087
  @click="toggleNodeSection(s.id)"
671
1088
  >
672
1089
  <ChevronRight :size="11" class="shrink-0 text-muted-foreground transition-transform" :class="sectionClosed(s.id) ? '' : 'rotate-90'" />
673
- <IntegrationsNodeLogo :manifest="s.manifest" :size="14" />
1090
+ <IntegrationsNodeMark :manifest="s.manifest" :size="14" :badge="false" />
674
1091
  <span class="min-w-0 truncate text-xs font-medium text-foreground">{{ s.name }}</span>
675
1092
  <span
676
1093
  v-if="!s.direct"
677
1094
  class="shrink-0 rounded bg-slate-100 px-1 py-px text-[9px] font-medium uppercase tracking-wider text-slate-500"
678
- title="Not directly connected; reachable further upstream"
1095
+ :title="t('integration.nodeInspector.input.nodes.indirectTitle')"
679
1096
  >
680
- upstream
1097
+ {{ t("integration.nodeInspector.input.nodes.indirectBadge") }}
681
1098
  </span>
682
1099
  </button>
683
1100
  <div v-show="!sectionClosed(s.id)" class="space-y-2 border-t border-black/[0.06] px-2 py-2">
@@ -686,25 +1103,37 @@ async function runTestStep() {
686
1103
  <span class="size-1.5 rounded-full" :class="portDotClass(p.kind, p.name)" />
687
1104
  <span class="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">{{ p.name }}</span>
688
1105
  </div>
689
- <div class="flex flex-wrap gap-1">
1106
+ <!-- Schema rows, n8n-style: glyph · key · sampled value. -->
1107
+ <div class="space-y-px">
690
1108
  <button
691
1109
  type="button"
692
- :class="chipClass"
1110
+ :class="rowClass"
1111
+ :aria-label="nodeOutputExpr(s.id, p.name)"
693
1112
  :title="nodeOutputExpr(s.id, p.name)"
694
1113
  draggable="true"
695
1114
  @dragstart="onChipDragStart($event, nodeOutputExpr(s.id, p.name))"
696
1115
  @click="insertExpression(nodeOutputExpr(s.id, p.name))"
697
- ><span v-for="(seg, i) in highlightSegments(p.name)" :key="i" :class="seg.hit ? 'rounded-[2px] bg-amber-200/80' : ''">{{ seg.text }}</span></button>
698
- <button
699
- v-for="f in p.fields"
700
- :key="f.name"
701
- type="button"
702
- :class="chipClass"
703
- :title="nodeOutputExpr(s.id, p.name, f.name) + (f.dataType ? ' \xB7 ' + f.dataType : '')"
704
- draggable="true"
705
- @dragstart="onChipDragStart($event, nodeOutputExpr(s.id, p.name, f.name))"
706
- @click="insertExpression(nodeOutputExpr(s.id, p.name, f.name))"
707
- ><span v-if="typeGlyph(f.dataType)" class="mr-1 font-semibold" :class="typeGlyph(f.dataType).cls">{{ typeGlyph(f.dataType).char }}</span><span v-for="(seg, i) in highlightSegments(f.name)" :key="i" :class="seg.hit ? 'rounded-[2px] bg-amber-200/80' : ''">{{ seg.text }}</span><span v-if="chipValue(s.id, p.name, f.name) !== null" class="text-slate-400">: {{ chipValue(s.id, p.name, f.name) }}</span></button>
1116
+ >
1117
+ <span class="w-3 shrink-0 text-center font-semibold text-slate-400">{}</span>
1118
+ <span class="shrink-0 font-medium"><span v-for="(seg, i) in highlightSegments(p.name)" :key="i" :class="seg.hit ? 'rounded-[2px] bg-amber-200/80' : ''">{{ seg.text }}</span></span>
1119
+ </button>
1120
+ <div class="space-y-px pl-3">
1121
+ <button
1122
+ v-for="f in p.fields"
1123
+ :key="f.name"
1124
+ type="button"
1125
+ :class="rowClass"
1126
+ :aria-label="nodeOutputExpr(s.id, p.name, f.name)"
1127
+ :title="nodeOutputExpr(s.id, p.name, f.name) + (f.dataType ? ' \xB7 ' + f.dataType : '')"
1128
+ draggable="true"
1129
+ @dragstart="onChipDragStart($event, nodeOutputExpr(s.id, p.name, f.name))"
1130
+ @click="insertExpression(nodeOutputExpr(s.id, p.name, f.name))"
1131
+ >
1132
+ <span class="w-3 shrink-0 text-center font-semibold" :class="typeGlyph(f.dataType)?.cls ?? 'text-slate-300'">{{ typeGlyph(f.dataType)?.char ?? "\xB7" }}</span>
1133
+ <span class="shrink-0"><span v-for="(seg, i) in highlightSegments(f.name)" :key="i" :class="seg.hit ? 'rounded-[2px] bg-amber-200/80' : ''">{{ seg.text }}</span></span>
1134
+ <span v-if="chipValue(s.id, p.name, f.name) !== null" class="min-w-0 flex-1 truncate text-slate-400">{{ chipValue(s.id, p.name, f.name) }}</span>
1135
+ </button>
1136
+ </div>
708
1137
  </div>
709
1138
  </div>
710
1139
  </div>
@@ -714,12 +1143,13 @@ async function runTestStep() {
714
1143
  <!-- Trigger payload -->
715
1144
  <div v-if="showTriggerChip" class="space-y-1.5">
716
1145
  <div class="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
717
- Trigger
1146
+ {{ t("integration.nodeInspector.input.trigger.heading") }}
718
1147
  </div>
719
1148
  <div class="flex flex-wrap gap-1">
720
1149
  <button
721
1150
  type="button"
722
1151
  :class="chipClass"
1152
+ :aria-label="triggerPayloadExpr()"
723
1153
  :title="triggerPayloadExpr()"
724
1154
  draggable="true"
725
1155
  @dragstart="onChipDragStart($event, triggerPayloadExpr())"
@@ -729,64 +1159,53 @@ async function runTestStep() {
729
1159
  </button>
730
1160
  </div>
731
1161
  <p class="text-xs leading-snug text-muted-foreground">
732
- 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") }}
733
1163
  </p>
734
1164
  </div>
735
1165
  </template>
736
1166
 
737
1167
  <p v-else class="text-xs leading-snug text-muted-foreground">
738
- Triggers start the workflow, so there is no upstream data. Workflow variables are available everywhere.
1168
+ {{ t("integration.nodeInspector.input.noUpstreamForTrigger") }}
739
1169
  </p>
740
1170
 
741
1171
  <!-- Workflow variables -->
742
1172
  <div v-if="!searchQuery || visibleVariableNames.length" class="space-y-1.5">
743
1173
  <div class="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
744
- Variables
1174
+ {{ t("integration.nodeInspector.input.variables.heading") }}
745
1175
  </div>
746
- <div v-if="visibleVariableNames.length" class="flex flex-wrap gap-1">
1176
+ <!-- Schema rows with the declared default as the value. -->
1177
+ <div v-if="variableRows.length" class="space-y-px">
747
1178
  <button
748
- v-for="name in visibleVariableNames"
749
- :key="name"
1179
+ v-for="row in variableRows"
1180
+ :key="row.name"
750
1181
  type="button"
751
- :class="chipClass"
752
- :title="variableExpr(name)"
1182
+ :class="rowClass"
1183
+ :aria-label="variableExpr(row.name)"
1184
+ :title="variableExpr(row.name)"
753
1185
  draggable="true"
754
- @dragstart="onChipDragStart($event, variableExpr(name))"
755
- @click="insertExpression(variableExpr(name))"
756
- ><span v-for="(seg, i) in highlightSegments(name)" :key="i" :class="seg.hit ? 'rounded-[2px] bg-amber-200/80' : ''">{{ seg.text }}</span></button>
1186
+ @dragstart="onChipDragStart($event, variableExpr(row.name))"
1187
+ @click="insertExpression(variableExpr(row.name))"
1188
+ >
1189
+ <span class="w-3 shrink-0 text-center font-semibold" :class="row.glyph?.cls ?? 'text-slate-300'">{{ row.glyph?.char ?? "\xB7" }}</span>
1190
+ <span class="shrink-0"><span v-for="(seg, i) in highlightSegments(row.name)" :key="i" :class="seg.hit ? 'rounded-[2px] bg-amber-200/80' : ''">{{ seg.text }}</span></span>
1191
+ <span v-if="row.value !== null" class="min-w-0 flex-1 truncate text-slate-400">{{ row.value }}</span>
1192
+ </button>
757
1193
  </div>
758
1194
  <p v-else class="text-xs leading-snug text-muted-foreground">
759
- No workflow variables defined.
1195
+ {{ t("integration.nodeInspector.input.variables.empty") }}
760
1196
  </p>
761
1197
  </div>
762
1198
 
763
1199
  <p v-if="searchEmpty" class="text-xs leading-snug text-muted-foreground">
764
- Nothing matches "{{ inputSearch.trim() }}". Clear the search to see all available data.
1200
+ {{ t("integration.nodeInspector.input.search.empty", { query: inputSearch.trim() }) }}
765
1201
  </p>
766
1202
  </div>
767
1203
  </aside>
768
1204
 
769
1205
  <!-- Center: tabs + content -->
770
1206
  <section class="flex min-w-0 flex-1 flex-col">
771
- <div class="flex shrink-0 items-center gap-1 border-b border-black/[0.06] px-4">
772
- <button
773
- type="button"
774
- class="relative -mb-px border-b-2 px-2.5 py-2 text-sm font-medium transition"
775
- :class="tab === 'parameters' ? 'border-foreground text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'"
776
- @click="tab = 'parameters'"
777
- >
778
- Parameters
779
- </button>
780
- <button
781
- v-if="showSettingsTab"
782
- type="button"
783
- class="relative -mb-px border-b-2 px-2.5 py-2 text-sm font-medium transition"
784
- :class="tab === 'settings' ? 'border-foreground text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'"
785
- @click="tab = 'settings'"
786
- >
787
- Settings
788
- </button>
789
- </div>
1207
+ <!-- Same TabBar as the workflow page — one tab look everywhere. -->
1208
+ <TabBar v-model="tab" :tabs="inspectorTabs" class="shrink-0 px-4" />
790
1209
 
791
1210
  <div
792
1211
  class="min-h-0 flex-1 overflow-y-auto p-4"
@@ -797,131 +1216,437 @@ async function runTestStep() {
797
1216
  @drop="onCenterDrop"
798
1217
  >
799
1218
  <!-- Parameters: trigger config -->
800
- <div v-if="!isNode && draftTrigger" class="mx-auto w-full max-w-3xl space-y-3">
1219
+ <!-- Full width: triggers have no output pane, a capped column
1220
+ would float lost in the wide center. -->
1221
+ <div v-if="!isNode && draftTrigger" class="w-full space-y-3">
801
1222
  <!-- Active toggle. Persists with the workflow save like every other trigger field. -->
802
- <div class="flex items-center justify-between rounded-lg border border-black/[0.06] bg-muted/20 px-3 py-2">
803
- <div>
804
- <div class="text-sm font-medium">Active</div>
805
- <p class="text-xs text-muted-foreground">
806
- A run needs at least one active trigger.
807
- </p>
808
- </div>
1223
+ <div class="flex items-center gap-3 rounded-lg border border-black/[0.06] bg-muted/20 px-3 py-2">
1224
+ <!-- The name lives in the text next to the switch, which is not
1225
+ a <label> — so the switch says it itself. -->
809
1226
  <Switch
1227
+ :aria-label="t('integration.nodeInspector.trigger.active.label')"
1228
+ class="data-[state=unchecked]:bg-slate-300"
810
1229
  :model-value="draftTrigger.active !== false"
811
1230
  @update:model-value="(v) => patchTrigger({ active: v })"
812
1231
  />
1232
+ <div>
1233
+ <div class="text-sm font-medium">{{ t("integration.nodeInspector.trigger.active.label") }}</div>
1234
+ <p class="text-xs text-muted-foreground">
1235
+ {{ t("integration.nodeInspector.trigger.active.hint") }}
1236
+ </p>
1237
+ </div>
813
1238
  </div>
1239
+ <!--
1240
+ The schedule is picked, not written. Each frequency shows only its
1241
+ own fields, and the expression appears in one place only: the
1242
+ Advanced field, for a schedule no picker can represent.
1243
+ -->
814
1244
  <template v-if="draftTrigger.type === 'schedule'">
815
- <div class="space-y-1.5">
816
- <Label class="text-mono text-muted-foreground">Cron</Label>
817
- <Input
818
- :model-value="readTriggerConfig('cron') ?? ''"
819
- placeholder="0 2 * * *"
820
- class="h-8 text-sm bg-white font-mono"
821
- @update:model-value="(v) => writeTriggerConfig('cron', v)"
822
- />
1245
+ <div class="grid grid-cols-2 gap-3">
1246
+ <div class="space-y-1.5">
1247
+ <!-- "Frequency", not "Runs": a run is an execution everywhere
1248
+ else in the studio, and there is a whole page of them. -->
1249
+ <Label for="ni-frequency" class="text-mono text-muted-foreground">{{ t("integration.nodeInspector.schedule.frequency.label") }}</Label>
1250
+ <Select :model-value="frequency" @update:model-value="applyFrequency">
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">
1252
+ <SelectValue />
1253
+ </SelectTrigger>
1254
+ <SelectContent>
1255
+ <SelectItem v-for="f in FREQUENCY_OPTIONS" :key="f.value" :value="f.value">{{ f.label }}</SelectItem>
1256
+ <SelectSeparator />
1257
+ <SelectItem :value="FREQUENCY_ADVANCED">{{ t("integration.nodeInspector.schedule.frequency.advanced") }}</SelectItem>
1258
+ </SelectContent>
1259
+ </Select>
1260
+ </div>
1261
+
1262
+ <!-- Every few minutes: the gap between firings. -->
1263
+ <div v-if="frequency === 'minutes'" class="space-y-1.5">
1264
+ <Label for="ni-minute-interval" class="text-mono text-muted-foreground">{{ t("integration.nodeInspector.schedule.interval.label") }}</Label>
1265
+ <Select :model-value="String(carry.minuteInterval)" @update:model-value="applyMinuteInterval">
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">
1267
+ <SelectValue />
1268
+ </SelectTrigger>
1269
+ <SelectContent>
1270
+ <SelectItem v-for="n in minuteIntervalOptions" :key="n" :value="String(n)">
1271
+ {{ t("integration.nodeInspector.schedule.minuteInterval.option", n) }}
1272
+ </SelectItem>
1273
+ </SelectContent>
1274
+ </Select>
1275
+ </div>
1276
+
1277
+ <!-- Every few hours: the gap, plus where in the hour it lands. -->
1278
+ <div v-if="frequency === 'hours'" class="space-y-1.5">
1279
+ <Label for="ni-hour-interval" class="text-mono text-muted-foreground">{{ t("integration.nodeInspector.schedule.interval.label") }}</Label>
1280
+ <Select :model-value="String(carry.hourInterval)" @update:model-value="applyHourInterval">
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">
1282
+ <SelectValue />
1283
+ </SelectTrigger>
1284
+ <SelectContent>
1285
+ <SelectItem v-for="n in HOUR_INTERVALS" :key="n" :value="String(n)">{{ t("integration.nodeInspector.schedule.hourInterval.option", { n }) }}</SelectItem>
1286
+ </SelectContent>
1287
+ </Select>
1288
+ </div>
1289
+
1290
+ <div v-if="frequency === 'hourly' || frequency === 'hours' || frequency === 'times'" class="space-y-1.5">
1291
+ <Label for="ni-minute-past" class="text-mono text-muted-foreground">{{ t("integration.nodeInspector.schedule.minutePast.label") }}</Label>
1292
+ <Select :model-value="String(carry.minute)" @update:model-value="applyMinutePast">
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">
1294
+ <SelectValue />
1295
+ </SelectTrigger>
1296
+ <SelectContent>
1297
+ <SelectItem v-for="n in minutePastOptions" :key="n" :value="String(n)">:{{ pad2(n) }}</SelectItem>
1298
+ </SelectContent>
1299
+ </Select>
1300
+ </div>
1301
+
1302
+ <!-- A fixed time of day: the one field all three calendar
1303
+ rhythms share, which is why it survives a change of
1304
+ frequency. -->
1305
+ <div v-if="frequency === 'daily' || frequency === 'weekly' || frequency === 'monthly'" class="space-y-1.5">
1306
+ <Label for="ni-time" class="text-mono text-muted-foreground">{{ t("integration.nodeInspector.schedule.time.label") }}</Label>
1307
+ <Input
1308
+ id="ni-time"
1309
+ type="time"
1310
+ :aria-label="t('integration.nodeInspector.schedule.time.label')"
1311
+ :model-value="timeValue"
1312
+ class="h-8 rounded-md border border-black/[0.08] ring-0 shadow-none text-sm bg-white tabular-nums"
1313
+ @update:model-value="applyTime"
1314
+ />
1315
+ </div>
1316
+
1317
+ <div v-if="frequency === 'monthly'" class="space-y-1.5">
1318
+ <Label for="ni-day-of-month" class="text-mono text-muted-foreground">{{ t("integration.nodeInspector.schedule.dayOfMonth.label") }}</Label>
1319
+ <Select :model-value="String(carry.day)" @update:model-value="applyDayOfMonth">
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">
1321
+ <SelectValue />
1322
+ </SelectTrigger>
1323
+ <SelectContent>
1324
+ <SelectItem v-for="n in 31" :key="n" :value="String(n)">{{ n }}</SelectItem>
1325
+ </SelectContent>
1326
+ </Select>
1327
+ </div>
1328
+
1329
+ <!-- Advanced: the expression itself, the only place it is shown. -->
1330
+ <div v-if="frequency === FREQUENCY_ADVANCED" class="space-y-1.5">
1331
+ <Label for="ni-cron" class="text-mono text-muted-foreground">{{ t("integration.nodeInspector.schedule.cron.label") }}</Label>
1332
+ <Input
1333
+ id="ni-cron"
1334
+ :aria-label="t('integration.nodeInspector.schedule.cron.label')"
1335
+ :model-value="cronValue"
1336
+ placeholder="0 9-17 * * 1-5"
1337
+ class="h-8 rounded-md border border-black/[0.08] ring-0 shadow-none text-sm bg-white font-mono"
1338
+ @update:model-value="(v) => writeTriggerConfig('cron', v)"
1339
+ />
1340
+ </div>
823
1341
  </div>
824
- <div class="space-y-1.5">
825
- <Label class="text-mono text-muted-foreground">Timezone</Label>
826
- <Input
827
- :model-value="readTriggerConfig('timezone') ?? ''"
828
- placeholder="Europe/Berlin"
829
- class="h-8 text-sm bg-white"
830
- @update:model-value="(v) => writeTriggerConfig('timezone', v)"
831
- />
1342
+
1343
+ <!--
1344
+ The hours of "several times a day". Two rows of twelve, and the
1345
+ minute above is shared by all of them: cron cannot express times
1346
+ with different minutes in one expression, so 07:30 and 09:15 is two
1347
+ schedule triggers rather than one field per row.
1348
+ -->
1349
+ <div v-if="frequency === 'times'" class="space-y-1.5">
1350
+ <Label class="text-mono text-muted-foreground">{{ t("integration.nodeInspector.schedule.hours.label") }}</Label>
1351
+ <ToggleGroup
1352
+ type="multiple"
1353
+ :aria-label="t('integration.nodeInspector.schedule.hours.label')"
1354
+ :model-value="selectedHours"
1355
+ class="grid w-fit grid-cols-12 gap-1"
1356
+ @update:model-value="applyHours"
1357
+ >
1358
+ <ToggleGroupItem
1359
+ v-for="h in HOURS_OF_DAY"
1360
+ :key="h"
1361
+ :value="String(h)"
1362
+ :aria-label="`${pad2(h)}:${pad2(carry.minute)}`"
1363
+ class="h-7 w-9 rounded-md border border-black/[0.08] bg-white text-xs tabular-nums data-[state=on]:border-emerald-500/40 data-[state=on]:bg-emerald-500/10 data-[state=on]:text-emerald-700"
1364
+ >
1365
+ {{ pad2(h) }}
1366
+ </ToggleGroupItem>
1367
+ </ToggleGroup>
832
1368
  </div>
1369
+
1370
+ <!-- Weekdays: full width, because seven toggles in half of the
1371
+ grid would wrap. Serves both rhythms that fall on days. -->
1372
+ <div v-if="frequency === 'weekly' || frequency === 'times'" class="space-y-1.5">
1373
+ <Label class="text-mono text-muted-foreground">{{ t("integration.nodeInspector.schedule.days.label") }}</Label>
1374
+ <ToggleGroup
1375
+ type="multiple"
1376
+ :aria-label="t('integration.nodeInspector.schedule.days.label')"
1377
+ :model-value="selectedDays"
1378
+ class="justify-start gap-1"
1379
+ @update:model-value="applyDays"
1380
+ >
1381
+ <ToggleGroupItem
1382
+ v-for="d in DAY_OPTIONS"
1383
+ :key="d.value"
1384
+ :value="String(d.value)"
1385
+ :aria-label="d.name"
1386
+ class="h-8 w-10 rounded-md border border-black/[0.08] bg-white text-sm data-[state=on]:border-emerald-500/40 data-[state=on]:bg-emerald-500/10 data-[state=on]:text-emerald-700"
1387
+ >
1388
+ {{ d.short }}
1389
+ </ToggleGroupItem>
1390
+ </ToggleGroup>
1391
+ </div>
1392
+
1393
+ <!--
1394
+ The confirmation. The timezone is in it because the platform reads
1395
+ the expression's numbers as wall-clock time in that zone, so a time
1396
+ without its zone would be half an answer.
1397
+ -->
1398
+ <p v-if="scheduleSentence" :aria-label="t('integration.nodeInspector.schedule.whenItFiresAria')" class="text-xs text-emerald-700">{{ scheduleSentence }}</p>
1399
+ <!-- No expression at all is its own answer, and the same one the
1400
+ Schedules page gives for this trigger: it is not written as
1401
+ anything, and it does not run. A trigger placed from the palette
1402
+ carries an expression, so this is for one that arrived without
1403
+ one. -->
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") }}
1406
+ </p>
1407
+ <p v-else class="text-xs text-muted-foreground">
1408
+ {{ t("integration.nodeInspector.schedule.writtenAsExpression") }}
1409
+ </p>
1410
+ <p v-if="frequency === 'monthly' && carry.day >= 29" class="text-xs text-muted-foreground">
1411
+ {{ t("integration.nodeInspector.schedule.monthlySkipHint") }}
1412
+ </p>
833
1413
  </template>
834
1414
  <template v-else-if="draftTrigger.type === 'webhook'">
835
1415
  <div class="space-y-1.5">
836
- <Label class="text-mono text-muted-foreground">Path</Label>
1416
+ <Label for="ni-webhook-url" class="text-mono text-muted-foreground">{{ t("integration.nodeInspector.trigger.webhook.url.label") }}</Label>
1417
+ <div v-if="webhookUrl" class="flex items-center gap-1.5">
1418
+ <Input
1419
+ id="ni-webhook-url"
1420
+ readonly
1421
+ :model-value="webhookUrl"
1422
+ class="h-8 flex-1 rounded-md border border-black/[0.08] ring-0 shadow-none bg-white font-mono text-xs"
1423
+ @focus="$event.target.select()"
1424
+ />
1425
+ <button
1426
+ type="button"
1427
+ :title="t('integration.nodeInspector.trigger.webhook.url.copyTitle')"
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"
1429
+ @click="copyWebhookUrl"
1430
+ >
1431
+ <Copy :size="14" />
1432
+ </button>
1433
+ </div>
1434
+ <p class="text-xs text-muted-foreground">
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>
1437
+ </p>
1438
+ </div>
1439
+ <div class="flex items-center gap-3 rounded-lg border border-black/[0.06] bg-muted/20 px-3 py-2">
1440
+ <Switch
1441
+ :aria-label="t('integration.nodeInspector.trigger.webhook.public.label')"
1442
+ class="data-[state=unchecked]:bg-slate-300"
1443
+ :model-value="webhookIsPublic"
1444
+ @update:model-value="(v) => writeTriggerConfigPatch(v ? { public: true, ...draftTrigger.config.authProfile ? {} : configPatchForAuthChoice('token') } : { public: void 0 })"
1445
+ />
1446
+ <div>
1447
+ <div class="text-sm font-medium">{{ t("integration.nodeInspector.trigger.webhook.public.label") }}</div>
1448
+ <p class="text-xs text-muted-foreground">
1449
+ {{ t("integration.nodeInspector.trigger.webhook.public.hint") }}
1450
+ </p>
1451
+ </div>
1452
+ </div>
1453
+ <div class="grid grid-cols-2 gap-3">
1454
+ <div class="space-y-1.5">
1455
+ <Label for="ni-webhook-method" class="text-mono text-muted-foreground">{{ t("integration.nodeInspector.trigger.webhook.method.label") }}</Label>
1456
+ <Select
1457
+ :model-value="readTriggerConfig('method') ?? 'POST'"
1458
+ @update:model-value="(v) => writeTriggerConfig('method', v)"
1459
+ >
1460
+ <SelectTrigger id="ni-webhook-method" size="sm" class="h-8 w-full rounded-md border border-black/[0.08] ring-0 shadow-none text-sm bg-white">
1461
+ <SelectValue />
1462
+ </SelectTrigger>
1463
+ <SelectContent>
1464
+ <SelectItem value="GET">GET</SelectItem>
1465
+ <SelectItem value="POST">POST</SelectItem>
1466
+ <SelectItem value="PUT">PUT</SelectItem>
1467
+ <SelectItem value="PATCH">PATCH</SelectItem>
1468
+ <SelectItem value="DELETE">DELETE</SelectItem>
1469
+ </SelectContent>
1470
+ </Select>
1471
+ </div>
1472
+ <div v-if="webhookIsPublic" class="space-y-1.5">
1473
+ <Label for="ni-webhook-protection" class="text-mono text-muted-foreground">{{ t("integration.nodeInspector.trigger.webhook.protection.label") }}</Label>
1474
+ <Select :model-value="authChoice" @update:model-value="applyAuthChoice">
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">
1476
+ <SelectValue />
1477
+ </SelectTrigger>
1478
+ <SelectContent>
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>
1483
+ </SelectContent>
1484
+ </Select>
1485
+ <p class="text-xs text-muted-foreground">{{ t("integration.nodeInspector.trigger.webhook.protection.hint") }}</p>
1486
+ </div>
1487
+ </div>
1488
+ <div v-if="webhookIsPublic && authChoice === 'token'" class="space-y-1.5">
1489
+ <Label for="ni-auth-header" class="text-mono text-muted-foreground">{{ t("integration.nodeInspector.trigger.webhook.header.label") }}</Label>
837
1490
  <Input
838
- :model-value="readTriggerConfig('path') ?? ''"
839
- placeholder="/orders"
840
- class="h-8 text-sm bg-white font-mono"
841
- @update:model-value="(v) => writeTriggerConfig('path', v)"
1491
+ id="ni-auth-header"
1492
+ :model-value="draftTrigger.config.authConfig?.header ?? ''"
1493
+ placeholder="X-Webhook-Token"
1494
+ class="h-8 rounded-md border border-black/[0.08] ring-0 shadow-none bg-white text-sm"
1495
+ @update:model-value="(v) => writeAuthConfigKey('header', String(v))"
842
1496
  />
1497
+ <p class="text-xs text-muted-foreground">{{ t("integration.nodeInspector.trigger.webhook.header.hint") }}</p>
843
1498
  </div>
844
- <div class="space-y-1.5">
845
- <Label class="text-mono text-muted-foreground">Method</Label>
846
- <Select
847
- :model-value="readTriggerConfig('method') ?? 'POST'"
848
- @update:model-value="(v) => writeTriggerConfig('method', v)"
849
- >
850
- <SelectTrigger class="h-8 text-sm bg-white">
851
- <SelectValue />
852
- </SelectTrigger>
853
- <SelectContent>
854
- <SelectItem value="GET">GET</SelectItem>
855
- <SelectItem value="POST">POST</SelectItem>
856
- <SelectItem value="PUT">PUT</SelectItem>
857
- <SelectItem value="PATCH">PATCH</SelectItem>
858
- <SelectItem value="DELETE">DELETE</SelectItem>
859
- </SelectContent>
860
- </Select>
1499
+ <div v-if="webhookIsPublic && authChoice === 'basic'" class="space-y-1.5">
1500
+ <Label for="ni-auth-username" class="text-mono text-muted-foreground">{{ t("integration.nodeInspector.trigger.webhook.username.label") }}</Label>
1501
+ <Input
1502
+ id="ni-auth-username"
1503
+ :model-value="draftTrigger.config.authConfig?.username ?? ''"
1504
+ :placeholder="t('integration.nodeInspector.trigger.webhook.username.placeholder')"
1505
+ class="h-8 rounded-md border border-black/[0.08] ring-0 shadow-none bg-white text-sm"
1506
+ @update:model-value="(v) => writeAuthConfigKey('username', String(v))"
1507
+ />
1508
+ <p class="text-xs text-muted-foreground">{{ t("integration.nodeInspector.trigger.webhook.username.hint") }}</p>
861
1509
  </div>
862
- <div class="space-y-1.5">
863
- <Label class="text-mono text-muted-foreground">Signing secret</Label>
864
- <Select
865
- :model-value="readTriggerConfig('secretRef') ?? ''"
866
- @update:model-value="(v) => writeTriggerConfig('secretRef', v)"
867
- >
868
- <SelectTrigger class="h-8 text-sm bg-white">
869
- <SelectValue placeholder="None" />
870
- </SelectTrigger>
871
- <SelectContent>
872
- <SelectItem v-for="k in secretKeys" :key="k" :value="k">{{ k }}</SelectItem>
873
- </SelectContent>
874
- </Select>
1510
+ <div v-if="webhookExposed && authChoice !== 'none'" class="flex items-center gap-2">
1511
+ <Button type="button" variant="outline" size="sm" class="h-7 text-sm" :disabled="rotateBusy" @click="rotateEndpointSecret">
1512
+ {{ rotateBusy ? t("integration.nodeInspector.trigger.webhook.rotate.busy") : t("integration.nodeInspector.trigger.webhook.rotate.action") }}
1513
+ </Button>
1514
+ <p class="text-xs text-muted-foreground">{{ t("integration.nodeInspector.trigger.webhook.rotate.hint") }}</p>
875
1515
  </div>
1516
+
1517
+ <Dialog v-model:open="rotateOpen">
1518
+ <DialogContent class="sm:max-w-md">
1519
+ <DialogHeader>
1520
+ <DialogTitle>{{ t("integration.nodeInspector.trigger.webhook.rotate.dialogTitle") }}</DialogTitle>
1521
+ <DialogDescription>
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
+ }) }}
1525
+ </DialogDescription>
1526
+ </DialogHeader>
1527
+ <div class="flex items-center gap-1.5">
1528
+ <Input
1529
+ readonly
1530
+ :model-value="rotatedSecret ?? ''"
1531
+ class="h-8 flex-1 rounded-md border border-black/[0.08] ring-0 shadow-none bg-white font-mono text-xs"
1532
+ @focus="$event.target.select()"
1533
+ />
1534
+ <button
1535
+ type="button"
1536
+ :title="t('integration.nodeInspector.trigger.webhook.rotate.copyTitle')"
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"
1538
+ @click="copySecret"
1539
+ >
1540
+ <Copy :size="14" />
1541
+ </button>
1542
+ </div>
1543
+ </DialogContent>
1544
+ </Dialog>
876
1545
  </template>
877
1546
  <template v-else-if="draftTrigger.type === 'event'">
878
1547
  <div class="space-y-1.5">
879
- <Label 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>
880
1549
  <EventTypeCombobox
1550
+ input-id="ni-event-type"
881
1551
  :model-value="readTriggerConfig('event_type') ?? ''"
882
1552
  @update:model-value="(v) => writeTriggerConfig('event_type', v)"
883
1553
  />
884
- <p class="text-sm text-muted-foreground">
885
- 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>.
1554
+ <p class="text-xs text-muted-foreground">
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>.
886
1556
  </p>
887
1557
  </div>
1558
+ <div v-if="eventSample" class="space-y-1.5">
1559
+ <!-- A caption, not a field label: it heads a read-only block,
1560
+ so it stays a plain line rather than an orphan <label>. -->
1561
+ <div class="text-mono text-muted-foreground">{{ t("integration.nodeInspector.trigger.event.sample.heading") }}</div>
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>
1563
+ <p class="text-xs text-muted-foreground">{{ t("integration.nodeInspector.trigger.event.sample.hint") }} <code class="font-mono">trigger.payload</code>.</p>
1564
+ </div>
1565
+ <p v-else-if="eventTopic" class="text-xs text-muted-foreground">
1566
+ {{ t("integration.nodeInspector.trigger.event.sample.empty") }}
1567
+ </p>
888
1568
  </template>
889
1569
  <template v-else>
890
- <p class="text-sm text-muted-foreground">
891
- Manual triggers have no configuration. The workflow runs when invoked from the editor or via the API.
892
- </p>
1570
+ <div class="space-y-1.5">
1571
+ <!-- Caption for the field list below, not a label for one
1572
+ control — the row's own controls are named individually. -->
1573
+ <div class="text-mono text-muted-foreground">{{ t("integration.nodeInspector.trigger.manual.heading") }}</div>
1574
+ <p class="text-xs text-muted-foreground">
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") }}
1578
+ </p>
1579
+ </div>
1580
+ <div v-if="manualFields.length" class="space-y-1.5">
1581
+ <div v-for="(mf, idx) in manualFields" :key="manualFieldUids[idx] ?? idx" class="flex items-center gap-1.5">
1582
+ <!-- A repeating row has no room for visible labels, so each
1583
+ control carries its own name and says which row it is. -->
1584
+ <Select :model-value="mf.kind ?? 'text'" @update:model-value="(v) => setManualField(idx, { kind: v })">
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">
1586
+ <SelectValue />
1587
+ </SelectTrigger>
1588
+ <SelectContent>
1589
+ <SelectItem value="text">{{ t("integration.nodeInspector.trigger.manual.kind.text") }}</SelectItem>
1590
+ <SelectItem value="file">{{ t("integration.nodeInspector.trigger.manual.kind.file") }}</SelectItem>
1591
+ </SelectContent>
1592
+ </Select>
1593
+ <Input
1594
+ :aria-label="t('integration.nodeInspector.trigger.manual.field.keyAria', { n: idx + 1 })"
1595
+ :model-value="mf.path"
1596
+ :placeholder="t('integration.nodeInspector.trigger.manual.field.keyPlaceholder')"
1597
+ class="h-8 w-[38%] rounded-md border border-black/[0.08] ring-0 shadow-none bg-white font-mono text-sm"
1598
+ @update:model-value="(v) => setManualField(idx, { path: String(v) })"
1599
+ />
1600
+ <Input
1601
+ :aria-label="t('integration.nodeInspector.trigger.manual.field.labelAria', { n: idx + 1 })"
1602
+ :model-value="mf.label ?? ''"
1603
+ :placeholder="t('integration.nodeInspector.trigger.manual.field.labelPlaceholder')"
1604
+ class="h-8 min-w-0 flex-1 rounded-md border border-black/[0.08] ring-0 shadow-none bg-white text-sm"
1605
+ @update:model-value="(v) => setManualField(idx, { label: String(v) })"
1606
+ />
1607
+ <button
1608
+ type="button"
1609
+ :aria-label="t('integration.nodeInspector.trigger.manual.field.removeAria', { n: idx + 1 })"
1610
+ class="flex size-7 shrink-0 items-center justify-center rounded text-muted-foreground transition hover:text-rose-600"
1611
+ @click="removeManualField(idx)"
1612
+ >
1613
+ <TrashBin :size="13" />
1614
+ </button>
1615
+ </div>
1616
+ </div>
1617
+ <Button type="button" variant="outline" size="sm" class="h-7 text-sm" @click="addManualField">
1618
+ <Plus :size="12" />
1619
+ {{ t("integration.nodeInspector.trigger.manual.action.addField") }}
1620
+ </Button>
893
1621
  </template>
894
1622
  </div>
895
1623
 
896
1624
  <!-- Parameters: node config, generated from the manifest -->
897
- <div v-else-if="tab === 'parameters' && draftNode" class="mx-auto w-full max-w-3xl">
1625
+ <div v-else-if="tab === 'parameters' && draftNode" class="mx-auto w-full max-w-5xl">
898
1626
  <div v-if="manifest?.config?.length" class="space-y-3">
899
1627
  <div class="flex items-center justify-between">
900
- <span class="text-mono text-muted-foreground">Configuration</span>
1628
+ <span class="text-mono text-muted-foreground">{{ t("integration.nodeInspector.parameters.heading") }}</span>
901
1629
  <div class="flex items-center gap-2">
902
- <span v-if="requiredMissingCount" class="text-sm text-amber-600">
903
- {{ requiredMissingCount }} required left
904
- </span>
905
1630
  <button
906
1631
  type="button"
907
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"
908
- title="Copy this node's config as JSON"
1633
+ :title="t('integration.nodeInspector.parameters.copyTitle')"
909
1634
  @click="copyConfig"
910
1635
  >
911
- <Copy :size="12" /> Copy
1636
+ <Copy :size="12" /> {{ t("integration.common.action.copy") }}
912
1637
  </button>
913
1638
  <button
914
1639
  type="button"
915
1640
  class="rounded px-1 py-0.5 text-sm text-muted-foreground transition hover:bg-muted/50 hover:text-foreground"
916
- title="Replace this node's config from a JSON object in the clipboard"
1641
+ :title="t('integration.nodeInspector.parameters.pasteTitle')"
917
1642
  @click="pasteConfig"
918
1643
  >
919
- Paste
1644
+ {{ t("integration.common.action.paste") }}
920
1645
  </button>
921
1646
  </div>
922
1647
  </div>
923
1648
  <IntegrationsNodeConfigField
924
- v-for="field in parameterFields"
1649
+ v-for="field in requiredFields"
925
1650
  :key="field.key"
926
1651
  :field="field"
927
1652
  :config="draftNode.config ?? {}"
@@ -932,22 +1657,74 @@ async function runTestStep() {
932
1657
  :depth="0"
933
1658
  :variables="variables"
934
1659
  :upstream-outputs="upstreamOutputs"
1660
+ :refused="refusedFields?.[field.key]"
935
1661
  @set="(k, v) => writeConfig(k, v)"
936
1662
  />
1663
+
1664
+ <!-- Optional settings, folded. The count is on the control on
1665
+ purpose: "Show 9 optional settings" is a decision the user can
1666
+ take without opening it, "Show more" is not. -->
1667
+ <template v-if="optionalFields.length">
1668
+ <button
1669
+ v-if="!optionalOpen"
1670
+ type="button"
1671
+ class="flex items-center gap-1.5 rounded px-1 py-1 text-sm text-muted-foreground transition hover:text-foreground"
1672
+ @click="optionalOpen = true"
1673
+ >
1674
+ <ChevronRight :size="13" />
1675
+ {{ t("integration.nodeInspector.parameters.optional.show", optionalFields.length) }}
1676
+ </button>
1677
+ <template v-else>
1678
+ <button
1679
+ v-if="requiredFields.length"
1680
+ type="button"
1681
+ class="flex items-center gap-1.5 rounded px-1 py-1 text-sm text-muted-foreground transition hover:text-foreground"
1682
+ @click="optionalOpen = false"
1683
+ >
1684
+ <ChevronDown :size="13" />
1685
+ {{ t("integration.nodeInspector.parameters.optional.hide", optionalFields.length) }}
1686
+ </button>
1687
+ <IntegrationsNodeConfigField
1688
+ v-for="field in optionalFields"
1689
+ :key="field.key"
1690
+ :field="field"
1691
+ :config="draftNode.config ?? {}"
1692
+ :slug="draftNode.nodeSlug"
1693
+ :version="draftNode.nodeVersion"
1694
+ :secret-keys="secretKeys"
1695
+ :static-keys="(manifest.config ?? []).map((f2) => f2.key)"
1696
+ :depth="0"
1697
+ :variables="variables"
1698
+ :upstream-outputs="upstreamOutputs"
1699
+ :refused="refusedFields?.[field.key]"
1700
+ @set="(k, v) => writeConfig(k, v)"
1701
+ />
1702
+ </template>
1703
+ </template>
1704
+
937
1705
  <p v-if="!parameterFields.length" class="text-sm text-muted-foreground italic">
938
- All of this node's options live on the Settings tab.
1706
+ {{ t("integration.nodeInspector.parameters.allOnSettingsTab") }}
939
1707
  </p>
940
1708
  </div>
941
1709
  <div v-else class="text-sm text-muted-foreground italic">
942
- This node has no configuration.
1710
+ {{ t("integration.nodeInspector.parameters.noConfiguration") }}
943
1711
  </div>
944
1712
  </div>
945
1713
 
946
1714
  <!-- Settings: node-instance execution settings -->
947
- <div v-else-if="tab === 'settings' && draftNode" class="mx-auto w-full max-w-3xl space-y-6">
948
- <!-- Node behavior: settings-like fields from the node's own config -->
1715
+ <div v-else-if="tab === 'settings' && draftNode" class="mx-auto w-full max-w-5xl space-y-6">
1716
+ <!-- The node's OWN options that happen to be about waiting or
1717
+ retrying. They read like engine settings and sit next to them,
1718
+ which is why both blocks say out loud which is which: a node's
1719
+ "Poll timeout" and the engine's "Timeout" are otherwise two
1720
+ identical-looking numbers with nothing to tell them apart. -->
949
1721
  <div v-if="settingsFields.length" class="space-y-3">
950
- <span class="text-mono text-muted-foreground">Node behavior</span>
1722
+ <div>
1723
+ <span class="text-mono text-muted-foreground">{{ t("integration.nodeInspector.settings.ownOptions.heading") }}</span>
1724
+ <p class="text-xs text-muted-foreground">
1725
+ {{ t("integration.nodeInspector.settings.ownOptions.hint") }}
1726
+ </p>
1727
+ </div>
951
1728
  <IntegrationsNodeConfigField
952
1729
  v-for="field in settingsFields"
953
1730
  :key="field.key"
@@ -960,56 +1737,72 @@ async function runTestStep() {
960
1737
  :depth="0"
961
1738
  :variables="variables"
962
1739
  :upstream-outputs="upstreamOutputs"
1740
+ :refused="refusedFields?.[field.key]"
963
1741
  @set="(k, v) => writeConfig(k, v)"
964
1742
  />
965
1743
  </div>
1744
+ <!-- The rule only earns its place when there is something above to
1745
+ divide from. -->
1746
+ <div :class="settingsFields.length ? 'border-t border-black/[0.06] pt-5' : ''">
1747
+ <span class="text-mono text-muted-foreground">{{ t("integration.nodeInspector.settings.engine.heading") }}</span>
1748
+ <p class="text-xs text-muted-foreground">
1749
+ {{ t("integration.nodeInspector.settings.engine.hint") }}
1750
+ </p>
1751
+ </div>
1752
+
966
1753
  <!-- Retry policy -->
967
- <section class="space-y-2">
968
- <div class="flex items-center justify-between rounded-lg border border-black/[0.06] bg-muted/20 px-3 py-2">
969
- <div>
970
- <div class="text-sm font-medium">Retry on failure</div>
971
- <p class="text-xs text-muted-foreground">
972
- Re-run this node automatically when an attempt fails.
973
- </p>
974
- </div>
1754
+ <!-- One card: the toggle heads it, the policy fields expand
1755
+ inside — they belong to the same schematic unit. -->
1756
+ <section class="rounded-lg border border-black/[0.06] bg-muted/20">
1757
+ <div class="flex items-center gap-3 px-3 py-2">
975
1758
  <Switch
1759
+ :aria-label="t('integration.nodeInspector.settings.retry.label')"
1760
+ class="data-[state=unchecked]:bg-slate-300"
976
1761
  :model-value="!!draftNode.retry"
977
1762
  @update:model-value="(v) => patchNode({ retry: v ? { maxAttempts: 3, backoffSeconds: 1, strategy: 'exponential' } : void 0 })"
978
1763
  />
1764
+ <div>
1765
+ <div class="text-sm font-medium">{{ t("integration.nodeInspector.settings.retry.label") }}</div>
1766
+ <p class="text-xs text-muted-foreground">
1767
+ {{ t("integration.nodeInspector.settings.retry.hint") }}
1768
+ </p>
1769
+ </div>
979
1770
  </div>
980
- <div v-if="draftNode.retry" class="grid grid-cols-2 gap-3 rounded-lg border border-black/[0.06] p-3">
1771
+ <div v-if="draftNode.retry" class="grid grid-cols-4 gap-3 border-t border-black/[0.06] p-3">
981
1772
  <div class="space-y-1.5">
982
- <Label 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>
983
1774
  <Input
1775
+ id="ni-retry-attempts"
984
1776
  type="number"
985
1777
  min="1"
986
1778
  :model-value="draftNode.retry?.maxAttempts ?? 3"
987
- class="h-8 text-sm bg-white"
1779
+ class="h-8 rounded-md border border-black/[0.08] ring-0 shadow-none text-sm bg-white"
988
1780
  @update:model-value="(v) => patchNode({ retry: { ...draftNode.retry, maxAttempts: Number(v) || 1 } })"
989
1781
  />
990
1782
  </div>
991
1783
  <div class="space-y-1.5">
992
- <Label 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>
993
1785
  <Input
1786
+ id="ni-retry-backoff"
994
1787
  type="number"
995
1788
  min="0"
996
1789
  :model-value="draftNode.retry?.backoffSeconds ?? 0"
997
- class="h-8 text-sm bg-white"
1790
+ class="h-8 rounded-md border border-black/[0.08] ring-0 shadow-none text-sm bg-white"
998
1791
  @update:model-value="(v) => patchNode({ retry: { ...draftNode.retry, backoffSeconds: Number(v) || 0 } })"
999
1792
  />
1000
1793
  </div>
1001
1794
  <div class="col-span-2 space-y-1.5">
1002
- <Label 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>
1003
1796
  <Select
1004
1797
  :model-value="draftNode.retry?.strategy ?? 'exponential'"
1005
1798
  @update:model-value="(v) => patchNode({ retry: { ...draftNode.retry, strategy: v } })"
1006
1799
  >
1007
- <SelectTrigger class="h-8 text-sm bg-white">
1800
+ <SelectTrigger id="ni-retry-strategy" size="sm" class="h-8 rounded-md border border-black/[0.08] ring-0 shadow-none text-sm bg-white">
1008
1801
  <SelectValue />
1009
1802
  </SelectTrigger>
1010
1803
  <SelectContent>
1011
- <SelectItem value="linear">linear backoff</SelectItem>
1012
- <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>
1013
1806
  </SelectContent>
1014
1807
  </Select>
1015
1808
  </div>
@@ -1018,207 +1811,195 @@ async function runTestStep() {
1018
1811
 
1019
1812
  <!-- Timeout: engine start-to-close ceiling for one attempt -->
1020
1813
  <section class="space-y-1.5">
1021
- <Label 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>
1022
1815
  <Input
1816
+ id="ni-timeout"
1023
1817
  type="number"
1024
1818
  min="1"
1025
1819
  max="7200"
1026
1820
  placeholder="600"
1027
1821
  :model-value="draftNode.timeoutSeconds ?? ''"
1028
- class="h-8 text-sm bg-white"
1822
+ class="h-8 rounded-md border border-black/[0.08] ring-0 shadow-none text-sm bg-white"
1029
1823
  @update:model-value="(v) => patchNode({ timeoutSeconds: Number(v) > 0 ? Math.min(Math.trunc(Number(v)), 7200) : void 0 })"
1030
1824
  />
1031
1825
  <p class="text-xs text-muted-foreground">
1032
- 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") }}
1033
1827
  </p>
1034
1828
  </section>
1035
1829
 
1036
1830
  <!-- On error: what happens once the retries are exhausted -->
1037
1831
  <section class="space-y-1.5">
1038
- <Label 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>
1039
1833
  <Select
1040
1834
  :model-value="draftNode.onError ?? 'fail'"
1041
1835
  @update:model-value="(v) => patchNode({ onError: v === 'route' ? 'route' : void 0 })"
1042
1836
  >
1043
- <SelectTrigger class="h-8 text-sm bg-white">
1837
+ <SelectTrigger id="ni-on-error" size="sm" class="h-8 rounded-md border border-black/[0.08] ring-0 shadow-none text-sm bg-white">
1044
1838
  <SelectValue />
1045
1839
  </SelectTrigger>
1046
1840
  <SelectContent>
1047
- <SelectItem value="fail">Fail the run</SelectItem>
1048
- <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>
1049
1843
  </SelectContent>
1050
1844
  </Select>
1051
1845
  <p class="text-xs text-muted-foreground">
1052
- Applies after the retry policy is exhausted. A Stop and Error node always fails the run.
1846
+ {{ t("integration.nodeInspector.settings.onError.hint") }}
1053
1847
  </p>
1054
1848
  </section>
1849
+ </div>
1055
1850
 
1056
- <!-- Notes: editor-only blob field -->
1057
- <section class="space-y-1.5">
1058
- <Label class="text-mono text-muted-foreground">Notes</Label>
1851
+ <!-- Test: run this one step and look at what came back. Its own tab
1852
+ rather than a permanent column — it is the last thing done, if it
1853
+ is done at all, and it needs the width when it is. -->
1854
+ <div v-else-if="tab === 'output' && draftNode" class="mx-auto w-full max-w-5xl space-y-5">
1855
+ <section class="space-y-2">
1856
+ <div class="flex items-center justify-between gap-2">
1857
+ <p class="text-xs text-muted-foreground">
1858
+ {{ t("integration.nodeInspector.output.description") }}
1859
+ </p>
1860
+ <div class="flex shrink-0 items-center rounded-md border border-black/[0.06] bg-white p-0.5">
1861
+ <button type="button" :class="outputView === 'schema' ? segActive : segIdle" @click="outputView = 'schema'">
1862
+ {{ t("integration.nodeInspector.output.view.schema") }}
1863
+ </button>
1864
+ <button type="button" :disabled="!selfSample" :class="outputView === 'json' ? segActive : segIdle" @click="outputView = 'json'">
1865
+ {{ t("integration.nodeInspector.output.view.json") }}
1866
+ </button>
1867
+ </div>
1868
+ </div>
1869
+ <template v-if="outputView === 'schema'">
1870
+ <IntegrationsNodeOutputSchema :ports="selfPorts" :value-at="(port, field2) => chipValue(draft.id, port, field2)" />
1871
+ </template>
1872
+ <pre v-else class="overflow-auto rounded-md border border-black/[0.06] bg-white p-3 font-mono text-xs leading-snug">{{ selfSampleJson }}</pre>
1873
+ </section>
1874
+
1875
+ <section class="space-y-2 border-t border-black/[0.06] pt-4">
1876
+ <span class="text-mono text-muted-foreground">{{ t("integration.nodeInspector.output.realValues.heading") }}</span>
1877
+ <div class="flex items-center gap-2">
1878
+ <Button
1879
+ size="sm"
1880
+ class="h-8"
1881
+ :disabled="testRunning"
1882
+ :title="t('integration.nodeInspector.output.test.runTitle')"
1883
+ @click="runTestStep"
1884
+ >
1885
+ {{ testRunning ? t("integration.nodeInspector.output.test.running") : t("integration.nodeInspector.output.test.action") }}
1886
+ </Button>
1887
+ <button
1888
+ type="button"
1889
+ class="rounded px-1 py-1 text-sm font-medium text-muted-foreground transition hover:text-foreground"
1890
+ @click="toggleTestInput"
1891
+ >
1892
+ {{ testInputOpen ? t("integration.nodeInspector.output.test.hideInput") : t("integration.nodeInspector.output.test.editInput") }}
1893
+ </button>
1894
+ <span class="text-xs text-muted-foreground">
1895
+ {{ t("integration.nodeInspector.output.test.runHint") }}
1896
+ </span>
1897
+ </div>
1059
1898
  <Textarea
1060
- :model-value="draftNode.notes ?? ''"
1061
- :rows="3"
1062
- placeholder="Optional note about this node"
1063
- class="text-sm bg-white"
1064
- @update:model-value="(v) => patchNode({ notes: String(v) || void 0 })"
1899
+ v-if="testInputOpen"
1900
+ v-model="testInputDraft"
1901
+ :aria-label="t('integration.nodeInspector.output.test.inputAria')"
1902
+ :rows="8"
1903
+ spellcheck="false"
1904
+ class="rounded-md border border-black/[0.08] ring-0 shadow-none bg-white font-mono text-xs"
1065
1905
  />
1066
- <p class="text-xs text-muted-foreground">
1067
- Only shown in the editor; never sent to the engine.
1906
+ <p v-if="testError" class="text-sm text-rose-600">
1907
+ {{ testError }}
1068
1908
  </p>
1909
+ <div v-if="testLogs.length" class="max-h-48 space-y-0.5 overflow-y-auto rounded-md border border-black/[0.06] bg-white p-2">
1910
+ <div
1911
+ v-for="(l, i) in testLogs"
1912
+ :key="i"
1913
+ class="font-mono text-[11px] leading-snug"
1914
+ :class="l.level === 'error' ? 'text-rose-600' : l.level === 'warn' ? 'text-amber-600' : 'text-slate-600'"
1915
+ >
1916
+ {{ l.message }}
1917
+ </div>
1918
+ </div>
1069
1919
  </section>
1070
- </div>
1071
- </div>
1072
- </section>
1073
1920
 
1074
- <!-- Output pane: what this node hands downstream -->
1075
- <aside v-if="isNode" class="flex w-[280px] shrink-0 flex-col border-l border-black/[0.06] bg-muted/10">
1076
- <div class="shrink-0 border-b border-black/[0.06] px-3 py-2.5">
1077
- <div class="flex items-center justify-between gap-2">
1078
- <div class="text-sm font-semibold text-foreground">
1079
- Output
1080
- </div>
1081
- <div class="flex items-center rounded-md border border-black/[0.06] bg-white p-0.5">
1082
- <button type="button" :class="outputView === 'schema' ? segActive : segIdle" @click="outputView = 'schema'">
1083
- Schema
1084
- </button>
1085
- <button type="button" :disabled="!selfSample" :class="outputView === 'json' ? segActive : segIdle" @click="outputView = 'json'">
1086
- JSON
1087
- </button>
1088
- </div>
1089
- </div>
1090
- <p class="mt-0.5 text-xs leading-snug text-muted-foreground">
1091
- What downstream nodes receive from this one.
1092
- </p>
1093
- </div>
1094
- <div class="shrink-0 space-y-1.5 border-b border-black/[0.06] px-3 py-2">
1095
- <div class="flex items-center gap-2">
1096
- <Button
1097
- size="sm"
1098
- class="h-7 flex-1 text-xs"
1099
- :disabled="testRunning"
1100
- title="Runs this node once with the current draft config"
1101
- @click="runTestStep"
1102
- >
1103
- {{ testRunning ? "Testing\u2026" : "Test step" }}
1104
- </Button>
1105
- <button
1106
- type="button"
1107
- class="shrink-0 text-[11px] font-medium text-muted-foreground transition hover:text-foreground"
1108
- @click="toggleTestInput"
1109
- >
1110
- {{ testInputOpen ? "Hide input" : "Edit input" }}
1111
- </button>
1112
- </div>
1113
- <Textarea
1114
- v-if="testInputOpen"
1115
- v-model="testInputDraft"
1116
- :rows="5"
1117
- spellcheck="false"
1118
- class="bg-white font-mono text-[11px]"
1119
- />
1120
- <p v-if="testError" class="text-[11px] leading-snug text-rose-600">
1121
- {{ testError }}
1122
- </p>
1123
- <div v-if="testLogs.length" class="max-h-24 space-y-0.5 overflow-y-auto rounded-md border border-black/[0.06] bg-white p-1.5">
1124
- <div
1125
- v-for="(l, i) in testLogs"
1126
- :key="i"
1127
- class="font-mono text-[10px] leading-snug"
1128
- :class="l.level === 'error' ? 'text-rose-600' : l.level === 'warn' ? 'text-amber-600' : 'text-slate-600'"
1129
- >
1130
- {{ l.message }}
1131
- </div>
1132
- </div>
1133
- </div>
1134
- <div class="min-h-0 flex-1 overflow-y-auto px-3 py-3">
1135
- <template v-if="outputView === 'schema'">
1136
- <div v-for="p in selfPorts" :key="p.name" class="mb-3 space-y-1">
1137
- <div class="flex items-center gap-1.5">
1138
- <span class="size-1.5 rounded-full" :class="portDotClass(p.kind, p.name)" />
1139
- <span class="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">{{ p.name }}</span>
1140
- </div>
1141
- <div v-if="p.fields.length" class="space-y-0.5">
1142
- <div v-for="f in p.fields" :key="f.name" class="flex min-w-0 items-baseline gap-1 font-mono text-[11px]">
1143
- <span v-if="typeGlyph(f.dataType)" class="shrink-0 font-semibold" :class="typeGlyph(f.dataType).cls">{{ typeGlyph(f.dataType).char }}</span>
1144
- <span class="shrink-0 text-slate-700">{{ f.name }}</span>
1145
- <span v-if="chipValue(draft.id, p.name, f.name) !== null" class="min-w-0 truncate text-slate-400">: {{ chipValue(draft.id, p.name, f.name) }}</span>
1921
+ <section class="space-y-2 border-t border-black/[0.06] pt-4">
1922
+ <template v-if="!pinEditorOpen">
1923
+ <div class="flex items-center gap-2">
1924
+ <Button variant="outline" size="sm" class="h-8" @click="openPinEditor">
1925
+ {{ selfPinned ? t("integration.nodeInspector.output.pin.editAction") : t("integration.nodeInspector.output.pin.pinAction") }}
1926
+ </Button>
1927
+ <button
1928
+ v-if="selfPinned"
1929
+ type="button"
1930
+ class="text-sm font-medium text-rose-600 transition hover:text-rose-700"
1931
+ @click="removePin"
1932
+ >
1933
+ {{ t("integration.nodeInspector.output.pin.unpinAction") }}
1934
+ </button>
1935
+ <span class="text-xs text-muted-foreground">
1936
+ {{ t("integration.nodeInspector.output.pin.hint") }}
1937
+ </span>
1146
1938
  </div>
1147
- </div>
1148
- <p v-else class="text-[11px] leading-snug text-muted-foreground">
1149
- No declared fields<template v-if="chipValue(draft.id, p.name) !== null">; sampled: <span class="font-mono">{{ chipValue(draft.id, p.name) }}</span></template>.
1150
- </p>
1151
- </div>
1152
- <p v-if="!selfPorts.length" class="text-xs leading-snug text-muted-foreground">
1153
- This node declares no outputs.
1154
- </p>
1155
- </template>
1156
- <pre v-else class="overflow-auto rounded-md border border-black/[0.06] bg-white p-2 font-mono text-[11px] leading-snug">{{ selfSampleJson }}</pre>
1157
- </div>
1158
- <div class="shrink-0 border-t border-black/[0.06] px-3 py-2">
1159
- <template v-if="!pinEditorOpen">
1160
- <div class="flex items-center gap-2">
1161
- <button
1162
- type="button"
1163
- 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"
1164
- @click="openPinEditor"
1165
- >
1166
- {{ selfPinned ? "Edit pinned sample" : "Pin sample output" }}
1167
- </button>
1168
- <button
1169
- v-if="selfPinned"
1170
- type="button"
1171
- class="text-[11px] font-medium text-rose-600 transition hover:text-rose-700"
1172
- @click="removePin"
1173
- >
1174
- Unpin
1175
- </button>
1176
- </div>
1177
- <p class="mt-1 text-[11px] leading-snug text-muted-foreground">
1178
- A pinned sample stands in for run data while you map downstream fields.
1179
- </p>
1180
- </template>
1181
- <template v-else>
1182
- <Textarea
1183
- v-model="pinDraft"
1184
- :rows="8"
1185
- spellcheck="false"
1186
- class="bg-white font-mono text-[11px]"
1187
- />
1188
- <p v-if="pinError" class="mt-1 text-[11px] text-amber-600">
1189
- {{ pinError }}
1190
- </p>
1191
- <div class="mt-1.5 flex items-center gap-2">
1192
- <Button size="sm" class="h-7 text-xs" @click="savePin">
1193
- Pin
1194
- </Button>
1195
- <Button size="sm" variant="outline" class="h-7 text-xs" @click="pinEditorOpen = false">
1196
- Cancel
1197
- </Button>
1198
- </div>
1199
- </template>
1939
+ </template>
1940
+ <template v-else>
1941
+ <Textarea
1942
+ v-model="pinDraft"
1943
+ :aria-label="t('integration.nodeInspector.output.pin.editorAria')"
1944
+ :rows="10"
1945
+ spellcheck="false"
1946
+ class="rounded-md border border-black/[0.08] ring-0 shadow-none bg-white font-mono text-xs"
1947
+ />
1948
+ <p v-if="pinError" class="text-sm text-amber-600">
1949
+ {{ pinError }}
1950
+ </p>
1951
+ <div class="flex items-center gap-2">
1952
+ <Button size="sm" class="h-8" @click="savePin">
1953
+ {{ t("integration.nodeInspector.output.pin.saveAction") }}
1954
+ </Button>
1955
+ <Button size="sm" variant="outline" class="h-8" @click="pinEditorOpen = false">
1956
+ {{ t("integration.common.action.cancel") }}
1957
+ </Button>
1958
+ </div>
1959
+ </template>
1960
+ </section>
1961
+ </div>
1200
1962
  </div>
1201
- </aside>
1963
+ </section>
1202
1964
  </div>
1203
1965
 
1204
1966
  <!-- Footer: draft semantics — nothing reaches the blob until Save -->
1205
1967
  <div class="flex shrink-0 items-center justify-between gap-3 border-t border-black/[0.06] px-4 py-3">
1968
+ <!-- Quiet until reached for: a bare glyph that unfolds into its name and
1969
+ its colour on hover or focus.
1970
+
1971
+ It is the rarest of the four ways to delete a node — the canvas
1972
+ offers the card's toolbar, the context menu and the Delete key — and
1973
+ it sits in the row the user reads to decide what happens to their
1974
+ edits, where a third, louder option reads as one of the answers.
1975
+ Rose carries a meaning now, too: a setting the last save refused.
1976
+ Spending it on a footer button weakens that.
1977
+
1978
+ The name is on the button itself, not only in the revealed span, so
1979
+ it reaches a screen reader without a pointer. Revealing on focus as
1980
+ well as hover is what keeps it usable from the keyboard. The button
1981
+ grows leftmost into empty space, so nothing else moves. -->
1206
1982
  <Button
1207
1983
  variant="ghost"
1208
1984
  size="sm"
1209
- class="h-8 text-sm text-rose-600 hover:bg-rose-500/10 hover:text-rose-700"
1985
+ :aria-label="t('integration.nodeInspector.footer.removeFromWorkflow')"
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"
1210
1987
  @click="emit('remove')"
1211
1988
  >
1212
- <Trash2 :size="13" />
1213
- Remove from workflow
1989
+ <TrashBin :size="13" />
1990
+ <span
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"
1992
+ >
1993
+ {{ t("integration.nodeInspector.footer.removeFromWorkflow") }}
1994
+ </span>
1214
1995
  </Button>
1215
1996
  <div class="flex items-center gap-2">
1216
- <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>
1217
1998
  <Button variant="outline" size="sm" class="h-8" @click="emit('close')">
1218
- Cancel
1999
+ {{ t("integration.common.action.cancel") }}
1219
2000
  </Button>
1220
2001
  <Button size="sm" class="h-8" @click="save">
1221
- Save
2002
+ {{ t("integration.common.action.save") }}
1222
2003
  </Button>
1223
2004
  </div>
1224
2005
  </div>