@revenexx/studio-integrations 2.6.0 → 2.8.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 (50) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/module.json +1 -1
  3. package/dist/runtime/components/Integrations/CredentialCreateDialog.vue +2 -1
  4. package/dist/runtime/components/Integrations/CredentialDetailDialog.vue +2 -1
  5. package/dist/runtime/components/Integrations/CredentialRevenexxSource.vue +2 -1
  6. package/dist/runtime/components/Integrations/NodeInspector.vue +11 -7
  7. package/dist/runtime/components/Integrations/NodeMark.vue +1 -1
  8. package/dist/runtime/components/Integrations/NodePalette.vue +8 -1
  9. package/dist/runtime/components/Integrations/SftpAccountImportDialog.vue +2 -1
  10. package/dist/runtime/components/Integrations/StateNamespaceCreateDialog.vue +2 -1
  11. package/dist/runtime/components/Integrations/TemplateFlowPreview.vue +1 -1
  12. package/dist/runtime/components/Integrations/WorkflowCreateDialog.vue +2 -1
  13. package/dist/runtime/components/Integrations/WorkflowDuplicateDialog.vue +2 -1
  14. package/dist/runtime/components/Integrations/WorkflowEditor.vue +5 -2
  15. package/dist/runtime/components/Integrations/WorkflowHistoryPanel.vue +21 -2
  16. package/dist/runtime/components/Integrations/WorkflowIconView.vue +1 -1
  17. package/dist/runtime/components/Integrations/WorkflowSettingsDialog.vue +5 -1
  18. package/dist/runtime/components/Integrations/WorkflowStateNamespacesDialog.vue +5 -1
  19. package/dist/runtime/components/Integrations/WorkflowTriggerView.vue +5 -1
  20. package/dist/runtime/components/Integrations/WorkflowVariablesDialog.vue +5 -1
  21. package/dist/runtime/composables/useIntegrationsAudit.d.ts +3 -0
  22. package/dist/runtime/composables/useIntegrationsNodes.d.ts +12 -1
  23. package/dist/runtime/composables/useIntegrationsNodes.js +24 -5
  24. package/dist/runtime/composables/useIntegrationsRevisions.d.ts +6 -0
  25. package/dist/runtime/composables/useIntegrationsTemplates.d.ts +1 -1
  26. package/dist/runtime/composables/useIntegrationsTemplates.js +25 -16
  27. package/dist/runtime/composables/useIntegrationsWorkflows.js +4 -1
  28. package/dist/runtime/data/eventTrigger.d.ts +22 -0
  29. package/dist/runtime/data/eventTrigger.js +18 -0
  30. package/dist/runtime/data/integrationNodeCatalog.js +1 -1
  31. package/dist/runtime/i18n/messages/de.json +3 -0
  32. package/dist/runtime/i18n/messages/en.json +3 -0
  33. package/dist/runtime/pages/integrations/audit.vue +23 -3
  34. package/dist/runtime/pages/integrations/credentials.vue +2 -1
  35. package/dist/runtime/pages/integrations/secrets.vue +2 -1
  36. package/dist/runtime/pages/integrations/templates/[slug].vue +3 -2
  37. package/dist/runtime/pages/integrations/templates/index.vue +1 -1
  38. package/dist/runtime/pages/integrations/workflows/[id].vue +4 -3
  39. package/dist/runtime/plugins/realtime.client.d.ts +15 -13
  40. package/dist/runtime/plugins/realtime.client.js +0 -1
  41. package/dist/runtime/types/integrations.d.ts +16 -0
  42. package/dist/runtime/utils/detailLoader.d.ts +52 -0
  43. package/dist/runtime/utils/detailLoader.js +65 -0
  44. package/dist/runtime/utils/dialog.d.ts +20 -0
  45. package/dist/runtime/utils/dialog.js +3 -0
  46. package/dist/runtime/utils/nodeCatalogLevel.d.ts +18 -0
  47. package/dist/runtime/utils/nodeCatalogLevel.js +7 -0
  48. package/dist/runtime/utils/personLabel.d.ts +18 -0
  49. package/dist/runtime/utils/personLabel.js +6 -0
  50. package/package.json +1 -1
@@ -4,6 +4,7 @@ import { errorReason } from "../utils/apiError.js";
4
4
  import { unwrapList, unwrapRecord } from "../utils/apiEnvelope.js";
5
5
  import { resolveLocalized } from "../utils/localize.js";
6
6
  import { useLoadGate } from "./useLoadGate.js";
7
+ import { createDetailLoader, keyedBatch } from "../utils/detailLoader.js";
7
8
  const BLOB_VERSION = "v0-draft";
8
9
  const INDUSTRY_VALUES = ["mechanical-engineering", "chemicals", "wholesale", "manufacturing", "retail", "any"];
9
10
  function plainExcerpt(md) {
@@ -89,6 +90,7 @@ function applyDetail(base, api) {
89
90
  outline: nodes.map((n) => n.nodeSlug ?? n.id).filter(Boolean)
90
91
  };
91
92
  }
93
+ let sharedLoader = null;
92
94
  export function useIntegrationsTemplates() {
93
95
  const t = useIntegrationsT();
94
96
  const rawList = useState("integrations.templates.raw", () => []);
@@ -127,26 +129,33 @@ export function useIntegrationsTemplates() {
127
129
  function get(slug) {
128
130
  return templates.value.find((t2) => t2.slug === slug) ?? null;
129
131
  }
132
+ const loader = sharedLoader ??= createDetailLoader({
133
+ fetchOne: async (slug) => unwrapRecord(await api.get(`/templates/${slug}`)),
134
+ // A host that predates `include=definition` ignores it and sends the plain
135
+ // gallery list, whose items carry no `definition`: that reads as "no batch".
136
+ fetchMany: async (slugs) => keyedBatch(unwrapList(await api.get(
137
+ `/templates?include=definition&slugs=${slugs.map(encodeURIComponent).join(",")}`
138
+ )), "definition")
139
+ }, {
140
+ // The most slugs the service accepts in one `slugs` filter; more is a 422.
141
+ batchSize: 100
142
+ });
143
+ function remember(details) {
144
+ const found = [...details].filter((entry) => entry[1] !== null);
145
+ if (!found.length) return;
146
+ rawDetails.value = { ...rawDetails.value, ...Object.fromEntries(found) };
147
+ }
130
148
  async function fetchDetail(slug) {
131
149
  const base = get(slug);
132
150
  if (!base?.fromApi) return base;
133
- try {
134
- const detail = unwrapRecord(await api.get(`/templates/${slug}`));
135
- rawDetails.value = { ...rawDetails.value, [slug]: detail };
136
- return get(slug);
137
- } catch {
138
- return base;
139
- }
151
+ const detail = await loader.one(slug);
152
+ if (detail) remember(/* @__PURE__ */ new Map([[slug, detail]]));
153
+ return get(slug);
140
154
  }
141
- async function ensureDetails(slugs, concurrency = 4) {
142
- const queue = slugs.filter((slug) => get(slug)?.fromApi && !get(slug)?.detailLoaded);
143
- const workers = Array.from({ length: Math.min(concurrency, queue.length) }, async () => {
144
- while (queue.length) {
145
- const slug = queue.shift();
146
- if (slug) await fetchDetail(slug);
147
- }
148
- });
149
- await Promise.all(workers);
155
+ async function ensureDetails(slugs, _concurrency = 4) {
156
+ const wanted = slugs.filter((slug) => get(slug)?.fromApi && !get(slug)?.detailLoaded);
157
+ if (!wanted.length) return;
158
+ remember(await loader.many(wanted));
150
159
  }
151
160
  async function requirements(slug) {
152
161
  try {
@@ -5,6 +5,7 @@ import { assignTriggerHandles, pausedTriggers } from "../utils/workflowFile.js";
5
5
  import { errorReason } from "../utils/apiError.js";
6
6
  import { unwrapList, unwrapRecord } from "../utils/apiEnvelope.js";
7
7
  import { useLoadGate } from "./useLoadGate.js";
8
+ import { eventConfigForPlatform } from "../data/eventTrigger.js";
8
9
  const BLOB_VERSION = "v0-draft";
9
10
  function toCockpit(api) {
10
11
  const apiBlob = api.blob ?? {};
@@ -44,6 +45,8 @@ function toCockpit(api) {
44
45
  // it) means "unknown", NOT "valid" — callers must fail open and only gate
45
46
  // activation on a non-empty array.
46
47
  warnings: api.warnings,
48
+ savedBy: api.saved_by_sub || api.saved_by_name || api.saved_by_email ? { sub: api.saved_by_sub ?? null, name: api.saved_by_name ?? null, email: api.saved_by_email ?? null } : null,
49
+ savedAt: api.saved_at ?? null,
47
50
  created_at: api.created_at ?? "",
48
51
  updated_at: api.updated_at ?? ""
49
52
  };
@@ -80,7 +83,7 @@ function toPayload(meta) {
80
83
  handle: t.id,
81
84
  type: t.type,
82
85
  name: (typeof t.name === "string" ? t.name : null) ?? null,
83
- config: t.type === "webhook" ? { method: "POST", ...t.config ?? {} } : t.config ?? null,
86
+ config: t.type === "webhook" ? { method: "POST", ...t.config ?? {} } : t.type === "event" && t.config ? eventConfigForPlatform(t.config) : t.config ?? null,
84
87
  active: t.active ?? true
85
88
  }))
86
89
  };
@@ -0,0 +1,22 @@
1
+ import type { WorkflowTrigger } from '../types/integrations.js';
2
+ /**
3
+ * The event an event trigger listens for, as the platform names it (PO-641).
4
+ *
5
+ * The platform reads `config.subject` — its save validates it, and its event
6
+ * consumer matches published topics against it. The studio used to write
7
+ * `config.event_type` instead, which the platform never read: every save of an
8
+ * event trigger was refused as having no event, chosen or not.
9
+ *
10
+ * `event_type` is still read, so a trigger written under the old name shows its
11
+ * event rather than an empty field. None should exist, since the platform refused
12
+ * every save that carried one; reading it costs one line.
13
+ */
14
+ type TriggerConfig = WorkflowTrigger['config'];
15
+ export declare function eventSubject(config: TriggerConfig | null | undefined): string | undefined;
16
+ /** The event as a topic to look up — trimmed, or null while none is named. */
17
+ export declare function eventTopicOf(config: TriggerConfig | null | undefined): string | null;
18
+ /** The config with the chosen event stored under the platform's name, and the old name gone. */
19
+ export declare function withEventSubject(config: TriggerConfig, subject: string): TriggerConfig;
20
+ /** An event trigger's config as the platform takes it: `subject`, never `event_type`. */
21
+ export declare function eventConfigForPlatform(config: TriggerConfig): TriggerConfig;
22
+ export {};
@@ -0,0 +1,18 @@
1
+ export function eventSubject(config) {
2
+ const subject = typeof config?.subject === "string" ? config.subject : void 0;
3
+ const legacy = typeof config?.event_type === "string" ? config.event_type : void 0;
4
+ return subject || legacy || subject;
5
+ }
6
+ export function eventTopicOf(config) {
7
+ return eventSubject(config)?.trim() || null;
8
+ }
9
+ export function withEventSubject(config, subject) {
10
+ const { event_type: _legacy, ...rest } = config;
11
+ return { ...rest, subject };
12
+ }
13
+ export function eventConfigForPlatform(config) {
14
+ if (!("event_type" in config)) return config;
15
+ const { event_type: _legacy, ...rest } = config;
16
+ const subject = eventSubject(config);
17
+ return subject === void 0 ? rest : { ...rest, subject };
18
+ }
@@ -91,6 +91,6 @@ export const TRIGGER_TEMPLATES = [
91
91
  en: "Starts when something happens on the platform, for example a new customer.",
92
92
  de: "Startet, wenn auf der Plattform etwas passiert, zum Beispiel ein neuer Kunde."
93
93
  },
94
- defaultConfig: { event_type: "" }
94
+ defaultConfig: { subject: "" }
95
95
  }
96
96
  ];
@@ -8,6 +8,8 @@
8
8
  "integration.audit.action.runStarted": "Run gestartet",
9
9
  "integration.audit.action.update": "aktualisiert",
10
10
  "integration.audit.actor.system": "System",
11
+ "integration.audit.detail.actorEmail": "E-Mail",
12
+ "integration.audit.detail.actorId": "Akteur-ID",
11
13
  "integration.audit.detail.detailsHeading": "Details",
12
14
  "integration.audit.detail.id": "ID",
13
15
  "integration.audit.detail.tenant": "Mandant",
@@ -513,6 +515,7 @@
513
515
  "integration.history.heading.description": "Jedes Speichern archiviert die vorherige Revision; die neueste ist die, die läuft, solange der Workflow aktiv ist.",
514
516
  "integration.history.heading.title": "Versionsverlauf",
515
517
  "integration.history.list.revisionLabel": "Revision {revision}",
518
+ "integration.history.list.savedBy": "Gespeichert von {name}",
516
519
  "integration.history.preview.title.current": "Aktuelle Version · Revision {revision}",
517
520
  "integration.history.preview.title.revision": "Revision {revision} (schreibgeschützt)",
518
521
  "integration.history.state.empty": "Noch keine archivierten Revisionen — speichern Sie eine Änderung, um die erste Momentaufnahme zu erstellen.",
@@ -8,6 +8,8 @@
8
8
  "integration.audit.action.runStarted": "run started",
9
9
  "integration.audit.action.update": "update",
10
10
  "integration.audit.actor.system": "system",
11
+ "integration.audit.detail.actorEmail": "E-mail",
12
+ "integration.audit.detail.actorId": "Actor ID",
11
13
  "integration.audit.detail.detailsHeading": "Details",
12
14
  "integration.audit.detail.id": "ID",
13
15
  "integration.audit.detail.tenant": "Tenant",
@@ -513,6 +515,7 @@
513
515
  "integration.history.heading.description": "Every save archives the previous revision; the newest one is what runs while the workflow is active.",
514
516
  "integration.history.heading.title": "Version history",
515
517
  "integration.history.list.revisionLabel": "Revision {revision}",
518
+ "integration.history.list.savedBy": "Saved by {name}",
516
519
  "integration.history.preview.title.current": "Current version · revision {revision}",
517
520
  "integration.history.preview.title.revision": "Revision {revision} (read-only)",
518
521
  "integration.history.state.empty": "No archived revisions yet — save a change to create the first snapshot.",
@@ -11,6 +11,7 @@ import {
11
11
  } from "#solar-icons";
12
12
  import { Badge, Button } from "@revenexx/studio";
13
13
  import { copyToClipboard } from "../../utils/clipboard";
14
+ import { isBareIdentifier, personLabel } from "../../utils/personLabel";
14
15
  const t = useIntegrationsT();
15
16
  useHead({ title: () => t("integration.audit.head.title") });
16
17
  const { entries, loading, error, hasMore, load, loadMore } = useIntegrationsAudit();
@@ -55,6 +56,18 @@ function resourceName(e) {
55
56
  function fmtDate(iso) {
56
57
  return iso ? new Date(iso).toLocaleString() : "\u2014";
57
58
  }
59
+ function actor(e) {
60
+ return { sub: e.actor_sub, name: e.actor_name, email: e.actor_email };
61
+ }
62
+ function actorLabel(e) {
63
+ return personLabel(actor(e)) ?? t("integration.audit.actor.system");
64
+ }
65
+ function actorIsId(e) {
66
+ return isBareIdentifier(actor(e));
67
+ }
68
+ function actorIsPerson(e) {
69
+ return !!(e.actor_name || e.actor_email);
70
+ }
58
71
  const filterTabs = computed(() => [
59
72
  {
60
73
  key: "resource",
@@ -72,7 +85,7 @@ const filterTabs = computed(() => [
72
85
  const columns = computed(() => [
73
86
  { key: "action", label: t("integration.audit.table.column.action"), width: "130px", sortable: true, sortGetter: (e) => e.action },
74
87
  { key: "resource", label: t("integration.audit.table.column.resource"), sortable: true, sortGetter: (e) => `${e.resource_type} ${resourceName(e)}`, searchGetter: (e) => `${e.resource_type} ${resourceName(e)} ${e.action} ${e.actor_sub ?? ""}` },
75
- { key: "actor", label: t("integration.audit.table.column.actor"), width: "170px", sortable: true, sortGetter: (e) => e.actor_sub ?? "" },
88
+ { key: "actor", label: t("integration.audit.table.column.actor"), width: "170px", sortable: true, sortGetter: (e) => personLabel(actor(e)) ?? "", searchGetter: (e) => `${e.actor_name ?? ""} ${e.actor_email ?? ""} ${e.actor_sub ?? ""}` },
76
89
  { key: "when", label: t("integration.audit.table.column.when"), width: "180px", sortable: true, sortGetter: (e) => e.created_at }
77
90
  ]);
78
91
  const selected = ref(null);
@@ -170,7 +183,10 @@ onUnmounted(() => document.removeEventListener("keydown", onKeydown));
170
183
  </div>
171
184
  </template>
172
185
  <template #cell-actor="{ row }">
173
- <span class="text-sm font-mono text-muted-foreground truncate">{{ row.actor_sub ?? t("integration.audit.actor.system") }}</span>
186
+ <div class="min-w-0">
187
+ <div class="text-sm truncate" :class="actorIsPerson(row) ? 'text-[#0B0522]' : 'text-muted-foreground'"><span :class="{ 'font-mono': actorIsId(row) }">{{ actorLabel(row) }}</span></div>
188
+ <div v-if="row.actor_name && row.actor_email" class="text-xs text-muted-foreground truncate">{{ row.actor_email }}</div>
189
+ </div>
174
190
  </template>
175
191
  <template #cell-when="{ row }">
176
192
  <span class="text-sm text-muted-foreground">{{ fmtDate(row.created_at) }}</span>
@@ -227,7 +243,11 @@ onUnmounted(() => document.removeEventListener("keydown", onKeydown));
227
243
  <div class="space-y-1.5">
228
244
  <div class="flex justify-between gap-2"><span class="text-muted-foreground">{{ t("integration.audit.table.column.resource") }}</span><span class="font-medium text-right">{{ resourceName(selected) }}</span></div>
229
245
  <div v-if="selected.resource_id" class="flex justify-between gap-2"><span class="text-muted-foreground">{{ t("integration.audit.detail.id") }}</span><code class="font-mono text-xs">{{ selected.resource_id }}</code></div>
230
- <div class="flex justify-between gap-2"><span class="text-muted-foreground">{{ t("integration.audit.table.column.actor") }}</span><code class="font-mono text-xs">{{ selected.actor_sub ?? t("integration.audit.actor.system") }}</code></div>
246
+ <div class="flex justify-between gap-2"><span class="text-muted-foreground">{{ t("integration.audit.table.column.actor") }}</span><code v-if="actorIsId(selected)" class="font-mono text-xs">{{ selected.actor_sub }}</code><span v-else class="font-medium text-right">{{ actorLabel(selected) }}</span></div>
247
+ <div v-if="selected.actor_name && selected.actor_email" class="flex justify-between gap-2"><span class="text-muted-foreground">{{ t("integration.audit.detail.actorEmail") }}</span><span class="text-right">{{ selected.actor_email }}</span></div>
248
+ <!-- The identifier stays for tracing — it is what the platform's own
249
+ logs and the identity provider know the person by. -->
250
+ <div v-if="selected.actor_sub && !actorIsId(selected)" class="flex justify-between gap-2"><span class="text-muted-foreground">{{ t("integration.audit.detail.actorId") }}</span><code class="font-mono text-xs">{{ selected.actor_sub }}</code></div>
231
251
  <div class="flex justify-between gap-2"><span class="text-muted-foreground">{{ t("integration.audit.table.column.when") }}</span><span>{{ fmtDate(selected.created_at) }}</span></div>
232
252
  <div v-if="selected.tenant_id" class="flex justify-between gap-2"><span class="text-muted-foreground">{{ t("integration.audit.detail.tenant") }}</span><span>{{ selected.tenant_id }}</span></div>
233
253
  </div>
@@ -36,6 +36,7 @@ import { CREDENTIAL_STATES, credentialState, credentialStateLabel, providerStepW
36
36
  import { hasRevenexxSource } from "../../utils/revenexxCredentialSource";
37
37
  import { resolveLocalized } from "../../utils/localize";
38
38
  import { fromModelValue, toItemValue, toModelValue } from "../../utils/selectOptions";
39
+ import { keepOpenBeside } from "../../utils/dialog";
39
40
  const t = useIntegrationsT();
40
41
  useHead({ title: () => t("integration.credentials.list.head.title") });
41
42
  const { credentials, loading, error, load, create, update, remove, testInline, test, oauthAuthorizeUrl } = useIntegrationsCredentials();
@@ -339,7 +340,7 @@ async function confirmDelete() {
339
340
 
340
341
  <!-- Create / edit dialog -->
341
342
  <Dialog v-model:open="dialogOpen">
342
- <DialogContent class="sm:max-w-lg">
343
+ <DialogContent class="sm:max-w-lg" @interact-outside="keepOpenBeside">
343
344
  <DialogHeader>
344
345
  <DialogTitle>{{ isEdit ? t("integration.credentials.shared.dialog.title.edit") : t("integration.credentials.shared.dialog.title.create") }}</DialogTitle>
345
346
  <DialogDescription class="text-sm">
@@ -25,6 +25,7 @@ import {
25
25
  Label
26
26
  } from "@revenexx/studio";
27
27
  import { errorReason } from "../../utils/apiError";
28
+ import { keepOpenBeside } from "../../utils/dialog";
28
29
  const t = useIntegrationsT();
29
30
  useHead({ title: () => t("integration.secrets.head.title") });
30
31
  const { secrets, loading, error, isValidKey, upsert, remove, load } = useIntegrationsSecrets();
@@ -103,7 +104,7 @@ const columns = computed(() => [
103
104
  {{ t("integration.secrets.action.new") }}
104
105
  </Button>
105
106
  </DialogTrigger>
106
- <DialogContent>
107
+ <DialogContent @interact-outside="keepOpenBeside">
107
108
  <DialogHeader>
108
109
  <DialogTitle>{{ t("integration.secrets.dialog.create.title") }}</DialogTitle>
109
110
  <DialogDescription class="text-sm">
@@ -18,6 +18,7 @@ import NodeMark from "../../../components/Integrations/NodeMark.vue";
18
18
  import { templateCategoryLabel, templatePackageLabel } from "../../../data/integrationTemplates";
19
19
  import { describeCron } from "../../../utils/schedule";
20
20
  import { renderTemplateDescription } from "../../../utils/templateDescription";
21
+ import { keepOpenBeside } from "../../../utils/dialog";
21
22
  const t = useIntegrationsT();
22
23
  const route = useRoute();
23
24
  const router = useRouter();
@@ -31,7 +32,7 @@ useHead({ title: () => t("integration.templates.detail.head.title", { name: tpl.
31
32
  const reqs = ref(null);
32
33
  onMounted(async () => {
33
34
  await load();
34
- await Promise.all([fetchDetail(slug.value), loadNodeCatalog(), loadCredentials(), loadSecrets()]);
35
+ await Promise.all([fetchDetail(slug.value), loadNodeCatalog(false, { summary: true }), loadCredentials(), loadSecrets()]);
35
36
  reqs.value = await requirements(slug.value);
36
37
  });
37
38
  const descriptionHtml = computed(() => renderTemplateDescription(tpl.value?.descriptionMd));
@@ -442,7 +443,7 @@ function levelDot(level) {
442
443
 
443
444
  <!-- Install: the shared step, in this page's dialog ------------------- -->
444
445
  <Dialog v-model:open="installOpen">
445
- <DialogContent class="sm:max-w-lg">
446
+ <DialogContent class="sm:max-w-lg" @interact-outside="keepOpenBeside">
446
447
  <DialogHeader>
447
448
  <DialogTitle>{{ t("integration.templates.detail.action.useTemplate") }}</DialogTitle>
448
449
  <DialogDescription class="text-sm">
@@ -10,7 +10,7 @@ useHead({ title: () => t("integration.templates.gallery.head.title") });
10
10
  const { templates, loading, error, loaded, load, ensureDetails } = useIntegrationsTemplates();
11
11
  const { load: loadNodeCatalog } = useIntegrationsNodes();
12
12
  onMounted(async () => {
13
- await Promise.all([load(true), loadNodeCatalog()]);
13
+ await Promise.all([load(true), loadNodeCatalog(false, { summary: true })]);
14
14
  void ensureDetails(templates.value.map((t2) => t2.slug));
15
15
  });
16
16
  const search = ref("");
@@ -44,8 +44,10 @@ import { splitRunOutputs } from "../../../utils/runOutputs";
44
44
  import { isTerminalRun, unwrapRun } from "../../../utils/runEnvelope";
45
45
  import { findingsSummary, groupFindingsByNode, nodeDisplayLabel } from "../../../utils/validationFindings";
46
46
  import { payloadFieldsFromBlob } from "../../../data/payloadForm";
47
+ import { eventTopicOf } from "../../../data/eventTrigger";
47
48
  import WorkflowIconView from "../../../components/Integrations/WorkflowIconView.vue";
48
49
  import { DEFAULT_RUN_DIRECTION, DEFAULT_RUN_SORT } from "../../../utils/runFeedQuery";
50
+ import { keepOpenBeside } from "../../../utils/dialog";
49
51
  const t = useIntegrationsT();
50
52
  const route = useRoute();
51
53
  const router = useRouter();
@@ -453,8 +455,7 @@ const eventLog = typeof useIntegrationsEvents === "function" ? useIntegrationsEv
453
455
  const prefillBusy = ref(null);
454
456
  const eventTriggerTopic = computed(() => {
455
457
  const trigger = (workflow.value?.blob.triggers ?? []).find((t2) => t2.type === "event");
456
- const topic = trigger?.config?.event_type;
457
- return typeof topic === "string" && topic.trim() !== "" ? topic.trim() : null;
458
+ return eventTopicOf(trigger?.config);
458
459
  });
459
460
  watch(runPayloadOpen, (open) => {
460
461
  if (open && eventTriggerTopic.value) void definedEvents.load();
@@ -1198,7 +1199,7 @@ async function confirmDeactivate() {
1198
1199
 
1199
1200
  <!-- The run dialog — opened by the editor's Run button. -->
1200
1201
  <Dialog v-model:open="runPayloadOpen">
1201
- <DialogContent class="sm:max-w-lg">
1202
+ <DialogContent class="sm:max-w-lg" @interact-outside="keepOpenBeside">
1202
1203
  <DialogHeader>
1203
1204
  <DialogTitle>{{ t("integration.editor.runPayload.dialog.title") }}</DialogTitle>
1204
1205
  <DialogDescription class="text-sm">
@@ -1,7 +1,5 @@
1
1
  /**
2
- * Starts realtime once per tab, client-only.
3
- *
4
- * TWO THINGS HAPPEN HERE AND NEITHER HAPPENS ANYWHERE ELSE.
2
+ * Starts the realtime client once per tab, client-only — and takes no channel.
5
3
  *
6
4
  * `usePlatformTalkback()` is what BUILDS the Talkback client: it imports the
7
5
  * package, mints a connection token and opens the socket on its first call, then
@@ -12,18 +10,22 @@
12
10
  * side effects (a dynamic import, a WebSocket, a token mint) and a composable a
13
11
  * component may reach during render is the wrong place for those.
14
12
  *
15
- * `useIntegrationsRealtime()` then takes the subscriptions. Warming it here rather
16
- * than leaving it to the first surface that asks means an event arriving before the
17
- * runs page is opened still moves the signals, and it puts the detached scope's
18
- * creation at a defined point in the app's life instead of inside whichever
19
- * component rendered first.
13
+ * THE STUDIO'S SUBSCRIPTIONS ARE NOT TAKEN HERE ANY MORE (PE-990). Warming
14
+ * `useIntegrationsRealtime()` from this plugin subscribed eleven channels — run ×5,
15
+ * workflow ×3, build ×3, each its own subscription-token mint — on every boot of
16
+ * every host page, the cockpit's commerce and settings pages included, where nothing
17
+ * reads them. The first Integration Studio surface that calls
18
+ * `useIntegrationsRealtime()` takes them now (runs list, a workflow, a run panel,
19
+ * the workflows list); the composable's detached scope keeps them for the rest of
20
+ * the tab, so leaving that surface does not drop them. Every promise in
21
+ * `specs/realtime-updates.md` is about an OPEN surface, and an open surface is
22
+ * exactly what subscribes.
20
23
  *
21
- * BOTH ARE IDEMPOTENT AND BOTH ARE FREE WHEN REALTIME IS OFF. An empty
22
- * `talkbackHost` makes `usePlatformTalkback()` return without building anything —
23
- * neither `@revenexx/talkback-js` nor `centrifuge` is imported — and this plugin
24
- * costs two function calls.
24
+ * FREE WHEN REALTIME IS OFF. An empty `talkbackHost` makes `usePlatformTalkback()`
25
+ * return without building anything — neither `@revenexx/talkback-js` nor
26
+ * `centrifuge` is imported.
25
27
  *
26
- * The bare names resolve per application, so a host that overrides
28
+ * The bare name resolves per application, so a host that overrides
27
29
  * `usePlatformTalkback` (the standalone `studio-ui` does, because its API dev token
28
30
  * is not one the Talkback facade will accept) gets its own client here, and a host
29
31
  * that does not gets studio-shared's default.
@@ -1,4 +1,3 @@
1
1
  export default defineNuxtPlugin(() => {
2
2
  usePlatformTalkback();
3
- useIntegrationsRealtime();
4
3
  });
@@ -56,6 +56,8 @@ export interface WorkflowTrigger {
56
56
  endpoint_id?: string;
57
57
  url?: string;
58
58
  };
59
+ subject?: string;
60
+ /** @deprecated the platform reads `subject`; still read by `eventSubject` (PO-641) */
59
61
  event_type?: string;
60
62
  payloadFields?: DeclaredPayloadField[];
61
63
  };
@@ -235,9 +237,23 @@ export interface Workflow {
235
237
  * this is a non-empty array. See [[WorkflowWarning]].
236
238
  */
237
239
  warnings?: WorkflowWarning[];
240
+ /**
241
+ * Who saved the current revision (PO-609), as their sign-in stated it then.
242
+ * Null when nobody is recorded: a platform write, or a revision from before
243
+ * authors were recorded.
244
+ */
245
+ savedBy?: RevisionAuthor | null;
246
+ /** When the current revision was saved; `updated_at` also moves on a rename. Null for a revision from before this was recorded. */
247
+ savedAt?: string | null;
238
248
  created_at: string;
239
249
  updated_at: string;
240
250
  }
251
+ /** Whoever saved a workflow revision. Any part may be missing; named by `personLabel`. */
252
+ export interface RevisionAuthor {
253
+ sub: string | null;
254
+ name: string | null;
255
+ email: string | null;
256
+ }
241
257
  export type NodeCategory = 'trigger' | 'action' | 'transform' | 'control' | 'io';
242
258
  export type NodeDataType = 'any' | 'object' | 'array' | 'string' | 'number' | 'boolean';
243
259
  export type OutputKind = 'default' | 'branch' | 'error';
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Reads per-slug details — one request per slug, or one request for many — with
3
+ * three guarantees the template previews needed (PE-990):
4
+ *
5
+ * - **One request per slug in flight.** Two surfaces asking for the same template
6
+ * at once (the dashboard panel and the create dialog, or a watcher firing twice)
7
+ * share the one request instead of each sending their own. The baseline showed
8
+ * the first four template details fetched twice on every dashboard visit.
9
+ * - **A batch when the host offers one.** `fetchMany` answers a whole set in one
10
+ * request. It resolves to `null` when the host does not batch (an API that
11
+ * predates the endpoint), and the loader then remembers that and asks one slug
12
+ * at a time from then on — so a client released before its backend still works.
13
+ * - **A bounded fan-out.** Single reads go through a small queue, so a gallery of
14
+ * twenty cards on an older host still sends at most `concurrency` at once.
15
+ * Batches are cut at `batchSize` slugs, the most one request may name; a set
16
+ * above it is several batches rather than one refused request and a fallback
17
+ * to single reads.
18
+ *
19
+ * A failed read resolves to `null` rather than rejecting: a preview that cannot be
20
+ * drawn falls back to the card without it, and one missing template must not take
21
+ * its neighbours down with it.
22
+ */
23
+ export interface DetailSource<D> {
24
+ fetchOne: (slug: string) => Promise<D>;
25
+ /**
26
+ * Details for every slug the host knows, keyed by slug — or `null` when the host
27
+ * has no batch read, which switches the loader to single reads for good.
28
+ */
29
+ fetchMany?: (slugs: string[]) => Promise<Map<string, D> | null>;
30
+ }
31
+ export interface DetailLoader<D> {
32
+ /** One slug, sharing a read already in flight for it. */
33
+ one: (slug: string) => Promise<D | null>;
34
+ /** Several slugs: one batch where the host offers it, single reads otherwise. */
35
+ many: (slugs: string[]) => Promise<Map<string, D | null>>;
36
+ /** Whether the host has been seen to batch: `undefined` until a batch was tried. */
37
+ readonly batchSupported: boolean | undefined;
38
+ }
39
+ export declare function createDetailLoader<D>(source: DetailSource<D>, opts?: {
40
+ concurrency?: number;
41
+ batchSize?: number;
42
+ }): DetailLoader<D>;
43
+ /**
44
+ * A batch answer keyed by slug, or `null` when it is not one. A host that does not
45
+ * know a batch parameter usually ignores it and answers with its plain list, whose
46
+ * items lack the key the batch adds — every item has to carry `requiredKey` for the
47
+ * answer to count, so that list is read as "no batch here" rather than as a set of
48
+ * empty details.
49
+ */
50
+ export declare function keyedBatch<T extends {
51
+ slug: string;
52
+ }>(list: T[], requiredKey: string): Map<string, T> | null;
@@ -0,0 +1,65 @@
1
+ export function createDetailLoader(source, opts = {}) {
2
+ const concurrency = Math.max(1, opts.concurrency ?? 4);
3
+ const batchSize = Math.max(1, opts.batchSize ?? Number.POSITIVE_INFINITY);
4
+ const inFlight = /* @__PURE__ */ new Map();
5
+ let batchSupported = source.fetchMany ? void 0 : false;
6
+ let active = 0;
7
+ const waiting = [];
8
+ async function limited(task) {
9
+ if (active >= concurrency) await new Promise((resolve) => waiting.push(resolve));
10
+ active++;
11
+ try {
12
+ return await task();
13
+ } finally {
14
+ active--;
15
+ waiting.shift()?.();
16
+ }
17
+ }
18
+ function track(slug, read) {
19
+ const settled = read.catch(() => null).finally(() => {
20
+ if (inFlight.get(slug) === settled) inFlight.delete(slug);
21
+ });
22
+ inFlight.set(slug, settled);
23
+ return settled;
24
+ }
25
+ function single(slug) {
26
+ return limited(() => source.fetchOne(slug));
27
+ }
28
+ function one(slug) {
29
+ return inFlight.get(slug) ?? track(slug, single(slug));
30
+ }
31
+ async function many(slugs) {
32
+ const unique = [...new Set(slugs.filter(Boolean))];
33
+ const missing = unique.filter((slug) => !inFlight.has(slug));
34
+ if (missing.length > 1 && source.fetchMany && batchSupported !== false) {
35
+ for (let i = 0; i < missing.length; i += batchSize) {
36
+ const chunk = missing.slice(i, i + batchSize);
37
+ const batch = source.fetchMany(chunk).then(
38
+ (found) => {
39
+ batchSupported = found !== null;
40
+ return found;
41
+ },
42
+ () => null
43
+ );
44
+ for (const slug of chunk) {
45
+ track(slug, batch.then((found) => found ? found.get(slug) ?? null : single(slug)));
46
+ }
47
+ }
48
+ } else {
49
+ for (const slug of missing) track(slug, single(slug));
50
+ }
51
+ const entries = await Promise.all(unique.map(async (slug) => [slug, await (inFlight.get(slug) ?? one(slug))]));
52
+ return new Map(entries);
53
+ }
54
+ return {
55
+ one,
56
+ many,
57
+ get batchSupported() {
58
+ return batchSupported;
59
+ }
60
+ };
61
+ }
62
+ export function keyedBatch(list, requiredKey) {
63
+ if (list.some((item) => !item || typeof item !== "object" || !(requiredKey in item))) return null;
64
+ return new Map(list.map((item) => [item.slug, item]));
65
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Keep a dialog open when the pointer or the focus lands beside it.
3
+ *
4
+ * Bound as `@interact-outside="keepOpenBeside"` on the `DialogContent` of every dialog
5
+ * that holds something entered or chosen — behaviour specs/dialog-dismissal.md AC-1.
6
+ * Without it the dialog takes any pointer-down outside its content as Cancel, and that
7
+ * is not always a click the operator aimed at the dialog: a host draws its toasts under
8
+ * the dialog's overlay, so the click that dismissed the message a step test raises
9
+ * closed the step's dialog and threw its settings away (PO-648).
10
+ *
11
+ * Reka reports focus moving outside the content through the same event, so a dialog
12
+ * bound to this stays open then as well. That is intended: focus leaving a modal is
13
+ * not an answer to it either.
14
+ *
15
+ * One function rather than an inline `e.preventDefault()` per dialog, so the rule has a
16
+ * name to grep for when the next dialog is added. Display-only dialogs — the shortcut
17
+ * list, a run's response, a timeline span — do not bind it: there is nothing to lose
18
+ * there, and a click beside them is the quickest way out.
19
+ */
20
+ export declare function keepOpenBeside(event: Event): void;
@@ -0,0 +1,3 @@
1
+ export function keepOpenBeside(event) {
2
+ event.preventDefault();
3
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * How much of each node manifest the catalogue holds (PE-990).
3
+ *
4
+ * `summary` is what `GET /nodes?fields=summary` answers with: enough to name, draw
5
+ * and group a node, without the settings schemas that make the full catalogue
6
+ * megabytes. `full` is every manifest whole, which the editor needs. The full
7
+ * catalogue satisfies every caller; the summary only the ones that asked for it.
8
+ */
9
+ export type CatalogLevel = 'summary' | 'full';
10
+ /** Whether a caller wanting `wanted` has to load, given what the catalogue holds. */
11
+ export declare function catalogNeedsLoad(held: CatalogLevel | null, wanted: CatalogLevel): boolean;
12
+ /**
13
+ * What to ask the service for. Never less than the catalogue already holds: a
14
+ * forced summary reload over a full catalogue (a signed image link expiring on the
15
+ * dashboard while the editor's catalogue is in memory) reads the full one again
16
+ * rather than cutting the editor's manifests down under it.
17
+ */
18
+ export declare function catalogRequest(held: CatalogLevel | null, wanted: CatalogLevel): CatalogLevel;
@@ -0,0 +1,7 @@
1
+ export function catalogNeedsLoad(held, wanted) {
2
+ if (held === null) return true;
3
+ return held === "summary" && wanted === "full";
4
+ }
5
+ export function catalogRequest(held, wanted) {
6
+ return held === "full" ? "full" : wanted;
7
+ }
@@ -0,0 +1,18 @@
1
+ /** Whoever did something, as the platform recorded them (PO-609). Any part may be missing. */
2
+ export interface PersonRef {
3
+ sub?: string | null;
4
+ name?: string | null;
5
+ email?: string | null;
6
+ }
7
+ /**
8
+ * What a person is called, wherever the studio names one: the name, else the
9
+ * e-mail, else the identifier the platform knows them by. `null` when nothing
10
+ * is recorded — a platform write, or a record from before people were recorded
11
+ * by name — and the caller decides what that says ("system", or nothing).
12
+ *
13
+ * One function for the Audit Log and the History alike, so the order cannot
14
+ * drift between the two surfaces that name the same people.
15
+ */
16
+ export declare function personLabel(person: PersonRef | null | undefined): string | null;
17
+ /** Whether {@link personLabel} falls back to the bare identifier — the one case set apart as an id. */
18
+ export declare function isBareIdentifier(person: PersonRef | null | undefined): boolean;
@@ -0,0 +1,6 @@
1
+ export function personLabel(person) {
2
+ return person?.name || person?.email || person?.sub || null;
3
+ }
4
+ export function isBareIdentifier(person) {
5
+ return !person?.name && !person?.email && !!person?.sub;
6
+ }