Files
zeekayandHanzo Dev e53ccdf6d5 the pages call the server this fork actually is
Three of these were not branding at all — they were live breakage, because the
server was renamed and its consumers were not:

- dashboard.html parsed `ClickHouseAsyncMetrics_` while PrometheusMetricsWriter
  emits `DatastoreAsyncMetrics_`. Paste the Prometheus output into the metrics
  editor and it matched nothing.
- trace-visualizer queried `attribute['clickhouse.thread_id']` while
  OpenTelemetryTraceContext records `datastore.thread_id`. The trace tab found
  no spans.
- both visualizers loaded `js/query-clickhouse.js`; the file on disk is
  `query-datastore.js`, so the script 404'd and the query function never
  existed.

The rest is the name: titles, navbar brands, the status line a user reads after
connecting, links into upstream's repo and docs site, their Cloud badges, and
tests-visualizer pulling a logo from their CDN and querying their public demo
server. Local symbols and DOM ids went with them (queryDatastore,
parseDatastoreTrace, #tabDatastore).

LICENSE, NOTICE and every copyright notice are untouched — no removed line in
this diff carries a ©, an SPDX id or an attribution.

`clickhouse_lexer_*` in docs.html STAYS, and a comment at the embed site says
why: those export names are baked into the base64 WebAssembly payload, so the
JS must match the blob. Renaming means renaming the C API, rebuilding
Lexer.wasm, re-embedding it and updating the calls in one change; rename either
side alone and syntax highlighting fails silently.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-14 10:17:16 -07:00

727 lines
23 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Hanzo Datastore Parts Visualizer</title>
<script src="https://d3js.org/d3.v7.min.js"></script>
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css">
<style>
body {
font-family: Liberation Sans, DejaVu Sans, sans-serif, Noto Color Emoji, Apple Color Emoji, Segoe UI Emoji;
padding: 1rem;
}
input#url, textarea#query {
border: 3px solid #EEE;
font-size: 12pt;
padding: 0.25rem;
}
textarea#query {
height: 82px;
}
input#play {
font-size: 16pt;
padding: 0;
}
#url {
width: 80%;
}
#user, #password {
width: 10%;
}
#query {
width: 100%;
height: 3rem;
}
input.my-button[type="button"] {
background: #FED;
width: 2rem;
height: 2rem;
}
input.my-button[type="button"]:hover {
background: #F88;
cursor: pointer;
}
#canvas {
margin-top: 0.25rem;
font-size: 10pt;
}
.tooltip {
position: absolute;
text-align: left; /* Align text to the left for better readability */
padding: 10px;
font: 12px sans-serif;
background: rgba(0, 0, 0, 0.8);
color: #fff;
border-radius: 4px;
pointer-events: none;
opacity: 0;
transition: opacity 0.3s;
box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.5);
}
.tooltip table {
border-collapse: collapse;
}
.tooltip td {
padding: 4px 8px;
}
.tooltip tr:nth-child(even) {
background-color: rgba(255, 255, 255, 0.1);
}
</style>
</head>
<body>
<div class="inputs">
<form id="params">
<div id="connection-params">
<input spellcheck="false" id="url" type="text" value="http://localhost:8123" placeholder="URL" /><input spellcheck="false" id="user" type="text" value="" placeholder="user" /><input spellcheck="false" id="password" type="password" placeholder="password" value="" />
<input id="hidden-submit" type="submit" hidden="true"/>
</div>
<textarea spellcheck="false" data-gramm="false" id="query" rows="10">SELECT * FROM system.parts WHERE active = 1 AND database != 'system' ORDER BY database, table, partition, min_block_number</textarea>
<input class="my-button" id="play" type="button" value="▶">
<div id="query-error" style="color: red; margin-top: 10px; display: none;"></div>
</form>
</div>
<div id="canvas">
</div>
<script type="module">
function determineTickStep(max_block_number) {
let step;
let unit;
if (max_block_number >= Math.pow(1000, 3)) // For G range
unit = Math.pow(1000, 3); // 1 G
else if (max_block_number >= Math.pow(1000, 2)) // For M range
unit = Math.pow(1000, 2); // 1 M
else if (max_block_number >= 1000) // For K range
unit = 1000; // 1 K
else
unit = 1; // range
// Determine appropriate tick step based on the range (1, 2, 5, 10, 20, 50, 100, 200...)
if (max_block_number / unit > 500)
step = 100 * unit;
else if (max_block_number / unit > 200)
step = 50 * unit;
else if (max_block_number / unit > 100)
step = 20 * unit;
else if (max_block_number / unit > 50)
step = 10 * unit;
else if (max_block_number / unit > 20)
step = 5 * unit;
else if (max_block_number / unit > 10)
step = 2 * unit;
else
step = 1 * unit;
return step;
}
function formatNumber(number) {
if (number >= Math.pow(1000, 4))
return (number / Math.pow(1000, 4)).toFixed(1) + 'T';
else if (number >= Math.pow(1000, 3))
return (number / Math.pow(1000, 3)).toFixed(1) + 'G';
else if (number >= Math.pow(1000, 2))
return (number / Math.pow(1000, 2)).toFixed(1) + 'M';
else if (number >= 1000)
return (number / 1000).toFixed(1) + 'K';
else
return number;
}
function formatBytes(bytes) {
const digits = 0;
if (bytes >= Math.pow(1024, 4))
return (bytes / Math.pow(1024, 4)).toFixed(digits) + 'TB';
else if (bytes >= Math.pow(1024, 3))
return (bytes / Math.pow(1024, 3)).toFixed(digits) + 'GB';
else if (bytes >= Math.pow(1024, 2))
return (bytes / Math.pow(1024, 2)).toFixed(digits) + 'MB';
else if (bytes >= 1024)
return (bytes / 1024).toFixed(digits) + 'KB';
else
return bytes;
}
function getFormatter(selector) {
switch (selector) {
case "level":
case "blocks":
case "rows":
return formatNumber;
case "bytes_on_disk":
case "data_compressed_bytes":
case "data_uncompressed_bytes":
return formatBytes;
default:
return d3.format(",");
}
}
function visualizeMergeTree(title, raw_data) {
let titleContainer = d3.select("body")
.append("div")
.attr("class", "container-fluid bg-primary text-white")
.text(title);
let selectorsContainer = d3.select("body")
.append("div")
.attr("class", "container mt-3")
.append("form")
.attr("class", "d-flex align-items-center")
let container = d3.select("body")
.append("div")
.attr("class", "container-fluid")
.append("div")
// Input visuals
const margin = { left: 50, right: 30, top: 10, bottom: 50 };
const width = container.node().clientWidth - margin.left - margin.right;
const height = 300;
const part_dy = 4;
const part_dx = 4;
// Set initial selector values
let xSelector = "blocks";
let leftSelector = "left_blocks";
let rightSelector = "right_blocks";
let ySelector = "bytes_on_disk";
let colorSelector = "level";
// Compute useful aggregates
function computeXAggregates(data, leftSelector, rightSelector) {
let minXValue = d3.min(data, d => d[leftSelector]);
let maxXValue = d3.max(data, d => d[rightSelector]);
return { minXValue, maxXValue };
}
function computeYAggregates(data, ySelector) {
let minYValue = d3.min(data, d => d[ySelector]);
let maxYValue = d3.max(data, d => d[ySelector]);
return { minYValue, maxYValue };
}
function computeColorAggregates(data, colorSelector) {
let minColorValue = d3.min(data, d => d[colorSelector]);
let maxColorValue = d3.max(data, d => d[colorSelector]);
return { minColorValue, maxColorValue };
}
// Prepare Data
let totals = {
bytes_on_disk: 0,
data_compressed_bytes: 0,
data_uncompressed_bytes: 0,
blocks: 0,
rows: 0,
};
let data = raw_data.map(row => {
row.blocks = +row.max_block_number - +row.min_block_number + 1;
totals.blocks = +row.min_block_number; // In case of skipped blocks
let lefts = {};
let rights = {};
let values = {};
for (const k in totals) {
const value = +row[k];
lefts[`left_${k}`] = totals[k];
totals[k] += value;
values[k] = value;
rights[`right_${k}`] = totals[k];
}
return {
name: row.name,
modification_time: row.modification_time,
part_type: row.part_type,
level: +row.level,
marks: +row.marks,
marks_bytes: +row.marks_bytes,
primary_key_size: +row.primary_key_size,
...lefts,
...rights,
...values,
};
});
let { minXValue, maxXValue } = computeXAggregates(data, leftSelector, rightSelector);
let { minYValue, maxYValue } = computeYAggregates(data, ySelector);
let { minColorValue, maxColorValue } = computeColorAggregates(data, colorSelector);
console.log([minXValue, maxXValue], [minYValue, maxYValue]);
const svgWidth = width + margin.left + margin.right;
const svgHeight = height + margin.top + margin.bottom;
// Set up the horizontal scale (x-axis) — linear scale
let xScale = d3.scaleLinear()
.domain([minXValue, maxXValue])
.range([margin.left, margin.left + width]);
// Set up the vertical scale (y-axis) — logarithmic scale
let yScale = d3.scaleLog()
.base(2)
.domain([Math.max(1, minYValue), Math.pow(2, Math.ceil(Math.log2(maxYValue)))])
.range([svgHeight - margin.bottom, margin.top]);
// Set up color scale
let colorScale = d3.scaleSequential(d3.interpolateViridis)
.domain([minColorValue, maxColorValue]);
// Create the SVG container
const svgContainer = container
.append("svg")
.attr("width", svgWidth)
.attr("height", svgHeight);
// To avoid negative width and height
function pxl(value) { return value; }
function pxr(value) { return Math.max(1, value); }
function pxt(value) { return value; }
function pxb(value) { return Math.max(1, value); }
// Clip path to avoid drawing outside the chart area
svgContainer.append("defs").append("clipPath")
.attr("id", "clip")
.append("rect")
.attr("x", margin.left)
.attr("y", margin.top)
.attr("width", width)
.attr("height", height);
// Create a group for the chart content
const chartGroup = svgContainer.append("g")
.attr("clip-path", "url(#clip)");
const bytesFormatter = bytes => `${d3.format(",")(bytes)} (${formatBytes(bytes)})`;
const tooltipFields = {
"name": d => d.name,
"part_type": d => d.part_type,
"modification_time": d => d.modification_time,
"marks": d => d3.format(",")(d.marks),
"marks_bytes": d => bytesFormatter(d.marks_bytes),
"primary_key_size": d => bytesFormatter(d.primary_key_size),
"bytes_on_disk": d => bytesFormatter(d.bytes_on_disk),
"data_compressed_bytes": d => bytesFormatter(d.data_compressed_bytes),
"data_uncompressed_bytes": d => bytesFormatter(d.data_uncompressed_bytes),
"blocks": d => d3.format(",")(d.blocks),
"rows": d => d3.format(",")(d.rows),
"level": d => d.level,
};
// Append rectangles for parts
const parts = chartGroup.selectAll("rect.part")
.data(data)
.enter()
.append("rect")
.attr("class", "part")
.attr("x", d => pxl(xScale(d[leftSelector])))
.attr("y", d => pxt(yScale(d[ySelector]) - part_dy))
.attr("width", d => pxr(xScale(d[rightSelector]) - xScale(d[leftSelector])))
.attr("height", d => pxb(part_dy))
.attr("fill", d => colorScale(d[colorSelector]))
.on("click", function(event, d) {
// Stop propagation to prevent triggering other click events
event.stopPropagation();
console.log("CLICKED", d);
// Generate table rows
const rows = Object.keys(tooltipFields).map(field => `
<tr><td><strong>${field}</strong></td><td>${tooltipFields[field](d)}</td></tr>
`).join("");
// Set the content of the tooltip
tooltip
.style("left", `${event.pageX + 10}px`)
.style("top", `${event.pageY + 10}px`)
.html(`
<table>${rows}</table>
`)
.transition()
.duration(200)
.style("opacity", 1);
// Get the tooltip's dimensions
const tooltipNode = tooltip.node();
const tooltipRect = tooltipNode.getBoundingClientRect();
const tooltipWidth = tooltipRect.width;
const tooltipHeight = tooltipRect.height;
// Get the viewport dimensions
const pageWidth = window.innerWidth;
const pageHeight = window.innerHeight;
// Define offsets
const offset = 10; // Distance from cursor
// Calculate initial position (right and below the cursor)
let left = event.pageX + offset;
let top = event.pageY + offset;
// Get the mouse position relative to the page
const [mouseX, mouseY] = d3.pointer(event);
// Adjust horizontal position if tooltip overflows to the right
if (mouseX + offset + tooltipWidth > pageWidth) {
left = event.pageX - tooltipWidth - offset;
}
// Adjust vertical position if tooltip overflows to the bottom
if (mouseY + offset + tooltipHeight > pageHeight) {
top = event.pageY - tooltipHeight - offset;
}
// Further adjust if tooltip goes beyond the left edge
if (left < 0) {
left = offset;
}
// Further adjust if tooltip goes beyond the top edge
if (top < 0) {
top = offset;
}
// Position the tooltip
tooltip
.style("left", `${left}px`)
.style("top", `${top}px`)
.style("visibility", "visible") // Make it visible
.transition()
.duration(200)
.style("opacity", 1);
});
// Create selectors for y-axis, x-axis, and color-axis
const yOptions = ["bytes_on_disk", "data_compressed_bytes", "data_uncompressed_bytes", "blocks", "rows"];
const xOptions = ["blocks", "bytes_on_disk", "rows"];
const colorOptions = ["level", "blocks", "rows", "bytes_on_disk", "data_compressed_bytes", "data_uncompressed_bytes"];
// X selector
const xSelectorCol = selectorsContainer
.append("div")
.attr("class", "me-3");
xSelectorCol.append("label")
.attr("class", "form-label small me-2")
.text("X: ");
const xSelectorElement = xSelectorCol.append("select")
.attr("class", "form-select form-select-sm me-2")
.on("change", function() {
xSelector = this.value;
leftSelector = `left_${xSelector}`;
rightSelector = `right_${xSelector}`;
// Rescale axis
const { minXValue, maxXValue } = computeXAggregates(data, leftSelector, rightSelector);
xScale.domain([minXValue, maxXValue]);
// Update the formatter
const xAxisFormatter = getFormatter(xSelector);
// Apply transitions to rectangles
parts.transition().duration(1000)
.attr("x", d => pxl(xScale(d[leftSelector])))
.attr("width", d => pxr(xScale(d[rightSelector]) - xScale(d[leftSelector])))
// Update axes with transitions
xAxisGroup.transition().duration(1000)
.call(d3.axisBottom(xScale).tickFormat(xAxisFormatter));
});
xSelectorElement.selectAll("option")
.data(xOptions)
.enter()
.append("option")
.text(d => d)
.attr("value", d => d);
// Y selector
const ySelectorCol = selectorsContainer
.append("div")
.attr("class", "me-3");
ySelectorCol.append("label")
.attr("class", "form-label small me-2")
.text("Y: ");
const ySelectorElement = ySelectorCol.append("select")
.attr("class", "form-select form-select-sm")
.on("change", function() {
ySelector = this.value;
// Rescale axis
const { minYValue, maxYValue } = computeYAggregates(data, ySelector);
yScale.domain([Math.max(1, minYValue), Math.pow(2, Math.ceil(Math.log2(maxYValue)))]);
// Update the formatter
const yAxisFormatter = getFormatter(ySelector);
// Apply transitions to rectangles
parts.transition().duration(1000)
.attr("y", d => pxt(yScale(d[ySelector]) - part_dy))
.attr("height", d => pxb(part_dy));
// Update axes with transitions
yAxisGroup.transition().duration(1000)
.call(d3.axisLeft(yScale).tickFormat(yAxisFormatter))
});
ySelectorElement.selectAll("option")
.data(yOptions)
.enter()
.append("option")
.text(d => d)
.attr("value", d => d);
// Color selector
const colorSelectorCol = selectorsContainer
.append("div")
.attr("class", "me-3");
colorSelectorCol.append("label")
.attr("class", "form-label small me-2")
.text("Color: ");
const colorSelectorElement = colorSelectorCol.append("select")
.attr("class", "form-select form-select-sm")
.on("change", function() {
colorSelector = this.value;
// Rescale color axis
const { minColorValue, maxColorValue } = computeColorAggregates(data, colorSelector);
colorScale.domain([minColorValue, maxColorValue]);
// Apply transitions to rectangles
parts.transition().duration(0)
.attr("fill", d => colorScale(d[colorSelector]));
});
colorSelectorElement.selectAll("option")
.data(colorOptions)
.enter()
.append("option")
.text(d => d)
.attr("value", d => d);
// Determine the tick step based on maxValue
const tickStep = determineTickStep(maxXValue - minXValue);
// Add the x-axis at the bottom of the SVG
const xAxis = d3.axisBottom(xScale)
.tickValues(d3.range(minXValue, maxXValue, tickStep))
.tickFormat(getFormatter(xSelector));
const xAxisGroup = svgContainer.append("g")
.attr("transform", `translate(0, ${svgHeight - margin.bottom})`)
.call(xAxis);
// Add the y-axis with custom ticks for powers of 2
const powersOfTwo = Array.from({ length: 50 }, (v, i) => Math.pow(2, i + 1));
const yAxis = d3.axisLeft(yScale)
.tickValues(powersOfTwo.filter(d => d >= minYValue && d <= maxYValue))
.tickFormat(getFormatter(ySelector));
const yAxisGroup = svgContainer.append("g")
.attr("transform", `translate(${margin.left}, 0)`)
.call(yAxis);
// Zoom functionality
const zoom = d3.zoom()
.scaleExtent([1, 1000000])
.translateExtent([[margin.left, 0], [svgWidth - margin.right, svgHeight]])
.on("zoom", zoomed);
svgContainer.call(zoom);
function zoomed(event) {
// Update x-scale with the zoom transform
const transform = event.transform;
const newXScale = transform.rescaleX(xScale);
// Update axes and rectangles with the new scale
xAxisGroup.call(xAxis.scale(newXScale));
parts.attr("x", d => pxl(newXScale(d[leftSelector])))
.attr("width", d => pxr(newXScale(d[rightSelector]) - newXScale(d[leftSelector])));
}
}
let add_http_cors_header = (location.protocol != 'file:');
if (!document.getElementById('url').value) {
document.getElementById('url').value = location.protocol != 'file:' ? location.origin : 'http://localhost:8123/';
}
if (!document.getElementById('user').value) {
let user = 'default';
const current_url = new URL(window.location);
/// Substitute user name if it's specified in the query string
const user_from_url = current_url.searchParams.get('user');
if (user_from_url) {
user = user_from_url;
}
document.getElementById('user').value = user;
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
let canvas = document.getElementById('canvas');
let loading = false;
let stopping = false;
let query_controller = null;
async function load() {
canvas.innerHTML = '';
const host = document.getElementById('url').value;
const user = document.getElementById('user').value;
const password = document.getElementById('password').value;
let url = `${host}?default_format=JSONEachRow&enable_http_compression=1`
if (add_http_cors_header) {
// For debug purposes, you may set add_http_cors_header from the browser console
url += '&add_http_cors_header=1';
}
if (user) {
url += `&user=${encodeURIComponent(user)}`;
}
if (password) {
url += `&password=${encodeURIComponent(password)}`;
}
const query = document.getElementById('query').value;
const errorDiv = document.getElementById('query-error');
errorDiv.style.display = 'none';
errorDiv.textContent = '';
let response, reply;
let error = '';
let controller = null;
try {
loading = true;
document.getElementById('play').value = '⏹';
query_controller = new AbortController();
response = await fetch(url, {
method: "POST",
body: query,
signal: query_controller.signal,
headers: { 'Authorization': 'never' }
});
if (!response.ok) {
const reply = await response.text();
console.log(reply);
for (let line of reply.split('\n')) {
if (line.startsWith(`{"exception":`)) {
throw new Error(`HTTP Status: ${response.status}. Error: ${JSON.parse(line).exception}`);
}
}
throw new Error(`HTTP Status: ${response.status}. Error: ${reply.toString()}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (stopping) {
stopped = true;
break;
}
buffer += decoder.decode(value, { stream: true });
let lines = buffer.split('\n');
for (const line of lines.slice(0, -1)) {
if (stopping) {
stopped = true;
break;
}
const data = JSON.parse(line);
await update(data);
};
buffer = lines[lines.length - 1];
}
} catch (e) {
console.log(e);
if (e instanceof TypeError) {
error = "Network error";
} else if (e.name === 'AbortError') {
error = "Query was cancelled";
} else {
error = e.toString();
}
}
loading = false;
stopping = false;
query_controller = null;
document.getElementById('play').value = '▶';
if (error) {
errorDiv.textContent = error;
errorDiv.style.display = 'block';
} else {
await visualize();
}
}
function stop() {
stopping = true;
if (query_controller) {
query_controller.abort();
}
}
document.getElementById('play').addEventListener('click', _ => {
if (loading) {
stop();
} else if (stopping) {
} else {
load();
}
});
let all = {};
async function update(data) {
// console.log(data);
const {database, table, partition} = data;
const key = database + "." + table + ":" + partition;
if (!(key in all))
all[key] = [];
all[key].push(data);
}
async function visualize() {
for (const key in all) {
console.log(key);
visualizeMergeTree(key, all[key]);
}
}
// Create tooltip div and hide it initially
const tooltip = d3.select("body").append("div")
.attr("class", "tooltip");
// Hide tooltip when clicking anywhere else on the page
d3.select("body").on("click", function() {
tooltip.transition()
.duration(200)
.style("opacity", 0);
});
</script>
</body>
</html>