ClickHouse/utils/dashboard.html
2022-08-02 04:51:18 +02:00

757 lines
24 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Dashboard</title>
<link rel="icon" href="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI1NCIgaGVpZ2h0PSI0OCIgdmlld0JveD0iMCAwIDkgOCI+PHN0eWxlPi5ve2ZpbGw6I2ZjMH0ucntmaWxsOnJlZH08L3N0eWxlPjxwYXRoIGQ9Ik0wLDcgaDEgdjEgaC0xIHoiIGNsYXNzPSJyIi8+PHBhdGggZD0iTTAsMCBoMSB2NyBoLTEgeiIgY2xhc3M9Im8iLz48cGF0aCBkPSJNMiwwIGgxIHY4IGgtMSB6IiBjbGFzcz0ibyIvPjxwYXRoIGQ9Ik00LDAgaDEgdjggaC0xIHoiIGNsYXNzPSJvIi8+PHBhdGggZD0iTTYsMCBoMSB2OCBoLTEgeiIgY2xhc3M9Im8iLz48cGF0aCBkPSJNOCwzLjI1IGgxIHYxLjUgaC0xIHoiIGNsYXNzPSJvIi8+PC9zdmc+">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/uplot@1.6.21/dist/uPlot.min.css"> <!-- TODO: Add SRI -->
<script src="https://cdn.jsdelivr.net/npm/uplot@1.6.21/dist/uPlot.iife.min.js"></script>
<style>
:root {
--color: black;
--background: linear-gradient(to bottom, #00CCFF, #00D0D0);
--chart-background: white;
--shadow-color: rgba(0, 0, 0, 0.25);
--input-shadow-color: rgba(0, 255, 0, 1);
--error-color: red;
--legend-background: rgba(255, 255, 255, 0.75);
--title-color: #666;
--text-color: black;
--edit-title-background: #FEE;
--edit-title-border: #F88;
--button-background-color: #FFCB80;
--button-text-color: black;
--new-chart-background-color: #EEE;
--new-chart-text-color: black;
--param-background-color: #EEE;
--param-text-color: black;
--input-background: white;
--chart-button-hover-color: red;
}
[data-theme="dark"] {
--color: white;
--background: #151C2C;
--chart-background: #1b2834;
--shadow-color: rgba(0, 0, 0, 0);
--input-shadow-color: rgba(255, 128, 0, 0.25);
--error-color: #F66;
--legend-background: rgba(255, 255, 255, 0.25);
--title-color: white;
--text-color: white;
--edit-title-background: #364f69;
--edit-title-border: #333;
--button-background-color: orange;
--button-text-color: black;
--new-chart-background-color: #666;
--new-chart-text-color: white;
--param-background-color: #666;
--param-text-color: white;
--input-background: #364f69;
--chart-button-hover-color: #F40;
}
* {
box-sizing: border-box;
}
html, body {
color: var(--color);
height: 100%;
overflow: auto;
margin: 0;
}
body {
font-family: Liberation Sans, DejaVu Sans, sans-serif, Noto Color Emoji, Apple Color Emoji, Segoe UI Emoji;
padding: 1rem;
overflow-x: hidden;
background: var(--background);
display: grid;
grid-template-columns: auto;
grid-template-rows: fit-content(10%) auto;
}
input {
/* iPad, Safari */
border-radius: 0;
margin: 0;
}
#charts
{
height: 100%;
display: flex;
flex-flow: row wrap;
gap: 1rem;
}
.chart {
flex: 1 40%;
min-width: 20rem;
min-height: 16rem;
background: var(--chart-background);
box-shadow: 0 0 1rem var(--shadow-color);
overflow: hidden;
position: relative;
}
.chart div { position: absolute; }
.inputs { font-size: 14pt; }
#connection-params {
margin-bottom: 0.5rem;
display: grid;
grid-template-columns: auto 15% 15%;
column-gap: 0.25rem;
}
.inputs input {
box-shadow: 0 0 1rem var(--shadow-color);
padding: 0.25rem;
}
#chart-params input {
margin-right: 0.25rem;
}
input {
font-family: Liberation Sans, DejaVu Sans, sans-serif, Noto Color Emoji, Apple Color Emoji, Segoe UI Emoji;
outline: none;
border: none;
font-size: 14pt;
background-color: var(--input-background);
color: var(--text-color);
}
.u-legend th { display: none; }
.themes {
float: right;
font-size: 20pt;
margin-bottom: 1rem;
}
#toggle-dark, #toggle-light {
padding-right: 0.5rem;
user-select: none;
cursor: pointer;
}
#toggle-dark:hover, #toggle-light:hover {
display: inline-block;
transform: translate(1px, 1px);
filter: brightness(125%);
}
#run {
background: var(--button-background-color);
color: var(--button-text-color);
font-weight: bold;
user-select: none;
cursor: pointer;
margin-bottom: 1rem;
}
#run:hover {
filter: contrast(125%);
}
#add {
font-weight: bold;
user-select: none;
cursor: pointer;
padding-left: 0.5rem;
padding-right: 0.5rem;
background: var(--new-chart-background-color);
color: var(--new-chart-text-color);
float: right;
margin-right: 0 !important;
margin-left: 1rem;
margin-bottom: 1rem;
}
#add:hover {
background: var(--button-background-color);
}
form {
display: inline;
}
form .param_name {
font-size: 14pt;
padding: 0.25rem;
background: var(--param-background-color);
color: var(--param-text-color);
display: inline-block;
box-shadow: 0 0 1rem var(--shadow-color);
margin-bottom: 1rem;
}
input:focus {
box-shadow: 0 0 1rem var(--input-shadow-color);
}
.title {
left: 50%;
top: 0.25em;
transform: translate(-50%, 0);
font-size: 16pt;
font-weight: bold;
color: var(--title-color);
z-index: 10;
}
.chart-buttons {
cursor: pointer;
display: none;
position: absolute;
top: 0.25rem;
right: 0.25rem;
font-size: 200%;
color: #888;
z-index: 10;
}
.chart-buttons a {
margin-right: 0.25rem;
}
.chart-buttons a:hover {
color: var(--chart-button-hover-color);
}
.query-editor {
display: none;
grid-template-columns: auto fit-content(10%);
grid-template-rows: auto fit-content(10%);
z-index: 11;
position: absolute;
width: 100%;
height: 100%;
}
.query-error {
display: none;
z-index: 10;
position: absolute;
color: var(--error-color);
padding: 2rem;
}
.query-editor textarea {
grid-row: 1;
grid-column: 1 / span 2;
z-index: 11;
padding: 0.5rem;
outline: none;
border: none;
font-size: 12pt;
border-bottom: 1px solid var(--edit-title-border);
background: var(--chart-background);
color: var(--text-color);
resize: none;
}
.query-editor input {
grid-row: 2;
padding: 0.5rem;
}
.edit-title {
background: var(--edit-title-background);
}
.edit-confirm {
background: var(--button-background-color);
color: var(--button-text-color);
font-weight: bold;
cursor: pointer;
}
.edit-confirm:hover {
filter: contrast(125%);
}
.nowrap {
white-space: pre;
}
</style>
</head>
<body>
<div class="inputs">
<form id="params">
<div id="connection-params">
<input spellcheck="false" id="url" type="text" value="" placeholder="URL" />
<input spellcheck="false" id="user" type="text" value="" placeholder="user" />
<input spellcheck="false" id="password" type="password" placeholder="password" />
</div>
<div>
<input id="add" type="button" value="Add chart">
<span class="nowrap themes"><span id="toggle-dark">🌚</span><span id="toggle-light">🌞</span></span>
<div id="chart-params"></div>
</div>
</form>
</div>
<div id="charts"></div>
</body>
<script>
let host = 'https://play.clickhouse.com/';
let user = 'explorer';
let password = '';
let queries = [
{
"title": "Unique Comment Authors",
"query": "SELECT toStartOfInterval(created_at, INTERVAL {group_days:UInt16} DAY)::INT AS t, uniq(actor_login) AS authors\nFROM github_events\nWHERE repo_name = {repo:String}\nAND created_at >= {start:Date}\nAND event_type = 'IssueCommentEvent'\nAND actor_login NOT LIKE 'robot-%' AND actor_login NOT LIKE '%[bot]'\nGROUP BY t\nORDER BY t"
},
{
"title": "Comments",
"query": "SELECT toStartOfInterval(created_at, INTERVAL {group_days:UInt16} DAY)::INT AS t, count() AS events\nFROM github_events\nWHERE repo_name = {repo:String}\nAND created_at >= {start:Date}\nAND event_type = 'IssueCommentEvent'\nAND actor_login NOT LIKE 'robot-%' AND actor_login NOT LIKE '%[bot]'\nGROUP BY t\nORDER BY t"
},
{
"title": "Unique Pull Request Authors",
"query": "SELECT toStartOfInterval(created_at, INTERVAL {group_days:UInt16} DAY)::INT AS t, uniq(actor_login) AS authors\nFROM github_events\nWHERE repo_name = {repo:String}\nAND created_at >= {start:Date}\nAND event_type = 'PullRequestEvent'\nAND action = 'opened'\nAND actor_login NOT LIKE 'robot-%' AND actor_login NOT LIKE '%[bot]'\nGROUP BY t\nORDER BY t"
},
{
"title": "New Pull Requests",
"query": "SELECT toStartOfInterval(created_at, INTERVAL {group_days:UInt16} DAY)::INT AS t, count() AS events\nFROM github_events\nWHERE repo_name = {repo:String}\nAND created_at >= {start:Date}\nAND event_type = 'PullRequestEvent'\nAND action = 'opened'\nAND actor_login NOT LIKE 'robot-%' AND actor_login NOT LIKE '%[bot]'\nGROUP BY t\nORDER BY t"
},
{
"title": "{repo} Stars",
"query": "SELECT toStartOfInterval(created_at, INTERVAL {group_days:UInt16} DAY)::INT AS t, count() AS events\nFROM github_events\nWHERE repo_name = {repo:String}\nAND created_at >= {start:Date}\nAND event_type = 'WatchEvent'\nAND actor_login NOT LIKE 'robot-%' AND actor_login NOT LIKE '%[bot]'\nGROUP BY t\nORDER BY t"
}
];
/// Query parameters with predefined default values.
/// All other parameters will be automatically found in the queries.
let params = {
"repo": "ClickHouse/ClickHouse",
"group_days": "7",
"start": "2020-01-01"
};
let theme = 'light';
function setTheme(new_theme) {
theme = new_theme;
document.documentElement.setAttribute('data-theme', theme);
window.localStorage.setItem('theme', theme);
drawAll();
}
document.getElementById('toggle-light').addEventListener('click', e => setTheme('light'));
document.getElementById('toggle-dark').addEventListener('click', e => setTheme('dark'));
/// uPlot objects will go here.
let plots = [];
/// chart div's will be here.
let charts = document.getElementById('charts');
/// This is not quite correct (we cannot really parse SQL with regexp) but tolerable.
const query_param_regexp = /\{(\w+):[^}]+\}/g;
/// Automatically parse more parameters from the queries.
function findParamsInQuery(query, new_params) {
for (let match of query.matchAll(query_param_regexp)) {
const name = match[1];
new_params[name] = params[name] || '';
}
}
function findParamsInQueries() {
let new_params = {}
queries.forEach(q => findParamsInQuery(q.query, new_params));
params = new_params;
}
function insertParam(name, value) {
let param_wrapper = document.createElement('span');
param_wrapper.className = 'nowrap';
let param_name = document.createElement('span');
param_name.className = 'param_name';
let param_name_text = document.createTextNode(`${name}: `);
param_name.appendChild(param_name_text);
let param_value = document.createElement('input');
param_value.className = 'param';
param_value.name = `${name}`;
param_value.type = 'text';
param_value.value = value;
param_value.spellcheck = false;
param_wrapper.appendChild(param_name);
param_wrapper.appendChild(param_value);
document.getElementById('chart-params').appendChild(param_wrapper);
}
function buildParams() {
let params_elem = document.getElementById('chart-params');
while (params_elem.firstChild) {
params_elem.removeChild(params_elem.lastChild);
}
for (let [name, value] of Object.entries(params)) {
insertParam(name, value);
}
let run = document.createElement('input');
run.id = 'run';
run.type = 'submit';
run.value = 'Ok';
document.getElementById('chart-params').appendChild(run);
}
function updateParams() {
[...document.getElementsByClassName('param')].forEach(e => { params[e.name] = e.value });
}
function getParamsForURL() {
let url = '';
for (let [name, value] of Object.entries(params)) {
url += `&param_${encodeURIComponent(name)}=${encodeURIComponent(value)}`;
};
return url;
}
function insertChart(i) {
let q = queries[i];
let chart = document.createElement('div');
chart.className = 'chart';
let chart_title = document.createElement('div');
let title_text = document.createTextNode('');
chart_title.appendChild(title_text);
chart_title.className = 'title';
chart.appendChild(chart_title);
let query_error = document.createElement('div');
query_error.className = 'query-error';
query_error.appendChild(document.createTextNode(''));
chart.appendChild(query_error);
let query_editor = document.createElement('div');
query_editor.className = 'query-editor';
let query_editor_textarea = document.createElement('textarea');
query_editor_textarea.spellcheck = false;
query_editor_textarea.value = q.query;
query_editor_textarea.placeholder = 'Query';
query_editor.appendChild(query_editor_textarea);
let query_editor_title = document.createElement('input');
query_editor_title.type = 'text';
query_editor_title.value = q.title;
query_editor_title.placeholder = 'Chart title';
query_editor_title.className = 'edit-title';
query_editor.appendChild(query_editor_title);
let query_editor_confirm = document.createElement('input');
query_editor_confirm.type = 'submit';
query_editor_confirm.value = 'Ok';
query_editor_confirm.className = 'edit-confirm';
function editConfirm() {
query_editor.style.display = 'none';
query_error.style.display = 'none';
q.title = query_editor_title.value;
q.query = query_editor_textarea.value;
title_text.data = '';
findParamsInQuery(q.query, params);
buildParams();
draw(i, chart, getParamsForURL(), q.query);
saveState();
}
query_editor_confirm.addEventListener('click', editConfirm);
/// Ctrl+Enter (or Cmd+Enter on Mac) will also confirm editing.
query_editor.addEventListener('keydown', e => {
if ((event.metaKey || event.ctrlKey) && (event.keyCode == 13 || event.keyCode == 10)) {
editConfirm();
}
});
query_editor.addEventListener('keyup', e => {
if (e.key == 'Escape') {
query_editor.style.display = 'none';
}
});
query_editor.appendChild(query_editor_confirm);
chart.appendChild(query_editor);
let edit_buttons = document.createElement('div');
edit_buttons.className = 'chart-buttons';
let edit = document.createElement('a');
let edit_text = document.createTextNode('✎');
edit.appendChild(edit_text);
function editStart() {
query_editor.style.display = 'grid';
query_editor_textarea.focus();
}
edit.addEventListener('click', e => editStart());
if (!q.query) {
editStart();
}
let trash = document.createElement('a');
let trash_text = document.createTextNode('✕');
trash.appendChild(trash_text);
trash.addEventListener('click', e => {
/// Indices may change after deletion of other element, hence captured "i" may become incorrect.
let idx = [...charts.querySelectorAll('.chart')].findIndex(child => chart == child);
if (plots[idx]) {
plots[idx].destroy();
plots[idx] = null;
}
plots.splice(idx, 1);
charts.removeChild(chart);
queries.splice(idx, 1);
findParamsInQueries();
buildParams();
resize();
saveState();
});
edit_buttons.appendChild(edit);
edit_buttons.appendChild(trash);
chart.appendChild(edit_buttons);
chart.addEventListener('mouseenter', e => { edit_buttons.style.display = 'block'; });
chart.addEventListener('mouseleave', e => { edit_buttons.style.display = 'none'; });
charts.appendChild(chart);
};
document.getElementById('add').addEventListener('click', e => {
queries.push({ title: '', query: '' });
insertChart(plots.length);
plots.push(null);
resize();
});
function legendAsTooltipPlugin({ className, style = { background: "var(--legend-background)" } } = {}) {
let legendEl;
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: "left",
pointerEvents: "none",
display: "none",
position: "absolute",
left: 0,
top: 0,
zIndex: 100,
boxShadow: "2px 2px 10px rgba(0,0,0,0.1)",
...style
});
// hide series color markers
const idents = legendEl.querySelectorAll(".u-marker");
for (let i = 0; i < idents.length; i++)
idents[i].style.display = "none";
const overEl = u.over;
overEl.appendChild(legendEl);
overEl.addEventListener("mouseenter", () => {legendEl.style.display = null;});
overEl.addEventListener("mouseleave", () => {legendEl.style.display = "none";});
}
function update(u) {
let { left, top } = u.cursor;
left -= legendEl.clientWidth / 2;
top -= legendEl.clientHeight / 2;
legendEl.style.transform = "translate(" + left + "px, " + top + "px)";
}
return {
hooks: {
init: init,
setCursor: update,
}
};
}
async function draw(idx, chart, url_params, query) {
if (plots[idx]) {
plots[idx].destroy();
plots[idx] = null;
}
host = document.getElementById('url').value;
user = document.getElementById('user').value;
password = document.getElementById('password').value;
let url = `${host}?default_format=JSONCompactColumns`
if (user) {
url += `&user=${encodeURIComponent(user)}`;
}
if (password) {
url += `&password=${encodeURIComponent(password)}`;
}
let response, data, error;
try {
response = await fetch(url + url_params, { method: "POST", body: query });
data = await response.text();
if (response.ok) {
data = JSON.parse(data);
} else {
error = data;
}
} catch (e) {
console.log(e);
error = e.toString();
}
if (!error) {
if (!Array.isArray(data)) {
error = "Query should return an array.";
} else if (data.length == 0) {
error = "Query returned empty result.";
} else if (data.length != 2) {
error = "Query should return exactly two columns: unix timestamp and value.";
} else if (!Array.isArray(data[0]) || !Array.isArray(data[1]) || data[0].length != data[1].length) {
error = "Wrong data format of the query.";
}
}
let error_div = chart.querySelector('.query-error');
let title_div = chart.querySelector('.title');
if (error) {
error_div.firstChild.data = error;
title_div.style.display = 'none';
error_div.style.display = 'block';
return;
} else {
error_div.firstChild.data = '';
error_div.style.display = 'none';
title_div.style.display = 'block';
}
const [line_color, fill_color, grid_color, axes_color] = theme != 'dark'
? ["#F88", "#FEE", "#EED", "#2c3235"]
: ["#864", "#045", "#2c3235", "#c7d0d9"];
let sync = uPlot.sync("sync");
const max_value = Math.max(...data[1]);
const opts = {
width: chart.clientWidth,
height: chart.clientHeight,
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 } ],
padding: [ null, null, null, (Math.round(max_value * 100) / 100).toString().length * 6 - 10 ],
plugins: [ legendAsTooltipPlugin() ],
cursor: {
sync: {
key: "sync",
}
}
};
plots[idx] = new uPlot(opts, data, chart);
sync.sub(plots[idx]);
/// Set title
const title = queries[idx].title.replaceAll(/\{(\w+)\}/g, (_, name) => params[name] );
chart.querySelector('.title').firstChild.data = title;
}
async function drawAll() {
let params = getParamsForURL();
const charts = document.getElementsByClassName('chart');
for (let i = 0; i < queries.length; ++i) {
draw(i, charts[i], params, queries[i].query);
}
}
function resize() {
plots.forEach(plot => {
if (plot) {
let chart = plot.over.closest('.chart');
plot.setSize({ width: chart.clientWidth, height: chart.clientHeight });
}
});
}
new ResizeObserver(resize).observe(document.body);
document.getElementById('params').onsubmit = function(event) {
updateParams();
drawAll();
saveState();
event.preventDefault();
}
function saveState() {
const state = { host: host, user: user, queries: queries, params: params };
history.pushState(state, '',
window.location.pathname + (window.location.search || '') + '#' + btoa(JSON.stringify(state)));
}
function regenerate() {
document.getElementById('url').value = host;
document.getElementById('user').value = user;
document.getElementById('password').value = password;
findParamsInQueries();
buildParams();
plots.forEach(elem => elem && elem.destroy());
plots = queries.map(e => null);
while (charts.firstChild) {
charts.removeChild(charts.lastChild);
}
for (let i = 0; i < queries.length; ++i) {
insertChart(i);
}
}
window.onpopstate = function(event) {
if (!event.state) { return; }
({host, user, queries, params} = event.state);
regenerate();
drawAll();
};
if (window.location.hash) {
try {
({host, user, queries, params} = JSON.parse(atob(window.location.hash.substring(1))));
} catch {}
}
regenerate();
let new_theme = window.localStorage.getItem('theme');
if (new_theme && new_theme != theme) {
setTheme(new_theme);
} else {
drawAll();
}
</script>
</html>