@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
@@ -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 }">
@@ -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 {
@@ -42,4 +42,56 @@ export declare const RUN_STATUS_WORDS: readonly ["running", "succeeded", "failed
42
42
  * never disagree about which status they mean.
43
43
  */
44
44
  export declare function normalisedRunStatus(status: string | null | undefined): string;
45
+ /**
46
+ * The classes a run's status badge is drawn with — the colour half of
47
+ * {@link runStatusLabel}, and it reads the same word.
48
+ *
49
+ * `statusClass` on its own is handed whatever the platform wrote, and the map it
50
+ * looks in knows the studio's vocabulary rather than Temporal's: `terminated` and
51
+ * `timed_out` are in no branch of it, so a run the operator terminated was drawn
52
+ * neutral — the colour a *cancelled* run wears — while its badge said "failed".
53
+ * Two runs reading the same, in two colours, because the word went through
54
+ * {@link normalisedRunStatus} and the colour did not.
55
+ *
56
+ * So this normalises first. Every surface drawing a run's state calls it rather
57
+ * than `statusClass`, which stays what it is: one map from a status *word* to a
58
+ * tone, for the many vocabularies across the cockpit that already speak it.
59
+ */
60
+ export declare function runStatusClass(status: string | null | undefined): string;
45
61
  export declare function runStatusLabel(status: string | null | undefined): string;
62
+ /**
63
+ * A missed run's state, as a word written for the operator.
64
+ *
65
+ * The counterpart to {@link runStatusLabel} for the entry a trigger firing leaves
66
+ * when its workflow could not run. A state the catalogue does not know is named as
67
+ * unknown and carries the platform's own value inside that frame, so an operator
68
+ * can still quote what arrived without the studio claiming the word as one of its
69
+ * own. Nothing in, nothing out — the same answer `runStatusLabel` gives, for a
70
+ * column that is never actually empty.
71
+ *
72
+ * **The match is exact, unlike `runStatusLabel`'s**, and that is the point rather
73
+ * than an omission. Everything else that reads this field compares it strictly —
74
+ * whether an entry offers replay and discard, and whether it counts towards the
75
+ * firings still waiting — so a value differing by case or a stray space is one the
76
+ * studio does not act on. Saying "waiting" for it would promise an action that is
77
+ * not offered; saying it is unknown is what the rest of the studio already
78
+ * believes.
79
+ *
80
+ * The frame carries the value with its surrounding whitespace off and nothing else
81
+ * touched. Not verbatim, twice over, and neither is worth chasing: the badge is
82
+ * HTML, which collapses whitespace, and it draws its text uppercased — so an
83
+ * operator reads the letters that arrived rather than the casing, exactly as they
84
+ * do for the three states the studio does have words for.
85
+ */
86
+ export declare function deadLetterStatusLabel(status: string | null | undefined): string;
87
+ /**
88
+ * The classes a missed run's state badge is drawn with, on every surface that lists
89
+ * one.
90
+ *
91
+ * The colour half of {@link deadLetterStatusLabel}, reading the same map by the same
92
+ * exact match, so the two cannot disagree about which states the studio knows: a
93
+ * state named as unknown is drawn as unknown. Tones come from the shared palette,
94
+ * so this is one more vocabulary mapped into the studio's colours rather than a
95
+ * second set of colours.
96
+ */
97
+ export declare function deadLetterStatusClass(status: string | null | undefined): string;
@@ -89,13 +89,39 @@ export function normalisedRunStatus(status) {
89
89
  case "terminated":
90
90
  case "timed_out":
91
91
  return "failed";
92
+ // Temporal spells a cancellation the American way and the studio does not, so
93
+ // without this the *word* half of the promise fails on the surfaces that read
94
+ // the platform's answer directly: the row said `cancelled`, the record
95
+ // `canceled`, and in German the record said it in English.
96
+ case "canceled":
97
+ return "cancelled";
92
98
  default:
93
99
  return s;
94
100
  }
95
101
  }
102
+ export function runStatusClass(status) {
103
+ return statusClass(normalisedRunStatus(status));
104
+ }
96
105
  export function runStatusLabel(status) {
97
106
  const normalized = normalisedRunStatus(status);
98
107
  if (!normalized) return "";
99
108
  if (!RUN_STATUS_WORD_SET.has(normalized)) return normalized;
100
109
  return useIntegrationsT()(`integration.runs.status.${normalized}`);
101
110
  }
111
+ const MISSED_RUN_STATUS_TONE = /* @__PURE__ */ new Map([
112
+ ["pending", "warning"],
113
+ ["replayed", "success"],
114
+ ["discarded", "neutral"]
115
+ ]);
116
+ export function deadLetterStatusLabel(status) {
117
+ if (!status) return "";
118
+ const raw = String(status);
119
+ if (!MISSED_RUN_STATUS_TONE.has(raw)) {
120
+ return useIntegrationsT()("integration.deadLetters.status.unknown", { status: raw.trim() || raw });
121
+ }
122
+ return useIntegrationsT()(`integration.deadLetters.status.${raw}`);
123
+ }
124
+ export function deadLetterStatusClass(status) {
125
+ const tone = status ? MISSED_RUN_STATUS_TONE.get(String(status)) : void 0;
126
+ return toneClass(tone ?? "neutral");
127
+ }
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.0",
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": {