@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,7 +3,7 @@ import { computed, onMounted, reactive, ref } from "vue";
3
3
  import {
4
4
  RefreshCircle as Loader2,
5
5
  AddCircle as Plus,
6
- TrashBinTrash as Trash2,
6
+ TrashBinMinimalistic as TrashBin,
7
7
  TestTube as TestIcon
8
8
  } from "#solar-icons";
9
9
  import {
@@ -32,10 +32,11 @@ import {
32
32
  } from "@revenexx/studio";
33
33
  import { toast } from "../../utils/notify";
34
34
  import { apiFieldErrors, refusalReason } from "../../utils/apiError";
35
- import { credentialState } from "../../utils/credentialState";
35
+ import { CREDENTIAL_STATES, credentialState, credentialStateLabel } from "../../utils/credentialState";
36
36
  import { hasRevenexxSource } from "../../utils/revenexxCredentialSource";
37
37
  import { resolveLocalized } from "../../utils/localize";
38
- useHead({ title: "Credentials \xB7 Revenexx Cockpit" });
38
+ const t = useIntegrationsT();
39
+ useHead({ title: () => t("integration.credentials.list.head.title") });
39
40
  const { credentials, loading, error, load, create, update, remove, testInline, test, oauthAuthorizeUrl } = useIntegrationsCredentials();
40
41
  const { types, load: loadTypes, bySlug } = useCredentialTypes();
41
42
  onMounted(async () => {
@@ -47,8 +48,8 @@ function typeFor(slug) {
47
48
  function typeName(slug) {
48
49
  return resolveLocalized(typeFor(slug)?.name) || slug;
49
50
  }
50
- function isOAuthAuthcode(t) {
51
- return t?.auth_kind === "oauth2-authcode";
51
+ function isOAuthAuthcode(t2) {
52
+ return t2?.auth_kind === "oauth2-authcode";
52
53
  }
53
54
  function formatDateTime(iso) {
54
55
  if (!iso) return "\u2014";
@@ -58,15 +59,32 @@ function formatDateTime(iso) {
58
59
  return iso;
59
60
  }
60
61
  }
61
- const columns = [
62
- { key: "system", label: "System", width: "minmax(160px,220px)", sortable: true, sortGetter: (c) => typeName(c.credential_type_slug).toLowerCase(), searchGetter: (c) => `${typeName(c.credential_type_slug)} ${c.credential_type_slug}` },
63
- { key: "name", label: "Name", sortable: true, sortGetter: (c) => c.name.toLowerCase(), searchGetter: (c) => c.name },
64
- { key: "status", label: "Status", width: "150px", sortable: true, sortGetter: (c) => credentialState(c).key },
65
- { key: "connected", label: "Connected", width: "150px", sortable: true, sortGetter: (c) => c.last_test_ok ? c.last_test_at ?? "" : "" },
66
- { key: "created", label: "Created", width: "150px", sortable: true, sortGetter: (c) => c.created_at ?? "" },
67
- { key: "updated", label: "Updated", width: "150px", sortable: true, sortGetter: (c) => c.updated_at ?? "" },
62
+ const filterTabs = computed(() => {
63
+ const systems = [...new Set(credentials.value.map((c) => c.credential_type_slug))].map((slug) => ({ value: slug, label: typeName(slug) })).sort((a, b) => a.label.localeCompare(b.label));
64
+ return [
65
+ {
66
+ key: "state",
67
+ label: t("integration.credentials.list.table.column.status"),
68
+ getter: (c) => credentialState(c).key,
69
+ options: CREDENTIAL_STATES.map((key) => ({ value: key, label: credentialStateLabel(key) }))
70
+ },
71
+ {
72
+ key: "system",
73
+ label: t("integration.credentials.list.table.column.system"),
74
+ getter: (c) => c.credential_type_slug,
75
+ options: systems
76
+ }
77
+ ];
78
+ });
79
+ const columns = computed(() => [
80
+ { key: "system", label: t("integration.credentials.list.table.column.system"), width: "minmax(160px,220px)", sortable: true, sortGetter: (c) => typeName(c.credential_type_slug).toLowerCase(), searchGetter: (c) => `${typeName(c.credential_type_slug)} ${c.credential_type_slug}` },
81
+ { key: "name", label: t("integration.credentials.list.table.column.name"), sortable: true, sortGetter: (c) => c.name.toLowerCase(), searchGetter: (c) => c.name },
82
+ { key: "status", label: t("integration.credentials.list.table.column.status"), width: "150px", sortable: true, sortGetter: (c) => credentialState(c).key },
83
+ { key: "connected", label: t("integration.credentials.list.table.column.connected"), width: "150px", sortable: true, sortGetter: (c) => c.last_test_ok ? c.last_test_at ?? "" : "" },
84
+ { key: "created", label: t("integration.credentials.list.table.column.created"), width: "150px", sortable: true, sortGetter: (c) => c.created_at ?? "" },
85
+ { key: "updated", label: t("integration.credentials.list.table.column.updated"), width: "150px", sortable: true, sortGetter: (c) => c.updated_at ?? "" },
68
86
  { key: "actions", label: "", width: "80px" }
69
- ];
87
+ ]);
70
88
  const dialogOpen = ref(false);
71
89
  const editing = ref(null);
72
90
  const pickedType = ref(null);
@@ -78,8 +96,8 @@ const saveError = ref(null);
78
96
  const fieldErrors = ref({});
79
97
  const sourceActive = ref(false);
80
98
  const isEdit = computed(() => editing.value !== null);
81
- function fieldsOf(t) {
82
- return t?.fields ?? [];
99
+ function fieldsOf(t2) {
100
+ return t2?.fields ?? [];
83
101
  }
84
102
  function configFieldAria(key) {
85
103
  return fieldErrors.value[`config.${key}`] ? { "aria-invalid": "true", "aria-describedby": `cred-field-${key}-error` } : {};
@@ -88,9 +106,9 @@ function clearRefusal() {
88
106
  saveError.value = null;
89
107
  fieldErrors.value = {};
90
108
  }
91
- function openCreate(t) {
109
+ function openCreate(t2) {
92
110
  editing.value = null;
93
- pickedType.value = t ?? null;
111
+ pickedType.value = t2 ?? null;
94
112
  form.name = "";
95
113
  form.config = {};
96
114
  testResult.value = null;
@@ -144,7 +162,7 @@ async function submit() {
144
162
  if (isEdit.value && editing.value) {
145
163
  await update(editing.value.id, { name: form.name.trim(), config: buildConfigPayload() });
146
164
  dialogOpen.value = false;
147
- toast.success("Credential updated");
165
+ toast.success(t("integration.credentials.toast.updated"));
148
166
  } else {
149
167
  const created = await create({
150
168
  credential_type_slug: pickedType.value.slug,
@@ -152,14 +170,14 @@ async function submit() {
152
170
  config: buildConfigPayload()
153
171
  });
154
172
  dialogOpen.value = false;
155
- toast.success("Credential created");
173
+ toast.success(t("integration.credentials.toast.created"));
156
174
  if (isOAuthAuthcode(pickedType.value)) {
157
175
  await connect(created);
158
176
  }
159
177
  }
160
178
  } catch (e) {
161
179
  fieldErrors.value = apiFieldErrors(e);
162
- saveError.value = refusalReason(e, "Could not save credential.");
180
+ saveError.value = refusalReason(e, t("integration.credentials.error.save"));
163
181
  toast.error(saveError.value);
164
182
  } finally {
165
183
  saving.value = false;
@@ -175,7 +193,7 @@ async function connect(c) {
175
193
  if (ev.data?.type === "integrations-oauth-complete") {
176
194
  window.removeEventListener("message", onMessage);
177
195
  await load(true);
178
- toast.success("Connected");
196
+ toast.success(t("integration.credentials.toast.connected"));
179
197
  }
180
198
  };
181
199
  window.addEventListener("message", onMessage);
@@ -187,7 +205,7 @@ async function connect(c) {
187
205
  }
188
206
  }, 1e3);
189
207
  } catch (e) {
190
- toast.error(e?.message ?? "Could not start OAuth connect.");
208
+ toast.error(e?.message ?? t("integration.credentials.error.oauthConnect"));
191
209
  } finally {
192
210
  connecting.value = null;
193
211
  }
@@ -200,10 +218,10 @@ async function confirmDelete() {
200
218
  deleting.value = true;
201
219
  try {
202
220
  await remove(c.id);
203
- toast.success("Credential deleted");
221
+ toast.success(t("integration.credentials.toast.deleted"));
204
222
  pendingDelete.value = null;
205
223
  } catch (e) {
206
- toast.error(e?.message ?? "Could not delete credential.");
224
+ toast.error(e?.message ?? t("integration.credentials.error.delete"));
207
225
  } finally {
208
226
  deleting.value = false;
209
227
  }
@@ -213,13 +231,13 @@ async function confirmDelete() {
213
231
  <template>
214
232
  <IntegrationsLayout>
215
233
  <PageHeader
216
- title="Credentials"
217
- description="Typed, testable access data for the systems your workflows talk to — one per connection."
234
+ :title="t('integration.credentials.list.header.title')"
235
+ :description="t('integration.credentials.list.header.description')"
218
236
  >
219
237
  <template #actions>
220
238
  <Button class="h-7 text-sm" :disabled="!types.length" @click="openCreate()">
221
239
  <Plus :size="14" class="mr-1" />
222
- New credential
240
+ {{ t("integration.credentials.list.action.new") }}
223
241
  </Button>
224
242
  </template>
225
243
  </PageHeader>
@@ -234,10 +252,10 @@ async function confirmDelete() {
234
252
  :columns="columns"
235
253
  :row-key="(c2) => c2.id"
236
254
  :default-sort="{ column: 'name', direction: 'asc' }"
237
- :search="{ placeholder: 'Search credentials\u2026' }"
238
- :filter="true"
255
+ :search="{ placeholder: t('integration.credentials.list.search.placeholder') }"
256
+ :filter-tabs="filterTabs"
239
257
  :loading="loading"
240
- empty-text="No credentials yet — hit “New credential” to connect your first system."
258
+ :empty-text="t('integration.credentials.list.empty', { action: t('integration.credentials.list.action.new') })"
241
259
  @row-click="(c2) => openEdit(c2)"
242
260
  >
243
261
  <template #cell-system="{ row: c }">
@@ -288,13 +306,13 @@ async function confirmDelete() {
288
306
  size="sm"
289
307
  class="h-7 text-amber-600"
290
308
  :disabled="connecting === c.id"
291
- :aria-label="`Reconnect ${c.name}`"
309
+ :aria-label="t('integration.credentials.list.action.reconnect.aria', { name: c.name })"
292
310
  @click="connect(c)"
293
311
  >
294
- Reconnect
312
+ {{ t("integration.credentials.list.action.reconnect") }}
295
313
  </Button>
296
- <button type="button" class="flex size-7 items-center justify-center rounded text-foreground/40 hover:text-rose-600 hover:bg-rose-500/10" :title="`Delete ${c.name}`" :aria-label="`Delete ${c.name}`" @click="pendingDelete = c">
297
- <Trash2 :size="13" />
314
+ <button type="button" class="flex size-7 items-center justify-center rounded text-foreground/40 hover:text-rose-600 hover:bg-rose-500/10" :title="t('integration.credentials.list.action.delete.aria', { name: c.name })" :aria-label="t('integration.credentials.list.action.delete.aria', { name: c.name })" @click="pendingDelete = c">
315
+ <TrashBin :size="13" />
298
316
  </button>
299
317
  </div>
300
318
  </template>
@@ -305,9 +323,9 @@ async function confirmDelete() {
305
323
  <Dialog v-model:open="dialogOpen">
306
324
  <DialogContent class="sm:max-w-lg">
307
325
  <DialogHeader>
308
- <DialogTitle>{{ isEdit ? "Edit credential" : "New credential" }}</DialogTitle>
326
+ <DialogTitle>{{ isEdit ? t("integration.credentials.shared.dialog.title.edit") : t("integration.credentials.shared.dialog.title.create") }}</DialogTitle>
309
327
  <DialogDescription class="text-sm">
310
- {{ isEdit ? "Update the credential. Leave secret fields blank to keep the current value." : "Pick a type and fill in its access data." }}
328
+ {{ isEdit ? t("integration.credentials.list.dialog.description.edit") : t("integration.credentials.list.dialog.description.create") }}
311
329
  </DialogDescription>
312
330
  </DialogHeader>
313
331
 
@@ -315,9 +333,9 @@ async function confirmDelete() {
315
333
  <IntegrationsCredentialTypePicker
316
334
  v-if="!pickedType"
317
335
  :types="types"
318
- @pick="(t) => {
319
- pickedType = t;
320
- form.name = resolveLocalized(t.name);
336
+ @pick="(picked) => {
337
+ pickedType = picked;
338
+ form.name = resolveLocalized(picked.name);
321
339
  clearRefusal();
322
340
  }"
323
341
  />
@@ -325,12 +343,12 @@ async function confirmDelete() {
325
343
  <!-- Step 2: dynamic form -->
326
344
  <div v-else class="space-y-3 py-1 max-h-[28rem] overflow-y-auto pr-1">
327
345
  <div class="space-y-1.5">
328
- <Label for="cred-name">Name</Label>
346
+ <Label for="cred-name">{{ t("integration.credentials.shared.field.name.label") }}</Label>
329
347
  <Input
330
348
  id="cred-name"
331
349
  v-model="form.name"
332
350
  class="bg-white"
333
- placeholder="e.g. Production SMTP"
351
+ :placeholder="t('integration.credentials.list.dialog.field.name.placeholder')"
334
352
  :aria-invalid="fieldErrors.name ? 'true' : void 0"
335
353
  :aria-describedby="fieldErrors.name ? 'cred-name-error' : void 0"
336
354
  />
@@ -362,7 +380,7 @@ async function confirmDelete() {
362
380
  :model-value="form.config[f.key] ?? ''"
363
381
  type="password"
364
382
  autocomplete="new-password"
365
- :placeholder="isEdit ? 'Leave blank to keep current' : ''"
383
+ :placeholder="isEdit ? t('integration.credentials.shared.field.secret.placeholder') : ''"
366
384
  class="bg-white"
367
385
  v-bind="configFieldAria(f.key)"
368
386
  @update:model-value="(v) => form.config[f.key] = v"
@@ -388,7 +406,7 @@ async function confirmDelete() {
388
406
  @update:model-value="(v) => form.config[f.key] = v"
389
407
  >
390
408
  <SelectTrigger :id="`cred-field-${f.key}`" class="bg-white" v-bind="configFieldAria(f.key)">
391
- <SelectValue placeholder="Pick one" />
409
+ <SelectValue :placeholder="t('integration.credentials.shared.field.select.placeholder')" />
392
410
  </SelectTrigger>
393
411
  <SelectContent>
394
412
  <SelectItem v-for="opt in f.options ?? []" :key="String(opt.value)" :value="String(opt.value)">
@@ -416,7 +434,7 @@ async function confirmDelete() {
416
434
  </div>
417
435
 
418
436
  <p v-if="isOAuthAuthcode(pickedType) && !isEdit" class="text-sm text-muted-foreground">
419
- After saving, a window opens to connect with the provider.
437
+ {{ t("integration.credentials.shared.oauthHint") }}
420
438
  </p>
421
439
 
422
440
  <div
@@ -424,7 +442,10 @@ async function confirmDelete() {
424
442
  class="rounded-md px-3 py-2 text-sm"
425
443
  :class="testResult.ok ? 'bg-emerald-500/10 text-emerald-700' : 'bg-rose-500/10 text-rose-700'"
426
444
  >
427
- {{ testResult.ok ? "Connection ok." : testResult.error ?? "Connection failed." }}
445
+ <!-- What the credential type said, whichever way the test went. The fixed
446
+ sentences are the fallback for a type that reports nothing, not the
447
+ first choice (specs/credential-test.md). -->
448
+ {{ testResult.ok ? testResult.message ?? t("integration.credentials.shared.test.ok") : testResult.error ?? testResult.message ?? t("integration.credentials.shared.test.failed") }}
428
449
  </div>
429
450
 
430
451
  </div>
@@ -442,18 +463,18 @@ async function confirmDelete() {
442
463
  <DialogFooter v-if="pickedType" class="sm:justify-between">
443
464
  <Button v-if="!isEdit" variant="ghost" size="sm" :disabled="saving || testing" @click="pickedType = null;
444
465
  clearRefusal()">
445
- Back
466
+ {{ t("integration.credentials.list.dialog.action.back") }}
446
467
  </Button>
447
468
  <span v-else />
448
469
  <div class="flex items-center gap-2">
449
470
  <Button variant="outline" :disabled="saving || testing" @click="runTest">
450
471
  <Loader2 v-if="testing" :size="14" class="mr-1 animate-spin" />
451
472
  <TestIcon v-else :size="14" class="mr-1" />
452
- Connect
473
+ {{ t("integration.credentials.shared.action.testConnection") }}
453
474
  </Button>
454
475
  <Button :disabled="saving || !form.name.trim()" @click="submit">
455
476
  <Loader2 v-if="saving" :size="14" class="mr-1 animate-spin" />
456
- Save
477
+ {{ t("integration.common.action.save") }}
457
478
  </Button>
458
479
  </div>
459
480
  </DialogFooter>
@@ -465,21 +486,21 @@ async function confirmDelete() {
465
486
  }">
466
487
  <AlertDialogContent>
467
488
  <AlertDialogHeader>
468
- <AlertDialogTitle>Delete credential?</AlertDialogTitle>
489
+ <AlertDialogTitle>{{ t("integration.credentials.shared.confirm.delete.title") }}</AlertDialogTitle>
469
490
  <AlertDialogDescription>
470
491
  <span v-if="pendingDelete">
471
492
  <span class="font-medium text-foreground">{{ pendingDelete.name }}</span>
472
- will be removed. Workflows that reference it will fail until they are re-bound to another credential.
493
+ {{ t("integration.credentials.shared.confirm.delete.body") }}
473
494
  </span>
474
495
  </AlertDialogDescription>
475
496
  </AlertDialogHeader>
476
497
  <AlertDialogFooter>
477
498
  <Button variant="outline" :disabled="deleting" @click="pendingDelete = null">
478
- Cancel
499
+ {{ t("integration.common.action.cancel") }}
479
500
  </Button>
480
501
  <Button class="bg-destructive text-white hover:bg-destructive/90" :disabled="deleting" @click="confirmDelete">
481
502
  <Loader2 v-if="deleting" :size="14" class="animate-spin" />
482
- {{ deleting ? "Deleting\u2026" : "Delete" }}
503
+ {{ deleting ? t("integration.common.state.deleting") : t("integration.common.action.delete") }}
483
504
  </Button>
484
505
  </AlertDialogFooter>
485
506
  </AlertDialogContent>
@@ -1,15 +1,19 @@
1
1
  <script setup>
2
2
  import { computed, ref, watch } from "vue";
3
+ import SurfaceCard from "../../components/Integrations/SurfaceCard.vue";
3
4
  import TemplateCard from "../../components/Integrations/TemplateCard.vue";
4
5
  import WorkflowCreateDialog from "../../components/Integrations/WorkflowCreateDialog.vue";
5
6
  import { Pulse as Activity, DangerTriangle as AlertTriangle, ArrowRight, Box as Boxes, ClockCircle as Clock, Database, Layers, AddCircle as Plus, Radio, MagicStick as Sparkles, LinkRound as Webhook, Programming as Workflow } from "#solar-icons";
6
7
  import { Badge, Button } from "@revenexx/studio";
7
- useHead({ title: "Dashboard \xB7 Integration Studio" });
8
+ import { servedPaths } from "../../utils/servedPaths";
9
+ import { runStatusLabel, statusClass } from "../../utils/statusStyle";
10
+ const t = useIntegrationsT();
11
+ useHead({ title: () => t("integration.dashboard.head.title") });
8
12
  const { workflows, activeCount: activeWorkflows, loading: workflowsLoading, load: loadWorkflows } = useIntegrationsWorkflows();
9
13
  const { runs, runningCount, failedCount, loading: runsLoading, load: loadRuns } = useIntegrationsRuns();
10
14
  const { startingPoints, loading: templatesLoading, load: loadTemplates, ensureDetails } = useIntegrationsTemplates();
11
- watch(() => startingPoints.value.map((t) => t.slug).join(","), () => {
12
- if (startingPoints.value.length) void ensureDetails(startingPoints.value.map((t) => t.slug));
15
+ watch(() => startingPoints.value.map((t2) => t2.slug).join(","), () => {
16
+ if (startingPoints.value.length) void ensureDetails(startingPoints.value.map((t2) => t2.slug));
13
17
  }, { immediate: true });
14
18
  const { activeCount: schedulesActive, load: loadSchedules } = useIntegrationsSchedules();
15
19
  const { pendingCount: missedRuns, globalLoading: missedLoading, loadGlobal: loadMissedRuns } = useIntegrationsDeadLetters();
@@ -39,80 +43,77 @@ const failedRecent = computed(() => latestByWorkflow.value.filter((r) => r.statu
39
43
  function formatRelative(iso) {
40
44
  if (!iso) return "\u2014";
41
45
  const ms = Date.now() - new Date(iso).getTime();
42
- if (ms < 6e4) return "just now";
43
- if (ms < 36e5) return `${Math.floor(ms / 6e4)}m ago`;
44
- if (ms < 864e5) return `${Math.floor(ms / 36e5)}h ago`;
45
- return `${Math.floor(ms / 864e5)}d ago`;
46
- }
47
- function statusClass(status) {
48
- switch (status) {
49
- case "succeeded":
50
- return "bg-emerald-500/15 text-emerald-700 border-emerald-500/20";
51
- case "running":
52
- return "bg-sky-500/15 text-sky-700 border-sky-500/20";
53
- case "failed":
54
- return "bg-rose-500/15 text-rose-700 border-rose-500/20";
55
- case "queued":
56
- return "bg-amber-500/15 text-amber-700 border-amber-500/20";
57
- case "cancelled":
58
- return "bg-slate-500/15 text-slate-700 border-slate-500/20";
59
- }
46
+ if (ms < 6e4) return t("integration.common.time.justNow");
47
+ if (ms < 36e5) return t("integration.common.time.minutesAgo", Math.floor(ms / 6e4));
48
+ if (ms < 864e5) return t("integration.common.time.hoursAgo", Math.floor(ms / 36e5));
49
+ return t("integration.common.time.daysAgo", Math.floor(ms / 864e5));
60
50
  }
61
51
  const kpis = computed(() => [
62
52
  {
63
- label: "Active workflows",
53
+ key: "activeWorkflows",
54
+ label: t("integration.dashboard.kpi.activeWorkflows.label"),
64
55
  value: activeWorkflows.value,
65
- sub: `${workflows.value.length - activeWorkflows.value} paused or drafts`,
56
+ // A plural key, so the count is the second argument. Called without one it
57
+ // would put the raw "a | b" on screen.
58
+ sub: t("integration.dashboard.kpi.activeWorkflows.sub", workflows.value.length - activeWorkflows.value),
66
59
  icon: Workflow,
67
60
  tile: "bg-emerald-500 text-white"
68
61
  },
69
62
  {
70
- label: "Running now",
63
+ key: "runningNow",
64
+ label: t("integration.dashboard.kpi.runningNow.label"),
71
65
  value: runningCount.value,
72
- sub: "live executions in flight",
66
+ sub: t("integration.dashboard.kpi.runningNow.sub"),
73
67
  icon: Activity,
74
68
  tile: "bg-sky-500 text-white"
75
69
  },
76
70
  {
77
- label: "Scheduled",
71
+ key: "scheduled",
72
+ label: t("integration.dashboard.kpi.scheduled.label"),
78
73
  value: schedulesActive.value,
79
- sub: "scheduled triggers active",
74
+ sub: t("integration.dashboard.kpi.scheduled.sub"),
80
75
  icon: Clock,
81
76
  tile: "bg-violet-500 text-white"
82
77
  },
83
78
  {
84
- label: "Missed runs",
79
+ key: "missedRuns",
80
+ label: t("integration.dashboard.kpi.missedRuns.label"),
85
81
  value: missedRuns.value,
86
- sub: "trigger firings without a run",
82
+ sub: t("integration.dashboard.kpi.missedRuns.sub"),
87
83
  icon: AlertTriangle,
88
84
  tile: missedRuns.value ? "bg-amber-500 text-white" : "bg-slate-300 text-white",
89
85
  to: "/integrations/runs?tab=missed"
90
86
  }
91
87
  ]);
92
- const surfaces = [
93
- { label: "Import & Export", to: "/integrations/io", icon: Database, hint: "Profiles that move catalogue and entity data in and out." },
94
- { label: "SFTP Server", to: "/integrations/sftp", icon: Boxes, hint: "Managed SFTP endpoints for file-based exchange." },
95
- { label: "Event Stream", to: "/integrations/events", icon: Radio, hint: "Live platform events that workflows subscribe to." },
96
- { label: "Webhooks", to: "/integrations/webhooks", icon: Webhook, hint: "Inbound HTTP endpoints that trigger workflows." }
97
- ];
88
+ const surfaces = computed(() => [
89
+ { label: t("integration.dashboard.surface.io.label"), to: "/integrations/io", icon: Database, hint: t("integration.dashboard.surface.io.hint") },
90
+ { label: t("integration.dashboard.surface.sftp.label"), to: "/integrations/sftp", icon: Boxes, hint: t("integration.dashboard.surface.sftp.hint") },
91
+ { label: t("integration.dashboard.surface.events.label"), to: "/integrations/events", icon: Radio, hint: t("integration.dashboard.surface.events.hint") },
92
+ { label: t("integration.dashboard.surface.webhooks.label"), to: "/integrations/webhooks", icon: Webhook, hint: t("integration.dashboard.surface.webhooks.hint") }
93
+ ]);
94
+ const router = useRouter();
95
+ function surfaceCards() {
96
+ const served = servedPaths(router.getRoutes().map((route) => route.path));
97
+ return surfaces.value.map((sfc) => ({ ...sfc, available: served.has(sfc.to) }));
98
+ }
98
99
  </script>
99
100
 
100
101
  <template>
101
102
  <IntegrationsLayout>
102
103
  <PageHeader
103
- title="Dashboard"
104
- description="Everything that connects Revenue Cloud to the outside world."
104
+ :title="t('integration.dashboard.header.title')"
105
+ :description="t('integration.dashboard.header.description')"
105
106
  >
106
107
  <template #actions>
107
108
  <NuxtLink to="/integrations/templates">
108
109
  <Button variant="outline" class="h-7 text-sm">
109
110
  <Layers :size="12" />
110
- Browse templates
111
+ {{ t("integration.dashboard.action.browseTemplates") }}
111
112
  </Button>
112
113
  </NuxtLink>
113
114
  <Button class="h-7 text-sm" @click="createOpen = true">
114
115
  <Plus :size="14" />
115
- New workflow
116
+ {{ t("integration.dashboard.action.newWorkflow") }}
116
117
  </Button>
117
118
  </template>
118
119
  </PageHeader>
@@ -122,11 +123,11 @@ const surfaces = [
122
123
  tile is a link, and a link takes its name from its label instead of its
123
124
  contents — named "Missed runs" alone it would announce less than the
124
125
  three tiles beside it. -->
125
- <div role="group" aria-label="Key figures" class="grid gap-2 sm:grid-cols-2 lg:grid-cols-4">
126
+ <div role="group" :aria-label="t('integration.dashboard.kpi.aria.group')" class="grid gap-2 sm:grid-cols-2 lg:grid-cols-4">
126
127
  <component
127
128
  :is="k.to ? NuxtLinkComp : 'div'"
128
129
  v-for="k in kpis"
129
- :key="k.label"
130
+ :key="k.key"
130
131
  :to="k.to"
131
132
  :role="k.to ? void 0 : 'group'"
132
133
  :aria-label="`${k.label}: ${k.value}`"
@@ -156,16 +157,16 @@ const surfaces = [
156
157
  <!-- Two-column: recent runs + needs attention + templates to start from -->
157
158
  <div class="grid gap-4 lg:grid-cols-3">
158
159
  <!-- Recent runs ─ takes 2 cols -->
159
- <div role="group" aria-label="Latest executions" class="lg:col-span-2 rounded-lg border border-black/[0.06] bg-white">
160
+ <div role="group" :aria-label="t('integration.dashboard.runs.title')" class="lg:col-span-2 rounded-lg border border-black/[0.06] bg-white">
160
161
  <div class="flex items-center justify-between border-b border-black/[0.06] px-3 py-2">
161
162
  <div class="flex items-center gap-2">
162
163
  <span class="flex size-6 items-center justify-center rounded-md bg-slate-100 text-slate-500">
163
164
  <Activity :size="12" />
164
165
  </span>
165
- <h3 class="text-sm font-semibold">Latest executions</h3>
166
+ <h3 class="text-sm font-semibold">{{ t("integration.dashboard.runs.title") }}</h3>
166
167
  </div>
167
168
  <NuxtLink to="/integrations/runs" class="text-sm text-muted-foreground hover:text-foreground inline-flex items-center gap-1">
168
- View all <ArrowRight :size="11" />
169
+ {{ t("integration.common.action.viewAll") }} <ArrowRight :size="11" />
169
170
  </NuxtLink>
170
171
  </div>
171
172
  <div>
@@ -180,7 +181,7 @@ const surfaces = [
180
181
  class="grid grid-cols-[90px_minmax(0,1fr)_100px_70px] items-center gap-2 border-b border-black/[0.04] px-3 py-2 text-sm last:border-b-0 hover:bg-muted/30"
181
182
  >
182
183
  <Badge variant="outline" :class="['border text-xs font-medium uppercase', statusClass(r.status)]">
183
- {{ r.status }}
184
+ {{ runStatusLabel(r.status) }}
184
185
  </Badge>
185
186
  <div class="truncate">
186
187
  <div class="font-medium text-foreground truncate">{{ r.workflowName }}</div>
@@ -209,19 +210,19 @@ const surfaces = [
209
210
  </div>
210
211
  </template>
211
212
  <div v-else-if="!latestPerWorkflow.length" class="px-3 py-8 text-center text-sm text-muted-foreground">
212
- No runs yet.
213
+ {{ t("integration.dashboard.runs.empty") }}
213
214
  </div>
214
215
  </div>
215
216
  </div>
216
217
 
217
218
  <!-- Needs attention -->
218
- <div role="group" aria-label="Needs attention" class="rounded-lg border border-black/[0.06] bg-white">
219
+ <div role="group" :aria-label="t('integration.dashboard.attention.title')" class="rounded-lg border border-black/[0.06] bg-white">
219
220
  <div class="flex items-center justify-between border-b border-black/[0.06] px-3 py-2">
220
221
  <div class="flex items-center gap-2">
221
222
  <span class="flex size-6 items-center justify-center rounded-md bg-slate-100 text-slate-500">
222
223
  <AlertTriangle :size="12" />
223
224
  </span>
224
- <h3 class="text-sm font-semibold">Needs attention</h3>
225
+ <h3 class="text-sm font-semibold">{{ t("integration.dashboard.attention.title") }}</h3>
225
226
  </div>
226
227
  </div>
227
228
  <div v-if="(missedLoading || latestLoading) && !missedRuns && !failedRecent.length" class="space-y-2 p-3">
@@ -234,9 +235,9 @@ const surfaces = [
234
235
  to="/integrations/runs?tab=missed"
235
236
  class="block space-y-1 border-b border-black/[0.04] px-3 py-2 text-sm hover:bg-muted/30"
236
237
  >
237
- <div class="font-medium">{{ missedRuns }} missed run{{ missedRuns === 1 ? "" : "s" }}</div>
238
+ <div class="font-medium">{{ t("integration.dashboard.attention.missedRuns", missedRuns) }}</div>
238
239
  <div class="text-sm text-amber-700">
239
- Triggers fired while their workflow couldn't run — review &amp; replay.
240
+ {{ t("integration.dashboard.attention.missedRuns.hint") }}
240
241
  </div>
241
242
  </NuxtLink>
242
243
  <!-- A link, for the same reason as the entries next door — and like
@@ -249,7 +250,7 @@ const surfaces = [
249
250
  >
250
251
  <div class="font-medium truncate">{{ r.workflowName }}</div>
251
252
  <div class="text-sm text-rose-700 truncate" :title="r.errorMessage">
252
- {{ r.errorMessage ?? "Failed." }}
253
+ {{ r.errorMessage ?? t("integration.dashboard.attention.failed") }}
253
254
  </div>
254
255
  <!-- When it failed is when it ended, not when it set off: a run
255
256
  that broke after an hour would otherwise be dated an hour early. -->
@@ -259,30 +260,22 @@ const surfaces = [
259
260
  </NuxtLink>
260
261
  </div>
261
262
  <div v-else class="px-3 py-6 text-center text-sm text-muted-foreground">
262
- All workflows healthy.
263
+ {{ t("integration.dashboard.attention.healthy") }}
263
264
  </div>
264
265
  </div>
265
266
  </div>
266
267
 
267
- <!-- Data exchange & events — the studio surfaces beyond workflows. -->
268
+ <!-- Data exchange & events — surfaces beyond workflows, and the only cards
269
+ here that lead OUT of this module: all four are the cockpit's pages.
270
+ Where the host does not have them the card stays and says so, rather
271
+ than leading into a 404 (PO-223, `SurfaceCard.vue`). -->
268
272
  <div class="grid gap-2 sm:grid-cols-2 lg:grid-cols-4">
269
- <NuxtLink
270
- v-for="sfc in surfaces"
273
+ <SurfaceCard
274
+ v-for="sfc in surfaceCards()"
271
275
  :key="sfc.to"
272
- :to="sfc.to"
273
- class="group rounded-lg border border-black/[0.06] bg-white p-3 transition hover:border-black/20"
274
- >
275
- <div class="flex items-center gap-2">
276
- <span class="flex size-6 items-center justify-center rounded-md bg-slate-100 text-slate-500">
277
- <component :is="sfc.icon" :size="12" />
278
- </span>
279
- <span class="min-w-0 flex-1 truncate text-sm font-semibold">{{ sfc.label }}</span>
280
- <ArrowRight :size="11" class="text-muted-foreground transition-transform group-hover:translate-x-0.5" />
281
- </div>
282
- <p class="mt-2 text-sm text-muted-foreground">
283
- {{ sfc.hint }}
284
- </p>
285
- </NuxtLink>
276
+ v-bind="sfc"
277
+ :note="t('integration.dashboard.surface.unavailable', { surface: sfc.label })"
278
+ />
286
279
  </div>
287
280
 
288
281
  <!-- Named for the offer it makes, not for a ranking nothing measures
@@ -290,16 +283,16 @@ const surfaces = [
290
283
  produced this list compared nothing at all. The wording deliberately
291
284
  echoes the create-workflow dialog's "Start from template" tab — the same
292
285
  offer, so the same words, rather than a second name for it. -->
293
- <div role="group" aria-label="Start from a template" class="rounded-lg border border-black/[0.06] bg-white">
286
+ <div role="group" :aria-label="t('integration.dashboard.templates.title')" class="rounded-lg border border-black/[0.06] bg-white">
294
287
  <div class="flex items-center justify-between border-b border-black/[0.06] px-3 py-2">
295
288
  <div class="flex items-center gap-2">
296
289
  <span class="flex size-6 items-center justify-center rounded-md bg-slate-100 text-slate-500">
297
290
  <Sparkles :size="12" />
298
291
  </span>
299
- <h3 class="text-sm font-semibold">Start from a template</h3>
292
+ <h3 class="text-sm font-semibold">{{ t("integration.dashboard.templates.title") }}</h3>
300
293
  </div>
301
294
  <NuxtLink to="/integrations/templates" class="text-sm text-muted-foreground hover:text-foreground inline-flex items-center gap-1">
302
- See all <ArrowRight :size="11" />
295
+ {{ t("integration.common.action.seeAll") }} <ArrowRight :size="11" />
303
296
  </NuxtLink>
304
297
  </div>
305
298
  <div v-if="templatesLoading && !startingPoints.length" class="grid gap-2 p-3 sm:grid-cols-2 lg:grid-cols-4">
@@ -310,7 +303,7 @@ const surfaces = [
310
303
  </div>
311
304
  </div>
312
305
  <div v-else-if="!startingPoints.length" class="px-3 py-6 text-center text-sm text-muted-foreground">
313
- No templates registered yet.
306
+ {{ t("integration.dashboard.templates.empty") }}
314
307
  </div>
315
308
  <div v-else class="grid gap-2 p-3 sm:grid-cols-2 lg:grid-cols-4">
316
309
  <TemplateCard