Files
datastore/programs/server/play.html
T

4435 lines
165 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<link rel="icon" href="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI1NCIgaGVpZ2h0PSI0OCIgdmlld0JveD0iMCAwIDExIDEwIj48c3R5bGU+LmJne2ZpbGw6I2ZmMH0ub3tmaWxsOiMwMDB9PC9zdHlsZT48cmVjdCBjbGFzcz0iYmciIHg9IjAiIHk9IjAiIHdpZHRoPSIxMDAlIiBoZWlnaHQ9IjEwMCUiLz48cGF0aCBkPSJNMSwxIGgxIHY4IGgtMSB6IiBjbGFzcz0ibyIvPjxwYXRoIGQ9Ik0zLDEgaDEgdjggaC0xIHoiIGNsYXNzPSJvIi8+PHBhdGggZD0iTTUsMSBoMSB2OCBoLTEgeiIgY2xhc3M9Im8iLz48cGF0aCBkPSJNNywxIGgxIHY4IGgtMSB6IiBjbGFzcz0ibyIvPjxwYXRoIGQ9Ik05LDQuMjUgaDEgdjEuNSBoLTEgeiIgY2xhc3M9Im8iLz48L3N2Zz4K">
<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 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 except the progress bar.
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.
Nice to have:
- show/hide the editor
- check for readonly
- query caching
- history navigation
- short links by saving in a special table
- sharing by creating a short-living user
- notebook mode
-->
<style>
:root {
--background-color-rotate: 215;
--background-color: oklch(96% 0.0296 var(--background-color-rotate));
--element-background-color: #FFF;
--element-color-ok: #080;
--element-background-color-fail: #FEE;
--bar-color: #FF8; /* Light bar in background of table cells. */
--bar-color-negative: #FEE;
--border-color: #CCC;
--shadow-color: rgba(0, 0, 0, 0.1);
--button-color: #FF0;
--text-color: #000;
--text-color-active: #000;
--button-active-color: #FE0;
--button-active-text-color: #000;
--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: #EEE;
--table-hover-filter: brightness(0.9);
--link-color: #06D;
--logo-color: #FFF;
--border-color-selected: #F80;
--menu-toggle-color: #000;
--progress-color: #0C4;
--table-size-bar-color: #FFF;
--selection-color: #FFF;
--selection-background-color: #456;
--current-query-background-color: #FFF4E0;
}
[data-theme="dark"] {
--background-color-rotate: 250;
--background-color: oklch(15% 0.0296 var(--background-color-rotate));
--element-background-color: #000;
--element-color-ok: #8F8;
--element-background-color-fail: #300;
--bar-color: #320;
--bar-color-negative: #311;
--border-color: #444;
--shadow-color: rgba(0, 0, 0, 0.1);
--text-color: #CCC;
--text-color-active: #FFF;
--button-color: rgb(250 255 105);
--button-text-color: #000;
--button-active-color: #FE0;
--button-active-text-color: #000;
--misc-text-color: #888;
--error-color: #400;
--table-header-color: #102020;
--table-hover-filter: contrast(0.9) brightness(2);
--link-color: #4BDAF7;
--logo-color: #444;
--border-color-selected: #FF0;
--menu-toggle-color: #FF0;
--progress-color: #FF0;
--table-size-bar-color: #104050;
--selection-color: #000;
--selection-background-color: #FF8;
--current-query-background-color: #442;
}
::selection {
color: var(--selection-color);
background-color: var(--selection-background-color);
}
*
{
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;
scrollbar-color: var(--border-color) var(--element-background-color);
}
html
{
/* The fonts that have full support for hinting. */
font-family: Roboto Mono, Liberation Sans, DejaVu Sans, sans-serif, Noto Color Emoji, Apple Color Emoji, Segoe UI Emoji;
background: var(--background-color);
color: var(--text-color);
}
body
{
position: relative;
display: grid;
grid-template-columns: auto 1fr;
grid-template-rows: auto 1fr;
grid-template-areas: "terminal terminal" "menu main";
}
#menu
{
padding: 0.25rem;
grid-area: menu;
position: sticky;
top: 0;
z-index: 1;
height: 100vh;
align-self: start;
background: var(--table-header-color);
border-right: 1px solid var(--border-color);
display: grid;
grid-template-rows: auto 1fr auto;
grid-template-areas: "databases-icon" "contents" "terminal-icon" "github-icon";
cursor: e-resize;
min-height: 0;
overflow-x: visible;
overflow-y: clip;
}
#main
{
overflow: auto;
grid-area: main;
padding: 0.5rem;
position: relative;
z-index: 0; /* stacking context so children don't overlap #menu */
}
.menu-icon
{
width: 2.25em;
cursor: pointer;
user-select: none;
fill: var(--menu-toggle-color);
}
.menu-icon:hover
{
transform: translate(1px, 1px);
}
#databases-icon
{
grid-area: databases-icon;
text-decoration: none;
display: block;
}
#databases
{
grid-area: contents;
max-width: 25em;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: var(--border-color) transparent;
}
.tables:not(:empty)
{
margin-bottom: 1em;
}
#terminal-icon
{
grid-area: terminal-icon;
margin-bottom: 0.25rem;
}
#terminal-icon.active svg
{
fill: var(--button-color);
}
#terminal-panel
{
grid-area: terminal;
display: none;
position: relative;
background: #000;
border-bottom: 2px solid var(--border-color);
}
#terminal-panel.active
{
display: block;
}
#terminal-iframe
{
width: 100%;
height: 100%;
border: none;
display: block;
}
#terminal-resizer
{
position: absolute;
bottom: -0.3rem;
left: 0;
width: 100%;
height: 0.5rem;
cursor: row-resize;
user-select: none;
z-index: 10;
}
#terminal-resizer:hover, .terminal-resizer-dragging
{
border-radius: 0.25rem;
background-color: var(--border-color);
}
#github-icon
{
grid-area: github-icon;
}
#github-icon-light, #github-icon-dark
{
display: none;
}
[data-theme="light"] #github-icon-light, [data-theme="dark"] #github-icon-dark
{
display: block;
}
.database button
{
cursor: pointer;
background: transparent;
font-size: 100%;
padding: 0;
user-select: none;
color: var(--text-color);
&:hover {
text-decoration: 1px underline dotted var(--text-color);
}
}
.database button:hover
{
color: var(--button-text-color);
background-color: var(--button-color);
}
.database button:focus
{
color: var(--button-text-color);
background-color: var(--button-color);
}
.loading-hourglass
{
display: inline-block;
margin-left: 0.5em;
color: var(--misc-text-color);
animation: hourglass-animation 2s linear infinite;
line-height: 0;
vertical-align: middle;
}
.no-tables
{
color: var(--misc-text-color);
}
.current
{
font-weight: bold;
}
.table
{
background-repeat: no-repeat;
}
.table button
{
display: inline-block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: middle;
}
.table-icon
{
width: 1em;
height: 1em;
vertical-align: -0.1em;
margin-right: 0.25em;
flex-shrink: 0;
opacity: 0.5;
}
.table-addendum
{
position: fixed;
z-index: 999;
white-space: pre;
transform: translateY(-50%);
color: var(--element-background-color);
background: color-mix(in srgb, var(--text-color) 50%, var(--background-color));
backdrop-filter: blur(4px);
border-radius: 0.5rem;
padding: 0.25rem;
margin-left: 0.25rem;
opacity: 0;
pointer-events: none;
transition: opacity 0s 0.1s;
}
.table-addendum::before {
content: " ";
position: absolute;
top: 50%;
transform: translate(calc(-100% - 0.2rem), -50%);
line-height: 0;
border-width: 0.5rem;
border-style: solid;
border-color: transparent color-mix(in srgb, var(--text-color) 50%, var(--background-color)) transparent transparent;
}
.table button:hover + .table-addendum,
.table .table-addendum:hover
{
opacity: 1;
pointer-events: auto;
transition: opacity 0s 0.1s;
}
/* While suppress-balloons is set on the body (set on scroll, cleared on the
next mousemove), no balloon may appear from a :hover state. This prevents
a different table button from scrolling under a stationary cursor and
revealing its balloon without `mouseenter` having fired to position it. */
body.suppress-balloons .table button:hover + .table-addendum,
body.suppress-balloons .table .table-addendum:hover
{
opacity: 0;
pointer-events: none;
}
.table-addendum-elem
{
display: block;
cursor: pointer;
&:hover {
text-decoration: 1px underline dotted var(--element-background-color);
}
}
#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: 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);
}
input:focus, textarea:focus
{
color: var(--text-color-active);
}
#query
{
display: block;
position: relative;
z-index: 1;
/* 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. */
width: 100%;
min-width: 100%;
max-width: 100%;
margin-top: -1px; /* To compensate the border. */
resize: none; /* Disable default browser resizer */
}
/* When the backdrop is active, the textarea becomes transparent so
the colored query spans behind it are visible. */
#query.has-backdrop
{
background: transparent;
}
#query-backdrop
{
display: none;
position: absolute;
inset: 0;
overflow: hidden;
pointer-events: none;
z-index: 0;
/* Typography MUST match the textarea exactly. */
font-family: DejaVu Sans Mono, Liberation Mono, MonoLisa, Consolas, monospace;
font-size: 11pt;
line-height: normal;
padding: 0.25rem;
border: 1px solid transparent; /* same box model as textarea */
white-space: pre-wrap;
word-wrap: break-word;
overflow-wrap: break-word;
background-color: var(--element-background-color);
color: transparent; /* text is invisible; only backgrounds are visible */
}
#query-backdrop .q-active { background-color: var(--current-query-background-color); }
#inputs
{
white-space: nowrap;
}
#url
{
width: 70%;
}
#user, #password
{
width: calc(15% + 1px);
margin-left: -1px;
}
input.ok
{
color: var(--element-color-ok);
}
input.fail
{
background-color: var(--element-background-color-fail);
}
#run_div
{
margin-top: 0.5rem;
display: flex;
align-items: center;
gap: 1rem;
}
query-progress
{
flex: 1;
}
#run, #run-all
{
grid-area: run;
user-select: none;
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 a lower font size. */
}
#run-all
{
display: none;
}
#run:hover, #run:focus, #run-all:hover, #run-all:focus, #copy-div:hover, #copy-div:focus, #download-div:hover, #download-div:focus
{
color: var(--button-active-text-color);
background-color: var(--button-active-color);
}
#hint
{
grid-area: hint;
color: var(--misc-text-color);
}
#copy-div
{
display: none;
user-select: none;
color: var(--button-text-color);
background-color: var(--button-color);
padding: 0.25rem 0.65rem;
cursor: pointer;
position: relative;
}
.copy-balloon
{
position: absolute;
bottom: calc(100% + 0.25rem);
left: 50%;
transform: translateX(-50%);
background-color: var(--button-color);
color: var(--button-text-color);
padding: 0.25rem 0.5rem;
white-space: nowrap;
font-size: 0.85em;
opacity: 0;
animation: balloon-fade 1s ease-out;
pointer-events: none;
}
@keyframes balloon-fade
{
0% { opacity: 1; }
70% { opacity: 1; }
100% { opacity: 0; }
}
#copy-icon
{
width: 1em;
height: 1em;
vertical-align: middle;
}
#download-div
{
grid-area: download;
display: none;
user-select: none;
color: var(--button-text-color);
background-color: var(--button-color);
padding: 0.25rem 0.65rem;
cursor: pointer;
position: relative;
}
#download
{
width: 1em;
height: 1em;
vertical-align: middle;
}
#download-dropdown
{
display: none;
position: absolute;
background-color: var(--button-color);
border: 1px solid var(--button-text-color);
padding: 0.5rem;
z-index: 999;
box-shadow: 0 4px 6px var(--shadow-color);
}
#download-dropdown.show
{
display: block;
}
.download-option
{
margin-bottom: 0.5rem;
display: flex;
align-items: center;
gap: 0.25rem;
}
.download-option label
{
color: var(--button-text-color);
white-space: nowrap;
margin-right: 0.5rem;
}
.download-option select,
#download-format-other
{
flex: 1;
padding: 0.25rem;
background-color: var(--element-background-color);
color: var(--text-color);
border: 1px solid var(--shadow-color);
&:focus {
outline: none;
}
}
#download-format-other
{
display: none;
margin-top: 0.25rem;
margin-bottom: 0.5rem;
}
#download-format-other.show
{
display: block;
}
#download-button
{
width: 100%;
margin-top: 0rem;
padding: 0.5rem 0.5rem;
background-color: var(--element-background-color);
color: var(--text-color);
border: none;
cursor: pointer;
font-weight: bold;
&:hover {
background-color: var(--button-active-color);
color: var(--button-active-text-color);
}
}
#theme
{
grid-area: theme;
}
#toggle-light, #toggle-dark
{
cursor: pointer;
user-select: none;
}
[data-theme="light"] #toggle-light, [data-theme="dark"] #toggle-dark
{
display: none;
}
.textarea-resizer {
position: absolute;
bottom: -0.3rem;
left: 0;
width: 100%;
height: 0.5rem;
cursor: row-resize;
user-select: none;
}
.textarea-resizer:hover, .textarea-resizer-dragging
{
border-radius: 0.25rem;
background-color: var(--border-color);
}
#query_div {
position: relative;
}
#query-params
{
display: flex;
flex-wrap: wrap;
gap: 0.25rem 0.5rem;
margin-top: 0.25rem;
align-items: center;
}
.query-param
{
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.query-param label
{
color: var(--misc-text-color);
white-space: nowrap;
}
.query-param input
{
width: 10em;
max-width: 20em;
}
a, a:visited
{
color: var(--link-color);
text-decoration: none;
}
#logo
{
fill: var(--logo-color);
}
#cloud-logo
{
color: transparent;
text-shadow: 0 0 0 var(--logo-color);
font-size: 10vw;
display: block;
}
#logo-container
{
text-align: center;
margin-top: 5em;
line-height: 0.75;
position: sticky;
left: 0;
z-index: -1; /* Below the expanded selected cell */
}
#url_status
{
position: absolute;
display: inline-block;
font-size: 11pt;
padding: 0.25rem;
transform: translateX(-100%);
color: transparent;
user-select: none;
}
#url_status.ok
{
color: #0F0;
}
#url_status.fail
{
color: #F00;
}
#server_info
{
color: var(--misc-text-color);
}
/* Prevents a different color when Chromium auto-fills inputs from the list. */
input:-webkit-autofill,
input:-webkit-autofill:hover,
input:-webkit-autofill:focus,
input:-webkit-autofill:active
{
transition: background-color 600000s 0s, color 600000s 0s;
}
</style>
</head>
<body>
<div id="menu">
<a id="databases-icon" class="menu-icon" href="">
<svg id="databases-toggle" viewBox="0 0 9 9"><path d="M0,0 h1 v8 h-1 z"/><path d="M2,0 h1 v8 h-1 z"/><path d="M4,0 h1 v8 h-1 z"/><path d="M6,0 h1 v8 h-1 z"/><path d="M8,3.25 h1 v1.5 h-1 z"/></svg>
</a>
<div id="databases"></div>
<div id="terminal-icon" class="menu-icon" title="Web Terminal (~)">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><rect x="1" y="1" width="22" height="22" rx="3" fill="none" stroke="currentColor" stroke-width="2"/><path d="M6 8l4 4-4 4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><line x1="13" y1="16" x2="18" y2="16" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
</div>
<!-- https://github.com/logos -->
<div id="github-icon" class="menu-icon">
<a href="https://github.com/ClickHouse/ClickHouse/">
<svg id="github-icon-light" viewBox="0 0 98 96" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z" fill="#24292f"/></svg>
<svg id="github-icon-dark" viewBox="0 0 98 96" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z" fill="#fff"/></svg>
</a>
</div>
</div>
<div id="terminal-panel">
<div id="terminal-resizer"></div>
</div>
<div id="main">
<form id="controls">
<div id="inputs">
<input name="url" spellcheck="false" class="monospace shadow" id="url" type="url" value="" placeholder="url" /><span id="url_status"><span id="server_info"></span>●</span><input name="user" spellcheck="false" class="monospace shadow" id="user" type="text" value="default" placeholder="user" /><input name="password" class="monospace shadow" id="password" type="password" placeholder="password" />
</div>
<div id="query_div"><div id="query-backdrop"></div><textarea autofocus spellcheck="false" autocorrect="false" autocomplete="false" data-gramm="false" class="monospace shadow" id="query"></textarea></div>
<div id="query-params"></div>
<div id="run_div">
<button type="submit" class="shadow" id="run">Run</button>
<button type="button" class="shadow" id="run-all">Run all</button>
<div id="hint">&nbsp;(Ctrl/Cmd+Enter, +Shift to run all)</div>
<query-progress id="query-progress"></query-progress>
<div id="copy-div" class="shadow" title="Copy raw result to clipboard">
<svg id="copy-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg>
</div>
<div id="download-div" class="shadow">
<svg id="download" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
</div>
<div id="theme"><span id="toggle-dark">🌘</span><span id="toggle-light">☀️</span></div>
</div>
</form>
<query-result id="query-result"></query-result>
<p id="logo-container">
<a href="https://clickhouse.com/">
<svg id="logo" width="50%" viewBox="0 0 180 28" version="1.1" xmlns="http://www.w3.org/2000/svg"><title>Powered by ClickHouse</title><g transform="translate(21, 0)"><path d="M9.76,20.95 C8.43,20.97 7.11,20.73 5.87,20.26 C4.72,19.80 3.67,19.11 2.81,18.22 C1.90,17.32 1.20,16.25 0.74,15.07 C-0.24,12.29 -0.24,9.27 0.74,6.49 C1.67,4.07 3.61,2.16 6.08,1.26 C7.35,0.79 8.71,0.55 10.07,0.57 C11.16,0.54 12.24,0.67 13.30,0.93 C14.15,1.16 14.98,1.47 15.78,1.87 L15.03,4.17 C14.33,3.70 13.57,3.32 12.78,3.03 C11.83,2.71 10.83,2.56 9.82,2.58 C7.96,2.56 6.18,3.35 4.96,4.74 C4.30,5.50 3.79,6.38 3.47,7.33 C3.08,8.46 2.89,9.65 2.91,10.85 C2.90,12.02 3.07,13.18 3.43,14.29 C3.73,15.23 4.21,16.09 4.86,16.84 C5.46,17.52 6.21,18.06 7.05,18.41 C7.95,18.78 8.92,18.96 9.90,18.96 C10.80,18.96 11.68,18.82 12.53,18.55 C13.34,18.27 14.12,17.91 14.85,17.47 L15.57,19.20 C14.87,19.72 14.09,20.13 13.25,20.40 C12.13,20.78 10.95,20.97 9.76,20.95 L9.76,20.95 Z"></path><polygon points="21.20 0 21.20 20.67 18.73 20.67 18.73 0 21.20 0"></polygon><path d="M26.51,4.15 C26.09,4.18 25.68,4.03 25.38,3.74 C25.08,3.46 24.92,3.05 24.94,2.64 C24.92,2.23 25.07,1.83 25.35,1.52 C25.99,0.92 26.99,0.92 27.63,1.52 C27.92,1.82 28.07,2.23 28.06,2.64 C28.07,3.05 27.92,3.44 27.63,3.72 C27.33,4.01 26.93,4.16 26.51,4.15 L26.51,4.15 Z M27.75,6.31 L27.75,20.56 L25.31,20.56 L25.31,6.31 L27.77,6.31 L27.75,6.31 Z"></path><path d="M38.20,20.95 C37.23,20.96 36.27,20.78 35.36,20.44 C34.50,20.12 33.72,19.62 33.07,18.98 C32.40,18.30 31.88,17.51 31.54,16.63 C30.78,14.61 30.78,12.39 31.54,10.36 C31.89,9.48 32.43,8.68 33.11,8.02 C33.80,7.36 34.62,6.85 35.53,6.53 C36.46,6.19 37.45,6.02 38.44,6.02 C39.23,6.02 40.02,6.11 40.78,6.29 C41.40,6.43 41.99,6.67 42.52,7.00 L41.73,9.14 C41.23,8.76 40.68,8.44 40.10,8.18 C39.49,7.95 38.85,7.84 38.20,7.86 C37.57,7.85 36.95,7.97 36.38,8.20 C35.80,8.44 35.28,8.80 34.87,9.26 C34.42,9.77 34.07,10.36 33.85,10.99 C33.58,11.78 33.45,12.61 33.46,13.44 C33.36,14.92 33.85,16.38 34.83,17.51 C35.81,18.45 37.15,18.94 38.53,18.87 C39.72,18.85 40.89,18.48 41.88,17.81 L42.44,19.61 C41.88,20.02 41.26,20.33 40.60,20.52 C39.82,20.79 39.01,20.93 38.20,20.95 L38.20,20.95 Z"></path><path d="M47.94,0 L47.94,20.67 L45.44,20.67 L45.44,0 L47.94,0 Z M54.35,20.67 L48.27,13.01 L54.27,6.33 L56.83,6.33 L50.90,12.72 L57.24,20.67 L54.35,20.67 L54.35,20.67 Z"></path><polygon points="73.11 11.87 62.77 11.87 62.77 20.65 60.10 20.65 60.10 0.85 62.77 0.85 62.77 9.89 73.11 9.89 73.11 0.85 75.78 0.85 75.78 20.67 73.11 20.67"></polygon><path d="M86.30,20.95 C85.38,20.95 84.47,20.78 83.61,20.44 C82.78,20.12 82.01,19.63 81.38,19.00 C80.72,18.32 80.21,17.53 79.87,16.65 C79.12,14.60 79.12,12.35 79.87,10.30 C80.21,9.43 80.72,8.64 81.38,7.98 C82.02,7.35 82.78,6.86 83.61,6.53 C84.47,6.20 85.38,6.02 86.30,6.02 C87.23,6.02 88.15,6.20 89.01,6.53 C89.86,6.86 90.62,7.35 91.27,7.98 C91.94,8.64 92.47,9.43 92.82,10.30 C93.59,12.35 93.59,14.60 92.82,16.65 C92.50,17.52 92.00,18.32 91.37,19.00 C90.73,19.63 89.96,20.12 89.12,20.44 C88.22,20.79 87.27,20.96 86.30,20.95 Z M86.30,19.10 C86.88,19.10 87.46,18.99 88.00,18.79 C88.56,18.59 89.07,18.25 89.47,17.81 C89.93,17.31 90.28,16.71 90.50,16.06 C90.78,15.23 90.91,14.35 90.89,13.48 C90.92,12.60 90.78,11.72 90.50,10.89 C90.28,10.25 89.93,9.66 89.47,9.16 C89.07,8.72 88.56,8.39 88.00,8.18 C87.46,7.98 86.88,7.88 86.30,7.88 C85.74,7.88 85.18,7.98 84.65,8.18 C84.10,8.39 83.61,8.73 83.22,9.16 C82.77,9.66 82.42,10.25 82.21,10.89 C81.94,11.73 81.81,12.60 81.84,13.48 C81.81,14.35 81.94,15.23 82.21,16.06 C82.42,16.71 82.76,17.31 83.22,17.81 C83.61,18.25 84.10,18.58 84.65,18.79 C85.18,18.99 85.74,19.10 86.30,19.10 L86.30,19.10 Z"></path><path d="M104.21,20.12 C103.26,20.71 102.14,21.00 101.01,20.95 C99.79,21.00 98.61,20.56 97.74,19.73 C96.83,18.70 96.38,17.34 96.50,15.98 L96.50,6.31 L98.98,6.31 L98.98,15.98 C98.97,16.47 99.04,16.95 99.19,17.41 C99.30,17.76 99.50,18.07 99.77,18.32 C100.01,18.55 100.31,18.72 100.64,18.81 C100.99,18.91 101.35,18.96 101.71,18.96 C102.65,19.00 103.58,18.73 104.34,18.18 C105.05,17.63 105.63,16.94 106.03,16.14 L106.03,6.31 L108.49,6.31 L108.49,20.56 L106.32,20.56 L106.10,18.00 L106.10,18.00 C105.68,18.86 105.03,19.60 104.21,20.12 L104.21,20.12 Z"></path><path d="M116.42,20.95 C115.80,20.99 115.17,20.99 114.55,20.95 C114.09,20.89 113.62,20.80 113.17,20.69 C112.81,20.60 112.46,20.48 112.11,20.34 C111.83,20.22 111.55,20.07 111.29,19.91 L112.09,17.88 C112.27,18.02 112.47,18.15 112.67,18.26 C112.97,18.43 113.28,18.57 113.60,18.69 C114.01,18.85 114.43,18.97 114.86,19.06 C115.38,19.16 115.90,19.20 116.42,19.20 C117.26,19.26 118.09,19.04 118.79,18.59 C119.33,18.19 119.63,17.56 119.60,16.90 C119.62,16.34 119.38,15.80 118.94,15.45 C118.24,14.98 117.47,14.63 116.64,14.43 L114.86,13.84 C114.33,13.66 113.82,13.39 113.36,13.07 C112.91,12.76 112.55,12.35 112.28,11.89 C111.99,11.34 111.84,10.72 111.87,10.10 C111.79,8.92 112.33,7.79 113.29,7.10 C114.46,6.39 115.83,6.05 117.20,6.13 C118.07,6.11 118.94,6.19 119.79,6.37 C120.38,6.49 120.97,6.68 121.52,6.92 L120.95,8.96 C120.47,8.73 119.99,8.52 119.50,8.34 C118.79,8.11 118.05,8.00 117.31,8.02 C116.53,7.98 115.76,8.13 115.05,8.45 C114.47,8.75 114.14,9.37 114.20,10.01 C114.20,10.33 114.29,10.63 114.47,10.89 C114.66,11.15 114.90,11.36 115.18,11.52 C115.50,11.72 115.85,11.87 116.21,11.99 L117.45,12.36 L119.13,12.91 C119.66,13.10 120.16,13.36 120.61,13.70 C121.60,14.41 122.15,15.56 122.08,16.76 C122.12,17.46 121.95,18.17 121.61,18.79 C121.30,19.33 120.86,19.79 120.35,20.14 C119.80,20.50 119.19,20.76 118.55,20.91 C117.84,21.01 117.13,21.02 116.42,20.95 Z"></path><path d="M136.37,19.42 C135.76,19.91 135.06,20.28 134.31,20.50 C133.39,20.80 132.43,20.95 131.47,20.93 C129.48,21.04 127.53,20.30 126.14,18.89 C124.81,17.36 124.15,15.38 124.28,13.38 C124.26,12.31 124.43,11.26 124.79,10.26 C125.11,9.39 125.59,8.60 126.22,7.92 C126.79,7.29 127.50,6.79 128.29,6.45 C129.13,6.11 130.03,5.94 130.93,5.94 C131.84,5.93 132.74,6.10 133.58,6.45 C134.35,6.77 135.02,7.28 135.53,7.94 C136.06,8.66 136.42,9.50 136.56,10.38 C136.75,11.50 136.75,12.64 136.56,13.76 L126.72,13.76 C126.83,17.17 128.46,18.87 131.62,18.87 C132.38,18.89 133.13,18.77 133.85,18.53 C134.49,18.30 135.10,18.00 135.67,17.63 L136.37,19.42 Z M131.02,7.73 C130.01,7.72 129.05,8.12 128.35,8.83 C127.52,9.75 127.03,10.91 126.94,12.13 L134.33,12.13 C134.53,10.97 134.25,9.78 133.56,8.81 C132.93,8.09 131.99,7.69 131.02,7.73 Z"></path></g></svg>
</a>
<a id="cloud-logo" href="https://clickhouse.cloud/">&#9729;</a>
</p>
<div id="download-dropdown">
<div>
<div class="download-option">
<label for="download-format">Format:</label>
<select id="download-format">
<option value="CSV">CSV</option>
<option value="TSV">TSV</option>
<option value="JSON">JSON</option>
<option value="NDJSON" selected>JSONLines</option>
<option value="Parquet">Parquet</option>
<option value="Markdown">Markdown</option>
<option value="other">Other...</option>
</select>
</div>
<input type="text" id="download-format-other" placeholder="Enter format name (e.g., Arrow, Avro)" />
</div>
<button id="download-button">Download</button>
</div>
</div>
</body>
<script type="text/javascript">
class QueryProgressElement extends HTMLElement
{
constructor()
{
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<style>
:host
{
display: grid;
grid-template-columns: auto 1fr auto;
gap: 1rem;
align-items: center;
}
#hourglass, #check-mark
{
grid-column: 1;
grid-row: 1;
}
#hourglass
{
display: none;
font-size: 110%;
color: #888;
animation: hourglass-animation 2s linear infinite;
}
#check-mark
{
display: none;
font-size: 110%;
color: #080;
}
#progress
{
display: none;
width: 100%;
color: var(--misc-text-color);
background: linear-gradient(to right,
transparent 0%,
var(--progress-color) var(--progress, 0%),
transparent var(--progress, 0%));
}
#stats
{
color: var(--misc-text-color);
white-space: nowrap;
}
#stats b
{
color: var(--progress-color);
}
@keyframes hourglass-animation
{
0% { transform: none; }
25% { transform: rotate(180deg); }
50% { transform: rotate(180deg); }
75% { transform: rotate(360deg); }
100% { transform: rotate(360deg); }
}
</style>
<span id="hourglass">&#10711;</span>
<span id="check-mark">&#10004;</span>
<span id="progress"></span>
<span id="stats"></span>
`;
this._hourglass = this.shadowRoot.getElementById('hourglass');
this._checkMark = this.shadowRoot.getElementById('check-mark');
this._progress = this.shadowRoot.getElementById('progress');
this._stats = this.shadowRoot.getElementById('stats');
}
clear()
{
this._hourglass.style.display = 'none';
this._checkMark.style.display = 'none';
this._stats.innerText = '';
this._progress.style.display = 'none';
this._progress.innerText = '';
this.style.setProperty('--progress', '0%');
}
start()
{
this._checkMark.style.display = 'none';
this._hourglass.style.display = 'inline-block';
}
finish()
{
this._hourglass.style.display = 'none';
this._checkMark.style.display = 'inline-block';
}
updateProgress(progress)
{
const rows = +progress.read_rows;
const bytes = +progress.read_bytes;
const total_rows = +progress.total_rows_to_read;
const elapsed_ns = +progress.elapsed_ns;
let formatted_rows = formatReadableRows(rows);
let formatted_bytes = formatReadableBytes(bytes);
const rps = rows * 1e9 / elapsed_ns;
const bps = bytes * 1e9 / elapsed_ns;
let formatted_rps = formatReadableRows(rps) + '/sec';
let formatted_bps = formatReadableBytes(bps) + '/sec';
if (rows >= 1e11) { formatted_rows = `<b>${formatted_rows}<\/b>`; }
if (bytes >= 1e12) { formatted_bytes = `<b>${formatted_bytes}<\/b>`; }
if (rps >= 1e10) { formatted_rps = `<b>${formatted_rps}<\/b>`; }
if (bps >= 1e10) { formatted_bps = `<b>${formatted_bps}<\/b>`; }
let text = '';
if (total_rows) { text += (100 * Math.min(1.0, rows / total_rows)).toFixed(1) + '%, '; }
text += `Read ${formatted_rows} rows, ${formatted_bytes}`;
if (rps > 1e6) { text += ` (${formatted_rps}, ${formatted_bps})`; }
this._stats.innerHTML = text;
this.style.setProperty('--progress',
rows && total_rows ? (100 * rows / total_rows) + '%' : '0');
}
updateText(rowCount, elapsedNs, incomplete)
{
let progress_text = '';
if (rowCount)
{
progress_text += `${rowCount}${incomplete ? '+' : ''} rows in result`;
if (elapsedNs) progress_text += ', ';
}
if (elapsedNs) { progress_text += `${(elapsedNs / 1e9).toFixed(2)} sec.`; }
this._progress.innerText = progress_text;
this._progress.style.display = 'block';
}
}
customElements.define('query-progress', QueryProgressElement);
class QueryResultElement extends HTMLElement
{
static _loadDagrePromise = null;
static _loadUplotPromise = null;
constructor()
{
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<style>
*
{
box-sizing: border-box;
}
:host
{
display: block;
}
.monospace
{
font-family: DejaVu Sans Mono, Liberation Mono, MonoLisa, Consolas, monospace;
}
.shadow
{
box-shadow: 0 0 1rem var(--shadow-color);
}
::selection
{
color: var(--selection-color);
background-color: var(--selection-background-color);
}
a, a:visited
{
color: var(--link-color);
text-decoration: none;
}
#data_div
{
margin-top: 1rem;
background-color: var(--element-background-color);
}
#data_div.fixed
{
width: fit-content;
}
#data-table
{
width: 100%;
border-spacing: 0;
background-color: var(--element-background-color);
border-collapse: collapse;
}
#data-table.fixed
{
table-layout: fixed;
width: var(--table-width);
}
#data-unparsed
{
width: fit-content;
background-color: var(--element-background-color);
margin-top: 0rem;
padding: 0.25rem 0.5rem;
display: none;
}
td
{
background-color: var(--element-background-color);
max-width: 40vw;
padding: 0; /* Padding is on .cell-content instead */
border: 1px solid var(--border-color);
white-space: pre;
vertical-align: top;
}
/* Inner div limits cell height to 3 lines; expands on selection */
.cell-content
{
padding: 0.25rem 0.5rem;
max-height: 3lh;
overflow: hidden;
}
td u
{
text-decoration-color: var(--misc-text-color);
}
.right
{
text-align: right;
}
th
{
position: sticky;
top: -0.5rem;
padding: 0.25rem 0.5rem;
text-align: center;
background-color: var(--table-header-color);
border: 1px solid var(--border-color);
}
th .name
{
display: block;
overflow: hidden;
text-overflow: ellipsis;
}
.type-hint
{
display: none;
position: absolute;
user-select: none;
left: 0;
top: -1px;
font-size: 75%;
font-weight: normal;
color: var(--misc-text-color);
background: var(--element-background-color);
max-height: 5em;
white-space: pre;
}
thead .type-hint
{
transform: translateY(-100%);
}
th:hover .type-hint
{
display: inline-block;
}
.resizer
{
position: absolute;
top: 0;
right: -0.3rem;
width: 0.5rem;
cursor: col-resize;
user-select: none;
height: 100%;
}
.resizer:hover, .resizer-dragging
{
border-radius: 0.25rem;
background-color: var(--border-color-selected);
}
/* Apply hover/selection filter per-cell, excluding the active cell,
because filter on tr would create a stacking context trapping it. */
tr:hover td:not(.td-selected)
{
filter: var(--table-hover-filter);
}
.tr-selected
{
position: relative;
z-index: 1; /* Above hovered rows */
}
.tr-selected td:not(.td-selected)
{
filter: var(--table-hover-filter);
}
#error
{
background: var(--error-color);
white-space: pre-wrap;
padding: 0.5rem 1rem;
display: none;
}
.cell-copy
{
display: none;
position: absolute;
bottom: 100%;
right: -1px;
padding: 0.25rem;
width: fit-content;
cursor: pointer;
filter: invert(1);
background-color: var(--element-background-color);
z-index: 1;
}
/* First row after a sticky header: place to the side to avoid overlap */
thead + tbody tr:first-child .cell-copy
{
bottom: auto;
left: 100%;
top: -1px;
}
thead + tbody tr:first-child td:last-child .cell-copy
{
left: auto;
right: 100%;
}
.cell-copy svg
{
width: 1em;
height: 1em;
vertical-align: middle;
}
.td-selected .cell-copy
{
display: block;
}
/* Selected cell stacks above all other cells */
.td-selected
{
position: relative;
z-index: 10;
}
/* Expand the inner div on top of the table without reflowing it */
.td-selected .cell-content
{
position: absolute;
top: -1px;
left: -1px;
width: calc(100% + 2px); /* Compensate for collapsed border (1px each side) */
min-height: calc(100% + 2px);
max-height: 50vh;
white-space: pre-wrap;
word-break: break-all;
overflow-y: auto;
scrollbar-width: thin;
filter: invert(1);
background-color: var(--element-background-color);
}
td.empty-result
{
text-align: center;
vertical-align: middle;
}
.row-number
{
width: 1%;
text-align: right;
padding: 0.25rem 0.5rem;
background-color: var(--table-header-color);
color: var(--misc-text-color);
}
tfoot td
{
font-weight: bold;
background-color: var(--table-header-color);
position: sticky;
bottom: -0.5rem;
border: 1px solid var(--border-color);
}
div.empty-result
{
opacity: 10%;
font-size: 7vw;
font-family: Liberation Sans, DejaVu Sans, sans-serif;
}
#graph
{
display: none;
margin: auto;
}
text
{
font-size: 14px;
fill: var(--text-color);
}
.node rect
{
fill: var(--element-background-color);
filter: drop-shadow(.2rem .2rem .2rem var(--shadow-color));
stroke: var(--border-color);
}
.edgePath path
{
stroke: var(--text-color);
}
marker
{
fill: var(--text-color);
}
#chart
{
border: 1px solid var(--border-color);
background-color: var(--element-background-color);
filter: drop-shadow(.2rem .2rem .2rem var(--shadow-color));
display: none;
height: 70vh;
}
.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 {font-family: Liberation Mono, DejaVu Sans Mono, MonoLisa, Consolas, monospace; border-spacing: 0; border-collapse: collapse;}
.u-inline {display: block;}
.u-inline * {display: inline-block;}
.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;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>
<div id="data_div">
<table class="monospace shadow" id="data-table"></table>
<pre class="monospace shadow" id="data-unparsed"></pre>
</div>
<div id="chart"></div>
<svg id="graph" fill="none"></svg>
<p id="error" class="monospace shadow"></p>
`;
this._dataDiv = this.shadowRoot.getElementById('data_div');
this._dataTable = this.shadowRoot.getElementById('data-table');
this._dataUnparsed = this.shadowRoot.getElementById('data-unparsed');
this._chart = this.shadowRoot.getElementById('chart');
this._graph = this.shadowRoot.getElementById('graph');
this._error = this.shadowRoot.getElementById('error');
this._row_idx = 0;
this._elapsed_ns = 0;
this._incomplete_result = false;
this._is_explain_graph = false;
this._explain_graph = '';
this._column_is_number = {};
this._extremes = {};
this._totals = null;
this._header = {};
this._current_selected_cell = null;
this._uplot = null;
this._queryText = '';
this._max_rows = 1_000;
this._max_cells = 100_000;
this.addEventListener('keydown', (e) => this._handleKeyDown(e));
/* Clicking on non-data areas (e.g. table header) deactivates the selected cell */
this.shadowRoot.addEventListener('click', (e) => {
if (!e.target.closest('td') || e.target.closest('.row-number')) this.clearSelection();
});
new ResizeObserver(() => this._resizeChart()).observe(this._chart);
}
connectedCallback()
{
if (!this.hasAttribute('tabindex')) this.setAttribute('tabindex', '0');
this.style.outline = 'none';
}
get rowCount() { return this._row_idx; }
get elapsedNs() { return this._elapsed_ns; }
set elapsedNs(v) { this._elapsed_ns = v; }
get incompleteResult() { return this._incomplete_result; }
get isExplainGraph() { return this._is_explain_graph; }
get explainGraph() { return this._explain_graph; }
set queryText(v) { this._queryText = v; }
get rawText() { return this._dataUnparsed.textContent; }
_clearElement(elem)
{
while (elem.firstChild)
{
elem.removeChild(elem.lastChild);
}
elem.style.display = 'none';
}
clear()
{
this._clearElement(this._dataTable);
this._clearElement(this._graph);
this._clearElement(this._chart);
this._clearElement(this._dataUnparsed);
this._clearElement(this._error);
this._dataTable.classList.remove('fixed');
this._dataDiv.classList.remove('fixed');
this._extremes = {};
this._totals = null;
this._header = {};
this._row_idx = 0;
this._elapsed_ns = 0;
this._incomplete_result = false;
this._is_explain_graph = false;
this._explain_graph = '';
this._current_selected_cell = null;
}
clearSelection()
{
if (this._current_selected_cell)
{
this._current_selected_cell.classList.remove('td-selected');
this._current_selected_cell.parentElement.classList.remove('tr-selected');
}
this._current_selected_cell = null;
}
clearError()
{
this._clearElement(this._error);
}
_renderCell(name, value)
{
let td = document.createElement('td');
let div = document.createElement('div');
div.className = 'cell-content'; /* Height-limited container; expands absolutely when selected */
let is_null = (value === null);
let is_link = false;
let text;
if (is_null)
{
text = '\u1D3A\u1D41\u1D38\u1D38';
}
else if (typeof(value) === 'object')
{
text = JSON.stringify(value);
}
else
{
text = value;
if (typeof(value) == 'string' && value.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.className = this._column_is_number[name] ? 'right' : 'left';
if (this._column_is_number[name])
{
if (text.length >= 5 && text.match(/^[\d]{5,}$/))
{
let i = 0;
div.innerHTML = text.split('').reverse().map(c => {
if (i > 0 && i % 3 == 0) { c = `<u>${c}<\/u>`; }; ++i; return c;
}).reverse().join('');
td.appendChild(div);
this._appendCopyButton(td, text, value);
return td;
}
}
div.appendChild(node);
td.appendChild(div);
this._appendCopyButton(td, text, value);
return td;
}
_appendCopyButton(td, text, value)
{
if (value === null || value === '' || value === 0 || value === '0') return;
let btn = document.createElement('span');
btn.className = 'cell-copy';
btn.title = 'Copy to clipboard';
btn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
btn.onclick = (e) => {
e.stopPropagation();
if (navigator.clipboard && navigator.clipboard.writeText)
{
navigator.clipboard.writeText(text);
}
};
td.appendChild(btn);
}
appendHeader(meta)
{
if (meta.length == 0) { return; }
this._header = meta;
let thead = document.createElement('thead');
let th = document.createElement('th');
th.className = 'row-number';
th.appendChild(document.createTextNode('\u2116'));
thead.appendChild(th);
let zIndex = meta.length;
meta.forEach(elem => {
let th = document.createElement('th');
const name = document.createTextNode(elem.name);
const nameSpan = document.createElement('span');
nameSpan.className = 'name';
nameSpan.appendChild(name);
th.appendChild(nameSpan);
let type_hint = document.createElement('span');
type_hint.className = 'type-hint';
type_hint.appendChild(document.createTextNode(elem.type));
th.appendChild(type_hint);
this._appendHeaderResizer(th);
th.style.zIndex = zIndex;
--zIndex;
thead.appendChild(th);
this._column_is_number[elem.name] = !!elem.type.match(/^(Nullable\()?(U?Int|Decimal|Float)/);
});
let table = this._dataTable;
while (table.firstChild)
{
table.removeChild(table.lastChild);
}
let tbody = document.createElement('tbody');
tbody.id = 'tbody';
table.appendChild(thead);
table.appendChild(tbody);
table.style.display = 'table';
this._row_idx = 0;
}
appendRow(row, fragment)
{
if (this._row_idx >= this._max_rows || (1 + this._row_idx) * Object.keys(this._header).length > this._max_cells)
{
this._incomplete_result = true;
return false;
}
++this._row_idx;
let tr = document.createElement('tr');
let td = document.createElement('td');
td.className = 'row-number';
td.appendChild(document.createTextNode(this._row_idx));
tr.appendChild(td);
for (const column of this._header)
{
const td = this._renderCell(column.name, row[column.name]);
td.onclick = () => {
if (this._current_selected_cell)
{
this._current_selected_cell.classList.remove('td-selected');
this._current_selected_cell.parentElement.classList.remove('tr-selected');
}
this._current_selected_cell = td;
td.classList.add('td-selected');
td.parentElement.classList.add('tr-selected');
(td.querySelector('.cell-content') || td).scrollIntoView({ block: 'nearest' });
this.focus();
};
tr.appendChild(td);
}
fragment.appendChild(tr);
this._incomplete_result = false;
return true;
}
flushFragment(fragment)
{
let tbody = this.shadowRoot.getElementById('tbody');
if (tbody && fragment.hasChildNodes())
{
tbody.appendChild(fragment);
}
}
renderExtremes()
{
let col_idx = 0;
for (const column of this._header)
{
const name = column.name;
++col_idx;
if (this._column_is_number[name]
&& this._extremes.min[name] !== undefined && this._extremes.max[name] !== undefined
&& Number(this._extremes.max[name]) > Number(this._extremes.min[name]))
{
let table = this._dataTable;
const double_sided = this._extremes.min[name] < 0;
const ratio_positive = this._extremes.max[name] / (this._extremes.max[name] - this._extremes.min[name]);
const ratio_negative = 1 - ratio_positive;
for (let row of table.rows)
{
let cell = row.cells[col_idx];
let div = cell.querySelector('.cell-content') || cell; /* Apply bars to the inner div */
let value = +cell.innerText;
if (!double_sided)
{
if (value > 0)
{
const ratio = value / this._extremes.max[name];
div.style.cssText = `
background-size: 100% 50%;
background-position: center;
background-repeat: no-repeat;
background: linear-gradient(to right,
var(--bar-color) 0%, var(--bar-color) ${100 * ratio}%,
var(--element-background-color) ${100 * ratio}%, var(--element-background-color) 100%)`;
}
}
else
{
if (value > 0)
{
const ratio = value / this._extremes.max[name];
div.style.cssText = `
background-size: 100% 50%;
background-position: center;
background-repeat: no-repeat;
background: linear-gradient(to right,
var(--element-background-color) 0%, var(--element-background-color) ${100 * ratio_negative}%,
var(--bar-color) ${100 * ratio_negative}%, var(--bar-color) ${100 * (ratio_negative + ratio * ratio_positive)}%,
var(--element-background-color) ${100 * (ratio_negative + ratio * ratio_positive)}%, var(--element-background-color) 100%)`;
}
else if (value < 0)
{
const ratio = value / this._extremes.min[name];
div.style.cssText = `
background-size: 100% 50%;
background-position: center;
background-repeat: no-repeat;
background: linear-gradient(to right,
var(--element-background-color) 0%, var(--element-background-color) ${100 * (1 - ratio) * ratio_negative}%,
var(--bar-color-negative) ${100 * (1 - ratio) * ratio_negative}%, var(--bar-color-negative) ${100 * ratio_negative}%,
var(--element-background-color) ${100 * ratio_negative}%, var(--element-background-color) 100%)`;
}
}
}
}
}
}
renderTotals()
{
if (!this._totals) return;
let table = this._dataTable;
let tfoot = document.createElement('tfoot');
let tr = document.createElement('tr');
let td = document.createElement('td');
td.className = 'row-number';
td.appendChild(document.createTextNode('\u03A3'));
tr.appendChild(td);
for (const column of this._header)
{
const td = this._renderCell(column.name, this._totals[column.name]);
tr.appendChild(td);
}
tfoot.appendChild(tr);
table.appendChild(tfoot);
}
transposeIfNeeded()
{
let table = this._dataTable;
if (this._totals) return;
if (Object.keys(this._header).length == 0 || table.rows.length > 1 || table.rows[0]?.cells.length <= 5) return;
let heads = table.tHead.childNodes;
let values = table.rows[0]?.cells;
let num_cols = heads.length - 1;
let new_tbody = document.createElement('tbody');
for (let i = 0; i < num_cols; ++i)
{
let tr = document.createElement('tr');
let th = heads[1];
th.className = 'right';
th.style.width = '0';
th.removeChild(th.querySelector('.resizer'));
tr.appendChild(th);
if (values)
{
let td = values[1];
td.classList.remove('right');
tr.appendChild(td);
}
else if (i == 0)
{
let td = document.createElement('td');
td.rowSpan = num_cols;
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);
}
new_tbody.appendChild(tr);
}
table.removeChild(table.tHead);
table.removeChild(table.tBodies[0]);
table.appendChild(new_tbody);
}
_changeTableLayout()
{
const table = this._dataTable;
const headers = table.querySelectorAll('th');
table.style.setProperty('--table-width', `${table.offsetWidth}px`);
let columnWidths = [];
headers.forEach(header => {
columnWidths.push(header.offsetWidth);
});
/* Freeze row heights so that absolutely-positioned cell content
does not cause reflow. Read all heights first, then write,
to avoid layout thrashing. */
let rowHeights = [];
for (let row of table.rows)
{
rowHeights.push(row.offsetHeight);
}
table.classList.add('fixed');
this._dataDiv.classList.add('fixed');
headers.forEach((header, index) => {
header.style.width = `${columnWidths[index]}px`;
});
let i = 0;
for (let row of table.rows)
{
row.style.height = `${rowHeights[i++]}px`;
}
}
_appendHeaderResizer(header)
{
let drag_state = {
elem: null,
is_dragging: true,
offset_x: null,
offset_width: null
};
const start = (e) => {
if (e.button !== 0) { return; }
this._changeTableLayout();
drag_state.offset_x = e.clientX;
drag_state.offset_width = header.offsetWidth;
drag_state.is_dragging = true;
drag_state.elem = e.target;
drag_state.elem.classList.add('resizer-dragging');
document.addEventListener('pointermove', move);
document.addEventListener('pointerup', stop);
document.addEventListener('pointercancel', stop);
};
const move = (e) => {
if (!drag_state.is_dragging) { return; }
const dx = e.clientX - drag_state.offset_x;
header.style.width = `${drag_state.offset_width + dx}px`;
if (dx < 0)
{
const table = this._dataTable;
table.style.setProperty('--table-width', `${table.offsetWidth + dx}px`);
}
};
const stop = (e) => {
drag_state.is_dragging = false;
drag_state.elem.classList.remove('resizer-dragging');
document.removeEventListener('pointermove', move);
document.removeEventListener('pointerup', stop);
document.removeEventListener('pointercancel', stop);
};
const resizer = document.createElement('div');
resizer.className = 'resizer';
resizer.addEventListener('pointerdown', start);
resizer.addEventListener('touchstart', (e) => e.preventDefault());
header.appendChild(resizer);
}
renderError(message)
{
this._error.innerText = message ? message : "No response.";
this._error.style.display = 'block';
}
updateRaw(data)
{
const fragment = document.createTextNode(data);
this._dataUnparsed.appendChild(fragment);
this._dataUnparsed.style.display = 'block';
}
update(json, fragment)
{
if (json.progress)
{
this._elapsed_ns = +json.progress.elapsed_ns;
this.dispatchEvent(new CustomEvent('query-progress', { detail: json.progress }));
return true;
}
else if (json.meta)
{
this.appendHeader(json.meta);
return true;
}
else if (json.row)
{
if (this._is_explain_graph
|| (this._row_idx == 0 && Object.keys(json.row).length == 1 && json.row.explain == 'digraph'
&& this._queryText.match(/^\s*EXPLAIN/i)))
{
this._is_explain_graph = true;
this._explain_graph += json.row.explain + '\n';
return true;
}
else
{
return this.appendRow(json.row, fragment);
}
}
else if (json.totals)
{
this._totals = json.totals;
return true;
}
else if (json.min)
{
this._extremes['min'] = json.min;
return true;
}
else if (json.max)
{
this._extremes['max'] = json.max;
return true;
}
else if (json.exception)
{
this.renderError(json.exception);
return true;
}
}
_handleKeyDown(e)
{
let cell = this._current_selected_cell;
if (!cell) { return; }
const row = cell.parentElement;
const table = row.parentElement;
let cell_index = cell.cellIndex;
let row_index = row.rowIndex;
switch(e.key)
{
case 'ArrowUp': --row_index; break;
case 'ArrowDown': ++row_index; break;
case 'ArrowLeft': --cell_index; break;
case 'ArrowRight': ++cell_index; break;
default:
return;
}
if (row_index < 0 || cell_index <= 0) { return; }
let new_cell = table.rows[row_index]?.cells[cell_index];
if (!new_cell) { return; }
e.preventDefault();
cell.classList.remove('td-selected');
cell.parentElement.classList.remove('tr-selected');
new_cell.classList.add('td-selected');
new_cell.parentElement.classList.add('tr-selected');
(new_cell.querySelector('.cell-content') || new_cell).scrollIntoView({ block: 'nearest' });
this._current_selected_cell = new_cell;
}
_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);
});
}
_loadDagre()
{
if (QueryResultElement._loadDagrePromise) { return QueryResultElement._loadDagrePromise; }
QueryResultElement._loadDagrePromise = Promise.all([
this._loadJS('https://dagrejs.github.io/project/dagre/v0.8.5/dagre.min.js',
'sha384-2IH3T69EIKYC4c+RXZifZRvaH5SRUdacJW7j6HtE5rQbvLhKKdawxq6vpIzJ7j9M'),
this._loadJS('https://dagrejs.github.io/project/graphlib-dot/v0.6.4/graphlib-dot.min.js',
'sha384-Q7oatU+b+y0oTkSoiRH9wTLH6sROySROCILZso/AbMMm9uKeq++r8ujD4l4f+CWj'),
this._loadJS('https://dagrejs.github.io/project/dagre-d3/v0.6.4/dagre-d3.min.js',
'sha384-9N1ty7Yz7VKL3aJbOk+8ParYNW8G5W+MvxEfFL9G7CRYPmkHI9gJqyAfSI/8190W'),
this._loadJS('https://cdn.jsdelivr.net/npm/[email protected]',
'sha384-S+Kf0r6YzKIhKA8d1k2/xtYv+j0xYUU3E7+5YLrcPVab6hBh/r1J6cq90OXhw80u'),
]);
return QueryResultElement._loadDagrePromise;
}
_loadUplot()
{
if (QueryResultElement._loadUplotPromise) { return QueryResultElement._loadUplotPromise; }
QueryResultElement._loadUplotPromise = this._loadJS('https://cdn.jsdelivr.net/npm/[email protected]/dist/uPlot.iife.min.js',
'sha384-TwdJPnTsKP6pnvFZZKda0WJCXpjcHCa7MYHmjrYDu6rsEsb/UnFdoL0phS5ODqTA');
return QueryResultElement._loadUplotPromise;
}
async renderGraph()
{
this._clearElement(this._dataTable);
await this._loadDagre();
const dot = this._explain_graph.replace(/shape\s*=\s*box/g, 'shape=rect');
let graph = graphlibDot.read(dot);
let render = new dagreD3.render();
graph.setGraph({
nodesep: 20,
rankdir: 'LR',
});
let svg = this._graph;
svg.style.display = 'block';
render(d3.select(svg), graph);
svg.style.width = graph.graph().width;
svg.style.height = graph.graph().height;
}
_legendAsTooltipPlugin({ className, style = {} } = {})
{
let legendEl;
let multiline;
function init(u, opts)
{
legendEl = u.root.querySelector(".u-legend");
legendEl.classList.remove("u-inline");
className && legendEl.classList.add(className);
uPlot.assign(legendEl.style, {
textAlign: "right",
pointerEvents: "none",
display: "none",
position: "absolute",
left: 0,
top: 0,
zIndex: 100,
boxShadow: "2px 2px 10px rgba(0, 0, 0, 0.1)",
...style
});
const nodes = legendEl.querySelectorAll("th");
for (let i = 0; i < nodes.length; i++)
nodes[i]._order = i;
if (opts.series.length == 2)
{
multiline = false;
for (let i = 0; i < nodes.length; i++)
nodes[i].style.display = "none";
}
else
{
multiline = true;
legendEl.querySelector("th").remove();
legendEl.querySelector("td").setAttribute('colspan', '2');
legendEl.querySelector("td").style.textAlign = 'center';
let footer = legendEl.insertRow().insertCell();
footer.setAttribute('colspan', '2');
footer.style.textAlign = 'center';
footer.classList.add('u-value');
footer.parentNode.classList.add('u-series','footer');
footer.textContent = ". . .";
}
const overEl = u.over;
overEl.style.overflow = "visible";
overEl.appendChild(legendEl);
overEl.addEventListener("mouseenter", () => {legendEl.style.display = null;});
overEl.addEventListener("mouseleave", () => {legendEl.style.display = "none";});
}
function nodeListToArray(nodeList)
{
return Array.prototype.slice.call(nodeList);
}
function update(u)
{
let { left, top } = u.cursor;
left -= legendEl.clientWidth * (left / u.width);
if (top >= legendEl.clientHeight)
{
top -= legendEl.clientHeight;
}
legendEl.style.transform = "translate(" + left + "px, " + top + "px)";
if (multiline)
{
let nodes = nodeListToArray(legendEl.querySelectorAll("tr"));
let header = nodes.shift();
let footer = nodes.pop();
let showLimit = Math.floor(u.height / 30);
nodes.forEach(function (node) { node._sort_key = nodes.length > showLimit ? +node.querySelector("td").textContent.replace(/,/g,'') : node._order; });
nodes.sort((a, b) => b._sort_key - a._sort_key);
nodes.forEach(function (node) { node.parentNode.appendChild(node); });
for (let i = 0; i < nodes.length; i++)
{
nodes[i].style.display = i < showLimit ? null : "none";
}
footer.parentNode.appendChild(footer);
footer.style.display = nodes.length > showLimit ? null : "none";
}
}
return {
hooks: {
init: init,
setCursor: update,
}
};
}
async renderChart(json)
{
await this._loadUplot();
if (this._uplot)
{
this._uplot.destroy();
this._uplot = null;
}
let chart = this._chart;
chart.style.display = 'block';
let paths = json[0].length < chart.clientWidth / 5 ? uPlot.paths.bars() : uPlot.paths.linear();
const [line_color, fill_color, grid_color, axes_color] = theme == 'light'
? ["rgba(255, 0, 200, 100%)", "rgba(255, 128, 200, 50%)", "#CCC", "#444"]
: ["rgba(255, 255, 0, 100%)", "rgba(255, 255, 100, 50%)", "#444", "#CCC"];
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, paths, points: { show: false } } ],
padding: [ null, null, null, (Math.ceil(Math.log10(Math.max(...json[1]))) + Math.floor(Math.log10(Math.max(...json[1])) / 3)) * 6 ],
plugins: [ this._legendAsTooltipPlugin() ],
};
this._uplot = new uPlot(opts, json, chart);
}
_resizeChart()
{
if (this._uplot)
{
let chart = this._chart;
this._uplot.setSize({ width: chart.clientWidth, height: chart.clientHeight });
}
}
redrawChart()
{
if (this._uplot && this._chart.style.display == 'block')
{
this.renderChart(this._uplot.data);
}
}
}
customElements.define('query-result', QueryResultElement);
/// 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 = '';
/// Start of the last query
let last_query_start = 0;
const current_url = new URL(window.location);
const opened_locally = location.protocol == 'file:';
/// Run query instantly after page is loaded if the run parameter is present.
const run_immediately = current_url.searchParams.has("run");
let url_elem = document.getElementById('url');
let user_elem = document.getElementById('user');
let password_elem = document.getElementById('password');
let progressEl = document.getElementById('query-progress');
let resultEl = document.getElementById('query-result');
let logoEl = document.getElementById('logo-container');
resultEl.addEventListener('query-progress', (e) => {
elapsed_ns = +e.detail.elapsed_ns;
progressEl.updateProgress(e.detail);
progressEl.updateText(resultEl.rowCount, elapsed_ns, resultEl.incompleteResult);
});
if (url_elem.value == '') {
const server_address = current_url.searchParams.get('url');
if (server_address) {
url_elem.value = server_address;
} else if (!opened_locally) {
/// Substitute the address of the server where the page is served.
url_elem.value = location.origin;
} else {
url_elem.value = 'http://localhost:8123/';
}
}
/// Substitute username if it's specified in the query string
const user_from_url = current_url.searchParams.get('user');
if (user_from_url) {
user_elem.value = user_from_url;
}
const pass_from_url = current_url.searchParams.get('password');
if (pass_from_url) {
password_elem.value = pass_from_url;
/// Browsers don't allow manipulating history for the 'file:' protocol.
if (!opened_locally) {
let replaced_pass = current_url.searchParams;
replaced_pass.delete('password');
window.history.replaceState(null, '',
window.location.origin + window.location.pathname + '?'
+ replaced_pass.toString() + window.location.hash);
}
}
async function ping(url) {
try {
let response = await fetch(new URL(url).origin + "?query", { method: 'OPTIONS', headers: { 'Authorization': 'never' } });
return response.ok;
} catch (e) {
return false;
}
}
let ping_request_num = 0;
function checkURL() {
++ping_request_num;
const current_ping_request_num = ping_request_num;
let elem = document.getElementById('url_status');
elem.className = '';
document.getElementById('server_info').innerText = '';
ping(url_elem.value).then(status => {
if (current_ping_request_num == ping_request_num) {
elem.className = status ? 'ok' : 'fail';
if (status) {
checkCredentials();
}
}
});
};
url_elem.addEventListener('input', checkURL);
checkURL();
async function auxiliaryQuery(server_address, user, password, query, url_params = '') {
let url = server_address +
(server_address.indexOf('?') >= 0 ? '&' : '?') +
'add_http_cors_header=1&default_format=JSON' + url_params;
if (user) url += '&user=' + encodeURIComponent(user);
if (password) url += '&password=' + encodeURIComponent(password);
let response;
try {
response = await fetch(url, { method: "POST", body: query, headers: { 'Authorization': 'never' } });
} catch (e) {
clear();
resultEl.renderError("Network error");
logoEl.style.display = 'none';
return false;
}
if (!response.ok) {
clear();
resultEl.renderError(await response.text());
logoEl.style.display = 'none';
return false;
}
resultEl.clearError();
logoEl.style.display = 'block';
return await response.json();
}
async function getServerStatus(server_address, user, password) {
let url = server_address +
(server_address.indexOf('?') >= 0 ? '&' : '?') +
'add_http_cors_header=1&default_format=JSONEachRow';
if (user) url += '&user=' + encodeURIComponent(user);
if (password) url += '&password=' + encodeURIComponent(password);
try {
let response = await fetch(url, { method: "POST", body: "SELECT version() AS v, uptime() AS t", headers: { 'Authorization': 'never' } });
if (!response.ok) return false;
json = await response.json();
return json;
} catch (e) {
return false;
}
}
let load_databases_request_num = 0;
async function loadDatabases(server_address, user, password) {
++load_databases_request_num;
const current_load_databases_request_num = load_databases_request_num;
let json = await auxiliaryQuery(server_address, user, password,
`SELECT database, database = currentDatabase() AS current
FROM system.databases WHERE database != 'INFORMATION_SCHEMA'
ORDER BY
database = 'information_schema',
database = 'system',
database != 'default',
database`);
if (!json) return false;
if (current_load_databases_request_num != load_databases_request_num) return false;
let databases_elem = document.getElementById('databases');
while (databases_elem.firstChild) databases_elem.removeChild(databases_elem.firstChild);
// Use natural order for database names
json.data.sort((a, b) => {
// Maintain original order for default, system, information_schema
const getPriority = (name) => {
switch (name) {
case 'default': return 0;
case 'system': return 2;
case 'information_schema': return 3
default: return 1;
}
}
return getPriority(a.database) - getPriority(b.database)
|| a.database.localeCompare(b.database, undefined, { numeric: true });
});
for (let elem of json.data) {
let database = document.createElement('div');
database.className = 'database';
let database_link = document.createElement('button');
if (elem.current) database_link.className = 'current';
database_link.append(elem.database);
database.appendChild(database_link);
let tables = document.createElement('div');
tables.className = 'tables';
database.appendChild(tables);
databases_elem.appendChild(database);
database_link.addEventListener('click', e => {
if (!database.dataset['opened']) {
let hourglass = document.createElement('span');
hourglass.className = 'loading-hourglass';
hourglass.textContent = '\u29D7';
database_link.after(hourglass);
loadTables(server_address, user, password, elem.database, database).then(ok => {
hourglass.remove();
if (ok !== false) database.dataset['opened'] = 1;
});
} else {
delete database.dataset['opened'];
while (tables.firstChild) tables.removeChild(tables.firstChild);
}
});
}
}
function makeSVG(viewBox, ...paths)
{
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('viewBox', viewBox);
svg.setAttribute('fill', 'none');
svg.setAttribute('stroke', 'currentColor');
svg.setAttribute('stroke-width', '2');
svg.setAttribute('stroke-linecap', 'round');
svg.setAttribute('stroke-linejoin', 'round');
svg.classList.add('table-icon');
for (const d of paths)
{
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('d', d);
svg.appendChild(path);
}
return svg;
}
function getTableIcon(engine)
{
const e = engine.replace(/^Replicated|^Shared/, '');
/// Aggregating/Summing - sigma
if (/^(Summing|Aggregating)MergeTree/.test(e))
{
const svg = makeSVG('0 0 24 24',
'M18 4 H6', // top bar
'M6 4 L16 12', // upper diagonal
'M16 12 L6 20', // lower diagonal
'M6 20 H18' // bottom bar
);
return svg;
}
/// Memory - microchip
if (e === 'Memory')
{
const svg = makeSVG('0 0 24 24',
'M6 6 h12 v12 h-12 z', // chip body
'M9 6 v-2', 'M12 6 v-2', 'M15 6 v-2', // top pins
'M9 18 v2', 'M12 18 v2', 'M15 18 v2', // bottom pins
'M6 10 h-2', 'M6 14 h-2', // left pins
'M18 10 h2', 'M18 14 h2' // right pins
);
return svg;
}
/// View - eye
if (e === 'View')
{
const svg = makeSVG('0 0 24 24',
'M1 12 C5 5, 19 5, 23 12 C19 19, 5 19, 1 12 Z', // eye outline
'M12 9 a3 3 0 1 0 0 6 a3 3 0 1 0 0 -6' // pupil
);
return svg;
}
/// MaterializedView - eye over table
if (e === 'MaterializedView')
{
const svg = makeSVG('0 0 24 24',
'M1 8 C5 2, 19 2, 23 8 C19 14, 5 14, 1 8 Z', // eye
'M12 5.5 a2.5 2.5 0 1 0 0 5 a2.5 2.5 0 1 0 0 -5', // pupil
'M4 17 h16', 'M4 21 h16' // table lines
);
return svg;
}
/// Merge - three overlaid tables
if (e === 'Merge')
{
const svg = makeSVG('0 0 24 24',
'M8 8 h14 v14 h-14 z', 'M8 12 h14', // back table (bottom-right)
'M5 5 h14 v14 h-14 z', 'M5 9 h14', // middle table
'M2 2 h14 v14 h-14 z', 'M2 6 h14' // front table (top-left)
);
svg.setAttribute('fill', 'var(--element-background-color)');
return svg;
}
/// Distributed - fanout
if (e === 'Distributed')
{
const svg = makeSVG('0 0 24 24',
'M2 9.5 h6 v5 h-6 z', // source box
'M16 1 h6 v5 h-6 z', // target top
'M16 9.5 h6 v5 h-6 z', // target middle
'M16 18 h6 v5 h-6 z', // target bottom
'M8 12 L16 3.5', // arrow top
'M8 12 L16 12', // arrow middle
'M8 12 L16 20.5' // arrow bottom
);
return svg;
}
/// StorageProxy - arrow between two boxes
if (e === 'Proxy')
{
const svg = makeSVG('0 0 24 24',
'M1 7 h6 v10 h-6 z', // left box
'M17 7 h6 v10 h-6 z', // right box
'M7 12 h10', // arrow line
'M14 9 l3 3 l-3 3' // arrowhead
);
return svg;
}
/// Default MergeTree family - table
const svg = makeSVG('0 0 24 24',
'M3 4 h18 v16 h-18 z', // border
'M3 9 h18', // header line
'M3 14 h18', // middle line
'M10 9 v11' // column divider
);
return svg;
}
let load_tables_request_num = 0;
async function loadTables(server_address, user, password, database, container) {
++load_tables_request_num;
const current_load_tables_request_num = load_tables_request_num;
let json = await auxiliaryQuery(server_address, user, password,
`SELECT table, engine, total_rows, total_bytes FROM system.tables WHERE database = {database:String}
ORDER BY table LIKE '.inner%', table`,
'&param_database=' + encodeURIComponent(database));
if (!json) return false;
if (current_load_tables_request_num != load_tables_request_num) return false;
container.parentElement.querySelector('.current').classList.remove('current');
container.firstChild.classList.add('current');
let tables_elem = container.querySelector('.tables');
while (tables_elem.firstChild) tables_elem.removeChild(tables_elem.firstChild);
let max_total_bytes = Math.max(...json.data.map(elem => +elem.total_bytes));
// Use natural order for table names; .inner tables go last
json.data.sort((a, b) => a.table.startsWith('.inner') - b.table.startsWith('.inner')
|| a.table.localeCompare(b.table, undefined, { numeric: true }));
for (let elem of json.data) {
let table = document.createElement('div');
table.className = 'table';
let table_link = document.createElement('button');
table_link.append(getTableIcon(elem.engine), elem.table);
if (max_total_bytes && +elem.total_bytes) {
const percent = Number(100 * +elem.total_bytes / max_total_bytes).toFixed(2);
table.style.backgroundImage = `linear-gradient(to right,
var(--table-size-bar-color) 0%,
var(--table-size-bar-color) ${percent}%,
transparent ${percent}%)`;
}
let table_addendum = document.createElement('span');
table_addendum.className = 'table-addendum';
const set_query_or_run = (e, value) => {
if (query_area.value == value) {
/// Double click will run the query.
postOne();
} else {
query_area.value = value;
query_area.dispatchEvent(new Event('input'));
}
/// #main is the scroll container (overflow: auto), so scroll it to bring the query area into view.
document.getElementById('main').scrollTo(0, 0);
e.stopPropagation();
}
const show_table_properties = e => set_query_or_run(e, `SELECT * FROM system.tables WHERE database = '${database}' AND table = '${elem.table}'`);
if (+elem.total_bytes) {
let bytes_elem = document.createElement('span');
bytes_elem.className = 'table-addendum-elem';
bytes_elem.textContent = formatReadableBytes(elem.total_bytes) + '\n';
bytes_elem.addEventListener('click', show_table_properties);
table_addendum.append(bytes_elem);
}
if (+elem.total_rows) {
let rows_elem = document.createElement('span');
rows_elem.className = 'table-addendum-elem';
rows_elem.textContent = formatReadableRows(elem.total_rows) + ' rows\n';
rows_elem.addEventListener('click', show_table_properties);
table_addendum.append(rows_elem);
}
let engine_elem = document.createElement('span');
engine_elem.textContent = elem.engine;
engine_elem.className = 'table-addendum-elem';
engine_elem.addEventListener('click', e => set_query_or_run(e, `SHOW TABLE "${database}"."${elem.table}"`));
table_addendum.append(engine_elem);
table_link.addEventListener('mouseenter', function() {
const rect = this.getBoundingClientRect();
table_addendum.style.left = rect.right + 'px';
table_addendum.style.top = (rect.top + rect.height / 2) + 'px';
table_addendum.style.visibility = '';
visible_balloon = table_addendum;
});
table.appendChild(table_link);
table.appendChild(table_addendum);
table.addEventListener('click', e => set_query_or_run(e, `SELECT * FROM "${database}"."${elem.table}" LIMIT 100`));
tables_elem.appendChild(table);
}
if (json.data.length == 0) {
let table = document.createElement('div');
table.className = 'no-tables';
table.append('no tables');
tables_elem.append(table);
}
}
let databases_toggle = document.getElementById('databases-toggle');
let menu_elem = document.getElementById('menu');
/// Hide the currently visible table-info balloon on any scroll: it uses
/// position:fixed and would detach from its anchor. Use capture phase so the
/// listener fires regardless of which element scrolls (scroll events do not
/// bubble). Only one balloon can be open at a time (the one the cursor most
/// recently entered), so we track that reference instead of scanning the DOM.
///
/// Also set `suppress-balloons` on the body until the next mousemove, so a
/// different table button scrolled under a stationary cursor cannot reveal its
/// balloon at an unset position via the CSS `:hover` rule (`mouseenter` does
/// not fire without actual mouse movement, so JS positioning would be skipped).
let visible_balloon = null;
function hideTableBalloons() {
if (visible_balloon) {
visible_balloon.style.visibility = 'hidden';
visible_balloon = null;
}
document.body.classList.add('suppress-balloons');
}
document.addEventListener('scroll', hideTableBalloons, { capture: true, passive: true });
document.addEventListener('mousemove', () => {
/// Guard so the no-op case does not touch classList on every mouse pixel.
if (document.body.classList.contains('suppress-balloons')) {
document.body.classList.remove('suppress-balloons');
}
}, { passive: true });
function toggleDatabases() {
if (!databases_toggle.dataset['opened']) {
loadDatabases(url_elem.value, user_elem.value, password_elem.value).then(ok => {
if (ok !== false) {
databases_toggle.dataset['opened'] = 1;
menu_elem.style.cursor = 'w-resize';
}
});
} else {
delete databases_toggle.dataset['opened'];
menu_elem.style.cursor = 'e-resize';
let databases_elem = document.getElementById('databases');
while (databases_elem.firstChild) databases_elem.removeChild(databases_elem.firstChild);
}
}
/// The logo is an <a> element so that Ctrl/Cmd/Shift+click and middle-click
/// natively open the current page (with the query) in a new tab.
/// We update the href on mousedown so it always reflects the current URL.
document.getElementById('databases-icon').addEventListener('mousedown', () => {
document.getElementById('databases-icon').href = window.location.href;
});
databases_toggle.addEventListener('click', e => {
e.stopPropagation();
/// Let the browser handle modifier clicks as a link (open in new tab).
if (e.ctrlKey || e.metaKey || e.shiftKey) return;
/// Normal click: toggle the databases panel instead of following the link.
e.preventDefault();
toggleDatabases();
});
document.getElementById('menu').addEventListener('click', e => {
if (e.ctrlKey || e.metaKey || e.shiftKey) return;
if (!e.target.closest('#github-icon') && !e.target.closest('#databases-toggle') && !e.target.closest('button')) {
toggleDatabases();
}
});
function formatUptime(t) {
if (t < 60) { return t + " sec" }
if (t < 3600) { return Number(t / 60).toFixed() + " min" }
if (t < 86400) { return Number(t / 3600).toFixed() + " hr" }
const days = Number(t / 86400).toFixed();
return days + " day" + (days > 1 ? 's' : '');
}
let check_credentials_request_num = 0;
function checkCredentials() {
++check_credentials_request_num;
const current_check_credentials_request_num = check_credentials_request_num;
getServerStatus(url_elem.value, user_elem.value, password_elem.value).then(json => {
if (current_check_credentials_request_num == check_credentials_request_num) {
if (json) {
[user_elem, password_elem].forEach(e => { e.classList.remove('fail'); e.classList.add('ok'); });
document.getElementById('server_info').innerText = `v${json.v}, uptime ${formatUptime(json.t)} `;
} else {
[user_elem, password_elem].forEach(e => { e.classList.remove('ok'); e.classList.add('fail'); });
}
}
});
}
function resetCredentialsStatus() {
[user_elem, password_elem].forEach(e => { e.classList.remove('ok'); e.classList.remove('fail'); });
}
user_elem.addEventListener('input', resetCredentialsStatus);
password_elem.addEventListener('input', resetCredentialsStatus);
/// The password is usually copy-pasted.
/// To not worry about extracting the clipboard data, but use the value instead,
/// and not worry too much about input/paste race, add the timeout.
user_elem.addEventListener('paste', () => setTimeout(checkCredentials, 100));
password_elem.addEventListener('paste', () => setTimeout(checkCredentials, 100));
function queryToColor(str) {
let hash = 2166136261;
for (let i = 0; i < str.length; i++) {
hash ^= str.charCodeAt(i);
hash = Math.imul(hash, 16777619);
}
/// Limited range to avoid violet colors.
return 50 + ((hash >>> 0) % 200);
}
function toBase64(str) {
const encoder = new TextEncoder();
const bytes = encoder.encode(str);
const binary = Array.from(bytes, byte => String.fromCharCode(byte)).join('');
return btoa(binary);
}
function fromBase64(base64) {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) { bytes[i] = binary.charCodeAt(i); }
const decoder = new TextDecoder();
return decoder.decode(bytes);
}
let elapsed_ns = 0;
const default_format = 'JSONStringsEachRowWithProgress';
let controller = null;
/// Execute a single query, rendering results into the given targetResultEl.
/// Returns { format, reply, response_ok, is_table, is_raw, is_chart, is_error }.
/// The caller is responsible for top-level UI (download buttons, history, favicon).
/// `abortController` is an AbortController that can be used to cancel this query.
async function postImpl(posted_request_num, query, targetResultEl, abortController, paramValuesSnapshot)
{
const user = user_elem.value;
const password = password_elem.value;
const server_address = url_elem.value;
let url = server_address +
(server_address.indexOf('?') >= 0 ? '&' : '?') +
/// Ask server to allow cross-domain requests.
'add_http_cors_header=1';
if (user) url += '&user=' + encodeURIComponent(user);
if (password) url += '&password=' + encodeURIComponent(password);
url += '&default_format=' + default_format + '&enable_http_compression=1';
/// We will put the result in the query cache, so it can be used when download is initiated later.
/// But we don't use cached results, so the results that we get here, are always fresh.
url += '&use_query_cache=1&enable_reads_from_query_cache=0&enable_writes_to_query_cache=1&query_cache_ttl=600&query_cache_nondeterministic_function_handling=save&query_cache_system_table_handling=ignore';
for (const [name, value] of Object.entries(paramValuesSnapshot))
{
url += '&param_' + encodeURIComponent(name) + '=' + encodeURIComponent(value);
}
/// Extremes only if the format is likely to be unchanged. This is imprecise.
if (!query.match(/\bFORMAT\s+\w+/i)) url += '&extremes=1';
targetResultEl.queryText = query;
let is_table = false;
let is_raw = false;
let is_chart = false;
let is_error = false;
let response, reply, format;
reply = '';
try {
response = await fetch(url, { method: "POST", body: query, signal: abortController.signal,
headers: { 'Authorization': 'never' } });
format = response.headers.get('X-ClickHouse-Format') ?? default_format;
if (!response.ok) {
is_error = true;
reply = await response.text();
if (posted_request_num != request_num) { return { cancelled: true }; }
let has_exception = false;
for (const line of reply.split('\n')) {
if (line.startsWith(`{"exception":`)) {
targetResultEl.update(JSON.parse(line));
has_exception = true;
break;
}
}
if (!has_exception) {
targetResultEl.renderError(reply);
}
return { format, reply, response_ok: false, is_table, is_raw, is_chart, is_error };
} else if (format == default_format) {
is_table = true;
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (posted_request_num != request_num) { return { cancelled: true }; }
if (done) break;
let new_content = decoder.decode(value, { stream: true });
buffer += new_content;
reply += new_content;
let lines = buffer.split('\n');
let fragment = document.createDocumentFragment();
let cont = true;
for (const line of lines.slice(0, -1)) {
const data = JSON.parse(line);
cont = targetResultEl.update(data, fragment);
if (!cont) { break; }
}
targetResultEl.flushFragment(fragment);
if (!cont)
{
abortController.abort();
break;
}
buffer = lines[lines.length - 1];
}
} else if (format == 'JSONCompactColumns') {
is_chart = true;
reply = await response.text();
if (posted_request_num != request_num) { return { cancelled: true }; }
targetResultEl.renderChart(JSON.parse(reply));
} else {
is_raw = true;
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
let new_content = decoder.decode(value, { stream: true });
reply += new_content;
if (posted_request_num != request_num) { return { cancelled: true }; }
targetResultEl.updateRaw(new_content);
}
}
} catch (e) {
if (posted_request_num != request_num) { return { cancelled: true }; }
console.log(e);
if (e instanceof TypeError) {
reply = "Network error";
} else if (e.name === 'AbortError') {
reply = "Query was cancelled";
} else {
reply = e.toString();
}
targetResultEl.renderError(reply);
return { format, reply, response_ok: false, is_table: false, is_raw: false, is_chart: false, is_error: true };
}
if (targetResultEl.isExplainGraph) {
await targetResultEl.renderGraph();
} else {
if (targetResultEl._extremes['min'] && targetResultEl._extremes['max']) targetResultEl.renderExtremes();
if (is_table) targetResultEl.renderTotals();
if (is_table) targetResultEl.transposeIfNeeded();
}
return { format, reply, response_ok: true, is_table, is_raw, is_chart, is_error };
}
/// Execute a single query (legacy path, used for single-query mode).
/// When `history_query_text` is provided, it overrides `query` for browser history/URL storage,
/// preserving the full textarea content when running a selected query in multi-query mode.
async function postSingle(posted_request_num, query, history_query_text)
{
const paramValuesSnapshot = getParamValues();
last_query_start = performance.now();
document.getElementById('download-div').style.display = 'none';
document.getElementById('copy-div').style.display = 'none';
clear();
progressEl.start();
controller = new AbortController();
const result = await postImpl(posted_request_num, query, resultEl, controller, paramValuesSnapshot);
if (result.cancelled) return;
if (result.is_error || !result.response_ok) {
logoEl.style.display = 'none';
progressEl.clear();
document.getElementById('download-div').style.display = 'none';
document.getElementById('copy-div').style.display = 'none';
return;
}
/// `postImpl` already performed `renderGraph`/`renderExtremes`/`renderTotals`/`transposeIfNeeded`;
/// the only step specific to the single-query path is the final table layout pass.
if (result.is_table) resultEl._changeTableLayout();
progressEl.finish();
document.getElementById('download-div').style.display = 'block';
document.getElementById('copy-div').style.display = result.is_raw ? 'block' : 'none';
last_query_for_download = query;
last_params_for_download = paramValuesSnapshot;
if (result.is_raw) {
resultEl.elapsedNs = 1e6 * (performance.now() - last_query_start);
elapsed_ns = resultEl.elapsedNs;
} else {
elapsed_ns = resultEl.elapsedNs;
}
progressEl.updateText(resultEl.rowCount, resultEl.elapsedNs, resultEl.incompleteResult);
progressEl.style.setProperty('--progress', '0');
document.documentElement.style.setProperty('--background-color-rotate', queryToColor(query));
if (posted_request_num != request_num) { return; }
/// The query is saved in browser history (in a state JSON object)
/// as well as in URL fragment identifier.
/// When history_query_text is provided (multi-query "Run selected" with a single query),
/// use the full textarea content for history/URL to avoid losing the rest of the editor.
const saved_query = history_query_text || query;
if (saved_query != previous_query) {
const state = {
query: saved_query,
format: result.format,
ok: result.response_ok,
data: result.reply.length > 100000 ? null : result.reply,
elapsed_ns: elapsed_ns,
params: paramValuesSnapshot,
};
const title = "ClickHouse Query: " + saved_query;
const user = user_elem.value;
const server_address = url_elem.value;
let history_url = window.location.pathname + '?user=' + encodeURIComponent(user);
if (run_immediately) {
history_url += "&run=1";
}
if (server_address != location.origin) {
history_url += '&url=' + encodeURIComponent(server_address);
}
for (const [name, value] of Object.entries(paramValuesSnapshot))
{
history_url += '&param_' + encodeURIComponent(name) + '=' + encodeURIComponent(value);
}
history_url += '#' + toBase64(saved_query);
if (previous_query == '') {
history.replaceState(state, title, history_url);
} else {
history.pushState(state, title, history_url);
}
document.title = title;
previous_query = saved_query;
}
}
/// Container for dynamically created result elements in multi-query mode.
let multiQueryContainer = null;
/// Array of AbortControllers for all in-flight queries in multi-query mode.
let multiQueryControllers = [];
/// Execute multiple queries with parallelization of consecutive SELECTs.
/// `parsed` is an array of { query, is_select } objects from splitAllQueries.
async function postMulti(posted_request_num, parsed)
{
const paramValuesSnapshot = getParamValues();
last_query_start = performance.now();
document.getElementById('download-div').style.display = 'none';
document.getElementById('copy-div').style.display = 'none';
clear();
progressEl.start();
/// Hide the default single result element and the logo.
resultEl.style.display = 'none';
logoEl.style.display = 'none';
/// Create a container for multiple results if it doesn't exist.
if (!multiQueryContainer) {
multiQueryContainer = document.createElement('div');
multiQueryContainer.id = 'multi-query-results';
resultEl.parentElement.insertBefore(multiQueryContainer, resultEl);
}
multiQueryContainer.innerHTML = '';
multiQueryContainer.style.display = 'block';
multiQueryControllers = [];
/// Create result elements for each query.
const resultElements = [];
for (let i = 0; i < parsed.length; ++i) {
const el = document.createElement('query-result');
multiQueryContainer.appendChild(el);
resultElements.push(el);
}
/// Track aggregated progress across all concurrent queries.
const progressState = new Map(); /// query index -> { read_rows, read_bytes, total_rows, elapsed_ns }
function updateAggregatedProgress() {
let total_read_rows = 0, total_read_bytes = 0, total_total_rows = 0, max_elapsed_ns = 0;
for (const [, p] of progressState) {
total_read_rows += p.read_rows;
total_read_bytes += p.read_bytes;
total_total_rows += p.total_rows;
max_elapsed_ns = Math.max(max_elapsed_ns, p.elapsed_ns);
}
progressEl.updateProgress({
read_rows: String(total_read_rows),
read_bytes: String(total_read_bytes),
total_rows_to_read: String(total_total_rows),
elapsed_ns: String(max_elapsed_ns),
});
}
/// Listen for progress events from each result element.
for (let i = 0; i < resultElements.length; ++i) {
resultElements[i].addEventListener('query-progress', (e) => {
progressState.set(i, {
read_rows: +e.detail.read_rows,
read_bytes: +e.detail.read_bytes,
total_rows: +e.detail.total_rows_to_read,
elapsed_ns: +e.detail.elapsed_ns,
});
updateAggregatedProgress();
});
}
/// Group consecutive parallelizable queries together.
/// Each group is either a batch of SELECTs (run in parallel) or a single non-SELECT (barrier).
const groups = [];
let current_group = null;
for (let i = 0; i < parsed.length; ++i) {
if (parsed[i].is_select) {
if (!current_group || !current_group.parallelizable) {
current_group = { parallelizable: true, indices: [] };
groups.push(current_group);
}
current_group.indices.push(i);
} else {
current_group = { parallelizable: false, indices: [i] };
groups.push(current_group);
}
}
/// Execute groups sequentially; within a parallelizable group, execute all queries concurrently.
let had_error = false;
for (const group of groups) {
if (posted_request_num != request_num) return;
if (had_error) break;
const promises = group.indices.map(i => {
const ac = new AbortController();
multiQueryControllers.push(ac);
return postImpl(posted_request_num, parsed[i].query, resultElements[i], ac, paramValuesSnapshot)
.then(result => ({ index: i, result }));
});
const results = await Promise.all(promises);
for (const { index, result } of results) {
if (result.cancelled) return;
if (result.is_error || !result.response_ok) {
had_error = true;
}
}
}
if (posted_request_num != request_num) return;
/// Compute aggregated stats.
let total_rows = 0;
let max_elapsed_ns = 0;
let any_incomplete = false;
for (const el of resultElements) {
total_rows += el.rowCount;
max_elapsed_ns = Math.max(max_elapsed_ns, el.elapsedNs);
if (el.incompleteResult) any_incomplete = true;
}
if (!had_error) {
logoEl.style.display = 'block';
}
progressEl.finish();
elapsed_ns = max_elapsed_ns || 1e6 * (performance.now() - last_query_start);
progressEl.updateText(total_rows, elapsed_ns, any_incomplete);
progressEl.style.setProperty('--progress', '0');
document.documentElement.style.setProperty('--background-color-rotate', queryToColor(parsed[0].query));
if (posted_request_num != request_num) return;
/// Save all queries to browser history, using the full textarea content.
const all_queries_text = query_area.value;
if (all_queries_text != previous_query) {
const state = {
query: all_queries_text,
format: null,
ok: !had_error,
data: null,
elapsed_ns: elapsed_ns,
params: paramValuesSnapshot,
};
const title = "ClickHouse Query: " + parsed[0].query;
const user = user_elem.value;
const server_address = url_elem.value;
let history_url = window.location.pathname + '?user=' + encodeURIComponent(user);
if (run_immediately) {
history_url += "&run=1";
}
if (server_address != location.origin) {
history_url += '&url=' + encodeURIComponent(server_address);
}
for (const [name, value] of Object.entries(paramValuesSnapshot))
{
history_url += '&param_' + encodeURIComponent(name) + '=' + encodeURIComponent(value);
}
history_url += '#' + toBase64(all_queries_text);
if (previous_query == '') {
history.replaceState(state, title, history_url);
} else {
history.pushState(state, title, history_url);
}
document.title = title;
previous_query = all_queries_text;
}
}
async function restoreFromHistory(state) {
clear();
query_area.value = state.query;
query_area.dispatchEvent(new Event('input'));
resultEl.queryText = state.query;
/// Restore query parameter inputs and their values.
await updateQueryParams();
if (state.params)
{
setParamValues(state.params);
}
if (!state.data) return;
let is_table = false;
let is_raw = false;
let is_chart = false;
let is_error = false;
let format = state.format;
if (!state.ok) {
is_error = true;
let has_exception = false;
for (const line of state.data.split('\n')) {
if (line.startsWith(`{"exception":`)) {
resultEl.update(JSON.parse(line));
has_exception = true;
break;
}
}
if (!has_exception) {
resultEl.renderError(state.data);
logoEl.style.display = 'none';
}
progressEl.clear();
return;
} else if (format == default_format) {
is_table = true;
let lines = state.data.split('\n');
let fragment = document.createDocumentFragment();
for (const line of lines) {
if (line.length == 0) continue;
if (!resultEl.update(JSON.parse(line), fragment)) { break; }
}
resultEl.flushFragment(fragment);
} else if (format == 'JSONCompactColumns') {
is_chart = true;
if (posted_request_num != request_num) { return; }
resultEl.renderChart(JSON.parse(state.data));
} else {
is_raw = true;
resultEl.updateRaw(state.data);
}
if (resultEl.isExplainGraph) {
await resultEl.renderGraph();
} else {
if (resultEl._extremes['min'] && resultEl._extremes['max']) resultEl.renderExtremes();
if (is_table) resultEl.renderTotals();
if (is_table) resultEl.transposeIfNeeded();
if (is_table) resultEl._changeTableLayout();
}
progressEl.finish();
document.getElementById('copy-div').style.display = is_raw ? 'block' : 'none';
if (is_raw) {
resultEl.elapsedNs = state.elapsed_ns;
}
elapsed_ns = resultEl.elapsedNs;
progressEl.updateText(resultEl.rowCount, resultEl.elapsedNs, resultEl.incompleteResult);
progressEl.style.setProperty('--progress', '0');
document.documentElement.style.setProperty('--background-color-rotate', queryToColor(query));
}
let query_area = document.getElementById('query');
appendTextareaResizer();
window.onpopstate = function(event) {
if (!event.state) return;
restoreFromHistory(event.state);
};
if (window.location.hash) {
query_area.value = fromBase64(window.location.hash.substr(1));
query_area.dispatchEvent(new Event('input'));
}
let in_flight = false;
// Favicon changes to gray while a query is running, to indicate progress on the browser tab.
const favicon_default = document.querySelector('link[rel="icon"]').href;
const favicon_running = 'data:image/svg+xml;base64,' + btoa(atob(favicon_default.split(',')[1]).replace('#ff0', '#bbb'));
function setFavicon(href) { document.querySelector('link[rel="icon"]').href = href; }
function beginFlight() {
in_flight = true;
setFavicon(favicon_running);
document.getElementById('run').innerText = 'Stop';
document.getElementById('run-all').style.display = 'none';
}
function endFlight() {
in_flight = false;
document.getElementById('run').blur();
updateRunButtons(isMultiQuery);
setFavicon(favicon_default);
}
/// Run the query under cursor, or all queries intersecting the selection.
async function postOne()
{
++request_num;
document.body.scrollTo(0, 0);
beginFlight();
const has_selection = query_area.selectionStart !== query_area.selectionEnd;
if (has_selection && isMultiQuery) {
/// Find all queries that intersect the selection and run them.
const all_text = query_area.value;
const parsed = await splitAllQueries(all_text);
const selStart = query_area.selectionStart;
const selEnd = query_area.selectionEnd;
/// Use start/end offsets from splitAllQueries to find which queries overlap with the selection.
const selected = [];
for (let i = 0; i < parsed.length; ++i) {
if (parsed[i].end > selStart && parsed[i].start < selEnd) {
selected.push(parsed[i]);
}
}
if (selected.length > 1) {
await postMulti(request_num, selected);
} else if (selected.length === 1) {
await postSingle(request_num, selected[0].query, all_text);
}
} else {
const query = await getQueryUnderCursor();
/// In multi-query mode, preserve the full textarea content in history/URL
/// even when running a single query under cursor (so refresh restores all queries).
await postSingle(request_num, query, isMultiQuery ? query_area.value : undefined);
}
endFlight();
}
/// Run all queries from the textarea.
async function postAll()
{
++request_num;
document.body.scrollTo(0, 0);
beginFlight();
const all_text = query_area.value;
const parsed = await splitAllQueries(all_text);
if (parsed.length > 1) {
await postMulti(request_num, parsed);
} else {
const query = await getQueryUnderCursor();
await postSingle(request_num, query);
}
endFlight();
}
async function cancel()
{
/// Invalidate in-flight requests so postImpl/postSingle/postMulti will
/// see a stale posted_request_num and bail out before saving history.
++request_num;
/// Cancel single-query mode controller.
if (controller) controller.abort();
/// Cancel all multi-query mode controllers.
for (const ac of multiQueryControllers) {
ac.abort();
}
multiQueryControllers = [];
endFlight();
}
function storeCredentials() {
if (password_elem.value) {
const cred = new PasswordCredential({
id: user_elem.value,
password: password_elem.value,
name: url_elem.value,
});
navigator.credentials.store(cred);
}
}
/// The "Run" / "Run one" button submits the form; handle it here.
document.getElementById('controls').addEventListener('submit', e =>
{
e.preventDefault();
if (in_flight) {
cancel();
} else {
postOne();
storeCredentials();
}
});
/// The "Run all" button.
document.getElementById('run-all').addEventListener('click', e =>
{
e.preventDefault();
if (!in_flight) {
postAll();
storeCredentials();
}
});
document.addEventListener('keydown', event => {
/// Firefox has code 13 for Enter and Chromium has code 10.
if ((event.metaKey || event.ctrlKey) && (event.keyCode == 13 || event.keyCode == 10)) {
if (in_flight) {
cancel();
} else if (event.shiftKey && isMultiQuery) {
/// Ctrl/Cmd+Shift+Enter runs all queries in multi-query mode.
postAll();
storeCredentials();
} else {
postOne();
storeCredentials();
}
event.preventDefault();
}
});
/// 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) {
document.execCommand('insertText', false, ' ');
e.preventDefault();
return false;
} else if (e.key === 'Enter' && !(event.metaKey || event.ctrlKey)) {
// If the user presses Enter, and the previous line starts with spaces,
// then we will insert the same number of spaces.
const elem = e.target;
if (elem.selectionStart !== elem.selectionEnd) {
// If there is a selection, then we will not insert spaces.
return;
}
const cursor_pos = elem.selectionStart;
const elem_value = elem.value;
const text_before_cursor = elem_value.substring(0, cursor_pos);
const text_after_cursor = elem_value.substring(cursor_pos);
const prev_lines = text_before_cursor.split('\n');
const prev_line = prev_lines.pop();
const lead_spaces = prev_line.match(/^\s*/)[0];
if (!lead_spaces) {
return;
}
// Add leading spaces to the current line.
elem.value = text_before_cursor + '\n' + lead_spaces + text_after_cursor;
elem.selectionStart = cursor_pos + lead_spaces.length + 1;
elem.selectionEnd = elem.selectionStart;
e.preventDefault();
return false;
} else if ((e.ctrlKey || e.metaKey) && e.key === '/' && !e.shiftKey) {
// Comment/uncomment selected lines on Ctrl+/ or Cmd+/
const elem = e.target;
const start = elem.selectionStart;
const end = elem.selectionEnd;
const value = elem.value;
let lineStart = value.lastIndexOf('\n', start - 1) + 1;
let lineEnd = value.indexOf('\n', end);
if (lineEnd === -1) lineEnd = value.length;
let selectedText = value.substring(lineStart, lineEnd);
let lines = selectedText.split('\n');
// Comment out each line, or uncomment if all lines are already commented
const allCommented = lines.every(line => /^\s*--/.test(line));
// If all lines are commented out, uncomment them
if (allCommented) {
lines = lines.map(line => line.replace(/^(\s*)-- ?/, '$1'));
} else {
lines = lines.map(line => {
if (/^\s*$/.test(line)) {
return line;
}
return line.replace(/^(\s*)/, '$1-- ');
});
}
const replacedText = lines.join('\n');
elem.value = value.slice(0, lineStart) + replacedText + value.slice(lineEnd);
// Adjust selection to keep the user in flow and enable quick reversal
elem.selectionStart = lineStart;
elem.selectionEnd = lineStart + replacedText.length;
e.preventDefault();
return false;
}
};
function clear()
{
resultEl.clear();
resultEl.style.display = '';
progressEl.clear();
logoEl.style.display = 'block';
elapsed_ns = 0;
/// Clean up multi-query results.
if (multiQueryContainer) {
multiQueryContainer.innerHTML = '';
multiQueryContainer.style.display = 'none';
}
}
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);
}
/* Clicking outside data cells deactivates the selected cell */
document.addEventListener('click', e => {
if (e.target.closest('query-result')) return;
resultEl.clearSelection();
});
function appendTextareaResizer() {
const queryDiv = document.getElementById('query_div');
const textarea = document.getElementById('query');
let drag_state = {
elem: null,
is_dragging: false,
offset_y: null,
offset_height: null
};
const start = (e) => {
if (e.button !== 0) { return; }
drag_state.offset_y = e.clientY;
drag_state.offset_height = textarea.offsetHeight;
drag_state.is_dragging = true;
drag_state.elem = e.target;
drag_state.elem.classList.add('textarea-resizer-dragging');
document.addEventListener('pointermove', move);
document.addEventListener('pointerup', stop);
document.addEventListener('pointercancel', stop);
}
const move = (e) => {
if (!drag_state.is_dragging) { return; }
const dy = e.clientY - drag_state.offset_y;
const newHeight = drag_state.offset_height + dy;
textarea.style.height = `${newHeight}px`;
}
const stop = (e) => {
drag_state.is_dragging = false;
drag_state.elem.classList.remove('textarea-resizer-dragging');
document.removeEventListener('pointermove', move);
document.removeEventListener('pointerup', stop);
document.removeEventListener('pointercancel', stop);
}
const resizer = document.createElement('div');
resizer.className = 'textarea-resizer';
resizer.addEventListener('pointerdown', start);
resizer.addEventListener('touchstart', (e) => e.preventDefault());
queryDiv.appendChild(resizer);
}
let lexer_module;
async function loadLexer() {
// base64 -w0 build/src/Parsers/Lexer.wasm
const lexer_base64 = "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A08NAkEBIQgDQCACLQAAIgVBMGshAQJAAkAgBwRAIAFB/wFxQQpJIAVBwQBrQQZJcg0CIAVB4QBrQQZPDQEMAgsgAUH/AXFBCkkNAQsgCCAFQd8AR3JBAXENBAJAIAJBAWoiCSADTw0AIAktAAAiBUEwayEBIAcEQCABQf8BcUEKSSAFQcEAa0EGSXINASAFQeEAa0EGTw0HDAELIAFB/wFxQQlLDQYLIAMgCUYNBAsgBiACQQFqIgI2AgBBACEIIAIgA0kNAAsMAgsgBiACQQFqIgI2AgBBACEIIAIgA0cNAAsLIAJBAWoiASADTw0AIAItAAAhBQJAIAcEQCAFQSByQfAARw0CDAELIAVBIHJB5QBHDQELIAYgATYCAAJAIAJBAmogA08NAAJAIAEtAABBK2sOAwABAAELIAYgAUEBaiIBNgIAIAEgA08NAgtBASECA0AgAS0AACIFQTBrQf8BcUEKTwRAIAIgBUHfAEdyQQFxDQMgAUEBaiICIANPDQMgAi0AAEEwa0H/AXFBCUsNAwsgBiABQQFqIgE2AgBBACECIAEgA0cNAAsMAQsgAiEBCwJ/AkACQCABIANPDQAgAS0AACICQd8ARiACQTBrQf8BcUEKSXJFIAJB3wFxQcEAa0H/AXFBGUtxDQAgAUEBaiEFA0AgBiAFIgE2AgAgASADTw0CIAFBAWohBSABLQAAIgdBMGshAiAHQd8ARiACQf8BcUEKSXIgB0HfAXFBwQBrQf8BcUEaSXINAAsgAUEBawwCCyAAIAE2AggMCgsgAUEBawshAwJAIAEgBE0NACAEQQFrIQUDQCAFQQFqIgUtAAAiAkEkRiACQd8ARnIgAkEwa0H/AXFBCklyRSACQd8BcUHBAGtB/wFxQRpPcUUEQCADIAVLDQEMAgsLIAAgATYCCCAAIAQ2AgQgAEEvOgAADwsMCQsgBiAEQQJqIgE2AgACQCABIANGDQADQCABLQAAQQpGDQEgAUEBaiIBIANHDQALIAMhAQsLIAAgATYCCCAAIAQ2AgQgAEEBOgAACyAGIAE2AgAPCyAAIAE2AgggACAENgIEIABBADoAAA8LIAAgBEEBaiIANgIIDAILIAAgBEECaiIANgIIDAELIAAgATYCCCAAIAQ2AgQgAEEoOgAADwsgBiAANgIADwsgACAENgIEIABBAzoAAA8LIAAgATYCCCAAIAQ2AgQgAEECOgAACyUAIABBADoAECAAIAM2AgwgACACNgIIIAAgATYCBCAAIAE2AgALNgEBfyMAQRBrIgMkACADQQRqIAAQASABIAMoAgg2AgAgAiADKAIMNgIAIAMtAAQgA0EQaiQACwcAIABBAUsLBwAgAEEnSwsHACAAQSdGCwsIAQBBgAgLARQAeQlwcm9kdWNlcnMBDHByb2Nlc3NlZC1ieQEFY2xhbmdZMjAuMC4wZ2l0IChnaXRAZ2l0aHViLmNvbTpsbHZtL2xsdm0tcHJvamVjdC5naXQgNmNiYzY0ZWQ5MjJjYzY5YmMyOTJkMzk0YmE1YzY4MWZhMzA5ZjQwNCkAaw90YXJnZXRfZmVhdHVyZXMGKw9tdXRhYmxlLWdsb2JhbHMrE25vbnRyYXBwaW5nLWZwdG9pbnQrC2J1bGstbWVtb3J5KwhzaWduLWV4dCsPcmVmZXJlbmNlLXR5cGVzKwptdWx0aXZhbHVl";
if (!lexer_module) {
const binary = atob(lexer_base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
lexer_module = await WebAssembly.instantiate(bytes);
}
}
async function tokenize(query) {
await loadLexer();
let exports = lexer_module.instance.exports;
let buffer = exports.memory.buffer;
let memory_offset = 0;
/// Allocate memory for the lexer object
const lexer = new Uint8Array(buffer, memory_offset, exports.clickhouse_lexer_size);
memory_offset += exports.clickhouse_lexer_size;
/// Allocate the query
const bytes = new TextEncoder().encode(query);
const query_array = new Uint8Array(buffer, memory_offset, bytes.length);
query_array.set(bytes);
const query_begin = memory_offset;
memory_offset += bytes.length;
const query_end = memory_offset;
/// Initialize the lexer
exports.clickhouse_lexer_create(lexer, query_begin, query_end, 65536);
/// Allocate the out ptrs
const token_begin = memory_offset;
memory_offset += 4;
const token_end = memory_offset;
memory_offset += 4;
let result = [];
while (true) {
const token_type = exports.clickhouse_lexer_next_token(lexer, token_begin, token_end);
if (exports.clickhouse_lexer_token_is_error(token_type) || exports.clickhouse_lexer_token_is_end(token_type)) {
break;
}
const view = new DataView(buffer);
const begin = view.getUint32(token_begin, true);
const end = view.getUint32(token_end, true);
const token_bytes = new Uint8Array(buffer, begin, end - begin);
let token = new TextDecoder().decode(token_bytes);
result.push({type: token_type, significant: exports.clickhouse_lexer_token_is_significant(token_type), token: token});
}
return result;
}
async function getQueryUnderCursor() {
const all_queries = query_area.value;
if (typeof WebAssembly !== 'object' || typeof WebAssembly.instantiate !== 'function') {
return all_queries;
}
const tokens = await tokenize(all_queries);
const cursor_position = query_area.selectionStart;
let current_query_start = 0;
let current_offset = 0;
for (const elem of tokens) {
if (current_query_start == current_offset && !elem.significant) {
current_query_start += elem.token.length;
}
current_offset += elem.token.length;
if (elem.token == ';') {
if (current_offset >= cursor_position) {
query_area.setSelectionRange(current_query_start, current_offset);
query_area.focus();
return all_queries.substring(current_query_start, current_offset);
} else {
current_query_start = current_offset;
}
}
}
/// Cursor is in the last query (no trailing semicolon).
const tail = all_queries.substring(current_query_start);
query_area.setSelectionRange(current_query_start, all_queries.length);
query_area.focus();
return tail;
}
const PARALLELIZABLE_KEYWORDS = new Set([
'SELECT', 'SHOW', 'DESCRIBE', 'DESC', 'EXISTS', 'EXPLAIN', 'WITH'
]);
/// Split the textarea content into individual queries using the lexer.
/// Returns an array of { query, is_select } objects.
/// `is_select` is true for queries that start with a parallelizable keyword
/// (after skipping whitespace, comments, and opening brackets).
async function splitAllQueries(text) {
if (typeof WebAssembly !== 'object' || typeof WebAssembly.instantiate !== 'function') {
return [{ query: text, is_select: PARALLELIZABLE_KEYWORDS.has(text.trimStart().split(/\s/)[0].toUpperCase()), start: 0, end: text.length }];
}
const tokens = await tokenize(text);
const queries = [];
let current_query_start = 0;
let current_offset = 0;
let first_keyword = null; /// First significant non-bracket token of the current query.
let has_significant = false;
let first_significant_offset = -1;
let last_significant_end = -1;
for (const elem of tokens) {
current_offset += elem.token.length;
if (elem.token == ';') {
if (has_significant) {
const q = text.substring(current_query_start, current_offset).trim();
if (q && q !== ';') {
queries.push({
query: q,
is_select: first_keyword !== null && PARALLELIZABLE_KEYWORDS.has(first_keyword),
start: first_significant_offset,
end: last_significant_end,
});
}
}
current_query_start = current_offset;
first_keyword = null;
has_significant = false;
first_significant_offset = -1;
last_significant_end = -1;
} else if (elem.significant) {
if (!has_significant) {
first_significant_offset = current_offset - elem.token.length;
}
last_significant_end = current_offset;
has_significant = true;
if (first_keyword === null && elem.token !== '(') {
first_keyword = elem.token.toUpperCase();
}
}
}
/// Last query (no trailing semicolon)
if (has_significant) {
const last = text.substring(current_query_start).trim();
if (last && last !== ';') {
queries.push({
query: last,
is_select: first_keyword !== null && PARALLELIZABLE_KEYWORDS.has(first_keyword),
start: first_significant_offset,
end: last_significant_end,
});
}
}
return queries;
}
/// Return an array of { start, end } character offsets for each non-empty query.
/// Includes surrounding whitespace/semicolons so spans cover the full text without gaps.
/// Used by updateQueryParams to detect multi-query mode and to highlight query regions.
function getQueryBoundaries(tokens, text) {
const boundaries = [];
let current_query_start = 0;
let current_offset = 0;
let has_significant = false;
let first_significant_offset = -1;
let last_significant_end = -1;
for (const elem of tokens) {
current_offset += elem.token.length;
if (elem.token == ';') {
if (has_significant) {
boundaries.push({ start: first_significant_offset, end: last_significant_end });
}
current_query_start = current_offset;
has_significant = false;
first_significant_offset = -1;
last_significant_end = -1;
} else if (elem.significant) {
if (!has_significant) {
first_significant_offset = current_offset - elem.token.length;
}
last_significant_end = current_offset;
has_significant = true;
}
}
/// Last query (no trailing semicolon).
if (has_significant) {
boundaries.push({ start: first_significant_offset, end: last_significant_end });
}
return boundaries;
}
/// Show/hide the "Run all" button and relabel "Run" depending on multi-query mode and selection.
let isMultiQuery = false;
function updateRunButtons(multi) {
isMultiQuery = multi;
const runBtn = document.getElementById('run');
const runAllBtn = document.getElementById('run-all');
const has_selection = query_area.selectionStart !== query_area.selectionEnd;
const selects_all = has_selection
&& query_area.selectionStart === 0
&& query_area.selectionEnd >= query_area.value.length;
if (!in_flight) {
if (!multi) {
runBtn.innerText = 'Run';
} else {
runBtn.innerText = has_selection ? 'Run selected' : 'Run one';
}
}
runAllBtn.style.display = (multi && !selects_all) ? 'inline-block' : 'none';
}
/// Update the backdrop div behind the textarea to highlight individual queries
/// with alternating faint background colors.
const queryBackdrop = document.getElementById('query-backdrop');
function escapeHTML(s) {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
/// Cached boundaries for cursor-tracking updates.
let currentBoundaries = [];
let currentBackdropText = '';
function updateQueryBackdrop(text, boundaries) {
currentBoundaries = boundaries;
currentBackdropText = text;
renderQueryBackdrop();
}
function renderQueryBackdrop() {
const text = currentBackdropText;
const boundaries = currentBoundaries;
/// Update button labels to reflect current selection state.
updateRunButtons(boundaries.length > 1);
if (boundaries.length <= 1) {
queryBackdrop.style.display = 'none';
query_area.classList.remove('has-backdrop');
return;
}
const cursor = query_area.selectionStart;
const focused = document.activeElement === query_area;
/// Find which query contains the cursor (only when textarea is focused).
let activeIdx = -1;
if (focused) for (let i = 0; i < boundaries.length; ++i) {
if (cursor >= boundaries[i].start && cursor <= boundaries[i].end) {
activeIdx = i;
break;
}
}
let html = '';
let pos = 0;
for (let i = 0; i < boundaries.length; ++i) {
const b = boundaries[i];
if (b.start > pos) {
html += escapeHTML(text.substring(pos, b.start));
}
if (i === activeIdx) {
html += '<span class="q-active">' + escapeHTML(text.substring(b.start, b.end)) + '</span>';
} else {
html += escapeHTML(text.substring(b.start, b.end));
}
pos = b.end;
}
if (pos < text.length) {
html += escapeHTML(text.substring(pos));
}
/// Trailing newline fix: browsers collapse a trailing \n in a div.
if (html.endsWith('\n')) html += ' ';
queryBackdrop.innerHTML = html;
queryBackdrop.style.display = 'block';
query_area.classList.add('has-backdrop');
/// Match the scrollbar: reserve extra right padding so text wraps identically.
/// clientWidth excludes borders and scrollbar; offsetWidth includes borders.
/// The border is 1px on each side, so scrollbar = offsetWidth - clientWidth - 2.
const scrollbarWidth = query_area.offsetWidth - query_area.clientWidth - 2;
queryBackdrop.style.paddingRight = scrollbarWidth > 0 ? 'calc(0.25rem + ' + scrollbarWidth + 'px)' : '';
queryBackdrop.scrollTop = query_area.scrollTop;
queryBackdrop.scrollLeft = query_area.scrollLeft;
}
/// Re-render backdrop when cursor moves or selection changes.
query_area.addEventListener('click', renderQueryBackdrop);
query_area.addEventListener('keyup', renderQueryBackdrop);
query_area.addEventListener('select', renderQueryBackdrop);
query_area.addEventListener('focus', renderQueryBackdrop);
query_area.addEventListener('blur', renderQueryBackdrop);
/// Keep backdrop scroll in sync with textarea.
query_area.addEventListener('scroll', () => {
queryBackdrop.scrollTop = query_area.scrollTop;
queryBackdrop.scrollLeft = query_area.scrollLeft;
});
/// Query parameters support: detect {name:Type} placeholders and show input fields.
let queryParamsContainer = document.getElementById('query-params');
let currentQueryParams = [];
let updateQueryParamsGeneration = 0;
function extractQueryParams(tokens)
{
const params = [];
const seen = new Set();
const significant = tokens.filter(t => t.significant);
for (let i = 0; i < significant.length - 3; i++)
{
if (significant[i].token === '{' && significant[i + 2].token === ':')
{
const name = significant[i + 1].token;
if (/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name) && !seen.has(name))
{
for (let j = i + 3; j < significant.length; j++)
{
if (significant[j].token === '}')
{
const type = significant.slice(i + 3, j).map(t => t.token).join('');
params.push({name: name, type: type});
seen.add(name);
break;
}
}
}
}
}
return params;
}
function getParamValues()
{
const values = {};
for (const param of currentQueryParams)
{
const input = document.getElementById('param-' + param.name);
if (input)
{
values[param.name] = input.value;
}
}
return values;
}
function setParamValues(values)
{
if (!values) return;
for (const [name, value] of Object.entries(values))
{
const input = document.getElementById('param-' + name);
if (input)
{
input.value = value;
input.dispatchEvent(new Event('input'));
}
}
}
async function updateQueryParams()
{
if (typeof WebAssembly !== 'object' || typeof WebAssembly.instantiate !== 'function')
{
return;
}
const query = query_area.value;
if (!query)
{
queryParamsContainer.innerHTML = '';
currentQueryParams = [];
updateRunButtons(false);
updateQueryBackdrop('', []);
return;
}
const generation = ++updateQueryParamsGeneration;
const tokens = await tokenize(query);
if (generation !== updateQueryParamsGeneration) { return; }
const params = extractQueryParams(tokens);
/// Detect multi-query mode from the same token stream.
const boundaries = getQueryBoundaries(tokens, query);
updateRunButtons(boundaries.length > 1);
updateQueryBackdrop(query, boundaries);
if (JSON.stringify(params) === JSON.stringify(currentQueryParams))
{
return;
}
const oldValues = getParamValues();
currentQueryParams = params;
queryParamsContainer.innerHTML = '';
for (const param of params)
{
const wrapper = document.createElement('span');
wrapper.className = 'query-param';
const label = document.createElement('label');
label.textContent = param.name + ':';
label.setAttribute('for', 'param-' + param.name);
wrapper.appendChild(label);
let placeholder = param.type;
if (/^Date$/.test(param.type)) placeholder = 'YYYY-MM-DD';
else if (/^DateTime/.test(param.type)) placeholder = 'YYYY-MM-DD hh:mm:ss';
const input = document.createElement('input');
input.type = 'text';
input.id = 'param-' + param.name;
input.className = 'monospace shadow';
input.spellcheck = false;
input.placeholder = placeholder;
if (oldValues[param.name] !== undefined)
{
input.value = oldValues[param.name];
}
input.addEventListener('input', function()
{
/// Resize to fit the value, clamped between 10em and 20em.
this.style.width = '10em';
if (this.scrollWidth > this.clientWidth)
{
this.style.width = Math.min(this.scrollWidth, this.clientWidth * 2) + 'px';
}
});
wrapper.appendChild(input);
queryParamsContainer.appendChild(wrapper);
/// Trigger resize after the element is in the DOM so scrollWidth is valid.
input.dispatchEvent(new Event('input'));
}
}
let paramDebounceTimer = null;
query_area.addEventListener('input', () =>
{
clearTimeout(paramDebounceTimer);
paramDebounceTimer = setTimeout(updateQueryParams, 100);
});
function setColorTheme(new_theme, update_preference) {
theme = new_theme;
if (update_preference) {
window.localStorage.setItem('theme', theme);
}
document.documentElement.setAttribute('data-theme', theme);
resultEl.redrawChart();
}
/// 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) {
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');
} else {
setColorTheme('light');
}
/// 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');
});
}
/// Detect query parameters on page load and restore their values from the URL.
/// If run_immediately is set, run the query after params are ready.
if (query_area.value)
{
updateQueryParams().then(() =>
{
const urlParamValues = {};
for (const param of currentQueryParams)
{
const value = current_url.searchParams.get('param_' + param.name);
if (value !== null)
{
urlParamValues[param.name] = value;
}
}
setParamValues(urlParamValues);
if (run_immediately)
{
postAll();
}
});
}
else if (run_immediately)
{
postAll();
}
document.getElementById('toggle-light').onclick = function() {
setColorTheme('light', true);
}
document.getElementById('toggle-dark').onclick = function() {
setColorTheme('dark', true);
}
// Copy raw result to clipboard
document.getElementById('copy-div').addEventListener('click', function() {
let text = resultEl.rawText;
if (!text) return;
let button = this;
let showBalloon = function(message) {
let balloon = document.createElement('span');
balloon.className = 'copy-balloon';
balloon.textContent = message;
button.appendChild(balloon);
balloon.addEventListener('animationend', () => balloon.remove());
};
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(
() => showBalloon('Result copied'),
() => showBalloon('Copy failed'));
} else {
showBalloon('Copy not supported');
}
});
// Download functionality
let last_query_for_download = '';
let last_params_for_download = {};
// Toggle download dropdown
document.getElementById('download-div').addEventListener('click', function(e) {
e.stopPropagation();
const dropdown = document.getElementById('download-dropdown');
const button = document.getElementById('download-div');
const context = document.getElementById('main');
if (dropdown.classList.contains('show')) {
dropdown.classList.remove('show');
} else {
// Position the dropdown relative to the button
dropdown.classList.add('show');
const button_rect = button.getBoundingClientRect();
const context_rect = context.getBoundingClientRect();
const dropdown_rect = dropdown.getBoundingClientRect();
dropdown.style.top = (button_rect.top - context_rect.top) + 'px';
dropdown.style.left = (button_rect.left - context_rect.left + button_rect.width - dropdown_rect.width) + 'px';
}
});
// Prevent dropdown from closing when clicking inside it
document.getElementById('download-dropdown').addEventListener('click', function(e) {
e.stopPropagation();
});
// Close dropdown when clicking outside
document.addEventListener('click', function() {
const dropdown = document.getElementById('download-dropdown');
dropdown.classList.remove('show');
});
// Handle "Other" format selection
document.getElementById('download-format').addEventListener('change', function() {
const otherInput = document.getElementById('download-format-other');
if (this.value === 'other') {
otherInput.classList.add('show');
otherInput.focus();
} else {
otherInput.classList.remove('show');
}
});
// Handle download button click
document.getElementById('download-button').addEventListener('click', async function() {
const formatSelect = document.getElementById('download-format');
const formatOther = document.getElementById('download-format-other');
let format = formatSelect.value;
if (format === 'other') {
format = formatOther.value.trim();
if (!format) {
alert('Please enter a format name');
return;
}
}
// Get current values
const user = user_elem.value;
const password = password_elem.value;
const server_address = url_elem.value;
const query = last_query_for_download;
if (!query) {
alert('No query to download results for');
return;
}
// Build download URL
let url = server_address +
(server_address.indexOf('?') >= 0 ? '&' : '?') +
'add_http_cors_header=1' +
'&enable_http_compression=1' +
'&use_query_cache=1&enable_reads_from_query_cache=1&enable_writes_to_query_cache=0&query_cache_ttl=600&query_cache_nondeterministic_function_handling=save&query_cache_system_table_handling=ignore' +
'&default_format=' + encodeURIComponent(format) +
'&http_response_headers=' + encodeURIComponent(`{'Content-Disposition':'attachment; filename="result.${encodeURIComponent(format.toLowerCase())}"'}`);
if (user) url += '&user=' + encodeURIComponent(user);
if (password) url += '&password=' + encodeURIComponent(password);
for (const [name, value] of Object.entries(last_params_for_download))
{
url += '&param_' + encodeURIComponent(name) + '=' + encodeURIComponent(value);
}
let downloadQuery = query.replaceAll(/\bFORMAT\s+\w+/ig, '');
// Create a form and submit it to trigger download
const form = document.createElement('form');
form.method = 'POST';
form.action = url;
form.enctype = 'multipart/form-data';
form.style.display = 'none';
const queryInput = document.createElement('input');
queryInput.type = 'hidden';
queryInput.name = 'query';
queryInput.value = downloadQuery;
form.appendChild(queryInput);
document.body.appendChild(form);
form.submit();
document.body.removeChild(form);
// Close dropdown
document.getElementById('download-dropdown').classList.remove('show');
});
// Web Terminal integration
{
const terminalIcon = document.getElementById('terminal-icon');
const terminalPanel = document.getElementById('terminal-panel');
const terminalResizer = document.getElementById('terminal-resizer');
const mainEl = document.getElementById('main');
let terminalIframe = null;
let terminalActive = false;
let terminalHiddenByResize = false;
const MIN_TERMINAL_HEIGHT = 20;
/// Hide the terminal icon if /webterminal is not available (gated by allow_experimental_webterminal)
terminalIcon.style.display = 'none';
fetch('/webterminal', {method: 'HEAD'}).then(function(resp) {
if (resp.ok) terminalIcon.style.display = '';
}).catch(function() {});
function getTerminalURL() {
return '/webterminal';
}
function sendCredentialsToTerminal() {
if (terminalIframe && terminalIframe.contentWindow) {
terminalIframe.contentWindow.postMessage({
type: 'webterminal-credentials',
user: user_elem.value || 'default',
password: password_elem.value || ''
}, window.location.origin);
}
}
function activateTerminal() {
if (terminalActive) return;
terminalActive = true;
terminalIcon.classList.add('active');
terminalPanel.classList.add('active');
if (!terminalIframe) {
terminalPanel.style.height = Math.round(window.innerHeight * 0.4) + 'px';
terminalIframe = document.createElement('iframe');
terminalIframe.id = 'terminal-iframe';
terminalIframe.src = getTerminalURL();
terminalIframe.addEventListener('load', sendCredentialsToTerminal);
terminalPanel.insertBefore(terminalIframe, terminalResizer);
} else if (terminalHiddenByResize) {
terminalPanel.style.height = Math.round(window.innerHeight * 0.4) + 'px';
}
terminalHiddenByResize = false;
// Tell the iframe to refit the terminal after becoming visible again, and focus it
if (terminalIframe && terminalIframe.contentWindow) {
terminalIframe.contentWindow.postMessage({type: 'webterminal-refit'}, window.location.origin);
terminalIframe.focus();
}
}
/// Hide the terminal panel but keep the iframe alive so the session persists.
function hideTerminal() {
if (!terminalActive) return;
terminalActive = false;
terminalIcon.classList.remove('active');
terminalPanel.classList.remove('active');
}
/// Destroy the terminal: remove the iframe and hide the panel.
function closeTerminal() {
hideTerminal();
if (terminalIframe) {
terminalIframe.remove();
terminalIframe = null;
}
}
function toggleTerminal() {
if (terminalActive) hideTerminal();
else activateTerminal();
}
terminalIcon.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
toggleTerminal();
});
// ~ key toggles terminal (Quake-style), but not when typing in inputs
document.addEventListener('keydown', function(e) {
if ((e.key === '`' || e.key === '~') && !e.ctrlKey && !e.altKey && !e.metaKey) {
const tag = e.target.tagName;
if (tag === 'INPUT' || tag === 'SELECT' || e.target.isContentEditable) return;
// In the query textarea: activate terminal if ~ is typed into an empty field
if (tag === 'TEXTAREA' && e.target.id === 'query') {
if (!terminalActive && e.target.value === '') {
e.preventDefault();
activateTerminal();
}
return;
}
e.preventDefault();
toggleTerminal();
}
});
// Drag-to-resize terminal panel
let termDrag = { active: false, startY: 0, startHeight: 0 };
terminalResizer.addEventListener('pointerdown', function(e) {
if (e.button !== 0) return;
termDrag.active = true;
termDrag.startY = e.clientY;
termDrag.startHeight = terminalPanel.offsetHeight;
terminalResizer.classList.add('terminal-resizer-dragging');
// Overlay to prevent iframe from stealing pointer events
const overlay = document.createElement('div');
overlay.id = 'terminal-drag-overlay';
overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;z-index:9999;cursor:row-resize;';
document.body.appendChild(overlay);
document.addEventListener('pointermove', termMove);
document.addEventListener('pointerup', termStop);
document.addEventListener('pointercancel', termStop);
});
function termMove(e) {
if (!termDrag.active) return;
const newHeight = termDrag.startHeight + (e.clientY - termDrag.startY);
if (newHeight < MIN_TERMINAL_HEIGHT) {
terminalHiddenByResize = true;
hideTerminal();
termStop(e);
return;
}
terminalPanel.style.height = newHeight + 'px';
}
function termStop() {
termDrag.active = false;
terminalResizer.classList.remove('terminal-resizer-dragging');
const overlay = document.getElementById('terminal-drag-overlay');
if (overlay) overlay.remove();
document.removeEventListener('pointermove', termMove);
document.removeEventListener('pointerup', termStop);
document.removeEventListener('pointercancel', termStop);
}
terminalResizer.addEventListener('touchstart', function(e) { e.preventDefault(); });
// Listen for messages from the webterminal iframe
window.addEventListener('message', function(e) {
if (!e.data) return;
if (e.data.type === 'webterminal-closed') {
closeTerminal();
} else if (e.data.type === 'webterminal-escape') {
hideTerminal();
}
});
}
</script>
</html>