@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,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,7 @@ 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
+ {{ testResult.ok ? t("integration.credentials.shared.test.ok") : testResult.error ?? t("integration.credentials.shared.test.failed") }}
428
446
  </div>
429
447
 
430
448
  </div>
@@ -442,18 +460,18 @@ async function confirmDelete() {
442
460
  <DialogFooter v-if="pickedType" class="sm:justify-between">
443
461
  <Button v-if="!isEdit" variant="ghost" size="sm" :disabled="saving || testing" @click="pickedType = null;
444
462
  clearRefusal()">
445
- Back
463
+ {{ t("integration.credentials.list.dialog.action.back") }}
446
464
  </Button>
447
465
  <span v-else />
448
466
  <div class="flex items-center gap-2">
449
467
  <Button variant="outline" :disabled="saving || testing" @click="runTest">
450
468
  <Loader2 v-if="testing" :size="14" class="mr-1 animate-spin" />
451
469
  <TestIcon v-else :size="14" class="mr-1" />
452
- Connect
470
+ {{ t("integration.credentials.shared.action.testConnection") }}
453
471
  </Button>
454
472
  <Button :disabled="saving || !form.name.trim()" @click="submit">
455
473
  <Loader2 v-if="saving" :size="14" class="mr-1 animate-spin" />
456
- Save
474
+ {{ t("integration.common.action.save") }}
457
475
  </Button>
458
476
  </div>
459
477
  </DialogFooter>
@@ -465,21 +483,21 @@ async function confirmDelete() {
465
483
  }">
466
484
  <AlertDialogContent>
467
485
  <AlertDialogHeader>
468
- <AlertDialogTitle>Delete credential?</AlertDialogTitle>
486
+ <AlertDialogTitle>{{ t("integration.credentials.shared.confirm.delete.title") }}</AlertDialogTitle>
469
487
  <AlertDialogDescription>
470
488
  <span v-if="pendingDelete">
471
489
  <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.
490
+ {{ t("integration.credentials.shared.confirm.delete.body") }}
473
491
  </span>
474
492
  </AlertDialogDescription>
475
493
  </AlertDialogHeader>
476
494
  <AlertDialogFooter>
477
495
  <Button variant="outline" :disabled="deleting" @click="pendingDelete = null">
478
- Cancel
496
+ {{ t("integration.common.action.cancel") }}
479
497
  </Button>
480
498
  <Button class="bg-destructive text-white hover:bg-destructive/90" :disabled="deleting" @click="confirmDelete">
481
499
  <Loader2 v-if="deleting" :size="14" class="animate-spin" />
482
- {{ deleting ? "Deleting\u2026" : "Delete" }}
500
+ {{ deleting ? t("integration.common.state.deleting") : t("integration.common.action.delete") }}
483
501
  </Button>
484
502
  </AlertDialogFooter>
485
503
  </AlertDialogContent>
@@ -4,12 +4,14 @@ import TemplateCard from "../../components/Integrations/TemplateCard.vue";
4
4
  import WorkflowCreateDialog from "../../components/Integrations/WorkflowCreateDialog.vue";
5
5
  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
6
  import { Badge, Button } from "@revenexx/studio";
7
- useHead({ title: "Dashboard \xB7 Integration Studio" });
7
+ import { runStatusLabel, statusClass } from "../../utils/statusStyle";
8
+ const t = useIntegrationsT();
9
+ useHead({ title: () => t("integration.dashboard.head.title") });
8
10
  const { workflows, activeCount: activeWorkflows, loading: workflowsLoading, load: loadWorkflows } = useIntegrationsWorkflows();
9
11
  const { runs, runningCount, failedCount, loading: runsLoading, load: loadRuns } = useIntegrationsRuns();
10
12
  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));
13
+ watch(() => startingPoints.value.map((t2) => t2.slug).join(","), () => {
14
+ if (startingPoints.value.length) void ensureDetails(startingPoints.value.map((t2) => t2.slug));
13
15
  }, { immediate: true });
14
16
  const { activeCount: schedulesActive, load: loadSchedules } = useIntegrationsSchedules();
15
17
  const { pendingCount: missedRuns, globalLoading: missedLoading, loadGlobal: loadMissedRuns } = useIntegrationsDeadLetters();
@@ -39,80 +41,72 @@ const failedRecent = computed(() => latestByWorkflow.value.filter((r) => r.statu
39
41
  function formatRelative(iso) {
40
42
  if (!iso) return "\u2014";
41
43
  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
- }
44
+ if (ms < 6e4) return t("integration.common.time.justNow");
45
+ if (ms < 36e5) return t("integration.common.time.minutesAgo", Math.floor(ms / 6e4));
46
+ if (ms < 864e5) return t("integration.common.time.hoursAgo", Math.floor(ms / 36e5));
47
+ return t("integration.common.time.daysAgo", Math.floor(ms / 864e5));
60
48
  }
61
49
  const kpis = computed(() => [
62
50
  {
63
- label: "Active workflows",
51
+ key: "activeWorkflows",
52
+ label: t("integration.dashboard.kpi.activeWorkflows.label"),
64
53
  value: activeWorkflows.value,
65
- sub: `${workflows.value.length - activeWorkflows.value} paused or drafts`,
54
+ // A plural key, so the count is the second argument. Called without one it
55
+ // would put the raw "a | b" on screen.
56
+ sub: t("integration.dashboard.kpi.activeWorkflows.sub", workflows.value.length - activeWorkflows.value),
66
57
  icon: Workflow,
67
58
  tile: "bg-emerald-500 text-white"
68
59
  },
69
60
  {
70
- label: "Running now",
61
+ key: "runningNow",
62
+ label: t("integration.dashboard.kpi.runningNow.label"),
71
63
  value: runningCount.value,
72
- sub: "live executions in flight",
64
+ sub: t("integration.dashboard.kpi.runningNow.sub"),
73
65
  icon: Activity,
74
66
  tile: "bg-sky-500 text-white"
75
67
  },
76
68
  {
77
- label: "Scheduled",
69
+ key: "scheduled",
70
+ label: t("integration.dashboard.kpi.scheduled.label"),
78
71
  value: schedulesActive.value,
79
- sub: "scheduled triggers active",
72
+ sub: t("integration.dashboard.kpi.scheduled.sub"),
80
73
  icon: Clock,
81
74
  tile: "bg-violet-500 text-white"
82
75
  },
83
76
  {
84
- label: "Missed runs",
77
+ key: "missedRuns",
78
+ label: t("integration.dashboard.kpi.missedRuns.label"),
85
79
  value: missedRuns.value,
86
- sub: "trigger firings without a run",
80
+ sub: t("integration.dashboard.kpi.missedRuns.sub"),
87
81
  icon: AlertTriangle,
88
82
  tile: missedRuns.value ? "bg-amber-500 text-white" : "bg-slate-300 text-white",
89
83
  to: "/integrations/runs?tab=missed"
90
84
  }
91
85
  ]);
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
- ];
86
+ const surfaces = computed(() => [
87
+ { label: t("integration.dashboard.surface.io.label"), to: "/integrations/io", icon: Database, hint: t("integration.dashboard.surface.io.hint") },
88
+ { label: t("integration.dashboard.surface.sftp.label"), to: "/integrations/sftp", icon: Boxes, hint: t("integration.dashboard.surface.sftp.hint") },
89
+ { label: t("integration.dashboard.surface.events.label"), to: "/integrations/events", icon: Radio, hint: t("integration.dashboard.surface.events.hint") },
90
+ { label: t("integration.dashboard.surface.webhooks.label"), to: "/integrations/webhooks", icon: Webhook, hint: t("integration.dashboard.surface.webhooks.hint") }
91
+ ]);
98
92
  </script>
99
93
 
100
94
  <template>
101
95
  <IntegrationsLayout>
102
96
  <PageHeader
103
- title="Dashboard"
104
- description="Everything that connects Revenue Cloud to the outside world."
97
+ :title="t('integration.dashboard.header.title')"
98
+ :description="t('integration.dashboard.header.description')"
105
99
  >
106
100
  <template #actions>
107
101
  <NuxtLink to="/integrations/templates">
108
102
  <Button variant="outline" class="h-7 text-sm">
109
103
  <Layers :size="12" />
110
- Browse templates
104
+ {{ t("integration.dashboard.action.browseTemplates") }}
111
105
  </Button>
112
106
  </NuxtLink>
113
107
  <Button class="h-7 text-sm" @click="createOpen = true">
114
108
  <Plus :size="14" />
115
- New workflow
109
+ {{ t("integration.dashboard.action.newWorkflow") }}
116
110
  </Button>
117
111
  </template>
118
112
  </PageHeader>
@@ -122,11 +116,11 @@ const surfaces = [
122
116
  tile is a link, and a link takes its name from its label instead of its
123
117
  contents — named "Missed runs" alone it would announce less than the
124
118
  three tiles beside it. -->
125
- <div role="group" aria-label="Key figures" class="grid gap-2 sm:grid-cols-2 lg:grid-cols-4">
119
+ <div role="group" :aria-label="t('integration.dashboard.kpi.aria.group')" class="grid gap-2 sm:grid-cols-2 lg:grid-cols-4">
126
120
  <component
127
121
  :is="k.to ? NuxtLinkComp : 'div'"
128
122
  v-for="k in kpis"
129
- :key="k.label"
123
+ :key="k.key"
130
124
  :to="k.to"
131
125
  :role="k.to ? void 0 : 'group'"
132
126
  :aria-label="`${k.label}: ${k.value}`"
@@ -156,16 +150,16 @@ const surfaces = [
156
150
  <!-- Two-column: recent runs + needs attention + templates to start from -->
157
151
  <div class="grid gap-4 lg:grid-cols-3">
158
152
  <!-- 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">
153
+ <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
154
  <div class="flex items-center justify-between border-b border-black/[0.06] px-3 py-2">
161
155
  <div class="flex items-center gap-2">
162
156
  <span class="flex size-6 items-center justify-center rounded-md bg-slate-100 text-slate-500">
163
157
  <Activity :size="12" />
164
158
  </span>
165
- <h3 class="text-sm font-semibold">Latest executions</h3>
159
+ <h3 class="text-sm font-semibold">{{ t("integration.dashboard.runs.title") }}</h3>
166
160
  </div>
167
161
  <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" />
162
+ {{ t("integration.common.action.viewAll") }} <ArrowRight :size="11" />
169
163
  </NuxtLink>
170
164
  </div>
171
165
  <div>
@@ -180,7 +174,7 @@ const surfaces = [
180
174
  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
175
  >
182
176
  <Badge variant="outline" :class="['border text-xs font-medium uppercase', statusClass(r.status)]">
183
- {{ r.status }}
177
+ {{ runStatusLabel(r.status) }}
184
178
  </Badge>
185
179
  <div class="truncate">
186
180
  <div class="font-medium text-foreground truncate">{{ r.workflowName }}</div>
@@ -209,19 +203,19 @@ const surfaces = [
209
203
  </div>
210
204
  </template>
211
205
  <div v-else-if="!latestPerWorkflow.length" class="px-3 py-8 text-center text-sm text-muted-foreground">
212
- No runs yet.
206
+ {{ t("integration.dashboard.runs.empty") }}
213
207
  </div>
214
208
  </div>
215
209
  </div>
216
210
 
217
211
  <!-- Needs attention -->
218
- <div role="group" aria-label="Needs attention" class="rounded-lg border border-black/[0.06] bg-white">
212
+ <div role="group" :aria-label="t('integration.dashboard.attention.title')" class="rounded-lg border border-black/[0.06] bg-white">
219
213
  <div class="flex items-center justify-between border-b border-black/[0.06] px-3 py-2">
220
214
  <div class="flex items-center gap-2">
221
215
  <span class="flex size-6 items-center justify-center rounded-md bg-slate-100 text-slate-500">
222
216
  <AlertTriangle :size="12" />
223
217
  </span>
224
- <h3 class="text-sm font-semibold">Needs attention</h3>
218
+ <h3 class="text-sm font-semibold">{{ t("integration.dashboard.attention.title") }}</h3>
225
219
  </div>
226
220
  </div>
227
221
  <div v-if="(missedLoading || latestLoading) && !missedRuns && !failedRecent.length" class="space-y-2 p-3">
@@ -234,9 +228,9 @@ const surfaces = [
234
228
  to="/integrations/runs?tab=missed"
235
229
  class="block space-y-1 border-b border-black/[0.04] px-3 py-2 text-sm hover:bg-muted/30"
236
230
  >
237
- <div class="font-medium">{{ missedRuns }} missed run{{ missedRuns === 1 ? "" : "s" }}</div>
231
+ <div class="font-medium">{{ t("integration.dashboard.attention.missedRuns", missedRuns) }}</div>
238
232
  <div class="text-sm text-amber-700">
239
- Triggers fired while their workflow couldn't run — review &amp; replay.
233
+ {{ t("integration.dashboard.attention.missedRuns.hint") }}
240
234
  </div>
241
235
  </NuxtLink>
242
236
  <!-- A link, for the same reason as the entries next door — and like
@@ -249,7 +243,7 @@ const surfaces = [
249
243
  >
250
244
  <div class="font-medium truncate">{{ r.workflowName }}</div>
251
245
  <div class="text-sm text-rose-700 truncate" :title="r.errorMessage">
252
- {{ r.errorMessage ?? "Failed." }}
246
+ {{ r.errorMessage ?? t("integration.dashboard.attention.failed") }}
253
247
  </div>
254
248
  <!-- When it failed is when it ended, not when it set off: a run
255
249
  that broke after an hour would otherwise be dated an hour early. -->
@@ -259,7 +253,7 @@ const surfaces = [
259
253
  </NuxtLink>
260
254
  </div>
261
255
  <div v-else class="px-3 py-6 text-center text-sm text-muted-foreground">
262
- All workflows healthy.
256
+ {{ t("integration.dashboard.attention.healthy") }}
263
257
  </div>
264
258
  </div>
265
259
  </div>
@@ -290,16 +284,16 @@ const surfaces = [
290
284
  produced this list compared nothing at all. The wording deliberately
291
285
  echoes the create-workflow dialog's "Start from template" tab — the same
292
286
  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">
287
+ <div role="group" :aria-label="t('integration.dashboard.templates.title')" class="rounded-lg border border-black/[0.06] bg-white">
294
288
  <div class="flex items-center justify-between border-b border-black/[0.06] px-3 py-2">
295
289
  <div class="flex items-center gap-2">
296
290
  <span class="flex size-6 items-center justify-center rounded-md bg-slate-100 text-slate-500">
297
291
  <Sparkles :size="12" />
298
292
  </span>
299
- <h3 class="text-sm font-semibold">Start from a template</h3>
293
+ <h3 class="text-sm font-semibold">{{ t("integration.dashboard.templates.title") }}</h3>
300
294
  </div>
301
295
  <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" />
296
+ {{ t("integration.common.action.seeAll") }} <ArrowRight :size="11" />
303
297
  </NuxtLink>
304
298
  </div>
305
299
  <div v-if="templatesLoading && !startingPoints.length" class="grid gap-2 p-3 sm:grid-cols-2 lg:grid-cols-4">
@@ -310,7 +304,7 @@ const surfaces = [
310
304
  </div>
311
305
  </div>
312
306
  <div v-else-if="!startingPoints.length" class="px-3 py-6 text-center text-sm text-muted-foreground">
313
- No templates registered yet.
307
+ {{ t("integration.dashboard.templates.empty") }}
314
308
  </div>
315
309
  <div v-else class="grid gap-2 p-3 sm:grid-cols-2 lg:grid-cols-4">
316
310
  <TemplateCard