chdb / public /play.html
root
x
5b065c1
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<link rel="icon" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAMAAACdt4HsAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAKdUExURf/dV//TI//NCP/MAv/MAf/MA//PEv/XOP/kfP/TJv/MBP7cVP/XOv/NBv3lgv/PD/3bUf/NBP3XPv3WPfzWPf/MAPzWPv7QFf7PF/7ODf/NAf7MB/7PEf7ODvjTQPXbcv7OEP/SJP/MB/7OD/XYZP7PE//VMv7QF//MCPXXZf7OEv3SKP7PFv7NDvXXYf7NEP3TLf7PFf/NCf3UMv7OFf/NCv7NDfzXPv3UM/7OFP/NC/Xbb//OAf+3APy2TPXabv7ND//KAf/BAf+3Af+YAP+ZAPy3Tv7OEf+4Af+7Afy5Vv/RHf/ODP+cB/zKff+qAPrHef/eXf3VMfTWXf+bBf+ZAfvJfv/EAf+hAP+9Af/QAf+1Af+aA/vJff/IAf+lAP+bAP+uAP+aAPuvPv/dVfzjgv+WAP+aAv/PEfm5W/XXZP65D/yiGv/ib/6eD//bTf/NAvXXXf6eDv+aAf/WNvzPGfTGgP/WNPrRLfTDev/XPP7MBv2gFfTEff/dWP3RI/2gFv+hFf/mhP3SJP/NA/ygFv/SI/+wOfe1U/7lf/6jHf/QGPqrNf+fDv6kHv3bVf2hGf6jG/+ZAv6kH/S9av3VN/6lH/6lIPixRv+lIP6lIviuP/3TL/++Xv6mIv7PI/y1SfisO/3PI//KfPy2S/3SI/zDa/+2AP2pJ/zCavy2Tv6lIfiwQ/+2Af2oKf+hFPzBafy3T/i6Xf/GAf6hF/7BZf++Xf+dDPy/Zfy4UP/CAf/LAf+8Afy9Xv+sAP/PAfy8W/y6V/isOv+XAP+iAP+xAP+5Af+0AfitPP3AI/2lI/2nJfiuQPyvO/e0UPrDcf6aA/6dDfLGhPm1T/6cCP2eD/W5XvbJhfmxRPyjH/2dDf6bB/ulI/ezTQAAALQaDucAAADfdFJOU////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////wA8JjrVAAAACXBIWXMAAA7CAAAOwgEVKEqAAAAEHklEQVRYR7WWh3sURRiHkdxRoqiAi7JiQUWwgQiKwgFGbjEBFcGcYEHDUYIFQaMC0u1KEwQBEVTAbgR7A6OEKEpRQUSK87fwfTO/nZ2ZnctDHh/f58nM3jfze3dutlxaif/I/ypodUrrskwm26ZtOxR8lBa0p3BM+akopiklOM2IMx1Ox4BLCcEZyCWUnYkhB7+gI1IWnTBo4xVkEXHojGELn6BEPpM5CxNMPII2Aean6YIpBr4VtG6JwbsHLTG4guzZ3LbA4ArKg3O464rpmTDEgcYxpL7CuUE37s6Tk8PzxQUXuquxDZagOzcXBRdzx4bwEj7q0azBFFwa9OSuV2wIL+NeiMubM1gruCK4krtewVXcde3NLdMHQY1xR9l7cHXQl7trlCGhGYMWZMu47Zfpz921wXXcJaQMAzCgBXT/l3N/feYG7gYGg7hLMA10ZXODh2AAAn5+giwfwTCUG5MbVZiouCnMDctHw1VdCTrIfYYhuFnWXGJDrjJfNSyfj6IRsiwFI+PrBMMt3CluvQ0HMIQVYcWoSHE7V1kwWoYl0jDmDm4l1VHhThxKQzh23F1330OnZ+7lIgvMF4g0aKppWmE8PrAhvO/+mglFlY+iiVQjwSRkFWWT1WSG866hYtwUWWVqqUSCqYgq1P0vUXnbkKvU5ycekALrFyB8EFOTvGV4KMIGSB5mwTREJd68ZXgEJUmBBdORZWY8iml23jI8hhJTx4LHjYf1CUxy82RIrmYBJaL4JAlmIszMwhwhMCOBTgZqUSGKs0nwlLGCOZhDGOeRzEXdXsE8EsxHWLIAkwjbsBBVIRahwsg9eBpZxTOYRpgGf14JxLPIKrxrKJGPnpOC5+13pseQ5F+IrG/2ohS8hGRMypDkhXhZVgCeBbHYXoJrMPO2YQl9ZoH9NBCWIckv5TOKZUirBSjBcgQ1huEV9MSKaCV3ryJft4o/SYFYjaDGMBi8xk+PEGuUYK2sKQG9ix1ShnXcwPA6xYvr+UgLxBsIahzDBvUGhGGjzmuBeBNBjWN4S+3ZGnyLt7lltEBsQlBjGDbT35bYIJeiSQRpwzsYoPv3XWqHeA2GwDXk3ntf1fn+/4D6D9Vl/Cj6mDtgChxDfT7/CVe3you2jY4+VRvwGTcxlsA05D6voffvF19+9bXMR9E3NP5tQRpMbEFiCL/7fnsNooq6HTQ+Qq3BwBHEhrAq+qGh3vwJIH6k8Z92qnkaV6AMYUNlvnHULkcgDS4pARtyVU0NP0eNbj7+l8AiLRCbctuLjU2/IGOxG1MMPALxK+0//wviou9/E59A/IaEjTfvF4g9yJgU92LQxi8Q+5BKqNuPIYcSAvH7HwiCPw9gwKWUQIiDfyFLZz/0N4ppSguEOPzPkaPHjh06/i8+e2lOcBIIcQLcIg1ac6/BWAAAAABJRU5ErkJggg==">
<title>chDB</title>
<!-- Code Style:
Do not use any JavaScript or CSS frameworks or preprocessors.
This HTML page should not require any build systems (node.js, npm, gulp, etc.)
This HTML page should not be minified, instead it should be reasonably minimalistic by itself.
This HTML page should not load any external resources on load.
(CSS and JavaScript must be embedded directly to the page. No external fonts or images should be loaded).
This UI should look as lightweight, clean and fast as possible.
All UI elements must be aligned in pixel-perfect way.
There should not be any animations.
No unexpected changes in positions of elements while the page is loading.
Navigation by keyboard should work.
64-bit numbers must display correctly.
-->
<!-- Development Roadmap:
1. Support readonly servers.
Check if readonly = 1 (with SELECT FROM system.settings) to avoid sending settings. It can be done once on address/credentials change.
It can be done in background, e.g. wait 100 ms after address/credentials change and do the check.
Also it can provide visual indication that credentials are correct.
-->
<style>
:root {
--background-color: #DDF8FF; /* Or #FFFBEF; actually many pastel colors look great for light theme. */
--element-background-color: #FFF;
--bar-color: #F8F4F0; /* Light bar in background of table cells. */
--border-color: #EEE;
--shadow-color: rgba(0, 0, 0, 0.1);
--button-color: #FFAA00; /* Orange on light-cyan is especially good. */
--text-color: #000;
--button-active-color: #F00;
--button-active-text-color: #FFF;
--misc-text-color: #888;
--error-color: #FEE; /* Light-pink on light-cyan is so neat, I even want to trigger errors to see this cool combination of colors. */
--table-header-color: #F8F8F8;
--table-hover-color: #FFF8EF;
--null-color: #A88;
--link-color: #06D;
--logo-color: #CEE;
--logo-color-active: #BDD;
}
[data-theme="dark"] {
--background-color: #000;
--element-background-color: #102030;
--bar-color: #182838;
--border-color: #111;
--shadow-color: rgba(255, 255, 255, 0.1);
--text-color: #CCC;
--button-color: #FFAA00;
--button-text-color: #000;
--button-active-color: #F00;
--button-active-text-color: #FFF;
--misc-text-color: #888;
--error-color: #400;
--table-header-color: #102020;
--table-hover-color: #003333;
--null-color: #A88;
--link-color: #4BDAF7;
--logo-color: #222;
--logo-color-active: #333;
}
*
{
box-sizing: border-box;
/* For iPad */
margin: 0;
border-radius: 0;
tab-size: 4;
}
html, body
{
height: 100%;
margin: 0;
/* This enables position: sticky on controls */
overflow: auto;
}
html
{
/* The fonts that have full support for hinting. */
font-family: Liberation Sans, DejaVu Sans, sans-serif, Noto Color Emoji, Apple Color Emoji, Segoe UI Emoji;
background: var(--background-color);
color: var(--text-color);
}
body
{
/* This element will show scroll-bar on overflow, and the scroll-bar will be outside of the padding. */
padding: 0.5rem;
}
#controls
{
/* When a page will be scrolled horizontally due to large table size, keep controls in place. */
position: sticky;
left: 0;
}
/* Otherwise Webkit based browsers will display ugly border on focus. */
textarea, input, button
{
outline: none;
border: none;
color: var(--text-color);
}
.monospace
{
/* Prefer fonts that have full hinting info. This is important for non-retina displays.
Also I personally dislike "Ubuntu" font due to the similarity of 'r' and 'г' (it looks very ignorant). */
font-family: Liberation Mono, DejaVu Sans Mono, MonoLisa, Consolas, monospace;
}
.monospace-table
{
/* Liberation is worse than DejaVu for block drawing characters. */
font-family: DejaVu Sans Mono, Liberation Mono, MonoLisa, Consolas, monospace;
}
.shadow
{
box-shadow: 0 0 1rem var(--shadow-color);
}
input, textarea
{
border: 1px solid var(--border-color);
/* The font must be not too small (to be inclusive) and not too large (it's less practical and make general feel of insecurity) */
font-size: 11pt;
padding: 0.25rem;
background-color: var(--element-background-color);
}
#query
{
/* Make enough space for even big queries. */
height: 20vh;
/* Keeps query text-area's width full screen even when user adjusting the width of the query box. */
min-width: 100%;
}
#inputs
{
white-space: nowrap;
}
#url
{
width: 70%;
}
#user
{
width: 15%;
}
#password
{
width: 15%;
}
#run_div
{
margin-top: 1rem;
}
#run
{
color: var(--button-text-color);
background-color: var(--button-color);
padding: 0.25rem 1rem;
cursor: pointer;
font-weight: bold;
font-size: 100%; /* Otherwise button element will have lower font size. */
}
#run:hover, #run:focus
{
color: var(--button-active-text-color);
background-color: var(--button-active-color);
}
#stats
{
float: right;
color: var(--misc-text-color);
}
#toggle-light, #toggle-dark
{
float: right;
padding-right: 0.5rem;
cursor: pointer;
}
.hint
{
color: var(--misc-text-color);
}
#data_div
{
margin-top: 1rem;
}
#data-table
{
width: 100%;
border-collapse: collapse;
border-spacing: 0;
}
/* Will be displayed when user specified custom format. */
#data-unparsed
{
background-color: var(--element-background-color);
margin-top: 0rem;
padding: 0.25rem 0.5rem;
display: none;
}
td
{
background-color: var(--element-background-color);
/* For wide tables any individual column will be no more than 50% of page width. */
max-width: 50vw;
/* The content is cut unless you hover. */
overflow: hidden;
text-overflow: ellipsis;
padding: 0.25rem 0.5rem;
border: 1px solid var(--border-color);
white-space: pre;
vertical-align: top;
}
.right
{
text-align: right;
}
th
{
padding: 0.25rem 0.5rem;
text-align: center;
background-color: var(--table-header-color);
border: 1px solid var(--border-color);
}
/* The row under mouse pointer is highlight for better legibility. */
tr:hover, tr:hover td
{
background-color: var(--table-hover-color);
}
tr:hover
{
box-shadow: 0 0 1rem rgba(0, 0, 0, 0.1);
}
#error
{
background: var(--error-color);
white-space: pre-wrap;
padding: 0.5rem 1rem;
display: none;
}
/* When mouse pointer is over table cell, will display full text (with wrap) instead of cut.
* We also keep it for some time on mouseout for "hysteresis" effect.
*/
td.left:hover, .td-hover-hysteresis
{
white-space: pre-wrap;
max-width: none;
}
.td-selected
{
white-space: pre-wrap;
max-width: none;
background-color: var(--table-hover-color);
border: 2px solid var(--border-color);
}
td.transposed
{
max-width: none;
overflow: auto;
white-space: pre-wrap;
}
td.empty-result
{
text-align: center;
vertical-align: middle;
}
.row-number
{
width: 1%;
text-align: right;
background-color: var(--table-header-color);
color: var(--misc-text-color);
}
div.empty-result
{
opacity: 10%;
font-size: 7vw;
font-family: Liberation Sans, DejaVu Sans, sans-serif;
}
/* The style for SQL NULL */
.null
{
color: var(--null-color);
}
@keyframes hourglass-animation {
0% {
transform: rotate(-180deg);
}
50% {
transform: rotate(-180deg);
}
100% {
transform: none;
}
}
#hourglass
{
display: none;
margin-left: 1rem;
font-size: 110%;
color: #888;
animation: hourglass-animation 1s linear infinite;
}
#check-mark
{
display: none;
padding-left: 1rem;
font-size: 110%;
color: #080;
}
a, a:visited
{
color: var(--link-color);
text-decoration: none;
}
#graph
{
display: none;
}
/* This is for graph in svg */
text
{
font-size: 14px;
fill: var(--text-color);
}
.node rect
{
fill: var(--element-background-color);
filter: drop-shadow(.2rem .2rem .2rem var(--shadow-color));
}
.edgePath path
{
stroke: var(--text-color);
}
marker
{
fill: var(--text-color);
}
#logo
{
fill: var(--logo-color);
}
#logo:hover
{
fill: var(--logo-color-active);
}
#logo-container
{
text-align: center;
margin-top: 5em;
}
#chart
{
background-color: var(--element-background-color);
filter: drop-shadow(.2rem .2rem .2rem var(--shadow-color));
display: none;
height: 70vh;
}
/* This is for charts (uPlot), Copyright (c) 2022 Leon Sorokin, MIT License, https://github.com/leeoniya/uPlot/ */
.u-wrap {position: relative;user-select: none;}
.u-over, .u-under, .u-axis {position: absolute;}
.u-under {overflow: hidden;}
.uplot canvas {display: block;position: relative;width: 100%;height: 100%;}
.u-legend {margin: auto;text-align: center; margin-top: 1em; font-family: Liberation Mono, DejaVu Sans Mono, MonoLisa, Consolas, monospace;}
.u-inline {display: block;}
.u-inline * {display: inline-block;}
.u-inline tr {margin-right: 16px;}
.u-legend th {font-weight: 600;}
.u-legend th > * {vertical-align: middle;display: inline-block;}
.u-legend td { min-width: 13em; }
.u-legend .u-marker {width: 1em;height: 1em;margin-right: 4px;background-clip: padding-box !important;}
.u-inline.u-live th::after {content: ":";vertical-align: middle;}
.u-inline:not(.u-live) .u-value {display: none;}
.u-series > * {padding: 4px;}
.u-series th {cursor: pointer;}
.u-legend .u-off > * {opacity: 0.3;}
.u-select {background: rgba(0,0,0,0.07);position: absolute;pointer-events: none;}
.u-cursor-x, .u-cursor-y {position: absolute;left: 0;top: 0;pointer-events: none;will-change: transform;z-index: 100;}
.u-hz .u-cursor-x, .u-vt .u-cursor-y {height: 100%;border-right: 1px dashed #607D8B;}
.u-hz .u-cursor-y, .u-vt .u-cursor-x {width: 100%;border-bottom: 1px dashed #607D8B;}
.u-cursor-pt {position: absolute;top: 0;left: 0;border-radius: 50%;border: 0 solid;pointer-events: none;will-change: transform;z-index: 100;/*this has to be !important since we set inline "background" shorthand */background-clip: padding-box !important;}
.u-axis.u-off, .u-select.u-off, .u-cursor-x.u-off, .u-cursor-y.u-off, .u-cursor-pt.u-off {display: none;}
</style>
</head>
<body>
<div id="controls">
<div id="inputs">
<input class="monospace shadow" id="url" type="text" value="http://localhost:8123/" placeholder="url" /><input class="monospace shadow" id="user" type="text" value="default" placeholder="user" /><input class="monospace shadow" id="password" type="password" placeholder="password" />
</div>
<div id="query_div">
<textarea autofocus spellcheck="false" class="monospace shadow" id="query"></textarea>
</div>
<div id="run_div">
<button class="shadow" id="run">Run</button>
<span class="hint">&nbsp;(Ctrl/Cmd+Enter)</span>
<span id="hourglass"></span>
<span id="check-mark"></span>
<span id="stats"></span>
<span id="toggle-dark">🌑</span><span id="toggle-light">🌞</span>
</div>
</div>
<div id="data_div">
<table class="monospace-table shadow" id="data-table"></table>
<pre class="monospace-table shadow" id="data-unparsed"></pre>
</div>
<div id="chart"></div>
<svg id="graph" fill="none"></svg>
<p id="error" class="monospace shadow">
</p>
<p id="logo-container"></p>
</body>
<script type="text/javascript">
/// Incremental request number. When response is received,
/// if its request number does not equal to the current request number, response will be ignored.
/// This is to avoid race conditions.
let request_num = 0;
/// Save query in history only if it is different.
let previous_query = '';
const current_url = new URL(window.location);
const server_address = current_url.searchParams.get('url');
if (server_address) {
document.getElementById('url').value = server_address;
} else if (location.protocol != 'file:') {
/// Substitute the address of the server where the page is served.
document.getElementById('url').value = location.origin;
}
/// Substitute user name if it's specified in the query string
const user_from_url = current_url.searchParams.get('user');
if (user_from_url) {
document.getElementById('user').value = user_from_url;
}
function postImpl(posted_request_num, query)
{
const user = document.getElementById('user').value;
const password = document.getElementById('password').value;
const server_address = document.getElementById('url').value;
var url = server_address +
(server_address.indexOf('?') >= 0 ? '&' : '?') +
/// Ask server to allow cross-domain requests.
'add_http_cors_header=1' +
'&default_format=JSONCompact' +
/// Safety settings to prevent results that browser cannot display.
'&max_result_rows=1000&max_result_bytes=10000000&result_overflow_mode=break';
// If play.html is opened locally, append username and password to the URL parameter to avoid CORS issue.
if (document.location.href.startsWith("file://")) {
url += '&user=' + encodeURIComponent(user) +
'&password=' + encodeURIComponent(password)
}
const xhr = new XMLHttpRequest;
xhr.open('POST', url, true);
// If play.html is open normally, use Basic auth to prevent username and password being exposed in URL parameters
if (!document.location.href.startsWith("file://")) {
xhr.setRequestHeader("Authorization", "Basic " + btoa(user+":"+password));
}
xhr.onreadystatechange = function()
{
if (posted_request_num != request_num) {
return;
} else if (this.readyState === XMLHttpRequest.DONE) {
renderResponse(this.status, this.response);
/// The query is saved in browser history (in state JSON object)
/// as well as in URL fragment identifier.
if (query != previous_query) {
const state = {
query: query,
status: this.status,
response: this.response.length > 100000 ? null : this.response /// Lower than the browser's limit.
};
const title = "Query: " + query;
let history_url = window.location.pathname + '?user=' + encodeURIComponent(user);
if (server_address != location.origin) {
/// Save server's address in URL if it's not identical to the address of the play UI.
history_url += '&url=' + encodeURIComponent(server_address);
}
history_url += '#' + window.btoa(query);
if (previous_query == '') {
history.replaceState(state, title, history_url);
} else {
history.pushState(state, title, history_url);
}
document.title = title;
previous_query = query;
}
} else {
//console.log(this);
}
}
document.getElementById('check-mark').style.display = 'none';
document.getElementById('hourglass').style.display = 'inline-block';
xhr.send(query);
}
function renderResponse(status, response) {
document.getElementById('hourglass').style.display = 'none';
if (status === 200) {
let json;
try { json = JSON.parse(response); } catch (e) {}
if (json !== undefined && json.statistics !== undefined) {
renderResult(json);
} else if (Array.isArray(json) && json.length == 2 &&
Array.isArray(json[0]) && Array.isArray(json[1]) && json[0].length > 1 && json[0].length == json[1].length) {
/// If user requested FORMAT JSONCompactColumns, we will render it as a chart.
renderChart(json);
} else {
renderUnparsedResult(response);
}
document.getElementById('check-mark').style.display = 'inline';
} else {
/// TODO: Proper rendering of network errors.
renderError(response);
}
}
let query_area = document.getElementById('query');
window.onpopstate = function(event) {
if (!event.state) {
return;
}
query_area.value = event.state.query;
if (!event.state.response) {
clear();
return;
}
renderResponse(event.state.status, event.state.response);
};
if (window.location.hash) {
query_area.value = window.atob(window.location.hash.substr(1));
}
function post()
{
++request_num;
let query = query_area.value;
postImpl(request_num, query);
}
document.getElementById('run').onclick = function()
{
post();
}
document.onkeydown = function(event)
{
/// Firefox has code 13 for Enter and Chromium has code 10.
if ((event.metaKey || event.ctrlKey) && (event.keyCode == 13 || event.keyCode == 10)) {
post();
}
}
/// Pressing Tab in textarea will increase indentation.
/// But for accessibility reasons, we will fall back to tab navigation if the user already used Tab for that.
let user_prefers_tab_navigation = false;
[...document.querySelectorAll('input')].map(elem => {
elem.onkeydown = (e) => {
if (e.key == 'Tab') { user_prefers_tab_navigation = true; }
};
});
query_area.onkeydown = (e) => {
if (e.key == 'Tab' && !event.shiftKey && !user_prefers_tab_navigation) {
let elem = e.target;
let selection_start = elem.selectionStart;
let selection_end = elem.selectionEnd;
elem.value = elem.value.substring(0, elem.selectionStart) + ' ' + elem.value.substring(elem.selectionEnd);
elem.selectionStart = selection_start + 4;
elem.selectionEnd = selection_start + 4;
e.preventDefault();
return false;
}
};
function clearElement(id)
{
let elem = document.getElementById(id);
while (elem.firstChild) {
elem.removeChild(elem.lastChild);
}
elem.style.display = 'none';
}
function clear()
{
clearElement('data-table');
clearElement('graph');
clearElement('chart');
clearElement('data-unparsed');
clearElement('error');
document.getElementById('check-mark').display = 'none';
document.getElementById('hourglass').display = 'none';
document.getElementById('stats').innerText = '';
document.getElementById('logo-container').style.display = 'block';
}
function formatReadable(number = 0, decimals = 2, units = []) {
const k = 1000;
const i = number ? Math.floor(Math.log(number) / Math.log(k)) : 0;
const unit = units[i];
const dm = unit ? decimals : 0;
return Number(number / Math.pow(k, i)).toFixed(dm) + unit;
}
function formatReadableBytes(bytes) {
const units = [' B', ' KB', ' MB', ' GB', ' TB', ' PB', ' EB', ' ZB', ' YB'];
return formatReadable(bytes, 2, units);
}
function formatReadableRows(rows) {
const units = ['', ' thousand', ' million', ' billion', ' trillion', ' quadrillion'];
return formatReadable(rows, 2, units);
}
function renderResult(response)
{
clear();
let stats = document.getElementById('stats');
const seconds = response.statistics.elapsed.toFixed(3);
const rows = response.statistics.rows_read;
const bytes = response.statistics.bytes_read;
const formatted_bytes = formatReadableBytes(bytes);
const formatted_rows = formatReadableRows(rows);
stats.innerText = `Elapsed: ${seconds} sec, read ${formatted_rows} rows, ${formatted_bytes}.`;
/// We can also render graphs if user performed EXPLAIN PIPELINE graph=1 or EXPLAIN AST graph = 1
if (response.data.length > 3 && query_area.value.match(/^\s*EXPLAIN/i) && typeof(response.data[0][0]) === "string" && response.data[0][0].startsWith("digraph")) {
renderGraph(response);
} else {
renderTable(response);
}
}
function renderCell(cell, col_idx, settings)
{
let td = document.createElement('td');
let is_null = (cell === null);
let is_link = false;
/// Test: SELECT number, toString(number) AS str, number % 2 ? number : NULL AS nullable, range(number) AS arr, CAST((['hello', 'world'], [number, number % 2]) AS Map(String, UInt64)) AS map FROM numbers(10)
let text;
if (is_null) {
text = 'ᴺᵁᴸᴸ';
} else if (typeof(cell) === 'object') {
text = JSON.stringify(cell);
} else {
text = cell;
/// If it looks like URL, create a link. This is for convenience.
if (typeof(cell) == 'string' && cell.match(/^https?:\/\/\S+$/)) {
is_link = true;
}
}
let node = document.createTextNode(text);
if (is_link) {
let link = document.createElement('a');
link.appendChild(node);
link.href = text;
link.setAttribute('target', '_blank');
node = link;
}
if (settings.is_transposed) {
td.className = 'left transposed';
} else {
td.className = settings.column_is_number[col_idx] ? 'right' : 'left';
}
if (is_null) {
td.className += ' null';
}
/// If it's a number, render bar in background.
if (!settings.is_transposed && settings.column_need_render_bars[col_idx] && text > 0) {
const ratio = 100 * text / settings.column_maximums[col_idx];
let div = document.createElement('div');
div.style.width = '100%';
div.style.background = `linear-gradient(to right,
var(--bar-color) 0%, var(--bar-color) ${ratio}%,
transparent ${ratio}%, transparent 100%)`;
div.appendChild(node);
node = div;
}
td.appendChild(node);
return td;
}
function renderTableTransposed(response)
{
let tbody = document.createElement('tbody');
for (let col_idx in response.meta) {
let tr = document.createElement('tr');
{
let th = document.createElement('th');
th.className = 'right';
th.style.width = '0';
th.appendChild(document.createTextNode(response.meta[col_idx].name));
tr.appendChild(th);
}
for (let row_idx in response.data)
{
let cell = response.data[row_idx][col_idx];
const td = renderCell(cell, col_idx, {is_transposed: true});
tr.appendChild(td);
}
if (response.data.length == 0 && col_idx == 0)
{
/// If result is empty, show this fact with a style.
let td = document.createElement('td');
td.rowSpan = response.meta.length;
td.className = 'empty-result';
let div = document.createElement('div');
div.appendChild(document.createTextNode("empty result"));
div.className = 'empty-result';
td.appendChild(div);
tr.appendChild(td);
}
tbody.appendChild(tr);
}
let table = document.getElementById('data-table');
table.appendChild(tbody);
table.style.display = 'table';
}
function renderTable(response)
{
if (response.data.length <= 1 && response.meta.length >= 5) {
renderTableTransposed(response)
return;
}
const should_display_row_numbers = response.data.length > 3;
let thead = document.createElement('thead');
if (should_display_row_numbers) {
let th = document.createElement('th');
th.className = 'row-number';
th.appendChild(document.createTextNode('№'));
thead.appendChild(th);
}
for (let idx in response.meta) {
let th = document.createElement('th');
const name = document.createTextNode(response.meta[idx].name);
th.appendChild(name);
thead.appendChild(th);
}
/// To prevent hanging the browser, limit the number of cells in a table.
/// It's important to have the limit on number of cells, not just rows, because tables may be wide or narrow.
/// Also we permit rendering of more records but only if elapsed time is not large.
const max_rows = 10000 / response.meta.length;
const max_render_ms = 200;
let row_num = 0;
const column_is_number = response.meta.map(elem => !!elem.type.match(/^(Nullable\()?(U?Int|Decimal|Float)/));
const column_maximums = column_is_number.map((elem, idx) => elem ? Math.max(...response.data.map(row => row[idx])) : 0);
const column_minimums = column_is_number.map((elem, idx) => elem ? Math.min(...response.data.map(row => Math.max(0, row[idx]))) : 0);
const column_need_render_bars = column_is_number.map((elem, idx) => column_maximums[idx] > 0 && column_maximums[idx] > column_minimums[idx]);
const settings = {
is_transposed: false,
column_is_number: column_is_number,
column_maximums: column_maximums,
column_minimums: column_minimums,
column_need_render_bars: column_need_render_bars,
};
const start_time = performance.now();
let tbody = document.createElement('tbody');
for (let row_idx in response.data) {
let tr = document.createElement('tr');
if (should_display_row_numbers) {
let td = document.createElement('td');
td.className = 'row-number';
td.appendChild(document.createTextNode(1 + +row_idx));
tr.appendChild(td);
}
for (let col_idx in response.data[row_idx]) {
let cell = response.data[row_idx][col_idx];
const td = renderCell(cell, col_idx, settings);
td.onclick = () => { td.classList.add('td-selected') };
td.onmouseenter = () => {
td.classList.add('td-hover-hysteresis');
td.onmouseleave = () => {
setTimeout(() => { td && td.classList.remove('td-hover-hysteresis') }, 1000);
};
};
tr.appendChild(td);
}
tbody.appendChild(tr);
++row_num;
if (row_num >= max_rows && performance.now() - start_time >= max_render_ms) {
break;
}
}
let table = document.getElementById('data-table');
table.appendChild(thead);
table.appendChild(tbody);
table.style.display = 'table';
}
/// A function to render raw data when non-default format is specified.
function renderUnparsedResult(response)
{
clear();
let data = document.getElementById('data-unparsed')
if (response === '') {
/// TODO: Fade or remove previous result when new request will be performed.
response = 'Ok.';
}
data.innerText = response;
/// inline-block make width adjust to the size of content.
data.style.display = 'inline-block';
}
function renderError(response)
{
clear();
document.getElementById('error').innerText = response ? response : "No response.";
document.getElementById('error').style.display = 'block';
document.getElementById('logo-container').style.display = 'none';
}
/// Huge JS libraries should be loaded only if needed.
function loadJS(src, integrity) {
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = src;
if (integrity) {
script.crossOrigin = 'anonymous';
script.integrity = integrity;
} else {
console.warn('no integrity for', src)
}
script.addEventListener('load', function() { resolve(true); });
document.head.appendChild(script);
});
}
let load_dagre_promise;
function loadDagre() {
if (load_dagre_promise) { return load_dagre_promise; }
load_dagre_promise = Promise.all([
loadJS('https://dagrejs.github.io/project/dagre/v0.8.5/dagre.min.js',
'sha384-2IH3T69EIKYC4c+RXZifZRvaH5SRUdacJW7j6HtE5rQbvLhKKdawxq6vpIzJ7j9M'),
loadJS('https://dagrejs.github.io/project/graphlib-dot/v0.6.4/graphlib-dot.min.js',
'sha384-Q7oatU+b+y0oTkSoiRH9wTLH6sROySROCILZso/AbMMm9uKeq++r8ujD4l4f+CWj'),
loadJS('https://dagrejs.github.io/project/dagre-d3/v0.6.4/dagre-d3.min.js',
'sha384-9N1ty7Yz7VKL3aJbOk+8ParYNW8G5W+MvxEfFL9G7CRYPmkHI9gJqyAfSI/8190W'),
loadJS('https://cdn.jsdelivr.net/npm/[email protected]',
'sha384-S+Kf0r6YzKIhKA8d1k2/xtYv+j0xYUU3E7+5YLrcPVab6hBh/r1J6cq90OXhw80u'),
]);
return load_dagre_promise;
}
async function renderGraph(response)
{
await loadDagre();
/// https://github.com/dagrejs/dagre-d3/issues/131
const dot = response.data.reduce((acc, row) => acc + '\n' + row[0].replace(/shape\s*=\s*box/g, 'shape=rect'));
let graph = graphlibDot.read(dot);
graph.graph().rankdir = 'TB';
let render = new dagreD3.render();
let svg = document.getElementById('graph');
svg.style.display = 'block';
render(d3.select("#graph"), graph);
svg.style.width = graph.graph().width;
svg.style.height = graph.graph().height;
}
let load_uplot_promise;
function loadUplot() {
if (load_uplot_promise) { return load_uplot_promise; }
load_uplot_promise = loadJS('https://cdn.jsdelivr.net/npm/[email protected]/dist/uPlot.iife.min.js',
'sha384-TwdJPnTsKP6pnvFZZKda0WJCXpjcHCa7MYHmjrYDu6rsEsb/UnFdoL0phS5ODqTA');
return load_uplot_promise;
}
let uplot;
async function renderChart(json)
{
await loadUplot();
clear();
let chart = document.getElementById('chart');
chart.style.display = 'block';
let paths = uPlot.paths.stepped({align: 1});
const [line_color, fill_color, grid_color, axes_color] = theme == 'light'
? ["#F80", "#FED", "#c7d0d9", "#2c3235"]
: ["#888", "#045", "#2c3235", "#c7d0d9"];
const opts = {
width: chart.clientWidth,
height: chart.clientHeight,
scales: { x: { time: json[0][0] > 1000000000 && json[0][0] < 2000000000 } },
axes: [ { stroke: axes_color,
grid: { width: 1 / devicePixelRatio, stroke: grid_color },
ticks: { width: 1 / devicePixelRatio, stroke: grid_color } },
{ stroke: axes_color,
grid: { width: 1 / devicePixelRatio, stroke: grid_color },
ticks: { width: 1 / devicePixelRatio, stroke: grid_color } } ],
series: [ { label: "x" },
{ label: "y", stroke: line_color, fill: fill_color,
drawStyle: 0, lineInterpolation: 1, paths } ],
padding: [ null, null, null, (Math.ceil(Math.log10(Math.max(...json[1]))) + Math.floor(Math.log10(Math.max(...json[1])) / 3)) * 6 ],
};
uplot = new uPlot(opts, json, chart);
}
function resizeChart() {
if (uplot) {
let chart = document.getElementById('chart');
uplot.setSize({ width: chart.clientWidth, height: chart.clientHeight });
}
}
function redrawChart() {
if (uplot && document.getElementById('chart').style.display == 'block') {
renderChart(uplot.data);
}
}
new ResizeObserver(resizeChart).observe(document.getElementById('chart'));
/// First we check if theme is set via the 'theme' GET parameter, if not, we check localStorage, otherwise we check OS preference.
let theme = current_url.searchParams.get('theme');
if (['dark', 'light'].indexOf(theme) === -1) {
theme = window.localStorage.getItem('theme');
}
if (!theme) {
theme = 'light';
}
function setColorTheme(new_theme, update_preference) {
theme = new_theme;
if (update_preference) {
window.localStorage.setItem('theme', theme);
}
document.documentElement.setAttribute('data-theme', theme);
redrawChart();
}
if (theme) {
document.documentElement.setAttribute('data-theme', theme);
} else {
/// Obtain system-level user preference
const media_query_list = window.matchMedia('(prefers-color-scheme: dark)');
if (media_query_list.matches) {
setColorTheme('dark');
}
/// There is a rumor that on some computers, the theme is changing automatically on day/night.
media_query_list.addEventListener('change', function(e) {
setColorTheme(e.matches ? 'dark' : 'light');
});
}
document.getElementById('toggle-light').onclick = function() {
setColorTheme('light', true);
}
document.getElementById('toggle-dark').onclick = function() {
setColorTheme('dark', true);
}
</script>
</html>