mirror of
https://github.com/VictoriaMetrics/VictoriaMetrics.git
synced 2024-11-21 14:44:00 +00:00
Cardinality explorer (#2625)
* Cardinality explorer * vmui, vmselect: updated field name, added description to spinner * make vmui-update * updated const name, make vmui-update * lib/storage: changes calculation for totalSeries values * added static files * wip * wip * wip * wip * docs/CHANGELOG.md: document cardinality explorer feature See https://github.com/VictoriaMetrics/VictoriaMetrics/issues/2233 Co-authored-by: f41gh7 <nik@victoriametrics.com> Co-authored-by: Aliaksandr Valialkin <valyala@victoriametrics.com>
This commit is contained in:
parent
ba406ff28c
commit
f2754c3e90
50 changed files with 1878 additions and 136 deletions
|
@ -207,6 +207,7 @@ func requestHandler(w http.ResponseWriter, r *http.Request) bool {
|
|||
}
|
||||
if path == "/api/v1/status/top_queries" {
|
||||
globalTopQueriesRequests.Inc()
|
||||
httpserver.EnableCORS(w, r)
|
||||
if err := prometheus.QueryStatsHandler(startTime, nil, w, r); err != nil {
|
||||
globalTopQueriesErrors.Inc()
|
||||
sendPrometheusError(w, r, err)
|
||||
|
@ -376,6 +377,7 @@ func selectHandler(qt *querytracer.Tracer, startTime time.Time, w http.ResponseW
|
|||
return true
|
||||
case "prometheus/api/v1/status/tsdb":
|
||||
statusTSDBRequests.Inc()
|
||||
httpserver.EnableCORS(w, r)
|
||||
if err := prometheus.TSDBStatusHandler(startTime, at, w, r); err != nil {
|
||||
statusTSDBErrors.Inc()
|
||||
sendPrometheusError(w, r, err)
|
||||
|
@ -388,6 +390,7 @@ func selectHandler(qt *querytracer.Tracer, startTime time.Time, w http.ResponseW
|
|||
return true
|
||||
case "prometheus/api/v1/status/top_queries":
|
||||
topQueriesRequests.Inc()
|
||||
httpserver.EnableCORS(w, r)
|
||||
if err := prometheus.QueryStatsHandler(startTime, at, w, r); err != nil {
|
||||
topQueriesErrors.Inc()
|
||||
sendPrometheusError(w, r, err)
|
||||
|
|
|
@ -2407,10 +2407,20 @@ func (sn *storageNode) getTSDBStatusForDateOnConn(bc *handshake.BufferedConn, ac
|
|||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot read seriesCountByLabelValuePair: %w", err)
|
||||
}
|
||||
totalSeries, err := readUint64(bc)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot read totalSeries: %w", err)
|
||||
}
|
||||
totalLabelValuePairs, err := readUint64(bc)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot read totalLabelValuePairs: %w", err)
|
||||
}
|
||||
status := &storage.TSDBStatus{
|
||||
SeriesCountByMetricName: seriesCountByMetricName,
|
||||
LabelValueCountByLabelName: labelValueCountByLabelName,
|
||||
SeriesCountByLabelValuePair: seriesCountByLabelValuePair,
|
||||
TotalSeries: totalSeries,
|
||||
TotalLabelValuePairs: totalLabelValuePairs,
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
|
|
|
@ -48,10 +48,10 @@ var (
|
|||
selectNodes = flagutil.NewArray("selectNode", "Comma-separated addresses of vmselect nodes; usage: -selectNode=vmselect-host1,...,vmselect-hostN")
|
||||
|
||||
maxUniqueTimeseries = flag.Int("search.maxUniqueTimeseries", 300e3, "The maximum number of unique time series, which can be selected during /api/v1/query and /api/v1/query_range queries. This option allows limiting memory usage")
|
||||
maxFederateSeries = flag.Int("search.maxFederateSeries", 300e3, "The maximum number of time series, which can be returned from /federate. This option allows limiting memory usage")
|
||||
maxExportSeries = flag.Int("search.maxExportSeries", 1e6, "The maximum number of time series, which can be returned from /api/v1/export* APIs. This option allows limiting memory usage")
|
||||
maxTSDBStatusSeries = flag.Int("search.maxTSDBStatusSeries", 1e6, "The maximum number of time series, which can be processed during the call to /api/v1/status/tsdb. This option allows limiting memory usage")
|
||||
maxSeriesLimit = flag.Int("search.maxSeries", 10e3, "The maximum number of time series, which can be returned from /api/v1/series. This option allows limiting memory usage")
|
||||
maxFederateSeries = flag.Int("search.maxFederateSeries", 1e6, "The maximum number of time series, which can be returned from /federate. This option allows limiting memory usage")
|
||||
maxExportSeries = flag.Int("search.maxExportSeries", 10e6, "The maximum number of time series, which can be returned from /api/v1/export* APIs. This option allows limiting memory usage")
|
||||
maxTSDBStatusSeries = flag.Int("search.maxTSDBStatusSeries", 10e6, "The maximum number of time series, which can be processed during the call to /api/v1/status/tsdb. This option allows limiting memory usage")
|
||||
maxSeriesLimit = flag.Int("search.maxSeries", 100e3, "The maximum number of time series, which can be returned from /api/v1/series. This option allows limiting memory usage")
|
||||
)
|
||||
|
||||
// Default step used if not set.
|
||||
|
|
|
@ -7,9 +7,11 @@ TSDBStatusResponse generates response for /api/v1/status/tsdb .
|
|||
"status":"success",
|
||||
"isPartial":{% if isPartial %}true{% else %}false{% endif %},
|
||||
"data":{
|
||||
"totalSeries": {%dul= status.TotalSeries %},
|
||||
"totalLabelValuePairs": {%dul= status.TotalLabelValuePairs %},
|
||||
"seriesCountByMetricName":{%= tsdbStatusEntries(status.SeriesCountByMetricName) %},
|
||||
"labelValueCountByLabelName":{%= tsdbStatusEntries(status.LabelValueCountByLabelName) %},
|
||||
"seriesCountByLabelValuePair":{%= tsdbStatusEntries(status.SeriesCountByLabelValuePair) %}
|
||||
"seriesCountByLabelValuePair":{%= tsdbStatusEntries(status.SeriesCountByLabelValuePair) %},
|
||||
"labelValueCountByLabelName":{%= tsdbStatusEntries(status.LabelValueCountByLabelName) %}
|
||||
}
|
||||
}
|
||||
{% endfunc %}
|
||||
|
|
|
@ -37,99 +37,107 @@ func StreamTSDBStatusResponse(qw422016 *qt422016.Writer, isPartial bool, status
|
|||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:8
|
||||
}
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:8
|
||||
qw422016.N().S(`,"data":{"seriesCountByMetricName":`)
|
||||
qw422016.N().S(`,"data":{"totalSeries":`)
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:10
|
||||
qw422016.N().DUL(status.TotalSeries)
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:10
|
||||
qw422016.N().S(`,"totalLabelValuePairs":`)
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:11
|
||||
qw422016.N().DUL(status.TotalLabelValuePairs)
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:11
|
||||
qw422016.N().S(`,"seriesCountByMetricName":`)
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:12
|
||||
streamtsdbStatusEntries(qw422016, status.SeriesCountByMetricName)
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:10
|
||||
qw422016.N().S(`,"labelValueCountByLabelName":`)
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:11
|
||||
streamtsdbStatusEntries(qw422016, status.LabelValueCountByLabelName)
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:11
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:12
|
||||
qw422016.N().S(`,"seriesCountByLabelValuePair":`)
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:12
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:13
|
||||
streamtsdbStatusEntries(qw422016, status.SeriesCountByLabelValuePair)
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:12
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:13
|
||||
qw422016.N().S(`,"labelValueCountByLabelName":`)
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:14
|
||||
streamtsdbStatusEntries(qw422016, status.LabelValueCountByLabelName)
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:14
|
||||
qw422016.N().S(`}}`)
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:15
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:17
|
||||
}
|
||||
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:15
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:17
|
||||
func WriteTSDBStatusResponse(qq422016 qtio422016.Writer, isPartial bool, status *storage.TSDBStatus) {
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:15
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:17
|
||||
qw422016 := qt422016.AcquireWriter(qq422016)
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:15
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:17
|
||||
StreamTSDBStatusResponse(qw422016, isPartial, status)
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:15
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:17
|
||||
qt422016.ReleaseWriter(qw422016)
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:15
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:17
|
||||
}
|
||||
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:15
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:17
|
||||
func TSDBStatusResponse(isPartial bool, status *storage.TSDBStatus) string {
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:15
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:17
|
||||
qb422016 := qt422016.AcquireByteBuffer()
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:15
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:17
|
||||
WriteTSDBStatusResponse(qb422016, isPartial, status)
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:15
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:17
|
||||
qs422016 := string(qb422016.B)
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:15
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:17
|
||||
qt422016.ReleaseByteBuffer(qb422016)
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:15
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:17
|
||||
return qs422016
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:15
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:17
|
||||
}
|
||||
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:17
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:19
|
||||
func streamtsdbStatusEntries(qw422016 *qt422016.Writer, a []storage.TopHeapEntry) {
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:17
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:19
|
||||
qw422016.N().S(`[`)
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:19
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:21
|
||||
for i, e := range a {
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:19
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:21
|
||||
qw422016.N().S(`{"name":`)
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:21
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:23
|
||||
qw422016.N().Q(e.Name)
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:21
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:23
|
||||
qw422016.N().S(`,"value":`)
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:22
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:24
|
||||
qw422016.N().D(int(e.Count))
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:22
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:24
|
||||
qw422016.N().S(`}`)
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:24
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:26
|
||||
if i+1 < len(a) {
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:24
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:26
|
||||
qw422016.N().S(`,`)
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:24
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:26
|
||||
}
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:25
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:27
|
||||
}
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:25
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:27
|
||||
qw422016.N().S(`]`)
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:27
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:29
|
||||
}
|
||||
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:27
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:29
|
||||
func writetsdbStatusEntries(qq422016 qtio422016.Writer, a []storage.TopHeapEntry) {
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:27
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:29
|
||||
qw422016 := qt422016.AcquireWriter(qq422016)
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:27
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:29
|
||||
streamtsdbStatusEntries(qw422016, a)
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:27
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:29
|
||||
qt422016.ReleaseWriter(qw422016)
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:27
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:29
|
||||
}
|
||||
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:27
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:29
|
||||
func tsdbStatusEntries(a []storage.TopHeapEntry) string {
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:27
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:29
|
||||
qb422016 := qt422016.AcquireByteBuffer()
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:27
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:29
|
||||
writetsdbStatusEntries(qb422016, a)
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:27
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:29
|
||||
qs422016 := string(qb422016.B)
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:27
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:29
|
||||
qt422016.ReleaseByteBuffer(qb422016)
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:27
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:29
|
||||
return qs422016
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:27
|
||||
//line app/vmselect/prometheus/tsdb_status_response.qtpl:29
|
||||
}
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"files": {
|
||||
"main.css": "./static/css/main.d8362c27.css",
|
||||
"main.js": "./static/js/main.a35e61a3.js",
|
||||
"main.js": "./static/js/main.105dbc4f.js",
|
||||
"static/js/27.939f971b.chunk.js": "./static/js/27.939f971b.chunk.js",
|
||||
"index.html": "./index.html"
|
||||
},
|
||||
"entrypoints": [
|
||||
"static/css/main.d8362c27.css",
|
||||
"static/js/main.a35e61a3.js"
|
||||
"static/js/main.105dbc4f.js"
|
||||
]
|
||||
}
|
BIN
app/vmselect/vmui/favicon.ico
Normal file
BIN
app/vmselect/vmui/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 15 KiB |
|
@ -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="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap"/><script src="./dashboards/index.js" type="module"></script><script defer="defer" src="./static/js/main.a35e61a3.js"></script><link href="./static/css/main.d8362c27.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="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap"/><script src="./dashboards/index.js" type="module"></script><script defer="defer" src="./static/js/main.105dbc4f.js"></script><link href="./static/css/main.d8362c27.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>
|
2
app/vmselect/vmui/static/js/main.105dbc4f.js
Normal file
2
app/vmselect/vmui/static/js/main.105dbc4f.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
@ -959,6 +959,12 @@ func (s *Server) processVMSelectTSDBStatus(ctx *vmselectRequestCtx) error {
|
|||
if err := writeTopHeapEntries(ctx, status.SeriesCountByLabelValuePair); err != nil {
|
||||
return fmt.Errorf("cannot write seriesCountByLabelValuePair to vmselect: %w", err)
|
||||
}
|
||||
if err := ctx.writeUint64(status.TotalSeries); err != nil {
|
||||
return fmt.Errorf("cannot write totalSeries to vmselect: %w", err)
|
||||
}
|
||||
if err := ctx.writeUint64(status.TotalLabelValuePairs); err != nil {
|
||||
return fmt.Errorf("cannot write totalLabelValuePairs to vmselect: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
BIN
app/vmui/packages/vmui/public/favicon.ico
Normal file
BIN
app/vmui/packages/vmui/public/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 15 KiB |
|
@ -4,6 +4,7 @@ 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 THEME from "./theme/theme";
|
||||
import { ThemeProvider, StyledEngineProvider } from "@mui/material/styles";
|
||||
import CssBaseline from "@mui/material/CssBaseline";
|
||||
|
@ -14,6 +15,7 @@ import router from "./router/index";
|
|||
import CustomPanel from "./components/CustomPanel/CustomPanel";
|
||||
import HomeLayout from "./components/Home/HomeLayout";
|
||||
import DashboardsLayout from "./components/PredefinedPanels/DashboardsLayout";
|
||||
import CardinalityPanel from "./components/CardinalityPanel/CardinalityPanel";
|
||||
|
||||
|
||||
const App: FC = () => {
|
||||
|
@ -27,14 +29,17 @@ const App: FC = () => {
|
|||
<StateProvider> {/* Serialized into query string, common app settings */}
|
||||
<AuthStateProvider> {/* Auth related info - optionally persisted to Local Storage */}
|
||||
<GraphStateProvider> {/* Graph settings */}
|
||||
<SnackbarProvider> {/* Display various snackbars */}
|
||||
<Routes>
|
||||
<Route path={"/"} element={<HomeLayout/>}>
|
||||
<Route path={router.home} element={<CustomPanel/>}/>
|
||||
<Route path={router.dashboards} element={<DashboardsLayout/>}/>
|
||||
</Route>
|
||||
</Routes>
|
||||
</SnackbarProvider>
|
||||
<CardinalityStateProvider> {/* Cardinality 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>
|
||||
</Routes>
|
||||
</SnackbarProvider>
|
||||
</CardinalityStateProvider>
|
||||
</GraphStateProvider>
|
||||
</AuthStateProvider>
|
||||
</StateProvider>
|
||||
|
|
12
app/vmui/packages/vmui/src/api/tsdb.ts
Normal file
12
app/vmui/packages/vmui/src/api/tsdb.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
export interface CardinalityRequestsParams {
|
||||
topN: number,
|
||||
extraLabel: string | null,
|
||||
match: string | null,
|
||||
date: string | null,
|
||||
}
|
||||
|
||||
export const getCardinalityInfo = (server: string, requestsParam: CardinalityRequestsParams) => {
|
||||
const match = requestsParam.match ? `&match[]=${requestsParam.match}` : "";
|
||||
return `${server}/api/v1/status/tsdb?topN=${requestsParam.topN}&date=${requestsParam.date}${match}`;
|
||||
};
|
||||
|
41
app/vmui/packages/vmui/src/components/BarChart/BarChart.tsx
Normal file
41
app/vmui/packages/vmui/src/components/BarChart/BarChart.tsx
Normal file
|
@ -0,0 +1,41 @@
|
|||
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";
|
||||
|
||||
const BarChart: FC<BarChartProps> = ({
|
||||
data,
|
||||
container,
|
||||
configs}) => {
|
||||
|
||||
const uPlotRef = useRef<HTMLDivElement>(null);
|
||||
const [isPanning, setPanning] = useState(false);
|
||||
const [uPlotInst, setUPlotInst] = useState<uPlot>();
|
||||
const layoutSize = useResize(container);
|
||||
|
||||
const options: uPlotOptions ={
|
||||
...configs,
|
||||
width: layoutSize.width || 400,
|
||||
};
|
||||
|
||||
const updateChart = (): void => {
|
||||
if (!uPlotInst) return;
|
||||
uPlotInst.setData(data);
|
||||
if (!isPanning) uPlotInst.redraw();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!uPlotRef.current) return;
|
||||
const u = new uPlot(options, data, uPlotRef.current);
|
||||
setUPlotInst(u);
|
||||
return u.destroy;
|
||||
}, [uPlotRef.current, layoutSize]);
|
||||
|
||||
useEffect(() => updateChart(), [data]);
|
||||
|
||||
return <div style={{pointerEvents: isPanning ? "none" : "auto", height: "100%"}}>
|
||||
<div ref={uPlotRef}/>
|
||||
</div>;
|
||||
};
|
||||
|
||||
export default BarChart;
|
51
app/vmui/packages/vmui/src/components/BarChart/consts.ts
Normal file
51
app/vmui/packages/vmui/src/components/BarChart/consts.ts
Normal file
|
@ -0,0 +1,51 @@
|
|||
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 = {
|
||||
unit: 3,
|
||||
values: (u: { data: number[][]; }) => u.data[1].map((_: number, idx) =>
|
||||
idx !== 0 ? "#33BB55" : "#F79420"
|
||||
),
|
||||
};
|
||||
|
||||
const fill: Fill = {
|
||||
unit: 3,
|
||||
values: (u: { data: number[][]; }) => u.data[1].map((_: number, idx) =>
|
||||
idx !== 0 ? "#33BB55" : "#F79420"
|
||||
),
|
||||
};
|
||||
|
||||
export const barOptions = {
|
||||
height: 500,
|
||||
width: 500,
|
||||
padding: [null, 0, null, 0] as [top: PaddingSide, right: PaddingSide, bottom: PaddingSide, left: PaddingSide],
|
||||
axes: [{ show: false }],
|
||||
series: [
|
||||
{
|
||||
label: "",
|
||||
value: (u: uPlot, v: string) => v
|
||||
},
|
||||
{
|
||||
label: " ",
|
||||
width: 0,
|
||||
fill: "",
|
||||
values: (u: uPlot, seriesIdx: number) => {
|
||||
const idxs = u.legend.idxs || [];
|
||||
|
||||
if (u.data === null || idxs.length === 0)
|
||||
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};
|
||||
}
|
||||
},
|
||||
] as Series[],
|
||||
plugins: [seriesBarsPlugin(getBarSeries([1], 0, 1, 0, barDisp(stroke, fill)))],
|
||||
};
|
7
app/vmui/packages/vmui/src/components/BarChart/types.ts
Normal file
7
app/vmui/packages/vmui/src/components/BarChart/types.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
import {AlignedData as uPlotData, Options as uPlotOptions} from "uplot";
|
||||
|
||||
export interface BarChartProps {
|
||||
data: uPlotData;
|
||||
container: HTMLDivElement | null,
|
||||
configs: uPlotOptions,
|
||||
}
|
|
@ -0,0 +1,27 @@
|
|||
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>
|
||||
);
|
|
@ -0,0 +1,78 @@
|
|||
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;
|
||||
query: string;
|
||||
topN: number;
|
||||
error?: ErrorTypes | string;
|
||||
}
|
||||
|
||||
const CardinalityConfigurator: FC<CardinalityConfiguratorProps> = ({
|
||||
topN,
|
||||
error,
|
||||
query,
|
||||
onSetHistory,
|
||||
onRunQuery,
|
||||
onSetQuery,
|
||||
onTopNChange }) => {
|
||||
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" gap="4px" width="100%" mb={0}>
|
||||
<QueryEditor
|
||||
query={query} index={0} autocomplete={autocomplete} queryOptions={queryOptions}
|
||||
error={error} setHistoryIndex={onSetHistory} runQuery={onRunQuery} setQuery={onSetQuery}
|
||||
label={"Arbitrary time series selector"}
|
||||
/>
|
||||
<Tooltip title="Execute Query">
|
||||
<IconButton onClick={onRunQuery} sx={{height: "49px", width: "49px"}}>
|
||||
<PlayCircleOutlineIcon/>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box display="flex" alignItems="center" mt={3} mr={"53px"}>
|
||||
<Box>
|
||||
<FormControlLabel label="Enable autocomplete"
|
||||
control={<BasicSwitch checked={autocomplete} onChange={onChangeAutocomplete}/>}
|
||||
/>
|
||||
</Box>
|
||||
<Box ml={2}>
|
||||
<TextField
|
||||
label="Number of top entries"
|
||||
type="number"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
value={topN}
|
||||
error={topN < 1}
|
||||
helperText={topN < 1 ? "Number must be bigger than zero" : " "}
|
||||
onChange={onTopNChange}/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>;
|
||||
};
|
||||
|
||||
export default CardinalityConfigurator;
|
|
@ -0,0 +1,157 @@
|
|||
import React, {ChangeEvent, FC, useState} from "react";
|
||||
import {SyntheticEvent} from "react";
|
||||
import {Typography, Grid, Alert, Box, Tabs, Tab, Tooltip} from "@mui/material";
|
||||
import TableChartIcon from "@mui/icons-material/TableChart";
|
||||
import ShowChartIcon from "@mui/icons-material/ShowChart";
|
||||
import {useFetchQuery} from "../../hooks/useCardinalityFetch";
|
||||
import EnhancedTable from "../Table/Table";
|
||||
import {TSDBStatus, TopHeapEntry, DefaultState, Tabs as TabsType, Containers} from "./types";
|
||||
import {
|
||||
defaultHeadCells,
|
||||
headCellsWithProgress,
|
||||
SPINNER_TITLE,
|
||||
spinnerContainerStyles
|
||||
} from "./consts";
|
||||
import {defaultProperties, progressCount, queryUpdater, tableTitles} from "./helpers";
|
||||
import {Data} from "../Table/types";
|
||||
import BarChart from "../BarChart/BarChart";
|
||||
import CardinalityConfigurator from "./CardinalityConfigurator/CardinalityConfigurator";
|
||||
import {barOptions} from "../BarChart/consts";
|
||||
import Spinner from "../common/Spinner";
|
||||
import TabPanel from "../TabPanel/TabPanel";
|
||||
import {useCardinalityDispatch, useCardinalityState} from "../../state/cardinality/CardinalityStateContext";
|
||||
import {tableCells} from "./TableCells/TableCells";
|
||||
|
||||
const CardinalityPanel: FC = () => {
|
||||
const cardinalityDispatch = useCardinalityDispatch();
|
||||
|
||||
const {topN, match, date} = 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 {isLoading, tsdbStatus, error} = useFetchQuery();
|
||||
const defaultProps = defaultProperties(tsdbStatus);
|
||||
const [stateTabs, setTab] = useState(defaultProps.defaultState);
|
||||
|
||||
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](name);
|
||||
setQuery(query);
|
||||
setQueryHistory(prev => [...prev, query]);
|
||||
setQueryHistoryIndex(prev => prev + 1);
|
||||
cardinalityDispatch({type: "SET_MATCH", payload: query});
|
||||
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}}>
|
||||
{SPINNER_TITLE}
|
||||
</Alert>}
|
||||
/>}
|
||||
<CardinalityConfigurator error={configError} query={query} onRunQuery={onRunQuery} onSetQuery={onSetQuery}
|
||||
onSetHistory={onSetHistory} onTopNChange={onTopNChange} topN={topN} />
|
||||
{error && <Alert color="error" severity="error" sx={{whiteSpace: "pre-wrap", mt: 2}}>{error}</Alert>}
|
||||
{<Box m={2}>
|
||||
Analyzed <b>{tsdbStatus.totalSeries}</b> series and <b>{tsdbStatus.totalLabelValuePairs}</b> label=value pairs
|
||||
at <b>{date}</b> {match && <span>for series selector <b>{match}</b></span>}. Show top {topN} entries per table.
|
||||
</Box>}
|
||||
{Object.keys(tsdbStatus).map((key ) => {
|
||||
if (key == "totalSeries" || key == "totalLabelValuePairs") return null;
|
||||
const tableTitle = tableTitles[key];
|
||||
const rows = tsdbStatus[key as keyof TSDBStatus] as unknown as Data[];
|
||||
rows.forEach((row) => {
|
||||
progressCount(tsdbStatus.totalSeries, key, row);
|
||||
row.actions = "0";
|
||||
});
|
||||
const headerCells = (key == "seriesCountByMetricName" || key == "seriesCountByLabelValuePair") ? headCellsWithProgress : defaultHeadCells;
|
||||
return (
|
||||
<>
|
||||
<Grid container spacing={2} sx={{px: 2}}>
|
||||
<Grid item xs={12} md={12} lg={12} key={key}>
|
||||
<Typography gutterBottom variant="h5" component="h5">
|
||||
{tableTitle}
|
||||
</Typography>
|
||||
<Box sx={{ borderBottom: 1, borderColor: "divider" }}>
|
||||
<Tabs
|
||||
value={stateTabs[key as keyof DefaultState]}
|
||||
onChange={handleTabChange} aria-label="basic tabs example">
|
||||
{defaultProps.tabs[key as keyof TabsType].map((title: string, i: number) =>
|
||||
<Tab
|
||||
key={title}
|
||||
label={title}
|
||||
aria-controls={`tabpanel-${i}`}
|
||||
id={key}
|
||||
iconPosition={"start"}
|
||||
icon={ i === 0 ? <TableChartIcon /> : <ShowChartIcon /> } />
|
||||
)}
|
||||
</Tabs>
|
||||
</Box>
|
||||
{defaultProps.tabs[key as keyof TabsType].map((_,idx) =>
|
||||
<div
|
||||
ref={defaultProps.containerRefs[key as keyof Containers<HTMLDivElement>]}
|
||||
style={{width: "100%", paddingRight: idx !== 0 ? "40px" : 0 }} key={`${key}-${idx}`}>
|
||||
<TabPanel value={stateTabs[key as keyof DefaultState]} index={idx}>
|
||||
{stateTabs[key as keyof DefaultState] === 0 ? <EnhancedTable
|
||||
rows={rows}
|
||||
headerCells={headerCells}
|
||||
defaultSortColumn={"value"}
|
||||
tableCells={(row) => tableCells(row,date,handleFilterClick(key))}
|
||||
/>: <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={defaultProps.containerRefs[key as keyof Containers<HTMLDivElement>]?.current}
|
||||
configs={barOptions}
|
||||
/>}
|
||||
</TabPanel>
|
||||
</div>
|
||||
)}
|
||||
</Grid>
|
||||
</Grid>
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CardinalityPanel;
|
|
@ -0,0 +1,50 @@
|
|||
import {TableCell, ButtonGroup} from "@mui/material";
|
||||
import {Data} from "../../Table/types";
|
||||
import {BorderLinearProgressWithLabel} from "../../BorderLineProgress/BorderLinearProgress";
|
||||
import React from "preact/compat";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import PlayCircleOutlineIcon from "@mui/icons-material/PlayCircleOutline";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import {SyntheticEvent} from "react";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
export const tableCells = (
|
||||
row: Data,
|
||||
date: string | null,
|
||||
onFilterClick: (e: SyntheticEvent) => void) => {
|
||||
const pathname = window.location.pathname;
|
||||
const withday = dayjs(date).add(1, "day").toDate();
|
||||
return Object.keys(row).map((key, idx) => {
|
||||
if (idx === 0) {
|
||||
return (<TableCell component="th" scope="row" key={key}>
|
||||
{row[key as keyof Data]}
|
||||
</TableCell>);
|
||||
}
|
||||
if (key === "progressValue") {
|
||||
return (
|
||||
<TableCell key={key}>
|
||||
<BorderLinearProgressWithLabel
|
||||
variant="determinate"
|
||||
value={row[key as keyof Data] as number}
|
||||
/>
|
||||
</TableCell>
|
||||
);
|
||||
}
|
||||
if (key === "actions") {
|
||||
const title = `Filter by ${row.name}`;
|
||||
return (<TableCell key={key}>
|
||||
<ButtonGroup variant="contained">
|
||||
<Tooltip title={title}>
|
||||
<IconButton
|
||||
id={row.name}
|
||||
onClick={onFilterClick}
|
||||
sx={{height: "20px", width: "20px"}}>
|
||||
<PlayCircleOutlineIcon/>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</ButtonGroup>
|
||||
</TableCell>);
|
||||
}
|
||||
return (<TableCell key={key}>{row[key as keyof Data]}</TableCell>);
|
||||
});
|
||||
};
|
|
@ -0,0 +1,44 @@
|
|||
import {HeadCell} from "../Table/types";
|
||||
|
||||
export const headCellsWithProgress = [
|
||||
{
|
||||
disablePadding: false,
|
||||
id: "name",
|
||||
label: "Name",
|
||||
numeric: false,
|
||||
},
|
||||
{
|
||||
disablePadding: false,
|
||||
id: "value",
|
||||
label: "Value",
|
||||
numeric: false,
|
||||
},
|
||||
{
|
||||
disablePadding: false,
|
||||
id: "percentage",
|
||||
label: "Percent of series",
|
||||
numeric: false,
|
||||
},
|
||||
{
|
||||
disablePadding: false,
|
||||
id: "action",
|
||||
label: "Action",
|
||||
numeric: false,
|
||||
}
|
||||
] as HeadCell[];
|
||||
|
||||
export const defaultHeadCells = headCellsWithProgress.filter((head) => head.id!=="percentage");
|
||||
|
||||
export 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,
|
||||
};
|
||||
};
|
||||
|
||||
export const SPINNER_TITLE = "Please wait while cardinality stats is calculated. This may take some time if the db contains big number of time series";
|
|
@ -0,0 +1,59 @@
|
|||
import {Containers, DefaultState, QueryUpdater, Tabs, TSDBStatus, TypographyFunctions} from "./types";
|
||||
import {Data} from "../Table/types";
|
||||
import {useRef} from "preact/compat";
|
||||
|
||||
export const tableTitles: {[key: string]: string} = {
|
||||
"seriesCountByMetricName": "Metric names with the highest number of series",
|
||||
"seriesCountByLabelValuePair": "Label=value pairs with the highest number of series",
|
||||
"labelValueCountByLabelName": "Labels with the highest number of unique values",
|
||||
};
|
||||
|
||||
export const queryUpdater: QueryUpdater = {
|
||||
labelValueCountByLabelName: (query: string): string => `{${query}!=""}`,
|
||||
seriesCountByLabelValuePair: (query: string): string => {
|
||||
const a = query.split("=");
|
||||
const label = a[0];
|
||||
const value = a.slice(1).join("=");
|
||||
return getSeriesSelector(label, value);
|
||||
},
|
||||
seriesCountByMetricName: (query: string): string => {
|
||||
return getSeriesSelector("__name__", query);
|
||||
},
|
||||
};
|
||||
|
||||
const getSeriesSelector = (label: string, value: string): string => {
|
||||
return "{" + label + "=" + JSON.stringify(value) + "}";
|
||||
};
|
||||
|
||||
export const progressCount = (totalSeries: number, key: string, row: Data): Data => {
|
||||
if (key === "seriesCountByMetricName" || key === "seriesCountByLabelValuePair") {
|
||||
row.progressValue = row.value / totalSeries * 100;
|
||||
return row;
|
||||
}
|
||||
return row;
|
||||
};
|
||||
|
||||
export const defaultProperties = (tsdbStatus: TSDBStatus) => {
|
||||
return Object.keys(tsdbStatus).reduce((acc, key) => {
|
||||
if (key === "totalSeries" || key === "totalLabelValuePairs") return acc;
|
||||
return {
|
||||
...acc,
|
||||
tabs:{
|
||||
...acc.tabs,
|
||||
[key]: ["table", "graph"],
|
||||
},
|
||||
containerRefs: {
|
||||
...acc.containerRefs,
|
||||
[key]: useRef<HTMLDivElement>(null),
|
||||
},
|
||||
defaultState: {
|
||||
...acc.defaultState,
|
||||
[key]: 0,
|
||||
},
|
||||
};
|
||||
}, {
|
||||
tabs:{} as Tabs,
|
||||
containerRefs: {} as Containers<HTMLDivElement>,
|
||||
defaultState: {} as DefaultState,
|
||||
});
|
||||
};
|
|
@ -0,0 +1,40 @@
|
|||
import {MutableRef} from "preact/hooks";
|
||||
|
||||
export interface TSDBStatus {
|
||||
labelValueCountByLabelName: TopHeapEntry[];
|
||||
seriesCountByLabelValuePair: TopHeapEntry[];
|
||||
seriesCountByMetricName: TopHeapEntry[];
|
||||
totalSeries: number;
|
||||
totalLabelValuePairs: number;
|
||||
}
|
||||
|
||||
export interface TopHeapEntry {
|
||||
name: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export type TypographyFunctions = {
|
||||
[key: string]: (value: number) => string,
|
||||
}
|
||||
|
||||
export type QueryUpdater = {
|
||||
[key: string]: (query: string) => string,
|
||||
}
|
||||
|
||||
export interface Tabs {
|
||||
labelValueCountByLabelName: string[];
|
||||
seriesCountByLabelValuePair: string[];
|
||||
seriesCountByMetricName: string[];
|
||||
}
|
||||
|
||||
export interface Containers<T> {
|
||||
labelValueCountByLabelName: MutableRef<T>;
|
||||
seriesCountByLabelValuePair: MutableRef<T>;
|
||||
seriesCountByMetricName: MutableRef<T>;
|
||||
}
|
||||
|
||||
export interface DefaultState {
|
||||
labelValueCountByLabelName: number;
|
||||
seriesCountByLabelValuePair: number;
|
||||
seriesCountByMetricName: number;
|
||||
}
|
|
@ -72,8 +72,10 @@ const QueryConfigurator: FC<QueryConfiguratorProps> = ({error, queryOptions}) =>
|
|||
{query.map((q, i) =>
|
||||
<Box key={i} display="grid" gridTemplateColumns="1fr auto auto" gap="4px" width="100%"
|
||||
mb={i === query.length - 1 ? 0 : 2.5}>
|
||||
<QueryEditor query={query[i]} index={i} autocomplete={autocomplete} queryOptions={queryOptions}
|
||||
error={error} setHistoryIndex={setHistoryIndex} runQuery={onRunQuery} setQuery={onSetQuery}/>
|
||||
<QueryEditor
|
||||
query={query[i]} index={i} autocomplete={autocomplete} queryOptions={queryOptions}
|
||||
error={error} setHistoryIndex={setHistoryIndex} runQuery={onRunQuery} setQuery={onSetQuery}
|
||||
label={`Query ${i + 1}`}/>
|
||||
{i === 0 && <Tooltip title="Execute Query">
|
||||
<IconButton onClick={onRunQuery} sx={{height: "49px", width: "49px"}}>
|
||||
<PlayCircleOutlineIcon/>
|
||||
|
@ -97,4 +99,4 @@ const QueryConfigurator: FC<QueryConfiguratorProps> = ({error, queryOptions}) =>
|
|||
</Box>;
|
||||
};
|
||||
|
||||
export default QueryConfigurator;
|
||||
export default QueryConfigurator;
|
||||
|
|
|
@ -18,6 +18,7 @@ export interface QueryEditorProps {
|
|||
autocomplete: boolean;
|
||||
error?: ErrorTypes | string;
|
||||
queryOptions: string[];
|
||||
label: string;
|
||||
}
|
||||
|
||||
const QueryEditor: FC<QueryEditorProps> = ({
|
||||
|
@ -28,7 +29,8 @@ const QueryEditor: FC<QueryEditorProps> = ({
|
|||
runQuery,
|
||||
autocomplete,
|
||||
error,
|
||||
queryOptions
|
||||
queryOptions,
|
||||
label,
|
||||
}) => {
|
||||
|
||||
const [focusField, setFocusField] = useState(false);
|
||||
|
@ -99,8 +101,9 @@ const QueryEditor: FC<QueryEditorProps> = ({
|
|||
<TextField
|
||||
defaultValue={query}
|
||||
fullWidth
|
||||
label={`Query ${index + 1}`}
|
||||
label={label}
|
||||
multiline
|
||||
focused={!!query}
|
||||
error={!!error}
|
||||
onFocus={() => setFocusField(true)}
|
||||
onBlur={(e) => {
|
||||
|
|
|
@ -12,6 +12,7 @@ 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";
|
||||
|
||||
const CustomPanel: FC = () => {
|
||||
|
||||
|
@ -33,7 +34,8 @@ const CustomPanel: FC = () => {
|
|||
dispatch({type: "SET_PERIOD", payload: {from, to}});
|
||||
};
|
||||
|
||||
const {isLoading, liveData, graphData, error, queryOptions} = useFetchQuery({
|
||||
const {queryOptions} = useFetchQueryOptions();
|
||||
const {isLoading, liveData, graphData, error} = useFetchQuery({
|
||||
visible: true,
|
||||
customStep
|
||||
});
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import React, {FC, useState} from "preact/compat";
|
||||
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";
|
||||
|
@ -12,7 +12,10 @@ 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 from "../../router/index";
|
||||
import router, {RouterOptions, routerOptions} from "../../router/index";
|
||||
import DatePicker from "../Main/DatePicker/DatePicker";
|
||||
import {useCardinalityState, useCardinalityDispatch} from "../../state/cardinality/CardinalityStateContext";
|
||||
import {useEffect} from "react";
|
||||
|
||||
const classes = {
|
||||
logo: {
|
||||
|
@ -54,11 +57,18 @@ const classes = {
|
|||
|
||||
const Header: FC = () => {
|
||||
|
||||
const {date} = useCardinalityState();
|
||||
const cardinalityDispatch = useCardinalityDispatch();
|
||||
|
||||
const {search, pathname} = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [activeMenu, setActiveMenu] = useState(pathname);
|
||||
|
||||
const headerSetup = useMemo(() => {
|
||||
return ((routerOptions[pathname] || {}) as RouterOptions).header || {};
|
||||
}, [pathname]);
|
||||
|
||||
const onClickLogo = () => {
|
||||
navigateHandler(router.home);
|
||||
setQueryStringWithoutPageReload("");
|
||||
|
@ -69,6 +79,10 @@ const Header: FC = () => {
|
|||
navigate({pathname, search: search});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setActiveMenu(pathname);
|
||||
}, [pathname]);
|
||||
|
||||
return <AppBar position="static" sx={{px: 1, boxShadow: "none"}}>
|
||||
<Toolbar>
|
||||
<Box display="grid" alignItems="center" justifyContent="center">
|
||||
|
@ -89,12 +103,23 @@ const Header: FC = () => {
|
|||
onChange={(e, val) => setActiveMenu(val)}>
|
||||
<Tab label="Custom panel" value={router.home} component={RouterLink} to={`${router.home}${search}`}/>
|
||||
<Tab label="Dashboards" value={router.dashboards} component={RouterLink} to={`${router.dashboards}${search}`}/>
|
||||
<Tab
|
||||
label="Cardinality"
|
||||
value={router.cardinality}
|
||||
component={RouterLink}
|
||||
to={`${router.cardinality}${search}`}/>
|
||||
</Tabs>
|
||||
</Box>
|
||||
<Box display="grid" gridTemplateColumns="repeat(3, auto)" gap={1} alignItems="center" ml="auto" mr={0}>
|
||||
<TimeSelector/>
|
||||
<ExecutionControls/>
|
||||
<GlobalSettings/>
|
||||
{headerSetup?.timeSelector && <TimeSelector/>}
|
||||
{headerSetup?.datePicker && (
|
||||
<DatePicker
|
||||
date={date}
|
||||
onChange={(val) => cardinalityDispatch({type: "SET_DATE", payload: val})}
|
||||
/>
|
||||
)}
|
||||
{headerSetup?.executionControls && <ExecutionControls/>}
|
||||
{headerSetup?.globalSettings && <GlobalSettings/>}
|
||||
</Box>
|
||||
</Toolbar>
|
||||
</AppBar>;
|
||||
|
|
|
@ -0,0 +1,67 @@
|
|||
import React, {FC} from "react";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import {useState} from "preact/compat";
|
||||
import StaticDatePicker from "@mui/lab/StaticDatePicker";
|
||||
import Box from "@mui/material/Box";
|
||||
import Button from "@mui/material/Button";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import dayjs from "dayjs";
|
||||
import Popper from "@mui/material/Popper";
|
||||
import ClickAwayListener from "@mui/material/ClickAwayListener";
|
||||
import Paper from "@mui/material/Paper";
|
||||
import EventIcon from "@mui/icons-material/Event";
|
||||
|
||||
const formatDate = "YYYY-MM-DD";
|
||||
|
||||
interface DatePickerProps {
|
||||
date: string | null,
|
||||
onChange: (val: string | null) => void
|
||||
}
|
||||
|
||||
const DatePicker: FC<DatePickerProps> = ({date, onChange}) => {
|
||||
|
||||
const dateFormatted = date ? dayjs(date).format(formatDate) : null;
|
||||
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null);
|
||||
const open = Boolean(anchorEl);
|
||||
|
||||
return <>
|
||||
<Tooltip title="Date control">
|
||||
<Button variant="contained" color="primary"
|
||||
sx={{
|
||||
color: "white",
|
||||
border: "1px solid rgba(0, 0, 0, 0.2)",
|
||||
boxShadow: "none"
|
||||
}}
|
||||
startIcon={<EventIcon/>}
|
||||
onClick={(e) => setAnchorEl(e.currentTarget)}>
|
||||
{dateFormatted}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Popper
|
||||
open={open}
|
||||
anchorEl={anchorEl}
|
||||
placement="bottom-end"
|
||||
modifiers={[{name: "offset", options: {offset: [0, 6]}}]}>
|
||||
<ClickAwayListener onClickAway={() => setAnchorEl(null)}>
|
||||
<Paper elevation={3}>
|
||||
<Box>
|
||||
<StaticDatePicker
|
||||
displayStaticWrapperAs="desktop"
|
||||
inputFormat={formatDate}
|
||||
mask="____-__-__"
|
||||
value={date}
|
||||
onChange={(newDate) => {
|
||||
onChange(newDate ? dayjs(newDate).format(formatDate) : null);
|
||||
setAnchorEl(null);
|
||||
}}
|
||||
renderInput={(params) => <TextField {...params}/>}
|
||||
/>
|
||||
</Box>
|
||||
</Paper>
|
||||
</ClickAwayListener>
|
||||
</Popper>
|
||||
</>;
|
||||
};
|
||||
|
||||
export default DatePicker;
|
26
app/vmui/packages/vmui/src/components/TabPanel/TabPanel.tsx
Normal file
26
app/vmui/packages/vmui/src/components/TabPanel/TabPanel.tsx
Normal file
|
@ -0,0 +1,26 @@
|
|||
import {ReactNode} from "react";
|
||||
import {Box} from "@mui/material";
|
||||
import React from "preact/compat";
|
||||
|
||||
interface TabPanelProps {
|
||||
children?: ReactNode;
|
||||
index: number;
|
||||
value: number;
|
||||
}
|
||||
|
||||
const TabPanel = (props: TabPanelProps) => {
|
||||
const { children, value, index, ...other } = props;
|
||||
return (
|
||||
<div
|
||||
role="tabpanel"
|
||||
hidden={value !== index}
|
||||
id={`simple-tabpanel-${index}`}
|
||||
aria-labelledby={`simple-tab-${index}`}
|
||||
{...other}
|
||||
>
|
||||
{value === index && (<Box sx={{ p: 3 }}>{children}</Box>)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TabPanel;
|
137
app/vmui/packages/vmui/src/components/Table/Table.tsx
Normal file
137
app/vmui/packages/vmui/src/components/Table/Table.tsx
Normal file
|
@ -0,0 +1,137 @@
|
|||
import {Box, Paper, Table, TableBody, TableCell, TableContainer, TablePagination, TableRow,} from "@mui/material";
|
||||
import React, {FC, useState} from "preact/compat";
|
||||
import {ChangeEvent, MouseEvent, SyntheticEvent} from "react";
|
||||
import {Data, Order, TableProps,} from "./types";
|
||||
import {EnhancedTableHead} from "./TableHead";
|
||||
import {getComparator, stableSort} from "./helpers";
|
||||
|
||||
|
||||
const EnhancedTable: FC<TableProps> = ({
|
||||
rows,
|
||||
headerCells,
|
||||
defaultSortColumn,
|
||||
isPagingEnabled,
|
||||
tableCells}) => {
|
||||
|
||||
const [order, setOrder] = useState<Order>("desc");
|
||||
const [orderBy, setOrderBy] = useState<keyof Data>(defaultSortColumn);
|
||||
const [selected, setSelected] = useState<readonly string[]>([]);
|
||||
const [page, setPage] = useState(0);
|
||||
const [rowsPerPage, setRowsPerPage] = useState(5);
|
||||
|
||||
const handleRequestSort = (
|
||||
event: MouseEvent<unknown>,
|
||||
property: keyof Data,
|
||||
) => {
|
||||
const isAsc = orderBy === property && order === "asc";
|
||||
setOrder(isAsc ? "desc" : "asc");
|
||||
setOrderBy(property);
|
||||
};
|
||||
|
||||
const handleSelectAllClick = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
if (event.target.checked) {
|
||||
const newSelecteds = rows.map((n) => n.name) as string[];
|
||||
setSelected(newSelecteds);
|
||||
return;
|
||||
}
|
||||
setSelected([]);
|
||||
};
|
||||
|
||||
const handleClick = (event: SyntheticEvent, name: string) => {
|
||||
const selectedIndex = selected.indexOf(name);
|
||||
let newSelected: readonly string[] = [];
|
||||
|
||||
if (selectedIndex === -1) {
|
||||
newSelected = newSelected.concat(selected, name);
|
||||
} else if (selectedIndex === 0) {
|
||||
newSelected = newSelected.concat(selected.slice(1));
|
||||
} else if (selectedIndex === selected.length - 1) {
|
||||
newSelected = newSelected.concat(selected.slice(0, -1));
|
||||
} else if (selectedIndex > 0) {
|
||||
newSelected = newSelected.concat(
|
||||
selected.slice(0, selectedIndex),
|
||||
selected.slice(selectedIndex + 1),
|
||||
);
|
||||
}
|
||||
|
||||
setSelected(newSelected);
|
||||
};
|
||||
|
||||
const handleChangePage = (event: unknown, newPage: number) => {
|
||||
setPage(newPage);
|
||||
};
|
||||
|
||||
const handleChangeRowsPerPage = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
setRowsPerPage(parseInt(event.target.value, 10));
|
||||
setPage(0);
|
||||
};
|
||||
|
||||
const isSelected = (name: string) => selected.indexOf(name) !== -1;
|
||||
|
||||
// Avoid a layout jump when reaching the last page with empty rows.
|
||||
const emptyRows =
|
||||
page > 0 ? Math.max(0, (1 + page) * rowsPerPage - rows.length) : 0;
|
||||
|
||||
const sortedData = isPagingEnabled ? stableSort(rows, getComparator(order, orderBy))
|
||||
.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage) : stableSort(rows, getComparator(order, orderBy));
|
||||
return (
|
||||
<Box sx={{width: "100%"}}>
|
||||
<Paper sx={{width: "100%", mb: 2}}>
|
||||
<TableContainer>
|
||||
<Table
|
||||
size={"small"}
|
||||
sx={{minWidth: 750}}
|
||||
aria-labelledby="tableTitle"
|
||||
>
|
||||
<EnhancedTableHead
|
||||
numSelected={selected.length}
|
||||
order={order}
|
||||
orderBy={orderBy}
|
||||
onSelectAllClick={handleSelectAllClick}
|
||||
onRequestSort={handleRequestSort}
|
||||
rowCount={rows.length}
|
||||
headerCells={headerCells}/>
|
||||
<TableBody>
|
||||
{/* if you don't need to support IE11, you can replace the `stableSort` call with:
|
||||
rows.slice().sort(getComparator(order, orderBy)) */}
|
||||
{sortedData
|
||||
.map((row) => {
|
||||
const isItemSelected = isSelected(row.name);
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
hover
|
||||
onClick={(event) => handleClick(event, row.name)}
|
||||
role="checkbox"
|
||||
aria-checked={isItemSelected}
|
||||
tabIndex={-1}
|
||||
key={row.name}
|
||||
selected={isItemSelected}
|
||||
>
|
||||
{tableCells(row)}
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
{emptyRows > 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6}/>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
{isPagingEnabled ? <TablePagination
|
||||
rowsPerPageOptions={[5, 10, 25]}
|
||||
component="div"
|
||||
count={rows.length}
|
||||
rowsPerPage={rowsPerPage}
|
||||
page={page}
|
||||
onPageChange={handleChangePage}
|
||||
onRowsPerPageChange={handleChangeRowsPerPage}
|
||||
/> : null}
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default EnhancedTable;
|
41
app/vmui/packages/vmui/src/components/Table/TableHead.tsx
Normal file
41
app/vmui/packages/vmui/src/components/Table/TableHead.tsx
Normal file
|
@ -0,0 +1,41 @@
|
|||
import {MouseEvent} from "react";
|
||||
import {Box, TableCell, TableHead, TableRow, TableSortLabel} from "@mui/material";
|
||||
import {visuallyHidden} from "@mui/utils";
|
||||
import React from "preact/compat";
|
||||
import {Data, EnhancedHeaderTableProps} from "./types";
|
||||
|
||||
|
||||
export function EnhancedTableHead(props: EnhancedHeaderTableProps) {
|
||||
const { order, orderBy, onRequestSort, headerCells } = props;
|
||||
const createSortHandler =
|
||||
(property: keyof Data) => (event: MouseEvent<unknown>) => {
|
||||
onRequestSort(event, property);
|
||||
};
|
||||
|
||||
return (
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
{headerCells.map((headCell) => (
|
||||
<TableCell
|
||||
key={headCell.id}
|
||||
align={headCell.numeric ? "right" : "left"}
|
||||
sortDirection={orderBy === headCell.id ? order : false}
|
||||
>
|
||||
<TableSortLabel
|
||||
active={orderBy === headCell.id}
|
||||
direction={orderBy === headCell.id ? order : "asc"}
|
||||
onClick={createSortHandler(headCell.id as keyof Data)}
|
||||
>
|
||||
{headCell.label}
|
||||
{orderBy === headCell.id ? (
|
||||
<Box component="span" sx={visuallyHidden}>
|
||||
{order === "desc" ? "sorted descending" : "sorted ascending"}
|
||||
</Box>
|
||||
) : null}
|
||||
</TableSortLabel>
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
);
|
||||
}
|
37
app/vmui/packages/vmui/src/components/Table/helpers.ts
Normal file
37
app/vmui/packages/vmui/src/components/Table/helpers.ts
Normal file
|
@ -0,0 +1,37 @@
|
|||
import {Order} from "./types";
|
||||
|
||||
export function descendingComparator<T>(a: T, b: T, orderBy: keyof T) {
|
||||
if (b[orderBy] < a[orderBy]) {
|
||||
return -1;
|
||||
}
|
||||
if (b[orderBy] > a[orderBy]) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function getComparator<Key extends keyof any>(
|
||||
order: Order,
|
||||
orderBy: Key,
|
||||
): (
|
||||
a: { [key in Key]: number | string },
|
||||
b: { [key in Key]: number | string },
|
||||
) => number {
|
||||
return order === "desc"
|
||||
? (a, b) => descendingComparator(a, b, orderBy)
|
||||
: (a, b) => -descendingComparator(a, b, orderBy);
|
||||
}
|
||||
|
||||
// This method is created for cross-browser compatibility, if you don't
|
||||
// need to support IE11, you can use Array.prototype.sort() directly
|
||||
export function stableSort<T>(array: readonly T[], comparator: (a: T, b: T) => number) {
|
||||
const stabilizedThis = array.map((el, index) => [el, index] as [T, number]);
|
||||
stabilizedThis.sort((a, b) => {
|
||||
const order = comparator(a[0], b[0]);
|
||||
if (order !== 0) {
|
||||
return order;
|
||||
}
|
||||
return a[1] - b[1];
|
||||
});
|
||||
return stabilizedThis.map((el) => el[0]);
|
||||
}
|
36
app/vmui/packages/vmui/src/components/Table/types.ts
Normal file
36
app/vmui/packages/vmui/src/components/Table/types.ts
Normal file
|
@ -0,0 +1,36 @@
|
|||
import {ChangeEvent, MouseEvent, ReactNode} from "react";
|
||||
|
||||
export type Order = "asc" | "desc";
|
||||
|
||||
export interface HeadCell {
|
||||
disablePadding: boolean;
|
||||
id: string;
|
||||
label: string | ReactNode;
|
||||
numeric: boolean;
|
||||
}
|
||||
|
||||
export interface EnhancedHeaderTableProps {
|
||||
numSelected: number;
|
||||
onRequestSort: (event: MouseEvent<unknown>, property: keyof Data) => void;
|
||||
onSelectAllClick: (event: ChangeEvent<HTMLInputElement>) => void;
|
||||
order: Order;
|
||||
orderBy: string;
|
||||
rowCount: number;
|
||||
headerCells: HeadCell[];
|
||||
}
|
||||
|
||||
export interface TableProps {
|
||||
rows: Data[];
|
||||
headerCells: HeadCell[],
|
||||
defaultSortColumn: keyof Data,
|
||||
tableCells: (row: Data) => ReactNode[],
|
||||
isPagingEnabled?: boolean,
|
||||
}
|
||||
|
||||
|
||||
export interface Data {
|
||||
name: string;
|
||||
value: number;
|
||||
progressValue: number;
|
||||
actions: string;
|
||||
}
|
|
@ -1,4 +1,5 @@
|
|||
import React, {FC} from "preact/compat";
|
||||
import {ReactNode} from "react";
|
||||
import Fade from "@mui/material/Fade";
|
||||
import Box from "@mui/material/Box";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
|
@ -6,25 +7,30 @@ import CircularProgress from "@mui/material/CircularProgress";
|
|||
interface SpinnerProps {
|
||||
isLoading: boolean;
|
||||
height?: string;
|
||||
containerStyles?: Record<string, string | number>;
|
||||
title?: string | ReactNode,
|
||||
}
|
||||
|
||||
const Spinner: FC<SpinnerProps> = ({isLoading, height}) => {
|
||||
export const defaultContainerStyles: Record<string, string | number> = {
|
||||
width: "100%",
|
||||
maxWidth: "calc(100vw - 64px)",
|
||||
height: "50%",
|
||||
position: "absolute",
|
||||
background: "rgba(255, 255, 255, 0.7)",
|
||||
pointerEvents: "none",
|
||||
zIndex: 2,
|
||||
};
|
||||
|
||||
const Spinner: FC<SpinnerProps> = ({isLoading, containerStyles, title}) => {
|
||||
const styles = containerStyles ?? defaultContainerStyles;
|
||||
return <Fade in={isLoading} style={{
|
||||
transitionDelay: isLoading ? "300ms" : "0ms",
|
||||
}}>
|
||||
<Box alignItems="center" justifyContent="center" flexDirection="column" display="flex"
|
||||
style={{
|
||||
width: "100%",
|
||||
maxWidth: "calc(100vw - 64px)",
|
||||
position: "absolute",
|
||||
height: height ?? "50%",
|
||||
background: "rgba(255, 255, 255, 0.7)",
|
||||
pointerEvents: "none",
|
||||
zIndex: 2,
|
||||
}}>
|
||||
<Box alignItems="center" justifyContent="center" flexDirection="column" display="flex" style={styles}>
|
||||
<CircularProgress/>
|
||||
{title}
|
||||
</Box>
|
||||
</Fade>;
|
||||
};
|
||||
|
||||
export default Spinner;
|
||||
export default Spinner;
|
||||
|
|
71
app/vmui/packages/vmui/src/hooks/useCardinalityFetch.ts
Normal file
71
app/vmui/packages/vmui/src/hooks/useCardinalityFetch.ts
Normal file
|
@ -0,0 +1,71 @@
|
|||
import {ErrorTypes} from "../types";
|
||||
import {useAppState} from "../state/common/StateContext";
|
||||
import {useEffect, useState} from "preact/compat";
|
||||
import {CardinalityRequestsParams, getCardinalityInfo} from "../api/tsdb";
|
||||
import {getAppModeEnable, getAppModeParams} from "../utils/app-mode";
|
||||
import {TSDBStatus} from "../components/CardinalityPanel/types";
|
||||
import {useCardinalityState} from "../state/cardinality/CardinalityStateContext";
|
||||
|
||||
const appModeEnable = getAppModeEnable();
|
||||
const {serverURL: appServerUrl} = getAppModeParams();
|
||||
const defaultTSDBStatus = {
|
||||
totalSeries: 0,
|
||||
totalLabelValuePairs: 0,
|
||||
seriesCountByMetricName: [],
|
||||
seriesCountByLabelValuePair: [],
|
||||
labelValueCountByLabelName: [],
|
||||
};
|
||||
|
||||
export const useFetchQuery = (): {
|
||||
fetchUrl?: string[],
|
||||
isLoading: boolean,
|
||||
error?: ErrorTypes | string
|
||||
tsdbStatus: TSDBStatus,
|
||||
} => {
|
||||
const {topN, extraLabel, match, date, runQuery} = useCardinalityState();
|
||||
|
||||
const {serverUrl} = useAppState();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<ErrorTypes | string>();
|
||||
const [tsdbStatus, setTSDBStatus] = useState<TSDBStatus>(defaultTSDBStatus);
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
setTSDBStatus(defaultTSDBStatus);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
const fetchCardinalityInfo = async (requestParams: CardinalityRequestsParams) => {
|
||||
const server = appModeEnable ? appServerUrl : serverUrl;
|
||||
if (!server) return;
|
||||
setError("");
|
||||
setIsLoading(true);
|
||||
setTSDBStatus(defaultTSDBStatus);
|
||||
const url = getCardinalityInfo(server, requestParams);
|
||||
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
const resp = await response.json();
|
||||
if (response.ok) {
|
||||
const {data} = resp;
|
||||
setTSDBStatus({ ...data });
|
||||
setIsLoading(false);
|
||||
} else {
|
||||
setError(resp.error);
|
||||
setTSDBStatus(defaultTSDBStatus);
|
||||
setIsLoading(false);
|
||||
}
|
||||
} catch (e) {
|
||||
setIsLoading(false);
|
||||
if (e instanceof Error) setError(`${e.name}: ${e.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
fetchCardinalityInfo({topN, extraLabel, match, date});
|
||||
}, [serverUrl, runQuery, date]);
|
||||
|
||||
return {isLoading, tsdbStatus, error};
|
||||
};
|
|
@ -1,5 +1,5 @@
|
|||
import {useEffect, useMemo, useCallback, useState} from "preact/compat";
|
||||
import {getQueryOptions, getQueryRangeUrl, getQueryUrl} from "../api/query-range";
|
||||
import {getQueryRangeUrl, getQueryUrl} from "../api/query-range";
|
||||
import {useAppState} from "../state/common/StateContext";
|
||||
import {InstantMetricResult, MetricBase, MetricResult} from "../api/types";
|
||||
import {isValidHttpUrl} from "../utils/url";
|
||||
|
@ -27,11 +27,9 @@ export const useFetchQuery = ({predefinedQuery, visible, display, customStep}: F
|
|||
graphData?: MetricResult[],
|
||||
liveData?: InstantMetricResult[],
|
||||
error?: ErrorTypes | string,
|
||||
queryOptions: string[],
|
||||
} => {
|
||||
const {query, displayType, serverUrl, time: {period}, queryControls: {nocache}} = useAppState();
|
||||
|
||||
const [queryOptions, setQueryOptions] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [graphData, setGraphData] = useState<MetricResult[]>();
|
||||
const [liveData, setLiveData] = useState<InstantMetricResult[]>();
|
||||
|
@ -78,22 +76,6 @@ export const useFetchQuery = ({predefinedQuery, visible, display, customStep}: F
|
|||
|
||||
const throttledFetchData = useCallback(throttle(fetchData, 1000), []);
|
||||
|
||||
const fetchOptions = async () => {
|
||||
const server = appModeEnable ? appServerUrl : serverUrl;
|
||||
if (!server) return;
|
||||
const url = getQueryOptions(server);
|
||||
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
const resp = await response.json();
|
||||
if (response.ok) {
|
||||
setQueryOptions(resp.data);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof Error) setError(`${e.name}: ${e.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchUrl = useMemo(() => {
|
||||
const server = appModeEnable ? appServerUrl : serverUrl;
|
||||
const expr = predefinedQuery ?? query;
|
||||
|
@ -117,10 +99,6 @@ export const useFetchQuery = ({predefinedQuery, visible, display, customStep}: F
|
|||
|
||||
const prevFetchUrl = usePrevious(fetchUrl);
|
||||
|
||||
useEffect(() => {
|
||||
fetchOptions();
|
||||
}, [serverUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible || (fetchUrl && prevFetchUrl && arrayEquals(fetchUrl, prevFetchUrl))) return;
|
||||
throttledFetchData(fetchUrl, fetchQueue, (display || displayType));
|
||||
|
@ -133,5 +111,5 @@ export const useFetchQuery = ({predefinedQuery, visible, display, customStep}: F
|
|||
setFetchQueue(fetchQueue.filter(f => !f.signal.aborted));
|
||||
}, [fetchQueue]);
|
||||
|
||||
return { fetchUrl, isLoading, graphData, liveData, error, queryOptions: queryOptions };
|
||||
return { fetchUrl, isLoading, graphData, liveData, error };
|
||||
};
|
||||
|
|
37
app/vmui/packages/vmui/src/hooks/useFetchQueryOptions.ts
Normal file
37
app/vmui/packages/vmui/src/hooks/useFetchQueryOptions.ts
Normal file
|
@ -0,0 +1,37 @@
|
|||
import {useEffect, useState} from "preact/compat";
|
||||
import {getQueryOptions} from "../api/query-range";
|
||||
import {useAppState} from "../state/common/StateContext";
|
||||
import {getAppModeEnable, getAppModeParams} from "../utils/app-mode";
|
||||
|
||||
const appModeEnable = getAppModeEnable();
|
||||
const {serverURL: appServerUrl} = getAppModeParams();
|
||||
|
||||
export const useFetchQueryOptions = (): {
|
||||
queryOptions: string[],
|
||||
} => {
|
||||
const {serverUrl} = useAppState();
|
||||
|
||||
const [queryOptions, setQueryOptions] = useState([]);
|
||||
|
||||
const fetchOptions = async () => {
|
||||
const server = appModeEnable ? appServerUrl : serverUrl;
|
||||
if (!server) return;
|
||||
const url = getQueryOptions(server);
|
||||
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
const resp = await response.json();
|
||||
if (response.ok) {
|
||||
setQueryOptions(resp.data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchOptions();
|
||||
}, [serverUrl]);
|
||||
|
||||
return { queryOptions };
|
||||
};
|
|
@ -1,4 +1,35 @@
|
|||
export default {
|
||||
const router = {
|
||||
home: "/",
|
||||
dashboards: "/dashboards"
|
||||
dashboards: "/dashboards",
|
||||
cardinality: "/cardinality",
|
||||
};
|
||||
|
||||
export interface RouterOptions {
|
||||
header: {
|
||||
timeSelector?: boolean,
|
||||
executionControls?: boolean,
|
||||
globalSettings?: boolean,
|
||||
datePicker?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
const routerOptionsDefault = {
|
||||
header: {
|
||||
timeSelector: true,
|
||||
executionControls: true,
|
||||
globalSettings: true,
|
||||
}
|
||||
};
|
||||
|
||||
export const routerOptions: {[key: string]: RouterOptions} = {
|
||||
[router.home]: routerOptionsDefault,
|
||||
[router.dashboards]: routerOptionsDefault,
|
||||
[router.cardinality]: {
|
||||
header: {
|
||||
datePicker: true,
|
||||
globalSettings: true,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default router;
|
||||
|
|
|
@ -0,0 +1,35 @@
|
|||
import React, {createContext, FC, useContext, useEffect, useMemo, useReducer} from "preact/compat";
|
||||
import {Action, CardinalityState, initialState, reducer} from "./reducer";
|
||||
import {Dispatch} from "react";
|
||||
import {useLocation} from "react-router-dom";
|
||||
import {setQueryStringValue} from "../../utils/query-string";
|
||||
import router from "../../router";
|
||||
|
||||
type CardinalityStateContextType = { state: CardinalityState, dispatch: Dispatch<Action> };
|
||||
|
||||
export const CardinalityStateContext = createContext<CardinalityStateContextType>({} as CardinalityStateContextType);
|
||||
|
||||
export const useCardinalityState = (): CardinalityState => useContext(CardinalityStateContext).state;
|
||||
export const useCardinalityDispatch = (): Dispatch<Action> => useContext(CardinalityStateContext).dispatch;
|
||||
|
||||
export const CardinalityStateProvider: FC = ({children}) => {
|
||||
const location = useLocation();
|
||||
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
|
||||
useEffect(() => {
|
||||
if (location.pathname !== router.cardinality) return;
|
||||
setQueryStringValue(state as unknown as Record<string, unknown>);
|
||||
}, [state, location]);
|
||||
|
||||
const contextValue = useMemo(() => {
|
||||
return { state, dispatch };
|
||||
}, [state, dispatch]);
|
||||
|
||||
|
||||
return <CardinalityStateContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</CardinalityStateContext.Provider>;
|
||||
};
|
||||
|
||||
|
57
app/vmui/packages/vmui/src/state/cardinality/reducer.ts
Normal file
57
app/vmui/packages/vmui/src/state/cardinality/reducer.ts
Normal file
|
@ -0,0 +1,57 @@
|
|||
import dayjs from "dayjs";
|
||||
import {getQueryStringValue} from "../../utils/query-string";
|
||||
|
||||
export interface CardinalityState {
|
||||
runQuery: number,
|
||||
topN: number
|
||||
date: string | null
|
||||
match: string | null
|
||||
extraLabel: string | null
|
||||
}
|
||||
|
||||
export type Action =
|
||||
| { type: "SET_TOP_N", payload: number }
|
||||
| { type: "SET_DATE", payload: string | null }
|
||||
| { type: "SET_MATCH", payload: string | null }
|
||||
| { type: "SET_EXTRA_LABEL", payload: string | null }
|
||||
| { type: "RUN_QUERY" }
|
||||
|
||||
export const initialState: CardinalityState = {
|
||||
runQuery: 0,
|
||||
topN: getQueryStringValue("topN", 10) as number,
|
||||
date: getQueryStringValue("date", dayjs(new Date()).format("YYYY-MM-DD")) as string,
|
||||
match: (getQueryStringValue("match", []) as string[]).join("&"),
|
||||
extraLabel: getQueryStringValue("extra_label", "") as string,
|
||||
};
|
||||
|
||||
export function reducer(state: CardinalityState, action: Action): CardinalityState {
|
||||
switch (action.type) {
|
||||
case "SET_TOP_N":
|
||||
return {
|
||||
...state,
|
||||
topN: action.payload
|
||||
};
|
||||
case "SET_DATE":
|
||||
return {
|
||||
...state,
|
||||
date: action.payload
|
||||
};
|
||||
case "SET_MATCH":
|
||||
return {
|
||||
...state,
|
||||
match: action.payload
|
||||
};
|
||||
case "SET_EXTRA_LABEL":
|
||||
return {
|
||||
...state,
|
||||
extraLabel: action.payload
|
||||
};
|
||||
case "RUN_QUERY":
|
||||
return {
|
||||
...state,
|
||||
runQuery: state.runQuery + 1
|
||||
};
|
||||
default:
|
||||
throw new Error();
|
||||
}
|
||||
}
|
|
@ -3,6 +3,7 @@ import {Action, AppState, initialState, reducer} from "./reducer";
|
|||
import {getQueryStringValue, setQueryStringValue} from "../../utils/query-string";
|
||||
import {Dispatch} from "react";
|
||||
import {useLocation} from "react-router-dom";
|
||||
import router from "../../router";
|
||||
|
||||
type StateContextType = { state: AppState, dispatch: Dispatch<Action> };
|
||||
|
||||
|
@ -23,6 +24,7 @@ export const StateProvider: FC = ({children}) => {
|
|||
const [state, dispatch] = useReducer(reducer, initialPrepopulatedState);
|
||||
|
||||
useEffect(() => {
|
||||
if (location.pathname === router.cardinality) return;
|
||||
setQueryStringValue(state as unknown as Record<string, unknown>);
|
||||
}, [state, location]);
|
||||
|
||||
|
|
|
@ -13,6 +13,12 @@ const graphStateToUrlParams = {
|
|||
const stateToUrlParams = {
|
||||
[router.home]: graphStateToUrlParams,
|
||||
[router.dashboards]: graphStateToUrlParams,
|
||||
[router.cardinality]: {
|
||||
"topN": "topN",
|
||||
"date": "date",
|
||||
"match": "match[]",
|
||||
"extraLabel": "extra_label"
|
||||
}
|
||||
};
|
||||
|
||||
// TODO need function for detect types.
|
||||
|
|
440
app/vmui/packages/vmui/src/utils/uplot/plugin.js
Normal file
440
app/vmui/packages/vmui/src/utils/uplot/plugin.js
Normal file
|
@ -0,0 +1,440 @@
|
|||
/* eslint-disable */
|
||||
import uPlot from "uplot";
|
||||
|
||||
export const seriesBarsPlugin = (opts) => {
|
||||
let pxRatio;
|
||||
let font;
|
||||
|
||||
let { ignore = [] } = opts;
|
||||
|
||||
let radius = opts.radius ?? 0;
|
||||
|
||||
function setPxRatio() {
|
||||
pxRatio = devicePixelRatio;
|
||||
font = Math.round(10 * pxRatio) + "px Arial";
|
||||
}
|
||||
|
||||
setPxRatio();
|
||||
|
||||
window.addEventListener("dppxchange", setPxRatio);
|
||||
|
||||
const ori = opts.ori;
|
||||
const dir = opts.dir;
|
||||
const stacked = opts.stacked;
|
||||
|
||||
const groupWidth = 0.9;
|
||||
const groupDistr = SPACE_BETWEEN;
|
||||
|
||||
const barWidth = 1;
|
||||
const barDistr = SPACE_BETWEEN;
|
||||
|
||||
function distrTwo(groupCount, barCount, _groupWidth = groupWidth) {
|
||||
let out = Array.from({length: barCount}, () => ({
|
||||
offs: Array(groupCount).fill(0),
|
||||
size: Array(groupCount).fill(0),
|
||||
}));
|
||||
|
||||
distr(groupCount, _groupWidth, groupDistr, null, (groupIdx, groupOffPct, groupDimPct) => {
|
||||
distr(barCount, barWidth, barDistr, null, (barIdx, barOffPct, barDimPct) => {
|
||||
out[barIdx].offs[groupIdx] = groupOffPct + (groupDimPct * barOffPct);
|
||||
out[barIdx].size[groupIdx] = groupDimPct * barDimPct;
|
||||
});
|
||||
});
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function distrOne(groupCount, barCount) {
|
||||
let out = Array.from({length: barCount}, () => ({
|
||||
offs: Array(groupCount).fill(0),
|
||||
size: Array(groupCount).fill(0),
|
||||
}));
|
||||
|
||||
distr(groupCount, groupWidth, groupDistr, null, (groupIdx, groupOffPct, groupDimPct) => {
|
||||
distr(barCount, barWidth, barDistr, null, (barIdx, barOffPct, barDimPct) => {
|
||||
out[barIdx].offs[groupIdx] = groupOffPct;
|
||||
out[barIdx].size[groupIdx] = groupDimPct;
|
||||
});
|
||||
});
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
let barsPctLayout;
|
||||
let barsColors;
|
||||
|
||||
let barsBuilder = uPlot.paths.bars({
|
||||
radius,
|
||||
disp: {
|
||||
x0: {
|
||||
unit: 2,
|
||||
values: (u, seriesIdx, idx0, idx1) => barsPctLayout[seriesIdx].offs,
|
||||
},
|
||||
size: {
|
||||
unit: 2,
|
||||
values: (u, seriesIdx, idx0, idx1) => barsPctLayout[seriesIdx].size,
|
||||
},
|
||||
...opts.disp,
|
||||
},
|
||||
each: (u, seriesIdx, dataIdx, lft, top, wid, hgt) => {
|
||||
// we get back raw canvas coords (included axes & padding). translate to the plotting area origin
|
||||
lft -= u.bbox.left;
|
||||
top -= u.bbox.top;
|
||||
qt.add({x: lft, y: top, w: wid, h: hgt, sidx: seriesIdx, didx: dataIdx});
|
||||
},
|
||||
});
|
||||
|
||||
function drawPoints(u, sidx, i0, i1) {
|
||||
u.ctx.save();
|
||||
|
||||
u.ctx.font = font;
|
||||
u.ctx.fillStyle = "black";
|
||||
|
||||
uPlot.orient(u, sidx, (
|
||||
series,
|
||||
dataX,
|
||||
dataY,
|
||||
scaleX,
|
||||
scaleY,
|
||||
valToPosX,
|
||||
valToPosY,
|
||||
xOff,
|
||||
yOff,
|
||||
xDim,
|
||||
yDim, moveTo, lineTo, rect) => {
|
||||
const _dir = dir * (ori === 0 ? 1 : -1);
|
||||
|
||||
const wid = Math.round(barsPctLayout[sidx].size[0] * xDim);
|
||||
|
||||
barsPctLayout[sidx].offs.forEach((offs, ix) => {
|
||||
if (dataY[ix] !== null) {
|
||||
let x0 = xDim * offs;
|
||||
let lft = Math.round(xOff + (_dir === 1 ? x0 : xDim - x0 - wid));
|
||||
let barWid = Math.round(wid);
|
||||
|
||||
let yPos = valToPosY(dataY[ix], scaleY, yDim, yOff);
|
||||
|
||||
let x = ori === 0 ? Math.round(lft + barWid/2) : Math.round(yPos);
|
||||
let y = ori === 0 ? Math.round(yPos) : Math.round(lft + barWid/2);
|
||||
|
||||
u.ctx.textAlign = ori === 0 ? "center" : dataY[ix] >= 0 ? "left" : "right";
|
||||
u.ctx.textBaseline = ori === 1 ? "middle" : dataY[ix] >= 0 ? "bottom" : "top";
|
||||
|
||||
u.ctx.fillText(dataY[ix], x, y);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
u.ctx.restore();
|
||||
}
|
||||
|
||||
function range(u, dataMin, dataMax) {
|
||||
let [min, max] = uPlot.rangeNum(0, dataMax, 0.05, true);
|
||||
return [0, max];
|
||||
}
|
||||
|
||||
let qt;
|
||||
|
||||
return {
|
||||
hooks: {
|
||||
drawClear: u => {
|
||||
qt = qt || new Quadtree(0, 0, u.bbox.width, u.bbox.height);
|
||||
|
||||
qt.clear();
|
||||
|
||||
// force-clear the path cache to cause drawBars() to rebuild new quadtree
|
||||
u.series.forEach(s => {
|
||||
s._paths = null;
|
||||
});
|
||||
|
||||
if (stacked)
|
||||
barsPctLayout = [null].concat(distrOne(u.data.length - 1 - ignore.length, u.data[0].length));
|
||||
else if (u.series.length === 2)
|
||||
barsPctLayout = [null].concat(distrOne(u.data[0].length, 1));
|
||||
else
|
||||
barsPctLayout = [null].concat(distrTwo(u.data[0].length, u.data.length - 1 - ignore.length, u.data[0].length === 1 ? 1 : groupWidth));
|
||||
|
||||
// TODOL only do on setData, not every redraw
|
||||
if (opts.disp?.fill != null) {
|
||||
barsColors = [null];
|
||||
|
||||
for (let i = 1; i < u.data.length; i++) {
|
||||
barsColors.push({
|
||||
fill: opts.disp.fill.values(u, i),
|
||||
stroke: opts.disp.stroke.values(u, i),
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
opts: (u, opts) => {
|
||||
const yScaleOpts = {
|
||||
range,
|
||||
ori: ori === 0 ? 1 : 0,
|
||||
};
|
||||
|
||||
// hovered
|
||||
let hRect;
|
||||
|
||||
uPlot.assign(opts, {
|
||||
select: {show: false},
|
||||
cursor: {
|
||||
x: false,
|
||||
y: false,
|
||||
dataIdx: (u, seriesIdx) => {
|
||||
if (seriesIdx === 1) {
|
||||
hRect = null;
|
||||
|
||||
let cx = u.cursor.left * pxRatio;
|
||||
let cy = u.cursor.top * pxRatio;
|
||||
|
||||
qt.get(cx, cy, 1, 1, o => {
|
||||
if (pointWithin(cx, cy, o.x, o.y, o.x + o.w, o.y + o.h))
|
||||
hRect = o;
|
||||
});
|
||||
}
|
||||
|
||||
return hRect && seriesIdx === hRect.sidx ? hRect.didx : null;
|
||||
},
|
||||
points: {
|
||||
// fill: "rgba(255,255,255, 0.3)",
|
||||
bbox: (u, seriesIdx) => {
|
||||
let isHovered = hRect && seriesIdx === hRect.sidx;
|
||||
|
||||
return {
|
||||
left: isHovered ? hRect.x / pxRatio : -10,
|
||||
top: isHovered ? hRect.y / pxRatio : -10,
|
||||
width: isHovered ? hRect.w / pxRatio : 0,
|
||||
height: isHovered ? hRect.h / pxRatio : 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
time: false,
|
||||
distr: 2,
|
||||
ori,
|
||||
dir,
|
||||
// auto: true,
|
||||
range: (u, min, max) => {
|
||||
min = 0;
|
||||
max = Math.max(1, u.data[0].length - 1);
|
||||
|
||||
let pctOffset = 0;
|
||||
|
||||
distr(u.data[0].length, groupWidth, groupDistr, 0, (di, lftPct, widPct) => {
|
||||
pctOffset = lftPct + widPct / 2;
|
||||
});
|
||||
|
||||
let rn = max - min;
|
||||
|
||||
if (pctOffset === 0.5)
|
||||
min -= rn;
|
||||
else {
|
||||
let upScale = 1 / (1 - pctOffset * 2);
|
||||
let offset = (upScale * rn - rn) / 2;
|
||||
|
||||
min -= offset;
|
||||
max += offset;
|
||||
}
|
||||
|
||||
return [min, max];
|
||||
}
|
||||
},
|
||||
rend: yScaleOpts,
|
||||
size: yScaleOpts,
|
||||
mem: yScaleOpts,
|
||||
inter: yScaleOpts,
|
||||
toggle: yScaleOpts,
|
||||
}
|
||||
});
|
||||
|
||||
if (ori === 1) {
|
||||
opts.padding = [0, null, 0, null];
|
||||
}
|
||||
|
||||
uPlot.assign(opts.axes[0], {
|
||||
splits: (u, axisIdx) => {
|
||||
const _dir = dir * (ori === 0 ? 1 : -1);
|
||||
let splits = u._data[0].slice();
|
||||
return _dir === 1 ? splits : splits.reverse();
|
||||
},
|
||||
values: u => u.data[0],
|
||||
gap: 15,
|
||||
size: ori === 0 ? 40 : 150,
|
||||
labelSize: 20,
|
||||
grid: {show: false},
|
||||
ticks: {show: false},
|
||||
|
||||
side: ori === 0 ? 2 : 3,
|
||||
});
|
||||
|
||||
opts.series.forEach((s, i) => {
|
||||
if (i > 0 && !ignore.includes(i)) {
|
||||
uPlot.assign(s, {
|
||||
// pxAlign: false,
|
||||
// stroke: "rgba(255,0,0,0.5)",
|
||||
paths: barsBuilder,
|
||||
points: {
|
||||
show: drawPoints
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const roundDec = (val, dec) => {
|
||||
return Math.round(val * (dec = 10**dec)) / dec;
|
||||
}
|
||||
|
||||
const SPACE_BETWEEN = 1;
|
||||
const SPACE_AROUND = 2;
|
||||
const SPACE_EVENLY = 3;
|
||||
|
||||
const coord = (i, offs, iwid, gap) => roundDec(offs + i * (iwid + gap), 6);
|
||||
|
||||
const distr = (numItems, sizeFactor, justify, onlyIdx, each) => {
|
||||
let space = 1 - sizeFactor;
|
||||
|
||||
let gap = (
|
||||
justify === SPACE_BETWEEN ? space / (numItems - 1) :
|
||||
justify === SPACE_AROUND ? space / (numItems ) :
|
||||
justify === SPACE_EVENLY ? space / (numItems + 1) : 0
|
||||
);
|
||||
|
||||
if (isNaN(gap) || gap === Infinity)
|
||||
gap = 0;
|
||||
|
||||
let offs = (
|
||||
justify === SPACE_BETWEEN ? 0 :
|
||||
justify === SPACE_AROUND ? gap / 2 :
|
||||
justify === SPACE_EVENLY ? gap : 0
|
||||
);
|
||||
|
||||
let iwid = sizeFactor / numItems;
|
||||
let _iwid = roundDec(iwid, 6);
|
||||
|
||||
if (onlyIdx == null) {
|
||||
for (let i = 0; i < numItems; i++)
|
||||
each(i, coord(i, offs, iwid, gap), _iwid);
|
||||
}
|
||||
else
|
||||
each(onlyIdx, coord(onlyIdx, offs, iwid, gap), _iwid);
|
||||
}
|
||||
|
||||
const pointWithin = (px, py, rlft, rtop, rrgt, rbtm) => {
|
||||
return px >= rlft && px <= rrgt && py >= rtop && py <= rbtm;
|
||||
}
|
||||
|
||||
const MAX_OBJECTS = 10;
|
||||
const MAX_LEVELS = 4;
|
||||
|
||||
function Quadtree(x, y, w, h, l) {
|
||||
let t = this;
|
||||
|
||||
t.x = x;
|
||||
t.y = y;
|
||||
t.w = w;
|
||||
t.h = h;
|
||||
t.l = l || 0;
|
||||
t.o = [];
|
||||
t.q = null;
|
||||
}
|
||||
|
||||
const proto = {
|
||||
split: function() {
|
||||
let t = this,
|
||||
x = t.x,
|
||||
y = t.y,
|
||||
w = t.w / 2,
|
||||
h = t.h / 2,
|
||||
l = t.l + 1;
|
||||
|
||||
t.q = [
|
||||
// top right
|
||||
new Quadtree(x + w, y, w, h, l),
|
||||
// top left
|
||||
new Quadtree(x, y, w, h, l),
|
||||
// bottom left
|
||||
new Quadtree(x, y + h, w, h, l),
|
||||
// bottom right
|
||||
new Quadtree(x + w, y + h, w, h, l),
|
||||
];
|
||||
},
|
||||
|
||||
// invokes callback with index of each overlapping quad
|
||||
quads: function(x, y, w, h, cb) {
|
||||
let t = this,
|
||||
q = t.q,
|
||||
hzMid = t.x + t.w / 2,
|
||||
vtMid = t.y + t.h / 2,
|
||||
startIsNorth = y < vtMid,
|
||||
startIsWest = x < hzMid,
|
||||
endIsEast = x + w > hzMid,
|
||||
endIsSouth = y + h > vtMid;
|
||||
|
||||
// top-right quad
|
||||
startIsNorth && endIsEast && cb(q[0]);
|
||||
// top-left quad
|
||||
startIsWest && startIsNorth && cb(q[1]);
|
||||
// bottom-left quad
|
||||
startIsWest && endIsSouth && cb(q[2]);
|
||||
// bottom-right quad
|
||||
endIsEast && endIsSouth && cb(q[3]);
|
||||
},
|
||||
|
||||
add: function(o) {
|
||||
let t = this;
|
||||
|
||||
if (t.q != null) {
|
||||
t.quads(o.x, o.y, o.w, o.h, q => {
|
||||
q.add(o);
|
||||
});
|
||||
}
|
||||
else {
|
||||
let os = t.o;
|
||||
|
||||
os.push(o);
|
||||
|
||||
if (os.length > MAX_OBJECTS && t.l < MAX_LEVELS) {
|
||||
t.split();
|
||||
|
||||
for (let i = 0; i < os.length; i++) {
|
||||
let oi = os[i];
|
||||
|
||||
t.quads(oi.x, oi.y, oi.w, oi.h, q => {
|
||||
q.add(oi);
|
||||
});
|
||||
}
|
||||
|
||||
t.o.length = 0;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
get: function(x, y, w, h, cb) {
|
||||
let t = this;
|
||||
let os = t.o;
|
||||
|
||||
for (let i = 0; i < os.length; i++)
|
||||
cb(os[i]);
|
||||
|
||||
if (t.q != null) {
|
||||
t.quads(x, y, w, h, q => {
|
||||
q.get(x, y, w, h, cb);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
clear: function() {
|
||||
this.o.length = 0;
|
||||
this.q = null;
|
||||
},
|
||||
};
|
||||
|
||||
Object.assign(Quadtree.prototype, proto);
|
||||
|
||||
global.Quadtree = Quadtree;
|
|
@ -1,7 +1,7 @@
|
|||
import {MetricResult} from "../../api/types";
|
||||
import {Series} from "uplot";
|
||||
import {getNameForMetric} from "../metric";
|
||||
import {LegendItem} from "./types";
|
||||
import {BarSeriesItem, Disp, Fill, LegendItem, Stroke} from "./types";
|
||||
import {getColorLine, getDashLine} from "./helpers";
|
||||
import {HideSeriesArgs} from "./types";
|
||||
|
||||
|
@ -50,3 +50,25 @@ export const getHideSeries = ({hideSeries, legend, metaKey, series}: HideSeriesA
|
|||
export const includesHideSeries = (label: string, group: string | number, hideSeries: string[]): boolean => {
|
||||
return hideSeries.includes(`${group}.${label}`);
|
||||
};
|
||||
|
||||
export const getBarSeries = (
|
||||
which: number[],
|
||||
ori: number,
|
||||
dir: number,
|
||||
radius: number,
|
||||
disp: Disp): BarSeriesItem => {
|
||||
return {
|
||||
which: which,
|
||||
ori: ori,
|
||||
dir: dir,
|
||||
radius: radius,
|
||||
disp: disp,
|
||||
};
|
||||
};
|
||||
|
||||
export const barDisp = (stroke: Stroke, fill: Fill): Disp => {
|
||||
return {
|
||||
stroke: stroke,
|
||||
fill: fill
|
||||
};
|
||||
};
|
||||
|
|
|
@ -39,3 +39,26 @@ export interface LegendItem {
|
|||
checked: boolean;
|
||||
freeFormFields: {[key: string]: string};
|
||||
}
|
||||
|
||||
export interface BarSeriesItem {
|
||||
which: number[],
|
||||
ori: number,
|
||||
dir: number,
|
||||
radius: number,
|
||||
disp: Disp
|
||||
}
|
||||
|
||||
export interface Disp {
|
||||
stroke: Stroke,
|
||||
fill: Fill,
|
||||
}
|
||||
|
||||
export interface Stroke {
|
||||
unit: number,
|
||||
values: (u: { data: number[][]; }) => string[],
|
||||
}
|
||||
|
||||
export interface Fill {
|
||||
unit: number,
|
||||
values: (u: { data: number[][]; }) => string[],
|
||||
}
|
||||
|
|
|
@ -18,6 +18,7 @@ The following tip changes can be tested by building VictoriaMetrics components f
|
|||
**Update notes:** this release introduces backwards-incompatible changes to communication protocol between `vmselect` and `vmstorage` nodes in cluster version of VictoriaMetrics because of added [query tracing](https://docs.victoriametrics.com/Single-server-VictoriaMetrics.html#query-tracing), so `vmselect` and `vmstorage` nodes may log communication errors during the upgrade. These errors should stop after all the `vmselect` and `vmstorage` nodes are updated to new release. It is safe to downgrade to previous releases.
|
||||
|
||||
* FEATURE: support query tracing, which allows determining bottlenecks during query processing. See [these docs](https://docs.victoriametrics.com/Single-server-VictoriaMetrics.html#query-tracing) and [this feature request](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/1403).
|
||||
* FEATURE: [vmui](https://docs.victoriametrics.com/#vmui): add `cardinality` tab, which can help identifying the source of [high cardinality](https://docs.victoriametrics.com/FAQ.html#what-is-high-cardinality) and [high churn rate](https://docs.victoriametrics.com/FAQ.html#what-is-high-churn-rate) issues. See [this feature request](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/2233).
|
||||
* FEATURE: allow overriding default limits for in-memory cache `indexdb/tagFilters` via flag `-storage.cacheSizeIndexDBTagFilters`. See [this issue](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/2663).
|
||||
* FEATURE: add support of `lowercase` and `uppercase` relabeling actions in the same way as [Prometheus 2.36.0 does](https://github.com/prometheus/prometheus/releases/tag/v2.36.0). See [this issue](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/2664).
|
||||
* FEATURE: add ability to change the `indexdb` rotation timezone offset via `-retentionTimezoneOffset` command-line flag. Previously it was performed at 4am UTC time. This could lead to performance degradation in the middle of the day when VictoriaMetrics runs in time zones located too far from UTC. Thanks to @cnych for [the pull request](https://github.com/VictoriaMetrics/VictoriaMetrics/pull/2574).
|
||||
|
|
|
@ -25,7 +25,7 @@ import (
|
|||
"github.com/VictoriaMetrics/VictoriaMetrics/lib/uint64set"
|
||||
"github.com/VictoriaMetrics/VictoriaMetrics/lib/workingsetcache"
|
||||
"github.com/VictoriaMetrics/fastcache"
|
||||
xxhash "github.com/cespare/xxhash/v2"
|
||||
"github.com/cespare/xxhash/v2"
|
||||
)
|
||||
|
||||
const (
|
||||
|
@ -1404,6 +1404,7 @@ func (is *indexSearch) getTSDBStatusWithFiltersForDate(tfss []*TagFilters, date
|
|||
thSeriesCountByMetricName := newTopHeap(topN)
|
||||
var tmp, labelName, labelNameValue []byte
|
||||
var labelValueCountByLabelName, seriesCountByLabelValuePair uint64
|
||||
var totalSeries, totalLabelValuePairs uint64
|
||||
nameEqualBytes := []byte("__name__=")
|
||||
|
||||
loopsPaceLimiter := 0
|
||||
|
@ -1444,50 +1445,57 @@ func (is *indexSearch) getTSDBStatusWithFiltersForDate(tfss []*TagFilters, date
|
|||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot unmarshal tag key from line %q: %w", item, err)
|
||||
}
|
||||
if isArtificialTagKey(tmp) {
|
||||
tagKey := tmp
|
||||
if isArtificialTagKey(tagKey) {
|
||||
// Skip artificially created tag keys.
|
||||
kb.B = append(kb.B[:0], prefix...)
|
||||
if len(tmp) > 0 && tmp[0] == compositeTagKeyPrefix {
|
||||
if len(tagKey) > 0 && tagKey[0] == compositeTagKeyPrefix {
|
||||
kb.B = append(kb.B, compositeTagKeyPrefix)
|
||||
} else {
|
||||
kb.B = marshalTagValue(kb.B, tmp)
|
||||
kb.B = marshalTagValue(kb.B, tagKey)
|
||||
}
|
||||
kb.B[len(kb.B)-1]++
|
||||
ts.Seek(kb.B)
|
||||
continue
|
||||
}
|
||||
if len(tmp) == 0 {
|
||||
tmp = append(tmp, "__name__"...)
|
||||
}
|
||||
if !bytes.Equal(tmp, labelName) {
|
||||
thLabelValueCountByLabelName.pushIfNonEmpty(labelName, labelValueCountByLabelName)
|
||||
labelValueCountByLabelName = 0
|
||||
labelName = append(labelName[:0], tmp...)
|
||||
if len(tagKey) == 0 {
|
||||
tagKey = append(tagKey, "__name__"...)
|
||||
tmp = tagKey
|
||||
}
|
||||
tmp = append(tmp, '=')
|
||||
tail, tmp, err = unmarshalTagValue(tmp, tail)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot unmarshal tag value from line %q: %w", item, err)
|
||||
}
|
||||
if !bytes.Equal(tmp, labelNameValue) {
|
||||
thSeriesCountByLabelValuePair.pushIfNonEmpty(labelNameValue, seriesCountByLabelValuePair)
|
||||
if bytes.HasPrefix(labelNameValue, nameEqualBytes) {
|
||||
thSeriesCountByMetricName.pushIfNonEmpty(labelNameValue[len(nameEqualBytes):], seriesCountByLabelValuePair)
|
||||
}
|
||||
seriesCountByLabelValuePair = 0
|
||||
labelValueCountByLabelName++
|
||||
labelNameValue = append(labelNameValue[:0], tmp...)
|
||||
}
|
||||
tagKeyValue := tmp
|
||||
if filter == nil {
|
||||
if err := mp.InitOnlyTail(item, tail); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
matchingSeriesCount = mp.MetricIDsLen()
|
||||
}
|
||||
if string(tagKey) == "__name__" {
|
||||
totalSeries += uint64(matchingSeriesCount)
|
||||
}
|
||||
if !bytes.Equal(tagKey, labelName) {
|
||||
thLabelValueCountByLabelName.pushIfNonEmpty(labelName, labelValueCountByLabelName)
|
||||
labelValueCountByLabelName = 0
|
||||
labelName = append(labelName[:0], tagKey...)
|
||||
}
|
||||
if !bytes.Equal(tagKeyValue, labelNameValue) {
|
||||
thSeriesCountByLabelValuePair.pushIfNonEmpty(labelNameValue, seriesCountByLabelValuePair)
|
||||
if bytes.HasPrefix(labelNameValue, nameEqualBytes) {
|
||||
thSeriesCountByMetricName.pushIfNonEmpty(labelNameValue[len(nameEqualBytes):], seriesCountByLabelValuePair)
|
||||
}
|
||||
seriesCountByLabelValuePair = 0
|
||||
labelValueCountByLabelName++
|
||||
labelNameValue = append(labelNameValue[:0], tagKeyValue...)
|
||||
}
|
||||
// Take into account deleted timeseries too.
|
||||
// It is OK if series can be counted multiple times in rare cases -
|
||||
// the returned number is an estimation.
|
||||
seriesCountByLabelValuePair += uint64(matchingSeriesCount)
|
||||
totalLabelValuePairs += uint64(matchingSeriesCount)
|
||||
}
|
||||
if err := ts.Error(); err != nil {
|
||||
return nil, fmt.Errorf("error when counting time series by metric names: %w", err)
|
||||
|
@ -1501,6 +1509,8 @@ func (is *indexSearch) getTSDBStatusWithFiltersForDate(tfss []*TagFilters, date
|
|||
SeriesCountByMetricName: thSeriesCountByMetricName.getSortedResult(),
|
||||
LabelValueCountByLabelName: thLabelValueCountByLabelName.getSortedResult(),
|
||||
SeriesCountByLabelValuePair: thSeriesCountByLabelValuePair.getSortedResult(),
|
||||
TotalSeries: totalSeries,
|
||||
TotalLabelValuePairs: totalLabelValuePairs,
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
|
@ -1509,6 +1519,8 @@ func (is *indexSearch) getTSDBStatusWithFiltersForDate(tfss []*TagFilters, date
|
|||
//
|
||||
// See https://prometheus.io/docs/prometheus/latest/querying/api/#tsdb-stats
|
||||
type TSDBStatus struct {
|
||||
TotalSeries uint64
|
||||
TotalLabelValuePairs uint64
|
||||
SeriesCountByMetricName []TopHeapEntry
|
||||
LabelValueCountByLabelName []TopHeapEntry
|
||||
SeriesCountByLabelValuePair []TopHeapEntry
|
||||
|
|
|
@ -1912,6 +1912,14 @@ func TestSearchTSIDWithTimeRange(t *testing.T) {
|
|||
if !reflect.DeepEqual(status.SeriesCountByLabelValuePair, expectedSeriesCountByLabelValuePair) {
|
||||
t.Fatalf("unexpected SeriesCountByLabelValuePair;\ngot\n%v\nwant\n%v", status.SeriesCountByLabelValuePair, expectedSeriesCountByLabelValuePair)
|
||||
}
|
||||
expectedTotalSeries := uint64(1000)
|
||||
if status.TotalSeries != expectedTotalSeries {
|
||||
t.Fatalf("unexpected TotalSeries; got %d; want %d", status.TotalSeries, expectedTotalSeries)
|
||||
}
|
||||
expectedLabelValuePairs := uint64(4000)
|
||||
if status.TotalLabelValuePairs != expectedLabelValuePairs {
|
||||
t.Fatalf("unexpected TotalLabelValuePairs; got %d; want %d", status.TotalLabelValuePairs, expectedLabelValuePairs)
|
||||
}
|
||||
|
||||
// Check GetTSDBStatusWithFiltersForDate
|
||||
tfs = NewTagFilters(accountID, projectID)
|
||||
|
@ -1934,6 +1942,43 @@ func TestSearchTSIDWithTimeRange(t *testing.T) {
|
|||
if !reflect.DeepEqual(status.SeriesCountByMetricName, expectedSeriesCountByMetricName) {
|
||||
t.Fatalf("unexpected SeriesCountByMetricName;\ngot\n%v\nwant\n%v", status.SeriesCountByMetricName, expectedSeriesCountByMetricName)
|
||||
}
|
||||
expectedTotalSeries = 1000
|
||||
if status.TotalSeries != expectedTotalSeries {
|
||||
t.Fatalf("unexpected TotalSeries; got %d; want %d", status.TotalSeries, expectedTotalSeries)
|
||||
}
|
||||
expectedLabelValuePairs = 4000
|
||||
if status.TotalLabelValuePairs != expectedLabelValuePairs {
|
||||
t.Fatalf("unexpected TotalLabelValuePairs; got %d; want %d", status.TotalLabelValuePairs, expectedLabelValuePairs)
|
||||
}
|
||||
// Check GetTSDBStatusWithFiltersForDate
|
||||
tfs = NewTagFilters(accountID, projectID)
|
||||
if err := tfs.Add([]byte("uniqueid"), []byte("0|1|3"), false, true); err != nil {
|
||||
t.Fatalf("cannot add filter: %s", err)
|
||||
}
|
||||
status, err = db.GetTSDBStatusWithFiltersForDate(accountID, projectID, []*TagFilters{tfs}, baseDate, 5, 1e6, noDeadline)
|
||||
if err != nil {
|
||||
t.Fatalf("error in GetTSDBStatusWithFiltersForDate: %s", err)
|
||||
}
|
||||
if !status.hasEntries() {
|
||||
t.Fatalf("expecting non-empty TSDB status")
|
||||
}
|
||||
expectedSeriesCountByMetricName = []TopHeapEntry{
|
||||
{
|
||||
Name: "testMetric",
|
||||
Count: 3,
|
||||
},
|
||||
}
|
||||
if !reflect.DeepEqual(status.SeriesCountByMetricName, expectedSeriesCountByMetricName) {
|
||||
t.Fatalf("unexpected SeriesCountByMetricName;\ngot\n%v\nwant\n%v", status.SeriesCountByMetricName, expectedSeriesCountByMetricName)
|
||||
}
|
||||
expectedTotalSeries = 3
|
||||
if status.TotalSeries != expectedTotalSeries {
|
||||
t.Fatalf("unexpected TotalSeries; got %d; want %d", status.TotalSeries, expectedTotalSeries)
|
||||
}
|
||||
expectedLabelValuePairs = 12
|
||||
if status.TotalLabelValuePairs != expectedLabelValuePairs {
|
||||
t.Fatalf("unexpected TotalLabelValuePairs; got %d; want %d", status.TotalLabelValuePairs, expectedLabelValuePairs)
|
||||
}
|
||||
}
|
||||
|
||||
func toTFPointers(tfs []tagFilter) []*tagFilter {
|
||||
|
|
Loading…
Reference in a new issue