@revenexx/studio-integrations 0.6.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 (137) hide show
  1. package/CHANGELOG.md +643 -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 +12 -3
  6. package/dist/runtime/components/Integrations/CredentialCreateDialog.vue +32 -28
  7. package/dist/runtime/components/Integrations/CredentialDetailDialog.vue +45 -38
  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 +21 -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.d.vue.ts +3 -0
  16. package/dist/runtime/components/Integrations/NodeConfigDynamicField.vue +30 -16
  17. package/dist/runtime/components/Integrations/NodeConfigDynamicField.vue.d.ts +3 -0
  18. package/dist/runtime/components/Integrations/NodeConfigDynamicSchema.vue +10 -8
  19. package/dist/runtime/components/Integrations/NodeConfigField.d.vue.ts +3 -0
  20. package/dist/runtime/components/Integrations/NodeConfigField.vue +45 -42
  21. package/dist/runtime/components/Integrations/NodeConfigField.vue.d.ts +3 -0
  22. package/dist/runtime/components/Integrations/NodeInspector.d.vue.ts +2 -2
  23. package/dist/runtime/components/Integrations/NodeInspector.vue +315 -221
  24. package/dist/runtime/components/Integrations/NodeInspector.vue.d.ts +2 -2
  25. package/dist/runtime/components/Integrations/NodeOutputSchema.d.vue.ts +2 -1
  26. package/dist/runtime/components/Integrations/NodeOutputSchema.vue +37 -10
  27. package/dist/runtime/components/Integrations/NodeOutputSchema.vue.d.ts +2 -1
  28. package/dist/runtime/components/Integrations/NodePalette.vue +40 -35
  29. package/dist/runtime/components/Integrations/QueryBuilder.vue +44 -43
  30. package/dist/runtime/components/Integrations/RunActionsMenu.vue +36 -12
  31. package/dist/runtime/components/Integrations/RunDetailPanel.vue +114 -62
  32. package/dist/runtime/components/Integrations/RunPayloadInput.vue +14 -13
  33. package/dist/runtime/components/Integrations/RunResponseModal.vue +11 -10
  34. package/dist/runtime/components/Integrations/RunsTable.d.vue.ts +0 -1
  35. package/dist/runtime/components/Integrations/RunsTable.vue +38 -16
  36. package/dist/runtime/components/Integrations/RunsTable.vue.d.ts +0 -1
  37. package/dist/runtime/components/Integrations/SftpAccountImportDialog.vue +8 -7
  38. package/dist/runtime/components/Integrations/Sidebar.vue +14 -10
  39. package/dist/runtime/components/Integrations/StickyNoteView.d.vue.ts +14 -4
  40. package/dist/runtime/components/Integrations/StickyNoteView.vue +82 -34
  41. package/dist/runtime/components/Integrations/StickyNoteView.vue.d.ts +14 -4
  42. package/dist/runtime/components/Integrations/SurfaceCard.d.vue.ts +50 -0
  43. package/dist/runtime/components/Integrations/SurfaceCard.vue +55 -0
  44. package/dist/runtime/components/Integrations/SurfaceCard.vue.d.ts +50 -0
  45. package/dist/runtime/components/Integrations/TemplateCard.vue +18 -8
  46. package/dist/runtime/components/Integrations/TemplateFlowPreview.vue +22 -12
  47. package/dist/runtime/components/Integrations/WorkflowContextMenu.vue +34 -33
  48. package/dist/runtime/components/Integrations/WorkflowCreateDialog.vue +41 -39
  49. package/dist/runtime/components/Integrations/WorkflowDuplicateDialog.vue +8 -7
  50. package/dist/runtime/components/Integrations/WorkflowEdgeView.vue +6 -4
  51. package/dist/runtime/components/Integrations/WorkflowEditor.d.vue.ts +16 -0
  52. package/dist/runtime/components/Integrations/WorkflowEditor.vue +131 -87
  53. package/dist/runtime/components/Integrations/WorkflowEditor.vue.d.ts +16 -0
  54. package/dist/runtime/components/Integrations/WorkflowHistoryPanel.d.vue.ts +6 -0
  55. package/dist/runtime/components/Integrations/WorkflowHistoryPanel.vue +27 -24
  56. package/dist/runtime/components/Integrations/WorkflowHistoryPanel.vue.d.ts +6 -0
  57. package/dist/runtime/components/Integrations/WorkflowIconPicker.vue +7 -6
  58. package/dist/runtime/components/Integrations/WorkflowNodeView.vue +76 -46
  59. package/dist/runtime/components/Integrations/WorkflowRunReplayPanel.vue +55 -43
  60. package/dist/runtime/components/Integrations/WorkflowSettingsDialog.d.vue.ts +16 -17
  61. package/dist/runtime/components/Integrations/WorkflowSettingsDialog.vue +55 -43
  62. package/dist/runtime/components/Integrations/WorkflowSettingsDialog.vue.d.ts +16 -17
  63. package/dist/runtime/components/Integrations/WorkflowTagsInput.vue +9 -8
  64. package/dist/runtime/components/Integrations/WorkflowTriggerView.vue +15 -13
  65. package/dist/runtime/components/Integrations/WorkflowVariablesDialog.vue +79 -39
  66. package/dist/runtime/components/Integrations/WorkflowsTable.vue +83 -45
  67. package/dist/runtime/composables/integrationsApi.js +4 -2
  68. package/dist/runtime/composables/useCredentialTypes.js +4 -2
  69. package/dist/runtime/composables/useIntegrationsCredentials.d.ts +11 -0
  70. package/dist/runtime/composables/useIntegrationsCredentials.js +8 -6
  71. package/dist/runtime/composables/useIntegrationsFocus.d.ts +12 -0
  72. package/dist/runtime/composables/useIntegrationsLocale.d.ts +26 -0
  73. package/dist/runtime/composables/useIntegrationsLocale.js +8 -0
  74. package/dist/runtime/composables/useIntegrationsNodes.js +14 -2
  75. package/dist/runtime/composables/useIntegrationsRealtime.d.ts +149 -0
  76. package/dist/runtime/composables/useIntegrationsRealtime.js +80 -0
  77. package/dist/runtime/composables/useIntegrationsRevisions.js +9 -7
  78. package/dist/runtime/composables/useIntegrationsRuns.d.ts +1 -1
  79. package/dist/runtime/composables/useIntegrationsRuns.js +3 -1
  80. package/dist/runtime/composables/useIntegrationsSchedules.d.ts +3 -2
  81. package/dist/runtime/composables/useIntegrationsSecrets.js +4 -2
  82. package/dist/runtime/composables/useIntegrationsT.d.ts +35 -0
  83. package/dist/runtime/composables/useIntegrationsT.js +9 -0
  84. package/dist/runtime/composables/useIntegrationsTemplates.d.ts +3 -3
  85. package/dist/runtime/composables/useIntegrationsTemplates.js +16 -8
  86. package/dist/runtime/composables/useIntegrationsTriggers.d.ts +23 -3
  87. package/dist/runtime/composables/useIntegrationsTriggers.js +21 -8
  88. package/dist/runtime/composables/useIntegrationsWorkflows.d.ts +10 -1
  89. package/dist/runtime/composables/useIntegrationsWorkflows.js +30 -7
  90. package/dist/runtime/composables/useNodeConfigResolve.js +4 -2
  91. package/dist/runtime/data/integrationNodeCatalog.d.ts +63 -1
  92. package/dist/runtime/data/integrationNodeCatalog.js +22 -4
  93. package/dist/runtime/data/integrationTemplates.d.ts +9 -0
  94. package/dist/runtime/data/integrationTemplates.js +7 -1
  95. package/dist/runtime/data/nodeInputData.d.ts +58 -1
  96. package/dist/runtime/data/nodeInputData.js +30 -5
  97. package/dist/runtime/data/payloadForm.js +2 -1
  98. package/dist/runtime/data/triggerGlyphs.d.ts +47 -0
  99. package/dist/runtime/data/triggerGlyphs.js +11 -0
  100. package/dist/runtime/data/workflowIconCatalog.js +2 -1
  101. package/dist/runtime/i18n/messages/de.json +1224 -0
  102. package/dist/runtime/i18n/messages/en.json +1224 -0
  103. package/dist/runtime/i18n/translate.d.ts +83 -0
  104. package/dist/runtime/i18n/translate.js +41 -0
  105. package/dist/runtime/pages/integrations/audit.vue +52 -34
  106. package/dist/runtime/pages/integrations/credentials/oauth-complete.vue +3 -2
  107. package/dist/runtime/pages/integrations/credentials.vue +73 -52
  108. package/dist/runtime/pages/integrations/index.vue +66 -73
  109. package/dist/runtime/pages/integrations/runs.vue +33 -21
  110. package/dist/runtime/pages/integrations/schedules.vue +49 -28
  111. package/dist/runtime/pages/integrations/secrets.vue +34 -34
  112. package/dist/runtime/pages/integrations/templates/[slug].vue +89 -72
  113. package/dist/runtime/pages/integrations/templates/index.vue +35 -27
  114. package/dist/runtime/pages/integrations/workflows/[id].vue +351 -197
  115. package/dist/runtime/pages/integrations/workflows/index.vue +10 -5
  116. package/dist/runtime/plugins/locale.d.ts +10 -0
  117. package/dist/runtime/plugins/locale.js +6 -0
  118. package/dist/runtime/plugins/realtime.client.d.ts +32 -0
  119. package/dist/runtime/plugins/realtime.client.js +4 -0
  120. package/dist/runtime/types/integrations.d.ts +25 -1
  121. package/dist/runtime/utils/cockpitSources.d.ts +22 -3
  122. package/dist/runtime/utils/credentialState.d.ts +30 -2
  123. package/dist/runtime/utils/credentialState.js +26 -11
  124. package/dist/runtime/utils/localize.d.ts +52 -5
  125. package/dist/runtime/utils/localize.js +47 -9
  126. package/dist/runtime/utils/pollBudget.d.ts +72 -0
  127. package/dist/runtime/utils/pollBudget.js +17 -0
  128. package/dist/runtime/utils/schedule.d.ts +0 -25
  129. package/dist/runtime/utils/schedule.js +37 -25
  130. package/dist/runtime/utils/servedPaths.d.ts +22 -0
  131. package/dist/runtime/utils/servedPaths.js +3 -0
  132. package/dist/runtime/utils/statusStyle.d.ts +26 -2
  133. package/dist/runtime/utils/statusStyle.js +9 -1
  134. package/dist/runtime/utils/validationFindings.d.ts +2 -2
  135. package/dist/runtime/utils/validationFindings.js +4 -3
  136. package/dist/types.d.mts +2 -6
  137. 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
+ });
@@ -318,7 +318,24 @@ export interface NodeManifest {
318
318
  title?: LocalizedString;
319
319
  category?: string;
320
320
  }>;
321
- input: NodeManifestInput;
321
+ /**
322
+ * The named ports a node accepts data on, keyed by port name — the mirror of
323
+ * `outputs`.
324
+ *
325
+ * **A node that accepts nothing arrives as `[]`, not `{}`**, which is why the
326
+ * empty array is in the type: the service encodes an empty PHP array as a JSON
327
+ * array, so the empty case is a different JSON *type* from the populated one. It
328
+ * is `never[]` rather than `NodeManifestInput[]` because a *populated* array is
329
+ * not a shape the service produces and not one anything here would read — only
330
+ * `[]` is real. Read the key through `inputPortsForManifest`, which handles both
331
+ * forms once.
332
+ *
333
+ * **Optional here, required there.** Every published manifest carries it — the
334
+ * service's schema lists it among the required keys — but the studio also builds
335
+ * manifest objects in tests and has to tolerate one that predates a field or
336
+ * omits it, so the type does not claim more than the studio can rely on.
337
+ */
338
+ inputs?: Record<string, NodeManifestInput> | never[];
322
339
  /** Empty marks a terminal node (PO-201): the run ends there, so the canvas
323
340
  * draws no output handle and no edge may leave it. */
324
341
  outputs: NodeManifestOutput[];
@@ -328,6 +345,13 @@ export interface NodeManifest {
328
345
  name: string;
329
346
  label: string | null;
330
347
  };
348
+ /**
349
+ * Whether this version's code was built and can therefore be executed
350
+ * (editor-side enrichment from `GET /nodes`; not manifest data). Absent means
351
+ * an API that does not report it — treated as executable, so an older host
352
+ * does not make every node disappear.
353
+ */
354
+ __hasBundle?: boolean;
331
355
  }
332
356
  export interface SecretRecord {
333
357
  key: string;
@@ -40,9 +40,18 @@
40
40
  * That is measured, not read off the regex: unimport's own `injectImports`, run
41
41
  * over this file, injects all five — and over the same file with the declarations
42
42
  * rewritten as `declare function`, injects nothing. Repeat it that way if the
43
- * spelling ever has to change. Nothing stands guard over it automatically, because
44
- * a test would mean this repository taking a dependency on a build tool it does
45
- * not otherwise use, to assert that tool's behaviour rather than its own.
43
+ * spelling ever has to change.
44
+ *
45
+ * **Since PO-271 something does stand guard over it, incidentally rather than by
46
+ * design.** The end-to-end suite runs against a host that auto-imports the same five
47
+ * names (`playground/app/composables/cockpitFakes.ts`), so the injection has to work
48
+ * *there* for `specs/credential-managed-source.md` AC-2 to pass at all: rewrite these
49
+ * as `declare function`, and unimport skips the injection in the playground exactly
50
+ * as it would in the cockpit, all five names are undefined, the panel disables
51
+ * itself, and AC-2 goes red. AC-1 would stay green, which is why this is worth
52
+ * writing down — the half of the spec that is about a host *without* the sources
53
+ * cannot see the difference. There is still no test asserting the build tool's
54
+ * behaviour directly, and there deliberately is not one.
46
55
  */
47
56
  /**
48
57
  * The shapes this module uses.
@@ -148,6 +157,16 @@ export interface CockpitMailCredential {
148
157
  * says nothing about why. `null` is what the caller turns into "the managed
149
158
  * sources are unavailable here", with the manual fields left in place.
150
159
  *
160
+ * **This is not a test for being inside a cockpit, and PO-223 learned that the hard
161
+ * way.** The dashboard read `!== null` as "am I in a cockpit" for its four shortcuts
162
+ * into host-owned surfaces, and the standalone dev studio answered yes: it provides
163
+ * all five names as inert stand-ins on purpose
164
+ * (`integrations/studio-ui/app/composables/consoleStubs.ts`) so that the managed
165
+ * credential panels render their empty states rather than nothing. What this answers
166
+ * is whether the five names are in scope — exactly what the managed sources need to
167
+ * know, and nothing beyond it. A caller asking anything else has to ask its own
168
+ * question; that dashboard now asks the router.
169
+ *
151
170
  * Must be called from a component's setup, like the composables it calls.
152
171
  */
153
172
  export declare function cockpitSources(): CockpitSources | null;
@@ -16,13 +16,41 @@ export interface CredentialStateInfo {
16
16
  label: string;
17
17
  /** Badge/pill classes for this state. */
18
18
  toneClass: string;
19
- /** Failure detail, when the state is `failed`. */
19
+ /**
20
+ * What the last test said, whether it passed or failed. A credential type that
21
+ * reached something can name it — which host, which environment, which plan —
22
+ * and that is exactly what an operator cannot see anywhere else once a node
23
+ * stops asking them to configure it.
24
+ */
20
25
  message?: string | null;
21
26
  /** When the underlying result was recorded (ISO). */
22
27
  at?: string | null;
23
28
  }
24
29
  type TestableCredential = Pick<Credential, 'status' | 'last_test_ok' | 'last_test_at' | 'last_test_message'>;
30
+ /** Every state a credential can be in, in the order a reader is offered them. */
31
+ export declare const CREDENTIAL_STATES: readonly ["connected", "failed", "needs_reauth", "untested"];
32
+ /**
33
+ * What a state is called for the reader, keyed rather than derived from a
34
+ * credential.
35
+ *
36
+ * `credentialState()` below answers "what state is this credential in, and what
37
+ * is it called"; this answers the second half on its own, for the list's filter,
38
+ * which offers all four states whether or not a credential is in them. They read
39
+ * the same map, so the badge on a row and the option that finds it cannot come to
40
+ * disagree — they did, briefly, as two copies of the same four `t()` calls.
41
+ */
42
+ export declare function credentialStateLabel(key: CredentialConnState): string;
43
+ /**
44
+ * The `label` is read at call time rather than held in a map beside `TONE`: a
45
+ * map built once would keep the language it was built in, and this function is
46
+ * called from inside the table's `computed()` on every render anyway.
47
+ */
25
48
  export declare function credentialState(c: TestableCredential): CredentialStateInfo;
26
- /** "just now" / "3m ago" / "2h ago" / "5d ago" / a date. Empty for no timestamp. */
49
+ /**
50
+ * "just now" / "3m ago" / "2h ago" / "5d ago" / a date. Empty for no timestamp.
51
+ *
52
+ * Reuses the studio's common time words (`integration.common.time.*`) rather
53
+ * than minting its own — the same rule as the dashboard's `formatRelative`.
54
+ */
27
55
  export declare function relativeTime(iso?: string | null): string;
28
56
  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, message: c.last_test_message, 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,59 @@
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;
14
+ /**
15
+ * The key a read answers from — which is the key a *deletion* has to remove, since
16
+ * what the user is clearing is the text in front of them.
17
+ *
18
+ * All four steps, in the order `resolveLocalized` reads them: the exact tag, the same
19
+ * language in another region, English, and the map's own first entry. `''` when the
20
+ * map holds no string at all.
21
+ */
22
+ export declare function languageKeyRead(map: unknown, locale?: string): string;
23
+ /**
24
+ * The key an edit is written to.
25
+ *
26
+ * The first two steps of {@link languageKeyRead} and deliberately not its last two:
27
+ * falling back to English or to the first entry answers "what can I show you", and
28
+ * writing there would put the author's text into a language they are not reading —
29
+ * the silent loss this exists to prevent (PO-259). A language the map does not hold
30
+ * yet is written under its base subtag, never the tag the host set, so that reading
31
+ * it back finds it: a `de-CH` studio reading a `de` text through the region step
32
+ * would otherwise write a new `de-CH` key and leave every plain-`de` reader on the
33
+ * old wording.
34
+ *
35
+ * **That guarantee is about the keys this function invents.** The first two steps
36
+ * return a key the map already holds, verbatim — so a map carrying `DE-ch` is written
37
+ * back into `DE-ch`, which the API's `^[a-z]{2,3}(-[A-Z]{2})?$` rejects. Not a case
38
+ * that can arise: such a map could not have been saved in the first place, and the
39
+ * only writer of these keys is this function. Returning an existing key as it stands
40
+ * is what makes the write land where the read came from, which is worth more here than
41
+ * normalising a shape nothing can produce — but it is why the sentence above says
42
+ * *invents* rather than *writes*.
43
+ *
44
+ * Deleting is the other question and has the other answer — see
45
+ * {@link languageKeyRead}.
46
+ */
47
+ export declare function languageKeyToWrite(map: unknown, locale?: string): string;
1
48
  /**
2
49
  * Resolve a multilingual value from the Integrations API. The API returns
3
50
  * names and descriptions either as a plain string or as an already-parsed
4
51
  * object `{de:"…", en:"…"}` (the HTTP client does JSON decoding automatically).
5
52
  * JSON-encoded strings like `'{"de":"…","en":"…"}'` are also handled.
6
53
  *
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.
54
+ * The language is the studio's ({@link setStudioLocale}) unless the caller
55
+ * names one. English is the fallback, and the map's own first entry the
56
+ * fallback after that.
10
57
  *
11
58
  * **This is the studio's only resolver, and that is the point.** The same three
12
59
  * lines used to sit in seventeen components under six names (`labelText`, `loc`,
@@ -22,7 +69,7 @@
22
69
  * `descriptionText`), so no grep for the rule's shape found them. Both resolve
23
70
  * text a person typed and went to `resolveDisplayName` below, not here.
24
71
  */
25
- export declare function resolveLocalized(value: unknown): string;
72
+ export declare function resolveLocalized(value: unknown, locale?: string): string;
26
73
  /**
27
74
  * The same resolution, for a value that may be **text a person wrote** — a
28
75
  * node's own name, a group frame's label.
@@ -37,4 +84,4 @@ export declare function resolveLocalized(value: unknown): string;
37
84
  * resolved. Use this wherever the value can be something a person typed; use
38
85
  * `resolveLocalized` for anything that can only have come from a manifest.
39
86
  */
40
- export declare function resolveDisplayName(value: string | Record<string, string> | undefined | null): string;
87
+ export declare function resolveDisplayName(value: string | Record<string, string> | undefined | null, locale?: string): string;
@@ -1,11 +1,49 @@
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 baseLanguage(locale) {
11
+ const base = locale.toLowerCase().split(/[-_]/)[0] ?? "";
12
+ return /^[a-z]{2,3}$/.test(base) ? base : "en";
13
+ }
14
+ export function languageKeyRead(map, locale = studioLocale()) {
15
+ const obj = map && typeof map === "object" ? map : {};
16
+ const keys = Object.keys(obj);
17
+ if (!keys.length) return "";
18
+ const wanted = locale.toLowerCase();
19
+ const base = baseLanguage(locale);
20
+ const candidates = [
21
+ keys.find((k) => k.toLowerCase() === wanted),
22
+ keys.find((k) => baseLanguage(k) === base),
23
+ keys.find((k) => baseLanguage(k) === "en"),
24
+ keys[0]
25
+ ];
26
+ for (const key of candidates) {
27
+ if (key === void 0) continue;
28
+ if (typeof obj[key] === "string") return key;
29
+ }
30
+ return "";
31
+ }
32
+ export function languageKeyToWrite(map, locale = studioLocale()) {
33
+ const keys = map && typeof map === "object" ? Object.keys(map) : [];
34
+ const wanted = locale.toLowerCase();
35
+ const base = baseLanguage(locale);
36
+ return keys.find((k) => k.toLowerCase() === wanted) ?? keys.find((k) => baseLanguage(k) === base) ?? base;
37
+ }
38
+ function pickLanguage(map, locale) {
39
+ const key = languageKeyRead(map, locale);
40
+ const picked = key ? map[key] : void 0;
41
+ return typeof picked === "string" ? picked : "";
42
+ }
43
+ export function resolveLocalized(value, locale = uiLocale.value) {
2
44
  if (value === null || value === void 0) return "";
3
45
  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 : "";
46
+ return pickLanguage(value, locale);
9
47
  }
10
48
  if (typeof value !== "string") return String(value);
11
49
  const trimmed = value.trim();
@@ -13,12 +51,12 @@ export function resolveLocalized(value) {
13
51
  try {
14
52
  const parsed = JSON.parse(trimmed);
15
53
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return trimmed;
16
- return resolveLocalized(parsed);
54
+ return resolveLocalized(parsed, locale);
17
55
  } catch {
18
56
  return trimmed;
19
57
  }
20
58
  }
21
- export function resolveDisplayName(value) {
59
+ export function resolveDisplayName(value, locale = uiLocale.value) {
22
60
  if (typeof value === "string") return value;
23
- return resolveLocalized(value);
61
+ return resolveLocalized(value, locale);
24
62
  }
@@ -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-8'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. */