@revenexx/studio-integrations 2.4.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 (34) hide show
  1. package/CHANGELOG.md +30 -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/WorkflowCreateDialog.vue +4 -3
  17. package/dist/runtime/components/Integrations/WorkflowEditor.vue +4 -4
  18. package/dist/runtime/components/Integrations/WorkflowTriggerView.vue +5 -4
  19. package/dist/runtime/composables/useIntegrationsCredentials.d.ts +6 -1
  20. package/dist/runtime/composables/useIntegrationsCredentials.js +6 -3
  21. package/dist/runtime/composables/useIntegrationsNodes.js +3 -0
  22. package/dist/runtime/composables/useNodeRetirement.d.ts +48 -0
  23. package/dist/runtime/composables/useNodeRetirement.js +31 -0
  24. package/dist/runtime/data/integrationNodeCatalog.d.ts +10 -1
  25. package/dist/runtime/data/integrationNodeCatalog.js +8 -5
  26. package/dist/runtime/data/triggerGlyphs.d.ts +8 -3
  27. package/dist/runtime/data/triggerGlyphs.js +2 -2
  28. package/dist/runtime/i18n/messages/de.json +28 -1
  29. package/dist/runtime/i18n/messages/en.json +28 -1
  30. package/dist/runtime/types/integrations.d.ts +7 -0
  31. package/dist/runtime/utils/cockpitSources.d.ts +10 -3
  32. package/dist/runtime/utils/credentialEdit.d.ts +16 -0
  33. package/dist/runtime/utils/credentialEdit.js +7 -0
  34. 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>
@@ -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">
@@ -58,7 +58,8 @@ import { deepClone } from "../../utils/deepClone";
58
58
  import { copyToClipboard } from "../../utils/clipboard";
59
59
  import { groupFindingsByNode, findingsByNodeField } from "../../utils/validationFindings";
60
60
  import { reorderBranchSubtrees } from "../../data/branchReorder";
61
- import { resolveDisplayName, resolveLocalized } from "../../utils/localize";
61
+ import { resolveLocalized } from "../../utils/localize";
62
+ import { nodeDisplayName } from "../../utils/nodeDisplayName";
62
63
  const props = defineProps({
63
64
  workflow: { type: Object, required: true },
64
65
  draft: { type: [Object, null], required: false },
@@ -873,9 +874,8 @@ const selectedUpstreamOutputs = computed(() => {
873
874
  const port = portsForNode(source, findManifest(source.nodeSlug, source.nodeVersion), dynamicOutputs.value[source.id]).find((p) => p.name === e.from.output);
874
875
  return [{
875
876
  nodeId: source.id,
876
- // A node's own name is text the user typed — never trimmed, never read
877
- // as JSON. The manifest strings below it get the normalising resolver.
878
- nodeName: resolveDisplayName(source.name) || source.nodeSlug,
877
+ // Called what its card calls it, not by its slug (PO-601).
878
+ nodeName: nodeDisplayName(source),
879
879
  output: e.from.output,
880
880
  fields: port?.fields.map((f) => f.name)
881
881
  }];
@@ -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
@@ -170,9 +170,11 @@
170
170
  "integration.credentials.detail.field.typeSlug": "Typ-Slug",
171
171
  "integration.credentials.detail.field.updated": "Aktualisiert",
172
172
  "integration.credentials.detail.field.version": "Version",
173
+ "integration.credentials.detail.state.testedAt": "· geprüft {time}",
173
174
  "integration.credentials.detail.state.untestedHint": "Testen Sie die Verbindung, um zu prüfen, ob diese Zugangsdaten tatsächlich funktionieren.",
174
175
  "integration.credentials.detail.tab.credentials": "Zugangsdaten",
175
176
  "integration.credentials.detail.tab.details": "Details",
177
+ "integration.credentials.detail.test.editNotSupported": "Der Dienst kann nur gespeicherte Werte testen, diese Werte wurden also nicht geprüft. Speichern Sie sie, um sie zu testen.",
176
178
  "integration.credentials.error.delete": "Zugangsdaten konnten nicht gelöscht werden.",
177
179
  "integration.credentials.error.loadFailed": "Zugangsdaten konnten nicht geladen werden.",
178
180
  "integration.credentials.error.loadTypes": "Zugangsdatentypen konnten nicht geladen werden.",
@@ -220,6 +222,7 @@
220
222
  "integration.credentials.revenexxSource.error.createSmtp": "Die Mail-Zugangsdaten konnten nicht erstellt werden.",
221
223
  "integration.credentials.revenexxSource.error.loadResources": "revenexx-Ressourcen konnten nicht geladen werden.",
222
224
  "integration.credentials.revenexxSource.error.mailCredentialGone": "Diese Mail-Zugangsdaten sind nicht mehr vorhanden.",
225
+ "integration.credentials.revenexxSource.error.mailCredentialNoSecret": "Diese Mail-Zugangsdaten haben keinen Key, deshalb wurde nichts übernommen. Wählen Sie andere aus oder prüfen Sie diese unter Mail.",
223
226
  "integration.credentials.revenexxSource.error.mailCredentialNotReturned": "Die Mail-Zugangsdaten wurden nicht zurückgegeben — bitte aus der Liste wählen, sobald sie erscheinen.",
224
227
  "integration.credentials.revenexxSource.error.noTenant": "Kein Mandant ausgewählt.",
225
228
  "integration.credentials.revenexxSource.error.provisioningFailed": "Bereitstellung fehlgeschlagen.",
@@ -1350,5 +1353,29 @@
1350
1353
  "integration.workflows.variables.row.aria": "Variable {key}",
1351
1354
  "integration.workflows.variables.state.empty": "Noch keine Variablen — fügen Sie die erste unten hinzu.",
1352
1355
  "integration.workflows.variables.toast.copied": "Ausdruck kopiert",
1353
- "integration.workflows.variables.toast.copyFailed": "Kopieren fehlgeschlagen"
1356
+ "integration.workflows.variables.toast.copyFailed": "Kopieren fehlgeschlagen",
1357
+ "integration.nodeVersions.retire.dialog.title": "Diese Version ausmustern?",
1358
+ "integration.nodeVersions.retire.dialog.description": "wird nicht mehr angeboten: Niemand kann sie mehr in einen Workflow einfügen. Workflows, die sie schon nutzen, laufen weiter.",
1359
+ "integration.nodeVersions.retire.confirm.label": "Zum Fortfahren {version} eintippen",
1360
+ "integration.nodeVersions.retire.confirm.aria": "Auszumusternde Version, ausgeschrieben",
1361
+ "integration.nodeVersions.retire.reason.label": "Grund (optional)",
1362
+ "integration.nodeVersions.retire.reason.placeholder": "z. B. der Anbieter hat API v1 abgeschaltet",
1363
+ "integration.nodeVersions.retire.usage.none": "Kein Workflow und kein Template nutzt diese Version.",
1364
+ "integration.nodeVersions.retire.usage.counts": "Genutzt von {workflows} Workflow(s), davon {active} aktiv, in {tenants} Mandant(en), und von {templates} Template(s).",
1365
+ "integration.nodeVersions.retire.usage.keepWorking": "Sie laufen unverändert weiter. Die Version wird nur nicht mehr angeboten und kann keinem Workflow neu hinzugefügt werden.",
1366
+ "integration.nodeVersions.retire.action.check": "Nutzung prüfen",
1367
+ "integration.nodeVersions.retire.action.retire": "Version ausmustern",
1368
+ "integration.nodeVersions.retire.error.generic": "Die Version konnte nicht ausgemustert werden.",
1369
+ "integration.nodeVersions.restore.error.generic": "Die Version konnte nicht wiederhergestellt werden.",
1370
+ "integration.nodeVersions.error.load": "Die Node-Versionen konnten nicht geladen werden.",
1371
+ "integration.nodeVersions.empty.noPackage": "Diese Integration hat noch kein Node-Paket veröffentlicht.",
1372
+ "integration.nodeVersions.empty.noNodes": "Dieses Paket registriert keine Nodes.",
1373
+ "integration.nodeVersions.loading": "Node-Versionen werden geladen…",
1374
+ "integration.nodeVersions.column.version": "Version",
1375
+ "integration.nodeVersions.column.state": "Status",
1376
+ "integration.nodeVersions.badge.retired": "Ausgemustert",
1377
+ "integration.nodeVersions.badge.offered": "Angeboten",
1378
+ "integration.nodeVersions.badge.notBuilt": "Nicht gebaut",
1379
+ "integration.nodeVersions.action.retire": "Ausmustern",
1380
+ "integration.nodeVersions.action.restore": "Wiederherstellen"
1354
1381
  }
@@ -170,9 +170,11 @@
170
170
  "integration.credentials.detail.field.typeSlug": "Type slug",
171
171
  "integration.credentials.detail.field.updated": "Updated",
172
172
  "integration.credentials.detail.field.version": "Version",
173
+ "integration.credentials.detail.state.testedAt": "· verified {time}",
173
174
  "integration.credentials.detail.state.untestedHint": "Run a test to verify these credentials actually connect.",
174
175
  "integration.credentials.detail.tab.credentials": "Credentials",
175
176
  "integration.credentials.detail.tab.details": "Details",
177
+ "integration.credentials.detail.test.editNotSupported": "The service can test only what is saved, so these values were not tried. Save them to test them.",
176
178
  "integration.credentials.error.delete": "Could not delete credential.",
177
179
  "integration.credentials.error.loadFailed": "Could not load credentials.",
178
180
  "integration.credentials.error.loadTypes": "Could not load credential types.",
@@ -220,6 +222,7 @@
220
222
  "integration.credentials.revenexxSource.error.createSmtp": "Could not create the mail credential.",
221
223
  "integration.credentials.revenexxSource.error.loadResources": "Could not load revenexx resources.",
222
224
  "integration.credentials.revenexxSource.error.mailCredentialGone": "That mail credential is no longer there.",
225
+ "integration.credentials.revenexxSource.error.mailCredentialNoSecret": "That mail credential has no key, so nothing was copied. Pick another one, or check this one in Mail.",
223
226
  "integration.credentials.revenexxSource.error.mailCredentialNotReturned": "The mail credential was not returned — pick it from the list once it appears.",
224
227
  "integration.credentials.revenexxSource.error.noTenant": "No tenant selected.",
225
228
  "integration.credentials.revenexxSource.error.provisioningFailed": "Provisioning failed.",
@@ -1350,5 +1353,29 @@
1350
1353
  "integration.workflows.variables.row.aria": "Variable {key}",
1351
1354
  "integration.workflows.variables.state.empty": "No variables yet — add the first one below.",
1352
1355
  "integration.workflows.variables.toast.copied": "Expression copied",
1353
- "integration.workflows.variables.toast.copyFailed": "Could not copy"
1356
+ "integration.workflows.variables.toast.copyFailed": "Could not copy",
1357
+ "integration.nodeVersions.retire.dialog.title": "Retire this version?",
1358
+ "integration.nodeVersions.retire.dialog.description": "stops being offered: nobody can add it to a workflow any more. Workflows that already use it keep working.",
1359
+ "integration.nodeVersions.retire.confirm.label": "Type {version} to continue",
1360
+ "integration.nodeVersions.retire.confirm.aria": "Version to retire, typed out",
1361
+ "integration.nodeVersions.retire.reason.label": "Reason (optional)",
1362
+ "integration.nodeVersions.retire.reason.placeholder": "e.g. the vendor switched off API v1",
1363
+ "integration.nodeVersions.retire.usage.none": "No workflow and no template uses this version.",
1364
+ "integration.nodeVersions.retire.usage.counts": "Used by {workflows} workflow(s), {active} of them active, in {tenants} tenant(s), and by {templates} template(s).",
1365
+ "integration.nodeVersions.retire.usage.keepWorking": "They keep working as they are. The version is only no longer offered, and cannot be added to a workflow again.",
1366
+ "integration.nodeVersions.retire.action.check": "Check usage",
1367
+ "integration.nodeVersions.retire.action.retire": "Retire version",
1368
+ "integration.nodeVersions.retire.error.generic": "The version could not be retired.",
1369
+ "integration.nodeVersions.restore.error.generic": "The version could not be restored.",
1370
+ "integration.nodeVersions.error.load": "The node versions could not be loaded.",
1371
+ "integration.nodeVersions.empty.noPackage": "This integration has not published a node package yet.",
1372
+ "integration.nodeVersions.empty.noNodes": "This package registers no nodes.",
1373
+ "integration.nodeVersions.loading": "Loading node versions…",
1374
+ "integration.nodeVersions.column.version": "Version",
1375
+ "integration.nodeVersions.column.state": "State",
1376
+ "integration.nodeVersions.badge.retired": "Retired",
1377
+ "integration.nodeVersions.badge.offered": "Offered",
1378
+ "integration.nodeVersions.badge.notBuilt": "Not built",
1379
+ "integration.nodeVersions.action.retire": "Retire",
1380
+ "integration.nodeVersions.action.restore": "Restore"
1354
1381
  }
@@ -425,6 +425,13 @@ export interface NodeManifest {
425
425
  * does not make every node disappear.
426
426
  */
427
427
  __hasBundle?: boolean;
428
+ /**
429
+ * Whether the publisher retired this version (editor-side enrichment from
430
+ * `GET /nodes`; not manifest data, PO-549). A retired version still draws and
431
+ * runs for the workflows already on it, but is offered nowhere new, and the
432
+ * platform refuses a save that newly uses it.
433
+ */
434
+ __retired?: boolean;
428
435
  }
429
436
  export interface SecretRecord {
430
437
  key: string;
@@ -205,14 +205,21 @@ export interface CockpitSftpAccount {
205
205
  /**
206
206
  * The members of the cockpit's `MailCredential` this module reads.
207
207
  *
208
- * The SMTP pair is optional on the wire: the mail engine returns it on `show` and
209
- * on create, but the type does not promise it, which is why every read of it here
210
- * falls back to an empty string rather than assuming.
208
+ * The SMTP pair is optional on the wire, and the mail engine leaves it out: its
209
+ * `show` and create answers carry the name and the key. So the login is read the
210
+ * way the cockpit's Mail tab reads it — `smtp_username` or else the name, and
211
+ * `smtp_password` or else the key — and a credential with neither secret copies
212
+ * nothing rather than an empty password (PO-602).
211
213
  */
212
214
  export interface CockpitMailCredential {
213
215
  uuid: string;
214
216
  name: string;
215
217
  type?: string;
218
+ /**
219
+ * The credential's secret. The mail engine authenticates SMTP by it alone, and
220
+ * where `smtp_password` is absent it is the password (PO-602).
221
+ */
222
+ key?: string;
216
223
  smtp_username?: string;
217
224
  smtp_password?: string;
218
225
  }
@@ -0,0 +1,16 @@
1
+ import type { CredentialTypeField } from '../types/integrations.js';
2
+ /**
3
+ * Whether the values an edit would send differ from what the credential holds, as
4
+ * far as the studio can see it (PO-535).
5
+ *
6
+ * `payload` is what a save would send: every non-secret field, and only the secrets
7
+ * that were typed — a blank secret means "keep the stored one" and is never sent.
8
+ * So a secret in the payload is a change by definition, since the stored one is never
9
+ * shown; a non-secret is a change when it reads differently from `public_config`.
10
+ *
11
+ * The platform judges this again on the merged values before it records a test, so
12
+ * this only decides whether to ask for a test of the edit at all — which takes the
13
+ * right to change the credential, where a test of the stored one takes only the
14
+ * right to read it.
15
+ */
16
+ export declare function credentialEditDiffers(fields: CredentialTypeField[], payload: Record<string, unknown>, publicConfig: Record<string, unknown>): boolean;