Interactive TLS Security Dashboard
Build a Dash dashboard to visualize TLS session data and export forensic reports.
Introduction
30 Second Summary
Security teams spend hours analyzing network traffic and flagging problems. But if those findings sit in raw data files that only the analyst can read, the work never reaches the people who need to act on it.
In this project, you will build an interactive Dash dashboard that visualizes TLS session data through Plotly charts and a drill-down interface. You will also create a report exporter that produces JSON, HTML, and PDF forensic reports with embedded charts.
What You'll Build
Open the dashboard, click any session row to inspect its certificate chain and risk assessment, then hit an export button and hand a polished PDF report to a stakeholder.
By the end of this project, you'll have:
- A live Dash dashboard with an aggregate security posture score, a filterable session table with color-coded risk labels, and interactive Plotly charts showing cipher suite distribution, TLS version breakdown, and risk score patterns.
- Drill-down panels that update when you click a session row, revealing certificate chain details, key exchange parameters, and vulnerability findings.
- A report exporter that produces a JSON data dump, a styled HTML report built with Jinja2 templates, or a print-ready PDF with embedded charts rendered by Kaleido and WeasyPrint.
- Secret Mission: Add a risk trend timeline that groups sessions by time window and plots how the aggregate posture score changes over time.
Are there any prerequisites?
This project builds on the JSON output from Projects 1 and 2 in this series (the PCAP Email TLS Handshake Analyzer and ML Risk Scoring projects). You need Python and Chrome (or Chromium) installed on your machine. Chrome is required by Kaleido for rendering chart images into the exported reports.
Before We Start
Before we dive in, take a moment to ground yourself in what you are building and why it matters.
Set Up the Dashboard Environment
Your Projects 1 and 2 produce rich JSON analysis data. TLS handshake details, cipher suites, certificate chains, risk scores. But raw JSON is not something you can hand to a stakeholder or click through during a live review.
In this step, you will stand up the full visualization and reporting stack. By the end, you will have a Python environment with Dash, Plotly, WeasyPrint, and Kaleido installed, a project directory ready to go, and sample data that simulates your earlier analysis output.
In this step, get ready to:
- Install system dependencies for PDF rendering.
- Create a virtual environment with all required Python packages.
- Set up the project directory structure with sample data.
Install system dependencies for WeasyPrint
WeasyPrint converts HTML to PDF, but it relies on native rendering libraries called Pango and Cairo. These cannot be installed with pip. Each operating system provides them through its own package manager.
macOS
On macOS, Homebrew handles the native dependencies for you.
- Install the WeasyPrint Homebrew formula (which pulls in Pango and Cairo) by running this command:
brew install weasyprint
What does this command do?
The brew install weasyprint formula installs the Pango and Cairo system libraries that WeasyPrint needs at runtime. You still install the Python package separately with pip in the next substep.
- If you later see a linking error when importing WeasyPrint in Python, set the library fallback path by running:
export DYLD_FALLBACK_LIBRARY_PATH=/opt/homebrew/lib:$DYLD_FALLBACK_LIBRARY_PATH
Make the path permanent
Add that export line to your ~/.zshrc file so it persists across terminal sessions.
Windows
On Windows, Pango and Cairo come through MSYS2, a Unix-like package manager.
- Download and install MSYS2 from https://www.msys2.org/.
- Open the MSYS2 UCRT64 terminal from your Start menu.
- Install the Pango and shared MIME packages by running this command:
pacman -S --noconfirm mingw-w64-x86_64-pango mingw-w64-x86_64-shared-mime-info
- Close the MSYS2 terminal after the install finishes.
- Press the Windows key, type Environment Variables, and select Edit the system environment variables.
- Click Environment Variables.
- Under System variables, select Path and click Edit.
- Click New and paste:
C:\msys64\mingw64\bin
- Click OK on all three dialogs to save.
- Close and reopen your terminal so the PATH change takes effect.
Why MSYS2?
WeasyPrint's PDF engine is built on Pango and Cairo, which are C libraries. On Windows, MSYS2 provides pre-compiled versions of these libraries so WeasyPrint can find them at runtime.
Linux
On Ubuntu and Debian, install the Pango and HarfBuzz runtime libraries with your package manager.
- Install the system dependencies by running this command:
sudo apt install libpango-1.0-0 libharfbuzz0b libpangoft2-1.0-0 libharfbuzz-subset0
Other distributions
On Fedora, use dnf to install pango and harfbuzz. On Arch, use pacman -S pango. The package names vary by distribution.
Create the virtual environment and install Python packages
With the system libraries in place, you can now set up an isolated Python environment and install the project's dependencies.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create the project folder and move into it by running these commands:
mkdir tls-dashboard
cd tls-dashboard
- Create a virtual environment and activate it by running these commands:
macOS
python3 -m venv .venv
source .venv/bin/activate
Windows
python -m venv .venv
.venv\Scripts\activate
Linux
python3 -m venv .venv
source .venv/bin/activate
Your terminal prompt now starts with (.venv), confirming the virtual environment is active.
- Install all the project's Python packages by running this command:
pip install dash==4.4.1 plotly==7.0.0 pandas weasyprint==69.0 Jinja2==3.1.6 kaleido==1.3.0
What are these packages?
- dash==4.4.1 is the web framework that serves your interactive dashboard.
- plotly==7.0.0 creates the interactive charts (bar, pie, histogram).
- pandas loads your JSON analysis data into tabular DataFrames for easy filtering.
- weasyprint==69.0 converts styled HTML into print-ready PDFs.
- Jinja2==3.1.6 is the templating engine that renders your HTML report.
- kaleido==1.3.0 exports Plotly charts to static PNG images for embedding in reports.
- Verify that the critical imports work by running this command:
python -c "from dash import Dash; from weasyprint import HTML; print('Ready')"
You should see Ready printed with no errors.
Seeing an import error for WeasyPrint?
This almost always means the system libraries are missing or not on your PATH. On Windows, confirm C:\msys64\mingw64\bin is in your system PATH and that you reopened your terminal after adding it. On macOS, run the export DYLD_FALLBACK_LIBRARY_PATH command from the macOS tab above.
Help me fix a WeasyPrint import error after installing system dependencies.
Create the project structure and sample data
Your dashboard needs a few files to start with. You will create placeholder files for the app and report generator now, then build a sample data script that simulates the JSON output from your earlier PCAP and ML projects.
- Create the project files and template directory by running these commands:
macOS
touch app.py report_generator.py sample_data.py
mkdir templates
touch templates/report.html
Windows
New-Item app.py, report_generator.py, sample_data.py -ItemType File
mkdir templates
New-Item templates/report.html -ItemType File
Linux
touch app.py report_generator.py sample_data.py
mkdir templates
touch templates/report.html
- Confirm the files exist by running:
ls
You should see app.py, report_generator.py, sample_data.py, and the templates folder in the output.
Now open sample_data.py in your editor. This script generates 30 realistic TLS session records that mirror the schema your Projects 1 and 2 produce. You will build it in sections.
- Start sample_data.py by adding the imports and data pool definitions:
import json
import random
from datetime import datetime, timedelta
def generate_sample_data(num_sessions=30):
# Email protocols and TLS version probability weights
protocols = ["SMTP", "IMAP", "POP3"]
tls_versions = ["TLS 1.3", "TLS 1.2", "TLS 1.1", "TLS 1.0", "SSLv3"]
tls_version_weights = [0.35, 0.40, 0.10, 0.10, 0.05]
cipher_suites_strong = [
"TLS_AES_256_GCM_SHA384",
"TLS_AES_128_GCM_SHA256",
"TLS_CHACHA20_POLY1305_SHA256",
"ECDHE-RSA-AES256-GCM-SHA384",
"ECDHE-RSA-AES128-GCM-SHA256",
]
cipher_suites_weak = [
"RSA-AES128-SHA",
"RSA-DES-CBC3-SHA",
"RC4-SHA",
"RSA-AES256-SHA",
]
What are these data pools?
Each list represents the range of values one session attribute can take. The generator picks from these pools when building each fake TLS session.
The tls_version_weights list ensures most sessions use TLS 1.2 or 1.3, with a smaller fraction using deprecated versions. The cipher suites are split into strong (forward secrecy, AES-GCM) and weak (RC4, bare RSA) groups to create realistic vulnerability distributions.
- Below the cipher_suites_weak list, add the key exchange, algorithm, and vulnerability pool definitions:
key_exchanges = ["ECDHE", "DHE", "RSA", "ECDH"]
key_algos = ["RSA", "ECDSA"]
sig_algos = ["SHA256withRSA", "SHA384withRSA", "SHA1withRSA", "SHA512withECDSA"]
# Known vulnerability types the generator assigns based on session attributes
vulnerability_pool = [
"Deprecated TLS version in use",
"Weak cipher suite detected",
"Certificate expires within 30 days",
"No forward secrecy",
"Self-signed certificate",
"SHA-1 signature algorithm",
"Short RSA key length (1024-bit)",
"Missing certificate chain intermediate",
"RC4 cipher in use",
"SSLv3 POODLE vulnerability exposure",
]
sessions = []
# Anchor timestamp for generating session times
base_time = datetime(2026, 8, 25, 8, 0, 0)
What does base_time control?
Each generated session gets a timestamp offset from base_time. This simulates sessions arriving over a time window, which matters when you build the risk trend timeline in the Secret Mission.
- Next, add the for loop that selects each session's TLS version, cipher suite, and key parameters:
for i in range(num_sessions):
# Pick a TLS version using weighted probability
tls_version = random.choices(tls_versions, weights=tls_version_weights, k=1)[0]
# Deprecated TLS or 30% random chance triggers weak cipher selection
is_weak = tls_version in ["TLS 1.0", "TLS 1.1", "SSLv3"] or random.random() < 0.3
cipher = random.choice(cipher_suites_weak if is_weak else cipher_suites_strong)
kx = random.choice(key_exchanges)
forward_secrecy = kx in ["ECDHE", "DHE"]
key_algo = random.choice(key_algos)
key_length = random.choice([4096, 2048, 1024]) if key_algo == "RSA" else 256
sig_algo = random.choice(sig_algos)
days_to_expiry = random.randint(-10, 365)
expiry_date = (base_time + timedelta(days=days_to_expiry)).strftime("%Y-%m-%d")
How does weakness selection work?
The is_weak flag ensures that sessions with deprecated TLS versions always get a weak cipher suite. A 30% random chance also assigns weak ciphers to otherwise modern sessions, creating realistic mixed environments.
- Continuing inside the for loop, add the vulnerability detection logic that checks each session's attributes:
# Build vulnerability list based on session attributes
vulns = []
if tls_version in ["TLS 1.0", "TLS 1.1", "SSLv3"]:
vulns.append("Deprecated TLS version in use")
if cipher in cipher_suites_weak:
vulns.append("Weak cipher suite detected")
if not forward_secrecy:
vulns.append("No forward secrecy")
if days_to_expiry < 30:
vulns.append("Certificate expires within 30 days")
if key_length <= 1024:
vulns.append("Short RSA key length (1024-bit)")
if sig_algo == "SHA1withRSA":
vulns.append("SHA-1 signature algorithm")
if "RC4" in cipher:
vulns.append("RC4 cipher in use")
if tls_version == "SSLv3":
vulns.append("SSLv3 POODLE vulnerability exposure")
if random.random() < 0.1:
vulns.append("Self-signed certificate")
What is this block doing?
Each if statement maps a session attribute to a real-world vulnerability. A session using SSLv3 picks up both the deprecated version finding and the POODLE exposure finding. The more issues a session has, the higher its risk score will be.
- Still inside the for loop, add the risk scoring, anomaly simulation, and protocol selection:
# Calculate risk score from vulnerability count
risk_score = min(100, len(vulns) * 18 + random.randint(-5, 10))
risk_score = max(0, risk_score)
# Assign risk label based on score thresholds
if risk_score >= 75:
risk_label = "Critical"
elif risk_score >= 50:
risk_label = "High"
elif risk_score >= 25:
risk_label = "Medium"
else:
risk_label = "Low"
# Simulate ML anomaly detection output
anomaly_score = random.uniform(0, 1)
anomaly_detected = anomaly_score > 0.7
protocol = random.choice(protocols)
port_map = {"SMTP": 25, "IMAP": 993, "POP3": 995}
How does the risk score work?
Each vulnerability adds roughly 18 points, with a small random offset. The label thresholds (Critical at 75+, High at 50+, Medium at 25+) mirror common security scoring tiers. The anomaly_score simulates your Project 2 ML model's output.
- Finish the for loop by assembling the session dictionary, then close the function with the return statement:
session = {
"session_id": f"S-{i+1:04d}",
"timestamp": (base_time + timedelta(minutes=i * random.randint(1, 10))).isoformat(),
"src_ip": f"192.168.1.{random.randint(10, 200)}",
"dst_ip": f"10.0.{random.randint(1, 5)}.{random.randint(1, 50)}",
"src_port": random.randint(49152, 65535),
"dst_port": port_map[protocol],
"protocol": protocol,
"starttls_detected": protocol == "SMTP" and random.random() > 0.3,
"tls_version": tls_version,
"cipher_suite": cipher,
"key_exchange": kx,
"forward_secrecy": forward_secrecy,
"certificate": {
"subject": f"mail.example-{random.randint(1, 10)}.com",
"issuer": random.choice(["Let's Encrypt Authority X3", "DigiCert Global G2", "Self-Signed"]),
"expiry_date": expiry_date,
"key_algorithm": key_algo,
"key_length": key_length,
"signature_algorithm": sig_algo,
},
"vulnerabilities": vulns,
"risk_score": risk_score,
"risk_label": risk_label,
"anomaly_score": round(anomaly_score, 3),
"anomaly_detected": anomaly_detected,
}
sessions.append(session)
return sessions
What does this session record look like?
Each session record contains the same fields your Projects 1 and 2 produce: network metadata (IPs, ports, protocol), TLS negotiation details (version, cipher, key exchange), certificate information (subject, issuer, expiry), and risk assessment (score, label, anomaly detection). This schema is what the dashboard will consume.
- Finally, add the main block at the bottom of the file, outside the function:
if __name__ == "__main__":
data = generate_sample_data()
with open("analysis_output.json", "w") as f:
json.dump(data, f, indent=2)
print(f"Generated {len(data)} sample sessions to analysis_output.json")
- Save sample_data.py.
✔️ Awesome, I've got everything!
Great. Make sure you have saved sample_data.py before running it in the next step.
ⓧ I'd like to double check the full code
Scroll up through your sample_data.py and confirm each of these sections is present, in order:
- Imports (json, random, datetime) and the generate_sample_data function definition.
- Data pool lists: protocols, tls_versions, tls_version_weights, cipher_suites_strong, cipher_suites_weak.
- Key exchange and algorithm lists: key_exchanges, key_algos, sig_algos.
- The vulnerability_pool list (10 items), sessions = [], and base_time.
- The for i in range(num_sessions) loop with TLS/cipher/key selection.
- The vulnerability detection block (9 if statements appending to vulns).
- Risk scoring, label assignment, anomaly simulation, and protocol selection.
- The session = { ... } dictionary, sessions.append(session), and return sessions.
- The if __name__ == "__main__" block at the bottom (outside the function).
Before you run the script, what do you expect to see in the terminal?
- Generate the sample data by running this command:
python sample_data.py
You should see: Generated 30 sample sessions to analysis_output.json.
- Confirm the JSON file was created by running:
ls
You should now see analysis_output.json alongside your other project files.
Seeing a syntax error when running sample_data.py?
The most common cause is a missing or extra indentation in one of the code blocks you pasted. Every line inside the for loop must be indented by 8 spaces (4 for the function, 4 for the loop). The if __name__ block at the bottom must have zero indentation.
Help me fix a syntax error in my sample_data.py file.
Your environment is wired up and your sample data is ready. Next up, you will load that data into a Dash app and build an interactive session table with color-coded risk labels.
Load Data and Build the Session Table
Your environment is ready and analysis_output.json holds 30 TLS session records waiting to be explored. Raw JSON is fine for machines, but judges and stakeholders cannot search, sort, or spot patterns in a wall of curly braces.
The first thing anyone should see when they open the dashboard is a scannable overview of every session. Color-coded risk labels make critical findings jump out immediately. In this step, you will load the data into a Dash DataTable and give it visual styling that turns raw session records into an at-a-glance security overview.
In this step, get ready to:
- Load TLS session data from analysis_output.json into a Dash-ready DataFrame.
- Build a styled DataTable displaying every session with color-coded risk labels.
- Add native filtering, sorting, and row selection for interactive exploration.
Build the Dash app layout with a DataTable
Before you touch app.py, the import at the top will reference functions from report_generator.py that do not exist yet. You need placeholder stubs so the import does not crash the app.
- Open report_generator.py in your editor and add these placeholder functions:
# Placeholder functions for report generation
# Full implementation comes in Step 5
def generate_json_report(sessions, posture_score):
pass
def generate_html_report(sessions, figures, posture_score):
pass
def generate_pdf_report(sessions, figures, posture_score):
pass
Why placeholder stubs?
Python resolves all imports when a module loads. If app.py tries to import functions that do not exist, the entire app crashes before the dashboard renders. These empty stubs satisfy the import until you build the real report generator in Step 5.
- Save report_generator.py.
- Open app.py and add the imports and data loading code:
import json
import base64
from pathlib import Path
import pandas as pd
import plotly.express as px
import plotly.graph_objects as go
from dash import Dash, html, dcc, dash_table, Input, Output, State, callback
from report_generator import generate_json_report, generate_html_report, generate_pdf_report
# Load TLS session data from the analysis output
DATA_PATH = Path("analysis_output.json")
with open(DATA_PATH) as f:
sessions = json.load(f)
# Convert to a DataFrame for easier manipulation
df = pd.DataFrame(sessions)
df["timestamp"] = pd.to_datetime(df["timestamp"])
What does this code do?
The imports pull in Dash for the web framework, Plotly for charting, and pandas for tabular data. The stub imports from report_generator are placeholders you will replace later.
The data loading reads analysis_output.json into a raw Python list (sessions) and also into a pandas DataFrame (df) with timestamps parsed for later time-based analysis.
- Save app.py.
- Verify the data loads by running this command in your terminal:
python -c "from app import df; print(f'{len(df)} sessions loaded')"
You should see 30 sessions loaded printed in the terminal.
Seeing an ImportError?
- If the error mentions report_generator, confirm you saved report_generator.py with the three stub functions.
- If the error mentions dash or pandas, make sure your virtual environment is activated.
- If the error mentions analysis_output.json, run python sample_data.py first to generate the data file.
Still stuck? Help me debug my data loading import error in app.py
- Next, add the configuration variables below the data loading code in app.py:
# Define which columns appear in the DataTable
table_columns = [
{"name": "Session", "id": "session_id"},
{"name": "Protocol", "id": "protocol"},
{"name": "Source IP", "id": "src_ip"},
{"name": "Dest IP", "id": "dst_ip"},
{"name": "TLS Version", "id": "tls_version"},
{"name": "Cipher Suite", "id": "cipher_suite"},
{"name": "Risk Score", "id": "risk_score"},
{"name": "Risk Label", "id": "risk_label"},
]
# Aggregate posture score: invert risk average so higher = healthier
posture_score = max(0, 100 - int(df["risk_score"].mean()))
# Map each risk label to a display color
RISK_COLORS = {
"Critical": "#e74c3c",
"High": "#e67e22",
"Medium": "#f1c40f",
"Low": "#2ecc71",
}
# Pick posture card color based on score threshold
if posture_score >= 70:
posture_color = "#2ecc71"
elif posture_score >= 40:
posture_color = "#f1c40f"
else:
posture_color = "#e74c3c"
What does posture_score measure?
The formula max(0, 100 - mean(risk_scores)) inverts the average risk score into a health metric. A cluster of low-risk sessions pushes the score toward 100. A cluster of critical sessions drags it toward 0.
Stakeholders instantly know whether the email infrastructure is in good shape (green, 70+), needs attention (yellow, 40-70), or is actively at risk (red, below 40).
- Save app.py and confirm the posture score computes correctly:
python -c "from app import posture_score; print(f'Posture score: {posture_score}/100')"
You should see a score printed (the exact number depends on your randomized sample data).
Seeing a NameError for df?
Make sure the config block sits directly below the data loading code in app.py. The posture_score line references df, which must be defined above it.
Need more help? Help me debug my posture_score NameError
Now you will build the dashboard layout. The layout is a single nested structure in Dash, so you will add it in two consecutive code blocks.
- Add the app creation and the first part of the layout below the config block in app.py:
app = Dash(__name__)
app.title = "TLS Security Dashboard"
app.layout = html.Div(
style={"fontFamily": "Segoe UI, sans-serif", "padding": "20px", "backgroundColor": "#1a1a2e"},
children=[
html.H1("Email TLS Security Dashboard", style={"color": "#eee", "textAlign": "center"}),
html.Div( # Metric cards row
style={"display": "flex", "justifyContent": "center", "gap": "20px", "marginBottom": "20px"},
children=[
html.Div(
style={"backgroundColor": posture_color, "padding": "20px 40px", "borderRadius": "12px", "textAlign": "center", "color": "#fff"},
children=[
html.Div("Aggregate Posture Score", style={"fontSize": "14px", "opacity": "0.9"}),
html.Div(f"{posture_score}/100", style={"fontSize": "36px", "fontWeight": "bold"}),
],
),
html.Div(
style={"backgroundColor": "#16213e", "padding": "20px 30px", "borderRadius": "12px", "textAlign": "center", "color": "#eee"},
children=[
html.Div("Total Sessions", style={"fontSize": "14px", "opacity": "0.7"}),
html.Div(str(len(df)), style={"fontSize": "36px", "fontWeight": "bold"}),
],
),
html.Div(
style={"backgroundColor": "#e74c3c", "padding": "20px 30px", "borderRadius": "12px", "textAlign": "center", "color": "#fff"},
children=[
html.Div("Critical Findings", style={"fontSize": "14px", "opacity": "0.9"}),
html.Div(str(len(df[df["risk_label"] == "Critical"])), style={"fontSize": "36px", "fontWeight": "bold"}),
],
),
],
),
What does this layout create?
This is the top section of your dashboard. app.layout defines the entire page as a dark-themed html.Div container. Inside it, a centered H1 title sits above three metric cards.
Each card uses the posture_color and RISK_COLORS values you computed above. The posture score card turns green, yellow, or red depending on the overall health of the session data.
- Paste the rest of the layout directly below the code you just added, continuing inside the same children list:
html.Div( # Export buttons (callbacks wired in Step 5)
style={"display": "flex", "gap": "10px", "justifyContent": "center", "marginBottom": "20px"},
children=[
html.Button("Export JSON", id="btn-json", style={"padding": "10px 20px", "cursor": "pointer"}),
html.Button("Export HTML", id="btn-html", style={"padding": "10px 20px", "cursor": "pointer"}),
html.Button("Export PDF", id="btn-pdf", style={"padding": "10px 20px", "cursor": "pointer"}),
],
),
dcc.Download(id="download-report"),
dash_table.DataTable( # Session overview table
id="session-table",
columns=table_columns,
data=df.drop(columns=["certificate", "vulnerabilities", "timestamp"]).to_dict("records"),
selected_rows=[],
page_size=15,
style_table={"overflowX": "auto"},
style_header={"backgroundColor": "#16213e", "color": "#eee", "fontWeight": "bold"},
style_cell={"backgroundColor": "#0f3460", "color": "#eee", "textAlign": "left", "padding": "8px"},
),
html.Div(id="detail-panel", style={"display": "flex", "gap": "20px"}, children=[
html.Div("Select a session to view details.", style={"color": "#aaa", "padding": "40px", "textAlign": "center", "width": "100%"})]),
],
)
if __name__ == "__main__":
app.run(debug=True)
What did this add?
Three export buttons (btn-json, btn-html, btn-pdf) are wired to a dcc.Download component. The buttons sit in the layout now, but their callbacks come in Step 5.
The dash_table.DataTable renders one row per TLS session. It drops the nested certificate and vulnerabilities columns (too complex for a flat table) and the raw timestamp. The detail-panel placeholder at the bottom will show drill-down details in Step 4.
- Save app.py.
- Start the dashboard by running:
python app.py
Open http://127.0.0.1:8050 in your browser. You should see the dashboard title, three metric cards (posture score, total sessions, critical findings), export buttons, and a DataTable listing session rows. The risk labels appear as plain text for now.
Hot reload is on
Because debug=True is set, Dash watches for file changes and auto-reloads the app when you save. Leave the server running in this terminal. For the next edit, your browser will refresh automatically after you save.
Dashboard not loading?
- If the terminal shows a syntax error, check that the two layout code blocks are pasted consecutively with no gap. The second block continues inside the same children list that the first block opened.
- If the page loads but looks broken, confirm you saved app.py before refreshing.
- If port 8050 is already in use, press Ctrl+C to stop any previous server, then run python app.py again.
Still stuck? Help me debug my Dash app layout not rendering
Add filtering and sorting to the table
The table displays sessions, but you cannot search for specific protocols or sort by risk score. Dash DataTables support native filtering, sorting, and row selection through three props.
- In app.py, find the dash_table.DataTable block. Locate these lines:
dash_table.DataTable(
id="session-table",
columns=table_columns,
data=df.drop(columns=["certificate", "vulnerabilities", "timestamp"]).to_dict("records"),
selected_rows=[],
page_size=15,
- Add three new props and the conditional styling block so the DataTable section now looks like this:
dash_table.DataTable(
id="session-table",
columns=table_columns,
data=df.drop(columns=["certificate", "vulnerabilities", "timestamp"]).to_dict("records"),
row_selectable="single", # Click a row to select it
selected_rows=[],
sort_action="native", # Click column headers to sort
filter_action="native", # Type in filter boxes below headers
page_size=15,
style_table={"overflowX": "auto"},
style_header={"backgroundColor": "#16213e", "color": "#eee", "fontWeight": "bold"},
style_cell={"backgroundColor": "#0f3460", "color": "#eee", "textAlign": "left", "padding": "8px"},
style_data_conditional=[
{"if": {"filter_query": '{risk_label} = "Critical"', "column_id": "risk_label"}, "backgroundColor": "#e74c3c", "color": "#fff"},
{"if": {"filter_query": '{risk_label} = "High"', "column_id": "risk_label"}, "backgroundColor": "#e67e22", "color": "#fff"},
{"if": {"filter_query": '{risk_label} = "Medium"', "column_id": "risk_label"}, "backgroundColor": "#f1c40f", "color": "#111"},
{"if": {"filter_query": '{risk_label} = "Low"', "column_id": "risk_label"}, "backgroundColor": "#2ecc71", "color": "#fff"},
],
),
What do these new props do?
- row_selectable="single" adds a radio button column. Clicking a row highlights it and stores its index for callbacks (used in Step 4).
- sort_action="native" lets you click any column header to sort ascending or descending. No custom callback needed.
- filter_action="native" adds a filter text box below each column header. Type a value to filter rows in real time.
- style_data_conditional applies background colors to the Risk Label cells. Critical rows turn red, High turns orange, Medium turns yellow, and Low turns green.
- Save app.py.
Before you check the browser, what do you expect the Risk Label column to look like now?
- Switch to your browser. The dashboard auto-reloads because debug=True is set. If it does not refresh on its own, manually reload the page.
The Risk Label cells now show color coding. Critical labels appear on a red background, High on orange, Medium on yellow, and Low on green. Each column header has a small filter box underneath it.
- Click the Risk Score column header to sort sessions by risk score.
- Type SMTP in the filter box under the Protocol column to narrow the table to SMTP sessions only.
- Click the radio button next to any row to select it. The row highlights, but the detail panel below still says "Select a session to view details." because the drill-down callback has not been written yet.
- Clear the SMTP filter by deleting the text in the filter box.
Filters or colors not showing?
- If the filter boxes are missing, check that filter_action="native" is inside the DataTable constructor, not outside it.
- If risk labels are not color-coded, confirm the style_data_conditional list is inside the DataTable and each entry's filter_query uses double quotes around the label value inside single-quoted strings.
- If the dashboard crashed, check the terminal for a traceback. A misplaced comma or bracket in the layout is the most common cause.
Still stuck? Help me debug my DataTable filtering and conditional styling
✔️ Awesome, I've got everything!
Great. Confirm you have saved both report_generator.py and app.py.
ⓧ I'd like to double check the full code
Your report_generator.py should contain only the three placeholder functions:
# Placeholder functions for report generation
# Full implementation comes in Step 5
def generate_json_report(sessions, posture_score):
pass
def generate_html_report(sessions, figures, posture_score):
pass
def generate_pdf_report(sessions, figures, posture_score):
pass
Your app.py is too long to show in one block. Scroll up and confirm it contains all of the following sections in order:
- Imports (json, base64, Path, pandas, plotly, dash, report_generator).
- Data loading: DATA_PATH, sessions, df with timestamp parsed.
- Configuration: table_columns, posture_score, RISK_COLORS, posture_color.
- App creation: app = Dash(__name__) and app.title.
- Layout: app.layout containing the H1 title, metric cards row, export buttons, dcc.Download, DataTable with row_selectable, sort_action, filter_action, style_data_conditional, and detail-panel.
- Run block: if __name__ == "__main__": app.run(debug=True).
Your dashboard now gives anyone a scannable overview of every TLS session at a glance. Next up, you will add Plotly charts that answer aggregate questions like "what percentage of sessions use deprecated TLS versions?" and "how are risk scores distributed?"
Add Plotly Security Visualizations
Your session table gives analysts a way to search and filter individual TLS sessions. It surfaces details row by row, but it cannot answer aggregate questions like what percentage of sessions use deprecated TLS versions or how risk scores are distributed.
Plotly charts answer these at a glance. In this step, you'll build three chart functions that surface cipher suite frequency, TLS version breakdown, and risk score distribution across all sessions.
In this step, get ready to:
- Build three Plotly chart-builder functions for aggregate security analysis.
- Add chart components to the dashboard layout below the session table.
Build the cipher suite distribution chart
The first chart answers the question: which cipher suites appear most often, and do they provide forward secrecy? A horizontal bar chart makes cipher suite names easy to read at a glance.
- In app.py, find the RISK_COLORS dictionary. Below it (before the if posture_score >= 70: line), add the cipher suite chart function:
def build_cipher_chart(dataframe):
# Count occurrences of each cipher suite
cipher_counts = dataframe["cipher_suite"].value_counts().reset_index()
cipher_counts.columns = ["cipher_suite", "count"]
# Determine if each suite supports forward secrecy
cipher_counts["forward_secrecy"] = cipher_counts["cipher_suite"].apply(
lambda c: "Yes" if any(k in c for k in ["ECDHE", "DHE", "CHACHA20"]) else "No"
)
# Horizontal bar chart colored by forward secrecy support
fig = px.bar(
cipher_counts,
y="cipher_suite",
x="count",
color="forward_secrecy",
orientation="h",
title="Cipher Suite Distribution",
labels={"count": "Sessions", "cipher_suite": "Cipher Suite", "forward_secrecy": "Forward Secrecy"},
color_discrete_map={"Yes": "#2ecc71", "No": "#e74c3c"},
)
fig.update_layout(height=350, margin=dict(l=20, r=20, t=40, b=20))
return fig
What does this code do?
- value_counts() tallies how many sessions used each cipher suite, producing a frequency table.
- The forward_secrecy column checks each cipher name for ECDHE, DHE, or CHACHA20. These indicate the suite uses ephemeral keys, so past sessions stay safe even if the server key is compromised later.
- px.bar creates a horizontal bar chart where green bars have forward secrecy and red bars do not.
- Save app.py. Check the terminal where your Dash server is running. It should auto-reload without any errors.
Build the TLS version and risk score charts
The second chart is a pie chart that shows which TLS versions your sessions negotiate. The third is a histogram of risk scores grouped by severity.
- Below build_cipher_chart, add the TLS version pie chart function:
def build_tls_version_chart(dataframe):
# Count sessions per TLS version
version_counts = dataframe["tls_version"].value_counts().reset_index()
version_counts.columns = ["tls_version", "count"]
# Mark deprecated versions in red, current ones in green
deprecated = ["TLS 1.0", "TLS 1.1", "SSLv3"]
colors = ["#e74c3c" if v in deprecated else "#2ecc71" for v in version_counts["tls_version"]]
fig = px.pie(
version_counts,
names="tls_version",
values="count",
title="TLS Version Distribution",
color_discrete_sequence=colors,
)
fig.update_layout(height=350, margin=dict(l=20, r=20, t=40, b=20))
return fig
What does this code do?
- The deprecated list flags TLS 1.0, TLS 1.1, and SSLv3 as outdated protocols.
- The color list maps each version to red (deprecated) or green (current), so risky versions jump out instantly in the pie chart.
- Save app.py. Confirm in the terminal that the Dash server auto-reloads without errors.
Seeing a syntax error on reload?
- Make sure both functions use consistent indentation (spaces, not tabs). Python is strict about mixing them.
- Check that each function ends with return fig at the same indent level as the fig = px.bar( or fig = px.pie( line above it.
- Verify that the new functions sit between RISK_COLORS and the if posture_score >= 70: block. Placing them inside another block will break the file.
Still stuck? Help me debug a syntax error when adding chart functions to app.py
- Below build_tls_version_chart, add the risk score histogram function:
def build_risk_histogram(dataframe):
# Histogram of risk scores with color-coded risk levels
fig = px.histogram(
dataframe,
x="risk_score",
color="risk_label",
nbins=20,
title="Risk Score Distribution",
labels={"risk_score": "Risk Score", "risk_label": "Risk Level"},
color_discrete_map=RISK_COLORS,
category_orders={"risk_label": ["Critical", "High", "Medium", "Low"]},
)
fig.update_layout(height=350, margin=dict(l=20, r=20, t=40, b=20))
return fig
What does this code do?
- px.histogram bins the risk_score values into 20 buckets and stacks bars by risk_label.
- color_discrete_map=RISK_COLORS reuses the same red/orange/yellow/green palette from the DataTable so the colors are consistent across the dashboard.
- category_orders forces the legend to display risk levels in severity order (Critical first, Low last) instead of alphabetically.
- Save app.py. The Dash server auto-reloads in the terminal. All three functions are defined and ready.
Add the charts to the dashboard layout
The three chart functions exist but are not called or displayed yet. You need to generate the figures at module level and add dcc.Graph components to the layout so the browser renders them.
- Below build_risk_histogram (still above the if posture_score >= 70: line), add these module-level calls to generate each chart figure:
# Generate chart figures from the loaded data
cipher_fig = build_cipher_chart(df)
tls_fig = build_tls_version_chart(df)
risk_fig = build_risk_histogram(df)
- Scroll down to the app.layout children list. Find the line html.Div(id="detail-panel",.
- Add the following code directly above that line to insert the charts and a section heading:
# Three side-by-side chart panels
html.Div(
style={"display": "flex", "gap": "20px", "marginTop": "20px"},
children=[
dcc.Graph(id="cipher-chart", figure=cipher_fig, style={"flex": "1"}),
dcc.Graph(id="tls-version-chart", figure=tls_fig, style={"flex": "1"}),
dcc.Graph(id="risk-histogram", figure=risk_fig, style={"flex": "1"}),
],
),
html.H2("Session Details", style={"color": "#eee", "marginTop": "30px"}),
What does this layout code do?
- Each dcc.Graph wraps a Plotly figure in a Dash-rendered container with built-in zoom, pan, and hover tools.
- The flex layout places all three charts in a single row. Each chart stretches equally to fill the available width.
- The html.H2("Session Details", ...) heading separates the charts from the detail panel below, which you'll wire up in the next step.
Before saving, what do you expect to see below the session table when the page reloads?
- Save app.py. Refresh http://127.0.0.1:8050 in your browser.
You should see three charts in a row below the session table: a horizontal bar chart of cipher suite frequency colored by forward secrecy, a pie chart of TLS version distribution with deprecated versions in red, and a histogram of risk scores colored by severity. The aggregate posture score card at the top still shows the overall health score.
Charts not appearing?
- Check that the three cipher_fig, tls_fig, and risk_fig calls sit between build_risk_histogram and if posture_score >= 70:. If they are inside a function or after app.layout, the variables will not be available when the layout references them.
- Verify that the charts html.Div sits inside the app.layout children list, not outside it. A missing comma after the DataTable block will also cause a syntax error.
- If you see a blank area instead of charts, check the terminal for a NameError. This means the figure variables are not defined at the point the layout reads them.
Still stuck? Help me debug why my Plotly charts are not showing up in the Dash dashboard
✔️ Awesome, I've got everything!
Great. Double check you have saved app.py and all three charts are visible in your browser.
ⓧ I'd like to double check the full code
Your app.py should have these sections from top to bottom. Scroll through your file and confirm each one is present in order:
- Imports (json, base64, Path, pandas, plotly.express, plotly.graph_objects, dash components, and report_generator functions).
- Data loading: DATA_PATH, sessions, df DataFrame, table_columns, posture_score, and RISK_COLORS.
- Three chart functions: build_cipher_chart, build_tls_version_chart, build_risk_histogram.
- Module-level calls: cipher_fig, tls_fig, risk_fig.
- posture_color computation (the if/elif/else block).
- app = Dash(__name__) and app.layout containing the header, score cards, export buttons, dcc.Download, the DataTable, the charts html.Div with three dcc.Graph components, the html.H2("Session Details") heading, and the detail panel.
- if __name__ == "__main__": app.run(debug=True) at the bottom.
Your dashboard now paints the big picture with charts and an aggregate posture score. Next up, you'll wire up interactive drill-down so clicking any session row reveals its certificate chain, connection details, and risk assessment.
Wire Up Interactive Session Drill-Down
Your dashboard shows the big picture. The charts answer aggregate questions and the table lets you search for individual sessions. But when a security analyst spots a high-risk session, they need to drill into that specific session's certificate chain, key exchange parameters, and vulnerability findings without leaving the dashboard.
Right now, clicking a table row highlights it but nothing else happens. Dash callbacks fix this. A callback watches a component for changes (like a row selection) and automatically updates other components in response. In this step, you will wire the session table to a detail panel that shows certificate, connection, and risk information for whichever row the analyst clicks.
In this step, get ready to:
- Add a reusable helper function that renders styled detail cards.
- Write a Dash callback that updates three detail panels when a session row is clicked.
Add the detail card helper function
The drill-down panel will show three side-by-side cards: Certificate Chain, Connection Details, and Risk Assessment. Two of these cards share the same structure: a title bar followed by a list of key-value rows. A reusable helper function avoids duplicating that layout code.
- In app.py, scroll down to the closing parenthesis of app.layout.
- Below it, add the detail_card() helper function by pasting this code:
def detail_card(title, items):
# Build a styled card with a title and key-value rows
return html.Div(
style={"backgroundColor": "#16213e", "padding": "15px", "borderRadius": "8px", "flex": "1", "color": "#eee"},
children=[
html.H3(title, style={"borderBottom": "1px solid #444", "paddingBottom": "8px", "marginTop": "0"}),
] + [
html.Div(
[html.Span(f"{k}: ", style={"color": "#888"}), html.Span(str(v))],
style={"marginBottom": "6px"},
)
for k, v in items.items()
],
)
What does this code do?
The detail_card() function takes a title string and a dictionary of items. It returns an html.Div with the title as an html.H3 heading, followed by one row per dictionary entry.
Each row shows the key in a muted gray span and the value in a brighter span. The flex: 1 style makes every card expand to fill equal width inside the detail panel's flexbox container.
- Save app.py.
- Switch back to your browser and confirm the dashboard still loads at http://127.0.0.1:8050. Dash's debug mode auto-reloads on save, so if the page renders without errors your helper function is valid.
Seeing a syntax error in the terminal?
- Make sure the function is placed outside of app.layout, not inside the children list. It should sit below the closing parenthesis of the layout assignment.
- Check that you kept the if __name__ == "__main__": block at the very bottom of the file, below the new function.
Help me debug my detail_card function in app.py
Write the drill-down callback
A Dash callback is a Python function decorated with @callback. You declare which component property triggers it (the Input) and which component property it updates (the Output). Dash calls the function automatically whenever the input changes.
- Below the detail_card() function you just added, paste the first part of the drill-down callback:
@callback(
Output("detail-panel", "children"),
Input("session-table", "selected_rows"),
State("session-table", "data"),
)
def update_detail_panel(selected_rows, table_data):
# Return placeholder when no row is selected
if not selected_rows:
return [html.Div("Select a session to view details.", style={"color": "#aaa", "padding": "40px", "textAlign": "center", "width": "100%"})]
# Look up the full session record by session_id
row = table_data[selected_rows[0]]
sid = row["session_id"]
session = next(s for s in sessions if s["session_id"] == sid)
# Build the certificate chain detail card
cert = session["certificate"]
cert_card = detail_card("Certificate Chain", {
"Subject": cert["subject"],
"Issuer": cert["issuer"],
"Expiry": cert["expiry_date"],
"Key Algorithm": cert["key_algorithm"],
"Key Length": f"{cert['key_length']} bits",
"Signature Algorithm": cert["signature_algorithm"],
})
What does this code do?
- The @callback decorator wires Input("session-table", "selected_rows") to Output("detail-panel", "children"). Whenever the selected row changes, Dash calls this function.
- The State("session-table", "data") passes the table's current data into the function without triggering it. This lets the callback read the row data without firing on every data change.
- The guard clause returns the placeholder text when no row is selected, keeping the panel clean on first load.
- The session lookup uses session_id from the clicked row to find the full record in the original sessions list. The DataTable only shows a subset of columns, but the full record has certificate data, vulnerabilities, and anomaly scores.
- Directly below the cert_card assignment (still inside the update_detail_panel function), paste the connection details card, the risk assessment card, and the return statement:
# Build the connection details card
conn_card = detail_card("Connection Details", {
"Protocol": session["protocol"],
"STARTTLS": "Yes" if session["starttls_detected"] else "No",
"TLS Version": session["tls_version"],
"Cipher Suite": session["cipher_suite"],
"Key Exchange": session["key_exchange"],
"Forward Secrecy": "Yes" if session["forward_secrecy"] else "No",
})
# Build the risk assessment card with vulnerability findings
vuln_list = session["vulnerabilities"] if session["vulnerabilities"] else ["No vulnerabilities detected"]
risk_card = html.Div(
style={"backgroundColor": "#16213e", "padding": "15px", "borderRadius": "8px", "flex": "1", "color": "#eee"},
children=[
html.H3("Risk Assessment", style={"borderBottom": "1px solid #444", "paddingBottom": "8px", "marginTop": "0"}),
html.Div([html.Span("Risk Score: ", style={"color": "#888"}), html.Span(f"{session['risk_score']}/100")], style={"marginBottom": "6px"}),
html.Div([html.Span("Risk Label: ", style={"color": "#888"}), html.Span(session["risk_label"], style={"color": RISK_COLORS.get(session["risk_label"], "#eee")})], style={"marginBottom": "6px"}),
html.Div([html.Span("Anomaly Detected: ", style={"color": "#888"}), html.Span("Yes" if session["anomaly_detected"] else "No")], style={"marginBottom": "6px"}),
html.Div([html.Span("Anomaly Score: ", style={"color": "#888"}), html.Span(str(session["anomaly_score"]))], style={"marginBottom": "12px"}),
html.H4("Findings", style={"marginBottom": "8px"}),
] + [html.Div(f"- {v}", style={"color": "#e74c3c" if v != "No vulnerabilities detected" else "#2ecc71", "marginBottom": "4px"}) for v in vuln_list],
)
return [cert_card, conn_card, risk_card]
What does this code do?
- The conn_card reuses detail_card() to display protocol, STARTTLS status, TLS version, cipher suite, key exchange, and forward secrecy.
- The risk_card is built manually because it needs custom formatting. The risk label text is colored using the RISK_COLORS dictionary so Critical appears red, High appears orange, and so on.
- The vulnerability findings are rendered as a list at the bottom of the risk card. Each finding is colored red, and if no vulnerabilities exist, a green "No vulnerabilities detected" message appears instead.
- The return statement sends all three cards back to Dash, which replaces the detail-panel div's children with the three side-by-side cards.
- Make sure the if __name__ == "__main__": block is still the very last thing in app.py, below the callback you just added.
- Save app.py.
Before you click a row, predict what you expect to see. Will the placeholder text stay, or will the panel change? What information do you think will appear?
- Switch back to your browser at http://127.0.0.1:8050. Dash auto-reloads on save.
- Click any row in the session table.
The "Session Details" section below the charts updates with three side-by-side cards. The Certificate Chain card shows subject, issuer, expiry, key algorithm, key length, and signature algorithm. The Connection Details card shows protocol, STARTTLS status, TLS version, cipher suite, key exchange, and forward secrecy. The Risk Assessment card shows the risk score, a color-coded risk label, anomaly detection results, and a list of vulnerability findings.
- Click a different row to confirm the panels update with that session's data.
Clicking a row but the detail panel stays blank?
- Check the terminal where app.py is running. If you see a traceback, read the last line for the error. A common cause is a missing indent on conn_card or risk_card. Both must be indented one level inside the update_detail_panel function body.
- Verify that the callback's Output targets "detail-panel". This must match the id="detail-panel" on the div in your layout exactly.
- Make sure row_selectable="single" is set on the DataTable in your layout. Without it, clicking a row does not populate selected_rows.
Help me debug my Dash drill-down callback
✔️ Awesome, I've got everything!
Great. Double check you have saved app.py.
ⓧ I'd like to double check the full code
The full app.py is too long to show in one block. Scroll through your file and confirm these sections appear in order:
- Imports (json, base64, Path, pandas, plotly.express, plotly.graph_objects, Dash components, and report_generator functions).
- Data loading: sessions, df, table_columns, posture_score, and RISK_COLORS.
- Three chart builder functions: build_cipher_chart(), build_tls_version_chart(), build_risk_histogram().
- Figure variables (cipher_fig, tls_fig, risk_fig) and posture_color logic.
- The full app.layout assignment with posture card, export buttons, dcc.Download, DataTable, three dcc.Graph charts, and the detail-panel div.
- The detail_card() helper function.
- The @callback decorated update_detail_panel() function with cert_card, conn_card, and risk_card.
- The if __name__ == "__main__": block at the very end.
Your dashboard is now fully interactive. Clicking any session row instantly populates the detail panel with certificate chain, connection, and risk information. Next up, you will build the multi-format report exporter so stakeholders can take this analysis offline as JSON, HTML, or PDF.
Build the Multi-Format Report Exporter
Your dashboard lets you explore every TLS session interactively. Clicking a row surfaces certificate details, cipher info, and vulnerability findings. But all of that insight lives in the browser. A security analyst who needs to attach findings to a Jira ticket, email a summary to management, or archive results for compliance needs a downloadable artifact.
The export buttons in your layout are already wired to report_generator.py, but that file is still empty. In this step, you'll build a Jinja2 HTML template for the report and implement the generator functions that produce JSON, HTML, and PDF downloads.
In this step, get ready to:
- Create a styled Jinja2 HTML report template with embedded chart placeholders and a session findings table.
- Build report generator functions for JSON, HTML, and PDF export using Kaleido and WeasyPrint.
- Test all three export formats from the dashboard.
Create the Jinja2 HTML report template
The report pipeline works in two stages. First, a Jinja2 template defines the report's HTML structure. Then, the generator fills it with data and chart images at export time. The same rendered HTML feeds both the browser-viewable HTML export and the PDF conversion.
- Open the empty templates/report.html file and paste the following head section with all the report styles:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>TLS Security Analysis Report</title>
<style>
body { font-family: "Segoe UI", Arial, sans-serif; margin: 40px; color: #222; line-height: 1.5; }
h1 { text-align: center; color: #1a1a2e; border-bottom: 3px solid #0f3460; padding-bottom: 10px; }
.meta { text-align: center; color: #666; margin-bottom: 30px; }
.score-box { text-align: center; padding: 20px; border-radius: 10px; color: #fff; font-size: 24px; font-weight: bold; margin: 20px auto; width: 300px; }
.summary-grid { display: flex; flex-wrap: wrap; gap: 15px; justify-content: center; margin-bottom: 30px; }
.summary-card { background: #f4f4f8; border-radius: 8px; padding: 15px 25px; text-align: center; min-width: 140px; }
.summary-card .value { font-size: 28px; font-weight: bold; color: #1a1a2e; }
.summary-card .label { font-size: 12px; color: #666; }
.chart-container { text-align: center; margin: 20px 0; }
.chart-container img { max-width: 100%; height: auto; }
table { width: 100%; border-collapse: collapse; margin-top: 20px; font-size: 11px; }
th { background: #1a1a2e; color: #fff; padding: 8px; text-align: left; }
td { padding: 6px 8px; border-bottom: 1px solid #ddd; }
tr:nth-child(even) { background: #f9f9f9; }
.risk-critical { background: #e74c3c; color: #fff; padding: 2px 8px; border-radius: 4px; }
.risk-high { background: #e67e22; color: #fff; padding: 2px 8px; border-radius: 4px; }
.risk-medium { background: #f1c40f; color: #111; padding: 2px 8px; border-radius: 4px; }
.risk-low { background: #2ecc71; color: #fff; padding: 2px 8px; border-radius: 4px; }
@media print { body { margin: 20px; } .chart-container { page-break-inside: avoid; } }
</style>
</head>
What does this code do?
This <head> section defines the entire visual design of the exported report. Each CSS class maps to a section you'll build next: .score-box for the posture score badge, .summary-grid for the statistics cards, .chart-container for embedded chart images, and .risk-* classes for color-coded risk labels in the findings table.
The @media print rule prevents charts from splitting across pages when the HTML is printed or converted to PDF.
- Below the </head> tag, add the body section with the report header, posture score box, and summary statistics grid:
<body>
<h1>TLS Security Analysis Report</h1>
<div class="meta">Generated: {{ summary.generated_at }}</div>
{% set score = summary.posture_score %}
{% if score >= 70 %}
{% set bg = "#2ecc71" %}
{% elif score >= 40 %}
{% set bg = "#f1c40f" %}
{% else %}
{% set bg = "#e74c3c" %}
{% endif %}
<div class="score-box" style="background: {{ bg }};">
Aggregate Posture Score: {{ score }}/100
</div>
<div class="summary-grid">
<div class="summary-card"><div class="value">{{ summary.total_sessions }}</div><div class="label">Total Sessions</div></div>
<div class="summary-card"><div class="value" style="color: #e74c3c;">{{ summary.critical_count }}</div><div class="label">Critical</div></div>
<div class="summary-card"><div class="value" style="color: #e67e22;">{{ summary.high_count }}</div><div class="label">High</div></div>
<div class="summary-card"><div class="value">{{ summary.deprecated_tls_count }}</div><div class="label">Deprecated TLS</div></div>
<div class="summary-card"><div class="value">{{ summary.no_forward_secrecy_count }}</div><div class="label">No Forward Secrecy</div></div>
<div class="summary-card"><div class="value">{{ summary.anomaly_count }}</div><div class="label">Anomalies</div></div>
</div>
What does this code do?
The {{ }} and {% %} tags are Jinja2 syntax. The {% set %} and {% if %} blocks pick a background color for the posture score box. Green means the infrastructure looks healthy, yellow is mixed, and red signals serious issues.
The summary grid renders six statistic cards. Each card pulls a value from the summary dictionary that the generator passes into the template at render time.
- Save templates/report.html and open it directly in your browser by double-clicking the file.
You'll see the styled layout skeleton. The CSS renders a centered heading, a score box, and card placeholders. The Jinja2 tags like {{ summary.generated_at }} appear as raw text because no data has been injected yet.
Seeing a blank page?
Make sure you saved the file with the .html extension, not .txt. Double-check that the <head> and <body> sections are pasted consecutively with no gap between </head> and <body>.
help me debug my report template
- Below the closing </div> of the summary grid, add the chart image sections and session findings table:
<h2>Cipher Suite Distribution</h2>
<div class="chart-container">
<img src="data:image/png;base64,{{ chart_images.cipher }}" alt="Cipher Suite Distribution">
</div>
<h2>TLS Version Breakdown</h2>
<div class="chart-container">
<img src="data:image/png;base64,{{ chart_images.tls_version }}" alt="TLS Version Distribution">
</div>
<h2>Risk Score Distribution</h2>
<div class="chart-container">
<img src="data:image/png;base64,{{ chart_images.risk }}" alt="Risk Score Distribution">
</div>
<h2>Session Findings</h2>
<table>
<thead>
<tr>
<th>Session</th><th>Protocol</th><th>TLS</th><th>Cipher Suite</th>
<th>FS</th><th>Score</th><th>Risk</th><th>Findings</th>
</tr>
</thead>
<tbody>
{% for s in sessions %}
<tr>
<td>{{ s.session_id }}</td><td>{{ s.protocol }}</td>
<td>{{ s.tls_version }}</td><td>{{ s.cipher_suite }}</td>
<td>{{ "Yes" if s.forward_secrecy else "No" }}</td>
<td>{{ s.risk_score }}</td>
<td><span class="risk-{{ s.risk_label | lower }}">{{ s.risk_label }}</span></td>
<td>{{ s.vulnerabilities | join("; ") if s.vulnerabilities else "None" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</body>
</html>
What does this code do?
The three <img> tags use data:image/png;base64, URIs. At render time, the generator converts each Plotly chart to a PNG using Kaleido, base64-encodes it, and injects the string. The charts appear inline in the HTML without any external files.
The {% for s in sessions %} loop creates one table row per session. The risk-{{ s.risk_label | lower }} filter applies the correct CSS class for color-coded risk badges.
- Save templates/report.html.
✔️ Awesome, I've got everything!
Great. Double check you've saved the file.
ⓧ I'd like to double check the full code
Your templates/report.html should contain these three sections in order:
- The <head> section with 19 CSS rules inside a <style> block (from body through @media print).
- The body header section with the <h1> title, Jinja2 conditional score box, and six summary cards inside .summary-grid.
- Three chart <img> tags with base64 data URIs, followed by a <table> with a {% for s in sessions %} loop, ending with </body></html>.
Build the report generator functions
The generator module converts raw session data into three export formats. JSON is a direct data dump. HTML renders the Jinja2 template you just built. PDF passes that rendered HTML through WeasyPrint to produce a print-ready document.
- Open the empty report_generator.py file and paste the imports and the chart-to-image helper:
import json
import base64
from datetime import datetime
from pathlib import Path
from jinja2 import Environment, FileSystemLoader
from weasyprint import HTML
# Path to the Jinja2 templates directory
TEMPLATE_DIR = Path(__file__).parent / "templates"
def _fig_to_base64(fig, width=700, height=350):
# Convert a Plotly figure to a base64-encoded PNG string
img_bytes = fig.to_image(format="png", width=width, height=height)
return base64.b64encode(img_bytes).decode("utf-8")
What does this code do?
TEMPLATE_DIR points to the templates/ folder relative to this file. This is how the generator finds report.html at render time.
_fig_to_base64() calls Plotly's to_image() method, which uses Kaleido to launch a headless Chrome process and render the chart as a PNG. The width and height parameters must be explicit because Kaleido does not fall back to the figure's own dimensions.
- Below _fig_to_base64, add the summary computation function:
def _compute_summary(sessions, posture_score):
# Aggregate key statistics for the report header
total = len(sessions)
critical = sum(1 for s in sessions if s["risk_label"] == "Critical")
high = sum(1 for s in sessions if s["risk_label"] == "High")
deprecated_tls = sum(1 for s in sessions if s["tls_version"] in ["TLS 1.0", "TLS 1.1", "SSLv3"])
no_fs = sum(1 for s in sessions if not s["forward_secrecy"])
anomalies = sum(1 for s in sessions if s["anomaly_detected"])
return {
"generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"posture_score": posture_score,
"total_sessions": total,
"critical_count": critical,
"high_count": high,
"deprecated_tls_count": deprecated_tls,
"no_forward_secrecy_count": no_fs,
"anomaly_count": anomalies,
}
What does this code do?
This function scans all sessions once and counts the metrics that appear in the report header. The dictionary keys (critical_count, deprecated_tls_count, etc.) match the Jinja2 template variables like {{ summary.critical_count }} you added to report.html.
- Verify the helpers load correctly by running this command:
python -c "from report_generator import _compute_summary; print('Helpers loaded')"
You should see Helpers loaded printed with no errors.
Seeing an ImportError?
Make sure your virtual environment is active (you should see (.venv) in your terminal prompt). Verify you are running the command from inside the tls-dashboard directory where report_generator.py lives.
help me fix my report_generator import error
- Below _compute_summary, add the three export functions that power the download buttons:
def generate_json_report(sessions, posture_score):
# Bundle sessions and summary into a JSON string
report = {
"report_type": "TLS Security Analysis",
"summary": _compute_summary(sessions, posture_score),
"sessions": sessions,
}
return json.dumps(report, indent=2, default=str)
def generate_html_report(sessions, figures, posture_score):
# Render the Jinja2 template with data and chart images
summary = _compute_summary(sessions, posture_score)
chart_images = {}
for name, fig in figures.items():
chart_images[name] = _fig_to_base64(fig)
env = Environment(loader=FileSystemLoader(str(TEMPLATE_DIR)))
template = env.get_template("report.html")
return template.render(
summary=summary,
sessions=sessions,
chart_images=chart_images,
)
def generate_pdf_report(sessions, figures, posture_score):
# Reuse the HTML report and convert to PDF with WeasyPrint
html_content = generate_html_report(sessions, figures, posture_score)
return HTML(string=html_content).write_pdf()
What does this code do?
generate_json_report() wraps the raw session data and computed summary into a single JSON string. The default=str argument handles any non-serializable values like datetime objects.
generate_html_report() is the core of the pipeline. It computes the summary, converts all three Plotly figures to base64 PNGs using Kaleido, then passes everything into the Jinja2 template. The rendered HTML string is a complete, self-contained web page.
generate_pdf_report() calls generate_html_report() and feeds the result to HTML(string=html_content).write_pdf(). WeasyPrint parses the HTML and CSS, then renders a print-ready PDF with the charts embedded as images.
- Save report_generator.py.
✔️ Awesome, I've got everything!
Great. Double check you've saved the file.
ⓧ I'd like to double check the full code
Your report_generator.py should contain these sections in order:
- Imports (json, base64, datetime, Path, Environment, FileSystemLoader, HTML) and TEMPLATE_DIR constant.
- _fig_to_base64() helper calling fig.to_image() and returning a base64 string.
- _compute_summary() returning a dictionary with 8 keys (generated_at through anomaly_count).
- generate_json_report(), generate_html_report(), and generate_pdf_report() as three separate functions.
How does app.py connect to these functions?
Your app.py already has the export_report callback wired to the three export buttons. It imports generate_json_report, generate_html_report, and generate_pdf_report at the top of the file. The callback uses ctx.triggered_id to determine which button was clicked, calls the matching function, and returns a dcc.send_string or dcc.send_bytes download.
Now that report_generator.py has real implementations, those buttons will work.
Before you restart the app, what do you think will happen when you click Export PDF? What steps does the code run before a file downloads?
- Stop the running Dash server with Ctrl+C and restart it by running:
python app.py
- Open http://127.0.0.1:8050 in your browser.
- Click the Export JSON button.
A file called tls_report.json downloads to your computer. Open it and you'll see the full session data with a summary block at the top.
- Click the Export HTML button.
- Open the downloaded tls_report.html in your browser.
You'll see a styled report page with the posture score badge, six summary cards, three embedded chart images, and a full findings table with color-coded risk labels.
- Switch back to the dashboard tab and click Export PDF.
- Open the downloaded tls_report.pdf in a PDF viewer.
The PDF contains the same posture score, summary charts (cipher distribution, TLS version breakdown, risk histogram), and findings table. This is a print-ready forensic report.
Export buttons not downloading anything?
Check your terminal for errors. If you see an ImportError mentioning report_generator, make sure you saved report_generator.py and that all three generate_* functions are defined.
If the PDF export fails with a WeasyPrint error, confirm the MSYS2 Pango libraries are on your PATH (Windows) by running python -c "from weasyprint import HTML; print('OK')" in a fresh terminal.
If charts appear as broken images in the HTML or PDF, Kaleido may not be finding Chrome. Run python -c "import plotly.io as pio; print(pio.full_figure_for_development)" to check if Kaleido initializes.
help me debug my export buttons
Your dashboard now produces exportable forensic reports in three formats. You can hand a PDF to management, archive JSON for compliance tooling, or send an HTML link to a colleague. That completes the full pipeline from data ingestion through visualization to reporting.
Secret mission
Add a Risk Trend Timeline
Your dashboard shows the current state of your TLS sessions, but security analysts need to see how posture changes over time. Add a configurable risk trend timeline that groups sessions into time windows and plots the aggregate posture score as a line chart. Wire it into the report exports so the trend appears in your HTML and PDF forensic reports too.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely on your machine using open-source libraries. There are no cloud services, paid APIs, or ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- tls-dashboard/ project directory containing all source files, templates, and generated data.
- Python virtual environment (.venv) with Dash, Plotly, WeasyPrint, Jinja2, Kaleido, and pandas.
- MSYS2 with Pango and Cairo system libraries (Windows only).
Keep everything running
No action needed. Choose this if you are still actively building or plan to keep iterating on your dashboard.
- Your tls-dashboard/ directory and all project files stay on disk with no ongoing cost.
- The Python virtual environment (.venv) inside your project keeps all installed packages ready to use.
- If you installed MSYS2 on Windows, it remains available for other projects that need Pango or Cairo.
Pause - I'll come back to this later
Stop the running Dash server and deactivate your virtual environment to free up system resources. Your files and installed packages stay on disk for later.
- Press Ctrl+C in the terminal running the Dash server to stop it.
- Deactivate the virtual environment by running:
deactivate
When you are ready to return, navigate to your tls-dashboard/ directory and reactivate the virtual environment.
macOS
- Reactivate the virtual environment by running:
source .venv/bin/activate
Windows
- Reactivate the virtual environment by running:
.venv\Scripts\activate
Delete - I don't want to use this again
Remove all project files, the virtual environment, and generated data to start fresh and free up disk space.
- Press Ctrl+C in the terminal running the Dash server to stop it, if it is still running.
- Deactivate the virtual environment by running:
deactivate
- Delete the entire project directory by running the command for your platform:
macOS
- Remove the project directory by running:
rm -rf ~/Desktop/tls-dashboard
Windows
- Remove the project directory by running:
rmdir /s /q %USERPROFILE%\Desktop\tls-dashboard
- If you installed MSYS2 for this project on Windows and no longer need it, open Settings, select Apps, then Installed apps. Search for MSYS2 and click Uninstall.
Nice Work!
Nice Work!
Nice work! You built a production-grade interactive security dashboard that ingests TLS session analysis data, surfaces findings through interactive visualizations and drill-down panels, and exports forensic reports in three formats.
You've learned how to:
- Built an interactive Dash dashboard with a filterable, sortable DataTable and color-coded risk labels, plus Plotly charts showing cipher suite distribution, TLS version breakdown, and risk score distribution alongside an aggregate security posture score.
- Wired up Dash callbacks for interactive session drill-down that updates certificate chain, connection details, and risk assessment panels when you click a row in the session table.
- Built a multi-format report exporter producing JSON, styled HTML (via Jinja2 templates), and print-ready PDF forensic reports with embedded chart images rendered via Kaleido and WeasyPrint.
- Secret Mission: Added a risk trend timeline that groups sessions by configurable time windows and plots how the aggregate posture score changes over time.
Ready to quiz yourself?