| 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> |