675 bulk removal of agents in copilot (#681)
* feat: implement bulk deletion of agents with filter options * feat: add bulk delete functionality for agents with selection and filter options - Enhanced AgentToolbar.vue to include selection mode and bulk delete actions. - Introduced BulkDeleteModal.vue for managing bulk deletions with selection and filter capabilities. - Updated Agents.vue to handle agent selection and integrate bulk delete modal. - Added new types for bulk delete requests and responses in agents.d.ts. * precommit-fixes * lint fixes
taylor_socfortress committed
Feb 4, 2026 at 11:38 UTC
0b24c4d85c2d95003a44de5959548bb6d59ed38f
10 files changed
+1502
-696
backend/app/agents/routes/agents.py
+246
@@ -1,6 +1,9 @@
1
import asyncio
2
import csv
3
import io
4
+from datetime import datetime
5
+from datetime import timedelta
6
+from typing import List
7
from typing import Optional
8
9
# from fastapi import BackgroundTasks
@@ -21,6 +24,10 @@ from sqlalchemy.future import select
24
from app.agents.schema.agents import AgentModifyResponse
25
from app.agents.schema.agents import AgentsResponse
26
from app.agents.schema.agents import AgentWazuhUpgradeResponse
27
+from app.agents.schema.agents import BulkDeleteAgentRequest
28
+from app.agents.schema.agents import BulkDeleteAgentResult
29
+from app.agents.schema.agents import BulkDeleteAgentsResponse
30
+from app.agents.schema.agents import BulkDeleteFilterRequest
31
from app.agents.schema.agents import OutdatedVelociraptorAgentsResponse
32
from app.agents.schema.agents import OutdatedWazuhAgentsResponse
33
from app.agents.schema.agents import SyncedAgentsResponse
@@ -96,6 +103,69 @@ async def check_wazuh_manager_version() -> bool:
103
return False
104
105
106
+async def delete_single_agent(
107
+ db: AsyncSession,
108
+ agent_id: str,
109
+ agent: Agents,
110
+) -> BulkDeleteAgentResult:
111
+ """
112
+ Delete a single agent and return the result.
113
+ This function handles errors gracefully and returns a result object.
114
+
115
+ Args:
116
+ db (AsyncSession): The database session.
117
+ agent_id (str): The ID of the agent to delete.
118
+ agent (Agents): The agent object from the database.
119
+
120
+ Returns:
121
+ BulkDeleteAgentResult: The result of the deletion attempt.
122
+ """
123
+ errors = []
124
+
125
+ # Try to delete from Wazuh
126
+ try:
127
+ await delete_agent_wazuh(agent_id)
128
+ except Exception as e:
129
+ error_msg = f"Failed to delete from Wazuh: {str(e)}"
130
+ logger.warning(f"Agent {agent_id}: {error_msg}")
131
+ errors.append(error_msg)
132
+
133
+ # Try to delete from Velociraptor if applicable
134
+ if agent.velociraptor_id and agent.velociraptor_id != "Unknown":
135
+ try:
136
+ await delete_agent_velociraptor(agent.velociraptor_id)
137
+ except Exception as e:
138
+ error_msg = f"Failed to delete from Velociraptor: {str(e)}"
139
+ logger.warning(f"Agent {agent_id}: {error_msg}")
140
+ errors.append(error_msg)
141
+
142
+ # Delete from database
143
+ try:
144
+ await delete_agent_from_database(db=db, agent_id=agent_id)
145
+ except Exception as e:
146
+ error_msg = f"Failed to delete from database: {str(e)}"
147
+ logger.error(f"Agent {agent_id}: {error_msg}")
148
+ errors.append(error_msg)
149
+ return BulkDeleteAgentResult(
150
+ agent_id=agent_id,
151
+ success=False,
152
+ message=f"Failed to delete agent: {'; '.join(errors)}",
153
+ )
154
+
155
+ if errors:
156
+ return BulkDeleteAgentResult(
157
+ agent_id=agent_id,
158
+ success=True,
159
+ message=f"Agent deleted from database but with warnings: {'; '.join(errors)}",
160
+ )
161
+
162
+ return BulkDeleteAgentResult(
163
+ agent_id=agent_id,
164
+ success=True,
165
+ message="Agent deleted successfully",
166
+ )
167
+
168
+
169
agents_router = APIRouter()
170
171
@@ -239,6 +309,182 @@ async def get_customer_agents_for_dashboard(
309
raise HTTPException(status_code=500, detail=f"Failed to fetch agents for customer {customer_code}")
310
311
312
+@agents_router.post(
313
+ "/bulk/delete",
314
+ response_model=BulkDeleteAgentsResponse,
315
+ description="Delete multiple agents by their IDs",
316
+ dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
317
+)
318
+async def bulk_delete_agents(
319
+ request: BulkDeleteAgentRequest,
320
+ current_user: User = Depends(AuthHandler().get_current_user),
321
+ session: AsyncSession = Depends(get_db),
322
+) -> BulkDeleteAgentsResponse:
323
+ """
324
+ Delete multiple agents by their IDs.
325
+ User must have access to each agent's customer.
326
+ If an agent fails to delete, the process continues with the remaining agents.
327
+
328
+ Args:
329
+ request (BulkDeleteAgentRequest): The request containing agent IDs to delete.
330
+ current_user (User): The authenticated user.
331
+ session (AsyncSession): The database session.
332
+
333
+ Returns:
334
+ BulkDeleteAgentsResponse: The response containing results for each deletion attempt.
335
+ """
336
+ logger.info(f"Bulk deleting {len(request.agent_ids)} agents")
337
+
338
+ results: List[BulkDeleteAgentResult] = []
339
+ successful_count = 0
340
+ failed_count = 0
341
+
342
+ for agent_id in request.agent_ids:
343
+ # Check customer access for each agent
344
+ base_query = select(Agents).filter(Agents.agent_id == agent_id)
345
+ filtered_query = await customer_access_handler.filter_query_by_customer_access(
346
+ current_user,
347
+ session,
348
+ base_query,
349
+ Agents.customer_code,
350
+ )
351
+
352
+ result = await session.execute(filtered_query)
353
+ agent = result.scalars().first()
354
+
355
+ if not agent:
356
+ results.append(
357
+ BulkDeleteAgentResult(
358
+ agent_id=agent_id,
359
+ success=False,
360
+ message="Agent not found or access denied",
361
+ ),
362
+ )
363
+ failed_count += 1
364
+ continue
365
+
366
+ # Delete the agent
367
+ delete_result = await delete_single_agent(db=session, agent_id=agent_id, agent=agent)
368
+ results.append(delete_result)
369
+
370
+ if delete_result.success:
371
+ successful_count += 1
372
+ else:
373
+ failed_count += 1
374
+
375
+ return BulkDeleteAgentsResponse(
376
+ success=failed_count == 0,
377
+ message=f"Bulk deletion completed: {successful_count} successful, {failed_count} failed",
378
+ total_requested=len(request.agent_ids),
379
+ successful_deletions=successful_count,
380
+ failed_deletions=failed_count,
381
+ results=results,
382
+ )
383
+
384
+
385
+@agents_router.post(
386
+ "/bulk/delete/filter",
387
+ response_model=BulkDeleteAgentsResponse,
388
+ description="Delete multiple agents based on filter conditions",
389
+ dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
390
+)
391
+async def bulk_delete_agents_by_filter(
392
+ request: BulkDeleteFilterRequest,
393
+ current_user: User = Depends(AuthHandler().get_current_user),
394
+ session: AsyncSession = Depends(get_db),
395
+) -> BulkDeleteAgentsResponse:
396
+ """
397
+ Delete multiple agents based on filter conditions.
398
+ User must have access to each agent's customer.
399
+ If an agent fails to delete, the process continues with the remaining agents.
400
+
401
+ Filter conditions:
402
+ - customer_code: Filter by customer code
403
+ - status: Filter by agent status ('disconnected', 'never_connected', 'active')
404
+ - disconnected_days: Filter agents disconnected for more than X days
405
+
406
+ Args:
407
+ request (BulkDeleteFilterRequest): The filter conditions.
408
+ current_user (User): The authenticated user.
409
+ session (AsyncSession): The database session.
410
+
411
+ Returns:
412
+ BulkDeleteAgentsResponse: The response containing results for each deletion attempt.
413
+ """
414
+ logger.info(f"Bulk deleting agents with filters: {request}")
415
+
416
+ # Safety check: require at least one filter to be specified
417
+ if not request.customer_code and not request.status and not request.disconnected_days:
418
+ raise HTTPException(
419
+ status_code=400,
420
+ detail="At least one filter condition must be specified to prevent accidental bulk deletion of all agents",
421
+ )
422
+
423
+ # Build the base query with filters
424
+ base_query = select(Agents)
425
+
426
+ if request.customer_code:
427
+ base_query = base_query.filter(Agents.customer_code == request.customer_code)
428
+
429
+ if request.status:
430
+ if request.status.lower() == "disconnected":
431
+ base_query = base_query.filter(Agents.wazuh_agent_status == "disconnected")
432
+ elif request.status.lower() == "never_connected":
433
+ base_query = base_query.filter(Agents.wazuh_agent_status == "never_connected")
434
+ elif request.status.lower() == "active":
435
+ base_query = base_query.filter(Agents.wazuh_agent_status == "active")
436
+
437
+ if request.disconnected_days:
438
+ cutoff_date = datetime.utcnow() - timedelta(days=request.disconnected_days)
439
+ # Filter agents whose last keep alive is older than the cutoff
440
+ base_query = base_query.filter(Agents.wazuh_last_seen < cutoff_date)
441
+
442
+ # Apply customer access filtering
443
+ filtered_query = await customer_access_handler.filter_query_by_customer_access(
444
+ current_user,
445
+ session,
446
+ base_query,
447
+ Agents.customer_code,
448
+ )
449
+
450
+ result = await session.execute(filtered_query)
451
+ agents_to_delete = result.scalars().all()
452
+
453
+ if not agents_to_delete:
454
+ return BulkDeleteAgentsResponse(
455
+ success=True,
456
+ message="No agents found matching the filter criteria",
457
+ total_requested=0,
458
+ successful_deletions=0,
459
+ failed_deletions=0,
460
+ results=[],
461
+ )
462
+
463
+ logger.info(f"Found {len(agents_to_delete)} agents matching filter criteria")
464
+
465
+ results: List[BulkDeleteAgentResult] = []
466
+ successful_count = 0
467
+ failed_count = 0
468
+
469
+ for agent in agents_to_delete:
470
+ delete_result = await delete_single_agent(db=session, agent_id=agent.agent_id, agent=agent)
471
+ results.append(delete_result)
472
+
473
+ if delete_result.success:
474
+ successful_count += 1
475
+ else:
476
+ failed_count += 1
477
+
478
+ return BulkDeleteAgentsResponse(
479
+ success=failed_count == 0,
480
+ message=f"Bulk deletion completed: {successful_count} successful, {failed_count} failed",
481
+ total_requested=len(agents_to_delete),
482
+ successful_deletions=successful_count,
483
+ failed_deletions=failed_count,
484
+ results=results,
485
+ )
486
+
487
+
488
@agents_router.get(
489
"/{agent_id}",
490
response_model=AgentsResponse,
backend/app/agents/schema/agents.py
+27
@@ -1,4 +1,6 @@
1
from typing import List
2
+from typing import Literal
3
+from typing import Optional
4
5
from pydantic import BaseModel
6
from pydantic import Field
@@ -57,3 +59,28 @@ class AgentUpdateCustomerCodeResponse(BaseModel):
59
class AgentWazuhUpgradeResponse(BaseModel):
60
success: bool
61
message: str
62
+
63
+
64
+class BulkDeleteAgentRequest(BaseModel):
65
+ agent_ids: List[str] = Field(..., description="List of agent IDs to delete")
66
+
67
+
68
+class BulkDeleteFilterRequest(BaseModel):
69
+ customer_code: Optional[str] = Field(None, description="Filter by customer code")
70
+ status: Optional[Literal["disconnected", "never_connected", "active"]] = Field(None, description="Filter by agent status")
71
+ disconnected_days: Optional[int] = Field(None, description="Filter agents disconnected for more than X days", ge=1)
72
+
73
+
74
+class BulkDeleteAgentResult(BaseModel):
75
+ agent_id: str
76
+ success: bool
77
+ message: str
78
+
79
+
80
+class BulkDeleteAgentsResponse(BaseModel):
81
+ success: bool
82
+ message: str
83
+ total_requested: int
84
+ successful_deletions: int
85
+ failed_deletions: int
86
+ results: List[BulkDeleteAgentResult]
frontend/src/api/endpoints/agents.ts
+19
@@ -3,6 +3,9 @@ import type {
3
AgentArtifactData,
4
AgentSca,
5
AgentVulnerabilities,
6
+ BulkDeleteAgentRequest,
7
+ BulkDeleteAgentsResponse,
8
+ BulkDeleteFilterRequest,
9
OutdatedVelociraptorAgents,
10
OutdatedWazuhAgents,
11
ScaPolicyResult
@@ -29,6 +32,22 @@ export default {
32
deleteAgent(agentId: string) {
33
return HttpClient.delete<FlaskBaseResponse>(`/agents/${agentId}/delete`)
34
},
35
+ /**
36
+ * Bulk delete agents by their IDs
37
+ * @param agentIds - Array of agent IDs to delete
38
+ */
39
+ bulkDeleteAgents(agentIds: string[]) {
40
+ const payload: BulkDeleteAgentRequest = { agent_ids: agentIds }
41
+ return HttpClient.post<BulkDeleteAgentsResponse>(`/agents/bulk/delete`, payload)
42
+ },
43
+ /**
44
+ * Bulk delete agents based on filter conditions
45
+ * At least one filter must be specified
46
+ * @param filters - Filter conditions (customer_code, status, disconnected_days)
47
+ */
48
+ bulkDeleteAgentsByFilter(filters: BulkDeleteFilterRequest) {
49
+ return HttpClient.post<BulkDeleteAgentsResponse>(`/agents/bulk/delete/filter`, filters)
50
+ },
51
syncAgents() {
52
return HttpClient.post<FlaskBaseResponse>(`/agents/sync`)
53
},
frontend/src/components/agents/AgentCard.vue
+227
-189
@@ -1,66 +1,80 @@
1
<template>
2
- <CardEntity class="agent-card" :loading :embedded :hoverable :clickable :class="{ critical: agent.critical_asset }">
3
- <div class="wrapper">
4
- <div class="agent-header">
5
- <div class="title">
6
- <n-tooltip>
7
- {{ `${isOnline ? "online" : "last seen"} - ${formatLastSeen}` }}
8
- <template #trigger>
9
- <div class="hostname" :class="{ online: isOnline }">
10
- {{ agent.hostname }}
11
- </div>
12
- </template>
13
- </n-tooltip>
14
- <div class="critical" :class="{ active: agent.critical_asset }">
2
+ <CardEntity
3
+ class="agent-card"
4
+ :loading
5
+ :embedded
6
+ :hoverable
7
+ :clickable
8
+ :class="{ critical: agent.critical_asset, selectable, selected }"
9
+ >
10
+ <div class="card-content">
11
+ <!-- Selection checkbox -->
12
+ <div v-if="selectable" class="selection-checkbox" @click.stop>
13
+ <n-checkbox :checked="selected" @update:checked="$emit('toggle-selection')" />
14
+ </div>
15
+
16
+ <div class="wrapper">
17
+ <div class="agent-header">
18
+ <div class="title">
19
<n-tooltip>
16
- Toggle Critical Assets
20
+ {{ `${isOnline ? "online" : "last seen"} - ${formatLastSeen}` }}
21
<template #trigger>
18
- <n-button
19
- quaternary
20
- circle
21
- :type="agent.critical_asset ? 'warning' : 'default'"
22
- @click.stop="toggleCritical(agent.agent_id, agent.critical_asset)"
23
- >
24
- <template #icon>
25
- <Icon :name="StarIcon" />
26
- </template>
27
- </n-button>
22
+ <div class="hostname" :class="{ online: isOnline }">
23
+ {{ agent.hostname }}
24
+ </div>
25
</template>
26
</n-tooltip>
27
+ <div class="critical" :class="{ active: agent.critical_asset }">
28
+ <n-tooltip>
29
+ Toggle Critical Assets
30
+ <template #trigger>
31
+ <n-button
32
+ quaternary
33
+ circle
34
+ :type="agent.critical_asset ? 'warning' : 'default'"
35
+ @click.stop="toggleCritical(agent.agent_id, agent.critical_asset)"
36
+ >
37
+ <template #icon>
38
+ <Icon :name="StarIcon" />
39
+ </template>
40
+ </n-button>
41
+ </template>
42
+ </n-tooltip>
43
+ </div>
44
+ <div v-show="agent.quarantined" class="quarantined">
45
+ <n-tooltip>
46
+ Quarantined
47
+ <template #trigger>
48
+ <Icon :name="QuarantinedIcon" :size="18" />
49
+ </template>
50
+ </n-tooltip>
51
+ </div>
52
</div>
31
- <div v-show="agent.quarantined" class="quarantined">
53
+ <div class="info">#{{ agent.agent_id }} / {{ agent.label }}</div>
54
+ </div>
55
+ <div class="agent-info">
56
+ <div class="os" :title="agent.os">
57
+ {{ agent.os }}
58
+ </div>
59
+ <div class="ip-address" :title="agent.ip_address">
60
+ {{ agent.ip_address }}
61
+ </div>
62
+ </div>
63
+
64
+ <div v-if="showActions" class="agent-actions">
65
+ <div class="box">
66
<n-tooltip>
33
- Quarantined
67
+ Delete
68
<template #trigger>
35
- <Icon :name="QuarantinedIcon" :size="18" />
69
+ <n-button quaternary circle type="error" @click.stop="handleDelete">
70
+ <template #icon>
71
+ <Icon :name="DeleteIcon" />
72
+ </template>
73
+ </n-button>
74
</template>
75
</n-tooltip>
76
</div>
77
</div>
40
- <div class="info">#{{ agent.agent_id }} / {{ agent.label }}</div>
41
- </div>
42
- <div class="agent-info">
43
- <div class="os" :title="agent.os">
44
- {{ agent.os }}
45
- </div>
46
- <div class="ip-address" :title="agent.ip_address">
47
- {{ agent.ip_address }}
48
- </div>
49
- </div>
50
-
51
- <div v-if="showActions" class="agent-actions">
52
- <div class="box">
53
- <n-tooltip>
54
- Delete
55
- <template #trigger>
56
- <n-button quaternary circle type="error" @click.stop="handleDelete">
57
- <template #icon>
58
- <Icon :name="DeleteIcon" />
59
- </template>
60
- </n-button>
61
- </template>
62
- </n-tooltip>
63
- </div>
78
</div>
79
</div>
80
</CardEntity>
@@ -68,7 +82,7 @@
82
83
<script setup lang="ts">
84
import type { Agent } from "@/types/agents.d"
71
-import { NButton, NTooltip, useDialog, useMessage } from "naive-ui"
85
+import { NButton, NCheckbox, NTooltip, useDialog, useMessage } from "naive-ui"
86
import { computed, ref, toRefs } from "vue"
87
import CardEntity from "@/components/common/cards/CardEntity.vue"
88
import Icon from "@/components/common/Icon.vue"
@@ -78,18 +92,22 @@ import dayjs from "@/utils/dayjs"
92
import { handleDeleteAgent, toggleAgentCritical } from "./utils"
93
94
const props = defineProps<{
81
- agent: Agent
82
- showActions?: boolean
83
- embedded?: boolean
84
- hoverable?: boolean
85
- clickable?: boolean
95
+ agent: Agent
96
+ showActions?: boolean
97
+ embedded?: boolean
98
+ hoverable?: boolean
99
+ clickable?: boolean
100
+ selectable?: boolean
101
+ selected?: boolean
102
}>()
103
104
const emit = defineEmits<{
89
- (e: "delete"): void
105
+ (e: "delete"): void
106
+ (e: "click"): void
107
+ (e: "toggle-selection"): void
108
}>()
109
92
-const { agent, showActions, embedded, hoverable, clickable } = toRefs(props)
110
+const { agent, showActions, embedded, hoverable, clickable, selectable, selected } = toRefs(props)
111
112
const QuarantinedIcon = "ph:seal-warning-light"
113
const StarIcon = "carbon:star"
@@ -99,162 +117,182 @@ const loading = ref(false)
117
const message = useMessage()
118
const dialog = useDialog()
119
const isOnline = computed(() => {
102
- return agent.value.wazuh_agent_status === AgentStatus.Active
120
+ return agent.value.wazuh_agent_status === AgentStatus.Active
121
})
122
const formatLastSeen = computed(() => {
105
- const lastSeenDate = dayjs(agent.value.wazuh_last_seen)
106
- if (!lastSeenDate.isValid()) return agent.value.wazuh_last_seen
123
+ const lastSeenDate = dayjs(agent.value.wazuh_last_seen)
124
+ if (!lastSeenDate.isValid()) return agent.value.wazuh_last_seen
125
108
- return lastSeenDate.format(dFormats.datetime)
126
+ return lastSeenDate.format(dFormats.datetime)
127
})
128
129
function handleDelete() {
112
- handleDeleteAgent({
113
- agent: agent.value,
114
- cbBefore: () => {
115
- loading.value = true
116
- },
117
- cbSuccess: () => {
118
- emit("delete")
119
- },
120
- cbAfter: () => {
121
- loading.value = false
122
- },
123
- message,
124
- dialog
125
- })
130
+ handleDeleteAgent({
131
+ agent: agent.value,
132
+ cbBefore: () => {
133
+ loading.value = true
134
+ },
135
+ cbSuccess: () => {
136
+ emit("delete")
137
+ },
138
+ cbAfter: () => {
139
+ loading.value = false
140
+ },
141
+ message,
142
+ dialog
143
+ })
144
}
145
146
function toggleCritical(agentId: string, criticalStatus: boolean) {
129
- toggleAgentCritical({
130
- agentId,
131
- criticalStatus,
132
- message,
133
- cbBefore: () => {
134
- loading.value = true
135
- },
136
- cbSuccess: () => {
137
- agent.value.critical_asset = !criticalStatus
138
- },
139
- cbAfter: () => {
140
- loading.value = false
141
- }
142
- })
147
+ toggleAgentCritical({
148
+ agentId,
149
+ criticalStatus,
150
+ message,
151
+ cbBefore: () => {
152
+ loading.value = true
153
+ },
154
+ cbSuccess: () => {
155
+ agent.value.critical_asset = !criticalStatus
156
+ },
157
+ cbAfter: () => {
158
+ loading.value = false
159
+ }
160
+ })
161
}
162
</script>
163
164
<style lang="scss" scoped>
165
.agent-card {
148
- container-type: inline-size;
166
+ container-type: inline-size;
167
+
168
+ &.selected {
169
+ border-color: var(--primary-color);
170
+ background-color: var(--primary-color-opacity-1);
171
+ }
172
+
173
+ .card-content {
174
+ display: flex;
175
+ align-items: center;
176
+ gap: 12px;
177
+
178
+ .selection-checkbox {
179
+ flex-shrink: 0;
180
+ display: flex;
181
+ align-items: center;
182
+ justify-content: center;
183
+ cursor: pointer;
184
+ }
185
150
- .wrapper {
151
- display: flex;
152
- flex-direction: row;
153
- align-items: center;
154
- gap: calc(var(--spacing) * 6);
155
- overflow: hidden;
186
+ .wrapper {
187
+ display: flex;
188
+ flex-direction: row;
189
+ align-items: center;
190
+ gap: calc(var(--spacing) * 6);
191
+ overflow: hidden;
192
+ flex-grow: 1;
193
157
- .agent-header {
158
- display: flex;
159
- flex-direction: column;
160
- min-width: 300px;
194
+ .agent-header {
195
+ display: flex;
196
+ flex-direction: column;
197
+ min-width: 300px;
198
162
- .title {
163
- display: flex;
164
- align-items: center;
165
- gap: calc(var(--spacing) * 2);
166
- margin-bottom: 4px;
199
+ .title {
200
+ display: flex;
201
+ align-items: center;
202
+ gap: calc(var(--spacing) * 2);
203
+ margin-bottom: 4px;
204
168
- .hostname {
169
- font-weight: bold;
170
- white-space: nowrap;
171
- line-height: 32px;
172
- height: 32px;
173
- border-radius: 4px;
174
- border: 1px solid var(--info-color);
175
- border-color: transparent;
176
- box-sizing: border-box;
177
- overflow: hidden;
178
- text-overflow: ellipsis;
205
+ .hostname {
206
+ font-weight: bold;
207
+ white-space: nowrap;
208
+ line-height: 32px;
209
+ height: 32px;
210
+ border-radius: 4px;
211
+ border: 1px solid var(--info-color);
212
+ border-color: transparent;
213
+ box-sizing: border-box;
214
+ overflow: hidden;
215
+ text-overflow: ellipsis;
216
180
- &.online {
181
- padding: 0px 15px;
182
- color: var(--success-color);
183
- border-color: var(--success-color);
184
- }
185
- }
217
+ &.online {
218
+ padding: 0px 15px;
219
+ color: var(--success-color);
220
+ border-color: var(--success-color);
221
+ }
222
+ }
223
187
- .quarantined {
188
- display: flex;
189
- padding-top: 1px;
190
- color: var(--warning-color);
191
- }
192
- }
193
- .info {
194
- font-family: var(--font-family-mono);
195
- font-size: var(--text-xs);
196
- opacity: 0.7;
197
- white-space: nowrap;
198
- overflow: hidden;
199
- text-overflow: ellipsis;
200
- margin-left: 2px;
201
- }
202
- }
224
+ .quarantined {
225
+ display: flex;
226
+ padding-top: 1px;
227
+ color: var(--warning-color);
228
+ }
229
+ }
230
+ .info {
231
+ font-family: var(--font-family-mono);
232
+ font-size: var(--text-xs);
233
+ opacity: 0.7;
234
+ white-space: nowrap;
235
+ overflow: hidden;
236
+ text-overflow: ellipsis;
237
+ margin-left: 2px;
238
+ }
239
+ }
240
204
- .agent-info {
205
- display: flex;
206
- flex-direction: column;
207
- flex-grow: 1;
208
- overflow: hidden;
241
+ .agent-info {
242
+ display: flex;
243
+ flex-direction: column;
244
+ flex-grow: 1;
245
+ overflow: hidden;
246
210
- .os {
211
- line-height: 32px;
212
- height: 32px;
213
- margin-bottom: 4px;
214
- white-space: nowrap;
215
- overflow: hidden;
216
- text-overflow: ellipsis;
217
- }
218
- .ip-address {
219
- white-space: nowrap;
220
- font-size: var(--text-xs);
221
- font-family: var(--font-family-mono);
222
- opacity: 0.7;
223
- overflow: hidden;
224
- text-overflow: ellipsis;
225
- }
226
- }
247
+ .os {
248
+ line-height: 32px;
249
+ height: 32px;
250
+ margin-bottom: 4px;
251
+ white-space: nowrap;
252
+ overflow: hidden;
253
+ text-overflow: ellipsis;
254
+ }
255
+ .ip-address {
256
+ white-space: nowrap;
257
+ font-size: var(--text-xs);
258
+ font-family: var(--font-family-mono);
259
+ opacity: 0.7;
260
+ overflow: hidden;
261
+ text-overflow: ellipsis;
262
+ }
263
+ }
264
228
- .agent-actions {
229
- display: flex;
230
- }
231
- }
265
+ .agent-actions {
266
+ display: flex;
267
+ }
268
+ }
269
+ }
270
233
- &.critical {
234
- border-color: var(--warning-color);
235
- }
271
+ &.critical {
272
+ border-color: var(--warning-color);
273
+ }
274
237
- @container (max-width: 550px) {
238
- .wrapper {
239
- gap: calc(var(--spacing) * 5);
275
+ @container (max-width: 550px) {
276
+ .card-content .wrapper {
277
+ gap: calc(var(--spacing) * 5);
278
241
- .agent-header {
242
- min-width: initial;
243
- }
244
- }
245
- }
246
- @container (max-width: 400px) {
247
- .wrapper {
248
- gap: calc(var(--spacing) * 4);
279
+ .agent-header {
280
+ min-width: initial;
281
+ }
282
+ }
283
+ }
284
+ @container (max-width: 400px) {
285
+ .card-content .wrapper {
286
+ gap: calc(var(--spacing) * 4);
287
250
- .agent-header {
251
- flex-grow: 1;
252
- overflow: hidden;
253
- }
254
- .agent-info {
255
- display: none;
256
- }
257
- }
258
- }
288
+ .agent-header {
289
+ flex-grow: 1;
290
+ overflow: hidden;
291
+ }
292
+ .agent-info {
293
+ display: none;
294
+ }
295
+ }
296
+ }
297
}
298
</style>
frontend/src/components/agents/AgentToolbar.vue
+238
-167
@@ -28,6 +28,43 @@
28
</div>
29
</div>
30
31
+ <!-- Selection Mode & Bulk Delete Section -->
32
+ <div class="bulk-actions flex flex-col gap-3">
33
+ <div class="selection-toggle flex items-center gap-2">
34
+ <n-switch v-model:value="selectionModeInternal" @update:value="emit('update:selection-mode', $event)">
35
+ <template #checked>Selection ON</template>
36
+ <template #unchecked>Selection OFF</template>
37
+ </n-switch>
38
+ </div>
39
+
40
+ <p v-if="selectionModeInternal" class="text-xs text-secondary-color">
41
+ Click agents to select them for bulk operations. Or select "Bulk Delete" and apply a filter to delete multiple agents at once.
42
+ </p>
43
+
44
+ <!-- Selected count and actions -->
45
+ <div v-if="selectedCount && selectedCount > 0" class="selected-info flex items-center gap-2">
46
+ <n-tag type="info" size="small">
47
+ {{ selectedCount }} selected
48
+ </n-tag>
49
+ <n-button size="tiny" quaternary @click="emit('clear-selection')">
50
+ Clear
51
+ </n-button>
52
+ </div>
53
+
54
+ <n-button
55
+ type="error"
56
+ secondary
57
+ size="small"
58
+ :disabled="syncing"
59
+ @click="emit('bulk-delete')"
60
+ >
61
+ <template #icon>
62
+ <Icon :name="DeleteIcon" />
63
+ </template>
64
+ Bulk Delete
65
+ </n-button>
66
+ </div>
67
+
68
<div class="agents-list flex grow flex-col overflow-hidden">
69
<n-scrollbar>
70
<div v-if="agentsCritical?.length" class="agents-critical-list">
@@ -73,204 +110,238 @@ import type { DropdownMixedOption } from "naive-ui/es/dropdown/src/interface"
110
import type { Agent } from "@/types/agents.d"
111
import type { Customer } from "@/types/customers.d"
112
import { useWindowSize } from "@vueuse/core"
76
-import { NButton, NCard, NDropdown, NInput, NScrollbar, useMessage } from "naive-ui"
77
-import { computed, h, ref, toRefs } from "vue"
113
+import { NButton, NCard, NDropdown, NInput, NScrollbar, NSwitch, NTag, useMessage } from "naive-ui"
114
+import { computed, h, ref, toRefs, watch } from "vue"
115
import Api from "@/api"
116
import Icon from "@/components/common/Icon.vue"
117
118
const props = defineProps<{
82
- modelValue: string
83
- syncing?: boolean
84
- enableSyncVulnerabilitiesDropdown?: boolean
85
- agentsLength?: number
86
- agentsFilteredLength?: number
87
- agentsCritical?: Agent[]
88
- agentsOnline?: Agent[]
119
+ modelValue: string
120
+ syncing?: boolean
121
+ enableSyncVulnerabilitiesDropdown?: boolean
122
+ agentsLength?: number
123
+ agentsFilteredLength?: number
124
+ agentsCritical?: Agent[]
125
+ agentsOnline?: Agent[]
126
+ selectionMode?: boolean
127
+ selectedCount?: number
128
}>()
129
130
const emit = defineEmits<{
92
- (e: "run", value: "sync-agents" | `sync-vulnerabilities:${string}`): void
93
- (e: "update:modelValue", value: string): void
94
- (e: "click", value: Agent): void
131
+ (e: "run", value: "sync-agents" | `sync-vulnerabilities:${string}`): void
132
+ (e: "update:modelValue", value: string): void
133
+ (e: "click", value: Agent): void
134
+ (e: "bulk-delete"): void
135
+ (e: "update:selection-mode", value: boolean): void
136
+ (e: "clear-selection"): void
137
}>()
138
139
const {
98
- modelValue,
99
- syncing,
100
- agentsLength,
101
- agentsFilteredLength,
102
- agentsCritical,
103
- agentsOnline,
104
- enableSyncVulnerabilitiesDropdown
140
+ modelValue,
141
+ syncing,
142
+ agentsLength,
143
+ agentsFilteredLength,
144
+ agentsCritical,
145
+ agentsOnline,
146
+ enableSyncVulnerabilitiesDropdown,
147
+ selectionMode,
148
+ selectedCount
149
} = toRefs(props)
150
151
const SearchIcon = "carbon:search"
152
+const DeleteIcon = "carbon:trash-can"
153
const message = useMessage()
154
+
155
const textFilter = computed<string>({
110
- get() {
111
- return modelValue.value
112
- },
113
- set(value) {
114
- emit("update:modelValue", value)
115
- }
156
+ get() {
157
+ return modelValue.value
158
+ },
159
+ set(value) {
160
+ emit("update:modelValue", value)
161
+ }
162
})
163
+
164
+const selectionModeInternal = ref(selectionMode?.value ?? false)
165
+
166
+watch(
167
+ () => selectionMode?.value,
168
+ val => {
169
+ selectionModeInternal.value = val ?? false
170
+ }
171
+)
172
+
173
const loadingCustomersList = ref(false)
174
const customersList = ref<Customer[]>([])
175
const { width: winWidth } = useWindowSize()
176
177
const customersOptions = computed(() => {
122
- const options: DropdownMixedOption[] = [
123
- {
124
- label: "Sync Agents",
125
- key: "sync-agents"
126
- }
127
- ]
128
-
129
- if (winWidth.value > 550) {
130
- options.push({
131
- label: `Sync Agent Vulnerabilities${loadingCustomersList.value ? "..." : ""}`,
132
- key: "sync-vulnerabilities",
133
- disabled: loadingCustomersList.value,
134
- children: loadingCustomersList.value
135
- ? undefined
136
- : [
137
- {
138
- label: () => h("div", { class: "pl-2" }, "Select a Customer"),
139
- type: "group",
140
- children: customersList.value.map(o => ({
141
- label: `#${o.customer_code} - ${o.customer_name}`,
142
- key: `sync-vulnerabilities:${o.customer_code}`
143
- }))
144
- }
145
- ]
146
- })
147
- } else {
148
- options.push({
149
- label: () => h("div", { class: "pl-2" }, "Select a Customer"),
150
- type: "group",
151
- children: customersList.value.map(o => ({
152
- label: `#${o.customer_code} - ${o.customer_name}`,
153
- key: `sync-vulnerabilities:${o.customer_code}`
154
- }))
155
- })
156
- }
157
-
158
- return options
178
+ const options: DropdownMixedOption[] = [
179
+ {
180
+ label: "Sync Agents",
181
+ key: "sync-agents"
182
+ }
183
+ ]
184
+
185
+ if (winWidth.value > 550) {
186
+ options.push({
187
+ label: `Sync Agent Vulnerabilities${loadingCustomersList.value ? "..." : ""}`,
188
+ key: "sync-vulnerabilities",
189
+ disabled: loadingCustomersList.value,
190
+ children: loadingCustomersList.value
191
+ ? undefined
192
+ : [
193
+ {
194
+ label: () => h("div", { class: "pl-2" }, "Select a Customer"),
195
+ type: "group",
196
+ children: customersList.value.map(o => ({
197
+ label: `#${o.customer_code} - ${o.customer_name}`,
198
+ key: `sync-vulnerabilities:${o.customer_code}`
199
+ }))
200
+ }
201
+ ]
202
+ })
203
+ } else {
204
+ options.push({
205
+ label: () => h("div", { class: "pl-2" }, "Select a Customer"),
206
+ type: "group",
207
+ children: customersList.value.map(o => ({
208
+ label: `#${o.customer_code} - ${o.customer_name}`,
209
+ key: `sync-vulnerabilities:${o.customer_code}`
210
+ }))
211
+ })
212
+ }
213
+
214
+ return options
215
})
216
217
function getCustomers() {
162
- loadingCustomersList.value = true
163
-
164
- Api.customers
165
- .getCustomers()
166
- .then(res => {
167
- if (res.data.success) {
168
- customersList.value = res.data?.customers || []
169
- } else {
170
- message.warning(res.data?.message || "An error occurred. Please try again later.")
171
- }
172
- })
173
- .catch(err => {
174
- message.error(err.response?.data?.message || "An error occurred. Please try again later.")
175
- })
176
- .finally(() => {
177
- loadingCustomersList.value = false
178
- })
218
+ loadingCustomersList.value = true
219
+
220
+ Api.customers
221
+ .getCustomers()
222
+ .then(res => {
223
+ if (res.data.success) {
224
+ customersList.value = res.data?.customers || []
225
+ } else {
226
+ message.warning(res.data?.message || "An error occurred. Please try again later.")
227
+ }
228
+ })
229
+ .catch(err => {
230
+ message.error(err.response?.data?.message || "An error occurred. Please try again later.")
231
+ })
232
+ .finally(() => {
233
+ loadingCustomersList.value = false
234
+ })
235
}
236
237
function load() {
182
- if (!customersList.value.length) {
183
- getCustomers()
184
- }
238
+ if (!customersList.value.length) {
239
+ getCustomers()
240
+ }
241
}
242
</script>
243
244
<style lang="scss" scoped>
245
.agent-toolbar {
190
- container-type: inline-size;
191
- overflow: hidden;
192
- max-width: 100%;
193
- min-width: 340px;
194
-
195
- .wrapper {
196
- overflow: hidden;
197
- height: 100%;
198
-
199
- .search-info {
200
- color: var(--fg-secondary-color);
201
- }
202
- .agents-list {
203
- .title {
204
- margin-bottom: calc(var(--spacing) * 2);
205
- }
206
-
207
- .list {
208
- .item {
209
- border: 2px solid transparent;
210
- padding-inline: calc(var(--spacing) * 3);
211
- padding-block: calc(var(--spacing) * 2);
212
- font-size: 14px;
213
- font-weight: bold;
214
- cursor: pointer;
215
- border-radius: var(--border-radius);
216
-
217
- &:not(:last-child) {
218
- margin-bottom: calc(var(--spacing) * 2);
219
- }
220
-
221
- &:hover {
222
- background-color: var(--hover-color);
223
- }
224
- }
225
- }
226
-
227
- .agents-critical-list {
228
- margin-bottom: calc(var(--spacing) * 5);
229
-
230
- .list {
231
- .item {
232
- border-color: var(--warning-color);
233
- }
234
- }
235
- }
236
- .agents-online-list {
237
- .list {
238
- .item {
239
- border-color: var(--success-color);
240
- }
241
- }
242
- }
243
- }
244
- }
245
-
246
- @container (min-width: 350px) {
247
- .wrapper {
248
- .agent-search {
249
- flex-grow: 1;
250
- }
251
- .search-info {
252
- display: none;
253
- }
254
- .agents-list {
255
- display: none;
256
- }
257
- }
258
- }
259
- @media (max-width: 500px) {
260
- min-width: 100%;
261
-
262
- .wrapper {
263
- .agent-search {
264
- flex-grow: 1;
265
- width: 100%;
266
- }
267
- .search-info {
268
- display: none;
269
- }
270
- .agents-list {
271
- display: none;
272
- }
273
- }
274
- }
246
+ container-type: inline-size;
247
+ overflow: hidden;
248
+ max-width: 100%;
249
+ min-width: 340px;
250
+
251
+ .wrapper {
252
+ overflow: hidden;
253
+ height: 100%;
254
+
255
+ .search-info {
256
+ color: var(--fg-secondary-color);
257
+ }
258
+
259
+ .bulk-actions {
260
+ padding: 12px;
261
+ background: var(--bg-secondary-color);
262
+ border-radius: var(--border-radius);
263
+
264
+ .selection-toggle {
265
+ font-size: 13px;
266
+ }
267
+
268
+ .selected-info {
269
+ flex-wrap: wrap;
270
+ }
271
+ }
272
+
273
+ .agents-list {
274
+ .title {
275
+ margin-bottom: calc(var(--spacing) * 2);
276
+ }
277
+
278
+ .list {
279
+ .item {
280
+ border: 2px solid transparent;
281
+ padding-inline: calc(var(--spacing) * 3);
282
+ padding-block: calc(var(--spacing) * 2);
283
+ font-size: 14px;
284
+ font-weight: bold;
285
+ cursor: pointer;
286
+ border-radius: var(--border-radius);
287
+
288
+ &:not(:last-child) {
289
+ margin-bottom: calc(var(--spacing) * 2);
290
+ }
291
+
292
+ &:hover {
293
+ background-color: var(--hover-color);
294
+ }
295
+ }
296
+ }
297
+
298
+ .agents-critical-list {
299
+ margin-bottom: calc(var(--spacing) * 5);
300
+
301
+ .list {
302
+ .item {
303
+ border-color: var(--warning-color);
304
+ }
305
+ }
306
+ }
307
+ .agents-online-list {
308
+ .list {
309
+ .item {
310
+ border-color: var(--success-color);
311
+ }
312
+ }
313
+ }
314
+ }
315
+ }
316
+
317
+ @container (min-width: 350px) {
318
+ .wrapper {
319
+ .agent-search {
320
+ flex-grow: 1;
321
+ }
322
+ .search-info {
323
+ display: none;
324
+ }
325
+ .agents-list {
326
+ display: none;
327
+ }
328
+ }
329
+ }
330
+ @media (max-width: 500px) {
331
+ min-width: 100%;
332
+
333
+ .wrapper {
334
+ .agent-search {
335
+ flex-grow: 1;
336
+ width: 100%;
337
+ }
338
+ .search-info {
339
+ display: none;
340
+ }
341
+ .agents-list {
342
+ display: none;
343
+ }
344
+ }
345
+ }
346
}
347
</style>
frontend/src/components/agents/BulkDeleteModal.vue
new
+309
@@ -0,0 +1,309 @@
1
+<template>
2
+ <n-modal v-model:show="showModal" preset="card" title="Bulk Delete Agents" class="bulk-delete-modal" :style="{ maxWidth: '600px' }">
3
+ <n-tabs v-model:value="activeTab" type="line">
4
+ <n-tab-pane name="selection" tab="By Selection">
5
+ <div class="tab-content">
6
+ <n-alert v-if="!selectedAgents.length" type="warning" class="mb-4">
7
+ No agents selected. Please select agents from the list first.
8
+ </n-alert>
9
+ <template v-else>
10
+ <p class="mb-4">
11
+ You are about to delete <strong>{{ selectedAgents.length }}</strong> selected agent(s).
12
+ </p>
13
+ <n-scrollbar style="max-height: 200px" class="mb-4">
14
+ <div class="selected-agents-list">
15
+ <n-tag
16
+ v-for="agent in selectedAgents"
17
+ :key="agent.agent_id"
18
+ closable
19
+ class="mr-2 mb-2"
20
+ @close="$emit('remove-selection', agent)"
21
+ >
22
+ {{ agent.hostname }} ({{ agent.agent_id }})
23
+ </n-tag>
24
+ </div>
25
+ </n-scrollbar>
26
+ </template>
27
+ </div>
28
+ </n-tab-pane>
29
+
30
+ <n-tab-pane name="filter" tab="By Filter">
31
+ <div class="tab-content">
32
+ <n-form ref="filterFormRef" :model="filterForm" label-placement="top">
33
+ <n-alert type="info" class="mb-4">
34
+ At least one filter must be specified. This will delete all matching agents.
35
+ </n-alert>
36
+
37
+ <n-form-item label="Customer Code" path="customer_code">
38
+ <n-select
39
+ v-model:value="filterForm.customer_code"
40
+ :options="customerOptions"
41
+ placeholder="Select customer (optional)"
42
+ clearable
43
+ filterable
44
+ />
45
+ </n-form-item>
46
+
47
+ <n-form-item label="Agent Status" path="status">
48
+ <n-select
49
+ v-model:value="filterForm.status"
50
+ :options="statusOptions"
51
+ placeholder="Select status (optional)"
52
+ clearable
53
+ />
54
+ </n-form-item>
55
+
56
+ <n-form-item label="Disconnected Days" path="disconnected_days">
57
+ <n-input-number
58
+ v-model:value="filterForm.disconnected_days"
59
+ :min="1"
60
+ :max="365"
61
+ placeholder="Agents disconnected for more than X days"
62
+ clearable
63
+ class="w-full"
64
+ />
65
+ </n-form-item>
66
+ </n-form>
67
+ </div>
68
+ </n-tab-pane>
69
+ </n-tabs>
70
+
71
+ <!-- Results Section -->
72
+ <template v-if="deleteResults">
73
+ <n-divider />
74
+ <div class="results-section">
75
+ <n-alert :type="deleteResults.success ? 'success' : 'warning'" class="mb-4">
76
+ {{ deleteResults.message }}
77
+ </n-alert>
78
+ <div class="results-stats mb-4">
79
+ <n-space>
80
+ <n-tag type="info">Total: {{ deleteResults.total_requested }}</n-tag>
81
+ <n-tag type="success">Successful: {{ deleteResults.successful_deletions }}</n-tag>
82
+ <n-tag v-if="deleteResults.failed_deletions > 0" type="error">
83
+ Failed: {{ deleteResults.failed_deletions }}
84
+ </n-tag>
85
+ </n-space>
86
+ </div>
87
+ <n-collapse v-if="deleteResults.results.length > 0">
88
+ <n-collapse-item title="Deletion Details" name="details">
89
+ <n-scrollbar style="max-height: 200px">
90
+ <div v-for="result in deleteResults.results" :key="result.agent_id" class="result-item">
91
+ <n-tag :type="result.success ? 'success' : 'error'" size="small">
92
+ {{ result.success ? "✓" : "✗" }}
93
+ </n-tag>
94
+ <span class="ml-2">{{ result.agent_id }}: {{ result.message }}</span>
95
+ </div>
96
+ </n-scrollbar>
97
+ </n-collapse-item>
98
+ </n-collapse>
99
+ </div>
100
+ </template>
101
+
102
+ <template #footer>
103
+ <n-space justify="end">
104
+ <n-button @click="closeModal">Cancel</n-button>
105
+ <n-button
106
+ type="error"
107
+ :loading="loading"
108
+ :disabled="!canDelete"
109
+ @click="confirmDelete"
110
+ >
111
+ <template #icon>
112
+ <n-icon><Icon :name="DeleteIcon" /></n-icon>
113
+ </template>
114
+ Delete Agents
115
+ </n-button>
116
+ </n-space>
117
+ </template>
118
+ </n-modal>
119
+
120
+ <!-- Confirmation Dialog -->
121
+ <n-modal v-model:show="showConfirmDialog" preset="dialog" type="warning" title="Confirm Bulk Deletion">
122
+ <template #default>
123
+ <p>Are you sure you want to delete these agents?</p>
124
+ <p class="mt-2 text-red-500">This action cannot be undone.</p>
125
+ <template v-if="activeTab === 'filter'">
126
+ <p class="mt-2">
127
+ <strong>Filters:</strong>
128
+ </p>
129
+ <ul class="list-disc ml-4">
130
+ <li v-if="filterForm.customer_code">Customer: {{ filterForm.customer_code }}</li>
131
+ <li v-if="filterForm.status">Status: {{ filterForm.status }}</li>
132
+ <li v-if="filterForm.disconnected_days">Disconnected for: {{ filterForm.disconnected_days }}+ days</li>
133
+ </ul>
134
+ </template>
135
+ <template v-else>
136
+ <p class="mt-2">{{ selectedAgents.length }} agent(s) will be deleted.</p>
137
+ </template>
138
+ </template>
139
+ <template #action>
140
+ <n-button @click="showConfirmDialog = false">Cancel</n-button>
141
+ <n-button type="error" :loading="loading" @click="executeDelete">
142
+ Confirm Delete
143
+ </n-button>
144
+ </template>
145
+ </n-modal>
146
+</template>
147
+
148
+<script setup lang="ts">
149
+import type { Agent, BulkDeleteAgentsResponse, BulkDeleteFilterRequest } from "@/types/agents.d"
150
+import {
151
+ NAlert,
152
+ NButton,
153
+ NCollapse,
154
+ NCollapseItem,
155
+ NDivider,
156
+ NForm,
157
+ NFormItem,
158
+ NIcon,
159
+ NInputNumber,
160
+ NModal,
161
+ NScrollbar,
162
+ NSelect,
163
+ NSpace,
164
+ NTabPane,
165
+ NTabs,
166
+ NTag,
167
+ useMessage
168
+} from "naive-ui"
169
+import { computed, ref, watch } from "vue"
170
+import Api from "@/api"
171
+import Icon from "@/components/common/Icon.vue"
172
+
173
+const props = defineProps<{
174
+ show: boolean
175
+ selectedAgents: Agent[]
176
+ customers: string[]
177
+}>()
178
+
179
+const emit = defineEmits<{
180
+ (e: "update:show", value: boolean): void
181
+ (e: "remove-selection", agent: Agent): void
182
+ (e: "deleted"): void
183
+}>()
184
+
185
+const message = useMessage()
186
+const DeleteIcon = "carbon:trash-can"
187
+
188
+const showModal = computed({
189
+ get: () => props.show,
190
+ set: (value) => emit("update:show", value)
191
+})
192
+
193
+const activeTab = ref<"selection" | "filter">("selection")
194
+const loading = ref(false)
195
+const showConfirmDialog = ref(false)
196
+const deleteResults = ref<BulkDeleteAgentsResponse | null>(null)
197
+
198
+const filterForm = ref<BulkDeleteFilterRequest>({
199
+ customer_code: undefined,
200
+ status: undefined,
201
+ disconnected_days: undefined
202
+})
203
+
204
+const customerOptions = computed(() => {
205
+ return props.customers.map(code => ({ label: code, value: code }))
206
+})
207
+
208
+const statusOptions = [
209
+ { label: "Disconnected", value: "disconnected" },
210
+ { label: "Never Connected", value: "never_connected" },
211
+ { label: "Active", value: "active" }
212
+]
213
+
214
+const hasFilters = computed(() => {
215
+ return filterForm.value.customer_code || filterForm.value.status || filterForm.value.disconnected_days
216
+})
217
+
218
+const canDelete = computed(() => {
219
+ if (activeTab.value === "selection") {
220
+ return props.selectedAgents.length > 0
221
+ }
222
+ return hasFilters.value
223
+})
224
+
225
+function closeModal() {
226
+ showModal.value = false
227
+ deleteResults.value = null
228
+}
229
+
230
+function confirmDelete() {
231
+ showConfirmDialog.value = true
232
+}
233
+
234
+async function executeDelete() {
235
+ showConfirmDialog.value = false
236
+ loading.value = true
237
+ deleteResults.value = null
238
+
239
+ try {
240
+ let response
241
+ if (activeTab.value === "selection") {
242
+ const agentIds = props.selectedAgents.map(a => a.agent_id)
243
+ response = await Api.agents.bulkDeleteAgents(agentIds)
244
+ } else {
245
+ response = await Api.agents.bulkDeleteAgentsByFilter(filterForm.value)
246
+ }
247
+
248
+ deleteResults.value = response.data
249
+
250
+ if (response.data.success) {
251
+ message.success(response.data.message)
252
+ emit("deleted")
253
+ } else {
254
+ message.warning(response.data.message)
255
+ }
256
+ } catch (err: any) {
257
+ message.error(err.response?.data?.detail || err.response?.data?.message || "Failed to delete agents")
258
+ } finally {
259
+ loading.value = false
260
+ }
261
+}
262
+
263
+// Reset form when modal closes
264
+watch(showModal, (newVal) => {
265
+ if (!newVal) {
266
+ filterForm.value = {
267
+ customer_code: undefined,
268
+ status: undefined,
269
+ disconnected_days: undefined
270
+ }
271
+ deleteResults.value = null
272
+ }
273
+})
274
+
275
+// Switch to selection tab if agents are selected
276
+watch(() => props.selectedAgents.length, (len) => {
277
+ if (len > 0) {
278
+ activeTab.value = "selection"
279
+ }
280
+})
281
+</script>
282
+
283
+<style lang="scss" scoped>
284
+.bulk-delete-modal {
285
+ .tab-content {
286
+ padding-top: 16px;
287
+ }
288
+
289
+ .selected-agents-list {
290
+ display: flex;
291
+ flex-wrap: wrap;
292
+ }
293
+
294
+ .result-item {
295
+ display: flex;
296
+ align-items: center;
297
+ padding: 4px 0;
298
+ border-bottom: 1px solid var(--border-color);
299
+
300
+ &:last-child {
301
+ border-bottom: none;
302
+ }
303
+ }
304
+
305
+ .w-full {
306
+ width: 100%;
307
+ }
308
+}
309
+</style>
frontend/src/components/customers/CustomerItem.vue
+5
-5
@@ -222,10 +222,10 @@
222
<n-scrollbar style="max-height: 470px" trigger="none">
223
<div class="p-6 pt-2">
224
<CustomerHealthcheckList
225
- source="wazuh"
226
- :customer-code="customer.customer_code"
227
- v-model:filters="healthcheckFilters"
228
- />
225
+ v-model:filters="healthcheckFilters"
226
+ source="wazuh"
227
+ :customer-code="customer.customer_code"
228
+ />
229
</div>
230
</n-scrollbar>
231
</n-tab-pane>
@@ -237,9 +237,9 @@
237
<n-scrollbar style="max-height: 470px" trigger="none">
238
<div class="p-6 pt-2">
239
<CustomerHealthcheckList
240
+ v-model:filters="healthcheckFilters"
241
source="velociraptor"
242
:customer-code="customer.customer_code"
242
- v-model:filters="healthcheckFilters"
243
/>
244
</div>
245
</n-scrollbar>
frontend/src/components/sca/StreamingList.vue
+169
-169
@@ -1,181 +1,181 @@
1
<template>
2
- <div class="sca-streaming-list">
3
- <!-- Progress Header -->
4
- <n-card v-if="isStreaming || streamComplete" class="mb-4">
5
- <div class="flex flex-col gap-4">
6
- <!-- Progress Bar -->
7
- <div class="flex items-center gap-4">
8
- <n-progress
9
- type="line"
10
- :percentage="progress.percent_complete"
11
- :status="streamError ? 'error' : streamComplete ? 'success' : 'default'"
12
- :show-indicator="true"
13
- class="flex-grow"
14
- />
15
- <n-button
16
- v-if="!isStreaming && !streamComplete"
17
- type="primary"
18
- @click="startStream"
19
- :loading="isConnecting"
20
- >
21
- <template #icon>
22
- <Icon :name="RefreshIcon" />
23
- </template>
24
- Load SCA Data
25
- </n-button>
26
- <n-button
27
- v-if="isStreaming"
28
- type="error"
29
- @click="stopStream"
30
- >
31
- <template #icon>
32
- <Icon :name="StopIcon" />
33
- </template>
34
- Stop
35
- </n-button>
36
- <n-button
37
- v-if="streamComplete"
38
- @click="startStream"
39
- >
40
- <template #icon>
41
- <Icon :name="RefreshIcon" />
42
- </template>
43
- Refresh
44
- </n-button>
45
- </div>
46
-
47
- <!-- Status Text -->
48
- <div class="flex items-center justify-between text-sm">
49
- <span class="text-secondary">
50
- {{ statusMessage }}
51
- </span>
52
- <div class="flex gap-4">
53
- <span>
54
- Agents:
55
- <code class="text-success">{{ progress.successful }}</code>
56
- /
57
- <code>{{ progress.total }}</code>
58
- <code v-if="progress.failed > 0" class="text-error ml-1">
59
- ({{ progress.failed }} failed)
60
- </code>
61
- </span>
62
- <span>
63
- Results: <code>{{ results.length }}</code>
64
- </span>
65
- </div>
66
- </div>
67
- </div>
68
- </n-card>
69
-
70
- <!-- Statistics Summary (shown when complete) -->
71
- <n-card v-if="streamComplete && stats" class="mb-4">
72
- <div class="flex flex-wrap justify-between gap-4">
73
- <n-statistic label="Total Agents" :value="stats.total_agents" />
74
- <n-statistic label="Total Policies" :value="stats.total_policies" />
75
- <n-statistic label="Average Score">
76
- <template #default>
77
- <span :class="getScoreClass(stats.average_score)">
78
- {{ stats.average_score }}%
79
- </span>
80
- </template>
81
- </n-statistic>
82
- <n-statistic label="Checks" :value="stats.total_checks" />
83
- <n-statistic label="Passed" :value="stats.total_passes" class="text-success" />
84
- <n-statistic label="Failed" :value="stats.total_fails" class="text-error" />
85
- </div>
86
- </n-card>
87
-
88
- <!-- Filters -->
89
- <n-card class="mb-4">
90
- <div class="flex flex-wrap gap-4">
91
- <n-select
92
- v-model:value="filters.customer_code"
93
- placeholder="All Customers"
94
- :options="customerOptions"
95
- clearable
96
- class="w-48"
97
- @update:value="onFilterChange"
98
- />
99
- <n-input
100
- v-model:value="filters.agent_name"
101
- placeholder="Agent Name"
102
- clearable
103
- class="w-48"
104
- @update:value="onFilterChange"
105
- />
106
- <n-input
107
- v-model:value="filters.policy_name"
108
- placeholder="Policy Name"
109
- clearable
110
- class="w-48"
111
- @update:value="onFilterChange"
112
- />
113
- <n-input-number
114
- v-model:value="filters.min_score"
115
- placeholder="Min Score"
116
- :min="0"
117
- :max="100"
118
- clearable
119
- class="w-32"
120
- @update:value="onFilterChange"
121
- />
122
- <n-input-number
123
- v-model:value="filters.max_score"
124
- placeholder="Max Score"
125
- :min="0"
126
- :max="100"
127
- clearable
128
- class="w-32"
129
- @update:value="onFilterChange"
130
- />
131
- </div>
132
- </n-card>
133
-
134
- <!-- Results List -->
135
- <div class="results-container">
136
- <n-spin :show="isConnecting">
137
- <!-- Empty State -->
138
- <n-empty
139
- v-if="!isStreaming && !streamComplete && filteredResults.length === 0"
140
- description="Click 'Load SCA Data' to start collecting results"
141
- class="py-12"
142
- >
143
- <template #extra>
144
- <n-button type="primary" @click="startStream">
145
- Load SCA Data
146
- </n-button>
147
- </template>
148
- </n-empty>
149
-
150
- <!-- Results Table -->
151
- <n-data-table
152
- v-else
153
- :columns="columns"
154
- :data="paginatedResults"
155
- :pagination="pagination"
156
- :loading="isConnecting"
157
- :row-key="(row: AgentScaOverviewItem) => `${row.agent_id}-${row.policy_id}`"
158
- striped
159
- />
160
- </n-spin>
161
- </div>
162
-
163
- <!-- Error Display -->
164
- <n-alert v-if="streamError" type="error" class="mt-4" closable @close="streamError = null">
165
- <template #header>Stream Error</template>
166
- {{ streamError }}
167
- </n-alert>
168
- </div>
2
+ <div class="sca-streaming-list">
3
+ <!-- Progress Header -->
4
+ <n-card v-if="isStreaming || streamComplete" class="mb-4">
5
+ <div class="flex flex-col gap-4">
6
+ <!-- Progress Bar -->
7
+ <div class="flex items-center gap-4">
8
+ <n-progress
9
+ type="line"
10
+ :percentage="progress.percent_complete"
11
+ :status="streamError ? 'error' : streamComplete ? 'success' : 'default'"
12
+ :show-indicator="true"
13
+ class="flex-grow"
14
+ />
15
+ <n-button
16
+ v-if="!isStreaming && !streamComplete"
17
+ type="primary"
18
+ :loading="isConnecting"
19
+ @click="startStream"
20
+ >
21
+ <template #icon>
22
+ <Icon :name="RefreshIcon" />
23
+ </template>
24
+ Load SCA Data
25
+ </n-button>
26
+ <n-button
27
+ v-if="isStreaming"
28
+ type="error"
29
+ @click="stopStream"
30
+ >
31
+ <template #icon>
32
+ <Icon :name="StopIcon" />
33
+ </template>
34
+ Stop
35
+ </n-button>
36
+ <n-button
37
+ v-if="streamComplete"
38
+ @click="startStream"
39
+ >
40
+ <template #icon>
41
+ <Icon :name="RefreshIcon" />
42
+ </template>
43
+ Refresh
44
+ </n-button>
45
+ </div>
46
+
47
+ <!-- Status Text -->
48
+ <div class="flex items-center justify-between text-sm">
49
+ <span class="text-secondary">
50
+ {{ statusMessage }}
51
+ </span>
52
+ <div class="flex gap-4">
53
+ <span>
54
+ Agents:
55
+ <code class="text-success">{{ progress.successful }}</code>
56
+ /
57
+ <code>{{ progress.total }}</code>
58
+ <code v-if="progress.failed > 0" class="text-error ml-1">
59
+ ({{ progress.failed }} failed)
60
+ </code>
61
+ </span>
62
+ <span>
63
+ Results: <code>{{ results.length }}</code>
64
+ </span>
65
+ </div>
66
+ </div>
67
+ </div>
68
+ </n-card>
69
+
70
+ <!-- Statistics Summary (shown when complete) -->
71
+ <n-card v-if="streamComplete && stats" class="mb-4">
72
+ <div class="flex flex-wrap justify-between gap-4">
73
+ <n-statistic label="Total Agents" :value="stats.total_agents" />
74
+ <n-statistic label="Total Policies" :value="stats.total_policies" />
75
+ <n-statistic label="Average Score">
76
+ <template #default>
77
+ <span :class="getScoreClass(stats.average_score)">
78
+ {{ stats.average_score }}%
79
+ </span>
80
+ </template>
81
+ </n-statistic>
82
+ <n-statistic label="Checks" :value="stats.total_checks" />
83
+ <n-statistic label="Passed" :value="stats.total_passes" class="text-success" />
84
+ <n-statistic label="Failed" :value="stats.total_fails" class="text-error" />
85
+ </div>
86
+ </n-card>
87
+
88
+ <!-- Filters -->
89
+ <n-card class="mb-4">
90
+ <div class="flex flex-wrap gap-4">
91
+ <n-select
92
+ v-model:value="filters.customer_code"
93
+ placeholder="All Customers"
94
+ :options="customerOptions"
95
+ clearable
96
+ class="w-48"
97
+ @update:value="onFilterChange"
98
+ />
99
+ <n-input
100
+ v-model:value="filters.agent_name"
101
+ placeholder="Agent Name"
102
+ clearable
103
+ class="w-48"
104
+ @update:value="onFilterChange"
105
+ />
106
+ <n-input
107
+ v-model:value="filters.policy_name"
108
+ placeholder="Policy Name"
109
+ clearable
110
+ class="w-48"
111
+ @update:value="onFilterChange"
112
+ />
113
+ <n-input-number
114
+ v-model:value="filters.min_score"
115
+ placeholder="Min Score"
116
+ :min="0"
117
+ :max="100"
118
+ clearable
119
+ class="w-32"
120
+ @update:value="onFilterChange"
121
+ />
122
+ <n-input-number
123
+ v-model:value="filters.max_score"
124
+ placeholder="Max Score"
125
+ :min="0"
126
+ :max="100"
127
+ clearable
128
+ class="w-32"
129
+ @update:value="onFilterChange"
130
+ />
131
+ </div>
132
+ </n-card>
133
+
134
+ <!-- Results List -->
135
+ <div class="results-container">
136
+ <n-spin :show="isConnecting">
137
+ <!-- Empty State -->
138
+ <n-empty
139
+ v-if="!isStreaming && !streamComplete && filteredResults.length === 0"
140
+ description="Click 'Load SCA Data' to start collecting results"
141
+ class="py-12"
142
+ >
143
+ <template #extra>
144
+ <n-button type="primary" @click="startStream">
145
+ Load SCA Data
146
+ </n-button>
147
+ </template>
148
+ </n-empty>
149
+
150
+ <!-- Results Table -->
151
+ <n-data-table
152
+ v-else
153
+ :columns="columns"
154
+ :data="paginatedResults"
155
+ :pagination="pagination"
156
+ :loading="isConnecting"
157
+ :row-key="(row: AgentScaOverviewItem) => `${row.agent_id}-${row.policy_id}`"
158
+ striped
159
+ />
160
+ </n-spin>
161
+ </div>
162
+
163
+ <!-- Error Display -->
164
+ <n-alert v-if="streamError" type="error" class="mt-4" closable @close="streamError = null">
165
+ <template #header>Stream Error</template>
166
+ {{ streamError }}
167
+ </n-alert>
168
+ </div>
169
</template>
170
171
<script setup lang="ts">
172
+import type { DataTableColumns } from "naive-ui"
173
import type {
174
AgentScaOverviewItem,
175
ScaOverviewQuery,
176
ScaStreamComplete,
177
ScaStreamProgress
178
} from "@/types/sca.d"
178
-import type { DataTableColumns } from "naive-ui"
179
import {
180
NAlert,
181
NButton,
@@ -192,8 +192,8 @@ import {
192
} from "naive-ui"
193
import { computed, h, onBeforeUnmount, reactive, ref } from "vue"
194
import Api from "@/api"
195
-import Icon from "@/components/common/Icon.vue"
195
import Badge from "@/components/common/Badge.vue"
196
+import Icon from "@/components/common/Icon.vue"
197
198
const RefreshIcon = "carbon:refresh"
199
const StopIcon = "carbon:stop"
frontend/src/types/agents.d.ts
+28
@@ -121,3 +121,31 @@ export interface AgentArtifactData {
121
notes: string | null
122
status: string
123
}
124
+
125
+// Bulk Delete Types
126
+export type BulkDeleteAgentStatus = "disconnected" | "never_connected" | "active"
127
+
128
+export interface BulkDeleteAgentRequest {
129
+ agent_ids: string[]
130
+}
131
+
132
+export interface BulkDeleteFilterRequest {
133
+ customer_code?: string
134
+ status?: BulkDeleteAgentStatus
135
+ disconnected_days?: number
136
+}
137
+
138
+export interface BulkDeleteAgentResult {
139
+ agent_id: string
140
+ success: boolean
141
+ message: string
142
+}
143
+
144
+export interface BulkDeleteAgentsResponse {
145
+ success: boolean
146
+ message: string
147
+ total_requested: number
148
+ successful_deletions: number
149
+ failed_deletions: number
150
+ results: BulkDeleteAgentResult[]
151
+}
frontend/src/views/agents/Agents.vue
+234
-166
@@ -9,8 +9,13 @@
9
:agents-filtered-length="agentsFiltered.length"
10
:agents-critical="agentsCritical"
11
:agents-online="agentsOnline"
12
+ :selection-mode="selectionMode"
13
+ :selected-count="selectedAgents.length"
14
@run="runCommand($event)"
15
@click="gotoAgent($event.agent_id)"
16
+ @bulk-delete="showBulkDeleteModal = true"
17
+ @update:selection-mode="selectionMode = $event"
18
+ @clear-selection="clearSelection"
19
/>
20
</div>
21
<div class="main flex grow flex-col overflow-hidden">
@@ -22,12 +27,15 @@
27
v-for="agent in itemsPaginated"
28
:key="agent.agent_id"
29
:agent
25
- show-actions
30
+ :show-actions="!selectionMode"
31
+ :selectable="selectionMode"
32
+ :selected="isAgentSelected(agent)"
33
hoverable
34
clickable
35
class="item-appear item-appear-bottom item-appear-005"
36
@delete="syncAgents()"
30
- @click.stop="gotoAgent(agent.agent_id)"
37
+ @click="handleAgentClick(agent)"
38
+ @toggle-selection="toggleAgentSelection(agent)"
39
/>
40
</template>
41
<template v-else>
@@ -51,6 +59,15 @@
59
</div>
60
</div>
61
</div>
62
+
63
+ <!-- Bulk Delete Modal -->
64
+ <BulkDeleteModal
65
+ v-model:show="showBulkDeleteModal"
66
+ :selected-agents="selectedAgents"
67
+ :customers="uniqueCustomers"
68
+ @remove-selection="removeFromSelection"
69
+ @deleted="onBulkDeleteComplete"
70
+ />
71
</div>
72
</template>
73
@@ -63,6 +80,7 @@ import { computed, onBeforeMount, ref, watch } from "vue"
80
import Api from "@/api"
81
import AgentCard from "@/components/agents/AgentCard.vue"
82
import AgentToolbar from "@/components/agents/AgentToolbar.vue"
83
+import BulkDeleteModal from "@/components/agents/BulkDeleteModal.vue"
84
import { useGoto } from "@/composables/useGoto"
85
import { AgentStatus } from "@/types/agents.d"
86
@@ -75,208 +93,258 @@ const textFilter = ref("")
93
const page = ref(1)
94
const pageSize = ref(20)
95
96
+// Selection mode state
97
+const selectionMode = ref(false)
98
+const selectedAgents = ref<Agent[]>([])
99
+const showBulkDeleteModal = ref(false)
100
+
101
const textFilterDebounced = ref("")
102
103
const update = _debounce(value => {
81
- textFilterDebounced.value = value
104
+ textFilterDebounced.value = value
105
}, 100)
106
107
watch(textFilter, val => {
85
- update(val)
108
+ update(val)
109
})
110
111
const agentsFiltered = computed(() => {
89
- return agents.value
90
- .filter(({ hostname, ip_address, agent_id, label }) =>
91
- (hostname + ip_address + agent_id + label)
92
- .toString()
93
- .toLowerCase()
94
- .includes(textFilterDebounced.value.toString().toLowerCase())
95
- )
96
- .sort((a, b) => parseInt(a.agent_id) - parseInt(b.agent_id))
112
+ return agents.value
113
+ .filter(({ hostname, ip_address, agent_id, label }) =>
114
+ (hostname + ip_address + agent_id + label)
115
+ .toString()
116
+ .toLowerCase()
117
+ .includes(textFilterDebounced.value.toString().toLowerCase())
118
+ )
119
+ .sort((a, b) => Number.parseInt(a.agent_id) - Number.parseInt(b.agent_id))
120
})
121
122
const itemsPaginated = computed(() => {
100
- const from = (page.value - 1) * pageSize.value
101
- const to = page.value * pageSize.value
123
+ const from = (page.value - 1) * pageSize.value
124
+ const to = page.value * pageSize.value
125
103
- return agentsFiltered.value.slice(from, to)
126
+ return agentsFiltered.value.slice(from, to)
127
})
128
129
const agentsCritical = computed(() => {
107
- return agents.value.filter(({ critical_asset }) => critical_asset)
130
+ return agents.value.filter(({ critical_asset }) => critical_asset)
131
})
132
133
const agentsOnline = computed(() => {
111
- return agents.value.filter(({ wazuh_agent_status }) => wazuh_agent_status === AgentStatus.Active)
134
+ return agents.value.filter(({ wazuh_agent_status }) => wazuh_agent_status === AgentStatus.Active)
135
+})
136
+
137
+// Get unique customer codes for filter dropdown
138
+const uniqueCustomers = computed(() => {
139
+ const codes = new Set(agents.value.map(a => a.customer_code).filter(Boolean))
140
+ return Array.from(codes) as string[]
141
})
142
143
+// Selection helpers
144
+function isAgentSelected(agent: Agent): boolean {
145
+ return selectedAgents.value.some(a => a.agent_id === agent.agent_id)
146
+}
147
+
148
+function toggleAgentSelection(agent: Agent) {
149
+ const index = selectedAgents.value.findIndex(a => a.agent_id === agent.agent_id)
150
+ if (index === -1) {
151
+ selectedAgents.value.push(agent)
152
+ } else {
153
+ selectedAgents.value.splice(index, 1)
154
+ }
155
+}
156
+
157
+function removeFromSelection(agent: Agent) {
158
+ const index = selectedAgents.value.findIndex(a => a.agent_id === agent.agent_id)
159
+ if (index !== -1) {
160
+ selectedAgents.value.splice(index, 1)
161
+ }
162
+}
163
+
164
+function clearSelection() {
165
+ selectedAgents.value = []
166
+}
167
+
168
+function handleAgentClick(agent: Agent) {
169
+ if (selectionMode.value) {
170
+ toggleAgentSelection(agent)
171
+ } else {
172
+ gotoAgent(agent.agent_id)
173
+ }
174
+}
175
+
176
+function onBulkDeleteComplete() {
177
+ clearSelection()
178
+ selectionMode.value = false
179
+ getAgents()
180
+}
181
+
182
function runCommand(command: string) {
115
- if (command === "sync-agents") {
116
- syncAgents()
117
- } else if (_split(command, ":").length) {
118
- syncVulnerabilities(_split(command, ":")[1])
119
- }
183
+ if (command === "sync-agents") {
184
+ syncAgents()
185
+ } else if (_split(command, ":").length) {
186
+ syncVulnerabilities(_split(command, ":")[1])
187
+ }
188
}
189
190
function getAgents() {
123
- loadingAgents.value = true
124
-
125
- Api.agents
126
- .getAgents()
127
- .then(res => {
128
- if (res.data.success) {
129
- agents.value = res.data.agents || []
130
- } else {
131
- message.error(res.data?.message || "An error occurred. Please try again later.")
132
- }
133
- })
134
- .catch(err => {
135
- message.error(err.response?.data?.message || "An error occurred. Please try again later.")
136
- })
137
- .finally(() => {
138
- loadingAgents.value = false
139
- })
191
+ loadingAgents.value = true
192
+
193
+ Api.agents
194
+ .getAgents()
195
+ .then(res => {
196
+ if (res.data.success) {
197
+ agents.value = res.data.agents || []
198
+ } else {
199
+ message.error(res.data?.message || "An error occurred. Please try again later.")
200
+ }
201
+ })
202
+ .catch(err => {
203
+ message.error(err.response?.data?.message || "An error occurred. Please try again later.")
204
+ })
205
+ .finally(() => {
206
+ loadingAgents.value = false
207
+ })
208
}
209
210
function syncAgents() {
143
- loadingSync.value = true
144
-
145
- Api.agents
146
- .syncAgents()
147
- .then(res => {
148
- if (res.data.success) {
149
- message.success("Agents Synced Successfully")
150
- getAgents()
151
- } else {
152
- message.error("An error occurred. Please try again later.")
153
- }
154
- })
155
- .catch(err => {
156
- if (err.response?.status === 401) {
157
- message.error(err.response?.data?.message || "Sync returned Unauthorized.")
158
- } else {
159
- message.error(err.response?.data?.message || "Failed to Sync Agents")
160
- }
161
- })
162
- .finally(() => {
163
- loadingSync.value = false
164
- })
211
+ loadingSync.value = true
212
+
213
+ Api.agents
214
+ .syncAgents()
215
+ .then(res => {
216
+ if (res.data.success) {
217
+ message.success("Agents Synced Successfully")
218
+ getAgents()
219
+ } else {
220
+ message.error("An error occurred. Please try again later.")
221
+ }
222
+ })
223
+ .catch(err => {
224
+ if (err.response?.status === 401) {
225
+ message.error(err.response?.data?.message || "Sync returned Unauthorized.")
226
+ } else {
227
+ message.error(err.response?.data?.message || "Failed to Sync Agents")
228
+ }
229
+ })
230
+ .finally(() => {
231
+ loadingSync.value = false
232
+ })
233
}
234
235
function syncVulnerabilities(customerCode: string) {
168
- loadingSync.value = true
169
-
170
- Api.agents
171
- .syncVulnerabilities(customerCode)
172
- .then(res => {
173
- if (res.data.success) {
174
- message.success("Agent vulnerabilities synced successfully")
175
- getAgents()
176
- } else {
177
- message.error("An error occurred. Please try again later.")
178
- }
179
- })
180
- .catch(err => {
181
- if (err.response?.status === 401) {
182
- message.error(err.response?.data?.message || "Sync returned Unauthorized.")
183
- } else {
184
- message.error(err.response?.data?.message || "Failed to Sync Agents")
185
- }
186
- })
187
- .finally(() => {
188
- loadingSync.value = false
189
- })
236
+ loadingSync.value = true
237
+
238
+ Api.agents
239
+ .syncVulnerabilities(customerCode)
240
+ .then(res => {
241
+ if (res.data.success) {
242
+ message.success("Agent vulnerabilities synced successfully")
243
+ getAgents()
244
+ } else {
245
+ message.error("An error occurred. Please try again later.")
246
+ }
247
+ })
248
+ .catch(err => {
249
+ if (err.response?.status === 401) {
250
+ message.error(err.response?.data?.message || "Sync returned Unauthorized.")
251
+ } else {
252
+ message.error(err.response?.data?.message || "Failed to Sync Agents")
253
+ }
254
+ })
255
+ .finally(() => {
256
+ loadingSync.value = false
257
+ })
258
}
259
260
onBeforeMount(() => {
193
- getAgents()
261
+ getAgents()
262
})
263
</script>
264
265
<style lang="scss" scoped>
266
.page {
199
- container-type: inline-size;
200
-
201
- .wrapper {
202
- position: relative;
203
- height: 100%;
204
- overflow: hidden;
205
-
206
- .sidebar {
207
- .agent-toolbar {
208
- height: 100%;
209
- }
210
- }
211
-
212
- :deep() {
213
- .n-spin-content {
214
- overflow: hidden;
215
- max-height: 100%;
216
- }
217
- }
218
-
219
- .main {
220
- position: relative;
221
- border-radius: var(--border-radius);
222
-
223
- :deep() {
224
- .n-scrollbar > .n-scrollbar-rail.n-scrollbar-rail--vertical {
225
- right: 0;
226
- bottom: 50px;
227
- }
228
- }
229
-
230
- .pagination-wrapper {
231
- --size: 10px;
232
- position: absolute;
233
- bottom: 0;
234
- right: 0;
235
- background-color: var(--bg-body-color);
236
- padding-left: var(--size);
237
- padding-top: var(--size);
238
- border-top-left-radius: var(--size);
239
-
240
- &::before,
241
- &::after {
242
- content: "";
243
- position: absolute;
244
- width: var(--size);
245
- height: var(--size);
246
- left: calc(var(--size) * -1);
247
- display: block;
248
- bottom: 0px;
249
- z-index: 1;
250
- background-image: radial-gradient(
251
- circle at 0 0,
252
- rgba(0, 0, 0, 0) calc(var(--size) - 1px),
253
- var(--bg-body-color) calc(var(--size) + 0px)
254
- );
255
- }
256
-
257
- &::after {
258
- bottom: initial;
259
- left: initial;
260
- top: calc(var(--size) * -1);
261
- right: 0;
262
- }
263
- }
264
- }
265
-
266
- .agents-list {
267
- width: 100%;
268
-
269
- .item-appear {
270
- &:last-child {
271
- margin-bottom: 50px;
272
- }
273
- }
274
- }
275
- }
276
- @container (max-width: 770px) {
277
- .wrapper {
278
- flex-direction: column;
279
- }
280
- }
267
+ container-type: inline-size;
268
+
269
+ .wrapper {
270
+ position: relative;
271
+ height: 100%;
272
+ overflow: hidden;
273
+
274
+ .sidebar {
275
+ .agent-toolbar {
276
+ height: 100%;
277
+ }
278
+ }
279
+
280
+ :deep() {
281
+ .n-spin-content {
282
+ overflow: hidden;
283
+ max-height: 100%;
284
+ }
285
+ }
286
+
287
+ .main {
288
+ position: relative;
289
+ border-radius: var(--border-radius);
290
+
291
+ :deep() {
292
+ .n-scrollbar > .n-scrollbar-rail.n-scrollbar-rail--vertical {
293
+ right: 0;
294
+ bottom: 50px;
295
+ }
296
+ }
297
+
298
+ .pagination-wrapper {
299
+ --size: 10px;
300
+ position: absolute;
301
+ bottom: 0;
302
+ right: 0;
303
+ background-color: var(--bg-body-color);
304
+ padding-left: var(--size);
305
+ padding-top: var(--size);
306
+ border-top-left-radius: var(--size);
307
+
308
+ &::before,
309
+ &::after {
310
+ content: "";
311
+ position: absolute;
312
+ width: var(--size);
313
+ height: var(--size);
314
+ left: calc(var(--size) * -1);
315
+ display: block;
316
+ bottom: 0px;
317
+ z-index: 1;
318
+ background-image: radial-gradient(
319
+ circle at 0 0,
320
+ rgba(0, 0, 0, 0) calc(var(--size) - 1px),
321
+ var(--bg-body-color) calc(var(--size) + 0px)
322
+ );
323
+ }
324
+
325
+ &::after {
326
+ bottom: initial;
327
+ left: initial;
328
+ top: calc(var(--size) * -1);
329
+ right: 0;
330
+ }
331
+ }
332
+ }
333
+
334
+ .agents-list {
335
+ width: 100%;
336
+
337
+ .item-appear {
338
+ &:last-child {
339
+ margin-bottom: 50px;
340
+ }
341
+ }
342
+ }
343
+ }
344
+ @container (max-width: 770px) {
345
+ .wrapper {
346
+ flex-direction: column;
347
+ }
348
+ }
349
}
350
</style>