mirror of
https://github.com/ClickHouse/ClickHouse.git
synced 2024-11-22 23:52:03 +00:00
818bbd6539
Co-authored-by: Vladimir <vdimir@yandex-team.ru>
686 lines
22 KiB
HTML
686 lines
22 KiB
HTML
<html> <!-- TODO If I write DOCTYPE HTML something changes but I don't know what. -->
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<link rel="icon" href="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI1NCIgaGVpZ2h0PSI0OCIgdmlld0JveD0iMCAwIDkgOCI+PHN0eWxlPi5ve2ZpbGw6I2ZjMH0ucntmaWxsOnJlZH08L3N0eWxlPjxwYXRoIGQ9Ik0wLDcgaDEgdjEgaC0xIHoiIGNsYXNzPSJyIi8+PHBhdGggZD0iTTAsMCBoMSB2NyBoLTEgeiIgY2xhc3M9Im8iLz48cGF0aCBkPSJNMiwwIGgxIHY4IGgtMSB6IiBjbGFzcz0ibyIvPjxwYXRoIGQ9Ik00LDAgaDEgdjggaC0xIHoiIGNsYXNzPSJvIi8+PHBhdGggZD0iTTYsMCBoMSB2OCBoLTEgeiIgY2xhc3M9Im8iLz48cGF0aCBkPSJNOCwzLjI1IGgxIHYxLjUgaC0xIHoiIGNsYXNzPSJvIi8+PC9zdmc+">
|
||
<title>ClickHouse Query</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
|
||
(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 type="text/css">
|
||
:root {
|
||
--background-color: #DDF8FF; /* Or #FFFBEF; actually many pastel colors look great for light theme. */
|
||
--element-background-color: #FFF;
|
||
--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;
|
||
}
|
||
|
||
[data-theme="dark"] {
|
||
--background-color: #000;
|
||
--element-background-color: #102030;
|
||
--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;
|
||
}
|
||
|
||
html, body
|
||
{
|
||
/* Personal choice. */
|
||
font-family: Sans-Serif;
|
||
background: var(--background-color);
|
||
color: var(--text-color);
|
||
}
|
||
|
||
/* Otherwise Webkit based browsers will display ugly border on focus. */
|
||
textarea, input, button
|
||
{
|
||
outline: none;
|
||
border: none;
|
||
color: var(--text-color);
|
||
}
|
||
|
||
/* Otherwise scrollbar may appear dynamically and it will alter viewport height,
|
||
then relative heights of elements will change suddenly, and it will break overall impression. */
|
||
/* html
|
||
{
|
||
overflow-x: scroll;
|
||
}*/
|
||
|
||
div
|
||
{
|
||
width: 100%;
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
.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 huge queries. */
|
||
height: 20%;
|
||
width: 100%;
|
||
}
|
||
|
||
#inputs
|
||
{
|
||
white-space: nowrap;
|
||
width: 100%;
|
||
}
|
||
|
||
#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
|
||
{
|
||
border-collapse: collapse;
|
||
border-spacing: 0;
|
||
/* I need pixel-perfect alignment but not sure the following is correct, please help */
|
||
min-width: calc(100vw - 2rem);
|
||
}
|
||
|
||
/* 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);
|
||
white-space: nowrap;
|
||
/* 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;
|
||
padding: 0.25rem 0.5rem;
|
||
border: 1px solid var(--border-color);
|
||
white-space: pre;
|
||
}
|
||
|
||
td.right
|
||
{
|
||
text-align: right;
|
||
}
|
||
|
||
th
|
||
{
|
||
padding: 0.25rem 0.5rem;
|
||
text-align: middle;
|
||
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.
|
||
TODO Find a way to make it work on touch devices. */
|
||
td.left:hover
|
||
{
|
||
white-space: pre-wrap;
|
||
}
|
||
|
||
/* The style for SQL NULL */
|
||
.null
|
||
{
|
||
color: var(--null-color);
|
||
}
|
||
|
||
#hourglass
|
||
{
|
||
display: none;
|
||
padding-left: 1rem;
|
||
font-size: 110%;
|
||
color: #888;
|
||
}
|
||
|
||
#check-mark
|
||
{
|
||
display: none;
|
||
padding-left: 1rem;
|
||
font-size: 110%;
|
||
color: #080;
|
||
}
|
||
|
||
a, a:visited
|
||
{
|
||
color: var(--link-color);
|
||
text-decoration: 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);
|
||
}
|
||
</style>
|
||
</head>
|
||
|
||
<body>
|
||
<div id="inputs">
|
||
<input class="monospace shadow" id="url" type="text" value="http://localhost:8123/" /><input class="monospace shadow" id="user" type="text" value="default" /><input class="monospace shadow" id="password" type="password" />
|
||
</div>
|
||
<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 id="data_div">
|
||
<table class="monospace shadow" id="data-table"></table>
|
||
<pre class="monospace shadow" id="data-unparsed"></pre>
|
||
</div>
|
||
<svg id="graph" fill="none"></svg>
|
||
<p id="error" class="monospace shadow">
|
||
</p>
|
||
</body>
|
||
|
||
<script type="text/javascript">
|
||
|
||
/// Incremental request number. When response is received,
|
||
/// if it's 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 = '';
|
||
|
||
/// Substitute the address of the server where the page is served.
|
||
if (location.protocol != 'file:') {
|
||
document.getElementById('url').value = location.origin;
|
||
}
|
||
|
||
/// Substitute user name if it's specified in the query string
|
||
let user_from_url = (new URL(window.location)).searchParams.get('user');
|
||
if (user_from_url) {
|
||
document.getElementById('user').value = user_from_url;
|
||
}
|
||
|
||
function postImpl(posted_request_num, query)
|
||
{
|
||
/// TODO: Check if URL already contains query string (append parameters).
|
||
|
||
let user = document.getElementById('user').value;
|
||
let password = document.getElementById('password').value;
|
||
|
||
let url = document.getElementById('url').value +
|
||
/// Ask server to allow cross-domain requests.
|
||
'?add_http_cors_header=1' +
|
||
'&user=' + encodeURIComponent(user) +
|
||
'&password=' + encodeURIComponent(password) +
|
||
'&default_format=JSONCompact' +
|
||
/// Safety settings to prevent results that browser cannot display.
|
||
'&max_result_rows=1000&max_result_bytes=10000000&result_overflow_mode=break';
|
||
|
||
let xhr = new XMLHttpRequest;
|
||
|
||
xhr.open('POST', url, true);
|
||
|
||
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) {
|
||
let state = {
|
||
query: query,
|
||
status: this.status,
|
||
response: this.response.length > 100000 ? null : this.response /// Lower than the browser's limit.
|
||
};
|
||
let title = "ClickHouse Query: " + query;
|
||
let url = window.location.pathname + '?user=' + encodeURIComponent(user) + '#' + window.btoa(query);
|
||
if (previous_query == '') {
|
||
history.replaceState(state, title, url);
|
||
} else {
|
||
history.pushState(state, title, url);
|
||
}
|
||
document.title = title;
|
||
previous_query = query;
|
||
}
|
||
} else {
|
||
//console.log(this);
|
||
}
|
||
}
|
||
|
||
document.getElementById('check-mark').style.display = 'none';
|
||
document.getElementById('hourglass').style.display = 'inline';
|
||
|
||
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 {
|
||
renderUnparsedResult(response);
|
||
}
|
||
document.getElementById('check-mark').style.display = 'inline';
|
||
} else {
|
||
/// TODO: Proper rendering of network errors.
|
||
renderError(response);
|
||
}
|
||
}
|
||
|
||
window.onpopstate = function(event) {
|
||
if (!event.state) {
|
||
return;
|
||
}
|
||
document.getElementById('query').value = event.state.query;
|
||
if (!event.state.response) {
|
||
clear();
|
||
return;
|
||
}
|
||
renderResponse(event.state.status, event.state.response);
|
||
};
|
||
|
||
if (window.location.hash) {
|
||
document.getElementById('query').value = window.atob(window.location.hash.substr(1));
|
||
}
|
||
|
||
function post()
|
||
{
|
||
++request_num;
|
||
let query = document.getElementById('query').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();
|
||
}
|
||
}
|
||
|
||
function clear()
|
||
{
|
||
let table = document.getElementById('data-table');
|
||
while (table.firstChild) {
|
||
table.removeChild(table.lastChild);
|
||
}
|
||
|
||
let graph = document.getElementById('graph');
|
||
while (graph.firstChild) {
|
||
graph.removeChild(graph.lastChild);
|
||
}
|
||
graph.style.display = 'none';
|
||
|
||
document.getElementById('data-unparsed').innerText = '';
|
||
document.getElementById('data-unparsed').style.display = 'none';
|
||
|
||
document.getElementById('error').innerText = '';
|
||
document.getElementById('error').style.display = 'none';
|
||
|
||
document.getElementById('stats').innerText = '';
|
||
|
||
document.getElementById('hourglass').style.display = 'none';
|
||
document.getElementById('check-mark').style.display = 'none';
|
||
}
|
||
|
||
function renderResult(response)
|
||
{
|
||
clear();
|
||
|
||
let stats = document.getElementById('stats');
|
||
stats.innerText = 'Elapsed: ' + response.statistics.elapsed.toFixed(3) + " sec, read " + response.statistics.rows_read + " rows.";
|
||
|
||
/// We can also render graphs if user performed EXPLAIN PIPELINE graph=1.
|
||
if (response.data.length > 3 && response.data[0][0] === "digraph" && document.getElementById('query').value.match(/^\s*EXPLAIN/i)) {
|
||
renderGraph(response);
|
||
} else {
|
||
renderTable(response);
|
||
}
|
||
}
|
||
|
||
function renderTable(response)
|
||
{
|
||
let thead = document.createElement('thead');
|
||
for (let idx in response.meta) {
|
||
let th = document.createElement('th');
|
||
let 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.
|
||
let max_rows = 10000 / response.meta.length;
|
||
let row_num = 0;
|
||
|
||
let column_classes = response.meta.map(elem => elem.type.match(/^(U?Int|Decimal|Float)/) ? 'right' : 'left');
|
||
|
||
let tbody = document.createElement('tbody');
|
||
for (let row_idx in response.data) {
|
||
let tr = document.createElement('tr');
|
||
for (let col_idx in response.data[row_idx]) {
|
||
let td = document.createElement('td');
|
||
let cell = response.data[row_idx][col_idx];
|
||
|
||
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;
|
||
}
|
||
|
||
td.appendChild(node);
|
||
td.className = column_classes[col_idx];
|
||
if (is_null) {
|
||
td.className += ' null';
|
||
}
|
||
tr.appendChild(td);
|
||
}
|
||
tbody.appendChild(tr);
|
||
|
||
++row_num;
|
||
if (row_num >= max_rows) {
|
||
break;
|
||
}
|
||
}
|
||
|
||
let table = document.getElementById('data-table');
|
||
table.appendChild(thead);
|
||
table.appendChild(tbody);
|
||
}
|
||
|
||
/// 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';
|
||
}
|
||
|
||
/// Huge JS libraries should be loaded only if needed.
|
||
function loadJS(src) {
|
||
return new Promise((resolve, reject) => {
|
||
const script = document.createElement('script');
|
||
script.src = 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'),
|
||
loadJS('https://dagrejs.github.io/project/graphlib-dot/v0.6.4/graphlib-dot.min.js'),
|
||
loadJS('https://dagrejs.github.io/project/dagre-d3/v0.6.4/dagre-d3.min.js'),
|
||
loadJS('https://cdn.jsdelivr.net/npm/d3@7.0.0'),
|
||
]);
|
||
|
||
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;
|
||
}
|
||
|
||
function setColorTheme(theme)
|
||
{
|
||
window.localStorage.setItem('theme', theme);
|
||
document.documentElement.setAttribute('data-theme', theme);
|
||
}
|
||
|
||
/// The choice of color theme is saved in browser.
|
||
let theme = window.localStorage.getItem('theme');
|
||
if (theme) {
|
||
setColorTheme(theme);
|
||
} else {
|
||
/// Obtain system-level user preference
|
||
let media_query_list = window.matchMedia('prefers-color-scheme: dark')
|
||
|
||
if (media_query_list.matches) {
|
||
/// Set without saving to localstorage
|
||
document.documentElement.setAttribute('data-theme', 'dark');
|
||
}
|
||
|
||
/// There is a rumor that on some computers, the theme is changing automatically on day/night.
|
||
media_query_list.addEventListener('change', function(e) {
|
||
if (e.matches) {
|
||
document.documentElement.setAttribute('data-theme', 'dark');
|
||
} else {
|
||
document.documentElement.setAttribute('data-theme', 'light');
|
||
}
|
||
});
|
||
}
|
||
|
||
document.getElementById('toggle-light').onclick = function()
|
||
{
|
||
setColorTheme('light');
|
||
}
|
||
|
||
document.getElementById('toggle-dark').onclick = function()
|
||
{
|
||
setColorTheme('dark');
|
||
}
|
||
</script>
|
||
</html>
|