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

This commit is contained in:
Aliaksandr Valialkin 2021-07-15 14:05:30 +03:00
commit 8df8c414de
280 changed files with 28470 additions and 3885 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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"},

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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",
},

View file

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

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

View file

@ -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")

View file

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

View file

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

View file

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

View file

@ -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"}`)

View file

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

View file

@ -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/`.

View file

@ -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"}`)

View file

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

View file

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

View file

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

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

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

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

View file

@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

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

File diff suppressed because one or more lines are too long

View 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.
*/

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

File diff suppressed because one or more lines are too long

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

View file

@ -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
View 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
View 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
View 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/`

View 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

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

View 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

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

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

View 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 cant go back!**
If you arent 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 youre on your own.
You dont have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldnt feel obligated to use this feature. However we understand that this tool wouldnt be useful if you couldnt 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/).

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

View 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

File diff suppressed because it is too large Load diff

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

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

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

View file

@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

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

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

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

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

View file

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

View file

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

View file

@ -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/>&nbsp;Query Range as Chart
</StylizedToggleButton>
<StylizedToggleButton value="code" aria-label="display as code">
<CodeIcon/>&nbsp;Instant Query as JSON
</StylizedToggleButton>
<StylizedToggleButton value="table" aria-label="display as table">
<TableChartIcon/>&nbsp;Instant Query as Table
</StylizedToggleButton>
</ToggleButtonGroup>;
};

View file

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

View file

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

View file

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

View file

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

View file

@ -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>:&nbsp;</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"/>,&nbsp;
<InlineBtn handler={() => setDurationString("1h")} text="1h"/>,&nbsp;
<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.&nbsp;
<InlineBtn handler={() => dispatch({type: "RUN_QUERY_TO_NOW"})} text="Switch to now"/>
</Typography>
</Box>
</Box>
</Box>;
};

View file

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

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

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

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

View 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.&nbsp;
{showN + showingIncrement >= amountOfSeries
?
<InlineBtn handler={() => setShowN(amountOfSeries)} text="Show all"/>
:
<>
<InlineBtn handler={() => setShowN(prev => Math.min(prev + showingIncrement, amountOfSeries))} text={`Show ${showingIncrement} more`}/>,&nbsp;
<InlineBtn handler={() => setShowN(amountOfSeries)} text="show all"/>.
</>}
</span>
: <span style={{fontStyle: "italic"}}>Showing all series.&nbsp;
<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;

View file

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

View file

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

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

View file

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

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

View file

@ -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}:&nbsp;</span>
<span style={{fontWeight: "bold"}}>{value}</span>
</Box>)}
</Typography>
</Box>
</Box>
);
};

View file

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

View file

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

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

View file

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

View file

@ -0,0 +1,5 @@
export type AggregatedDataSet = {
key: number;
value: aggregatedDataValue;
};
export type aggregatedDataValue = {[key: string]: number};

View file

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

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

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

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

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

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

View file

@ -0,0 +1 @@
/// <reference types="react-scripts" />

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

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

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

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

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

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

View 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