@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
@@ -1,16 +1,19 @@
1
1
  <script setup>
2
2
  import { computed, onMounted, onUnmounted, ref } from "vue";
3
3
  import { Badge } from "@revenexx/studio";
4
- import { statusClass } from "../../utils/statusStyle";
4
+ import { RUN_STATUS_WORDS, normalisedRunStatus, runStatusLabel, statusClass } from "../../utils/statusStyle";
5
+ import { TRIGGER_TYPE_WORDS, triggerTypeLabel } from "../../data/integrationNodeCatalog";
5
6
  const props = defineProps({
6
7
  runs: { type: Array, required: true },
7
8
  loading: { type: Boolean, required: false, default: false },
8
- emptyText: { type: String, required: false, default: "No runs yet \u2014 kick off a workflow to populate this view." },
9
+ emptyText: { type: String, required: false },
9
10
  pagination: { type: Boolean, required: false, default: true },
10
11
  showWorkflow: { type: Boolean, required: false, default: true }
11
12
  });
12
13
  const emit = defineEmits(["changed"]);
14
+ const t = useIntegrationsT();
13
15
  const router = useRouter();
16
+ const emptyText = computed(() => props.emptyText ?? t("integration.runs.table.empty"));
14
17
  const now = ref(Date.now());
15
18
  let tick = null;
16
19
  onMounted(() => {
@@ -31,10 +34,10 @@ function formatRelative(iso) {
31
34
  if (!iso) return "\u2014";
32
35
  const ms = Date.now() - new Date(iso).getTime();
33
36
  if (Number.isNaN(ms)) return "\u2014";
34
- if (ms < 6e4) return `${Math.max(1, Math.floor(ms / 1e3))}s ago`;
35
- if (ms < 36e5) return `${Math.floor(ms / 6e4)}m ago`;
36
- if (ms < 864e5) return `${Math.floor(ms / 36e5)}h ago`;
37
- return `${Math.floor(ms / 864e5)}d ago`;
37
+ if (ms < 6e4) return t("integration.common.time.secondsAgo", Math.max(1, Math.floor(ms / 1e3)));
38
+ if (ms < 36e5) return t("integration.common.time.minutesAgo", Math.floor(ms / 6e4));
39
+ if (ms < 864e5) return t("integration.common.time.hoursAgo", Math.floor(ms / 36e5));
40
+ return t("integration.common.time.daysAgo", Math.floor(ms / 864e5));
38
41
  }
39
42
  function formatDuration(ms) {
40
43
  if (ms == null) return "\u2014";
@@ -49,16 +52,35 @@ function liveDuration(run) {
49
52
  const end = run.finishedAt ? Date.parse(run.finishedAt) : now.value;
50
53
  return end - start;
51
54
  }
55
+ const filterTabs = computed(() => props.pagination ? [
56
+ {
57
+ key: "status",
58
+ label: t("integration.runs.table.column.status"),
59
+ getter: (r) => normalisedRunStatus(r.status),
60
+ options: RUN_STATUS_WORDS.map((v) => ({ value: v, label: runStatusLabel(v) }))
61
+ },
62
+ {
63
+ key: "trigger",
64
+ label: t("integration.runs.table.column.trigger"),
65
+ getter: (r) => r.triggerType ?? "",
66
+ options: TRIGGER_TYPE_WORDS.map((v) => ({ value: v, label: triggerTypeLabel(v) }))
67
+ }
68
+ ] : void 0);
52
69
  const columns = computed(() => [
53
- { key: "status", label: "Status", width: "100px", sortable: true, sortGetter: (r) => r.status },
54
- ...props.showWorkflow ? [{ key: "workflow", label: "Workflow", sortable: true, sortGetter: (r) => r.workflowName.toLowerCase(), searchGetter: (r) => r.workflowName }] : [],
70
+ // Sorted and searched by the word on screen, not by the raw field: a column
71
+ // with no `searchGetter` is searched through its `sortGetter`, so a German
72
+ // reader typing the status they can see has to find the row that shows it.
73
+ { key: "status", label: t("integration.runs.table.column.status"), width: "100px", sortable: true, sortGetter: (r) => runStatusLabel(r.status) },
74
+ ...props.showWorkflow ? [{ key: "workflow", label: t("integration.runs.table.column.workflow"), sortable: true, sortGetter: (r) => r.workflowName.toLowerCase(), searchGetter: (r) => r.workflowName }] : [],
55
75
  // Fixed floor wide enough for a full UUID, flexible above it — header and
56
76
  // body rows are separate grids, so a content-sized track (max-content)
57
77
  // would misalign them. The flex share also lets the table fill the width.
58
- { key: "id", label: "ID", width: "minmax(280px,1fr)", searchGetter: (r) => `${r.temporalRunId ?? ""} ${r.id}` },
59
- { key: "trigger", label: "Trigger", width: "120px", sortable: true, sortGetter: (r) => r.triggerType },
60
- { key: "started", label: "Started", width: "120px", sortable: true, sortGetter: (r) => r.startedAt },
61
- { key: "duration", label: "Duration", width: "100px", sortable: true, sortGetter: (r) => r.durationMs ?? -1 },
78
+ { key: "id", label: t("integration.runs.table.column.id"), width: "minmax(280px,1fr)", searchGetter: (r) => `${r.temporalRunId ?? ""} ${r.id}` },
79
+ // Same rule as the status column above: the word on screen is what sorts and
80
+ // what a search has to match.
81
+ { key: "trigger", label: t("integration.runs.table.column.trigger"), width: "120px", sortable: true, sortGetter: (r) => triggerTypeLabel(r.triggerType) },
82
+ { key: "started", label: t("integration.runs.table.column.started"), width: "120px", sortable: true, sortGetter: (r) => r.startedAt },
83
+ { key: "duration", label: t("integration.runs.table.column.duration"), width: "100px", sortable: true, sortGetter: (r) => r.durationMs ?? -1 },
62
84
  { key: "actions", label: "", width: "52px" }
63
85
  ]);
64
86
  </script>
@@ -69,8 +91,8 @@ const columns = computed(() => [
69
91
  :columns="columns"
70
92
  :row-key="(r) => r.id"
71
93
  :default-sort="{ column: 'started', direction: 'desc' }"
72
- :search="{ placeholder: 'Search runs\u2026' }"
73
- :filter="true"
94
+ :search="{ placeholder: t('integration.runs.table.search.placeholder') }"
95
+ :filter-tabs="filterTabs"
74
96
  :loading="loading"
75
97
  :pagination="pagination"
76
98
  :empty-text="emptyText"
@@ -78,7 +100,7 @@ const columns = computed(() => [
78
100
  >
79
101
  <template #cell-status="{ row: run }">
80
102
  <Badge variant="outline" :class="['border text-xs font-medium uppercase', statusClass(run.status)]">
81
- {{ run.status }}
103
+ {{ runStatusLabel(run.status) }}
82
104
  </Badge>
83
105
  </template>
84
106
 
@@ -93,7 +115,7 @@ const columns = computed(() => [
93
115
 
94
116
  <template #cell-trigger="{ row: run }">
95
117
  <span class="inline-flex items-center whitespace-nowrap rounded-md border border-black/[0.06] bg-muted/50 px-1.5 py-0.5 text-xs font-medium uppercase text-muted-foreground">
96
- {{ run.triggerType }}
118
+ {{ triggerTypeLabel(run.triggerType) }}
97
119
  </span>
98
120
  </template>
99
121
 
@@ -14,7 +14,6 @@ declare const _default: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {
14
14
  onChanged?: (() => any) | undefined;
15
15
  }>, {
16
16
  loading: boolean;
17
- emptyText: string;
18
17
  pagination: boolean;
19
18
  showWorkflow: boolean;
20
19
  }, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
@@ -5,6 +5,7 @@ import { Badge, Button, Dialog, DialogContent, DialogDescription, DialogHeader,
5
5
  import { useIntegrationsCredentials } from "../../composables/useIntegrationsCredentials";
6
6
  const open = defineModel("open", { type: Boolean, ...{ default: false } });
7
7
  const emit = defineEmits(["created"]);
8
+ const t = useIntegrationsT();
8
9
  const sftp = typeof useConsoleSftp === "function" ? useConsoleSftp() : {
9
10
  accounts: ref([]),
10
11
  loading: ref(false),
@@ -30,7 +31,7 @@ async function importAccount(account) {
30
31
  try {
31
32
  const creds = await sftp.revealPassword(tenant, account.id);
32
33
  if (!creds?.password) {
33
- error.value = "Could not read the account password.";
34
+ error.value = t("integration.credentials.sftpImport.error.readPassword");
34
35
  return;
35
36
  }
36
37
  const created = await create({
@@ -46,7 +47,7 @@ async function importAccount(account) {
46
47
  emit("created", created.id);
47
48
  open.value = false;
48
49
  } catch (err) {
49
- error.value = err instanceof Error ? err.message : "Import failed.";
50
+ error.value = err instanceof Error ? err.message : t("integration.credentials.sftpImport.error.importFailed");
50
51
  } finally {
51
52
  busyId.value = null;
52
53
  }
@@ -57,9 +58,9 @@ async function importAccount(account) {
57
58
  <Dialog v-model:open="open">
58
59
  <DialogContent class="sm:max-w-md">
59
60
  <DialogHeader>
60
- <DialogTitle>Import platform SFTP account</DialogTitle>
61
+ <DialogTitle>{{ t("integration.credentials.sftpImport.title") }}</DialogTitle>
61
62
  <DialogDescription class="text-sm">
62
- Creates an SFTP credential from a managed platform account — host, username and password are copied once. Later password changes must be re-imported.
63
+ {{ t("integration.credentials.sftpImport.description") }}
63
64
  </DialogDescription>
64
65
  </DialogHeader>
65
66
 
@@ -67,7 +68,7 @@ async function importAccount(account) {
67
68
  <div v-for="n in 3" :key="`sk-${n}`" class="h-9 rounded-md bg-black/[0.07] animate-pulse" />
68
69
  </div>
69
70
  <p v-else-if="!activeAccounts.length" class="py-4 text-center text-sm text-muted-foreground">
70
- No active platform SFTP accounts. Create one under Integration Studio → SFTP first.
71
+ {{ t("integration.credentials.sftpImport.empty") }}
71
72
  </p>
72
73
  <div v-else class="max-h-72 space-y-1 overflow-y-auto">
73
74
  <button
@@ -82,7 +83,7 @@ async function importAccount(account) {
82
83
  <span class="block truncate font-mono text-sm">{{ account.login_username }}</span>
83
84
  <span class="block truncate text-xs text-muted-foreground">{{ account.home_directory ?? account.server ?? "" }}</span>
84
85
  </span>
85
- <Badge variant="outline" class="text-xs font-medium uppercase shrink-0">Active</Badge>
86
+ <Badge variant="outline" class="text-xs font-medium uppercase shrink-0">{{ t("integration.credentials.sftpImport.badge.active") }}</Badge>
86
87
  <Loader2 v-if="busyId === account.id" :size="14" class="shrink-0 animate-spin text-muted-foreground" />
87
88
  </button>
88
89
  </div>
@@ -90,7 +91,7 @@ async function importAccount(account) {
90
91
  <p v-if="error" class="text-sm text-rose-600">{{ error }}</p>
91
92
 
92
93
  <div class="flex justify-end">
93
- <Button variant="outline" size="sm" class="h-8 text-sm" @click="open = false">Close</Button>
94
+ <Button variant="outline" size="sm" class="h-8 text-sm" @click="open = false">{{ t("integration.common.action.close") }}</Button>
94
95
  </div>
95
96
  </DialogContent>
96
97
  </Dialog>
@@ -1,6 +1,7 @@
1
1
  <script setup>
2
2
  import { Pulse as Activity, ClockCircle as Clock, Home, Key as KeyRound, Layers, ShieldCheck, ShieldKeyhole as Credentials, Programming as Workflow } from "#solar-icons";
3
3
  import SidebarNav from "@revenexx/studio-shared/components/Shared/SidebarNav.vue";
4
+ const t = useIntegrationsT();
4
5
  const { workflows } = useIntegrationsWorkflows();
5
6
  const { runningCount } = useIntegrationsRuns();
6
7
  const { activeCount: schedulesActive } = useIntegrationsSchedules();
@@ -10,25 +11,28 @@ const sections = computed(() => [
10
11
  {
11
12
  key: "pinned",
12
13
  items: [
13
- { label: "Dashboard", to: "/integrations", icon: Home, exact: true }
14
+ { label: t("integration.nav.item.dashboard"), to: "/integrations", icon: Home, exact: true }
14
15
  ]
15
16
  },
16
17
  {
17
18
  key: "integrations",
18
- label: "Integrations",
19
+ // A rail's section heading is a proper noun and stays English in every UI
20
+ // language. It is still a key, so the decision is written down where a
21
+ // translator will look for it rather than only in a review comment.
22
+ label: t("integration.nav.section.integrations"),
19
23
  items: [
20
- { label: "Workflows", to: "/integrations/workflows", icon: Workflow, badge: workflows.value.length || null },
21
- { label: "Templates", to: "/integrations/templates", icon: Layers },
22
- { label: "Runs", to: "/integrations/runs", icon: Activity, badge: runningCount.value || null },
23
- { label: "Schedules", to: "/integrations/schedules", icon: Clock, badge: schedulesActive.value || null },
24
- { label: "Credentials", to: "/integrations/credentials", icon: Credentials, badge: credentialsTotal.value || null },
25
- { label: "Secrets", to: "/integrations/secrets", icon: KeyRound, badge: secretsTotal.value || null },
26
- { label: "Audit Log", to: "/integrations/audit", icon: ShieldCheck }
24
+ { label: t("integration.nav.item.workflows"), to: "/integrations/workflows", icon: Workflow, badge: workflows.value.length || null },
25
+ { label: t("integration.nav.item.templates"), to: "/integrations/templates", icon: Layers },
26
+ { label: t("integration.nav.item.runs"), to: "/integrations/runs", icon: Activity, badge: runningCount.value || null },
27
+ { label: t("integration.nav.item.schedules"), to: "/integrations/schedules", icon: Clock, badge: schedulesActive.value || null },
28
+ { label: t("integration.nav.item.credentials"), to: "/integrations/credentials", icon: Credentials, badge: credentialsTotal.value || null },
29
+ { label: t("integration.nav.item.secrets"), to: "/integrations/secrets", icon: KeyRound, badge: secretsTotal.value || null },
30
+ { label: t("integration.nav.item.audit"), to: "/integrations/audit", icon: ShieldCheck }
27
31
  ]
28
32
  }
29
33
  ]);
30
34
  </script>
31
35
 
32
36
  <template>
33
- <SidebarNav title="Integration Studio" :icon="Workflow" :sections="sections" minimal />
37
+ <SidebarNav :title="t('integration.nav.title')" :icon="Workflow" :sections="sections" minimal />
34
38
  </template>
@@ -11,13 +11,23 @@ import type { WorkflowSticky } from '../../types/integrations.js';
11
11
  * - NodeToolbar on hover/selection: 7-color pastel palette + delete.
12
12
  * - Hand-rolled bottom-right resize handle (no @vue-flow/node-resizer dep);
13
13
  * size persists once on pointer-up.
14
+ *
15
+ * **Absence of the callbacks is what makes a note read-only** — the run replay and
16
+ * the history preview hand it none, and then it draws no toolbar, no resize handle
17
+ * and cannot be opened for typing (behaviour specs/sticky-notes.md AC-9). They used
18
+ * to be handed no-ops instead, which left every one of those affordances on screen
19
+ * doing nothing: a reader could not tell "not allowed here" from "broken" (PO-284).
20
+ * `GroupFrameView` gates its own toolbar on the same absence.
14
21
  */
15
22
  export interface StickyNodeData {
16
23
  sticky: WorkflowSticky;
17
- /** Persist a partial change into the blob (editor-side, syncs + pushes). */
18
- onUpdate: (patch: Partial<Pick<WorkflowSticky, 'content' | 'size' | 'color'>>) => void;
19
- /** Remove this sticky from the blob (editor-side). */
20
- onRemove: () => void;
24
+ /**
25
+ * Persist a partial change into the blob (editor-side, syncs + pushes).
26
+ * Absent on a read-only canvas — see the note above.
27
+ */
28
+ onUpdate?: (patch: Partial<Pick<WorkflowSticky, 'content' | 'size' | 'color'>>) => void;
29
+ /** Remove this sticky from the blob (editor-side). Absent when read-only. */
30
+ onRemove?: () => void;
21
31
  }
22
32
  type __VLS_Props = {
23
33
  id: string;
@@ -2,7 +2,7 @@
2
2
  import { computed, nextTick, ref, watch } from "vue";
3
3
  import { Position, useVueFlow } from "@vue-flow/core";
4
4
  import { NodeToolbar } from "@vue-flow/node-toolbar";
5
- import { Trash2 } from "lucide-vue-next";
5
+ import { TrashBinMinimalistic as TrashBin } from "#solar-icons";
6
6
  const props = defineProps({
7
7
  id: { type: String, required: true },
8
8
  data: { type: Object, required: true },
@@ -10,20 +10,22 @@ const props = defineProps({
10
10
  });
11
11
  defineOptions({ inheritAttrs: false });
12
12
  defineEmits(["updateNodeInternals"]);
13
+ const t = useIntegrationsT();
13
14
  const flow = useVueFlow();
14
15
  const STICKY_COLORS = [
15
- { bg: "#fef3c7", border: "#fde68a", name: "Amber" },
16
- { bg: "#ffedd5", border: "#fed7aa", name: "Orange" },
17
- { bg: "#ffe4e6", border: "#fecdd3", name: "Rose" },
18
- { bg: "#dcfce7", border: "#bbf7d0", name: "Green" },
19
- { bg: "#dbeafe", border: "#bfdbfe", name: "Blue" },
20
- { bg: "#ede9fe", border: "#ddd6fe", name: "Violet" },
21
- { bg: "#f5f5f4", border: "#e7e5e4", name: "Gray" }
16
+ { bg: "#fef3c7", border: "#fde68a", nameKey: "integration.canvas.color.amber" },
17
+ { bg: "#ffedd5", border: "#fed7aa", nameKey: "integration.canvas.color.orange" },
18
+ { bg: "#ffe4e6", border: "#fecdd3", nameKey: "integration.canvas.color.rose" },
19
+ { bg: "#dcfce7", border: "#bbf7d0", nameKey: "integration.canvas.color.green" },
20
+ { bg: "#dbeafe", border: "#bfdbfe", nameKey: "integration.canvas.color.blue" },
21
+ { bg: "#ede9fe", border: "#ddd6fe", nameKey: "integration.canvas.color.violet" },
22
+ { bg: "#f5f5f4", border: "#e7e5e4", nameKey: "integration.canvas.color.gray" }
22
23
  ];
23
24
  const color = computed(() => STICKY_COLORS[props.data.sticky.color ?? 0] ?? STICKY_COLORS[0]);
25
+ const editable = computed(() => !!props.data.onUpdate && !!props.data.onRemove);
24
26
  function pickColor(i) {
25
27
  if ((props.data.sticky.color ?? 0) === i) return;
26
- props.data.onUpdate({ color: i });
28
+ props.data.onUpdate?.({ color: i });
27
29
  }
28
30
  const DEFAULT_WIDTH = 240;
29
31
  const DEFAULT_HEIGHT = 160;
@@ -54,13 +56,18 @@ function onResizeMove(e) {
54
56
  function onResizeEnd() {
55
57
  if (!resizing.value) return;
56
58
  resizing.value = false;
57
- props.data.onUpdate({ size: { width: width.value, height: height.value } });
59
+ props.data.onUpdate?.({ size: { width: width.value, height: height.value } });
58
60
  }
59
61
  const editing = ref(false);
60
62
  const draft = ref("");
61
63
  const textareaEl = ref(null);
64
+ function onDoubleClick(event) {
65
+ if (!editable.value) return;
66
+ event.stopPropagation();
67
+ startEdit();
68
+ }
62
69
  function startEdit() {
63
- if (editing.value) return;
70
+ if (!editable.value || editing.value) return;
64
71
  draft.value = props.data.sticky.content;
65
72
  editing.value = true;
66
73
  nextTick(() => textareaEl.value?.focus());
@@ -69,7 +76,7 @@ function commitEdit() {
69
76
  if (!editing.value) return;
70
77
  editing.value = false;
71
78
  if (draft.value !== props.data.sticky.content) {
72
- props.data.onUpdate({ content: draft.value });
79
+ props.data.onUpdate?.({ content: draft.value });
73
80
  }
74
81
  }
75
82
  function onTextareaKeydown(e) {
@@ -91,26 +98,60 @@ function onHoverLeave() {
91
98
  hovered.value = false;
92
99
  }, 250);
93
100
  }
94
- const toolbarVisible = computed(() => !editing.value && (hovered.value || !!props.selected));
101
+ const toolbarVisible = computed(() => editable.value && !editing.value && (hovered.value || !!props.selected));
95
102
  function escapeHtml(s) {
96
- return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
103
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
97
104
  }
98
105
  function inline(s) {
99
106
  return s.replace(/`([^`\n]+)`/g, '<code class="rounded bg-black/[0.07] px-1 py-px font-mono text-[0.85em]">$1</code>').replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer" class="font-medium underline decoration-current/40 underline-offset-2 hover:decoration-current">$1</a>').replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>").replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, "$1<em>$2</em>");
100
107
  }
108
+ const NOTE_HEADINGS = [
109
+ { tag: "h2", cls: "m-0 text-base font-semibold" },
110
+ { tag: "h3", cls: "m-0 text-sm font-semibold" },
111
+ { tag: "h4", cls: "m-0 text-sm font-medium" }
112
+ ];
113
+ const HEADING_RE = /^(#{1,3})\s+(.+)$/;
114
+ const UL_RE = /^[-*]\s+(.+)$/;
115
+ const OL_RE = /^(\d+)\.\s+(.+)$/;
116
+ function renderList(tag, items) {
117
+ const li = items.map(({ marker, text }) => {
118
+ const numeric = tag === "ol" ? " tabular-nums" : "";
119
+ const hidden = tag === "ul" ? ' aria-hidden="true"' : "";
120
+ const ordinal = tag === "ol" ? ` value="${Number.parseInt(marker, 10)}"` : "";
121
+ return `<li${ordinal} class="flex gap-1.5"><span${hidden} class="select-none${numeric}">${marker}</span><span class="min-w-0">${text}</span></li>`;
122
+ }).join("");
123
+ return `<${tag} role="list" class="m-0 list-none p-0 pl-1">${li}</${tag}>`;
124
+ }
101
125
  function renderMarkdownLite(src) {
102
- return escapeHtml(src).split("\n").map((line) => {
103
- const h = /^(#{1,3})\s+(.+)$/.exec(line);
126
+ const lines = escapeHtml(src).split("\n");
127
+ const out = [];
128
+ let i = 0;
129
+ while (i < lines.length) {
130
+ const line = lines[i] ?? "";
131
+ const h = HEADING_RE.exec(line);
104
132
  if (h) {
105
- const cls = h[1].length === 1 ? "text-base font-semibold" : h[1].length === 2 ? "text-sm font-semibold" : "text-sm font-medium";
106
- return `<div class="${cls}">${inline(h[2])}</div>`;
133
+ const { tag, cls } = NOTE_HEADINGS[(h[1] ?? "#").length - 1] ?? NOTE_HEADINGS[2];
134
+ out.push(`<${tag} class="${cls}">${inline(h[2] ?? "")}</${tag}>`);
135
+ i++;
136
+ continue;
107
137
  }
108
- const ul = /^[-*]\s+(.+)$/.exec(line);
109
- if (ul) return `<div class="flex gap-1.5 pl-1"><span class="select-none">\u2022</span><span class="min-w-0">${inline(ul[1])}</span></div>`;
110
- const ol = /^(\d+)\.\s+(.+)$/.exec(line);
111
- if (ol) return `<div class="flex gap-1.5 pl-1"><span class="select-none tabular-nums">${ol[1]}.</span><span class="min-w-0">${inline(ol[2])}</span></div>`;
112
- return line ? `<div>${inline(line)}</div>` : "<div>&nbsp;</div>";
113
- }).join("");
138
+ const listRe = UL_RE.test(line) ? UL_RE : OL_RE.test(line) ? OL_RE : null;
139
+ if (listRe) {
140
+ const tag = listRe === UL_RE ? "ul" : "ol";
141
+ const items = [];
142
+ while (i < lines.length) {
143
+ const m = listRe.exec(lines[i] ?? "");
144
+ if (!m) break;
145
+ items.push({ marker: tag === "ul" ? "\u2022" : `${m[1]}.`, text: inline(m[m.length - 1] ?? "") });
146
+ i++;
147
+ }
148
+ out.push(renderList(tag, items));
149
+ continue;
150
+ }
151
+ out.push(line ? `<div>${inline(line)}</div>` : "<div>&nbsp;</div>");
152
+ i++;
153
+ }
154
+ return out.join("");
114
155
  }
115
156
  const rendered = computed(() => renderMarkdownLite(props.data.sticky.content));
116
157
  function onViewMouseDown(e) {
@@ -133,17 +174,17 @@ function onViewMouseDown(e) {
133
174
  class="size-5 rounded-full border transition hover:scale-110"
134
175
  :class="(data.sticky.color ?? 0) === i ? 'ring-2 ring-black/25 ring-offset-1' : ''"
135
176
  :style="{ backgroundColor: c.bg, borderColor: c.border }"
136
- :title="c.name"
177
+ :title="t(c.nameKey)"
137
178
  @click="pickColor(i)"
138
179
  />
139
180
  <div class="mx-0.5 h-4 w-px bg-black/10" />
140
181
  <button
141
182
  type="button"
142
183
  class="flex size-6 items-center justify-center rounded-md text-muted-foreground transition hover:bg-rose-50 hover:text-rose-600"
143
- title="Delete note"
144
- @click="data.onRemove()"
184
+ :title="t('integration.canvas.sticky.action.delete')"
185
+ @click="data.onRemove?.()"
145
186
  >
146
- <Trash2 :size="13" />
187
+ <TrashBin :size="13" />
147
188
  </button>
148
189
  </div>
149
190
  </NodeToolbar>
@@ -163,7 +204,7 @@ function onViewMouseDown(e) {
163
204
  }"
164
205
  @mouseenter="onHoverEnter"
165
206
  @mouseleave="onHoverLeave"
166
- @dblclick.stop="startEdit"
207
+ @dblclick="onDoubleClick"
167
208
  >
168
209
  <!-- View mode -->
169
210
  <div
@@ -172,7 +213,11 @@ function onViewMouseDown(e) {
172
213
  @mousedown="onViewMouseDown"
173
214
  >
174
215
  <div v-if="data.sticky.content" v-html="rendered" />
175
- <span v-else class="select-none text-stone-400">Double-click to edit</span>
216
+ <!-- The hint is an instruction, not content, so it goes with the rest of the
217
+ affordances: on a read-only canvas it would name the one gesture
218
+ `startEdit` refuses, which is the false affordance AC-9 removes rather
219
+ than a smaller version of it. An empty note there simply reads empty. -->
220
+ <span v-else-if="editable" class="select-none text-stone-400">{{ t("integration.canvas.sticky.state.emptyHint") }}</span>
176
221
  </div>
177
222
 
178
223
  <!-- Edit mode — nodrag/nowheel so typing and scrolling never drag or zoom
@@ -181,9 +226,9 @@ function onViewMouseDown(e) {
181
226
  v-else
182
227
  ref="textareaEl"
183
228
  v-model="draft"
184
- aria-label="Note text"
229
+ :aria-label="t('integration.canvas.sticky.textarea.aria')"
185
230
  class="nodrag nowheel h-full w-full resize-none bg-transparent px-3 py-2 text-sm leading-snug text-stone-700 outline-none placeholder:text-stone-400 focus:ring-2 focus:ring-inset focus:ring-stone-500/40"
186
- placeholder="Write a note… (# heading, **bold**, *italic*, `code`, [link](https://…), - list)"
231
+ :placeholder="t('integration.canvas.sticky.textarea.placeholder')"
187
232
  spellcheck="false"
188
233
  @blur="commitEdit"
189
234
  @keydown="onTextareaKeydown"
@@ -191,10 +236,13 @@ function onViewMouseDown(e) {
191
236
  @dblclick.stop
192
237
  />
193
238
 
194
- <!-- Bottom-right resize handle (hand-rolled; min 150x80) -->
239
+ <!-- Bottom-right resize handle (hand-rolled; min 150x80). Gone on a
240
+ read-only canvas: a handle that cannot resize is the same lie as a bin
241
+ that cannot delete. -->
195
242
  <div
243
+ v-if="editable"
196
244
  class="nodrag absolute bottom-0 right-0 flex size-4 cursor-se-resize items-end justify-end p-0.5 text-stone-400/80"
197
- title="Resize"
245
+ :title="t('integration.canvas.sticky.action.resize')"
198
246
  @pointerdown="onResizeStart"
199
247
  @pointermove="onResizeMove"
200
248
  @pointerup="onResizeEnd"
@@ -11,13 +11,23 @@ import type { WorkflowSticky } from '../../types/integrations.js';
11
11
  * - NodeToolbar on hover/selection: 7-color pastel palette + delete.
12
12
  * - Hand-rolled bottom-right resize handle (no @vue-flow/node-resizer dep);
13
13
  * size persists once on pointer-up.
14
+ *
15
+ * **Absence of the callbacks is what makes a note read-only** — the run replay and
16
+ * the history preview hand it none, and then it draws no toolbar, no resize handle
17
+ * and cannot be opened for typing (behaviour specs/sticky-notes.md AC-9). They used
18
+ * to be handed no-ops instead, which left every one of those affordances on screen
19
+ * doing nothing: a reader could not tell "not allowed here" from "broken" (PO-284).
20
+ * `GroupFrameView` gates its own toolbar on the same absence.
14
21
  */
15
22
  export interface StickyNodeData {
16
23
  sticky: WorkflowSticky;
17
- /** Persist a partial change into the blob (editor-side, syncs + pushes). */
18
- onUpdate: (patch: Partial<Pick<WorkflowSticky, 'content' | 'size' | 'color'>>) => void;
19
- /** Remove this sticky from the blob (editor-side). */
20
- onRemove: () => void;
24
+ /**
25
+ * Persist a partial change into the blob (editor-side, syncs + pushes).
26
+ * Absent on a read-only canvas — see the note above.
27
+ */
28
+ onUpdate?: (patch: Partial<Pick<WorkflowSticky, 'content' | 'size' | 'color'>>) => void;
29
+ /** Remove this sticky from the blob (editor-side). Absent when read-only. */
30
+ onRemove?: () => void;
21
31
  }
22
32
  type __VLS_Props = {
23
33
  id: string;
@@ -0,0 +1,50 @@
1
+ /**
2
+ * One shortcut into a surface the *host* provides — Import & Export, the SFTP
3
+ * server, the event stream, inbound webhooks. None of the four is a page of this
4
+ * module: they are the cockpit's, and this card is the studio's landing page
5
+ * pointing at them.
6
+ *
7
+ * So the card has two states, and which one it is in is not its own decision:
8
+ *
9
+ * - **`available`** — the host has the surface. The card is a link and behaves
10
+ * exactly as it did before PO-223.
11
+ * - **not available** — the card stays on screen, looks the same, and is a button
12
+ * that explains where the surface lives instead of leading into a route the host
13
+ * does not serve. Before PO-223 it was a link into nothing: in the playground
14
+ * and in the standalone dev studio all four led to a 404.
15
+ *
16
+ * **Staying on screen is the point**, and it is the call the managed credential
17
+ * sources already made (`CredentialRevenexxSource.vue`, PO-222): a control that
18
+ * vanishes leaves nothing to read the explanation from, and the reader cannot tell
19
+ * a broken feature from one that belongs somewhere else.
20
+ *
21
+ * **No `aria-label`, on purpose** — like `TemplateCard`, the card takes its name
22
+ * from what it shows. The state is carried by the *role*: "Import & Export,
23
+ * button" instead of "link" is what says to a screen reader that this does not
24
+ * navigate, and it says it without announcing something no sighted reader can see
25
+ * (`specs/accessibility.md` AC-1 names the on-screen text as the source of a name).
26
+ *
27
+ * Who decides `available`, and why it is the router rather than a guess about the
28
+ * host, is `docs/host-surfaces.md`.
29
+ *
30
+ * The two branches draw the same body twice rather than sharing it: `as-child`
31
+ * wants a real element as its child, and the alternatives — a wrapper element
32
+ * around the card, or a second component for eight lines — cost either the grid
33
+ * layout or a file. **Keep the two bodies identical.**
34
+ */
35
+ import type { Component } from 'vue';
36
+ type __VLS_Props = {
37
+ /** What the surface is called, in the reader's language. */
38
+ label: string;
39
+ /** One line on what it is for. Shown in both states. */
40
+ hint: string;
41
+ icon: Component;
42
+ /** Where the surface lives in a host that has it. */
43
+ to: string;
44
+ /** Whether this host has it. See the comment above; no default on purpose. */
45
+ available: boolean;
46
+ /** What the popover says when it does not. */
47
+ note: string;
48
+ };
49
+ declare const _default: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
50
+ export default _default;
@@ -0,0 +1,55 @@
1
+ <script setup>
2
+ import { ArrowRight } from "#solar-icons";
3
+ import { Popover, PopoverContent, PopoverTrigger } from "@revenexx/studio";
4
+ defineProps({
5
+ label: { type: String, required: true },
6
+ hint: { type: String, required: true },
7
+ icon: { type: null, required: true },
8
+ to: { type: String, required: true },
9
+ available: { type: Boolean, required: true },
10
+ note: { type: String, required: true }
11
+ });
12
+ const CARD_CLASS = "group rounded-lg border border-black/[0.06] bg-white p-3 transition hover:border-black/20";
13
+ </script>
14
+
15
+ <template>
16
+ <NuxtLink v-if="available" :to="to" :class="CARD_CLASS">
17
+ <div class="flex items-center gap-2">
18
+ <span class="flex size-6 items-center justify-center rounded-md bg-slate-100 text-slate-500">
19
+ <component :is="icon" :size="12" />
20
+ </span>
21
+ <span class="min-w-0 flex-1 truncate text-sm font-semibold">{{ label }}</span>
22
+ <ArrowRight :size="11" class="text-muted-foreground transition-transform group-hover:translate-x-0.5" />
23
+ </div>
24
+ <p class="mt-2 text-sm text-muted-foreground">
25
+ {{ hint }}
26
+ </p>
27
+ </NuxtLink>
28
+
29
+ <Popover v-else>
30
+ <PopoverTrigger as-child>
31
+ <!-- No `id` here: `PopoverTrigger` puts reka-ui's generated `triggerId` on
32
+ its child and `PopoverContentImpl` points the opened dialog's
33
+ `aria-labelledby` at exactly that id, so the popover is named by this
34
+ card. Ours would replace it (reka's `Slot` merges with the child's
35
+ props winning) and leave the dialog naming nothing. -->
36
+ <button type="button" :class="[CARD_CLASS, 'w-full text-left']">
37
+ <div class="flex items-center gap-2">
38
+ <span class="flex size-6 items-center justify-center rounded-md bg-slate-100 text-slate-500">
39
+ <component :is="icon" :size="12" />
40
+ </span>
41
+ <span class="min-w-0 flex-1 truncate text-sm font-semibold">{{ label }}</span>
42
+ <ArrowRight :size="11" class="text-muted-foreground transition-transform group-hover:translate-x-0.5" />
43
+ </div>
44
+ <p class="mt-2 text-sm text-muted-foreground">
45
+ {{ hint }}
46
+ </p>
47
+ </button>
48
+ </PopoverTrigger>
49
+ <PopoverContent align="start" class="w-72 rounded-lg border border-black/[0.06] bg-white p-3 shadow-lg">
50
+ <p class="text-sm text-muted-foreground">
51
+ {{ note }}
52
+ </p>
53
+ </PopoverContent>
54
+ </Popover>
55
+ </template>