@cryptotaxi247 / CoPilot / commits / f0127d64

Copilot action frontend (#502)

* Add Copilot Actions feature with inventory management and action invocation * Refactor ActionCard layout for improved responsiveness and update pagination threshold * Enhance Parameters display with improved layout and styling for better readability * Update parameter card styling for improved visibility and hover effects * Refactor parameter card styling for improved UX and consistency * Enhance InvokeActionForm layout and styling for improved usability and visual clarity * Refactor pagination handling and remove unused variables for improved code clarity * Rename hostnames to agent_names in InvokeActionForm for clarity and consistency * Add structured response model for invoking copilot actions and update invoke_action endpoint * Enhance success messages in invoke_action response and InvokeActionForm for clarity and guidance on checking results in Grafana dashboard * Enhance action header styling in InvokeActionForm for improved readability and visual hierarchy * Enhance description styling in ActionCardContent for improved readability and visual appeal * Enhance description styling in ActionCard for improved readability and visual hierarchy * precommit fixes * Add info banner to List component with details on CoPilot Actions and Velociraptor integration

taylor_socfortress committed Sep 8, 2025 at 15:06 UTC f0127d6489928380245290d2855533b22a4c8897
12 files changed +1101 -4
backend/app/integrations/copilot_action/routes/copilot_action.py
+25 -4
@@ -23,6 +23,9 @@ from app.integrations.copilot_action.schema.copilot_action import (
23 InventoryMetricsResponse,
24 )
25 from app.integrations.copilot_action.schema.copilot_action import InventoryResponse
26 +from app.integrations.copilot_action.schema.copilot_action import (
27 + InvokeCopilotActionResponse,
28 +)
29 from app.integrations.copilot_action.schema.copilot_action import Technology
30 from app.integrations.copilot_action.services.copilot_action import CopilotActionService
31
@@ -334,11 +337,11 @@ async def invoke_action_on_agent(
337
338 @copilot_action_router.post(
339 "/invoke",
337 - response_model=List[CollectArtifactResponse], # Now returns a list of responses
340 + response_model=InvokeCopilotActionResponse, # Updated to use structured response
341 description="Invoke a Copilot Action on multiple target agents",
342 dependencies=[Security(AuthHandler().get_current_user, scopes=["admin"])],
343 )
341 -async def invoke_action(body: InvokeCopilotActionBody, session: AsyncSession = Depends(get_db)) -> List[CollectArtifactResponse]:
344 +async def invoke_action(body: InvokeCopilotActionBody, session: AsyncSession = Depends(get_db)) -> InvokeCopilotActionResponse:
345 """
346 Invoke a Copilot Action on multiple target agents.
347
@@ -352,7 +355,7 @@ async def invoke_action(body: InvokeCopilotActionBody, session: AsyncSession = D
355 session: Database session
356
357 Returns:
355 - List[CollectArtifactResponse]: List of responses from the artifact collections
358 + InvokeCopilotActionResponse: Structured response with list of results, message, and success status
359 """
360 logger.info(f"Invoking Copilot action '{body.copilot_action_name}' on {len(body.agent_names)} agents")
361
@@ -408,7 +411,25 @@ async def invoke_action(body: InvokeCopilotActionBody, session: AsyncSession = D
411 if failed_agents:
412 logger.warning(f"Failed agents: {failed_agents}")
413
411 - return responses
414 + # Return structured response
415 + if len(failed_agents) == 0:
416 + return InvokeCopilotActionResponse(
417 + responses=[response.dict() for response in responses],
418 + message=f"Successfully invoked action on all {len(successful_agents)} agent(s). Check the appropriate Grafana dashboard for results.",
419 + success=True,
420 + )
421 + elif len(successful_agents) == 0:
422 + return InvokeCopilotActionResponse(
423 + responses=[response.dict() for response in responses],
424 + message=f"Failed to invoke action on all {len(failed_agents)} agent(s)",
425 + success=False,
426 + )
427 + else:
428 + return InvokeCopilotActionResponse(
429 + responses=[response.dict() for response in responses],
430 + message=f"Partially successful: {len(successful_agents)} succeeded, {len(failed_agents)} failed",
431 + success=True, # Consider partial success as success
432 + )
433
434 except HTTPException:
435 # Re-raise HTTP exceptions (validation errors, not found, etc.)
backend/app/integrations/copilot_action/schema/copilot_action.py
+8
@@ -113,3 +113,11 @@ class InventoryMetricsResponse(BaseModel):
113 metrics: Dict[str, Any]
114 message: str = "Successfully retrieved inventory metrics"
115 success: bool = True
116 +
117 +
118 +class InvokeCopilotActionResponse(BaseModel):
119 + """Response model for invoking copilot actions"""
120 +
121 + responses: List[Dict[str, Any]] # List of CollectArtifactResponse-like objects
122 + message: str
123 + success: bool
frontend/src/api/endpoints/copilotAction.ts new
+86
@@ -0,0 +1,86 @@
1 +import type {
2 + ActionDetailResponse,
3 + InventoryMetricsResponse,
4 + InventoryResponse,
5 + InvokeCopilotActionRequest,
6 + InvokeCopilotActionResponse,
7 + TechnologiesResponse,
8 + Technology
9 +} from "@/types/copilotAction.d"
10 +import { HttpClient } from "../httpClient"
11 +
12 +export interface CopilotActionInventoryQuery {
13 + /** Filter by technology type */
14 + technology?: Technology
15 + /** Filter by category */
16 + category?: string
17 + /** Filter by tag */
18 + tag?: string
19 + /** Free-text search query */
20 + q?: string
21 + /** Maximum number of results */
22 + limit?: number
23 + /** Offset for pagination */
24 + offset?: number
25 + /** Force refresh cache */
26 + refresh?: boolean
27 + /** Comma-separated extra fields to include */
28 + include?: string
29 +}
30 +
31 +export default {
32 + /**
33 + * Get inventory of available active response scripts
34 + */
35 + getInventory(query?: CopilotActionInventoryQuery, signal?: AbortSignal) {
36 + return HttpClient.get<InventoryResponse>(`/copilot_action/inventory`, {
37 + params: {
38 + technology: query?.technology,
39 + category: query?.category,
40 + tag: query?.tag,
41 + q: query?.q,
42 + limit: query?.limit || 100,
43 + offset: query?.offset || 0,
44 + refresh: query?.refresh || false,
45 + include: query?.include
46 + },
47 + signal
48 + })
49 + },
50 +
51 + /**
52 + * Get details for a specific active response script
53 + */
54 + getActionByName(copilotActionName: string, signal?: AbortSignal) {
55 + return HttpClient.get<ActionDetailResponse>(`/copilot_action/inventory/${copilotActionName}`, {
56 + signal
57 + })
58 + },
59 +
60 + /**
61 + * Get inventory metrics and status
62 + */
63 + getMetrics(signal?: AbortSignal) {
64 + return HttpClient.get<InventoryMetricsResponse>(`/copilot_action/metrics`, {
65 + signal
66 + })
67 + },
68 +
69 + /**
70 + * Get available technology types
71 + */
72 + getTechnologies(signal?: AbortSignal) {
73 + return HttpClient.get<TechnologiesResponse>(`/copilot_action/technologies`, {
74 + signal
75 + })
76 + },
77 +
78 + /**
79 + * Invoke a Copilot Action on multiple target agents
80 + */
81 + invokeAction(payload: InvokeCopilotActionRequest, signal?: AbortSignal) {
82 + return HttpClient.post<InvokeCopilotActionResponse>(`/copilot_action/invoke`, payload, {
83 + signal
84 + })
85 + }
86 +}
frontend/src/api/index.ts
+2
@@ -6,6 +6,7 @@ import askSocfortress from "./endpoints/askSocfortress"
6 import auth from "./endpoints/auth"
7 import cloudSecurityAssessment from "./endpoints/cloudSecurityAssessment"
8 import connectors from "./endpoints/connectors"
9 +import copilotAction from "./endpoints/copilotAction"
10 import copilotMCP from "./endpoints/copilotMCP"
11 import customers from "./endpoints/customers"
12 import flow from "./endpoints/flow"
@@ -37,6 +38,7 @@ export default {
38 artifacts,
39 auth,
40 connectors,
41 + copilotAction,
42 graylog,
43 indices,
44 soc,
frontend/src/app-layouts/common/Navbar/items.tsx
+13
@@ -92,6 +92,19 @@ export default function getItems(): MenuMixedOption[] {
92 { default: () => "Detection Rules" }
93 ),
94 key: "DetectionRules"
95 + },
96 + {
97 + label: () =>
98 + h(
99 + RouterLink,
100 + {
101 + to: {
102 + name: "CopilotActions"
103 + }
104 + },
105 + { default: () => "CoPilot Actions" }
106 + ),
107 + key: "CopilotActions"
108 }
109 ]
110 },
frontend/src/components/copilotAction/ActionCard.vue new
+141
@@ -0,0 +1,141 @@
1 +<template>
2 + <div class="action-card h-full">
3 + <CardEntity hoverable clickable :embedded class="@container h-full flex flex-col" @click.stop="showDetails = true">
4 + <template #headerMain>{{ action.copilot_action_name }}</template>
5 + <template #headerExtra>
6 + <Badge :color="getTechnologyColor(action.technology)">
7 + <template #iconLeft><Icon :name="getTechnologyIcon(action.technology)" :size="14" /></template>
8 + <template #value>{{ action.technology }}</template>
9 + </Badge>
10 + </template>
11 + <template #default>
12 + <div class="flex-1">
13 + <p class="text-base font-medium opacity-90 leading-relaxed line-clamp-3">{{ action.description }}</p>
14 + </div>
15 + </template>
16 + <template #footerMain>
17 + <div class="flex flex-wrap items-center gap-2">
18 + <Badge v-if="action.category" class="text-xs">
19 + <template #value>{{ action.category }}</template>
20 + </Badge>
21 +
22 + <Badge v-if="action.version" color="primary" type="splitted" class="text-xs">
23 + <template #label>v</template>
24 + <template #value>{{ action.version }}</template>
25 + </Badge>
26 +
27 + <Badge v-if="action.script_parameters.length > 0" color="warning" type="splitted" class="text-xs">
28 + <template #label>params</template>
29 + <template #value>{{ action.script_parameters.length }}</template>
30 + </Badge>
31 +
32 + <div v-if="action.tags && action.tags.length > 0" class="flex gap-1">
33 + <Badge v-for="tag of action.tags.slice(0, 2)" :key="tag" size="small" class="text-xs">
34 + <template #value>{{ tag }}</template>
35 + </Badge>
36 + <Badge v-if="action.tags.length > 2" size="small" class="text-xs">
37 + <template #value>+{{ action.tags.length - 2 }}</template>
38 + </Badge>
39 + </div>
40 + </div>
41 + </template>
42 + <template #footerExtra>
43 + <n-button size="small" type="primary" secondary @click.stop="showInvokeModal = true">
44 + <template #icon>
45 + <Icon :name="PlayIcon"></Icon>
46 + </template>
47 + Invoke
48 + </n-button>
49 + </template>
50 + </CardEntity>
51 +
52 + <!-- Action Details Modal -->
53 + <n-modal
54 + v-model:show="showDetails"
55 + preset="card"
56 + :style="{ maxWidth: 'min(800px, 90vw)', minHeight: 'min(600px, 90vh)', overflow: 'hidden' }"
57 + :title="`CoPilot Action: ${action.copilot_action_name}`"
58 + :bordered="false"
59 + segmented
60 + >
61 + <ActionCardContent :action="action" />
62 + </n-modal>
63 +
64 + <!-- Invoke Action Modal -->
65 + <n-modal
66 + v-model:show="showInvokeModal"
67 + preset="card"
68 + :style="{ maxWidth: 'min(600px, 90vw)' }"
69 + :title="`Invoke: ${action.copilot_action_name}`"
70 + :bordered="false"
71 + segmented
72 + >
73 + <InvokeActionForm :action="action" @success="handleInvokeSuccess" @close="showInvokeModal = false" />
74 + </n-modal>
75 + </div>
76 +</template>
77 +
78 +<script setup lang="ts">
79 +import type { ActiveResponseItem } from "@/types/copilotAction.d"
80 +import { NButton, NModal, useMessage } from "naive-ui"
81 +import { ref } from "vue"
82 +import Badge from "@/components/common/Badge.vue"
83 +import CardEntity from "@/components/common/cards/CardEntity.vue"
84 +import Icon from "@/components/common/Icon.vue"
85 +import { Technology } from "@/types/copilotAction.d"
86 +import ActionCardContent from "./ActionCardContent.vue"
87 +import InvokeActionForm from "./InvokeActionForm.vue"
88 +
89 +const { action } = defineProps<{ action: ActiveResponseItem; embedded?: boolean }>()
90 +
91 +const showDetails = ref(false)
92 +const showInvokeModal = ref(false)
93 +const message = useMessage()
94 +const PlayIcon = "carbon:play"
95 +
96 +function getTechnologyIcon(technology: string): string {
97 + const iconMap: Record<string, string> = {
98 + [Technology.WINDOWS]: "carbon:logo-windows",
99 + [Technology.LINUX]: "carbon:logo-linux",
100 + [Technology.MACOS]: "carbon:logo-apple",
101 + [Technology.WAZUH]: "carbon:security",
102 + [Technology.VELOCIRAPTOR]: "carbon:eagle",
103 + [Technology.NETWORK]: "carbon:network-3",
104 + [Technology.CLOUD]: "carbon:cloud"
105 + }
106 + return iconMap[technology] || "carbon:application"
107 +}
108 +
109 +function getTechnologyColor(technology: string): "primary" | "warning" | "success" | "danger" | undefined {
110 + const colorMap: Record<string, "primary" | "warning" | "success" | "danger"> = {
111 + [Technology.WINDOWS]: "primary",
112 + [Technology.LINUX]: "warning",
113 + [Technology.MACOS]: "success",
114 + [Technology.WAZUH]: "success",
115 + [Technology.VELOCIRAPTOR]: "primary",
116 + [Technology.NETWORK]: "primary",
117 + [Technology.CLOUD]: "success"
118 + }
119 + return colorMap[technology]
120 +}
121 +
122 +function handleInvokeSuccess() {
123 + showInvokeModal.value = false
124 + message.success("Action invoked successfully!")
125 +}
126 +</script>
127 +
128 +<style scoped>
129 +.action-card {
130 + min-height: 200px;
131 +}
132 +
133 +.line-clamp-3 {
134 + display: -webkit-box;
135 + -webkit-line-clamp: 3;
136 + line-clamp: 3;
137 + -webkit-box-orient: vertical;
138 + overflow: hidden;
139 + text-overflow: ellipsis;
140 +}
141 +</style>
frontend/src/components/copilotAction/ActionCardContent.vue new
+181
@@ -0,0 +1,181 @@
1 +<template>
2 + <div class="action-details">
3 + <n-spin :show="loading" class="min-h-48">
4 + <template v-if="action">
5 + <div class="flex flex-col gap-4">
6 + <!-- Basic Information -->
7 + <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
8 + <div class="flex flex-col gap-2">
9 + <h3 class="text-lg font-semibold">Information</h3>
10 + <div class="flex flex-col gap-1">
11 + <div><strong>Technology:</strong> {{ action.technology }}</div>
12 + <div v-if="action.category"><strong>Category:</strong> {{ action.category }}</div>
13 + <div v-if="action.version"><strong>Version:</strong> {{ action.version }}</div>
14 + <div v-if="action.last_updated"><strong>Last Updated:</strong> {{ formatDate(action.last_updated) }}</div>
15 + </div>
16 + </div>
17 + <div class="flex flex-col gap-2">
18 + <h3 class="text-lg font-semibold">Script Details</h3>
19 + <div class="flex flex-col gap-1">
20 + <div v-if="action.script_name"><strong>Script Name:</strong> {{ action.script_name }}</div>
21 + <div>
22 + <strong>Repository:</strong>
23 + <a :href="action.repo_url" target="_blank" class="text-blue-500 hover:underline">
24 + {{ action.repo_url }}
25 + </a>
26 + </div>
27 + </div>
28 + </div>
29 + </div>
30 +
31 + <!-- Description -->
32 + <div class="flex flex-col gap-2">
33 + <h3 class="text-lg font-semibold">Description</h3>
34 + <p class="text-base font-medium opacity-90 leading-relaxed">{{ action.description }}</p>
35 + </div>
36 +
37 + <!-- Tags -->
38 + <div v-if="action.tags && action.tags.length > 0" class="flex flex-col gap-2">
39 + <h3 class="text-lg font-semibold">Tags</h3>
40 + <div class="flex flex-wrap gap-2">
41 + <Badge v-for="tag of action.tags" :key="tag">
42 + <template #value>{{ tag }}</template>
43 + </Badge>
44 + </div>
45 + </div>
46 +
47 + <!-- Parameters -->
48 + <div v-if="action.script_parameters.length > 0" class="flex flex-col gap-4">
49 + <h3 class="text-lg font-semibold">Parameters</h3>
50 + <div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
51 + <div
52 + v-for="param in action.script_parameters"
53 + :key="param.name"
54 + class="parameter-card border rounded-lg p-4 hover:shadow-sm transition-shadow"
55 + >
56 + <div class="flex items-start justify-between mb-2">
57 + <div class="flex items-center gap-2">
58 + <h4 class="font-mono text-sm font-semibold">{{ param.name }}</h4>
59 + <Badge :color="param.required ? 'danger' : 'success'" size="small">
60 + <template #value>{{ param.required ? 'Required' : 'Optional' }}</template>
61 + </Badge>
62 + </div>
63 + <Badge color="primary" size="small">
64 + <template #value>{{ param.type }}</template>
65 + </Badge>
66 + </div>
67 +
68 + <div v-if="param.description" class="text-sm opacity-75 mb-3">
69 + {{ param.description }}
70 + </div>
71 +
72 + <div class="flex flex-col gap-1">
73 + <div v-if="param.default !== null && param.default !== undefined" class="text-xs opacity-60">
74 + <span class="font-medium">Default:</span>
75 + <code class="code-block px-1 py-0.5 rounded text-xs ml-1">{{ param.default }}</code>
76 + </div>
77 +
78 + <div v-if="param.enum && param.enum.length > 0" class="text-xs opacity-60">
79 + <span class="font-medium">Options:</span>
80 + <div class="flex flex-wrap gap-1 mt-1">
81 + <code
82 + v-for="option in param.enum"
83 + :key="option"
84 + class="enum-option px-1 py-0.5 rounded text-xs"
85 + >
86 + {{ option }}
87 + </code>
88 + </div>
89 + </div>
90 +
91 + <div v-if="param.arg_position" class="text-xs opacity-60">
92 + <span class="font-medium">Position:</span> {{ param.arg_position }}
93 + </div>
94 + </div>
95 + </div>
96 + </div>
97 + </div>
98 + </div>
99 + </template>
100 + <template v-else>
101 + <n-empty v-if="!loading" description="No action details found" class="h-48 justify-center" />
102 + </template>
103 + </n-spin>
104 + </div>
105 +</template>
106 +
107 +<script setup lang="ts">
108 +import type { ActiveResponseItem } from "@/types/copilotAction.d"
109 +import { NEmpty, NSpin } from "naive-ui"
110 +import { ref } from "vue"
111 +import Badge from "@/components/common/Badge.vue"
112 +
113 +const { action } = defineProps<{
114 + action: ActiveResponseItem
115 +}>()
116 +
117 +const loading = ref(false)
118 +
119 +function formatDate(date: Date): string {
120 + return new Date(date).toLocaleDateString()
121 +}
122 +</script>
123 +
124 +<style scoped>
125 +.action-details {
126 + max-height: 70vh;
127 + overflow-y: auto;
128 +}
129 +
130 +/* Custom scrollbar for better UX */
131 +.action-details::-webkit-scrollbar {
132 + width: 6px;
133 +}
134 +
135 +.action-details::-webkit-scrollbar-track {
136 + background: var(--border-color);
137 + border-radius: 3px;
138 +}
139 +
140 +.action-details::-webkit-scrollbar-thumb {
141 + background: var(--text-color-3);
142 + border-radius: 3px;
143 +}
144 +
145 +.action-details::-webkit-scrollbar-thumb:hover {
146 + background: var(--text-color-2);
147 +}
148 +
149 +/* Parameter cards */
150 +.parameter-card {
151 + background-color: var(--card-color);
152 + border-color: var(--border-color);
153 + transition: all 0.2s ease;
154 +}
155 +
156 +.parameter-card:hover {
157 + background-color: var(--hover-color);
158 + border-color: var(--border-color-hover);
159 + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
160 +}
161 +
162 +/* Code blocks */
163 +.code-block {
164 + background-color: var(--code-color);
165 + color: var(--text-color-1);
166 + border: 1px solid var(--border-color);
167 +}
168 +
169 +.enum-option {
170 + background-color: var(--primary-color-hover);
171 + color: var(--primary-color);
172 + border: 1px solid var(--primary-color-hover);
173 +}
174 +
175 +/* Dark mode adjustments */
176 +@media (prefers-color-scheme: dark) {
177 + .parameter-card:hover {
178 + box-shadow: 0 1px 3px 0 rgba(255, 255, 255, 0.1), 0 1px 2px 0 rgba(255, 255, 255, 0.06);
179 + }
180 +}
181 +</style>
frontend/src/components/copilotAction/InvokeActionForm.vue new
+374
@@ -0,0 +1,374 @@
1 +<template>
2 + <div class="invoke-action-form">
3 + <n-spin :show="loading">
4 + <div class="flex flex-col gap-6">
5 + <!-- Action Information -->
6 + <div class="action-header p-4 rounded-lg border">
7 + <div class="flex items-start justify-between mb-3">
8 + <div class="flex-1">
9 + <h4 class="text-lg font-semibold mb-2">{{ action.copilot_action_name }}</h4>
10 + <p class="text-base font-medium opacity-90 leading-relaxed">{{ action.description }}</p>
11 + </div>
12 + <Badge :color="getTechnologyColor(action.technology)" class="ml-3">
13 + <template #iconLeft><Icon :name="getTechnologyIcon(action.technology)" :size="14" /></template>
14 + <template #value>{{ action.technology }}</template>
15 + </Badge>
16 + </div>
17 + </div>
18 +
19 + <!-- Target Agents Selection -->
20 + <div class="form-section">
21 + <div class="section-header mb-3">
22 + <h5 class="font-semibold text-base">Target Agents</h5>
23 + <span class="required-indicator">*</span>
24 + </div>
25 + <n-select
26 + v-model:value="form.agent_names"
27 + :options="agentOptions"
28 + multiple
29 + filterable
30 + placeholder="Select target agents..."
31 + :loading="loadingAgents"
32 + clearable
33 + size="large"
34 + class="mb-2"
35 + />
36 + <p class="helper-text">Select one or more agents to run this action on</p>
37 + </div>
38 +
39 + <!-- Parameters Form -->
40 + <div v-if="action.script_parameters.length > 0" class="form-section">
41 + <div class="section-header mb-4">
42 + <h5 class="font-semibold text-base">Parameters</h5>
43 + </div>
44 +
45 + <!-- Required Parameters -->
46 + <div v-if="requiredParameters.length > 0" class="parameter-group mb-6">
47 + <div class="parameter-group-header mb-4">
48 + <h6 class="text-sm font-medium opacity-90">Required Parameters</h6>
49 + <div class="parameter-group-line"></div>
50 + </div>
51 + <div class="grid grid-cols-1 gap-4">
52 + <div v-for="param in requiredParameters" :key="param.name" class="parameter-field">
53 + <div class="parameter-label mb-2">
54 + <label class="font-medium text-sm">
55 + {{ param.name }}
56 + <span class="required-indicator">*</span>
57 + </label>
58 + <Badge v-if="param.type" color="primary" size="small" class="ml-2">
59 + <template #value>{{ param.type }}</template>
60 + </Badge>
61 + </div>
62 + <component
63 + :is="getInputComponent(param.type)"
64 + v-model:value="form.parameters[param.name]"
65 + :placeholder="getPlaceholder(param)"
66 + :options="param.enum?.map(e => ({ label: e, value: e }))"
67 + clearable
68 + size="large"
69 + class="mb-1"
70 + />
71 + <p v-if="param.description" class="helper-text">{{ param.description }}</p>
72 + </div>
73 + </div>
74 + </div>
75 +
76 + <!-- Optional Parameters -->
77 + <div v-if="optionalParameters.length > 0" class="parameter-group">
78 + <div class="parameter-group-header mb-4">
79 + <h6 class="text-sm font-medium opacity-90">Optional Parameters</h6>
80 + <div class="parameter-group-line"></div>
81 + </div>
82 + <div class="grid grid-cols-1 gap-4">
83 + <div v-for="param in optionalParameters" :key="param.name" class="parameter-field">
84 + <div class="parameter-label mb-2">
85 + <label class="font-medium text-sm">{{ param.name }}</label>
86 + <Badge v-if="param.type" color="primary" size="small" class="ml-2">
87 + <template #value>{{ param.type }}</template>
88 + </Badge>
89 + </div>
90 + <component
91 + :is="getInputComponent(param.type)"
92 + v-model:value="form.parameters[param.name]"
93 + :placeholder="getPlaceholder(param)"
94 + :options="param.enum?.map(e => ({ label: e, value: e }))"
95 + clearable
96 + size="large"
97 + class="mb-1"
98 + />
99 + <p v-if="param.description" class="helper-text">{{ param.description }}</p>
100 + </div>
101 + </div>
102 + </div>
103 + </div> <!-- Action Buttons -->
104 + <div class="action-buttons flex justify-end gap-3 pt-6 mt-6 border-t border-opacity-20">
105 + <n-button size="large" class="px-6" @click="$emit('close')">
106 + Cancel
107 + </n-button>
108 + <n-button
109 + type="primary"
110 + size="large"
111 + class="px-8"
112 + :loading="loading"
113 + :disabled="!isFormValid"
114 + @click="handleSubmit"
115 + >
116 + <template v-if="!loading" #icon>
117 + <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
118 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
119 + </svg>
120 + </template>
121 + {{ loading ? 'Invoking...' : 'Invoke Action' }}
122 + </n-button>
123 + </div>
124 + </div>
125 + </n-spin>
126 + </div>
127 +</template>
128 +
129 +<script setup lang="ts">
130 +import type { ActiveResponseItem, InvokeCopilotActionRequest, ScriptParameter } from "@/types/copilotAction.d"
131 +import { NButton, NInput, NInputNumber, NSelect, NSpin, NSwitch, useMessage } from "naive-ui"
132 +import { computed, onMounted, ref } from "vue"
133 +import Api from "@/api"
134 +import Badge from "@/components/common/Badge.vue"
135 +import Icon from "@/components/common/Icon.vue"
136 +import { Technology } from "@/types/copilotAction.d"
137 +
138 +const { action } = defineProps<{
139 + action: ActiveResponseItem
140 +}>()
141 +
142 +const emit = defineEmits<{
143 + success: []
144 + close: []
145 +}>()
146 +
147 +const message = useMessage()
148 +const loading = ref(false)
149 +const loadingAgents = ref(false)
150 +
151 +const agentOptions = ref<{ label: string; value: string }[]>([])
152 +
153 +const form = ref<{
154 + agent_names: string[]
155 + parameters: Record<string, any>
156 +}>({
157 + agent_names: [],
158 + parameters: {}
159 +})
160 +
161 +// Separate required and optional parameters
162 +const requiredParameters = computed(() => action.script_parameters.filter(p => p.required))
163 +const optionalParameters = computed(() => action.script_parameters.filter(p => !p.required))
164 +
165 +const isFormValid = computed(() => {
166 + if (form.value.agent_names.length === 0) return false
167 +
168 + // Check all required parameters are filled
169 + for (const param of requiredParameters.value) {
170 + const value = form.value.parameters[param.name]
171 + if (value === null || value === undefined || value === '') {
172 + return false
173 + }
174 + }
175 +
176 + return true
177 +})
178 +
179 +function getInputComponent(type: string) {
180 + switch (type.toLowerCase()) {
181 + case 'int':
182 + case 'integer':
183 + case 'float':
184 + case 'number':
185 + return NInputNumber
186 + case 'bool':
187 + case 'boolean':
188 + return NSwitch
189 + case 'enum':
190 + return NSelect
191 + default:
192 + return NInput
193 + }
194 +}
195 +
196 +function getPlaceholder(param: ScriptParameter): string {
197 + if (param.default !== null && param.default !== undefined) {
198 + return `Default: ${param.default}`
199 + }
200 + return `Enter ${param.name}...`
201 +}
202 +
203 +function getTechnologyIcon(technology: string): string {
204 + const iconMap: Record<string, string> = {
205 + [Technology.WINDOWS]: "carbon:logo-windows",
206 + [Technology.LINUX]: "carbon:logo-linux",
207 + [Technology.MACOS]: "carbon:logo-apple",
208 + [Technology.WAZUH]: "carbon:security",
209 + [Technology.VELOCIRAPTOR]: "carbon:eagle",
210 + [Technology.NETWORK]: "carbon:network-3",
211 + [Technology.CLOUD]: "carbon:cloud"
212 + }
213 + return iconMap[technology] || "carbon:application"
214 +}
215 +
216 +function getTechnologyColor(technology: string): "primary" | "warning" | "success" | "danger" | undefined {
217 + const colorMap: Record<string, "primary" | "warning" | "success" | "danger"> = {
218 + [Technology.WINDOWS]: "primary",
219 + [Technology.LINUX]: "warning",
220 + [Technology.MACOS]: "success",
221 + [Technology.WAZUH]: "success",
222 + [Technology.VELOCIRAPTOR]: "primary",
223 + [Technology.NETWORK]: "primary",
224 + [Technology.CLOUD]: "success"
225 + }
226 + return colorMap[technology]
227 +}
228 +
229 +async function loadAgents() {
230 + loadingAgents.value = true
231 + try {
232 + const response = await Api.agents.getAgents()
233 + if (response.data.success) {
234 + agentOptions.value = response.data.agents.map(agent => ({
235 + label: `${agent.hostname} (${agent.ip_address})`,
236 + value: agent.hostname
237 + }))
238 + } else {
239 + message.error('Failed to load agents')
240 + }
241 + } catch {
242 + message.error('Error loading agents')
243 + } finally {
244 + loadingAgents.value = false
245 + }
246 +}
247 +
248 +async function handleSubmit() {
249 + if (!isFormValid.value) return
250 +
251 + loading.value = true
252 + try {
253 + // Prepare the payload
254 + const payload: InvokeCopilotActionRequest = {
255 + copilot_action_name: action.copilot_action_name,
256 + agent_names: form.value.agent_names,
257 + parameters: {
258 + RepoURL: action.repo_url,
259 + ScriptName: action.script_name || action.copilot_action_name,
260 + ...form.value.parameters
261 + }
262 + }
263 +
264 + const response = await Api.copilotAction.invokeAction(payload)
265 +
266 + if (response.data.success) {
267 + message.success(`Action invoked successfully on ${form.value.agent_names.length} agent(s). Check the appropriate Grafana dashboard for results.`)
268 + emit('success')
269 + } else {
270 + message.error(response.data.message || 'Failed to invoke action')
271 + }
272 + } catch (error: any) {
273 + message.error(error.response?.data?.message || 'Error invoking action')
274 + } finally {
275 + loading.value = false
276 + }
277 +}
278 +
279 +// Initialize form with default values
280 +function initializeForm() {
281 + const parameters: Record<string, any> = {}
282 +
283 + action.script_parameters.forEach(param => {
284 + if (param.default !== null && param.default !== undefined) {
285 + parameters[param.name] = param.default
286 + }
287 + })
288 +
289 + form.value.parameters = parameters
290 +}
291 +
292 +onMounted(() => {
293 + loadAgents()
294 + initializeForm()
295 +})
296 +</script>
297 +
298 +<style scoped>
299 +.invoke-action-form {
300 + max-height: 70vh;
301 + overflow-y: auto;
302 +}
303 +
304 +/* Form section styling */
305 +.form-section {
306 + padding: 1rem;
307 + border: 1px solid var(--border-color);
308 + border-radius: 8px;
309 +}
310 +
311 +.section-header {
312 + display: flex;
313 + align-items: center;
314 + gap: 0.5rem;
315 +}
316 +
317 +.required-indicator {
318 + color: #f56565;
319 + font-weight: 600;
320 +}
321 +
322 +/* Parameter groups */
323 +.parameter-group-header {
324 + display: flex;
325 + align-items: center;
326 + gap: 0.75rem;
327 +}
328 +
329 +.parameter-group-line {
330 + flex: 1;
331 + height: 1px;
332 + background: linear-gradient(to right, var(--border-color) 0%, transparent 100%);
333 +}
334 +
335 +.parameter-field {
336 + position: relative;
337 +}
338 +
339 +.parameter-label {
340 + display: flex;
341 + align-items: center;
342 + gap: 0.5rem;
343 +}
344 +
345 +.helper-text {
346 + font-size: 0.875rem;
347 + opacity: 0.7;
348 + margin-top: 0.25rem;
349 +}
350 +
351 +/* Action header styling */
352 +.action-header {
353 + border-color: var(--border-color);
354 +}
355 +
356 +/* Action buttons styling */
357 +.action-buttons {
358 + border-color: var(--border-color);
359 +}
360 +
361 +/* CSS Variables for theme support */
362 +.light-theme {
363 + --border-color: rgba(0, 0, 0, 0.1);
364 +}
365 +
366 +.dark-theme {
367 + --border-color: rgba(255, 255, 255, 0.1);
368 +}
369 +
370 +/* Default fallback */
371 +:not(.light-theme):not(.dark-theme) {
372 + --border-color: rgba(0, 0, 0, 0.1);
373 +}
374 +</style>
frontend/src/components/copilotAction/List.vue new
+168
@@ -0,0 +1,168 @@
1 +<template>
2 + <div class="flex flex-col gap-4">
3 + <!-- Info Banner -->
4 + <div class="info-banner p-3 rounded-lg border border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-950/30">
5 + <div class="flex items-start gap-3">
6 + <Icon :name="InfoIcon" class="text-blue-600 dark:text-blue-400 mt-0.5" :size="16" />
7 + <p class="text-sm text-blue-800 dark:text-blue-200 leading-relaxed">
8 + CoPilot Actions leverages Velociraptor to run actions and Grafana to view results. See
9 + <a
10 + href="https://github.com/socfortress/CoPilot-Action"
11 + target="_blank"
12 + class="underline hover:no-underline font-medium"
13 + >
14 + https://github.com/socfortress/CoPilot-Action
15 + </a>
16 + for details.
17 + </p>
18 + </div>
19 + </div>
20 +
21 + <div class="flex flex-col">
22 + <div ref="header" class="header flex items-center justify-end gap-2">
23 + <div class="info flex grow gap-2">
24 + <n-popover overlap placement="bottom-start">
25 + <template #trigger>
26 + <div class="bg-default rounded-lg">
27 + <n-button size="small" class="!cursor-help">
28 + <template #icon>
29 + <Icon :name="InfoIcon"></Icon>
30 + </template>
31 + </n-button>
32 + </div>
33 + </template>
34 + <div class="flex flex-col gap-2">
35 + <div class="box">
36 + Total Actions:
37 + <code>{{ total }}</code>
38 + </div>
39 + </div>
40 + </n-popover>
41 +
42 + <n-select
43 + v-model:value="selectedTechnology"
44 + :options="technologyOptions"
45 + clearable
46 + size="small"
47 + placeholder="Technology"
48 + class="max-w-32"
49 + />
50 +
51 + <n-select
52 + v-model:value="selectedCategory"
53 + :options="categoryOptions"
54 + clearable
55 + size="small"
56 + placeholder="Category"
57 + class="max-w-32"
58 + />
59 +
60 + <n-input
61 + v-model:value="searchQuery"
62 + size="small"
63 + placeholder="Search actions..."
64 + class="max-w-48"
65 + clearable
66 + >
67 + <template #prefix>
68 + <Icon :name="SearchIcon"></Icon>
69 + </template>
70 + </n-input>
71 + </div>
72 + </div>
73 +
74 + <n-spin :show="loading">
75 + <div class="my-3">
76 + <template v-if="list.length">
77 + <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
78 + <ActionCard v-for="item of list" :key="item.copilot_action_name" :action="item" />
79 + </div>
80 + </template>
81 + <template v-else>
82 + <n-empty v-if="!loading" description="No actions found" class="h-48 justify-center" />
83 + </template>
84 + </div>
85 + </n-spin>
86 + </div>
87 + </div>
88 +</template>
89 +
90 +<script setup lang="ts">
91 +import type { CopilotActionInventoryQuery } from "@/api/endpoints/copilotAction"
92 +import type { ActiveResponseItem } from "@/types/copilotAction.d"
93 +import { watchDebounced } from "@vueuse/core"
94 +import axios from "axios"
95 +import { NButton, NEmpty, NInput, NPopover, NSelect, NSpin, useMessage } from "naive-ui"
96 +import { computed, ref } from "vue"
97 +import Api from "@/api"
98 +import Icon from "@/components/common/Icon.vue"
99 +import { Technology } from "@/types/copilotAction.d"
100 +import ActionCard from "./ActionCard.vue"
101 +
102 +const loading = ref(false)
103 +const message = useMessage()
104 +const list = ref<ActiveResponseItem[]>([])
105 +const header = ref()
106 +const total = ref(0)
107 +const selectedTechnology = ref<Technology | null>(null)
108 +const selectedCategory = ref<string | null>(null)
109 +const searchQuery = ref<string>("")
110 +const InfoIcon = "carbon:information"
111 +const SearchIcon = "carbon:search"
112 +
113 +const technologyOptions = Object.values(Technology).map(tech => ({
114 + label: tech,
115 + value: tech
116 +}))
117 +
118 +// Get unique categories from the loaded actions
119 +const categoryOptions = computed(() => {
120 + const categories = [...new Set(list.value.map(action => action.category).filter(Boolean))]
121 + return categories.map(category => ({
122 + label: category,
123 + value: category
124 + }))
125 +})
126 +
127 +let abortController: AbortController | null = null
128 +
129 +function getList() {
130 + abortController?.abort()
131 + abortController = new AbortController()
132 +
133 + loading.value = true
134 +
135 + const query: CopilotActionInventoryQuery = {
136 + limit: 100,
137 + offset: 0,
138 + technology: selectedTechnology.value || undefined,
139 + category: selectedCategory.value || undefined,
140 + q: searchQuery.value || undefined
141 + }
142 +
143 + Api.copilotAction
144 + .getInventory(query, abortController.signal)
145 + .then(res => {
146 + loading.value = false
147 +
148 + if (res.data.success) {
149 + list.value = res.data?.copilot_actions || []
150 + total.value = res.data?.copilot_actions?.length || 0
151 + } else {
152 + message.warning(res.data?.message || "An error occurred. Please try again later.")
153 + }
154 + })
155 + .catch(err => {
156 + if (!axios.isCancel(err)) {
157 + message.error(err.response?.data?.message || "An error occurred. Please try again later.")
158 + loading.value = false
159 + }
160 + })
161 +}
162 +
163 +watchDebounced([selectedTechnology, selectedCategory, searchQuery], getList, {
164 + deep: true,
165 + debounce: 300,
166 + immediate: true
167 +})
168 +</script>
frontend/src/router/index.ts
+6
@@ -61,6 +61,12 @@ const router = createRouter({
61 name: "DetectionRules",
62 component: () => import("@/views/agents/DetectionRules.vue"),
63 meta: { title: "Detection Rules" }
64 + },
65 + {
66 + path: "copilot-actions",
67 + name: "CopilotActions",
68 + component: () => import("@/views/agents/CopilotActions.vue"),
69 + meta: { title: "CoPilot Actions" }
70 }
71 ]
72 },
frontend/src/types/copilotAction.d.ts new
+88
@@ -0,0 +1,88 @@
1 +export enum Technology {
2 + WAZUH = "Wazuh",
3 + LINUX = "Linux",
4 + WINDOWS = "Windows",
5 + MACOS = "macOS",
6 + NETWORK = "Network",
7 + CLOUD = "Cloud",
8 + VELOCIRAPTOR = "Velociraptor"
9 +}
10 +
11 +export interface ScriptParameter {
12 + name: string
13 + type: string
14 + required: boolean
15 + description?: string
16 + default?: string | number | boolean | Array<any> | Record<string, any>
17 + enum?: string[]
18 + arg_position?: string
19 +}
20 +
21 +export interface ActiveResponseItem {
22 + copilot_action_name: string
23 + description: string
24 + technology: Technology
25 + icon?: string
26 + script_parameters: ScriptParameter[]
27 + repo_url: string
28 + script_name?: string
29 + version?: string
30 + last_updated?: Date
31 + category?: string
32 + tags?: string[]
33 +}
34 +
35 +export interface InventoryQueryRequest {
36 + technology?: Technology
37 + category?: string
38 + tag?: string
39 + q?: string
40 + limit?: number
41 + offset?: number
42 + refresh?: boolean
43 + include?: string
44 +}
45 +
46 +export interface InventoryResponse {
47 + copilot_actions: ActiveResponseItem[]
48 + message: string
49 + success: boolean
50 +}
51 +
52 +export interface ActionDetailResponse {
53 + copilot_action: ActiveResponseItem
54 + message: string
55 + success: boolean
56 +}
57 +
58 +export interface InventoryMetricsResponse {
59 + status: string
60 + metrics: Record<string, any>
61 + message: string
62 + success: boolean
63 +}
64 +
65 +export interface InvokeCopilotActionRequest {
66 + copilot_action_name: string
67 + agent_names: string[]
68 + parameters: Record<string, any>
69 +}
70 +
71 +export interface CollectArtifactResponse {
72 + message: string
73 + success: boolean
74 + session_id?: string
75 + flow_id?: string
76 +}
77 +
78 +export interface InvokeCopilotActionResponse {
79 + responses: CollectArtifactResponse[]
80 + message: string
81 + success: boolean
82 +}
83 +
84 +export interface TechnologiesResponse {
85 + technologies: Technology[]
86 + message: string
87 + success: boolean
88 +}
frontend/src/views/agents/CopilotActions.vue new
+9
@@ -0,0 +1,9 @@
1 +<template>
2 + <div class="page">
3 + <List />
4 + </div>
5 +</template>
6 +
7 +<script setup lang="ts">
8 +import List from "@/components/copilotAction/List.vue"
9 +</script>