@revenexx/studio-integrations 1.0.0 → 1.1.1

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 (34) hide show
  1. package/CHANGELOG.md +254 -0
  2. package/dist/module.json +1 -1
  3. package/dist/runtime/components/Integrations/DeadLettersPanel.vue +2 -6
  4. package/dist/runtime/components/Integrations/ExpressionInput.vue +33 -4
  5. package/dist/runtime/components/Integrations/NodeInspector.vue +40 -0
  6. package/dist/runtime/components/Integrations/RunActionsMenu.vue +3 -2
  7. package/dist/runtime/components/Integrations/RunDetailPanel.vue +9 -6
  8. package/dist/runtime/components/Integrations/RunsTable.vue +15 -3
  9. package/dist/runtime/components/Integrations/WorkflowEditor.vue +27 -1
  10. package/dist/runtime/components/Integrations/WorkflowHistoryPanel.vue +9 -1
  11. package/dist/runtime/components/Integrations/WorkflowNodeView.d.vue.ts +13 -0
  12. package/dist/runtime/components/Integrations/WorkflowNodeView.vue +38 -3
  13. package/dist/runtime/components/Integrations/WorkflowNodeView.vue.d.ts +13 -0
  14. package/dist/runtime/components/Integrations/WorkflowRunReplayPanel.vue +8 -6
  15. package/dist/runtime/components/Integrations/WorkflowsTable.vue +9 -4
  16. package/dist/runtime/composables/useIntegrationsDeadLetters.d.ts +9 -2
  17. package/dist/runtime/composables/useIntegrationsDeadLetters.js +27 -18
  18. package/dist/runtime/composables/useIntegrationsRuns.d.ts +30 -1
  19. package/dist/runtime/composables/useIntegrationsRuns.js +70 -29
  20. package/dist/runtime/composables/useIntegrationsSchedules.d.ts +7 -15
  21. package/dist/runtime/composables/useIntegrationsSchedules.js +80 -35
  22. package/dist/runtime/composables/useIntegrationsTriggers.d.ts +8 -1
  23. package/dist/runtime/data/loopBody.d.ts +129 -0
  24. package/dist/runtime/data/loopBody.js +76 -0
  25. package/dist/runtime/i18n/messages/de.json +46 -33
  26. package/dist/runtime/i18n/messages/en.json +13 -0
  27. package/dist/runtime/pages/integrations/index.vue +29 -19
  28. package/dist/runtime/pages/integrations/runs.vue +22 -10
  29. package/dist/runtime/pages/integrations/schedules.vue +64 -7
  30. package/dist/runtime/pages/integrations/workflows/[id].vue +22 -8
  31. package/dist/runtime/types/integrations.d.ts +39 -0
  32. package/dist/runtime/utils/statusStyle.d.ts +76 -0
  33. package/dist/runtime/utils/statusStyle.js +27 -0
  34. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,259 @@
1
1
  # @revenexx/studio-integrations
2
2
 
3
+ ## 1.1.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 77ea454: A workflow's Runs tab no longer puts the operator back on the newest page while
8
+ they are paging. The tab's own refresh used to consult the page that last arrived
9
+ rather than the page that was asked for, so a refresh falling due between "show me
10
+ the older runs" and those runs arriving won the race and undid the click. It now
11
+ leaves a pending page alone, and a refresh that was already on its way no longer
12
+ draws over the page the operator asked for.
13
+ - ddb95eb: The `{ }` button beside a node setting is no longer announced as its own glyph.
14
+ It now carries a name saying what it does and what it opens ("Insert expression —
15
+ available data for this setting", and the German equivalent), reports whether the
16
+ list of available data is open, and points at that list while it is there.
17
+
18
+ It can also be operated from the keyboard at all now: it acted on a mouse press
19
+ alone, so Enter and Space did nothing, and Escape while it had focus closed the whole
20
+ node configuration dialog instead of the list. Nothing changes for a pointer.
21
+
22
+ - ec8f025: A run row no longer offers actions the platform will refuse. A run that has been
23
+ accepted but not started yet has no execution to act on, and the API answers a
24
+ cancel, a terminate or a resume for it with a 409 — so the row now withholds all
25
+ three, the way the run's own record already does, instead of turning the click into
26
+ an error toast.
27
+
28
+ Two German words on the same menu are fixed with it: the row's "Retry" and the
29
+ record's were shipped untranslated, and the entry that opens a run's replay said
30
+ "Wiederholen", which reads as an offer to run it again. They now read "Erneut
31
+ ausführen" and "Wiedergabe" — the word the replay surface itself already uses.
32
+
33
+ - 0de6228: A run's outcome is no longer cut off or drawn over the workflow name beside it. The
34
+ Status column in both lists of runs — the Runs page and the dashboard's latest runs —
35
+ was a fixed width measured against the English words, so the German
36
+ "fehlgeschlagen" ran out of its cell; the dashboard's own column was narrower still,
37
+ and even "erfolgreich" overflowed it. Both now share one track that keeps a floor and
38
+ grows to the widest word on the page, in whatever language the studio is read.
39
+
40
+ ## 1.1.0
41
+
42
+ ### Minor Changes
43
+
44
+ - c8f121b: The Schedules page shows the firing the platform actually holds, and the run it
45
+ really performed.
46
+
47
+ Both times in that list were made up. There was no telemetry to read, so the page
48
+ computed them from the schedule's own identity — arithmetic over the workflow id
49
+ and the trigger id — which told the truth about the _shape_ of the answer and
50
+ nothing about the value. A schedule that fires nightly at 03:00 could read "in
51
+ 18m", and one that had never run could report a last run.
52
+
53
+ They are now the platform's: the next firing it holds, with the offset of the
54
+ schedule's own zone available as the cell's title, and the last run it really
55
+ performed. The header's live count and the timezones the filter offers are its
56
+ counts too, taken over the tenant rather than over the rows on screen — so
57
+ narrowing the list no longer shrinks the menu that narrows it.
58
+
59
+ Two promises changed with them.
60
+
61
+ A paused schedule used to state neither time, which looked like one rule and was
62
+ really an artefact of both being invented from the same flag. It has no next
63
+ firing, because the platform holds none for it — and it keeps the run it performed
64
+ before it was paused. Forgetting that is how a paused schedule reads as one that
65
+ never ran.
66
+
67
+ "A distance further out than a day is stated as the date and time instead" was a
68
+ promise with nothing to prove it: the fabricated times stayed inside twelve hours,
69
+ so no fixture could produce the day-scale form. A weekly schedule reaches it, and
70
+ it now has a test.
71
+
72
+ What the page still cannot say is _why_ a schedule will not fire. The platform
73
+ distinguishes six reasons beyond "somebody paused it" — the workflow is off, its
74
+ bundle never built, the expression cannot be read, the zone is not a real one, the
75
+ rhythm can never come round — and the page shows the absence of a next run and the
76
+ live/paused badge, which covers the first and leaves the others looking like it.
77
+
78
+ Review then found four more things a fully mocked test suite could not see, and
79
+ they are user-visible.
80
+
81
+ A schedule carrying no zone of its own showed a blank timezone and could be found
82
+ under none of the zones the filter offers — the platform counts such a row under
83
+ its own default and narrows to it there, so the studio puts that default back
84
+ rather than showing nothing.
85
+
86
+ A run the platform has created and no worker has started now says so, instead of
87
+ reading as a schedule that has never run: when a run _started_ is recorded only
88
+ once something picks it up.
89
+
90
+ A read the platform refuses says so. Until now the page fell through to the words
91
+ a tenant with no schedules gets, which on this page is the opposite state — an
92
+ operator told there are no schedules stops looking, and a tenant whose list merely
93
+ could not be reached is the one where something may be silently not firing.
94
+
95
+ And a date further out than a day is drawn in the schedule's own timezone, so it
96
+ no longer contradicts the zone stated beside it — or the exact moment on its own
97
+ hover — by an hour or two.
98
+
99
+ - 82884b7: The Runs page, its Missed runs tab and the Dashboard read the platform's own
100
+ lists and figures instead of assembling them in the browser.
101
+
102
+ Runs are a per-workflow resource, so the tenant-wide views used to collect them:
103
+ a request per workflow, paged to the end of each one's history — up to a hundred
104
+ pages of a hundred rows apiece — and then ordered, filtered and counted here. A
105
+ tenant's ordering and totals were therefore only ever as complete as that
106
+ collecting, and nothing said how far back the page reached. The same went for the
107
+ missed firings and for the four figures on the landing page, four of which could
108
+ not start until one shared list of workflows had arrived.
109
+
110
+ The platform serves all of it now. One request for the runs, ordered by it; one
111
+ for the counts, taken over the tenant rather than over what happened to load; one
112
+ for the latest run of each workflow, which is what the Workflows list and the
113
+ dashboard's two panels actually want. The dashboard no longer loads a run list at
114
+ all.
115
+
116
+ Three things visibly change rather than merely getting faster:
117
+
118
+ The trigger column and its filter tell runs apart. Every row claimed to have been
119
+ started by hand, because the payload carried nothing about what started it, so
120
+ that menu had one populated option. The platform reports it, so the column is
121
+ true and narrowing by it works.
122
+
123
+ The Workflows list stops being able to say "never run" about a workflow that
124
+ simply ran a while ago. That cell used to pick the newest run out of the tenant's
125
+ whole history held in memory, which only worked while all of it was loaded.
126
+
127
+ The counts are the tenant's. The Missed runs badge, the runs in flight and the
128
+ failed figure are counted by the platform under the same narrowing as the list
129
+ beside them, so a figure and its rows can no longer disagree.
130
+
131
+ What is still bounded is depth: the Runs list reaches the most recent hundred
132
+ runs, and narrowing reaches what is on the page. Paging past that needs the shared
133
+ studio table to be able to report a narrowing back to the page, which it cannot
134
+ yet — that is the remaining half of PO-231.
135
+
136
+ - 59133ea: The canvas says which steps a loop repeats.
137
+
138
+ An iterator draws its two outputs — Item and Done — like any other pair of ports,
139
+ so nothing said that everything hanging off Item runs once per element while
140
+ everything off Done runs once, afterwards. Somebody who had not drawn the workflow
141
+ could not see where the repeating part began or how far it reached.
142
+
143
+ Every step a loop repeats now carries a mark saying so, and naming the loop; a
144
+ step inside two loops names both, outermost first. The loop itself carries the
145
+ number of steps it repeats — including zero, which is what a loop nothing is wired
146
+ to yet looks like. Selecting the loop draws its whole body as part of the
147
+ selection, for as long as the selection lasts — and selecting any step of the body
148
+ does the same from the other end, drawing the loop and everything that repeats
149
+ alongside it. The marks are on all three canvases
150
+ that draw a workflow — the editor, the run replay and the revision preview — and
151
+ they follow a rewiring at once, before anything is saved.
152
+
153
+ What is marked is what the engine will actually repeat, which is not always the
154
+ body as it was drawn: the repeating part has no end anywhere in the workflow, only
155
+ a reach, so a step wired to the last step of the body runs once per element even
156
+ when it was meant to run once afterwards. A loop's Output tab now counts the steps
157
+ it repeats, names them, and says which of them are wired after the loop as well —
158
+ reported rather than refused, because a body that rejoins the main flow can be
159
+ exactly what its author wanted.
160
+
161
+ The connections themselves are unchanged: they carry importance, never meaning,
162
+ and colour on them stays reserved for a run that actually happened.
163
+
164
+ ### Patch Changes
165
+
166
+ - 5385146: A missed run's state is said in the studio's own words.
167
+
168
+ The badge on a missed run showed the state as the platform spells it — `PENDING`,
169
+ `REPLAYED`, `DISCARDED` — while the badge on a run row one tab away said
170
+ "succeeded" or "running" in the operator's language. A German interface therefore
171
+ read English in this one list, and any state the platform starts recording
172
+ tomorrow would have appeared on screen untranslated with nothing to notice it.
173
+
174
+ Both lists that carry the entry — the tenant-wide Missed runs tab and the panel
175
+ inside a workflow's editor — now say the state through the message catalogue: a
176
+ firing still waiting, one replayed, one discarded, in English and in German. A
177
+ state the studio has no word for says exactly that and keeps the platform's own
178
+ value alongside it, so an operator can quote what arrived without the studio
179
+ passing it off as a word somebody chose.
180
+
181
+ The colours of those badges are unchanged, and the two surfaces still tint them
182
+ differently — that is a separate finding, not this one.
183
+
184
+ - 630523f: A missed run's state is drawn the same way on both lists.
185
+
186
+ The same entry was coloured differently depending on where it was opened. Beside
187
+ the tenant's runs it read the cockpit-wide status palette, where a waiting firing
188
+ is tinted like a run that is about to start and the other two states are absent
189
+ altogether — so _replayed_ and _discarded_ were both grey. Inside a workflow the
190
+ panel carried a palette of its own: amber for waiting, green for replayed, grey
191
+ for discarded.
192
+
193
+ Both lists now draw a state from one place: waiting asks for attention, replayed
194
+ reads as finished well, discarded and anything the studio has no word for read as
195
+ over. The colours are the shared palette's, so this is one more vocabulary mapped
196
+ into the studio's tones rather than a second set of colours — and the word and the
197
+ colour for a state now come from a single map, which is what stops the next state
198
+ being added to one and forgotten in the other.
199
+
200
+ Visible change: on the tenant-wide _Missed runs_ tab a waiting firing turns from
201
+ blue to amber and a replayed one from grey to green. The panel inside a workflow
202
+ keeps the colours it had, give or take the shared palette's border and text tints.
203
+
204
+ - 6e1a42e: A run's outcome is drawn the same way wherever it is shown.
205
+
206
+ A run terminated by an operator read _failed_ in a list and _failed_ at its own
207
+ record — in two colours. The word went through the studio's vocabulary, where the
208
+ platform's `terminated` becomes _failed_, and the colour did not: the palette was
209
+ handed the platform's word, which it has no branch for, so the badge fell back to
210
+ the grey a cancelled run wears. Clicking the row took the operator from a red
211
+ badge to a grey one for the same run.
212
+
213
+ The colour is now decided from the same word as the label, everywhere a run's or a
214
+ step's state is drawn: the runs list, the dashboard's latest runs, a run's record,
215
+ its timeline and the replay. Nothing changes on the surfaces that were already
216
+ right — a row's status is mapped before anything draws it — and a run whose state
217
+ the platform reports as `terminated` or `timed_out` now looks like the failure the
218
+ badge says it is.
219
+
220
+ A cancelled run was affected in the same way, in the _word_ rather than the
221
+ colour: the platform spells a cancellation the American way, so a run cancelled
222
+ from its row read _cancelled_ on the row and _canceled_ at its own record — and in
223
+ German the record said it in English. It now reads the studio's word wherever it
224
+ is shown.
225
+
226
+ One of those surfaces was worse in German than in English: the timeline's step
227
+ lightbox derived its colour from the word it had already translated, so a failed
228
+ step was drawn neutral for a German reader and red for an English one. The colour
229
+ is now taken from the status itself.
230
+
231
+ `statusClass` is unchanged and still exports the studio-wide status palette;
232
+ `runStatusClass` is the run-vocabulary door to it.
233
+
234
+ - e69e407: A run is called a Run in German too.
235
+
236
+ The German interface used two words for one object. Three quarters of the
237
+ catalogue said _Run_ — the sidebar item, the editor's Runs tab, the Run button, the
238
+ breadcrumb, every audit entry — while 29 values said _Ausführung_, including the
239
+ tab sitting directly beside the one that said _Runs_, and two sentences that used
240
+ both words in the same breath ("Noch keine Ausführungen — im Editor auf Run
241
+ klicken").
242
+
243
+ Those 29 now say _Run_: the editor's _Verpasste Runs_ tab, the dashboard's _Letzte
244
+ Runs_ panel and its missed-run count, the schedules table's _Letzter Run_ and
245
+ _Nächster Run_, and the toasts and hints around starting, retrying and replaying
246
+ one.
247
+
248
+ _Ausführung_ stays where it names the act rather than the object —
249
+ _Ausführungsmodus_, _Ausführungsverhalten_, _Ausführungsverlauf_,
250
+ _Hintergrundausführung_, and the settings section _Ausführung_. Their English says
251
+ _Execution_ too, which is the test for the next one: if the English says _run_, the
252
+ German says _Run_. The rule is now written down in `docs/interface-language.md`
253
+ rather than living in one catalogue entry's description.
254
+
255
+ No English copy changed, and no key was added or removed.
256
+
3
257
  ## 1.0.0
4
258
 
5
259
  ### Major Changes
package/dist/module.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@revenexx/studio-integrations",
3
3
  "configKey": "studioIntegrations",
4
- "version": "1.0.0",
4
+ "version": "1.1.1",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.2",
7
7
  "unbuild": "unknown"
@@ -3,6 +3,7 @@ import { onMounted, ref, watch } from "vue";
3
3
  import { RefreshCircle as Loader2, Refresh as ReplayIcon, TrashBinMinimalistic as TrashBin } from "#solar-icons";
4
4
  import { Badge, Button } from "@revenexx/studio";
5
5
  import { toast } from "vue-sonner";
6
+ import { deadLetterStatusClass, deadLetterStatusLabel } from "../../utils/statusStyle";
6
7
  const props = defineProps({
7
8
  workflowId: { type: Number, required: true }
8
9
  });
@@ -62,11 +63,6 @@ async function onDiscard(dl) {
62
63
  busyId.value = null;
63
64
  }
64
65
  }
65
- function statusClass(status) {
66
- if (status === "pending") return "bg-amber-500/15 text-amber-700 border-amber-500/25";
67
- if (status === "replayed") return "bg-emerald-500/15 text-emerald-700 border-emerald-500/25";
68
- return "bg-slate-500/15 text-slate-600 border-slate-500/20";
69
- }
70
66
  function fmt(iso) {
71
67
  if (!iso) return "\u2014";
72
68
  try {
@@ -117,7 +113,7 @@ function fmt(iso) {
117
113
  :key="dl.id"
118
114
  class="flex items-start gap-3 px-3 py-2.5"
119
115
  >
120
- <Badge variant="outline" :class="['border text-xs font-medium uppercase shrink-0 mt-0.5', statusClass(dl.status)]">{{ dl.status }}</Badge>
116
+ <Badge variant="outline" :class="['border text-xs font-medium uppercase shrink-0 mt-0.5', deadLetterStatusClass(dl.status)]">{{ deadLetterStatusLabel(dl.status) }}</Badge>
121
117
  <div class="min-w-0 flex-1">
122
118
  <div class="text-sm break-words">{{ dl.reason || t("integration.deadLetters.reason.fallback") }}</div>
123
119
  <div class="mt-0.5 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs text-muted-foreground font-mono">
@@ -1,5 +1,5 @@
1
1
  <script setup>
2
- import { computed, inject, onBeforeUnmount, onMounted, ref, watch } from "vue";
2
+ import { computed, inject, onBeforeUnmount, onMounted, ref, useId, watch } from "vue";
3
3
  import { Editor, EditorContent } from "@tiptap/vue-3";
4
4
  import { Node } from "@tiptap/core";
5
5
  import Document from "@tiptap/extension-document";
@@ -158,10 +158,16 @@ const filteredSections = computed(() => {
158
158
  });
159
159
  const open = ref(false);
160
160
  const wrapRef = ref(null);
161
+ const triggerRef = ref(null);
162
+ const listOpen = computed(() => open.value && hasSuggestions.value);
163
+ const pickerId = useId();
161
164
  function toggle() {
162
165
  if (!open.value && !hasSuggestions.value) return;
163
166
  open.value = !open.value;
164
167
  }
168
+ function onTriggerClick(e) {
169
+ if (e.detail === 0) toggle();
170
+ }
165
171
  function onFocusOut(e) {
166
172
  if (wrapRef.value && e.relatedTarget instanceof globalThis.Node && wrapRef.value.contains(e.relatedTarget)) return;
167
173
  open.value = false;
@@ -173,6 +179,13 @@ function onKeydown(e) {
173
179
  open.value = false;
174
180
  }
175
181
  }
182
+ function onPickerKeydown(e) {
183
+ if (e.key !== "Escape" || !open.value) return;
184
+ e.stopPropagation();
185
+ e.preventDefault();
186
+ open.value = false;
187
+ triggerRef.value?.focus();
188
+ }
176
189
  function onDocMousedown(e) {
177
190
  if (wrapRef.value && !wrapRef.value.contains(e.target)) open.value = false;
178
191
  }
@@ -188,7 +201,11 @@ function insert(expr) {
188
201
  </script>
189
202
 
190
203
  <template>
191
- <div ref="wrapRef" class="relative" @focusout="onFocusOut">
204
+ <!-- `keydown` on the widget rather than on the editor alone: with focus on the
205
+ trigger, Escape used to reach the configuration lightbox and close the whole
206
+ dialog. The editor keeps its own handler, which stops the event before it
207
+ arrives here. -->
208
+ <div ref="wrapRef" class="relative" @focusout="onFocusOut" @keydown="onKeydown">
192
209
  <div class="flex items-center gap-1">
193
210
  <div
194
211
  class="expr-editor relative h-8 min-w-0 flex-1 overflow-hidden rounded-md border bg-white px-2 text-sm transition focus-within:outline-2 focus-within:-outline-offset-1 focus-within:outline-ring"
@@ -200,12 +217,22 @@ function insert(expr) {
200
217
  >{{ placeholder }}</span>
201
218
  <EditorContent :editor="editor" class="h-full" @keydown="onKeydown" />
202
219
  </div>
220
+ <!-- The name says what the button does AND what it opens, because the glyph
221
+ says neither; `aria-expanded` says whether the list is there, and
222
+ `aria-controls` only while it is — an id pointing at nothing is worse
223
+ than no link. See the docblock on `listOpen` for why this is not the
224
+ shared popover. -->
203
225
  <button
226
+ ref="triggerRef"
204
227
  type="button"
205
228
  :title="t('integration.nodeConfig.expression.insertTitle')"
229
+ :aria-label="t('integration.nodeConfig.expression.insertAria')"
230
+ :aria-expanded="listOpen"
231
+ :aria-controls="listOpen ? pickerId : void 0"
206
232
  class="flex h-8 shrink-0 items-center rounded-md border border-black/[0.08] bg-white px-1.5 font-mono text-sm text-muted-foreground transition hover:text-foreground hover:bg-muted/30"
207
- :class="open ? 'ring-2 ring-emerald-500/40' : ''"
233
+ :class="listOpen ? 'ring-2 ring-emerald-500/40' : ''"
208
234
  @mousedown.prevent="toggle"
235
+ @click="onTriggerClick"
209
236
  >
210
237
  {{ triggerLabel }}
211
238
  </button>
@@ -224,8 +251,10 @@ function insert(expr) {
224
251
  here and in the panel beside the form — and a popup nothing can address
225
252
  is a popup nothing can test either. -->
226
253
  <div
227
- v-if="open && hasSuggestions"
254
+ v-if="listOpen"
255
+ :id="pickerId"
228
256
  role="group"
257
+ @keydown="onPickerKeydown"
229
258
  :aria-label="t('integration.nodeConfig.expression.picker.aria')"
230
259
  class="absolute right-0 top-full z-50 mt-1 flex max-h-72 w-80 flex-col rounded-md border border-black/[0.08] bg-white text-sm shadow-lg"
231
260
  >
@@ -38,6 +38,8 @@ import {
38
38
  variableExpr
39
39
  } from "../../data/nodeInputData";
40
40
  import { portDotClass } from "../../data/portTone";
41
+ import { computeLoopBodies, manifestIterates } from "../../data/loopBody";
42
+ import { nodeDisplayLabel } from "../../utils/validationFindings";
41
43
  import { authChoiceFromConfig, configPatchForAuthChoice } from "../../data/webhookAuth";
42
44
  import {
43
45
  dataTypeOf,
@@ -772,6 +774,22 @@ const outputView = ref("schema");
772
774
  const selfInputs = computed(() => inputPortsForManifest(manifest.value));
773
775
  const selfSample = computed(() => draftNode.value ? sampleFor(draftNode.value.id) : void 0);
774
776
  const selfPorts = computed(() => draftNode.value ? withSampledFields(portsForNode(draftNode.value, manifest.value, props.dynamicOutputs?.[draftNode.value.id]), selfSample.value) : []);
777
+ const loopBody = computed(() => {
778
+ if (!draftNode.value || !manifestIterates(manifest.value)) return null;
779
+ const nodes = props.nodes ?? [];
780
+ const [body] = computeLoopBodies([draftNode.value.id], props.edges ?? []);
781
+ if (!body) return null;
782
+ const labelOf = (id) => {
783
+ const n = nodes.find((x) => x.id === id);
784
+ return n ? nodeDisplayLabel(n) : id;
785
+ };
786
+ const inBody = (ids) => nodes.filter((n) => ids.has(n.id)).map((n) => labelOf(n.id));
787
+ return {
788
+ size: body.nodeIds.size,
789
+ names: inBody(body.nodeIds),
790
+ joined: inBody(body.joinedWithDone)
791
+ };
792
+ });
775
793
  const selfSampleJson = computed(() => selfSample.value ? JSON.stringify(selfSample.value, null, 2) : "");
776
794
  const selfPinned = computed(() => !!draftNode.value && !!pinnedSamples.value[draftNode.value.id]);
777
795
  watch(selfSample, (s) => {
@@ -1937,6 +1955,28 @@ async function runTestStep() {
1937
1955
  rather than a permanent column — it is the last thing done, if it
1938
1956
  is done at all, and it needs the width when it is. -->
1939
1957
  <div v-else-if="tab === 'output' && draftNode" class="mx-auto w-full max-w-5xl space-y-5">
1958
+ <!-- An iterator's own tab leads with what it repeats (PO-277): the
1959
+ ports below say what leaves Item and Done, and this says how much
1960
+ of the workflow hangs off the first of them. Above the port
1961
+ schema on purpose — the count is the thing a reader came here to
1962
+ check, and it is the one statement the canvas cannot make. -->
1963
+ <section v-if="loopBody" class="space-y-1.5 rounded-md border border-black/[0.06] bg-white p-3">
1964
+ <span class="text-mono text-muted-foreground">{{ t("integration.nodeInspector.output.loopBody.heading") }}</span>
1965
+ <p class="text-sm text-foreground/80">
1966
+ {{ t("integration.nodeInspector.output.loopBody.size", loopBody.size) }}
1967
+ </p>
1968
+ <p v-if="loopBody.names.length" class="text-xs text-muted-foreground">
1969
+ {{ t("integration.nodeInspector.output.loopBody.names", { names: loopBody.names.join(", ") }) }}
1970
+ </p>
1971
+ <!-- Amber, not rose, and not a validation finding: a body wired
1972
+ into the Done side may be exactly what the author wanted, so
1973
+ this states what the engine will do and leaves the judgement
1974
+ to them. Routing it through the findings would refuse an
1975
+ activation that is legitimate. -->
1976
+ <p v-if="loopBody.joined.length" class="text-xs text-amber-700">
1977
+ {{ t("integration.nodeInspector.output.loopBody.joined", loopBody.joined.length, { names: loopBody.joined.join(", ") }) }}
1978
+ </p>
1979
+ </section>
1940
1980
  <section class="space-y-2">
1941
1981
  <div class="flex items-center justify-between gap-2">
1942
1982
  <p class="text-xs text-muted-foreground">
@@ -23,9 +23,10 @@ function viewDetails() {
23
23
  function viewReplay() {
24
24
  void router.push({ path: `/integrations/workflows/${props.run.workflowId}`, query: { run: props.run.id, view: "replay" } });
25
25
  }
26
- const isActive = computed(() => props.run.status === "running" || props.run.status === "queued");
26
+ const hasExecution = computed(() => !!props.run.temporalRunId);
27
+ const isActive = computed(() => (props.run.status === "running" || props.run.status === "queued") && hasExecution.value);
27
28
  const isTerminal = computed(() => ["succeeded", "failed", "cancelled"].includes(props.run.status));
28
- const canResume = computed(() => props.run.status === "failed");
29
+ const canResume = computed(() => props.run.status === "failed" && hasExecution.value);
29
30
  const hasAny = computed(() => isActive.value || isTerminal.value);
30
31
  const busy = ref(false);
31
32
  function actionLabel(a) {
@@ -2,7 +2,7 @@
2
2
  import { computed, onUnmounted, ref, watch } from "vue";
3
3
  import { Download as DownloadIcon, RefreshCircle as RefreshIcon } from "#solar-icons";
4
4
  import { Badge, Button, Dialog, DialogContent, DialogHeader, DialogTitle } from "@revenexx/studio";
5
- import { runStatusLabel, statusClass } from "../../utils/statusStyle";
5
+ import { runStatusClass, runStatusLabel } from "../../utils/statusStyle";
6
6
  import { IntegrationsApiError } from "../../utils/apiError";
7
7
  import { createPollBudget, SAFETY_POLL_MS } from "../../utils/pollBudget";
8
8
  const props = defineProps({
@@ -270,9 +270,12 @@ function timelineBarClass(row) {
270
270
  }
271
271
  }
272
272
  const timelineDetail = ref(null);
273
+ function timelineRowStatusWord(row) {
274
+ if (row.durationMs == null) return "running";
275
+ return row.ok ? "succeeded" : "failed";
276
+ }
273
277
  function timelineRowStatus(row) {
274
- if (row.durationMs == null) return t("integration.runs.status.running");
275
- return t(row.ok ? "integration.runs.status.succeeded" : "integration.runs.status.failed");
278
+ return runStatusLabel(timelineRowStatusWord(row));
276
279
  }
277
280
  function fmtClock(ms) {
278
281
  try {
@@ -583,7 +586,7 @@ async function confirmAction() {
583
586
  <div>
584
587
  <div class="text-[10px] uppercase tracking-wide text-muted-foreground">{{ t("integration.runs.detail.field.status") }}</div>
585
588
  <div class="mt-0.5">
586
- <Badge variant="outline" :class="['border text-xs font-medium uppercase', statusClass(dStatus ?? run.status)]">
589
+ <Badge variant="outline" :class="['border text-xs font-medium uppercase', runStatusClass(dStatus ?? run.status)]">
587
590
  {{ runStatusLabel(dStatus ?? run.status) }}
588
591
  </Badge>
589
592
  </div>
@@ -699,7 +702,7 @@ async function confirmAction() {
699
702
  class="w-full flex items-center gap-3 px-3 py-2 text-left"
700
703
  @click="toggleStep(i)"
701
704
  >
702
- <Badge variant="outline" :class="['border text-xs font-medium uppercase shrink-0', statusClass(st.status)]">{{ runStatusLabel(st.status) }}</Badge>
705
+ <Badge variant="outline" :class="['border text-xs font-medium uppercase shrink-0', runStatusClass(st.status)]">{{ runStatusLabel(st.status) }}</Badge>
703
706
  <div class="min-w-0 flex-1">
704
707
  <div class="font-mono text-xs truncate">{{ st.node_slug || st.node_id }}</div>
705
708
  <div class="text-[10px] text-muted-foreground truncate">
@@ -802,7 +805,7 @@ async function confirmAction() {
802
805
  <Badge
803
806
  v-if="timelineDetail"
804
807
  variant="outline"
805
- :class="['border text-xs font-medium uppercase', statusClass(timelineRowStatus(timelineDetail))]"
808
+ :class="['border text-xs font-medium uppercase', runStatusClass(timelineRowStatusWord(timelineDetail))]"
806
809
  >{{ timelineRowStatus(timelineDetail) }}</Badge>
807
810
  <span v-if="timelineDetail" class="text-sm tabular-nums text-muted-foreground">{{ fmtMs(timelineDetail.durationMs) }}</span>
808
811
  </DialogTitle>
@@ -1,7 +1,7 @@
1
1
  <script setup>
2
2
  import { computed, onMounted, onUnmounted, ref } from "vue";
3
3
  import { Badge } from "@revenexx/studio";
4
- import { RUN_STATUS_WORDS, normalisedRunStatus, runStatusLabel, statusClass } from "../../utils/statusStyle";
4
+ import { RUN_STATUS_COLUMN_WIDTH, RUN_STATUS_WORDS, normalisedRunStatus, runStatusClass, runStatusLabel } from "../../utils/statusStyle";
5
5
  import { TRIGGER_TYPE_WORDS, triggerTypeLabel } from "../../data/integrationNodeCatalog";
6
6
  const props = defineProps({
7
7
  runs: { type: Array, required: true },
@@ -70,7 +70,11 @@ const columns = computed(() => [
70
70
  // Sorted and searched by the word on screen, not by the raw field: a column
71
71
  // with no `searchGetter` is searched through its `sortGetter`, so a German
72
72
  // reader typing the status they can see has to find the row that shows it.
73
- { key: "status", label: t("integration.runs.table.column.status"), width: "100px", sortable: true, sortGetter: (r) => runStatusLabel(r.status) },
73
+ // One floor for both lists of runs, this one and the dashboard's, rather than a
74
+ // number guessed twice — see `RUN_STATUS_COLUMN_WIDTH` for how it was chosen and
75
+ // why the track cannot be elastic. At the 100px it used to be, the German
76
+ // "fehlgeschlagen" ran out of its cell and under the workflow name (PO-362).
77
+ { key: "status", label: t("integration.runs.table.column.status"), width: RUN_STATUS_COLUMN_WIDTH, sortable: true, sortGetter: (r) => runStatusLabel(r.status) },
74
78
  ...props.showWorkflow ? [{ key: "workflow", label: t("integration.runs.table.column.workflow"), sortable: true, sortGetter: (r) => r.workflowName.toLowerCase(), searchGetter: (r) => r.workflowName }] : [],
75
79
  // Fixed floor wide enough for a full UUID, flexible above it — header and
76
80
  // body rows are separate grids, so a content-sized track (max-content)
@@ -99,7 +103,15 @@ const columns = computed(() => [
99
103
  @row-click="(r) => openRun(r)"
100
104
  >
101
105
  <template #cell-status="{ row: run }">
102
- <Badge variant="outline" :class="['border text-xs font-medium uppercase', statusClass(run.status)]">
106
+ <!-- `max-w-full` is the net, not the fix: a word wider than the column above is
107
+ then clipped inside its own cell instead of being drawn over the one beside
108
+ it, and the `title` keeps it readable. A hard clip, not an ellipsis — the
109
+ badge is an inline-flex box, where `text-overflow` does not apply. -->
110
+ <Badge
111
+ variant="outline"
112
+ :title="runStatusLabel(run.status)"
113
+ :class="['border text-xs font-medium uppercase max-w-full', runStatusClass(run.status)]"
114
+ >
103
115
  {{ runStatusLabel(run.status) }}
104
116
  </Badge>
105
117
  </template>
@@ -49,6 +49,7 @@ import WorkflowEdgeView from "./WorkflowEdgeView.vue";
49
49
  import { layoutWorkflowNodes } from "./workflowLayout";
50
50
  import { TRIGGER_TEMPLATES, findManifest } from "../../data/integrationNodeCatalog";
51
51
  import { isTerminalManifest, portsForNode } from "../../data/nodeInputData";
52
+ import { computeLoopBodies, iteratorNodeIds, nodeLoopData } from "../../data/loopBody";
52
53
  import { portFromHandleId } from "../../data/handleId";
53
54
  import { groupFindingsByNode } from "../../utils/validationFindings";
54
55
  import { reorderBranchSubtrees } from "../../data/branchReorder";
@@ -89,6 +90,29 @@ watch(() => JSON.stringify(invalidNodeFields.value), () => {
89
90
  if (dragInFlight) return;
90
91
  syncFromBlob();
91
92
  });
93
+ const selectedLoopBody = computed(() => {
94
+ const selected = new Set(flow.getSelectedNodes.value.map((n) => n.id));
95
+ if (!selected.size) return /* @__PURE__ */ new Set();
96
+ const loops = iteratorNodeIds(blob.value.nodes);
97
+ if (!loops.length) return /* @__PURE__ */ new Set();
98
+ const out = /* @__PURE__ */ new Set();
99
+ for (const body of computeLoopBodies(loops, blob.value.edges)) {
100
+ const touched = selected.has(body.iteratorId) || [...body.nodeIds].some((id) => selected.has(id));
101
+ if (!touched) continue;
102
+ out.add(body.iteratorId);
103
+ for (const id of body.nodeIds) out.add(id);
104
+ }
105
+ return out;
106
+ });
107
+ provide("workflow-loop-highlight", selectedLoopBody);
108
+ watch(() => iteratorNodeIds(blob.value.nodes).join("|"), () => {
109
+ if (dragInFlight) return;
110
+ syncFromBlob();
111
+ });
112
+ watch(useIntegrationsUiLocale(), () => {
113
+ if (dragInFlight) return;
114
+ syncFromBlob();
115
+ });
92
116
  const dynamicOutputNodes = computed(() => blob.value.nodes.filter((n) => {
93
117
  const m = findManifest(n.nodeSlug, n.nodeVersion);
94
118
  return (m?.outputs ?? []).some((o) => o.resolveOutputs === true);
@@ -229,6 +253,7 @@ function bornClass(id) {
229
253
  return bornIds.value.has(id) ? "node-pop-in" : void 0;
230
254
  }
231
255
  function syncFromBlob() {
256
+ const loops = nodeLoopData(blob.value.nodes, blob.value.edges);
232
257
  const stickies = [...blob.value.stickies ?? []].sort((a, b) => stickyArea(b) - stickyArea(a));
233
258
  const collapsedBy = collapsedGroupOf();
234
259
  const groupNodes = (blob.value.groups ?? []).map((g, i) => {
@@ -284,7 +309,8 @@ function syncFromBlob() {
284
309
  data: {
285
310
  ...n,
286
311
  __resolvedOutputs: dynamicOutputs.value[n.id],
287
- __invalidFields: invalidNodeFields.value[n.id]
312
+ __invalidFields: invalidNodeFields.value[n.id],
313
+ __loop: loops.get(n.id)
288
314
  }
289
315
  }))
290
316
  ];
@@ -18,6 +18,7 @@ import GroupFrameView from "./GroupFrameView.vue";
18
18
  import WorkflowEdgeView from "./WorkflowEdgeView.vue";
19
19
  import NodeDynamicOutputsResolver from "./NodeDynamicOutputsResolver.vue";
20
20
  import { findManifest } from "../../data/integrationNodeCatalog";
21
+ import { iteratorNodeIds, nodeLoopData } from "../../data/loopBody";
21
22
  import { useIntegrationsRevisions } from "../../composables/useIntegrationsRevisions";
22
23
  const props = defineProps({
23
24
  workflow: { type: Object, required: true },
@@ -142,6 +143,7 @@ function buildPreview(blob) {
142
143
  }
143
144
  return "default";
144
145
  };
146
+ const loops = nodeLoopData(blob.nodes, blob.edges);
145
147
  const stickies = [...blob.stickies ?? []].sort((a, b) => stickyArea(b) - stickyArea(a));
146
148
  previewNodes.value = [
147
149
  ...previewGroupNodes(blob, nodePos),
@@ -167,7 +169,7 @@ function buildPreview(blob) {
167
169
  type: "workflow",
168
170
  position: n.position ?? { x: 240, y: 80 },
169
171
  draggable: false,
170
- data: { ...n, __resolvedOutputs: dynamicOutputs.value[n.id] }
172
+ data: { ...n, __resolvedOutputs: dynamicOutputs.value[n.id], __loop: loops.get(n.id) }
171
173
  }))
172
174
  ];
173
175
  previewEdges.value = blob.edges.map((e) => {
@@ -245,6 +247,12 @@ async function restoreRevision(rev) {
245
247
  watch(() => props.workflow.revision, () => {
246
248
  if (selected.value === null) buildPreview(props.workflow.blob);
247
249
  });
250
+ watch(() => iteratorNodeIds(previewBlob.value.nodes).join("|"), () => {
251
+ buildPreview(previewBlob.value);
252
+ });
253
+ watch(useIntegrationsUiLocale(), () => {
254
+ buildPreview(previewBlob.value);
255
+ });
248
256
  function formatDateTime(iso) {
249
257
  if (!iso) return "\u2014";
250
258
  try {