@revenexx/studio-integrations 0.5.0 → 0.6.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 (129) hide show
  1. package/CHANGELOG.md +201 -0
  2. package/README.md +17 -4
  3. package/dist/module.json +1 -1
  4. package/dist/runtime/components/Integrations/CredentialCreateDialog.vue +15 -14
  5. package/dist/runtime/components/Integrations/CredentialDetailDialog.vue +91 -19
  6. package/dist/runtime/components/Integrations/CredentialRevenexxSource.vue +58 -21
  7. package/dist/runtime/components/Integrations/CredentialTypePicker.vue +4 -8
  8. package/dist/runtime/components/Integrations/EventTypeCombobox.d.vue.ts +1 -0
  9. package/dist/runtime/components/Integrations/EventTypeCombobox.vue +4 -2
  10. package/dist/runtime/components/Integrations/EventTypeCombobox.vue.d.ts +1 -0
  11. package/dist/runtime/components/Integrations/ExpressionInput.d.vue.ts +4 -0
  12. package/dist/runtime/components/Integrations/ExpressionInput.vue +54 -25
  13. package/dist/runtime/components/Integrations/ExpressionInput.vue.d.ts +4 -0
  14. package/dist/runtime/components/Integrations/GroupFrameView.vue +12 -12
  15. package/dist/runtime/components/Integrations/Layout.vue +21 -2
  16. package/dist/runtime/components/Integrations/NodeConfigDynamicField.vue +10 -11
  17. package/dist/runtime/components/Integrations/NodeConfigDynamicSchema.vue +2 -5
  18. package/dist/runtime/components/Integrations/NodeConfigField.d.vue.ts +2 -0
  19. package/dist/runtime/components/Integrations/NodeConfigField.vue +160 -67
  20. package/dist/runtime/components/Integrations/NodeConfigField.vue.d.ts +2 -0
  21. package/dist/runtime/components/Integrations/NodeInspector.d.vue.ts +6 -0
  22. package/dist/runtime/components/Integrations/NodeInspector.vue +1075 -303
  23. package/dist/runtime/components/Integrations/NodeInspector.vue.d.ts +6 -0
  24. package/dist/runtime/components/Integrations/NodeLogo.d.vue.ts +12 -0
  25. package/dist/runtime/components/Integrations/NodeLogo.vue +2 -30
  26. package/dist/runtime/components/Integrations/NodeLogo.vue.d.ts +12 -0
  27. package/dist/runtime/components/Integrations/NodeMark.d.vue.ts +15 -0
  28. package/dist/runtime/components/Integrations/NodeMark.vue +79 -0
  29. package/dist/runtime/components/Integrations/NodeMark.vue.d.ts +15 -0
  30. package/dist/runtime/components/Integrations/NodeOutputSchema.d.vue.ts +21 -0
  31. package/dist/runtime/components/Integrations/NodeOutputSchema.vue +42 -0
  32. package/dist/runtime/components/Integrations/NodeOutputSchema.vue.d.ts +21 -0
  33. package/dist/runtime/components/Integrations/NodePalette.vue +100 -34
  34. package/dist/runtime/components/Integrations/QueryBuilder.vue +8 -1
  35. package/dist/runtime/components/Integrations/RunActionsMenu.vue +3 -1
  36. package/dist/runtime/components/Integrations/RunPayloadInput.vue +17 -4
  37. package/dist/runtime/components/Integrations/StickyNoteView.vue +2 -1
  38. package/dist/runtime/components/Integrations/TemplateCard.d.vue.ts +18 -0
  39. package/dist/runtime/components/Integrations/TemplateCard.vue +109 -0
  40. package/dist/runtime/components/Integrations/TemplateCard.vue.d.ts +18 -0
  41. package/dist/runtime/components/Integrations/TemplateFlowPreview.d.vue.ts +2 -2
  42. package/dist/runtime/components/Integrations/TemplateFlowPreview.vue +7 -10
  43. package/dist/runtime/components/Integrations/TemplateFlowPreview.vue.d.ts +2 -2
  44. package/dist/runtime/components/Integrations/WorkflowCreateDialog.vue +21 -37
  45. package/dist/runtime/components/Integrations/WorkflowEdgeView.d.vue.ts +18 -4
  46. package/dist/runtime/components/Integrations/WorkflowEdgeView.vue +23 -7
  47. package/dist/runtime/components/Integrations/WorkflowEdgeView.vue.d.ts +18 -4
  48. package/dist/runtime/components/Integrations/WorkflowEditor.d.vue.ts +22 -1
  49. package/dist/runtime/components/Integrations/WorkflowEditor.vue +170 -48
  50. package/dist/runtime/components/Integrations/WorkflowEditor.vue.d.ts +22 -1
  51. package/dist/runtime/components/Integrations/WorkflowHistoryPanel.vue +4 -20
  52. package/dist/runtime/components/Integrations/WorkflowIconPicker.d.vue.ts +19 -1
  53. package/dist/runtime/components/Integrations/WorkflowIconPicker.vue +11 -2
  54. package/dist/runtime/components/Integrations/WorkflowIconPicker.vue.d.ts +19 -1
  55. package/dist/runtime/components/Integrations/WorkflowNodeView.d.vue.ts +4 -0
  56. package/dist/runtime/components/Integrations/WorkflowNodeView.vue +189 -46
  57. package/dist/runtime/components/Integrations/WorkflowNodeView.vue.d.ts +4 -0
  58. package/dist/runtime/components/Integrations/WorkflowRunReplayPanel.vue +4 -20
  59. package/dist/runtime/components/Integrations/WorkflowSettingsDialog.vue +15 -9
  60. package/dist/runtime/components/Integrations/WorkflowTagsInput.d.vue.ts +1 -0
  61. package/dist/runtime/components/Integrations/WorkflowTagsInput.vue +7 -2
  62. package/dist/runtime/components/Integrations/WorkflowTagsInput.vue.d.ts +1 -0
  63. package/dist/runtime/components/Integrations/WorkflowTriggerView.vue +4 -2
  64. package/dist/runtime/components/Integrations/WorkflowVariablesDialog.vue +30 -17
  65. package/dist/runtime/components/Integrations/WorkflowsTable.vue +47 -7
  66. package/dist/runtime/composables/useCredentialTypes.js +4 -4
  67. package/dist/runtime/composables/useIntegrationsAudit.js +4 -3
  68. package/dist/runtime/composables/useIntegrationsCredentials.js +4 -4
  69. package/dist/runtime/composables/useIntegrationsDeadLetters.js +7 -6
  70. package/dist/runtime/composables/useIntegrationsNodes.js +4 -4
  71. package/dist/runtime/composables/useIntegrationsRuns.js +11 -6
  72. package/dist/runtime/composables/useIntegrationsSchedules.d.ts +4 -0
  73. package/dist/runtime/composables/useIntegrationsSchedules.js +10 -3
  74. package/dist/runtime/composables/useIntegrationsSecrets.js +10 -7
  75. package/dist/runtime/composables/useIntegrationsTemplates.d.ts +1 -1
  76. package/dist/runtime/composables/useIntegrationsTemplates.js +12 -8
  77. package/dist/runtime/composables/useIntegrationsTriggers.js +6 -6
  78. package/dist/runtime/composables/useIntegrationsWorkflows.js +8 -4
  79. package/dist/runtime/composables/useLoadGate.d.ts +17 -0
  80. package/dist/runtime/composables/useLoadGate.js +8 -0
  81. package/dist/runtime/data/branchReorder.d.ts +37 -0
  82. package/dist/runtime/data/branchReorder.js +71 -0
  83. package/dist/runtime/data/handleId.d.ts +23 -0
  84. package/dist/runtime/data/handleId.js +6 -0
  85. package/dist/runtime/data/integrationTemplates.d.ts +39 -0
  86. package/dist/runtime/data/integrationTemplates.js +15 -0
  87. package/dist/runtime/data/integrationWorkflowSamples.js +1 -1
  88. package/dist/runtime/data/nodeIcons.d.ts +2 -0
  89. package/dist/runtime/data/nodeIcons.js +73 -0
  90. package/dist/runtime/data/nodeInputData.d.ts +16 -4
  91. package/dist/runtime/data/nodeInputData.js +4 -0
  92. package/dist/runtime/data/payloadForm.d.ts +9 -2
  93. package/dist/runtime/data/payloadForm.js +27 -3
  94. package/dist/runtime/data/portTone.d.ts +17 -0
  95. package/dist/runtime/data/portTone.js +7 -0
  96. package/dist/runtime/data/webhookAuth.d.ts +18 -0
  97. package/dist/runtime/data/webhookAuth.js +19 -0
  98. package/dist/runtime/data/workflowIconCatalog.js +2 -5
  99. package/dist/runtime/pages/integrations/audit.vue +91 -6
  100. package/dist/runtime/pages/integrations/credentials/oauth-complete.vue +6 -4
  101. package/dist/runtime/pages/integrations/credentials.vue +119 -21
  102. package/dist/runtime/pages/integrations/index.vue +49 -44
  103. package/dist/runtime/pages/integrations/runs.vue +1 -1
  104. package/dist/runtime/pages/integrations/schedules.vue +51 -7
  105. package/dist/runtime/pages/integrations/secrets.vue +29 -7
  106. package/dist/runtime/pages/integrations/templates/[slug].vue +52 -30
  107. package/dist/runtime/pages/integrations/templates/index.vue +57 -86
  108. package/dist/runtime/pages/integrations/workflows/[id].vue +243 -25
  109. package/dist/runtime/pages/integrations/workflows/index.vue +15 -2
  110. package/dist/runtime/types/integrations.d.ts +65 -6
  111. package/dist/runtime/utils/apiError.d.ts +47 -0
  112. package/dist/runtime/utils/apiError.js +32 -0
  113. package/dist/runtime/utils/cockpitSources.d.ts +153 -0
  114. package/dist/runtime/utils/cockpitSources.js +14 -0
  115. package/dist/runtime/utils/loadGate.d.ts +61 -0
  116. package/dist/runtime/utils/loadGate.js +22 -0
  117. package/dist/runtime/utils/localize.d.ts +29 -0
  118. package/dist/runtime/utils/localize.js +4 -0
  119. package/dist/runtime/utils/notify.d.ts +59 -0
  120. package/dist/runtime/utils/notify.js +21 -0
  121. package/dist/runtime/utils/revenexxCredentialSource.d.ts +26 -0
  122. package/dist/runtime/utils/revenexxCredentialSource.js +8 -0
  123. package/dist/runtime/utils/schedule.d.ts +141 -0
  124. package/dist/runtime/utils/schedule.js +207 -0
  125. package/dist/runtime/utils/validationFindings.d.ts +101 -0
  126. package/dist/runtime/utils/validationFindings.js +53 -0
  127. package/dist/runtime/utils/whenIdle.d.ts +42 -0
  128. package/dist/runtime/utils/whenIdle.js +12 -0
  129. package/package.json +8 -2
@@ -1,15 +1,15 @@
1
1
  <script setup>
2
2
  import { computed, ref, watch } from "vue";
3
- import TemplateFlowPreview from "../../components/Integrations/TemplateFlowPreview.vue";
3
+ 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
7
  useHead({ title: "Dashboard \xB7 Integration Studio" });
8
8
  const { workflows, activeCount: activeWorkflows, loading: workflowsLoading, load: loadWorkflows } = useIntegrationsWorkflows();
9
9
  const { runs, runningCount, failedCount, loading: runsLoading, load: loadRuns } = useIntegrationsRuns();
10
- const { popular, loading: templatesLoading, load: loadTemplates, ensureDetails } = useIntegrationsTemplates();
11
- watch(() => popular.value.map((t) => t.slug).join(","), () => {
12
- if (popular.value.length) void ensureDetails(popular.value.map((t) => t.slug));
10
+ 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
13
  }, { immediate: true });
14
14
  const { activeCount: schedulesActive, load: loadSchedules } = useIntegrationsSchedules();
15
15
  const { pendingCount: missedRuns, globalLoading: missedLoading, loadGlobal: loadMissedRuns } = useIntegrationsDeadLetters();
@@ -24,18 +24,18 @@ onMounted(() => {
24
24
  void loadSchedules();
25
25
  void loadMissedRuns();
26
26
  });
27
- const latestPerWorkflow = computed(() => {
27
+ const latestByWorkflow = computed(() => {
28
28
  const seen = /* @__PURE__ */ new Set();
29
29
  const out = [];
30
30
  for (const r of runs.value) {
31
31
  if (seen.has(r.workflowId)) continue;
32
32
  seen.add(r.workflowId);
33
33
  out.push(r);
34
- if (out.length >= 10) break;
35
34
  }
36
35
  return out;
37
36
  });
38
- const failedRecent = computed(() => runs.value.filter((r) => r.status === "failed").slice(0, 3));
37
+ const latestPerWorkflow = computed(() => latestByWorkflow.value.slice(0, 10));
38
+ const failedRecent = computed(() => latestByWorkflow.value.filter((r) => r.status === "failed").slice(0, 3));
39
39
  function formatRelative(iso) {
40
40
  if (!iso) return "\u2014";
41
41
  const ms = Date.now() - new Date(iso).getTime();
@@ -76,7 +76,7 @@ const kpis = computed(() => [
76
76
  {
77
77
  label: "Scheduled",
78
78
  value: schedulesActive.value,
79
- sub: "cron triggers active",
79
+ sub: "scheduled triggers active",
80
80
  icon: Clock,
81
81
  tile: "bg-violet-500 text-white"
82
82
  },
@@ -118,13 +118,18 @@ const surfaces = [
118
118
  </PageHeader>
119
119
 
120
120
  <div class="p-4 space-y-4">
121
- <!-- KPI strip -->
122
- <div class="grid gap-2 sm:grid-cols-2 lg:grid-cols-4">
121
+ <!-- KPI strip. Each tile carries its figure in its own name: the Missed-runs
122
+ tile is a link, and a link takes its name from its label instead of its
123
+ contents — named "Missed runs" alone it would announce less than the
124
+ three tiles beside it. -->
125
+ <div role="group" aria-label="Key figures" class="grid gap-2 sm:grid-cols-2 lg:grid-cols-4">
123
126
  <component
124
127
  :is="k.to ? NuxtLinkComp : 'div'"
125
128
  v-for="k in kpis"
126
129
  :key="k.label"
127
130
  :to="k.to"
131
+ :role="k.to ? void 0 : 'group'"
132
+ :aria-label="`${k.label}: ${k.value}`"
128
133
  class="block rounded-lg border border-black/[0.06] bg-white p-3"
129
134
  :class="k.to ? 'transition hover:border-black/20' : ''"
130
135
  >
@@ -148,10 +153,10 @@ const surfaces = [
148
153
  </component>
149
154
  </div>
150
155
 
151
- <!-- Two-column: recent runs + needs attention + popular templates -->
156
+ <!-- Two-column: recent runs + needs attention + templates to start from -->
152
157
  <div class="grid gap-4 lg:grid-cols-3">
153
158
  <!-- Recent runs ─ takes 2 cols -->
154
- <div class="lg:col-span-2 rounded-lg border border-black/[0.06] bg-white">
159
+ <div role="group" aria-label="Latest executions" class="lg:col-span-2 rounded-lg border border-black/[0.06] bg-white">
155
160
  <div class="flex items-center justify-between border-b border-black/[0.06] px-3 py-2">
156
161
  <div class="flex items-center gap-2">
157
162
  <span class="flex size-6 items-center justify-center rounded-md bg-slate-100 text-slate-500">
@@ -164,11 +169,15 @@ const surfaces = [
164
169
  </NuxtLink>
165
170
  </div>
166
171
  <div>
167
- <div
172
+ <!-- An entry is a link, not a div that answers a click: that is what
173
+ makes it reachable by keyboard, and it is what gives it a name —
174
+ the values it shows. A name written onto a plain row would be
175
+ heard by nobody, because ARIA does not name a generic element. -->
176
+ <NuxtLink
168
177
  v-for="r in latestPerWorkflow"
169
178
  :key="r.id"
170
- 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 cursor-pointer hover:bg-muted/30"
171
- @click="$router.push({ path: `/integrations/workflows/${r.workflowId}`, query: { run: r.id } })"
179
+ :to="{ path: `/integrations/workflows/${r.workflowId}`, query: { run: r.id } }"
180
+ 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"
172
181
  >
173
182
  <Badge variant="outline" :class="['border text-xs font-medium uppercase', statusClass(r.status)]">
174
183
  {{ r.status }}
@@ -183,7 +192,7 @@ const surfaces = [
183
192
  <div class="text-sm text-muted-foreground tabular-nums text-right">
184
193
  {{ r.durationMs == null ? "\u2026" : `${(r.durationMs / 1e3).toFixed(2)}s` }}
185
194
  </div>
186
- </div>
195
+ </NuxtLink>
187
196
  <template v-if="latestLoading && !latestPerWorkflow.length">
188
197
  <div
189
198
  v-for="n in 6"
@@ -206,7 +215,7 @@ const surfaces = [
206
215
  </div>
207
216
 
208
217
  <!-- Needs attention -->
209
- <div class="rounded-lg border border-black/[0.06] bg-white">
218
+ <div role="group" aria-label="Needs attention" class="rounded-lg border border-black/[0.06] bg-white">
210
219
  <div class="flex items-center justify-between border-b border-black/[0.06] px-3 py-2">
211
220
  <div class="flex items-center gap-2">
212
221
  <span class="flex size-6 items-center justify-center rounded-md bg-slate-100 text-slate-500">
@@ -230,20 +239,24 @@ const surfaces = [
230
239
  Triggers fired while their workflow couldn't run — review &amp; replay.
231
240
  </div>
232
241
  </NuxtLink>
233
- <div
242
+ <!-- A link, for the same reason as the entries next door — and like
243
+ the waiting-firings entry above it, which was one all along. -->
244
+ <NuxtLink
234
245
  v-for="r in failedRecent"
235
246
  :key="r.id"
236
- class="space-y-1 border-b border-black/[0.04] px-3 py-2 text-sm last:border-b-0 cursor-pointer hover:bg-muted/30"
237
- @click="$router.push(`/integrations/workflows/${r.workflowId}`)"
247
+ :to="`/integrations/workflows/${r.workflowId}`"
248
+ class="block space-y-1 border-b border-black/[0.04] px-3 py-2 text-sm last:border-b-0 hover:bg-muted/30"
238
249
  >
239
250
  <div class="font-medium truncate">{{ r.workflowName }}</div>
240
251
  <div class="text-sm text-rose-700 truncate" :title="r.errorMessage">
241
252
  {{ r.errorMessage ?? "Failed." }}
242
253
  </div>
254
+ <!-- When it failed is when it ended, not when it set off: a run
255
+ that broke after an hour would otherwise be dated an hour early. -->
243
256
  <div class="text-sm text-muted-foreground tabular-nums">
244
- {{ formatRelative(r.startedAt) }}
257
+ {{ formatRelative(r.finishedAt ?? r.startedAt) }}
245
258
  </div>
246
- </div>
259
+ </NuxtLink>
247
260
  </div>
248
261
  <div v-else class="px-3 py-6 text-center text-sm text-muted-foreground">
249
262
  All workflows healthy.
@@ -272,49 +285,41 @@ const surfaces = [
272
285
  </NuxtLink>
273
286
  </div>
274
287
 
275
- <!-- Popular templates -->
276
- <div class="rounded-lg border border-black/[0.06] bg-white">
288
+ <!-- Named for the offer it makes, not for a ranking nothing measures
289
+ (PO-238): every template's install count was nought, so the sort that
290
+ produced this list compared nothing at all. The wording deliberately
291
+ echoes the create-workflow dialog's "Start from template" tab — the same
292
+ 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">
277
294
  <div class="flex items-center justify-between border-b border-black/[0.06] px-3 py-2">
278
295
  <div class="flex items-center gap-2">
279
296
  <span class="flex size-6 items-center justify-center rounded-md bg-slate-100 text-slate-500">
280
297
  <Sparkles :size="12" />
281
298
  </span>
282
- <h3 class="text-sm font-semibold">Popular templates</h3>
299
+ <h3 class="text-sm font-semibold">Start from a template</h3>
283
300
  </div>
284
301
  <NuxtLink to="/integrations/templates" class="text-sm text-muted-foreground hover:text-foreground inline-flex items-center gap-1">
285
302
  See all <ArrowRight :size="11" />
286
303
  </NuxtLink>
287
304
  </div>
288
- <div v-if="templatesLoading && !popular.length" class="grid gap-2 p-3 sm:grid-cols-2 lg:grid-cols-4">
305
+ <div v-if="templatesLoading && !startingPoints.length" class="grid gap-2 p-3 sm:grid-cols-2 lg:grid-cols-4">
289
306
  <div v-for="n in 4" :key="`sk-tpl-${n}`" class="rounded-md border border-black/[0.06] bg-white p-3 space-y-2">
290
307
  <div class="h-5 w-20 rounded bg-black/[0.07] animate-pulse" />
291
308
  <div class="h-3 w-3/4 rounded bg-black/[0.07] animate-pulse" />
292
309
  <div class="h-3 w-1/2 rounded bg-black/[0.05] animate-pulse" />
293
310
  </div>
294
311
  </div>
295
- <div v-else-if="!popular.length" class="px-3 py-6 text-center text-sm text-muted-foreground">
312
+ <div v-else-if="!startingPoints.length" class="px-3 py-6 text-center text-sm text-muted-foreground">
296
313
  No templates registered yet.
297
314
  </div>
298
315
  <div v-else class="grid gap-2 p-3 sm:grid-cols-2 lg:grid-cols-4">
299
- <NuxtLink
300
- v-for="tpl in popular"
316
+ <TemplateCard
317
+ v-for="tpl in startingPoints"
301
318
  :key="tpl.slug"
319
+ :template="tpl"
320
+ density="compact"
302
321
  :to="`/integrations/templates/${tpl.slug}`"
303
- class="flex flex-col overflow-hidden rounded-md border border-black/[0.06] bg-white transition hover:border-black/20 hover:shadow-sm"
304
- >
305
- <div class="relative h-20 shrink-0 border-b border-black/[0.06] bg-slate-50/70 [background-image:radial-gradient(circle,rgba(15,23,42,0.07)_1px,transparent_1px)] [background-size:12px_12px]">
306
- <TemplateFlowPreview v-if="tpl.detailLoaded" :blob="tpl.blob" :height="80" class="px-1" />
307
- <span class="absolute left-1.5 top-1.5 inline-flex items-center whitespace-nowrap rounded-full bg-slate-100 px-1.5 py-px text-[11px] font-medium uppercase text-slate-700">
308
- {{ tpl.category }}
309
- </span>
310
- </div>
311
- <div class="flex min-h-0 flex-1 flex-col p-3">
312
- <div class="text-sm font-medium truncate">{{ tpl.name }}</div>
313
- <p class="mt-1 text-sm text-muted-foreground line-clamp-2">
314
- {{ tpl.description }}
315
- </p>
316
- </div>
317
- </NuxtLink>
322
+ />
318
323
  </div>
319
324
  </div>
320
325
  </div>
@@ -27,7 +27,7 @@ onMounted(() => {
27
27
  void load(true);
28
28
  void loadGlobal(true);
29
29
  pollTimer = setInterval(() => {
30
- if (hasActiveRuns.value) void load(true);
30
+ if (hasActiveRuns.value && !loading.value) void load(true);
31
31
  }, 3e3);
32
32
  });
33
33
  onUnmounted(() => {
@@ -1,6 +1,7 @@
1
1
  <script setup>
2
2
  import { ClockCircle as Clock, Globus as Globe } from "#solar-icons";
3
3
  import { Badge } from "@revenexx/studio";
4
+ import { cadenceRank, describeCron } from "../../utils/schedule";
4
5
  useHead({ title: "Schedules \xB7 Integration Studio" });
5
6
  const router = useRouter();
6
7
  const { entries, activeCount, loading, load } = useIntegrationsSchedules();
@@ -22,8 +23,18 @@ function formatRelative(iso) {
22
23
  return new Date(iso).toLocaleString(void 0, { month: "short", day: "2-digit", hour: "2-digit", minute: "2-digit" });
23
24
  }
24
25
  const columns = [
25
- { key: "workflow", label: "Workflow", sortable: true, sortGetter: (e) => e.workflowName.toLowerCase(), searchGetter: (e) => `${e.workflowName} ${e.cron} ${e.timezone}` },
26
- { key: "cron", label: "Cron", width: "180px", sortable: true, sortGetter: (e) => e.cron },
26
+ // Two columns share the slack, and the sentence gets the larger share of it:
27
+ // "Weekdays, hourly from 09:00 to 17:00" is what this page is for, while a
28
+ // workflow name is a label the reader recognises from its first few words.
29
+ // Both flex, so no width is left unused at the end of the row.
30
+ //
31
+ // The expression stays in the search text although the column no longer shows
32
+ // it: support quotes expressions, and a row has to be findable by the string
33
+ // 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) ?? ""}` },
35
+ // Sorted by how often a schedule fires. The expression's characters would be
36
+ // meaningless here — the column shows a sentence.
37
+ { key: "cron", label: "When", width: "minmax(260px, 1.4fr)", sortable: true, sortGetter: (e) => cadenceRank(e.cron) },
27
38
  { key: "timezone", label: "Timezone", width: "140px", sortable: true, sortGetter: (e) => e.timezone },
28
39
  { key: "nextRun", label: "Next run", width: "140px", sortable: true, sortGetter: (e) => e.nextRunAt ?? "" },
29
40
  { key: "lastRun", label: "Last run", width: "140px", sortable: true, sortGetter: (e) => e.lastRunAt ?? "" },
@@ -35,7 +46,7 @@ const columns = [
35
46
  <IntegrationsLayout>
36
47
  <PageHeader
37
48
  title="Schedules"
38
- description="Every cron-driven trigger across your workflows. Click a row to open the workflow."
49
+ description="Every scheduled trigger across your workflows. Click a row to open the workflow."
39
50
  >
40
51
  <template #actions>
41
52
  <span class="text-sm text-muted-foreground">
@@ -51,7 +62,7 @@ const columns = [
51
62
  :columns="columns"
52
63
  :row-key="(e2) => e2.id"
53
64
  :default-sort="{ column: 'nextRun', direction: 'asc' }"
54
- :search="{ placeholder: 'Search by workflow, cron, timezone\u2026' }"
65
+ :search="{ placeholder: 'Search by workflow, schedule, timezone\u2026' }"
55
66
  :filter="true"
56
67
  empty-text="No schedules yet. Add a schedule trigger to any workflow."
57
68
  @row-click="(e2) => router.push(`/integrations/workflows/${e2.workflowId}`)"
@@ -67,8 +78,33 @@ const columns = [
67
78
  </div>
68
79
  </template>
69
80
 
81
+ <!--
82
+ The sentence is the answer, and the expression is not on the page: the
83
+ question this column asks is "when does this run", which an expression
84
+ answers in a language the reader may not speak. It is still one hover
85
+ away — support quotes expressions — and it still stands alone in the one
86
+ case where nothing else would be true: a schedule the studio cannot say
87
+ in words, which is one no picker built either.
88
+ -->
70
89
  <template #cell-cron="{ row: e }">
71
- <code class="font-mono text-sm truncate">{{ e.cron }}</code>
90
+ <div class="min-w-0">
91
+ <!-- The sentence wraps rather than truncating: a schedule that fires
92
+ at several named times is a long sentence, and half of one is
93
+ the wrong half. Its title is the expression, so there is no
94
+ 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">
96
+ {{ describeCron(e.cron) }}
97
+ </div>
98
+ <template v-else-if="e.cron">
99
+ <div aria-label="When it fires" class="text-sm truncate">
100
+ Custom schedule
101
+ </div>
102
+ <code aria-label="Cron expression" class="block font-mono text-xs text-muted-foreground truncate">{{ e.cron }}</code>
103
+ </template>
104
+ <div v-else aria-label="When it fires" class="text-sm text-muted-foreground truncate">
105
+ No schedule set
106
+ </div>
107
+ </div>
72
108
  </template>
73
109
 
74
110
  <template #cell-timezone="{ row: e }">
@@ -78,12 +114,20 @@ const columns = [
78
114
  </div>
79
115
  </template>
80
116
 
117
+ <!--
118
+ The two time cells carry the name their column header states, so each is
119
+ addressable by what it is rather than by how it is typeset. It is not the
120
+ accessible name a screen reader hears — the cell is a plain span, and
121
+ ARIA prohibits naming a generic element — that needs row and cell
122
+ semantics in the shared table (PO-233, and a gap in
123
+ `specs/accessibility.md`).
124
+ -->
81
125
  <template #cell-nextRun="{ row: e }">
82
- <span class="text-sm tabular-nums">{{ formatRelative(e.nextRunAt) }}</span>
126
+ <span aria-label="Next run" class="text-sm tabular-nums">{{ formatRelative(e.nextRunAt) }}</span>
83
127
  </template>
84
128
 
85
129
  <template #cell-lastRun="{ row: e }">
86
- <span class="text-sm tabular-nums text-muted-foreground">{{ formatRelative(e.lastRunAt) }}</span>
130
+ <span aria-label="Last run" class="text-sm tabular-nums text-muted-foreground">{{ formatRelative(e.lastRunAt) }}</span>
87
131
  </template>
88
132
 
89
133
  <template #cell-status="{ row: e }">
@@ -71,18 +71,18 @@ async function confirmDelete() {
71
71
  deleting.value = false;
72
72
  pendingDelete.value = null;
73
73
  }
74
- function formatDateTime(iso) {
74
+ function formatDate(iso) {
75
75
  if (!iso) return "\u2014";
76
76
  try {
77
- return new Date(iso).toLocaleString(void 0, { month: "short", day: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit" });
77
+ return new Date(iso).toLocaleDateString(void 0, { month: "short", day: "2-digit", year: "numeric" });
78
78
  } catch {
79
79
  return iso;
80
80
  }
81
81
  }
82
82
  const columns = [
83
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 },
85
- { key: "updated", label: "Updated", width: "160px", sortable: true, sortGetter: (s) => s.updated_at },
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: () => "" },
86
86
  { key: "actions", label: "", width: "40px", toggleable: false }
87
87
  ];
88
88
  </script>
@@ -136,6 +136,7 @@ const columns = [
136
136
  <button
137
137
  type="button"
138
138
  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'"
139
140
  @click="showValue = !showValue"
140
141
  >
141
142
  <component :is="showValue ? EyeOff : Eye" :size="14" />
@@ -188,19 +189,40 @@ const columns = [
188
189
  </div>
189
190
  </template>
190
191
 
192
+ <!--
193
+ `time` carries the full timestamp the cell no longer prints, and the
194
+ label says which date this is and whose — the table is a div grid, so
195
+ a date read on its own is otherwise a number without a column.
196
+
197
+ A date the service did not send is a plain span: `time` promises a
198
+ machine-readable moment, either in `datetime` or as its own text, and
199
+ the dash is neither — `datetime=""` would be an empty promise instead
200
+ of none. The label stays either way, because it is what ties the cell
201
+ to its row and column.
202
+ -->
191
203
  <template #cell-created="{ row: s }">
192
- <span class="text-sm tabular-nums text-muted-foreground">{{ formatDateTime(s.created_at) }}</span>
204
+ <component
205
+ :is="s.created_at ? 'time' : 'span'"
206
+ :datetime="s.created_at || void 0"
207
+ :aria-label="`Created for ${s.key}: ${formatDate(s.created_at)}`"
208
+ class="text-sm tabular-nums whitespace-nowrap text-muted-foreground"
209
+ >{{ formatDate(s.created_at) }}</component>
193
210
  </template>
194
211
 
195
212
  <template #cell-updated="{ row: s }">
196
- <span class="text-sm tabular-nums text-muted-foreground">{{ formatDateTime(s.updated_at) }}</span>
213
+ <component
214
+ :is="s.updated_at ? 'time' : 'span'"
215
+ :datetime="s.updated_at || void 0"
216
+ :aria-label="`Updated for ${s.key}: ${formatDate(s.updated_at)}`"
217
+ class="text-sm tabular-nums whitespace-nowrap text-muted-foreground"
218
+ >{{ formatDate(s.updated_at) }}</component>
197
219
  </template>
198
220
 
199
221
  <template #cell-actions="{ row: s }">
200
222
  <div @click.stop>
201
223
  <DropdownMenu>
202
224
  <DropdownMenuTrigger as-child>
203
- <Button variant="ghost" size="icon" class="size-7">
225
+ <Button variant="ghost" size="icon" class="size-7" :aria-label="`Actions for ${s.key}`">
204
226
  <MoreHorizontal :size="14" />
205
227
  </Button>
206
228
  </DropdownMenuTrigger>
@@ -20,8 +20,9 @@ import {
20
20
  SelectValue
21
21
  } from "@revenexx/studio";
22
22
  import TemplateFlowPreview from "../../../components/Integrations/TemplateFlowPreview.vue";
23
- import NodeLogo from "../../../components/Integrations/NodeLogo.vue";
24
- import { TEMPLATE_CATEGORIES } from "../../../data/integrationTemplates";
23
+ import NodeMark from "../../../components/Integrations/NodeMark.vue";
24
+ import { templateCategoryLabel, templatePackageLabel } from "../../../data/integrationTemplates";
25
+ import { describeCron } from "../../../utils/schedule";
25
26
  const SELECT_LATER = "__later__";
26
27
  const route = useRoute();
27
28
  const router = useRouter();
@@ -91,7 +92,10 @@ function firstSentence(text) {
91
92
  const TRIGGER_ICON = { schedule: Clock, webhook: Webhook, manual: Play, event: Zap };
92
93
  const triggerChips = computed(() => (tpl.value?.blob.triggers ?? []).map((t) => {
93
94
  let label = t.type.charAt(0).toUpperCase() + t.type.slice(1);
94
- if (t.type === "schedule" && t.config?.cron) label = `Schedule \xB7 ${t.config.cron}${t.config.timezone ? ` (${t.config.timezone})` : ""}`;
95
+ if (t.type === "schedule" && t.config?.cron) {
96
+ const when = describeCron(String(t.config.cron)) ?? String(t.config.cron);
97
+ label = `Schedule \xB7 ${when}${t.config.timezone ? ` (${t.config.timezone})` : ""}`;
98
+ }
95
99
  if (t.type === "webhook" && t.config?.path) label = `Webhook \xB7 ${t.config.path}`;
96
100
  if (t.type === "manual") label = "Run on demand";
97
101
  return { key: t.id, icon: TRIGGER_ICON[t.type] ?? Zap, label };
@@ -104,7 +108,9 @@ const variables = computed(() => Object.entries(tpl.value?.blob.variables ?? {})
104
108
  const variableColumns = [
105
109
  { key: "key", label: "Variable", width: "minmax(0,180px)", sortable: true },
106
110
  { key: "description", label: "Description" },
107
- { key: "default", label: "Default", width: "minmax(0,200px)" }
111
+ // Stored as `default` in the blob, but it *is* the value the workflow runs
112
+ // with — see `specs/workflow-variables.md`.
113
+ { key: "default", label: "Value", width: "minmax(0,200px)" }
108
114
  ];
109
115
  const credentialReqs = computed(() => reqs.value?.credentialTypes ?? []);
110
116
  const secretReqs = computed(() => reqs.value?.secrets ?? []);
@@ -126,17 +132,8 @@ const secretChecks = computed(() => {
126
132
  const missingSecrets = computed(() => secretChecks.value.filter((s) => !s.present).length);
127
133
  const missingCredentials = computed(() => credentialReqs.value.filter((c) => !ofType(c.slug).length).length);
128
134
  const setupReady = computed(() => missingSecrets.value === 0 && missingCredentials.value === 0);
129
- const packageLabel = computed(() => {
130
- const pkg = tpl.value?.package;
131
- if (!pkg) return null;
132
- const tail = (pkg.name.split("/").pop() ?? pkg.name).replace(/^integrations-nodes-/, "");
133
- if (tail === "core") return null;
134
- return pkg.label ?? tail.split("-").map((s) => s.charAt(0).toUpperCase() + s.slice(1)).join(" ");
135
- });
136
- const categoryLabel = computed(() => {
137
- const cat = tpl.value?.category;
138
- return TEMPLATE_CATEGORIES.find((c) => c.key === cat)?.label ?? (cat ? cat.charAt(0).toUpperCase() + cat.slice(1) : "");
139
- });
135
+ const packageLabel = computed(() => tpl.value ? templatePackageLabel(tpl.value) : null);
136
+ const categoryLabel = computed(() => templateCategoryLabel(tpl.value?.category ?? ""));
140
137
  const installOpen = ref(false);
141
138
  const installing = ref(false);
142
139
  const installError = ref(null);
@@ -207,7 +204,7 @@ function levelDot(level) {
207
204
  <Loader2 :size="18" class="animate-spin text-muted-foreground/50" />
208
205
  </div>
209
206
  <div class="absolute left-3 top-3 flex items-center gap-1.5">
210
- <span class="inline-flex items-center rounded-full bg-slate-100 px-2 py-0.5 uppercase text-xs font-medium text-slate-700">
207
+ <span v-if="categoryLabel" class="inline-flex items-center rounded-full bg-slate-100 px-2 py-0.5 uppercase text-xs font-medium text-slate-700">
211
208
  {{ categoryLabel }}
212
209
  </span>
213
210
  <span class="inline-flex items-center gap-1 rounded-full bg-slate-100 px-2 py-0.5 uppercase text-xs font-medium text-slate-700">
@@ -233,8 +230,13 @@ function levelDot(level) {
233
230
  <div class="grid gap-4 lg:grid-cols-3">
234
231
  <div class="lg:col-span-2 space-y-4">
235
232
  <!-- About: the registry description, rendered -->
236
- <div v-if="descriptionHtml" class="rounded-lg border border-black/[0.06] bg-white p-4">
237
- <h3 class="text-sm font-semibold mb-2">About this template</h3>
233
+ <div
234
+ v-if="descriptionHtml"
235
+ role="region"
236
+ aria-labelledby="tpl-about-heading"
237
+ class="rounded-lg border border-black/[0.06] bg-white p-4"
238
+ >
239
+ <h3 id="tpl-about-heading" class="text-sm font-semibold mb-2">About this template</h3>
238
240
  <div
239
241
  class="text-sm leading-relaxed text-foreground/90 space-y-2 [&_h1]:text-sm [&_h1]:font-semibold [&_h2]:text-sm [&_h2]:font-semibold [&_h3]:text-sm [&_h3]:font-semibold [&_ol]:list-decimal [&_ol]:pl-5 [&_ul]:list-disc [&_ul]:pl-5 [&_li]:mt-1 [&_code]:rounded [&_code]:bg-slate-100 [&_code]:px-1 [&_code]:py-px [&_code]:font-mono [&_code]:text-[13px] [&_a]:text-primary [&_a]:underline [&_strong]:font-semibold"
240
242
  v-html="descriptionHtml"
@@ -242,8 +244,12 @@ function levelDot(level) {
242
244
  </div>
243
245
 
244
246
  <!-- Step by step, humanized from the actual nodes -->
245
- <div class="rounded-lg border border-black/[0.06] bg-white p-4">
246
- <h3 class="text-sm font-semibold mb-3">Step by step</h3>
247
+ <div
248
+ role="region"
249
+ aria-labelledby="tpl-steps-heading"
250
+ class="rounded-lg border border-black/[0.06] bg-white p-4"
251
+ >
252
+ <h3 id="tpl-steps-heading" class="text-sm font-semibold mb-3">Step by step</h3>
247
253
  <ol v-if="steps.length" class="space-y-2.5">
248
254
  <li
249
255
  v-for="(step, i) in steps"
@@ -254,7 +260,7 @@ function levelDot(level) {
254
260
  {{ i + 1 }}
255
261
  </span>
256
262
  <span class="flex size-7 shrink-0 items-center justify-center rounded-md bg-white ring-1 ring-black/[0.08]">
257
- <NodeLogo :manifest="step.manifest" :size="15" />
263
+ <NodeMark :manifest="step.manifest" :size="15" :badge="false" />
258
264
  </span>
259
265
  <span class="min-w-0 pt-0.5">
260
266
  <span class="block text-sm font-medium text-foreground">{{ step.label }}</span>
@@ -268,8 +274,17 @@ function levelDot(level) {
268
274
  </div>
269
275
 
270
276
  <!-- Variables the operator can tune after install -->
271
- <div v-if="variables.length" class="rounded-lg border border-black/[0.06] bg-white p-4">
272
- <h3 class="text-sm font-semibold mb-1">Variables you can tune</h3>
277
+ <!-- Named by its own heading rather than by a copy of it: two strings
278
+ saying the same thing drift, and the test only holds one of them.
279
+ `role` stays because this is a div — a bare <section> would be a
280
+ region on its own, this one has to say so. -->
281
+ <div
282
+ v-if="variables.length"
283
+ role="region"
284
+ aria-labelledby="tpl-variables-heading"
285
+ class="rounded-lg border border-black/[0.06] bg-white p-4"
286
+ >
287
+ <h3 id="tpl-variables-heading" class="text-sm font-semibold mb-1">Variables you can tune</h3>
273
288
  <p class="text-sm text-muted-foreground mb-3">Set once after install, used across the workflow.</p>
274
289
  <DataTable
275
290
  :data="variables"
@@ -296,7 +311,11 @@ function levelDot(level) {
296
311
  <!-- Setup rail -->
297
312
  <div class="space-y-3">
298
313
  <!-- Readiness + CTA -->
299
- <div class="rounded-lg border border-black/[0.06] bg-white p-3 space-y-2.5">
314
+ <!-- `aria-label` deliberately, unlike the two panels below: this one
315
+ shows no heading of its own, so there is no visible string to
316
+ point `aria-labelledby` at. Copy this pattern only for a panel
317
+ that genuinely has none. -->
318
+ <div role="group" aria-label="Setup readiness" class="rounded-lg border border-black/[0.06] bg-white p-3 space-y-2.5">
300
319
  <div v-if="setupReady" class="flex items-center gap-1.5 text-sm text-emerald-700">
301
320
  <CheckCircle2 :size="14" />
302
321
  Everything is ready to install.
@@ -349,8 +368,11 @@ function levelDot(level) {
349
368
  </div>
350
369
  </div>
351
370
 
352
- <div v-if="credentialReqs.length" class="rounded-lg border border-black/[0.06] bg-white p-3 space-y-2">
353
- <div class="flex items-center gap-1.5 text-mono text-muted-foreground">
371
+ <!-- Named by the label it shows, like the variables panel above: an
372
+ invented name is a second string saying the same thing, and it is
373
+ what a screen reader hears instead of what is written. -->
374
+ <div v-if="credentialReqs.length" role="group" aria-labelledby="tpl-credentials-label" class="rounded-lg border border-black/[0.06] bg-white p-3 space-y-2">
375
+ <div id="tpl-credentials-label" class="flex items-center gap-1.5 text-mono text-muted-foreground">
354
376
  <Key :size="11" /> Credentials
355
377
  </div>
356
378
  <ul class="space-y-1">
@@ -378,9 +400,9 @@ function levelDot(level) {
378
400
  <NuxtLink to="/integrations/credentials" class="block text-sm text-primary hover:underline">Manage credentials →</NuxtLink>
379
401
  </div>
380
402
 
381
- <div v-if="secretChecks.length" class="rounded-lg border border-black/[0.06] bg-white p-3 space-y-2">
403
+ <div v-if="secretChecks.length" role="group" aria-labelledby="tpl-secrets-label" class="rounded-lg border border-black/[0.06] bg-white p-3 space-y-2">
382
404
  <div class="flex items-center gap-2">
383
- <span class="text-mono text-muted-foreground">Required secrets</span>
405
+ <span id="tpl-secrets-label" class="text-mono text-muted-foreground">Required secrets</span>
384
406
  <span v-if="missingSecrets > 0" class="flex items-center gap-1 text-sm text-amber-700">
385
407
  <ShieldAlert :size="11" />
386
408
  {{ missingSecrets }} missing
@@ -472,14 +494,14 @@ function levelDot(level) {
472
494
  first, or install now and wire it up in the editor.
473
495
  </p>
474
496
  <div v-for="nodeId in nodesFor(req.usedBy)" v-else :key="nodeId" class="space-y-1">
475
- <Label class="text-xs text-muted-foreground font-mono">node {{ nodeId }}</Label>
497
+ <Label :for="`tpl-cred-${nodeId}`" class="text-xs text-muted-foreground font-mono">node {{ nodeId }}</Label>
476
498
  <Select
477
499
  :model-value="bindings[nodeId] ?? SELECT_LATER"
478
500
  @update:model-value="(v2) => {
479
501
  bindings[nodeId] = v2 ?? SELECT_LATER;
480
502
  }"
481
503
  >
482
- <SelectTrigger class="bg-white">
504
+ <SelectTrigger :id="`tpl-cred-${nodeId}`" class="bg-white">
483
505
  <SelectValue placeholder="Select later" />
484
506
  </SelectTrigger>
485
507
  <SelectContent>