@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.
Files changed (33) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/module.json +1 -1
  3. package/dist/runtime/components/Integrations/CredentialCreateDialog.vue +34 -6
  4. package/dist/runtime/components/Integrations/CredentialDetailDialog.vue +42 -10
  5. package/dist/runtime/components/Integrations/CredentialNetworkSection.d.vue.ts +21 -0
  6. package/dist/runtime/components/Integrations/CredentialNetworkSection.vue +28 -0
  7. package/dist/runtime/components/Integrations/CredentialNetworkSection.vue.d.ts +21 -0
  8. package/dist/runtime/components/Integrations/NodeConfigField.vue +46 -3
  9. package/dist/runtime/components/Integrations/RunsTable.d.vue.ts +9 -0
  10. package/dist/runtime/components/Integrations/RunsTable.vue +14 -8
  11. package/dist/runtime/components/Integrations/RunsTable.vue.d.ts +9 -0
  12. package/dist/runtime/components/Integrations/VpnRoutePicker.d.vue.ts +20 -0
  13. package/dist/runtime/components/Integrations/VpnRoutePicker.vue +194 -0
  14. package/dist/runtime/components/Integrations/VpnRoutePicker.vue.d.ts +20 -0
  15. package/dist/runtime/composables/useIntegrationsCredentials.d.ts +15 -9
  16. package/dist/runtime/composables/useIntegrationsCredentials.js +9 -4
  17. package/dist/runtime/composables/useIntegrationsRuns.d.ts +4 -2
  18. package/dist/runtime/composables/useIntegrationsRuns.js +12 -39
  19. package/dist/runtime/composables/useVpnConnections.d.ts +19 -0
  20. package/dist/runtime/composables/useVpnConnections.js +28 -0
  21. package/dist/runtime/i18n/messages/de.json +35 -0
  22. package/dist/runtime/i18n/messages/en.json +35 -0
  23. package/dist/runtime/pages/integrations/credentials.vue +67 -15
  24. package/dist/runtime/pages/integrations/workflows/[id].vue +15 -1
  25. package/dist/runtime/types/integrations.d.ts +64 -2
  26. package/dist/runtime/utils/configDefaults.d.ts +5 -2
  27. package/dist/runtime/utils/runFeedQuery.d.ts +8 -5
  28. package/dist/runtime/utils/runFeedQuery.js +9 -4
  29. package/dist/runtime/utils/runRecord.d.ts +39 -0
  30. package/dist/runtime/utils/runRecord.js +39 -0
  31. package/dist/runtime/utils/vpnRoute.d.ts +69 -0
  32. package/dist/runtime/utils/vpnRoute.js +79 -0
  33. 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>) => Promise<CredentialTestResult>;
75
- test: (id: string, config?: Record<string, unknown>) => Promise<CredentialTestResult>;
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 res = await api.post("/credentials/test", { credential_type_slug: credentialTypeSlug, config });
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
- config ? { config } : void 0
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: res?.meta?.total ?? list2.length,
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
- return mapRun(apiRun, workflowName);
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}",