feat: enhance customer agents tab with sorting and export (#661)
- Sort agents alphabetically by hostname for easier navigation - Open agent details in new tab to preserve customer view context - Add CSV export button with customer_code, agent_id, hostname, ip_address, os, wazuh_last_seen, and velociraptor_last_seen fields
oliver-oat committed
Feb 2, 2026 at 19:54 UTC
55b1c9b7c6ea269e2f5e2f63789384a48903b942
1 file changed
+71
-7
frontend/src/components/customers/CustomerAgents.vue
+71
-7
@@ -1,17 +1,25 @@
1
<template>
2
<n-spin :show="loading">
3
<div class="flex min-h-28 flex-col gap-2">
4
+ <div v-if="sortedList.length" class="flex justify-end mb-2">
5
+ <n-button type="primary" @click="exportToCSV">
6
+ <template #icon>
7
+ <Icon :name="DownloadIcon" />
8
+ </template>
9
+ Export
10
+ </n-button>
11
+ </div>
12
<AgentCard
5
- v-for="agent in list"
13
+ v-for="agent in sortedList"
14
:key="agent.agent_id"
15
:agent
16
embedded
17
show-actions
18
class="item-appear item-appear-bottom item-appear-005"
19
@delete="getAgents()"
12
- @click="gotoAgent(agent.agent_id)"
20
+ @click="openAgentInNewTab(agent.agent_id)"
21
/>
14
- <n-empty v-if="!list.length" description="No Agents found" class="h-48 justify-center" />
22
+ <n-empty v-if="!sortedList.length" description="No Agents found" class="h-48 justify-center" />
23
</div>
24
</n-spin>
25
</template>
@@ -19,22 +27,78 @@
27
<script setup lang="ts">
28
import type { Agent } from "@/types/agents.d"
29
import type { Customer } from "@/types/customers.d"
22
-import { NEmpty, NSpin, useMessage } from "naive-ui"
23
-import { onBeforeMount, ref, toRefs } from "vue"
30
+import { NButton, NEmpty, NSpin, useMessage } from "naive-ui"
31
+import { computed, onBeforeMount, ref, toRefs } from "vue"
32
+import { useRouter } from "vue-router"
33
import Api from "@/api"
34
import AgentCard from "@/components/agents/AgentCard.vue"
26
-import { useGoto } from "@/composables/useGoto"
35
+import Icon from "@/components/common/Icon.vue"
36
37
const props = defineProps<{
38
customer: Customer
39
}>()
40
const { customer } = toRefs(props)
41
42
+const DownloadIcon = "carbon:download"
43
+
44
const loading = ref(false)
34
-const { gotoAgent } = useGoto()
45
+const router = useRouter()
46
const message = useMessage()
47
const list = ref<Agent[] | []>([])
48
49
+const sortedList = computed(() => {
50
+ return [...list.value].sort((a, b) => {
51
+ return a.hostname.toLowerCase().localeCompare(b.hostname.toLowerCase())
52
+ })
53
+})
54
+
55
+function exportToCSV() {
56
+ if (!sortedList.value.length) {
57
+ message.warning("No agents to export")
58
+ return
59
+ }
60
+
61
+ // CSV headers
62
+ const headers = ["customer_code", "agent_id", "hostname", "ip_address", "os", "wazuh_last_seen", "velociraptor_last_seen"]
63
+
64
+ // Create CSV content
65
+ const csvContent = [
66
+ headers.join(","),
67
+ ...sortedList.value.map(agent => {
68
+ return [
69
+ agent.customer_code || "",
70
+ agent.agent_id,
71
+ `"${agent.hostname.replace(/"/g, '""')}"`, // Escape quotes in hostname
72
+ agent.ip_address,
73
+ `"${agent.os.replace(/"/g, '""')}"`, // Escape quotes in OS
74
+ agent.wazuh_last_seen,
75
+ agent.velociraptor_last_seen || ""
76
+ ].join(",")
77
+ })
78
+ ].join("\n")
79
+
80
+ // Create blob and download
81
+ const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" })
82
+ const link = document.createElement("a")
83
+ const url = URL.createObjectURL(blob)
84
+
85
+ const filename = `agents_${customer.value.customer_code}_${new Date().toISOString().split("T")[0]}.csv`
86
+
87
+ link.setAttribute("href", url)
88
+ link.setAttribute("download", filename)
89
+ link.style.visibility = "hidden"
90
+ document.body.appendChild(link)
91
+ link.click()
92
+ document.body.removeChild(link)
93
+
94
+ message.success("Export completed successfully")
95
+}
96
+
97
+function openAgentInNewTab(agentId: string) {
98
+ const route = router.resolve({ name: "Agent", params: { id: agentId } })
99
+ window.open(route.href, "_blank")
100
+}
101
+
102
function getAgents() {
103
loading.value = true
104