@nuxtagentic/automations 0.0.0-stage → 0.1.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 (38) hide show
  1. package/LICENSE.md +9 -0
  2. package/README.md +173 -2
  3. package/client.ts +24 -0
  4. package/module.ts +55 -0
  5. package/package.json +59 -3
  6. package/runtime/components/AutomationCanvas.vue +488 -0
  7. package/runtime/components/AutomationInspector.vue +436 -0
  8. package/runtime/components/AutomationNodeIcon.vue +27 -0
  9. package/runtime/components/AutomationRunsPanel.vue +475 -0
  10. package/runtime/components/AutomationSidebar.vue +253 -0
  11. package/runtime/components/AutomationStatusIcon.vue +44 -0
  12. package/runtime/pages/automations.vue +1181 -0
  13. package/runtime/server/api/automations/[id]/endpoint.get.ts +12 -0
  14. package/runtime/server/api/automations/[id]/run.post.ts +30 -0
  15. package/runtime/server/api/automations/events.post.ts +37 -0
  16. package/runtime/server/api/automations/runs/[id]/resume.post.ts +23 -0
  17. package/runtime/server/handlers/automation-hook.post.ts +51 -0
  18. package/runtime/server/handlers/connect-hook.post.ts +54 -0
  19. package/runtime/server/handlers/integration-hook.post.ts +86 -0
  20. package/runtime/server/plugins/events.ts +79 -0
  21. package/runtime/server/plugins/maintenance.ts +20 -0
  22. package/runtime/server/utils/agent-runner.ts +15 -0
  23. package/runtime/server/utils/automation-context.ts +77 -0
  24. package/runtime/server/utils/automation-engine.ts +697 -0
  25. package/runtime/server/utils/automation-launch.ts +205 -0
  26. package/runtime/server/utils/automation-schedule.ts +31 -0
  27. package/runtime/server/utils/connection-events.ts +114 -0
  28. package/runtime/server/utils/maintenance.ts +255 -0
  29. package/runtime/server/utils/schedule-sync.ts +32 -0
  30. package/runtime/server/utils/transient-retry.ts +34 -0
  31. package/runtime/server/workflows/runner.ts +39 -0
  32. package/runtime/shared/agent.ts +5 -0
  33. package/runtime/shared/automation-run.ts +61 -0
  34. package/runtime/shared/automation.ts +202 -0
  35. package/runtime/workspace.ts +56 -0
  36. package/schema.ts +222 -0
  37. package/server.ts +2 -0
  38. package/workflow.ts +2 -0
@@ -0,0 +1,1181 @@
1
+ <script setup lang="ts">
2
+ import { computed, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'
3
+ import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'
4
+ import { CloudConfigClientError } from '@cloudconfig/core/client'
5
+ import {
6
+ definePageMeta,
7
+ useAsyncData,
8
+ useCloudConfig,
9
+ useRequestFetch,
10
+ useRoute,
11
+ useRouter,
12
+ useState,
13
+ } from '#imports'
14
+ import { useWorkspaceScope } from '@nuxtagentic/identity/runtime/composables/useWorkspaceScope'
15
+ import { useCurrentUser } from '@nuxtagentic/identity/runtime/composables/useCurrentUser'
16
+ import { useAgentActionHandler } from '@nuxtagentic/workspace/runtime/composables/useAgentActions'
17
+ import { useWorkspaceModules } from '@nuxtagentic/workspace/runtime/composables/useWorkspaceModules'
18
+ import { useAgents } from '@nuxtagentic/agents/runtime/composables/useAgents'
19
+ import type { InferSelect } from '@cloudconfig/core'
20
+ import type { AutomationsConfig } from '../../schema'
21
+ import { createAutomationCommands } from '../../client'
22
+ import { createIntegrationCommands } from '@nuxtagentic/integrations/client'
23
+ import { useEventListener, useTimeoutPoll } from '@vueuse/core'
24
+ import { INTEGRATION_CATALOG } from '@nuxtagentic/integrations/runtime/shared/catalog'
25
+ import {
26
+ createAutomationDraft,
27
+ LOCAL_EVENTS,
28
+ describeAutomationNodes,
29
+ type AutomationDraft,
30
+ type AutomationNode,
31
+ type AutomationStep,
32
+ } from '../shared/automation'
33
+ import AutomationCanvas from '../components/AutomationCanvas.vue'
34
+ import AutomationSidebar from '../components/AutomationSidebar.vue'
35
+ import AutomationRunsPanel from '../components/AutomationRunsPanel.vue'
36
+ import AutomationInspector from '../components/AutomationInspector.vue'
37
+
38
+ definePageMeta({ layout: 'dashboard', middleware: ['auth'] })
39
+ const api = useCloudConfig<AutomationsConfig>()
40
+ const fetcher = useRequestFetch()
41
+ const route = useRoute()
42
+ const router = useRouter()
43
+ const scope = useWorkspaceScope()
44
+ const user = useCurrentUser()
45
+ const commands = createAutomationCommands(fetcher)
46
+ const integrationCommands = createIntegrationCommands(fetcher)
47
+ const workspace = computed(() => scope.value.slice(scope.value.indexOf(':') + 1))
48
+ const agents = useAgents()
49
+ const modules = useWorkspaceModules()
50
+ type Automation = InferSelect<AutomationsConfig, 'automation'>
51
+ type Folder = InferSelect<AutomationsConfig, 'automationFolder'>
52
+ const workspaceFilter = computed(() => ({
53
+ type: 'group' as const,
54
+ logic: 'and' as const,
55
+ children: [
56
+ {
57
+ type: 'condition' as const,
58
+ field: 'workspace',
59
+ operator: 'eq' as const,
60
+ value: workspace.value,
61
+ },
62
+ ],
63
+ }))
64
+ const [
65
+ { data: collection, refresh: refreshCollection, error: collectionError },
66
+ { data: folders, refresh: refreshFolders, error: folderError },
67
+ { data: integrations },
68
+ ] = await Promise.all([
69
+ useAsyncData(`automations:${scope.value}`, async () => {
70
+ const data: Automation[] = []
71
+ let page = 1
72
+ let totalPages: number
73
+ do {
74
+ const result = await api.automation.list({
75
+ page,
76
+ pageSize: 100,
77
+ sortBy: 'name',
78
+ filters: workspaceFilter.value,
79
+ })
80
+ data.push(...result.data)
81
+ totalPages = result.totalPages
82
+ page++
83
+ } while (page <= totalPages)
84
+ return { data }
85
+ }),
86
+ useAsyncData(`automation-folders:${scope.value}`, async () => {
87
+ const data: Folder[] = []
88
+ let page = 1
89
+ let totalPages: number
90
+ do {
91
+ const result = await api.automationFolder.list({
92
+ page,
93
+ pageSize: 100,
94
+ sortBy: 'name',
95
+ filters: workspaceFilter.value,
96
+ })
97
+ data.push(...result.data)
98
+ totalPages = result.totalPages
99
+ page++
100
+ } while (page <= totalPages)
101
+ return data
102
+ }),
103
+ useAsyncData(`automation-integrations:${scope.value}`, integrationCommands.catalog),
104
+ ])
105
+ type Run = InferSelect<AutomationsConfig, 'automationRun'>
106
+ const draft = ref<AutomationDraft>(createAutomationDraft(workspace.value))
107
+ const baseline = ref('')
108
+ const id = ref<number | null>(null)
109
+ const isBusy = ref(false)
110
+ const message = ref('')
111
+ const isCanvasDragging = ref(false)
112
+ const sidebarOpen = useState('dashboard-automations-sidebar-open', () => true)
113
+ const isTestMenuOpen = ref(false)
114
+ onMounted(() => {
115
+ if (window.innerWidth < 768) {
116
+ sidebarOpen.value = false
117
+ }
118
+ })
119
+ const currentFolder = computed(() =>
120
+ typeof route.query.automationFolder === 'string'
121
+ ? route.query.automationFolder
122
+ : draft.value.folder,
123
+ )
124
+ const folderNames = computed(() =>
125
+ [
126
+ ...new Set([
127
+ ...(folders.value ?? []).map((folder) => folder.name),
128
+ ...(collection.value?.data ?? []).map((row) => row.folder),
129
+ draft.value.folder,
130
+ ]),
131
+ ]
132
+ .filter((name) => name !== 'Unsorted')
133
+ .toSorted((a, b) => a.localeCompare(b)),
134
+ )
135
+ const sidebarRows = computed(() => {
136
+ const rows = (collection.value?.data ?? []).map((row) =>
137
+ row.id === id.value
138
+ ? { ...row, name: draft.value.name, folder: draft.value.folder, active: draft.value.active }
139
+ : row,
140
+ )
141
+ return id.value
142
+ ? rows
143
+ : [
144
+ ...rows,
145
+ { id: 0, name: draft.value.name, folder: draft.value.folder, active: draft.value.active },
146
+ ]
147
+ })
148
+ const selected = computed({
149
+ get: () =>
150
+ ['trigger', 'agent', 'success', 'error'].includes(String(route.query.node))
151
+ ? (route.query.node as AutomationNode)
152
+ : null,
153
+ set: (node: AutomationNode | null) => {
154
+ void router.replace({
155
+ query: { ...route.query, node: node ?? undefined, runs: undefined, run: undefined },
156
+ })
157
+ },
158
+ })
159
+ // The inspector keeps showing the last node while it slides closed.
160
+ const inspectorNode = computed<AutomationNode>(
161
+ (previous) => selected.value ?? previous ?? 'trigger',
162
+ )
163
+
164
+ function routeView() {
165
+ return {
166
+ x: Number(route.query.x) || 0,
167
+ y: route.query.y === undefined ? 24 : Number(route.query.y) || 0,
168
+ z: Math.max(0.35, Math.min(1.6, Number(route.query.z) || 1)),
169
+ }
170
+ }
171
+ const view = shallowRef(routeView())
172
+ let viewTimer: ReturnType<typeof setTimeout> | undefined
173
+ watch([() => route.query.x, () => route.query.y, () => route.query.z], () => {
174
+ clearTimeout(viewTimer)
175
+ view.value = routeView()
176
+ })
177
+ function persistView() {
178
+ clearTimeout(viewTimer)
179
+ void router.replace({
180
+ query: {
181
+ ...route.query,
182
+ x: String(Math.round(view.value.x)),
183
+ y: String(Math.round(view.value.y)),
184
+ z: view.value.z.toFixed(2),
185
+ },
186
+ })
187
+ }
188
+ function updateView(value: typeof view.value) {
189
+ view.value = value
190
+ clearTimeout(viewTimer)
191
+ viewTimer = setTimeout(persistView, 150)
192
+ }
193
+ onBeforeUnmount(() => clearTimeout(viewTimer))
194
+ function zoomIn() {
195
+ updateView({ ...view.value, z: Math.min(1.6, view.value.z * 1.2) })
196
+ }
197
+ function zoomOut() {
198
+ updateView({ ...view.value, z: Math.max(0.35, view.value.z / 1.2) })
199
+ }
200
+ function zoomReset() {
201
+ updateView({ x: 0, y: 24, z: 1 })
202
+ }
203
+ function selectFolder(name: string) {
204
+ return router.replace({ query: { ...route.query, automationFolder: name } })
205
+ }
206
+ async function createFolder(name: string) {
207
+ if (isBusy.value) {
208
+ return false
209
+ }
210
+ if (
211
+ name.toLowerCase() === 'unsorted' ||
212
+ folderNames.value.some((folder) => folder.toLowerCase() === name.toLowerCase())
213
+ ) {
214
+ message.value = 'Choose a different folder name.'
215
+ return false
216
+ }
217
+ isBusy.value = true
218
+ message.value = ''
219
+ try {
220
+ await api.automationFolder.create({ name, workspace: workspace.value })
221
+ await refreshFolders()
222
+ await selectFolder(name)
223
+ return true
224
+ } catch (cause) {
225
+ message.value = errorMessage(cause)
226
+ return false
227
+ } finally {
228
+ isBusy.value = false
229
+ }
230
+ }
231
+ async function removeFolder(name: string) {
232
+ if (isBusy.value) {
233
+ return
234
+ }
235
+ isBusy.value = true
236
+ message.value = ''
237
+ try {
238
+ for (const row of collection.value?.data ?? []) {
239
+ if (row.folder !== name) {
240
+ continue
241
+ }
242
+ await api.automation.update(row.id, { folder: 'Unsorted' })
243
+ if (row.id === id.value) {
244
+ draft.value.folder = 'Unsorted'
245
+ baseline.value = JSON.stringify({ ...JSON.parse(baseline.value), folder: 'Unsorted' })
246
+ }
247
+ }
248
+ for (const folder of folders.value ?? []) {
249
+ if (folder.name === name) {
250
+ await api.automationFolder.delete(folder.id)
251
+ }
252
+ }
253
+ if (draft.value.folder === name) {
254
+ draft.value.folder = 'Unsorted'
255
+ }
256
+ if (currentFolder.value === name) {
257
+ await selectFolder('Unsorted')
258
+ }
259
+ } catch (cause) {
260
+ message.value = errorMessage(cause)
261
+ } finally {
262
+ await Promise.all([refreshCollection(), refreshFolders()])
263
+ isBusy.value = false
264
+ }
265
+ }
266
+ const snapshot = computed(() => JSON.stringify(draft.value))
267
+ const dirty = computed(() => !id.value || snapshot.value !== baseline.value)
268
+ const isTestOpen = ref(false)
269
+ const isCopied = ref(false)
270
+ const activeRunId = ref<number | null>(null)
271
+ const historyOpen = computed(() => route.query.runs === '1' || Number(route.query.run) > 0)
272
+ const sample = ref(
273
+ JSON.stringify(
274
+ { user: { id: user.value?.id, name: user.value?.name, email: user.value?.email } },
275
+ null,
276
+ 2,
277
+ ),
278
+ )
279
+ const selectedAgent = computed(() =>
280
+ agents.rows.value.find((row) => row.id === draft.value.agentId),
281
+ )
282
+ const connected = (provider: string) =>
283
+ integrations.value?.connections.some((row) => row.provider === provider && row.enabled)
284
+ const events = computed(() => [
285
+ ...LOCAL_EVENTS.map((entry) => ({
286
+ id: entry.id as string,
287
+ label: entry.label as string,
288
+ detail: entry.detail as string,
289
+ })),
290
+ ...INTEGRATION_CATALOG.filter((entry) => connected(entry.id)).flatMap((entry) =>
291
+ entry.triggers.map((trigger) => ({ ...trigger, detail: `Event endpoint · ${entry.name}` })),
292
+ ),
293
+ ])
294
+ const summaries = computed(() =>
295
+ describeAutomationNodes(draft.value, {
296
+ agentName: selectedAgent.value?.name,
297
+ eventLabel: events.value.find((event) => event.id === draft.value.trigger.event)?.label,
298
+ }),
299
+ )
300
+ function errorMessage(cause: unknown) {
301
+ if (cause instanceof CloudConfigClientError && cause.issues.length) {
302
+ return cause.issues.map((issue) => issue.message).join(' ')
303
+ }
304
+ const error = cause as { data?: { statusMessage?: string; message?: string }; message?: string }
305
+ return (
306
+ error.data?.statusMessage ??
307
+ error.data?.message ??
308
+ error.message ??
309
+ 'Could not save this automation.'
310
+ )
311
+ }
312
+ function fromRow(row: InferSelect<AutomationsConfig, 'automation'>): AutomationDraft {
313
+ return {
314
+ name: row.name,
315
+ folder: row.folder,
316
+ active: row.active,
317
+ workspace: row.workspace,
318
+ agentId: row.agentId,
319
+ instructions: row.instructions,
320
+ timeout: row.timeout,
321
+ retries: row.retries,
322
+ trigger: structuredClone(row.trigger) as AutomationDraft['trigger'],
323
+ success: structuredClone(row.success) as AutomationDraft['success'],
324
+ failure: structuredClone(row.failure) as AutomationDraft['failure'],
325
+ positions: structuredClone(row.positions) as AutomationDraft['positions'],
326
+ runAs: row.runAs === 'workspace' ? 'workspace' : 'owner',
327
+ }
328
+ }
329
+ let autosaveTimer: ReturnType<typeof setTimeout> | undefined
330
+ let savePromise: Promise<boolean> | null = null
331
+ let failedSnapshot = ''
332
+ function cancelAutosave() {
333
+ clearTimeout(autosaveTimer)
334
+ autosaveTimer = undefined
335
+ }
336
+ function scheduleAutosave() {
337
+ cancelAutosave()
338
+ if (
339
+ isBusy.value ||
340
+ loading.value ||
341
+ isCanvasDragging.value ||
342
+ snapshot.value === baseline.value ||
343
+ snapshot.value === failedSnapshot
344
+ ) {
345
+ return
346
+ }
347
+ autosaveTimer = setTimeout(() => void save(), 400)
348
+ }
349
+ function openDraft(row: Automation | null) {
350
+ cancelAutosave()
351
+ id.value = row?.id ?? null
352
+ draft.value = row ? fromRow(row) : createAutomationDraft(workspace.value, currentFolder.value)
353
+ baseline.value = snapshot.value
354
+ failedSnapshot = ''
355
+ activeRunId.value = null
356
+ message.value = ''
357
+ }
358
+
359
+ const requestedId = computed(() =>
360
+ route.query.automation === undefined
361
+ ? (collection.value?.data[0]?.id ?? 0)
362
+ : Number(route.query.automation) || 0,
363
+ )
364
+ const {
365
+ data: record,
366
+ status: recordStatus,
367
+ error: recordError,
368
+ } = await useAsyncData(
369
+ () => `automation:${scope.value}:${requestedId.value}`,
370
+ async () => {
371
+ if (!requestedId.value) {
372
+ return null
373
+ }
374
+ const row =
375
+ collection.value?.data.find((entry) => entry.id === requestedId.value) ??
376
+ (await api.automation.get(requestedId.value))
377
+ if (row.workspace !== workspace.value) {
378
+ throw new Error('Choose an automation from this workspace.')
379
+ }
380
+ return row
381
+ },
382
+ { default: () => null },
383
+ )
384
+ const loading = computed(() => recordStatus.value === 'pending')
385
+ // Saving a new automation routes to its id; that must not reset the draft being edited.
386
+ watch(
387
+ record,
388
+ (row) => {
389
+ if (!row || row.id !== id.value) {
390
+ openDraft(row)
391
+ }
392
+ },
393
+ { immediate: true },
394
+ )
395
+
396
+ function listRuns(automationId: number, page = 1) {
397
+ return api.automationRun.list({
398
+ page,
399
+ pageSize: 50,
400
+ sortBy: 'id',
401
+ sortDir: 'desc',
402
+ filters: {
403
+ type: 'group',
404
+ logic: 'and',
405
+ children: [{ type: 'condition', field: 'automationId', operator: 'eq', value: automationId }],
406
+ },
407
+ })
408
+ }
409
+ const {
410
+ data: runs,
411
+ pending: runsPending,
412
+ error: runsError,
413
+ refresh: refreshRuns,
414
+ } = useAsyncData(
415
+ () => `automation-runs:${scope.value}:${id.value}`,
416
+ async () => (id.value ? listRuns(id.value) : null),
417
+ )
418
+ const isLoadingMoreRuns = ref(false)
419
+ const history = computed(() => runs.value?.data ?? [])
420
+ const historyTotal = computed(() => runs.value?.totalCount ?? 0)
421
+ const hasMoreRuns = computed(() => !!runs.value && runs.value.page < runs.value.totalPages)
422
+ const currentRun = computed(() => history.value.find((run) => run.id === activeRunId.value) ?? null)
423
+ const selectedRun = computed(
424
+ () => history.value.find((run) => run.id === Number(route.query.run)) ?? null,
425
+ )
426
+ const isRunning = (run: Run) => ['queued', 'running', 'waiting'].includes(run.status)
427
+ const running = computed(() => !!currentRun.value && isRunning(currentRun.value))
428
+ const runResultClass = computed(() => {
429
+ if (running.value) {
430
+ return 'text-muted'
431
+ }
432
+ return currentRun.value?.status === 'succeeded' ? 'text-success' : 'text-error'
433
+ })
434
+ const defaultRunSource = computed(() => {
435
+ const { kind, event } = draft.value.trigger
436
+ if (kind === 'webhook') {
437
+ return 'Webhook'
438
+ }
439
+ return kind === 'schedule' ? 'Schedule' : event
440
+ })
441
+ const inspectorPanelClass = computed(() => {
442
+ if (selected.value) {
443
+ return 'translate-x-0 opacity-100'
444
+ }
445
+ return historyOpen.value
446
+ ? 'pointer-events-none -translate-x-3 opacity-0'
447
+ : 'pointer-events-none translate-x-0 opacity-0'
448
+ })
449
+ const steps = computed(
450
+ () => ((selectedRun.value ?? currentRun.value)?.steps as AutomationStep[]) ?? [],
451
+ )
452
+ const runResultLabel = computed(() => {
453
+ const run = currentRun.value
454
+ if (!run) {
455
+ return ''
456
+ }
457
+ if (run.status === 'waiting') {
458
+ return 'Waiting for approval in Integrations'
459
+ }
460
+ if (isRunning(run)) {
461
+ return 'Running…'
462
+ }
463
+ if (run.status === 'succeeded') {
464
+ const duration =
465
+ run.startedAt && run.finishedAt
466
+ ? `${((new Date(run.finishedAt).getTime() - new Date(run.startedAt).getTime()) / 1000).toFixed(1)}s`
467
+ : ''
468
+ return `Succeeded${duration ? ` in ${duration}` : ''}`
469
+ }
470
+ const steps = run.steps as AutomationStep[]
471
+ const agentFailed = steps.find((step) => step.node === 'agent')?.status === 'failed'
472
+ const handled = steps.find((step) => step.node === 'error')?.status === 'succeeded'
473
+ const retries = Math.max(0, run.attempts - 1)
474
+ return `${agentFailed ? 'Agent failed' : 'Run failed'}${retries ? ` after ${retries} ${retries === 1 ? 'retry' : 'retries'}` : ''}${handled ? ', handled by On error' : ''}`
475
+ })
476
+ useTimeoutPoll(async () => {
477
+ const pending = history.value.filter(isRunning)
478
+ if (!pending.length || !runs.value) {
479
+ return
480
+ }
481
+ try {
482
+ const updates = await Promise.all(pending.map((run) => api.automationRun.get(run.id)))
483
+ runs.value = {
484
+ ...runs.value,
485
+ data: runs.value.data.map((run) => updates.find((update) => update.id === run.id) ?? run),
486
+ }
487
+ } catch (cause) {
488
+ message.value = errorMessage(cause)
489
+ }
490
+ }, 1500)
491
+ async function loadMoreHistory() {
492
+ const loaded = runs.value
493
+ if (!id.value || !loaded || !hasMoreRuns.value || isLoadingMoreRuns.value) {
494
+ return
495
+ }
496
+ isLoadingMoreRuns.value = true
497
+ try {
498
+ const next = await listRuns(id.value, loaded.page + 1)
499
+ const seen = new Set(loaded.data.map((run) => run.id))
500
+ runs.value = {
501
+ ...next,
502
+ data: [...loaded.data, ...next.data.filter((run) => !seen.has(run.id))],
503
+ }
504
+ } catch (cause) {
505
+ message.value = errorMessage(cause)
506
+ } finally {
507
+ isLoadingMoreRuns.value = false
508
+ }
509
+ }
510
+
511
+ const webhookId = computed(() =>
512
+ id.value && !dirty.value && draft.value.trigger.kind === 'webhook' ? id.value : null,
513
+ )
514
+ const { data: endpoint, error: endpointError } = useAsyncData(
515
+ () => `automation-endpoint:${scope.value}:${webhookId.value}`,
516
+ async () => (webhookId.value ? (await commands.endpoint(webhookId.value)).url : ''),
517
+ { default: () => '' },
518
+ )
519
+ const loadError = computed(
520
+ () =>
521
+ collectionError.value ||
522
+ folderError.value ||
523
+ recordError.value ||
524
+ runsError.value ||
525
+ endpointError.value,
526
+ )
527
+ function save(): Promise<boolean> {
528
+ cancelAutosave()
529
+ if (savePromise) {
530
+ return savePromise.then((saved) =>
531
+ saved && snapshot.value !== baseline.value ? save() : saved,
532
+ )
533
+ }
534
+ if (isBusy.value || loading.value) {
535
+ return Promise.resolve(false)
536
+ }
537
+ const submitted = snapshot.value
538
+ const input = JSON.parse(submitted) as AutomationDraft
539
+ isBusy.value = true
540
+ message.value = ''
541
+ savePromise = (async () => {
542
+ try {
543
+ const row = id.value
544
+ ? await api.automation.update(id.value, input)
545
+ : await api.automation.create(input)
546
+ id.value = row.id
547
+ const rows = collection.value?.data ?? []
548
+ collection.value = {
549
+ data: rows.some((entry) => entry.id === row.id)
550
+ ? rows.map((entry) => (entry.id === row.id ? row : entry))
551
+ : [...rows, row],
552
+ }
553
+ const saved = fromRow(row)
554
+ if (snapshot.value === submitted) {
555
+ draft.value = saved
556
+ }
557
+ baseline.value = JSON.stringify(saved)
558
+ failedSnapshot = ''
559
+ if (Number(route.query.automation) !== row.id) {
560
+ await router.replace({ query: { ...route.query, automation: String(row.id) } })
561
+ }
562
+ return true
563
+ } catch (cause) {
564
+ failedSnapshot = submitted
565
+ message.value = errorMessage(cause)
566
+ return false
567
+ } finally {
568
+ savePromise = null
569
+ isBusy.value = false
570
+ }
571
+ })()
572
+ return savePromise
573
+ }
574
+ watch([snapshot, isBusy, loading, isCanvasDragging], scheduleAutosave)
575
+ async function flushAutosave() {
576
+ if (!savePromise && snapshot.value === baseline.value) {
577
+ return true
578
+ }
579
+ return save()
580
+ }
581
+ onBeforeRouteLeave(flushAutosave)
582
+ onBeforeRouteUpdate((to, from) => {
583
+ if (to.query.automation === from.query.automation || Number(to.query.automation) === id.value) {
584
+ return true
585
+ }
586
+ return flushAutosave()
587
+ })
588
+ onBeforeUnmount(cancelAutosave)
589
+ function toggle(active: boolean) {
590
+ draft.value.active = active
591
+ }
592
+ async function switchAutomation(automationId: number) {
593
+ if (!automationId) {
594
+ return
595
+ }
596
+ if (automationId === id.value) {
597
+ return
598
+ }
599
+ if (!(await flushAutosave())) {
600
+ return
601
+ }
602
+ await router.push({
603
+ query: {
604
+ ...route.query,
605
+ automation: String(automationId),
606
+ automationFolder: collection.value?.data.find((row) => row.id === automationId)?.folder,
607
+ node: undefined,
608
+ run: undefined,
609
+ runs: undefined,
610
+ },
611
+ })
612
+ }
613
+ async function newAutomation() {
614
+ if (!(await flushAutosave())) {
615
+ return
616
+ }
617
+ await router.replace({
618
+ query: { ...route.query, automation: 'new', node: 'trigger', run: undefined, runs: undefined },
619
+ })
620
+ openDraft(null)
621
+ }
622
+ async function remove() {
623
+ if (!id.value || isBusy.value || loading.value) {
624
+ return
625
+ }
626
+ cancelAutosave()
627
+ isBusy.value = true
628
+ message.value = ''
629
+ try {
630
+ await api.automation.delete(id.value)
631
+ id.value = null
632
+ baseline.value = snapshot.value
633
+ await newAutomation()
634
+ await refreshCollection()
635
+ } catch (cause) {
636
+ message.value = errorMessage(cause)
637
+ } finally {
638
+ isBusy.value = false
639
+ }
640
+ }
641
+
642
+ async function copy() {
643
+ try {
644
+ await navigator.clipboard.writeText(endpoint.value)
645
+ isCopied.value = true
646
+ } catch {
647
+ message.value = 'Select the endpoint and copy it.'
648
+ }
649
+ }
650
+ async function test(errorPath = false) {
651
+ const revealRun = historyOpen.value
652
+ if (running.value || isBusy.value) {
653
+ message.value = running.value
654
+ ? 'Wait for the current test run to finish.'
655
+ : 'Wait for the current change to finish.'
656
+ return false
657
+ }
658
+ if (!draft.value.agentId) {
659
+ selected.value = 'agent'
660
+ message.value = 'Choose an agent before running a test.'
661
+ return false
662
+ }
663
+ if ((!id.value || dirty.value) && !(await save())) {
664
+ return false
665
+ }
666
+ isBusy.value = true
667
+ message.value = ''
668
+ try {
669
+ const payload = JSON.parse(sample.value)
670
+ const result = await commands.run(id.value!, payload, { errorPath })
671
+ activeRunId.value = result.runId
672
+ isTestOpen.value = false
673
+ await refreshRuns()
674
+ if (revealRun) {
675
+ selectRun(result.runId)
676
+ }
677
+ return true
678
+ } catch (cause) {
679
+ message.value =
680
+ cause instanceof SyntaxError ? 'Enter a valid JSON sample payload.' : errorMessage(cause)
681
+ return false
682
+ } finally {
683
+ isBusy.value = false
684
+ }
685
+ }
686
+ async function showHistory() {
687
+ if (!id.value) {
688
+ return
689
+ }
690
+ void refreshRuns()
691
+ await router.push({ query: { ...route.query, node: undefined, runs: '1', run: undefined } })
692
+ }
693
+ function selectRun(runId: number) {
694
+ void router.push({ query: { ...route.query, node: undefined, runs: '1', run: String(runId) } })
695
+ }
696
+ function backToRuns() {
697
+ void router.push({ query: { ...route.query, runs: '1', run: undefined } })
698
+ }
699
+ function closeRuns() {
700
+ void router.push({ query: { ...route.query, runs: undefined, run: undefined } })
701
+ }
702
+ function rerun(run: Run) {
703
+ sample.value = JSON.stringify(run.payload, null, 2)
704
+ void test()
705
+ }
706
+ const active = computed(() => route.path === '/dashboard/automations')
707
+ async function runAction(action: () => Promise<boolean>, fallback: string) {
708
+ if (await action()) {
709
+ return
710
+ }
711
+ const error = message.value || fallback
712
+ message.value = ''
713
+ throw new Error(error)
714
+ }
715
+ function saveAction() {
716
+ return runAction(save, 'Could not save this automation. Try again.')
717
+ }
718
+ useAgentActionHandler('save-automation', saveAction, active)
719
+ useAgentActionHandler(
720
+ 'test-automation',
721
+ () => runAction(test, 'Could not start a test run. Try again.'),
722
+ active,
723
+ )
724
+ useAgentActionHandler(
725
+ 'webhook-automation',
726
+ () => {
727
+ draft.value.trigger.kind = 'webhook'
728
+ selected.value = 'trigger'
729
+ return saveAction()
730
+ },
731
+ active,
732
+ )
733
+ useAgentActionHandler(
734
+ 'retry-automation',
735
+ () => {
736
+ draft.value.retries = 3
737
+ selected.value = 'error'
738
+ return saveAction()
739
+ },
740
+ active,
741
+ )
742
+ useAgentActionHandler(
743
+ 'slack-automation',
744
+ () => {
745
+ draft.value.failure.action = 'slack'
746
+ selected.value = 'error'
747
+ return saveAction()
748
+ },
749
+ active,
750
+ )
751
+ function keydown(event: KeyboardEvent) {
752
+ if (
753
+ !active.value ||
754
+ event.defaultPrevented ||
755
+ isTestMenuOpen.value ||
756
+ (event.target as HTMLElement).closest('[role=dialog],[role=menu],[role=listbox]')
757
+ ) {
758
+ return
759
+ }
760
+ if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
761
+ event.preventDefault()
762
+ void save()
763
+ } else if (
764
+ event.key === 'Escape' &&
765
+ !(event.target as HTMLElement).closest('input,textarea,[role=dialog]')
766
+ ) {
767
+ if (selectedRun.value) {
768
+ backToRuns()
769
+ } else if (historyOpen.value) {
770
+ closeRuns()
771
+ } else {
772
+ selected.value = null
773
+ }
774
+ }
775
+ }
776
+ useEventListener('keydown', keydown)
777
+ </script>
778
+
779
+ <template>
780
+ <ClientOnly>
781
+ <Teleport
782
+ defer
783
+ to="#workspace-navbar-title"
784
+ >
785
+ <div class="flex min-w-0 flex-1 items-center gap-2.5 overflow-hidden">
786
+ <UBreadcrumb
787
+ :items="[
788
+ { label: 'Automations', icon: 'i-lucide-workflow' },
789
+ {
790
+ label: draft.name || 'Untitled automation',
791
+ active: true,
792
+ class: 'font-medium text-highlighted',
793
+ },
794
+ ]"
795
+ :ui="{
796
+ root: 'min-w-0',
797
+ list: 'flex-nowrap gap-1.5',
798
+ item: 'min-w-0 first:shrink-0',
799
+ link: 'gap-1.5 text-[13px] leading-5',
800
+ linkLeadingIcon: 'size-3 shrink-0',
801
+ linkLabel: 'truncate',
802
+ separatorIcon: 'size-3 text-dimmed',
803
+ }"
804
+ />
805
+ <button
806
+ v-if="currentRun"
807
+ type="button"
808
+ class="h-7 min-w-0 flex-[0_100_auto] truncate rounded-md px-2 text-[13px] transition-[color,opacity] duration-200 enabled:hover:bg-(--gray-400) focus-visible:outline-2 focus-visible:outline-primary motion-reduce:transition-none"
809
+ :class="runResultClass"
810
+ :disabled="running"
811
+ :title="runResultLabel"
812
+ @click="selectRun(currentRun.id)"
813
+ >
814
+ {{ runResultLabel }}
815
+ </button>
816
+ </div>
817
+ </Teleport>
818
+ <Teleport
819
+ defer
820
+ to="#workspace-navbar-actions"
821
+ >
822
+ <div
823
+ class="flex shrink-0 items-center gap-2.5"
824
+ role="toolbar"
825
+ aria-label="Automation controls"
826
+ @pointerdown.stop
827
+ @wheel.stop
828
+ >
829
+ <button
830
+ type="button"
831
+ role="switch"
832
+ :aria-checked="draft.active"
833
+ :aria-label="draft.active ? 'Active automation' : 'Paused automation'"
834
+ :disabled="isBusy || loading"
835
+ class="flex h-8 shrink-0 items-center gap-2 rounded-md py-0 pr-2.5 pl-2 text-[13px] text-(--gray-1000) hover:bg-(--gray-400) focus-visible:outline-2 focus-visible:outline-primary disabled:opacity-50"
836
+ @click="toggle(!draft.active)"
837
+ >
838
+ <span
839
+ class="relative h-4 w-7 shrink-0 rounded-full transition-colors duration-200 ease-[cubic-bezier(.2,0,0,1)] motion-reduce:transition-none"
840
+ :class="draft.active ? 'bg-success' : 'bg-(--gray-600)'"
841
+ >
842
+ <span
843
+ class="absolute top-0.5 left-0.5 size-3 rounded-full transition-[transform,background-color] duration-200 ease-[cubic-bezier(.2,0,0,1)] motion-reduce:transition-none"
844
+ :class="draft.active ? 'translate-x-3 bg-(--ui-bg)' : 'bg-(--gray-900)'"
845
+ />
846
+ </span>
847
+ <span class="hidden sm:inline">{{ draft.active ? 'Active' : 'Paused' }}</span>
848
+ </button>
849
+ <button
850
+ type="button"
851
+ class="flex h-8 shrink-0 items-center gap-2 rounded-lg border border-(--gray-500) px-2.5 text-[13px] text-highlighted transition-colors hover:bg-(--gray-400) active:scale-[0.98] focus-visible:outline-2 focus-visible:outline-primary disabled:opacity-50"
852
+ :class="historyOpen && 'bg-(--gray-400)'"
853
+ :disabled="!id"
854
+ :aria-pressed="historyOpen"
855
+ @click="historyOpen ? closeRuns() : showHistory()"
856
+ >
857
+ <svg
858
+ class="size-3.5 shrink-0"
859
+ viewBox="0 0 24 24"
860
+ fill="none"
861
+ stroke="currentColor"
862
+ stroke-width="1.5"
863
+ stroke-linecap="round"
864
+ stroke-linejoin="round"
865
+ aria-hidden="true"
866
+ >
867
+ <path d="M3 12a9 9 0 1 0 3-6.7L3 8M3 3v5h5M12 7v5l3 2" />
868
+ </svg>
869
+ <span>Runs</span>
870
+ <span class="tabular-nums text-dimmed">{{ Math.max(historyTotal, history.length) }}</span>
871
+ </button>
872
+ <div
873
+ class="hidden h-8 shrink-0 items-center gap-0.5 rounded-lg border border-(--gray-500) bg-(--gray-300) px-0.5 sm:flex"
874
+ role="group"
875
+ aria-label="Canvas zoom"
876
+ >
877
+ <button
878
+ type="button"
879
+ aria-label="Zoom out"
880
+ class="grid h-[26px] w-7 place-items-center rounded-md text-muted hover:bg-(--gray-500) hover:text-highlighted focus-visible:outline-2 focus-visible:outline-primary"
881
+ @click="zoomOut"
882
+ >
883
+ <svg
884
+ class="size-3.5"
885
+ viewBox="0 0 24 24"
886
+ fill="none"
887
+ stroke="currentColor"
888
+ stroke-width="2"
889
+ stroke-linecap="round"
890
+ aria-hidden="true"
891
+ >
892
+ <path d="M5 12h14" />
893
+ </svg>
894
+ </button>
895
+ <button
896
+ type="button"
897
+ title="Reset view"
898
+ aria-label="Reset canvas view"
899
+ class="h-[26px] min-w-12 rounded-md px-1 text-xs tabular-nums text-highlighted hover:bg-(--gray-500) focus-visible:outline-2 focus-visible:outline-primary"
900
+ @click="zoomReset"
901
+ >
902
+ {{ Math.round(view.z * 100) }}%
903
+ </button>
904
+ <button
905
+ type="button"
906
+ aria-label="Zoom in"
907
+ class="grid h-[26px] w-7 place-items-center rounded-md text-muted hover:bg-(--gray-500) hover:text-highlighted focus-visible:outline-2 focus-visible:outline-primary"
908
+ @click="zoomIn"
909
+ >
910
+ <svg
911
+ class="size-3.5"
912
+ viewBox="0 0 24 24"
913
+ fill="none"
914
+ stroke="currentColor"
915
+ stroke-width="2"
916
+ stroke-linecap="round"
917
+ aria-hidden="true"
918
+ >
919
+ <path d="M12 5v14M5 12h14" />
920
+ </svg>
921
+ </button>
922
+ </div>
923
+ <div class="flex shrink-0">
924
+ <button
925
+ type="button"
926
+ class="flex h-8 items-center gap-1.5 rounded-l-lg bg-inverted px-3 text-[13px] font-medium text-inverted hover:bg-(--gray-1000) active:scale-[0.98] focus-visible:outline-2 focus-visible:outline-primary disabled:opacity-50"
927
+ :disabled="running || isBusy || loading || !draft.agentId"
928
+ @click="test()"
929
+ >
930
+ <svg
931
+ class="size-3"
932
+ viewBox="0 0 24 24"
933
+ fill="currentColor"
934
+ aria-hidden="true"
935
+ >
936
+ <path d="M7 4v16l13-8Z" />
937
+ </svg>
938
+ <span class="hidden sm:inline">Test run</span>
939
+ </button>
940
+ <UDropdownMenu
941
+ v-model:open="isTestMenuOpen"
942
+ :items="[
943
+ [
944
+ {
945
+ label: 'Test success path',
946
+ description: 'Agent returns its actual result',
947
+ icon: 'i-lucide-circle',
948
+ disabled: running || isBusy || !draft.agentId,
949
+ onSelect: () => test(),
950
+ },
951
+ {
952
+ label: 'Simulate error',
953
+ description: 'Run the failure action',
954
+ icon: 'i-lucide-circle',
955
+ disabled: running || isBusy || !draft.agentId,
956
+ onSelect: () => test(true),
957
+ },
958
+ ],
959
+ [
960
+ {
961
+ label: 'Edit sample payload',
962
+ icon: 'i-lucide-braces',
963
+ onSelect: () => (isTestOpen = true),
964
+ },
965
+ {
966
+ label: 'Save automation',
967
+ icon: 'i-lucide-save',
968
+ disabled: !dirty || isBusy || loading,
969
+ onSelect: () => save(),
970
+ },
971
+ {
972
+ label: 'Delete automation',
973
+ icon: 'i-lucide-trash-2',
974
+ color: 'error',
975
+ disabled: !id || isBusy || loading,
976
+ onSelect: remove,
977
+ },
978
+ ],
979
+ ]"
980
+ :content="{ align: 'end', sideOffset: 8 }"
981
+ :ui="{
982
+ content:
983
+ 'w-[220px] rounded-[10px] border border-(--gray-500) bg-(--gray-300) p-1.5 ring-0',
984
+ viewport: 'divide-y-0',
985
+ group: 'p-0',
986
+ item: 'min-h-11 gap-2.5 rounded-md px-2.5 py-1.5 text-[13px] text-highlighted data-highlighted:before:bg-(--gray-500)',
987
+ itemLabel: 'font-normal',
988
+ itemDescription: 'text-xs text-muted',
989
+ }"
990
+ >
991
+ <template #item-leading="{ item }">
992
+ <span
993
+ v-if="item.label === 'Test success path' || item.label === 'Simulate error'"
994
+ class="size-2 shrink-0 rounded-full"
995
+ :class="item.label === 'Simulate error' ? 'bg-error' : 'bg-success'"
996
+ />
997
+ <UIcon
998
+ v-else-if="item.icon"
999
+ :name="item.icon"
1000
+ class="size-4 shrink-0 text-muted"
1001
+ />
1002
+ </template>
1003
+ <button
1004
+ type="button"
1005
+ aria-label="Test options"
1006
+ class="grid h-8 w-7 place-items-center rounded-r-lg border-l border-(--gray-800) bg-inverted text-inverted hover:bg-(--gray-1000) focus-visible:outline-2 focus-visible:outline-primary"
1007
+ >
1008
+ <svg
1009
+ class="size-3 transition-transform duration-220 motion-reduce:transition-none"
1010
+ :class="isTestMenuOpen && 'rotate-180'"
1011
+ viewBox="0 0 24 24"
1012
+ fill="none"
1013
+ stroke="currentColor"
1014
+ stroke-width="2"
1015
+ stroke-linecap="round"
1016
+ stroke-linejoin="round"
1017
+ aria-hidden="true"
1018
+ >
1019
+ <path d="m6 9 6 6 6-6" />
1020
+ </svg>
1021
+ </button>
1022
+ </UDropdownMenu>
1023
+ </div>
1024
+ </div>
1025
+ </Teleport>
1026
+ </ClientOnly>
1027
+
1028
+ <div class="@container/automation relative flex min-h-0 flex-1 flex-col">
1029
+ <div
1030
+ v-if="loading"
1031
+ class="absolute inset-0 z-30 grid place-items-center bg-(--gray-200)/80"
1032
+ >
1033
+ <UIcon
1034
+ name="i-lucide-loader-circle"
1035
+ class="size-5 animate-spin text-muted"
1036
+ />
1037
+ </div>
1038
+ <div class="relative flex min-h-0 flex-1">
1039
+ <AutomationSidebar
1040
+ v-model:open="sidebarOpen"
1041
+ :automations="sidebarRows"
1042
+ :folders="folderNames"
1043
+ :current-id="id ?? 0"
1044
+ :current-folder="currentFolder"
1045
+ :busy="isBusy || loading"
1046
+ :create-folder="createFolder"
1047
+ @select="switchAutomation"
1048
+ @folder="selectFolder"
1049
+ @create="newAutomation"
1050
+ @remove-folder="removeFolder"
1051
+ />
1052
+ <div class="relative flex min-h-0 min-w-0 flex-1">
1053
+ <UButton
1054
+ v-if="!sidebarOpen"
1055
+ icon="i-lucide-panel-left-open"
1056
+ color="neutral"
1057
+ variant="soft"
1058
+ class="absolute top-3 left-3 z-10 size-8 justify-center rounded-md p-0"
1059
+ aria-label="Open automation folders"
1060
+ aria-controls="automations-folder-sidebar"
1061
+ :aria-expanded="sidebarOpen"
1062
+ @click="sidebarOpen = true"
1063
+ />
1064
+ <AutomationCanvas
1065
+ v-model:positions="draft.positions"
1066
+ :view="view"
1067
+ :selected="selected"
1068
+ :summaries="summaries"
1069
+ :steps="steps"
1070
+ @update:view="updateView"
1071
+ @dragging="isCanvasDragging = $event"
1072
+ @select="selected = $event"
1073
+ />
1074
+ <div
1075
+ v-if="message || loadError"
1076
+ class="absolute top-3 left-1/2 z-30 w-max max-w-[min(480px,calc(100%-24px))] -translate-x-1/2 rounded-lg bg-(--gray-200) shadow-lg"
1077
+ >
1078
+ <UAlert
1079
+ color="error"
1080
+ variant="subtle"
1081
+ icon="i-lucide-circle-alert"
1082
+ :title="message || errorMessage(loadError)"
1083
+ :close="true"
1084
+ :ui="{
1085
+ root: 'items-center gap-2 py-2 pr-2 pl-3',
1086
+ title: 'text-[13px] font-normal text-default',
1087
+ icon: 'size-4',
1088
+ }"
1089
+ @update:open="message = ''"
1090
+ />
1091
+ </div>
1092
+ </div>
1093
+ <aside
1094
+ class="absolute top-0 right-0 bottom-0 z-10 shrink-0 overflow-hidden border-l bg-(--gray-200) transition-[width,border-color] duration-260 ease-[cubic-bezier(.2,0,0,1)] motion-reduce:transition-none @min-[700px]/automation:relative"
1095
+ :class="
1096
+ selected || historyOpen
1097
+ ? 'w-[min(340px,100%)] border-(--gray-400)'
1098
+ : 'w-0 border-transparent'
1099
+ "
1100
+ :inert="!selected && !historyOpen"
1101
+ :aria-hidden="!selected && !historyOpen"
1102
+ >
1103
+ <div
1104
+ class="absolute inset-y-0 left-0 w-[340px] max-w-full overflow-hidden transition-[opacity,transform] duration-240 ease-[cubic-bezier(.2,0,0,1)] motion-reduce:transition-none"
1105
+ :class="
1106
+ historyOpen
1107
+ ? 'translate-x-0 opacity-100'
1108
+ : 'pointer-events-none translate-x-3 opacity-0'
1109
+ "
1110
+ :inert="!historyOpen"
1111
+ :aria-hidden="!historyOpen"
1112
+ >
1113
+ <AutomationRunsPanel
1114
+ :key="id ?? 'draft'"
1115
+ :runs="history"
1116
+ :total="historyTotal"
1117
+ :has-more="hasMoreRuns"
1118
+ :selected-run="selectedRun"
1119
+ :default-source="defaultRunSource"
1120
+ :loading="runsPending || isLoadingMoreRuns"
1121
+ :running="running"
1122
+ @select="selectRun"
1123
+ @back="backToRuns"
1124
+ @close="closeRuns"
1125
+ @rerun="rerun"
1126
+ @load-more="loadMoreHistory"
1127
+ />
1128
+ </div>
1129
+ <div
1130
+ class="absolute inset-y-0 left-0 flex h-full w-[340px] max-w-full flex-col transition-[opacity,transform] duration-240 ease-[cubic-bezier(.2,0,0,1)] motion-reduce:transition-none"
1131
+ :class="inspectorPanelClass"
1132
+ :inert="!selected"
1133
+ :aria-hidden="!selected"
1134
+ >
1135
+ <AutomationInspector
1136
+ v-model="draft"
1137
+ :node="inspectorNode"
1138
+ :agents="agents.rows.value"
1139
+ :events="events"
1140
+ :connected-providers="
1141
+ (integrations?.connections ?? [])
1142
+ .filter((row) => row.enabled)
1143
+ .map((row) => row.provider)
1144
+ "
1145
+ :agents-installed="modules.isInstalled('agents')"
1146
+ :agent-error="agents.error.value ? errorMessage(agents.error.value) : ''"
1147
+ :endpoint="endpoint"
1148
+ :sample="sample"
1149
+ :copied="isCopied"
1150
+ @close="selected = null"
1151
+ @copy="copy"
1152
+ />
1153
+ </div>
1154
+ </aside>
1155
+ </div>
1156
+ </div>
1157
+ <UModal
1158
+ v-model:open="isTestOpen"
1159
+ title="Sample event"
1160
+ description="Test runs execute the agent and its configured actions."
1161
+ :ui="{ content: 'max-w-xl' }"
1162
+ >
1163
+ <template #body>
1164
+ <UTextarea
1165
+ v-model="sample"
1166
+ :rows="12"
1167
+ class="w-full"
1168
+ :ui="{ base: 'font-mono text-xs' }"
1169
+ />
1170
+ </template>
1171
+ <template #footer>
1172
+ <UButton
1173
+ label="Run automation"
1174
+ icon="i-lucide-play"
1175
+ color="neutral"
1176
+ :disabled="!draft.agentId || running || isBusy"
1177
+ @click="test()"
1178
+ />
1179
+ </template>
1180
+ </UModal>
1181
+ </template>