mirror of
https://github.com/VictoriaMetrics/VictoriaMetrics.git
synced 2025-01-30 15:22:07 +00:00
Merge branch 'public-single-node' into pmm-6401-read-prometheus-data-files
This commit is contained in:
commit
8df8c414de
280 changed files with 28470 additions and 3885 deletions
30
.github/workflows/github-pages.yml
vendored
30
.github/workflows/github-pages.yml
vendored
|
@ -1,30 +0,0 @@
|
|||
name: github-pages
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- 'docs/*'
|
||||
- 'README.md'
|
||||
branches:
|
||||
- master
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@master
|
||||
- name: publish
|
||||
shell: bash
|
||||
env:
|
||||
TOKEN: ${{secrets.CI_TOKEN}}
|
||||
run: |
|
||||
git clone https://vika:${TOKEN}@github.com/VictoriaMetrics/VictoriaMetrics.github.io.git gpages
|
||||
cp docs/* gpages
|
||||
cp README.md gpages
|
||||
cd gpages
|
||||
git config --local user.email "info@victoriametrics.com"
|
||||
git config --local user.name "Vika"
|
||||
git add .
|
||||
git commit -m "update github pages"
|
||||
remote_repo="https://vika:${TOKEN}@github.com/VictoriaMetrics/VictoriaMetrics.github.io.git"
|
||||
git push "${remote_repo}"
|
||||
cd ..
|
||||
rm -rf gpages
|
2
.github/workflows/wiki.yml
vendored
2
.github/workflows/wiki.yml
vendored
|
@ -16,7 +16,7 @@ jobs:
|
|||
TOKEN: ${{secrets.CI_TOKEN}}
|
||||
run: |
|
||||
git clone https://vika:${TOKEN}@github.com/VictoriaMetrics/VictoriaMetrics.wiki.git wiki
|
||||
cp docs/* wiki
|
||||
cp -r docs/* wiki
|
||||
cd wiki
|
||||
git config --local user.email "info@victoriametrics.com"
|
||||
git config --local user.name "Vika"
|
||||
|
|
1
Makefile
1
Makefile
|
@ -277,6 +277,7 @@ copy-docs:
|
|||
# Cluster docs are supposed to be ordered as 9th.
|
||||
# For The rest of docs is ordered manually.t
|
||||
docs-sync:
|
||||
cp README.md docs/README.md
|
||||
SRC=README.md DST=docs/Single-server-VictoriaMetrics.md ORDER=1 $(MAKE) copy-docs
|
||||
SRC=app/vmagent/README.md DST=docs/vmagent.md ORDER=3 $(MAKE) copy-docs
|
||||
SRC=app/vmalert/README.md DST=docs/vmalert.md ORDER=4 $(MAKE) copy-docs
|
||||
|
|
80
README.md
80
README.md
|
@ -2,7 +2,7 @@
|
|||
|
||||
[![Latest Release](https://img.shields.io/github/release/VictoriaMetrics/VictoriaMetrics.svg?style=flat-square)](https://github.com/VictoriaMetrics/VictoriaMetrics/releases/latest)
|
||||
[![Docker Pulls](https://img.shields.io/docker/pulls/victoriametrics/victoria-metrics.svg?maxAge=604800)](https://hub.docker.com/r/victoriametrics/victoria-metrics)
|
||||
[![Slack](https://img.shields.io/badge/join%20slack-%23victoriametrics-brightgreen.svg)](http://slack.victoriametrics.com/)
|
||||
[![Slack](https://img.shields.io/badge/join%20slack-%23victoriametrics-brightgreen.svg)](https://slack.victoriametrics.com/)
|
||||
[![GitHub license](https://img.shields.io/github/license/VictoriaMetrics/VictoriaMetrics.svg)](https://github.com/VictoriaMetrics/VictoriaMetrics/blob/master/LICENSE)
|
||||
[![Go Report](https://goreportcard.com/badge/github.com/VictoriaMetrics/VictoriaMetrics)](https://goreportcard.com/report/github.com/VictoriaMetrics/VictoriaMetrics)
|
||||
[![Build Status](https://github.com/VictoriaMetrics/VictoriaMetrics/workflows/main/badge.svg)](https://github.com/VictoriaMetrics/VictoriaMetrics/actions)
|
||||
|
@ -346,6 +346,7 @@ Currently the following [scrape_config](https://prometheus.io/docs/prometheus/la
|
|||
* [consul_sd_config](https://prometheus.io/docs/prometheus/latest/configuration/configuration/#consul_sd_config)
|
||||
* [dns_sd_config](https://prometheus.io/docs/prometheus/latest/configuration/configuration/#dns_sd_config)
|
||||
* [openstack_sd_config](https://prometheus.io/docs/prometheus/latest/configuration/configuration/#openstack_sd_config)
|
||||
* [docker_sd_config](https://prometheus.io/docs/prometheus/latest/configuration/configuration/#docker_sd_config)
|
||||
* [dockerswarm_sd_config](https://prometheus.io/docs/prometheus/latest/configuration/configuration/#dockerswarm_sd_config)
|
||||
* [eureka_sd_config](https://prometheus.io/docs/prometheus/latest/configuration/configuration/#eureka_sd_config)
|
||||
* [digitalocean_sd_config](https://prometheus.io/docs/prometheus/latest/configuration/configuration/#digitalocean_sd_config)
|
||||
|
@ -576,15 +577,9 @@ VictoriaMetrics accepts `round_digits` query arg for `/api/v1/query` and `/api/v
|
|||
|
||||
By default, VictoriaMetrics returns time series for the last 5 minutes from `/api/v1/series`, while the Prometheus API defaults to all time. Use `start` and `end` to select a different time range.
|
||||
|
||||
VictoriaMetrics accepts additional args for `/api/v1/labels` and `/api/v1/label/.../values` handlers.
|
||||
|
||||
* Any number [time series selectors](https://prometheus.io/docs/prometheus/latest/querying/basics/#time-series-selectors) via `match[]` query arg.
|
||||
* Optional `start` and `end` query args for limiting the time range for the selected labels or label values.
|
||||
|
||||
See [this feature request](https://github.com/prometheus/prometheus/issues/6178) for details.
|
||||
|
||||
Additionally VictoriaMetrics provides the following handlers:
|
||||
|
||||
* `/vmui` - Basic Web UI
|
||||
* `/api/v1/series/count` - returns the total number of time series in the database. Some notes:
|
||||
* the handler scans all the inverted index, so it can be slow if the database contains tens of millions of time series;
|
||||
* the handler may count [deleted time series](#how-to-delete-time-series) additionally to normal time series due to internal implementation restrictions;
|
||||
|
@ -663,7 +658,7 @@ to your needs or when testing bugfixes.
|
|||
|
||||
### Development build
|
||||
|
||||
1. [Install Go](https://golang.org/doc/install). The minimum supported version is Go 1.15.
|
||||
1. [Install Go](https://golang.org/doc/install). The minimum supported version is Go 1.16.
|
||||
2. Run `make victoria-metrics` from the root folder of [the repository](https://github.com/VictoriaMetrics/VictoriaMetrics).
|
||||
It builds `victoria-metrics` binary and puts it into the `bin` folder.
|
||||
|
||||
|
@ -679,7 +674,7 @@ ARM build may run on Raspberry Pi or on [energy-efficient ARM servers](https://b
|
|||
|
||||
### Development ARM build
|
||||
|
||||
1. [Install Go](https://golang.org/doc/install). The minimum supported version is Go 1.15.
|
||||
1. [Install Go](https://golang.org/doc/install). The minimum supported version is Go 1.16.
|
||||
2. Run `make victoria-metrics-arm` or `make victoria-metrics-arm64` from the root folder of [the repository](https://github.com/VictoriaMetrics/VictoriaMetrics).
|
||||
It builds `victoria-metrics-arm` or `victoria-metrics-arm64` binary respectively and puts it into the `bin` folder.
|
||||
|
||||
|
@ -693,7 +688,7 @@ ARM build may run on Raspberry Pi or on [energy-efficient ARM servers](https://b
|
|||
|
||||
`Pure Go` mode builds only Go code without [cgo](https://golang.org/cmd/cgo/) dependencies.
|
||||
|
||||
1. [Install Go](https://golang.org/doc/install). The minimum supported version is Go 1.15.
|
||||
1. [Install Go](https://golang.org/doc/install). The minimum supported version is Go 1.16.
|
||||
2. Run `make victoria-metrics-pure` from the root folder of [the repository](https://github.com/VictoriaMetrics/VictoriaMetrics).
|
||||
It builds `victoria-metrics-pure` binary and puts it into the `bin` folder.
|
||||
|
||||
|
@ -1094,46 +1089,28 @@ on the interval `[now - max_lookback ... now]` is scraped for each time series.
|
|||
For instance, `/federate?match[]=up&max_lookback=1h` would return last points on the `[now - 1h ... now]` interval. This may be useful for time series federation
|
||||
with scrape intervals exceeding `5m`.
|
||||
|
||||
|
||||
## Capacity planning
|
||||
|
||||
A rough estimation of the required resources for ingestion path:
|
||||
VictoriaMetrics uses lower amounts of CPU, RAM and storage space on production workloads compared to competing solutions (Prometheus, Thanos, Cortex, TimescaleDB, InfluxDB, QuestDB, M3DB) according to [our case studies](https://docs.victoriametrics.com/CaseStudies.html).
|
||||
|
||||
* RAM size: less than 1KB per active time series. So, ~1GB of RAM is required for 1M active time series.
|
||||
Time series is considered active if new data points have been added to it recently or if it has been recently queried.
|
||||
The number of active time series may be obtained from `vm_cache_entries{type="storage/hour_metric_ids"}` metric
|
||||
exported on the `/metrics` page.
|
||||
VictoriaMetrics stores various caches in RAM. Memory size for these caches may be limited with `-memory.allowedPercent` or `-memory.allowedBytes` flags.
|
||||
VictoriaMetrics capacity scales linearly with the available resources. The needed amounts of CPU and RAM highly depends on the workload - the number of active time series, series churn rate, query types, query qps, etc. It is recommended setting up a test VictoriaMetrics for your production workload and iteratively scaling CPU and RAM resources until it becomes stable according to [troubleshooting docs](#troubleshooting). A single-node VictoriaMetrics works perfectly with the following production workload according to [our case studies](https://docs.victoriametrics.com/CaseStudies.html):
|
||||
|
||||
* CPU cores: a CPU core per 300K inserted data points per second. So, ~4 CPU cores are required for processing
|
||||
the insert stream of 1M data points per second. The ingestion rate may be lower for high cardinality data or for time series with high number of labels.
|
||||
See [this article](https://medium.com/@valyala/insert-benchmarks-with-inch-influxdb-vs-victoriametrics-e31a41ae2893) for details.
|
||||
If you see lower numbers per CPU core, then it is likely active time series info doesn't fit caches,
|
||||
so you need more RAM for lowering CPU usage.
|
||||
* Ingestion rate: 1.5+ million samples per second
|
||||
* Active time series: 50+ million
|
||||
* Total time series: 5+ billion
|
||||
* Time series churn rate: 150+ million of new series per day
|
||||
* Total number of samples: 10+ trillion
|
||||
* Queries: 200+ qps
|
||||
* Query latency (99th percentile): 1 second
|
||||
|
||||
* Storage space: less than a byte per data point on average. So, ~260GB is required for storing a month-long insert stream
|
||||
of 100K data points per second.
|
||||
The actual storage size heavily depends on data randomness (entropy) and the average number of samples per time series.
|
||||
Higher randomness means higher storage size requirements. Lower average number of samples per time series means higher storage requirement.
|
||||
Read [this article](https://medium.com/faun/victoriametrics-achieving-better-compression-for-time-series-data-than-gorilla-317bc1f95932)
|
||||
for details.
|
||||
The needed storage space for the given retention (the retention is set via `-retentionPeriod` command-line flag) can be extrapolated from disk space usage in a test run. For example, if `-storageDataPath` directory size becomes 10GB after a day-long test run on a production workload, then it will need at least `10GB*100=1TB` of disk space for `-retentionPeriod=100d` (100-days retention period).
|
||||
|
||||
* Network usage: outbound traffic is negligible. Ingress traffic is ~100 bytes per ingested data point via
|
||||
[Prometheus remote_write API](https://prometheus.io/docs/prometheus/latest/configuration/configuration/#remote_write).
|
||||
The actual ingress bandwidth usage depends on the average number of labels per ingested metric and the average size
|
||||
of label values. The higher number of per-metric labels and longer label values mean the higher ingress bandwidth.
|
||||
It is recommended leaving the following amounts of spare resources:
|
||||
|
||||
The required resources for query path:
|
||||
|
||||
* RAM size: depends on the number of time series to scan in each query and the `step`
|
||||
argument passed to [/api/v1/query_range](https://prometheus.io/docs/prometheus/latest/querying/api/#range-queries).
|
||||
The higher number of scanned time series and lower `step` argument results in the higher RAM usage.
|
||||
|
||||
* CPU cores: a CPU core per 30 millions of scanned data points per second.
|
||||
This means that heavy queries that touch big number of time series (over 10K) and/or big number data points (over 100M)
|
||||
usually require more CPU resources than tiny queries that touch a few time series with small number of data points.
|
||||
|
||||
* Network usage: depends on the frequency and the type of incoming requests. Typical Grafana dashboards usually
|
||||
require negligible network bandwidth.
|
||||
* 50% of free RAM for reducing the probability of OOM (out of memory) crashes and slowdowns during temporary spikes in workload.
|
||||
* 50% of spare CPU for reducing the probability of slowdowns during temporary spikes in workload.
|
||||
* At least 30% of free storage space at the directory pointed by `-storageDataPath` command-line flag.
|
||||
|
||||
|
||||
## High availability
|
||||
|
@ -1428,6 +1405,11 @@ These limits are approximate, so VictoriaMetrics can underflow/overflow the limi
|
|||
* VictoriaMetrics ignores `NaN` values during data ingestion.
|
||||
|
||||
|
||||
## Cache removal
|
||||
|
||||
VictoriaMetrics uses various internal caches. These caches are stored to `<-storageDataPath>/cache` directory during graceful shutdown (e.g. when VictoriaMetrics is stopped by sending `SIGINT` signal). The caches are read on the next VictoriaMetrics startup. Sometimes it is needed to remove such caches on the next startup. This can be performed by placing `reset_cache_on_startup` file inside the `<-storageDataPath>/cache` directory before the restart of VictoriaMetrics. See [this issue](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/1447) for details.
|
||||
|
||||
|
||||
## Data migration
|
||||
|
||||
Use [vmctl](https://docs.victoriametrics.com/vmctl.html) for data migration. It supports the following data migration types:
|
||||
|
@ -1533,7 +1515,7 @@ Contact us with any questions regarding VictoriaMetrics at [info@victoriametrics
|
|||
|
||||
Feel free asking any questions regarding VictoriaMetrics:
|
||||
|
||||
* [slack](http://slack.victoriametrics.com/)
|
||||
* [slack](https://slack.victoriametrics.com/)
|
||||
* [reddit](https://www.reddit.com/r/VictoriaMetrics/)
|
||||
* [telegram-en](https://t.me/VictoriaMetrics_en)
|
||||
* [telegram-ru](https://t.me/VictoriaMetrics_ru1)
|
||||
|
@ -1601,7 +1583,7 @@ Pass `-help` to VictoriaMetrics in order to see the list of supported command-li
|
|||
-csvTrimTimestamp duration
|
||||
Trim timestamps when importing csv data to this duration. Minimum practical duration is 1ms. Higher duration (i.e. 1s) may be used for reducing disk space usage for timestamp data (default 1ms)
|
||||
-dedup.minScrapeInterval duration
|
||||
Remove superflouos samples from time series if they are located closer to each other than this duration. This may be useful for reducing overhead when multiple identically configured Prometheus instances write data to the same VictoriaMetrics. Deduplication is disabled if the -dedup.minScrapeInterval is 0
|
||||
Leave only the first sample in every time series per each discrete interval equal to -dedup.minScrapeInterval > 0. See https://docs.victoriametrics.com/#deduplication for details
|
||||
-deleteAuthKey string
|
||||
authKey for metrics' deletion via /api/v1/admin/tsdb/delete_series and /tags/delSeries
|
||||
-denyQueriesOutsideRetention
|
||||
|
@ -1729,7 +1711,7 @@ Pass `-help` to VictoriaMetrics in order to see the list of supported command-li
|
|||
-promscrape.consulSDCheckInterval duration
|
||||
Interval for checking for changes in Consul. This works only if consul_sd_configs is configured in '-promscrape.config' file. See https://prometheus.io/docs/prometheus/latest/configuration/configuration/#consul_sd_config for details (default 30s)
|
||||
-promscrape.digitaloceanSDCheckInterval duration
|
||||
Interval for checking for changes in digital ocean. This works only if digitalocean_sd_configs is configured in '-promscrape.config' file. See https://prometheus.io/docs/prometheus/latest/configuration/configuration/#digitalocean_sd_config for details (default 1m0s)
|
||||
Interval for checking for changes in digital ocean. This works only if digitalocean_sd_configs is configured in '-promscrape.config' file. See https://prometheus.io/docs/prometheus/latest/configuration/configuration/#digitalocean_sd_config for details (default 1m0s)
|
||||
-promscrape.disableCompression
|
||||
Whether to disable sending 'Accept-Encoding: gzip' request headers to all the scrape targets. This may reduce CPU usage on scrape targets at the cost of higher network bandwidth utilization. It is possible to set 'disable_compression: true' individually per each 'scrape_config' section in '-promscrape.config' for fine grained control
|
||||
-promscrape.disableKeepAlive
|
||||
|
@ -1740,6 +1722,8 @@ Pass `-help` to VictoriaMetrics in order to see the list of supported command-li
|
|||
The maximum duration for waiting to perform API requests if more than -promscrape.discovery.concurrency requests are simultaneously performed (default 1m0s)
|
||||
-promscrape.dnsSDCheckInterval duration
|
||||
Interval for checking for changes in dns. This works only if dns_sd_configs is configured in '-promscrape.config' file. See https://prometheus.io/docs/prometheus/latest/configuration/configuration/#dns_sd_config for details (default 30s)
|
||||
-promscrape.dockerSDCheckInterval duration
|
||||
Interval for checking for changes in docker. This works only if docker_sd_configs is configured in '-promscrape.config' file. See https://prometheus.io/docs/prometheus/latest/configuration/configuration/#docker_sd_config for details (default 30s)
|
||||
-promscrape.dockerswarmSDCheckInterval duration
|
||||
Interval for checking for changes in dockerswarm. This works only if dockerswarm_sd_configs is configured in '-promscrape.config' file. See https://prometheus.io/docs/prometheus/latest/configuration/configuration/#dockerswarm_sd_config for details (default 30s)
|
||||
-promscrape.dropOriginalLabels
|
||||
|
@ -1753,7 +1737,7 @@ Pass `-help` to VictoriaMetrics in order to see the list of supported command-li
|
|||
-promscrape.gceSDCheckInterval duration
|
||||
Interval for checking for changes in gce. This works only if gce_sd_configs is configured in '-promscrape.config' file. See https://prometheus.io/docs/prometheus/latest/configuration/configuration/#gce_sd_config for details (default 1m0s)
|
||||
-promscrape.httpSDCheckInterval duration
|
||||
Interval for checking for changes in http service discovery. This works only if http_sd_configs is configured in '-promscrape.config' file. See https://prometheus.io/docs/prometheus/latest/configuration/configuration/#http_sd_config for details (default 1m0s)
|
||||
Interval for checking for changes in http endpoint service discovery. This works only if http_sd_configs is configured in '-promscrape.config' file. See https://prometheus.io/docs/prometheus/latest/configuration/configuration/#http_sd_config for details (default 1m0s)
|
||||
-promscrape.kubernetes.apiServerTimeout duration
|
||||
How frequently to reload the full state from Kuberntes API server (default 30m0s)
|
||||
-promscrape.kubernetesSDCheckInterval duration
|
||||
|
|
|
@ -24,9 +24,8 @@ import (
|
|||
|
||||
var (
|
||||
httpListenAddr = flag.String("httpListenAddr", ":8428", "TCP address to listen for http connections")
|
||||
minScrapeInterval = flag.Duration("dedup.minScrapeInterval", 0, "Remove superflouos samples from time series if they are located closer to each other than this duration. "+
|
||||
"This may be useful for reducing overhead when multiple identically configured Prometheus instances write data to the same VictoriaMetrics. "+
|
||||
"Deduplication is disabled if the -dedup.minScrapeInterval is 0")
|
||||
minScrapeInterval = flag.Duration("dedup.minScrapeInterval", 0, "Leave only the first sample in every time series per each discrete interval "+
|
||||
"equal to -dedup.minScrapeInterval > 0. See https://docs.victoriametrics.com/#deduplication for details")
|
||||
dryRun = flag.Bool("dryRun", false, "Whether to check only -promscrape.config and then exit. "+
|
||||
"Unknown config entries are allowed in -promscrape.config by default. This can be changed with -promscrape.config.strictParse")
|
||||
)
|
||||
|
@ -97,6 +96,7 @@ func requestHandler(w http.ResponseWriter, r *http.Request) bool {
|
|||
fmt.Fprintf(w, "See docs at <a href='https://docs.victoriametrics.com/'>https://docs.victoriametrics.com/</a></br>")
|
||||
fmt.Fprintf(w, "Useful endpoints:</br>")
|
||||
httpserver.WriteAPIHelp(w, [][2]string{
|
||||
{"/vmui", "Web UI"},
|
||||
{"/targets", "discovered targets list"},
|
||||
{"/api/v1/targets", "advanced information about discovered targets in JSON format"},
|
||||
{"/metrics", "available service metrics"},
|
||||
|
|
|
@ -173,6 +173,8 @@ The following scrape types in [scrape_config](https://prometheus.io/docs/prometh
|
|||
* `openstack_sd_configs` - is for scraping OpenStack targets.
|
||||
See [openstack_sd_config](https://prometheus.io/docs/prometheus/latest/configuration/configuration/#openstack_sd_config) for details.
|
||||
[OpenStack identity API v3](https://docs.openstack.org/api-ref/identity/v3/) is supported only.
|
||||
* `docker_sd_configs` - is for scraping Docker targets.
|
||||
See [docker_sd_config](https://prometheus.io/docs/prometheus/latest/configuration/configuration/#docker_sd_config) for details.
|
||||
* `dockerswarm_sd_configs` - is for scraping Docker Swarm targets.
|
||||
See [dockerswarm_sd_config](https://prometheus.io/docs/prometheus/latest/configuration/configuration/#dockerswarm_sd_config) for details.
|
||||
* `eureka_sd_configs` - is for scraping targets registered in [Netflix Eureka](https://github.com/Netflix/eureka).
|
||||
|
@ -447,7 +449,7 @@ We recommend using [binary releases](https://github.com/VictoriaMetrics/Victoria
|
|||
|
||||
### Development build
|
||||
|
||||
1. [Install Go](https://golang.org/doc/install). The minimum supported version is Go 1.15.
|
||||
1. [Install Go](https://golang.org/doc/install). The minimum supported version is Go 1.16.
|
||||
2. Run `make vmagent` from the root folder of [the repository](https://github.com/VictoriaMetrics/VictoriaMetrics).
|
||||
It builds the `vmagent` binary and puts it into the `bin` folder.
|
||||
|
||||
|
@ -476,7 +478,7 @@ ARM build may run on Raspberry Pi or on [energy-efficient ARM servers](https://b
|
|||
|
||||
### Development ARM build
|
||||
|
||||
1. [Install Go](https://golang.org/doc/install). The minimum supported version is Go 1.15.
|
||||
1. [Install Go](https://golang.org/doc/install). The minimum supported version is Go 1.16.
|
||||
2. Run `make vmagent-arm` or `make vmagent-arm64` from the root folder of [the repository](https://github.com/VictoriaMetrics/VictoriaMetrics)
|
||||
It builds `vmagent-arm` or `vmagent-arm64` binary respectively and puts it into the `bin` folder.
|
||||
|
||||
|
|
|
@ -165,7 +165,7 @@ func requestHandler(w http.ResponseWriter, r *http.Request) bool {
|
|||
prometheusWriteRequests.Inc()
|
||||
if err := promremotewrite.InsertHandler(r); err != nil {
|
||||
prometheusWriteErrors.Inc()
|
||||
httpserver.Errorf(w, r, "error in %q: %s", r.URL.Path, err)
|
||||
httpserver.Errorf(w, r, "%s", err)
|
||||
return true
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
|
@ -174,7 +174,7 @@ func requestHandler(w http.ResponseWriter, r *http.Request) bool {
|
|||
vmimportRequests.Inc()
|
||||
if err := vmimport.InsertHandler(r); err != nil {
|
||||
vmimportErrors.Inc()
|
||||
httpserver.Errorf(w, r, "error in %q: %s", r.URL.Path, err)
|
||||
httpserver.Errorf(w, r, "%s", err)
|
||||
return true
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
|
@ -183,7 +183,7 @@ func requestHandler(w http.ResponseWriter, r *http.Request) bool {
|
|||
csvimportRequests.Inc()
|
||||
if err := csvimport.InsertHandler(r); err != nil {
|
||||
csvimportErrors.Inc()
|
||||
httpserver.Errorf(w, r, "error in %q: %s", r.URL.Path, err)
|
||||
httpserver.Errorf(w, r, "%s", err)
|
||||
return true
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
|
@ -192,7 +192,7 @@ func requestHandler(w http.ResponseWriter, r *http.Request) bool {
|
|||
prometheusimportRequests.Inc()
|
||||
if err := prometheusimport.InsertHandler(r); err != nil {
|
||||
prometheusimportErrors.Inc()
|
||||
httpserver.Errorf(w, r, "error in %q: %s", r.URL.Path, err)
|
||||
httpserver.Errorf(w, r, "%s", err)
|
||||
return true
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
|
@ -201,7 +201,7 @@ func requestHandler(w http.ResponseWriter, r *http.Request) bool {
|
|||
nativeimportRequests.Inc()
|
||||
if err := native.InsertHandler(r); err != nil {
|
||||
nativeimportErrors.Inc()
|
||||
httpserver.Errorf(w, r, "error in %q: %s", r.URL.Path, err)
|
||||
httpserver.Errorf(w, r, "%s", err)
|
||||
return true
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
|
@ -210,7 +210,7 @@ func requestHandler(w http.ResponseWriter, r *http.Request) bool {
|
|||
influxWriteRequests.Inc()
|
||||
if err := influx.InsertHandlerForHTTP(r); err != nil {
|
||||
influxWriteErrors.Inc()
|
||||
httpserver.Errorf(w, r, "error in %q: %s", r.URL.Path, err)
|
||||
httpserver.Errorf(w, r, "%s", err)
|
||||
return true
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
|
|
|
@ -558,7 +558,7 @@ It is recommended using
|
|||
|
||||
### Development build
|
||||
|
||||
1. [Install Go](https://golang.org/doc/install). The minimum supported version is Go 1.15.
|
||||
1. [Install Go](https://golang.org/doc/install). The minimum supported version is Go 1.16.
|
||||
2. Run `make vmalert` from the root folder of [the repository](https://github.com/VictoriaMetrics/VictoriaMetrics).
|
||||
It builds `vmalert` binary and puts it into the `bin` folder.
|
||||
|
||||
|
@ -575,7 +575,7 @@ ARM build may run on Raspberry Pi or on [energy-efficient ARM servers](https://b
|
|||
|
||||
### Development ARM build
|
||||
|
||||
1. [Install Go](https://golang.org/doc/install). The minimum supported version is Go 1.15.
|
||||
1. [Install Go](https://golang.org/doc/install). The minimum supported version is Go 1.16.
|
||||
2. Run `make vmalert-arm` or `make vmalert-arm64` from the root folder of [the repository](https://github.com/VictoriaMetrics/VictoriaMetrics).
|
||||
It builds `vmalert-arm` or `vmalert-arm64` binary respectively and puts it into the `bin` folder.
|
||||
|
||||
|
|
|
@ -8,7 +8,6 @@ import (
|
|||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/VictoriaMetrics/VictoriaMetrics/app/vmalert/datasource"
|
||||
|
||||
|
@ -16,7 +15,6 @@ import (
|
|||
"github.com/VictoriaMetrics/VictoriaMetrics/app/vmalert/utils"
|
||||
"github.com/VictoriaMetrics/VictoriaMetrics/lib/envtemplate"
|
||||
"github.com/VictoriaMetrics/VictoriaMetrics/lib/logger"
|
||||
"github.com/VictoriaMetrics/metricsql"
|
||||
"gopkg.in/yaml.v2"
|
||||
)
|
||||
|
||||
|
@ -25,10 +23,10 @@ import (
|
|||
type Group struct {
|
||||
Type datasource.Type `yaml:"type,omitempty"`
|
||||
File string
|
||||
Name string `yaml:"name"`
|
||||
Interval time.Duration `yaml:"interval,omitempty"`
|
||||
Rules []Rule `yaml:"rules"`
|
||||
Concurrency int `yaml:"concurrency"`
|
||||
Name string `yaml:"name"`
|
||||
Interval utils.PromDuration `yaml:"interval,omitempty"`
|
||||
Rules []Rule `yaml:"rules"`
|
||||
Concurrency int `yaml:"concurrency"`
|
||||
// ExtraFilterLabels is a list label filters applied to every rule
|
||||
// request withing a group. Is compatible only with VM datasources.
|
||||
// See https://docs.victoriametrics.com#prometheus-querying-api-enhancements
|
||||
|
@ -119,54 +117,18 @@ func (g *Group) Validate(validateAnnotations, validateExpressions bool) error {
|
|||
// recording rule or alerting rule.
|
||||
type Rule struct {
|
||||
ID uint64
|
||||
Type datasource.Type `yaml:"type,omitempty"`
|
||||
Record string `yaml:"record,omitempty"`
|
||||
Alert string `yaml:"alert,omitempty"`
|
||||
Expr string `yaml:"expr"`
|
||||
For PromDuration `yaml:"for"`
|
||||
Labels map[string]string `yaml:"labels,omitempty"`
|
||||
Annotations map[string]string `yaml:"annotations,omitempty"`
|
||||
Type datasource.Type `yaml:"type,omitempty"`
|
||||
Record string `yaml:"record,omitempty"`
|
||||
Alert string `yaml:"alert,omitempty"`
|
||||
Expr string `yaml:"expr"`
|
||||
For utils.PromDuration `yaml:"for"`
|
||||
Labels map[string]string `yaml:"labels,omitempty"`
|
||||
Annotations map[string]string `yaml:"annotations,omitempty"`
|
||||
|
||||
// Catches all undefined fields and must be empty after parsing.
|
||||
XXX map[string]interface{} `yaml:",inline"`
|
||||
}
|
||||
|
||||
// PromDuration is Prometheus duration.
|
||||
type PromDuration struct {
|
||||
milliseconds int64
|
||||
}
|
||||
|
||||
// NewPromDuration returns PromDuration for given d.
|
||||
func NewPromDuration(d time.Duration) PromDuration {
|
||||
return PromDuration{
|
||||
milliseconds: d.Milliseconds(),
|
||||
}
|
||||
}
|
||||
|
||||
// MarshalYAML implements yaml.Marshaler interface.
|
||||
func (pd PromDuration) MarshalYAML() (interface{}, error) {
|
||||
return pd.Duration().String(), nil
|
||||
}
|
||||
|
||||
// UnmarshalYAML implements yaml.Unmarshaler interface.
|
||||
func (pd *PromDuration) UnmarshalYAML(unmarshal func(interface{}) error) error {
|
||||
var s string
|
||||
if err := unmarshal(&s); err != nil {
|
||||
return err
|
||||
}
|
||||
ms, err := metricsql.DurationValue(s, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pd.milliseconds = ms
|
||||
return nil
|
||||
}
|
||||
|
||||
// Duration returns duration for pd.
|
||||
func (pd *PromDuration) Duration() time.Duration {
|
||||
return time.Duration(pd.milliseconds) * time.Millisecond
|
||||
}
|
||||
|
||||
// UnmarshalYAML implements the yaml.Unmarshaler interface.
|
||||
func (r *Rule) UnmarshalYAML(unmarshal func(interface{}) error) error {
|
||||
type rule Rule
|
||||
|
|
|
@ -8,10 +8,9 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/VictoriaMetrics/VictoriaMetrics/app/vmalert/datasource"
|
||||
|
||||
"gopkg.in/yaml.v2"
|
||||
|
||||
"github.com/VictoriaMetrics/VictoriaMetrics/app/vmalert/notifier"
|
||||
"github.com/VictoriaMetrics/VictoriaMetrics/app/vmalert/utils"
|
||||
"gopkg.in/yaml.v2"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
|
@ -264,7 +263,7 @@ func TestGroup_Validate(t *testing.T) {
|
|||
Rules: []Rule{
|
||||
{
|
||||
Expr: "sumSeries(time('foo.bar',10))",
|
||||
For: PromDuration{milliseconds: 10},
|
||||
For: utils.NewPromDuration(10 * time.Millisecond),
|
||||
},
|
||||
{
|
||||
Expr: "sum(up == 0 ) by (host)",
|
||||
|
@ -280,7 +279,7 @@ func TestGroup_Validate(t *testing.T) {
|
|||
Rules: []Rule{
|
||||
{
|
||||
Expr: "sum(up == 0 ) by (host)",
|
||||
For: PromDuration{milliseconds: 10},
|
||||
For: utils.NewPromDuration(10 * time.Millisecond),
|
||||
},
|
||||
{
|
||||
Expr: "sumSeries(time('foo.bar',10))",
|
||||
|
@ -348,7 +347,7 @@ func TestHashRule(t *testing.T) {
|
|||
true,
|
||||
},
|
||||
{
|
||||
Rule{Alert: "alert", Expr: "up == 1", For: NewPromDuration(time.Minute)},
|
||||
Rule{Alert: "alert", Expr: "up == 1", For: utils.NewPromDuration(time.Minute)},
|
||||
Rule{Alert: "alert", Expr: "up == 1"},
|
||||
true,
|
||||
},
|
||||
|
|
|
@ -55,7 +55,7 @@ func newGroup(cfg config.Group, qb datasource.QuerierBuilder, defaultInterval ti
|
|||
Type: cfg.Type,
|
||||
Name: cfg.Name,
|
||||
File: cfg.File,
|
||||
Interval: cfg.Interval,
|
||||
Interval: cfg.Interval.Duration(),
|
||||
Concurrency: cfg.Concurrency,
|
||||
Checksum: cfg.Checksum,
|
||||
ExtraFilterLabels: cfg.ExtraFilterLabels,
|
||||
|
|
|
@ -6,10 +6,10 @@ import (
|
|||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/VictoriaMetrics/VictoriaMetrics/app/vmalert/datasource"
|
||||
|
||||
"github.com/VictoriaMetrics/VictoriaMetrics/app/vmalert/config"
|
||||
"github.com/VictoriaMetrics/VictoriaMetrics/app/vmalert/datasource"
|
||||
"github.com/VictoriaMetrics/VictoriaMetrics/app/vmalert/notifier"
|
||||
"github.com/VictoriaMetrics/VictoriaMetrics/app/vmalert/utils"
|
||||
)
|
||||
|
||||
func init() {
|
||||
|
@ -34,7 +34,7 @@ func TestUpdateWith(t *testing.T) {
|
|||
[]config.Rule{{
|
||||
Alert: "foo",
|
||||
Expr: "up > 0",
|
||||
For: config.NewPromDuration(time.Second),
|
||||
For: utils.NewPromDuration(time.Second),
|
||||
Labels: map[string]string{
|
||||
"bar": "baz",
|
||||
},
|
||||
|
@ -46,7 +46,7 @@ func TestUpdateWith(t *testing.T) {
|
|||
[]config.Rule{{
|
||||
Alert: "foo",
|
||||
Expr: "up > 10",
|
||||
For: config.NewPromDuration(time.Second),
|
||||
For: utils.NewPromDuration(time.Second),
|
||||
Labels: map[string]string{
|
||||
"baz": "bar",
|
||||
},
|
||||
|
|
|
@ -8,6 +8,7 @@ import (
|
|||
|
||||
"github.com/VictoriaMetrics/VictoriaMetrics/app/vmalert/config"
|
||||
"github.com/VictoriaMetrics/VictoriaMetrics/app/vmalert/datasource"
|
||||
"github.com/VictoriaMetrics/VictoriaMetrics/app/vmalert/utils"
|
||||
)
|
||||
|
||||
type fakeReplayQuerier struct {
|
||||
|
@ -82,7 +83,7 @@ func TestReplay(t *testing.T) {
|
|||
to: "2021-01-01T15:02:30.000Z",
|
||||
maxDP: 60,
|
||||
cfg: []config.Group{
|
||||
{Interval: time.Minute, Rules: []config.Rule{{Record: "foo", Expr: "sum(up)"}}},
|
||||
{Interval: utils.NewPromDuration(time.Minute), Rules: []config.Rule{{Record: "foo", Expr: "sum(up)"}}},
|
||||
},
|
||||
qb: &fakeReplayQuerier{
|
||||
registry: map[string]map[string]struct{}{
|
||||
|
|
43
app/vmalert/utils/prom_duration.go
Normal file
43
app/vmalert/utils/prom_duration.go
Normal file
|
@ -0,0 +1,43 @@
|
|||
package utils
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/VictoriaMetrics/metricsql"
|
||||
)
|
||||
|
||||
// PromDuration is Prometheus duration.
|
||||
type PromDuration struct {
|
||||
milliseconds int64
|
||||
}
|
||||
|
||||
// NewPromDuration returns PromDuration for given d.
|
||||
func NewPromDuration(d time.Duration) PromDuration {
|
||||
return PromDuration{
|
||||
milliseconds: d.Milliseconds(),
|
||||
}
|
||||
}
|
||||
|
||||
// MarshalYAML implements yaml.Marshaler interface.
|
||||
func (pd PromDuration) MarshalYAML() (interface{}, error) {
|
||||
return pd.Duration().String(), nil
|
||||
}
|
||||
|
||||
// UnmarshalYAML implements yaml.Unmarshaler interface.
|
||||
func (pd *PromDuration) UnmarshalYAML(unmarshal func(interface{}) error) error {
|
||||
var s string
|
||||
if err := unmarshal(&s); err != nil {
|
||||
return err
|
||||
}
|
||||
ms, err := metricsql.DurationValue(s, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pd.milliseconds = ms
|
||||
return nil
|
||||
}
|
||||
|
||||
// Duration returns duration for pd.
|
||||
func (pd *PromDuration) Duration() time.Duration {
|
||||
return time.Duration(pd.milliseconds) * time.Millisecond
|
||||
}
|
|
@ -34,7 +34,7 @@ func (rh *requestHandler) handler(w http.ResponseWriter, r *http.Request) bool {
|
|||
case "/api/v1/groups":
|
||||
data, err := rh.listGroups()
|
||||
if err != nil {
|
||||
httpserver.Errorf(w, r, "error in %q: %s", r.URL.Path, err)
|
||||
httpserver.Errorf(w, r, "%s", err)
|
||||
return true
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
|
@ -43,7 +43,7 @@ func (rh *requestHandler) handler(w http.ResponseWriter, r *http.Request) bool {
|
|||
case "/api/v1/alerts":
|
||||
data, err := rh.listAlerts()
|
||||
if err != nil {
|
||||
httpserver.Errorf(w, r, "error in %q: %s", r.URL.Path, err)
|
||||
httpserver.Errorf(w, r, "%s", err)
|
||||
return true
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
|
@ -61,7 +61,7 @@ func (rh *requestHandler) handler(w http.ResponseWriter, r *http.Request) bool {
|
|||
// /api/v1/<groupName>/<alertID>/status
|
||||
data, err := rh.alert(r.URL.Path)
|
||||
if err != nil {
|
||||
httpserver.Errorf(w, r, "error in %q: %s", r.URL.Path, err)
|
||||
httpserver.Errorf(w, r, "%s", err)
|
||||
return true
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
|
|
|
@ -146,7 +146,7 @@ It is recommended using [binary releases](https://github.com/VictoriaMetrics/Vic
|
|||
|
||||
### Development build
|
||||
|
||||
1. [Install Go](https://golang.org/doc/install). The minimum supported version is Go 1.15.
|
||||
1. [Install Go](https://golang.org/doc/install). The minimum supported version is Go 1.16.
|
||||
2. Run `make vmauth` from the root folder of [the repository](https://github.com/VictoriaMetrics/VictoriaMetrics).
|
||||
It builds `vmauth` binary and puts it into the `bin` folder.
|
||||
|
||||
|
|
|
@ -235,7 +235,7 @@ It is recommended using [binary releases](https://github.com/VictoriaMetrics/Vic
|
|||
|
||||
### Development build
|
||||
|
||||
1. [Install Go](https://golang.org/doc/install). The minimum supported version is Go 1.15.
|
||||
1. [Install Go](https://golang.org/doc/install). The minimum supported version is Go 1.16.
|
||||
2. Run `make vmbackup` from the root folder of [the repository](https://github.com/VictoriaMetrics/VictoriaMetrics).
|
||||
It builds `vmbackup` binary and puts it into the `bin` folder.
|
||||
|
||||
|
|
|
@ -22,7 +22,7 @@ It is recommended using [binary releases](https://github.com/VictoriaMetrics/Vic
|
|||
|
||||
### Development build
|
||||
|
||||
1. [Install Go](https://golang.org/doc/install). The minimum supported version is Go 1.15.
|
||||
1. [Install Go](https://golang.org/doc/install). The minimum supported version is Go 1.16.
|
||||
2. Run `make vmctl` from the root folder of [the repository](https://github.com/VictoriaMetrics/VictoriaMetrics).
|
||||
It builds `vmctl` binary and puts it into the `bin` folder.
|
||||
|
||||
|
@ -51,7 +51,7 @@ ARM build may run on Raspberry Pi or on [energy-efficient ARM servers](https://b
|
|||
|
||||
#### Development ARM build
|
||||
|
||||
1. [Install Go](https://golang.org/doc/install). The minimum supported version is Go 1.15.
|
||||
1. [Install Go](https://golang.org/doc/install). The minimum supported version is Go 1.16.
|
||||
2. Run `make vmctl-arm` or `make vmctl-arm64` from the root folder of [the repository](https://github.com/VictoriaMetrics/VictoriaMetrics).
|
||||
It builds `vmctl-arm` or `vmctl-arm64` binary respectively and puts it into the `bin` folder.
|
||||
|
||||
|
|
|
@ -6,6 +6,7 @@ import (
|
|||
"net/http"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/VictoriaMetrics/VictoriaMetrics/app/vminsert/csvimport"
|
||||
"github.com/VictoriaMetrics/VictoriaMetrics/app/vminsert/graphite"
|
||||
|
@ -91,13 +92,16 @@ func Stop() {
|
|||
|
||||
// RequestHandler is a handler for Prometheus remote storage write API
|
||||
func RequestHandler(w http.ResponseWriter, r *http.Request) bool {
|
||||
startTime := time.Now()
|
||||
defer requestDuration.UpdateDuration(startTime)
|
||||
|
||||
path := strings.Replace(r.URL.Path, "//", "/", -1)
|
||||
switch path {
|
||||
case "/prometheus/api/v1/write", "/api/v1/write":
|
||||
prometheusWriteRequests.Inc()
|
||||
if err := promremotewrite.InsertHandler(r); err != nil {
|
||||
prometheusWriteErrors.Inc()
|
||||
httpserver.Errorf(w, r, "error in %q: %s", r.URL.Path, err)
|
||||
httpserver.Errorf(w, r, "%s", err)
|
||||
return true
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
|
@ -106,7 +110,7 @@ func RequestHandler(w http.ResponseWriter, r *http.Request) bool {
|
|||
vmimportRequests.Inc()
|
||||
if err := vmimport.InsertHandler(r); err != nil {
|
||||
vmimportErrors.Inc()
|
||||
httpserver.Errorf(w, r, "error in %q: %s", r.URL.Path, err)
|
||||
httpserver.Errorf(w, r, "%s", err)
|
||||
return true
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
|
@ -115,7 +119,7 @@ func RequestHandler(w http.ResponseWriter, r *http.Request) bool {
|
|||
csvimportRequests.Inc()
|
||||
if err := csvimport.InsertHandler(r); err != nil {
|
||||
csvimportErrors.Inc()
|
||||
httpserver.Errorf(w, r, "error in %q: %s", r.URL.Path, err)
|
||||
httpserver.Errorf(w, r, "%s", err)
|
||||
return true
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
|
@ -124,7 +128,7 @@ func RequestHandler(w http.ResponseWriter, r *http.Request) bool {
|
|||
prometheusimportRequests.Inc()
|
||||
if err := prometheusimport.InsertHandler(r); err != nil {
|
||||
prometheusimportErrors.Inc()
|
||||
httpserver.Errorf(w, r, "error in %q: %s", r.URL.Path, err)
|
||||
httpserver.Errorf(w, r, "%s", err)
|
||||
return true
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
|
@ -133,7 +137,7 @@ func RequestHandler(w http.ResponseWriter, r *http.Request) bool {
|
|||
nativeimportRequests.Inc()
|
||||
if err := native.InsertHandler(r); err != nil {
|
||||
nativeimportErrors.Inc()
|
||||
httpserver.Errorf(w, r, "error in %q: %s", r.URL.Path, err)
|
||||
httpserver.Errorf(w, r, "%s", err)
|
||||
return true
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
|
@ -142,7 +146,7 @@ func RequestHandler(w http.ResponseWriter, r *http.Request) bool {
|
|||
influxWriteRequests.Inc()
|
||||
if err := influx.InsertHandlerForHTTP(r); err != nil {
|
||||
influxWriteErrors.Inc()
|
||||
httpserver.Errorf(w, r, "error in %q: %s", r.URL.Path, err)
|
||||
httpserver.Errorf(w, r, "%s", err)
|
||||
return true
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
|
@ -183,6 +187,8 @@ func RequestHandler(w http.ResponseWriter, r *http.Request) bool {
|
|||
}
|
||||
|
||||
var (
|
||||
requestDuration = metrics.NewHistogram(`vminsert_request_duration_seconds`)
|
||||
|
||||
prometheusWriteRequests = metrics.NewCounter(`vm_http_requests_total{path="/api/v1/write", protocol="promremotewrite"}`)
|
||||
prometheusWriteErrors = metrics.NewCounter(`vm_http_request_errors_total{path="/api/v1/write", protocol="promremotewrite"}`)
|
||||
|
||||
|
|
|
@ -131,7 +131,7 @@ It is recommended using [binary releases](https://github.com/VictoriaMetrics/Vic
|
|||
|
||||
### Development build
|
||||
|
||||
1. [Install Go](https://golang.org/doc/install). The minimum supported version is Go 1.15.
|
||||
1. [Install Go](https://golang.org/doc/install). The minimum supported version is Go 1.16.
|
||||
2. Run `make vmrestore` from the root folder of [the repository](https://github.com/VictoriaMetrics/VictoriaMetrics).
|
||||
It builds `vmrestore` binary and puts it into the `bin` folder.
|
||||
|
||||
|
|
|
@ -1,2 +1,4 @@
|
|||
`vmselect` performs the incoming queries and fetches the required data
|
||||
from `vmstorage`.
|
||||
|
||||
The `vmui` directory contains static contents built from [app/vmui](https://github.com/VictoriaMetrics/VictoriaMetrics/tree/master/app/vmui) package with `make vmui-update` command. The `vmui` page is available at `http://<victoria-metrics>:8428/vmui/`.
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package vmselect
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
|
@ -77,9 +78,22 @@ var (
|
|||
})
|
||||
)
|
||||
|
||||
// RequestHandler handles remote read API requests for Prometheus
|
||||
//go:embed vmui
|
||||
var vmuiFiles embed.FS
|
||||
|
||||
var vmuiFileServer = http.FileServer(http.FS(vmuiFiles))
|
||||
|
||||
// RequestHandler handles remote read API requests
|
||||
func RequestHandler(w http.ResponseWriter, r *http.Request) bool {
|
||||
// vmui access.
|
||||
if strings.HasPrefix(r.URL.Path, "/vmui") {
|
||||
vmuiFileServer.ServeHTTP(w, r)
|
||||
return true
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
defer requestDuration.UpdateDuration(startTime)
|
||||
|
||||
// Limit the number of concurrent queries.
|
||||
select {
|
||||
case concurrencyCh <- struct{}{}:
|
||||
|
@ -117,7 +131,7 @@ func RequestHandler(w http.ResponseWriter, r *http.Request) bool {
|
|||
d := time.Since(actualStartTime)
|
||||
if d >= *logSlowQueryDuration {
|
||||
remoteAddr := httpserver.GetQuotedRemoteAddr(r)
|
||||
requestURI := getRequestURI(r)
|
||||
requestURI := httpserver.GetRequestURI(r)
|
||||
logger.Warnf("slow query according to -search.logSlowQueryDuration=%s: remoteAddr=%s, duration=%.3f seconds; requestURI: %q",
|
||||
*logSlowQueryDuration, remoteAddr, d.Seconds(), requestURI)
|
||||
slowQueries.Inc()
|
||||
|
@ -164,7 +178,7 @@ func RequestHandler(w http.ResponseWriter, r *http.Request) bool {
|
|||
graphiteTagValuesRequests.Inc()
|
||||
if err := graphite.TagValuesHandler(startTime, tagName, w, r); err != nil {
|
||||
graphiteTagValuesErrors.Inc()
|
||||
httpserver.Errorf(w, r, "error in %q: %s", r.URL.Path, err)
|
||||
httpserver.Errorf(w, r, "%s", err)
|
||||
return true
|
||||
}
|
||||
return true
|
||||
|
@ -249,7 +263,7 @@ func RequestHandler(w http.ResponseWriter, r *http.Request) bool {
|
|||
exportRequests.Inc()
|
||||
if err := prometheus.ExportHandler(startTime, w, r); err != nil {
|
||||
exportErrors.Inc()
|
||||
httpserver.Errorf(w, r, "error in %q: %s", r.URL.Path, err)
|
||||
httpserver.Errorf(w, r, "%s", err)
|
||||
return true
|
||||
}
|
||||
return true
|
||||
|
@ -257,7 +271,7 @@ func RequestHandler(w http.ResponseWriter, r *http.Request) bool {
|
|||
exportCSVRequests.Inc()
|
||||
if err := prometheus.ExportCSVHandler(startTime, w, r); err != nil {
|
||||
exportCSVErrors.Inc()
|
||||
httpserver.Errorf(w, r, "error in %q: %s", r.URL.Path, err)
|
||||
httpserver.Errorf(w, r, "%s", err)
|
||||
return true
|
||||
}
|
||||
return true
|
||||
|
@ -265,7 +279,7 @@ func RequestHandler(w http.ResponseWriter, r *http.Request) bool {
|
|||
exportNativeRequests.Inc()
|
||||
if err := prometheus.ExportNativeHandler(startTime, w, r); err != nil {
|
||||
exportNativeErrors.Inc()
|
||||
httpserver.Errorf(w, r, "error in %q: %s", r.URL.Path, err)
|
||||
httpserver.Errorf(w, r, "%s", err)
|
||||
return true
|
||||
}
|
||||
return true
|
||||
|
@ -273,7 +287,7 @@ func RequestHandler(w http.ResponseWriter, r *http.Request) bool {
|
|||
federateRequests.Inc()
|
||||
if err := prometheus.FederateHandler(startTime, w, r); err != nil {
|
||||
federateErrors.Inc()
|
||||
httpserver.Errorf(w, r, "error in %q: %s", r.URL.Path, err)
|
||||
httpserver.Errorf(w, r, "%s", err)
|
||||
return true
|
||||
}
|
||||
return true
|
||||
|
@ -282,7 +296,7 @@ func RequestHandler(w http.ResponseWriter, r *http.Request) bool {
|
|||
httpserver.EnableCORS(w, r)
|
||||
if err := graphite.MetricsFindHandler(startTime, w, r); err != nil {
|
||||
graphiteMetricsFindErrors.Inc()
|
||||
httpserver.Errorf(w, r, "error in %q: %s", r.URL.Path, err)
|
||||
httpserver.Errorf(w, r, "%s", err)
|
||||
return true
|
||||
}
|
||||
return true
|
||||
|
@ -291,7 +305,7 @@ func RequestHandler(w http.ResponseWriter, r *http.Request) bool {
|
|||
httpserver.EnableCORS(w, r)
|
||||
if err := graphite.MetricsExpandHandler(startTime, w, r); err != nil {
|
||||
graphiteMetricsExpandErrors.Inc()
|
||||
httpserver.Errorf(w, r, "error in %q: %s", r.URL.Path, err)
|
||||
httpserver.Errorf(w, r, "%s", err)
|
||||
return true
|
||||
}
|
||||
return true
|
||||
|
@ -300,7 +314,7 @@ func RequestHandler(w http.ResponseWriter, r *http.Request) bool {
|
|||
httpserver.EnableCORS(w, r)
|
||||
if err := graphite.MetricsIndexHandler(startTime, w, r); err != nil {
|
||||
graphiteMetricsIndexErrors.Inc()
|
||||
httpserver.Errorf(w, r, "error in %q: %s", r.URL.Path, err)
|
||||
httpserver.Errorf(w, r, "%s", err)
|
||||
return true
|
||||
}
|
||||
return true
|
||||
|
@ -308,7 +322,7 @@ func RequestHandler(w http.ResponseWriter, r *http.Request) bool {
|
|||
graphiteTagsTagSeriesRequests.Inc()
|
||||
if err := graphite.TagsTagSeriesHandler(startTime, w, r); err != nil {
|
||||
graphiteTagsTagSeriesErrors.Inc()
|
||||
httpserver.Errorf(w, r, "error in %q: %s", r.URL.Path, err)
|
||||
httpserver.Errorf(w, r, "%s", err)
|
||||
return true
|
||||
}
|
||||
return true
|
||||
|
@ -316,7 +330,7 @@ func RequestHandler(w http.ResponseWriter, r *http.Request) bool {
|
|||
graphiteTagsTagMultiSeriesRequests.Inc()
|
||||
if err := graphite.TagsTagMultiSeriesHandler(startTime, w, r); err != nil {
|
||||
graphiteTagsTagMultiSeriesErrors.Inc()
|
||||
httpserver.Errorf(w, r, "error in %q: %s", r.URL.Path, err)
|
||||
httpserver.Errorf(w, r, "%s", err)
|
||||
return true
|
||||
}
|
||||
return true
|
||||
|
@ -324,7 +338,7 @@ func RequestHandler(w http.ResponseWriter, r *http.Request) bool {
|
|||
graphiteTagsRequests.Inc()
|
||||
if err := graphite.TagsHandler(startTime, w, r); err != nil {
|
||||
graphiteTagsErrors.Inc()
|
||||
httpserver.Errorf(w, r, "error in %q: %s", r.URL.Path, err)
|
||||
httpserver.Errorf(w, r, "%s", err)
|
||||
return true
|
||||
}
|
||||
return true
|
||||
|
@ -332,7 +346,7 @@ func RequestHandler(w http.ResponseWriter, r *http.Request) bool {
|
|||
graphiteTagsFindSeriesRequests.Inc()
|
||||
if err := graphite.TagsFindSeriesHandler(startTime, w, r); err != nil {
|
||||
graphiteTagsFindSeriesErrors.Inc()
|
||||
httpserver.Errorf(w, r, "error in %q: %s", r.URL.Path, err)
|
||||
httpserver.Errorf(w, r, "%s", err)
|
||||
return true
|
||||
}
|
||||
return true
|
||||
|
@ -341,7 +355,7 @@ func RequestHandler(w http.ResponseWriter, r *http.Request) bool {
|
|||
httpserver.EnableCORS(w, r)
|
||||
if err := graphite.TagsAutoCompleteTagsHandler(startTime, w, r); err != nil {
|
||||
graphiteTagsAutoCompleteTagsErrors.Inc()
|
||||
httpserver.Errorf(w, r, "error in %q: %s", r.URL.Path, err)
|
||||
httpserver.Errorf(w, r, "%s", err)
|
||||
return true
|
||||
}
|
||||
return true
|
||||
|
@ -350,7 +364,7 @@ func RequestHandler(w http.ResponseWriter, r *http.Request) bool {
|
|||
httpserver.EnableCORS(w, r)
|
||||
if err := graphite.TagsAutoCompleteValuesHandler(startTime, w, r); err != nil {
|
||||
graphiteTagsAutoCompleteValuesErrors.Inc()
|
||||
httpserver.Errorf(w, r, "error in %q: %s", r.URL.Path, err)
|
||||
httpserver.Errorf(w, r, "%s", err)
|
||||
return true
|
||||
}
|
||||
return true
|
||||
|
@ -363,7 +377,7 @@ func RequestHandler(w http.ResponseWriter, r *http.Request) bool {
|
|||
}
|
||||
if err := graphite.TagsDelSeriesHandler(startTime, w, r); err != nil {
|
||||
graphiteTagsDelSeriesErrors.Inc()
|
||||
httpserver.Errorf(w, r, "error in %q: %s", r.URL.Path, err)
|
||||
httpserver.Errorf(w, r, "%s", err)
|
||||
return true
|
||||
}
|
||||
return true
|
||||
|
@ -400,7 +414,7 @@ func RequestHandler(w http.ResponseWriter, r *http.Request) bool {
|
|||
}
|
||||
if err := prometheus.DeleteHandler(startTime, r); err != nil {
|
||||
deleteErrors.Inc()
|
||||
httpserver.Errorf(w, r, "error in %q: %s", r.URL.Path, err)
|
||||
httpserver.Errorf(w, r, "%s", err)
|
||||
return true
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
|
@ -423,7 +437,7 @@ func isGraphiteTagsPath(path string) bool {
|
|||
}
|
||||
|
||||
func sendPrometheusError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
logger.Warnf("error in %q: %s", r.RequestURI, err)
|
||||
logger.Warnf("error in %q: %s", httpserver.GetRequestURI(r), err)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
statusCode := http.StatusUnprocessableEntity
|
||||
|
@ -435,24 +449,9 @@ func sendPrometheusError(w http.ResponseWriter, r *http.Request, err error) {
|
|||
prometheus.WriteErrorResponse(w, statusCode, err)
|
||||
}
|
||||
|
||||
func getRequestURI(r *http.Request) string {
|
||||
requestURI := r.RequestURI
|
||||
if r.Method != "POST" {
|
||||
return requestURI
|
||||
}
|
||||
_ = r.ParseForm()
|
||||
queryArgs := r.PostForm.Encode()
|
||||
if len(queryArgs) == 0 {
|
||||
return requestURI
|
||||
}
|
||||
delimiter := "?"
|
||||
if strings.Contains(requestURI, delimiter) {
|
||||
delimiter = "&"
|
||||
}
|
||||
return requestURI + delimiter + queryArgs
|
||||
}
|
||||
|
||||
var (
|
||||
requestDuration = metrics.NewHistogram(`vmselect_request_duration_seconds`)
|
||||
|
||||
labelValuesRequests = metrics.NewCounter(`vm_http_requests_total{path="/api/v1/label/{}/values"}`)
|
||||
labelValuesErrors = metrics.NewCounter(`vm_http_request_errors_total{path="/api/v1/label/{}/values"}`)
|
||||
|
||||
|
|
|
@ -25,7 +25,6 @@ import (
|
|||
"github.com/VictoriaMetrics/VictoriaMetrics/lib/logger"
|
||||
"github.com/VictoriaMetrics/VictoriaMetrics/lib/storage"
|
||||
"github.com/VictoriaMetrics/metrics"
|
||||
"github.com/VictoriaMetrics/metricsql"
|
||||
"github.com/valyala/fastjson/fastfloat"
|
||||
"github.com/valyala/quicktemplate"
|
||||
)
|
||||
|
@ -991,15 +990,9 @@ func QueryHandler(startTime time.Time, w http.ResponseWriter, r *http.Request) e
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if childQuery, windowStr, offsetStr := promql.IsMetricSelectorWithRollup(query); childQuery != "" {
|
||||
window, err := parsePositiveDuration(windowStr, step)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot parse window: %w", err)
|
||||
}
|
||||
offset, err := parseDuration(offsetStr, step)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot parse offset: %w", err)
|
||||
}
|
||||
if childQuery, windowExpr, offsetExpr := promql.IsMetricSelectorWithRollup(query); childQuery != "" {
|
||||
window := windowExpr.Duration(step)
|
||||
offset := offsetExpr.Duration(step)
|
||||
start -= offset
|
||||
end := start
|
||||
start = end - window
|
||||
|
@ -1014,22 +1007,13 @@ func QueryHandler(startTime time.Time, w http.ResponseWriter, r *http.Request) e
|
|||
queryDuration.UpdateDuration(startTime)
|
||||
return nil
|
||||
}
|
||||
if childQuery, windowStr, stepStr, offsetStr := promql.IsRollup(query); childQuery != "" {
|
||||
newStep, err := parsePositiveDuration(stepStr, step)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot parse step: %w", err)
|
||||
}
|
||||
if childQuery, windowExpr, stepExpr, offsetExpr := promql.IsRollup(query); childQuery != "" {
|
||||
newStep := stepExpr.Duration(step)
|
||||
if newStep > 0 {
|
||||
step = newStep
|
||||
}
|
||||
window, err := parsePositiveDuration(windowStr, step)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot parse window: %w", err)
|
||||
}
|
||||
offset, err := parseDuration(offsetStr, step)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot parse offset: %w", err)
|
||||
}
|
||||
window := windowExpr.Duration(step)
|
||||
offset := offsetExpr.Duration(step)
|
||||
start -= offset
|
||||
end := start
|
||||
start = end - window
|
||||
|
@ -1086,20 +1070,6 @@ func QueryHandler(startTime time.Time, w http.ResponseWriter, r *http.Request) e
|
|||
|
||||
var queryDuration = metrics.NewSummary(`vm_request_duration_seconds{path="/api/v1/query"}`)
|
||||
|
||||
func parseDuration(s string, step int64) (int64, error) {
|
||||
if len(s) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
return metricsql.DurationValue(s, step)
|
||||
}
|
||||
|
||||
func parsePositiveDuration(s string, step int64) (int64, error) {
|
||||
if len(s) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
return metricsql.PositiveDurationValue(s, step)
|
||||
}
|
||||
|
||||
// QueryRangeHandler processes /api/v1/query_range request.
|
||||
//
|
||||
// See https://prometheus.io/docs/prometheus/latest/querying/api/#range-queries
|
||||
|
|
|
@ -336,6 +336,12 @@ func evalExpr(ec *EvalConfig, e metricsql.Expr) ([]*timeseries, error) {
|
|||
rv := evalString(ec, se.S)
|
||||
return rv, nil
|
||||
}
|
||||
if de, ok := e.(*metricsql.DurationExpr); ok {
|
||||
d := de.Duration(ec.Step)
|
||||
dSec := float64(d) / 1000
|
||||
rv := evalNumber(ec, dSec)
|
||||
return rv, nil
|
||||
}
|
||||
return nil, fmt.Errorf("unexpected expression %q", e.AppendString(nil))
|
||||
}
|
||||
|
||||
|
@ -473,12 +479,8 @@ func getRollupExprArg(arg metricsql.Expr) *metricsql.RollupExpr {
|
|||
func evalRollupFunc(ec *EvalConfig, name string, rf rollupFunc, expr metricsql.Expr, re *metricsql.RollupExpr, iafc *incrementalAggrFuncContext) ([]*timeseries, error) {
|
||||
ecNew := ec
|
||||
var offset int64
|
||||
if len(re.Offset) > 0 {
|
||||
var err error
|
||||
offset, err = metricsql.DurationValue(re.Offset, ec.Step)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if re.Offset != nil {
|
||||
offset = re.Offset.Duration(ec.Step)
|
||||
ecNew = newEvalConfig(ecNew)
|
||||
ecNew.Start -= offset
|
||||
ecNew.End -= offset
|
||||
|
@ -526,24 +528,11 @@ func evalRollupFunc(ec *EvalConfig, name string, rf rollupFunc, expr metricsql.E
|
|||
|
||||
func evalRollupFuncWithSubquery(ec *EvalConfig, name string, rf rollupFunc, expr metricsql.Expr, re *metricsql.RollupExpr) ([]*timeseries, error) {
|
||||
// TODO: determine whether to use rollupResultCacheV here.
|
||||
var step int64
|
||||
if len(re.Step) > 0 {
|
||||
var err error
|
||||
step, err = metricsql.PositiveDurationValue(re.Step, ec.Step)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
step := re.Step.Duration(ec.Step)
|
||||
if step == 0 {
|
||||
step = ec.Step
|
||||
}
|
||||
var window int64
|
||||
if len(re.Window) > 0 {
|
||||
var err error
|
||||
window, err = metricsql.PositiveDurationValue(re.Window, ec.Step)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
window := re.Window.Duration(ec.Step)
|
||||
|
||||
ecSQ := newEvalConfig(ec)
|
||||
ecSQ.Start -= window + maxSilenceInterval + step
|
||||
|
@ -652,18 +641,11 @@ var (
|
|||
)
|
||||
|
||||
func evalRollupFuncWithMetricExpr(ec *EvalConfig, name string, rf rollupFunc,
|
||||
expr metricsql.Expr, me *metricsql.MetricExpr, iafc *incrementalAggrFuncContext, windowStr string) ([]*timeseries, error) {
|
||||
expr metricsql.Expr, me *metricsql.MetricExpr, iafc *incrementalAggrFuncContext, windowExpr *metricsql.DurationExpr) ([]*timeseries, error) {
|
||||
if me.IsEmpty() {
|
||||
return evalNumber(ec, nan), nil
|
||||
}
|
||||
var window int64
|
||||
if len(windowStr) > 0 {
|
||||
var err error
|
||||
window, err = metricsql.PositiveDurationValue(windowStr, ec.Step)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
window := windowExpr.Duration(ec.Step)
|
||||
|
||||
// Search for partial results in cache.
|
||||
tssCached, start := rollupResultCacheV.Get(ec, expr, window)
|
||||
|
|
|
@ -232,6 +232,17 @@ func TestExecSuccess(t *testing.T) {
|
|||
resultExpected := []netstorage.Result{r}
|
||||
f(q, resultExpected)
|
||||
})
|
||||
t.Run("time()[:100] offset 0", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
q := `time()[:100] offset 0`
|
||||
r := netstorage.Result{
|
||||
MetricName: metricNameExpected,
|
||||
Values: []float64{1000, 1200, 1400, 1600, 1800, 2000},
|
||||
Timestamps: timestampsExpected,
|
||||
}
|
||||
resultExpected := []netstorage.Result{r}
|
||||
f(q, resultExpected)
|
||||
})
|
||||
t.Run("time() offset 1h40s0ms", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
q := `time() offset 1h40s0ms`
|
||||
|
@ -243,6 +254,17 @@ func TestExecSuccess(t *testing.T) {
|
|||
resultExpected := []netstorage.Result{r}
|
||||
f(q, resultExpected)
|
||||
})
|
||||
t.Run("time() offset 3640", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
q := `time() offset 3640`
|
||||
r := netstorage.Result{
|
||||
MetricName: metricNameExpected,
|
||||
Values: []float64{-2800, -2600, -2400, -2200, -2000, -1800},
|
||||
Timestamps: timestampsExpected,
|
||||
}
|
||||
resultExpected := []netstorage.Result{r}
|
||||
f(q, resultExpected)
|
||||
})
|
||||
t.Run("time() offset -1h40s0ms", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
q := `time() offset -1h40s0ms`
|
||||
|
@ -361,6 +383,28 @@ func TestExecSuccess(t *testing.T) {
|
|||
resultExpected := []netstorage.Result{r1, r2}
|
||||
f(q, resultExpected)
|
||||
})
|
||||
t.Run("1h", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
q := `1h`
|
||||
r := netstorage.Result{
|
||||
MetricName: metricNameExpected,
|
||||
Values: []float64{3600, 3600, 3600, 3600, 3600, 3600},
|
||||
Timestamps: timestampsExpected,
|
||||
}
|
||||
resultExpected := []netstorage.Result{r}
|
||||
f(q, resultExpected)
|
||||
})
|
||||
t.Run("sum_over_time(time()[1h]) / 1h", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
q := `sum_over_time(time()[1h]) / 1h`
|
||||
r := netstorage.Result{
|
||||
MetricName: metricNameExpected,
|
||||
Values: []float64{-3.5, -2.5, -1.5, -0.5, 0.5, 1.5},
|
||||
Timestamps: timestampsExpected,
|
||||
}
|
||||
resultExpected := []netstorage.Result{r}
|
||||
f(q, resultExpected)
|
||||
})
|
||||
t.Run("time()[:100s] offset 100s", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
q := `time()[:100s] offset 100s`
|
||||
|
@ -383,6 +427,17 @@ func TestExecSuccess(t *testing.T) {
|
|||
resultExpected := []netstorage.Result{r}
|
||||
f(q, resultExpected)
|
||||
})
|
||||
t.Run("time()[300:100] offset 100", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
q := `time()[300:100] offset 100`
|
||||
r := netstorage.Result{
|
||||
MetricName: metricNameExpected,
|
||||
Values: []float64{900, 1100, 1300, 1500, 1700, 1900},
|
||||
Timestamps: timestampsExpected,
|
||||
}
|
||||
resultExpected := []netstorage.Result{r}
|
||||
f(q, resultExpected)
|
||||
})
|
||||
t.Run("time()[1.5i:0.5i] offset 0.5i", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
q := `time()[1.5i:0.5i] offset 0.5i`
|
||||
|
|
|
@ -10,13 +10,13 @@ import (
|
|||
// IsRollup verifies whether s is a rollup with non-empty window.
|
||||
//
|
||||
// It returns the wrapped query with the corresponding window, step and offset.
|
||||
func IsRollup(s string) (childQuery string, window, step, offset string) {
|
||||
func IsRollup(s string) (childQuery string, window, step, offset *metricsql.DurationExpr) {
|
||||
expr, err := parsePromQLWithCache(s)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
re, ok := expr.(*metricsql.RollupExpr)
|
||||
if !ok || len(re.Window) == 0 {
|
||||
if !ok || re.Window == nil {
|
||||
return
|
||||
}
|
||||
wrappedQuery := re.Expr.AppendString(nil)
|
||||
|
@ -27,13 +27,13 @@ func IsRollup(s string) (childQuery string, window, step, offset string) {
|
|||
// wrapped into rollup.
|
||||
//
|
||||
// It returns the wrapped query with the corresponding window with offset.
|
||||
func IsMetricSelectorWithRollup(s string) (childQuery string, window, offset string) {
|
||||
func IsMetricSelectorWithRollup(s string) (childQuery string, window, offset *metricsql.DurationExpr) {
|
||||
expr, err := parsePromQLWithCache(s)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
re, ok := expr.(*metricsql.RollupExpr)
|
||||
if !ok || len(re.Window) == 0 || len(re.Step) > 0 {
|
||||
if !ok || re.Window == nil || re.Step != nil {
|
||||
return
|
||||
}
|
||||
me, ok := re.Expr.(*metricsql.MetricExpr)
|
||||
|
|
BIN
app/vmselect/vmui/apple-touch-icon.png
Normal file
BIN
app/vmselect/vmui/apple-touch-icon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 14 KiB |
17
app/vmselect/vmui/asset-manifest.json
Normal file
17
app/vmselect/vmui/asset-manifest.json
Normal file
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"files": {
|
||||
"main.css": "./static/css/main.0ba440d3.chunk.css",
|
||||
"main.js": "./static/js/main.ffd27a2f.chunk.js",
|
||||
"runtime-main.js": "./static/js/runtime-main.50ad8b45.js",
|
||||
"static/js/2.3cdac8ea.chunk.js": "./static/js/2.3cdac8ea.chunk.js",
|
||||
"static/js/3.d52da3ae.chunk.js": "./static/js/3.d52da3ae.chunk.js",
|
||||
"index.html": "./index.html",
|
||||
"static/js/2.3cdac8ea.chunk.js.LICENSE.txt": "./static/js/2.3cdac8ea.chunk.js.LICENSE.txt"
|
||||
},
|
||||
"entrypoints": [
|
||||
"static/js/runtime-main.50ad8b45.js",
|
||||
"static/js/2.3cdac8ea.chunk.js",
|
||||
"static/css/main.0ba440d3.chunk.css",
|
||||
"static/js/main.ffd27a2f.chunk.js"
|
||||
]
|
||||
}
|
BIN
app/vmselect/vmui/favicon-32x32.png
Normal file
BIN
app/vmselect/vmui/favicon-32x32.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.6 KiB |
1
app/vmselect/vmui/index.html
Normal file
1
app/vmselect/vmui/index.html
Normal file
|
@ -0,0 +1 @@
|
|||
<!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="icon" href="./favicon.ico"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><meta name="description" content="VM-UI is a metric explorer for Victoria Metrics"/><link rel="apple-touch-icon" href="./apple-touch-icon.png"/><link rel="icon" type="image/png" sizes="32x32" href="./favicon-32x32.png"><link rel="manifest" href="./manifest.json"/><title>VM UI</title><link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap"/><link href="./static/css/main.0ba440d3.chunk.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div><script>!function(e){function r(r){for(var n,i,a=r[0],c=r[1],l=r[2],s=0,p=[];s<a.length;s++)i=a[s],Object.prototype.hasOwnProperty.call(o,i)&&o[i]&&p.push(o[i][0]),o[i]=0;for(n in c)Object.prototype.hasOwnProperty.call(c,n)&&(e[n]=c[n]);for(f&&f(r);p.length;)p.shift()();return u.push.apply(u,l||[]),t()}function t(){for(var e,r=0;r<u.length;r++){for(var t=u[r],n=!0,a=1;a<t.length;a++){var c=t[a];0!==o[c]&&(n=!1)}n&&(u.splice(r--,1),e=i(i.s=t[0]))}return e}var n={},o={1:0},u=[];function i(r){if(n[r])return n[r].exports;var t=n[r]={i:r,l:!1,exports:{}};return e[r].call(t.exports,t,t.exports,i),t.l=!0,t.exports}i.e=function(e){var r=[],t=o[e];if(0!==t)if(t)r.push(t[2]);else{var n=new Promise((function(r,n){t=o[e]=[r,n]}));r.push(t[2]=n);var u,a=document.createElement("script");a.charset="utf-8",a.timeout=120,i.nc&&a.setAttribute("nonce",i.nc),a.src=function(e){return i.p+"static/js/"+({}[e]||e)+"."+{3:"d52da3ae"}[e]+".chunk.js"}(e);var c=new Error;u=function(r){a.onerror=a.onload=null,clearTimeout(l);var t=o[e];if(0!==t){if(t){var n=r&&("load"===r.type?"missing":r.type),u=r&&r.target&&r.target.src;c.message="Loading chunk "+e+" failed.\n("+n+": "+u+")",c.name="ChunkLoadError",c.type=n,c.request=u,t[1](c)}o[e]=void 0}};var l=setTimeout((function(){u({type:"timeout",target:a})}),12e4);a.onerror=a.onload=u,document.head.appendChild(a)}return Promise.all(r)},i.m=e,i.c=n,i.d=function(e,r,t){i.o(e,r)||Object.defineProperty(e,r,{enumerable:!0,get:t})},i.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},i.t=function(e,r){if(1&r&&(e=i(e)),8&r)return e;if(4&r&&"object"==typeof e&&e&&e.__esModule)return e;var t=Object.create(null);if(i.r(t),Object.defineProperty(t,"default",{enumerable:!0,value:e}),2&r&&"string"!=typeof e)for(var n in e)i.d(t,n,function(r){return e[r]}.bind(null,n));return t},i.n=function(e){var r=e&&e.__esModule?function(){return e.default}:function(){return e};return i.d(r,"a",r),r},i.o=function(e,r){return Object.prototype.hasOwnProperty.call(e,r)},i.p="./",i.oe=function(e){throw console.error(e),e};var a=this.webpackJsonpvmui=this.webpackJsonpvmui||[],c=a.push.bind(a);a.push=r,a=a.slice();for(var l=0;l<a.length;l++)r(a[l]);var f=c;t()}([])</script><script src="./static/js/2.3cdac8ea.chunk.js"></script><script src="./static/js/main.ffd27a2f.chunk.js"></script></body></html>
|
20
app/vmselect/vmui/manifest.json
Normal file
20
app/vmselect/vmui/manifest.json
Normal file
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"short_name": "Victoria Metrics UI",
|
||||
"name": "Victoria Metrics UI is a metric explorer for Victoria Metrics",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon-32x32.png",
|
||||
"sizes": "32x32",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "apple-touch-icon.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff"
|
||||
}
|
3
app/vmselect/vmui/robots.txt
Normal file
3
app/vmselect/vmui/robots.txt
Normal file
|
@ -0,0 +1,3 @@
|
|||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
1
app/vmselect/vmui/static/css/main.0ba440d3.chunk.css
Normal file
1
app/vmselect/vmui/static/css/main.0ba440d3.chunk.css
Normal file
|
@ -0,0 +1 @@
|
|||
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","Roboto","Oxygen","Ubuntu","Cantarell","Fira Sans","Droid Sans","Helvetica Neue",sans-serif;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}code{font-family:source-code-pro,Menlo,Monaco,Consolas,"Courier New",monospace}.MuiAccordionSummary-content{margin:10px 0!important}.cm-activeLine{background-color:inherit!important}.cm-wrap{border-radius:4px;border:1px solid #b9b9b9;font-size:10px}.one-line-scroll .cm-wrap{height:24px}.line{fill:none;stroke-width:2}.overlay{fill:none;pointer-events:all}.dot{fill:#621773;stroke:#fff}
|
2
app/vmselect/vmui/static/js/2.3cdac8ea.chunk.js
Normal file
2
app/vmselect/vmui/static/js/2.3cdac8ea.chunk.js
Normal file
File diff suppressed because one or more lines are too long
98
app/vmselect/vmui/static/js/2.3cdac8ea.chunk.js.LICENSE.txt
Normal file
98
app/vmselect/vmui/static/js/2.3cdac8ea.chunk.js.LICENSE.txt
Normal file
|
@ -0,0 +1,98 @@
|
|||
/*
|
||||
object-assign
|
||||
(c) Sindre Sorhus
|
||||
@license MIT
|
||||
*/
|
||||
|
||||
/*! *****************************************************************************
|
||||
Copyright (c) Microsoft Corporation.
|
||||
|
||||
Permission to use, copy, modify, and/or distribute this software for any
|
||||
purpose with or without fee is hereby granted.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
PERFORMANCE OF THIS SOFTWARE.
|
||||
***************************************************************************** */
|
||||
|
||||
/**
|
||||
* A better abstraction over CSS.
|
||||
*
|
||||
* @copyright Oleg Isonen (Slobodskoi) / Isonen 2014-present
|
||||
* @website https://github.com/cssinjs/jss
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
/** @license React v0.20.1
|
||||
* scheduler.production.min.js
|
||||
*
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
/** @license React v16.13.1
|
||||
* react-is.production.min.js
|
||||
*
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
/** @license React v17.0.1
|
||||
* react-dom.production.min.js
|
||||
*
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
/** @license React v17.0.1
|
||||
* react-jsx-runtime.production.min.js
|
||||
*
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
/** @license React v17.0.1
|
||||
* react.production.min.js
|
||||
*
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
/**!
|
||||
* @fileOverview Kickass library to create and place poppers near their reference elements.
|
||||
* @version 1.16.1-lts
|
||||
* @license
|
||||
* Copyright (c) 2016 Federico Zivolo and contributors
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
1
app/vmselect/vmui/static/js/3.d52da3ae.chunk.js
Normal file
1
app/vmselect/vmui/static/js/3.d52da3ae.chunk.js
Normal file
|
@ -0,0 +1 @@
|
|||
(this.webpackJsonpvmui=this.webpackJsonpvmui||[]).push([[3],{430:function(t,n,e){"use strict";e.r(n),e.d(n,"getCLS",(function(){return l})),e.d(n,"getFCP",(function(){return g})),e.d(n,"getFID",(function(){return h})),e.d(n,"getLCP",(function(){return y})),e.d(n,"getTTFB",(function(){return F}));var i,a,r=function(){return"".concat(Date.now(),"-").concat(Math.floor(8999999999999*Math.random())+1e12)},o=function(t){var n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:-1;return{name:t,value:n,delta:0,entries:[],id:r(),isFinal:!1}},u=function(t,n){try{if(PerformanceObserver.supportedEntryTypes.includes(t)){var e=new PerformanceObserver((function(t){return t.getEntries().map(n)}));return e.observe({type:t,buffered:!0}),e}}catch(t){}},s=!1,c=!1,d=function(t){s=!t.persisted},f=function(){addEventListener("pagehide",d),addEventListener("beforeunload",(function(){}))},p=function(t){var n=arguments.length>1&&void 0!==arguments[1]&&arguments[1];c||(f(),c=!0),addEventListener("visibilitychange",(function(n){var e=n.timeStamp;"hidden"===document.visibilityState&&t({timeStamp:e,isUnloading:s})}),{capture:!0,once:n})},v=function(t,n,e,i){var a;return function(){e&&n.isFinal&&e.disconnect(),n.value>=0&&(i||n.isFinal||"hidden"===document.visibilityState)&&(n.delta=n.value-(a||0),(n.delta||n.isFinal||void 0===a)&&(t(n),a=n.value))}},l=function(t){var n,e=arguments.length>1&&void 0!==arguments[1]&&arguments[1],i=o("CLS",0),a=function(t){t.hadRecentInput||(i.value+=t.value,i.entries.push(t),n())},r=u("layout-shift",a);r&&(n=v(t,i,r,e),p((function(t){var e=t.isUnloading;r.takeRecords().map(a),e&&(i.isFinal=!0),n()})))},m=function(){return void 0===i&&(i="hidden"===document.visibilityState?0:1/0,p((function(t){var n=t.timeStamp;return i=n}),!0)),{get timeStamp(){return i}}},g=function(t){var n,e=o("FCP"),i=m(),a=u("paint",(function(t){"first-contentful-paint"===t.name&&t.startTime<i.timeStamp&&(e.value=t.startTime,e.isFinal=!0,e.entries.push(t),n())}));a&&(n=v(t,e,a))},h=function(t){var n=o("FID"),e=m(),i=function(t){t.startTime<e.timeStamp&&(n.value=t.processingStart-t.startTime,n.entries.push(t),n.isFinal=!0,r())},a=u("first-input",i),r=v(t,n,a);a?p((function(){a.takeRecords().map(i),a.disconnect()}),!0):window.perfMetrics&&window.perfMetrics.onFirstInputDelay&&window.perfMetrics.onFirstInputDelay((function(t,i){i.timeStamp<e.timeStamp&&(n.value=t,n.isFinal=!0,n.entries=[{entryType:"first-input",name:i.type,target:i.target,cancelable:i.cancelable,startTime:i.timeStamp,processingStart:i.timeStamp+t}],r())}))},S=function(){return a||(a=new Promise((function(t){return["scroll","keydown","pointerdown"].map((function(n){addEventListener(n,t,{once:!0,passive:!0,capture:!0})}))}))),a},y=function(t){var n,e=arguments.length>1&&void 0!==arguments[1]&&arguments[1],i=o("LCP"),a=m(),r=function(t){var e=t.startTime;e<a.timeStamp?(i.value=e,i.entries.push(t)):i.isFinal=!0,n()},s=u("largest-contentful-paint",r);if(s){n=v(t,i,s,e);var c=function(){i.isFinal||(s.takeRecords().map(r),i.isFinal=!0,n())};S().then(c),p(c,!0)}},F=function(t){var n,e=o("TTFB");n=function(){try{var n=performance.getEntriesByType("navigation")[0]||function(){var t=performance.timing,n={entryType:"navigation",startTime:0};for(var e in t)"navigationStart"!==e&&"toJSON"!==e&&(n[e]=Math.max(t[e]-t.navigationStart,0));return n}();e.value=e.delta=n.responseStart,e.entries=[n],e.isFinal=!0,t(e)}catch(t){}},"complete"===document.readyState?setTimeout(n,0):addEventListener("pageshow",n)}}}]);
|
1
app/vmselect/vmui/static/js/main.ffd27a2f.chunk.js
Normal file
1
app/vmselect/vmui/static/js/main.ffd27a2f.chunk.js
Normal file
File diff suppressed because one or more lines are too long
1
app/vmselect/vmui/static/js/runtime-main.50ad8b45.js
Normal file
1
app/vmselect/vmui/static/js/runtime-main.50ad8b45.js
Normal file
|
@ -0,0 +1 @@
|
|||
!function(e){function r(r){for(var n,i,a=r[0],c=r[1],l=r[2],s=0,p=[];s<a.length;s++)i=a[s],Object.prototype.hasOwnProperty.call(o,i)&&o[i]&&p.push(o[i][0]),o[i]=0;for(n in c)Object.prototype.hasOwnProperty.call(c,n)&&(e[n]=c[n]);for(f&&f(r);p.length;)p.shift()();return u.push.apply(u,l||[]),t()}function t(){for(var e,r=0;r<u.length;r++){for(var t=u[r],n=!0,a=1;a<t.length;a++){var c=t[a];0!==o[c]&&(n=!1)}n&&(u.splice(r--,1),e=i(i.s=t[0]))}return e}var n={},o={1:0},u=[];function i(r){if(n[r])return n[r].exports;var t=n[r]={i:r,l:!1,exports:{}};return e[r].call(t.exports,t,t.exports,i),t.l=!0,t.exports}i.e=function(e){var r=[],t=o[e];if(0!==t)if(t)r.push(t[2]);else{var n=new Promise((function(r,n){t=o[e]=[r,n]}));r.push(t[2]=n);var u,a=document.createElement("script");a.charset="utf-8",a.timeout=120,i.nc&&a.setAttribute("nonce",i.nc),a.src=function(e){return i.p+"static/js/"+({}[e]||e)+"."+{3:"d52da3ae"}[e]+".chunk.js"}(e);var c=new Error;u=function(r){a.onerror=a.onload=null,clearTimeout(l);var t=o[e];if(0!==t){if(t){var n=r&&("load"===r.type?"missing":r.type),u=r&&r.target&&r.target.src;c.message="Loading chunk "+e+" failed.\n("+n+": "+u+")",c.name="ChunkLoadError",c.type=n,c.request=u,t[1](c)}o[e]=void 0}};var l=setTimeout((function(){u({type:"timeout",target:a})}),12e4);a.onerror=a.onload=u,document.head.appendChild(a)}return Promise.all(r)},i.m=e,i.c=n,i.d=function(e,r,t){i.o(e,r)||Object.defineProperty(e,r,{enumerable:!0,get:t})},i.r=function(e){"undefined"!==typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},i.t=function(e,r){if(1&r&&(e=i(e)),8&r)return e;if(4&r&&"object"===typeof e&&e&&e.__esModule)return e;var t=Object.create(null);if(i.r(t),Object.defineProperty(t,"default",{enumerable:!0,value:e}),2&r&&"string"!=typeof e)for(var n in e)i.d(t,n,function(r){return e[r]}.bind(null,n));return t},i.n=function(e){var r=e&&e.__esModule?function(){return e.default}:function(){return e};return i.d(r,"a",r),r},i.o=function(e,r){return Object.prototype.hasOwnProperty.call(e,r)},i.p="./",i.oe=function(e){throw console.error(e),e};var a=this.webpackJsonpvmui=this.webpackJsonpvmui||[],c=a.push.bind(a);a.push=r,a=a.slice();for(var l=0;l<a.length;l++)r(a[l]);var f=c;t()}([]);
|
|
@ -656,7 +656,7 @@ func registerStorageMetrics() {
|
|||
return float64(idbm().IndexBlocksCacheSize)
|
||||
})
|
||||
metrics.NewGauge(`vm_cache_entries{type="indexdb/tagFilters"}`, func() float64 {
|
||||
return float64(idbm().TagCacheSize)
|
||||
return float64(idbm().TagFiltersCacheSize)
|
||||
})
|
||||
metrics.NewGauge(`vm_cache_entries{type="indexdb/uselessTagFilters"}`, func() float64 {
|
||||
return float64(idbm().UselessTagFiltersCacheSize)
|
||||
|
@ -699,7 +699,7 @@ func registerStorageMetrics() {
|
|||
return float64(m().NextDayMetricIDCacheSizeBytes)
|
||||
})
|
||||
metrics.NewGauge(`vm_cache_size_bytes{type="indexdb/tagFilters"}`, func() float64 {
|
||||
return float64(idbm().TagCacheSizeBytes)
|
||||
return float64(idbm().TagFiltersCacheSizeBytes)
|
||||
})
|
||||
metrics.NewGauge(`vm_cache_size_bytes{type="indexdb/uselessTagFilters"}`, func() float64 {
|
||||
return float64(idbm().UselessTagFiltersCacheSizeBytes)
|
||||
|
@ -730,7 +730,7 @@ func registerStorageMetrics() {
|
|||
return float64(idbm().IndexBlocksCacheRequests)
|
||||
})
|
||||
metrics.NewGauge(`vm_cache_requests_total{type="indexdb/tagFilters"}`, func() float64 {
|
||||
return float64(idbm().TagCacheRequests)
|
||||
return float64(idbm().TagFiltersCacheRequests)
|
||||
})
|
||||
metrics.NewGauge(`vm_cache_requests_total{type="indexdb/uselessTagFilters"}`, func() float64 {
|
||||
return float64(idbm().UselessTagFiltersCacheRequests)
|
||||
|
@ -761,7 +761,7 @@ func registerStorageMetrics() {
|
|||
return float64(idbm().IndexBlocksCacheMisses)
|
||||
})
|
||||
metrics.NewGauge(`vm_cache_misses_total{type="indexdb/tagFilters"}`, func() float64 {
|
||||
return float64(idbm().TagCacheMisses)
|
||||
return float64(idbm().TagFiltersCacheMisses)
|
||||
})
|
||||
metrics.NewGauge(`vm_cache_misses_total{type="indexdb/uselessTagFilters"}`, func() float64 {
|
||||
return float64(idbm().UselessTagFiltersCacheMisses)
|
||||
|
|
107
app/vmui/.gitignore
vendored
Normal file
107
app/vmui/.gitignore
vendored
Normal file
|
@ -0,0 +1,107 @@
|
|||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# TypeScript v1 declaration files
|
||||
typings/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variables file
|
||||
.env
|
||||
.env.test
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
# Comment in the public line in if your project uses Gatsby and *not* Next.js
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
# WebStorm etc
|
||||
.idea/
|
26
app/vmui/Makefile
Normal file
26
app/vmui/Makefile
Normal file
|
@ -0,0 +1,26 @@
|
|||
# All these commands must run from repository root.
|
||||
|
||||
vmui-package-base-image:
|
||||
(docker image ls --format '{{.Repository}}:{{.Tag}}' | grep -q vmui-builder-image) \
|
||||
|| docker build -t vmui-builder-image -f app/vmui/packages/vmui/Docker-build ./app/vmui
|
||||
|
||||
vmui-build: vmui-package-base-image
|
||||
docker run --rm \
|
||||
--user $(shell id -u):$(shell id -g) \
|
||||
--mount type=bind,src="$(shell pwd)/app/vmui",dst=/build \
|
||||
-w /build/packages/vmui \
|
||||
--entrypoint=/bin/bash \
|
||||
vmui-builder-image -c "npm install && npm run build"
|
||||
|
||||
vmui-release: vmui-build
|
||||
docker build -t ${DOCKER_NAMESPACE}/vmui:latest -f app/vmui/packages/vmui/Dockerfile-web ./app/vmui/packages/vmui
|
||||
docker tag ${DOCKER_NAMESPACE}/vmui:latest ${DOCKER_NAMESPACE}/vmui:${PKG_TAG}
|
||||
|
||||
vmui-publish-latest: vmui-release
|
||||
docker push ${DOCKER_NAMESPACE}/vmui
|
||||
|
||||
vmui-publish-release: vmui-release
|
||||
docker push ${DOCKER_NAMESPACE}/vmui:${PKG_TAG}
|
||||
|
||||
vmui-update: vmui-build
|
||||
rm -rf app/vmselect/vmui/* && mv app/vmui/packages/vmui/build/* app/vmselect/vmui
|
68
app/vmui/README.md
Normal file
68
app/vmui/README.md
Normal file
|
@ -0,0 +1,68 @@
|
|||
# vmui
|
||||
|
||||
Web UI for VictoriaMetrics
|
||||
|
||||
Features:
|
||||
|
||||
- configurable Server URL
|
||||
- configurable time range - every variant have own resolution to show around 30 data points
|
||||
- query editor has basic highlighting and can be multi-line
|
||||
- chart is responsive by width
|
||||
- color assignment for series is automatic
|
||||
- legend with reduced naming
|
||||
- tooltips for closest data point
|
||||
- auto-refresh mode with several time interval presets
|
||||
- table and raw JSON Query viewer
|
||||
|
||||
|
||||
## Docker image build
|
||||
|
||||
Run the following command from the root of VictoriaMetrics repository in order to build `victoriametrics/vmui` Docker image:
|
||||
|
||||
```
|
||||
make vmui-release
|
||||
```
|
||||
|
||||
Then run the built image with:
|
||||
|
||||
```
|
||||
docker run --rm --name vmui -p 8080:8080 victoriametrics/vmui
|
||||
```
|
||||
|
||||
Then naviate to `http://localhost:8080` in order to see the web UI.
|
||||
|
||||
|
||||
## Static build
|
||||
|
||||
Run the following command from the root of VictoriaMetrics repository for building `vmui` static contents:
|
||||
|
||||
```
|
||||
make vmui-build
|
||||
```
|
||||
|
||||
The built static contents is put into `app/vmui/packages/vmui/` directory.
|
||||
|
||||
|
||||
## Updating vmui embedded into VictoriaMetrics
|
||||
|
||||
Run the following command from the root of VictoriaMetrics repository for updating `vmui` embedded into VictoriaMetrics:
|
||||
|
||||
```
|
||||
make vmui-update
|
||||
```
|
||||
|
||||
This command should update `vmui` static files at `app/vmselect/vmui` directory. Commit changes to these files if needed.
|
||||
|
||||
Then build VictoriaMetrics with the following command:
|
||||
|
||||
```
|
||||
make victoria-metrics
|
||||
```
|
||||
|
||||
Then run the built binary with the following command:
|
||||
|
||||
```
|
||||
bin/victoria-metrics -selfScrapeInterval=5s
|
||||
```
|
||||
|
||||
Then navigate to `http://localhost:8428/vmui/`
|
11
app/vmui/packages/vmui/.dockerignore
Normal file
11
app/vmui/packages/vmui/.dockerignore
Normal file
|
@ -0,0 +1,11 @@
|
|||
# Items that don't need to be in a Docker image.
|
||||
# Anything not used by the build system should go here.
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
.gitignore
|
||||
README.md
|
||||
|
||||
# Artifacts that will be built during image creation.
|
||||
# This should contain all files created during `npm run build`.
|
||||
#build
|
||||
node_modules
|
58
app/vmui/packages/vmui/.eslintrc.js
Normal file
58
app/vmui/packages/vmui/.eslintrc.js
Normal file
|
@ -0,0 +1,58 @@
|
|||
module.exports = {
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es2021": true
|
||||
},
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:react/recommended",
|
||||
"plugin:@typescript-eslint/recommended"
|
||||
],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"parserOptions": {
|
||||
"ecmaFeatures": {
|
||||
"jsx": true
|
||||
},
|
||||
"ecmaVersion": 12,
|
||||
"sourceType": "module"
|
||||
},
|
||||
"plugins": [
|
||||
"react",
|
||||
"@typescript-eslint"
|
||||
],
|
||||
"rules": {
|
||||
"indent": [
|
||||
"error",
|
||||
2,
|
||||
{ "SwitchCase": 1 }
|
||||
],
|
||||
"linebreak-style": [
|
||||
"error",
|
||||
"unix"
|
||||
],
|
||||
"quotes": [
|
||||
"error",
|
||||
"double"
|
||||
],
|
||||
"semi": [
|
||||
"error",
|
||||
"always"
|
||||
],
|
||||
"react/prop-types": 0,
|
||||
"max-lines": [
|
||||
"error",
|
||||
{"max": 150}
|
||||
]
|
||||
},
|
||||
"settings": {
|
||||
"react": {
|
||||
"pragma": "React", // Pragma to use, default to "React"
|
||||
"version": "detect"
|
||||
},
|
||||
"linkComponents": [
|
||||
// Components used as alternatives to <a> for linking, eg. <Link to={ url } />
|
||||
"Hyperlink",
|
||||
{"name": "Link", "linkAttribute": "to"}
|
||||
]
|
||||
}
|
||||
};
|
23
app/vmui/packages/vmui/.gitignore
vendored
Normal file
23
app/vmui/packages/vmui/.gitignore
vendored
Normal file
|
@ -0,0 +1,23 @@
|
|||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
6
app/vmui/packages/vmui/Docker-build
Normal file
6
app/vmui/packages/vmui/Docker-build
Normal file
|
@ -0,0 +1,6 @@
|
|||
FROM node:14-alpine3.12 as build-stage
|
||||
|
||||
RUN apk update && apk add --no-cache bash bash-doc bash-completion libtool autoconf automake nasm pkgconfig libpng gcc make g++ zlib-dev gawk
|
||||
|
||||
RUN mkdir -p /app
|
||||
WORKDIR /app
|
19
app/vmui/packages/vmui/Dockerfile
Normal file
19
app/vmui/packages/vmui/Dockerfile
Normal file
|
@ -0,0 +1,19 @@
|
|||
FROM node:14-alpine3.12 as build-stage
|
||||
|
||||
RUN apk update && apk add --no-cache bash bash-doc bash-completion libtool autoconf automake nasm pkgconfig libpng gcc make g++ zlib-dev gawk
|
||||
|
||||
RUN mkdir -p /app
|
||||
WORKDIR /app
|
||||
COPY ./package.json /app/package.json
|
||||
COPY ./package-lock.json /app/package-lock.json
|
||||
RUN cd /app && npm install
|
||||
COPY . /app
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:latest as production-stage
|
||||
|
||||
COPY --from=build-stage /app/build /usr/share/nginx/html
|
||||
COPY ./nginx/nginx.conf /etc/nginx/nginx.conf
|
||||
COPY ./nginx/default /etc/nginx/sites-enabled/default
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
18
app/vmui/packages/vmui/Dockerfile-web
Normal file
18
app/vmui/packages/vmui/Dockerfile-web
Normal file
|
@ -0,0 +1,18 @@
|
|||
FROM golang:1.16.2 as build-web-stage
|
||||
COPY build /build
|
||||
|
||||
WORKDIR /build
|
||||
COPY web/ /build/
|
||||
RUN GOOS=linux GOARCH=amd64 GO111MODULE=on CGO_ENABLED=0 go build -o web-amd64 github.com/VictoriMetrics/vmui/ && \
|
||||
GOOS=windows GOARCH=amd64 GO111MODULE=on CGO_ENABLED=0 go build -o web-windows github.com/VictoriMetrics/vmui/
|
||||
|
||||
FROM alpine:3.13.2
|
||||
USER root
|
||||
|
||||
COPY --from=build-web-stage /build/web-amd64 /app/web
|
||||
COPY --from=build-web-stage /build/web-windows /app/web-windows
|
||||
RUN adduser -S -D -u 1000 web && chown -R web /app
|
||||
|
||||
USER web
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["/app/web"]
|
48
app/vmui/packages/vmui/README.md
Normal file
48
app/vmui/packages/vmui/README.md
Normal file
|
@ -0,0 +1,48 @@
|
|||
# Getting Started with Create React App
|
||||
|
||||
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
|
||||
|
||||
## Available Scripts
|
||||
|
||||
In the project directory, you can run:
|
||||
|
||||
### `npm start`
|
||||
|
||||
Runs the app in the development mode.\
|
||||
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
|
||||
|
||||
The page will reload if you make edits.\
|
||||
You will also see any lint errors in the console.
|
||||
|
||||
### `npm test`
|
||||
|
||||
Launches the test runner in the interactive watch mode.\
|
||||
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
|
||||
|
||||
### `npm run build`
|
||||
|
||||
Builds the app for production to the `build` folder.\
|
||||
It correctly bundles React in production mode and optimizes the build for the best performance.
|
||||
|
||||
The build is minified and the filenames include the hashes.\
|
||||
Your app is ready to be deployed!
|
||||
|
||||
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
|
||||
|
||||
### `npm run eject`
|
||||
|
||||
**Note: this is a one-way operation. Once you `eject`, you can’t go back!**
|
||||
|
||||
If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
|
||||
|
||||
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own.
|
||||
|
||||
You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it.
|
||||
|
||||
**Note:** this [Dockerfile](https://github.com/VictoriaMetrics/vmui/blob/master/packages/vmui/Dockerfile) use static built files from [npm run build](https://github.com/VictoriaMetrics/vmui/tree/master/packages/vmui#npm-run-eject) .
|
||||
|
||||
## Learn More
|
||||
|
||||
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
|
||||
|
||||
To learn React, check out the [React documentation](https://reactjs.org/).
|
48
app/vmui/packages/vmui/nginx/default
Normal file
48
app/vmui/packages/vmui/nginx/default
Normal file
|
@ -0,0 +1,48 @@
|
|||
server {
|
||||
listen 80;
|
||||
root /var/www/html;
|
||||
index index.html;
|
||||
|
||||
add_header X-XSS-Protection "1; mode=block";
|
||||
add_header X-Content-Type-Options "nosniff";
|
||||
add_header X-Frame-Options "sameorigin";
|
||||
|
||||
|
||||
location ~ /\.(?!well-known).* {
|
||||
deny all;
|
||||
access_log off;
|
||||
log_not_found off;
|
||||
}
|
||||
|
||||
|
||||
location ~* \.(jpg|jpeg|gif|png|ico|cur|gz|svg|svgz|mp4|ogg|ogv|otf|webm|htc|ttf|woff|woff2)$ {
|
||||
expires 0;
|
||||
access_log off;
|
||||
add_header Pragma public;
|
||||
add_header Cache-Control "public, max-age=604800"; #one week
|
||||
add_header X-Asset "yes";
|
||||
}
|
||||
|
||||
location = /favicon.ico {
|
||||
log_not_found off;
|
||||
access_log off;
|
||||
}
|
||||
|
||||
location ~ \.(html|gz)$ {
|
||||
expires 0;
|
||||
add_header Pragma "public";
|
||||
add_header Cache-Control "max-age=600, public, must-revalidate, proxy-revalidate";
|
||||
}
|
||||
|
||||
location = /robots.txt {
|
||||
allow all;
|
||||
log_not_found off;
|
||||
access_log off;
|
||||
}
|
||||
|
||||
error_log /dev/stdout warn;
|
||||
access_log /dev/stdout extended_json;
|
||||
# access_log /var/log/nginx/vmui-access.log;
|
||||
# error_log /var/log/nginx/vmui-error.log;
|
||||
|
||||
}
|
105
app/vmui/packages/vmui/nginx/nginx.conf
Normal file
105
app/vmui/packages/vmui/nginx/nginx.conf
Normal file
|
@ -0,0 +1,105 @@
|
|||
user www-data;
|
||||
worker_processes auto;
|
||||
pid /run/nginx.pid;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
multi_accept on;
|
||||
use epoll;
|
||||
}
|
||||
|
||||
http {
|
||||
|
||||
##
|
||||
# Basic Settings
|
||||
##
|
||||
|
||||
sendfile on;
|
||||
tcp_nopush on;
|
||||
tcp_nodelay on;
|
||||
keepalive_timeout 75 20;
|
||||
types_hash_max_size 2048;
|
||||
server_tokens off;
|
||||
|
||||
# server_names_hash_bucket_size 64;
|
||||
# server_name_in_redirect off;
|
||||
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
##
|
||||
# SSL Settings
|
||||
##
|
||||
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_prefer_server_ciphers on;
|
||||
|
||||
##
|
||||
# Logging Settings
|
||||
##
|
||||
log_format extended_json escape=json
|
||||
'{'
|
||||
'"event_datetime": "$time_iso8601", '
|
||||
'"server_name": "$server_name", '
|
||||
'"remote_addr": "$remote_addr", '
|
||||
'"remote_user": "$remote_user", '
|
||||
'"http_x_real_ip": "$http_x_real_ip", '
|
||||
'"status": "$status", '
|
||||
'"scheme": "$scheme", '
|
||||
'"request_method": "$request_method", '
|
||||
'"request_uri": "$request_uri", '
|
||||
'"server_protocol": "$server_protocol", '
|
||||
'"body_bytes_sent": $body_bytes_sent, '
|
||||
'"http_referer": "$http_referer", '
|
||||
'"http_user_agent": "$http_user_agent", '
|
||||
'"request_bytes": "$request_length", '
|
||||
'"request_time": "$request_time", '
|
||||
'"upstream_addr": "$upstream_addr", '
|
||||
'"upstream_response_time": "$upstream_response_time", '
|
||||
'"hostname": "$hostname", '
|
||||
'"host": "$host"'
|
||||
'}';
|
||||
|
||||
|
||||
error_log /dev/stdout warn;
|
||||
access_log /dev/stdout extended_json;
|
||||
|
||||
##
|
||||
# Gzip Settings
|
||||
##
|
||||
|
||||
gzip on;
|
||||
gzip_min_length 1024;
|
||||
gzip_vary on;
|
||||
gzip_static on;
|
||||
gzip_proxied any;
|
||||
gzip_proxied expired no-cache no-store private auth;
|
||||
gzip_types application/atom+xml application/geo+json application/javascript application/x-javascript application/json application/ld+json application/manifest+json application/rdf+xml application/rss+xml application/vnd.ms-fontobject application/wasm application/x-web-app-manifest+json application/xhtml+xml application/xml application/font-woff2 application/x-font-woff application/font-woff application/x-font-ttf font/eot font/otf font/ttf image/bmp image/svg+xml text/cache-manifest text/calendar text/markdown text/plain text/xml text/vcard text/vnd.rim.location.xloc text/vtt text/x-component text/x-cross-domain-policy;
|
||||
gzip_comp_level 6;
|
||||
gzip_disable "MSIE [1-6]\.(?!.*SV1)";
|
||||
|
||||
|
||||
server_names_hash_max_size 8192;
|
||||
#ignore_invalid_headers on;
|
||||
server_name_in_redirect off;
|
||||
|
||||
#proxy_buffer_size 8k;
|
||||
#proxy_buffers 8 64k;
|
||||
#proxy_connect_timeout 1000;
|
||||
#proxy_read_timeout 12000;
|
||||
#proxy_send_timeout 12000;
|
||||
|
||||
#proxy_cache_path /var/cache/nginx levels=2 keys_zone=pagecache:5m inactive=10m max_size=50m;
|
||||
|
||||
#real_ip_header X-Real-IP;
|
||||
#proxy_set_header Host $host;
|
||||
#proxy_set_header X-Real-IP $remote_addr;
|
||||
#proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
||||
#allow all;
|
||||
|
||||
|
||||
include /etc/nginx/conf.d/*.conf;
|
||||
include /etc/nginx/sites-enabled/*;
|
||||
client_max_body_size 20M;
|
||||
}
|
16795
app/vmui/packages/vmui/package-lock.json
generated
Normal file
16795
app/vmui/packages/vmui/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
66
app/vmui/packages/vmui/package.json
Normal file
66
app/vmui/packages/vmui/package.json
Normal file
|
@ -0,0 +1,66 @@
|
|||
{
|
||||
"name": "vmui",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"homepage": "./",
|
||||
"dependencies": {
|
||||
"@codemirror/next": "~0.13.1",
|
||||
"@date-io/dayjs": "^1.3.13",
|
||||
"@material-ui/core": "^4.11.0",
|
||||
"@material-ui/icons": "^4.11.2",
|
||||
"@material-ui/lab": "^4.0.0-alpha.56",
|
||||
"@material-ui/pickers": "^3.3.10",
|
||||
"@testing-library/jest-dom": "^5.11.6",
|
||||
"@testing-library/react": "^11.1.2",
|
||||
"@testing-library/user-event": "^12.2.2",
|
||||
"@types/d3": "^6.1.0",
|
||||
"@types/jest": "^26.0.15",
|
||||
"@types/node": "^12.19.4",
|
||||
"@types/qs": "^6.9.6",
|
||||
"@types/react": "^16.9.56",
|
||||
"@types/react-dom": "^16.9.9",
|
||||
"@types/react-measure": "^2.0.6",
|
||||
"codemirror-promql": "^0.10.2",
|
||||
"d3": "^6.2.0",
|
||||
"dayjs": "^1.10.4",
|
||||
"qs": "^6.5.2",
|
||||
"react": "^17.0.1",
|
||||
"react-dom": "^17.0.1",
|
||||
"react-measure": "^2.5.2",
|
||||
"react-scripts": "4.0.0",
|
||||
"typescript": "~4.0.5",
|
||||
"web-vitals": "^0.2.4"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "GENERATE_SOURCEMAP=false react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject",
|
||||
"lint": "eslint src --ext tsx,ts",
|
||||
"lint:fix": "eslint src --ext tsx,ts --fix"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "^4.14.2",
|
||||
"@typescript-eslint/parser": "^4.14.2",
|
||||
"eslint": "^7.14.0",
|
||||
"eslint-plugin-react": "^7.22.0"
|
||||
}
|
||||
}
|
BIN
app/vmui/packages/vmui/public/apple-touch-icon.png
Normal file
BIN
app/vmui/packages/vmui/public/apple-touch-icon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 14 KiB |
BIN
app/vmui/packages/vmui/public/favicon-32x32.png
Normal file
BIN
app/vmui/packages/vmui/public/favicon-32x32.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.6 KiB |
45
app/vmui/packages/vmui/public/index.html
Normal file
45
app/vmui/packages/vmui/public/index.html
Normal file
|
@ -0,0 +1,45 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta
|
||||
name="description"
|
||||
content="VM-UI is a metric explorer for Victoria Metrics"
|
||||
/>
|
||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="%PUBLIC_URL%/favicon-32x32.png">
|
||||
<!--
|
||||
manifest.json provides metadata used when your web app is installed on a
|
||||
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||
-->
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<!--
|
||||
Notice the use of %PUBLIC_URL% in the tags above.
|
||||
It will be replaced with the URL of the `public` folder during the build.
|
||||
Only files inside the `public` folder can be referenced from the HTML.
|
||||
|
||||
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
||||
work correctly both with client-side routing and a non-root public URL.
|
||||
Learn how to configure a non-root public URL by running `npm run build`.
|
||||
-->
|
||||
<title>VM UI</title>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" />
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<!--
|
||||
This HTML file is a template.
|
||||
If you open it directly in the browser, you will see an empty page.
|
||||
|
||||
You can add webfonts, meta tags, or analytics to this file.
|
||||
The build step will place the bundled scripts into the <body> tag.
|
||||
|
||||
To begin the development, run `npm start` or `yarn start`.
|
||||
To create a production bundle, use `npm run build` or `yarn build`.
|
||||
-->
|
||||
</body>
|
||||
</html>
|
20
app/vmui/packages/vmui/public/manifest.json
Normal file
20
app/vmui/packages/vmui/public/manifest.json
Normal file
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"short_name": "Victoria Metrics UI",
|
||||
"name": "Victoria Metrics UI is a metric explorer for Victoria Metrics",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon-32x32.png",
|
||||
"sizes": "32x32",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "apple-touch-icon.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff"
|
||||
}
|
3
app/vmui/packages/vmui/public/robots.txt
Normal file
3
app/vmui/packages/vmui/public/robots.txt
Normal file
|
@ -0,0 +1,3 @@
|
|||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
9
app/vmui/packages/vmui/src/App.test.tsx
Normal file
9
app/vmui/packages/vmui/src/App.test.tsx
Normal file
|
@ -0,0 +1,9 @@
|
|||
import React from "react";
|
||||
import {render, screen} from "@testing-library/react";
|
||||
import App from "./App";
|
||||
|
||||
test("renders header", () => {
|
||||
render(<App />);
|
||||
const headerElement = screen.getByText(/VMUI/i);
|
||||
expect(headerElement).toBeInTheDocument();
|
||||
});
|
40
app/vmui/packages/vmui/src/App.tsx
Normal file
40
app/vmui/packages/vmui/src/App.tsx
Normal file
|
@ -0,0 +1,40 @@
|
|||
import React, {FC} from "react";
|
||||
import {SnackbarProvider} from "./contexts/Snackbar";
|
||||
import HomeLayout from "./components/Home/HomeLayout";
|
||||
import {StateProvider} from "./state/common/StateContext";
|
||||
import {AuthStateProvider} from "./state/auth/AuthStateContext";
|
||||
import {createMuiTheme, MuiThemeProvider} from "@material-ui/core";
|
||||
|
||||
import CssBaseline from "@material-ui/core/CssBaseline";
|
||||
|
||||
import {MuiPickersUtilsProvider} from "@material-ui/pickers";
|
||||
// pick a date util library
|
||||
import DayJsUtils from "@date-io/dayjs";
|
||||
|
||||
const App: FC = () => {
|
||||
|
||||
const THEME = createMuiTheme({
|
||||
typography: {
|
||||
"fontSize": 10
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<CssBaseline /> {/* CSS Baseline: kind of normalize.css made by materialUI team - can be scoped */}
|
||||
<MuiPickersUtilsProvider utils={DayJsUtils}> {/* Allows datepicker to work with DayJS */}
|
||||
<MuiThemeProvider theme={THEME}> {/* Material UI theme customization */}
|
||||
<StateProvider> {/* Serialized into query string, common app settings */}
|
||||
<AuthStateProvider> {/* Auth related info - optionally persisted to Local Storage */}
|
||||
<SnackbarProvider> {/* Display various snackbars */}
|
||||
<HomeLayout/>
|
||||
</SnackbarProvider>
|
||||
</AuthStateProvider>
|
||||
</StateProvider>
|
||||
</MuiThemeProvider>
|
||||
</MuiPickersUtilsProvider>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
7
app/vmui/packages/vmui/src/api/query-range.ts
Normal file
7
app/vmui/packages/vmui/src/api/query-range.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
import {TimeParams} from "../types";
|
||||
|
||||
export const getQueryRangeUrl = (server: string, query: string, period: TimeParams): string =>
|
||||
`${server}/api/v1/query_range?query=${encodeURIComponent(query)}&start=${period.start}&end=${period.end}&step=${period.step}`;
|
||||
|
||||
export const getQueryUrl = (server: string, query: string, period: TimeParams): string =>
|
||||
`${server}/api/v1/query?query=${encodeURIComponent(query)}&start=${period.start}&end=${period.end}&step=${period.step}`;
|
22
app/vmui/packages/vmui/src/api/types.ts
Normal file
22
app/vmui/packages/vmui/src/api/types.ts
Normal file
|
@ -0,0 +1,22 @@
|
|||
export interface MetricBase {
|
||||
metric: {
|
||||
[key: string]: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface MetricResult extends MetricBase {
|
||||
values: [number, string][]
|
||||
}
|
||||
|
||||
|
||||
export interface InstantMetricResult extends MetricBase {
|
||||
value: [number, string]
|
||||
}
|
||||
|
||||
export interface QueryRangeResponse {
|
||||
status: string;
|
||||
data: {
|
||||
result: MetricResult[];
|
||||
resultType: "matrix";
|
||||
}
|
||||
}
|
|
@ -0,0 +1,217 @@
|
|||
/* eslint max-lines: ["error", {"max": 300}] */
|
||||
|
||||
import React, {useState} from "react";
|
||||
import DialogTitle from "@material-ui/core/DialogTitle";
|
||||
import Dialog from "@material-ui/core/Dialog";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
createStyles,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
FormControl,
|
||||
FormControlLabel,
|
||||
FormHelperText,
|
||||
Input,
|
||||
InputAdornment,
|
||||
InputLabel,
|
||||
Tab,
|
||||
Tabs,
|
||||
TextField,
|
||||
Typography
|
||||
} from "@material-ui/core";
|
||||
import TabPanel from "./AuthTabPanel";
|
||||
import PersonIcon from "@material-ui/icons/Person";
|
||||
import LockIcon from "@material-ui/icons/Lock";
|
||||
import {makeStyles} from "@material-ui/core/styles";
|
||||
import {useAuthDispatch, useAuthState} from "../../../state/auth/AuthStateContext";
|
||||
import {AUTH_METHOD, WithCheckbox} from "../../../state/auth/reducer";
|
||||
|
||||
// TODO: make generic when creating second dialog
|
||||
export interface DialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export interface AuthTab {
|
||||
title: string;
|
||||
id: AUTH_METHOD;
|
||||
}
|
||||
|
||||
const useStyles = makeStyles(() =>
|
||||
createStyles({
|
||||
tabsContent: {
|
||||
height: "200px"
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const BEARER_PREFIX = "Bearer ";
|
||||
|
||||
const tabs: AuthTab[] = [
|
||||
{title: "No auth", id: "NO_AUTH"},
|
||||
{title: "Basic Auth", id: "BASIC_AUTH"},
|
||||
{title: "Bearer Token", id: "BEARER_AUTH"}
|
||||
];
|
||||
|
||||
export const AuthDialog: React.FC<DialogProps> = (props) => {
|
||||
|
||||
const classes = useStyles();
|
||||
const {onClose, open} = props;
|
||||
|
||||
const {saveAuthLocally, basicData, bearerData, authMethod} = useAuthState();
|
||||
const dispatch = useAuthDispatch();
|
||||
|
||||
const [authCheckbox, setAuthCheckbox] = useState(saveAuthLocally);
|
||||
|
||||
const [basicValue, setBasicValue] = useState(basicData || {password: "", login: ""});
|
||||
|
||||
const [bearerValue, setBearerValue] = useState(bearerData?.token || BEARER_PREFIX);
|
||||
|
||||
const [tabIndex, setTabIndex] = useState(tabs.findIndex(el => el.id === authMethod) || 0);
|
||||
|
||||
const handleChange = (event: unknown, newValue: number) => {
|
||||
setTabIndex(newValue);
|
||||
};
|
||||
|
||||
const handleBearerChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newVal = event.target.value;
|
||||
if (newVal.startsWith(BEARER_PREFIX)) {
|
||||
setBearerValue(newVal);
|
||||
} else {
|
||||
setBearerValue(BEARER_PREFIX);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
};
|
||||
|
||||
const onBearerPaste = (e: React.ClipboardEvent) => {
|
||||
// if you're pasting token word Bearer will be added automagically
|
||||
const newVal = e.clipboardData.getData("text/plain");
|
||||
if (newVal.startsWith(BEARER_PREFIX)) {
|
||||
setBearerValue(newVal);
|
||||
} else {
|
||||
setBearerValue(BEARER_PREFIX + newVal);
|
||||
}
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
const handleApply = () => {
|
||||
// TODO: handle validation/required fields
|
||||
switch (tabIndex) {
|
||||
case 0:
|
||||
dispatch({type: "SET_NO_AUTH", payload: {checkbox: authCheckbox} as WithCheckbox});
|
||||
break;
|
||||
case 1:
|
||||
dispatch({type: "SET_BASIC_AUTH", payload: { checkbox: authCheckbox, value: basicValue}});
|
||||
break;
|
||||
case 2:
|
||||
dispatch({type: "SET_BEARER_AUTH", payload: {checkbox: authCheckbox, value: {token: bearerValue}}});
|
||||
break;
|
||||
}
|
||||
handleClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog onClose={handleClose} aria-labelledby="simple-dialog-title" open={open}>
|
||||
<DialogTitle id="simple-dialog-title">Request Auth Settings</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
This affects Authorization header sent to the server you specify. Not shown in URL and can be optionally stored on a client side
|
||||
</DialogContentText>
|
||||
|
||||
<Tabs
|
||||
value={tabIndex}
|
||||
onChange={handleChange}
|
||||
indicatorColor="primary"
|
||||
textColor="primary"
|
||||
>
|
||||
{
|
||||
tabs.map(t => <Tab key={t.id} label={t.title} />)
|
||||
}
|
||||
</Tabs>
|
||||
<Box p={0} display="flex" flexDirection="column" className={classes.tabsContent}>
|
||||
<Box flexGrow={1}>
|
||||
<TabPanel value={tabIndex} index={0}>
|
||||
<Typography style={{fontStyle: "italic"}}>
|
||||
No Authorization Header
|
||||
</Typography>
|
||||
</TabPanel>
|
||||
<TabPanel value={tabIndex} index={1}>
|
||||
<FormControl margin="dense" fullWidth={true}>
|
||||
<InputLabel htmlFor="basic-login">User</InputLabel>
|
||||
<Input
|
||||
id="basic-login"
|
||||
startAdornment={
|
||||
<InputAdornment position="start">
|
||||
<PersonIcon />
|
||||
</InputAdornment>
|
||||
}
|
||||
required
|
||||
onChange={e => setBasicValue(prev => ({...prev, login: e.target.value || ""}))}
|
||||
value={basicValue?.login || ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl margin="dense" fullWidth={true}>
|
||||
<InputLabel htmlFor="basic-pass">Password</InputLabel>
|
||||
<Input
|
||||
id="basic-pass"
|
||||
// type="password" // Basic auth is not super secure in any case :)
|
||||
startAdornment={
|
||||
<InputAdornment position="start">
|
||||
<LockIcon />
|
||||
</InputAdornment>
|
||||
}
|
||||
onChange={e => setBasicValue(prev => ({...prev, password: e.target.value || ""}))}
|
||||
value={basicValue?.password || ""}
|
||||
/>
|
||||
</FormControl>
|
||||
</TabPanel>
|
||||
<TabPanel value={tabIndex} index={2}>
|
||||
<TextField
|
||||
id="bearer-auth"
|
||||
label="Bearer token"
|
||||
multiline
|
||||
fullWidth={true}
|
||||
value={bearerValue}
|
||||
onChange={handleBearerChange}
|
||||
InputProps={{
|
||||
onPaste: onBearerPaste
|
||||
}}
|
||||
rowsMax={6}
|
||||
/>
|
||||
</TabPanel>
|
||||
</Box>
|
||||
|
||||
<FormControl>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={authCheckbox}
|
||||
onChange={() => setAuthCheckbox(prev => !prev)}
|
||||
name="checkedB"
|
||||
color="primary"
|
||||
/>
|
||||
}
|
||||
label="Persist Auth Data Locally"
|
||||
/>
|
||||
<FormHelperText>
|
||||
{authCheckbox ? "Auth Data and the Selected method will be saved to LocalStorage" : "Auth Data won't be saved. All previously saved Auth Data will be removed"}
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
</Box>
|
||||
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleApply} color="primary">
|
||||
Apply
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,29 @@
|
|||
import React from "react";
|
||||
import Box from "@material-ui/core/Box";
|
||||
|
||||
interface TabPanelProps {
|
||||
index: number;
|
||||
value: number;
|
||||
}
|
||||
|
||||
const AuthTabPanel: React.FC<TabPanelProps> = (props) => {
|
||||
const { children, value, index, ...other } = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="tabpanel"
|
||||
hidden={value !== index}
|
||||
id={`auth-config-tabpanel-${index}`}
|
||||
aria-labelledby={`auth-config-tab-${index}`}
|
||||
{...other}
|
||||
>
|
||||
{value === index && (
|
||||
<Box py={2}>
|
||||
{children}
|
||||
</Box>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuthTabPanel;
|
|
@ -0,0 +1,46 @@
|
|||
import React, {FC} from "react";
|
||||
|
||||
import TableChartIcon from "@material-ui/icons/TableChart";
|
||||
import ShowChartIcon from "@material-ui/icons/ShowChart";
|
||||
import CodeIcon from "@material-ui/icons/Code";
|
||||
|
||||
import {ToggleButton, ToggleButtonGroup} from "@material-ui/lab";
|
||||
import {useAppDispatch, useAppState} from "../../../state/common/StateContext";
|
||||
import {withStyles} from "@material-ui/core";
|
||||
|
||||
export type DisplayType = "table" | "chart" | "code";
|
||||
|
||||
const StylizedToggleButton = withStyles({
|
||||
root: {
|
||||
padding: 6,
|
||||
color: "white",
|
||||
"&.Mui-selected": {
|
||||
color: "white"
|
||||
}
|
||||
}
|
||||
})(ToggleButton);
|
||||
|
||||
export const DisplayTypeSwitch: FC = () => {
|
||||
|
||||
const {displayType} = useAppState();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
return <ToggleButtonGroup
|
||||
value={displayType}
|
||||
exclusive
|
||||
onChange={
|
||||
(e, val) =>
|
||||
// Toggle Button Group returns null in case of click on selected element, avoiding it
|
||||
dispatch({type: "SET_DISPLAY_TYPE", payload: val ?? displayType})
|
||||
}>
|
||||
<StylizedToggleButton value="chart" aria-label="display as chart">
|
||||
<ShowChartIcon/> Query Range as Chart
|
||||
</StylizedToggleButton>
|
||||
<StylizedToggleButton value="code" aria-label="display as code">
|
||||
<CodeIcon/> Instant Query as JSON
|
||||
</StylizedToggleButton>
|
||||
<StylizedToggleButton value="table" aria-label="display as table">
|
||||
<TableChartIcon/> Instant Query as Table
|
||||
</StylizedToggleButton>
|
||||
</ToggleButtonGroup>;
|
||||
};
|
|
@ -0,0 +1,92 @@
|
|||
import React, {FC, useEffect, useState} from "react";
|
||||
import {Box, FormControlLabel, IconButton, Switch, Tooltip} from "@material-ui/core";
|
||||
import PlayCircleOutlineIcon from "@material-ui/icons/PlayCircleOutline";
|
||||
|
||||
import EqualizerIcon from "@material-ui/icons/Equalizer";
|
||||
import {useAppDispatch, useAppState} from "../../../state/common/StateContext";
|
||||
import CircularProgressWithLabel from "../../common/CircularProgressWithLabel";
|
||||
import {makeStyles} from "@material-ui/core/styles";
|
||||
|
||||
const useStyles = makeStyles({
|
||||
colorizing: {
|
||||
color: "white"
|
||||
}
|
||||
});
|
||||
|
||||
export const ExecutionControls: FC = () => {
|
||||
const classes = useStyles();
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const {queryControls: {autoRefresh}} = useAppState();
|
||||
|
||||
const [delay, setDelay] = useState<(1|2|5)>(5);
|
||||
const [lastUpdate, setLastUpdate] = useState<number|undefined>();
|
||||
const [progress, setProgress] = React.useState(100);
|
||||
|
||||
const handleChange = () => {
|
||||
dispatch({type: "TOGGLE_AUTOREFRESH"});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let timer: number;
|
||||
if (autoRefresh) {
|
||||
setLastUpdate(new Date().valueOf());
|
||||
timer = setInterval(() => {
|
||||
setLastUpdate(new Date().valueOf());
|
||||
dispatch({type: "RUN_QUERY_TO_NOW"});
|
||||
}, delay * 1000) as unknown as number;
|
||||
}
|
||||
return () => {
|
||||
timer && clearInterval(timer);
|
||||
};
|
||||
}, [delay, autoRefresh]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
if (autoRefresh && lastUpdate) {
|
||||
const delta = (new Date().valueOf() - lastUpdate) / 1000; //s
|
||||
const nextValue = Math.floor(delta / delay * 100);
|
||||
setProgress(nextValue);
|
||||
}
|
||||
}, 16);
|
||||
return () => {
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [autoRefresh, lastUpdate, delay]);
|
||||
|
||||
const iterateDelays = () => {
|
||||
setDelay(prev => {
|
||||
switch (prev) {
|
||||
case 1:
|
||||
return 2;
|
||||
case 2:
|
||||
return 5;
|
||||
case 5:
|
||||
return 1;
|
||||
default:
|
||||
return 5;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return <Box display="flex" alignItems="center">
|
||||
<Box mr={2}>
|
||||
<Tooltip title="Execute Query">
|
||||
<IconButton onClick={()=>dispatch({type: "RUN_QUERY"})}>
|
||||
<PlayCircleOutlineIcon className={classes.colorizing} fontSize="large"/>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
{<FormControlLabel
|
||||
control={<Switch size="small" className={classes.colorizing} checked={autoRefresh} onChange={handleChange} />}
|
||||
label="Auto-refresh"
|
||||
/>}
|
||||
|
||||
{autoRefresh && <>
|
||||
<CircularProgressWithLabel className={classes.colorizing} label={delay} value={progress} onClick={() => {iterateDelays();}} />
|
||||
<Box ml={1}>
|
||||
<IconButton onClick={() => {iterateDelays();}}><EqualizerIcon style={{color: "white"}} /></IconButton>
|
||||
</Box>
|
||||
</>}
|
||||
</Box>;
|
||||
};
|
|
@ -0,0 +1,82 @@
|
|||
import React, {FC, useState} from "react";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionDetails,
|
||||
AccordionSummary,
|
||||
Box,
|
||||
Grid,
|
||||
IconButton,
|
||||
TextField,
|
||||
Typography
|
||||
} from "@material-ui/core";
|
||||
import QueryEditor from "./QueryEditor";
|
||||
import {TimeSelector} from "./TimeSelector";
|
||||
import {useAppDispatch, useAppState} from "../../../state/common/StateContext";
|
||||
import ExpandMoreIcon from "@material-ui/icons/ExpandMore";
|
||||
import SecurityIcon from "@material-ui/icons/Security";
|
||||
import {AuthDialog} from "./AuthDialog";
|
||||
|
||||
const QueryConfigurator: FC = () => {
|
||||
|
||||
const {serverUrl, query, time: {duration}} = useAppState();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Accordion expanded={expanded} onChange={() => setExpanded(prev => !prev)}>
|
||||
<AccordionSummary
|
||||
expandIcon={<ExpandMoreIcon/>}
|
||||
aria-controls="panel1a-content"
|
||||
id="panel1a-header"
|
||||
>
|
||||
<Box mr={2}>
|
||||
<Typography variant="h6" component="h2">Query Configuration</Typography>
|
||||
</Box>
|
||||
{!expanded && <Box flexGrow={1} onClick={e => e.stopPropagation()} onFocusCapture={e => e.stopPropagation()}>
|
||||
<QueryEditor server={serverUrl} query={query} oneLiner setQuery={(query) => dispatch({type: "SET_QUERY", payload: query})}/>
|
||||
</Box>}
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Box>
|
||||
<Box py={2} display="flex">
|
||||
<TextField variant="outlined" fullWidth label="Server URL" value={serverUrl}
|
||||
inputProps={{
|
||||
style: {fontFamily: "Monospace"}
|
||||
}}
|
||||
onChange={(e) => dispatch({type: "SET_SERVER", payload: e.target.value})}/>
|
||||
<Box pl={.5} flexGrow={0}>
|
||||
<IconButton onClick={() => setDialogOpen(true)}>
|
||||
<SecurityIcon/>
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
<QueryEditor server={serverUrl} query={query} setQuery={(query) => dispatch({type: "SET_QUERY", payload: query})}/>
|
||||
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Box style={{
|
||||
borderRadius: "4px",
|
||||
borderColor: "#b9b9b9",
|
||||
borderStyle: "solid",
|
||||
borderWidth: "1px",
|
||||
height: "calc(100% - 18px)",
|
||||
marginTop: "16px"
|
||||
}}>
|
||||
<TimeSelector setDuration={(dur) => dispatch({type: "SET_DURATION", payload: dur})} duration={duration}/>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
<AuthDialog open={dialogOpen} onClose={() => setDialogOpen(false)}/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default QueryConfigurator;
|
|
@ -0,0 +1,62 @@
|
|||
import {EditorState} from "@codemirror/next/state";
|
||||
import {EditorView, keymap} from "@codemirror/next/view";
|
||||
import {defaultKeymap} from "@codemirror/next/commands";
|
||||
import React, {FC, useEffect, useRef, useState} from "react";
|
||||
import { PromQLExtension } from "codemirror-promql";
|
||||
import { basicSetup } from "@codemirror/next/basic-setup";
|
||||
|
||||
export interface QueryEditorProps {
|
||||
setQuery: (query: string) => void;
|
||||
query: string;
|
||||
server: string;
|
||||
oneLiner?: boolean;
|
||||
}
|
||||
|
||||
const QueryEditor: FC<QueryEditorProps> = ({query, setQuery, server, oneLiner = false}) => {
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [editorView, setEditorView] = useState<EditorView>();
|
||||
|
||||
// init editor view on load
|
||||
useEffect(() => {
|
||||
if (ref.current) {
|
||||
setEditorView(new EditorView(
|
||||
{
|
||||
parent: ref.current
|
||||
})
|
||||
);
|
||||
}
|
||||
return () => editorView?.destroy();
|
||||
}, []);
|
||||
|
||||
// update state on change of autocomplete server
|
||||
useEffect(() => {
|
||||
|
||||
const promQL = new PromQLExtension().setComplete({url: server});
|
||||
|
||||
const listenerExtension = EditorView.updateListener.of(editorUpdate => {
|
||||
if (editorUpdate.docChanged) {
|
||||
setQuery(
|
||||
editorUpdate.state.doc.toJSON().map(el => el.trim()).join("")
|
||||
);
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
editorView?.setState(EditorState.create({
|
||||
doc: query,
|
||||
extensions: [basicSetup, keymap(defaultKeymap), listenerExtension, promQL.asExtension()]
|
||||
}));
|
||||
|
||||
}, [server, editorView]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/*Class one-line-scroll and other codemirror stylings are declared in index.css*/}
|
||||
<div ref={ref} className={oneLiner ? "one-line-scroll" : undefined}></div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default QueryEditor;
|
|
@ -0,0 +1,25 @@
|
|||
import React, {FC} from "react";
|
||||
import {Paper, Table, TableBody, TableCell, TableContainer, TableHead, TableRow} from "@material-ui/core";
|
||||
import {supportedDurations} from "../../../utils/time";
|
||||
|
||||
export const TimeDurationPopover: FC = () => {
|
||||
|
||||
return <TableContainer component={Paper}>
|
||||
<Table aria-label="simple table" size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Long</TableCell>
|
||||
<TableCell>Short</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{supportedDurations.map((row, index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell component="th" scope="row">{row.long}</TableCell>
|
||||
<TableCell>{row.short}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>;
|
||||
};
|
|
@ -0,0 +1,120 @@
|
|||
import React, {FC, useEffect, useState} from "react";
|
||||
import {Box, Popover, TextField, Typography} from "@material-ui/core";
|
||||
import { KeyboardDateTimePicker } from "@material-ui/pickers";
|
||||
import {TimeDurationPopover} from "./TimeDurationPopover";
|
||||
import {useAppDispatch, useAppState} from "../../../state/common/StateContext";
|
||||
import {dateFromSeconds, formatDateForNativeInput} from "../../../utils/time";
|
||||
import {InlineBtn} from "../../common/InlineBtn";
|
||||
|
||||
interface TimeSelectorProps {
|
||||
setDuration: (str: string) => void;
|
||||
duration: string;
|
||||
}
|
||||
|
||||
export const TimeSelector: FC<TimeSelectorProps> = ({setDuration}) => {
|
||||
|
||||
const [durationStringFocused, setFocused] = useState(false);
|
||||
const [anchorEl, setAnchorEl] = React.useState<Element | null>(null);
|
||||
const [until, setUntil] = useState<string>();
|
||||
|
||||
const {time: {period: {end}, duration}} = useAppState();
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const [durationString, setDurationString] = useState<string>(duration);
|
||||
|
||||
useEffect(() => {
|
||||
setDurationString(duration);
|
||||
}, [duration]);
|
||||
|
||||
useEffect(() => {
|
||||
setUntil(formatDateForNativeInput(dateFromSeconds(end)));
|
||||
}, [end]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!durationStringFocused) {
|
||||
setDuration(durationString);
|
||||
}
|
||||
}, [durationString, durationStringFocused]);
|
||||
|
||||
const handleDurationChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setDurationString(event.target.value);
|
||||
};
|
||||
|
||||
const handlePopoverOpen = (event: React.MouseEvent<Element, MouseEvent>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
};
|
||||
|
||||
const handlePopoverClose = () => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
const open = Boolean(anchorEl);
|
||||
|
||||
return <Box m={1} flexDirection="row" display="flex">
|
||||
{/*setup duration*/}
|
||||
<Box px={1}>
|
||||
<Box>
|
||||
<TextField label="Duration" value={durationString} onChange={handleDurationChange}
|
||||
fullWidth={true}
|
||||
onBlur={() => {
|
||||
setFocused(false);
|
||||
}}
|
||||
onFocus={() => {
|
||||
setFocused(true);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Box my={2}>
|
||||
<Typography variant="body2">
|
||||
Possible options<span aria-owns={open ? "mouse-over-popover" : undefined}
|
||||
aria-haspopup="true"
|
||||
style={{cursor: "pointer"}}
|
||||
onMouseEnter={handlePopoverOpen}
|
||||
onMouseLeave={handlePopoverClose}><EFBFBD>: </span>
|
||||
<Popover
|
||||
open={open}
|
||||
anchorEl={anchorEl}
|
||||
anchorOrigin={{
|
||||
vertical: "bottom",
|
||||
horizontal: "left",
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: "top",
|
||||
horizontal: "left",
|
||||
}}
|
||||
style={{pointerEvents: "none"}} // important
|
||||
onClose={handlePopoverClose}
|
||||
disableRestoreFocus
|
||||
>
|
||||
<TimeDurationPopover/>
|
||||
</Popover>
|
||||
<InlineBtn handler={() => setDurationString("5m")} text="5m"/>,
|
||||
<InlineBtn handler={() => setDurationString("1h")} text="1h"/>,
|
||||
<InlineBtn handler={() => setDurationString("1h 30m")} text="1h 30m"/>
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
{/*setup end time*/}
|
||||
<Box px={1}>
|
||||
<Box>
|
||||
<KeyboardDateTimePicker
|
||||
variant="inline"
|
||||
ampm={false}
|
||||
label="Until"
|
||||
value={until}
|
||||
onChange={date => dispatch({type: "SET_UNTIL", payload: date as unknown as Date})}
|
||||
onError={console.log}
|
||||
format="DD/MM/YYYY HH:mm:ss"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box my={2}>
|
||||
<Typography variant="body2">
|
||||
Will be changed to current time for auto-refresh mode.
|
||||
<InlineBtn handler={() => dispatch({type: "RUN_QUERY_TO_NOW"})} text="Switch to now"/>
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>;
|
||||
};
|
|
@ -0,0 +1,90 @@
|
|||
import {useEffect, useMemo, useState} from "react";
|
||||
import {getQueryRangeUrl, getQueryUrl} from "../../../api/query-range";
|
||||
import {useAppState} from "../../../state/common/StateContext";
|
||||
import {InstantMetricResult, MetricResult} from "../../../api/types";
|
||||
import {saveToStorage} from "../../../utils/storage";
|
||||
import {isValidHttpUrl} from "../../../utils/url";
|
||||
import {useAuthState} from "../../../state/auth/AuthStateContext";
|
||||
|
||||
export const useFetchQuery = (): {
|
||||
fetchUrl?: string,
|
||||
isLoading: boolean,
|
||||
graphData?: MetricResult[],
|
||||
liveData?: InstantMetricResult[],
|
||||
error?: string
|
||||
} => {
|
||||
const {query, displayType, serverUrl, time: {period}} = useAppState();
|
||||
|
||||
const {basicData, bearerData, authMethod} = useAuthState();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [graphData, setGraphData] = useState<MetricResult[]>();
|
||||
const [liveData, setLiveData] = useState<InstantMetricResult[]>();
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
setGraphData(undefined);
|
||||
setLiveData(undefined);
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
const fetchUrl = useMemo(() => {
|
||||
if (period) {
|
||||
if (!serverUrl) {
|
||||
setError("Please enter Server URL");
|
||||
return;
|
||||
}
|
||||
if (!query.trim()) {
|
||||
setError("Please enter a valid Query and execute it");
|
||||
return;
|
||||
}
|
||||
if (isValidHttpUrl(serverUrl)) {
|
||||
return displayType === "chart"
|
||||
? getQueryRangeUrl(serverUrl, query, period)
|
||||
: getQueryUrl(serverUrl, query, period);
|
||||
} else {
|
||||
setError("Please provide a valid URL");
|
||||
}
|
||||
}
|
||||
},
|
||||
[serverUrl, period, displayType]);
|
||||
|
||||
// TODO: this should depend on query as well, but need to decide when to do the request.
|
||||
// Doing it on each query change - looks to be a bad idea. Probably can be done on blur
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (fetchUrl) {
|
||||
const headers = new Headers();
|
||||
if (authMethod === "BASIC_AUTH") {
|
||||
headers.set("Authorization", "Basic " + btoa(`${basicData?.login || ""}:${basicData?.password || ""}`));
|
||||
}
|
||||
if (authMethod === "BEARER_AUTH") {
|
||||
headers.set("Authorization", bearerData?.token || "");
|
||||
}
|
||||
setIsLoading(true);
|
||||
const response = await fetch(fetchUrl, {
|
||||
headers
|
||||
});
|
||||
if (response.ok) {
|
||||
saveToStorage("PREFERRED_URL", serverUrl);
|
||||
saveToStorage("LAST_QUERY", query);
|
||||
const resp = await response.json();
|
||||
setError(undefined);
|
||||
displayType === "chart" ? setGraphData(resp.data.result) : setLiveData(resp.data.result);
|
||||
} else {
|
||||
setError((await response.json())?.error);
|
||||
}
|
||||
setIsLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [fetchUrl, serverUrl, displayType]);
|
||||
|
||||
return {
|
||||
fetchUrl,
|
||||
isLoading,
|
||||
graphData,
|
||||
liveData,
|
||||
error
|
||||
};
|
||||
};
|
87
app/vmui/packages/vmui/src/components/Home/HomeLayout.tsx
Normal file
87
app/vmui/packages/vmui/src/components/Home/HomeLayout.tsx
Normal file
|
@ -0,0 +1,87 @@
|
|||
import React, {FC} from "react";
|
||||
import {AppBar, Box, CircularProgress, Fade, Link, Toolbar, Typography} from "@material-ui/core";
|
||||
import {ExecutionControls} from "./Configurator/ExecutionControls";
|
||||
import {DisplayTypeSwitch} from "./Configurator/DisplayTypeSwitch";
|
||||
import GraphView from "./Views/GraphView";
|
||||
import TableView from "./Views/TableView";
|
||||
import {useAppState} from "../../state/common/StateContext";
|
||||
import QueryConfigurator from "./Configurator/QueryConfigurator";
|
||||
import {useFetchQuery} from "./Configurator/useFetchQuery";
|
||||
import JsonView from "./Views/JsonView";
|
||||
import {UrlCopy} from "./UrlCopy";
|
||||
import {Alert} from "@material-ui/lab";
|
||||
|
||||
const HomeLayout: FC = () => {
|
||||
|
||||
const {displayType, time: {period}} = useAppState();
|
||||
|
||||
const {fetchUrl, isLoading, liveData, graphData, error} = useFetchQuery();
|
||||
|
||||
return (
|
||||
<>
|
||||
<AppBar position="static">
|
||||
<Toolbar>
|
||||
<Box mr={2} display="flex">
|
||||
<Typography variant="h5">
|
||||
<span style={{fontWeight: "bolder"}}>VM</span>
|
||||
<span style={{fontWeight: "lighter"}}>UI</span>
|
||||
</Typography>
|
||||
<div style={{
|
||||
fontSize: "10px",
|
||||
marginTop: "-2px"
|
||||
}}>
|
||||
<div>BETA</div>
|
||||
</div>
|
||||
</Box>
|
||||
<div style={{
|
||||
fontSize: "10px",
|
||||
position: "absolute",
|
||||
top: "40px",
|
||||
opacity: ".4"
|
||||
}}>
|
||||
<Link color="inherit" href="https://github.com/VictoriaMetrics/vmui/issues/new" target="_blank">
|
||||
Create an issue
|
||||
</Link>
|
||||
</div>
|
||||
<Box flexGrow={1}>
|
||||
<ExecutionControls/>
|
||||
</Box>
|
||||
<DisplayTypeSwitch/>
|
||||
<UrlCopy url={fetchUrl}/>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
<Box display="flex" flexDirection="column" style={{height: "calc(100vh - 64px)"}}>
|
||||
<Box m={2}>
|
||||
<QueryConfigurator/>
|
||||
</Box>
|
||||
<Box flexShrink={1} style={{overflowY: "scroll"}}>
|
||||
{isLoading && <Fade in={isLoading} style={{
|
||||
transitionDelay: isLoading ? "300ms" : "0ms",
|
||||
}}>
|
||||
<Box alignItems="center" flexDirection="column" display="flex"
|
||||
style={{
|
||||
width: "100%",
|
||||
position: "absolute",
|
||||
height: "150px",
|
||||
background: "linear-gradient(rgba(255,255,255,.7), rgba(255,255,255,.7), rgba(255,255,255,0))"
|
||||
}} m={2}>
|
||||
<CircularProgress/>
|
||||
</Box>
|
||||
</Fade>}
|
||||
{<Box p={2}>
|
||||
{error &&
|
||||
<Alert color="error" style={{fontSize: "14px"}}>
|
||||
{error}
|
||||
</Alert>}
|
||||
{graphData && period && (displayType === "chart") &&
|
||||
<GraphView data={graphData} timePresets={period}></GraphView>}
|
||||
{liveData && (displayType === "code") && <JsonView data={liveData}/>}
|
||||
{liveData && (displayType === "table") && <TableView data={liveData}/>}
|
||||
</Box>}
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default HomeLayout;
|
27
app/vmui/packages/vmui/src/components/Home/UrlCopy.tsx
Normal file
27
app/vmui/packages/vmui/src/components/Home/UrlCopy.tsx
Normal file
|
@ -0,0 +1,27 @@
|
|||
import React, {FC} from "react";
|
||||
import {Box, IconButton, Tooltip} from "@material-ui/core";
|
||||
import FileCopyIcon from "@material-ui/icons/FileCopy";
|
||||
import {useSnack} from "../../contexts/Snackbar";
|
||||
|
||||
interface UrlCopyProps {
|
||||
url?: string
|
||||
}
|
||||
|
||||
export const UrlCopy: FC<UrlCopyProps> = ({url}) => {
|
||||
|
||||
const {showInfoMessage} = useSnack();
|
||||
|
||||
return <Box pl={2} py={1} flexShrink={0} display="flex">
|
||||
<Tooltip title="Copy Query URL">
|
||||
<IconButton size="small" onClick={(e) => {
|
||||
if (url) {
|
||||
navigator.clipboard.writeText(url);
|
||||
showInfoMessage("Value has been copied");
|
||||
e.preventDefault(); // needed to avoid snackbar immediate disappearing
|
||||
}
|
||||
}}>
|
||||
<FileCopyIcon style={{color: "white"}}/>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>;
|
||||
};
|
42
app/vmui/packages/vmui/src/components/Home/UrlLine.tsx
Normal file
42
app/vmui/packages/vmui/src/components/Home/UrlLine.tsx
Normal file
|
@ -0,0 +1,42 @@
|
|||
import React, {FC} from "react";
|
||||
import {Box, Button, Grid, Typography} from "@material-ui/core";
|
||||
import {useSnack} from "../../contexts/Snackbar";
|
||||
|
||||
interface UrlLineProps {
|
||||
url?: string
|
||||
}
|
||||
|
||||
export const UrlLine: FC<UrlLineProps> = ({url}) => {
|
||||
|
||||
const {showInfoMessage} = useSnack();
|
||||
|
||||
return <Grid item style={{backgroundColor: "#eee", width: "100%"}}>
|
||||
<Box flexDirection="row" display="flex" justifyContent="space-between" alignItems="center">
|
||||
<Box pl={2} py={1} display="flex" style={{
|
||||
flex: 1,
|
||||
minWidth: 0
|
||||
}}>
|
||||
<Typography style={{
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
fontStyle: "italic",
|
||||
fontSize: "small",
|
||||
color: "#555"
|
||||
}}>
|
||||
Currently showing {url}
|
||||
</Typography>
|
||||
|
||||
</Box>
|
||||
<Box px={2} py={1} flexShrink={0} display="flex">
|
||||
<Button size="small" onClick={(e) => {
|
||||
if (url) {
|
||||
navigator.clipboard.writeText(url);
|
||||
showInfoMessage("Value has been copied");
|
||||
e.preventDefault(); // needed to avoid snackbar immediate disappearing
|
||||
}
|
||||
}}>Copy Query Url</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Grid>;
|
||||
};
|
128
app/vmui/packages/vmui/src/components/Home/Views/GraphView.tsx
Normal file
128
app/vmui/packages/vmui/src/components/Home/Views/GraphView.tsx
Normal file
|
@ -0,0 +1,128 @@
|
|||
import React, {FC, useEffect, useMemo, useState} from "react";
|
||||
import {MetricResult} from "../../../api/types";
|
||||
|
||||
import {schemeCategory10, scaleOrdinal, interpolateRainbow, range as d3Range} from "d3";
|
||||
|
||||
import {LineChart} from "../../LineChart/LineChart";
|
||||
import {DataSeries, TimeParams} from "../../../types";
|
||||
import {getNameForMetric} from "../../../utils/metric";
|
||||
import {Legend, LegendItem} from "../../Legend/Legend";
|
||||
import {useSortedCategories} from "../../../hooks/useSortedCategories";
|
||||
import {InlineBtn} from "../../common/InlineBtn";
|
||||
|
||||
export interface GraphViewProps {
|
||||
data: MetricResult[];
|
||||
timePresets: TimeParams
|
||||
}
|
||||
|
||||
const preDefinedScale = schemeCategory10;
|
||||
|
||||
const initialMaxAmount = 20;
|
||||
const showingIncrement = 20;
|
||||
|
||||
const GraphView: FC<GraphViewProps> = ({data, timePresets}) => {
|
||||
|
||||
const [showN, setShowN] = useState(initialMaxAmount);
|
||||
|
||||
const series: DataSeries[] = useMemo(() => {
|
||||
return data?.map(d => ({
|
||||
metadata: {
|
||||
name: getNameForMetric(d)
|
||||
},
|
||||
metric: d.metric,
|
||||
// VM metrics are tuples - much simpler to work with objects in chart
|
||||
values: d.values.map(v => ({
|
||||
key: v[0],
|
||||
value: +v[1]
|
||||
}))
|
||||
}));
|
||||
}, [data]);
|
||||
|
||||
const showingSeries = useMemo(() => series.slice(0 ,showN), [series, showN]);
|
||||
|
||||
const sortedCategories = useSortedCategories(data);
|
||||
|
||||
const seriesNames = useMemo(() => showingSeries.map(s => s.metadata.name), [showingSeries]);
|
||||
|
||||
// should not change as often as array of series names (for instance between executions of same query) to
|
||||
// keep related state (like selection of a labels)
|
||||
const [seriesNamesStable, setSeriesNamesStable] = useState(seriesNames);
|
||||
|
||||
useEffect(() => {
|
||||
// primitive way to check the fact that array contents are identical
|
||||
if (seriesNamesStable.join(",") !== seriesNames.join(",")) {
|
||||
setSeriesNamesStable(seriesNames);
|
||||
}
|
||||
}, [seriesNames, setSeriesNamesStable, seriesNamesStable]);
|
||||
|
||||
const amountOfSeries = useMemo(() => series.length, [series]);
|
||||
|
||||
const color = useMemo(() => {
|
||||
const len = seriesNamesStable.length;
|
||||
const scheme = len <= preDefinedScale.length
|
||||
? preDefinedScale
|
||||
: d3Range(len).map(d => d / len).map(interpolateRainbow); // dynamically generate n colors
|
||||
return scaleOrdinal<string>()
|
||||
.domain(seriesNamesStable) // associate series names with colors
|
||||
.range(scheme);
|
||||
}, [seriesNamesStable]);
|
||||
|
||||
|
||||
// changes only if names of series are different
|
||||
const initLabels = useMemo(() => {
|
||||
return seriesNamesStable.map(name => ({
|
||||
color: color(name),
|
||||
seriesName: name,
|
||||
labelData: showingSeries.find(s => s.metadata.name === name)?.metric, // find is O(n) - can do faster
|
||||
checked: true // init with checked always
|
||||
} as LegendItem));
|
||||
}, [color, seriesNamesStable]);
|
||||
|
||||
const [labels, setLabels] = useState(initLabels);
|
||||
|
||||
useEffect(() => {
|
||||
setLabels(initLabels);
|
||||
}, [initLabels]);
|
||||
|
||||
const visibleNames = useMemo(() => labels.filter(l => l.checked).map(l => l.seriesName), [labels]);
|
||||
|
||||
const visibleSeries = useMemo(() => showingSeries.filter(s => visibleNames.includes(s.metadata.name)), [showingSeries, visibleNames]);
|
||||
|
||||
const onLegendChange = (index: number) => {
|
||||
setLabels(prevState => {
|
||||
if (prevState) {
|
||||
const newState = [...prevState];
|
||||
newState[index] = {...newState[index], checked: !newState[index].checked};
|
||||
return newState;
|
||||
}
|
||||
return prevState;
|
||||
});
|
||||
};
|
||||
|
||||
return <>
|
||||
{(amountOfSeries > 0)
|
||||
? <>
|
||||
{amountOfSeries > initialMaxAmount && <div style={{textAlign: "center"}}>
|
||||
{amountOfSeries > showN
|
||||
? <span style={{fontStyle: "italic"}}>Showing only first {showN} of {amountOfSeries} series.
|
||||
{showN + showingIncrement >= amountOfSeries
|
||||
?
|
||||
<InlineBtn handler={() => setShowN(amountOfSeries)} text="Show all"/>
|
||||
:
|
||||
<>
|
||||
<InlineBtn handler={() => setShowN(prev => Math.min(prev + showingIncrement, amountOfSeries))} text={`Show ${showingIncrement} more`}/>,
|
||||
<InlineBtn handler={() => setShowN(amountOfSeries)} text="show all"/>.
|
||||
</>}
|
||||
</span>
|
||||
: <span style={{fontStyle: "italic"}}>Showing all series.
|
||||
<InlineBtn handler={() => setShowN(initialMaxAmount)} text={`Show only ${initialMaxAmount}`}/>.
|
||||
</span>}
|
||||
</div>}
|
||||
<LineChart height={400} series={visibleSeries} color={color} timePresets={timePresets} categories={sortedCategories}></LineChart>
|
||||
<Legend labels={labels} onChange={onLegendChange} categories={sortedCategories}></Legend>
|
||||
</>
|
||||
: <div style={{textAlign: "center"}}>No data to show</div>}
|
||||
</>;
|
||||
};
|
||||
|
||||
export default GraphView;
|
|
@ -0,0 +1,33 @@
|
|||
import React, {FC, useMemo} from "react";
|
||||
import {InstantMetricResult} from "../../../api/types";
|
||||
import {Box, Button} from "@material-ui/core";
|
||||
import {useSnack} from "../../../contexts/Snackbar";
|
||||
|
||||
export interface JsonViewProps {
|
||||
data: InstantMetricResult[];
|
||||
}
|
||||
|
||||
const JsonView: FC<JsonViewProps> = ({data}) => {
|
||||
const {showInfoMessage} = useSnack();
|
||||
|
||||
const formattedJson = useMemo(() => JSON.stringify(data, null, 2), [data]);
|
||||
|
||||
return (
|
||||
<Box position="relative">
|
||||
<Box flexDirection="column" justifyContent="flex-end" display="flex"
|
||||
style={{
|
||||
position: "fixed",
|
||||
right: "16px"
|
||||
}}>
|
||||
<Button variant="outlined" onClick={(e) => {
|
||||
navigator.clipboard.writeText(formattedJson);
|
||||
showInfoMessage("Formatted JSON has been copied");
|
||||
e.preventDefault(); // needed to avoid snackbar immediate disappearing
|
||||
}}>Copy JSON</Button>
|
||||
</Box>
|
||||
<pre>{formattedJson}</pre>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default JsonView;
|
|
@ -0,0 +1,65 @@
|
|||
import React, {FC, useMemo} from "react";
|
||||
import {InstantMetricResult} from "../../../api/types";
|
||||
import {InstantDataSeries} from "../../../types";
|
||||
import {Paper, Table, TableBody, TableCell, TableContainer, TableHead, TableRow} from "@material-ui/core";
|
||||
import {makeStyles} from "@material-ui/core/styles";
|
||||
import {useSortedCategories} from "../../../hooks/useSortedCategories";
|
||||
|
||||
export interface GraphViewProps {
|
||||
data: InstantMetricResult[];
|
||||
}
|
||||
|
||||
const useStyles = makeStyles({
|
||||
deemphasized: {
|
||||
opacity: 0.4
|
||||
}
|
||||
});
|
||||
|
||||
const TableView: FC<GraphViewProps> = ({data}) => {
|
||||
|
||||
const classes = useStyles();
|
||||
|
||||
const sortedColumns = useSortedCategories(data);
|
||||
|
||||
const rows: InstantDataSeries[] = useMemo(() => {
|
||||
return data?.map(d => ({
|
||||
metadata: sortedColumns.map(c => d.metric[c.key] || "-"),
|
||||
value: d.value[1]
|
||||
}));
|
||||
}, [sortedColumns, data]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{(rows.length > 0)
|
||||
? <TableContainer component={Paper}>
|
||||
<Table aria-label="simple table">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
{sortedColumns.map((col, index) => (
|
||||
<TableCell style={{textTransform: "capitalize"}} key={index}>{col.key}</TableCell>))}
|
||||
<TableCell align="right">Value</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{rows.map((row, index) => (
|
||||
<TableRow key={index}>
|
||||
{row.metadata.map((rowMeta, index2) => {
|
||||
const prevRowValue = rows[index - 1] && rows[index - 1].metadata[index2];
|
||||
return (
|
||||
<TableCell className={prevRowValue === rowMeta ? classes.deemphasized : undefined}
|
||||
key={index2}>{rowMeta}</TableCell>
|
||||
);
|
||||
}
|
||||
)}
|
||||
<TableCell align="right">{row.value}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
: <div style={{textAlign: "center"}}>No data to show</div>}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TableView;
|
67
app/vmui/packages/vmui/src/components/Legend/Legend.tsx
Normal file
67
app/vmui/packages/vmui/src/components/Legend/Legend.tsx
Normal file
|
@ -0,0 +1,67 @@
|
|||
import React, {FC, useMemo} from "react";
|
||||
import {Checkbox, FormControlLabel, Typography} from "@material-ui/core";
|
||||
import {MetricCategory} from "../../hooks/useSortedCategories";
|
||||
import {makeStyles} from "@material-ui/core/styles";
|
||||
|
||||
export interface LegendItem {
|
||||
seriesName: string;
|
||||
labelData: {[key: string]: string};
|
||||
color: string;
|
||||
checked: boolean;
|
||||
}
|
||||
|
||||
export interface LegendProps {
|
||||
labels: LegendItem[];
|
||||
categories: MetricCategory[];
|
||||
onChange: (index: number) => void;
|
||||
}
|
||||
|
||||
const useStyles = makeStyles({
|
||||
legendWrapper: {
|
||||
display: "grid",
|
||||
width: "100%",
|
||||
gridTemplateColumns: "repeat(auto-fit)", // experiments like repeat(auto-fit, minmax(200px , auto)) may reduce size but readability as well
|
||||
gridColumnGap: ".5em",
|
||||
paddingLeft: "8px"
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
export const Legend: FC<LegendProps> = ({labels, onChange, categories}) => {
|
||||
const classes = useStyles();
|
||||
|
||||
const commonLabels = useMemo(() => labels.length > 0
|
||||
? categories
|
||||
.filter(c => c.variations === 1)
|
||||
.map(c => `${c.key}: ${labels[0].labelData[c.key]}`)
|
||||
: [], [categories, labels]);
|
||||
|
||||
const uncommonLabels = useMemo(() => categories.filter(c => c.variations !== 1).map(c => c.key), [categories]);
|
||||
|
||||
return <div>
|
||||
<div style={{textAlign: "center"}}>{`Legend for ${commonLabels.join(", ")}`}</div>
|
||||
<div className={classes.legendWrapper}>
|
||||
{labels.map((legendItem: LegendItem, index) =>
|
||||
<div key={legendItem.seriesName}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
size="small"
|
||||
checked={legendItem.checked}
|
||||
onChange={() => {
|
||||
onChange(index);
|
||||
}}
|
||||
style={{
|
||||
color: legendItem.color,
|
||||
padding: "4px"
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label={<Typography variant="body2">{uncommonLabels.map(l => `${l}: ${legendItem.labelData[l]}`).join(", ")}</Typography>}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>;
|
||||
};
|
|
@ -0,0 +1,18 @@
|
|||
import React, {useEffect, useRef} from "react";
|
||||
import {axisBottom, ScaleTime, select as d3Select} from "d3";
|
||||
|
||||
interface AxisBottomI {
|
||||
xScale: ScaleTime<number, number>;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export const AxisBottom: React.FC<AxisBottomI> = ({xScale, height}) => {
|
||||
//eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const ref = useRef<SVGSVGElement | any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
d3Select(ref.current)
|
||||
.call(axisBottom<Date>(xScale));
|
||||
}, [xScale]);
|
||||
return <g ref={ref} className="x axis" transform={`translate(0, ${height})`} />;
|
||||
};
|
39
app/vmui/packages/vmui/src/components/LineChart/AxisLeft.tsx
Normal file
39
app/vmui/packages/vmui/src/components/LineChart/AxisLeft.tsx
Normal file
|
@ -0,0 +1,39 @@
|
|||
import React, {useEffect, useRef} from "react";
|
||||
import {axisLeft, ScaleLinear, select as d3Select} from "d3";
|
||||
import {format as d3Format} from "d3-format";
|
||||
|
||||
interface AxisLeftI {
|
||||
yScale: ScaleLinear<number, number>;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const yFormatter = (val: number): string => {
|
||||
const v = Math.abs(val); // helps to handle negatives the same way
|
||||
const DECIMAL_THRESHOLD = 0.001;
|
||||
let format = ".2~s"; // 21K tilde means that it won't be 2.0K but just 2K
|
||||
if (v > 0 && v < DECIMAL_THRESHOLD) {
|
||||
format = ".0e"; // 1E-3 for values below DECIMAL_THRESHOLD
|
||||
}
|
||||
if (v >= DECIMAL_THRESHOLD && v < 1) {
|
||||
format = ".3~f"; // just plain 0.932
|
||||
}
|
||||
return d3Format(format)(val);
|
||||
};
|
||||
|
||||
export const AxisLeft: React.FC<AxisLeftI> = ({yScale, label}) => {
|
||||
//eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const ref = useRef<SVGSVGElement | any>(null);
|
||||
useEffect(() => {
|
||||
yScale && d3Select(ref.current).call(axisLeft<number>(yScale).tickFormat(yFormatter));
|
||||
}, [yScale]);
|
||||
return (
|
||||
<>
|
||||
<g className="y axis" ref={ref} />
|
||||
{label && (
|
||||
<text style={{fontSize: "0.6rem"}} transform="translate(0,-2)">
|
||||
{label}
|
||||
</text>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,48 @@
|
|||
import React from "react";
|
||||
import {Box, makeStyles, Typography} from "@material-ui/core";
|
||||
|
||||
export interface ChartTooltipData {
|
||||
value: number;
|
||||
metrics: {
|
||||
key: string;
|
||||
value: string;
|
||||
}[];
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export interface ChartTooltipProps {
|
||||
data: ChartTooltipData;
|
||||
time?: Date;
|
||||
}
|
||||
|
||||
const useStyle = makeStyles(() => ({
|
||||
wrapper: {
|
||||
maxWidth: "40vw"
|
||||
}
|
||||
}));
|
||||
|
||||
export const ChartTooltip: React.FC<ChartTooltipProps> = ({data, time}) => {
|
||||
const classes = useStyle();
|
||||
|
||||
return (
|
||||
<Box px={1} className={classes.wrapper}>
|
||||
<Box fontStyle="italic" mb={.5}>
|
||||
<Typography variant="subtitle1">{`${time?.toLocaleDateString()} ${time?.toLocaleTimeString()}`}</Typography>
|
||||
</Box>
|
||||
<Box mb={.5} my={1}>
|
||||
<Typography variant="subtitle2">{`Value: ${new Intl.NumberFormat(undefined, {
|
||||
maximumFractionDigits: 10
|
||||
}).format(data.value)}`}</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="body2">
|
||||
{data.metrics.map(({key, value}) =>
|
||||
<Box mb={.25} key={key} display="flex" flexDirection="row" alignItems="center">
|
||||
<span>{key}: </span>
|
||||
<span style={{fontWeight: "bold"}}>{value}</span>
|
||||
</Box>)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,124 @@
|
|||
/* eslint max-lines: ["error", {"max": 200}] */ // Complex D3 logic here - file can be larger
|
||||
import React, {useEffect, useMemo, useRef, useState} from "react";
|
||||
import {bisector, brushX, pointer as d3Pointer, ScaleLinear, ScaleTime, select as d3Select} from "d3";
|
||||
|
||||
interface LineI {
|
||||
yScale: ScaleLinear<number, number>;
|
||||
xScale: ScaleTime<number, number>;
|
||||
datesInChart: Date[];
|
||||
setSelection: (from: Date, to: Date) => void;
|
||||
onInteraction: (index: number | undefined, y: number | undefined) => void; // key is index. undefined means no interaction
|
||||
}
|
||||
|
||||
export const InteractionArea: React.FC<LineI> = ({yScale, xScale, datesInChart, onInteraction, setSelection}) => {
|
||||
const refBrush = useRef<SVGGElement>(null);
|
||||
|
||||
const [currentActivePoint, setCurrentActivePoint] = useState<number>();
|
||||
const [currentY, setCurrentY] = useState<number>();
|
||||
const [isBrushed, setIsBrushed] = useState(false);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any,@typescript-eslint/explicit-function-return-type
|
||||
function brushEnded(this: any, event: any) {
|
||||
const selection = event.selection;
|
||||
if (selection) {
|
||||
if (!event.sourceEvent) return; // see comment in brushstarted
|
||||
setIsBrushed(true);
|
||||
const [from, to]: [Date, Date] = selection.map((s: number) => xScale.invert(s));
|
||||
setSelection(from, to);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
d3Select(refBrush.current).call(brush.move as any, null); // clean brush
|
||||
} else {
|
||||
// end event with empty selection means that we're cancelling brush
|
||||
setIsBrushed(false);
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const brushStarted = (event: any): void => {
|
||||
// first of all: event is a d3 global value that stores current event (sort of).
|
||||
// This is weird but this is how d3 works with events.
|
||||
//This check is important:
|
||||
// Inside brushended - we have .call(brush.move, ...) in order to snap selected range to dates
|
||||
// that internally calls brushstarted again. But in this case sourceEvent is null, since the call
|
||||
// is programmatic. If we do not need to adjust selected are - no need to have this check (probably)
|
||||
if (event.sourceEvent) {
|
||||
setCurrentActivePoint(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const brush = useMemo(
|
||||
() =>
|
||||
brushX()
|
||||
.extent([
|
||||
[0, 0],
|
||||
[xScale.range()[1], yScale.range()[0]]
|
||||
])
|
||||
.on("end", brushEnded)
|
||||
.on("start", brushStarted),
|
||||
[brushEnded, xScale, yScale]
|
||||
);
|
||||
|
||||
// Needed to clean brush if we need to keep it
|
||||
|
||||
// const resetBrushHandler = useCallback(
|
||||
// (e) => {
|
||||
// const el = e.target as HTMLElement;
|
||||
// if (
|
||||
// el &&
|
||||
// el.tagName !== "rect" &&
|
||||
// e.target.classList.length &&
|
||||
// !e.target.classList.contains("selection")
|
||||
// ) {
|
||||
// // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
// d3Select(refBrush.current).call(brush.move as any, null);
|
||||
// }
|
||||
// },
|
||||
// [brush.move]
|
||||
// );
|
||||
|
||||
// useEffect(() => {
|
||||
// window.addEventListener("click", resetBrushHandler);
|
||||
// return () => {
|
||||
// window.removeEventListener("click", resetBrushHandler);
|
||||
// };
|
||||
// }, [resetBrushHandler]);
|
||||
|
||||
useEffect(() => {
|
||||
const bisect = bisector((d: Date) => d).center;
|
||||
const defineActivePoint = (mx: number): void => {
|
||||
const date = xScale.invert(mx); // date is a Date object
|
||||
const index = bisect(datesInChart, date, 1);
|
||||
setCurrentActivePoint(index);
|
||||
};
|
||||
|
||||
d3Select(refBrush.current)
|
||||
.on("touchmove mousemove", (event) => {
|
||||
const coords: [number, number] = d3Pointer(event);
|
||||
if (!isBrushed) {
|
||||
defineActivePoint(coords[0]);
|
||||
setCurrentY(coords[1]);
|
||||
}
|
||||
})
|
||||
.on("mouseout", () => {
|
||||
if (!isBrushed) {
|
||||
setCurrentActivePoint(undefined);
|
||||
}
|
||||
});
|
||||
}, [xScale, datesInChart, isBrushed]);
|
||||
|
||||
useEffect(() => {
|
||||
onInteraction(currentActivePoint, currentY);
|
||||
}, [currentActivePoint, currentY, onInteraction]);
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
brush && xScale && d3Select(refBrush.current).call(brush);
|
||||
}, [xScale, brush]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<g ref={refBrush} />
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,10 @@
|
|||
import React from "react";
|
||||
|
||||
interface LineI {
|
||||
height: number;
|
||||
x: number | undefined;
|
||||
}
|
||||
|
||||
export const InteractionLine: React.FC<LineI> = ({height, x}) => {
|
||||
return <>{x && <line x1={x} y1="0" x2={x} y2={height} stroke="black" strokeDasharray="4" />}</>;
|
||||
};
|
196
app/vmui/packages/vmui/src/components/LineChart/LineChart.tsx
Normal file
196
app/vmui/packages/vmui/src/components/LineChart/LineChart.tsx
Normal file
|
@ -0,0 +1,196 @@
|
|||
/* eslint max-lines: ["error", {"max": 300}] */
|
||||
import React, {useCallback, useMemo, useRef, useState} from "react";
|
||||
import {line as d3Line, max as d3Max, min as d3Min, scaleLinear, ScaleOrdinal, scaleTime} from "d3";
|
||||
import "./line-chart.css";
|
||||
import Measure from "react-measure";
|
||||
import {AxisBottom} from "./AxisBottom";
|
||||
import {AxisLeft} from "./AxisLeft";
|
||||
import {DataSeries, DataValue, TimeParams} from "../../types";
|
||||
import {InteractionLine} from "./InteractionLine";
|
||||
import {InteractionArea} from "./InteractionArea";
|
||||
import {Box, Popover} from "@material-ui/core";
|
||||
import {ChartTooltip, ChartTooltipData} from "./ChartTooltip";
|
||||
import {useAppDispatch} from "../../state/common/StateContext";
|
||||
import {dateFromSeconds} from "../../utils/time";
|
||||
import {MetricCategory} from "../../hooks/useSortedCategories";
|
||||
|
||||
interface LineChartProps {
|
||||
series: DataSeries[];
|
||||
timePresets: TimeParams;
|
||||
height: number;
|
||||
color: ScaleOrdinal<string, string>; // maps name to color hex code
|
||||
categories: MetricCategory[];
|
||||
}
|
||||
|
||||
interface TooltipState {
|
||||
xCoord: number;
|
||||
date: Date;
|
||||
index: number;
|
||||
leftPart: boolean;
|
||||
activeSeries: number;
|
||||
}
|
||||
|
||||
const TOOLTIP_MARGIN = 20;
|
||||
|
||||
export const LineChart: React.FC<LineChartProps> = ({series, timePresets, height, color, categories}) => {
|
||||
const [screenWidth, setScreenWidth] = useState<number>(window.innerWidth);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const margin = {top: 10, right: 20, bottom: 40, left: 50};
|
||||
const svgWidth = useMemo(() => screenWidth - margin.left - margin.right, [screenWidth, margin.left, margin.right]);
|
||||
const svgHeight = useMemo(() => height - margin.top - margin.bottom, [margin.top, margin.bottom]);
|
||||
const xScale = useMemo(() => scaleTime().domain([timePresets.start,timePresets.end].map(dateFromSeconds)).range([0, svgWidth]), [
|
||||
svgWidth,
|
||||
timePresets
|
||||
]);
|
||||
|
||||
const [showTooltip, setShowTooltip] = useState(false);
|
||||
|
||||
const [tooltipState, setTooltipState] = useState<TooltipState>();
|
||||
|
||||
const yAxisLabel = ""; // TODO: label
|
||||
|
||||
const yScale = useMemo(
|
||||
() => {
|
||||
const seriesValues = series.reduce((acc: DataValue[], next: DataSeries) => [...acc, ...next.values], []).map(_ => _.value);
|
||||
const max = d3Max(seriesValues) ?? 1; // || 1 will cause one additional tick if max is 0
|
||||
const min = d3Min(seriesValues) || 0;
|
||||
return scaleLinear()
|
||||
.domain([min > 0 ? 0 : min, max < 0 ? 0 : max]) // input
|
||||
.range([svgHeight, 0])
|
||||
.nice();
|
||||
},
|
||||
[series, svgHeight]
|
||||
);
|
||||
|
||||
const line = useMemo(
|
||||
() =>
|
||||
d3Line<DataValue>()
|
||||
.x((d) => xScale(dateFromSeconds(d.key)))
|
||||
.y((d) => yScale(d.value || 0)),
|
||||
[xScale, yScale]
|
||||
);
|
||||
const getDataLine = (series: DataSeries) => line(series.values);
|
||||
|
||||
const handleChartInteraction = useCallback(
|
||||
async (key: number | undefined, y: number | undefined) => {
|
||||
if (typeof key === "number") {
|
||||
if (y && series && series[0]) {
|
||||
|
||||
// define closest series in chart
|
||||
const hoveringOverValue = yScale.invert(y);
|
||||
const closestPoint = series.map(s => s.values[key]?.value).reduce((acc, nextValue, index) => {
|
||||
const delta = Math.abs(hoveringOverValue - nextValue);
|
||||
if (delta < acc.delta) {
|
||||
acc = {delta, index};
|
||||
}
|
||||
return acc;
|
||||
}, {delta: Infinity, index: 0});
|
||||
|
||||
const date = dateFromSeconds(series[0].values[key].key);
|
||||
// popover orientation should be defined based on the scale domain middle, not data, since
|
||||
// data may not be present for the whole range
|
||||
const leftPart = date.valueOf() < (xScale.domain()[1].valueOf() + xScale.domain()[0].valueOf()) / 2;
|
||||
setTooltipState({
|
||||
date,
|
||||
xCoord: xScale(date),
|
||||
index: key,
|
||||
activeSeries: closestPoint.index,
|
||||
leftPart
|
||||
});
|
||||
setShowTooltip(true);
|
||||
}
|
||||
} else {
|
||||
setShowTooltip(false);
|
||||
setTooltipState(undefined);
|
||||
}
|
||||
},
|
||||
[xScale, yScale, series]
|
||||
);
|
||||
|
||||
const tooltipData: ChartTooltipData | undefined = useMemo(() => {
|
||||
if (tooltipState?.activeSeries) {
|
||||
return {
|
||||
value: series[tooltipState.activeSeries].values[tooltipState.index].value,
|
||||
metrics: categories.map(c => ({ key: c.key, value: series[tooltipState.activeSeries].metric[c.key]}))
|
||||
};
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}, [tooltipState, series]);
|
||||
|
||||
const tooltipAnchor = useRef<SVGGElement>(null);
|
||||
|
||||
const seriesDates = useMemo(() => {
|
||||
if (series && series[0]) {
|
||||
return series[0].values.map(v => v.key).map(dateFromSeconds);
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
}, [series]);
|
||||
|
||||
const setSelection = (from: Date, to: Date) => {
|
||||
dispatch({type: "SET_PERIOD", payload: {from, to}});
|
||||
};
|
||||
|
||||
return (
|
||||
<Measure bounds onResize={({bounds}) => bounds && setScreenWidth(bounds?.width)}>
|
||||
{({measureRef}) => (
|
||||
<div ref={measureRef} style={{width: "100%"}}>
|
||||
{tooltipAnchor && tooltipData && (
|
||||
<Popover
|
||||
disableScrollLock={true}
|
||||
style={{pointerEvents: "none"}} // IMPORTANT in order to allow interactions through popover's backdrop
|
||||
id="chart-tooltip-popover"
|
||||
open={showTooltip}
|
||||
anchorEl={tooltipAnchor.current}
|
||||
anchorOrigin={{
|
||||
vertical: "top",
|
||||
horizontal: tooltipState?.leftPart ? TOOLTIP_MARGIN : -TOOLTIP_MARGIN
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: "top",
|
||||
horizontal: tooltipState?.leftPart ? "left" : "right"
|
||||
}}
|
||||
disableRestoreFocus>
|
||||
<Box m={1}>
|
||||
<ChartTooltip data={tooltipData} time={tooltipState?.date}/>
|
||||
</Box>
|
||||
</Popover>
|
||||
)}
|
||||
<svg width="100%" height={height}>
|
||||
<g transform={`translate(${margin.left}, ${margin.top})`}>
|
||||
<defs>
|
||||
{/*Clip path helps to clip the line*/}
|
||||
<clipPath id="clip-line">
|
||||
{/*Transforming and adding size to clip-path in order to avoid clipping of chart elements*/}
|
||||
<rect transform={"translate(0, -2)"} width={xScale.range()[1] + 4} height={yScale.range()[0] + 2} />
|
||||
</clipPath>
|
||||
</defs>
|
||||
<AxisBottom xScale={xScale} height={svgHeight} />
|
||||
<AxisLeft yScale={yScale} label={yAxisLabel} />
|
||||
{series.map((s, i) =>
|
||||
<path stroke={color(s.metadata.name)}
|
||||
key={i} className="line"
|
||||
style={{opacity: tooltipState?.activeSeries !== undefined ? (i === tooltipState?.activeSeries ? 1 : .2) : 1 }}
|
||||
d={getDataLine(s) as string}
|
||||
clipPath="url(#clip-line)"/>)}
|
||||
<g ref={tooltipAnchor}>
|
||||
<InteractionLine height={svgHeight} x={tooltipState?.xCoord} />
|
||||
</g>
|
||||
{/*NOTE: in SVG last element wins - so since we want mouseover to work in all area this should be last*/}
|
||||
<InteractionArea
|
||||
xScale={xScale}
|
||||
yScale={yScale}
|
||||
datesInChart={seriesDates}
|
||||
onInteraction={handleChartInteraction}
|
||||
setSelection={setSelection}
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</Measure>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,15 @@
|
|||
.line {
|
||||
fill: none;
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
fill: none;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
/* Style the dots by assigning a fill and stroke */
|
||||
.dot {
|
||||
fill: #621773;
|
||||
stroke: #fff;
|
||||
}
|
5
app/vmui/packages/vmui/src/components/LineChart/model.ts
Normal file
5
app/vmui/packages/vmui/src/components/LineChart/model.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
export type AggregatedDataSet = {
|
||||
key: number;
|
||||
value: aggregatedDataValue;
|
||||
};
|
||||
export type aggregatedDataValue = {[key: string]: number};
|
|
@ -0,0 +1,26 @@
|
|||
import CircularProgress, {CircularProgressProps} from "@material-ui/core/CircularProgress";
|
||||
import {Box} from "@material-ui/core";
|
||||
import Typography from "@material-ui/core/Typography";
|
||||
import React, {FC} from "react";
|
||||
|
||||
const CircularProgressWithLabel: FC<CircularProgressProps & { label: number }> = (props) => {
|
||||
return (
|
||||
<Box position="relative" display="inline-flex">
|
||||
<CircularProgress variant="determinate" {...props} />
|
||||
<Box
|
||||
top={0}
|
||||
left={0}
|
||||
bottom={0}
|
||||
right={0}
|
||||
position="absolute"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
<Typography variant="caption" component="div">{`${props.label}s`}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default CircularProgressWithLabel;
|
19
app/vmui/packages/vmui/src/components/common/InlineBtn.tsx
Normal file
19
app/vmui/packages/vmui/src/components/common/InlineBtn.tsx
Normal file
|
@ -0,0 +1,19 @@
|
|||
import {makeStyles} from "@material-ui/core/styles";
|
||||
import React from "react";
|
||||
import {Link} from "@material-ui/core";
|
||||
|
||||
const useStyles = makeStyles({
|
||||
inlineBtn: {
|
||||
"&:hover": {
|
||||
cursor: "pointer"
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
export const InlineBtn: React.FC<{handler: () => void; text: string}> = ({handler, text}) => {
|
||||
const classes = useStyles();
|
||||
return <Link component="span" className={classes.inlineBtn}
|
||||
onClick={handler}>
|
||||
{text}
|
||||
</Link>;
|
||||
};
|
55
app/vmui/packages/vmui/src/contexts/Snackbar.tsx
Normal file
55
app/vmui/packages/vmui/src/contexts/Snackbar.tsx
Normal file
|
@ -0,0 +1,55 @@
|
|||
import React, {createContext, FC, useContext, useEffect, useState} from "react";
|
||||
import {Snackbar} from "@material-ui/core";
|
||||
import {Alert} from "@material-ui/lab";
|
||||
|
||||
export interface SnackModel {
|
||||
message?: string;
|
||||
color?: string;
|
||||
open?: boolean;
|
||||
key?: number;
|
||||
}
|
||||
|
||||
type SnackbarContextType = { showInfoMessage: (value: string) => void };
|
||||
|
||||
export const SnackbarContext = createContext<SnackbarContextType>({
|
||||
showInfoMessage: () => {
|
||||
// TODO: default value here makes no sense
|
||||
}
|
||||
});
|
||||
|
||||
export const useSnack = (): SnackbarContextType => useContext(SnackbarContext);
|
||||
|
||||
export const SnackbarProvider: FC = ({children}) => {
|
||||
const [snack, setSnack] = useState<SnackModel>({});
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const [infoMessage, setInfoMessage] = useState<string | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (infoMessage) {
|
||||
setSnack({
|
||||
message: infoMessage,
|
||||
key: new Date().getTime()
|
||||
});
|
||||
setOpen(true);
|
||||
}
|
||||
}, [infoMessage]);
|
||||
|
||||
const handleClose = (e: unknown, reason: string): void => {
|
||||
if (reason !== "clickaway") {
|
||||
setInfoMessage(undefined);
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return <SnackbarContext.Provider value={{showInfoMessage: setInfoMessage}}>
|
||||
<Snackbar open={open} key={snack.key} autoHideDuration={4000} onClose={handleClose}>
|
||||
<Alert>
|
||||
{snack.message}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
{children}
|
||||
</SnackbarContext.Provider>;
|
||||
};
|
||||
|
||||
|
21
app/vmui/packages/vmui/src/hooks/useSortedCategories.ts
Normal file
21
app/vmui/packages/vmui/src/hooks/useSortedCategories.ts
Normal file
|
@ -0,0 +1,21 @@
|
|||
import {useMemo} from "react";
|
||||
import {MetricBase} from "../api/types";
|
||||
|
||||
export type MetricCategory = {
|
||||
key: string;
|
||||
variations: number;
|
||||
}
|
||||
|
||||
export const useSortedCategories = (data: MetricBase[]): MetricCategory[] => useMemo(() => {
|
||||
const columns: { [key: string]: { options: Set<string> } } = {};
|
||||
data.forEach(d =>
|
||||
Object.entries(d.metric).forEach(e =>
|
||||
columns[e[0]] ? columns[e[0]].options.add(e[1]) : columns[e[0]] = {options: new Set([e[1]])}
|
||||
)
|
||||
);
|
||||
|
||||
return Object.entries(columns).map(e => ({
|
||||
key: e[0],
|
||||
variations: e[1].options.size
|
||||
})).sort((a1, a2) => a1.variations - a2.variations);
|
||||
}, [data]);
|
33
app/vmui/packages/vmui/src/index.css
Normal file
33
app/vmui/packages/vmui/src/index.css
Normal file
|
@ -0,0 +1,33 @@
|
|||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
}
|
||||
|
||||
/*Material UI global classes*/
|
||||
|
||||
.MuiAccordionSummary-content {
|
||||
margin: 10px 0 !important;
|
||||
}
|
||||
|
||||
/* TODO: find better way to override codemirror styles */
|
||||
.cm-activeLine {
|
||||
background-color: inherit !important;
|
||||
}
|
||||
.cm-wrap {
|
||||
border-radius: 4px;
|
||||
border-color: #b9b9b9;
|
||||
border-style: solid;
|
||||
border-width: 1px;
|
||||
font-size: 10px;
|
||||
}
|
||||
.one-line-scroll .cm-wrap {
|
||||
height: 24px;
|
||||
}
|
16
app/vmui/packages/vmui/src/index.tsx
Normal file
16
app/vmui/packages/vmui/src/index.tsx
Normal file
|
@ -0,0 +1,16 @@
|
|||
import React from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import "./index.css";
|
||||
import App from "./App";
|
||||
import reportWebVitals from "./reportWebVitals";
|
||||
ReactDOM.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
document.getElementById("root")
|
||||
);
|
||||
|
||||
// If you want to start measuring performance in your app, pass a function
|
||||
// to log results (for example: reportWebVitals(console.log))
|
||||
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
|
||||
reportWebVitals();
|
1
app/vmui/packages/vmui/src/react-app-env.d.ts
vendored
Normal file
1
app/vmui/packages/vmui/src/react-app-env.d.ts
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
/// <reference types="react-scripts" />
|
15
app/vmui/packages/vmui/src/reportWebVitals.ts
Normal file
15
app/vmui/packages/vmui/src/reportWebVitals.ts
Normal file
|
@ -0,0 +1,15 @@
|
|||
import {ReportHandler} from "web-vitals";
|
||||
|
||||
const reportWebVitals = (onPerfEntry?: ReportHandler): void => {
|
||||
if (onPerfEntry && onPerfEntry instanceof Function) {
|
||||
import("web-vitals").then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
|
||||
getCLS(onPerfEntry);
|
||||
getFID(onPerfEntry);
|
||||
getFCP(onPerfEntry);
|
||||
getLCP(onPerfEntry);
|
||||
getTTFB(onPerfEntry);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export default reportWebVitals;
|
5
app/vmui/packages/vmui/src/setupTests.ts
Normal file
5
app/vmui/packages/vmui/src/setupTests.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
// jest-dom adds custom jest matchers for asserting on DOM nodes.
|
||||
// allows you to do things like:
|
||||
// expect(element).toHaveTextContent(/react/i)
|
||||
// learn more: https://github.com/testing-library/jest-dom
|
||||
import "@testing-library/jest-dom";
|
25
app/vmui/packages/vmui/src/state/auth/AuthStateContext.tsx
Normal file
25
app/vmui/packages/vmui/src/state/auth/AuthStateContext.tsx
Normal file
|
@ -0,0 +1,25 @@
|
|||
import React, {createContext, Dispatch, FC, useContext, useMemo, useReducer} from "react";
|
||||
import {AuthAction, AuthState, initialPrepopulatedState, reducer} from "./reducer";
|
||||
|
||||
type AuthStateContextType = { state: AuthState, dispatch: Dispatch<AuthAction> };
|
||||
|
||||
export const AuthStateContext = createContext<AuthStateContextType>({} as AuthStateContextType);
|
||||
|
||||
export const useAuthState = (): AuthState => useContext(AuthStateContext).state;
|
||||
export const useAuthDispatch = (): Dispatch<AuthAction> => useContext(AuthStateContext).dispatch;
|
||||
|
||||
export const AuthStateProvider: FC = ({children}) => {
|
||||
|
||||
const [state, dispatch] = useReducer(reducer, initialPrepopulatedState);
|
||||
|
||||
const contextValue = useMemo(() => {
|
||||
return { state, dispatch };
|
||||
}, [state, dispatch]);
|
||||
|
||||
|
||||
return <AuthStateContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</AuthStateContext.Provider>;
|
||||
};
|
||||
|
||||
|
80
app/vmui/packages/vmui/src/state/auth/reducer.ts
Normal file
80
app/vmui/packages/vmui/src/state/auth/reducer.ts
Normal file
|
@ -0,0 +1,80 @@
|
|||
import {authKeys, getFromStorage, removeFromStorage, saveToStorage} from "../../utils/storage";
|
||||
|
||||
export type AUTH_METHOD = "NO_AUTH" | "BASIC_AUTH" | "BEARER_AUTH";
|
||||
|
||||
export type BasicAuthData = {
|
||||
login: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
export type BearerAuthData = {
|
||||
token: string; // "Bearer xxx"
|
||||
};
|
||||
|
||||
export interface AuthState {
|
||||
authMethod: AUTH_METHOD;
|
||||
basicData?: BasicAuthData;
|
||||
bearerData?: BearerAuthData;
|
||||
saveAuthLocally: boolean;
|
||||
}
|
||||
|
||||
export type WithCheckbox<T = undefined> = {checkbox: boolean; value: T};
|
||||
|
||||
export type AuthAction =
|
||||
| { type: "SET_BASIC_AUTH", payload: WithCheckbox<BasicAuthData> }
|
||||
| { type: "SET_BEARER_AUTH", payload: WithCheckbox<BearerAuthData> }
|
||||
| { type: "SET_NO_AUTH", payload: WithCheckbox}
|
||||
|
||||
export const initialState: AuthState = {
|
||||
authMethod: "NO_AUTH",
|
||||
saveAuthLocally: false
|
||||
};
|
||||
|
||||
const initialAuthMethodData = getFromStorage("AUTH_TYPE") as AUTH_METHOD;
|
||||
const initialBasicAuthData = getFromStorage("BASIC_AUTH_DATA") as BasicAuthData;
|
||||
const initialBearerAuthData = getFromStorage("BEARER_AUTH_DATA") as BearerAuthData;
|
||||
|
||||
export const initialPrepopulatedState: AuthState = {
|
||||
...initialState,
|
||||
authMethod: initialAuthMethodData || initialState.authMethod,
|
||||
basicData: initialBasicAuthData,
|
||||
bearerData: initialBearerAuthData,
|
||||
saveAuthLocally: !!(initialBasicAuthData || initialBearerAuthData)
|
||||
};
|
||||
|
||||
export const removeAuthKeys = (): void => {
|
||||
removeFromStorage(authKeys);
|
||||
};
|
||||
|
||||
export function reducer(state: AuthState, action: AuthAction): AuthState {
|
||||
// Reducer should not have side effects
|
||||
// but until auth storage is handled ONLY HERE,
|
||||
// it should be fine
|
||||
switch (action.type) {
|
||||
case "SET_BASIC_AUTH":
|
||||
action.payload.checkbox ? saveToStorage("BASIC_AUTH_DATA", action.payload.value) : removeAuthKeys();
|
||||
saveToStorage("AUTH_TYPE", "BASIC_AUTH");
|
||||
return {
|
||||
...state,
|
||||
authMethod: "BASIC_AUTH",
|
||||
basicData: action.payload.value
|
||||
};
|
||||
case "SET_BEARER_AUTH":
|
||||
action.payload.checkbox ? saveToStorage("BEARER_AUTH_DATA", action.payload.value) : removeAuthKeys();
|
||||
saveToStorage("AUTH_TYPE", "BEARER_AUTH");
|
||||
return {
|
||||
...state,
|
||||
authMethod: "BEARER_AUTH",
|
||||
bearerData: action.payload.value
|
||||
};
|
||||
case "SET_NO_AUTH":
|
||||
!action.payload.checkbox && removeAuthKeys();
|
||||
saveToStorage("AUTH_TYPE", "NO_AUTH");
|
||||
return {
|
||||
...state,
|
||||
authMethod: "NO_AUTH"
|
||||
};
|
||||
default:
|
||||
throw new Error();
|
||||
}
|
||||
}
|
36
app/vmui/packages/vmui/src/state/common/StateContext.tsx
Normal file
36
app/vmui/packages/vmui/src/state/common/StateContext.tsx
Normal file
|
@ -0,0 +1,36 @@
|
|||
import React, {createContext, Dispatch, FC, useContext, useEffect, useMemo, useReducer} from "react";
|
||||
import {Action, AppState, initialState, reducer} from "./reducer";
|
||||
import {getQueryStringValue, setQueryStringValue} from "../../utils/query-string";
|
||||
|
||||
type StateContextType = { state: AppState, dispatch: Dispatch<Action> };
|
||||
|
||||
export const StateContext = createContext<StateContextType>({} as StateContextType);
|
||||
|
||||
export const useAppState = (): AppState => useContext(StateContext).state;
|
||||
export const useAppDispatch = (): Dispatch<Action> => useContext(StateContext).dispatch;
|
||||
|
||||
export const initialPrepopulatedState = Object.entries(initialState)
|
||||
.reduce((acc, [key, value]) => ({
|
||||
...acc,
|
||||
[key]: getQueryStringValue(key) || value
|
||||
}), {}) as AppState;
|
||||
|
||||
export const StateProvider: FC = ({children}) => {
|
||||
|
||||
const [state, dispatch] = useReducer(reducer, initialPrepopulatedState);
|
||||
|
||||
useEffect(() => {
|
||||
setQueryStringValue(state as unknown as Record<string, unknown>);
|
||||
}, [state]);
|
||||
|
||||
const contextValue = useMemo(() => {
|
||||
return { state, dispatch };
|
||||
}, [state, dispatch]);
|
||||
|
||||
|
||||
return <StateContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</StateContext.Provider>;
|
||||
};
|
||||
|
||||
|
121
app/vmui/packages/vmui/src/state/common/reducer.ts
Normal file
121
app/vmui/packages/vmui/src/state/common/reducer.ts
Normal file
|
@ -0,0 +1,121 @@
|
|||
import {DisplayType} from "../../components/Home/Configurator/DisplayTypeSwitch";
|
||||
import {TimeParams, TimePeriod} from "../../types";
|
||||
import {dateFromSeconds, getDurationFromPeriod, getTimeperiodForDuration} from "../../utils/time";
|
||||
import {getFromStorage} from "../../utils/storage";
|
||||
|
||||
export interface TimeState {
|
||||
duration: string;
|
||||
period: TimeParams;
|
||||
}
|
||||
|
||||
export interface AppState {
|
||||
serverUrl: string;
|
||||
displayType: DisplayType;
|
||||
query: string;
|
||||
time: TimeState;
|
||||
queryControls: {
|
||||
autoRefresh: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
export type Action =
|
||||
| { type: "SET_DISPLAY_TYPE", payload: DisplayType }
|
||||
| { type: "SET_SERVER", payload: string }
|
||||
| { type: "SET_QUERY", payload: string }
|
||||
| { type: "SET_DURATION", payload: string }
|
||||
| { type: "SET_UNTIL", payload: Date }
|
||||
| { type: "SET_PERIOD", payload: TimePeriod }
|
||||
| { type: "RUN_QUERY"}
|
||||
| { type: "RUN_QUERY_TO_NOW"}
|
||||
| { type: "TOGGLE_AUTOREFRESH"}
|
||||
|
||||
export const initialState: AppState = {
|
||||
serverUrl: getFromStorage("PREFERRED_URL") as string || "https://", // https://demo.promlabs.com or https://play.victoriametrics.com/select/accounting/1/6a716b0f-38bc-4856-90ce-448fd713e3fe/prometheus",
|
||||
displayType: "chart",
|
||||
query: getFromStorage("LAST_QUERY") as string || "\n", // demo_memory_usage_bytes
|
||||
time: {
|
||||
duration: "1h",
|
||||
period: getTimeperiodForDuration("1h")
|
||||
},
|
||||
queryControls: {
|
||||
autoRefresh: false
|
||||
}
|
||||
};
|
||||
|
||||
export function reducer(state: AppState, action: Action): AppState {
|
||||
switch (action.type) {
|
||||
case "SET_DISPLAY_TYPE":
|
||||
return {
|
||||
...state,
|
||||
displayType: action.payload
|
||||
};
|
||||
case "SET_SERVER":
|
||||
return {
|
||||
...state,
|
||||
serverUrl: action.payload
|
||||
};
|
||||
case "SET_QUERY":
|
||||
return {
|
||||
...state,
|
||||
query: action.payload
|
||||
};
|
||||
case "SET_DURATION":
|
||||
return {
|
||||
...state,
|
||||
time: {
|
||||
...state.time,
|
||||
duration: action.payload,
|
||||
period: getTimeperiodForDuration(action.payload, dateFromSeconds(state.time.period.end))
|
||||
}
|
||||
};
|
||||
case "SET_UNTIL":
|
||||
return {
|
||||
...state,
|
||||
time: {
|
||||
...state.time,
|
||||
period: getTimeperiodForDuration(state.time.duration, action.payload)
|
||||
}
|
||||
};
|
||||
case "SET_PERIOD":
|
||||
// eslint-disable-next-line no-case-declarations
|
||||
const duration = getDurationFromPeriod(action.payload);
|
||||
return {
|
||||
...state,
|
||||
queryControls: {
|
||||
...state.queryControls,
|
||||
autoRefresh: false // since we're considering this to action to be fired from period selection on chart
|
||||
},
|
||||
time: {
|
||||
...state.time,
|
||||
duration,
|
||||
period: getTimeperiodForDuration(duration, action.payload.to)
|
||||
}
|
||||
};
|
||||
case "TOGGLE_AUTOREFRESH":
|
||||
return {
|
||||
...state,
|
||||
queryControls: {
|
||||
...state.queryControls,
|
||||
autoRefresh: !state.queryControls.autoRefresh
|
||||
}
|
||||
};
|
||||
case "RUN_QUERY":
|
||||
return {
|
||||
...state,
|
||||
time: {
|
||||
...state.time,
|
||||
period: getTimeperiodForDuration(state.time.duration, dateFromSeconds(state.time.period.end))
|
||||
}
|
||||
};
|
||||
case "RUN_QUERY_TO_NOW":
|
||||
return {
|
||||
...state,
|
||||
time: {
|
||||
...state.time,
|
||||
period: getTimeperiodForDuration(state.time.duration)
|
||||
}
|
||||
};
|
||||
default:
|
||||
throw new Error();
|
||||
}
|
||||
}
|
30
app/vmui/packages/vmui/src/types/index.ts
Normal file
30
app/vmui/packages/vmui/src/types/index.ts
Normal file
|
@ -0,0 +1,30 @@
|
|||
import {MetricBase} from "../api/types";
|
||||
|
||||
export interface TimeParams {
|
||||
start: number; // timestamp in seconds
|
||||
end: number; // timestamp in seconds
|
||||
step?: number; // seconds
|
||||
}
|
||||
|
||||
export interface TimePeriod {
|
||||
from: Date;
|
||||
to: Date;
|
||||
}
|
||||
|
||||
export interface DataValue {
|
||||
key: number; // timestamp in seconds
|
||||
value: number; // y axis value
|
||||
}
|
||||
|
||||
export interface DataSeries extends MetricBase{
|
||||
metadata: {
|
||||
name: string;
|
||||
},
|
||||
values: DataValue[]; // sorted by key which is timestamp
|
||||
}
|
||||
|
||||
|
||||
export interface InstantDataSeries {
|
||||
metadata: string[]; // just ordered columns
|
||||
value: string;
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue