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

This commit is contained in:
Aliaksandr Valialkin 2023-02-23 19:27:31 -08:00
commit be94882ada
No known key found for this signature in database
GPG key ID: A72BEC6CD3D0DED1
252 changed files with 4517 additions and 1340 deletions

View file

@ -105,6 +105,7 @@ Case studies:
* [Brandwatch](https://docs.victoriametrics.com/CaseStudies.html#brandwatch) * [Brandwatch](https://docs.victoriametrics.com/CaseStudies.html#brandwatch)
* [CERN](https://docs.victoriametrics.com/CaseStudies.html#cern) * [CERN](https://docs.victoriametrics.com/CaseStudies.html#cern)
* [COLOPL](https://docs.victoriametrics.com/CaseStudies.html#colopl) * [COLOPL](https://docs.victoriametrics.com/CaseStudies.html#colopl)
* [Dig Security](https://docs.victoriametrics.com/CaseStudies.html#dig-security)
* [Fly.io](https://docs.victoriametrics.com/CaseStudies.html#flyio) * [Fly.io](https://docs.victoriametrics.com/CaseStudies.html#flyio)
* [German Research Center for Artificial Intelligence](https://docs.victoriametrics.com/CaseStudies.html#german-research-center-for-artificial-intelligence) * [German Research Center for Artificial Intelligence](https://docs.victoriametrics.com/CaseStudies.html#german-research-center-for-artificial-intelligence)
* [Grammarly](https://docs.victoriametrics.com/CaseStudies.html#grammarly) * [Grammarly](https://docs.victoriametrics.com/CaseStudies.html#grammarly)
@ -2227,7 +2228,7 @@ Pass `-help` to VictoriaMetrics in order to see the list of supported command-li
-insert.maxQueueDuration duration -insert.maxQueueDuration duration
The maximum duration to wait in the queue when -maxConcurrentInserts concurrent insert requests are executed (default 1m0s) The maximum duration to wait in the queue when -maxConcurrentInserts concurrent insert requests are executed (default 1m0s)
-internStringMaxLen int -internStringMaxLen int
The maximum length for strings to intern. Lower limit may save memory at the cost of higher CPU usage. See https://en.wikipedia.org/wiki/String_interning (default 300) The maximum length for strings to intern. Lower limit may save memory at the cost of higher CPU usage. See https://en.wikipedia.org/wiki/String_interning (default 500)
-logNewSeries -logNewSeries
Whether to log new series. This option is for debug purposes only. It can lead to performance issues when big number of new series are ingested into VictoriaMetrics Whether to log new series. This option is for debug purposes only. It can lead to performance issues when big number of new series are ingested into VictoriaMetrics
-loggerDisableTimestamps -loggerDisableTimestamps
@ -2263,11 +2264,11 @@ Pass `-help` to VictoriaMetrics in order to see the list of supported command-li
-metricsAuthKey string -metricsAuthKey string
Auth key for /metrics endpoint. It must be passed via authKey query arg. It overrides httpAuth.* settings Auth key for /metrics endpoint. It must be passed via authKey query arg. It overrides httpAuth.* settings
-opentsdbHTTPListenAddr string -opentsdbHTTPListenAddr string
TCP address to listen for OpentTSDB HTTP put requests. Usually :4242 must be set. Doesn't work if empty. See also -opentsdbHTTPListenAddr.useProxyProtocol TCP address to listen for OpenTSDB HTTP put requests. Usually :4242 must be set. Doesn't work if empty. See also -opentsdbHTTPListenAddr.useProxyProtocol
-opentsdbHTTPListenAddr.useProxyProtocol -opentsdbHTTPListenAddr.useProxyProtocol
Whether to use proxy protocol for connections accepted at -opentsdbHTTPListenAddr . See https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt Whether to use proxy protocol for connections accepted at -opentsdbHTTPListenAddr . See https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt
-opentsdbListenAddr string -opentsdbListenAddr string
TCP and UDP address to listen for OpentTSDB metrics. Telnet put messages and HTTP /api/put messages are simultaneously served on TCP port. Usually :4242 must be set. Doesn't work if empty. See also -opentsdbListenAddr.useProxyProtocol TCP and UDP address to listen for OpenTSDB metrics. Telnet put messages and HTTP /api/put messages are simultaneously served on TCP port. Usually :4242 must be set. Doesn't work if empty. See also -opentsdbListenAddr.useProxyProtocol
-opentsdbListenAddr.useProxyProtocol -opentsdbListenAddr.useProxyProtocol
Whether to use proxy protocol for connections accepted at -opentsdbListenAddr . See https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt Whether to use proxy protocol for connections accepted at -opentsdbListenAddr . See https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt
-opentsdbTrimTimestamp duration -opentsdbTrimTimestamp duration
@ -2337,6 +2338,8 @@ Pass `-help` to VictoriaMetrics in order to see the list of supported command-li
How frequently to reload the full state from Kubernetes API server (default 30m0s) How frequently to reload the full state from Kubernetes API server (default 30m0s)
-promscrape.kubernetesSDCheckInterval duration -promscrape.kubernetesSDCheckInterval duration
Interval for checking for changes in Kubernetes API server. This works only if kubernetes_sd_configs is configured in '-promscrape.config' file. See https://docs.victoriametrics.com/sd_configs.html#kubernetes_sd_configs for details (default 30s) Interval for checking for changes in Kubernetes API server. This works only if kubernetes_sd_configs is configured in '-promscrape.config' file. See https://docs.victoriametrics.com/sd_configs.html#kubernetes_sd_configs for details (default 30s)
-promscrape.kumaSDCheckInterval duration
Interval for checking for changes in kuma service discovery. This works only if kuma_sd_configs is configured in '-promscrape.config' file. See https://docs.victoriametrics.com/sd_configs.html#kuma_sd_configs for details (default 30s)
-promscrape.maxDroppedTargets int -promscrape.maxDroppedTargets int
The maximum number of droppedTargets to show at /api/v1/targets page. Increase this value if your setup drops more scrape targets during relabeling and you need investigating labels for all the dropped targets. Note that the increased number of tracked dropped targets may result in increased memory usage (default 1000) The maximum number of droppedTargets to show at /api/v1/targets page. Increase this value if your setup drops more scrape targets during relabeling and you need investigating labels for all the dropped targets. Note that the increased number of tracked dropped targets may result in increased memory usage (default 1000)
-promscrape.maxResponseHeadersSize size -promscrape.maxResponseHeadersSize size
@ -2398,8 +2401,11 @@ Pass `-help` to VictoriaMetrics in order to see the list of supported command-li
The interval between datapoints stored in the database. It is used at Graphite Render API handler for normalizing the interval between datapoints in case it isn't normalized. It can be overridden by sending 'storage_step' query arg to /render API or by sending the desired interval via 'Storage-Step' http header during querying /render API. This flag is available only in VictoriaMetrics enterprise. See https://docs.victoriametrics.com/enterprise.html (default 10s) The interval between datapoints stored in the database. It is used at Graphite Render API handler for normalizing the interval between datapoints in case it isn't normalized. It can be overridden by sending 'storage_step' query arg to /render API or by sending the desired interval via 'Storage-Step' http header during querying /render API. This flag is available only in VictoriaMetrics enterprise. See https://docs.victoriametrics.com/enterprise.html (default 10s)
-search.latencyOffset duration -search.latencyOffset duration
The time when data points become visible in query results after the collection. It can be overridden on per-query basis via latency_offset arg. Too small value can result in incomplete last points for query results (default 30s) The time when data points become visible in query results after the collection. It can be overridden on per-query basis via latency_offset arg. Too small value can result in incomplete last points for query results (default 30s)
-search.logQueryMemoryUsage size
Log queries, which require more memory than specified by this flag. This may help detecting and optimizing heavy queries. Query logging is disabled by default. See also -search.logSlowQueryDuration and -search.maxMemoryPerQuery
Supports the following optional suffixes for size values: KB, MB, GB, TB, KiB, MiB, GiB, TiB (default 0)
-search.logSlowQueryDuration duration -search.logSlowQueryDuration duration
Log queries with execution time exceeding this value. Zero disables slow query logging (default 5s) Log queries with execution time exceeding this value. Zero disables slow query logging. See also -search.logQueryMemoryUsage (default 5s)
-search.maxConcurrentRequests int -search.maxConcurrentRequests int
The maximum number of concurrent search requests. It shouldn't be high, since a single request can saturate all the CPU cores, while many concurrently executed requests may require high amounts of memory. See also -search.maxQueueDuration and -search.maxMemoryPerQuery (default 8) The maximum number of concurrent search requests. It shouldn't be high, since a single request can saturate all the CPU cores, while many concurrently executed requests may require high amounts of memory. See also -search.maxQueueDuration and -search.maxMemoryPerQuery (default 8)
-search.maxExportDuration duration -search.maxExportDuration duration
@ -2413,7 +2419,7 @@ Pass `-help` to VictoriaMetrics in order to see the list of supported command-li
-search.maxLookback duration -search.maxLookback duration
Synonym to -search.lookback-delta from Prometheus. The value is dynamically detected from interval between time series datapoints if not set. It can be overridden on per-query basis via max_lookback arg. See also '-search.maxStalenessInterval' flag, which has the same meaining due to historical reasons Synonym to -search.lookback-delta from Prometheus. The value is dynamically detected from interval between time series datapoints if not set. It can be overridden on per-query basis via max_lookback arg. See also '-search.maxStalenessInterval' flag, which has the same meaining due to historical reasons
-search.maxMemoryPerQuery size -search.maxMemoryPerQuery size
The maximum amounts of memory a single query may consume. Queries requiring more memory are rejected. The total memory limit for concurrently executed queries can be estimated as -search.maxMemoryPerQuery multiplied by -search.maxConcurrentRequests The maximum amounts of memory a single query may consume. Queries requiring more memory are rejected. The total memory limit for concurrently executed queries can be estimated as -search.maxMemoryPerQuery multiplied by -search.maxConcurrentRequests . See also -search.logQueryMemoryUsage
Supports the following optional suffixes for size values: KB, MB, GB, TB, KiB, MiB, GiB, TiB (default 0) Supports the following optional suffixes for size values: KB, MB, GB, TB, KiB, MiB, GiB, TiB (default 0)
-search.maxPointsPerTimeseries int -search.maxPointsPerTimeseries int
The maximum points per a single timeseries returned from /api/v1/query_range. This option doesn't limit the number of scanned raw samples in the database. The main purpose of this option is to limit the number of per-series points returned to graphing UI such as VMUI or Grafana. There is no sense in setting this limit to values bigger than the horizontal resolution of the graph (default 30000) The maximum points per a single timeseries returned from /api/v1/query_range. This option doesn't limit the number of scanned raw samples in the database. The main purpose of this option is to limit the number of per-series points returned to graphing UI such as VMUI or Grafana. There is no sense in setting this limit to values bigger than the horizontal resolution of the graph (default 30000)

View file

@ -103,7 +103,7 @@ func main() {
func requestHandler(w http.ResponseWriter, r *http.Request) bool { func requestHandler(w http.ResponseWriter, r *http.Request) bool {
if r.URL.Path == "/" { if r.URL.Path == "/" {
if r.Method != "GET" { if r.Method != http.MethodGet {
return false return false
} }
w.Header().Add("Content-Type", "text/html; charset=utf-8") w.Header().Add("Content-Type", "text/html; charset=utf-8")

View file

@ -181,30 +181,25 @@ There is also support for multitenant writes. See [these docs](#multitenancy).
## VictoriaMetrics remote write protocol ## VictoriaMetrics remote write protocol
By default `vmagent` uses Prometheus remote_write protocol for sending the data to the configured `-remoteWrite.url`. `vmagent` supports sending data to the configured `-remoteWrite.url` either via Prometheus remote write protocol
This allows sending data to [any Prometheus-compatible remote storage](https://prometheus.io/docs/operating/integrations/#remote-endpoints-and-storage). or via VictoriaMetrics remote write protocol.
The Prometheus remote_write protocol may require big amounts of network bandwidth under high load. VictoriaMetrics remote write protocol provides the following benefits comparing to Prometheus remote write protocol:
This may result in high network egress costs when the configured remote storage is located in remote datacenter or availability zone.
This also may result in the increased disk IO at `vmagent` when it writes to disk the pending data, which must be sent to remote storage.
In this case the `vmagent` can be instructed to use VictoriaMetrics remote write protocol.
This allows reducing egress network bandwidth costs while reducing disk read/write IO at `vmagent` side under high load.
The `-remoteWrite.useVMProto=true` command-line flag instructs `vmagent` to send the data to the corresponding `-remoteWrite.url`
via VictoriaMetrics remote write protocol.
While all the [recently released](https://docs.victoriametrics.com/CHANGELOG.html) VictoriaMetrics components support - Reduced network bandwidth usage by 2x-5x. This allows saving network bandwidth usage costs when `vmagent` and
the VictoriaMetrics remote write protocol, third-party systems and old versions of VictoriaMetrics components may miss the support of this protocol. the configured remote storage systems are located in different datacenters, availability zones or regions.
The `-remoteWrite.useVMProto` command-line flag can be set independently per each configured `-remoteWrite.url`. - Reduced disk read/write IO and disk space usage at `vmagent` when the remote storage is temporarily unavailable.
For example, the following command instructs `vmagent` to send the data to `https://victoriametrics/api/v1/write` via VictoriaMetrics remote write protocol, In this case `vmagent` buffers the incoming data to disk using the VictoriaMetrics remote write format.
while sending the data to `https://prom-compatible-storage/write` via Prometheus remote write protocol: This reduces disk read/write IO and disk space usage by 2x-5x comparing to Prometheus remote write format.
``` `vmagent` automatically uses VictoriaMetrics remote write protocol when it sends data to VictoriaMetrics components such as other `vmagent` instances,
./vmagent -remoteWrite.url=https://victoriametrics/api/v1/write \ [single-node VictoriaMetrics](https://docs.victoriametrics.com/Single-server-VictoriaMetrics.html)
-remoteWrite.useVMProto=true \ or `vminsert` at [cluster version](https://docs.victoriametrics.com/Cluster-VictoriaMetrics.html).
-remoteWrite.url=https://prom-compatible-storage/write \
-remoteWrite.useVMProto=false `vmagent` automatically switches to Prometheus remote write protocol when it sends data to old versions of VictoriaMetrics components
``` or to other Prometheus-compatible remote storage systems. It is possible to force switch to Prometheus remote write protocol
by specifying `-remoteWrite.forcePromProto` command-line flag for the corresponding `-remoteWrite.url`.
## Multitenancy ## Multitenancy
@ -1299,11 +1294,11 @@ See the docs at https://docs.victoriametrics.com/vmagent.html .
-metricsAuthKey string -metricsAuthKey string
Auth key for /metrics endpoint. It must be passed via authKey query arg. It overrides httpAuth.* settings Auth key for /metrics endpoint. It must be passed via authKey query arg. It overrides httpAuth.* settings
-opentsdbHTTPListenAddr string -opentsdbHTTPListenAddr string
TCP address to listen for OpentTSDB HTTP put requests. Usually :4242 must be set. Doesn't work if empty. See also -opentsdbHTTPListenAddr.useProxyProtocol TCP address to listen for OpenTSDB HTTP put requests. Usually :4242 must be set. Doesn't work if empty. See also -opentsdbHTTPListenAddr.useProxyProtocol
-opentsdbHTTPListenAddr.useProxyProtocol -opentsdbHTTPListenAddr.useProxyProtocol
Whether to use proxy protocol for connections accepted at -opentsdbHTTPListenAddr . See https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt Whether to use proxy protocol for connections accepted at -opentsdbHTTPListenAddr . See https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt
-opentsdbListenAddr string -opentsdbListenAddr string
TCP and UDP address to listen for OpentTSDB metrics. Telnet put messages and HTTP /api/put messages are simultaneously served on TCP port. Usually :4242 must be set. Doesn't work if empty. See also -opentsdbListenAddr.useProxyProtocol TCP and UDP address to listen for OpenTSDB metrics. Telnet put messages and HTTP /api/put messages are simultaneously served on TCP port. Usually :4242 must be set. Doesn't work if empty. See also -opentsdbListenAddr.useProxyProtocol
-opentsdbListenAddr.useProxyProtocol -opentsdbListenAddr.useProxyProtocol
Whether to use proxy protocol for connections accepted at -opentsdbListenAddr . See https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt Whether to use proxy protocol for connections accepted at -opentsdbListenAddr . See https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt
-opentsdbTrimTimestamp duration -opentsdbTrimTimestamp duration
@ -1371,6 +1366,8 @@ See the docs at https://docs.victoriametrics.com/vmagent.html .
How frequently to reload the full state from Kubernetes API server (default 30m0s) How frequently to reload the full state from Kubernetes API server (default 30m0s)
-promscrape.kubernetesSDCheckInterval duration -promscrape.kubernetesSDCheckInterval duration
Interval for checking for changes in Kubernetes API server. This works only if kubernetes_sd_configs is configured in '-promscrape.config' file. See https://docs.victoriametrics.com/sd_configs.html#kubernetes_sd_configs for details (default 30s) Interval for checking for changes in Kubernetes API server. This works only if kubernetes_sd_configs is configured in '-promscrape.config' file. See https://docs.victoriametrics.com/sd_configs.html#kubernetes_sd_configs for details (default 30s)
-promscrape.kumaSDCheckInterval duration
Interval for checking for changes in kuma service discovery. This works only if kuma_sd_configs is configured in '-promscrape.config' file. See https://docs.victoriametrics.com/sd_configs.html#kuma_sd_configs for details (default 30s)
-promscrape.maxDroppedTargets int -promscrape.maxDroppedTargets int
The maximum number of droppedTargets to show at /api/v1/targets page. Increase this value if your setup drops more scrape targets during relabeling and you need investigating labels for all the dropped targets. Note that the increased number of tracked dropped targets may result in increased memory usage (default 1000) The maximum number of droppedTargets to show at /api/v1/targets page. Increase this value if your setup drops more scrape targets during relabeling and you need investigating labels for all the dropped targets. Note that the increased number of tracked dropped targets may result in increased memory usage (default 1000)
-promscrape.maxResponseHeadersSize size -promscrape.maxResponseHeadersSize size
@ -1451,6 +1448,9 @@ See the docs at https://docs.victoriametrics.com/vmagent.html .
Supports an array of values separated by comma or specified via multiple flags. Supports an array of values separated by comma or specified via multiple flags.
-remoteWrite.flushInterval duration -remoteWrite.flushInterval duration
Interval for flushing the data to remote storage. This option takes effect only when less than 10K data points per second are pushed to -remoteWrite.url (default 1s) Interval for flushing the data to remote storage. This option takes effect only when less than 10K data points per second are pushed to -remoteWrite.url (default 1s)
-remoteWrite.forcePromProto array
Whether to force Prometheus remote write protocol for sending data to the corresponding -remoteWrite.url . See https://docs.victoriametrics.com/vmagent.html#victoriametrics-remote-write-protocol
Supports array of values separated by comma or specified via multiple flags.
-remoteWrite.headers array -remoteWrite.headers array
Optional HTTP headers to send with each request to the corresponding -remoteWrite.url. For example, -remoteWrite.headers='My-Auth:foobar' would send 'My-Auth: foobar' HTTP header with every request to the corresponding -remoteWrite.url. Multiple headers must be delimited by '^^': -remoteWrite.headers='header1:value1^^header2:value2' Optional HTTP headers to send with each request to the corresponding -remoteWrite.url. For example, -remoteWrite.headers='My-Auth:foobar' would send 'My-Auth: foobar' HTTP header with every request to the corresponding -remoteWrite.url. Multiple headers must be delimited by '^^': -remoteWrite.headers='header1:value1^^header2:value2'
Supports an array of values separated by comma or specified via multiple flags. Supports an array of values separated by comma or specified via multiple flags.
@ -1536,14 +1536,11 @@ See the docs at https://docs.victoriametrics.com/vmagent.html .
-remoteWrite.tmpDataPath string -remoteWrite.tmpDataPath string
Path to directory where temporary data for remote write component is stored. See also -remoteWrite.maxDiskUsagePerURL (default "vmagent-remotewrite-data") Path to directory where temporary data for remote write component is stored. See also -remoteWrite.maxDiskUsagePerURL (default "vmagent-remotewrite-data")
-remoteWrite.url array -remoteWrite.url array
Remote storage URL to write data to. It must support Prometheus remote_write protocol. Example url: http://<victoriametrics-host>:8428/api/v1/write . It is recommended setting -remoteWrite.useVMProto command-line option when VictoriaMetrics is used as a remote storage in order to save network bandwidth. See https://docs.victoriametrics.com/vmagent.html#victoriametrics-remote-write-protocol . Pass multiple -remoteWrite.url options in order to replicate the collected data to multiple remote storage systems. See also -remoteWrite.multitenantURL Remote storage URL to write data to. It must support either VictoriaMetrics remote write protocol or Prometheus remote_write protocol. Example url: http://<victoriametrics-host>:8428/api/v1/write . Pass multiple -remoteWrite.url options in order to replicate the collected data to multiple remote storage systems. See also -remoteWrite.multitenantURL
Supports an array of values separated by comma or specified via multiple flags. Supports an array of values separated by comma or specified via multiple flags.
-remoteWrite.urlRelabelConfig array -remoteWrite.urlRelabelConfig array
Optional path to relabel configs for the corresponding -remoteWrite.url. See also -remoteWrite.relabelConfig. The path can point either to local file or to http url. See https://docs.victoriametrics.com/vmagent.html#relabeling Optional path to relabel configs for the corresponding -remoteWrite.url. See also -remoteWrite.relabelConfig. The path can point either to local file or to http url. See https://docs.victoriametrics.com/vmagent.html#relabeling
Supports an array of values separated by comma or specified via multiple flags. Supports an array of values separated by comma or specified via multiple flags.
-remoteWrite.useVMProto array
Whether to use VictoriaMetrics protocol for sending the data to the given -remoteWrite.url in order to reduce network bandwidth usage and disk read/write IO under high load. See https://docs.victoriametrics.com/vmagent.html#victoriametrics-remote-write-protocol
Supports array of values separated by comma or specified via multiple flags.
-sortLabels -sortLabels
Whether to sort labels for incoming samples before writing them to all the configured remote storage systems. This may be needed for reducing memory usage at remote storage when the order of labels in incoming samples is random. For example, if m{k1="v1",k2="v2"} may be sent as m{k2="v2",k1="v1"}Enabled sorting for labels can slow down ingestion performance a bit Whether to sort labels for incoming samples before writing them to all the configured remote storage systems. This may be needed for reducing memory usage at remote storage when the order of labels in incoming samples is random. For example, if m{k1="v1",k2="v2"} may be sent as m{k2="v2",k1="v1"}Enabled sorting for labels can slow down ingestion performance a bit
-tls -tls

View file

@ -56,12 +56,12 @@ var (
"See also -graphiteListenAddr.useProxyProtocol") "See also -graphiteListenAddr.useProxyProtocol")
graphiteUseProxyProtocol = flag.Bool("graphiteListenAddr.useProxyProtocol", false, "Whether to use proxy protocol for connections accepted at -graphiteListenAddr . "+ graphiteUseProxyProtocol = flag.Bool("graphiteListenAddr.useProxyProtocol", false, "Whether to use proxy protocol for connections accepted at -graphiteListenAddr . "+
"See https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt") "See https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt")
opentsdbListenAddr = flag.String("opentsdbListenAddr", "", "TCP and UDP address to listen for OpentTSDB metrics. "+ opentsdbListenAddr = flag.String("opentsdbListenAddr", "", "TCP and UDP address to listen for OpenTSDB metrics. "+
"Telnet put messages and HTTP /api/put messages are simultaneously served on TCP port. "+ "Telnet put messages and HTTP /api/put messages are simultaneously served on TCP port. "+
"Usually :4242 must be set. Doesn't work if empty. See also -opentsdbListenAddr.useProxyProtocol") "Usually :4242 must be set. Doesn't work if empty. See also -opentsdbListenAddr.useProxyProtocol")
opentsdbUseProxyProtocol = flag.Bool("opentsdbListenAddr.useProxyProtocol", false, "Whether to use proxy protocol for connections accepted at -opentsdbListenAddr . "+ opentsdbUseProxyProtocol = flag.Bool("opentsdbListenAddr.useProxyProtocol", false, "Whether to use proxy protocol for connections accepted at -opentsdbListenAddr . "+
"See https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt") "See https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt")
opentsdbHTTPListenAddr = flag.String("opentsdbHTTPListenAddr", "", "TCP address to listen for OpentTSDB HTTP put requests. Usually :4242 must be set. Doesn't work if empty. "+ opentsdbHTTPListenAddr = flag.String("opentsdbHTTPListenAddr", "", "TCP address to listen for OpenTSDB HTTP put requests. Usually :4242 must be set. Doesn't work if empty. "+
"See also -opentsdbHTTPListenAddr.useProxyProtocol") "See also -opentsdbHTTPListenAddr.useProxyProtocol")
opentsdbHTTPUseProxyProtocol = flag.Bool("opentsdbHTTPListenAddr.useProxyProtocol", false, "Whether to use proxy protocol for connections accepted "+ opentsdbHTTPUseProxyProtocol = flag.Bool("opentsdbHTTPListenAddr.useProxyProtocol", false, "Whether to use proxy protocol for connections accepted "+
"at -opentsdbHTTPListenAddr . See https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt") "at -opentsdbHTTPListenAddr . See https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt")
@ -208,7 +208,7 @@ func getAuthTokenFromPath(path string) (*auth.Token, error) {
func requestHandler(w http.ResponseWriter, r *http.Request) bool { func requestHandler(w http.ResponseWriter, r *http.Request) bool {
if r.URL.Path == "/" { if r.URL.Path == "/" {
if r.Method != "GET" { if r.Method != http.MethodGet {
return false return false
} }
w.Header().Add("Content-Type", "text/html; charset=utf-8") w.Header().Add("Content-Type", "text/html; charset=utf-8")
@ -253,6 +253,9 @@ func requestHandler(w http.ResponseWriter, r *http.Request) bool {
} }
switch path { switch path {
case "/prometheus/api/v1/write", "/api/v1/write": case "/prometheus/api/v1/write", "/api/v1/write":
if common.HandleVMProtoServerHandshake(w, r) {
return true
}
prometheusWriteRequests.Inc() prometheusWriteRequests.Inc()
if err := promremotewrite.InsertHandler(nil, r); err != nil { if err := promremotewrite.InsertHandler(nil, r); err != nil {
prometheusWriteErrors.Inc() prometheusWriteErrors.Inc()

View file

@ -23,7 +23,7 @@ var (
func TestInsertHandler(t *testing.T) { func TestInsertHandler(t *testing.T) {
setUp() setUp()
defer tearDown() defer tearDown()
req := httptest.NewRequest("POST", "/insert/0/api/v1/import/prometheus", bytes.NewBufferString(`{"foo":"bar"} req := httptest.NewRequest(http.MethodPost, "/insert/0/api/v1/import/prometheus", bytes.NewBufferString(`{"foo":"bar"}
go_memstats_alloc_bytes_total 1`)) go_memstats_alloc_bytes_total 1`))
if err := InsertHandler(nil, req); err != nil { if err := InsertHandler(nil, req); err != nil {
t.Errorf("unxepected error %s", err) t.Errorf("unxepected error %s", err)

View file

@ -123,6 +123,10 @@ func newHTTPClient(argIdx int, remoteWriteURL, sanitizedURL string, fq *persiste
} }
tr.Proxy = http.ProxyURL(pu) tr.Proxy = http.ProxyURL(pu)
} }
hc := &http.Client{
Transport: tr,
Timeout: sendTimeout.GetOptionalArgOrDefault(argIdx, time.Minute),
}
c := &client{ c := &client{
sanitizedURL: sanitizedURL, sanitizedURL: sanitizedURL,
remoteWriteURL: remoteWriteURL, remoteWriteURL: remoteWriteURL,
@ -130,11 +134,8 @@ func newHTTPClient(argIdx int, remoteWriteURL, sanitizedURL string, fq *persiste
authCfg: authCfg, authCfg: authCfg,
awsCfg: awsCfg, awsCfg: awsCfg,
fq: fq, fq: fq,
hc: &http.Client{ hc: hc,
Transport: tr, stopCh: make(chan struct{}),
Timeout: sendTimeout.GetOptionalArgOrDefault(argIdx, time.Minute),
},
stopCh: make(chan struct{}),
} }
c.sendBlock = c.sendBlockHTTP c.sendBlock = c.sendBlockHTTP
return c return c
@ -309,7 +310,7 @@ func (c *client) sendBlockHTTP(block []byte) bool {
} }
again: again:
req, err := http.NewRequest("POST", c.remoteWriteURL, bytes.NewBuffer(block)) req, err := http.NewRequest(http.MethodPost, c.remoteWriteURL, bytes.NewBuffer(block))
if err != nil { if err != nil {
logger.Panicf("BUG: unexpected error from http.NewRequest(%q): %s", c.sanitizedURL, err) logger.Panicf("BUG: unexpected error from http.NewRequest(%q): %s", c.sanitizedURL, err)
} }

View file

@ -2,6 +2,7 @@ package remotewrite
import ( import (
"fmt" "fmt"
"math"
"testing" "testing"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/prompbmarshal" "github.com/VictoriaMetrics/VictoriaMetrics/lib/prompbmarshal"
@ -21,7 +22,7 @@ func TestPushWriteRequest(t *testing.T) {
} }
func testPushWriteRequest(t *testing.T, rowsCount, expectedBlockLenProm, expectedBlockLenVM int) { func testPushWriteRequest(t *testing.T, rowsCount, expectedBlockLenProm, expectedBlockLenVM int) {
f := func(isVMRemoteWrite bool, expectedBlockLen int) { f := func(isVMRemoteWrite bool, expectedBlockLen int, tolerancePrc float64) {
t.Helper() t.Helper()
wr := newTestWriteRequest(rowsCount, 20) wr := newTestWriteRequest(rowsCount, 20)
pushBlockLen := 0 pushBlockLen := 0
@ -32,17 +33,17 @@ func testPushWriteRequest(t *testing.T, rowsCount, expectedBlockLenProm, expecte
pushBlockLen = len(block) pushBlockLen = len(block)
} }
pushWriteRequest(wr, pushBlock, isVMRemoteWrite) pushWriteRequest(wr, pushBlock, isVMRemoteWrite)
if pushBlockLen != expectedBlockLen { if math.Abs(float64(pushBlockLen-expectedBlockLen)/float64(expectedBlockLen)*100) > tolerancePrc {
t.Fatalf("unexpected block len for rowsCount=%d, isVMRemoteWrite=%v; got %d bytes; expecting %d bytes", t.Fatalf("unexpected block len for rowsCount=%d, isVMRemoteWrite=%v; got %d bytes; expecting %d bytes +- %.0f%%",
rowsCount, isVMRemoteWrite, pushBlockLen, expectedBlockLen) rowsCount, isVMRemoteWrite, pushBlockLen, expectedBlockLen, tolerancePrc)
} }
} }
// Check Prometheus remote write // Check Prometheus remote write
f(false, expectedBlockLenProm) f(false, expectedBlockLenProm, 0)
// Check VictoriaMetrics remote write // Check VictoriaMetrics remote write
f(true, expectedBlockLenVM) f(true, expectedBlockLenVM, 15)
} }
func newTestWriteRequest(seriesCount, labelsCount int) *prompbmarshal.WriteRequest { func newTestWriteRequest(seriesCount, labelsCount int) *prompbmarshal.WriteRequest {

View file

@ -21,6 +21,7 @@ import (
"github.com/VictoriaMetrics/VictoriaMetrics/lib/procutil" "github.com/VictoriaMetrics/VictoriaMetrics/lib/procutil"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/prompbmarshal" "github.com/VictoriaMetrics/VictoriaMetrics/lib/prompbmarshal"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/promrelabel" "github.com/VictoriaMetrics/VictoriaMetrics/lib/promrelabel"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/protoparser/common"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/streamaggr" "github.com/VictoriaMetrics/VictoriaMetrics/lib/streamaggr"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/tenantmetrics" "github.com/VictoriaMetrics/VictoriaMetrics/lib/tenantmetrics"
"github.com/VictoriaMetrics/metrics" "github.com/VictoriaMetrics/metrics"
@ -28,17 +29,14 @@ import (
) )
var ( var (
remoteWriteURLs = flagutil.NewArrayString("remoteWrite.url", "Remote storage URL to write data to. It must support Prometheus remote_write protocol. "+ remoteWriteURLs = flagutil.NewArrayString("remoteWrite.url", "Remote storage URL to write data to. It must support either VictoriaMetrics remote write protocol "+
"Example url: http://<victoriametrics-host>:8428/api/v1/write . "+ "or Prometheus remote_write protocol. Example url: http://<victoriametrics-host>:8428/api/v1/write . "+
"It is recommended setting -remoteWrite.useVMProto command-line option when VictoriaMetrics is used as a remote storage in order to save network bandwidth. "+
"See https://docs.victoriametrics.com/vmagent.html#victoriametrics-remote-write-protocol . "+
"Pass multiple -remoteWrite.url options in order to replicate the collected data to multiple remote storage systems. See also -remoteWrite.multitenantURL") "Pass multiple -remoteWrite.url options in order to replicate the collected data to multiple remote storage systems. See also -remoteWrite.multitenantURL")
remoteWriteMultitenantURLs = flagutil.NewArrayString("remoteWrite.multitenantURL", "Base path for multitenant remote storage URL to write data to. "+ remoteWriteMultitenantURLs = flagutil.NewArrayString("remoteWrite.multitenantURL", "Base path for multitenant remote storage URL to write data to. "+
"See https://docs.victoriametrics.com/vmagent.html#multitenancy for details. Example url: http://<vminsert>:8480 . "+ "See https://docs.victoriametrics.com/vmagent.html#multitenancy for details. Example url: http://<vminsert>:8480 . "+
"Pass multiple -remoteWrite.multitenantURL flags in order to replicate data to multiple remote storage systems. See also -remoteWrite.url") "Pass multiple -remoteWrite.multitenantURL flags in order to replicate data to multiple remote storage systems. See also -remoteWrite.url")
useVMProto = flagutil.NewArrayBool("remoteWrite.useVMProto", "Whether to use VictoriaMetrics protocol for sending the data to the given -remoteWrite.url "+ forcePromProto = flagutil.NewArrayBool("remoteWrite.forcePromProto", "Whether to force Prometheus remote write protocol for sending data "+
"in order to reduce network bandwidth usage and disk read/write IO under high load. "+ "to the corresponding -remoteWrite.url . See https://docs.victoriametrics.com/vmagent.html#victoriametrics-remote-write-protocol")
"See https://docs.victoriametrics.com/vmagent.html#victoriametrics-remote-write-protocol")
tmpDataPath = flag.String("remoteWrite.tmpDataPath", "vmagent-remotewrite-data", "Path to directory where temporary data for remote write component is stored. "+ tmpDataPath = flag.String("remoteWrite.tmpDataPath", "vmagent-remotewrite-data", "Path to directory where temporary data for remote write component is stored. "+
"See also -remoteWrite.maxDiskUsagePerURL") "See also -remoteWrite.maxDiskUsagePerURL")
queues = flag.Int("remoteWrite.queues", cgroup.AvailableCPUs()*2, "The number of concurrent queues to each -remoteWrite.url. Set more queues if default number of queues "+ queues = flag.Int("remoteWrite.queues", cgroup.AvailableCPUs()*2, "The number of concurrent queues to each -remoteWrite.url. Set more queues if default number of queues "+
@ -480,7 +478,18 @@ func newRemoteWriteCtx(argIdx int, at *auth.Token, remoteWriteURL *url.URL, maxI
_ = metrics.GetOrCreateGauge(fmt.Sprintf(`vmagent_remotewrite_pending_inmemory_blocks{path=%q, url=%q}`, queuePath, sanitizedURL), func() float64 { _ = metrics.GetOrCreateGauge(fmt.Sprintf(`vmagent_remotewrite_pending_inmemory_blocks{path=%q, url=%q}`, queuePath, sanitizedURL), func() float64 {
return float64(fq.GetInmemoryQueueLen()) return float64(fq.GetInmemoryQueueLen())
}) })
isVMRemoteWrite := useVMProto.GetOptionalArg(argIdx)
// Auto-detect whether the remote storage supports VictoriaMetrics remote write protocol.
isVMRemoteWrite := false
usePromProto := forcePromProto.GetOptionalArg(argIdx)
if !usePromProto {
isVMRemoteWrite = common.HandleVMProtoClientHandshake(remoteWriteURL)
if !isVMRemoteWrite {
logger.Infof("the remote storage at %q doesn't support VictoriaMetrics remote write protocol. Switching to Prometheus remote write protocol. "+
"See https://docs.victoriametrics.com/vmagent.html#victoriametrics-remote-write-protocol", sanitizedURL)
}
}
var c *client var c *client
switch remoteWriteURL.Scheme { switch remoteWriteURL.Scheme {
case "http", "https": case "http", "https":

View file

@ -621,6 +621,8 @@ can read the same rules configuration as normal, evaluate them on the given time
results via remote write to the configured storage. vmalert supports any PromQL/MetricsQL compatible results via remote write to the configured storage. vmalert supports any PromQL/MetricsQL compatible
data source for backfilling. data source for backfilling.
See a blogpost about [Rules backfilling via vmalert](https://victoriametrics.com/blog/rules-replay/).
### How it works ### How it works
In `replay` mode vmalert works as a cli-tool and exits immediately after work is done. In `replay` mode vmalert works as a cli-tool and exits immediately after work is done.

View file

@ -174,7 +174,7 @@ func (s *VMStorage) do(ctx context.Context, req *http.Request) (*http.Response,
} }
func (s *VMStorage) newRequestPOST() (*http.Request, error) { func (s *VMStorage) newRequestPOST() (*http.Request, error) {
req, err := http.NewRequest("POST", s.datasourceURL, nil) req, err := http.NewRequest(http.MethodPost, s.datasourceURL, nil)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View file

@ -64,7 +64,7 @@ func (am *AlertManager) send(ctx context.Context, alerts []Alert) error {
b := &bytes.Buffer{} b := &bytes.Buffer{}
writeamRequest(b, alerts, am.argFunc, am.relabelConfigs) writeamRequest(b, alerts, am.argFunc, am.relabelConfigs)
req, err := http.NewRequest("POST", am.addr, b) req, err := http.NewRequest(http.MethodPost, am.addr, b)
if err != nil { if err != nil {
return err return err
} }

View file

@ -225,7 +225,7 @@ func (c *Client) flush(ctx context.Context, wr *prompbmarshal.WriteRequest) {
func (c *Client) send(ctx context.Context, data []byte) error { func (c *Client) send(ctx context.Context, data []byte) error {
r := bytes.NewReader(data) r := bytes.NewReader(data)
req, err := http.NewRequest("POST", c.addr, r) req, err := http.NewRequest(http.MethodPost, c.addr, r)
if err != nil { if err != nil {
return fmt.Errorf("failed to create new HTTP request: %w", err) return fmt.Errorf("failed to create new HTTP request: %w", err)
} }

View file

@ -57,7 +57,7 @@ func (rh *requestHandler) handler(w http.ResponseWriter, r *http.Request) bool {
switch r.URL.Path { switch r.URL.Path {
case "/", "/vmalert", "/vmalert/": case "/", "/vmalert", "/vmalert/":
if r.Method != "GET" { if r.Method != http.MethodGet {
httpserver.Errorf(w, r, "path %q supports only GET method", r.URL.Path) httpserver.Errorf(w, r, "path %q supports only GET method", r.URL.Path)
return false return false
} }

View file

@ -169,7 +169,7 @@ func tryProcessingRequest(w http.ResponseWriter, r *http.Request, targetURL *url
if err != nil { if err != nil {
remoteAddr := httpserver.GetQuotedRemoteAddr(r) remoteAddr := httpserver.GetQuotedRemoteAddr(r)
requestURI := httpserver.GetRequestURI(r) requestURI := httpserver.GetRequestURI(r)
if r.Method == "POST" || r.Method == "PUT" { if r.Method == http.MethodPost || r.Method == http.MethodPut {
// It is impossible to retry POST and PUT requests, // It is impossible to retry POST and PUT requests,
// since we already proxied the request body to the backend. // since we already proxied the request body to the backend.
err = &httpserver.ErrorWithStatusCode{ err = &httpserver.ErrorWithStatusCode{

View file

@ -0,0 +1,60 @@
package backoff
import (
"context"
"errors"
"fmt"
"math"
"time"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/logger"
)
const (
backoffRetries = 5
backoffFactor = 1.7
backoffMinDuration = time.Second
)
// retryableFunc describes call back which will repeat on errors
type retryableFunc func() error
var ErrBadRequest = errors.New("bad request")
// Backoff describes object with backoff policy params
type Backoff struct {
retries int
factor float64
minDuration time.Duration
}
// New initialize backoff object
func New() *Backoff {
return &Backoff{
retries: backoffRetries,
factor: backoffFactor,
minDuration: backoffMinDuration,
}
}
// Retry process retries until all attempts are completed
func (b *Backoff) Retry(ctx context.Context, cb retryableFunc) (uint64, error) {
var attempt uint64
for i := 0; i < b.retries; i++ {
// @TODO we should use context to cancel retries
err := cb()
if err == nil {
return attempt, nil
}
if errors.Is(err, ErrBadRequest) {
logger.Errorf("unrecoverable error: %s", err)
return attempt, err // fail fast if not recoverable
}
attempt++
backoff := float64(b.minDuration) * math.Pow(b.factor, float64(i))
dur := time.Duration(backoff)
logger.Errorf("got error: %s on attempt: %d; will retry in %v", err, attempt, dur)
time.Sleep(time.Duration(backoff))
}
return attempt, fmt.Errorf("execution failed after %d retry attempts", b.retries)
}

View file

@ -0,0 +1,96 @@
package backoff
import (
"context"
"fmt"
"testing"
"time"
)
func TestRetry_Do(t *testing.T) {
counter := 0
tests := []struct {
name string
backoffRetries int
backoffFactor float64
backoffMinDuration time.Duration
retryableFunc retryableFunc
ctx context.Context
withCancel bool
want uint64
wantErr bool
}{
{
name: "return bad request",
retryableFunc: func() error {
return ErrBadRequest
},
ctx: context.Background(),
want: 0,
wantErr: true,
},
{
name: "empty retries values",
retryableFunc: func() error {
time.Sleep(time.Millisecond * 100)
return nil
},
ctx: context.Background(),
want: 0,
wantErr: false,
},
{
name: "only one retry test",
backoffRetries: 5,
backoffFactor: 1.7,
backoffMinDuration: time.Millisecond * 10,
retryableFunc: func() error {
t := time.NewTicker(time.Millisecond * 5)
defer t.Stop()
for range t.C {
counter++
if counter%2 == 0 {
return fmt.Errorf("got some error")
}
if counter%3 == 0 {
return nil
}
}
return nil
},
ctx: context.Background(),
want: 1,
wantErr: false,
},
{
name: "all retries failed test",
backoffRetries: 5,
backoffFactor: 0.1,
backoffMinDuration: time.Millisecond * 10,
retryableFunc: func() error {
t := time.NewTicker(time.Millisecond * 5)
defer t.Stop()
for range t.C {
return fmt.Errorf("got some error")
}
return nil
},
ctx: context.Background(),
want: 5,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := New()
got, err := r.Retry(tt.ctx, tt.retryableFunc)
if (err != nil) != tt.wantErr {
t.Errorf("Retry() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("Retry() got = %v, want %v", got, tt.want)
}
})
}
}

View file

@ -65,7 +65,7 @@ func main() {
// disable progress bars since openTSDB implementation // disable progress bars since openTSDB implementation
// does not use progress bar pool // does not use progress bar pool
vmCfg.DisableProgressBar = true vmCfg.DisableProgressBar = true
importer, err := vm.NewImporter(vmCfg) importer, err := vm.NewImporter(ctx, vmCfg)
if err != nil { if err != nil {
return fmt.Errorf("failed to create VM importer: %s", err) return fmt.Errorf("failed to create VM importer: %s", err)
} }
@ -100,7 +100,7 @@ func main() {
} }
vmCfg := initConfigVM(c) vmCfg := initConfigVM(c)
importer, err = vm.NewImporter(vmCfg) importer, err = vm.NewImporter(ctx, vmCfg)
if err != nil { if err != nil {
return fmt.Errorf("failed to create VM importer: %s", err) return fmt.Errorf("failed to create VM importer: %s", err)
} }
@ -137,7 +137,7 @@ func main() {
vmCfg := initConfigVM(c) vmCfg := initConfigVM(c)
importer, err := vm.NewImporter(vmCfg) importer, err := vm.NewImporter(ctx, vmCfg)
if err != nil { if err != nil {
return fmt.Errorf("failed to create VM importer: %s", err) return fmt.Errorf("failed to create VM importer: %s", err)
} }
@ -163,7 +163,7 @@ func main() {
fmt.Println("Prometheus import mode") fmt.Println("Prometheus import mode")
vmCfg := initConfigVM(c) vmCfg := initConfigVM(c)
importer, err = vm.NewImporter(vmCfg) importer, err = vm.NewImporter(ctx, vmCfg)
if err != nil { if err != nil {
return fmt.Errorf("failed to create VM importer: %s", err) return fmt.Errorf("failed to create VM importer: %s", err)
} }

View file

@ -1,6 +1,7 @@
package main package main
import ( import (
"context"
"os" "os"
"testing" "testing"
"time" "time"
@ -58,7 +59,7 @@ func Test_prometheusProcessor_run(t *testing.T) {
return client return client
}, },
im: func(vmCfg vm.Config) *vm.Importer { im: func(vmCfg vm.Config) *vm.Importer {
importer, err := vm.NewImporter(vmCfg) importer, err := vm.NewImporter(context.Background(), vmCfg)
if err != nil { if err != nil {
t.Fatalf("error init importer: %s", err) t.Fatalf("error init importer: %s", err)
} }
@ -95,7 +96,7 @@ func Test_prometheusProcessor_run(t *testing.T) {
return client return client
}, },
im: func(vmCfg vm.Config) *vm.Importer { im: func(vmCfg vm.Config) *vm.Importer {
importer, err := vm.NewImporter(vmCfg) importer, err := vm.NewImporter(context.Background(), vmCfg)
if err != nil { if err != nil {
t.Fatalf("error init importer: %s", err) t.Fatalf("error init importer: %s", err)
} }

View file

@ -110,6 +110,7 @@ func TestRemoteRead(t *testing.T) {
for _, tt := range testCases { for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
remoteReadServer := remote_read_integration.NewRemoteReadServer(t) remoteReadServer := remote_read_integration.NewRemoteReadServer(t)
defer remoteReadServer.Close() defer remoteReadServer.Close()
remoteWriteServer := remote_read_integration.NewRemoteWriteServer(t) remoteWriteServer := remote_read_integration.NewRemoteWriteServer(t)
@ -139,7 +140,7 @@ func TestRemoteRead(t *testing.T) {
tt.vmCfg.Addr = remoteWriteServer.URL() tt.vmCfg.Addr = remoteWriteServer.URL()
importer, err := vm.NewImporter(tt.vmCfg) importer, err := vm.NewImporter(ctx, tt.vmCfg)
if err != nil { if err != nil {
t.Fatalf("failed to create VM importer: %s", err) t.Fatalf("failed to create VM importer: %s", err)
} }
@ -156,7 +157,6 @@ func TestRemoteRead(t *testing.T) {
cc: 1, cc: 1,
} }
ctx := context.Background()
err = rmp.run(ctx, true, false) err = rmp.run(ctx, true, false)
if err != nil { if err != nil {
t.Fatalf("failed to run remote read processor: %s", err) t.Fatalf("failed to run remote read processor: %s", err)
@ -263,6 +263,7 @@ func TestSteamRemoteRead(t *testing.T) {
for _, tt := range testCases { for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
remoteReadServer := remote_read_integration.NewRemoteReadStreamServer(t) remoteReadServer := remote_read_integration.NewRemoteReadStreamServer(t)
defer remoteReadServer.Close() defer remoteReadServer.Close()
remoteWriteServer := remote_read_integration.NewRemoteWriteServer(t) remoteWriteServer := remote_read_integration.NewRemoteWriteServer(t)
@ -292,7 +293,7 @@ func TestSteamRemoteRead(t *testing.T) {
tt.vmCfg.Addr = remoteWriteServer.URL() tt.vmCfg.Addr = remoteWriteServer.URL()
importer, err := vm.NewImporter(tt.vmCfg) importer, err := vm.NewImporter(ctx, tt.vmCfg)
if err != nil { if err != nil {
t.Fatalf("failed to create VM importer: %s", err) t.Fatalf("failed to create VM importer: %s", err)
} }
@ -309,7 +310,6 @@ func TestSteamRemoteRead(t *testing.T) {
cc: 1, cc: 1,
} }
ctx := context.Background()
err = rmp.run(ctx, true, false) err = rmp.run(ctx, true, false)
if err != nil { if err != nil {
t.Fatalf("failed to run remote read processor: %s", err) t.Fatalf("failed to run remote read processor: %s", err)

View file

@ -157,7 +157,7 @@ func (c *Client) do(req *http.Request) (*http.Response, error) {
// Ping checks the health of the read source // Ping checks the health of the read source
func (c *Client) Ping() error { func (c *Client) Ping() error {
url := c.addr + healthPath url := c.addr + healthPath
req, err := http.NewRequest("GET", url, nil) req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil { if err != nil {
return fmt.Errorf("cannot create request to %q: %s", url, err) return fmt.Errorf("cannot create request to %q: %s", url, err)
} }
@ -174,7 +174,7 @@ func (c *Client) Ping() error {
func (c *Client) fetch(ctx context.Context, data []byte, streamCb StreamCallback) error { func (c *Client) fetch(ctx context.Context, data []byte, streamCb StreamCallback) error {
r := bytes.NewReader(data) r := bytes.NewReader(data)
url := c.addr + remoteReadPath url := c.addr + remoteReadPath
req, err := http.NewRequest("POST", url, r) req, err := http.NewRequest(http.MethodPost, url, r)
if err != nil { if err != nil {
return fmt.Errorf("failed to create new HTTP request: %w", err) return fmt.Errorf("failed to create new HTTP request: %w", err)
} }

View file

@ -3,15 +3,16 @@ package vm
import ( import (
"bufio" "bufio"
"compress/gzip" "compress/gzip"
"context"
"errors" "errors"
"fmt" "fmt"
"io" "io"
"math"
"net/http" "net/http"
"strings" "strings"
"sync" "sync"
"time" "time"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vmctl/backoff"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vmctl/barpool" "github.com/VictoriaMetrics/VictoriaMetrics/app/vmctl/barpool"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vmctl/limiter" "github.com/VictoriaMetrics/VictoriaMetrics/app/vmctl/limiter"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/decimal" "github.com/VictoriaMetrics/VictoriaMetrics/lib/decimal"
@ -75,7 +76,8 @@ type Importer struct {
wg sync.WaitGroup wg sync.WaitGroup
once sync.Once once sync.Once
s *stats s *stats
backoff *backoff.Backoff
} }
// ResetStats resets im stats. // ResetStats resets im stats.
@ -107,7 +109,7 @@ func AddExtraLabelsToImportPath(path string, extraLabels []string) (string, erro
} }
// NewImporter creates new Importer for the given cfg. // NewImporter creates new Importer for the given cfg.
func NewImporter(cfg Config) (*Importer, error) { func NewImporter(ctx context.Context, cfg Config) (*Importer, error) {
if cfg.Concurrency < 1 { if cfg.Concurrency < 1 {
return nil, fmt.Errorf("concurrency can't be lower than 1") return nil, fmt.Errorf("concurrency can't be lower than 1")
} }
@ -136,6 +138,7 @@ func NewImporter(cfg Config) (*Importer, error) {
close: make(chan struct{}), close: make(chan struct{}),
input: make(chan *TimeSeries, cfg.Concurrency*4), input: make(chan *TimeSeries, cfg.Concurrency*4),
errors: make(chan *ImportError, cfg.Concurrency), errors: make(chan *ImportError, cfg.Concurrency),
backoff: backoff.New(),
} }
if err := im.Ping(); err != nil { if err := im.Ping(); err != nil {
return nil, fmt.Errorf("ping to %q failed: %s", addr, err) return nil, fmt.Errorf("ping to %q failed: %s", addr, err)
@ -154,7 +157,7 @@ func NewImporter(cfg Config) (*Importer, error) {
} }
go func(bar *pb.ProgressBar) { go func(bar *pb.ProgressBar) {
defer im.wg.Done() defer im.wg.Done()
im.startWorker(bar, cfg.BatchSize, cfg.SignificantFigures, cfg.RoundDigits) im.startWorker(ctx, bar, cfg.BatchSize, cfg.SignificantFigures, cfg.RoundDigits)
}(bar) }(bar)
} }
im.ResetStats() im.ResetStats()
@ -205,7 +208,7 @@ func (im *Importer) Close() {
}) })
} }
func (im *Importer) startWorker(bar *pb.ProgressBar, batchSize, significantFigures, roundDigits int) { func (im *Importer) startWorker(ctx context.Context, bar *pb.ProgressBar, batchSize, significantFigures, roundDigits int) {
var batch []*TimeSeries var batch []*TimeSeries
var dataPoints int var dataPoints int
var waitForBatch time.Time var waitForBatch time.Time
@ -219,7 +222,9 @@ func (im *Importer) startWorker(bar *pb.ProgressBar, batchSize, significantFigur
exitErr := &ImportError{ exitErr := &ImportError{
Batch: batch, Batch: batch,
} }
if err := im.Import(batch); err != nil { retryableFunc := func() error { return im.Import(batch) }
_, err := im.backoff.Retry(ctx, retryableFunc)
if err != nil {
exitErr.Err = err exitErr.Err = err
} }
im.errors <- exitErr im.errors <- exitErr
@ -249,7 +254,7 @@ func (im *Importer) startWorker(bar *pb.ProgressBar, batchSize, significantFigur
im.s.idleDuration += time.Since(waitForBatch) im.s.idleDuration += time.Since(waitForBatch)
im.s.Unlock() im.s.Unlock()
if err := im.flush(batch); err != nil { if err := im.flush(ctx, batch); err != nil {
im.errors <- &ImportError{ im.errors <- &ImportError{
Batch: batch, Batch: batch,
Err: err, Err: err,
@ -264,36 +269,22 @@ func (im *Importer) startWorker(bar *pb.ProgressBar, batchSize, significantFigur
} }
} }
const ( func (im *Importer) flush(ctx context.Context, b []*TimeSeries) error {
// TODO: make configurable retryableFunc := func() error { return im.Import(b) }
backoffRetries = 5 attempts, err := im.backoff.Retry(ctx, retryableFunc)
backoffFactor = 1.7 if err != nil {
backoffMinDuration = time.Second return fmt.Errorf("import failed with %d retries: %s", attempts, err)
)
func (im *Importer) flush(b []*TimeSeries) error {
var err error
for i := 0; i < backoffRetries; i++ {
err = im.Import(b)
if err == nil {
return nil
}
if errors.Is(err, ErrBadRequest) {
return err // fail fast if not recoverable
}
im.s.Lock()
im.s.retries++
im.s.Unlock()
backoff := float64(backoffMinDuration) * math.Pow(backoffFactor, float64(i))
time.Sleep(time.Duration(backoff))
} }
return fmt.Errorf("import failed with %d retries: %s", backoffRetries, err) im.s.Lock()
im.s.retries = attempts
im.s.Unlock()
return nil
} }
// Ping sends a ping to im.addr. // Ping sends a ping to im.addr.
func (im *Importer) Ping() error { func (im *Importer) Ping() error {
url := fmt.Sprintf("%s/health", im.addr) url := fmt.Sprintf("%s/health", im.addr)
req, err := http.NewRequest("GET", url, nil) req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil { if err != nil {
return fmt.Errorf("cannot create request to %q: %s", im.addr, err) return fmt.Errorf("cannot create request to %q: %s", im.addr, err)
} }
@ -317,7 +308,7 @@ func (im *Importer) Import(tsBatch []*TimeSeries) error {
} }
pr, pw := io.Pipe() pr, pw := io.Pipe()
req, err := http.NewRequest("POST", im.importPath, pr) req, err := http.NewRequest(http.MethodPost, im.importPath, pr)
if err != nil { if err != nil {
return fmt.Errorf("cannot create request to %q: %s", im.addr, err) return fmt.Errorf("cannot create request to %q: %s", im.addr, err)
} }

View file

@ -147,7 +147,7 @@ func (p *vmNativeProcessor) runSingle(ctx context.Context, f filter, srcURL, dst
sync := make(chan struct{}) sync := make(chan struct{})
go func() { go func() {
defer func() { close(sync) }() defer func() { close(sync) }()
req, err := http.NewRequestWithContext(ctx, "POST", dstURL, pr) req, err := http.NewRequestWithContext(ctx, http.MethodPost, dstURL, pr)
if err != nil { if err != nil {
log.Fatalf("cannot create import request to %q: %s", p.dst.addr, err) log.Fatalf("cannot create import request to %q: %s", p.dst.addr, err)
} }
@ -198,7 +198,7 @@ func (p *vmNativeProcessor) runSingle(ctx context.Context, f filter, srcURL, dst
func (p *vmNativeProcessor) getSourceTenants(ctx context.Context, f filter) ([]string, error) { func (p *vmNativeProcessor) getSourceTenants(ctx context.Context, f filter) ([]string, error) {
u := fmt.Sprintf("%s/%s", p.src.addr, nativeTenantsAddr) u := fmt.Sprintf("%s/%s", p.src.addr, nativeTenantsAddr)
req, err := http.NewRequestWithContext(ctx, "GET", u, nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil { if err != nil {
return nil, fmt.Errorf("cannot create request to %q: %s", u, err) return nil, fmt.Errorf("cannot create request to %q: %s", u, err)
} }
@ -232,7 +232,7 @@ func (p *vmNativeProcessor) getSourceTenants(ctx context.Context, f filter) ([]s
} }
func (p *vmNativeProcessor) exportPipe(ctx context.Context, url string, f filter) (io.ReadCloser, error) { func (p *vmNativeProcessor) exportPipe(ctx context.Context, url string, f filter) (io.ReadCloser, error) {
req, err := http.NewRequestWithContext(ctx, "GET", url, nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil { if err != nil {
return nil, fmt.Errorf("cannot create request to %q: %s", p.src.addr, err) return nil, fmt.Errorf("cannot create request to %q: %s", p.src.addr, err)
} }

View file

@ -48,13 +48,13 @@ var (
"See also -influxListenAddr.useProxyProtocol") "See also -influxListenAddr.useProxyProtocol")
influxUseProxyProtocol = flag.Bool("influxListenAddr.useProxyProtocol", false, "Whether to use proxy protocol for connections accepted at -influxListenAddr . "+ influxUseProxyProtocol = flag.Bool("influxListenAddr.useProxyProtocol", false, "Whether to use proxy protocol for connections accepted at -influxListenAddr . "+
"See https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt") "See https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt")
opentsdbListenAddr = flag.String("opentsdbListenAddr", "", "TCP and UDP address to listen for OpentTSDB metrics. "+ opentsdbListenAddr = flag.String("opentsdbListenAddr", "", "TCP and UDP address to listen for OpenTSDB metrics. "+
"Telnet put messages and HTTP /api/put messages are simultaneously served on TCP port. "+ "Telnet put messages and HTTP /api/put messages are simultaneously served on TCP port. "+
"Usually :4242 must be set. Doesn't work if empty. "+ "Usually :4242 must be set. Doesn't work if empty. "+
"See also -opentsdbListenAddr.useProxyProtocol") "See also -opentsdbListenAddr.useProxyProtocol")
opentsdbUseProxyProtocol = flag.Bool("opentsdbListenAddr.useProxyProtocol", false, "Whether to use proxy protocol for connections accepted at -opentsdbListenAddr . "+ opentsdbUseProxyProtocol = flag.Bool("opentsdbListenAddr.useProxyProtocol", false, "Whether to use proxy protocol for connections accepted at -opentsdbListenAddr . "+
"See https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt") "See https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt")
opentsdbHTTPListenAddr = flag.String("opentsdbHTTPListenAddr", "", "TCP address to listen for OpentTSDB HTTP put requests. Usually :4242 must be set. Doesn't work if empty. "+ opentsdbHTTPListenAddr = flag.String("opentsdbHTTPListenAddr", "", "TCP address to listen for OpenTSDB HTTP put requests. Usually :4242 must be set. Doesn't work if empty. "+
"See also -opentsdbHTTPListenAddr.useProxyProtocol") "See also -opentsdbHTTPListenAddr.useProxyProtocol")
opentsdbHTTPUseProxyProtocol = flag.Bool("opentsdbHTTPListenAddr.useProxyProtocol", false, "Whether to use proxy protocol for connections accepted "+ opentsdbHTTPUseProxyProtocol = flag.Bool("opentsdbHTTPListenAddr.useProxyProtocol", false, "Whether to use proxy protocol for connections accepted "+
"at -opentsdbHTTPListenAddr . See https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt") "at -opentsdbHTTPListenAddr . See https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt")
@ -157,6 +157,9 @@ func RequestHandler(w http.ResponseWriter, r *http.Request) bool {
} }
switch path { switch path {
case "/prometheus/api/v1/write", "/api/v1/write": case "/prometheus/api/v1/write", "/api/v1/write":
if common.HandleVMProtoServerHandshake(w, r) {
return true
}
prometheusWriteRequests.Inc() prometheusWriteRequests.Inc()
if err := promremotewrite.InsertHandler(r); err != nil { if err := promremotewrite.InsertHandler(r); err != nil {
prometheusWriteErrors.Inc() prometheusWriteErrors.Inc()

View file

@ -35,8 +35,9 @@ var (
maxQueueDuration = flag.Duration("search.maxQueueDuration", 10*time.Second, "The maximum time the request waits for execution when -search.maxConcurrentRequests "+ maxQueueDuration = flag.Duration("search.maxQueueDuration", 10*time.Second, "The maximum time the request waits for execution when -search.maxConcurrentRequests "+
"limit is reached; see also -search.maxQueryDuration") "limit is reached; see also -search.maxQueryDuration")
resetCacheAuthKey = flag.String("search.resetCacheAuthKey", "", "Optional authKey for resetting rollup cache via /internal/resetRollupResultCache call") resetCacheAuthKey = flag.String("search.resetCacheAuthKey", "", "Optional authKey for resetting rollup cache via /internal/resetRollupResultCache call")
logSlowQueryDuration = flag.Duration("search.logSlowQueryDuration", 5*time.Second, "Log queries with execution time exceeding this value. Zero disables slow query logging") logSlowQueryDuration = flag.Duration("search.logSlowQueryDuration", 5*time.Second, "Log queries with execution time exceeding this value. Zero disables slow query logging. "+
vmalertProxyURL = flag.String("vmalert.proxyURL", "", "Optional URL for proxying requests to vmalert. For example, if -vmalert.proxyURL=http://vmalert:8880 , then alerting API requests such as /api/v1/rules from Grafana will be proxied to http://vmalert:8880/api/v1/rules") "See also -search.logQueryMemoryUsage")
vmalertProxyURL = flag.String("vmalert.proxyURL", "", "Optional URL for proxying requests to vmalert. For example, if -vmalert.proxyURL=http://vmalert:8880 , then alerting API requests such as /api/v1/rules from Grafana will be proxied to http://vmalert:8880/api/v1/rules")
) )
var slowQueries = metrics.NewCounter(`vm_slow_queries_total`) var slowQueries = metrics.NewCounter(`vm_slow_queries_total`)

View file

@ -759,6 +759,9 @@ func QueryHandler(qt *querytracer.Tracer, startTime time.Time, w http.ResponseWr
LookbackDelta: lookbackDelta, LookbackDelta: lookbackDelta,
RoundDigits: getRoundDigits(r), RoundDigits: getRoundDigits(r),
EnforcedTagFilterss: etfs, EnforcedTagFilterss: etfs,
GetRequestURI: func() string {
return httpserver.GetRequestURI(r)
},
} }
result, err := promql.Exec(qt, &ec, query, true) result, err := promql.Exec(qt, &ec, query, true)
if err != nil { if err != nil {
@ -860,6 +863,9 @@ func queryRangeHandler(qt *querytracer.Tracer, startTime time.Time, w http.Respo
LookbackDelta: lookbackDelta, LookbackDelta: lookbackDelta,
RoundDigits: getRoundDigits(r), RoundDigits: getRoundDigits(r),
EnforcedTagFilterss: etfs, EnforcedTagFilterss: etfs,
GetRequestURI: func() string {
return httpserver.GetRequestURI(r)
},
} }
result, err := promql.Exec(qt, &ec, query, false) result, err := promql.Exec(qt, &ec, query, false)
if err != nil { if err != nil {
@ -1013,8 +1019,10 @@ func getRoundDigits(r *http.Request) int {
func getLatencyOffsetMilliseconds(r *http.Request) (int64, error) { func getLatencyOffsetMilliseconds(r *http.Request) (int64, error) {
d := latencyOffset.Milliseconds() d := latencyOffset.Milliseconds()
if d <= 1000 { if d < 0 {
d = 1000 // Zero latency offset may be useful for some use cases.
// See https://github.com/VictoriaMetrics/VictoriaMetrics/issues/2061#issuecomment-1299109836
d = 0
} }
return searchutils.GetDuration(r, "latency_offset", d) return searchutils.GetDuration(r, "latency_offset", d)
} }

View file

@ -200,7 +200,7 @@ func TestAdjustLastPoints(t *testing.T) {
func TestGetLatencyOffsetMillisecondsSuccess(t *testing.T) { func TestGetLatencyOffsetMillisecondsSuccess(t *testing.T) {
f := func(url string, expectedOffset int64) { f := func(url string, expectedOffset int64) {
t.Helper() t.Helper()
r, err := http.NewRequest("GET", url, nil) r, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil { if err != nil {
t.Fatalf("unexpected error in NewRequest(%q): %s", url, err) t.Fatalf("unexpected error in NewRequest(%q): %s", url, err)
} }
@ -219,7 +219,7 @@ func TestGetLatencyOffsetMillisecondsSuccess(t *testing.T) {
func TestGetLatencyOffsetMillisecondsFailure(t *testing.T) { func TestGetLatencyOffsetMillisecondsFailure(t *testing.T) {
f := func(url string) { f := func(url string) {
t.Helper() t.Helper()
r, err := http.NewRequest("GET", url, nil) r, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil { if err != nil {
t.Fatalf("unexpected error in NewRequest(%q): %s", url, err) t.Fatalf("unexpected error in NewRequest(%q): %s", url, err)
} }

View file

@ -30,7 +30,11 @@ var (
"See https://valyala.medium.com/prometheus-subqueries-in-victoriametrics-9b1492b720b3") "See https://valyala.medium.com/prometheus-subqueries-in-victoriametrics-9b1492b720b3")
maxMemoryPerQuery = flagutil.NewBytes("search.maxMemoryPerQuery", 0, "The maximum amounts of memory a single query may consume. "+ maxMemoryPerQuery = flagutil.NewBytes("search.maxMemoryPerQuery", 0, "The maximum amounts of memory a single query may consume. "+
"Queries requiring more memory are rejected. The total memory limit for concurrently executed queries can be estimated "+ "Queries requiring more memory are rejected. The total memory limit for concurrently executed queries can be estimated "+
"as -search.maxMemoryPerQuery multiplied by -search.maxConcurrentRequests") "as -search.maxMemoryPerQuery multiplied by -search.maxConcurrentRequests . "+
"See also -search.logQueryMemoryUsage")
logQueryMemoryUsage = flagutil.NewBytes("search.logQueryMemoryUsage", 0, "Log queries, which require more memory than specified by this flag. "+
"This may help detecting and optimizing heavy queries. Query logging is disabled by default. "+
"See also -search.logSlowQueryDuration and -search.maxMemoryPerQuery")
noStaleMarkers = flag.Bool("search.noStaleMarkers", false, "Set this flag to true if the database doesn't contain Prometheus stale markers, "+ noStaleMarkers = flag.Bool("search.noStaleMarkers", false, "Set this flag to true if the database doesn't contain Prometheus stale markers, "+
"so there is no need in spending additional CPU time on its handling. Staleness markers may exist only in data obtained from Prometheus scrape targets") "so there is no need in spending additional CPU time on its handling. Staleness markers may exist only in data obtained from Prometheus scrape targets")
) )
@ -123,6 +127,10 @@ type EvalConfig struct {
// EnforcedTagFilterss may contain additional label filters to use in the query. // EnforcedTagFilterss may contain additional label filters to use in the query.
EnforcedTagFilterss [][]storage.TagFilter EnforcedTagFilterss [][]storage.TagFilter
// The callback, which returns the request URI during logging.
// The request URI isn't stored here because its' construction may take non-trivial amounts of CPU.
GetRequestURI func() string
timestamps []int64 timestamps []int64
timestampsOnce sync.Once timestampsOnce sync.Once
} }
@ -140,6 +148,7 @@ func copyEvalConfig(src *EvalConfig) *EvalConfig {
ec.LookbackDelta = src.LookbackDelta ec.LookbackDelta = src.LookbackDelta
ec.RoundDigits = src.RoundDigits ec.RoundDigits = src.RoundDigits
ec.EnforcedTagFilterss = src.EnforcedTagFilterss ec.EnforcedTagFilterss = src.EnforcedTagFilterss
ec.GetRequestURI = src.GetRequestURI
// do not copy src.timestamps - they must be generated again. // do not copy src.timestamps - they must be generated again.
return &ec return &ec
@ -1079,26 +1088,33 @@ func evalRollupFuncWithMetricExpr(qt *querytracer.Tracer, ec *EvalConfig, funcNa
} }
rollupPoints := mulNoOverflow(pointsPerTimeseries, int64(timeseriesLen*len(rcs))) rollupPoints := mulNoOverflow(pointsPerTimeseries, int64(timeseriesLen*len(rcs)))
rollupMemorySize = sumNoOverflow(mulNoOverflow(int64(rssLen), 1000), mulNoOverflow(rollupPoints, 16)) rollupMemorySize = sumNoOverflow(mulNoOverflow(int64(rssLen), 1000), mulNoOverflow(rollupPoints, 16))
if maxMemory := int64(logQueryMemoryUsage.N); maxMemory > 0 && rollupMemorySize > maxMemory {
requestURI := ec.GetRequestURI()
logger.Warnf("remoteAddr=%s, requestURI=%s: the %s requires %d bytes of memory for processing; "+
"logging this query, since it exceeds the -search.logQueryMemoryUsage=%d; "+
"the query selects %d time series and generates %d points across all the time series; try reducing the number of selected time series",
ec.QuotedRemoteAddr, requestURI, expr.AppendString(nil), rollupMemorySize, maxMemory, timeseriesLen*len(rcs), rollupPoints)
}
if maxMemory := int64(maxMemoryPerQuery.N); maxMemory > 0 && rollupMemorySize > maxMemory { if maxMemory := int64(maxMemoryPerQuery.N); maxMemory > 0 && rollupMemorySize > maxMemory {
rss.Cancel() rss.Cancel()
return nil, &UserReadableError{ return nil, &UserReadableError{
Err: fmt.Errorf("not enough memory for processing %d data points across %d time series with %d points in each time series "+ Err: fmt.Errorf("not enough memory for processing %s, which returns %d data points across %d time series with %d points in each time series "+
"according to -search.maxMemoryPerQuery=%d; requested memory: %d bytes; "+ "according to -search.maxMemoryPerQuery=%d; requested memory: %d bytes; "+
"possible solutions are: reducing the number of matching time series; increasing `step` query arg (step=%gs); "+ "possible solutions are: reducing the number of matching time series; increasing `step` query arg (step=%gs); "+
"increasing -search.maxMemoryPerQuery", "increasing -search.maxMemoryPerQuery",
rollupPoints, timeseriesLen*len(rcs), pointsPerTimeseries, maxMemory, rollupMemorySize, float64(ec.Step)/1e3), expr.AppendString(nil), rollupPoints, timeseriesLen*len(rcs), pointsPerTimeseries, maxMemory, rollupMemorySize, float64(ec.Step)/1e3),
} }
} }
rml := getRollupMemoryLimiter() rml := getRollupMemoryLimiter()
if !rml.Get(uint64(rollupMemorySize)) { if !rml.Get(uint64(rollupMemorySize)) {
rss.Cancel() rss.Cancel()
return nil, &UserReadableError{ return nil, &UserReadableError{
Err: fmt.Errorf("not enough memory for processing %d data points across %d time series with %d points in each time series; "+ Err: fmt.Errorf("not enough memory for processing %s, which returns %d data points across %d time series with %d points in each time series; "+
"total available memory for concurrent requests: %d bytes; "+ "total available memory for concurrent requests: %d bytes; "+
"requested memory: %d bytes; "+ "requested memory: %d bytes; "+
"possible solutions are: reducing the number of matching time series; increasing `step` query arg (step=%gs); "+ "possible solutions are: reducing the number of matching time series; increasing `step` query arg (step=%gs); "+
"switching to node with more RAM; increasing -memory.allowedPercent", "switching to node with more RAM; increasing -memory.allowedPercent",
rollupPoints, timeseriesLen*len(rcs), pointsPerTimeseries, rml.MaxSize, uint64(rollupMemorySize), float64(ec.Step)/1e3), expr.AppendString(nil), rollupPoints, timeseriesLen*len(rcs), pointsPerTimeseries, rml.MaxSize, uint64(rollupMemorySize), float64(ec.Step)/1e3),
} }
} }
defer rml.Put(uint64(rollupMemorySize)) defer rml.Put(uint64(rollupMemorySize))

View file

@ -6212,7 +6212,7 @@ func TestExecSuccess(t *testing.T) {
q := `interpolate(time() < 1300)` q := `interpolate(time() < 1300)`
r1 := netstorage.Result{ r1 := netstorage.Result{
MetricName: metricNameExpected, MetricName: metricNameExpected,
Values: []float64{1000, 1200, 1200, 1200, 1200, 1200}, Values: []float64{1000, 1200, nan, nan, nan, nan},
Timestamps: timestampsExpected, Timestamps: timestampsExpected,
} }
resultExpected := []netstorage.Result{r1} resultExpected := []netstorage.Result{r1}
@ -6223,7 +6223,18 @@ func TestExecSuccess(t *testing.T) {
q := `interpolate(time() > 1500)` q := `interpolate(time() > 1500)`
r1 := netstorage.Result{ r1 := netstorage.Result{
MetricName: metricNameExpected, MetricName: metricNameExpected,
Values: []float64{1600, 1600, 1600, 1600, 1800, 2000}, Values: []float64{nan, nan, nan, 1600, 1800, 2000},
Timestamps: timestampsExpected,
}
resultExpected := []netstorage.Result{r1}
f(q, resultExpected)
})
t.Run(`interpolate(tail_head_and_middle)`, func(t *testing.T) {
t.Parallel()
q := `interpolate(time() > 1100 and time() < 1300 default time() > 1700 and time() < 1900)`
r1 := netstorage.Result{
MetricName: metricNameExpected,
Values: []float64{nan, 1200, 1400, 1600, 1800, nan},
Timestamps: timestampsExpected, Timestamps: timestampsExpected,
} }
resultExpected := []netstorage.Result{r1} resultExpected := []netstorage.Result{r1}

View file

@ -1169,7 +1169,8 @@ func transformInterpolate(tfa *transformFuncArg) ([]*timeseries, error) {
} }
rvs := args[0] rvs := args[0]
for _, ts := range rvs { for _, ts := range rvs {
values := ts.Values values := skipLeadingNaNs(ts.Values)
values = skipTrailingNaNs(values)
if len(values) == 0 { if len(values) == 0 {
continue continue
} }

View file

@ -15,7 +15,7 @@ func TestGetDurationSuccess(t *testing.T) {
f := func(s string, dExpected int64) { f := func(s string, dExpected int64) {
t.Helper() t.Helper()
urlStr := fmt.Sprintf("http://foo.bar/baz?s=%s", url.QueryEscape(s)) urlStr := fmt.Sprintf("http://foo.bar/baz?s=%s", url.QueryEscape(s))
r, err := http.NewRequest("GET", urlStr, nil) r, err := http.NewRequest(http.MethodGet, urlStr, nil)
if err != nil { if err != nil {
t.Fatalf("unexpected error in NewRequest: %s", err) t.Fatalf("unexpected error in NewRequest: %s", err)
} }
@ -54,7 +54,7 @@ func TestGetDurationError(t *testing.T) {
f := func(s string) { f := func(s string) {
t.Helper() t.Helper()
urlStr := fmt.Sprintf("http://foo.bar/baz?s=%s", url.QueryEscape(s)) urlStr := fmt.Sprintf("http://foo.bar/baz?s=%s", url.QueryEscape(s))
r, err := http.NewRequest("GET", urlStr, nil) r, err := http.NewRequest(http.MethodGet, urlStr, nil)
if err != nil { if err != nil {
t.Fatalf("unexpected error in NewRequest: %s", err) t.Fatalf("unexpected error in NewRequest: %s", err)
} }
@ -78,7 +78,7 @@ func TestGetTimeSuccess(t *testing.T) {
f := func(s string, timestampExpected int64) { f := func(s string, timestampExpected int64) {
t.Helper() t.Helper()
urlStr := fmt.Sprintf("http://foo.bar/baz?s=%s", url.QueryEscape(s)) urlStr := fmt.Sprintf("http://foo.bar/baz?s=%s", url.QueryEscape(s))
r, err := http.NewRequest("GET", urlStr, nil) r, err := http.NewRequest(http.MethodGet, urlStr, nil)
if err != nil { if err != nil {
t.Fatalf("unexpected error in NewRequest: %s", err) t.Fatalf("unexpected error in NewRequest: %s", err)
} }
@ -127,7 +127,7 @@ func TestGetTimeError(t *testing.T) {
f := func(s string) { f := func(s string) {
t.Helper() t.Helper()
urlStr := fmt.Sprintf("http://foo.bar/baz?s=%s", url.QueryEscape(s)) urlStr := fmt.Sprintf("http://foo.bar/baz?s=%s", url.QueryEscape(s))
r, err := http.NewRequest("GET", urlStr, nil) r, err := http.NewRequest(http.MethodGet, urlStr, nil)
if err != nil { if err != nil {
t.Fatalf("unexpected error in NewRequest: %s", err) t.Fatalf("unexpected error in NewRequest: %s", err)
} }

View file

@ -1,14 +1,14 @@
{ {
"files": { "files": {
"main.css": "./static/css/main.b9c2d13c.css", "main.css": "./static/css/main.5c28f4a7.css",
"main.js": "./static/js/main.44784d74.js", "main.js": "./static/js/main.0be86920.js",
"static/js/27.c1ccfd29.chunk.js": "./static/js/27.c1ccfd29.chunk.js", "static/js/27.c1ccfd29.chunk.js": "./static/js/27.c1ccfd29.chunk.js",
"static/media/Lato-Regular.ttf": "./static/media/Lato-Regular.d714fec1633b69a9c2e9.ttf", "static/media/Lato-Regular.ttf": "./static/media/Lato-Regular.d714fec1633b69a9c2e9.ttf",
"static/media/Lato-Bold.ttf": "./static/media/Lato-Bold.32360ba4b57802daa4d6.ttf", "static/media/Lato-Bold.ttf": "./static/media/Lato-Bold.32360ba4b57802daa4d6.ttf",
"index.html": "./index.html" "index.html": "./index.html"
}, },
"entrypoints": [ "entrypoints": [
"static/css/main.b9c2d13c.css", "static/css/main.5c28f4a7.css",
"static/js/main.44784d74.js" "static/js/main.0be86920.js"
] ]
} }

View file

@ -1 +1 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="icon" href="./favicon.ico"/><meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no"/><meta name="theme-color" content="#000000"/><meta name="description" content="UI for VictoriaMetrics"/><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><script src="./dashboards/index.js" type="module"></script><meta name="twitter:card" content="summary_large_image"><meta name="twitter:image" content="./preview.jpg"><meta name="twitter:title" content="UI for VictoriaMetrics"><meta name="twitter:description" content="Explore and troubleshoot your VictoriaMetrics data"><meta name="twitter:site" content="@VictoriaMetrics"><meta property="og:title" content="Metric explorer for VictoriaMetrics"><meta property="og:description" content="Explore and troubleshoot your VictoriaMetrics data"><meta property="og:image" content="./preview.jpg"><meta property="og:type" content="website"><script defer="defer" src="./static/js/main.44784d74.js"></script><link href="./static/css/main.b9c2d13c.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html> <!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="icon" href="./favicon.ico"/><meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no"/><meta name="theme-color" content="#000000"/><meta name="description" content="UI for VictoriaMetrics"/><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><script src="./dashboards/index.js" type="module"></script><meta name="twitter:card" content="summary_large_image"><meta name="twitter:image" content="./preview.jpg"><meta name="twitter:title" content="UI for VictoriaMetrics"><meta name="twitter:description" content="Explore and troubleshoot your VictoriaMetrics data"><meta name="twitter:site" content="@VictoriaMetrics"><meta property="og:title" content="Metric explorer for VictoriaMetrics"><meta property="og:description" content="Explore and troubleshoot your VictoriaMetrics data"><meta property="og:image" content="./preview.jpg"><meta property="og:type" content="website"><script defer="defer" src="./static/js/main.0be86920.js"></script><link href="./static/css/main.5c28f4a7.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1,11 +1,15 @@
import React, { FC } from "preact/compat"; import React, { FC, useRef, useState } from "preact/compat";
import { useCustomPanelDispatch, useCustomPanelState } from "../../../state/customPanel/CustomPanelStateContext"; import { useCustomPanelDispatch, useCustomPanelState } from "../../../state/customPanel/CustomPanelStateContext";
import { useQueryDispatch, useQueryState } from "../../../state/query/QueryStateContext"; import { useQueryDispatch, useQueryState } from "../../../state/query/QueryStateContext";
import "./style.scss"; import "./style.scss";
import Switch from "../../Main/Switch/Switch"; import Switch from "../../Main/Switch/Switch";
import useDeviceDetect from "../../../hooks/useDeviceDetect";
import Popper from "../../Main/Popper/Popper";
import { TuneIcon } from "../../Main/Icons";
import Button from "../../Main/Button/Button";
import classNames from "classnames";
const AdditionalSettings: FC = () => { const AdditionalSettingsControls: FC<{isMobile?: boolean}> = ({ isMobile }) => {
const { autocomplete } = useQueryState(); const { autocomplete } = useQueryState();
const queryDispatch = useQueryDispatch(); const queryDispatch = useQueryDispatch();
@ -24,23 +28,72 @@ const AdditionalSettings: FC = () => {
queryDispatch({ type: "TOGGLE_AUTOCOMPLETE" }); queryDispatch({ type: "TOGGLE_AUTOCOMPLETE" });
}; };
return <div className="vm-additional-settings"> return (
<Switch <div
label={"Autocomplete"} className={classNames({
value={autocomplete} "vm-additional-settings": true,
onChange={onChangeAutocomplete} "vm-additional-settings_mobile": isMobile
/> })}
<Switch >
label={"Disable cache"} <Switch
value={nocache} label={"Autocomplete"}
onChange={onChangeCache} value={autocomplete}
/> onChange={onChangeAutocomplete}
<Switch fullWidth={isMobile}
label={"Trace query"} />
value={isTracingEnabled} <Switch
onChange={onChangeQueryTracing} label={"Disable cache"}
/> value={nocache}
</div>; onChange={onChangeCache}
fullWidth={isMobile}
/>
<Switch
label={"Trace query"}
value={isTracingEnabled}
onChange={onChangeQueryTracing}
fullWidth={isMobile}
/>
</div>
);
};
const AdditionalSettings: FC = () => {
const { isMobile } = useDeviceDetect();
const [openList, setOpenList] = useState(false);
const targetRef = useRef<HTMLDivElement>(null);
const handleToggleList = () => {
setOpenList(prev => !prev);
};
const handleCloseList = () => {
setOpenList(false);
};
if (isMobile) {
return (
<>
<div ref={targetRef}>
<Button
variant="outlined"
startIcon={<TuneIcon/>}
onClick={handleToggleList}
/>
</div>
<Popper
open={openList}
buttonRef={targetRef}
placement="bottom-left"
onClose={handleCloseList}
title={"Query settings"}
>
<AdditionalSettingsControls isMobile={isMobile}/>
</Popper>
</>
);
}
return <AdditionalSettingsControls/>;
}; };
export default AdditionalSettings; export default AdditionalSettings;

View file

@ -5,10 +5,19 @@
align-items: center; align-items: center;
justify-content: flex-start; justify-content: flex-start;
flex-wrap: wrap; flex-wrap: wrap;
gap: 24px; gap: $padding-global;
&__input { &__input {
flex-basis: 160px; flex-basis: 160px;
margin-bottom: -6px; margin-bottom: -6px;
} }
&_mobile {
display: grid;
grid-template-columns: 1fr;
align-items: flex-start;
padding: 0 $padding-global;
gap: $padding-medium;
width: 100%;
}
} }

View file

@ -2,13 +2,15 @@ import React, { FC, useMemo, useRef } from "preact/compat";
import { useCardinalityState, useCardinalityDispatch } from "../../../state/cardinality/CardinalityStateContext"; import { useCardinalityState, useCardinalityDispatch } from "../../../state/cardinality/CardinalityStateContext";
import dayjs from "dayjs"; import dayjs from "dayjs";
import Button from "../../Main/Button/Button"; import Button from "../../Main/Button/Button";
import { CalendarIcon } from "../../Main/Icons"; import { ArrowDownIcon, CalendarIcon } from "../../Main/Icons";
import Tooltip from "../../Main/Tooltip/Tooltip"; import Tooltip from "../../Main/Tooltip/Tooltip";
import { getAppModeEnable } from "../../../utils/app-mode"; import { getAppModeEnable } from "../../../utils/app-mode";
import { DATE_FORMAT } from "../../../constants/date"; import { DATE_FORMAT } from "../../../constants/date";
import DatePicker from "../../Main/DatePicker/DatePicker"; import DatePicker from "../../Main/DatePicker/DatePicker";
import useDeviceDetect from "../../../hooks/useDeviceDetect";
const CardinalityDatePicker: FC = () => { const CardinalityDatePicker: FC = () => {
const { isMobile } = useDeviceDetect();
const appModeEnable = getAppModeEnable(); const appModeEnable = getAppModeEnable();
const buttonRef = useRef<HTMLDivElement>(null); const buttonRef = useRef<HTMLDivElement>(null);
@ -24,18 +26,30 @@ const CardinalityDatePicker: FC = () => {
return ( return (
<div> <div>
<div ref={buttonRef}> <div ref={buttonRef}>
<Tooltip title="Date control"> {isMobile ? (
<Button <div className="vm-mobile-option">
className={appModeEnable ? "" : "vm-header-button"} <span className="vm-mobile-option__icon"><CalendarIcon/></span>
variant="contained" <div className="vm-mobile-option-text">
color="primary" <span className="vm-mobile-option-text__label">Date control</span>
startIcon={<CalendarIcon/>} <span className="vm-mobile-option-text__value">{dateFormatted}</span>
> </div>
{dateFormatted} <span className="vm-mobile-option__arrow"><ArrowDownIcon/></span>
</Button> </div>
</Tooltip> ) : (
<Tooltip title="Date control">
<Button
className={appModeEnable ? "" : "vm-header-button"}
variant="contained"
color="primary"
startIcon={<CalendarIcon/>}
>
{dateFormatted}
</Button>
</Tooltip>
)}
</div> </div>
<DatePicker <DatePicker
label="Date control"
date={date || ""} date={date || ""}
format={DATE_FORMAT} format={DATE_FORMAT}
onChange={handleChangeDate} onChange={handleChangeDate}

View file

@ -1,7 +1,7 @@
import React, { FC, useEffect, useState } from "preact/compat"; import React, { FC, useEffect, useState } from "preact/compat";
import ServerConfigurator from "./ServerConfigurator/ServerConfigurator"; import ServerConfigurator from "./ServerConfigurator/ServerConfigurator";
import { useAppDispatch, useAppState } from "../../../state/common/StateContext"; import { useAppDispatch, useAppState } from "../../../state/common/StateContext";
import { SettingsIcon } from "../../Main/Icons"; import { ArrowDownIcon, SettingsIcon } from "../../Main/Icons";
import Button from "../../Main/Button/Button"; import Button from "../../Main/Button/Button";
import Modal from "../../Main/Modal/Modal"; import Modal from "../../Main/Modal/Modal";
import "./style.scss"; import "./style.scss";
@ -18,7 +18,7 @@ import useDeviceDetect from "../../../hooks/useDeviceDetect";
const title = "Settings"; const title = "Settings";
const GlobalSettings: FC<{showTitle?: boolean}> = ({ showTitle }) => { const GlobalSettings: FC = () => {
const { isMobile } = useDeviceDetect(); const { isMobile } = useDeviceDetect();
const appModeEnable = getAppModeEnable(); const appModeEnable = getAppModeEnable();
@ -42,7 +42,6 @@ const GlobalSettings: FC<{showTitle?: boolean}> = ({ showTitle }) => {
dispatch({ type: "SET_SERVER", payload: serverUrl }); dispatch({ type: "SET_SERVER", payload: serverUrl });
timeDispatch({ type: "SET_TIMEZONE", payload: timezone }); timeDispatch({ type: "SET_TIMEZONE", payload: timezone });
customPanelDispatch({ type: "SET_SERIES_LIMITS", payload: limits }); customPanelDispatch({ type: "SET_SERIES_LIMITS", payload: limits });
handleClose();
}; };
useEffect(() => { useEffect(() => {
@ -51,22 +50,30 @@ const GlobalSettings: FC<{showTitle?: boolean}> = ({ showTitle }) => {
}, [stateServerUrl]); }, [stateServerUrl]);
return <> return <>
<Tooltip {isMobile ? (
open={showTitle === true ? false : undefined} <div
title={title} className="vm-mobile-option"
>
<Button
className={classNames({
"vm-header-button": !appModeEnable
})}
variant="contained"
color="primary"
startIcon={<SettingsIcon/>}
onClick={handleOpen} onClick={handleOpen}
> >
{showTitle && title} <span className="vm-mobile-option__icon"><SettingsIcon/></span>
</Button> <div className="vm-mobile-option-text">
</Tooltip> <span className="vm-mobile-option-text__label">{title}</span>
</div>
<span className="vm-mobile-option__arrow"><ArrowDownIcon/></span>
</div>
) : (
<Tooltip title={title}>
<Button
className={classNames({
"vm-header-button": !appModeEnable
})}
variant="contained"
color="primary"
startIcon={<SettingsIcon/>}
onClick={handleOpen}
/>
</Tooltip>
)}
{open && ( {open && (
<Modal <Modal
title={title} title={title}
@ -84,6 +91,7 @@ const GlobalSettings: FC<{showTitle?: boolean}> = ({ showTitle }) => {
serverUrl={serverUrl} serverUrl={serverUrl}
onChange={setServerUrl} onChange={setServerUrl}
onEnter={handlerApply} onEnter={handlerApply}
onBlur={handlerApply}
/> />
</div> </div>
)} )}
@ -105,21 +113,6 @@ const GlobalSettings: FC<{showTitle?: boolean}> = ({ showTitle }) => {
<ThemeControl/> <ThemeControl/>
</div> </div>
)} )}
<div className="vm-server-configurator__footer">
<Button
variant="outlined"
color="error"
onClick={handleClose}
>
Cancel
</Button>
<Button
variant="contained"
onClick={handlerApply}
>
apply
</Button>
</div>
</div> </div>
</Modal> </Modal>
)} )}

View file

@ -6,6 +6,8 @@ import { InfoIcon, RestartIcon } from "../../../Main/Icons";
import Button from "../../../Main/Button/Button"; import Button from "../../../Main/Button/Button";
import { DEFAULT_MAX_SERIES } from "../../../../constants/graph"; import { DEFAULT_MAX_SERIES } from "../../../../constants/graph";
import "./style.scss"; import "./style.scss";
import classNames from "classnames";
import useDeviceDetect from "../../../../hooks/useDeviceDetect";
export interface ServerConfiguratorProps { export interface ServerConfiguratorProps {
limits: SeriesLimits limits: SeriesLimits
@ -20,6 +22,7 @@ const fields: {label: string, type: DisplayType}[] = [
]; ];
const LimitsConfigurator: FC<ServerConfiguratorProps> = ({ limits, onChange , onEnter }) => { const LimitsConfigurator: FC<ServerConfiguratorProps> = ({ limits, onChange , onEnter }) => {
const { isMobile } = useDeviceDetect();
const [error, setError] = useState({ const [error, setError] = useState({
table: "", table: "",
@ -68,7 +71,12 @@ const LimitsConfigurator: FC<ServerConfiguratorProps> = ({ limits, onChange , on
</Button> </Button>
</div> </div>
</div> </div>
<div className="vm-limits-configurator__inputs"> <div
className={classNames({
"vm-limits-configurator__inputs": true,
"vm-limits-configurator__inputs_mobile": isMobile
})}
>
{fields.map(f => ( {fields.map(f => (
<div key={f.type}> <div key={f.type}>
<TextField <TextField

View file

@ -18,6 +18,10 @@
justify-content: space-between; justify-content: space-between;
gap: $padding-global; gap: $padding-global;
&_mobile {
gap: $padding-small;
}
div { div {
flex-grow: 1; flex-grow: 1;
} }

View file

@ -7,9 +7,10 @@ export interface ServerConfiguratorProps {
serverUrl: string serverUrl: string
onChange: (url: string) => void onChange: (url: string) => void
onEnter: () => void onEnter: () => void
onBlur: () => void
} }
const ServerConfigurator: FC<ServerConfiguratorProps> = ({ serverUrl, onChange , onEnter }) => { const ServerConfigurator: FC<ServerConfiguratorProps> = ({ serverUrl, onChange , onEnter, onBlur }) => {
const [error, setError] = useState(""); const [error, setError] = useState("");
@ -29,6 +30,8 @@ const ServerConfigurator: FC<ServerConfiguratorProps> = ({ serverUrl, onChange ,
error={error} error={error}
onChange={onChangeServer} onChange={onChangeServer}
onEnter={onEnter} onEnter={onEnter}
onBlur={onBlur}
inputmode="url"
/> />
); );
}; };

View file

@ -41,7 +41,7 @@ const TenantsConfiguration: FC<{accountIds: string[]}> = ({ accountIds }) => {
}; };
const showTenantSelector = useMemo(() => { const showTenantSelector = useMemo(() => {
const id = getTenantIdFromUrl(serverUrl); const id = true; //getTenantIdFromUrl(serverUrl);
return accountIds.length > 1 && id; return accountIds.length > 1 && id;
}, [accountIds, serverUrl]); }, [accountIds, serverUrl]);
@ -81,26 +81,40 @@ const TenantsConfiguration: FC<{accountIds: string[]}> = ({ accountIds }) => {
<div className="vm-tenant-input"> <div className="vm-tenant-input">
<Tooltip title="Define Tenant ID if you need request to another storage"> <Tooltip title="Define Tenant ID if you need request to another storage">
<div ref={optionsButtonRef}> <div ref={optionsButtonRef}>
<Button {isMobile ? (
className={appModeEnable ? "" : "vm-header-button"} <div
variant="contained" className="vm-mobile-option"
color="primary" onClick={toggleOpenOptions}
fullWidth >
startIcon={<StorageIcon/>} <span className="vm-mobile-option__icon"><StorageIcon/></span>
endIcon={!isMobile ? ( <div className="vm-mobile-option-text">
<div <span className="vm-mobile-option-text__label">Tenant ID</span>
className={classNames({ <span className="vm-mobile-option-text__value">{tenantIdState}</span>
"vm-execution-controls-buttons__arrow": true,
"vm-execution-controls-buttons__arrow_open": openOptions,
})}
>
<ArrowDownIcon/>
</div> </div>
) : undefined} <span className="vm-mobile-option__arrow"><ArrowDownIcon/></span>
onClick={toggleOpenOptions} </div>
> ) : (
{!isMobile && tenantIdState} <Button
</Button> className={appModeEnable ? "" : "vm-header-button"}
variant="contained"
color="primary"
fullWidth
startIcon={<StorageIcon/>}
endIcon={(
<div
className={classNames({
"vm-execution-controls-buttons__arrow": true,
"vm-execution-controls-buttons__arrow_open": openOptions,
})}
>
<ArrowDownIcon/>
</div>
)}
onClick={toggleOpenOptions}
>
{tenantIdState}
</Button>
)}
</div> </div>
</Tooltip> </Tooltip>
<Popper <Popper
@ -108,20 +122,28 @@ const TenantsConfiguration: FC<{accountIds: string[]}> = ({ accountIds }) => {
placement="bottom-right" placement="bottom-right"
onClose={handleCloseOptions} onClose={handleCloseOptions}
buttonRef={optionsButtonRef} buttonRef={optionsButtonRef}
title={isMobile ? "Define Tenant ID" : undefined}
> >
<div className="vm-list vm-tenant-input-list"> <div
className={classNames({
"vm-list vm-tenant-input-list": true,
"vm-list vm-tenant-input-list_mobile": isMobile,
})}
>
<div className="vm-tenant-input-list__search"> <div className="vm-tenant-input-list__search">
<TextField <TextField
autofocus autofocus
label="Search" label="Search"
value={search} value={search}
onChange={setSearch} onChange={setSearch}
type="search"
/> />
</div> </div>
{accountIdsFiltered.map(id => ( {accountIdsFiltered.map(id => (
<div <div
className={classNames({ className={classNames({
"vm-list-item": true, "vm-list-item": true,
"vm-list-item_mobile": isMobile,
"vm-list-item_active": id === tenantIdState "vm-list-item_active": id === tenantIdState
})} })}
key={id} key={id}

View file

@ -9,10 +9,18 @@
overscroll-behavior: none; overscroll-behavior: none;
border-radius: $border-radius-medium; border-radius: $border-radius-medium;
&_mobile {
max-height: calc(($vh * 100) - 70px);
}
&_mobile &__search {
padding: 0 $padding-global $padding-small;
}
&__search { &__search {
position: sticky; position: sticky;
top: 0; top: 0;
padding: $padding-small; padding: $padding-small $padding-global;
background-color: $color-background-block; background-color: $color-background-block;
} }
} }

View file

@ -8,6 +8,7 @@ import dayjs from "dayjs";
import TextField from "../../../Main/TextField/TextField"; import TextField from "../../../Main/TextField/TextField";
import { Timezone } from "../../../../types"; import { Timezone } from "../../../../types";
import "./style.scss"; import "./style.scss";
import useDeviceDetect from "../../../../hooks/useDeviceDetect";
interface TimezonesProps { interface TimezonesProps {
timezoneState: string timezoneState: string
@ -15,7 +16,7 @@ interface TimezonesProps {
} }
const Timezones: FC<TimezonesProps> = ({ timezoneState, onChange }) => { const Timezones: FC<TimezonesProps> = ({ timezoneState, onChange }) => {
const { isMobile } = useDeviceDetect();
const timezones = getTimezoneList(); const timezones = getTimezoneList();
const [openList, setOpenList] = useState(false); const [openList, setOpenList] = useState(false);
@ -92,8 +93,14 @@ const Timezones: FC<TimezonesProps> = ({ timezoneState, onChange }) => {
placement="bottom-left" placement="bottom-left"
onClose={handleCloseList} onClose={handleCloseList}
fullWidth fullWidth
title={isMobile ? "Time zone" : undefined}
> >
<div className="vm-timezones-list"> <div
className={classNames({
"vm-timezones-list": true,
"vm-timezones-list_mobile": isMobile,
})}
>
<div className="vm-timezones-list-header"> <div className="vm-timezones-list-header">
<div className="vm-timezones-list-header__search"> <div className="vm-timezones-list-header__search">
<TextField <TextField

View file

@ -51,6 +51,14 @@
border-radius: $border-radius-medium; border-radius: $border-radius-medium;
overflow: auto; overflow: auto;
&_mobile {
max-height: calc(($vh * 100) - 70px);
}
&_mobile &-header__search {
padding: 0 $padding-global 0;
}
&-header { &-header {
position: sticky; position: sticky;
top: 0; top: 0;

View file

@ -6,6 +6,7 @@
align-items: center; align-items: center;
gap: $padding-medium; gap: $padding-medium;
width: 600px; width: 600px;
padding-bottom: $padding-medium;
&_mobile { &_mobile {
grid-auto-rows: min-content; grid-auto-rows: min-content;
@ -20,12 +21,6 @@
&__input { &__input {
width: 100%; width: 100%;
&_server {
display: grid;
grid-template-columns: 1fr auto;
gap: 0 $padding-small;
}
} }
&__title { &__title {
@ -37,20 +32,4 @@
font-weight: bold; font-weight: bold;
margin-bottom: $padding-global; margin-bottom: $padding-global;
} }
&__footer {
display: inline-grid;
grid-template-columns: repeat(2, 1fr);
align-items: center;
justify-content: flex-end;
gap: $padding-small;
margin-left: auto;
margin-right: 0;
}
&_mobile &__footer {
align-items: flex-end;
flex-grow: 1;
width: 100%;
}
} }

View file

@ -4,6 +4,8 @@ import { AxisRange, YaxisState } from "../../../../state/graph/reducer";
import "./style.scss"; import "./style.scss";
import TextField from "../../../Main/TextField/TextField"; import TextField from "../../../Main/TextField/TextField";
import Switch from "../../../Main/Switch/Switch"; import Switch from "../../../Main/Switch/Switch";
import useDeviceDetect from "../../../../hooks/useDeviceDetect";
import classNames from "classnames";
interface AxesLimitsConfiguratorProps { interface AxesLimitsConfiguratorProps {
yaxis: YaxisState, yaxis: YaxisState,
@ -12,6 +14,7 @@ interface AxesLimitsConfiguratorProps {
} }
const AxesLimitsConfigurator: FC<AxesLimitsConfiguratorProps> = ({ yaxis, setYaxisLimits, toggleEnableLimits }) => { const AxesLimitsConfigurator: FC<AxesLimitsConfiguratorProps> = ({ yaxis, setYaxisLimits, toggleEnableLimits }) => {
const { isMobile } = useDeviceDetect();
const axes = useMemo(() => Object.keys(yaxis.limits.range), [yaxis.limits.range]); const axes = useMemo(() => Object.keys(yaxis.limits.range), [yaxis.limits.range]);
@ -27,11 +30,17 @@ const AxesLimitsConfigurator: FC<AxesLimitsConfiguratorProps> = ({ yaxis, setYax
debouncedOnChangeLimit(val, axis, index); debouncedOnChangeLimit(val, axis, index);
}; };
return <div className="vm-axes-limits"> return <div
className={classNames({
"vm-axes-limits": true,
"vm-axes-limits_mobile": isMobile
})}
>
<Switch <Switch
value={yaxis.limits.enable} value={yaxis.limits.enable}
onChange={toggleEnableLimits} onChange={toggleEnableLimits}
label="Fix the limits for y-axis" label="Fix the limits for y-axis"
fullWidth={isMobile}
/> />
<div className="vm-axes-limits-list"> <div className="vm-axes-limits-list">
{axes.map(axis => ( {axes.map(axis => (

View file

@ -6,6 +6,16 @@
gap: $padding-global; gap: $padding-global;
max-width: 300px; max-width: 300px;
&_mobile {
width: 100%;
max-width: 100%;
gap: $padding-medium;
}
&_mobile &-list__inputs {
grid-template-columns: repeat(2, 1fr);
}
&-list { &-list {
display: grid; display: grid;
align-items: center; align-items: center;

View file

@ -1,9 +1,8 @@
import React, { FC, useRef, useState } from "preact/compat"; import React, { FC, useRef, useState } from "preact/compat";
import AxesLimitsConfigurator from "./AxesLimitsConfigurator/AxesLimitsConfigurator"; import AxesLimitsConfigurator from "./AxesLimitsConfigurator/AxesLimitsConfigurator";
import { AxisRange, YaxisState } from "../../../state/graph/reducer"; import { AxisRange, YaxisState } from "../../../state/graph/reducer";
import { CloseIcon, SettingsIcon } from "../../Main/Icons"; import { SettingsIcon } from "../../Main/Icons";
import Button from "../../Main/Button/Button"; import Button from "../../Main/Button/Button";
import useClickOutside from "../../../hooks/useClickOutside";
import Popper from "../../Main/Popper/Popper"; import Popper from "../../Main/Popper/Popper";
import "./style.scss"; import "./style.scss";
import Tooltip from "../../Main/Tooltip/Tooltip"; import Tooltip from "../../Main/Tooltip/Tooltip";
@ -20,7 +19,6 @@ const GraphSettings: FC<GraphSettingsProps> = ({ yaxis, setYaxisLimits, toggleEn
const popperRef = useRef<HTMLDivElement>(null); const popperRef = useRef<HTMLDivElement>(null);
const [openPopper, setOpenPopper] = useState(false); const [openPopper, setOpenPopper] = useState(false);
const buttonRef = useRef<HTMLDivElement>(null); const buttonRef = useRef<HTMLDivElement>(null);
useClickOutside(popperRef, () => setOpenPopper(false), buttonRef);
const toggleOpen = () => { const toggleOpen = () => {
setOpenPopper(prev => !prev); setOpenPopper(prev => !prev);
@ -46,22 +44,12 @@ const GraphSettings: FC<GraphSettingsProps> = ({ yaxis, setYaxisLimits, toggleEn
buttonRef={buttonRef} buttonRef={buttonRef}
placement="bottom-right" placement="bottom-right"
onClose={handleClose} onClose={handleClose}
title={title}
> >
<div <div
className="vm-graph-settings-popper" className="vm-graph-settings-popper"
ref={popperRef} ref={popperRef}
> >
<div className="vm-popper-header">
<h3 className="vm-popper-header__title">
{title}
</h3>
<Button
size="small"
variant="text"
startIcon={<CloseIcon/>}
onClick={handleClose}
/>
</div>
<div className="vm-graph-settings-popper__body"> <div className="vm-graph-settings-popper__body">
<AxesLimitsConfigurator <AxesLimitsConfigurator
yaxis={yaxis} yaxis={yaxis}

View file

@ -78,9 +78,11 @@ const QueryEditor: FC<QueryEditorProps> = ({
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onChange={onChange} onChange={onChange}
disabled={disabled} disabled={disabled}
inputmode={"search"}
/> />
{autocomplete && ( {autocomplete && (
<Autocomplete <Autocomplete
disabledFullScreen
value={value} value={value}
options={options} options={options}
anchor={autocompleteAnchorEl} anchor={autocompleteAnchorEl}

View file

@ -1,5 +1,5 @@
import React, { FC, useEffect, useRef, useState } from "preact/compat"; import React, { FC, useEffect, useRef, useState } from "preact/compat";
import { RestartIcon, TimelineIcon } from "../../Main/Icons"; import { ArrowDownIcon, RestartIcon, TimelineIcon } from "../../Main/Icons";
import TextField from "../../Main/TextField/TextField"; import TextField from "../../Main/TextField/TextField";
import Button from "../../Main/Button/Button"; import Button from "../../Main/Button/Button";
import Tooltip from "../../Main/Tooltip/Tooltip"; import Tooltip from "../../Main/Tooltip/Tooltip";
@ -11,9 +11,12 @@ import usePrevious from "../../../hooks/usePrevious";
import "./style.scss"; import "./style.scss";
import { getAppModeEnable } from "../../../utils/app-mode"; import { getAppModeEnable } from "../../../utils/app-mode";
import Popper from "../../Main/Popper/Popper"; import Popper from "../../Main/Popper/Popper";
import useDeviceDetect from "../../../hooks/useDeviceDetect";
import classNames from "classnames";
const StepConfigurator: FC = () => { const StepConfigurator: FC = () => {
const appModeEnable = getAppModeEnable(); const appModeEnable = getAppModeEnable();
const { isMobile } = useDeviceDetect();
const { customStep: value } = useGraphState(); const { customStep: value } = useGraphState();
const { period: { step: defaultStep } } = useTimeState(); const { period: { step: defaultStep } } = useTimeState();
@ -103,29 +106,49 @@ const StepConfigurator: FC = () => {
className="vm-step-control" className="vm-step-control"
ref={buttonRef} ref={buttonRef}
> >
<Tooltip title="Query resolution step width"> {isMobile ? (
<Button <div
className={appModeEnable ? "" : "vm-header-button"} className="vm-mobile-option"
variant="contained"
color="primary"
startIcon={<TimelineIcon/>}
onClick={toggleOpenOptions} onClick={toggleOpenOptions}
> >
<p> <span className="vm-mobile-option__icon"><TimelineIcon/></span>
STEP <div className="vm-mobile-option-text">
<p className="vm-step-control__value"> <span className="vm-mobile-option-text__label">Step</span>
{customStep} <span className="vm-mobile-option-text__value">{customStep}</span>
</div>
<span className="vm-mobile-option__arrow"><ArrowDownIcon/></span>
</div>
) : (
<Tooltip title="Query resolution step width">
<Button
className={appModeEnable ? "" : "vm-header-button"}
variant="contained"
color="primary"
startIcon={<TimelineIcon/>}
onClick={toggleOpenOptions}
>
<p>
STEP
<p className="vm-step-control__value">
{customStep}
</p>
</p> </p>
</p> </Button>
</Button> </Tooltip>
</Tooltip> )}
<Popper <Popper
open={openOptions} open={openOptions}
placement="bottom-right" placement="bottom-right"
onClose={handleCloseOptions} onClose={handleCloseOptions}
buttonRef={buttonRef} buttonRef={buttonRef}
title={isMobile ? "Query resolution step width" : undefined}
> >
<div className="vm-step-control-popper"> <div
className={classNames({
"vm-step-control-popper": true,
"vm-step-control-popper_mobile": isMobile,
})}
>
<TextField <TextField
autofocus autofocus
label="Step value" label="Step value"

View file

@ -11,10 +11,6 @@
&__value { &__value {
display: inline; display: inline;
margin-left: 3px; margin-left: 3px;
@media (max-width: 500px) {
display: none;
}
} }
&-popper { &-popper {
@ -26,6 +22,16 @@
padding: $padding-global; padding: $padding-global;
font-size: $font-size; font-size: $font-size;
&_mobile {
padding: 0 $padding-global $padding-small;
max-width: 100%;
max-height: calc(($vh * 100) - 70px);
}
&_mobile &-info {
font-size: $font-size;
}
&-info { &-info {
font-size: $font-size-small; font-size: $font-size-small;
line-height: 1.6; line-height: 1.6;

View file

@ -10,5 +10,6 @@
&_mobile &__toggle { &_mobile &__toggle {
display: flex; display: flex;
min-width: 100%;
} }
} }

View file

@ -2,12 +2,12 @@ import React, { FC, useEffect, useRef, useState } from "preact/compat";
import { useTimeDispatch } from "../../../../state/time/TimeStateContext"; import { useTimeDispatch } from "../../../../state/time/TimeStateContext";
import { getAppModeEnable } from "../../../../utils/app-mode"; import { getAppModeEnable } from "../../../../utils/app-mode";
import Button from "../../../Main/Button/Button"; import Button from "../../../Main/Button/Button";
import { ArrowDownIcon, RefreshIcon } from "../../../Main/Icons"; import { ArrowDownIcon, RefreshIcon, RestartIcon } from "../../../Main/Icons";
import Popper from "../../../Main/Popper/Popper"; import Popper from "../../../Main/Popper/Popper";
import "./style.scss"; import "./style.scss";
import classNames from "classnames"; import classNames from "classnames";
import Tooltip from "../../../Main/Tooltip/Tooltip"; import Tooltip from "../../../Main/Tooltip/Tooltip";
import useResize from "../../../../hooks/useResize"; import useDeviceDetect from "../../../../hooks/useDeviceDetect";
interface AutoRefreshOption { interface AutoRefreshOption {
seconds: number seconds: number
@ -30,7 +30,7 @@ const delayOptions: AutoRefreshOption[] = [
]; ];
export const ExecutionControls: FC = () => { export const ExecutionControls: FC = () => {
const windowSize = useResize(document.body); const { isMobile } = useDeviceDetect();
const dispatch = useTimeDispatch(); const dispatch = useTimeDispatch();
const appModeEnable = getAppModeEnable(); const appModeEnable = getAppModeEnable();
@ -85,11 +85,11 @@ export const ExecutionControls: FC = () => {
<div <div
className={classNames({ className={classNames({
"vm-execution-controls-buttons": true, "vm-execution-controls-buttons": true,
"vm-execution-controls-buttons_mobile": isMobile,
"vm-header-button": !appModeEnable, "vm-header-button": !appModeEnable,
"vm-execution-controls-buttons_short": windowSize.width <= 360
})} })}
> >
{windowSize.width > 360 && ( {!isMobile && (
<Tooltip title="Refresh dashboard"> <Tooltip title="Refresh dashboard">
<Button <Button
variant="contained" variant="contained"
@ -99,28 +99,42 @@ export const ExecutionControls: FC = () => {
/> />
</Tooltip> </Tooltip>
)} )}
<Tooltip title="Auto-refresh control"> {isMobile ? (
<div ref={optionsButtonRef}> <div
<Button className="vm-mobile-option"
variant="contained" onClick={toggleOpenOptions}
color="primary" >
fullWidth <span className="vm-mobile-option__icon"><RestartIcon/></span>
endIcon={( <div className="vm-mobile-option-text">
<div <span className="vm-mobile-option-text__label">Auto-refresh</span>
className={classNames({ <span className="vm-mobile-option-text__value">{selectedDelay.title}</span>
"vm-execution-controls-buttons__arrow": true, </div>
"vm-execution-controls-buttons__arrow_open": openOptions, <span className="vm-mobile-option__arrow"><ArrowDownIcon/></span>
})}
>
<ArrowDownIcon/>
</div>
)}
onClick={toggleOpenOptions}
>
{selectedDelay.title}
</Button>
</div> </div>
</Tooltip> ) : (
<Tooltip title="Auto-refresh control">
<div ref={optionsButtonRef}>
<Button
variant="contained"
color="primary"
fullWidth
endIcon={(
<div
className={classNames({
"vm-execution-controls-buttons__arrow": true,
"vm-execution-controls-buttons__arrow_open": openOptions,
})}
>
<ArrowDownIcon/>
</div>
)}
onClick={toggleOpenOptions}
>
{selectedDelay.title}
</Button>
</div>
</Tooltip>
)}
</div> </div>
</div> </div>
<Popper <Popper
@ -128,12 +142,19 @@ export const ExecutionControls: FC = () => {
placement="bottom-right" placement="bottom-right"
onClose={handleCloseOptions} onClose={handleCloseOptions}
buttonRef={optionsButtonRef} buttonRef={optionsButtonRef}
title={isMobile ? "Auto-refresh duration" : undefined}
> >
<div className="vm-execution-controls-list"> <div
className={classNames({
"vm-execution-controls-list": true,
"vm-execution-controls-list_mobile": isMobile,
})}
>
{delayOptions.map(d => ( {delayOptions.map(d => (
<div <div
className={classNames({ className={classNames({
"vm-list-item": true, "vm-list-item": true,
"vm-list-item_mobile": isMobile,
"vm-list-item_active": d.seconds === selectedDelay.seconds "vm-list-item_active": d.seconds === selectedDelay.seconds
})} })}
key={d.seconds} key={d.seconds}

View file

@ -9,8 +9,9 @@
border-radius: calc($button-radius + 1px); border-radius: calc($button-radius + 1px);
min-width: 107px; min-width: 107px;
&_short { &_mobile {
min-width: auto; flex-direction: column;
gap: $padding-medium;
} }
&__arrow { &__arrow {
@ -32,5 +33,11 @@
overflow: auto; overflow: auto;
padding: $padding-small 0; padding: $padding-small 0;
font-size: $font-size; font-size: $font-size;
&_mobile {
width: 100%;
max-height: calc(($vh * 100) - 70px);
padding: 0;
}
} }
} }

View file

@ -2,6 +2,7 @@ import React, { FC } from "preact/compat";
import { relativeTimeOptions } from "../../../../utils/time"; import { relativeTimeOptions } from "../../../../utils/time";
import "./style.scss"; import "./style.scss";
import classNames from "classnames"; import classNames from "classnames";
import useDeviceDetect from "../../../../hooks/useDeviceDetect";
interface TimeDurationSelector { interface TimeDurationSelector {
setDuration: ({ duration, until, id }: {duration: string, until: Date, id: string}) => void; setDuration: ({ duration, until, id }: {duration: string, until: Date, id: string}) => void;
@ -9,17 +10,24 @@ interface TimeDurationSelector {
} }
const TimeDurationSelector: FC<TimeDurationSelector> = ({ relativeTime, setDuration }) => { const TimeDurationSelector: FC<TimeDurationSelector> = ({ relativeTime, setDuration }) => {
const { isMobile } = useDeviceDetect();
const createHandlerClick = (value: { duration: string, until: Date, id: string }) => () => { const createHandlerClick = (value: { duration: string, until: Date, id: string }) => () => {
setDuration(value); setDuration(value);
}; };
return ( return (
<div className="vm-time-duration"> <div
className={classNames({
"vm-time-duration": true,
"vm-time-duration_mobile": isMobile,
})}
>
{relativeTimeOptions.map(({ id, duration, until, title }) => ( {relativeTimeOptions.map(({ id, duration, until, title }) => (
<div <div
className={classNames({ className={classNames({
"vm-list-item": true, "vm-list-item": true,
"vm-list-item_mobile": isMobile,
"vm-list-item_active": id === relativeTime "vm-list-item_active": id === relativeTime
})} })}
key={id} key={id}

View file

@ -4,4 +4,8 @@
max-height: 200px; max-height: 200px;
overflow: auto; overflow: auto;
font-size: $font-size; font-size: $font-size;
&_mobile {
max-height: 100%
}
} }

View file

@ -4,7 +4,7 @@ import TimeDurationSelector from "../TimeDurationSelector/TimeDurationSelector";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { getAppModeEnable } from "../../../../utils/app-mode"; import { getAppModeEnable } from "../../../../utils/app-mode";
import { useTimeDispatch, useTimeState } from "../../../../state/time/TimeStateContext"; import { useTimeDispatch, useTimeState } from "../../../../state/time/TimeStateContext";
import { AlarmIcon, CalendarIcon, ClockIcon } from "../../../Main/Icons"; import { AlarmIcon, ArrowDownIcon, CalendarIcon, ClockIcon } from "../../../Main/Icons";
import Button from "../../../Main/Button/Button"; import Button from "../../../Main/Button/Button";
import Popper from "../../../Main/Popper/Popper"; import Popper from "../../../Main/Popper/Popper";
import Tooltip from "../../../Main/Tooltip/Tooltip"; import Tooltip from "../../../Main/Tooltip/Tooltip";
@ -15,8 +15,10 @@ import "./style.scss";
import useClickOutside from "../../../../hooks/useClickOutside"; import useClickOutside from "../../../../hooks/useClickOutside";
import classNames from "classnames"; import classNames from "classnames";
import { useAppState } from "../../../../state/common/StateContext"; import { useAppState } from "../../../../state/common/StateContext";
import useDeviceDetect from "../../../../hooks/useDeviceDetect";
export const TimeSelector: FC = () => { export const TimeSelector: FC = () => {
const { isMobile } = useDeviceDetect();
const { isDarkTheme } = useAppState(); const { isDarkTheme } = useAppState();
const wrapperRef = useRef<HTMLDivElement>(null); const wrapperRef = useRef<HTMLDivElement>(null);
const documentSize = useResize(document.body); const documentSize = useResize(document.body);
@ -112,6 +114,7 @@ export const TimeSelector: FC = () => {
}, [timezone]); }, [timezone]);
useClickOutside(wrapperRef, (e) => { useClickOutside(wrapperRef, (e) => {
if (isMobile) return;
const target = e.target as HTMLElement; const target = e.target as HTMLElement;
const isFromButton = fromRef?.current && fromRef.current.contains(target); const isFromButton = fromRef?.current && fromRef.current.contains(target);
const isUntilButton = untilRef?.current && untilRef.current.contains(target); const isUntilButton = untilRef?.current && untilRef.current.contains(target);
@ -123,17 +126,31 @@ export const TimeSelector: FC = () => {
return <> return <>
<div ref={buttonRef}> <div ref={buttonRef}>
<Tooltip title={displayFullDate ? "Time range controls" : dateTitle}> {isMobile ? (
<Button <div
className={appModeEnable ? "" : "vm-header-button"} className="vm-mobile-option"
variant="contained"
color="primary"
startIcon={<ClockIcon/>}
onClick={toggleOpenOptions} onClick={toggleOpenOptions}
> >
{displayFullDate && <span>{dateTitle}</span>} <span className="vm-mobile-option__icon"><ClockIcon/></span>
</Button> <div className="vm-mobile-option-text">
</Tooltip> <span className="vm-mobile-option-text__label">Time range</span>
<span className="vm-mobile-option-text__value">{dateTitle}</span>
</div>
<span className="vm-mobile-option__arrow"><ArrowDownIcon/></span>
</div>
) : (
<Tooltip title={displayFullDate ? "Time range controls" : dateTitle}>
<Button
className={appModeEnable ? "" : "vm-header-button"}
variant="contained"
color="primary"
startIcon={<ClockIcon/>}
onClick={toggleOpenOptions}
>
{displayFullDate && <span>{dateTitle}</span>}
</Button>
</Tooltip>
)}
</div> </div>
<Popper <Popper
open={openOptions} open={openOptions}
@ -141,9 +158,13 @@ export const TimeSelector: FC = () => {
placement="bottom-right" placement="bottom-right"
onClose={handleCloseOptions} onClose={handleCloseOptions}
clickOutside={false} clickOutside={false}
title={isMobile ? "Time range controls" : ""}
> >
<div <div
className="vm-time-selector" className={classNames({
"vm-time-selector": true,
"vm-time-selector_mobile": isMobile
})}
ref={wrapperRef} ref={wrapperRef}
> >
<div className="vm-time-selector-left"> <div className="vm-time-selector-left">
@ -161,6 +182,7 @@ export const TimeSelector: FC = () => {
<span>{formFormat}</span> <span>{formFormat}</span>
<CalendarIcon/> <CalendarIcon/>
<DatePicker <DatePicker
label={"Date From"}
ref={fromPickerRef} ref={fromPickerRef}
date={from || ""} date={from || ""}
onChange={handleFromChange} onChange={handleFromChange}
@ -176,6 +198,7 @@ export const TimeSelector: FC = () => {
<span>{untilFormat}</span> <span>{untilFormat}</span>
<CalendarIcon/> <CalendarIcon/>
<DatePicker <DatePicker
label={"Date To"}
ref={untilPickerRef} ref={untilPickerRef}
date={until || ""} date={until || ""}
onChange={handleUntilChange} onChange={handleUntilChange}

View file

@ -5,9 +5,18 @@
grid-template-columns: repeat(2, 230px); grid-template-columns: repeat(2, 230px);
padding: $padding-global 0; padding: $padding-global 0;
@media (max-width: 500px) { &_mobile {
grid-template-columns: 1fr; grid-template-columns: 1fr;
min-width: 250px; min-width: 250px;
width: 100%;
max-height: calc(($vh * 100) - 70px);
overflow: auto;
}
&_mobile &-left {
border-right: none;
border-bottom: $border-divider;
padding-bottom: $padding-global;
} }
&-left { &-left {
@ -17,12 +26,6 @@
border-right: $border-divider; border-right: $border-divider;
padding: 0 $padding-global; padding: 0 $padding-global;
@media (max-width: 500px) {
border-right: none;
border-bottom: $border-divider;
padding-bottom: $padding-global;
}
&-inputs { &-inputs {
flex-grow: 1; flex-grow: 1;
display: grid; display: grid;
@ -62,6 +65,7 @@
grid-column: 1/3; grid-column: 1/3;
font-size: $font-size-small; font-size: $font-size-small;
color: $color-text-secondary; color: $color-text-secondary;
user-select: none;
} }
svg { svg {

View file

@ -8,6 +8,8 @@ import Spinner from "../../Main/Spinner/Spinner";
import Alert from "../../Main/Alert/Alert"; import Alert from "../../Main/Alert/Alert";
import Button from "../../Main/Button/Button"; import Button from "../../Main/Button/Button";
import "./style.scss"; import "./style.scss";
import classNames from "classnames";
import useDeviceDetect from "../../../hooks/useDeviceDetect";
interface ExploreMetricItemGraphProps { interface ExploreMetricItemGraphProps {
name: string, name: string,
@ -26,6 +28,7 @@ const ExploreMetricItem: FC<ExploreMetricItemGraphProps> = ({
isBucket, isBucket,
height height
}) => { }) => {
const { isMobile } = useDeviceDetect();
const { customStep, yaxis } = useGraphState(); const { customStep, yaxis } = useGraphState();
const { period } = useTimeState(); const { period } = useTimeState();
@ -92,7 +95,12 @@ with (q = ${queryBase}) (
}; };
return ( return (
<div className="vm-explore-metrics-graph"> <div
className={classNames({
"vm-explore-metrics-graph": true,
"vm-explore-metrics-graph_mobile": isMobile
})}
>
{isLoading && <Spinner />} {isLoading && <Spinner />}
{error && <Alert variant="error">{error}</Alert>} {error && <Alert variant="error">{error}</Alert>}
{warning && <Alert variant="warning"> {warning && <Alert variant="warning">

View file

@ -3,6 +3,10 @@
.vm-explore-metrics-graph { .vm-explore-metrics-graph {
padding: 0 $padding-global $padding-global; padding: 0 $padding-global $padding-global;
&_mobile {
padding: 0 $padding-global $padding-global;
}
&__warning { &__warning {
display: grid; display: grid;
grid-template-columns: 1fr auto; grid-template-columns: 1fr auto;

View file

@ -10,6 +10,7 @@ interface ExploreMetricItemProps {
job: string job: string
instance: string instance: string
index: number index: number
length: number
size: GraphSize size: GraphSize
onRemoveItem: (name: string) => void onRemoveItem: (name: string) => void
onChangeOrder: (name: string, oldIndex: number, newIndex: number) => void onChangeOrder: (name: string, oldIndex: number, newIndex: number) => void
@ -20,6 +21,7 @@ const ExploreMetricItem: FC<ExploreMetricItemProps> = ({
job, job,
instance, instance,
index, index,
length,
size, size,
onRemoveItem, onRemoveItem,
onChangeOrder, onChangeOrder,
@ -42,6 +44,7 @@ const ExploreMetricItem: FC<ExploreMetricItemProps> = ({
<ExploreMetricItemHeader <ExploreMetricItemHeader
name={name} name={name}
index={index} index={index}
length={length}
isBucket={isBucket} isBucket={isBucket}
rateEnabled={rateEnabled} rateEnabled={rateEnabled}
size={size.id} size={size.id}

View file

@ -1,13 +1,16 @@
import React, { FC } from "preact/compat"; import React, { FC, useState } from "preact/compat";
import "./style.scss"; import "./style.scss";
import Switch from "../../Main/Switch/Switch"; import Switch from "../../Main/Switch/Switch";
import Tooltip from "../../Main/Tooltip/Tooltip"; import Tooltip from "../../Main/Tooltip/Tooltip";
import Button from "../../Main/Button/Button"; import Button from "../../Main/Button/Button";
import { ArrowDownIcon, CloseIcon } from "../../Main/Icons"; import { ArrowDownIcon, CloseIcon, MinusIcon, MoreIcon, PlusIcon } from "../../Main/Icons";
import useDeviceDetect from "../../../hooks/useDeviceDetect";
import Modal from "../../Main/Modal/Modal";
interface ExploreMetricItemControlsProps { interface ExploreMetricItemControlsProps {
name: string name: string
index: number index: number
length: number
isBucket: boolean isBucket: boolean
rateEnabled: boolean rateEnabled: boolean
size: string size: string
@ -19,12 +22,15 @@ interface ExploreMetricItemControlsProps {
const ExploreMetricItemHeader: FC<ExploreMetricItemControlsProps> = ({ const ExploreMetricItemHeader: FC<ExploreMetricItemControlsProps> = ({
name, name,
index, index,
length,
isBucket, isBucket,
rateEnabled, rateEnabled,
onChangeRate, onChangeRate,
onRemoveItem, onRemoveItem,
onChangeOrder, onChangeOrder,
}) => { }) => {
const { isMobile } = useDeviceDetect();
const [openOptions, setOpenOptions] = useState(false);
const handleClickRemove = () => { const handleClickRemove = () => {
onRemoveItem(name); onRemoveItem(name);
@ -38,6 +44,76 @@ const ExploreMetricItemHeader: FC<ExploreMetricItemControlsProps> = ({
onChangeOrder(name, index, index - 1); onChangeOrder(name, index, index - 1);
}; };
const handleOpenOptions = () => {
setOpenOptions(true);
};
const handleCloseOptions = () => {
setOpenOptions(false);
};
if (isMobile) {
return (
<div className="vm-explore-metrics-item-header vm-explore-metrics-item-header_mobile">
<div className="vm-explore-metrics-item-header__name">{name}</div>
<Button
variant="text"
size="small"
startIcon={<MoreIcon/>}
onClick={handleOpenOptions}
/>
{openOptions && (
<Modal
title={name}
onClose={handleCloseOptions}
>
<div className="vm-explore-metrics-item-header-modal">
<div className="vm-explore-metrics-item-header-modal-order">
<Button
startIcon={<MinusIcon/>}
variant="outlined"
onClick={handleOrderUp}
disabled={index === 0}
/>
<p>position:
<span className="vm-explore-metrics-item-header-modal-order__index">#{index + 1}</span>
</p>
<Button
endIcon={<PlusIcon/>}
variant="outlined"
onClick={handleOrderDown}
disabled={index === length - 1}
/>
</div>
{!isBucket && (
<div className="vm-explore-metrics-item-header-modal__rate">
<Switch
label={<span>enable <code>rate()</code></span>}
value={rateEnabled}
onChange={onChangeRate}
fullWidth
/>
<p>
calculates the average per-second speed of metrics change
</p>
</div>
)}
<Button
startIcon={<CloseIcon/>}
color="error"
variant="outlined"
onClick={handleClickRemove}
fullWidth
>
Remove graph
</Button>
</div>
</Modal>
)}
</div>
);
}
return ( return (
<div className="vm-explore-metrics-item-header"> <div className="vm-explore-metrics-item-header">
<div className="vm-explore-metrics-item-header-order"> <div className="vm-explore-metrics-item-header-order">
@ -65,15 +141,17 @@ const ExploreMetricItemHeader: FC<ExploreMetricItemControlsProps> = ({
</div> </div>
<div className="vm-explore-metrics-item-header__name">{name}</div> <div className="vm-explore-metrics-item-header__name">{name}</div>
{!isBucket && ( {!isBucket && (
<Tooltip title="calculates the average per-second speed of metric's change"> <div className="vm-explore-metrics-item-header__rate">
<Switch <Tooltip title="calculates the average per-second speed of metric's change">
label={<span>enable <code>rate()</code></span>} <Switch
value={rateEnabled} label={<span>enable <code>rate()</code></span>}
onChange={onChangeRate} value={rateEnabled}
/> onChange={onChangeRate}
</Tooltip> />
</Tooltip>
</div>
)} )}
<div className="vm-explore-metrics-item-header__layout"> <div className="vm-explore-metrics-item-header__close">
<Tooltip title="close graph"> <Tooltip title="close graph">
<Button <Button
startIcon={<CloseIcon/>} startIcon={<CloseIcon/>}

View file

@ -1,14 +1,19 @@
@use "src/styles/variables" as *; @use "src/styles/variables" as *;
.vm-explore-metrics-item-header { .vm-explore-metrics-item-header {
display: flex; display: grid;
flex-wrap: wrap; grid-template-columns: auto 1fr auto auto;
align-items: center; align-items: center;
justify-content: flex-start; justify-content: flex-start;
padding: $padding-global; padding: $padding-global;
border-bottom: $border-divider; border-bottom: $border-divider;
gap: $padding-global; gap: $padding-global;
&_mobile {
grid-template-columns: 1fr auto;
padding: $padding-small $padding-global;
}
&__index { &__index {
color: $color-text-secondary; color: $color-text-secondary;
font-size: $font-size-small; font-size: $font-size-small;
@ -17,9 +22,14 @@
&__name { &__name {
flex-grow: 1; flex-grow: 1;
font-weight: bold; font-weight: bold;
max-width: 100%;
text-overflow: ellipsis;
overflow: hidden;
line-height: 130%;
} }
&-order { &-order {
grid-column: 1;
display: grid; display: grid;
grid-template-columns: auto 20px auto; grid-template-columns: auto 20px auto;
align-items: center; align-items: center;
@ -31,7 +41,13 @@
} }
} }
&__layout { &__rate {
grid-column: 3;
}
&__close {
grid-row: 1;
grid-column: 4;
display: grid; display: grid;
align-items: center; align-items: center;
} }
@ -42,4 +58,35 @@
background-color: $color-hover-black; background-color: $color-hover-black;
border-radius: 6px; border-radius: 6px;
} }
&-modal {
display: grid;
align-items: flex-start;
gap: $padding-medium;
&-order {
display: flex;
align-items: center;
justify-content: space-between;
gap: $padding-medium;
p {
display: flex;
align-items: center;
}
&__index {
margin-left: 4px;
}
}
&__rate {
display: grid;
gap: $padding-small;
p {
color: $color-text-secondary;
}
}
}
} }

View file

@ -2,6 +2,8 @@ import React, { FC, useMemo } from "preact/compat";
import Select from "../../Main/Select/Select"; import Select from "../../Main/Select/Select";
import "./style.scss"; import "./style.scss";
import { GRAPH_SIZES } from "../../../constants/graph"; import { GRAPH_SIZES } from "../../../constants/graph";
import classNames from "classnames";
import useDeviceDetect from "../../../hooks/useDeviceDetect";
interface ExploreMetricsHeaderProps { interface ExploreMetricsHeaderProps {
jobs: string[] jobs: string[]
@ -34,9 +36,17 @@ const ExploreMetricsHeader: FC<ExploreMetricsHeaderProps> = ({
}) => { }) => {
const noInstanceText = useMemo(() => job ? "" : "No instances. Please select job", [job]); const noInstanceText = useMemo(() => job ? "" : "No instances. Please select job", [job]);
const noMetricsText = useMemo(() => job ? "" : "No metric names. Please select job", [job]); const noMetricsText = useMemo(() => job ? "" : "No metric names. Please select job", [job]);
const { isMobile } = useDeviceDetect();
return ( return (
<div className="vm-explore-metrics-header vm-block"> <div
className={classNames({
"vm-explore-metrics-header": true,
"vm-explore-metrics-header_mobile": isMobile,
"vm-block": true,
"vm-block_mobile": isMobile,
})}
>
<div className="vm-explore-metrics-header__job"> <div className="vm-explore-metrics-header__job">
<Select <Select
value={job} value={job}
@ -45,6 +55,7 @@ const ExploreMetricsHeader: FC<ExploreMetricsHeaderProps> = ({
placeholder="Please select job" placeholder="Please select job"
onChange={onChangeJob} onChange={onChangeJob}
autofocus={!job} autofocus={!job}
searchable
/> />
</div> </div>
<div className="vm-explore-metrics-header__instance"> <div className="vm-explore-metrics-header__instance">
@ -56,6 +67,7 @@ const ExploreMetricsHeader: FC<ExploreMetricsHeaderProps> = ({
onChange={onChangeInstance} onChange={onChangeInstance}
noOptionsText={noInstanceText} noOptionsText={noInstanceText}
clearable clearable
searchable
/> />
</div> </div>
<div className="vm-explore-metrics-header__size"> <div className="vm-explore-metrics-header__size">
@ -68,12 +80,14 @@ const ExploreMetricsHeader: FC<ExploreMetricsHeaderProps> = ({
</div> </div>
<div className="vm-explore-metrics-header-metrics"> <div className="vm-explore-metrics-header-metrics">
<Select <Select
label={"Metrics"}
value={selectedMetrics} value={selectedMetrics}
list={names} list={names}
placeholder="Search metric name" placeholder="Search metric name"
onChange={onToggleMetric} onChange={onToggleMetric}
noOptionsText={noMetricsText} noOptionsText={noMetricsText}
clearable clearable
searchable
/> />
</div> </div>
</div> </div>

View file

@ -6,17 +6,26 @@
align-items: center; align-items: center;
justify-content: flex-start; justify-content: flex-start;
gap: $padding-global calc($padding-small + 10px); gap: $padding-global calc($padding-small + 10px);
max-width: calc(100vw - var(--scrollbar-width));
&_mobile {
flex-direction: column;
align-items: stretch;
}
&__job { &__job {
flex-grow: 1; flex-grow: 1;
min-width: 150px;
} }
&__instance { &__instance {
flex-grow: 2; flex-grow: 2;
min-width: 150px;
} }
&__size { &__size {
flex-grow: 1; flex-grow: 1;
min-width: 150px;
} }
&-metrics { &-metrics {
@ -35,5 +44,4 @@
opacity: 0.7 opacity: 0.7
} }
} }
} }

View file

@ -2,8 +2,10 @@ import React, { FC } from "preact/compat";
import dayjs from "dayjs"; import dayjs from "dayjs";
import "./style.scss"; import "./style.scss";
import { IssueIcon, LogoIcon, WikiIcon } from "../../Main/Icons"; import { IssueIcon, LogoIcon, WikiIcon } from "../../Main/Icons";
import useDeviceDetect from "../../../hooks/useDeviceDetect";
const Footer: FC = () => { const Footer: FC = () => {
const { isMobile } = useDeviceDetect();
const copyrightYears = `2019-${dayjs().format("YYYY")}`; const copyrightYears = `2019-${dayjs().format("YYYY")}`;
return <footer className="vm-footer"> return <footer className="vm-footer">
@ -23,7 +25,7 @@ const Footer: FC = () => {
rel="help noreferrer" rel="help noreferrer"
> >
<WikiIcon/> <WikiIcon/>
Documentation {isMobile ? "Docs" : "Documentation"}
</a> </a>
<a <a
className="vm-link vm-footer__link" className="vm-link vm-footer__link"
@ -32,7 +34,7 @@ const Footer: FC = () => {
rel="noreferrer" rel="noreferrer"
> >
<IssueIcon/> <IssueIcon/>
Create an issue {isMobile ? "New issue" : "Create an issue"}
</a> </a>
<div className="vm-footer__copyright"> <div className="vm-footer__copyright">
&copy; {copyrightYears} VictoriaMetrics &copy; {copyrightYears} VictoriaMetrics

View file

@ -11,6 +11,11 @@
color: $color-text-secondary; color: $color-text-secondary;
background: $color-background-body; background: $color-background-body;
@media (max-width: 768px) {
padding: $padding-global;
gap: $padding-global;
}
&__link, &__link,
&__website { &__website {
display: grid; display: grid;
@ -25,7 +30,6 @@
@media (max-width: 768px) { @media (max-width: 768px) {
margin-right: 0; margin-right: 0;
width: 100%;
} }
} }
@ -39,6 +43,7 @@
@media (max-width: 768px) { @media (max-width: 768px) {
width: 100%; width: 100%;
font-size: $font-size-small;
text-align: center; text-align: center;
} }
} }

View file

@ -1,32 +1,26 @@
import React, { FC, useMemo } from "preact/compat"; import React, { FC, useMemo } from "preact/compat";
import { ExecutionControls } from "../../Configurators/TimeRangeSettings/ExecutionControls/ExecutionControls"; import { useNavigate } from "react-router-dom";
import { setQueryStringWithoutPageReload } from "../../../utils/query-string"; import router from "../../../router";
import { TimeSelector } from "../../Configurators/TimeRangeSettings/TimeSelector/TimeSelector";
import GlobalSettings from "../../Configurators/GlobalSettings/GlobalSettings";
import { useLocation, useNavigate } from "react-router-dom";
import router, { RouterOptions, routerOptions } from "../../../router";
import ShortcutKeys from "../../Main/ShortcutKeys/ShortcutKeys";
import { getAppModeEnable, getAppModeParams } from "../../../utils/app-mode"; import { getAppModeEnable, getAppModeParams } from "../../../utils/app-mode";
import CardinalityDatePicker from "../../Configurators/CardinalityDatePicker/CardinalityDatePicker";
import { LogoFullIcon } from "../../Main/Icons"; import { LogoFullIcon } from "../../Main/Icons";
import { getCssVariable } from "../../../utils/theme"; import { getCssVariable } from "../../../utils/theme";
import "./style.scss"; import "./style.scss";
import classNames from "classnames"; import classNames from "classnames";
import StepConfigurator from "../../Configurators/StepConfigurator/StepConfigurator";
import { useAppState } from "../../../state/common/StateContext"; import { useAppState } from "../../../state/common/StateContext";
import HeaderNav from "./HeaderNav/HeaderNav"; import HeaderNav from "./HeaderNav/HeaderNav";
import TenantsConfiguration from "../../Configurators/GlobalSettings/TenantsConfiguration/TenantsConfiguration";
import { useFetchAccountIds } from "../../Configurators/GlobalSettings/TenantsConfiguration/hooks/useFetchAccountIds";
import useResize from "../../../hooks/useResize"; import useResize from "../../../hooks/useResize";
import SidebarHeader from "./SidebarNav/SidebarHeader"; import SidebarHeader from "./SidebarNav/SidebarHeader";
import HeaderControls from "./HeaderControls/HeaderControls";
import useDeviceDetect from "../../../hooks/useDeviceDetect";
const Header: FC = () => { const Header: FC = () => {
const { isMobile } = useDeviceDetect();
const windowSize = useResize(document.body); const windowSize = useResize(document.body);
const displaySidebar = useMemo(() => window.innerWidth < 1000, [windowSize]); const displaySidebar = useMemo(() => window.innerWidth < 1000, [windowSize]);
const { isDarkTheme } = useAppState(); const { isDarkTheme } = useAppState();
const appModeEnable = getAppModeEnable(); const appModeEnable = getAppModeEnable();
const { accountIds } = useFetchAccountIds();
const primaryColor = useMemo(() => { const primaryColor = useMemo(() => {
const variable = isDarkTheme ? "color-background-block" : "color-primary"; const variable = isDarkTheme ? "color-background-block" : "color-primary";
@ -43,15 +37,9 @@ const Header: FC = () => {
}, [primaryColor]); }, [primaryColor]);
const navigate = useNavigate(); const navigate = useNavigate();
const { search, pathname } = useLocation();
const headerSetup = useMemo(() => {
return ((routerOptions[pathname] || {}) as RouterOptions).header || {};
}, [pathname]);
const onClickLogo = () => { const onClickLogo = () => {
navigate({ pathname: router.home, search: search }); navigate({ pathname: router.home });
setQueryStringWithoutPageReload({});
window.location.reload(); window.location.reload();
}; };
@ -59,7 +47,8 @@ const Header: FC = () => {
className={classNames({ className={classNames({
"vm-header": true, "vm-header": true,
"vm-header_app": appModeEnable, "vm-header_app": appModeEnable,
"vm-header_dark": isDarkTheme "vm-header_dark": isDarkTheme,
"vm-header_mobile": isMobile
})} })}
style={{ background, color }} style={{ background, color }}
> >
@ -67,7 +56,6 @@ const Header: FC = () => {
<SidebarHeader <SidebarHeader
background={background} background={background}
color={color} color={color}
onClickLogo={onClickLogo}
/> />
) : ( ) : (
<> <>
@ -86,15 +74,19 @@ const Header: FC = () => {
/> />
</> </>
)} )}
<div className="vm-header__settings"> {isMobile && (
{headerSetup?.tenant && <TenantsConfiguration accountIds={accountIds}/>} <div
{headerSetup?.stepControl && <StepConfigurator/>} className="vm-header-logo vm-header-logo_mobile"
{headerSetup?.timeSelector && <TimeSelector/>} onClick={onClickLogo}
{headerSetup?.cardinalityDatePicker && <CardinalityDatePicker/>} style={{ color }}
{headerSetup?.executionControls && <ExecutionControls/>} >
{!displaySidebar && <GlobalSettings/>} <LogoFullIcon/>
{!displaySidebar && <ShortcutKeys/>} </div>
</div> )}
<HeaderControls
displaySidebar={displaySidebar}
isMobile={isMobile}
/>
</header>; </header>;
}; };

View file

@ -0,0 +1,107 @@
import React, { FC, useMemo, useState } from "preact/compat";
import { RouterOptions, routerOptions, RouterOptionsHeader } from "../../../../router";
import TenantsConfiguration from "../../../Configurators/GlobalSettings/TenantsConfiguration/TenantsConfiguration";
import StepConfigurator from "../../../Configurators/StepConfigurator/StepConfigurator";
import { TimeSelector } from "../../../Configurators/TimeRangeSettings/TimeSelector/TimeSelector";
import CardinalityDatePicker from "../../../Configurators/CardinalityDatePicker/CardinalityDatePicker";
import { ExecutionControls } from "../../../Configurators/TimeRangeSettings/ExecutionControls/ExecutionControls";
import GlobalSettings from "../../../Configurators/GlobalSettings/GlobalSettings";
import ShortcutKeys from "../../../Main/ShortcutKeys/ShortcutKeys";
import { useLocation } from "react-router-dom";
import { useFetchAccountIds } from "../../../Configurators/GlobalSettings/TenantsConfiguration/hooks/useFetchAccountIds";
import Button from "../../../Main/Button/Button";
import { MoreIcon } from "../../../Main/Icons";
import "./style.scss";
import classNames from "classnames";
import { getAppModeEnable } from "../../../../utils/app-mode";
import Modal from "../../../Main/Modal/Modal";
interface HeaderControlsProp {
displaySidebar: boolean
isMobile?: boolean
headerSetup?: RouterOptionsHeader
accountIds?: string[]
}
const Controls: FC<HeaderControlsProp> = ({
displaySidebar,
isMobile,
headerSetup,
accountIds
}) => {
return (
<div
className={classNames({
"vm-header-controls": true,
"vm-header-controls_mobile": isMobile,
})}
>
{headerSetup?.tenant && <TenantsConfiguration accountIds={accountIds || []}/>}
{headerSetup?.stepControl && <StepConfigurator/>}
{headerSetup?.timeSelector && <TimeSelector/>}
{headerSetup?.cardinalityDatePicker && <CardinalityDatePicker/>}
{headerSetup?.executionControls && <ExecutionControls/>}
<GlobalSettings/>
{!displaySidebar && <ShortcutKeys/>}
</div>
);
};
const HeaderControls: FC<HeaderControlsProp> = (props) => {
const appModeEnable = getAppModeEnable();
const [openList, setOpenList] = useState(false);
const { pathname } = useLocation();
const { accountIds } = useFetchAccountIds();
const headerSetup = useMemo(() => {
return ((routerOptions[pathname] || {}) as RouterOptions).header || {};
}, [pathname]);
const handleToggleList = () => {
setOpenList(prev => !prev);
};
const handleCloseList = () => {
setOpenList(false);
};
if (props.isMobile) {
return (
<>
<div>
<Button
className={classNames({
"vm-header-button": !appModeEnable
})}
startIcon={<MoreIcon/>}
onClick={handleToggleList}
/>
</div>
<Modal
title={"Controls"}
onClose={handleCloseList}
isOpen={openList}
className={classNames({
"vm-header-controls-modal": true,
"vm-header-controls-modal_open": openList,
})}
>
<Controls
{...props}
accountIds={accountIds}
headerSetup={headerSetup}
/>
</Modal>
</>
);
}
return <Controls
{...props}
accountIds={accountIds}
headerSetup={headerSetup}
/>;
};
export default HeaderControls;

View file

@ -0,0 +1,27 @@
@use "src/styles/variables" as *;
.vm-header-controls {
display: flex;
align-items: center;
justify-content: flex-end;
gap: $padding-small;
flex-grow: 1;
&_mobile {
display: grid;
grid-template-columns: 1fr;
padding: 0;
.vm-header-button {
border: none;
}
}
&-modal {
transform: scale(0);
&_open {
transform: scale(1);
}
}
}

View file

@ -1,8 +1,6 @@
import React, { FC, useEffect, useRef, useState } from "preact/compat"; import React, { FC, useEffect, useRef, useState } from "preact/compat";
import GlobalSettings from "../../../Configurators/GlobalSettings/GlobalSettings";
import { useLocation } from "react-router-dom"; import { useLocation } from "react-router-dom";
import ShortcutKeys from "../../../Main/ShortcutKeys/ShortcutKeys"; import ShortcutKeys from "../../../Main/ShortcutKeys/ShortcutKeys";
import { LogoFullIcon } from "../../../Main/Icons";
import classNames from "classnames"; import classNames from "classnames";
import HeaderNav from "../HeaderNav/HeaderNav"; import HeaderNav from "../HeaderNav/HeaderNav";
import useClickOutside from "../../../../hooks/useClickOutside"; import useClickOutside from "../../../../hooks/useClickOutside";
@ -13,13 +11,11 @@ import "./style.scss";
interface SidebarHeaderProps { interface SidebarHeaderProps {
background: string background: string
color: string color: string
onClickLogo: () => void
} }
const SidebarHeader: FC<SidebarHeaderProps> = ({ const SidebarHeader: FC<SidebarHeaderProps> = ({
background, background,
color, color,
onClickLogo,
}) => { }) => {
const { pathname } = useLocation(); const { pathname } = useLocation();
const { isMobile } = useDeviceDetect(); const { isMobile } = useDeviceDetect();
@ -48,11 +44,9 @@ const SidebarHeader: FC<SidebarHeaderProps> = ({
"vm-header-sidebar-button": true, "vm-header-sidebar-button": true,
"vm-header-sidebar-button_open": openMenu "vm-header-sidebar-button_open": openMenu
})} })}
onClick={handleToggleMenu}
> >
<MenuBurger <MenuBurger open={openMenu}/>
open={openMenu}
onClick={handleToggleMenu}
/>
</div> </div>
<div <div
className={classNames({ className={classNames({
@ -60,13 +54,6 @@ const SidebarHeader: FC<SidebarHeaderProps> = ({
"vm-header-sidebar-menu_open": openMenu "vm-header-sidebar-menu_open": openMenu
})} })}
> >
<div
className="vm-header-sidebar-menu__logo"
onClick={onClickLogo}
style={{ color }}
>
<LogoFullIcon/>
</div>
<div> <div>
<HeaderNav <HeaderNav
color={color} color={color}
@ -75,7 +62,6 @@ const SidebarHeader: FC<SidebarHeaderProps> = ({
/> />
</div> </div>
<div className="vm-header-sidebar-menu-settings"> <div className="vm-header-sidebar-menu-settings">
<GlobalSettings showTitle={true}/>
{!isMobile && <ShortcutKeys showTitle={true}/>} {!isMobile && <ShortcutKeys showTitle={true}/>}
</div> </div>
</div> </div>

View file

@ -1,5 +1,7 @@
@use "src/styles/variables" as *; @use "src/styles/variables" as *;
$sidebar-transition: cubic-bezier(0.280, 0.840, 0.420, 1);
.vm-header-sidebar { .vm-header-sidebar {
width: 24px; width: 24px;
height: 24px; height: 24px;
@ -7,14 +9,19 @@
background-color: inherit; background-color: inherit;
&-button { &-button {
display: flex;
align-items: center;
justify-content: center;
position: absolute; position: absolute;
left: $padding-global; left: 0;
top: $padding-global; top: 0;
transition: left 300ms cubic-bezier(0.280, 0.840, 0.420, 1); height: 51px;
width: 51px;
transition: left 350ms $sidebar-transition;
&_open { &_open {
position: fixed; position: fixed;
left: calc(182px - $padding-global); left: 149px;
z-index: 102; z-index: 102;
} }
} }
@ -26,14 +33,14 @@
display: grid; display: grid;
gap: $padding-global; gap: $padding-global;
padding: $padding-global; padding: $padding-global;
grid-template-rows: auto 1fr auto; grid-template-rows: 1fr auto;
width: 200px; width: 200px;
height: 100%; height: 100%;
background-color: inherit; background-color: inherit;
z-index: 101; z-index: 101;
transform-origin: left; transform-origin: left;
transform: translateX(-100%); transform: translateX(-100%);
transition: transform 300ms cubic-bezier(0.280, 0.840, 0.420, 1); transition: transform 300ms $sidebar-transition;
box-shadow: $box-shadow-popper; box-shadow: $box-shadow-popper;
&_open { &_open {

View file

@ -21,6 +21,12 @@
padding: $padding-small; padding: $padding-small;
} }
&_mobile {
display: grid;
grid-template-columns: 33px 1fr 33px;
justify-content: space-between;
}
&_dark { &_dark {
.vm-header-button, .vm-header-button,
button:before, button:before,
@ -50,18 +56,16 @@
max-width: 65px; max-width: 65px;
min-width: 65px; min-width: 65px;
} }
&_mobile {
max-width: 65px;
min-width: 65px;
margin: 0 auto;
}
} }
&-nav { &-nav {
font-size: $font-size-small; font-size: $font-size-small;
font-weight: bold; font-weight: bold;
} }
&__settings {
display: flex;
align-items: center;
justify-content: flex-end;
gap: $padding-small;
flex-grow: 1;
}
} }

View file

@ -7,9 +7,11 @@ import classNames from "classnames";
import Footer from "./Footer/Footer"; import Footer from "./Footer/Footer";
import { routerOptions } from "../../router"; import { routerOptions } from "../../router";
import { useFetchDashboards } from "../../pages/PredefinedPanels/hooks/useFetchDashboards"; import { useFetchDashboards } from "../../pages/PredefinedPanels/hooks/useFetchDashboards";
import useDeviceDetect from "../../hooks/useDeviceDetect";
const Layout: FC = () => { const Layout: FC = () => {
const appModeEnable = getAppModeEnable(); const appModeEnable = getAppModeEnable();
const { isMobile } = useDeviceDetect();
useFetchDashboards(); useFetchDashboards();
const { pathname } = useLocation(); const { pathname } = useLocation();
@ -24,6 +26,7 @@ const Layout: FC = () => {
<div <div
className={classNames({ className={classNames({
"vm-container-body": true, "vm-container-body": true,
"vm-container-body_mobile": isMobile,
"vm-container-body_app": appModeEnable "vm-container-body_app": appModeEnable
})} })}
> >

View file

@ -11,8 +11,12 @@
padding: $padding-medium; padding: $padding-medium;
background-color: $color-background-body; background-color: $color-background-body;
&_mobile {
padding: $padding-small 0 0;
}
@media (max-width: 768px) { @media (max-width: 768px) {
padding: 0; padding: $padding-small 0 0;
} }
&_app { &_app {

View file

@ -4,6 +4,7 @@ import classNames from "classnames";
import { ErrorIcon, InfoIcon, SuccessIcon, WarningIcon } from "../Icons"; import { ErrorIcon, InfoIcon, SuccessIcon, WarningIcon } from "../Icons";
import "./style.scss"; import "./style.scss";
import { useAppState } from "../../../state/common/StateContext"; import { useAppState } from "../../../state/common/StateContext";
import useDeviceDetect from "../../../hooks/useDeviceDetect";
interface AlertProps { interface AlertProps {
variant?: "success" | "error" | "info" | "warning" variant?: "success" | "error" | "info" | "warning"
@ -21,13 +22,15 @@ const Alert: FC<AlertProps> = ({
variant, variant,
children }) => { children }) => {
const { isDarkTheme } = useAppState(); const { isDarkTheme } = useAppState();
const { isMobile } = useDeviceDetect();
return ( return (
<div <div
className={classNames({ className={classNames({
"vm-alert": true, "vm-alert": true,
[`vm-alert_${variant}`]: variant, [`vm-alert_${variant}`]: variant,
"vm-alert_dark": isDarkTheme "vm-alert_dark": isDarkTheme,
"vm-alert_mobile": isMobile
})} })}
> >
<div className="vm-alert__icon">{icons[variant || "info"]}</div> <div className="vm-alert__icon">{icons[variant || "info"]}</div>

View file

@ -15,6 +15,11 @@
color: $color-text; color: $color-text;
line-height: 20px; line-height: 20px;
&_mobile {
align-items: flex-start;
border-radius: 0;
}
&:after { &:after {
position: absolute; position: absolute;
content: ''; content: '';
@ -27,6 +32,10 @@
opacity: 0.1; opacity: 0.1;
} }
&_mobile:after {
border-radius: 0;
}
&__icon, &__icon,
&__content { &__content {
position: relative; position: relative;
@ -48,8 +57,8 @@
color: $color-success; color: $color-success;
&:after { &:after {
background-color: $color-success; background-color: $color-success;
} }
} }
&_error { &_error {

View file

@ -1,9 +1,9 @@
import React, { FC, Ref, useEffect, useMemo, useRef, useState } from "preact/compat"; import React, { FC, Ref, useEffect, useMemo, useRef, useState } from "preact/compat";
import classNames from "classnames"; import classNames from "classnames";
import useClickOutside from "../../../hooks/useClickOutside";
import Popper from "../Popper/Popper"; import Popper from "../Popper/Popper";
import "./style.scss"; import "./style.scss";
import { DoneIcon } from "../Icons"; import { DoneIcon } from "../Icons";
import useDeviceDetect from "../../../hooks/useDeviceDetect";
interface AutocompleteProps { interface AutocompleteProps {
value: string value: string
@ -15,7 +15,9 @@ interface AutocompleteProps {
fullWidth?: boolean fullWidth?: boolean
noOptionsText?: string noOptionsText?: string
selected?: string[] selected?: string[]
onSelect: (val: string) => void, label?: string
disabledFullScreen?: boolean
onSelect: (val: string) => void
onOpenAutocomplete?: (val: boolean) => void onOpenAutocomplete?: (val: boolean) => void
} }
@ -29,9 +31,12 @@ const Autocomplete: FC<AutocompleteProps> = ({
fullWidth, fullWidth,
selected, selected,
noOptionsText, noOptionsText,
label,
disabledFullScreen,
onSelect, onSelect,
onOpenAutocomplete onOpenAutocomplete
}) => { }) => {
const { isMobile } = useDeviceDetect();
const wrapperEl = useRef<HTMLDivElement>(null); const wrapperEl = useRef<HTMLDivElement>(null);
const [openAutocomplete, setOpenAutocomplete] = useState(false); const [openAutocomplete, setOpenAutocomplete] = useState(false);
@ -118,8 +123,6 @@ const Autocomplete: FC<AutocompleteProps> = ({
onOpenAutocomplete && onOpenAutocomplete(openAutocomplete); onOpenAutocomplete && onOpenAutocomplete(openAutocomplete);
}, [openAutocomplete]); }, [openAutocomplete]);
useClickOutside(wrapperEl, handleCloseAutocomplete, anchor);
return ( return (
<Popper <Popper
open={openAutocomplete} open={openAutocomplete}
@ -127,9 +130,14 @@ const Autocomplete: FC<AutocompleteProps> = ({
placement="bottom-left" placement="bottom-left"
onClose={handleCloseAutocomplete} onClose={handleCloseAutocomplete}
fullWidth={fullWidth} fullWidth={fullWidth}
title={isMobile ? label : undefined}
disabledFullScreen={disabledFullScreen}
> >
<div <div
className="vm-autocomplete" className={classNames({
"vm-autocomplete": true,
"vm-autocomplete_mobile": isMobile && !disabledFullScreen,
})}
ref={wrapperEl} ref={wrapperEl}
> >
{displayNoOptionsText && <div className="vm-autocomplete__no-options">{noOptionsText}</div>} {displayNoOptionsText && <div className="vm-autocomplete__no-options">{noOptionsText}</div>}
@ -137,6 +145,7 @@ const Autocomplete: FC<AutocompleteProps> = ({
<div <div
className={classNames({ className={classNames({
"vm-list-item": true, "vm-list-item": true,
"vm-list-item_mobile": isMobile,
"vm-list-item_active": i === focusOption, "vm-list-item_active": i === focusOption,
"vm-list-item_multiselect": selected, "vm-list-item_multiselect": selected,
"vm-list-item_multiselect_selected": selected?.includes(option) "vm-list-item_multiselect_selected": selected?.includes(option)

View file

@ -3,6 +3,11 @@
.vm-autocomplete { .vm-autocomplete {
max-height: 300px; max-height: 300px;
overflow: auto; overflow: auto;
overscroll-behavior: none;
&_mobile {
max-height: calc(($vh * 100) - 70px);
}
&__no-options { &__no-options {
padding: $padding-global; padding: $padding-global;

View file

@ -93,6 +93,7 @@ $button-radius: 6px;
/* variant CONTAINED */ /* variant CONTAINED */
&_contained_primary { &_contained_primary {
color: $color-primary-text; color: $color-primary-text;
background-color: $color-primary;
&:before { &:before {
background-color: $color-primary; background-color: $color-primary;

View file

@ -8,6 +8,8 @@ import { DATE_TIME_FORMAT } from "../../../../constants/date";
import "./style.scss"; import "./style.scss";
import { CalendarIcon, ClockIcon } from "../../Icons"; import { CalendarIcon, ClockIcon } from "../../Icons";
import Tabs from "../../Tabs/Tabs"; import Tabs from "../../Tabs/Tabs";
import useDeviceDetect from "../../../../hooks/useDeviceDetect";
import classNames from "classnames";
interface DatePickerProps { interface DatePickerProps {
date: Date | Dayjs date: Date | Dayjs
@ -33,6 +35,7 @@ const Calendar: FC<DatePickerProps> = ({
const [viewDate, setViewDate] = useState(dayjs.tz(date)); const [viewDate, setViewDate] = useState(dayjs.tz(date));
const [selectDate, setSelectDate] = useState(dayjs.tz(date)); const [selectDate, setSelectDate] = useState(dayjs.tz(date));
const [tab, setTab] = useState(tabs[0].value); const [tab, setTab] = useState(tabs[0].value);
const { isMobile } = useDeviceDetect();
const toggleDisplayYears = () => { const toggleDisplayYears = () => {
setDisplayYears(prev => !prev); setDisplayYears(prev => !prev);
@ -67,7 +70,12 @@ const Calendar: FC<DatePickerProps> = ({
}, [selectDate]); }, [selectDate]);
return ( return (
<div className="vm-calendar"> <div
className={classNames({
"vm-calendar": true,
"vm-calendar_mobile": isMobile,
})}
>
{tab === "date" && ( {tab === "date" && (
<CalendarHeader <CalendarHeader
viewDate={viewDate} viewDate={viewDate}

View file

@ -9,6 +9,10 @@
background-color: $color-background-block; background-color: $color-background-block;
border-radius: $border-radius-medium; border-radius: $border-radius-medium;
&_mobile {
padding: 0 $padding-global;
}
&__tabs { &__tabs {
margin: 0 0-$padding-global 0-$padding-global; margin: 0 0-$padding-global 0-$padding-global;
border-top: $border-divider; border-top: $border-divider;
@ -61,6 +65,8 @@
&__prev, &__prev,
&__next { &__next {
margin: -8px;
padding: 8px;
cursor: pointer; cursor: pointer;
transition: opacity 200ms ease-in-out; transition: opacity 200ms ease-in-out;
@ -87,6 +93,11 @@
justify-content: center; justify-content: center;
gap: 2px; gap: 2px;
@media (max-width: 500px) {
grid-template-columns: repeat(7, calc((100vw - ($padding-global * 2) - (6 * 2px))/7));
grid-template-rows: repeat(6, calc((100vw - ($padding-global * 2) - (5 * 2px))/7));
}
&-cell { &-cell {
display: flex; display: flex;
align-items: center; align-items: center;

View file

@ -3,12 +3,14 @@ import Calendar from "../../Main/DatePicker/Calendar/Calendar";
import dayjs, { Dayjs } from "dayjs"; import dayjs, { Dayjs } from "dayjs";
import Popper from "../../Main/Popper/Popper"; import Popper from "../../Main/Popper/Popper";
import { DATE_TIME_FORMAT } from "../../../constants/date"; import { DATE_TIME_FORMAT } from "../../../constants/date";
import useDeviceDetect from "../../../hooks/useDeviceDetect";
interface DatePickerProps { interface DatePickerProps {
date: string | Date | Dayjs, date: string | Date | Dayjs,
targetRef: Ref<HTMLElement> targetRef: Ref<HTMLElement>
format?: string format?: string
timepicker?: boolean timepicker?: boolean
label?: string
onChange: (val: string) => void onChange: (val: string) => void
} }
@ -18,9 +20,11 @@ const DatePicker = forwardRef<HTMLDivElement, DatePickerProps>(({
format = DATE_TIME_FORMAT, format = DATE_TIME_FORMAT,
timepicker, timepicker,
onChange, onChange,
label
}, ref) => { }, ref) => {
const [openCalendar, setOpenCalendar] = useState(false); const [openCalendar, setOpenCalendar] = useState(false);
const dateDayjs = useMemo(() => date ? dayjs.tz(date) : dayjs().tz(), [date]); const dateDayjs = useMemo(() => date ? dayjs.tz(date) : dayjs().tz(), [date]);
const { isMobile } = useDeviceDetect();
const toggleOpenCalendar = () => { const toggleOpenCalendar = () => {
setOpenCalendar(prev => !prev); setOpenCalendar(prev => !prev);
@ -61,6 +65,7 @@ const DatePicker = forwardRef<HTMLDivElement, DatePickerProps>(({
buttonRef={targetRef} buttonRef={targetRef}
placement="bottom-right" placement="bottom-right"
onClose={handleCloseCalendar} onClose={handleCloseCalendar}
title={isMobile ? label : undefined}
> >
<div ref={ref}> <div ref={ref}>
<Calendar <Calendar

View file

@ -125,17 +125,6 @@ export const ArrowDropDownIcon = () => (
</svg> </svg>
); );
export const PlusCircleFillIcon = () => (
<svg
viewBox="0 0 24 24"
fill="currentColor"
>
<path
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm5 11h-4v4h-2v-4H7v-2h4V7h2v4h4v2z"
></path>
</svg>
);
export const ClockIcon = () => ( export const ClockIcon = () => (
<svg <svg
viewBox="0 0 24 24" viewBox="0 0 24 24"
@ -181,15 +170,6 @@ export const KeyboardIcon = () => (
</svg> </svg>
); );
export const RemoveCircleIcon = () => (
<svg
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm5 11H7v-2h10v2z"></path>
</svg>
);
export const PlayIcon = () => ( export const PlayIcon = () => (
<svg <svg
viewBox="0 0 24 24" viewBox="0 0 24 24"
@ -257,6 +237,15 @@ export const PlusIcon = () => (
</svg> </svg>
); );
export const MinusIcon = () => (
<svg
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M19 13H5v-2h14v2z"></path>
</svg>
);
export const DoneIcon = () => ( export const DoneIcon = () => (
<svg <svg
viewBox="0 0 24 24" viewBox="0 0 24 24"
@ -310,30 +299,6 @@ export const DragIcon = () => (
</svg> </svg>
); );
export const SearchIcon = () => (
<svg
viewBox="0 0 24 24"
fill="currentColor"
>
<path
d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"
></path>
</svg>
);
export const ResizeIcon = () => (
<svg
className="MuiSvgIcon-root MuiSvgIcon-fontSizeMedium MuiBox-root css-1om0hkc"
focusable="false"
aria-hidden="true"
viewBox="0 0 24 24"
data-testid="OpenInFullIcon"
fill="currentColor"
>
<path d="M21 11V3h-8l3.29 3.29-10 10L3 13v8h8l-3.29-3.29 10-10z"></path>
</svg>
);
export const TimelineIcon = () => ( export const TimelineIcon = () => (
<svg <svg
viewBox="0 0 24 24" viewBox="0 0 24 24"
@ -401,13 +366,24 @@ export const StorageIcon = () => (
</svg> </svg>
); );
export const MenuIcon = () => ( export const MoreIcon = () => (
<svg <svg
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="currentColor" fill="currentColor"
> >
<path <path
d="M4 18h16c.55 0 1-.45 1-1s-.45-1-1-1H4c-.55 0-1 .45-1 1s.45 1 1 1zm0-5h16c.55 0 1-.45 1-1s-.45-1-1-1H4c-.55 0-1 .45-1 1s.45 1 1 1zM3 7c0 .55.45 1 1 1h16c.55 0 1-.45 1-1s-.45-1-1-1H4c-.55 0-1 .45-1 1z" d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"
></path>
</svg>
);
export const TuneIcon = () => (
<svg
viewBox="0 0 24 24"
fill="currentColor"
>
<path
d="M3 17v2h6v-2H3zM3 5v2h10V5H3zm10 16v-2h8v-2h-8v-2h-2v6h2zM7 9v2H3v2h4v2h2V9H7zm14 4v-2H11v2h10zm-6-4h2V7h4V5h-4V3h-2v6z"
></path> ></path>
</svg> </svg>
); );

View file

@ -2,13 +2,12 @@ import React from "preact/compat";
import classNames from "classnames"; import classNames from "classnames";
import "./style.scss"; import "./style.scss";
const MenuBurger = ({ open, onClick }: {open: boolean, onClick: () => void}) => ( const MenuBurger = ({ open }: {open: boolean}) => (
<button <button
className={classNames({ className={classNames({
"vm-menu-burger": true, "vm-menu-burger": true,
"vm-menu-burger_opened": open "vm-menu-burger_opened": open
})} })}
onClick={onClick}
> >
<span></span> <span></span>
</button> </button>

View file

@ -6,15 +6,26 @@ import { ReactNode, MouseEvent } from "react";
import "./style.scss"; import "./style.scss";
import useDeviceDetect from "../../../hooks/useDeviceDetect"; import useDeviceDetect from "../../../hooks/useDeviceDetect";
import classNames from "classnames"; import classNames from "classnames";
import { useLocation, useNavigate } from "react-router-dom";
interface ModalProps { interface ModalProps {
title?: string title?: string
children: ReactNode children: ReactNode
onClose: () => void onClose: () => void
className?: string
isOpen?: boolean
} }
const Modal: FC<ModalProps> = ({ title, children, onClose }) => { const Modal: FC<ModalProps> = ({
title,
children,
onClose,
className,
isOpen= true
}) => {
const { isMobile } = useDeviceDetect(); const { isMobile } = useDeviceDetect();
const navigate = useNavigate();
const location = useLocation();
const handleKeyUp = (e: KeyboardEvent) => { const handleKeyUp = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose(); if (e.key === "Escape") onClose();
@ -24,7 +35,23 @@ const Modal: FC<ModalProps> = ({ title, children, onClose }) => {
e.stopPropagation(); e.stopPropagation();
}; };
const handlePopstate = () => {
if (isOpen) {
navigate(location, { replace: true });
onClose();
}
};
useEffect(() => { useEffect(() => {
window.addEventListener("popstate", handlePopstate);
return () => {
window.removeEventListener("popstate", handlePopstate);
};
}, [isOpen, location]);
const handleDisplayModal = () => {
if (!isOpen) return;
document.body.style.overflow = "hidden"; document.body.style.overflow = "hidden";
window.addEventListener("keyup", handleKeyUp); window.addEventListener("keyup", handleKeyUp);
@ -32,18 +59,24 @@ const Modal: FC<ModalProps> = ({ title, children, onClose }) => {
document.body.style.overflow = "auto"; document.body.style.overflow = "auto";
window.removeEventListener("keyup", handleKeyUp); window.removeEventListener("keyup", handleKeyUp);
}; };
}, []); };
useEffect(handleDisplayModal, [isOpen]);
return ReactDOM.createPortal(( return ReactDOM.createPortal((
<div <div
className={classNames({ className={classNames({
"vm-modal": true, "vm-modal": true,
"vm-modal_mobile": isMobile "vm-modal_mobile": isMobile,
[`${className}`]: className
})} })}
onMouseDown={onClose} onMouseDown={onClose}
> >
<div className="vm-modal-content"> <div className="vm-modal-content">
<div className="vm-modal-content-header"> <div
className="vm-modal-content-header"
onMouseDown={handleMouseDown}
>
{title && ( {title && (
<div className="vm-modal-content-header__title"> <div className="vm-modal-content-header__title">
{title} {title}

View file

@ -14,16 +14,31 @@ $padding-modal: 22px;
justify-content: center; justify-content: center;
background: rgba($color-black, 0.55); background: rgba($color-black, 0.55);
&_mobile &-content { &_mobile {
align-items: flex-start;
min-height: calc($vh * 100); min-height: calc($vh * 100);
max-height: calc($vh * 100); max-height: calc($vh * 100);
overflow: auto;
}
&_mobile &-content {
width: 100vw; width: 100vw;
border-radius: 0; border-radius: 0;
overflow: visible;
min-height: 100%;
max-height: max-content;
grid-template-rows: 70px max-content;
&-header {
padding: $padding-small $padding-small $padding-small $padding-global;
margin-bottom: $padding-global;
}
&-body { &-body {
display: grid; display: grid;
align-items: flex-start; align-items: flex-start;
min-height: 100%; min-height: 100%;
padding: 0 $padding-global $padding-modal;
} }
} }
@ -31,7 +46,6 @@ $padding-modal: 22px;
display: grid; display: grid;
grid-template-rows: auto 1fr; grid-template-rows: auto 1fr;
align-items: flex-start; align-items: flex-start;
padding: $padding-modal;
background: $color-background-block; background: $color-background-block;
box-shadow: 0 0 24px rgba($color-black, 0.07); box-shadow: 0 0 24px rgba($color-black, 0.07);
border-radius: $border-radius-small; border-radius: $border-radius-small;
@ -39,14 +53,25 @@ $padding-modal: 22px;
overflow: auto; overflow: auto;
&-header { &-header {
position: sticky;
top: 0;
display: grid; display: grid;
grid-template-columns: 1fr auto; grid-template-columns: 1fr auto;
gap: $padding-small;
align-items: center; align-items: center;
margin-bottom: $padding-modal ; justify-content: space-between;
background-color: $color-background-block;
padding: $padding-global $padding-modal;
border-radius: $border-radius-small $border-radius-small 0 0;
color: $color-text;
border-bottom: $border-divider;
margin-bottom: $padding-modal;
min-height: 51px;
z-index: 3;
&__title { &__title {
font-weight: bold; font-weight: bold;
font-size: $font-size-medium; user-select: none;
} }
&__close { &__close {
@ -61,7 +86,9 @@ $padding-modal: 22px;
} }
} }
&-body {} &-body {
padding: 0 $padding-modal $padding-modal;
}
} }
} }

View file

@ -1,8 +1,12 @@
import React, { FC, ReactNode, useEffect, useMemo, useRef, useState } from "react"; import React, { FC, MouseEvent as ReactMouseEvent, ReactNode, useEffect, useMemo, useRef, useState } from "react";
import classNames from "classnames"; import classNames from "classnames";
import ReactDOM from "react-dom"; import ReactDOM from "react-dom";
import "./style.scss"; import "./style.scss";
import useClickOutside from "../../../hooks/useClickOutside"; import useClickOutside from "../../../hooks/useClickOutside";
import useDeviceDetect from "../../../hooks/useDeviceDetect";
import Button from "../Button/Button";
import { CloseIcon } from "../Icons";
import { useLocation, useNavigate } from "react-router-dom";
interface PopperProps { interface PopperProps {
children: ReactNode children: ReactNode
@ -14,6 +18,8 @@ interface PopperProps {
offset?: {top: number, left: number} offset?: {top: number, left: number}
clickOutside?: boolean, clickOutside?: boolean,
fullWidth?: boolean fullWidth?: boolean
title?: string
disabledFullScreen?: boolean
} }
const Popper: FC<PopperProps> = ({ const Popper: FC<PopperProps> = ({
@ -24,10 +30,14 @@ const Popper: FC<PopperProps> = ({
onClose, onClose,
offset = { top: 6, left: 0 }, offset = { top: 6, left: 0 },
clickOutside = true, clickOutside = true,
fullWidth fullWidth,
title,
disabledFullScreen
}) => { }) => {
const { isMobile } = useDeviceDetect();
const [isOpen, setIsOpen] = useState(true); const navigate = useNavigate();
const location = useLocation();
const [isOpen, setIsOpen] = useState(false);
const [popperSize, setPopperSize] = useState({ width: 0, height: 0 }); const [popperSize, setPopperSize] = useState({ width: 0, height: 0 });
const popperRef = useRef<HTMLDivElement>(null); const popperRef = useRef<HTMLDivElement>(null);
@ -50,6 +60,13 @@ const Popper: FC<PopperProps> = ({
useEffect(() => { useEffect(() => {
if (!isOpen && onClose) onClose(); if (!isOpen && onClose) onClose();
if (isOpen && isMobile && !disabledFullScreen) {
document.body.style.overflow = "hidden";
}
return () => {
document.body.style.overflow = "auto";
};
}, [isOpen]); }, [isOpen]);
useEffect(() => { useEffect(() => {
@ -63,7 +80,7 @@ const Popper: FC<PopperProps> = ({
const popperStyle = useMemo(() => { const popperStyle = useMemo(() => {
const buttonEl = buttonRef.current; const buttonEl = buttonRef.current;
if (!buttonEl|| !isOpen) return {}; if (!buttonEl || !isOpen) return {};
const buttonPos = buttonEl.getBoundingClientRect(); const buttonPos = buttonEl.getBoundingClientRect();
@ -104,28 +121,63 @@ const Popper: FC<PopperProps> = ({
return position; return position;
},[buttonRef, placement, isOpen, children, fullWidth]); },[buttonRef, placement, isOpen, children, fullWidth]);
const handleClickClose = (e: ReactMouseEvent<HTMLButtonElement, MouseEvent>) => {
e.stopPropagation();
onClose();
};
if (clickOutside) useClickOutside(popperRef, () => setIsOpen(false), buttonRef); if (clickOutside) useClickOutside(popperRef, () => setIsOpen(false), buttonRef);
useEffect(() => { useEffect(() => {
if (!popperRef.current || !isOpen) return; if (!popperRef.current || !isOpen || (isMobile && !disabledFullScreen)) return;
const { right, width } = popperRef.current.getBoundingClientRect(); const { right, width } = popperRef.current.getBoundingClientRect();
if (right > window.innerWidth) popperRef.current.style.left = `${window.innerWidth - 20 -width}px`; if (right > window.innerWidth) {
const left = window.innerWidth - 20 - width;
popperRef.current.style.left = left < window.innerWidth ? "0" : `${left}px`;
}
}, [isOpen, popperRef]); }, [isOpen, popperRef]);
const handlePopstate = () => {
if (isOpen && isMobile && !disabledFullScreen) {
navigate(location, { replace: true });
onClose();
}
};
useEffect(() => {
window.addEventListener("popstate", handlePopstate);
return () => {
window.removeEventListener("popstate", handlePopstate);
};
}, [isOpen, isMobile, disabledFullScreen, location]);
const popperClasses = classNames({ const popperClasses = classNames({
"vm-popper": true, "vm-popper": true,
"vm-popper_open": isOpen, "vm-popper_mobile": isMobile && !disabledFullScreen,
"vm-popper_open": (isMobile || Object.keys(popperStyle).length) && isOpen,
}); });
return ( return (
<> <>
{isOpen && ReactDOM.createPortal(( {(isOpen || !popperSize.width) && ReactDOM.createPortal((
<div <div
className={popperClasses} className={popperClasses}
ref={popperRef} ref={popperRef}
style={popperStyle} style={(isMobile && !disabledFullScreen) ? {} : popperStyle}
> >
{(title || (isMobile && !disabledFullScreen)) && (
<div className="vm-popper-header">
<p className="vm-popper-header__title">{title}</p>
<Button
variant="text"
size="small"
onClick={handleClickClose}
>
<CloseIcon/>
</Button>
</div>
)}
{children} {children}
</div>), document.body)} </div>), document.body)}
</> </>

View file

@ -17,6 +17,38 @@
animation: vm-slider 150ms cubic-bezier(0.280, 0.840, 0.420, 1.1); animation: vm-slider 150ms cubic-bezier(0.280, 0.840, 0.420, 1.1);
pointer-events: auto; pointer-events: auto;
} }
&_mobile {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
width: 100%;
border-radius: 0;
overflow: auto;
animation: none;
}
&-header {
display: grid;
grid-template-columns: 1fr auto;
gap: $padding-small;
align-items: center;
justify-content: space-between;
background-color: $color-background-block;
padding: $padding-small $padding-small $padding-small $padding-global;
border-radius: $border-radius-small $border-radius-small 0 0;
color: $color-text;
border-bottom: $border-divider;
margin-bottom: $padding-global;
min-height: 51px;
&__title {
font-weight: bold;
user-select: none;
}
}
} }
@keyframes vm-slider { @keyframes vm-slider {

View file

@ -5,6 +5,7 @@ import { FormEvent, MouseEvent } from "react";
import Autocomplete from "../Autocomplete/Autocomplete"; import Autocomplete from "../Autocomplete/Autocomplete";
import { useAppState } from "../../../state/common/StateContext"; import { useAppState } from "../../../state/common/StateContext";
import "./style.scss"; import "./style.scss";
import useDeviceDetect from "../../../hooks/useDeviceDetect";
interface SelectProps { interface SelectProps {
value: string | string[] value: string | string[]
@ -13,6 +14,7 @@ interface SelectProps {
placeholder?: string placeholder?: string
noOptionsText?: string noOptionsText?: string
clearable?: boolean clearable?: boolean
searchable?: boolean
autofocus?: boolean autofocus?: boolean
onChange: (value: string) => void onChange: (value: string) => void
} }
@ -24,10 +26,12 @@ const Select: FC<SelectProps> = ({
placeholder, placeholder,
noOptionsText, noOptionsText,
clearable = false, clearable = false,
searchable = false,
autofocus, autofocus,
onChange onChange
}) => { }) => {
const { isDarkTheme } = useAppState(); const { isDarkTheme } = useAppState();
const { isMobile } = useDeviceDetect();
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const autocompleteAnchorEl = useRef<HTMLDivElement>(null); const autocompleteAnchorEl = useRef<HTMLDivElement>(null);
@ -95,7 +99,7 @@ const Select: FC<SelectProps> = ({
}, [openList, inputRef]); }, [openList, inputRef]);
useEffect(() => { useEffect(() => {
if (!autofocus || !inputRef.current) return; if (!autofocus || !inputRef.current || isMobile) return;
inputRef.current.focus(); inputRef.current.focus();
}, [autofocus, inputRef]); }, [autofocus, inputRef]);
@ -120,25 +124,33 @@ const Select: FC<SelectProps> = ({
ref={autocompleteAnchorEl} ref={autocompleteAnchorEl}
> >
<div className="vm-select-input-content"> <div className="vm-select-input-content">
{selectedValues && selectedValues.map(item => ( {!isMobile && selectedValues && selectedValues.map(item => (
<div <div
className="vm-select-input-content__selected" className="vm-select-input-content__selected"
key={item} key={item}
> >
{item} <span>{item}</span>
<div onClick={createHandleClick(item)}> <div onClick={createHandleClick(item)}>
<CloseIcon/> <CloseIcon/>
</div> </div>
</div> </div>
))} ))}
<input {isMobile && !!selectedValues?.length && (
value={textFieldValue} <span className="vm-select-input-content__counter">
type="text" selected {selectedValues.length}
placeholder={placeholder} </span>
onInput={handleChange} )}
onFocus={handleFocus} {!isMobile || (isMobile && (!selectedValues || !selectedValues?.length)) && (
ref={inputRef} <input
/> value={textFieldValue}
type="text"
placeholder={placeholder}
onInput={handleChange}
onFocus={handleFocus}
ref={inputRef}
readOnly={isMobile || !searchable}
/>
)}
</div> </div>
{label && <span className="vm-text-field__label">{label}</span>} {label && <span className="vm-text-field__label">{label}</span>}
{clearable && value && ( {clearable && value && (
@ -159,6 +171,7 @@ const Select: FC<SelectProps> = ({
</div> </div>
</div> </div>
<Autocomplete <Autocomplete
label={label}
value={autocompleteValue} value={autocompleteValue}
options={list} options={list}
anchor={autocompleteAnchorEl} anchor={autocompleteAnchorEl}

View file

@ -19,6 +19,16 @@
flex-wrap: wrap; flex-wrap: wrap;
gap: $padding-small; gap: $padding-small;
width: 100%; width: 100%;
max-width: calc(100% - ($padding-global + 61px));
&_mobile {
flex-wrap: nowrap;
}
&__counter {
font-size: $font-size;
line-height: $font-size;
}
&__selected { &__selected {
display: inline-flex; display: inline-flex;
@ -29,6 +39,13 @@
border-radius: $border-radius-small; border-radius: $border-radius-small;
font-size: $font-size; font-size: $font-size;
line-height: $font-size; line-height: $font-size;
max-width: 100%;
span {
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
}
svg { svg {
width: 20px; width: 20px;
@ -95,4 +112,20 @@
} }
} }
} }
&-options {
display: grid;
gap: $padding-small;
max-width: 300px;
max-height: 208px;
overflow: auto;
padding: $padding-global;
font-size: $font-size;
&_mobile {
padding: 0 $padding-global $padding-small;
max-width: 100%;
max-height: calc(($vh * 100) - 70px);
}
}
} }

View file

@ -8,11 +8,17 @@ interface SwitchProps {
color?: "primary" | "secondary" | "error" color?: "primary" | "secondary" | "error"
disabled?: boolean disabled?: boolean
label?: string | ReactNode label?: string | ReactNode
fullWidth?: boolean
onChange: (value: boolean) => void onChange: (value: boolean) => void
} }
const Switch: FC<SwitchProps> = ({ const Switch: FC<SwitchProps> = ({
value = false, disabled = false, label, color = "secondary", onChange value = false,
disabled = false,
label,
color = "secondary",
fullWidth,
onChange
}) => { }) => {
const toggleSwitch = () => { const toggleSwitch = () => {
if (disabled) return; if (disabled) return;
@ -21,6 +27,7 @@ const Switch: FC<SwitchProps> = ({
const switchClasses = classNames({ const switchClasses = classNames({
"vm-switch": true, "vm-switch": true,
"vm-switch_full-width": fullWidth,
"vm-switch_disabled": disabled, "vm-switch_disabled": disabled,
"vm-switch_active": value, "vm-switch_active": value,
[`vm-switch_${color}_active`]: value, [`vm-switch_${color}_active`]: value,

View file

@ -11,6 +11,16 @@ $switch-border-radius: $switch-handle-size + ($switch-padding * 2);
align-items: center; align-items: center;
justify-content: flex-start; justify-content: flex-start;
cursor: pointer; cursor: pointer;
user-select: none;
&_full-width {
justify-content: space-between;
flex-direction: row-reverse;
}
&_full-width &__label {
margin-left: 0;
}
&_disabled { &_disabled {
opacity: 0.6; opacity: 0.6;

View file

@ -2,6 +2,7 @@ import React, { FC, KeyboardEvent, useEffect, useRef, HTMLInputTypeAttribute, Re
import classNames from "classnames"; import classNames from "classnames";
import { useMemo } from "preact/compat"; import { useMemo } from "preact/compat";
import { useAppState } from "../../../state/common/StateContext"; import { useAppState } from "../../../state/common/StateContext";
import useDeviceDetect from "../../../hooks/useDeviceDetect";
import "./style.scss"; import "./style.scss";
interface TextFieldProps { interface TextFieldProps {
@ -15,6 +16,7 @@ interface TextFieldProps {
disabled?: boolean disabled?: boolean
autofocus?: boolean autofocus?: boolean
helperText?: string helperText?: string
inputmode?: "search" | "text" | "email" | "tel" | "url" | "none" | "numeric" | "decimal"
onChange?: (value: string) => void onChange?: (value: string) => void
onEnter?: () => void onEnter?: () => void
onKeyDown?: (e: KeyboardEvent) => void onKeyDown?: (e: KeyboardEvent) => void
@ -33,6 +35,7 @@ const TextField: FC<TextFieldProps> = ({
disabled = false, disabled = false,
autofocus = false, autofocus = false,
helperText, helperText,
inputmode = "text",
onChange, onChange,
onEnter, onEnter,
onKeyDown, onKeyDown,
@ -40,6 +43,7 @@ const TextField: FC<TextFieldProps> = ({
onBlur onBlur
}) => { }) => {
const { isDarkTheme } = useAppState(); const { isDarkTheme } = useAppState();
const { isMobile } = useDeviceDetect();
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
@ -67,7 +71,7 @@ const TextField: FC<TextFieldProps> = ({
}; };
useEffect(() => { useEffect(() => {
if (!autofocus) return; if (!autofocus || isMobile) return;
fieldRef?.current?.focus && fieldRef.current.focus(); fieldRef?.current?.focus && fieldRef.current.focus();
}, [fieldRef, autofocus]); }, [fieldRef, autofocus]);
@ -97,7 +101,9 @@ const TextField: FC<TextFieldProps> = ({
ref={textareaRef} ref={textareaRef}
value={value} value={value}
rows={1} rows={1}
inputMode={inputmode}
placeholder={placeholder} placeholder={placeholder}
autoCapitalize={"none"}
onInput={handleChange} onInput={handleChange}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onFocus={handleFocus} onFocus={handleFocus}
@ -112,6 +118,8 @@ const TextField: FC<TextFieldProps> = ({
value={value} value={value}
type={type} type={type}
placeholder={placeholder} placeholder={placeholder}
inputMode={inputmode}
autoCapitalize={"none"}
onInput={handleChange} onInput={handleChange}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onFocus={handleFocus} onFocus={handleFocus}

View file

@ -43,6 +43,11 @@
-webkit-line-clamp: 2; /* number of lines to show */ -webkit-line-clamp: 2; /* number of lines to show */
line-clamp: 2; line-clamp: 2;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
@media (max-width: 500px) {
-webkit-line-clamp: 1; /* number of lines to show */
line-clamp: 1;
}
} }
&__label { &__label {

View file

@ -7,6 +7,7 @@ import { darkPalette, lightPalette } from "../../../constants/palette";
import { Theme } from "../../../types"; import { Theme } from "../../../types";
import { useAppDispatch, useAppState } from "../../../state/common/StateContext"; import { useAppDispatch, useAppState } from "../../../state/common/StateContext";
import useSystemTheme from "../../../hooks/useSystemTheme"; import useSystemTheme from "../../../hooks/useSystemTheme";
import useResize from "../../../hooks/useResize";
interface ThemeProviderProps { interface ThemeProviderProps {
onLoaded: (val: boolean) => void onLoaded: (val: boolean) => void
@ -28,6 +29,7 @@ export const ThemeProvider: FC<ThemeProviderProps> = ({ onLoaded }) => {
const { theme } = useAppState(); const { theme } = useAppState();
const isDarkTheme = useSystemTheme(); const isDarkTheme = useSystemTheme();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const windowSize = useResize(document.body);
const [palette, setPalette] = useState({ const [palette, setPalette] = useState({
[Theme.dark]: darkPalette, [Theme.dark]: darkPalette,
@ -93,6 +95,7 @@ export const ThemeProvider: FC<ThemeProviderProps> = ({ onLoaded }) => {
setTheme(); setTheme();
}, [palette]); }, [palette]);
useEffect(setScrollbarSize, [windowSize]);
useEffect(updatePalette, [theme, isDarkTheme]); useEffect(updatePalette, [theme, isDarkTheme]);
useEffect(() => { useEffect(() => {

Some files were not shown because too many files have changed in this diff Show more