@revenexx/studio-integrations 3.1.0 → 3.3.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.
- package/CHANGELOG.md +22 -0
- package/dist/module.json +1 -1
- package/dist/runtime/components/Integrations/CredentialCreateDialog.vue +34 -6
- package/dist/runtime/components/Integrations/CredentialDetailDialog.vue +42 -10
- package/dist/runtime/components/Integrations/CredentialNetworkSection.d.vue.ts +21 -0
- package/dist/runtime/components/Integrations/CredentialNetworkSection.vue +28 -0
- package/dist/runtime/components/Integrations/CredentialNetworkSection.vue.d.ts +21 -0
- package/dist/runtime/components/Integrations/NodeConfigField.vue +46 -3
- package/dist/runtime/components/Integrations/RunDetailPanel.vue +3 -2
- package/dist/runtime/components/Integrations/TemplateInstallStep.vue +28 -24
- package/dist/runtime/components/Integrations/VpnRoutePicker.d.vue.ts +20 -0
- package/dist/runtime/components/Integrations/VpnRoutePicker.vue +194 -0
- package/dist/runtime/components/Integrations/VpnRoutePicker.vue.d.ts +20 -0
- package/dist/runtime/components/Integrations/WorkflowRunReplayPanel.vue +14 -2
- package/dist/runtime/composables/useIntegrationsCredentials.d.ts +15 -9
- package/dist/runtime/composables/useIntegrationsCredentials.js +9 -4
- package/dist/runtime/composables/useIntegrationsRuns.d.ts +1 -1
- package/dist/runtime/composables/useIntegrationsTemplates.d.ts +2 -0
- package/dist/runtime/composables/useIntegrationsTemplates.js +15 -9
- package/dist/runtime/composables/useVpnConnections.d.ts +19 -0
- package/dist/runtime/composables/useVpnConnections.js +28 -0
- package/dist/runtime/i18n/messages/de.json +39 -0
- package/dist/runtime/i18n/messages/en.json +39 -0
- package/dist/runtime/pages/integrations/credentials.vue +67 -15
- package/dist/runtime/pages/integrations/templates/[slug].vue +21 -22
- package/dist/runtime/types/integrations.d.ts +74 -2
- package/dist/runtime/utils/configDefaults.d.ts +5 -2
- package/dist/runtime/utils/stepError.d.ts +13 -0
- package/dist/runtime/utils/stepError.js +24 -0
- package/dist/runtime/utils/vpnRoute.d.ts +69 -0
- package/dist/runtime/utils/vpnRoute.js +79 -0
- package/package.json +1 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script setup>
|
|
2
|
-
import { computed, nextTick, onMounted, reactive, ref } from "vue";
|
|
2
|
+
import { computed, nextTick, onMounted, reactive, ref, watch } from "vue";
|
|
3
3
|
import {
|
|
4
4
|
RefreshCircle as Loader2,
|
|
5
5
|
AddCircle as Plus,
|
|
@@ -35,9 +35,11 @@ import { apiRefusal, errorReason } from "../../utils/apiError";
|
|
|
35
35
|
import { CREDENTIAL_STATES, credentialState, credentialStateLabel, providerStepWord } from "../../utils/credentialState";
|
|
36
36
|
import { hasRevenexxSource } from "../../utils/revenexxCredentialSource";
|
|
37
37
|
import { resolveLocalized } from "../../utils/localize";
|
|
38
|
+
import { defaultsFor } from "../../utils/configDefaults";
|
|
38
39
|
import { fromModelValue, toItemValue, toModelValue } from "../../utils/selectOptions";
|
|
39
40
|
import { keepOpenBeside } from "../../utils/dialog";
|
|
40
41
|
import { testOutcomeView } from "../../utils/credentialTest";
|
|
42
|
+
import { credentialNetworkPayload, credentialVpnBadge, replacedFieldTexts } from "../../utils/vpnRoute";
|
|
41
43
|
const t = useIntegrationsT();
|
|
42
44
|
useHead({ title: () => t("integration.credentials.list.head.title") });
|
|
43
45
|
const { credentials, loading, error, load, create, update, remove, testInline, test, oauthAuthorizeUrl } = useIntegrationsCredentials();
|
|
@@ -45,6 +47,13 @@ const { types, load: loadTypes, bySlug } = useCredentialTypes();
|
|
|
45
47
|
onMounted(async () => {
|
|
46
48
|
await Promise.all([load(), loadTypes()]);
|
|
47
49
|
});
|
|
50
|
+
const { connections: vpnConnections, loaded: vpnLoaded, load: loadVpn } = useVpnConnections();
|
|
51
|
+
watch(() => credentials.value.some((c) => c.network), (has) => {
|
|
52
|
+
if (has) void loadVpn();
|
|
53
|
+
}, { immediate: true });
|
|
54
|
+
function vpnBadge(c) {
|
|
55
|
+
return credentialVpnBadge(c.network, vpnConnections.value, vpnLoaded.value);
|
|
56
|
+
}
|
|
48
57
|
function typeFor(slug) {
|
|
49
58
|
return bySlug(slug);
|
|
50
59
|
}
|
|
@@ -108,12 +117,19 @@ const testing = ref(false);
|
|
|
108
117
|
const testResult = ref(null);
|
|
109
118
|
const saveRefusal = ref(null);
|
|
110
119
|
const sourceActive = ref(false);
|
|
120
|
+
const network = ref(null);
|
|
121
|
+
const networkComplete = ref(true);
|
|
122
|
+
const replaced = computed(() => replacedFieldTexts(pickedType.value, network.value, vpnConnections.value));
|
|
123
|
+
function networkPayload() {
|
|
124
|
+
return pickedType.value?.network ? credentialNetworkPayload(network.value) : void 0;
|
|
125
|
+
}
|
|
111
126
|
const isEdit = computed(() => editing.value !== null);
|
|
112
127
|
function fieldsOf(t2) {
|
|
113
128
|
return t2?.fields ?? [];
|
|
114
129
|
}
|
|
115
130
|
const drawnKeys = computed(() => [
|
|
116
131
|
"name",
|
|
132
|
+
...pickedType.value?.network ? ["network"] : [],
|
|
117
133
|
...sourceActive.value ? [] : fieldsOf(pickedType.value).map((f) => `config.${f.key}`)
|
|
118
134
|
]);
|
|
119
135
|
const refusal = computed(() => apiRefusal(saveRefusal.value, drawnKeys.value));
|
|
@@ -131,20 +147,30 @@ async function focusFirstRefusedField() {
|
|
|
131
147
|
await nextTick();
|
|
132
148
|
const key = drawnKeys.value.find((k) => k in fieldErrors.value);
|
|
133
149
|
if (!key) return;
|
|
134
|
-
const id = key === "name" ? "cred-name" : `cred-field-${key.slice("config.".length)}`;
|
|
150
|
+
const id = key === "name" ? "cred-name" : key === "network" ? "cred-network-public" : `cred-field-${key.slice("config.".length)}`;
|
|
135
151
|
document.getElementById(id)?.focus();
|
|
136
152
|
}
|
|
137
153
|
function clearRefusal() {
|
|
138
154
|
saveRefusal.value = null;
|
|
139
155
|
}
|
|
156
|
+
function pickCreateType(picked) {
|
|
157
|
+
pickedType.value = picked;
|
|
158
|
+
form.name = resolveLocalized(picked.name);
|
|
159
|
+
form.config = defaultsFor(fieldsOf(picked));
|
|
160
|
+
network.value = null;
|
|
161
|
+
testResult.value = null;
|
|
162
|
+
sourceActive.value = false;
|
|
163
|
+
clearRefusal();
|
|
164
|
+
}
|
|
140
165
|
function openCreate(t2) {
|
|
141
166
|
editing.value = null;
|
|
142
167
|
pickedType.value = t2 ?? null;
|
|
143
168
|
form.name = "";
|
|
144
|
-
form.config =
|
|
169
|
+
form.config = defaultsFor(fieldsOf(pickedType.value));
|
|
145
170
|
testResult.value = null;
|
|
146
171
|
clearRefusal();
|
|
147
172
|
sourceActive.value = false;
|
|
173
|
+
network.value = null;
|
|
148
174
|
dialogOpen.value = true;
|
|
149
175
|
}
|
|
150
176
|
const detailOpen = ref(false);
|
|
@@ -180,7 +206,7 @@ async function runTest() {
|
|
|
180
206
|
testResult.value = null;
|
|
181
207
|
clearRefusal();
|
|
182
208
|
try {
|
|
183
|
-
testResult.value = isEdit.value && editing.value ? await test(editing.value.id) : await testInline(pickedType.value.slug, buildConfigPayload());
|
|
209
|
+
testResult.value = isEdit.value && editing.value ? await test(editing.value.id) : await testInline(pickedType.value.slug, buildConfigPayload(), networkPayload());
|
|
184
210
|
} finally {
|
|
185
211
|
testing.value = false;
|
|
186
212
|
}
|
|
@@ -191,14 +217,15 @@ async function submit() {
|
|
|
191
217
|
clearRefusal();
|
|
192
218
|
try {
|
|
193
219
|
if (isEdit.value && editing.value) {
|
|
194
|
-
await update(editing.value.id, { name: form.name.trim(), config: buildConfigPayload() });
|
|
220
|
+
await update(editing.value.id, { name: form.name.trim(), config: buildConfigPayload(), network: networkPayload() });
|
|
195
221
|
dialogOpen.value = false;
|
|
196
222
|
toast.success(t("integration.credentials.toast.updated"));
|
|
197
223
|
} else {
|
|
198
224
|
const created = await create({
|
|
199
225
|
credential_type_slug: pickedType.value.slug,
|
|
200
226
|
name: form.name.trim(),
|
|
201
|
-
config: buildConfigPayload()
|
|
227
|
+
config: buildConfigPayload(),
|
|
228
|
+
network: networkPayload()
|
|
202
229
|
});
|
|
203
230
|
dialogOpen.value = false;
|
|
204
231
|
toast.success(t("integration.credentials.toast.created"));
|
|
@@ -285,7 +312,18 @@ async function confirmDelete() {
|
|
|
285
312
|
</template>
|
|
286
313
|
|
|
287
314
|
<template #cell-name="{ row: c }">
|
|
288
|
-
<
|
|
315
|
+
<div class="flex min-w-0 items-center gap-1.5">
|
|
316
|
+
<span class="text-sm truncate">{{ c.name }}</span>
|
|
317
|
+
<!-- Reached through a VPN route: which connection, and — where the
|
|
318
|
+
reference no longer resolves — that it is broken, before a run says
|
|
319
|
+
so (specs/credential-network.md AC-7). -->
|
|
320
|
+
<span
|
|
321
|
+
v-if="vpnBadge(c)"
|
|
322
|
+
class="shrink-0 rounded-full border px-1.5 py-px text-[10px] font-medium"
|
|
323
|
+
:class="vpnBadge(c).tone"
|
|
324
|
+
:aria-label="t('integration.vpn.badge.aria', { name: c.name, badge: vpnBadge(c).text })"
|
|
325
|
+
>{{ vpnBadge(c).text }}</span>
|
|
326
|
+
</div>
|
|
289
327
|
</template>
|
|
290
328
|
|
|
291
329
|
<template #cell-status="{ row: c }">
|
|
@@ -353,11 +391,7 @@ async function confirmDelete() {
|
|
|
353
391
|
<IntegrationsCredentialTypePicker
|
|
354
392
|
v-if="!pickedType"
|
|
355
393
|
:types="types"
|
|
356
|
-
@pick="
|
|
357
|
-
pickedType = picked;
|
|
358
|
-
form.name = resolveLocalized(picked.name);
|
|
359
|
-
clearRefusal();
|
|
360
|
-
}"
|
|
394
|
+
@pick="pickCreateType"
|
|
361
395
|
/>
|
|
362
396
|
|
|
363
397
|
<!-- Step 2: dynamic form -->
|
|
@@ -390,15 +424,33 @@ async function confirmDelete() {
|
|
|
390
424
|
@apply="(c2) => Object.assign(form.config, c2)"
|
|
391
425
|
/>
|
|
392
426
|
|
|
427
|
+
<!-- Public internet or a VPN route — for a type that says which of its
|
|
428
|
+
fields a route stands in for (specs/credential-network.md). -->
|
|
429
|
+
<IntegrationsCredentialNetworkSection
|
|
430
|
+
v-model="network"
|
|
431
|
+
:type="pickedType"
|
|
432
|
+
id-prefix="cred-network"
|
|
433
|
+
:error="fieldErrors.network"
|
|
434
|
+
@update:complete="(c2) => networkComplete = c2"
|
|
435
|
+
/>
|
|
436
|
+
|
|
393
437
|
<!-- Each label names its own control (`for`/`id`): without it a screen
|
|
394
438
|
reader reads the field as unlabelled, and the label text cannot be
|
|
395
439
|
used to reach it at all. -->
|
|
396
440
|
<div v-for="f in fieldsOf(pickedType)" v-show="!sourceActive" :key="f.key" class="space-y-1.5">
|
|
397
441
|
<Label :for="`cred-field-${f.key}`">{{ resolveLocalized(f.label) }}{{ f.required ? " *" : "" }}</Label>
|
|
398
442
|
|
|
443
|
+
<!-- stood in for by the chosen VPN route: what it reaches, not editable -->
|
|
444
|
+
<Input
|
|
445
|
+
v-if="replaced[f.key]"
|
|
446
|
+
:id="`cred-field-${f.key}`"
|
|
447
|
+
:model-value="replaced[f.key]"
|
|
448
|
+
readonly
|
|
449
|
+
class="bg-muted/40"
|
|
450
|
+
/>
|
|
399
451
|
<!-- secret -->
|
|
400
452
|
<Input
|
|
401
|
-
v-if="f.type === 'secret'"
|
|
453
|
+
v-else-if="f.type === 'secret'"
|
|
402
454
|
:id="`cred-field-${f.key}`"
|
|
403
455
|
:model-value="form.config[f.key] ?? ''"
|
|
404
456
|
type="password"
|
|
@@ -492,12 +544,12 @@ async function confirmDelete() {
|
|
|
492
544
|
</Button>
|
|
493
545
|
<span v-else />
|
|
494
546
|
<div class="flex items-center gap-2">
|
|
495
|
-
<Button variant="outline" :disabled="saving || testing" @click="runTest">
|
|
547
|
+
<Button variant="outline" :disabled="saving || testing || !networkComplete" @click="runTest">
|
|
496
548
|
<Loader2 v-if="testing" :size="14" class="mr-1 animate-spin" />
|
|
497
549
|
<TestIcon v-else :size="14" class="mr-1" />
|
|
498
550
|
{{ t("integration.credentials.shared.action.testConnection") }}
|
|
499
551
|
</Button>
|
|
500
|
-
<Button :disabled="saving || !form.name.trim()" @click="submit">
|
|
552
|
+
<Button :disabled="saving || !form.name.trim() || !networkComplete" @click="submit">
|
|
501
553
|
<Loader2 v-if="saving" :size="14" class="mr-1 animate-spin" />
|
|
502
554
|
{{ t("integration.common.action.save") }}
|
|
503
555
|
</Button>
|
|
@@ -22,7 +22,7 @@ import { keepOpenBeside } from "../../../utils/dialog";
|
|
|
22
22
|
const t = useIntegrationsT();
|
|
23
23
|
const route = useRoute();
|
|
24
24
|
const router = useRouter();
|
|
25
|
-
const { get, load, loaded, fetchDetail,
|
|
25
|
+
const { get, load, loaded, fetchDetail, checkRequirements, uncheckedReason } = useIntegrationsTemplates();
|
|
26
26
|
const { load: loadNodeCatalog, findManifest, resolveLocalized } = useIntegrationsNodes();
|
|
27
27
|
const { keys: secretKeys, load: loadSecrets } = useIntegrationsSecrets();
|
|
28
28
|
const { ofType, load: loadCredentials } = useIntegrationsCredentials();
|
|
@@ -30,10 +30,15 @@ const slug = computed(() => String(route.params.slug));
|
|
|
30
30
|
const tpl = computed(() => get(slug.value));
|
|
31
31
|
useHead({ title: () => t("integration.templates.detail.head.title", { name: tpl.value?.name ?? t("integration.templates.detail.head.fallbackName") }) });
|
|
32
32
|
const reqs = ref(null);
|
|
33
|
+
const reqsError = ref(null);
|
|
33
34
|
onMounted(async () => {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
try {
|
|
36
|
+
await load();
|
|
37
|
+
await Promise.all([fetchDetail(slug.value), loadNodeCatalog(false, { summary: true }), loadCredentials(), loadSecrets()]);
|
|
38
|
+
reqs.value = await checkRequirements(slug.value);
|
|
39
|
+
} catch (e) {
|
|
40
|
+
reqsError.value = uncheckedReason(e);
|
|
41
|
+
}
|
|
37
42
|
});
|
|
38
43
|
const descriptionHtml = computed(() => renderTemplateDescription(tpl.value?.descriptionMd));
|
|
39
44
|
const steps = computed(() => {
|
|
@@ -104,24 +109,14 @@ const variableColumns = computed(() => [
|
|
|
104
109
|
]);
|
|
105
110
|
const credentialReqs = computed(() => reqs.value?.credentialTypes ?? []);
|
|
106
111
|
const secretReqs = computed(() => reqs.value?.secrets ?? []);
|
|
107
|
-
const secretChecks = computed(() => {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
label: s.key ?? t("integration.templates.detail.secrets.chosenAtInstall"),
|
|
113
|
-
present: s.exists || (s.key ? secretKeys.value.includes(s.key) : false)
|
|
114
|
-
}));
|
|
115
|
-
}
|
|
116
|
-
return (tpl.value?.requiredSecrets ?? []).map((key) => ({
|
|
117
|
-
key,
|
|
118
|
-
label: key,
|
|
119
|
-
present: secretKeys.value.includes(key)
|
|
120
|
-
}));
|
|
121
|
-
});
|
|
112
|
+
const secretChecks = computed(() => secretReqs.value.map((s) => ({
|
|
113
|
+
key: s.key,
|
|
114
|
+
label: s.key ?? t("integration.templates.detail.secrets.chosenAtInstall"),
|
|
115
|
+
present: s.exists || (s.key ? secretKeys.value.includes(s.key) : false)
|
|
116
|
+
})));
|
|
122
117
|
const missingSecrets = computed(() => secretChecks.value.filter((s) => !s.present).length);
|
|
123
118
|
const missingCredentials = computed(() => credentialReqs.value.filter((c) => !ofType(c.slug).length).length);
|
|
124
|
-
const setupReady = computed(() => missingSecrets.value === 0 && missingCredentials.value === 0);
|
|
119
|
+
const setupReady = computed(() => reqs.value !== null && missingSecrets.value === 0 && missingCredentials.value === 0);
|
|
125
120
|
const packageLabel = computed(() => tpl.value ? templatePackageLabel(tpl.value) : null);
|
|
126
121
|
const categoryLabel = computed(() => templateCategoryLabel(tpl.value?.category ?? ""));
|
|
127
122
|
const installOpen = ref(false);
|
|
@@ -298,11 +293,15 @@ function levelDot(level) {
|
|
|
298
293
|
point `aria-labelledby` at. Copy this pattern only for a panel
|
|
299
294
|
that genuinely has none. -->
|
|
300
295
|
<div role="group" :aria-label="t('integration.templates.detail.setup.ariaLabel')" class="rounded-lg border border-black/[0.06] bg-white p-3 space-y-2.5">
|
|
301
|
-
<div v-if="
|
|
296
|
+
<div v-if="reqsError" class="flex items-start gap-1.5 text-sm text-amber-700" data-testid="template-setup-unchecked">
|
|
297
|
+
<ShieldAlert :size="14" class="mt-0.5 shrink-0" />
|
|
298
|
+
{{ t("integration.templates.detail.setup.unchecked", { reason: reqsError }) }}
|
|
299
|
+
</div>
|
|
300
|
+
<div v-else-if="setupReady" class="flex items-center gap-1.5 text-sm text-emerald-700">
|
|
302
301
|
<CheckCircle2 :size="14" />
|
|
303
302
|
{{ t("integration.templates.detail.setup.ready") }}
|
|
304
303
|
</div>
|
|
305
|
-
<div v-else class="text-sm text-amber-700 space-y-0.5">
|
|
304
|
+
<div v-else-if="reqs" class="text-sm text-amber-700 space-y-0.5">
|
|
306
305
|
<div v-if="missingCredentials" class="flex items-center gap-1.5">
|
|
307
306
|
<Key :size="12" />
|
|
308
307
|
{{ t("integration.templates.detail.setup.missingCredentials", missingCredentials) }}
|
|
@@ -282,7 +282,10 @@ export interface NodeManifestOutput {
|
|
|
282
282
|
}
|
|
283
283
|
export type ConfigFieldType = 'string' | 'number' | 'boolean' | 'select' | 'multiselect' | 'object' | 'array' | 'expression' | 'secret-ref' | 'credentials-ref'
|
|
284
284
|
/** Names one of the workflow's declared state namespaces (PO-374). */
|
|
285
|
-
| 'state-ref'
|
|
285
|
+
| 'state-ref'
|
|
286
|
+
/** A route of one of the tenant's VPN connections, or the public internet
|
|
287
|
+
* (PO-831). Stored as `VpnRouteValue | null`. */
|
|
288
|
+
| 'vpn-route' | 'dynamic-schema';
|
|
286
289
|
export interface ConfigFieldOption {
|
|
287
290
|
value: string | number | boolean;
|
|
288
291
|
label: LocalizedString;
|
|
@@ -335,6 +338,11 @@ export interface ConfigField {
|
|
|
335
338
|
* the stored value is the namespace NAME.
|
|
336
339
|
*/
|
|
337
340
|
stateRole?: WorkflowStateRole;
|
|
341
|
+
/**
|
|
342
|
+
* Only for `type: 'vpn-route'` (PO-831): which kind of route the picker offers.
|
|
343
|
+
* `http` where the node declares nothing, as the SDK defaults it.
|
|
344
|
+
*/
|
|
345
|
+
routeKind?: VpnRouteKind;
|
|
338
346
|
/** keys of other config fields this field's options/schema depend on. */
|
|
339
347
|
dependsOn?: string[];
|
|
340
348
|
/** When this setting applies at all (PO-410): another setting's key, an
|
|
@@ -471,6 +479,8 @@ export interface CredentialTypeField {
|
|
|
471
479
|
type: 'string' | 'number' | 'boolean' | 'select' | 'secret';
|
|
472
480
|
label: LocalizedString;
|
|
473
481
|
required?: boolean;
|
|
482
|
+
/** What the system uses when the operator changes nothing; a new credential's form starts from it (IN-38). */
|
|
483
|
+
default?: string | number | boolean;
|
|
474
484
|
options?: Array<{
|
|
475
485
|
label: LocalizedString;
|
|
476
486
|
value: string | number | boolean;
|
|
@@ -503,6 +513,27 @@ export interface CredentialType {
|
|
|
503
513
|
images?: CredentialTypeImage[];
|
|
504
514
|
auth_kind: AuthKind;
|
|
505
515
|
fields: CredentialTypeField[];
|
|
516
|
+
/**
|
|
517
|
+
* Which of the type's fields a VPN route stands in for (PO-829, the SDK's
|
|
518
|
+
* `ICredentialDescription.network`). Absent: the type cannot be routed through a
|
|
519
|
+
* VPN, and the credential forms draw no Network section for it.
|
|
520
|
+
*/
|
|
521
|
+
network?: CredentialNetworkDeclaration | null;
|
|
522
|
+
}
|
|
523
|
+
/** What a credential type declares a VPN route replaces. */
|
|
524
|
+
export type CredentialNetworkDeclaration = {
|
|
525
|
+
kind: 'tcp';
|
|
526
|
+
hostField: string;
|
|
527
|
+
portField: string;
|
|
528
|
+
} | {
|
|
529
|
+
kind: 'http';
|
|
530
|
+
urlField: string;
|
|
531
|
+
};
|
|
532
|
+
/** How a stored credential reaches its system, when not over the public internet. */
|
|
533
|
+
export interface CredentialNetwork {
|
|
534
|
+
kind: 'vpn';
|
|
535
|
+
connection: string;
|
|
536
|
+
route: string;
|
|
506
537
|
}
|
|
507
538
|
export type CredentialStatus = 'active' | 'needs_reauth';
|
|
508
539
|
/** A tenant's credential instance (secret fields never returned). */
|
|
@@ -517,9 +548,35 @@ export interface Credential {
|
|
|
517
548
|
last_test_at?: string | null;
|
|
518
549
|
last_test_ok?: boolean | null;
|
|
519
550
|
last_test_message?: string | null;
|
|
551
|
+
/** The VPN route this credential is reached through; `null` or absent = public internet. */
|
|
552
|
+
network?: CredentialNetwork | null;
|
|
520
553
|
created_at: string;
|
|
521
554
|
updated_at: string;
|
|
522
555
|
}
|
|
556
|
+
export type VpnRouteKind = 'http' | 'tcp';
|
|
557
|
+
/** Console's derived status of a connection; only `active` carries traffic. */
|
|
558
|
+
export type VpnConnectionStatus = 'provisioning' | 'failed' | 'active' | 'down' | 'standby' | 'disabled' | 'unknown';
|
|
559
|
+
export interface VpnRoute {
|
|
560
|
+
name: string;
|
|
561
|
+
kind: VpnRouteKind;
|
|
562
|
+
/** The target inside the tenant's network. */
|
|
563
|
+
host: string;
|
|
564
|
+
port: number;
|
|
565
|
+
proto?: string | null;
|
|
566
|
+
/** Where the gateway serves the route (http: a URL, tcp: `ip:port`). */
|
|
567
|
+
endpoint?: string | null;
|
|
568
|
+
}
|
|
569
|
+
export interface VpnConnection {
|
|
570
|
+
name: string;
|
|
571
|
+
display_name?: string | null;
|
|
572
|
+
status: VpnConnectionStatus | string;
|
|
573
|
+
routes: VpnRoute[];
|
|
574
|
+
}
|
|
575
|
+
/** What a `vpn-route` setting stores, and what a credential's `network` names. */
|
|
576
|
+
export interface VpnRouteValue {
|
|
577
|
+
connection: string;
|
|
578
|
+
route: string;
|
|
579
|
+
}
|
|
523
580
|
export type ConnectionStatus = 'connected' | 'misconfigured' | 'unverified' | 'failing';
|
|
524
581
|
export interface ConnectionTemplate {
|
|
525
582
|
/** kebab-case slug (e.g. 'shopify'). */
|
|
@@ -698,6 +755,17 @@ export interface WorkflowRunNodeStep {
|
|
|
698
755
|
attempt: number;
|
|
699
756
|
durationMs: number | null;
|
|
700
757
|
}
|
|
758
|
+
export interface RunStepFrame {
|
|
759
|
+
file: string;
|
|
760
|
+
line: number;
|
|
761
|
+
column: number;
|
|
762
|
+
name?: string | null;
|
|
763
|
+
}
|
|
764
|
+
export interface RunStepError {
|
|
765
|
+
message: string;
|
|
766
|
+
type?: string;
|
|
767
|
+
frames?: RunStepFrame[];
|
|
768
|
+
}
|
|
701
769
|
export interface RunStepRow {
|
|
702
770
|
node_id: string;
|
|
703
771
|
node_slug: string;
|
|
@@ -716,7 +784,11 @@ export interface RunStepRow {
|
|
|
716
784
|
port: string;
|
|
717
785
|
} | null;
|
|
718
786
|
branch?: string | null;
|
|
719
|
-
|
|
787
|
+
/**
|
|
788
|
+
* Why the step failed. The platform sends the object; a string is what older
|
|
789
|
+
* rows and older mocks carry. Read it through `utils/stepError.ts`, never print it.
|
|
790
|
+
*/
|
|
791
|
+
error?: RunStepError | string | null;
|
|
720
792
|
input?: unknown;
|
|
721
793
|
output?: unknown;
|
|
722
794
|
/** Resolved node config for this step (present with include_payloads). */
|
|
@@ -9,8 +9,11 @@ import type { ConfigField } from '../types/integrations.js';
|
|
|
9
9
|
* applied never gets to apply it. An absent key is the absence the node's
|
|
10
10
|
* default answers to.
|
|
11
11
|
*
|
|
12
|
-
* Used
|
|
13
|
-
*
|
|
12
|
+
* Used wherever a fresh set of values is made: a node dropped on the canvas
|
|
13
|
+
* (WorkflowEditor), a row added to a list setting (NodeConfigField), and a
|
|
14
|
+
* credential's form — a new one in both create dialogs, and a stored one's
|
|
15
|
+
* missing fields in the detail view (IN-38), which also compares an edit against
|
|
16
|
+
* it. A change here changes all of them.
|
|
14
17
|
* Flat on purpose — it reads `default` at the level it is given and does not
|
|
15
18
|
* descend into `properties` or `items`, so what a newly inserted node carries
|
|
16
19
|
* is exactly what it carried before this became shared.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { RunStepError, RunStepFrame } from '../types/integrations.js';
|
|
2
|
+
/**
|
|
3
|
+
* A failed step's error as an operator reads it (IN-12).
|
|
4
|
+
*
|
|
5
|
+
* The platform sends it as an object — the message, the failure's type, and where
|
|
6
|
+
* the node package's own code was when it failed (`frames`, PO-447). The studio used
|
|
7
|
+
* to read it as text, and a template prints an object as JSON. A row from before the
|
|
8
|
+
* object, or a mock that still answers a string, is read as the message itself, so
|
|
9
|
+
* no shape ever reaches the screen as a dump.
|
|
10
|
+
*/
|
|
11
|
+
export declare function stepError(error: unknown): RunStepError | null;
|
|
12
|
+
/** One frame as a reader names it: `src/nodes/Foo.ts:12:5` and the function, where known. */
|
|
13
|
+
export declare function frameLabel(frame: RunStepFrame): string;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export function stepError(error) {
|
|
2
|
+
if (typeof error === "string") return error.trim() === "" ? null : { message: error };
|
|
3
|
+
if (error === null || typeof error !== "object") return null;
|
|
4
|
+
const raw = error;
|
|
5
|
+
const message = typeof raw.message === "string" && raw.message.trim() !== "" ? raw.message : null;
|
|
6
|
+
if (message === null) return null;
|
|
7
|
+
return {
|
|
8
|
+
message,
|
|
9
|
+
...typeof raw.type === "string" && raw.type.trim() !== "" ? { type: raw.type } : {},
|
|
10
|
+
...Array.isArray(raw.frames) ? { frames: raw.frames.filter(isFrame).map(withName) } : {}
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
export function frameLabel(frame) {
|
|
14
|
+
const at = `${frame.file}:${frame.line}:${frame.column}`;
|
|
15
|
+
return frame.name ? `${frame.name} (${at})` : at;
|
|
16
|
+
}
|
|
17
|
+
function isFrame(value) {
|
|
18
|
+
if (value === null || typeof value !== "object") return false;
|
|
19
|
+
const f = value;
|
|
20
|
+
return typeof f.file === "string" && typeof f.line === "number" && typeof f.column === "number";
|
|
21
|
+
}
|
|
22
|
+
function withName(frame) {
|
|
23
|
+
return typeof frame.name === "string" && frame.name.trim() !== "" ? frame : { file: frame.file, line: frame.line, column: frame.column };
|
|
24
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { CredentialNetwork, CredentialNetworkDeclaration, CredentialType, VpnConnection, VpnRoute, VpnRouteKind, VpnRouteValue } from '../types/integrations.js';
|
|
2
|
+
/**
|
|
3
|
+
* What the studio makes of a VPN route reference — a credential's `network`, or the
|
|
4
|
+
* value of a `vpn-route` setting — against the tenant's connections
|
|
5
|
+
* (specs/credential-network.md). Pure, so the surfaces that draw it agree.
|
|
6
|
+
*/
|
|
7
|
+
/** The name a connection is shown under: its display name, else its own. */
|
|
8
|
+
export declare function connectionLabel(c: Pick<VpnConnection, 'name' | 'display_name'>): string;
|
|
9
|
+
/** The routes of a connection a picker filtered to `kind` offers. */
|
|
10
|
+
export declare function routesOfKind(c: VpnConnection | undefined, kind: VpnRouteKind): VpnRoute[];
|
|
11
|
+
/** Whether a stored value names a route at all, as opposed to the public internet. */
|
|
12
|
+
export declare function isVpnRouteValue(v: unknown): v is VpnRouteValue;
|
|
13
|
+
export type VpnReference =
|
|
14
|
+
/** The route exists; `active` says whether its connection carries traffic now. */
|
|
15
|
+
{
|
|
16
|
+
state: 'ok';
|
|
17
|
+
connection: VpnConnection;
|
|
18
|
+
route: VpnRoute;
|
|
19
|
+
active: boolean;
|
|
20
|
+
}
|
|
21
|
+
/** The connection or the route is gone — a broken reference. */
|
|
22
|
+
| {
|
|
23
|
+
state: 'missing';
|
|
24
|
+
connection?: VpnConnection;
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* Resolve a reference against the tenant's connections. A connection that is still
|
|
28
|
+
* there but no longer has the route counts as missing, as the backend refuses it
|
|
29
|
+
* with `VPN_ROUTE_MISSING` either way.
|
|
30
|
+
*/
|
|
31
|
+
export declare function resolveVpnReference(connections: readonly VpnConnection[], ref: VpnRouteValue): VpnReference;
|
|
32
|
+
/** The keys of a credential type's fields a VPN route stands in for. */
|
|
33
|
+
export declare function replacedFieldKeys(decl: CredentialNetworkDeclaration | null | undefined): string[];
|
|
34
|
+
/**
|
|
35
|
+
* What a replaced field reads while a route stands in for it: `host:port via
|
|
36
|
+
* <connection>` for a tcp route, the gateway's endpoint for an http one — the
|
|
37
|
+
* address the request is actually sent to.
|
|
38
|
+
*/
|
|
39
|
+
export declare function routeTargetText(route: VpnRoute, connection: VpnConnection): string;
|
|
40
|
+
/**
|
|
41
|
+
* The read-only text of every field a chosen route replaces, keyed by field. Empty
|
|
42
|
+
* while the credential is reached over the public internet, or while the route
|
|
43
|
+
* cannot be resolved — then the fields keep their stored values on screen.
|
|
44
|
+
*/
|
|
45
|
+
export declare function replacedFieldTexts(type: Pick<CredentialType, 'network'> | null | undefined, value: VpnRouteValue | null, connections: readonly VpnConnection[]): Record<string, string>;
|
|
46
|
+
/** Whether the gateway reaches the target over TLS it does not verify. */
|
|
47
|
+
export declare function isHttpsTarget(route: VpnRoute | undefined): boolean;
|
|
48
|
+
/** The credential's `network` as the service takes it, from what the form holds. */
|
|
49
|
+
export declare function credentialNetworkPayload(value: VpnRouteValue | null): CredentialNetwork | null;
|
|
50
|
+
/** The picker's value from what a credential stores. */
|
|
51
|
+
export declare function fromCredentialNetwork(n: CredentialNetwork | null | undefined): VpnRouteValue | null;
|
|
52
|
+
/** Whether two values name the same route (both public counts as the same). */
|
|
53
|
+
export declare function sameVpnRoute(a: VpnRouteValue | null, b: VpnRouteValue | null): boolean;
|
|
54
|
+
/** A connection's status in the reader's words; an unforeseen one reads as unknown. */
|
|
55
|
+
export declare function connectionStatusLabel(status: string): string;
|
|
56
|
+
/** Badge classes for a connection status: green only for one that carries traffic. */
|
|
57
|
+
export declare function connectionStatusTone(status: string): string;
|
|
58
|
+
/**
|
|
59
|
+
* The VPN badge a credential carries where it is picked or listed, or `null` for one
|
|
60
|
+
* reached over the public internet. `connections` loaded or not decides whether a
|
|
61
|
+
* reference nothing resolves is called broken: before the list arrives it is only
|
|
62
|
+
* named.
|
|
63
|
+
*/
|
|
64
|
+
export declare function credentialVpnBadge(network: CredentialNetwork | null | undefined, connections: readonly VpnConnection[], loaded: boolean): {
|
|
65
|
+
text: string;
|
|
66
|
+
broken: boolean;
|
|
67
|
+
active: boolean;
|
|
68
|
+
tone: string;
|
|
69
|
+
} | null;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { useIntegrationsT } from "../composables/useIntegrationsT.js";
|
|
2
|
+
export function connectionLabel(c) {
|
|
3
|
+
return c.display_name?.trim() || c.name;
|
|
4
|
+
}
|
|
5
|
+
export function routesOfKind(c, kind) {
|
|
6
|
+
return (c?.routes ?? []).filter((r) => r.kind === kind);
|
|
7
|
+
}
|
|
8
|
+
export function isVpnRouteValue(v) {
|
|
9
|
+
return !!v && typeof v === "object" && typeof v.connection === "string" && v.connection !== "" && typeof v.route === "string" && v.route !== "";
|
|
10
|
+
}
|
|
11
|
+
export function resolveVpnReference(connections, ref) {
|
|
12
|
+
const connection = connections.find((c) => c.name === ref.connection);
|
|
13
|
+
const route = connection?.routes.find((r) => r.name === ref.route);
|
|
14
|
+
if (!connection || !route) return { state: "missing", connection };
|
|
15
|
+
return { state: "ok", connection, route, active: connection.status === "active" };
|
|
16
|
+
}
|
|
17
|
+
export function replacedFieldKeys(decl) {
|
|
18
|
+
if (!decl) return [];
|
|
19
|
+
return decl.kind === "tcp" ? [decl.hostField, decl.portField] : [decl.urlField];
|
|
20
|
+
}
|
|
21
|
+
export function routeTargetText(route, connection) {
|
|
22
|
+
const t = useIntegrationsT();
|
|
23
|
+
const target = route.kind === "http" && route.endpoint ? route.endpoint : `${route.host}:${route.port}`;
|
|
24
|
+
return t("integration.vpn.target", { target, connection: connectionLabel(connection) });
|
|
25
|
+
}
|
|
26
|
+
export function replacedFieldTexts(type, value, connections) {
|
|
27
|
+
const keys = replacedFieldKeys(type?.network);
|
|
28
|
+
if (!keys.length || !value) return {};
|
|
29
|
+
const ref = resolveVpnReference(connections, value);
|
|
30
|
+
if (ref.state !== "ok") return {};
|
|
31
|
+
const text = routeTargetText(ref.route, ref.connection);
|
|
32
|
+
return Object.fromEntries(keys.map((k) => [k, text]));
|
|
33
|
+
}
|
|
34
|
+
export function isHttpsTarget(route) {
|
|
35
|
+
return (route?.proto ?? "").toLowerCase() === "https";
|
|
36
|
+
}
|
|
37
|
+
export function credentialNetworkPayload(value) {
|
|
38
|
+
return value ? { kind: "vpn", connection: value.connection, route: value.route } : null;
|
|
39
|
+
}
|
|
40
|
+
export function fromCredentialNetwork(n) {
|
|
41
|
+
return n && n.kind === "vpn" && n.connection && n.route ? { connection: n.connection, route: n.route } : null;
|
|
42
|
+
}
|
|
43
|
+
export function sameVpnRoute(a, b) {
|
|
44
|
+
if (!a || !b) return !a && !b;
|
|
45
|
+
return a.connection === b.connection && a.route === b.route;
|
|
46
|
+
}
|
|
47
|
+
const STATUS_KEYS = ["provisioning", "failed", "active", "down", "standby", "disabled", "unknown"];
|
|
48
|
+
export function connectionStatusLabel(status) {
|
|
49
|
+
const key = STATUS_KEYS.includes(status) ? status : "unknown";
|
|
50
|
+
return useIntegrationsT()(`integration.vpn.status.${key}`);
|
|
51
|
+
}
|
|
52
|
+
export function connectionStatusTone(status) {
|
|
53
|
+
if (status === "active") return "bg-emerald-500/10 border-emerald-500/20 text-emerald-700";
|
|
54
|
+
if (status === "failed" || status === "down") return "bg-rose-500/10 border-rose-500/20 text-rose-700";
|
|
55
|
+
if (status === "provisioning" || status === "standby") return "bg-amber-500/10 border-amber-500/20 text-amber-700";
|
|
56
|
+
return "bg-slate-500/10 border-slate-500/20 text-slate-600";
|
|
57
|
+
}
|
|
58
|
+
export function credentialVpnBadge(network, connections, loaded) {
|
|
59
|
+
const value = fromCredentialNetwork(network);
|
|
60
|
+
if (!value) return null;
|
|
61
|
+
const t = useIntegrationsT();
|
|
62
|
+
if (!loaded) {
|
|
63
|
+
return { text: t("integration.vpn.badge.named", { connection: value.connection }), broken: false, active: true, tone: connectionStatusTone("unknown") };
|
|
64
|
+
}
|
|
65
|
+
const ref = resolveVpnReference(connections, value);
|
|
66
|
+
if (ref.state === "missing") {
|
|
67
|
+
return { text: t("integration.vpn.badge.missing"), broken: true, active: false, tone: connectionStatusTone("failed") };
|
|
68
|
+
}
|
|
69
|
+
const name = connectionLabel(ref.connection);
|
|
70
|
+
if (ref.active) {
|
|
71
|
+
return { text: t("integration.vpn.badge.named", { connection: name }), broken: false, active: true, tone: connectionStatusTone("active") };
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
text: t("integration.vpn.badge.withState", { connection: name, status: connectionStatusLabel(String(ref.connection.status)) }),
|
|
75
|
+
broken: false,
|
|
76
|
+
active: false,
|
|
77
|
+
tone: connectionStatusTone(String(ref.connection.status))
|
|
78
|
+
};
|
|
79
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@revenexx/studio-integrations",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Integration Studio (core: workflows, node editor, runs, schedules, credentials, secrets, audit) as a standalone, publishable Nuxt module.",
|
|
6
6
|
"repository": {
|