@revenexx/studio-integrations 3.7.1 → 3.8.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 (36) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/module.json +1 -1
  3. package/dist/runtime/components/Integrations/CredentialCreateDialog.vue +3 -1
  4. package/dist/runtime/components/Integrations/DeadLettersPanel.vue +2 -1
  5. package/dist/runtime/components/Integrations/NodeConfigDynamicField.vue +37 -17
  6. package/dist/runtime/components/Integrations/NodeConfigDynamicSchema.d.vue.ts +2 -0
  7. package/dist/runtime/components/Integrations/NodeConfigDynamicSchema.vue +5 -2
  8. package/dist/runtime/components/Integrations/NodeConfigDynamicSchema.vue.d.ts +2 -0
  9. package/dist/runtime/components/Integrations/NodeConfigField.vue +1 -0
  10. package/dist/runtime/components/Integrations/RunResponseModal.d.vue.ts +6 -0
  11. package/dist/runtime/components/Integrations/RunResponseModal.vue +36 -5
  12. package/dist/runtime/components/Integrations/RunResponseModal.vue.d.ts +6 -0
  13. package/dist/runtime/components/Integrations/Sidebar.vue +22 -5
  14. package/dist/runtime/components/Integrations/TemplateFlowPreview.vue +3 -3
  15. package/dist/runtime/components/Integrations/WorkflowEditor.vue +32 -27
  16. package/dist/runtime/components/Integrations/WorkflowTriggerView.vue +1 -1
  17. package/dist/runtime/components/Integrations/WorkflowsTable.vue +9 -14
  18. package/dist/runtime/composables/useIntegrationsSummary.d.ts +29 -0
  19. package/dist/runtime/composables/useIntegrationsSummary.js +37 -0
  20. package/dist/runtime/composables/useNodeConfigResolve.d.ts +25 -2
  21. package/dist/runtime/composables/useNodeConfigResolve.js +11 -2
  22. package/dist/runtime/composables/useOAuthConnect.d.ts +5 -0
  23. package/dist/runtime/composables/useOAuthConnect.js +3 -1
  24. package/dist/runtime/i18n/messages/de.json +4 -3
  25. package/dist/runtime/i18n/messages/en.json +4 -3
  26. package/dist/runtime/pages/integrations/runs.vue +2 -1
  27. package/dist/runtime/pages/integrations/workflows/[id].vue +39 -2
  28. package/dist/runtime/utils/canvasFit.d.ts +59 -0
  29. package/dist/runtime/utils/canvasFit.js +78 -0
  30. package/dist/runtime/utils/executionMode.d.ts +18 -0
  31. package/dist/runtime/utils/executionMode.js +17 -0
  32. package/dist/runtime/utils/optionSearch.d.ts +24 -0
  33. package/dist/runtime/utils/optionSearch.js +31 -0
  34. package/dist/runtime/utils/runFailure.d.ts +37 -0
  35. package/dist/runtime/utils/runFailure.js +16 -0
  36. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # @revenexx/studio-integrations
2
2
 
3
+ ## 3.8.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 0933441: A trigger kind is named in the reader's language wherever the studio shows one — "Zeitplan", "Manuell" in German, "Schedule", "Manual" in English. The workflows list, the trigger card on the canvas, the template miniature and the origin of a missed run used to print the platform's own word ("schedule", "Trigger · manual") in every language. The workflows list's Mode column and its filter now use the same word as the workflow's header: "Async", "Sync" and "Caller decides" in English, "Asynchron", "Synchron" and "Aufrufer entscheidet" in German, where the list used to show "async", "sync" and "caller" (in German "Aufrufer"). The filter's option names change with it, which is why this is a minor. The list sorts both columns by the word it shows. The three `integration.workflows.table.mode.*` message keys are gone; the list reads `integration.editor.executionMode.*`.
8
+
9
+ ### Patch Changes
10
+
11
+ - 78b99ec: A setting whose options the platform resolves is no longer held back by a setting it depends on that the node does not require. That setting is sent with the question when it is filled in, and left out when it is empty — so SOAP Request's operation list reads a WSDL behind a login with the chosen credential, and a public WSDL still without one. A setting the node requires still holds the list back and says so.
12
+ - b3001f3: When the browser refuses to open the window for connecting a credential at the provider — a pop-up blocker, a hardened profile — the studio now says so and says what to do: "The browser blocked the window for signing in at the provider. Allow pop-ups for this site, then try again." Before, Connect and Reconnect did nothing visible in the credentials list, a credential's detail view, and the New-credential dialog straight after saving. The New-credential dialog now also reports an authorisation address the service could not hand back, which it used to drop silently. New message key `integration.credentials.error.popupBlocked`.
13
+ - a3bbf46: The badges beside Workflows, Runs, Schedules, Credentials and Secrets in the navigation now show the tenant's counts — the same figure on every page — instead of the length of whatever the last page loaded. They are read from the Integrations service's new `GET /v1/summary`, which a host's own sidebar can read too through `useIntegrationsSummary()`.
14
+ - 8e21845: Typing into a setting whose options come from the platform now narrows its list or says that nothing matches, instead of showing every option again, and puts the option whose code or name starts with what was typed first. While the answer to a typed term is awaited the options stay where they are and cannot be chosen, and the field shows that it is asking, so a click no longer lands on the option below the one aimed at.
15
+ - 9e773ec: A workflow opens, and *Fit* and *Tidy up* fit it, into the part of the canvas that *Add node*, the Map and the control pane leave free, wherever each is docked or floated, so no step lands under a panel where it cannot be clicked; a panel collapsed to its pill takes no room (PO-660). The view a workflow opens on used to avoid only the control pane.
16
+ - 5861e33: A run started from the editor that fails now says so in the run dialog: instead of a green "Run started" confirmation and "The run returned nothing", it shows that the run failed, the step it failed at (named as on the canvas) and the cause, with "View run" for the details. This holds for both the waiting and the immediate mode.
17
+
3
18
  ## 3.7.1
4
19
 
5
20
  ### Patch 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.7.1",
4
+ "version": "3.8.0",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.3",
7
7
  "unbuild": "unknown"
@@ -137,7 +137,9 @@ async function submit() {
137
137
  await load(true);
138
138
  toast.success(t("integration.credentials.toast.created"));
139
139
  if (isOAuthAuthcode(pickedType.value)) {
140
- void connect(created.id);
140
+ connect(created.id).catch((e) => {
141
+ toast.error(errorReason(e, t("integration.credentials.error.oauthConnect")));
142
+ });
141
143
  }
142
144
  emit("created", created.id);
143
145
  emit("update:open", false);
@@ -7,6 +7,7 @@ import LoadingAnnouncement from "./LoadingAnnouncement.vue";
7
7
  import { deadLetterStatusClass, deadLetterStatusLabel } from "../../utils/statusStyle";
8
8
  import { errorReason } from "../../utils/apiError";
9
9
  import { formatDateTime } from "../../utils/formatDateTime";
10
+ import { triggerTypeLabel } from "../../data/integrationNodeCatalog";
10
11
  const props = defineProps({
11
12
  workflowId: { type: Number, required: true }
12
13
  });
@@ -122,7 +123,7 @@ async function onDiscard(dl) {
122
123
  <div class="text-sm break-words">{{ dl.reason || t("integration.deadLetters.reason.fallback") }}</div>
123
124
  <div class="mt-0.5 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs text-muted-foreground font-mono">
124
125
  <span>#{{ dl.id }}</span>
125
- <span v-if="dl.source">{{ dl.source }}</span>
126
+ <span v-if="dl.source">{{ triggerTypeLabel(dl.source) }}</span>
126
127
  <span v-if="dl.trigger_handle">↳ {{ dl.trigger_handle }}</span>
127
128
  <span>{{ formatDateTime(dl.created_at) }}</span>
128
129
  </div>
@@ -1,9 +1,11 @@
1
1
  <script setup>
2
2
  import { computed, nextTick, ref, watch } from "vue";
3
3
  import { isVersionExecutable } from "../../data/integrationNodeCatalog";
4
- import { ChevronDown } from "lucide-vue-next";
4
+ import { ChevronDown, LoaderCircle } from "lucide-vue-next";
5
5
  import { Input } from "@revenexx/studio";
6
+ import { optionalDependencies } from "../../composables/useNodeConfigResolve";
6
7
  import { resolveLocalized } from "../../utils/localize";
8
+ import { narrowOptions } from "../../utils/optionSearch";
7
9
  const props = defineProps({
8
10
  slug: { type: String, required: true },
9
11
  version: { type: String, required: true },
@@ -23,6 +25,7 @@ const q = useNodeConfigResolve({
23
25
  target: () => props.field.key,
24
26
  config: () => props.config,
25
27
  dependsOn: () => props.field.dependsOn,
28
+ optional: () => optionalDependencies(props.field.dependsOn, props.siblingFields),
26
29
  search: () => focused.value ? search.value : ""
27
30
  });
28
31
  const unsatisfiedLabels = computed(() => q.unsatisfied.value.map((key) => {
@@ -72,13 +75,10 @@ function onComboClick() {
72
75
  listDismissed.value = false;
73
76
  }
74
77
  const visibleOptions = computed(() => {
75
- const term = search.value.trim().toLowerCase();
76
- if (!focused.value || !term) return options.value;
77
- const hits = options.value.filter(
78
- (o) => (resolveLocalized(o.label) || "").toLowerCase().includes(term) || String(o.value).toLowerCase().includes(term)
79
- );
80
- return hits.length ? hits : options.value;
78
+ if (!focused.value) return options.value;
79
+ return narrowOptions(options.value, search.value, (o) => resolveLocalized(o.label) || "", (o) => String(o.value));
81
80
  });
81
+ const answerPending = computed(() => listOpen.value && q.isPending.value);
82
82
  function onComboFocus() {
83
83
  search.value = "";
84
84
  listDismissed.value = false;
@@ -88,6 +88,7 @@ function onComboBlur() {
88
88
  focused.value = false;
89
89
  }
90
90
  function pick(value) {
91
+ if (answerPending.value) return;
91
92
  emit("update:modelValue", value);
92
93
  search.value = "";
93
94
  focused.value = false;
@@ -126,8 +127,10 @@ function toggleMulti(optValue) {
126
127
  <p class="mt-1 text-sm text-amber-600">{{ t("integration.nodeConfig.dynamicField.gated", { keys: unsatisfiedLabels.join(", ") }) }}</p>
127
128
  </template>
128
129
 
129
- <!-- first load: no data yet -->
130
- <template v-else-if="q.isFetching.value && options.length === 0">
130
+ <!-- first load: no answer yet. Not "no options": a search that found nothing
131
+ empties the list too, and swapping the field out then would take the focus
132
+ from an operator correcting their term (AC-13) -->
133
+ <template v-else-if="q.isFetching.value && !q.hasData.value">
131
134
  <div :class="disabledControlClass">{{ t("integration.nodeConfig.dynamicField.resolving") }}</div>
132
135
  </template>
133
136
 
@@ -198,26 +201,43 @@ function toggleMulti(optValue) {
198
201
  @keydown.down="onComboArrowDown"
199
202
  @blur="onComboBlur"
200
203
  />
201
- <ChevronDown :size="14" class="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
204
+ <!-- waiting is shown in the field, not as a row of the list: a row that
205
+ comes and goes moves every option under the pointer (AC-14) -->
206
+ <LoaderCircle
207
+ v-if="answerPending"
208
+ :size="14"
209
+ role="status"
210
+ :aria-label="search ? t('integration.nodeConfig.dynamicField.searching') : t('integration.nodeConfig.dynamicField.resolving')"
211
+ class="absolute right-2 top-1/2 -translate-y-1/2 animate-spin text-muted-foreground"
212
+ />
213
+ <ChevronDown v-else :size="14" class="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
202
214
  <div
203
215
  v-if="listOpen"
216
+ :aria-busy="answerPending"
204
217
  class="absolute left-0 right-0 top-full z-30 mt-1 max-h-56 overflow-y-auto rounded-md border border-black/[0.08] bg-white py-1 shadow-md"
205
218
  >
206
- <div v-if="q.isFetching.value" class="px-2.5 py-1.5 text-sm text-muted-foreground">
207
- {{ search ? t("integration.nodeConfig.dynamicField.searching") : t("integration.nodeConfig.dynamicField.resolving") }}
208
- </div>
209
219
  <button
210
220
  v-for="opt in visibleOptions"
211
221
  :key="String(opt.value)"
212
222
  type="button"
213
- class="flex w-full items-center px-2.5 py-1.5 text-left text-sm hover:bg-muted/50"
214
- :class="String(opt.value) === currentValue ? 'bg-emerald-500/10 text-emerald-800' : ''"
223
+ :aria-disabled="answerPending || void 0"
224
+ class="flex w-full items-center px-2.5 py-1.5 text-left text-sm"
225
+ :class="[
226
+ String(opt.value) === currentValue ? 'bg-emerald-500/10 text-emerald-800' : '',
227
+ answerPending ? 'cursor-wait opacity-60' : 'hover:bg-muted/50'
228
+ ]"
215
229
  @mousedown.prevent="pick(String(opt.value))"
216
230
  >
217
231
  <span class="min-w-0 truncate">{{ resolveLocalized(opt.label) || String(opt.value) }}</span>
218
232
  </button>
219
- <div v-if="!q.isFetching.value && visibleOptions.length === 0" class="px-2.5 py-1.5 text-sm text-muted-foreground">
220
- {{ search ? t("integration.nodeConfig.dynamicField.noMatch", { query: search }) : t("integration.nodeConfig.dynamicField.empty") }}
233
+ <!-- with nothing to show, the one row says why: still asking, or nothing matches -->
234
+ <div v-if="visibleOptions.length === 0" class="px-2.5 py-1.5 text-sm text-muted-foreground">
235
+ <template v-if="answerPending">
236
+ {{ search ? t("integration.nodeConfig.dynamicField.searching") : t("integration.nodeConfig.dynamicField.resolving") }}
237
+ </template>
238
+ <template v-else>
239
+ {{ search.trim() ? t("integration.nodeConfig.dynamicField.noMatch", { query: search.trim() }) : t("integration.nodeConfig.dynamicField.empty") }}
240
+ </template>
221
241
  </div>
222
242
  </div>
223
243
  </div>
@@ -7,6 +7,8 @@ type __VLS_Props = {
7
7
  staticKeys: string[];
8
8
  secretKeys: string[];
9
9
  depth?: number;
10
+ /** The settings beside this one, so an optional `dependsOn` does not gate (IN-80). */
11
+ siblingFields?: ConfigField[];
10
12
  };
11
13
  declare const _default: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
12
14
  set: (key: string, value: unknown) => any;
@@ -2,6 +2,7 @@
2
2
  import { computed } from "vue";
3
3
  import { Button } from "@revenexx/studio";
4
4
  import { IntegrationsApiError } from "../../utils/apiError";
5
+ import { optionalDependencies } from "../../composables/useNodeConfigResolve";
5
6
  import { resolveLocalized } from "../../utils/localize";
6
7
  import { applyingFields, staleKeys } from "../../utils/settingCondition";
7
8
  const props = defineProps({
@@ -11,7 +12,8 @@ const props = defineProps({
11
12
  config: { type: Object, required: true },
12
13
  staticKeys: { type: Array, required: true },
13
14
  secretKeys: { type: Array, required: true },
14
- depth: { type: Number, required: false }
15
+ depth: { type: Number, required: false },
16
+ siblingFields: { type: Array, required: false }
15
17
  });
16
18
  const emit = defineEmits(["set"]);
17
19
  const t = useIntegrationsT();
@@ -22,7 +24,8 @@ const q = useNodeConfigResolve({
22
24
  version: () => props.version,
23
25
  target: () => "*",
24
26
  config: () => props.config,
25
- dependsOn: () => props.field.dependsOn
27
+ dependsOn: () => props.field.dependsOn,
28
+ optional: () => optionalDependencies(props.field.dependsOn, props.siblingFields)
26
29
  });
27
30
  const resolvedFields = computed(() => q.data.value?.fields ?? []);
28
31
  const applyingResolvedFields = computed(() => applyingFields(resolvedFields.value, props.config));
@@ -7,6 +7,8 @@ type __VLS_Props = {
7
7
  staticKeys: string[];
8
8
  secretKeys: string[];
9
9
  depth?: number;
10
+ /** The settings beside this one, so an optional `dependsOn` does not gate (IN-80). */
11
+ siblingFields?: ConfigField[];
10
12
  };
11
13
  declare const _default: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
12
14
  set: (key: string, value: unknown) => any;
@@ -594,6 +594,7 @@ function setArrayItemField(index, fieldKey, v) {
594
594
  :static-keys="staticKeys ?? []"
595
595
  :secret-keys="secretKeys"
596
596
  :depth="depth ?? 0"
597
+ :sibling-fields="siblingFields"
597
598
  @set="(k, v) => emit('set', k, v)"
598
599
  />
599
600
 
@@ -1,4 +1,5 @@
1
1
  import type { RunOutput } from '../../utils/runOutputs.js';
2
+ import { type RunFailure } from '../../utils/runFailure.js';
2
3
  /**
3
4
  * Result surface for a manual run, sync and async alike.
4
5
  *
@@ -44,6 +45,11 @@ type __VLS_Props = {
44
45
  * which has the status code; `error` alone cannot tell the two apart.
45
46
  */
46
47
  refused?: boolean;
48
+ /**
49
+ * Why the run failed — the step, named as the canvas names it, and the cause —
50
+ * as far as the page has found out. Read only for a run that failed (PO-690).
51
+ */
52
+ failure?: RunFailure | null;
47
53
  };
48
54
  declare const _default: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
49
55
  "update:open": (args_0: boolean) => any;
@@ -12,6 +12,7 @@ import {
12
12
  import { formatDuration } from "../../utils/formatDuration";
13
13
  import { runStatusClass, runStatusLabel } from "../../utils/statusStyle";
14
14
  import { isTerminalRun } from "../../utils/runEnvelope";
15
+ import { isFailedRun } from "../../utils/runFailure";
15
16
  const props = defineProps({
16
17
  open: { type: Boolean, required: true },
17
18
  loading: { type: Boolean, required: false },
@@ -22,7 +23,8 @@ const props = defineProps({
22
23
  following: { type: Boolean, required: false },
23
24
  error: { type: [String, null], required: false },
24
25
  runId: { type: [String, null], required: false },
25
- refused: { type: Boolean, required: false }
26
+ refused: { type: Boolean, required: false },
27
+ failure: { type: [Object, null], required: false }
26
28
  });
27
29
  const emit = defineEmits(["update:open", "view-run"]);
28
30
  const t = useIntegrationsT();
@@ -40,6 +42,8 @@ const ended = computed(() => {
40
42
  if (props.mode !== "async") return true;
41
43
  return isTerminalRun(props.run);
42
44
  });
45
+ const failed = computed(() => ended.value && isFailedRun(props.run));
46
+ const cancelled = computed(() => ["cancelled", "canceled"].includes(status.value.toLowerCase()));
43
47
  const returnedNothing = computed(() => props.result == null || typeof props.result === "object" && !Array.isArray(props.result) && !Object.keys(props.result).length);
44
48
  const refused = computed(() => !props.loading && !!props.refused);
45
49
  const unconfirmed = computed(() => !props.loading && !refused.value && props.mode === "async" && !!props.error && !runNumber.value);
@@ -94,8 +98,16 @@ function pretty(value) {
94
98
  >
95
99
  {{ error }}
96
100
  </DialogDescription>
97
- <div v-else class="rounded-md border border-rose-500/20 bg-rose-500/5 px-3 py-2 text-rose-700">
98
- {{ error }}
101
+ <div v-else role="alert" class="rounded-md border border-rose-500/20 bg-rose-500/5 px-3 py-2 text-rose-700">
102
+ <p>{{ error }}</p>
103
+ <!-- A waiting run the engine failed ships the run, so it exists and has
104
+ a failed step and a reason like any other (PO-690). -->
105
+ <p v-if="runId && failure?.step" class="mt-1">
106
+ {{ t("integration.runs.response.failure.step", { step: failure.step }) }}
107
+ </p>
108
+ <p v-if="runId && failure?.cause" class="mt-1 whitespace-pre-wrap break-words">
109
+ {{ failure.cause }}
110
+ </p>
99
111
  </div>
100
112
  <div v-if="runId" class="mt-3 flex justify-end">
101
113
  <Button class="h-8 text-sm" @click="emit('view-run', runId)">
@@ -107,8 +119,25 @@ function pretty(value) {
107
119
  <template v-else>
108
120
  <!-- The confirmation an async press earns: the run exists and is named,
109
121
  whatever the studio manages to read back about it afterwards. -->
122
+ <!-- A run that failed says so in place of the confirmation, rather than
123
+ beside it: green "started" above red "failed" is two answers (PO-690). -->
124
+ <div
125
+ v-if="failed"
126
+ role="alert"
127
+ class="rounded-md border border-rose-500/20 bg-rose-500/5 px-3 py-2 text-rose-700"
128
+ >
129
+ <p class="font-medium">
130
+ {{ runNumber ? t("integration.runs.response.failure.runWithId", { id: runNumber }) : t("integration.runs.response.failure.run") }}
131
+ </p>
132
+ <p v-if="failure?.step" class="mt-1">
133
+ {{ t("integration.runs.response.failure.step", { step: failure.step }) }}
134
+ </p>
135
+ <p v-if="failure?.cause" class="mt-1 whitespace-pre-wrap break-words">
136
+ {{ failure.cause }}
137
+ </p>
138
+ </div>
110
139
  <div
111
- v-if="mode === 'async'"
140
+ v-else-if="mode === 'async'"
112
141
  class="flex items-center gap-2 rounded-md border border-emerald-500/20 bg-emerald-500/5 px-3 py-2 text-emerald-700"
113
142
  >
114
143
  <CheckCircle2 :size="15" class="shrink-0" />
@@ -148,7 +177,9 @@ function pretty(value) {
148
177
  <!-- Nothing at all where the run has not ended: the studio has stopped
149
178
  looking, which is not the same as the run having finished, and the
150
179
  confirmation and the status line above already say where it stands. -->
151
- <template v-else-if="ended">
180
+ <!-- A failed run has said why above; "returned nothing" under it would read
181
+ as the explanation, and a `null` result printed whole as the answer. -->
182
+ <template v-else-if="ended && !failed && !cancelled">
152
183
  <p v-if="returnedNothing" class="mt-3 py-3 text-muted-foreground">
153
184
  {{ t("integration.runs.response.nothingReturned") }}
154
185
  </p>
@@ -1,4 +1,5 @@
1
1
  import type { RunOutput } from '../../utils/runOutputs.js';
2
+ import { type RunFailure } from '../../utils/runFailure.js';
2
3
  /**
3
4
  * Result surface for a manual run, sync and async alike.
4
5
  *
@@ -44,6 +45,11 @@ type __VLS_Props = {
44
45
  * which has the status code; `error` alone cannot tell the two apart.
45
46
  */
46
47
  refused?: boolean;
48
+ /**
49
+ * Why the run failed — the step, named as the canvas names it, and the cause —
50
+ * as far as the page has found out. Read only for a run that failed (PO-690).
51
+ */
52
+ failure?: RunFailure | null;
47
53
  };
48
54
  declare const _default: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
49
55
  "update:open": (args_0: boolean) => any;
@@ -1,12 +1,29 @@
1
1
  <script setup>
2
+ import { computed, onMounted, watch } from "vue";
2
3
  import { Pulse as Activity, ClockCircle as Clock, Home, Key as KeyRound, Layers, ShieldCheck, ShieldKeyhole as Credentials, Programming as Workflow } from "#solar-icons";
3
4
  import SidebarNav from "@revenexx/studio-shared/components/Shared/SidebarNav.vue";
4
5
  const t = useIntegrationsT();
6
+ const route = useRoute();
7
+ const { summary, load: loadSummary } = useIntegrationsSummary();
8
+ onMounted(loadSummary);
9
+ watch(() => route.path, () => {
10
+ void loadSummary();
11
+ });
5
12
  const { workflows } = useIntegrationsWorkflows();
6
13
  const { runningCount } = useIntegrationsRuns();
7
14
  const { activeCount: schedulesActive } = useIntegrationsSchedules();
8
15
  const { total: credentialsTotal } = useIntegrationsCredentials();
9
16
  const { total: secretsTotal } = useIntegrationsSecrets();
17
+ const { tenant } = useIntegrationsApi();
18
+ watch(
19
+ () => [tenant.value, workflows.value.length, runningCount.value, schedulesActive.value, credentialsTotal.value, secretsTotal.value],
20
+ () => {
21
+ void loadSummary();
22
+ }
23
+ );
24
+ function badge(count) {
25
+ return count || null;
26
+ }
10
27
  const sections = computed(() => [
11
28
  {
12
29
  key: "pinned",
@@ -21,12 +38,12 @@ const sections = computed(() => [
21
38
  // translator will look for it rather than only in a review comment.
22
39
  label: t("integration.nav.section.integrations"),
23
40
  items: [
24
- { label: t("integration.nav.item.workflows"), to: "/integrations/workflows", icon: Workflow, badge: workflows.value.length || null },
41
+ { label: t("integration.nav.item.workflows"), to: "/integrations/workflows", icon: Workflow, badge: badge(summary.value?.workflows) },
25
42
  { label: t("integration.nav.item.templates"), to: "/integrations/templates", icon: Layers },
26
- { label: t("integration.nav.item.runs"), to: "/integrations/runs", icon: Activity, badge: runningCount.value || null },
27
- { label: t("integration.nav.item.schedules"), to: "/integrations/schedules", icon: Clock, badge: schedulesActive.value || null },
28
- { label: t("integration.nav.item.credentials"), to: "/integrations/credentials", icon: Credentials, badge: credentialsTotal.value || null },
29
- { label: t("integration.nav.item.secrets"), to: "/integrations/secrets", icon: KeyRound, badge: secretsTotal.value || null },
43
+ { label: t("integration.nav.item.runs"), to: "/integrations/runs", icon: Activity, badge: badge(summary.value?.runsInFlight) },
44
+ { label: t("integration.nav.item.schedules"), to: "/integrations/schedules", icon: Clock, badge: badge(summary.value?.schedulesActive) },
45
+ { label: t("integration.nav.item.credentials"), to: "/integrations/credentials", icon: Credentials, badge: badge(summary.value?.credentials) },
46
+ { label: t("integration.nav.item.secrets"), to: "/integrations/secrets", icon: KeyRound, badge: badge(summary.value?.secrets) },
30
47
  { label: t("integration.nav.item.audit"), to: "/integrations/audit", icon: ShieldCheck }
31
48
  ]
32
49
  }
@@ -2,7 +2,7 @@
2
2
  import { computed, onMounted } from "vue";
3
3
  import { nodeIcon } from "../../data/nodeIcons";
4
4
  import { triggerGlyph } from "../../data/triggerGlyphs";
5
- import { findManifest, NODE_CATEGORIES, TRIGGER_TEMPLATES } from "../../data/integrationNodeCatalog";
5
+ import { findManifest, NODE_CATEGORIES, TRIGGER_TEMPLATES, triggerTypeLabel } from "../../data/integrationNodeCatalog";
6
6
  import { resolveLocalized } from "../../utils/localize";
7
7
  import { describeCron } from "../../utils/schedule";
8
8
  import IntegrationsNodeMark from "./NodeMark.vue";
@@ -38,7 +38,7 @@ const boxes = computed(() => {
38
38
  y: pos.y,
39
39
  w: TRIG_W,
40
40
  h: TRIG_H,
41
- label: typeof t2.name === "string" && t2.name || t2.type.charAt(0).toUpperCase() + t2.type.slice(1),
41
+ label: typeof t2.name === "string" && t2.name || triggerTypeLabel(t2.type),
42
42
  subtitle,
43
43
  type: t2.type,
44
44
  manifest: null,
@@ -175,7 +175,7 @@ const bounds = computed(() => {
175
175
  </div>
176
176
  </div>
177
177
  <div class="rounded-b-md border-t border-emerald-500/15 bg-emerald-500/[0.04] px-3 py-1 text-mono font-semibold text-emerald-700/80">
178
- {{ t("integration.templates.preview.triggerLabel") }} · {{ b.type }}
178
+ {{ t("integration.templates.preview.triggerLabel") }} · {{ triggerTypeLabel(b.type) }}
179
179
  </div>
180
180
  </div>
181
181
 
@@ -6,6 +6,7 @@ import {
6
6
  MarkerType,
7
7
  SelectionMode,
8
8
  VueFlow,
9
+ getRectOfNodes,
9
10
  useVueFlow
10
11
  } from "@vue-flow/core";
11
12
  import { Background } from "@vue-flow/background";
@@ -58,6 +59,7 @@ import { copiedRefusalText, joinRefusalText, refusalText } from "../../data/refu
58
59
  import { inputHandleId, outputHandleId, portFromHandleId } from "../../data/handleId";
59
60
  import { OPPOSITE_SIDE } from "./canvasSides";
60
61
  import { deepClone } from "../../utils/deepClone";
62
+ import { fitArea, paddingFor } from "../../utils/canvasFit";
61
63
  import { copyToClipboard } from "../../utils/clipboard";
62
64
  import { groupFindingsByNode, findingsByNodeField, nodeDisplayLabel } from "../../utils/validationFindings";
63
65
  import { reorderBranchSubtrees } from "../../data/branchReorder";
@@ -403,32 +405,10 @@ function edgeColorByKind(kind) {
403
405
  syncFromBlob();
404
406
  const paneReady = ref(false);
405
407
  const pendingReveal = ref(null);
406
- function liftAboveControlPane() {
407
- const bar = controlBar.value?.getBoundingClientRect();
408
- const wrap = canvasWrapRef.value?.getBoundingClientRect();
409
- const vp = flow.viewport.value;
410
- if (!bar || !wrap || !vp) return;
411
- let top = Number.POSITIVE_INFINITY;
412
- let bottom = Number.NEGATIVE_INFINITY;
413
- for (const n of vfNodes.value) {
414
- const height = flow.findNode(n.id)?.dimensions?.height ?? 76;
415
- top = Math.min(top, n.position.y);
416
- bottom = Math.max(bottom, n.position.y + height);
417
- }
418
- if (!Number.isFinite(top) || !Number.isFinite(bottom)) return;
419
- const CLEARANCE = 8;
420
- const overlap = wrap.top + vp.y + bottom * vp.zoom - bar.top + CLEARANCE;
421
- if (overlap <= 0) return;
422
- const roomAtTop = wrap.top + vp.y + top * vp.zoom - wrap.top - CLEARANCE;
423
- const lift = Math.min(overlap, Math.max(roomAtTop, 0));
424
- if (lift <= 0) return;
425
- flow.setViewport({ ...vp, y: vp.y - lift });
426
- }
427
408
  flow.onPaneReady(async () => {
428
409
  await nextTick();
429
410
  void canvasWrapRef.value?.getBoundingClientRect();
430
- await flow.fitView({ padding: { x: 0.2, top: 0.2, bottom: "96px" }, maxZoom: 1 });
431
- liftAboveControlPane();
411
+ await fitClearOfPanels({ duration: 0 });
432
412
  paneReady.value = true;
433
413
  if (pendingReveal.value) {
434
414
  centerOnNode(pendingReveal.value);
@@ -864,7 +844,7 @@ function onMenuAction(id) {
864
844
  void tidyUp();
865
845
  break;
866
846
  case "fit-view":
867
- flow.fitView({ padding: 0.2, duration: 400, maxZoom: 1 });
847
+ void fitClearOfPanels();
868
848
  break;
869
849
  case "add-node":
870
850
  void focusPalette();
@@ -1385,7 +1365,7 @@ async function tidyUp() {
1385
1365
  syncFromBlob();
1386
1366
  push();
1387
1367
  await nextTick();
1388
- flow.fitView({ padding: 0.2, duration: 400, maxZoom: 1 });
1368
+ await fitClearOfPanels();
1389
1369
  }
1390
1370
  const POINTER_MODE_KEY = "integrations.editor.pointerMode";
1391
1371
  const pointerMode = ref("select");
@@ -1445,6 +1425,29 @@ const MINIMAP_CORNER_CLASS = {
1445
1425
  };
1446
1426
  const minimapPlacementClass = computed(() => minimapLayout.mode.value === "floating" ? "" : MINIMAP_CORNER_CLASS[minimapLayout.mode.value]);
1447
1427
  const minimapPreviewClass = computed(() => minimapLayout.dockPreview.value ? MINIMAP_CORNER_CLASS[minimapLayout.dockPreview.value] : "");
1428
+ async function fitClearOfPanels({ duration = 400 } = {}) {
1429
+ const wrap = canvasWrapRef.value?.getBoundingClientRect();
1430
+ const visible = flow.getNodes.value.filter((n) => !n.hidden && n.dimensions.width && n.dimensions.height);
1431
+ if (!wrap || wrap.width === 0 || !visible.length) {
1432
+ await flow.fitView({ padding: 0.2, duration, maxZoom: 1 });
1433
+ return;
1434
+ }
1435
+ const canvas = { left: wrap.left, top: wrap.top, right: wrap.right, bottom: wrap.bottom };
1436
+ const panels = [];
1437
+ for (const el of [
1438
+ palette.collapsed.value ? null : paletteShellRef.value,
1439
+ minimapLayout.collapsed.value ? null : minimapShellRef.value,
1440
+ controlBar.value
1441
+ ]) {
1442
+ const box = el?.getBoundingClientRect();
1443
+ if (box && box.width > 0 && box.height > 0) {
1444
+ panels.push({ left: box.left, top: box.top, right: box.right, bottom: box.bottom });
1445
+ }
1446
+ }
1447
+ const bounds = getRectOfNodes(visible);
1448
+ const area = fitArea(canvas, panels, { width: bounds.width, height: bounds.height }, flow.minZoom.value, 1);
1449
+ await flow.fitView({ padding: paddingFor(canvas, area), duration, maxZoom: 1 });
1450
+ }
1448
1451
  async function focusPalette() {
1449
1452
  if (palette.collapsed.value) {
1450
1453
  palette.setCollapsed(false);
@@ -1519,7 +1522,9 @@ useCanvasShortcuts(() => ({
1519
1522
  void flow.zoomIn({ duration: 150 });
1520
1523
  },
1521
1524
  [shortcutKeys("zoomReset")]: () => setZoomPct(100),
1522
- [shortcutKeys("fitView")]: () => flow.fitView({ padding: 0.2, duration: 400, maxZoom: 1 }),
1525
+ [shortcutKeys("fitView")]: () => {
1526
+ void fitClearOfPanels();
1527
+ },
1523
1528
  [shortcutKeys("addNode")]: () => {
1524
1529
  void focusPalette();
1525
1530
  },
@@ -1878,7 +1883,7 @@ const edgeTypes = {
1878
1883
  <button
1879
1884
  type="button"
1880
1885
  class="flex h-8 items-center gap-1.5 rounded-lg px-2.5 text-sm font-medium text-foreground transition hover:bg-black/[0.04]"
1881
- @click="flow.fitView({ padding: 0.2, duration: 400, maxZoom: 1 })"
1886
+ @click="fitClearOfPanels()"
1882
1887
  >
1883
1888
  <Sparkles :size="13" />
1884
1889
  {{ t("integration.editor.tool.fitView.label") }}
@@ -92,7 +92,7 @@ const OUT_SIDES = ["bottom"];
92
92
  </div>
93
93
  </div>
94
94
  <div class="flex items-center justify-between border-t border-emerald-500/15 bg-emerald-500/[0.04] rounded-b-md px-3 py-1 text-mono text-emerald-700/80 font-semibold">
95
- <span>{{ t("integration.canvas.badge.trigger") }} · {{ data.type }}</span>
95
+ <span>{{ t("integration.canvas.badge.trigger") }} · {{ triggerTypeLabel(data.type) }}</span>
96
96
  <span v-if="data.active === false" class="text-amber-600">{{ t("integration.canvas.trigger.badge.inactive") }}</span>
97
97
  </div>
98
98
  </div>
@@ -19,6 +19,8 @@ import {
19
19
  Switch
20
20
  } from "@revenexx/studio";
21
21
  import { formatShortDateTime } from "../../utils/formatDateTime";
22
+ import { executionModeLabel } from "../../utils/executionMode";
23
+ import { triggerTypeLabel } from "../../data/integrationNodeCatalog";
22
24
  defineProps({
23
25
  workflows: { type: Array, required: true },
24
26
  loading: { type: Boolean, required: false, default: false }
@@ -125,16 +127,7 @@ function buildState(wf) {
125
127
  }
126
128
  }
127
129
  function modeLabel(m) {
128
- switch (m) {
129
- case "sync_only":
130
- return t("integration.workflows.table.mode.sync");
131
- case "caller_decides":
132
- return t("integration.workflows.table.mode.caller");
133
- case "async_only":
134
- return t("integration.workflows.table.mode.async");
135
- default:
136
- return "\u2014";
137
- }
130
+ return executionModeLabel(m);
138
131
  }
139
132
  const LIFECYCLE = ["active", "draft", "paused"];
140
133
  const BUILD_STATES = ["pending", "building", "ready", "failed"];
@@ -187,16 +180,18 @@ const columns = computed(() => [
187
180
  {
188
181
  key: "mode",
189
182
  label: t("integration.workflows.table.column.mode"),
190
- width: "120px",
183
+ // Wide enough for the longest word it shows, set in capitals: German
184
+ // "Aufrufer entscheidet" is about 160px and ran into the Trigger column at 120.
185
+ width: "170px",
191
186
  sortable: true,
192
- sortGetter: (w) => w.execution_mode ?? ""
187
+ sortGetter: (w) => w.execution_mode ? modeLabel(w.execution_mode) : ""
193
188
  },
194
189
  {
195
190
  key: "triggers",
196
191
  label: t("integration.workflows.table.column.triggers"),
197
192
  width: "140px",
198
193
  sortable: true,
199
- sortGetter: (w) => w.blob.triggers.map((tr) => tr.type).join(",")
194
+ sortGetter: (w) => w.blob.triggers.map((tr) => triggerTypeLabel(tr.type)).join(",")
200
195
  },
201
196
  {
202
197
  key: "lastRun",
@@ -321,7 +316,7 @@ const columns = computed(() => [
321
316
  :key="trigger.id"
322
317
  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"
323
318
  >
324
- {{ trigger.type }}
319
+ {{ triggerTypeLabel(trigger.type) }}
325
320
  </span>
326
321
  </div>
327
322
  </template>
@@ -0,0 +1,29 @@
1
+ /**
2
+ * How much the tenant has, counted over the whole tenant (`GET /summary`) — the
3
+ * figures the navigation shows beside its entries (PO-617).
4
+ *
5
+ * Kept apart from the pages' own state on purpose. The badges used to be the
6
+ * length of whatever the last page had loaded: the editor puts the one workflow
7
+ * it opened into the workflows list, so a fresh visit to a workflow read "1"
8
+ * beside Workflows, and a page nobody had opened yet read nothing at all. These
9
+ * counts are asked for on their own and no page writes to them, so opening a
10
+ * page cannot change what a badge says.
11
+ *
12
+ * Each count means what its page counts: `runsInFlight` is the runs summary's
13
+ * `in_flight`, `schedulesActive` the schedules list's `live_count`.
14
+ *
15
+ * Exported through the module's auto-imports, so the cockpit's own sidebar can
16
+ * read the same figures (IN-85).
17
+ */
18
+ export interface IntegrationsSummary {
19
+ workflows: number;
20
+ runsInFlight: number;
21
+ schedulesActive: number;
22
+ credentials: number;
23
+ secrets: number;
24
+ }
25
+ export declare function toIntegrationsSummary(res: unknown): IntegrationsSummary | null;
26
+ export declare function useIntegrationsSummary(): {
27
+ summary: any;
28
+ load: () => Promise<IntegrationsSummary | null>;
29
+ };