<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>ClickHouse Dashboard</title> <link rel="icon" href="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI1NCIgaGVpZ2h0PSI0OCIgdmlld0JveD0iMCAwIDkgOCI+PHN0eWxlPi5ve2ZpbGw6I2ZjMH0ucntmaWxsOnJlZH08L3N0eWxlPjxwYXRoIGQ9Ik0wLDcgaDEgdjEgaC0xIHoiIGNsYXNzPSJyIi8+PHBhdGggZD0iTTAsMCBoMSB2NyBoLTEgeiIgY2xhc3M9Im8iLz48cGF0aCBkPSJNMiwwIGgxIHY4IGgtMSB6IiBjbGFzcz0ibyIvPjxwYXRoIGQ9Ik00LDAgaDEgdjggaC0xIHoiIGNsYXNzPSJvIi8+PHBhdGggZD0iTTYsMCBoMSB2OCBoLTEgeiIgY2xhc3M9Im8iLz48cGF0aCBkPSJNOCwzLjI1IGgxIHYxLjUgaC0xIHoiIGNsYXNzPSJvIi8+PC9zdmc+"> <script src="https://cdn.jsdelivr.net/npm/uplot@1.6.21/dist/uPlot.iife.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/lz-string@1.5.0/libs/lz-string.min.js"></script> <style> :root { --color: black; --background-color-1: #00CCFF; --background: linear-gradient(to bottom, var(--background-color-1), #00D0D0); --chart-background: white; --shadow-color: rgba(0, 0, 0, 0.25); --moving-shadow-color: rgba(0, 0, 0, 0.5); --input-shadow-color: rgba(0, 255, 0, 1); --error-color: red; --global-error-color: white; --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-color-1: #151C2C; --background: var(--background-color-1); --chart-background: #1b2834; --shadow-color: rgba(0, 0, 0, 0); --moving-shadow-color: rgba(255, 255, 255, 0.25); --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 1 40rem; min-height: 16rem; background: var(--chart-background); box-shadow: 1px 1px 0 var(--shadow-color); overflow: hidden; position: relative; } .chart-maximized { flex: 1 100%; height: 75vh } .chart-moving { z-index: 11; box-shadow: 0 0 2rem var(--moving-shadow-color); } .chart-displaced { opacity: 75%; filter: blur(1px); } .chart > div { position: absolute; } .inputs { height: auto; width: 100%; font-size: 14pt; display: flex; flex-flow: column nowrap; justify-content: center; position: sticky; top: -1rem; margin-top: -1rem; margin-left: -1rem; margin-right: -1rem; border-top: 1rem solid var(--background-color-1); border-left: 1rem solid var(--background-color-1); border-right: 1rem solid var(--background-color-1); box-sizing: content-box; z-index: 1000; background: var(--background-color-1); } .inputs.unconnected { height: 100vh; } .unconnected #params { display: flex; flex-flow: column nowrap; justify-content: center; align-items: center; } .unconnected #connection-params { width: 50%; display: flex; flex-flow: column nowrap; } .unconnected #url { width: 100%; } .unconnected #button-options { display: grid; grid-auto-flow: column; grid-auto-columns: 1fr; gap: 0.3rem; } .unconnected #user { margin-right: 0; width: auto; } .unconnected #password { width: auto; } #user { margin-right: 0.25rem; width: 50%; } #password { width: 49.5%; } .unconnected input { margin-bottom: 5px; } #username-password { width: 100%; display: flex; flex-flow: row nowrap; } .unconnected #username-password { width: 100%; gap: 0.3rem; display: grid; grid-template-columns: 1fr 1fr; } .inputs #chart-params { display: block; } .inputs.unconnected #chart-params { display: none; } #connection-params { margin-bottom: 0.5rem; display: grid; grid-template-columns: 69.77% 30%; column-gap: 0.25rem; } .inputs input { box-shadow: 1px 1px 0 var(--shadow-color); padding: 0.25rem; } #chart-params input { margin-right: 0.25rem; } #chart-params .param { width: 6%; } 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); } .themes { float: right; font-size: 20pt; gap: 0.3rem; display: flex; justify-content: center; } #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; } #run:hover { filter: contrast(125%); } #add, #reload, #edit, #search { padding: 0.25rem 0.5rem; text-align: center; font-weight: bold; user-select: none; cursor: pointer; background: var(--new-chart-background-color); color: var(--new-chart-text-color); float: right; margin-right: 1rem !important; margin-left: 0rem; margin-bottom: 1rem; height: 3ex; } #add:hover, #reload:hover, #edit:hover, #search:hover { background: var(--button-background-color); } #search-query { float: right; width: 36%; } #global-error { align-self: center; width: 60%; padding: .5rem; color: var(--global-error-color); display: flex; flex-flow: row nowrap; justify-content: center; } 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: 1px 1px 0 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; user-select: none; } .chart-buttons a:hover { color: var(--chart-button-hover-color); } .disabled { opacity: 0.5; } .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; } textarea { padding: 0.5rem; outline: none; border: none; font-size: 12pt; background: var(--chart-background); color: var(--text-color); resize: none; } .query-editor textarea { grid-row: 1; grid-column: 1 / span 2; z-index: 11; border-bottom: 1px solid var(--edit-title-border); margin: 0; } .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%); } .edit-cancel { cursor: pointer; background: var(--new-chart-background-color); } .edit-cancel:hover { filter: contrast(125%); } .nowrap { white-space: pre; } #mass-editor { display: none; grid-template-columns: auto fit-content(10%) fit-content(10%); grid-template-rows: auto fit-content(10%); row-gap: 1rem; column-gap: 1rem; } #mass-editor-textarea { width: 100%; height: 100%; grid-row: 1; grid-column: 1 / span 3; } #mass-editor input { padding: 0.5rem; } #mass-editor-message { color: var(--global-error-color); } #charts > div:only-child .display-only-if-more-than-one-chart { display: none; } .u-series { line-height: 0.8; } .u-series.footer { font-size: 8px; padding-top: 0; margin-top: 0; } /* Source: https://cdn.jsdelivr.net/npm/uplot@1.6.21/dist/uPlot.min.css * It is copy-pasted to lower the number of requests. */ .uplot, .uplot *, .uplot *::before, .uplot *::after {box-sizing: border-box;}.uplot {font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";line-height: 1.5;width: min-content;}.u-title {text-align: center;font-size: 18px;font-weight: bold;}.u-wrap {position: relative;user-select: none;}.u-over, .u-under {position: absolute;}.u-under {overflow: hidden;}.uplot canvas {display: block;position: relative;width: 100%;height: 100%;}.u-axis {position: absolute;}.u-legend {font-size: 14px;margin: auto;text-align: center;}.u-inline {display: block;}.u-inline * {display: inline-block;}.u-inline tr {margin-right: 16px;}.u-legend th {font-weight: 600;}.u-legend th > * {vertical-align: middle;display: inline-block;}.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;/*this has to be !important since we set inline "background" shorthand */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> </head> <body> <div class="inputs unconnected"> <form id="params"> <div id="connection-params"> <input spellcheck="false" id="url" type="text" value="" placeholder="URL" /> <div id="username-password"> <input spellcheck="false" id="user" type="text" value="" placeholder="user" /> <input spellcheck="false" id="password" type="password" placeholder="password" /> <input id="hidden-submit" type="submit" hidden="true"/> </div> </div> <div id="button-options"> <span class="nowrap themes"><span id="toggle-dark">🌚</span><span id="toggle-light">🌞</span></span> <input id="edit" type="button" value="✎" style="display: none;"> <input id="add" type="button" value="Add chart" style="display: none;"> <input id="reload" type="button" value="Reload"> <span id="search-span" class="nowrap" style="display: none;"><input id="search" type="button" value="🔎" title="Run query to obtain list of charts from ClickHouse"><input id="search-query" name="search" type="text" spellcheck="false"></span> <div id="chart-params"></div> </div> </form> <div id="global-error"></div> </div> <div id="charts"></div> <div id="mass-editor"> <textarea id="mass-editor-textarea" spellcheck="false" data-gramm="false"></textarea> <span id="mass-editor-message"> </span> <input type="submit" id="mass-editor-cancel" class="edit-cancel" value="Cancel"> <input type="submit" id="mass-editor-confirm" class="edit-confirm" value="Apply"> </div> <script> /** Implementation note: it might be more natural to use some reactive framework. * But for now it is small enough to avoid it. As a bonus we have less number of dependencies, * which is better for maintainability. * * TODO: * - zoom on the graphs should work on touch devices; * - footer with "about" or a link to source code; * - allow to configure a table on a server to save the dashboards; * - if a query returned one value, display this value instead of a diagram; * - if a query returned something unusual, display the table; */ let host = location.protocol != 'file:' ? location.origin : 'http://localhost:8123/'; let user = 'default'; let password = ''; let add_http_cors_header = (location.protocol != 'file:'); const errorCodeMessageMap = { 516: 'Error authenticating with database. Please check your connection params and try again.' } const errorMessages = [ { regex: /TypeError: Failed to fetch/, messageFunc: () => 'Error authenticating with database. Please check your connection url and try again.', }, { regex: /Code: (\d+)/, messageFunc: (match) => { return errorCodeMessageMap[match[1]] } } ] /// Query to fill `queries` list for the dashboard let search_query = `SELECT title, query FROM system.dashboards WHERE dashboard = 'Overview'`; let customized = false; let queries = []; /// Query parameters with predefined default values. /// All other parameters will be automatically found in the queries. let default_params = { 'rounding': '60', 'seconds': '86400' }; let params = default_params; /// Palette generation for charts function generatePalette(numColors) { // oklch() does not work in firefox<=125 inside <canvas> element so we convert it back to rgb for now. // Based on https://github.com/color-js/color.js/blob/main/src/spaces/oklch.js const multiplyMatrices = (A, B) => { return [ A[0]*B[0] + A[1]*B[1] + A[2]*B[2], A[3]*B[0] + A[4]*B[1] + A[5]*B[2], A[6]*B[0] + A[7]*B[1] + A[8]*B[2] ]; } const oklch2oklab = ([l, c, h]) => [ l, isNaN(h) ? 0 : c * Math.cos(h * Math.PI / 180), isNaN(h) ? 0 : c * Math.sin(h * Math.PI / 180) ] const srgbLinear2rgb = rgb => rgb.map(c => Math.abs(c) > 0.0031308 ? (c < 0 ? -1 : 1) * (1.055 * (Math.abs(c) ** (1 / 2.4)) - 0.055) : 12.92 * c ) const oklab2xyz = lab => { const LMSg = multiplyMatrices([ 1, 0.3963377773761749, 0.2158037573099136, 1, -0.1055613458156586, -0.0638541728258133, 1, -0.0894841775298119, -1.2914855480194092, ], lab) const LMS = LMSg.map(val => val ** 3) return multiplyMatrices([ 1.2268798758459243, -0.5578149944602171, 0.2813910456659647, -0.0405757452148008, 1.1122868032803170, -0.0717110580655164, -0.0763729366746601, -0.4214933324022432, 1.5869240198367816 ], LMS) } const xyz2rgbLinear = xyz => { return multiplyMatrices([ 3.2409699419045226, -1.537383177570094, -0.4986107602930034, -0.9692436362808796, 1.8759675015077202, 0.04155505740717559, 0.05563007969699366, -0.20397695888897652, 1.0569715142428786 ], xyz) } const oklch2rgb = lch => srgbLinear2rgb(xyz2rgbLinear(oklab2xyz(oklch2oklab(lch)))) palette = []; for (let i = 0; i < numColors; i++) { //palette.push(`oklch(${theme != 'dark' ? 0.75 : 0.5}, 0.15, ${360 * i / numColors})`); let rgb = oklch2rgb([theme != 'dark' ? 0.75 : 0.5, 0.15, 360 * i / numColors]); palette.push(`rgb(${rgb[0] * 255}, ${rgb[1] * 255}, ${rgb[2] * 255})`); } return palette; } 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) { const typeDefault = (type) => type.includes('Int') ? '0' : (type.includes('Float') ? '0.0' : (type.includes('Bool') ? 'false' : (type.includes('Date') ? new Date().toISOString().slice(0, 10) : (type.includes('UUID') ? '00000000-0000-0000-0000-000000000000' : '')))); for (let match of query.matchAll(query_param_regexp)) { const name = match[1]; new_params[name] = params[name] || default_params[name] || typeDefault(match[2]); } } 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 += `¶m_${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_textarea.setAttribute('data-gramm', false); 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 getCurrentIndex() { /// Indices may change after deletion of other element, hence captured "i" may become incorrect. return [...charts.querySelectorAll('.chart')].findIndex(child => chart == child); } 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(); refreshCustomized(true); saveState(); const idx = getCurrentIndex(); draw(idx, chart, getParamsForURL(), q.query); } query_editor_confirm.addEventListener('click', editConfirm); /// Ctrl+Enter (or Cmd+Enter on Mac) will also confirm editing. query_editor.addEventListener('keydown', event => { 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 move = document.createElement('a'); let move_text = document.createTextNode('✥'); move.appendChild(move_text); let drag_state = { is_dragging: false, idx: null, offset_x: null, offset_y: null, displace_idx: null, displace_chart: null }; function dragStop(e) { drag_state.is_dragging = false; chart.className = 'chart'; chart.style.left = null; chart.style.top = null; if (drag_state.displace_idx !== null) { const elem = queries[drag_state.idx]; queries.splice(drag_state.idx, 1); queries.splice(drag_state.displace_idx, 0, elem); drag_state.displace_chart.className = 'chart'; drawAll(); } } function dragMove(e) { if (!drag_state.is_dragging) return; let x = e.clientX - drag_state.offset_x; let y = e.clientY - drag_state.offset_y; chart.style.left = `${x}px`; chart.style.top = `${y}px`; drag_state.displace_idx = null; drag_state.displace_chart = null; let current_idx = -1; for (const elem of charts.querySelectorAll('.chart')) { ++current_idx; if (current_idx == drag_state.idx) { continue; } const this_rect = chart.getBoundingClientRect(); const this_center_x = this_rect.left + this_rect.width / 2; const this_center_y = this_rect.top + this_rect.height / 2; const elem_rect = elem.getBoundingClientRect(); if (this_center_x >= elem_rect.left && this_center_x <= elem_rect.right && this_center_y >= elem_rect.top && this_center_y <= elem_rect.bottom) { elem.className = 'chart chart-displaced'; drag_state.displace_idx = current_idx; drag_state.displace_chart = elem; } else { elem.className = 'chart'; } } } function dragStart(e) { if (e.button !== 0) return; /// left button only move.setPointerCapture(e.pointerId); drag_state.is_dragging = true; drag_state.idx = getCurrentIndex(); chart.className = 'chart chart-moving'; drag_state.offset_x = e.clientX; drag_state.offset_y = e.clientY; } /// Read https://www.redblobgames.com/making-of/draggable/ move.addEventListener('pointerdown', dragStart); move.addEventListener('pointermove', dragMove); move.addEventListener('pointerup', dragStop); move.addEventListener('pointerancel', dragStop); move.addEventListener('touchstart', (e) => e.preventDefault()); let maximize = document.createElement('a'); let maximize_text = document.createTextNode('🗖'); maximize.appendChild(maximize_text); maximize.addEventListener('click', e => { const idx = getCurrentIndex(); chart.className = (chart.className == 'chart' ? 'chart chart-maximized' : 'chart'); resize(); }); 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 => { const idx = getCurrentIndex(); if (plots[idx]) { plots[idx].destroy(); plots[idx] = null; } plots.splice(idx, 1); charts.removeChild(chart); queries.splice(idx, 1); findParamsInQueries(); buildParams(); resize(); refreshCustomized(true); saveState(); }); move.classList.add('display-only-if-more-than-one-chart'); maximize.classList.add('display-only-if-more-than-one-chart'); edit_buttons.appendChild(move); edit_buttons.appendChild(maximize); 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); return {chart: chart, textarea: query_editor_textarea}; } document.getElementById('add').addEventListener('click', e => { queries.push({ title: '', query: '' }); const {chart, textarea} = insertChart(plots.length); chart.scrollIntoView(); textarea.focus(); plots.push(null); resize(); }); document.getElementById('reload').addEventListener('click', e => { reloadAll(queries.length == 0); }); document.getElementById('search').addEventListener('click', e => { reloadAll(true); }); let mass_editor_active = false; function showMassEditor() { document.getElementById('charts').style.display = 'none'; let editor_div = document.getElementById('mass-editor'); editor_div.style.display = 'grid'; let editor = document.getElementById('mass-editor-textarea'); editor.value = JSON.stringify({params: params, queries: queries}, null, 2); mass_editor_active = true; } function hideMassEditor() { document.getElementById('mass-editor').style.display = 'none'; document.getElementById('charts').style.display = 'flex'; mass_editor_active = false; } function massEditorApplyChanges() { let editor = document.getElementById('mass-editor-textarea'); ({params, queries} = JSON.parse(editor.value)); hideMassEditor(); regenerate(); refreshCustomized(true); saveState(); drawAll(); } document.getElementById('edit').addEventListener('click', e => { if (mass_editor_active) { massEditorApplyChanges(); hideMassEditor(); } else { showMassEditor(); } }); document.getElementById('mass-editor-confirm').addEventListener('click', e => { massEditorApplyChanges(); hideMassEditor(); }); document.getElementById('mass-editor-cancel').addEventListener('click', e => { hideMassEditor(); }); document.getElementById('mass-editor-textarea').addEventListener('input', e => { let message = document.getElementById('mass-editor-message').firstChild; message.data = ''; if (e.target.value != '') { try { JSON.parse(e.target.value) } catch (e) { message.data = e.toString(); } } }); function legendAsTooltipPlugin({ className, style = { background: "var(--legend-background)" } } = {}) { 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: "left", 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 / 2; top -= legendEl.clientHeight / 2; 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 function doFetch(query, url_params = '') { host = document.getElementById('url').value || host; user = document.getElementById('user').value; password = document.getElementById('password').value; let url = `${host}?default_format=JSONColumnsWithMetadata&enable_http_compression=1` if (add_http_cors_header) { // For debug purposes, you may set add_http_cors_header from a browser console url += '&add_http_cors_header=1'; } if (user) { url += `&user=${encodeURIComponent(user)}`; } if (password) { url += `&password=${encodeURIComponent(password)}`; } let response, reply, error; try { response = await fetch(url + url_params, { method: "POST", body: query }); reply = await response.text(); if (response.ok) { reply = JSON.parse(reply); if (reply.exception) { error = reply.exception; } } else { error = reply; } } catch (e) { console.log(e); error = e.toString(); } if (error) { const errorMatch = errorMessages.find(({ regex }) => error.match(regex)); if (!errorMatch) { throw new Error(error); } const match = error.match(errorMatch.regex); const message = errorMatch.messageFunc(match); if (message) { throw new Error(message); } } return {reply, error}; } async function draw(idx, chart, url_params, query) { if (plots[idx]) { plots[idx].destroy(); plots[idx] = null; } let {reply, error} = await doFetch(query, url_params); if (!error) { if (reply.rows.length == 0) { error = "Query returned empty result."; } else if (reply.meta.length < 2) { error = "Query should return at least two columns: unix timestamp and value."; } else { for (let i = 0; i < reply.meta.length; i++) { let label = reply.meta[i].name; let column = reply.data[label]; if (!Array.isArray(column) || column.length != reply.data[reply.meta[0].name].length) { error = "Wrong data format of the query."; break; } } } } // Transform string-labeled data to multi-column data function transformToColumns() { const x = reply.meta[0].name; // time; must be ordered const l = reply.meta[1].name; // string label column to distinguish series; must be ordered const y = reply.meta[2].name; // values; must have single value for (x, l) pair const labels = [...new Set(reply.data[l])].sort((a, b) => a - b); if (labels.includes('__time__')) { error = "The second column is not allowed to contain '__time__' values."; return; } const times = [...new Set(reply.data[x])].sort((a, b) => a - b); let new_meta = [{ name: '__time__', type: reply.meta[0].type }]; let new_data = { __time__: [] }; for (let label of labels) { new_meta.push({ name: label, type: reply.meta[2].type }); new_data[label] = []; } let new_rows = 0; function row_done(row_time) { new_rows++; new_data.__time__.push(row_time); for (let label of labels) { if (new_data[label].length < new_rows) { new_data[label].push(null); } } } let prev_time = reply.data[x][0]; const old_rows = reply.data[x].length; for (let i = 0; i < old_rows; i++) { const time = reply.data[x][i]; const label = reply.data[l][i]; const value = reply.data[y][i]; if (prev_time != time) { row_done(prev_time); prev_time = time; } new_data[label].push(value); } row_done(prev_time); reply.meta = new_meta; reply.data = new_data; reply.rows = new_rows; } function isStringColumn(type) { return type === 'String' || type === 'LowCardinality(String)'; } if (!error) { if (reply.meta.length == 3 && isStringColumn(reply.meta[1].type)) { transformToColumns(); } } 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 false; } 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' ? ["#ff8888", "#ffeeee", "#eeeedd", "#2c3235"] : ["#886644", "#004455", "#2c3235", "#c7d0d9"]; let sync = uPlot.sync("sync"); let axis = { stroke: axes_color, grid: { width: 1 / devicePixelRatio, stroke: grid_color }, ticks: { width: 1 / devicePixelRatio, stroke: grid_color } }; let axes = [axis, axis]; let series = [{ label: "x" }]; let data = [reply.data[reply.meta[0].name]]; // Treat every column as series const series_count = reply.meta.length; const fill = series_count == 2 ? fill_color : undefined; const palette = series_count == 2 ? [line_color] : generatePalette(series_count); let max_value = Number.NEGATIVE_INFINITY; for (let i = 1; i < series_count; i++) { let label = reply.meta[i].name; series.push({ label, stroke: palette[i - 1], fill }); data.push(reply.data[label]); max_value = Math.max(max_value, ...reply.data[label]); } const opts = { width: chart.clientWidth, height: chart.clientHeight, axes, series, 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] && queries[idx].title ? queries[idx].title.replaceAll(/\{(\w+)\}/g, (_, name) => params[name] ) : ''; chart.querySelector('.title').firstChild.data = title; return true; } function showError(message) { const charts = document.getElementById('charts'); charts.style.height = '0px'; charts.style.opacity = '0'; document.getElementById('add').style.display = 'none'; document.getElementById('edit').style.display = 'none'; const error = document.getElementById('global-error'); error.textContent = message; error.style.display = 'flex'; } function hideError() { const charts = document.getElementById('charts'); charts.style.height = 'auto'; charts.style.opacity = '1'; const error = document.getElementById('global-error'); error.textContent = ''; error.style.display = 'none'; } let firstLoad = true; let is_drawing = false; // Prevent race condition leading to duplicate/dangling charts. async function drawAll() { if (is_drawing) return; is_drawing = true; let params = getParamsForURL(); const chartsArray = document.getElementsByClassName('chart'); if (!firstLoad) { hideError(); } await Promise.all([...Array(queries.length)].map(async (_, i) => { return draw(i, chartsArray[i], params, queries[i].query).catch((e) => { if (!firstLoad) { showError(e.message); } return false; }); })).then((results) => { if (firstLoad) { firstLoad = false; } else { enableButtons(); } if (results.includes(true)) { const element = document.querySelector('.inputs'); element.classList.remove('unconnected'); document.getElementById('add').style.display = 'inline-block'; document.getElementById('edit').style.display = 'inline-block'; document.getElementById('search-span').style.display = ''; hideError(); } else { document.getElementById('charts').style.height = '0px'; } }); is_drawing = false; } 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); function disableButtons() { const reloadButton = document.getElementById('reload'); reloadButton.value = 'Reloading…'; reloadButton.disabled = true; reloadButton.classList.add('disabled'); const runButton = document.getElementById('run'); if (runButton) { runButton.value = 'Reloading…'; runButton.disabled = true; runButton.classList.add('disabled'); } const searchButton = document.getElementById('search'); searchButton.value = '…'; searchButton.disabled = true; searchButton.classList.add('disabled'); } function enableButtons() { const reloadButton = document.getElementById('reload'); reloadButton.value = 'Reload'; reloadButton.disabled = false; reloadButton.classList.remove('disabled'); const runButton = document.getElementById('run'); if (runButton) { runButton.value = 'Ok'; runButton.disabled = false; runButton.classList.remove('disabled'); } const searchButton = document.getElementById('search'); searchButton.value = '🔎'; searchButton.disabled = false; searchButton.classList.remove('disabled'); } async function reloadAll(do_search) { disableButtons(); try { updateParams(); if (do_search) { search_query = document.getElementById('search-query').value; queries = []; refreshCustomized(false); } saveState(); if (do_search) { await searchQueries(); } await drawAll(); } catch (e) { showError(e.message); } enableButtons(); } document.getElementById('params').onsubmit = function(event) { if (document.activeElement === document.getElementById('search-query')) { reloadAll(true); } else { reloadAll(queries.length == 0); } event.preventDefault(); } const decodeState = (x) => JSON.parse(LZString.decompressFromEncodedURIComponent(x) || atob(x)); const encodeState = (x) => LZString.compressToEncodedURIComponent(JSON.stringify(x)); function saveState() { const state = { host, user, queries, params, search_query, customized }; history.pushState(state, '', window.location.pathname + (window.location.search || '') + '#' + encodeState(state)); } async function searchQueries() { let {reply, error} = await doFetch(search_query); if (error) { throw new Error(error); } let data = reply.data; if (reply.rows == 0) { throw new Error("Search query returned empty result."); } else if (reply.meta.length != 2 || reply.meta[0].name != "title" || reply.meta[1].name != "query") { throw new Error("Search query should return exactly two columns: title and query."); } else if (!Array.isArray(data.title) || !Array.isArray(data.query) || data.title.length != data.query.length) { throw new Error("Wrong data format of the search query."); } for (let i = 0; i < data.title.length; i++) { queries.push({title: data.title[i], query: data.query[i]}); } regenerate(); } function refreshCustomized(value) { if (value !== undefined) { customized = value; } document.getElementById('search-span').style.opacity = customized ? 0.5 : 1.0; } function updateFromState() { document.getElementById('url').value = host; document.getElementById('user').value = user; document.getElementById('password').value = password; document.getElementById('search-query').value = search_query; refreshCustomized(); } function regenerate() { 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, search_query, customized} = event.state); updateFromState(); regenerate(); drawAll(); }; if (window.location.hash) { try { let search_query_, customized_; ({host, user, queries, params, search_query_, customized_} = decodeState(window.location.hash.substring(1))); // For compatibility with old URLs' hashes search_query = search_query_ !== undefined ? search_query_ : search_query; customized = customized_ !== undefined ? customized_ : true; } catch {} } async function start() { try { updateFromState(); if (queries.length == 0) { await searchQueries(); } else { regenerate(); } saveState(); let new_theme = window.localStorage.getItem('theme'); if (new_theme && new_theme != theme) { setTheme(new_theme); } else { drawAll(); } } catch (e) { showError(e.message); } } start(); </script> </body> </html>