@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
@@ -1,10 +1,12 @@
1
1
  <script setup>
2
- import { RefreshCircle as Loader2, Refresh as ReplayIcon, TrashBinMinimalistic as Trash2 } from "#solar-icons";
2
+ import { RefreshCircle as Loader2, Refresh as ReplayIcon, TrashBinMinimalistic as TrashBin } from "#solar-icons";
3
3
  import { Badge, Button } from "@revenexx/studio";
4
4
  import { toast } from "vue-sonner";
5
5
  import RunsTable from "../../components/Integrations/RunsTable.vue";
6
+ import { createPollBudget, SAFETY_POLL_MS } from "../../utils/pollBudget";
6
7
  import { statusClass } from "../../utils/statusStyle";
7
- useHead({ title: "Runs \xB7 Integration Studio" });
8
+ const t = useIntegrationsT();
9
+ useHead({ title: () => t("integration.runs.list.head.title") });
8
10
  const route = useRoute();
9
11
  const { runs, loading, load } = useIntegrationsRuns();
10
12
  const {
@@ -17,18 +19,28 @@ const {
17
19
  } = useIntegrationsDeadLetters();
18
20
  const tab = ref(route.query.tab === "missed" ? "missed" : "runs");
19
21
  const tabs = computed(() => [
20
- { key: "runs", label: "Runs" },
22
+ { key: "runs", label: t("integration.runs.tabs.runs.label") },
21
23
  // "Missed runs": trigger firings that never became a run (dead letters).
22
- { key: "missed", label: "Missed runs", badge: pendingCount.value || null }
24
+ { key: "missed", label: t("integration.deadLetters.tabs.missed.label"), badge: pendingCount.value || null }
23
25
  ]);
26
+ const POLL_MS = 3e3;
24
27
  const hasActiveRuns = computed(() => runs.value.some((r) => r.status === "running" || r.status === "queued"));
28
+ const { delivering, runTick, workflowTick } = useIntegrationsRealtime();
29
+ const budget = createPollBudget({ intervalMs: POLL_MS, liveIntervalMs: SAFETY_POLL_MS });
30
+ function reload() {
31
+ budget.spend();
32
+ void load(true);
33
+ }
34
+ watch([runTick, workflowTick], () => {
35
+ reload();
36
+ });
25
37
  let pollTimer = null;
26
38
  onMounted(() => {
27
39
  void load(true);
28
40
  void loadGlobal(true);
29
41
  pollTimer = setInterval(() => {
30
- if (hasActiveRuns.value && !loading.value) void load(true);
31
- }, 3e3);
42
+ if (hasActiveRuns.value && !loading.value && budget.due(delivering.value)) void load(true);
43
+ }, POLL_MS);
32
44
  });
33
45
  onUnmounted(() => {
34
46
  if (pollTimer) {
@@ -42,11 +54,11 @@ async function onReplay(dl) {
42
54
  try {
43
55
  const res = await replay(dl.id);
44
56
  const newId = res && typeof res === "object" && "id" in res ? String(res.id) : null;
45
- toast.success(newId ? `Replayed \u2014 started run #${newId}` : "Replayed");
57
+ toast.success(newId ? t("integration.deadLetters.toast.replay.started", { id: newId }) : t("integration.deadLetters.toast.replay.done"));
46
58
  await loadGlobal(true);
47
- void load(true);
59
+ reload();
48
60
  } catch (e) {
49
- toast.error(e?.message ?? "Could not replay.");
61
+ toast.error(e?.message ?? t("integration.deadLetters.toast.replay.error"));
50
62
  } finally {
51
63
  busyId.value = null;
52
64
  }
@@ -55,10 +67,10 @@ async function onDiscard(dl) {
55
67
  busyId.value = dl.id;
56
68
  try {
57
69
  await discard(dl.id);
58
- toast.success("Discarded");
70
+ toast.success(t("integration.deadLetters.toast.discard.done"));
59
71
  await loadGlobal(true);
60
72
  } catch (e) {
61
- toast.error(e?.message ?? "Could not discard.");
73
+ toast.error(e?.message ?? t("integration.deadLetters.toast.discard.error"));
62
74
  } finally {
63
75
  busyId.value = null;
64
76
  }
@@ -76,25 +88,25 @@ function fmt(iso) {
76
88
  <template>
77
89
  <IntegrationsLayout>
78
90
  <PageHeader
79
- title="Runs"
80
- description="Execution history across every workflow — sourced from Temporal."
91
+ :title="t('integration.runs.list.header.title')"
92
+ :description="t('integration.runs.list.header.description')"
81
93
  />
82
94
 
83
95
  <TabBar v-model="tab" :tabs="tabs" class="px-4" />
84
96
 
85
97
  <div class="p-4 space-y-4">
86
- <RunsTable v-if="tab === 'runs'" :runs="runs" :loading="loading" @changed="() => load(true)" />
98
+ <RunsTable v-if="tab === 'runs'" :runs="runs" :loading="loading" @changed="reload" />
87
99
 
88
100
  <!-- Missed runs — trigger firings that never produced a run (the
89
101
  workflow wasn't runnable when its schedule/event fired). -->
90
102
  <template v-else>
91
103
  <div class="flex items-center justify-between">
92
104
  <p class="text-sm text-muted-foreground">
93
- Triggers that fired while their workflow couldn't run (bundle building or failed). Replay starts a fresh run from the captured payload.
105
+ {{ t("integration.deadLetters.list.description") }}
94
106
  </p>
95
107
  <Button variant="outline" size="sm" class="h-7 text-sm" :disabled="missedLoading" @click="() => loadGlobal(true)">
96
108
  <Loader2 v-if="missedLoading" :size="12" class="animate-spin" />
97
- Refresh
109
+ {{ t("integration.common.action.refresh") }}
98
110
  </Button>
99
111
  </div>
100
112
 
@@ -109,7 +121,7 @@ function fmt(iso) {
109
121
  </div>
110
122
  </div>
111
123
  <div v-else-if="!missed.length" class="rounded-lg border border-black/[0.06] bg-white p-6 text-center text-sm text-muted-foreground">
112
- No missed runs. Every trigger firing made it into a run.
124
+ {{ t("integration.deadLetters.state.empty") }}
113
125
  </div>
114
126
 
115
127
  <div v-else class="rounded-lg border border-black/[0.06] bg-white divide-y divide-black/[0.04]">
@@ -123,7 +135,7 @@ function fmt(iso) {
123
135
  <NuxtLink :to="`/integrations/workflows/${dl.workflow_id}`" class="text-sm font-medium truncate transition-colors hover:text-primary">
124
136
  {{ dl.workflowName }}
125
137
  </NuxtLink>
126
- <div class="text-sm break-words">{{ dl.reason || "No reason recorded." }}</div>
138
+ <div class="text-sm break-words">{{ dl.reason || t("integration.deadLetters.reason.fallback") }}</div>
127
139
  <div class="mt-0.5 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs text-muted-foreground font-mono">
128
140
  <span>#{{ dl.id }}</span>
129
141
  <span v-if="dl.source">{{ dl.source }}</span>
@@ -135,11 +147,11 @@ function fmt(iso) {
135
147
  <Button variant="outline" size="sm" class="h-7 text-sm" :disabled="busyId === dl.id" @click="onReplay(dl)">
136
148
  <Loader2 v-if="busyId === dl.id" :size="12" class="animate-spin" />
137
149
  <ReplayIcon v-else :size="12" />
138
- Replay
150
+ {{ t("integration.deadLetters.action.replay") }}
139
151
  </Button>
140
152
  <Button variant="ghost" size="sm" class="h-7 text-sm text-rose-600 hover:bg-rose-500/10" :disabled="busyId === dl.id" @click="onDiscard(dl)">
141
- <Trash2 :size="12" />
142
- Discard
153
+ <TrashBin :size="12" />
154
+ {{ t("integration.deadLetters.action.discard") }}
143
155
  </Button>
144
156
  </div>
145
157
  </div>
@@ -2,7 +2,8 @@
2
2
  import { ClockCircle as Clock, Globus as Globe } from "#solar-icons";
3
3
  import { Badge } from "@revenexx/studio";
4
4
  import { cadenceRank, describeCron } from "../../utils/schedule";
5
- useHead({ title: "Schedules \xB7 Integration Studio" });
5
+ const t = useIntegrationsT();
6
+ useHead({ title: () => t("integration.schedules.head.title") });
6
7
  const router = useRouter();
7
8
  const { entries, activeCount, loading, load } = useIntegrationsSchedules();
8
9
  onMounted(() => {
@@ -11,18 +12,38 @@ onMounted(() => {
11
12
  function formatRelative(iso) {
12
13
  if (!iso) return "\u2014";
13
14
  const ms = new Date(iso).getTime() - Date.now();
14
- if (Math.abs(ms) < 6e4) return ms > 0 ? "in <1m" : "just now";
15
+ if (Math.abs(ms) < 6e4) return ms > 0 ? t("integration.schedules.time.inLessThanMinute") : t("integration.common.time.justNow");
15
16
  if (Math.abs(ms) < 36e5) {
16
17
  const mins = Math.round(Math.abs(ms) / 6e4);
17
- return ms > 0 ? `in ${mins}m` : `${mins}m ago`;
18
+ return ms > 0 ? t("integration.schedules.time.inMinutes", mins) : t("integration.common.time.minutesAgo", mins);
18
19
  }
19
20
  if (Math.abs(ms) < 864e5) {
20
21
  const hrs = Math.round(Math.abs(ms) / 36e5);
21
- return ms > 0 ? `in ${hrs}h` : `${hrs}h ago`;
22
+ return ms > 0 ? t("integration.schedules.time.inHours", hrs) : t("integration.common.time.hoursAgo", hrs);
22
23
  }
23
24
  return new Date(iso).toLocaleString(void 0, { month: "short", day: "2-digit", hour: "2-digit", minute: "2-digit" });
24
25
  }
25
- const columns = [
26
+ const filterTabs = computed(() => [
27
+ {
28
+ key: "status",
29
+ label: t("integration.schedules.table.column.status"),
30
+ // The flag, not the word: a stored value has to survive a language switch, or the
31
+ // narrowed list empties itself when the host changes language. Only the labels are
32
+ // translated (see `credentials.vue` for the whole reasoning).
33
+ getter: (e) => e.active ? "live" : "paused",
34
+ options: [
35
+ { value: "live", label: t("integration.schedules.status.live") },
36
+ { value: "paused", label: t("integration.schedules.status.paused") }
37
+ ]
38
+ },
39
+ {
40
+ key: "timezone",
41
+ label: t("integration.schedules.table.column.timezone"),
42
+ getter: (e) => e.timezone,
43
+ options: [...new Set(entries.value.map((e) => e.timezone))].sort().map((tz) => ({ value: tz, label: tz }))
44
+ }
45
+ ]);
46
+ const columns = computed(() => [
26
47
  // Two columns share the slack, and the sentence gets the larger share of it:
27
48
  // "Weekdays, hourly from 09:00 to 17:00" is what this page is for, while a
28
49
  // workflow name is a label the reader recognises from its first few words.
@@ -31,26 +52,26 @@ const columns = [
31
52
  // The expression stays in the search text although the column no longer shows
32
53
  // it: support quotes expressions, and a row has to be findable by the string
33
54
  // someone was given.
34
- { key: "workflow", label: "Workflow", width: "minmax(180px, 1fr)", sortable: true, sortGetter: (e) => e.workflowName.toLowerCase(), searchGetter: (e) => `${e.workflowName} ${e.cron ?? ""} ${e.timezone} ${describeCron(e.cron) ?? ""}` },
55
+ { key: "workflow", label: t("integration.schedules.table.column.workflow"), width: "minmax(180px, 1fr)", sortable: true, sortGetter: (e) => e.workflowName.toLowerCase(), searchGetter: (e) => `${e.workflowName} ${e.cron ?? ""} ${e.timezone} ${describeCron(e.cron) ?? ""}` },
35
56
  // Sorted by how often a schedule fires. The expression's characters would be
36
57
  // meaningless here — the column shows a sentence.
37
- { key: "cron", label: "When", width: "minmax(260px, 1.4fr)", sortable: true, sortGetter: (e) => cadenceRank(e.cron) },
38
- { key: "timezone", label: "Timezone", width: "140px", sortable: true, sortGetter: (e) => e.timezone },
39
- { key: "nextRun", label: "Next run", width: "140px", sortable: true, sortGetter: (e) => e.nextRunAt ?? "" },
40
- { key: "lastRun", label: "Last run", width: "140px", sortable: true, sortGetter: (e) => e.lastRunAt ?? "" },
41
- { key: "status", label: "Status", width: "80px", sortable: true, sortGetter: (e) => e.active ? "live" : "paused" }
42
- ];
58
+ { key: "cron", label: t("integration.schedules.table.column.when"), width: "minmax(260px, 1.4fr)", sortable: true, sortGetter: (e) => cadenceRank(e.cron) },
59
+ { key: "timezone", label: t("integration.schedules.table.column.timezone"), width: "140px", sortable: true, sortGetter: (e) => e.timezone },
60
+ { key: "nextRun", label: t("integration.schedules.table.column.nextRun"), width: "140px", sortable: true, sortGetter: (e) => e.nextRunAt ?? "" },
61
+ { key: "lastRun", label: t("integration.schedules.table.column.lastRun"), width: "140px", sortable: true, sortGetter: (e) => e.lastRunAt ?? "" },
62
+ { key: "status", label: t("integration.schedules.table.column.status"), width: "80px", sortable: true, sortGetter: (e) => e.active ? "live" : "paused" }
63
+ ]);
43
64
  </script>
44
65
 
45
66
  <template>
46
67
  <IntegrationsLayout>
47
68
  <PageHeader
48
- title="Schedules"
49
- description="Every scheduled trigger across your workflows. Click a row to open the workflow."
69
+ :title="t('integration.schedules.header.title')"
70
+ :description="t('integration.schedules.header.description')"
50
71
  >
51
72
  <template #actions>
52
73
  <span class="text-sm text-muted-foreground">
53
- <strong class="text-foreground">{{ activeCount }}</strong> active
74
+ <strong class="text-foreground">{{ activeCount }}</strong> {{ t("integration.schedules.stat.active") }}
54
75
  </span>
55
76
  </template>
56
77
  </PageHeader>
@@ -62,9 +83,9 @@ const columns = [
62
83
  :columns="columns"
63
84
  :row-key="(e2) => e2.id"
64
85
  :default-sort="{ column: 'nextRun', direction: 'asc' }"
65
- :search="{ placeholder: 'Search by workflow, schedule, timezone\u2026' }"
66
- :filter="true"
67
- empty-text="No schedules yet. Add a schedule trigger to any workflow."
86
+ :search="{ placeholder: t('integration.schedules.search.placeholder') }"
87
+ :filter-tabs="filterTabs"
88
+ :empty-text="t('integration.schedules.state.empty')"
68
89
  @row-click="(e2) => router.push(`/integrations/workflows/${e2.workflowId}`)"
69
90
  >
70
91
  <template #cell-workflow="{ row: e }">
@@ -92,17 +113,17 @@ const columns = [
92
113
  at several named times is a long sentence, and half of one is
93
114
  the wrong half. Its title is the expression, so there is no
94
115
  tooltip left to carry the rest of the words. -->
95
- <div v-if="describeCron(e.cron)" aria-label="When it fires" :title="e.cron ?? void 0" class="text-sm leading-snug">
116
+ <div v-if="describeCron(e.cron)" :aria-label="t('integration.schedules.cell.whenItFires.aria')" :title="e.cron ?? void 0" class="text-sm leading-snug">
96
117
  {{ describeCron(e.cron) }}
97
118
  </div>
98
119
  <template v-else-if="e.cron">
99
- <div aria-label="When it fires" class="text-sm truncate">
100
- Custom schedule
120
+ <div :aria-label="t('integration.schedules.cell.whenItFires.aria')" class="text-sm truncate">
121
+ {{ t("integration.schedules.cell.customSchedule") }}
101
122
  </div>
102
- <code aria-label="Cron expression" class="block font-mono text-xs text-muted-foreground truncate">{{ e.cron }}</code>
123
+ <code :aria-label="t('integration.schedules.cell.cronExpression.aria')" class="block font-mono text-xs text-muted-foreground truncate">{{ e.cron }}</code>
103
124
  </template>
104
- <div v-else aria-label="When it fires" class="text-sm text-muted-foreground truncate">
105
- No schedule set
125
+ <div v-else :aria-label="t('integration.schedules.cell.whenItFires.aria')" class="text-sm text-muted-foreground truncate">
126
+ {{ t("integration.schedules.cell.noSchedule") }}
106
127
  </div>
107
128
  </div>
108
129
  </template>
@@ -123,19 +144,19 @@ const columns = [
123
144
  `specs/accessibility.md`).
124
145
  -->
125
146
  <template #cell-nextRun="{ row: e }">
126
- <span aria-label="Next run" class="text-sm tabular-nums">{{ formatRelative(e.nextRunAt) }}</span>
147
+ <span :aria-label="t('integration.schedules.table.column.nextRun')" class="text-sm tabular-nums">{{ formatRelative(e.nextRunAt) }}</span>
127
148
  </template>
128
149
 
129
150
  <template #cell-lastRun="{ row: e }">
130
- <span aria-label="Last run" class="text-sm tabular-nums text-muted-foreground">{{ formatRelative(e.lastRunAt) }}</span>
151
+ <span :aria-label="t('integration.schedules.table.column.lastRun')" class="text-sm tabular-nums text-muted-foreground">{{ formatRelative(e.lastRunAt) }}</span>
131
152
  </template>
132
153
 
133
154
  <template #cell-status="{ row: e }">
134
155
  <Badge v-if="e.active" variant="outline" class="bg-emerald-500/10 border-emerald-500/20 text-emerald-700 text-xs font-medium uppercase">
135
- live
156
+ {{ t("integration.schedules.status.live") }}
136
157
  </Badge>
137
158
  <Badge v-else variant="outline" class="text-xs font-medium uppercase">
138
- paused
159
+ {{ t("integration.schedules.status.paused") }}
139
160
  </Badge>
140
161
  </template>
141
162
  </DataTable>
@@ -1,5 +1,5 @@
1
1
  <script setup>
2
- import { Eye, EyeClosed as EyeOff, Key as KeyRound, RefreshCircle as Loader2, MenuDots as MoreHorizontal, AddCircle as Plus, TrashBinMinimalistic as Trash2 } from "#solar-icons";
2
+ import { Eye, EyeClosed as EyeOff, Key as KeyRound, RefreshCircle as Loader2, MenuDots as MoreHorizontal, AddCircle as Plus, TrashBinMinimalistic as TrashBin } from "#solar-icons";
3
3
  import {
4
4
  AlertDialog,
5
5
  AlertDialogContent,
@@ -24,7 +24,8 @@ import {
24
24
  Input,
25
25
  Label
26
26
  } from "@revenexx/studio";
27
- useHead({ title: "Secrets \xB7 Revenexx Cockpit" });
27
+ const t = useIntegrationsT();
28
+ useHead({ title: () => t("integration.secrets.head.title") });
28
29
  const { secrets, loading, error, isValidKey, upsert, remove, load } = useIntegrationsSecrets();
29
30
  onMounted(() => {
30
31
  void load();
@@ -45,11 +46,11 @@ function openCreate() {
45
46
  async function submitCreate() {
46
47
  const key = newKey.value.trim();
47
48
  if (!isValidKey(key)) {
48
- formError.value = "Key must match ^[A-Z0-9_]+$ (uppercase letters, digits, underscores).";
49
+ formError.value = t("integration.secrets.form.error.invalidKey");
49
50
  return;
50
51
  }
51
52
  if (!newValue.value) {
52
- formError.value = "Value is required.";
53
+ formError.value = t("integration.secrets.error.valueRequired");
53
54
  return;
54
55
  }
55
56
  saving.value = true;
@@ -79,39 +80,39 @@ function formatDate(iso) {
79
80
  return iso;
80
81
  }
81
82
  }
82
- const columns = [
83
- { key: "key", label: "Key", sortable: true, sortGetter: (s) => s.key.toLowerCase() },
84
- { key: "created", label: "Created", width: "160px", sortable: true, sortGetter: (s) => s.created_at, searchGetter: () => "" },
85
- { key: "updated", label: "Updated", width: "160px", sortable: true, sortGetter: (s) => s.updated_at, searchGetter: () => "" },
83
+ const columns = computed(() => [
84
+ { key: "key", label: t("integration.secrets.table.column.key"), sortable: true, sortGetter: (s) => s.key.toLowerCase() },
85
+ { key: "created", label: t("integration.secrets.table.column.created"), width: "160px", sortable: true, sortGetter: (s) => s.created_at, searchGetter: () => "" },
86
+ { key: "updated", label: t("integration.secrets.table.column.updated"), width: "160px", sortable: true, sortGetter: (s) => s.updated_at, searchGetter: () => "" },
86
87
  { key: "actions", label: "", width: "40px", toggleable: false }
87
- ];
88
+ ]);
88
89
  </script>
89
90
 
90
91
  <template>
91
92
  <IntegrationsLayout>
92
93
  <PageHeader
93
- title="Secrets"
94
- description="Per-tenant encrypted key/value store. Values are encrypted at rest and never returned by the API."
94
+ :title="t('integration.secrets.header.title')"
95
+ :description="t('integration.secrets.header.description')"
95
96
  >
96
97
  <template #actions>
97
98
  <Dialog v-model:open="createOpen">
98
99
  <DialogTrigger as-child>
99
100
  <Button class="h-7 text-sm" @click="openCreate">
100
101
  <Plus :size="14" />
101
- New secret
102
+ {{ t("integration.secrets.action.new") }}
102
103
  </Button>
103
104
  </DialogTrigger>
104
105
  <DialogContent>
105
106
  <DialogHeader>
106
- <DialogTitle>Create a secret</DialogTitle>
107
+ <DialogTitle>{{ t("integration.secrets.dialog.create.title") }}</DialogTitle>
107
108
  <DialogDescription class="text-sm">
108
- Once stored, the value is encrypted with AES-256-GCM and never echoed back. You can overwrite it later, but to read it you have to know it.
109
+ {{ t("integration.secrets.dialog.create.description") }}
109
110
  </DialogDescription>
110
111
  </DialogHeader>
111
112
 
112
113
  <div class="space-y-4 py-2">
113
114
  <div class="space-y-1.5">
114
- <Label for="sec-key">Key</Label>
115
+ <Label for="sec-key">{{ t("integration.secrets.form.key.label") }}</Label>
115
116
  <Input
116
117
  id="sec-key"
117
118
  v-model="newKey"
@@ -120,11 +121,11 @@ const columns = [
120
121
  autocomplete="off"
121
122
  />
122
123
  <p class="text-sm text-muted-foreground">
123
- Pattern: <code class="font-mono">^[A-Z0-9_]+$</code>
124
+ {{ t("integration.secrets.form.key.patternLabel") }} <code class="font-mono">^[A-Z0-9_]+$</code>
124
125
  </p>
125
126
  </div>
126
127
  <div class="space-y-1.5">
127
- <Label for="sec-value">Value</Label>
128
+ <Label for="sec-value">{{ t("integration.secrets.form.value.label") }}</Label>
128
129
  <div class="relative">
129
130
  <Input
130
131
  id="sec-value"
@@ -136,7 +137,7 @@ const columns = [
136
137
  <button
137
138
  type="button"
138
139
  class="absolute right-1 top-1/2 -translate-y-1/2 flex size-7 items-center justify-center rounded text-muted-foreground hover:text-foreground"
139
- :aria-label="showValue ? 'Hide value' : 'Show value'"
140
+ :aria-label="showValue ? t('integration.secrets.form.value.hide') : t('integration.secrets.form.value.show')"
140
141
  @click="showValue = !showValue"
141
142
  >
142
143
  <component :is="showValue ? EyeOff : Eye" :size="14" />
@@ -151,11 +152,11 @@ const columns = [
151
152
 
152
153
  <DialogFooter>
153
154
  <Button variant="outline" :disabled="saving" @click="createOpen = false">
154
- Cancel
155
+ {{ t("integration.common.action.cancel") }}
155
156
  </Button>
156
157
  <Button :disabled="saving" @click="submitCreate">
157
158
  <Loader2 v-if="saving" :size="14" class="animate-spin" />
158
- {{ saving ? "Saving\u2026" : "Save" }}
159
+ {{ saving ? t("integration.common.state.saving") : t("integration.common.action.save") }}
159
160
  </Button>
160
161
  </DialogFooter>
161
162
  </DialogContent>
@@ -173,9 +174,8 @@ const columns = [
173
174
  :columns="columns"
174
175
  :row-key="(s2) => s2.key"
175
176
  :default-sort="{ column: 'key', direction: 'asc' }"
176
- :search="{ placeholder: 'Search keys\u2026' }"
177
- :filter="true"
178
- empty-text="No secrets yet."
177
+ :search="{ placeholder: t('integration.secrets.search.placeholder') }"
178
+ :empty-text="t('integration.secrets.state.empty')"
179
179
  >
180
180
  <template #cell-key="{ row: s }">
181
181
  <div class="flex items-center gap-2 min-w-0">
@@ -184,7 +184,7 @@ const columns = [
184
184
  </div>
185
185
  <div class="min-w-0">
186
186
  <div class="font-mono text-sm truncate">{{ s.key }}</div>
187
- <div class="text-sm text-muted-foreground">Value masked — write-only</div>
187
+ <div class="text-sm text-muted-foreground">{{ t("integration.secrets.cell.masked") }}</div>
188
188
  </div>
189
189
  </div>
190
190
  </template>
@@ -204,7 +204,7 @@ const columns = [
204
204
  <component
205
205
  :is="s.created_at ? 'time' : 'span'"
206
206
  :datetime="s.created_at || void 0"
207
- :aria-label="`Created for ${s.key}: ${formatDate(s.created_at)}`"
207
+ :aria-label="t('integration.secrets.cell.created.aria', { key: s.key, date: formatDate(s.created_at) })"
208
208
  class="text-sm tabular-nums whitespace-nowrap text-muted-foreground"
209
209
  >{{ formatDate(s.created_at) }}</component>
210
210
  </template>
@@ -213,7 +213,7 @@ const columns = [
213
213
  <component
214
214
  :is="s.updated_at ? 'time' : 'span'"
215
215
  :datetime="s.updated_at || void 0"
216
- :aria-label="`Updated for ${s.key}: ${formatDate(s.updated_at)}`"
216
+ :aria-label="t('integration.secrets.cell.updated.aria', { key: s.key, date: formatDate(s.updated_at) })"
217
217
  class="text-sm tabular-nums whitespace-nowrap text-muted-foreground"
218
218
  >{{ formatDate(s.updated_at) }}</component>
219
219
  </template>
@@ -222,16 +222,16 @@ const columns = [
222
222
  <div @click.stop>
223
223
  <DropdownMenu>
224
224
  <DropdownMenuTrigger as-child>
225
- <Button variant="ghost" size="icon" class="size-7" :aria-label="`Actions for ${s.key}`">
225
+ <Button variant="ghost" size="icon" class="size-7" :aria-label="t('integration.secrets.cell.actions.aria', { key: s.key })">
226
226
  <MoreHorizontal :size="14" />
227
227
  </Button>
228
228
  </DropdownMenuTrigger>
229
229
  <DropdownMenuContent align="end">
230
- <DropdownMenuLabel>Actions</DropdownMenuLabel>
230
+ <DropdownMenuLabel>{{ t("integration.secrets.menu.label") }}</DropdownMenuLabel>
231
231
  <DropdownMenuSeparator />
232
232
  <DropdownMenuItem class="text-destructive focus:text-destructive" @select="pendingDelete = s">
233
- <Trash2 :size="14" />
234
- Delete
233
+ <TrashBin :size="14" />
234
+ {{ t("integration.common.action.delete") }}
235
235
  </DropdownMenuItem>
236
236
  </DropdownMenuContent>
237
237
  </DropdownMenu>
@@ -245,21 +245,21 @@ const columns = [
245
245
  }">
246
246
  <AlertDialogContent>
247
247
  <AlertDialogHeader>
248
- <AlertDialogTitle>Delete secret?</AlertDialogTitle>
248
+ <AlertDialogTitle>{{ t("integration.secrets.dialog.delete.title") }}</AlertDialogTitle>
249
249
  <AlertDialogDescription>
250
250
  <span v-if="pendingDelete">
251
251
  <span class="font-mono font-medium text-foreground">{{ pendingDelete.key }}</span>
252
- will be permanently removed. Workflows that reference it will break the next time they run.
252
+ {{ t("integration.secrets.dialog.delete.body") }}
253
253
  </span>
254
254
  </AlertDialogDescription>
255
255
  </AlertDialogHeader>
256
256
  <AlertDialogFooter>
257
257
  <Button variant="outline" :disabled="deleting" @click="pendingDelete = null">
258
- Cancel
258
+ {{ t("integration.common.action.cancel") }}
259
259
  </Button>
260
260
  <Button class="bg-destructive text-white hover:bg-destructive/90" :disabled="deleting" @click="confirmDelete">
261
261
  <Loader2 v-if="deleting" :size="14" class="animate-spin" />
262
- {{ deleting ? "Deleting\u2026" : "Delete" }}
262
+ {{ deleting ? t("integration.common.state.deleting") : t("integration.common.action.delete") }}
263
263
  </Button>
264
264
  </AlertDialogFooter>
265
265
  </AlertDialogContent>