@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
@@ -1,5 +1,5 @@
1
1
  <script setup>
2
- import { computed, nextTick, onMounted, reactive, ref } from "vue";
2
+ import { computed, nextTick, onMounted, reactive, ref, watch } from "vue";
3
3
  import {
4
4
  RefreshCircle as Loader2,
5
5
  AddCircle as Plus,
@@ -35,9 +35,11 @@ import { apiRefusal, errorReason } from "../../utils/apiError";
35
35
  import { CREDENTIAL_STATES, credentialState, credentialStateLabel, providerStepWord } from "../../utils/credentialState";
36
36
  import { hasRevenexxSource } from "../../utils/revenexxCredentialSource";
37
37
  import { resolveLocalized } from "../../utils/localize";
38
+ import { defaultsFor } from "../../utils/configDefaults";
38
39
  import { fromModelValue, toItemValue, toModelValue } from "../../utils/selectOptions";
39
40
  import { keepOpenBeside } from "../../utils/dialog";
40
41
  import { testOutcomeView } from "../../utils/credentialTest";
42
+ import { credentialNetworkPayload, credentialVpnBadge, replacedFieldTexts } from "../../utils/vpnRoute";
41
43
  const t = useIntegrationsT();
42
44
  useHead({ title: () => t("integration.credentials.list.head.title") });
43
45
  const { credentials, loading, error, load, create, update, remove, testInline, test, oauthAuthorizeUrl } = useIntegrationsCredentials();
@@ -45,6 +47,13 @@ const { types, load: loadTypes, bySlug } = useCredentialTypes();
45
47
  onMounted(async () => {
46
48
  await Promise.all([load(), loadTypes()]);
47
49
  });
50
+ const { connections: vpnConnections, loaded: vpnLoaded, load: loadVpn } = useVpnConnections();
51
+ watch(() => credentials.value.some((c) => c.network), (has) => {
52
+ if (has) void loadVpn();
53
+ }, { immediate: true });
54
+ function vpnBadge(c) {
55
+ return credentialVpnBadge(c.network, vpnConnections.value, vpnLoaded.value);
56
+ }
48
57
  function typeFor(slug) {
49
58
  return bySlug(slug);
50
59
  }
@@ -108,12 +117,19 @@ const testing = ref(false);
108
117
  const testResult = ref(null);
109
118
  const saveRefusal = ref(null);
110
119
  const sourceActive = ref(false);
120
+ const network = ref(null);
121
+ const networkComplete = ref(true);
122
+ const replaced = computed(() => replacedFieldTexts(pickedType.value, network.value, vpnConnections.value));
123
+ function networkPayload() {
124
+ return pickedType.value?.network ? credentialNetworkPayload(network.value) : void 0;
125
+ }
111
126
  const isEdit = computed(() => editing.value !== null);
112
127
  function fieldsOf(t2) {
113
128
  return t2?.fields ?? [];
114
129
  }
115
130
  const drawnKeys = computed(() => [
116
131
  "name",
132
+ ...pickedType.value?.network ? ["network"] : [],
117
133
  ...sourceActive.value ? [] : fieldsOf(pickedType.value).map((f) => `config.${f.key}`)
118
134
  ]);
119
135
  const refusal = computed(() => apiRefusal(saveRefusal.value, drawnKeys.value));
@@ -131,20 +147,30 @@ async function focusFirstRefusedField() {
131
147
  await nextTick();
132
148
  const key = drawnKeys.value.find((k) => k in fieldErrors.value);
133
149
  if (!key) return;
134
- const id = key === "name" ? "cred-name" : `cred-field-${key.slice("config.".length)}`;
150
+ const id = key === "name" ? "cred-name" : key === "network" ? "cred-network-public" : `cred-field-${key.slice("config.".length)}`;
135
151
  document.getElementById(id)?.focus();
136
152
  }
137
153
  function clearRefusal() {
138
154
  saveRefusal.value = null;
139
155
  }
156
+ function pickCreateType(picked) {
157
+ pickedType.value = picked;
158
+ form.name = resolveLocalized(picked.name);
159
+ form.config = defaultsFor(fieldsOf(picked));
160
+ network.value = null;
161
+ testResult.value = null;
162
+ sourceActive.value = false;
163
+ clearRefusal();
164
+ }
140
165
  function openCreate(t2) {
141
166
  editing.value = null;
142
167
  pickedType.value = t2 ?? null;
143
168
  form.name = "";
144
- form.config = {};
169
+ form.config = defaultsFor(fieldsOf(pickedType.value));
145
170
  testResult.value = null;
146
171
  clearRefusal();
147
172
  sourceActive.value = false;
173
+ network.value = null;
148
174
  dialogOpen.value = true;
149
175
  }
150
176
  const detailOpen = ref(false);
@@ -180,7 +206,7 @@ async function runTest() {
180
206
  testResult.value = null;
181
207
  clearRefusal();
182
208
  try {
183
- testResult.value = isEdit.value && editing.value ? await test(editing.value.id) : await testInline(pickedType.value.slug, buildConfigPayload());
209
+ testResult.value = isEdit.value && editing.value ? await test(editing.value.id) : await testInline(pickedType.value.slug, buildConfigPayload(), networkPayload());
184
210
  } finally {
185
211
  testing.value = false;
186
212
  }
@@ -191,14 +217,15 @@ async function submit() {
191
217
  clearRefusal();
192
218
  try {
193
219
  if (isEdit.value && editing.value) {
194
- await update(editing.value.id, { name: form.name.trim(), config: buildConfigPayload() });
220
+ await update(editing.value.id, { name: form.name.trim(), config: buildConfigPayload(), network: networkPayload() });
195
221
  dialogOpen.value = false;
196
222
  toast.success(t("integration.credentials.toast.updated"));
197
223
  } else {
198
224
  const created = await create({
199
225
  credential_type_slug: pickedType.value.slug,
200
226
  name: form.name.trim(),
201
- config: buildConfigPayload()
227
+ config: buildConfigPayload(),
228
+ network: networkPayload()
202
229
  });
203
230
  dialogOpen.value = false;
204
231
  toast.success(t("integration.credentials.toast.created"));
@@ -285,7 +312,18 @@ async function confirmDelete() {
285
312
  </template>
286
313
 
287
314
  <template #cell-name="{ row: c }">
288
- <span class="text-sm truncate">{{ c.name }}</span>
315
+ <div class="flex min-w-0 items-center gap-1.5">
316
+ <span class="text-sm truncate">{{ c.name }}</span>
317
+ <!-- Reached through a VPN route: which connection, and — where the
318
+ reference no longer resolves — that it is broken, before a run says
319
+ so (specs/credential-network.md AC-7). -->
320
+ <span
321
+ v-if="vpnBadge(c)"
322
+ class="shrink-0 rounded-full border px-1.5 py-px text-[10px] font-medium"
323
+ :class="vpnBadge(c).tone"
324
+ :aria-label="t('integration.vpn.badge.aria', { name: c.name, badge: vpnBadge(c).text })"
325
+ >{{ vpnBadge(c).text }}</span>
326
+ </div>
289
327
  </template>
290
328
 
291
329
  <template #cell-status="{ row: c }">
@@ -353,11 +391,7 @@ async function confirmDelete() {
353
391
  <IntegrationsCredentialTypePicker
354
392
  v-if="!pickedType"
355
393
  :types="types"
356
- @pick="(picked) => {
357
- pickedType = picked;
358
- form.name = resolveLocalized(picked.name);
359
- clearRefusal();
360
- }"
394
+ @pick="pickCreateType"
361
395
  />
362
396
 
363
397
  <!-- Step 2: dynamic form -->
@@ -390,15 +424,33 @@ async function confirmDelete() {
390
424
  @apply="(c2) => Object.assign(form.config, c2)"
391
425
  />
392
426
 
427
+ <!-- Public internet or a VPN route — for a type that says which of its
428
+ fields a route stands in for (specs/credential-network.md). -->
429
+ <IntegrationsCredentialNetworkSection
430
+ v-model="network"
431
+ :type="pickedType"
432
+ id-prefix="cred-network"
433
+ :error="fieldErrors.network"
434
+ @update:complete="(c2) => networkComplete = c2"
435
+ />
436
+
393
437
  <!-- Each label names its own control (`for`/`id`): without it a screen
394
438
  reader reads the field as unlabelled, and the label text cannot be
395
439
  used to reach it at all. -->
396
440
  <div v-for="f in fieldsOf(pickedType)" v-show="!sourceActive" :key="f.key" class="space-y-1.5">
397
441
  <Label :for="`cred-field-${f.key}`">{{ resolveLocalized(f.label) }}{{ f.required ? " *" : "" }}</Label>
398
442
 
443
+ <!-- stood in for by the chosen VPN route: what it reaches, not editable -->
444
+ <Input
445
+ v-if="replaced[f.key]"
446
+ :id="`cred-field-${f.key}`"
447
+ :model-value="replaced[f.key]"
448
+ readonly
449
+ class="bg-muted/40"
450
+ />
399
451
  <!-- secret -->
400
452
  <Input
401
- v-if="f.type === 'secret'"
453
+ v-else-if="f.type === 'secret'"
402
454
  :id="`cred-field-${f.key}`"
403
455
  :model-value="form.config[f.key] ?? ''"
404
456
  type="password"
@@ -492,12 +544,12 @@ async function confirmDelete() {
492
544
  </Button>
493
545
  <span v-else />
494
546
  <div class="flex items-center gap-2">
495
- <Button variant="outline" :disabled="saving || testing" @click="runTest">
547
+ <Button variant="outline" :disabled="saving || testing || !networkComplete" @click="runTest">
496
548
  <Loader2 v-if="testing" :size="14" class="mr-1 animate-spin" />
497
549
  <TestIcon v-else :size="14" class="mr-1" />
498
550
  {{ t("integration.credentials.shared.action.testConnection") }}
499
551
  </Button>
500
- <Button :disabled="saving || !form.name.trim()" @click="submit">
552
+ <Button :disabled="saving || !form.name.trim() || !networkComplete" @click="submit">
501
553
  <Loader2 v-if="saving" :size="14" class="mr-1 animate-spin" />
502
554
  {{ t("integration.common.action.save") }}
503
555
  </Button>
@@ -209,6 +209,8 @@ watch(id, async () => {
209
209
  if (!w) await leaveTo("/integrations/workflows");
210
210
  draft.value = null;
211
211
  warningsExpanded.value = false;
212
+ runsStatus.value = [];
213
+ runsTrigger.value = "";
212
214
  if (tab.value === "runs") void loadRuns(1);
213
215
  settleBuildStatus();
214
216
  }, { immediate: true });
@@ -530,6 +532,8 @@ const runsLastPage = ref(1);
530
532
  const runsWanted = ref(1);
531
533
  const runsSort = ref(DEFAULT_RUN_SORT);
532
534
  const runsDirection = ref(DEFAULT_RUN_DIRECTION);
535
+ const runsStatus = ref([]);
536
+ const runsTrigger = ref("");
533
537
  let runsSeq = 0;
534
538
  let runsLoadingSeq = 0;
535
539
  const RUNS_POLL_MS = 3e3;
@@ -634,7 +638,8 @@ async function loadRuns(page = runsWanted.value, opts = {}) {
634
638
  page,
635
639
  perPage: RUNS_PER_PAGE,
636
640
  sort: runsSort.value,
637
- direction: runsDirection.value
641
+ direction: runsDirection.value,
642
+ narrowing: { status: runsStatus.value, triggerType: runsTrigger.value }
638
643
  });
639
644
  if (seq !== runsSeq) return;
640
645
  wfRuns.value = res.runs;
@@ -655,6 +660,11 @@ function reorderRuns(order) {
655
660
  runsDirection.value = order.direction;
656
661
  void loadRuns(1);
657
662
  }
663
+ function narrowRuns(patch) {
664
+ if (patch.status !== void 0) runsStatus.value = patch.status;
665
+ if (patch.triggerType !== void 0) runsTrigger.value = patch.triggerType;
666
+ void loadRuns(1);
667
+ }
658
668
  function goRunsPage(p) {
659
669
  const next = Math.min(Math.max(1, p), runsLastPage.value);
660
670
  if (next !== runsWanted.value) void loadRuns(next);
@@ -1179,9 +1189,13 @@ async function confirmDeactivate() {
1179
1189
  orderable
1180
1190
  :sort="runsSort"
1181
1191
  :direction="runsDirection"
1192
+ narrowable
1193
+ :status="runsStatus"
1194
+ :trigger-type="runsTrigger"
1182
1195
  :empty-text="t('integration.editor.runs.empty')"
1183
1196
  @changed="() => loadRuns()"
1184
1197
  @reorder="reorderRuns"
1198
+ @narrow="narrowRuns"
1185
1199
  />
1186
1200
 
1187
1201
  <!-- Server pagination — the endpoint pages, so the whole history is reachable. -->
@@ -282,7 +282,10 @@ export interface NodeManifestOutput {
282
282
  }
283
283
  export type ConfigFieldType = 'string' | 'number' | 'boolean' | 'select' | 'multiselect' | 'object' | 'array' | 'expression' | 'secret-ref' | 'credentials-ref'
284
284
  /** Names one of the workflow's declared state namespaces (PO-374). */
285
- | 'state-ref' | 'dynamic-schema';
285
+ | 'state-ref'
286
+ /** A route of one of the tenant's VPN connections, or the public internet
287
+ * (PO-831). Stored as `VpnRouteValue | null`. */
288
+ | 'vpn-route' | 'dynamic-schema';
286
289
  export interface ConfigFieldOption {
287
290
  value: string | number | boolean;
288
291
  label: LocalizedString;
@@ -335,6 +338,11 @@ export interface ConfigField {
335
338
  * the stored value is the namespace NAME.
336
339
  */
337
340
  stateRole?: WorkflowStateRole;
341
+ /**
342
+ * Only for `type: 'vpn-route'` (PO-831): which kind of route the picker offers.
343
+ * `http` where the node declares nothing, as the SDK defaults it.
344
+ */
345
+ routeKind?: VpnRouteKind;
338
346
  /** keys of other config fields this field's options/schema depend on. */
339
347
  dependsOn?: string[];
340
348
  /** When this setting applies at all (PO-410): another setting's key, an
@@ -471,6 +479,8 @@ export interface CredentialTypeField {
471
479
  type: 'string' | 'number' | 'boolean' | 'select' | 'secret';
472
480
  label: LocalizedString;
473
481
  required?: boolean;
482
+ /** What the system uses when the operator changes nothing; a new credential's form starts from it (IN-38). */
483
+ default?: string | number | boolean;
474
484
  options?: Array<{
475
485
  label: LocalizedString;
476
486
  value: string | number | boolean;
@@ -503,6 +513,27 @@ export interface CredentialType {
503
513
  images?: CredentialTypeImage[];
504
514
  auth_kind: AuthKind;
505
515
  fields: CredentialTypeField[];
516
+ /**
517
+ * Which of the type's fields a VPN route stands in for (PO-829, the SDK's
518
+ * `ICredentialDescription.network`). Absent: the type cannot be routed through a
519
+ * VPN, and the credential forms draw no Network section for it.
520
+ */
521
+ network?: CredentialNetworkDeclaration | null;
522
+ }
523
+ /** What a credential type declares a VPN route replaces. */
524
+ export type CredentialNetworkDeclaration = {
525
+ kind: 'tcp';
526
+ hostField: string;
527
+ portField: string;
528
+ } | {
529
+ kind: 'http';
530
+ urlField: string;
531
+ };
532
+ /** How a stored credential reaches its system, when not over the public internet. */
533
+ export interface CredentialNetwork {
534
+ kind: 'vpn';
535
+ connection: string;
536
+ route: string;
506
537
  }
507
538
  export type CredentialStatus = 'active' | 'needs_reauth';
508
539
  /** A tenant's credential instance (secret fields never returned). */
@@ -517,9 +548,35 @@ export interface Credential {
517
548
  last_test_at?: string | null;
518
549
  last_test_ok?: boolean | null;
519
550
  last_test_message?: string | null;
551
+ /** The VPN route this credential is reached through; `null` or absent = public internet. */
552
+ network?: CredentialNetwork | null;
520
553
  created_at: string;
521
554
  updated_at: string;
522
555
  }
556
+ export type VpnRouteKind = 'http' | 'tcp';
557
+ /** Console's derived status of a connection; only `active` carries traffic. */
558
+ export type VpnConnectionStatus = 'provisioning' | 'failed' | 'active' | 'down' | 'standby' | 'disabled' | 'unknown';
559
+ export interface VpnRoute {
560
+ name: string;
561
+ kind: VpnRouteKind;
562
+ /** The target inside the tenant's network. */
563
+ host: string;
564
+ port: number;
565
+ proto?: string | null;
566
+ /** Where the gateway serves the route (http: a URL, tcp: `ip:port`). */
567
+ endpoint?: string | null;
568
+ }
569
+ export interface VpnConnection {
570
+ name: string;
571
+ display_name?: string | null;
572
+ status: VpnConnectionStatus | string;
573
+ routes: VpnRoute[];
574
+ }
575
+ /** What a `vpn-route` setting stores, and what a credential's `network` names. */
576
+ export interface VpnRouteValue {
577
+ connection: string;
578
+ route: string;
579
+ }
523
580
  export type ConnectionStatus = 'connected' | 'misconfigured' | 'unverified' | 'failing';
524
581
  export interface ConnectionTemplate {
525
582
  /** kebab-case slug (e.g. 'shopify'). */
@@ -765,7 +822,12 @@ export interface WorkflowRun {
765
822
  /** Temporal run id for this execution; null when the row predates Temporal. */
766
823
  temporalRunId?: string | null;
767
824
  triggerId: string;
768
- triggerType: TriggerType;
825
+ /**
826
+ * What started the run, as the platform reported it. Null where it reported
827
+ * nothing — the trigger was deleted, or the run predates it being recorded — and
828
+ * drawn as nothing rather than as a guess (PO-335).
829
+ */
830
+ triggerType: TriggerType | null;
769
831
  startedAt: string;
770
832
  finishedAt: string | null;
771
833
  durationMs: number | null;
@@ -9,8 +9,11 @@ import type { ConfigField } from '../types/integrations.js';
9
9
  * applied never gets to apply it. An absent key is the absence the node's
10
10
  * default answers to.
11
11
  *
12
- * Used in the two places a fresh set of values is made: a node dropped on the
13
- * canvas (WorkflowEditor), and a row added to a list setting (NodeConfigField).
12
+ * Used wherever a fresh set of values is made: a node dropped on the canvas
13
+ * (WorkflowEditor), a row added to a list setting (NodeConfigField), and a
14
+ * credential's form — a new one in both create dialogs, and a stored one's
15
+ * missing fields in the detail view (IN-38), which also compares an edit against
16
+ * it. A change here changes all of them.
14
17
  * Flat on purpose — it reads `default` at the level it is given and does not
15
18
  * descend into `properties` or `items`, so what a newly inserted node carries
16
19
  * is exactly what it carried before this became shared.
@@ -122,13 +122,16 @@ export interface RunFeedQuery {
122
122
  * empty `status` as a value and answers 422.
123
123
  */
124
124
  export declare function runFeedQueryString(query: RunFeedQuery): string;
125
+ /** The narrowing a workflow's own list takes: the feed's, minus the text search. */
126
+ export type WorkflowRunsNarrowing = Pick<RunFeedQuery, 'status' | 'triggerType'>;
125
127
  /**
126
- * The query string for a workflow's own `GET /workflows/{id}/runs`: a page, and
127
- * the order it is read in. That endpoint narrows by nothing yet (PO-319), so this
128
- * is the paging and the order alone — the order sent by the same rule as the
129
- * tenant feed's, so the two lists can never ask for one order two ways.
128
+ * The query string for a workflow's own `GET /workflows/{id}/runs`: a page, the
129
+ * order it is read in, and the narrowing by outcome and trigger kind (PO-319) —
130
+ * each sent by the same rule as the tenant feed's, so the two lists can never ask
131
+ * for one thing two ways. No text search: that endpoint takes none, and the tab's
132
+ * search box searches the page in hand.
130
133
  */
131
- export declare function workflowRunsQueryString(page: number, perPage: number, sort?: RunSort, direction?: RunSortDirection): string;
134
+ export declare function workflowRunsQueryString(page: number, perPage: number, sort?: RunSort, direction?: RunSortDirection, narrowing?: WorkflowRunsNarrowing): string;
132
135
  /**
133
136
  * The narrowing alone, without the paging — what `GET /runs/summary` is read
134
137
  * with so the pager's total describes the same rows as the page beside it.
@@ -29,10 +29,11 @@ export function runFeedQueryString(query) {
29
29
  setOrder(params, query.sort, query.direction);
30
30
  return params.toString();
31
31
  }
32
- export function workflowRunsQueryString(page, perPage, sort = DEFAULT_RUN_SORT, direction = DEFAULT_RUN_DIRECTION) {
32
+ export function workflowRunsQueryString(page, perPage, sort = DEFAULT_RUN_SORT, direction = DEFAULT_RUN_DIRECTION, narrowing = { status: [], triggerType: "" }) {
33
33
  const params = new URLSearchParams();
34
34
  params.set("page", String(page));
35
35
  params.set("per_page", String(perPage));
36
+ for (const [key, value] of outcomeParams(narrowing)) params.set(key, value);
36
37
  setOrder(params, sort, direction);
37
38
  return params.toString();
38
39
  }
@@ -45,11 +46,15 @@ function setOrder(params, sort, direction) {
45
46
  export function runFeedNarrowingString(query) {
46
47
  return narrowingParams(query).toString();
47
48
  }
48
- function narrowingParams(query) {
49
+ function outcomeParams(narrowing) {
49
50
  const params = new URLSearchParams();
50
- const statuses = apiStatusesFor(query.status);
51
+ const statuses = apiStatusesFor(narrowing.status);
51
52
  if (statuses.length) params.set("status", statuses.join(","));
52
- if (query.triggerType) params.set("trigger_type", query.triggerType);
53
+ if (narrowing.triggerType) params.set("trigger_type", narrowing.triggerType);
54
+ return params;
55
+ }
56
+ function narrowingParams(query) {
57
+ const params = outcomeParams(query);
53
58
  const search = query.search.trim();
54
59
  if (search) params.set("search", search);
55
60
  return params;
@@ -0,0 +1,39 @@
1
+ import type { TriggerType, WorkflowRun } from '../types/integrations.js';
2
+ /**
3
+ * A run as either list sends it, and as the by-id read does. The three do not
4
+ * carry the same fields: see `mapRun` and `keepListTrigger` for what fills in.
5
+ */
6
+ export interface ApiRun {
7
+ id: number;
8
+ workflow_id: number;
9
+ status: string;
10
+ temporal_workflow_id?: string;
11
+ temporal_run_id?: string;
12
+ started_at?: string;
13
+ completed_at?: string;
14
+ /** Why the run failed, as the platform recorded it. Present in the list too. */
15
+ error?: string | null;
16
+ /**
17
+ * Present on the tenant feed, absent on the per-workflow subresource — which
18
+ * has no need for them, since the caller supplied the workflow and the tab
19
+ * shows one workflow's runs.
20
+ */
21
+ workflow_name?: string;
22
+ trigger_type?: TriggerType | null;
23
+ trigger_handle?: string | null;
24
+ }
25
+ /**
26
+ * `workflowName` is a FALLBACK, not the source. The tenant feed carries the
27
+ * name on the row; the per-workflow subresource does not, and its caller knows
28
+ * it. Passing it used to be the only way a row had a name at all.
29
+ */
30
+ export declare function mapRun(r: ApiRun, workflowName?: string): WorkflowRun;
31
+ /**
32
+ * A run re-read by id, keeping what started it from the row it replaces.
33
+ *
34
+ * The by-id read sends no trigger — only the two lists do — so swapping a
35
+ * reconciled run in whole blanked a schedule's run to no trigger at all, a row
36
+ * after the list had named it (api-contract runs.execution F2). What started a
37
+ * run does not change while it runs, so the list's word stays the right one.
38
+ */
39
+ export declare function keepListTrigger(fresh: WorkflowRun, listed: WorkflowRun): WorkflowRun;
@@ -0,0 +1,39 @@
1
+ import { mapRunStatus } from "./runFeedQuery.js";
2
+ export function mapRun(r, workflowName = "") {
3
+ const startedMs = r.started_at ? new Date(r.started_at).getTime() : null;
4
+ const finishedMs = r.completed_at ? new Date(r.completed_at).getTime() : null;
5
+ return {
6
+ id: String(r.id),
7
+ workflowId: r.workflow_id,
8
+ workflowName: r.workflow_name ?? workflowName,
9
+ status: mapRunStatus(r.status),
10
+ // Kept beside the mapped word, because the mapping folds `terminated` into
11
+ // `failed` and the platform resumes only the literal `failed` (PO-355).
12
+ apiStatus: r.status,
13
+ temporalRunId: r.temporal_run_id ?? null,
14
+ triggerId: r.trigger_handle ?? "",
15
+ // The platform reports what started the run, on both lists. Null — the
16
+ // trigger was deleted, or the run predates trigger_handle — stays null: it
17
+ // was read as `manual` once, and every row of a workflow's own Runs tab then
18
+ // claimed a start by hand, because that list sent no trigger at all (PO-335).
19
+ // A by-id read still sends none; see `keepListTrigger`.
20
+ triggerType: r.trigger_type ?? null,
21
+ startedAt: r.started_at ?? "",
22
+ finishedAt: r.completed_at ?? null,
23
+ // The list carries the reason a run failed; dropping it here was why every
24
+ // failure read the same wherever the list is shown. An empty string is not
25
+ // a reason — it has to fall through to whatever the surface says instead,
26
+ // or it renders as a blank line where the sentence belongs.
27
+ errorMessage: r.error || void 0,
28
+ // Derived from the timestamps — the list payload carries no duration.
29
+ durationMs: startedMs != null && finishedMs != null && finishedMs >= startedMs ? finishedMs - startedMs : null,
30
+ steps: []
31
+ };
32
+ }
33
+ export function keepListTrigger(fresh, listed) {
34
+ return {
35
+ ...fresh,
36
+ triggerId: fresh.triggerId || listed.triggerId,
37
+ triggerType: fresh.triggerType ?? listed.triggerType
38
+ };
39
+ }
@@ -0,0 +1,69 @@
1
+ import type { CredentialNetwork, CredentialNetworkDeclaration, CredentialType, VpnConnection, VpnRoute, VpnRouteKind, VpnRouteValue } from '../types/integrations.js';
2
+ /**
3
+ * What the studio makes of a VPN route reference — a credential's `network`, or the
4
+ * value of a `vpn-route` setting — against the tenant's connections
5
+ * (specs/credential-network.md). Pure, so the surfaces that draw it agree.
6
+ */
7
+ /** The name a connection is shown under: its display name, else its own. */
8
+ export declare function connectionLabel(c: Pick<VpnConnection, 'name' | 'display_name'>): string;
9
+ /** The routes of a connection a picker filtered to `kind` offers. */
10
+ export declare function routesOfKind(c: VpnConnection | undefined, kind: VpnRouteKind): VpnRoute[];
11
+ /** Whether a stored value names a route at all, as opposed to the public internet. */
12
+ export declare function isVpnRouteValue(v: unknown): v is VpnRouteValue;
13
+ export type VpnReference =
14
+ /** The route exists; `active` says whether its connection carries traffic now. */
15
+ {
16
+ state: 'ok';
17
+ connection: VpnConnection;
18
+ route: VpnRoute;
19
+ active: boolean;
20
+ }
21
+ /** The connection or the route is gone — a broken reference. */
22
+ | {
23
+ state: 'missing';
24
+ connection?: VpnConnection;
25
+ };
26
+ /**
27
+ * Resolve a reference against the tenant's connections. A connection that is still
28
+ * there but no longer has the route counts as missing, as the backend refuses it
29
+ * with `VPN_ROUTE_MISSING` either way.
30
+ */
31
+ export declare function resolveVpnReference(connections: readonly VpnConnection[], ref: VpnRouteValue): VpnReference;
32
+ /** The keys of a credential type's fields a VPN route stands in for. */
33
+ export declare function replacedFieldKeys(decl: CredentialNetworkDeclaration | null | undefined): string[];
34
+ /**
35
+ * What a replaced field reads while a route stands in for it: `host:port via
36
+ * <connection>` for a tcp route, the gateway's endpoint for an http one — the
37
+ * address the request is actually sent to.
38
+ */
39
+ export declare function routeTargetText(route: VpnRoute, connection: VpnConnection): string;
40
+ /**
41
+ * The read-only text of every field a chosen route replaces, keyed by field. Empty
42
+ * while the credential is reached over the public internet, or while the route
43
+ * cannot be resolved — then the fields keep their stored values on screen.
44
+ */
45
+ export declare function replacedFieldTexts(type: Pick<CredentialType, 'network'> | null | undefined, value: VpnRouteValue | null, connections: readonly VpnConnection[]): Record<string, string>;
46
+ /** Whether the gateway reaches the target over TLS it does not verify. */
47
+ export declare function isHttpsTarget(route: VpnRoute | undefined): boolean;
48
+ /** The credential's `network` as the service takes it, from what the form holds. */
49
+ export declare function credentialNetworkPayload(value: VpnRouteValue | null): CredentialNetwork | null;
50
+ /** The picker's value from what a credential stores. */
51
+ export declare function fromCredentialNetwork(n: CredentialNetwork | null | undefined): VpnRouteValue | null;
52
+ /** Whether two values name the same route (both public counts as the same). */
53
+ export declare function sameVpnRoute(a: VpnRouteValue | null, b: VpnRouteValue | null): boolean;
54
+ /** A connection's status in the reader's words; an unforeseen one reads as unknown. */
55
+ export declare function connectionStatusLabel(status: string): string;
56
+ /** Badge classes for a connection status: green only for one that carries traffic. */
57
+ export declare function connectionStatusTone(status: string): string;
58
+ /**
59
+ * The VPN badge a credential carries where it is picked or listed, or `null` for one
60
+ * reached over the public internet. `connections` loaded or not decides whether a
61
+ * reference nothing resolves is called broken: before the list arrives it is only
62
+ * named.
63
+ */
64
+ export declare function credentialVpnBadge(network: CredentialNetwork | null | undefined, connections: readonly VpnConnection[], loaded: boolean): {
65
+ text: string;
66
+ broken: boolean;
67
+ active: boolean;
68
+ tone: string;
69
+ } | null;
@@ -0,0 +1,79 @@
1
+ import { useIntegrationsT } from "../composables/useIntegrationsT.js";
2
+ export function connectionLabel(c) {
3
+ return c.display_name?.trim() || c.name;
4
+ }
5
+ export function routesOfKind(c, kind) {
6
+ return (c?.routes ?? []).filter((r) => r.kind === kind);
7
+ }
8
+ export function isVpnRouteValue(v) {
9
+ return !!v && typeof v === "object" && typeof v.connection === "string" && v.connection !== "" && typeof v.route === "string" && v.route !== "";
10
+ }
11
+ export function resolveVpnReference(connections, ref) {
12
+ const connection = connections.find((c) => c.name === ref.connection);
13
+ const route = connection?.routes.find((r) => r.name === ref.route);
14
+ if (!connection || !route) return { state: "missing", connection };
15
+ return { state: "ok", connection, route, active: connection.status === "active" };
16
+ }
17
+ export function replacedFieldKeys(decl) {
18
+ if (!decl) return [];
19
+ return decl.kind === "tcp" ? [decl.hostField, decl.portField] : [decl.urlField];
20
+ }
21
+ export function routeTargetText(route, connection) {
22
+ const t = useIntegrationsT();
23
+ const target = route.kind === "http" && route.endpoint ? route.endpoint : `${route.host}:${route.port}`;
24
+ return t("integration.vpn.target", { target, connection: connectionLabel(connection) });
25
+ }
26
+ export function replacedFieldTexts(type, value, connections) {
27
+ const keys = replacedFieldKeys(type?.network);
28
+ if (!keys.length || !value) return {};
29
+ const ref = resolveVpnReference(connections, value);
30
+ if (ref.state !== "ok") return {};
31
+ const text = routeTargetText(ref.route, ref.connection);
32
+ return Object.fromEntries(keys.map((k) => [k, text]));
33
+ }
34
+ export function isHttpsTarget(route) {
35
+ return (route?.proto ?? "").toLowerCase() === "https";
36
+ }
37
+ export function credentialNetworkPayload(value) {
38
+ return value ? { kind: "vpn", connection: value.connection, route: value.route } : null;
39
+ }
40
+ export function fromCredentialNetwork(n) {
41
+ return n && n.kind === "vpn" && n.connection && n.route ? { connection: n.connection, route: n.route } : null;
42
+ }
43
+ export function sameVpnRoute(a, b) {
44
+ if (!a || !b) return !a && !b;
45
+ return a.connection === b.connection && a.route === b.route;
46
+ }
47
+ const STATUS_KEYS = ["provisioning", "failed", "active", "down", "standby", "disabled", "unknown"];
48
+ export function connectionStatusLabel(status) {
49
+ const key = STATUS_KEYS.includes(status) ? status : "unknown";
50
+ return useIntegrationsT()(`integration.vpn.status.${key}`);
51
+ }
52
+ export function connectionStatusTone(status) {
53
+ if (status === "active") return "bg-emerald-500/10 border-emerald-500/20 text-emerald-700";
54
+ if (status === "failed" || status === "down") return "bg-rose-500/10 border-rose-500/20 text-rose-700";
55
+ if (status === "provisioning" || status === "standby") return "bg-amber-500/10 border-amber-500/20 text-amber-700";
56
+ return "bg-slate-500/10 border-slate-500/20 text-slate-600";
57
+ }
58
+ export function credentialVpnBadge(network, connections, loaded) {
59
+ const value = fromCredentialNetwork(network);
60
+ if (!value) return null;
61
+ const t = useIntegrationsT();
62
+ if (!loaded) {
63
+ return { text: t("integration.vpn.badge.named", { connection: value.connection }), broken: false, active: true, tone: connectionStatusTone("unknown") };
64
+ }
65
+ const ref = resolveVpnReference(connections, value);
66
+ if (ref.state === "missing") {
67
+ return { text: t("integration.vpn.badge.missing"), broken: true, active: false, tone: connectionStatusTone("failed") };
68
+ }
69
+ const name = connectionLabel(ref.connection);
70
+ if (ref.active) {
71
+ return { text: t("integration.vpn.badge.named", { connection: name }), broken: false, active: true, tone: connectionStatusTone("active") };
72
+ }
73
+ return {
74
+ text: t("integration.vpn.badge.withState", { connection: name, status: connectionStatusLabel(String(ref.connection.status)) }),
75
+ broken: false,
76
+ active: false,
77
+ tone: connectionStatusTone(String(ref.connection.status))
78
+ };
79
+ }