@revenexx/studio-integrations 0.6.0 → 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 (110) hide show
  1. package/CHANGELOG.md +227 -0
  2. package/README.md +31 -0
  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 +29 -28
  7. package/dist/runtime/components/Integrations/CredentialDetailDialog.vue +38 -37
  8. package/dist/runtime/components/Integrations/CredentialRevenexxSource.vue +58 -58
  9. package/dist/runtime/components/Integrations/CredentialTypePicker.vue +10 -9
  10. package/dist/runtime/components/Integrations/DeadLettersPanel.vue +18 -17
  11. package/dist/runtime/components/Integrations/EventTypeCombobox.vue +4 -3
  12. package/dist/runtime/components/Integrations/ExpressionInput.vue +16 -15
  13. package/dist/runtime/components/Integrations/GroupFrameView.vue +29 -19
  14. package/dist/runtime/components/Integrations/Layout.vue +28 -3
  15. package/dist/runtime/components/Integrations/NodeConfigDynamicField.vue +15 -14
  16. package/dist/runtime/components/Integrations/NodeConfigDynamicSchema.vue +9 -8
  17. package/dist/runtime/components/Integrations/NodeConfigField.vue +43 -42
  18. package/dist/runtime/components/Integrations/NodeInspector.vue +225 -216
  19. package/dist/runtime/components/Integrations/NodeOutputSchema.d.vue.ts +1 -1
  20. package/dist/runtime/components/Integrations/NodeOutputSchema.vue +3 -2
  21. package/dist/runtime/components/Integrations/NodeOutputSchema.vue.d.ts +1 -1
  22. package/dist/runtime/components/Integrations/NodePalette.vue +30 -29
  23. package/dist/runtime/components/Integrations/QueryBuilder.vue +44 -43
  24. package/dist/runtime/components/Integrations/RunActionsMenu.vue +36 -12
  25. package/dist/runtime/components/Integrations/RunDetailPanel.vue +114 -62
  26. package/dist/runtime/components/Integrations/RunPayloadInput.vue +14 -13
  27. package/dist/runtime/components/Integrations/RunResponseModal.vue +11 -10
  28. package/dist/runtime/components/Integrations/RunsTable.d.vue.ts +0 -1
  29. package/dist/runtime/components/Integrations/RunsTable.vue +38 -16
  30. package/dist/runtime/components/Integrations/RunsTable.vue.d.ts +0 -1
  31. package/dist/runtime/components/Integrations/SftpAccountImportDialog.vue +8 -7
  32. package/dist/runtime/components/Integrations/Sidebar.vue +14 -10
  33. package/dist/runtime/components/Integrations/StickyNoteView.d.vue.ts +14 -4
  34. package/dist/runtime/components/Integrations/StickyNoteView.vue +37 -23
  35. package/dist/runtime/components/Integrations/StickyNoteView.vue.d.ts +14 -4
  36. package/dist/runtime/components/Integrations/TemplateCard.vue +16 -6
  37. package/dist/runtime/components/Integrations/TemplateFlowPreview.vue +10 -9
  38. package/dist/runtime/components/Integrations/WorkflowContextMenu.vue +34 -33
  39. package/dist/runtime/components/Integrations/WorkflowCreateDialog.vue +36 -35
  40. package/dist/runtime/components/Integrations/WorkflowDuplicateDialog.vue +8 -7
  41. package/dist/runtime/components/Integrations/WorkflowEdgeView.vue +6 -4
  42. package/dist/runtime/components/Integrations/WorkflowEditor.vue +82 -79
  43. package/dist/runtime/components/Integrations/WorkflowHistoryPanel.vue +24 -23
  44. package/dist/runtime/components/Integrations/WorkflowIconPicker.vue +7 -6
  45. package/dist/runtime/components/Integrations/WorkflowNodeView.vue +19 -20
  46. package/dist/runtime/components/Integrations/WorkflowRunReplayPanel.vue +52 -39
  47. package/dist/runtime/components/Integrations/WorkflowSettingsDialog.vue +20 -19
  48. package/dist/runtime/components/Integrations/WorkflowTagsInput.vue +9 -8
  49. package/dist/runtime/components/Integrations/WorkflowTriggerView.vue +12 -9
  50. package/dist/runtime/components/Integrations/WorkflowVariablesDialog.vue +27 -26
  51. package/dist/runtime/components/Integrations/WorkflowsTable.vue +83 -45
  52. package/dist/runtime/composables/integrationsApi.js +4 -2
  53. package/dist/runtime/composables/useCredentialTypes.js +4 -2
  54. package/dist/runtime/composables/useIntegrationsCredentials.js +6 -4
  55. package/dist/runtime/composables/useIntegrationsFocus.d.ts +12 -0
  56. package/dist/runtime/composables/useIntegrationsLocale.d.ts +26 -0
  57. package/dist/runtime/composables/useIntegrationsLocale.js +8 -0
  58. package/dist/runtime/composables/useIntegrationsNodes.js +3 -1
  59. package/dist/runtime/composables/useIntegrationsRealtime.d.ts +149 -0
  60. package/dist/runtime/composables/useIntegrationsRealtime.js +80 -0
  61. package/dist/runtime/composables/useIntegrationsRevisions.js +9 -7
  62. package/dist/runtime/composables/useIntegrationsRuns.d.ts +1 -1
  63. package/dist/runtime/composables/useIntegrationsRuns.js +3 -1
  64. package/dist/runtime/composables/useIntegrationsSecrets.js +4 -2
  65. package/dist/runtime/composables/useIntegrationsT.d.ts +35 -0
  66. package/dist/runtime/composables/useIntegrationsT.js +9 -0
  67. package/dist/runtime/composables/useIntegrationsTemplates.d.ts +3 -3
  68. package/dist/runtime/composables/useIntegrationsTemplates.js +16 -8
  69. package/dist/runtime/composables/useIntegrationsTriggers.js +4 -2
  70. package/dist/runtime/composables/useIntegrationsWorkflows.js +5 -3
  71. package/dist/runtime/composables/useNodeConfigResolve.js +2 -1
  72. package/dist/runtime/data/integrationNodeCatalog.d.ts +41 -0
  73. package/dist/runtime/data/integrationNodeCatalog.js +14 -0
  74. package/dist/runtime/data/integrationTemplates.d.ts +9 -0
  75. package/dist/runtime/data/integrationTemplates.js +7 -1
  76. package/dist/runtime/data/payloadForm.js +2 -1
  77. package/dist/runtime/data/workflowIconCatalog.js +2 -1
  78. package/dist/runtime/i18n/messages/de.json +1207 -0
  79. package/dist/runtime/i18n/messages/en.json +1207 -0
  80. package/dist/runtime/i18n/translate.d.ts +83 -0
  81. package/dist/runtime/i18n/translate.js +41 -0
  82. package/dist/runtime/pages/integrations/audit.vue +52 -34
  83. package/dist/runtime/pages/integrations/credentials/oauth-complete.vue +3 -2
  84. package/dist/runtime/pages/integrations/credentials.vue +70 -52
  85. package/dist/runtime/pages/integrations/index.vue +50 -56
  86. package/dist/runtime/pages/integrations/runs.vue +33 -21
  87. package/dist/runtime/pages/integrations/schedules.vue +49 -28
  88. package/dist/runtime/pages/integrations/secrets.vue +34 -34
  89. package/dist/runtime/pages/integrations/templates/[slug].vue +77 -68
  90. package/dist/runtime/pages/integrations/templates/index.vue +35 -27
  91. package/dist/runtime/pages/integrations/workflows/[id].vue +164 -99
  92. package/dist/runtime/pages/integrations/workflows/index.vue +10 -5
  93. package/dist/runtime/plugins/locale.d.ts +10 -0
  94. package/dist/runtime/plugins/locale.js +6 -0
  95. package/dist/runtime/plugins/realtime.client.d.ts +32 -0
  96. package/dist/runtime/plugins/realtime.client.js +4 -0
  97. package/dist/runtime/utils/credentialState.d.ts +24 -1
  98. package/dist/runtime/utils/credentialState.js +26 -11
  99. package/dist/runtime/utils/localize.d.ts +18 -5
  100. package/dist/runtime/utils/localize.js +32 -9
  101. package/dist/runtime/utils/pollBudget.d.ts +72 -0
  102. package/dist/runtime/utils/pollBudget.js +17 -0
  103. package/dist/runtime/utils/schedule.d.ts +0 -25
  104. package/dist/runtime/utils/schedule.js +37 -25
  105. package/dist/runtime/utils/statusStyle.d.ts +26 -2
  106. package/dist/runtime/utils/statusStyle.js +9 -1
  107. package/dist/runtime/utils/validationFindings.d.ts +2 -2
  108. package/dist/runtime/utils/validationFindings.js +4 -3
  109. package/dist/types.d.mts +2 -6
  110. package/package.json +3 -2
@@ -3,11 +3,16 @@ import { AddCircle as Plus } from "#solar-icons";
3
3
  import { Button } from "@revenexx/studio";
4
4
  import WorkflowsTable from "../../../components/Integrations/WorkflowsTable.vue";
5
5
  import WorkflowCreateDialog from "../../../components/Integrations/WorkflowCreateDialog.vue";
6
- useHead({ title: "Workflows \xB7 Revenexx Cockpit" });
6
+ const t = useIntegrationsT();
7
+ useHead({ title: () => t("integration.workflows.list.head.title") });
7
8
  const { workflows, loading, error, load } = useIntegrationsWorkflows();
8
9
  onMounted(() => {
9
10
  void load();
10
11
  });
12
+ const { buildTick } = useIntegrationsRealtime();
13
+ watch(buildTick, () => {
14
+ void load(true);
15
+ });
11
16
  const loadFailed = computed(() => !!error && !!error.value && !workflows.value.length);
12
17
  const createOpen = ref(false);
13
18
  </script>
@@ -15,12 +20,12 @@ const createOpen = ref(false);
15
20
  <template>
16
21
  <IntegrationsLayout>
17
22
  <PageHeader
18
- title="Workflows"
23
+ :title="t('integration.workflows.list.header.title')"
19
24
  >
20
25
  <template #actions>
21
26
  <Button class="h-7 text-sm" @click="createOpen = true">
22
27
  <Plus :size="14" />
23
- New workflow
28
+ {{ t("integration.workflows.list.action.new") }}
24
29
  </Button>
25
30
  </template>
26
31
  </PageHeader>
@@ -29,12 +34,12 @@ const createOpen = ref(false);
29
34
  <StateCard
30
35
  v-if="loadFailed"
31
36
  variant="error"
32
- title="Could not load workflows"
37
+ :title="t('integration.workflows.list.error.title')"
33
38
  :description="error ?? void 0"
34
39
  >
35
40
  <template #actions>
36
41
  <Button variant="outline" class="h-8 text-sm" @click="load(true)">
37
- Try again
42
+ {{ t("integration.common.action.retry") }}
38
43
  </Button>
39
44
  </template>
40
45
  </StateCard>
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Apply the language a host configured statically (`studioIntegrations.locale`,
3
+ * or `NUXT_PUBLIC_STUDIO_INTEGRATIONS_LOCALE`) before anything renders.
4
+ *
5
+ * It runs ahead of the host's own plugins — module plugins are registered
6
+ * first — so a host that switches the language at runtime is applying its
7
+ * choice after this one, never being overwritten by it.
8
+ */
9
+ declare const _default: any;
10
+ export default _default;
@@ -0,0 +1,6 @@
1
+ import { setStudioLocale } from "../utils/localize.js";
2
+ import { defineNuxtPlugin, useRuntimeConfig } from "#app";
3
+ export default defineNuxtPlugin(() => {
4
+ const configured = useRuntimeConfig().public.studioIntegrations?.locale;
5
+ if (configured) setStudioLocale(configured);
6
+ });
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Starts realtime once per tab, client-only.
3
+ *
4
+ * TWO THINGS HAPPEN HERE AND NEITHER HAPPENS ANYWHERE ELSE.
5
+ *
6
+ * `usePlatformTalkback()` is what BUILDS the Talkback client: it imports the
7
+ * package, mints a connection token and opens the socket on its first call, then
8
+ * registers the client in studio-shared's registry. The channel composables only
9
+ * READ that registry — so without this call `useTalkbackTopic()` would find no
10
+ * client, subscribe to nothing and report a null channel, with no error anywhere.
11
+ * A plugin rather than a call inside the composable because building the client has
12
+ * side effects (a dynamic import, a WebSocket, a token mint) and a composable a
13
+ * component may reach during render is the wrong place for those.
14
+ *
15
+ * `useIntegrationsRealtime()` then takes the subscriptions. Warming it here rather
16
+ * than leaving it to the first surface that asks means an event arriving before the
17
+ * runs page is opened still moves the signals, and it puts the detached scope's
18
+ * creation at a defined point in the app's life instead of inside whichever
19
+ * component rendered first.
20
+ *
21
+ * BOTH ARE IDEMPOTENT AND BOTH ARE FREE WHEN REALTIME IS OFF. An empty
22
+ * `talkbackHost` makes `usePlatformTalkback()` return without building anything —
23
+ * neither `@revenexx/talkback-js` nor `centrifuge` is imported — and this plugin
24
+ * costs two function calls.
25
+ *
26
+ * The bare names resolve per application, so a host that overrides
27
+ * `usePlatformTalkback` (the standalone `studio-ui` does, because its API dev token
28
+ * is not one the Talkback facade will accept) gets its own client here, and a host
29
+ * that does not gets studio-shared's default.
30
+ */
31
+ declare const _default: any;
32
+ export default _default;
@@ -0,0 +1,4 @@
1
+ export default defineNuxtPlugin(() => {
2
+ usePlatformTalkback();
3
+ useIntegrationsRealtime();
4
+ });
@@ -22,7 +22,30 @@ export interface CredentialStateInfo {
22
22
  at?: string | null;
23
23
  }
24
24
  type TestableCredential = Pick<Credential, 'status' | 'last_test_ok' | 'last_test_at' | 'last_test_message'>;
25
+ /** Every state a credential can be in, in the order a reader is offered them. */
26
+ export declare const CREDENTIAL_STATES: readonly ["connected", "failed", "needs_reauth", "untested"];
27
+ /**
28
+ * What a state is called for the reader, keyed rather than derived from a
29
+ * credential.
30
+ *
31
+ * `credentialState()` below answers "what state is this credential in, and what
32
+ * is it called"; this answers the second half on its own, for the list's filter,
33
+ * which offers all four states whether or not a credential is in them. They read
34
+ * the same map, so the badge on a row and the option that finds it cannot come to
35
+ * disagree — they did, briefly, as two copies of the same four `t()` calls.
36
+ */
37
+ export declare function credentialStateLabel(key: CredentialConnState): string;
38
+ /**
39
+ * The `label` is read at call time rather than held in a map beside `TONE`: a
40
+ * map built once would keep the language it was built in, and this function is
41
+ * called from inside the table's `computed()` on every render anyway.
42
+ */
25
43
  export declare function credentialState(c: TestableCredential): CredentialStateInfo;
26
- /** "just now" / "3m ago" / "2h ago" / "5d ago" / a date. Empty for no timestamp. */
44
+ /**
45
+ * "just now" / "3m ago" / "2h ago" / "5d ago" / a date. Empty for no timestamp.
46
+ *
47
+ * Reuses the studio's common time words (`integration.common.time.*`) rather
48
+ * than minting its own — the same rule as the dashboard's `formatRelative`.
49
+ */
27
50
  export declare function relativeTime(iso?: string | null): string;
28
51
  export {};
@@ -1,3 +1,16 @@
1
+ import { useIntegrationsT } from "../composables/useIntegrationsT.js";
2
+ export const CREDENTIAL_STATES = ["connected", "failed", "needs_reauth", "untested"];
3
+ const MESSAGE_KEY = {
4
+ connected: "integration.credentials.state.connected",
5
+ failed: "integration.credentials.state.failed",
6
+ // Not derivable from the key: the state is called `needs_reauth` on the wire and
7
+ // "Needs reconnect" to a reader, which is the reason this map exists at all.
8
+ needs_reauth: "integration.credentials.state.needsReconnect",
9
+ untested: "integration.credentials.state.untested"
10
+ };
11
+ export function credentialStateLabel(key) {
12
+ return useIntegrationsT()(MESSAGE_KEY[key]);
13
+ }
1
14
  const TONE = {
2
15
  connected: "bg-emerald-500/10 border-emerald-500/20 text-emerald-700",
3
16
  failed: "bg-rose-500/10 border-rose-500/20 text-rose-700",
@@ -5,25 +18,27 @@ const TONE = {
5
18
  untested: "bg-slate-500/10 border-slate-500/20 text-slate-600"
6
19
  };
7
20
  export function credentialState(c) {
21
+ const t = useIntegrationsT();
8
22
  if (c.status === "needs_reauth") {
9
- return { key: "needs_reauth", label: "Needs reconnect", toneClass: TONE.needs_reauth, at: c.last_test_at };
23
+ return { key: "needs_reauth", label: t(MESSAGE_KEY.needs_reauth), toneClass: TONE.needs_reauth, at: c.last_test_at };
10
24
  }
11
25
  if (c.last_test_ok === true) {
12
- return { key: "connected", label: "Connected", toneClass: TONE.connected, at: c.last_test_at };
26
+ return { key: "connected", label: t(MESSAGE_KEY.connected), toneClass: TONE.connected, at: c.last_test_at };
13
27
  }
14
28
  if (c.last_test_ok === false) {
15
- return { key: "failed", label: "Failed", toneClass: TONE.failed, message: c.last_test_message, at: c.last_test_at };
29
+ return { key: "failed", label: t(MESSAGE_KEY.failed), toneClass: TONE.failed, message: c.last_test_message, at: c.last_test_at };
16
30
  }
17
- return { key: "untested", label: "Not Connected", toneClass: TONE.untested };
31
+ return { key: "untested", label: t(MESSAGE_KEY.untested), toneClass: TONE.untested };
18
32
  }
19
33
  export function relativeTime(iso) {
20
34
  if (!iso) return "";
21
- const t = new Date(iso).getTime();
22
- if (Number.isNaN(t)) return "";
23
- const ms = Date.now() - t;
24
- if (ms < 6e4) return "just now";
25
- if (ms < 36e5) return `${Math.floor(ms / 6e4)}m ago`;
26
- if (ms < 864e5) return `${Math.floor(ms / 36e5)}h ago`;
27
- if (ms < 30 * 864e5) return `${Math.floor(ms / 864e5)}d ago`;
35
+ const at = new Date(iso).getTime();
36
+ if (Number.isNaN(at)) return "";
37
+ const t = useIntegrationsT();
38
+ const ms = Date.now() - at;
39
+ if (ms < 6e4) return t("integration.common.time.justNow");
40
+ if (ms < 36e5) return t("integration.common.time.minutesAgo", Math.floor(ms / 6e4));
41
+ if (ms < 864e5) return t("integration.common.time.hoursAgo", Math.floor(ms / 36e5));
42
+ if (ms < 30 * 864e5) return t("integration.common.time.daysAgo", Math.floor(ms / 864e5));
28
43
  return new Date(iso).toLocaleDateString();
29
44
  }
@@ -1,12 +1,25 @@
1
+ /**
2
+ * Tell the studio which language to render in. **This is the host's job**, and
3
+ * it is the whole contract: the module is published, so it must not go looking
4
+ * for the cockpit's storage keys or import the cockpit's i18n. A host with a
5
+ * language switcher calls this whenever the choice changes; a host with a fixed
6
+ * language sets `studioIntegrations.locale` in its Nuxt config instead and
7
+ * never calls this at all.
8
+ *
9
+ * A BCP-47 tag (`de`, `en`, `de-CH`). Empty or absent means English.
10
+ */
11
+ export declare function setStudioLocale(locale: string | null | undefined): void;
12
+ /** The language the studio is currently rendering in. Reactive when read in an effect. */
13
+ export declare function studioLocale(): string;
1
14
  /**
2
15
  * Resolve a multilingual value from the Integrations API. The API returns
3
16
  * names and descriptions either as a plain string or as an already-parsed
4
17
  * object `{de:"…", en:"…"}` (the HTTP client does JSON decoding automatically).
5
18
  * JSON-encoded strings like `'{"de":"…","en":"…"}'` are also handled.
6
19
  *
7
- * Priority: 1) "en", 2) first key found. English is the cockpit default —
8
- * there is no working language switcher yet; browser-locale preference
9
- * returns together with the switcher.
20
+ * The language is the studio's ({@link setStudioLocale}) unless the caller
21
+ * names one. English is the fallback, and the map's own first entry the
22
+ * fallback after that.
10
23
  *
11
24
  * **This is the studio's only resolver, and that is the point.** The same three
12
25
  * lines used to sit in seventeen components under six names (`labelText`, `loc`,
@@ -22,7 +35,7 @@
22
35
  * `descriptionText`), so no grep for the rule's shape found them. Both resolve
23
36
  * text a person typed and went to `resolveDisplayName` below, not here.
24
37
  */
25
- export declare function resolveLocalized(value: unknown): string;
38
+ export declare function resolveLocalized(value: unknown, locale?: string): string;
26
39
  /**
27
40
  * The same resolution, for a value that may be **text a person wrote** — a
28
41
  * node's own name, a group frame's label.
@@ -37,4 +50,4 @@ export declare function resolveLocalized(value: unknown): string;
37
50
  * resolved. Use this wherever the value can be something a person typed; use
38
51
  * `resolveLocalized` for anything that can only have come from a manifest.
39
52
  */
40
- export declare function resolveDisplayName(value: string | Record<string, string> | undefined | null): string;
53
+ export declare function resolveDisplayName(value: string | Record<string, string> | undefined | null, locale?: string): string;
@@ -1,11 +1,34 @@
1
- export function resolveLocalized(value) {
1
+ import { shallowRef } from "vue";
2
+ const uiLocale = shallowRef("en");
3
+ export function setStudioLocale(locale) {
4
+ const next = typeof locale === "string" ? locale.trim() : "";
5
+ uiLocale.value = next || "en";
6
+ }
7
+ export function studioLocale() {
8
+ return uiLocale.value;
9
+ }
10
+ function pickLanguage(map, locale) {
11
+ const keys = Object.keys(map);
12
+ if (!keys.length) return "";
13
+ const wanted = locale.toLowerCase();
14
+ const base = wanted.split("-")[0];
15
+ const candidates = [
16
+ keys.find((k) => k.toLowerCase() === wanted),
17
+ keys.find((k) => k.toLowerCase().split("-")[0] === base),
18
+ keys.find((k) => k.toLowerCase().split("-")[0] === "en"),
19
+ keys[0]
20
+ ];
21
+ for (const key of candidates) {
22
+ if (key === void 0) continue;
23
+ const picked = map[key];
24
+ if (typeof picked === "string") return picked;
25
+ }
26
+ return "";
27
+ }
28
+ export function resolveLocalized(value, locale = uiLocale.value) {
2
29
  if (value === null || value === void 0) return "";
3
30
  if (typeof value === "object" && !Array.isArray(value)) {
4
- const map = value;
5
- const keys = Object.keys(map);
6
- if (!keys.length) return "";
7
- const picked = map["en"] ?? map[keys[0]];
8
- return typeof picked === "string" ? picked : "";
31
+ return pickLanguage(value, locale);
9
32
  }
10
33
  if (typeof value !== "string") return String(value);
11
34
  const trimmed = value.trim();
@@ -13,12 +36,12 @@ export function resolveLocalized(value) {
13
36
  try {
14
37
  const parsed = JSON.parse(trimmed);
15
38
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return trimmed;
16
- return resolveLocalized(parsed);
39
+ return resolveLocalized(parsed, locale);
17
40
  } catch {
18
41
  return trimmed;
19
42
  }
20
43
  }
21
- export function resolveDisplayName(value) {
44
+ export function resolveDisplayName(value, locale = uiLocale.value) {
22
45
  if (typeof value === "string") return value;
23
- return resolveLocalized(value);
46
+ return resolveLocalized(value, locale);
24
47
  }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * How often a poll is still allowed to fetch once realtime is carrying the same
3
+ * updates.
4
+ *
5
+ * Every run surface polls: the runs list every three seconds, the detail panel
6
+ * every three and its timeline every ten, the replay canvas every four. That is the
7
+ * only thing that made a `running → completed` transition appear at all, and it is
8
+ * expensive — `runs.vue`'s pass fans out one request per workflow and then
9
+ * reconciles up to five active runs, so the list can cost dozens of requests every
10
+ * three seconds while anything is in flight.
11
+ *
12
+ * Talkback replaces the *latency* argument for polling, not the safety argument. So
13
+ * the intervals stay exactly where they are and this decides, per tick, whether the
14
+ * tick may spend a request: at the ordinary cadence while nothing is being reported,
15
+ * and at a slow safety cadence once something is.
16
+ *
17
+ * COUNTING TICKS, NOT READING A CLOCK, and that is deliberate twice over. The tick
18
+ * is the only thing that has actually happened — a wall-clock reading would make
19
+ * this disagree with a `setInterval` the browser throttled in a background tab. And
20
+ * `specs/runs.md` AC-12's test installs Playwright's fake clock and fast-forwards
21
+ * it; a `Date.now()` in here would read the fake time, which is the kind of
22
+ * coupling that makes a test pass for the wrong reason.
23
+ *
24
+ * WHAT `delivering` HAS TO MEAN, and this is the argument the parameter's name is
25
+ * carrying. Being subscribed is no evidence: a channel resolves from the local
26
+ * subscription, so a tenant whose event catalogue never publishes these topics holds
27
+ * open channels that stay silent for the life of the tab — and thinning on that state
28
+ * would make such a tenant ten times slower than before any of this existed, with
29
+ * nothing anywhere saying so. `useIntegrationsRealtime` therefore counts deliveries,
30
+ * and this is only ever thinned once something has really arrived.
31
+ *
32
+ * WHY A SLOW POLL RATHER THAN NONE. What is still unavailable is whether delivery is
33
+ * CONTINUING: a socket that dies quietly after one event keeps reading as delivering,
34
+ * because the per-channel confirmation that would settle it is not passed through the
35
+ * packages between Centrifugo and here. A budget that stopped fetching entirely would
36
+ * then show a stale list for as long as the tab stayed open, with nothing anywhere
37
+ * saying so. Backing off from three seconds to thirty gives up latency we now get
38
+ * from events and keeps the property that mattered: the view converges without the
39
+ * operator doing anything.
40
+ *
41
+ * The caller keeps its own tick-shedding (`!loading.value`) in front of this, so a
42
+ * tick that finds a load still out neither fetches nor spends budget — see
43
+ * `loadGate.ts` for why that flag is trustworthy.
44
+ */
45
+ export interface PollBudgetOptions {
46
+ /** The caller's own interval, i.e. how much time one `due()` call stands for. */
47
+ intervalMs: number;
48
+ /** The gap between fetches while reports are arriving. */
49
+ liveIntervalMs: number;
50
+ }
51
+ export interface PollBudget {
52
+ /**
53
+ * Whether this tick may fetch. Advances the budget, so call it once per tick and
54
+ * last in the condition — `hasActive && !loading && budget.due(delivering)`.
55
+ */
56
+ due: (delivering: boolean) => boolean;
57
+ /**
58
+ * Counts a fetch this budget did not authorise: a report's refetch, a manual
59
+ * refresh, a row action, an opening surface's first read. Without it the next due
60
+ * tick would send a second request for a state that was just read.
61
+ *
62
+ * CALL IT IN THE LOADER, not at each call site, wherever the surface has a loader
63
+ * of its own to put it in — `[id].vue`'s `loadRuns`, `RunDetailPanel`'s
64
+ * `loadSummary` and `loadTimelineEvents`, `runs.vue`'s `reload`. A contract that
65
+ * asks every future caller to remember is one that ends up wider than the wiring,
66
+ * which is how this note came to be written.
67
+ */
68
+ spend: () => void;
69
+ }
70
+ /** The safety cadence every run surface backs off to while reports are arriving. */
71
+ export declare const SAFETY_POLL_MS = 30000;
72
+ export declare function createPollBudget(options: PollBudgetOptions): PollBudget;
@@ -0,0 +1,17 @@
1
+ export const SAFETY_POLL_MS = 3e4;
2
+ export function createPollBudget(options) {
3
+ let elapsed = 0;
4
+ return {
5
+ due(delivering) {
6
+ elapsed += options.intervalMs;
7
+ if (!delivering || elapsed >= options.liveIntervalMs) {
8
+ elapsed = 0;
9
+ return true;
10
+ }
11
+ return false;
12
+ },
13
+ spend() {
14
+ elapsed = 0;
15
+ }
16
+ };
17
+ }
@@ -1,28 +1,3 @@
1
- /**
2
- * A schedule, in both directions: the structured rhythm the studio lets a user
3
- * pick, the five-field cron expression the platform runs on, and the sentence
4
- * that says it in words.
5
- *
6
- * The studio no longer asks anyone to write cron. It asks for a rhythm — every
7
- * few minutes, daily at a time, on these weekdays — and `toCron` turns that into
8
- * the expression the API stores. `parseCron` is the way back, so a schedule that
9
- * arrives from anywhere (a template, the MCP tools, a hand-written expression from
10
- * before this existed) opens in the same controls if it can, and stays an
11
- * expression if it cannot.
12
- *
13
- * **The sentence is total over what the picker can build.** `describeRecurrence`
14
- * always answers; the refusal to guess lives in `parseCron`, which returns `null`
15
- * for everything outside the shapes below — several stepped fields, minute and
16
- * hour lists, `L`/`#` syntax, month restrictions, seconds precision. That is what
17
- * lets the studio show the sentence instead of the expression: for anything it
18
- * authored there is always a sentence, and where there is none the expression is
19
- * shown, because nothing else would be true.
20
- *
21
- * **Nothing here converts a time.** The platform evaluates the expression in the
22
- * trigger's own timezone (`CronExpression->isDue($now->setTimezone($tz))`), so the
23
- * numbers in it are wall-clock time in that zone. The zone is therefore half of
24
- * the schedule and the caller's to show — it is not a formatting detail.
25
- */
26
1
  /** A rhythm the picker can build, and `describeRecurrence` can always say. */
27
2
  export type Recurrence =
28
3
  /** `interval` minutes apart, all day, every day. `1` means every minute. */
@@ -1,4 +1,14 @@
1
- const DAY_NAMES = ["Sundays", "Mondays", "Tuesdays", "Wednesdays", "Thursdays", "Fridays", "Saturdays"];
1
+ import { useIntegrationsT } from "../composables/useIntegrationsT.js";
2
+ import { studioLocale } from "./localize.js";
3
+ const WEEKDAY_KEYS = [
4
+ "integration.schedules.cron.weekday.sunday",
5
+ "integration.schedules.cron.weekday.monday",
6
+ "integration.schedules.cron.weekday.tuesday",
7
+ "integration.schedules.cron.weekday.wednesday",
8
+ "integration.schedules.cron.weekday.thursday",
9
+ "integration.schedules.cron.weekday.friday",
10
+ "integration.schedules.cron.weekday.saturday"
11
+ ];
2
12
  export const PLATFORM_TIMEZONE = "UTC";
3
13
  export function scheduleTimezone(timezone) {
4
14
  return typeof timezone === "string" && timezone.trim() !== "" ? timezone.trim() : PLATFORM_TIMEZONE;
@@ -126,54 +136,56 @@ export function toCron(r) {
126
136
  function at(hour, minute) {
127
137
  return `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`;
128
138
  }
129
- function plural(n, unit) {
130
- return `${n} ${unit}${n === 1 ? "" : "s"}`;
131
- }
132
139
  function ordinal(n) {
140
+ if (studioLocale().split("-")[0] === "de") return `${n}.`;
133
141
  const suffix = n % 100 >= 11 && n % 100 <= 13 ? "th" : ["th", "st", "nd", "rd"][n % 10] ?? "th";
134
142
  return `${n}${suffix}`;
135
143
  }
136
144
  function sameDays(days, other) {
137
145
  return days.length === other.length && days.every((d) => other.includes(d));
138
146
  }
139
- function listWords(words) {
147
+ function listWords(words, t) {
140
148
  if (words.length <= 1) return words[0] ?? "";
141
- return `${words.slice(0, -1).join(", ")} and ${words[words.length - 1]}`;
149
+ return `${words.slice(0, -1).join(", ")} ${t("integration.common.list.and")} ${words[words.length - 1]}`;
150
+ }
151
+ function isEveryDay(days) {
152
+ if (!days) return true;
153
+ const set = new Set(days.map((d) => d === 7 ? 0 : d));
154
+ return set.size === 0 || set.size === 7;
142
155
  }
143
- function dayPhrase(days) {
144
- if (!days) return "Daily";
145
- const set = [...new Set(days.map((d) => d === 7 ? 0 : d))].sort((a, b) => a - b);
146
- if (!set.length || set.length === 7) return "Daily";
147
- if (sameDays(set, WEEKDAYS)) return "Weekdays";
148
- if (sameDays(set, WEEKEND)) return "Weekends";
149
- return listWords(set.map((d) => DAY_NAMES[d] ?? "").filter(Boolean));
156
+ function dayPhrase(days, t) {
157
+ if (isEveryDay(days)) return t("integration.schedules.cron.day.daily");
158
+ const set = [...new Set((days ?? []).map((d) => d === 7 ? 0 : d))].sort((a, b) => a - b);
159
+ if (sameDays(set, WEEKDAYS)) return t("integration.schedules.cron.day.weekdays");
160
+ if (sameDays(set, WEEKEND)) return t("integration.schedules.cron.day.weekends");
161
+ return listWords(set.map((d) => t(WEEKDAY_KEYS[d])), t);
150
162
  }
151
163
  export function describeRecurrence(r) {
164
+ const t = useIntegrationsT();
152
165
  switch (r.kind) {
153
166
  case "minutes":
154
- return r.interval <= 1 ? "Every minute" : `Every ${plural(r.interval, "minute")}`;
167
+ return r.interval <= 1 ? t("integration.schedules.cron.everyMinute") : t("integration.schedules.cron.everyNMinutes", { n: r.interval });
155
168
  case "hourly":
156
- return r.minute === 0 ? "Hourly, on the hour" : `Hourly at :${String(r.minute).padStart(2, "0")}`;
169
+ return r.minute === 0 ? t("integration.schedules.cron.hourlyOnTheHour") : t("integration.schedules.cron.hourlyAt", { minute: String(r.minute).padStart(2, "0") });
157
170
  case "hours":
158
171
  if (r.interval <= 1) return describeRecurrence({ kind: "hourly", minute: r.minute });
159
- return r.minute === 0 ? `Every ${plural(r.interval, "hour")}, on the hour` : `Every ${plural(r.interval, "hour")} at :${String(r.minute).padStart(2, "0")}`;
172
+ return r.minute === 0 ? t("integration.schedules.cron.everyNHoursOnTheHour", { n: r.interval }) : t("integration.schedules.cron.everyNHoursAt", { n: r.interval, minute: String(r.minute).padStart(2, "0") });
160
173
  case "daily":
161
- return `Daily at ${at(r.hour, r.minute)}`;
174
+ return t("integration.schedules.cron.dailyAt", { time: at(r.hour, r.minute) });
162
175
  case "times": {
163
176
  const hours = [...new Set(r.hours)].sort((a, b) => a - b);
164
177
  if (hours.length < 2) return describeRecurrence({ kind: "weekly", days: r.days ?? [0, 1, 2, 3, 4, 5, 6], hour: hours[0] ?? 0, minute: r.minute });
165
- const phrase = dayPhrase(r.days);
166
178
  if (saysAsSpan(hours)) {
167
- const span = `hourly from ${at(hours[0], r.minute)} to ${at(hours[hours.length - 1], r.minute)}`;
168
- return phrase === "Daily" ? `Hourly from ${at(hours[0], r.minute)} to ${at(hours[hours.length - 1], r.minute)}` : `${phrase}, ${span}`;
179
+ const from = at(hours[0], r.minute);
180
+ const to = at(hours[hours.length - 1], r.minute);
181
+ return isEveryDay(r.days) ? t("integration.schedules.cron.spanAllDay", { from, to }) : t("integration.schedules.cron.spanWithDays", { days: dayPhrase(r.days, t), from, to });
169
182
  }
170
- return `${phrase} at ${listWords(hours.map((h) => at(h, r.minute)))}`;
171
- }
172
- case "weekly": {
173
- return `${dayPhrase(r.days)} at ${at(r.hour, r.minute)}`;
183
+ return t("integration.schedules.cron.namedTimes", { days: dayPhrase(r.days, t), times: listWords(hours.map((h) => at(h, r.minute)), t) });
174
184
  }
185
+ case "weekly":
186
+ return t("integration.schedules.cron.weeklyAt", { days: dayPhrase(r.days, t), time: at(r.hour, r.minute) });
175
187
  case "monthly":
176
- return `Monthly on the ${ordinal(r.day)}, at ${at(r.hour, r.minute)}`;
188
+ return t("integration.schedules.cron.monthlyAt", { ordinal: ordinal(r.day), time: at(r.hour, r.minute) });
177
189
  }
178
190
  }
179
191
  export function describeCron(cron) {
@@ -13,9 +13,33 @@ export declare function statusTone(status: string | null | undefined): StatusTon
13
13
  export declare function statusClass(status: string | null | undefined): string;
14
14
  /** Tone classes directly (when you already have a tone). */
15
15
  export declare function toneClass(tone: StatusTone): string;
16
+ /** The run-status words the studio itself displays (as opposed to whatever
17
+ * vocabulary Temporal or the raw API used to get there) — the only ones
18
+ * `runStatusLabel` translates. Anything else passes through unlabelled: a
19
+ * status this catalogue doesn't know is a raw field, not the studio's chrome.
20
+ *
21
+ * An array rather than a set, and exported, because the runs list offers these
22
+ * five as the choices it can be narrowed to and has to offer them in a fixed
23
+ * order. It used to keep its own copy of the list, so a sixth word added here
24
+ * would have been labelled everywhere and silently missing from that menu. */
25
+ export declare const RUN_STATUS_WORDS: readonly ["running", "succeeded", "failed", "cancelled", "queued"];
16
26
  /**
17
27
  * Raw API/Temporal run vocabulary → the cockpit's run-status labels, so a run
18
- * never reads "completed" in one surface and "succeeded" in another. Unknown
19
- * statuses pass through lowercased.
28
+ * never reads "completed" in one surface and "succeeded" in another. This IS
29
+ * the studio's own chrome (Temporal's "completed" becomes our "succeeded"), so
30
+ * unlike a raw `run.status` read elsewhere, it is translated. Unknown statuses
31
+ * still pass through lowercased, untranslated — this function only relabels
32
+ * the fixed run-status vocabulary, not arbitrary strings.
33
+ */
34
+ /**
35
+ * The platform's run vocabulary reduced to the studio's own five words — Temporal's
36
+ * `completed` is our `succeeded`, `pending` is `queued`, and two ways of dying are
37
+ * both `failed`. A status this catalogue does not know comes back as itself.
38
+ *
39
+ * Exported because a run's status is used as an *identity* as well as a word: the runs
40
+ * list narrows by it, and what a filter stores has to be something that does not move
41
+ * when the reader's language does. Both sides read this, so a filter and a row can
42
+ * never disagree about which status they mean.
20
43
  */
44
+ export declare function normalisedRunStatus(status: string | null | undefined): string;
21
45
  export declare function runStatusLabel(status: string | null | undefined): string;
@@ -76,7 +76,9 @@ export function statusClass(status) {
76
76
  export function toneClass(tone) {
77
77
  return TONE_CLASS[tone];
78
78
  }
79
- export function runStatusLabel(status) {
79
+ export const RUN_STATUS_WORDS = ["running", "succeeded", "failed", "cancelled", "queued"];
80
+ const RUN_STATUS_WORD_SET = new Set(RUN_STATUS_WORDS);
81
+ export function normalisedRunStatus(status) {
80
82
  if (!status) return "";
81
83
  const s = String(status).toLowerCase().replace(/[\s-]+/g, "_");
82
84
  switch (s) {
@@ -91,3 +93,9 @@ export function runStatusLabel(status) {
91
93
  return s;
92
94
  }
93
95
  }
96
+ export function runStatusLabel(status) {
97
+ const normalized = normalisedRunStatus(status);
98
+ if (!normalized) return "";
99
+ if (!RUN_STATUS_WORD_SET.has(normalized)) return normalized;
100
+ return useIntegrationsT()(`integration.runs.status.${normalized}`);
101
+ }
@@ -2,7 +2,7 @@ import type { WorkflowNode } from '../types/integrations.js';
2
2
  /**
3
3
  * Reading the backend's validation findings — for the editor's validation
4
4
  * banner (behavior specs/workflow-validation.md, how docs/workflow-validation.md)
5
- * and for the node configuration dialog (specs/node-inspector.md AC-8).
5
+ * and for the node configuration dialog (specs/node-configuration.md AC-8).
6
6
  *
7
7
  * The backend reports one finding per incomplete setting, each keyed by its
8
8
  * blob path (`blob.nodes.4.config.companyId`). The two readers want different
@@ -74,7 +74,7 @@ export declare function findingFieldKey(path: string): string | null;
74
74
  export declare function groupFindingsByNode(findings: readonly ValidationFinding[], nodes: readonly WorkflowNode[], labelOf?: (node: WorkflowNode) => string): FindingNodeGroup[];
75
75
  /**
76
76
  * Node id → config key → the message to show AT that setting
77
- * (specs/node-inspector.md AC-8).
77
+ * (specs/node-configuration.md AC-8).
78
78
  *
79
79
  * First finding wins: the backend reports the most specific reason first, and
80
80
  * two sentences stacked under one input would be noise.
@@ -1,3 +1,4 @@
1
+ import { useIntegrationsT } from "../composables/useIntegrationsT.js";
1
2
  import { findManifest } from "../data/integrationNodeCatalog.js";
2
3
  import { resolveLocalized } from "./localize.js";
3
4
  export function nodeDisplayLabel(node) {
@@ -47,7 +48,7 @@ export function findingsByNodeField(findings, nodes) {
47
48
  return out;
48
49
  }
49
50
  export function findingsSummary(findingCount, nodeCount) {
50
- const settings = `${findingCount} setting${findingCount === 1 ? "" : "s"}`;
51
- const inNodes = nodeCount ? ` in ${nodeCount} node${nodeCount === 1 ? "" : "s"}` : "";
52
- return `${settings}${inNodes} still need${findingCount === 1 ? "s" : ""} fixing`;
51
+ const t = useIntegrationsT();
52
+ const nodesClause = nodeCount ? ` ${t("integration.workflows.validation.summary.nodesClause", nodeCount)}` : "";
53
+ return t("integration.workflows.validation.summary", findingCount, { nodesClause });
53
54
  }
package/dist/types.d.mts CHANGED
@@ -1,9 +1,5 @@
1
- import type { NuxtModule } from '@nuxt/schema'
2
-
3
- import type { default as Module } from './module.mjs'
4
-
5
- export type ModuleOptions = typeof Module extends NuxtModule<infer O> ? Partial<O> : Record<string, any>
6
-
7
1
  export { default } from './module.mjs'
8
2
 
3
+ export { type ModuleOptions } from './module.mjs'
4
+
9
5
  export * from '../dist/runtime/types/integrations.js'