@revenexx/studio-integrations 0.5.1 → 0.7.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 (171) hide show
  1. package/CHANGELOG.md +428 -0
  2. package/README.md +48 -4
  3. package/dist/module.d.mts +17 -1
  4. package/dist/module.json +1 -1
  5. package/dist/module.mjs +10 -2
  6. package/dist/runtime/components/Integrations/CredentialCreateDialog.vue +43 -41
  7. package/dist/runtime/components/Integrations/CredentialDetailDialog.vue +123 -50
  8. package/dist/runtime/components/Integrations/CredentialRevenexxSource.vue +108 -71
  9. package/dist/runtime/components/Integrations/CredentialTypePicker.vue +12 -15
  10. package/dist/runtime/components/Integrations/DeadLettersPanel.vue +18 -17
  11. package/dist/runtime/components/Integrations/EventTypeCombobox.d.vue.ts +1 -0
  12. package/dist/runtime/components/Integrations/EventTypeCombobox.vue +7 -4
  13. package/dist/runtime/components/Integrations/EventTypeCombobox.vue.d.ts +1 -0
  14. package/dist/runtime/components/Integrations/ExpressionInput.d.vue.ts +4 -0
  15. package/dist/runtime/components/Integrations/ExpressionInput.vue +67 -37
  16. package/dist/runtime/components/Integrations/ExpressionInput.vue.d.ts +4 -0
  17. package/dist/runtime/components/Integrations/GroupFrameView.vue +30 -20
  18. package/dist/runtime/components/Integrations/Layout.vue +48 -4
  19. package/dist/runtime/components/Integrations/NodeConfigDynamicField.vue +23 -23
  20. package/dist/runtime/components/Integrations/NodeConfigDynamicSchema.vue +11 -13
  21. package/dist/runtime/components/Integrations/NodeConfigField.d.vue.ts +2 -0
  22. package/dist/runtime/components/Integrations/NodeConfigField.vue +187 -93
  23. package/dist/runtime/components/Integrations/NodeConfigField.vue.d.ts +2 -0
  24. package/dist/runtime/components/Integrations/NodeInspector.d.vue.ts +6 -0
  25. package/dist/runtime/components/Integrations/NodeInspector.vue +1157 -376
  26. package/dist/runtime/components/Integrations/NodeInspector.vue.d.ts +6 -0
  27. package/dist/runtime/components/Integrations/NodeLogo.d.vue.ts +12 -0
  28. package/dist/runtime/components/Integrations/NodeLogo.vue +2 -30
  29. package/dist/runtime/components/Integrations/NodeLogo.vue.d.ts +12 -0
  30. package/dist/runtime/components/Integrations/NodeMark.d.vue.ts +15 -0
  31. package/dist/runtime/components/Integrations/NodeMark.vue +79 -0
  32. package/dist/runtime/components/Integrations/NodeMark.vue.d.ts +15 -0
  33. package/dist/runtime/components/Integrations/NodeOutputSchema.d.vue.ts +21 -0
  34. package/dist/runtime/components/Integrations/NodeOutputSchema.vue +43 -0
  35. package/dist/runtime/components/Integrations/NodeOutputSchema.vue.d.ts +21 -0
  36. package/dist/runtime/components/Integrations/NodePalette.vue +122 -55
  37. package/dist/runtime/components/Integrations/QueryBuilder.vue +46 -38
  38. package/dist/runtime/components/Integrations/RunActionsMenu.vue +38 -12
  39. package/dist/runtime/components/Integrations/RunDetailPanel.vue +114 -62
  40. package/dist/runtime/components/Integrations/RunPayloadInput.vue +27 -13
  41. package/dist/runtime/components/Integrations/RunResponseModal.vue +11 -10
  42. package/dist/runtime/components/Integrations/RunsTable.d.vue.ts +0 -1
  43. package/dist/runtime/components/Integrations/RunsTable.vue +38 -16
  44. package/dist/runtime/components/Integrations/RunsTable.vue.d.ts +0 -1
  45. package/dist/runtime/components/Integrations/SftpAccountImportDialog.vue +8 -7
  46. package/dist/runtime/components/Integrations/Sidebar.vue +14 -10
  47. package/dist/runtime/components/Integrations/StickyNoteView.d.vue.ts +14 -4
  48. package/dist/runtime/components/Integrations/StickyNoteView.vue +38 -23
  49. package/dist/runtime/components/Integrations/StickyNoteView.vue.d.ts +14 -4
  50. package/dist/runtime/components/Integrations/TemplateCard.d.vue.ts +18 -0
  51. package/dist/runtime/components/Integrations/TemplateCard.vue +119 -0
  52. package/dist/runtime/components/Integrations/TemplateCard.vue.d.ts +18 -0
  53. package/dist/runtime/components/Integrations/TemplateFlowPreview.d.vue.ts +2 -2
  54. package/dist/runtime/components/Integrations/TemplateFlowPreview.vue +16 -18
  55. package/dist/runtime/components/Integrations/TemplateFlowPreview.vue.d.ts +2 -2
  56. package/dist/runtime/components/Integrations/WorkflowContextMenu.vue +34 -33
  57. package/dist/runtime/components/Integrations/WorkflowCreateDialog.vue +52 -67
  58. package/dist/runtime/components/Integrations/WorkflowDuplicateDialog.vue +8 -7
  59. package/dist/runtime/components/Integrations/WorkflowEdgeView.d.vue.ts +18 -4
  60. package/dist/runtime/components/Integrations/WorkflowEdgeView.vue +29 -11
  61. package/dist/runtime/components/Integrations/WorkflowEdgeView.vue.d.ts +18 -4
  62. package/dist/runtime/components/Integrations/WorkflowEditor.d.vue.ts +20 -1
  63. package/dist/runtime/components/Integrations/WorkflowEditor.vue +240 -116
  64. package/dist/runtime/components/Integrations/WorkflowEditor.vue.d.ts +20 -1
  65. package/dist/runtime/components/Integrations/WorkflowHistoryPanel.vue +28 -43
  66. package/dist/runtime/components/Integrations/WorkflowIconPicker.d.vue.ts +19 -1
  67. package/dist/runtime/components/Integrations/WorkflowIconPicker.vue +17 -7
  68. package/dist/runtime/components/Integrations/WorkflowIconPicker.vue.d.ts +19 -1
  69. package/dist/runtime/components/Integrations/WorkflowNodeView.d.vue.ts +4 -0
  70. package/dist/runtime/components/Integrations/WorkflowNodeView.vue +198 -56
  71. package/dist/runtime/components/Integrations/WorkflowNodeView.vue.d.ts +4 -0
  72. package/dist/runtime/components/Integrations/WorkflowRunReplayPanel.vue +56 -59
  73. package/dist/runtime/components/Integrations/WorkflowSettingsDialog.vue +28 -21
  74. package/dist/runtime/components/Integrations/WorkflowTagsInput.d.vue.ts +1 -0
  75. package/dist/runtime/components/Integrations/WorkflowTagsInput.vue +15 -9
  76. package/dist/runtime/components/Integrations/WorkflowTagsInput.vue.d.ts +1 -0
  77. package/dist/runtime/components/Integrations/WorkflowTriggerView.vue +15 -10
  78. package/dist/runtime/components/Integrations/WorkflowVariablesDialog.vue +50 -36
  79. package/dist/runtime/components/Integrations/WorkflowsTable.vue +126 -48
  80. package/dist/runtime/composables/integrationsApi.js +4 -2
  81. package/dist/runtime/composables/useCredentialTypes.js +8 -6
  82. package/dist/runtime/composables/useIntegrationsAudit.js +4 -3
  83. package/dist/runtime/composables/useIntegrationsCredentials.js +10 -8
  84. package/dist/runtime/composables/useIntegrationsDeadLetters.js +7 -6
  85. package/dist/runtime/composables/useIntegrationsFocus.d.ts +12 -0
  86. package/dist/runtime/composables/useIntegrationsLocale.d.ts +26 -0
  87. package/dist/runtime/composables/useIntegrationsLocale.js +8 -0
  88. package/dist/runtime/composables/useIntegrationsNodes.js +7 -5
  89. package/dist/runtime/composables/useIntegrationsRealtime.d.ts +149 -0
  90. package/dist/runtime/composables/useIntegrationsRealtime.js +80 -0
  91. package/dist/runtime/composables/useIntegrationsRevisions.js +9 -7
  92. package/dist/runtime/composables/useIntegrationsRuns.d.ts +1 -1
  93. package/dist/runtime/composables/useIntegrationsRuns.js +14 -7
  94. package/dist/runtime/composables/useIntegrationsSchedules.d.ts +4 -0
  95. package/dist/runtime/composables/useIntegrationsSchedules.js +10 -3
  96. package/dist/runtime/composables/useIntegrationsSecrets.js +14 -9
  97. package/dist/runtime/composables/useIntegrationsT.d.ts +35 -0
  98. package/dist/runtime/composables/useIntegrationsT.js +9 -0
  99. package/dist/runtime/composables/useIntegrationsTemplates.d.ts +3 -3
  100. package/dist/runtime/composables/useIntegrationsTemplates.js +28 -16
  101. package/dist/runtime/composables/useIntegrationsTriggers.js +10 -8
  102. package/dist/runtime/composables/useIntegrationsWorkflows.js +13 -7
  103. package/dist/runtime/composables/useLoadGate.d.ts +17 -0
  104. package/dist/runtime/composables/useLoadGate.js +8 -0
  105. package/dist/runtime/composables/useNodeConfigResolve.js +2 -1
  106. package/dist/runtime/data/branchReorder.d.ts +37 -0
  107. package/dist/runtime/data/branchReorder.js +71 -0
  108. package/dist/runtime/data/handleId.d.ts +23 -0
  109. package/dist/runtime/data/handleId.js +6 -0
  110. package/dist/runtime/data/integrationNodeCatalog.d.ts +41 -0
  111. package/dist/runtime/data/integrationNodeCatalog.js +14 -0
  112. package/dist/runtime/data/integrationTemplates.d.ts +48 -0
  113. package/dist/runtime/data/integrationTemplates.js +21 -0
  114. package/dist/runtime/data/integrationWorkflowSamples.js +1 -1
  115. package/dist/runtime/data/nodeIcons.d.ts +2 -0
  116. package/dist/runtime/data/nodeIcons.js +73 -0
  117. package/dist/runtime/data/nodeInputData.d.ts +16 -4
  118. package/dist/runtime/data/nodeInputData.js +4 -0
  119. package/dist/runtime/data/payloadForm.d.ts +9 -2
  120. package/dist/runtime/data/payloadForm.js +29 -4
  121. package/dist/runtime/data/portTone.d.ts +17 -0
  122. package/dist/runtime/data/portTone.js +7 -0
  123. package/dist/runtime/data/webhookAuth.d.ts +18 -0
  124. package/dist/runtime/data/webhookAuth.js +19 -0
  125. package/dist/runtime/data/workflowIconCatalog.js +4 -6
  126. package/dist/runtime/i18n/messages/de.json +1207 -0
  127. package/dist/runtime/i18n/messages/en.json +1207 -0
  128. package/dist/runtime/i18n/translate.d.ts +83 -0
  129. package/dist/runtime/i18n/translate.js +41 -0
  130. package/dist/runtime/pages/integrations/audit.vue +140 -37
  131. package/dist/runtime/pages/integrations/credentials/oauth-complete.vue +9 -6
  132. package/dist/runtime/pages/integrations/credentials.vue +180 -64
  133. package/dist/runtime/pages/integrations/index.vue +91 -92
  134. package/dist/runtime/pages/integrations/runs.vue +33 -21
  135. package/dist/runtime/pages/integrations/schedules.vue +88 -23
  136. package/dist/runtime/pages/integrations/secrets.vue +57 -35
  137. package/dist/runtime/pages/integrations/templates/[slug].vue +119 -88
  138. package/dist/runtime/pages/integrations/templates/index.vue +82 -103
  139. package/dist/runtime/pages/integrations/workflows/[id].vue +344 -91
  140. package/dist/runtime/pages/integrations/workflows/index.vue +23 -5
  141. package/dist/runtime/plugins/locale.d.ts +10 -0
  142. package/dist/runtime/plugins/locale.js +6 -0
  143. package/dist/runtime/plugins/realtime.client.d.ts +32 -0
  144. package/dist/runtime/plugins/realtime.client.js +4 -0
  145. package/dist/runtime/types/integrations.d.ts +65 -6
  146. package/dist/runtime/utils/apiError.d.ts +47 -0
  147. package/dist/runtime/utils/apiError.js +32 -0
  148. package/dist/runtime/utils/cockpitSources.d.ts +153 -0
  149. package/dist/runtime/utils/cockpitSources.js +14 -0
  150. package/dist/runtime/utils/credentialState.d.ts +24 -1
  151. package/dist/runtime/utils/credentialState.js +26 -11
  152. package/dist/runtime/utils/loadGate.d.ts +61 -0
  153. package/dist/runtime/utils/loadGate.js +22 -0
  154. package/dist/runtime/utils/localize.d.ts +46 -4
  155. package/dist/runtime/utils/localize.js +34 -7
  156. package/dist/runtime/utils/notify.d.ts +59 -0
  157. package/dist/runtime/utils/notify.js +21 -0
  158. package/dist/runtime/utils/pollBudget.d.ts +72 -0
  159. package/dist/runtime/utils/pollBudget.js +17 -0
  160. package/dist/runtime/utils/revenexxCredentialSource.d.ts +26 -0
  161. package/dist/runtime/utils/revenexxCredentialSource.js +8 -0
  162. package/dist/runtime/utils/schedule.d.ts +116 -0
  163. package/dist/runtime/utils/schedule.js +219 -0
  164. package/dist/runtime/utils/statusStyle.d.ts +26 -2
  165. package/dist/runtime/utils/statusStyle.js +9 -1
  166. package/dist/runtime/utils/validationFindings.d.ts +101 -0
  167. package/dist/runtime/utils/validationFindings.js +54 -0
  168. package/dist/runtime/utils/whenIdle.d.ts +42 -0
  169. package/dist/runtime/utils/whenIdle.js +12 -0
  170. package/dist/types.d.mts +2 -6
  171. package/package.json +10 -3
@@ -1,11 +1,15 @@
1
1
  import { useIntegrationsApi } from "./integrationsApi.js";
2
+ import { useIntegrationsT } from "./useIntegrationsT.js";
2
3
  import { IntegrationsApiError } from "../utils/apiError.js";
4
+ import { useLoadGate } from "./useLoadGate.js";
3
5
  export function useCredentialTypes() {
4
6
  const types = useState("integrations.credentialTypes", () => []);
5
7
  const loading = useState("integrations.credentialTypes.loading", () => false);
6
8
  const error = useState("integrations.credentialTypes.error", () => null);
7
9
  const loaded = useState("integrations.credentialTypes.loaded", () => false);
10
+ const gate = useLoadGate("integrations.credentialTypes", { loading, loaded });
8
11
  const api = useIntegrationsApi();
12
+ const t = useIntegrationsT();
9
13
  function normalize(res) {
10
14
  const obj = res;
11
15
  if (Array.isArray(obj)) return obj;
@@ -17,22 +21,20 @@ export function useCredentialTypes() {
17
21
  return e?.message ?? fallback;
18
22
  }
19
23
  async function load(force = false) {
20
- if (loading.value) return types.value;
21
- if (loaded.value && !force) return types.value;
22
- loading.value = true;
24
+ if (!await gate.claim(force)) return types.value;
23
25
  error.value = null;
24
26
  try {
25
27
  types.value = normalize(await api.get("/credential-types"));
26
28
  loaded.value = true;
27
29
  } catch (e) {
28
- error.value = captureError(e, "Could not load credential types.");
30
+ error.value = captureError(e, t("integration.credentials.error.loadTypes"));
29
31
  } finally {
30
- loading.value = false;
32
+ gate.release();
31
33
  }
32
34
  return types.value;
33
35
  }
34
36
  function bySlug(slug) {
35
- return types.value.find((t) => t.slug === slug) ?? null;
37
+ return types.value.find((t2) => t2.slug === slug) ?? null;
36
38
  }
37
39
  async function get(slug) {
38
40
  const cached = bySlug(slug);
@@ -1,11 +1,13 @@
1
1
  import { useIntegrationsApi } from "./integrationsApi.js";
2
2
  import { IntegrationsApiError } from "../utils/apiError.js";
3
+ import { useLoadGate } from "./useLoadGate.js";
3
4
  export function useIntegrationsAudit() {
4
5
  const api = useIntegrationsApi();
5
6
  const entries = useState("int.audit.entries", () => []);
6
7
  const nextCursor = useState("int.audit.cursor", () => null);
7
8
  const loading = useState("int.audit.loading", () => false);
8
9
  const error = useState("int.audit.error", () => null);
10
+ const gate = useLoadGate("int.audit", { loading });
9
11
  function qs(filters, cursor) {
10
12
  const p = new URLSearchParams();
11
13
  if (filters.sub) p.set("sub", filters.sub);
@@ -16,8 +18,7 @@ export function useIntegrationsAudit() {
16
18
  return p.toString();
17
19
  }
18
20
  async function load(filters = {}, append = false) {
19
- if (loading.value) return;
20
- loading.value = true;
21
+ if (!await gate.claim()) return;
21
22
  error.value = null;
22
23
  try {
23
24
  const res = await api.get(
@@ -29,7 +30,7 @@ export function useIntegrationsAudit() {
29
30
  } catch (e) {
30
31
  error.value = e instanceof IntegrationsApiError ? e.message : e?.message ?? "Could not load the audit log.";
31
32
  } finally {
32
- loading.value = false;
33
+ gate.release();
33
34
  }
34
35
  }
35
36
  function loadMore(filters = {}) {
@@ -1,12 +1,16 @@
1
1
  import { computed } from "vue";
2
2
  import { useIntegrationsApi } from "./integrationsApi.js";
3
+ import { useIntegrationsT } from "./useIntegrationsT.js";
3
4
  import { IntegrationsApiError } from "../utils/apiError.js";
5
+ import { useLoadGate } from "./useLoadGate.js";
4
6
  export function useIntegrationsCredentials() {
5
7
  const credentials = useState("integrations.credentials", () => []);
6
8
  const loading = useState("integrations.credentials.loading", () => false);
7
9
  const error = useState("integrations.credentials.error", () => null);
8
10
  const loaded = useState("integrations.credentials.loaded", () => false);
11
+ const gate = useLoadGate("integrations.credentials", { loading, loaded });
9
12
  const api = useIntegrationsApi();
13
+ const t = useIntegrationsT();
10
14
  function normalize(res) {
11
15
  const obj = res;
12
16
  if (Array.isArray(obj)) return obj;
@@ -21,17 +25,15 @@ export function useIntegrationsCredentials() {
21
25
  return e?.message ?? fallback;
22
26
  }
23
27
  async function load(force = false) {
24
- if (loading.value) return credentials.value;
25
- if (loaded.value && !force) return credentials.value;
26
- loading.value = true;
28
+ if (!await gate.claim(force)) return credentials.value;
27
29
  error.value = null;
28
30
  try {
29
31
  credentials.value = normalize(await api.get("/credentials"));
30
32
  loaded.value = true;
31
33
  } catch (e) {
32
- error.value = captureError(e, "Could not load credentials.");
34
+ error.value = captureError(e, t("integration.credentials.error.loadFailed"));
33
35
  } finally {
34
- loading.value = false;
36
+ gate.release();
35
37
  }
36
38
  return credentials.value;
37
39
  }
@@ -60,7 +62,7 @@ export function useIntegrationsCredentials() {
60
62
  const res = await api.post("/credentials/test", { credential_type_slug: credentialTypeSlug, config });
61
63
  return res?.ok === false ? res : { ok: true };
62
64
  } catch (e) {
63
- return { ok: false, error: captureError(e, "Connection test failed.") };
65
+ return { ok: false, error: captureError(e, t("integration.credentials.error.testFailed")) };
64
66
  }
65
67
  }
66
68
  async function test(id) {
@@ -68,13 +70,13 @@ export function useIntegrationsCredentials() {
68
70
  const res = await api.post(`/credentials/${encodeURIComponent(id)}/test`);
69
71
  return res?.ok === false ? res : { ok: true };
70
72
  } catch (e) {
71
- return { ok: false, error: captureError(e, "Connection test failed.") };
73
+ return { ok: false, error: captureError(e, t("integration.credentials.error.testFailed")) };
72
74
  }
73
75
  }
74
76
  async function oauthAuthorizeUrl(id) {
75
77
  const res = await api.post(`/credentials/${encodeURIComponent(id)}/oauth/authorize-url`);
76
78
  const authorizeUrl = res?.authorize_url ?? res?.data?.authorize_url;
77
- if (!authorizeUrl) throw new IntegrationsApiError(0, res, "No authorize URL returned.");
79
+ if (!authorizeUrl) throw new IntegrationsApiError(0, res, t("integration.credentials.error.noAuthorizeUrl"));
78
80
  return authorizeUrl;
79
81
  }
80
82
  const total = computed(() => credentials.value.length);
@@ -1,5 +1,6 @@
1
1
  import { computed } from "vue";
2
2
  import { useIntegrationsApi } from "./integrationsApi.js";
3
+ import { useLoadGate } from "./useLoadGate.js";
3
4
  export function useIntegrationsDeadLetters() {
4
5
  const api = useIntegrationsApi();
5
6
  async function list(workflowId, opts = {}) {
@@ -17,15 +18,14 @@ export function useIntegrationsDeadLetters() {
17
18
  }
18
19
  const replay = (id) => api.post(`/dead-letters/${id}/replay`, {});
19
20
  const discard = (id) => api.post(`/dead-letters/${id}/discard`, {});
20
- const { workflows, load: loadWorkflows } = useIntegrationsWorkflows();
21
+ const { workflows, load: loadWorkflows, loaded: workflowsLoaded } = useIntegrationsWorkflows();
21
22
  const globalItems = useState("integrations.deadletters.global", () => []);
22
23
  const globalLoading = useState("integrations.deadletters.global.loading", () => false);
23
24
  const globalLoaded = useState("integrations.deadletters.global.loaded", () => false);
25
+ const gate = useLoadGate("integrations.deadletters.global", { loading: globalLoading, loaded: globalLoaded });
24
26
  const pendingCount = computed(() => globalItems.value.filter((i) => i.status === "pending").length);
25
27
  async function loadGlobal(force = false) {
26
- if (globalLoading.value) return globalItems.value;
27
- if (globalLoaded.value && !force) return globalItems.value;
28
- globalLoading.value = true;
28
+ if (!await gate.claim(force)) return globalItems.value;
29
29
  try {
30
30
  await loadWorkflows();
31
31
  const MAX_PAGES = 20;
@@ -41,10 +41,11 @@ export function useIntegrationsDeadLetters() {
41
41
  return out;
42
42
  }));
43
43
  globalItems.value = per.flatMap((p) => p.status === "fulfilled" ? p.value : []).sort((a, b) => new Date(b.created_at ?? 0).getTime() - new Date(a.created_at ?? 0).getTime());
44
- globalLoaded.value = true;
44
+ globalLoaded.value = workflowsLoaded.value && per.every((p) => p.status === "fulfilled");
45
45
  } finally {
46
- globalLoading.value = false;
46
+ gate.release();
47
47
  }
48
+ return globalItems.value;
48
49
  }
49
50
  return { list, replay, discard, loadGlobal, globalItems, globalLoading, pendingCount };
50
51
  }
@@ -6,6 +6,18 @@
6
6
  * Pages opt in by calling `useIntegrationsFocus().enable()` in onMounted
7
7
  * and `disable()` in onUnmounted (we provide an `autoEnable()` helper
8
8
  * that wires both).
9
+ *
10
+ * **A page that hides the sidebar renders the control that brings it back** — see
11
+ * specs/studio-navigation.md. The control belongs to the page and not to the
12
+ * shell, because the cockpit ships its own shell and reads this same state: a
13
+ * control in the module's shell would be missing in the cockpit, which is the
14
+ * one-way switch PO-273 was filed about.
15
+ *
16
+ * `autoEnable()` *is* that one-way switch, and `toggle()` has no caller: the
17
+ * workflow editor derives the state from a remembered preference and applies it
18
+ * with `enable()`/`disable()`, which a derived state can do idempotently and a
19
+ * flip cannot. Both are kept because they are part of what this module publishes,
20
+ * and a host driving the fold itself may still want them.
9
21
  */
10
22
  export declare function useIntegrationsFocus(): {
11
23
  focused: any;
@@ -0,0 +1,26 @@
1
+ import type { ComputedRef } from 'vue';
2
+ /**
3
+ * The studio's interface language, for a host to read and to set.
4
+ *
5
+ * This is the seam between the application the studio is embedded in and the
6
+ * text the Integrations API returns. The module is published and installed into
7
+ * hosts it knows nothing about, so it never reads a browser store or a cockpit
8
+ * i18n instance: the host owns the choice and hands it over here.
9
+ *
10
+ * A host with a language switcher wires it once, at boot:
11
+ *
12
+ * const { setLocale } = useIntegrationsLocale()
13
+ * watch(uiLocale, l => setLocale(l), { immediate: true })
14
+ *
15
+ * A host with a fixed language sets `studioIntegrations.locale` in its Nuxt
16
+ * config and never calls this. Neither is required — with nothing set the
17
+ * studio renders English.
18
+ *
19
+ * `locale` is reactive, and so is every name, label and description on screen:
20
+ * they are resolved through the same value, so a switch reaches surfaces that
21
+ * are already mounted without reloading anything.
22
+ */
23
+ export declare function useIntegrationsLocale(): {
24
+ locale: ComputedRef<string>;
25
+ setLocale: (locale: string | null | undefined) => void;
26
+ };
@@ -0,0 +1,8 @@
1
+ import { computed } from "vue";
2
+ import { setStudioLocale, studioLocale } from "../utils/localize.js";
3
+ export function useIntegrationsLocale() {
4
+ return {
5
+ locale: computed(() => studioLocale()),
6
+ setLocale: setStudioLocale
7
+ };
8
+ }
@@ -1,16 +1,18 @@
1
1
  import { applyNodeCatalog, findManifest } from "../data/integrationNodeCatalog.js";
2
2
  import { resolveLocalized } from "../utils/localize.js";
3
3
  import { useIntegrationsApi } from "./integrationsApi.js";
4
+ import { useIntegrationsT } from "./useIntegrationsT.js";
4
5
  import { IntegrationsApiError } from "../utils/apiError.js";
6
+ import { useLoadGate } from "./useLoadGate.js";
5
7
  export function useIntegrationsNodes() {
6
8
  const loading = useState("integrations.nodes.loading", () => false);
7
9
  const error = useState("integrations.nodes.error", () => null);
8
10
  const loaded = useState("integrations.nodes.loaded", () => false);
11
+ const gate = useLoadGate("integrations.nodes", { loading, loaded });
9
12
  const api = useIntegrationsApi();
13
+ const t = useIntegrationsT();
10
14
  async function load(force = false) {
11
- if (loading.value) return;
12
- if (loaded.value && !force) return;
13
- loading.value = true;
15
+ if (!await gate.claim(force)) return;
14
16
  error.value = null;
15
17
  try {
16
18
  const res = await api.get("/nodes");
@@ -27,9 +29,9 @@ export function useIntegrationsNodes() {
27
29
  loaded.value = true;
28
30
  } catch (e) {
29
31
  applyNodeCatalog([]);
30
- error.value = e instanceof IntegrationsApiError ? e.message : e?.message ?? "Could not load nodes.";
32
+ error.value = e instanceof IntegrationsApiError ? e.message : e?.message ?? t("integration.nodes.error.loadFailed");
31
33
  } finally {
32
- loading.value = false;
34
+ gate.release();
33
35
  }
34
36
  }
35
37
  return { loading, error, loaded, load, findManifest, resolveLocalized };
@@ -0,0 +1,149 @@
1
+ import type { ComputedRef, Ref } from 'vue';
2
+ /**
3
+ * The Studio's single Talkback subscriber.
4
+ *
5
+ * The integrations service publishes its run, workflow and bundle-build lifecycle
6
+ * onto the platform event bus (`App\Enums\OutboxTopic` → `event_outbox` → the relay
7
+ * → NATS → the Talkback bridge → Centrifugo), so a browser can learn that a run
8
+ * finished — or that a build is done — instead of asking every three seconds. This
9
+ * is the browser end of that chain, and everything it hands out is a **refetch
10
+ * signal**: the envelope carries no run payload — the bus has a 1 MB cap — so "the
11
+ * data arrived over the socket" was never on offer, and every surface still reloads
12
+ * through its own loader.
13
+ *
14
+ * ONE SUBSCRIBER FOR THE WHOLE APPLICATION, and this is the part that is not a
15
+ * style preference. `talkback-js` deduplicates on `envelope.id` **across the whole
16
+ * client**, before it dispatches to a channel's listeners (`src/core/talkback.ts`,
17
+ * `seen.admit`). The bridge publishes every event twice — once to the action
18
+ * channel, once to the resource channel, which is contractual (ADR-0094) — so if
19
+ * the runs grid took the action channel and an open detail panel took the resource
20
+ * channel, only whichever arrived first would fire and the other would silently
21
+ * never refetch. Two components cannot each own a subscription to the same event.
22
+ * So the channels are owned here, once, and the surfaces watch the signals below.
23
+ *
24
+ * ONLY ACTION CHANNELS, for the same reason: subscribing to just one of the two
25
+ * channels an event lands on makes each event exactly one delivery, with nothing
26
+ * to lose the race against. A resource channel would buy a narrower filter that
27
+ * `lastRunEvent.topicId` already provides.
28
+ *
29
+ * ONE ACTION IS ONE CHANNEL. The action is the fourth topic segment and Centrifugo
30
+ * has no wildcards, so watching the five run actions costs five subscriptions, and
31
+ * the eleven topics the service emits cost eleven for the whole application. The
32
+ * three-segment form (`revenexx.integrations.run`) is the resource KIND channel,
33
+ * which the bridge never publishes to.
34
+ *
35
+ * SAFE WHEN REALTIME IS OFF, which is the production state in the cockpit until
36
+ * the edge is shown to pass a WebSocket upgrade (PE-382 T0.2). With no
37
+ * `talkbackHost` configured nothing is subscribed, `delivering` stays false, and
38
+ * every caller keeps the polling it had — as it does for a tenant that is
39
+ * configured but whose channels never carry anything.
40
+ */
41
+ /** Mirrors `App\Enums\OutboxTopic` in `revenexx/integrations`. */
42
+ export declare const RUN_TOPIC = "revenexx.integrations.run";
43
+ export declare const WORKFLOW_TOPIC = "revenexx.integrations.workflow";
44
+ /**
45
+ * The bundle build. Its own entity rather than `workflow.build_started`, because
46
+ * the service's envelope allows no underscore in a topic segment and Console's
47
+ * manifest schema allows no hyphen — `build.started` is the only form that passes
48
+ * both. `topic_id` is still the WORKFLOW's id: a build has no row of its own.
49
+ */
50
+ export declare const BUILD_TOPIC = "revenexx.integrations.build";
51
+ /** `run.cancelled` also carries a `terminated` status — the two collapse into one topic. */
52
+ export declare const RUN_ACTIONS: readonly ["created", "started", "finished", "failed", "cancelled"];
53
+ /**
54
+ * There is no `run.deleted`. Runs cascade with their workflow inside Postgres
55
+ * without firing model events, which is why `workflow.deleted` carries
56
+ * `cascades_runs: true` instead.
57
+ */
58
+ export declare const WORKFLOW_ACTIONS: readonly ["created", "updated", "deleted"];
59
+ /**
60
+ * `started` is emitted per queue ATTEMPT, and the job has three: a failing build
61
+ * sends started/failed up to three times, with `data.attempt` (1-based) as the
62
+ * only way to tell a final failure from one that is about to be retried.
63
+ */
64
+ export declare const BUILD_ACTIONS: readonly ["started", "succeeded", "failed"];
65
+ /** What a surface needs off an envelope. A structural subset of talkback-js's `Envelope`. */
66
+ export interface RealtimeEvent {
67
+ /** `evt_<ulid>`, the client's deduplication key. Carried for logging. */
68
+ id: string;
69
+ /** The fourth topic segment: `finished`, `deleted`, … */
70
+ action: string;
71
+ /** The run's or workflow's primary key, as a string. Never a number — see OutboxEnvelope. */
72
+ topicId: string | null;
73
+ /** The event's `data` map, e.g. `cascades_runs` on `workflow.deleted`. */
74
+ data: Record<string, unknown>;
75
+ }
76
+ export interface IntegrationsRealtime {
77
+ /**
78
+ * Whether this connection has actually reported something, and therefore
79
+ * whether a poll may take the long budget (`pollBudget.ts`).
80
+ *
81
+ * IT COUNTS DELIVERIES, NOT CONFIGURATION, and that is the whole reason it is
82
+ * not called `live`. Being subscribed is no evidence of anything: a channel
83
+ * resolves from the LOCAL subscription, and a tenant whose Console event
84
+ * catalogue does not mark these topics `channel: true` holds eleven channels
85
+ * that will never carry a byte (PE-375). Spending that state as a delivery
86
+ * signal would thin every run surface's refresh by ten with nothing arriving to
87
+ * make up for it — ten times worse than before any of this existed, silently.
88
+ * So the budget is only thinned once something has really been delivered.
89
+ *
90
+ * What it still cannot say is whether delivery is *continuing*: a socket that
91
+ * dies quietly after one event keeps reading true, because the per-channel
92
+ * confirmation that would settle it is not passed through the packages between
93
+ * Centrifugo and here (`docs/realtime.md`). That is why the poll backs off to a
94
+ * slow safety interval rather than stopping.
95
+ */
96
+ delivering: ComputedRef<boolean>;
97
+ /** Bumped on every run event. Watch it to refetch a run list. */
98
+ runTick: Readonly<Ref<number>>;
99
+ /** Bumped on every workflow event. */
100
+ workflowTick: Readonly<Ref<number>>;
101
+ /**
102
+ * Bumped on every bundle-build event.
103
+ *
104
+ * Kept apart from `workflowTick` rather than folded into it: a build is the one
105
+ * workflow change that happens WITHOUT anybody editing the workflow, and it can
106
+ * fire six times for a single save (three attempts × started/failed). The runs
107
+ * grid watches `workflowTick` to catch a cascade-delete and has no business
108
+ * reloading for build progress.
109
+ */
110
+ buildTick: Readonly<Ref<number>>;
111
+ /** The last run event, for a surface that only cares about one run's id. */
112
+ lastRunEvent: Readonly<Ref<RealtimeEvent | null>>;
113
+ lastWorkflowEvent: Readonly<Ref<RealtimeEvent | null>>;
114
+ /** The last build event. `topicId` is the workflow it belongs to. */
115
+ lastBuildEvent: Readonly<Ref<RealtimeEvent | null>>;
116
+ /** The subscribed channel names, for diagnostics. */
117
+ channels: ComputedRef<string[]>;
118
+ }
119
+ /** The shape a handler receives. Talkback's `Envelope` is assignable to it. */
120
+ export interface EnvelopeLike {
121
+ id?: unknown;
122
+ topic?: unknown;
123
+ topic_id?: unknown;
124
+ data?: unknown;
125
+ }
126
+ /**
127
+ * Narrows a publication to the fields a surface acts on.
128
+ *
129
+ * Everything is read defensively rather than cast. The envelope crosses four
130
+ * services to get here (`OutboxEmitter` → the relay → NATS → the bridge), and a
131
+ * surface that threw on an unexpected shape would take the page down over an event
132
+ * it could simply have ignored.
133
+ *
134
+ * `topic_id` is stringified on purpose: it is a string on the wire — an 18-digit id
135
+ * loses precision as a float64 above 2^53 — and `OutboxEnvelope` is careful about
136
+ * that, so nothing here should be the place that turns it back into a number.
137
+ */
138
+ export declare function toRealtimeEvent(envelope: EnvelopeLike): RealtimeEvent;
139
+ /**
140
+ * The workflow a run or build event belongs to, or null.
141
+ *
142
+ * `WorkflowRunEventObserver`'s payload carries `workflow_id` beside `run_id`, which
143
+ * is what lets a workflow-scoped surface ignore every other workflow's runs — in a
144
+ * busy tenant that is the difference between one refetch and a refetch per run in
145
+ * the account. Null means "cannot tell", and a caller should refetch rather than
146
+ * assume the event was not for it.
147
+ */
148
+ export declare function workflowIdOf(event: RealtimeEvent | null): string | null;
149
+ export declare function useIntegrationsRealtime(): IntegrationsRealtime;
@@ -0,0 +1,80 @@
1
+ import { computed, effectScope, ref, shallowRef } from "vue";
2
+ export const RUN_TOPIC = "revenexx.integrations.run";
3
+ export const WORKFLOW_TOPIC = "revenexx.integrations.workflow";
4
+ export const BUILD_TOPIC = "revenexx.integrations.build";
5
+ export const RUN_ACTIONS = ["created", "started", "finished", "failed", "cancelled"];
6
+ export const WORKFLOW_ACTIONS = ["created", "updated", "deleted"];
7
+ export const BUILD_ACTIONS = ["started", "succeeded", "failed"];
8
+ export function toRealtimeEvent(envelope) {
9
+ const topic = typeof envelope.topic === "string" ? envelope.topic : "";
10
+ const topicId = envelope.topic_id;
11
+ const data = envelope.data;
12
+ return {
13
+ id: typeof envelope.id === "string" ? envelope.id : "",
14
+ action: topic.split(".")[3] ?? "",
15
+ topicId: topicId === null || topicId === void 0 ? null : String(topicId),
16
+ data: data !== null && typeof data === "object" ? data : {}
17
+ };
18
+ }
19
+ export function workflowIdOf(event) {
20
+ const id = event?.data.workflow_id;
21
+ return typeof id === "string" && id ? id : null;
22
+ }
23
+ const runTick = ref(0);
24
+ const workflowTick = ref(0);
25
+ const buildTick = ref(0);
26
+ const lastRunEvent = shallowRef(null);
27
+ const lastWorkflowEvent = shallowRef(null);
28
+ const lastBuildEvent = shallowRef(null);
29
+ const deliveries = computed(() => runTick.value + workflowTick.value + buildTick.value);
30
+ let scope = null;
31
+ let openChannels = null;
32
+ function onRun(envelope) {
33
+ lastRunEvent.value = toRealtimeEvent(envelope);
34
+ runTick.value++;
35
+ }
36
+ function onWorkflow(envelope) {
37
+ lastWorkflowEvent.value = toRealtimeEvent(envelope);
38
+ workflowTick.value++;
39
+ }
40
+ function onBuild(envelope) {
41
+ lastBuildEvent.value = toRealtimeEvent(envelope);
42
+ buildTick.value++;
43
+ }
44
+ function onRunGap() {
45
+ lastRunEvent.value = null;
46
+ runTick.value++;
47
+ }
48
+ function onWorkflowGap() {
49
+ lastWorkflowEvent.value = null;
50
+ workflowTick.value++;
51
+ }
52
+ function onBuildGap() {
53
+ lastBuildEvent.value = null;
54
+ buildTick.value++;
55
+ }
56
+ export function useIntegrationsRealtime() {
57
+ const { talkback, enabled } = usePlatformTalkback();
58
+ if (enabled && import.meta.client && !scope) {
59
+ scope = effectScope(true);
60
+ scope.run(() => {
61
+ const subs = [
62
+ ...RUN_ACTIONS.map((action) => useTalkbackTopic(`${RUN_TOPIC}.${action}`, { handler: onRun, onResync: onRunGap })),
63
+ ...WORKFLOW_ACTIONS.map((action) => useTalkbackTopic(`${WORKFLOW_TOPIC}.${action}`, { handler: onWorkflow, onResync: onWorkflowGap })),
64
+ ...BUILD_ACTIONS.map((action) => useTalkbackTopic(`${BUILD_TOPIC}.${action}`, { handler: onBuild, onResync: onBuildGap }))
65
+ ];
66
+ openChannels = computed(() => subs.map((s) => s.channel.value).filter((c) => !!c));
67
+ });
68
+ }
69
+ const channels = computed(() => openChannels?.value ?? []);
70
+ return {
71
+ delivering: computed(() => deliveries.value > 0 && enabled && !!talkback.value && channels.value.length > 0),
72
+ runTick,
73
+ workflowTick,
74
+ buildTick,
75
+ lastRunEvent,
76
+ lastWorkflowEvent,
77
+ lastBuildEvent,
78
+ channels
79
+ };
80
+ }
@@ -1,8 +1,10 @@
1
1
  import { ref } from "vue";
2
2
  import { useIntegrationsApi } from "./integrationsApi.js";
3
+ import { useIntegrationsT } from "./useIntegrationsT.js";
3
4
  import { IntegrationsApiError } from "../utils/apiError.js";
4
5
  export function useIntegrationsRevisions() {
5
6
  const api = useIntegrationsApi();
7
+ const t = useIntegrationsT();
6
8
  const loading = ref(false);
7
9
  const error = ref(null);
8
10
  function captureError(e, fallback) {
@@ -16,7 +18,7 @@ export function useIntegrationsRevisions() {
16
18
  const res = await api.get(`/workflows/${workflowId}/revisions?page=${page}&per_page=${perPage}`);
17
19
  return { revisions: res.data ?? [], meta: res.meta };
18
20
  } catch (e) {
19
- error.value = captureError(e, "Could not load the revision history.");
21
+ error.value = captureError(e, t("integration.revisions.error.loadFailed"));
20
22
  throw e;
21
23
  } finally {
22
24
  loading.value = false;
@@ -25,12 +27,12 @@ export function useIntegrationsRevisions() {
25
27
  async function get(workflowId, revision) {
26
28
  const res = await api.get(`/workflows/${workflowId}/revisions/${revision}`);
27
29
  const apiBlob = res.blob ?? {};
28
- const triggers = (res.triggers ?? []).map((t) => ({
29
- id: t.handle ?? String(t.id ?? ""),
30
- type: t.type,
31
- name: typeof t.name === "string" ? t.name : t.name ?? void 0,
32
- active: t.active,
33
- config: t.config ?? {}
30
+ const triggers = (res.triggers ?? []).map((t2) => ({
31
+ id: t2.handle ?? String(t2.id ?? ""),
32
+ type: t2.type,
33
+ name: typeof t2.name === "string" ? t2.name : t2.name ?? void 0,
34
+ active: t2.active,
35
+ config: t2.config ?? {}
34
36
  }));
35
37
  return {
36
38
  ...res,
@@ -24,7 +24,7 @@ export declare function useIntegrationsRuns(): {
24
24
  }) => WorkflowRun[];
25
25
  get: (id: string) => WorkflowRun | null;
26
26
  fetchRun: (workflowId: number | string, runId: number | string, workflowName: string) => Promise<WorkflowRun>;
27
- trigger: (workflowId: number | string, payload?: Record<string, unknown>, mode?: "sync" | "async") => Promise<unknown>;
27
+ trigger: (workflowId: number | string, payload?: Record<string, unknown>, mode?: "async" | "sync") => Promise<unknown>;
28
28
  cancelRun: (workflowId: number | string, runId: number | string, reason?: string) => Promise<unknown>;
29
29
  terminateRun: (workflowId: number | string, runId: number | string, reason?: string) => Promise<unknown>;
30
30
  retryRun: (workflowId: number | string, runId: number | string) => Promise<unknown>;
@@ -1,6 +1,8 @@
1
1
  import { computed } from "vue";
2
2
  import { useIntegrationsApi } from "./integrationsApi.js";
3
+ import { useIntegrationsT } from "./useIntegrationsT.js";
3
4
  import { IntegrationsApiError } from "../utils/apiError.js";
5
+ import { useLoadGate } from "./useLoadGate.js";
4
6
  function mapStatus(status) {
5
7
  switch (status) {
6
8
  case "completed":
@@ -29,9 +31,11 @@ export function useIntegrationsRuns() {
29
31
  const error = useState("integrations.runs.error", () => null);
30
32
  const loaded = useState("integrations.runs.loaded", () => false);
31
33
  const reconciling = useState("integrations.runs.reconciling", () => false);
34
+ const gate = useLoadGate("integrations.runs", { loading, loaded });
32
35
  const SYNC_LIMIT = 5;
33
36
  const api = useIntegrationsApi();
34
- const { workflows, load: loadWorkflows } = useIntegrationsWorkflows();
37
+ const t = useIntegrationsT();
38
+ const { workflows, load: loadWorkflows, loaded: workflowsLoaded } = useIntegrationsWorkflows();
35
39
  function mapRun(r, workflowName) {
36
40
  const startedMs = r.started_at ? new Date(r.started_at).getTime() : null;
37
41
  const finishedMs = r.completed_at ? new Date(r.completed_at).getTime() : null;
@@ -45,15 +49,18 @@ export function useIntegrationsRuns() {
45
49
  triggerType: "manual",
46
50
  startedAt: r.started_at ?? "",
47
51
  finishedAt: r.completed_at ?? null,
52
+ // The list carries the reason a run failed; dropping it here was why every
53
+ // failure read the same wherever the list is shown. An empty string is not
54
+ // a reason — it has to fall through to whatever the surface says instead,
55
+ // or it renders as a blank line where the sentence belongs.
56
+ errorMessage: r.error || void 0,
48
57
  // Derived from the timestamps — the list payload carries no duration.
49
58
  durationMs: startedMs != null && finishedMs != null && finishedMs >= startedMs ? finishedMs - startedMs : null,
50
59
  steps: []
51
60
  };
52
61
  }
53
62
  async function load(force = false) {
54
- if (loading.value) return runs.value;
55
- if (loaded.value && !force) return runs.value;
56
- loading.value = true;
63
+ if (!await gate.claim(force)) return runs.value;
57
64
  error.value = null;
58
65
  try {
59
66
  await loadWorkflows();
@@ -92,11 +99,11 @@ export function useIntegrationsRuns() {
92
99
  reconciling.value = false;
93
100
  }
94
101
  }
95
- loaded.value = true;
102
+ loaded.value = workflowsLoaded.value && perWorkflow.every((p) => p.status === "fulfilled");
96
103
  } catch (e) {
97
- error.value = e instanceof IntegrationsApiError ? e.message : e?.message ?? "Could not load runs.";
104
+ error.value = e instanceof IntegrationsApiError ? e.message : e?.message ?? t("integration.runs.error.loadFailed");
98
105
  } finally {
99
- loading.value = false;
106
+ gate.release();
100
107
  }
101
108
  return runs.value;
102
109
  }
@@ -4,6 +4,10 @@ import type { ScheduleEntry } from '../types/integrations.js';
4
4
  * sourced from the real triggers sub-resource (useIntegrationsTriggers).
5
5
  * Next/last-run timestamps are mocked deterministically (no telemetry
6
6
  * endpoint yet).
7
+ *
8
+ * Unordered on purpose: the page's table sorts, by its own default and by
9
+ * whichever column the operator clicks, so an order established here would be
10
+ * replaced before anyone saw it.
7
11
  */
8
12
  export declare function useIntegrationsSchedules(): {
9
13
  entries: import("vue").ComputedRef<ScheduleEntry[]>;
@@ -1,4 +1,5 @@
1
1
  import { computed } from "vue";
2
+ import { scheduleTimezone } from "../utils/schedule.js";
2
3
  export function useIntegrationsSchedules() {
3
4
  const { all, loading, loadAll } = useIntegrationsTriggers();
4
5
  const entries = computed(
@@ -10,13 +11,19 @@ export function useIntegrationsSchedules() {
10
11
  workflowId: t.workflow_id,
11
12
  workflowName: t.workflowName,
12
13
  triggerId,
13
- cron: String(cfg.cron ?? "* * * * *"),
14
- timezone: String(cfg.timezone ?? "UTC"),
14
+ // Never invented: a trigger without an expression says so on the page.
15
+ // Standing in `* * * * *` for it would read as "every minute", and with
16
+ // the expression no longer on screen that guess would be invisible.
17
+ cron: typeof cfg.cron === "string" && cfg.cron.trim() !== "" ? cfg.cron : null,
18
+ // The schedule's own zone, or the platform's default where it has none —
19
+ // from the same helper the trigger settings read, so one schedule cannot
20
+ // be shown in two zones on two screens.
21
+ timezone: scheduleTimezone(cfg.timezone),
15
22
  active: t.active,
16
23
  nextRunAt: t.active ? mockNextRun(t.workflow_id, triggerId) : null,
17
24
  lastRunAt: t.active ? mockLastRun(t.workflow_id, triggerId) : null
18
25
  };
19
- }).sort((a, b) => a.cron.localeCompare(b.cron))
26
+ })
20
27
  );
21
28
  const total = computed(() => entries.value.length);
22
29
  const activeCount = computed(() => entries.value.filter((e) => e.active).length);