mirror of
https://github.com/prometheus/prometheus.git
synced 2024-12-25 05:34:05 -08:00
Merge remote-tracking branch 'upstream/main' into fix-panic-in-ooo-query2
This commit is contained in:
commit
cac58b8bb7
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -22,7 +22,8 @@ benchmark.txt
|
|||
/documentation/examples/remote_storage/example_write_adapter/example_write_adapter
|
||||
|
||||
npm_licenses.tar.bz2
|
||||
/web/ui/static/react
|
||||
/web/ui/static/react-app
|
||||
/web/ui/static/mantine-ui
|
||||
|
||||
/vendor
|
||||
/.build
|
||||
|
|
8
Makefile
8
Makefile
|
@ -49,6 +49,10 @@ ui-bump-version:
|
|||
.PHONY: ui-install
|
||||
ui-install:
|
||||
cd $(UI_PATH) && npm install
|
||||
# The old React app has been separated from the npm workspaces setup to avoid
|
||||
# issues with conflicting dependencies. This is a temporary solution until the
|
||||
# new Mantine-based UI is fully integrated and the old app can be removed.
|
||||
cd $(UI_PATH)/react-app && npm install
|
||||
|
||||
.PHONY: ui-build
|
||||
ui-build:
|
||||
|
@ -65,6 +69,10 @@ ui-test:
|
|||
.PHONY: ui-lint
|
||||
ui-lint:
|
||||
cd $(UI_PATH) && npm run lint
|
||||
# The old React app has been separated from the npm workspaces setup to avoid
|
||||
# issues with conflicting dependencies. This is a temporary solution until the
|
||||
# new Mantine-based UI is fully integrated and the old app can be removed.
|
||||
cd $(UI_PATH)/react-app && npm run lint
|
||||
|
||||
.PHONY: assets
|
||||
assets: ui-install ui-build
|
||||
|
|
|
@ -259,6 +259,9 @@ func (c *flagConfig) setFeatureListOptions(logger log.Logger) error {
|
|||
continue
|
||||
case "promql-at-modifier", "promql-negative-offset":
|
||||
level.Warn(logger).Log("msg", "This option for --enable-feature is now permanently enabled and therefore a no-op.", "option", o)
|
||||
case "old-ui":
|
||||
c.web.UseOldUI = true
|
||||
level.Info(logger).Log("msg", "Serving previous version of the Prometheus web UI.")
|
||||
default:
|
||||
level.Warn(logger).Log("msg", "Unknown option for --enable-feature", "option", o)
|
||||
}
|
||||
|
@ -504,7 +507,7 @@ func main() {
|
|||
|
||||
a.Flag("scrape.name-escaping-scheme", `Method for escaping legacy invalid names when sending to Prometheus that does not support UTF-8. Can be one of "values", "underscores", or "dots".`).Default(scrape.DefaultNameEscapingScheme.String()).StringVar(&cfg.nameEscapingScheme)
|
||||
|
||||
a.Flag("enable-feature", "Comma separated feature names to enable. Valid options: agent, auto-gomaxprocs, auto-gomemlimit, auto-reload-config, concurrent-rule-eval, created-timestamp-zero-ingestion, delayed-compaction, exemplar-storage, expand-external-labels, extra-scrape-metrics, memory-snapshot-on-shutdown, native-histograms, new-service-discovery-manager, no-default-scrape-port, otlp-write-receiver, promql-experimental-functions, promql-delayed-name-removal, promql-per-step-stats, remote-write-receiver (DEPRECATED), utf8-names. See https://prometheus.io/docs/prometheus/latest/feature_flags/ for more details.").
|
||||
a.Flag("enable-feature", "Comma separated feature names to enable. Valid options: agent, auto-gomaxprocs, auto-gomemlimit, auto-reload-config, concurrent-rule-eval, created-timestamp-zero-ingestion, delayed-compaction, exemplar-storage, expand-external-labels, extra-scrape-metrics, memory-snapshot-on-shutdown, native-histograms, new-service-discovery-manager, no-default-scrape-port, old-ui, otlp-write-receiver, promql-experimental-functions, promql-delayed-name-removal, promql-per-step-stats, remote-write-receiver (DEPRECATED), utf8-names. See https://prometheus.io/docs/prometheus/latest/feature_flags/ for more details.").
|
||||
Default("").StringsVar(&cfg.featureList)
|
||||
|
||||
promlogflag.AddFlags(a, &cfg.promlogConfig)
|
||||
|
|
|
@ -58,7 +58,7 @@ The Prometheus monitoring server
|
|||
| <code class="text-nowrap">--query.max-concurrency</code> | Maximum number of queries executed concurrently. Use with server mode only. | `20` |
|
||||
| <code class="text-nowrap">--query.max-samples</code> | Maximum number of samples a single query can load into memory. Note that queries will fail if they try to load more samples than this into memory, so this also limits the number of samples a query can return. Use with server mode only. | `50000000` |
|
||||
| <code class="text-nowrap">--scrape.name-escaping-scheme</code> | Method for escaping legacy invalid names when sending to Prometheus that does not support UTF-8. Can be one of "values", "underscores", or "dots". | `values` |
|
||||
| <code class="text-nowrap">--enable-feature</code> <code class="text-nowrap">...<code class="text-nowrap"> | Comma separated feature names to enable. Valid options: agent, auto-gomaxprocs, auto-gomemlimit, auto-reload-config, concurrent-rule-eval, created-timestamp-zero-ingestion, delayed-compaction, exemplar-storage, expand-external-labels, extra-scrape-metrics, memory-snapshot-on-shutdown, native-histograms, new-service-discovery-manager, no-default-scrape-port, otlp-write-receiver, promql-experimental-functions, promql-delayed-name-removal, promql-per-step-stats, remote-write-receiver (DEPRECATED), utf8-names. See https://prometheus.io/docs/prometheus/latest/feature_flags/ for more details. | |
|
||||
| <code class="text-nowrap">--enable-feature</code> <code class="text-nowrap">...<code class="text-nowrap"> | Comma separated feature names to enable. Valid options: agent, auto-gomaxprocs, auto-gomemlimit, auto-reload-config, concurrent-rule-eval, created-timestamp-zero-ingestion, delayed-compaction, exemplar-storage, expand-external-labels, extra-scrape-metrics, memory-snapshot-on-shutdown, native-histograms, new-service-discovery-manager, no-default-scrape-port, old-ui, otlp-write-receiver, promql-experimental-functions, promql-delayed-name-removal, promql-per-step-stats, remote-write-receiver (DEPRECATED), utf8-names. See https://prometheus.io/docs/prometheus/latest/feature_flags/ for more details. | |
|
||||
| <code class="text-nowrap">--log.level</code> | Only log messages with the given severity or above. One of: [debug, info, warn, error] | `info` |
|
||||
| <code class="text-nowrap">--log.format</code> | Output format of log messages. One of: [logfmt, json] | `logfmt` |
|
||||
|
||||
|
|
|
@ -193,7 +193,7 @@ This should **only** be applied to metrics that currently produce such labels.
|
|||
`--enable-feature=otlp-write-receiver`
|
||||
|
||||
The OTLP receiver allows Prometheus to accept [OpenTelemetry](https://opentelemetry.io/) metrics writes.
|
||||
Prometheus is best used as a Pull based system, and staleness, `up` metric, and other Pull enabled features
|
||||
Prometheus is best used as a Pull based system, and staleness, `up` metric, and other Pull enabled features
|
||||
won't work when you push OTLP metrics.
|
||||
|
||||
## Experimental PromQL functions
|
||||
|
@ -226,6 +226,12 @@ This has the potential to improve rule group evaluation latency and resource uti
|
|||
|
||||
The number of concurrent rule evaluations can be configured with `--rules.max-concurrent-rule-evals`, which is set to `4` by default.
|
||||
|
||||
## Serve old Prometheus UI
|
||||
|
||||
Fall back to serving the old (Prometheus 2.x) web UI instead of the new UI. The new UI that was released as part of Prometheus 3.0 is a complete rewrite and aims to be cleaner, less cluttered, and more modern under the hood. However, it is not fully feature complete and battle-tested yet, so some users may still prefer using the old UI.
|
||||
|
||||
`--enable-feature=old-ui`
|
||||
|
||||
## Metadata WAL Records
|
||||
|
||||
`--enable-feature=metadata-wal-records`
|
||||
|
|
|
@ -239,6 +239,75 @@ $ curl 'http://localhost:9090/api/v1/format_query?query=foo/bar'
|
|||
}
|
||||
```
|
||||
|
||||
## Parsing a PromQL expressions into a abstract syntax tree (AST)
|
||||
|
||||
This endpoint is **experimental** and might change in the future. It is currently only meant to be used by Prometheus' own web UI, and the endpoint name and exact format returned may change from one Prometheus version to another. It may also be removed again in case it is no longer needed by the UI.
|
||||
|
||||
The following endpoint parses a PromQL expression and returns it as a JSON-formatted AST (abstract syntax tree) representation:
|
||||
|
||||
```
|
||||
GET /api/v1/parse_query
|
||||
POST /api/v1/parse_query
|
||||
```
|
||||
|
||||
URL query parameters:
|
||||
|
||||
- `query=<string>`: Prometheus expression query string.
|
||||
|
||||
You can URL-encode these parameters directly in the request body by using the `POST` method and
|
||||
`Content-Type: application/x-www-form-urlencoded` header. This is useful when specifying a large
|
||||
query that may breach server-side URL character limits.
|
||||
|
||||
The `data` section of the query result is a string containing the AST of the parsed query expression.
|
||||
|
||||
The following example parses the expression `foo/bar`:
|
||||
|
||||
```json
|
||||
$ curl 'http://localhost:9090/api/v1/parse_query?query=foo/bar'
|
||||
{
|
||||
"data" : {
|
||||
"bool" : false,
|
||||
"lhs" : {
|
||||
"matchers" : [
|
||||
{
|
||||
"name" : "__name__",
|
||||
"type" : "=",
|
||||
"value" : "foo"
|
||||
}
|
||||
],
|
||||
"name" : "foo",
|
||||
"offset" : 0,
|
||||
"startOrEnd" : null,
|
||||
"timestamp" : null,
|
||||
"type" : "vectorSelector"
|
||||
},
|
||||
"matching" : {
|
||||
"card" : "one-to-one",
|
||||
"include" : [],
|
||||
"labels" : [],
|
||||
"on" : false
|
||||
},
|
||||
"op" : "/",
|
||||
"rhs" : {
|
||||
"matchers" : [
|
||||
{
|
||||
"name" : "__name__",
|
||||
"type" : "=",
|
||||
"value" : "bar"
|
||||
}
|
||||
],
|
||||
"name" : "bar",
|
||||
"offset" : 0,
|
||||
"startOrEnd" : null,
|
||||
"timestamp" : null,
|
||||
"type" : "vectorSelector"
|
||||
},
|
||||
"type" : "binaryExpr"
|
||||
},
|
||||
"status" : "success"
|
||||
}
|
||||
```
|
||||
|
||||
## Querying metadata
|
||||
|
||||
Prometheus offers a set of API endpoints to query metadata about series and their labels.
|
||||
|
@ -693,7 +762,7 @@ URL query parameters:
|
|||
- `rule_name[]=<string>`: only return rules with the given rule name. If the parameter is repeated, rules with any of the provided names are returned. If we've filtered out all the rules of a group, the group is not returned. When the parameter is absent or empty, no filtering is done.
|
||||
- `rule_group[]=<string>`: only return rules with the given rule group name. If the parameter is repeated, rules with any of the provided rule group names are returned. When the parameter is absent or empty, no filtering is done.
|
||||
- `file[]=<string>`: only return rules with the given filepath. If the parameter is repeated, rules with any of the provided filepaths are returned. When the parameter is absent or empty, no filtering is done.
|
||||
- `exclude_alerts=<bool>`: only return rules, do not return active alerts.
|
||||
- `exclude_alerts=<bool>`: only return rules, do not return active alerts.
|
||||
- `match[]=<label_selector>`: only return rules that have configured labels that satisfy the label selectors. If the parameter is repeated, rules that match any of the sets of label selectors are returned. Note that matching is on the labels in the definition of each rule, not on the values after template expansion (for alerting rules). Optional.
|
||||
|
||||
```json
|
||||
|
|
|
@ -30,8 +30,8 @@ function publish() {
|
|||
cmd+=" --dry-run"
|
||||
fi
|
||||
for workspace in ${workspaces}; do
|
||||
# package "app" is private so we shouldn't try to publish it.
|
||||
if [[ "${workspace}" != "react-app" ]]; then
|
||||
# package "mantine-ui" is private so we shouldn't try to publish it.
|
||||
if [[ "${workspace}" != "mantine-ui" ]]; then
|
||||
cd "${workspace}"
|
||||
eval "${cmd}"
|
||||
cd "${root_ui_folder}"
|
||||
|
|
|
@ -366,6 +366,9 @@ func (api *API) Register(r *route.Router) {
|
|||
r.Get("/format_query", wrapAgent(api.formatQuery))
|
||||
r.Post("/format_query", wrapAgent(api.formatQuery))
|
||||
|
||||
r.Get("/parse_query", wrapAgent(api.parseQuery))
|
||||
r.Post("/parse_query", wrapAgent(api.parseQuery))
|
||||
|
||||
r.Get("/labels", wrapAgent(api.labelNames))
|
||||
r.Post("/labels", wrapAgent(api.labelNames))
|
||||
r.Get("/label/:name/values", wrapAgent(api.labelValues))
|
||||
|
@ -485,6 +488,15 @@ func (api *API) formatQuery(r *http.Request) (result apiFuncResult) {
|
|||
return apiFuncResult{expr.Pretty(0), nil, nil, nil}
|
||||
}
|
||||
|
||||
func (api *API) parseQuery(r *http.Request) apiFuncResult {
|
||||
expr, err := parser.ParseExpr(r.FormValue("query"))
|
||||
if err != nil {
|
||||
return invalidParamError(err, "query")
|
||||
}
|
||||
|
||||
return apiFuncResult{data: translateAST(expr), err: nil, warnings: nil, finalizer: nil}
|
||||
}
|
||||
|
||||
func extractQueryOpts(r *http.Request) (promql.QueryOpts, error) {
|
||||
var duration time.Duration
|
||||
|
||||
|
|
157
web/api/v1/translate_ast.go
Normal file
157
web/api/v1/translate_ast.go
Normal file
|
@ -0,0 +1,157 @@
|
|||
// Copyright 2024 The Prometheus Authors
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package v1
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/prometheus/prometheus/model/labels"
|
||||
"github.com/prometheus/prometheus/promql/parser"
|
||||
)
|
||||
|
||||
// Take a Go PromQL AST and translate it to an object that's nicely JSON-serializable
|
||||
// for the tree view in the UI.
|
||||
// TODO: Could it make sense to do this via the normal JSON marshalling methods? Maybe
|
||||
// too UI-specific though.
|
||||
func translateAST(node parser.Expr) interface{} {
|
||||
if node == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch n := node.(type) {
|
||||
case *parser.AggregateExpr:
|
||||
return map[string]interface{}{
|
||||
"type": "aggregation",
|
||||
"op": n.Op.String(),
|
||||
"expr": translateAST(n.Expr),
|
||||
"param": translateAST(n.Param),
|
||||
"grouping": sanitizeList(n.Grouping),
|
||||
"without": n.Without,
|
||||
}
|
||||
case *parser.BinaryExpr:
|
||||
var matching interface{}
|
||||
if m := n.VectorMatching; m != nil {
|
||||
matching = map[string]interface{}{
|
||||
"card": m.Card.String(),
|
||||
"labels": sanitizeList(m.MatchingLabels),
|
||||
"on": m.On,
|
||||
"include": sanitizeList(m.Include),
|
||||
}
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"type": "binaryExpr",
|
||||
"op": n.Op.String(),
|
||||
"lhs": translateAST(n.LHS),
|
||||
"rhs": translateAST(n.RHS),
|
||||
"matching": matching,
|
||||
"bool": n.ReturnBool,
|
||||
}
|
||||
case *parser.Call:
|
||||
args := []interface{}{}
|
||||
for _, arg := range n.Args {
|
||||
args = append(args, translateAST(arg))
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"type": "call",
|
||||
"func": map[string]interface{}{
|
||||
"name": n.Func.Name,
|
||||
"argTypes": n.Func.ArgTypes,
|
||||
"variadic": n.Func.Variadic,
|
||||
"returnType": n.Func.ReturnType,
|
||||
},
|
||||
"args": args,
|
||||
}
|
||||
case *parser.MatrixSelector:
|
||||
vs := n.VectorSelector.(*parser.VectorSelector)
|
||||
return map[string]interface{}{
|
||||
"type": "matrixSelector",
|
||||
"name": vs.Name,
|
||||
"range": n.Range.Milliseconds(),
|
||||
"offset": vs.OriginalOffset.Milliseconds(),
|
||||
"matchers": translateMatchers(vs.LabelMatchers),
|
||||
"timestamp": vs.Timestamp,
|
||||
"startOrEnd": getStartOrEnd(vs.StartOrEnd),
|
||||
}
|
||||
case *parser.SubqueryExpr:
|
||||
return map[string]interface{}{
|
||||
"type": "subquery",
|
||||
"expr": translateAST(n.Expr),
|
||||
"range": n.Range.Milliseconds(),
|
||||
"offset": n.OriginalOffset.Milliseconds(),
|
||||
"step": n.Step.Milliseconds(),
|
||||
"timestamp": n.Timestamp,
|
||||
"startOrEnd": getStartOrEnd(n.StartOrEnd),
|
||||
}
|
||||
case *parser.NumberLiteral:
|
||||
return map[string]string{
|
||||
"type": "numberLiteral",
|
||||
"val": strconv.FormatFloat(n.Val, 'f', -1, 64),
|
||||
}
|
||||
case *parser.ParenExpr:
|
||||
return map[string]interface{}{
|
||||
"type": "parenExpr",
|
||||
"expr": translateAST(n.Expr),
|
||||
}
|
||||
case *parser.StringLiteral:
|
||||
return map[string]interface{}{
|
||||
"type": "stringLiteral",
|
||||
"val": n.Val,
|
||||
}
|
||||
case *parser.UnaryExpr:
|
||||
return map[string]interface{}{
|
||||
"type": "unaryExpr",
|
||||
"op": n.Op.String(),
|
||||
"expr": translateAST(n.Expr),
|
||||
}
|
||||
case *parser.VectorSelector:
|
||||
return map[string]interface{}{
|
||||
"type": "vectorSelector",
|
||||
"name": n.Name,
|
||||
"offset": n.OriginalOffset.Milliseconds(),
|
||||
"matchers": translateMatchers(n.LabelMatchers),
|
||||
"timestamp": n.Timestamp,
|
||||
"startOrEnd": getStartOrEnd(n.StartOrEnd),
|
||||
}
|
||||
}
|
||||
panic("unsupported node type")
|
||||
}
|
||||
|
||||
func sanitizeList(l []string) []string {
|
||||
if l == nil {
|
||||
return []string{}
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
func translateMatchers(in []*labels.Matcher) interface{} {
|
||||
out := []map[string]interface{}{}
|
||||
for _, m := range in {
|
||||
out = append(out, map[string]interface{}{
|
||||
"name": m.Name,
|
||||
"value": m.Value,
|
||||
"type": m.Type.String(),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func getStartOrEnd(startOrEnd parser.ItemType) interface{} {
|
||||
if startOrEnd == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return startOrEnd.String()
|
||||
}
|
|
@ -1,4 +1,5 @@
|
|||
## Overview
|
||||
|
||||
The `ui` directory contains static files and templates used in the web UI. For
|
||||
easier distribution they are compressed (c.f. Makefile) and statically compiled
|
||||
into the Prometheus binary using the embed package.
|
||||
|
@ -15,15 +16,23 @@ This will serve all files from your local filesystem. This is for development pu
|
|||
|
||||
### Introduction
|
||||
|
||||
The react application is a monorepo composed by multiple different npm packages. The main one is `react-app` which
|
||||
contains the code of the react application.
|
||||
This directory contains two generations of Prometheus' React-based web UI:
|
||||
|
||||
* `react-app`: The old 2.x web UI
|
||||
* `mantine-ui`: The new 3.x web UI
|
||||
|
||||
Both UIs are built and compiled into Prometheus. The new UI is served by default, but a feature flag
|
||||
(`--enable-feature=old-ui`) can be used to switch back to serving the old UI.
|
||||
|
||||
Then you have different npm packages located in the folder `modules`. These packages are supposed to be used by the
|
||||
react-app and also by others consumers (like Thanos)
|
||||
two React apps and also by others consumers (like Thanos).
|
||||
|
||||
While most of these applications / modules are part of the same npm workspace, the old UI in the `react-app` directory
|
||||
has been separated out of the workspace setup, since its dependencies were too incompatible.
|
||||
|
||||
### Pre-requisite
|
||||
|
||||
To be able to build the react application you need:
|
||||
To be able to build either of the React applications, you need:
|
||||
|
||||
* npm >= v7
|
||||
* node >= v20
|
||||
|
@ -38,46 +47,50 @@ need to move to the directory `web/ui` and then download and install them locall
|
|||
npm consults the `package.json` and `package-lock.json` files for dependencies to install. It creates a `node_modules`
|
||||
directory with all installed dependencies.
|
||||
|
||||
**NOTE**: Do not run `npm install` in the `react-app` folder or in any sub folder of the `module` directory.
|
||||
**NOTE**: Do not run `npm install` in the `react-app` / `mantine-ui` folder or in any sub folder of the `module` directory.
|
||||
|
||||
### Upgrading npm dependencies
|
||||
|
||||
As it is a monorepo, when upgrading a dependency, you have to upgrade it in every packages that composed this monorepo (
|
||||
aka, in all sub folder of `module` and in `react-app`)
|
||||
As it is a monorepo, when upgrading a dependency, you have to upgrade it in every packages that composed this monorepo
|
||||
(aka, in all sub folders of `module` and `react-app` / `mantine-ui`)
|
||||
|
||||
Then you have to run the command `npm install` in `web/ui` and not in a sub folder / sub package. It won't simply work.
|
||||
|
||||
### Running a local development server
|
||||
|
||||
You can start a development server for the React UI outside of a running Prometheus server by running:
|
||||
You can start a development server for the new React UI outside of a running Prometheus server by running:
|
||||
|
||||
npm start
|
||||
|
||||
This will open a browser window with the React app running on http://localhost:3000/. The page will reload if you make
|
||||
(For the old UI, you will have to run the same command from the `react-app` subdirectory.)
|
||||
|
||||
This will open a browser window with the React app running on http://localhost:5173/. The page will reload if you make
|
||||
edits to the source code. You will also see any lint errors in the console.
|
||||
|
||||
**NOTE**: It will reload only if you change the code in `react-app` folder. Any code changes in the folder `module` is
|
||||
**NOTE**: It will reload only if you change the code in `mantine-ui` folder. Any code changes in the folder `module` is
|
||||
not considered by the command `npm start`. In order to see the changes in the react-app you will have to
|
||||
run `npm run build:module`
|
||||
|
||||
Due to a `"proxy": "http://localhost:9090"` setting in the `package.json` file, any API requests from the React UI are
|
||||
Due to a `"proxy": "http://localhost:9090"` setting in the `mantine-ui/vite.config.ts` file, any API requests from the React UI are
|
||||
proxied to `localhost` on port `9090` by the development server. This allows you to run a normal Prometheus server to
|
||||
handle API requests, while iterating separately on the UI.
|
||||
|
||||
[browser] ----> [localhost:3000 (dev server)] --(proxy API requests)--> [localhost:9090 (Prometheus)]
|
||||
[browser] ----> [localhost:5173 (dev server)] --(proxy API requests)--> [localhost:9090 (Prometheus)]
|
||||
|
||||
### Running tests
|
||||
|
||||
To run the test for the react-app and for all modules, you can simply run:
|
||||
To run the test for the new React app and for all modules, you can simply run:
|
||||
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
|
||||
if you want to run the test only for a specific module, you need to go to the folder of the module and run
|
||||
(For the old UI, you will have to run the same command from the `react-app` subdirectory.)
|
||||
|
||||
If you want to run the test only for a specific module, you need to go to the folder of the module and run
|
||||
again `npm test`.
|
||||
|
||||
For example, in case you only want to run the test of the react-app, go to `web/ui/react-app` and run `npm test`
|
||||
For example, in case you only want to run the test of the new React app, go to `web/ui/mantine-ui` and run `npm test`
|
||||
|
||||
To generate an HTML-based test coverage report, run:
|
||||
|
||||
|
@ -93,7 +106,7 @@ running tests.
|
|||
|
||||
### Building the app for production
|
||||
|
||||
To build a production-optimized version of the React app to a `build` subdirectory, run:
|
||||
To build a production-optimized version of both React app versions to a `static/{react-app,mantine-ui}` subdirectory, run:
|
||||
|
||||
npm run build
|
||||
|
||||
|
@ -102,10 +115,10 @@ Prometheus `Makefile` when building the full binary.
|
|||
|
||||
### Integration into Prometheus
|
||||
|
||||
To build a Prometheus binary that includes a compiled-in version of the production build of the React app, change to the
|
||||
To build a Prometheus binary that includes a compiled-in version of the production build of both React app versions, change to the
|
||||
root of the repository and run:
|
||||
|
||||
make build
|
||||
|
||||
This installs dependencies via npm, builds a production build of the React app, and then finally compiles in all web
|
||||
This installs dependencies via npm, builds a production build of both React apps, and then finally compiles in all web
|
||||
assets into the Prometheus binary.
|
||||
|
|
|
@ -31,9 +31,16 @@ function buildModule() {
|
|||
|
||||
function buildReactApp() {
|
||||
echo "build react-app"
|
||||
npm run build -w @prometheus-io/app
|
||||
rm -rf ./static/react
|
||||
mv ./react-app/build ./static/react
|
||||
(cd react-app && npm run build)
|
||||
rm -rf ./static/react-app
|
||||
mv ./react-app/build ./static/react-app
|
||||
}
|
||||
|
||||
function buildMantineUI() {
|
||||
echo "build mantine-ui"
|
||||
npm run build -w @prometheus-io/mantine-ui
|
||||
rm -rf ./static/mantine-ui
|
||||
mv ./mantine-ui/dist ./static/mantine-ui
|
||||
}
|
||||
|
||||
for i in "$@"; do
|
||||
|
@ -41,6 +48,7 @@ for i in "$@"; do
|
|||
--all)
|
||||
buildModule
|
||||
buildReactApp
|
||||
buildMantineUI
|
||||
shift
|
||||
;;
|
||||
--build-module)
|
||||
|
|
24
web/ui/mantine-ui/.gitignore
vendored
Normal file
24
web/ui/mantine-ui/.gitignore
vendored
Normal file
|
@ -0,0 +1,24 @@
|
|||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
30
web/ui/mantine-ui/README.md
Normal file
30
web/ui/mantine-ui/README.md
Normal file
|
@ -0,0 +1,30 @@
|
|||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
|
||||
|
||||
- Configure the top-level `parserOptions` property like this:
|
||||
|
||||
```js
|
||||
export default {
|
||||
// other rules...
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
project: ['./tsconfig.json', './tsconfig.node.json'],
|
||||
tsconfigRootDir: __dirname,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked`
|
||||
- Optionally add `plugin:@typescript-eslint/stylistic-type-checked`
|
||||
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list
|
71
web/ui/mantine-ui/eslint.config.mjs
Normal file
71
web/ui/mantine-ui/eslint.config.mjs
Normal file
|
@ -0,0 +1,71 @@
|
|||
import { fixupConfigRules } from '@eslint/compat';
|
||||
import reactRefresh from 'eslint-plugin-react-refresh';
|
||||
import globals from 'globals';
|
||||
import tsParser from '@typescript-eslint/parser';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import js from '@eslint/js';
|
||||
import { FlatCompat } from '@eslint/eslintrc';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: __dirname,
|
||||
recommendedConfig: js.configs.recommended,
|
||||
allConfig: js.configs.all
|
||||
});
|
||||
|
||||
export default [{
|
||||
ignores: ['**/dist', '**/.eslintrc.cjs'],
|
||||
}, ...fixupConfigRules(compat.extends(
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:react-hooks/recommended',
|
||||
)), {
|
||||
plugins: {
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
},
|
||||
|
||||
parser: tsParser,
|
||||
},
|
||||
|
||||
rules: {
|
||||
'react-refresh/only-export-components': ['warn', {
|
||||
allowConstantExport: true,
|
||||
}],
|
||||
|
||||
// Disable the base rule as it can report incorrect errors
|
||||
'no-unused-vars': 'off',
|
||||
|
||||
// Use the TypeScript-specific rule for unused vars
|
||||
'@typescript-eslint/no-unused-vars': ['warn', {
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
caughtErrorsIgnorePattern: '^_',
|
||||
}],
|
||||
|
||||
'prefer-const': ['error', {
|
||||
destructuring: 'all',
|
||||
}],
|
||||
},
|
||||
},
|
||||
// Override for Node.js-based config files
|
||||
{
|
||||
files: ['postcss.config.cjs'], // Specify any other config files
|
||||
languageOptions: {
|
||||
ecmaVersion: 2021, // Optional, set ECMAScript version
|
||||
sourceType: 'script', // For CommonJS (non-ESM) modules
|
||||
globals: {
|
||||
module: 'readonly',
|
||||
require: 'readonly',
|
||||
process: 'readonly',
|
||||
__dirname: 'readonly', // Include other Node.js globals if needed
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
35
web/ui/mantine-ui/index.html
Normal file
35
web/ui/mantine-ui/index.html
Normal file
|
@ -0,0 +1,35 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
|
||||
<!--
|
||||
Placeholders replaced by Prometheus during serving:
|
||||
- GLOBAL_CONSOLES_LINK is replaced and set to the consoles link if it exists.
|
||||
It will render a "Consoles" link in the navbar when it is non-empty.
|
||||
- PROMETHEUS_AGENT_MODE is replaced by a boolean indicating if Prometheus is running in agent mode.
|
||||
It true, it will disable querying capacities in the UI and generally adapt the UI to the agent mode.
|
||||
It has to be represented as a string, because booleans can be mangled to !1 in production builds.
|
||||
- PROMETHEUS_READY is replaced by a boolean indicating whether Prometheus was ready at the time the
|
||||
web app was served. It has to be represented as a string, because booleans can be mangled to !1 in
|
||||
production builds.
|
||||
-->
|
||||
<script>
|
||||
const GLOBAL_CONSOLES_LINK='CONSOLES_LINK_PLACEHOLDER';
|
||||
const GLOBAL_AGENT_MODE='AGENT_MODE_PLACEHOLDER';
|
||||
const GLOBAL_READY='READY_PLACEHOLDER';
|
||||
</script>
|
||||
|
||||
<!--
|
||||
The TITLE_PLACEHOLDER magic value is replaced during serving by Prometheus.
|
||||
We need it dynamic because it can be overridden by the command line flag `web.page-title`.
|
||||
-->
|
||||
<title>TITLE_PLACEHOLDER</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
72
web/ui/mantine-ui/package.json
Normal file
72
web/ui/mantine-ui/package.json
Normal file
|
@ -0,0 +1,72 @@
|
|||
{
|
||||
"name": "@prometheus-io/mantine-ui",
|
||||
"private": true,
|
||||
"version": "0.54.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"lint": "eslint . --report-unused-disable-directives --max-warnings 0",
|
||||
"lint:fix": "eslint . --report-unused-disable-directives --max-warnings 0 --fix",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.18.0",
|
||||
"@codemirror/language": "^6.10.2",
|
||||
"@codemirror/lint": "^6.8.1",
|
||||
"@codemirror/state": "^6.4.1",
|
||||
"@codemirror/view": "^6.33.0",
|
||||
"@floating-ui/dom": "^1.6.7",
|
||||
"@lezer/common": "^1.2.1",
|
||||
"@lezer/highlight": "^1.2.1",
|
||||
"@mantine/code-highlight": "^7.11.2",
|
||||
"@mantine/core": "^7.11.2",
|
||||
"@mantine/dates": "^7.11.2",
|
||||
"@mantine/hooks": "^7.11.2",
|
||||
"@mantine/notifications": "^7.11.2",
|
||||
"@nexucis/fuzzy": "^0.5.1",
|
||||
"@nexucis/kvsearch": "^0.9.1",
|
||||
"@prometheus-io/codemirror-promql": "^0.54.1",
|
||||
"@reduxjs/toolkit": "^2.2.1",
|
||||
"@tabler/icons-react": "^2.47.0",
|
||||
"@tanstack/react-query": "^5.22.2",
|
||||
"@testing-library/jest-dom": "^6.5.0",
|
||||
"@testing-library/react": "^16.0.1",
|
||||
"@types/lodash": "^4.17.7",
|
||||
"@types/sanitize-html": "^2.13.0",
|
||||
"@uiw/react-codemirror": "^4.23.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.10",
|
||||
"lodash": "^4.17.21",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-infinite-scroll-component": "^6.1.0",
|
||||
"react-redux": "^9.1.2",
|
||||
"react-router-dom": "^6.26.1",
|
||||
"sanitize-html": "^2.13.0",
|
||||
"uplot": "^1.6.30",
|
||||
"uplot-react": "^1.2.2",
|
||||
"use-query-params": "^2.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/compat": "^1.1.1",
|
||||
"@eslint/eslintrc": "^3.1.0",
|
||||
"@eslint/js": "^9.9.1",
|
||||
"@types/react": "^18.3.5",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.21.0",
|
||||
"@typescript-eslint/parser": "^6.21.0",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"eslint": "^9.9.1",
|
||||
"eslint-plugin-react-hooks": "^5.1.0-rc-e56f4ae3-20240830",
|
||||
"eslint-plugin-react-refresh": "^0.4.11",
|
||||
"globals": "^15.9.0",
|
||||
"jsdom": "^25.0.0",
|
||||
"postcss": "^8.4.35",
|
||||
"postcss-preset-mantine": "^1.17.0",
|
||||
"postcss-simple-vars": "^7.0.1",
|
||||
"vite": "^5.1.0",
|
||||
"vitest": "^2.0.5"
|
||||
}
|
||||
}
|
14
web/ui/mantine-ui/postcss.config.cjs
Normal file
14
web/ui/mantine-ui/postcss.config.cjs
Normal file
|
@ -0,0 +1,14 @@
|
|||
module.exports = {
|
||||
plugins: {
|
||||
'postcss-preset-mantine': {},
|
||||
'postcss-simple-vars': {
|
||||
variables: {
|
||||
'mantine-breakpoint-xs': '36em',
|
||||
'mantine-breakpoint-sm': '48em',
|
||||
'mantine-breakpoint-md': '62em',
|
||||
'mantine-breakpoint-lg': '75em',
|
||||
'mantine-breakpoint-xl': '88em',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
50
web/ui/mantine-ui/public/favicon.svg
Normal file
50
web/ui/mantine-ui/public/favicon.svg
Normal file
|
@ -0,0 +1,50 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
version="1.1"
|
||||
id="Layer_1"
|
||||
x="0px"
|
||||
y="0px"
|
||||
width="115.333px"
|
||||
height="114px"
|
||||
viewBox="0 0 115.333 114"
|
||||
enable-background="new 0 0 115.333 114"
|
||||
xml:space="preserve"
|
||||
sodipodi:docname="prometheus_logo_orange.svg"
|
||||
inkscape:version="0.92.1 r15371"><metadata
|
||||
id="metadata4495"><rdf:RDF><cc:Work
|
||||
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title></dc:title></cc:Work></rdf:RDF></metadata><defs
|
||||
id="defs4493" /><sodipodi:namedview
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1"
|
||||
objecttolerance="10"
|
||||
gridtolerance="10"
|
||||
guidetolerance="10"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:window-width="1484"
|
||||
inkscape:window-height="886"
|
||||
id="namedview4491"
|
||||
showgrid="false"
|
||||
inkscape:zoom="5.2784901"
|
||||
inkscape:cx="60.603667"
|
||||
inkscape:cy="60.329656"
|
||||
inkscape:window-x="54"
|
||||
inkscape:window-y="7"
|
||||
inkscape:window-maximized="0"
|
||||
inkscape:current-layer="Layer_1" /><g
|
||||
id="Layer_2" /><path
|
||||
style="fill:#e6522c;fill-opacity:1"
|
||||
inkscape:connector-curvature="0"
|
||||
id="path4486"
|
||||
d="M 56.667,0.667 C 25.372,0.667 0,26.036 0,57.332 c 0,31.295 25.372,56.666 56.667,56.666 31.295,0 56.666,-25.371 56.666,-56.666 0,-31.296 -25.372,-56.665 -56.666,-56.665 z m 0,106.055 c -8.904,0 -16.123,-5.948 -16.123,-13.283 H 72.79 c 0,7.334 -7.219,13.283 -16.123,13.283 z M 83.297,89.04 H 30.034 V 79.382 H 83.298 V 89.04 Z M 83.106,74.411 H 30.186 C 30.01,74.208 29.83,74.008 29.66,73.802 24.208,67.182 22.924,63.726 21.677,60.204 c -0.021,-0.116 6.611,1.355 11.314,2.413 0,0 2.42,0.56 5.958,1.205 -3.397,-3.982 -5.414,-9.044 -5.414,-14.218 0,-11.359 8.712,-21.285 5.569,-29.308 3.059,0.249 6.331,6.456 6.552,16.161 3.252,-4.494 4.613,-12.701 4.613,-17.733 0,-5.21 3.433,-11.262 6.867,-11.469 -3.061,5.045 0.793,9.37 4.219,20.099 1.285,4.03 1.121,10.812 2.113,15.113 C 63.797,33.534 65.333,20.5 71,16 c -2.5,5.667 0.37,12.758 2.333,16.167 3.167,5.5 5.087,9.667 5.087,17.548 0,5.284 -1.951,10.259 -5.242,14.148 3.742,-0.702 6.326,-1.335 6.326,-1.335 l 12.152,-2.371 c 10e-4,-10e-4 -1.765,7.261 -8.55,14.254 z" /></svg>
|
After Width: | Height: | Size: 2.7 KiB |
40
web/ui/mantine-ui/src/App.module.css
Normal file
40
web/ui/mantine-ui/src/App.module.css
Normal file
|
@ -0,0 +1,40 @@
|
|||
.control {
|
||||
display: block;
|
||||
padding: var(--mantine-spacing-xs) var(--mantine-spacing-md);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
font-weight: 500;
|
||||
|
||||
@mixin hover {
|
||||
background-color: var(--mantine-color-gray-8);
|
||||
}
|
||||
}
|
||||
|
||||
.link {
|
||||
display: block;
|
||||
line-height: 1;
|
||||
padding: rem(8px) rem(12px);
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
text-decoration: none;
|
||||
color: var(--mantine-color-gray-0);
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
font-weight: 500;
|
||||
background-color: transparent;
|
||||
|
||||
@mixin hover {
|
||||
background-color: var(--mantine-color-gray-6);
|
||||
color: var(--mantine-color-gray-0);
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme] &[aria-current="page"] {
|
||||
background-color: var(--mantine-color-blue-filled);
|
||||
color: var(--mantine-color-white);
|
||||
}
|
||||
}
|
||||
|
||||
/* Font used for autocompletion item icons. */
|
||||
@font-face {
|
||||
font-family: "codicon";
|
||||
src:
|
||||
local("codicon"),
|
||||
url(./fonts/codicon.ttf) format("truetype");
|
||||
}
|
463
web/ui/mantine-ui/src/App.tsx
Normal file
463
web/ui/mantine-ui/src/App.tsx
Normal file
|
@ -0,0 +1,463 @@
|
|||
import "@mantine/core/styles.css";
|
||||
import "@mantine/code-highlight/styles.css";
|
||||
import "@mantine/notifications/styles.css";
|
||||
import "@mantine/dates/styles.css";
|
||||
import classes from "./App.module.css";
|
||||
import PrometheusLogo from "./images/prometheus-logo.svg";
|
||||
|
||||
import {
|
||||
AppShell,
|
||||
Box,
|
||||
Burger,
|
||||
Button,
|
||||
Group,
|
||||
MantineProvider,
|
||||
Menu,
|
||||
Skeleton,
|
||||
Text,
|
||||
createTheme,
|
||||
rem,
|
||||
} from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import {
|
||||
IconBell,
|
||||
IconBellFilled,
|
||||
IconChevronDown,
|
||||
IconChevronRight,
|
||||
IconCloudDataConnection,
|
||||
IconDatabase,
|
||||
IconDeviceDesktopAnalytics,
|
||||
IconFlag,
|
||||
IconHeartRateMonitor,
|
||||
IconInfoCircle,
|
||||
IconSearch,
|
||||
IconServer,
|
||||
IconServerCog,
|
||||
} from "@tabler/icons-react";
|
||||
import {
|
||||
BrowserRouter,
|
||||
Link,
|
||||
NavLink,
|
||||
Navigate,
|
||||
Route,
|
||||
Routes,
|
||||
} from "react-router-dom";
|
||||
import { IconTable } from "@tabler/icons-react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import QueryPage from "./pages/query/QueryPage";
|
||||
import AlertsPage from "./pages/AlertsPage";
|
||||
import RulesPage from "./pages/RulesPage";
|
||||
import TargetsPage from "./pages/targets/TargetsPage";
|
||||
import StatusPage from "./pages/StatusPage";
|
||||
import TSDBStatusPage from "./pages/TSDBStatusPage";
|
||||
import FlagsPage from "./pages/FlagsPage";
|
||||
import ConfigPage from "./pages/ConfigPage";
|
||||
import AgentPage from "./pages/AgentPage";
|
||||
import { Suspense, useEffect } from "react";
|
||||
import ErrorBoundary from "./components/ErrorBoundary";
|
||||
import { ThemeSelector } from "./components/ThemeSelector";
|
||||
import { Notifications } from "@mantine/notifications";
|
||||
import { useAppDispatch } from "./state/hooks";
|
||||
import { updateSettings, useSettings } from "./state/settingsSlice";
|
||||
import SettingsMenu from "./components/SettingsMenu";
|
||||
import ReadinessWrapper from "./components/ReadinessWrapper";
|
||||
import { QueryParamProvider } from "use-query-params";
|
||||
import { ReactRouter6Adapter } from "use-query-params/adapters/react-router-6";
|
||||
import ServiceDiscoveryPage from "./pages/service-discovery/ServiceDiscoveryPage";
|
||||
import AlertmanagerDiscoveryPage from "./pages/AlertmanagerDiscoveryPage";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
const navIconStyle = { width: rem(16), height: rem(16) };
|
||||
|
||||
const mainNavPages = [
|
||||
{
|
||||
title: "Query",
|
||||
path: "/query",
|
||||
icon: <IconSearch style={navIconStyle} />,
|
||||
element: <QueryPage />,
|
||||
inAgentMode: false,
|
||||
},
|
||||
{
|
||||
title: "Alerts",
|
||||
path: "/alerts",
|
||||
icon: <IconBellFilled style={navIconStyle} />,
|
||||
element: <AlertsPage />,
|
||||
inAgentMode: false,
|
||||
},
|
||||
];
|
||||
|
||||
const monitoringStatusPages = [
|
||||
{
|
||||
title: "Target health",
|
||||
path: "/targets",
|
||||
icon: <IconHeartRateMonitor style={navIconStyle} />,
|
||||
element: <TargetsPage />,
|
||||
inAgentMode: true,
|
||||
},
|
||||
{
|
||||
title: "Rule health",
|
||||
path: "/rules",
|
||||
icon: <IconTable style={navIconStyle} />,
|
||||
element: <RulesPage />,
|
||||
inAgentMode: false,
|
||||
},
|
||||
{
|
||||
title: "Service discovery",
|
||||
path: "/service-discovery",
|
||||
icon: <IconCloudDataConnection style={navIconStyle} />,
|
||||
element: <ServiceDiscoveryPage />,
|
||||
inAgentMode: true,
|
||||
},
|
||||
{
|
||||
title: "Alertmanager discovery",
|
||||
path: "/discovered-alertmanagers",
|
||||
icon: <IconBell style={navIconStyle} />,
|
||||
element: <AlertmanagerDiscoveryPage />,
|
||||
inAgentMode: false,
|
||||
},
|
||||
];
|
||||
|
||||
const serverStatusPages = [
|
||||
{
|
||||
title: "Runtime & build information",
|
||||
path: "/status",
|
||||
icon: <IconInfoCircle style={navIconStyle} />,
|
||||
element: <StatusPage />,
|
||||
inAgentMode: true,
|
||||
},
|
||||
{
|
||||
title: "TSDB status",
|
||||
path: "/tsdb-status",
|
||||
icon: <IconDatabase style={navIconStyle} />,
|
||||
element: <TSDBStatusPage />,
|
||||
inAgentMode: false,
|
||||
},
|
||||
{
|
||||
title: "Command-line flags",
|
||||
path: "/flags",
|
||||
icon: <IconFlag style={navIconStyle} />,
|
||||
element: <FlagsPage />,
|
||||
inAgentMode: true,
|
||||
},
|
||||
{
|
||||
title: "Configuration",
|
||||
path: "/config",
|
||||
icon: <IconServerCog style={navIconStyle} />,
|
||||
element: <ConfigPage />,
|
||||
inAgentMode: true,
|
||||
},
|
||||
];
|
||||
|
||||
const allStatusPages = [...monitoringStatusPages, ...serverStatusPages];
|
||||
|
||||
const theme = createTheme({
|
||||
colors: {
|
||||
"codebox-bg": [
|
||||
"#f5f5f5",
|
||||
"#e7e7e7",
|
||||
"#cdcdcd",
|
||||
"#b2b2b2",
|
||||
"#9a9a9a",
|
||||
"#8b8b8b",
|
||||
"#848484",
|
||||
"#717171",
|
||||
"#656565",
|
||||
"#575757",
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// This dynamically/generically determines the pathPrefix by stripping the first known
|
||||
// endpoint suffix from the window location path. It works out of the box for both direct
|
||||
// hosting and reverse proxy deployments with no additional configurations required.
|
||||
const getPathPrefix = (path: string) => {
|
||||
if (path.endsWith("/")) {
|
||||
path = path.slice(0, -1);
|
||||
}
|
||||
|
||||
const pagePaths = [
|
||||
...mainNavPages,
|
||||
...allStatusPages,
|
||||
{ path: "/agent" },
|
||||
].map((p) => p.path);
|
||||
|
||||
const pagePath = pagePaths.find((p) => path.endsWith(p));
|
||||
return path.slice(0, path.length - (pagePath || "").length);
|
||||
};
|
||||
|
||||
const navLinkXPadding = "md";
|
||||
|
||||
function App() {
|
||||
const [opened, { toggle }] = useDisclosure();
|
||||
|
||||
const pathPrefix = getPathPrefix(window.location.pathname);
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(updateSettings({ pathPrefix }));
|
||||
}, [pathPrefix, dispatch]);
|
||||
|
||||
const { agentMode, consolesLink } = useSettings();
|
||||
|
||||
const navLinks = (
|
||||
<>
|
||||
{consolesLink && (
|
||||
<Button
|
||||
component="a"
|
||||
href={consolesLink}
|
||||
className={classes.link}
|
||||
leftSection={<IconDeviceDesktopAnalytics style={navIconStyle} />}
|
||||
px={navLinkXPadding}
|
||||
>
|
||||
Consoles
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{mainNavPages
|
||||
.filter((p) => !agentMode || p.inAgentMode)
|
||||
.map((p) => (
|
||||
<Button
|
||||
key={p.path}
|
||||
component={NavLink}
|
||||
to={p.path}
|
||||
className={classes.link}
|
||||
leftSection={p.icon}
|
||||
px={navLinkXPadding}
|
||||
>
|
||||
{p.title}
|
||||
</Button>
|
||||
))}
|
||||
|
||||
<Menu shadow="md" width={240}>
|
||||
<Routes>
|
||||
{allStatusPages
|
||||
.filter((p) => !agentMode || p.inAgentMode)
|
||||
.map((p) => (
|
||||
<Route
|
||||
key={p.path}
|
||||
path={p.path}
|
||||
element={
|
||||
<Menu.Target>
|
||||
<Button
|
||||
component={NavLink}
|
||||
to={p.path}
|
||||
className={classes.link}
|
||||
leftSection={p.icon}
|
||||
rightSection={<IconChevronDown style={navIconStyle} />}
|
||||
px={navLinkXPadding}
|
||||
>
|
||||
Status <IconChevronRight style={navIconStyle} /> {p.title}
|
||||
</Button>
|
||||
</Menu.Target>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
<Route
|
||||
path="*"
|
||||
element={
|
||||
<Menu.Target>
|
||||
<Button
|
||||
component={NavLink}
|
||||
to="/"
|
||||
className={classes.link}
|
||||
leftSection={<IconServer style={navIconStyle} />}
|
||||
rightSection={<IconChevronDown style={navIconStyle} />}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
px={navLinkXPadding}
|
||||
>
|
||||
Status
|
||||
</Button>
|
||||
</Menu.Target>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
|
||||
<Menu.Dropdown>
|
||||
<Menu.Label>Monitoring status</Menu.Label>
|
||||
{monitoringStatusPages
|
||||
.filter((p) => !agentMode || p.inAgentMode)
|
||||
.map((p) => (
|
||||
<Menu.Item
|
||||
key={p.path}
|
||||
component={NavLink}
|
||||
to={p.path}
|
||||
leftSection={p.icon}
|
||||
>
|
||||
{p.title}
|
||||
</Menu.Item>
|
||||
))}
|
||||
|
||||
<Menu.Divider />
|
||||
<Menu.Label>Server status</Menu.Label>
|
||||
{serverStatusPages
|
||||
.filter((p) => !agentMode || p.inAgentMode)
|
||||
.map((p) => (
|
||||
<Menu.Item
|
||||
key={p.path}
|
||||
component={NavLink}
|
||||
to={p.path}
|
||||
leftSection={p.icon}
|
||||
>
|
||||
{p.title}
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
|
||||
{/* <Button
|
||||
component="a"
|
||||
href="https://prometheus.io/docs/prometheus/latest/getting_started/"
|
||||
className={classes.link}
|
||||
leftSection={<IconHelp style={navIconStyle} />}
|
||||
target="_blank"
|
||||
px={navLinkXPadding}
|
||||
>
|
||||
Help
|
||||
</Button> */}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<BrowserRouter basename={pathPrefix}>
|
||||
<QueryParamProvider adapter={ReactRouter6Adapter}>
|
||||
<MantineProvider defaultColorScheme="auto" theme={theme}>
|
||||
<Notifications position="top-right" />
|
||||
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AppShell
|
||||
header={{ height: 56 }}
|
||||
navbar={{
|
||||
width: 300,
|
||||
// TODO: On pages with a long title like "/status", the navbar
|
||||
// breaks in an ugly way for narrow windows. Fix this.
|
||||
breakpoint: "sm",
|
||||
collapsed: { desktop: true, mobile: !opened },
|
||||
}}
|
||||
padding="md"
|
||||
>
|
||||
<AppShell.Header bg="rgb(65, 73, 81)" c="#fff">
|
||||
<Group h="100%" px="md" wrap="nowrap">
|
||||
<Group
|
||||
style={{ flex: 1 }}
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
>
|
||||
<Group gap={65} wrap="nowrap">
|
||||
<Link
|
||||
to="/"
|
||||
style={{ textDecoration: "none", color: "white" }}
|
||||
>
|
||||
<Group gap={10} wrap="nowrap">
|
||||
<img src={PrometheusLogo} height={30} />
|
||||
<Text fz={20}>Prometheus{agentMode && " Agent"}</Text>
|
||||
</Group>
|
||||
</Link>
|
||||
<Group gap={12} visibleFrom="sm" wrap="nowrap">
|
||||
{navLinks}
|
||||
</Group>
|
||||
</Group>
|
||||
<Group visibleFrom="xs" wrap="nowrap">
|
||||
<ThemeSelector />
|
||||
<SettingsMenu />
|
||||
</Group>
|
||||
</Group>
|
||||
<Burger
|
||||
opened={opened}
|
||||
onClick={toggle}
|
||||
hiddenFrom="sm"
|
||||
size="sm"
|
||||
color="gray.2"
|
||||
/>
|
||||
</Group>
|
||||
</AppShell.Header>
|
||||
|
||||
<AppShell.Navbar py="md" px={4} bg="rgb(65, 73, 81)" c="#fff">
|
||||
{navLinks}
|
||||
<Group mt="md" hiddenFrom="xs" justify="center">
|
||||
<ThemeSelector />
|
||||
<SettingsMenu />
|
||||
</Group>
|
||||
</AppShell.Navbar>
|
||||
|
||||
<AppShell.Main>
|
||||
<ErrorBoundary key={location.pathname}>
|
||||
<Suspense
|
||||
fallback={
|
||||
<Box mt="lg">
|
||||
{Array.from(Array(10), (_, i) => (
|
||||
<Skeleton
|
||||
key={i}
|
||||
height={40}
|
||||
mb={15}
|
||||
width={1000}
|
||||
mx="auto"
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<Navigate
|
||||
to={agentMode ? "/agent" : "/query"}
|
||||
replace
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{agentMode ? (
|
||||
<Route
|
||||
path="/agent"
|
||||
element={
|
||||
<ReadinessWrapper>
|
||||
<AgentPage />
|
||||
</ReadinessWrapper>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Route
|
||||
path="/query"
|
||||
element={
|
||||
<ReadinessWrapper>
|
||||
<QueryPage />
|
||||
</ReadinessWrapper>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/alerts"
|
||||
element={
|
||||
<ReadinessWrapper>
|
||||
<AlertsPage />
|
||||
</ReadinessWrapper>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{allStatusPages.map((p) => (
|
||||
<Route
|
||||
key={p.path}
|
||||
path={p.path}
|
||||
element={
|
||||
<ReadinessWrapper>{p.element}</ReadinessWrapper>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</AppShell.Main>
|
||||
</AppShell>
|
||||
{/* <ReactQueryDevtools initialIsOpen={false} /> */}
|
||||
</QueryClientProvider>
|
||||
</MantineProvider>
|
||||
</QueryParamProvider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
58
web/ui/mantine-ui/src/Badge.module.css
Normal file
58
web/ui/mantine-ui/src/Badge.module.css
Normal file
|
@ -0,0 +1,58 @@
|
|||
.statsBadge {
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-gray-1),
|
||||
var(--mantine-color-gray-8)
|
||||
);
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-5));
|
||||
}
|
||||
|
||||
.labelBadge {
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-gray-1),
|
||||
var(--mantine-color-gray-8)
|
||||
);
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-5));
|
||||
}
|
||||
|
||||
.healthOk {
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-green-1),
|
||||
var(--mantine-color-green-9)
|
||||
);
|
||||
color: light-dark(var(--mantine-color-green-9), var(--mantine-color-green-1));
|
||||
}
|
||||
|
||||
.healthErr {
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-red-1),
|
||||
darken(var(--mantine-color-red-9), 0.25)
|
||||
);
|
||||
color: light-dark(var(--mantine-color-red-9), var(--mantine-color-red-1));
|
||||
}
|
||||
|
||||
.healthWarn {
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-yellow-1),
|
||||
var(--mantine-color-yellow-9)
|
||||
);
|
||||
color: light-dark(
|
||||
var(--mantine-color-yellow-9),
|
||||
var(--mantine-color-yellow-1)
|
||||
);
|
||||
}
|
||||
|
||||
.healthInfo {
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-blue-1),
|
||||
var(--mantine-color-blue-9)
|
||||
);
|
||||
color: light-dark(var(--mantine-color-blue-9), var(--mantine-color-blue-1));
|
||||
}
|
||||
|
||||
.healthUnknown {
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-gray-2),
|
||||
var(--mantine-color-gray-7)
|
||||
);
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-4));
|
||||
}
|
19
web/ui/mantine-ui/src/Panel.module.css
Normal file
19
web/ui/mantine-ui/src/Panel.module.css
Normal file
|
@ -0,0 +1,19 @@
|
|||
.panelHealthOk {
|
||||
border-left: 5px solid
|
||||
light-dark(var(--mantine-color-green-3), var(--mantine-color-green-8)) !important;
|
||||
}
|
||||
|
||||
.panelHealthErr {
|
||||
border-left: 5px solid
|
||||
light-dark(var(--mantine-color-red-3), var(--mantine-color-red-9)) !important;
|
||||
}
|
||||
|
||||
.panelHealthWarn {
|
||||
border-left: 5px solid
|
||||
light-dark(var(--mantine-color-orange-3), var(--mantine-color-yellow-9)) !important;
|
||||
}
|
||||
|
||||
.panelHealthUnknown {
|
||||
border-left: 5px solid
|
||||
light-dark(var(--mantine-color-gray-3), var(--mantine-color-gray-6)) !important;
|
||||
}
|
128
web/ui/mantine-ui/src/api/api.ts
Normal file
128
web/ui/mantine-ui/src/api/api.ts
Normal file
|
@ -0,0 +1,128 @@
|
|||
import { QueryKey, useQuery, useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { useSettings } from "../state/settingsSlice";
|
||||
|
||||
export const API_PATH = "api/v1";
|
||||
|
||||
export type SuccessAPIResponse<T> = {
|
||||
status: "success";
|
||||
data: T;
|
||||
warnings?: string[];
|
||||
infos?: string[];
|
||||
};
|
||||
|
||||
export type ErrorAPIResponse = {
|
||||
status: "error";
|
||||
errorType: string;
|
||||
error: string;
|
||||
};
|
||||
|
||||
export type APIResponse<T> = SuccessAPIResponse<T> | ErrorAPIResponse;
|
||||
|
||||
const createQueryFn =
|
||||
<T>({
|
||||
pathPrefix,
|
||||
path,
|
||||
params,
|
||||
recordResponseTime,
|
||||
}: {
|
||||
pathPrefix: string;
|
||||
path: string;
|
||||
params?: Record<string, string>;
|
||||
recordResponseTime?: (time: number) => void;
|
||||
}) =>
|
||||
async ({ signal }: { signal: AbortSignal }) => {
|
||||
const queryString = params
|
||||
? `?${new URLSearchParams(params).toString()}`
|
||||
: "";
|
||||
|
||||
try {
|
||||
const startTime = Date.now();
|
||||
|
||||
const res = await fetch(
|
||||
`${pathPrefix}/${API_PATH}${path}${queryString}`,
|
||||
{
|
||||
cache: "no-store",
|
||||
credentials: "same-origin",
|
||||
signal,
|
||||
}
|
||||
);
|
||||
|
||||
if (
|
||||
!res.ok &&
|
||||
!res.headers.get("content-type")?.startsWith("application/json")
|
||||
) {
|
||||
// For example, Prometheus may send a 503 Service Unavailable response
|
||||
// with a "text/plain" content type when it's starting up. But the API
|
||||
// may also respond with a JSON error message and the same error code.
|
||||
throw new Error(res.statusText);
|
||||
}
|
||||
|
||||
const apiRes = (await res.json()) as APIResponse<T>;
|
||||
|
||||
if (recordResponseTime) {
|
||||
recordResponseTime(Date.now() - startTime);
|
||||
}
|
||||
|
||||
if (apiRes.status === "error") {
|
||||
throw new Error(
|
||||
apiRes.error !== undefined
|
||||
? apiRes.error
|
||||
: 'missing "error" field in response JSON'
|
||||
);
|
||||
}
|
||||
|
||||
return apiRes as SuccessAPIResponse<T>;
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error)) {
|
||||
throw new Error("Unknown error");
|
||||
}
|
||||
|
||||
switch (error.name) {
|
||||
case "TypeError":
|
||||
throw new Error("Network error or unable to reach the server");
|
||||
case "SyntaxError":
|
||||
throw new Error("Invalid JSON response");
|
||||
default:
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
type QueryOptions = {
|
||||
key?: QueryKey;
|
||||
path: string;
|
||||
params?: Record<string, string>;
|
||||
enabled?: boolean;
|
||||
recordResponseTime?: (time: number) => void;
|
||||
};
|
||||
|
||||
export const useAPIQuery = <T>({
|
||||
key,
|
||||
path,
|
||||
params,
|
||||
enabled,
|
||||
recordResponseTime,
|
||||
}: QueryOptions) => {
|
||||
const { pathPrefix } = useSettings();
|
||||
|
||||
return useQuery<SuccessAPIResponse<T>>({
|
||||
queryKey: key !== undefined ? key : [path, params],
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false,
|
||||
gcTime: 0,
|
||||
enabled,
|
||||
queryFn: createQueryFn({ pathPrefix, path, params, recordResponseTime }),
|
||||
});
|
||||
};
|
||||
|
||||
export const useSuspenseAPIQuery = <T>({ key, path, params }: QueryOptions) => {
|
||||
const { pathPrefix } = useSettings();
|
||||
|
||||
return useSuspenseQuery<SuccessAPIResponse<T>>({
|
||||
queryKey: key !== undefined ? key : [path, params],
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false,
|
||||
gcTime: 0,
|
||||
queryFn: createQueryFn({ pathPrefix, path, params }),
|
||||
});
|
||||
};
|
10
web/ui/mantine-ui/src/api/responseTypes/alertmanagers.ts
Normal file
10
web/ui/mantine-ui/src/api/responseTypes/alertmanagers.ts
Normal file
|
@ -0,0 +1,10 @@
|
|||
export type AlertmanagerTarget = {
|
||||
url: string;
|
||||
};
|
||||
|
||||
// Result type for /api/v1/alertmanagers endpoint.
|
||||
// See: https://prometheus.io/docs/prometheus/latest/querying/api/#alertmanagers
|
||||
export type AlertmanagersResult = {
|
||||
activeAlertmanagers: AlertmanagerTarget[];
|
||||
droppedAlertmanagers: AlertmanagerTarget[];
|
||||
};
|
5
web/ui/mantine-ui/src/api/responseTypes/config.ts
Normal file
5
web/ui/mantine-ui/src/api/responseTypes/config.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
// Result type for /api/v1/status/config endpoint.
|
||||
// See: https://prometheus.io/docs/prometheus/latest/querying/api/#config
|
||||
export default interface ConfigResult {
|
||||
yaml: string;
|
||||
}
|
3
web/ui/mantine-ui/src/api/responseTypes/labelValues.ts
Normal file
3
web/ui/mantine-ui/src/api/responseTypes/labelValues.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
// Result type for /api/v1/label/<label_name>/values endpoint.
|
||||
// See: https://prometheus.io/docs/prometheus/latest/querying/api/#querying-label-values
|
||||
export type LabelValuesResult = string[];
|
6
web/ui/mantine-ui/src/api/responseTypes/metadata.ts
Normal file
6
web/ui/mantine-ui/src/api/responseTypes/metadata.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
// Result type for /api/v1/alerts endpoint.
|
||||
// See: https://prometheus.io/docs/prometheus/latest/querying/api/#querying-target-metadata
|
||||
export type MetadataResult = Record<
|
||||
string,
|
||||
{ type: string; help: string; unit: string }[]
|
||||
>;
|
51
web/ui/mantine-ui/src/api/responseTypes/query.ts
Normal file
51
web/ui/mantine-ui/src/api/responseTypes/query.ts
Normal file
|
@ -0,0 +1,51 @@
|
|||
export interface Metric {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
export interface Histogram {
|
||||
count: string;
|
||||
sum: string;
|
||||
buckets?: [number, string, string, string][];
|
||||
}
|
||||
|
||||
export interface InstantSample {
|
||||
metric: Metric;
|
||||
value?: SampleValue;
|
||||
histogram?: SampleHistogram;
|
||||
}
|
||||
|
||||
export interface RangeSamples {
|
||||
metric: Metric;
|
||||
values?: SampleValue[];
|
||||
histograms?: SampleHistogram[];
|
||||
}
|
||||
|
||||
export type SampleValue = [number, string];
|
||||
export type SampleHistogram = [number, Histogram];
|
||||
|
||||
// Result type for /api/v1/query endpoint.
|
||||
// See: https://prometheus.io/docs/prometheus/latest/querying/api/#instant-queries
|
||||
export type InstantQueryResult =
|
||||
| {
|
||||
resultType: "vector";
|
||||
result: InstantSample[];
|
||||
}
|
||||
| {
|
||||
resultType: "matrix";
|
||||
result: RangeSamples[];
|
||||
}
|
||||
| {
|
||||
resultType: "scalar";
|
||||
result: SampleValue;
|
||||
}
|
||||
| {
|
||||
resultType: "string";
|
||||
result: SampleValue;
|
||||
};
|
||||
|
||||
// Result type for /api/v1/query_range endpoint.
|
||||
// See: https://prometheus.io/docs/prometheus/latest/querying/api/#range-queries
|
||||
export type RangeQueryResult = {
|
||||
resultType: "matrix";
|
||||
result: RangeSamples[];
|
||||
};
|
63
web/ui/mantine-ui/src/api/responseTypes/rules.ts
Normal file
63
web/ui/mantine-ui/src/api/responseTypes/rules.ts
Normal file
|
@ -0,0 +1,63 @@
|
|||
type RuleState = "pending" | "firing" | "inactive";
|
||||
|
||||
export interface Alert {
|
||||
labels: Record<string, string>;
|
||||
state: RuleState;
|
||||
value: string;
|
||||
annotations: Record<string, string>;
|
||||
activeAt: string;
|
||||
keepFiringSince: string;
|
||||
}
|
||||
|
||||
type CommonRuleFields = {
|
||||
name: string;
|
||||
query: string;
|
||||
evaluationTime: string;
|
||||
health: "ok" | "unknown" | "err";
|
||||
lastError?: string;
|
||||
lastEvaluation: string;
|
||||
};
|
||||
|
||||
export type AlertingRule = {
|
||||
type: "alerting";
|
||||
// For alerting rules, the 'labels' field is always present, even when there are no labels.
|
||||
labels: Record<string, string>;
|
||||
annotations: Record<string, string>;
|
||||
duration: number;
|
||||
keepFiringFor: number;
|
||||
state: RuleState;
|
||||
alerts: Alert[];
|
||||
} & CommonRuleFields;
|
||||
|
||||
type RecordingRule = {
|
||||
type: "recording";
|
||||
// For recording rules, the 'labels' field is only present when there are labels.
|
||||
labels?: Record<string, string>;
|
||||
} & CommonRuleFields;
|
||||
|
||||
export type Rule = AlertingRule | RecordingRule;
|
||||
|
||||
interface RuleGroup {
|
||||
name: string;
|
||||
file: string;
|
||||
interval: string;
|
||||
rules: Rule[];
|
||||
evaluationTime: string;
|
||||
lastEvaluation: string;
|
||||
}
|
||||
|
||||
export type AlertingRuleGroup = Omit<RuleGroup, "rules"> & {
|
||||
rules: AlertingRule[];
|
||||
};
|
||||
|
||||
// Result type for /api/v1/alerts endpoint.
|
||||
// See: https://prometheus.io/docs/prometheus/latest/querying/api/#alerts
|
||||
export interface RulesResult {
|
||||
groups: RuleGroup[];
|
||||
}
|
||||
|
||||
// Same as RulesResult above, but can be used when the caller ensures via a
|
||||
// "type=alert" query parameter that all rules are alerting rules.
|
||||
export interface AlertingRulesResult {
|
||||
groups: AlertingRuleGroup[];
|
||||
}
|
2
web/ui/mantine-ui/src/api/responseTypes/scrapePools.ts
Normal file
2
web/ui/mantine-ui/src/api/responseTypes/scrapePools.ts
Normal file
|
@ -0,0 +1,2 @@
|
|||
// Result type for /api/v1/scrape_pools endpoint.
|
||||
export type ScrapePoolsResult = { scrapePools: string[] };
|
6
web/ui/mantine-ui/src/api/responseTypes/series.ts
Normal file
6
web/ui/mantine-ui/src/api/responseTypes/series.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
// Result type for /api/v1/series endpoint.
|
||||
|
||||
import { Metric } from "./query";
|
||||
|
||||
// See: https://prometheus.io/docs/prometheus/latest/querying/api/#finding-series-by-label-matchers
|
||||
export type SeriesResult = Metric[];
|
29
web/ui/mantine-ui/src/api/responseTypes/targets.ts
Normal file
29
web/ui/mantine-ui/src/api/responseTypes/targets.ts
Normal file
|
@ -0,0 +1,29 @@
|
|||
export interface Labels {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
export type Target = {
|
||||
discoveredLabels: Labels;
|
||||
labels: Labels;
|
||||
scrapePool: string;
|
||||
scrapeUrl: string;
|
||||
globalUrl: string;
|
||||
lastError: string;
|
||||
lastScrape: string;
|
||||
lastScrapeDuration: number;
|
||||
health: string;
|
||||
scrapeInterval: string;
|
||||
scrapeTimeout: string;
|
||||
};
|
||||
|
||||
export interface DroppedTarget {
|
||||
discoveredLabels: Labels;
|
||||
}
|
||||
|
||||
// Result type for /api/v1/targets endpoint.
|
||||
// See: https://prometheus.io/docs/prometheus/latest/querying/api/#targets
|
||||
export type TargetsResult = {
|
||||
activeTargets: Target[];
|
||||
droppedTargets: DroppedTarget[];
|
||||
droppedTargetCounts: Record<string, number>;
|
||||
};
|
22
web/ui/mantine-ui/src/api/responseTypes/tsdbStatus.ts
Normal file
22
web/ui/mantine-ui/src/api/responseTypes/tsdbStatus.ts
Normal file
|
@ -0,0 +1,22 @@
|
|||
interface Stats {
|
||||
name: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
interface HeadStats {
|
||||
numSeries: number;
|
||||
numLabelPairs: number;
|
||||
chunkCount: number;
|
||||
minTime: number;
|
||||
maxTime: number;
|
||||
}
|
||||
|
||||
// Result type for /api/v1/status/tsdb endpoint.
|
||||
// See: https://prometheus.io/docs/prometheus/latest/querying/api/#tsdb-stats
|
||||
export interface TSDBStatusResult {
|
||||
headStats: HeadStats;
|
||||
seriesCountByMetricName: Stats[];
|
||||
labelValueCountByLabelName: Stats[];
|
||||
memoryInBytesByLabelName: Stats[];
|
||||
seriesCountByLabelValuePair: Stats[];
|
||||
}
|
7
web/ui/mantine-ui/src/api/responseTypes/walreplay.ts
Normal file
7
web/ui/mantine-ui/src/api/responseTypes/walreplay.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
// Result type for /api/v1/status/walreplay endpoint.
|
||||
// See: https://prometheus.io/docs/prometheus/latest/querying/api/#wal-replay-stats
|
||||
export interface WALReplayStatus {
|
||||
min: number;
|
||||
max: number;
|
||||
current: number;
|
||||
}
|
323
web/ui/mantine-ui/src/codemirror/theme.ts
vendored
Normal file
323
web/ui/mantine-ui/src/codemirror/theme.ts
vendored
Normal file
|
@ -0,0 +1,323 @@
|
|||
import { HighlightStyle } from "@codemirror/language";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { tags } from "@lezer/highlight";
|
||||
|
||||
export const baseTheme = EditorView.theme({
|
||||
".cm-content": {
|
||||
paddingTop: "3px",
|
||||
paddingBottom: "0px",
|
||||
},
|
||||
"&.cm-editor": {
|
||||
"&.cm-focused": {
|
||||
outline: "none",
|
||||
outline_fallback: "none",
|
||||
},
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
".cm-scroller": {
|
||||
overflow: "hidden",
|
||||
fontFamily: '"DejaVu Sans Mono", monospace',
|
||||
},
|
||||
".cm-placeholder": {
|
||||
fontFamily:
|
||||
'-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans","Liberation Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji"',
|
||||
},
|
||||
|
||||
".cm-matchingBracket": {
|
||||
fontWeight: "bold",
|
||||
outline: "1px dashed transparent",
|
||||
},
|
||||
".cm-nonmatchingBracket": { borderColor: "red" },
|
||||
|
||||
".cm-tooltip.cm-tooltip-autocomplete": {
|
||||
"& > ul": {
|
||||
maxHeight: "350px",
|
||||
fontFamily: '"DejaVu Sans Mono", monospace',
|
||||
maxWidth: "unset",
|
||||
},
|
||||
"& > ul > li": {
|
||||
padding: "2px 1em 2px 3px",
|
||||
},
|
||||
minWidth: "30%",
|
||||
},
|
||||
|
||||
".cm-completionDetail": {
|
||||
float: "right",
|
||||
color: "#999",
|
||||
},
|
||||
|
||||
".cm-tooltip.cm-completionInfo": {
|
||||
padding: "10px",
|
||||
fontFamily:
|
||||
"'Open Sans', 'Lucida Sans Unicode', 'Lucida Grande', sans-serif;",
|
||||
border: "none",
|
||||
minWidth: "250px",
|
||||
maxWidth: "min-content",
|
||||
},
|
||||
|
||||
".cm-completionInfo.cm-completionInfo-right": {
|
||||
"&:before": {
|
||||
content: "' '",
|
||||
height: "0",
|
||||
position: "absolute",
|
||||
width: "0",
|
||||
left: "-20px",
|
||||
borderWidth: "10px",
|
||||
borderStyle: "solid",
|
||||
borderColor: "transparent",
|
||||
},
|
||||
marginTop: "-11px",
|
||||
marginLeft: "12px",
|
||||
},
|
||||
".cm-completionInfo.cm-completionInfo-left": {
|
||||
"&:before": {
|
||||
content: "' '",
|
||||
height: "0",
|
||||
position: "absolute",
|
||||
width: "0",
|
||||
right: "-20px",
|
||||
borderWidth: "10px",
|
||||
borderStyle: "solid",
|
||||
borderColor: "transparent",
|
||||
},
|
||||
marginTop: "-11px",
|
||||
marginRight: "12px",
|
||||
},
|
||||
".cm-completionInfo.cm-completionInfo-right-narrow": {
|
||||
"&:before": {
|
||||
content: "' '",
|
||||
height: "0",
|
||||
position: "absolute",
|
||||
width: "0",
|
||||
top: "-20px",
|
||||
borderWidth: "10px",
|
||||
borderStyle: "solid",
|
||||
borderColor: "transparent",
|
||||
},
|
||||
marginTop: "10px",
|
||||
marginLeft: "150px",
|
||||
},
|
||||
".cm-completionMatchedText": {
|
||||
textDecoration: "none",
|
||||
fontWeight: "bold",
|
||||
},
|
||||
|
||||
".cm-selectionMatch": {
|
||||
backgroundColor: "#e6f3ff",
|
||||
},
|
||||
|
||||
".cm-diagnostic": {
|
||||
"&.cm-diagnostic-error": {
|
||||
borderLeft: "3px solid #e65013",
|
||||
},
|
||||
},
|
||||
|
||||
".cm-completionIcon": {
|
||||
boxSizing: "content-box",
|
||||
fontSize: "16px",
|
||||
lineHeight: "1",
|
||||
marginRight: "10px",
|
||||
verticalAlign: "top",
|
||||
"&:after": { content: "'\\ea88'" },
|
||||
fontFamily: "codicon",
|
||||
paddingRight: "0",
|
||||
opacity: "1",
|
||||
},
|
||||
|
||||
".cm-completionIcon-function, .cm-completionIcon-method": {
|
||||
"&:after": { content: "'\\ea8c'" },
|
||||
},
|
||||
".cm-completionIcon-class": {
|
||||
"&:after": { content: "'○'" },
|
||||
},
|
||||
".cm-completionIcon-interface": {
|
||||
"&:after": { content: "'◌'" },
|
||||
},
|
||||
".cm-completionIcon-variable": {
|
||||
"&:after": { content: "'𝑥'" },
|
||||
},
|
||||
".cm-completionIcon-constant": {
|
||||
"&:after": { content: "'\\eb5f'" },
|
||||
},
|
||||
".cm-completionIcon-type": {
|
||||
"&:after": { content: "'𝑡'" },
|
||||
},
|
||||
".cm-completionIcon-enum": {
|
||||
"&:after": { content: "'∪'" },
|
||||
},
|
||||
".cm-completionIcon-property": {
|
||||
"&:after": { content: "'□'" },
|
||||
},
|
||||
".cm-completionIcon-keyword": {
|
||||
"&:after": { content: "'\\eb62'" },
|
||||
},
|
||||
".cm-completionIcon-namespace": {
|
||||
"&:after": { content: "'▢'" },
|
||||
},
|
||||
".cm-completionIcon-text": {
|
||||
"&:after": { content: "'\\ea95'" },
|
||||
color: "#ee9d28",
|
||||
},
|
||||
});
|
||||
|
||||
export const lightTheme = EditorView.theme(
|
||||
{
|
||||
".cm-tooltip": {
|
||||
backgroundColor: "#f8f8f8",
|
||||
borderColor: "rgba(52, 79, 113, 0.2)",
|
||||
},
|
||||
|
||||
".cm-tooltip.cm-tooltip-autocomplete": {
|
||||
"& li:hover": {
|
||||
backgroundColor: "#ddd",
|
||||
},
|
||||
"& > ul > li[aria-selected]": {
|
||||
backgroundColor: "#d6ebff",
|
||||
color: "unset",
|
||||
},
|
||||
},
|
||||
|
||||
".cm-tooltip.cm-completionInfo": {
|
||||
backgroundColor: "#d6ebff",
|
||||
},
|
||||
|
||||
".cm-tooltip > .cm-completionInfo.cm-completionInfo-right": {
|
||||
"&:before": {
|
||||
borderRightColor: "#d6ebff",
|
||||
},
|
||||
},
|
||||
".cm-tooltip > .cm-completionInfo.cm-completionInfo-right-narrow": {
|
||||
"&:before": {
|
||||
borderBottomColor: "#d6ebff",
|
||||
},
|
||||
},
|
||||
".cm-tooltip > .cm-completionInfo.cm-completionInfo-left": {
|
||||
"&:before": {
|
||||
borderLeftColor: "#d6ebff",
|
||||
},
|
||||
},
|
||||
|
||||
".cm-line": {
|
||||
"&::selection": {
|
||||
backgroundColor: "#add6ff",
|
||||
},
|
||||
"& > span::selection": {
|
||||
backgroundColor: "#add6ff",
|
||||
},
|
||||
},
|
||||
|
||||
".cm-matchingBracket": {
|
||||
color: "#000",
|
||||
backgroundColor: "#dedede",
|
||||
},
|
||||
|
||||
".cm-completionMatchedText": {
|
||||
color: "#0066bf",
|
||||
},
|
||||
|
||||
".cm-completionIcon": {
|
||||
color: "#007acc",
|
||||
},
|
||||
|
||||
".cm-completionIcon-constant": {
|
||||
color: "#007acc",
|
||||
},
|
||||
|
||||
".cm-completionIcon-function, .cm-completionIcon-method": {
|
||||
color: "#652d90",
|
||||
},
|
||||
|
||||
".cm-completionIcon-keyword": {
|
||||
color: "#616161",
|
||||
},
|
||||
},
|
||||
{ dark: false }
|
||||
);
|
||||
|
||||
export const darkTheme = EditorView.theme(
|
||||
{
|
||||
".cm-content": {
|
||||
caretColor: "#fff",
|
||||
},
|
||||
|
||||
".cm-tooltip.cm-completionInfo": {
|
||||
backgroundColor: "#333338",
|
||||
},
|
||||
|
||||
".cm-tooltip > .cm-completionInfo.cm-completionInfo-right": {
|
||||
"&:before": {
|
||||
borderRightColor: "#333338",
|
||||
},
|
||||
},
|
||||
".cm-tooltip > .cm-completionInfo.cm-completionInfo-right-narrow": {
|
||||
"&:before": {
|
||||
borderBottomColor: "#333338",
|
||||
},
|
||||
},
|
||||
".cm-tooltip > .cm-completionInfo.cm-completionInfo-left": {
|
||||
"&:before": {
|
||||
borderLeftColor: "#333338",
|
||||
},
|
||||
},
|
||||
|
||||
".cm-line": {
|
||||
"&::selection": {
|
||||
backgroundColor: "#767676",
|
||||
},
|
||||
"& > span::selection": {
|
||||
backgroundColor: "#767676",
|
||||
},
|
||||
},
|
||||
|
||||
".cm-matchingBracket, &.cm-focused .cm-matchingBracket": {
|
||||
backgroundColor: "#616161",
|
||||
},
|
||||
|
||||
".cm-completionMatchedText": {
|
||||
color: "#7dd3fc",
|
||||
},
|
||||
|
||||
".cm-completionIcon, .cm-completionIcon-constant": {
|
||||
color: "#7dd3fc",
|
||||
},
|
||||
|
||||
".cm-completionIcon-function, .cm-completionIcon-method": {
|
||||
color: "#d8b4fe",
|
||||
},
|
||||
|
||||
".cm-completionIcon-keyword": {
|
||||
color: "#cbd5e1 !important",
|
||||
},
|
||||
},
|
||||
{ dark: true }
|
||||
);
|
||||
|
||||
export const promqlHighlighter = HighlightStyle.define([
|
||||
{ tag: tags.number, color: "#09885a" },
|
||||
{ tag: tags.string, color: "#a31515" },
|
||||
{ tag: tags.keyword, color: "#008080" },
|
||||
{ tag: tags.function(tags.variableName), color: "#008080" },
|
||||
{ tag: tags.labelName, color: "#800000" },
|
||||
{ tag: tags.operator },
|
||||
{ tag: tags.modifier, color: "#008080" },
|
||||
{ tag: tags.paren },
|
||||
{ tag: tags.squareBracket },
|
||||
{ tag: tags.brace },
|
||||
{ tag: tags.invalid, color: "red" },
|
||||
{ tag: tags.comment, color: "#888", fontStyle: "italic" },
|
||||
]);
|
||||
|
||||
export const darkPromqlHighlighter = HighlightStyle.define([
|
||||
{ tag: tags.number, color: "#22c55e" },
|
||||
{ tag: tags.string, color: "#fca5a5" },
|
||||
{ tag: tags.keyword, color: "#14bfad" },
|
||||
{ tag: tags.function(tags.variableName), color: "#14bfad" },
|
||||
{ tag: tags.labelName, color: "#ff8585" },
|
||||
{ tag: tags.operator },
|
||||
{ tag: tags.modifier, color: "#14bfad" },
|
||||
{ tag: tags.paren },
|
||||
{ tag: tags.squareBracket },
|
||||
{ tag: tags.brace },
|
||||
{ tag: tags.invalid, color: "#ff3d3d" },
|
||||
{ tag: tags.comment, color: "#9ca3af", fontStyle: "italic" },
|
||||
]);
|
54
web/ui/mantine-ui/src/components/CustomInfiniteScroll.tsx
Normal file
54
web/ui/mantine-ui/src/components/CustomInfiniteScroll.tsx
Normal file
|
@ -0,0 +1,54 @@
|
|||
import { ComponentType, useEffect, useState } from "react";
|
||||
import InfiniteScroll from "react-infinite-scroll-component";
|
||||
|
||||
const initialNumberOfItemsDisplayed = 50;
|
||||
|
||||
export interface InfiniteScrollItemsProps<T> {
|
||||
items: T[];
|
||||
}
|
||||
|
||||
interface CustomInfiniteScrollProps<T> {
|
||||
allItems: T[];
|
||||
child: ComponentType<InfiniteScrollItemsProps<T>>;
|
||||
}
|
||||
|
||||
const CustomInfiniteScroll = <T,>({
|
||||
allItems,
|
||||
child,
|
||||
}: CustomInfiniteScrollProps<T>) => {
|
||||
const [items, setItems] = useState<T[]>(allItems.slice(0, 50));
|
||||
const [index, setIndex] = useState<number>(initialNumberOfItemsDisplayed);
|
||||
const [hasMore, setHasMore] = useState<boolean>(
|
||||
allItems.length > initialNumberOfItemsDisplayed
|
||||
);
|
||||
const Child = child;
|
||||
|
||||
useEffect(() => {
|
||||
setItems(allItems.slice(0, initialNumberOfItemsDisplayed));
|
||||
setHasMore(allItems.length > initialNumberOfItemsDisplayed);
|
||||
}, [allItems]);
|
||||
|
||||
const fetchMoreData = () => {
|
||||
if (items.length === allItems.length) {
|
||||
setHasMore(false);
|
||||
} else {
|
||||
const newIndex = index + initialNumberOfItemsDisplayed;
|
||||
setIndex(newIndex);
|
||||
setItems(allItems.slice(0, newIndex));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<InfiniteScroll
|
||||
next={fetchMoreData}
|
||||
hasMore={hasMore}
|
||||
loader={<h4>loading...</h4>}
|
||||
dataLength={items.length}
|
||||
height={items.length > 25 ? "75vh" : ""}
|
||||
>
|
||||
<Child items={items} />
|
||||
</InfiniteScroll>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomInfiniteScroll;
|
58
web/ui/mantine-ui/src/components/EndpointLink.tsx
Normal file
58
web/ui/mantine-ui/src/components/EndpointLink.tsx
Normal file
|
@ -0,0 +1,58 @@
|
|||
import { Anchor, Badge, Group, Stack } from "@mantine/core";
|
||||
import { FC } from "react";
|
||||
|
||||
export interface EndpointLinkProps {
|
||||
endpoint: string;
|
||||
globalUrl: string;
|
||||
}
|
||||
|
||||
const EndpointLink: FC<EndpointLinkProps> = ({ endpoint, globalUrl }) => {
|
||||
let url: URL;
|
||||
let search = "";
|
||||
let invalidURL = false;
|
||||
try {
|
||||
url = new URL(endpoint);
|
||||
} catch (err: unknown) {
|
||||
// In cases of IPv6 addresses with a Zone ID, URL may not be parseable.
|
||||
// See https://github.com/prometheus/prometheus/issues/9760
|
||||
// In this case, we attempt to prepare a synthetic URL with the
|
||||
// same query parameters, for rendering purposes.
|
||||
invalidURL = true;
|
||||
if (endpoint.indexOf("?") > -1) {
|
||||
search = endpoint.substring(endpoint.indexOf("?"));
|
||||
}
|
||||
url = new URL("http://0.0.0.0" + search);
|
||||
}
|
||||
|
||||
const { host, pathname, protocol, searchParams }: URL = url;
|
||||
const params = Array.from(searchParams.entries());
|
||||
const displayLink = invalidURL
|
||||
? endpoint.replace(search, "")
|
||||
: `${protocol}//${host}${pathname}`;
|
||||
return (
|
||||
<Stack gap={0}>
|
||||
<Anchor size="sm" href={globalUrl} target="_blank">
|
||||
{displayLink}
|
||||
</Anchor>
|
||||
{params.length > 0 && (
|
||||
<Group gap="xs" mt="md">
|
||||
{params.map(([labelName, labelValue]: [string, string]) => {
|
||||
return (
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color="gray"
|
||||
key={`${labelName}/${labelValue}`}
|
||||
style={{ textTransform: "none" }}
|
||||
>
|
||||
{`${labelName}="${labelValue}"`}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default EndpointLink;
|
58
web/ui/mantine-ui/src/components/ErrorBoundary.tsx
Normal file
58
web/ui/mantine-ui/src/components/ErrorBoundary.tsx
Normal file
|
@ -0,0 +1,58 @@
|
|||
import { Alert } from "@mantine/core";
|
||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
||||
import { Component, ErrorInfo, ReactNode } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
|
||||
interface Props {
|
||||
children?: ReactNode;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
interface State {
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
class ErrorBoundary extends Component<Props, State> {
|
||||
public state: State = {
|
||||
error: null,
|
||||
};
|
||||
|
||||
public static getDerivedStateFromError(error: Error): State {
|
||||
// Update state so the next render will show the fallback UI.
|
||||
return { error };
|
||||
}
|
||||
|
||||
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
console.error("Uncaught error:", error, errorInfo);
|
||||
}
|
||||
|
||||
public render() {
|
||||
if (this.state.error !== null) {
|
||||
return (
|
||||
<Alert
|
||||
color="red"
|
||||
title={this.props.title || "Error querying page data"}
|
||||
icon={<IconAlertTriangle size={14} />}
|
||||
maw={500}
|
||||
mx="auto"
|
||||
mt="lg"
|
||||
>
|
||||
<strong>Error:</strong> {this.state.error.message}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
const ResettingErrorBoundary = (props: Props) => {
|
||||
const location = useLocation();
|
||||
return (
|
||||
<ErrorBoundary key={location.pathname} title={props.title}>
|
||||
{props.children}
|
||||
</ErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResettingErrorBoundary;
|
38
web/ui/mantine-ui/src/components/LabelBadges.tsx
Normal file
38
web/ui/mantine-ui/src/components/LabelBadges.tsx
Normal file
|
@ -0,0 +1,38 @@
|
|||
import { Badge, BadgeVariant, Group, MantineColor, Stack } from "@mantine/core";
|
||||
import { FC } from "react";
|
||||
import { escapeString } from "../lib/escapeString";
|
||||
import badgeClasses from "../Badge.module.css";
|
||||
|
||||
export interface LabelBadgesProps {
|
||||
labels: Record<string, string>;
|
||||
variant?: BadgeVariant;
|
||||
color?: MantineColor;
|
||||
wrapper?: typeof Group | typeof Stack;
|
||||
}
|
||||
|
||||
export const LabelBadges: FC<LabelBadgesProps> = ({
|
||||
labels,
|
||||
variant,
|
||||
color,
|
||||
wrapper: Wrapper = Group,
|
||||
}) => (
|
||||
<Wrapper gap="xs">
|
||||
{Object.entries(labels).map(([k, v]) => {
|
||||
return (
|
||||
<Badge
|
||||
variant={variant ? variant : "light"}
|
||||
color={color ? color : undefined}
|
||||
className={color ? undefined : badgeClasses.labelBadge}
|
||||
styles={{
|
||||
label: {
|
||||
textTransform: "none",
|
||||
},
|
||||
}}
|
||||
key={k}
|
||||
>
|
||||
{k}="{escapeString(v)}"
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</Wrapper>
|
||||
);
|
93
web/ui/mantine-ui/src/components/ReadinessWrapper.tsx
Normal file
93
web/ui/mantine-ui/src/components/ReadinessWrapper.tsx
Normal file
|
@ -0,0 +1,93 @@
|
|||
import { FC, PropsWithChildren, useEffect, useState } from "react";
|
||||
import { useAppDispatch } from "../state/hooks";
|
||||
import { updateSettings, useSettings } from "../state/settingsSlice";
|
||||
import { useSuspenseAPIQuery } from "../api/api";
|
||||
import { WALReplayStatus } from "../api/responseTypes/walreplay";
|
||||
import { Progress, Stack, Title } from "@mantine/core";
|
||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||
|
||||
const ReadinessLoader: FC = () => {
|
||||
const { pathPrefix } = useSettings();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
// Query key is incremented every second to retrigger the status fetching.
|
||||
const [queryKey, setQueryKey] = useState(0);
|
||||
|
||||
// Query readiness status.
|
||||
const { data: ready } = useSuspenseQuery<boolean>({
|
||||
queryKey: ["ready", queryKey],
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false,
|
||||
gcTime: 0,
|
||||
queryFn: async ({ signal }: { signal: AbortSignal }) => {
|
||||
try {
|
||||
const res = await fetch(`${pathPrefix}/-/ready`, {
|
||||
cache: "no-store",
|
||||
credentials: "same-origin",
|
||||
signal,
|
||||
});
|
||||
switch (res.status) {
|
||||
case 200:
|
||||
return true;
|
||||
case 503:
|
||||
return false;
|
||||
default:
|
||||
throw new Error(res.statusText);
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error("Unexpected error while fetching ready status");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Query WAL replay status.
|
||||
const {
|
||||
data: {
|
||||
data: { min, max, current },
|
||||
},
|
||||
} = useSuspenseAPIQuery<WALReplayStatus>({
|
||||
path: "/status/walreplay",
|
||||
key: ["walreplay", queryKey],
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (ready) {
|
||||
dispatch(updateSettings({ ready: ready }));
|
||||
}
|
||||
}, [ready, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => setQueryKey((v) => v + 1), 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Stack gap="lg" maw={1000} mx="auto" mt="xs">
|
||||
<Title order={2}>Starting up...</Title>
|
||||
{max > 0 && (
|
||||
<>
|
||||
<p>
|
||||
Replaying WAL ({current}/{max})
|
||||
</p>
|
||||
<Progress
|
||||
size="xl"
|
||||
animated
|
||||
value={((current - min + 1) / (max - min + 1)) * 100}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export const ReadinessWrapper: FC<PropsWithChildren> = ({ children }) => {
|
||||
const { ready } = useSettings();
|
||||
|
||||
if (ready) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return <ReadinessLoader />;
|
||||
};
|
||||
|
||||
export default ReadinessWrapper;
|
15
web/ui/mantine-ui/src/components/RuleDefinition.module.css
Normal file
15
web/ui/mantine-ui/src/components/RuleDefinition.module.css
Normal file
|
@ -0,0 +1,15 @@
|
|||
.codebox {
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-gray-1),
|
||||
var(--mantine-color-gray-9)
|
||||
);
|
||||
}
|
||||
|
||||
.queryButton {
|
||||
opacity: 0;
|
||||
transition: opacity 0.1s ease-in-out;
|
||||
}
|
||||
|
||||
.codebox:hover .queryButton {
|
||||
opacity: 1;
|
||||
}
|
116
web/ui/mantine-ui/src/components/RuleDefinition.tsx
Normal file
116
web/ui/mantine-ui/src/components/RuleDefinition.tsx
Normal file
|
@ -0,0 +1,116 @@
|
|||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Box,
|
||||
Card,
|
||||
Group,
|
||||
rem,
|
||||
Table,
|
||||
Tooltip,
|
||||
useComputedColorScheme,
|
||||
} from "@mantine/core";
|
||||
import { IconClockPause, IconClockPlay, IconSearch } from "@tabler/icons-react";
|
||||
import { FC } from "react";
|
||||
import { formatPrometheusDuration } from "../lib/formatTime";
|
||||
import codeboxClasses from "./RuleDefinition.module.css";
|
||||
import { Rule } from "../api/responseTypes/rules";
|
||||
import CodeMirror, { EditorView } from "@uiw/react-codemirror";
|
||||
import { syntaxHighlighting } from "@codemirror/language";
|
||||
import {
|
||||
baseTheme,
|
||||
darkPromqlHighlighter,
|
||||
lightTheme,
|
||||
promqlHighlighter,
|
||||
} from "../codemirror/theme";
|
||||
import { PromQLExtension } from "@prometheus-io/codemirror-promql";
|
||||
import { LabelBadges } from "./LabelBadges";
|
||||
import { useSettings } from "../state/settingsSlice";
|
||||
|
||||
const promqlExtension = new PromQLExtension();
|
||||
|
||||
const RuleDefinition: FC<{ rule: Rule }> = ({ rule }) => {
|
||||
const theme = useComputedColorScheme();
|
||||
const { pathPrefix } = useSettings();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card p="xs" className={codeboxClasses.codebox} fz="sm" shadow="none">
|
||||
<CodeMirror
|
||||
basicSetup={false}
|
||||
value={rule.query}
|
||||
editable={false}
|
||||
extensions={[
|
||||
baseTheme,
|
||||
lightTheme,
|
||||
syntaxHighlighting(
|
||||
theme === "light" ? promqlHighlighter : darkPromqlHighlighter
|
||||
),
|
||||
promqlExtension.asExtension(),
|
||||
EditorView.lineWrapping,
|
||||
]}
|
||||
/>
|
||||
|
||||
<Tooltip label={"Query rule expression"} withArrow position="top">
|
||||
<ActionIcon
|
||||
pos="absolute"
|
||||
top={7}
|
||||
right={7}
|
||||
variant="light"
|
||||
onClick={() => {
|
||||
window.open(
|
||||
`${pathPrefix}/query?g0.expr=${encodeURIComponent(rule.query)}&g0.tab=1`,
|
||||
"_blank"
|
||||
);
|
||||
}}
|
||||
className={codeboxClasses.queryButton}
|
||||
>
|
||||
<IconSearch style={{ width: rem(14) }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Card>
|
||||
{rule.type === "alerting" && (
|
||||
<Group mt="lg" gap="xs">
|
||||
{rule.duration && (
|
||||
<Badge
|
||||
variant="light"
|
||||
styles={{ label: { textTransform: "none" } }}
|
||||
leftSection={<IconClockPause size={12} />}
|
||||
>
|
||||
for: {formatPrometheusDuration(rule.duration * 1000)}
|
||||
</Badge>
|
||||
)}
|
||||
{rule.keepFiringFor && (
|
||||
<Badge
|
||||
variant="light"
|
||||
styles={{ label: { textTransform: "none" } }}
|
||||
leftSection={<IconClockPlay size={12} />}
|
||||
>
|
||||
keep_firing_for: {formatPrometheusDuration(rule.duration * 1000)}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
{rule.labels && Object.keys(rule.labels).length > 0 && (
|
||||
<Box mt="lg">
|
||||
<LabelBadges labels={rule.labels} />
|
||||
</Box>
|
||||
)}
|
||||
{rule.type === "alerting" && Object.keys(rule.annotations).length > 0 && (
|
||||
<Table mt="lg" fz="sm">
|
||||
<Table.Tbody>
|
||||
{Object.entries(rule.annotations).map(([k, v]) => (
|
||||
<Table.Tr key={k}>
|
||||
<Table.Th c="light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-4))">
|
||||
{k}
|
||||
</Table.Th>
|
||||
<Table.Td>{v}</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default RuleDefinition;
|
107
web/ui/mantine-ui/src/components/SettingsMenu.tsx
Normal file
107
web/ui/mantine-ui/src/components/SettingsMenu.tsx
Normal file
|
@ -0,0 +1,107 @@
|
|||
import { Popover, ActionIcon, Fieldset, Checkbox, Stack } from "@mantine/core";
|
||||
import { IconSettings } from "@tabler/icons-react";
|
||||
import { FC } from "react";
|
||||
import { useAppDispatch } from "../state/hooks";
|
||||
import { updateSettings, useSettings } from "../state/settingsSlice";
|
||||
|
||||
const SettingsMenu: FC = () => {
|
||||
const {
|
||||
useLocalTime,
|
||||
enableQueryHistory,
|
||||
enableAutocomplete,
|
||||
enableSyntaxHighlighting,
|
||||
enableLinter,
|
||||
showAnnotations,
|
||||
} = useSettings();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
return (
|
||||
<Popover position="bottom" withArrow shadow="md">
|
||||
<Popover.Target>
|
||||
<ActionIcon color="gray" aria-label="Settings" size={32}>
|
||||
<IconSettings size={20} />
|
||||
</ActionIcon>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<Stack>
|
||||
<Fieldset p="md" legend="Global settings">
|
||||
<Checkbox
|
||||
checked={useLocalTime}
|
||||
label="Use local time"
|
||||
onChange={(event) =>
|
||||
dispatch(
|
||||
updateSettings({ useLocalTime: event.currentTarget.checked })
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Fieldset>
|
||||
|
||||
<Fieldset p="md" legend="Query page settings">
|
||||
<Stack>
|
||||
<Checkbox
|
||||
checked={enableQueryHistory}
|
||||
label="Enable query history"
|
||||
onChange={(event) =>
|
||||
dispatch(
|
||||
updateSettings({
|
||||
enableQueryHistory: event.currentTarget.checked,
|
||||
})
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Checkbox
|
||||
checked={enableAutocomplete}
|
||||
label="Enable autocomplete"
|
||||
onChange={(event) =>
|
||||
dispatch(
|
||||
updateSettings({
|
||||
enableAutocomplete: event.currentTarget.checked,
|
||||
})
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Checkbox
|
||||
checked={enableSyntaxHighlighting}
|
||||
label="Enable syntax highlighting"
|
||||
onChange={(event) =>
|
||||
dispatch(
|
||||
updateSettings({
|
||||
enableSyntaxHighlighting: event.currentTarget.checked,
|
||||
})
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Checkbox
|
||||
checked={enableLinter}
|
||||
label="Enable linter"
|
||||
onChange={(event) =>
|
||||
dispatch(
|
||||
updateSettings({
|
||||
enableLinter: event.currentTarget.checked,
|
||||
})
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
</Fieldset>
|
||||
|
||||
<Fieldset p="md" legend="Alerts page settings">
|
||||
<Checkbox
|
||||
checked={showAnnotations}
|
||||
label="Show expanded annotations"
|
||||
onChange={(event) =>
|
||||
dispatch(
|
||||
updateSettings({
|
||||
showAnnotations: event.currentTarget.checked,
|
||||
})
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Fieldset>
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsMenu;
|
142
web/ui/mantine-ui/src/components/StateMultiSelect.tsx
Normal file
142
web/ui/mantine-ui/src/components/StateMultiSelect.tsx
Normal file
|
@ -0,0 +1,142 @@
|
|||
import { FC } from "react";
|
||||
import {
|
||||
CheckIcon,
|
||||
Combobox,
|
||||
ComboboxChevron,
|
||||
ComboboxClearButton,
|
||||
Group,
|
||||
Pill,
|
||||
PillsInput,
|
||||
useCombobox,
|
||||
} from "@mantine/core";
|
||||
import { IconHeartRateMonitor } from "@tabler/icons-react";
|
||||
|
||||
interface StatePillProps extends React.ComponentPropsWithoutRef<"div"> {
|
||||
value: string;
|
||||
onRemove?: () => void;
|
||||
}
|
||||
|
||||
export function StatePill({ value, onRemove, ...others }: StatePillProps) {
|
||||
return (
|
||||
<Pill
|
||||
fw={600}
|
||||
style={{ textTransform: "uppercase", fontWeight: 600 }}
|
||||
onRemove={onRemove}
|
||||
{...others}
|
||||
withRemoveButton={!!onRemove}
|
||||
>
|
||||
{value}
|
||||
</Pill>
|
||||
);
|
||||
}
|
||||
|
||||
interface StateMultiSelectProps {
|
||||
options: string[];
|
||||
optionClass: (option: string) => string;
|
||||
optionCount?: (option: string) => number;
|
||||
placeholder: string;
|
||||
values: string[];
|
||||
onChange: (values: string[]) => void;
|
||||
}
|
||||
|
||||
export const StateMultiSelect: FC<StateMultiSelectProps> = ({
|
||||
options,
|
||||
optionClass,
|
||||
optionCount,
|
||||
placeholder,
|
||||
values,
|
||||
onChange,
|
||||
}) => {
|
||||
const combobox = useCombobox({
|
||||
onDropdownClose: () => combobox.resetSelectedOption(),
|
||||
onDropdownOpen: () => combobox.updateSelectedOptionIndex("active"),
|
||||
});
|
||||
|
||||
const handleValueSelect = (val: string) =>
|
||||
onChange(
|
||||
values.includes(val) ? values.filter((v) => v !== val) : [...values, val]
|
||||
);
|
||||
|
||||
const handleValueRemove = (val: string) =>
|
||||
onChange(values.filter((v) => v !== val));
|
||||
|
||||
const renderedValues = values.map((item) => (
|
||||
<StatePill
|
||||
value={optionCount ? `${item} (${optionCount(item)})` : item}
|
||||
className={optionClass(item)}
|
||||
onRemove={() => handleValueRemove(item)}
|
||||
key={item}
|
||||
/>
|
||||
));
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
store={combobox}
|
||||
onOptionSubmit={handleValueSelect}
|
||||
withinPortal={false}
|
||||
>
|
||||
<Combobox.DropdownTarget>
|
||||
<PillsInput
|
||||
pointer
|
||||
onClick={() => combobox.toggleDropdown()}
|
||||
miw={200}
|
||||
leftSection={<IconHeartRateMonitor size={14} />}
|
||||
rightSection={
|
||||
values.length > 0 ? (
|
||||
<ComboboxClearButton onClear={() => onChange([])} />
|
||||
) : (
|
||||
<ComboboxChevron />
|
||||
)
|
||||
}
|
||||
>
|
||||
<Pill.Group>
|
||||
{renderedValues.length > 0 ? (
|
||||
renderedValues
|
||||
) : (
|
||||
<PillsInput.Field placeholder={placeholder} mt={1} />
|
||||
)}
|
||||
|
||||
<Combobox.EventsTarget>
|
||||
<PillsInput.Field
|
||||
type="hidden"
|
||||
onBlur={() => combobox.closeDropdown()}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Backspace") {
|
||||
event.preventDefault();
|
||||
handleValueRemove(values[values.length - 1]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Combobox.EventsTarget>
|
||||
</Pill.Group>
|
||||
</PillsInput>
|
||||
</Combobox.DropdownTarget>
|
||||
|
||||
<Combobox.Dropdown>
|
||||
<Combobox.Options>
|
||||
{options.map((value) => {
|
||||
return (
|
||||
<Combobox.Option
|
||||
value={value}
|
||||
key={value}
|
||||
active={values.includes(value)}
|
||||
>
|
||||
<Group gap="sm">
|
||||
{values.includes(value) ? (
|
||||
<CheckIcon size={12} color="gray" />
|
||||
) : null}
|
||||
<StatePill
|
||||
value={
|
||||
optionCount ? `${value} (${optionCount(value)})` : value
|
||||
}
|
||||
className={optionClass(value)}
|
||||
/>
|
||||
</Group>
|
||||
</Combobox.Option>
|
||||
);
|
||||
})}
|
||||
</Combobox.Options>
|
||||
</Combobox.Dropdown>
|
||||
</Combobox>
|
||||
);
|
||||
};
|
64
web/ui/mantine-ui/src/components/ThemeSelector.tsx
Normal file
64
web/ui/mantine-ui/src/components/ThemeSelector.tsx
Normal file
|
@ -0,0 +1,64 @@
|
|||
import {
|
||||
useMantineColorScheme,
|
||||
SegmentedControl,
|
||||
rem,
|
||||
MantineColorScheme,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconMoonFilled,
|
||||
IconSunFilled,
|
||||
IconUserFilled,
|
||||
} from "@tabler/icons-react";
|
||||
import { FC } from "react";
|
||||
|
||||
export const ThemeSelector: FC = () => {
|
||||
const { colorScheme, setColorScheme } = useMantineColorScheme();
|
||||
const iconProps = {
|
||||
style: { width: rem(20), height: rem(20), display: "block" },
|
||||
stroke: 1.5,
|
||||
};
|
||||
|
||||
return (
|
||||
<SegmentedControl
|
||||
color="gray.7"
|
||||
size="xs"
|
||||
// styles={{ root: { backgroundColor: "var(--mantine-color-gray-7)" } }}
|
||||
styles={{
|
||||
root: {
|
||||
padding: 3,
|
||||
backgroundColor: "var(--mantine-color-gray-6)",
|
||||
},
|
||||
}}
|
||||
withItemsBorders={false}
|
||||
value={colorScheme}
|
||||
onChange={(v) => setColorScheme(v as MantineColorScheme)}
|
||||
data={[
|
||||
{
|
||||
value: "light",
|
||||
label: (
|
||||
<Tooltip label="Use light theme" offset={15}>
|
||||
<IconSunFilled {...iconProps} />
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "dark",
|
||||
label: (
|
||||
<Tooltip label="Use dark theme" offset={15}>
|
||||
<IconMoonFilled {...iconProps} />
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "auto",
|
||||
label: (
|
||||
<Tooltip label="Use browser-preferred theme" offset={15}>
|
||||
<IconUserFilled {...iconProps} />
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
BIN
web/ui/mantine-ui/src/fonts/codicon.ttf
Normal file
BIN
web/ui/mantine-ui/src/fonts/codicon.ttf
Normal file
Binary file not shown.
19
web/ui/mantine-ui/src/images/prometheus-logo.svg
Normal file
19
web/ui/mantine-ui/src/images/prometheus-logo.svg
Normal file
|
@ -0,0 +1,19 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
width="115.333px" height="114px" viewBox="0 0 115.333 114" enable-background="new 0 0 115.333 114" xml:space="preserve">
|
||||
<g id="Layer_2">
|
||||
</g>
|
||||
<g>
|
||||
<path fill="#EEEEEE" d="M56.667,0.667C25.372,0.667,0,26.036,0,57.332c0,31.295,25.372,56.666,56.667,56.666
|
||||
s56.666-25.371,56.666-56.666C113.333,26.036,87.961,0.667,56.667,0.667z M56.667,106.722c-8.904,0-16.123-5.948-16.123-13.283
|
||||
H72.79C72.79,100.773,65.571,106.722,56.667,106.722z M83.297,89.04H30.034v-9.658h53.264V89.04z M83.106,74.411h-52.92
|
||||
c-0.176-0.203-0.356-0.403-0.526-0.609c-5.452-6.62-6.736-10.076-7.983-13.598c-0.021-0.116,6.611,1.355,11.314,2.413
|
||||
c0,0,2.42,0.56,5.958,1.205c-3.397-3.982-5.414-9.044-5.414-14.218c0-11.359,8.712-21.285,5.569-29.308
|
||||
c3.059,0.249,6.331,6.456,6.552,16.161c3.252-4.494,4.613-12.701,4.613-17.733c0-5.21,3.433-11.262,6.867-11.469
|
||||
c-3.061,5.045,0.793,9.37,4.219,20.099c1.285,4.03,1.121,10.812,2.113,15.113C63.797,33.534,65.333,20.5,71,16
|
||||
c-2.5,5.667,0.37,12.758,2.333,16.167c3.167,5.5,5.087,9.667,5.087,17.548c0,5.284-1.951,10.259-5.242,14.148
|
||||
c3.742-0.702,6.326-1.335,6.326-1.335l12.152-2.371C91.657,60.156,89.891,67.418,83.106,74.411z"/>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 1.5 KiB |
4
web/ui/mantine-ui/src/lib/escapeString.ts
Normal file
4
web/ui/mantine-ui/src/lib/escapeString.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
// Used for escaping escape sequences and double quotes in double-quoted strings.
|
||||
export const escapeString = (str: string) => {
|
||||
return str.replace(/([\\"])/g, "\\$1");
|
||||
};
|
21
web/ui/mantine-ui/src/lib/formatFloatValue.ts
Normal file
21
web/ui/mantine-ui/src/lib/formatFloatValue.ts
Normal file
|
@ -0,0 +1,21 @@
|
|||
export const parsePrometheusFloat = (str: string): number => {
|
||||
switch (str) {
|
||||
case "+Inf":
|
||||
return Infinity;
|
||||
case "-Inf":
|
||||
return -Infinity;
|
||||
default:
|
||||
return parseFloat(str);
|
||||
}
|
||||
};
|
||||
|
||||
export const formatPrometheusFloat = (num: number): string => {
|
||||
switch (num) {
|
||||
case Infinity:
|
||||
return "+Inf";
|
||||
case -Infinity:
|
||||
return "-Inf";
|
||||
default:
|
||||
return num.toString();
|
||||
}
|
||||
};
|
12
web/ui/mantine-ui/src/lib/formatSeries.ts
Normal file
12
web/ui/mantine-ui/src/lib/formatSeries.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
import { escapeString } from "./escapeString";
|
||||
|
||||
export const formatSeries = (labels: { [key: string]: string }): string => {
|
||||
if (labels === null) {
|
||||
return "scalar";
|
||||
}
|
||||
|
||||
return `${labels.__name__ || ""}{${Object.entries(labels)
|
||||
.filter(([k]) => k !== "__name__")
|
||||
.map(([k, v]) => `${k}="${escapeString(v)}"`)
|
||||
.join(", ")}}`;
|
||||
};
|
136
web/ui/mantine-ui/src/lib/formatTime.ts
Normal file
136
web/ui/mantine-ui/src/lib/formatTime.ts
Normal file
|
@ -0,0 +1,136 @@
|
|||
import dayjs from "dayjs";
|
||||
import duration from "dayjs/plugin/duration";
|
||||
dayjs.extend(duration);
|
||||
import utc from "dayjs/plugin/utc";
|
||||
dayjs.extend(utc);
|
||||
|
||||
// Parse Prometheus-specific duration strings such as "5m" or "1d2h3m4s" into milliseconds.
|
||||
export const parsePrometheusDuration = (durationStr: string): number | null => {
|
||||
if (durationStr === "") {
|
||||
return null;
|
||||
}
|
||||
if (durationStr === "0") {
|
||||
// Allow 0 without a unit.
|
||||
return 0;
|
||||
}
|
||||
|
||||
const durationRE = new RegExp(
|
||||
"^(([0-9]+)y)?(([0-9]+)w)?(([0-9]+)d)?(([0-9]+)h)?(([0-9]+)m)?(([0-9]+)s)?(([0-9]+)ms)?$"
|
||||
);
|
||||
const matches = durationStr.match(durationRE);
|
||||
if (!matches) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let dur = 0;
|
||||
|
||||
// Parse the match at pos `pos` in the regex and use `mult` to turn that
|
||||
// into ms, then add that value to the total parsed duration.
|
||||
const m = (pos: number, mult: number) => {
|
||||
if (matches[pos] === undefined) {
|
||||
return;
|
||||
}
|
||||
const n = parseInt(matches[pos]);
|
||||
dur += n * mult;
|
||||
};
|
||||
|
||||
m(2, 1000 * 60 * 60 * 24 * 365); // y
|
||||
m(4, 1000 * 60 * 60 * 24 * 7); // w
|
||||
m(6, 1000 * 60 * 60 * 24); // d
|
||||
m(8, 1000 * 60 * 60); // h
|
||||
m(10, 1000 * 60); // m
|
||||
m(12, 1000); // s
|
||||
m(14, 1); // ms
|
||||
|
||||
return dur;
|
||||
};
|
||||
|
||||
// Format a duration in milliseconds into a Prometheus duration string like "1d2h3m4s".
|
||||
export const formatPrometheusDuration = (d: number): string => {
|
||||
let ms = d;
|
||||
let r = "";
|
||||
if (ms === 0) {
|
||||
return "0s";
|
||||
}
|
||||
|
||||
const f = (unit: string, mult: number, exact: boolean) => {
|
||||
if (exact && ms % mult !== 0) {
|
||||
return;
|
||||
}
|
||||
const v = Math.floor(ms / mult);
|
||||
if (v > 0) {
|
||||
r += `${v}${unit}`;
|
||||
ms -= v * mult;
|
||||
}
|
||||
};
|
||||
|
||||
// Only format years and weeks if the remainder is zero, as it is often
|
||||
// easier to read 90d than 12w6d.
|
||||
f("y", 1000 * 60 * 60 * 24 * 365, true);
|
||||
f("w", 1000 * 60 * 60 * 24 * 7, true);
|
||||
|
||||
f("d", 1000 * 60 * 60 * 24, false);
|
||||
f("h", 1000 * 60 * 60, false);
|
||||
f("m", 1000 * 60, false);
|
||||
f("s", 1000, false);
|
||||
f("ms", 1, false);
|
||||
|
||||
return r;
|
||||
};
|
||||
|
||||
export function parseTime(timeText: string): number {
|
||||
return dayjs.utc(timeText).valueOf();
|
||||
}
|
||||
|
||||
export const now = (): number => dayjs().valueOf();
|
||||
|
||||
export const humanizeDuration = (milliseconds: number): string => {
|
||||
if (milliseconds === 0) {
|
||||
return "0s";
|
||||
}
|
||||
|
||||
const sign = milliseconds < 0 ? "-" : "";
|
||||
const duration = dayjs.duration(Math.abs(milliseconds), "ms");
|
||||
const ms = Math.floor(duration.milliseconds());
|
||||
const s = Math.floor(duration.seconds());
|
||||
const m = Math.floor(duration.minutes());
|
||||
const h = Math.floor(duration.hours());
|
||||
const d = Math.floor(duration.asDays());
|
||||
const parts: string[] = [];
|
||||
if (d !== 0) {
|
||||
parts.push(`${d}d`);
|
||||
}
|
||||
if (h !== 0) {
|
||||
parts.push(`${h}h`);
|
||||
}
|
||||
if (m !== 0) {
|
||||
parts.push(`${m}m`);
|
||||
}
|
||||
if (s !== 0) {
|
||||
if (ms !== 0) {
|
||||
parts.push(`${s}.${ms}s`);
|
||||
} else {
|
||||
parts.push(`${s}s`);
|
||||
}
|
||||
} else if (milliseconds !== 0) {
|
||||
parts.push(`${milliseconds.toFixed(3)}ms`);
|
||||
}
|
||||
return sign + parts.join(" ");
|
||||
};
|
||||
|
||||
export const humanizeDurationRelative = (
|
||||
startStr: string,
|
||||
end: number,
|
||||
suffix: string = " ago"
|
||||
): string => {
|
||||
const start = parseTime(startStr);
|
||||
if (start < 0) {
|
||||
return "never";
|
||||
}
|
||||
return humanizeDuration(end - start) + suffix;
|
||||
};
|
||||
|
||||
export const formatTimestamp = (t: number, useLocalTime: boolean) =>
|
||||
useLocalTime
|
||||
? dayjs.unix(t).tz(dayjs.tz.guess()).format()
|
||||
: dayjs.unix(t).utc().format();
|
15
web/ui/mantine-ui/src/main.tsx
Normal file
15
web/ui/mantine-ui/src/main.tsx
Normal file
|
@ -0,0 +1,15 @@
|
|||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App.tsx";
|
||||
import store from "./state/store.ts";
|
||||
import { Provider } from "react-redux";
|
||||
import "./fonts/codicon.ttf";
|
||||
import "./promql.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<Provider store={store}>
|
||||
<App />
|
||||
</Provider>
|
||||
</React.StrictMode>
|
||||
);
|
27
web/ui/mantine-ui/src/pages/AgentPage.tsx
Normal file
27
web/ui/mantine-ui/src/pages/AgentPage.tsx
Normal file
|
@ -0,0 +1,27 @@
|
|||
import { Card, Group, Text } from "@mantine/core";
|
||||
import { IconSpy } from "@tabler/icons-react";
|
||||
import { FC } from "react";
|
||||
|
||||
const AgentPage: FC = () => {
|
||||
return (
|
||||
<Card shadow="xs" withBorder p="md" mt="xs">
|
||||
<Group wrap="nowrap" align="center" ml="xs" mb="sm" gap="xs">
|
||||
<IconSpy size={22} />
|
||||
<Text fz="xl" fw={600}>
|
||||
Prometheus Agent
|
||||
</Text>
|
||||
</Group>
|
||||
<Text p="md">
|
||||
This Prometheus instance is running in <strong>agent mode</strong>. In
|
||||
this mode, Prometheus is only used to scrape discovered targets and
|
||||
forward the scraped metrics to remote write endpoints.
|
||||
</Text>
|
||||
<Text p="md">
|
||||
Some features are not available in this mode, such as querying and
|
||||
alerting.
|
||||
</Text>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default AgentPage;
|
80
web/ui/mantine-ui/src/pages/AlertmanagerDiscoveryPage.tsx
Normal file
80
web/ui/mantine-ui/src/pages/AlertmanagerDiscoveryPage.tsx
Normal file
|
@ -0,0 +1,80 @@
|
|||
import { Alert, Card, Group, Stack, Table, Text } from "@mantine/core";
|
||||
import { IconBell, IconBellOff, IconInfoCircle } from "@tabler/icons-react";
|
||||
|
||||
import { useSuspenseAPIQuery } from "../api/api";
|
||||
import { AlertmanagersResult } from "../api/responseTypes/alertmanagers";
|
||||
import EndpointLink from "../components/EndpointLink";
|
||||
|
||||
export const targetPoolDisplayLimit = 20;
|
||||
|
||||
export default function AlertmanagerDiscoveryPage() {
|
||||
// Load the list of all available scrape pools.
|
||||
const {
|
||||
data: {
|
||||
data: { activeAlertmanagers, droppedAlertmanagers },
|
||||
},
|
||||
} = useSuspenseAPIQuery<AlertmanagersResult>({
|
||||
path: `/alertmanagers`,
|
||||
});
|
||||
|
||||
return (
|
||||
<Stack gap="lg" maw={1000} mx="auto" mt="xs">
|
||||
<Card shadow="xs" withBorder p="md">
|
||||
<Group wrap="nowrap" align="center" ml="xs" mb="sm" gap="xs">
|
||||
<IconBell size={22} />
|
||||
<Text fz="xl" fw={600}>
|
||||
Active Alertmanagers
|
||||
</Text>
|
||||
</Group>
|
||||
{activeAlertmanagers.length === 0 ? (
|
||||
<Alert title="No active alertmanagers" icon={<IconInfoCircle />}>
|
||||
No active alertmanagers found.
|
||||
</Alert>
|
||||
) : (
|
||||
<Table layout="fixed">
|
||||
<Table.Tbody>
|
||||
{activeAlertmanagers.map((alertmanager) => (
|
||||
<Table.Tr key={alertmanager.url}>
|
||||
<Table.Td>
|
||||
<EndpointLink
|
||||
endpoint={alertmanager.url}
|
||||
globalUrl={alertmanager.url}
|
||||
/>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</Card>
|
||||
<Card shadow="xs" withBorder p="md">
|
||||
<Group wrap="nowrap" align="center" ml="xs" mb="sm" gap="xs">
|
||||
<IconBellOff size={22} />
|
||||
<Text fz="xl" fw={600}>
|
||||
Dropped Alertmanagers
|
||||
</Text>
|
||||
</Group>
|
||||
{droppedAlertmanagers.length === 0 ? (
|
||||
<Alert title="No dropped alertmanagers" icon={<IconInfoCircle />}>
|
||||
No dropped alertmanagers found.
|
||||
</Alert>
|
||||
) : (
|
||||
<Table layout="fixed">
|
||||
<Table.Tbody>
|
||||
{droppedAlertmanagers.map((alertmanager) => (
|
||||
<Table.Tr key={alertmanager.url}>
|
||||
<Table.Td>
|
||||
<EndpointLink
|
||||
endpoint={alertmanager.url}
|
||||
globalUrl={alertmanager.url}
|
||||
/>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</Card>
|
||||
</Stack>
|
||||
);
|
||||
}
|
413
web/ui/mantine-ui/src/pages/AlertsPage.tsx
Normal file
413
web/ui/mantine-ui/src/pages/AlertsPage.tsx
Normal file
|
@ -0,0 +1,413 @@
|
|||
import {
|
||||
Card,
|
||||
Group,
|
||||
Table,
|
||||
Text,
|
||||
Accordion,
|
||||
Badge,
|
||||
Tooltip,
|
||||
Box,
|
||||
Stack,
|
||||
Alert,
|
||||
TextInput,
|
||||
Anchor,
|
||||
} from "@mantine/core";
|
||||
import { useSuspenseAPIQuery } from "../api/api";
|
||||
import { AlertingRule, AlertingRulesResult } from "../api/responseTypes/rules";
|
||||
import badgeClasses from "../Badge.module.css";
|
||||
import panelClasses from "../Panel.module.css";
|
||||
import RuleDefinition from "../components/RuleDefinition";
|
||||
import { humanizeDurationRelative, now } from "../lib/formatTime";
|
||||
import { Fragment, useMemo } from "react";
|
||||
import { StateMultiSelect } from "../components/StateMultiSelect";
|
||||
import { IconInfoCircle, IconSearch } from "@tabler/icons-react";
|
||||
import { LabelBadges } from "../components/LabelBadges";
|
||||
import { useSettings } from "../state/settingsSlice";
|
||||
import {
|
||||
ArrayParam,
|
||||
BooleanParam,
|
||||
StringParam,
|
||||
useQueryParam,
|
||||
withDefault,
|
||||
} from "use-query-params";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { KVSearch } from "@nexucis/kvsearch";
|
||||
|
||||
type AlertsPageData = {
|
||||
// How many rules are in each state across all groups.
|
||||
globalCounts: {
|
||||
inactive: number;
|
||||
pending: number;
|
||||
firing: number;
|
||||
};
|
||||
groups: {
|
||||
name: string;
|
||||
file: string;
|
||||
// How many rules are in each state for this group.
|
||||
counts: {
|
||||
total: number;
|
||||
inactive: number;
|
||||
pending: number;
|
||||
firing: number;
|
||||
};
|
||||
rules: {
|
||||
rule: AlertingRule;
|
||||
// How many alerts are in each state for this rule.
|
||||
counts: {
|
||||
firing: number;
|
||||
pending: number;
|
||||
};
|
||||
}[];
|
||||
}[];
|
||||
};
|
||||
|
||||
const kvSearch = new KVSearch<AlertingRule>({
|
||||
shouldSort: true,
|
||||
indexedKeys: ["name", "labels", ["labels", /.*/]],
|
||||
});
|
||||
|
||||
const buildAlertsPageData = (
|
||||
data: AlertingRulesResult,
|
||||
search: string,
|
||||
stateFilter: (string | null)[]
|
||||
) => {
|
||||
const pageData: AlertsPageData = {
|
||||
globalCounts: {
|
||||
inactive: 0,
|
||||
pending: 0,
|
||||
firing: 0,
|
||||
},
|
||||
groups: [],
|
||||
};
|
||||
|
||||
for (const group of data.groups) {
|
||||
const groupCounts = {
|
||||
total: 0,
|
||||
inactive: 0,
|
||||
pending: 0,
|
||||
firing: 0,
|
||||
};
|
||||
|
||||
for (const r of group.rules) {
|
||||
groupCounts.total++;
|
||||
switch (r.state) {
|
||||
case "inactive":
|
||||
pageData.globalCounts.inactive++;
|
||||
groupCounts.inactive++;
|
||||
break;
|
||||
case "firing":
|
||||
pageData.globalCounts.firing++;
|
||||
groupCounts.firing++;
|
||||
break;
|
||||
case "pending":
|
||||
pageData.globalCounts.pending++;
|
||||
groupCounts.pending++;
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown rule state: ${r.state}`);
|
||||
}
|
||||
}
|
||||
|
||||
const filteredRules: AlertingRule[] = (
|
||||
search === ""
|
||||
? group.rules
|
||||
: kvSearch.filter(search, group.rules).map((value) => value.original)
|
||||
).filter((r) => stateFilter.length === 0 || stateFilter.includes(r.state));
|
||||
|
||||
pageData.groups.push({
|
||||
name: group.name,
|
||||
file: group.file,
|
||||
counts: groupCounts,
|
||||
rules: filteredRules.map((r) => ({
|
||||
rule: r,
|
||||
counts: {
|
||||
firing: r.alerts.filter((a) => a.state === "firing").length,
|
||||
pending: r.alerts.filter((a) => a.state === "pending").length,
|
||||
},
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return pageData;
|
||||
};
|
||||
|
||||
export default function AlertsPage() {
|
||||
// Fetch the alerting rules data.
|
||||
const { data } = useSuspenseAPIQuery<AlertingRulesResult>({
|
||||
path: `/rules`,
|
||||
params: {
|
||||
type: "alert",
|
||||
},
|
||||
});
|
||||
|
||||
const { showAnnotations } = useSettings();
|
||||
|
||||
// Define URL query params.
|
||||
const [stateFilter, setStateFilter] = useQueryParam(
|
||||
"state",
|
||||
withDefault(ArrayParam, [])
|
||||
);
|
||||
const [searchFilter, setSearchFilter] = useQueryParam(
|
||||
"search",
|
||||
withDefault(StringParam, "")
|
||||
);
|
||||
const [debouncedSearch] = useDebouncedValue<string>(searchFilter.trim(), 250);
|
||||
const [showEmptyGroups, setShowEmptyGroups] = useQueryParam(
|
||||
"showEmptyGroups",
|
||||
withDefault(BooleanParam, true)
|
||||
);
|
||||
|
||||
// Update the page data whenever the fetched data or filters change.
|
||||
const alertsPageData: AlertsPageData = useMemo(
|
||||
() => buildAlertsPageData(data.data, debouncedSearch, stateFilter),
|
||||
[data, stateFilter, debouncedSearch]
|
||||
);
|
||||
|
||||
const shownGroups = showEmptyGroups
|
||||
? alertsPageData.groups
|
||||
: alertsPageData.groups.filter((g) => g.rules.length > 0);
|
||||
|
||||
return (
|
||||
<Stack mt="xs">
|
||||
<Group>
|
||||
<StateMultiSelect
|
||||
options={["inactive", "pending", "firing"]}
|
||||
optionClass={(o) =>
|
||||
o === "inactive"
|
||||
? badgeClasses.healthOk
|
||||
: o === "pending"
|
||||
? badgeClasses.healthWarn
|
||||
: badgeClasses.healthErr
|
||||
}
|
||||
optionCount={(o) =>
|
||||
alertsPageData.globalCounts[
|
||||
o as keyof typeof alertsPageData.globalCounts
|
||||
]
|
||||
}
|
||||
placeholder="Filter by rule state"
|
||||
values={(stateFilter?.filter((v) => v !== null) as string[]) || []}
|
||||
onChange={(values) => setStateFilter(values)}
|
||||
/>
|
||||
<TextInput
|
||||
flex={1}
|
||||
leftSection={<IconSearch size={14} />}
|
||||
placeholder="Filter by rule name or labels"
|
||||
value={searchFilter || ""}
|
||||
onChange={(event) =>
|
||||
setSearchFilter(event.currentTarget.value || null)
|
||||
}
|
||||
></TextInput>
|
||||
</Group>
|
||||
{alertsPageData.groups.length === 0 ? (
|
||||
<Alert title="No rules found" icon={<IconInfoCircle size={14} />}>
|
||||
No rules found.
|
||||
</Alert>
|
||||
) : (
|
||||
!showEmptyGroups &&
|
||||
alertsPageData.groups.length !== shownGroups.length && (
|
||||
<Alert
|
||||
title="Hiding groups with no matching rules"
|
||||
icon={<IconInfoCircle size={14} />}
|
||||
>
|
||||
Hiding {alertsPageData.groups.length - shownGroups.length} empty
|
||||
groups due to filters or no rules.
|
||||
<Anchor ml="md" fz="1em" onClick={() => setShowEmptyGroups(true)}>
|
||||
Show empty groups
|
||||
</Anchor>
|
||||
</Alert>
|
||||
)
|
||||
)}
|
||||
<Stack>
|
||||
{shownGroups.map((g, i) => {
|
||||
return (
|
||||
<Card
|
||||
shadow="xs"
|
||||
withBorder
|
||||
p="md"
|
||||
key={i} // TODO: Find a stable and definitely unique key.
|
||||
>
|
||||
<Group mb="md" mt="xs" ml="xs" justify="space-between">
|
||||
<Group align="baseline">
|
||||
<Text
|
||||
fz="xl"
|
||||
fw={600}
|
||||
c="var(--mantine-primary-color-filled)"
|
||||
>
|
||||
{g.name}
|
||||
</Text>
|
||||
<Text fz="sm" c="gray.6">
|
||||
{g.file}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group>
|
||||
{g.counts.firing > 0 && (
|
||||
<Badge className={badgeClasses.healthErr}>
|
||||
firing ({g.counts.firing})
|
||||
</Badge>
|
||||
)}
|
||||
{g.counts.pending > 0 && (
|
||||
<Badge className={badgeClasses.healthWarn}>
|
||||
pending ({g.counts.pending})
|
||||
</Badge>
|
||||
)}
|
||||
{g.counts.inactive > 0 && (
|
||||
<Badge className={badgeClasses.healthOk}>
|
||||
inactive ({g.counts.inactive})
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
{g.counts.total === 0 ? (
|
||||
<Alert title="No rules" icon={<IconInfoCircle />}>
|
||||
No rules in this group.
|
||||
<Anchor
|
||||
ml="md"
|
||||
fz="1em"
|
||||
onClick={() => setShowEmptyGroups(false)}
|
||||
>
|
||||
Hide empty groups
|
||||
</Anchor>
|
||||
</Alert>
|
||||
) : g.rules.length === 0 ? (
|
||||
<Alert title="No matching rules" icon={<IconInfoCircle />}>
|
||||
No rules in this group match your filter criteria (omitted{" "}
|
||||
{g.counts.total} filtered rules).
|
||||
<Anchor
|
||||
ml="md"
|
||||
fz="1em"
|
||||
onClick={() => setShowEmptyGroups(false)}
|
||||
>
|
||||
Hide empty groups
|
||||
</Anchor>
|
||||
</Alert>
|
||||
) : (
|
||||
<Accordion multiple variant="separated">
|
||||
{g.rules.map((r, j) => {
|
||||
return (
|
||||
<Accordion.Item
|
||||
styles={{
|
||||
item: {
|
||||
// TODO: This transparency hack is an OK workaround to make the collapsed items
|
||||
// have a different background color than their surrounding group card in dark mode,
|
||||
// but it would be better to use CSS to override the light/dark colors for
|
||||
// collapsed/expanded accordion items.
|
||||
backgroundColor: "#c0c0c015",
|
||||
},
|
||||
}}
|
||||
key={j}
|
||||
value={j.toString()}
|
||||
className={
|
||||
r.counts.firing > 0
|
||||
? panelClasses.panelHealthErr
|
||||
: r.counts.pending > 0
|
||||
? panelClasses.panelHealthWarn
|
||||
: panelClasses.panelHealthOk
|
||||
}
|
||||
>
|
||||
<Accordion.Control>
|
||||
<Group wrap="nowrap" justify="space-between" mr="lg">
|
||||
<Text>{r.rule.name}</Text>
|
||||
<Group gap="xs">
|
||||
{r.counts.firing > 0 && (
|
||||
<Badge className={badgeClasses.healthErr}>
|
||||
firing ({r.counts.firing})
|
||||
</Badge>
|
||||
)}
|
||||
{r.counts.pending > 0 && (
|
||||
<Badge className={badgeClasses.healthWarn}>
|
||||
pending ({r.counts.pending})
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<RuleDefinition rule={r.rule} />
|
||||
{r.rule.alerts.length > 0 && (
|
||||
<Table mt="lg">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Alert labels</Table.Th>
|
||||
<Table.Th>State</Table.Th>
|
||||
<Table.Th>Active Since</Table.Th>
|
||||
<Table.Th>Value</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{r.rule.type === "alerting" &&
|
||||
r.rule.alerts.map((a, k) => (
|
||||
<Fragment key={k}>
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
<LabelBadges labels={a.labels} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
className={
|
||||
a.state === "firing"
|
||||
? badgeClasses.healthErr
|
||||
: badgeClasses.healthWarn
|
||||
}
|
||||
>
|
||||
{a.state}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td
|
||||
style={{ whiteSpace: "nowrap" }}
|
||||
>
|
||||
<Tooltip label={a.activeAt}>
|
||||
<Box>
|
||||
{humanizeDurationRelative(
|
||||
a.activeAt,
|
||||
now(),
|
||||
""
|
||||
)}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Table.Td>
|
||||
<Table.Td
|
||||
style={{ whiteSpace: "nowrap" }}
|
||||
>
|
||||
{isNaN(Number(a.value))
|
||||
? a.value
|
||||
: Number(a.value)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
{showAnnotations && (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={4}>
|
||||
<Table mt="md" mb="xl">
|
||||
<Table.Tbody>
|
||||
{Object.entries(
|
||||
a.annotations
|
||||
).map(([k, v]) => (
|
||||
<Table.Tr key={k}>
|
||||
<Table.Th c="light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-4))">
|
||||
{k}
|
||||
</Table.Th>
|
||||
<Table.Td>{v}</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
);
|
||||
})}
|
||||
</Accordion>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
23
web/ui/mantine-ui/src/pages/ConfigPage.tsx
Normal file
23
web/ui/mantine-ui/src/pages/ConfigPage.tsx
Normal file
|
@ -0,0 +1,23 @@
|
|||
import { CodeHighlight } from "@mantine/code-highlight";
|
||||
import { useSuspenseAPIQuery } from "../api/api";
|
||||
import ConfigResult from "../api/responseTypes/config";
|
||||
|
||||
export default function ConfigPage() {
|
||||
const {
|
||||
data: {
|
||||
data: { yaml },
|
||||
},
|
||||
} = useSuspenseAPIQuery<ConfigResult>({ path: `/status/config` });
|
||||
|
||||
return (
|
||||
<CodeHighlight
|
||||
code={yaml}
|
||||
language="yaml"
|
||||
miw="50vw"
|
||||
w="fit-content"
|
||||
maw="calc(100vw - 75px)"
|
||||
mx="auto"
|
||||
mt="xs"
|
||||
/>
|
||||
);
|
||||
}
|
21
web/ui/mantine-ui/src/pages/FlagsPage.module.css
Normal file
21
web/ui/mantine-ui/src/pages/FlagsPage.module.css
Normal file
|
@ -0,0 +1,21 @@
|
|||
.th {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.control {
|
||||
width: 100%;
|
||||
padding: var(--mantine-spacing-xs) var(--mantine-spacing-md);
|
||||
|
||||
@mixin hover {
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-gray-0),
|
||||
var(--mantine-color-dark-6)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: rem(21px);
|
||||
height: rem(21px);
|
||||
border-radius: rem(21px);
|
||||
}
|
182
web/ui/mantine-ui/src/pages/FlagsPage.tsx
Normal file
182
web/ui/mantine-ui/src/pages/FlagsPage.tsx
Normal file
|
@ -0,0 +1,182 @@
|
|||
import { useState } from "react";
|
||||
import {
|
||||
Table,
|
||||
UnstyledButton,
|
||||
Group,
|
||||
Text,
|
||||
Center,
|
||||
TextInput,
|
||||
rem,
|
||||
keys,
|
||||
Card,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconSelector,
|
||||
IconChevronDown,
|
||||
IconChevronUp,
|
||||
IconSearch,
|
||||
} from "@tabler/icons-react";
|
||||
import classes from "./FlagsPage.module.css";
|
||||
import { useSuspenseAPIQuery } from "../api/api";
|
||||
|
||||
interface RowData {
|
||||
flag: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface ThProps {
|
||||
children: React.ReactNode;
|
||||
reversed: boolean;
|
||||
sorted: boolean;
|
||||
onSort(): void;
|
||||
}
|
||||
|
||||
function Th({ children, reversed, sorted, onSort }: ThProps) {
|
||||
const Icon = sorted
|
||||
? reversed
|
||||
? IconChevronUp
|
||||
: IconChevronDown
|
||||
: IconSelector;
|
||||
return (
|
||||
<Table.Th className={classes.th}>
|
||||
<UnstyledButton onClick={onSort} className={classes.control}>
|
||||
<Group justify="space-between">
|
||||
<Text fw={600} fz="sm">
|
||||
{children}
|
||||
</Text>
|
||||
<Center className={classes.icon}>
|
||||
<Icon style={{ width: rem(16), height: rem(16) }} stroke={1.5} />
|
||||
</Center>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
</Table.Th>
|
||||
);
|
||||
}
|
||||
|
||||
function filterData(data: RowData[], search: string) {
|
||||
const query = search.toLowerCase().trim();
|
||||
return data.filter((item) =>
|
||||
keys(data[0]).some((key) => item[key].toLowerCase().includes(query))
|
||||
);
|
||||
}
|
||||
|
||||
function sortData(
|
||||
data: RowData[],
|
||||
payload: { sortBy: keyof RowData | null; reversed: boolean; search: string }
|
||||
) {
|
||||
const { sortBy } = payload;
|
||||
|
||||
if (!sortBy) {
|
||||
return filterData(data, payload.search);
|
||||
}
|
||||
|
||||
return filterData(
|
||||
[...data].sort((a, b) => {
|
||||
if (payload.reversed) {
|
||||
return b[sortBy].localeCompare(a[sortBy]);
|
||||
}
|
||||
|
||||
return a[sortBy].localeCompare(b[sortBy]);
|
||||
}),
|
||||
payload.search
|
||||
);
|
||||
}
|
||||
|
||||
export default function FlagsPage() {
|
||||
const { data } = useSuspenseAPIQuery<Record<string, string>>({
|
||||
path: `/status/flags`,
|
||||
});
|
||||
|
||||
const flags = Object.entries(data.data).map(([flag, value]) => ({
|
||||
flag,
|
||||
value,
|
||||
}));
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
const [sortedData, setSortedData] = useState(flags);
|
||||
const [sortBy, setSortBy] = useState<keyof RowData | null>(null);
|
||||
const [reverseSortDirection, setReverseSortDirection] = useState(false);
|
||||
|
||||
const setSorting = (field: keyof RowData) => {
|
||||
const reversed = field === sortBy ? !reverseSortDirection : false;
|
||||
setReverseSortDirection(reversed);
|
||||
setSortBy(field);
|
||||
setSortedData(sortData(flags, { sortBy: field, reversed, search }));
|
||||
};
|
||||
|
||||
const handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { value } = event.currentTarget;
|
||||
setSearch(value);
|
||||
setSortedData(
|
||||
sortData(flags, { sortBy, reversed: reverseSortDirection, search: value })
|
||||
);
|
||||
};
|
||||
|
||||
const rows = sortedData.map((row) => (
|
||||
<Table.Tr key={row.flag}>
|
||||
<Table.Td>
|
||||
<code>--{row.flag}</code>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<code>{row.value}</code>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
));
|
||||
|
||||
return (
|
||||
<Card shadow="xs" maw={1000} mx="auto" mt="xs" withBorder>
|
||||
<TextInput
|
||||
placeholder="Filter by flag name or value"
|
||||
mb="md"
|
||||
autoFocus
|
||||
leftSection={
|
||||
<IconSearch
|
||||
style={{ width: rem(16), height: rem(16) }}
|
||||
stroke={1.5}
|
||||
/>
|
||||
}
|
||||
value={search}
|
||||
onChange={handleSearchChange}
|
||||
/>
|
||||
<Table
|
||||
horizontalSpacing="md"
|
||||
verticalSpacing="xs"
|
||||
miw={700}
|
||||
layout="fixed"
|
||||
>
|
||||
<Table.Tbody>
|
||||
<Table.Tr>
|
||||
<Th
|
||||
sorted={sortBy === "flag"}
|
||||
reversed={reverseSortDirection}
|
||||
onSort={() => setSorting("flag")}
|
||||
>
|
||||
Flag
|
||||
</Th>
|
||||
|
||||
<Th
|
||||
sorted={sortBy === "value"}
|
||||
reversed={reverseSortDirection}
|
||||
onSort={() => setSorting("value")}
|
||||
>
|
||||
Value
|
||||
</Th>
|
||||
</Table.Tr>
|
||||
</Table.Tbody>
|
||||
<Table.Tbody>
|
||||
{rows.length > 0 ? (
|
||||
rows
|
||||
) : (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={2}>
|
||||
<Text fw={500} ta="center">
|
||||
Nothing found
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Card>
|
||||
);
|
||||
}
|
201
web/ui/mantine-ui/src/pages/RulesPage.tsx
Normal file
201
web/ui/mantine-ui/src/pages/RulesPage.tsx
Normal file
|
@ -0,0 +1,201 @@
|
|||
import {
|
||||
Accordion,
|
||||
Alert,
|
||||
Badge,
|
||||
Card,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
// import { useQuery } from "react-query";
|
||||
import {
|
||||
humanizeDurationRelative,
|
||||
humanizeDuration,
|
||||
now,
|
||||
} from "../lib/formatTime";
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconBell,
|
||||
IconHourglass,
|
||||
IconInfoCircle,
|
||||
IconRefresh,
|
||||
IconRepeat,
|
||||
IconTimeline,
|
||||
} from "@tabler/icons-react";
|
||||
import { useSuspenseAPIQuery } from "../api/api";
|
||||
import { RulesResult } from "../api/responseTypes/rules";
|
||||
import badgeClasses from "../Badge.module.css";
|
||||
import RuleDefinition from "../components/RuleDefinition";
|
||||
|
||||
const healthBadgeClass = (state: string) => {
|
||||
switch (state) {
|
||||
case "ok":
|
||||
return badgeClasses.healthOk;
|
||||
case "err":
|
||||
return badgeClasses.healthErr;
|
||||
case "unknown":
|
||||
return badgeClasses.healthUnknown;
|
||||
default:
|
||||
throw new Error("Unknown rule health state");
|
||||
}
|
||||
};
|
||||
|
||||
export default function RulesPage() {
|
||||
const { data } = useSuspenseAPIQuery<RulesResult>({ path: `/rules` });
|
||||
|
||||
return (
|
||||
<Stack mt="xs">
|
||||
{data.data.groups.length === 0 && (
|
||||
<Alert title="No rule groups" icon={<IconInfoCircle size={14} />}>
|
||||
No rule groups configured.
|
||||
</Alert>
|
||||
)}
|
||||
{data.data.groups.map((g, i) => (
|
||||
<Card
|
||||
shadow="xs"
|
||||
withBorder
|
||||
p="md"
|
||||
mb="md"
|
||||
key={i} // TODO: Find a stable and definitely unique key.
|
||||
>
|
||||
<Group mb="md" mt="xs" ml="xs" justify="space-between">
|
||||
<Group align="baseline">
|
||||
<Text fz="xl" fw={600} c="var(--mantine-primary-color-filled)">
|
||||
{g.name}
|
||||
</Text>
|
||||
<Text fz="sm" c="gray.6">
|
||||
{g.file}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group>
|
||||
<Tooltip label="Last group evaluation" withArrow>
|
||||
<Badge
|
||||
variant="light"
|
||||
className={badgeClasses.statsBadge}
|
||||
styles={{ label: { textTransform: "none" } }}
|
||||
leftSection={<IconRefresh size={12} />}
|
||||
>
|
||||
last run {humanizeDurationRelative(g.lastEvaluation, now())}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
<Tooltip label="Duration of last group evaluation" withArrow>
|
||||
<Badge
|
||||
variant="light"
|
||||
className={badgeClasses.statsBadge}
|
||||
styles={{ label: { textTransform: "none" } }}
|
||||
leftSection={<IconHourglass size={12} />}
|
||||
>
|
||||
took {humanizeDuration(parseFloat(g.evaluationTime) * 1000)}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
<Tooltip label="Group evaluation interval" withArrow>
|
||||
<Badge
|
||||
variant="transparent"
|
||||
className={badgeClasses.statsBadge}
|
||||
styles={{ label: { textTransform: "none" } }}
|
||||
leftSection={<IconRepeat size={12} />}
|
||||
>
|
||||
every {humanizeDuration(parseFloat(g.interval) * 1000)}{" "}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Group>
|
||||
{g.rules.length === 0 && (
|
||||
<Alert title="No rules" icon={<IconInfoCircle size={14} />}>
|
||||
No rules in rule group.
|
||||
</Alert>
|
||||
)}
|
||||
<Accordion multiple variant="separated">
|
||||
{g.rules.map((r, j) => (
|
||||
<Accordion.Item
|
||||
styles={{
|
||||
item: {
|
||||
// TODO: This transparency hack is an OK workaround to make the collapsed items
|
||||
// have a different background color than their surrounding group card in dark mode,
|
||||
// but it would be better to use CSS to override the light/dark colors for
|
||||
// collapsed/expanded accordion items.
|
||||
backgroundColor: "#c0c0c015",
|
||||
},
|
||||
}}
|
||||
key={j}
|
||||
value={j.toString()}
|
||||
style={{
|
||||
borderLeft:
|
||||
r.health === "err"
|
||||
? "5px solid var(--mantine-color-red-4)"
|
||||
: r.health === "unknown"
|
||||
? "5px solid var(--mantine-color-gray-5)"
|
||||
: "5px solid var(--mantine-color-green-4)",
|
||||
}}
|
||||
>
|
||||
<Accordion.Control>
|
||||
<Group justify="space-between" mr="lg">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
{r.type === "alerting" ? (
|
||||
<Tooltip label="Alerting rule" withArrow>
|
||||
<IconBell size={15} />
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip label="Recording rule" withArrow>
|
||||
<IconTimeline size={15} />
|
||||
</Tooltip>
|
||||
)}
|
||||
<Text>{r.name}</Text>
|
||||
</Group>
|
||||
<Group gap="xs">
|
||||
<Group gap="xs" wrap="wrap">
|
||||
<Tooltip label="Last rule evaluation" withArrow>
|
||||
<Badge
|
||||
variant="light"
|
||||
className={badgeClasses.statsBadge}
|
||||
styles={{ label: { textTransform: "none" } }}
|
||||
leftSection={<IconRefresh size={12} />}
|
||||
>
|
||||
{humanizeDurationRelative(r.lastEvaluation, now())}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip
|
||||
label="Duration of last rule evaluation"
|
||||
withArrow
|
||||
>
|
||||
<Badge
|
||||
variant="light"
|
||||
className={badgeClasses.statsBadge}
|
||||
styles={{ label: { textTransform: "none" } }}
|
||||
leftSection={<IconHourglass size={12} />}
|
||||
>
|
||||
{humanizeDuration(
|
||||
parseFloat(r.evaluationTime) * 1000
|
||||
)}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
<Badge className={healthBadgeClass(r.health)}>
|
||||
{r.health}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Group>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<RuleDefinition rule={r} />
|
||||
{r.lastError && (
|
||||
<Alert
|
||||
color="red"
|
||||
mt="sm"
|
||||
title="Rule failed to evaluate"
|
||||
icon={<IconAlertTriangle size={14} />}
|
||||
>
|
||||
<strong>Error:</strong> {r.lastError}
|
||||
</Alert>
|
||||
)}
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
))}
|
||||
</Accordion>
|
||||
</Card>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
90
web/ui/mantine-ui/src/pages/StatusPage.tsx
Normal file
90
web/ui/mantine-ui/src/pages/StatusPage.tsx
Normal file
|
@ -0,0 +1,90 @@
|
|||
import { Card, Group, Stack, Table, Text } from "@mantine/core";
|
||||
import { useSuspenseAPIQuery } from "../api/api";
|
||||
import { IconRun, IconWall } from "@tabler/icons-react";
|
||||
import { formatTimestamp } from "../lib/formatTime";
|
||||
import { useSettings } from "../state/settingsSlice";
|
||||
|
||||
export default function StatusPage() {
|
||||
const { data: buildinfo } = useSuspenseAPIQuery<Record<string, string>>({
|
||||
path: `/status/buildinfo`,
|
||||
});
|
||||
const { data: runtimeinfo } = useSuspenseAPIQuery<Record<string, string>>({
|
||||
path: `/status/runtimeinfo`,
|
||||
});
|
||||
|
||||
const { useLocalTime } = useSettings();
|
||||
|
||||
const statusConfig: Record<
|
||||
string,
|
||||
{
|
||||
title?: string;
|
||||
formatValue?: (v: string | boolean) => string;
|
||||
}
|
||||
> = {
|
||||
startTime: {
|
||||
title: "Start time",
|
||||
formatValue: (v: string | boolean) =>
|
||||
formatTimestamp(new Date(v as string).valueOf() / 1000, useLocalTime),
|
||||
},
|
||||
CWD: { title: "Working directory" },
|
||||
reloadConfigSuccess: {
|
||||
title: "Configuration reload",
|
||||
formatValue: (v: string | boolean) => (v ? "Successful" : "Unsuccessful"),
|
||||
},
|
||||
lastConfigTime: {
|
||||
title: "Last successful configuration reload",
|
||||
formatValue: (v: string | boolean) =>
|
||||
formatTimestamp(new Date(v as string).valueOf() / 1000, useLocalTime),
|
||||
},
|
||||
corruptionCount: { title: "WAL corruptions" },
|
||||
goroutineCount: { title: "Goroutines" },
|
||||
storageRetention: { title: "Storage retention" },
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="lg" maw={1000} mx="auto" mt="xs">
|
||||
<Card shadow="xs" withBorder p="md">
|
||||
<Group wrap="nowrap" align="center" ml="xs" mb="sm" gap="xs">
|
||||
<IconWall size={22} />
|
||||
<Text fz="xl" fw={600}>
|
||||
Build information
|
||||
</Text>
|
||||
</Group>
|
||||
<Table layout="fixed">
|
||||
<Table.Tbody>
|
||||
{Object.entries(buildinfo.data).map(([k, v]) => (
|
||||
<Table.Tr key={k}>
|
||||
<Table.Th style={{ textTransform: "capitalize" }}>{k}</Table.Th>
|
||||
<Table.Td>{v}</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Card>
|
||||
<Card shadow="xs" withBorder p="md">
|
||||
<Group wrap="nowrap" align="center" ml="xs" mb="sm" gap="xs">
|
||||
<IconRun size={22} />
|
||||
<Text fz="xl" fw={600}>
|
||||
Runtime information
|
||||
</Text>
|
||||
</Group>
|
||||
<Table layout="fixed">
|
||||
<Table.Tbody>
|
||||
{Object.entries(runtimeinfo.data).map(([k, v]) => {
|
||||
const { title = k, formatValue = (val: string) => val } =
|
||||
statusConfig[k] || {};
|
||||
return (
|
||||
<Table.Tr key={k}>
|
||||
<Table.Th style={{ textTransform: "capitalize" }}>
|
||||
{title}
|
||||
</Table.Th>
|
||||
<Table.Td>{formatValue(v)}</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Card>
|
||||
</Stack>
|
||||
);
|
||||
}
|
105
web/ui/mantine-ui/src/pages/TSDBStatusPage.tsx
Normal file
105
web/ui/mantine-ui/src/pages/TSDBStatusPage.tsx
Normal file
|
@ -0,0 +1,105 @@
|
|||
import { Stack, Card, Table, Text } from "@mantine/core";
|
||||
import { useSuspenseAPIQuery } from "../api/api";
|
||||
import { TSDBStatusResult } from "../api/responseTypes/tsdbStatus";
|
||||
import { formatTimestamp } from "../lib/formatTime";
|
||||
import { useSettings } from "../state/settingsSlice";
|
||||
|
||||
export default function TSDBStatusPage() {
|
||||
const {
|
||||
data: {
|
||||
data: {
|
||||
headStats,
|
||||
labelValueCountByLabelName,
|
||||
seriesCountByMetricName,
|
||||
memoryInBytesByLabelName,
|
||||
seriesCountByLabelValuePair,
|
||||
},
|
||||
},
|
||||
} = useSuspenseAPIQuery<TSDBStatusResult>({ path: `/status/tsdb` });
|
||||
|
||||
const { useLocalTime } = useSettings();
|
||||
|
||||
const unixToTime = (unix: number): string => {
|
||||
const formatted = formatTimestamp(unix, useLocalTime);
|
||||
if (formatted === "Invalid Date") {
|
||||
if (numSeries === 0) {
|
||||
return "No datapoints yet";
|
||||
}
|
||||
return `Error parsing time (${unix})`;
|
||||
}
|
||||
|
||||
return formatted;
|
||||
};
|
||||
|
||||
const { chunkCount, numSeries, numLabelPairs, minTime, maxTime } = headStats;
|
||||
const stats = [
|
||||
{ name: "Number of Series", value: numSeries },
|
||||
{ name: "Number of Chunks", value: chunkCount },
|
||||
{ name: "Number of Label Pairs", value: numLabelPairs },
|
||||
{ name: "Current Min Time", value: `${unixToTime(minTime / 1000)}` },
|
||||
{ name: "Current Max Time", value: `${unixToTime(maxTime / 1000)}` },
|
||||
];
|
||||
|
||||
return (
|
||||
<Stack gap="lg" maw={1000} mx="auto" mt="xs">
|
||||
{[
|
||||
{
|
||||
title: "TSDB Head Status",
|
||||
stats,
|
||||
formatAsCode: false,
|
||||
},
|
||||
{
|
||||
title: "Top 10 label names with value count",
|
||||
stats: labelValueCountByLabelName,
|
||||
formatAsCode: true,
|
||||
},
|
||||
{
|
||||
title: "Top 10 series count by metric names",
|
||||
stats: seriesCountByMetricName,
|
||||
formatAsCode: true,
|
||||
},
|
||||
{
|
||||
title: "Top 10 label names with high memory usage",
|
||||
unit: "Bytes",
|
||||
stats: memoryInBytesByLabelName,
|
||||
formatAsCode: true,
|
||||
},
|
||||
{
|
||||
title: "Top 10 series count by label value pairs",
|
||||
stats: seriesCountByLabelValuePair,
|
||||
formatAsCode: true,
|
||||
},
|
||||
].map(({ title, unit = "Count", stats, formatAsCode }) => (
|
||||
<Card shadow="xs" withBorder p="md">
|
||||
<Text fz="xl" fw={600} ml="xs" mb="sm">
|
||||
{title}
|
||||
</Text>
|
||||
<Table layout="fixed">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Name</Table.Th>
|
||||
<Table.Th>{unit}</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{stats.map(({ name, value }) => {
|
||||
return (
|
||||
<Table.Tr key={name}>
|
||||
<Table.Td
|
||||
style={{
|
||||
wordBreak: "break-all",
|
||||
}}
|
||||
>
|
||||
{formatAsCode ? <code>{name}</code> : name}
|
||||
</Table.Td>
|
||||
<Table.Td>{value}</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Card>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
10
web/ui/mantine-ui/src/pages/query/DataTable.module.css
Normal file
10
web/ui/mantine-ui/src/pages/query/DataTable.module.css
Normal file
|
@ -0,0 +1,10 @@
|
|||
.tableWrapper {
|
||||
border: 1px solid
|
||||
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
|
||||
border-radius: var(--mantine-radius-default);
|
||||
}
|
||||
|
||||
.numberCell {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
214
web/ui/mantine-ui/src/pages/query/DataTable.tsx
Normal file
214
web/ui/mantine-ui/src/pages/query/DataTable.tsx
Normal file
|
@ -0,0 +1,214 @@
|
|||
import { FC, ReactNode, useState } from "react";
|
||||
import {
|
||||
Table,
|
||||
Alert,
|
||||
Box,
|
||||
SegmentedControl,
|
||||
ScrollArea,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
Anchor,
|
||||
} from "@mantine/core";
|
||||
import { IconAlertTriangle, IconInfoCircle } from "@tabler/icons-react";
|
||||
import {
|
||||
InstantQueryResult,
|
||||
InstantSample,
|
||||
RangeSamples,
|
||||
} from "../../api/responseTypes/query";
|
||||
import SeriesName from "./SeriesName";
|
||||
import classes from "./DataTable.module.css";
|
||||
import dayjs from "dayjs";
|
||||
import timezone from "dayjs/plugin/timezone";
|
||||
import { formatTimestamp } from "../../lib/formatTime";
|
||||
import HistogramChart from "./HistogramChart";
|
||||
import { Histogram } from "../../types/types";
|
||||
import { bucketRangeString } from "./HistogramHelpers";
|
||||
import { useSettings } from "../../state/settingsSlice";
|
||||
dayjs.extend(timezone);
|
||||
|
||||
const maxFormattableSeries = 1000;
|
||||
const maxDisplayableSeries = 10000;
|
||||
|
||||
const limitSeries = <S extends InstantSample | RangeSamples>(
|
||||
series: S[],
|
||||
limit: boolean
|
||||
): S[] => {
|
||||
if (limit && series.length > maxDisplayableSeries) {
|
||||
return series.slice(0, maxDisplayableSeries);
|
||||
}
|
||||
return series;
|
||||
};
|
||||
|
||||
export interface DataTableProps {
|
||||
data: InstantQueryResult;
|
||||
limitResults: boolean;
|
||||
setLimitResults: (limit: boolean) => void;
|
||||
}
|
||||
|
||||
const DataTable: FC<DataTableProps> = ({
|
||||
data,
|
||||
limitResults,
|
||||
setLimitResults,
|
||||
}) => {
|
||||
const [scale, setScale] = useState<string>("exponential");
|
||||
const { useLocalTime } = useSettings();
|
||||
|
||||
const { result, resultType } = data;
|
||||
const doFormat = result.length <= maxFormattableSeries;
|
||||
|
||||
return (
|
||||
<Stack gap="lg" mt={0}>
|
||||
{limitResults &&
|
||||
["vector", "matrix"].includes(resultType) &&
|
||||
result.length > maxDisplayableSeries && (
|
||||
<Alert
|
||||
color="orange"
|
||||
icon={<IconAlertTriangle size={14} />}
|
||||
title="Showing limited results"
|
||||
>
|
||||
Fetched {data.result.length} metrics, only displaying first{" "}
|
||||
{maxDisplayableSeries} for performance reasons.
|
||||
<Anchor ml="md" fz="1em" onClick={() => setLimitResults(false)}>
|
||||
Show all results
|
||||
</Anchor>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!doFormat && (
|
||||
<Alert
|
||||
title="Formatting turned off"
|
||||
icon={<IconInfoCircle size={14} />}
|
||||
>
|
||||
Showing more than {maxFormattableSeries} series, turning off label
|
||||
formatting to improve rendering performance.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Box pos="relative" className={classes.tableWrapper}>
|
||||
<Table fz="xs">
|
||||
<Table.Tbody>
|
||||
{resultType === "vector" ? (
|
||||
limitSeries<InstantSample>(result, limitResults).map((s, idx) => (
|
||||
<Table.Tr key={idx}>
|
||||
<Table.Td>
|
||||
<SeriesName labels={s.metric} format={doFormat} />
|
||||
</Table.Td>
|
||||
<Table.Td className={classes.numberCell}>
|
||||
{s.value && s.value[1]}
|
||||
{s.histogram && (
|
||||
<Stack>
|
||||
<HistogramChart
|
||||
histogram={s.histogram[1]}
|
||||
index={idx}
|
||||
scale={scale}
|
||||
/>
|
||||
<Group justify="space-between" align="center" p={10}>
|
||||
<Group align="center" gap="1rem">
|
||||
<span>
|
||||
<strong>Count:</strong> {s.histogram[1].count}
|
||||
</span>
|
||||
<span>
|
||||
<strong>Sum:</strong> {s.histogram[1].sum}
|
||||
</span>
|
||||
</Group>
|
||||
<Group align="center" gap="1rem">
|
||||
<span>x-axis scale:</span>
|
||||
<SegmentedControl
|
||||
size={"xs"}
|
||||
value={scale}
|
||||
onChange={setScale}
|
||||
data={["exponential", "linear"]}
|
||||
/>
|
||||
</Group>
|
||||
</Group>
|
||||
{histogramTable(s.histogram[1])}
|
||||
</Stack>
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))
|
||||
) : resultType === "matrix" ? (
|
||||
limitSeries<RangeSamples>(result, limitResults).map((s, idx) => (
|
||||
<Table.Tr key={idx}>
|
||||
<Table.Td>
|
||||
<SeriesName labels={s.metric} format={doFormat} />
|
||||
</Table.Td>
|
||||
<Table.Td className={classes.numberCell}>
|
||||
{s.values &&
|
||||
s.values.map((v, idx) => (
|
||||
<div key={idx}>
|
||||
{v[1]}{" "}
|
||||
<Text
|
||||
span
|
||||
c="gray.7"
|
||||
size="1em"
|
||||
title={formatTimestamp(v[0], useLocalTime)}
|
||||
>
|
||||
@ {v[0]}
|
||||
</Text>
|
||||
</div>
|
||||
))}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))
|
||||
) : resultType === "scalar" ? (
|
||||
<Table.Tr>
|
||||
<Table.Td>Scalar value</Table.Td>
|
||||
<Table.Td className={classes.numberCell}>{result[1]}</Table.Td>
|
||||
</Table.Tr>
|
||||
) : resultType === "string" ? (
|
||||
<Table.Tr>
|
||||
<Table.Td>String value</Table.Td>
|
||||
<Table.Td>{result[1]}</Table.Td>
|
||||
</Table.Tr>
|
||||
) : (
|
||||
<Alert
|
||||
color="red"
|
||||
title="Invalid query response"
|
||||
icon={<IconAlertTriangle size={14} />}
|
||||
>
|
||||
Invalid result value type
|
||||
</Alert>
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const histogramTable = (h: Histogram): ReactNode => (
|
||||
<Table withTableBorder fz="xs">
|
||||
<Table.Tbody
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<Table.Tr
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<Table.Th>Bucket range</Table.Th>
|
||||
<Table.Th>Count</Table.Th>
|
||||
</Table.Tr>
|
||||
<ScrollArea w={"100%"} h={265}>
|
||||
{h.buckets?.map((b, i) => (
|
||||
<Table.Tr key={i}>
|
||||
<Table.Td style={{ textAlign: "left" }}>
|
||||
{bucketRangeString(b)}
|
||||
</Table.Td>
|
||||
<Table.Td>{b[3]}</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</ScrollArea>
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
);
|
||||
|
||||
export default DataTable;
|
109
web/ui/mantine-ui/src/pages/query/ExplainViews/Aggregation.tsx
Normal file
109
web/ui/mantine-ui/src/pages/query/ExplainViews/Aggregation.tsx
Normal file
|
@ -0,0 +1,109 @@
|
|||
import { FC } from "react";
|
||||
import ASTNode, { Aggregation, aggregationType } from "../../../promql/ast";
|
||||
import { labelNameList } from "../../../promql/format";
|
||||
import { parsePrometheusFloat } from "../../../lib/formatFloatValue";
|
||||
import { Card, Text } from "@mantine/core";
|
||||
|
||||
const describeAggregationType = (
|
||||
aggrType: aggregationType,
|
||||
param: ASTNode | null
|
||||
) => {
|
||||
switch (aggrType) {
|
||||
case "sum":
|
||||
return "sums over the sample values of the input series";
|
||||
case "min":
|
||||
return "takes the minimum of the sample values of the input series";
|
||||
case "max":
|
||||
return "takes the maximum of the sample values of the input series";
|
||||
case "avg":
|
||||
return "calculates the average of the sample values of the input series";
|
||||
case "stddev":
|
||||
return "calculates the population standard deviation of the sample values of the input series";
|
||||
case "stdvar":
|
||||
return "calculates the population standard variation of the sample values of the input series";
|
||||
case "count":
|
||||
return "counts the number of input series";
|
||||
case "group":
|
||||
return "groups the input series by the supplied grouping labels, while setting the sample value to 1";
|
||||
case "count_values":
|
||||
if (param === null) {
|
||||
throw new Error(
|
||||
"encountered count_values() node without label parameter"
|
||||
);
|
||||
}
|
||||
if (param.type !== "stringLiteral") {
|
||||
throw new Error(
|
||||
"encountered count_values() node without string literal label parameter"
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
outputs one time series for each unique sample value in the input
|
||||
series (each counting the number of occurrences of that value and
|
||||
indicating the original value in the {labelNameList([param.val])}{" "}
|
||||
label)
|
||||
</>
|
||||
);
|
||||
case "bottomk":
|
||||
return "returns the bottom K series by value";
|
||||
case "topk":
|
||||
return "returns the top K series by value";
|
||||
case "quantile":
|
||||
if (param === null) {
|
||||
throw new Error(
|
||||
"encountered quantile() node without quantile parameter"
|
||||
);
|
||||
}
|
||||
if (param.type === "numberLiteral") {
|
||||
return `calculates the ${param.val}th quantile (${
|
||||
parsePrometheusFloat(param.val) * 100
|
||||
}th percentile) over the sample values of the input series`;
|
||||
}
|
||||
return "calculates a quantile over the sample values of the input series";
|
||||
|
||||
case "limitk":
|
||||
return "limits the output to K series";
|
||||
case "limit_ratio":
|
||||
return "limits the output to a ratio of the input series";
|
||||
default:
|
||||
throw new Error(`invalid aggregation type ${aggrType}`);
|
||||
}
|
||||
};
|
||||
|
||||
const describeAggregationGrouping = (grouping: string[], without: boolean) => {
|
||||
if (without) {
|
||||
return (
|
||||
<>aggregating away the [{labelNameList(grouping)}] label dimensions</>
|
||||
);
|
||||
}
|
||||
|
||||
if (grouping.length === 1) {
|
||||
return <>grouped by their {labelNameList(grouping)} label dimension</>;
|
||||
}
|
||||
|
||||
if (grouping.length > 1) {
|
||||
return <>grouped by their [{labelNameList(grouping)}] label dimensions</>;
|
||||
}
|
||||
|
||||
return "aggregating away any label dimensions";
|
||||
};
|
||||
|
||||
interface AggregationExplainViewProps {
|
||||
node: Aggregation;
|
||||
}
|
||||
|
||||
const AggregationExplainView: FC<AggregationExplainViewProps> = ({ node }) => {
|
||||
return (
|
||||
<Card withBorder>
|
||||
<Text fz="lg" fw={600} mb="md">
|
||||
Aggregation
|
||||
</Text>
|
||||
<Text fz="sm">
|
||||
This node {describeAggregationType(node.op, node.param)},{" "}
|
||||
{describeAggregationGrouping(node.grouping, node.without)}.
|
||||
</Text>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default AggregationExplainView;
|
|
@ -0,0 +1,106 @@
|
|||
import { FC } from "react";
|
||||
import { BinaryExpr } from "../../../../promql/ast";
|
||||
import serializeNode from "../../../../promql/serialize";
|
||||
import VectorScalarBinaryExprExplainView from "./VectorScalar";
|
||||
import VectorVectorBinaryExprExplainView from "./VectorVector";
|
||||
import ScalarScalarBinaryExprExplainView from "./ScalarScalar";
|
||||
import { nodeValueType } from "../../../../promql/utils";
|
||||
import { useSuspenseAPIQuery } from "../../../../api/api";
|
||||
import { InstantQueryResult } from "../../../../api/responseTypes/query";
|
||||
import { Card, Text } from "@mantine/core";
|
||||
|
||||
interface BinaryExprExplainViewProps {
|
||||
node: BinaryExpr;
|
||||
}
|
||||
|
||||
const BinaryExprExplainView: FC<BinaryExprExplainViewProps> = ({ node }) => {
|
||||
const { data: lhs } = useSuspenseAPIQuery<InstantQueryResult>({
|
||||
path: `/query`,
|
||||
params: {
|
||||
query: serializeNode(node.lhs),
|
||||
},
|
||||
});
|
||||
const { data: rhs } = useSuspenseAPIQuery<InstantQueryResult>({
|
||||
path: `/query`,
|
||||
params: {
|
||||
query: serializeNode(node.rhs),
|
||||
},
|
||||
});
|
||||
|
||||
if (
|
||||
lhs.data.resultType !== nodeValueType(node.lhs) ||
|
||||
rhs.data.resultType !== nodeValueType(node.rhs)
|
||||
) {
|
||||
// This can happen for a brief transitionary render when "node" has changed, but "lhs" and "rhs"
|
||||
// haven't switched back to loading yet (leading to a crash in e.g. the vector-vector explain view).
|
||||
return null;
|
||||
}
|
||||
|
||||
// Scalar-scalar binops.
|
||||
if (lhs.data.resultType === "scalar" && rhs.data.resultType === "scalar") {
|
||||
return (
|
||||
<Card withBorder>
|
||||
<Text fz="lg" fw={600} mb="md">
|
||||
Scalar-to-scalar binary operation
|
||||
</Text>
|
||||
<ScalarScalarBinaryExprExplainView
|
||||
node={node}
|
||||
lhs={lhs.data.result}
|
||||
rhs={rhs.data.result}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Vector-scalar binops.
|
||||
if (lhs.data.resultType === "scalar" && rhs.data.resultType === "vector") {
|
||||
return (
|
||||
<Card withBorder>
|
||||
<Text fz="lg" fw={600} mb="md">
|
||||
Scalar-to-vector binary operation
|
||||
</Text>
|
||||
<VectorScalarBinaryExprExplainView
|
||||
node={node}
|
||||
vector={rhs.data.result}
|
||||
scalar={lhs.data.result}
|
||||
scalarLeft={true}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
if (lhs.data.resultType === "vector" && rhs.data.resultType === "scalar") {
|
||||
return (
|
||||
<Card withBorder>
|
||||
<Text fz="lg" fw={600} mb="md">
|
||||
Vector-to-scalar binary operation
|
||||
</Text>
|
||||
<VectorScalarBinaryExprExplainView
|
||||
node={node}
|
||||
scalar={rhs.data.result}
|
||||
vector={lhs.data.result}
|
||||
scalarLeft={false}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Vector-vector binops.
|
||||
if (lhs.data.resultType === "vector" && rhs.data.resultType === "vector") {
|
||||
return (
|
||||
<Card withBorder>
|
||||
<Text fz="lg" fw={600} mb="md">
|
||||
Vector-to-vector binary operation
|
||||
</Text>
|
||||
<VectorVectorBinaryExprExplainView
|
||||
node={node}
|
||||
lhs={lhs.data.result}
|
||||
rhs={rhs.data.result}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error("invalid binary operator argument types");
|
||||
};
|
||||
|
||||
export default BinaryExprExplainView;
|
|
@ -0,0 +1,54 @@
|
|||
import { FC } from "react";
|
||||
import { BinaryExpr } from "../../../../promql/ast";
|
||||
import { scalarBinOp } from "../../../../promql/binOp";
|
||||
import { Table } from "@mantine/core";
|
||||
import { SampleValue } from "../../../../api/responseTypes/query";
|
||||
import {
|
||||
formatPrometheusFloat,
|
||||
parsePrometheusFloat,
|
||||
} from "../../../../lib/formatFloatValue";
|
||||
|
||||
interface ScalarScalarBinaryExprExplainViewProps {
|
||||
node: BinaryExpr;
|
||||
lhs: SampleValue;
|
||||
rhs: SampleValue;
|
||||
}
|
||||
|
||||
const ScalarScalarBinaryExprExplainView: FC<
|
||||
ScalarScalarBinaryExprExplainViewProps
|
||||
> = ({ node, lhs, rhs }) => {
|
||||
const [lhsVal, rhsVal] = [
|
||||
parsePrometheusFloat(lhs[1]),
|
||||
parsePrometheusFloat(rhs[1]),
|
||||
];
|
||||
|
||||
return (
|
||||
<Table withColumnBorders withTableBorder>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Left value</Table.Th>
|
||||
<Table.Th>Operator</Table.Th>
|
||||
<Table.Th>Right value</Table.Th>
|
||||
<Table.Th></Table.Th>
|
||||
<Table.Th>Result</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
<Table.Tr>
|
||||
<Table.Td className="number-cell">{lhs[1]}</Table.Td>
|
||||
<Table.Td className="op-cell">
|
||||
{node.op}
|
||||
{node.bool && " bool"}
|
||||
</Table.Td>
|
||||
<Table.Td className="number-cell">{rhs[1]}</Table.Td>
|
||||
<Table.Td className="op-cell">=</Table.Td>
|
||||
<Table.Td className="number-cell">
|
||||
{formatPrometheusFloat(scalarBinOp(node.op, lhsVal, rhsVal))}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScalarScalarBinaryExprExplainView;
|
|
@ -0,0 +1,104 @@
|
|||
import { FC } from "react";
|
||||
import { BinaryExpr } from "../../../../promql/ast";
|
||||
// import SeriesName from '../../../../utils/SeriesName';
|
||||
import { isComparisonOperator } from "../../../../promql/utils";
|
||||
import { vectorElemBinop } from "../../../../promql/binOp";
|
||||
import {
|
||||
InstantSample,
|
||||
SampleValue,
|
||||
} from "../../../../api/responseTypes/query";
|
||||
import { Alert, Table, Text } from "@mantine/core";
|
||||
import {
|
||||
formatPrometheusFloat,
|
||||
parsePrometheusFloat,
|
||||
} from "../../../../lib/formatFloatValue";
|
||||
import SeriesName from "../../SeriesName";
|
||||
|
||||
interface VectorScalarBinaryExprExplainViewProps {
|
||||
node: BinaryExpr;
|
||||
scalar: SampleValue;
|
||||
vector: InstantSample[];
|
||||
scalarLeft: boolean;
|
||||
}
|
||||
|
||||
const VectorScalarBinaryExprExplainView: FC<
|
||||
VectorScalarBinaryExprExplainViewProps
|
||||
> = ({ node, scalar, vector, scalarLeft }) => {
|
||||
if (vector.length === 0) {
|
||||
return (
|
||||
<Alert>
|
||||
One side of the binary operation produces 0 results, no matching
|
||||
information shown.
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Table withTableBorder withRowBorders withColumnBorders fz="xs">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
{!scalarLeft && <Table.Th>Left labels</Table.Th>}
|
||||
<Table.Th>Left value</Table.Th>
|
||||
<Table.Th>Operator</Table.Th>
|
||||
{scalarLeft && <Table.Th>Right labels</Table.Th>}
|
||||
<Table.Th>Right value</Table.Th>
|
||||
<Table.Th></Table.Th>
|
||||
<Table.Th>Result</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{vector.map((sample: InstantSample, idx) => {
|
||||
if (!sample.value) {
|
||||
// TODO: Handle native histograms or show a better error message.
|
||||
throw new Error("Native histograms are not supported yet");
|
||||
}
|
||||
|
||||
const vecVal = parsePrometheusFloat(sample.value[1]);
|
||||
const scalVal = parsePrometheusFloat(scalar[1]);
|
||||
|
||||
let { value, keep } = scalarLeft
|
||||
? vectorElemBinop(node.op, scalVal, vecVal)
|
||||
: vectorElemBinop(node.op, vecVal, scalVal);
|
||||
if (isComparisonOperator(node.op) && scalarLeft) {
|
||||
value = vecVal;
|
||||
}
|
||||
if (node.bool) {
|
||||
value = Number(keep);
|
||||
keep = true;
|
||||
}
|
||||
|
||||
const scalarCell = <Table.Td ta="right">{scalar[1]}</Table.Td>;
|
||||
const vectorCells = (
|
||||
<>
|
||||
<Table.Td>
|
||||
<SeriesName labels={sample.metric} format={true} />
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">{sample.value[1]}</Table.Td>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Table.Tr key={idx}>
|
||||
{scalarLeft ? scalarCell : vectorCells}
|
||||
<Table.Td ta="center">
|
||||
{node.op}
|
||||
{node.bool && " bool"}
|
||||
</Table.Td>
|
||||
{scalarLeft ? vectorCells : scalarCell}
|
||||
<Table.Td ta="center">=</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
{keep ? (
|
||||
formatPrometheusFloat(value)
|
||||
) : (
|
||||
<Text c="dimmed">dropped</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
);
|
||||
};
|
||||
|
||||
export default VectorScalarBinaryExprExplainView;
|
|
@ -0,0 +1,679 @@
|
|||
import React, { FC, useState } from "react";
|
||||
import { BinaryExpr, vectorMatchCardinality } from "../../../../promql/ast";
|
||||
import { InstantSample, Metric } from "../../../../api/responseTypes/query";
|
||||
import { isComparisonOperator, isSetOperator } from "../../../../promql/utils";
|
||||
import {
|
||||
VectorMatchError,
|
||||
BinOpMatchGroup,
|
||||
MatchErrorType,
|
||||
computeVectorVectorBinOp,
|
||||
filteredSampleValue,
|
||||
} from "../../../../promql/binOp";
|
||||
import { formatNode, labelNameList } from "../../../../promql/format";
|
||||
import {
|
||||
Alert,
|
||||
Anchor,
|
||||
Box,
|
||||
Group,
|
||||
List,
|
||||
Switch,
|
||||
Table,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { useLocalStorage } from "@mantine/hooks";
|
||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
||||
import SeriesName from "../../SeriesName";
|
||||
|
||||
// We use this color pool for two purposes:
|
||||
//
|
||||
// 1. To distinguish different match groups from each other.
|
||||
// 2. To distinguish multiple series within one match group from each other.
|
||||
const colorPool = [
|
||||
"#1f77b4",
|
||||
"#ff7f0e",
|
||||
"#2ca02c",
|
||||
"#d62728",
|
||||
"#9467bd",
|
||||
"#8c564b",
|
||||
"#e377c2",
|
||||
"#7f7f7f",
|
||||
"#bcbd22",
|
||||
"#17becf",
|
||||
"#393b79",
|
||||
"#637939",
|
||||
"#8c6d31",
|
||||
"#843c39",
|
||||
"#d6616b",
|
||||
"#7b4173",
|
||||
"#ce6dbd",
|
||||
"#9c9ede",
|
||||
"#c5b0d5",
|
||||
"#c49c94",
|
||||
"#f7b6d2",
|
||||
"#c7c7c7",
|
||||
"#dbdb8d",
|
||||
"#9edae5",
|
||||
"#393b79",
|
||||
"#637939",
|
||||
"#8c6d31",
|
||||
"#843c39",
|
||||
"#d6616b",
|
||||
"#7b4173",
|
||||
"#ce6dbd",
|
||||
"#9c9ede",
|
||||
"#c5b0d5",
|
||||
"#c49c94",
|
||||
"#f7b6d2",
|
||||
"#c7c7c7",
|
||||
"#dbdb8d",
|
||||
"#9edae5",
|
||||
"#17becf",
|
||||
"#393b79",
|
||||
"#637939",
|
||||
"#8c6d31",
|
||||
"#843c39",
|
||||
"#d6616b",
|
||||
"#7b4173",
|
||||
"#ce6dbd",
|
||||
"#9c9ede",
|
||||
"#c5b0d5",
|
||||
"#c49c94",
|
||||
"#f7b6d2",
|
||||
];
|
||||
|
||||
const rhsColorOffset = colorPool.length / 2 + 3;
|
||||
const colorForIndex = (idx: number, offset?: number) =>
|
||||
`${colorPool[(idx + (offset || 0)) % colorPool.length]}80`;
|
||||
|
||||
const seriesSwatch = (color: string) => (
|
||||
<Box
|
||||
display="inline-block"
|
||||
w={12}
|
||||
h={12}
|
||||
bg={color}
|
||||
style={{
|
||||
borderRadius: 2,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
interface VectorVectorBinaryExprExplainViewProps {
|
||||
node: BinaryExpr;
|
||||
lhs: InstantSample[];
|
||||
rhs: InstantSample[];
|
||||
}
|
||||
|
||||
const noMatchLabels = (
|
||||
metric: Metric,
|
||||
on: boolean,
|
||||
labels: string[]
|
||||
): Metric => {
|
||||
const result: Metric = {};
|
||||
for (const name in metric) {
|
||||
if (!(labels.includes(name) === on && (on || name !== "__name__"))) {
|
||||
result[name] = metric[name];
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const explanationText = (node: BinaryExpr): React.ReactNode => {
|
||||
const matching = node.matching!;
|
||||
const [oneSide, manySide] =
|
||||
matching.card === vectorMatchCardinality.oneToMany
|
||||
? ["left", "right"]
|
||||
: ["right", "left"];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text size="sm">
|
||||
{isComparisonOperator(node.op) ? (
|
||||
<>
|
||||
This node filters the series from the left-hand side based on the
|
||||
result of a "
|
||||
<span className="promql-code promql-operator">{node.op}</span>"
|
||||
comparison with matching series from the right-hand side.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
This node calculates the result of applying the "
|
||||
<span className="promql-code promql-operator">{node.op}</span>"
|
||||
operator between the sample values of matching series from two sets
|
||||
of time series.
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
<List my="md" fz="sm" withPadding>
|
||||
{(matching.labels.length > 0 || matching.on) &&
|
||||
(matching.on ? (
|
||||
<List.Item>
|
||||
<span className="promql-code promql-keyword">on</span>(
|
||||
{labelNameList(matching.labels)}):{" "}
|
||||
{matching.labels.length > 0 ? (
|
||||
<>
|
||||
series on both sides are matched on the labels{" "}
|
||||
{labelNameList(matching.labels)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
all series from one side are matched to all series on the
|
||||
other side.
|
||||
</>
|
||||
)}
|
||||
</List.Item>
|
||||
) : (
|
||||
<List.Item>
|
||||
<span className="promql-code promql-keyword">ignoring</span>(
|
||||
{labelNameList(matching.labels)}): series on both sides are
|
||||
matched on all of their labels, except{" "}
|
||||
{labelNameList(matching.labels)}.
|
||||
</List.Item>
|
||||
))}
|
||||
{matching.card === vectorMatchCardinality.oneToOne ? (
|
||||
<List.Item>
|
||||
One-to-one match. Each series from the left-hand side is allowed to
|
||||
match with at most one series on the right-hand side, and vice
|
||||
versa.
|
||||
</List.Item>
|
||||
) : (
|
||||
<List.Item>
|
||||
<span className="promql-code promql-keyword">
|
||||
group_{manySide}({labelNameList(matching.include)})
|
||||
</span>
|
||||
: {matching.card} match. Each series from the {oneSide}-hand side is
|
||||
allowed to match with multiple series from the {manySide}-hand side.
|
||||
{matching.include.length !== 0 && (
|
||||
<>
|
||||
{" "}
|
||||
Any {labelNameList(matching.include)} labels found on the{" "}
|
||||
{oneSide}-hand side are propagated into the result, in addition
|
||||
to the match group's labels.
|
||||
</>
|
||||
)}
|
||||
</List.Item>
|
||||
)}
|
||||
{node.bool && (
|
||||
<List.Item>
|
||||
<span className="promql-code promql-keyword">bool</span>: Instead of
|
||||
filtering series based on the outcome of the comparison for matched
|
||||
series, keep all series, but return the comparison outcome as a
|
||||
boolean <span className="promql-code promql-number">0</span> or{" "}
|
||||
<span className="promql-code promql-number">1</span> sample value.
|
||||
</List.Item>
|
||||
)}
|
||||
</List>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const explainError = (
|
||||
binOp: BinaryExpr,
|
||||
_mg: BinOpMatchGroup,
|
||||
err: VectorMatchError
|
||||
) => {
|
||||
const fixes = (
|
||||
<>
|
||||
<Text size="sm">
|
||||
<strong>Possible fixes:</strong>
|
||||
</Text>
|
||||
<List withPadding my="md" fz="sm">
|
||||
{err.type === MatchErrorType.multipleMatchesForOneToOneMatching && (
|
||||
<List.Item>
|
||||
<Text size="sm">
|
||||
<strong>
|
||||
Allow {err.dupeSide === "left" ? "many-to-one" : "one-to-many"}{" "}
|
||||
matching
|
||||
</strong>
|
||||
: If you want to allow{" "}
|
||||
{err.dupeSide === "left" ? "many-to-one" : "one-to-many"}{" "}
|
||||
matching, you need to explicitly request it by adding a{" "}
|
||||
<span className="promql-code promql-keyword">
|
||||
group_{err.dupeSide}()
|
||||
</span>{" "}
|
||||
modifier to the operator:
|
||||
</Text>
|
||||
<Text size="sm" ta="center" my="md">
|
||||
{formatNode(
|
||||
{
|
||||
...binOp,
|
||||
matching: {
|
||||
...(binOp.matching
|
||||
? binOp.matching
|
||||
: { labels: [], on: false, include: [] }),
|
||||
card:
|
||||
err.dupeSide === "left"
|
||||
? vectorMatchCardinality.manyToOne
|
||||
: vectorMatchCardinality.oneToMany,
|
||||
},
|
||||
},
|
||||
true,
|
||||
1
|
||||
)}
|
||||
</Text>
|
||||
</List.Item>
|
||||
)}
|
||||
<List.Item>
|
||||
<strong>Update your matching parameters:</strong> Consider including
|
||||
more differentiating labels in your matching modifiers (via{" "}
|
||||
<span className="promql-code promql-keyword">on()</span> /{" "}
|
||||
<span className="promql-code promql-keyword">ignoring()</span>) to
|
||||
split multiple series into distinct match groups.
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
<strong>Aggregate the input:</strong> Consider aggregating away the
|
||||
extra labels that create multiple series per group before applying the
|
||||
binary operation.
|
||||
</List.Item>
|
||||
</List>
|
||||
</>
|
||||
);
|
||||
|
||||
switch (err.type) {
|
||||
case MatchErrorType.multipleMatchesForOneToOneMatching:
|
||||
return (
|
||||
<>
|
||||
<Text size="sm">
|
||||
Binary operators only allow <strong>one-to-one</strong> matching by
|
||||
default, but we found{" "}
|
||||
<strong>multiple series on the {err.dupeSide} side</strong> for this
|
||||
match group.
|
||||
</Text>
|
||||
{fixes}
|
||||
</>
|
||||
);
|
||||
case MatchErrorType.multipleMatchesOnBothSides:
|
||||
return (
|
||||
<>
|
||||
<Text size="sm">
|
||||
We found <strong>multiple series on both sides</strong> for this
|
||||
match group. Since <strong>many-to-many matching</strong> is not
|
||||
supported, you need to ensure that at least one of the sides only
|
||||
yields a single series.
|
||||
</Text>
|
||||
{fixes}
|
||||
</>
|
||||
);
|
||||
case MatchErrorType.multipleMatchesOnOneSide: {
|
||||
const [oneSide, manySide] =
|
||||
binOp.matching!.card === vectorMatchCardinality.oneToMany
|
||||
? ["left", "right"]
|
||||
: ["right", "left"];
|
||||
return (
|
||||
<>
|
||||
<Text size="sm">
|
||||
You requested{" "}
|
||||
<strong>
|
||||
{oneSide === "right" ? "many-to-one" : "one-to-many"} matching
|
||||
</strong>{" "}
|
||||
via{" "}
|
||||
<span className="promql-code promql-keyword">
|
||||
group_{manySide}()
|
||||
</span>
|
||||
, but we also found{" "}
|
||||
<strong>multiple series on the {oneSide} side</strong> of the match
|
||||
group. Make sure that the {oneSide} side only contains a single
|
||||
series.
|
||||
</Text>
|
||||
{fixes}
|
||||
</>
|
||||
);
|
||||
}
|
||||
default:
|
||||
throw new Error("unknown match error");
|
||||
}
|
||||
};
|
||||
|
||||
const VectorVectorBinaryExprExplainView: FC<
|
||||
VectorVectorBinaryExprExplainViewProps
|
||||
> = ({ node, lhs, rhs }) => {
|
||||
// TODO: Don't use Mantine's local storage as a one-off here. Decide whether we
|
||||
// want to keep Redux, and then do it only via one or the other everywhere.
|
||||
const [showSampleValues, setShowSampleValues] = useLocalStorage<boolean>({
|
||||
key: "queryPage.explain.binaryOperators.showSampleValues",
|
||||
defaultValue: false,
|
||||
});
|
||||
|
||||
const [maxGroups, setMaxGroups] = useState<number | undefined>(100);
|
||||
const [maxSeriesPerGroup, setMaxSeriesPerGroup] = useState<
|
||||
number | undefined
|
||||
>(100);
|
||||
|
||||
const { matching } = node;
|
||||
if (matching === null) {
|
||||
// The parent should make sure to only pass in vector-vector binops that have their "matching" field filled out.
|
||||
throw new Error("missing matching parameters in vector-to-vector binop");
|
||||
}
|
||||
|
||||
const { groups: matchGroups, numGroups } = computeVectorVectorBinOp(
|
||||
node.op,
|
||||
matching,
|
||||
node.bool,
|
||||
lhs,
|
||||
rhs,
|
||||
{
|
||||
maxGroups: maxGroups,
|
||||
maxSeriesPerGroup: maxSeriesPerGroup,
|
||||
}
|
||||
);
|
||||
const errCount = Object.values(matchGroups).filter((mg) => mg.error).length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text size="sm">{explanationText(node)}</Text>
|
||||
|
||||
{!isSetOperator(node.op) && (
|
||||
<>
|
||||
<Group my="lg" justify="flex-end" gap="xl">
|
||||
{/* <Switch
|
||||
label="Break long lines"
|
||||
checked={allowLineBreaks}
|
||||
onChange={(event) =>
|
||||
setAllowLineBreaks(event.currentTarget.checked)
|
||||
}
|
||||
/> */}
|
||||
<Switch
|
||||
label="Show sample values"
|
||||
checked={showSampleValues}
|
||||
onChange={(event) =>
|
||||
setShowSampleValues(event.currentTarget.checked)
|
||||
}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{numGroups > Object.keys(matchGroups).length && (
|
||||
<Alert
|
||||
color="yellow"
|
||||
mb="md"
|
||||
icon={<IconAlertTriangle size={14} />}
|
||||
>
|
||||
Too many match groups to display, only showing{" "}
|
||||
{Object.keys(matchGroups).length} out of {numGroups} groups.
|
||||
<br />
|
||||
<br />
|
||||
<Anchor fz="sm" onClick={() => setMaxGroups(undefined)}>
|
||||
Show all groups
|
||||
</Anchor>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{errCount > 0 && (
|
||||
<Alert
|
||||
color="yellow"
|
||||
mb="md"
|
||||
icon={<IconAlertTriangle size={14} />}
|
||||
>
|
||||
Found matching issues in {errCount} match group
|
||||
{errCount > 1 ? "s" : ""}. See below for per-group error details.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Table fz="xs" withRowBorders={false}>
|
||||
<Table.Tbody>
|
||||
{Object.values(matchGroups).map((mg, mgIdx) => {
|
||||
const {
|
||||
groupLabels,
|
||||
lhs,
|
||||
lhsCount,
|
||||
rhs,
|
||||
rhsCount,
|
||||
result,
|
||||
error,
|
||||
} = mg;
|
||||
|
||||
const matchGroupTitleRow = (color: string) => (
|
||||
<Table.Tr ta="center">
|
||||
<Table.Td
|
||||
colSpan={2}
|
||||
style={{ backgroundColor: `${color}25` }}
|
||||
>
|
||||
<SeriesName labels={groupLabels} format={true} />
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
|
||||
const matchGroupTable = (
|
||||
series: InstantSample[],
|
||||
seriesCount: number,
|
||||
color: string,
|
||||
colorOffset?: number
|
||||
) => (
|
||||
<Box
|
||||
style={{
|
||||
borderRadius: 3,
|
||||
border: "2px solid",
|
||||
borderColor:
|
||||
series.length === 0
|
||||
? "light-dark(var(--mantine-color-gray-4), var(--mantine-color-gray-7))"
|
||||
: color,
|
||||
}}
|
||||
>
|
||||
<Table fz="xs" withRowBorders={false} verticalSpacing={5}>
|
||||
<Table.Tbody>
|
||||
{series.length === 0 ? (
|
||||
<Table.Tr>
|
||||
<Table.Td
|
||||
ta="center"
|
||||
c="light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-5))"
|
||||
py="md"
|
||||
fw="bold"
|
||||
>
|
||||
no matching series
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
) : (
|
||||
<>
|
||||
{matchGroupTitleRow(color)}
|
||||
{series.map((s, sIdx) => {
|
||||
if (s.value === undefined) {
|
||||
// TODO: Figure out how to handle native histograms.
|
||||
throw new Error(
|
||||
"Native histograms are not supported yet"
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Table.Tr key={sIdx}>
|
||||
<Table.Td>
|
||||
<Group wrap="nowrap" gap={7} align="center">
|
||||
{seriesSwatch(
|
||||
colorForIndex(sIdx, colorOffset)
|
||||
)}
|
||||
|
||||
<SeriesName
|
||||
labels={noMatchLabels(
|
||||
s.metric,
|
||||
matching.on,
|
||||
matching.labels
|
||||
)}
|
||||
format={true}
|
||||
/>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
{showSampleValues && (
|
||||
<Table.Td ta="right">{s.value[1]}</Table.Td>
|
||||
)}
|
||||
</Table.Tr>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
{seriesCount > series.length && (
|
||||
<Table.Tr>
|
||||
<Table.Td ta="center" py="md" fw="bold" c="gray.6">
|
||||
{seriesCount - series.length} more series omitted
|
||||
–
|
||||
<Anchor
|
||||
size="xs"
|
||||
onClick={() => setMaxSeriesPerGroup(undefined)}
|
||||
>
|
||||
Show all series
|
||||
</Anchor>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
);
|
||||
|
||||
const noLHSMatches = lhs.length === 0;
|
||||
const noRHSMatches = rhs.length === 0;
|
||||
|
||||
const groupColor = colorPool[mgIdx % colorPool.length];
|
||||
|
||||
const lhsTable = matchGroupTable(lhs, lhsCount, groupColor);
|
||||
const rhsTable = matchGroupTable(
|
||||
rhs,
|
||||
rhsCount,
|
||||
groupColor,
|
||||
rhsColorOffset
|
||||
);
|
||||
|
||||
const resultTable = (
|
||||
<Box
|
||||
style={{
|
||||
borderRadius: 3,
|
||||
border: `2px solid`,
|
||||
borderColor:
|
||||
noLHSMatches || noRHSMatches || error !== null
|
||||
? "light-dark(var(--mantine-color-gray-4), var(--mantine-color-gray-7))"
|
||||
: groupColor,
|
||||
}}
|
||||
>
|
||||
<Table fz="xs" withRowBorders={false} verticalSpacing={5}>
|
||||
<Table.Tbody>
|
||||
{noLHSMatches || noRHSMatches ? (
|
||||
<Table.Tr>
|
||||
<Table.Td
|
||||
ta="center"
|
||||
c="light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-5))"
|
||||
py="md"
|
||||
fw="bold"
|
||||
>
|
||||
dropped
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
) : error !== null ? (
|
||||
<Table.Tr>
|
||||
<Table.Td
|
||||
ta="center"
|
||||
c="light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-5))"
|
||||
py="md"
|
||||
fw="bold"
|
||||
>
|
||||
error, result omitted
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
) : (
|
||||
<>
|
||||
{result.map(({ sample, manySideIdx }, resIdx) => {
|
||||
if (sample.value === undefined) {
|
||||
// TODO: Figure out how to handle native histograms.
|
||||
throw new Error(
|
||||
"Native histograms are not supported yet"
|
||||
);
|
||||
}
|
||||
|
||||
const filtered =
|
||||
sample.value[1] === filteredSampleValue;
|
||||
const [lIdx, rIdx] =
|
||||
matching.card ===
|
||||
vectorMatchCardinality.oneToMany
|
||||
? [0, manySideIdx]
|
||||
: [manySideIdx, 0];
|
||||
|
||||
return (
|
||||
<Table.Tr key={resIdx}>
|
||||
<Table.Td
|
||||
style={{ opacity: filtered ? 0.5 : 1 }}
|
||||
title={
|
||||
filtered
|
||||
? "Series has been filtered by comparison operator"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Group
|
||||
wrap="nowrap"
|
||||
gap="xs"
|
||||
align="flex-start"
|
||||
>
|
||||
<Group wrap="nowrap" gap={0}>
|
||||
{seriesSwatch(colorForIndex(lIdx))}
|
||||
<span style={{ color: "#aaa" }}>–</span>
|
||||
{seriesSwatch(
|
||||
colorForIndex(rIdx, rhsColorOffset)
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<SeriesName
|
||||
labels={sample.metric}
|
||||
format={true}
|
||||
/>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
{showSampleValues && (
|
||||
<Table.Td ta="right">
|
||||
{filtered ? (
|
||||
<span style={{ color: "grey" }}>
|
||||
filtered
|
||||
</span>
|
||||
) : (
|
||||
<span>{sample.value[1]}</span>
|
||||
)}
|
||||
</Table.Td>
|
||||
)}
|
||||
</Table.Tr>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<React.Fragment key={mgIdx}>
|
||||
{mgIdx !== 0 && <tr style={{ height: 30 }}></tr>}
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={5}>
|
||||
{error && (
|
||||
<Alert
|
||||
color="red"
|
||||
mb="md"
|
||||
title="Error in match group below"
|
||||
icon={<IconAlertTriangle size={14} />}
|
||||
>
|
||||
{explainError(node, mg, error)}
|
||||
</Alert>
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
<Table.Tr>
|
||||
<Table.Td valign="middle" p={0}>
|
||||
{lhsTable}
|
||||
</Table.Td>
|
||||
<Table.Td ta="center">
|
||||
{node.op}
|
||||
{node.bool && " bool"}
|
||||
</Table.Td>
|
||||
<Table.Td valign="middle" p={0}>
|
||||
{rhsTable}
|
||||
</Table.Td>
|
||||
<Table.Td ta="center">=</Table.Td>
|
||||
<Table.Td valign="middle" p={0}>
|
||||
{resultTable}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default VectorVectorBinaryExprExplainView;
|
|
@ -0,0 +1,8 @@
|
|||
.funcDoc code {
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-gray-1),
|
||||
var(--mantine-color-dark-5)
|
||||
);
|
||||
padding: 0.05em 0.2em;
|
||||
border-radius: 0.2em;
|
||||
}
|
201
web/ui/mantine-ui/src/pages/query/ExplainViews/ExplainView.tsx
Normal file
201
web/ui/mantine-ui/src/pages/query/ExplainViews/ExplainView.tsx
Normal file
|
@ -0,0 +1,201 @@
|
|||
import { FC } from "react";
|
||||
import { Alert, Text, Anchor, Card, Divider } from "@mantine/core";
|
||||
import ASTNode, { nodeType } from "../../../promql/ast";
|
||||
// import AggregationExplainView from "./Aggregation";
|
||||
// import BinaryExprExplainView from "./BinaryExpr/BinaryExpr";
|
||||
// import SelectorExplainView from "./Selector";
|
||||
import funcDocs from "../../../promql/functionDocs";
|
||||
import { escapeString } from "../../../promql/utils";
|
||||
import { formatPrometheusDuration } from "../../../lib/formatTime";
|
||||
import classes from "./ExplainView.module.css";
|
||||
import SelectorExplainView from "./Selector";
|
||||
import AggregationExplainView from "./Aggregation";
|
||||
import BinaryExprExplainView from "./BinaryExpr/BinaryExpr";
|
||||
import { IconInfoCircle } from "@tabler/icons-react";
|
||||
interface ExplainViewProps {
|
||||
node: ASTNode | null;
|
||||
treeShown: boolean;
|
||||
showTree: () => void;
|
||||
}
|
||||
|
||||
const ExplainView: FC<ExplainViewProps> = ({
|
||||
node,
|
||||
treeShown,
|
||||
showTree: setShowTree,
|
||||
}) => {
|
||||
if (node === null) {
|
||||
return (
|
||||
<Alert title="How to use the Explain view" icon={<IconInfoCircle />}>
|
||||
This tab can help you understand the behavior of individual components
|
||||
of a query.
|
||||
<br />
|
||||
<br />
|
||||
To use the Explain view,{" "}
|
||||
{!treeShown && (
|
||||
<>
|
||||
<Anchor fz="unset" onClick={setShowTree}>
|
||||
enable the query tree view
|
||||
</Anchor>{" "}
|
||||
(also available via the expression input menu) and then
|
||||
</>
|
||||
)}{" "}
|
||||
select a node in the tree above.
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
switch (node.type) {
|
||||
case nodeType.aggregation:
|
||||
return <AggregationExplainView node={node} />;
|
||||
case nodeType.binaryExpr:
|
||||
return <BinaryExprExplainView node={node} />;
|
||||
case nodeType.call:
|
||||
return (
|
||||
<Card withBorder>
|
||||
<Text fz="lg" fw={600} mb="md">
|
||||
Function call
|
||||
</Text>
|
||||
<Text fz="sm">
|
||||
This node calls the{" "}
|
||||
<Anchor
|
||||
fz="inherit"
|
||||
href={`https://prometheus.io/docs/prometheus/latest/querying/functions/#${node.func.name}`}
|
||||
target="_blank"
|
||||
>
|
||||
<span className="promql-code promql-keyword">
|
||||
{node.func.name}()
|
||||
</span>
|
||||
</Anchor>{" "}
|
||||
function{node.args.length > 0 ? " on the provided inputs" : ""}.
|
||||
</Text>
|
||||
<Divider my="md" />
|
||||
{/* TODO: Some docs, like x_over_time, have relative links pointing back to the Prometheus docs,
|
||||
make sure to modify those links in the docs extraction so they work from the explain view */}
|
||||
<Text fz="sm" className={classes.funcDoc}>
|
||||
{funcDocs[node.func.name]}
|
||||
</Text>
|
||||
</Card>
|
||||
);
|
||||
case nodeType.matrixSelector:
|
||||
return <SelectorExplainView node={node} />;
|
||||
case nodeType.subquery:
|
||||
return (
|
||||
<Card withBorder>
|
||||
<Text fz="lg" fw={600} mb="md">
|
||||
Subquery
|
||||
</Text>
|
||||
<Text fz="sm">
|
||||
This node evaluates the passed expression as a subquery over the
|
||||
last{" "}
|
||||
<span className="promql-code promql-duration">
|
||||
{formatPrometheusDuration(node.range)}
|
||||
</span>{" "}
|
||||
at a query resolution{" "}
|
||||
{node.step > 0 ? (
|
||||
<>
|
||||
of{" "}
|
||||
<span className="promql-code promql-duration">
|
||||
{formatPrometheusDuration(node.step)}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
"equal to the default rule evaluation interval"
|
||||
)}
|
||||
{node.timestamp !== null ? (
|
||||
<>
|
||||
, evaluated relative to an absolute evaluation timestamp of{" "}
|
||||
<span className="promql-number">
|
||||
{(node.timestamp / 1000).toFixed(3)}
|
||||
</span>
|
||||
</>
|
||||
) : node.startOrEnd !== null ? (
|
||||
<>
|
||||
, evaluated relative to the {node.startOrEnd} of the query range
|
||||
</>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
{node.offset === 0 ? (
|
||||
<></>
|
||||
) : node.offset > 0 ? (
|
||||
<>
|
||||
, time-shifted{" "}
|
||||
<span className="promql-code promql-duration">
|
||||
{formatPrometheusDuration(node.offset)}
|
||||
</span>{" "}
|
||||
into the past
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
, time-shifted{" "}
|
||||
<span className="promql-code promql-duration">
|
||||
{formatPrometheusDuration(-node.offset)}
|
||||
</span>{" "}
|
||||
into the future
|
||||
</>
|
||||
)}
|
||||
.
|
||||
</Text>
|
||||
</Card>
|
||||
);
|
||||
case nodeType.numberLiteral:
|
||||
return (
|
||||
<Card withBorder>
|
||||
<Text fz="lg" fw={600} mb="md">
|
||||
Number literal
|
||||
</Text>
|
||||
<Text fz="sm">
|
||||
A scalar number literal with the value{" "}
|
||||
<span className="promql-code promql-number">{node.val}</span>.
|
||||
</Text>
|
||||
</Card>
|
||||
);
|
||||
case nodeType.parenExpr:
|
||||
return (
|
||||
<Card withBorder>
|
||||
<Text fz="lg" fw={600} mb="md">
|
||||
Parentheses
|
||||
</Text>
|
||||
<Text fz="sm">
|
||||
Parentheses that contain a sub-expression to be evaluated.
|
||||
</Text>
|
||||
</Card>
|
||||
);
|
||||
case nodeType.stringLiteral:
|
||||
return (
|
||||
<Card withBorder>
|
||||
<Text fz="lg" fw={600} mb="md">
|
||||
String literal
|
||||
</Text>
|
||||
<Text fz="sm">
|
||||
A string literal with the value{" "}
|
||||
<span className="promql-code promql-string">
|
||||
"{escapeString(node.val)}"
|
||||
</span>
|
||||
.
|
||||
</Text>
|
||||
</Card>
|
||||
);
|
||||
case nodeType.unaryExpr:
|
||||
return (
|
||||
<Card withBorder>
|
||||
<Text fz="lg" fw={600} mb="md">
|
||||
Unary expression
|
||||
</Text>
|
||||
<Text fz="sm">
|
||||
A unary expression that{" "}
|
||||
{node.op === "+"
|
||||
? "does not affect the expression it is applied to"
|
||||
: "changes the sign of the expression it is applied to"}
|
||||
.
|
||||
</Text>
|
||||
</Card>
|
||||
);
|
||||
case nodeType.vectorSelector:
|
||||
return <SelectorExplainView node={node} />;
|
||||
default:
|
||||
throw new Error("invalid node type");
|
||||
}
|
||||
};
|
||||
|
||||
export default ExplainView;
|
230
web/ui/mantine-ui/src/pages/query/ExplainViews/Selector.tsx
Normal file
230
web/ui/mantine-ui/src/pages/query/ExplainViews/Selector.tsx
Normal file
|
@ -0,0 +1,230 @@
|
|||
import { FC, ReactNode } from "react";
|
||||
import {
|
||||
VectorSelector,
|
||||
MatrixSelector,
|
||||
nodeType,
|
||||
LabelMatcher,
|
||||
matchType,
|
||||
} from "../../../promql/ast";
|
||||
import { escapeString } from "../../../promql/utils";
|
||||
import { useSuspenseAPIQuery } from "../../../api/api";
|
||||
import { Card, Text, Divider, List } from "@mantine/core";
|
||||
import { MetadataResult } from "../../../api/responseTypes/metadata";
|
||||
import { formatPrometheusDuration } from "../../../lib/formatTime";
|
||||
|
||||
interface SelectorExplainViewProps {
|
||||
node: VectorSelector | MatrixSelector;
|
||||
}
|
||||
|
||||
const matchingCriteriaList = (
|
||||
name: string,
|
||||
matchers: LabelMatcher[]
|
||||
): ReactNode => {
|
||||
return (
|
||||
<List fz="sm" my="md" withPadding>
|
||||
{name.length > 0 && (
|
||||
<List.Item>
|
||||
The metric name is{" "}
|
||||
<span className="promql-code promql-metric-name">{name}</span>.
|
||||
</List.Item>
|
||||
)}
|
||||
{matchers
|
||||
.filter((m) => !(m.name === "__name__"))
|
||||
.map((m) => {
|
||||
switch (m.type) {
|
||||
case matchType.equal:
|
||||
return (
|
||||
<List.Item>
|
||||
<span className="promql-code promql-label-name">
|
||||
{m.name}
|
||||
</span>
|
||||
<span className="promql-code promql-operator">{m.type}</span>
|
||||
<span className="promql-code promql-string">
|
||||
"{escapeString(m.value)}"
|
||||
</span>
|
||||
: The label{" "}
|
||||
<span className="promql-code promql-label-name">
|
||||
{m.name}
|
||||
</span>{" "}
|
||||
is exactly{" "}
|
||||
<span className="promql-code promql-string">
|
||||
"{escapeString(m.value)}"
|
||||
</span>
|
||||
.
|
||||
</List.Item>
|
||||
);
|
||||
case matchType.notEqual:
|
||||
return (
|
||||
<List.Item>
|
||||
<span className="promql-code promql-label-name">
|
||||
{m.name}
|
||||
</span>
|
||||
<span className="promql-code promql-operator">{m.type}</span>
|
||||
<span className="promql-code promql-string">
|
||||
"{escapeString(m.value)}"
|
||||
</span>
|
||||
: The label{" "}
|
||||
<span className="promql-code promql-label-name">
|
||||
{m.name}
|
||||
</span>{" "}
|
||||
is not{" "}
|
||||
<span className="promql-code promql-string">
|
||||
"{escapeString(m.value)}"
|
||||
</span>
|
||||
.
|
||||
</List.Item>
|
||||
);
|
||||
case matchType.matchRegexp:
|
||||
return (
|
||||
<List.Item>
|
||||
<span className="promql-code promql-label-name">
|
||||
{m.name}
|
||||
</span>
|
||||
<span className="promql-code promql-operator">{m.type}</span>
|
||||
<span className="promql-code promql-string">
|
||||
"{escapeString(m.value)}"
|
||||
</span>
|
||||
: The label{" "}
|
||||
<span className="promql-code promql-label-name">
|
||||
{m.name}
|
||||
</span>{" "}
|
||||
matches the regular expression{" "}
|
||||
<span className="promql-code promql-string">
|
||||
"{escapeString(m.value)}"
|
||||
</span>
|
||||
.
|
||||
</List.Item>
|
||||
);
|
||||
case matchType.matchNotRegexp:
|
||||
return (
|
||||
<List.Item>
|
||||
<span className="promql-code promql-label-name">
|
||||
{m.name}
|
||||
</span>
|
||||
<span className="promql-code promql-operator">{m.type}</span>
|
||||
<span className="promql-code promql-string">
|
||||
"{escapeString(m.value)}"
|
||||
</span>
|
||||
: The label{" "}
|
||||
<span className="promql-code promql-label-name">
|
||||
{m.name}
|
||||
</span>{" "}
|
||||
does not match the regular expression{" "}
|
||||
<span className="promql-code promql-string">
|
||||
"{escapeString(m.value)}"
|
||||
</span>
|
||||
.
|
||||
</List.Item>
|
||||
);
|
||||
default:
|
||||
throw new Error("invalid matcher type");
|
||||
}
|
||||
})}
|
||||
</List>
|
||||
);
|
||||
};
|
||||
|
||||
const SelectorExplainView: FC<SelectorExplainViewProps> = ({ node }) => {
|
||||
const baseMetricName = node.name.replace(/(_count|_sum|_bucket)$/, "");
|
||||
const { data: metricMeta } = useSuspenseAPIQuery<MetadataResult>({
|
||||
path: `/metadata`,
|
||||
params: {
|
||||
metric: baseMetricName,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Card withBorder>
|
||||
<Text fz="lg" fw={600} mb="md">
|
||||
{node.type === nodeType.vectorSelector ? "Instant" : "Range"} vector
|
||||
selector
|
||||
</Text>
|
||||
<Text fz="sm">
|
||||
{metricMeta.data === undefined ||
|
||||
metricMeta.data[baseMetricName] === undefined ||
|
||||
metricMeta.data[baseMetricName].length < 1 ? (
|
||||
<>No metric metadata found.</>
|
||||
) : (
|
||||
<>
|
||||
<strong>Metric help</strong>:{" "}
|
||||
{metricMeta.data[baseMetricName][0].help}
|
||||
<br />
|
||||
<strong>Metric type</strong>:{" "}
|
||||
{metricMeta.data[baseMetricName][0].type}
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
<Divider my="md" />
|
||||
<Text fz="sm">
|
||||
{node.type === nodeType.vectorSelector ? (
|
||||
<>
|
||||
This node selects the latest (non-stale) sample value within the
|
||||
last <span className="promql-code promql-duration">5m</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
This node selects{" "}
|
||||
<span className="promql-code promql-duration">
|
||||
{formatPrometheusDuration(node.range)}
|
||||
</span>{" "}
|
||||
of data going backward from the evaluation timestamp
|
||||
</>
|
||||
)}
|
||||
{node.timestamp !== null ? (
|
||||
<>
|
||||
, evaluated relative to an absolute evaluation timestamp of{" "}
|
||||
<span className="promql-number">
|
||||
{(node.timestamp / 1000).toFixed(3)}
|
||||
</span>
|
||||
</>
|
||||
) : node.startOrEnd !== null ? (
|
||||
<>, evaluated relative to the {node.startOrEnd} of the query range</>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
{node.offset === 0 ? (
|
||||
<></>
|
||||
) : node.offset > 0 ? (
|
||||
<>
|
||||
, time-shifted{" "}
|
||||
<span className="promql-code promql-duration">
|
||||
{formatPrometheusDuration(node.offset)}
|
||||
</span>{" "}
|
||||
into the past,
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
, time-shifted{" "}
|
||||
<span className="promql-code promql-duration">
|
||||
{formatPrometheusDuration(-node.offset)}
|
||||
</span>{" "}
|
||||
into the future,
|
||||
</>
|
||||
)}{" "}
|
||||
for any series that match all of the following criteria:
|
||||
</Text>
|
||||
{matchingCriteriaList(node.name, node.matchers)}
|
||||
<Text fz="sm">
|
||||
If a series has no values in the last{" "}
|
||||
<span className="promql-code promql-duration">
|
||||
{node.type === nodeType.vectorSelector
|
||||
? "5m"
|
||||
: formatPrometheusDuration(node.range)}
|
||||
</span>
|
||||
{node.offset > 0 && (
|
||||
<>
|
||||
{" "}
|
||||
(relative to the time-shifted instant{" "}
|
||||
<span className="promql-code promql-duration">
|
||||
{formatPrometheusDuration(node.offset)}
|
||||
</span>{" "}
|
||||
in the past)
|
||||
</>
|
||||
)}
|
||||
, the series will not be returned.
|
||||
</Text>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default SelectorExplainView;
|
13
web/ui/mantine-ui/src/pages/query/ExpressionInput.module.css
Normal file
13
web/ui/mantine-ui/src/pages/query/ExpressionInput.module.css
Normal file
|
@ -0,0 +1,13 @@
|
|||
.input {
|
||||
/* border: calc(0.0625rem * var(--mantine-scale)) solid var(--input-bd); */
|
||||
border-radius: var(--mantine-radius-default);
|
||||
flex: auto;
|
||||
/* padding: 4px 0 0 8px; */
|
||||
/* font-size: 15px; */
|
||||
/* font-family: "DejaVu Sans Mono"; */
|
||||
|
||||
&:focus-within {
|
||||
outline: rem(1.3px) solid var(--mantine-color-blue-filled);
|
||||
border-color: transparent;
|
||||
}
|
||||
}
|
384
web/ui/mantine-ui/src/pages/query/ExpressionInput.tsx
Normal file
384
web/ui/mantine-ui/src/pages/query/ExpressionInput.tsx
Normal file
|
@ -0,0 +1,384 @@
|
|||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
InputBase,
|
||||
Loader,
|
||||
Menu,
|
||||
Modal,
|
||||
rem,
|
||||
Skeleton,
|
||||
useComputedColorScheme,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
CompleteStrategy,
|
||||
PromQLExtension,
|
||||
newCompleteStrategy,
|
||||
} from "@prometheus-io/codemirror-promql";
|
||||
import { FC, Suspense, useEffect, useRef, useState } from "react";
|
||||
import CodeMirror, {
|
||||
EditorState,
|
||||
EditorView,
|
||||
Prec,
|
||||
ReactCodeMirrorRef,
|
||||
highlightSpecialChars,
|
||||
keymap,
|
||||
placeholder,
|
||||
} from "@uiw/react-codemirror";
|
||||
import {
|
||||
baseTheme,
|
||||
darkPromqlHighlighter,
|
||||
darkTheme,
|
||||
lightTheme,
|
||||
promqlHighlighter,
|
||||
} from "../../codemirror/theme";
|
||||
import {
|
||||
bracketMatching,
|
||||
indentOnInput,
|
||||
syntaxHighlighting,
|
||||
syntaxTree,
|
||||
} from "@codemirror/language";
|
||||
import classes from "./ExpressionInput.module.css";
|
||||
import {
|
||||
CompletionContext,
|
||||
CompletionResult,
|
||||
autocompletion,
|
||||
closeBrackets,
|
||||
closeBracketsKeymap,
|
||||
completionKeymap,
|
||||
} from "@codemirror/autocomplete";
|
||||
import {
|
||||
defaultKeymap,
|
||||
history,
|
||||
historyKeymap,
|
||||
insertNewlineAndIndent,
|
||||
} from "@codemirror/commands";
|
||||
import { highlightSelectionMatches } from "@codemirror/search";
|
||||
import { lintKeymap } from "@codemirror/lint";
|
||||
import {
|
||||
IconAlignJustified,
|
||||
IconBinaryTree,
|
||||
IconDotsVertical,
|
||||
IconSearch,
|
||||
IconTerminal,
|
||||
IconTrash,
|
||||
} from "@tabler/icons-react";
|
||||
import { useAPIQuery } from "../../api/api";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useSettings } from "../../state/settingsSlice";
|
||||
import MetricsExplorer from "./MetricsExplorer/MetricsExplorer";
|
||||
import ErrorBoundary from "../../components/ErrorBoundary";
|
||||
import { useAppSelector } from "../../state/hooks";
|
||||
|
||||
const promqlExtension = new PromQLExtension();
|
||||
|
||||
// Autocompletion strategy that wraps the main one and enriches
|
||||
// it with past query items.
|
||||
export class HistoryCompleteStrategy implements CompleteStrategy {
|
||||
private complete: CompleteStrategy;
|
||||
private queryHistory: string[];
|
||||
constructor(complete: CompleteStrategy, queryHistory: string[]) {
|
||||
this.complete = complete;
|
||||
this.queryHistory = queryHistory;
|
||||
}
|
||||
|
||||
promQL(
|
||||
context: CompletionContext
|
||||
): Promise<CompletionResult | null> | CompletionResult | null {
|
||||
return Promise.resolve(this.complete.promQL(context)).then((res) => {
|
||||
const { state, pos } = context;
|
||||
const tree = syntaxTree(state).resolve(pos, -1);
|
||||
const start = res != null ? res.from : tree.from;
|
||||
|
||||
if (start !== 0) {
|
||||
return res;
|
||||
}
|
||||
|
||||
const historyItems: CompletionResult = {
|
||||
from: start,
|
||||
to: pos,
|
||||
options: this.queryHistory.map((q) => ({
|
||||
label: q.length < 80 ? q : q.slice(0, 76).concat("..."),
|
||||
detail: "past query",
|
||||
apply: q,
|
||||
info: q.length < 80 ? undefined : q,
|
||||
})),
|
||||
validFor: /^[a-zA-Z0-9_:]+$/,
|
||||
};
|
||||
|
||||
if (res !== null) {
|
||||
historyItems.options = historyItems.options.concat(res.options);
|
||||
}
|
||||
return historyItems;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
interface ExpressionInputProps {
|
||||
initialExpr: string;
|
||||
metricNames: string[];
|
||||
executeQuery: (expr: string) => void;
|
||||
treeShown: boolean;
|
||||
setShowTree: (showTree: boolean) => void;
|
||||
removePanel: () => void;
|
||||
}
|
||||
|
||||
const ExpressionInput: FC<ExpressionInputProps> = ({
|
||||
initialExpr,
|
||||
metricNames,
|
||||
executeQuery,
|
||||
removePanel,
|
||||
treeShown,
|
||||
setShowTree,
|
||||
}) => {
|
||||
const theme = useComputedColorScheme();
|
||||
const { queryHistory } = useAppSelector((state) => state.queryPage);
|
||||
const {
|
||||
pathPrefix,
|
||||
enableAutocomplete,
|
||||
enableSyntaxHighlighting,
|
||||
enableLinter,
|
||||
enableQueryHistory,
|
||||
} = useSettings();
|
||||
const [expr, setExpr] = useState(initialExpr);
|
||||
useEffect(() => {
|
||||
setExpr(initialExpr);
|
||||
}, [initialExpr]);
|
||||
|
||||
const {
|
||||
data: formatResult,
|
||||
error: formatError,
|
||||
isFetching: isFormatting,
|
||||
refetch: formatQuery,
|
||||
} = useAPIQuery<string>({
|
||||
path: "/format_query",
|
||||
params: {
|
||||
query: expr,
|
||||
},
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (formatError) {
|
||||
notifications.show({
|
||||
color: "red",
|
||||
title: "Error formatting query",
|
||||
message: formatError.message,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (formatResult) {
|
||||
setExpr(formatResult.data);
|
||||
notifications.show({
|
||||
title: "Expression formatted",
|
||||
message: "Expression formatted successfully!",
|
||||
});
|
||||
}
|
||||
}, [formatResult, formatError]);
|
||||
|
||||
const cmRef = useRef<ReactCodeMirrorRef>(null);
|
||||
|
||||
const [showMetricsExplorer, setShowMetricsExplorer] = useState(false);
|
||||
|
||||
// (Re)initialize editor based on settings / setting changes.
|
||||
useEffect(() => {
|
||||
// Build the dynamic part of the config.
|
||||
promqlExtension
|
||||
.activateCompletion(enableAutocomplete)
|
||||
.activateLinter(enableLinter)
|
||||
.setComplete({
|
||||
completeStrategy: new HistoryCompleteStrategy(
|
||||
newCompleteStrategy({
|
||||
remote: {
|
||||
url: pathPrefix,
|
||||
cache: { initialMetricList: metricNames },
|
||||
},
|
||||
}),
|
||||
enableQueryHistory ? queryHistory : []
|
||||
),
|
||||
});
|
||||
}, [
|
||||
pathPrefix,
|
||||
metricNames,
|
||||
enableAutocomplete,
|
||||
enableLinter,
|
||||
enableQueryHistory,
|
||||
queryHistory,
|
||||
]); // TODO: Maybe use dynamic config compartment again as in the old UI?
|
||||
|
||||
return (
|
||||
<Group align="flex-start" wrap="nowrap" gap="xs">
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
<InputBase<any>
|
||||
leftSection={
|
||||
isFormatting ? <Loader size="xs" color="gray.5" /> : <IconTerminal />
|
||||
}
|
||||
rightSection={
|
||||
<Menu shadow="md" width={200}>
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
size="lg"
|
||||
variant="transparent"
|
||||
color="gray"
|
||||
aria-label="Show query options"
|
||||
>
|
||||
<IconDotsVertical style={{ width: "1rem", height: "1rem" }} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Label>Query options</Menu.Label>
|
||||
<Menu.Item
|
||||
leftSection={
|
||||
<IconSearch style={{ width: rem(14), height: rem(14) }} />
|
||||
}
|
||||
onClick={() => setShowMetricsExplorer(true)}
|
||||
>
|
||||
Explore metrics
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={
|
||||
<IconAlignJustified
|
||||
style={{ width: rem(14), height: rem(14) }}
|
||||
/>
|
||||
}
|
||||
onClick={() => formatQuery()}
|
||||
disabled={
|
||||
isFormatting || expr === "" || expr === formatResult?.data
|
||||
}
|
||||
>
|
||||
Format expression
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={
|
||||
<IconBinaryTree style={{ width: rem(14), height: rem(14) }} />
|
||||
}
|
||||
onClick={() => setShowTree(!treeShown)}
|
||||
>
|
||||
{treeShown ? "Hide" : "Show"} tree view
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={
|
||||
<IconTrash style={{ width: rem(14), height: rem(14) }} />
|
||||
}
|
||||
onClick={removePanel}
|
||||
>
|
||||
Remove query
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
}
|
||||
component={CodeMirror}
|
||||
className={classes.input}
|
||||
basicSetup={false}
|
||||
value={expr}
|
||||
onChange={setExpr}
|
||||
autoFocus
|
||||
ref={cmRef}
|
||||
extensions={[
|
||||
baseTheme,
|
||||
highlightSpecialChars(),
|
||||
history(),
|
||||
EditorState.allowMultipleSelections.of(true),
|
||||
indentOnInput(),
|
||||
bracketMatching(),
|
||||
closeBrackets(),
|
||||
autocompletion(),
|
||||
highlightSelectionMatches(),
|
||||
EditorView.lineWrapping,
|
||||
keymap.of([
|
||||
...closeBracketsKeymap,
|
||||
...defaultKeymap,
|
||||
...historyKeymap,
|
||||
...completionKeymap,
|
||||
...lintKeymap,
|
||||
]),
|
||||
placeholder("Enter expression (press Shift+Enter for newlines)"),
|
||||
enableSyntaxHighlighting
|
||||
? syntaxHighlighting(
|
||||
theme === "light" ? promqlHighlighter : darkPromqlHighlighter
|
||||
)
|
||||
: [],
|
||||
promqlExtension.asExtension(),
|
||||
theme === "light" ? lightTheme : darkTheme,
|
||||
keymap.of([
|
||||
{
|
||||
key: "Escape",
|
||||
run: (v: EditorView): boolean => {
|
||||
v.contentDOM.blur();
|
||||
return false;
|
||||
},
|
||||
},
|
||||
]),
|
||||
Prec.highest(
|
||||
keymap.of([
|
||||
{
|
||||
key: "Enter",
|
||||
run: (): boolean => {
|
||||
executeQuery(expr);
|
||||
return true;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "Shift-Enter",
|
||||
run: insertNewlineAndIndent,
|
||||
},
|
||||
])
|
||||
),
|
||||
]}
|
||||
multiline
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => executeQuery(expr)}
|
||||
// Without this, the button can be squeezed to a width
|
||||
// that doesn't fit its text when the window is too narrow.
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
Execute
|
||||
</Button>
|
||||
<Modal
|
||||
size="95%"
|
||||
opened={showMetricsExplorer}
|
||||
onClose={() => setShowMetricsExplorer(false)}
|
||||
title="Explore metrics"
|
||||
>
|
||||
<ErrorBoundary key={location.pathname} title="Error showing metrics">
|
||||
<Suspense
|
||||
fallback={
|
||||
<Box mt="lg">
|
||||
{Array.from(Array(20), (_, i) => (
|
||||
<Skeleton key={i} height={30} mb={15} width="100%" />
|
||||
))}
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<MetricsExplorer
|
||||
metricNames={metricNames}
|
||||
insertText={(text: string) => {
|
||||
if (cmRef.current && cmRef.current.view) {
|
||||
const view = cmRef.current.view;
|
||||
view.dispatch(
|
||||
view.state.update({
|
||||
changes: {
|
||||
from: view.state.selection.ranges[0].from,
|
||||
to: view.state.selection.ranges[0].to,
|
||||
insert: text,
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
}}
|
||||
close={() => setShowMetricsExplorer(false)}
|
||||
/>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</Modal>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExpressionInput;
|
367
web/ui/mantine-ui/src/pages/query/ExpressionInput.tsx.old
Normal file
367
web/ui/mantine-ui/src/pages/query/ExpressionInput.tsx.old
Normal file
|
@ -0,0 +1,367 @@
|
|||
import React, { FC, useState, useEffect, useRef } from "react";
|
||||
|
||||
import {
|
||||
EditorView,
|
||||
highlightSpecialChars,
|
||||
keymap,
|
||||
ViewUpdate,
|
||||
placeholder,
|
||||
} from "@codemirror/view";
|
||||
import { EditorState, Prec, Compartment } from "@codemirror/state";
|
||||
import {
|
||||
bracketMatching,
|
||||
indentOnInput,
|
||||
syntaxHighlighting,
|
||||
syntaxTree,
|
||||
} from "@codemirror/language";
|
||||
import {
|
||||
defaultKeymap,
|
||||
history,
|
||||
historyKeymap,
|
||||
insertNewlineAndIndent,
|
||||
} from "@codemirror/commands";
|
||||
import { highlightSelectionMatches } from "@codemirror/search";
|
||||
import { lintKeymap } from "@codemirror/lint";
|
||||
import {
|
||||
autocompletion,
|
||||
completionKeymap,
|
||||
CompletionContext,
|
||||
CompletionResult,
|
||||
closeBrackets,
|
||||
closeBracketsKeymap,
|
||||
} from "@codemirror/autocomplete";
|
||||
import {
|
||||
baseTheme,
|
||||
lightTheme,
|
||||
darkTheme,
|
||||
promqlHighlighter,
|
||||
darkPromqlHighlighter,
|
||||
} from "../../codemirror/theme";
|
||||
|
||||
import {
|
||||
CompleteStrategy,
|
||||
PromQLExtension,
|
||||
} from "@prometheus-io/codemirror-promql";
|
||||
import { newCompleteStrategy } from "@prometheus-io/codemirror-promql/dist/esm/complete";
|
||||
|
||||
const promqlExtension = new PromQLExtension();
|
||||
|
||||
interface ExpressionInputProps {
|
||||
value: string;
|
||||
onChange: (expr: string) => void;
|
||||
queryHistory: string[];
|
||||
metricNames: string[];
|
||||
executeQuery: () => void;
|
||||
}
|
||||
|
||||
const dynamicConfigCompartment = new Compartment();
|
||||
|
||||
// Autocompletion strategy that wraps the main one and enriches
|
||||
// it with past query items.
|
||||
export class HistoryCompleteStrategy implements CompleteStrategy {
|
||||
private complete: CompleteStrategy;
|
||||
private queryHistory: string[];
|
||||
constructor(complete: CompleteStrategy, queryHistory: string[]) {
|
||||
this.complete = complete;
|
||||
this.queryHistory = queryHistory;
|
||||
}
|
||||
|
||||
promQL(
|
||||
context: CompletionContext
|
||||
): Promise<CompletionResult | null> | CompletionResult | null {
|
||||
return Promise.resolve(this.complete.promQL(context)).then((res) => {
|
||||
const { state, pos } = context;
|
||||
const tree = syntaxTree(state).resolve(pos, -1);
|
||||
const start = res != null ? res.from : tree.from;
|
||||
|
||||
if (start !== 0) {
|
||||
return res;
|
||||
}
|
||||
|
||||
const historyItems: CompletionResult = {
|
||||
from: start,
|
||||
to: pos,
|
||||
options: this.queryHistory.map((q) => ({
|
||||
label: q.length < 80 ? q : q.slice(0, 76).concat("..."),
|
||||
detail: "past query",
|
||||
apply: q,
|
||||
info: q.length < 80 ? undefined : q,
|
||||
})),
|
||||
validFor: /^[a-zA-Z0-9_:]+$/,
|
||||
};
|
||||
|
||||
if (res !== null) {
|
||||
historyItems.options = historyItems.options.concat(res.options);
|
||||
}
|
||||
return historyItems;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const ExpressionInput: FC<ExpressionInputProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
queryHistory,
|
||||
metricNames,
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const viewRef = useRef<EditorView | null>(null);
|
||||
const [showMetricsExplorer, setShowMetricsExplorer] =
|
||||
useState<boolean>(false);
|
||||
const pathPrefix = usePathPrefix();
|
||||
const { theme } = useTheme();
|
||||
|
||||
const [formatError, setFormatError] = useState<string | null>(null);
|
||||
const [isFormatting, setIsFormatting] = useState<boolean>(false);
|
||||
const [exprFormatted, setExprFormatted] = useState<boolean>(false);
|
||||
|
||||
// (Re)initialize editor based on settings / setting changes.
|
||||
useEffect(() => {
|
||||
// Build the dynamic part of the config.
|
||||
promqlExtension
|
||||
.activateCompletion(enableAutocomplete)
|
||||
.activateLinter(enableLinter)
|
||||
.setComplete({
|
||||
completeStrategy: new HistoryCompleteStrategy(
|
||||
newCompleteStrategy({
|
||||
remote: {
|
||||
url: pathPrefix,
|
||||
cache: { initialMetricList: metricNames },
|
||||
},
|
||||
}),
|
||||
queryHistory
|
||||
),
|
||||
});
|
||||
|
||||
let highlighter = syntaxHighlighting(
|
||||
theme === "dark" ? darkPromqlHighlighter : promqlHighlighter
|
||||
);
|
||||
if (theme === "dark") {
|
||||
highlighter = syntaxHighlighting(darkPromqlHighlighter);
|
||||
}
|
||||
|
||||
const dynamicConfig = [
|
||||
enableHighlighting ? highlighter : [],
|
||||
promqlExtension.asExtension(),
|
||||
theme === "dark" ? darkTheme : lightTheme,
|
||||
];
|
||||
|
||||
// Create or reconfigure the editor.
|
||||
const view = viewRef.current;
|
||||
if (view === null) {
|
||||
// If the editor does not exist yet, create it.
|
||||
if (!containerRef.current) {
|
||||
throw new Error("expected CodeMirror container element to exist");
|
||||
}
|
||||
|
||||
const startState = EditorState.create({
|
||||
doc: value,
|
||||
extensions: [
|
||||
baseTheme,
|
||||
highlightSpecialChars(),
|
||||
history(),
|
||||
EditorState.allowMultipleSelections.of(true),
|
||||
indentOnInput(),
|
||||
bracketMatching(),
|
||||
closeBrackets(),
|
||||
autocompletion(),
|
||||
highlightSelectionMatches(),
|
||||
EditorView.lineWrapping,
|
||||
keymap.of([
|
||||
...closeBracketsKeymap,
|
||||
...defaultKeymap,
|
||||
...historyKeymap,
|
||||
...completionKeymap,
|
||||
...lintKeymap,
|
||||
]),
|
||||
placeholder("Expression (press Shift+Enter for newlines)"),
|
||||
dynamicConfigCompartment.of(dynamicConfig),
|
||||
// This keymap is added without precedence so that closing the autocomplete dropdown
|
||||
// via Escape works without blurring the editor.
|
||||
keymap.of([
|
||||
{
|
||||
key: "Escape",
|
||||
run: (v: EditorView): boolean => {
|
||||
v.contentDOM.blur();
|
||||
return false;
|
||||
},
|
||||
},
|
||||
]),
|
||||
Prec.highest(
|
||||
keymap.of([
|
||||
{
|
||||
key: "Enter",
|
||||
run: (v: EditorView): boolean => {
|
||||
executeQuery();
|
||||
return true;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "Shift-Enter",
|
||||
run: insertNewlineAndIndent,
|
||||
},
|
||||
])
|
||||
),
|
||||
EditorView.updateListener.of((update: ViewUpdate): void => {
|
||||
if (update.docChanged) {
|
||||
onExpressionChange(update.state.doc.toString());
|
||||
setExprFormatted(false);
|
||||
}
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const view = new EditorView({
|
||||
state: startState,
|
||||
parent: containerRef.current,
|
||||
});
|
||||
|
||||
viewRef.current = view;
|
||||
|
||||
view.focus();
|
||||
} else {
|
||||
// The editor already exists, just reconfigure the dynamically configured parts.
|
||||
view.dispatch(
|
||||
view.state.update({
|
||||
effects: dynamicConfigCompartment.reconfigure(dynamicConfig),
|
||||
})
|
||||
);
|
||||
}
|
||||
// "value" is only used in the initial render, so we don't want to
|
||||
// re-run this effect every time that "value" changes.
|
||||
//
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
enableAutocomplete,
|
||||
enableHighlighting,
|
||||
enableLinter,
|
||||
executeQuery,
|
||||
onExpressionChange,
|
||||
queryHistory,
|
||||
theme,
|
||||
]);
|
||||
|
||||
const insertAtCursor = (value: string) => {
|
||||
const view = viewRef.current;
|
||||
if (view === null) {
|
||||
return;
|
||||
}
|
||||
const { from, to } = view.state.selection.ranges[0];
|
||||
view.dispatch(
|
||||
view.state.update({
|
||||
changes: { from, to, insert: value },
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const formatExpression = () => {
|
||||
setFormatError(null);
|
||||
setIsFormatting(true);
|
||||
|
||||
fetch(
|
||||
`${pathPrefix}/${API_PATH}/format_query?${new URLSearchParams({
|
||||
query: value,
|
||||
})}`,
|
||||
{
|
||||
cache: "no-store",
|
||||
credentials: "same-origin",
|
||||
}
|
||||
)
|
||||
.then((resp) => {
|
||||
if (!resp.ok && resp.status !== 400) {
|
||||
throw new Error(`format HTTP request failed: ${resp.statusText}`);
|
||||
}
|
||||
|
||||
return resp.json();
|
||||
})
|
||||
.then((json) => {
|
||||
if (json.status !== "success") {
|
||||
throw new Error(json.error || "invalid response JSON");
|
||||
}
|
||||
|
||||
const view = viewRef.current;
|
||||
if (view === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
view.dispatch(
|
||||
view.state.update({
|
||||
changes: { from: 0, to: view.state.doc.length, insert: json.data },
|
||||
})
|
||||
);
|
||||
setExprFormatted(true);
|
||||
})
|
||||
.catch((err) => {
|
||||
setFormatError(err.message);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsFormatting(false);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<InputGroup className="expression-input">
|
||||
<InputGroupAddon addonType="prepend">
|
||||
<InputGroupText>
|
||||
{loading ? (
|
||||
<FontAwesomeIcon icon={faSpinner} spin />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faSearch} />
|
||||
)}
|
||||
</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
<div ref={containerRef} className="cm-expression-input" />
|
||||
<InputGroupAddon addonType="append">
|
||||
<Button
|
||||
className="expression-input-action-btn"
|
||||
title={
|
||||
isFormatting
|
||||
? "Formatting expression"
|
||||
: exprFormatted
|
||||
? "Expression formatted"
|
||||
: "Format expression"
|
||||
}
|
||||
onClick={formatExpression}
|
||||
disabled={isFormatting || exprFormatted}
|
||||
>
|
||||
{isFormatting ? (
|
||||
<FontAwesomeIcon icon={faSpinner} spin />
|
||||
) : exprFormatted ? (
|
||||
<FontAwesomeIcon icon={faCheck} />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faIndent} />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
className="expression-input-action-btn"
|
||||
title="Open metrics explorer"
|
||||
onClick={() => setShowMetricsExplorer(true)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faGlobeEurope} />
|
||||
</Button>
|
||||
<Button
|
||||
className="execute-btn"
|
||||
color="primary"
|
||||
onClick={executeQuery}
|
||||
>
|
||||
Execute
|
||||
</Button>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
|
||||
{formatError && (
|
||||
<Alert color="danger">Error formatting expression: {formatError}</Alert>
|
||||
)}
|
||||
|
||||
<MetricsExplorer
|
||||
show={showMetricsExplorer}
|
||||
updateShow={setShowMetricsExplorer}
|
||||
metrics={metricNames}
|
||||
insertAtCursor={insertAtCursor}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExpressionInput;
|
11
web/ui/mantine-ui/src/pages/query/Graph.module.css
Normal file
11
web/ui/mantine-ui/src/pages/query/Graph.module.css
Normal file
|
@ -0,0 +1,11 @@
|
|||
.chartWrapper {
|
||||
border: 1px solid
|
||||
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
|
||||
border-radius: var(--mantine-radius-default);
|
||||
}
|
||||
|
||||
.uplotChart {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 15px;
|
||||
}
|
192
web/ui/mantine-ui/src/pages/query/Graph.tsx
Normal file
192
web/ui/mantine-ui/src/pages/query/Graph.tsx
Normal file
|
@ -0,0 +1,192 @@
|
|||
import { FC, useEffect, useId, useState } from "react";
|
||||
import { Alert, Skeleton, Box, LoadingOverlay, Stack } from "@mantine/core";
|
||||
import { IconAlertTriangle, IconInfoCircle } from "@tabler/icons-react";
|
||||
import { RangeQueryResult } from "../../api/responseTypes/query";
|
||||
import { SuccessAPIResponse, useAPIQuery } from "../../api/api";
|
||||
import classes from "./Graph.module.css";
|
||||
import {
|
||||
GraphDisplayMode,
|
||||
GraphResolution,
|
||||
getEffectiveResolution,
|
||||
} from "../../state/queryPageSlice";
|
||||
import "uplot/dist/uPlot.min.css";
|
||||
import "./uplot.css";
|
||||
import { useElementSize } from "@mantine/hooks";
|
||||
import UPlotChart, { UPlotChartRange } from "./UPlotChart";
|
||||
import ASTNode, { nodeType } from "../../promql/ast";
|
||||
import serializeNode from "../../promql/serialize";
|
||||
|
||||
export interface GraphProps {
|
||||
expr: string;
|
||||
node: ASTNode | null;
|
||||
endTime: number | null;
|
||||
range: number;
|
||||
resolution: GraphResolution;
|
||||
showExemplars: boolean;
|
||||
displayMode: GraphDisplayMode;
|
||||
retriggerIdx: number;
|
||||
onSelectRange: (start: number, end: number) => void;
|
||||
}
|
||||
|
||||
const Graph: FC<GraphProps> = ({
|
||||
expr,
|
||||
node,
|
||||
endTime,
|
||||
range,
|
||||
resolution,
|
||||
showExemplars,
|
||||
displayMode,
|
||||
retriggerIdx,
|
||||
onSelectRange,
|
||||
}) => {
|
||||
const { ref, width } = useElementSize();
|
||||
const [rerender, setRerender] = useState(true);
|
||||
|
||||
const effectiveExpr =
|
||||
node === null
|
||||
? expr
|
||||
: serializeNode(
|
||||
node.type === nodeType.matrixSelector
|
||||
? {
|
||||
type: nodeType.vectorSelector,
|
||||
name: node.name,
|
||||
matchers: node.matchers,
|
||||
offset: node.offset,
|
||||
timestamp: node.timestamp,
|
||||
startOrEnd: node.startOrEnd,
|
||||
}
|
||||
: node
|
||||
);
|
||||
|
||||
const effectiveEndTime = (endTime !== null ? endTime : Date.now()) / 1000;
|
||||
const startTime = effectiveEndTime - range / 1000;
|
||||
const effectiveResolution = getEffectiveResolution(resolution, range) / 1000;
|
||||
|
||||
const { data, error, isFetching, isLoading, refetch } =
|
||||
useAPIQuery<RangeQueryResult>({
|
||||
key: [useId()],
|
||||
path: "/query_range",
|
||||
params: {
|
||||
query: effectiveExpr,
|
||||
step: effectiveResolution.toString(),
|
||||
start: startTime.toString(),
|
||||
end: effectiveEndTime.toString(),
|
||||
},
|
||||
enabled: effectiveExpr !== "",
|
||||
});
|
||||
|
||||
// Bundle the chart data and the displayed range together. This has two purposes:
|
||||
// 1. If we update them separately, we cause unnecessary rerenders of the uPlot chart itself.
|
||||
// 2. We want to keep displaying the old range in the chart while a query for a new range
|
||||
// is still in progress.
|
||||
const [dataAndRange, setDataAndRange] = useState<{
|
||||
data: SuccessAPIResponse<RangeQueryResult>;
|
||||
range: UPlotChartRange;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (data !== undefined) {
|
||||
setDataAndRange({
|
||||
data: data,
|
||||
range: {
|
||||
startTime: startTime,
|
||||
endTime: effectiveEndTime,
|
||||
resolution: effectiveResolution,
|
||||
},
|
||||
});
|
||||
}
|
||||
// We actually want to update the displayed range only once the new data is there,
|
||||
// so we don't want to include any of the range-related parameters in the dependencies.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data]);
|
||||
|
||||
// Re-execute the query when the user presses Enter (or hits the Execute button).
|
||||
useEffect(() => {
|
||||
effectiveExpr !== "" && refetch();
|
||||
}, [retriggerIdx, refetch, effectiveExpr, endTime, range, resolution]);
|
||||
|
||||
// The useElementSize hook above only gets a valid size on the second render, so this
|
||||
// is a workaround to make the component render twice after mount.
|
||||
useEffect(() => {
|
||||
if (dataAndRange !== null && rerender) {
|
||||
setRerender(false);
|
||||
}
|
||||
}, [dataAndRange, rerender, setRerender]);
|
||||
|
||||
// TODO: Share all the loading/error/empty data notices with the DataTable?
|
||||
|
||||
// Show a skeleton only on the first load, not on subsequent ones.
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box>
|
||||
{Array.from(Array(5), (_, i) => (
|
||||
<Skeleton key={i} height={30} mb={15} />
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Alert
|
||||
color="red"
|
||||
title="Error executing query"
|
||||
icon={<IconAlertTriangle size={14} />}
|
||||
>
|
||||
{error.message}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (dataAndRange === null) {
|
||||
return <Alert variant="transparent">No data queried yet</Alert>;
|
||||
}
|
||||
|
||||
const { result } = dataAndRange.data.data;
|
||||
|
||||
if (result.length === 0) {
|
||||
return (
|
||||
<Alert title="Empty query result" icon={<IconInfoCircle size={14} />}>
|
||||
This query returned no data.
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
{node !== null && node.type === nodeType.matrixSelector && (
|
||||
<Alert
|
||||
color="orange"
|
||||
title="Graphing modified expression"
|
||||
icon={<IconAlertTriangle size={14} />}
|
||||
>
|
||||
<strong>Note:</strong> Range vector selectors can't be graphed, so
|
||||
graphing the equivalent instant vector selector instead.
|
||||
</Alert>
|
||||
)}
|
||||
<Box pos="relative" ref={ref} className={classes.chartWrapper}>
|
||||
<LoadingOverlay
|
||||
visible={isFetching}
|
||||
zIndex={1000}
|
||||
h={570}
|
||||
overlayProps={{ radius: "sm", blur: 0.5 }}
|
||||
loaderProps={{ type: "dots", color: "gray.6" }}
|
||||
// loaderProps={{
|
||||
// children: <Skeleton m={0} w="100%" h="100%" />,
|
||||
// }}
|
||||
// styles={{ loader: { width: "100%", height: "100%" } }}
|
||||
/>
|
||||
<UPlotChart
|
||||
data={dataAndRange.data.data.result}
|
||||
range={dataAndRange.range}
|
||||
width={width}
|
||||
showExemplars={showExemplars}
|
||||
displayMode={displayMode}
|
||||
onSelectRange={onSelectRange}
|
||||
/>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default Graph;
|
106
web/ui/mantine-ui/src/pages/query/HistogramChart.module.css
Normal file
106
web/ui/mantine-ui/src/pages/query/HistogramChart.module.css
Normal file
|
@ -0,0 +1,106 @@
|
|||
.histogramYWrapper {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
align-items: flex-start;
|
||||
box-sizing: border-box;
|
||||
margin: 15px 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.histogramYLabels {
|
||||
height: 200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.histogramYLabel {
|
||||
margin-right: 8px;
|
||||
height: 25%;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.histogramXWrapper {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.histogramXLabels {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.histogramXLabel {
|
||||
position: relative;
|
||||
margin-top: 5px;
|
||||
width: 100%;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.histogramContainer {
|
||||
margin-top: 9px;
|
||||
position: relative;
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.histogramAxes {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-bottom: 1px solid var(--mantine-color-gray-7);
|
||||
border-left: 1px solid var(--mantine-color-gray-7);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.histogramYGrid {
|
||||
position: absolute;
|
||||
border-bottom: 1px dashed var(--mantine-color-gray-6);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.histogramYTick {
|
||||
position: absolute;
|
||||
border-bottom: 1px solid var(--mantine-color-gray-7);
|
||||
left: -5px;
|
||||
height: 0px;
|
||||
width: 5px;
|
||||
}
|
||||
|
||||
.histogramXGrid {
|
||||
position: absolute;
|
||||
border-left: 1px dashed var(--mantine-color-gray-6);
|
||||
height: 100%;
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.histogramXTick {
|
||||
position: absolute;
|
||||
border-left: 1px solid var(--mantine-color-gray-7);
|
||||
height: 5px;
|
||||
width: 0;
|
||||
bottom: -5px;
|
||||
}
|
||||
|
||||
.histogramBucketSlot {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.histogramBucket {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
bottom: 0;
|
||||
background-color: #2db453;
|
||||
border: 1px solid #77de94;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.histogramBucketSlot:hover {
|
||||
background-color: var(--mantine-color-gray-4);
|
||||
}
|
||||
|
||||
.histogramBucketSlot:hover .histogramBucket {
|
||||
background-color: #88e1a1;
|
||||
border: 1px solid #77de94;
|
||||
}
|
309
web/ui/mantine-ui/src/pages/query/HistogramChart.tsx
Normal file
309
web/ui/mantine-ui/src/pages/query/HistogramChart.tsx
Normal file
|
@ -0,0 +1,309 @@
|
|||
import React, { FC } from "react";
|
||||
import { Histogram } from "../../types/types";
|
||||
import {
|
||||
calculateDefaultExpBucketWidth,
|
||||
findMinPositive,
|
||||
findMaxNegative,
|
||||
findZeroAxisLeft,
|
||||
showZeroAxis,
|
||||
findZeroBucket,
|
||||
bucketRangeString,
|
||||
} from "./HistogramHelpers";
|
||||
import classes from "./HistogramChart.module.css";
|
||||
import { Tooltip } from "@mantine/core";
|
||||
|
||||
interface HistogramChartProps {
|
||||
histogram: Histogram;
|
||||
index: number;
|
||||
scale: string;
|
||||
}
|
||||
|
||||
const HistogramChart: FC<HistogramChartProps> = ({
|
||||
index,
|
||||
histogram,
|
||||
scale,
|
||||
}) => {
|
||||
const { buckets } = histogram;
|
||||
if (!buckets || buckets.length === 0) {
|
||||
return <div>No data</div>;
|
||||
}
|
||||
const formatter = Intl.NumberFormat("en", { notation: "compact" });
|
||||
|
||||
// For linear scales, the count of a histogram bucket is represented by its area rather than its height. This means it considers
|
||||
// both the count and the range (width) of the bucket. For this, we can set the height of the bucket proportional
|
||||
// to its frequency density (fd). The fd is the count of the bucket divided by the width of the bucket.
|
||||
const fds = [];
|
||||
for (const bucket of buckets) {
|
||||
const left = parseFloat(bucket[1]);
|
||||
const right = parseFloat(bucket[2]);
|
||||
const count = parseFloat(bucket[3]);
|
||||
const width = right - left;
|
||||
|
||||
// This happens when a user want observations of precisely zero to be included in the zero bucket
|
||||
if (width === 0) {
|
||||
fds.push(0);
|
||||
continue;
|
||||
}
|
||||
fds.push(count / width);
|
||||
}
|
||||
const fdMax = Math.max(...fds);
|
||||
|
||||
const first = buckets[0];
|
||||
const last = buckets[buckets.length - 1];
|
||||
|
||||
const rangeMax = parseFloat(last[2]);
|
||||
const rangeMin = parseFloat(first[1]);
|
||||
const countMax = Math.max(...buckets.map((b) => parseFloat(b[3])));
|
||||
|
||||
const defaultExpBucketWidth = calculateDefaultExpBucketWidth(last, buckets);
|
||||
|
||||
const maxPositive = rangeMax > 0 ? rangeMax : 0;
|
||||
const minPositive = findMinPositive(buckets);
|
||||
const maxNegative = findMaxNegative(buckets);
|
||||
const minNegative = parseFloat(first[1]) < 0 ? parseFloat(first[1]) : 0;
|
||||
|
||||
// Calculate the borders of positive and negative buckets in the exponential scale from left to right
|
||||
const startNegative =
|
||||
minNegative !== 0 ? -Math.log(Math.abs(minNegative)) : 0;
|
||||
const endNegative = maxNegative !== 0 ? -Math.log(Math.abs(maxNegative)) : 0;
|
||||
const startPositive = minPositive !== 0 ? Math.log(minPositive) : 0;
|
||||
const endPositive = maxPositive !== 0 ? Math.log(maxPositive) : 0;
|
||||
|
||||
// Calculate the width of negative, positive, and all exponential bucket ranges on the x-axis
|
||||
const xWidthNegative = endNegative - startNegative;
|
||||
const xWidthPositive = endPositive - startPositive;
|
||||
const xWidthTotal = xWidthNegative + defaultExpBucketWidth + xWidthPositive;
|
||||
|
||||
const zeroBucketIdx = findZeroBucket(buckets);
|
||||
const zeroAxisLeft = findZeroAxisLeft(
|
||||
scale,
|
||||
rangeMin,
|
||||
rangeMax,
|
||||
minPositive,
|
||||
maxNegative,
|
||||
zeroBucketIdx,
|
||||
xWidthNegative,
|
||||
xWidthTotal,
|
||||
defaultExpBucketWidth,
|
||||
);
|
||||
const zeroAxis = showZeroAxis(zeroAxisLeft);
|
||||
|
||||
return (
|
||||
<div className={classes.histogramYWrapper}>
|
||||
<div className={classes.histogramYLabels}>
|
||||
{[1, 0.75, 0.5, 0.25].map((i) => (
|
||||
<div key={i} className={classes.histogramYLabel}>
|
||||
{scale === "linear" ? "" : formatter.format(countMax * i)}
|
||||
</div>
|
||||
))}
|
||||
<div key={0} className={classes.histogramYLabel} style={{ height: 0 }}>
|
||||
0
|
||||
</div>
|
||||
</div>
|
||||
<div className={classes.histogramXWrapper}>
|
||||
<div className={classes.histogramContainer}>
|
||||
{[0, 0.25, 0.5, 0.75, 1].map((i) => (
|
||||
<React.Fragment key={i}>
|
||||
<div
|
||||
className={classes.histogramYGrid}
|
||||
style={{ bottom: i * 100 + "%" }}
|
||||
></div>
|
||||
<div
|
||||
className={classes.histogramYTick}
|
||||
style={{ bottom: i * 100 + "%" }}
|
||||
></div>
|
||||
<div
|
||||
className={classes.histogramXGrid}
|
||||
style={{ left: i * 100 + "%" }}
|
||||
></div>
|
||||
</React.Fragment>
|
||||
))}
|
||||
<div className={classes.histogramXTick} style={{ left: "0%" }}></div>
|
||||
<div
|
||||
className={classes.histogramXTick}
|
||||
style={{ left: zeroAxisLeft }}
|
||||
></div>
|
||||
<div
|
||||
className={classes.histogramXGrid}
|
||||
style={{ left: zeroAxisLeft }}
|
||||
></div>
|
||||
<div
|
||||
className={classes.histogramXTick}
|
||||
style={{ left: "100%" }}
|
||||
></div>
|
||||
|
||||
<RenderHistogramBars
|
||||
buckets={buckets}
|
||||
scale={scale}
|
||||
rangeMin={rangeMin}
|
||||
rangeMax={rangeMax}
|
||||
index={index}
|
||||
fds={fds}
|
||||
fdMax={fdMax}
|
||||
countMax={countMax}
|
||||
defaultExpBucketWidth={defaultExpBucketWidth}
|
||||
minPositive={minPositive}
|
||||
maxNegative={maxNegative}
|
||||
startPositive={startPositive}
|
||||
startNegative={startNegative}
|
||||
xWidthNegative={xWidthNegative}
|
||||
xWidthTotal={xWidthTotal}
|
||||
/>
|
||||
|
||||
<div className={classes.histogramAxes}></div>
|
||||
</div>
|
||||
<div className={classes.histogramXLabels}>
|
||||
<div className={classes.histogramXLabel}>
|
||||
<React.Fragment>
|
||||
<div style={{ position: "absolute", left: 0 }}>
|
||||
{formatter.format(rangeMin)}
|
||||
</div>
|
||||
{rangeMin < 0 && zeroAxis && (
|
||||
<div style={{ position: "absolute", left: zeroAxisLeft }}>
|
||||
0
|
||||
</div>
|
||||
)}
|
||||
<div style={{ position: "absolute", right: 0 }}>
|
||||
{formatter.format(rangeMax)}
|
||||
</div>
|
||||
</React.Fragment>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface RenderHistogramProps {
|
||||
buckets: [number, string, string, string][];
|
||||
scale: string;
|
||||
rangeMin: number;
|
||||
rangeMax: number;
|
||||
index: number;
|
||||
fds: number[];
|
||||
fdMax: number;
|
||||
countMax: number;
|
||||
defaultExpBucketWidth: number;
|
||||
minPositive: number;
|
||||
maxNegative: number;
|
||||
startPositive: number;
|
||||
startNegative: number;
|
||||
xWidthNegative: number;
|
||||
xWidthTotal: number;
|
||||
}
|
||||
|
||||
const RenderHistogramBars: FC<RenderHistogramProps> = ({
|
||||
buckets,
|
||||
scale,
|
||||
rangeMin,
|
||||
rangeMax,
|
||||
index,
|
||||
fds,
|
||||
fdMax,
|
||||
countMax,
|
||||
defaultExpBucketWidth,
|
||||
minPositive,
|
||||
maxNegative,
|
||||
startPositive,
|
||||
startNegative,
|
||||
xWidthNegative,
|
||||
xWidthTotal,
|
||||
}) => {
|
||||
return (
|
||||
<React.Fragment>
|
||||
{buckets.map((b, bIdx) => {
|
||||
const left = parseFloat(b[1]);
|
||||
const right = parseFloat(b[2]);
|
||||
const count = parseFloat(b[3]);
|
||||
const bucketIdx = `bucket-${index}-${bIdx}-${Math.ceil(parseFloat(b[3]) * 100)}`;
|
||||
|
||||
const logWidth = Math.abs(
|
||||
Math.log(Math.abs(right)) - Math.log(Math.abs(left)),
|
||||
);
|
||||
const expBucketWidth =
|
||||
logWidth === 0 ? defaultExpBucketWidth : logWidth;
|
||||
|
||||
let bucketWidth = "";
|
||||
let bucketLeft = "";
|
||||
let bucketHeight = "";
|
||||
|
||||
switch (scale) {
|
||||
case "linear": {
|
||||
bucketWidth = ((right - left) / (rangeMax - rangeMin)) * 100 + "%";
|
||||
bucketLeft =
|
||||
((left - rangeMin) / (rangeMax - rangeMin)) * 100 + "%";
|
||||
if (left === 0 && right === 0) {
|
||||
bucketLeft = "0%"; // do not render zero-width zero bucket
|
||||
bucketWidth = "0%";
|
||||
}
|
||||
bucketHeight = (fds[bIdx] / fdMax) * 100 + "%";
|
||||
break;
|
||||
}
|
||||
case "exponential": {
|
||||
let adjust = 0; // if buckets are all positive/negative, we need to remove the width of the zero bucket
|
||||
if (minPositive === 0 || maxNegative === 0) {
|
||||
adjust = defaultExpBucketWidth;
|
||||
}
|
||||
bucketWidth = (expBucketWidth / (xWidthTotal - adjust)) * 100 + "%";
|
||||
if (left < 0) {
|
||||
// negative buckets boundary
|
||||
bucketLeft =
|
||||
(-(Math.log(Math.abs(left)) + startNegative) /
|
||||
(xWidthTotal - adjust)) *
|
||||
100 +
|
||||
"%";
|
||||
} else {
|
||||
// positive buckets boundary
|
||||
bucketLeft =
|
||||
((Math.log(left) -
|
||||
startPositive +
|
||||
defaultExpBucketWidth +
|
||||
xWidthNegative -
|
||||
adjust) /
|
||||
(xWidthTotal - adjust)) *
|
||||
100 +
|
||||
"%";
|
||||
}
|
||||
if (left < 0 && right > 0) {
|
||||
// if the bucket crosses the zero axis
|
||||
bucketLeft = (xWidthNegative / xWidthTotal) * 100 + "%";
|
||||
}
|
||||
if (left === 0 && right === 0) {
|
||||
// do not render zero width zero bucket
|
||||
bucketLeft = "0%";
|
||||
bucketWidth = "0%";
|
||||
}
|
||||
|
||||
bucketHeight = (count / countMax) * 100 + "%";
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw new Error("Invalid scale");
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip label={`range: ${bucketRangeString(b)}`} key={bIdx}>
|
||||
<div
|
||||
id={bucketIdx}
|
||||
className={classes.histogramBucketSlot}
|
||||
style={{
|
||||
left: bucketLeft,
|
||||
width: bucketWidth,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
id={bucketIdx}
|
||||
className={classes.histogramBucket}
|
||||
style={{
|
||||
height: bucketHeight,
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
export default HistogramChart;
|
144
web/ui/mantine-ui/src/pages/query/HistogramHelpers.ts
Normal file
144
web/ui/mantine-ui/src/pages/query/HistogramHelpers.ts
Normal file
|
@ -0,0 +1,144 @@
|
|||
// Calculates a default width of exponential histogram bucket ranges. If the last bucket is [0, 0],
|
||||
// the width is calculated using the second to last bucket. returns error if the last bucket is [-0, 0],
|
||||
export function calculateDefaultExpBucketWidth(
|
||||
last: [number, string, string, string],
|
||||
buckets: [number, string, string, string][]
|
||||
): number {
|
||||
if (parseFloat(last[2]) === 0 || parseFloat(last[1]) === 0) {
|
||||
if (buckets.length > 1) {
|
||||
return Math.abs(
|
||||
Math.log(Math.abs(parseFloat(buckets[buckets.length - 2][2]))) -
|
||||
Math.log(Math.abs(parseFloat(buckets[buckets.length - 2][1])))
|
||||
);
|
||||
} else {
|
||||
throw new Error(
|
||||
"Only one bucket in histogram ([-0, 0]). Cannot calculate defaultExpBucketWidth."
|
||||
);
|
||||
}
|
||||
} else {
|
||||
return Math.abs(
|
||||
Math.log(Math.abs(parseFloat(last[2]))) -
|
||||
Math.log(Math.abs(parseFloat(last[1])))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Finds the lowest positive value from the bucket ranges
|
||||
// Returns 0 if no positive values are found or if there are no buckets.
|
||||
export function findMinPositive(buckets: [number, string, string, string][]) {
|
||||
if (!buckets || buckets.length === 0) {
|
||||
return 0; // no buckets
|
||||
}
|
||||
for (let i = 0; i < buckets.length; i++) {
|
||||
const right = parseFloat(buckets[i][2]);
|
||||
const left = parseFloat(buckets[i][1]);
|
||||
|
||||
if (left > 0) {
|
||||
return left;
|
||||
}
|
||||
if (left < 0 && right > 0) {
|
||||
return right;
|
||||
}
|
||||
if (i === buckets.length - 1) {
|
||||
if (right > 0) {
|
||||
return right;
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0; // all buckets are negative
|
||||
}
|
||||
|
||||
// Finds the lowest negative value from the bucket ranges
|
||||
// Returns 0 if no negative values are found or if there are no buckets.
|
||||
export function findMaxNegative(buckets: [number, string, string, string][]) {
|
||||
if (!buckets || buckets.length === 0) {
|
||||
return 0; // no buckets
|
||||
}
|
||||
for (let i = 0; i < buckets.length; i++) {
|
||||
const right = parseFloat(buckets[i][2]);
|
||||
const left = parseFloat(buckets[i][1]);
|
||||
const prevRight = i > 0 ? parseFloat(buckets[i - 1][2]) : 0;
|
||||
|
||||
if (right >= 0) {
|
||||
if (i === 0) {
|
||||
if (left < 0) {
|
||||
return left; // return the first negative bucket
|
||||
}
|
||||
return 0; // all buckets are positive
|
||||
}
|
||||
return prevRight; // return the last negative bucket
|
||||
}
|
||||
}
|
||||
console.log("findmaxneg returning: ", buckets[buckets.length - 1][2]);
|
||||
return parseFloat(buckets[buckets.length - 1][2]); // all buckets are negative
|
||||
}
|
||||
|
||||
// Calculates the left position of the zero axis as a percentage string.
|
||||
export function findZeroAxisLeft(
|
||||
scale: string,
|
||||
rangeMin: number,
|
||||
rangeMax: number,
|
||||
minPositive: number,
|
||||
maxNegative: number,
|
||||
zeroBucketIdx: number,
|
||||
widthNegative: number,
|
||||
widthTotal: number,
|
||||
expBucketWidth: number
|
||||
): string {
|
||||
if (scale === "linear") {
|
||||
return ((0 - rangeMin) / (rangeMax - rangeMin)) * 100 + "%";
|
||||
} else {
|
||||
if (maxNegative === 0) {
|
||||
return "0%";
|
||||
}
|
||||
if (minPositive === 0) {
|
||||
return "100%";
|
||||
}
|
||||
if (zeroBucketIdx === -1) {
|
||||
// if there is no zero bucket, we must zero axis between buckets around zero
|
||||
return (widthNegative / widthTotal) * 100 + "%";
|
||||
}
|
||||
if ((widthNegative + 0.5 * expBucketWidth) / widthTotal > 0) {
|
||||
return ((widthNegative + 0.5 * expBucketWidth) / widthTotal) * 100 + "%";
|
||||
} else {
|
||||
return "0%";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Determines if the zero axis should be shown such that the zero label does not overlap with the range labels.
|
||||
// The zero axis is shown if it is between 5% and 95% of the graph.
|
||||
export function showZeroAxis(zeroAxisLeft: string) {
|
||||
const axisNumber = parseFloat(zeroAxisLeft.slice(0, -1));
|
||||
if (5 < axisNumber && axisNumber < 95) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Finds the index of the bucket whose range includes zero
|
||||
export function findZeroBucket(
|
||||
buckets: [number, string, string, string][]
|
||||
): number {
|
||||
for (let i = 0; i < buckets.length; i++) {
|
||||
const left = parseFloat(buckets[i][1]);
|
||||
const right = parseFloat(buckets[i][2]);
|
||||
if (left <= 0 && right >= 0) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
const leftDelim = (br: number): string => (br === 3 || br === 1 ? "[" : "(");
|
||||
const rightDelim = (br: number): string => (br === 3 || br === 0 ? "]" : ")");
|
||||
|
||||
export const bucketRangeString = ([
|
||||
boundaryRule,
|
||||
leftBoundary,
|
||||
rightBoundary,
|
||||
|
||||
_,
|
||||
]: [number, string, string, string]): string => {
|
||||
return `${leftDelim(boundaryRule)}${leftBoundary} -> ${rightBoundary}${rightDelim(boundaryRule)}`;
|
||||
};
|
|
@ -0,0 +1,18 @@
|
|||
.labelValue {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.labelValue:hover {
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-gray-2),
|
||||
var(--mantine-color-gray-8)
|
||||
);
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
}
|
||||
|
||||
.promqlPill {
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-gray-1),
|
||||
var(--mantine-color-dark-5)
|
||||
);
|
||||
}
|
|
@ -0,0 +1,415 @@
|
|||
import { FC, useMemo, useState } from "react";
|
||||
import {
|
||||
LabelMatcher,
|
||||
matchType,
|
||||
nodeType,
|
||||
VectorSelector,
|
||||
} from "../../../promql/ast";
|
||||
import {
|
||||
Alert,
|
||||
Anchor,
|
||||
Autocomplete,
|
||||
Box,
|
||||
Button,
|
||||
CopyButton,
|
||||
Group,
|
||||
List,
|
||||
Pill,
|
||||
Text,
|
||||
SegmentedControl,
|
||||
Select,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Table,
|
||||
} from "@mantine/core";
|
||||
import { escapeString } from "../../../lib/escapeString";
|
||||
import serializeNode from "../../../promql/serialize";
|
||||
import { SeriesResult } from "../../../api/responseTypes/series";
|
||||
import { useAPIQuery } from "../../../api/api";
|
||||
import { Metric } from "../../../api/responseTypes/query";
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconArrowLeft,
|
||||
IconCheck,
|
||||
IconCodePlus,
|
||||
IconCopy,
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
import { formatNode } from "../../../promql/format";
|
||||
import classes from "./LabelsExplorer.module.css";
|
||||
|
||||
type LabelsExplorerProps = {
|
||||
metricName: string;
|
||||
insertText: (_text: string) => void;
|
||||
hideLabelsExplorer: () => void;
|
||||
};
|
||||
|
||||
const LabelsExplorer: FC<LabelsExplorerProps> = ({
|
||||
metricName,
|
||||
insertText,
|
||||
hideLabelsExplorer,
|
||||
}) => {
|
||||
const [expandedLabels, setExpandedLabels] = useState<string[]>([]);
|
||||
const [matchers, setMatchers] = useState<LabelMatcher[]>([]);
|
||||
const [newMatcher, setNewMatcher] = useState<LabelMatcher | null>(null);
|
||||
const [sortByCard, setSortByCard] = useState<boolean>(true);
|
||||
|
||||
const removeMatcher = (name: string) => {
|
||||
setMatchers(matchers.filter((m) => m.name !== name));
|
||||
};
|
||||
|
||||
const addMatcher = () => {
|
||||
if (newMatcher === null) {
|
||||
throw new Error("tried to add null label matcher");
|
||||
}
|
||||
|
||||
setMatchers([...matchers, newMatcher]);
|
||||
setNewMatcher(null);
|
||||
};
|
||||
|
||||
const matcherBadge = (m: LabelMatcher) => (
|
||||
<Pill
|
||||
key={m.name}
|
||||
size="md"
|
||||
withRemoveButton
|
||||
onRemove={() => {
|
||||
removeMatcher(m.name);
|
||||
}}
|
||||
className={classes.promqlPill}
|
||||
>
|
||||
<span className="promql-code">
|
||||
<span className="promql-label-name">{m.name}</span>
|
||||
{m.type}
|
||||
<span className="promql-string">"{escapeString(m.value)}"</span>
|
||||
</span>
|
||||
</Pill>
|
||||
);
|
||||
|
||||
const selector: VectorSelector = {
|
||||
type: nodeType.vectorSelector,
|
||||
name: metricName,
|
||||
matchers,
|
||||
offset: 0,
|
||||
timestamp: null,
|
||||
startOrEnd: null,
|
||||
};
|
||||
|
||||
// Based on the selected pool (if any), load the list of targets.
|
||||
const { data, error, isLoading } = useAPIQuery<SeriesResult>({
|
||||
path: `/series`,
|
||||
params: {
|
||||
"match[]": serializeNode(selector),
|
||||
},
|
||||
});
|
||||
|
||||
// When new series data is loaded, update the corresponding label cardinality and example data.
|
||||
const [numSeries, sortedLabelCards, labelExamples] = useMemo(() => {
|
||||
const labelCardinalities: Record<string, number> = {};
|
||||
const labelExamples: Record<string, { value: string; count: number }[]> =
|
||||
{};
|
||||
|
||||
const labelValuesByName: Record<string, Record<string, number>> = {};
|
||||
|
||||
if (data !== undefined) {
|
||||
data.data.forEach((series: Metric) => {
|
||||
Object.entries(series).forEach(([ln, lv]) => {
|
||||
if (ln !== "__name__") {
|
||||
if (!(ln in labelValuesByName)) {
|
||||
labelValuesByName[ln] = { [lv]: 1 };
|
||||
} else {
|
||||
if (!(lv in labelValuesByName[ln])) {
|
||||
labelValuesByName[ln][lv] = 1;
|
||||
} else {
|
||||
labelValuesByName[ln][lv]++;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Object.entries(labelValuesByName).forEach(([ln, lvs]) => {
|
||||
labelCardinalities[ln] = Object.keys(lvs).length;
|
||||
// labelExamples[ln] = Array.from({ length: Math.min(5, lvs.size) }, (i => () => i.next().value)(lvs.keys()));
|
||||
// Sort label values by their number of occurrences within this label name.
|
||||
labelExamples[ln] = Object.entries(lvs)
|
||||
.sort(([, aCnt], [, bCnt]) => bCnt - aCnt)
|
||||
.map(([lv, cnt]) => ({ value: lv, count: cnt }));
|
||||
});
|
||||
}
|
||||
|
||||
// Sort labels by cardinality if desired, so the labels with the most values are at the top.
|
||||
const sortedLabelCards = Object.entries(labelCardinalities).sort((a, b) =>
|
||||
sortByCard ? b[1] - a[1] : 0
|
||||
);
|
||||
|
||||
return [data?.data.length, sortedLabelCards, labelExamples];
|
||||
}, [data, sortByCard]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Alert
|
||||
color="red"
|
||||
title="Error querying series"
|
||||
icon={<IconAlertTriangle size={14} />}
|
||||
>
|
||||
<strong>Error:</strong> {error.message}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack fz="sm">
|
||||
<Stack style={{ overflow: "auto" }}>
|
||||
{/* Selector */}
|
||||
<Group align="center" mt="lg" wrap="nowrap">
|
||||
<Box w={70} fw={700} style={{ flexShrink: 0 }}>
|
||||
Selector:
|
||||
</Box>
|
||||
<Pill.Group>
|
||||
<Pill size="md" className={classes.promqlPill}>
|
||||
<span style={{ wordBreak: "break-word", whiteSpace: "pre" }}>
|
||||
{formatNode(selector, false)}
|
||||
</span>
|
||||
</Pill>
|
||||
</Pill.Group>
|
||||
<Group wrap="nowrap">
|
||||
<Button
|
||||
variant="light"
|
||||
size="xs"
|
||||
onClick={() => insertText(serializeNode(selector))}
|
||||
leftSection={<IconCodePlus size={18} />}
|
||||
title="Insert selector at cursor and close explorer"
|
||||
>
|
||||
Insert
|
||||
</Button>
|
||||
<CopyButton value={serializeNode(selector)}>
|
||||
{({ copied, copy }) => (
|
||||
<Button
|
||||
variant="light"
|
||||
size="xs"
|
||||
leftSection={
|
||||
copied ? <IconCheck size={18} /> : <IconCopy size={18} />
|
||||
}
|
||||
onClick={copy}
|
||||
title="Copy selector to clipboard"
|
||||
>
|
||||
Copy
|
||||
</Button>
|
||||
)}
|
||||
</CopyButton>
|
||||
</Group>
|
||||
</Group>
|
||||
{/* Filters */}
|
||||
<Group align="center">
|
||||
<Box w={70} fw={700} style={{ flexShrink: 0 }}>
|
||||
Filters:
|
||||
</Box>
|
||||
|
||||
{matchers.length > 0 ? (
|
||||
<Pill.Group>{matchers.map((m) => matcherBadge(m))}</Pill.Group>
|
||||
) : (
|
||||
<>No label filters</>
|
||||
)}
|
||||
</Group>
|
||||
{/* Number of series */}
|
||||
<Group
|
||||
style={{ display: "flex", alignItems: "center", marginBottom: 25 }}
|
||||
>
|
||||
<Box w={70} fw={700} style={{ flexShrink: 0 }}>
|
||||
Results:
|
||||
</Box>
|
||||
<>{numSeries !== undefined ? `${numSeries} series` : "loading..."}</>
|
||||
</Group>
|
||||
</Stack>
|
||||
{/* Sort order */}
|
||||
<Group justify="space-between">
|
||||
<Box>
|
||||
<Button
|
||||
variant="light"
|
||||
size="xs"
|
||||
onClick={hideLabelsExplorer}
|
||||
leftSection={<IconArrowLeft size={18} />}
|
||||
>
|
||||
Back to all metrics
|
||||
</Button>
|
||||
</Box>
|
||||
<SegmentedControl
|
||||
w="fit-content"
|
||||
size="xs"
|
||||
value={sortByCard ? "cardinality" : "alphabetic"}
|
||||
onChange={(value) => setSortByCard(value === "cardinality")}
|
||||
data={[
|
||||
{ label: "By cardinality", value: "cardinality" },
|
||||
{ label: "Alphabetic", value: "alphabetic" },
|
||||
]}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{/* Labels and their values */}
|
||||
{isLoading ? (
|
||||
<Box mt="lg">
|
||||
{Array.from(Array(10), (_, i) => (
|
||||
<Skeleton key={i} height={40} mb={15} width="100%" />
|
||||
))}
|
||||
</Box>
|
||||
) : (
|
||||
<Table fz="sm">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Label</Table.Th>
|
||||
<Table.Th>Values</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{sortedLabelCards.map(([ln, card]) => (
|
||||
<Table.Tr key={ln}>
|
||||
<Table.Td w="50%">
|
||||
<form
|
||||
onSubmit={(e: React.FormEvent) => {
|
||||
// Without this, the page gets reloaded for forms that only have a single input field, see
|
||||
// https://stackoverflow.com/questions/1370021/why-does-forms-with-single-input-field-submit-upon-pressing-enter-key-in-input.
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" align="baseline">
|
||||
<span className="promql-code promql-label-name">
|
||||
{ln}
|
||||
</span>
|
||||
{matchers.some((m) => m.name === ln) ? (
|
||||
matcherBadge(matchers.find((m) => m.name === ln)!)
|
||||
) : newMatcher?.name === ln ? (
|
||||
<Group wrap="nowrap" gap="xs">
|
||||
<Select
|
||||
size="xs"
|
||||
w={50}
|
||||
style={{ width: "auto" }}
|
||||
value={newMatcher.type}
|
||||
data={Object.values(matchType).map((mt) => ({
|
||||
value: mt,
|
||||
label: mt,
|
||||
}))}
|
||||
onChange={(_value, option) =>
|
||||
setNewMatcher({
|
||||
...newMatcher,
|
||||
type: option.value as matchType,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Autocomplete
|
||||
value={newMatcher.value}
|
||||
size="xs"
|
||||
placeholder="label value"
|
||||
onChange={(value) =>
|
||||
setNewMatcher({ ...newMatcher, value: value })
|
||||
}
|
||||
data={labelExamples[ln].map((ex) => ex.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="xs"
|
||||
onClick={() => addMatcher()}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
<Button
|
||||
variant="light"
|
||||
w={40}
|
||||
size="xs"
|
||||
onClick={() => setNewMatcher(null)}
|
||||
title="Cancel"
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
<IconX size={18} />
|
||||
</Button>
|
||||
</Group>
|
||||
) : (
|
||||
<Button
|
||||
variant="light"
|
||||
size="xs"
|
||||
mr="xs"
|
||||
onClick={() =>
|
||||
setNewMatcher({
|
||||
name: ln,
|
||||
type: matchType.equal,
|
||||
value: "",
|
||||
})
|
||||
}
|
||||
>
|
||||
Filter...
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</form>
|
||||
</Table.Td>
|
||||
<Table.Td w="50%">
|
||||
<Text fw={700} fz="sm" my="xs">
|
||||
{card} value{card > 1 && "s"}
|
||||
</Text>
|
||||
<List size="sm" listStyleType="none">
|
||||
{(expandedLabels.includes(ln)
|
||||
? labelExamples[ln]
|
||||
: labelExamples[ln].slice(0, 5)
|
||||
).map(({ value, count }) => (
|
||||
<List.Item key={value}>
|
||||
<span
|
||||
className={`${classes.labelValue} promql-code promql-string`}
|
||||
onClick={() => {
|
||||
setMatchers([
|
||||
...matchers.filter((m) => m.name !== ln),
|
||||
{ name: ln, type: matchType.equal, value: value },
|
||||
]);
|
||||
setNewMatcher(null);
|
||||
}}
|
||||
title="Click to filter by value"
|
||||
>
|
||||
"{escapeString(value)}"
|
||||
</span>{" "}
|
||||
({count} series)
|
||||
</List.Item>
|
||||
))}
|
||||
|
||||
{expandedLabels.includes(ln) ? (
|
||||
<List.Item my="xs">
|
||||
<Anchor
|
||||
size="sm"
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setExpandedLabels(
|
||||
expandedLabels.filter((l) => l != ln)
|
||||
);
|
||||
}}
|
||||
>
|
||||
Hide full values
|
||||
</Anchor>
|
||||
</List.Item>
|
||||
) : (
|
||||
labelExamples[ln].length > 5 && (
|
||||
<List.Item my="xs">
|
||||
<Anchor
|
||||
size="sm"
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setExpandedLabels([...expandedLabels, ln]);
|
||||
}}
|
||||
>
|
||||
Show {labelExamples[ln].length - 5} more values...
|
||||
</Anchor>
|
||||
</List.Item>
|
||||
)
|
||||
)}
|
||||
</List>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default LabelsExplorer;
|
|
@ -0,0 +1,7 @@
|
|||
.typeLabel {
|
||||
color: light-dark(#008080, #14bfad);
|
||||
}
|
||||
|
||||
.helpLabel {
|
||||
color: light-dark(#800000, #ff8585);
|
||||
}
|
|
@ -0,0 +1,185 @@
|
|||
import { FC, useMemo, useState } from "react";
|
||||
import { useSuspenseAPIQuery } from "../../../api/api";
|
||||
import { MetadataResult } from "../../../api/responseTypes/metadata";
|
||||
import { ActionIcon, Group, Stack, Table, TextInput } from "@mantine/core";
|
||||
import React from "react";
|
||||
import { Fuzzy } from "@nexucis/fuzzy";
|
||||
import sanitizeHTML from "sanitize-html";
|
||||
import { IconCodePlus, IconCopy, IconZoomCode } from "@tabler/icons-react";
|
||||
import LabelsExplorer from "./LabelsExplorer";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import classes from "./MetricsExplorer.module.css";
|
||||
import CustomInfiniteScroll from "../../../components/CustomInfiniteScroll";
|
||||
|
||||
const fuz = new Fuzzy({
|
||||
pre: '<b style="color: rgb(0, 102, 191)">',
|
||||
post: "</b>",
|
||||
shouldSort: true,
|
||||
});
|
||||
|
||||
const sanitizeOpts = {
|
||||
allowedTags: ["b"],
|
||||
allowedAttributes: { b: ["style"] },
|
||||
};
|
||||
|
||||
type MetricsExplorerProps = {
|
||||
metricNames: string[];
|
||||
insertText: (text: string) => void;
|
||||
close: () => void;
|
||||
};
|
||||
|
||||
const getSearchMatches = (input: string, expressions: string[]) =>
|
||||
fuz.filter(input.replace(/ /g, ""), expressions, {
|
||||
pre: '<b style="color: rgb(0, 102, 191)">',
|
||||
post: "</b>",
|
||||
});
|
||||
|
||||
const MetricsExplorer: FC<MetricsExplorerProps> = ({
|
||||
metricNames,
|
||||
insertText,
|
||||
close,
|
||||
}) => {
|
||||
// Fetch the alerting rules data.
|
||||
const { data } = useSuspenseAPIQuery<MetadataResult>({
|
||||
path: `/metadata`,
|
||||
});
|
||||
const [selectedMetric, setSelectedMetric] = useState<string | null>(null);
|
||||
|
||||
const [filterText, setFilterText] = useState("");
|
||||
const [debouncedFilterText] = useDebouncedValue(filterText, 250);
|
||||
|
||||
const searchMatches = useMemo(() => {
|
||||
if (debouncedFilterText === "") {
|
||||
return metricNames.map((m) => ({ original: m, rendered: m }));
|
||||
}
|
||||
return getSearchMatches(debouncedFilterText, metricNames);
|
||||
}, [debouncedFilterText, metricNames]);
|
||||
|
||||
const getMeta = (m: string) =>
|
||||
data.data[m.replace(/(_count|_sum|_bucket)$/, "")] || [
|
||||
{ help: "unknown", type: "unknown", unit: "unknown" },
|
||||
];
|
||||
|
||||
if (selectedMetric !== null) {
|
||||
return (
|
||||
<LabelsExplorer
|
||||
metricName={selectedMetric}
|
||||
insertText={(text: string) => {
|
||||
insertText(text);
|
||||
close();
|
||||
}}
|
||||
hideLabelsExplorer={() => setSelectedMetric(null)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<TextInput
|
||||
title="Filter by text"
|
||||
placeholder="Enter text to filter metric names by..."
|
||||
value={filterText}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setFilterText(e.target.value)
|
||||
}
|
||||
autoFocus
|
||||
/>
|
||||
<CustomInfiniteScroll
|
||||
allItems={searchMatches}
|
||||
child={({ items }) => (
|
||||
<Table>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Metric</Table.Th>
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th>Help</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{items.map((m) => (
|
||||
<Table.Tr key={m.original}>
|
||||
<Table.Td>
|
||||
<Group justify="space-between">
|
||||
{debouncedFilterText === "" ? (
|
||||
m.original
|
||||
) : (
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: sanitizeHTML(m.rendered, sanitizeOpts),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Group gap="xs">
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
color="gray"
|
||||
variant="light"
|
||||
title="Explore metric"
|
||||
onClick={() => {
|
||||
setSelectedMetric(m.original);
|
||||
}}
|
||||
>
|
||||
<IconZoomCode
|
||||
style={{ width: "70%", height: "70%" }}
|
||||
stroke={1.5}
|
||||
/>
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
color="gray"
|
||||
variant="light"
|
||||
title="Insert at cursor and close explorer"
|
||||
onClick={() => {
|
||||
insertText(m.original);
|
||||
close();
|
||||
}}
|
||||
>
|
||||
<IconCodePlus
|
||||
style={{ width: "70%", height: "70%" }}
|
||||
stroke={1.5}
|
||||
/>
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
color="gray"
|
||||
variant="light"
|
||||
title="Copy to clipboard"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(m.original);
|
||||
}}
|
||||
>
|
||||
<IconCopy
|
||||
style={{ width: "70%", height: "70%" }}
|
||||
stroke={1.5}
|
||||
/>
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td px="lg">
|
||||
{getMeta(m.original).map((meta, idx) => (
|
||||
<React.Fragment key={idx}>
|
||||
<span className={classes.typeLabel}>{meta.type}</span>
|
||||
<br />
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{getMeta(m.original).map((meta, idx) => (
|
||||
<React.Fragment key={idx}>
|
||||
<span className={classes.helpLabel}>{meta.help}</span>
|
||||
<br />
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default MetricsExplorer;
|
141
web/ui/mantine-ui/src/pages/query/QueryPage.tsx
Normal file
141
web/ui/mantine-ui/src/pages/query/QueryPage.tsx
Normal file
|
@ -0,0 +1,141 @@
|
|||
import { Alert, Box, Button, Stack, rem } from "@mantine/core";
|
||||
import {
|
||||
IconAlertCircle,
|
||||
IconAlertTriangle,
|
||||
IconPlus,
|
||||
} from "@tabler/icons-react";
|
||||
import { useAppDispatch, useAppSelector } from "../../state/hooks";
|
||||
import {
|
||||
addPanel,
|
||||
newDefaultPanel,
|
||||
setPanels,
|
||||
} from "../../state/queryPageSlice";
|
||||
import Panel from "./QueryPanel";
|
||||
import { LabelValuesResult } from "../../api/responseTypes/labelValues";
|
||||
import { useAPIQuery } from "../../api/api";
|
||||
import { useEffect, useState } from "react";
|
||||
import { InstantQueryResult } from "../../api/responseTypes/query";
|
||||
import { humanizeDuration } from "../../lib/formatTime";
|
||||
import { decodePanelOptionsFromURLParams } from "./urlStateEncoding";
|
||||
|
||||
export default function QueryPage() {
|
||||
const panels = useAppSelector((state) => state.queryPage.panels);
|
||||
const dispatch = useAppDispatch();
|
||||
const [timeDelta, setTimeDelta] = useState(0);
|
||||
|
||||
// Update the panels whenever the URL params change.
|
||||
useEffect(() => {
|
||||
const handleURLChange = () => {
|
||||
const panels = decodePanelOptionsFromURLParams(window.location.search);
|
||||
if (panels.length > 0) {
|
||||
dispatch(setPanels(panels));
|
||||
}
|
||||
};
|
||||
|
||||
handleURLChange();
|
||||
|
||||
window.addEventListener("popstate", handleURLChange);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("popstate", handleURLChange);
|
||||
};
|
||||
}, [dispatch]);
|
||||
|
||||
// Clear the query page when navigating away from it.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
dispatch(setPanels([newDefaultPanel()]));
|
||||
};
|
||||
}, [dispatch]);
|
||||
|
||||
const { data: metricNamesResult, error: metricNamesError } =
|
||||
useAPIQuery<LabelValuesResult>({
|
||||
path: "/label/__name__/values",
|
||||
});
|
||||
|
||||
const { data: timeResult, error: timeError } =
|
||||
useAPIQuery<InstantQueryResult>({
|
||||
path: "/query",
|
||||
params: {
|
||||
query: "time()",
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!timeResult) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (timeResult.data.resultType !== "scalar") {
|
||||
throw new Error("Unexpected result type from time query");
|
||||
}
|
||||
|
||||
const browserTime = new Date().getTime() / 1000;
|
||||
const serverTime = timeResult.data.result[0];
|
||||
setTimeDelta(Math.abs(browserTime - serverTime));
|
||||
}, [timeResult]);
|
||||
|
||||
return (
|
||||
<Box mt="xs">
|
||||
{metricNamesError && (
|
||||
<Alert
|
||||
mb="sm"
|
||||
icon={
|
||||
<IconAlertTriangle style={{ width: rem(14), height: rem(14) }} />
|
||||
}
|
||||
color="red"
|
||||
title="Error fetching metrics list"
|
||||
withCloseButton
|
||||
>
|
||||
Unable to fetch list of metric names: {metricNamesError.message}
|
||||
</Alert>
|
||||
)}
|
||||
{timeError && (
|
||||
<Alert
|
||||
mb="sm"
|
||||
icon={
|
||||
<IconAlertTriangle style={{ width: rem(14), height: rem(14) }} />
|
||||
}
|
||||
color="red"
|
||||
title="Error fetching server time"
|
||||
withCloseButton
|
||||
>
|
||||
{timeError.message}
|
||||
</Alert>
|
||||
)}
|
||||
{timeDelta > 30 && (
|
||||
<Alert
|
||||
mb="sm"
|
||||
title="Server time is out of sync"
|
||||
color="red"
|
||||
icon={<IconAlertCircle style={{ width: rem(14), height: rem(14) }} />}
|
||||
onClose={() => setTimeDelta(0)}
|
||||
>
|
||||
Detected a time difference of{" "}
|
||||
<strong>{humanizeDuration(timeDelta * 1000)}</strong> between your
|
||||
browser and the server. You may see unexpected time-shifted query
|
||||
results due to the time drift.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Stack gap="xl">
|
||||
{panels.map((p, idx) => (
|
||||
<Panel
|
||||
key={p.id}
|
||||
idx={idx}
|
||||
metricNames={metricNamesResult?.data || []}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
<Button
|
||||
variant="light"
|
||||
mt="xl"
|
||||
leftSection={<IconPlus size={18} />}
|
||||
onClick={() => dispatch(addPanel())}
|
||||
>
|
||||
Add query
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
}
|
14
web/ui/mantine-ui/src/pages/query/QueryPanel.module.css
Normal file
14
web/ui/mantine-ui/src/pages/query/QueryPanel.module.css
Normal file
|
@ -0,0 +1,14 @@
|
|||
.input {
|
||||
font-family: "DejaVu Sans Mono";
|
||||
padding-top: 7px;
|
||||
transition: none;
|
||||
|
||||
&:focus-within {
|
||||
outline: rem(2px) solid var(--mantine-color-blue-filled);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
&:placeholder-shown {
|
||||
font-family: unset;
|
||||
}
|
||||
}
|
337
web/ui/mantine-ui/src/pages/query/QueryPanel.tsx
Normal file
337
web/ui/mantine-ui/src/pages/query/QueryPanel.tsx
Normal file
|
@ -0,0 +1,337 @@
|
|||
import {
|
||||
Group,
|
||||
Tabs,
|
||||
Center,
|
||||
Space,
|
||||
Box,
|
||||
SegmentedControl,
|
||||
Stack,
|
||||
Skeleton,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconChartAreaFilled,
|
||||
IconChartLine,
|
||||
IconGraph,
|
||||
IconInfoCircle,
|
||||
IconTable,
|
||||
} from "@tabler/icons-react";
|
||||
import { FC, Suspense, useCallback, useMemo, useState } from "react";
|
||||
import { useAppDispatch, useAppSelector } from "../../state/hooks";
|
||||
import {
|
||||
addQueryToHistory,
|
||||
GraphDisplayMode,
|
||||
GraphResolution,
|
||||
removePanel,
|
||||
setExpr,
|
||||
setShowTree,
|
||||
setVisualizer,
|
||||
} from "../../state/queryPageSlice";
|
||||
import TimeInput from "./TimeInput";
|
||||
import RangeInput from "./RangeInput";
|
||||
import ExpressionInput from "./ExpressionInput";
|
||||
import Graph from "./Graph";
|
||||
import ResolutionInput from "./ResolutionInput";
|
||||
import TableTab from "./TableTab";
|
||||
import TreeView from "./TreeView";
|
||||
import ErrorBoundary from "../../components/ErrorBoundary";
|
||||
import ASTNode from "../../promql/ast";
|
||||
import serializeNode from "../../promql/serialize";
|
||||
import ExplainView from "./ExplainViews/ExplainView";
|
||||
|
||||
export interface PanelProps {
|
||||
idx: number;
|
||||
metricNames: string[];
|
||||
}
|
||||
|
||||
// TODO: This is duplicated everywhere, unify it.
|
||||
const iconStyle = { width: "0.9rem", height: "0.9rem" };
|
||||
|
||||
const QueryPanel: FC<PanelProps> = ({ idx, metricNames }) => {
|
||||
// Used to indicate to the selected display component that it should retrigger
|
||||
// the query, even if the expression has not changed (e.g. when the user presses
|
||||
// the "Execute" button or hits <Enter> again).
|
||||
const [retriggerIdx, setRetriggerIdx] = useState<number>(0);
|
||||
|
||||
const panel = useAppSelector((state) => state.queryPage.panels[idx]);
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const [selectedNode, setSelectedNode] = useState<{
|
||||
id: string;
|
||||
node: ASTNode;
|
||||
} | null>(null);
|
||||
|
||||
const expr = useMemo(
|
||||
() =>
|
||||
selectedNode !== null ? serializeNode(selectedNode.node) : panel.expr,
|
||||
[selectedNode, panel.expr]
|
||||
);
|
||||
|
||||
const onSelectRange = useCallback(
|
||||
(start: number, end: number) =>
|
||||
dispatch(
|
||||
setVisualizer({
|
||||
idx,
|
||||
visualizer: {
|
||||
...panel.visualizer,
|
||||
range: (end - start) * 1000,
|
||||
endTime: end * 1000,
|
||||
},
|
||||
})
|
||||
),
|
||||
// TODO: How to have panel.visualizer in the dependencies, but not re-create
|
||||
// the callback every time it changes by the callback's own update? This leads
|
||||
// to extra renders of the plot further down.
|
||||
[dispatch, idx, panel.visualizer]
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<ExpressionInput
|
||||
// TODO: Maybe just pass the panelIdx and retriggerIdx to the ExpressionInput
|
||||
// so it can manage its own state?
|
||||
initialExpr={panel.expr}
|
||||
metricNames={metricNames}
|
||||
executeQuery={(expr: string) => {
|
||||
setRetriggerIdx((idx) => idx + 1);
|
||||
dispatch(setExpr({ idx, expr }));
|
||||
|
||||
if (!metricNames.includes(expr) && expr.trim() !== "") {
|
||||
dispatch(addQueryToHistory(expr));
|
||||
}
|
||||
}}
|
||||
treeShown={panel.showTree}
|
||||
setShowTree={(showTree: boolean) => {
|
||||
dispatch(setShowTree({ idx, showTree }));
|
||||
if (!showTree) {
|
||||
setSelectedNode(null);
|
||||
}
|
||||
}}
|
||||
removePanel={() => {
|
||||
dispatch(removePanel(idx));
|
||||
}}
|
||||
/>
|
||||
{panel.expr.trim() !== "" && panel.showTree && (
|
||||
<ErrorBoundary key={retriggerIdx} title="Error showing tree view">
|
||||
<Suspense
|
||||
fallback={
|
||||
<Box mt="lg">
|
||||
{Array.from(Array(20), (_, i) => (
|
||||
<Skeleton key={i} height={30} mb={15} width="100%" />
|
||||
))}
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<TreeView
|
||||
panelIdx={idx}
|
||||
selectedNode={selectedNode}
|
||||
setSelectedNode={setSelectedNode}
|
||||
closeTreeView={() => {
|
||||
dispatch(setShowTree({ idx, showTree: false }));
|
||||
setSelectedNode(null);
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
<Tabs
|
||||
value={panel.visualizer.activeTab}
|
||||
onChange={(v) =>
|
||||
dispatch(
|
||||
setVisualizer({
|
||||
idx,
|
||||
visualizer: {
|
||||
...panel.visualizer,
|
||||
activeTab: v as "table" | "graph",
|
||||
},
|
||||
})
|
||||
)
|
||||
}
|
||||
keepMounted={false}
|
||||
>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="table" leftSection={<IconTable style={iconStyle} />}>
|
||||
Table
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="graph" leftSection={<IconGraph style={iconStyle} />}>
|
||||
Graph
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab
|
||||
value="explain"
|
||||
leftSection={<IconInfoCircle style={iconStyle} />}
|
||||
>
|
||||
Explain
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
<Tabs.Panel pt="sm" value="table">
|
||||
<TableTab expr={expr} panelIdx={idx} retriggerIdx={retriggerIdx} />
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel pt="sm" value="graph">
|
||||
<Group mt="xs" justify="space-between">
|
||||
<Group>
|
||||
<RangeInput
|
||||
range={panel.visualizer.range}
|
||||
onChangeRange={(range) =>
|
||||
dispatch(
|
||||
setVisualizer({
|
||||
idx,
|
||||
visualizer: { ...panel.visualizer, range },
|
||||
})
|
||||
)
|
||||
}
|
||||
/>
|
||||
<TimeInput
|
||||
time={panel.visualizer.endTime}
|
||||
range={panel.visualizer.range}
|
||||
description="End time"
|
||||
onChangeTime={(time) =>
|
||||
dispatch(
|
||||
setVisualizer({
|
||||
idx,
|
||||
visualizer: { ...panel.visualizer, endTime: time },
|
||||
})
|
||||
)
|
||||
}
|
||||
/>
|
||||
<ResolutionInput
|
||||
resolution={panel.visualizer.resolution}
|
||||
range={panel.visualizer.range}
|
||||
onChangeResolution={(res: GraphResolution) => {
|
||||
dispatch(
|
||||
setVisualizer({
|
||||
idx,
|
||||
visualizer: {
|
||||
...panel.visualizer,
|
||||
resolution: res,
|
||||
},
|
||||
})
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Group gap="lg">
|
||||
{/* <Button
|
||||
variant="subtle"
|
||||
color="gray.9"
|
||||
size="xs"
|
||||
leftSection={
|
||||
panel.visualizer.showExemplars ? (
|
||||
<IconCheckbox
|
||||
style={{
|
||||
width: "1.5em",
|
||||
height: "1.5em",
|
||||
marginRight: -1,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<IconSquare
|
||||
style={{
|
||||
width: "1.3em",
|
||||
height: "1.3em",
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
onClick={() =>
|
||||
dispatch(
|
||||
setVisualizer({
|
||||
idx,
|
||||
visualizer: {
|
||||
...panel.visualizer,
|
||||
showExemplars: !panel.visualizer.showExemplars,
|
||||
},
|
||||
})
|
||||
)
|
||||
}
|
||||
>
|
||||
Show exemplars
|
||||
</Button> */}
|
||||
|
||||
<SegmentedControl
|
||||
onChange={(value) =>
|
||||
dispatch(
|
||||
setVisualizer({
|
||||
idx,
|
||||
visualizer: {
|
||||
...panel.visualizer,
|
||||
displayMode: value as GraphDisplayMode,
|
||||
},
|
||||
})
|
||||
)
|
||||
}
|
||||
value={panel.visualizer.displayMode}
|
||||
data={[
|
||||
{
|
||||
value: GraphDisplayMode.Lines,
|
||||
label: (
|
||||
<Center>
|
||||
<IconChartLine style={iconStyle} />
|
||||
<Box ml={10}>Unstacked</Box>
|
||||
</Center>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: GraphDisplayMode.Stacked,
|
||||
label: (
|
||||
<Center>
|
||||
<IconChartAreaFilled style={iconStyle} />
|
||||
<Box ml={10}>Stacked</Box>
|
||||
</Center>
|
||||
),
|
||||
},
|
||||
// {
|
||||
// value: GraphDisplayMode.Heatmap,
|
||||
// label: (
|
||||
// <Center>
|
||||
// <IconChartGridDots style={iconStyle} />
|
||||
// <Box ml={10}>Heatmap</Box>
|
||||
// </Center>
|
||||
// ),
|
||||
// },
|
||||
]}
|
||||
/>
|
||||
</Group>
|
||||
</Group>
|
||||
<Space h="lg" />
|
||||
<Graph
|
||||
expr={expr}
|
||||
node={selectedNode?.node ?? null}
|
||||
endTime={panel.visualizer.endTime}
|
||||
range={panel.visualizer.range}
|
||||
resolution={panel.visualizer.resolution}
|
||||
showExemplars={panel.visualizer.showExemplars}
|
||||
displayMode={panel.visualizer.displayMode}
|
||||
retriggerIdx={retriggerIdx}
|
||||
onSelectRange={onSelectRange}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel pt="sm" value="explain">
|
||||
<ErrorBoundary
|
||||
key={selectedNode?.id}
|
||||
title="Error showing explain view"
|
||||
>
|
||||
<Suspense
|
||||
fallback={
|
||||
<Box mt="lg">
|
||||
{Array.from(Array(20), (_, i) => (
|
||||
<Skeleton key={i} height={30} mb={15} width="100%" />
|
||||
))}
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<ExplainView
|
||||
node={selectedNode?.node ?? null}
|
||||
treeShown={panel.showTree}
|
||||
showTree={() => {
|
||||
dispatch(setShowTree({ idx, showTree: true }));
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default QueryPanel;
|
120
web/ui/mantine-ui/src/pages/query/RangeInput.tsx
Normal file
120
web/ui/mantine-ui/src/pages/query/RangeInput.tsx
Normal file
|
@ -0,0 +1,120 @@
|
|||
import { FC, useEffect, useState } from "react";
|
||||
import { ActionIcon, Group, TextInput } from "@mantine/core";
|
||||
import { IconMinus, IconPlus } from "@tabler/icons-react";
|
||||
import {
|
||||
formatPrometheusDuration,
|
||||
parsePrometheusDuration,
|
||||
} from "../../lib/formatTime";
|
||||
|
||||
interface RangeInputProps {
|
||||
range: number;
|
||||
onChangeRange: (range: number) => void;
|
||||
}
|
||||
|
||||
const iconStyle = { width: "0.9rem", height: "0.9rem" };
|
||||
|
||||
const rangeSteps = [
|
||||
1,
|
||||
10,
|
||||
60,
|
||||
5 * 60,
|
||||
15 * 60,
|
||||
30 * 60,
|
||||
60 * 60,
|
||||
2 * 60 * 60,
|
||||
6 * 60 * 60,
|
||||
12 * 60 * 60,
|
||||
24 * 60 * 60,
|
||||
48 * 60 * 60,
|
||||
7 * 24 * 60 * 60,
|
||||
14 * 24 * 60 * 60,
|
||||
28 * 24 * 60 * 60,
|
||||
56 * 24 * 60 * 60,
|
||||
112 * 24 * 60 * 60,
|
||||
182 * 24 * 60 * 60,
|
||||
365 * 24 * 60 * 60,
|
||||
730 * 24 * 60 * 60,
|
||||
].map((s) => s * 1000);
|
||||
|
||||
const RangeInput: FC<RangeInputProps> = ({ range, onChangeRange }) => {
|
||||
// TODO: Make sure that when "range" changes externally (like via the URL),
|
||||
// the input is updated, either via useEffect() or some better architecture.
|
||||
const [rangeInput, setRangeInput] = useState<string>(
|
||||
formatPrometheusDuration(range)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setRangeInput(formatPrometheusDuration(range));
|
||||
}, [range]);
|
||||
|
||||
const onChangeRangeInput = (rangeText: string): void => {
|
||||
const newRange = parsePrometheusDuration(rangeText);
|
||||
if (newRange === null) {
|
||||
setRangeInput(formatPrometheusDuration(range));
|
||||
} else {
|
||||
onChangeRange(newRange);
|
||||
}
|
||||
};
|
||||
|
||||
const increaseRange = (): void => {
|
||||
for (const step of rangeSteps) {
|
||||
if (range < step) {
|
||||
setRangeInput(formatPrometheusDuration(step));
|
||||
onChangeRange(step);
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const decreaseRange = (): void => {
|
||||
for (const step of rangeSteps.slice().reverse()) {
|
||||
if (range > step) {
|
||||
setRangeInput(formatPrometheusDuration(step));
|
||||
onChangeRange(step);
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Group gap={5}>
|
||||
<TextInput
|
||||
title="Range"
|
||||
value={rangeInput}
|
||||
onChange={(event) => setRangeInput(event.currentTarget.value)}
|
||||
onBlur={() => onChangeRangeInput(rangeInput)}
|
||||
onKeyDown={(event) =>
|
||||
event.key === "Enter" && onChangeRangeInput(rangeInput)
|
||||
}
|
||||
aria-label="Range"
|
||||
style={{ width: `calc(44px + ${rangeInput.length + 3}ch)` }}
|
||||
leftSection={
|
||||
<ActionIcon
|
||||
size="lg"
|
||||
variant="transparent"
|
||||
color="gray"
|
||||
aria-label="Decrease range"
|
||||
onClick={decreaseRange}
|
||||
>
|
||||
<IconMinus style={iconStyle} />
|
||||
</ActionIcon>
|
||||
}
|
||||
rightSection={
|
||||
<ActionIcon
|
||||
size="lg"
|
||||
variant="transparent"
|
||||
color="gray"
|
||||
aria-label="Increase range"
|
||||
onClick={increaseRange}
|
||||
>
|
||||
<IconPlus style={iconStyle} />
|
||||
</ActionIcon>
|
||||
}
|
||||
leftSectionPointerEvents="all"
|
||||
rightSectionPointerEvents="all"
|
||||
/>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
export default RangeInput;
|
142
web/ui/mantine-ui/src/pages/query/ResolutionInput.tsx
Normal file
142
web/ui/mantine-ui/src/pages/query/ResolutionInput.tsx
Normal file
|
@ -0,0 +1,142 @@
|
|||
import { FC, useState } from "react";
|
||||
import { Select, TextInput } from "@mantine/core";
|
||||
import {
|
||||
formatPrometheusDuration,
|
||||
parsePrometheusDuration,
|
||||
} from "../../lib/formatTime";
|
||||
import {
|
||||
GraphResolution,
|
||||
getEffectiveResolution,
|
||||
} from "../../state/queryPageSlice";
|
||||
|
||||
interface ResolutionInputProps {
|
||||
resolution: GraphResolution;
|
||||
range: number;
|
||||
onChangeResolution: (resolution: GraphResolution) => void;
|
||||
}
|
||||
|
||||
const ResolutionInput: FC<ResolutionInputProps> = ({
|
||||
resolution,
|
||||
range,
|
||||
onChangeResolution,
|
||||
}) => {
|
||||
const [customResolutionInput, setCustomResolutionInput] = useState<string>(
|
||||
formatPrometheusDuration(getEffectiveResolution(resolution, range))
|
||||
);
|
||||
|
||||
const onChangeCustomResolutionInput = (resText: string): void => {
|
||||
const newResolution = parsePrometheusDuration(resText);
|
||||
|
||||
if (resolution.type === "custom" && newResolution === resolution.step) {
|
||||
// Nothing changed.
|
||||
return;
|
||||
}
|
||||
|
||||
if (newResolution === null) {
|
||||
setCustomResolutionInput(
|
||||
formatPrometheusDuration(getEffectiveResolution(resolution, range))
|
||||
);
|
||||
} else {
|
||||
onChangeResolution({ type: "custom", step: newResolution });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Select
|
||||
title="Resolution"
|
||||
placeholder="Resolution"
|
||||
maxDropdownHeight={500}
|
||||
data={[
|
||||
{
|
||||
group: "Automatic resolution",
|
||||
items: [
|
||||
{ label: "Low res.", value: "low" },
|
||||
{ label: "Medium res.", value: "medium" },
|
||||
{ label: "High res.", value: "high" },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: "Fixed resolution",
|
||||
items: [
|
||||
{ label: "10s", value: "10000" },
|
||||
{ label: "30s", value: "30000" },
|
||||
{ label: "1m", value: "60000" },
|
||||
{ label: "5m", value: "300000" },
|
||||
{ label: "15m", value: "900000" },
|
||||
{ label: "1h", value: "3600000" },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: "Custom resolution",
|
||||
items: [{ label: "Enter value...", value: "custom" }],
|
||||
},
|
||||
]}
|
||||
w={160}
|
||||
value={
|
||||
resolution.type === "auto"
|
||||
? resolution.density
|
||||
: resolution.type === "fixed"
|
||||
? resolution.step.toString()
|
||||
: "custom"
|
||||
}
|
||||
onChange={(_value, option) => {
|
||||
if (["low", "medium", "high"].includes(option.value)) {
|
||||
onChangeResolution({
|
||||
type: "auto",
|
||||
density: option.value as "low" | "medium" | "high",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (option.value === "custom") {
|
||||
// Start the custom resolution at the current effective resolution.
|
||||
const effectiveResolution = getEffectiveResolution(
|
||||
resolution,
|
||||
range
|
||||
);
|
||||
onChangeResolution({
|
||||
type: "custom",
|
||||
step: effectiveResolution,
|
||||
});
|
||||
setCustomResolutionInput(
|
||||
formatPrometheusDuration(effectiveResolution)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const value = parseInt(option.value);
|
||||
if (!isNaN(value)) {
|
||||
onChangeResolution({
|
||||
type: "fixed",
|
||||
step: value,
|
||||
});
|
||||
} else {
|
||||
throw new Error("Invalid resolution value");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{resolution.type === "custom" && (
|
||||
<TextInput
|
||||
placeholder="Resolution"
|
||||
value={customResolutionInput}
|
||||
onChange={(event) =>
|
||||
setCustomResolutionInput(event.currentTarget.value)
|
||||
}
|
||||
onBlur={() => onChangeCustomResolutionInput(customResolutionInput)}
|
||||
onKeyDown={(event) =>
|
||||
event.key === "Enter" &&
|
||||
onChangeCustomResolutionInput(customResolutionInput)
|
||||
}
|
||||
aria-label="Range"
|
||||
style={{
|
||||
width: `calc(44px + ${customResolutionInput.length + 3}ch)`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResolutionInput;
|
19
web/ui/mantine-ui/src/pages/query/SeriesName.module.css
Normal file
19
web/ui/mantine-ui/src/pages/query/SeriesName.module.css
Normal file
|
@ -0,0 +1,19 @@
|
|||
.metricName {
|
||||
}
|
||||
|
||||
.labelPair:hover {
|
||||
--bg-expand: 4px;
|
||||
background-color: #add6ffa0;
|
||||
border-radius: 3px;
|
||||
padding: var(--bg-expand);
|
||||
margin: calc(-1 * var(--bg-expand));
|
||||
color: #495057;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.labelName {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.labelValue {
|
||||
}
|
77
web/ui/mantine-ui/src/pages/query/SeriesName.tsx
Normal file
77
web/ui/mantine-ui/src/pages/query/SeriesName.tsx
Normal file
|
@ -0,0 +1,77 @@
|
|||
import React, { FC } from "react";
|
||||
// import { useToastContext } from "../../contexts/ToastContext";
|
||||
import { formatSeries } from "../../lib/formatSeries";
|
||||
import classes from "./SeriesName.module.css";
|
||||
import { escapeString } from "../../lib/escapeString";
|
||||
import { useClipboard } from "@mantine/hooks";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
|
||||
interface SeriesNameProps {
|
||||
labels: { [key: string]: string } | null;
|
||||
format: boolean;
|
||||
}
|
||||
|
||||
const SeriesName: FC<SeriesNameProps> = ({ labels, format }) => {
|
||||
const clipboard = useClipboard();
|
||||
|
||||
const renderFormatted = (): React.ReactElement => {
|
||||
const labelNodes: React.ReactElement[] = [];
|
||||
let first = true;
|
||||
for (const label in labels) {
|
||||
if (label === "__name__") {
|
||||
continue;
|
||||
}
|
||||
|
||||
labelNodes.push(
|
||||
<span key={label}>
|
||||
{!first && ", "}
|
||||
<span
|
||||
className={classes.labelPair}
|
||||
onClick={(e) => {
|
||||
const text = e.currentTarget.innerText;
|
||||
clipboard.copy(text);
|
||||
notifications.show({
|
||||
title: "Copied matcher!",
|
||||
message: `Label matcher ${text} copied to clipboard`,
|
||||
});
|
||||
}}
|
||||
title="Click to copy label matcher"
|
||||
>
|
||||
<span className={classes.labelName}>{label}</span>=
|
||||
<span className={classes.labelValue}>
|
||||
"{escapeString(labels[label])}"
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
if (first) {
|
||||
first = false;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<span>
|
||||
<span className={classes.metricName}>
|
||||
{labels ? labels.__name__ : ""}
|
||||
</span>
|
||||
{"{"}
|
||||
{labelNodes}
|
||||
{"}"}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
if (labels === null) {
|
||||
return <>scalar</>;
|
||||
}
|
||||
|
||||
if (format) {
|
||||
return renderFormatted();
|
||||
}
|
||||
// Return a simple text node. This is much faster to scroll through
|
||||
// for longer lists (hundreds of items).
|
||||
return <>{formatSeries(labels)}</>;
|
||||
};
|
||||
|
||||
export default SeriesName;
|
132
web/ui/mantine-ui/src/pages/query/TableTab.tsx
Normal file
132
web/ui/mantine-ui/src/pages/query/TableTab.tsx
Normal file
|
@ -0,0 +1,132 @@
|
|||
import { FC, useEffect, useId, useLayoutEffect, useState } from "react";
|
||||
import { Alert, Skeleton, Box, Group, Stack, Text } from "@mantine/core";
|
||||
import { IconAlertTriangle, IconInfoCircle } from "@tabler/icons-react";
|
||||
import { InstantQueryResult } from "../../api/responseTypes/query";
|
||||
import { useAPIQuery } from "../../api/api";
|
||||
import dayjs from "dayjs";
|
||||
import timezone from "dayjs/plugin/timezone";
|
||||
import { useAppDispatch, useAppSelector } from "../../state/hooks";
|
||||
import { setVisualizer } from "../../state/queryPageSlice";
|
||||
import TimeInput from "./TimeInput";
|
||||
import DataTable from "./DataTable";
|
||||
dayjs.extend(timezone);
|
||||
|
||||
export interface TableTabProps {
|
||||
panelIdx: number;
|
||||
retriggerIdx: number;
|
||||
expr: string;
|
||||
}
|
||||
|
||||
const TableTab: FC<TableTabProps> = ({ panelIdx, retriggerIdx, expr }) => {
|
||||
const [responseTime, setResponseTime] = useState<number>(0);
|
||||
const [limitResults, setLimitResults] = useState<boolean>(true);
|
||||
|
||||
const { visualizer } = useAppSelector(
|
||||
(state) => state.queryPage.panels[panelIdx]
|
||||
);
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const { endTime, range } = visualizer;
|
||||
|
||||
const { data, error, isFetching, refetch } = useAPIQuery<InstantQueryResult>({
|
||||
key: [useId()],
|
||||
path: "/query",
|
||||
params: {
|
||||
query: expr,
|
||||
time: `${(endTime !== null ? endTime : Date.now()) / 1000}`,
|
||||
},
|
||||
enabled: expr !== "",
|
||||
recordResponseTime: setResponseTime,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
expr !== "" && refetch();
|
||||
}, [retriggerIdx, refetch, expr, endTime]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
setLimitResults(true);
|
||||
}, [data, isFetching]);
|
||||
|
||||
return (
|
||||
<Stack gap="lg" mt="sm">
|
||||
<Group justify="space-between">
|
||||
<TimeInput
|
||||
time={endTime}
|
||||
range={range}
|
||||
description="Evaluation time"
|
||||
onChangeTime={(time) =>
|
||||
dispatch(
|
||||
setVisualizer({
|
||||
idx: panelIdx,
|
||||
visualizer: { ...visualizer, endTime: time },
|
||||
})
|
||||
)
|
||||
}
|
||||
/>
|
||||
{!isFetching && data !== undefined && (
|
||||
<Text size="xs" c="gray">
|
||||
Load time: {responseTime}ms   Result series:{" "}
|
||||
{data.data.result.length}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
{isFetching ? (
|
||||
<Box>
|
||||
{Array.from(Array(5), (_, i) => (
|
||||
<Skeleton key={i} height={30} mb={15} />
|
||||
))}
|
||||
</Box>
|
||||
) : error !== null ? (
|
||||
<Alert
|
||||
color="red"
|
||||
title="Error executing query"
|
||||
icon={<IconAlertTriangle size={14} />}
|
||||
>
|
||||
{error.message}
|
||||
</Alert>
|
||||
) : data === undefined ? (
|
||||
<Alert variant="transparent">No data queried yet</Alert>
|
||||
) : (
|
||||
<>
|
||||
{data.data.result.length === 0 && (
|
||||
<Alert
|
||||
title="Empty query result"
|
||||
icon={<IconInfoCircle size={14} />}
|
||||
>
|
||||
This query returned no data.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{data.warnings?.map((w, idx) => (
|
||||
<Alert
|
||||
key={idx}
|
||||
color="red"
|
||||
title="Query warning"
|
||||
icon={<IconAlertTriangle size={14} />}
|
||||
>
|
||||
{w}
|
||||
</Alert>
|
||||
))}
|
||||
|
||||
{data.infos?.map((w, idx) => (
|
||||
<Alert
|
||||
key={idx}
|
||||
color="yellow"
|
||||
title="Query notice"
|
||||
icon={<IconInfoCircle size={14} />}
|
||||
>
|
||||
{w}
|
||||
</Alert>
|
||||
))}
|
||||
<DataTable
|
||||
data={data.data}
|
||||
limitResults={limitResults}
|
||||
setLimitResults={setLimitResults}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default TableTab;
|
88
web/ui/mantine-ui/src/pages/query/TimeInput.tsx
Normal file
88
web/ui/mantine-ui/src/pages/query/TimeInput.tsx
Normal file
|
@ -0,0 +1,88 @@
|
|||
import { Group, ActionIcon, CloseButton } from "@mantine/core";
|
||||
import { DatesProvider, DateTimePicker } from "@mantine/dates";
|
||||
import { IconChevronLeft, IconChevronRight } from "@tabler/icons-react";
|
||||
import { FC } from "react";
|
||||
import { useSettings } from "../../state/settingsSlice";
|
||||
|
||||
interface TimeInputProps {
|
||||
time: number | null; // Timestamp in milliseconds.
|
||||
range: number; // Range in seconds.
|
||||
description: string;
|
||||
onChangeTime: (time: number | null) => void;
|
||||
}
|
||||
|
||||
const iconStyle = { width: "0.9rem", height: "0.9rem" };
|
||||
|
||||
const TimeInput: FC<TimeInputProps> = ({
|
||||
time,
|
||||
range,
|
||||
description,
|
||||
onChangeTime,
|
||||
}) => {
|
||||
const baseTime = () => (time !== null ? time : Date.now().valueOf());
|
||||
const { useLocalTime } = useSettings();
|
||||
|
||||
return (
|
||||
<Group gap={5}>
|
||||
<DatesProvider settings={{ timezone: useLocalTime ? undefined : "UTC" }}>
|
||||
<DateTimePicker
|
||||
title="End time"
|
||||
w={230}
|
||||
valueFormat="YYYY-MM-DD HH:mm:ss"
|
||||
withSeconds
|
||||
// clearable
|
||||
value={time !== null ? new Date(time) : undefined}
|
||||
onChange={(value) => onChangeTime(value ? value.getTime() : null)}
|
||||
aria-label={description}
|
||||
placeholder={description}
|
||||
onClick={() => {
|
||||
if (time === null) {
|
||||
onChangeTime(baseTime());
|
||||
}
|
||||
}}
|
||||
leftSection={
|
||||
<ActionIcon
|
||||
size="lg"
|
||||
color="gray"
|
||||
variant="transparent"
|
||||
title="Decrease time"
|
||||
aria-label="Decrease time"
|
||||
onClick={() => onChangeTime(baseTime() - range / 2)}
|
||||
>
|
||||
<IconChevronLeft style={iconStyle} />
|
||||
</ActionIcon>
|
||||
}
|
||||
styles={{ section: { width: "unset" } }}
|
||||
rightSection={
|
||||
<>
|
||||
{time && (
|
||||
<CloseButton
|
||||
variant="transparent"
|
||||
color="gray"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
tabIndex={-1}
|
||||
onClick={() => {
|
||||
onChangeTime(null);
|
||||
}}
|
||||
size="xs"
|
||||
/>
|
||||
)}
|
||||
<ActionIcon
|
||||
size="lg"
|
||||
color="gray"
|
||||
variant="transparent"
|
||||
title="Increase time"
|
||||
aria-label="Increase time"
|
||||
onClick={() => onChangeTime(baseTime() + range / 2)}
|
||||
>
|
||||
<IconChevronRight style={iconStyle} />
|
||||
</ActionIcon>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</DatesProvider>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
export default TimeInput;
|
36
web/ui/mantine-ui/src/pages/query/TreeNode.module.css
Normal file
36
web/ui/mantine-ui/src/pages/query/TreeNode.module.css
Normal file
|
@ -0,0 +1,36 @@
|
|||
.nodeText {
|
||||
cursor: pointer;
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-gray-1),
|
||||
var(--mantine-color-dark-5)
|
||||
);
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
|
||||
.nodeText.nodeTextSelected,
|
||||
.nodeText.nodeTextSelected:hover {
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-gray-4),
|
||||
var(--mantine-color-gray-7)
|
||||
);
|
||||
border: 2px solid
|
||||
light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-2));
|
||||
}
|
||||
|
||||
.nodeText:hover {
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-gray-2),
|
||||
var(--mantine-color-dark-4)
|
||||
);
|
||||
}
|
||||
|
||||
.nodeText.nodeTextError {
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-red-1),
|
||||
darken(var(--mantine-color-red-5), 70%)
|
||||
);
|
||||
}
|
||||
|
||||
.errorText {
|
||||
color: light-dark(var(--mantine-color-red-9), var(--mantine-color-red-3));
|
||||
}
|
432
web/ui/mantine-ui/src/pages/query/TreeNode.tsx
Normal file
432
web/ui/mantine-ui/src/pages/query/TreeNode.tsx
Normal file
|
@ -0,0 +1,432 @@
|
|||
import {
|
||||
FC,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import ASTNode, { nodeType } from "../../promql/ast";
|
||||
import { escapeString, getNodeChildren } from "../../promql/utils";
|
||||
import { formatNode } from "../../promql/format";
|
||||
import {
|
||||
Box,
|
||||
Code,
|
||||
CSSProperties,
|
||||
Group,
|
||||
List,
|
||||
Loader,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useAPIQuery } from "../../api/api";
|
||||
import {
|
||||
InstantQueryResult,
|
||||
InstantSample,
|
||||
RangeSamples,
|
||||
} from "../../api/responseTypes/query";
|
||||
import serializeNode from "../../promql/serialize";
|
||||
import { IconPointFilled } from "@tabler/icons-react";
|
||||
import classes from "./TreeNode.module.css";
|
||||
import clsx from "clsx";
|
||||
import { useId } from "@mantine/hooks";
|
||||
import { functionSignatures } from "../../promql/functionSignatures";
|
||||
|
||||
const nodeIndent = 20;
|
||||
const maxLabelNames = 10;
|
||||
const maxLabelValues = 10;
|
||||
|
||||
type NodeState = "waiting" | "running" | "error" | "success";
|
||||
|
||||
const mergeChildStates = (states: NodeState[]): NodeState => {
|
||||
if (states.includes("error")) {
|
||||
return "error";
|
||||
}
|
||||
if (states.includes("waiting")) {
|
||||
return "waiting";
|
||||
}
|
||||
if (states.includes("running")) {
|
||||
return "running";
|
||||
}
|
||||
|
||||
return "success";
|
||||
};
|
||||
|
||||
const TreeNode: FC<{
|
||||
node: ASTNode;
|
||||
selectedNode: { id: string; node: ASTNode } | null;
|
||||
setSelectedNode: (Node: { id: string; node: ASTNode } | null) => void;
|
||||
parentRef?: React.RefObject<HTMLDivElement>;
|
||||
reportNodeState?: (childIdx: number, state: NodeState) => void;
|
||||
reverse: boolean;
|
||||
// The index of this node in its parent's children.
|
||||
childIdx: number;
|
||||
}> = ({
|
||||
node,
|
||||
selectedNode,
|
||||
setSelectedNode,
|
||||
parentRef,
|
||||
reportNodeState,
|
||||
reverse,
|
||||
childIdx,
|
||||
}) => {
|
||||
const nodeID = useId();
|
||||
const nodeRef = useRef<HTMLDivElement>(null);
|
||||
const [connectorStyle, setConnectorStyle] = useState<CSSProperties>({
|
||||
borderColor:
|
||||
"light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-3))",
|
||||
borderLeftStyle: "solid",
|
||||
borderLeftWidth: 2,
|
||||
width: nodeIndent - 7,
|
||||
left: -nodeIndent + 7,
|
||||
});
|
||||
const [responseTime, setResponseTime] = useState<number>(0);
|
||||
const [resultStats, setResultStats] = useState<{
|
||||
numSeries: number;
|
||||
labelExamples: Record<string, { value: string; count: number }[]>;
|
||||
sortedLabelCards: [string, number][];
|
||||
}>({
|
||||
numSeries: 0,
|
||||
labelExamples: {},
|
||||
sortedLabelCards: [],
|
||||
});
|
||||
|
||||
// Select the node when it is mounted and it is the root of the tree.
|
||||
useEffect(() => {
|
||||
if (parentRef === undefined) {
|
||||
setSelectedNode({ id: nodeID, node: node });
|
||||
}
|
||||
}, [parentRef, setSelectedNode, nodeID, node]);
|
||||
|
||||
// Deselect node when node is unmounted.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setSelectedNode(null);
|
||||
};
|
||||
}, [setSelectedNode]);
|
||||
|
||||
const children = getNodeChildren(node);
|
||||
|
||||
const [childStates, setChildStates] = useState<NodeState[]>(
|
||||
children.map(() => "waiting")
|
||||
);
|
||||
const mergedChildState = useMemo(
|
||||
() => mergeChildStates(childStates),
|
||||
[childStates]
|
||||
);
|
||||
|
||||
// Optimize range vector selector fetches to give us the info we're looking for
|
||||
// more cheaply. E.g. 'foo[7w]' can be expensive to fully fetch, but wrapping it
|
||||
// in 'last_over_time(foo[7w])' is cheaper and also gives us all the info we
|
||||
// need (number of series and labels).
|
||||
let queryNode = node;
|
||||
if (queryNode.type === nodeType.matrixSelector) {
|
||||
queryNode = {
|
||||
type: nodeType.call,
|
||||
func: functionSignatures["last_over_time"],
|
||||
args: [node],
|
||||
};
|
||||
}
|
||||
|
||||
const { data, error, isFetching } = useAPIQuery<InstantQueryResult>({
|
||||
key: [useId()],
|
||||
path: "/query",
|
||||
params: {
|
||||
query: serializeNode(queryNode),
|
||||
},
|
||||
recordResponseTime: setResponseTime,
|
||||
enabled: mergedChildState === "success",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (mergedChildState === "error") {
|
||||
reportNodeState && reportNodeState(childIdx, "error");
|
||||
}
|
||||
}, [mergedChildState, reportNodeState, childIdx]);
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
reportNodeState && reportNodeState(childIdx, "error");
|
||||
}
|
||||
}, [error, reportNodeState, childIdx]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isFetching) {
|
||||
reportNodeState && reportNodeState(childIdx, "running");
|
||||
}
|
||||
}, [isFetching, reportNodeState, childIdx]);
|
||||
|
||||
const childReportNodeState = useCallback(
|
||||
(childIdx: number, state: NodeState) => {
|
||||
setChildStates((prev) => {
|
||||
const newStates = [...prev];
|
||||
newStates[childIdx] = state;
|
||||
return newStates;
|
||||
});
|
||||
},
|
||||
[setChildStates]
|
||||
);
|
||||
|
||||
// Update the size and position of tree connector lines based on the node's and its parent's position.
|
||||
useLayoutEffect(() => {
|
||||
if (parentRef === undefined) {
|
||||
// We're the root node.
|
||||
return;
|
||||
}
|
||||
|
||||
if (parentRef.current === null || nodeRef.current === null) {
|
||||
return;
|
||||
}
|
||||
const parentRect = parentRef.current.getBoundingClientRect();
|
||||
const nodeRect = nodeRef.current.getBoundingClientRect();
|
||||
if (reverse) {
|
||||
setConnectorStyle((prevStyle) => ({
|
||||
...prevStyle,
|
||||
top: "calc(50% - 1px)",
|
||||
bottom: nodeRect.bottom - parentRect.top,
|
||||
borderTopLeftRadius: 3,
|
||||
borderTopStyle: "solid",
|
||||
borderBottomLeftRadius: undefined,
|
||||
}));
|
||||
} else {
|
||||
setConnectorStyle((prevStyle) => ({
|
||||
...prevStyle,
|
||||
top: parentRect.bottom - nodeRect.top,
|
||||
bottom: "calc(50% - 1px)",
|
||||
borderBottomLeftRadius: 3,
|
||||
borderBottomStyle: "solid",
|
||||
borderTopLeftRadius: undefined,
|
||||
}));
|
||||
}
|
||||
}, [parentRef, reverse, nodeRef, setConnectorStyle]);
|
||||
|
||||
// Update the node info state based on the query result.
|
||||
useEffect(() => {
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
|
||||
reportNodeState && reportNodeState(childIdx, "success");
|
||||
|
||||
let resultSeries = 0;
|
||||
const labelValuesByName: Record<string, Record<string, number>> = {};
|
||||
const { resultType, result } = data.data;
|
||||
|
||||
if (resultType === "scalar" || resultType === "string") {
|
||||
resultSeries = 1;
|
||||
} else if (result && result.length > 0) {
|
||||
resultSeries = result.length;
|
||||
result.forEach((s: InstantSample | RangeSamples) => {
|
||||
Object.entries(s.metric).forEach(([ln, lv]) => {
|
||||
// TODO: If we ever want to include __name__ here again, we cannot use the
|
||||
// last_over_time(foo[7d]) optimization since that removes the metric name.
|
||||
if (ln !== "__name__") {
|
||||
if (!labelValuesByName[ln]) {
|
||||
labelValuesByName[ln] = {};
|
||||
}
|
||||
labelValuesByName[ln][lv] = (labelValuesByName[ln][lv] || 0) + 1;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const labelCardinalities: Record<string, number> = {};
|
||||
const labelExamples: Record<string, { value: string; count: number }[]> =
|
||||
{};
|
||||
Object.entries(labelValuesByName).forEach(([ln, lvs]) => {
|
||||
labelCardinalities[ln] = Object.keys(lvs).length;
|
||||
// Sort label values by their number of occurrences within this label name.
|
||||
labelExamples[ln] = Object.entries(lvs)
|
||||
.sort(([, aCnt], [, bCnt]) => bCnt - aCnt)
|
||||
.slice(0, maxLabelValues)
|
||||
.map(([lv, cnt]) => ({ value: lv, count: cnt }));
|
||||
});
|
||||
|
||||
setResultStats({
|
||||
numSeries: resultSeries,
|
||||
sortedLabelCards: Object.entries(labelCardinalities).sort(
|
||||
(a, b) => b[1] - a[1]
|
||||
),
|
||||
labelExamples,
|
||||
});
|
||||
}, [data, reportNodeState, childIdx]);
|
||||
|
||||
const innerNode = (
|
||||
<Group
|
||||
w="fit-content"
|
||||
gap="lg"
|
||||
my="sm"
|
||||
wrap="nowrap"
|
||||
pos="relative"
|
||||
align="center"
|
||||
>
|
||||
{parentRef && (
|
||||
// Connector line between this node and its parent.
|
||||
<Box pos="absolute" display="inline-block" style={connectorStyle} />
|
||||
)}
|
||||
{/* The node (visible box) itself. */}
|
||||
<Box
|
||||
ref={nodeRef}
|
||||
w="fit-content"
|
||||
px={10}
|
||||
py={4}
|
||||
style={{ borderRadius: 4, flexShrink: 0 }}
|
||||
className={clsx(classes.nodeText, {
|
||||
[classes.nodeTextError]: error,
|
||||
[classes.nodeTextSelected]: selectedNode?.id === nodeID,
|
||||
})}
|
||||
onClick={() => {
|
||||
if (selectedNode?.id === nodeID) {
|
||||
setSelectedNode(null);
|
||||
} else {
|
||||
setSelectedNode({ id: nodeID, node: node });
|
||||
}
|
||||
}}
|
||||
>
|
||||
{formatNode(node, false, 1)}
|
||||
</Box>
|
||||
{mergedChildState === "waiting" ? (
|
||||
<Group c="gray">
|
||||
<IconPointFilled size={18} />
|
||||
</Group>
|
||||
) : mergedChildState === "running" ? (
|
||||
<Loader size={14} color="gray" type="dots" />
|
||||
) : mergedChildState === "error" ? (
|
||||
<Group c="orange.7" gap={5} fz="xs" wrap="nowrap">
|
||||
<IconPointFilled size={18} /> Blocked on child query error
|
||||
</Group>
|
||||
) : isFetching ? (
|
||||
<Loader size={14} color="gray" />
|
||||
) : error ? (
|
||||
<Group
|
||||
gap={5}
|
||||
wrap="nowrap"
|
||||
style={{ flexShrink: 0 }}
|
||||
className={classes.errorText}
|
||||
>
|
||||
<IconPointFilled size={18} />
|
||||
<Text fz="xs">
|
||||
<strong>Error executing query:</strong> {error.message}
|
||||
</Text>
|
||||
</Group>
|
||||
) : (
|
||||
<Group gap={0} wrap="nowrap">
|
||||
<Text c="dimmed" fz="xs" style={{ whiteSpace: "nowrap" }}>
|
||||
{resultStats.numSeries} result{resultStats.numSeries !== 1 && "s"}
|
||||
–
|
||||
{responseTime}ms
|
||||
{resultStats.sortedLabelCards.length > 0 && (
|
||||
<> – </>
|
||||
)}
|
||||
</Text>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
{resultStats.sortedLabelCards
|
||||
.slice(0, maxLabelNames)
|
||||
.map(([ln, cnt]) => (
|
||||
<Tooltip
|
||||
key={ln}
|
||||
position="bottom"
|
||||
withArrow
|
||||
color="dark.6"
|
||||
label={
|
||||
<Box p="xs">
|
||||
<List fz="xs">
|
||||
{resultStats.labelExamples[ln].map(
|
||||
({ value, count }) => (
|
||||
<List.Item key={value} py={1}>
|
||||
<Code c="red.3" bg="gray.8">
|
||||
{escapeString(value)}
|
||||
</Code>{" "}
|
||||
({count}
|
||||
x)
|
||||
</List.Item>
|
||||
)
|
||||
)}
|
||||
{cnt > maxLabelValues && <li>...</li>}
|
||||
</List>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<span style={{ cursor: "pointer", whiteSpace: "nowrap" }}>
|
||||
<Text
|
||||
component="span"
|
||||
fz="xs"
|
||||
className="promql-code promql-label-name"
|
||||
c="light-dark(var(--mantine-color-green-9), var(--mantine-color-green-6))"
|
||||
>
|
||||
{ln}
|
||||
</Text>
|
||||
<Text component="span" fz="xs" c="dimmed">
|
||||
: {cnt}
|
||||
</Text>
|
||||
</span>
|
||||
</Tooltip>
|
||||
))}
|
||||
{resultStats.sortedLabelCards.length > maxLabelNames ? (
|
||||
<Text
|
||||
component="span"
|
||||
c="dimmed"
|
||||
fz="xs"
|
||||
style={{ whiteSpace: "nowrap" }}
|
||||
>
|
||||
...{resultStats.sortedLabelCards.length - maxLabelNames} more...
|
||||
</Text>
|
||||
) : null}
|
||||
</Group>
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
|
||||
if (node.type === nodeType.binaryExpr) {
|
||||
return (
|
||||
<div>
|
||||
<Box ml={nodeIndent}>
|
||||
<TreeNode
|
||||
node={children[0]}
|
||||
selectedNode={selectedNode}
|
||||
setSelectedNode={setSelectedNode}
|
||||
parentRef={nodeRef}
|
||||
reverse={true}
|
||||
childIdx={0}
|
||||
reportNodeState={childReportNodeState}
|
||||
/>
|
||||
</Box>
|
||||
{innerNode}
|
||||
<Box ml={nodeIndent}>
|
||||
<TreeNode
|
||||
node={children[1]}
|
||||
selectedNode={selectedNode}
|
||||
setSelectedNode={setSelectedNode}
|
||||
parentRef={nodeRef}
|
||||
reverse={false}
|
||||
childIdx={1}
|
||||
reportNodeState={childReportNodeState}
|
||||
/>
|
||||
</Box>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{innerNode}
|
||||
{children.map((child, idx) => (
|
||||
<Box ml={nodeIndent} key={idx}>
|
||||
<TreeNode
|
||||
node={child}
|
||||
selectedNode={selectedNode}
|
||||
setSelectedNode={setSelectedNode}
|
||||
parentRef={nodeRef}
|
||||
reverse={false}
|
||||
childIdx={idx}
|
||||
reportNodeState={childReportNodeState}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TreeNode;
|
54
web/ui/mantine-ui/src/pages/query/TreeView.tsx
Normal file
54
web/ui/mantine-ui/src/pages/query/TreeView.tsx
Normal file
|
@ -0,0 +1,54 @@
|
|||
import { FC } from "react";
|
||||
import { useSuspenseAPIQuery } from "../../api/api";
|
||||
import { useAppSelector } from "../../state/hooks";
|
||||
import ASTNode from "../../promql/ast";
|
||||
import TreeNode from "./TreeNode";
|
||||
import { Card, CloseButton } from "@mantine/core";
|
||||
|
||||
const TreeView: FC<{
|
||||
panelIdx: number;
|
||||
selectedNode: {
|
||||
id: string;
|
||||
node: ASTNode;
|
||||
} | null;
|
||||
setSelectedNode: (
|
||||
node: {
|
||||
id: string;
|
||||
node: ASTNode;
|
||||
} | null
|
||||
) => void;
|
||||
closeTreeView: () => void;
|
||||
}> = ({ panelIdx, selectedNode, setSelectedNode, closeTreeView }) => {
|
||||
const { expr } = useAppSelector((state) => state.queryPage.panels[panelIdx]);
|
||||
|
||||
const { data } = useSuspenseAPIQuery<ASTNode>({
|
||||
path: "/parse_query",
|
||||
params: {
|
||||
query: expr,
|
||||
},
|
||||
enabled: expr !== "",
|
||||
});
|
||||
|
||||
return (
|
||||
<Card withBorder fz="sm" style={{ overflowX: "auto" }} pl="sm">
|
||||
<CloseButton
|
||||
aria-label="Close tree view"
|
||||
title="Close tree view"
|
||||
pos="absolute"
|
||||
top={7}
|
||||
size="sm"
|
||||
right={7}
|
||||
onClick={closeTreeView}
|
||||
/>
|
||||
<TreeNode
|
||||
childIdx={0}
|
||||
node={data.data}
|
||||
selectedNode={selectedNode}
|
||||
setSelectedNode={setSelectedNode}
|
||||
reverse={false}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default TreeView;
|
99
web/ui/mantine-ui/src/pages/query/UPlotChart.tsx
Normal file
99
web/ui/mantine-ui/src/pages/query/UPlotChart.tsx
Normal file
|
@ -0,0 +1,99 @@
|
|||
import { FC, useEffect, useState } from "react";
|
||||
import { RangeSamples } from "../../api/responseTypes/query";
|
||||
import classes from "./Graph.module.css";
|
||||
import { GraphDisplayMode } from "../../state/queryPageSlice";
|
||||
import uPlot from "uplot";
|
||||
import UplotReact from "uplot-react";
|
||||
import { useSettings } from "../../state/settingsSlice";
|
||||
import { useComputedColorScheme } from "@mantine/core";
|
||||
|
||||
import "uplot/dist/uPlot.min.css";
|
||||
import "./uplot.css";
|
||||
import { getUPlotData, getUPlotOptions } from "./uPlotChartHelpers";
|
||||
import { setStackedOpts } from "./uPlotStackHelpers";
|
||||
|
||||
export interface UPlotChartRange {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
resolution: number;
|
||||
}
|
||||
|
||||
export interface UPlotChartProps {
|
||||
data: RangeSamples[];
|
||||
range: UPlotChartRange;
|
||||
width: number;
|
||||
showExemplars: boolean;
|
||||
displayMode: GraphDisplayMode;
|
||||
onSelectRange: (start: number, end: number) => void;
|
||||
}
|
||||
|
||||
// This wrapper component translates the incoming Prometheus RangeSamples[] data to the
|
||||
// uPlot format and sets up the uPlot options object depending on the UI settings.
|
||||
const UPlotChart: FC<UPlotChartProps> = ({
|
||||
data,
|
||||
range: { startTime, endTime, resolution },
|
||||
width,
|
||||
displayMode,
|
||||
onSelectRange,
|
||||
}) => {
|
||||
const [options, setOptions] = useState<uPlot.Options | null>(null);
|
||||
const [processedData, setProcessedData] = useState<uPlot.AlignedData | null>(
|
||||
null
|
||||
);
|
||||
const { useLocalTime } = useSettings();
|
||||
const theme = useComputedColorScheme();
|
||||
|
||||
useEffect(() => {
|
||||
if (width === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const seriesData: uPlot.AlignedData = getUPlotData(
|
||||
data,
|
||||
startTime,
|
||||
endTime,
|
||||
resolution
|
||||
);
|
||||
|
||||
const opts = getUPlotOptions(
|
||||
seriesData,
|
||||
width,
|
||||
data,
|
||||
useLocalTime,
|
||||
theme === "light",
|
||||
onSelectRange
|
||||
);
|
||||
|
||||
if (displayMode === GraphDisplayMode.Stacked) {
|
||||
setProcessedData(setStackedOpts(opts, seriesData).data);
|
||||
} else {
|
||||
setProcessedData(seriesData);
|
||||
}
|
||||
|
||||
setOptions(opts);
|
||||
}, [
|
||||
width,
|
||||
data,
|
||||
displayMode,
|
||||
startTime,
|
||||
endTime,
|
||||
resolution,
|
||||
useLocalTime,
|
||||
theme,
|
||||
onSelectRange,
|
||||
]);
|
||||
|
||||
if (options === null || processedData === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<UplotReact
|
||||
options={options}
|
||||
data={processedData}
|
||||
className={classes.uplotChart}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default UPlotChart;
|
942
web/ui/mantine-ui/src/pages/query/colorPool.ts
Normal file
942
web/ui/mantine-ui/src/pages/query/colorPool.ts
Normal file
|
@ -0,0 +1,942 @@
|
|||
import { lighten } from "@mantine/core";
|
||||
|
||||
export const getSeriesColor = (idx: number, light: boolean): string => {
|
||||
const color = colorPool[idx % colorPool.length];
|
||||
return light ? color : lighten(color, 0.4);
|
||||
};
|
||||
|
||||
const colorPool = [
|
||||
"#008000",
|
||||
"#008080",
|
||||
"#800000",
|
||||
"#800080",
|
||||
"#808000",
|
||||
"#808080",
|
||||
"#0000c0",
|
||||
"#008040",
|
||||
"#0080c0",
|
||||
"#800040",
|
||||
"#8000c0",
|
||||
"#808040",
|
||||
"#8080c0",
|
||||
"#00c000",
|
||||
"#00c080",
|
||||
"#804000",
|
||||
"#804080",
|
||||
"#80c000",
|
||||
"#80c080",
|
||||
"#0040c0",
|
||||
"#00c040",
|
||||
"#00c0c0",
|
||||
"#804040",
|
||||
"#8040c0",
|
||||
"#80c040",
|
||||
"#80c0c0",
|
||||
"#408000",
|
||||
"#408080",
|
||||
"#c00000",
|
||||
"#c00080",
|
||||
"#c08000",
|
||||
"#c08080",
|
||||
"#4000c0",
|
||||
"#408040",
|
||||
"#4080c0",
|
||||
"#c00040",
|
||||
"#c000c0",
|
||||
"#c08040",
|
||||
"#c080c0",
|
||||
"#404000",
|
||||
"#404080",
|
||||
"#40c000",
|
||||
"#40c080",
|
||||
"#c04000",
|
||||
"#c04080",
|
||||
"#c0c000",
|
||||
"#c0c080",
|
||||
"#404040",
|
||||
"#4040c0",
|
||||
"#40c040",
|
||||
"#40c0c0",
|
||||
"#c04040",
|
||||
"#c040c0",
|
||||
"#c0c040",
|
||||
"#0000a0",
|
||||
"#008020",
|
||||
"#0080a0",
|
||||
"#800020",
|
||||
"#8000a0",
|
||||
"#808020",
|
||||
"#8080a0",
|
||||
"#0000e0",
|
||||
"#008060",
|
||||
"#0080e0",
|
||||
"#800060",
|
||||
"#8000e0",
|
||||
"#808060",
|
||||
"#8080e0",
|
||||
"#0040a0",
|
||||
"#00c020",
|
||||
"#00c0a0",
|
||||
"#804020",
|
||||
"#8040a0",
|
||||
"#80c020",
|
||||
"#80c0a0",
|
||||
"#0040e0",
|
||||
"#00c060",
|
||||
"#00c0e0",
|
||||
"#804060",
|
||||
"#8040e0",
|
||||
"#80c060",
|
||||
"#80c0e0",
|
||||
"#4000a0",
|
||||
"#408020",
|
||||
"#4080a0",
|
||||
"#c00020",
|
||||
"#c000a0",
|
||||
"#c08020",
|
||||
"#c080a0",
|
||||
"#4000e0",
|
||||
"#408060",
|
||||
"#4080e0",
|
||||
"#c00060",
|
||||
"#c000e0",
|
||||
"#c08060",
|
||||
"#c080e0",
|
||||
"#404020",
|
||||
"#4040a0",
|
||||
"#40c020",
|
||||
"#40c0a0",
|
||||
"#c04020",
|
||||
"#c040a0",
|
||||
"#c0c020",
|
||||
"#c0c0a0",
|
||||
"#404060",
|
||||
"#4040e0",
|
||||
"#40c060",
|
||||
"#40c0e0",
|
||||
"#c04060",
|
||||
"#c040e0",
|
||||
"#c0c060",
|
||||
"#00a000",
|
||||
"#00a080",
|
||||
"#802000",
|
||||
"#802080",
|
||||
"#80a000",
|
||||
"#80a080",
|
||||
"#0020c0",
|
||||
"#00a040",
|
||||
"#00a0c0",
|
||||
"#802040",
|
||||
"#8020c0",
|
||||
"#80a040",
|
||||
"#80a0c0",
|
||||
"#006000",
|
||||
"#006080",
|
||||
"#00e000",
|
||||
"#00e080",
|
||||
"#806000",
|
||||
"#806080",
|
||||
"#80e000",
|
||||
"#80e080",
|
||||
"#006040",
|
||||
"#0060c0",
|
||||
"#00e040",
|
||||
"#00e0c0",
|
||||
"#806040",
|
||||
"#8060c0",
|
||||
"#80e040",
|
||||
"#80e0c0",
|
||||
"#40a000",
|
||||
"#40a080",
|
||||
"#c02000",
|
||||
"#c02080",
|
||||
"#c0a000",
|
||||
"#c0a080",
|
||||
"#4020c0",
|
||||
"#40a040",
|
||||
"#40a0c0",
|
||||
"#c02040",
|
||||
"#c020c0",
|
||||
"#c0a040",
|
||||
"#c0a0c0",
|
||||
"#406000",
|
||||
"#406080",
|
||||
"#40e000",
|
||||
"#40e080",
|
||||
"#c06000",
|
||||
"#c06080",
|
||||
"#c0e000",
|
||||
"#c0e080",
|
||||
"#406040",
|
||||
"#4060c0",
|
||||
"#40e040",
|
||||
"#40e0c0",
|
||||
"#c06040",
|
||||
"#c060c0",
|
||||
"#c0e040",
|
||||
"#c0e0c0",
|
||||
"#0020a0",
|
||||
"#00a020",
|
||||
"#00a0a0",
|
||||
"#802020",
|
||||
"#8020a0",
|
||||
"#80a020",
|
||||
"#80a0a0",
|
||||
"#0020e0",
|
||||
"#00a060",
|
||||
"#00a0e0",
|
||||
"#802060",
|
||||
"#8020e0",
|
||||
"#80a060",
|
||||
"#80a0e0",
|
||||
"#006020",
|
||||
"#0060a0",
|
||||
"#00e020",
|
||||
"#00e0a0",
|
||||
"#806020",
|
||||
"#8060a0",
|
||||
"#80e020",
|
||||
"#80e0a0",
|
||||
"#006060",
|
||||
"#0060e0",
|
||||
"#00e060",
|
||||
"#00e0e0",
|
||||
"#806060",
|
||||
"#8060e0",
|
||||
"#80e060",
|
||||
"#80e0e0",
|
||||
"#4020a0",
|
||||
"#40a020",
|
||||
"#40a0a0",
|
||||
"#c02020",
|
||||
"#c020a0",
|
||||
"#c0a020",
|
||||
"#c0a0a0",
|
||||
"#4020e0",
|
||||
"#40a060",
|
||||
"#40a0e0",
|
||||
"#c02060",
|
||||
"#c020e0",
|
||||
"#c0a060",
|
||||
"#c0a0e0",
|
||||
"#406020",
|
||||
"#4060a0",
|
||||
"#40e020",
|
||||
"#40e0a0",
|
||||
"#c06020",
|
||||
"#c060a0",
|
||||
"#c0e020",
|
||||
"#c0e0a0",
|
||||
"#406060",
|
||||
"#4060e0",
|
||||
"#40e060",
|
||||
"#40e0e0",
|
||||
"#c06060",
|
||||
"#c060e0",
|
||||
"#c0e060",
|
||||
"#208000",
|
||||
"#208080",
|
||||
"#a00000",
|
||||
"#a00080",
|
||||
"#a08000",
|
||||
"#a08080",
|
||||
"#208040",
|
||||
"#2080c0",
|
||||
"#a00040",
|
||||
"#a000c0",
|
||||
"#a08040",
|
||||
"#a080c0",
|
||||
"#204080",
|
||||
"#20c000",
|
||||
"#20c080",
|
||||
"#a04000",
|
||||
"#a04080",
|
||||
"#a0c000",
|
||||
"#a0c080",
|
||||
"#2040c0",
|
||||
"#20c040",
|
||||
"#20c0c0",
|
||||
"#a04040",
|
||||
"#a040c0",
|
||||
"#a0c040",
|
||||
"#a0c0c0",
|
||||
"#608000",
|
||||
"#608080",
|
||||
"#e00000",
|
||||
"#e00080",
|
||||
"#e08000",
|
||||
"#e08080",
|
||||
"#6000c0",
|
||||
"#608040",
|
||||
"#6080c0",
|
||||
"#e00040",
|
||||
"#e000c0",
|
||||
"#e08040",
|
||||
"#e080c0",
|
||||
"#604080",
|
||||
"#60c000",
|
||||
"#60c080",
|
||||
"#e04000",
|
||||
"#e04080",
|
||||
"#e0c000",
|
||||
"#e0c080",
|
||||
"#604040",
|
||||
"#6040c0",
|
||||
"#60c040",
|
||||
"#60c0c0",
|
||||
"#e04040",
|
||||
"#e040c0",
|
||||
"#e0c040",
|
||||
"#e0c0c0",
|
||||
"#208020",
|
||||
"#2080a0",
|
||||
"#a00020",
|
||||
"#a000a0",
|
||||
"#a08020",
|
||||
"#a080a0",
|
||||
"#2000e0",
|
||||
"#208060",
|
||||
"#2080e0",
|
||||
"#a00060",
|
||||
"#a000e0",
|
||||
"#a08060",
|
||||
"#a080e0",
|
||||
"#2040a0",
|
||||
"#20c020",
|
||||
"#20c0a0",
|
||||
"#a04020",
|
||||
"#a040a0",
|
||||
"#a0c020",
|
||||
"#2040e0",
|
||||
"#20c060",
|
||||
"#20c0e0",
|
||||
"#a04060",
|
||||
"#a040e0",
|
||||
"#a0c060",
|
||||
"#a0c0e0",
|
||||
"#6000a0",
|
||||
"#608020",
|
||||
"#6080a0",
|
||||
"#e00020",
|
||||
"#e000a0",
|
||||
"#e08020",
|
||||
"#e080a0",
|
||||
"#6000e0",
|
||||
"#608060",
|
||||
"#6080e0",
|
||||
"#e00060",
|
||||
"#e000e0",
|
||||
"#e08060",
|
||||
"#e080e0",
|
||||
"#604020",
|
||||
"#6040a0",
|
||||
"#60c020",
|
||||
"#60c0a0",
|
||||
"#e04020",
|
||||
"#e040a0",
|
||||
"#e0c020",
|
||||
"#e0c0a0",
|
||||
"#604060",
|
||||
"#6040e0",
|
||||
"#60c060",
|
||||
"#60c0e0",
|
||||
"#e04060",
|
||||
"#e040e0",
|
||||
"#e0c060",
|
||||
"#e0c0e0",
|
||||
"#20a000",
|
||||
"#20a080",
|
||||
"#a02000",
|
||||
"#a02080",
|
||||
"#a0a000",
|
||||
"#a0a080",
|
||||
"#2020c0",
|
||||
"#20a040",
|
||||
"#20a0c0",
|
||||
"#a02040",
|
||||
"#a020c0",
|
||||
"#a0a040",
|
||||
"#a0a0c0",
|
||||
"#206000",
|
||||
"#206080",
|
||||
"#20e000",
|
||||
"#20e080",
|
||||
"#a06000",
|
||||
"#a06080",
|
||||
"#a0e000",
|
||||
"#a0e080",
|
||||
"#206040",
|
||||
"#2060c0",
|
||||
"#20e040",
|
||||
"#20e0c0",
|
||||
"#a06040",
|
||||
"#a060c0",
|
||||
"#a0e040",
|
||||
"#a0e0c0",
|
||||
"#602080",
|
||||
"#60a000",
|
||||
"#60a080",
|
||||
"#e02000",
|
||||
"#e02080",
|
||||
"#e0a000",
|
||||
"#e0a080",
|
||||
"#6020c0",
|
||||
"#60a040",
|
||||
"#60a0c0",
|
||||
"#e02040",
|
||||
"#e020c0",
|
||||
"#e0a040",
|
||||
"#e0a0c0",
|
||||
"#606000",
|
||||
"#606080",
|
||||
"#60e000",
|
||||
"#60e080",
|
||||
"#e06000",
|
||||
"#e06080",
|
||||
"#e0e000",
|
||||
"#e0e080",
|
||||
"#606040",
|
||||
"#6060c0",
|
||||
"#60e040",
|
||||
"#60e0c0",
|
||||
"#e06040",
|
||||
"#e060c0",
|
||||
"#e0e040",
|
||||
"#e0e0c0",
|
||||
"#20a020",
|
||||
"#20a0a0",
|
||||
"#a02020",
|
||||
"#a020a0",
|
||||
"#a0a020",
|
||||
"#a0a0a0",
|
||||
"#2020e0",
|
||||
"#20a060",
|
||||
"#20a0e0",
|
||||
"#a02060",
|
||||
"#a020e0",
|
||||
"#a0a060",
|
||||
"#a0a0e0",
|
||||
"#206020",
|
||||
"#2060a0",
|
||||
"#20e020",
|
||||
"#20e0a0",
|
||||
"#a06020",
|
||||
"#a060a0",
|
||||
"#a0e020",
|
||||
"#a0e0a0",
|
||||
"#206060",
|
||||
"#2060e0",
|
||||
"#20e060",
|
||||
"#20e0e0",
|
||||
"#a06060",
|
||||
"#a060e0",
|
||||
"#a0e060",
|
||||
"#a0e0e0",
|
||||
"#6020a0",
|
||||
"#60a020",
|
||||
"#60a0a0",
|
||||
"#e02020",
|
||||
"#e020a0",
|
||||
"#e0a020",
|
||||
"#e0a0a0",
|
||||
"#602060",
|
||||
"#6020e0",
|
||||
"#60a060",
|
||||
"#60a0e0",
|
||||
"#e02060",
|
||||
"#e020e0",
|
||||
"#e0a060",
|
||||
"#e0a0e0",
|
||||
"#606020",
|
||||
"#6060a0",
|
||||
"#60e020",
|
||||
"#60e0a0",
|
||||
"#e06020",
|
||||
"#e060a0",
|
||||
"#e0e020",
|
||||
"#e0e0a0",
|
||||
"#606060",
|
||||
"#6060e0",
|
||||
"#60e060",
|
||||
"#60e0e0",
|
||||
"#e06060",
|
||||
"#e060e0",
|
||||
"#e0e060",
|
||||
"#008010",
|
||||
"#008090",
|
||||
"#800010",
|
||||
"#800090",
|
||||
"#808010",
|
||||
"#808090",
|
||||
"#0000d0",
|
||||
"#008050",
|
||||
"#0080d0",
|
||||
"#800050",
|
||||
"#8000d0",
|
||||
"#808050",
|
||||
"#8080d0",
|
||||
"#004010",
|
||||
"#004090",
|
||||
"#00c010",
|
||||
"#00c090",
|
||||
"#804010",
|
||||
"#804090",
|
||||
"#80c010",
|
||||
"#80c090",
|
||||
"#004050",
|
||||
"#0040d0",
|
||||
"#00c050",
|
||||
"#00c0d0",
|
||||
"#804050",
|
||||
"#8040d0",
|
||||
"#80c050",
|
||||
"#80c0d0",
|
||||
"#400090",
|
||||
"#408010",
|
||||
"#408090",
|
||||
"#c00010",
|
||||
"#c00090",
|
||||
"#c08010",
|
||||
"#c08090",
|
||||
"#4000d0",
|
||||
"#408050",
|
||||
"#4080d0",
|
||||
"#c00050",
|
||||
"#c000d0",
|
||||
"#c08050",
|
||||
"#c080d0",
|
||||
"#404010",
|
||||
"#404090",
|
||||
"#40c010",
|
||||
"#40c090",
|
||||
"#c04010",
|
||||
"#c04090",
|
||||
"#c0c010",
|
||||
"#c0c090",
|
||||
"#404050",
|
||||
"#4040d0",
|
||||
"#40c050",
|
||||
"#40c0d0",
|
||||
"#c04050",
|
||||
"#c040d0",
|
||||
"#c0c050",
|
||||
"#0000b0",
|
||||
"#008030",
|
||||
"#0080b0",
|
||||
"#800030",
|
||||
"#8000b0",
|
||||
"#808030",
|
||||
"#8080b0",
|
||||
"#0000f0",
|
||||
"#008070",
|
||||
"#0080f0",
|
||||
"#800070",
|
||||
"#8000f0",
|
||||
"#808070",
|
||||
"#8080f0",
|
||||
"#004030",
|
||||
"#0040b0",
|
||||
"#00c030",
|
||||
"#00c0b0",
|
||||
"#804030",
|
||||
"#8040b0",
|
||||
"#80c030",
|
||||
"#80c0b0",
|
||||
"#004070",
|
||||
"#0040f0",
|
||||
"#00c070",
|
||||
"#00c0f0",
|
||||
"#804070",
|
||||
"#8040f0",
|
||||
"#80c070",
|
||||
"#80c0f0",
|
||||
"#4000b0",
|
||||
"#408030",
|
||||
"#4080b0",
|
||||
"#c00030",
|
||||
"#c000b0",
|
||||
"#c08030",
|
||||
"#c080b0",
|
||||
"#400070",
|
||||
"#4000f0",
|
||||
"#408070",
|
||||
"#4080f0",
|
||||
"#c00070",
|
||||
"#c000f0",
|
||||
"#c08070",
|
||||
"#c080f0",
|
||||
"#404030",
|
||||
"#4040b0",
|
||||
"#40c030",
|
||||
"#40c0b0",
|
||||
"#c04030",
|
||||
"#c040b0",
|
||||
"#c0c030",
|
||||
"#c0c0b0",
|
||||
"#404070",
|
||||
"#4040f0",
|
||||
"#40c070",
|
||||
"#40c0f0",
|
||||
"#c04070",
|
||||
"#c040f0",
|
||||
"#c0c070",
|
||||
"#c0c0f0",
|
||||
"#002090",
|
||||
"#00a010",
|
||||
"#00a090",
|
||||
"#802010",
|
||||
"#802090",
|
||||
"#80a010",
|
||||
"#80a090",
|
||||
"#0020d0",
|
||||
"#00a050",
|
||||
"#00a0d0",
|
||||
"#802050",
|
||||
"#8020d0",
|
||||
"#80a050",
|
||||
"#80a0d0",
|
||||
"#006010",
|
||||
"#006090",
|
||||
"#00e010",
|
||||
"#00e090",
|
||||
"#806010",
|
||||
"#806090",
|
||||
"#80e010",
|
||||
"#80e090",
|
||||
"#006050",
|
||||
"#0060d0",
|
||||
"#00e050",
|
||||
"#00e0d0",
|
||||
"#806050",
|
||||
"#8060d0",
|
||||
"#80e050",
|
||||
"#80e0d0",
|
||||
"#402090",
|
||||
"#40a010",
|
||||
"#40a090",
|
||||
"#c02010",
|
||||
"#c02090",
|
||||
"#c0a010",
|
||||
"#c0a090",
|
||||
"#402050",
|
||||
"#4020d0",
|
||||
"#40a050",
|
||||
"#40a0d0",
|
||||
"#c02050",
|
||||
"#c020d0",
|
||||
"#c0a050",
|
||||
"#c0a0d0",
|
||||
"#406010",
|
||||
"#406090",
|
||||
"#40e010",
|
||||
"#40e090",
|
||||
"#c06010",
|
||||
"#c06090",
|
||||
"#c0e010",
|
||||
"#c0e090",
|
||||
"#406050",
|
||||
"#4060d0",
|
||||
"#40e050",
|
||||
"#40e0d0",
|
||||
"#c06050",
|
||||
"#c060d0",
|
||||
"#c0e050",
|
||||
"#c0e0d0",
|
||||
"#0020b0",
|
||||
"#00a030",
|
||||
"#00a0b0",
|
||||
"#802030",
|
||||
"#8020b0",
|
||||
"#80a030",
|
||||
"#80a0b0",
|
||||
"#0020f0",
|
||||
"#00a070",
|
||||
"#00a0f0",
|
||||
"#802070",
|
||||
"#8020f0",
|
||||
"#80a070",
|
||||
"#80a0f0",
|
||||
"#006030",
|
||||
"#0060b0",
|
||||
"#00e030",
|
||||
"#00e0b0",
|
||||
"#806030",
|
||||
"#8060b0",
|
||||
"#80e030",
|
||||
"#80e0b0",
|
||||
"#006070",
|
||||
"#0060f0",
|
||||
"#00e070",
|
||||
"#00e0f0",
|
||||
"#806070",
|
||||
"#8060f0",
|
||||
"#80e070",
|
||||
"#80e0f0",
|
||||
"#4020b0",
|
||||
"#40a030",
|
||||
"#40a0b0",
|
||||
"#c02030",
|
||||
"#c020b0",
|
||||
"#c0a030",
|
||||
"#c0a0b0",
|
||||
"#4020f0",
|
||||
"#40a070",
|
||||
"#40a0f0",
|
||||
"#c02070",
|
||||
"#c020f0",
|
||||
"#c0a070",
|
||||
"#c0a0f0",
|
||||
"#406030",
|
||||
"#4060b0",
|
||||
"#40e030",
|
||||
"#40e0b0",
|
||||
"#c06030",
|
||||
"#c060b0",
|
||||
"#c0e030",
|
||||
"#c0e0b0",
|
||||
"#406070",
|
||||
"#4060f0",
|
||||
"#40e070",
|
||||
"#40e0f0",
|
||||
"#c06070",
|
||||
"#c060f0",
|
||||
"#c0e070",
|
||||
"#208010",
|
||||
"#208090",
|
||||
"#a00010",
|
||||
"#a00090",
|
||||
"#a08010",
|
||||
"#a08090",
|
||||
"#2000d0",
|
||||
"#208050",
|
||||
"#2080d0",
|
||||
"#a00050",
|
||||
"#a000d0",
|
||||
"#a08050",
|
||||
"#a080d0",
|
||||
"#204010",
|
||||
"#204090",
|
||||
"#20c010",
|
||||
"#20c090",
|
||||
"#a04010",
|
||||
"#a04090",
|
||||
"#a0c010",
|
||||
"#a0c090",
|
||||
"#204050",
|
||||
"#2040d0",
|
||||
"#20c050",
|
||||
"#20c0d0",
|
||||
"#a04050",
|
||||
"#a040d0",
|
||||
"#a0c050",
|
||||
"#a0c0d0",
|
||||
"#600090",
|
||||
"#608010",
|
||||
"#608090",
|
||||
"#e00010",
|
||||
"#e00090",
|
||||
"#e08010",
|
||||
"#e08090",
|
||||
"#600050",
|
||||
"#6000d0",
|
||||
"#608050",
|
||||
"#6080d0",
|
||||
"#e00050",
|
||||
"#e000d0",
|
||||
"#e08050",
|
||||
"#e080d0",
|
||||
"#604010",
|
||||
"#604090",
|
||||
"#60c010",
|
||||
"#60c090",
|
||||
"#e04010",
|
||||
"#e04090",
|
||||
"#e0c010",
|
||||
"#e0c090",
|
||||
"#604050",
|
||||
"#6040d0",
|
||||
"#60c050",
|
||||
"#60c0d0",
|
||||
"#e04050",
|
||||
"#e040d0",
|
||||
"#e0c050",
|
||||
"#e0c0d0",
|
||||
"#2000b0",
|
||||
"#208030",
|
||||
"#2080b0",
|
||||
"#a00030",
|
||||
"#a000b0",
|
||||
"#a08030",
|
||||
"#a080b0",
|
||||
"#2000f0",
|
||||
"#208070",
|
||||
"#2080f0",
|
||||
"#a00070",
|
||||
"#a000f0",
|
||||
"#a08070",
|
||||
"#a080f0",
|
||||
"#204030",
|
||||
"#2040b0",
|
||||
"#20c030",
|
||||
"#20c0b0",
|
||||
"#a04030",
|
||||
"#a040b0",
|
||||
"#a0c030",
|
||||
"#a0c0b0",
|
||||
"#204070",
|
||||
"#2040f0",
|
||||
"#20c070",
|
||||
"#20c0f0",
|
||||
"#a04070",
|
||||
"#a040f0",
|
||||
"#a0c070",
|
||||
"#a0c0f0",
|
||||
"#6000b0",
|
||||
"#608030",
|
||||
"#6080b0",
|
||||
"#e00030",
|
||||
"#e000b0",
|
||||
"#e08030",
|
||||
"#e080b0",
|
||||
"#600070",
|
||||
"#6000f0",
|
||||
"#608070",
|
||||
"#e00070",
|
||||
"#e000f0",
|
||||
"#e08070",
|
||||
"#e080f0",
|
||||
"#604030",
|
||||
"#6040b0",
|
||||
"#60c030",
|
||||
"#60c0b0",
|
||||
"#e04030",
|
||||
"#e040b0",
|
||||
"#e0c030",
|
||||
"#e0c0b0",
|
||||
"#604070",
|
||||
"#6040f0",
|
||||
"#60c070",
|
||||
"#60c0f0",
|
||||
"#e04070",
|
||||
"#e040f0",
|
||||
"#e0c070",
|
||||
"#e0c0f0",
|
||||
"#20a010",
|
||||
"#20a090",
|
||||
"#a02010",
|
||||
"#a02090",
|
||||
"#a0a010",
|
||||
"#a0a090",
|
||||
"#2020d0",
|
||||
"#20a050",
|
||||
"#20a0d0",
|
||||
"#a02050",
|
||||
"#a020d0",
|
||||
"#a0a050",
|
||||
"#a0a0d0",
|
||||
"#206010",
|
||||
"#206090",
|
||||
"#20e010",
|
||||
"#20e090",
|
||||
"#a06010",
|
||||
"#a06090",
|
||||
"#a0e010",
|
||||
"#a0e090",
|
||||
"#206050",
|
||||
"#2060d0",
|
||||
"#20e050",
|
||||
"#20e0d0",
|
||||
"#a06050",
|
||||
"#a060d0",
|
||||
"#a0e050",
|
||||
"#a0e0d0",
|
||||
"#602090",
|
||||
"#60a010",
|
||||
"#60a090",
|
||||
"#e02010",
|
||||
"#e02090",
|
||||
"#e0a010",
|
||||
"#e0a090",
|
||||
"#602050",
|
||||
"#6020d0",
|
||||
"#60a050",
|
||||
"#60a0d0",
|
||||
"#e02050",
|
||||
"#e020d0",
|
||||
"#e0a050",
|
||||
"#e0a0d0",
|
||||
"#606010",
|
||||
"#606090",
|
||||
"#60e010",
|
||||
"#60e090",
|
||||
"#e06010",
|
||||
"#e06090",
|
||||
"#e0e010",
|
||||
"#e0e090",
|
||||
"#606050",
|
||||
"#6060d0",
|
||||
"#60e050",
|
||||
"#60e0d0",
|
||||
"#e06050",
|
||||
"#e060d0",
|
||||
"#e0e050",
|
||||
"#2020b0",
|
||||
"#20a030",
|
||||
"#20a0b0",
|
||||
"#a02030",
|
||||
"#a020b0",
|
||||
"#a0a030",
|
||||
"#a0a0b0",
|
||||
"#2020f0",
|
||||
"#20a070",
|
||||
"#20a0f0",
|
||||
"#a02070",
|
||||
"#a020f0",
|
||||
"#a0a070",
|
||||
"#a0a0f0",
|
||||
"#206030",
|
||||
"#2060b0",
|
||||
"#20e030",
|
||||
"#20e0b0",
|
||||
"#a06030",
|
||||
"#a060b0",
|
||||
"#a0e030",
|
||||
"#a0e0b0",
|
||||
"#206070",
|
||||
"#2060f0",
|
||||
"#20e070",
|
||||
"#20e0f0",
|
||||
"#a06070",
|
||||
"#a060f0",
|
||||
"#a0e070",
|
||||
"#a0e0f0",
|
||||
"#6020b0",
|
||||
"#60a030",
|
||||
"#60a0b0",
|
||||
"#e02030",
|
||||
"#e020b0",
|
||||
"#e0a030",
|
||||
"#e0a0b0",
|
||||
"#6020f0",
|
||||
"#60a070",
|
||||
"#60a0f0",
|
||||
"#e02070",
|
||||
"#e020f0",
|
||||
"#e0a070",
|
||||
"#e0a0f0",
|
||||
"#606030",
|
||||
"#6060b0",
|
||||
"#60e030",
|
||||
"#60e0b0",
|
||||
"#e06030",
|
||||
"#e060b0",
|
||||
"#e0e030",
|
||||
"#e0e0b0",
|
||||
"#606070",
|
||||
"#6060f0",
|
||||
"#60e070",
|
||||
"#60e0f0",
|
||||
"#e06070",
|
||||
"#e060f0",
|
||||
"#e0e070",
|
||||
];
|
443
web/ui/mantine-ui/src/pages/query/uPlotChartHelpers.ts
Normal file
443
web/ui/mantine-ui/src/pages/query/uPlotChartHelpers.ts
Normal file
|
@ -0,0 +1,443 @@
|
|||
import { RangeSamples } from "../../api/responseTypes/query";
|
||||
import { formatSeries } from "../../lib/formatSeries";
|
||||
import { formatTimestamp } from "../../lib/formatTime";
|
||||
import { getSeriesColor } from "./colorPool";
|
||||
import { computePosition, shift, flip, offset } from "@floating-ui/dom";
|
||||
import uPlot, { AlignedData, Series } from "uplot";
|
||||
|
||||
const formatYAxisTickValue = (y: number | null): string => {
|
||||
if (y === null) {
|
||||
return "null";
|
||||
}
|
||||
const absY = Math.abs(y);
|
||||
|
||||
if (absY >= 1e24) {
|
||||
return (y / 1e24).toFixed(2) + "Y";
|
||||
} else if (absY >= 1e21) {
|
||||
return (y / 1e21).toFixed(2) + "Z";
|
||||
} else if (absY >= 1e18) {
|
||||
return (y / 1e18).toFixed(2) + "E";
|
||||
} else if (absY >= 1e15) {
|
||||
return (y / 1e15).toFixed(2) + "P";
|
||||
} else if (absY >= 1e12) {
|
||||
return (y / 1e12).toFixed(2) + "T";
|
||||
} else if (absY >= 1e9) {
|
||||
return (y / 1e9).toFixed(2) + "G";
|
||||
} else if (absY >= 1e6) {
|
||||
return (y / 1e6).toFixed(2) + "M";
|
||||
} else if (absY >= 1e3) {
|
||||
return (y / 1e3).toFixed(2) + "k";
|
||||
} else if (absY >= 1) {
|
||||
return y.toFixed(2);
|
||||
} else if (absY === 0) {
|
||||
return y.toFixed(2);
|
||||
} else if (absY < 1e-23) {
|
||||
return (y / 1e-24).toFixed(2) + "y";
|
||||
} else if (absY < 1e-20) {
|
||||
return (y / 1e-21).toFixed(2) + "z";
|
||||
} else if (absY < 1e-17) {
|
||||
return (y / 1e-18).toFixed(2) + "a";
|
||||
} else if (absY < 1e-14) {
|
||||
return (y / 1e-15).toFixed(2) + "f";
|
||||
} else if (absY < 1e-11) {
|
||||
return (y / 1e-12).toFixed(2) + "p";
|
||||
} else if (absY < 1e-8) {
|
||||
return (y / 1e-9).toFixed(2) + "n";
|
||||
} else if (absY < 1e-5) {
|
||||
return (y / 1e-6).toFixed(2) + "µ";
|
||||
} else if (absY < 1e-2) {
|
||||
return (y / 1e-3).toFixed(2) + "m";
|
||||
} else if (absY <= 1) {
|
||||
return y.toFixed(2);
|
||||
}
|
||||
throw Error("couldn't format a value, this is a bug");
|
||||
};
|
||||
|
||||
const escapeHTML = (str: string): string => {
|
||||
const entityMap: { [key: string]: string } = {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
"/": "/",
|
||||
};
|
||||
|
||||
return String(str).replace(/[&<>"'/]/g, function (s) {
|
||||
return entityMap[s];
|
||||
});
|
||||
};
|
||||
|
||||
const formatLabels = (labels: { [key: string]: string }): string => `
|
||||
<div class="labels">
|
||||
${Object.keys(labels).length === 0 ? '<div class="no-labels">no labels</div>' : ""}
|
||||
${labels["__name__"] ? `<div><strong>${escapeHTML(labels["__name__"])}</strong></div>` : ""}
|
||||
${Object.keys(labels)
|
||||
.filter((k) => k !== "__name__")
|
||||
.map(
|
||||
(k) =>
|
||||
`<div><strong>${escapeHTML(k)}</strong>: ${escapeHTML(labels[k])}</div>`
|
||||
)
|
||||
.join("")}
|
||||
</div>`;
|
||||
|
||||
const tooltipPlugin = (useLocalTime: boolean, data: AlignedData) => {
|
||||
let over: HTMLDivElement;
|
||||
let boundingLeft: number;
|
||||
let boundingTop: number;
|
||||
let selectedSeriesIdx: number | null = null;
|
||||
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "u-tooltip";
|
||||
overlay.style.display = "none";
|
||||
|
||||
return {
|
||||
hooks: {
|
||||
// Set up event handlers and append overlay.
|
||||
init: (u: uPlot) => {
|
||||
over = u.over;
|
||||
|
||||
over.addEventListener("mouseenter", () => {
|
||||
overlay.style.display = "block";
|
||||
});
|
||||
|
||||
over.addEventListener("mouseleave", () => {
|
||||
overlay.style.display = "none";
|
||||
});
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
},
|
||||
// When the chart is destroyed, remove the overlay from the DOM.
|
||||
destroy: () => {
|
||||
overlay.remove();
|
||||
},
|
||||
// When the chart is resized, store the bounding box of the overlay.
|
||||
setSize: () => {
|
||||
const bbox = over.getBoundingClientRect();
|
||||
boundingLeft = bbox.left;
|
||||
boundingTop = bbox.top;
|
||||
},
|
||||
// When a series is selected by hovering close to it, store the
|
||||
// index of the selected series, so we can update the hover tooltip
|
||||
// in setCursor.
|
||||
setSeries: (_u: uPlot, seriesIdx: number | null, _opts: Series) => {
|
||||
selectedSeriesIdx = seriesIdx;
|
||||
},
|
||||
// When the cursor is moved, update the tooltip with the current
|
||||
// series value and position it near the cursor.
|
||||
setCursor: (u: uPlot) => {
|
||||
const { left, top, idx } = u.cursor;
|
||||
|
||||
if (
|
||||
idx === null ||
|
||||
idx === undefined ||
|
||||
left === null ||
|
||||
left === undefined ||
|
||||
top === null ||
|
||||
top === undefined ||
|
||||
selectedSeriesIdx === null
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ts = u.data[0][idx];
|
||||
const value = data[selectedSeriesIdx][idx];
|
||||
const series = u.series[selectedSeriesIdx];
|
||||
// @ts-expect-error - uPlot doesn't have a field for labels, but we just attach some anyway.
|
||||
const labels = series.labels;
|
||||
if (typeof series.stroke !== "function") {
|
||||
throw new Error("series.stroke is not a function");
|
||||
}
|
||||
const color = series.stroke(u, selectedSeriesIdx);
|
||||
|
||||
const x = left + boundingLeft;
|
||||
const y = top + boundingTop;
|
||||
|
||||
overlay.innerHTML = `
|
||||
<div class="date">${formatTimestamp(ts, useLocalTime)}</div>
|
||||
<div class="series-value">
|
||||
<span class="detail-swatch" style="background-color: ${color}"></span>
|
||||
<span>${labels.__name__ ? labels.__name__ + ": " : " "}<strong>${value}</strong></span>
|
||||
</div>
|
||||
${formatLabels(labels)}
|
||||
`.trimEnd();
|
||||
|
||||
const virtualEl = {
|
||||
getBoundingClientRect() {
|
||||
return {
|
||||
width: 0,
|
||||
height: 0,
|
||||
x: x,
|
||||
y: y,
|
||||
left: x,
|
||||
right: x,
|
||||
top: y,
|
||||
bottom: y,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
computePosition(virtualEl, overlay, {
|
||||
placement: "right-start",
|
||||
middleware: [offset(5), flip(), shift()],
|
||||
}).then(({ x, y }) => {
|
||||
Object.assign(overlay.style, {
|
||||
top: `${y}px`,
|
||||
left: `${x}px`,
|
||||
});
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// A helper function to automatically create enough space for the Y axis
|
||||
// ticket labels depending on their length.
|
||||
const autoPadLeft = (
|
||||
u: uPlot,
|
||||
values: string[],
|
||||
axisIdx: number,
|
||||
cycleNum: number
|
||||
) => {
|
||||
const axis = u.axes[axisIdx];
|
||||
|
||||
// bail out, force convergence
|
||||
if (cycleNum > 1) {
|
||||
// @ts-expect-error - got this from a uPlot demo example, not sure if it's correct.
|
||||
return axis._size;
|
||||
}
|
||||
|
||||
let axisSize = axis.ticks!.size! + axis.gap!;
|
||||
|
||||
// Find longest tick text.
|
||||
const longestVal = (values ?? []).reduce(
|
||||
(acc, val) => (val.length > acc.length ? val : acc),
|
||||
""
|
||||
);
|
||||
|
||||
if (longestVal != "") {
|
||||
u.ctx.font = axis.font![0];
|
||||
axisSize += u.ctx.measureText(longestVal).width / devicePixelRatio;
|
||||
}
|
||||
|
||||
return Math.ceil(axisSize);
|
||||
};
|
||||
|
||||
// This filter functions ensures that only points that are disconnected
|
||||
// from their neighbors are drawn. Otherwise, we just draw line segments
|
||||
// without dots on them.
|
||||
//
|
||||
// Adapted from https://github.com/leeoniya/uPlot/blob/91de800538ee5d6f45f448d98b660a4a658e587b/demos/points.html#L15-L64
|
||||
const onlyDrawPointsForDisconnectedSamplesFilter = (
|
||||
u: uPlot,
|
||||
seriesIdx: number,
|
||||
show: boolean,
|
||||
gaps?: null | number[][]
|
||||
) => {
|
||||
const filtered = [];
|
||||
|
||||
const series = u.series[seriesIdx];
|
||||
|
||||
if (!show && gaps && gaps.length) {
|
||||
const [firstIdx, lastIdx] = series.idxs!;
|
||||
const xData = u.data[0];
|
||||
const yData = u.data[seriesIdx];
|
||||
const firstPos = Math.round(u.valToPos(xData[firstIdx], "x", true));
|
||||
const lastPos = Math.round(u.valToPos(xData[lastIdx], "x", true));
|
||||
|
||||
if (gaps[0][0] === firstPos) {
|
||||
filtered.push(firstIdx);
|
||||
}
|
||||
|
||||
// show single points between consecutive gaps that share end/start
|
||||
for (let i = 0; i < gaps.length; i++) {
|
||||
const thisGap = gaps[i];
|
||||
const nextGap = gaps[i + 1];
|
||||
|
||||
if (nextGap && thisGap[1] === nextGap[0]) {
|
||||
// approx when data density is > 1pt/px, since gap start/end pixels are rounded
|
||||
let approxIdx = u.posToIdx(thisGap[1], true);
|
||||
|
||||
if (yData[approxIdx] == null) {
|
||||
// scan left/right alternating to find closest index with non-null value
|
||||
for (let j = 1; j < 100; j++) {
|
||||
if (yData[approxIdx + j] != null) {
|
||||
approxIdx += j;
|
||||
break;
|
||||
}
|
||||
if (yData[approxIdx - j] != null) {
|
||||
approxIdx -= j;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
filtered.push(approxIdx);
|
||||
}
|
||||
}
|
||||
|
||||
if (gaps[gaps.length - 1][1] === lastPos) {
|
||||
filtered.push(lastIdx);
|
||||
}
|
||||
}
|
||||
|
||||
return filtered.length ? filtered : null;
|
||||
};
|
||||
|
||||
export const getUPlotOptions = (
|
||||
data: AlignedData,
|
||||
width: number,
|
||||
result: RangeSamples[],
|
||||
useLocalTime: boolean,
|
||||
light: boolean,
|
||||
onSelectRange: (_start: number, _end: number) => void
|
||||
): uPlot.Options => ({
|
||||
width: width - 30,
|
||||
height: 550,
|
||||
cursor: {
|
||||
focus: {
|
||||
prox: 1000,
|
||||
},
|
||||
// Whether dragging on the chart should select a zoom area.
|
||||
drag: {
|
||||
x: true,
|
||||
// Don't zoom into the existing data via uPlot. We want to load new
|
||||
// (finer-grained) data instead, which we do via a setSelect hook.
|
||||
setScale: false,
|
||||
},
|
||||
},
|
||||
tzDate: useLocalTime
|
||||
? undefined
|
||||
: (ts) => uPlot.tzDate(new Date(ts * 1e3), "Etc/UTC"),
|
||||
plugins: [tooltipPlugin(useLocalTime, data)],
|
||||
legend: {
|
||||
show: true,
|
||||
live: false,
|
||||
markers: {
|
||||
fill: (
|
||||
_u: uPlot,
|
||||
seriesIdx: number
|
||||
): CSSStyleDeclaration["borderColor"] =>
|
||||
// Because the index here is coming from uPlot, we need to subtract 1. Series 0
|
||||
// represents the X axis, so we need to skip it.
|
||||
getSeriesColor(seriesIdx - 1, light),
|
||||
},
|
||||
},
|
||||
// @ts-expect-error - uPlot enum types don't work across module boundaries,
|
||||
// see https://github.com/leeoniya/uPlot/issues/973.
|
||||
drawOrder: ["series", "axes"],
|
||||
focus: {
|
||||
alpha: 1,
|
||||
},
|
||||
axes: [
|
||||
// X axis (time).
|
||||
{
|
||||
labelSize: 20,
|
||||
stroke: light ? "#333" : "#eee",
|
||||
ticks: {
|
||||
stroke: light ? "#00000010" : "#ffffff20",
|
||||
},
|
||||
grid: {
|
||||
show: false,
|
||||
stroke: light ? "#eee" : "#333",
|
||||
width: 2,
|
||||
dash: [],
|
||||
},
|
||||
},
|
||||
// Y axis (sample value).
|
||||
{
|
||||
values: (_u: uPlot, splits: number[]) => splits.map(formatYAxisTickValue),
|
||||
ticks: {
|
||||
stroke: light ? "#00000010" : "#ffffff20",
|
||||
},
|
||||
grid: {
|
||||
show: true,
|
||||
stroke: light ? "#00000010" : "#ffffff20",
|
||||
width: 2,
|
||||
dash: [],
|
||||
},
|
||||
labelGap: 8,
|
||||
labelSize: 8 + 12 + 8,
|
||||
stroke: light ? "#333" : "#eee",
|
||||
size: autoPadLeft,
|
||||
},
|
||||
],
|
||||
series: [
|
||||
{},
|
||||
...result.map(
|
||||
(r, idx): uPlot.Series => ({
|
||||
points: {
|
||||
filter: onlyDrawPointsForDisconnectedSamplesFilter,
|
||||
},
|
||||
label: formatSeries(r.metric),
|
||||
width: 1.5,
|
||||
// @ts-expect-error - uPlot doesn't have a field for labels, but we just attach some anyway.
|
||||
labels: r.metric,
|
||||
stroke: getSeriesColor(idx, light),
|
||||
})
|
||||
),
|
||||
],
|
||||
hooks: {
|
||||
setSelect: [
|
||||
(self: uPlot) => {
|
||||
onSelectRange(
|
||||
self.posToVal(self.select.left, "x"),
|
||||
self.posToVal(self.select.left + self.select.width, "x")
|
||||
);
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const parseValue = (value: string): null | number => {
|
||||
const val = parseFloat(value);
|
||||
// "+Inf", "-Inf", "+Inf" will be parsed into NaN by parseFloat(). They
|
||||
// can't be graphed, so show them as gaps (null).
|
||||
return isNaN(val) ? null : val;
|
||||
};
|
||||
|
||||
export const getUPlotData = (
|
||||
inputData: RangeSamples[],
|
||||
startTime: number,
|
||||
endTime: number,
|
||||
resolution: number
|
||||
): uPlot.AlignedData => {
|
||||
const timeData: number[] = [];
|
||||
for (let t = startTime; t <= endTime; t += resolution) {
|
||||
timeData.push(t);
|
||||
}
|
||||
|
||||
const values = inputData.map(({ values, histograms }) => {
|
||||
const data: (number | null)[] = [];
|
||||
let valuePos = 0;
|
||||
let histogramPos = 0;
|
||||
|
||||
for (let t = startTime; t <= endTime; t += resolution) {
|
||||
const currentValue = values && values[valuePos];
|
||||
const currentHistogram = histograms && histograms[histogramPos];
|
||||
|
||||
// Allow for floating point inaccuracy.
|
||||
if (
|
||||
currentValue &&
|
||||
values.length > valuePos &&
|
||||
currentValue[0] < t + resolution / 100
|
||||
) {
|
||||
data.push(parseValue(currentValue[1]));
|
||||
valuePos++;
|
||||
} else if (
|
||||
currentHistogram &&
|
||||
histograms.length > histogramPos &&
|
||||
currentHistogram[0] < t + resolution / 100
|
||||
) {
|
||||
data.push(parseValue(currentHistogram[1].sum));
|
||||
histogramPos++;
|
||||
} else {
|
||||
// Insert nulls for all missing steps.
|
||||
data.push(null);
|
||||
}
|
||||
}
|
||||
return data;
|
||||
});
|
||||
|
||||
return [timeData, ...values];
|
||||
};
|
96
web/ui/mantine-ui/src/pages/query/uPlotStackHelpers.ts
Normal file
96
web/ui/mantine-ui/src/pages/query/uPlotStackHelpers.ts
Normal file
|
@ -0,0 +1,96 @@
|
|||
import { lighten } from "@mantine/core";
|
||||
import uPlot, { AlignedData, TypedArray } from "uplot";
|
||||
|
||||
// Stacking code adapted from https://leeoniya.github.io/uPlot/demos/stack.js
|
||||
function stack(
|
||||
data: uPlot.AlignedData,
|
||||
omit: (i: number) => boolean
|
||||
): { data: uPlot.AlignedData; bands: uPlot.Band[] } {
|
||||
const data2: uPlot.AlignedData = [];
|
||||
let bands: uPlot.Band[] = [];
|
||||
const d0Len = data[0].length;
|
||||
const accum = Array(d0Len);
|
||||
|
||||
for (let i = 0; i < d0Len; i++) {
|
||||
accum[i] = 0;
|
||||
}
|
||||
|
||||
for (let i = 1; i < data.length; i++) {
|
||||
data2.push(
|
||||
(omit(i)
|
||||
? data[i]
|
||||
: data[i].map((v, i) => (accum[i] += +(v || 0)))) as TypedArray
|
||||
);
|
||||
}
|
||||
|
||||
for (let i = 1; i < data.length; i++) {
|
||||
!omit(i) &&
|
||||
bands.push({
|
||||
series: [data.findIndex((_s, j) => j > i && !omit(j)), i],
|
||||
});
|
||||
}
|
||||
|
||||
bands = bands.filter((b) => b.series[1] > -1);
|
||||
|
||||
return {
|
||||
data: [data[0]].concat(data2) as AlignedData,
|
||||
bands,
|
||||
};
|
||||
}
|
||||
|
||||
export function setStackedOpts(opts: uPlot.Options, data: uPlot.AlignedData) {
|
||||
const stacked = stack(data, (_i) => false);
|
||||
opts.bands = stacked.bands;
|
||||
|
||||
opts.cursor = opts.cursor || {};
|
||||
opts.cursor.dataIdx = (_u, seriesIdx, closestIdx, _xValue) =>
|
||||
data[seriesIdx][closestIdx] == null ? null : closestIdx;
|
||||
|
||||
opts.series.forEach((s) => {
|
||||
// s.value = (u, v, si, i) => data[si][i];
|
||||
|
||||
s.points = s.points || {};
|
||||
|
||||
if (s.stroke) {
|
||||
s.fill = lighten(s.stroke as string, 0.6);
|
||||
}
|
||||
|
||||
// scan raw unstacked data to return only real points
|
||||
s.points.filter = (
|
||||
_self: uPlot,
|
||||
seriesIdx: number,
|
||||
show: boolean,
|
||||
_gaps?: null | number[][]
|
||||
): number[] | null => {
|
||||
if (show) {
|
||||
const pts: number[] = [];
|
||||
data[seriesIdx].forEach((v, i) => {
|
||||
v != null && pts.push(i);
|
||||
});
|
||||
return pts;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
});
|
||||
|
||||
// force 0 to be the sum minimum this instead of the bottom series
|
||||
opts.scales = opts.scales || {};
|
||||
opts.scales.y = {
|
||||
range: (_u, _min, max) => {
|
||||
const minMax = uPlot.rangeNum(0, max, 0.1, true);
|
||||
return [0, minMax[1]];
|
||||
},
|
||||
};
|
||||
|
||||
// restack on toggle
|
||||
opts.hooks = opts.hooks || {};
|
||||
opts.hooks.setSeries = opts.hooks.setSeries || [];
|
||||
opts.hooks.setSeries.push((u, _i) => {
|
||||
const stacked = stack(data, (i) => !u.series[i].show);
|
||||
u.delBand(null);
|
||||
stacked.bands.forEach((b) => u.addBand(b));
|
||||
u.setData(stacked.data);
|
||||
});
|
||||
|
||||
return { opts, data: stacked.data };
|
||||
}
|
105
web/ui/mantine-ui/src/pages/query/uplot.css
Normal file
105
web/ui/mantine-ui/src/pages/query/uplot.css
Normal file
|
@ -0,0 +1,105 @@
|
|||
.uplot {
|
||||
.u-legend {
|
||||
text-align: left;
|
||||
margin-left: 25px;
|
||||
|
||||
.u-marker {
|
||||
margin-right: 8px;
|
||||
height: 0.8em;
|
||||
width: 0.8em;
|
||||
}
|
||||
|
||||
th {
|
||||
font-weight: 500;
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
}
|
||||
|
||||
tr {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.u-under {
|
||||
background-color: light-dark(unset, #1f1f1f);
|
||||
}
|
||||
|
||||
.u-over {
|
||||
box-shadow: 0px 0px 0px 0.5px #ccc;
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.u-legend {
|
||||
text-align: left;
|
||||
margin: 20px 25px;
|
||||
}
|
||||
|
||||
.u-inline tr {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.u-label {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.u-select {
|
||||
background: rgba(255, 200, 150, 0.2);
|
||||
}
|
||||
|
||||
.u-hz .u-cursor-x,
|
||||
.u-vt .u-cursor-y {
|
||||
border-right: 1px dashed light-dark(#607d8b, #90adbc);
|
||||
}
|
||||
|
||||
.u-hz .u-cursor-y,
|
||||
.u-vt .u-cursor-x {
|
||||
border-bottom: 1px dashed light-dark(#607d8b, #90adbc);
|
||||
}
|
||||
}
|
||||
|
||||
.u-tooltip {
|
||||
font-size: 0.8em;
|
||||
white-space: nowrap;
|
||||
/* background: var(--mantine-color-gray-7);
|
||||
color: var(--mantine-color-gray-1); */
|
||||
/* background: rgba(0, 0, 0, 0.8); */
|
||||
/* color: #fff; */
|
||||
background: light-dark(rgba(255, 255, 255, 0.95), rgba(25, 25, 25, 0.95));
|
||||
color: light-dark(var(--mantine-color-gray-9), var(--mantine-color-gray-5));
|
||||
border: 2px solid
|
||||
light-dark(var(--mantine-color-gray-6), var(--mantine-color-gray-6));
|
||||
border-radius: 4px;
|
||||
position: absolute;
|
||||
padding: 0.8em 1.5em;
|
||||
margin: 0.75rem;
|
||||
z-index: 10;
|
||||
pointer-events: none;
|
||||
|
||||
.series-value {
|
||||
margin: 5px 0;
|
||||
}
|
||||
|
||||
.series-label {
|
||||
margin-top: 0.5em;
|
||||
margin-bottom: 0.3em;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.labels {
|
||||
font-size: 0.9em;
|
||||
line-height: 1.3em;
|
||||
|
||||
div {
|
||||
margin-bottom: 0.2em;
|
||||
}
|
||||
|
||||
.no-labels {
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-swatch {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue