Skip to content

Commit d36b2c4

Browse files
NotYuShengclaude
andauthored
feat: multi-select UX improvements and permanent PCAP merge (#188)
* feat: multi-select UX improvements and permanent PCAP merge - Clicking a completed file row now toggles its checkbox - "Compare selected" opens a modal asking to Analyze Together or Permanently Merge - Analyze Together navigates to the existing /compare joint topology view - Permanently Merge calls POST /api/files/merge, shows a loading overlay, then navigates to the new file's analysis page on success - Merged filename is auto-generated (max 3 source names × 20 chars) and editable by the user before confirming; .pcap extension is enforced - Backend: new MergeFilesRequest DTO, FileService#mergeFiles, and POST /api/files/merge endpoint; uses mergecap to combine PCAPs, uploads result to MinIO, triggers normal analysis pipeline - Fix hover text colour on action modal buttons in light mode Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: stream merge output for hash/upload, add timeout, add validation - Replace Files.readAllBytes + uploadBytes with streaming SHA-256 hash (DigestInputStream) and streaming MinIO upload (new StorageService#uploadFile overload taking a File) to avoid OOM on large PCAPs - Add 5-minute timeout to mergecap via process.waitFor(5, MINUTES) with a daemon drain thread for stdout/stderr; forcibly destroy on timeout - Add @notempty + @SiZe(min=2) to MergeFilesRequest#fileIds and @Valid to the controller endpoint to reject bad requests before the service layer - Null-safe log in FileController#mergeFiles Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 4f834db commit d36b2c4

File tree

9 files changed

+393
-4
lines changed

9 files changed

+393
-4
lines changed

backend/src/main/java/com/tracepcap/file/controller/FileController.java

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@
22

33
import com.tracepcap.file.dto.FileMetadataDto;
44
import com.tracepcap.file.dto.FileUploadResponse;
5+
import com.tracepcap.file.dto.MergeFilesRequest;
56
import com.tracepcap.file.service.FileService;
67
import io.swagger.v3.oas.annotations.Operation;
78
import io.swagger.v3.oas.annotations.tags.Tag;
9+
import jakarta.validation.Valid;
810
import java.io.InputStream;
911
import java.util.UUID;
1012
import lombok.RequiredArgsConstructor;
@@ -101,4 +103,22 @@ public ResponseEntity<Void> deleteFile(@PathVariable String fileId) {
101103

102104
return ResponseEntity.noContent().build();
103105
}
106+
107+
@PostMapping("/merge")
108+
@Operation(
109+
summary = "Merge PCAP files",
110+
description = "Merge two or more PCAP files into a single new file and trigger analysis")
111+
public ResponseEntity<FileUploadResponse> mergeFiles(
112+
@Valid @RequestBody MergeFilesRequest request,
113+
@RequestParam(value = "enableNdpi", defaultValue = "true") boolean enableNdpi,
114+
@RequestParam(value = "enableFileExtraction", defaultValue = "true")
115+
boolean enableFileExtraction) {
116+
117+
log.info("Received merge request for {} files", request.getFileIds() != null ? request.getFileIds().size() : 0);
118+
119+
FileUploadResponse response =
120+
fileService.mergeFiles(request.getFileIds(), request.getMergedFileName(), enableNdpi, enableFileExtraction);
121+
122+
return ResponseEntity.status(HttpStatus.CREATED).body(response);
123+
}
104124
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package com.tracepcap.file.dto;
2+
3+
import jakarta.validation.constraints.NotEmpty;
4+
import jakarta.validation.constraints.Size;
5+
import java.util.List;
6+
import java.util.UUID;
7+
import lombok.Data;
8+
9+
/** Request body for merging multiple PCAP files */
10+
@Data
11+
public class MergeFilesRequest {
12+
13+
@NotEmpty(message = "At least two file IDs are required")
14+
@Size(min = 2, message = "At least two file IDs are required")
15+
private List<UUID> fileIds;
16+
17+
/** Optional user-supplied name for the merged file (without extension). */
18+
private String mergedFileName;
19+
}

backend/src/main/java/com/tracepcap/file/service/FileService.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import com.tracepcap.file.dto.FileUploadResponse;
55
import com.tracepcap.file.entity.FileEntity;
66
import java.io.InputStream;
7+
import java.util.List;
78
import java.util.UUID;
89
import org.springframework.data.domain.Page;
910
import org.springframework.data.domain.Pageable;
@@ -67,4 +68,14 @@ FileUploadResponse uploadFile(
6768
* @return file entity
6869
*/
6970
FileEntity getFileById(UUID fileId);
71+
72+
/**
73+
* Merge multiple PCAP files into a single new file and trigger analysis.
74+
*
75+
* @param fileIds ordered list of file IDs to merge
76+
* @param enableNdpi whether to run nDPI on the merged file
77+
* @param enableFileExtraction whether to run file extraction on the merged file
78+
* @return upload response for the newly created merged file
79+
*/
80+
FileUploadResponse mergeFiles(List<UUID> fileIds, String mergedFileName, boolean enableNdpi, boolean enableFileExtraction);
7081
}

backend/src/main/java/com/tracepcap/file/service/FileServiceImpl.java

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,17 @@
99
import com.tracepcap.file.event.FileUploadedEvent;
1010
import com.tracepcap.file.mapper.FileMapper;
1111
import com.tracepcap.file.repository.FileRepository;
12+
import java.io.BufferedInputStream;
13+
import java.io.File;
14+
import java.io.FileInputStream;
15+
import java.io.IOException;
1216
import java.io.InputStream;
17+
import java.nio.file.Files;
18+
import java.security.DigestInputStream;
1319
import java.security.MessageDigest;
20+
import java.util.concurrent.TimeUnit;
1421
import java.time.LocalDateTime;
22+
import java.util.ArrayList;
1523
import java.util.Arrays;
1624
import java.util.HexFormat;
1725
import java.util.List;
@@ -198,6 +206,165 @@ private String getFileExtension(String filename) {
198206
return lastDotIndex > 0 ? filename.substring(lastDotIndex) : "";
199207
}
200208

209+
@Override
210+
@Transactional
211+
public FileUploadResponse mergeFiles(
212+
List<UUID> fileIds, String mergedFileName, boolean enableNdpi, boolean enableFileExtraction) {
213+
if (fileIds == null || fileIds.size() < 2) {
214+
throw new InvalidFileException("At least two files are required for merging");
215+
}
216+
217+
log.info("Starting PCAP merge for {} files: {}", fileIds.size(), fileIds);
218+
219+
List<File> tempInputs = new ArrayList<>();
220+
File tempOutput = null;
221+
222+
try {
223+
// Download each source file to a local temp file
224+
for (UUID fileId : fileIds) {
225+
FileEntity entity = getFileById(fileId);
226+
File tmp = File.createTempFile("merge-input-" + fileId, ".pcap");
227+
storageService.downloadFileToLocal(entity.getMinioPath(), tmp);
228+
tempInputs.add(tmp);
229+
}
230+
231+
// Build mergecap command
232+
tempOutput = File.createTempFile("merge-output-", ".pcap");
233+
List<String> cmd = new ArrayList<>();
234+
cmd.add("mergecap");
235+
cmd.add("-w");
236+
cmd.add(tempOutput.getAbsolutePath());
237+
for (File f : tempInputs) {
238+
cmd.add(f.getAbsolutePath());
239+
}
240+
241+
log.info("Running mergecap: {}", cmd);
242+
Process process = new ProcessBuilder(cmd)
243+
.redirectErrorStream(true)
244+
.start();
245+
246+
// Drain stdout/stderr in a background thread to prevent blocking
247+
final StringBuilder processOutput = new StringBuilder();
248+
Thread drainThread = new Thread(() -> {
249+
try {
250+
processOutput.append(new String(process.getInputStream().readAllBytes()));
251+
} catch (IOException ignored) {}
252+
});
253+
drainThread.setDaemon(true);
254+
drainThread.start();
255+
256+
boolean finished = process.waitFor(5, TimeUnit.MINUTES);
257+
drainThread.join(5000);
258+
if (!finished) {
259+
process.destroyForcibly();
260+
throw new InvalidFileException("mergecap timed out after 5 minutes");
261+
}
262+
int exitCode = process.exitValue();
263+
if (exitCode != 0) {
264+
throw new InvalidFileException("mergecap failed (exit " + exitCode + "): " + processOutput);
265+
}
266+
267+
// Compute hash by streaming the merged file (avoids loading it fully into memory)
268+
String fileHash = computeSha256FromFile(tempOutput);
269+
270+
Optional<FileEntity> existing =
271+
fileRepository.findFirstByFileHashOrderByUploadedAtDesc(fileHash);
272+
if (existing.isPresent()) {
273+
throw new DuplicateFileException(existing.get().getId());
274+
}
275+
276+
// Use caller-supplied name or auto-generate from source names
277+
String mergedName = (mergedFileName != null && !mergedFileName.isBlank())
278+
? sanitizeMergedFileName(mergedFileName)
279+
: buildAutoMergedName(fileIds);
280+
281+
// Stream-upload the merged file to MinIO (avoids loading it fully into memory)
282+
UUID newFileId = UUID.randomUUID();
283+
String storedName = newFileId + ".pcap";
284+
storageService.uploadFile(tempOutput, storedName, "application/vnd.tcpdump.pcap");
285+
286+
// Persist metadata
287+
FileEntity fileEntity =
288+
FileEntity.builder()
289+
.id(newFileId)
290+
.fileName(mergedName)
291+
.fileSize(tempOutput.length())
292+
.minioPath(storedName)
293+
.uploadedAt(LocalDateTime.now())
294+
.status(FileEntity.FileStatus.PROCESSING)
295+
.fileHash(fileHash)
296+
.enableNdpi(enableNdpi)
297+
.enableFileExtraction(enableFileExtraction)
298+
.build();
299+
300+
fileEntity = fileRepository.save(fileEntity);
301+
log.info("Merged PCAP saved: {} (ID: {})", mergedName, newFileId);
302+
303+
// Trigger async analysis
304+
eventPublisher.publishEvent(new FileUploadedEvent(this, newFileId));
305+
306+
return fileMapper.toUploadResponse(fileEntity);
307+
308+
} catch (InvalidFileException | DuplicateFileException e) {
309+
throw e;
310+
} catch (Exception e) {
311+
log.error("Failed to merge PCAP files", e);
312+
throw new InvalidFileException("Failed to merge files: " + e.getMessage(), e);
313+
} finally {
314+
for (File f : tempInputs) {
315+
try { Files.deleteIfExists(f.toPath()); } catch (IOException ignored) {}
316+
}
317+
if (tempOutput != null) {
318+
try { Files.deleteIfExists(tempOutput.toPath()); } catch (IOException ignored) {}
319+
}
320+
}
321+
}
322+
323+
private String buildAutoMergedName(List<UUID> fileIds) {
324+
final int MAX_PART = 20;
325+
final int MAX_SHOWN = 3;
326+
List<String> parts = new ArrayList<>();
327+
for (int i = 0; i < Math.min(MAX_SHOWN, fileIds.size()); i++) {
328+
try {
329+
String base = getFileById(fileIds.get(i)).getFileName().replaceFirst("\\.[^.]+$", "");
330+
parts.add(base.length() > MAX_PART ? base.substring(0, MAX_PART) : base);
331+
} catch (Exception ignored) {
332+
parts.add(fileIds.get(i).toString().substring(0, 8));
333+
}
334+
}
335+
String joined = String.join("+", parts);
336+
if (fileIds.size() > MAX_SHOWN) {
337+
joined += "+" + (fileIds.size() - MAX_SHOWN) + "_more";
338+
}
339+
return "merged_" + joined + ".pcap";
340+
}
341+
342+
private String sanitizeMergedFileName(String name) {
343+
// Strip any path separators and control characters, ensure .pcap extension
344+
String safe = name.replaceAll("[/\\\\<>:\"|?*\\p{Cntrl}]", "_").trim();
345+
if (safe.isBlank()) {
346+
safe = "merged";
347+
}
348+
// Ensure it ends with .pcap
349+
if (!safe.toLowerCase().endsWith(".pcap")) {
350+
safe = safe + ".pcap";
351+
}
352+
return safe;
353+
}
354+
355+
private String computeSha256FromFile(File file) {
356+
try {
357+
MessageDigest digest = MessageDigest.getInstance("SHA-256");
358+
try (InputStream is = new DigestInputStream(new BufferedInputStream(new FileInputStream(file)), digest)) {
359+
byte[] buf = new byte[8192];
360+
while (is.read(buf) != -1) { /* drain to feed the digest */ }
361+
}
362+
return HexFormat.of().formatHex(digest.digest());
363+
} catch (Exception e) {
364+
throw new InvalidFileException("Could not compute hash of merged file", e);
365+
}
366+
}
367+
201368
/** Compute SHA-256 hex digest of the uploaded file using a streaming approach */
202369
private String computeSha256(MultipartFile file) {
203370
try {

backend/src/main/java/com/tracepcap/file/service/StorageService.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,4 +64,14 @@ public interface StorageService {
6464
* @return the storage path
6565
*/
6666
String uploadBytes(byte[] data, String path, String contentType);
67+
68+
/**
69+
* Upload a local file to storage via streaming (avoids loading the whole file into memory).
70+
*
71+
* @param source the local file to upload
72+
* @param path the destination path in storage
73+
* @param contentType MIME type of the content
74+
* @return the storage path
75+
*/
76+
String uploadFile(java.io.File source, String path, String contentType);
6777
}

backend/src/main/java/com/tracepcap/file/service/StorageServiceImpl.java

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@
44
import com.tracepcap.config.MinioConfig;
55
import io.minio.*;
66
import io.minio.http.Method;
7+
import java.io.BufferedInputStream;
78
import java.io.ByteArrayInputStream;
89
import java.io.File;
10+
import java.io.FileInputStream;
911
import java.io.FileOutputStream;
1012
import java.io.InputStream;
1113
import java.util.concurrent.TimeUnit;
@@ -134,6 +136,25 @@ public String uploadBytes(byte[] data, String path, String contentType) {
134136
}
135137
}
136138

139+
@Override
140+
public String uploadFile(File source, String path, String contentType) {
141+
try {
142+
ensureBucketExists();
143+
try (InputStream stream = new BufferedInputStream(new FileInputStream(source))) {
144+
minioClient.putObject(
145+
PutObjectArgs.builder().bucket(minioConfig.getBucket()).object(path).stream(
146+
stream, source.length(), -1)
147+
.contentType(contentType != null ? contentType : "application/octet-stream")
148+
.build());
149+
}
150+
log.info("Successfully streamed file to MinIO: {}", path);
151+
return path;
152+
} catch (Exception e) {
153+
log.error("Failed to stream file to MinIO: {}", path, e);
154+
throw new StorageException("Failed to upload file to storage", e);
155+
}
156+
}
157+
137158
/** Ensure the bucket exists, create if it doesn't */
138159
private void ensureBucketExists() {
139160
try {

frontend/src/components/upload/FileList/FileList.css

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
/* Multi-select action modal buttons */
2+
.multiselect-action-btn:hover .text-muted,
3+
.multiselect-action-btn:hover small {
4+
color: inherit !important;
5+
}
6+
17
/* File List Card Styles */
28
.file-list-card {
39
box-shadow: 0 2px 4px rgb(0 0 0 / 10%);

0 commit comments

Comments
 (0)