620 copilot actions (#631)
* feat: enhance InvokeActionForm and TechnologyIcon components with improved agent selection and dynamic parameter handling * feat: enhance agent selection in InvokeActionForm with select all and clear options * precommit-fixes * chore: update CURRENT_VERSION to 0.1.31
taylor_socfortress committed
Jan 24, 2026 at 16:56 UTC
c32505a373e19dddff8bb48080ce98a73d774195
3 files changed
+329
-193
backend/app/version/services/version.py
+1
-1
@@ -7,7 +7,7 @@ from loguru import logger
7
from packaging.version import Version
8
9
# Current version - update this with each release
10
-CURRENT_VERSION = "0.1.30"
10
+CURRENT_VERSION = "0.1.31"
11
VERSION_CHECK_URL = "https://api.github.com/repos/socfortress/CoPilot/releases/latest"
12
13
frontend/src/components/copilotAction/InvokeActionForm.vue
+302
-168
@@ -1,215 +1,349 @@
1
<template>
2
- <n-spin :show="loading">
3
- <div class="flex flex-col gap-6">
4
- <div class="text-sm leading-relaxed">{{ action.description }}</div>
2
+ <n-form ref="formRef" :model="formValue" :rules="rules" label-placement="left" label-width="auto">
3
+ <!-- Technology Info Alert -->
4
+ <n-alert v-if="action?.technology" type="info" class="mb-4" size="small">
5
+ <template #header>
6
+ <div class="flex items-center gap-2">
7
+ <TechnologyIcon :technology="action.technology" :size="16" />
8
+ <span>{{ action.technology }} Action</span>
9
+ </div>
10
+ </template>
11
+ Only showing agents compatible with {{ action.technology }} technology
12
+ <template v-if="incompatibleAgentCount > 0">
13
+ ({{ incompatibleAgentCount }} incompatible agent{{ incompatibleAgentCount !== 1 ? 's' : '' }} hidden)
14
+ </template>
15
+ </n-alert>
16
+
17
+ <n-form-item label="Target Agents" path="agent_names" required>
18
+ <div class="flex flex-col gap-2 w-full">
19
+ <n-select
20
+ v-model:value="formValue.agent_names"
21
+ :options="filteredAgentOptions"
22
+ :loading="loadingAgents"
23
+ multiple
24
+ filterable
25
+ placeholder="Select target agents"
26
+ :max-tag-count="3"
27
+ >
28
+ <template #empty>
29
+ <n-empty description="No compatible agents found" />
30
+ </template>
31
+ </n-select>
32
+ <div class="flex gap-2 justify-end">
33
+ <n-button
34
+ secondary
35
+ type="primary"
36
+ size="small"
37
+ :disabled="loadingAgents || filteredAgentOptions.length === 0"
38
+ @click="selectAllAgents"
39
+ >
40
+ <template #icon>
41
+ <Icon name="carbon:checkbox-checked-filled" />
42
+ </template>
43
+ Select All
44
+ </n-button>
45
+ <n-button
46
+ v-if="formValue.agent_names.length > 0"
47
+ secondary
48
+ size="small"
49
+ @click="clearAllAgents"
50
+ >
51
+ <template #icon>
52
+ <Icon name="carbon:close" />
53
+ </template>
54
+ Clear
55
+ </n-button>
56
+ </div>
57
+ </div>
58
+ </n-form-item>
59
+
60
+ <!-- Parameters Section -->
61
+ <div v-if="action?.script_parameters?.length" class="mb-4">
62
+ <n-divider>Parameters</n-divider>
63
+ <div v-for="param in action.script_parameters" :key="param.name" class="mb-3">
64
+ <n-form-item
65
+ :label="param.name"
66
+ :path="`parameters.${param.name}`"
67
+ :required="param.required"
68
+ >
69
+ <template #label>
70
+ <div class="flex items-center gap-2">
71
+ <span>{{ param.name }}</span>
72
+ <n-tag v-if="param.type" size="tiny" :bordered="false">
73
+ {{ param.type }}
74
+ </n-tag>
75
+ </div>
76
+ </template>
77
+
78
+ <!-- Boolean Parameter -->
79
+ <n-switch
80
+ v-if="param.type === 'boolean'"
81
+ v-model:value="formValue.parameters[param.name]"
82
+ />
83
6
- <!-- Target Agents Selection -->
7
- <div class="mt-4 flex flex-col gap-1">
8
- <n-form-item label="Target Agents" required :show-feedback="false">
84
+ <!-- Integer Parameter -->
85
+ <n-input-number
86
+ v-else-if="param.type === 'integer'"
87
+ v-model:value="formValue.parameters[param.name]"
88
+ :placeholder="param.default?.toString() || 'Enter value...'"
89
+ class="w-full"
90
+ />
91
+
92
+ <!-- String with Enum (Select) -->
93
<n-select
10
- v-model:value="form.agent_names"
11
- :options="agentOptions"
12
- multiple
13
- filterable
14
- placeholder="Select target agents..."
15
- :loading="loadingAgents"
16
- clearable
17
- size="large"
18
- class="mb-2"
94
+ v-else-if="param.enum?.length"
95
+ v-model:value="formValue.parameters[param.name]"
96
+ :options="param.enum.map(v => ({ label: v, value: v }))"
97
+ :placeholder="param.default?.toString() || 'Select value...'"
98
/>
20
- </n-form-item>
21
- <p class="px-0.5 text-sm">Select one or more agents to run this action on</p>
22
- </div>
99
24
- <!-- Parameters Form -->
25
- <CardEntity v-if="action.script_parameters.length > 0" embedded>
26
- <template #header>Parameters</template>
100
+ <!-- String Parameter -->
101
+ <n-input
102
+ v-else
103
+ v-model:value="formValue.parameters[param.name]"
104
+ :placeholder="param.default?.toString() || 'Enter value...'"
105
+ clearable
106
+ />
107
28
- <div class="flex flex-col gap-4">
29
- <n-card
30
- v-for="param in parameters"
31
- :key="param.name"
32
- embedded
33
- size="small"
34
- content-class="flex flex-col gap-2"
35
- >
36
- <n-form-item :required="param.required" :show-feedback="false">
37
- <template #label>
38
- <div class="flex items-center gap-2">
39
- <span>{{ param.name }}</span>
40
- <code>{{ param.type }}</code>
41
- </div>
42
- </template>
43
- <component
44
- :is="getInputComponent(param.type)"
45
- v-model:value="form.parameters[param.name]"
46
- :placeholder="getPlaceholder(param)"
47
- :options="param.enum?.map(e => ({ label: e, value: e }))"
48
- clearable
49
- />
50
- </n-form-item>
51
- <p v-if="param.description" class="px-0.5 text-sm">{{ param.description }}</p>
52
- </n-card>
53
- </div>
54
- </CardEntity>
55
-
56
- <!-- Action Buttons -->
57
- <div class="mt-6 flex justify-end gap-3">
58
- <n-button size="large" @click="$emit('close')">Cancel</n-button>
59
- <n-button type="primary" size="large" :loading="loading" :disabled="!isFormValid" @click="handleSubmit">
60
- <template #icon>
61
- <Icon :size="18" :name="InvokeIcon" />
108
+ <template v-if="param.description" #feedback>
109
+ <span class="text-xs text-gray-500">{{ param.description }}</span>
110
</template>
63
- {{ loading ? "Invoking..." : "Invoke Action" }}
64
- </n-button>
111
+ </n-form-item>
112
</div>
113
</div>
67
- </n-spin>
114
+
115
+ <div class="flex justify-end gap-2">
116
+ <n-button @click="emit('close')">Cancel</n-button>
117
+ <n-button
118
+ type="primary"
119
+ :loading="loading"
120
+ :disabled="!isFormValid"
121
+ @click="handleInvoke"
122
+ >
123
+ <template #icon>
124
+ <Icon :name="PlayIcon" />
125
+ </template>
126
+ Invoke Action
127
+ </n-button>
128
+ </div>
129
+ </n-form>
130
</template>
131
132
<script setup lang="ts">
71
-import type { CopilotAction, InvokeCopilotActionRequest, ScriptParameter } from "@/types/copilotAction.d"
72
-import _orderBy from "lodash/orderBy"
73
-import { NButton, NCard, NFormItem, NInput, NInputNumber, NSelect, NSpin, NSwitch, useMessage } from "naive-ui"
133
+import type { FormInst, FormRules } from "naive-ui"
134
+import type { Agent } from "@/types/agents.d"
135
+import type { CopilotAction } from "@/types/copilotAction.d"
136
+import {
137
+ NAlert,
138
+ NButton,
139
+ NDivider,
140
+ NEmpty,
141
+ NForm,
142
+ NFormItem,
143
+ NInput,
144
+ NInputNumber,
145
+ NSelect,
146
+ NSwitch,
147
+ NTag,
148
+ useMessage
149
+} from "naive-ui"
150
import { computed, onBeforeMount, ref } from "vue"
151
import Api from "@/api"
76
-import CardEntity from "@/components/common/cards/CardEntity.vue"
152
import Icon from "@/components/common/Icon.vue"
153
+import TechnologyIcon from "./TechnologyIcon.vue"
154
155
const { action } = defineProps<{
80
- action: CopilotAction
156
+ action: CopilotAction
157
}>()
158
159
const emit = defineEmits<{
84
- success: []
85
- close: []
160
+ (e: "success"): void
161
+ (e: "close"): void
162
}>()
163
88
-const InvokeIcon = "solar:playback-speed-outline"
164
const message = useMessage()
165
+const formRef = ref<FormInst | null>(null)
166
const loading = ref(false)
167
const loadingAgents = ref(false)
168
+const agents = ref<Agent[]>([])
169
+const PlayIcon = "carbon:play"
170
93
-const agentOptions = ref<{ label: string; value: string }[]>([])
94
-
95
-const form = ref<{
96
- agent_names: string[]
97
- parameters: Record<string, string | number | boolean>
171
+const formValue = ref<{
172
+ agent_names: string[]
173
+ parameters: Record<string, string | number | boolean>
174
}>({
99
- agent_names: [],
100
- parameters: {}
175
+ agent_names: [],
176
+ parameters: {}
177
+})
178
+
179
+// Initialize default parameter values
180
+onBeforeMount(() => {
181
+ if (action?.script_parameters?.length) {
182
+ action.script_parameters.forEach(param => {
183
+ if (param.default !== undefined && param.default !== null) {
184
+ formValue.value.parameters[param.name] = param.default
185
+ }
186
+ })
187
+ }
188
+ getAgents()
189
})
190
103
-// Separate required and optional parameters
104
-const requiredParameters = computed(() => action.script_parameters.filter(p => p.required))
105
-const parameters = computed(() => _orderBy(action.script_parameters, ["required"], ["desc"]))
191
+// Helper function to determine if agent OS is compatible with technology
192
+function isAgentCompatible(agent: Agent, technology: string): boolean {
193
+ const agentOS = agent.os?.toLowerCase() || ''
194
+ const tech = technology.toLowerCase()
195
107
-const isFormValid = computed(() => {
108
- if (form.value.agent_names.length === 0) return false
196
+ switch (tech) {
197
+ case 'linux':
198
+ return agentOS.includes('linux') ||
199
+ agentOS.includes('ubuntu') ||
200
+ agentOS.includes('debian') ||
201
+ agentOS.includes('centos') ||
202
+ agentOS.includes('red hat') ||
203
+ agentOS.includes('fedora') ||
204
+ agentOS.includes('suse')
205
+
206
+ case 'windows':
207
+ return agentOS.includes('windows')
208
+
209
+ case 'macos':
210
+ case 'darwin':
211
+ return agentOS.includes('darwin') || agentOS.includes('macos')
212
+
213
+ // For other technologies or if no specific filtering is needed
214
+ default:
215
+ return true
216
+ }
217
+}
218
+
219
+// Filtered agent options based on technology
220
+const filteredAgentOptions = computed(() => {
221
+ if (!action?.technology) {
222
+ return agents.value.map(a => ({
223
+ label: `${a.hostname} (${a.os})`,
224
+ value: a.hostname
225
+ }))
226
+ }
227
+
228
+ return agents.value
229
+ .filter(agent => isAgentCompatible(agent, action.technology))
230
+ .map(a => ({
231
+ label: `${a.hostname} (${a.os})`,
232
+ value: a.hostname
233
+ }))
234
+})
235
110
- // Check all required parameters are filled
111
- for (const param of requiredParameters.value) {
112
- const value = form.value.parameters[param.name]
113
- if (value === null || value === undefined || value === "") {
114
- return false
115
- }
116
- }
236
+// Count of incompatible agents (for display purposes)
237
+const incompatibleAgentCount = computed(() => {
238
+ if (!action?.technology) return 0
239
118
- return true
240
+ return agents.value.filter(agent => !isAgentCompatible(agent, action.technology)).length
241
})
242
121
-function getInputComponent(type: string) {
122
- switch (type.toLowerCase()) {
123
- case "int":
124
- case "integer":
125
- case "float":
126
- case "number":
127
- return NInputNumber
128
- case "bool":
129
- case "boolean":
130
- return NSwitch
131
- case "enum":
132
- return NSelect
133
- default:
134
- return NInput
135
- }
243
+// Select all filtered agents
244
+function selectAllAgents() {
245
+ formValue.value.agent_names = filteredAgentOptions.value.map(option => option.value)
246
+ message.success(`Selected ${formValue.value.agent_names.length} agent${formValue.value.agent_names.length !== 1 ? 's' : ''}`)
247
}
248
138
-function getPlaceholder(param: ScriptParameter): string {
139
- if (param.default !== null && param.default !== undefined) {
140
- return `Default: ${param.default}`
141
- }
142
- return `Enter ${param.name}...`
249
+// Clear all selected agents
250
+function clearAllAgents() {
251
+ formValue.value.agent_names = []
252
+ message.info('Cleared all agents')
253
}
254
145
-async function loadAgents() {
146
- loadingAgents.value = true
147
-
148
- try {
149
- const response = await Api.agents.getAgents()
150
- if (response.data.success) {
151
- agentOptions.value = response.data.agents.map(agent => ({
152
- label: `${agent.hostname} (${agent.ip_address})`,
153
- value: agent.hostname
154
- }))
155
- } else {
156
- message.error("Failed to load agents")
157
- }
158
- } catch {
159
- message.error("Error loading agents")
160
- } finally {
161
- loadingAgents.value = false
162
- }
255
+const rules: FormRules = {
256
+ agent_names: {
257
+ type: 'array',
258
+ required: true,
259
+ message: 'Please select at least one agent',
260
+ trigger: ['blur', 'change']
261
+ }
262
}
263
165
-async function handleSubmit() {
166
- if (!isFormValid.value) return
167
-
168
- loading.value = true
169
- try {
170
- // Prepare the payload
171
- const payload: InvokeCopilotActionRequest = {
172
- copilot_action_name: action.copilot_action_name,
173
- agent_names: form.value.agent_names,
174
- parameters: {
175
- ScriptURL: action.repo_url,
176
- ...form.value.parameters
177
- }
178
- }
179
-
180
- const response = await Api.copilotAction.invokeAction(payload)
181
-
182
- if (response.data.success) {
183
- message.success(
184
- `Action invoked successfully on ${form.value.agent_names.length} agent(s). Check the appropriate Grafana dashboard for results.`
185
- )
186
- emit("success")
187
- } else {
188
- message.error(response.data.message || "Failed to invoke action")
189
- }
190
- } catch (error: any) {
191
- // TODO: remove any
192
- message.error(error.response?.data?.message || "Error invoking action")
193
- } finally {
194
- loading.value = false
195
- }
264
+// Add dynamic rules for required parameters
265
+if (action?.script_parameters?.length) {
266
+ action.script_parameters.forEach(param => {
267
+ if (param.required) {
268
+ rules[`parameters.${param.name}`] = {
269
+ required: true,
270
+ message: `${param.name} is required`,
271
+ trigger: ['blur', 'change']
272
+ }
273
+ }
274
+ })
275
}
276
198
-// Initialize form with default values
199
-function initializeForm() {
200
- const parameters: Record<string, string | number | boolean> = {}
277
+const isFormValid = computed(() => {
278
+ if (!formValue.value.agent_names.length) return false
279
+
280
+ // Check required parameters
281
+ if (action?.script_parameters?.length) {
282
+ for (const param of action.script_parameters) {
283
+ if (param.required) {
284
+ const value = formValue.value.parameters[param.name]
285
+ if (value === undefined || value === null || value === '') {
286
+ return false
287
+ }
288
+ }
289
+ }
290
+ }
291
202
- action.script_parameters.forEach(param => {
203
- if (param.default !== null && param.default !== undefined) {
204
- parameters[param.name] = param.default
205
- }
206
- })
292
+ return true
293
+})
294
208
- form.value.parameters = parameters
295
+async function getAgents() {
296
+ loadingAgents.value = true
297
+ try {
298
+ const res = await Api.agents.getAgents()
299
+ if (res.data.success) {
300
+ agents.value = res.data.agents || []
301
+ } else {
302
+ message.error(res.data?.message || "Failed to load agents")
303
+ }
304
+ } catch (err: any) {
305
+ message.error(err.response?.data?.message || "Failed to load agents")
306
+ } finally {
307
+ loadingAgents.value = false
308
+ }
309
}
310
211
-onBeforeMount(() => {
212
- loadAgents()
213
- initializeForm()
214
-})
311
+async function handleInvoke() {
312
+ if (!formRef.value) return
313
+
314
+ try {
315
+ await formRef.value.validate()
316
+ } catch {
317
+ return
318
+ }
319
+
320
+ loading.value = true
321
+
322
+ // Filter out undefined/null parameters
323
+ const cleanedParameters: Record<string, string | number> = {}
324
+ Object.entries(formValue.value.parameters).forEach(([key, value]) => {
325
+ if (value !== undefined && value !== null && value !== '') {
326
+ cleanedParameters[key] = typeof value === 'boolean' ? (value ? 'true' : 'false') : value
327
+ }
328
+ })
329
+
330
+ try {
331
+ const res = await Api.copilotAction.invokeAction({
332
+ copilot_action_name: action.copilot_action_name,
333
+ agent_names: formValue.value.agent_names,
334
+ parameters: cleanedParameters
335
+ })
336
+
337
+ if (res.data.success) {
338
+ message.success(res.data?.message || "Action invoked successfully")
339
+ emit('success')
340
+ } else {
341
+ message.warning(res.data?.message || "Failed to invoke action")
342
+ }
343
+ } catch (err: any) {
344
+ message.error(err.response?.data?.message || "Failed to invoke action")
345
+ } finally {
346
+ loading.value = false
347
+ }
348
+}
349
</script>
frontend/src/components/copilotAction/TechnologyIcon.vue
+26
-24
@@ -1,36 +1,38 @@
1
<template>
2
- <Icon :name="getTechnologyIcon(action.technology)" :size />
2
+ <Icon v-if="technology" :name="getTechnologyIcon(technology)" :size />
3
</template>
4
5
<script setup lang="ts">
6
-import type { CopilotAction } from "@/types/copilotAction.d"
6
import Icon from "@/components/common/Icon.vue"
7
import { iconFromOs } from "@/utils"
8
10
-const { action, size = 14 } = defineProps<{ action: CopilotAction; size?: number }>()
9
+const { technology, size = 14 } = defineProps<{
10
+ technology?: string
11
+ size?: number
12
+}>()
13
12
-function getTechnologyIcon(technology: string): string {
13
- if (
14
- technology.toLowerCase().includes("win") ||
15
- technology.toLowerCase().includes("lin") ||
16
- technology.toLowerCase().includes("mac")
17
- ) {
18
- return iconFromOs(technology)
19
- }
14
+function getTechnologyIcon(tech: string | undefined): string {
15
+ if (!tech) return "carbon:application"
16
21
- if (technology.toLowerCase().includes("wazuh")) {
22
- return "carbon:security"
23
- }
24
- if (technology.toLowerCase().includes("velociraptor")) {
25
- return "fluent-emoji-high-contrast:eagle"
26
- }
27
- if (technology.toLowerCase().includes("network")) {
28
- return "carbon:network-3"
29
- }
30
- if (technology.toLowerCase().includes("cloud")) {
31
- return "carbon:cloud"
32
- }
17
+ const techLower = tech.toLowerCase()
18
34
- return "carbon:application"
19
+ if (techLower.includes("win") || techLower.includes("lin") || techLower.includes("mac")) {
20
+ return iconFromOs(tech)
21
+ }
22
+
23
+ if (techLower.includes("wazuh")) {
24
+ return "carbon:security"
25
+ }
26
+ if (techLower.includes("velociraptor")) {
27
+ return "fluent-emoji-high-contrast:eagle"
28
+ }
29
+ if (techLower.includes("network")) {
30
+ return "carbon:network-3"
31
+ }
32
+ if (techLower.includes("cloud")) {
33
+ return "carbon:cloud"
34
+ }
35
+
36
+ return "carbon:application"
37
}
38
</script>