@revenexx/studio-integrations 1.0.0 → 1.1.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 (31) hide show
  1. package/CHANGELOG.md +217 -0
  2. package/dist/module.json +1 -1
  3. package/dist/runtime/components/Integrations/DeadLettersPanel.vue +2 -6
  4. package/dist/runtime/components/Integrations/NodeInspector.vue +40 -0
  5. package/dist/runtime/components/Integrations/RunDetailPanel.vue +9 -6
  6. package/dist/runtime/components/Integrations/RunsTable.vue +2 -2
  7. package/dist/runtime/components/Integrations/WorkflowEditor.vue +27 -1
  8. package/dist/runtime/components/Integrations/WorkflowHistoryPanel.vue +9 -1
  9. package/dist/runtime/components/Integrations/WorkflowNodeView.d.vue.ts +13 -0
  10. package/dist/runtime/components/Integrations/WorkflowNodeView.vue +38 -3
  11. package/dist/runtime/components/Integrations/WorkflowNodeView.vue.d.ts +13 -0
  12. package/dist/runtime/components/Integrations/WorkflowRunReplayPanel.vue +8 -6
  13. package/dist/runtime/components/Integrations/WorkflowsTable.vue +9 -4
  14. package/dist/runtime/composables/useIntegrationsDeadLetters.d.ts +9 -2
  15. package/dist/runtime/composables/useIntegrationsDeadLetters.js +27 -18
  16. package/dist/runtime/composables/useIntegrationsRuns.d.ts +30 -1
  17. package/dist/runtime/composables/useIntegrationsRuns.js +70 -29
  18. package/dist/runtime/composables/useIntegrationsSchedules.d.ts +7 -15
  19. package/dist/runtime/composables/useIntegrationsSchedules.js +80 -35
  20. package/dist/runtime/composables/useIntegrationsTriggers.d.ts +8 -1
  21. package/dist/runtime/data/loopBody.d.ts +129 -0
  22. package/dist/runtime/data/loopBody.js +76 -0
  23. package/dist/runtime/i18n/messages/de.json +42 -30
  24. package/dist/runtime/i18n/messages/en.json +12 -0
  25. package/dist/runtime/pages/integrations/index.vue +16 -17
  26. package/dist/runtime/pages/integrations/runs.vue +22 -10
  27. package/dist/runtime/pages/integrations/schedules.vue +64 -7
  28. package/dist/runtime/types/integrations.d.ts +39 -0
  29. package/dist/runtime/utils/statusStyle.d.ts +52 -0
  30. package/dist/runtime/utils/statusStyle.js +26 -0
  31. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,222 @@
1
1
  # @revenexx/studio-integrations
2
2
 
3
+ ## 1.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - c8f121b: The Schedules page shows the firing the platform actually holds, and the run it
8
+ really performed.
9
+
10
+ Both times in that list were made up. There was no telemetry to read, so the page
11
+ computed them from the schedule's own identity — arithmetic over the workflow id
12
+ and the trigger id — which told the truth about the _shape_ of the answer and
13
+ nothing about the value. A schedule that fires nightly at 03:00 could read "in
14
+ 18m", and one that had never run could report a last run.
15
+
16
+ They are now the platform's: the next firing it holds, with the offset of the
17
+ schedule's own zone available as the cell's title, and the last run it really
18
+ performed. The header's live count and the timezones the filter offers are its
19
+ counts too, taken over the tenant rather than over the rows on screen — so
20
+ narrowing the list no longer shrinks the menu that narrows it.
21
+
22
+ Two promises changed with them.
23
+
24
+ A paused schedule used to state neither time, which looked like one rule and was
25
+ really an artefact of both being invented from the same flag. It has no next
26
+ firing, because the platform holds none for it — and it keeps the run it performed
27
+ before it was paused. Forgetting that is how a paused schedule reads as one that
28
+ never ran.
29
+
30
+ "A distance further out than a day is stated as the date and time instead" was a
31
+ promise with nothing to prove it: the fabricated times stayed inside twelve hours,
32
+ so no fixture could produce the day-scale form. A weekly schedule reaches it, and
33
+ it now has a test.
34
+
35
+ What the page still cannot say is _why_ a schedule will not fire. The platform
36
+ distinguishes six reasons beyond "somebody paused it" — the workflow is off, its
37
+ bundle never built, the expression cannot be read, the zone is not a real one, the
38
+ rhythm can never come round — and the page shows the absence of a next run and the
39
+ live/paused badge, which covers the first and leaves the others looking like it.
40
+
41
+ Review then found four more things a fully mocked test suite could not see, and
42
+ they are user-visible.
43
+
44
+ A schedule carrying no zone of its own showed a blank timezone and could be found
45
+ under none of the zones the filter offers — the platform counts such a row under
46
+ its own default and narrows to it there, so the studio puts that default back
47
+ rather than showing nothing.
48
+
49
+ A run the platform has created and no worker has started now says so, instead of
50
+ reading as a schedule that has never run: when a run _started_ is recorded only
51
+ once something picks it up.
52
+
53
+ A read the platform refuses says so. Until now the page fell through to the words
54
+ a tenant with no schedules gets, which on this page is the opposite state — an
55
+ operator told there are no schedules stops looking, and a tenant whose list merely
56
+ could not be reached is the one where something may be silently not firing.
57
+
58
+ And a date further out than a day is drawn in the schedule's own timezone, so it
59
+ no longer contradicts the zone stated beside it — or the exact moment on its own
60
+ hover — by an hour or two.
61
+
62
+ - 82884b7: The Runs page, its Missed runs tab and the Dashboard read the platform's own
63
+ lists and figures instead of assembling them in the browser.
64
+
65
+ Runs are a per-workflow resource, so the tenant-wide views used to collect them:
66
+ a request per workflow, paged to the end of each one's history — up to a hundred
67
+ pages of a hundred rows apiece — and then ordered, filtered and counted here. A
68
+ tenant's ordering and totals were therefore only ever as complete as that
69
+ collecting, and nothing said how far back the page reached. The same went for the
70
+ missed firings and for the four figures on the landing page, four of which could
71
+ not start until one shared list of workflows had arrived.
72
+
73
+ The platform serves all of it now. One request for the runs, ordered by it; one
74
+ for the counts, taken over the tenant rather than over what happened to load; one
75
+ for the latest run of each workflow, which is what the Workflows list and the
76
+ dashboard's two panels actually want. The dashboard no longer loads a run list at
77
+ all.
78
+
79
+ Three things visibly change rather than merely getting faster:
80
+
81
+ The trigger column and its filter tell runs apart. Every row claimed to have been
82
+ started by hand, because the payload carried nothing about what started it, so
83
+ that menu had one populated option. The platform reports it, so the column is
84
+ true and narrowing by it works.
85
+
86
+ The Workflows list stops being able to say "never run" about a workflow that
87
+ simply ran a while ago. That cell used to pick the newest run out of the tenant's
88
+ whole history held in memory, which only worked while all of it was loaded.
89
+
90
+ The counts are the tenant's. The Missed runs badge, the runs in flight and the
91
+ failed figure are counted by the platform under the same narrowing as the list
92
+ beside them, so a figure and its rows can no longer disagree.
93
+
94
+ What is still bounded is depth: the Runs list reaches the most recent hundred
95
+ runs, and narrowing reaches what is on the page. Paging past that needs the shared
96
+ studio table to be able to report a narrowing back to the page, which it cannot
97
+ yet — that is the remaining half of PO-231.
98
+
99
+ - 59133ea: The canvas says which steps a loop repeats.
100
+
101
+ An iterator draws its two outputs — Item and Done — like any other pair of ports,
102
+ so nothing said that everything hanging off Item runs once per element while
103
+ everything off Done runs once, afterwards. Somebody who had not drawn the workflow
104
+ could not see where the repeating part began or how far it reached.
105
+
106
+ Every step a loop repeats now carries a mark saying so, and naming the loop; a
107
+ step inside two loops names both, outermost first. The loop itself carries the
108
+ number of steps it repeats — including zero, which is what a loop nothing is wired
109
+ to yet looks like. Selecting the loop draws its whole body as part of the
110
+ selection, for as long as the selection lasts — and selecting any step of the body
111
+ does the same from the other end, drawing the loop and everything that repeats
112
+ alongside it. The marks are on all three canvases
113
+ that draw a workflow — the editor, the run replay and the revision preview — and
114
+ they follow a rewiring at once, before anything is saved.
115
+
116
+ What is marked is what the engine will actually repeat, which is not always the
117
+ body as it was drawn: the repeating part has no end anywhere in the workflow, only
118
+ a reach, so a step wired to the last step of the body runs once per element even
119
+ when it was meant to run once afterwards. A loop's Output tab now counts the steps
120
+ it repeats, names them, and says which of them are wired after the loop as well —
121
+ reported rather than refused, because a body that rejoins the main flow can be
122
+ exactly what its author wanted.
123
+
124
+ The connections themselves are unchanged: they carry importance, never meaning,
125
+ and colour on them stays reserved for a run that actually happened.
126
+
127
+ ### Patch Changes
128
+
129
+ - 5385146: A missed run's state is said in the studio's own words.
130
+
131
+ The badge on a missed run showed the state as the platform spells it — `PENDING`,
132
+ `REPLAYED`, `DISCARDED` — while the badge on a run row one tab away said
133
+ "succeeded" or "running" in the operator's language. A German interface therefore
134
+ read English in this one list, and any state the platform starts recording
135
+ tomorrow would have appeared on screen untranslated with nothing to notice it.
136
+
137
+ Both lists that carry the entry — the tenant-wide Missed runs tab and the panel
138
+ inside a workflow's editor — now say the state through the message catalogue: a
139
+ firing still waiting, one replayed, one discarded, in English and in German. A
140
+ state the studio has no word for says exactly that and keeps the platform's own
141
+ value alongside it, so an operator can quote what arrived without the studio
142
+ passing it off as a word somebody chose.
143
+
144
+ The colours of those badges are unchanged, and the two surfaces still tint them
145
+ differently — that is a separate finding, not this one.
146
+
147
+ - 630523f: A missed run's state is drawn the same way on both lists.
148
+
149
+ The same entry was coloured differently depending on where it was opened. Beside
150
+ the tenant's runs it read the cockpit-wide status palette, where a waiting firing
151
+ is tinted like a run that is about to start and the other two states are absent
152
+ altogether — so _replayed_ and _discarded_ were both grey. Inside a workflow the
153
+ panel carried a palette of its own: amber for waiting, green for replayed, grey
154
+ for discarded.
155
+
156
+ Both lists now draw a state from one place: waiting asks for attention, replayed
157
+ reads as finished well, discarded and anything the studio has no word for read as
158
+ over. The colours are the shared palette's, so this is one more vocabulary mapped
159
+ into the studio's tones rather than a second set of colours — and the word and the
160
+ colour for a state now come from a single map, which is what stops the next state
161
+ being added to one and forgotten in the other.
162
+
163
+ Visible change: on the tenant-wide _Missed runs_ tab a waiting firing turns from
164
+ blue to amber and a replayed one from grey to green. The panel inside a workflow
165
+ keeps the colours it had, give or take the shared palette's border and text tints.
166
+
167
+ - 6e1a42e: A run's outcome is drawn the same way wherever it is shown.
168
+
169
+ A run terminated by an operator read _failed_ in a list and _failed_ at its own
170
+ record — in two colours. The word went through the studio's vocabulary, where the
171
+ platform's `terminated` becomes _failed_, and the colour did not: the palette was
172
+ handed the platform's word, which it has no branch for, so the badge fell back to
173
+ the grey a cancelled run wears. Clicking the row took the operator from a red
174
+ badge to a grey one for the same run.
175
+
176
+ The colour is now decided from the same word as the label, everywhere a run's or a
177
+ step's state is drawn: the runs list, the dashboard's latest runs, a run's record,
178
+ its timeline and the replay. Nothing changes on the surfaces that were already
179
+ right — a row's status is mapped before anything draws it — and a run whose state
180
+ the platform reports as `terminated` or `timed_out` now looks like the failure the
181
+ badge says it is.
182
+
183
+ A cancelled run was affected in the same way, in the _word_ rather than the
184
+ colour: the platform spells a cancellation the American way, so a run cancelled
185
+ from its row read _cancelled_ on the row and _canceled_ at its own record — and in
186
+ German the record said it in English. It now reads the studio's word wherever it
187
+ is shown.
188
+
189
+ One of those surfaces was worse in German than in English: the timeline's step
190
+ lightbox derived its colour from the word it had already translated, so a failed
191
+ step was drawn neutral for a German reader and red for an English one. The colour
192
+ is now taken from the status itself.
193
+
194
+ `statusClass` is unchanged and still exports the studio-wide status palette;
195
+ `runStatusClass` is the run-vocabulary door to it.
196
+
197
+ - e69e407: A run is called a Run in German too.
198
+
199
+ The German interface used two words for one object. Three quarters of the
200
+ catalogue said _Run_ — the sidebar item, the editor's Runs tab, the Run button, the
201
+ breadcrumb, every audit entry — while 29 values said _Ausführung_, including the
202
+ tab sitting directly beside the one that said _Runs_, and two sentences that used
203
+ both words in the same breath ("Noch keine Ausführungen — im Editor auf Run
204
+ klicken").
205
+
206
+ Those 29 now say _Run_: the editor's _Verpasste Runs_ tab, the dashboard's _Letzte
207
+ Runs_ panel and its missed-run count, the schedules table's _Letzter Run_ and
208
+ _Nächster Run_, and the toasts and hints around starting, retrying and replaying
209
+ one.
210
+
211
+ _Ausführung_ stays where it names the act rather than the object —
212
+ _Ausführungsmodus_, _Ausführungsverhalten_, _Ausführungsverlauf_,
213
+ _Hintergrundausführung_, and the settings section _Ausführung_. Their English says
214
+ _Execution_ too, which is the test for the next one: if the English says _run_, the
215
+ German says _Run_. The rule is now written down in `docs/interface-language.md`
216
+ rather than living in one catalogue entry's description.
217
+
218
+ No English copy changed, and no key was added or removed.
219
+
3
220
  ## 1.0.0
4
221
 
5
222
  ### 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.0",
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">
@@ -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">
@@ -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_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 },
@@ -99,7 +99,7 @@ const columns = computed(() => [
99
99
  @row-click="(r) => openRun(r)"
100
100
  >
101
101
  <template #cell-status="{ row: run }">
102
- <Badge variant="outline" :class="['border text-xs font-medium uppercase', statusClass(run.status)]">
102
+ <Badge variant="outline" :class="['border text-xs font-medium uppercase', runStatusClass(run.status)]">
103
103
  {{ runStatusLabel(run.status) }}
104
104
  </Badge>
105
105
  </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 {
@@ -9,6 +9,19 @@ interface NodeData extends WorkflowNode {
9
9
  * injected by the editor the same non-persisting way. Read-only canvases
10
10
  * (run replay, history preview) never pass them, so nothing is marked there. */
11
11
  __invalidFields?: string[];
12
+ /**
13
+ * Where this node stands with respect to an iterator's loop body (PO-277),
14
+ * computed per sync from the blob's edges (`data/loopBody.ts`) and injected
15
+ * like the two above. Unlike them it reaches ALL THREE canvases: what repeats
16
+ * is a property of the workflow rather than of editing it, and a run replay is
17
+ * where the repetition is most real.
18
+ */
19
+ __loop?: {
20
+ /** The iterators whose body this node is in, outermost first, by label. */
21
+ inside?: string[];
22
+ /** Set on an iterator itself: how many nodes hang off its `item` port. */
23
+ bodySize?: number;
24
+ };
12
25
  }
13
26
  type __VLS_Props = {
14
27
  id: string;
@@ -3,7 +3,7 @@ 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
- import { Copy, DangerTriangle as AlertTriangle, RefreshCircle as Loader2, TrashBinMinimalistic as TrashBin } from "#solar-icons";
6
+ import { Copy, DangerTriangle as AlertTriangle, RefreshCircle as Loader2, Repeat, TrashBinMinimalistic as TrashBin } from "#solar-icons";
7
7
  import { NODE_CATEGORIES, findManifest, isVersionExecutable } from "../../data/integrationNodeCatalog";
8
8
  import { isTerminalManifest } from "../../data/nodeInputData";
9
9
  import { resolveDisplayName, resolveLocalized } from "../../utils/localize";
@@ -22,6 +22,12 @@ const title = computed(
22
22
  );
23
23
  const invalidFields = computed(() => props.data.__invalidFields ?? []);
24
24
  const invalidLabel = computed(() => t("integration.canvas.node.badge.invalidConfig", invalidFields.value.length, { fields: invalidFields.value.join(", ") }));
25
+ const insideLoops = computed(() => props.data.__loop?.inside ?? []);
26
+ const repeatsLabel = computed(() => t("integration.canvas.node.badge.repeats", insideLoops.value.length, { iterators: insideLoops.value.join(", ") }));
27
+ const bodySize = computed(() => props.data.__loop?.bodySize);
28
+ const bodySizeLabel = computed(() => t("integration.canvas.node.badge.loopBody", bodySize.value ?? 0));
29
+ const loopHighlight = inject("workflow-loop-highlight", null);
30
+ const inSelectedLoop = computed(() => !!loopHighlight?.value.has(props.id));
25
31
  const notes = computed(() => (props.data.notes ?? "").trim());
26
32
  const noteOpen = ref(false);
27
33
  const noteReady = ref(false);
@@ -264,7 +270,7 @@ function remove() {
264
270
  @mouseenter="onHoverEnter"
265
271
  @mouseleave="onHoverLeave"
266
272
  :class="[
267
- 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]',
273
+ 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' : inSelectedLoop ? 'border-slate-400/80 shadow-[0_0_0_2px_rgba(100,116,139,0.20)]' : 'border-black/[0.08] shadow-sm hover:shadow-md hover:border-black/[0.16]',
268
274
  showPortLabels ? 'pb-3.5' : ''
269
275
  ]"
270
276
  >
@@ -275,7 +281,7 @@ function remove() {
275
281
  a bare count at a corner is a riddle, so each carries its own title;
276
282
  `nodrag` keeps that hover from starting a card drag. -->
277
283
  <span
278
- v-if="invalidFields.length || unbuildableVersion"
284
+ v-if="invalidFields.length || unbuildableVersion || insideLoops.length || bodySize !== void 0"
279
285
  class="nodrag absolute -left-2 -top-2 z-10 flex items-center gap-1"
280
286
  >
281
287
  <span
@@ -305,6 +311,35 @@ function remove() {
305
311
  >
306
312
  <AlertTriangle :size="9" weight="Bold" />
307
313
  </span>
314
+ <!-- Repetition (PO-277). Slate, not amber or rose: a loop is how the
315
+ workflow is built, not something wrong with it. The glyph is the
316
+ mark; the count next to it is the depth, printed from 2 up because
317
+ "repeats" is the news at one loop and "repeats twice over" is the
318
+ news above it. Both marks carry their sentence as their accessible
319
+ name — a bare arrow at a corner is a riddle. -->
320
+ <span
321
+ v-if="insideLoops.length"
322
+ role="img"
323
+ :aria-label="repeatsLabel"
324
+ :title="repeatsLabel"
325
+ class="flex cursor-help items-center gap-0.5 rounded-full bg-slate-600 px-1.5 py-0.5 text-[9px] font-bold leading-none text-white shadow-sm"
326
+ >
327
+ <Repeat :size="9" weight="Bold" />
328
+ <template v-if="insideLoops.length > 1">{{ insideLoops.length }}</template>
329
+ </span>
330
+ <!-- The loop itself, and how much of the canvas it repeats. Outlined
331
+ rather than filled: it names the same fact from the other end, and two
332
+ solid slate pills side by side on a nested iterator would read as one. -->
333
+ <span
334
+ v-if="bodySize !== void 0"
335
+ role="img"
336
+ :aria-label="bodySizeLabel"
337
+ :title="bodySizeLabel"
338
+ class="flex cursor-help items-center gap-0.5 rounded-full border border-slate-300 bg-white px-1.5 py-0.5 text-[9px] font-bold leading-none text-slate-600 shadow-sm"
339
+ >
340
+ <Repeat :size="9" weight="Bold" />
341
+ {{ bodySize }}
342
+ </span>
308
343
  </span>
309
344
  <!-- Always-visible edit affordance: single click opens the config lightbox.
310
345
  (Double-click on the whole card works too.) Hidden in read-only hosts
@@ -9,6 +9,19 @@ interface NodeData extends WorkflowNode {
9
9
  * injected by the editor the same non-persisting way. Read-only canvases
10
10
  * (run replay, history preview) never pass them, so nothing is marked there. */
11
11
  __invalidFields?: string[];
12
+ /**
13
+ * Where this node stands with respect to an iterator's loop body (PO-277),
14
+ * computed per sync from the blob's edges (`data/loopBody.ts`) and injected
15
+ * like the two above. Unlike them it reaches ALL THREE canvases: what repeats
16
+ * is a property of the workflow rather than of editing it, and a run replay is
17
+ * where the repetition is most real.
18
+ */
19
+ __loop?: {
20
+ /** The iterators whose body this node is in, outermost first, by label. */
21
+ inside?: string[];
22
+ /** Set on an iterator itself: how many nodes hang off its `item` port. */
23
+ bodySize?: number;
24
+ };
12
25
  }
13
26
  type __VLS_Props = {
14
27
  id: string;