mirror of
https://github.com/hanzoai/datastore.git
synced 2026-09-26 13:55:07 +00:00
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]>
727 lines
23 KiB
HTML
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>
|