@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.
- package/LICENSE.md +9 -0
- package/README.md +173 -2
- package/client.ts +24 -0
- package/module.ts +55 -0
- package/package.json +59 -3
- package/runtime/components/AutomationCanvas.vue +488 -0
- package/runtime/components/AutomationInspector.vue +436 -0
- package/runtime/components/AutomationNodeIcon.vue +27 -0
- package/runtime/components/AutomationRunsPanel.vue +475 -0
- package/runtime/components/AutomationSidebar.vue +253 -0
- package/runtime/components/AutomationStatusIcon.vue +44 -0
- package/runtime/pages/automations.vue +1181 -0
- package/runtime/server/api/automations/[id]/endpoint.get.ts +12 -0
- package/runtime/server/api/automations/[id]/run.post.ts +30 -0
- package/runtime/server/api/automations/events.post.ts +37 -0
- package/runtime/server/api/automations/runs/[id]/resume.post.ts +23 -0
- package/runtime/server/handlers/automation-hook.post.ts +51 -0
- package/runtime/server/handlers/connect-hook.post.ts +54 -0
- package/runtime/server/handlers/integration-hook.post.ts +86 -0
- package/runtime/server/plugins/events.ts +79 -0
- package/runtime/server/plugins/maintenance.ts +20 -0
- package/runtime/server/utils/agent-runner.ts +15 -0
- package/runtime/server/utils/automation-context.ts +77 -0
- package/runtime/server/utils/automation-engine.ts +697 -0
- package/runtime/server/utils/automation-launch.ts +205 -0
- package/runtime/server/utils/automation-schedule.ts +31 -0
- package/runtime/server/utils/connection-events.ts +114 -0
- package/runtime/server/utils/maintenance.ts +255 -0
- package/runtime/server/utils/schedule-sync.ts +32 -0
- package/runtime/server/utils/transient-retry.ts +34 -0
- package/runtime/server/workflows/runner.ts +39 -0
- package/runtime/shared/agent.ts +5 -0
- package/runtime/shared/automation-run.ts +61 -0
- package/runtime/shared/automation.ts +202 -0
- package/runtime/workspace.ts +56 -0
- package/schema.ts +222 -0
- package/server.ts +2 -0
- 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>
|