@revenexx/studio-integrations 2.7.0 → 2.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/dist/module.json +1 -1
- package/dist/runtime/components/Integrations/CredentialCreateDialog.vue +2 -1
- package/dist/runtime/components/Integrations/CredentialDetailDialog.vue +2 -1
- package/dist/runtime/components/Integrations/CredentialRevenexxSource.vue +2 -1
- package/dist/runtime/components/Integrations/NodeConfigDynamicField.vue +44 -11
- package/dist/runtime/components/Integrations/NodeInspector.vue +11 -7
- package/dist/runtime/components/Integrations/NodeMark.vue +1 -1
- package/dist/runtime/components/Integrations/NodePalette.vue +8 -1
- package/dist/runtime/components/Integrations/SftpAccountImportDialog.vue +2 -1
- package/dist/runtime/components/Integrations/StateNamespaceCreateDialog.vue +2 -1
- package/dist/runtime/components/Integrations/TemplateFlowPreview.vue +1 -1
- package/dist/runtime/components/Integrations/WorkflowCreateDialog.vue +2 -1
- package/dist/runtime/components/Integrations/WorkflowDuplicateDialog.vue +2 -1
- package/dist/runtime/components/Integrations/WorkflowEditor.vue +5 -2
- package/dist/runtime/components/Integrations/WorkflowHistoryPanel.vue +21 -2
- package/dist/runtime/components/Integrations/WorkflowIconView.vue +1 -1
- package/dist/runtime/components/Integrations/WorkflowSettingsDialog.vue +5 -1
- package/dist/runtime/components/Integrations/WorkflowStateNamespacesDialog.vue +5 -1
- package/dist/runtime/components/Integrations/WorkflowTriggerView.vue +5 -1
- package/dist/runtime/components/Integrations/WorkflowVariablesDialog.vue +5 -1
- package/dist/runtime/composables/useIntegrationsAudit.d.ts +3 -0
- package/dist/runtime/composables/useIntegrationsNodes.d.ts +12 -1
- package/dist/runtime/composables/useIntegrationsNodes.js +24 -5
- package/dist/runtime/composables/useIntegrationsRevisions.d.ts +6 -0
- package/dist/runtime/composables/useIntegrationsTemplates.d.ts +1 -1
- package/dist/runtime/composables/useIntegrationsTemplates.js +25 -16
- package/dist/runtime/composables/useIntegrationsWorkflows.js +4 -1
- package/dist/runtime/data/eventTrigger.d.ts +22 -0
- package/dist/runtime/data/eventTrigger.js +18 -0
- package/dist/runtime/data/integrationNodeCatalog.js +1 -1
- package/dist/runtime/i18n/messages/de.json +3 -0
- package/dist/runtime/i18n/messages/en.json +3 -0
- package/dist/runtime/pages/integrations/audit.vue +23 -3
- package/dist/runtime/pages/integrations/credentials.vue +2 -1
- package/dist/runtime/pages/integrations/secrets.vue +2 -1
- package/dist/runtime/pages/integrations/templates/[slug].vue +3 -2
- package/dist/runtime/pages/integrations/templates/index.vue +1 -1
- package/dist/runtime/pages/integrations/workflows/[id].vue +4 -3
- package/dist/runtime/plugins/realtime.client.d.ts +15 -13
- package/dist/runtime/plugins/realtime.client.js +0 -1
- package/dist/runtime/types/integrations.d.ts +16 -0
- package/dist/runtime/utils/detailLoader.d.ts +52 -0
- package/dist/runtime/utils/detailLoader.js +65 -0
- package/dist/runtime/utils/dialog.d.ts +20 -0
- package/dist/runtime/utils/dialog.js +3 -0
- package/dist/runtime/utils/nodeCatalogLevel.d.ts +18 -0
- package/dist/runtime/utils/nodeCatalogLevel.js +7 -0
- package/dist/runtime/utils/personLabel.d.ts +18 -0
- package/dist/runtime/utils/personLabel.js +6 -0
- package/package.json +2 -2
|
@@ -15,6 +15,12 @@ export interface WorkflowRevisionMeta {
|
|
|
15
15
|
description: string | null;
|
|
16
16
|
active: boolean;
|
|
17
17
|
blob_definition_version: string;
|
|
18
|
+
/** Who saved this revision (PO-609); all null for a platform write and for a revision from before authors were recorded. */
|
|
19
|
+
saved_by_sub?: string | null;
|
|
20
|
+
saved_by_name?: string | null;
|
|
21
|
+
saved_by_email?: string | null;
|
|
22
|
+
/** When that person saved it — not `archived_at`, which is when the next save replaced it. */
|
|
23
|
+
saved_at?: string | null;
|
|
18
24
|
created_at: string | null;
|
|
19
25
|
updated_at: string | null;
|
|
20
26
|
archived_at: string | null;
|
|
@@ -10,7 +10,7 @@ export declare function useIntegrationsTemplates(): {
|
|
|
10
10
|
list: () => WorkflowTemplate[];
|
|
11
11
|
get: (slug: string) => WorkflowTemplate | null;
|
|
12
12
|
fetchDetail: (slug: string) => Promise<WorkflowTemplate | null>;
|
|
13
|
-
ensureDetails: (slugs: string[],
|
|
13
|
+
ensureDetails: (slugs: string[], _concurrency?: number) => Promise<void>;
|
|
14
14
|
requirements: (slug: string) => Promise<TemplateRequirements | null>;
|
|
15
15
|
instantiate: (slug: string, opts?: {
|
|
16
16
|
name?: string;
|
|
@@ -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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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,
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
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: {
|
|
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
|
-
<
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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
|
|
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.
|
|
@@ -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,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;
|