307 lines
8.6 KiB
Python
307 lines
8.6 KiB
Python
#!/usr/bin/env python3
|
|
"""Export pgbench summary.csv into an HTML report with TPS and latency charts."""
|
|
|
|
import argparse
|
|
import csv
|
|
import json
|
|
from pathlib import Path
|
|
from typing import Dict, List, Optional
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(description="Create an HTML chart report from pgbench summary.csv")
|
|
parser.add_argument("summary_csv", help="Path to summary.csv generated by pgbench benchmark script")
|
|
parser.add_argument(
|
|
"-o",
|
|
"--output",
|
|
default=None,
|
|
help="Output HTML path (default: <summary_dir>/pgbench_report.html)",
|
|
)
|
|
return parser.parse_args()
|
|
|
|
|
|
def load_rows(path: Path) -> List[Dict[str, str]]:
|
|
with path.open("r", encoding="utf-8-sig", newline="") as f:
|
|
reader = csv.DictReader(f)
|
|
expected = {"mode", "clients", "tps", "latency_ms"}
|
|
if not expected.issubset(set(reader.fieldnames or [])):
|
|
missing = sorted(expected - set(reader.fieldnames or []))
|
|
raise ValueError(f"summary.csv missing required columns: {', '.join(missing)}")
|
|
return list(reader)
|
|
|
|
|
|
def parse_optional_float(raw: str) -> Optional[float]:
|
|
value = (raw or "").strip()
|
|
if not value:
|
|
return None
|
|
try:
|
|
return float(value)
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
def build_series(rows: List[Dict[str, str]]) -> Dict[str, List[Dict[str, float]]]:
|
|
series: Dict[str, List[Dict[str, float]]] = {}
|
|
for row in rows:
|
|
mode = row["mode"].strip()
|
|
try:
|
|
clients = int(row["clients"])
|
|
tps = float(row["tps"])
|
|
latency = float(row["latency_ms"])
|
|
except ValueError:
|
|
continue
|
|
|
|
point: Dict[str, float] = {
|
|
"clients": clients,
|
|
"tps": tps,
|
|
"latency_ms": latency,
|
|
}
|
|
|
|
p95 = parse_optional_float(row.get("p95_ms", ""))
|
|
p99 = parse_optional_float(row.get("p99_ms", ""))
|
|
if p95 is not None:
|
|
point["p95_ms"] = p95
|
|
if p99 is not None:
|
|
point["p99_ms"] = p99
|
|
|
|
series.setdefault(mode, []).append(point)
|
|
|
|
for mode in series:
|
|
series[mode].sort(key=lambda item: item["clients"])
|
|
|
|
return series
|
|
|
|
|
|
def html_report(title: str, data: Dict[str, List[Dict[str, float]]]) -> str:
|
|
payload = json.dumps(data)
|
|
return f"""<!doctype html>
|
|
<html lang=\"en\">
|
|
<head>
|
|
<meta charset=\"utf-8\" />
|
|
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />
|
|
<title>{title}</title>
|
|
<style>
|
|
:root {{
|
|
--bg: #f7f7f2;
|
|
--fg: #182020;
|
|
--panel: #ffffff;
|
|
--muted: #4f5b5b;
|
|
--grid: #dde4e4;
|
|
}}
|
|
* {{ box-sizing: border-box; }}
|
|
body {{
|
|
margin: 0;
|
|
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
|
|
background: radial-gradient(circle at 8% 15%, #e7f6f2, var(--bg));
|
|
color: var(--fg);
|
|
}}
|
|
.container {{
|
|
max-width: 1100px;
|
|
margin: 0 auto;
|
|
padding: 24px;
|
|
}}
|
|
.card {{
|
|
background: var(--panel);
|
|
border: 1px solid #e2ebeb;
|
|
border-radius: 14px;
|
|
padding: 16px;
|
|
margin-bottom: 18px;
|
|
box-shadow: 0 10px 26px rgba(20, 30, 30, 0.06);
|
|
}}
|
|
h1 {{ margin: 0 0 8px 0; font-size: 1.6rem; }}
|
|
p {{ margin: 0; color: var(--muted); }}
|
|
canvas {{ width: 100%; height: 360px; display: block; }}
|
|
.legend {{ display: flex; gap: 12px; flex-wrap: wrap; margin-top: 10px; }}
|
|
.legend-item {{ display: inline-flex; align-items: center; gap: 8px; font-size: 0.9rem; color: var(--muted); }}
|
|
.swatch {{ width: 14px; height: 14px; border-radius: 3px; display: inline-block; }}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class=\"container\">
|
|
<div class=\"card\">
|
|
<h1>{title}</h1>
|
|
<p>Generated from summary.csv. X-axis = clients. Lines are grouped by mode.</p>
|
|
</div>
|
|
|
|
<div class=\"card\">
|
|
<h2>TPS by Clients</h2>
|
|
<canvas id=\"tpsChart\" width=\"1040\" height=\"360\"></canvas>
|
|
<div id=\"legend\" class=\"legend\"></div>
|
|
</div>
|
|
|
|
<div class=\"card\">
|
|
<h2>Latency (ms) by Clients</h2>
|
|
<canvas id=\"latencyChart\" width=\"1040\" height=\"360\"></canvas>
|
|
</div>
|
|
|
|
<div class=\"card\">
|
|
<h2>p95 Latency (ms) by Clients</h2>
|
|
<canvas id=\"p95Chart\" width=\"1040\" height=\"360\"></canvas>
|
|
</div>
|
|
|
|
<div class=\"card\">
|
|
<h2>p99 Latency (ms) by Clients</h2>
|
|
<canvas id=\"p99Chart\" width=\"1040\" height=\"360\"></canvas>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
const series = {payload};
|
|
const palette = ["#0b6e4f", "#f26419", "#33658a", "#2f4858", "#5f0f40", "#9a031e", "#386641", "#6a4c93"];
|
|
|
|
function flattenValues(metric) {{
|
|
const out = [];
|
|
Object.values(series).forEach(items => items.forEach(i => {{
|
|
const v = i[metric];
|
|
if (typeof v === 'number' && Number.isFinite(v)) out.push(v);
|
|
}}));
|
|
return out;
|
|
}}
|
|
|
|
function flattenClients() {{
|
|
const out = [];
|
|
Object.values(series).forEach(items => items.forEach(i => out.push(i.clients)));
|
|
return out;
|
|
}}
|
|
|
|
function drawChart(canvasId, metric, yLabel) {{
|
|
const c = document.getElementById(canvasId);
|
|
const ctx = c.getContext('2d');
|
|
const W = c.width;
|
|
const H = c.height;
|
|
const m = {{ top: 22, right: 20, bottom: 44, left: 60 }};
|
|
const x0 = m.left, y0 = H - m.bottom, x1 = W - m.right, y1 = m.top;
|
|
|
|
ctx.clearRect(0, 0, W, H);
|
|
ctx.fillStyle = '#182020';
|
|
ctx.font = '13px Segoe UI, sans-serif';
|
|
ctx.fillText(yLabel, 8, 16);
|
|
|
|
const clientsAll = flattenClients();
|
|
const valuesAll = flattenValues(metric);
|
|
if (!clientsAll.length || !valuesAll.length) {{
|
|
ctx.fillText('No data in summary.csv for this metric', x0 + 10, y0 - 10);
|
|
return;
|
|
}}
|
|
|
|
const minX = Math.min(...clientsAll);
|
|
const maxX = Math.max(...clientsAll);
|
|
const minY = 0;
|
|
const maxY = Math.max(...valuesAll) * 1.1;
|
|
|
|
const x = v => x0 + ((v - minX) / Math.max(1, maxX - minX)) * (x1 - x0);
|
|
const y = v => y0 - ((v - minY) / Math.max(1e-9, maxY - minY)) * (y0 - y1);
|
|
|
|
ctx.strokeStyle = '#dde4e4';
|
|
ctx.lineWidth = 1;
|
|
for (let i = 0; i <= 5; i++) {{
|
|
const yy = y0 - ((y0 - y1) * i / 5);
|
|
ctx.beginPath();
|
|
ctx.moveTo(x0, yy);
|
|
ctx.lineTo(x1, yy);
|
|
ctx.stroke();
|
|
const val = (maxY * i / 5).toFixed(1);
|
|
ctx.fillStyle = '#4f5b5b';
|
|
ctx.fillText(val, 16, yy + 4);
|
|
}}
|
|
|
|
const xTicks = [...new Set(clientsAll)].sort((a, b) => a - b);
|
|
xTicks.forEach(v => {{
|
|
const xx = x(v);
|
|
ctx.beginPath();
|
|
ctx.moveTo(xx, y0);
|
|
ctx.lineTo(xx, y0 + 5);
|
|
ctx.strokeStyle = '#9da9a9';
|
|
ctx.stroke();
|
|
ctx.fillStyle = '#4f5b5b';
|
|
ctx.fillText(String(v), xx - 6, y0 + 18);
|
|
}});
|
|
|
|
ctx.strokeStyle = '#182020';
|
|
ctx.beginPath();
|
|
ctx.moveTo(x0, y0);
|
|
ctx.lineTo(x1, y0);
|
|
ctx.stroke();
|
|
ctx.beginPath();
|
|
ctx.moveTo(x0, y0);
|
|
ctx.lineTo(x0, y1);
|
|
ctx.stroke();
|
|
|
|
const legend = document.getElementById('legend');
|
|
if (metric === 'tps') legend.innerHTML = '';
|
|
|
|
Object.entries(series).forEach(([mode, items], idx) => {{
|
|
const color = palette[idx % palette.length];
|
|
const sorted = [...items]
|
|
.filter(p => typeof p[metric] === 'number' && Number.isFinite(p[metric]))
|
|
.sort((a, b) => a.clients - b.clients);
|
|
|
|
if (!sorted.length) return;
|
|
|
|
ctx.strokeStyle = color;
|
|
ctx.lineWidth = 2;
|
|
ctx.beginPath();
|
|
sorted.forEach((p, i) => {{
|
|
const px = x(p.clients);
|
|
const py = y(p[metric]);
|
|
if (i === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py);
|
|
}});
|
|
ctx.stroke();
|
|
|
|
sorted.forEach(p => {{
|
|
const px = x(p.clients);
|
|
const py = y(p[metric]);
|
|
ctx.fillStyle = color;
|
|
ctx.beginPath();
|
|
ctx.arc(px, py, 3, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
}});
|
|
|
|
if (metric === 'tps') {{
|
|
const item = document.createElement('div');
|
|
item.className = 'legend-item';
|
|
item.innerHTML = `<span class=\"swatch\" style=\"background:${{color}}\"></span>${{mode}}`;
|
|
legend.appendChild(item);
|
|
}}
|
|
}});
|
|
}}
|
|
|
|
drawChart('tpsChart', 'tps', 'TPS');
|
|
drawChart('latencyChart', 'latency_ms', 'Latency (ms)');
|
|
drawChart('p95Chart', 'p95_ms', 'p95 (ms)');
|
|
drawChart('p99Chart', 'p99_ms', 'p99 (ms)');
|
|
</script>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
|
|
def main() -> int:
|
|
args = parse_args()
|
|
summary_path = Path(args.summary_csv).resolve()
|
|
if not summary_path.is_file():
|
|
raise FileNotFoundError(f"summary.csv not found: {summary_path}")
|
|
|
|
rows = load_rows(summary_path)
|
|
if not rows:
|
|
raise ValueError("summary.csv has no data rows")
|
|
|
|
series = build_series(rows)
|
|
if not series:
|
|
raise ValueError("No valid numeric rows found in summary.csv")
|
|
|
|
output_path = (
|
|
Path(args.output).resolve()
|
|
if args.output
|
|
else summary_path.parent / "pgbench_report.html"
|
|
)
|
|
|
|
title = f"pgbench report: {summary_path.parent.name}"
|
|
output_path.write_text(html_report(title=title, data=series), encoding="utf-8")
|
|
print(f"Report written: {output_path}")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|