mirror of
https://github.com/VictoriaMetrics/VictoriaMetrics.git
synced 2024-11-21 14:44:00 +00:00
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:
parent
2be340a4c9
commit
8c764e88f0
96 changed files with 19967 additions and 60 deletions
|
@ -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;
|
||||
|
|
|
@ -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"},
|
||||
|
|
|
@ -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/`.
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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"
|
||||
]
|
||||
}
|
|
@ -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
|
@ -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
|
@ -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()}([]);
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
17
app/vmselect/vmui/asset-manifest.json
Normal file
17
app/vmselect/vmui/asset-manifest.json
Normal 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"
|
||||
]
|
||||
}
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
1
app/vmselect/vmui/index.html
Normal file
1
app/vmselect/vmui/index.html
Normal 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>
|
2
app/vmselect/vmui/static/js/2.3cdac8ea.chunk.js
Normal file
2
app/vmselect/vmui/static/js/2.3cdac8ea.chunk.js
Normal file
File diff suppressed because one or more lines are too long
1
app/vmselect/vmui/static/js/3.d52da3ae.chunk.js
Normal file
1
app/vmselect/vmui/static/js/3.d52da3ae.chunk.js
Normal 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)}}}]);
|
1
app/vmselect/vmui/static/js/main.ffd27a2f.chunk.js
Normal file
1
app/vmselect/vmui/static/js/main.ffd27a2f.chunk.js
Normal file
File diff suppressed because one or more lines are too long
1
app/vmselect/vmui/static/js/runtime-main.50ad8b45.js
Normal file
1
app/vmselect/vmui/static/js/runtime-main.50ad8b45.js
Normal 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
107
app/vmui/.gitignore
vendored
Normal 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
26
app/vmui/Makefile
Normal 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
68
app/vmui/README.md
Normal 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/`
|
11
app/vmui/packages/vmui/.dockerignore
Normal file
11
app/vmui/packages/vmui/.dockerignore
Normal 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
|
58
app/vmui/packages/vmui/.eslintrc.js
Normal file
58
app/vmui/packages/vmui/.eslintrc.js
Normal 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
23
app/vmui/packages/vmui/.gitignore
vendored
Normal 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*
|
6
app/vmui/packages/vmui/Docker-build
Normal file
6
app/vmui/packages/vmui/Docker-build
Normal 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
|
19
app/vmui/packages/vmui/Dockerfile
Normal file
19
app/vmui/packages/vmui/Dockerfile
Normal 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;"]
|
18
app/vmui/packages/vmui/Dockerfile-web
Normal file
18
app/vmui/packages/vmui/Dockerfile-web
Normal 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"]
|
48
app/vmui/packages/vmui/README.md
Normal file
48
app/vmui/packages/vmui/README.md
Normal 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 can’t go back!**
|
||||
|
||||
If you aren’t 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 you’re on your own.
|
||||
|
||||
You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t 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/).
|
48
app/vmui/packages/vmui/nginx/default
Normal file
48
app/vmui/packages/vmui/nginx/default
Normal 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;
|
||||
|
||||
}
|
105
app/vmui/packages/vmui/nginx/nginx.conf
Normal file
105
app/vmui/packages/vmui/nginx/nginx.conf
Normal 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
16795
app/vmui/packages/vmui/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
66
app/vmui/packages/vmui/package.json
Normal file
66
app/vmui/packages/vmui/package.json
Normal 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"
|
||||
}
|
||||
}
|
BIN
app/vmui/packages/vmui/public/apple-touch-icon.png
Normal file
BIN
app/vmui/packages/vmui/public/apple-touch-icon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 14 KiB |
BIN
app/vmui/packages/vmui/public/favicon-32x32.png
Normal file
BIN
app/vmui/packages/vmui/public/favicon-32x32.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.6 KiB |
45
app/vmui/packages/vmui/public/index.html
Normal file
45
app/vmui/packages/vmui/public/index.html
Normal 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>
|
20
app/vmui/packages/vmui/public/manifest.json
Normal file
20
app/vmui/packages/vmui/public/manifest.json
Normal 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"
|
||||
}
|
3
app/vmui/packages/vmui/public/robots.txt
Normal file
3
app/vmui/packages/vmui/public/robots.txt
Normal file
|
@ -0,0 +1,3 @@
|
|||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
9
app/vmui/packages/vmui/src/App.test.tsx
Normal file
9
app/vmui/packages/vmui/src/App.test.tsx
Normal 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();
|
||||
});
|
40
app/vmui/packages/vmui/src/App.tsx
Normal file
40
app/vmui/packages/vmui/src/App.tsx
Normal 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;
|
7
app/vmui/packages/vmui/src/api/query-range.ts
Normal file
7
app/vmui/packages/vmui/src/api/query-range.ts
Normal 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}`;
|
22
app/vmui/packages/vmui/src/api/types.ts
Normal file
22
app/vmui/packages/vmui/src/api/types.ts
Normal 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";
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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;
|
|
@ -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/> Query Range as Chart
|
||||
</StylizedToggleButton>
|
||||
<StylizedToggleButton value="code" aria-label="display as code">
|
||||
<CodeIcon/> Instant Query as JSON
|
||||
</StylizedToggleButton>
|
||||
<StylizedToggleButton value="table" aria-label="display as table">
|
||||
<TableChartIcon/> Instant Query as Table
|
||||
</StylizedToggleButton>
|
||||
</ToggleButtonGroup>;
|
||||
};
|
|
@ -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>;
|
||||
};
|
|
@ -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;
|
|
@ -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;
|
|
@ -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>;
|
||||
};
|
|
@ -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>: </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"/>,
|
||||
<InlineBtn handler={() => setDurationString("1h")} text="1h"/>,
|
||||
<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.
|
||||
<InlineBtn handler={() => dispatch({type: "RUN_QUERY_TO_NOW"})} text="Switch to now"/>
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>;
|
||||
};
|
|
@ -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
|
||||
};
|
||||
};
|
87
app/vmui/packages/vmui/src/components/Home/HomeLayout.tsx
Normal file
87
app/vmui/packages/vmui/src/components/Home/HomeLayout.tsx
Normal 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;
|
27
app/vmui/packages/vmui/src/components/Home/UrlCopy.tsx
Normal file
27
app/vmui/packages/vmui/src/components/Home/UrlCopy.tsx
Normal 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>;
|
||||
};
|
42
app/vmui/packages/vmui/src/components/Home/UrlLine.tsx
Normal file
42
app/vmui/packages/vmui/src/components/Home/UrlLine.tsx
Normal 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>;
|
||||
};
|
128
app/vmui/packages/vmui/src/components/Home/Views/GraphView.tsx
Normal file
128
app/vmui/packages/vmui/src/components/Home/Views/GraphView.tsx
Normal 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.
|
||||
{showN + showingIncrement >= amountOfSeries
|
||||
?
|
||||
<InlineBtn handler={() => setShowN(amountOfSeries)} text="Show all"/>
|
||||
:
|
||||
<>
|
||||
<InlineBtn handler={() => setShowN(prev => Math.min(prev + showingIncrement, amountOfSeries))} text={`Show ${showingIncrement} more`}/>,
|
||||
<InlineBtn handler={() => setShowN(amountOfSeries)} text="show all"/>.
|
||||
</>}
|
||||
</span>
|
||||
: <span style={{fontStyle: "italic"}}>Showing all series.
|
||||
<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;
|
|
@ -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;
|
|
@ -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;
|
67
app/vmui/packages/vmui/src/components/Legend/Legend.tsx
Normal file
67
app/vmui/packages/vmui/src/components/Legend/Legend.tsx
Normal 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>;
|
||||
};
|
|
@ -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})`} />;
|
||||
};
|
39
app/vmui/packages/vmui/src/components/LineChart/AxisLeft.tsx
Normal file
39
app/vmui/packages/vmui/src/components/LineChart/AxisLeft.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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}: </span>
|
||||
<span style={{fontWeight: "bold"}}>{value}</span>
|
||||
</Box>)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
|
@ -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} />
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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" />}</>;
|
||||
};
|
196
app/vmui/packages/vmui/src/components/LineChart/LineChart.tsx
Normal file
196
app/vmui/packages/vmui/src/components/LineChart/LineChart.tsx
Normal 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>
|
||||
);
|
||||
};
|
|
@ -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;
|
||||
}
|
5
app/vmui/packages/vmui/src/components/LineChart/model.ts
Normal file
5
app/vmui/packages/vmui/src/components/LineChart/model.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
export type AggregatedDataSet = {
|
||||
key: number;
|
||||
value: aggregatedDataValue;
|
||||
};
|
||||
export type aggregatedDataValue = {[key: string]: number};
|
|
@ -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;
|
19
app/vmui/packages/vmui/src/components/common/InlineBtn.tsx
Normal file
19
app/vmui/packages/vmui/src/components/common/InlineBtn.tsx
Normal 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>;
|
||||
};
|
55
app/vmui/packages/vmui/src/contexts/Snackbar.tsx
Normal file
55
app/vmui/packages/vmui/src/contexts/Snackbar.tsx
Normal 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>;
|
||||
};
|
||||
|
||||
|
21
app/vmui/packages/vmui/src/hooks/useSortedCategories.ts
Normal file
21
app/vmui/packages/vmui/src/hooks/useSortedCategories.ts
Normal 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]);
|
33
app/vmui/packages/vmui/src/index.css
Normal file
33
app/vmui/packages/vmui/src/index.css
Normal 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;
|
||||
}
|
16
app/vmui/packages/vmui/src/index.tsx
Normal file
16
app/vmui/packages/vmui/src/index.tsx
Normal 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();
|
1
app/vmui/packages/vmui/src/react-app-env.d.ts
vendored
Normal file
1
app/vmui/packages/vmui/src/react-app-env.d.ts
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
/// <reference types="react-scripts" />
|
15
app/vmui/packages/vmui/src/reportWebVitals.ts
Normal file
15
app/vmui/packages/vmui/src/reportWebVitals.ts
Normal 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;
|
5
app/vmui/packages/vmui/src/setupTests.ts
Normal file
5
app/vmui/packages/vmui/src/setupTests.ts
Normal 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";
|
25
app/vmui/packages/vmui/src/state/auth/AuthStateContext.tsx
Normal file
25
app/vmui/packages/vmui/src/state/auth/AuthStateContext.tsx
Normal 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>;
|
||||
};
|
||||
|
||||
|
80
app/vmui/packages/vmui/src/state/auth/reducer.ts
Normal file
80
app/vmui/packages/vmui/src/state/auth/reducer.ts
Normal 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();
|
||||
}
|
||||
}
|
36
app/vmui/packages/vmui/src/state/common/StateContext.tsx
Normal file
36
app/vmui/packages/vmui/src/state/common/StateContext.tsx
Normal 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>;
|
||||
};
|
||||
|
||||
|
121
app/vmui/packages/vmui/src/state/common/reducer.ts
Normal file
121
app/vmui/packages/vmui/src/state/common/reducer.ts
Normal 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();
|
||||
}
|
||||
}
|
30
app/vmui/packages/vmui/src/types/index.ts
Normal file
30
app/vmui/packages/vmui/src/types/index.ts
Normal 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;
|
||||
}
|
9
app/vmui/packages/vmui/src/utils/metric.ts
Normal file
9
app/vmui/packages/vmui/src/utils/metric.ts
Normal 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(", ")}}`;
|
||||
};
|
49
app/vmui/packages/vmui/src/utils/query-string.ts
Normal file
49
app/vmui/packages/vmui/src/utils/query-string.ts
Normal 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];
|
||||
};
|
29
app/vmui/packages/vmui/src/utils/storage.ts
Normal file
29
app/vmui/packages/vmui/src/utils/storage.ts
Normal 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"];
|
||||
|
78
app/vmui/packages/vmui/src/utils/time.ts
Normal file
78
app/vmui/packages/vmui/src/utils/time.ts
Normal 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);
|
11
app/vmui/packages/vmui/src/utils/url.ts
Normal file
11
app/vmui/packages/vmui/src/utils/url.ts
Normal 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:";
|
||||
};
|
26
app/vmui/packages/vmui/tsconfig.json
Normal file
26
app/vmui/packages/vmui/tsconfig.json
Normal 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"
|
||||
]
|
||||
}
|
3
app/vmui/packages/vmui/web/go.mod
Normal file
3
app/vmui/packages/vmui/web/go.mod
Normal file
|
@ -0,0 +1,3 @@
|
|||
module github.com/VictoriMetrics/vmui
|
||||
|
||||
go 1.16
|
28
app/vmui/packages/vmui/web/main.go
Normal file
28
app/vmui/packages/vmui/web/main.go
Normal 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))
|
||||
}
|
|
@ -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)$$') \
|
||||
|
|
|
@ -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).
|
||||
|
|
|
@ -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>`.
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
Loading…
Reference in a new issue