Files
datastore/programs/server/play.html
T
Alexey MilovidovandClaude Opus 4.8 8a4d0b07e0 Add regression test for getQueryUnderCursor in play.html
The `getQueryUnderCursor` logic regressed once during review (`SELECT 1;;`
and incomplete tails like `SELECT 1; '` / `SELECT 1; /*`), so lock the
contract with an automated test.

There is no JavaScript/WebAssembly runtime in CI, so the browser code in
`programs/server/play.html` cannot be run directly. Instead the test in
`src/Parsers/tests/gtest_play_get_query_under_cursor.cpp` reproduces the exact
token-walking algorithm on top of the real `DB::Lexer` — the same source the
Web UI compiles to WebAssembly. It covers the cursor-at-end cases requested in
review: `SELECT 1;`, `SELECT 1;;`, `SELECT 1; '`, `SELECT 1; /*`, plus
no-semicolon, leading whitespace, trailing comment, and multi-query selection.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-05 02:07:28 +00:00

4736 lines
181 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;
/* Syntax highlighting palette, modelled after `clickhouse-client`. The
textarea text is made transparent when highlighting is ready, so the
user sees the backdrop text in these colors directly.
Light-theme values are darker variants of the terminal palette so they
read well on a light background. */
--syntax-default: var(--text-color-active);
--syntax-keyword: var(--text-color-active); /* bold default fg */
--syntax-identifier: #00838F; /* CYAN */
--syntax-function: #875F00; /* BROWN */
--syntax-number: #008700; /* bright green */
--syntax-string: #006400; /* GREEN */
--syntax-qident: #008B8B; /* alias / teal */
--syntax-comment: #757575;
--syntax-operator: var(--text-color-active);
--syntax-error: #B71C1C;
}
[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;
/* Dark-theme palette uses the actual xterm 16-color values that
`clickhouse-client` renders to in a dark terminal. */
--syntax-keyword: var(--text-color-active); /* bold default fg */
--syntax-identifier: #00CDCD; /* CYAN */
--syntax-function: #CDCD00; /* BROWN */
--syntax-number: #00D700; /* rgb666(0,4,0) */
--syntax-string: #00CD00; /* GREEN */
--syntax-qident: #00D7D7; /* rgb666(0,4,4) */
--syntax-comment: #9E9E9E;
--syntax-operator: var(--text-color-active);
--syntax-error: #FF6E40;
}
::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: calc(100vh - var(--terminal-height, 0px));
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;
}
/* When the databases list is expanded, lay out the bottom icons (Web
Terminal, GitHub) side by side instead of stacked, so the list of
databases/tables gets more vertical space. */
#menu.databases-opened
{
grid-template-columns: auto 1fr;
grid-template-areas: "databases-icon databases-icon" "contents contents" "terminal-icon github-icon";
}
#menu.databases-opened #github-icon
{
justify-self: start;
}
#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 populated with the highlighted version of the text,
we hide the textarea's own text by setting its color to transparent. What
the user sees is the backdrop directly. The caret keeps a normal color via
`caret-color` (the bare `color: transparent` would also make the caret
transparent because the caret defaults to `currentColor`). */
#query.has-backdrop
{
background: transparent;
color: transparent;
caret-color: var(--text-color-active);
}
#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);
/* Default backdrop text is the "neutral" color of the chosen palette.
It coincides with the canonical text color after the difference blend. */
color: var(--syntax-default);
}
#query-backdrop .q-active { background-color: var(--current-query-background-color); }
/* Per-token syntax highlighting classes. The color value here is the raw color
painted in the backdrop; the visible color the user sees is the difference
between this and --text-color-active (see the comment on .has-backdrop). */
#query-backdrop .q-kw { color: var(--syntax-keyword); font-weight: bold; }
#query-backdrop .q-id { color: var(--syntax-identifier); }
#query-backdrop .q-fn { color: var(--syntax-function); }
#query-backdrop .q-num { color: var(--syntax-number); }
#query-backdrop .q-str { color: var(--syntax-string); }
#query-backdrop .q-qid { color: var(--syntax-qident); }
#query-backdrop .q-com { color: var(--syntax-comment); font-style: italic; }
#query-backdrop .q-op { color: var(--syntax-operator); }
#query-backdrop .q-err { color: var(--syntax-error); text-decoration: underline wavy; }
#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="" 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="CSVWithNames">CSVWithNames</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 _loadVizPromise = null;
static _vizInstance = 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;
overflow: auto;
background: white;
color: black;
padding: 0.5rem;
border-radius: 3px;
filter: drop-shadow(.2rem .2rem .2rem var(--shadow-color));
}
#graph svg
{
display: block;
margin: auto;
}
#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>
<div id="graph"></div>
<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 if (!src.startsWith('/'))
{
console.warn('no integrity for', src);
}
script.addEventListener('load', function() { resolve(true); });
document.head.appendChild(script);
});
}
async _getViz()
{
if (QueryResultElement._vizInstance) { return QueryResultElement._vizInstance; }
if (!QueryResultElement._loadVizPromise)
{
QueryResultElement._loadVizPromise = this._loadJS('/js/viz-standalone.js');
}
await QueryResultElement._loadVizPromise;
if (typeof Viz === 'undefined')
{
throw new Error('Viz.js failed to load from /js/viz-standalone.js.');
}
QueryResultElement._vizInstance = await Viz.instance();
return QueryResultElement._vizInstance;
}
_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);
const viz = await this._getViz();
const svg = viz.renderSVGElement(this._explain_graph);
this._graph.replaceChildren(svg);
this._graph.style.display = 'block';
}
_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';
menu_elem.classList.add('databases-opened');
}
});
} else {
delete databases_toggle.dataset['opened'];
menu_elem.style.cursor = 'e-resize';
menu_elem.classList.remove('databases-opened');
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;
const history_params = [];
if (user) {
history_params.push('user=' + encodeURIComponent(user));
}
if (run_immediately) {
history_params.push('run=1');
}
if (server_address != location.origin) {
history_params.push('url=' + encodeURIComponent(server_address));
}
for (const [name, value] of Object.entries(paramValuesSnapshot))
{
history_params.push('param_' + encodeURIComponent(name) + '=' + encodeURIComponent(value));
}
let history_url = window.location.pathname;
if (history_params.length > 0) {
history_url += '?' + history_params.join('&');
}
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;
const history_params = [];
if (user) {
history_params.push('user=' + encodeURIComponent(user));
}
if (run_immediately) {
history_params.push('run=1');
}
if (server_address != location.origin) {
history_params.push('url=' + encodeURIComponent(server_address));
}
for (const [name, value] of Object.entries(paramValuesSnapshot))
{
history_params.push('param_' + encodeURIComponent(name) + '=' + encodeURIComponent(value));
}
let history_url = window.location.pathname;
if (history_params.length > 0) {
history_url += '?' + history_params.join('&');
}
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;
// Notify listeners (e.g. the syntax-highlighting backdrop) that the textarea
// value changed — direct assignment to `value` does not fire `input`.
elem.dispatchEvent(new Event('input', { bubbles: true }));
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;
// Notify listeners (e.g. the syntax-highlighting backdrop) that the textarea
// value changed — direct assignment to `value` does not fire `input`.
elem.dispatchEvent(new Event('input', { bubbles: true }));
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);
}
function resizeQueryAreaIfSlightlyOverflowing()
{
const textarea = document.getElementById('query');
const current_height = textarea.offsetHeight;
if (!current_height) { return; }
const style = window.getComputedStyle(textarea);
const vertical_border = parseFloat(style.borderTopWidth) + parseFloat(style.borderBottomWidth);
const required_height = textarea.scrollHeight + vertical_border;
const max_auto_resize_height = current_height * 1.3;
if (required_height > current_height && required_height <= max_auto_resize_height)
{
textarea.style.height = `${required_height}px`;
}
}
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;
}
/// Regression coverage for this function lives in
/// `src/Parsers/tests/gtest_play_get_query_under_cursor.cpp` (a port of the token-walking
/// algorithm on top of the same `DB::Lexer`). Keep the two in sync when changing the logic.
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;
/// Track the most recently completed non-empty query so that if the cursor
/// is past the last `;` (with only whitespace/comments after it, or only
/// further delimiter-only segments like `;;`), we run that query instead
/// of returning an empty string or a bare delimiter.
let last_query_start = -1;
let last_query_end = -1;
for (const elem of tokens) {
if (current_query_start == current_offset && !elem.significant) {
current_query_start += elem.token.length;
}
const offset_before_token = current_offset;
current_offset += elem.token.length;
if (elem.token == ';') {
/// If `current_query_start == offset_before_token`, the segment up
/// to this `;` has no significant content (only whitespace/comments
/// that the leading-skip above has consumed, or nothing at all).
/// Treat such a delimiter-only segment as empty: do not run it and
/// do not record it as the last completed query.
const has_content = current_query_start < offset_before_token;
if (has_content && current_offset >= cursor_position) {
query_area.setSelectionRange(current_query_start, current_offset);
query_area.focus();
return all_queries.substring(current_query_start, current_offset);
}
if (has_content) {
last_query_start = current_query_start;
last_query_end = current_offset;
}
current_query_start = current_offset;
}
}
/// The cursor is past the last `;`. If the tail has no significant content
/// (only whitespace/comments, which the leading-skip loop above will have
/// advanced `current_query_start` past until it equals `current_offset`),
/// fall back to the query that ended at the last `;` rather than returning
/// an empty string. This is what handles the "single query ending in `;`
/// with the cursor placed after it" case.
if (current_query_start === current_offset && current_offset === all_queries.length && last_query_end >= 0) {
query_area.setSelectionRange(last_query_start, last_query_end);
query_area.focus();
return all_queries.substring(last_query_start, last_query_end);
}
/// 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: paint per-query active backgrounds
/// and per-token syntax-highlighting colors. The textarea above is in
/// `mix-blend-mode: difference`, so the colors here mix with the user's text.
const queryBackdrop = document.getElementById('query-backdrop');
function escapeHTML(s) {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
/// Numeric TokenType values, matching the order of the C++ enum in src/Parsers/Lexer.h.
/// Only the categories we actually classify are named; everything else is treated as
/// "default" (unstyled) and renders in the canonical text color via the diff blend.
const TT = {
Whitespace: 0, Comment: 1, BareWord: 2, Number: 3, StringLiteral: 4, QuotedIdentifier: 5,
OpeningRoundBracket: 6, ClosingRoundBracket: 7,
Semicolon: 13, Asterisk: 16, HereDoc: 17, DollarSign: 18,
Plus: 19, Minus: 20, Slash: 21, Percent: 22, Arrow: 23,
QuestionMark: 24, Colon: 25, Caret: 26, DoubleColon: 27,
Equals: 28, NotEquals: 29, Less: 30, Greater: 31,
LessOrEquals: 32, GreaterOrEquals: 33, Spaceship: 34,
PipeMark: 35, Concatenation: 36, At: 37, DoubleAt: 38,
};
/// SQL keywords recognized for highlighting. The lexer reports them as BareWord, so we
/// disambiguate identifiers from keywords here. Comparisons are case-insensitive.
const SQL_KEYWORDS = new Set([
'ADD', 'AFTER', 'ALL', 'ALTER', 'AND', 'ANTI', 'ANY', 'ARRAY', 'AS', 'ASC', 'ASCENDING',
'ASOF', 'AST', 'ASYNC', 'ATTACH', 'BACKUP', 'BEGIN', 'BETWEEN', 'BOTH', 'BY',
'CACHE', 'CASCADE', 'CASE', 'CAST', 'CHANGE', 'CHANGED', 'CHECK', 'CLEAR', 'CLUSTER',
'CODEC', 'COLLATE', 'COLUMN', 'COLUMNS', 'COMMENT', 'COMMIT', 'CONSTRAINT', 'CREATE',
'CROSS', 'CUBE', 'CURRENT',
'DATABASE', 'DATABASES', 'DAY', 'DEDUPLICATE', 'DEFAULT', 'DELETE', 'DESC', 'DESCENDING',
'DESCRIBE', 'DETACH', 'DICTIONARIES', 'DICTIONARY', 'DISK', 'DISTINCT', 'DISTRIBUTED',
'DROP', 'ELSE', 'END', 'ENGINE', 'ESTIMATE', 'EVENTS', 'EXCEPT', 'EXCHANGE', 'EXISTS',
'EXPLAIN', 'EXPRESSION', 'EXTENDED', 'EXTRACT',
'FALSE', 'FETCH', 'FETCHES', 'FILE', 'FILESYSTEM', 'FINAL', 'FIRST', 'FLUSH', 'FOLLOWING',
'FOR', 'FOREIGN', 'FORMAT', 'FREEZE', 'FROM', 'FULL', 'FUNCTION',
'GLOBAL', 'GRANT', 'GROUP', 'GROUPS', 'HAVING', 'HIERARCHICAL', 'HOUR',
'ID', 'IDENTIFIED', 'IF', 'ILIKE', 'IN', 'INDEX', 'INF', 'INHERIT', 'INJECTIVE',
'INNER', 'INSERT', 'INTERSECT', 'INTERVAL', 'INTO', 'INVISIBLE', 'IS', 'IS_OBJECT_ID',
'JOIN', 'KEY', 'KEYED', 'KILL',
'LAST', 'LATERAL', 'LAYOUT', 'LEADING', 'LEFT', 'LIFETIME', 'LIKE', 'LIMIT', 'LIMITS',
'LIVE', 'LOCAL', 'LOGS',
'MATERIALIZE', 'MATERIALIZED', 'MAX', 'MERGES', 'MICROSECOND', 'MILLISECOND', 'MIN',
'MINUTE', 'MODIFY', 'MONTH', 'MOVE', 'MUTATION',
'NAN_SQL', 'NEXT', 'NO', 'NONE', 'NOT', 'NULL', 'NULLS',
'OFFSET', 'ON', 'ONLY', 'OPTIMIZE', 'OPTION', 'OR', 'ORDER', 'OUTER', 'OUTFILE', 'OVER',
'PARTITION', 'PASTE', 'PERMANENTLY', 'PLAN', 'POPULATE', 'PRECEDING', 'PRECISION',
'PREWHERE', 'PRIMARY', 'PROFILE', 'PROJECTION', 'QUARTER', 'QUERY', 'QUOTA',
'RANDOMIZED', 'RANGE', 'RECURSIVE', 'REFRESH', 'REGEXP', 'RELOAD', 'REMOTE', 'RENAME',
'REPLACE', 'REPLICA', 'REPLICAS', 'RESET', 'RESTORE', 'RESTRICT', 'RESTRICTIVE',
'RETURNS', 'REVOKE', 'RIGHT', 'ROLE', 'ROLLBACK', 'ROLLUP', 'ROW', 'ROWS',
'SAMPLE', 'SECOND', 'SELECT', 'SEMI', 'SENDS', 'SET', 'SETS', 'SETTINGS', 'SHARD',
'SHOW', 'SIGNED', 'SOURCE', 'SQL_SECURITY', 'START', 'STEP', 'STORAGE', 'STRICT',
'STRICTLY_ASCENDING', 'SUBPARTITION', 'SUBSTRING', 'SUSPEND', 'SYNC', 'SYNTAX', 'SYSTEM',
'TABLE', 'TABLES', 'TEMPORARY', 'TEST', 'THEN', 'TIES', 'TIMESTAMP', 'TO', 'TOP',
'TOTALS', 'TRACKING', 'TRAILING', 'TRANSACTION', 'TRIGGER', 'TRIM', 'TRUE', 'TRUNCATE',
'TYPE',
'UNBOUNDED', 'UNFREEZE', 'UNION', 'UNIQUE', 'UNSIGNED', 'UPDATE', 'USE', 'USING',
'UUID', 'VALUES', 'VARYING', 'VIEW', 'VIRTUAL', 'VISIBLE',
'WATCH', 'WEEK', 'WHEN', 'WHERE', 'WINDOW', 'WITH', 'WORK', 'WRITABLE',
'XOR', 'YEAR', 'ZKPATH',
]);
/// Map a single token to a CSS class. For BareWords we also peek at the next
/// non-whitespace token to distinguish a function call (`foo(`) from a plain
/// identifier — the lexer alone cannot tell them apart.
function tokenClass(tokens, i) {
const elem = tokens[i];
switch (elem.type) {
case TT.Comment: return 'q-com';
case TT.Number: return 'q-num';
case TT.StringLiteral:
case TT.HereDoc: return 'q-str';
case TT.QuotedIdentifier: return 'q-qid';
case TT.BareWord: {
if (SQL_KEYWORDS.has(elem.token.toUpperCase())) return 'q-kw';
for (let j = i + 1; j < tokens.length; ++j) {
if (tokens[j].type === TT.Whitespace) continue;
return tokens[j].type === TT.OpeningRoundBracket ? 'q-fn' : 'q-id';
}
return 'q-id';
}
case TT.Asterisk: case TT.Plus: case TT.Minus: case TT.Slash: case TT.Percent:
case TT.Arrow: case TT.QuestionMark: case TT.Colon: case TT.DoubleColon: case TT.Caret:
case TT.Equals: case TT.NotEquals:
case TT.Less: case TT.Greater: case TT.LessOrEquals: case TT.GreaterOrEquals:
case TT.Spaceship: case TT.PipeMark: case TT.Concatenation:
case TT.At: case TT.DoubleAt: case TT.DollarSign:
return 'q-op';
default:
return '';
}
}
/// Cached state for cursor-tracking re-renders that do not re-tokenize.
let currentTokens = [];
let currentBoundaries = [];
let currentBackdropText = '';
function updateQueryBackdrop(text, tokens, boundaries) {
currentTokens = tokens || [];
currentBoundaries = boundaries || [];
currentBackdropText = text || '';
renderQueryBackdrop();
}
function renderQueryBackdrop() {
const text = currentBackdropText;
const tokens = currentTokens;
const boundaries = currentBoundaries;
/// Update button labels to reflect current selection state.
updateRunButtons(boundaries.length > 1);
/// No content, or the WASM lexer is not available — fall back to the unstyled textarea.
if (!text || tokens.length === 0) {
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 and we are
/// actually in multi-query mode).
let activeStart = -1;
let activeEnd = -1;
if (focused && boundaries.length > 1) {
for (let i = 0; i < boundaries.length; ++i) {
if (cursor >= boundaries[i].start && cursor <= boundaries[i].end) {
activeStart = boundaries[i].start;
activeEnd = boundaries[i].end;
break;
}
}
}
let html = '';
let offset = 0;
let activeOpen = false;
for (let i = 0; i < tokens.length; ++i) {
const elem = tokens[i];
const tokStart = offset;
const tokEnd = offset + elem.token.length;
offset = tokEnd;
const inActive = activeStart >= 0 && tokEnd > activeStart && tokStart < activeEnd;
if (inActive && !activeOpen) {
html += '<span class="q-active">';
activeOpen = true;
} else if (!inActive && activeOpen) {
html += '</span>';
activeOpen = false;
}
const cls = tokenClass(tokens, i);
const escaped = escapeHTML(elem.token);
if (cls) {
html += '<span class="' + cls + '">' + escaped + '</span>';
} else {
html += escaped;
}
}
if (activeOpen) {
html += '</span>';
}
/// Any tail not covered by tokens (lexer hit an error or reached the size limit)
/// is shown with the error style.
if (offset < text.length) {
html += '<span class="q-err">' + escapeHTML(text.substring(offset)) + '</span>';
}
/// 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;
}
/// Bump the generation up-front so that any in-flight older runs (which only
/// check this counter after their `await tokenize`) see a newer value and bail
/// out — including the empty-query fast path below, where we would otherwise
/// clear state and return, and a stale older run could then overwrite it.
const generation = ++updateQueryParamsGeneration;
const query = query_area.value;
if (!query)
{
queryParamsContainer.innerHTML = '';
currentQueryParams = [];
updateRunButtons(false);
updateQueryBackdrop('', [], []);
return;
}
/// If tokenization or rendering throws (e.g. the query is larger than the WASM
/// memory window currently allocated to the lexer), fall back to the unstyled
/// textarea instead of leaving the previous `.has-backdrop` state in place —
/// otherwise `#query.has-backdrop` keeps the real text invisible
/// (`color: transparent`) while the stale backdrop is shown.
let tokens;
try
{
tokens = await tokenize(query);
}
catch (e)
{
if (generation !== updateQueryParamsGeneration) { return; }
console.error('Tokenization failed, disabling syntax highlighting:', e);
updateQueryBackdrop('', [], []);
return;
}
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);
try
{
updateQueryBackdrop(query, tokens, boundaries);
}
catch (e)
{
console.error('Rendering syntax highlighting failed, disabling backdrop:', e);
updateQueryBackdrop('', [], []);
}
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'));
}
}
query_area.addEventListener('input', () =>
{
resizeQueryAreaIfSlightlyOverflowing();
/// Run the update synchronously instead of with a setTimeout: the heavy work
/// (WASM tokenize, building backdrop HTML) is fast, and the await chain inside
/// `updateQueryParams` runs in microtasks — i.e. it finishes before the browser
/// paints the next frame. If we deferred this with `setTimeout`, the browser
/// would render one frame with the textarea's own text hidden (`color:
/// transparent` from `.has-backdrop`) and the backdrop still showing the
/// pre-edit contents, so the freshly typed characters would be invisible.
updateQueryParams();
});
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)
{
resizeQueryAreaIfSlightlyOverflowing();
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');
}
});
const formatExtensions = {
CSVWithNames: "csv",
NDJSON: "jsonl",
JSONEachRow: "jsonl",
Markdown: "md"
};
// 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;
}
}
const extension = formatExtensions[format] || format.toLowerCase();
// 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(extension)}"'}`);
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;
/// Origin the iframe was loaded from, used as the target origin for `postMessage`.
/// Captured once when the iframe is created so it stays in sync with `iframe.src`
/// even if the user later edits the server URL field.
let terminalOrigin = null;
const MIN_TERMINAL_HEIGHT = 20;
/// The `/webterminal` endpoint lives on the ClickHouse server addressed by `url_elem.value`,
/// which may differ from the page's own origin when the page is opened locally with `?url=...`.
/// Resolve both the availability probe and the iframe `src` against the same configured server URL.
/// Returns `null` if the configured URL cannot be parsed, so the caller can treat the terminal
/// as unavailable instead of letting the exception abort `play.html` initialization.
function getTerminalURL() {
try {
return new URL('/webterminal', url_elem.value).href;
} catch (e) {
return null;
}
}
/// Hide the terminal icon if /webterminal is not available (gated by allow_experimental_webterminal).
terminalIcon.style.display = 'none';
const terminalProbeURL = getTerminalURL();
if (terminalProbeURL) {
fetch(terminalProbeURL, {method: 'HEAD'}).then(function(resp) {
if (resp.ok) terminalIcon.style.display = '';
}).catch(function() {});
}
function sendCredentialsToTerminal() {
if (terminalIframe && terminalIframe.contentWindow && terminalOrigin) {
terminalIframe.contentWindow.postMessage({
type: 'webterminal-credentials',
user: user_elem.value,
password: password_elem.value || ''
}, terminalOrigin);
}
}
/// Keep the `--terminal-height` CSS variable in sync so the side menu can
/// size itself as `100vh - terminal_height` and its bottom icons stay visible.
function updateTerminalHeightVar() {
const h = terminalActive ? terminalPanel.offsetHeight : 0;
document.documentElement.style.setProperty('--terminal-height', h + 'px');
}
function activateTerminal() {
if (terminalActive) return;
/// Bail out before mutating any state if the configured server URL is currently invalid.
/// The icon is only shown when the initial probe succeeded, so this can happen only if the
/// user edited `url_elem.value` to something unparsable between page load and this click.
const terminalSrc = terminalIframe ? null : getTerminalURL();
if (!terminalIframe && !terminalSrc) 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';
terminalOrigin = new URL(terminalSrc).origin;
terminalIframe.src = terminalSrc;
terminalIframe.addEventListener('load', sendCredentialsToTerminal);
terminalPanel.insertBefore(terminalIframe, terminalResizer);
} else if (terminalHiddenByResize) {
terminalPanel.style.height = Math.round(window.innerHeight * 0.4) + 'px';
}
terminalHiddenByResize = false;
updateTerminalHeightVar();
// Tell the iframe to refit the terminal after becoming visible again, and focus it
if (terminalIframe && terminalIframe.contentWindow && terminalOrigin) {
terminalIframe.contentWindow.postMessage({type: 'webterminal-refit'}, terminalOrigin);
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');
updateTerminalHeightVar();
}
/// Destroy the terminal: remove the iframe and hide the panel.
function closeTerminal() {
hideTerminal();
if (terminalIframe) {
terminalIframe.remove();
terminalIframe = null;
terminalOrigin = 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';
updateTerminalHeightVar();
}
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>