app/vmui: move source code from https://github.com/VictoriaMetrics/vmui to app/vmui

Updates https://github.com/VictoriaMetrics/VictoriaMetrics/issues/1413
This commit is contained in:
Aliaksandr Valialkin 2021-07-09 17:04:28 +03:00
parent 2be340a4c9
commit 8c764e88f0
96 changed files with 19967 additions and 60 deletions

View file

@ -577,15 +577,9 @@ VictoriaMetrics accepts `round_digits` query arg for `/api/v1/query` and `/api/v
By default, VictoriaMetrics returns time series for the last 5 minutes from `/api/v1/series`, while the Prometheus API defaults to all time. Use `start` and `end` to select a different time range.
VictoriaMetrics accepts additional args for `/api/v1/labels` and `/api/v1/label/.../values` handlers.
* Any number [time series selectors](https://prometheus.io/docs/prometheus/latest/querying/basics/#time-series-selectors) via `match[]` query arg.
* Optional `start` and `end` query args for limiting the time range for the selected labels or label values.
See [this feature request](https://github.com/prometheus/prometheus/issues/6178) for details.
Additionally VictoriaMetrics provides the following handlers:
* `/vmui` - Basic Web UI
* `/api/v1/series/count` - returns the total number of time series in the database. Some notes:
* the handler scans all the inverted index, so it can be slow if the database contains tens of millions of time series;
* the handler may count [deleted time series](#how-to-delete-time-series) additionally to normal time series due to internal implementation restrictions;

View file

@ -90,7 +90,7 @@ func requestHandler(w http.ResponseWriter, r *http.Request) bool {
fmt.Fprintf(w, "See docs at <a href='https://docs.victoriametrics.com/'>https://docs.victoriametrics.com/</a></br>")
fmt.Fprintf(w, "Useful endpoints:</br>")
httpserver.WriteAPIHelp(w, [][2]string{
{"/ui", "Web UI"},
{"/vmui", "Web UI"},
{"/targets", "discovered targets list"},
{"/api/v1/targets", "advanced information about discovered targets in JSON format"},
{"/metrics", "available service metrics"},

View file

@ -1,2 +1,4 @@
`vmselect` performs the incoming queries and fetches the required data
from `vmstorage`.
The `vmui` directory contains static contents built from [app/vmui](https://github.com/VictoriaMetrics/VictoriaMetrics/tree/master/app/vmui) package with `make vmui-update` command. The `vmui` page is available at `http://<victoria-metrics>:8428/vmui/`.

View file

@ -78,17 +78,16 @@ var (
})
)
// static content
//go:embed ui
var uiFiles embed.FS
//go:embed vmui
var vmuiFiles embed.FS
var uiFileServer = http.FileServer(http.FS(uiFiles))
var vmuiFileServer = http.FileServer(http.FS(vmuiFiles))
// RequestHandler handles remote read API requests
func RequestHandler(w http.ResponseWriter, r *http.Request) bool {
// ui access.
if strings.HasPrefix(r.URL.Path, "/ui") {
uiFileServer.ServeHTTP(w, r)
// vmui access.
if strings.HasPrefix(r.URL.Path, "/vmui") {
vmuiFileServer.ServeHTTP(w, r)
return true
}

View file

@ -1,17 +0,0 @@
{
"files": {
"main.css": "./static/css/main.0ba440d3.chunk.css",
"main.js": "./static/js/main.37569ff7.chunk.js",
"runtime-main.js": "./static/js/runtime-main.04462c68.js",
"static/js/2.644dfc9f.chunk.js": "./static/js/2.644dfc9f.chunk.js",
"static/js/3.1aaf74ff.chunk.js": "./static/js/3.1aaf74ff.chunk.js",
"index.html": "./index.html",
"static/js/2.644dfc9f.chunk.js.LICENSE.txt": "./static/js/2.644dfc9f.chunk.js.LICENSE.txt"
},
"entrypoints": [
"static/js/runtime-main.04462c68.js",
"static/js/2.644dfc9f.chunk.js",
"static/css/main.0ba440d3.chunk.css",
"static/js/main.37569ff7.chunk.js"
]
}

View file

@ -1 +0,0 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="icon" href="./favicon.ico"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><meta name="description" content="VM-UI is a metric explorer for Victoria Metrics"/><link rel="apple-touch-icon" href="./apple-touch-icon.png"/><link rel="icon" type="image/png" sizes="32x32" href="./favicon-32x32.png"><link rel="manifest" href="./manifest.json"/><title>VM UI</title><link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap"/><link href="./static/css/main.0ba440d3.chunk.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div><script>!function(e){function r(r){for(var n,u,a=r[0],c=r[1],f=r[2],s=0,p=[];s<a.length;s++)u=a[s],Object.prototype.hasOwnProperty.call(o,u)&&o[u]&&p.push(o[u][0]),o[u]=0;for(n in c)Object.prototype.hasOwnProperty.call(c,n)&&(e[n]=c[n]);for(l&&l(r);p.length;)p.shift()();return i.push.apply(i,f||[]),t()}function t(){for(var e,r=0;r<i.length;r++){for(var t=i[r],n=!0,a=1;a<t.length;a++){var c=t[a];0!==o[c]&&(n=!1)}n&&(i.splice(r--,1),e=u(u.s=t[0]))}return e}var n={},o={1:0},i=[];function u(r){if(n[r])return n[r].exports;var t=n[r]={i:r,l:!1,exports:{}};return e[r].call(t.exports,t,t.exports,u),t.l=!0,t.exports}u.e=function(e){var r=[],t=o[e];if(0!==t)if(t)r.push(t[2]);else{var n=new Promise((function(r,n){t=o[e]=[r,n]}));r.push(t[2]=n);var i,a=document.createElement("script");a.charset="utf-8",a.timeout=120,u.nc&&a.setAttribute("nonce",u.nc),a.src=function(e){return u.p+"static/js/"+({}[e]||e)+"."+{3:"1aaf74ff"}[e]+".chunk.js"}(e);var c=new Error;i=function(r){a.onerror=a.onload=null,clearTimeout(f);var t=o[e];if(0!==t){if(t){var n=r&&("load"===r.type?"missing":r.type),i=r&&r.target&&r.target.src;c.message="Loading chunk "+e+" failed.\n("+n+": "+i+")",c.name="ChunkLoadError",c.type=n,c.request=i,t[1](c)}o[e]=void 0}};var f=setTimeout((function(){i({type:"timeout",target:a})}),12e4);a.onerror=a.onload=i,document.head.appendChild(a)}return Promise.all(r)},u.m=e,u.c=n,u.d=function(e,r,t){u.o(e,r)||Object.defineProperty(e,r,{enumerable:!0,get:t})},u.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},u.t=function(e,r){if(1&r&&(e=u(e)),8&r)return e;if(4&r&&"object"==typeof e&&e&&e.__esModule)return e;var t=Object.create(null);if(u.r(t),Object.defineProperty(t,"default",{enumerable:!0,value:e}),2&r&&"string"!=typeof e)for(var n in e)u.d(t,n,function(r){return e[r]}.bind(null,n));return t},u.n=function(e){var r=e&&e.__esModule?function(){return e.default}:function(){return e};return u.d(r,"a",r),r},u.o=function(e,r){return Object.prototype.hasOwnProperty.call(e,r)},u.p="./",u.oe=function(e){throw console.error(e),e};var a=this["webpackJsonpvictoria-metrics-ui"]=this["webpackJsonpvictoria-metrics-ui"]||[],c=a.push.bind(a);a.push=r,a=a.slice();for(var f=0;f<a.length;f++)r(a[f]);var l=c;t()}([])</script><script src="./static/js/2.644dfc9f.chunk.js"></script><script src="./static/js/main.37569ff7.chunk.js"></script></body></html>

File diff suppressed because one or more lines are too long

View file

@ -1 +0,0 @@
(this["webpackJsonpvictoria-metrics-ui"]=this["webpackJsonpvictoria-metrics-ui"]||[]).push([[3],{430:function(t,n,e){"use strict";e.r(n),e.d(n,"getCLS",(function(){return l})),e.d(n,"getFCP",(function(){return g})),e.d(n,"getFID",(function(){return h})),e.d(n,"getLCP",(function(){return y})),e.d(n,"getTTFB",(function(){return F}));var i,a,r=function(){return"".concat(Date.now(),"-").concat(Math.floor(8999999999999*Math.random())+1e12)},o=function(t){var n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:-1;return{name:t,value:n,delta:0,entries:[],id:r(),isFinal:!1}},u=function(t,n){try{if(PerformanceObserver.supportedEntryTypes.includes(t)){var e=new PerformanceObserver((function(t){return t.getEntries().map(n)}));return e.observe({type:t,buffered:!0}),e}}catch(t){}},c=!1,s=!1,d=function(t){c=!t.persisted},f=function(){addEventListener("pagehide",d),addEventListener("beforeunload",(function(){}))},p=function(t){var n=arguments.length>1&&void 0!==arguments[1]&&arguments[1];s||(f(),s=!0),addEventListener("visibilitychange",(function(n){var e=n.timeStamp;"hidden"===document.visibilityState&&t({timeStamp:e,isUnloading:c})}),{capture:!0,once:n})},v=function(t,n,e,i){var a;return function(){e&&n.isFinal&&e.disconnect(),n.value>=0&&(i||n.isFinal||"hidden"===document.visibilityState)&&(n.delta=n.value-(a||0),(n.delta||n.isFinal||void 0===a)&&(t(n),a=n.value))}},l=function(t){var n,e=arguments.length>1&&void 0!==arguments[1]&&arguments[1],i=o("CLS",0),a=function(t){t.hadRecentInput||(i.value+=t.value,i.entries.push(t),n())},r=u("layout-shift",a);r&&(n=v(t,i,r,e),p((function(t){var e=t.isUnloading;r.takeRecords().map(a),e&&(i.isFinal=!0),n()})))},m=function(){return void 0===i&&(i="hidden"===document.visibilityState?0:1/0,p((function(t){var n=t.timeStamp;return i=n}),!0)),{get timeStamp(){return i}}},g=function(t){var n,e=o("FCP"),i=m(),a=u("paint",(function(t){"first-contentful-paint"===t.name&&t.startTime<i.timeStamp&&(e.value=t.startTime,e.isFinal=!0,e.entries.push(t),n())}));a&&(n=v(t,e,a))},h=function(t){var n=o("FID"),e=m(),i=function(t){t.startTime<e.timeStamp&&(n.value=t.processingStart-t.startTime,n.entries.push(t),n.isFinal=!0,r())},a=u("first-input",i),r=v(t,n,a);a?p((function(){a.takeRecords().map(i),a.disconnect()}),!0):window.perfMetrics&&window.perfMetrics.onFirstInputDelay&&window.perfMetrics.onFirstInputDelay((function(t,i){i.timeStamp<e.timeStamp&&(n.value=t,n.isFinal=!0,n.entries=[{entryType:"first-input",name:i.type,target:i.target,cancelable:i.cancelable,startTime:i.timeStamp,processingStart:i.timeStamp+t}],r())}))},S=function(){return a||(a=new Promise((function(t){return["scroll","keydown","pointerdown"].map((function(n){addEventListener(n,t,{once:!0,passive:!0,capture:!0})}))}))),a},y=function(t){var n,e=arguments.length>1&&void 0!==arguments[1]&&arguments[1],i=o("LCP"),a=m(),r=function(t){var e=t.startTime;e<a.timeStamp?(i.value=e,i.entries.push(t)):i.isFinal=!0,n()},c=u("largest-contentful-paint",r);if(c){n=v(t,i,c,e);var s=function(){i.isFinal||(c.takeRecords().map(r),i.isFinal=!0,n())};S().then(s),p(s,!0)}},F=function(t){var n,e=o("TTFB");n=function(){try{var n=performance.getEntriesByType("navigation")[0]||function(){var t=performance.timing,n={entryType:"navigation",startTime:0};for(var e in t)"navigationStart"!==e&&"toJSON"!==e&&(n[e]=Math.max(t[e]-t.navigationStart,0));return n}();e.value=e.delta=n.responseStart,e.entries=[n],e.isFinal=!0,t(e)}catch(t){}},"complete"===document.readyState?setTimeout(n,0):addEventListener("pageshow",n)}}}]);

File diff suppressed because one or more lines are too long

View file

@ -1 +0,0 @@
!function(e){function r(r){for(var n,u,a=r[0],c=r[1],f=r[2],s=0,p=[];s<a.length;s++)u=a[s],Object.prototype.hasOwnProperty.call(o,u)&&o[u]&&p.push(o[u][0]),o[u]=0;for(n in c)Object.prototype.hasOwnProperty.call(c,n)&&(e[n]=c[n]);for(l&&l(r);p.length;)p.shift()();return i.push.apply(i,f||[]),t()}function t(){for(var e,r=0;r<i.length;r++){for(var t=i[r],n=!0,a=1;a<t.length;a++){var c=t[a];0!==o[c]&&(n=!1)}n&&(i.splice(r--,1),e=u(u.s=t[0]))}return e}var n={},o={1:0},i=[];function u(r){if(n[r])return n[r].exports;var t=n[r]={i:r,l:!1,exports:{}};return e[r].call(t.exports,t,t.exports,u),t.l=!0,t.exports}u.e=function(e){var r=[],t=o[e];if(0!==t)if(t)r.push(t[2]);else{var n=new Promise((function(r,n){t=o[e]=[r,n]}));r.push(t[2]=n);var i,a=document.createElement("script");a.charset="utf-8",a.timeout=120,u.nc&&a.setAttribute("nonce",u.nc),a.src=function(e){return u.p+"static/js/"+({}[e]||e)+"."+{3:"1aaf74ff"}[e]+".chunk.js"}(e);var c=new Error;i=function(r){a.onerror=a.onload=null,clearTimeout(f);var t=o[e];if(0!==t){if(t){var n=r&&("load"===r.type?"missing":r.type),i=r&&r.target&&r.target.src;c.message="Loading chunk "+e+" failed.\n("+n+": "+i+")",c.name="ChunkLoadError",c.type=n,c.request=i,t[1](c)}o[e]=void 0}};var f=setTimeout((function(){i({type:"timeout",target:a})}),12e4);a.onerror=a.onload=i,document.head.appendChild(a)}return Promise.all(r)},u.m=e,u.c=n,u.d=function(e,r,t){u.o(e,r)||Object.defineProperty(e,r,{enumerable:!0,get:t})},u.r=function(e){"undefined"!==typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},u.t=function(e,r){if(1&r&&(e=u(e)),8&r)return e;if(4&r&&"object"===typeof e&&e&&e.__esModule)return e;var t=Object.create(null);if(u.r(t),Object.defineProperty(t,"default",{enumerable:!0,value:e}),2&r&&"string"!=typeof e)for(var n in e)u.d(t,n,function(r){return e[r]}.bind(null,n));return t},u.n=function(e){var r=e&&e.__esModule?function(){return e.default}:function(){return e};return u.d(r,"a",r),r},u.o=function(e,r){return Object.prototype.hasOwnProperty.call(e,r)},u.p="./",u.oe=function(e){throw console.error(e),e};var a=this["webpackJsonpvictoria-metrics-ui"]=this["webpackJsonpvictoria-metrics-ui"]||[],c=a.push.bind(a);a.push=r,a=a.slice();for(var f=0;f<a.length;f++)r(a[f]);var l=c;t()}([]);

View file

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View file

@ -0,0 +1,17 @@
{
"files": {
"main.css": "./static/css/main.0ba440d3.chunk.css",
"main.js": "./static/js/main.ffd27a2f.chunk.js",
"runtime-main.js": "./static/js/runtime-main.50ad8b45.js",
"static/js/2.3cdac8ea.chunk.js": "./static/js/2.3cdac8ea.chunk.js",
"static/js/3.d52da3ae.chunk.js": "./static/js/3.d52da3ae.chunk.js",
"index.html": "./index.html",
"static/js/2.3cdac8ea.chunk.js.LICENSE.txt": "./static/js/2.3cdac8ea.chunk.js.LICENSE.txt"
},
"entrypoints": [
"static/js/runtime-main.50ad8b45.js",
"static/js/2.3cdac8ea.chunk.js",
"static/css/main.0ba440d3.chunk.css",
"static/js/main.ffd27a2f.chunk.js"
]
}

View file

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

View file

@ -0,0 +1 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="icon" href="./favicon.ico"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><meta name="description" content="VM-UI is a metric explorer for Victoria Metrics"/><link rel="apple-touch-icon" href="./apple-touch-icon.png"/><link rel="icon" type="image/png" sizes="32x32" href="./favicon-32x32.png"><link rel="manifest" href="./manifest.json"/><title>VM UI</title><link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap"/><link href="./static/css/main.0ba440d3.chunk.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div><script>!function(e){function r(r){for(var n,i,a=r[0],c=r[1],l=r[2],s=0,p=[];s<a.length;s++)i=a[s],Object.prototype.hasOwnProperty.call(o,i)&&o[i]&&p.push(o[i][0]),o[i]=0;for(n in c)Object.prototype.hasOwnProperty.call(c,n)&&(e[n]=c[n]);for(f&&f(r);p.length;)p.shift()();return u.push.apply(u,l||[]),t()}function t(){for(var e,r=0;r<u.length;r++){for(var t=u[r],n=!0,a=1;a<t.length;a++){var c=t[a];0!==o[c]&&(n=!1)}n&&(u.splice(r--,1),e=i(i.s=t[0]))}return e}var n={},o={1:0},u=[];function i(r){if(n[r])return n[r].exports;var t=n[r]={i:r,l:!1,exports:{}};return e[r].call(t.exports,t,t.exports,i),t.l=!0,t.exports}i.e=function(e){var r=[],t=o[e];if(0!==t)if(t)r.push(t[2]);else{var n=new Promise((function(r,n){t=o[e]=[r,n]}));r.push(t[2]=n);var u,a=document.createElement("script");a.charset="utf-8",a.timeout=120,i.nc&&a.setAttribute("nonce",i.nc),a.src=function(e){return i.p+"static/js/"+({}[e]||e)+"."+{3:"d52da3ae"}[e]+".chunk.js"}(e);var c=new Error;u=function(r){a.onerror=a.onload=null,clearTimeout(l);var t=o[e];if(0!==t){if(t){var n=r&&("load"===r.type?"missing":r.type),u=r&&r.target&&r.target.src;c.message="Loading chunk "+e+" failed.\n("+n+": "+u+")",c.name="ChunkLoadError",c.type=n,c.request=u,t[1](c)}o[e]=void 0}};var l=setTimeout((function(){u({type:"timeout",target:a})}),12e4);a.onerror=a.onload=u,document.head.appendChild(a)}return Promise.all(r)},i.m=e,i.c=n,i.d=function(e,r,t){i.o(e,r)||Object.defineProperty(e,r,{enumerable:!0,get:t})},i.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},i.t=function(e,r){if(1&r&&(e=i(e)),8&r)return e;if(4&r&&"object"==typeof e&&e&&e.__esModule)return e;var t=Object.create(null);if(i.r(t),Object.defineProperty(t,"default",{enumerable:!0,value:e}),2&r&&"string"!=typeof e)for(var n in e)i.d(t,n,function(r){return e[r]}.bind(null,n));return t},i.n=function(e){var r=e&&e.__esModule?function(){return e.default}:function(){return e};return i.d(r,"a",r),r},i.o=function(e,r){return Object.prototype.hasOwnProperty.call(e,r)},i.p="./",i.oe=function(e){throw console.error(e),e};var a=this.webpackJsonpvmui=this.webpackJsonpvmui||[],c=a.push.bind(a);a.push=r,a=a.slice();for(var l=0;l<a.length;l++)r(a[l]);var f=c;t()}([])</script><script src="./static/js/2.3cdac8ea.chunk.js"></script><script src="./static/js/main.ffd27a2f.chunk.js"></script></body></html>

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
(this.webpackJsonpvmui=this.webpackJsonpvmui||[]).push([[3],{430:function(t,n,e){"use strict";e.r(n),e.d(n,"getCLS",(function(){return l})),e.d(n,"getFCP",(function(){return g})),e.d(n,"getFID",(function(){return h})),e.d(n,"getLCP",(function(){return y})),e.d(n,"getTTFB",(function(){return F}));var i,a,r=function(){return"".concat(Date.now(),"-").concat(Math.floor(8999999999999*Math.random())+1e12)},o=function(t){var n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:-1;return{name:t,value:n,delta:0,entries:[],id:r(),isFinal:!1}},u=function(t,n){try{if(PerformanceObserver.supportedEntryTypes.includes(t)){var e=new PerformanceObserver((function(t){return t.getEntries().map(n)}));return e.observe({type:t,buffered:!0}),e}}catch(t){}},s=!1,c=!1,d=function(t){s=!t.persisted},f=function(){addEventListener("pagehide",d),addEventListener("beforeunload",(function(){}))},p=function(t){var n=arguments.length>1&&void 0!==arguments[1]&&arguments[1];c||(f(),c=!0),addEventListener("visibilitychange",(function(n){var e=n.timeStamp;"hidden"===document.visibilityState&&t({timeStamp:e,isUnloading:s})}),{capture:!0,once:n})},v=function(t,n,e,i){var a;return function(){e&&n.isFinal&&e.disconnect(),n.value>=0&&(i||n.isFinal||"hidden"===document.visibilityState)&&(n.delta=n.value-(a||0),(n.delta||n.isFinal||void 0===a)&&(t(n),a=n.value))}},l=function(t){var n,e=arguments.length>1&&void 0!==arguments[1]&&arguments[1],i=o("CLS",0),a=function(t){t.hadRecentInput||(i.value+=t.value,i.entries.push(t),n())},r=u("layout-shift",a);r&&(n=v(t,i,r,e),p((function(t){var e=t.isUnloading;r.takeRecords().map(a),e&&(i.isFinal=!0),n()})))},m=function(){return void 0===i&&(i="hidden"===document.visibilityState?0:1/0,p((function(t){var n=t.timeStamp;return i=n}),!0)),{get timeStamp(){return i}}},g=function(t){var n,e=o("FCP"),i=m(),a=u("paint",(function(t){"first-contentful-paint"===t.name&&t.startTime<i.timeStamp&&(e.value=t.startTime,e.isFinal=!0,e.entries.push(t),n())}));a&&(n=v(t,e,a))},h=function(t){var n=o("FID"),e=m(),i=function(t){t.startTime<e.timeStamp&&(n.value=t.processingStart-t.startTime,n.entries.push(t),n.isFinal=!0,r())},a=u("first-input",i),r=v(t,n,a);a?p((function(){a.takeRecords().map(i),a.disconnect()}),!0):window.perfMetrics&&window.perfMetrics.onFirstInputDelay&&window.perfMetrics.onFirstInputDelay((function(t,i){i.timeStamp<e.timeStamp&&(n.value=t,n.isFinal=!0,n.entries=[{entryType:"first-input",name:i.type,target:i.target,cancelable:i.cancelable,startTime:i.timeStamp,processingStart:i.timeStamp+t}],r())}))},S=function(){return a||(a=new Promise((function(t){return["scroll","keydown","pointerdown"].map((function(n){addEventListener(n,t,{once:!0,passive:!0,capture:!0})}))}))),a},y=function(t){var n,e=arguments.length>1&&void 0!==arguments[1]&&arguments[1],i=o("LCP"),a=m(),r=function(t){var e=t.startTime;e<a.timeStamp?(i.value=e,i.entries.push(t)):i.isFinal=!0,n()},s=u("largest-contentful-paint",r);if(s){n=v(t,i,s,e);var c=function(){i.isFinal||(s.takeRecords().map(r),i.isFinal=!0,n())};S().then(c),p(c,!0)}},F=function(t){var n,e=o("TTFB");n=function(){try{var n=performance.getEntriesByType("navigation")[0]||function(){var t=performance.timing,n={entryType:"navigation",startTime:0};for(var e in t)"navigationStart"!==e&&"toJSON"!==e&&(n[e]=Math.max(t[e]-t.navigationStart,0));return n}();e.value=e.delta=n.responseStart,e.entries=[n],e.isFinal=!0,t(e)}catch(t){}},"complete"===document.readyState?setTimeout(n,0):addEventListener("pageshow",n)}}}]);

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
!function(e){function r(r){for(var n,i,a=r[0],c=r[1],l=r[2],s=0,p=[];s<a.length;s++)i=a[s],Object.prototype.hasOwnProperty.call(o,i)&&o[i]&&p.push(o[i][0]),o[i]=0;for(n in c)Object.prototype.hasOwnProperty.call(c,n)&&(e[n]=c[n]);for(f&&f(r);p.length;)p.shift()();return u.push.apply(u,l||[]),t()}function t(){for(var e,r=0;r<u.length;r++){for(var t=u[r],n=!0,a=1;a<t.length;a++){var c=t[a];0!==o[c]&&(n=!1)}n&&(u.splice(r--,1),e=i(i.s=t[0]))}return e}var n={},o={1:0},u=[];function i(r){if(n[r])return n[r].exports;var t=n[r]={i:r,l:!1,exports:{}};return e[r].call(t.exports,t,t.exports,i),t.l=!0,t.exports}i.e=function(e){var r=[],t=o[e];if(0!==t)if(t)r.push(t[2]);else{var n=new Promise((function(r,n){t=o[e]=[r,n]}));r.push(t[2]=n);var u,a=document.createElement("script");a.charset="utf-8",a.timeout=120,i.nc&&a.setAttribute("nonce",i.nc),a.src=function(e){return i.p+"static/js/"+({}[e]||e)+"."+{3:"d52da3ae"}[e]+".chunk.js"}(e);var c=new Error;u=function(r){a.onerror=a.onload=null,clearTimeout(l);var t=o[e];if(0!==t){if(t){var n=r&&("load"===r.type?"missing":r.type),u=r&&r.target&&r.target.src;c.message="Loading chunk "+e+" failed.\n("+n+": "+u+")",c.name="ChunkLoadError",c.type=n,c.request=u,t[1](c)}o[e]=void 0}};var l=setTimeout((function(){u({type:"timeout",target:a})}),12e4);a.onerror=a.onload=u,document.head.appendChild(a)}return Promise.all(r)},i.m=e,i.c=n,i.d=function(e,r,t){i.o(e,r)||Object.defineProperty(e,r,{enumerable:!0,get:t})},i.r=function(e){"undefined"!==typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},i.t=function(e,r){if(1&r&&(e=i(e)),8&r)return e;if(4&r&&"object"===typeof e&&e&&e.__esModule)return e;var t=Object.create(null);if(i.r(t),Object.defineProperty(t,"default",{enumerable:!0,value:e}),2&r&&"string"!=typeof e)for(var n in e)i.d(t,n,function(r){return e[r]}.bind(null,n));return t},i.n=function(e){var r=e&&e.__esModule?function(){return e.default}:function(){return e};return i.d(r,"a",r),r},i.o=function(e,r){return Object.prototype.hasOwnProperty.call(e,r)},i.p="./",i.oe=function(e){throw console.error(e),e};var a=this.webpackJsonpvmui=this.webpackJsonpvmui||[],c=a.push.bind(a);a.push=r,a=a.slice();for(var l=0;l<a.length;l++)r(a[l]);var f=c;t()}([]);

107
app/vmui/.gitignore vendored Normal file
View file

@ -0,0 +1,107 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# TypeScript v1 declaration files
typings/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
.env.test
# parcel-bundler cache (https://parceljs.org/)
.cache
# Next.js build output
.next
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and *not* Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# WebStorm etc
.idea/

26
app/vmui/Makefile Normal file
View file

@ -0,0 +1,26 @@
# All these commands must run from repository root.
vmui-package-base-image:
(docker image ls --format '{{.Repository}}:{{.Tag}}' | grep -q vmui-builder-image) \
|| docker build -t vmui-builder-image -f app/vmui/packages/vmui/Docker-build ./app/vmui
vmui-build: vmui-package-base-image
docker run --rm \
--user $(shell id -u):$(shell id -g) \
--mount type=bind,src="$(shell pwd)/app/vmui",dst=/build \
-w /build/packages/vmui \
--entrypoint=/bin/bash \
vmui-builder-image -c "npm install && npm run build"
vmui-release: vmui-build
docker build -t ${DOCKER_NAMESPACE}/vmui:latest -f app/vmui/packages/vmui/Dockerfile-web ./app/vmui/packages/vmui
docker tag ${DOCKER_NAMESPACE}/vmui:latest ${DOCKER_NAMESPACE}/vmui:${PKG_TAG}
vmui-publish-latest: vmui-release
docker push ${DOCKER_NAMESPACE}/vmui
vmui-publish-release: vmui-release
docker push ${DOCKER_NAMESPACE}/vmui:${PKG_TAG}
vmui-update: vmui-build
rm -rf app/vmselect/vmui/* && mv app/vmui/packages/vmui/build/* app/vmselect/vmui

68
app/vmui/README.md Normal file
View file

@ -0,0 +1,68 @@
# vmui
Web UI for VictoriaMetrics
Features:
- configurable Server URL
- configurable time range - every variant have own resolution to show around 30 data points
- query editor has basic highlighting and can be multi-line
- chart is responsive by width
- color assignment for series is automatic
- legend with reduced naming
- tooltips for closest data point
- auto-refresh mode with several time interval presets
- table and raw JSON Query viewer
## Docker image build
Run the following command from the root of VictoriaMetrics repository in order to build `victoriametrics/vmui` Docker image:
```
make vmui-release
```
Then run the built image with:
```
docker run --rm --name vmui -p 8080:8080 victoriametrics/vmui
```
Then naviate to `http://localhost:8080` in order to see the web UI.
## Static build
Run the following command from the root of VictoriaMetrics repository for building `vmui` static contents:
```
make vmui-build
```
The built static contents is put into `app/vmui/packages/vmui/` directory.
## Updating vmui embedded into VictoriaMetrics
Run the following command from the root of VictoriaMetrics repository for updating `vmui` embedded into VictoriaMetrics:
```
make vmui-update
```
This command should update `vmui` static files at `app/vmselect/vmui` directory. Commit changes to these files if needed.
Then build VictoriaMetrics with the following command:
```
make victoria-metrics
```
Then run the built binary with the following command:
```
bin/victoria-metrics -selfScrapeInterval=5s
```
Then navigate to `http://localhost:8428/vmui/`

View file

@ -0,0 +1,11 @@
# Items that don't need to be in a Docker image.
# Anything not used by the build system should go here.
Dockerfile
.dockerignore
.gitignore
README.md
# Artifacts that will be built during image creation.
# This should contain all files created during `npm run build`.
#build
node_modules

View file

@ -0,0 +1,58 @@
module.exports = {
"env": {
"browser": true,
"es2021": true
},
"extends": [
"eslint:recommended",
"plugin:react/recommended",
"plugin:@typescript-eslint/recommended"
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaFeatures": {
"jsx": true
},
"ecmaVersion": 12,
"sourceType": "module"
},
"plugins": [
"react",
"@typescript-eslint"
],
"rules": {
"indent": [
"error",
2,
{ "SwitchCase": 1 }
],
"linebreak-style": [
"error",
"unix"
],
"quotes": [
"error",
"double"
],
"semi": [
"error",
"always"
],
"react/prop-types": 0,
"max-lines": [
"error",
{"max": 150}
]
},
"settings": {
"react": {
"pragma": "React", // Pragma to use, default to "React"
"version": "detect"
},
"linkComponents": [
// Components used as alternatives to <a> for linking, eg. <Link to={ url } />
"Hyperlink",
{"name": "Link", "linkAttribute": "to"}
]
}
};

23
app/vmui/packages/vmui/.gitignore vendored Normal file
View file

@ -0,0 +1,23 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*

View file

@ -0,0 +1,6 @@
FROM node:14-alpine3.12 as build-stage
RUN apk update && apk add --no-cache bash bash-doc bash-completion libtool autoconf automake nasm pkgconfig libpng gcc make g++ zlib-dev gawk
RUN mkdir -p /app
WORKDIR /app

View file

@ -0,0 +1,19 @@
FROM node:14-alpine3.12 as build-stage
RUN apk update && apk add --no-cache bash bash-doc bash-completion libtool autoconf automake nasm pkgconfig libpng gcc make g++ zlib-dev gawk
RUN mkdir -p /app
WORKDIR /app
COPY ./package.json /app/package.json
COPY ./package-lock.json /app/package-lock.json
RUN cd /app && npm install
COPY . /app
RUN npm run build
FROM nginx:latest as production-stage
COPY --from=build-stage /app/build /usr/share/nginx/html
COPY ./nginx/nginx.conf /etc/nginx/nginx.conf
COPY ./nginx/default /etc/nginx/sites-enabled/default
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View file

@ -0,0 +1,18 @@
FROM golang:1.16.2 as build-web-stage
COPY build /build
WORKDIR /build
COPY web/ /build/
RUN GOOS=linux GOARCH=amd64 GO111MODULE=on CGO_ENABLED=0 go build -o web-amd64 github.com/VictoriMetrics/vmui/ && \
GOOS=windows GOARCH=amd64 GO111MODULE=on CGO_ENABLED=0 go build -o web-windows github.com/VictoriMetrics/vmui/
FROM alpine:3.13.2
USER root
COPY --from=build-web-stage /build/web-amd64 /app/web
COPY --from=build-web-stage /build/web-windows /app/web-windows
RUN adduser -S -D -u 1000 web && chown -R web /app
USER web
EXPOSE 8080
ENTRYPOINT ["/app/web"]

View file

@ -0,0 +1,48 @@
# Getting Started with Create React App
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
## Available Scripts
In the project directory, you can run:
### `npm start`
Runs the app in the development mode.\
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
The page will reload if you make edits.\
You will also see any lint errors in the console.
### `npm test`
Launches the test runner in the interactive watch mode.\
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
### `npm run build`
Builds the app for production to the `build` folder.\
It correctly bundles React in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.\
Your app is ready to be deployed!
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
### `npm run eject`
**Note: this is a one-way operation. Once you `eject`, you cant go back!**
If you arent satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point youre on your own.
You dont have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldnt feel obligated to use this feature. However we understand that this tool wouldnt be useful if you couldnt customize it when you are ready for it.
**Note:** this [Dockerfile](https://github.com/VictoriaMetrics/vmui/blob/master/packages/vmui/Dockerfile) use static built files from [npm run build](https://github.com/VictoriaMetrics/vmui/tree/master/packages/vmui#npm-run-eject) .
## Learn More
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
To learn React, check out the [React documentation](https://reactjs.org/).

View file

@ -0,0 +1,48 @@
server {
listen 80;
root /var/www/html;
index index.html;
add_header X-XSS-Protection "1; mode=block";
add_header X-Content-Type-Options "nosniff";
add_header X-Frame-Options "sameorigin";
location ~ /\.(?!well-known).* {
deny all;
access_log off;
log_not_found off;
}
location ~* \.(jpg|jpeg|gif|png|ico|cur|gz|svg|svgz|mp4|ogg|ogv|otf|webm|htc|ttf|woff|woff2)$ {
expires 0;
access_log off;
add_header Pragma public;
add_header Cache-Control "public, max-age=604800"; #one week
add_header X-Asset "yes";
}
location = /favicon.ico {
log_not_found off;
access_log off;
}
location ~ \.(html|gz)$ {
expires 0;
add_header Pragma "public";
add_header Cache-Control "max-age=600, public, must-revalidate, proxy-revalidate";
}
location = /robots.txt {
allow all;
log_not_found off;
access_log off;
}
error_log /dev/stdout warn;
access_log /dev/stdout extended_json;
# access_log /var/log/nginx/vmui-access.log;
# error_log /var/log/nginx/vmui-error.log;
}

View file

@ -0,0 +1,105 @@
user www-data;
worker_processes auto;
pid /run/nginx.pid;
events {
worker_connections 1024;
multi_accept on;
use epoll;
}
http {
##
# Basic Settings
##
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 75 20;
types_hash_max_size 2048;
server_tokens off;
# server_names_hash_bucket_size 64;
# server_name_in_redirect off;
include /etc/nginx/mime.types;
default_type application/octet-stream;
##
# SSL Settings
##
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;
##
# Logging Settings
##
log_format extended_json escape=json
'{'
'"event_datetime": "$time_iso8601", '
'"server_name": "$server_name", '
'"remote_addr": "$remote_addr", '
'"remote_user": "$remote_user", '
'"http_x_real_ip": "$http_x_real_ip", '
'"status": "$status", '
'"scheme": "$scheme", '
'"request_method": "$request_method", '
'"request_uri": "$request_uri", '
'"server_protocol": "$server_protocol", '
'"body_bytes_sent": $body_bytes_sent, '
'"http_referer": "$http_referer", '
'"http_user_agent": "$http_user_agent", '
'"request_bytes": "$request_length", '
'"request_time": "$request_time", '
'"upstream_addr": "$upstream_addr", '
'"upstream_response_time": "$upstream_response_time", '
'"hostname": "$hostname", '
'"host": "$host"'
'}';
error_log /dev/stdout warn;
access_log /dev/stdout extended_json;
##
# Gzip Settings
##
gzip on;
gzip_min_length 1024;
gzip_vary on;
gzip_static on;
gzip_proxied any;
gzip_proxied expired no-cache no-store private auth;
gzip_types application/atom+xml application/geo+json application/javascript application/x-javascript application/json application/ld+json application/manifest+json application/rdf+xml application/rss+xml application/vnd.ms-fontobject application/wasm application/x-web-app-manifest+json application/xhtml+xml application/xml application/font-woff2 application/x-font-woff application/font-woff application/x-font-ttf font/eot font/otf font/ttf image/bmp image/svg+xml text/cache-manifest text/calendar text/markdown text/plain text/xml text/vcard text/vnd.rim.location.xloc text/vtt text/x-component text/x-cross-domain-policy;
gzip_comp_level 6;
gzip_disable "MSIE [1-6]\.(?!.*SV1)";
server_names_hash_max_size 8192;
#ignore_invalid_headers on;
server_name_in_redirect off;
#proxy_buffer_size 8k;
#proxy_buffers 8 64k;
#proxy_connect_timeout 1000;
#proxy_read_timeout 12000;
#proxy_send_timeout 12000;
#proxy_cache_path /var/cache/nginx levels=2 keys_zone=pagecache:5m inactive=10m max_size=50m;
#real_ip_header X-Real-IP;
#proxy_set_header Host $host;
#proxy_set_header X-Real-IP $remote_addr;
#proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
#allow all;
include /etc/nginx/conf.d/*.conf;
include /etc/nginx/sites-enabled/*;
client_max_body_size 20M;
}

16795
app/vmui/packages/vmui/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,66 @@
{
"name": "vmui",
"version": "0.1.0",
"private": true,
"homepage": "./",
"dependencies": {
"@codemirror/next": "~0.13.1",
"@date-io/dayjs": "^1.3.13",
"@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.11.2",
"@material-ui/lab": "^4.0.0-alpha.56",
"@material-ui/pickers": "^3.3.10",
"@testing-library/jest-dom": "^5.11.6",
"@testing-library/react": "^11.1.2",
"@testing-library/user-event": "^12.2.2",
"@types/d3": "^6.1.0",
"@types/jest": "^26.0.15",
"@types/node": "^12.19.4",
"@types/qs": "^6.9.6",
"@types/react": "^16.9.56",
"@types/react-dom": "^16.9.9",
"@types/react-measure": "^2.0.6",
"codemirror-promql": "^0.10.2",
"d3": "^6.2.0",
"dayjs": "^1.10.4",
"qs": "^6.5.2",
"react": "^17.0.1",
"react-dom": "^17.0.1",
"react-measure": "^2.5.2",
"react-scripts": "4.0.0",
"typescript": "~4.0.5",
"web-vitals": "^0.2.4"
},
"scripts": {
"start": "react-scripts start",
"build": "GENERATE_SOURCEMAP=false react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject",
"lint": "eslint src --ext tsx,ts",
"lint:fix": "eslint src --ext tsx,ts --fix"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"@typescript-eslint/eslint-plugin": "^4.14.2",
"@typescript-eslint/parser": "^4.14.2",
"eslint": "^7.14.0",
"eslint-plugin-react": "^7.22.0"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

View file

@ -0,0 +1,45 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="VM-UI is a metric explorer for Victoria Metrics"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="%PUBLIC_URL%/favicon-32x32.png">
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>VM UI</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" />
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

View file

@ -0,0 +1,20 @@
{
"short_name": "Victoria Metrics UI",
"name": "Victoria Metrics UI is a metric explorer for Victoria Metrics",
"icons": [
{
"src": "favicon-32x32.png",
"sizes": "32x32",
"type": "image/png"
},
{
"src": "apple-touch-icon.png",
"type": "image/png",
"sizes": "192x192"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

View file

@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

View file

@ -0,0 +1,9 @@
import React from "react";
import {render, screen} from "@testing-library/react";
import App from "./App";
test("renders header", () => {
render(<App />);
const headerElement = screen.getByText(/VMUI/i);
expect(headerElement).toBeInTheDocument();
});

View file

@ -0,0 +1,40 @@
import React, {FC} from "react";
import {SnackbarProvider} from "./contexts/Snackbar";
import HomeLayout from "./components/Home/HomeLayout";
import {StateProvider} from "./state/common/StateContext";
import {AuthStateProvider} from "./state/auth/AuthStateContext";
import {createMuiTheme, MuiThemeProvider} from "@material-ui/core";
import CssBaseline from "@material-ui/core/CssBaseline";
import {MuiPickersUtilsProvider} from "@material-ui/pickers";
// pick a date util library
import DayJsUtils from "@date-io/dayjs";
const App: FC = () => {
const THEME = createMuiTheme({
typography: {
"fontSize": 10
}
});
return (
<>
<CssBaseline /> {/* CSS Baseline: kind of normalize.css made by materialUI team - can be scoped */}
<MuiPickersUtilsProvider utils={DayJsUtils}> {/* Allows datepicker to work with DayJS */}
<MuiThemeProvider theme={THEME}> {/* Material UI theme customization */}
<StateProvider> {/* Serialized into query string, common app settings */}
<AuthStateProvider> {/* Auth related info - optionally persisted to Local Storage */}
<SnackbarProvider> {/* Display various snackbars */}
<HomeLayout/>
</SnackbarProvider>
</AuthStateProvider>
</StateProvider>
</MuiThemeProvider>
</MuiPickersUtilsProvider>
</>
);
};
export default App;

View file

@ -0,0 +1,7 @@
import {TimeParams} from "../types";
export const getQueryRangeUrl = (server: string, query: string, period: TimeParams): string =>
`${server}/api/v1/query_range?query=${encodeURIComponent(query)}&start=${period.start}&end=${period.end}&step=${period.step}`;
export const getQueryUrl = (server: string, query: string, period: TimeParams): string =>
`${server}/api/v1/query?query=${encodeURIComponent(query)}&start=${period.start}&end=${period.end}&step=${period.step}`;

View file

@ -0,0 +1,22 @@
export interface MetricBase {
metric: {
[key: string]: string;
};
}
export interface MetricResult extends MetricBase {
values: [number, string][]
}
export interface InstantMetricResult extends MetricBase {
value: [number, string]
}
export interface QueryRangeResponse {
status: string;
data: {
result: MetricResult[];
resultType: "matrix";
}
}

View file

@ -0,0 +1,217 @@
/* eslint max-lines: ["error", {"max": 300}] */
import React, {useState} from "react";
import DialogTitle from "@material-ui/core/DialogTitle";
import Dialog from "@material-ui/core/Dialog";
import {
Box,
Button,
Checkbox,
createStyles,
DialogActions,
DialogContent,
DialogContentText,
FormControl,
FormControlLabel,
FormHelperText,
Input,
InputAdornment,
InputLabel,
Tab,
Tabs,
TextField,
Typography
} from "@material-ui/core";
import TabPanel from "./AuthTabPanel";
import PersonIcon from "@material-ui/icons/Person";
import LockIcon from "@material-ui/icons/Lock";
import {makeStyles} from "@material-ui/core/styles";
import {useAuthDispatch, useAuthState} from "../../../state/auth/AuthStateContext";
import {AUTH_METHOD, WithCheckbox} from "../../../state/auth/reducer";
// TODO: make generic when creating second dialog
export interface DialogProps {
open: boolean;
onClose: () => void;
}
export interface AuthTab {
title: string;
id: AUTH_METHOD;
}
const useStyles = makeStyles(() =>
createStyles({
tabsContent: {
height: "200px"
},
}),
);
const BEARER_PREFIX = "Bearer ";
const tabs: AuthTab[] = [
{title: "No auth", id: "NO_AUTH"},
{title: "Basic Auth", id: "BASIC_AUTH"},
{title: "Bearer Token", id: "BEARER_AUTH"}
];
export const AuthDialog: React.FC<DialogProps> = (props) => {
const classes = useStyles();
const {onClose, open} = props;
const {saveAuthLocally, basicData, bearerData, authMethod} = useAuthState();
const dispatch = useAuthDispatch();
const [authCheckbox, setAuthCheckbox] = useState(saveAuthLocally);
const [basicValue, setBasicValue] = useState(basicData || {password: "", login: ""});
const [bearerValue, setBearerValue] = useState(bearerData?.token || BEARER_PREFIX);
const [tabIndex, setTabIndex] = useState(tabs.findIndex(el => el.id === authMethod) || 0);
const handleChange = (event: unknown, newValue: number) => {
setTabIndex(newValue);
};
const handleBearerChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const newVal = event.target.value;
if (newVal.startsWith(BEARER_PREFIX)) {
setBearerValue(newVal);
} else {
setBearerValue(BEARER_PREFIX);
}
};
const handleClose = () => {
onClose();
};
const onBearerPaste = (e: React.ClipboardEvent) => {
// if you're pasting token word Bearer will be added automagically
const newVal = e.clipboardData.getData("text/plain");
if (newVal.startsWith(BEARER_PREFIX)) {
setBearerValue(newVal);
} else {
setBearerValue(BEARER_PREFIX + newVal);
}
e.preventDefault();
};
const handleApply = () => {
// TODO: handle validation/required fields
switch (tabIndex) {
case 0:
dispatch({type: "SET_NO_AUTH", payload: {checkbox: authCheckbox} as WithCheckbox});
break;
case 1:
dispatch({type: "SET_BASIC_AUTH", payload: { checkbox: authCheckbox, value: basicValue}});
break;
case 2:
dispatch({type: "SET_BEARER_AUTH", payload: {checkbox: authCheckbox, value: {token: bearerValue}}});
break;
}
handleClose();
};
return (
<Dialog onClose={handleClose} aria-labelledby="simple-dialog-title" open={open}>
<DialogTitle id="simple-dialog-title">Request Auth Settings</DialogTitle>
<DialogContent>
<DialogContentText>
This affects Authorization header sent to the server you specify. Not shown in URL and can be optionally stored on a client side
</DialogContentText>
<Tabs
value={tabIndex}
onChange={handleChange}
indicatorColor="primary"
textColor="primary"
>
{
tabs.map(t => <Tab key={t.id} label={t.title} />)
}
</Tabs>
<Box p={0} display="flex" flexDirection="column" className={classes.tabsContent}>
<Box flexGrow={1}>
<TabPanel value={tabIndex} index={0}>
<Typography style={{fontStyle: "italic"}}>
No Authorization Header
</Typography>
</TabPanel>
<TabPanel value={tabIndex} index={1}>
<FormControl margin="dense" fullWidth={true}>
<InputLabel htmlFor="basic-login">User</InputLabel>
<Input
id="basic-login"
startAdornment={
<InputAdornment position="start">
<PersonIcon />
</InputAdornment>
}
required
onChange={e => setBasicValue(prev => ({...prev, login: e.target.value || ""}))}
value={basicValue?.login || ""}
/>
</FormControl>
<FormControl margin="dense" fullWidth={true}>
<InputLabel htmlFor="basic-pass">Password</InputLabel>
<Input
id="basic-pass"
// type="password" // Basic auth is not super secure in any case :)
startAdornment={
<InputAdornment position="start">
<LockIcon />
</InputAdornment>
}
onChange={e => setBasicValue(prev => ({...prev, password: e.target.value || ""}))}
value={basicValue?.password || ""}
/>
</FormControl>
</TabPanel>
<TabPanel value={tabIndex} index={2}>
<TextField
id="bearer-auth"
label="Bearer token"
multiline
fullWidth={true}
value={bearerValue}
onChange={handleBearerChange}
InputProps={{
onPaste: onBearerPaste
}}
rowsMax={6}
/>
</TabPanel>
</Box>
<FormControl>
<FormControlLabel
control={
<Checkbox
checked={authCheckbox}
onChange={() => setAuthCheckbox(prev => !prev)}
name="checkedB"
color="primary"
/>
}
label="Persist Auth Data Locally"
/>
<FormHelperText>
{authCheckbox ? "Auth Data and the Selected method will be saved to LocalStorage" : "Auth Data won't be saved. All previously saved Auth Data will be removed"}
</FormHelperText>
</FormControl>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={handleApply} color="primary">
Apply
</Button>
</DialogActions>
</Dialog>
);
};

View file

@ -0,0 +1,29 @@
import React from "react";
import Box from "@material-ui/core/Box";
interface TabPanelProps {
index: number;
value: number;
}
const AuthTabPanel: React.FC<TabPanelProps> = (props) => {
const { children, value, index, ...other } = props;
return (
<div
role="tabpanel"
hidden={value !== index}
id={`auth-config-tabpanel-${index}`}
aria-labelledby={`auth-config-tab-${index}`}
{...other}
>
{value === index && (
<Box py={2}>
{children}
</Box>
)}
</div>
);
};
export default AuthTabPanel;

View file

@ -0,0 +1,46 @@
import React, {FC} from "react";
import TableChartIcon from "@material-ui/icons/TableChart";
import ShowChartIcon from "@material-ui/icons/ShowChart";
import CodeIcon from "@material-ui/icons/Code";
import {ToggleButton, ToggleButtonGroup} from "@material-ui/lab";
import {useAppDispatch, useAppState} from "../../../state/common/StateContext";
import {withStyles} from "@material-ui/core";
export type DisplayType = "table" | "chart" | "code";
const StylizedToggleButton = withStyles({
root: {
padding: 6,
color: "white",
"&.Mui-selected": {
color: "white"
}
}
})(ToggleButton);
export const DisplayTypeSwitch: FC = () => {
const {displayType} = useAppState();
const dispatch = useAppDispatch();
return <ToggleButtonGroup
value={displayType}
exclusive
onChange={
(e, val) =>
// Toggle Button Group returns null in case of click on selected element, avoiding it
dispatch({type: "SET_DISPLAY_TYPE", payload: val ?? displayType})
}>
<StylizedToggleButton value="chart" aria-label="display as chart">
<ShowChartIcon/>&nbsp;Query Range as Chart
</StylizedToggleButton>
<StylizedToggleButton value="code" aria-label="display as code">
<CodeIcon/>&nbsp;Instant Query as JSON
</StylizedToggleButton>
<StylizedToggleButton value="table" aria-label="display as table">
<TableChartIcon/>&nbsp;Instant Query as Table
</StylizedToggleButton>
</ToggleButtonGroup>;
};

View file

@ -0,0 +1,92 @@
import React, {FC, useEffect, useState} from "react";
import {Box, FormControlLabel, IconButton, Switch, Tooltip} from "@material-ui/core";
import PlayCircleOutlineIcon from "@material-ui/icons/PlayCircleOutline";
import EqualizerIcon from "@material-ui/icons/Equalizer";
import {useAppDispatch, useAppState} from "../../../state/common/StateContext";
import CircularProgressWithLabel from "../../common/CircularProgressWithLabel";
import {makeStyles} from "@material-ui/core/styles";
const useStyles = makeStyles({
colorizing: {
color: "white"
}
});
export const ExecutionControls: FC = () => {
const classes = useStyles();
const dispatch = useAppDispatch();
const {queryControls: {autoRefresh}} = useAppState();
const [delay, setDelay] = useState<(1|2|5)>(5);
const [lastUpdate, setLastUpdate] = useState<number|undefined>();
const [progress, setProgress] = React.useState(100);
const handleChange = () => {
dispatch({type: "TOGGLE_AUTOREFRESH"});
};
useEffect(() => {
let timer: number;
if (autoRefresh) {
setLastUpdate(new Date().valueOf());
timer = setInterval(() => {
setLastUpdate(new Date().valueOf());
dispatch({type: "RUN_QUERY_TO_NOW"});
}, delay * 1000) as unknown as number;
}
return () => {
timer && clearInterval(timer);
};
}, [delay, autoRefresh]);
useEffect(() => {
const timer = setInterval(() => {
if (autoRefresh && lastUpdate) {
const delta = (new Date().valueOf() - lastUpdate) / 1000; //s
const nextValue = Math.floor(delta / delay * 100);
setProgress(nextValue);
}
}, 16);
return () => {
clearInterval(timer);
};
}, [autoRefresh, lastUpdate, delay]);
const iterateDelays = () => {
setDelay(prev => {
switch (prev) {
case 1:
return 2;
case 2:
return 5;
case 5:
return 1;
default:
return 5;
}
});
};
return <Box display="flex" alignItems="center">
<Box mr={2}>
<Tooltip title="Execute Query">
<IconButton onClick={()=>dispatch({type: "RUN_QUERY"})}>
<PlayCircleOutlineIcon className={classes.colorizing} fontSize="large"/>
</IconButton>
</Tooltip>
</Box>
{<FormControlLabel
control={<Switch size="small" className={classes.colorizing} checked={autoRefresh} onChange={handleChange} />}
label="Auto-refresh"
/>}
{autoRefresh && <>
<CircularProgressWithLabel className={classes.colorizing} label={delay} value={progress} onClick={() => {iterateDelays();}} />
<Box ml={1}>
<IconButton onClick={() => {iterateDelays();}}><EqualizerIcon style={{color: "white"}} /></IconButton>
</Box>
</>}
</Box>;
};

View file

@ -0,0 +1,82 @@
import React, {FC, useState} from "react";
import {
Accordion,
AccordionDetails,
AccordionSummary,
Box,
Grid,
IconButton,
TextField,
Typography
} from "@material-ui/core";
import QueryEditor from "./QueryEditor";
import {TimeSelector} from "./TimeSelector";
import {useAppDispatch, useAppState} from "../../../state/common/StateContext";
import ExpandMoreIcon from "@material-ui/icons/ExpandMore";
import SecurityIcon from "@material-ui/icons/Security";
import {AuthDialog} from "./AuthDialog";
const QueryConfigurator: FC = () => {
const {serverUrl, query, time: {duration}} = useAppState();
const dispatch = useAppDispatch();
const [dialogOpen, setDialogOpen] = useState(false);
const [expanded, setExpanded] = useState(true);
return (
<>
<Accordion expanded={expanded} onChange={() => setExpanded(prev => !prev)}>
<AccordionSummary
expandIcon={<ExpandMoreIcon/>}
aria-controls="panel1a-content"
id="panel1a-header"
>
<Box mr={2}>
<Typography variant="h6" component="h2">Query Configuration</Typography>
</Box>
{!expanded && <Box flexGrow={1} onClick={e => e.stopPropagation()} onFocusCapture={e => e.stopPropagation()}>
<QueryEditor server={serverUrl} query={query} oneLiner setQuery={(query) => dispatch({type: "SET_QUERY", payload: query})}/>
</Box>}
</AccordionSummary>
<AccordionDetails>
<Grid container spacing={2}>
<Grid item xs={12} md={6}>
<Box>
<Box py={2} display="flex">
<TextField variant="outlined" fullWidth label="Server URL" value={serverUrl}
inputProps={{
style: {fontFamily: "Monospace"}
}}
onChange={(e) => dispatch({type: "SET_SERVER", payload: e.target.value})}/>
<Box pl={.5} flexGrow={0}>
<IconButton onClick={() => setDialogOpen(true)}>
<SecurityIcon/>
</IconButton>
</Box>
</Box>
<QueryEditor server={serverUrl} query={query} setQuery={(query) => dispatch({type: "SET_QUERY", payload: query})}/>
</Box>
</Grid>
<Grid item xs={12} md={6}>
<Box style={{
borderRadius: "4px",
borderColor: "#b9b9b9",
borderStyle: "solid",
borderWidth: "1px",
height: "calc(100% - 18px)",
marginTop: "16px"
}}>
<TimeSelector setDuration={(dur) => dispatch({type: "SET_DURATION", payload: dur})} duration={duration}/>
</Box>
</Grid>
</Grid>
</AccordionDetails>
</Accordion>
<AuthDialog open={dialogOpen} onClose={() => setDialogOpen(false)}/>
</>
);
};
export default QueryConfigurator;

View file

@ -0,0 +1,62 @@
import {EditorState} from "@codemirror/next/state";
import {EditorView, keymap} from "@codemirror/next/view";
import {defaultKeymap} from "@codemirror/next/commands";
import React, {FC, useEffect, useRef, useState} from "react";
import { PromQLExtension } from "codemirror-promql";
import { basicSetup } from "@codemirror/next/basic-setup";
export interface QueryEditorProps {
setQuery: (query: string) => void;
query: string;
server: string;
oneLiner?: boolean;
}
const QueryEditor: FC<QueryEditorProps> = ({query, setQuery, server, oneLiner = false}) => {
const ref = useRef<HTMLDivElement>(null);
const [editorView, setEditorView] = useState<EditorView>();
// init editor view on load
useEffect(() => {
if (ref.current) {
setEditorView(new EditorView(
{
parent: ref.current
})
);
}
return () => editorView?.destroy();
}, []);
// update state on change of autocomplete server
useEffect(() => {
const promQL = new PromQLExtension().setComplete({url: server});
const listenerExtension = EditorView.updateListener.of(editorUpdate => {
if (editorUpdate.docChanged) {
setQuery(
editorUpdate.state.doc.toJSON().map(el => el.trim()).join("")
);
}
});
editorView?.setState(EditorState.create({
doc: query,
extensions: [basicSetup, keymap(defaultKeymap), listenerExtension, promQL.asExtension()]
}));
}, [server, editorView]);
return (
<>
{/*Class one-line-scroll and other codemirror stylings are declared in index.css*/}
<div ref={ref} className={oneLiner ? "one-line-scroll" : undefined}></div>
</>
);
};
export default QueryEditor;

View file

@ -0,0 +1,25 @@
import React, {FC} from "react";
import {Paper, Table, TableBody, TableCell, TableContainer, TableHead, TableRow} from "@material-ui/core";
import {supportedDurations} from "../../../utils/time";
export const TimeDurationPopover: FC = () => {
return <TableContainer component={Paper}>
<Table aria-label="simple table" size="small">
<TableHead>
<TableRow>
<TableCell>Long</TableCell>
<TableCell>Short</TableCell>
</TableRow>
</TableHead>
<TableBody>
{supportedDurations.map((row, index) => (
<TableRow key={index}>
<TableCell component="th" scope="row">{row.long}</TableCell>
<TableCell>{row.short}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>;
};

View file

@ -0,0 +1,120 @@
import React, {FC, useEffect, useState} from "react";
import {Box, Popover, TextField, Typography} from "@material-ui/core";
import { KeyboardDateTimePicker } from "@material-ui/pickers";
import {TimeDurationPopover} from "./TimeDurationPopover";
import {useAppDispatch, useAppState} from "../../../state/common/StateContext";
import {dateFromSeconds, formatDateForNativeInput} from "../../../utils/time";
import {InlineBtn} from "../../common/InlineBtn";
interface TimeSelectorProps {
setDuration: (str: string) => void;
duration: string;
}
export const TimeSelector: FC<TimeSelectorProps> = ({setDuration}) => {
const [durationStringFocused, setFocused] = useState(false);
const [anchorEl, setAnchorEl] = React.useState<Element | null>(null);
const [until, setUntil] = useState<string>();
const {time: {period: {end}, duration}} = useAppState();
const dispatch = useAppDispatch();
const [durationString, setDurationString] = useState<string>(duration);
useEffect(() => {
setDurationString(duration);
}, [duration]);
useEffect(() => {
setUntil(formatDateForNativeInput(dateFromSeconds(end)));
}, [end]);
useEffect(() => {
if (!durationStringFocused) {
setDuration(durationString);
}
}, [durationString, durationStringFocused]);
const handleDurationChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setDurationString(event.target.value);
};
const handlePopoverOpen = (event: React.MouseEvent<Element, MouseEvent>) => {
setAnchorEl(event.currentTarget);
};
const handlePopoverClose = () => {
setAnchorEl(null);
};
const open = Boolean(anchorEl);
return <Box m={1} flexDirection="row" display="flex">
{/*setup duration*/}
<Box px={1}>
<Box>
<TextField label="Duration" value={durationString} onChange={handleDurationChange}
fullWidth={true}
onBlur={() => {
setFocused(false);
}}
onFocus={() => {
setFocused(true);
}}
/>
</Box>
<Box my={2}>
<Typography variant="body2">
Possible options<span aria-owns={open ? "mouse-over-popover" : undefined}
aria-haspopup="true"
style={{cursor: "pointer"}}
onMouseEnter={handlePopoverOpen}
onMouseLeave={handlePopoverClose}><EFBFBD>:&nbsp;</span>
<Popover
open={open}
anchorEl={anchorEl}
anchorOrigin={{
vertical: "bottom",
horizontal: "left",
}}
transformOrigin={{
vertical: "top",
horizontal: "left",
}}
style={{pointerEvents: "none"}} // important
onClose={handlePopoverClose}
disableRestoreFocus
>
<TimeDurationPopover/>
</Popover>
<InlineBtn handler={() => setDurationString("5m")} text="5m"/>,&nbsp;
<InlineBtn handler={() => setDurationString("1h")} text="1h"/>,&nbsp;
<InlineBtn handler={() => setDurationString("1h 30m")} text="1h 30m"/>
</Typography>
</Box>
</Box>
{/*setup end time*/}
<Box px={1}>
<Box>
<KeyboardDateTimePicker
variant="inline"
ampm={false}
label="Until"
value={until}
onChange={date => dispatch({type: "SET_UNTIL", payload: date as unknown as Date})}
onError={console.log}
format="DD/MM/YYYY HH:mm:ss"
/>
</Box>
<Box my={2}>
<Typography variant="body2">
Will be changed to current time for auto-refresh mode.&nbsp;
<InlineBtn handler={() => dispatch({type: "RUN_QUERY_TO_NOW"})} text="Switch to now"/>
</Typography>
</Box>
</Box>
</Box>;
};

View file

@ -0,0 +1,90 @@
import {useEffect, useMemo, useState} from "react";
import {getQueryRangeUrl, getQueryUrl} from "../../../api/query-range";
import {useAppState} from "../../../state/common/StateContext";
import {InstantMetricResult, MetricResult} from "../../../api/types";
import {saveToStorage} from "../../../utils/storage";
import {isValidHttpUrl} from "../../../utils/url";
import {useAuthState} from "../../../state/auth/AuthStateContext";
export const useFetchQuery = (): {
fetchUrl?: string,
isLoading: boolean,
graphData?: MetricResult[],
liveData?: InstantMetricResult[],
error?: string
} => {
const {query, displayType, serverUrl, time: {period}} = useAppState();
const {basicData, bearerData, authMethod} = useAuthState();
const [isLoading, setIsLoading] = useState(false);
const [graphData, setGraphData] = useState<MetricResult[]>();
const [liveData, setLiveData] = useState<InstantMetricResult[]>();
const [error, setError] = useState<string>();
useEffect(() => {
if (error) {
setGraphData(undefined);
setLiveData(undefined);
}
}, [error]);
const fetchUrl = useMemo(() => {
if (period) {
if (!serverUrl) {
setError("Please enter Server URL");
return;
}
if (!query.trim()) {
setError("Please enter a valid Query and execute it");
return;
}
if (isValidHttpUrl(serverUrl)) {
return displayType === "chart"
? getQueryRangeUrl(serverUrl, query, period)
: getQueryUrl(serverUrl, query, period);
} else {
setError("Please provide a valid URL");
}
}
},
[serverUrl, period, displayType]);
// TODO: this should depend on query as well, but need to decide when to do the request.
// Doing it on each query change - looks to be a bad idea. Probably can be done on blur
useEffect(() => {
(async () => {
if (fetchUrl) {
const headers = new Headers();
if (authMethod === "BASIC_AUTH") {
headers.set("Authorization", "Basic " + btoa(`${basicData?.login || ""}:${basicData?.password || ""}`));
}
if (authMethod === "BEARER_AUTH") {
headers.set("Authorization", bearerData?.token || "");
}
setIsLoading(true);
const response = await fetch(fetchUrl, {
headers
});
if (response.ok) {
saveToStorage("PREFERRED_URL", serverUrl);
saveToStorage("LAST_QUERY", query);
const resp = await response.json();
setError(undefined);
displayType === "chart" ? setGraphData(resp.data.result) : setLiveData(resp.data.result);
} else {
setError((await response.json())?.error);
}
setIsLoading(false);
}
})();
}, [fetchUrl, serverUrl, displayType]);
return {
fetchUrl,
isLoading,
graphData,
liveData,
error
};
};

View file

@ -0,0 +1,87 @@
import React, {FC} from "react";
import {AppBar, Box, CircularProgress, Fade, Link, Toolbar, Typography} from "@material-ui/core";
import {ExecutionControls} from "./Configurator/ExecutionControls";
import {DisplayTypeSwitch} from "./Configurator/DisplayTypeSwitch";
import GraphView from "./Views/GraphView";
import TableView from "./Views/TableView";
import {useAppState} from "../../state/common/StateContext";
import QueryConfigurator from "./Configurator/QueryConfigurator";
import {useFetchQuery} from "./Configurator/useFetchQuery";
import JsonView from "./Views/JsonView";
import {UrlCopy} from "./UrlCopy";
import {Alert} from "@material-ui/lab";
const HomeLayout: FC = () => {
const {displayType, time: {period}} = useAppState();
const {fetchUrl, isLoading, liveData, graphData, error} = useFetchQuery();
return (
<>
<AppBar position="static">
<Toolbar>
<Box mr={2} display="flex">
<Typography variant="h5">
<span style={{fontWeight: "bolder"}}>VM</span>
<span style={{fontWeight: "lighter"}}>UI</span>
</Typography>
<div style={{
fontSize: "10px",
marginTop: "-2px"
}}>
<div>BETA</div>
</div>
</Box>
<div style={{
fontSize: "10px",
position: "absolute",
top: "40px",
opacity: ".4"
}}>
<Link color="inherit" href="https://github.com/VictoriaMetrics/vmui/issues/new" target="_blank">
Create an issue
</Link>
</div>
<Box flexGrow={1}>
<ExecutionControls/>
</Box>
<DisplayTypeSwitch/>
<UrlCopy url={fetchUrl}/>
</Toolbar>
</AppBar>
<Box display="flex" flexDirection="column" style={{height: "calc(100vh - 64px)"}}>
<Box m={2}>
<QueryConfigurator/>
</Box>
<Box flexShrink={1} style={{overflowY: "scroll"}}>
{isLoading && <Fade in={isLoading} style={{
transitionDelay: isLoading ? "300ms" : "0ms",
}}>
<Box alignItems="center" flexDirection="column" display="flex"
style={{
width: "100%",
position: "absolute",
height: "150px",
background: "linear-gradient(rgba(255,255,255,.7), rgba(255,255,255,.7), rgba(255,255,255,0))"
}} m={2}>
<CircularProgress/>
</Box>
</Fade>}
{<Box p={2}>
{error &&
<Alert color="error" style={{fontSize: "14px"}}>
{error}
</Alert>}
{graphData && period && (displayType === "chart") &&
<GraphView data={graphData} timePresets={period}></GraphView>}
{liveData && (displayType === "code") && <JsonView data={liveData}/>}
{liveData && (displayType === "table") && <TableView data={liveData}/>}
</Box>}
</Box>
</Box>
</>
);
};
export default HomeLayout;

View file

@ -0,0 +1,27 @@
import React, {FC} from "react";
import {Box, IconButton, Tooltip} from "@material-ui/core";
import FileCopyIcon from "@material-ui/icons/FileCopy";
import {useSnack} from "../../contexts/Snackbar";
interface UrlCopyProps {
url?: string
}
export const UrlCopy: FC<UrlCopyProps> = ({url}) => {
const {showInfoMessage} = useSnack();
return <Box pl={2} py={1} flexShrink={0} display="flex">
<Tooltip title="Copy Query URL">
<IconButton size="small" onClick={(e) => {
if (url) {
navigator.clipboard.writeText(url);
showInfoMessage("Value has been copied");
e.preventDefault(); // needed to avoid snackbar immediate disappearing
}
}}>
<FileCopyIcon style={{color: "white"}}/>
</IconButton>
</Tooltip>
</Box>;
};

View file

@ -0,0 +1,42 @@
import React, {FC} from "react";
import {Box, Button, Grid, Typography} from "@material-ui/core";
import {useSnack} from "../../contexts/Snackbar";
interface UrlLineProps {
url?: string
}
export const UrlLine: FC<UrlLineProps> = ({url}) => {
const {showInfoMessage} = useSnack();
return <Grid item style={{backgroundColor: "#eee", width: "100%"}}>
<Box flexDirection="row" display="flex" justifyContent="space-between" alignItems="center">
<Box pl={2} py={1} display="flex" style={{
flex: 1,
minWidth: 0
}}>
<Typography style={{
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
fontStyle: "italic",
fontSize: "small",
color: "#555"
}}>
Currently showing {url}
</Typography>
</Box>
<Box px={2} py={1} flexShrink={0} display="flex">
<Button size="small" onClick={(e) => {
if (url) {
navigator.clipboard.writeText(url);
showInfoMessage("Value has been copied");
e.preventDefault(); // needed to avoid snackbar immediate disappearing
}
}}>Copy Query Url</Button>
</Box>
</Box>
</Grid>;
};

View file

@ -0,0 +1,128 @@
import React, {FC, useEffect, useMemo, useState} from "react";
import {MetricResult} from "../../../api/types";
import {schemeCategory10, scaleOrdinal, interpolateRainbow, range as d3Range} from "d3";
import {LineChart} from "../../LineChart/LineChart";
import {DataSeries, TimeParams} from "../../../types";
import {getNameForMetric} from "../../../utils/metric";
import {Legend, LegendItem} from "../../Legend/Legend";
import {useSortedCategories} from "../../../hooks/useSortedCategories";
import {InlineBtn} from "../../common/InlineBtn";
export interface GraphViewProps {
data: MetricResult[];
timePresets: TimeParams
}
const preDefinedScale = schemeCategory10;
const initialMaxAmount = 20;
const showingIncrement = 20;
const GraphView: FC<GraphViewProps> = ({data, timePresets}) => {
const [showN, setShowN] = useState(initialMaxAmount);
const series: DataSeries[] = useMemo(() => {
return data?.map(d => ({
metadata: {
name: getNameForMetric(d)
},
metric: d.metric,
// VM metrics are tuples - much simpler to work with objects in chart
values: d.values.map(v => ({
key: v[0],
value: +v[1]
}))
}));
}, [data]);
const showingSeries = useMemo(() => series.slice(0 ,showN), [series, showN]);
const sortedCategories = useSortedCategories(data);
const seriesNames = useMemo(() => showingSeries.map(s => s.metadata.name), [showingSeries]);
// should not change as often as array of series names (for instance between executions of same query) to
// keep related state (like selection of a labels)
const [seriesNamesStable, setSeriesNamesStable] = useState(seriesNames);
useEffect(() => {
// primitive way to check the fact that array contents are identical
if (seriesNamesStable.join(",") !== seriesNames.join(",")) {
setSeriesNamesStable(seriesNames);
}
}, [seriesNames, setSeriesNamesStable, seriesNamesStable]);
const amountOfSeries = useMemo(() => series.length, [series]);
const color = useMemo(() => {
const len = seriesNamesStable.length;
const scheme = len <= preDefinedScale.length
? preDefinedScale
: d3Range(len).map(d => d / len).map(interpolateRainbow); // dynamically generate n colors
return scaleOrdinal<string>()
.domain(seriesNamesStable) // associate series names with colors
.range(scheme);
}, [seriesNamesStable]);
// changes only if names of series are different
const initLabels = useMemo(() => {
return seriesNamesStable.map(name => ({
color: color(name),
seriesName: name,
labelData: showingSeries.find(s => s.metadata.name === name)?.metric, // find is O(n) - can do faster
checked: true // init with checked always
} as LegendItem));
}, [color, seriesNamesStable]);
const [labels, setLabels] = useState(initLabels);
useEffect(() => {
setLabels(initLabels);
}, [initLabels]);
const visibleNames = useMemo(() => labels.filter(l => l.checked).map(l => l.seriesName), [labels]);
const visibleSeries = useMemo(() => showingSeries.filter(s => visibleNames.includes(s.metadata.name)), [showingSeries, visibleNames]);
const onLegendChange = (index: number) => {
setLabels(prevState => {
if (prevState) {
const newState = [...prevState];
newState[index] = {...newState[index], checked: !newState[index].checked};
return newState;
}
return prevState;
});
};
return <>
{(amountOfSeries > 0)
? <>
{amountOfSeries > initialMaxAmount && <div style={{textAlign: "center"}}>
{amountOfSeries > showN
? <span style={{fontStyle: "italic"}}>Showing only first {showN} of {amountOfSeries} series.&nbsp;
{showN + showingIncrement >= amountOfSeries
?
<InlineBtn handler={() => setShowN(amountOfSeries)} text="Show all"/>
:
<>
<InlineBtn handler={() => setShowN(prev => Math.min(prev + showingIncrement, amountOfSeries))} text={`Show ${showingIncrement} more`}/>,&nbsp;
<InlineBtn handler={() => setShowN(amountOfSeries)} text="show all"/>.
</>}
</span>
: <span style={{fontStyle: "italic"}}>Showing all series.&nbsp;
<InlineBtn handler={() => setShowN(initialMaxAmount)} text={`Show only ${initialMaxAmount}`}/>.
</span>}
</div>}
<LineChart height={400} series={visibleSeries} color={color} timePresets={timePresets} categories={sortedCategories}></LineChart>
<Legend labels={labels} onChange={onLegendChange} categories={sortedCategories}></Legend>
</>
: <div style={{textAlign: "center"}}>No data to show</div>}
</>;
};
export default GraphView;

View file

@ -0,0 +1,33 @@
import React, {FC, useMemo} from "react";
import {InstantMetricResult} from "../../../api/types";
import {Box, Button} from "@material-ui/core";
import {useSnack} from "../../../contexts/Snackbar";
export interface JsonViewProps {
data: InstantMetricResult[];
}
const JsonView: FC<JsonViewProps> = ({data}) => {
const {showInfoMessage} = useSnack();
const formattedJson = useMemo(() => JSON.stringify(data, null, 2), [data]);
return (
<Box position="relative">
<Box flexDirection="column" justifyContent="flex-end" display="flex"
style={{
position: "fixed",
right: "16px"
}}>
<Button variant="outlined" onClick={(e) => {
navigator.clipboard.writeText(formattedJson);
showInfoMessage("Formatted JSON has been copied");
e.preventDefault(); // needed to avoid snackbar immediate disappearing
}}>Copy JSON</Button>
</Box>
<pre>{formattedJson}</pre>
</Box>
);
};
export default JsonView;

View file

@ -0,0 +1,65 @@
import React, {FC, useMemo} from "react";
import {InstantMetricResult} from "../../../api/types";
import {InstantDataSeries} from "../../../types";
import {Paper, Table, TableBody, TableCell, TableContainer, TableHead, TableRow} from "@material-ui/core";
import {makeStyles} from "@material-ui/core/styles";
import {useSortedCategories} from "../../../hooks/useSortedCategories";
export interface GraphViewProps {
data: InstantMetricResult[];
}
const useStyles = makeStyles({
deemphasized: {
opacity: 0.4
}
});
const TableView: FC<GraphViewProps> = ({data}) => {
const classes = useStyles();
const sortedColumns = useSortedCategories(data);
const rows: InstantDataSeries[] = useMemo(() => {
return data?.map(d => ({
metadata: sortedColumns.map(c => d.metric[c.key] || "-"),
value: d.value[1]
}));
}, [sortedColumns, data]);
return (
<>
{(rows.length > 0)
? <TableContainer component={Paper}>
<Table aria-label="simple table">
<TableHead>
<TableRow>
{sortedColumns.map((col, index) => (
<TableCell style={{textTransform: "capitalize"}} key={index}>{col.key}</TableCell>))}
<TableCell align="right">Value</TableCell>
</TableRow>
</TableHead>
<TableBody>
{rows.map((row, index) => (
<TableRow key={index}>
{row.metadata.map((rowMeta, index2) => {
const prevRowValue = rows[index - 1] && rows[index - 1].metadata[index2];
return (
<TableCell className={prevRowValue === rowMeta ? classes.deemphasized : undefined}
key={index2}>{rowMeta}</TableCell>
);
}
)}
<TableCell align="right">{row.value}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
: <div style={{textAlign: "center"}}>No data to show</div>}
</>
);
};
export default TableView;

View file

@ -0,0 +1,67 @@
import React, {FC, useMemo} from "react";
import {Checkbox, FormControlLabel, Typography} from "@material-ui/core";
import {MetricCategory} from "../../hooks/useSortedCategories";
import {makeStyles} from "@material-ui/core/styles";
export interface LegendItem {
seriesName: string;
labelData: {[key: string]: string};
color: string;
checked: boolean;
}
export interface LegendProps {
labels: LegendItem[];
categories: MetricCategory[];
onChange: (index: number) => void;
}
const useStyles = makeStyles({
legendWrapper: {
display: "grid",
width: "100%",
gridTemplateColumns: "repeat(auto-fit)", // experiments like repeat(auto-fit, minmax(200px , auto)) may reduce size but readability as well
gridColumnGap: ".5em",
paddingLeft: "8px"
}
});
export const Legend: FC<LegendProps> = ({labels, onChange, categories}) => {
const classes = useStyles();
const commonLabels = useMemo(() => labels.length > 0
? categories
.filter(c => c.variations === 1)
.map(c => `${c.key}: ${labels[0].labelData[c.key]}`)
: [], [categories, labels]);
const uncommonLabels = useMemo(() => categories.filter(c => c.variations !== 1).map(c => c.key), [categories]);
return <div>
<div style={{textAlign: "center"}}>{`Legend for ${commonLabels.join(", ")}`}</div>
<div className={classes.legendWrapper}>
{labels.map((legendItem: LegendItem, index) =>
<div key={legendItem.seriesName}>
<FormControlLabel
control={
<Checkbox
size="small"
checked={legendItem.checked}
onChange={() => {
onChange(index);
}}
style={{
color: legendItem.color,
padding: "4px"
}}
/>
}
label={<Typography variant="body2">{uncommonLabels.map(l => `${l}: ${legendItem.labelData[l]}`).join(", ")}</Typography>}
/>
</div>
)}
</div>
</div>;
};

View file

@ -0,0 +1,18 @@
import React, {useEffect, useRef} from "react";
import {axisBottom, ScaleTime, select as d3Select} from "d3";
interface AxisBottomI {
xScale: ScaleTime<number, number>;
height: number;
}
export const AxisBottom: React.FC<AxisBottomI> = ({xScale, height}) => {
//eslint-disable-next-line @typescript-eslint/no-explicit-any
const ref = useRef<SVGSVGElement | any>(null);
useEffect(() => {
d3Select(ref.current)
.call(axisBottom<Date>(xScale));
}, [xScale]);
return <g ref={ref} className="x axis" transform={`translate(0, ${height})`} />;
};

View file

@ -0,0 +1,39 @@
import React, {useEffect, useRef} from "react";
import {axisLeft, ScaleLinear, select as d3Select} from "d3";
import {format as d3Format} from "d3-format";
interface AxisLeftI {
yScale: ScaleLinear<number, number>;
label: string;
}
const yFormatter = (val: number): string => {
const v = Math.abs(val); // helps to handle negatives the same way
const DECIMAL_THRESHOLD = 0.001;
let format = ".2~s"; // 21K tilde means that it won't be 2.0K but just 2K
if (v > 0 && v < DECIMAL_THRESHOLD) {
format = ".0e"; // 1E-3 for values below DECIMAL_THRESHOLD
}
if (v >= DECIMAL_THRESHOLD && v < 1) {
format = ".3~f"; // just plain 0.932
}
return d3Format(format)(val);
};
export const AxisLeft: React.FC<AxisLeftI> = ({yScale, label}) => {
//eslint-disable-next-line @typescript-eslint/no-explicit-any
const ref = useRef<SVGSVGElement | any>(null);
useEffect(() => {
yScale && d3Select(ref.current).call(axisLeft<number>(yScale).tickFormat(yFormatter));
}, [yScale]);
return (
<>
<g className="y axis" ref={ref} />
{label && (
<text style={{fontSize: "0.6rem"}} transform="translate(0,-2)">
{label}
</text>
)}
</>
);
};

View file

@ -0,0 +1,48 @@
import React from "react";
import {Box, makeStyles, Typography} from "@material-ui/core";
export interface ChartTooltipData {
value: number;
metrics: {
key: string;
value: string;
}[];
color?: string;
}
export interface ChartTooltipProps {
data: ChartTooltipData;
time?: Date;
}
const useStyle = makeStyles(() => ({
wrapper: {
maxWidth: "40vw"
}
}));
export const ChartTooltip: React.FC<ChartTooltipProps> = ({data, time}) => {
const classes = useStyle();
return (
<Box px={1} className={classes.wrapper}>
<Box fontStyle="italic" mb={.5}>
<Typography variant="subtitle1">{`${time?.toLocaleDateString()} ${time?.toLocaleTimeString()}`}</Typography>
</Box>
<Box mb={.5} my={1}>
<Typography variant="subtitle2">{`Value: ${new Intl.NumberFormat(undefined, {
maximumFractionDigits: 10
}).format(data.value)}`}</Typography>
</Box>
<Box>
<Typography variant="body2">
{data.metrics.map(({key, value}) =>
<Box mb={.25} key={key} display="flex" flexDirection="row" alignItems="center">
<span>{key}:&nbsp;</span>
<span style={{fontWeight: "bold"}}>{value}</span>
</Box>)}
</Typography>
</Box>
</Box>
);
};

View file

@ -0,0 +1,124 @@
/* eslint max-lines: ["error", {"max": 200}] */ // Complex D3 logic here - file can be larger
import React, {useEffect, useMemo, useRef, useState} from "react";
import {bisector, brushX, pointer as d3Pointer, ScaleLinear, ScaleTime, select as d3Select} from "d3";
interface LineI {
yScale: ScaleLinear<number, number>;
xScale: ScaleTime<number, number>;
datesInChart: Date[];
setSelection: (from: Date, to: Date) => void;
onInteraction: (index: number | undefined, y: number | undefined) => void; // key is index. undefined means no interaction
}
export const InteractionArea: React.FC<LineI> = ({yScale, xScale, datesInChart, onInteraction, setSelection}) => {
const refBrush = useRef<SVGGElement>(null);
const [currentActivePoint, setCurrentActivePoint] = useState<number>();
const [currentY, setCurrentY] = useState<number>();
const [isBrushed, setIsBrushed] = useState(false);
// eslint-disable-next-line @typescript-eslint/no-explicit-any,@typescript-eslint/explicit-function-return-type
function brushEnded(this: any, event: any) {
const selection = event.selection;
if (selection) {
if (!event.sourceEvent) return; // see comment in brushstarted
setIsBrushed(true);
const [from, to]: [Date, Date] = selection.map((s: number) => xScale.invert(s));
setSelection(from, to);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
d3Select(refBrush.current).call(brush.move as any, null); // clean brush
} else {
// end event with empty selection means that we're cancelling brush
setIsBrushed(false);
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const brushStarted = (event: any): void => {
// first of all: event is a d3 global value that stores current event (sort of).
// This is weird but this is how d3 works with events.
//This check is important:
// Inside brushended - we have .call(brush.move, ...) in order to snap selected range to dates
// that internally calls brushstarted again. But in this case sourceEvent is null, since the call
// is programmatic. If we do not need to adjust selected are - no need to have this check (probably)
if (event.sourceEvent) {
setCurrentActivePoint(undefined);
}
};
const brush = useMemo(
() =>
brushX()
.extent([
[0, 0],
[xScale.range()[1], yScale.range()[0]]
])
.on("end", brushEnded)
.on("start", brushStarted),
[brushEnded, xScale, yScale]
);
// Needed to clean brush if we need to keep it
// const resetBrushHandler = useCallback(
// (e) => {
// const el = e.target as HTMLElement;
// if (
// el &&
// el.tagName !== "rect" &&
// e.target.classList.length &&
// !e.target.classList.contains("selection")
// ) {
// // eslint-disable-next-line @typescript-eslint/no-explicit-any
// d3Select(refBrush.current).call(brush.move as any, null);
// }
// },
// [brush.move]
// );
// useEffect(() => {
// window.addEventListener("click", resetBrushHandler);
// return () => {
// window.removeEventListener("click", resetBrushHandler);
// };
// }, [resetBrushHandler]);
useEffect(() => {
const bisect = bisector((d: Date) => d).center;
const defineActivePoint = (mx: number): void => {
const date = xScale.invert(mx); // date is a Date object
const index = bisect(datesInChart, date, 1);
setCurrentActivePoint(index);
};
d3Select(refBrush.current)
.on("touchmove mousemove", (event) => {
const coords: [number, number] = d3Pointer(event);
if (!isBrushed) {
defineActivePoint(coords[0]);
setCurrentY(coords[1]);
}
})
.on("mouseout", () => {
if (!isBrushed) {
setCurrentActivePoint(undefined);
}
});
}, [xScale, datesInChart, isBrushed]);
useEffect(() => {
onInteraction(currentActivePoint, currentY);
}, [currentActivePoint, currentY, onInteraction]);
useEffect(() => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
brush && xScale && d3Select(refBrush.current).call(brush);
}, [xScale, brush]);
return (
<>
<g ref={refBrush} />
</>
);
};

View file

@ -0,0 +1,10 @@
import React from "react";
interface LineI {
height: number;
x: number | undefined;
}
export const InteractionLine: React.FC<LineI> = ({height, x}) => {
return <>{x && <line x1={x} y1="0" x2={x} y2={height} stroke="black" strokeDasharray="4" />}</>;
};

View file

@ -0,0 +1,196 @@
/* eslint max-lines: ["error", {"max": 300}] */
import React, {useCallback, useMemo, useRef, useState} from "react";
import {line as d3Line, max as d3Max, min as d3Min, scaleLinear, ScaleOrdinal, scaleTime} from "d3";
import "./line-chart.css";
import Measure from "react-measure";
import {AxisBottom} from "./AxisBottom";
import {AxisLeft} from "./AxisLeft";
import {DataSeries, DataValue, TimeParams} from "../../types";
import {InteractionLine} from "./InteractionLine";
import {InteractionArea} from "./InteractionArea";
import {Box, Popover} from "@material-ui/core";
import {ChartTooltip, ChartTooltipData} from "./ChartTooltip";
import {useAppDispatch} from "../../state/common/StateContext";
import {dateFromSeconds} from "../../utils/time";
import {MetricCategory} from "../../hooks/useSortedCategories";
interface LineChartProps {
series: DataSeries[];
timePresets: TimeParams;
height: number;
color: ScaleOrdinal<string, string>; // maps name to color hex code
categories: MetricCategory[];
}
interface TooltipState {
xCoord: number;
date: Date;
index: number;
leftPart: boolean;
activeSeries: number;
}
const TOOLTIP_MARGIN = 20;
export const LineChart: React.FC<LineChartProps> = ({series, timePresets, height, color, categories}) => {
const [screenWidth, setScreenWidth] = useState<number>(window.innerWidth);
const dispatch = useAppDispatch();
const margin = {top: 10, right: 20, bottom: 40, left: 50};
const svgWidth = useMemo(() => screenWidth - margin.left - margin.right, [screenWidth, margin.left, margin.right]);
const svgHeight = useMemo(() => height - margin.top - margin.bottom, [margin.top, margin.bottom]);
const xScale = useMemo(() => scaleTime().domain([timePresets.start,timePresets.end].map(dateFromSeconds)).range([0, svgWidth]), [
svgWidth,
timePresets
]);
const [showTooltip, setShowTooltip] = useState(false);
const [tooltipState, setTooltipState] = useState<TooltipState>();
const yAxisLabel = ""; // TODO: label
const yScale = useMemo(
() => {
const seriesValues = series.reduce((acc: DataValue[], next: DataSeries) => [...acc, ...next.values], []).map(_ => _.value);
const max = d3Max(seriesValues) ?? 1; // || 1 will cause one additional tick if max is 0
const min = d3Min(seriesValues) || 0;
return scaleLinear()
.domain([min > 0 ? 0 : min, max < 0 ? 0 : max]) // input
.range([svgHeight, 0])
.nice();
},
[series, svgHeight]
);
const line = useMemo(
() =>
d3Line<DataValue>()
.x((d) => xScale(dateFromSeconds(d.key)))
.y((d) => yScale(d.value || 0)),
[xScale, yScale]
);
const getDataLine = (series: DataSeries) => line(series.values);
const handleChartInteraction = useCallback(
async (key: number | undefined, y: number | undefined) => {
if (typeof key === "number") {
if (y && series && series[0]) {
// define closest series in chart
const hoveringOverValue = yScale.invert(y);
const closestPoint = series.map(s => s.values[key]?.value).reduce((acc, nextValue, index) => {
const delta = Math.abs(hoveringOverValue - nextValue);
if (delta < acc.delta) {
acc = {delta, index};
}
return acc;
}, {delta: Infinity, index: 0});
const date = dateFromSeconds(series[0].values[key].key);
// popover orientation should be defined based on the scale domain middle, not data, since
// data may not be present for the whole range
const leftPart = date.valueOf() < (xScale.domain()[1].valueOf() + xScale.domain()[0].valueOf()) / 2;
setTooltipState({
date,
xCoord: xScale(date),
index: key,
activeSeries: closestPoint.index,
leftPart
});
setShowTooltip(true);
}
} else {
setShowTooltip(false);
setTooltipState(undefined);
}
},
[xScale, yScale, series]
);
const tooltipData: ChartTooltipData | undefined = useMemo(() => {
if (tooltipState?.activeSeries) {
return {
value: series[tooltipState.activeSeries].values[tooltipState.index].value,
metrics: categories.map(c => ({ key: c.key, value: series[tooltipState.activeSeries].metric[c.key]}))
};
} else {
return undefined;
}
}, [tooltipState, series]);
const tooltipAnchor = useRef<SVGGElement>(null);
const seriesDates = useMemo(() => {
if (series && series[0]) {
return series[0].values.map(v => v.key).map(dateFromSeconds);
} else {
return [];
}
}, [series]);
const setSelection = (from: Date, to: Date) => {
dispatch({type: "SET_PERIOD", payload: {from, to}});
};
return (
<Measure bounds onResize={({bounds}) => bounds && setScreenWidth(bounds?.width)}>
{({measureRef}) => (
<div ref={measureRef} style={{width: "100%"}}>
{tooltipAnchor && tooltipData && (
<Popover
disableScrollLock={true}
style={{pointerEvents: "none"}} // IMPORTANT in order to allow interactions through popover's backdrop
id="chart-tooltip-popover"
open={showTooltip}
anchorEl={tooltipAnchor.current}
anchorOrigin={{
vertical: "top",
horizontal: tooltipState?.leftPart ? TOOLTIP_MARGIN : -TOOLTIP_MARGIN
}}
transformOrigin={{
vertical: "top",
horizontal: tooltipState?.leftPart ? "left" : "right"
}}
disableRestoreFocus>
<Box m={1}>
<ChartTooltip data={tooltipData} time={tooltipState?.date}/>
</Box>
</Popover>
)}
<svg width="100%" height={height}>
<g transform={`translate(${margin.left}, ${margin.top})`}>
<defs>
{/*Clip path helps to clip the line*/}
<clipPath id="clip-line">
{/*Transforming and adding size to clip-path in order to avoid clipping of chart elements*/}
<rect transform={"translate(0, -2)"} width={xScale.range()[1] + 4} height={yScale.range()[0] + 2} />
</clipPath>
</defs>
<AxisBottom xScale={xScale} height={svgHeight} />
<AxisLeft yScale={yScale} label={yAxisLabel} />
{series.map((s, i) =>
<path stroke={color(s.metadata.name)}
key={i} className="line"
style={{opacity: tooltipState?.activeSeries !== undefined ? (i === tooltipState?.activeSeries ? 1 : .2) : 1 }}
d={getDataLine(s) as string}
clipPath="url(#clip-line)"/>)}
<g ref={tooltipAnchor}>
<InteractionLine height={svgHeight} x={tooltipState?.xCoord} />
</g>
{/*NOTE: in SVG last element wins - so since we want mouseover to work in all area this should be last*/}
<InteractionArea
xScale={xScale}
yScale={yScale}
datesInChart={seriesDates}
onInteraction={handleChartInteraction}
setSelection={setSelection}
/>
</g>
</svg>
</div>
)}
</Measure>
);
};

View file

@ -0,0 +1,15 @@
.line {
fill: none;
stroke-width: 2;
}
.overlay {
fill: none;
pointer-events: all;
}
/* Style the dots by assigning a fill and stroke */
.dot {
fill: #621773;
stroke: #fff;
}

View file

@ -0,0 +1,5 @@
export type AggregatedDataSet = {
key: number;
value: aggregatedDataValue;
};
export type aggregatedDataValue = {[key: string]: number};

View file

@ -0,0 +1,26 @@
import CircularProgress, {CircularProgressProps} from "@material-ui/core/CircularProgress";
import {Box} from "@material-ui/core";
import Typography from "@material-ui/core/Typography";
import React, {FC} from "react";
const CircularProgressWithLabel: FC<CircularProgressProps & { label: number }> = (props) => {
return (
<Box position="relative" display="inline-flex">
<CircularProgress variant="determinate" {...props} />
<Box
top={0}
left={0}
bottom={0}
right={0}
position="absolute"
display="flex"
alignItems="center"
justifyContent="center"
>
<Typography variant="caption" component="div">{`${props.label}s`}</Typography>
</Box>
</Box>
);
};
export default CircularProgressWithLabel;

View file

@ -0,0 +1,19 @@
import {makeStyles} from "@material-ui/core/styles";
import React from "react";
import {Link} from "@material-ui/core";
const useStyles = makeStyles({
inlineBtn: {
"&:hover": {
cursor: "pointer"
},
}
});
export const InlineBtn: React.FC<{handler: () => void; text: string}> = ({handler, text}) => {
const classes = useStyles();
return <Link component="span" className={classes.inlineBtn}
onClick={handler}>
{text}
</Link>;
};

View file

@ -0,0 +1,55 @@
import React, {createContext, FC, useContext, useEffect, useState} from "react";
import {Snackbar} from "@material-ui/core";
import {Alert} from "@material-ui/lab";
export interface SnackModel {
message?: string;
color?: string;
open?: boolean;
key?: number;
}
type SnackbarContextType = { showInfoMessage: (value: string) => void };
export const SnackbarContext = createContext<SnackbarContextType>({
showInfoMessage: () => {
// TODO: default value here makes no sense
}
});
export const useSnack = (): SnackbarContextType => useContext(SnackbarContext);
export const SnackbarProvider: FC = ({children}) => {
const [snack, setSnack] = useState<SnackModel>({});
const [open, setOpen] = useState(false);
const [infoMessage, setInfoMessage] = useState<string | undefined>(undefined);
useEffect(() => {
if (infoMessage) {
setSnack({
message: infoMessage,
key: new Date().getTime()
});
setOpen(true);
}
}, [infoMessage]);
const handleClose = (e: unknown, reason: string): void => {
if (reason !== "clickaway") {
setInfoMessage(undefined);
setOpen(false);
}
};
return <SnackbarContext.Provider value={{showInfoMessage: setInfoMessage}}>
<Snackbar open={open} key={snack.key} autoHideDuration={4000} onClose={handleClose}>
<Alert>
{snack.message}
</Alert>
</Snackbar>
{children}
</SnackbarContext.Provider>;
};

View file

@ -0,0 +1,21 @@
import {useMemo} from "react";
import {MetricBase} from "../api/types";
export type MetricCategory = {
key: string;
variations: number;
}
export const useSortedCategories = (data: MetricBase[]): MetricCategory[] => useMemo(() => {
const columns: { [key: string]: { options: Set<string> } } = {};
data.forEach(d =>
Object.entries(d.metric).forEach(e =>
columns[e[0]] ? columns[e[0]].options.add(e[1]) : columns[e[0]] = {options: new Set([e[1]])}
)
);
return Object.entries(columns).map(e => ({
key: e[0],
variations: e[1].options.size
})).sort((a1, a2) => a1.variations - a2.variations);
}, [data]);

View file

@ -0,0 +1,33 @@
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
/*Material UI global classes*/
.MuiAccordionSummary-content {
margin: 10px 0 !important;
}
/* TODO: find better way to override codemirror styles */
.cm-activeLine {
background-color: inherit !important;
}
.cm-wrap {
border-radius: 4px;
border-color: #b9b9b9;
border-style: solid;
border-width: 1px;
font-size: 10px;
}
.one-line-scroll .cm-wrap {
height: 24px;
}

View file

@ -0,0 +1,16 @@
import React from "react";
import ReactDOM from "react-dom";
import "./index.css";
import App from "./App";
import reportWebVitals from "./reportWebVitals";
ReactDOM.render(
<React.StrictMode>
<App />
</React.StrictMode>,
document.getElementById("root")
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

View file

@ -0,0 +1 @@
/// <reference types="react-scripts" />

View file

@ -0,0 +1,15 @@
import {ReportHandler} from "web-vitals";
const reportWebVitals = (onPerfEntry?: ReportHandler): void => {
if (onPerfEntry && onPerfEntry instanceof Function) {
import("web-vitals").then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
getCLS(onPerfEntry);
getFID(onPerfEntry);
getFCP(onPerfEntry);
getLCP(onPerfEntry);
getTTFB(onPerfEntry);
});
}
};
export default reportWebVitals;

View file

@ -0,0 +1,5 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import "@testing-library/jest-dom";

View file

@ -0,0 +1,25 @@
import React, {createContext, Dispatch, FC, useContext, useMemo, useReducer} from "react";
import {AuthAction, AuthState, initialPrepopulatedState, reducer} from "./reducer";
type AuthStateContextType = { state: AuthState, dispatch: Dispatch<AuthAction> };
export const AuthStateContext = createContext<AuthStateContextType>({} as AuthStateContextType);
export const useAuthState = (): AuthState => useContext(AuthStateContext).state;
export const useAuthDispatch = (): Dispatch<AuthAction> => useContext(AuthStateContext).dispatch;
export const AuthStateProvider: FC = ({children}) => {
const [state, dispatch] = useReducer(reducer, initialPrepopulatedState);
const contextValue = useMemo(() => {
return { state, dispatch };
}, [state, dispatch]);
return <AuthStateContext.Provider value={contextValue}>
{children}
</AuthStateContext.Provider>;
};

View file

@ -0,0 +1,80 @@
import {authKeys, getFromStorage, removeFromStorage, saveToStorage} from "../../utils/storage";
export type AUTH_METHOD = "NO_AUTH" | "BASIC_AUTH" | "BEARER_AUTH";
export type BasicAuthData = {
login: string;
password: string;
};
export type BearerAuthData = {
token: string; // "Bearer xxx"
};
export interface AuthState {
authMethod: AUTH_METHOD;
basicData?: BasicAuthData;
bearerData?: BearerAuthData;
saveAuthLocally: boolean;
}
export type WithCheckbox<T = undefined> = {checkbox: boolean; value: T};
export type AuthAction =
| { type: "SET_BASIC_AUTH", payload: WithCheckbox<BasicAuthData> }
| { type: "SET_BEARER_AUTH", payload: WithCheckbox<BearerAuthData> }
| { type: "SET_NO_AUTH", payload: WithCheckbox}
export const initialState: AuthState = {
authMethod: "NO_AUTH",
saveAuthLocally: false
};
const initialAuthMethodData = getFromStorage("AUTH_TYPE") as AUTH_METHOD;
const initialBasicAuthData = getFromStorage("BASIC_AUTH_DATA") as BasicAuthData;
const initialBearerAuthData = getFromStorage("BEARER_AUTH_DATA") as BearerAuthData;
export const initialPrepopulatedState: AuthState = {
...initialState,
authMethod: initialAuthMethodData || initialState.authMethod,
basicData: initialBasicAuthData,
bearerData: initialBearerAuthData,
saveAuthLocally: !!(initialBasicAuthData || initialBearerAuthData)
};
export const removeAuthKeys = (): void => {
removeFromStorage(authKeys);
};
export function reducer(state: AuthState, action: AuthAction): AuthState {
// Reducer should not have side effects
// but until auth storage is handled ONLY HERE,
// it should be fine
switch (action.type) {
case "SET_BASIC_AUTH":
action.payload.checkbox ? saveToStorage("BASIC_AUTH_DATA", action.payload.value) : removeAuthKeys();
saveToStorage("AUTH_TYPE", "BASIC_AUTH");
return {
...state,
authMethod: "BASIC_AUTH",
basicData: action.payload.value
};
case "SET_BEARER_AUTH":
action.payload.checkbox ? saveToStorage("BEARER_AUTH_DATA", action.payload.value) : removeAuthKeys();
saveToStorage("AUTH_TYPE", "BEARER_AUTH");
return {
...state,
authMethod: "BEARER_AUTH",
bearerData: action.payload.value
};
case "SET_NO_AUTH":
!action.payload.checkbox && removeAuthKeys();
saveToStorage("AUTH_TYPE", "NO_AUTH");
return {
...state,
authMethod: "NO_AUTH"
};
default:
throw new Error();
}
}

View file

@ -0,0 +1,36 @@
import React, {createContext, Dispatch, FC, useContext, useEffect, useMemo, useReducer} from "react";
import {Action, AppState, initialState, reducer} from "./reducer";
import {getQueryStringValue, setQueryStringValue} from "../../utils/query-string";
type StateContextType = { state: AppState, dispatch: Dispatch<Action> };
export const StateContext = createContext<StateContextType>({} as StateContextType);
export const useAppState = (): AppState => useContext(StateContext).state;
export const useAppDispatch = (): Dispatch<Action> => useContext(StateContext).dispatch;
export const initialPrepopulatedState = Object.entries(initialState)
.reduce((acc, [key, value]) => ({
...acc,
[key]: getQueryStringValue(key) || value
}), {}) as AppState;
export const StateProvider: FC = ({children}) => {
const [state, dispatch] = useReducer(reducer, initialPrepopulatedState);
useEffect(() => {
setQueryStringValue(state as unknown as Record<string, unknown>);
}, [state]);
const contextValue = useMemo(() => {
return { state, dispatch };
}, [state, dispatch]);
return <StateContext.Provider value={contextValue}>
{children}
</StateContext.Provider>;
};

View file

@ -0,0 +1,121 @@
import {DisplayType} from "../../components/Home/Configurator/DisplayTypeSwitch";
import {TimeParams, TimePeriod} from "../../types";
import {dateFromSeconds, getDurationFromPeriod, getTimeperiodForDuration} from "../../utils/time";
import {getFromStorage} from "../../utils/storage";
export interface TimeState {
duration: string;
period: TimeParams;
}
export interface AppState {
serverUrl: string;
displayType: DisplayType;
query: string;
time: TimeState;
queryControls: {
autoRefresh: boolean;
}
}
export type Action =
| { type: "SET_DISPLAY_TYPE", payload: DisplayType }
| { type: "SET_SERVER", payload: string }
| { type: "SET_QUERY", payload: string }
| { type: "SET_DURATION", payload: string }
| { type: "SET_UNTIL", payload: Date }
| { type: "SET_PERIOD", payload: TimePeriod }
| { type: "RUN_QUERY"}
| { type: "RUN_QUERY_TO_NOW"}
| { type: "TOGGLE_AUTOREFRESH"}
export const initialState: AppState = {
serverUrl: getFromStorage("PREFERRED_URL") as string || "https://", // https://demo.promlabs.com or https://play.victoriametrics.com/select/accounting/1/6a716b0f-38bc-4856-90ce-448fd713e3fe/prometheus",
displayType: "chart",
query: getFromStorage("LAST_QUERY") as string || "\n", // demo_memory_usage_bytes
time: {
duration: "1h",
period: getTimeperiodForDuration("1h")
},
queryControls: {
autoRefresh: false
}
};
export function reducer(state: AppState, action: Action): AppState {
switch (action.type) {
case "SET_DISPLAY_TYPE":
return {
...state,
displayType: action.payload
};
case "SET_SERVER":
return {
...state,
serverUrl: action.payload
};
case "SET_QUERY":
return {
...state,
query: action.payload
};
case "SET_DURATION":
return {
...state,
time: {
...state.time,
duration: action.payload,
period: getTimeperiodForDuration(action.payload, dateFromSeconds(state.time.period.end))
}
};
case "SET_UNTIL":
return {
...state,
time: {
...state.time,
period: getTimeperiodForDuration(state.time.duration, action.payload)
}
};
case "SET_PERIOD":
// eslint-disable-next-line no-case-declarations
const duration = getDurationFromPeriod(action.payload);
return {
...state,
queryControls: {
...state.queryControls,
autoRefresh: false // since we're considering this to action to be fired from period selection on chart
},
time: {
...state.time,
duration,
period: getTimeperiodForDuration(duration, action.payload.to)
}
};
case "TOGGLE_AUTOREFRESH":
return {
...state,
queryControls: {
...state.queryControls,
autoRefresh: !state.queryControls.autoRefresh
}
};
case "RUN_QUERY":
return {
...state,
time: {
...state.time,
period: getTimeperiodForDuration(state.time.duration, dateFromSeconds(state.time.period.end))
}
};
case "RUN_QUERY_TO_NOW":
return {
...state,
time: {
...state.time,
period: getTimeperiodForDuration(state.time.duration)
}
};
default:
throw new Error();
}
}

View file

@ -0,0 +1,30 @@
import {MetricBase} from "../api/types";
export interface TimeParams {
start: number; // timestamp in seconds
end: number; // timestamp in seconds
step?: number; // seconds
}
export interface TimePeriod {
from: Date;
to: Date;
}
export interface DataValue {
key: number; // timestamp in seconds
value: number; // y axis value
}
export interface DataSeries extends MetricBase{
metadata: {
name: string;
},
values: DataValue[]; // sorted by key which is timestamp
}
export interface InstantDataSeries {
metadata: string[]; // just ordered columns
value: string;
}

View file

@ -0,0 +1,9 @@
import {MetricBase} from "../api/types";
export const getNameForMetric = (result: MetricBase): string => {
if (Object.keys(result.metric).length === 0) {
return "Query result"; // a bit better than just {} for case of aggregation functions
}
const { __name__: name, ...freeFormFields } = result.metric;
return `${name || ""} {${Object.entries(freeFormFields).map(e => `${e[0]}: ${e[1]}`).join(", ")}}`;
};

View file

@ -0,0 +1,49 @@
import qs from "qs";
const decoder = (value: string) => {
if (/^(\d+|\d*\.\d+)$/.test(value)) {
return parseFloat(value);
}
const keywords = {
true: true,
false: false,
null: null,
undefined: undefined,
};
if (value in keywords) {
return keywords[value as keyof typeof keywords];
}
return decodeURI(value);
};
export const setQueryStringWithoutPageReload = (qsValue: string): void => {
const w = window;
if (w) {
const newurl = w.location.protocol +
"//" +
w.location.host +
w.location.pathname +
"?" +
qsValue;
w.history.pushState({ path: newurl }, "", newurl);
}
};
export const setQueryStringValue = (
newValue: Record<string, unknown>,
queryString = window.location.search
): void => {
const values = qs.parse(queryString, { ignoreQueryPrefix: true, decoder });
const newQsValue = qs.stringify({ ...values, ...newValue }, { encode: false });
setQueryStringWithoutPageReload(newQsValue);
};
export const getQueryStringValue = (
key: string,
queryString = window.location.search
): unknown => {
const values = qs.parse(queryString, { ignoreQueryPrefix: true, decoder });
return values[key];
};

View file

@ -0,0 +1,29 @@
export type StorageKeys = "PREFERRED_URL" | "LAST_QUERY" | "BASIC_AUTH_DATA" | "BEARER_AUTH_DATA" | "AUTH_TYPE";
export const saveToStorage = (key: StorageKeys, value: string | boolean | Record<string, unknown>): void => {
if (value) {
// keeping object in storage so that keeping the string is not different from keeping
window.localStorage.setItem(key, JSON.stringify({value}));
} else {
removeFromStorage([key]);
}
};
// TODO: make this aware of data type that is stored
export const getFromStorage = (key: StorageKeys): undefined | boolean | string | Record<string, unknown> => {
const valueObj = window.localStorage.getItem(key);
if (valueObj === null) {
return undefined;
} else {
try {
return JSON.parse(valueObj)?.value; // see comment in "saveToStorage"
} catch (e) {
return valueObj; // fallback for corrupted json
}
}
};
export const removeFromStorage = (keys: StorageKeys[]): void => keys.forEach(k => window.localStorage.removeItem(k));
export const authKeys: StorageKeys[] = ["BASIC_AUTH_DATA", "BEARER_AUTH_DATA"];

View file

@ -0,0 +1,78 @@
import {TimeParams, TimePeriod} from "../types";
import dayjs, {UnitTypeShort} from "dayjs";
import duration from "dayjs/plugin/duration";
dayjs.extend(duration);
const MAX_ITEMS_PER_CHART = 30; // TODO: make dependent from screen size
export const supportedDurations = [
{long: "days", short: "d", possible: "day"},
{long: "weeks", short: "w", possible: "week"},
{long: "months", short: "M", possible: "mon"},
{long: "years", short: "y", possible: "year"},
{long: "hours", short: "h", possible: "hour"},
{long: "minutes", short: "m", possible: "min"},
{long: "seconds", short: "s", possible: "sec"},
{long: "milliseconds", short: "ms", possible: "millisecond"}
];
const shortDurations = supportedDurations.map(d => d.short);
export const isSupportedDuration = (str: string): Partial<Record<UnitTypeShort, string>> | undefined => {
const digits = str.match(/\d+/g);
const words = str.match(/[a-zA-Z]+/g);
if (words && digits && shortDurations.includes(words[0])) {
return {[words[0]]: digits[0]};
}
};
export const getTimeperiodForDuration = (dur: string, date?: Date): TimeParams => {
const n = (date || new Date()).valueOf() / 1000;
const durItems = dur.trim().split(" ");
const durObject = durItems.reduce((prev, curr) => {
const dur = isSupportedDuration(curr);
if (dur) {
return {
...prev,
...dur
};
} else {
return {
...prev
};
}
}, {});
const delta = dayjs.duration(durObject).asSeconds();
return {
start: n - delta,
end: n,
step: delta / MAX_ITEMS_PER_CHART
};
};
export const formatDateForNativeInput = (date: Date): string => {
const isoString = dayjs(date).format("YYYY-MM-DD[T]HH:mm:ss");
return isoString;
};
export const getDurationFromPeriod = (p: TimePeriod): string => {
const dur = dayjs.duration(p.to.valueOf() - p.from.valueOf());
const durs: UnitTypeShort[] = ["d", "h", "m", "s"];
return durs
.map(d => ({val: dur.get(d), str: d}))
.filter(obj => obj.val !== 0)
.map(obj => `${obj.val}${obj.str}`)
.join(" ");
};
export const dateFromSeconds = (epochTimeInSeconds: number): Date =>
new Date(epochTimeInSeconds * 1000);

View file

@ -0,0 +1,11 @@
export const isValidHttpUrl = (str: string): boolean => {
let url;
try {
url = new URL(str);
} catch (_) {
return false;
}
return url.protocol === "http:" || url.protocol === "https:";
};

View file

@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react"
},
"include": [
"src"
]
}

View file

@ -0,0 +1,3 @@
module github.com/VictoriMetrics/vmui
go 1.16

View file

@ -0,0 +1,28 @@
package main
import (
"embed"
"flag"
"log"
"net/http"
)
// specific files
//go:embed favicon-32x32.png robots.txt index.html manifest.json asset-manifest.json
// static content
//go:embed static
var files embed.FS
var listenAddr = flag.String("listenAddr", ":8080", "defines listen addr for http server, default to :8080")
func main() {
flag.Parse()
handler := http.NewServeMux()
handler.Handle("/", http.FileServer(http.FS(files)))
handler.HandleFunc("/health", func(writer http.ResponseWriter, request *http.Request) {
writer.WriteHeader(http.StatusOK)
_, _ = writer.Write([]byte(`OK`))
})
log.Printf("starting web server at: %v", *listenAddr)
log.Fatal(http.ListenAndServe(*listenAddr, handler))
}

View file

@ -7,13 +7,6 @@ CERTS_IMAGE := alpine:3.14.0
GO_BUILDER_IMAGE := golang:1.16.5
BUILDER_IMAGE := local/builder:2.0.0-$(shell echo $(GO_BUILDER_IMAGE) | tr : _)
BASE_IMAGE := local/base:1.1.3-$(shell echo $(ROOT_IMAGE) | tr : _)-$(shell echo $(CERTS_IMAGE) | tr : _)
VMUI_VERSION=v0.1.0
vmui-update:
curl -L https://github.com/VictoriaMetrics/vmui/releases/download/${VMUI_VERSION}/static.zip > vmui-static.zip && \
unzip vmui-static.zip -d vmui-static && \
rm -rf app/vmselect/ui/* && mv vmui-static/build/* app/vmselect/ui && \
rm -rf vmui-static vmui-static.zip
package-base:
(docker image ls --format '{{.Repository}}:{{.Tag}}' | grep -q '$(BASE_IMAGE)$$') \

View file

@ -10,7 +10,9 @@ sort: 15
* FEATURE: reduce memory usage by up to 30% on production workloads.
* FEATURE: log http request path plus all the query args on errors during request processing. Previously only http request path was logged without query args, so it could be hard debugging such errors.
* FEATURE: export `vmselect_request_duration_seconds` and `vminsert_request_duration_seconds` [VictoriaMetrics histograms](https://valyala.medium.com/improving-histogram-usability-for-prometheus-and-grafana-bc7e5df0e350) at `/metrics` page. These histograms can be used for determining latency distribution for the served requests.
* FEATURE: vmselect: embed [vmui](https://github.com/VictoriaMetrics/vmui) into a single-node VictoriaMetrics and into `vmselect` component of cluster version. See [this feature request](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/1413). The web interface is available at `/ui` page.
* FEATURE: vmselect: embed [vmui](https://github.com/VictoriaMetrics/vmui) into a single-node VictoriaMetrics and into `vmselect` component of cluster version. See [this feature request](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/1413). The web interface is available at the following paths:
* `/vmui/` for a single-node VictoriaMetrics
* `/select/<accountID>/prometheus/vmui/` for `vmselect` at cluster version of VictoriaMetrics
* BUGFIX: vmagent: remove `{ %space %}` typo in `/targets` output. The typo has been introduced in v1.62.0. See [this issue](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/1408).
* BUGFIX: vmagent: fix CSS styles on `/targets` page. See [this issue](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/1422).

View file

@ -235,6 +235,8 @@ It is recommended setting up alerts in [vmalert](https://docs.victoriametrics.co
- `tags/autoComplete/values` - returns tag values matching the given `valuePrefix` and/or `expr`. See [these docs](https://graphite.readthedocs.io/en/stable/tags.html#auto-complete-support).
- `tags/delSeries` - deletes series matching the given `path`. See [these docs](https://graphite.readthedocs.io/en/stable/tags.html#removing-series-from-the-tagdb).
* URL with basic Web UI: `http://<vmselect>:8481/select/<accountID>/prometheus/vmui/`.
* URL for query stats across all tenants: `http://<vmselect>:8481/api/v1/status/top_queries`. It lists with the most frequently executed queries and queries taking the most duration.
* URL for time series deletion: `http://<vmselect>:8481/delete/<accountID>/prometheus/api/v1/admin/tsdb/delete_series?match[]=<timeseries_selector_for_delete>`.

View file

@ -577,15 +577,9 @@ VictoriaMetrics accepts `round_digits` query arg for `/api/v1/query` and `/api/v
By default, VictoriaMetrics returns time series for the last 5 minutes from `/api/v1/series`, while the Prometheus API defaults to all time. Use `start` and `end` to select a different time range.
VictoriaMetrics accepts additional args for `/api/v1/labels` and `/api/v1/label/.../values` handlers.
* Any number [time series selectors](https://prometheus.io/docs/prometheus/latest/querying/basics/#time-series-selectors) via `match[]` query arg.
* Optional `start` and `end` query args for limiting the time range for the selected labels or label values.
See [this feature request](https://github.com/prometheus/prometheus/issues/6178) for details.
Additionally VictoriaMetrics provides the following handlers:
* `/vmui` - Basic Web UI
* `/api/v1/series/count` - returns the total number of time series in the database. Some notes:
* the handler scans all the inverted index, so it can be slow if the database contains tens of millions of time series;
* the handler may count [deleted time series](#how-to-delete-time-series) additionally to normal time series due to internal implementation restrictions;

View file

@ -581,15 +581,9 @@ VictoriaMetrics accepts `round_digits` query arg for `/api/v1/query` and `/api/v
By default, VictoriaMetrics returns time series for the last 5 minutes from `/api/v1/series`, while the Prometheus API defaults to all time. Use `start` and `end` to select a different time range.
VictoriaMetrics accepts additional args for `/api/v1/labels` and `/api/v1/label/.../values` handlers.
* Any number [time series selectors](https://prometheus.io/docs/prometheus/latest/querying/basics/#time-series-selectors) via `match[]` query arg.
* Optional `start` and `end` query args for limiting the time range for the selected labels or label values.
See [this feature request](https://github.com/prometheus/prometheus/issues/6178) for details.
Additionally VictoriaMetrics provides the following handlers:
* `/vmui` - Basic Web UI
* `/api/v1/series/count` - returns the total number of time series in the database. Some notes:
* the handler scans all the inverted index, so it can be slow if the database contains tens of millions of time series;
* the handler may count [deleted time series](#how-to-delete-time-series) additionally to normal time series due to internal implementation restrictions;