Merge remote-tracking branch 'upstream/main' into fix-panic-in-ooo-query2

This commit is contained in:
György Krajcsovits 2024-09-09 13:20:20 +02:00
commit cac58b8bb7
146 changed files with 49722 additions and 14219 deletions

3
.gitignore vendored
View file

@ -22,7 +22,8 @@ benchmark.txt
/documentation/examples/remote_storage/example_write_adapter/example_write_adapter /documentation/examples/remote_storage/example_write_adapter/example_write_adapter
npm_licenses.tar.bz2 npm_licenses.tar.bz2
/web/ui/static/react /web/ui/static/react-app
/web/ui/static/mantine-ui
/vendor /vendor
/.build /.build

View file

@ -49,6 +49,10 @@ ui-bump-version:
.PHONY: ui-install .PHONY: ui-install
ui-install: ui-install:
cd $(UI_PATH) && npm 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 .PHONY: ui-build
ui-build: ui-build:
@ -65,6 +69,10 @@ ui-test:
.PHONY: ui-lint .PHONY: ui-lint
ui-lint: ui-lint:
cd $(UI_PATH) && npm run 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 .PHONY: assets
assets: ui-install ui-build assets: ui-install ui-build

View file

@ -259,6 +259,9 @@ func (c *flagConfig) setFeatureListOptions(logger log.Logger) error {
continue continue
case "promql-at-modifier", "promql-negative-offset": 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) 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: default:
level.Warn(logger).Log("msg", "Unknown option for --enable-feature", "option", o) 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("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) Default("").StringsVar(&cfg.featureList)
promlogflag.AddFlags(a, &cfg.promlogConfig) promlogflag.AddFlags(a, &cfg.promlogConfig)

View file

@ -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-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">--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">--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.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` | | <code class="text-nowrap">--log.format</code> | Output format of log messages. One of: [logfmt, json] | `logfmt` |

View file

@ -193,7 +193,7 @@ This should **only** be applied to metrics that currently produce such labels.
`--enable-feature=otlp-write-receiver` `--enable-feature=otlp-write-receiver`
The OTLP receiver allows Prometheus to accept [OpenTelemetry](https://opentelemetry.io/) metrics writes. 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. won't work when you push OTLP metrics.
## Experimental PromQL functions ## 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. 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 ## Metadata WAL Records
`--enable-feature=metadata-wal-records` `--enable-feature=metadata-wal-records`

View file

@ -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 ## Querying metadata
Prometheus offers a set of API endpoints to query metadata about series and their labels. 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_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. - `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. - `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. - `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 ```json

View file

@ -30,8 +30,8 @@ function publish() {
cmd+=" --dry-run" cmd+=" --dry-run"
fi fi
for workspace in ${workspaces}; do for workspace in ${workspaces}; do
# package "app" is private so we shouldn't try to publish it. # package "mantine-ui" is private so we shouldn't try to publish it.
if [[ "${workspace}" != "react-app" ]]; then if [[ "${workspace}" != "mantine-ui" ]]; then
cd "${workspace}" cd "${workspace}"
eval "${cmd}" eval "${cmd}"
cd "${root_ui_folder}" cd "${root_ui_folder}"

View file

@ -366,6 +366,9 @@ func (api *API) Register(r *route.Router) {
r.Get("/format_query", wrapAgent(api.formatQuery)) r.Get("/format_query", wrapAgent(api.formatQuery))
r.Post("/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.Get("/labels", wrapAgent(api.labelNames))
r.Post("/labels", wrapAgent(api.labelNames)) r.Post("/labels", wrapAgent(api.labelNames))
r.Get("/label/:name/values", wrapAgent(api.labelValues)) 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} 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) { func extractQueryOpts(r *http.Request) (promql.QueryOpts, error) {
var duration time.Duration var duration time.Duration

157
web/api/v1/translate_ast.go Normal file
View 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()
}

View file

@ -1,4 +1,5 @@
## Overview ## Overview
The `ui` directory contains static files and templates used in the web UI. For 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 easier distribution they are compressed (c.f. Makefile) and statically compiled
into the Prometheus binary using the embed package. 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 ### Introduction
The react application is a monorepo composed by multiple different npm packages. The main one is `react-app` which This directory contains two generations of Prometheus' React-based web UI:
contains the code of the react application.
* `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 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 ### 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 * npm >= v7
* node >= v20 * 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` npm consults the `package.json` and `package-lock.json` files for dependencies to install. It creates a `node_modules`
directory with all installed dependencies. 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 ### Upgrading npm dependencies
As it is a monorepo, when upgrading a dependency, you have to upgrade it in every packages that composed this monorepo ( 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`) (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. 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 ### 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 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. 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 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` 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 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. 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 ### 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 ```bash
npm test 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`. 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: To generate an HTML-based test coverage report, run:
@ -93,7 +106,7 @@ running tests.
### Building the app for production ### 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 npm run build
@ -102,10 +115,10 @@ Prometheus `Makefile` when building the full binary.
### Integration into Prometheus ### 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: root of the repository and run:
make build 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. assets into the Prometheus binary.

View file

@ -31,9 +31,16 @@ function buildModule() {
function buildReactApp() { function buildReactApp() {
echo "build react-app" echo "build react-app"
npm run build -w @prometheus-io/app (cd react-app && npm run build)
rm -rf ./static/react rm -rf ./static/react-app
mv ./react-app/build ./static/react 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 for i in "$@"; do
@ -41,6 +48,7 @@ for i in "$@"; do
--all) --all)
buildModule buildModule
buildReactApp buildReactApp
buildMantineUI
shift shift
;; ;;
--build-module) --build-module)

24
web/ui/mantine-ui/.gitignore vendored Normal file
View 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?

View 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

View 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
},
},
},
];

View 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>

View 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"
}
}

View 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',
},
},
},
};

View 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

View 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");
}

View 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;

View 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));
}

View 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;
}

View 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 }),
});
};

View 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[];
};

View 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;
}

View 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[];

View 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 }[]
>;

View 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[];
};

View 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[];
}

View file

@ -0,0 +1,2 @@
// Result type for /api/v1/scrape_pools endpoint.
export type ScrapePoolsResult = { scrapePools: string[] };

View 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[];

View 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>;
};

View 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[];
}

View 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;
}

View 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" },
]);

View 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;

View 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;

View 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;

View 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>
);

View 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;

View 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;
}

View 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;

View 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;

View 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>
);
};

View 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>
),
},
]}
/>
);
};

Binary file not shown.

View 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

View 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");
};

View 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();
}
};

View 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(", ")}}`;
};

View 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();

View 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>
);

View 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;

View 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>
);
}

View 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>
);
}

View 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"
/>
);
}

View 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);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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;
}

View 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;

View 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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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
&nbsp;&nbsp;&nbsp;&nbsp;
<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;

View file

@ -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;
}

View 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;

View 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;

View 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;
}
}

View 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;

View 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;

View 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;
}

View 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;

View 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;
}

View 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;

View 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)}`;
};

View file

@ -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)
);
}

View file

@ -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;

View file

@ -0,0 +1,7 @@
.typeLabel {
color: light-dark(#008080, #14bfad);
}
.helpLabel {
color: light-dark(#800000, #ff8585);
}

View file

@ -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;

View 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>
);
}

View 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;
}
}

View 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;

View 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;

View 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;

View 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 {
}

View 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;

View 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 &ensp; 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;

View 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;

View 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));
}

View 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"}
&nbsp;&nbsp;&nbsp;&nbsp;
{responseTime}ms
{resultStats.sortedLabelCards.length > 0 && (
<>&nbsp;&nbsp;&nbsp;&nbsp;</>
)}
</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;

View 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;

View 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;

View 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",
];

View 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 } = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
"/": "&#x2F;",
};
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];
};

View 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 };
}

View 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