@cryptotaxi247 / CoPilot / commits / c32505a3

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>