main
vue 521 lines 13.3 KB
Raw
1 <template>
2 <div class="artifacts-collect">
3 <div class="header flex items-start justify-end gap-2">
4 <div v-if="isDirty" class="info flex gap-5">
5 <n-popover overlap placement="bottom-start">
6 <template #trigger>
7 <div class="bg-default rounded-lg">
8 <n-button size="small" class="cursor-help!">
9 <template #icon>
10 <Icon :name="InfoIcon" />
11 </template>
12 </n-button>
13 </div>
14 </template>
15 <div class="flex flex-col gap-2">
16 <div class="box">
17 Total :
18 <code>{{ total }}</code>
19 </div>
20 </div>
21 </n-popover>
22 </div>
23 <div class="flex grow flex-wrap items-center justify-end gap-2">
24 <div v-if="!hideHostnameField" class="grow basis-56">
25 <n-select
26 v-model:value="filters.hostname"
27 :options="agentHostnameOptions"
28 placeholder="Agent hostname"
29 clearable
30 filterable
31 :disabled="loading"
32 size="small"
33 :loading="loadingAgents"
34 />
35 </div>
36 <div class="grow basis-56">
37 <n-select
38 v-model:value="filters.artifact_name"
39 :options="artifactsOptions"
40 placeholder="Artifact name"
41 clearable
42 :disabled="loading"
43 filterable
44 size="small"
45 :loading="loadingArtifacts"
46 @update:value="onArtifactSelect"
47 />
48 </div>
49 <div v-if="!hideVelociraptorIdField" class="grow basis-56">
50 <n-input
51 v-model:value="filters.velociraptor_id"
52 placeholder="Velociraptor ID"
53 clearable
54 :readonly="loading"
55 size="small"
56 />
57 </div>
58 <div>
59 <n-tooltip trigger="hover" placement="top">
60 <template #trigger>
61 <n-checkbox v-model:checked="filters.data_store_only" :disabled="loading" size="small">
62 Store Only
63 </n-checkbox>
64 </template>
65 <div class="text-xs">
66 Skip rendering results in frontend and only store the artifact in the data store.
67 <br />
68 This is faster for large collections.
69 </div>
70 </n-tooltip>
71 </div>
72 <div>
73 <n-button
74 size="small"
75 type="primary"
76 secondary
77 :loading
78 :disabled="!areFiltersValid"
79 @click="getData()"
80 >
81 Submit
82 </n-button>
83 </div>
84 </div>
85 </div>
86
87 <!-- Parameters Section -->
88 <div v-if="selectedArtifactParameters.length" class="parameters-section my-4">
89 <n-card title="Artifact Parameters" size="small">
90 <template #header-extra>
91 <n-tag size="small" type="info">
92 {{ selectedArtifactParameters.length }} parameter{{
93 selectedArtifactParameters.length !== 1 ? "s" : ""
94 }}
95 </n-tag>
96 </template>
97 <n-spin :show="loadingParameters">
98 <n-scrollbar style="max-height: 400px">
99 <div class="parameters-grid">
100 <div v-for="param in selectedArtifactParameters" :key="param.name" class="parameter-field">
101 <div class="parameter-header">
102 <div class="flex items-center gap-2">
103 <span class="parameter-name">{{ param.name }}</span>
104 <n-tag v-if="param.type" size="tiny" :bordered="false">
105 {{ param.type }}
106 </n-tag>
107 </div>
108 <n-popover v-if="param.description" trigger="hover" placement="top">
109 <template #trigger>
110 <Icon
111 :name="InfoIcon"
112 :size="16"
113 class="cursor-help text-gray-400 hover:text-gray-600"
114 />
115 </template>
116 <div class="parameter-tooltip">
117 <div class="mb-2 font-medium">{{ param.name }}</div>
118 <div class="text-sm">{{ param.description }}</div>
119 <div v-if="param.default" class="mt-2 text-xs opacity-70">
120 Default:
121 <code class="rounded bg-gray-100 px-1">{{ param.default }}</code>
122 </div>
123 </div>
124 </n-popover>
125 </div>
126 <n-input
127 v-model:value="parameterValues[param.name]"
128 :placeholder="param.default?.toString() || 'Enter value...'"
129 size="small"
130 clearable
131 class="mt-2"
132 >
133 <template v-if="param.default" #suffix>
134 <n-tooltip trigger="hover" placement="top">
135 <template #trigger>
136 <n-button
137 text
138 size="tiny"
139 @click="
140 parameterValues[param.name] = escapeBackslashes(
141 param.default?.toString() || ''
142 )
143 "
144 >
145 <Icon name="carbon:reset" :size="14" />
146 </n-button>
147 </template>
148 Reset to default
149 </n-tooltip>
150 </template>
151 </n-input>
152 <div v-if="param.description" class="parameter-description">
153 {{ param.description }}
154 </div>
155 </div>
156 </div>
157 </n-scrollbar>
158 </n-spin>
159 </n-card>
160 </div>
161
162 <n-spin :show="loading">
163 <div class="my-7 flex min-h-52 flex-col gap-3">
164 <template v-if="filters.data_store_only && isDirty">
165 <n-alert
166 type="success"
167 title="Artifact Stored Successfully"
168 class="item-appear item-appear-bottom item-appear-005"
169 >
170 The artifact has been collected and stored in the data store. Results were not retrieved to
171 improve performance.
172 </n-alert>
173 </template>
174 <template v-else-if="collectList.length">
175 <CollectItem
176 v-for="collect of collectList"
177 :key="`${collect.___id}`"
178 embedded
179 :collect
180 class="item-appear item-appear-bottom item-appear-005"
181 />
182 </template>
183 <template v-else>
184 <n-empty v-if="!loading" description="No items found" class="h-48 justify-center" />
185 </template>
186 </div>
187 </n-spin>
188 </div>
189 </template>
190
191 <script setup lang="ts">
192 // TODO-FE: refactor
193 import type { ArtifactsQuery, CollectRequest } from "@/api/endpoints/artifacts"
194 import type { Agent } from "@/types/agents.d"
195 import type { Artifact, ArtifactParameter, CollectResult } from "@/types/artifacts.d"
196 import {
197 NAlert,
198 NButton,
199 NCard,
200 NCheckbox,
201 NEmpty,
202 NInput,
203 NPopover,
204 NScrollbar,
205 NSelect,
206 NSpin,
207 NTag,
208 NTooltip,
209 useMessage
210 } from "naive-ui"
211 import { nanoid } from "nanoid"
212 import { computed, nextTick, onBeforeMount, ref, toRefs } from "vue"
213 import Api from "@/api"
214 import Icon from "@/components/common/Icon.vue"
215 import CollectItem from "./CollectItem.vue"
216
217 const props = defineProps<{
218 hostname?: string
219 velociraptorId?: string
220 agents?: Agent[]
221 artifacts?: Artifact[]
222 artifactsFilter?: ArtifactsQuery
223 hideHostnameField?: boolean
224 hideVelociraptorIdField?: boolean
225 }>()
226
227 const emit = defineEmits<{
228 (e: "loaded-agents", value: Agent[]): void
229 (e: "loaded-artifacts", value: Artifact[]): void
230 }>()
231
232 const { hostname, velociraptorId, agents, artifacts, artifactsFilter, hideHostnameField, hideVelociraptorIdField } =
233 toRefs(props)
234
235 const message = useMessage()
236 const loadingAgents = ref(false)
237 const loadingArtifacts = ref(false)
238 const loadingParameters = ref(false)
239 const loading = ref(false)
240 const agentsList = ref<Agent[]>([])
241 const artifactsList = ref<Artifact[]>([])
242 const collectList = ref<CollectResult[]>([])
243 const isDirty = ref(false)
244 const selectedArtifactParameters = ref<ArtifactParameter[]>([])
245 const parameterValues = ref<Record<string, string>>({})
246
247 const InfoIcon = "carbon:information"
248
249 const total = computed<number>(() => {
250 return collectList.value.length || 0
251 })
252
253 const filters = ref<Partial<CollectRequest>>({
254 data_store_only: false
255 })
256
257 const areFiltersValid = computed(() => {
258 return !!filters.value.artifact_name && !!filters.value.hostname
259 })
260
261 const agentHostnameOptions = computed(() => {
262 if (hostname?.value) {
263 return [{ value: hostname.value, label: hostname.value }]
264 }
265 return (agentsList.value || []).map(o => ({ value: o.hostname, label: o.hostname }))
266 })
267
268 const artifactsOptions = computed(() => {
269 return (artifactsList.value || []).map(o => ({ value: o.name, label: o.name }))
270 })
271
272 const WINDOWS_PATH_BACKSLASH_REGEX = /\\/g
273
274 // Function to escape backslashes in Windows paths
275 function escapeBackslashes(value: string): string {
276 // Only escape if it looks like a Windows path (contains backslashes)
277 if (value && typeof value === "string" && value.includes("\\")) {
278 // Replace single backslashes with double backslashes
279 return value.replace(WINDOWS_PATH_BACKSLASH_REGEX, "\\\\")
280 }
281 return value
282 }
283
284 async function onArtifactSelect(artifactName: string | null) {
285 selectedArtifactParameters.value = []
286 parameterValues.value = {}
287
288 if (!artifactName) {
289 return
290 }
291
292 loadingParameters.value = true
293
294 try {
295 const res = await Api.artifacts.getByName(artifactName)
296
297 if (res.data.success && res.data.artifacts?.length) {
298 const artifact = res.data.artifacts[0]
299
300 if (artifact?.parameters?.length) {
301 selectedArtifactParameters.value = artifact.parameters
302
303 // Initialize parameter values with defaults (with escaped backslashes)
304 artifact.parameters.forEach(param => {
305 if (param.default !== undefined && param.default !== null && param.default !== "") {
306 const defaultValue = param.default.toString()
307 parameterValues.value[param.name] = escapeBackslashes(defaultValue)
308 }
309 })
310 }
311 }
312 } catch (err: any) {
313 message.error(err.response?.data?.message || "Failed to load artifact parameters")
314 } finally {
315 loadingParameters.value = false
316 }
317 }
318
319 function getData() {
320 if (areFiltersValid.value) {
321 loading.value = true
322
323 // Build parameters object if any values are set
324 const parameters: CollectRequest["parameters"] = {
325 env: []
326 }
327
328 Object.entries(parameterValues.value).forEach(([key, value]) => {
329 if (value !== undefined && value !== null && value !== "") {
330 parameters.env?.push({ key, value })
331 }
332 })
333
334 const payload: CollectRequest = {
335 ...filters.value,
336 hostname: filters.value.hostname || "",
337 artifact_name: filters.value.artifact_name || "",
338 data_store_only: filters.value.data_store_only || false
339 }
340
341 // Only add parameters if there are any
342 if (parameters.env?.length && parameters.env.length > 0) {
343 payload.parameters = parameters
344 }
345
346 Api.artifacts
347 .collect(payload)
348 .then(res => {
349 if (res.data.success) {
350 isDirty.value = true
351
352 // If data_store_only, results will be null/empty
353 if (filters.value.data_store_only) {
354 collectList.value = []
355 message.success(res.data?.message || "Artifact stored successfully")
356 } else {
357 collectList.value = (res.data?.results || []).map(o => {
358 o.___id = nanoid()
359 return o
360 })
361 }
362 } else {
363 message.warning(res.data?.message || "An error occurred. Please try again later.")
364 }
365 })
366 .catch(err => {
367 collectList.value = []
368
369 message.error(err.response?.data?.message || "An error occurred. Please try again later.")
370 })
371 .finally(() => {
372 loading.value = false
373 })
374 }
375 }
376
377 function getAgents(cb?: (agents: Agent[]) => void) {
378 loadingAgents.value = true
379
380 Api.agents
381 .getAgents()
382 .then(res => {
383 if (res.data.success) {
384 agentsList.value = res.data.agents || []
385
386 if (cb && typeof cb === "function") {
387 cb(agentsList.value)
388 }
389 } else {
390 message.error(res.data?.message || "An error occurred. Please try again later.")
391 }
392 })
393 .catch(err => {
394 message.error(err.response?.data?.message || "An error occurred. Please try again later.")
395 })
396 .finally(() => {
397 loadingAgents.value = false
398 })
399 }
400
401 function getArtifacts(cb?: (artifacts: Artifact[]) => void) {
402 loadingArtifacts.value = true
403
404 Api.artifacts
405 .getAll(artifactsFilter.value)
406 .then(res => {
407 if (res.data.success) {
408 artifactsList.value = res.data.artifacts || []
409
410 if (cb && typeof cb === "function") {
411 cb(artifactsList.value)
412 }
413 } else {
414 message.error(res.data?.message || "An error occurred. Please try again later.")
415 }
416 })
417 .catch(err => {
418 message.error(err.response?.data?.message || "An error occurred. Please try again later.")
419 })
420 .finally(() => {
421 loadingArtifacts.value = false
422 })
423 }
424
425 onBeforeMount(() => {
426 if (hostname?.value) {
427 filters.value.hostname = hostname.value
428 }
429
430 if (velociraptorId?.value) {
431 filters.value.velociraptor_id = velociraptorId.value
432 }
433
434 if (agents?.value?.length && !agentsList.value.length) {
435 agentsList.value = agents.value
436 }
437
438 if (artifacts?.value?.length && !artifactsList.value.length) {
439 artifactsList.value = artifacts.value
440 }
441
442 nextTick(() => {
443 if (!agentsList.value.length && !hostname?.value) {
444 getAgents((agents: Agent[]) => {
445 emit("loaded-agents", agents)
446 })
447 }
448 if (!artifactsList.value.length) {
449 getArtifacts((artifacts: Artifact[]) => {
450 emit("loaded-artifacts", artifacts)
451 })
452 }
453 })
454 })
455 </script>
456
457 <style scoped>
458 .parameters-grid {
459 display: grid;
460 gap: 1rem;
461 padding: 0.5rem;
462 }
463
464 .parameter-field {
465 background-color: #f9fafb;
466 border: 1px solid #e5e7eb;
467 border-radius: 8px;
468 padding: 1rem;
469 transition: all 0.2s ease;
470 }
471
472 .parameter-field:hover {
473 border-color: #d1d5db;
474 box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
475 }
476
477 .parameter-header {
478 display: flex;
479 align-items: center;
480 justify-content: space-between;
481 margin-bottom: 0.5rem;
482 }
483
484 .parameter-name {
485 font-weight: 600;
486 font-size: 0.875rem;
487 color: #374151;
488 }
489
490 .parameter-description {
491 font-size: 0.75rem;
492 color: #6b7280;
493 margin-top: 0.5rem;
494 line-height: 1.4;
495 }
496
497 .parameter-tooltip {
498 max-width: 400px;
499 }
500
501 /* Dark mode support */
502 @media (prefers-color-scheme: dark) {
503 .parameter-field {
504 background-color: #1f2937;
505 border-color: #374151;
506 }
507
508 .parameter-field:hover {
509 border-color: #4b5563;
510 box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
511 }
512
513 .parameter-name {
514 color: #f3f4f6;
515 }
516
517 .parameter-description {
518 color: #9ca3af;
519 }
520 }
521 </style>