@revenexx/studio-integrations 3.2.0 → 3.4.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 +20 -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/RunsTable.d.vue.ts +9 -0
- package/dist/runtime/components/Integrations/RunsTable.vue +14 -8
- package/dist/runtime/components/Integrations/RunsTable.vue.d.ts +9 -0
- 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/composables/useIntegrationsCredentials.d.ts +15 -9
- package/dist/runtime/composables/useIntegrationsCredentials.js +9 -4
- package/dist/runtime/composables/useIntegrationsRuns.d.ts +4 -2
- package/dist/runtime/composables/useIntegrationsRuns.js +12 -39
- package/dist/runtime/composables/useVpnConnections.d.ts +19 -0
- package/dist/runtime/composables/useVpnConnections.js +28 -0
- package/dist/runtime/i18n/messages/de.json +35 -0
- package/dist/runtime/i18n/messages/en.json +35 -0
- package/dist/runtime/pages/integrations/credentials.vue +67 -15
- package/dist/runtime/pages/integrations/workflows/[id].vue +15 -1
- package/dist/runtime/types/integrations.d.ts +64 -2
- package/dist/runtime/utils/configDefaults.d.ts +5 -2
- package/dist/runtime/utils/runFeedQuery.d.ts +8 -5
- package/dist/runtime/utils/runFeedQuery.js +9 -4
- package/dist/runtime/utils/runRecord.d.ts +39 -0
- package/dist/runtime/utils/runRecord.js +39 -0
- package/dist/runtime/utils/vpnRoute.d.ts +69 -0
- package/dist/runtime/utils/vpnRoute.js +79 -0
- package/package.json +2 -2
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
|
|
3
|
+
import {
|
|
4
|
+
Button,
|
|
5
|
+
Label,
|
|
6
|
+
RadioGroup,
|
|
7
|
+
RadioGroupItem,
|
|
8
|
+
Select,
|
|
9
|
+
SelectContent,
|
|
10
|
+
SelectItem,
|
|
11
|
+
SelectTrigger,
|
|
12
|
+
SelectValue
|
|
13
|
+
} from "@revenexx/studio";
|
|
14
|
+
import {
|
|
15
|
+
connectionLabel,
|
|
16
|
+
connectionStatusLabel,
|
|
17
|
+
connectionStatusTone,
|
|
18
|
+
isHttpsTarget,
|
|
19
|
+
resolveVpnReference,
|
|
20
|
+
routesOfKind
|
|
21
|
+
} from "../../utils/vpnRoute";
|
|
22
|
+
import { servedPaths } from "../../utils/servedPaths";
|
|
23
|
+
const props = defineProps({
|
|
24
|
+
modelValue: { type: [Object, null], required: true },
|
|
25
|
+
kind: { type: String, required: true },
|
|
26
|
+
idPrefix: { type: String, required: true },
|
|
27
|
+
label: { type: String, required: false },
|
|
28
|
+
error: { type: String, required: false }
|
|
29
|
+
});
|
|
30
|
+
const emit = defineEmits(["update:modelValue", "update:complete"]);
|
|
31
|
+
const t = useIntegrationsT();
|
|
32
|
+
const { connections, loaded, loading, error: loadError, load } = useVpnConnections();
|
|
33
|
+
onMounted(() => {
|
|
34
|
+
void load(true);
|
|
35
|
+
});
|
|
36
|
+
const viaVpn = ref(!!props.modelValue);
|
|
37
|
+
const pickedConnection = ref(props.modelValue?.connection ?? "");
|
|
38
|
+
watch(() => props.modelValue ? `${props.modelValue.connection}\0${props.modelValue.route}` : "", () => {
|
|
39
|
+
const v = props.modelValue;
|
|
40
|
+
if (v) {
|
|
41
|
+
viaVpn.value = true;
|
|
42
|
+
pickedConnection.value = v.connection;
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
const current = computed(() => props.modelValue?.connection === pickedConnection.value ? props.modelValue : null);
|
|
46
|
+
const complete = computed(() => !viaVpn.value || !!current.value);
|
|
47
|
+
watch(complete, (c) => emit("update:complete", c), { immediate: true });
|
|
48
|
+
onUnmounted(() => emit("update:complete", true));
|
|
49
|
+
function name(key) {
|
|
50
|
+
const base = t(key);
|
|
51
|
+
return props.label ? t("integration.vpn.picker.ariaFor", { label: props.label, control: base }) : base;
|
|
52
|
+
}
|
|
53
|
+
function setMode(mode) {
|
|
54
|
+
if (mode === "vpn") {
|
|
55
|
+
viaVpn.value = true;
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
viaVpn.value = false;
|
|
59
|
+
pickedConnection.value = "";
|
|
60
|
+
emit("update:modelValue", null);
|
|
61
|
+
}
|
|
62
|
+
const connection = computed(() => connections.value.find((c) => c.name === pickedConnection.value));
|
|
63
|
+
const routes = computed(() => routesOfKind(connection.value, props.kind));
|
|
64
|
+
function pickConnection(v) {
|
|
65
|
+
const next = String(v ?? "");
|
|
66
|
+
if (next === pickedConnection.value) return;
|
|
67
|
+
pickedConnection.value = next;
|
|
68
|
+
}
|
|
69
|
+
function pickRoute(v) {
|
|
70
|
+
const route = String(v ?? "");
|
|
71
|
+
if (!route || !pickedConnection.value) return;
|
|
72
|
+
emit("update:modelValue", { connection: pickedConnection.value, route });
|
|
73
|
+
}
|
|
74
|
+
const reference = computed(() => current.value && loaded.value ? resolveVpnReference(connections.value, current.value) : null);
|
|
75
|
+
const chosenRoute = computed(() => reference.value?.state === "ok" ? reference.value.route : void 0);
|
|
76
|
+
const noConnections = computed(() => loaded.value && !connections.value.length);
|
|
77
|
+
const pending = computed(() => {
|
|
78
|
+
if (complete.value) return "";
|
|
79
|
+
const stored = props.modelValue;
|
|
80
|
+
if (!stored) return t("integration.vpn.picker.pending.public");
|
|
81
|
+
const via = connections.value.find((c) => c.name === stored.connection);
|
|
82
|
+
return t("integration.vpn.picker.pending.route", { route: stored.route, connection: via ? connectionLabel(via) : stored.connection });
|
|
83
|
+
});
|
|
84
|
+
const VPN_PAGE = "/integrations/vpn";
|
|
85
|
+
const router = useRouter();
|
|
86
|
+
function vpnPageServed() {
|
|
87
|
+
return servedPaths(router.getRoutes().map((r) => r.path)).has(VPN_PAGE);
|
|
88
|
+
}
|
|
89
|
+
</script>
|
|
90
|
+
|
|
91
|
+
<template>
|
|
92
|
+
<div class="space-y-2">
|
|
93
|
+
<RadioGroup
|
|
94
|
+
:model-value="viaVpn ? 'vpn' : 'public'"
|
|
95
|
+
:aria-label="name('integration.vpn.picker.group')"
|
|
96
|
+
class="gap-1.5"
|
|
97
|
+
@update:model-value="(v) => setMode(String(v))"
|
|
98
|
+
>
|
|
99
|
+
<div class="flex items-center gap-2">
|
|
100
|
+
<RadioGroupItem :id="`${idPrefix}-public`" value="public" />
|
|
101
|
+
<Label :for="`${idPrefix}-public`" class="font-normal">{{ t("integration.vpn.picker.public") }}</Label>
|
|
102
|
+
</div>
|
|
103
|
+
<div class="flex items-center gap-2">
|
|
104
|
+
<!-- Not offered where there is nothing to pick from — unless a stored route
|
|
105
|
+
already points through one, which has to stay readable as such. -->
|
|
106
|
+
<RadioGroupItem :id="`${idPrefix}-vpn`" value="vpn" :disabled="noConnections && !modelValue" />
|
|
107
|
+
<Label :for="`${idPrefix}-vpn`" class="font-normal">{{ t("integration.vpn.picker.vpn") }}</Label>
|
|
108
|
+
</div>
|
|
109
|
+
</RadioGroup>
|
|
110
|
+
|
|
111
|
+
<p v-if="noConnections" class="text-sm text-muted-foreground">
|
|
112
|
+
{{ t("integration.vpn.empty.text") }}
|
|
113
|
+
<NuxtLink v-if="vpnPageServed()" :to="VPN_PAGE" class="font-medium text-violet-600 hover:underline">{{ t("integration.vpn.empty.link") }}</NuxtLink>
|
|
114
|
+
<template v-else>{{ t("integration.vpn.empty.elsewhere") }}</template>
|
|
115
|
+
</p>
|
|
116
|
+
|
|
117
|
+
<template v-if="viaVpn">
|
|
118
|
+
<p v-if="loading && !loaded" class="text-sm text-muted-foreground">{{ t("integration.vpn.loading") }}</p>
|
|
119
|
+
<div v-else-if="loadError" role="alert" class="flex items-center gap-2 text-sm text-rose-700">
|
|
120
|
+
<span>{{ loadError }}</span>
|
|
121
|
+
<Button variant="outline" size="sm" class="h-7" @click="load(true)">{{ t("integration.common.action.retry") }}</Button>
|
|
122
|
+
</div>
|
|
123
|
+
|
|
124
|
+
<template v-if="loaded">
|
|
125
|
+
<div class="space-y-1.5">
|
|
126
|
+
<Label :for="`${idPrefix}-connection`">{{ t("integration.vpn.picker.connection") }}</Label>
|
|
127
|
+
<Select :model-value="pickedConnection" @update:model-value="pickConnection">
|
|
128
|
+
<SelectTrigger :id="`${idPrefix}-connection`" :aria-label="name('integration.vpn.picker.connection')" class="bg-white">
|
|
129
|
+
<SelectValue :placeholder="t('integration.vpn.picker.connection.placeholder')">
|
|
130
|
+
<span v-if="connection" class="flex min-w-0 items-center gap-1.5">
|
|
131
|
+
<span class="min-w-0 truncate">{{ connectionLabel(connection) }}</span>
|
|
132
|
+
<span class="shrink-0 rounded-full border px-1.5 py-px text-[10px] font-medium" :class="connectionStatusTone(String(connection.status))">
|
|
133
|
+
{{ connectionStatusLabel(String(connection.status)) }}
|
|
134
|
+
</span>
|
|
135
|
+
</span>
|
|
136
|
+
<span v-else-if="pickedConnection" class="text-rose-600">{{ pickedConnection }}</span>
|
|
137
|
+
<span v-else class="text-muted-foreground">{{ t("integration.vpn.picker.connection.placeholder") }}</span>
|
|
138
|
+
</SelectValue>
|
|
139
|
+
</SelectTrigger>
|
|
140
|
+
<SelectContent>
|
|
141
|
+
<SelectItem v-for="c in connections" :key="c.name" :value="c.name">
|
|
142
|
+
<span class="flex items-center gap-1.5">
|
|
143
|
+
<span class="min-w-0 truncate">{{ connectionLabel(c) }}</span>
|
|
144
|
+
<span class="shrink-0 rounded-full border px-1.5 py-px text-[10px] font-medium" :class="connectionStatusTone(String(c.status))">
|
|
145
|
+
{{ connectionStatusLabel(String(c.status)) }}
|
|
146
|
+
</span>
|
|
147
|
+
</span>
|
|
148
|
+
</SelectItem>
|
|
149
|
+
</SelectContent>
|
|
150
|
+
</Select>
|
|
151
|
+
</div>
|
|
152
|
+
|
|
153
|
+
<div v-if="pickedConnection" class="space-y-1.5">
|
|
154
|
+
<Label :for="`${idPrefix}-route`">{{ t("integration.vpn.picker.route") }}</Label>
|
|
155
|
+
<Select :model-value="current?.route ?? ''" @update:model-value="pickRoute">
|
|
156
|
+
<SelectTrigger :id="`${idPrefix}-route`" :aria-label="name('integration.vpn.picker.route')" class="bg-white">
|
|
157
|
+
<SelectValue :placeholder="t('integration.vpn.picker.route.placeholder')">
|
|
158
|
+
<span v-if="chosenRoute">{{ chosenRoute.name }} — {{ chosenRoute.host }}:{{ chosenRoute.port }}</span>
|
|
159
|
+
<span v-else-if="current?.route" class="text-rose-600">{{ current.route }}</span>
|
|
160
|
+
<span v-else class="text-muted-foreground">{{ t("integration.vpn.picker.route.placeholder") }}</span>
|
|
161
|
+
</SelectValue>
|
|
162
|
+
</SelectTrigger>
|
|
163
|
+
<SelectContent>
|
|
164
|
+
<SelectItem v-for="r in routes" :key="r.name" :value="r.name">
|
|
165
|
+
{{ r.name }} — {{ r.host }}:{{ r.port }}
|
|
166
|
+
</SelectItem>
|
|
167
|
+
<div v-if="!routes.length" class="px-2 py-1.5 text-sm text-muted-foreground">
|
|
168
|
+
{{ t(`integration.vpn.picker.route.empty.${kind}`) }}
|
|
169
|
+
</div>
|
|
170
|
+
</SelectContent>
|
|
171
|
+
</Select>
|
|
172
|
+
</div>
|
|
173
|
+
|
|
174
|
+
<!-- A reference the tenant's connections no longer resolve. Said in words,
|
|
175
|
+
because the choice would otherwise look merely unfinished while every
|
|
176
|
+
run through it fails. -->
|
|
177
|
+
<p v-if="pending" class="text-sm text-amber-700">
|
|
178
|
+
{{ pending }}
|
|
179
|
+
</p>
|
|
180
|
+
<p v-if="reference?.state === 'missing' && current" class="text-sm text-rose-700">
|
|
181
|
+
{{ t("integration.vpn.reference.missing", { connection: current.connection, route: current.route }) }}
|
|
182
|
+
</p>
|
|
183
|
+
<p v-else-if="connection && connection.status !== 'active'" class="text-sm text-amber-700">
|
|
184
|
+
{{ t("integration.vpn.reference.inactive", { connection: connectionLabel(connection), status: connectionStatusLabel(String(connection.status)) }) }}
|
|
185
|
+
</p>
|
|
186
|
+
<p v-if="isHttpsTarget(chosenRoute)" class="text-sm text-muted-foreground">
|
|
187
|
+
{{ t("integration.vpn.httpsNote") }}
|
|
188
|
+
</p>
|
|
189
|
+
</template>
|
|
190
|
+
</template>
|
|
191
|
+
|
|
192
|
+
<p v-if="error" class="text-sm text-rose-700">{{ error }}</p>
|
|
193
|
+
</div>
|
|
194
|
+
</template>
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { VpnRouteKind, VpnRouteValue } from '../../types/integrations.js';
|
|
2
|
+
type __VLS_Props = {
|
|
3
|
+
modelValue: VpnRouteValue | null;
|
|
4
|
+
/** The kind of route on offer — the credential type's declared kind, or the setting's `routeKind`. */
|
|
5
|
+
kind: VpnRouteKind;
|
|
6
|
+
/** Prefix for the ids of the controls, unique on the page. */
|
|
7
|
+
idPrefix: string;
|
|
8
|
+
/** The setting this picker belongs to, prefixed to every control's name; absent in the credential forms. */
|
|
9
|
+
label?: string;
|
|
10
|
+
/** Why the last save refused the choice, if it did. */
|
|
11
|
+
error?: string;
|
|
12
|
+
};
|
|
13
|
+
declare const _default: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
|
|
14
|
+
"update:complete": (args_0: boolean) => any;
|
|
15
|
+
"update:modelValue": (args_0: VpnRouteValue | null) => any;
|
|
16
|
+
}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
|
|
17
|
+
"onUpdate:complete"?: ((args_0: boolean) => any) | undefined;
|
|
18
|
+
"onUpdate:modelValue"?: ((args_0: VpnRouteValue | null) => any) | undefined;
|
|
19
|
+
}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
|
|
20
|
+
export default _default;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Credential } from '../types/integrations.js';
|
|
1
|
+
import type { Credential, CredentialNetwork } from '../types/integrations.js';
|
|
2
2
|
import { IntegrationsApiError } from '../utils/apiError.js';
|
|
3
3
|
/**
|
|
4
4
|
* Typed, per-tenant credential instances, backed by the Integrations service
|
|
@@ -6,15 +6,19 @@ import { IntegrationsApiError } from '../utils/apiError.js';
|
|
|
6
6
|
* carries `public_config` (non-secret fields) + `status` only.
|
|
7
7
|
*
|
|
8
8
|
* GET /credentials[?type=slug] → list
|
|
9
|
-
* POST /credentials → create { credential_type_slug, name, config }
|
|
9
|
+
* POST /credentials → create { credential_type_slug, name, config, network? }
|
|
10
10
|
* GET /credentials/{id} → show (masked)
|
|
11
|
-
* PATCH /credentials/{id} → update { name?, config? } (blank secret = keep,
|
|
12
|
-
* unless the same edit changes any other value
|
|
13
|
-
* then 422 at config.<secret>, PO-765)
|
|
11
|
+
* PATCH /credentials/{id} → update { name?, config?, network? } (blank secret = keep,
|
|
12
|
+
* unless the same edit changes any other value
|
|
13
|
+
* or the network: then 422 at config.<secret>, PO-765)
|
|
14
14
|
* DELETE /credentials/{id} → remove
|
|
15
|
-
* POST /credentials/test → inline test { credential_type_slug, config }
|
|
16
|
-
* POST /credentials/{id}/test → test { config? } → { ok, message, recorded, last_test_at, last_test_ok }
|
|
15
|
+
* POST /credentials/test → inline test { credential_type_slug, config, network? }
|
|
16
|
+
* POST /credentials/{id}/test → test { config?, network? } → { ok, message, recorded, last_test_at, last_test_ok }
|
|
17
17
|
* POST /credentials/{id}/oauth/authorize-url → { authorize_url }
|
|
18
|
+
*
|
|
19
|
+
* `network` is `{ kind: 'vpn', connection, route }` or `null` (public internet), and
|
|
20
|
+
* is sent only for a type that declares what a VPN route replaces (PO-829); left out,
|
|
21
|
+
* the service keeps what is stored.
|
|
18
22
|
*/
|
|
19
23
|
/**
|
|
20
24
|
* `message` is what the credential type said about the test, whichever way it went —
|
|
@@ -65,13 +69,15 @@ export declare function useIntegrationsCredentials(): {
|
|
|
65
69
|
credential_type_slug: string;
|
|
66
70
|
name: string;
|
|
67
71
|
config: Record<string, unknown>;
|
|
72
|
+
network?: CredentialNetwork | null;
|
|
68
73
|
}) => Promise<Credential>;
|
|
69
74
|
update: (id: string, input: {
|
|
70
75
|
name?: string;
|
|
71
76
|
config?: Record<string, unknown>;
|
|
77
|
+
network?: CredentialNetwork | null;
|
|
72
78
|
}) => Promise<Credential>;
|
|
73
79
|
remove: (id: string) => Promise<void>;
|
|
74
|
-
testInline: (credentialTypeSlug: string, config: Record<string, unknown
|
|
75
|
-
test: (id: string, config?: Record<string, unknown
|
|
80
|
+
testInline: (credentialTypeSlug: string, config: Record<string, unknown>, network?: CredentialNetwork | null) => Promise<CredentialTestResult>;
|
|
81
|
+
test: (id: string, config?: Record<string, unknown>, network?: CredentialNetwork | null) => Promise<CredentialTestResult>;
|
|
76
82
|
oauthAuthorizeUrl: (id: string) => Promise<string>;
|
|
77
83
|
};
|
|
@@ -49,19 +49,24 @@ export function useIntegrationsCredentials() {
|
|
|
49
49
|
await api.del(`/credentials/${encodeURIComponent(id)}`);
|
|
50
50
|
credentials.value = credentials.value.filter((c) => c.id !== id);
|
|
51
51
|
}
|
|
52
|
-
async function testInline(credentialTypeSlug, config) {
|
|
52
|
+
async function testInline(credentialTypeSlug, config, network) {
|
|
53
53
|
try {
|
|
54
|
-
const
|
|
54
|
+
const body = { credential_type_slug: credentialTypeSlug, config };
|
|
55
|
+
if (network !== void 0) body.network = network;
|
|
56
|
+
const res = await api.post("/credentials/test", body);
|
|
55
57
|
return res?.ok === false ? res : { ok: true, message: res?.message };
|
|
56
58
|
} catch (e) {
|
|
57
59
|
return { ok: false, error: errorReason(e, t("integration.credentials.error.testFailed")) };
|
|
58
60
|
}
|
|
59
61
|
}
|
|
60
|
-
async function test(id, config) {
|
|
62
|
+
async function test(id, config, network) {
|
|
61
63
|
try {
|
|
64
|
+
const body = {};
|
|
65
|
+
if (config) body.config = config;
|
|
66
|
+
if (network !== void 0) body.network = network;
|
|
62
67
|
const res = await api.post(
|
|
63
68
|
`/credentials/${encodeURIComponent(id)}/test`,
|
|
64
|
-
|
|
69
|
+
Object.keys(body).length ? body : void 0
|
|
65
70
|
);
|
|
66
71
|
if (!config && res?.ok === false && res.recorded === false) return { ...res, untested: true };
|
|
67
72
|
return res?.ok === false ? res : { ok: true, message: res?.message, recorded: res?.recorded };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { RunStatus, RunStepRow, WorkflowRun } from '../types/integrations.js';
|
|
2
|
-
import type { RunFeedQuery, RunSort, RunSortDirection } from '../utils/runFeedQuery.js';
|
|
2
|
+
import type { RunFeedQuery, RunSort, RunSortDirection, WorkflowRunsNarrowing } from '../utils/runFeedQuery.js';
|
|
3
3
|
export type { RunFeedQuery };
|
|
4
4
|
interface RunSummary {
|
|
5
5
|
total: number;
|
|
@@ -27,6 +27,8 @@ export declare function useIntegrationsRuns(): {
|
|
|
27
27
|
perPage?: number;
|
|
28
28
|
sort?: RunSort;
|
|
29
29
|
direction?: RunSortDirection;
|
|
30
|
+
/** Narrowed by the platform, before it pages — so `total` counts what is left. */
|
|
31
|
+
narrowing?: WorkflowRunsNarrowing;
|
|
30
32
|
}) => Promise<{
|
|
31
33
|
runs: WorkflowRun[];
|
|
32
34
|
total: number;
|
|
@@ -38,7 +40,7 @@ export declare function useIntegrationsRuns(): {
|
|
|
38
40
|
status?: RunStatus;
|
|
39
41
|
}) => WorkflowRun[];
|
|
40
42
|
get: (id: string) => WorkflowRun | null;
|
|
41
|
-
fetchRun: (workflowId: number | string, runId: number | string, workflowName: string) => Promise<WorkflowRun>;
|
|
43
|
+
fetchRun: (workflowId: number | string, runId: number | string, workflowName: string, listed?: WorkflowRun) => Promise<WorkflowRun>;
|
|
42
44
|
latestPerWorkflow: any;
|
|
43
45
|
latestLoaded: any;
|
|
44
46
|
latestLoading: any;
|
|
@@ -4,6 +4,7 @@ import { useIntegrationsT } from "./useIntegrationsT.js";
|
|
|
4
4
|
import { errorReason } from "../utils/apiError.js";
|
|
5
5
|
import { bareRecord } from "../utils/apiEnvelope.js";
|
|
6
6
|
import { RUN_OWN_FIELDS } from "../utils/runEnvelope.js";
|
|
7
|
+
import { keepListTrigger, mapRun } from "../utils/runRecord.js";
|
|
7
8
|
import { useLoadGate } from "./useLoadGate.js";
|
|
8
9
|
import {
|
|
9
10
|
DEFAULT_RUN_DIRECTION,
|
|
@@ -42,39 +43,6 @@ export function useIntegrationsRuns() {
|
|
|
42
43
|
const SYNC_LIMIT = 5;
|
|
43
44
|
const api = useIntegrationsApi();
|
|
44
45
|
const t = useIntegrationsT();
|
|
45
|
-
function mapRun(r, workflowName = "") {
|
|
46
|
-
const startedMs = r.started_at ? new Date(r.started_at).getTime() : null;
|
|
47
|
-
const finishedMs = r.completed_at ? new Date(r.completed_at).getTime() : null;
|
|
48
|
-
return {
|
|
49
|
-
id: String(r.id),
|
|
50
|
-
workflowId: r.workflow_id,
|
|
51
|
-
workflowName: r.workflow_name ?? workflowName,
|
|
52
|
-
status: mapRunStatus(r.status),
|
|
53
|
-
// Kept beside the mapped word, because the mapping folds `terminated` into
|
|
54
|
-
// `failed` and the platform resumes only the literal `failed` (PO-355).
|
|
55
|
-
apiStatus: r.status,
|
|
56
|
-
temporalRunId: r.temporal_run_id ?? null,
|
|
57
|
-
triggerId: r.trigger_handle ?? "",
|
|
58
|
-
// The platform reports what started the run. This was hardcoded to
|
|
59
|
-
// `manual` because the payload carried nothing, which left the trigger
|
|
60
|
-
// column and its filter with one populated value in production.
|
|
61
|
-
// Null — the trigger was deleted, or the run predates trigger_handle, or the
|
|
62
|
-
// row came from a per-workflow read, which sends no trigger at all — is read
|
|
63
|
-
// as `manual` here although the platform said nothing (IN-9, api-contract
|
|
64
|
-
// runs.feed F6 and runs.execution F2).
|
|
65
|
-
triggerType: r.trigger_type ?? "manual",
|
|
66
|
-
startedAt: r.started_at ?? "",
|
|
67
|
-
finishedAt: r.completed_at ?? null,
|
|
68
|
-
// The list carries the reason a run failed; dropping it here was why every
|
|
69
|
-
// failure read the same wherever the list is shown. An empty string is not
|
|
70
|
-
// a reason — it has to fall through to whatever the surface says instead,
|
|
71
|
-
// or it renders as a blank line where the sentence belongs.
|
|
72
|
-
errorMessage: r.error || void 0,
|
|
73
|
-
// Derived from the timestamps — the list payload carries no duration.
|
|
74
|
-
durationMs: startedMs != null && finishedMs != null && finishedMs >= startedMs ? finishedMs - startedMs : null,
|
|
75
|
-
steps: []
|
|
76
|
-
};
|
|
77
|
-
}
|
|
78
46
|
async function load(force = false) {
|
|
79
47
|
if (!await gate.claim(force)) return runs.value;
|
|
80
48
|
error.value = null;
|
|
@@ -103,7 +71,7 @@ export function useIntegrationsRuns() {
|
|
|
103
71
|
const active = runs.value.filter((r) => r.status === "running" || r.status === "queued").sort((a, b) => Number(b.id) - Number(a.id)).slice(0, SYNC_LIMIT);
|
|
104
72
|
await Promise.allSettled(active.map(async (r) => {
|
|
105
73
|
try {
|
|
106
|
-
const fresh = await fetchRun(r.workflowId, r.id, r.workflowName);
|
|
74
|
+
const fresh = await fetchRun(r.workflowId, r.id, r.workflowName, r);
|
|
107
75
|
runs.value = runs.value.map((x) => x.id === fresh.id ? fresh : x);
|
|
108
76
|
} catch {
|
|
109
77
|
}
|
|
@@ -125,22 +93,26 @@ export function useIntegrationsRuns() {
|
|
|
125
93
|
const page = opts.page ?? 1;
|
|
126
94
|
const perPage = opts.perPage ?? 25;
|
|
127
95
|
const res = await api.get(
|
|
128
|
-
`/workflows/${workflowId}/runs?${workflowRunsQueryString(page, perPage, opts.sort, opts.direction)}`
|
|
96
|
+
`/workflows/${workflowId}/runs?${workflowRunsQueryString(page, perPage, opts.sort, opts.direction, opts.narrowing)}`
|
|
129
97
|
);
|
|
130
98
|
let list2 = (res?.data ?? []).map((r) => mapRun(r, workflowName));
|
|
131
99
|
if (opts.reconcile) {
|
|
132
100
|
const active = list2.filter((r) => r.status === "running" || r.status === "queued").sort((a, b) => Number(b.id) - Number(a.id)).slice(0, SYNC_LIMIT);
|
|
133
101
|
await Promise.allSettled(active.map(async (r) => {
|
|
134
102
|
try {
|
|
135
|
-
const fresh = await fetchRun(workflowId, r.id, workflowName);
|
|
103
|
+
const fresh = await fetchRun(workflowId, r.id, workflowName, r);
|
|
136
104
|
list2 = list2.map((x) => x.id === fresh.id ? fresh : x);
|
|
137
105
|
} catch {
|
|
138
106
|
}
|
|
139
107
|
}));
|
|
140
108
|
}
|
|
109
|
+
const wanted = opts.narrowing?.status ?? [];
|
|
110
|
+
const before = list2.length;
|
|
111
|
+
if (wanted.length) list2 = list2.filter((r) => wanted.includes(r.status));
|
|
112
|
+
const total2 = res?.meta?.total ?? before;
|
|
141
113
|
return {
|
|
142
114
|
runs: list2,
|
|
143
|
-
total:
|
|
115
|
+
total: total2 - (before - list2.length),
|
|
144
116
|
page: res?.meta?.current_page ?? page,
|
|
145
117
|
lastPage: res?.meta?.last_page ?? 1
|
|
146
118
|
};
|
|
@@ -202,9 +174,10 @@ export function useIntegrationsRuns() {
|
|
|
202
174
|
const retryRun = (workflowId, runId) => runAction(workflowId, runId, "retry");
|
|
203
175
|
const resumeRun = (workflowId, runId, reason) => runAction(workflowId, runId, "resume", reason);
|
|
204
176
|
const getRun = (workflowId, runId) => api.get(`/workflows/${workflowId}/runs/${runId}`);
|
|
205
|
-
async function fetchRun(workflowId, runId, workflowName) {
|
|
177
|
+
async function fetchRun(workflowId, runId, workflowName, listed) {
|
|
206
178
|
const apiRun = bareRecord(await getRun(workflowId, runId), RUN_OWN_FIELDS);
|
|
207
|
-
|
|
179
|
+
const fresh = mapRun(apiRun, workflowName);
|
|
180
|
+
return listed ? keepListTrigger(fresh, listed) : fresh;
|
|
208
181
|
}
|
|
209
182
|
const getDetails = (workflowId, runId) => api.get(`/workflows/${workflowId}/runs/${runId}/details`);
|
|
210
183
|
const getSteps = (workflowId, runId, includePayloads = false) => api.get(
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { VpnConnection } from '../types/integrations.js';
|
|
2
|
+
/**
|
|
3
|
+
* The tenant's VPN connections and their routes, as the Integrations service
|
|
4
|
+
* reports them (`GET /api/v1/vpn-connections`, PO-825). Each carries the status
|
|
5
|
+
* Console derives for it; only an `active` one carries traffic.
|
|
6
|
+
*
|
|
7
|
+
* Read by the credential forms' Network section, the credential picker's VPN badge
|
|
8
|
+
* and the `vpn-route` node setting — so it is one shared list, loaded once per page
|
|
9
|
+
* and re-read on request. A tenant without any answers an empty list; a Console
|
|
10
|
+
* that cannot be reached answers 502, which lands in `error` rather than reading as
|
|
11
|
+
* "no connections".
|
|
12
|
+
*/
|
|
13
|
+
export declare function useVpnConnections(): {
|
|
14
|
+
connections: any;
|
|
15
|
+
loading: any;
|
|
16
|
+
error: any;
|
|
17
|
+
loaded: any;
|
|
18
|
+
load: (force?: boolean) => Promise<VpnConnection[]>;
|
|
19
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { useIntegrationsApi } from "./integrationsApi.js";
|
|
2
|
+
import { useIntegrationsT } from "./useIntegrationsT.js";
|
|
3
|
+
import { errorReason } from "../utils/apiError.js";
|
|
4
|
+
import { unwrapList } from "../utils/apiEnvelope.js";
|
|
5
|
+
import { useLoadGate } from "./useLoadGate.js";
|
|
6
|
+
export function useVpnConnections() {
|
|
7
|
+
const connections = useState("integrations.vpnConnections", () => []);
|
|
8
|
+
const loading = useState("integrations.vpnConnections.loading", () => false);
|
|
9
|
+
const error = useState("integrations.vpnConnections.error", () => null);
|
|
10
|
+
const loaded = useState("integrations.vpnConnections.loaded", () => false);
|
|
11
|
+
const gate = useLoadGate("integrations.vpnConnections", { loading, loaded });
|
|
12
|
+
const api = useIntegrationsApi();
|
|
13
|
+
const t = useIntegrationsT();
|
|
14
|
+
async function load(force = false) {
|
|
15
|
+
if (!await gate.claim(force)) return connections.value;
|
|
16
|
+
error.value = null;
|
|
17
|
+
try {
|
|
18
|
+
connections.value = unwrapList(await api.get("/vpn-connections"));
|
|
19
|
+
loaded.value = true;
|
|
20
|
+
} catch (e) {
|
|
21
|
+
error.value = errorReason(e, t("integration.vpn.error.load"));
|
|
22
|
+
} finally {
|
|
23
|
+
gate.release();
|
|
24
|
+
}
|
|
25
|
+
return connections.value;
|
|
26
|
+
}
|
|
27
|
+
return { connections, loading, error, loaded, load };
|
|
28
|
+
}
|
|
@@ -574,8 +574,10 @@
|
|
|
574
574
|
"integration.nodeConfig.credential.state.failed": "Letzter Test fehlgeschlagen: {message}",
|
|
575
575
|
"integration.nodeConfig.credential.state.needsReauth": "Diese Zugangsdaten müssen neu verbunden werden.",
|
|
576
576
|
"integration.nodeConfig.credential.trigger.bound": "{label}: {name}, {state}",
|
|
577
|
+
"integration.nodeConfig.credential.trigger.boundVpn": "{label}: {name}, {state}, {vpn}",
|
|
577
578
|
"integration.nodeConfig.credential.trigger.unknown": "{label}: {unknown}",
|
|
578
579
|
"integration.nodeConfig.credential.unknown": "Unbekannte Zugangsdaten — bitte erneut auswählen",
|
|
580
|
+
"integration.nodeConfig.credential.vpnInactive": "Ausführungen mit diesen Zugangsdaten schlagen fehl, bis ihre VPN-Verbindung wieder aktiv ist.",
|
|
579
581
|
"integration.nodeConfig.dynamicField.currentNotResolved": "aktueller Wert nicht in den geladenen Optionen",
|
|
580
582
|
"integration.nodeConfig.dynamicField.empty": "keine Optionen verfügbar",
|
|
581
583
|
"integration.nodeConfig.dynamicField.emptyFiltered": "keine Optionen passen zum Filter",
|
|
@@ -1231,6 +1233,39 @@
|
|
|
1231
1233
|
"integration.templates.trigger.manual": "Bei Bedarf ausführen",
|
|
1232
1234
|
"integration.templates.trigger.webhook": "Webhook",
|
|
1233
1235
|
"integration.triggers.error.loadFailed": "Trigger konnten nicht geladen werden.",
|
|
1236
|
+
"integration.vpn.badge.aria": "{name}: {badge}",
|
|
1237
|
+
"integration.vpn.badge.missing": "VPN-Route fehlt",
|
|
1238
|
+
"integration.vpn.badge.named": "VPN · {connection}",
|
|
1239
|
+
"integration.vpn.badge.withState": "VPN · {connection} ({status})",
|
|
1240
|
+
"integration.vpn.empty.elsewhere": "VPN-Verbindungen werden im Cockpit eingerichtet.",
|
|
1241
|
+
"integration.vpn.empty.link": "Jetzt einrichten",
|
|
1242
|
+
"integration.vpn.empty.text": "Für diesen Mandanten ist noch keine VPN-Verbindung eingerichtet.",
|
|
1243
|
+
"integration.vpn.error.load": "Die VPN-Verbindungen konnten nicht geladen werden.",
|
|
1244
|
+
"integration.vpn.httpsNote": "Das Ziel wird über HTTPS erreicht; sein Zertifikat wird am Gateway nicht geprüft.",
|
|
1245
|
+
"integration.vpn.loading": "VPN-Verbindungen werden geladen…",
|
|
1246
|
+
"integration.vpn.picker.ariaFor": "{label}: {control}",
|
|
1247
|
+
"integration.vpn.picker.connection": "VPN-Verbindung",
|
|
1248
|
+
"integration.vpn.picker.connection.placeholder": "Verbindung auswählen",
|
|
1249
|
+
"integration.vpn.picker.group": "Netzwerk",
|
|
1250
|
+
"integration.vpn.picker.pending.public": "Noch keine Route gewählt. Bis eine gewählt ist, gilt das öffentliche Internet.",
|
|
1251
|
+
"integration.vpn.picker.pending.route": "Noch keine Route gewählt. Bis eine gewählt ist, gilt {route} über {connection}.",
|
|
1252
|
+
"integration.vpn.picker.public": "Öffentliches Internet",
|
|
1253
|
+
"integration.vpn.picker.route": "Route",
|
|
1254
|
+
"integration.vpn.picker.route.empty.http": "Diese Verbindung hat keine HTTP-Routen.",
|
|
1255
|
+
"integration.vpn.picker.route.empty.tcp": "Diese Verbindung hat keine TCP-Routen.",
|
|
1256
|
+
"integration.vpn.picker.route.placeholder": "Route auswählen",
|
|
1257
|
+
"integration.vpn.picker.vpn": "Über VPN-Verbindung",
|
|
1258
|
+
"integration.vpn.reference.inactive": "{connection} ist nicht aktiv ({status}) — Ausführungen darüber schlagen fehl, bis die Verbindung wieder aktiv ist.",
|
|
1259
|
+
"integration.vpn.reference.missing": "Die VPN-Route {connection}/{route} existiert nicht mehr. Wählen Sie eine andere Route oder das öffentliche Internet.",
|
|
1260
|
+
"integration.vpn.section.title": "Netzwerk",
|
|
1261
|
+
"integration.vpn.status.active": "Aktiv",
|
|
1262
|
+
"integration.vpn.status.disabled": "Deaktiviert",
|
|
1263
|
+
"integration.vpn.status.down": "Getrennt",
|
|
1264
|
+
"integration.vpn.status.failed": "Fehlgeschlagen",
|
|
1265
|
+
"integration.vpn.status.provisioning": "Wird eingerichtet",
|
|
1266
|
+
"integration.vpn.status.standby": "Bereitschaft",
|
|
1267
|
+
"integration.vpn.status.unknown": "Unbekannt",
|
|
1268
|
+
"integration.vpn.target": "{target} über {connection}",
|
|
1234
1269
|
"integration.workflowFile.action.export": "Als Datei exportieren",
|
|
1235
1270
|
"integration.workflowFile.action.import": "Aus Datei importieren…",
|
|
1236
1271
|
"integration.workflowFile.count.both": "{nodes}, {triggers}",
|
|
@@ -574,8 +574,10 @@
|
|
|
574
574
|
"integration.nodeConfig.credential.state.failed": "Last test failed: {message}",
|
|
575
575
|
"integration.nodeConfig.credential.state.needsReauth": "This credential needs reconnect.",
|
|
576
576
|
"integration.nodeConfig.credential.trigger.bound": "{label}: {name}, {state}",
|
|
577
|
+
"integration.nodeConfig.credential.trigger.boundVpn": "{label}: {name}, {state}, {vpn}",
|
|
577
578
|
"integration.nodeConfig.credential.trigger.unknown": "{label}: {unknown}",
|
|
578
579
|
"integration.nodeConfig.credential.unknown": "Unknown credential — pick again",
|
|
580
|
+
"integration.nodeConfig.credential.vpnInactive": "Runs that use this credential fail until its VPN connection is active again.",
|
|
579
581
|
"integration.nodeConfig.dynamicField.currentNotResolved": "current value not in resolved options",
|
|
580
582
|
"integration.nodeConfig.dynamicField.empty": "no options available",
|
|
581
583
|
"integration.nodeConfig.dynamicField.emptyFiltered": "no options match the filter",
|
|
@@ -1231,6 +1233,39 @@
|
|
|
1231
1233
|
"integration.templates.trigger.manual": "Run on demand",
|
|
1232
1234
|
"integration.templates.trigger.webhook": "Webhook",
|
|
1233
1235
|
"integration.triggers.error.loadFailed": "Could not load triggers.",
|
|
1236
|
+
"integration.vpn.badge.aria": "{name}: {badge}",
|
|
1237
|
+
"integration.vpn.badge.missing": "VPN route missing",
|
|
1238
|
+
"integration.vpn.badge.named": "VPN · {connection}",
|
|
1239
|
+
"integration.vpn.badge.withState": "VPN · {connection} ({status})",
|
|
1240
|
+
"integration.vpn.empty.elsewhere": "VPN connections are set up in the cockpit.",
|
|
1241
|
+
"integration.vpn.empty.link": "Set one up",
|
|
1242
|
+
"integration.vpn.empty.text": "No VPN connection is set up for this tenant yet.",
|
|
1243
|
+
"integration.vpn.error.load": "The VPN connections could not be loaded.",
|
|
1244
|
+
"integration.vpn.httpsNote": "The target is reached over HTTPS, and its certificate is not verified at the gateway.",
|
|
1245
|
+
"integration.vpn.loading": "Loading VPN connections…",
|
|
1246
|
+
"integration.vpn.picker.ariaFor": "{label}: {control}",
|
|
1247
|
+
"integration.vpn.picker.connection": "VPN connection",
|
|
1248
|
+
"integration.vpn.picker.connection.placeholder": "Pick a connection",
|
|
1249
|
+
"integration.vpn.picker.group": "Network",
|
|
1250
|
+
"integration.vpn.picker.pending.public": "No route chosen yet. Until one is, the public internet is used.",
|
|
1251
|
+
"integration.vpn.picker.pending.route": "No route chosen yet. Until one is, {route} via {connection} is used.",
|
|
1252
|
+
"integration.vpn.picker.public": "Public internet",
|
|
1253
|
+
"integration.vpn.picker.route": "Route",
|
|
1254
|
+
"integration.vpn.picker.route.empty.http": "This connection has no HTTP routes.",
|
|
1255
|
+
"integration.vpn.picker.route.empty.tcp": "This connection has no TCP routes.",
|
|
1256
|
+
"integration.vpn.picker.route.placeholder": "Pick a route",
|
|
1257
|
+
"integration.vpn.picker.vpn": "Through a VPN connection",
|
|
1258
|
+
"integration.vpn.reference.inactive": "{connection} is not active ({status}) — runs through it fail until it is active again.",
|
|
1259
|
+
"integration.vpn.reference.missing": "The VPN route {connection}/{route} no longer exists. Choose another route or the public internet.",
|
|
1260
|
+
"integration.vpn.section.title": "Network",
|
|
1261
|
+
"integration.vpn.status.active": "Active",
|
|
1262
|
+
"integration.vpn.status.disabled": "Disabled",
|
|
1263
|
+
"integration.vpn.status.down": "Down",
|
|
1264
|
+
"integration.vpn.status.failed": "Failed",
|
|
1265
|
+
"integration.vpn.status.provisioning": "Provisioning",
|
|
1266
|
+
"integration.vpn.status.standby": "Standby",
|
|
1267
|
+
"integration.vpn.status.unknown": "Unknown",
|
|
1268
|
+
"integration.vpn.target": "{target} via {connection}",
|
|
1234
1269
|
"integration.workflowFile.action.export": "Export as file",
|
|
1235
1270
|
"integration.workflowFile.action.import": "Import from file…",
|
|
1236
1271
|
"integration.workflowFile.count.both": "{nodes}, {triggers}",
|