2020-10-19 19:25:58 +00:00
< html > <!-- TODO If I write DOCTYPE HTML something changes but I don't know what. -->
2020-10-19 18:29:51 +00:00
< head >
< meta charset = "UTF-8" >
2020-11-23 07:15:33 +00:00
< link rel = "icon" href = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI1NCIgaGVpZ2h0PSI0OCIgdmlld0JveD0iMCAwIDkgOCI+PHN0eWxlPi5ve2ZpbGw6I2ZjMH0ucntmaWxsOnJlZH08L3N0eWxlPjxwYXRoIGQ9Ik0wLDcgaDEgdjEgaC0xIHoiIGNsYXNzPSJyIi8+PHBhdGggZD0iTTAsMCBoMSB2NyBoLTEgeiIgY2xhc3M9Im8iLz48cGF0aCBkPSJNMiwwIGgxIHY4IGgtMSB6IiBjbGFzcz0ibyIvPjxwYXRoIGQ9Ik00LDAgaDEgdjggaC0xIHoiIGNsYXNzPSJvIi8+PHBhdGggZD0iTTYsMCBoMSB2OCBoLTEgeiIgY2xhc3M9Im8iLz48cGF0aCBkPSJNOCwzLjI1IGgxIHYxLjUgaC0xIHoiIGNsYXNzPSJvIi8+PC9zdmc+" >
2020-10-19 18:29:51 +00:00
< title > ClickHouse Query< / title >
2020-11-04 22:17:19 +00:00
<!-- Code Style:
2020-10-19 19:25:58 +00:00
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.
2020-10-19 23:37:10 +00:00
No unexpected changes in positions of elements while the page is loading.
Navigation by keyboard should work.
64-bit numbers must display correctly.
2020-10-19 19:25:58 +00:00
-->
2020-11-04 22:17:19 +00:00
<!-- Development Roadmap:
2020-11-23 07:15:52 +00:00
1. Support readonly servers.
2020-11-04 22:17:19 +00:00
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.
-->
2020-10-19 18:29:51 +00:00
< style type = "text/css" >
2020-10-19 23:37:10 +00:00
: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);
2020-10-20 01:05:29 +00:00
--button-color: #FFAA00; /* Orange on light-cyan is especially good. */
2020-10-19 23:37:10 +00:00
--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;
}
[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;
2020-10-20 22:13:41 +00:00
--error-color: #400;
2020-10-19 23:37:10 +00:00
--table-header-color: #102020;
--table-hover-color: #003333;
--null-color: #A88;
}
2020-10-19 18:29:51 +00:00
html, body
{
2020-10-19 19:25:58 +00:00
/* Personal choice. */
2020-10-19 18:29:51 +00:00
font-family: Sans-Serif;
2020-10-19 23:37:10 +00:00
background: var(--background-color);
color: var(--text-color);
2020-10-19 19:25:58 +00:00
}
/* Otherwise Webkit based browsers will display ugly border on focus. */
2020-10-19 23:37:10 +00:00
textarea, input, button
2020-10-19 19:25:58 +00:00
{
outline: none;
2020-10-19 23:37:10 +00:00
border: none;
color: var(--text-color);
2020-10-19 18:29:51 +00:00
}
2020-10-19 19:25:58 +00:00
/* 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
2020-10-19 18:29:51 +00:00
{
overflow-x: scroll;
}*/
div
{
width: 100%;
}
.monospace
{
2020-10-19 19:25:58 +00:00
/* 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).
*/
2020-10-19 18:29:51 +00:00
font-family: Liberation Mono, DejaVu Sans Mono, MonoLisa, Consolas, Monospace;
}
.shadow
{
2020-10-19 23:37:10 +00:00
box-shadow: 0 0 1rem var(--shadow-color);
2020-10-19 18:29:51 +00:00
}
input, textarea
{
2020-10-19 23:37:10 +00:00
border: 1px solid var(--border-color);
2020-10-19 19:25:58 +00:00
/* The font must be not too small (to be inclusive) and not too large (it's less practical and make general feel of insecurity) */
2020-10-19 18:29:51 +00:00
font-size: 11pt;
padding: 0.25rem;
2020-10-19 23:37:10 +00:00
background-color: var(--element-background-color);
2020-10-19 18:29:51 +00:00
}
#query
{
2020-10-19 19:25:58 +00:00
/* Make enough space for even huge queries. */
2020-10-19 18:29:51 +00:00
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
{
2020-10-19 23:37:10 +00:00
color: var(--button-text-color);
background-color: var(--button-color);
2020-10-19 18:29:51 +00:00
padding: 0.25rem 1rem;
cursor: pointer;
font-weight: bold;
2020-10-19 23:37:10 +00:00
font-size: 100%; /* Otherwise button element will have lower font size. */
2020-10-19 18:29:51 +00:00
}
2020-10-19 23:37:10 +00:00
#run:hover, #run:focus
2020-10-19 18:29:51 +00:00
{
2020-10-19 23:37:10 +00:00
color: var(--button-active-text-color);
background-color: var(--button-active-color);
2020-10-19 18:29:51 +00:00
}
#stats
{
float: right;
2020-10-19 23:37:10 +00:00
color: var(--misc-text-color);
}
#toggle-light, #toggle-dark
{
float: right;
padding-right: 0.5rem;
cursor: pointer;
2020-10-19 18:29:51 +00:00
}
.hint
{
2020-10-19 23:37:10 +00:00
color: var(--misc-text-color);
2020-10-19 18:29:51 +00:00
}
#data_div
{
margin-top: 1rem;
}
#data-table
{
border-collapse: collapse;
border-spacing: 0;
2020-10-19 19:25:58 +00:00
/* I need pixel-perfect alignment but not sure the following is correct, please help */
2020-10-19 18:29:51 +00:00
min-width: calc(100vw - 2rem);
}
2020-10-19 23:37:10 +00:00
/* 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;
}
2020-10-19 18:29:51 +00:00
td
{
2020-10-19 23:37:10 +00:00
background-color: var(--element-background-color);
2020-10-19 18:29:51 +00:00
white-space: nowrap;
2020-10-19 19:25:58 +00:00
/* For wide tables any individual column will be no more than 50% of page width. */
2020-10-19 18:29:51 +00:00
max-width: 50vw;
2020-10-19 19:25:58 +00:00
/* The content is cut unless you hover. */
2020-10-19 18:29:51 +00:00
overflow: hidden;
padding: 0.25rem 0.5rem;
2020-10-19 23:37:10 +00:00
border: 1px solid var(--border-color);
2020-10-19 18:29:51 +00:00
white-space: pre;
}
td.right
{
text-align: right;
}
th
{
padding: 0.25rem 0.5rem;
text-align: middle;
2020-10-19 23:37:10 +00:00
background-color: var(--table-header-color);
border: 1px solid var(--border-color);
2020-10-19 18:29:51 +00:00
}
2020-10-19 19:25:58 +00:00
/* The row under mouse pointer is highlight for better legibility. */
2020-10-19 18:29:51 +00:00
tr:hover, tr:hover td
{
2020-10-19 23:37:10 +00:00
background-color: var(--table-hover-color);
2020-10-19 18:29:51 +00:00
}
tr:hover
{
box-shadow: 0 0 1rem rgba(0, 0, 0, 0.1);
}
#error
{
2020-10-19 23:37:10 +00:00
background: var(--error-color);
2020-10-19 18:29:51 +00:00
white-space: pre-wrap;
padding: 0.5rem 1rem;
display: none;
}
2020-10-19 19:25:58 +00:00
/* 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. */
2020-10-19 18:29:51 +00:00
td.left:hover
{
white-space: pre-wrap;
}
2020-10-19 19:25:58 +00:00
/* The style for SQL NULL */
2020-10-19 18:29:51 +00:00
.null
{
2020-10-19 23:37:10 +00:00
color: var(--null-color);
2020-10-19 18:29:51 +00:00
}
2020-11-23 07:15:33 +00:00
#hourglass
{
display: none;
padding-left: 1rem;
font-size: 110%;
color: #888;
}
#check-mark
{
display: none;
padding-left: 1rem;
font-size: 110%;
color: #080;
}
2020-10-19 18:29:51 +00:00
< / 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 >
2020-10-19 23:37:10 +00:00
< textarea autofocus spellcheck = "false" class = "monospace shadow" id = "query" > < / textarea >
2020-10-19 18:29:51 +00:00
< / div >
< div id = "run_div" >
2020-10-19 23:37:10 +00:00
< button class = "shadow" id = "run" > Run< / button >
2021-01-13 13:53:17 +00:00
< span class = "hint" > (Ctrl/Cmd+Enter)< / span >
2020-11-23 07:15:33 +00:00
< span id = "hourglass" > ⧗< / span >
< span id = "check-mark" > ✔< / span >
2020-10-19 18:29:51 +00:00
< span id = "stats" > < / span >
2020-10-19 23:37:10 +00:00
< span id = "toggle-dark" > 🌑< / span > < span id = "toggle-light" > 🌞< / span >
2020-10-19 18:29:51 +00:00
< / div >
< div id = "data_div" >
2020-10-19 23:37:10 +00:00
< table class = "monospace shadow" id = "data-table" > < / table >
< pre class = "monospace shadow" id = "data-unparsed" > < / pre >
2020-10-19 18:29:51 +00:00
< / div >
< p id = "error" class = "monospace shadow" >
< / p >
< / body >
< script type = "text/javascript" >
2020-10-19 23:37:10 +00:00
2020-11-23 06:35:08 +00:00
/// 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.
2021-03-16 16:31:25 +00:00
let request_num = 0;
2020-11-23 06:35:08 +00:00
/// Save query in history only if it is different.
2021-03-16 16:31:25 +00:00
let previous_query = '';
2020-11-23 06:35:08 +00:00
2020-10-19 23:37:10 +00:00
/// Substitute the address of the server where the page is served.
if (location.protocol != 'file:') {
document.getElementById('url').value = location.origin;
}
2020-11-23 08:05:13 +00:00
/// Substitute user name if it's specified in the query string
2021-03-16 16:31:25 +00:00
let user_from_url = (new URL(window.location)).searchParams.get('user');
2020-11-23 08:05:13 +00:00
if (user_from_url) {
document.getElementById('user').value = user_from_url;
}
2020-11-23 06:35:08 +00:00
function postImpl(posted_request_num, query)
2020-10-19 18:29:51 +00:00
{
2020-10-20 22:13:41 +00:00
/// TODO: Check if URL already contains query string (append parameters).
2021-03-16 16:31:25 +00:00
let user = document.getElementById('user').value;
let password = document.getElementById('password').value;
2020-11-23 08:05:13 +00:00
2021-03-16 16:31:25 +00:00
let url = document.getElementById('url').value +
2020-10-19 23:37:10 +00:00
/// Ask server to allow cross-domain requests.
2020-10-19 18:29:51 +00:00
'?add_http_cors_header=1' +
2020-11-23 08:05:13 +00:00
'& user=' + encodeURIComponent(user) +
'& password=' + encodeURIComponent(password) +
2020-10-19 18:29:51 +00:00
'& default_format=JSONCompact' +
2020-10-19 23:37:10 +00:00
/// Safety settings to prevent results that browser cannot display.
2020-10-19 18:29:51 +00:00
'&max_result_rows=1000&max_result_bytes=10000000&result_overflow_mode=break';
2021-03-16 16:31:25 +00:00
let xhr = new XMLHttpRequest;
2020-10-19 18:29:51 +00:00
xhr.open('POST', url, true);
2020-11-23 07:15:33 +00:00
2020-10-19 18:29:51 +00:00
xhr.onreadystatechange = function()
{
2020-11-23 06:35:08 +00:00
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) {
2021-03-16 16:31:25 +00:00
let state = {
2020-12-03 20:20:09 +00:00
query: query,
status: this.status,
response: this.response.length > 100000 ? null : this.response /// Lower than the browser's limit.
};
2021-03-16 16:31:25 +00:00
let title = "ClickHouse Query: " + query;
let url = window.location.pathname + '?user=' + encodeURIComponent(user) + '#' + window.btoa(query);
2020-12-03 20:20:09 +00:00
if (previous_query == '') {
history.replaceState(state, title, url);
} else {
history.pushState(state, title, url);
}
2020-11-23 06:35:08 +00:00
document.title = title;
2020-12-03 20:20:09 +00:00
previous_query = query;
2020-10-19 18:29:51 +00:00
}
} else {
2020-10-19 23:37:10 +00:00
//console.log(this);
2020-10-19 18:29:51 +00:00
}
}
2020-11-23 07:52:33 +00:00
document.getElementById('check-mark').style.display = 'none';
document.getElementById('hourglass').style.display = 'inline';
xhr.send(query);
2020-10-19 18:29:51 +00:00
}
2020-11-23 06:35:08 +00:00
function renderResponse(status, response) {
2020-11-23 07:15:33 +00:00
document.getElementById('hourglass').style.display = 'none';
2020-11-23 06:35:08 +00:00
if (status === 200) {
2021-03-16 16:31:25 +00:00
let json;
2020-11-23 06:35:08 +00:00
try { json = JSON.parse(response); } catch (e) {}
if (json !== undefined & & json.statistics !== undefined) {
renderResult(json);
} else {
renderUnparsedResult(response);
}
2020-11-23 07:15:33 +00:00
document.getElementById('check-mark').style.display = 'inline';
2020-11-23 06:35:08 +00:00
} 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;
2021-03-16 16:31:25 +00:00
let query = document.getElementById('query').value;
2020-11-23 06:35:08 +00:00
postImpl(request_num, query);
}
2020-10-19 18:29:51 +00:00
document.getElementById('run').onclick = function()
{
post();
}
2021-01-13 13:53:17 +00:00
document.onkeydown = function(event)
2020-10-19 18:29:51 +00:00
{
2020-10-20 01:08:30 +00:00
/// Firefox has code 13 for Enter and Chromium has code 10.
2021-01-13 14:29:11 +00:00
if ((event.metaKey || event.ctrlKey) & & (event.keyCode == 13 || event.keyCode == 10)) {
2020-10-19 18:29:51 +00:00
post();
}
}
function clear()
{
2021-03-16 16:31:25 +00:00
let table = document.getElementById('data-table');
2020-10-19 18:29:51 +00:00
while (table.firstChild) {
table.removeChild(table.lastChild);
}
2020-10-19 23:37:10 +00:00
document.getElementById('data-unparsed').innerText = '';
document.getElementById('data-unparsed').style.display = 'none';
2020-10-19 18:29:51 +00:00
document.getElementById('error').innerText = '';
document.getElementById('error').style.display = 'none';
2020-10-19 23:37:10 +00:00
document.getElementById('stats').innerText = '';
2020-11-23 07:15:33 +00:00
document.getElementById('hourglass').style.display = 'none';
document.getElementById('check-mark').style.display = 'none';
2020-10-19 18:29:51 +00:00
}
function renderResult(response)
{
//console.log(response);
clear();
2021-03-16 16:31:25 +00:00
let stats = document.getElementById('stats');
2020-10-19 18:29:51 +00:00
stats.innerText = 'Elapsed: ' + response.statistics.elapsed.toFixed(3) + " sec, read " + response.statistics.rows_read + " rows.";
2021-03-16 16:31:25 +00:00
let thead = document.createElement('thead');
for (let idx in response.meta) {
let th = document.createElement('th');
let name = document.createTextNode(response.meta[idx].name);
2020-10-19 18:29:51 +00:00
th.appendChild(name);
thead.appendChild(th);
}
2020-10-19 23:37:10 +00:00
/// 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.
2021-03-16 16:31:25 +00:00
let max_rows = 10000 / response.meta.length;
let row_num = 0;
2021-03-16 16:54:16 +00:00
let column_classes = response.meta.map(elem => elem.type.match(/^(U?Int|Decimal|Float)/) ? 'right' : 'left');
2021-03-16 16:31:25 +00:00
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];
2021-03-16 16:54:16 +00:00
2021-03-16 16:31:25 +00:00
let is_null = (cell === null);
2021-03-16 16:54:16 +00:00
/// 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;
}
td.appendChild(document.createTextNode(text));
td.className = column_classes[col_idx];
2020-10-19 18:29:51 +00:00
if (is_null) {
td.className += ' null';
}
tr.appendChild(td);
}
tbody.appendChild(tr);
++row_num;
if (row_num >= max_rows) {
break;
}
}
2021-03-16 16:31:25 +00:00
let table = document.getElementById('data-table');
2020-10-19 18:29:51 +00:00
table.appendChild(thead);
table.appendChild(tbody);
}
2020-10-20 01:08:30 +00:00
/// A function to render raw data when non-default format is specified.
2020-10-19 23:37:10 +00:00
function renderUnparsedResult(response)
{
clear();
2021-03-16 16:31:25 +00:00
let data = document.getElementById('data-unparsed')
2020-10-20 22:13:41 +00:00
if (response === '') {
/// TODO: Fade or remove previous result when new request will be performed.
response = 'Ok.';
}
2020-10-19 23:37:10 +00:00
data.innerText = response;
/// inline-block make width adjust to the size of content.
data.style.display = 'inline-block';
}
2020-10-19 18:29:51 +00:00
function renderError(response)
{
clear();
2020-11-23 07:52:33 +00:00
document.getElementById('error').innerText = response ? response : "No response.";
2020-10-19 18:29:51 +00:00
document.getElementById('error').style.display = 'block';
}
2020-10-19 23:37:10 +00:00
2020-10-20 00:32:50 +00:00
function setColorTheme(theme)
2020-10-19 23:37:10 +00:00
{
2020-10-20 00:32:50 +00:00
window.localStorage.setItem('theme', theme);
document.documentElement.setAttribute('data-theme', theme);
2020-10-19 23:37:10 +00:00
}
2020-10-20 01:08:30 +00:00
/// The choice of color theme is saved in browser.
2021-03-16 16:31:25 +00:00
let theme = window.localStorage.getItem('theme');
2020-10-20 00:32:50 +00:00
if (theme) {
setColorTheme(theme);
2020-12-13 17:54:57 +00:00
} else {
/// Obtain system-level user preference
2021-03-16 16:31:25 +00:00
let media_query_list = window.matchMedia('prefers-color-scheme: dark')
2020-12-13 17:54:57 +00:00
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');
}
});
2020-10-19 23:37:10 +00:00
}
document.getElementById('toggle-light').onclick = function()
{
2020-10-20 00:32:50 +00:00
setColorTheme('light');
2020-10-19 23:37:10 +00:00
}
document.getElementById('toggle-dark').onclick = function()
{
2020-10-20 00:32:50 +00:00
setColorTheme('dark');
2020-10-19 23:37:10 +00:00
}
2020-10-19 18:29:51 +00:00
< / script >
< / html >