@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
@@ -71,6 +71,8 @@
71
71
  "integration.canvas.node.action.duplicateAria": "Duplicate node",
72
72
  "integration.canvas.node.badge.invalidConfig": "Config incomplete — {n} setting still needs fixing: {fields} | Config incomplete — {n} settings still need fixing: {fields}",
73
73
  "integration.canvas.node.badge.noBuiltCode": "Version {version} is not ready to use — this step cannot be tested",
74
+ "integration.canvas.node.badge.repeats": "Repeats — runs once per element of {iterators} | Repeats — runs once per element of each of the {n} loops it sits inside: {iterators}",
75
+ "integration.canvas.node.badge.loopBody": "Loop — nothing is wired to Item yet, so nothing repeats | Loop — repeats the one step wired to Item, once per element | Loop — repeats the {n} steps wired to Item, once per element",
74
76
  "integration.canvas.node.handle.input": "Input",
75
77
  "integration.canvas.node.meta.versionTitle": "Version {version}",
76
78
  "integration.canvas.node.note.heading": "Note",
@@ -298,12 +300,17 @@
298
300
  "integration.deadLetters.panel.title": "Missed runs",
299
301
  "integration.deadLetters.reason.fallback": "No reason recorded.",
300
302
  "integration.deadLetters.state.empty": "No missed runs. Every trigger firing made it into a run.",
303
+ "integration.deadLetters.status.discarded": "discarded",
304
+ "integration.deadLetters.status.pending": "waiting",
305
+ "integration.deadLetters.status.replayed": "replayed",
306
+ "integration.deadLetters.status.unknown": "unknown ({status})",
301
307
  "integration.deadLetters.tabs.missed.label": "Missed runs",
302
308
  "integration.deadLetters.toast.discard.done": "Discarded",
303
309
  "integration.deadLetters.toast.discard.error": "Could not discard.",
304
310
  "integration.deadLetters.toast.replay.done": "Replayed",
305
311
  "integration.deadLetters.toast.replay.error": "Could not replay.",
306
312
  "integration.deadLetters.toast.replay.started": "Replayed — started run #{id}",
313
+ "integration.deadLetters.workflow.fallback": "Workflow #{id}",
307
314
  "integration.editor.action.activate.title": "Activate this workflow",
308
315
  "integration.editor.action.activate.title.invalid": "Fix {n} node setting before activating | Fix {n} node settings before activating",
309
316
  "integration.editor.action.backToWorkflow": "Back to Workflow",
@@ -513,6 +520,7 @@
513
520
  "integration.nodeConfig.dynamicSchema.resolving": "Resolving fields…",
514
521
  "integration.nodeConfig.expression.badge.title": "Accepts ${{ … }} expressions",
515
522
  "integration.nodeConfig.expression.insertTitle": "Insert expression",
523
+ "integration.nodeConfig.expression.insertAria": "Insert expression — available data for this setting",
516
524
  "integration.nodeConfig.expression.picker.aria": "Available data for this setting",
517
525
  "integration.nodeConfig.expression.picker.empty": "Nothing matches “{query}”.",
518
526
  "integration.nodeConfig.expression.picker.filterAria": "Filter available data",
@@ -622,6 +630,10 @@
622
630
  "integration.nodeInspector.notice.versionNotReady.none": "Version {version} of this node is no longer usable, and no version of it is. This step cannot be tested, and the choice lists in its settings stay empty.",
623
631
  "integration.nodeInspector.notice.versionNotReady.upgrade": "Version {version} of this node is no longer usable. To keep using this step, it has to be upgraded to version {ready}.",
624
632
  "integration.nodeInspector.output.description": "What downstream nodes receive from this one. Declared by the node type; filled with real values once the step has run.",
633
+ "integration.nodeInspector.output.loopBody.heading": "The loop body",
634
+ "integration.nodeInspector.output.loopBody.size": "Nothing is wired to Item yet, so this loop repeats nothing. | One step is wired to Item and runs once per element. | {n} steps are wired to Item and each runs once per element.",
635
+ "integration.nodeInspector.output.loopBody.names": "These repeat: {names}",
636
+ "integration.nodeInspector.output.loopBody.joined": "One of them is wired after the loop as well, on the Done side — so it repeats with the body rather than running once: {names} | {n} of them are wired after the loop as well, on the Done side — so they repeat with the body rather than running once: {names}",
625
637
  "integration.nodeInspector.output.pin.editAction": "Edit pinned sample",
626
638
  "integration.nodeInspector.output.pin.editorAria": "Pinned sample output",
627
639
  "integration.nodeInspector.output.pin.hint": "A pinned sample stands in for run data while you map downstream fields.",
@@ -948,6 +960,7 @@
948
960
  "integration.schedules.cron.weekday.tuesday": "Tuesdays",
949
961
  "integration.schedules.cron.weekday.wednesday": "Wednesdays",
950
962
  "integration.schedules.cron.weeklyAt": "{days} at {time}",
963
+ "integration.schedules.error.loadFailed": "Schedules could not be loaded.",
951
964
  "integration.schedules.head.title": "Schedules · Integration Studio",
952
965
  "integration.schedules.header.description": "Every scheduled trigger across your workflows. Click a row to open the workflow.",
953
966
  "integration.schedules.header.title": "Schedules",
@@ -6,38 +6,38 @@ import WorkflowCreateDialog from "../../components/Integrations/WorkflowCreateDi
6
6
  import { Pulse as Activity, DangerTriangle as AlertTriangle, ArrowRight, Box as Boxes, ClockCircle as Clock, Database, Layers, AddCircle as Plus, Radio, MagicStick as Sparkles, LinkRound as Webhook, Programming as Workflow } from "#solar-icons";
7
7
  import { Badge, Button } from "@revenexx/studio";
8
8
  import { servedPaths } from "../../utils/servedPaths";
9
- import { runStatusLabel, statusClass } from "../../utils/statusStyle";
9
+ import { RUN_STATUS_COLUMN_WIDTH, runStatusClass, runStatusLabel } from "../../utils/statusStyle";
10
+ const LATEST_RUN_COLUMNS = `${RUN_STATUS_COLUMN_WIDTH} minmax(0,1fr) 100px 70px`;
10
11
  const t = useIntegrationsT();
11
12
  useHead({ title: () => t("integration.dashboard.head.title") });
12
13
  const { workflows, activeCount: activeWorkflows, loading: workflowsLoading, load: loadWorkflows } = useIntegrationsWorkflows();
13
- const { runs, runningCount, failedCount, loading: runsLoading, load: loadRuns } = useIntegrationsRuns();
14
+ const {
15
+ latestPerWorkflow: latestRunPerWorkflow,
16
+ runningCount,
17
+ latestLoading: runsLoading,
18
+ loadSummary: loadRunSummary,
19
+ loadLatestPerWorkflow
20
+ } = useIntegrationsRuns();
14
21
  const { startingPoints, loading: templatesLoading, load: loadTemplates, ensureDetails } = useIntegrationsTemplates();
15
22
  watch(() => startingPoints.value.map((t2) => t2.slug).join(","), () => {
16
23
  if (startingPoints.value.length) void ensureDetails(startingPoints.value.map((t2) => t2.slug));
17
24
  }, { immediate: true });
18
25
  const { activeCount: schedulesActive, load: loadSchedules } = useIntegrationsSchedules();
19
26
  const { pendingCount: missedRuns, globalLoading: missedLoading, loadGlobal: loadMissedRuns } = useIntegrationsDeadLetters();
20
- const kpisLoading = computed(() => workflowsLoading.value && !workflows.value.length || runsLoading.value && !runs.value.length);
21
- const latestLoading = computed(() => runsLoading.value && !runs.value.length);
27
+ const kpisLoading = computed(() => workflowsLoading.value && !workflows.value.length || runsLoading.value && !latestRunPerWorkflow.value.length);
28
+ const latestLoading = computed(() => runsLoading.value && !latestRunPerWorkflow.value.length);
22
29
  const createOpen = ref(false);
23
30
  const NuxtLinkComp = resolveComponent("NuxtLink");
24
31
  onMounted(() => {
25
32
  void loadWorkflows();
26
- void loadRuns();
33
+ void loadRunSummary();
34
+ void loadLatestPerWorkflow();
27
35
  void loadTemplates();
28
36
  void loadSchedules();
29
- void loadMissedRuns();
30
- });
31
- const latestByWorkflow = computed(() => {
32
- const seen = /* @__PURE__ */ new Set();
33
- const out = [];
34
- for (const r of runs.value) {
35
- if (seen.has(r.workflowId)) continue;
36
- seen.add(r.workflowId);
37
- out.push(r);
38
- }
39
- return out;
37
+ void loadMissedRuns().catch(() => {
38
+ });
40
39
  });
40
+ const latestByWorkflow = computed(() => latestRunPerWorkflow.value);
41
41
  const latestPerWorkflow = computed(() => latestByWorkflow.value.slice(0, 10));
42
42
  const failedRecent = computed(() => latestByWorkflow.value.filter((r) => r.status === "failed").slice(0, 3));
43
43
  function formatRelative(iso) {
@@ -178,9 +178,18 @@ function surfaceCards() {
178
178
  v-for="r in latestPerWorkflow"
179
179
  :key="r.id"
180
180
  :to="{ path: `/integrations/workflows/${r.workflowId}`, query: { run: r.id } }"
181
- class="grid grid-cols-[90px_minmax(0,1fr)_100px_70px] items-center gap-2 border-b border-black/[0.04] px-3 py-2 text-sm last:border-b-0 hover:bg-muted/30"
181
+ class="grid items-center gap-2 border-b border-black/[0.04] px-3 py-2 text-sm last:border-b-0 hover:bg-muted/30"
182
+ :style="{ gridTemplateColumns: LATEST_RUN_COLUMNS }"
182
183
  >
183
- <Badge variant="outline" :class="['border text-xs font-medium uppercase', statusClass(r.status)]">
184
+ <!-- The runs list's own track, not a second guess: this panel was the
185
+ narrower of the two at 90px, so three German words ran out of it,
186
+ "erfolgreich" included (PO-362). `max-w-full` is the net behind it,
187
+ and it clips rather than ellipsing — see the runs list. -->
188
+ <Badge
189
+ variant="outline"
190
+ :title="runStatusLabel(r.status)"
191
+ :class="['border text-xs font-medium uppercase max-w-full', runStatusClass(r.status)]"
192
+ >
184
193
  {{ runStatusLabel(r.status) }}
185
194
  </Badge>
186
195
  <div class="truncate">
@@ -198,7 +207,8 @@ function surfaceCards() {
198
207
  <div
199
208
  v-for="n in 6"
200
209
  :key="`sk-run-${n}`"
201
- class="grid grid-cols-[90px_minmax(0,1fr)_100px_70px] items-center gap-2 border-b border-black/[0.04] px-3 py-2.5 last:border-b-0"
210
+ class="grid items-center gap-2 border-b border-black/[0.04] px-3 py-2.5 last:border-b-0"
211
+ :style="{ gridTemplateColumns: LATEST_RUN_COLUMNS }"
202
212
  >
203
213
  <div class="h-5 w-16 rounded bg-black/[0.07] animate-pulse" />
204
214
  <div class="space-y-1.5">
@@ -4,7 +4,7 @@ import { Badge, Button } from "@revenexx/studio";
4
4
  import { toast } from "vue-sonner";
5
5
  import RunsTable from "../../components/Integrations/RunsTable.vue";
6
6
  import { createPollBudget, SAFETY_POLL_MS } from "../../utils/pollBudget";
7
- import { statusClass } from "../../utils/statusStyle";
7
+ import { deadLetterStatusClass, deadLetterStatusLabel } from "../../utils/statusStyle";
8
8
  const t = useIntegrationsT();
9
9
  useHead({ title: () => t("integration.runs.list.head.title") });
10
10
  const route = useRoute();
@@ -37,7 +37,7 @@ watch([runTick, workflowTick], () => {
37
37
  let pollTimer = null;
38
38
  onMounted(() => {
39
39
  void load(true);
40
- void loadGlobal(true);
40
+ void reloadMissedRuns();
41
41
  pollTimer = setInterval(() => {
42
42
  if (hasActiveRuns.value && !loading.value && budget.due(delivering.value)) void load(true);
43
43
  }, POLL_MS);
@@ -49,31 +49,43 @@ onUnmounted(() => {
49
49
  }
50
50
  });
51
51
  const busyId = ref(null);
52
+ async function reloadMissedRuns() {
53
+ try {
54
+ await loadGlobal(true);
55
+ } catch {
56
+ }
57
+ }
52
58
  async function onReplay(dl) {
53
59
  busyId.value = dl.id;
54
60
  try {
55
61
  const res = await replay(dl.id);
56
62
  const newId = res && typeof res === "object" && "id" in res ? String(res.id) : null;
57
63
  toast.success(newId ? t("integration.deadLetters.toast.replay.started", { id: newId }) : t("integration.deadLetters.toast.replay.done"));
58
- await loadGlobal(true);
59
- reload();
60
64
  } catch (e) {
61
65
  toast.error(e?.message ?? t("integration.deadLetters.toast.replay.error"));
62
- } finally {
63
66
  busyId.value = null;
67
+ return;
64
68
  }
69
+ await reloadMissedRuns();
70
+ reload();
71
+ busyId.value = null;
65
72
  }
66
73
  async function onDiscard(dl) {
67
74
  busyId.value = dl.id;
68
75
  try {
69
76
  await discard(dl.id);
70
77
  toast.success(t("integration.deadLetters.toast.discard.done"));
71
- await loadGlobal(true);
72
78
  } catch (e) {
73
79
  toast.error(e?.message ?? t("integration.deadLetters.toast.discard.error"));
74
- } finally {
75
80
  busyId.value = null;
81
+ return;
76
82
  }
83
+ await reloadMissedRuns();
84
+ busyId.value = null;
85
+ }
86
+ function workflowNameOf(dl) {
87
+ const name = dl.workflow_name;
88
+ return typeof name === "string" && name.trim() !== "" ? name : t("integration.deadLetters.workflow.fallback", { id: dl.workflow_id });
77
89
  }
78
90
  function fmt(iso) {
79
91
  if (!iso) return "\u2014";
@@ -104,7 +116,7 @@ function fmt(iso) {
104
116
  <p class="text-sm text-muted-foreground">
105
117
  {{ t("integration.deadLetters.list.description") }}
106
118
  </p>
107
- <Button variant="outline" size="sm" class="h-7 text-sm" :disabled="missedLoading" @click="() => loadGlobal(true)">
119
+ <Button variant="outline" size="sm" class="h-7 text-sm" :disabled="missedLoading" @click="() => reloadMissedRuns()">
108
120
  <Loader2 v-if="missedLoading" :size="12" class="animate-spin" />
109
121
  {{ t("integration.common.action.refresh") }}
110
122
  </Button>
@@ -130,10 +142,10 @@ function fmt(iso) {
130
142
  :key="dl.id"
131
143
  class="flex items-start gap-3 px-3 py-2.5"
132
144
  >
133
- <Badge variant="outline" :class="['border text-xs font-medium uppercase shrink-0 mt-0.5', statusClass(dl.status)]">{{ dl.status }}</Badge>
145
+ <Badge variant="outline" :class="['border text-xs font-medium uppercase shrink-0 mt-0.5', deadLetterStatusClass(dl.status)]">{{ deadLetterStatusLabel(dl.status) }}</Badge>
134
146
  <div class="min-w-0 flex-1">
135
147
  <NuxtLink :to="`/integrations/workflows/${dl.workflow_id}`" class="text-sm font-medium truncate transition-colors hover:text-primary">
136
- {{ dl.workflowName }}
148
+ {{ workflowNameOf(dl) }}
137
149
  </NuxtLink>
138
150
  <div class="text-sm break-words">{{ dl.reason || t("integration.deadLetters.reason.fallback") }}</div>
139
151
  <div class="mt-0.5 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs text-muted-foreground font-mono">
@@ -2,14 +2,23 @@
2
2
  import { ClockCircle as Clock, Globus as Globe } from "#solar-icons";
3
3
  import { Badge } from "@revenexx/studio";
4
4
  import { cadenceRank, describeCron } from "../../utils/schedule";
5
+ import { runStatusLabel } from "../../utils/statusStyle";
5
6
  const t = useIntegrationsT();
6
7
  useHead({ title: () => t("integration.schedules.head.title") });
7
8
  const router = useRouter();
8
- const { entries, activeCount, loading, load } = useIntegrationsSchedules();
9
+ const { entries, activeCount, timezonesInUse, loading, error, load } = useIntegrationsSchedules();
9
10
  onMounted(() => {
10
11
  void load(true);
11
12
  });
12
- function formatRelative(iso) {
13
+ function inScheduleZone(iso, timezone) {
14
+ const parts = { month: "short", day: "2-digit", hour: "2-digit", minute: "2-digit" };
15
+ try {
16
+ return new Date(iso).toLocaleString(void 0, { ...parts, timeZone: timezone });
17
+ } catch {
18
+ return new Date(iso).toLocaleString(void 0, parts);
19
+ }
20
+ }
21
+ function formatRelative(iso, timezone) {
13
22
  if (!iso) return "\u2014";
14
23
  const ms = new Date(iso).getTime() - Date.now();
15
24
  if (Math.abs(ms) < 6e4) return ms > 0 ? t("integration.schedules.time.inLessThanMinute") : t("integration.common.time.justNow");
@@ -21,7 +30,11 @@ function formatRelative(iso) {
21
30
  const hrs = Math.round(Math.abs(ms) / 36e5);
22
31
  return ms > 0 ? t("integration.schedules.time.inHours", hrs) : t("integration.common.time.hoursAgo", hrs);
23
32
  }
24
- return new Date(iso).toLocaleString(void 0, { month: "short", day: "2-digit", hour: "2-digit", minute: "2-digit" });
33
+ return inScheduleZone(iso, timezone);
34
+ }
35
+ function lastRunText(e) {
36
+ if (e.lastRunAt) return formatRelative(e.lastRunAt, e.timezone);
37
+ return e.lastRunStatus ? runStatusLabel(e.lastRunStatus) : "\u2014";
25
38
  }
26
39
  const filterTabs = computed(() => [
27
40
  {
@@ -40,7 +53,10 @@ const filterTabs = computed(() => [
40
53
  key: "timezone",
41
54
  label: t("integration.schedules.table.column.timezone"),
42
55
  getter: (e) => e.timezone,
43
- options: [...new Set(entries.value.map((e) => e.timezone))].sort().map((tz) => ({ value: tz, label: tz }))
56
+ // The zones the platform reports as in use across the tenant, not the ones
57
+ // visible in the rows: with the list narrowed, deriving them from the rows
58
+ // would shrink the menu to what the previous choice left standing.
59
+ options: timezonesInUse.value.map((tz) => ({ value: tz, label: tz }))
44
60
  }
45
61
  ]);
46
62
  const columns = computed(() => [
@@ -76,7 +92,25 @@ const columns = computed(() => [
76
92
  </template>
77
93
  </PageHeader>
78
94
 
95
+ <!--
96
+ A refused read says so. Without this the page fell through to the table's
97
+ own empty text, so a 503 and a tenant with no schedules were one screen —
98
+ and the message it needed was already written and translated
99
+ (`specs/schedules.md` AC-13). An alert rather than a bare div, so it is
100
+ announced and not only visible.
101
+ -->
102
+ <div v-if="error" role="alert" class="m-4 rounded-md border border-rose-500/20 bg-rose-500/5 px-3 py-2 text-sm text-rose-700">
103
+ {{ error }}
104
+ </div>
105
+
79
106
  <div class="p-4">
107
+ <!--
108
+ The empty text answers the error too, so the table cannot go on saying
109
+ "No schedules yet" underneath a banner explaining that there was no
110
+ answer to count. Two renderings of one fact rather than two facts: the
111
+ banner carries the platform's own words, which may be specific, and this
112
+ is why the list is blank.
113
+ -->
80
114
  <DataTable
81
115
  :loading="loading"
82
116
  :data="entries"
@@ -85,7 +119,7 @@ const columns = computed(() => [
85
119
  :default-sort="{ column: 'nextRun', direction: 'asc' }"
86
120
  :search="{ placeholder: t('integration.schedules.search.placeholder') }"
87
121
  :filter-tabs="filterTabs"
88
- :empty-text="t('integration.schedules.state.empty')"
122
+ :empty-text="error ? t('integration.schedules.error.loadFailed') : t('integration.schedules.state.empty')"
89
123
  @row-click="(e2) => router.push(`/integrations/workflows/${e2.workflowId}`)"
90
124
  >
91
125
  <template #cell-workflow="{ row: e }">
@@ -143,12 +177,35 @@ const columns = computed(() => [
143
177
  semantics in the shared table (PO-233, and a gap in
144
178
  `specs/accessibility.md`).
145
179
  -->
180
+ <!--
181
+ The instant the platform holds, shown as a distance from now, with the
182
+ moment in the schedule's own zone as its title: "in 6d" answers when,
183
+ and "2026-08-24T08:30:00+02:00" answers which side of a clock change it
184
+ falls on. Neither is worked out here — a browser doing summer-time
185
+ arithmetic that a wrong answer makes worse than no answer is exactly
186
+ what this replaced.
187
+ -->
146
188
  <template #cell-nextRun="{ row: e }">
147
- <span :aria-label="t('integration.schedules.table.column.nextRun')" class="text-sm tabular-nums">{{ formatRelative(e.nextRunAt) }}</span>
189
+ <span
190
+ :aria-label="t('integration.schedules.table.column.nextRun')"
191
+ :title="e.nextRunLocal ?? void 0"
192
+ class="text-sm tabular-nums"
193
+ >{{ formatRelative(e.nextRunAt, e.timezone) }}</span>
148
194
  </template>
149
195
 
196
+ <!--
197
+ The last run carries a title too, and it is the instant rather than a
198
+ zone reading: the platform reports no local form for a run the way it
199
+ does for a firing, and inventing one here is what this page stopped
200
+ doing. Without it a run further back than a day was readable to the
201
+ minute and to no greater precision than the cell's own text.
202
+ -->
150
203
  <template #cell-lastRun="{ row: e }">
151
- <span :aria-label="t('integration.schedules.table.column.lastRun')" class="text-sm tabular-nums text-muted-foreground">{{ formatRelative(e.lastRunAt) }}</span>
204
+ <span
205
+ :aria-label="t('integration.schedules.table.column.lastRun')"
206
+ :title="e.lastRunAt ?? void 0"
207
+ class="text-sm tabular-nums text-muted-foreground"
208
+ >{{ lastRunText(e) }}</span>
152
209
  </template>
153
210
 
154
211
  <template #cell-status="{ row: e }">
@@ -397,6 +397,9 @@ const runsError = ref(null);
397
397
  const runsPage = ref(1);
398
398
  const runsTotal = ref(0);
399
399
  const runsLastPage = ref(1);
400
+ const runsWanted = ref(1);
401
+ let runsSeq = 0;
402
+ let runsLoadingSeq = 0;
400
403
  const RUNS_POLL_MS = 3e3;
401
404
  const { delivering: realtimeDelivering, runTick, lastRunEvent, buildTick, lastBuildEvent } = useIntegrationsRealtime();
402
405
  const runsBudget = createPollBudget({ intervalMs: RUNS_POLL_MS, liveIntervalMs: SAFETY_POLL_MS });
@@ -410,12 +413,14 @@ function stopRunsPoll() {
410
413
  function startRunsPoll() {
411
414
  stopRunsPoll();
412
415
  runsPoll = setInterval(() => {
413
- if (runsPage.value === 1 && runsBudget.due(realtimeDelivering.value)) void loadRuns(1, { silent: true });
416
+ if (!runsLoading.value && runsWanted.value === 1 && runsBudget.due(realtimeDelivering.value)) {
417
+ void loadRuns(1, { silent: true });
418
+ }
414
419
  }, RUNS_POLL_MS);
415
420
  }
416
421
  onUnmounted(stopRunsPoll);
417
422
  watch(runTick, () => {
418
- if (tab.value !== "runs" || runsPage.value !== 1) return;
423
+ if (tab.value !== "runs" || runsWanted.value !== 1 || runsLoading.value) return;
419
424
  const eventWorkflow = workflowIdOf(lastRunEvent.value);
420
425
  if (eventWorkflow && eventWorkflow !== String(workflow.value?.id ?? "")) return;
421
426
  void loadRuns(1, { silent: true });
@@ -426,10 +431,15 @@ watch(buildTick, () => {
426
431
  if (eventWorkflow && eventWorkflow !== String(workflow.value.id)) return;
427
432
  void get(workflow.value.id);
428
433
  });
429
- async function loadRuns(page = runsPage.value, opts = {}) {
434
+ async function loadRuns(page = runsWanted.value, opts = {}) {
430
435
  if (!workflow.value) return;
436
+ runsWanted.value = page;
437
+ const seq = ++runsSeq;
431
438
  runsBudget.spend();
432
- if (!opts.silent) runsLoading.value = true;
439
+ if (!opts.silent) {
440
+ runsLoading.value = true;
441
+ runsLoadingSeq = seq;
442
+ }
433
443
  runsError.value = null;
434
444
  try {
435
445
  const res = await loadForWorkflow(workflow.value.id, workflow.value.name, {
@@ -437,19 +447,23 @@ async function loadRuns(page = runsPage.value, opts = {}) {
437
447
  page,
438
448
  perPage: RUNS_PER_PAGE
439
449
  });
450
+ if (seq !== runsSeq) return;
440
451
  wfRuns.value = res.runs;
441
452
  runsTotal.value = res.total;
442
453
  runsLastPage.value = res.lastPage;
443
454
  runsPage.value = res.page;
455
+ runsWanted.value = res.page;
444
456
  } catch (e) {
457
+ if (seq !== runsSeq) return;
445
458
  runsError.value = e.message || t("integration.editor.runs.error.load");
459
+ runsWanted.value = runsPage.value;
446
460
  } finally {
447
- if (!opts.silent) runsLoading.value = false;
461
+ if (!opts.silent && seq === runsLoadingSeq) runsLoading.value = false;
448
462
  }
449
463
  }
450
464
  function goRunsPage(p) {
451
465
  const next = Math.min(Math.max(1, p), runsLastPage.value);
452
- if (next !== runsPage.value) void loadRuns(next);
466
+ if (next !== runsWanted.value) void loadRuns(next);
453
467
  }
454
468
  watch(tab, (t2) => {
455
469
  if (t2 === "runs") {
@@ -912,10 +926,10 @@ function formatDateTime(iso) {
912
926
  <div v-if="runsLastPage > 1 || runsTotal > wfRuns.length" class="flex items-center justify-between text-sm text-muted-foreground">
913
927
  <span class="tabular-nums">{{ t("integration.editor.runs.pagination.page", { page: runsPage, lastPage: runsLastPage }) }} · {{ t("integration.editor.runs.pagination.count", runsTotal) }}</span>
914
928
  <div class="flex gap-2">
915
- <Button variant="outline" size="sm" class="h-7 text-sm" :disabled="runsPage <= 1 || runsLoading" @click="goRunsPage(runsPage - 1)">
929
+ <Button variant="outline" size="sm" class="h-7 text-sm" :disabled="runsWanted <= 1 || runsLoading" @click="goRunsPage(runsWanted - 1)">
916
930
  {{ t("integration.editor.runs.pagination.newer") }}
917
931
  </Button>
918
- <Button variant="outline" size="sm" class="h-7 text-sm" :disabled="runsPage >= runsLastPage || runsLoading" @click="goRunsPage(runsPage + 1)">
932
+ <Button variant="outline" size="sm" class="h-7 text-sm" :disabled="runsWanted >= runsLastPage || runsLoading" @click="goRunsPage(runsWanted + 1)">
919
933
  {{ t("integration.editor.runs.pagination.older") }}
920
934
  </Button>
921
935
  </div>
@@ -339,6 +339,18 @@ export interface NodeManifest {
339
339
  /** Empty marks a terminal node (PO-201): the run ends there, so the canvas
340
340
  * draws no output handle and no edge may leave it. */
341
341
  outputs: NodeManifestOutput[];
342
+ /**
343
+ * When true, the node runs whatever is wired to its `item` output once per
344
+ * element, and its `done` output once afterwards — so the canvas marks that
345
+ * part as repeating (PO-277, `specs/editor-loop-body.md`).
346
+ *
347
+ * **Nothing publishes it yet** (later phase; harmless now, like
348
+ * `resolveOutputs` above). The worker derives the same fact at bundle-build
349
+ * time by duck-typing the implementation, and no field of `GET /nodes` carries
350
+ * it, so until one does the editor falls back to the published iterator's slug
351
+ * — `ITERATOR_SLUGS` in `data/loopBody.ts`, which this field is here to retire.
352
+ */
353
+ iteration?: boolean;
342
354
  config?: ConfigField[];
343
355
  /** Source package (editor-side enrichment from GET /nodes; not manifest data). */
344
356
  __package?: {
@@ -453,6 +465,12 @@ export interface WebhookEntry {
453
465
  lastFiredAt: string | null;
454
466
  callCount24h: number;
455
467
  }
468
+ /**
469
+ * Why a schedule holds no next firing. Mirrors the conditions under which the
470
+ * platform's scheduler would never select it, so a paused schedule and one whose
471
+ * expression nobody can read stop reading the same way.
472
+ */
473
+ export type ScheduleNotFiringReason = 'trigger_paused' | 'workflow_inactive' | 'workflow_not_ready' | 'no_cron' | 'invalid_cron' | 'unknown_timezone' | 'unsatisfiable';
456
474
  export interface ScheduleEntry {
457
475
  id: string;
458
476
  workflowId: number;
@@ -462,8 +480,29 @@ export interface ScheduleEntry {
462
480
  cron: string | null;
463
481
  timezone: string;
464
482
  active: boolean;
483
+ /** Whether the workflow the schedule belongs to is active. */
484
+ workflowActive: boolean;
485
+ /**
486
+ * The next firing the platform holds, as an instant, or `null` when it holds
487
+ * none. Not computed here: a browser working it out from the expression has to
488
+ * do summer-time arithmetic that a wrong answer makes worse than no answer.
489
+ */
465
490
  nextRunAt: string | null;
491
+ /** The same moment in the schedule's own zone, offset included. */
492
+ nextRunLocal: string | null;
493
+ /** When the schedule last actually ran — `null` when it never has. */
466
494
  lastRunAt: string | null;
495
+ /** How that run ended. */
496
+ lastRunStatus: string | null;
497
+ /**
498
+ * When the scheduler last DECIDED to fire this schedule. Stamped before the
499
+ * run exists, so it can differ from `lastRunAt` — a firing that was dispatched
500
+ * and then lost.
501
+ */
502
+ lastDispatchAt: string | null;
503
+ /** False whenever the platform would not fire this schedule. */
504
+ holdsFirings: boolean;
505
+ notFiringReason: ScheduleNotFiringReason | null;
467
506
  }
468
507
  export type TemplateCategory = 'sync' | 'notification' | 'enrichment' | 'fulfilment' | 'reporting' | 'finance' | 'commerce' | 'sales';
469
508
  export interface WorkflowTemplate {
@@ -23,6 +23,30 @@ export declare function toneClass(tone: StatusTone): string;
23
23
  * order. It used to keep its own copy of the list, so a sixth word added here
24
24
  * would have been labelled everywhere and silently missing from that menu. */
25
25
  export declare const RUN_STATUS_WORDS: readonly ["running", "succeeded", "failed", "cancelled", "queued"];
26
+ /**
27
+ * How wide a list draws its Status column, as a grid track.
28
+ *
29
+ * A fixed width, and it has to be one: the shared table gives the header row, every
30
+ * body row and every skeleton row a grid of their own, so a content-sized track
31
+ * (`max-content`, `auto`, `fit-content`) is resolved per row and a row holding a
32
+ * longer word would step out of line with the header and with the rows around it.
33
+ * The `id` column below says the same thing, and this constant was `minmax(136px,
34
+ * max-content)` for one commit because that warning was read too late.
35
+ *
36
+ * So the number is the promise, and it is a floor rather than a measurement. The
37
+ * column stood at 100px, which fitted every English word with room to spare and let
38
+ * the German "fehlgeschlagen" — 126px as a rendered badge on the machine this was
39
+ * measured on — run out of its cell and under the workflow name beside it (PO-362).
40
+ * 160px is that word plus a quarter, because the rendered width is not ours to know:
41
+ * a font that failed to load, a browser zoom or a reader's own minimum font size all
42
+ * move it, and one of those is why a first attempt at 136 was still reported cut.
43
+ *
44
+ * What happens when it is still not enough, and why that is survivable: the badge
45
+ * carries `max-w-full`, so it is clipped inside its own cell rather than drawn over
46
+ * the column beside it, and the whole word stays readable in its `title`. Raising
47
+ * this number is the fix at that point — not making the track elastic.
48
+ */
49
+ export declare const RUN_STATUS_COLUMN_WIDTH = "160px";
26
50
  /**
27
51
  * Raw API/Temporal run vocabulary → the cockpit's run-status labels, so a run
28
52
  * never reads "completed" in one surface and "succeeded" in another. This IS
@@ -42,4 +66,56 @@ export declare const RUN_STATUS_WORDS: readonly ["running", "succeeded", "failed
42
66
  * never disagree about which status they mean.
43
67
  */
44
68
  export declare function normalisedRunStatus(status: string | null | undefined): string;
69
+ /**
70
+ * The classes a run's status badge is drawn with — the colour half of
71
+ * {@link runStatusLabel}, and it reads the same word.
72
+ *
73
+ * `statusClass` on its own is handed whatever the platform wrote, and the map it
74
+ * looks in knows the studio's vocabulary rather than Temporal's: `terminated` and
75
+ * `timed_out` are in no branch of it, so a run the operator terminated was drawn
76
+ * neutral — the colour a *cancelled* run wears — while its badge said "failed".
77
+ * Two runs reading the same, in two colours, because the word went through
78
+ * {@link normalisedRunStatus} and the colour did not.
79
+ *
80
+ * So this normalises first. Every surface drawing a run's state calls it rather
81
+ * than `statusClass`, which stays what it is: one map from a status *word* to a
82
+ * tone, for the many vocabularies across the cockpit that already speak it.
83
+ */
84
+ export declare function runStatusClass(status: string | null | undefined): string;
45
85
  export declare function runStatusLabel(status: string | null | undefined): string;
86
+ /**
87
+ * A missed run's state, as a word written for the operator.
88
+ *
89
+ * The counterpart to {@link runStatusLabel} for the entry a trigger firing leaves
90
+ * when its workflow could not run. A state the catalogue does not know is named as
91
+ * unknown and carries the platform's own value inside that frame, so an operator
92
+ * can still quote what arrived without the studio claiming the word as one of its
93
+ * own. Nothing in, nothing out — the same answer `runStatusLabel` gives, for a
94
+ * column that is never actually empty.
95
+ *
96
+ * **The match is exact, unlike `runStatusLabel`'s**, and that is the point rather
97
+ * than an omission. Everything else that reads this field compares it strictly —
98
+ * whether an entry offers replay and discard, and whether it counts towards the
99
+ * firings still waiting — so a value differing by case or a stray space is one the
100
+ * studio does not act on. Saying "waiting" for it would promise an action that is
101
+ * not offered; saying it is unknown is what the rest of the studio already
102
+ * believes.
103
+ *
104
+ * The frame carries the value with its surrounding whitespace off and nothing else
105
+ * touched. Not verbatim, twice over, and neither is worth chasing: the badge is
106
+ * HTML, which collapses whitespace, and it draws its text uppercased — so an
107
+ * operator reads the letters that arrived rather than the casing, exactly as they
108
+ * do for the three states the studio does have words for.
109
+ */
110
+ export declare function deadLetterStatusLabel(status: string | null | undefined): string;
111
+ /**
112
+ * The classes a missed run's state badge is drawn with, on every surface that lists
113
+ * one.
114
+ *
115
+ * The colour half of {@link deadLetterStatusLabel}, reading the same map by the same
116
+ * exact match, so the two cannot disagree about which states the studio knows: a
117
+ * state named as unknown is drawn as unknown. Tones come from the shared palette,
118
+ * so this is one more vocabulary mapped into the studio's colours rather than a
119
+ * second set of colours.
120
+ */
121
+ export declare function deadLetterStatusClass(status: string | null | undefined): string;
@@ -77,6 +77,7 @@ export function toneClass(tone) {
77
77
  return TONE_CLASS[tone];
78
78
  }
79
79
  export const RUN_STATUS_WORDS = ["running", "succeeded", "failed", "cancelled", "queued"];
80
+ export const RUN_STATUS_COLUMN_WIDTH = "160px";
80
81
  const RUN_STATUS_WORD_SET = new Set(RUN_STATUS_WORDS);
81
82
  export function normalisedRunStatus(status) {
82
83
  if (!status) return "";
@@ -89,13 +90,39 @@ export function normalisedRunStatus(status) {
89
90
  case "terminated":
90
91
  case "timed_out":
91
92
  return "failed";
93
+ // Temporal spells a cancellation the American way and the studio does not, so
94
+ // without this the *word* half of the promise fails on the surfaces that read
95
+ // the platform's answer directly: the row said `cancelled`, the record
96
+ // `canceled`, and in German the record said it in English.
97
+ case "canceled":
98
+ return "cancelled";
92
99
  default:
93
100
  return s;
94
101
  }
95
102
  }
103
+ export function runStatusClass(status) {
104
+ return statusClass(normalisedRunStatus(status));
105
+ }
96
106
  export function runStatusLabel(status) {
97
107
  const normalized = normalisedRunStatus(status);
98
108
  if (!normalized) return "";
99
109
  if (!RUN_STATUS_WORD_SET.has(normalized)) return normalized;
100
110
  return useIntegrationsT()(`integration.runs.status.${normalized}`);
101
111
  }
112
+ const MISSED_RUN_STATUS_TONE = /* @__PURE__ */ new Map([
113
+ ["pending", "warning"],
114
+ ["replayed", "success"],
115
+ ["discarded", "neutral"]
116
+ ]);
117
+ export function deadLetterStatusLabel(status) {
118
+ if (!status) return "";
119
+ const raw = String(status);
120
+ if (!MISSED_RUN_STATUS_TONE.has(raw)) {
121
+ return useIntegrationsT()("integration.deadLetters.status.unknown", { status: raw.trim() || raw });
122
+ }
123
+ return useIntegrationsT()(`integration.deadLetters.status.${raw}`);
124
+ }
125
+ export function deadLetterStatusClass(status) {
126
+ const tone = status ? MISSED_RUN_STATUS_TONE.get(String(status)) : void 0;
127
+ return toneClass(tone ?? "neutral");
128
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@revenexx/studio-integrations",
3
- "version": "1.0.0",
3
+ "version": "1.1.1",
4
4
  "type": "module",
5
5
  "description": "Integration Studio (core: workflows, node editor, runs, schedules, credentials, secrets, audit) as a standalone, publishable Nuxt module.",
6
6
  "repository": {