Skip to content

Commit 01853c6

Browse files
NotYuShengclaude
andauthored
feat: show active filters and node-limit note on report diagram pages (#196)
* feat: show active filters and node-limit note on report diagram pages - Filter state is now applied to the report diagram even when the Network Diagram tab has never been visited (fallback path fetches the graph and applies all client-side filters from lifted AnalysisPage state) - Filter label for node-type keys strips the internal nt:/dt: prefix and maps to human-readable names (nodeFilterLabel helper in constants.ts) - Each topology diagram page renders active filter chips and a node-limit note (e.g. "Showing the 200 most significant nodes (428 hidden)…") in a fully absolute-positioned footer so nothing overlaps the image - All footer elements (disclaimer, node note, badge chips) are built as PdfPTable first so getTotalHeight() gives exact heights; image slot usableH is derived from those exact values, guaranteeing no overlap - Diagram pages use PdfContentByte for all content (title, image, footer) so iText's flow layout cannot interfere with positioning - ReportRequest gains nodeLimitNote field passed from the frontend Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix/buildNetworkGraph-param-order-and-portrait-check Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 9e15253 commit 01853c6

5 files changed

Lines changed: 304 additions & 48 deletions

File tree

backend/src/main/java/com/tracepcap/report/ReportRequest.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,6 @@ public class ReportRequest {
1212
private String hierarchicalImage;
1313
/** Human-readable labels for each active network-diagram filter, e.g. "Protocol: HTTPS". */
1414
private List<String> activeFilters;
15+
/** Node-limit banner text when not all nodes are shown, e.g. "Showing the 50 most significant nodes (428 hidden)…". */
16+
private String nodeLimitNote;
1517
}

backend/src/main/java/com/tracepcap/report/ReportService.java

Lines changed: 163 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
import com.lowagie.text.Paragraph;
99
import com.lowagie.text.Phrase;
1010
import com.lowagie.text.Rectangle;
11+
import com.lowagie.text.pdf.ColumnText;
12+
import com.lowagie.text.pdf.PdfContentByte;
1113
import com.lowagie.text.pdf.PdfPCell;
1214
import com.lowagie.text.pdf.PdfPTable;
1315
import com.lowagie.text.pdf.PdfWriter;
@@ -118,7 +120,7 @@ public void generateReport(UUID fileId, ReportRequest request, OutputStream out)
118120

119121
Document document = new Document(PageSize.A4, 40, 40, 60, 40);
120122
try {
121-
PdfWriter.getInstance(document, out);
123+
PdfWriter writer = PdfWriter.getInstance(document, out);
122124
document.open();
123125

124126
// ── Sections ──────────────────────────────────────────────────────────
@@ -202,13 +204,15 @@ public void generateReport(UUID fileId, ReportRequest request, OutputStream out)
202204
addExtractedFiles(document, extractedFiles, sec++);
203205
}
204206

205-
if (request.getActiveFilters() != null && !request.getActiveFilters().isEmpty()) {
206-
addNetworkDiagramFilters(document, request.getActiveFilters(), sec++);
207-
}
208-
209-
addTopologyDiagram(document, request.getForceDirectedImage(), "Force-Directed Layout", sec++);
207+
List<String> activeFilters =
208+
request.getActiveFilters() != null ? request.getActiveFilters() : List.of();
209+
String nodeLimitNote = request.getNodeLimitNote();
210+
addTopologyDiagram(
211+
document, writer, request.getForceDirectedImage(), "Force-Directed Layout", sec++,
212+
activeFilters, nodeLimitNote);
210213
addTopologyDiagram(
211-
document, request.getHierarchicalImage(), "Hierarchical Layout (Top-Down)", sec++);
214+
document, writer, request.getHierarchicalImage(), "Hierarchical Layout (Top-Down)", sec++,
215+
activeFilters, nodeLimitNote);
212216

213217
} catch (Exception e) {
214218
log.error("PDF generation failed for file {}", fileId, e);
@@ -828,57 +832,177 @@ private void addNetworkDiagramFilters(Document doc, List<String> filters, int se
828832
// ══════════════════════════════════════════════════════════════════════════
829833

830834
private void addTopologyDiagram(
831-
Document doc, String base64Image, String layoutName, int sectionNum) throws Exception {
835+
Document doc, PdfWriter writer, String base64Image, String layoutName, int sectionNum,
836+
List<String> activeFilters, String nodeLimitNote)
837+
throws Exception {
832838

833-
// Each topology diagram gets its own full landscape page.
834-
// Use an explicit Rectangle rather than rotate() so the size is applied
835-
// unambiguously regardless of the previous page's orientation.
836-
Rectangle landscape = new Rectangle(PageSize.A4.getHeight(), PageSize.A4.getWidth());
837-
doc.setPageSize(landscape);
839+
// ── Page setup ────────────────────────────────────────────────────────────
840+
// setPageSize only takes effect on the next newPage(). Calling newPage()
841+
// twice guarantees the landscape size is active before we draw anything:
842+
// the first call closes the previous page with its current size, the second
843+
// opens a fresh page with the new landscape size.
844+
float pageW = PageSize.A4.getHeight(); // 841.89 pt
845+
float pageH = PageSize.A4.getWidth(); // 595.28 pt
846+
doc.setPageSize(new Rectangle(pageW, pageH));
847+
doc.setMargins(40, 40, 40, 40);
838848
doc.newPage();
849+
// Verify the page size took effect; if the writer still reports portrait
850+
// dimensions, force one more page turn.
851+
if (writer.getPageSize().getHeight() >= writer.getPageSize().getWidth()
852+
&& writer.getPageSize().getWidth() < pageW - 1f) {
853+
doc.newPage();
854+
}
855+
856+
// ── Constants ─────────────────────────────────────────────────────────────
857+
final int BADGE_COLS = 5;
858+
final float MARGIN = 40f;
859+
final float TITLE_H = 16f; // 11pt font + leading
860+
final float TITLE_GAP = 6f; // gap between title bottom and image top
861+
final float FOOTER_GAP = 8f; // gap between image bottom and topmost footer element
839862

840-
// Compact title — avoids the ~50pt overhead of the banner-style section
841-
// header so the image fits on the same page without shrinking.
863+
List<String> nonNullFilters =
864+
(activeFilters != null)
865+
? activeFilters.stream().filter(f -> f != null)
866+
.collect(java.util.stream.Collectors.toList())
867+
: List.of();
868+
boolean hasFilters = !nonNullFilters.isEmpty();
869+
boolean hasNodeNote = nodeLimitNote != null && !nodeLimitNote.isBlank();
870+
871+
float contentW = pageW - MARGIN * 2;
872+
873+
Font disclaimerFont = new Font(Font.HELVETICA, 7.5f, Font.ITALIC, new Color(120, 120, 120));
874+
Font noteFont = new Font(Font.HELVETICA, 7.5f, Font.ITALIC, new Color(80, 80, 80));
875+
876+
// ── Build badge chips table so getTotalHeight() is exact ─────────────────
877+
// (disclaimer + note are built below as a single combined table)
878+
879+
// Badge chips (optional)
880+
PdfPTable badges = null;
881+
float badgesH = 0f;
882+
if (hasFilters) {
883+
Font labelFont = new Font(Font.HELVETICA, 7f, Font.BOLD, new Color(30, 64, 175));
884+
Font valueFont = new Font(Font.HELVETICA, 7f, Font.NORMAL, new Color(30, 41, 59));
885+
Color chipBg = new Color(219, 234, 254);
886+
int cols = Math.min(nonNullFilters.size(), BADGE_COLS);
887+
badges = new PdfPTable(cols);
888+
badges.setTotalWidth(contentW);
889+
for (String filter : nonNullFilters) {
890+
int colon = filter.indexOf(':');
891+
Phrase phrase = new Phrase();
892+
if (colon > 0) {
893+
phrase.add(new Phrase(filter.substring(0, colon + 1) + " ", labelFont));
894+
phrase.add(new Phrase(filter.substring(colon + 1).trim(), valueFont));
895+
} else {
896+
phrase.add(new Phrase(filter, valueFont));
897+
}
898+
PdfPCell chip = new PdfPCell(phrase);
899+
chip.setBackgroundColor(chipBg);
900+
chip.setPaddingTop(2); chip.setPaddingBottom(2);
901+
chip.setPaddingLeft(5); chip.setPaddingRight(5);
902+
chip.setBorderColor(new Color(147, 197, 253));
903+
chip.setBorderWidth(0.5f);
904+
badges.addCell(chip);
905+
}
906+
int remainder = nonNullFilters.size() % cols;
907+
if (remainder != 0) {
908+
for (int p = remainder; p < cols; p++) {
909+
PdfPCell empty = new PdfPCell(new Phrase(""));
910+
empty.setBorder(Rectangle.NO_BORDER);
911+
badges.addCell(empty);
912+
}
913+
}
914+
badgesH = badges.getTotalHeight();
915+
}
916+
917+
// ── Merge disclaimer + note into one table for exact combined height ───────
918+
// Building them separately risks coordinate drift; one table guarantees
919+
// the text block height is measured as a single unit.
920+
PdfPTable textFooter = new PdfPTable(1);
921+
textFooter.setTotalWidth(contentW);
922+
if (hasNodeNote) {
923+
PdfPCell noteCell2 = new PdfPCell(new Phrase(nodeLimitNote, noteFont));
924+
noteCell2.setBorder(Rectangle.NO_BORDER);
925+
noteCell2.setHorizontalAlignment(Element.ALIGN_CENTER);
926+
noteCell2.setPaddingTop(0); noteCell2.setPaddingBottom(2);
927+
textFooter.addCell(noteCell2);
928+
}
929+
PdfPCell discCell2 = new PdfPCell(new Phrase(
930+
"Note: This diagram is automatically generated and may not render all connections accurately "
931+
+ "for large or complex network captures. For a complete view, consider taking a manual "
932+
+ "screenshot from the Network Diagram page.",
933+
disclaimerFont));
934+
discCell2.setBorder(Rectangle.NO_BORDER);
935+
discCell2.setHorizontalAlignment(Element.ALIGN_CENTER);
936+
discCell2.setPaddingTop(0); discCell2.setPaddingBottom(0);
937+
textFooter.addCell(discCell2);
938+
float textFooterH = textFooter.getTotalHeight();
939+
940+
// ── Exact footer height ───────────────────────────────────────────────────
941+
float footerH = FOOTER_GAP + textFooterH + badgesH;
942+
943+
// ── Derive image slot ─────────────────────────────────────────────────────
944+
float titleTop = pageH - MARGIN;
945+
float imageTop = titleTop - TITLE_H - TITLE_GAP;
946+
float imageBot = MARGIN + footerH;
947+
float usableH = imageTop - imageBot;
948+
949+
log.info("Topology diagram '{}': pageH={} footerH={} (textFooter={} badges={}) imageBot={} usableH={}",
950+
layoutName, pageH, footerH, textFooterH, badgesH, imageBot, usableH);
951+
952+
// ── Draw title ────────────────────────────────────────────────────────────
953+
PdfContentByte cb = writer.getDirectContent();
842954
Font titleFont = new Font(Font.HELVETICA, 11, Font.BOLD, C_HEADER_BG);
843-
Paragraph title = new Paragraph(sectionNum + ". Network Topology — " + layoutName, titleFont);
844-
title.setSpacingBefore(4);
845-
title.setSpacingAfter(6);
846-
doc.add(title);
955+
ColumnText ctTitle = new ColumnText(cb);
956+
ctTitle.setSimpleColumn(MARGIN, titleTop - TITLE_H, MARGIN + contentW, titleTop);
957+
ctTitle.setAlignment(Element.ALIGN_LEFT);
958+
ctTitle.addText(new Phrase(sectionNum + ". Network Topology \u2014 " + layoutName, titleFont));
959+
ctTitle.go();
847960

848961
if (base64Image == null || base64Image.isBlank()) {
849-
doc.add(new Paragraph("Diagram image not provided.", cellFont()));
962+
ColumnText ctErr = new ColumnText(cb);
963+
ctErr.setSimpleColumn(MARGIN, imageBot, MARGIN + contentW, imageTop);
964+
ctErr.addText(new Phrase("Diagram image not provided.", cellFont()));
965+
ctErr.go();
966+
doc.add(new Paragraph(" "));
850967
return;
851968
}
852969

853-
// Usable area: landscape height minus top+bottom margins (100), title (21),
854-
// image spacingBefore (8), and disclaimer with its spacing (40).
855-
float usableW = landscape.getWidth() - 80f;
856-
float usableH = landscape.getHeight() - 100f - 21f - 8f - 40f;
857-
970+
// ── Draw image ────────────────────────────────────────────────────────────
858971
byte[] imageBytes;
859972
try {
860973
String data = base64Image.contains(",") ? base64Image.split(",")[1] : base64Image;
861974
imageBytes = Base64.getDecoder().decode(data);
862975
} catch (IllegalArgumentException e) {
863976
log.warn("Invalid base64 image data for layout: {}", layoutName);
864-
doc.add(new Paragraph("Invalid diagram image data.", cellFont()));
977+
ColumnText ctErr = new ColumnText(cb);
978+
ctErr.setSimpleColumn(MARGIN, imageBot, MARGIN + contentW, imageTop);
979+
ctErr.addText(new Phrase("Invalid diagram image data.", cellFont()));
980+
ctErr.go();
981+
doc.add(new Paragraph(" "));
865982
return;
866983
}
867984
Image img = Image.getInstance(imageBytes);
868-
img.scaleToFit(usableW, usableH);
869-
img.setAlignment(Image.ALIGN_CENTER);
870-
img.setSpacingBefore(8);
871-
doc.add(img);
985+
img.scaleToFit(contentW, usableH);
986+
float imgX = MARGIN + (contentW - img.getScaledWidth()) / 2f;
987+
float imgY = imageTop - img.getScaledHeight();
988+
img.setAbsolutePosition(imgX, imgY);
989+
cb.addImage(img);
990+
991+
// ── Draw footer bottom-up — two elements, exact heights, no estimates ─────
992+
float y = MARGIN;
993+
994+
// 1. Badge chips (bottom-most)
995+
if (badges != null) {
996+
badges.writeSelectedRows(0, -1, MARGIN, y + badgesH, cb);
997+
y += badgesH;
998+
}
872999

873-
Font disclaimerFont = new Font(Font.HELVETICA, 7.5f, Font.ITALIC, new Color(120, 120, 120));
874-
Paragraph disclaimer =
875-
new Paragraph(
876-
"Note: This diagram is automatically generated and may not render all connections accurately for large or complex network captures. "
877-
+ "For a complete view, consider taking a manual screenshot from the Network Diagram page.",
878-
disclaimerFont);
879-
disclaimer.setAlignment(Element.ALIGN_CENTER);
880-
disclaimer.setSpacingBefore(6);
881-
doc.add(disclaimer);
1000+
// 2. Text block (node note + disclaimer) directly above badges
1001+
y += FOOTER_GAP;
1002+
textFooter.writeSelectedRows(0, -1, MARGIN, y + textFooterH, cb);
1003+
1004+
// Advance iText's flow cursor so the next section opens a new page.
1005+
doc.add(new Paragraph(" "));
8821006
}
8831007

8841008
// ══════════════════════════════════════════════════════════════════════════

frontend/src/features/network/constants.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,31 @@ export const NODE_TYPE_LABELS: Record<string, string> = {
5858
unknown: 'Unknown',
5959
};
6060

61+
/**
62+
* Converts a raw activeNodeFilters key (e.g. "nt:router", "dt:IOT") to a
63+
* human-readable label using the existing display maps.
64+
*/
65+
export function nodeFilterLabel(key: string): string {
66+
if (key.startsWith('nt:')) {
67+
const type = key.slice(3);
68+
return NODE_TYPE_LABELS[type] ?? type.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
69+
}
70+
if (key.startsWith('dt:')) {
71+
// Inline the deviceTypeLabel logic to avoid a circular import.
72+
const dt = key.slice(3);
73+
switch (dt) {
74+
case 'ROUTER': return 'Router';
75+
case 'MOBILE': return 'Mobile';
76+
case 'LAPTOP_DESKTOP': return 'Laptop / Desktop';
77+
case 'SERVER': return 'Server';
78+
case 'IOT': return 'IoT Device';
79+
case 'UNKNOWN': return 'Unknown';
80+
default: return dt;
81+
}
82+
}
83+
return key;
84+
}
85+
6186
/**
6287
* Single source of truth for node type colors used in
6388
* NetworkGraph (node fill) and NetworkControls (legend).

0 commit comments

Comments
 (0)