@revenexx/studio-integrations 3.4.0 → 3.5.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # @revenexx/studio-integrations
2
2
 
3
+ ## 3.5.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 06a52b1: The studio takes `@revenexx/studio-shared` 0.4.1, so the filter menu of every list counts each option over the rows the other applied filters leave — a count is what a click on it will show — and a list with no filters draws no Filter button.
8
+
9
+ Two more things change with that release:
10
+
11
+ - Every tab bar — a workflow's Editor, History and Runs, the node dialog, the create-workflow and credential dialogs, Runs and Missed runs — is now a set of real tabs: a screen reader announces them as tabs, and the arrow keys move between them.
12
+ - The Missed runs tab no longer shows how many firings are waiting, because the shared tab bar shows no counts on tabs. The dashboard's Needs attention panel still does.
13
+
14
+ ### Patch Changes
15
+
16
+ - fa29ad1: The studio takes `@revenexx/studio-shared` 0.4.2, so after a page loads the first Tab reaches *Skip to content* again. With 0.4.1 the sidebar scrolled to the current page in a way that, in Chrome, sent the first Tab past the skip link to the sidebar link after the current page, or into the page.
17
+
18
+ ## 3.4.1
19
+
20
+ ### Patch Changes
21
+
22
+ - 3e92677: The "Use revenexx API Keys" panel now shows the cockpit's active tenant — the project chosen in its top bar — and no longer lets it be changed. The cockpit mints a key only under that tenant, so picking another one used to label the credential with a tenant the key did not belong to. With no active tenant the panel says to choose a project first and offers no key to mint. The SFTP offer keeps its free tenant choice.
23
+
3
24
  ## 3.4.0
4
25
 
5
26
  ### Minor Changes
package/dist/module.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@revenexx/studio-integrations",
3
3
  "configKey": "studioIntegrations",
4
- "version": "3.4.0",
4
+ "version": "3.5.0",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.3",
7
7
  "unbuild": "unknown"
@@ -85,7 +85,8 @@ async function ensureLists() {
85
85
  loadingList.value = true;
86
86
  try {
87
87
  await loadTenants();
88
- if (!tenant.value) tenant.value = onlyTenantSlug.value ?? tenants.value[0]?.slug ?? null;
88
+ if (kind.value === "api") tenant.value = onlyTenantSlug.value;
89
+ else if (!tenant.value) tenant.value = onlyTenantSlug.value ?? tenants.value[0]?.slug ?? null;
89
90
  if (kind.value === "sftp") await sftp.load(tenant.value);
90
91
  else if (kind.value === "smtp") {
91
92
  const list = await mail.listCredentials();
@@ -179,6 +180,9 @@ function onSelect(id) {
179
180
  if (kind.value === "sftp") void applySftp();
180
181
  else if (kind.value === "smtp") void applySmtp();
181
182
  }
183
+ watch(onlyTenantSlug, (slug) => {
184
+ if (active.value && kind.value === "api") tenant.value = slug;
185
+ });
182
186
  watch([tenant, marketCode], () => {
183
187
  if (active.value && kind.value === "api" && apiKeyReady.value) {
184
188
  emit("apply", { tenant: tenant.value ?? "", market: marketSlug() });
@@ -303,8 +307,8 @@ function submitCreate() {
303
307
  <!-- Tenant (preselected) -->
304
308
  <div class="space-y-1.5">
305
309
  <Label :for="`${uid}-tenant`">{{ t("integration.credentials.revenexxSource.field.tenant.label") }}</Label>
306
- <Select :model-value="tenant ?? ''" @update:model-value="(v) => tenant = v">
307
- <SelectTrigger :id="`${uid}-tenant`" class="bg-white">
310
+ <Select :model-value="tenant ?? ''" :disabled="kind === 'api'" @update:model-value="(v) => tenant = v">
311
+ <SelectTrigger :id="`${uid}-tenant`" class="bg-white" :aria-describedby="kind === 'api' ? `${uid}-tenant-hint` : void 0">
308
312
  <SelectValue :placeholder="t('integration.credentials.revenexxSource.field.tenant.placeholder')" />
309
313
  </SelectTrigger>
310
314
  <SelectContent>
@@ -313,6 +317,9 @@ function submitCreate() {
313
317
  </SelectItem>
314
318
  </SelectContent>
315
319
  </Select>
320
+ <p v-if="kind === 'api'" :id="`${uid}-tenant-hint`" class="text-xs text-muted-foreground">
321
+ {{ tenant ? t("integration.credentials.revenexxSource.field.tenant.hint.api") : t("integration.credentials.revenexxSource.field.tenant.hint.apiNone") }}
322
+ </p>
316
323
  </div>
317
324
 
318
325
  <!-- Market scope — only the Revenexx API credential is market-scoped -->
@@ -28,7 +28,7 @@ import {
28
28
  ToggleGroupItem
29
29
  } from "@revenexx/studio";
30
30
  import EventTypeCombobox from "./EventTypeCombobox.vue";
31
- import { findManifest, nodeCategoryLabel, isVersionExecutable, newestExecutableVersion, executableVersionsOf, newerExecutableVersions } from "../../data/integrationNodeCatalog";
31
+ import { channelManifestOf, findManifest, isChannelPin, nodeCategoryLabel, isVersionExecutable, newestExecutableVersion, executableVersionsOf, newerExecutableVersions } from "../../data/integrationNodeCatalog";
32
32
  import NodeVersionMoveDialog from "./NodeVersionMoveDialog.vue";
33
33
  import {
34
34
  collectUpstreamNodes,
@@ -131,6 +131,7 @@ const newerVersions = computed(() => {
131
131
  const canMoveVersion = computed(() => {
132
132
  const wn = draftNode.value;
133
133
  if (!wn) return false;
134
+ if (isChannelPin(wn.nodeVersion)) return true;
134
135
  const runnable = executableVersionsOf(wn.nodeSlug);
135
136
  if (!runnable.length) return false;
136
137
  return runnable.length > 1 || runnable[0]?.version !== wn.nodeVersion;
@@ -151,6 +152,10 @@ const namePlaceholder = computed(() => {
151
152
  });
152
153
  const subtitleText = computed(() => {
153
154
  const wn = draftNode.value;
155
+ if (wn && isChannelPin(wn.nodeVersion)) {
156
+ const current = channelManifestOf(wn.nodeSlug)?.version;
157
+ return wn.nodeSlug + " \xB7 " + (current ? t("integration.common.nodeVersion.channelCurrent", { version: current }) : t("integration.common.nodeVersion.channel"));
158
+ }
154
159
  if (wn) return wn.nodeSlug + "@" + wn.nodeVersion;
155
160
  return t("integration.nodeInspector.trigger.placeholder", { type: TRIGGER_LABELS.value[draftTrigger.value?.type ?? "manual"] });
156
161
  });
@@ -1387,7 +1392,9 @@ async function runTestStep() {
1387
1392
  v-if="isNode && !versionExecutable"
1388
1393
  class="mx-4 mb-1 shrink-0 rounded-md border border-rose-200 bg-rose-50 px-2.5 py-1.5 text-sm text-rose-700"
1389
1394
  >
1390
- {{ readyVersion ? t("integration.nodeInspector.notice.versionNotReady.upgrade", { version: draftNode?.nodeVersion ?? "", ready: readyVersion }) : t("integration.nodeInspector.notice.versionNotReady.none", { version: draftNode?.nodeVersion ?? "" }) }}
1395
+ <!-- A step on the channel has no number to name: what it lacks is a
1396
+ version on the channel (MAR-4075). -->
1397
+ {{ isChannelPin(draftNode?.nodeVersion ?? "") ? t("integration.nodeInspector.version.move.channelUnavailable") : readyVersion ? t("integration.nodeInspector.notice.versionNotReady.upgrade", { version: draftNode?.nodeVersion ?? "", ready: readyVersion }) : t("integration.nodeInspector.notice.versionNotReady.none", { version: draftNode?.nodeVersion ?? "" }) }}
1391
1398
  <!-- The sentence named a version and led nowhere (PO-339). It leads
1392
1399
  here now; where the node has no runnable version at all there is
1393
1400
  still nothing to offer, and the notice stays the statement it
@@ -3,7 +3,7 @@ import { computed, ref, watch } from "vue";
3
3
  import { AltArrowLeft as ArrowLeft, Bolt as Zap, Magnifier as Search } from "#solar-icons";
4
4
  import { triggerGlyph } from "../../data/triggerGlyphs";
5
5
  import * as Lucide from "lucide-vue-next";
6
- import { INTEGRATION_NODE_CATALOG, isOffered, NODE_CATEGORIES, TRIGGER_TEMPLATES } from "../../data/integrationNodeCatalog";
6
+ import { CHANNEL_VERSION, channelManifestOf, INTEGRATION_NODE_CATALOG, isCandidate, isChannelPin, isOffered, NODE_CATEGORIES, offersChannel, TRIGGER_TEMPLATES } from "../../data/integrationNodeCatalog";
7
7
  import { resolveLocalized, studioLocale } from "../../utils/localize";
8
8
  import { orderFolderLeaves, orderFolders } from "../../utils/paletteOrder";
9
9
  import { declaredFolderIcon } from "../../utils/folderMark";
@@ -93,6 +93,7 @@ const latestManifests = computed(() => {
93
93
  const out = [];
94
94
  for (const m of INTEGRATION_NODE_CATALOG) {
95
95
  if (seen.has(m.slug) || !isExecutable(m)) continue;
96
+ if (isCandidate(m) && INTEGRATION_NODE_CATALOG.some((o) => o.slug === m.slug && isExecutable(o) && !isCandidate(o))) continue;
96
97
  seen.add(m.slug);
97
98
  out.push(m);
98
99
  }
@@ -117,13 +118,18 @@ function setSelectedVersion(slug, version) {
117
118
  }
118
119
  function effVersion(m) {
119
120
  const sel = selectedVersion.value[m.slug];
121
+ if (isChannelPin(sel)) return offersChannel(m.slug) ? CHANNEL_VERSION : m.version;
120
122
  return sel && versionsOf(m.slug).some((v) => v.version === sel) ? sel : m.version;
121
123
  }
122
124
  function effManifest(m) {
123
125
  const v = effVersion(m);
126
+ if (isChannelPin(v)) return channelManifestOf(m.slug) ?? m;
124
127
  if (v === m.version) return m;
125
128
  return versionsOf(m.slug).find((x) => x.version === v) ?? m;
126
129
  }
130
+ function versionOptionLabel(v) {
131
+ return isCandidate(v) ? t("integration.common.nodeVersion.candidate", { version: v.version }) : `v${v.version}`;
132
+ }
127
133
  const tree = computed(() => {
128
134
  const apps = makeGroup("Integrations");
129
135
  const core = makeGroup("Core");
@@ -264,7 +270,7 @@ function onVersionSelectLeave(ev) {
264
270
  function onManifestDragStart(leaf, ev) {
265
271
  if (!ev.dataTransfer) return;
266
272
  const m = effManifest(leaf);
267
- ev.dataTransfer.setData("application/integration-node", JSON.stringify({ kind: "manifest", slug: m.slug, version: m.version }));
273
+ ev.dataTransfer.setData("application/integration-node", JSON.stringify({ kind: "manifest", slug: m.slug, version: effVersion(leaf) }));
268
274
  ev.dataTransfer.effectAllowed = "move";
269
275
  setCustomDragImage(ev, resolveLocalized(m.name), logoSrcOf(m), "#8b5cf6");
270
276
  nodeDrag.start({ kind: "manifest", manifest: m });
@@ -281,7 +287,7 @@ function onDragEnd() {
281
287
  }
282
288
  function insertManifest(leaf) {
283
289
  const m = effManifest(leaf);
284
- emit("insert", { kind: "manifest", slug: m.slug, version: m.version });
290
+ emit("insert", { kind: "manifest", slug: m.slug, version: effVersion(leaf) });
285
291
  }
286
292
  function insertTrigger(tpl) {
287
293
  emit("insert", { kind: "trigger", type: tpl.type });
@@ -408,9 +414,10 @@ const searching = computed(() => search.value.trim().length > 0);
408
414
  @focus="paletteDragEnabled = false"
409
415
  @blur="paletteDragEnabled = true"
410
416
  >
411
- <option v-for="v in versionsOf(hit.manifest.slug)" :key="v.version" :value="v.version">v{{ v.version }}</option>
417
+ <option v-if="offersChannel(hit.manifest.slug)" :value="CHANNEL_VERSION">{{ t("integration.common.nodeVersion.channel") }}</option>
418
+ <option v-for="v in versionsOf(hit.manifest.slug)" :key="v.version" :value="v.version">{{ versionOptionLabel(v) }}</option>
412
419
  </select>
413
- <span v-else class="shrink-0 text-xs tabular-nums text-muted-foreground/60">v{{ hit.manifest.version }}</span>
420
+ <span v-else class="shrink-0 text-xs tabular-nums text-muted-foreground/60">{{ versionOptionLabel(hit.manifest) }}</span>
414
421
  </div>
415
422
  <div class="text-xs leading-snug text-muted-foreground">{{ resolveLocalized(hit.manifest.description) }}</div>
416
423
  <div class="truncate text-xs text-muted-foreground/70">{{ hit.path.join(" \u203A ") }}</div>
@@ -546,9 +553,10 @@ const searching = computed(() => search.value.trim().length > 0);
546
553
  @focus="paletteDragEnabled = false"
547
554
  @blur="paletteDragEnabled = true"
548
555
  >
549
- <option v-for="v in versionsOf(m.slug)" :key="v.version" :value="v.version">v{{ v.version }}</option>
556
+ <option v-if="offersChannel(m.slug)" :value="CHANNEL_VERSION">{{ t("integration.common.nodeVersion.channel") }}</option>
557
+ <option v-for="v in versionsOf(m.slug)" :key="v.version" :value="v.version">{{ versionOptionLabel(v) }}</option>
550
558
  </select>
551
- <span v-else class="shrink-0 text-xs tabular-nums text-muted-foreground/60">v{{ m.version }}</span>
559
+ <span v-else class="shrink-0 text-xs tabular-nums text-muted-foreground/60">{{ versionOptionLabel(m) }}</span>
552
560
  </div>
553
561
  <div class="text-xs leading-snug text-muted-foreground">{{ resolveLocalized(m.description) }}</div>
554
562
  </div>
@@ -9,7 +9,16 @@ import {
9
9
  AlertDialogTitle,
10
10
  Button
11
11
  } from "@revenexx/studio";
12
- import { executableVersionsOf, findManifest, newestExecutableVersion } from "../../data/integrationNodeCatalog";
12
+ import {
13
+ CHANNEL_VERSION,
14
+ channelManifestOf,
15
+ executableVersionsOf,
16
+ findManifest,
17
+ isCandidate,
18
+ isChannelPin,
19
+ newestExecutableVersion,
20
+ offersChannel
21
+ } from "../../data/integrationNodeCatalog";
13
22
  import { planVersionMove } from "../../data/nodeVersionMove";
14
23
  import { resolveLocalized } from "../../utils/localize";
15
24
  const open = defineModel("open", { type: Boolean, ...{ default: false } });
@@ -22,9 +31,25 @@ const versions = computed(() => props.node ? executableVersionsOf(props.node.nod
22
31
  const target = ref("");
23
32
  watch(open, (isOpen) => {
24
33
  if (!isOpen || !props.node) return;
34
+ if (isChannelPin(props.node.nodeVersion)) {
35
+ target.value = CHANNEL_VERSION;
36
+ return;
37
+ }
25
38
  const newest = newestExecutableVersion(props.node.nodeSlug);
26
39
  target.value = newest && newest !== props.node.nodeVersion ? newest : props.node.nodeVersion;
27
40
  });
41
+ const channelManifest = computed(() => props.node ? channelManifestOf(props.node.nodeSlug) : null);
42
+ const targetManifest = computed(() => props.node ? findManifestExact(props.node.nodeSlug, target.value) : null);
43
+ const channelOffered = computed(() => !!props.node && (offersChannel(props.node.nodeSlug) || isChannelPin(props.node.nodeVersion)));
44
+ const channelUnavailable = computed(() => isChannelPin(target.value) && channelManifest.value === null);
45
+ function versionLabel(version) {
46
+ if (!isChannelPin(version)) return `v${version}`;
47
+ const current = channelManifest.value?.version;
48
+ return current ? t("integration.common.nodeVersion.channelCurrent", { version: current }) : t("integration.common.nodeVersion.channel");
49
+ }
50
+ function optionLabel(m) {
51
+ return isCandidate(m) ? t("integration.common.nodeVersion.candidate", { version: m.version }) : `v${m.version}`;
52
+ }
28
53
  const plan = computed(() => {
29
54
  const n = props.node;
30
55
  if (!n) return null;
@@ -35,16 +60,18 @@ const plan = computed(() => {
35
60
  );
36
61
  });
37
62
  function findManifestExact(slug, version) {
63
+ if (isChannelPin(version)) return channelManifestOf(slug);
38
64
  const m = findManifest(slug, version);
39
65
  return m && m.version === version ? m : null;
40
66
  }
41
67
  const unchanged = computed(() => !props.node || target.value === props.node.nodeVersion);
68
+ const blocked = computed(() => unchanged.value || channelUnavailable.value);
42
69
  function labelOf(entry) {
43
70
  return resolveLocalized(entry.field?.label) || entry.key;
44
71
  }
45
72
  function confirm() {
46
73
  const p = plan.value;
47
- if (!props.node || !p || unchanged.value) return;
74
+ if (!props.node || !p || blocked.value) return;
48
75
  emit("move", { version: target.value, config: p.nextConfig });
49
76
  open.value = false;
50
77
  }
@@ -64,19 +91,32 @@ function confirm() {
64
91
 
65
92
  <div v-if="props.node && plan" class="space-y-3 text-sm">
66
93
  <div class="flex items-center gap-2">
67
- <span class="tabular-nums text-muted-foreground">v{{ props.node.nodeVersion }}</span>
94
+ <span class="tabular-nums text-muted-foreground">{{ versionLabel(props.node.nodeVersion) }}</span>
68
95
  <span aria-hidden="true">→</span>
69
96
  <select
70
97
  v-model="target"
71
98
  class="rounded border border-black/10 bg-white px-1.5 py-0.5 tabular-nums"
72
99
  :aria-label="t('integration.nodeInspector.version.move.targetAria')"
73
100
  >
101
+ <option v-if="channelOffered" :value="CHANNEL_VERSION">
102
+ {{ t("integration.common.nodeVersion.channel") }}
103
+ </option>
74
104
  <option v-for="m in versions" :key="m.version" :value="m.version">
75
- v{{ m.version }}
105
+ {{ optionLabel(m) }}
76
106
  </option>
77
107
  </select>
78
108
  </div>
79
109
 
110
+ <p v-if="channelUnavailable" class="text-amber-600">
111
+ {{ t("integration.nodeInspector.version.move.channelUnavailable") }}
112
+ </p>
113
+ <p v-else-if="isChannelPin(target) && !unchanged" class="text-muted-foreground">
114
+ {{ t("integration.nodeInspector.version.move.channelHint") }}
115
+ </p>
116
+ <p v-else-if="!isChannelPin(target) && isCandidate(targetManifest)" class="text-amber-600">
117
+ {{ t("integration.nodeInspector.version.move.candidateHint") }}
118
+ </p>
119
+
80
120
  <!-- A version the catalogue no longer carries cannot be compared against,
81
121
  so the lists below are what arrives rather than what was checked. Said
82
122
  once, here, instead of marking every setting as suspect. -->
@@ -88,7 +128,7 @@ function confirm() {
88
128
  {{ t("integration.nodeInspector.version.move.same") }}
89
129
  </p>
90
130
 
91
- <template v-else>
131
+ <template v-else-if="!channelUnavailable">
92
132
  <p v-if="plan.lossless" class="text-muted-foreground">
93
133
  {{ t("integration.nodeInspector.version.move.lossless") }}
94
134
  </p>
@@ -124,7 +164,7 @@ function confirm() {
124
164
  <Button variant="outline" @click="open = false">
125
165
  {{ t("integration.common.action.cancel") }}
126
166
  </Button>
127
- <Button :disabled="unchanged" @click="confirm">
167
+ <Button :disabled="blocked" @click="confirm">
128
168
  {{ t("integration.nodeInspector.version.move.confirm") }}
129
169
  </Button>
130
170
  </AlertDialogFooter>
@@ -49,7 +49,7 @@ import { toast } from "vue-sonner";
49
49
  import WorkflowEdgeView from "./WorkflowEdgeView.vue";
50
50
  import { layoutWorkflowNodes } from "./workflowLayout";
51
51
  import { defaultsFor } from "../../utils/configDefaults";
52
- import { TRIGGER_TEMPLATES, findManifest, triggerTypeLabel } from "../../data/integrationNodeCatalog";
52
+ import { CHANNEL_VERSION, TRIGGER_TEMPLATES, findManifest, isChannelPin, triggerTypeLabel } from "../../data/integrationNodeCatalog";
53
53
  import { collectUpstreamNodes, isTerminalManifest, portsForNode } from "../../data/nodeInputData";
54
54
  import { computeLoopBodies, iteratorNodeIds, nodeLoopData } from "../../data/loopBody";
55
55
  import { canvasShapes } from "../../data/canvasShapes";
@@ -1111,7 +1111,7 @@ function addFromPayload(payload, pos) {
1111
1111
  blob.value.nodes.push({
1112
1112
  id,
1113
1113
  nodeSlug: manifest.slug,
1114
- nodeVersion: manifest.version,
1114
+ nodeVersion: pinnedVersion(payload.version, manifest),
1115
1115
  config: defaultsFor(manifest.config ?? []),
1116
1116
  position: at
1117
1117
  });
@@ -1156,7 +1156,7 @@ function onPaletteInsert(payload) {
1156
1156
  blob.value.nodes.push({
1157
1157
  id,
1158
1158
  nodeSlug: manifest.slug,
1159
- nodeVersion: manifest.version,
1159
+ nodeVersion: pinnedVersion(payload.version, manifest),
1160
1160
  config: defaultsFor(manifest.config ?? []),
1161
1161
  position: { x: pc.pos.x, y: pc.pos.y }
1162
1162
  });
@@ -1194,6 +1194,9 @@ function primaryOutputName(slug, version) {
1194
1194
  const m = findManifest(slug, version);
1195
1195
  return (m?.outputs ?? []).find((o) => o.name && o.kind !== "error")?.name ?? "default";
1196
1196
  }
1197
+ function pinnedVersion(chosen, manifest) {
1198
+ return isChannelPin(chosen) ? CHANNEL_VERSION : manifest.version;
1199
+ }
1197
1200
  function spliceIntoEdge(edgeId, payload) {
1198
1201
  const edge = blob.value.edges.find((e) => e.id === edgeId);
1199
1202
  const manifest = findManifest(payload.slug, payload.version);
@@ -1208,7 +1211,7 @@ function spliceIntoEdge(edgeId, payload) {
1208
1211
  const inserted = {
1209
1212
  id,
1210
1213
  nodeSlug: manifest.slug,
1211
- nodeVersion: manifest.version,
1214
+ nodeVersion: pinnedVersion(payload.version, manifest),
1212
1215
  config: defaultsFor(manifest.config ?? []),
1213
1216
  position: { x: snap((a.x + b.x) / 2), y: snap((a.y + b.y) / 2) }
1214
1217
  };
@@ -4,7 +4,7 @@ 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
6
  import { Copy, DangerTriangle as AlertTriangle, RefreshCircle as Loader2, Repeat, TrashBinMinimalistic as TrashBin } from "#solar-icons";
7
- import { NODE_CATEGORIES, findManifest, isVersionExecutable } from "../../data/integrationNodeCatalog";
7
+ import { NODE_CATEGORIES, channelManifestOf, findManifest, isChannelPin, isVersionExecutable } from "../../data/integrationNodeCatalog";
8
8
  import { inputHandleId, outputHandleId } from "../../data/handleId";
9
9
  import { branchLineText } from "../../data/canvasShapes";
10
10
  import { joinRefusalText } from "../../data/refusalMessage";
@@ -23,6 +23,11 @@ defineOptions({ inheritAttrs: false });
23
23
  defineEmits(["updateNodeInternals"]);
24
24
  const t = useIntegrationsT();
25
25
  const manifest = computed(() => findManifest(props.data.nodeSlug, props.data.nodeVersion));
26
+ const versionChip = computed(() => {
27
+ if (!isChannelPin(props.data.nodeVersion)) return `v${props.data.nodeVersion}`;
28
+ const channel = channelManifestOf(props.data.nodeSlug);
29
+ return channel ? t("integration.canvas.node.meta.channel", { version: channel.version }) : t("integration.canvas.node.meta.channelNone");
30
+ });
26
31
  const category = computed(() => NODE_CATEGORIES.find((c) => c.key === manifest.value?.category));
27
32
  const title = computed(() => nodeDisplayName(props.data));
28
33
  const fromPackage = computed(() => manifest.value?.__package ? packageLabel(manifest.value.__package) : null);
@@ -584,7 +589,7 @@ function remove() {
584
589
  <span
585
590
  class="shrink-0 text-[10px] font-medium leading-none tabular-nums text-muted-foreground/55"
586
591
  :title="t('integration.canvas.node.meta.versionTitle', { version: data.nodeVersion })"
587
- >v{{ data.nodeVersion }}</span>
592
+ >{{ versionChip }}</span>
588
593
  </div>
589
594
  </div>
590
595
  </div>
@@ -54,7 +54,12 @@ export function useIntegrationsNodes() {
54
54
  __retired: n.retired === true,
55
55
  // The package's own order, which the palette falls back to inside a
56
56
  // folder (PO-537). Null and absent both mean "not recorded".
57
- __packagePosition: typeof n.package_position === "number" ? n.package_position : void 0
57
+ __packagePosition: typeof n.package_position === "number" ? n.package_position : void 0,
58
+ // Where the version stands in its approval, and whether the tenant's
59
+ // release channel runs it (MAR-4075). Missing fields = released, and
60
+ // the catalogue works out the channel's version on its own.
61
+ __releaseState: n.release_state === "candidate" || n.release_state === "approved" ? n.release_state : null,
62
+ __channelVersion: n.channel_version === true ? true : void 0
58
63
  }));
59
64
  applyNodeCatalog(manifests);
60
65
  level.value = request === "summary" && res?.fields === "summary" ? "summary" : "full";
@@ -59,6 +59,36 @@ export declare const TRIGGER_TYPE_WORDS: readonly ["manual", "schedule", "webhoo
59
59
  * key itself on screen, because a missing message resolves to its own name.
60
60
  */
61
61
  export declare function triggerTypeLabel(type: TriggerType | string | null | undefined): string;
62
+ /**
63
+ * The pin a step carries to follow the tenant's release channel instead of a
64
+ * version number (MAR-4075). The platform resolves it per tenant when it builds
65
+ * the workflow; the definition keeps saying `channel`.
66
+ */
67
+ export declare const CHANNEL_VERSION = "channel";
68
+ /** Whether a step's version is the channel pin rather than a number. */
69
+ export declare function isChannelPin(version: string | null | undefined): boolean;
70
+ /**
71
+ * Whether this version is a candidate: built, and only given to tenants on the
72
+ * candidate release channel while it is tested (MAR-4075). A host that names no
73
+ * state reads as released.
74
+ */
75
+ export declare function isCandidate(m: NodeManifest | null | undefined): boolean;
76
+ /**
77
+ * The version a step pinned to `channel` runs for this tenant, or `null` when the
78
+ * catalogue has none.
79
+ *
80
+ * The platform marks it (`channel_version`). A host that does not falls back to
81
+ * the rule the platform itself falls back to: the newest offered version that is
82
+ * not a candidate.
83
+ */
84
+ export declare function channelManifestOf(slug: string): NodeManifest | null;
85
+ /**
86
+ * Whether the release channel is a choice to offer for this node: the platform
87
+ * marked which version a `channel` step runs. A platform that marks none
88
+ * predates the channel pin and would refuse a save carrying it, so the studio
89
+ * offers it nowhere (MAR-4075).
90
+ */
91
+ export declare function offersChannel(slug: string): boolean;
62
92
  /**
63
93
  * Whether this exact node version can be executed — its code was built, so
64
94
  * `execute:test` and `config:resolve` will not be refused (PO-338).
@@ -87,6 +117,10 @@ export declare function isOffered(m: NodeManifest): boolean;
87
117
  * within a slug, so the first executable entry is the newest one. `null` where the
88
118
  * whole node is unusable — then there is nothing to point at and the surface says
89
119
  * only what is wrong.
120
+ *
121
+ * A candidate is never what a surface suggests (MAR-4075): it is a version under
122
+ * test, chosen deliberately or not at all. Only where a node has nothing but
123
+ * candidates is the newest of them the answer.
90
124
  */
91
125
  export declare function newestExecutableVersion(slug: string): string | null;
92
126
  /**
@@ -26,7 +26,26 @@ export function triggerTypeLabel(type) {
26
26
  if (!TRIGGER_TYPE_WORD_SET.has(kind)) return kind;
27
27
  return useIntegrationsT()(`integration.common.triggerType.${kind}`);
28
28
  }
29
+ export const CHANNEL_VERSION = "channel";
30
+ export function isChannelPin(version) {
31
+ return version === CHANNEL_VERSION;
32
+ }
33
+ export function isCandidate(m) {
34
+ return m?.__releaseState === "candidate";
35
+ }
36
+ export function channelManifestOf(slug) {
37
+ const versions = INTEGRATION_NODE_CATALOG.filter((m) => m.slug === slug);
38
+ return versions.find((m) => m.__channelVersion === true) ?? versions.find((m) => isOffered(m) && !isCandidate(m)) ?? null;
39
+ }
40
+ export function offersChannel(slug) {
41
+ return INTEGRATION_NODE_CATALOG.some((m) => m.slug === slug && m.__channelVersion === true && isOffered(m));
42
+ }
29
43
  export function isVersionExecutable(slug, version) {
44
+ if (isChannelPin(version)) {
45
+ if (!INTEGRATION_NODE_CATALOG.some((m) => m.slug === slug)) return true;
46
+ const channel = channelManifestOf(slug);
47
+ return channel ? channel.__hasBundle !== false : false;
48
+ }
30
49
  const exact = INTEGRATION_NODE_CATALOG.find((m) => m.slug === slug && m.version === version);
31
50
  return exact ? exact.__hasBundle !== false : true;
32
51
  }
@@ -34,20 +53,22 @@ export function isOffered(m) {
34
53
  return m.__hasBundle !== false && m.__retired !== true;
35
54
  }
36
55
  export function newestExecutableVersion(slug) {
37
- const hit = INTEGRATION_NODE_CATALOG.find((m) => m.slug === slug && isOffered(m));
38
- return hit?.version ?? null;
56
+ const offered = INTEGRATION_NODE_CATALOG.filter((m) => m.slug === slug && isOffered(m));
57
+ return (offered.find((m) => !isCandidate(m)) ?? offered[0])?.version ?? null;
39
58
  }
40
59
  export function executableVersionsOf(slug) {
41
60
  return INTEGRATION_NODE_CATALOG.filter((m) => m.slug === slug && isOffered(m));
42
61
  }
43
62
  export function newerExecutableVersions(slug, version) {
63
+ if (isChannelPin(version)) return [];
44
64
  const built = INTEGRATION_NODE_CATALOG.filter((m) => m.slug === slug && m.__hasBundle !== false);
45
65
  const at = built.findIndex((m) => m.version === version);
46
- return at < 0 ? [] : built.slice(0, at).filter(isOffered);
66
+ return at < 0 ? [] : built.slice(0, at).filter((m) => isOffered(m) && !isCandidate(m));
47
67
  }
48
68
  export function findManifest(slug, version) {
49
69
  const matches = INTEGRATION_NODE_CATALOG.filter((m) => m.slug === slug);
50
70
  if (!matches.length) return null;
71
+ if (isChannelPin(version)) return channelManifestOf(slug) ?? matches[0] ?? null;
51
72
  if (version) return matches.find((m) => m.version === version) ?? matches[0] ?? null;
52
73
  return matches[0] ?? null;
53
74
  }
@@ -94,6 +94,8 @@
94
94
  "integration.canvas.node.handle.virtualConverging": "Zusammenführung, nur gezeichnet: der Zweig, der ankommt",
95
95
  "integration.canvas.node.handle.virtualMerger": "Merger, nur gezeichnet: wartet auf alle Zweige",
96
96
  "integration.canvas.node.meta.versionTitle": "Version {version}",
97
+ "integration.canvas.node.meta.channel": "Kanal · v{version}",
98
+ "integration.canvas.node.meta.channelNone": "Kanal · keine Version",
97
99
  "integration.canvas.node.note.heading": "Notiz",
98
100
  "integration.canvas.node.port.outputFallback": "Ausgang",
99
101
  "integration.canvas.node.port.resolving": "Ports",
@@ -170,6 +172,9 @@
170
172
  "integration.common.triggerType.manual": "Manuell",
171
173
  "integration.common.triggerType.schedule": "Zeitplan",
172
174
  "integration.common.triggerType.webhook": "Webhook",
175
+ "integration.common.nodeVersion.channel": "Kanal (folgt dem Release-Kanal)",
176
+ "integration.common.nodeVersion.candidate": "v{version} (Kandidat)",
177
+ "integration.common.nodeVersion.channelCurrent": "Kanal · derzeit v{version}",
173
178
  "integration.credentials.createDialog.action.changeType": "Typ ändern",
174
179
  "integration.credentials.createDialog.description": "Erstellen Sie Zugangsdaten für diesen Node, ohne den Editor zu verlassen.",
175
180
  "integration.credentials.createDialog.error.create": "Zugangsdaten konnten nicht erstellt werden.",
@@ -252,6 +257,8 @@
252
257
  "integration.credentials.revenexxSource.field.resource.placeholder": "Auswählen",
253
258
  "integration.credentials.revenexxSource.field.resource.sftpLabel": "SFTP-Konto",
254
259
  "integration.credentials.revenexxSource.field.tenant.label": "Mandant",
260
+ "integration.credentials.revenexxSource.field.tenant.hint.api": "Das Cockpit erstellt den Key für das oben in der Leiste gewählte Projekt, daher lässt sich der Mandant hier nicht ändern.",
261
+ "integration.credentials.revenexxSource.field.tenant.hint.apiNone": "Bitte zuerst oben in der Cockpit-Leiste ein Projekt wählen — der Key wird für dieses Projekt erstellt.",
255
262
  "integration.credentials.revenexxSource.field.tenant.placeholder": "Mandant auswählen",
256
263
  "integration.credentials.revenexxSource.hint.available": "Eine vorhandene revenexx-Ressource wiederverwenden, statt Zugangsdaten von Hand einzugeben.",
257
264
  "integration.credentials.revenexxSource.hint.unavailable": "Hier nicht verfügbar — die verwalteten revenexx-Ressourcen befinden sich im Cockpit. Bitte stattdessen die Zugangsdaten von Hand eingeben.",
@@ -905,6 +912,9 @@
905
912
  "integration.nodeInspector.version.move.targetAria": "Version, auf die dieser Schritt wechselt",
906
913
  "integration.nodeInspector.version.move.title": "Version dieses Schritts wechseln",
907
914
  "integration.nodeInspector.version.moveTo": "Auf Version {version} wechseln",
915
+ "integration.nodeInspector.version.move.candidateHint": "Diese Version ist ein Kandidat und wird noch getestet. Nur Mandanten im Kandidaten-Kanal erhalten sie. Wählen Sie sie nur, wenn Sie sie gezielt ausprobieren möchten.",
916
+ "integration.nodeInspector.version.move.channelHint": "Der Schritt läuft dann mit der Version, die der Release-Kanal Ihres Mandanten vorgibt, und wechselt mit, wenn sich der Kanal ändert. Die Einstellungen werden mit der Version verglichen, die der Kanal heute vorgibt.",
917
+ "integration.nodeInspector.version.move.channelUnavailable": "Auf Ihrem Release-Kanal ist noch keine Version dieses Nodes verfügbar, deshalb kann der Schritt ihm nicht folgen.",
908
918
  "integration.nodes.category.action": "Aktionen",
909
919
  "integration.nodes.category.control": "Ablaufsteuerung",
910
920
  "integration.nodes.category.io": "I/O",
@@ -94,6 +94,8 @@
94
94
  "integration.canvas.node.handle.virtualConverging": "Converging input, drawn only: whichever of these branches arrives",
95
95
  "integration.canvas.node.handle.virtualMerger": "Merger, drawn only: waits for every branch",
96
96
  "integration.canvas.node.meta.versionTitle": "Version {version}",
97
+ "integration.canvas.node.meta.channel": "channel · v{version}",
98
+ "integration.canvas.node.meta.channelNone": "channel · no version",
97
99
  "integration.canvas.node.note.heading": "Note",
98
100
  "integration.canvas.node.port.outputFallback": "out",
99
101
  "integration.canvas.node.port.resolving": "Ports",
@@ -170,6 +172,9 @@
170
172
  "integration.common.triggerType.manual": "Manual",
171
173
  "integration.common.triggerType.schedule": "Schedule",
172
174
  "integration.common.triggerType.webhook": "Webhook",
175
+ "integration.common.nodeVersion.channel": "Channel (follows release channel)",
176
+ "integration.common.nodeVersion.candidate": "v{version} (candidate)",
177
+ "integration.common.nodeVersion.channelCurrent": "Channel · currently v{version}",
173
178
  "integration.credentials.createDialog.action.changeType": "Change type",
174
179
  "integration.credentials.createDialog.description": "Create a credential for this node without leaving the editor.",
175
180
  "integration.credentials.createDialog.error.create": "Could not create credential.",
@@ -252,6 +257,8 @@
252
257
  "integration.credentials.revenexxSource.field.resource.placeholder": "Select one",
253
258
  "integration.credentials.revenexxSource.field.resource.sftpLabel": "SFTP account",
254
259
  "integration.credentials.revenexxSource.field.tenant.label": "Tenant",
260
+ "integration.credentials.revenexxSource.field.tenant.hint.api": "The cockpit creates the key for the project chosen in its top bar, so this tenant cannot be changed here.",
261
+ "integration.credentials.revenexxSource.field.tenant.hint.apiNone": "Choose a project in the cockpit’s top bar first — the key is created for that project.",
255
262
  "integration.credentials.revenexxSource.field.tenant.placeholder": "Select tenant",
256
263
  "integration.credentials.revenexxSource.hint.available": "Reuse an existing revenexx resource instead of typing access data by hand.",
257
264
  "integration.credentials.revenexxSource.hint.unavailable": "Not available here — the managed revenexx resources live in the cockpit. Enter the access data by hand instead.",
@@ -905,6 +912,9 @@
905
912
  "integration.nodeInspector.version.move.targetAria": "Version to change this step to",
906
913
  "integration.nodeInspector.version.move.title": "Change this step's version",
907
914
  "integration.nodeInspector.version.moveTo": "Change to version {version}",
915
+ "integration.nodeInspector.version.move.candidateHint": "This version is a candidate: it is still being tested and only reaches tenants on the candidate release channel. Pin it only to try it out.",
916
+ "integration.nodeInspector.version.move.channelHint": "The step will run whichever version your tenant's release channel gives it, and move along when the channel changes. The settings are compared against the version the channel runs today.",
917
+ "integration.nodeInspector.version.move.channelUnavailable": "No version of this node is available on your release channel yet, so the step cannot follow it.",
908
918
  "integration.nodes.category.action": "Actions",
909
919
  "integration.nodes.category.control": "Control flow",
910
920
  "integration.nodes.category.io": "I/O",
@@ -14,7 +14,6 @@ const { runs, loading, error, load, query, setQuery, narrowedTotal } = useIntegr
14
14
  const {
15
15
  globalItems: missed,
16
16
  globalLoading: missedLoading,
17
- pendingCount,
18
17
  globalPage: missedPage,
19
18
  globalLastPage: missedLastPage,
20
19
  goGlobalPage,
@@ -54,7 +53,7 @@ const tab = ref(route.query.tab === "missed" ? "missed" : "runs");
54
53
  const tabs = computed(() => [
55
54
  { key: "runs", label: t("integration.runs.tabs.runs.label") },
56
55
  // "Missed runs": trigger firings that never became a run (dead letters).
57
- { key: "missed", label: t("integration.deadLetters.tabs.missed.label"), badge: pendingCount.value || null }
56
+ { key: "missed", label: t("integration.deadLetters.tabs.missed.label") }
58
57
  ]);
59
58
  const POLL_MS = 3e3;
60
59
  const hasActiveRuns = computed(() => runs.value.some((r) => r.status === "running" || r.status === "queued"));
@@ -465,6 +465,20 @@ export interface NodeManifest {
465
465
  * platform refuses a save that newly uses it.
466
466
  */
467
467
  __retired?: boolean;
468
+ /**
469
+ * Where this version stands in its package's approval (editor-side enrichment
470
+ * from `GET /nodes`; not manifest data, MAR-4075): `candidate` for a build only
471
+ * tenants on the candidate release channel are given, `approved` for a released
472
+ * one. Absent or `null` on a host that does not report it, which reads as
473
+ * released.
474
+ */
475
+ __releaseState?: 'approved' | 'candidate' | null;
476
+ /**
477
+ * Whether this is the version a step pinned to `channel` runs for the tenant
478
+ * (editor-side enrichment from `GET /nodes`, MAR-4075). Absent on a host that
479
+ * does not report it; the catalogue then works the answer out itself.
480
+ */
481
+ __channelVersion?: boolean;
468
482
  }
469
483
  export interface SecretRecord {
470
484
  key: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@revenexx/studio-integrations",
3
- "version": "3.4.0",
3
+ "version": "3.5.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": {
@@ -26,7 +26,7 @@
26
26
  },
27
27
  "dependencies": {
28
28
  "@nuxt/kit": "^4.0.0",
29
- "@revenexx/studio-shared": "^0.3.2",
29
+ "@revenexx/studio-shared": "^0.4.2",
30
30
  "@solar-icons/nuxt": "^1.2.0",
31
31
  "@solar-icons/vue": "^1.2.0",
32
32
  "dompurify": "^3.4.16",