@revenexx/studio-integrations 3.7.1 → 3.8.1

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 (41) hide show
  1. package/CHANGELOG.md +21 -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/GroupFrameView.vue +17 -6
  6. package/dist/runtime/components/Integrations/NodeConfigDynamicField.vue +37 -17
  7. package/dist/runtime/components/Integrations/NodeConfigDynamicSchema.d.vue.ts +2 -0
  8. package/dist/runtime/components/Integrations/NodeConfigDynamicSchema.vue +5 -2
  9. package/dist/runtime/components/Integrations/NodeConfigDynamicSchema.vue.d.ts +2 -0
  10. package/dist/runtime/components/Integrations/NodeConfigField.vue +1 -0
  11. package/dist/runtime/components/Integrations/RunResponseModal.d.vue.ts +6 -0
  12. package/dist/runtime/components/Integrations/RunResponseModal.vue +36 -5
  13. package/dist/runtime/components/Integrations/RunResponseModal.vue.d.ts +6 -0
  14. package/dist/runtime/components/Integrations/Sidebar.vue +22 -5
  15. package/dist/runtime/components/Integrations/StickyNoteView.vue +22 -9
  16. package/dist/runtime/components/Integrations/TemplateFlowPreview.vue +3 -3
  17. package/dist/runtime/components/Integrations/WorkflowEdgeView.vue +3 -2
  18. package/dist/runtime/components/Integrations/WorkflowEditor.vue +32 -27
  19. package/dist/runtime/components/Integrations/WorkflowTriggerView.vue +1 -1
  20. package/dist/runtime/components/Integrations/WorkflowsTable.vue +9 -14
  21. package/dist/runtime/composables/useCanvasTarget.d.ts +15 -0
  22. package/dist/runtime/composables/useCanvasTarget.js +10 -0
  23. package/dist/runtime/composables/useIntegrationsSummary.d.ts +29 -0
  24. package/dist/runtime/composables/useIntegrationsSummary.js +37 -0
  25. package/dist/runtime/composables/useNodeConfigResolve.d.ts +25 -2
  26. package/dist/runtime/composables/useNodeConfigResolve.js +11 -2
  27. package/dist/runtime/composables/useOAuthConnect.d.ts +5 -0
  28. package/dist/runtime/composables/useOAuthConnect.js +3 -1
  29. package/dist/runtime/i18n/messages/de.json +4 -3
  30. package/dist/runtime/i18n/messages/en.json +4 -3
  31. package/dist/runtime/pages/integrations/runs.vue +2 -1
  32. package/dist/runtime/pages/integrations/workflows/[id].vue +39 -2
  33. package/dist/runtime/utils/canvasFit.d.ts +59 -0
  34. package/dist/runtime/utils/canvasFit.js +78 -0
  35. package/dist/runtime/utils/executionMode.d.ts +18 -0
  36. package/dist/runtime/utils/executionMode.js +17 -0
  37. package/dist/runtime/utils/optionSearch.d.ts +24 -0
  38. package/dist/runtime/utils/optionSearch.js +31 -0
  39. package/dist/runtime/utils/runFailure.d.ts +37 -0
  40. package/dist/runtime/utils/runFailure.js +16 -0
  41. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # @revenexx/studio-integrations
2
2
 
3
+ ## 3.8.1
4
+
5
+ ### Patch Changes
6
+
7
+ - f5c169c: The insert and delete buttons on a connection, and the corners a sticky note and a group frame are resized by, are now at least 24 pixels on screen at every zoom level. They used to shrink with the canvas: a workflow of eighteen steps opens at 58 %, and there they measured 14, 11.6 and 9.3 pixels. The connection's buttons now keep their size on screen, as the node's toolbar already did. The resize corners look the same as before, and the area that reacts to the pointer reaches outward past the note or frame, so a bigger target covers none of the content. The pencil and the note badge on a node card are not changed here.
8
+
9
+ ## 3.8.0
10
+
11
+ ### Minor Changes
12
+
13
+ - 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.*`.
14
+
15
+ ### Patch Changes
16
+
17
+ - 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.
18
+ - 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`.
19
+ - 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()`.
20
+ - 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.
21
+ - 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.
22
+ - 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.
23
+
3
24
  ## 3.7.1
4
25
 
5
26
  ### 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.1",
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>
@@ -6,6 +6,7 @@ import { ChevronsDownUp, ChevronsUpDown, Ungroup } from "lucide-vue-next";
6
6
  import { TrashBinMinimalistic as TrashBin } from "#solar-icons";
7
7
  import { inputHandleId, outputHandleId } from "../../data/handleId";
8
8
  import { resolveDisplayName } from "../../utils/localize";
9
+ import { useCanvasTarget } from "../../composables/useCanvasTarget";
9
10
  const props = defineProps({
10
11
  id: { type: String, required: true },
11
12
  data: { type: Object, required: true },
@@ -65,6 +66,8 @@ const resizing = ref(false);
65
66
  const liveW = ref(0);
66
67
  const liveH = ref(0);
67
68
  let resizeStart = { x: 0, y: 0, w: 0, h: 0 };
69
+ const RESIZE_CORNER = 20;
70
+ const resizeTarget = useCanvasTarget(RESIZE_CORNER);
68
71
  const frameW = computed(() => resizing.value ? liveW.value : props.data.rect.width);
69
72
  const frameH = computed(() => resizing.value ? liveH.value : props.data.rect.height);
70
73
  function onResizeStart(e) {
@@ -215,18 +218,26 @@ function onResizeEnd() {
215
218
  <!-- Bottom-right resize handle (editable frames only). -->
216
219
  <div
217
220
  v-if="editable"
218
- class="nodrag pointer-events-auto absolute bottom-0 right-0 flex size-5 cursor-se-resize items-end justify-end p-1"
219
- :style="{ color: color.chip }"
221
+ class="nodrag pointer-events-auto absolute cursor-se-resize"
222
+ :style="{
223
+ color: color.chip,
224
+ width: `${resizeTarget}px`,
225
+ height: `${resizeTarget}px`,
226
+ right: `${RESIZE_CORNER - resizeTarget}px`,
227
+ bottom: `${RESIZE_CORNER - resizeTarget}px`
228
+ }"
220
229
  :title="t('integration.canvas.group.action.resize')"
221
230
  @pointerdown="onResizeStart"
222
231
  @pointermove="onResizeMove"
223
232
  @pointerup="onResizeEnd"
224
233
  @pointercancel="onResizeEnd"
225
234
  >
226
- <svg viewBox="0 0 8 8" class="size-2.5" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round">
227
- <path d="M7 1 1 7" />
228
- <path d="M7 4.5 4.5 7" />
229
- </svg>
235
+ <div class="flex size-5 items-end justify-end p-1">
236
+ <svg viewBox="0 0 8 8" class="size-2.5" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round">
237
+ <path d="M7 1 1 7" />
238
+ <path d="M7 4.5 4.5 7" />
239
+ </svg>
240
+ </div>
230
241
  </div>
231
242
  </div>
232
243
  </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
  }
@@ -3,6 +3,7 @@ import { computed, nextTick, ref, watch } from "vue";
3
3
  import { Position, useVueFlow } from "@vue-flow/core";
4
4
  import { NodeToolbar } from "@vue-flow/node-toolbar";
5
5
  import { TrashBinMinimalistic as TrashBin } from "#solar-icons";
6
+ import { useCanvasTarget } from "../../composables/useCanvasTarget";
6
7
  const props = defineProps({
7
8
  id: { type: String, required: true },
8
9
  data: { type: Object, required: true },
@@ -34,6 +35,8 @@ const MIN_HEIGHT = 80;
34
35
  const width = ref(props.data.sticky.size?.width ?? DEFAULT_WIDTH);
35
36
  const height = ref(props.data.sticky.size?.height ?? DEFAULT_HEIGHT);
36
37
  const resizing = ref(false);
38
+ const RESIZE_CORNER = 16;
39
+ const resizeTarget = useCanvasTarget(RESIZE_CORNER);
37
40
  watch(() => props.data.sticky.size, (s) => {
38
41
  if (resizing.value) return;
39
42
  width.value = s?.width ?? DEFAULT_WIDTH;
@@ -191,7 +194,7 @@ function onViewMouseDown(e) {
191
194
 
192
195
  <!-- Card -->
193
196
  <div
194
- class="relative flex flex-col overflow-hidden rounded-lg border text-sm leading-snug text-stone-700 shadow-sm"
197
+ class="relative flex flex-col rounded-lg border text-sm leading-snug text-stone-700 shadow-sm"
195
198
  :class="[
196
199
  selected ? 'ring-2 ring-emerald-400/60' : '',
197
200
  editing ? '' : 'cursor-grab active:cursor-grabbing'
@@ -209,7 +212,7 @@ function onViewMouseDown(e) {
209
212
  <!-- View mode -->
210
213
  <div
211
214
  v-if="!editing"
212
- class="h-full w-full overflow-hidden whitespace-pre-wrap break-words px-3 py-2"
215
+ class="h-full w-full overflow-hidden rounded-[inherit] whitespace-pre-wrap break-words px-3 py-2"
213
216
  @mousedown="onViewMouseDown"
214
217
  >
215
218
  <!-- eslint-disable-next-line vue/no-v-html -- renderMarkdownLite escapes the note before it adds a tag -->
@@ -228,7 +231,7 @@ function onViewMouseDown(e) {
228
231
  ref="textareaEl"
229
232
  v-model="draft"
230
233
  :aria-label="t('integration.canvas.sticky.textarea.aria')"
231
- class="nodrag nowheel h-full w-full resize-none bg-transparent px-3 py-2 text-sm leading-snug text-stone-700 outline-none placeholder:text-stone-400 focus:ring-2 focus:ring-inset focus:ring-stone-500/40"
234
+ class="nodrag nowheel h-full w-full resize-none rounded-[inherit] bg-transparent px-3 py-2 text-sm leading-snug text-stone-700 outline-none placeholder:text-stone-400 focus:ring-2 focus:ring-inset focus:ring-stone-500/40"
232
235
  :placeholder="t('integration.canvas.sticky.textarea.placeholder')"
233
236
  spellcheck="false"
234
237
  @blur="commitEdit"
@@ -239,20 +242,30 @@ function onViewMouseDown(e) {
239
242
 
240
243
  <!-- Bottom-right resize handle (hand-rolled; min 150x80). Gone on a
241
244
  read-only canvas: a handle that cannot resize is the same lie as a bin
242
- that cannot delete. -->
245
+ that cannot delete. The hit area reaches past the note's corner (see
246
+ `resizeTarget`) — which is why the card no longer clips its overflow —
247
+ and the drawn corner sits in its top-left, exactly where it always sat. -->
243
248
  <div
244
249
  v-if="editable"
245
- class="nodrag absolute bottom-0 right-0 flex size-4 cursor-se-resize items-end justify-end p-0.5 text-stone-400/80"
250
+ class="nodrag absolute cursor-se-resize"
251
+ :style="{
252
+ width: `${resizeTarget}px`,
253
+ height: `${resizeTarget}px`,
254
+ right: `${RESIZE_CORNER - resizeTarget}px`,
255
+ bottom: `${RESIZE_CORNER - resizeTarget}px`
256
+ }"
246
257
  :title="t('integration.canvas.sticky.action.resize')"
247
258
  @pointerdown="onResizeStart"
248
259
  @pointermove="onResizeMove"
249
260
  @pointerup="onResizeEnd"
250
261
  @pointercancel="onResizeEnd"
251
262
  >
252
- <svg viewBox="0 0 8 8" class="size-2.5" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round">
253
- <path d="M7 1 1 7" />
254
- <path d="M7 4.5 4.5 7" />
255
- </svg>
263
+ <div class="flex size-4 items-end justify-end p-0.5 text-stone-400/80">
264
+ <svg viewBox="0 0 8 8" class="size-2.5" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round">
265
+ <path d="M7 1 1 7" />
266
+ <path d="M7 4.5 4.5 7" />
267
+ </svg>
268
+ </div>
256
269
  </div>
257
270
  </div>
258
271
  </template>
@@ -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
 
@@ -92,6 +92,7 @@ function onHoverLeave() {
92
92
  }, 200);
93
93
  }
94
94
  const toolsVisible = computed(() => edgeActions !== null && !props.data?.replay && (hovered.value || isSelected.value));
95
+ const unzoom = computed(() => 1 / (flow.viewport.value.zoom || 1));
95
96
  function removeEdge() {
96
97
  flow.removeEdges([props.id]);
97
98
  }
@@ -128,7 +129,7 @@ function spliceNode() {
128
129
  <div
129
130
  class="absolute pointer-events-none select-none rounded-md border px-1.5 py-0.5 text-mono shadow-sm"
130
131
  :style="{
131
- transform: `translate(-50%, -50%) translate(${path[1]}px, ${path[2] - (toolsVisible ? 16 : 0)}px)`,
132
+ transform: `translate(-50%, -50%) translate(${path[1]}px, ${path[2] - (toolsVisible ? 16 * unzoom : 0)}px)`,
132
133
  color: color.label,
133
134
  background: color.bg,
134
135
  borderColor: color.stroke + '55'
@@ -142,7 +143,7 @@ function spliceNode() {
142
143
  <EdgeLabelRenderer v-if="toolsVisible">
143
144
  <div
144
145
  class="absolute z-20 flex items-center gap-0.5 rounded-md border border-black/[0.08] bg-white p-0.5 shadow-md"
145
- :style="{ transform: `translate(-50%, -50%) translate(${path[1]}px, ${path[2] + (props.data?.label ? 14 : 0)}px)`, pointerEvents: 'all' }"
146
+ :style="{ transform: `translate(-50%, -50%) translate(${path[1]}px, ${path[2] + (props.data?.label ? 14 * unzoom : 0)}px) scale(${unzoom})`, pointerEvents: 'all' }"
146
147
  @mouseenter="onHoverEnter"
147
148
  @mouseleave="onHoverLeave"
148
149
  >