@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
@@ -0,0 +1,134 @@
1
+ <script setup>
2
+ import { computed, onMounted, ref, watch } from "vue";
3
+ import { RefreshCircle as Loader2 } from "#solar-icons";
4
+ import { Badge, Button, Card, CardContent, CardHeader, CardTitle } from "@revenexx/studio";
5
+ import { useIntegrationsT } from "../../composables/useIntegrationsT";
6
+ import { useNodeRetirement } from "../../composables/useNodeRetirement";
7
+ import { errorReason } from "../../utils/apiError";
8
+ import NodeRetireDialog, {} from "./NodeRetireDialog.vue";
9
+ const props = defineProps({
10
+ packageName: { type: [String, null], required: true }
11
+ });
12
+ const t = useIntegrationsT();
13
+ const { versionsOfPackage, restore } = useNodeRetirement();
14
+ const versions = ref([]);
15
+ const loading = ref(false);
16
+ const error = ref(null);
17
+ const restoring = ref(null);
18
+ const retireTarget = ref(null);
19
+ const retireOpen = ref(false);
20
+ const nodes = computed(() => {
21
+ const bySlug = /* @__PURE__ */ new Map();
22
+ for (const v of versions.value) {
23
+ const entry = bySlug.get(v.slug) ?? { slug: v.slug, name: v.name, versions: [] };
24
+ entry.versions.push(v);
25
+ bySlug.set(v.slug, entry);
26
+ }
27
+ return [...bySlug.values()];
28
+ });
29
+ async function load() {
30
+ if (!props.packageName) {
31
+ versions.value = [];
32
+ return;
33
+ }
34
+ loading.value = true;
35
+ error.value = null;
36
+ try {
37
+ versions.value = await versionsOfPackage(props.packageName);
38
+ } catch (e) {
39
+ error.value = errorReason(e, t("integration.nodeVersions.error.load"));
40
+ } finally {
41
+ loading.value = false;
42
+ }
43
+ }
44
+ function askRetire(v) {
45
+ retireTarget.value = { slug: v.slug, version: v.version, name: v.name };
46
+ retireOpen.value = true;
47
+ }
48
+ async function doRestore(v) {
49
+ const key = `${v.slug}@${v.version}`;
50
+ restoring.value = key;
51
+ error.value = null;
52
+ try {
53
+ await restore(v.slug, v.version);
54
+ await load();
55
+ } catch (e) {
56
+ error.value = errorReason(e, t("integration.nodeVersions.restore.error.generic"));
57
+ } finally {
58
+ restoring.value = null;
59
+ }
60
+ }
61
+ watch(() => props.packageName, () => void load());
62
+ onMounted(() => void load());
63
+ </script>
64
+
65
+ <template>
66
+ <div class="space-y-4" data-testid="node-versions-panel">
67
+ <p v-if="!packageName" class="text-sm text-muted-foreground">
68
+ {{ t("integration.nodeVersions.empty.noPackage") }}
69
+ </p>
70
+ <p v-else-if="error" class="text-sm text-rose-600">{{ error }}</p>
71
+ <div v-if="loading" class="flex items-center gap-2 text-sm text-muted-foreground">
72
+ <Loader2 :size="14" class="animate-spin" />
73
+ {{ t("integration.nodeVersions.loading") }}
74
+ </div>
75
+ <p v-else-if="packageName && !error && !nodes.length" class="text-sm text-muted-foreground">
76
+ {{ t("integration.nodeVersions.empty.noNodes") }}
77
+ </p>
78
+
79
+ <Card v-for="node in nodes" :key="node.slug" :data-testid="`node-versions-${node.slug}`">
80
+ <CardHeader class="pb-2">
81
+ <CardTitle class="flex flex-wrap items-baseline gap-2 text-base">
82
+ <span>{{ node.name }}</span>
83
+ <span class="font-mono text-xs font-normal text-muted-foreground">{{ node.slug }}</span>
84
+ </CardTitle>
85
+ </CardHeader>
86
+ <CardContent>
87
+ <table class="w-full text-sm">
88
+ <thead class="text-left text-muted-foreground">
89
+ <tr>
90
+ <th class="py-1 pr-3 font-medium">{{ t("integration.nodeVersions.column.version") }}</th>
91
+ <th class="py-1 pr-3 font-medium">{{ t("integration.nodeVersions.column.state") }}</th>
92
+ <th class="py-1" />
93
+ </tr>
94
+ </thead>
95
+ <tbody>
96
+ <tr v-for="v in node.versions" :key="v.version" class="border-t border-border" :data-testid="`node-version-${v.version}`">
97
+ <td class="py-2 pr-3 font-mono">v{{ v.version }}</td>
98
+ <td class="py-2 pr-3">
99
+ <Badge v-if="v.retired" variant="outline" class="text-xs font-medium border-amber-500/30 text-amber-700">
100
+ {{ t("integration.nodeVersions.badge.retired") }}
101
+ </Badge>
102
+ <!-- Not retired is not the same as offered: a version whose code was
103
+ never built is left out of the palette too (PO-338). -->
104
+ <Badge v-else-if="v.has_bundle === false" variant="outline" class="text-xs font-medium border-slate-400/40 text-slate-600">
105
+ {{ t("integration.nodeVersions.badge.notBuilt") }}
106
+ </Badge>
107
+ <Badge v-else variant="outline" class="text-xs font-medium border-emerald-500/30 text-emerald-700">
108
+ {{ t("integration.nodeVersions.badge.offered") }}
109
+ </Badge>
110
+ <span v-if="v.retired && v.retirement_reason" class="ml-2 text-muted-foreground">{{ v.retirement_reason }}</span>
111
+ </td>
112
+ <td class="py-2 text-right">
113
+ <Button
114
+ v-if="v.retired"
115
+ variant="outline"
116
+ size="sm"
117
+ :disabled="restoring === `${v.slug}@${v.version}`"
118
+ @click="doRestore(v)"
119
+ >
120
+ {{ t("integration.nodeVersions.action.restore") }}
121
+ </Button>
122
+ <Button v-else variant="outline" size="sm" class="text-destructive" @click="askRetire(v)">
123
+ {{ t("integration.nodeVersions.action.retire") }}
124
+ </Button>
125
+ </td>
126
+ </tr>
127
+ </tbody>
128
+ </table>
129
+ </CardContent>
130
+ </Card>
131
+
132
+ <NodeRetireDialog v-model:open="retireOpen" :target="retireTarget" @retired="load" />
133
+ </div>
134
+ </template>
@@ -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;
@@ -106,6 +106,7 @@ const EDGE_STROKE = {
106
106
  trigger: "#94a3b8",
107
107
  error: "#cbd5e1"
108
108
  };
109
+ const triggerChipClass = NODE_CATEGORIES.find((c) => c.key === "trigger")?.color;
109
110
  const bounds = computed(() => {
110
111
  if (!boxes.value.length) return { x: 0, y: 0, w: 1, h: 1 };
111
112
  const minX = Math.min(...boxes.value.map((b) => b.x)) - props.padding;
@@ -165,8 +166,8 @@ const bounds = computed(() => {
165
166
  class="flex h-full w-full flex-col rounded-lg border-2 border-emerald-400/40 bg-gradient-to-br from-emerald-50 to-white shadow-sm"
166
167
  >
167
168
  <div class="flex flex-1 items-center gap-2.5 px-3 py-2">
168
- <span class="flex size-8 shrink-0 items-center justify-center rounded-md bg-emerald-500 text-white shadow-sm">
169
- <component :is="b.icon" :size="15" />
169
+ <span class="flex size-8 shrink-0 items-center justify-center rounded-md" :class="triggerChipClass">
170
+ <component :is="b.icon" :size="18" />
170
171
  </span>
171
172
  <div class="min-w-0 flex-1">
172
173
  <div class="truncate text-sm font-semibold leading-tight text-foreground">{{ b.label }}</div>
@@ -11,6 +11,8 @@ type __VLS_Props = {
11
11
  targetIsTrigger?: boolean;
12
12
  /** Busy flag for the Run item. */
13
13
  runBusy?: boolean;
14
+ /** The workflow has unsaved work, so the Run item saves it first — and says so. */
15
+ runSaves?: boolean;
14
16
  };
15
17
  declare var __VLS_14: {};
16
18
  type __VLS_Slots = {} & {
@@ -16,7 +16,8 @@ const props = defineProps({
16
16
  selectionCount: { type: Number, required: true },
17
17
  totalNodes: { type: Number, required: true },
18
18
  targetIsTrigger: { type: Boolean, required: false },
19
- runBusy: { type: Boolean, required: false }
19
+ runBusy: { type: Boolean, required: false },
20
+ runSaves: { type: Boolean, required: false }
20
21
  });
21
22
  const emit = defineEmits(["action", "update:open"]);
22
23
  const t = useIntegrationsT();
@@ -33,7 +34,7 @@ const sections = computed(() => {
33
34
  { id: "duplicate", label: t("integration.canvas.contextMenu.item.duplicate"), shortcut: primaryShortcut("duplicate"), disabled: props.targetIsTrigger }
34
35
  ],
35
36
  [
36
- { id: "run", label: t("integration.canvas.contextMenu.item.runWorkflow"), shortcut: primaryShortcut("run"), disabled: props.runBusy },
37
+ { id: "run", label: t(props.runSaves ? "integration.canvas.contextMenu.item.saveAndRunWorkflow" : "integration.canvas.contextMenu.item.runWorkflow"), shortcut: primaryShortcut("run"), disabled: props.runBusy },
37
38
  { id: "tidy-up", label: t("integration.canvas.contextMenu.item.tidyUp"), shortcut: primaryShortcut("tidyUp") }
38
39
  ],
39
40
  [
@@ -11,6 +11,8 @@ type __VLS_Props = {
11
11
  targetIsTrigger?: boolean;
12
12
  /** Busy flag for the Run item. */
13
13
  runBusy?: boolean;
14
+ /** The workflow has unsaved work, so the Run item saves it first — and says so. */
15
+ runSaves?: boolean;
14
16
  };
15
17
  declare var __VLS_14: {};
16
18
  type __VLS_Slots = {} & {
@@ -18,7 +18,7 @@ import {
18
18
  SelectValue,
19
19
  Textarea
20
20
  } from "@revenexx/studio";
21
- import { TRIGGER_TEMPLATES } from "../../data/integrationNodeCatalog";
21
+ import { NODE_CATEGORIES, TRIGGER_TEMPLATES } from "../../data/integrationNodeCatalog";
22
22
  import LoadingAnnouncement from "./LoadingAnnouncement.vue";
23
23
  import TemplateCard from "./TemplateCard.vue";
24
24
  import TemplateInstallStep from "./TemplateInstallStep.vue";
@@ -61,6 +61,7 @@ const executionModes = computed(() => [
61
61
  { key: "caller_decides", label: t("integration.workflows.create.executionMode.callerDecides.label"), hint: t("integration.workflows.create.executionMode.callerDecides.hint"), icon: "SlidersHorizontal" }
62
62
  ]);
63
63
  const selectedTrigger = computed(() => TRIGGER_TEMPLATES.find((tt) => tt.type === triggerType.value) ?? null);
64
+ const triggerChipClass = NODE_CATEGORIES.find((c) => c.key === "trigger")?.color;
64
65
  const selectedMode = computed(() => executionModes.value.find((m) => m.key === executionMode.value) ?? executionModes.value[0]);
65
66
  watch(open, (v) => {
66
67
  if (!v) return;
@@ -196,7 +197,7 @@ async function installed(id) {
196
197
  <SelectTrigger id="wf-trigger" class="w-full bg-white">
197
198
  <SelectValue>
198
199
  <span class="flex min-w-0 items-center gap-2">
199
- <span class="flex size-6 shrink-0 items-center justify-center rounded-md bg-emerald-100 text-emerald-600">
200
+ <span class="flex size-6 shrink-0 items-center justify-center rounded-md" :class="triggerChipClass">
200
201
  <component :is="triggerGlyph(selectedTrigger?.type)" :size="13" />
201
202
  </span>
202
203
  <span class="shrink-0 text-sm font-medium">{{ resolveLocalized(selectedTrigger?.name) }}</span>
@@ -207,7 +208,7 @@ async function installed(id) {
207
208
  <SelectContent>
208
209
  <SelectItem v-for="tpl in TRIGGER_TEMPLATES" :key="tpl.id" :value="tpl.type">
209
210
  <span class="flex items-center gap-2.5 py-0.5">
210
- <span class="flex size-7 shrink-0 items-center justify-center rounded-md bg-emerald-100 text-emerald-600">
211
+ <span class="flex size-7 shrink-0 items-center justify-center rounded-md" :class="triggerChipClass">
211
212
  <component :is="triggerGlyph(tpl.type)" :size="14" />
212
213
  </span>
213
214
  <span class="min-w-0">
@@ -20,10 +20,13 @@ type __VLS_Props = {
20
20
  * keystrokes.
21
21
  */
22
22
  draft?: WorkflowBlob | null;
23
- /** Busy flag for the control-pane Play button (a run is being triggered). */
23
+ /** A run is being triggered — the context menu's Run item and the shortcut stand down. */
24
24
  runBusy?: boolean;
25
- /** The run is queued behind a bundle build — busy, but worth saying why. */
26
- runWaiting?: boolean;
25
+ /**
26
+ * The workflow has unsaved work, so Run saves it first. Only the context menu
27
+ * reads it, to say so: Run itself stands in the page header, beside Save.
28
+ */
29
+ runSaves?: boolean;
27
30
  /**
28
31
  * One of the page's own lightboxes is open above the editor (settings,
29
32
  * duplicate, run payload, the sync response, the deactivation prompt). The
@@ -13,10 +13,8 @@ import { MiniMap } from "@vue-flow/minimap";
13
13
  import {
14
14
  Widget as LayoutGrid,
15
15
  MagicStick as Sparkles,
16
- Play,
17
16
  MinusCircle as ZoomOut,
18
- AddCircle as ZoomIn,
19
- RefreshCircle as Loader2
17
+ AddCircle as ZoomIn
20
18
  } from "#solar-icons";
21
19
  import {
22
20
  Dialog,
@@ -60,12 +58,13 @@ import { deepClone } from "../../utils/deepClone";
60
58
  import { copyToClipboard } from "../../utils/clipboard";
61
59
  import { groupFindingsByNode, findingsByNodeField } from "../../utils/validationFindings";
62
60
  import { reorderBranchSubtrees } from "../../data/branchReorder";
63
- import { resolveDisplayName, resolveLocalized } from "../../utils/localize";
61
+ import { resolveLocalized } from "../../utils/localize";
62
+ import { nodeDisplayName } from "../../utils/nodeDisplayName";
64
63
  const props = defineProps({
65
64
  workflow: { type: Object, required: true },
66
65
  draft: { type: [Object, null], required: false },
67
66
  runBusy: { type: Boolean, required: false },
68
- runWaiting: { type: Boolean, required: false },
67
+ runSaves: { type: Boolean, required: false },
69
68
  overlayOpen: { type: Boolean, required: false }
70
69
  });
71
70
  const emit = defineEmits(["change", "run", "toggleActive", "openSettings"]);
@@ -875,9 +874,8 @@ const selectedUpstreamOutputs = computed(() => {
875
874
  const port = portsForNode(source, findManifest(source.nodeSlug, source.nodeVersion), dynamicOutputs.value[source.id]).find((p) => p.name === e.from.output);
876
875
  return [{
877
876
  nodeId: source.id,
878
- // A node's own name is text the user typed — never trimmed, never read
879
- // as JSON. The manifest strings below it get the normalising resolver.
880
- nodeName: resolveDisplayName(source.name) || source.nodeSlug,
877
+ // Called what its card calls it, not by its slug (PO-601).
878
+ nodeName: nodeDisplayName(source),
881
879
  output: e.from.output,
882
880
  fields: port?.fields.map((f) => f.name)
883
881
  }];
@@ -1420,6 +1418,7 @@ const edgeTypes = {
1420
1418
  :total-nodes="vfNodes.length"
1421
1419
  :target-is-trigger="!!menu.targetId && blob.triggers.some((t2) => t2.id === menu.targetId)"
1422
1420
  :run-busy="runBusy"
1421
+ :run-saves="runSaves"
1423
1422
  @action="onMenuAction"
1424
1423
  >
1425
1424
  <!-- `pan-activation-key-code` is null rather than left at its default, which
@@ -1695,25 +1694,6 @@ const edgeTypes = {
1695
1694
  <TooltipProvider :delay-duration="250">
1696
1695
  <div class="rounded-lg border border-gray-400/10 outline-1 outline-white/30 backdrop-blur-sm">
1697
1696
  <div class="flex items-center gap-0.5 whitespace-nowrap border border-white bg-white rounded-lg bg-gradient-to-br from-white via-violet-50/40 to-white px-2 py-1">
1698
- <!-- Smart Run: the page decides what "run" means — a workflow that
1699
- consumes trigger.payload, or leaves the mode to the caller, opens
1700
- the run dialog; one that asks neither fires immediately. One
1701
- button either way. -->
1702
- <IntegrationsEditorTip :label="runWaiting ? t('integration.editor.tool.run.tooltipWaiting') : t('integration.editor.tool.run.tooltip')" :shortcut="primaryShortcut('run')">
1703
- <button
1704
- type="button"
1705
- class="relative flex h-8 items-center gap-1.5 rounded-lg bg-gradient-to-b from-emerald-500 to-emerald-600 px-3.5 text-sm font-medium text-white transition after:pointer-events-none after:absolute after:inset-0 after:rounded-[inherit] after:shadow-[inset_0_1px_0_rgba(255,255,255,0.3),inset_0_-2px_0_rgba(0,0,0,0.25)] hover:from-emerald-600 hover:to-emerald-600 active:translate-y-px disabled:opacity-60"
1706
- :disabled="runBusy"
1707
- @click="emit('run')"
1708
- >
1709
- <Loader2 v-if="runBusy" :size="14" class="animate-spin" />
1710
- <Play v-else :size="14" />
1711
- {{ runWaiting ? t("integration.editor.run.waitingForBuild") : t("integration.editor.run.button") }}
1712
- </button>
1713
- </IntegrationsEditorTip>
1714
-
1715
- <div class="mx-1 h-5 w-px bg-black/10" />
1716
-
1717
1697
  <div class="flex items-center gap-0.5 rounded-lg bg-black/[0.05] p-0.5 shadow-[inset_0_1px_2px_rgba(15,23,42,0.12)]">
1718
1698
  <!-- Icon-only, and the tooltip is not an accessible name: each one
1719
1699
  says what it does itself, and whether it is the active mode. -->
@@ -20,10 +20,13 @@ type __VLS_Props = {
20
20
  * keystrokes.
21
21
  */
22
22
  draft?: WorkflowBlob | null;
23
- /** Busy flag for the control-pane Play button (a run is being triggered). */
23
+ /** A run is being triggered — the context menu's Run item and the shortcut stand down. */
24
24
  runBusy?: boolean;
25
- /** The run is queued behind a bundle build — busy, but worth saying why. */
26
- runWaiting?: boolean;
25
+ /**
26
+ * The workflow has unsaved work, so Run saves it first. Only the context menu
27
+ * reads it, to say so: Run itself stands in the page header, beside Save.
28
+ */
29
+ runSaves?: boolean;
27
30
  /**
28
31
  * One of the page's own lightboxes is open above the editor (settings,
29
32
  * duplicate, run payload, the sync response, the deactivation prompt). The
@@ -7,7 +7,7 @@ import { SIDE_POSITION } from "./canvasSides";
7
7
  import { outputHandleId } from "../../data/handleId";
8
8
  import { triggerGlyph as glyphForTrigger } from "../../data/triggerGlyphs";
9
9
  import { resolveDisplayName } from "../../utils/localize";
10
- import { triggerTypeLabel } from "../../data/integrationNodeCatalog";
10
+ import { NODE_CATEGORIES, triggerTypeLabel } from "../../data/integrationNodeCatalog";
11
11
  import { describeCron } from "../../utils/schedule";
12
12
  const props = defineProps({
13
13
  id: { type: String, required: true },
@@ -18,6 +18,7 @@ defineOptions({ inheritAttrs: false });
18
18
  defineEmits(["updateNodeInternals"]);
19
19
  const t = useIntegrationsT();
20
20
  const icon = computed(() => glyphForTrigger(props.data.type));
21
+ const chipClass = NODE_CATEGORIES.find((c) => c.key === "trigger")?.color;
21
22
  const subtitle = computed(() => {
22
23
  const c = props.data.config;
23
24
  if (props.data.type === "schedule") return describeCron(c.cron) ?? c.cron ?? t("integration.canvas.trigger.subtitle.noSchedule");
@@ -72,10 +73,10 @@ const OUT_SIDES = ["bottom"];
72
73
  @mouseleave="onHoverLeave"
73
74
  >
74
75
  <!-- Same type scale as WorkflowNodeView so trigger and node cards read
75
- identically (icon chip size-8/15, subtitle plain text-sm, mt-0.5). -->
76
+ identically (icon chip size-8 with an 18px glyph, subtitle plain text-sm, mt-0.5). -->
76
77
  <div class="flex items-center gap-2.5 px-3 py-2.5">
77
- <span class="flex size-8 shrink-0 items-center justify-center rounded-md bg-emerald-500 text-white shadow-sm">
78
- <component :is="icon" :size="15" />
78
+ <span class="flex size-8 shrink-0 items-center justify-center rounded-md" :class="chipClass">
79
+ <component :is="icon" :size="18" />
79
80
  </span>
80
81
  <div class="min-w-0 flex-1">
81
82
  <div class="text-sm font-semibold text-foreground leading-tight truncate">
@@ -27,6 +27,11 @@ export interface CredentialTestResult {
27
27
  ok: boolean;
28
28
  message?: string;
29
29
  error?: string;
30
+ /**
31
+ * Whether the platform kept this answer as the credential's state. `false` for a
32
+ * test of an edit that tried values other than the stored ones (PO-535).
33
+ */
34
+ recorded?: boolean;
30
35
  }
31
36
  export declare function useIntegrationsCredentials(): {
32
37
  credentials: any;
@@ -49,6 +54,6 @@ export declare function useIntegrationsCredentials(): {
49
54
  }) => Promise<Credential>;
50
55
  remove: (id: string) => Promise<void>;
51
56
  testInline: (credentialTypeSlug: string, config: Record<string, unknown>) => Promise<CredentialTestResult>;
52
- test: (id: string) => Promise<CredentialTestResult>;
57
+ test: (id: string, config?: Record<string, unknown>) => Promise<CredentialTestResult>;
53
58
  oauthAuthorizeUrl: (id: string) => Promise<string>;
54
59
  };
@@ -53,10 +53,13 @@ export function useIntegrationsCredentials() {
53
53
  return { ok: false, error: errorReason(e, t("integration.credentials.error.testFailed")) };
54
54
  }
55
55
  }
56
- async function test(id) {
56
+ async function test(id, config) {
57
57
  try {
58
- const res = await api.post(`/credentials/${encodeURIComponent(id)}/test`);
59
- return res?.ok === false ? res : { ok: true, message: res?.message };
58
+ const res = await api.post(
59
+ `/credentials/${encodeURIComponent(id)}/test`,
60
+ config ? { config } : void 0
61
+ );
62
+ return res?.ok === false ? res : { ok: true, message: res?.message, recorded: res?.recorded };
60
63
  } catch (e) {
61
64
  return { ok: false, error: errorReason(e, t("integration.credentials.error.testFailed")) };
62
65
  }
@@ -31,6 +31,9 @@ export function useIntegrationsNodes() {
31
31
  // was never built can be drawn but not executed, so the palette must
32
32
  // be able to leave it out (PO-338). Missing field = assume built.
33
33
  __hasBundle: n.has_bundle !== false,
34
+ // Retired by its publisher: still drawn for the workflows already on
35
+ // it, offered nowhere new (PO-549). Missing field = not retired.
36
+ __retired: n.retired === true,
34
37
  // The package's own order, which the palette falls back to inside a
35
38
  // folder (PO-537). Null and absent both mean "not recorded".
36
39
  __packagePosition: typeof n.package_position === "number" ? n.package_position : void 0
@@ -0,0 +1,48 @@
1
+ /**
2
+ * A publisher withdrawing a version of its own node from new use, and taking
3
+ * that back (PO-549; behaviour in specs/node-versions.md).
4
+ *
5
+ * Retiring is not deleting. The workflows already built on the version — in any
6
+ * tenant, since a public package's nodes sit in workflows its publisher cannot
7
+ * see — keep drawing, saving and running; what a retired version loses is the
8
+ * offer, and the platform refuses any save that would newly use it. The platform
9
+ * decides who may do this (the organisation the slug is namespaced under) and
10
+ * insists on the version being named in `confirm`, whatever this screen does.
11
+ */
12
+ /** One registered version of a node, as the catalogue lists it. */
13
+ export interface PublishedNodeVersion {
14
+ slug: string;
15
+ version: string;
16
+ name: string;
17
+ package: {
18
+ name: string;
19
+ version: string;
20
+ label?: string | null;
21
+ } | null;
22
+ has_bundle?: boolean;
23
+ retired?: boolean;
24
+ retired_at?: string | null;
25
+ retirement_reason?: string | null;
26
+ }
27
+ /** How widely one version is used, across every tenant — counts only. */
28
+ export interface NodeVersionUsage {
29
+ workflows: number;
30
+ active_workflows: number;
31
+ tenants: number;
32
+ templates: number;
33
+ }
34
+ interface RetirementAnswer {
35
+ slug: string;
36
+ version: string;
37
+ retired: boolean;
38
+ retired_at: string | null;
39
+ retirement_reason: string | null;
40
+ usage: NodeVersionUsage;
41
+ }
42
+ export declare function useNodeRetirement(): {
43
+ versionsOfPackage: (packageName: string) => Promise<PublishedNodeVersion[]>;
44
+ usage: (slug: string, version: string) => Promise<NodeVersionUsage>;
45
+ retire: (slug: string, version: string, reason: string | null) => Promise<RetirementAnswer>;
46
+ restore: (slug: string, version: string) => Promise<RetirementAnswer>;
47
+ };
48
+ export {};
@@ -0,0 +1,31 @@
1
+ import { IntegrationsApiError } from "../utils/apiError.js";
2
+ import { unwrapList, unwrapRecord } from "../utils/apiEnvelope.js";
3
+ import { useIntegrationsApi } from "./integrationsApi.js";
4
+ function versionPath(slug, version) {
5
+ return `/nodes/${encodeURIComponent(slug)}/${encodeURIComponent(version)}`;
6
+ }
7
+ export function useNodeRetirement() {
8
+ const api = useIntegrationsApi();
9
+ async function versionsOfPackage(packageName) {
10
+ const all = unwrapList(await api.get("/nodes"));
11
+ return all.filter((n) => n.package?.name === packageName);
12
+ }
13
+ async function usage(slug, version) {
14
+ const answer = unwrapRecord(await api.get(`${versionPath(slug, version)}/usage`), ["usage"]);
15
+ const counts = answer?.usage;
16
+ if (!counts || typeof counts.workflows !== "number") {
17
+ throw new IntegrationsApiError(200, answer, "");
18
+ }
19
+ return counts;
20
+ }
21
+ async function retire(slug, version, reason) {
22
+ return unwrapRecord(await api.post(`${versionPath(slug, version)}/retire`, {
23
+ confirm: `${slug}@${version}`,
24
+ ...reason ? { reason } : {}
25
+ }), ["usage"]);
26
+ }
27
+ async function restore(slug, version) {
28
+ return unwrapRecord(await api.del(`${versionPath(slug, version)}/retire`), ["usage"]);
29
+ }
30
+ return { versionsOfPackage, usage, retire, restore };
31
+ }
@@ -72,6 +72,14 @@ export declare function triggerTypeLabel(type: TriggerType | string | null | und
72
72
  * executable, because the alternative is a studio that hides working nodes.
73
73
  */
74
74
  export declare function isVersionExecutable(slug: string, version: string): boolean;
75
+ /**
76
+ * Whether this version is one to offer: its code was built, and its publisher
77
+ * has not retired it (PO-338, PO-549). What the palette inserts and what a
78
+ * move points at. Narrower than {@link isVersionExecutable} on purpose — a
79
+ * retired version still runs for the workflows already on it, it is only no
80
+ * longer a choice.
81
+ */
82
+ export declare function isOffered(m: NodeManifest): boolean;
75
83
  /**
76
84
  * The newest version of this node that can be executed, or `null` when none can.
77
85
  *
@@ -92,7 +100,8 @@ export declare function newestExecutableVersion(slug: string): string | null;
92
100
  *
93
101
  * A version whose code was never built is left out, for the reason the palette
94
102
  * leaves it out: it is not a choice, it is a dead end with a version number
95
- * (PO-338). The version a step is pinned to is therefore not guaranteed to be in
103
+ * (PO-338). So is one its publisher retired, which the platform would refuse
104
+ * to newly pin (PO-549). The version a step is pinned to is therefore not guaranteed to be in
96
105
  * here — which is exactly the state the move exists to get out of.
97
106
  */
98
107
  export declare function executableVersionsOf(slug: string): NodeManifest[];
@@ -30,17 +30,20 @@ export function isVersionExecutable(slug, version) {
30
30
  const exact = INTEGRATION_NODE_CATALOG.find((m) => m.slug === slug && m.version === version);
31
31
  return exact ? exact.__hasBundle !== false : true;
32
32
  }
33
+ export function isOffered(m) {
34
+ return m.__hasBundle !== false && m.__retired !== true;
35
+ }
33
36
  export function newestExecutableVersion(slug) {
34
- const hit = INTEGRATION_NODE_CATALOG.find((m) => m.slug === slug && m.__hasBundle !== false);
37
+ const hit = INTEGRATION_NODE_CATALOG.find((m) => m.slug === slug && isOffered(m));
35
38
  return hit?.version ?? null;
36
39
  }
37
40
  export function executableVersionsOf(slug) {
38
- return INTEGRATION_NODE_CATALOG.filter((m) => m.slug === slug && m.__hasBundle !== false);
41
+ return INTEGRATION_NODE_CATALOG.filter((m) => m.slug === slug && isOffered(m));
39
42
  }
40
43
  export function newerExecutableVersions(slug, version) {
41
- const versions = executableVersionsOf(slug);
42
- const at = versions.findIndex((m) => m.version === version);
43
- return at < 0 ? [] : versions.slice(0, at);
44
+ const built = INTEGRATION_NODE_CATALOG.filter((m) => m.slug === slug && m.__hasBundle !== false);
45
+ const at = built.findIndex((m) => m.version === version);
46
+ return at < 0 ? [] : built.slice(0, at).filter(isOffered);
44
47
  }
45
48
  export function findManifest(slug, version) {
46
49
  const matches = INTEGRATION_NODE_CATALOG.filter((m) => m.slug === slug);
@@ -22,6 +22,11 @@ import type { TriggerType } from '../types/integrations.js';
22
22
  * ('Play', 'Clock', 'Webhook', 'Zap') were Lucide names by habit rather than by
23
23
  * contract. The node-type icon map (`nodeIcons.ts`) is a different thing and
24
24
  * stays Lucide deliberately — see `docs/icons.md`.
25
+ *
26
+ * `manual` is a person, not the play triangle. The play glyph is the Run button's,
27
+ * and on a trigger card — white on the filled chip — it read as a second Run button
28
+ * that did nothing when pressed (PO-606). A trigger's glyph names who or what fires
29
+ * it; it is a label, so it must not borrow an action's shape.
25
30
  */
26
31
  export declare const TRIGGER_GLYPHS: Record<TriggerType, Component>;
27
32
  /**
@@ -32,9 +37,9 @@ export declare const TRIGGER_GLYPHS: Record<TriggerType, Component>;
32
37
  * the studio has no word for yet — `templates/[slug].vue` is built for exactly
33
38
  * that case and says so.
34
39
  *
35
- * Such a kind falls back to the **bolt**, not to the manual play button, which is
36
- * what the templates surfaces did before this map existed. A play button asserts
37
- * "you start this yourself", and for an unknown kind that is a claim rather than a
40
+ * Such a kind falls back to the **bolt**, not to the manual trigger's glyph, which
41
+ * is what the templates surfaces did before this map existed. That glyph asserts
42
+ * "a person starts this", and for an unknown kind that is a claim rather than a
38
43
  * gap; the bolt says only that something fires it.
39
44
  *
40
45
  * `Object.hasOwn`, not `?? Bolt` on its own: a plain object inherits
@@ -1,6 +1,6 @@
1
- import { Bolt, ClockCircle, LinkRound, Play } from "#solar-icons";
1
+ import { Bolt, ClockCircle, LinkRound, User } from "#solar-icons";
2
2
  export const TRIGGER_GLYPHS = {
3
- manual: Play,
3
+ manual: User,
4
4
  schedule: ClockCircle,
5
5
  webhook: LinkRound,
6
6
  event: Bolt