@revenexx/studio-integrations 0.7.0 → 1.0.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 (71) hide show
  1. package/CHANGELOG.md +416 -0
  2. package/dist/module.json +1 -1
  3. package/dist/module.mjs +2 -1
  4. package/dist/runtime/components/Integrations/CredentialCreateDialog.vue +4 -1
  5. package/dist/runtime/components/Integrations/CredentialDetailDialog.vue +8 -2
  6. package/dist/runtime/components/Integrations/ExpressionInput.vue +5 -0
  7. package/dist/runtime/components/Integrations/NodeConfigDynamicField.d.vue.ts +3 -0
  8. package/dist/runtime/components/Integrations/NodeConfigDynamicField.vue +16 -3
  9. package/dist/runtime/components/Integrations/NodeConfigDynamicField.vue.d.ts +3 -0
  10. package/dist/runtime/components/Integrations/NodeConfigDynamicSchema.vue +1 -0
  11. package/dist/runtime/components/Integrations/NodeConfigField.d.vue.ts +3 -0
  12. package/dist/runtime/components/Integrations/NodeConfigField.vue +2 -0
  13. package/dist/runtime/components/Integrations/NodeConfigField.vue.d.ts +3 -0
  14. package/dist/runtime/components/Integrations/NodeInspector.d.vue.ts +2 -2
  15. package/dist/runtime/components/Integrations/NodeInspector.vue +95 -10
  16. package/dist/runtime/components/Integrations/NodeInspector.vue.d.ts +2 -2
  17. package/dist/runtime/components/Integrations/NodeOutputSchema.d.vue.ts +2 -1
  18. package/dist/runtime/components/Integrations/NodeOutputSchema.vue +34 -8
  19. package/dist/runtime/components/Integrations/NodeOutputSchema.vue.d.ts +2 -1
  20. package/dist/runtime/components/Integrations/NodePalette.vue +12 -8
  21. package/dist/runtime/components/Integrations/StickyNoteView.vue +45 -11
  22. package/dist/runtime/components/Integrations/SurfaceCard.d.vue.ts +50 -0
  23. package/dist/runtime/components/Integrations/SurfaceCard.vue +55 -0
  24. package/dist/runtime/components/Integrations/SurfaceCard.vue.d.ts +50 -0
  25. package/dist/runtime/components/Integrations/TemplateCard.vue +3 -3
  26. package/dist/runtime/components/Integrations/TemplateFlowPreview.vue +12 -3
  27. package/dist/runtime/components/Integrations/WorkflowCreateDialog.vue +5 -4
  28. package/dist/runtime/components/Integrations/WorkflowEditor.d.vue.ts +16 -0
  29. package/dist/runtime/components/Integrations/WorkflowEditor.vue +52 -11
  30. package/dist/runtime/components/Integrations/WorkflowEditor.vue.d.ts +16 -0
  31. package/dist/runtime/components/Integrations/WorkflowHistoryPanel.d.vue.ts +6 -0
  32. package/dist/runtime/components/Integrations/WorkflowHistoryPanel.vue +4 -2
  33. package/dist/runtime/components/Integrations/WorkflowHistoryPanel.vue.d.ts +6 -0
  34. package/dist/runtime/components/Integrations/WorkflowNodeView.vue +59 -28
  35. package/dist/runtime/components/Integrations/WorkflowRunReplayPanel.vue +3 -4
  36. package/dist/runtime/components/Integrations/WorkflowSettingsDialog.d.vue.ts +16 -17
  37. package/dist/runtime/components/Integrations/WorkflowSettingsDialog.vue +37 -26
  38. package/dist/runtime/components/Integrations/WorkflowSettingsDialog.vue.d.ts +16 -17
  39. package/dist/runtime/components/Integrations/WorkflowTriggerView.vue +4 -5
  40. package/dist/runtime/components/Integrations/WorkflowVariablesDialog.vue +53 -14
  41. package/dist/runtime/composables/useIntegrationsCredentials.d.ts +11 -0
  42. package/dist/runtime/composables/useIntegrationsCredentials.js +2 -2
  43. package/dist/runtime/composables/useIntegrationsNodes.js +11 -1
  44. package/dist/runtime/composables/useIntegrationsSchedules.d.ts +3 -2
  45. package/dist/runtime/composables/useIntegrationsTriggers.d.ts +23 -3
  46. package/dist/runtime/composables/useIntegrationsTriggers.js +18 -7
  47. package/dist/runtime/composables/useIntegrationsWorkflows.d.ts +10 -1
  48. package/dist/runtime/composables/useIntegrationsWorkflows.js +25 -4
  49. package/dist/runtime/composables/useNodeConfigResolve.js +2 -1
  50. package/dist/runtime/data/integrationNodeCatalog.d.ts +22 -1
  51. package/dist/runtime/data/integrationNodeCatalog.js +8 -4
  52. package/dist/runtime/data/nodeInputData.d.ts +58 -1
  53. package/dist/runtime/data/nodeInputData.js +30 -5
  54. package/dist/runtime/data/triggerGlyphs.d.ts +47 -0
  55. package/dist/runtime/data/triggerGlyphs.js +11 -0
  56. package/dist/runtime/i18n/messages/de.json +20 -3
  57. package/dist/runtime/i18n/messages/en.json +20 -3
  58. package/dist/runtime/pages/integrations/credentials.vue +4 -1
  59. package/dist/runtime/pages/integrations/index.vue +16 -17
  60. package/dist/runtime/pages/integrations/templates/[slug].vue +13 -5
  61. package/dist/runtime/pages/integrations/workflows/[id].vue +193 -104
  62. package/dist/runtime/types/integrations.d.ts +25 -1
  63. package/dist/runtime/utils/cockpitSources.d.ts +22 -3
  64. package/dist/runtime/utils/credentialState.d.ts +6 -1
  65. package/dist/runtime/utils/credentialState.js +1 -1
  66. package/dist/runtime/utils/localize.d.ts +34 -0
  67. package/dist/runtime/utils/localize.js +22 -7
  68. package/dist/runtime/utils/pollBudget.d.ts +1 -1
  69. package/dist/runtime/utils/servedPaths.d.ts +22 -0
  70. package/dist/runtime/utils/servedPaths.js +3 -0
  71. package/package.json +1 -1
@@ -1,11 +1,11 @@
1
1
  <script setup>
2
- import { ref, watch } from "vue";
3
- import { RefreshCircle as Loader2 } from "#solar-icons";
2
+ import { computed, ref, watch } from "vue";
4
3
  import {
5
4
  Button,
6
5
  Dialog,
7
6
  DialogContent,
8
7
  DialogDescription,
8
+ DialogFooter,
9
9
  DialogHeader,
10
10
  DialogTitle,
11
11
  Input,
@@ -23,24 +23,35 @@ const props = defineProps({
23
23
  open: { type: Boolean, required: true },
24
24
  workflow: { type: Object, required: true },
25
25
  modes: { type: Array, required: true },
26
- modeDraft: { type: String, required: true },
27
- savingMode: { type: Boolean, required: false },
28
- savingMeta: { type: Boolean, required: false },
26
+ current: { type: Object, required: true },
29
27
  tagSuggestions: { type: Array, required: false }
30
28
  });
31
- const emit = defineEmits(["update:open", "change-mode", "save-meta"]);
29
+ const emit = defineEmits(["update:open", "apply"]);
32
30
  const t = useIntegrationsT();
33
31
  const nameDraft = ref("");
34
32
  const descriptionDraft = ref("");
35
33
  const tagsDraft = ref([]);
36
34
  const iconDraft = ref(null);
35
+ const modeDraft = ref("");
37
36
  watch(() => props.open, (v) => {
38
37
  if (!v) return;
39
- nameDraft.value = props.workflow.name;
40
- descriptionDraft.value = props.workflow.description ?? "";
41
- tagsDraft.value = [...props.workflow.blob.tags ?? []];
42
- iconDraft.value = props.workflow.blob.icon ?? null;
38
+ nameDraft.value = props.current.name;
39
+ descriptionDraft.value = props.current.description;
40
+ tagsDraft.value = [...props.current.tags];
41
+ iconDraft.value = props.current.icon;
42
+ modeDraft.value = props.current.executionMode;
43
43
  }, { immediate: true });
44
+ const edited = computed(() => ({
45
+ name: nameDraft.value.trim(),
46
+ description: descriptionDraft.value.trim(),
47
+ tags: tagsDraft.value,
48
+ icon: iconDraft.value,
49
+ executionMode: modeDraft.value
50
+ }));
51
+ function applyAndClose() {
52
+ if (JSON.stringify(edited.value) !== JSON.stringify(props.current)) emit("apply", edited.value);
53
+ emit("update:open", false);
54
+ }
44
55
  function formatDateTime(iso) {
45
56
  if (!iso) return "\u2014";
46
57
  try {
@@ -57,7 +68,10 @@ function formatDateTime(iso) {
57
68
  <DialogHeader>
58
69
  <DialogTitle>{{ t("integration.workflows.settings.dialog.title") }}</DialogTitle>
59
70
  <DialogDescription class="text-sm">
60
- {{ t("integration.workflows.settings.dialog.description", { name: workflow.name }) }}
71
+ <!-- The name as it stands, drafted included: this dialog is where a rename is
72
+ typed, so naming the workflow by its stored name here contradicts the
73
+ field directly below. -->
74
+ {{ t("integration.workflows.settings.dialog.description", { name: current.name }) }}
61
75
  </DialogDescription>
62
76
  </DialogHeader>
63
77
 
@@ -66,9 +80,7 @@ function formatDateTime(iso) {
66
80
  <div class="text-mono text-muted-foreground">{{ t("integration.workflows.settings.section.execution") }}</div>
67
81
  <div class="flex items-center gap-2">
68
82
  <Select
69
- :model-value="modeDraft"
70
- :disabled="savingMode"
71
- @update:model-value="(v) => emit('change-mode', v)"
83
+ v-model="modeDraft"
72
84
  >
73
85
  <!-- Named explicitly: the section caption above says "Execution",
74
86
  which is not a name the control itself carries. -->
@@ -81,7 +93,6 @@ function formatDateTime(iso) {
81
93
  </SelectItem>
82
94
  </SelectContent>
83
95
  </Select>
84
- <Loader2 v-if="savingMode" :size="14" class="shrink-0 animate-spin text-muted-foreground" />
85
96
  </div>
86
97
  <p class="text-xs text-muted-foreground">
87
98
  {{ modes.find((m2) => m2.value === modeDraft)?.hint }}
@@ -109,17 +120,6 @@ function formatDateTime(iso) {
109
120
  <Label id="wf-set-icon-label" class="text-sm font-normal text-muted-foreground">{{ t("integration.workflows.settings.field.icon.label") }}</Label>
110
121
  <WorkflowIconPicker v-model="iconDraft" trigger-labelledby="wf-set-icon-label" />
111
122
  </div>
112
- <div class="flex justify-end">
113
- <Button
114
- size="sm"
115
- class="h-7 text-sm"
116
- :disabled="savingMeta || !nameDraft.trim()"
117
- @click="emit('save-meta', { name: nameDraft.trim(), description: descriptionDraft.trim(), tags: tagsDraft, icon: iconDraft })"
118
- >
119
- <Loader2 v-if="savingMeta" :size="12" class="animate-spin" />
120
- {{ t("integration.workflows.settings.action.save") }}
121
- </Button>
122
- </div>
123
123
  </div>
124
124
 
125
125
  <div class="rounded-lg border border-black/[0.06] bg-white p-4 space-y-2">
@@ -138,6 +138,17 @@ function formatDateTime(iso) {
138
138
  </div>
139
139
  </div>
140
140
  </div>
141
+
142
+ <!-- Cancel / Done, the pair every dialog in the editor closes on. Done reaches
143
+ the workflow's unsaved state; the header's Save reaches the platform. -->
144
+ <DialogFooter>
145
+ <Button variant="outline" size="sm" class="h-8" @click="emit('update:open', false)">
146
+ {{ t("integration.common.action.cancel") }}
147
+ </Button>
148
+ <Button size="sm" class="h-8" :disabled="!nameDraft.trim()" @click="applyAndClose">
149
+ {{ t("integration.workflows.settings.action.done") }}
150
+ </Button>
151
+ </DialogFooter>
141
152
  </DialogContent>
142
153
  </Dialog>
143
154
  </template>
@@ -4,33 +4,32 @@ export interface ExecutionModeOption {
4
4
  label: string;
5
5
  hint: string;
6
6
  }
7
+ /** What this dialog edits — the values as they stand, saved or not. */
8
+ export interface WorkflowSettingsValues {
9
+ name: string;
10
+ description: string;
11
+ tags: string[];
12
+ icon: WorkflowIcon | null;
13
+ executionMode: string;
14
+ }
7
15
  type __VLS_Props = {
8
16
  open: boolean;
9
17
  workflow: Workflow;
10
18
  modes: ExecutionModeOption[];
11
- modeDraft: string;
12
- savingMode?: boolean;
13
- savingMeta?: boolean;
19
+ /**
20
+ * The values to edit: the editor's unsaved ones where it has any, the saved ones
21
+ * otherwise. Reopening the dialog therefore shows what the operator last entered
22
+ * rather than what the platform still holds.
23
+ */
24
+ current: WorkflowSettingsValues;
14
25
  /** Tag suggestions (tags already used across the tenant's workflows). */
15
26
  tagSuggestions?: string[];
16
27
  };
17
28
  declare const _default: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
18
29
  "update:open": (args_0: boolean) => any;
19
- "change-mode": (mode: string) => any;
20
- "save-meta": (meta: {
21
- name: string;
22
- description: string;
23
- tags: string[];
24
- icon: WorkflowIcon | null;
25
- }) => any;
30
+ apply: (values: WorkflowSettingsValues) => any;
26
31
  }, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
27
32
  "onUpdate:open"?: ((args_0: boolean) => any) | undefined;
28
- "onChange-mode"?: ((mode: string) => any) | undefined;
29
- "onSave-meta"?: ((meta: {
30
- name: string;
31
- description: string;
32
- tags: string[];
33
- icon: WorkflowIcon | null;
34
- }) => any) | undefined;
33
+ onApply?: ((values: WorkflowSettingsValues) => any) | undefined;
35
34
  }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
36
35
  export default _default;
@@ -2,7 +2,8 @@
2
2
  import { computed, ref } from "vue";
3
3
  import { Handle, Position, useVueFlow } from "@vue-flow/core";
4
4
  import { NodeToolbar } from "@vue-flow/node-toolbar";
5
- import { ClockCircle as Clock, Play, TrashBinMinimalistic as TrashBin, LinkRound as Webhook, Bolt } from "#solar-icons";
5
+ import { TrashBinMinimalistic as TrashBin } from "#solar-icons";
6
+ import { triggerGlyph as glyphForTrigger } from "../../data/triggerGlyphs";
6
7
  import { resolveDisplayName } from "../../utils/localize";
7
8
  import { triggerTypeLabel } from "../../data/integrationNodeCatalog";
8
9
  import { describeCron } from "../../utils/schedule";
@@ -14,9 +15,7 @@ const props = defineProps({
14
15
  defineOptions({ inheritAttrs: false });
15
16
  defineEmits(["updateNodeInternals"]);
16
17
  const t = useIntegrationsT();
17
- const icon = computed(() => {
18
- return props.data.type === "schedule" ? Clock : props.data.type === "webhook" ? Webhook : props.data.type === "event" ? Bolt : Play;
19
- });
18
+ const icon = computed(() => glyphForTrigger(props.data.type));
20
19
  const subtitle = computed(() => {
21
20
  const c = props.data.config;
22
21
  if (props.data.type === "schedule") return describeCron(c.cron) ?? c.cron ?? t("integration.canvas.trigger.subtitle.noSchedule");
@@ -77,7 +76,7 @@ function sideToPosition(s) {
77
76
  identically (icon chip size-8/15, subtitle plain text-sm, mt-0.5). -->
78
77
  <div class="flex items-center gap-2.5 px-3 py-2.5">
79
78
  <span class="flex size-8 shrink-0 items-center justify-center rounded-md bg-emerald-500 text-white shadow-sm">
80
- <component :is="icon" :size="15" :stroke-width="2.5" />
79
+ <component :is="icon" :size="15" />
81
80
  </span>
82
81
  <div class="min-w-0 flex-1">
83
82
  <div class="text-sm font-semibold text-foreground leading-tight truncate">
@@ -6,6 +6,7 @@ import {
6
6
  Dialog,
7
7
  DialogContent,
8
8
  DialogDescription,
9
+ DialogFooter,
9
10
  DialogHeader,
10
11
  DialogTitle,
11
12
  Input,
@@ -18,7 +19,7 @@ import {
18
19
  SelectValue
19
20
  } from "@revenexx/studio";
20
21
  import { toast } from "vue-sonner";
21
- import { resolveDisplayName } from "../../utils/localize";
22
+ import { languageKeyRead, languageKeyToWrite, resolveDisplayName } from "../../utils/localize";
22
23
  const props = defineProps({
23
24
  open: { type: Boolean, required: true },
24
25
  variables: { type: Object, required: true }
@@ -27,32 +28,50 @@ const emit = defineEmits(["update:open", "update:variables"]);
27
28
  const t = useIntegrationsT();
28
29
  const KEY_PATTERN = /^[a-z_][a-z0-9_]*$/i;
29
30
  const TYPES = ["string", "number", "boolean", "object", "array"];
31
+ const working = ref({});
30
32
  const jsonDrafts = reactive({});
31
33
  const jsonErrors = reactive({});
34
+ const descDrafts = reactive({});
32
35
  watch(() => props.open, (o) => {
33
36
  if (!o) return;
37
+ working.value = JSON.parse(JSON.stringify(props.variables));
34
38
  for (const k of Object.keys(jsonDrafts)) delete jsonDrafts[k];
35
39
  for (const k of Object.keys(jsonErrors)) delete jsonErrors[k];
36
- for (const [key, v] of Object.entries(props.variables)) {
40
+ for (const k of Object.keys(descDrafts)) delete descDrafts[k];
41
+ for (const [key, v] of Object.entries(working.value)) {
42
+ descDrafts[key] = descriptionText(v);
37
43
  if (v.type === "object" || v.type === "array") {
38
44
  jsonDrafts[key] = v.default === void 0 ? "" : JSON.stringify(v.default, null, 2);
39
45
  jsonErrors[key] = null;
40
46
  }
41
47
  }
42
48
  });
43
- const entries = computed(() => Object.entries(props.variables));
49
+ const entries = computed(() => Object.entries(working.value));
50
+ const changed = computed(() => JSON.stringify(working.value) !== JSON.stringify(props.variables));
51
+ const hasJsonError = computed(() => Object.values(jsonErrors).some(Boolean));
44
52
  function commit(next) {
45
- emit("update:variables", next);
53
+ working.value = next;
54
+ }
55
+ function applyAndClose() {
56
+ for (const [key, v] of Object.entries(working.value)) {
57
+ if ((v.type === "object" || v.type === "array") && jsonDrafts[key] !== void 0) {
58
+ applyJsonDraft(key, v.type);
59
+ }
60
+ }
61
+ if (hasJsonError.value) return;
62
+ if (changed.value) emit("update:variables", working.value);
63
+ emit("update:open", false);
46
64
  }
47
65
  function patchVar(key, patch) {
48
- const cur = props.variables[key];
66
+ const cur = working.value[key];
49
67
  if (!cur) return;
50
- commit({ ...props.variables, [key]: { ...cur, ...patch } });
68
+ commit({ ...working.value, [key]: { ...cur, ...patch } });
51
69
  }
52
70
  function removeVar(key) {
53
- const next = { ...props.variables };
71
+ const next = { ...working.value };
54
72
  delete next[key];
55
73
  delete jsonDrafts[key];
74
+ delete descDrafts[key];
56
75
  delete jsonErrors[key];
57
76
  commit(next);
58
77
  }
@@ -65,6 +84,8 @@ function changeType(key, type) {
65
84
  array: void 0
66
85
  };
67
86
  patchVar(key, { type, default: blank[type] });
87
+ delete jsonDrafts[key];
88
+ delete jsonErrors[key];
68
89
  if (type === "object" || type === "array") {
69
90
  jsonDrafts[key] = "";
70
91
  jsonErrors[key] = null;
@@ -94,28 +115,34 @@ function descriptionText(v) {
94
115
  return resolveDisplayName(v.description);
95
116
  }
96
117
  function writeDescription(key, text) {
97
- const cur = props.variables[key];
118
+ const cur = working.value[key];
98
119
  if (!cur) return;
120
+ descDrafts[key] = text;
121
+ const existing = typeof cur.description === "object" && cur.description !== null ? { ...cur.description } : {};
99
122
  if (!text.trim()) {
123
+ delete existing[languageKeyRead(cur.description)];
124
+ if (Object.keys(existing).length) {
125
+ patchVar(key, { description: existing });
126
+ return;
127
+ }
100
128
  const { description: _drop, ...rest } = cur;
101
- commit({ ...props.variables, [key]: rest });
129
+ commit({ ...working.value, [key]: rest });
102
130
  return;
103
131
  }
104
- const existing = typeof cur.description === "object" && cur.description !== null ? cur.description : {};
105
- patchVar(key, { description: { ...existing, en: text } });
132
+ patchVar(key, { description: { ...existing, [languageKeyToWrite(cur.description)]: text } });
106
133
  }
107
134
  const newKey = ref("");
108
135
  const newKeyError = computed(() => {
109
136
  const k = newKey.value.trim();
110
137
  if (!k) return null;
111
138
  if (!KEY_PATTERN.test(k)) return t("integration.workflows.variables.error.invalidKey");
112
- if (props.variables[k]) return t("integration.workflows.variables.error.keyExists");
139
+ if (working.value[k]) return t("integration.workflows.variables.error.keyExists");
113
140
  return null;
114
141
  });
115
142
  function addVariable() {
116
143
  const k = newKey.value.trim();
117
144
  if (!k || newKeyError.value) return;
118
- commit({ ...props.variables, [k]: { type: "string", default: "" } });
145
+ commit({ ...working.value, [k]: { type: "string", default: "" } });
119
146
  newKey.value = "";
120
147
  }
121
148
  async function copyUsage(key) {
@@ -247,7 +274,7 @@ async function copyUsage(key) {
247
274
  <Label :for="`var-${key}-desc`" class="text-mono text-muted-foreground">{{ t("integration.workflows.variables.field.description.label") }}</Label>
248
275
  <Input
249
276
  :id="`var-${key}-desc`"
250
- :model-value="descriptionText(v)"
277
+ :model-value="descDrafts[key] ?? descriptionText(v)"
251
278
  class="h-8 bg-white text-sm"
252
279
  :placeholder="t('integration.workflows.variables.field.description.placeholder')"
253
280
  @update:model-value="(val) => writeDescription(key, String(val))"
@@ -274,6 +301,18 @@ async function copyUsage(key) {
274
301
  <p v-if="newKeyError" class="mt-1 text-xs text-rose-600">{{ newKeyError }}</p>
275
302
  </div>
276
303
  </div>
304
+
305
+ <!-- Cancel / Done, the same pair every dialog in the editor closes on. Done
306
+ reaches the workflow's unsaved state; only the header's Save reaches the
307
+ platform. -->
308
+ <DialogFooter>
309
+ <Button variant="outline" size="sm" class="h-8" @click="emit('update:open', false)">
310
+ {{ t("integration.common.action.cancel") }}
311
+ </Button>
312
+ <Button size="sm" class="h-8" :disabled="hasJsonError" @click="applyAndClose">
313
+ {{ t("integration.workflows.variables.action.done") }}
314
+ </Button>
315
+ </DialogFooter>
277
316
  </DialogContent>
278
317
  </Dialog>
279
318
  </template>
@@ -13,8 +13,19 @@ import type { Credential } from '../types/integrations.js';
13
13
  * POST /credentials/{id}/test → test saved instance
14
14
  * POST /credentials/{id}/oauth/authorize-url → { authorize_url, state }
15
15
  */
16
+ /**
17
+ * `message` is what the credential type said about the test, whichever way it went —
18
+ * which host answered, which environment, which plan, or why it was refused. The
19
+ * service sends it on every path, and it may be absent.
20
+ *
21
+ * `error` is not the service's: it is what THIS composable puts there when the call
22
+ * never arrived — a network failure, a refusal that is not an answer. Nothing on the
23
+ * wire ever carries it, so a surface that reads `error` alone and falls back to a
24
+ * fixed sentence throws away every reason the platform did send.
25
+ */
16
26
  export interface CredentialTestResult {
17
27
  ok: boolean;
28
+ message?: string;
18
29
  error?: string;
19
30
  }
20
31
  export declare function useIntegrationsCredentials(): {
@@ -60,7 +60,7 @@ export function useIntegrationsCredentials() {
60
60
  async function testInline(credentialTypeSlug, config) {
61
61
  try {
62
62
  const res = await api.post("/credentials/test", { credential_type_slug: credentialTypeSlug, config });
63
- return res?.ok === false ? res : { ok: true };
63
+ return res?.ok === false ? res : { ok: true, message: res?.message };
64
64
  } catch (e) {
65
65
  return { ok: false, error: captureError(e, t("integration.credentials.error.testFailed")) };
66
66
  }
@@ -68,7 +68,7 @@ export function useIntegrationsCredentials() {
68
68
  async function test(id) {
69
69
  try {
70
70
  const res = await api.post(`/credentials/${encodeURIComponent(id)}/test`);
71
- return res?.ok === false ? res : { ok: true };
71
+ return res?.ok === false ? res : { ok: true, message: res?.message };
72
72
  } catch (e) {
73
73
  return { ok: false, error: captureError(e, t("integration.credentials.error.testFailed")) };
74
74
  }
@@ -4,14 +4,17 @@ import { useIntegrationsApi } from "./integrationsApi.js";
4
4
  import { useIntegrationsT } from "./useIntegrationsT.js";
5
5
  import { IntegrationsApiError } from "../utils/apiError.js";
6
6
  import { useLoadGate } from "./useLoadGate.js";
7
+ const RETRY_COOLDOWN_MS = 3e4;
7
8
  export function useIntegrationsNodes() {
8
9
  const loading = useState("integrations.nodes.loading", () => false);
9
10
  const error = useState("integrations.nodes.error", () => null);
10
11
  const loaded = useState("integrations.nodes.loaded", () => false);
12
+ const failedAt = useState("integrations.nodes.failedAt", () => 0);
11
13
  const gate = useLoadGate("integrations.nodes", { loading, loaded });
12
14
  const api = useIntegrationsApi();
13
15
  const t = useIntegrationsT();
14
16
  async function load(force = false) {
17
+ if (!force && failedAt.value && Date.now() - failedAt.value < RETRY_COOLDOWN_MS) return;
15
18
  if (!await gate.claim(force)) return;
16
19
  error.value = null;
17
20
  try {
@@ -23,12 +26,19 @@ export function useIntegrationsNodes() {
23
26
  version: n.manifest.version ?? n.version,
24
27
  name: n.manifest.name ?? n.name,
25
28
  // Keep the source package — the palette groups app nodes by it.
26
- __package: n.package ? { name: n.package.name, label: n.package.label ?? null } : void 0
29
+ __package: n.package ? { name: n.package.name, label: n.package.label ?? null } : void 0,
30
+ // Registered and built are two different things: a version whose code
31
+ // was never built can be drawn but not executed, so the palette must
32
+ // be able to leave it out (PO-338). Missing field = assume built.
33
+ __hasBundle: n.has_bundle !== false
27
34
  }));
28
35
  applyNodeCatalog(manifests);
29
36
  loaded.value = true;
37
+ failedAt.value = 0;
30
38
  } catch (e) {
31
39
  applyNodeCatalog([]);
40
+ loaded.value = false;
41
+ failedAt.value = Date.now();
32
42
  error.value = e instanceof IntegrationsApiError ? e.message : e?.message ?? t("integration.nodes.error.loadFailed");
33
43
  } finally {
34
44
  gate.release();
@@ -1,7 +1,8 @@
1
1
  import type { ScheduleEntry } from '../types/integrations.js';
2
2
  /**
3
- * Derived view — every trigger of type `schedule` across all workflows,
4
- * sourced from the real triggers sub-resource (useIntegrationsTriggers).
3
+ * Derived view — every trigger of type `schedule` across all workflows, as
4
+ * `useIntegrationsTriggers` holds them: read out of the workflow list, which carries
5
+ * each workflow's triggers, rather than fetched per workflow (PO-236).
5
6
  * Next/last-run timestamps are mocked deterministically (no telemetry
6
7
  * endpoint yet).
7
8
  *
@@ -1,9 +1,14 @@
1
1
  import type { TriggerType } from '../types/integrations.js';
2
+ import type { ApiTriggerLite } from './useIntegrationsWorkflows.js';
2
3
  /**
3
4
  * Workflow triggers — a per-workflow sub-resource in the Integrations service
4
- * (`/workflows/{id}/triggers`), NOT part of the workflow blob. The global
5
- * Schedules / Webhooks views fan `loadAll()` out across the tenant's workflows
6
- * and filter by `type`.
5
+ * (`/workflows/{id}/triggers`), NOT part of the workflow blob. `list()`, `create()`,
6
+ * `update()` and `remove()` address that sub-resource, one workflow at a time, which
7
+ * is how the editor's trigger settings work.
8
+ *
9
+ * `loadAll()` does NOT: the workflow list carries each workflow's triggers in full,
10
+ * so the tenant-wide view is derived from that response rather than fetched per
11
+ * workflow (PO-236). What it still costs is the workflow list, once.
7
12
  */
8
13
  export interface ApiTrigger {
9
14
  id: number;
@@ -26,6 +31,21 @@ export interface TriggerInput {
26
31
  config?: Record<string, unknown> | null;
27
32
  active?: boolean;
28
33
  }
34
+ /**
35
+ * The tenant's triggers, read off the workflow list rather than fetched per workflow
36
+ * (PO-236). Pure, and exported for that reason: it is the whole of what the tenant-wide
37
+ * view now does, and the composable around it needs a Nuxt app that the unit setup has
38
+ * no environment for.
39
+ *
40
+ * `byWorkflow` holds the triggers as the API sent them (`useIntegrationsWorkflows`
41
+ * keeps them beside the cockpit model, whose `id` is the handle). Two fields are not
42
+ * in there and are filled from the workflow the triggers arrived with: `workflow_id`,
43
+ * which every row id and route is built from, and `workflowName`, which the views show.
44
+ */
45
+ export declare function tenantTriggers(workflows: {
46
+ id: number;
47
+ name: string;
48
+ }[], byWorkflow: Record<number, ApiTriggerLite[]>): TriggerWithWorkflow[];
29
49
  export declare function useIntegrationsTriggers(): {
30
50
  all: any;
31
51
  loading: any;
@@ -2,6 +2,19 @@ import { useIntegrationsApi } from "./integrationsApi.js";
2
2
  import { useIntegrationsT } from "./useIntegrationsT.js";
3
3
  import { IntegrationsApiError } from "../utils/apiError.js";
4
4
  import { useLoadGate } from "./useLoadGate.js";
5
+ export function tenantTriggers(workflows, byWorkflow) {
6
+ return workflows.flatMap((wf) => (byWorkflow[wf.id] ?? []).flatMap((t) => {
7
+ if (typeof t.id !== "number") return [];
8
+ return [{
9
+ ...t,
10
+ id: t.id,
11
+ workflow_id: wf.id,
12
+ workflowName: wf.name,
13
+ handle: t.handle ?? "",
14
+ active: t.active ?? true
15
+ }];
16
+ }));
17
+ }
5
18
  export function useIntegrationsTriggers() {
6
19
  const all = useState("integrations.triggers.all", () => []);
7
20
  const loading = useState("integrations.triggers.loading", () => false);
@@ -9,7 +22,7 @@ export function useIntegrationsTriggers() {
9
22
  const loaded = useState("integrations.triggers.loaded", () => false);
10
23
  const api = useIntegrationsApi();
11
24
  const t = useIntegrationsT();
12
- const { workflows, load: loadWorkflows, loaded: workflowsLoaded } = useIntegrationsWorkflows();
25
+ const { workflows, apiTriggers, load: loadWorkflows, loaded: workflowsLoaded, error: workflowsError } = useIntegrationsWorkflows();
13
26
  const gate = useLoadGate("integrations.triggers", { loading, loaded });
14
27
  function unwrapList(res) {
15
28
  if (Array.isArray(res)) return res;
@@ -22,12 +35,10 @@ export function useIntegrationsTriggers() {
22
35
  if (!await gate.claim(force)) return all.value;
23
36
  error.value = null;
24
37
  try {
25
- await loadWorkflows();
26
- const settled = await Promise.allSettled(
27
- workflows.value.map(async (wf) => (await list(wf.id)).map((t2) => ({ ...t2, workflowName: wf.name })))
28
- );
29
- all.value = settled.flatMap((p) => p.status === "fulfilled" ? p.value : []);
30
- loaded.value = workflowsLoaded.value && settled.every((p) => p.status === "fulfilled");
38
+ await loadWorkflows(force);
39
+ all.value = tenantTriggers(workflows.value, apiTriggers.value);
40
+ error.value = workflowsError.value;
41
+ loaded.value = workflowsLoaded.value && !workflowsError.value;
31
42
  } catch (e) {
32
43
  error.value = e instanceof IntegrationsApiError ? e.message : e?.message ?? t("integration.triggers.error.loadFailed");
33
44
  } finally {
@@ -1,9 +1,18 @@
1
- import type { Workflow, WorkflowBlob, WorkflowExecutionMode } from '../types/integrations.js';
1
+ import type { TriggerType, Workflow, WorkflowBlob, WorkflowExecutionMode } from '../types/integrations.js';
2
+ export interface ApiTriggerLite {
3
+ id?: number;
4
+ handle?: string;
5
+ type: TriggerType;
6
+ name?: string | Record<string, string> | null;
7
+ config?: Record<string, unknown> | null;
8
+ active?: boolean;
9
+ }
2
10
  export declare function useIntegrationsWorkflows(): {
3
11
  workflows: any;
4
12
  loading: any;
5
13
  error: any;
6
14
  loaded: any;
15
+ apiTriggers: any;
7
16
  total: import("vue").ComputedRef<any>;
8
17
  activeCount: import("vue").ComputedRef<any>;
9
18
  load: (force?: boolean) => Promise<Workflow[]>;
@@ -82,6 +82,7 @@ export function useIntegrationsWorkflows() {
82
82
  const loading = useState("integrations.workflows.loading", () => false);
83
83
  const error = useState("integrations.workflows.error", () => null);
84
84
  const loaded = useState("integrations.workflows.loaded", () => false);
85
+ const apiTriggers = useState("integrations.workflows.apiTriggers", () => ({}));
85
86
  const gate = useLoadGate("integrations.workflows", { loading, loaded });
86
87
  const api = useIntegrationsApi();
87
88
  const t = useIntegrationsT();
@@ -92,6 +93,24 @@ export function useIntegrationsWorkflows() {
92
93
  if (e instanceof IntegrationsApiError) return e.message;
93
94
  return e?.message ?? fallback;
94
95
  }
96
+ function remember(api2, into) {
97
+ if (api2.triggers !== void 0) into[Number(api2.id)] = api2.triggers;
98
+ }
99
+ function ingest(api2) {
100
+ const next = { ...apiTriggers.value };
101
+ remember(api2, next);
102
+ apiTriggers.value = next;
103
+ return toCockpit(api2);
104
+ }
105
+ function ingestList(list2) {
106
+ const next = { ...apiTriggers.value };
107
+ const out = list2.map((api2) => {
108
+ remember(api2, next);
109
+ return toCockpit(api2);
110
+ });
111
+ apiTriggers.value = next;
112
+ return out;
113
+ }
95
114
  function upsertCache(wf) {
96
115
  const idx = workflows.value.findIndex((w) => w.id === wf.id);
97
116
  workflows.value = idx < 0 ? [...workflows.value, wf] : workflows.value.map((w) => w.id === wf.id ? wf : w);
@@ -101,10 +120,11 @@ export function useIntegrationsWorkflows() {
101
120
  error.value = null;
102
121
  try {
103
122
  const list2 = unwrap(await api.get("/workflows")) ?? [];
104
- workflows.value = list2.map(toCockpit);
123
+ workflows.value = ingestList(list2);
105
124
  loaded.value = true;
106
125
  } catch (e) {
107
126
  error.value = captureError(e, t("integration.workflows.error.loadFailed"));
127
+ loaded.value = false;
108
128
  } finally {
109
129
  gate.release();
110
130
  }
@@ -116,7 +136,7 @@ export function useIntegrationsWorkflows() {
116
136
  }
117
137
  async function get(id) {
118
138
  try {
119
- const wf = toCockpit(unwrap(await api.get(`/workflows/${id}`)));
139
+ const wf = ingest(unwrap(await api.get(`/workflows/${id}`)));
120
140
  upsertCache(wf);
121
141
  return wf;
122
142
  } catch {
@@ -124,7 +144,7 @@ export function useIntegrationsWorkflows() {
124
144
  }
125
145
  }
126
146
  async function create(input) {
127
- const wf = toCockpit(unwrap(await api.post("/workflows", toPayload({
147
+ const wf = ingest(unwrap(await api.post("/workflows", toPayload({
128
148
  name: input.name,
129
149
  description: input.description,
130
150
  blob: input.blob,
@@ -175,7 +195,7 @@ export function useIntegrationsWorkflows() {
175
195
  throw lastError instanceof Error ? lastError : new Error(t("integration.workflows.error.duplicateFailed"));
176
196
  }
177
197
  async function update(id, patch) {
178
- const wf = toCockpit(unwrap(await api.put(`/workflows/${id}`, toPayload({
198
+ const wf = ingest(unwrap(await api.put(`/workflows/${id}`, toPayload({
179
199
  name: patch.name,
180
200
  description: patch.description,
181
201
  blob: patch.blob,
@@ -201,6 +221,7 @@ export function useIntegrationsWorkflows() {
201
221
  loading,
202
222
  error,
203
223
  loaded,
224
+ apiTriggers,
204
225
  total,
205
226
  activeCount,
206
227
  load,
@@ -1,4 +1,5 @@
1
1
  import { computed, onScopeDispose, ref, toValue, watch } from "vue";
2
+ import { isVersionExecutable } from "../data/integrationNodeCatalog.js";
2
3
  import { useIntegrationsApi } from "./integrationsApi.js";
3
4
  import { IntegrationsApiError } from "../utils/apiError.js";
4
5
  export function isResolvableValue(v) {
@@ -54,7 +55,7 @@ export function useNodeConfigResolve(opts) {
54
55
  });
55
56
  const gated = computed(() => unsatisfied.value.length > 0);
56
57
  const isEnabled = computed(
57
- () => (toValue(opts.enabled) ?? true) && !gated.value && !!toValue(opts.slug) && !!toValue(opts.version)
58
+ () => (toValue(opts.enabled) ?? true) && isVersionExecutable(toValue(opts.slug), toValue(opts.version)) && !gated.value && !!toValue(opts.slug) && !!toValue(opts.version)
58
59
  );
59
60
  const subsetKey = computed(() => {
60
61
  const cfg = toValue(opts.config);