Merge branch 'public-single-node' into pmm-6401-read-prometheus-data-files

This commit is contained in:
Aliaksandr Valialkin 2022-11-17 01:33:16 +02:00
commit 2dd82e8355
No known key found for this signature in database
GPG key ID: A72BEC6CD3D0DED1
263 changed files with 14165 additions and 10203 deletions

View file

@ -2162,6 +2162,8 @@ Pass `-help` to VictoriaMetrics in order to see the list of supported command-li
Auth key for /debug/pprof/* endpoints. It must be passed via authKey query arg. It overrides httpAuth.* settings
-precisionBits int
The number of precision bits to store per each value. Lower precision bits improves data compression at the cost of precision loss (default 64)
-prevCacheRemovalPercent float
The previous cache is removed when the percent of requests it serves becomes lower than this value. Higher values reduce average memory usage at the cost of higher CPU usage (default 0.2)
-promscrape.azureSDCheckInterval duration
Interval for checking for changes in Azure. This works only if azure_sd_configs is configured in '-promscrape.config' file. See https://docs.victoriametrics.com/sd_configs.html#azure_sd_configs for details (default 1m0s)
-promscrape.cluster.memberNum string

View file

@ -6380,19 +6380,39 @@ func TestExecSuccess(t *testing.T) {
q := `range_quantile(0.5, time())`
r := netstorage.Result{
MetricName: metricNameExpected,
// time() results in [1000 1200 1400 1600 1800 2000]
Values: []float64{1500, 1500, 1500, 1500, 1500, 1500},
Timestamps: timestampsExpected,
}
resultExpected := []netstorage.Result{r}
f(q, resultExpected)
})
t.Run(`range_stddev()`, func(t *testing.T) {
t.Parallel()
q := `round(range_stddev(time()),0.01)`
r := netstorage.Result{
MetricName: metricNameExpected,
Values: []float64{341.57, 341.57, 341.57, 341.57, 341.57, 341.57},
Timestamps: timestampsExpected,
}
resultExpected := []netstorage.Result{r}
f(q, resultExpected)
})
t.Run(`range_stdvar()`, func(t *testing.T) {
t.Parallel()
q := `round(range_stdvar(time()),0.01)`
r := netstorage.Result{
MetricName: metricNameExpected,
Values: []float64{116666.67, 116666.67, 116666.67, 116666.67, 116666.67, 116666.67},
Timestamps: timestampsExpected,
}
resultExpected := []netstorage.Result{r}
f(q, resultExpected)
})
t.Run(`range_median()`, func(t *testing.T) {
t.Parallel()
q := `range_median(time())`
r := netstorage.Result{
MetricName: metricNameExpected,
// time() results in [1000 1200 1400 1600 1800 2000]
Values: []float64{1500, 1500, 1500, 1500, 1500, 1500},
Timestamps: timestampsExpected,
}
@ -6905,6 +6925,51 @@ func TestExecSuccess(t *testing.T) {
resultExpected := []netstorage.Result{r}
f(q, resultExpected)
})
t.Run(`range_linear_regression(time())`, func(t *testing.T) {
t.Parallel()
q := `range_linear_regression(time())`
r := netstorage.Result{
MetricName: metricNameExpected,
Values: []float64{1000, 1200, 1400, 1600, 1800, 2000},
Timestamps: timestampsExpected,
}
resultExpected := []netstorage.Result{r}
f(q, resultExpected)
})
t.Run(`range_linear_regression(-time())`, func(t *testing.T) {
t.Parallel()
q := `range_linear_regression(-time())`
r := netstorage.Result{
MetricName: metricNameExpected,
Values: []float64{-1000, -1200, -1400, -1600, -1800, -2000},
Timestamps: timestampsExpected,
}
resultExpected := []netstorage.Result{r}
f(q, resultExpected)
})
t.Run(`range_linear_regression(100/time())`, func(t *testing.T) {
t.Parallel()
q := `sort_desc(round((
alias(range_linear_regression(100/time()), "regress"),
alias(100/time(), "orig"),
),
0.001
))`
r1 := netstorage.Result{
MetricName: metricNameExpected,
Values: []float64{0.1, 0.083, 0.071, 0.062, 0.056, 0.05},
Timestamps: timestampsExpected,
}
r1.MetricName.MetricGroup = []byte("orig")
r2 := netstorage.Result{
MetricName: metricNameExpected,
Values: []float64{0.095, 0.085, 0.075, 0.066, 0.056, 0.046},
Timestamps: timestampsExpected,
}
r2.MetricName.MetricGroup = []byte("regress")
resultExpected := []netstorage.Result{r1, r2}
f(q, resultExpected)
})
t.Run(`deriv(N)`, func(t *testing.T) {
t.Parallel()
q := `deriv(1000)`
@ -8034,6 +8099,8 @@ func TestExecError(t *testing.T) {
f(`nonexisting()`)
// Invalid number of args
f(`range_stddev()`)
f(`range_stdvar()`)
f(`range_quantile()`)
f(`range_quantile(1, 2, 3)`)
f(`range_median()`)
@ -8097,6 +8164,7 @@ func TestExecError(t *testing.T) {
f(`range_sum(1, 2)`)
f(`range_first(1, 2)`)
f(`range_last(1, 2)`)
f(`range_linear_regression(1, 2)`)
f(`smooth_exponential()`)
f(`smooth_exponential(1)`)
f(`remove_resets()`)

View file

@ -894,7 +894,7 @@ func newRollupPredictLinear(args []interface{}) (rollupFunc, error) {
return nil, err
}
rf := func(rfa *rollupFuncArg) float64 {
v, k := linearRegression(rfa)
v, k := linearRegression(rfa.values, rfa.timestamps, rfa.currTimestamp)
if math.IsNaN(v) {
return nan
}
@ -904,13 +904,8 @@ func newRollupPredictLinear(args []interface{}) (rollupFunc, error) {
return rf, nil
}
func linearRegression(rfa *rollupFuncArg) (float64, float64) {
// There is no need in handling NaNs here, since they must be cleaned up
// before calling rollup funcs.
values := rfa.values
timestamps := rfa.timestamps
n := float64(len(values))
if n == 0 {
func linearRegression(values []float64, timestamps []int64, interceptTime int64) (float64, float64) {
if len(values) == 0 {
return nan, nan
}
if areConstValues(values) {
@ -918,25 +913,32 @@ func linearRegression(rfa *rollupFuncArg) (float64, float64) {
}
// See https://en.wikipedia.org/wiki/Simple_linear_regression#Numerical_example
interceptTime := rfa.currTimestamp
vSum := float64(0)
tSum := float64(0)
tvSum := float64(0)
ttSum := float64(0)
n := 0
for i, v := range values {
if math.IsNaN(v) {
continue
}
dt := float64(timestamps[i]-interceptTime) / 1e3
vSum += v
tSum += dt
tvSum += dt * v
ttSum += dt * dt
n++
}
if n == 0 {
return nan, nan
}
k := float64(0)
tDiff := ttSum - tSum*tSum/n
tDiff := ttSum - tSum*tSum/float64(n)
if math.Abs(tDiff) >= 1e-6 {
// Prevent from incorrect division for too small tDiff values.
k = (tvSum - tSum*vSum/n) / tDiff
k = (tvSum - tSum*vSum/float64(n)) / tDiff
}
v := vSum/n - k*tSum/n
v := vSum/float64(n) - k*tSum/float64(n)
return v, k
}
@ -1473,16 +1475,20 @@ func rollupStaleSamples(rfa *rollupFuncArg) float64 {
}
func rollupStddev(rfa *rollupFuncArg) float64 {
stdvar := rollupStdvar(rfa)
return math.Sqrt(stdvar)
return stddev(rfa.values)
}
func rollupStdvar(rfa *rollupFuncArg) float64 {
// See `Rapid calculation methods` at https://en.wikipedia.org/wiki/Standard_deviation
return stdvar(rfa.values)
}
// There is no need in handling NaNs here, since they must be cleaned up
// before calling rollup funcs.
values := rfa.values
func stddev(values []float64) float64 {
v := stdvar(values)
return math.Sqrt(v)
}
func stdvar(values []float64) float64 {
// See `Rapid calculation methods` at https://en.wikipedia.org/wiki/Standard_deviation
if len(values) == 0 {
return nan
}
@ -1494,11 +1500,17 @@ func rollupStdvar(rfa *rollupFuncArg) float64 {
var count float64
var q float64
for _, v := range values {
if math.IsNaN(v) {
continue
}
count++
avgNew := avg + (v-avg)/count
q += (v - avg) * (v - avgNew)
avg = avgNew
}
if count == 0 {
return nan
}
return q / count
}
@ -1605,7 +1617,7 @@ func rollupIdelta(rfa *rollupFuncArg) float64 {
func rollupDerivSlow(rfa *rollupFuncArg) float64 {
// Use linear regression like Prometheus does.
// See https://github.com/VictoriaMetrics/VictoriaMetrics/issues/73
_, k := linearRegression(rfa)
_, k := linearRegression(rfa.values, rfa.timestamps, rfa.currTimestamp)
return k
}

View file

@ -388,12 +388,7 @@ func TestRollupPredictLinear(t *testing.T) {
func TestLinearRegression(t *testing.T) {
f := func(values []float64, timestamps []int64, expV, expK float64) {
t.Helper()
rfa := &rollupFuncArg{
values: values,
timestamps: timestamps,
currTimestamp: timestamps[0] + 100,
}
v, k := linearRegression(rfa)
v, k := linearRegression(values, timestamps, timestamps[0]+100)
if err := compareValues([]float64{v}, []float64{expV}); err != nil {
t.Fatalf("unexpected v err: %s", err)
}

View file

@ -88,9 +88,12 @@ var transformFuncs = map[string]transformFunc{
"range_avg": newTransformFuncRange(runningAvg),
"range_first": transformRangeFirst,
"range_last": transformRangeLast,
"range_linear_regression": transformRangeLinearRegression,
"range_max": newTransformFuncRange(runningMax),
"range_min": newTransformFuncRange(runningMin),
"range_quantile": transformRangeQuantile,
"range_stddev": transformRangeStddev,
"range_stdvar": transformRangeStdvar,
"range_sum": newTransformFuncRange(runningSum),
"remove_resets": transformRemoveResets,
"round": transformRound,
@ -125,25 +128,28 @@ var transformFuncs = map[string]transformFunc{
// These functions don't change physical meaning of input time series,
// so they don't drop metric name
var transformFuncsKeepMetricName = map[string]bool{
"ceil": true,
"clamp": true,
"clamp_max": true,
"clamp_min": true,
"floor": true,
"interpolate": true,
"keep_last_value": true,
"keep_next_value": true,
"range_avg": true,
"range_first": true,
"range_last": true,
"range_max": true,
"range_min": true,
"range_quantile": true,
"round": true,
"running_avg": true,
"running_max": true,
"running_min": true,
"smooth_exponential": true,
"ceil": true,
"clamp": true,
"clamp_max": true,
"clamp_min": true,
"floor": true,
"interpolate": true,
"keep_last_value": true,
"keep_next_value": true,
"range_avg": true,
"range_first": true,
"range_last": true,
"range_linear_regression": true,
"range_max": true,
"range_min": true,
"range_quantile": true,
"range_stdvar": true,
"range_sddev": true,
"round": true,
"running_avg": true,
"running_max": true,
"running_min": true,
"smooth_exponential": true,
}
func getTransformFunc(s string) transformFunc {
@ -1234,6 +1240,59 @@ func newTransformFuncRange(rf func(a, b float64, idx int) float64) transformFunc
}
}
func transformRangeLinearRegression(tfa *transformFuncArg) ([]*timeseries, error) {
args := tfa.args
if err := expectTransformArgsNum(args, 1); err != nil {
return nil, err
}
rvs := args[0]
for _, ts := range rvs {
values := ts.Values
timestamps := ts.Timestamps
if len(timestamps) == 0 {
continue
}
interceptTimestamp := timestamps[0]
v, k := linearRegression(values, timestamps, interceptTimestamp)
for i, t := range timestamps {
values[i] = v + k*float64(t-interceptTimestamp)/1e3
}
}
return rvs, nil
}
func transformRangeStddev(tfa *transformFuncArg) ([]*timeseries, error) {
args := tfa.args
if err := expectTransformArgsNum(args, 1); err != nil {
return nil, err
}
rvs := args[0]
for _, ts := range rvs {
values := ts.Values
v := stddev(values)
for i := range values {
values[i] = v
}
}
return rvs, nil
}
func transformRangeStdvar(tfa *transformFuncArg) ([]*timeseries, error) {
args := tfa.args
if err := expectTransformArgsNum(args, 1); err != nil {
return nil, err
}
rvs := args[0]
for _, ts := range rvs {
values := ts.Values
v := stdvar(values)
for i := range values {
values[i] = v
}
}
return rvs, nil
}
func transformRangeQuantile(tfa *transformFuncArg) ([]*timeseries, error) {
args := tfa.args
if err := expectTransformArgsNum(args, 2); err != nil {

View file

@ -1,12 +1,12 @@
{
"files": {
"main.css": "./static/css/main.07bcc4ad.css",
"main.js": "./static/js/main.07d9522d.js",
"static/js/27.939f971b.chunk.js": "./static/js/27.939f971b.chunk.js",
"main.css": "./static/css/main.0493d695.css",
"main.js": "./static/js/main.0b7317e2.js",
"static/js/27.c1ccfd29.chunk.js": "./static/js/27.c1ccfd29.chunk.js",
"index.html": "./index.html"
},
"entrypoints": [
"static/css/main.07bcc4ad.css",
"static/js/main.07d9522d.js"
"static/css/main.0493d695.css",
"static/js/main.0b7317e2.js"
]
}

View file

@ -1 +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="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin><link href="https://fonts.googleapis.com/css2?family=Lato:ital,wght@0,300;0,400;0,700;1,300;1,400;1,700&display=swap" rel="stylesheet"><script src="./dashboards/index.js" type="module"></script><script defer="defer" src="./static/js/main.07d9522d.js"></script><link href="./static/css/main.07bcc4ad.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>
<!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="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin><link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono&family=Lato:wght@300;400;700&display=swap" rel="stylesheet"><script src="./dashboards/index.js" type="module"></script><script defer="defer" src="./static/js/main.0b7317e2.js"></script><link href="./static/css/main.0493d695.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>

File diff suppressed because one or more lines are too long

View file

@ -1 +0,0 @@
body{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;font-family:Lato,sans-serif}code{font-family:source-code-pro,Menlo,Monaco,Consolas,Courier New,monospace}.MuiAccordionSummary-content{margin:0!important}.shortcut-key{align-items:center;border:1px solid #dedede;border-radius:4px;cursor:default;display:inline-flex;font-size:10px;justify-content:center;line-height:22px;padding:2px 6px 0;text-align:center;white-space:nowrap}input[type=number]::-webkit-inner-spin-button,input[type=number]::-webkit-outer-spin-button{-webkit-appearance:none;margin:0}.uplot,.uplot *,.uplot :after,.uplot :before{box-sizing:border-box}.uplot{font-family:system-ui,-apple-system,Segoe UI,Roboto,Helvetica Neue,Arial,Noto Sans,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;line-height:1.5;width:-webkit-min-content;width:min-content}.u-title{font-size:18px;font-weight:700;text-align:center}.u-wrap{position:relative;-webkit-user-select:none;-ms-user-select:none;user-select:none}.u-over,.u-under{position:absolute}.u-under{overflow:hidden}.uplot canvas{display:block;height:100%;position:relative;width:100%}.u-axis{position:absolute}.u-legend{font-size:14px;margin:auto;text-align:center}.u-inline{display:block}.u-inline *{display:inline-block}.u-inline tr{margin-right:16px}.u-legend th{font-weight:600}.u-legend th>*{display:inline-block;vertical-align:middle}.u-legend .u-marker{background-clip:padding-box!important;height:1em;margin-right:4px;width:1em}.u-inline.u-live th:after{content:":";vertical-align:middle}.u-inline:not(.u-live) .u-value{display:none}.u-series>*{padding:4px}.u-series th{cursor:pointer}.u-legend .u-off>*{opacity:.3}.u-select{background:rgba(0,0,0,.07)}.u-cursor-x,.u-cursor-y,.u-select{pointer-events:none;position:absolute}.u-cursor-x,.u-cursor-y{left:0;top:0;will-change:transform;z-index:100}.u-hz .u-cursor-x,.u-vt .u-cursor-y{border-right:1px dashed #607d8b;height:100%}.u-hz .u-cursor-y,.u-vt .u-cursor-x{border-bottom:1px dashed #607d8b;width:100%}.u-cursor-pt{background-clip:padding-box!important;border:0 solid;border-radius:50%;left:0;pointer-events:none;position:absolute;top:0;will-change:transform;z-index:100}.u-axis.u-off,.u-cursor-pt.u-off,.u-cursor-x.u-off,.u-cursor-y.u-off,.u-select.u-off,.u-tooltip{display:none}.u-tooltip{grid-gap:12px;word-wrap:break-word;background:rgba(57,57,57,.9);border-radius:4px;color:#fff;font-family:monospace;font-size:10px;font-weight:700;line-height:1.4em;max-width:300px;padding:8px;pointer-events:none;position:absolute;z-index:100}.u-tooltip-data{align-items:center;display:flex;flex-wrap:wrap;font-size:11px;line-height:150%}.u-tooltip-data__value{font-weight:700;padding:4px}.u-tooltip__info{grid-gap:4px;display:grid}.u-tooltip__marker{height:12px;margin-right:4px;width:12px}.legendWrapper{cursor:default;display:flex;flex-wrap:wrap;margin-top:20px;position:relative}.legendGroup{margin:0 12px 0 0;padding:10px 6px}.legendGroupTitle{align-items:center;border-bottom:1px solid #ecebe6;display:flex;font-size:11px;margin-bottom:5px;padding:0 10px 5px}.legendGroupQuery{font-weight:700;margin-right:4px}.legendGroupLine{margin-right:10px}.legendItem{grid-gap:6px;align-items:start;background-color:#fff;cursor:pointer;display:grid;grid-template-columns:auto auto;justify-content:start;padding:7px 50px 7px 10px;transition:.2s ease}.legendItemHide{opacity:.5;text-decoration:line-through}.legendItem:hover{background-color:rgba(0,0,0,.1)}.legendMarker{box-sizing:border-box;height:12px;transition:.2s ease;width:12px}.legendLabel{font-size:11px;font-weight:400;line-height:12px}.legendFreeFields{cursor:pointer;padding:3px}.legendFreeFields:hover{text-decoration:underline}.legendFreeFields:not(:last-child):after{content:","}.panelDescription ul{line-height:2.2}.panelDescription a{color:#fff}.panelDescription code{background-color:rgba(0,0,0,.3);border-radius:2px;color:#fff;display:inline;font-size:inherit;font-weight:400;max-width:100%;padding:4px 6px}

View file

@ -1 +0,0 @@
"use strict";(self.webpackChunkvmui=self.webpackChunkvmui||[]).push([[27],{4027:function(e,t,n){n.r(t),n.d(t,{getCLS:function(){return y},getFCP:function(){return g},getFID:function(){return C},getLCP:function(){return P},getTTFB:function(){return D}});var i,r,a,o,u=function(e,t){return{name:e,value:void 0===t?-1:t,delta:0,entries:[],id:"v2-".concat(Date.now(),"-").concat(Math.floor(8999999999999*Math.random())+1e12)}},c=function(e,t){try{if(PerformanceObserver.supportedEntryTypes.includes(e)){if("first-input"===e&&!("PerformanceEventTiming"in self))return;var n=new PerformanceObserver((function(e){return e.getEntries().map(t)}));return n.observe({type:e,buffered:!0}),n}}catch(e){}},f=function(e,t){var n=function n(i){"pagehide"!==i.type&&"hidden"!==document.visibilityState||(e(i),t&&(removeEventListener("visibilitychange",n,!0),removeEventListener("pagehide",n,!0)))};addEventListener("visibilitychange",n,!0),addEventListener("pagehide",n,!0)},s=function(e){addEventListener("pageshow",(function(t){t.persisted&&e(t)}),!0)},m=function(e,t,n){var i;return function(r){t.value>=0&&(r||n)&&(t.delta=t.value-(i||0),(t.delta||void 0===i)&&(i=t.value,e(t)))}},v=-1,p=function(){return"hidden"===document.visibilityState?0:1/0},d=function(){f((function(e){var t=e.timeStamp;v=t}),!0)},l=function(){return v<0&&(v=p(),d(),s((function(){setTimeout((function(){v=p(),d()}),0)}))),{get firstHiddenTime(){return v}}},g=function(e,t){var n,i=l(),r=u("FCP"),a=function(e){"first-contentful-paint"===e.name&&(f&&f.disconnect(),e.startTime<i.firstHiddenTime&&(r.value=e.startTime,r.entries.push(e),n(!0)))},o=window.performance&&performance.getEntriesByName&&performance.getEntriesByName("first-contentful-paint")[0],f=o?null:c("paint",a);(o||f)&&(n=m(e,r,t),o&&a(o),s((function(i){r=u("FCP"),n=m(e,r,t),requestAnimationFrame((function(){requestAnimationFrame((function(){r.value=performance.now()-i.timeStamp,n(!0)}))}))})))},h=!1,T=-1,y=function(e,t){h||(g((function(e){T=e.value})),h=!0);var n,i=function(t){T>-1&&e(t)},r=u("CLS",0),a=0,o=[],v=function(e){if(!e.hadRecentInput){var t=o[0],i=o[o.length-1];a&&e.startTime-i.startTime<1e3&&e.startTime-t.startTime<5e3?(a+=e.value,o.push(e)):(a=e.value,o=[e]),a>r.value&&(r.value=a,r.entries=o,n())}},p=c("layout-shift",v);p&&(n=m(i,r,t),f((function(){p.takeRecords().map(v),n(!0)})),s((function(){a=0,T=-1,r=u("CLS",0),n=m(i,r,t)})))},E={passive:!0,capture:!0},w=new Date,L=function(e,t){i||(i=t,r=e,a=new Date,F(removeEventListener),S())},S=function(){if(r>=0&&r<a-w){var e={entryType:"first-input",name:i.type,target:i.target,cancelable:i.cancelable,startTime:i.timeStamp,processingStart:i.timeStamp+r};o.forEach((function(t){t(e)})),o=[]}},b=function(e){if(e.cancelable){var t=(e.timeStamp>1e12?new Date:performance.now())-e.timeStamp;"pointerdown"==e.type?function(e,t){var n=function(){L(e,t),r()},i=function(){r()},r=function(){removeEventListener("pointerup",n,E),removeEventListener("pointercancel",i,E)};addEventListener("pointerup",n,E),addEventListener("pointercancel",i,E)}(t,e):L(t,e)}},F=function(e){["mousedown","keydown","touchstart","pointerdown"].forEach((function(t){return e(t,b,E)}))},C=function(e,t){var n,a=l(),v=u("FID"),p=function(e){e.startTime<a.firstHiddenTime&&(v.value=e.processingStart-e.startTime,v.entries.push(e),n(!0))},d=c("first-input",p);n=m(e,v,t),d&&f((function(){d.takeRecords().map(p),d.disconnect()}),!0),d&&s((function(){var a;v=u("FID"),n=m(e,v,t),o=[],r=-1,i=null,F(addEventListener),a=p,o.push(a),S()}))},k={},P=function(e,t){var n,i=l(),r=u("LCP"),a=function(e){var t=e.startTime;t<i.firstHiddenTime&&(r.value=t,r.entries.push(e),n())},o=c("largest-contentful-paint",a);if(o){n=m(e,r,t);var v=function(){k[r.id]||(o.takeRecords().map(a),o.disconnect(),k[r.id]=!0,n(!0))};["keydown","click"].forEach((function(e){addEventListener(e,v,{once:!0,capture:!0})})),f(v,!0),s((function(i){r=u("LCP"),n=m(e,r,t),requestAnimationFrame((function(){requestAnimationFrame((function(){r.value=performance.now()-i.timeStamp,k[r.id]=!0,n(!0)}))}))}))}},D=function(e){var t,n=u("TTFB");t=function(){try{var t=performance.getEntriesByType("navigation")[0]||function(){var e=performance.timing,t={entryType:"navigation",startTime:0};for(var n in e)"navigationStart"!==n&&"toJSON"!==n&&(t[n]=Math.max(e[n]-e.navigationStart,0));return t}();if(n.value=n.delta=t.responseStart,n.value<0||n.value>performance.now())return;n.entries=[t],e(n)}catch(e){}},"complete"===document.readyState?setTimeout(t,0):addEventListener("load",(function(){return setTimeout(t,0)}))}}}]);

View file

@ -0,0 +1 @@
"use strict";(self.webpackChunkvmui=self.webpackChunkvmui||[]).push([[27],{27:function(e,t,n){n.r(t),n.d(t,{getCLS:function(){return y},getFCP:function(){return g},getFID:function(){return C},getLCP:function(){return P},getTTFB:function(){return D}});var i,r,a,o,u=function(e,t){return{name:e,value:void 0===t?-1:t,delta:0,entries:[],id:"v2-".concat(Date.now(),"-").concat(Math.floor(8999999999999*Math.random())+1e12)}},c=function(e,t){try{if(PerformanceObserver.supportedEntryTypes.includes(e)){if("first-input"===e&&!("PerformanceEventTiming"in self))return;var n=new PerformanceObserver((function(e){return e.getEntries().map(t)}));return n.observe({type:e,buffered:!0}),n}}catch(e){}},f=function(e,t){var n=function n(i){"pagehide"!==i.type&&"hidden"!==document.visibilityState||(e(i),t&&(removeEventListener("visibilitychange",n,!0),removeEventListener("pagehide",n,!0)))};addEventListener("visibilitychange",n,!0),addEventListener("pagehide",n,!0)},s=function(e){addEventListener("pageshow",(function(t){t.persisted&&e(t)}),!0)},m=function(e,t,n){var i;return function(r){t.value>=0&&(r||n)&&(t.delta=t.value-(i||0),(t.delta||void 0===i)&&(i=t.value,e(t)))}},v=-1,p=function(){return"hidden"===document.visibilityState?0:1/0},d=function(){f((function(e){var t=e.timeStamp;v=t}),!0)},l=function(){return v<0&&(v=p(),d(),s((function(){setTimeout((function(){v=p(),d()}),0)}))),{get firstHiddenTime(){return v}}},g=function(e,t){var n,i=l(),r=u("FCP"),a=function(e){"first-contentful-paint"===e.name&&(f&&f.disconnect(),e.startTime<i.firstHiddenTime&&(r.value=e.startTime,r.entries.push(e),n(!0)))},o=window.performance&&performance.getEntriesByName&&performance.getEntriesByName("first-contentful-paint")[0],f=o?null:c("paint",a);(o||f)&&(n=m(e,r,t),o&&a(o),s((function(i){r=u("FCP"),n=m(e,r,t),requestAnimationFrame((function(){requestAnimationFrame((function(){r.value=performance.now()-i.timeStamp,n(!0)}))}))})))},h=!1,T=-1,y=function(e,t){h||(g((function(e){T=e.value})),h=!0);var n,i=function(t){T>-1&&e(t)},r=u("CLS",0),a=0,o=[],v=function(e){if(!e.hadRecentInput){var t=o[0],i=o[o.length-1];a&&e.startTime-i.startTime<1e3&&e.startTime-t.startTime<5e3?(a+=e.value,o.push(e)):(a=e.value,o=[e]),a>r.value&&(r.value=a,r.entries=o,n())}},p=c("layout-shift",v);p&&(n=m(i,r,t),f((function(){p.takeRecords().map(v),n(!0)})),s((function(){a=0,T=-1,r=u("CLS",0),n=m(i,r,t)})))},E={passive:!0,capture:!0},w=new Date,L=function(e,t){i||(i=t,r=e,a=new Date,F(removeEventListener),S())},S=function(){if(r>=0&&r<a-w){var e={entryType:"first-input",name:i.type,target:i.target,cancelable:i.cancelable,startTime:i.timeStamp,processingStart:i.timeStamp+r};o.forEach((function(t){t(e)})),o=[]}},b=function(e){if(e.cancelable){var t=(e.timeStamp>1e12?new Date:performance.now())-e.timeStamp;"pointerdown"==e.type?function(e,t){var n=function(){L(e,t),r()},i=function(){r()},r=function(){removeEventListener("pointerup",n,E),removeEventListener("pointercancel",i,E)};addEventListener("pointerup",n,E),addEventListener("pointercancel",i,E)}(t,e):L(t,e)}},F=function(e){["mousedown","keydown","touchstart","pointerdown"].forEach((function(t){return e(t,b,E)}))},C=function(e,t){var n,a=l(),v=u("FID"),p=function(e){e.startTime<a.firstHiddenTime&&(v.value=e.processingStart-e.startTime,v.entries.push(e),n(!0))},d=c("first-input",p);n=m(e,v,t),d&&f((function(){d.takeRecords().map(p),d.disconnect()}),!0),d&&s((function(){var a;v=u("FID"),n=m(e,v,t),o=[],r=-1,i=null,F(addEventListener),a=p,o.push(a),S()}))},k={},P=function(e,t){var n,i=l(),r=u("LCP"),a=function(e){var t=e.startTime;t<i.firstHiddenTime&&(r.value=t,r.entries.push(e),n())},o=c("largest-contentful-paint",a);if(o){n=m(e,r,t);var v=function(){k[r.id]||(o.takeRecords().map(a),o.disconnect(),k[r.id]=!0,n(!0))};["keydown","click"].forEach((function(e){addEventListener(e,v,{once:!0,capture:!0})})),f(v,!0),s((function(i){r=u("LCP"),n=m(e,r,t),requestAnimationFrame((function(){requestAnimationFrame((function(){r.value=performance.now()-i.timeStamp,k[r.id]=!0,n(!0)}))}))}))}},D=function(e){var t,n=u("TTFB");t=function(){try{var t=performance.getEntriesByType("navigation")[0]||function(){var e=performance.timing,t={entryType:"navigation",startTime:0};for(var n in e)"navigationStart"!==n&&"toJSON"!==n&&(t[n]=Math.max(e[n]-e.navigationStart,0));return t}();if(n.value=n.delta=t.responseStart,n.value<0||n.value>performance.now())return;n.entries=[t],e(n)}catch(e){}},"complete"===document.readyState?setTimeout(t,0):addEventListener("load",(function(){return setTimeout(t,0)}))}}}]);

File diff suppressed because one or more lines are too long

View file

@ -1,45 +0,0 @@
/**
* React Router DOM v6.3.0
*
* Copyright (c) Remix Software Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE.md file in the root directory of this source tree.
*
* @license MIT
*/
/**
* React Router v6.3.0
*
* Copyright (c) Remix Software Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE.md file in the root directory of this source tree.
*
* @license MIT
*/
/** @license MUI v5.6.1
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/** @license React v16.13.1
* react-is.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/** @license React v17.0.2
* react-is.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,29 @@
/*!
Copyright (c) 2018 Jed Watson.
Licensed under the MIT License (MIT), see
http://jedwatson.github.io/classnames
*/
/*! regenerator-runtime -- Copyright (c) 2014-present, Facebook, Inc. -- license (MIT): https://github.com/facebook/regenerator/blob/main/LICENSE */
/**
* @remix-run/router v1.0.3
*
* Copyright (c) Remix Software Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE.md file in the root directory of this source tree.
*
* @license MIT
*/
/**
* React Router v6.4.3
*
* Copyright (c) Remix Software Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE.md file in the root directory of this source tree.
*
* @license MIT
*/

View file

@ -337,12 +337,27 @@ func RequestHandler(w http.ResponseWriter, r *http.Request) bool {
case "/delete":
w.Header().Set("Content-Type", "application/json")
snapshotName := r.FormValue("snapshot")
if err := Storage.DeleteSnapshot(snapshotName); err != nil {
err = fmt.Errorf("cannot delete snapshot %q: %w", snapshotName, err)
snapshots, err := Storage.ListSnapshots()
if err != nil {
err = fmt.Errorf("cannot list snapshots: %w", err)
jsonResponseError(w, err)
return true
}
fmt.Fprintf(w, `{"status":"ok"}`)
for _, snName := range snapshots {
if snName == snapshotName {
if err := Storage.DeleteSnapshot(snName); err != nil {
err = fmt.Errorf("cannot delete snapshot %q: %w", snName, err)
jsonResponseError(w, err)
return true
}
fmt.Fprintf(w, `{"status":"ok"}`)
return true
}
}
err = fmt.Errorf("cannot find snapshot %q: %w", snapshotName, err)
jsonResponseError(w, err)
return true
case "/delete_all":
w.Header().Set("Content-Type", "application/json")

View file

@ -1,3 +1,4 @@
// eslint-disable-next-line no-undef
module.exports = {
"env": {
"browser": true,
@ -10,9 +11,7 @@ module.exports = {
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaFeatures": {
"jsx": true
},
"ecmaFeatures": { "jsx": true },
"ecmaVersion": 12,
"sourceType": "module"
},
@ -21,32 +20,15 @@ module.exports = {
"@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": 1000,
"skipBlankLines": true,
"skipComments": true,
}
]
"react/jsx-closing-bracket-location": [1, "line-aligned"],
"react/jsx-max-props-per-line":[1, { "maximum": 1 }],
"react/jsx-first-prop-new-line": [1, "multiline"],
"object-curly-spacing": [2, "always"],
"indent": ["error", 2, { "SwitchCase": 1 }],
"linebreak-style": ["error", "unix"],
"quotes": ["error", "double"],
"semi": ["error", "always"],
"react/prop-types": 0
},
"settings": {
"react": {
@ -56,7 +38,10 @@ module.exports = {
"linkComponents": [
// Components used as alternatives to <a> for linking, eg. <Link to={ url } />
"Hyperlink",
{"name": "Link", "linkAttribute": "to"}
{
"name": "Link", "linkAttribute": "to"
}
]
}
};

View file

@ -56,18 +56,18 @@ VMUI can be used to paste into other applications
#### Options (JSON):
| Name | Default | Description |
|:------------------------|:--------------:|--------------------------------------------------------------------------------------:|
| serverURL | domain name | Can't be changed from the UI |
| inputTenantID | - | If the flag is present, the "Tenant ID" field is displayed |
| headerStyles.background | `#FFFFFF` | Header background color |
| headerStyles.color | `primary.main` | Header font color |
| palette.primary | `#3F51B5` | used to represent primary interface elements for a user |
| palette.secondary | `#F50057` | used to represent secondary interface elements for a user |
| palette.error | `#FF4141` | used to represent interface elements that the user should be made aware of |
| palette.warning | `#FF9800` | used to represent potentially dangerous actions or important messages |
| palette.success | `#4CAF50` | used to indicate the successful completion of an action that user triggered |
| palette.info | `#03A9F4` | used to present information to the user that is neutral and not necessarily important |
| Name | Default | Description |
|:------------------------|:-----------:|--------------------------------------------------------------------------------------:|
| serverURL | domain name | Can't be changed from the UI |
| inputTenantID | - | If the flag is present, the "Tenant ID" field is displayed |
| headerStyles.background | `#FFFFFF` | Header background color |
| headerStyles.color | `#3F51B5` | Header font color |
| palette.primary | `#3F51B5` | used to represent primary interface elements for a user |
| palette.secondary | `#F50057` | used to represent secondary interface elements for a user |
| palette.error | `#FF4141` | used to represent interface elements that the user should be made aware of |
| palette.warning | `#FF9800` | used to represent potentially dangerous actions or important messages |
| palette.success | `#4CAF50` | used to indicate the successful completion of an action that user triggered |
| palette.info | `#03A9F4` | used to present information to the user that is neutral and not necessarily important |
#### JSON example:
```json
@ -75,8 +75,8 @@ VMUI can be used to paste into other applications
"serverURL": "http://localhost:8428",
"inputTenantID": "true",
"headerStyles": {
"background": "#fff",
"color": "primary.main"
"background": "#FFFFFF",
"color": "#538DE8"
},
"palette": {
"primary": "#538DE8",
@ -92,7 +92,7 @@ VMUI can be used to paste into other applications
#### HTML example:
```html
<div id="root" data-params='{"serverURL":"http://localhost:8428","inputTenantID":"true","headerStyles":{"background":"#fff","color":"primary.main"},"palette":{"primary":"#538DE8","secondary":"#F76F8E","error":"#FD151B","warning":"#FFB30F","success":"#7BE622","info":"#0F5BFF"}}'></div>
<div id="root" data-params='{"serverURL":"http://localhost:8428","inputTenantID":"true","headerStyles":{"background":"#FFFFFF","color":"#538DE8"},"palette":{"primary":"#538DE8","secondary":"#F76F8E","error":"#FD151B","warning":"#FFB30F","success":"#7BE622","info":"#0F5BFF"}}'></div>
```

View file

@ -0,0 +1,98 @@
# Test cases
----
**Name:** Force execution of a queries
**Steps:**
1. click to button `Execute query`
2. click to icon `Refresh dashboard`
3. press `enter` on the query field
**Expected Result:**
For each step sends a request and render new data
----
**Name:** Time Range with auto refresh
**Steps:**
1. Set absolute time range
2. Enable auto refresh
3. Change delay auto refresh
4. Disable auto refresh
**Expected Result:**
Time range has not changed
----
**Name:** Query history
**Steps:**
1. Run query one by one: `1`, `2`, `3`
2. Press `Ctrl + ArrowUp`/`Ctrl + ArrowDown` when the query field focus
**Expected Result:**
Query value changes according to execution order (Preserve execution order).
<br/>
`Ctrl + ArrowUp` - set prev value, `Ctrl + ArrowDown` - set next value
----
**Name:** Absolute time range fields
**Steps:**
1. Open `Time range controls`
2. Change `From` or `Until` time value
3. Click to `Apply`
**Expected Result:**
When you change one of the fields, the second does not change
----
**Name:** Auto update after query delete
**Steps:**
1. Add multiple query
2. Execute queries
3. Delete one of the queries
**Expected Result:**
Graph is automatically updated after the query delete
----
**Name:** Query URL params
**Steps:**
1. [Open graph](http://localhost:3000/?g0.range_input=1d&g0.end_input=2022-10-26T14%3A00%3A00&g0.step_input=180&g0.relative_time=none&g0.tab=chart&g0.expr=1&g1.range_input=1d&g1.end_input=2022-10-26T14%3A00%3A00&g1.step_input=180&g1.relative_time=none&g1.tab=chart&g1.expr=2#/) with params:
> ?g0.range_input=1d&g0.end_input=2022-10-26T14%3A00%3A00&g0.step_input=180&g0.relative_time=none&g0.tab=chart&g0.expr=1&g1.range_input=1d&g1.end_input=2022-10-26T14%3A00%3A00&g1.step_input=180&g1.relative_time=none&g1.tab=chart&g1.expr=2#/
**Expected Result:**
Executed two query with params:
```
query: 1 and 2
start: 1666706400
end: 1666792800
step: from "Step value" field (depends on screen width)
```
- Display two queries: `1` and `2`
- Time range from `2022-10-25 16:00:00` to `2022-10-26 16:00:00` (:warning: by UTC +2)
- Display tab `Table`
----
**Name:** Prometheus query URL params
**Steps:**
1. [Open graph](http://localhost:3000/?g0.expr=node_arp_entries&g0.tab=1&g0.stacked=0&g0.range_input=30m&g0.end_input=2021-09-11%2000%3A00%3A00&g0.moment_input=2021-09-11%2000%3A00%3A00&g0.step_input=6&g1.expr=node_cpu_guest_seconds_total&g1.tab=1&g1.stacked=0&g1.range_input=30m&g1.end_input=2022-12-01%2014%3A00%3A00&g1.moment_input=2022-12-01%2014%3A00%3A00&g1.step_input=6) with params:
> ?g0.expr=node_arp_entries&g0.tab=1&g0.stacked=0&g0.range_input=30m&g0.end_input=2021-09-11%2000%3A00%3A00&g0.moment_input=2021-09-11%2000%3A00%3A00&g0.step_input=6&g1.expr=node_cpu_guest_seconds_total&g1.tab=1&g1.stacked=0&g1.range_input=30m&g1.end_input=2022-12-01%2014%3A00%3A00&g1.moment_input=2022-12-01%2014%3A00%3A00&g1.step_input=6
**Expected Result:**
- Display two queries: `node_arp_entries` and `node_cpu_guest_seconds_total`
- Time range from `2021-09-11 01:30:00` to `2021-09-11 02:00:00` (:warning: by UTC +2)
- Display tab `Table`
----

File diff suppressed because it is too large Load diff

View file

@ -4,11 +4,6 @@
"private": true,
"homepage": "./",
"dependencies": {
"@date-io/dayjs": "^2.13.1",
"@emotion/styled": "^11.8.1",
"@mui/icons-material": "^5.6.0",
"@mui/lab": "^5.0.0-alpha.73",
"@mui/material": "^5.5.1",
"@testing-library/jest-dom": "^5.16.2",
"@testing-library/react": "^13.0.0",
"@testing-library/user-event": "^14.0.4",
@ -21,6 +16,7 @@
"@types/qs": "^6.9.7",
"@types/react-router-dom": "^5.3.3",
"@types/webpack-env": "^1.16.3",
"classnames": "^2.3.2",
"dayjs": "^1.11.0",
"lodash.debounce": "^4.0.8",
"lodash.get": "^4.4.2",
@ -29,6 +25,7 @@
"preact": "^10.7.1",
"qs": "^6.10.3",
"react-router-dom": "^6.3.0",
"sass": "^1.56.0",
"typescript": "~4.6.2",
"uplot": "^1.6.19",
"web-vitals": "^2.1.4"

View file

@ -28,7 +28,7 @@
<title>VM UI</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Lato:ital,wght@0,300;0,400;0,700;1,300;1,400;1,700&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono&family=Lato:wght@300;400;700&display=swap" rel="stylesheet">
<script src="%PUBLIC_URL%/dashboards/index.js" type="module"></script>
</head>
<body>

View file

@ -1,5 +1,5 @@
import React from "preact/compat";
import {render, screen} from "@testing-library/react";
import { render, screen } from "@testing-library/react";
import App from "./App";
test("renders header", () => {

View file

@ -1,56 +1,53 @@
import React, {FC} from "preact/compat";
import {HashRouter, Route, Routes} from "react-router-dom";
import {SnackbarProvider} from "./contexts/Snackbar";
import {StateProvider} from "./state/common/StateContext";
import {AuthStateProvider} from "./state/auth/AuthStateContext";
import {GraphStateProvider} from "./state/graph/GraphStateContext";
import {CardinalityStateProvider} from "./state/cardinality/CardinalityStateContext";
import {TopQueriesStateProvider} from "./state/topQueries/TopQueriesStateContext";
import THEME from "./theme/theme";
import { ThemeProvider, StyledEngineProvider } from "@mui/material/styles";
import CssBaseline from "@mui/material/CssBaseline";
import LocalizationProvider from "@mui/lab/LocalizationProvider";
import DayjsUtils from "@date-io/dayjs";
import router from "./router/index";
import CustomPanel from "./components/CustomPanel/CustomPanel";
import React, { FC, useState } from "preact/compat";
import { HashRouter, Route, Routes } from "react-router-dom";
import router from "./router";
import AppContextProvider from "./contexts/AppContextProvider";
import HomeLayout from "./components/Home/HomeLayout";
import DashboardsLayout from "./components/PredefinedPanels/DashboardsLayout";
import CardinalityPanel from "./components/CardinalityPanel/CardinalityPanel";
import TopQueries from "./components/TopQueries/TopQueries";
import CustomPanel from "./pages/CustomPanel";
import DashboardsLayout from "./pages/PredefinedPanels";
import CardinalityPanel from "./pages/CardinalityPanel";
import TopQueries from "./pages/TopQueries";
import ThemeProvider from "./components/Main/ThemeProvider/ThemeProvider";
import Spinner from "./components/Main/Spinner/Spinner";
const App: FC = () => {
const [loadingTheme, setLoadingTheme] = useState(true);
if (loadingTheme) return (
<>
<Spinner/>
<ThemeProvider setLoadingTheme={setLoadingTheme}/>;
</>
);
return <>
<HashRouter>
<CssBaseline /> {/* CSS Baseline: kind of normalize.css made by materialUI team - can be scoped */}
<LocalizationProvider dateAdapter={DayjsUtils}> {/* Allows datepicker to work with DayJS */}
<StyledEngineProvider injectFirst>
<ThemeProvider theme={THEME}> {/* Material UI theme customization */}
<StateProvider> {/* Serialized into query string, common app settings */}
<AuthStateProvider> {/* Auth related info - optionally persisted to Local Storage */}
<GraphStateProvider> {/* Graph settings */}
<CardinalityStateProvider> {/* Cardinality settings */}
<TopQueriesStateProvider> {/* Top Queries settings */}
<SnackbarProvider> {/* Display various snackbars */}
<Routes>
<Route path={"/"} element={<HomeLayout/>}>
<Route path={router.home} element={<CustomPanel/>}/>
<Route path={router.dashboards} element={<DashboardsLayout/>}/>
<Route path={router.cardinality} element={<CardinalityPanel/>} />
<Route path={router.topQueries} element={<TopQueries/>} />
</Route>
</Routes>
</SnackbarProvider>
</TopQueriesStateProvider>
</CardinalityStateProvider>
</GraphStateProvider>
</AuthStateProvider>
</StateProvider>
</ThemeProvider>
</StyledEngineProvider>
</LocalizationProvider>
<AppContextProvider>
<Routes>
<Route
path={"/"}
element={<HomeLayout/>}
>
<Route
path={router.home}
element={<CustomPanel/>}
/>
<Route
path={router.dashboards}
element={<DashboardsLayout/>}
/>
<Route
path={router.cardinality}
element={<CardinalityPanel/>}
/>
<Route
path={router.topQueries}
element={<TopQueries/>}
/>
</Route>
</Routes>
</AppContextProvider>
</HashRouter>
</>;
};

View file

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

View file

@ -1,27 +0,0 @@
import React from "preact/compat";
import { styled } from "@mui/material/styles";
import LinearProgressWithLabel, {linearProgressClasses, LinearProgressProps} from "@mui/material/LinearProgress";
import {Box, Typography} from "@mui/material";
export const BorderLinearProgress = styled(LinearProgressWithLabel)(({ theme }) => ({
height: 20,
borderRadius: 5,
[`&.${linearProgressClasses.colorPrimary}`]: {
backgroundColor: theme.palette.grey[theme.palette.mode === "light" ? 200 : 800],
},
[`& .${linearProgressClasses.bar}`]: {
borderRadius: 5,
backgroundColor: theme.palette.mode === "light" ? "#1a90ff" : "#308fe8",
},
}));
export const BorderLinearProgressWithLabel = (props: LinearProgressProps & { value: number }) => (
<Box sx={{ display: "flex", alignItems: "center" }}>
<Box sx={{ width: "100%", mr: 1 }}>
<BorderLinearProgress variant="determinate" {...props} />
</Box>
<Box sx={{ minWidth: 35 }}>
<Typography variant="body2" color="text.secondary">{`${props.value.toFixed(2)}%`}</Typography>
</Box>
</Box>
);

View file

@ -1,103 +0,0 @@
import React, {ChangeEvent, FC} from "react";
import Box from "@mui/material/Box";
import QueryEditor from "../../CustomPanel/Configurator/Query/QueryEditor";
import Tooltip from "@mui/material/Tooltip";
import IconButton from "@mui/material/IconButton";
import PlayCircleOutlineIcon from "@mui/icons-material/PlayCircleOutline";
import {useFetchQueryOptions} from "../../../hooks/useFetchQueryOptions";
import {useAppDispatch, useAppState} from "../../../state/common/StateContext";
import FormControlLabel from "@mui/material/FormControlLabel";
import BasicSwitch from "../../../theme/switch";
import {saveToStorage} from "../../../utils/storage";
import TextField from "@mui/material/TextField";
import {ErrorTypes} from "../../../types";
export interface CardinalityConfiguratorProps {
onSetHistory: (step: number, index: number) => void;
onSetQuery: (query: string, index: number) => void;
onRunQuery: () => void;
onTopNChange: (e: ChangeEvent<HTMLTextAreaElement|HTMLInputElement>) => void;
onFocusLabelChange: (e: ChangeEvent<HTMLTextAreaElement|HTMLInputElement>) => void;
query: string;
topN: number;
error?: ErrorTypes | string;
totalSeries: number;
totalLabelValuePairs: number;
date: string | null;
match: string | null;
focusLabel: string | null;
}
const CardinalityConfigurator: FC<CardinalityConfiguratorProps> = ({
topN,
error,
query,
onSetHistory,
onRunQuery,
onSetQuery,
onTopNChange,
onFocusLabelChange,
totalSeries,
totalLabelValuePairs,
date,
match,
focusLabel
}) => {
const dispatch = useAppDispatch();
const {queryControls: {autocomplete}} = useAppState();
const {queryOptions} = useFetchQueryOptions();
const onChangeAutocomplete = () => {
dispatch({type: "TOGGLE_AUTOCOMPLETE"});
saveToStorage("AUTOCOMPLETE", !autocomplete);
};
return <Box boxShadow="rgba(99, 99, 99, 0.2) 0px 2px 8px 0px;" p={4} pb={2} mb={2}>
<Box>
<Box display="grid" gridTemplateColumns="1fr auto auto auto auto" gap="4px" width="100%" mb={4}>
<QueryEditor
query={query} index={0} autocomplete={autocomplete} queryOptions={queryOptions}
error={error} setHistoryIndex={onSetHistory} runQuery={onRunQuery} setQuery={onSetQuery}
label={"Time series selector"}
/>
<Box mr={2}>
<TextField
label="Number of entries per table"
type="number"
size="medium"
variant="outlined"
value={topN}
error={topN < 1}
helperText={topN < 1 ? "Number must be bigger than zero" : " "}
onChange={onTopNChange}/>
</Box>
<Box mr={2}>
<TextField
label="Focus label"
type="text"
size="medium"
variant="outlined"
value={focusLabel}
onChange={onFocusLabelChange} />
</Box>
<Box>
<FormControlLabel label="Autocomplete"
control={<BasicSwitch checked={autocomplete} onChange={onChangeAutocomplete}/>}
/>
</Box>
<Tooltip title="Execute Query">
<IconButton onClick={onRunQuery} sx={{height: "49px", width: "49px"}}>
<PlayCircleOutlineIcon/>
</IconButton>
</Tooltip>
</Box>
</Box>
<Box>
Analyzed <b>{totalSeries}</b> series with <b>{totalLabelValuePairs}</b> &quot;label=value&quot; pairs
at <b>{date}</b> {match && <span>for series selector <b>{match}</b></span>}.
Show top {topN} entries per table.
</Box>
</Box>;
};
export default CardinalityConfigurator;

View file

@ -1,119 +0,0 @@
import React, {ChangeEvent, FC, useState} from "react";
import {SyntheticEvent} from "react";
import {Alert} from "@mui/material";
import {useFetchQuery} from "../../hooks/useCardinalityFetch";
import {queryUpdater} from "./helpers";
import {Data} from "../Table/types";
import CardinalityConfigurator from "./CardinalityConfigurator/CardinalityConfigurator";
import Spinner from "../common/Spinner";
import {useCardinalityDispatch, useCardinalityState} from "../../state/cardinality/CardinalityStateContext";
import MetricsContent from "./MetricsContent/MetricsContent";
import {DefaultActiveTab, Tabs, TSDBStatus, Containers} from "./types";
const spinnerContainerStyles = (height: string) => {
return {
width: "100%",
maxWidth: "100%",
position: "absolute",
height: height ?? "50%",
background: "rgba(255, 255, 255, 0.7)",
pointerEvents: "none",
zIndex: 1000,
};
};
const CardinalityPanel: FC = () => {
const cardinalityDispatch = useCardinalityDispatch();
const {topN, match, date, focusLabel} = useCardinalityState();
const configError = "";
const [query, setQuery] = useState(match || "");
const [queryHistoryIndex, setQueryHistoryIndex] = useState(0);
const [queryHistory, setQueryHistory] = useState<string[]>([]);
const onRunQuery = () => {
setQueryHistory(prev => [...prev, query]);
setQueryHistoryIndex(prev => prev + 1);
cardinalityDispatch({type: "SET_MATCH", payload: query});
cardinalityDispatch({type: "RUN_QUERY"});
};
const onSetQuery = (query: string) => {
setQuery(query);
};
const onSetHistory = (step: number) => {
const newIndexHistory = queryHistoryIndex + step;
if (newIndexHistory < 0 || newIndexHistory >= queryHistory.length) return;
setQueryHistoryIndex(newIndexHistory);
setQuery(queryHistory[newIndexHistory]);
};
const onTopNChange = (e: ChangeEvent<HTMLTextAreaElement|HTMLInputElement>) => {
cardinalityDispatch({type: "SET_TOP_N", payload: +e.target.value});
};
const onFocusLabelChange = (e: ChangeEvent<HTMLTextAreaElement|HTMLInputElement>) => {
cardinalityDispatch({type: "SET_FOCUS_LABEL", payload: e.target.value});
};
const {isLoading, appConfigurator, error} = useFetchQuery();
const [stateTabs, setTab] = useState(appConfigurator.defaultState.defaultActiveTab);
const {tsdbStatusData, defaultState, tablesHeaders} = appConfigurator;
const handleTabChange = (e: SyntheticEvent, newValue: number) => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
setTab({...stateTabs, [e.target.id]: newValue});
};
const handleFilterClick = (key: string) => (e: SyntheticEvent) => {
const name = e.currentTarget.id;
const query = queryUpdater[key](focusLabel, name);
setQuery(query);
setQueryHistory(prev => [...prev, query]);
setQueryHistoryIndex(prev => prev + 1);
cardinalityDispatch({type: "SET_MATCH", payload: query});
let newFocusLabel = "";
if (key === "labelValueCountByLabelName" || key == "seriesCountByLabelName") {
newFocusLabel = name;
}
cardinalityDispatch({type: "SET_FOCUS_LABEL", payload: newFocusLabel});
cardinalityDispatch({type: "RUN_QUERY"});
};
return (
<>
{isLoading && <Spinner
isLoading={isLoading}
height={"800px"}
containerStyles={spinnerContainerStyles("100%")}
title={<Alert color="error" severity="error" sx={{whiteSpace: "pre-wrap", mt: 2}}>
Please wait while cardinality stats is calculated. This may take some time if the db contains big number of time series
</Alert>}
/>}
<CardinalityConfigurator error={configError} query={query} onRunQuery={onRunQuery} onSetQuery={onSetQuery}
onSetHistory={onSetHistory} onTopNChange={onTopNChange} topN={topN} date={date} match={match}
totalSeries={tsdbStatusData.totalSeries} totalLabelValuePairs={tsdbStatusData.totalLabelValuePairs}
focusLabel={focusLabel} onFocusLabelChange={onFocusLabelChange}
/>
{error && <Alert color="error" severity="error" sx={{whiteSpace: "pre-wrap", mt: 2}}>{error}</Alert>}
{appConfigurator.keys(focusLabel).map((keyName) => (
<MetricsContent
key={keyName}
sectionTitle={appConfigurator.sectionsTitles(focusLabel)[keyName]}
activeTab={stateTabs[keyName as keyof DefaultActiveTab]}
rows={tsdbStatusData[keyName as keyof TSDBStatus] as unknown as Data[]}
onChange={handleTabChange}
onActionClick={handleFilterClick(keyName)}
tabs={defaultState.tabs[keyName as keyof Tabs]}
chartContainer={defaultState.containerRefs[keyName as keyof Containers<HTMLDivElement>]}
totalSeries={appConfigurator.totalSeries(keyName)}
tabId={keyName}
tableHeaderCells={tablesHeaders[keyName]}
/>
))}
</>
);
};
export default CardinalityPanel;

View file

@ -1,96 +0,0 @@
import {FC} from "react";
import {Box, Grid, Tab, Tabs, Typography} from "@mui/material";
import TableChartIcon from "@mui/icons-material/TableChart";
import ShowChartIcon from "@mui/icons-material/ShowChart";
import TabPanel from "../../TabPanel/TabPanel";
import EnhancedTable from "../../Table/Table";
import TableCells from "../TableCells/TableCells";
import BarChart from "../../BarChart/BarChart";
import {barOptions} from "../../BarChart/consts";
import React, {SyntheticEvent} from "react";
import {Data, HeadCell} from "../../Table/types";
import {MutableRef} from "preact/hooks";
interface MetricsProperties {
rows: Data[];
activeTab: number;
onChange: (e: SyntheticEvent, newValue: number) => void;
onActionClick: (e: SyntheticEvent) => void;
tabs: string[];
chartContainer: MutableRef<HTMLDivElement> | undefined;
totalSeries: number,
tabId: string;
sectionTitle: string;
tableHeaderCells: HeadCell[];
}
const MetricsContent: FC<MetricsProperties> = ({
rows,
activeTab,
onChange,
tabs,
chartContainer,
totalSeries,
tabId,
onActionClick,
sectionTitle,
tableHeaderCells,
}) => {
const tableCells = (row: Data) => (
<TableCells
row={row}
totalSeries={totalSeries}
onActionClick={onActionClick}
/>
);
return (
<>
<Grid container spacing={2} sx={{px: 2}}>
<Grid item xs={12} md={12} lg={12}>
<Typography gutterBottom variant="h5" component="h5">{sectionTitle}</Typography>
<Box sx={{ borderBottom: 1, borderColor: "divider" }}>
<Tabs
value={activeTab}
onChange={onChange} aria-label="basic tabs example">
{tabs.map((title: string, i: number) =>
<Tab
key={title}
label={title}
aria-controls={`tabpanel-${i}`}
id={tabId}
iconPosition={"start"}
icon={ i === 0 ? <TableChartIcon /> : <ShowChartIcon /> } />
)}
</Tabs>
</Box>
{tabs.map((_,idx) =>
<div
ref={chartContainer}
style={{width: "100%", paddingRight: idx !== 0 ? "40px" : 0 }} key={`chart-${idx}`}>
<TabPanel value={activeTab} index={idx}>
{activeTab === 0 ? <EnhancedTable
rows={rows}
headerCells={tableHeaderCells}
defaultSortColumn={"value"}
tableCells={tableCells}
/>: <BarChart
data={[
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
rows.map((v) => v.name),
rows.map((v) => v.value),
rows.map((_, i) => i % 12 == 0 ? 1 : i % 10 == 0 ? 2 : 0),
]}
container={chartContainer?.current || null}
configs={barOptions}
/>}
</TabPanel>
</div>
)}
</Grid>
</Grid>
</>
);
};
export default MetricsContent;

View file

@ -1,39 +0,0 @@
import {SyntheticEvent} from "react";
import React, {FC} from "preact/compat";
import {TableCell, ButtonGroup} from "@mui/material";
import {Data} from "../../Table/types";
import {BorderLinearProgressWithLabel} from "../../BorderLineProgress/BorderLinearProgress";
import IconButton from "@mui/material/IconButton";
import PlayCircleOutlineIcon from "@mui/icons-material/PlayCircleOutline";
import Tooltip from "@mui/material/Tooltip";
interface CardinalityTableCells {
row: Data,
totalSeries: number;
onActionClick: (e: SyntheticEvent) => void;
}
const TableCells: FC<CardinalityTableCells> = ({ row, totalSeries, onActionClick }) => {
const progress = totalSeries > 0 ? row.value / totalSeries * 100 : -1;
return <>
<TableCell key={row.name}>{row.name}</TableCell>
<TableCell key={row.value}>{row.value}</TableCell>
{progress > 0 ? <TableCell key={row.progressValue}>
<BorderLinearProgressWithLabel variant="determinate" value={progress} />
</TableCell> : null}
<TableCell key={"action"}>
<ButtonGroup variant="contained">
<Tooltip title={`Filter by ${row.name}`}>
<IconButton
id={row.name}
onClick={onActionClick}
sx={{height: "20px", width: "20px"}}>
<PlayCircleOutlineIcon/>
</IconButton>
</Tooltip>
</ButtonGroup>
</TableCell>
</>;
};
export default TableCells;

View file

@ -1,15 +1,15 @@
import React, {FC, useEffect, useRef, useState} from "preact/compat";
import uPlot, {Options as uPlotOptions} from "uplot";
import useResize from "../../hooks/useResize";
import {BarChartProps} from "./types";
import React, { FC, useEffect, useRef, useState } from "preact/compat";
import uPlot, { Options as uPlotOptions } from "uplot";
import useResize from "../../../hooks/useResize";
import { BarChartProps } from "./types";
import "./style.scss";
const BarChart: FC<BarChartProps> = ({
data,
container,
configs}) => {
configs }) => {
const uPlotRef = useRef<HTMLDivElement>(null);
const [isPanning] = useState(false);
const [uPlotInst, setUPlotInst] = useState<uPlot>();
const layoutSize = useResize(container);
@ -21,7 +21,6 @@ const BarChart: FC<BarChartProps> = ({
const updateChart = (): void => {
if (!uPlotInst) return;
uPlotInst.setData(data);
if (!isPanning) uPlotInst.redraw();
};
useEffect(() => {
@ -33,7 +32,7 @@ const BarChart: FC<BarChartProps> = ({
useEffect(() => updateChart(), [data]);
return <div style={{pointerEvents: isPanning ? "none" : "auto", height: "100%"}}>
return <div style={{ height: "100%" }}>
<div ref={uPlotRef}/>
</div>;
};

View file

@ -1,7 +1,7 @@
import {seriesBarsPlugin} from "../../utils/uplot/plugin";
import {barDisp, getBarSeries} from "../../utils/uplot/series";
import {Fill, Stroke} from "../../utils/uplot/types";
import {PaddingSide, Series} from "uplot";
import { seriesBarsPlugin } from "../../../utils/uplot/plugin";
import { barDisp, getBarSeries } from "../../../utils/uplot/series";
import { Fill, Stroke } from "../../../utils/uplot/types";
import { PaddingSide, Series } from "uplot";
const stroke: Stroke = {
@ -36,14 +36,14 @@ export const barOptions = {
const idxs = u.legend.idxs || [];
if (u.data === null || idxs.length === 0)
return {"Name": null, "Value": null,};
return { "Name": null, "Value": null, };
const dataIdx = idxs[seriesIdx] || 0;
const build = u.data[0][dataIdx];
const duration = u.data[seriesIdx][dataIdx];
return {"Name": build, "Value": duration};
return { "Name": build, "Value": duration };
}
},
] as Series[],

View file

@ -0,0 +1,36 @@
@use "src/styles/variables" as *;
.u-legend {
font-family: $font-family-global;
font-size: $font-size-medium;
color: $color-text;
.u-thead {
display: none;
}
.u-series {
display: flex;
gap: $padding-small;
th {
display: none;
}
td {
&:nth-child(2) {
&:after {
content: ':';
margin-left: $padding-small;
}
}
}
.u-value {
display: block;
padding: 0;
text-align: left;
}
}
}

View file

@ -1,4 +1,4 @@
import {AlignedData as uPlotData, Options as uPlotOptions} from "uplot";
import { AlignedData as uPlotData, Options as uPlotOptions } from "uplot";
export interface BarChartProps {
data: uPlotData;

View file

@ -0,0 +1,41 @@
import React, { FC, useMemo } from "preact/compat";
import { LegendItemType } from "../../../utils/uplot/types";
import LegendItem from "./LegendItem/LegendItem";
import "./style.scss";
interface LegendProps {
labels: LegendItemType[];
query: string[];
onChange: (item: LegendItemType, metaKey: boolean) => void;
}
const Legend: FC<LegendProps> = ({ labels, query, onChange }) => {
const groups = useMemo(() => {
return Array.from(new Set(labels.map(l => l.group)));
}, [labels]);
return <>
<div className="vm-legend">
{groups.map((group) => <div
className="vm-legend-group"
key={group}
>
<div className="vm-legend-group-title">
<span className="vm-legend-group-title__count">Query {group}: </span>
<span className="vm-legend-group-title__query">{query[group - 1]}</span>
</div>
<div>
{labels.filter(l => l.group === group).map((legendItem: LegendItemType) =>
<LegendItem
key={legendItem.label}
legend={legendItem}
onChange={onChange}
/>
)}
</div>
</div>)}
</div>
</>;
};
export default Legend;

View file

@ -0,0 +1,75 @@
import React, { FC, useState, useMemo } from "preact/compat";
import { MouseEvent } from "react";
import { LegendItemType } from "../../../../utils/uplot/types";
import { getLegendLabel } from "../../../../utils/uplot/helpers";
import "./style.scss";
import classNames from "classnames";
import Tooltip from "../../../Main/Tooltip/Tooltip";
import { getFreeFields } from "./helpers";
interface LegendItemProps {
legend: LegendItemType;
onChange: (item: LegendItemType, metaKey: boolean) => void;
}
const LegendItem: FC<LegendItemProps> = ({ legend, onChange }) => {
const [copiedValue, setCopiedValue] = useState("");
const freeFormFields = useMemo(() => getFreeFields(legend), [legend]);
const handleClickFreeField = async (val: string, id: string) => {
await navigator.clipboard.writeText(val);
setCopiedValue(id);
setTimeout(() => setCopiedValue(""), 2000);
};
const createHandlerClick = (legend: LegendItemType) => (e: MouseEvent<HTMLDivElement>) => {
onChange(legend, e.ctrlKey || e.metaKey);
};
const createHandlerCopy = (freeField: string, id: string) => (e: MouseEvent<HTMLDivElement>) => {
e.stopPropagation();
handleClickFreeField(freeField, id);
};
return (
<div
className={classNames({
"vm-legend-item": true,
"vm-legend-item_hide": !legend.checked,
})}
onClick={createHandlerClick(legend)}
>
<div
className="vm-legend-item__marker"
style={{ backgroundColor: legend.color }}
/>
<div className="vm-legend-item-info">
<span className="vm-legend-item-info__label">
{getLegendLabel(legend.label)}
</span>
&#160;&#123;
{freeFormFields.map(f => (
<Tooltip
key={f.id}
open={copiedValue === f.id}
title={"Copied!"}
placement="top-center"
>
<span
className="vm-legend-item-info__free-fields"
key={f.key}
onClick={createHandlerCopy(f.freeField, f.id)}
>
{f.freeField}
</span>
</Tooltip>
))}
&#125;
</div>
</div>
);
};
export default LegendItem;

View file

@ -0,0 +1,16 @@
import { LegendItemType } from "../../../../utils/uplot/types";
export const getFreeFields = (legend: LegendItemType) => {
const keys = Object.keys(legend.freeFormFields).filter(f => f !== "__name__");
return keys.map(f => {
const freeField = `${f}="${legend.freeFormFields[f]}"`;
const id = `${legend.label}.${freeField}`;
return {
id,
freeField,
key: f
};
});
};

View file

@ -0,0 +1,51 @@
@use "src/styles/variables" as *;
.vm-legend-item {
display: grid;
grid-template-columns: auto auto;
grid-gap: $padding-small;
align-items: start;
justify-content: start;
padding: $padding-small $padding-large $padding-small $padding-small;
background-color: $color-background-block;
cursor: pointer;
transition: 0.2s ease;
&:hover {
background-color: rgba(0, 0, 0, 0.1);
}
&_hide {
text-decoration: line-through;
opacity: 0.5;
}
&__marker {
width: 14px;
height: 14px;
box-sizing: border-box;
transition: 0.2s ease;
border-radius: 2px;
}
&-info {
font-weight: normal;
&__label {
}
&__free-fields {
padding: 3px;
cursor: pointer;
&:hover {
text-decoration: underline;
}
&:not(:last-child):after {
content: ",";
}
}
}
}

View file

@ -0,0 +1,30 @@
@use "src/styles/variables" as *;
.vm-legend {
position: relative;
display: flex;
flex-wrap: wrap;
margin-top: $padding-medium;
cursor: default;
&-group {
min-width: 23%;
margin: 0 $padding-global $padding-global 0;
&-title {
display: flex;
align-items: center;
padding: 0 $padding-small $padding-small;
margin-bottom: 1px;
border-bottom: $border-divider;
&__count {
font-weight: bold;
margin-right: $padding-small;
}
&__query {
}
}
}
}

View file

@ -1,17 +1,18 @@
import React, {FC, useCallback, useEffect, useRef, useState} from "preact/compat";
import uPlot, {AlignedData as uPlotData, Options as uPlotOptions, Series as uPlotSeries, Range, Scales, Scale} from "uplot";
import {defaultOptions} from "../../utils/uplot/helpers";
import {dragChart} from "../../utils/uplot/events";
import {getAxes, getMinMaxBuffer} from "../../utils/uplot/axes";
import {setTooltip} from "../../utils/uplot/tooltip";
import {MetricResult} from "../../api/types";
import {limitsDurations} from "../../utils/time";
import React, { FC, useCallback, useEffect, useRef, useState } from "preact/compat";
import uPlot, { AlignedData as uPlotData, Options as uPlotOptions, Series as uPlotSeries, Range, Scales, Scale } from "uplot";
import { defaultOptions } from "../../../utils/uplot/helpers";
import { dragChart } from "../../../utils/uplot/events";
import { getAxes, getMinMaxBuffer } from "../../../utils/uplot/axes";
import { setTooltip } from "../../../utils/uplot/tooltip";
import { MetricResult } from "../../../api/types";
import { limitsDurations } from "../../../utils/time";
import throttle from "lodash.throttle";
import useResize from "../../../hooks/useResize";
import { TimeParams } from "../../../types";
import { YaxisState } from "../../../state/graph/reducer";
import "uplot/dist/uPlot.min.css";
import "./tooltip.css";
import useResize from "../../hooks/useResize";
import {TimeParams} from "../../types";
import {YaxisState} from "../../state/graph/reducer";
import "./style.scss";
import classNames from "classnames";
export interface LineChartProps {
metrics: MetricResult[];
@ -20,35 +21,35 @@ export interface LineChartProps {
yaxis: YaxisState;
series: uPlotSeries[];
unit?: string;
setPeriod: ({from, to}: {from: Date, to: Date}) => void;
setPeriod: ({ from, to }: {from: Date, to: Date}) => void;
container: HTMLDivElement | null
}
enum typeChartUpdate {xRange = "xRange", yRange = "yRange", data = "data"}
const LineChart: FC<LineChartProps> = ({data, series, metrics = [],
period, yaxis, unit, setPeriod, container}) => {
const LineChart: FC<LineChartProps> = ({ data, series, metrics = [],
period, yaxis, unit, setPeriod, container }) => {
const uPlotRef = useRef<HTMLDivElement>(null);
const [isPanning, setPanning] = useState(false);
const [xRange, setXRange] = useState({min: period.start, max: period.end});
const [xRange, setXRange] = useState({ min: period.start, max: period.end });
const [uPlotInst, setUPlotInst] = useState<uPlot>();
const layoutSize = useResize(container);
const tooltip = document.createElement("div");
tooltip.className = "u-tooltip";
const tooltipIdx: {seriesIdx: number | null, dataIdx: number | undefined} = {seriesIdx: null, dataIdx: undefined};
const tooltipOffset = {left: 0, top: 0};
const tooltipIdx: {seriesIdx: number | null, dataIdx: number | undefined} = { seriesIdx: null, dataIdx: undefined };
const tooltipOffset = { left: 0, top: 0 };
const setScale = ({min, max}: { min: number, max: number }): void => {
setPeriod({from: new Date(min * 1000), to: new Date(max * 1000)});
const setScale = ({ min, max }: { min: number, max: number }): void => {
setPeriod({ from: new Date(min * 1000), to: new Date(max * 1000) });
};
const throttledSetScale = useCallback(throttle(setScale, 500), []);
const setPlotScale = ({u, min, max}: { u: uPlot, min: number, max: number }) => {
const setPlotScale = ({ u, min, max }: { u: uPlot, min: number, max: number }) => {
const delta = (max - min) * 1000;
if ((delta < limitsDurations.min) || (delta > limitsDurations.max)) return;
u.setScale("x", {min, max});
setXRange({min, max});
throttledSetScale({min, max});
u.setScale("x", { min, max });
setXRange({ min, max });
throttledSetScale({ min, max });
};
const onReadyChart = (u: uPlot) => {
@ -57,31 +58,31 @@ const LineChart: FC<LineChartProps> = ({data, series, metrics = [],
tooltipOffset.top = parseFloat(u.over.style.top);
u.root.querySelector(".u-wrap")?.appendChild(tooltip);
u.over.addEventListener("mousedown", e => {
const {ctrlKey, metaKey} = e;
const { ctrlKey, metaKey } = e;
const leftClick = e.button === 0;
const leftClickWithMeta = leftClick && (ctrlKey || metaKey);
if (leftClickWithMeta) {
// drag pan
dragChart({u, e, setPanning, setPlotScale, factor});
dragChart({ u, e, setPanning, setPlotScale, factor });
}
});
u.over.addEventListener("wheel", e => {
if (!e.ctrlKey && !e.metaKey) return;
e.preventDefault();
const {width} = u.over.getBoundingClientRect();
const { width } = u.over.getBoundingClientRect();
const zoomPos = u.cursor.left && u.cursor.left > 0 ? u.cursor.left : 0;
const xVal = u.posToVal(zoomPos, "x");
const oxRange = (u.scales.x.max || 0) - (u.scales.x.min || 0);
const nxRange = e.deltaY < 0 ? oxRange * factor : oxRange / factor;
const min = xVal - (zoomPos / width) * nxRange;
const max = min + nxRange;
u.batch(() => setPlotScale({u, min, max}));
u.batch(() => setPlotScale({ u, min, max }));
});
};
const handleKeyDown = (e: KeyboardEvent) => {
const {target, ctrlKey, metaKey, key} = e;
const { target, ctrlKey, metaKey, key } = e;
const isInput = target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement;
if (!uPlotInst || isInput) return;
const minus = key === "-";
@ -101,7 +102,7 @@ const LineChart: FC<LineChartProps> = ({data, series, metrics = [],
if (tooltipIdx.dataIdx === u.cursor.idx) return;
tooltipIdx.dataIdx = u.cursor.idx || 0;
if (tooltipIdx.seriesIdx !== null && tooltipIdx.dataIdx !== undefined) {
setTooltip({u, tooltipIdx, metrics, series, tooltip, tooltipOffset, unit});
setTooltip({ u, tooltipIdx, metrics, series, tooltip, tooltipOffset, unit });
}
};
@ -109,7 +110,7 @@ const LineChart: FC<LineChartProps> = ({data, series, metrics = [],
if (tooltipIdx.seriesIdx === sidx) return;
tooltipIdx.seriesIdx = sidx;
sidx && tooltipIdx.dataIdx !== undefined
? setTooltip({u, tooltipIdx, metrics, series, tooltip, tooltipOffset, unit})
? setTooltip({ u, tooltipIdx, metrics, series, tooltip, tooltipOffset, unit })
: tooltip.style.display = "none";
};
const getRangeX = (): Range.MinMax => [xRange.min, xRange.max];
@ -119,10 +120,10 @@ const LineChart: FC<LineChartProps> = ({data, series, metrics = [],
};
const getScales = (): Scales => {
const scales: { [key: string]: { range: Scale.Range } } = {x: {range: getRangeX}};
const scales: { [key: string]: { range: Scale.Range } } = { x: { range: getRangeX } };
const ranges = Object.keys(yaxis.limits.range);
(ranges.length ? ranges : ["1"]).forEach(axis => {
scales[axis] = {range: (u: uPlot, min = 0, max = 1) => getRangeY(u, min, max, axis)};
scales[axis] = { range: (u: uPlot, min = 0, max = 1) => getRangeY(u, min, max, axis) };
});
return scales;
};
@ -130,16 +131,16 @@ const LineChart: FC<LineChartProps> = ({data, series, metrics = [],
const options: uPlotOptions = {
...defaultOptions,
series,
axes: getAxes( [{}, {scale: "1"}], unit),
scales: {...getScales()},
axes: getAxes( [{}, { scale: "1" }], unit),
scales: { ...getScales() },
width: layoutSize.width || 400,
plugins: [{hooks: {ready: onReadyChart, setCursor, setSeries: seriesFocus}}],
plugins: [{ hooks: { ready: onReadyChart, setCursor, setSeries: seriesFocus } }],
hooks: {
setSelect: [
(u) => {
const min = u.posToVal(u.select.left, "x");
const max = u.posToVal(u.select.left + u.select.width, "x");
setPlotScale({u, min, max});
setPlotScale({ u, min, max });
}
]
}
@ -164,13 +165,13 @@ const LineChart: FC<LineChartProps> = ({data, series, metrics = [],
if (!isPanning) uPlotInst.redraw();
};
useEffect(() => setXRange({min: period.start, max: period.end}), [period]);
useEffect(() => setXRange({ min: period.start, max: period.end }), [period]);
useEffect(() => {
if (!uPlotRef.current) return;
const u = new uPlot(options, data, uPlotRef.current);
setUPlotInst(u);
setXRange({min: period.start, max: period.end});
setXRange({ min: period.start, max: period.end });
return u.destroy;
}, [uPlotRef.current, series, layoutSize]);
@ -186,9 +187,16 @@ const LineChart: FC<LineChartProps> = ({data, series, metrics = [],
useEffect(() => updateChart(typeChartUpdate.xRange), [xRange]);
useEffect(() => updateChart(typeChartUpdate.yRange), [yaxis]);
return <div style={{pointerEvents: isPanning ? "none" : "auto", height: "500px"}}>
<div ref={uPlotRef}/>
</div>;
return (
<div
className={classNames({
"vm-line-chart": true,
"vm-line-chart_panning": isPanning
})}
>
<div ref={uPlotRef}/>
</div>
);
};
export default LineChart;

View file

@ -0,0 +1,51 @@
@use "src/styles/variables" as *;
.vm-line-chart {
height: 500px;
pointer-events: auto;
&_panning {
pointer-events: none;
}
}
.u-tooltip {
position: absolute;
display: none;
grid-gap: $padding-global;
max-width: 300px;
padding: $padding-small;
border-radius: $border-radius-medium;
background: $color-background-tooltip;
color: $color-white;
font-size: $font-size-small;
font-weight: normal;
line-height: 1.4;
word-wrap: break-word;
font-family: monospace;
pointer-events: none;
z-index: 100;
&-data {
display: flex;
flex-wrap: wrap;
align-items: center;
line-height: 150%;
&__value {
padding: 4px;
font-weight: bold;
}
}
&__info {
display: grid;
grid-gap: 4px;
}
&__marker {
width: 12px;
height: 12px;
margin-right: $padding-small;
}
}

View file

@ -0,0 +1,71 @@
import React, { FC } from "preact/compat";
import StepConfigurator from "../StepConfigurator/StepConfigurator";
import { useGraphDispatch } from "../../../state/graph/GraphStateContext";
import { getAppModeParams } from "../../../utils/app-mode";
import TenantsConfiguration from "../TenantsConfiguration/TenantsConfiguration";
import { useCustomPanelDispatch, useCustomPanelState } from "../../../state/customPanel/CustomPanelStateContext";
import { useTimeState } from "../../../state/time/TimeStateContext";
import { useQueryDispatch, useQueryState } from "../../../state/query/QueryStateContext";
import "./style.scss";
import Switch from "../../Main/Switch/Switch";
const AdditionalSettings: FC = () => {
const graphDispatch = useGraphDispatch();
const { inputTenantID } = getAppModeParams();
const { autocomplete } = useQueryState();
const queryDispatch = useQueryDispatch();
const { nocache, isTracingEnabled } = useCustomPanelState();
const customPanelDispatch = useCustomPanelDispatch();
const { period: { step } } = useTimeState();
const onChangeCache = () => {
customPanelDispatch({ type: "TOGGLE_NO_CACHE" });
};
const onChangeQueryTracing = () => {
customPanelDispatch({ type: "TOGGLE_QUERY_TRACING" });
};
const onChangeAutocomplete = () => {
queryDispatch({ type: "TOGGLE_AUTOCOMPLETE" });
};
const onChangeStep = (value: number) => {
graphDispatch({ type: "SET_CUSTOM_STEP", payload: value });
};
return <div className="vm-additional-settings">
<Switch
label={"Autocomplete"}
value={autocomplete}
onChange={onChangeAutocomplete}
/>
<Switch
label={"Disable cache"}
value={nocache}
onChange={onChangeCache}
/>
<Switch
label={"Trace query"}
value={isTracingEnabled}
onChange={onChangeQueryTracing}
/>
<div className="vm-additional-settings__input">
<StepConfigurator
defaultStep={step}
setStep={onChangeStep}
/>
</div>
{!!inputTenantID && (
<div className="vm-additional-settings__input">
<TenantsConfiguration/>
</div>
)}
</div>;
};
export default AdditionalSettings;

View file

@ -0,0 +1,14 @@
@use "src/styles/variables" as *;
.vm-additional-settings {
display: inline-flex;
align-items: center;
justify-content: flex-start;
flex-wrap: wrap;
gap: 24px;
&__input {
flex-basis: 160px;
margin-bottom: -6px;
}
}

View file

@ -0,0 +1,48 @@
import React, { FC, useMemo, useRef } from "preact/compat";
import { useCardinalityState, useCardinalityDispatch } from "../../../state/cardinality/CardinalityStateContext";
import dayjs from "dayjs";
import Button from "../../Main/Button/Button";
import { CalendarIcon } from "../../Main/Icons";
import Tooltip from "../../Main/Tooltip/Tooltip";
import { getAppModeEnable } from "../../../utils/app-mode";
import { DATE_FORMAT } from "../../../constants/date";
import DatePicker from "../../Main/DatePicker/DatePicker";
const CardinalityDatePicker: FC = () => {
const appModeEnable = getAppModeEnable();
const buttonRef = useRef<HTMLDivElement>(null);
const { date } = useCardinalityState();
const cardinalityDispatch = useCardinalityDispatch();
const dateFormatted = useMemo(() => dayjs(date).format(DATE_FORMAT), [date]);
const handleChangeDate = (val: string) => {
cardinalityDispatch({ type: "SET_DATE", payload: val });
};
return (
<div>
<div ref={buttonRef}>
<Tooltip title="Date control">
<Button
className={appModeEnable ? "" : "vm-header-button"}
variant="contained"
color="primary"
startIcon={<CalendarIcon/>}
>
{dateFormatted}
</Button>
</Tooltip>
</div>
<DatePicker
date={date || ""}
format={DATE_FORMAT}
onChange={handleChangeDate}
targetRef={buttonRef}
/>
</div>
);
};
export default CardinalityDatePicker;

View file

@ -0,0 +1,74 @@
import React, { FC, useState } from "preact/compat";
import ServerConfigurator from "./ServerConfigurator/ServerConfigurator";
import { useAppDispatch, useAppState } from "../../../state/common/StateContext";
import { SettingsIcon } from "../../Main/Icons";
import Button from "../../Main/Button/Button";
import Modal from "../../Main/Modal/Modal";
import "./style.scss";
import Tooltip from "../../Main/Tooltip/Tooltip";
const title = "Setting Server URL";
const GlobalSettings: FC = () => {
const { serverUrl } = useAppState();
const dispatch = useAppDispatch();
const [changedServerUrl, setChangedServerUrl] = useState(serverUrl);
const setServer = (url?: string) => {
dispatch({ type: "SET_SERVER", payload: url || changedServerUrl });
handleClose();
};
const createSetServer = () => () => {
setServer();
};
const [open, setOpen] = useState(false);
const handleOpen = () => setOpen(true);
const handleClose = () => setOpen(false);
return <>
<Tooltip title={title}>
<Button
className="vm-header-button"
variant="contained"
color="primary"
startIcon={<SettingsIcon/>}
onClick={handleOpen}
/>
</Tooltip>
{open && (
<Modal
title={title}
onClose={handleClose}
>
<div className="vm-server-configurator">
<div className="vm-server-configurator__input">
<ServerConfigurator
setServer={setChangedServerUrl}
onEnter={setServer}
/>
</div>
<div className="vm-server-configurator__footer">
<Button
variant="outlined"
color="error"
onClick={handleClose}
>
Cancel
</Button>
<Button
variant="contained"
onClick={createSetServer()}
>
apply
</Button>
</div>
</div>
</Modal>
)}
</>;
};
export default GlobalSettings;

View file

@ -0,0 +1,43 @@
import React, { FC, useState } from "preact/compat";
import { useAppState } from "../../../../state/common/StateContext";
import { ErrorTypes } from "../../../../types";
import TextField from "../../../Main/TextField/TextField";
import { isValidHttpUrl } from "../../../../utils/url";
export interface ServerConfiguratorProps {
setServer: (url: string) => void
onEnter: (url: string) => void
}
const ServerConfigurator: FC<ServerConfiguratorProps> = ({ setServer , onEnter }) => {
const { serverUrl } = useAppState();
const [error, setError] = useState("");
const [changedServerUrl, setChangedServerUrl] = useState(serverUrl);
const onChangeServer = (val: string) => {
const value = val || "";
setChangedServerUrl(value);
setServer(value);
setError("");
if (!value) setError(ErrorTypes.emptyServer);
if (!isValidHttpUrl(value)) setError(ErrorTypes.validServer);
};
const handleEnter = () => {
onEnter(changedServerUrl);
};
return (
<TextField
autofocus
label="Server URL"
value={changedServerUrl}
error={error}
onChange={onChangeServer}
onEnter={handleEnter}
/>
);
};
export default ServerConfigurator;

View file

@ -0,0 +1,22 @@
@use "src/styles/variables" as *;
.vm-server-configurator {
display: grid;
align-items: center;
gap: $padding-global;
width: 600px;
&__input {
}
&__footer {
display: inline-grid;
grid-template-columns: repeat(2, 1fr);
align-items: center;
justify-content: flex-end;
gap: $padding-small;
margin-left: auto;
margin-right: 0;
}
}

View file

@ -0,0 +1,62 @@
import React, { FC, useCallback, useMemo } from "preact/compat";
import debounce from "lodash.debounce";
import { AxisRange, YaxisState } from "../../../../state/graph/reducer";
import "./style.scss";
import TextField from "../../../Main/TextField/TextField";
import Switch from "../../../Main/Switch/Switch";
interface AxesLimitsConfiguratorProps {
yaxis: YaxisState,
setYaxisLimits: (limits: AxisRange) => void,
toggleEnableLimits: () => void
}
const AxesLimitsConfigurator: FC<AxesLimitsConfiguratorProps> = ({ yaxis, setYaxisLimits, toggleEnableLimits }) => {
const axes = useMemo(() => Object.keys(yaxis.limits.range), [yaxis.limits.range]);
const onChangeLimit = (value: string, axis: string, index: number) => {
const newLimits = yaxis.limits.range;
newLimits[axis][index] = +value;
if (newLimits[axis][0] === newLimits[axis][1] || newLimits[axis][0] > newLimits[axis][1]) return;
setYaxisLimits(newLimits);
};
const debouncedOnChangeLimit = useCallback(debounce(onChangeLimit, 500), [yaxis.limits.range]);
const createHandlerOnchangeAxis = (axis: string, index: number) => (val: string) => {
debouncedOnChangeLimit(val, axis, index);
};
return <div className="vm-axes-limits">
<Switch
value={yaxis.limits.enable}
onChange={toggleEnableLimits}
label="Fix the limits for y-axis"
/>
<div className="vm-axes-limits-list">
{axes.map(axis => (
<div
className="vm-axes-limits-list__inputs"
key={axis}
>
<TextField
label={`Min ${axis}`}
type="number"
disabled={!yaxis.limits.enable}
value={yaxis.limits.range[axis][0]}
onChange={createHandlerOnchangeAxis(axis, 0)}
/>
<TextField
label={`Max ${axis}`}
type="number"
disabled={!yaxis.limits.enable}
value={yaxis.limits.range[axis][1]}
onChange={createHandlerOnchangeAxis(axis, 1)}
/>
</div>
))}
</div>
</div>;
};
export default AxesLimitsConfigurator;

View file

@ -0,0 +1,20 @@
@use "src/styles/variables" as *;
.vm-axes-limits {
display: grid;
align-items: center;
gap: $padding-global;
max-width: 300px;
&-list {
display: grid;
align-items: center;
gap: $padding-global;
&__inputs {
display: grid;
grid-template-columns: repeat(2, 120px);
gap: $padding-small;
}
}
}

View file

@ -0,0 +1,77 @@
import React, { FC, useRef, useState } from "preact/compat";
import AxesLimitsConfigurator from "./AxesLimitsConfigurator/AxesLimitsConfigurator";
import { AxisRange, YaxisState } from "../../../state/graph/reducer";
import { CloseIcon, SettingsIcon } from "../../Main/Icons";
import Button from "../../Main/Button/Button";
import useClickOutside from "../../../hooks/useClickOutside";
import Popper from "../../Main/Popper/Popper";
import "./style.scss";
import Tooltip from "../../Main/Tooltip/Tooltip";
const title = "Axes settings";
interface GraphSettingsProps {
yaxis: YaxisState,
setYaxisLimits: (limits: AxisRange) => void,
toggleEnableLimits: () => void
}
const GraphSettings: FC<GraphSettingsProps> = ({ yaxis, setYaxisLimits, toggleEnableLimits }) => {
const popperRef = useRef<HTMLDivElement>(null);
const [openPopper, setOpenPopper] = useState(false);
const buttonRef = useRef<HTMLDivElement>(null);
useClickOutside(popperRef, () => setOpenPopper(false), buttonRef);
const toggleOpen = () => {
setOpenPopper(prev => !prev);
};
const handleClose = () => {
setOpenPopper(false);
};
return (
<div className="vm-graph-settings">
<Tooltip title={title}>
<div ref={buttonRef}>
<Button
variant="text"
startIcon={<SettingsIcon/>}
onClick={toggleOpen}
/>
</div>
</Tooltip>
<Popper
open={openPopper}
buttonRef={buttonRef}
placement="bottom-right"
onClose={handleClose}
>
<div
className="vm-graph-settings-popper"
ref={popperRef}
>
<div className="vm-popper-header">
<h3 className="vm-popper-header__title">
{title}
</h3>
<Button
size="small"
startIcon={<CloseIcon/>}
onClick={handleClose}
/>
</div>
<div className="vm-graph-settings-popper__body">
<AxesLimitsConfigurator
yaxis={yaxis}
setYaxisLimits={setYaxisLimits}
toggleEnableLimits={toggleEnableLimits}
/>
</div>
</div>
</Popper>
</div>
);
};
export default GraphSettings;

View file

@ -0,0 +1,15 @@
@use "src/styles/variables" as *;
.vm-graph-settings {
&-popper {
display: grid;
gap: $padding-global;
padding: 0 0 $padding-global;
&__body {
display: grid;
gap: $padding-small;
padding: 0 $padding-global;
}
}
}

View file

@ -0,0 +1,158 @@
import React, { FC, useEffect, useMemo, useRef, useState } from "preact/compat";
import { KeyboardEvent } from "react";
import { ErrorTypes } from "../../../types";
import TextField from "../../Main/TextField/TextField";
import Popper from "../../Main/Popper/Popper";
import useClickOutside from "../../../hooks/useClickOutside";
import "./style.scss";
import classNames from "classnames";
export interface QueryEditorProps {
onChange: (query: string) => void;
onEnter: () => void;
onArrowUp: () => void;
onArrowDown: () => void;
value: string;
oneLiner?: boolean;
autocomplete: boolean;
error?: ErrorTypes | string;
options: string[];
label: string;
size?: "small" | "medium" | undefined;
}
const QueryEditor: FC<QueryEditorProps> = ({
value,
onChange,
onEnter,
onArrowUp,
onArrowDown,
autocomplete,
error,
options,
label,
}) => {
const [focusOption, setFocusOption] = useState(-1);
const [openAutocomplete, setOpenAutocomplete] = useState(false);
const autocompleteAnchorEl = useRef<HTMLDivElement>(null);
const wrapperEl = useRef<HTMLDivElement>(null);
const foundOptions = useMemo(() => {
setFocusOption(0);
if (!openAutocomplete) return [];
try {
const regexp = new RegExp(String(value), "i");
const found = options.filter((item) => regexp.test(item) && (item !== value));
return found.sort((a,b) => (a.match(regexp)?.index || 0) - (b.match(regexp)?.index || 0));
} catch (e) {
return [];
}
}, [openAutocomplete, options]);
const handleKeyDown = (e: KeyboardEvent) => {
const { key, ctrlKey, metaKey, shiftKey } = e;
const ctrlMetaKey = ctrlKey || metaKey;
const arrowUp = key === "ArrowUp";
const arrowDown = key === "ArrowDown";
const enter = key === "Enter";
const hasAutocomplete = openAutocomplete && foundOptions.length;
const isArrows = arrowUp || arrowDown;
const arrowsByOptions = isArrows && hasAutocomplete;
const arrowsByHistory = isArrows && ctrlMetaKey;
const enterByOptions = enter && hasAutocomplete;
if (arrowsByOptions || arrowsByHistory || enterByOptions) {
e.preventDefault();
}
// ArrowUp
if (arrowUp && hasAutocomplete && !ctrlMetaKey) {
setFocusOption((prev) => prev === 0 ? 0 : prev - 1);
} else if (arrowUp && ctrlMetaKey) {
onArrowUp();
}
// ArrowDown
if (arrowDown && hasAutocomplete && !ctrlMetaKey) {
setFocusOption((prev) => prev >= foundOptions.length - 1 ? foundOptions.length - 1 : prev + 1);
} else if (arrowDown && ctrlMetaKey) {
onArrowDown();
}
// Enter
if (enter && hasAutocomplete && !shiftKey && !ctrlMetaKey) {
onChange(foundOptions[focusOption]);
setOpenAutocomplete(false);
} else if (enter && !shiftKey) {
onEnter();
}
};
const handleCloseAutocomplete = () => {
setOpenAutocomplete(false);
};
const createHandlerOnChangeAutocomplete = (item: string) => () => {
onChange(item);
handleCloseAutocomplete();
};
useEffect(() => {
const words = (value.match(/[a-zA-Z_:.][a-zA-Z0-9_:.]*/gm) || []).length;
setOpenAutocomplete(autocomplete && value.length > 2 && words <= 1);
}, [autocomplete, value]);
useEffect(() => {
if (!wrapperEl.current) return;
const target = wrapperEl.current.childNodes[focusOption] as HTMLElement;
if (target?.scrollIntoView) target.scrollIntoView({ block: "center" });
}, [focusOption]);
useClickOutside(autocompleteAnchorEl, () => setOpenAutocomplete(false), wrapperEl);
return <div
className="vm-query-editor"
ref={autocompleteAnchorEl}
>
<TextField
value={value}
label={label}
type={"textarea"}
autofocus={!!value}
error={error}
onKeyDown={handleKeyDown}
onChange={onChange}
/>
<Popper
open={openAutocomplete}
buttonRef={autocompleteAnchorEl}
placement="bottom-left"
onClose={handleCloseAutocomplete}
>
<div
className="vm-query-editor-autocomplete"
ref={wrapperEl}
>
{foundOptions.map((item, i) =>
<div
className={classNames({
"vm-list__item": true,
"vm-list__item_active": i === focusOption
})}
id={`$autocomplete$${item}`}
key={item}
onClick={createHandlerOnChangeAutocomplete(item)}
>
{item}
</div>)}
</div>
</Popper>
</div>;
};
export default QueryEditor;

View file

@ -0,0 +1,9 @@
@use "src/styles/variables" as *;
.vm-query-editor {
&-autocomplete {
max-height: 300px;
overflow: auto;
}
}

View file

@ -0,0 +1,67 @@
import React, { FC, useCallback, useState } from "preact/compat";
import { useEffect } from "react";
import debounce from "lodash.debounce";
import { RestartIcon } from "../../Main/Icons";
import TextField from "../../Main/TextField/TextField";
import Button from "../../Main/Button/Button";
import Tooltip from "../../Main/Tooltip/Tooltip";
interface StepConfiguratorProps {
defaultStep?: number,
setStep: (step: number) => void,
}
const StepConfigurator: FC<StepConfiguratorProps> = ({ defaultStep, setStep }) => {
const [customStep, setCustomStep] = useState(defaultStep);
const [error, setError] = useState("");
const handleApply = (step: number) => setStep(step || 1);
const debouncedHandleApply = useCallback(debounce(handleApply, 700), []);
const onChangeStep = (val: string) => {
const value = +val;
if (!value) return;
handleSetStep(value);
};
const handleSetStep = (value: number) => {
if (value > 0) {
setCustomStep(value);
debouncedHandleApply(value);
setError("");
} else {
setError("step is out of allowed range");
}
};
const handleReset = () => {
handleSetStep(defaultStep || 1);
};
useEffect(() => {
if (defaultStep) handleSetStep(defaultStep);
}, [defaultStep]);
return (
<TextField
label="Step value"
type="number"
value={customStep}
error={error}
onChange={onChangeStep}
endIcon={(
<Tooltip title="Reset step to default">
<Button
variant={"text"}
size={"small"}
startIcon={<RestartIcon/>}
onClick={handleReset}
/>
</Tooltip>
)}
/>
);
};
export default StepConfigurator;

View file

@ -0,0 +1,58 @@
import React, { FC, useState, useEffect, useCallback } from "preact/compat";
import { useAppDispatch, useAppState } from "../../../state/common/StateContext";
import debounce from "lodash.debounce";
import { getAppModeParams } from "../../../utils/app-mode";
import { useTimeDispatch } from "../../../state/time/TimeStateContext";
import { InfoIcon } from "../../Main/Icons";
import TextField from "../../Main/TextField/TextField";
import Button from "../../Main/Button/Button";
import Tooltip from "../../Main/Tooltip/Tooltip";
const TenantsConfiguration: FC = () => {
const { serverURL } = getAppModeParams();
const { tenantId: tenantIdState } = useAppState();
const dispatch = useAppDispatch();
const timeDispatch = useTimeDispatch();
const [tenantId, setTenantId] = useState<string | number>(tenantIdState || 0);
const handleApply = (value: string | number) => {
const tenantId = Number(value);
dispatch({ type: "SET_TENANT_ID", payload: tenantId });
if (serverURL) {
const updateServerUrl = serverURL.replace(/(\/select\/)([\d]+)(\/prometheus)/gmi, `$1${tenantId}$3`);
dispatch({ type: "SET_SERVER", payload: updateServerUrl });
timeDispatch({ type: "RUN_QUERY" });
}
};
const debouncedHandleApply = useCallback(debounce(handleApply, 700), []);
const handleChange = (value: string) => {
setTenantId(value);
debouncedHandleApply(value);
};
useEffect(() => {
if (tenantId === tenantIdState) return;
setTenantId(tenantIdState);
}, [tenantIdState]);
return <TextField
label="Tenant ID"
type="number"
value={tenantId}
onChange={handleChange}
endIcon={(
<Tooltip title={"Define tenant id if you need request to another storage"}>
<Button
variant={"text"}
size={"small"}
startIcon={<InfoIcon/>}
/>
</Tooltip>
)}
/>;
};
export default TenantsConfiguration;

View file

@ -0,0 +1,143 @@
import React, { FC, useEffect, useRef, useState } from "preact/compat";
import { useTimeDispatch } from "../../../../state/time/TimeStateContext";
import { getAppModeEnable } from "../../../../utils/app-mode";
import Button from "../../../Main/Button/Button";
import { ArrowDownIcon, RefreshIcon } from "../../../Main/Icons";
import Popper from "../../../Main/Popper/Popper";
import "./style.scss";
import classNames from "classnames";
import Tooltip from "../../../Main/Tooltip/Tooltip";
interface AutoRefreshOption {
seconds: number
title: string
}
const delayOptions: AutoRefreshOption[] = [
{ seconds: 0, title: "Off" },
{ seconds: 1, title: "1s" },
{ seconds: 2, title: "2s" },
{ seconds: 5, title: "5s" },
{ seconds: 10, title: "10s" },
{ seconds: 30, title: "30s" },
{ seconds: 60, title: "1m" },
{ seconds: 300, title: "5m" },
{ seconds: 900, title: "15m" },
{ seconds: 1800, title: "30m" },
{ seconds: 3600, title: "1h" },
{ seconds: 7200, title: "2h" }
];
export const ExecutionControls: FC = () => {
const dispatch = useTimeDispatch();
const appModeEnable = getAppModeEnable();
const [autoRefresh, setAutoRefresh] = useState(false);
const [selectedDelay, setSelectedDelay] = useState<AutoRefreshOption>(delayOptions[0]);
const handleChange = (d: AutoRefreshOption) => {
if ((autoRefresh && !d.seconds) || (!autoRefresh && d.seconds)) {
setAutoRefresh(prev => !prev);
}
setSelectedDelay(d);
setOpenOptions(false);
};
const handleUpdate = () => {
dispatch({ type: "RUN_QUERY" });
};
useEffect(() => {
const delay = selectedDelay.seconds;
let timer: number;
if (autoRefresh) {
timer = setInterval(() => {
dispatch({ type: "RUN_QUERY" });
}, delay * 1000) as unknown as number;
} else {
setSelectedDelay(delayOptions[0]);
}
return () => {
timer && clearInterval(timer);
};
}, [selectedDelay, autoRefresh]);
const [openOptions, setOpenOptions] = useState(false);
const optionsButtonRef = useRef<HTMLDivElement>(null);
const toggleOpenOptions = () => {
setOpenOptions(prev => !prev);
};
const handleCloseOptions = () => {
setOpenOptions(false);
};
const createHandlerChange = (d: AutoRefreshOption) => () => {
handleChange(d);
};
return <>
<div className="vm-execution-controls">
<div
className={classNames({
"vm-execution-controls-buttons": true,
"vm-header-button": !appModeEnable
})}
>
<Tooltip title="Refresh dashboard">
<Button
variant="contained"
color="primary"
onClick={handleUpdate}
startIcon={<RefreshIcon/>}
/>
</Tooltip>
<Tooltip title="Auto-refresh control">
<div ref={optionsButtonRef}>
<Button
variant="contained"
color="primary"
fullWidth
endIcon={(
<div
className={classNames({
"vm-execution-controls-buttons__arrow": true,
"vm-execution-controls-buttons__arrow_open": openOptions,
})}
>
<ArrowDownIcon/>
</div>
)}
onClick={toggleOpenOptions}
>
{selectedDelay.title}
</Button>
</div>
</Tooltip>
</div>
</div>
<Popper
open={openOptions}
placement="bottom-right"
onClose={handleCloseOptions}
buttonRef={optionsButtonRef}
>
<div className="vm-execution-controls-list">
{delayOptions.map(d => (
<div
className={classNames({
"vm-list__item": true,
"vm-list__item_active": d.seconds === selectedDelay.seconds
})}
key={d.seconds}
onClick={createHandlerChange(d)}
>
{d.title}
</div>
))}
</div>
</Popper>
</>;
};

View file

@ -0,0 +1,32 @@
@use "src/styles/variables" as *;
@use "src/components/Main/Button/style" as *;
.vm-execution-controls {
&-buttons {
display: flex;
justify-content: space-between;
border-radius: calc($button-radius + 1px);
min-width: 107px;
&__arrow {
display: flex;
align-items: center;
justify-content: center;
transform: rotate(0);
transition: transform 200ms ease-in-out;
&_open {
transform: rotate(180deg);
}
}
}
&-list {
width: 124px;
max-height: 208px;
overflow: auto;
padding: $padding-small 0;
font-size: $font-size;
}
}

View file

@ -0,0 +1,35 @@
import React, { FC } from "preact/compat";
import { relativeTimeOptions } from "../../../../utils/time";
import "./style.scss";
import classNames from "classnames";
interface TimeDurationSelector {
setDuration: ({ duration, until, id }: {duration: string, until: Date, id: string}) => void;
relativeTime: string;
}
const TimeDurationSelector: FC<TimeDurationSelector> = ({ relativeTime, setDuration }) => {
const createHandlerClick = (value: { duration: string, until: Date, id: string }) => () => {
setDuration(value);
};
return (
<div className="vm-time-duration">
{relativeTimeOptions.map(({ id, duration, until, title }) => (
<div
className={classNames({
"vm-list__item": true,
"vm-list__item_active": id === relativeTime
})}
key={id}
onClick={createHandlerClick({ duration, until: until(), id })}
>
{title || duration}
</div>
))}
</div>
);
};
export default TimeDurationSelector;

View file

@ -0,0 +1,7 @@
@use "src/styles/variables" as *;
.vm-time-duration {
max-height: 168px;
overflow: auto;
font-size: $font-size;
}

View file

@ -0,0 +1,192 @@
import React, { FC, useEffect, useState, useMemo, useRef } from "preact/compat";
import { dateFromSeconds, formatDateForNativeInput } from "../../../../utils/time";
import TimeDurationSelector from "../TimeDurationSelector/TimeDurationSelector";
import dayjs from "dayjs";
import { getAppModeEnable } from "../../../../utils/app-mode";
import { useTimeDispatch, useTimeState } from "../../../../state/time/TimeStateContext";
import { AlarmIcon, CalendarIcon, ClockIcon } from "../../../Main/Icons";
import Button from "../../../Main/Button/Button";
import Popper from "../../../Main/Popper/Popper";
import Tooltip from "../../../Main/Tooltip/Tooltip";
import { DATE_TIME_FORMAT } from "../../../../constants/date";
import useResize from "../../../../hooks/useResize";
import DatePicker from "../../../Main/DatePicker/DatePicker";
import "./style.scss";
import useClickOutside from "../../../../hooks/useClickOutside";
export const TimeSelector: FC = () => {
const wrapperRef = useRef<HTMLDivElement>(null);
const documentSize = useResize(document.body);
const displayFullDate = useMemo(() => documentSize.width > 1120, [documentSize]);
const [until, setUntil] = useState<string>();
const [from, setFrom] = useState<string>();
const formFormat = useMemo(() => dayjs(from).format(DATE_TIME_FORMAT), [from]);
const untilFormat = useMemo(() => dayjs(until).format(DATE_TIME_FORMAT), [until]);
const { period: { end, start }, relativeTime } = useTimeState();
const dispatch = useTimeDispatch();
const appModeEnable = getAppModeEnable();
useEffect(() => {
setUntil(formatDateForNativeInput(dateFromSeconds(end)));
}, [end]);
useEffect(() => {
setFrom(formatDateForNativeInput(dateFromSeconds(start)));
}, [start]);
const setDuration = ({ duration, until, id }: {duration: string, until: Date, id: string}) => {
dispatch({ type: "SET_RELATIVE_TIME", payload: { duration, until, id } });
setOpenOptions(false);
};
const formatRange = useMemo(() => {
const startFormat = dayjs(dateFromSeconds(start)).format(DATE_TIME_FORMAT);
const endFormat = dayjs(dateFromSeconds(end)).format(DATE_TIME_FORMAT);
return {
start: startFormat,
end: endFormat
};
}, [start, end]);
const dateTitle = useMemo(() => {
const isRelativeTime = relativeTime && relativeTime !== "none";
return isRelativeTime ? relativeTime.replace(/_/g, " ") : `${formatRange.start} - ${formatRange.end}`;
}, [relativeTime, formatRange]);
const fromRef = useRef<HTMLDivElement>(null);
const untilRef = useRef<HTMLDivElement>(null);
const fromPickerRef = useRef<HTMLDivElement>(null);
const untilPickerRef = useRef<HTMLDivElement>(null);
const [openOptions, setOpenOptions] = useState(false);
const buttonRef = useRef<HTMLDivElement>(null);
const setTimeAndClosePicker = () => {
if (from && until) {
dispatch({ type: "SET_PERIOD", payload: { from: new Date(from), to: new Date(until) } });
}
setOpenOptions(false);
};
const handleFromChange = (from: string) => setFrom(from);
const handleUntilChange = (until: string) => setUntil(until);
const onApplyClick = () => setTimeAndClosePicker();
const onSwitchToNow = () => dispatch({ type: "RUN_QUERY_TO_NOW" });
const onCancelClick = () => {
setUntil(formatDateForNativeInput(dateFromSeconds(end)));
setFrom(formatDateForNativeInput(dateFromSeconds(start)));
setOpenOptions(false);
};
const toggleOpenOptions = () => {
setOpenOptions(prev => !prev);
};
const handleCloseOptions = () => {
setOpenOptions(false);
};
useClickOutside(wrapperRef, (e) => {
const target = e.target as HTMLElement;
const isFromButton = fromRef?.current && fromRef.current.contains(target);
const isUntilButton = untilRef?.current && untilRef.current.contains(target);
const isFromPicker = fromPickerRef?.current && fromPickerRef?.current?.contains(target);
const isUntilPicker = untilPickerRef?.current && untilPickerRef?.current?.contains(target);
if (isFromButton || isUntilButton || isFromPicker || isUntilPicker) return;
handleCloseOptions();
});
return <>
<div ref={buttonRef}>
<Tooltip title="Time range controls">
<Button
className={appModeEnable ? "" : "vm-header-button"}
variant="contained"
color="primary"
startIcon={<ClockIcon/>}
onClick={toggleOpenOptions}
>
{displayFullDate && <span>{dateTitle}</span>}
</Button>
</Tooltip>
</div>
<Popper
open={openOptions}
buttonRef={buttonRef}
placement="bottom-right"
onClose={handleCloseOptions}
clickOutside={false}
>
<div
className="vm-time-selector"
ref={wrapperRef}
>
<div className="vm-time-selector-left">
<div className="vm-time-selector-left-inputs">
<div
className="vm-time-selector-left-inputs__date"
ref={fromRef}
>
<label>From:</label>
<span>{formFormat}</span>
<CalendarIcon/>
<DatePicker
ref={fromPickerRef}
date={from || ""}
onChange={handleFromChange}
targetRef={fromRef}
timepicker={true}
/>
</div>
<div
className="vm-time-selector-left-inputs__date"
ref={untilRef}
>
<label>To:</label>
<span>{untilFormat}</span>
<CalendarIcon/>
<DatePicker
ref={untilPickerRef}
date={until || ""}
onChange={handleUntilChange}
targetRef={untilRef}
timepicker={true}
/>
</div>
</div>
<Button
variant="text"
startIcon={<AlarmIcon />}
onClick={onSwitchToNow}
>
switch to now
</Button>
<div className="vm-time-selector-left__controls">
<Button
color="error"
variant="outlined"
onClick={onCancelClick}
>
Cancel
</Button>
<Button
color="primary"
onClick={onApplyClick}
>
Apply
</Button>
</div>
</div>
<TimeDurationSelector
relativeTime={relativeTime || ""}
setDuration={setDuration}
/>
</div>
</Popper>
</>;
};

View file

@ -0,0 +1,61 @@
@use "src/styles/variables" as *;
.vm-time-selector {
display: grid;
grid-template-columns: repeat(2, 230px);
padding: $padding-global 0;
&-left {
display: flex;
flex-direction: column;
gap: $padding-small;
border-right: $border-divider;
padding: 0 $padding-global;
&-inputs {
flex-grow: 1;
display: grid;
align-items: flex-start;
justify-content: stretch;
&__date {
display: grid;
grid-template-columns: 1fr 14px;
gap: $padding-small;
align-items: center;
justify-content: center;
padding-bottom: $padding-small;
margin-bottom: $padding-global;
border-bottom: $border-divider;
cursor: pointer;
transition: color 200ms ease-in-out, border-bottom-color 300ms ease;
&:hover {
border-bottom-color: $color-primary;
}
&:hover svg,
&:hover {
color: $color-primary;
}
label {
grid-column: 1/3;
font-size: $font-size-small;
color: $color-text-secondary;
}
svg {
color: $color-text-secondary;
transition: color 200ms ease-in-out;
}
}
}
&__controls {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: $padding-small;
}
}
}

View file

@ -1,205 +0,0 @@
/* eslint max-lines: ["error", {"max": 300}] */
import React, {useState} from "preact/compat";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import Checkbox from "@mui/material/Checkbox";
import DialogActions from "@mui/material/DialogActions";
import DialogContent from "@mui/material/DialogContent";
import DialogContentText from "@mui/material/DialogContentText";
import FormControl from "@mui/material/FormControl";
import FormControlLabel from "@mui/material/FormControlLabel";
import FormHelperText from "@mui/material/FormHelperText";
import Input from "@mui/material/Input";
import InputAdornment from "@mui/material/InputAdornment";
import InputLabel from "@mui/material/InputLabel";
import Tab from "@mui/material/Tab";
import Tabs from "@mui/material/Tabs";
import TextField from "@mui/material/TextField";
import Typography from "@mui/material/Typography";
import DialogTitle from "@mui/material/DialogTitle";
import Dialog from "@mui/material/Dialog";
import TabPanel from "./AuthTabPanel";
import PersonIcon from "@mui/icons-material/Person";
import LockIcon from "@mui/icons-material/Lock";
import {useAuthDispatch, useAuthState} from "../../../../state/auth/AuthStateContext";
import {AUTH_METHOD, WithCheckbox} from "../../../../state/auth/reducer";
import {ChangeEvent, ClipboardEvent} from "react";
// TODO: make generic when creating second dialog
export interface DialogProps {
open: boolean;
onClose: () => void;
}
export interface AuthTab {
title: string;
id: AUTH_METHOD;
}
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 {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: ChangeEvent<HTMLInputElement>) => {
const newVal = event.target.value;
if (newVal.startsWith(BEARER_PREFIX)) {
setBearerValue(newVal);
} else {
setBearerValue(BEARER_PREFIX);
}
};
const handleClose = () => {
onClose();
};
const onBearerPaste = (e: 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" sx={{height: "200px"}}>
<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
}}
maxRows={6}
/>
</TabPanel>
</Box>
<FormControl>
<FormControlLabel
control={
<Checkbox
checked={authCheckbox}
onChange={() => setAuthCheckbox(prev => !prev)}
name="checkedB"
color="primary"
/>
}
label="Persist Auth Data Locally"
/>
<FormHelperText>
{authCheckbox ? "Auth Data and the Selected method will be saved to LocalStorage" : "Auth Data won't be saved. All previously saved Auth Data will be removed"}
</FormHelperText>
</FormControl>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={handleApply} color="primary">
Apply
</Button>
</DialogActions>
</Dialog>
);
};

View file

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

View file

@ -1,40 +0,0 @@
import React, {FC} from "preact/compat";
import TableChartIcon from "@mui/icons-material/TableChart";
import ShowChartIcon from "@mui/icons-material/ShowChart";
import CodeIcon from "@mui/icons-material/Code";
import Tabs from "@mui/material/Tabs";
import Tab from "@mui/material/Tab";
import {useAppDispatch, useAppState} from "../../../state/common/StateContext";
import {SyntheticEvent} from "react";
export type DisplayType = "table" | "chart" | "code";
export const displayTypeTabs = [
{value: "chart", icon: <ShowChartIcon/>, label: "Graph", prometheusCode: 0},
{value: "code", icon: <CodeIcon/>, label: "JSON"},
{value: "table", icon: <TableChartIcon/>, label: "Table", prometheusCode: 1}
];
export const DisplayTypeSwitch: FC = () => {
const {displayType} = useAppState();
const dispatch = useAppDispatch();
const handleChange = (event: SyntheticEvent, newValue: DisplayType) => {
dispatch({type: "SET_DISPLAY_TYPE", payload: newValue ?? displayType});
};
return <Tabs
value={displayType}
onChange={handleChange}
sx={{minHeight: "0", marginBottom: "-1px"}}
>
{displayTypeTabs.map(t =>
<Tab key={t.value}
icon={t.icon}
iconPosition="start"
label={t.label} value={t.value}
sx={{minHeight: "41px"}}
/>)}
</Tabs>;
};

View file

@ -1,48 +0,0 @@
import React, {FC, useCallback, useMemo} from "preact/compat";
import {ChangeEvent} from "react";
import Box from "@mui/material/Box";
import FormControlLabel from "@mui/material/FormControlLabel";
import TextField from "@mui/material/TextField";
import debounce from "lodash.debounce";
import BasicSwitch from "../../../../theme/switch";
import {AxisRange, YaxisState} from "../../../../state/graph/reducer";
interface AxesLimitsConfiguratorProps {
yaxis: YaxisState,
setYaxisLimits: (limits: AxisRange) => void,
toggleEnableLimits: () => void
}
const AxesLimitsConfigurator: FC<AxesLimitsConfiguratorProps> = ({yaxis, setYaxisLimits, toggleEnableLimits}) => {
const axes = useMemo(() => Object.keys(yaxis.limits.range), [yaxis.limits.range]);
const onChangeLimit = (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>, axis: string, index: number) => {
const newLimits = yaxis.limits.range;
newLimits[axis][index] = +e.target.value;
if (newLimits[axis][0] === newLimits[axis][1] || newLimits[axis][0] > newLimits[axis][1]) return;
setYaxisLimits(newLimits);
};
const debouncedOnChangeLimit = useCallback(debounce(onChangeLimit, 500), [yaxis.limits.range]);
return <Box display="grid" alignItems="center" gap={2}>
<FormControlLabel
control={<BasicSwitch checked={yaxis.limits.enable} onChange={toggleEnableLimits}/>}
label="Fix the limits for y-axis"
/>
<Box display="grid" alignItems="center" gap={2}>
{axes.map(axis => <Box display="grid" gridTemplateColumns="120px 120px" gap={1} key={axis}>
<TextField label={`Min ${axis}`} type="number" size="small" variant="outlined"
disabled={!yaxis.limits.enable}
defaultValue={yaxis.limits.range[axis][0]}
onChange={(e) => debouncedOnChangeLimit(e, axis, 0)}/>
<TextField label={`Max ${axis}`} type="number" size="small" variant="outlined"
disabled={!yaxis.limits.enable}
defaultValue={yaxis.limits.range[axis][1]}
onChange={(e) => debouncedOnChangeLimit(e, axis, 1)} />
</Box>)}
</Box>
</Box>;
};
export default AxesLimitsConfigurator;

View file

@ -1,80 +0,0 @@
import SettingsIcon from "@mui/icons-material/Settings";
import React, {FC, useState} from "preact/compat";
import AxesLimitsConfigurator from "./AxesLimitsConfigurator";
import Box from "@mui/material/Box";
import IconButton from "@mui/material/IconButton";
import Paper from "@mui/material/Paper";
import Popper from "@mui/material/Popper";
import Tooltip from "@mui/material/Tooltip";
import Typography from "@mui/material/Typography";
import CloseIcon from "@mui/icons-material/Close";
import ClickAwayListener from "@mui/material/ClickAwayListener";
import {AxisRange, YaxisState} from "../../../../state/graph/reducer";
const classes = {
popover: {
display: "grid",
gridGap: "16px",
padding: "0 0 25px",
},
popoverHeader: {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
backgroundColor: "primary.main",
padding: "6px 6px 6px 12px",
borderRadius: "4px 4px 0 0",
color: "primary.contrastText",
},
popoverBody: {
display: "grid",
gridGap: "6px",
padding: "0 14px",
}
};
const title = "Axes Settings";
interface GraphSettingsProps {
yaxis: YaxisState,
setYaxisLimits: (limits: AxisRange) => void,
toggleEnableLimits: () => void
}
const GraphSettings: FC<GraphSettingsProps> = ({yaxis, setYaxisLimits, toggleEnableLimits}) => {
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null);
const open = Boolean(anchorEl);
return <Box>
<Tooltip title={title}>
<IconButton onClick={(e) => setAnchorEl(e.currentTarget)}>
<SettingsIcon/>
</IconButton>
</Tooltip>
<Popper
open={open}
anchorEl={anchorEl}
placement="left-start"
modifiers={[{name: "offset", options: {offset: [0, 6]}}]}>
<ClickAwayListener onClickAway={() => setAnchorEl(null)}>
<Paper elevation={3} sx={classes.popover}>
<Box id="handle" sx={classes.popoverHeader}>
<Typography variant="body1"><b>{title}</b></Typography>
<IconButton size="small" onClick={() => setAnchorEl(null)}>
<CloseIcon style={{color: "white"}}/>
</IconButton>
</Box>
<Box sx={classes.popoverBody}>
<AxesLimitsConfigurator
yaxis={yaxis}
setYaxisLimits={setYaxisLimits}
toggleEnableLimits={toggleEnableLimits}
/>
</Box>
</Paper>
</ClickAwayListener>
</Popper>
</Box>;
};
export default GraphSettings;

View file

@ -1,62 +0,0 @@
import React, {FC} from "preact/compat";
import Box from "@mui/material/Box";
import FormControlLabel from "@mui/material/FormControlLabel";
import {saveToStorage} from "../../../../utils/storage";
import {useAppDispatch, useAppState} from "../../../../state/common/StateContext";
import BasicSwitch from "../../../../theme/switch";
import StepConfigurator from "./StepConfigurator";
import {useGraphDispatch} from "../../../../state/graph/GraphStateContext";
import {getAppModeParams} from "../../../../utils/app-mode";
import TenantsConfiguration from "../Settings/TenantsConfiguration";
const AdditionalSettings: FC = () => {
const graphDispatch = useGraphDispatch();
const {inputTenantID} = getAppModeParams();
const {queryControls: {autocomplete, nocache, isTracingEnabled}, time: {period: {step}}} = useAppState();
const dispatch = useAppDispatch();
const onChangeAutocomplete = () => {
dispatch({type: "TOGGLE_AUTOCOMPLETE"});
saveToStorage("AUTOCOMPLETE", !autocomplete);
};
const onChangeCache = () => {
dispatch({type: "NO_CACHE"});
saveToStorage("NO_CACHE", !nocache);
};
const onChangeQueryTracing = () => {
dispatch({type: "TOGGLE_QUERY_TRACING"});
saveToStorage("QUERY_TRACING", !isTracingEnabled);
};
return <Box display="flex" alignItems="center" flexWrap="wrap" gap={2}>
<Box>
<FormControlLabel label="Autocomplete" sx={{m: 0}}
control={<BasicSwitch checked={autocomplete} onChange={onChangeAutocomplete}/>}
/>
</Box>
<Box>
<FormControlLabel label="Disable cache" sx={{m: 0}}
control={<BasicSwitch checked={nocache} onChange={onChangeCache}/>}
/>
</Box>
<Box>
<FormControlLabel label="Trace query" sx={{m: 0}}
control={<BasicSwitch checked={isTracingEnabled} onChange={onChangeQueryTracing} />}
/>
</Box>
<Box ml={2}>
<StepConfigurator defaultStep={step}
setStep={(value) => {
graphDispatch({type: "SET_CUSTOM_STEP", payload: value});
}}
/>
</Box>
{!!inputTenantID && <Box ml={2}><TenantsConfiguration/></Box>}
</Box>;
};
export default AdditionalSettings;

View file

@ -1,110 +0,0 @@
import React, {FC, useState, useEffect} from "preact/compat";
import Box from "@mui/material/Box";
import IconButton from "@mui/material/IconButton";
import Tooltip from "@mui/material/Tooltip";
import QueryEditor from "./QueryEditor";
import {useAppDispatch, useAppState} from "../../../../state/common/StateContext";
import DeleteIcon from "@mui/icons-material/Delete";
import AddIcon from "@mui/icons-material/Add";
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
import AdditionalSettings from "./AdditionalSettings";
import {ErrorTypes} from "../../../../types";
import Button from "@mui/material/Button";
import Typography from "@mui/material/Typography";
import usePrevious from "../../../../hooks/usePrevious";
import {MAX_QUERY_FIELDS} from "../../../../config";
export interface QueryConfiguratorProps {
error?: ErrorTypes | string;
queryOptions: string[]
}
const QueryConfigurator: FC<QueryConfiguratorProps> = ({error, queryOptions}) => {
const {query, queryHistory, queryControls: {autocomplete}} = useAppState();
const [stateQuery, setStateQuery] = useState(query || []);
const prevStateQuery = usePrevious(stateQuery) as (undefined | string[]);
const dispatch = useAppDispatch();
const updateHistory = () => {
dispatch({
type: "SET_QUERY_HISTORY", payload: stateQuery.map((q, i) => {
const h = queryHistory[i] || {values: []};
const queryEqual = q === h.values[h.values.length - 1];
return {
index: h.values.length - Number(queryEqual),
values: !queryEqual && q ? [...h.values, q] : h.values
};
})
});
};
const onRunQuery = () => {
updateHistory();
dispatch({type: "SET_QUERY", payload: stateQuery});
dispatch({type: "RUN_QUERY"});
};
const onAddQuery = () => {
setStateQuery(prev => [...prev, ""]);
};
const onRemoveQuery = (index: number) => {
setStateQuery(prev => prev.filter((q, i) => i !== index));
};
const onSetQuery = (value: string, index: number) => {
setStateQuery(prev => prev.map((q, i) => i === index ? value : q));
};
const setHistoryIndex = (step: number, indexQuery: number) => {
const {index, values} = queryHistory[indexQuery];
const newIndexHistory = index + step;
if (newIndexHistory < 0 || newIndexHistory >= values.length) return;
onSetQuery(values[newIndexHistory] || "", indexQuery);
dispatch({
type: "SET_QUERY_HISTORY_BY_INDEX",
payload: {value: {values, index: newIndexHistory}, queryNumber: indexQuery}
});
};
useEffect(() => {
if (prevStateQuery && (stateQuery.length < prevStateQuery.filter(q => q).length)) {
onRunQuery();
}
}, [stateQuery]);
return <Box>
<Box>
{stateQuery.map((q, i) =>
<Box key={i} display="grid" gridTemplateColumns="1fr auto" gap="4px" width="100%" position="relative"
mb={i === stateQuery.length - 1 ? 0 : 2}>
<QueryEditor
query={stateQuery[i]} index={i} autocomplete={autocomplete} queryOptions={queryOptions}
error={error} setHistoryIndex={setHistoryIndex} runQuery={onRunQuery} setQuery={onSetQuery}
label={`Query ${i + 1}`} size={"small"}/>
{stateQuery.length > 1 && <Tooltip title="Remove Query">
<IconButton onClick={() => onRemoveQuery(i)} sx={{height: "33px", width: "33px", padding: 0}} color={"error"}>
<DeleteIcon fontSize={"small"}/>
</IconButton>
</Tooltip>}
</Box>)}
</Box>
<Box mt={3} display="grid" gridTemplateColumns="1fr auto" alignItems="start" gap={4}>
<AdditionalSettings/>
<Box display="grid" gridTemplateColumns="repeat(2, auto)" gap={1}>
{stateQuery.length < MAX_QUERY_FIELDS && (
<Button variant="outlined" onClick={onAddQuery} startIcon={<AddIcon/>}>
<Typography lineHeight={"20px"} fontWeight="500">Add Query</Typography>
</Button>
)}
<Button variant="contained" onClick={onRunQuery} startIcon={<PlayArrowIcon/>}>
<Typography lineHeight={"20px"} fontWeight="500">Execute Query</Typography>
</Button>
</Box>
</Box>
</Box>;
};
export default QueryConfigurator;

View file

@ -1,142 +0,0 @@
import React, {FC, useEffect, useMemo, useRef, useState} from "preact/compat";
import {KeyboardEvent} from "react";
import {ErrorTypes} from "../../../../types";
import Popper from "@mui/material/Popper";
import TextField from "@mui/material/TextField";
import Box from "@mui/material/Box";
import Paper from "@mui/material/Paper";
import MenuItem from "@mui/material/MenuItem";
import MenuList from "@mui/material/MenuList";
import ClickAwayListener from "@mui/material/ClickAwayListener";
export interface QueryEditorProps {
setHistoryIndex: (step: number, index: number) => void;
setQuery: (query: string, index: number) => void;
runQuery: () => void;
query: string;
index: number;
oneLiner?: boolean;
autocomplete: boolean;
error?: ErrorTypes | string;
queryOptions: string[];
label: string;
size?: "small" | "medium" | undefined;
}
const QueryEditor: FC<QueryEditorProps> = ({
index,
query,
setHistoryIndex,
setQuery,
runQuery,
autocomplete,
error,
queryOptions,
label,
size = "medium"
}) => {
const [focusField, setFocusField] = useState(false);
const [focusOption, setFocusOption] = useState(-1);
const autocompleteAnchorEl = useRef<HTMLDivElement>(null);
const wrapperEl = useRef<HTMLUListElement>(null);
const [openAutocomplete, setOpenAutocomplete] = useState(false);
useEffect(() => {
if (!focusField) return;
const words = (query.match(/[a-zA-Z_:.][a-zA-Z0-9_:.]*/gm) || []).length;
setOpenAutocomplete(!(!autocomplete || query.length < 2 || words > 1));
},
[autocomplete, query]);
const actualOptions = useMemo(() => {
setFocusOption(0);
if (!openAutocomplete) return [];
try {
const regexp = new RegExp(String(query), "i");
const options = queryOptions.filter((item) => regexp.test(item) && (item !== query));
return options.sort((a,b) => (a.match(regexp)?.index || 0) - (b.match(regexp)?.index || 0));
} catch (e) {
return [];
}
}, [autocomplete, query, queryOptions]);
const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
const {key, ctrlKey, metaKey, shiftKey} = e;
const ctrlMetaKey = ctrlKey || metaKey;
const arrowUp = key === "ArrowUp";
const arrowDown = key === "ArrowDown";
const enter = key === "Enter";
const hasAutocomplete = openAutocomplete && actualOptions.length;
if (((arrowUp || arrowDown) && (hasAutocomplete || ctrlMetaKey)) || (enter && (hasAutocomplete || ctrlMetaKey || !shiftKey))) {
e.preventDefault();
}
// ArrowUp
if (arrowUp && hasAutocomplete && !ctrlMetaKey) {
setFocusOption((prev) => prev === 0 ? 0 : prev - 1);
} else if (arrowUp && ctrlMetaKey) {
setHistoryIndex(-1, index);
}
// ArrowDown
if (arrowDown && hasAutocomplete && !ctrlMetaKey) {
setFocusOption((prev) => prev >= actualOptions.length - 1 ? actualOptions.length - 1 : prev + 1);
} else if (arrowDown && ctrlMetaKey) {
setHistoryIndex(1, index);
}
// Enter
if (enter && hasAutocomplete && !shiftKey && !ctrlMetaKey) {
setQuery(actualOptions[focusOption], index);
} else if (enter && !shiftKey) {
runQuery();
}
};
useEffect(() => {
if (!wrapperEl.current) return;
const target = wrapperEl.current.childNodes[focusOption] as HTMLElement;
if (target?.scrollIntoView) target.scrollIntoView({block: "center"});
}, [focusOption]);
return <Box ref={autocompleteAnchorEl}>
<TextField
defaultValue={query}
fullWidth
label={label}
multiline
focused={!!query}
error={!!error}
onFocus={() => setFocusField(true)}
onKeyDown={handleKeyDown}
onChange={(e) => setQuery(e.target.value, index)}
size={size}
/>
<Popper open={openAutocomplete} anchorEl={autocompleteAnchorEl.current} placement="bottom-start" sx={{zIndex: 3}}>
<ClickAwayListener onClickAway={() => setOpenAutocomplete(false)}>
<Paper elevation={3} sx={{ maxHeight: 300, overflow: "auto" }}>
<MenuList ref={wrapperEl} dense>
{actualOptions.map((item, i) =>
<MenuItem
id={`$autocomplete$${item}`}
key={item}
sx={{bgcolor: `rgba(0, 0, 0, ${i === focusOption ? 0.12 : 0})`}}
onClick={() => {
setQuery(item, index);
setOpenAutocomplete(false);
}}
>
{item}
</MenuItem>)}
</MenuList>
</Paper>
</ClickAwayListener>
</Popper>
</Box>;
};
export default QueryEditor;

View file

@ -1,67 +0,0 @@
import React, {FC, useCallback, useState} from "preact/compat";
import {ChangeEvent, useEffect} from "react";
import TextField from "@mui/material/TextField";
import debounce from "lodash.debounce";
import InputAdornment from "@mui/material/InputAdornment";
import Tooltip from "@mui/material/Tooltip";
import RestartAltIcon from "@mui/icons-material/RestartAlt";
import IconButton from "@mui/material/IconButton";
interface StepConfiguratorProps {
defaultStep?: number,
setStep: (step: number) => void,
}
const StepConfigurator: FC<StepConfiguratorProps> = ({defaultStep, setStep}) => {
const [customStep, setCustomStep] = useState(defaultStep);
const [error, setError] = useState(false);
const handleApply = (step: number) => setStep(step || 1);
const debouncedHandleApply = useCallback(debounce(handleApply, 700), []);
const onChangeStep = (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const value = +e.target.value;
if (!value) return;
handleSetStep(value);
};
const handleSetStep = (value: number) => {
if (value > 0) {
setCustomStep(value);
debouncedHandleApply(value);
setError(false);
} else {
setError(true);
}
};
useEffect(() => {
if (defaultStep) handleSetStep(defaultStep);
}, [defaultStep]);
return <TextField
label="Step value"
type="number"
size="small"
variant="outlined"
value={customStep}
error={error}
helperText={error ? "step is out of allowed range" : " "}
onChange={onChangeStep}
InputProps={{
inputProps: {min: 0},
endAdornment: (
<InputAdornment position="start" sx={{mr: -0.5, cursor: "pointer"}}>
<Tooltip title={"Reset step to default"}>
<IconButton size={"small"} onClick={() => handleSetStep(defaultStep || 1)}>
<RestartAltIcon fontSize={"small"} />
</IconButton>
</Tooltip>
</InputAdornment>
),
}}
/>;
};
export default StepConfigurator;

View file

@ -1,80 +0,0 @@
import React, {FC, useState} from "preact/compat";
import Tooltip from "@mui/material/Tooltip";
import SettingsIcon from "@mui/icons-material/Settings";
import Button from "@mui/material/Button";
import Box from "@mui/material/Box";
import Modal from "@mui/material/Modal";
import ServerConfigurator from "./ServerConfigurator";
import Typography from "@mui/material/Typography";
import CloseIcon from "@mui/icons-material/Close";
import IconButton from "@mui/material/IconButton";
import {useAppDispatch, useAppState} from "../../../../state/common/StateContext";
const modalStyle = {
position: "absolute" as const,
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
bgcolor: "background.paper",
p: 3,
borderRadius: "4px",
width: "80%",
maxWidth: "800px"
};
const title = "Setting Server URL";
const GlobalSettings: FC = () => {
const {serverUrl} = useAppState();
const dispatch = useAppDispatch();
const [changedServerUrl, setChangedServerUrl] = useState(serverUrl);
const setServer = (url?: string) => {
dispatch({type: "SET_SERVER", payload: url || changedServerUrl});
handleClose();
};
const [open, setOpen] = useState(false);
const handleOpen = () => setOpen(true);
const handleClose = () => setOpen(false);
return <>
<Tooltip title={title}>
<Button variant="contained" color="primary"
sx={{
color: "white",
border: "1px solid rgba(0, 0, 0, 0.2)",
minWidth: "34px",
padding: "6px 8px",
boxShadow: "none",
}}
startIcon={<SettingsIcon style={{marginRight: "-8px", marginLeft: "4px"}}/>}
onClick={handleOpen}>
</Button>
</Tooltip>
<Modal open={open} onClose={handleClose}>
<Box sx={modalStyle}>
<Box display="grid" gridTemplateColumns="1fr auto" alignItems="center" mb={4}>
<Typography id="modal-modal-title" variant="h6" component="h2">
{title}
</Typography>
<IconButton size="small" onClick={handleClose}>
<CloseIcon/>
</IconButton>
</Box>
<ServerConfigurator setServer={setChangedServerUrl} onEnter={setServer}/>
<Box display="grid" gridTemplateColumns="auto auto" gap={1} justifyContent="end" mt={4}>
<Button variant="outlined" onClick={handleClose}>
Cancel
</Button>
<Button variant="contained" onClick={() => setServer()}>
apply
</Button>
</Box>
</Box>
</Modal>
</>;
};
export default GlobalSettings;

View file

@ -1,44 +0,0 @@
import React, {FC, useState} from "preact/compat";
import TextField from "@mui/material/TextField";
import {useAppState} from "../../../../state/common/StateContext";
import {ErrorTypes} from "../../../../types";
import {ChangeEvent, KeyboardEvent} from "react";
export interface ServerConfiguratorProps {
error?: ErrorTypes | string;
setServer: (url: string) => void
onEnter: (url: string) => void
}
const ServerConfigurator: FC<ServerConfiguratorProps> = ({error, setServer, onEnter}) => {
const {serverUrl} = useAppState();
const [changedServerUrl, setChangedServerUrl] = useState(serverUrl);
const onChangeServer = (e: ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => {
const value = e.target.value || "";
setChangedServerUrl(value);
setServer(value);
};
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
onEnter(changedServerUrl);
}
};
return <TextField
autoFocus
fullWidth
variant="outlined"
label="Server URL"
value={changedServerUrl || ""}
error={error === ErrorTypes.validServer || error === ErrorTypes.emptyServer}
inputProps={{style: {fontFamily: "Monospace"}}}
onChange={onChangeServer}
onKeyDown={onKeyDown}
/>;
};
export default ServerConfigurator;

View file

@ -1,60 +0,0 @@
import React, {FC, useState, useEffect, useCallback} from "preact/compat";
import TextField from "@mui/material/TextField";
import InputAdornment from "@mui/material/InputAdornment";
import Tooltip from "@mui/material/Tooltip";
import InfoIcon from "@mui/icons-material/Info";
import {useAppDispatch, useAppState} from "../../../../state/common/StateContext";
import {ChangeEvent} from "react";
import debounce from "lodash.debounce";
import {getAppModeParams} from "../../../../utils/app-mode";
const TenantsConfiguration: FC = () => {
const {serverURL} = getAppModeParams();
const {tenantId: tenantIdState} = useAppState();
const dispatch = useAppDispatch();
const [tenantId, setTenantId] = useState<string | number>(tenantIdState || 0);
const handleApply = (value: string | number) => {
const tenantId = Number(value);
dispatch({type: "SET_TENANT_ID", payload: tenantId});
if (serverURL) {
const updateServerUrl = serverURL.replace(/(\/select\/)([\d]+)(\/prometheus)/gmi, `$1${tenantId}$3`);
dispatch({type: "SET_SERVER", payload: updateServerUrl});
dispatch({type: "RUN_QUERY"});
}
};
const debouncedHandleApply = useCallback(debounce(handleApply, 700), []);
const handleChange = (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
setTenantId(e.target.value);
debouncedHandleApply(e.target.value);
};
useEffect(() => {
if (tenantId === tenantIdState) return;
setTenantId(tenantIdState);
}, [tenantIdState]);
return <TextField
label="Tenant ID"
type="number"
size="small"
variant="outlined"
value={tenantId}
onChange={handleChange}
InputProps={{
inputProps: {min: 0},
startAdornment: (
<InputAdornment position="start">
<Tooltip title={"Define tenant id if you need request to another storage"}>
<InfoIcon fontSize={"small"} />
</Tooltip>
</InputAdornment>
),
}}
/>;
};
export default TenantsConfiguration;

View file

@ -1,125 +0,0 @@
import React, {FC, useEffect, useState} from "preact/compat";
import Tooltip from "@mui/material/Tooltip";
import {useAppDispatch, useAppState} from "../../../../state/common/StateContext";
import Button from "@mui/material/Button";
import Popper from "@mui/material/Popper";
import Paper from "@mui/material/Paper";
import ClickAwayListener from "@mui/material/ClickAwayListener";
import AutorenewIcon from "@mui/icons-material/Autorenew";
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
import List from "@mui/material/List";
import ListItem from "@mui/material/ListItem";
import ListItemText from "@mui/material/ListItemText";
import {useLocation} from "react-router-dom";
import {getAppModeEnable} from "../../../../utils/app-mode";
import Box from "@mui/material/Box";
interface AutoRefreshOption {
seconds: number
title: string
}
const delayOptions: AutoRefreshOption[] = [
{seconds: 0, title: "Off"},
{seconds: 1, title: "1s"},
{seconds: 2, title: "2s"},
{seconds: 5, title: "5s"},
{seconds: 10, title: "10s"},
{seconds: 30, title: "30s"},
{seconds: 60, title: "1m"},
{seconds: 300, title: "5m"},
{seconds: 900, title: "15m"},
{seconds: 1800, title: "30m"},
{seconds: 3600, title: "1h"},
{seconds: 7200, title: "2h"}
];
export const ExecutionControls: FC = () => {
const dispatch = useAppDispatch();
const appModeEnable = getAppModeEnable();
const {queryControls: {autoRefresh}} = useAppState();
const location = useLocation();
useEffect(() => {
if (autoRefresh) dispatch({type: "TOGGLE_AUTOREFRESH"});
}, [location]);
const [selectedDelay, setSelectedDelay] = useState<AutoRefreshOption>(delayOptions[0]);
const handleChange = (d: AutoRefreshOption) => {
if ((autoRefresh && !d.seconds) || (!autoRefresh && d.seconds)) {
dispatch({type: "TOGGLE_AUTOREFRESH"});
}
setSelectedDelay(d);
setAnchorEl(null);
};
const handleUpdate = () => {
dispatch({type: "RUN_QUERY"});
};
useEffect(() => {
const delay = selectedDelay.seconds;
let timer: number;
if (autoRefresh) {
timer = setInterval(() => {
dispatch({type: "RUN_QUERY"});
}, delay * 1000) as unknown as number;
} else {
setSelectedDelay(delayOptions[0]);
}
return () => {
timer && clearInterval(timer);
};
}, [selectedDelay, autoRefresh]);
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null);
const open = Boolean(anchorEl);
return <>
<Box sx={{
minWidth: "110px",
color: "white",
border: appModeEnable ? "none" : "1px solid rgba(0, 0, 0, 0.2)",
justifyContent: "space-between",
boxShadow: "none",
borderRadius: "4px",
display: "grid",
gridTemplateColumns: "auto 1fr"
}}>
<Tooltip title="Refresh dashboard">
<Button variant="contained" color="primary"
sx={{color: "white", minWidth: "34px", boxShadow: "none", borderRadius: "3px 0 0 3px", p: "6px 6px"}}
startIcon={<AutorenewIcon fontSize={"small"} style={{marginRight: "-8px", marginLeft: "4px"}}/>}
onClick={handleUpdate}
>
</Button>
</Tooltip>
<Tooltip title="Auto-refresh control">
<Button variant="contained" color="primary" sx={{boxShadow: "none", borderRadius: "0 3px 3px 0"}} fullWidth
endIcon={<KeyboardArrowDownIcon sx={{transform: open ? "rotate(180deg)" : "none"}}/>}
onClick={(e) => setAnchorEl(e.currentTarget)}
>
{selectedDelay.title}
</Button>
</Tooltip>
</Box>
<Popper
open={open}
anchorEl={anchorEl}
placement="bottom-end"
modifiers={[{name: "offset", options: {offset: [0, 6]}}]}>
<ClickAwayListener onClickAway={() => setAnchorEl(null)}>
<Paper elevation={3}>
<List style={{minWidth: "110px", maxHeight: "208px", overflow: "auto", padding: "20px 0"}}>
{delayOptions.map(d =>
<ListItem key={d.seconds} button onClick={() => handleChange(d)}>
<ListItemText primary={d.title}/>
</ListItem>)}
</List>
</Paper>
</ClickAwayListener></Popper>
</>;
};

View file

@ -1,21 +0,0 @@
import React, {FC} from "preact/compat";
import List from "@mui/material/List";
import ListItemButton from "@mui/material/ListItemButton";
import ListItemText from "@mui/material/ListItemText";
import {relativeTimeOptions} from "../../../../utils/time";
interface TimeDurationSelector {
setDuration: ({duration, until, id}: {duration: string, until: Date, id: string}) => void;
}
const TimeDurationSelector: FC<TimeDurationSelector> = ({setDuration}) => {
return <List style={{maxHeight: "168px", overflow: "auto", paddingRight: "15px"}}>
{relativeTimeOptions.map(({id, duration, until, title}) =>
<ListItemButton key={id} onClick={() => setDuration({duration, until: until(), id})}>
<ListItemText primary={title || duration}/>
</ListItemButton>)}
</List>;
};
export default TimeDurationSelector;

View file

@ -1,173 +0,0 @@
import React, {FC, useEffect, useState, useMemo} from "preact/compat";
import {KeyboardEvent} from "react";
import {useAppDispatch, useAppState} from "../../../../state/common/StateContext";
import {dateFromSeconds, formatDateForNativeInput} from "../../../../utils/time";
import TimeDurationSelector from "./TimeDurationSelector";
import dayjs from "dayjs";
import QueryBuilderIcon from "@mui/icons-material/QueryBuilder";
import Box from "@mui/material/Box";
import TextField from "@mui/material/TextField";
import DateTimePicker from "@mui/lab/DateTimePicker";
import Button from "@mui/material/Button";
import Popper from "@mui/material/Popper";
import Paper from "@mui/material/Paper";
import Divider from "@mui/material/Divider";
import ClickAwayListener from "@mui/material/ClickAwayListener";
import Tooltip from "@mui/material/Tooltip";
import AlarmAdd from "@mui/icons-material/AlarmAdd";
import useMediaQuery from "@mui/material/useMediaQuery";
import {getAppModeEnable} from "../../../../utils/app-mode";
const formatDate = "YYYY-MM-DD HH:mm:ss";
const classes = {
container: {
display: "grid",
gridTemplateColumns: "200px auto 200px",
gridGap: "10px",
padding: "20px",
},
timeControls: {
display: "grid",
gridTemplateRows: "auto 1fr auto",
gridGap: "16px 0",
},
datePickerItem: {
minWidth: "200px",
},
};
export const TimeSelector: FC = () => {
const displayFullDate = useMediaQuery("(min-width: 1120px)");
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null);
const [until, setUntil] = useState<string>();
const [from, setFrom] = useState<string>();
const {time: {period: {end, start}, relativeTime}} = useAppState();
const dispatch = useAppDispatch();
const appModeEnable = getAppModeEnable();
useEffect(() => {
setUntil(formatDateForNativeInput(dateFromSeconds(end)));
}, [end]);
useEffect(() => {
setFrom(formatDateForNativeInput(dateFromSeconds(start)));
}, [start]);
const setDuration = ({duration, until, id}: {duration: string, until: Date, id: string}) => {
dispatch({type: "SET_RELATIVE_TIME", payload: {duration, until, id}});
setAnchorEl(null);
};
const formatRange = useMemo(() => {
const startFormat = dayjs(dateFromSeconds(start)).format(formatDate);
const endFormat = dayjs(dateFromSeconds(end)).format(formatDate);
return {
start: startFormat,
end: endFormat
};
}, [start, end]);
const open = Boolean(anchorEl);
const setTimeAndClosePicker = () => {
if (from && until) {
dispatch({type: "SET_PERIOD", payload: {from: new Date(from), to: new Date(until)}});
}
setAnchorEl(null);
};
const onFromChange = (from: dayjs.Dayjs | null) => setFrom(from?.format(formatDate));
const onUntilChange = (until: dayjs.Dayjs | null) => setUntil(until?.format(formatDate));
const onApplyClick = () => setTimeAndClosePicker();
const onSwitchToNow = () => dispatch({type: "RUN_QUERY_TO_NOW"});
const onCancelClick = () => {
setUntil(formatDateForNativeInput(dateFromSeconds(end)));
setFrom(formatDateForNativeInput(dateFromSeconds(start)));
setAnchorEl(null);
};
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Enter" || e.keyCode === 13) {
setTimeAndClosePicker();
}
};
return <>
<Tooltip title="Time range controls">
<Button variant="contained" color="primary"
sx={{
color: "white",
border: appModeEnable ? "none" : "1px solid rgba(0, 0, 0, 0.2)",
boxShadow: "none",
minWidth: "34px",
padding: displayFullDate ? "" : "6px 8px",
}}
startIcon={<QueryBuilderIcon style={displayFullDate ? {} : {marginRight: "-8px", marginLeft: "4px"}}/>}
onClick={(e) => setAnchorEl(e.currentTarget)}>
{displayFullDate && <span>
{relativeTime && relativeTime !== "none"
? relativeTime.replace(/_/g, " ")
: `${formatRange.start} - ${formatRange.end}`}
</span>}
</Button>
</Tooltip>
<Popper
open={open}
anchorEl={anchorEl}
placement="bottom-end"
modifiers={[{name: "offset", options: {offset: [0, 6]}}]}
sx={{zIndex: 3, position: "relative"}}
>
<ClickAwayListener onClickAway={() => setAnchorEl(null)}>
<Paper elevation={3}>
<Box sx={classes.container}>
<Box sx={classes.timeControls}>
<Box sx={classes.datePickerItem}>
<DateTimePicker
label="From"
ampm={false}
value={from}
onChange={onFromChange}
onError={console.log}
inputFormat={formatDate}
mask="____-__-__ __:__:__"
renderInput={(params) => <TextField {...params} variant="standard" onKeyDown={onKeyDown}/>}
maxDate={dayjs(until)}
PopperProps={{disablePortal: true}}/>
</Box>
<Box sx={classes.datePickerItem}>
<DateTimePicker
label="To"
ampm={false}
value={until}
onChange={onUntilChange}
onError={console.log}
inputFormat={formatDate}
mask="____-__-__ __:__:__"
renderInput={(params) => <TextField {...params} variant="standard" onKeyDown={onKeyDown}/>}
PopperProps={{disablePortal: true}}/>
</Box>
<Box display="grid" gridTemplateColumns="auto 1fr" gap={1}>
<Button variant="outlined" onClick={onCancelClick}>
Cancel
</Button>
<Button variant="outlined" onClick={onApplyClick} color={"success"}>
Apply
</Button>
<Button startIcon={<AlarmAdd />} onClick={onSwitchToNow}>
switch to now
</Button>
</Box>
</Box>
{/*setup duration*/}
<Divider orientation="vertical" flexItem />
<Box>
<TimeDurationSelector setDuration={setDuration}/>
</Box>
</Box>
</Paper>
</ClickAwayListener>
</Popper>
</>;
};

View file

@ -1,105 +0,0 @@
import React, {FC, useState, useEffect} from "preact/compat";
import Alert from "@mui/material/Alert";
import Box from "@mui/material/Box";
import GraphView from "./Views/GraphView";
import TableView from "./Views/TableView";
import {useAppDispatch, useAppState} from "../../state/common/StateContext";
import QueryConfigurator from "./Configurator/Query/QueryConfigurator";
import {useFetchQuery} from "../../hooks/useFetchQuery";
import JsonView from "./Views/JsonView";
import {DisplayTypeSwitch} from "./Configurator/DisplayTypeSwitch";
import GraphSettings from "./Configurator/Graph/GraphSettings";
import {useGraphDispatch, useGraphState} from "../../state/graph/GraphStateContext";
import {AxisRange} from "../../state/graph/reducer";
import Spinner from "../common/Spinner";
import {useFetchQueryOptions} from "../../hooks/useFetchQueryOptions";
import TracingsView from "./Views/TracingsView";
import Trace from "./Trace/Trace";
import TableSettings from "../Table/TableSettings";
const CustomPanel: FC = () => {
const [displayColumns, setDisplayColumns] = useState<string[]>();
const [tracesState, setTracesState] = useState<Trace[]>([]);
const {displayType, time: {period}, query, queryControls: {isTracingEnabled}} = useAppState();
const { customStep, yaxis } = useGraphState();
const dispatch = useAppDispatch();
const graphDispatch = useGraphDispatch();
const setYaxisLimits = (limits: AxisRange) => {
graphDispatch({type: "SET_YAXIS_LIMITS", payload: limits});
};
const toggleEnableLimits = () => {
graphDispatch({type: "TOGGLE_ENABLE_YAXIS_LIMITS"});
};
const setPeriod = ({from, to}: {from: Date, to: Date}) => {
dispatch({type: "SET_PERIOD", payload: {from, to}});
};
const {queryOptions} = useFetchQueryOptions();
const {isLoading, liveData, graphData, error, warning, traces} = useFetchQuery({
visible: true,
customStep
});
const handleTraceDelete = (trace: Trace) => {
const updatedTraces = tracesState.filter((data) => data.idValue !== trace.idValue);
setTracesState([...updatedTraces]);
};
useEffect(() => {
if (traces) {
setTracesState([...tracesState, ...traces]);
}
}, [traces]);
useEffect(() => {
setTracesState([]);
}, [displayType]);
return (
<Box p={4} display="grid" gridTemplateRows="auto 1fr" style={{minHeight: "calc(100vh - 64px)"}}>
<Box boxShadow="rgba(99, 99, 99, 0.2) 0px 2px 8px 0px;" p={4} pb={2} m={-4} mb={2}>
<QueryConfigurator error={error} queryOptions={queryOptions}/>
</Box>
<Box height="100%">
{isLoading && <Spinner isLoading={isLoading} height={"500px"}/>}
{<Box height={"100%"} bgcolor={"#fff"}>
<Box display="grid" gridTemplateColumns="1fr auto" alignItems="center" mb={2}
borderBottom={1} borderColor="divider">
<DisplayTypeSwitch/>
<Box display={"flex"}>
{displayType === "chart" && <GraphSettings
yaxis={yaxis}
setYaxisLimits={setYaxisLimits}
toggleEnableLimits={toggleEnableLimits}
/>}
{displayType === "table" && <TableSettings
data={liveData || []}
defaultColumns={displayColumns}
onChange={setDisplayColumns}
/>}
</Box>
</Box>
{error && <Alert color="error" severity="error" sx={{whiteSpace: "pre-wrap", mt: 2}}>{error}</Alert>}
{warning && <Alert color="warning" severity="warning" sx={{whiteSpace: "pre-wrap", my: 2}}>{warning}</Alert>}
{isTracingEnabled && <TracingsView
traces={tracesState}
onDeleteClick={handleTraceDelete}
/>}
{graphData && period && (displayType === "chart") && <>
<GraphView data={graphData} period={period} customStep={customStep} query={query} yaxis={yaxis}
setYaxisLimits={setYaxisLimits} setPeriod={setPeriod}/>
</>}
{liveData && (displayType === "code") && <JsonView data={liveData}/>}
{liveData && (displayType === "table") && <TableView data={liveData} displayColumns={displayColumns}/>}
</Box>}
</Box>
</Box>
);
};
export default CustomPanel;

View file

@ -1,69 +0,0 @@
import React, {FC, useState} from "preact/compat";
import Box from "@mui/material/Box";
import ListItem from "@mui/material/ListItem";
import ListItemText from "@mui/material/ListItemText";
import ListItemButton from "@mui/material/ListItemButton";
import ListItemIcon from "@mui/material/ListItemIcon";
import ExpandLess from "@mui/icons-material/ExpandLess";
import AddCircleRoundedIcon from "@mui/icons-material/AddCircleRounded";
import Collapse from "@mui/material/Collapse";
import List from "@mui/material/List";
import {BorderLinearProgressWithLabel} from "../../BorderLineProgress/BorderLinearProgress";
import Trace from "../Trace/Trace";
interface RecursiveProps {
trace: Trace;
totalMsec: number;
}
interface OpenLevels {
[x: number]: boolean
}
const NestedNav: FC<RecursiveProps> = ({ trace, totalMsec}) => {
const [openLevels, setOpenLevels] = useState({} as OpenLevels);
const handleListClick = (level: number) => () => {
setOpenLevels((prevState:OpenLevels) => {
return {...prevState, [level]: !prevState[level]};
});
};
const hasChildren = trace.children && trace.children.length;
const progress = trace.duration / totalMsec * 100;
return (
<Box sx={{ bgcolor: "rgba(201, 227, 246, 0.4)" }}>
<ListItem onClick={handleListClick(trace.idValue)} sx={!hasChildren ? {p:0, pl: 7} : {p:0}}>
<ListItemButton alignItems={"flex-start"} sx={{ pt: 0, pb: 0}} style={{ userSelect: "text" }} disableRipple>
{hasChildren ? <ListItemIcon>
{openLevels[trace.idValue] ?
<ExpandLess fontSize={"large"} color={"info"} /> :
<AddCircleRoundedIcon fontSize={"large"} color={"info"} />}
</ListItemIcon>: null}
<Box display="flex" flexDirection="column" flexGrow={0.5} sx={{ ml: 4, mr: 4, width: "100%" }}>
<ListItemText>
<BorderLinearProgressWithLabel variant="determinate" value={progress} />
</ListItemText>
<ListItemText
primary={trace.message}
secondary={`duration: ${trace.duration} ms`}
/>
</Box>
</ListItemButton>
</ListItem>
<>
<Collapse in={openLevels[trace.idValue]} timeout="auto" unmountOnExit>
<List component="div" disablePadding sx={{ pl: 4 }}>
{hasChildren ?
trace.children.map((trace) => <NestedNav
key={trace.duration}
trace={trace}
totalMsec={totalMsec}
/>) : null}
</List>
</Collapse>
</>
</Box>
);
};
export default NestedNav;

View file

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

View file

@ -1,44 +0,0 @@
import React, {FC} from "preact/compat";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import Typography from "@mui/material/Typography";
import {useSnack} from "../../contexts/Snackbar";
interface UrlLineProps {
url?: string
}
export const UrlLine: FC<UrlLineProps> = ({url}) => {
const {showInfoMessage} = useSnack();
return <Box 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>
</Box>;
};

View file

@ -1,41 +0,0 @@
import React, {FC, useMemo} from "preact/compat";
import {InstantMetricResult} from "../../../api/types";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import {useSnack} from "../../../contexts/Snackbar";
import {TopQuery} from "../../../types";
export interface JsonViewProps {
data: InstantMetricResult[] | TopQuery[];
}
const JsonView: FC<JsonViewProps> = ({data}) => {
const {showInfoMessage} = useSnack();
const formattedJson = useMemo(() => JSON.stringify(data, null, 2), [data]);
return (
<Box position="relative">
<Box
style={{
position: "sticky",
top: "16px",
display: "flex",
justifyContent: "flex-end",
}}>
<Button variant="outlined"
fullWidth={false}
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 style={{margin: 0}}>{formattedJson}</pre>
</Box>
);
};
export default JsonView;

View file

@ -1,110 +0,0 @@
import React, {FC, useEffect, useMemo, useRef, useState} from "preact/compat";
import {InstantMetricResult} from "../../../api/types";
import {InstantDataSeries} from "../../../types";
import Table from "@mui/material/Table";
import TableBody from "@mui/material/TableBody";
import TableCell from "@mui/material/TableCell";
import TableContainer from "@mui/material/TableContainer";
import TableHead from "@mui/material/TableHead";
import TableRow from "@mui/material/TableRow";
import TableSortLabel from "@mui/material/TableSortLabel";
import {useSortedCategories} from "../../../hooks/useSortedCategories";
import Alert from "@mui/material/Alert";
import {useAppState} from "../../../state/common/StateContext";
export interface GraphViewProps {
data: InstantMetricResult[];
displayColumns?: string[]
}
const TableView: FC<GraphViewProps> = ({data, displayColumns}) => {
const sortedColumns = useSortedCategories(data, displayColumns);
const [orderBy, setOrderBy] = useState("");
const [orderDir, setOrderDir] = useState<"asc" | "desc">("asc");
const rows: InstantDataSeries[] = useMemo(() => {
const rows = data?.map(d => ({
metadata: sortedColumns.map(c => d.metric[c.key] || "-"),
value: d.value ? d.value[1] : "-"
}));
const orderByValue = orderBy === "Value";
const rowIndex = sortedColumns.findIndex(c => c.key === orderBy);
if (!orderByValue && rowIndex === -1) return rows;
return rows.sort((a,b) => {
const n1 = orderByValue ? Number(a.value) : a.metadata[rowIndex];
const n2 = orderByValue ? Number(b.value) : b.metadata[rowIndex];
const asc = orderDir === "asc" ? n1 < n2 : n1 > n2;
return asc ? -1 : 1;
});
}, [sortedColumns, data, orderBy, orderDir]);
const sortHandler = (key: string) => {
setOrderDir((prev) => prev === "asc" && orderBy === key ? "desc" : "asc");
setOrderBy(key);
};
const {query} = useAppState();
const [tableContainerHeight, setTableContainerHeight] = useState("");
const tableContainerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!tableContainerRef.current) return;
const {top} = tableContainerRef.current.getBoundingClientRect();
setTableContainerHeight(`calc(100vh - ${top + 32}px)`);
}, [tableContainerRef, query]);
return (
<>
{(rows.length > 0)
? <TableContainer ref={tableContainerRef} sx={{width: "calc(100vw - 68px)", height: tableContainerHeight}}>
<Table stickyHeader aria-label="simple table">
<TableHead>
<TableRow>
{sortedColumns.map((col, index) => (
<TableCell key={index} style={{textTransform: "capitalize", paddingTop: 0}}>
<TableSortLabel
active={orderBy === col.key}
direction={orderDir}
onClick={() => sortHandler(col.key)}
>
{col.key}
</TableSortLabel>
</TableCell>
))}
<TableCell align="right">
<TableSortLabel
active={orderBy === "Value"}
direction={orderDir}
onClick={() => sortHandler("Value")}
>
Value
</TableSortLabel>
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{rows.map((row, index) => (
<TableRow key={index} hover>
{row.metadata.map((rowMeta, index2) => {
const prevRowValue = rows[index - 1] && rows[index - 1].metadata[index2];
return (
<TableCell
sx={prevRowValue === rowMeta ? {opacity: 0.4} : {}}
style={{whiteSpace: "nowrap"}}
key={index2}>{rowMeta}</TableCell>
);
}
)}
<TableCell align="right">{row.value}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
: <Alert color="warning" severity="warning" sx={{mt: 2}}>No data to show</Alert>}
</>
);
};
export default TableView;

View file

@ -1,17 +0,0 @@
import React, {FC} from "preact/compat";
import List from "@mui/material/List";
import NestedNav from "../NestedNav/NestedNav";
import Trace from "../Trace/Trace";
interface TraceViewProps {
trace: Trace;
}
const TraceView: FC<TraceViewProps> = ({trace}) => {
return (<List sx={{ width: "100%" }} component="nav">
<NestedNav trace={trace} totalMsec={trace.duration} />
</List>);
};
export default TraceView;

View file

@ -1,38 +0,0 @@
import React, {FC} from "preact/compat";
import Typography from "@mui/material/Typography";
import TraceView from "./TraceView";
import Alert from "@mui/material/Alert";
import RemoveCircleIcon from "@mui/icons-material/RemoveCircle";
import Button from "@mui/material/Button";
import Trace from "../Trace/Trace";
interface TraceViewProps {
traces: Trace[];
onDeleteClick: (trace: Trace) => void;
}
const TracingsView: FC<TraceViewProps> = ({traces, onDeleteClick}) => {
if (!traces.length) {
return (
<Alert color={"info"} severity="info" sx={{whiteSpace: "pre-wrap", mt: 2}}>
Please re-run the query to see results of the tracing
</Alert>
);
}
const handleDeleteClick = (tracingData: Trace) => () => {
onDeleteClick(tracingData);
};
return <>{traces.map((trace: Trace) => <>
<Typography variant="h5" component="div">
Trace for <b>{trace.queryValue}</b>
<Button onClick={handleDeleteClick(trace)}>
<RemoveCircleIcon fontSize={"medium"} color={"error"} />
</Button>
</Typography>
<TraceView trace={trace} />
</>)}</>;
};
export default TracingsView;

View file

@ -1,74 +1,31 @@
import React, {FC, useMemo, useState} from "preact/compat";
import AppBar from "@mui/material/AppBar";
import Box from "@mui/material/Box";
import Link from "@mui/material/Link";
import Toolbar from "@mui/material/Toolbar";
import {ExecutionControls} from "../CustomPanel/Configurator/Time/ExecutionControls";
import Logo from "../common/Logo";
import {setQueryStringWithoutPageReload} from "../../utils/query-string";
import {TimeSelector} from "../CustomPanel/Configurator/Time/TimeSelector";
import GlobalSettings from "../CustomPanel/Configurator/Settings/GlobalSettings";
import {Link as RouterLink, useLocation, useNavigate} from "react-router-dom";
import Tabs from "@mui/material/Tabs";
import Tab from "@mui/material/Tab";
import router, {RouterOptions, routerOptions} from "../../router/index";
import DatePicker from "../Main/DatePicker/DatePicker";
import {useCardinalityState, useCardinalityDispatch} from "../../state/cardinality/CardinalityStateContext";
import {useEffect} from "react";
import ShortcutKeys from "../ShortcutKeys/ShortcutKeys";
import {getAppModeEnable, getAppModeParams} from "../../utils/app-mode";
const classes = {
logo: {
position: "relative",
display: "flex",
alignItems: "center",
color: "#fff",
cursor: "pointer",
width: "100%",
marginBottom: "2px"
},
issueLink: {
textAlign: "center",
fontSize: "10px",
opacity: ".4",
color: "inherit",
textDecoration: "underline",
transition: ".2s opacity",
whiteSpace: "nowrap",
"&:hover": {
opacity: ".8",
}
},
menuLink: {
display: "block",
padding: "16px 8px",
color: "white",
fontSize: "11px",
textDecoration: "none",
cursor: "pointer",
textTransform: "uppercase",
borderRadius: "4px",
transition: ".2s background",
"&:hover": {
boxShadow: "rgba(0, 0, 0, 0.15) 0px 2px 8px"
}
}
};
import React, { FC, useMemo, useState } from "preact/compat";
import { ExecutionControls } from "../Configurators/TimeRangeSettings/ExecutionControls/ExecutionControls";
import { setQueryStringWithoutPageReload } from "../../utils/query-string";
import { TimeSelector } from "../Configurators/TimeRangeSettings/TimeSelector/TimeSelector";
import GlobalSettings from "../Configurators/GlobalSettings/GlobalSettings";
import { useLocation, useNavigate } from "react-router-dom";
import router, { RouterOptions, routerOptions } from "../../router";
import { useEffect } from "react";
import ShortcutKeys from "../Main/ShortcutKeys/ShortcutKeys";
import { getAppModeEnable, getAppModeParams } from "../../utils/app-mode";
import CardinalityDatePicker from "../Configurators/CardinalityDatePicker/CardinalityDatePicker";
import { LogoIcon } from "../Main/Icons";
import { getCssVariable } from "../../utils/theme";
import Tabs from "../Main/Tabs/Tabs";
import "./style.scss";
import classNames from "classnames";
const Header: FC = () => {
const primaryColor = getCssVariable("color-primary");
const appModeEnable = getAppModeEnable();
const {headerStyles: {
background = appModeEnable ? "#FFF" : "primary.main",
color = appModeEnable ? "primary.main" : "#FFF",
} = {}} = getAppModeParams();
const {date} = useCardinalityState();
const cardinalityDispatch = useCardinalityDispatch();
const { headerStyles: {
background = appModeEnable ? "#FFF" : primaryColor,
color = appModeEnable ? primaryColor : "#FFF",
} = {} } = getAppModeParams();
const navigate = useNavigate();
const {search, pathname} = useLocation();
const { search, pathname } = useLocation();
const routes = useMemo(() => ([
{
label: "Custom panel",
@ -91,66 +48,73 @@ const Header: FC = () => {
const [activeMenu, setActiveMenu] = useState(pathname);
const handleChangeTab = (value: string) => {
setActiveMenu(value);
navigate(value);
};
const headerSetup = useMemo(() => {
return ((routerOptions[pathname] || {}) as RouterOptions).header || {};
}, [pathname]);
const onClickLogo = () => {
navigateHandler(router.home);
setQueryStringWithoutPageReload("");
setQueryStringWithoutPageReload({});
window.location.reload();
};
const navigateHandler = (pathname: string) => {
navigate({pathname, search: search});
navigate({ pathname, search: search });
};
useEffect(() => {
setActiveMenu(pathname);
}, [pathname]);
return <AppBar position="static" sx={{px: 1, boxShadow: "none", bgcolor: background, color}}>
<Toolbar>
{!appModeEnable && (
<Box display="grid" alignItems="center" justifyContent="center">
<Box onClick={onClickLogo} sx={classes.logo}>
<Logo style={{color: "inherit", width: "100%"}}/>
</Box>
<Link sx={classes.issueLink} target="_blank"
href="https://github.com/VictoriaMetrics/VictoriaMetrics/issues/new">
return <header
className={classNames({
"vm-header": true,
"vm-header_app": appModeEnable
})}
style={{ background, color }}
>
{!appModeEnable && (
<div
className="vm-header-logo"
style={{ color }}
>
<div
className="vm-header-logo__icon"
onClick={onClickLogo}
>
<LogoIcon/>
</div>
<a
className="vm-header-logo__issue"
target="_blank"
href="https://github.com/VictoriaMetrics/VictoriaMetrics/issues/new"
rel="noreferrer"
>
create an issue
</Link>
</Box>
)}
<Box ml={appModeEnable ? 0 : 8} flexGrow={1}>
<Tabs value={activeMenu} textColor="inherit" TabIndicatorProps={{style: {background: color}}}
onChange={(e, val) => setActiveMenu(val)}>
{routes.filter(r => !r.hide).map(r => (
<Tab
key={`${r.label}_${r.value}`}
label={r.label}
value={r.value}
component={RouterLink}
to={`${r.value}${search}`}
sx={{color}}
/>
))}
</Tabs>
</Box>
<Box display="flex" gap={1} alignItems="center" mr={0} ml={4}>
{headerSetup?.timeSelector && <TimeSelector/>}
{headerSetup?.datePicker && (
<DatePicker
date={date}
onChange={(val) => cardinalityDispatch({type: "SET_DATE", payload: val})}
/>
)}
{headerSetup?.executionControls && <ExecutionControls/>}
{headerSetup?.globalSettings && !appModeEnable && <GlobalSettings/>}
<ShortcutKeys/>
</Box>
</Toolbar>
</AppBar>;
</a>
</div>
)}
<div className="vm-header-nav">
<Tabs
activeItem={activeMenu}
items={routes.filter(r => !r.hide)}
color={color}
onChange={handleChangeTab}
/>
</div>
<div className="vm-header__settings">
{headerSetup?.timeSelector && <TimeSelector/>}
{headerSetup?.cardinalityDatePicker && <CardinalityDatePicker/>}
{headerSetup?.executionControls && <ExecutionControls/>}
{headerSetup?.globalSettings && !appModeEnable && <GlobalSettings/>}
<ShortcutKeys/>
</div>
</header>;
};
export default Header;

View file

@ -0,0 +1,56 @@
@use "src/styles/variables" as *;
.vm-header {
display: flex;
align-items: center;
justify-content: flex-start;
padding: $padding-small $padding-medium;
gap: $padding-large;
&_app {
padding: $padding-small 0;
}
&-logo {
display: grid;
align-items: center;
justify-content: center;
&__icon {
position: relative;
display: flex;
align-items: center;
cursor: pointer;
width: 100%;
margin-bottom: 2px;
}
&__issue {
text-align: center;
font-size: 10px;
opacity: 0.4;
color: inherit;
text-decoration: underline;
transition: 0.2s opacity;
white-space: nowrap;
cursor: pointer;
&:hover {
opacity: 0.8;
}
}
}
&-nav {
font-size: $font-size-small;
font-weight: 600;
}
&__settings {
display: flex;
align-items: center;
justify-content: flex-end;
gap: $padding-small;
flex-grow: 1;
}
}

View file

@ -1,13 +1,24 @@
import Header from "../Header/Header";
import React, {FC} from "preact/compat";
import Box from "@mui/material/Box";
import React, { FC } from "preact/compat";
import { Outlet } from "react-router-dom";
import "./style.scss";
import { getAppModeEnable } from "../../utils/app-mode";
import classNames from "classnames";
const HomeLayout: FC = () => {
return <Box>
const appModeEnable = getAppModeEnable();
return <section className="vm-container">
<Header/>
<Outlet/>
</Box>;
<div
className={classNames({
"vm-container-body": true,
"vm-container-body_app": appModeEnable
})}
>
<Outlet/>
</div>
</section>;
};
export default HomeLayout;

View file

@ -0,0 +1,19 @@
@use "src/styles/variables" as *;
.vm-container {
display: flex;
flex-direction: column;
min-height: calc(100vh - var(--scrollbar-height));
&-body {
flex-grow: 1;
min-height: 100%;
padding: $padding-medium;
background-color: $color-background-body;
&_app {
padding: $padding-small 0;
background-color: transparent;
}
}
}

View file

@ -1,66 +0,0 @@
import React, {FC, useMemo, useState} from "preact/compat";
import {LegendItem} from "../../utils/uplot/types";
import "./legend.css";
import {getLegendLabel} from "../../utils/uplot/helpers";
import Tooltip from "@mui/material/Tooltip";
export interface LegendProps {
labels: LegendItem[];
query: string[];
onChange: (item: LegendItem, metaKey: boolean) => void;
}
const Legend: FC<LegendProps> = ({labels, query, onChange}) => {
const [copiedValue, setCopiedValue] = useState("");
const groups = useMemo(() => {
return Array.from(new Set(labels.map(l => l.group)));
}, [labels]);
const handleClickFreeField = async (val: string, id: string) => {
await navigator.clipboard.writeText(val);
setCopiedValue(id);
setTimeout(() => setCopiedValue(""), 2000);
};
return <>
<div className="legendWrapper">
{groups.map((group) => <div className="legendGroup" key={group}>
<div className="legendGroupTitle">
<span className="legendGroupQuery">Query {group}</span>
<span>(&quot;{query[group - 1]}&quot;)</span>
</div>
<div>
{labels.filter(l => l.group === group).map((legendItem: LegendItem) =>
<div className={legendItem.checked ? "legendItem" : "legendItem legendItemHide"}
key={legendItem.label}
onClick={(e) => onChange(legendItem, e.ctrlKey || e.metaKey)}>
<div className="legendMarker" style={{backgroundColor: legendItem.color}}/>
<div className="legendLabel">
{getLegendLabel(legendItem.label)}
{!!Object.keys(legendItem.freeFormFields).length && <>
&#160;&#123;
{Object.keys(legendItem.freeFormFields).filter(f => f !== "__name__").map((f) => {
const freeField = `${f}="${legendItem.freeFormFields[f]}"`;
const fieldId = `${legendItem.label}.${freeField}`;
return <Tooltip arrow key={f} open={copiedValue === fieldId} title={"Copied!"}>
<span className="legendFreeFields" onClick={(e) => {
e.stopPropagation();
handleClickFreeField(freeField, fieldId);
}}>
{freeField}
</span>
</Tooltip>;
})}
&#125;
</>}
</div>
</div>
)}
</div>
</div>)}
</div>
</>;
};
export default Legend;

View file

@ -1,77 +0,0 @@
.legendWrapper {
position: relative;
display: flex;
flex-wrap: wrap;
margin-top: 20px;
cursor: default;
}
.legendGroup {
margin: 0 12px 0 0;
padding: 10px 6px;
}
.legendGroupTitle {
display: flex;
align-items: center;
padding: 0 10px 5px;
margin-bottom: 5px;
font-size: 11px;
border-bottom: 1px solid #ECEBE6;
}
.legendGroupQuery {
font-weight: bold;
margin-right: 4px;
}
.legendGroupLine {
margin-right: 10px;
}
.legendItem {
display: grid;
grid-template-columns: auto auto;
grid-gap: 6px;
align-items: start;
justify-content: start;
padding: 7px 50px 7px 10px;
background-color: #FFF;
cursor: pointer;
transition: 0.2s ease;
}
.legendItemHide {
text-decoration: line-through;
opacity: 0.5;
}
.legendItem:hover {
background-color: rgba(0, 0, 0, 0.1);
}
.legendMarker {
width: 12px;
height: 12px;
box-sizing: border-box;
transition: 0.2s ease;
}
.legendLabel {
font-size: 11px;
line-height: 12px;
font-weight: normal;
}
.legendFreeFields {
padding: 3px;
cursor: pointer;
}
.legendFreeFields:hover {
text-decoration: underline;
}
.legendFreeFields:not(:last-child):after {
content: ",";
}

View file

@ -1,41 +0,0 @@
.u-tooltip {
position: absolute;
display: none;
grid-gap: 12px;
max-width: 300px;
padding: 8px;
border-radius: 4px;
background: rgba(57, 57, 57, 0.9);
color: #fff;
font-size: 10px;
line-height: 1.4em;
font-weight: bold;
word-wrap: break-word;
font-family: monospace;
pointer-events: none;
z-index: 100;
}
.u-tooltip-data {
display: flex;
flex-wrap: wrap;
align-items: center;
font-size: 11px;
line-height: 150%;
}
.u-tooltip-data__value {
padding: 4px;
font-weight: bold;
}
.u-tooltip__info {
display: grid;
grid-gap: 4px;
}
.u-tooltip__marker {
width: 12px;
height: 12px;
margin-right: 4px;
}

View file

@ -0,0 +1,52 @@
import React, { FC, useState, useEffect } from "preact/compat";
import { ArrowDownIcon } from "../Icons";
import "./style.scss";
import { ReactNode } from "react";
interface AccordionProps {
title: ReactNode
children: ReactNode
defaultExpanded?: boolean
onChange?: (value: boolean) => void
}
const Accordion: FC<AccordionProps> = ({
defaultExpanded = false,
onChange,
title,
children
}) => {
const [isOpen, setIsOpen] = useState(defaultExpanded);
const toggleOpen = () => {
setIsOpen(prev => !prev);
};
useEffect(() => {
onChange && onChange(isOpen);
}, [isOpen]);
return (
<>
<header
className={`vm-accordion-header ${isOpen && "vm-accordion-header_open"}`}
onClick={toggleOpen}
>
{title}
<div className={`vm-accordion-header__arrow ${isOpen && "vm-accordion-header__arrow_open"}`}>
<ArrowDownIcon />
</div>
</header>
{isOpen && (
<section
className="vm-accordion-section"
key="content"
>
{children}
</section>
)}
</>
);
};
export default Accordion;

View file

@ -0,0 +1,33 @@
@use "src/styles/variables" as *;
.vm-accordion-header {
position: relative;
font-size: inherit;
cursor: pointer;
display: grid;
align-items: center;
&__arrow {
display: flex;
align-items: center;
justify-content: center;
position: absolute;
right: 14px;
top: auto;
transform: rotate(0);
transition: transform 200ms ease-in-out;
&_open {
transform: rotate(180deg);
}
svg {
width: 14px;
height: auto;
}
}
}
.accordion-section {
overflow: hidden;
}

View file

@ -0,0 +1,36 @@
import React, { FC } from "preact/compat";
import { ReactNode } from "react";
import classNames from "classnames";
import "./style.scss";
import { ErrorIcon, InfoIcon, SuccessIcon, WarningIcon } from "../Icons";
interface AlertProps {
variant?: "success" | "error" | "info" | "warning"
children: ReactNode
}
const icons = {
success: <SuccessIcon/>,
error: <ErrorIcon/>,
warning: <WarningIcon/>,
info: <InfoIcon/>
};
const Alert: FC<AlertProps> = ({
variant,
children }) => {
return (
<div
className={classNames({
"vm-alert": true,
[`vm-alert_${variant}`]: variant
})}
>
<div className="vm-alert__icon">{icons[variant || "info"]}</div>
<div className="vm-alert__content">{children}</div>
</div>
);
};
export default Alert;

View file

@ -0,0 +1,77 @@
@use "src/styles/variables" as *;
.vm-alert {
position: relative;
display: grid;
grid-template-columns: 20px 1fr;
align-items: center;
gap: $padding-small;
padding: $padding-global;
background-color: $color-background-block;
border-radius: $border-radius-medium;
box-shadow: $box-shadow;
font-size: $font-size-medium;
font-weight: 500;
color: $color-text;
line-height: 1.3;
&:after {
position: absolute;
content: '';
top: 0;
left: 0;
width: 100%;
height: 100%;
border-radius: $border-radius-medium;
z-index: 1;
opacity: 0.1;
}
&__icon,
&__content {
position: relative;
z-index: 2;
}
&__icon {
display: flex;
align-items: center;
justify-content: center;
}
&__content {
filter: brightness(0.6);
}
&_success {
color: $color-success;
&:after {
background-color: $color-success;
}
}
&_error {
color: $color-error;
&:after {
background-color: $color-error;
}
}
&_info {
color: $color-info;
&:after {
background-color: $color-info;
}
}
&_warning {
color: $color-warning;
&:after {
background-color: $color-warning;
}
}
}

View file

@ -0,0 +1,58 @@
import React, { FC } from "preact/compat";
import classNames from "classnames";
import { MouseEvent as ReactMouseEvent, ReactNode } from "react";
import "./style.scss";
interface ButtonProps {
variant?: "contained" | "outlined" | "text"
color?: "primary" | "secondary" | "success" | "error"
size?: "small" | "medium" | "large"
endIcon?: ReactNode
startIcon?: ReactNode
fullWidth?: boolean
disabled?: boolean
children?: ReactNode
className?: string
onClick?: (e: ReactMouseEvent<HTMLButtonElement, MouseEvent>) => void
}
const Button: FC<ButtonProps> = ({
variant = "contained",
color = "primary",
size = "medium",
children,
endIcon,
startIcon,
fullWidth = false,
className,
disabled,
onClick,
}) => {
const classesButton = classNames({
"vm-button": true,
[`vm-button_${variant}_${color}`]: true,
[`vm-button_${size}`]: size,
"vm-button_icon": (startIcon || endIcon) && !children,
"vm-button_full-width": fullWidth,
"vm-button_with-icon": startIcon || endIcon,
"vm-button_disabled": disabled,
[className || ""]: className
});
return (
<button
className={classesButton}
disabled={disabled}
onClick={onClick}
>
<>
{startIcon && <span className="vm-button__start-icon">{startIcon}</span>}
{children && <span>{children}</span>}
{endIcon && <span className="vm-button__end-icon">{endIcon}</span>}
</>
</button>
);
};
export default Button;

View file

@ -0,0 +1,177 @@
@use "src/styles/variables" as *;
$button-radius: 6px;
.vm-button {
position: relative;
display: flex;
align-items: center;
justify-content: center;
padding: 6px 14px;
font-size: $font-size-small;
line-height: 15px;
font-weight: 500;
min-height: 31px;
border-radius: $button-radius;
color: $color-white;
transform-style: preserve-3d;
cursor: pointer;
text-transform: uppercase;
user-select: none;
white-space: nowrap;
&:hover:after {
background-color: rgba($color-black, 0.05);
}
&:before,
&:after {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
transition: background-color 200ms ease;
border-radius: $button-radius;
}
&:before {
transform: translateZ(-2px);
}
&:after {
background-color: transparent;
transform: translateZ(-1px);
}
span {
display: grid;
align-items: center;
justify-content: center;
svg {
width: 15px;
}
}
&__start-icon {
margin-right: 6px;
}
&__end-icon {
margin-left: 6px;
}
&_disabled {
opacity: 0.3;
cursor: not-allowed;
}
&_icon {
padding: 6px $padding-small;
}
&_icon &__start-icon,
&_icon &__end-icon {
margin: 0;
}
/* size SMALL */
&_small {
padding: 4px 6px;
min-height: 25px;
span {
svg {
width: 13px;
}
}
}
/* variant CONTAINED */
&_contained_primary {
color: $color-primary-text;
&:before {
background-color: $color-primary;
}
&:hover:after {
background-color: rgba($color-black, 0.2)
}
}
&_contained_secondary {
color: $color-secondary-text;
&:before {
background-color: $color-secondary;
}
&:hover:after {
background-color: rgba($color-black, 0.2)
}
}
&_contained_success {
color: $color-success-text;
&:before {
background-color: $color-success;
}
&:hover:after {
background-color: rgba($color-black, 0.2)
}
}
&_contained_error {
color: $color-error-text;
&:before {
background-color: $color-error;
}
}
/* variant TEXT */
&_text_primary {
color: $color-primary;
}
&_text_secondary {
color: $color-secondary;
}
&_text_success {
color: $color-success;
}
&_text_error {
color: $color-error;
}
/* variant OUTLINED */
&_outlined_primary {
border: 1px solid $color-primary;
color: $color-primary;
}
&_outlined_error {
border: 1px solid $color-error;
color: $color-error;
}
&_outlined_secondary {
border: 1px solid $color-secondary;
color: $color-secondary;
}
&_outlined_success {
border: 1px solid $color-success;
color: $color-success;
}
}

View file

@ -0,0 +1,46 @@
import React from "react";
import classNames from "classnames";
import "./style.scss";
import { FC } from "preact/compat";
import { DoneIcon } from "../Icons";
interface CheckboxProps {
checked: boolean
color?: "primary" | "secondary" | "error"
disabled?: boolean
label?: string
onChange: (value: boolean) => void
}
const Checkbox: FC<CheckboxProps> = ({
checked = false, disabled = false, label, color = "secondary", onChange
}) => {
const toggleCheckbox = () => {
if (disabled) return;
onChange(!checked);
};
const checkboxClasses = classNames({
"vm-checkbox": true,
"vm-checkbox_disabled": disabled,
"vm-checkbox_active": checked,
[`vm-checkbox_${color}_active`]: checked,
[`vm-checkbox_${color}`]: color
});
return (
<div
className={checkboxClasses}
onClick={toggleCheckbox}
>
<div className="vm-checkbox-track">
<div className="vm-checkbox-track__thumb">
<DoneIcon/>
</div>
</div>
{label && <span className="vm-checkbox__label">{label}</span>}
</div>
);
};
export default Checkbox;

View file

@ -0,0 +1,81 @@
@use "src/styles/variables" as *;
$checkbox-size: 16px;
$checkbox-padding: 2px;
$checkbox-handle-size: $checkbox-size - ($checkbox-padding * 2);
$checkbox-border-radius: $border-radius-small;
.vm-checkbox {
display: flex;
align-items: center;
justify-content: flex-start;
cursor: pointer;
user-select: none;
&_disabled {
opacity: 0.6;
cursor: default;
}
&_secondary_active &-track {
background-color: $color-secondary;
}
&_secondary &-track {
border: 1px solid $color-secondary;
}
&_primary_active &-track {
background-color: $color-primary;
}
&_primary &-track {
border: 1px solid $color-primary;
}
&_active &-track {
&__thumb {
transform: scale(1);
}
}
&:hover &-track {
opacity: 0.8;
}
&-track {
position: relative;
background-color: transparent;
display: flex;
align-items: center;
justify-content: center;
border-radius: $checkbox-border-radius;
padding: $checkbox-padding;
width: $checkbox-size;
height: $checkbox-size;
transition: background-color 200ms ease, opacity 300ms ease-out;
&__thumb {
display: grid;
align-items: center;
justify-content: center;
width: $checkbox-handle-size;
height: $checkbox-handle-size;
color: $color-white;
transform: scale(0);
transition: transform 100ms ease-in-out;
svg {
width: 100%;
}
}
}
&__label {
white-space: nowrap;
font-size: inherit;
color: inherit;
margin-left: $padding-small;
transition: color 200ms ease;
}
}

View file

@ -0,0 +1,120 @@
import React, { FC, useEffect, useState } from "preact/compat";
import dayjs, { Dayjs } from "dayjs";
import CalendarHeader from "./CalendarHeader/CalendarHeader";
import CalendarBody from "./CalendarBody/CalendarBody";
import YearsList from "./YearsList/YearsList";
import TimePicker from "../TImePicker/TimePicker";
import { DATE_TIME_FORMAT } from "../../../../constants/date";
import "./style.scss";
import { CalendarIcon, ClockIcon } from "../../Icons";
import Tabs from "../../Tabs/Tabs";
interface DatePickerProps {
date: Date | Dayjs
format?: string
timepicker?: boolean,
onChange: (date: string) => void
onClose?: () => void
}
const tabs = [
{ value: "date", icon: <CalendarIcon/> },
{ value: "time", icon: <ClockIcon/> }
];
const Calendar: FC<DatePickerProps> = ({
date,
timepicker = false,
format = DATE_TIME_FORMAT,
onChange,
onClose
}) => {
const [displayYears, setDisplayYears] = useState(false);
const [viewDate, setViewDate] = useState(dayjs(date));
const [selectDate, setSelectDate] = useState(dayjs(date));
const [tab, setTab] = useState(tabs[0].value);
const toggleDisplayYears = () => {
setDisplayYears(prev => !prev);
};
const handleChangeViewDate = (date: Dayjs) => {
setViewDate(date);
setDisplayYears(false);
};
const handleChangeSelectDate = (date: Dayjs) => {
setSelectDate(date);
if (timepicker) setTab("time");
};
const handleChangeTime = (time: string) => {
const [hour, minute, second] = time.split(":");
setSelectDate(prev => prev.set("hour", +hour).set("minute", +minute).set("second", +second));
};
const handleChangeTab = (value: string) => {
setTab(value);
};
const handleClose = () => {
onClose && onClose();
};
useEffect(() => {
if (selectDate.format() === dayjs(date).format()) return;
onChange(selectDate.format(format));
}, [selectDate]);
return (
<div className="vm-calendar">
{tab === "date" && (
<CalendarHeader
viewDate={viewDate}
onChangeViewDate={handleChangeViewDate}
toggleDisplayYears={toggleDisplayYears}
displayYears={displayYears}
/>
)}
{tab === "date" && (
<>
{!displayYears && (
<CalendarBody
viewDate={viewDate}
selectDate={selectDate}
onChangeSelectDate={handleChangeSelectDate}
/>
)}
{displayYears && (
<YearsList
viewDate={viewDate}
onChangeViewDate={handleChangeViewDate}
/>
)}
</>
)}
{tab === "time" && (
<TimePicker
selectDate={selectDate}
onChangeTime={handleChangeTime}
onClose={handleClose}
/>
)}
{timepicker && (
<div className="vm-calendar__tabs">
<Tabs
activeItem={tab}
items={tabs}
onChange={handleChangeTab}
indicatorPlacement="top"
/>
</div>
)}
</div>
);
};
export default Calendar;

Some files were not shown because too many files have changed in this diff Show more