mirror of
https://github.com/hanzoai/datastore.git
synced 2026-09-26 13:55:07 +00:00
569 lines
21 KiB
HTML
569 lines
21 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<title>Latency & Throughput Comparison</title>
|
|
<script src="https://d3js.org/d3.v7.min.js"></script>
|
|
<!-- Tippy.js -->
|
|
<link rel="stylesheet" href="https://unpkg.com/tippy.js@6/dist/tippy.css">
|
|
<script src="https://unpkg.com/@popperjs/core@2"></script>
|
|
<script src="https://unpkg.com/tippy.js@6"></script>
|
|
<style>
|
|
:root {
|
|
--bg: #000000;
|
|
--text: #FFFFFF;
|
|
|
|
--brand-yellow: #EFFB5E;
|
|
--accent-cyan: #71BFED;
|
|
--accent-magenta: #E373EC;
|
|
--accent-lime: #70E350;
|
|
--accent-orange: #DB7B40;
|
|
|
|
--grid-light: #DADAD9;
|
|
--grid-mid: #9FA19C;
|
|
}
|
|
|
|
body {
|
|
margin: 0;
|
|
font-family: system-ui, sans-serif;
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
}
|
|
|
|
header {
|
|
display: flex;
|
|
gap: 1rem;
|
|
align-items: center;
|
|
padding: 10px 14px;
|
|
flex-wrap: wrap;
|
|
position: relative;
|
|
padding-right: 260px; /* reserve space so legend doesn't overlap controls */
|
|
}
|
|
|
|
select {
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
border: 1px solid var(--grid-mid);
|
|
padding: 4px 6px;
|
|
}
|
|
|
|
.legend {
|
|
position: fixed;
|
|
top: 8px;
|
|
right: 14px;
|
|
z-index: 10;
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 6px;
|
|
padding: 4px 8px;
|
|
background: rgba(0, 0, 0, 0.55);
|
|
border: 1px solid var(--grid-mid);
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.legend-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
font-size: 12px;
|
|
}
|
|
|
|
.legend-swatch {
|
|
width: 16px;
|
|
height: 14px;
|
|
border: 1px solid var(--grid-mid);
|
|
}
|
|
|
|
.panes {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.pane {
|
|
flex: 1 1 620px;
|
|
min-width: 480px;
|
|
border: none;
|
|
}
|
|
|
|
.pane-header {
|
|
padding: 6px 10px;
|
|
font-weight: 600;
|
|
font-size: 14px;
|
|
background: rgba(255, 255, 255, 0.04);
|
|
display: flex;
|
|
justify-content: center;
|
|
position: relative;
|
|
}
|
|
|
|
.pane-header .status {
|
|
position: absolute;
|
|
right: 10px;
|
|
top: 6px;
|
|
}
|
|
|
|
.charts {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 12px;
|
|
padding: 6px 10px 24px;
|
|
}
|
|
|
|
svg {
|
|
width: 100%;
|
|
overflow: visible;
|
|
font-size: 11px;
|
|
}
|
|
|
|
.axis path,
|
|
.axis line {
|
|
stroke: var(--grid-mid);
|
|
}
|
|
|
|
.axis text {
|
|
fill: var(--text);
|
|
}
|
|
|
|
.bar-seg {
|
|
stroke: none;
|
|
}
|
|
|
|
.qps-dot {
|
|
fill: var(--brand-yellow);
|
|
}
|
|
|
|
.qps-line {
|
|
stroke: var(--brand-yellow);
|
|
}
|
|
|
|
.status {
|
|
font-size: 11px;
|
|
color: var(--grid-mid);
|
|
margin-left: 8px;
|
|
}
|
|
|
|
.grid-line {
|
|
stroke: var(--grid-light);
|
|
stroke-width: 1;
|
|
opacity: .25;
|
|
}
|
|
|
|
/* Tippy theme */
|
|
.tippy-box[data-theme~='ch-dark'] {
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
border: 1px solid var(--grid-mid);
|
|
font-size: 12px;
|
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.6);
|
|
}
|
|
|
|
.tippy-box[data-theme~='ch-dark'] .tippy-content {
|
|
padding: 6px 8px;
|
|
line-height: 1.25;
|
|
}
|
|
</style>
|
|
</head>
|
|
|
|
<body>
|
|
<header>
|
|
<label for="querySelect">Query:</label>
|
|
<select id="querySelect"></select>
|
|
<span id="info"></span>
|
|
</header>
|
|
<div id="legend" class="legend"></div>
|
|
<div class="panes">
|
|
<div class="pane" id="pane-left">
|
|
<div class="pane-header">
|
|
<select id="left-dataset"></select>
|
|
<span class="status" id="left-status"></span>
|
|
</div>
|
|
<div class="charts">
|
|
<svg id="left-latency"></svg>
|
|
</div>
|
|
</div>
|
|
<div class="pane" id="pane-right">
|
|
<div class="pane-header">
|
|
<select id="right-dataset"></select>
|
|
<span class="status" id="right-status"></span>
|
|
</div>
|
|
<div class="charts">
|
|
<svg id="right-latency"></svg>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<script>
|
|
// (() => {
|
|
'use strict';
|
|
// ---------------------- Config ----------------------
|
|
const DATASET_FILES = [
|
|
"no-concurrency-control.json",
|
|
"concurrency-control.json"
|
|
];
|
|
const REQUIRED_POINTS = 32; // minimum required data rows (after filtering invalid rows)
|
|
const PCTS = ["0", "10", "20", "30", "40", "50", "60", "70", "80", "90", "95", "99", "99.9", "99.99"];
|
|
const DRAW_PCTS = PCTS.filter(p => parseFloat(p) <= 99 && parseFloat(p) != 95);
|
|
|
|
// New: derived constants and formatters
|
|
const TOP_PCT = DRAW_PCTS[DRAW_PCTS.length - 1];
|
|
const fmtQps = d3.format(",.1f");
|
|
const fmtMs = d3.format(".3f");
|
|
const fmtMsAxis = d3.format(",.0f"); // axis-friendly ms
|
|
|
|
// Build colorScale using CSS variables (single source of color truth)
|
|
const css = getComputedStyle(document.documentElement);
|
|
const colorScale = d3.scaleLinear()
|
|
.domain([0, 0.25, 0.5, 0.75, 1])
|
|
.range([
|
|
css.getPropertyValue('--bg').trim(),
|
|
css.getPropertyValue('--accent-lime').trim(),
|
|
css.getPropertyValue('--brand-yellow').trim(),
|
|
css.getPropertyValue('--accent-orange').trim(),
|
|
css.getPropertyValue('--accent-magenta').trim()
|
|
]);
|
|
|
|
const margin = { top: 30, right: 70, bottom: 44, left: 70 };
|
|
const latencyHeight = 340; // unified chart height
|
|
|
|
// ---------------------- State -----------------------
|
|
let datasetData = {};
|
|
let leftDatasetId = null;
|
|
let rightDatasetId = null;
|
|
let currentKey = null;
|
|
|
|
// ---------------------- Legend ----------------------
|
|
function buildLegend() {
|
|
const lg = d3.select("#legend").html("");
|
|
// Skip P0 in the legend (we don't render a P0 segment)
|
|
DRAW_PCTS.filter(p => p !== "0").forEach(p => {
|
|
lg.append("div").attr("class", "legend-item")
|
|
.html(`<div class="legend-swatch" style="background:${colorScale(parseFloat(p) / 100)}"></div>P${p}`);
|
|
});
|
|
}
|
|
|
|
// ---------------------- Data Prep -------------------
|
|
function prepareRows(rows) {
|
|
if (!rows) return [];
|
|
return rows
|
|
.filter(r => r[0] > 0)
|
|
.sort((a, b) => d3.ascending(a[0], b[0]))
|
|
.map(r => {
|
|
const conc = r[0];
|
|
const qps = r[1];
|
|
const percentiles = {};
|
|
PCTS.forEach((p, i) => {
|
|
let v = r[2 + i];
|
|
// convert seconds -> milliseconds
|
|
if (v != null) v = v * 1000;
|
|
percentiles[p] = v;
|
|
});
|
|
return { conc, qps, percentiles };
|
|
});
|
|
}
|
|
function getDataForKey(raw, key) { return prepareRows(raw[key]); }
|
|
|
|
// Helper: strip ".log" suffix from keys
|
|
function normalizeKeys(obj) {
|
|
const out = {};
|
|
Object.keys(obj || {}).forEach(k => {
|
|
const nk = k.replace(/\.log$/, '');
|
|
out[nk] = obj[k];
|
|
});
|
|
return out;
|
|
}
|
|
|
|
// ---------------------- Rendering -------------------
|
|
function renderAll() {
|
|
if (!leftDatasetId || !rightDatasetId) return;
|
|
const leftDataRaw = datasetData[leftDatasetId]?.raw || {};
|
|
const rightDataRaw = datasetData[rightDatasetId]?.raw || {};
|
|
if (!currentKey) return;
|
|
|
|
const leftData = getDataForKey(leftDataRaw, currentKey);
|
|
const rightData = getDataForKey(rightDataRaw, currentKey);
|
|
|
|
const latencyMax = d3.max([
|
|
leftData.length ? d3.max(leftData, d => d.percentiles[TOP_PCT]) : 0,
|
|
rightData.length ? d3.max(rightData, d => d.percentiles[TOP_PCT]) : 0
|
|
]) || 1;
|
|
const qpsMax = d3.max([
|
|
leftData.length ? d3.max(leftData, d => d.qps) : 0,
|
|
rightData.length ? d3.max(rightData, d => d.qps) : 0
|
|
]) || 1;
|
|
|
|
const leftStatus = datasetData[leftDatasetId]?.status || "ok";
|
|
const rightStatus = datasetData[rightDatasetId]?.status || "ok";
|
|
|
|
renderPane({
|
|
key: currentKey, raw: leftDataRaw,
|
|
svg: d3.select("#left-latency"),
|
|
statusSel: d3.select("#left-status"),
|
|
latencyMax, qpsMax, statusStr: leftStatus
|
|
});
|
|
renderPane({
|
|
key: currentKey, raw: rightDataRaw,
|
|
svg: d3.select("#right-latency"),
|
|
statusSel: d3.select("#right-status"),
|
|
latencyMax, qpsMax, statusStr: rightStatus
|
|
});
|
|
}
|
|
|
|
function renderPane({ key, raw, svg, statusSel, latencyMax, qpsMax, statusStr }) {
|
|
const data = getDataForKey(raw, key);
|
|
if (!data.length) {
|
|
svg.selectAll("*").remove();
|
|
statusSel.text(statusStr === "error" ? "Load failed" : "No data");
|
|
return;
|
|
}
|
|
statusSel.text(statusStr === "error" ? "Load failed" : "");
|
|
|
|
const x = d3.scaleBand()
|
|
.domain(data.map(d => d.conc))
|
|
.paddingInner(0.2)
|
|
.paddingOuter(0.05);
|
|
|
|
function resize() {
|
|
const width = svg.node().clientWidth;
|
|
x.range([margin.left, width - margin.right]);
|
|
drawCombined(svg, data, x, width, key, latencyMax, qpsMax);
|
|
}
|
|
|
|
// Removed per-pane resize listener; rely on global renderAll
|
|
resize();
|
|
}
|
|
|
|
function drawCombined(svg, data, x, width, key, latencyMax, qpsMax) {
|
|
svg
|
|
.attr("height", latencyHeight + margin.top + margin.bottom)
|
|
.selectAll("*").remove();
|
|
|
|
// Scales
|
|
const yLat = d3.scaleLinear()
|
|
.domain([0, latencyMax]).nice()
|
|
.range([latencyHeight + margin.top, margin.top]);
|
|
|
|
const yQps = d3.scaleLinear()
|
|
.domain([0, qpsMax]).nice()
|
|
.range([latencyHeight + margin.top, margin.top]);
|
|
|
|
// Grid lines (QPS axis)
|
|
svg.append("g")
|
|
.selectAll("line")
|
|
.data(yQps.ticks(10))
|
|
.join("line")
|
|
.attr("class", "grid-line")
|
|
.attr("x1", margin.left)
|
|
.attr("x2", width - margin.right)
|
|
.attr("y1", d => yQps(d))
|
|
.attr("y2", d => yQps(d));
|
|
|
|
// X axis
|
|
svg.append("g")
|
|
.attr("class", "axis")
|
|
.attr("transform", `translate(0,${latencyHeight + margin.top})`)
|
|
.call(d3.axisBottom(x).tickSizeOuter(0));
|
|
|
|
// Left axis: QPS
|
|
svg.append("g")
|
|
.attr("class", "axis")
|
|
.attr("transform", `translate(${margin.left},0)`)
|
|
.call(d3.axisLeft(yQps).ticks(10).tickFormat(fmtQps));
|
|
|
|
// Right axis: Latency (ms)
|
|
svg.append("g")
|
|
.attr("class", "axis")
|
|
.attr("transform", `translate(${width - margin.right},0)`)
|
|
.call(d3.axisRight(yLat).ticks(10).tickFormat(d => fmtMsAxis(d)));
|
|
|
|
// Title
|
|
svg.append("text")
|
|
.attr("x", width / 2)
|
|
.attr("y", 18)
|
|
.attr("fill", "var(--text)")
|
|
.attr("font-weight", "bold")
|
|
.attr("text-anchor", "middle")
|
|
.text(`${key} performance`);
|
|
|
|
// Axis labels
|
|
svg.append("text")
|
|
.attr("x", margin.left - 50)
|
|
.attr("y", margin.top - 18)
|
|
.attr("fill", "var(--grid-mid)")
|
|
.attr("font-size", "10px")
|
|
.text("Throughput, QPS");
|
|
|
|
svg.append("text")
|
|
.attr("x", width - margin.right + 40)
|
|
.attr("y", margin.top - 18)
|
|
.attr("fill", "var(--grid-mid)")
|
|
.attr("font-size", "10px")
|
|
.attr("text-anchor", "end")
|
|
.text("Latency, ms");
|
|
|
|
// X-axis label (moved down)
|
|
svg.append("text")
|
|
.attr("x", width / 2)
|
|
.attr("y", latencyHeight + margin.top + 36)
|
|
.attr("fill", "var(--grid-mid)")
|
|
.attr("font-size", "10px")
|
|
.attr("text-anchor", "middle")
|
|
.text("Concurrent queries");
|
|
|
|
// Latency stacked percentile bars (ms)
|
|
const barWidth = x.bandwidth();
|
|
const g = svg.append("g");
|
|
data.forEach(d => {
|
|
const base = d.percentiles[DRAW_PCTS[0]];
|
|
if (base == null) return; // guard missing base percentile
|
|
|
|
let prev = base;
|
|
DRAW_PCTS.slice(1).forEach(p => {
|
|
const val = d.percentiles[p];
|
|
if (val == null || val < prev) return;
|
|
const h = yLat(prev) - yLat(val);
|
|
if (h <= 0) { prev = val; return; }
|
|
g.append("rect")
|
|
.attr("class", "bar-seg")
|
|
.attr("x", x(d.conc))
|
|
.attr("width", barWidth)
|
|
.attr("y", yLat(val))
|
|
.attr("height", h)
|
|
.attr("fill", colorScale(parseFloat(p) / 100))
|
|
.attr("data-tippy-content",
|
|
`Concurrency: ${d.conc}<br>P${p}: ${fmtMs(val)} ms`
|
|
);
|
|
prev = val;
|
|
});
|
|
});
|
|
|
|
// QPS line + dots
|
|
const bw = x.bandwidth();
|
|
const line = d3.line()
|
|
.x(d => x(d.conc) + bw / 2)
|
|
.y(d => yQps(d.qps))
|
|
.curve(d3.curveMonotoneX);
|
|
|
|
svg.append("path")
|
|
.attr("fill", "none")
|
|
.attr("class", "qps-line")
|
|
.attr("stroke-width", 2)
|
|
.attr("d", line(data));
|
|
|
|
svg.selectAll(".qps-dot")
|
|
.data(data)
|
|
.join("circle")
|
|
.attr("class", "qps-dot")
|
|
.attr("cx", d => x(d.conc) + bw / 2)
|
|
.attr("cy", d => yQps(d.qps))
|
|
.attr("r", 3)
|
|
.attr("data-tippy-content", d =>
|
|
`Concurrency: ${d.conc}<br>QPS: ${fmtQps(d.qps)}`
|
|
);
|
|
|
|
// Initialize a single delegated Tippy per SVG (avoids per-element instances)
|
|
if (window.tippy && !svg.node().__tippyDelegated) {
|
|
tippy.delegate(svg.node(), {
|
|
target: '[data-tippy-content]',
|
|
allowHTML: true,
|
|
theme: 'ch-dark',
|
|
animation: 'none',
|
|
appendTo: document.body,
|
|
maxWidth: 320,
|
|
delay: [0, 0],
|
|
duration: [0, 0]
|
|
});
|
|
svg.node().__tippyDelegated = true;
|
|
}
|
|
}
|
|
|
|
// ---------------------- Dataset / Query Selectors ---------------
|
|
function datasetIdFromPath(p) {
|
|
const parts = p.split("/").filter(Boolean);
|
|
return parts.length >= 2 ? parts[parts.length - 2] : p.replace(".json", "");
|
|
}
|
|
function buildDatasetSelects() {
|
|
const ids = Object.keys(datasetData);
|
|
const leftSel = d3.select("#left-dataset").html("");
|
|
const rightSel = d3.select("#right-dataset").html("");
|
|
ids.forEach(id => {
|
|
leftSel.append("option").attr("value", id).text(id);
|
|
rightSel.append("option").attr("value", id).text(id);
|
|
});
|
|
if (!leftDatasetId) {
|
|
// Prefer specific defaults if present
|
|
leftDatasetId = ids.find(i => i === "no-control") || ids[0];
|
|
rightDatasetId = ids.find(i => i === "concurrency-control-16") || ids[1] || ids[0];
|
|
}
|
|
leftSel.property("value", leftDatasetId);
|
|
rightSel.property("value", rightDatasetId);
|
|
leftSel.on("change", e => { leftDatasetId = e.target.value; updateQueries(); });
|
|
rightSel.on("change", e => { rightDatasetId = e.target.value; updateQueries(); });
|
|
}
|
|
function updateQueries() {
|
|
const leftRaw = datasetData[leftDatasetId]?.raw || {};
|
|
const rightRaw = datasetData[rightDatasetId]?.raw || {};
|
|
const leftKeys = Object.keys(leftRaw);
|
|
const rightKeys = Object.keys(rightRaw);
|
|
let keys = leftKeys.filter(k => rightKeys.includes(k));
|
|
if (!keys.length) keys = Array.from(new Set([...leftKeys, ...rightKeys]));
|
|
keys.sort((a, b) => {
|
|
const na = parseInt(a.replace(/[^\d]/g, ''), 10);
|
|
const nb = parseInt(b.replace(/[^\d]/g, ''), 10);
|
|
return (isNaN(na) || isNaN(nb)) ? d3.ascending(a, b) : d3.ascending(na, nb);
|
|
});
|
|
populateSelect(keys);
|
|
}
|
|
function populateSelect(keys) {
|
|
const sel = d3.select("#querySelect").html("");
|
|
keys.forEach(k => sel.append("option").attr("value", k).text(k));
|
|
sel.on("change", e => { currentKey = e.target.value; renderAll(); });
|
|
if (!keys.length) {
|
|
currentKey = null;
|
|
renderAll();
|
|
return;
|
|
}
|
|
if (!currentKey || !keys.includes(currentKey)) currentKey = keys[0];
|
|
sel.property("value", currentKey);
|
|
renderAll();
|
|
}
|
|
|
|
// ---------------------- Init ------------------------
|
|
function init() {
|
|
buildLegend();
|
|
Promise.allSettled(
|
|
DATASET_FILES.map(url => fetch(url).then(r => r.json()))
|
|
).then(results => {
|
|
results.forEach((res, idx) => {
|
|
const url = DATASET_FILES[idx];
|
|
const id = datasetIdFromPath(url);
|
|
if (res.status === "fulfilled") {
|
|
let rawAll = normalizeKeys(res.value);
|
|
// Filter: drop keys with fewer than REQUIRED_POINTS valid rows.
|
|
const filtered = {};
|
|
Object.keys(rawAll).forEach(k => {
|
|
const rows = Array.isArray(rawAll[k]) ? rawAll[k] : [];
|
|
// count only rows we would later keep (r[0] > 0)
|
|
const validCount = rows.filter(r => r && r[0] > 0).length;
|
|
if (validCount >= REQUIRED_POINTS) filtered[k] = rawAll[k];
|
|
});
|
|
// Fallback: if everything filtered out, keep original so UI not empty.
|
|
const raw = Object.keys(filtered).length ? filtered : rawAll;
|
|
datasetData[id] = { url, raw, status: "ok" };
|
|
} else {
|
|
datasetData[id] = { url, raw: {}, status: "error" };
|
|
}
|
|
});
|
|
buildDatasetSelects();
|
|
updateQueries();
|
|
window.addEventListener("resize", renderAll);
|
|
// Status text now handled dynamically in renderPane
|
|
}).catch(err => {
|
|
d3.select("#info").text("Init error: " + err);
|
|
});
|
|
}
|
|
init();
|
|
// })(); // end IIFE
|
|
</script>
|
|
</body>
|
|
|
|
</html>
|