@revenexx/studio-integrations 0.5.1 → 0.6.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 (129) hide show
  1. package/CHANGELOG.md +201 -0
  2. package/README.md +17 -4
  3. package/dist/module.json +1 -1
  4. package/dist/runtime/components/Integrations/CredentialCreateDialog.vue +15 -14
  5. package/dist/runtime/components/Integrations/CredentialDetailDialog.vue +91 -19
  6. package/dist/runtime/components/Integrations/CredentialRevenexxSource.vue +58 -21
  7. package/dist/runtime/components/Integrations/CredentialTypePicker.vue +4 -8
  8. package/dist/runtime/components/Integrations/EventTypeCombobox.d.vue.ts +1 -0
  9. package/dist/runtime/components/Integrations/EventTypeCombobox.vue +4 -2
  10. package/dist/runtime/components/Integrations/EventTypeCombobox.vue.d.ts +1 -0
  11. package/dist/runtime/components/Integrations/ExpressionInput.d.vue.ts +4 -0
  12. package/dist/runtime/components/Integrations/ExpressionInput.vue +54 -25
  13. package/dist/runtime/components/Integrations/ExpressionInput.vue.d.ts +4 -0
  14. package/dist/runtime/components/Integrations/GroupFrameView.vue +12 -12
  15. package/dist/runtime/components/Integrations/Layout.vue +21 -2
  16. package/dist/runtime/components/Integrations/NodeConfigDynamicField.vue +10 -11
  17. package/dist/runtime/components/Integrations/NodeConfigDynamicSchema.vue +2 -5
  18. package/dist/runtime/components/Integrations/NodeConfigField.d.vue.ts +2 -0
  19. package/dist/runtime/components/Integrations/NodeConfigField.vue +160 -67
  20. package/dist/runtime/components/Integrations/NodeConfigField.vue.d.ts +2 -0
  21. package/dist/runtime/components/Integrations/NodeInspector.d.vue.ts +6 -0
  22. package/dist/runtime/components/Integrations/NodeInspector.vue +1075 -303
  23. package/dist/runtime/components/Integrations/NodeInspector.vue.d.ts +6 -0
  24. package/dist/runtime/components/Integrations/NodeLogo.d.vue.ts +12 -0
  25. package/dist/runtime/components/Integrations/NodeLogo.vue +2 -30
  26. package/dist/runtime/components/Integrations/NodeLogo.vue.d.ts +12 -0
  27. package/dist/runtime/components/Integrations/NodeMark.d.vue.ts +15 -0
  28. package/dist/runtime/components/Integrations/NodeMark.vue +79 -0
  29. package/dist/runtime/components/Integrations/NodeMark.vue.d.ts +15 -0
  30. package/dist/runtime/components/Integrations/NodeOutputSchema.d.vue.ts +21 -0
  31. package/dist/runtime/components/Integrations/NodeOutputSchema.vue +42 -0
  32. package/dist/runtime/components/Integrations/NodeOutputSchema.vue.d.ts +21 -0
  33. package/dist/runtime/components/Integrations/NodePalette.vue +100 -34
  34. package/dist/runtime/components/Integrations/QueryBuilder.vue +8 -1
  35. package/dist/runtime/components/Integrations/RunActionsMenu.vue +3 -1
  36. package/dist/runtime/components/Integrations/RunPayloadInput.vue +17 -4
  37. package/dist/runtime/components/Integrations/StickyNoteView.vue +2 -1
  38. package/dist/runtime/components/Integrations/TemplateCard.d.vue.ts +18 -0
  39. package/dist/runtime/components/Integrations/TemplateCard.vue +109 -0
  40. package/dist/runtime/components/Integrations/TemplateCard.vue.d.ts +18 -0
  41. package/dist/runtime/components/Integrations/TemplateFlowPreview.d.vue.ts +2 -2
  42. package/dist/runtime/components/Integrations/TemplateFlowPreview.vue +7 -10
  43. package/dist/runtime/components/Integrations/TemplateFlowPreview.vue.d.ts +2 -2
  44. package/dist/runtime/components/Integrations/WorkflowCreateDialog.vue +21 -37
  45. package/dist/runtime/components/Integrations/WorkflowEdgeView.d.vue.ts +18 -4
  46. package/dist/runtime/components/Integrations/WorkflowEdgeView.vue +23 -7
  47. package/dist/runtime/components/Integrations/WorkflowEdgeView.vue.d.ts +18 -4
  48. package/dist/runtime/components/Integrations/WorkflowEditor.d.vue.ts +20 -1
  49. package/dist/runtime/components/Integrations/WorkflowEditor.vue +167 -46
  50. package/dist/runtime/components/Integrations/WorkflowEditor.vue.d.ts +20 -1
  51. package/dist/runtime/components/Integrations/WorkflowHistoryPanel.vue +4 -20
  52. package/dist/runtime/components/Integrations/WorkflowIconPicker.d.vue.ts +19 -1
  53. package/dist/runtime/components/Integrations/WorkflowIconPicker.vue +11 -2
  54. package/dist/runtime/components/Integrations/WorkflowIconPicker.vue.d.ts +19 -1
  55. package/dist/runtime/components/Integrations/WorkflowNodeView.d.vue.ts +4 -0
  56. package/dist/runtime/components/Integrations/WorkflowNodeView.vue +189 -46
  57. package/dist/runtime/components/Integrations/WorkflowNodeView.vue.d.ts +4 -0
  58. package/dist/runtime/components/Integrations/WorkflowRunReplayPanel.vue +4 -20
  59. package/dist/runtime/components/Integrations/WorkflowSettingsDialog.vue +15 -9
  60. package/dist/runtime/components/Integrations/WorkflowTagsInput.d.vue.ts +1 -0
  61. package/dist/runtime/components/Integrations/WorkflowTagsInput.vue +7 -2
  62. package/dist/runtime/components/Integrations/WorkflowTagsInput.vue.d.ts +1 -0
  63. package/dist/runtime/components/Integrations/WorkflowTriggerView.vue +4 -2
  64. package/dist/runtime/components/Integrations/WorkflowVariablesDialog.vue +30 -17
  65. package/dist/runtime/components/Integrations/WorkflowsTable.vue +47 -7
  66. package/dist/runtime/composables/useCredentialTypes.js +4 -4
  67. package/dist/runtime/composables/useIntegrationsAudit.js +4 -3
  68. package/dist/runtime/composables/useIntegrationsCredentials.js +4 -4
  69. package/dist/runtime/composables/useIntegrationsDeadLetters.js +7 -6
  70. package/dist/runtime/composables/useIntegrationsNodes.js +4 -4
  71. package/dist/runtime/composables/useIntegrationsRuns.js +11 -6
  72. package/dist/runtime/composables/useIntegrationsSchedules.d.ts +4 -0
  73. package/dist/runtime/composables/useIntegrationsSchedules.js +10 -3
  74. package/dist/runtime/composables/useIntegrationsSecrets.js +10 -7
  75. package/dist/runtime/composables/useIntegrationsTemplates.d.ts +1 -1
  76. package/dist/runtime/composables/useIntegrationsTemplates.js +12 -8
  77. package/dist/runtime/composables/useIntegrationsTriggers.js +6 -6
  78. package/dist/runtime/composables/useIntegrationsWorkflows.js +8 -4
  79. package/dist/runtime/composables/useLoadGate.d.ts +17 -0
  80. package/dist/runtime/composables/useLoadGate.js +8 -0
  81. package/dist/runtime/data/branchReorder.d.ts +37 -0
  82. package/dist/runtime/data/branchReorder.js +71 -0
  83. package/dist/runtime/data/handleId.d.ts +23 -0
  84. package/dist/runtime/data/handleId.js +6 -0
  85. package/dist/runtime/data/integrationTemplates.d.ts +39 -0
  86. package/dist/runtime/data/integrationTemplates.js +15 -0
  87. package/dist/runtime/data/integrationWorkflowSamples.js +1 -1
  88. package/dist/runtime/data/nodeIcons.d.ts +2 -0
  89. package/dist/runtime/data/nodeIcons.js +73 -0
  90. package/dist/runtime/data/nodeInputData.d.ts +16 -4
  91. package/dist/runtime/data/nodeInputData.js +4 -0
  92. package/dist/runtime/data/payloadForm.d.ts +9 -2
  93. package/dist/runtime/data/payloadForm.js +27 -3
  94. package/dist/runtime/data/portTone.d.ts +17 -0
  95. package/dist/runtime/data/portTone.js +7 -0
  96. package/dist/runtime/data/webhookAuth.d.ts +18 -0
  97. package/dist/runtime/data/webhookAuth.js +19 -0
  98. package/dist/runtime/data/workflowIconCatalog.js +2 -5
  99. package/dist/runtime/pages/integrations/audit.vue +91 -6
  100. package/dist/runtime/pages/integrations/credentials/oauth-complete.vue +6 -4
  101. package/dist/runtime/pages/integrations/credentials.vue +119 -21
  102. package/dist/runtime/pages/integrations/index.vue +49 -44
  103. package/dist/runtime/pages/integrations/runs.vue +1 -1
  104. package/dist/runtime/pages/integrations/schedules.vue +51 -7
  105. package/dist/runtime/pages/integrations/secrets.vue +29 -7
  106. package/dist/runtime/pages/integrations/templates/[slug].vue +52 -30
  107. package/dist/runtime/pages/integrations/templates/index.vue +57 -86
  108. package/dist/runtime/pages/integrations/workflows/[id].vue +196 -8
  109. package/dist/runtime/pages/integrations/workflows/index.vue +15 -2
  110. package/dist/runtime/types/integrations.d.ts +65 -6
  111. package/dist/runtime/utils/apiError.d.ts +47 -0
  112. package/dist/runtime/utils/apiError.js +32 -0
  113. package/dist/runtime/utils/cockpitSources.d.ts +153 -0
  114. package/dist/runtime/utils/cockpitSources.js +14 -0
  115. package/dist/runtime/utils/loadGate.d.ts +61 -0
  116. package/dist/runtime/utils/loadGate.js +22 -0
  117. package/dist/runtime/utils/localize.d.ts +29 -0
  118. package/dist/runtime/utils/localize.js +4 -0
  119. package/dist/runtime/utils/notify.d.ts +59 -0
  120. package/dist/runtime/utils/notify.js +21 -0
  121. package/dist/runtime/utils/revenexxCredentialSource.d.ts +26 -0
  122. package/dist/runtime/utils/revenexxCredentialSource.js +8 -0
  123. package/dist/runtime/utils/schedule.d.ts +141 -0
  124. package/dist/runtime/utils/schedule.js +207 -0
  125. package/dist/runtime/utils/validationFindings.d.ts +101 -0
  126. package/dist/runtime/utils/validationFindings.js +53 -0
  127. package/dist/runtime/utils/whenIdle.d.ts +42 -0
  128. package/dist/runtime/utils/whenIdle.js +12 -0
  129. package/package.json +8 -2
@@ -5,6 +5,10 @@ interface NodeData extends WorkflowNode {
5
5
  * editor without persisting to the blob. When present and non-empty, these
6
6
  * drive the rendered handles instead of the static manifest computation. */
7
7
  __resolvedOutputs?: NodeManifestOutput[];
8
+ /** Config keys the backend reported as incomplete at the last save (PO-209),
9
+ * injected by the editor the same non-persisting way. Read-only canvases
10
+ * (run replay, history preview) never pass them, so nothing is marked there. */
11
+ __invalidFields?: string[];
8
12
  }
9
13
  type __VLS_Props = {
10
14
  id: string;
@@ -1,9 +1,11 @@
1
1
  <script setup>
2
- import { computed, inject, ref, watch } from "vue";
2
+ import { computed, inject, nextTick, ref, watch } from "vue";
3
3
  import { Handle, Position, useVueFlow } from "@vue-flow/core";
4
4
  import { NodeToolbar } from "@vue-flow/node-toolbar";
5
5
  import * as Lucide from "lucide-vue-next";
6
6
  import { NODE_CATEGORIES, findManifest } from "../../data/integrationNodeCatalog";
7
+ import { isTerminalManifest } from "../../data/nodeInputData";
8
+ import { resolveDisplayName, resolveLocalized } from "../../utils/localize";
7
9
  const props = defineProps({
8
10
  id: { type: String, required: true },
9
11
  data: { type: Object, required: true },
@@ -13,42 +15,28 @@ defineOptions({ inheritAttrs: false });
13
15
  defineEmits(["updateNodeInternals"]);
14
16
  const manifest = computed(() => findManifest(props.data.nodeSlug, props.data.nodeVersion));
15
17
  const category = computed(() => NODE_CATEGORIES.find((c) => c.key === manifest.value?.category));
16
- const hasLogo = computed(() => (manifest.value?.images ?? []).some((i) => i.category === "logo"));
17
- function iconFor(name) {
18
- if (!name) return Lucide.Box;
19
- const cleaned = (name.includes(":") ? name.split(":").pop() : name).split("-").map((s) => s.charAt(0).toUpperCase() + s.slice(1)).join("");
20
- return Lucide[cleaned] || Lucide.Box;
21
- }
22
- function labelText(value) {
23
- if (!value) return "";
24
- return typeof value === "string" ? value : value.en ?? Object.values(value)[0] ?? "";
25
- }
26
18
  const title = computed(
27
- () => labelText(props.data.name) || labelText(manifest.value?.name) || props.data.nodeSlug
19
+ () => resolveDisplayName(props.data.name) || resolveLocalized(manifest.value?.name) || props.data.nodeSlug
28
20
  );
29
21
  const inputRequired = computed(() => manifest.value?.input?.required !== false);
30
- const subtitleKeys = computed(() => {
31
- const fields = manifest.value?.config ?? [];
32
- const driving = /* @__PURE__ */ new Set();
33
- for (const f of fields) for (const dep of f.dependsOn ?? []) driving.add(dep);
34
- const drivingSelects = fields.filter((f) => f.type === "select" && driving.has(f.key)).map((f) => f.key);
35
- if (drivingSelects.length) return drivingSelects;
36
- return fields.filter((f) => f.type === "select" || f.type === "multiselect").slice(0, 2).map((f) => f.key);
22
+ const invalidFields = computed(() => props.data.__invalidFields ?? []);
23
+ const invalidLabel = computed(() => {
24
+ const n = invalidFields.value.length;
25
+ return `Config incomplete \u2014 ${n} setting${n === 1 ? "" : "s"} still need${n === 1 ? "s" : ""} fixing: ${invalidFields.value.join(", ")}`;
37
26
  });
38
- function scalarText(v) {
39
- if (Array.isArray(v)) {
40
- return v.filter((x) => ["string", "number", "boolean"].includes(typeof x)).map((x) => String(x).trim()).filter(Boolean).join(", ");
41
- }
42
- return ["string", "number", "boolean"].includes(typeof v) ? String(v).trim() : "";
43
- }
44
- const subtitle = computed(
45
- () => subtitleKeys.value.map((k) => scalarText(props.data.config?.[k])).filter((s) => s !== "").join(" \xB7 ")
46
- );
27
+ const notes = computed(() => (props.data.notes ?? "").trim());
28
+ const noteOpen = ref(false);
29
+ const noteReady = ref(false);
30
+ const notePanel = ref(null);
31
+ const notePlacement = ref(Position.Bottom);
32
+ const noteMaxHeight = ref(0);
33
+ let noteTimer = null;
34
+ const notePanelId = computed(() => `node-note-${props.id}`);
47
35
  function portsFromOutputs(list) {
48
36
  const ports = [];
49
37
  for (const o of list) {
50
38
  if (o.name) {
51
- ports.push({ name: o.name, kind: o.kind, label: labelText(o.label) || o.name });
39
+ ports.push({ name: o.name, kind: o.kind, label: resolveLocalized(o.label) || o.name });
52
40
  } else if (o.sourceFromConfig) {
53
41
  const raw = props.data.config?.[o.sourceFromConfig];
54
42
  const values = Array.isArray(raw) ? raw : [];
@@ -60,7 +48,7 @@ function portsFromOutputs(list) {
60
48
  ports.push({
61
49
  name: o.fallback.name,
62
50
  kind: o.kind,
63
- label: labelText(o.fallback.label) || o.fallback.name
51
+ label: resolveLocalized(o.fallback.label) || o.fallback.name
64
52
  });
65
53
  }
66
54
  }
@@ -69,24 +57,48 @@ function portsFromOutputs(list) {
69
57
  }
70
58
  const resolvePending = computed(() => (manifest.value?.outputs ?? []).some((o) => o.resolveOutputs === true) && !Array.isArray(props.data.__resolvedOutputs));
71
59
  const outputs = computed(() => {
60
+ const m = manifest.value;
61
+ if (isTerminalManifest(m)) return [];
72
62
  const resolved = props.data.__resolvedOutputs;
73
63
  if (Array.isArray(resolved) && resolved.length) {
74
64
  const ports2 = portsFromOutputs(resolved);
75
65
  if (!ports2.length) ports2.push({ name: "default", kind: "default", label: "out" });
76
66
  return ports2;
77
67
  }
78
- const m = manifest.value;
79
68
  if (!m) return [{ name: "default", kind: "default", label: "out" }];
80
69
  const ports = portsFromOutputs(m.outputs ?? []);
81
70
  if (!ports.length) ports.push({ name: "default", kind: "default", label: "out" });
82
71
  return ports;
83
72
  });
73
+ function portSemantic(p) {
74
+ if (p.name === "true") return "branch-true";
75
+ if (p.name === "false") return "branch-false";
76
+ if (p.name === "error" || p.kind === "error") return "error";
77
+ if (p.name === "done") return "success";
78
+ if (p.kind === "branch") return "branch";
79
+ const portDef = (manifest.value?.outputs ?? []).find((o) => o.name === p.name);
80
+ return portDef ? "success" : "default";
81
+ }
84
82
  function portTone(p) {
85
- if (p.name === "true") return { handle: "!bg-emerald-500", label: "text-emerald-600" };
86
- if (p.name === "false") return { handle: "!bg-amber-400", label: "text-amber-600" };
87
- if (p.kind === "error") return { handle: "!bg-rose-500", label: "text-rose-600" };
88
- if (p.kind === "branch") return { handle: "!bg-violet-500", label: "text-violet-600" };
89
- return { handle: "!bg-slate-500", label: "text-slate-500" };
83
+ switch (portSemantic(p)) {
84
+ case "success":
85
+ case "branch-true":
86
+ // Switch cases / iterator `item`: a taken branch is a normal continuation,
87
+ // so it reads green like any other output rather than a special colour.
88
+ case "branch":
89
+ return { handle: "!bg-emerald-500", label: "text-emerald-600" };
90
+ // `false` is the neutral "didn't match" route, not a warning — muted slate
91
+ // so it recedes behind the green primary path (matches `default`).
92
+ case "branch-false":
93
+ return { handle: "!bg-slate-500", label: "text-slate-500" };
94
+ // Soft rose dot marks the error port; the LABEL stays neutral slate (not
95
+ // rose) so it's legible without a red-alert pull that would out-shout the
96
+ // green success label. Colour lives on the dot, the label just names it.
97
+ case "error":
98
+ return { handle: "!bg-rose-300", label: "text-slate-500" };
99
+ default:
100
+ return { handle: "!bg-slate-500", label: "text-slate-500" };
101
+ }
90
102
  }
91
103
  const showPortLabels = computed(() => resolvePending.value || outputs.value.length > 1 || outputs.value.some((p) => p.kind !== "default"));
92
104
  const IN_SIDES = ["top"];
@@ -112,23 +124,67 @@ function onHoverLeave() {
112
124
  hovered.value = false;
113
125
  }, 200);
114
126
  }
115
- watch(subtitle, () => flow.updateNodeInternals([props.id]));
127
+ watch(() => Boolean(props.data.nodeVersion), () => flow.updateNodeInternals([props.id]));
128
+ const NOTE_GAP = 6;
129
+ const NOTE_TOOLBAR_CLEARANCE = 42;
130
+ const canvasBottomInset = inject("workflow-canvas-bottom-inset", null);
131
+ const noteTopOffset = ref(NOTE_GAP);
132
+ async function openNote(ev) {
133
+ holdNote();
134
+ if (noteOpen.value) return;
135
+ const card = ev.currentTarget?.closest(".vue-flow__node");
136
+ noteTopOffset.value = nodeActions && (hovered.value || props.selected) ? NOTE_TOOLBAR_CLEARANCE : NOTE_GAP;
137
+ noteOpen.value = true;
138
+ noteReady.value = false;
139
+ await nextTick();
140
+ const bounds = flow.vueFlowRef.value?.getBoundingClientRect();
141
+ if (notePanel.value && card && bounds) {
142
+ const rect = card.getBoundingClientRect();
143
+ const floor = Math.min(bounds.bottom, window.innerHeight) - (canvasBottomInset?.() ?? 0);
144
+ const ceiling = Math.max(bounds.top, 0);
145
+ const below = floor - rect.bottom - NOTE_GAP;
146
+ const above = rect.top - ceiling - noteTopOffset.value;
147
+ const flip = notePanel.value.offsetHeight > below && above > below;
148
+ notePlacement.value = flip ? Position.Top : Position.Bottom;
149
+ noteMaxHeight.value = Math.max(80, Math.floor(flip ? above : below));
150
+ }
151
+ noteReady.value = true;
152
+ }
153
+ function holdNote() {
154
+ if (noteTimer) clearTimeout(noteTimer);
155
+ noteTimer = null;
156
+ }
157
+ function releaseNote() {
158
+ if (noteTimer) clearTimeout(noteTimer);
159
+ noteTimer = setTimeout(() => {
160
+ noteOpen.value = false;
161
+ }, 250);
162
+ }
116
163
  const nodeActions = inject("workflow-node-actions", null);
164
+ const canConfigure = computed(() => typeof nodeActions?.configure === "function");
165
+ function configure() {
166
+ nodeActions?.configure?.(props.id);
167
+ }
117
168
  function duplicate() {
118
169
  nodeActions?.duplicate(props.id);
119
170
  }
120
171
  function remove() {
121
- if (nodeActions) nodeActions.remove(props.id);
122
- else flow.removeNodes([props.id]);
172
+ nodeActions?.remove(props.id);
123
173
  }
124
174
  </script>
125
175
 
126
176
  <template>
127
- <NodeToolbar :is-visible="hovered || selected" :position="Position.Top" :offset="8" class="flex items-center gap-1 rounded-md border border-black/[0.08] bg-white px-1 py-1 shadow-md" @mouseenter="onHoverEnter" @mouseleave="onHoverLeave">
177
+ <!-- No node-actions injected → read-only canvas (e.g. run replay): the
178
+ duplicate/delete affordance would be dead, so don't offer it. -->
179
+ <!-- Icon-only, so each action carries a name — and it names the node, not
180
+ just the verb: the editor header has its own Duplicate (the workflow),
181
+ and "Duplicate" alone would be the same announcement for both. -->
182
+ <NodeToolbar v-if="nodeActions" :is-visible="hovered || selected" :position="Position.Top" :offset="8" class="flex items-center gap-1 rounded-md border border-black/[0.08] bg-white px-1 py-1 shadow-md" @mouseenter="onHoverEnter" @mouseleave="onHoverLeave">
128
183
  <button
129
184
  type="button"
130
185
  class="flex h-6 items-center gap-1 rounded px-1.5 text-sm text-foreground/80 hover:bg-muted/50"
131
186
  title="Duplicate"
187
+ aria-label="Duplicate node"
132
188
  @click="duplicate"
133
189
  >
134
190
  <Lucide.Copy :size="11" />
@@ -138,12 +194,37 @@ function remove() {
138
194
  type="button"
139
195
  class="flex h-6 items-center gap-1 rounded px-1.5 text-sm text-rose-600 hover:bg-rose-50"
140
196
  title="Delete"
197
+ aria-label="Delete node"
141
198
  @click="remove"
142
199
  >
143
200
  <Lucide.Trash2 :size="11" />
144
201
  </button>
145
202
  </NodeToolbar>
146
203
 
204
+ <!-- The note itself, hanging off the corner the badge sits in — below the card
205
+ by default, above it when a long note would otherwise run past the bottom
206
+ of the canvas. A NodeToolbar rather than an in-card panel: it escapes the
207
+ node's stacking context, so the text can't end up behind a neighbour. -->
208
+ <NodeToolbar
209
+ v-if="notes"
210
+ :is-visible="noteOpen"
211
+ :position="notePlacement"
212
+ align="end"
213
+ :offset="notePlacement === Position.Top ? noteTopOffset : NOTE_GAP"
214
+ class="nowheel max-w-[240px] overflow-y-auto overscroll-contain rounded-lg border border-black/[0.08] bg-white px-2.5 py-2 text-left shadow-lg"
215
+ :class="noteReady ? '' : 'invisible'"
216
+ :style="noteMaxHeight ? { maxHeight: `${noteMaxHeight}px` } : void 0"
217
+ @mouseenter="holdNote"
218
+ @mouseleave="releaseNote"
219
+ >
220
+ <div ref="notePanel" role="tooltip">
221
+ <div class="mb-1 text-[8px] font-semibold uppercase tracking-[0.08em] text-muted-foreground/60">
222
+ Note
223
+ </div>
224
+ <p class="whitespace-pre-wrap break-words text-[11px] leading-snug text-foreground/80">{{ notes }}</p>
225
+ </div>
226
+ </NodeToolbar>
227
+
147
228
  <!-- Input: ONE logical input, attachable from the left or the top — both
148
229
  dots feed the same `__in` port. Clearly visible, ring + shadow. -->
149
230
  <template v-if="inputRequired">
@@ -181,10 +262,66 @@ function remove() {
181
262
  @mouseenter="onHoverEnter"
182
263
  @mouseleave="onHoverLeave"
183
264
  :class="[
184
- selected ? 'border-emerald-500/50 shadow-[0_0_0_2px_rgba(16,185,129,0.18)]' : 'border-black/[0.08] shadow-sm hover:shadow-md hover:border-black/[0.16]',
265
+ selected ? 'border-emerald-500/50 shadow-[0_0_0_2px_rgba(16,185,129,0.18)]' : invalidFields.length ? 'border-amber-500/70 shadow-[0_0_0_2px_rgba(245,158,11,0.22)] hover:shadow-md' : 'border-black/[0.08] shadow-sm hover:shadow-md hover:border-black/[0.16]',
185
266
  showPortLabels ? 'pb-3.5' : ''
186
267
  ]"
187
268
  >
269
+ <!-- Incomplete settings at the last save: the count, amber, at the corner
270
+ the input handles leave free. Hoverable on purpose — the count alone
271
+ doesn't say what is wrong, so the title spells it out and lists the
272
+ fields; `nodrag` keeps that hover from starting a card drag. -->
273
+ <span
274
+ v-if="invalidFields.length"
275
+ role="img"
276
+ :aria-label="invalidLabel"
277
+ :title="invalidLabel"
278
+ class="nodrag absolute -left-2 -top-2 z-10 flex cursor-help items-center gap-0.5 rounded-full bg-amber-500 px-1.5 py-0.5 text-[9px] font-bold leading-none text-white shadow-sm"
279
+ >
280
+ <Lucide.TriangleAlert :size="9" :stroke-width="2.5" />
281
+ {{ invalidFields.length }}
282
+ </span>
283
+ <!-- Always-visible edit affordance: single click opens the config lightbox.
284
+ (Double-click on the whole card works too.) Hidden in read-only hosts
285
+ that don't provide `configure`. `nodrag` so pressing it never starts a
286
+ node drag; `.stop` so it doesn't bubble to pane/canvas. -->
287
+ <button
288
+ v-if="canConfigure"
289
+ type="button"
290
+ class="nodrag absolute right-1.5 top-1.5 z-10 flex size-5 items-center justify-center rounded text-muted-foreground/50 transition-colors hover:bg-muted hover:text-foreground"
291
+ title="Configure"
292
+ aria-label="Configure"
293
+ @click.stop="configure"
294
+ @mousedown.stop
295
+ @dblclick.stop
296
+ >
297
+ <Lucide.Pencil :size="11" />
298
+ </button>
299
+ <!-- Note badge, mirroring the edit affordance into the opposite corner. Its
300
+ hit area is the whole 20px square, not the 12px glyph. Sits clear of the
301
+ port-label strip when there is one; `nodrag` + `.stop` so grabbing it
302
+ neither drags the node nor opens the config. -->
303
+ <button
304
+ v-if="notes"
305
+ type="button"
306
+ class="nodrag absolute right-1.5 z-10 flex size-5 items-center justify-center rounded text-muted-foreground/45 transition-colors hover:bg-muted hover:text-foreground"
307
+ :class="showPortLabels ? 'bottom-4' : 'bottom-1.5'"
308
+ aria-label="Note"
309
+ :aria-describedby="notePanelId"
310
+ @mouseenter="openNote"
311
+ @mouseleave="releaseNote"
312
+ @focus="openNote"
313
+ @blur="releaseNote"
314
+ @click.stop
315
+ @mousedown.stop
316
+ @dblclick.stop
317
+ >
318
+ <Lucide.MessageSquareText :size="12" :stroke-width="2.25" />
319
+ </button>
320
+ <!-- The badge's description. `sr-only` keeps it out of the picture and out of
321
+ the layout (it is absolutely positioned, so the card's height — which
322
+ feeds the handle positions — is untouched), while keeping it in the
323
+ accessibility tree, where the visible panel cannot be relied on. -->
324
+ <span v-if="notes" :id="notePanelId" class="sr-only">{{ notes }}</span>
188
325
  <!-- Port labels sit ABOVE their bottom dots, inside the card's label
189
326
  strip, whenever ports are distinguishable (true/false, error, cases). -->
190
327
  <template v-if="showPortLabels">
@@ -212,17 +349,23 @@ function remove() {
212
349
  <div class="flex items-center gap-2.5 px-3 py-2.5">
213
350
  <span
214
351
  class="flex size-8 shrink-0 items-center justify-center rounded-md"
215
- :class="hasLogo ? 'bg-white ring-1 ring-black/[0.06]' : category?.color ?? 'bg-muted text-foreground'"
352
+ :class="category?.color ?? 'bg-muted text-foreground'"
216
353
  >
217
- <IntegrationsNodeLogo v-if="hasLogo" :manifest="manifest" :size="18" />
218
- <component :is="iconFor(manifest?.icon)" v-else :size="15" :stroke-width="2.25" />
354
+ <IntegrationsNodeMark :manifest="manifest" :size="18" />
219
355
  </span>
220
356
  <div class="min-w-0 flex-1">
221
357
  <div class="text-sm font-semibold leading-tight text-foreground truncate">
222
358
  {{ title }}
223
359
  </div>
224
- <div v-if="subtitle" class="mt-0.5 text-sm text-muted-foreground truncate" :title="subtitle">
225
- {{ subtitle }}
360
+ <!-- Meta line: the pinned version, small + muted but always visible so
361
+ you can tell which version an instance runs without opening the
362
+ inspector. Left-aligned and on the second line, clear of the
363
+ top-right edit affordance (PO-192). -->
364
+ <div v-if="data.nodeVersion" class="mt-0.5 flex items-center gap-1.5 text-sm text-muted-foreground">
365
+ <span
366
+ class="shrink-0 text-[10px] font-medium leading-none tabular-nums text-muted-foreground/55"
367
+ :title="`Version ${data.nodeVersion}`"
368
+ >v{{ data.nodeVersion }}</span>
226
369
  </div>
227
370
  </div>
228
371
  </div>
@@ -5,6 +5,10 @@ interface NodeData extends WorkflowNode {
5
5
  * editor without persisting to the blob. When present and non-empty, these
6
6
  * drive the rendered handles instead of the static manifest computation. */
7
7
  __resolvedOutputs?: NodeManifestOutput[];
8
+ /** Config keys the backend reported as incomplete at the last save (PO-209),
9
+ * injected by the editor the same non-persisting way. Read-only canvases
10
+ * (run replay, history preview) never pass them, so nothing is marked there. */
11
+ __invalidFields?: string[];
8
12
  }
9
13
  type __VLS_Props = {
10
14
  id: string;
@@ -2,7 +2,6 @@
2
2
  import { computed, markRaw, nextTick, onBeforeUnmount, onMounted, provide, ref, watch } from "vue";
3
3
  import {
4
4
  MarkerType,
5
- PanOnScrollMode,
6
5
  VueFlow,
7
6
  useVueFlow
8
7
  } from "@vue-flow/core";
@@ -150,9 +149,7 @@ preview.onPaneReady(() => {
150
149
  preview.updateNodeInternals(vfNodes.value.map((n) => n.id));
151
150
  void preview.fitView({ padding: 0.2, maxZoom: 1 });
152
151
  });
153
- provide("workflow-node-actions", { duplicate: () => {
154
- }, remove: () => {
155
- } });
152
+ provide("workflow-node-actions", null);
156
153
  const dynamicOutputs = ref({});
157
154
  const dynamicOutputNodes = computed(() => props.workflow.blob.nodes.filter((n) => {
158
155
  const m = findManifest(n.nodeSlug, n.nodeVersion);
@@ -178,20 +175,7 @@ const finalStatus = computed(() => {
178
175
  const runNodeIds = computed(() => new Set(steps.value.map((s) => s.node_id)));
179
176
  const triggerIds = computed(() => new Set(props.workflow.blob.triggers.map((t) => t.id)));
180
177
  function edgeColorByKind(kind) {
181
- switch (kind) {
182
- case "success":
183
- return "#10b981";
184
- case "error":
185
- return "#f43f5e";
186
- case "branch-true":
187
- return "#10b981";
188
- case "branch-false":
189
- return "#f59e0b";
190
- case "branch":
191
- return "#8b5cf6";
192
- default:
193
- return "#94a3b8";
194
- }
178
+ return kind === "error" ? "#cbd5e1" : "#94a3b8";
195
179
  }
196
180
  const GROUP_PAD = 24;
197
181
  const GROUP_LABEL_ROOM = 26;
@@ -304,6 +288,7 @@ const vfEdges = computed(() => {
304
288
  if (output === "true") return "branch-true";
305
289
  if (output === "false") return "branch-false";
306
290
  if (output === "error") return "error";
291
+ if (output === "done") return "success";
307
292
  const outs = [...manifest.outputs ?? [], ...dynamicOutputs.value[sourceNodeId] ?? []];
308
293
  const portDef = outs.find((o) => o.name === output);
309
294
  if (portDef) {
@@ -323,7 +308,7 @@ const vfEdges = computed(() => {
323
308
  const kind = edgeKind(e.from.nodeId, e.from.output);
324
309
  const wn = blob.nodes.find((n) => n.id === e.from.nodeId);
325
310
  const manifest = wn ? findManifest(wn.nodeSlug, wn.nodeVersion) : null;
326
- const isBranchPort = (manifest?.outputs ?? []).some((o) => o.sourceFromConfig) || ["true", "false", "error"].includes(e.from.output);
311
+ const isBranchPort = (manifest?.outputs ?? []).some((o) => o.sourceFromConfig) || ["true", "false"].includes(e.from.output);
327
312
  const srcSide = pickSourceSide(e.from.nodeId, e.to.nodeId);
328
313
  const sourceRan = triggerIds.value.has(e.from.nodeId) ? steps.value.length > 0 : runNodeIds.value.has(e.from.nodeId);
329
314
  const traversed = sourceRan && runNodeIds.value.has(e.to.nodeId);
@@ -451,7 +436,6 @@ watch(() => props.initialRunId, (id) => {
451
436
  :nodes-draggable="false"
452
437
  :nodes-connectable="false"
453
438
  :edges-updatable="false"
454
- :pan-on-scroll-mode="PanOnScrollMode.Free"
455
439
  :delete-key-code="null"
456
440
  :min-zoom="0.25"
457
441
  :max-zoom="2"
@@ -9,6 +9,7 @@ import {
9
9
  DialogHeader,
10
10
  DialogTitle,
11
11
  Input,
12
+ Label,
12
13
  Select,
13
14
  SelectContent,
14
15
  SelectItem,
@@ -68,7 +69,9 @@ function formatDateTime(iso) {
68
69
  :disabled="savingMode"
69
70
  @update:model-value="(v) => emit('change-mode', v)"
70
71
  >
71
- <SelectTrigger class="h-8 w-full bg-white text-sm">
72
+ <!-- Named explicitly: the section caption above says "Execution",
73
+ which is not a name the control itself carries. -->
74
+ <SelectTrigger aria-label="Execution mode" class="h-8 w-full bg-white text-sm">
72
75
  <SelectValue placeholder="Pick a mode" />
73
76
  </SelectTrigger>
74
77
  <SelectContent>
@@ -86,21 +89,24 @@ function formatDateTime(iso) {
86
89
 
87
90
  <div class="rounded-lg border border-black/[0.06] bg-white p-4 space-y-3">
88
91
  <div class="text-mono text-muted-foreground">Appearance</div>
92
+ <!-- Real labels, not styled captions: each one names its own control
93
+ (`for`/`id`), otherwise a screen reader reads four unlabelled
94
+ fields in a row. -->
89
95
  <div class="space-y-1.5">
90
- <div class="text-sm text-muted-foreground">Name</div>
91
- <Input v-model="nameDraft" class="bg-white" placeholder="Workflow name" />
96
+ <Label for="wf-set-name" class="text-sm font-normal text-muted-foreground">Name</Label>
97
+ <Input id="wf-set-name" v-model="nameDraft" class="bg-white" placeholder="Workflow name" />
92
98
  </div>
93
99
  <div class="space-y-1.5">
94
- <div class="text-sm text-muted-foreground">Description</div>
95
- <Textarea v-model="descriptionDraft" rows="2" class="bg-white" placeholder="What this workflow does and why it exists." />
100
+ <Label for="wf-set-desc" class="text-sm font-normal text-muted-foreground">Description</Label>
101
+ <Textarea id="wf-set-desc" v-model="descriptionDraft" rows="2" class="bg-white" placeholder="What this workflow does and why it exists." />
96
102
  </div>
97
103
  <div class="space-y-1.5">
98
- <div class="text-sm text-muted-foreground">Tags</div>
99
- <WorkflowTagsInput v-model="tagsDraft" :suggestions="tagSuggestions ?? []" />
104
+ <Label for="wf-set-tags" class="text-sm font-normal text-muted-foreground">Tags</Label>
105
+ <WorkflowTagsInput v-model="tagsDraft" input-id="wf-set-tags" :suggestions="tagSuggestions ?? []" />
100
106
  </div>
101
107
  <div class="space-y-1.5">
102
- <div class="text-sm text-muted-foreground">Icon</div>
103
- <WorkflowIconPicker v-model="iconDraft" />
108
+ <Label id="wf-set-icon-label" class="text-sm font-normal text-muted-foreground">Icon</Label>
109
+ <WorkflowIconPicker v-model="iconDraft" trigger-labelledby="wf-set-icon-label" />
104
110
  </div>
105
111
  <div class="flex justify-end">
106
112
  <Button
@@ -1,5 +1,6 @@
1
1
  type __VLS_Props = {
2
2
  suggestions?: string[];
3
+ inputId?: string;
3
4
  };
4
5
  type __VLS_PublicProps = __VLS_Props & {
5
6
  modelValue?: string[];
@@ -2,7 +2,8 @@
2
2
  import { ref, useId } from "vue";
3
3
  const model = defineModel({ type: Array, ...{ default: () => [] } });
4
4
  const props = defineProps({
5
- suggestions: { type: Array, required: false, default: () => [] }
5
+ suggestions: { type: Array, required: false, default: () => [] },
6
+ inputId: { type: String, required: false }
6
7
  });
7
8
  const draft = ref("");
8
9
  const inputRef = ref(null);
@@ -28,7 +29,7 @@ function onKeydown(e) {
28
29
 
29
30
  <template>
30
31
  <div
31
- class="flex min-h-9 cursor-text flex-wrap items-center gap-1 rounded-md border border-black/[0.08] bg-white px-2 py-1.5"
32
+ class="flex min-h-9 cursor-text flex-wrap items-center gap-1 rounded-md border border-black/[0.08] bg-white px-2 py-1.5 focus-within:ring-2 focus-within:ring-primary/40"
32
33
  @click="inputRef?.focus()"
33
34
  >
34
35
  <span
@@ -41,12 +42,16 @@ function onKeydown(e) {
41
42
  type="button"
42
43
  class="text-muted-foreground transition hover:text-foreground"
43
44
  :title="`Remove ${t}`"
45
+ :aria-label="`Remove ${t}`"
44
46
  @click.stop="remove(t)"
45
47
  >
46
48
  ×
47
49
  </button>
48
50
  </span>
51
+ <!-- No ring of its own: the wrapper above is the field the user sees, and
52
+ it carries the focus ring via focus-within. -->
49
53
  <input
54
+ :id="props.inputId"
50
55
  ref="inputRef"
51
56
  v-model="draft"
52
57
  type="text"
@@ -1,5 +1,6 @@
1
1
  type __VLS_Props = {
2
2
  suggestions?: string[];
3
+ inputId?: string;
3
4
  };
4
5
  type __VLS_PublicProps = __VLS_Props & {
5
6
  modelValue?: string[];
@@ -3,6 +3,8 @@ import { computed, ref } from "vue";
3
3
  import { Handle, Position, useVueFlow } from "@vue-flow/core";
4
4
  import { NodeToolbar } from "@vue-flow/node-toolbar";
5
5
  import { ClockCircle as Clock, Play, TrashBinMinimalistic as Trash2, LinkRound as Webhook, Bolt } from "#solar-icons";
6
+ import { resolveDisplayName } from "../../utils/localize";
7
+ import { describeCron } from "../../utils/schedule";
6
8
  const props = defineProps({
7
9
  id: { type: String, required: true },
8
10
  data: { type: Object, required: true },
@@ -15,7 +17,7 @@ const icon = computed(() => {
15
17
  });
16
18
  const subtitle = computed(() => {
17
19
  const c = props.data.config;
18
- if (props.data.type === "schedule") return c.cron ?? "no cron set";
20
+ if (props.data.type === "schedule") return describeCron(c.cron) ?? c.cron ?? "no schedule set";
19
21
  if (props.data.type === "webhook") return `${c.method ?? "POST"} ${c.path ?? "/"}`;
20
22
  if (props.data.type === "event") return c.event_type ? `on ${c.event_type}` : "no event set";
21
23
  return "On demand";
@@ -23,7 +25,7 @@ const subtitle = computed(() => {
23
25
  const title = computed(() => {
24
26
  const n = props.data.name;
25
27
  if (!n) return props.data.type.charAt(0).toUpperCase() + props.data.type.slice(1);
26
- return typeof n === "string" ? n : n.en ?? Object.values(n)[0];
28
+ return resolveDisplayName(n);
27
29
  });
28
30
  const hovered = ref(false);
29
31
  let hoverTimer = null;