@revenexx/studio-integrations 2.3.0 → 2.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.
Files changed (40) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/dist/module.json +1 -1
  3. package/dist/module.mjs +30 -2
  4. package/dist/runtime/components/Integrations/CredentialDetailDialog.vue +50 -14
  5. package/dist/runtime/components/Integrations/CredentialRevenexxSource.vue +27 -18
  6. package/dist/runtime/components/Integrations/ExpressionInput.vue +7 -4
  7. package/dist/runtime/components/Integrations/NodeInspector.vue +6 -2
  8. package/dist/runtime/components/Integrations/NodePalette.vue +5 -4
  9. package/dist/runtime/components/Integrations/NodeRetireDialog.d.vue.ts +19 -0
  10. package/dist/runtime/components/Integrations/NodeRetireDialog.vue +131 -0
  11. package/dist/runtime/components/Integrations/NodeRetireDialog.vue.d.ts +19 -0
  12. package/dist/runtime/components/Integrations/NodeVersionsPanel.d.vue.ts +5 -0
  13. package/dist/runtime/components/Integrations/NodeVersionsPanel.vue +134 -0
  14. package/dist/runtime/components/Integrations/NodeVersionsPanel.vue.d.ts +5 -0
  15. package/dist/runtime/components/Integrations/TemplateFlowPreview.vue +3 -2
  16. package/dist/runtime/components/Integrations/WorkflowContextMenu.d.vue.ts +2 -0
  17. package/dist/runtime/components/Integrations/WorkflowContextMenu.vue +3 -2
  18. package/dist/runtime/components/Integrations/WorkflowContextMenu.vue.d.ts +2 -0
  19. package/dist/runtime/components/Integrations/WorkflowCreateDialog.vue +4 -3
  20. package/dist/runtime/components/Integrations/WorkflowEditor.d.vue.ts +6 -3
  21. package/dist/runtime/components/Integrations/WorkflowEditor.vue +7 -27
  22. package/dist/runtime/components/Integrations/WorkflowEditor.vue.d.ts +6 -3
  23. package/dist/runtime/components/Integrations/WorkflowTriggerView.vue +5 -4
  24. package/dist/runtime/composables/useIntegrationsCredentials.d.ts +6 -1
  25. package/dist/runtime/composables/useIntegrationsCredentials.js +6 -3
  26. package/dist/runtime/composables/useIntegrationsNodes.js +3 -0
  27. package/dist/runtime/composables/useNodeRetirement.d.ts +48 -0
  28. package/dist/runtime/composables/useNodeRetirement.js +31 -0
  29. package/dist/runtime/data/integrationNodeCatalog.d.ts +10 -1
  30. package/dist/runtime/data/integrationNodeCatalog.js +8 -5
  31. package/dist/runtime/data/triggerGlyphs.d.ts +8 -3
  32. package/dist/runtime/data/triggerGlyphs.js +2 -2
  33. package/dist/runtime/i18n/messages/de.json +30 -1
  34. package/dist/runtime/i18n/messages/en.json +30 -1
  35. package/dist/runtime/pages/integrations/workflows/[id].vue +29 -12
  36. package/dist/runtime/types/integrations.d.ts +7 -0
  37. package/dist/runtime/utils/cockpitSources.d.ts +10 -3
  38. package/dist/runtime/utils/credentialEdit.d.ts +16 -0
  39. package/dist/runtime/utils/credentialEdit.js +7 -0
  40. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,41 @@
1
1
  # @revenexx/studio-integrations
2
2
 
3
+ ## 2.5.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 2d78623: Testing a credential on its detail view now tries the values in the form when they differ from what is stored (PO-535). It used to test the stored access data whatever had been typed, and recorded that answer, so a working new key read as failing on the list until it was saved. A test of an unsaved edit no longer changes the credential's state; with nothing changed, the test is a test of the stored credential as before. Once a test has run on the detail view, its result is the only answer shown: the credential's state at the top stands aside until the view is opened again. The time beside that state is now translated (German read "verified vor 1 Min."). The detail view gains a **Cancel** that closes it and leaves the stored values as they were. Saving changed access data now tests what was saved, so the state follows a key that was just fixed; a save that only renames asks the provider nothing. Against an Integrations API without it, a test of typed values says that they were not tried rather than showing the stored values' answer as theirs. Needs the Integrations API with PO-535, which takes an optional `config` on `POST /credentials/{id}/test`. `test()` from `useIntegrationsCredentials` gains that optional `config`, and its result an optional `recorded`.
8
+ - 0aa67e6: A publisher can see and retire the versions of the nodes a package publishes (PO-549).
9
+
10
+ - New `IntegrationsNodeVersionsPanel` component, taking a `package-name`. It lists
11
+ every node that package registers with each of its versions, marking which are
12
+ offered and which are retired, with the reason. The cockpit mounts it as the
13
+ _Nodes_ tab of one of _My integrations_.
14
+ - Retiring a version takes two steps. The publisher types it out as
15
+ `<slug>@<version>`, then sees how widely it is used (workflows, how many are
16
+ active, tenants, templates) before retiring it. A retired version can be restored
17
+ without typing.
18
+ - The palette no longer offers a version its publisher retired, and neither does
19
+ moving a node to another version. A workflow already pinned to a retired version
20
+ still draws and runs, and its step still says a newer version is available.
21
+ - The panel marks a version that was registered but never built as _Not built_,
22
+ not _Offered_.
23
+
24
+ Needs an Integrations service that reports `retired` on `GET /nodes` and serves
25
+ `/nodes/{slug}/{version}/retire` and `/usage` (revenexx/integrations#275).
26
+
27
+ ### Patch Changes
28
+
29
+ - aabada7: A toast sent from the studio now reaches the host's `<Toaster>` when the host's Vite dev server pre-bundles `@revenexx/studio` (PO-276). Vite inlined `vue-sonner` into that pre-bundle while the module's transpiled runtime imported the package itself, so the two held separate stores and no message ever appeared — with nothing in the console. The module now adds `vue-sonner` to `vite.optimizeDeps.include` whenever the host can resolve it, keeping whatever the host listed there. Hosts that already pre-bundle it need no change.
30
+ - 1f6e49c: The `{ }` mapping list is as wide as its setting and shows every entry in full, and a step nobody renamed is called what its card calls it instead of its slug (PO-601).
31
+ - b816ff6: A manual trigger no longer looks like a second Run button (PO-606). Its glyph is now a person instead of the play triangle — the Run button's glyph — on every surface that draws a trigger: canvas card, node palette, inspector, create dialog, template card and detail page. And a trigger's glyph now sits on the same light chip a node's does — on the canvas card and in a template's flow preview instead of a filled green one, so nothing on the card reads as pressable, and in the node palette and the new-workflow dialog instead of a pale green one, so a trigger looks the same from picking it to placing it. The green border, background and footer still mark it as a trigger.
32
+
33
+ ## 2.4.0
34
+
35
+ ### Minor Changes
36
+
37
+ - 22eb158: Run in the workflow editor no longer starts the saved version while the canvas shows unsaved changes (PO-567). A changed setting used to be run as its old value, with nothing on screen to say so. Run now stands in the header beside Save rather than in the canvas's control pane, and while there is unsaved work it reads **Save & run** (German **Speichern & Run**): the press saves first and runs the workflow as it then stands. A save that is refused, or waits on the question whether to keep the work as paused, starts no run. The canvas's right-click menu names the save the same way. The header's dispatch badge now shows the mode as entered in the settings, as the name beside it already did, because that is the mode the next run goes out in. `WorkflowEditor` loses its `runWaiting` prop and gains `runSaves`.
38
+
3
39
  ## 2.3.0
4
40
 
5
41
  ### 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": "2.3.0",
4
+ "version": "2.5.0",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.2",
7
7
  "unbuild": "unknown"
package/dist/module.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { readdirSync, statSync, existsSync } from 'node:fs';
2
- import { join } from 'node:path';
3
- import { defineNuxtModule, createResolver, addPlugin, hasNuxtModule, installModule, addComponentsDir, addImportsDir, extendPages } from '@nuxt/kit';
2
+ import { join, dirname } from 'node:path';
3
+ import { defineNuxtModule, createResolver, addPlugin, hasNuxtModule, installModule, extendViteConfig, addComponentsDir, addImportsDir, extendPages } from '@nuxt/kit';
4
4
 
5
5
  function toSegment(name) {
6
6
  if (name.startsWith("[...")) return `:${name.slice(4, -1)}(.*)*`;
@@ -23,6 +23,29 @@ function scanRoutes(dir, baseUrl, baseName) {
23
23
  return routes;
24
24
  }
25
25
 
26
+ const SHARED_PEERS_TO_PRE_BUNDLE = ["vue-sonner"];
27
+ function resolvableSharedPeers(rootDir, exists = existsSync) {
28
+ return SHARED_PEERS_TO_PRE_BUNDLE.filter((pkg) => {
29
+ for (let dir = rootDir; ; dir = dirname(dir)) {
30
+ if (exists(join(dir, "node_modules", pkg, "package.json"))) {
31
+ return true;
32
+ }
33
+ if (dirname(dir) === dir) {
34
+ return false;
35
+ }
36
+ }
37
+ });
38
+ }
39
+ function withPreBundled(include, peers) {
40
+ const merged = [...include ?? []];
41
+ for (const pkg of peers) {
42
+ if (!merged.includes(pkg)) {
43
+ merged.push(pkg);
44
+ }
45
+ }
46
+ return merged;
47
+ }
48
+
26
49
  const module$1 = defineNuxtModule({
27
50
  meta: {
28
51
  name: "@revenexx/studio-integrations",
@@ -58,6 +81,11 @@ const module$1 = defineNuxtModule({
58
81
  new RegExp(runtime.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
59
82
  );
60
83
  nuxt.options.build.transpile.push(runtime);
84
+ const peers = resolvableSharedPeers(nuxt.options.rootDir);
85
+ extendViteConfig((config) => {
86
+ config.optimizeDeps ??= {};
87
+ config.optimizeDeps.include = withPreBundled(config.optimizeDeps.include, peers);
88
+ });
61
89
  addComponentsDir({ path: join(runtime, "components"), pathPrefix: true });
62
90
  addImportsDir(join(runtime, "composables"));
63
91
  addPlugin(join(runtime, "plugins", "realtime.client"));
@@ -32,6 +32,7 @@ import {
32
32
  import { toast } from "../../utils/notify";
33
33
  import { apiRefusal, errorReason } from "../../utils/apiError";
34
34
  import { credentialState } from "../../utils/credentialState";
35
+ import { credentialEditDiffers } from "../../utils/credentialEdit";
35
36
  import { relativeTime } from "../../utils/relativeTime";
36
37
  import { useNow } from "../../composables/useNow";
37
38
  import { hasRevenexxSource } from "../../utils/revenexxCredentialSource";
@@ -98,9 +99,11 @@ function formatDateTime(iso) {
98
99
  return String(iso);
99
100
  }
100
101
  }
102
+ let viewOpening = 0;
101
103
  watch(
102
104
  [() => props.open, () => props.credential?.id],
103
105
  () => {
106
+ viewOpening++;
104
107
  if (!props.open || !props.credential) return;
105
108
  tab.value = "credentials";
106
109
  form.name = props.credential.name;
@@ -122,26 +125,41 @@ function buildConfigPayload() {
122
125
  }
123
126
  async function runTest() {
124
127
  if (!props.credential) return;
128
+ const opening = viewOpening;
125
129
  testing.value = true;
126
130
  liveTest.value = null;
127
131
  clearRefusal();
128
132
  try {
129
- liveTest.value = await test(props.credential.id);
130
- await load(true);
131
- emit("changed");
133
+ const payload = buildConfigPayload();
134
+ const edited = credentialEditDiffers(fieldsOf(props.type), payload, props.credential.public_config ?? {});
135
+ const result = await test(props.credential.id, edited ? payload : void 0);
136
+ if (result.recorded !== false) {
137
+ await load(true);
138
+ emit("changed");
139
+ }
140
+ if (opening !== viewOpening) return;
141
+ liveTest.value = edited && result.recorded === void 0 && !result.error ? { ok: false, error: t("integration.credentials.detail.test.editNotSupported") } : result;
132
142
  } finally {
133
- testing.value = false;
143
+ if (opening === viewOpening) testing.value = false;
134
144
  }
135
145
  }
146
+ async function testSaved(id) {
147
+ await test(id);
148
+ emit("changed");
149
+ }
136
150
  async function save() {
137
151
  if (!props.credential || !form.name.trim()) return;
138
152
  saving.value = true;
139
153
  clearRefusal();
154
+ const id = props.credential.id;
155
+ const payload = buildConfigPayload();
156
+ const edited = credentialEditDiffers(fieldsOf(props.type), payload, props.credential.public_config ?? {});
140
157
  try {
141
- await update(props.credential.id, { name: form.name.trim(), config: buildConfigPayload() });
158
+ await update(id, { name: form.name.trim(), config: payload });
142
159
  emit("changed");
143
160
  emit("update:open", false);
144
161
  toast.success(t("integration.credentials.toast.updated"));
162
+ if (edited) void testSaved(id);
145
163
  } catch (e) {
146
164
  saveRefusal.value = e;
147
165
  void focusFirstRefusedField();
@@ -209,13 +227,18 @@ async function connect() {
209
227
  pane was the whole tab, so nothing about the spacing around them moves. -->
210
228
  <template v-if="tab === 'credentials'">
211
229
  <div class="space-y-5 max-h-[62vh] overflow-y-auto pr-1 pt-4">
212
- <!-- Connection state — the headline answer: are these credentials correct? -->
213
- <div class="rounded-lg border p-3" :class="state.toneClass">
230
+ <!-- Connection state — the headline answer: are these credentials correct?
231
+ Until the operator tests from here: from then on the result below is
232
+ the only answer on screen. The state is about the stored values and a
233
+ test may have tried the typed ones, and two answers side by side — red
234
+ above, green below — were read as the studio contradicting itself, not
235
+ as two questions (PO-535). -->
236
+ <div v-if="!liveTest" class="rounded-lg border p-3" :class="state.toneClass">
214
237
  <div class="flex items-center gap-2">
215
238
  <CheckCircle2 v-if="state.key === 'connected'" :size="16" class="shrink-0" />
216
239
  <XCircle v-else-if="state.key === 'failed'" :size="16" class="shrink-0" />
217
240
  <span class="font-medium">{{ state.label }}</span>
218
- <span v-if="state.at" class="text-sm opacity-70">· verified {{ relativeTime(state.at, now) }}</span>
241
+ <span v-if="state.at" class="text-sm opacity-70">{{ t("integration.credentials.detail.state.testedAt", { time: relativeTime(state.at, now) }) }}</span>
219
242
  <div class="flex-1" />
220
243
  <Button
221
244
  v-if="isOAuthAuthcode"
@@ -229,19 +252,26 @@ async function connect() {
229
252
  {{ t("integration.credentials.detail.action.reconnect") }}
230
253
  </Button>
231
254
  </div>
232
- <!-- The stored sentence, and only while nothing fresher is on screen. The
233
- live box below shows the result of a test just run, and the reload that
234
- follows it puts the very same sentence here — so both would print it
235
- twice, and past the length the platform truncates at, twice differently.
236
- The freshly returned one wins, being whole (specs/credential-test.md
255
+ <!-- The stored sentence. Never beside a fresh one: the whole block stands
256
+ aside once a test has run here, and the reload that follows a test puts
257
+ the same sentence in it — printed twice, and past the length the
258
+ platform truncates at, twice differently (specs/credential-test.md
237
259
  AC-1). -->
238
- <p v-if="state.message && !liveTest" class="mt-1 text-sm break-words opacity-90">
260
+ <p v-if="state.message" class="mt-1 text-sm break-words opacity-90">
239
261
  {{ state.message }}
240
262
  </p>
241
263
  <p v-else-if="state.key === 'untested'" class="mt-1 text-sm opacity-80">
242
264
  {{ t("integration.credentials.detail.state.untestedHint") }}
243
265
  </p>
244
266
  </div>
267
+ <!-- The state stood aside for a test result, and took the way to reconnect
268
+ with it; a three-legged credential keeps that way open. -->
269
+ <div v-else-if="isOAuthAuthcode" class="flex justify-end">
270
+ <Button size="sm" variant="outline" :disabled="connecting" @click="connect">
271
+ <Loader2 v-if="connecting" :size="13" class="mr-1 animate-spin" />
272
+ {{ t("integration.credentials.detail.action.reconnect") }}
273
+ </Button>
274
+ </div>
245
275
 
246
276
  <!-- Editable fields -->
247
277
  <div class="space-y-3">
@@ -359,6 +389,12 @@ async function connect() {
359
389
 
360
390
  <div class="flex items-center gap-2 pt-3">
361
391
  <div class="flex-1" />
392
+ <!-- Leaves everything as stored: what was typed goes, since the form is
393
+ seeded afresh each time the view opens. The one click back after a test
394
+ of a new key failed, where the ✕ does not say it discards anything. -->
395
+ <Button variant="ghost" :disabled="saving" @click="emit('update:open', false)">
396
+ {{ t("integration.common.action.cancel") }}
397
+ </Button>
362
398
  <Button variant="outline" :disabled="testing || saving" @click="runTest">
363
399
  <Loader2 v-if="testing" :size="14" class="mr-1 animate-spin" />
364
400
  <TestIcon v-else :size="14" class="mr-1" />
@@ -130,23 +130,39 @@ async function applySftp() {
130
130
  applying.value = false;
131
131
  }
132
132
  }
133
+ function smtpLogin(cred) {
134
+ return { user: cred.smtp_username || cred.name, password: cred.smtp_password || cred.key || "" };
135
+ }
136
+ function applyMailLogin(cred) {
137
+ const login = smtpLogin(cred);
138
+ if (!login.password) {
139
+ toast.error(t("integration.credentials.revenexxSource.error.mailCredentialNoSecret"));
140
+ emit("apply", { user: "", password: "" });
141
+ applied.value = null;
142
+ return false;
143
+ }
144
+ emit("apply", {
145
+ host: mailSmtpHost,
146
+ port: mailSmtpPort,
147
+ user: login.user,
148
+ password: login.password,
149
+ secure: mailSmtpPort === 465
150
+ });
151
+ applied.value = `${login.user}@${mailSmtpHost}:${mailSmtpPort}`;
152
+ return true;
153
+ }
133
154
  async function applySmtp() {
134
155
  if (!mail) return;
156
+ const id = selectedId.value;
135
157
  applying.value = true;
136
158
  try {
137
- const cred = await mail.getCredential(selectedId.value);
159
+ const cred = await mail.getCredential(id);
160
+ if (selectedId.value !== id) return;
138
161
  if (!cred) {
139
162
  toast.error(t("integration.credentials.revenexxSource.error.mailCredentialGone"));
140
163
  return;
141
164
  }
142
- emit("apply", {
143
- host: mailSmtpHost,
144
- port: mailSmtpPort,
145
- user: cred.smtp_username ?? "",
146
- password: cred.smtp_password ?? "",
147
- secure: mailSmtpPort === 465
148
- });
149
- applied.value = `${cred.smtp_username}@${mailSmtpHost}:${mailSmtpPort}`;
165
+ applyMailLogin(cred);
150
166
  } catch (e) {
151
167
  toast.error(errorReason(e, t("integration.credentials.revenexxSource.error.readMailCredential")));
152
168
  } finally {
@@ -218,18 +234,11 @@ async function createSmtp() {
218
234
  toast.error(t("integration.credentials.revenexxSource.error.mailCredentialNotReturned"));
219
235
  return;
220
236
  }
221
- emit("apply", {
222
- host: mailSmtpHost,
223
- port: mailSmtpPort,
224
- user: cred.smtp_username ?? "",
225
- password: cred.smtp_password ?? "",
226
- secure: mailSmtpPort === 465
227
- });
228
- applied.value = `${cred.smtp_username}@${mailSmtpHost}:${mailSmtpPort}`;
237
+ const copied = applyMailLogin(cred);
229
238
  smtpCreds.value = [{ uuid: cred.uuid, name: cred.name }, ...smtpCreds.value];
230
239
  selectedId.value = cred.uuid;
231
240
  createOpen.value = false;
232
- toast.success(t("integration.credentials.revenexxSource.toast.smtpCreated"));
241
+ if (copied) toast.success(t("integration.credentials.revenexxSource.toast.smtpCreated"));
233
242
  } catch (e) {
234
243
  toast.error(errorReason(e, t("integration.credentials.revenexxSource.error.createSmtp")));
235
244
  } finally {
@@ -352,7 +352,10 @@ function insert(expr) {
352
352
  <span class="min-w-0 truncate font-mono" :class="preview.misses ? 'text-amber-600' : 'text-emerald-700'">{{ preview.resolved }}</span>
353
353
  </p>
354
354
 
355
- <!-- Named, because the same expressions are offered twice on this surface —
355
+ <!-- As wide as the setting, and every line wraps (PO-601): entries of one step
356
+ differ only at their end — `invoices.count`, `invoices.invoices` — and a
357
+ fixed 20rem with `truncate` cut exactly that part off.
358
+ Named, because the same expressions are offered twice on this surface —
356
359
  here and in the panel beside the form — and a popup nothing can address
357
360
  is a popup nothing can test either. -->
358
361
  <div
@@ -360,7 +363,7 @@ function insert(expr) {
360
363
  :id="pickerId"
361
364
  role="group"
362
365
  :aria-label="t('integration.nodeConfig.expression.picker.aria')"
363
- class="absolute right-0 top-full z-50 mt-1 flex max-h-72 w-80 flex-col rounded-md border border-black/[0.08] bg-white text-sm shadow-lg"
366
+ class="absolute right-0 top-full z-50 mt-1 flex max-h-72 w-full min-w-80 flex-col rounded-md border border-black/[0.08] bg-white text-sm shadow-lg"
364
367
  @keydown="onPickerKeydown"
365
368
  >
366
369
  <div class="shrink-0 border-b border-black/[0.06] p-1">
@@ -387,10 +390,10 @@ function insert(expr) {
387
390
  class="block w-full px-2 py-1 text-left transition hover:bg-muted/50"
388
391
  @mousedown.prevent="insert(item.expr)"
389
392
  >
390
- <div class="truncate text-muted-foreground">
393
+ <div class="break-words text-muted-foreground">
391
394
  {{ item.label }}
392
395
  </div>
393
- <div class="truncate font-mono text-emerald-700">
396
+ <div class="break-all font-mono text-emerald-700">
394
397
  {{ item.expr }}
395
398
  </div>
396
399
  </button>
@@ -52,6 +52,7 @@ import {
52
52
  valueAtPath
53
53
  } from "../../data/nodeRunData";
54
54
  import { resolveDisplayName, resolveLocalized } from "../../utils/localize";
55
+ import { nodeDisplayName } from "../../utils/nodeDisplayName";
55
56
  import { describeRecurrence, parseCron, scheduleTimezone, toCron } from "../../utils/schedule";
56
57
  import { errorReason } from "../../utils/apiError";
57
58
  const t = useIntegrationsT();
@@ -616,7 +617,10 @@ const sampleContext = computed(() => {
616
617
  });
617
618
  provide("integrationsSampleContext", sampleContext);
618
619
  provide("integrationsNodeNames", computed(
619
- () => Object.fromEntries((props.nodes ?? []).map((n) => [n.id, resolveDisplayName(n.name) || n.nodeSlug]))
620
+ () => (
621
+ // A pill names an unnamed step as its card does, not by its slug (PO-601).
622
+ Object.fromEntries((props.nodes ?? []).map((n) => [n.id, nodeDisplayName(n)]))
623
+ )
620
624
  ));
621
625
  const upstreamSections = computed(() => {
622
626
  if (!isNode.value) return [];
@@ -624,7 +628,7 @@ const upstreamSections = computed(() => {
624
628
  const m = findManifest(node.nodeSlug, node.nodeVersion);
625
629
  return {
626
630
  id: node.id,
627
- name: resolveDisplayName(node.name) || node.nodeSlug,
631
+ name: nodeDisplayName(node),
628
632
  manifest: m,
629
633
  direct,
630
634
  ports: withSampledFields(portsForNode(node, m, props.dynamicOutputs?.[node.id]), sampleFor(node.id))
@@ -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, NODE_CATEGORIES, TRIGGER_TEMPLATES } from "../../data/integrationNodeCatalog";
6
+ import { INTEGRATION_NODE_CATALOG, isOffered, NODE_CATEGORIES, TRIGGER_TEMPLATES } from "../../data/integrationNodeCatalog";
7
7
  import { resolveLocalized, studioLocale } from "../../utils/localize";
8
8
  import { orderFolderLeaves, orderFolders } from "../../utils/paletteOrder";
9
9
  const { load: loadNodes } = useIntegrationsNodes();
@@ -88,7 +88,7 @@ function makeGroup(label) {
88
88
  return { label, children: /* @__PURE__ */ new Map(), leaves: [], count: 0 };
89
89
  }
90
90
  function isExecutable(m) {
91
- return m.__hasBundle !== false;
91
+ return isOffered(m);
92
92
  }
93
93
  const latestManifests = computed(() => {
94
94
  const seen = /* @__PURE__ */ new Set();
@@ -274,6 +274,7 @@ function insertManifest(leaf) {
274
274
  function insertTrigger(tpl) {
275
275
  emit("insert", { kind: "trigger", type: tpl.type });
276
276
  }
277
+ const triggerChipClass = NODE_CATEGORIES.find((c) => c.key === "trigger")?.color;
277
278
  function categoryColor(key) {
278
279
  const meta = key ? CORE_FOLDERS[key] : void 0;
279
280
  if (meta) return meta.color;
@@ -351,7 +352,7 @@ const searching = computed(() => search.value.trim().length > 0);
351
352
  @dragend="onDragEnd"
352
353
  @click="insertTrigger(tpl)"
353
354
  >
354
- <span class="flex size-6 shrink-0 items-center justify-center rounded-md bg-emerald-100 text-emerald-600">
355
+ <span class="flex size-6 shrink-0 items-center justify-center rounded-md" :class="triggerChipClass">
355
356
  <component :is="triggerGlyph(tpl.type)" :size="13" />
356
357
  </span>
357
358
  <div class="min-w-0">
@@ -415,7 +416,7 @@ const searching = computed(() => search.value.trim().length > 0);
415
416
  @dragend="onDragEnd"
416
417
  @click="insertTrigger(tpl)"
417
418
  >
418
- <span class="flex size-6 shrink-0 items-center justify-center rounded-md bg-emerald-100 text-emerald-600">
419
+ <span class="flex size-6 shrink-0 items-center justify-center rounded-md" :class="triggerChipClass">
419
420
  <component :is="triggerGlyph(tpl.type)" :size="13" />
420
421
  </span>
421
422
  <div class="min-w-0">
@@ -0,0 +1,19 @@
1
+ export type RetirableVersion = {
2
+ slug: string;
3
+ version: string;
4
+ name: string;
5
+ };
6
+ type __VLS_Props = {
7
+ target: RetirableVersion | null;
8
+ };
9
+ type __VLS_PublicProps = __VLS_Props & {
10
+ 'open'?: boolean;
11
+ };
12
+ declare const _default: import("vue").DefineComponent<__VLS_PublicProps, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
13
+ "update:open": (value: boolean) => any;
14
+ retired: (target: RetirableVersion) => any;
15
+ }, string, import("vue").PublicProps, Readonly<__VLS_PublicProps> & Readonly<{
16
+ "onUpdate:open"?: ((value: boolean) => any) | undefined;
17
+ onRetired?: ((target: RetirableVersion) => any) | undefined;
18
+ }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
19
+ export default _default;
@@ -0,0 +1,131 @@
1
+ <script setup>
2
+ import { computed, ref, watch } from "vue";
3
+ import { RefreshCircle as Loader2 } from "#solar-icons";
4
+ import {
5
+ AlertDialog,
6
+ AlertDialogContent,
7
+ AlertDialogDescription,
8
+ AlertDialogFooter,
9
+ AlertDialogHeader,
10
+ AlertDialogTitle,
11
+ Button,
12
+ Input
13
+ } from "@revenexx/studio";
14
+ import { useIntegrationsT } from "../../composables/useIntegrationsT";
15
+ import { useNodeRetirement } from "../../composables/useNodeRetirement";
16
+ import { errorReason } from "../../utils/apiError";
17
+ const open = defineModel("open", { type: Boolean, ...{ default: false } });
18
+ const props = defineProps({
19
+ target: { type: [Object, null], required: true }
20
+ });
21
+ const emit = defineEmits(["retired"]);
22
+ const t = useIntegrationsT();
23
+ const { usage: readUsage, retire } = useNodeRetirement();
24
+ const typed = ref("");
25
+ const reason = ref("");
26
+ const step = ref("confirm");
27
+ const usage = ref(null);
28
+ const busy = ref(false);
29
+ const error = ref(null);
30
+ const expected = computed(() => props.target ? `${props.target.slug}@${props.target.version}` : "");
31
+ const typedMatches = computed(() => typed.value.trim() === expected.value);
32
+ const PLACEHOLDER = "\0";
33
+ const confirmPrompt = computed(() => {
34
+ const [before = "", after = ""] = t("integration.nodeVersions.retire.confirm.label", { version: PLACEHOLDER }).split(PLACEHOLDER);
35
+ return { before, after };
36
+ });
37
+ watch(open, (o) => {
38
+ if (!o) return;
39
+ typed.value = "";
40
+ reason.value = "";
41
+ step.value = "confirm";
42
+ usage.value = null;
43
+ error.value = null;
44
+ });
45
+ async function check() {
46
+ const target = props.target;
47
+ if (!target || !typedMatches.value || busy.value) return;
48
+ busy.value = true;
49
+ error.value = null;
50
+ try {
51
+ usage.value = await readUsage(target.slug, target.version);
52
+ step.value = "review";
53
+ } catch (e) {
54
+ error.value = errorReason(e, t("integration.nodeVersions.retire.error.generic"));
55
+ } finally {
56
+ busy.value = false;
57
+ }
58
+ }
59
+ async function confirm() {
60
+ const target = props.target;
61
+ if (!target || busy.value) return;
62
+ busy.value = true;
63
+ error.value = null;
64
+ try {
65
+ await retire(target.slug, target.version, reason.value.trim() || null);
66
+ open.value = false;
67
+ emit("retired", target);
68
+ } catch (e) {
69
+ error.value = errorReason(e, t("integration.nodeVersions.retire.error.generic"));
70
+ } finally {
71
+ busy.value = false;
72
+ }
73
+ }
74
+ </script>
75
+
76
+ <template>
77
+ <AlertDialog :open="open" @update:open="(v) => {
78
+ if (!v && !busy) open = false;
79
+ }">
80
+ <AlertDialogContent>
81
+ <AlertDialogHeader>
82
+ <AlertDialogTitle>{{ t("integration.nodeVersions.retire.dialog.title") }}</AlertDialogTitle>
83
+ <AlertDialogDescription>
84
+ <span v-if="props.target">
85
+ <span class="font-medium text-foreground font-mono">{{ expected }}</span>
86
+ {{ t("integration.nodeVersions.retire.dialog.description") }}
87
+ </span>
88
+ </AlertDialogDescription>
89
+ </AlertDialogHeader>
90
+
91
+ <form v-if="step === 'confirm'" class="space-y-3" @submit.prevent="check">
92
+ <label class="block space-y-1 text-sm">
93
+ <span>{{ confirmPrompt.before }}<code class="rounded bg-muted px-1 py-0.5 font-mono text-xs text-foreground">{{ expected }}</code>{{ confirmPrompt.after }}</span>
94
+ <Input v-model="typed" autocomplete="off" spellcheck="false" class="font-mono" :aria-label="t('integration.nodeVersions.retire.confirm.aria')" />
95
+ </label>
96
+ <label class="block space-y-1 text-sm">
97
+ <span>{{ t("integration.nodeVersions.retire.reason.label") }}</span>
98
+ <Input v-model="reason" :placeholder="t('integration.nodeVersions.retire.reason.placeholder')" />
99
+ </label>
100
+ </form>
101
+
102
+ <div v-else-if="usage" class="space-y-2 text-sm" data-testid="node-retire-usage">
103
+ <p v-if="usage.workflows === 0 && usage.templates === 0">
104
+ {{ t("integration.nodeVersions.retire.usage.none") }}
105
+ </p>
106
+ <template v-else>
107
+ <p class="font-medium">
108
+ {{ t("integration.nodeVersions.retire.usage.counts", { workflows: usage.workflows, active: usage.active_workflows, tenants: usage.tenants, templates: usage.templates }) }}
109
+ </p>
110
+ <p class="text-muted-foreground">{{ t("integration.nodeVersions.retire.usage.keepWorking") }}</p>
111
+ </template>
112
+ </div>
113
+
114
+ <p v-if="error" class="text-sm text-rose-600">{{ error }}</p>
115
+
116
+ <AlertDialogFooter>
117
+ <Button variant="outline" :disabled="busy" @click="open = false">
118
+ {{ t("integration.common.action.cancel") }}
119
+ </Button>
120
+ <Button v-if="step === 'confirm'" :disabled="!typedMatches || busy" @click="check">
121
+ <Loader2 v-if="busy" :size="14" class="animate-spin" />
122
+ {{ t("integration.nodeVersions.retire.action.check") }}
123
+ </Button>
124
+ <Button v-else class="bg-destructive text-white hover:bg-destructive/90" :disabled="busy" @click="confirm">
125
+ <Loader2 v-if="busy" :size="14" class="animate-spin" />
126
+ {{ t("integration.nodeVersions.retire.action.retire") }}
127
+ </Button>
128
+ </AlertDialogFooter>
129
+ </AlertDialogContent>
130
+ </AlertDialog>
131
+ </template>
@@ -0,0 +1,19 @@
1
+ export type RetirableVersion = {
2
+ slug: string;
3
+ version: string;
4
+ name: string;
5
+ };
6
+ type __VLS_Props = {
7
+ target: RetirableVersion | null;
8
+ };
9
+ type __VLS_PublicProps = __VLS_Props & {
10
+ 'open'?: boolean;
11
+ };
12
+ declare const _default: import("vue").DefineComponent<__VLS_PublicProps, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
13
+ "update:open": (value: boolean) => any;
14
+ retired: (target: RetirableVersion) => any;
15
+ }, string, import("vue").PublicProps, Readonly<__VLS_PublicProps> & Readonly<{
16
+ "onUpdate:open"?: ((value: boolean) => any) | undefined;
17
+ onRetired?: ((target: RetirableVersion) => any) | undefined;
18
+ }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
19
+ export default _default;
@@ -0,0 +1,5 @@
1
+ type __VLS_Props = {
2
+ packageName: string | null;
3
+ };
4
+ declare const _default: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
5
+ export default _default;