@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
package/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # @revenexx/studio-integrations
2
2
 
3
+ ## 3.4.0
4
+
5
+ ### Minor Changes
6
+
7
+ - e755223: A workflow's own Runs tab can be narrowed by outcome and by what started a run, with the same Filter control as the Runs page. The platform narrows the workflow's whole history before it pages it, so a failure from three pages back is found, and the pager counts the matches. A narrowing that matches nothing says so, instead of saying the workflow has never run.
8
+
9
+ ### Patch Changes
10
+
11
+ - 57b4430: A workflow's own Runs tab says what started each run. Every row there read "Manual", whatever had fired the run, because the list sent no trigger and the studio filled the gap with that word. The tab now shows the trigger the platform reports, and a dash where it reports none — a deleted trigger, or a run older than the platform recording it — on the tenant-wide Runs page as well. A run that is still going keeps its trigger while the list re-reads it.
12
+
13
+ ## 3.3.0
14
+
15
+ ### Minor Changes
16
+
17
+ - 1318abe: A credential can reach its system through a VPN route instead of the public internet. For a credential type that declares which of its fields a route stands in for, both credential forms gain a Network section: public internet, or one of the tenant's VPN connections (with its status) and a route of the kind the type speaks. The fields the route replaces turn read-only and show what they reach; an HTTPS target notes that its certificate is not verified at the gateway; a tenant without connections is pointed at the VPN connections page where the host has one. A credential that goes through a VPN carries a badge in the credentials list and in a node's credential picker, naming the connection, its state when it is not active, and a route that no longer exists. A node setting of the new type `vpn-route` is drawn as the same choice and stores `{ connection, route }`, or nothing for the public internet. Needs the Integrations service's `GET /api/v1/vpn-connections` and the credential `network` field (PO-825).
18
+
19
+ ### Patch Changes
20
+
21
+ - f73ff6c: A new credential's form starts from the defaults its system declares. A list read *Select* and a number stood empty even where the system had chosen a value — an FTP credential's encryption, its port — so the screen said nothing had been chosen; they now show that value, and saving without touching them stores it. Picking the system again after going back starts from those defaults instead of the last attempt's values. Both places a credential is added work the same: the credentials list and a node's credential setting. Opening a stored credential shows the default for a field it never stored, rather than an empty field or a switch drawn off; a value it did store still wins, and an untouched form still tests the stored credential.
22
+
3
23
  ## 3.2.0
4
24
 
5
25
  ### Minor Changes
package/dist/module.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@revenexx/studio-integrations",
3
3
  "configKey": "studioIntegrations",
4
- "version": "3.2.0",
4
+ "version": "3.4.0",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.3",
7
7
  "unbuild": "unknown"
@@ -20,10 +20,12 @@ import {
20
20
  } from "@revenexx/studio";
21
21
  import { toast } from "vue-sonner";
22
22
  import { resolveLocalized } from "../../utils/localize";
23
+ import { defaultsFor } from "../../utils/configDefaults";
23
24
  import { fromModelValue, toItemValue, toModelValue } from "../../utils/selectOptions";
24
25
  import { errorReason } from "../../utils/apiError";
25
26
  import { keepOpenBeside } from "../../utils/dialog";
26
27
  import { testOutcomeView } from "../../utils/credentialTest";
28
+ import { credentialNetworkPayload, replacedFieldTexts } from "../../utils/vpnRoute";
27
29
  const props = defineProps({
28
30
  open: { type: Boolean, required: true },
29
31
  credentialType: { type: [String, Array], required: false }
@@ -61,6 +63,13 @@ const sourceActive = ref(false);
61
63
  const saving = ref(false);
62
64
  const testing = ref(false);
63
65
  const testResult = ref(null);
66
+ const { connections: vpnConnections } = useVpnConnections();
67
+ const network = ref(null);
68
+ const networkComplete = ref(true);
69
+ const replaced = computed(() => replacedFieldTexts(pickedType.value, network.value, vpnConnections.value));
70
+ function networkPayload() {
71
+ return pickedType.value?.network ? credentialNetworkPayload(network.value) : void 0;
72
+ }
64
73
  function fieldsOf(t2) {
65
74
  return t2?.fields ?? [];
66
75
  }
@@ -72,10 +81,12 @@ watch(() => props.open, async (o) => {
72
81
  testResult.value = null;
73
82
  sourceActive.value = false;
74
83
  form.config = {};
84
+ network.value = null;
75
85
  await loadTypes();
76
86
  if (allowedTypes.value.length === 1) {
77
87
  pickedType.value = allowedTypes.value[0];
78
88
  form.name = resolveLocalized(pickedType.value.name);
89
+ form.config = defaultsFor(fieldsOf(pickedType.value));
79
90
  } else {
80
91
  pickedType.value = null;
81
92
  form.name = "";
@@ -83,9 +94,10 @@ watch(() => props.open, async (o) => {
83
94
  }, { immediate: true });
84
95
  function pickType(t2) {
85
96
  pickedType.value = t2;
86
- form.config = {};
97
+ form.config = defaultsFor(fieldsOf(t2));
87
98
  testResult.value = null;
88
99
  sourceActive.value = false;
100
+ network.value = null;
89
101
  if (!form.name) form.name = resolveLocalized(t2.name);
90
102
  }
91
103
  function buildConfigPayload() {
@@ -101,7 +113,7 @@ async function runTest() {
101
113
  testing.value = true;
102
114
  testResult.value = null;
103
115
  try {
104
- testResult.value = await testInline(pickedType.value.slug, buildConfigPayload());
116
+ testResult.value = await testInline(pickedType.value.slug, buildConfigPayload(), networkPayload());
105
117
  } finally {
106
118
  testing.value = false;
107
119
  }
@@ -119,7 +131,8 @@ async function submit() {
119
131
  const created = await create({
120
132
  credential_type_slug: pickedType.value.slug,
121
133
  name: form.name.trim(),
122
- config: buildConfigPayload()
134
+ config: buildConfigPayload(),
135
+ network: networkPayload()
123
136
  });
124
137
  await load(true);
125
138
  toast.success(t("integration.credentials.toast.created"));
@@ -193,13 +206,28 @@ async function submit() {
193
206
  @apply="(c) => Object.assign(form.config, c)"
194
207
  />
195
208
 
209
+ <IntegrationsCredentialNetworkSection
210
+ v-model="network"
211
+ :type="pickedType"
212
+ id-prefix="create-network"
213
+ @update:complete="(c) => networkComplete = c"
214
+ />
215
+
196
216
  <!-- Each label names its own control (`for`/`id`): without it a screen
197
217
  reader reads the field as unlabelled, and the label text cannot be
198
218
  used to reach it at all. -->
199
219
  <div v-for="f in fieldsOf(pickedType)" v-show="!sourceActive" :key="f.key" class="space-y-1.5">
200
220
  <Label :for="`create-field-${f.key}`">{{ resolveLocalized(f.label) }}{{ f.required ? " *" : "" }}</Label>
221
+ <!-- stood in for by the chosen VPN route: what it reaches, not editable -->
222
+ <Input
223
+ v-if="replaced[f.key]"
224
+ :id="`create-field-${f.key}`"
225
+ :model-value="replaced[f.key]"
226
+ readonly
227
+ class="bg-muted/40"
228
+ />
201
229
  <Input
202
- v-if="f.type === 'secret'"
230
+ v-else-if="f.type === 'secret'"
203
231
  :id="`create-field-${f.key}`"
204
232
  :model-value="form.config[f.key] ?? ''"
205
233
  type="password"
@@ -259,12 +287,12 @@ async function submit() {
259
287
 
260
288
  <DialogFooter v-if="pickedType">
261
289
  <div class="flex items-center gap-2">
262
- <Button variant="outline" :disabled="saving || testing" @click="runTest">
290
+ <Button variant="outline" :disabled="saving || testing || !networkComplete" @click="runTest">
263
291
  <Loader2 v-if="testing" :size="14" class="mr-1 animate-spin" />
264
292
  <TestIcon v-else :size="14" class="mr-1" />
265
293
  {{ t("integration.credentials.shared.action.testConnection") }}
266
294
  </Button>
267
- <Button :disabled="saving || !form.name.trim()" @click="submit">
295
+ <Button :disabled="saving || !form.name.trim() || !networkComplete" @click="submit">
268
296
  <Loader2 v-if="saving" :size="14" class="mr-1 animate-spin" />
269
297
  {{ t("integration.common.action.save") }}
270
298
  </Button>
@@ -33,6 +33,7 @@ import { toast } from "../../utils/notify";
33
33
  import { apiRefusal, errorReason } from "../../utils/apiError";
34
34
  import { credentialState } from "../../utils/credentialState";
35
35
  import { credentialEditDiffers } from "../../utils/credentialEdit";
36
+ import { defaultsFor } from "../../utils/configDefaults";
36
37
  import { relativeTime } from "../../utils/relativeTime";
37
38
  import { useNow } from "../../composables/useNow";
38
39
  import { hasRevenexxSource } from "../../utils/revenexxCredentialSource";
@@ -40,6 +41,7 @@ import { resolveLocalized } from "../../utils/localize";
40
41
  import { fromModelValue, toItemValue, toModelValue } from "../../utils/selectOptions";
41
42
  import { keepOpenBeside } from "../../utils/dialog";
42
43
  import { testOutcomeView } from "../../utils/credentialTest";
44
+ import { credentialNetworkPayload, fromCredentialNetwork, replacedFieldTexts, sameVpnRoute } from "../../utils/vpnRoute";
43
45
  const props = defineProps({
44
46
  open: { type: Boolean, required: true },
45
47
  credential: { type: [Object, null], required: true },
@@ -61,8 +63,17 @@ const deleting = ref(false);
61
63
  const testing = ref(false);
62
64
  const liveTest = ref(null);
63
65
  const saveRefusal = ref(null);
66
+ const { connections: vpnConnections } = useVpnConnections();
67
+ const network = ref(null);
68
+ const networkComplete = ref(true);
69
+ const replaced = computed(() => replacedFieldTexts(props.type, network.value, vpnConnections.value));
70
+ const networkChanged = computed(() => !!props.type?.network && !sameVpnRoute(network.value, fromCredentialNetwork(props.credential?.network)));
71
+ function networkPayload() {
72
+ return networkChanged.value ? credentialNetworkPayload(network.value) : void 0;
73
+ }
64
74
  const drawnKeys = computed(() => [
65
75
  "name",
76
+ ...props.type?.network ? ["network"] : [],
66
77
  ...sourceActive.value ? [] : fieldsOf(props.type).map((f) => `config.${f.key}`)
67
78
  ]);
68
79
  const refusal = computed(() => apiRefusal(saveRefusal.value, drawnKeys.value));
@@ -80,7 +91,7 @@ async function focusFirstRefusedField() {
80
91
  await nextTick();
81
92
  const key = drawnKeys.value.find((k) => k in fieldErrors.value);
82
93
  if (!key) return;
83
- const id = key === "name" ? "detail-cred-name" : `detail-field-${key.slice("config.".length)}`;
94
+ const id = key === "name" ? "detail-cred-name" : key === "network" ? "detail-network-public" : `detail-field-${key.slice("config.".length)}`;
84
95
  document.getElementById(id)?.focus();
85
96
  }
86
97
  function clearRefusal() {
@@ -102,14 +113,16 @@ function formatDateTime(iso) {
102
113
  }
103
114
  }
104
115
  let viewOpening = 0;
116
+ const storedConfig = computed(() => ({ ...defaultsFor(fieldsOf(props.type)), ...props.credential?.public_config ?? {} }));
105
117
  watch(
106
- [() => props.open, () => props.credential?.id],
118
+ [() => props.open, () => props.credential?.id, () => props.type?.slug],
107
119
  () => {
108
120
  viewOpening++;
109
121
  if (!props.open || !props.credential) return;
110
122
  tab.value = "credentials";
111
123
  form.name = props.credential.name;
112
- form.config = { ...props.credential.public_config ?? {} };
124
+ form.config = { ...storedConfig.value };
125
+ network.value = fromCredentialNetwork(props.credential.network);
113
126
  sourceActive.value = false;
114
127
  liveTest.value = null;
115
128
  clearRefusal();
@@ -133,8 +146,8 @@ async function runTest() {
133
146
  clearRefusal();
134
147
  try {
135
148
  const payload = buildConfigPayload();
136
- const edited = credentialEditDiffers(fieldsOf(props.type), payload, props.credential.public_config ?? {});
137
- const result = await test(props.credential.id, edited ? payload : void 0);
149
+ const edited = credentialEditDiffers(fieldsOf(props.type), payload, storedConfig.value) || networkChanged.value;
150
+ const result = await test(props.credential.id, edited ? payload : void 0, edited ? networkPayload() : void 0);
138
151
  if (result.refusal) {
139
152
  if (opening !== viewOpening) return;
140
153
  saveRefusal.value = result.refusal;
@@ -161,10 +174,10 @@ async function save() {
161
174
  clearRefusal();
162
175
  const id = props.credential.id;
163
176
  const payload = buildConfigPayload();
164
- const edited = credentialEditDiffers(fieldsOf(props.type), payload, props.credential.public_config ?? {});
177
+ const edited = credentialEditDiffers(fieldsOf(props.type), payload, storedConfig.value) || networkChanged.value;
165
178
  try {
166
179
  const wasConnected = props.credential.status !== "needs_reauth";
167
- const updated = await update(id, { name: form.name.trim(), config: payload });
180
+ const updated = await update(id, { name: form.name.trim(), config: payload, network: networkPayload() });
168
181
  const lostConsent = wasConnected && updated.status === "needs_reauth";
169
182
  emit("changed");
170
183
  emit("update:open", false);
@@ -315,14 +328,33 @@ async function connect() {
315
328
  @apply="(c) => Object.assign(form.config, c)"
316
329
  />
317
330
 
331
+ <!-- Keyed by the credential: the picker holds a half-made choice of
332
+ its own, which belongs to the credential it was made on. -->
333
+ <IntegrationsCredentialNetworkSection
334
+ :key="credential.id"
335
+ v-model="network"
336
+ :type="type"
337
+ id-prefix="detail-network"
338
+ :error="fieldErrors.network"
339
+ @update:complete="(c) => networkComplete = c"
340
+ />
341
+
318
342
  <!-- Each label names its own control (`for`/`id`): without it a
319
343
  screen reader reads the field as unlabelled, and the label text
320
344
  cannot be used to reach it at all. -->
321
345
  <div v-for="f in fieldsOf(type)" v-show="!sourceActive" :key="f.key" class="space-y-1.5">
322
346
  <Label :for="`detail-field-${f.key}`">{{ resolveLocalized(f.label) }}{{ f.required ? " *" : "" }}</Label>
323
347
 
348
+ <!-- stood in for by the chosen VPN route: what it reaches, not editable -->
349
+ <Input
350
+ v-if="replaced[f.key]"
351
+ :id="`detail-field-${f.key}`"
352
+ :model-value="replaced[f.key]"
353
+ readonly
354
+ class="bg-muted/40"
355
+ />
324
356
  <Input
325
- v-if="f.type === 'secret'"
357
+ v-else-if="f.type === 'secret'"
326
358
  :id="`detail-field-${f.key}`"
327
359
  :model-value="form.config[f.key] ?? ''"
328
360
  type="password"
@@ -405,12 +437,12 @@ async function connect() {
405
437
  <Button variant="ghost" :disabled="saving" @click="emit('update:open', false)">
406
438
  {{ t("integration.common.action.cancel") }}
407
439
  </Button>
408
- <Button variant="outline" :disabled="testing || saving" @click="runTest">
440
+ <Button variant="outline" :disabled="testing || saving || !networkComplete" @click="runTest">
409
441
  <Loader2 v-if="testing" :size="14" class="mr-1 animate-spin" />
410
442
  <TestIcon v-else :size="14" class="mr-1" />
411
443
  {{ t("integration.credentials.shared.action.testConnection") }}
412
444
  </Button>
413
- <Button :disabled="saving || !form.name.trim()" @click="save">
445
+ <Button :disabled="saving || !form.name.trim() || !networkComplete" @click="save">
414
446
  <Loader2 v-if="saving" :size="14" class="mr-1 animate-spin" />
415
447
  {{ t("integration.common.action.save") }}
416
448
  </Button>
@@ -0,0 +1,21 @@
1
+ /**
2
+ * The Network section of a credential form: how the credential reaches its system —
3
+ * over the public internet, or through a route of one of the tenant's VPN
4
+ * connections (specs/credential-network.md). Drawn only for a type that declares
5
+ * which of its fields a route stands in for; every other type has nothing to choose.
6
+ */
7
+ import type { CredentialType, VpnRouteValue } from '../../types/integrations.js';
8
+ type __VLS_Props = {
9
+ type: CredentialType | null;
10
+ modelValue: VpnRouteValue | null;
11
+ idPrefix: string;
12
+ error?: string;
13
+ };
14
+ declare const _default: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
15
+ "update:complete": (args_0: boolean) => any;
16
+ "update:modelValue": (args_0: VpnRouteValue | null) => any;
17
+ }, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
18
+ "onUpdate:complete"?: ((args_0: boolean) => any) | undefined;
19
+ "onUpdate:modelValue"?: ((args_0: VpnRouteValue | null) => any) | undefined;
20
+ }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
21
+ export default _default;
@@ -0,0 +1,28 @@
1
+ <script setup>
2
+ defineProps({
3
+ type: { type: [Object, null], required: true },
4
+ modelValue: { type: [Object, null], required: true },
5
+ idPrefix: { type: String, required: true },
6
+ error: { type: String, required: false }
7
+ });
8
+ const emit = defineEmits(["update:modelValue", "update:complete"]);
9
+ const t = useIntegrationsT();
10
+ </script>
11
+
12
+ <template>
13
+ <section
14
+ v-if="type?.network"
15
+ :aria-labelledby="`${idPrefix}-heading`"
16
+ class="space-y-2 rounded-lg border border-black/[0.08] p-3"
17
+ >
18
+ <h3 :id="`${idPrefix}-heading`" class="text-sm font-semibold">{{ t("integration.vpn.section.title") }}</h3>
19
+ <IntegrationsVpnRoutePicker
20
+ :model-value="modelValue"
21
+ :kind="type.network.kind"
22
+ :id-prefix="idPrefix"
23
+ :error="error"
24
+ @update:model-value="(v) => emit('update:modelValue', v)"
25
+ @update:complete="(c) => emit('update:complete', c)"
26
+ />
27
+ </section>
28
+ </template>
@@ -0,0 +1,21 @@
1
+ /**
2
+ * The Network section of a credential form: how the credential reaches its system —
3
+ * over the public internet, or through a route of one of the tenant's VPN
4
+ * connections (specs/credential-network.md). Drawn only for a type that declares
5
+ * which of its fields a route stands in for; every other type has nothing to choose.
6
+ */
7
+ import type { CredentialType, VpnRouteValue } from '../../types/integrations.js';
8
+ type __VLS_Props = {
9
+ type: CredentialType | null;
10
+ modelValue: VpnRouteValue | null;
11
+ idPrefix: string;
12
+ error?: string;
13
+ };
14
+ declare const _default: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
15
+ "update:complete": (args_0: boolean) => any;
16
+ "update:modelValue": (args_0: VpnRouteValue | null) => any;
17
+ }, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
18
+ "onUpdate:complete"?: ((args_0: boolean) => any) | undefined;
19
+ "onUpdate:modelValue"?: ((args_0: VpnRouteValue | null) => any) | undefined;
20
+ }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
21
+ export default _default;
@@ -20,6 +20,7 @@ import { credentialState } from "../../utils/credentialState";
20
20
  import { defaultsFor } from "../../utils/configDefaults";
21
21
  import { applyingFields, settingApplies, staleKeys } from "../../utils/settingCondition";
22
22
  import { coerceSettingValue } from "../../utils/settingValue";
23
+ import { credentialVpnBadge, isVpnRouteValue } from "../../utils/vpnRoute";
23
24
  const props = defineProps({
24
25
  field: { type: Object, required: true },
25
26
  config: { type: Object, required: true },
@@ -110,6 +111,14 @@ const { credentials, loaded: credentialsLoaded, load: loadCredentials, refresh:
110
111
  onMounted(() => {
111
112
  void loadCredentials();
112
113
  });
114
+ const { connections: vpnConnections, loaded: vpnLoaded, load: loadVpn } = useVpnConnections();
115
+ watch(
116
+ () => props.field.type === "credentials-ref" && credsForField(props.field).some((c) => c.network),
117
+ (needed) => {
118
+ if (needed) void loadVpn();
119
+ },
120
+ { immediate: true }
121
+ );
113
122
  function onCredentialPickerOpen(open) {
114
123
  if (open) void refreshCredentials();
115
124
  }
@@ -132,14 +141,15 @@ function credsForField(field) {
132
141
  const bound = computed(() => {
133
142
  const id = value.value;
134
143
  const c = typeof id === "string" && id ? credentials.value.find((x) => x.id === id) : void 0;
135
- return c ? { credential: c, state: credentialState(c) } : null;
144
+ return c ? { credential: c, state: credentialState(c), vpn: credentialVpnBadge(c.network, vpnConnections.value, vpnLoaded.value) } : null;
136
145
  });
137
146
  const credOptions = computed(
138
- () => credsForField(props.field).map((c) => ({ credential: c, state: credentialState(c) }))
147
+ () => credsForField(props.field).map((c) => ({ credential: c, state: credentialState(c), vpn: credentialVpnBadge(c.network, vpnConnections.value, vpnLoaded.value) }))
139
148
  );
140
149
  const UNKNOWN_CREDENTIAL = computed(() => t("integration.nodeConfig.credential.unknown"));
141
150
  const credTriggerLabel = computed(() => {
142
151
  const label = controlName.value;
152
+ if (bound.value?.vpn) return t("integration.nodeConfig.credential.trigger.boundVpn", { label, name: bound.value.credential.name, state: bound.value.state.label, vpn: bound.value.vpn.text });
143
153
  if (bound.value) return t("integration.nodeConfig.credential.trigger.bound", { label, name: bound.value.credential.name, state: bound.value.state.label });
144
154
  if (value.value && credentialsLoaded.value) return t("integration.nodeConfig.credential.trigger.unknown", { label, unknown: UNKNOWN_CREDENTIAL.value });
145
155
  return label;
@@ -394,8 +404,21 @@ function setArrayItemField(index, fieldKey, v) {
394
404
  inside string values). Wins over expressionAllowed — an object value
395
405
  in a text input would render as [object Object]. -->
396
406
  <!-- queries parameter: filter builder -->
407
+ <!-- vpn-route — public internet, or a route of one of the tenant's VPN
408
+ connections, of the kind the node asks for (specs/credential-network.md
409
+ AC-8). Ahead of every other branch: its value is an object, which the JSON
410
+ and expression branches below would otherwise claim. -->
411
+ <IntegrationsVpnRoutePicker
412
+ v-if="field.type === 'vpn-route'"
413
+ :model-value="isVpnRouteValue(value) ? { connection: value.connection, route: value.route } : null"
414
+ :kind="field.routeKind ?? 'http'"
415
+ :id-prefix="`vpn-${field.key}-${rowIndex ?? 0}`"
416
+ :label="controlName"
417
+ @update:model-value="(v) => emit('set', field.key, v)"
418
+ />
419
+
397
420
  <IntegrationsQueryBuilder
398
- v-if="isQueryField"
421
+ v-else-if="isQueryField"
399
422
  :model-value="value"
400
423
  @update:model-value="(v) => write(v)"
401
424
  />
@@ -651,6 +674,13 @@ function setArrayItemField(index, fieldKey, v) {
651
674
  >
652
675
  {{ bound.state.label }}
653
676
  </span>
677
+ <span
678
+ v-if="bound.vpn"
679
+ class="shrink-0 rounded-full border px-1.5 py-px text-[10px] font-medium"
680
+ :class="bound.vpn.tone"
681
+ >
682
+ {{ bound.vpn.text }}
683
+ </span>
654
684
  </span>
655
685
  <!-- A stored id the tenant's credentials no longer contain: the
656
686
  credential was deleted, and the field would otherwise look untouched
@@ -677,6 +707,13 @@ function setArrayItemField(index, fieldKey, v) {
677
707
  :class="o.state.toneClass"
678
708
  :title="o.state.message ?? void 0"
679
709
  >{{ o.state.label }}</span>
710
+ <!-- Reached through a VPN route: the connection, and its state where it
711
+ is not carrying traffic (specs/credential-network.md AC-6). -->
712
+ <span
713
+ v-if="o.vpn"
714
+ class="shrink-0 rounded-full border px-1.5 py-px text-[10px] font-medium"
715
+ :class="o.vpn.tone"
716
+ >{{ o.vpn.text }}</span>
680
717
  </span>
681
718
  </SelectItem>
682
719
  <div v-if="!credOptions.length" class="px-2 py-1.5 text-sm text-muted-foreground">
@@ -699,6 +736,12 @@ function setArrayItemField(index, fieldKey, v) {
699
736
  <p v-else-if="bound?.state.key === 'failed' && bound.state.message" class="text-sm break-words text-rose-600">
700
737
  {{ t("integration.nodeConfig.credential.state.failed", { message: bound.state.message }) }}
701
738
  </p>
739
+ <p v-if="bound?.vpn?.broken && bound.credential.network" class="text-sm break-words text-rose-600">
740
+ {{ t("integration.vpn.reference.missing", { connection: bound.credential.network.connection, route: bound.credential.network.route }) }}
741
+ </p>
742
+ <p v-else-if="bound?.vpn && !bound.vpn.active" class="text-sm text-amber-600">
743
+ {{ t("integration.nodeConfig.credential.vpnInactive") }}
744
+ </p>
702
745
  <IntegrationsCredentialCreateDialog
703
746
  v-model:open="credCreateOpen"
704
747
  :credential-type="field.credentialType"
@@ -39,6 +39,14 @@ type __VLS_Props = {
39
39
  /** The order the platform is reading the list in. Server mode or orderable only. */
40
40
  sort?: RunSort;
41
41
  direction?: RunSortDirection;
42
+ /**
43
+ * The owning list asks the platform to narrow it even though the table is not
44
+ * in server mode — the workflow's own Runs tab, which narrows on the server but
45
+ * keeps its own pager (PO-319). The table then offers the same narrowing control
46
+ * as the tenant-wide list and reports a choice through `narrow`. Server mode
47
+ * implies it.
48
+ */
49
+ narrowable?: boolean;
42
50
  };
43
51
  type __VLS_PublicProps = __VLS_Props & {
44
52
  /** 1-based, as the platform pages. The shared table counts from zero. */
@@ -81,5 +89,6 @@ declare const _default: import("vue").DefineComponent<__VLS_PublicProps, {}, {},
81
89
  showWorkflow: boolean;
82
90
  serverMode: boolean;
83
91
  orderable: boolean;
92
+ narrowable: boolean;
84
93
  }, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
85
94
  export default _default;
@@ -27,13 +27,14 @@ const props = defineProps({
27
27
  triggerType: { type: String, required: false, default: "" },
28
28
  orderable: { type: Boolean, required: false, default: false },
29
29
  sort: { type: null, required: false, default: DEFAULT_RUN_SORT },
30
- direction: { type: String, required: false, default: DEFAULT_RUN_DIRECTION }
30
+ direction: { type: String, required: false, default: DEFAULT_RUN_DIRECTION },
31
+ narrowable: { type: Boolean, required: false, default: false }
31
32
  });
32
33
  const emit = defineEmits(["changed", "narrow", "search", "page-size", "reorder"]);
33
34
  const page = defineModel("page", { type: Number, ...{ default: 1 } });
34
35
  const t = useIntegrationsT();
35
36
  const router = useRouter();
36
- const emptyText = computed(() => props.emptyText ?? (anyNarrowing.value || searchText.value ? t("integration.runs.table.empty.narrowed") : t("integration.runs.table.empty")));
37
+ const emptyText = computed(() => anyNarrowing.value || searchText.value ? t("integration.runs.table.empty.narrowed") : props.emptyText ?? t("integration.runs.table.empty"));
37
38
  const now = useNow();
38
39
  function openRun(r) {
39
40
  void router.push({ path: `/integrations/workflows/${r.workflowId}`, query: { run: r.id } });
@@ -45,7 +46,8 @@ function liveDuration(run) {
45
46
  const end = run.finishedAt ? Date.parse(run.finishedAt) : now.value;
46
47
  return end - start;
47
48
  }
48
- const filterTabs = computed(() => props.pagination && !props.serverMode ? [
49
+ const narrowsOnServer = computed(() => props.serverMode || props.narrowable);
50
+ const filterTabs = computed(() => props.pagination && !narrowsOnServer.value ? [
49
51
  {
50
52
  key: "status",
51
53
  label: t("integration.runs.table.column.status"),
@@ -140,7 +142,7 @@ const columns = computed(() => [
140
142
  :row-key="(r) => r.id"
141
143
  :search="{ placeholder: t('integration.runs.table.search.placeholder') }"
142
144
  :filter-tabs="filterTabs"
143
- :filter="serverMode ? { width: 'w-72', active: anyNarrowing } : false"
145
+ :filter="narrowsOnServer ? { width: 'w-72', active: anyNarrowing } : false"
144
146
  :server-mode="serverMode"
145
147
  :total="total"
146
148
  :loading="loading"
@@ -160,14 +162,15 @@ const columns = computed(() => [
160
162
  apply them together; this one picks one value per dimension and applies it on
161
163
  the spot, because each pick is a request to the platform and staging them
162
164
  would only delay the same round trip. So the shared narrowing helper does not
163
- reach this page and `runs.spec.ts` carries its own — said in both places
164
- rather than left to be discovered (`e2e/support/studio-table.ts`).
165
+ reach either run list, which share one of their own in `e2e/support/runFilter.ts`
166
+ — said in both places rather than left to be discovered
167
+ (`e2e/support/studio-table.ts`).
165
168
 
166
169
  What is still NOT copied is the count beside each option: the built-in counts
167
170
  the rows in hand, and counting a page here would put a number next to
168
171
  "failed" that describes this page rather than the history the option narrows.
169
172
  -->
170
- <template v-if="serverMode" #filter-menu>
173
+ <template v-if="narrowsOnServer" #filter-menu>
171
174
  <!--
172
175
  Two selects rather than the sub-menus this slot used to hold. Since
173
176
  `studio-shared` 0.3.2 the filter trigger opens a popover instead of a
@@ -242,9 +245,12 @@ const columns = computed(() => [
242
245
  </template>
243
246
 
244
247
  <template #cell-trigger="{ row: run }">
245
- <span class="inline-flex items-center whitespace-nowrap rounded-md border border-black/[0.06] bg-muted/50 px-1.5 py-0.5 text-xs font-medium uppercase text-muted-foreground">
248
+ <span v-if="run.triggerType" class="inline-flex items-center whitespace-nowrap rounded-md border border-black/[0.06] bg-muted/50 px-1.5 py-0.5 text-xs font-medium uppercase text-muted-foreground">
246
249
  {{ triggerTypeLabel(run.triggerType) }}
247
250
  </span>
251
+ <!-- What started the run is not known: nothing is said, rather than a guess
252
+ (specs/run-row.md AC-14). -->
253
+ <span v-else class="text-xs text-muted-foreground">—</span>
248
254
  </template>
249
255
 
250
256
  <template #cell-started="{ row: run }">
@@ -39,6 +39,14 @@ type __VLS_Props = {
39
39
  /** The order the platform is reading the list in. Server mode or orderable only. */
40
40
  sort?: RunSort;
41
41
  direction?: RunSortDirection;
42
+ /**
43
+ * The owning list asks the platform to narrow it even though the table is not
44
+ * in server mode — the workflow's own Runs tab, which narrows on the server but
45
+ * keeps its own pager (PO-319). The table then offers the same narrowing control
46
+ * as the tenant-wide list and reports a choice through `narrow`. Server mode
47
+ * implies it.
48
+ */
49
+ narrowable?: boolean;
42
50
  };
43
51
  type __VLS_PublicProps = __VLS_Props & {
44
52
  /** 1-based, as the platform pages. The shared table counts from zero. */
@@ -81,5 +89,6 @@ declare const _default: import("vue").DefineComponent<__VLS_PublicProps, {}, {},
81
89
  showWorkflow: boolean;
82
90
  serverMode: boolean;
83
91
  orderable: boolean;
92
+ narrowable: boolean;
84
93
  }, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
85
94
  export default _default;
@@ -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;