Spaces:
Sleeping
Sleeping
<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 ;} | |
.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 ;} | |
.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"> (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> | |