@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
@@ -18,6 +18,7 @@ import {
18
18
  SelectValue
19
19
  } from "@revenexx/studio";
20
20
  import { toast } from "vue-sonner";
21
+ import { resolveDisplayName } from "../../utils/localize";
21
22
  const props = defineProps({
22
23
  open: { type: Boolean, required: true },
23
24
  variables: { type: Object, required: true }
@@ -89,9 +90,7 @@ function applyJsonDraft(key, type) {
89
90
  }
90
91
  }
91
92
  function descriptionText(v) {
92
- const d = v.description;
93
- if (!d) return "";
94
- return typeof d === "string" ? d : d.en ?? Object.values(d)[0] ?? "";
93
+ return resolveDisplayName(v.description);
95
94
  }
96
95
  function writeDescription(key, text) {
97
96
  const cur = props.variables[key];
@@ -148,6 +147,8 @@ async function copyUsage(key) {
148
147
  <div
149
148
  v-for="[key, v] in entries"
150
149
  :key="key"
150
+ role="group"
151
+ :aria-label="`Variable ${key}`"
151
152
  class="rounded-lg border border-black/[0.06] bg-white p-3"
152
153
  >
153
154
  <div class="flex items-center gap-2">
@@ -161,13 +162,6 @@ async function copyUsage(key) {
161
162
  <Copy :size="12" />
162
163
  </button>
163
164
  <div class="flex-1" />
164
- <label class="flex items-center gap-1.5 text-sm text-muted-foreground">
165
- Required
166
- <Switch
167
- :model-value="v.required === true"
168
- @update:model-value="(val) => patchVar(key, { required: val || void 0 })"
169
- />
170
- </label>
171
165
  <button
172
166
  type="button"
173
167
  class="flex size-6 shrink-0 items-center justify-center rounded-md text-muted-foreground transition hover:bg-rose-50 hover:text-rose-600"
@@ -179,10 +173,15 @@ async function copyUsage(key) {
179
173
  </div>
180
174
 
181
175
  <div class="mt-2 grid gap-2 sm:grid-cols-[8rem_1fr]">
176
+ <!-- One row per variable, so every id carries its key — otherwise
177
+ the labels of the second row point at the first row's fields. -->
182
178
  <div class="space-y-1">
183
- <Label class="text-mono text-muted-foreground">Type</Label>
179
+ <Label :for="`var-${key}-type`" class="text-mono text-muted-foreground">Type</Label>
184
180
  <Select :model-value="v.type" @update:model-value="(t) => changeType(key, t)">
185
- <SelectTrigger class="h-8 bg-white text-sm">
181
+ <!-- Both: the `id` lets the visible Label's `for` resolve, and the
182
+ `aria-label` names the trigger without relying on an engine
183
+ naming a button from a <label> — see the sweep's docblock. -->
184
+ <SelectTrigger :id="`var-${key}-type`" aria-label="Type" class="h-8 bg-white text-sm">
186
185
  <SelectValue />
187
186
  </SelectTrigger>
188
187
  <SelectContent>
@@ -192,31 +191,43 @@ async function copyUsage(key) {
192
191
  </div>
193
192
 
194
193
  <div class="space-y-1">
195
- <Label class="text-mono text-muted-foreground">
196
- Default{{ v.required ? " (ignored while required)" : "" }}
197
- </Label>
194
+ <!-- "Value", not "Default": PO-206. The `for`/`id` still name the
195
+ blob field, which is called `default` and stays that way. -->
196
+ <Label :for="`var-${key}-default`" class="text-mono text-muted-foreground">Value</Label>
198
197
 
198
+ <!-- Only one of these renders, so they share the one `id` the Label
199
+ points at. The labelable ones take their name from that Label —
200
+ one string, and renaming it turns the tests red instead of
201
+ leaving them green against changed copy. -->
199
202
  <Input
200
203
  v-if="v.type === 'string'"
204
+ :id="`var-${key}-default`"
201
205
  :model-value="typeof v.default === 'string' ? v.default : ''"
202
206
  class="h-8 bg-white text-sm"
203
207
  @update:model-value="(val) => patchVar(key, { default: String(val) })"
204
208
  />
205
209
  <Input
206
210
  v-else-if="v.type === 'number'"
211
+ :id="`var-${key}-default`"
207
212
  type="number"
208
213
  :model-value="typeof v.default === 'number' ? v.default : ''"
209
214
  class="h-8 bg-white text-sm"
210
215
  @update:model-value="(val) => patchVar(key, { default: val === '' ? void 0 : Number(val) })"
211
216
  />
212
217
  <div v-else-if="v.type === 'boolean'" class="flex h-8 items-center">
218
+ <!-- Named out loud: a Switch is a `button role="switch"`, and
219
+ HTML-AAM does not name a button from a <label> — Chromium
220
+ does, other engines need not. -->
213
221
  <Switch
222
+ :id="`var-${key}-default`"
214
223
  :model-value="v.default === true"
224
+ aria-label="Value"
215
225
  @update:model-value="(val) => patchVar(key, { default: val })"
216
226
  />
217
227
  </div>
218
228
  <div v-else>
219
229
  <textarea
230
+ :id="`var-${key}-default`"
220
231
  :value="jsonDrafts[key] ?? ''"
221
232
  rows="3"
222
233
  spellcheck="false"
@@ -232,8 +243,9 @@ async function copyUsage(key) {
232
243
  </div>
233
244
 
234
245
  <div class="mt-2 space-y-1">
235
- <Label class="text-mono text-muted-foreground">Description</Label>
246
+ <Label :for="`var-${key}-desc`" class="text-mono text-muted-foreground">Description</Label>
236
247
  <Input
248
+ :id="`var-${key}-desc`"
237
249
  :model-value="descriptionText(v)"
238
250
  class="h-8 bg-white text-sm"
239
251
  placeholder="What this variable is for"
@@ -244,9 +256,10 @@ async function copyUsage(key) {
244
256
 
245
257
  <!-- Add row -->
246
258
  <div class="rounded-lg border border-dashed border-black/[0.12] p-3">
247
- <Label class="text-mono text-muted-foreground">New variable</Label>
259
+ <Label for="var-new-key" class="text-mono text-muted-foreground">New variable</Label>
248
260
  <div class="mt-1 flex items-center gap-2">
249
261
  <Input
262
+ id="var-new-key"
250
263
  v-model="newKey"
251
264
  class="h-8 flex-1 bg-white font-mono text-sm"
252
265
  placeholder="e.g. base_url"
@@ -1,6 +1,7 @@
1
1
  <script setup>
2
2
  import { RefreshCircle as Loader2, MenuDots as MoreHorizontal } from "#solar-icons";
3
3
  import { toast } from "vue-sonner";
4
+ import { apiValidationIssues } from "../../utils/apiError";
4
5
  import WorkflowDuplicateDialog from "./WorkflowDuplicateDialog.vue";
5
6
  import WorkflowIconView from "./WorkflowIconView.vue";
6
7
  import { Layers } from "#solar-icons";
@@ -48,7 +49,7 @@ watch(groupByTags, (on) => {
48
49
  } catch {
49
50
  }
50
51
  });
51
- const { runs, load: loadRuns, list: listRuns } = useIntegrationsRuns();
52
+ const { runs, loaded: runsLoaded, load: loadRuns, list: listRuns } = useIntegrationsRuns();
52
53
  onMounted(() => {
53
54
  void loadRuns();
54
55
  });
@@ -86,6 +87,18 @@ async function confirmDelete() {
86
87
  deleting.value = false;
87
88
  }
88
89
  }
90
+ async function onToggleActive(wf) {
91
+ try {
92
+ await toggleActive(wf.id);
93
+ } catch (e) {
94
+ const issues = apiValidationIssues(e);
95
+ if (issues.length) {
96
+ toast.error(`Can\u2019t activate \u201C${wf.name}\u201D \u2014 ${issues.length} validation issue${issues.length === 1 ? "" : "s"} to fix. Open the editor to resolve them.`);
97
+ return;
98
+ }
99
+ toast.error(e?.message ?? "Could not change the workflow status.");
100
+ }
101
+ }
89
102
  function statusBadge(wf) {
90
103
  if (!wf.active) return { label: "Paused", class: "bg-slate-100 text-slate-700 border-slate-200" };
91
104
  if (wf.revision === 0) return { label: "Draft", class: "bg-amber-500/10 text-amber-700 border-amber-500/20" };
@@ -238,14 +251,28 @@ const columns = [
238
251
  </div>
239
252
  </template>
240
253
 
254
+ <!-- The badge word alone ("Paused", "Failed") also matches a column header
255
+ and the neighbouring column's own words, so each badge carries its
256
+ workflow's name — the rows are plain divs with nothing else to tie a
257
+ value to the workflow it belongs to. -->
241
258
  <template #cell-status="{ row: wf }">
242
- <Badge variant="outline" :class="['border text-xs font-medium uppercase', statusBadge(wf).class]">
259
+ <Badge
260
+ variant="outline"
261
+ :class="['border text-xs font-medium uppercase', statusBadge(wf).class]"
262
+ :aria-label="`${wf.name}: ${statusBadge(wf).label}`"
263
+ >
243
264
  {{ statusBadge(wf).label }}
244
265
  </Badge>
245
266
  </template>
246
267
 
268
+ <!-- A response without a build state renders `—`; naming that would announce
269
+ "<name> build: —", which claims nothing in more words. No state, no name. -->
247
270
  <template #cell-build="{ row: wf }">
248
- <Badge variant="outline" :class="['border text-xs font-medium uppercase', buildState(wf).class]">
271
+ <Badge
272
+ variant="outline"
273
+ :class="['border text-xs font-medium uppercase', buildState(wf).class]"
274
+ :aria-label="wf.buildStatus ? `${wf.name} build: ${buildState(wf).label}` : void 0"
275
+ >
249
276
  {{ buildState(wf).label }}
250
277
  </Badge>
251
278
  </template>
@@ -273,14 +300,25 @@ const columns = [
273
300
 
274
301
  <template #cell-lastRun="{ row: wf }">
275
302
  <div v-if="lastRunFor(wf.id)" class="flex items-center gap-2 min-w-0">
276
- <Badge variant="outline" :class="['border text-xs font-medium uppercase shrink-0', runBadge(lastRunFor(wf.id).status).class]">
303
+ <Badge
304
+ variant="outline"
305
+ :class="['border text-xs font-medium uppercase shrink-0', runBadge(lastRunFor(wf.id).status).class]"
306
+ :aria-label="`${wf.name} last run: ${runBadge(lastRunFor(wf.id).status).label}`"
307
+ >
277
308
  {{ runBadge(lastRunFor(wf.id).status).label }}
278
309
  </Badge>
279
310
  <span class="text-sm tabular-nums text-muted-foreground truncate">
280
311
  {{ formatDateTime(lastRunFor(wf.id).startedAt) }}
281
312
  </span>
282
313
  </div>
283
- <span v-else class="text-sm text-muted-foreground">—</span>
314
+ <!-- Runs arrive after the rows do, so until they are in, "never run" is
315
+ not known — the bare `—` says nothing, and that is the honest answer.
316
+ The name is only claimed once there is something to claim it from. -->
317
+ <span
318
+ v-else
319
+ class="text-sm text-muted-foreground"
320
+ :aria-label="runsLoaded ? `${wf.name} last run: Never run` : void 0"
321
+ >—</span>
284
322
  </template>
285
323
 
286
324
  <template #cell-updated="{ row: wf }">
@@ -293,7 +331,9 @@ const columns = [
293
331
  <div @click.stop>
294
332
  <DropdownMenu>
295
333
  <DropdownMenuTrigger as-child>
296
- <Button variant="ghost" size="icon" class="size-7">
334
+ <!-- Icon-only, so it needs a name of its own — the rows are plain
335
+ divs, which leaves nothing else to address this button by. -->
336
+ <Button variant="ghost" size="icon" class="size-7" :aria-label="`Actions for ${wf.name}`">
297
337
  <MoreHorizontal :size="14" />
298
338
  </Button>
299
339
  </DropdownMenuTrigger>
@@ -306,7 +346,7 @@ const columns = [
306
346
  <DropdownMenuItem @select="askDuplicate(wf)">
307
347
  Duplicate
308
348
  </DropdownMenuItem>
309
- <DropdownMenuItem @select="toggleActive(wf.id)">
349
+ <DropdownMenuItem @select="onToggleActive(wf)">
310
350
  {{ wf.active ? "Pause" : "Activate" }}
311
351
  </DropdownMenuItem>
312
352
  <DropdownMenuSeparator />
@@ -1,10 +1,12 @@
1
1
  import { useIntegrationsApi } from "./integrationsApi.js";
2
2
  import { IntegrationsApiError } from "../utils/apiError.js";
3
+ import { useLoadGate } from "./useLoadGate.js";
3
4
  export function useCredentialTypes() {
4
5
  const types = useState("integrations.credentialTypes", () => []);
5
6
  const loading = useState("integrations.credentialTypes.loading", () => false);
6
7
  const error = useState("integrations.credentialTypes.error", () => null);
7
8
  const loaded = useState("integrations.credentialTypes.loaded", () => false);
9
+ const gate = useLoadGate("integrations.credentialTypes", { loading, loaded });
8
10
  const api = useIntegrationsApi();
9
11
  function normalize(res) {
10
12
  const obj = res;
@@ -17,9 +19,7 @@ export function useCredentialTypes() {
17
19
  return e?.message ?? fallback;
18
20
  }
19
21
  async function load(force = false) {
20
- if (loading.value) return types.value;
21
- if (loaded.value && !force) return types.value;
22
- loading.value = true;
22
+ if (!await gate.claim(force)) return types.value;
23
23
  error.value = null;
24
24
  try {
25
25
  types.value = normalize(await api.get("/credential-types"));
@@ -27,7 +27,7 @@ export function useCredentialTypes() {
27
27
  } catch (e) {
28
28
  error.value = captureError(e, "Could not load credential types.");
29
29
  } finally {
30
- loading.value = false;
30
+ gate.release();
31
31
  }
32
32
  return types.value;
33
33
  }
@@ -1,11 +1,13 @@
1
1
  import { useIntegrationsApi } from "./integrationsApi.js";
2
2
  import { IntegrationsApiError } from "../utils/apiError.js";
3
+ import { useLoadGate } from "./useLoadGate.js";
3
4
  export function useIntegrationsAudit() {
4
5
  const api = useIntegrationsApi();
5
6
  const entries = useState("int.audit.entries", () => []);
6
7
  const nextCursor = useState("int.audit.cursor", () => null);
7
8
  const loading = useState("int.audit.loading", () => false);
8
9
  const error = useState("int.audit.error", () => null);
10
+ const gate = useLoadGate("int.audit", { loading });
9
11
  function qs(filters, cursor) {
10
12
  const p = new URLSearchParams();
11
13
  if (filters.sub) p.set("sub", filters.sub);
@@ -16,8 +18,7 @@ export function useIntegrationsAudit() {
16
18
  return p.toString();
17
19
  }
18
20
  async function load(filters = {}, append = false) {
19
- if (loading.value) return;
20
- loading.value = true;
21
+ if (!await gate.claim()) return;
21
22
  error.value = null;
22
23
  try {
23
24
  const res = await api.get(
@@ -29,7 +30,7 @@ export function useIntegrationsAudit() {
29
30
  } catch (e) {
30
31
  error.value = e instanceof IntegrationsApiError ? e.message : e?.message ?? "Could not load the audit log.";
31
32
  } finally {
32
- loading.value = false;
33
+ gate.release();
33
34
  }
34
35
  }
35
36
  function loadMore(filters = {}) {
@@ -1,11 +1,13 @@
1
1
  import { computed } from "vue";
2
2
  import { useIntegrationsApi } from "./integrationsApi.js";
3
3
  import { IntegrationsApiError } from "../utils/apiError.js";
4
+ import { useLoadGate } from "./useLoadGate.js";
4
5
  export function useIntegrationsCredentials() {
5
6
  const credentials = useState("integrations.credentials", () => []);
6
7
  const loading = useState("integrations.credentials.loading", () => false);
7
8
  const error = useState("integrations.credentials.error", () => null);
8
9
  const loaded = useState("integrations.credentials.loaded", () => false);
10
+ const gate = useLoadGate("integrations.credentials", { loading, loaded });
9
11
  const api = useIntegrationsApi();
10
12
  function normalize(res) {
11
13
  const obj = res;
@@ -21,9 +23,7 @@ export function useIntegrationsCredentials() {
21
23
  return e?.message ?? fallback;
22
24
  }
23
25
  async function load(force = false) {
24
- if (loading.value) return credentials.value;
25
- if (loaded.value && !force) return credentials.value;
26
- loading.value = true;
26
+ if (!await gate.claim(force)) return credentials.value;
27
27
  error.value = null;
28
28
  try {
29
29
  credentials.value = normalize(await api.get("/credentials"));
@@ -31,7 +31,7 @@ export function useIntegrationsCredentials() {
31
31
  } catch (e) {
32
32
  error.value = captureError(e, "Could not load credentials.");
33
33
  } finally {
34
- loading.value = false;
34
+ gate.release();
35
35
  }
36
36
  return credentials.value;
37
37
  }
@@ -1,5 +1,6 @@
1
1
  import { computed } from "vue";
2
2
  import { useIntegrationsApi } from "./integrationsApi.js";
3
+ import { useLoadGate } from "./useLoadGate.js";
3
4
  export function useIntegrationsDeadLetters() {
4
5
  const api = useIntegrationsApi();
5
6
  async function list(workflowId, opts = {}) {
@@ -17,15 +18,14 @@ export function useIntegrationsDeadLetters() {
17
18
  }
18
19
  const replay = (id) => api.post(`/dead-letters/${id}/replay`, {});
19
20
  const discard = (id) => api.post(`/dead-letters/${id}/discard`, {});
20
- const { workflows, load: loadWorkflows } = useIntegrationsWorkflows();
21
+ const { workflows, load: loadWorkflows, loaded: workflowsLoaded } = useIntegrationsWorkflows();
21
22
  const globalItems = useState("integrations.deadletters.global", () => []);
22
23
  const globalLoading = useState("integrations.deadletters.global.loading", () => false);
23
24
  const globalLoaded = useState("integrations.deadletters.global.loaded", () => false);
25
+ const gate = useLoadGate("integrations.deadletters.global", { loading: globalLoading, loaded: globalLoaded });
24
26
  const pendingCount = computed(() => globalItems.value.filter((i) => i.status === "pending").length);
25
27
  async function loadGlobal(force = false) {
26
- if (globalLoading.value) return globalItems.value;
27
- if (globalLoaded.value && !force) return globalItems.value;
28
- globalLoading.value = true;
28
+ if (!await gate.claim(force)) return globalItems.value;
29
29
  try {
30
30
  await loadWorkflows();
31
31
  const MAX_PAGES = 20;
@@ -41,10 +41,11 @@ export function useIntegrationsDeadLetters() {
41
41
  return out;
42
42
  }));
43
43
  globalItems.value = per.flatMap((p) => p.status === "fulfilled" ? p.value : []).sort((a, b) => new Date(b.created_at ?? 0).getTime() - new Date(a.created_at ?? 0).getTime());
44
- globalLoaded.value = true;
44
+ globalLoaded.value = workflowsLoaded.value && per.every((p) => p.status === "fulfilled");
45
45
  } finally {
46
- globalLoading.value = false;
46
+ gate.release();
47
47
  }
48
+ return globalItems.value;
48
49
  }
49
50
  return { list, replay, discard, loadGlobal, globalItems, globalLoading, pendingCount };
50
51
  }
@@ -2,15 +2,15 @@ import { applyNodeCatalog, findManifest } from "../data/integrationNodeCatalog.j
2
2
  import { resolveLocalized } from "../utils/localize.js";
3
3
  import { useIntegrationsApi } from "./integrationsApi.js";
4
4
  import { IntegrationsApiError } from "../utils/apiError.js";
5
+ import { useLoadGate } from "./useLoadGate.js";
5
6
  export function useIntegrationsNodes() {
6
7
  const loading = useState("integrations.nodes.loading", () => false);
7
8
  const error = useState("integrations.nodes.error", () => null);
8
9
  const loaded = useState("integrations.nodes.loaded", () => false);
10
+ const gate = useLoadGate("integrations.nodes", { loading, loaded });
9
11
  const api = useIntegrationsApi();
10
12
  async function load(force = false) {
11
- if (loading.value) return;
12
- if (loaded.value && !force) return;
13
- loading.value = true;
13
+ if (!await gate.claim(force)) return;
14
14
  error.value = null;
15
15
  try {
16
16
  const res = await api.get("/nodes");
@@ -29,7 +29,7 @@ export function useIntegrationsNodes() {
29
29
  applyNodeCatalog([]);
30
30
  error.value = e instanceof IntegrationsApiError ? e.message : e?.message ?? "Could not load nodes.";
31
31
  } finally {
32
- loading.value = false;
32
+ gate.release();
33
33
  }
34
34
  }
35
35
  return { loading, error, loaded, load, findManifest, resolveLocalized };
@@ -1,6 +1,7 @@
1
1
  import { computed } from "vue";
2
2
  import { useIntegrationsApi } from "./integrationsApi.js";
3
3
  import { IntegrationsApiError } from "../utils/apiError.js";
4
+ import { useLoadGate } from "./useLoadGate.js";
4
5
  function mapStatus(status) {
5
6
  switch (status) {
6
7
  case "completed":
@@ -29,9 +30,10 @@ export function useIntegrationsRuns() {
29
30
  const error = useState("integrations.runs.error", () => null);
30
31
  const loaded = useState("integrations.runs.loaded", () => false);
31
32
  const reconciling = useState("integrations.runs.reconciling", () => false);
33
+ const gate = useLoadGate("integrations.runs", { loading, loaded });
32
34
  const SYNC_LIMIT = 5;
33
35
  const api = useIntegrationsApi();
34
- const { workflows, load: loadWorkflows } = useIntegrationsWorkflows();
36
+ const { workflows, load: loadWorkflows, loaded: workflowsLoaded } = useIntegrationsWorkflows();
35
37
  function mapRun(r, workflowName) {
36
38
  const startedMs = r.started_at ? new Date(r.started_at).getTime() : null;
37
39
  const finishedMs = r.completed_at ? new Date(r.completed_at).getTime() : null;
@@ -45,15 +47,18 @@ export function useIntegrationsRuns() {
45
47
  triggerType: "manual",
46
48
  startedAt: r.started_at ?? "",
47
49
  finishedAt: r.completed_at ?? null,
50
+ // The list carries the reason a run failed; dropping it here was why every
51
+ // failure read the same wherever the list is shown. An empty string is not
52
+ // a reason — it has to fall through to whatever the surface says instead,
53
+ // or it renders as a blank line where the sentence belongs.
54
+ errorMessage: r.error || void 0,
48
55
  // Derived from the timestamps — the list payload carries no duration.
49
56
  durationMs: startedMs != null && finishedMs != null && finishedMs >= startedMs ? finishedMs - startedMs : null,
50
57
  steps: []
51
58
  };
52
59
  }
53
60
  async function load(force = false) {
54
- if (loading.value) return runs.value;
55
- if (loaded.value && !force) return runs.value;
56
- loading.value = true;
61
+ if (!await gate.claim(force)) return runs.value;
57
62
  error.value = null;
58
63
  try {
59
64
  await loadWorkflows();
@@ -92,11 +97,11 @@ export function useIntegrationsRuns() {
92
97
  reconciling.value = false;
93
98
  }
94
99
  }
95
- loaded.value = true;
100
+ loaded.value = workflowsLoaded.value && perWorkflow.every((p) => p.status === "fulfilled");
96
101
  } catch (e) {
97
102
  error.value = e instanceof IntegrationsApiError ? e.message : e?.message ?? "Could not load runs.";
98
103
  } finally {
99
- loading.value = false;
104
+ gate.release();
100
105
  }
101
106
  return runs.value;
102
107
  }
@@ -4,6 +4,10 @@ import type { ScheduleEntry } from '../types/integrations.js';
4
4
  * sourced from the real triggers sub-resource (useIntegrationsTriggers).
5
5
  * Next/last-run timestamps are mocked deterministically (no telemetry
6
6
  * endpoint yet).
7
+ *
8
+ * Unordered on purpose: the page's table sorts, by its own default and by
9
+ * whichever column the operator clicks, so an order established here would be
10
+ * replaced before anyone saw it.
7
11
  */
8
12
  export declare function useIntegrationsSchedules(): {
9
13
  entries: import("vue").ComputedRef<ScheduleEntry[]>;
@@ -1,4 +1,5 @@
1
1
  import { computed } from "vue";
2
+ import { scheduleTimezone } from "../utils/schedule.js";
2
3
  export function useIntegrationsSchedules() {
3
4
  const { all, loading, loadAll } = useIntegrationsTriggers();
4
5
  const entries = computed(
@@ -10,13 +11,19 @@ export function useIntegrationsSchedules() {
10
11
  workflowId: t.workflow_id,
11
12
  workflowName: t.workflowName,
12
13
  triggerId,
13
- cron: String(cfg.cron ?? "* * * * *"),
14
- timezone: String(cfg.timezone ?? "UTC"),
14
+ // Never invented: a trigger without an expression says so on the page.
15
+ // Standing in `* * * * *` for it would read as "every minute", and with
16
+ // the expression no longer on screen that guess would be invisible.
17
+ cron: typeof cfg.cron === "string" && cfg.cron.trim() !== "" ? cfg.cron : null,
18
+ // The schedule's own zone, or the platform's default where it has none —
19
+ // from the same helper the trigger settings read, so one schedule cannot
20
+ // be shown in two zones on two screens.
21
+ timezone: scheduleTimezone(cfg.timezone),
15
22
  active: t.active,
16
23
  nextRunAt: t.active ? mockNextRun(t.workflow_id, triggerId) : null,
17
24
  lastRunAt: t.active ? mockLastRun(t.workflow_id, triggerId) : null
18
25
  };
19
- }).sort((a, b) => a.cron.localeCompare(b.cron))
26
+ })
20
27
  );
21
28
  const total = computed(() => entries.value.length);
22
29
  const activeCount = computed(() => entries.value.filter((e) => e.active).length);
@@ -1,32 +1,35 @@
1
1
  import { computed } from "vue";
2
2
  import { useIntegrationsApi } from "./integrationsApi.js";
3
3
  import { IntegrationsApiError } from "../utils/apiError.js";
4
+ import { useLoadGate } from "./useLoadGate.js";
4
5
  const KEY_PATTERN = /^[A-Z0-9_]+$/;
5
6
  export function useIntegrationsSecrets() {
6
7
  const secrets = useState("integrations.secrets", () => []);
7
8
  const loading = useState("integrations.secrets.loading", () => false);
8
9
  const error = useState("integrations.secrets.error", () => null);
9
10
  const loaded = useState("integrations.secrets.loaded", () => false);
11
+ const gate = useLoadGate("integrations.secrets", { loading, loaded });
10
12
  const api = useIntegrationsApi();
11
13
  function isValidKey(key) {
12
14
  return KEY_PATTERN.test(key);
13
15
  }
14
16
  function normalize(res) {
15
17
  const obj = res;
18
+ if (Array.isArray(obj?.data)) return obj.data.map(toRecord);
19
+ if (Array.isArray(obj?.secrets)) return obj.secrets.map(toRecord);
16
20
  if (Array.isArray(obj?.keys)) return obj.keys.map((key) => ({ key, created_at: "", updated_at: "" }));
17
- if (Array.isArray(obj?.data)) return obj.data;
18
- if (Array.isArray(obj?.secrets)) return obj.secrets;
19
- if (Array.isArray(res)) return res;
21
+ if (Array.isArray(res)) return res.map(toRecord);
20
22
  return [];
21
23
  }
24
+ function toRecord(r) {
25
+ return { key: r.key ?? "", created_at: r.created_at ?? "", updated_at: r.updated_at ?? "" };
26
+ }
22
27
  function captureError(e, fallback) {
23
28
  if (e instanceof IntegrationsApiError) return e.message;
24
29
  return e?.message ?? fallback;
25
30
  }
26
31
  async function load(force = false) {
27
- if (loading.value) return secrets.value;
28
- if (loaded.value && !force) return secrets.value;
29
- loading.value = true;
32
+ if (!await gate.claim(force)) return secrets.value;
30
33
  error.value = null;
31
34
  try {
32
35
  secrets.value = normalize(await api.get("/secrets"));
@@ -34,7 +37,7 @@ export function useIntegrationsSecrets() {
34
37
  } catch (e) {
35
38
  error.value = captureError(e, "Could not load secrets.");
36
39
  } finally {
37
- loading.value = false;
40
+ gate.release();
38
41
  }
39
42
  return secrets.value;
40
43
  }
@@ -5,7 +5,7 @@ export declare function useIntegrationsTemplates(): {
5
5
  loading: any;
6
6
  error: any;
7
7
  total: import("vue").ComputedRef<any>;
8
- popular: import("vue").ComputedRef<any[]>;
8
+ startingPoints: import("vue").ComputedRef<any>;
9
9
  load: (force?: boolean) => Promise<WorkflowTemplate[]>;
10
10
  list: () => WorkflowTemplate[];
11
11
  get: (slug: string) => WorkflowTemplate | null;
@@ -2,6 +2,7 @@ import { computed } from "vue";
2
2
  import { useIntegrationsApi } from "./integrationsApi.js";
3
3
  import { IntegrationsApiError } from "../utils/apiError.js";
4
4
  import { resolveLocalized } from "../utils/localize.js";
5
+ import { useLoadGate } from "./useLoadGate.js";
5
6
  const BLOB_VERSION = "v0-draft";
6
7
  const INDUSTRY_VALUES = ["mechanical-engineering", "chemicals", "wholesale", "manufacturing", "retail", "any"];
7
8
  function plainExcerpt(md) {
@@ -34,14 +35,18 @@ function mapListItem(api) {
34
35
  triggerTypes: api.triggerTypes ?? [],
35
36
  images: api.images ?? [],
36
37
  package: api.package ? { name: api.package.name, label: api.package.label ?? null } : null,
37
- category: api.category ?? "sync",
38
+ // The registry documents this field as free-form and returns it as nullable,
39
+ // so "no category" is a state and not an error. It used to become `sync` —
40
+ // a category nothing publishes, so the studio was badging a template with a
41
+ // name no publisher had chosen. Empty travels instead, and every surface that
42
+ // draws a chip leaves it out rather than filling it in (PO-243).
43
+ category: api.category || "",
38
44
  industries: mapIndustries(api.industries),
39
45
  vendors: api.vendors ?? [],
40
46
  requiredSecrets: [],
41
47
  level: api.level ?? "beginner",
42
48
  outline: [],
43
49
  nodeCount: 0,
44
- installs: 0,
45
50
  publisher: publisherFor(api.slug, api.vendors ?? []),
46
51
  updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
47
52
  blob: emptyBlob(resolveLocalized(api.name)),
@@ -83,6 +88,7 @@ export function useIntegrationsTemplates() {
83
88
  const loaded = useState("integrations.templates.loaded", () => false);
84
89
  const loading = useState("integrations.templates.loading", () => false);
85
90
  const error = useState("integrations.templates.error", () => null);
91
+ const gate = useLoadGate("integrations.templates", { loading, loaded });
86
92
  const api = useIntegrationsApi();
87
93
  function unwrap(res) {
88
94
  return res?.data ?? res;
@@ -91,9 +97,7 @@ export function useIntegrationsTemplates() {
91
97
  return e instanceof IntegrationsApiError ? e.message : e?.message ?? fallback;
92
98
  }
93
99
  async function load(force = false) {
94
- if (loading.value) return templates.value;
95
- if (loaded.value && !force) return templates.value;
96
- loading.value = true;
100
+ if (!await gate.claim(force)) return templates.value;
97
101
  error.value = null;
98
102
  try {
99
103
  const list2 = unwrap(await api.get("/templates")) ?? [];
@@ -104,7 +108,7 @@ export function useIntegrationsTemplates() {
104
108
  templates.value = [];
105
109
  loaded.value = true;
106
110
  } finally {
107
- loading.value = false;
111
+ gate.release();
108
112
  }
109
113
  return templates.value;
110
114
  }
@@ -165,6 +169,6 @@ export function useIntegrationsTemplates() {
165
169
  return instantiate(slug);
166
170
  }
167
171
  const total = computed(() => templates.value.length);
168
- const popular = computed(() => [...templates.value].sort((a, b) => b.installs - a.installs).slice(0, 4));
169
- return { templates, loaded, loading, error, total, popular, load, list, get, fetchDetail, ensureDetails, requirements, instantiate, install };
172
+ const startingPoints = computed(() => templates.value.slice(0, 4));
173
+ return { templates, loaded, loading, error, total, startingPoints, load, list, get, fetchDetail, ensureDetails, requirements, instantiate, install };
170
174
  }