main
vue 449 lines 10.9 KB
Raw
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
13 class="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 v-if="isStreaming" type="error" @click="stopStream">
27 <template #icon>
28 <Icon :name="StopIcon" />
29 </template>
30 Stop
31 </n-button>
32 <n-button v-if="streamComplete" @click="startStream">
33 <template #icon>
34 <Icon :name="RefreshIcon" />
35 </template>
36 Refresh
37 </n-button>
38 </div>
39
40 <!-- Status Text -->
41 <div class="flex items-center justify-between text-sm">
42 <span class="text-secondary">
43 {{ statusMessage }}
44 </span>
45 <div class="flex gap-4">
46 <span>
47 Agents:
48 <code class="text-success">{{ progress.successful }}</code>
49 /
50 <code>{{ progress.total }}</code>
51 <code v-if="progress.failed > 0" class="text-error ml-1">
52 ({{ progress.failed }} failed)
53 </code>
54 </span>
55 <span>
56 Results:
57 <code>{{ results.length }}</code>
58 </span>
59 </div>
60 </div>
61 </div>
62 </n-card>
63
64 <!-- Statistics Summary (shown when complete) -->
65 <n-card v-if="streamComplete && stats" class="mb-4">
66 <div class="flex flex-wrap justify-between gap-4">
67 <n-statistic label="Total Agents" :value="stats.total_agents" />
68 <n-statistic label="Total Policies" :value="stats.total_policies" />
69 <n-statistic label="Average Score">
70 <template #default>
71 <span :class="getScoreClass(stats.average_score)">{{ stats.average_score }}%</span>
72 </template>
73 </n-statistic>
74 <n-statistic label="Checks" :value="stats.total_checks" />
75 <n-statistic label="Passed" :value="stats.total_passes" class="text-success" />
76 <n-statistic label="Failed" :value="stats.total_fails" class="text-error" />
77 </div>
78 </n-card>
79
80 <!-- Filters -->
81 <n-card class="mb-4">
82 <div class="flex flex-wrap gap-4">
83 <n-select
84 v-model:value="filters.customer_code"
85 placeholder="All Customers"
86 :options="customerOptions"
87 clearable
88 class="w-48"
89 @update:value="onFilterChange"
90 />
91 <n-input
92 v-model:value="filters.agent_name"
93 placeholder="Agent Name"
94 clearable
95 class="w-48"
96 @update:value="onFilterChange"
97 />
98 <n-input
99 v-model:value="filters.policy_name"
100 placeholder="Policy Name"
101 clearable
102 class="w-48"
103 @update:value="onFilterChange"
104 />
105 <n-input-number
106 v-model:value="filters.min_score"
107 placeholder="Min Score"
108 :min="0"
109 :max="100"
110 clearable
111 class="w-32"
112 @update:value="onFilterChange"
113 />
114 <n-input-number
115 v-model:value="filters.max_score"
116 placeholder="Max Score"
117 :min="0"
118 :max="100"
119 clearable
120 class="w-32"
121 @update:value="onFilterChange"
122 />
123 </div>
124 </n-card>
125
126 <!-- Results List -->
127 <div class="results-container">
128 <n-spin :show="isConnecting">
129 <!-- Empty State -->
130 <n-empty
131 v-if="!isStreaming && !streamComplete && filteredResults.length === 0"
132 description="Click 'Load SCA Data' to start collecting results"
133 class="py-12"
134 >
135 <template #extra>
136 <n-button type="primary" @click="startStream">Load SCA Data</n-button>
137 </template>
138 </n-empty>
139
140 <!-- Results Table -->
141 <n-data-table
142 v-else
143 :columns
144 :data="paginatedResults"
145 :pagination
146 :loading="isConnecting"
147 :row-key="(row: AgentScaOverviewItem) => `${row.agent_id}-${row.policy_id}`"
148 striped
149 />
150 </n-spin>
151 </div>
152
153 <!-- Error Display -->
154 <n-alert v-if="streamError" type="error" class="mt-4" closable @close="streamError = null">
155 <template #header>Stream Error</template>
156 {{ streamError }}
157 </n-alert>
158 </div>
159 </template>
160
161 <script setup lang="ts">
162 // TODO-FE: refactor
163 import type { DataTableColumns } from "naive-ui"
164 import type { AgentScaOverviewItem, ScaOverviewQuery, ScaStreamComplete, ScaStreamProgress } from "@/types/sca.d"
165 import {
166 NAlert,
167 NButton,
168 NCard,
169 NDataTable,
170 NEmpty,
171 NInput,
172 NInputNumber,
173 NProgress,
174 NSelect,
175 NSpin,
176 NStatistic,
177 useMessage
178 } from "naive-ui"
179 import { computed, h, onBeforeUnmount, reactive, ref } from "vue"
180 import Api from "@/api"
181 import Badge from "@/components/common/Badge.vue"
182 import Icon from "@/components/common/Icon.vue"
183
184 const RefreshIcon = "carbon:refresh"
185 const StopIcon = "carbon:stop"
186
187 const message = useMessage()
188
189 // State
190 const isConnecting = ref(false)
191 const isStreaming = ref(false)
192 const streamComplete = ref(false)
193 const streamError = ref<string | null>(null)
194 const results = ref<AgentScaOverviewItem[]>([])
195 const stats = ref<ScaStreamComplete | null>(null)
196 const abortController = ref<AbortController | null>(null)
197
198 const progress = reactive<ScaStreamProgress>({
199 processed: 0,
200 total: 0,
201 successful: 0,
202 failed: 0,
203 results_so_far: 0,
204 percent_complete: 0
205 })
206
207 const filters = reactive<ScaOverviewQuery>({
208 customer_code: undefined,
209 agent_name: undefined,
210 policy_name: undefined,
211 min_score: undefined,
212 max_score: undefined
213 })
214
215 // Customer options (you'd populate this from your API)
216 const customerOptions = ref<{ label: string; value: string }[]>([])
217
218 // Computed
219 const statusMessage = computed(() => {
220 if (isConnecting.value) return "Connecting..."
221 if (isStreaming.value) return `Collecting SCA data... ${progress.processed}/${progress.total} agents`
222 if (streamComplete.value) return stats.value?.message || "Collection complete"
223 return "Ready to load SCA data"
224 })
225
226 const filteredResults = computed(() => {
227 return results.value.filter(item => {
228 if (filters.policy_name && !item.policy_name.toLowerCase().includes(filters.policy_name.toLowerCase())) {
229 return false
230 }
231 return true
232 })
233 })
234
235 const pagination = reactive({
236 page: 1,
237 pageSize: 25,
238 showSizePicker: true,
239 pageSizes: [10, 25, 50, 100],
240 itemCount: computed(() => filteredResults.value.length),
241 onChange: (page: number) => {
242 pagination.page = page
243 },
244 onUpdatePageSize: (pageSize: number) => {
245 pagination.pageSize = pageSize
246 pagination.page = 1
247 }
248 })
249
250 const paginatedResults = computed(() => {
251 const start = (pagination.page - 1) * pagination.pageSize
252 const end = start + pagination.pageSize
253 return filteredResults.value.slice(start, end)
254 })
255
256 // Table columns
257 const columns: DataTableColumns<AgentScaOverviewItem> = [
258 {
259 title: "Agent",
260 key: "agent_name",
261 width: 150,
262 ellipsis: { tooltip: true }
263 },
264 {
265 title: "Customer",
266 key: "customer_code",
267 width: 120
268 },
269 {
270 title: "Policy",
271 key: "policy_name",
272 ellipsis: { tooltip: true }
273 },
274 {
275 title: "Checks",
276 key: "total_checks",
277 width: 80,
278 align: "center"
279 },
280 {
281 title: "Passed",
282 key: "pass_count",
283 width: 80,
284 align: "center",
285 render: row => h("span", { class: "text-success" }, row.pass)
286 },
287 {
288 title: "Failed",
289 key: "fail_count",
290 width: 80,
291 align: "center",
292 render: row => h("span", { class: "text-error" }, row.fail)
293 },
294 {
295 title: "Score",
296 key: "score",
297 width: 100,
298 align: "center",
299 sorter: (a, b) => a.score - b.score,
300 render: row =>
301 h(
302 Badge,
303 {
304 type: "splitted",
305 color: row.score >= 80 ? "success" : row.score >= 60 ? "warning" : "danger"
306 },
307 { label: () => `${row.score}%` }
308 )
309 },
310 {
311 title: "Last Scan",
312 key: "end_scan",
313 width: 160,
314 render: row => new Date(row.end_scan).toLocaleString()
315 }
316 ]
317
318 // Methods
319 function getScoreClass(score: number): string {
320 if (score >= 80) return "text-success"
321 if (score >= 60) return "text-warning"
322 return "text-error"
323 }
324
325 async function startStream() {
326 // Reset state
327 results.value = []
328 stats.value = null
329 streamError.value = null
330 streamComplete.value = false
331 isConnecting.value = true
332
333 Object.assign(progress, {
334 processed: 0,
335 total: 0,
336 successful: 0,
337 failed: 0,
338 results_so_far: 0,
339 percent_complete: 0
340 })
341
342 // Abort existing connection if any
343 if (abortController.value) {
344 abortController.value.abort()
345 }
346
347 // Create new abort controller
348 abortController.value = new AbortController()
349
350 // Build query params
351 const query: ScaOverviewQuery = {}
352 if (filters.customer_code) query.customer_code = filters.customer_code
353 if (filters.agent_name) query.agent_name = filters.agent_name
354 if (filters.policy_name) query.policy_name = filters.policy_name
355 if (filters.min_score !== undefined) query.min_score = filters.min_score
356 if (filters.max_score !== undefined) query.max_score = filters.max_score
357
358 try {
359 await Api.sca.streamScaOverview(
360 query,
361 {
362 onStart(data) {
363 isConnecting.value = false
364 isStreaming.value = true
365 progress.total = data.total_agents
366 message.info(data.message)
367 },
368 onAgentResult(data) {
369 // Add all policies from this agent
370 for (const policy of data.policies) {
371 results.value.push({
372 agent_id: data.agent_id,
373 agent_name: data.agent_name,
374 customer_code: data.customer_code,
375 ...policy
376 })
377 }
378 },
379 onAgentEmpty(data) {
380 // Agent had no SCA data - could log or display if needed
381 console.warn(`Agent ${data.agent_name} has no SCA data`)
382 },
383 onProgress(data) {
384 Object.assign(progress, data)
385 },
386 onComplete(data) {
387 stats.value = data
388 isStreaming.value = false
389 streamComplete.value = true
390
391 // Sort results by score (lowest first)
392 results.value.sort((a, b) => a.score - b.score)
393
394 message.success(data.message)
395 },
396 onError(error) {
397 const errorMessage = error?.message || error?.error || "Unknown error"
398 console.warn("Stream error:", error)
399
400 // Only set error if we haven't completed successfully
401 if (!streamComplete.value) {
402 streamError.value = errorMessage
403 }
404 progress.failed++
405 }
406 },
407 abortController.value
408 )
409 } catch (error: any) {
410 // Don't show error for intentional abort
411 if (error.name !== "AbortError") {
412 streamError.value = error.message || "Connection error"
413 console.error("Stream connection error:", error)
414 }
415 } finally {
416 isStreaming.value = false
417 isConnecting.value = false
418 }
419 }
420
421 function stopStream() {
422 if (abortController.value) {
423 abortController.value.abort()
424 abortController.value = null
425 }
426 isStreaming.value = false
427 isConnecting.value = false
428 message.warning("Stream stopped by user")
429 }
430
431 function onFilterChange() {
432 // Debounce and restart stream with new filters if already streaming
433 // Or just filter client-side if data is already loaded
434 pagination.page = 1
435 }
436
437 // Cleanup on unmount
438 onBeforeUnmount(() => {
439 if (abortController.value) {
440 abortController.value.abort()
441 }
442 })
443 </script>
444
445 <style scoped>
446 .results-container {
447 min-height: 400px;
448 }
449 </style>