Skip to content

Commit df55ad7

Browse files
committed
perf(039): paginate scanned files API (37K files -> 100 per page)
Pagination with limit/offset/suspicious_only. Progressive loading in frontend with "Load more" button. Suspicious files sorted first. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 89a84fb commit df55ad7

26 files changed

Lines changed: 169 additions & 73 deletions

frontend/src/services/api.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -811,8 +811,8 @@ class APIService {
811811
return this.request<any>(`/api/v1/servers/${encodeURIComponent(serverName)}/scan/report`)
812812
}
813813

814-
async getScanFiles(serverName: string): Promise<APIResponse<any>> {
815-
return this.request<any>(`/api/v1/servers/${encodeURIComponent(serverName)}/scan/files`)
814+
async getScanFiles(serverName: string, limit = 100, offset = 0): Promise<APIResponse<any>> {
815+
return this.request<any>(`/api/v1/servers/${encodeURIComponent(serverName)}/scan/files?limit=${limit}&offset=${offset}`)
816816
}
817817

818818
async cancelScan(serverName: string): Promise<APIResponse<void>> {

frontend/src/views/ServerDetail.vue

Lines changed: 70 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -934,7 +934,7 @@
934934
</div>
935935
</div>
936936

937-
<!-- Scanned Files (lazy-loaded collapsible) -->
937+
<!-- Scanned Files (lazy-loaded with pagination) -->
938938
<div v-if="scanContext && scanContext.source_method !== 'none' && scanContext.source_method !== 'url'" class="pt-4">
939939
<div class="collapse collapse-arrow bg-base-100 shadow-md">
940940
<input type="checkbox" @change="onScannedFilesToggle" />
@@ -943,34 +943,53 @@
943943
<span v-if="scanContext.total_files" class="text-base-content/60 font-normal">
944944
({{ scanContext.total_files }} files, {{ formatFileSize(scanContext.total_size_bytes) }})
945945
</span>
946+
<span v-if="scanFilesMeta.suspicious_count" class="badge badge-error badge-sm ml-2">
947+
{{ scanFilesMeta.suspicious_count }} suspicious
948+
</span>
946949
</div>
947950
<div class="collapse-content">
948-
<div v-if="scanFilesLoading" class="text-center py-4">
951+
<div v-if="scanFilesLoading && scanFiles.length === 0" class="text-center py-4">
949952
<span class="loading loading-spinner loading-sm"></span>
950953
<span class="ml-2 text-sm">Loading file list...</span>
951954
</div>
952-
<div v-else-if="scanFiles.length === 0" class="text-sm text-base-content/40 py-2">
955+
<div v-else-if="!scanFilesLoading && scanFiles.length === 0" class="text-sm text-base-content/40 py-2">
953956
No file information available.
954957
</div>
955-
<ul v-else class="space-y-0.5 py-1">
956-
<li
957-
v-for="(file, idx) in scanFiles"
958-
:key="file.path"
959-
class="flex items-center gap-2 py-0.5"
960-
>
961-
<span class="text-base-content/30 text-xs select-none w-4 text-right">{{ idx === scanFiles.length - 1 ? '\u2514' : '\u251C' }}</span>
962-
<code
963-
class="text-sm"
964-
:class="file.suspicious ? 'text-error font-semibold' : 'text-base-content/80'"
965-
>{{ file.path }}</code>
966-
<span
967-
v-if="file.suspicious && file.findings?.length"
968-
class="badge badge-error badge-xs gap-1"
958+
<template v-else>
959+
<ul class="space-y-0.5 py-1 max-h-96 overflow-y-auto">
960+
<li
961+
v-for="(file, idx) in scanFiles"
962+
:key="file.path + idx"
963+
class="flex items-center gap-2 py-0.5"
969964
>
970-
{{ file.findings.join(', ') }}
965+
<span class="text-base-content/30 text-xs select-none w-4 text-right">{{ '\u251C' }}</span>
966+
<code
967+
class="text-xs"
968+
:class="file.suspicious ? 'text-error font-semibold' : 'text-base-content/60'"
969+
>{{ file.path }}</code>
970+
<span
971+
v-if="file.suspicious && file.findings?.length"
972+
class="badge badge-error badge-xs gap-1 shrink-0"
973+
>
974+
{{ file.findings[0] }}
975+
</span>
976+
</li>
977+
</ul>
978+
<div class="flex items-center gap-2 mt-2 pt-2 border-t border-base-200">
979+
<span class="text-xs text-base-content/50">
980+
Showing {{ scanFiles.length }} of {{ scanFilesMeta.total || scanContext?.total_files || '?' }}
971981
</span>
972-
</li>
973-
</ul>
982+
<button
983+
v-if="scanFilesMeta.has_more"
984+
@click="loadMoreFiles"
985+
:disabled="scanFilesLoading"
986+
class="btn btn-xs btn-ghost"
987+
>
988+
<span v-if="scanFilesLoading" class="loading loading-spinner loading-xs"></span>
989+
Load more
990+
</button>
991+
</div>
992+
</template>
974993
</div>
975994
</div>
976995
</div>
@@ -1054,6 +1073,9 @@ let scanPollTimer: ReturnType<typeof setInterval> | null = null
10541073
const scanFiles = ref<Array<{ path: string; suspicious: boolean; findings?: string[] }>>([])
10551074
const scanFilesLoading = ref(false)
10561075
const scanFilesLoaded = ref(false)
1076+
const scanFilesMeta = ref<{ total: number; has_more: boolean; suspicious_count: number; offset: number }>({
1077+
total: 0, has_more: false, suspicious_count: 0, offset: 0
1078+
})
10571079
10581080
const scanContext = computed(() => {
10591081
return scanStatus.value?.scan_context || null
@@ -1604,21 +1626,40 @@ function formatFileSize(bytes: number): string {
16041626
async function onScannedFilesToggle(event: Event) {
16051627
const checkbox = event.target as HTMLInputElement
16061628
if (checkbox.checked && !scanFilesLoaded.value && server.value) {
1607-
scanFilesLoading.value = true
1608-
try {
1609-
const response = await api.getScanFiles(server.value.name)
1610-
if (response.success && response.data) {
1629+
await loadScanFiles(0)
1630+
}
1631+
}
1632+
1633+
async function loadScanFiles(offset: number) {
1634+
if (!server.value) return
1635+
scanFilesLoading.value = true
1636+
try {
1637+
const response = await api.getScanFiles(server.value.name, 100, offset)
1638+
if (response.success && response.data) {
1639+
if (offset === 0) {
16111640
scanFiles.value = response.data.files || []
1612-
scanFilesLoaded.value = true
1641+
} else {
1642+
scanFiles.value = [...scanFiles.value, ...(response.data.files || [])]
16131643
}
1614-
} catch {
1615-
// Silently fail
1616-
} finally {
1617-
scanFilesLoading.value = false
1644+
scanFilesMeta.value = {
1645+
total: response.data.total_files || 0,
1646+
has_more: response.data.has_more || false,
1647+
suspicious_count: response.data.suspicious_count || 0,
1648+
offset: offset + (response.data.files?.length || 0),
1649+
}
1650+
scanFilesLoaded.value = true
16181651
}
1652+
} catch {
1653+
// Silently fail
1654+
} finally {
1655+
scanFilesLoading.value = false
16191656
}
16201657
}
16211658
1659+
async function loadMoreFiles() {
1660+
await loadScanFiles(scanFilesMeta.value.offset)
1661+
}
1662+
16221663
function formatRelativeTime(isoString: string): string {
16231664
const date = new Date(isoString)
16241665
const now = new Date()

internal/httpapi/security_scanner.go

Lines changed: 62 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"encoding/json"
66
"net/http"
7+
"strconv"
78

89
"github.com/go-chi/chi/v5"
910

@@ -328,6 +329,24 @@ func (s *Server) handleGetScanFiles(w http.ResponseWriter, r *http.Request) {
328329
return
329330
}
330331

332+
// Pagination: ?limit=100&offset=0&suspicious_only=true
333+
limit := 100
334+
offset := 0
335+
suspiciousOnly := false
336+
if l := r.URL.Query().Get("limit"); l != "" {
337+
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 && parsed <= 1000 {
338+
limit = parsed
339+
}
340+
}
341+
if o := r.URL.Query().Get("offset"); o != "" {
342+
if parsed, err := strconv.Atoi(o); err == nil && parsed >= 0 {
343+
offset = parsed
344+
}
345+
}
346+
if r.URL.Query().Get("suspicious_only") == "true" {
347+
suspiciousOnly = true
348+
}
349+
331350
// Get scan status for context
332351
job, err := s.securityController.GetScanStatus(r.Context(), name)
333352
if err != nil {
@@ -338,8 +357,8 @@ func (s *Server) handleGetScanFiles(w http.ResponseWriter, r *http.Request) {
338357
// Get report for finding locations
339358
report, _ := s.securityController.GetScanReport(r.Context(), name)
340359

341-
// Build file tree with suspicious markers
342-
result := buildFileTree(job, report)
360+
// Build file tree with suspicious markers and pagination
361+
result := buildFileTreePaginated(job, report, limit, offset, suspiciousOnly)
343362
s.writeSuccess(w, result)
344363
}
345364

@@ -356,11 +375,19 @@ type fileTreeResponse struct {
356375
DockerIsolation bool `json:"docker_isolation"`
357376
TotalFiles int `json:"total_files"`
358377
TotalSizeBytes int64 `json:"total_size_bytes"`
378+
SuspiciousCount int `json:"suspicious_count"`
359379
Files []fileTreeEntry `json:"files"`
380+
Offset int `json:"offset"`
381+
Limit int `json:"limit"`
382+
HasMore bool `json:"has_more"`
360383
}
361384

362385
func buildFileTree(job *scanner.ScanJob, report *scanner.AggregatedReport) *fileTreeResponse {
363-
resp := &fileTreeResponse{}
386+
return buildFileTreePaginated(job, report, 100, 0, false)
387+
}
388+
389+
func buildFileTreePaginated(job *scanner.ScanJob, report *scanner.AggregatedReport, limit, offset int, suspiciousOnly bool) *fileTreeResponse {
390+
resp := &fileTreeResponse{Limit: limit, Offset: offset}
364391

365392
if job == nil || job.ScanContext == nil {
366393
return resp
@@ -378,29 +405,57 @@ func buildFileTree(job *scanner.ScanJob, report *scanner.AggregatedReport) *file
378405
if report != nil {
379406
for _, f := range report.Findings {
380407
if f.Location != "" {
381-
// Normalize: strip line number for matching
382408
filePath := f.Location
383409
if idx := lastIndexByte(filePath, ':'); idx > 0 {
384410
filePath = filePath[:idx]
385411
}
386-
// Strip leading /scan/source/ prefix
387412
filePath = trimScanPrefix(filePath)
388413
locationFindings[filePath] = append(locationFindings[filePath], f.Title)
389414
}
390415
}
391416
}
392417

393-
// Build entries
418+
// Build all entries first (for counting), then paginate
419+
// Suspicious files always sorted first
420+
var suspiciousFiles []fileTreeEntry
421+
var normalFiles []fileTreeEntry
422+
394423
for _, path := range ctx.ScannedFiles {
395424
normalized := trimScanPrefix(path)
396425
entry := fileTreeEntry{Path: path}
397426
if findings, ok := locationFindings[normalized]; ok {
398427
entry.Suspicious = true
399428
entry.Findings = findings
429+
suspiciousFiles = append(suspiciousFiles, entry)
430+
} else {
431+
normalFiles = append(normalFiles, entry)
400432
}
401-
resp.Files = append(resp.Files, entry)
402433
}
403434

435+
resp.SuspiciousCount = len(suspiciousFiles)
436+
437+
// Combine: suspicious first, then normal
438+
var allFiles []fileTreeEntry
439+
if suspiciousOnly {
440+
allFiles = suspiciousFiles
441+
} else {
442+
allFiles = append(suspiciousFiles, normalFiles...)
443+
}
444+
445+
// Apply pagination
446+
totalFiltered := len(allFiles)
447+
if offset >= totalFiltered {
448+
resp.HasMore = false
449+
return resp
450+
}
451+
452+
end := offset + limit
453+
if end > totalFiltered {
454+
end = totalFiltered
455+
}
456+
resp.Files = allFiles[offset:end]
457+
resp.HasMore = end < totalFiltered
458+
404459
return resp
405460
}
406461

web/frontend/dist/assets/Activity-DiCUi_2z.js renamed to web/frontend/dist/assets/Activity-CUxJCR2U.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)