Add misc scripts to repoitory
This commit is contained in:
@@ -0,0 +1,306 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user