@revenexx/studio-integrations 0.5.0 → 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 +22 -1
  49. package/dist/runtime/components/Integrations/WorkflowEditor.vue +170 -48
  50. package/dist/runtime/components/Integrations/WorkflowEditor.vue.d.ts +22 -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 +243 -25
  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
@@ -1,9 +1,9 @@
1
1
  <script setup>
2
2
  import { computed, ref, watch } from "vue";
3
- import { Magnifier as Search, RefreshCircle as Loader2, Layers, DangerTriangle as AlertTriangle, Bolt as Zap, ClockCircle as Clock, Global as Webhook, Play } from "#solar-icons";
3
+ import { Magnifier as Search, Layers, DangerTriangle as AlertTriangle } from "#solar-icons";
4
4
  import { Input, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@revenexx/studio";
5
- import TemplateFlowPreview from "../../../components/Integrations/TemplateFlowPreview.vue";
6
- import { TEMPLATE_CATEGORIES } from "../../../data/integrationTemplates";
5
+ import TemplateCard from "../../../components/Integrations/TemplateCard.vue";
6
+ import { templateCategoryLabel, templatePackageLabel } from "../../../data/integrationTemplates";
7
7
  useHead({ title: "Templates \xB7 Integration Studio" });
8
8
  const { templates, loading, error, loaded, load, ensureDetails } = useIntegrationsTemplates();
9
9
  const { load: loadNodeCatalog } = useIntegrationsNodes();
@@ -15,16 +15,13 @@ const search = ref("");
15
15
  const activeCategory = ref("all");
16
16
  const activeLevel = ref("all");
17
17
  const sortBy = ref("name");
18
- const CATEGORY_LABELS = Object.fromEntries(
19
- TEMPLATE_CATEGORIES.map((c) => [c.key, c.label])
20
- );
21
- function categoryLabel(key) {
22
- return CATEGORY_LABELS[key] ?? key.charAt(0).toUpperCase() + key.slice(1);
23
- }
24
18
  const categories = computed(() => {
25
19
  const counts = /* @__PURE__ */ new Map();
26
- for (const t of templates.value) counts.set(t.category, (counts.get(t.category) ?? 0) + 1);
27
- return [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([key, count]) => ({ key, label: categoryLabel(key), count }));
20
+ for (const t of templates.value) {
21
+ if (!t.category) continue;
22
+ counts.set(t.category, (counts.get(t.category) ?? 0) + 1);
23
+ }
24
+ return [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([key, count]) => ({ key, label: templateCategoryLabel(key), count }));
28
25
  });
29
26
  const LEVELS = ["beginner", "intermediate", "advanced"];
30
27
  const LEVEL_RANK = { beginner: 0, intermediate: 1, advanced: 2 };
@@ -35,7 +32,7 @@ const filtered = computed(() => {
35
32
  if (activeLevel.value !== "all") items = items.filter((t) => t.level === activeLevel.value);
36
33
  if (q) {
37
34
  items = items.filter(
38
- (t) => t.name.toLowerCase().includes(q) || t.description.toLowerCase().includes(q) || t.vendors.some((v) => v.includes(q)) || (packageLabel(t) ?? "").toLowerCase().includes(q) || t.industries.some((i) => i.includes(q))
35
+ (t) => t.name.toLowerCase().includes(q) || t.description.toLowerCase().includes(q) || t.vendors.some((v) => v.toLowerCase().includes(q)) || (templatePackageLabel(t) ?? "").toLowerCase().includes(q) || t.industries.some((i) => i.toLowerCase().includes(q))
39
36
  );
40
37
  }
41
38
  const sorted = [...items];
@@ -47,21 +44,8 @@ const filtered = computed(() => {
47
44
  watch(() => templates.value.length, () => {
48
45
  if (templates.value.length) void ensureDetails(templates.value.map((t) => t.slug));
49
46
  });
50
- const TRIGGER_ICON = { schedule: Clock, webhook: Webhook, manual: Play, event: Zap };
51
- const TRIGGER_LABEL = { schedule: "Scheduled", webhook: "Webhook", manual: "Run on demand", event: "Event-driven" };
52
- function levelDot(level) {
53
- return level === "beginner" ? "bg-emerald-500" : level === "intermediate" ? "bg-amber-500" : "bg-rose-500";
54
- }
55
- function bannerFor(tpl) {
56
- return tpl.images.find((i) => i.category === "banner")?.src ?? null;
57
- }
58
- function packageLabel(tpl) {
59
- const pkg = tpl.package;
60
- if (!pkg) return null;
61
- const tail = (pkg.name.split("/").pop() ?? pkg.name).replace(/^integrations-nodes-/, "");
62
- if (tail === "core") return null;
63
- return pkg.label ?? tail.split("-").map((s) => s.charAt(0).toUpperCase() + s.slice(1)).join(" ");
64
- }
47
+ const awaitingCatalogue = computed(() => loading.value && !templates.value.length);
48
+ const statusText = computed(() => awaitingCatalogue.value ? "Loading templates\u2026" : "");
65
49
  </script>
66
50
 
67
51
  <template>
@@ -96,7 +80,7 @@ function packageLabel(tpl) {
96
80
  </div>
97
81
 
98
82
  <Select v-model="activeLevel">
99
- <SelectTrigger class="h-7 data-[size=default]:h-7 min-h-0 py-0 w-36 rounded-full bg-white text-sm">
83
+ <SelectTrigger aria-label="Filter by level" class="h-7 data-[size=default]:h-7 min-h-0 py-0 w-36 rounded-full bg-white text-sm">
100
84
  <SelectValue />
101
85
  </SelectTrigger>
102
86
  <SelectContent>
@@ -105,7 +89,7 @@ function packageLabel(tpl) {
105
89
  </SelectContent>
106
90
  </Select>
107
91
  <Select v-model="sortBy">
108
- <SelectTrigger class="h-7 data-[size=default]:h-7 min-h-0 py-0 w-40 rounded-full bg-white text-sm">
92
+ <SelectTrigger aria-label="Sort templates" class="h-7 data-[size=default]:h-7 min-h-0 py-0 w-40 rounded-full bg-white text-sm">
109
93
  <SelectValue />
110
94
  </SelectTrigger>
111
95
  <SelectContent>
@@ -116,15 +100,40 @@ function packageLabel(tpl) {
116
100
  </Select>
117
101
  <div class="relative w-60">
118
102
  <Search :size="12" class="absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
119
- <Input v-model="search" placeholder="Search templates, vendors…" class="h-7 min-h-0 py-0 rounded-full pl-7 text-sm bg-white" />
103
+ <Input v-model="search" aria-label="Search templates and vendors" placeholder="Search templates, vendors…" class="h-7 min-h-0 py-0 rounded-full pl-7 text-sm bg-white" />
120
104
  </div>
121
105
  </div>
122
106
  </template>
123
107
  </PageHeader>
124
108
 
125
109
  <div class="p-4">
126
- <!-- Loading (first fetch) -->
127
- <div v-if="loading && !templates.length" class="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
110
+ <!-- What the page is doing, in words (specs/templates.md AC-9).
111
+ The shimmer says "still working" only to somebody who can see it, and
112
+ six blank tiles carry no text, no name and no state — so waiting and
113
+ "nothing is registered" were the same silence, which are the two states
114
+ that ask for opposite reactions.
115
+
116
+ The region is OUTSIDE the v-if chain below and always in the DOM, with
117
+ only its text changing. A live region that appears together with its
118
+ content is a region assistive technology may never have been watching:
119
+ what is announced is a change inside an existing region.
120
+
121
+ Named, because it is not the only `role="status"` on this page: every
122
+ card draws one while its flow miniature loads ("Loading the flow"). Two
123
+ unnamed status regions on one page are two things a reader — and a test —
124
+ cannot tell apart, and this one is about the gallery rather than a card. -->
125
+ <p role="status" aria-live="polite" aria-label="Template gallery" class="sr-only">{{ statusText }}</p>
126
+
127
+ <!-- The placeholders are taken out of the accessibility tree rather than
128
+ marked busy. `aria-busy` only asks assistive technology to defer
129
+ exposing changes, and on a role-less div that is not a live region NVDA
130
+ and JAWS effectively ignore it — it would have expressed the state
131
+ without keeping anything from being walked. A placeholder carries no
132
+ information, so there is nothing to lose by removing it, and the region
133
+ above says in words what this area means. Which is also why the named
134
+ "Templates" group below is absent while this branch renders: the set of
135
+ cards does not exist yet, rather than existing and being empty. -->
136
+ <div v-if="awaitingCatalogue" aria-hidden="true" class="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
128
137
  <div v-for="n in 6" :key="`sk-${n}`" class="rounded-lg border border-black/[0.06] bg-white p-0 overflow-hidden">
129
138
  <div class="h-36 bg-black/[0.04] animate-pulse" />
130
139
  <div class="p-4 space-y-2">
@@ -135,8 +144,15 @@ function packageLabel(tpl) {
135
144
  </div>
136
145
  </div>
137
146
 
138
- <!-- Fetch failed -->
139
- <div v-else-if="error && !templates.length" class="rounded-lg border border-rose-500/20 bg-rose-500/5 p-4 text-sm text-rose-700 flex items-start gap-2">
147
+ <!-- Fetch failed. `role="alert"` because this block is how the wait ends for
148
+ somebody who was told through the region above to hold on: it is
149
+ announced when it is inserted, so the sequence is "Loading templates…"
150
+ then the reason, rather than "Loading templates…" then silence. An alert
151
+ rather than a copy of the text in the status region, which would put the
152
+ same sentence in the page twice and read it out twice to anybody walking
153
+ it. The other two empty states are not alerts: nothing had spoken before
154
+ them, and their copy is where the reader is going anyway. -->
155
+ <div v-else-if="error && !templates.length" role="alert" class="rounded-lg border border-rose-500/20 bg-rose-500/5 p-4 text-sm text-rose-700 flex items-start gap-2">
140
156
  <AlertTriangle :size="15" class="mt-0.5 shrink-0" />
141
157
  <div>
142
158
  <div class="font-medium">Couldn't load templates.</div>
@@ -155,61 +171,16 @@ function packageLabel(tpl) {
155
171
  </p>
156
172
  </div>
157
173
 
158
- <!-- Cards -->
159
- <div v-else class="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
160
- <NuxtLink
174
+ <!-- The group is named so the cards can be addressed as a set; a card names
175
+ itself from what it shows, which TemplateCard says more about. -->
176
+ <div v-else role="group" aria-label="Templates" class="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
177
+ <TemplateCard
161
178
  v-for="tpl in filtered"
162
179
  :key="tpl.slug"
180
+ :template="tpl"
181
+ density="full"
163
182
  :to="`/integrations/templates/${tpl.slug}`"
164
- class="group flex flex-col overflow-hidden rounded-lg border border-black/[0.06] bg-white transition hover:border-black/20 hover:shadow-sm"
165
- >
166
- <!-- Live workflow preview (or the registry banner, when one ships) -->
167
- <div class="relative h-36 shrink-0 border-b border-black/[0.06] bg-slate-50/70 [background-image:radial-gradient(circle,rgba(15,23,42,0.07)_1px,transparent_1px)] [background-size:14px_14px]">
168
- <img
169
- v-if="bannerFor(tpl)"
170
- :src="bannerFor(tpl)"
171
- alt=""
172
- class="h-full w-full object-cover"
173
- >
174
- <TemplateFlowPreview v-else-if="tpl.detailLoaded" :blob="tpl.blob" :height="144" class="px-2" />
175
- <div v-else class="flex h-full items-center justify-center">
176
- <Loader2 :size="16" class="animate-spin text-muted-foreground/50" />
177
- </div>
178
- <div class="absolute left-2 top-2 flex items-center gap-1.5">
179
- <span class="inline-flex items-center whitespace-nowrap rounded-full bg-slate-100 px-2 py-0.5 uppercase text-xs font-medium text-slate-700">
180
- {{ categoryLabel(tpl.category) }}
181
- </span>
182
- <span
183
- v-if="packageLabel(tpl)"
184
- class="inline-flex items-center whitespace-nowrap rounded-full bg-violet-100 px-2 py-0.5 uppercase text-xs font-medium text-violet-700"
185
- >
186
- {{ packageLabel(tpl) }}
187
- </span>
188
- </div>
189
- </div>
190
-
191
- <div class="flex min-h-0 flex-1 flex-col p-4">
192
- <div class="text-sm font-semibold text-foreground line-clamp-1">{{ tpl.name }}</div>
193
- <p class="mt-1 flex-1 text-sm text-muted-foreground line-clamp-2">{{ tpl.description }}</p>
194
-
195
- <div class="mt-3 flex items-center gap-2 text-xs text-muted-foreground">
196
- <span
197
- v-for="t in tpl.triggerTypes"
198
- :key="t"
199
- class="inline-flex items-center gap-1 whitespace-nowrap rounded-full bg-slate-100 px-2 py-0.5 uppercase"
200
- >
201
- <component :is="TRIGGER_ICON[t] ?? Zap" :size="10" />
202
- {{ TRIGGER_LABEL[t] ?? t }}
203
- </span>
204
- <span class="flex-1" />
205
- <span class="inline-flex items-center gap-1 whitespace-nowrap rounded-full bg-slate-100 px-2 py-0.5 uppercase">
206
- <span class="size-1.5 rounded-full" :class="levelDot(tpl.level)" />
207
- {{ tpl.level }}
208
- </span>
209
- <span v-if="tpl.publisher.kind !== 'revenexx'" class="inline-flex items-center rounded-full bg-slate-100 px-2 py-0.5 uppercase">{{ tpl.publisher.name }}</span>
210
- </div>
211
- </div>
212
- </NuxtLink>
183
+ />
213
184
  </div>
214
185
 
215
186
  <div v-if="templates.length && !filtered.length" class="mt-4 rounded-lg border border-black/[0.06] bg-white p-8 text-center text-sm text-muted-foreground">
@@ -1,7 +1,13 @@
1
1
  <script setup>
2
- import { computed, onMounted, onUnmounted, ref, watch } from "vue";
2
+ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
3
3
  import { AltArrowLeft as ArrowLeft, AltArrowRight as Chevron, Copy as CopyIcon, Pause as PauseIcon, RefreshCircle as Loader2, Play, Refresh as RetryIcon, Diskette as Save } from "#solar-icons";
4
4
  import {
5
+ AlertDialog,
6
+ AlertDialogContent,
7
+ AlertDialogDescription,
8
+ AlertDialogFooter,
9
+ AlertDialogHeader,
10
+ AlertDialogTitle,
5
11
  Badge,
6
12
  Button,
7
13
  Dialog,
@@ -24,7 +30,8 @@ import WorkflowDuplicateDialog from "../../../components/Integrations/WorkflowDu
24
30
  import RunsTable from "../../../components/Integrations/RunsTable.vue";
25
31
  import RunDetailPanel from "../../../components/Integrations/RunDetailPanel.vue";
26
32
  import { toast } from "vue-sonner";
27
- import { IntegrationsApiError } from "../../../utils/apiError";
33
+ import { IntegrationsApiError, apiValidationIssues } from "../../../utils/apiError";
34
+ import { findingsSummary, groupFindingsByNode, nodeDisplayLabel } from "../../../utils/validationFindings";
28
35
  import { payloadFieldsFromBlob } from "../../../data/payloadForm";
29
36
  import WorkflowIconView from "../../../components/Integrations/WorkflowIconView.vue";
30
37
  const route = useRoute();
@@ -46,6 +53,24 @@ const dirtyBlob = ref(null);
46
53
  const isDirty = computed(() => dirtyBlob.value !== null);
47
54
  const saving = ref(false);
48
55
  const saveError = ref(null);
56
+ const validationWarnings = computed(() => workflow.value?.warnings);
57
+ const isInvalid = computed(() => (validationWarnings.value?.length ?? 0) > 0);
58
+ const WARN_COLLAPSED = 3;
59
+ const warningsExpanded = ref(false);
60
+ const warningGroups = computed(() => groupFindingsByNode(validationWarnings.value ?? [], workflow.value?.blob.nodes ?? []));
61
+ const warningSummary = computed(() => findingsSummary(validationWarnings.value?.length ?? 0, warningGroups.value.filter((g) => g.nodeId).length));
62
+ const editorRef = ref(null);
63
+ async function revealNode(nodeId) {
64
+ tab.value = "editor";
65
+ await nextTick();
66
+ editorRef.value?.revealNode(nodeId);
67
+ }
68
+ const { load: loadNodeCatalog } = useIntegrationsNodes();
69
+ onMounted(() => {
70
+ void loadNodeCatalog();
71
+ });
72
+ const deactivationOpen = ref(false);
73
+ const deactivationIssues = ref([]);
49
74
  const { load: loadCredentials, needsReauth } = useIntegrationsCredentials();
50
75
  onMounted(() => {
51
76
  void loadCredentials();
@@ -54,11 +79,33 @@ const reauthCredentials = computed(() => {
54
79
  const blob = JSON.stringify(dirtyBlob.value ?? workflow.value?.blob ?? {});
55
80
  return needsReauth.value.filter((c) => blob.includes(c.id));
56
81
  });
57
- const buildBlocked = computed(() => {
58
- const s = workflow.value?.buildStatus;
59
- return s === "pending" || s === "building" || s === "failed";
60
- });
61
82
  const buildWorking = computed(() => workflow.value?.buildStatus === "building" || workflow.value?.buildStatus === "pending");
83
+ const buildFailed = computed(() => workflow.value?.buildStatus === "failed");
84
+ const waitingForBuild = ref(false);
85
+ const BUILD_WAIT_POLL_MS = 2500;
86
+ const BUILD_WAIT_TIMEOUT_MS = 5 * 6e4;
87
+ async function waitForBuildReady() {
88
+ waitingForBuild.value = true;
89
+ try {
90
+ const started = Date.now();
91
+ while (Date.now() - started < BUILD_WAIT_TIMEOUT_MS) {
92
+ await new Promise((resolve) => setTimeout(resolve, BUILD_WAIT_POLL_MS));
93
+ if (!workflow.value) return false;
94
+ const fresh = await get(workflow.value.id);
95
+ const status = fresh?.buildStatus;
96
+ if (status === "pending" || status === "building") continue;
97
+ if (status === "failed") {
98
+ toast.error("The workflow bundle failed to build \u2014 the run was not started.");
99
+ return false;
100
+ }
101
+ return true;
102
+ }
103
+ toast.error("The bundle build is taking unusually long \u2014 try the run again once it is ready.");
104
+ return false;
105
+ } finally {
106
+ waitingForBuild.value = false;
107
+ }
108
+ }
62
109
  const buildNotice = computed(() => {
63
110
  switch (workflow.value?.buildStatus) {
64
111
  case "building":
@@ -78,6 +125,7 @@ watch(id, async () => {
78
125
  const w = await get(id.value);
79
126
  if (!w) await router.push("/integrations/workflows");
80
127
  dirtyBlob.value = null;
128
+ warningsExpanded.value = false;
81
129
  if (tab.value === "runs") void loadRuns(1);
82
130
  }, { immediate: true });
83
131
  function onChange(blob) {
@@ -101,6 +149,7 @@ async function onRevisionRestored() {
101
149
  }
102
150
  const { trigger: triggerRun, loadForWorkflow, fetchRun, retryRun } = useIntegrationsRuns();
103
151
  const running = ref(false);
152
+ const waitingMode = ref(null);
104
153
  const payloadText = ref("{}");
105
154
  const payloadError = ref(null);
106
155
  const primaryMode = computed(() => workflow.value?.execution_mode === "sync_only" ? "sync" : "async");
@@ -129,6 +178,29 @@ function syncErrorMessage(e) {
129
178
  }
130
179
  return e?.message || "Sync run failed";
131
180
  }
181
+ function refusedForSettings(e) {
182
+ const issues = apiValidationIssues(e);
183
+ if (!issues.length) return null;
184
+ const groups = groupFindingsByNode(issues, workflow.value?.blob.nodes ?? []);
185
+ const named = groups.filter((g) => g.nodeId);
186
+ const summary = findingsSummary(issues.length, named.length);
187
+ const parts = [
188
+ ...named.map((g) => `${g.label} (${g.fields.join(", ")})`),
189
+ ...groups.filter((g) => !g.nodeId).flatMap((g) => g.messages)
190
+ ];
191
+ const where = parts.length ? ` \u2014 ${parts.join("; ")}` : "";
192
+ const line = `Can\u2019t run \u2014 ${summary}${where}`;
193
+ return /[.!?]$/.test(line) ? line : `${line}.`;
194
+ }
195
+ async function reportRunFailure(e, fallback) {
196
+ const settings = refusedForSettings(e);
197
+ if (!settings) {
198
+ syncError.value = fallback(e);
199
+ return;
200
+ }
201
+ syncError.value = settings;
202
+ if (workflow.value) await get(workflow.value.id);
203
+ }
132
204
  async function run(mode) {
133
205
  if (!workflow.value) return;
134
206
  let payload;
@@ -139,6 +211,12 @@ async function run(mode) {
139
211
  return;
140
212
  }
141
213
  payloadError.value = null;
214
+ if (buildWorking.value) {
215
+ waitingMode.value = mode;
216
+ const ready = await waitForBuildReady();
217
+ waitingMode.value = null;
218
+ if (!ready) return;
219
+ }
142
220
  runModalMode.value = mode;
143
221
  runModalRunId.value = null;
144
222
  syncOpen.value = true;
@@ -150,7 +228,7 @@ async function run(mode) {
150
228
  syncResponse.value = await triggerRun(workflow.value.id, payload, "sync");
151
229
  runModalRunId.value = extractRunId(syncResponse.value);
152
230
  } catch (e) {
153
- syncError.value = syncErrorMessage(e);
231
+ await reportRunFailure(e, syncErrorMessage);
154
232
  if (e instanceof IntegrationsApiError) {
155
233
  runModalRunId.value = extractRunId(e.body?.run);
156
234
  }
@@ -165,7 +243,7 @@ async function run(mode) {
165
243
  const res = await triggerRun(workflow.value.id, payload, "async");
166
244
  runModalRunId.value = extractRunId(res);
167
245
  } catch (e) {
168
- syncError.value = e.message || "Could not start the run.";
246
+ await reportRunFailure(e, (err) => err?.message || "Could not start the run.");
169
247
  } finally {
170
248
  running.value = false;
171
249
  syncLoading.value = false;
@@ -412,6 +490,25 @@ async function saveWorkflowMeta(meta) {
412
490
  savingMeta.value = false;
413
491
  }
414
492
  }
493
+ async function onToggleActive() {
494
+ const wf = workflow.value;
495
+ if (!wf) return;
496
+ if (!wf.active && isInvalid.value) {
497
+ toast.error(`Can\u2019t activate \u2014 ${validationWarnings.value?.length} setting${validationWarnings.value?.length === 1 ? "" : "s"} still need${validationWarnings.value?.length === 1 ? "s" : ""} fixing (see the banner above).`);
498
+ return;
499
+ }
500
+ try {
501
+ await toggleActive(wf.id);
502
+ } catch (e) {
503
+ const issues = apiValidationIssues(e);
504
+ if (issues.length) {
505
+ await get(wf.id);
506
+ toast.error(`Can\u2019t activate \u2014 ${issues.length} validation issue${issues.length === 1 ? "" : "s"} to fix.`);
507
+ return;
508
+ }
509
+ toast.error(e.message || "Could not change the workflow status.");
510
+ }
511
+ }
415
512
  async function save() {
416
513
  if (!workflow.value || !dirtyBlob.value) return;
417
514
  saving.value = true;
@@ -426,6 +523,42 @@ async function save() {
426
523
  });
427
524
  dirtyBlob.value = null;
428
525
  } catch (e) {
526
+ const issues = apiValidationIssues(e);
527
+ if (issues.length && workflow.value.active) {
528
+ const nodes = workflow.value.blob.nodes;
529
+ deactivationIssues.value = issues.map((i) => {
530
+ const m = /^blob\.nodes\.(\d+)\b/.exec(i.path);
531
+ const node = m ? nodes[Number(m[1])] : void 0;
532
+ return { ...i, where: node ? nodeDisplayLabel(node) : null };
533
+ });
534
+ deactivationOpen.value = true;
535
+ } else {
536
+ saveError.value = e.message || "Save failed.";
537
+ }
538
+ } finally {
539
+ saving.value = false;
540
+ }
541
+ }
542
+ async function confirmDeactivate() {
543
+ if (!workflow.value || !dirtyBlob.value) {
544
+ deactivationOpen.value = false;
545
+ return;
546
+ }
547
+ saving.value = true;
548
+ try {
549
+ await update(workflow.value.id, {
550
+ name: workflow.value.name,
551
+ description: workflow.value.description,
552
+ blob: dirtyBlob.value,
553
+ active: false,
554
+ executionMode: workflow.value.execution_mode
555
+ });
556
+ await get(workflow.value.id);
557
+ dirtyBlob.value = null;
558
+ deactivationOpen.value = false;
559
+ toast.success("Saved as paused \u2014 fix the highlighted settings, then activate again.");
560
+ } catch (e) {
561
+ deactivationOpen.value = false;
429
562
  saveError.value = e.message || "Save failed.";
430
563
  } finally {
431
564
  saving.value = false;
@@ -513,8 +646,9 @@ function formatDateTime(iso) {
513
646
  :variant="workflow.active ? 'outline' : 'default'"
514
647
  class="h-7 text-sm"
515
648
  :class="workflow.active ? 'text-amber-700 hover:text-amber-800' : 'bg-emerald-600 text-white hover:bg-emerald-700'"
516
- :title="workflow.active ? 'Pause this workflow' : 'Activate this workflow'"
517
- @click="toggleActive(workflow.id)"
649
+ :disabled="!workflow.active && isInvalid"
650
+ :title="workflow.active ? 'Pause this workflow' : isInvalid ? `Fix ${validationWarnings?.length} node setting(s) before activating` : 'Activate this workflow'"
651
+ @click="onToggleActive"
518
652
  >
519
653
  <component :is="workflow.active ? PauseIcon : Play" :size="12" />
520
654
  {{ workflow.active ? "Pause" : "Activate" }}
@@ -540,6 +674,51 @@ function formatDateTime(iso) {
540
674
  </NuxtLink>.
541
675
  </div>
542
676
 
677
+ <!-- Manifest validation (PO-186): an inactive workflow with warnings can't
678
+ be activated. The banner names what's incomplete; the Activate button
679
+ stays disabled until the next save recomputes an empty warnings set.
680
+ Amber (not red) — the work is saved fine, it just isn't runnable yet.
681
+ One line per NODE, listing its fields (PO-209): the messages behind
682
+ them ride along as the line's title, and the line leads to the node. -->
683
+ <div
684
+ v-if="!openRunId && !workflow.active && isInvalid"
685
+ class="border-b border-amber-500/20 bg-amber-500/5 px-4 py-2 text-sm text-amber-800"
686
+ >
687
+ <p class="font-medium">
688
+ This workflow can’t be activated yet — {{ warningSummary }}:
689
+ </p>
690
+ <ul
691
+ class="mt-1 space-y-0.5"
692
+ :class="warningsExpanded ? 'max-h-36 overflow-y-auto pr-1' : ''"
693
+ >
694
+ <li
695
+ v-for="(g, i) in warningsExpanded ? warningGroups : warningGroups.slice(0, WARN_COLLAPSED)"
696
+ :key="`${g.nodeId ?? 'unattributed'}-${i}`"
697
+ class="flex gap-1.5"
698
+ >
699
+ <span class="select-none text-amber-500">•</span>
700
+ <!-- Attributed to a node: the whole line jumps there and opens its
701
+ settings. Otherwise there is nowhere to go — state the finding. -->
702
+ <button
703
+ v-if="g.nodeId"
704
+ type="button"
705
+ class="text-left underline decoration-amber-700/30 underline-offset-2 hover:decoration-amber-800"
706
+ :title="g.messages.join('\n')"
707
+ :aria-label="`Fix ${g.fields.length} setting${g.fields.length === 1 ? '' : 's'} in ${g.label}`"
708
+ @click="revealNode(g.nodeId)"
709
+ >
710
+ <span class="font-medium">{{ g.label }}</span> — {{ g.fields.join(", ") }}
711
+ </button>
712
+ <span v-else>{{ g.messages.join(" ") }}</span>
713
+ </li>
714
+ <li v-if="!warningsExpanded && warningGroups.length > WARN_COLLAPSED" class="pl-3">
715
+ <button type="button" class="text-amber-700/90 underline underline-offset-2 hover:text-amber-900" @click="warningsExpanded = true">
716
+ …and {{ warningGroups.length - WARN_COLLAPSED }} more — open a node to fix its config.
717
+ </button>
718
+ </li>
719
+ </ul>
720
+ </div>
721
+
543
722
  <!-- Only the FAILED build state banners (its message matters); the
544
723
  working states live as the header chip — and inside a run the
545
724
  workflow's build state is noise, so nothing shows at all. -->
@@ -569,15 +748,23 @@ function formatDateTime(iso) {
569
748
  </template>
570
749
 
571
750
  <!-- Editor tab — fills the remaining height inside the panel via
572
- the flex wrapper in the studio layout. -->
751
+ the flex wrapper in the studio layout.
752
+
753
+ overlay-open: every lightbox this page owns renders as a sibling of
754
+ the tabs, so the editor stays mounted underneath one and would keep
755
+ its keyboard shortcuts live (Delete deleting the selected node behind
756
+ the dialog — PO-208). Add new page-level dialogs to this list. -->
573
757
  <div v-else-if="tab === 'editor'" class="flex-1 min-h-0">
574
758
  <WorkflowEditor
575
759
  :key="editorResetKey"
760
+ ref="editorRef"
576
761
  :workflow="workflow"
577
- :run-busy="running || syncLoading"
762
+ :run-busy="running || syncLoading || waitingForBuild"
763
+ :run-waiting="waitingForBuild"
764
+ :overlay-open="settingsOpen || duplicateOpen || syncOpen || runPayloadOpen || deactivationOpen"
578
765
  @change="onChange"
579
766
  @run="runFromEditor"
580
- @toggle-active="toggleActive(workflow.id)"
767
+ @toggle-active="onToggleActive"
581
768
  @open-settings="settingsOpen = true"
582
769
  />
583
770
  </div>
@@ -669,36 +856,36 @@ function formatDateTime(iso) {
669
856
  <template v-if="workflow.execution_mode === 'caller_decides'">
670
857
  <Button
671
858
  variant="outline"
672
- :disabled="running || syncLoading || buildBlocked"
673
- :title="buildBlocked ? 'The workflow bundle is not ready to run yet.' : void 0"
859
+ :disabled="running || syncLoading || waitingForBuild || buildFailed"
860
+ :title="buildFailed ? 'The workflow bundle failed to build.' : void 0"
674
861
  class="h-8 text-sm"
675
862
  @click="runFromPayloadDialog('sync')"
676
863
  >
677
- <Loader2 v-if="syncLoading" :size="14" class="animate-spin" />
864
+ <Loader2 v-if="syncLoading || waitingMode === 'sync'" :size="14" class="animate-spin" />
678
865
  <Play v-else :size="14" />
679
- Run (sync)
866
+ {{ waitingMode === "sync" ? "Waiting for build\u2026" : "Run (sync)" }}
680
867
  </Button>
681
868
  <Button
682
- :disabled="running || syncLoading || buildBlocked"
683
- :title="buildBlocked ? 'The workflow bundle is not ready to run yet.' : void 0"
869
+ :disabled="running || syncLoading || waitingForBuild || buildFailed"
870
+ :title="buildFailed ? 'The workflow bundle failed to build.' : void 0"
684
871
  class="h-8 text-sm"
685
872
  @click="runFromPayloadDialog('async')"
686
873
  >
687
- <Loader2 v-if="running" :size="14" class="animate-spin" />
874
+ <Loader2 v-if="running || waitingMode === 'async'" :size="14" class="animate-spin" />
688
875
  <Play v-else :size="14" />
689
- Run (async)
876
+ {{ waitingMode === "async" ? "Waiting for build\u2026" : "Run (async)" }}
690
877
  </Button>
691
878
  </template>
692
879
  <Button
693
880
  v-else
694
- :disabled="running || syncLoading || buildBlocked"
695
- :title="buildBlocked ? 'The workflow bundle is not ready to run yet.' : void 0"
881
+ :disabled="running || syncLoading || waitingForBuild || buildFailed"
882
+ :title="buildFailed ? 'The workflow bundle failed to build.' : void 0"
696
883
  class="h-8 text-sm"
697
884
  @click="runFromPayloadDialog(primaryMode)"
698
885
  >
699
- <Loader2 v-if="running || syncLoading" :size="14" class="animate-spin" />
886
+ <Loader2 v-if="running || syncLoading || waitingForBuild" :size="14" class="animate-spin" />
700
887
  <Play v-else :size="14" />
701
- Run ({{ primaryMode }})
888
+ {{ waitingForBuild ? "Waiting for build\u2026" : `Run (${primaryMode})` }}
702
889
  </Button>
703
890
  </div>
704
891
  </DialogContent>
@@ -729,5 +916,36 @@ function formatDateTime(iso) {
729
916
  />
730
917
 
731
918
  <WorkflowDuplicateDialog v-model:open="duplicateOpen" :workflow="duplicateTarget" @duplicated="onDuplicated" />
919
+
920
+ <!-- Active-save blocked by manifest validation: keep the work by saving it
921
+ as paused. Cancelling persists nothing (the 422 already rolled back). -->
922
+ <AlertDialog :open="deactivationOpen" @update:open="(v) => {
923
+ if (!v) deactivationOpen = false;
924
+ }">
925
+ <AlertDialogContent>
926
+ <AlertDialogHeader>
927
+ <AlertDialogTitle>Can’t keep this workflow active</AlertDialogTitle>
928
+ <AlertDialogDescription>
929
+ These node settings must be valid while the workflow is active. Save it as
930
+ <span class="font-medium">paused</span> to keep your changes, then fix them and activate again — or cancel to discard this save.
931
+ </AlertDialogDescription>
932
+ </AlertDialogHeader>
933
+ <ul class="max-h-48 space-y-0.5 overflow-y-auto rounded-md border border-amber-500/20 bg-amber-500/5 px-3 py-2 text-sm text-amber-800">
934
+ <li v-for="(w, i) in deactivationIssues" :key="`${w.path}-${i}`" class="flex gap-1.5">
935
+ <span class="select-none text-amber-500">•</span>
936
+ <span><span v-if="w.where" class="font-medium">{{ w.where }}:</span> {{ w.message }}</span>
937
+ </li>
938
+ </ul>
939
+ <AlertDialogFooter>
940
+ <Button variant="outline" :disabled="saving" @click="deactivationOpen = false">
941
+ Cancel
942
+ </Button>
943
+ <Button class="bg-emerald-600 text-white hover:bg-emerald-700" :disabled="saving" @click="confirmDeactivate">
944
+ <Loader2 v-if="saving" :size="12" class="animate-spin" />
945
+ Save as paused
946
+ </Button>
947
+ </AlertDialogFooter>
948
+ </AlertDialogContent>
949
+ </AlertDialog>
732
950
  </IntegrationsLayout>
733
951
  </template>
@@ -4,10 +4,11 @@ import { Button } from "@revenexx/studio";
4
4
  import WorkflowsTable from "../../../components/Integrations/WorkflowsTable.vue";
5
5
  import WorkflowCreateDialog from "../../../components/Integrations/WorkflowCreateDialog.vue";
6
6
  useHead({ title: "Workflows \xB7 Revenexx Cockpit" });
7
- const { workflows, loading, load } = useIntegrationsWorkflows();
7
+ const { workflows, loading, error, load } = useIntegrationsWorkflows();
8
8
  onMounted(() => {
9
9
  void load();
10
10
  });
11
+ const loadFailed = computed(() => !!error && !!error.value && !workflows.value.length);
11
12
  const createOpen = ref(false);
12
13
  </script>
13
14
 
@@ -25,7 +26,19 @@ const createOpen = ref(false);
25
26
  </PageHeader>
26
27
 
27
28
  <div class="p-4">
28
- <WorkflowsTable :workflows="workflows" :loading="loading" />
29
+ <StateCard
30
+ v-if="loadFailed"
31
+ variant="error"
32
+ title="Could not load workflows"
33
+ :description="error ?? void 0"
34
+ >
35
+ <template #actions>
36
+ <Button variant="outline" class="h-8 text-sm" @click="load(true)">
37
+ Try again
38
+ </Button>
39
+ </template>
40
+ </StateCard>
41
+ <WorkflowsTable v-else :workflows="workflows" :loading="loading" />
29
42
  </div>
30
43
 
31
44
  <WorkflowCreateDialog v-model:open="createOpen" />