@cryptotaxi247 / CoPilot / commits / 0b24c4d8

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>