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)
* [CERN](https://docs.victoriametrics.com/CaseStudies.html#cern)
* [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)
* [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)
@ -2227,7 +2228,7 @@ Pass `-help` to VictoriaMetrics in order to see the list of supported command-li
-insert.maxQueueDuration duration
The maximum duration to wait in the queue when -maxConcurrentInserts concurrent insert requests are executed (default 1m0s)
-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
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
@ -2263,11 +2264,11 @@ Pass `-help` to VictoriaMetrics in order to see the list of supported command-li
-metricsAuthKey string
Auth key for /metrics endpoint. It must be passed via authKey query arg. It overrides httpAuth.* settings
-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
Whether to use proxy protocol for connections accepted at -opentsdbHTTPListenAddr . See https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt
-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
Whether to use proxy protocol for connections accepted at -opentsdbListenAddr . See https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt
-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)
-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)
-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
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
@ -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)
-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)
-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
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
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
@ -2413,7 +2419,7 @@ Pass `-help` to VictoriaMetrics in order to see the list of supported command-li
-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
-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)
-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)

View file

@ -103,7 +103,7 @@ func main() {
func requestHandler(w http.ResponseWriter, r *http.Request) bool {
if r.URL.Path == "/" {
if r.Method != "GET" {
if r.Method != http.MethodGet {
return false
}
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
By default `vmagent` uses Prometheus remote_write protocol for sending the data to the configured `-remoteWrite.url`.
This allows sending data to [any Prometheus-compatible remote storage](https://prometheus.io/docs/operating/integrations/#remote-endpoints-and-storage).
`vmagent` supports sending data to the configured `-remoteWrite.url` either via Prometheus remote write protocol
or via VictoriaMetrics remote write protocol.
The Prometheus remote_write protocol may require big amounts of network bandwidth under high load.
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.
VictoriaMetrics remote write protocol provides the following benefits comparing to Prometheus remote write protocol:
While all the [recently released](https://docs.victoriametrics.com/CHANGELOG.html) VictoriaMetrics components support
the VictoriaMetrics remote write protocol, third-party systems and old versions of VictoriaMetrics components may miss the support of this protocol.
- Reduced network bandwidth usage by 2x-5x. This allows saving network bandwidth usage costs when `vmagent` and
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`.
For example, the following command instructs `vmagent` to send the data to `https://victoriametrics/api/v1/write` via VictoriaMetrics remote write protocol,
while sending the data to `https://prom-compatible-storage/write` via Prometheus remote write protocol:
- Reduced disk read/write IO and disk space usage at `vmagent` when the remote storage is temporarily unavailable.
In this case `vmagent` buffers the incoming data to disk using the VictoriaMetrics remote write format.
This reduces disk read/write IO and disk space usage by 2x-5x comparing to Prometheus remote write format.
```
./vmagent -remoteWrite.url=https://victoriametrics/api/v1/write \
-remoteWrite.useVMProto=true \
-remoteWrite.url=https://prom-compatible-storage/write \
-remoteWrite.useVMProto=false
```
`vmagent` automatically uses VictoriaMetrics remote write protocol when it sends data to VictoriaMetrics components such as other `vmagent` instances,
[single-node VictoriaMetrics](https://docs.victoriametrics.com/Single-server-VictoriaMetrics.html)
or `vminsert` at [cluster version](https://docs.victoriametrics.com/Cluster-VictoriaMetrics.html).
`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
@ -1299,11 +1294,11 @@ See the docs at https://docs.victoriametrics.com/vmagent.html .
-metricsAuthKey string
Auth key for /metrics endpoint. It must be passed via authKey query arg. It overrides httpAuth.* settings
-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
Whether to use proxy protocol for connections accepted at -opentsdbHTTPListenAddr . See https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt
-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
Whether to use proxy protocol for connections accepted at -opentsdbListenAddr . See https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt
-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)
-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)
-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
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
@ -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.
-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)
-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
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.
@ -1536,14 +1536,11 @@ See the docs at https://docs.victoriametrics.com/vmagent.html .
-remoteWrite.tmpDataPath string
Path to directory where temporary data for remote write component is stored. See also -remoteWrite.maxDiskUsagePerURL (default "vmagent-remotewrite-data")
-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.
-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
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
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

View file

@ -56,12 +56,12 @@ var (
"See also -graphiteListenAddr.useProxyProtocol")
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")
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. "+
"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 . "+
"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")
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")
@ -208,7 +208,7 @@ func getAuthTokenFromPath(path string) (*auth.Token, error) {
func requestHandler(w http.ResponseWriter, r *http.Request) bool {
if r.URL.Path == "/" {
if r.Method != "GET" {
if r.Method != http.MethodGet {
return false
}
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 {
case "/prometheus/api/v1/write", "/api/v1/write":
if common.HandleVMProtoServerHandshake(w, r) {
return true
}
prometheusWriteRequests.Inc()
if err := promremotewrite.InsertHandler(nil, r); err != nil {
prometheusWriteErrors.Inc()

View file

@ -23,7 +23,7 @@ var (
func TestInsertHandler(t *testing.T) {
setUp()
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`))
if err := InsertHandler(nil, req); err != nil {
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)
}
hc := &http.Client{
Transport: tr,
Timeout: sendTimeout.GetOptionalArgOrDefault(argIdx, time.Minute),
}
c := &client{
sanitizedURL: sanitizedURL,
remoteWriteURL: remoteWriteURL,
@ -130,11 +134,8 @@ func newHTTPClient(argIdx int, remoteWriteURL, sanitizedURL string, fq *persiste
authCfg: authCfg,
awsCfg: awsCfg,
fq: fq,
hc: &http.Client{
Transport: tr,
Timeout: sendTimeout.GetOptionalArgOrDefault(argIdx, time.Minute),
},
stopCh: make(chan struct{}),
hc: hc,
stopCh: make(chan struct{}),
}
c.sendBlock = c.sendBlockHTTP
return c
@ -309,7 +310,7 @@ func (c *client) sendBlockHTTP(block []byte) bool {
}
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 {
logger.Panicf("BUG: unexpected error from http.NewRequest(%q): %s", c.sanitizedURL, err)
}

View file

@ -2,6 +2,7 @@ package remotewrite
import (
"fmt"
"math"
"testing"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/prompbmarshal"
@ -21,7 +22,7 @@ func TestPushWriteRequest(t *testing.T) {
}
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()
wr := newTestWriteRequest(rowsCount, 20)
pushBlockLen := 0
@ -32,17 +33,17 @@ func testPushWriteRequest(t *testing.T, rowsCount, expectedBlockLenProm, expecte
pushBlockLen = len(block)
}
pushWriteRequest(wr, pushBlock, isVMRemoteWrite)
if pushBlockLen != expectedBlockLen {
t.Fatalf("unexpected block len for rowsCount=%d, isVMRemoteWrite=%v; got %d bytes; expecting %d bytes",
rowsCount, isVMRemoteWrite, 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 +- %.0f%%",
rowsCount, isVMRemoteWrite, pushBlockLen, expectedBlockLen, tolerancePrc)
}
}
// Check Prometheus remote write
f(false, expectedBlockLenProm)
f(false, expectedBlockLenProm, 0)
// Check VictoriaMetrics remote write
f(true, expectedBlockLenVM)
f(true, expectedBlockLenVM, 15)
}
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/prompbmarshal"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/promrelabel"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/protoparser/common"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/streamaggr"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/tenantmetrics"
"github.com/VictoriaMetrics/metrics"
@ -28,17 +29,14 @@ import (
)
var (
remoteWriteURLs = flagutil.NewArrayString("remoteWrite.url", "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 . "+
remoteWriteURLs = flagutil.NewArrayString("remoteWrite.url", "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")
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 . "+
"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 "+
"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")
forcePromProto = flagutil.NewArrayBool("remoteWrite.forcePromProto", "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")
tmpDataPath = flag.String("remoteWrite.tmpDataPath", "vmagent-remotewrite-data", "Path to directory where temporary data for remote write component is stored. "+
"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 "+
@ -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 {
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
switch remoteWriteURL.Scheme {
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
data source for backfilling.
See a blogpost about [Rules backfilling via vmalert](https://victoriametrics.com/blog/rules-replay/).
### How it works
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) {
req, err := http.NewRequest("POST", s.datasourceURL, nil)
req, err := http.NewRequest(http.MethodPost, s.datasourceURL, nil)
if err != nil {
return nil, err
}

View file

@ -64,7 +64,7 @@ func (am *AlertManager) send(ctx context.Context, alerts []Alert) error {
b := &bytes.Buffer{}
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 {
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 {
r := bytes.NewReader(data)
req, err := http.NewRequest("POST", c.addr, r)
req, err := http.NewRequest(http.MethodPost, c.addr, r)
if err != nil {
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 {
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)
return false
}

View file

@ -169,7 +169,7 @@ func tryProcessingRequest(w http.ResponseWriter, r *http.Request, targetURL *url
if err != nil {
remoteAddr := httpserver.GetQuotedRemoteAddr(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,
// since we already proxied the request body to the backend.
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
// does not use progress bar pool
vmCfg.DisableProgressBar = true
importer, err := vm.NewImporter(vmCfg)
importer, err := vm.NewImporter(ctx, vmCfg)
if err != nil {
return fmt.Errorf("failed to create VM importer: %s", err)
}
@ -100,7 +100,7 @@ func main() {
}
vmCfg := initConfigVM(c)
importer, err = vm.NewImporter(vmCfg)
importer, err = vm.NewImporter(ctx, vmCfg)
if err != nil {
return fmt.Errorf("failed to create VM importer: %s", err)
}
@ -137,7 +137,7 @@ func main() {
vmCfg := initConfigVM(c)
importer, err := vm.NewImporter(vmCfg)
importer, err := vm.NewImporter(ctx, vmCfg)
if err != nil {
return fmt.Errorf("failed to create VM importer: %s", err)
}
@ -163,7 +163,7 @@ func main() {
fmt.Println("Prometheus import mode")
vmCfg := initConfigVM(c)
importer, err = vm.NewImporter(vmCfg)
importer, err = vm.NewImporter(ctx, vmCfg)
if err != nil {
return fmt.Errorf("failed to create VM importer: %s", err)
}

View file

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

View file

@ -110,6 +110,7 @@ func TestRemoteRead(t *testing.T) {
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
remoteReadServer := remote_read_integration.NewRemoteReadServer(t)
defer remoteReadServer.Close()
remoteWriteServer := remote_read_integration.NewRemoteWriteServer(t)
@ -139,7 +140,7 @@ func TestRemoteRead(t *testing.T) {
tt.vmCfg.Addr = remoteWriteServer.URL()
importer, err := vm.NewImporter(tt.vmCfg)
importer, err := vm.NewImporter(ctx, tt.vmCfg)
if err != nil {
t.Fatalf("failed to create VM importer: %s", err)
}
@ -156,7 +157,6 @@ func TestRemoteRead(t *testing.T) {
cc: 1,
}
ctx := context.Background()
err = rmp.run(ctx, true, false)
if err != nil {
t.Fatalf("failed to run remote read processor: %s", err)
@ -263,6 +263,7 @@ func TestSteamRemoteRead(t *testing.T) {
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
remoteReadServer := remote_read_integration.NewRemoteReadStreamServer(t)
defer remoteReadServer.Close()
remoteWriteServer := remote_read_integration.NewRemoteWriteServer(t)
@ -292,7 +293,7 @@ func TestSteamRemoteRead(t *testing.T) {
tt.vmCfg.Addr = remoteWriteServer.URL()
importer, err := vm.NewImporter(tt.vmCfg)
importer, err := vm.NewImporter(ctx, tt.vmCfg)
if err != nil {
t.Fatalf("failed to create VM importer: %s", err)
}
@ -309,7 +310,6 @@ func TestSteamRemoteRead(t *testing.T) {
cc: 1,
}
ctx := context.Background()
err = rmp.run(ctx, true, false)
if err != nil {
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
func (c *Client) Ping() error {
url := c.addr + healthPath
req, err := http.NewRequest("GET", url, nil)
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
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 {
r := bytes.NewReader(data)
url := c.addr + remoteReadPath
req, err := http.NewRequest("POST", url, r)
req, err := http.NewRequest(http.MethodPost, url, r)
if err != nil {
return fmt.Errorf("failed to create new HTTP request: %w", err)
}

View file

@ -3,15 +3,16 @@ package vm
import (
"bufio"
"compress/gzip"
"context"
"errors"
"fmt"
"io"
"math"
"net/http"
"strings"
"sync"
"time"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vmctl/backoff"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vmctl/barpool"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vmctl/limiter"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/decimal"
@ -75,7 +76,8 @@ type Importer struct {
wg sync.WaitGroup
once sync.Once
s *stats
s *stats
backoff *backoff.Backoff
}
// ResetStats resets im stats.
@ -107,7 +109,7 @@ func AddExtraLabelsToImportPath(path string, extraLabels []string) (string, erro
}
// 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 {
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{}),
input: make(chan *TimeSeries, cfg.Concurrency*4),
errors: make(chan *ImportError, cfg.Concurrency),
backoff: backoff.New(),
}
if err := im.Ping(); err != nil {
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) {
defer im.wg.Done()
im.startWorker(bar, cfg.BatchSize, cfg.SignificantFigures, cfg.RoundDigits)
im.startWorker(ctx, bar, cfg.BatchSize, cfg.SignificantFigures, cfg.RoundDigits)
}(bar)
}
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 dataPoints int
var waitForBatch time.Time
@ -219,7 +222,9 @@ func (im *Importer) startWorker(bar *pb.ProgressBar, batchSize, significantFigur
exitErr := &ImportError{
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
}
im.errors <- exitErr
@ -249,7 +254,7 @@ func (im *Importer) startWorker(bar *pb.ProgressBar, batchSize, significantFigur
im.s.idleDuration += time.Since(waitForBatch)
im.s.Unlock()
if err := im.flush(batch); err != nil {
if err := im.flush(ctx, batch); err != nil {
im.errors <- &ImportError{
Batch: batch,
Err: err,
@ -264,36 +269,22 @@ func (im *Importer) startWorker(bar *pb.ProgressBar, batchSize, significantFigur
}
}
const (
// TODO: make configurable
backoffRetries = 5
backoffFactor = 1.7
backoffMinDuration = time.Second
)
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))
func (im *Importer) flush(ctx context.Context, b []*TimeSeries) error {
retryableFunc := func() error { return im.Import(b) }
attempts, err := im.backoff.Retry(ctx, retryableFunc)
if err != nil {
return fmt.Errorf("import failed with %d retries: %s", attempts, err)
}
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.
func (im *Importer) Ping() error {
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 {
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()
req, err := http.NewRequest("POST", im.importPath, pr)
req, err := http.NewRequest(http.MethodPost, im.importPath, pr)
if err != nil {
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{})
go func() {
defer func() { close(sync) }()
req, err := http.NewRequestWithContext(ctx, "POST", dstURL, pr)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, dstURL, pr)
if err != nil {
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) {
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 {
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) {
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
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")
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")
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. "+
"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 . "+
"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")
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")
@ -157,6 +157,9 @@ func RequestHandler(w http.ResponseWriter, r *http.Request) bool {
}
switch path {
case "/prometheus/api/v1/write", "/api/v1/write":
if common.HandleVMProtoServerHandshake(w, r) {
return true
}
prometheusWriteRequests.Inc()
if err := promremotewrite.InsertHandler(r); err != nil {
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 "+
"limit is reached; see also -search.maxQueryDuration")
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")
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")
logSlowQueryDuration = flag.Duration("search.logSlowQueryDuration", 5*time.Second, "Log queries with execution time exceeding this value. Zero disables slow query logging. "+
"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`)

View file

@ -759,6 +759,9 @@ func QueryHandler(qt *querytracer.Tracer, startTime time.Time, w http.ResponseWr
LookbackDelta: lookbackDelta,
RoundDigits: getRoundDigits(r),
EnforcedTagFilterss: etfs,
GetRequestURI: func() string {
return httpserver.GetRequestURI(r)
},
}
result, err := promql.Exec(qt, &ec, query, true)
if err != nil {
@ -860,6 +863,9 @@ func queryRangeHandler(qt *querytracer.Tracer, startTime time.Time, w http.Respo
LookbackDelta: lookbackDelta,
RoundDigits: getRoundDigits(r),
EnforcedTagFilterss: etfs,
GetRequestURI: func() string {
return httpserver.GetRequestURI(r)
},
}
result, err := promql.Exec(qt, &ec, query, false)
if err != nil {
@ -1013,8 +1019,10 @@ func getRoundDigits(r *http.Request) int {
func getLatencyOffsetMilliseconds(r *http.Request) (int64, error) {
d := latencyOffset.Milliseconds()
if d <= 1000 {
d = 1000
if d < 0 {
// 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)
}

View file

@ -200,7 +200,7 @@ func TestAdjustLastPoints(t *testing.T) {
func TestGetLatencyOffsetMillisecondsSuccess(t *testing.T) {
f := func(url string, expectedOffset int64) {
t.Helper()
r, err := http.NewRequest("GET", url, nil)
r, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
t.Fatalf("unexpected error in NewRequest(%q): %s", url, err)
}
@ -219,7 +219,7 @@ func TestGetLatencyOffsetMillisecondsSuccess(t *testing.T) {
func TestGetLatencyOffsetMillisecondsFailure(t *testing.T) {
f := func(url string) {
t.Helper()
r, err := http.NewRequest("GET", url, nil)
r, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
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")
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 "+
"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, "+
"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 [][]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
timestampsOnce sync.Once
}
@ -140,6 +148,7 @@ func copyEvalConfig(src *EvalConfig) *EvalConfig {
ec.LookbackDelta = src.LookbackDelta
ec.RoundDigits = src.RoundDigits
ec.EnforcedTagFilterss = src.EnforcedTagFilterss
ec.GetRequestURI = src.GetRequestURI
// do not copy src.timestamps - they must be generated again.
return &ec
@ -1079,26 +1088,33 @@ func evalRollupFuncWithMetricExpr(qt *querytracer.Tracer, ec *EvalConfig, funcNa
}
rollupPoints := mulNoOverflow(pointsPerTimeseries, int64(timeseriesLen*len(rcs)))
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 {
rss.Cancel()
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; "+
"possible solutions are: reducing the number of matching time series; increasing `step` query arg (step=%gs); "+
"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()
if !rml.Get(uint64(rollupMemorySize)) {
rss.Cancel()
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; "+
"requested memory: %d bytes; "+
"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",
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))

View file

@ -6212,7 +6212,7 @@ func TestExecSuccess(t *testing.T) {
q := `interpolate(time() < 1300)`
r1 := netstorage.Result{
MetricName: metricNameExpected,
Values: []float64{1000, 1200, 1200, 1200, 1200, 1200},
Values: []float64{1000, 1200, nan, nan, nan, nan},
Timestamps: timestampsExpected,
}
resultExpected := []netstorage.Result{r1}
@ -6223,7 +6223,18 @@ func TestExecSuccess(t *testing.T) {
q := `interpolate(time() > 1500)`
r1 := netstorage.Result{
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,
}
resultExpected := []netstorage.Result{r1}

View file

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

View file

@ -15,7 +15,7 @@ func TestGetDurationSuccess(t *testing.T) {
f := func(s string, dExpected int64) {
t.Helper()
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 {
t.Fatalf("unexpected error in NewRequest: %s", err)
}
@ -54,7 +54,7 @@ func TestGetDurationError(t *testing.T) {
f := func(s string) {
t.Helper()
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 {
t.Fatalf("unexpected error in NewRequest: %s", err)
}
@ -78,7 +78,7 @@ func TestGetTimeSuccess(t *testing.T) {
f := func(s string, timestampExpected int64) {
t.Helper()
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 {
t.Fatalf("unexpected error in NewRequest: %s", err)
}
@ -127,7 +127,7 @@ func TestGetTimeError(t *testing.T) {
f := func(s string) {
t.Helper()
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 {
t.Fatalf("unexpected error in NewRequest: %s", err)
}

View file

@ -1,14 +1,14 @@
{
"files": {
"main.css": "./static/css/main.b9c2d13c.css",
"main.js": "./static/js/main.44784d74.js",
"main.css": "./static/css/main.5c28f4a7.css",
"main.js": "./static/js/main.0be86920.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-Bold.ttf": "./static/media/Lato-Bold.32360ba4b57802daa4d6.ttf",
"index.html": "./index.html"
},
"entrypoints": [
"static/css/main.b9c2d13c.css",
"static/js/main.44784d74.js"
"static/css/main.5c28f4a7.css",
"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 { useQueryDispatch, useQueryState } from "../../../state/query/QueryStateContext";
import "./style.scss";
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 queryDispatch = useQueryDispatch();
@ -24,23 +28,72 @@ const AdditionalSettings: FC = () => {
queryDispatch({ type: "TOGGLE_AUTOCOMPLETE" });
};
return <div className="vm-additional-settings">
<Switch
label={"Autocomplete"}
value={autocomplete}
onChange={onChangeAutocomplete}
/>
<Switch
label={"Disable cache"}
value={nocache}
onChange={onChangeCache}
/>
<Switch
label={"Trace query"}
value={isTracingEnabled}
onChange={onChangeQueryTracing}
/>
</div>;
return (
<div
className={classNames({
"vm-additional-settings": true,
"vm-additional-settings_mobile": isMobile
})}
>
<Switch
label={"Autocomplete"}
value={autocomplete}
onChange={onChangeAutocomplete}
fullWidth={isMobile}
/>
<Switch
label={"Disable cache"}
value={nocache}
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;

View file

@ -5,10 +5,19 @@
align-items: center;
justify-content: flex-start;
flex-wrap: wrap;
gap: 24px;
gap: $padding-global;
&__input {
flex-basis: 160px;
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 dayjs from "dayjs";
import Button from "../../Main/Button/Button";
import { CalendarIcon } from "../../Main/Icons";
import { ArrowDownIcon, CalendarIcon } from "../../Main/Icons";
import Tooltip from "../../Main/Tooltip/Tooltip";
import { getAppModeEnable } from "../../../utils/app-mode";
import { DATE_FORMAT } from "../../../constants/date";
import DatePicker from "../../Main/DatePicker/DatePicker";
import useDeviceDetect from "../../../hooks/useDeviceDetect";
const CardinalityDatePicker: FC = () => {
const { isMobile } = useDeviceDetect();
const appModeEnable = getAppModeEnable();
const buttonRef = useRef<HTMLDivElement>(null);
@ -24,18 +26,30 @@ const CardinalityDatePicker: FC = () => {
return (
<div>
<div ref={buttonRef}>
<Tooltip title="Date control">
<Button
className={appModeEnable ? "" : "vm-header-button"}
variant="contained"
color="primary"
startIcon={<CalendarIcon/>}
>
{dateFormatted}
</Button>
</Tooltip>
{isMobile ? (
<div className="vm-mobile-option">
<span className="vm-mobile-option__icon"><CalendarIcon/></span>
<div className="vm-mobile-option-text">
<span className="vm-mobile-option-text__label">Date control</span>
<span className="vm-mobile-option-text__value">{dateFormatted}</span>
</div>
<span className="vm-mobile-option__arrow"><ArrowDownIcon/></span>
</div>
) : (
<Tooltip title="Date control">
<Button
className={appModeEnable ? "" : "vm-header-button"}
variant="contained"
color="primary"
startIcon={<CalendarIcon/>}
>
{dateFormatted}
</Button>
</Tooltip>
)}
</div>
<DatePicker
label="Date control"
date={date || ""}
format={DATE_FORMAT}
onChange={handleChangeDate}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -8,6 +8,7 @@ import dayjs from "dayjs";
import TextField from "../../../Main/TextField/TextField";
import { Timezone } from "../../../../types";
import "./style.scss";
import useDeviceDetect from "../../../../hooks/useDeviceDetect";
interface TimezonesProps {
timezoneState: string
@ -15,7 +16,7 @@ interface TimezonesProps {
}
const Timezones: FC<TimezonesProps> = ({ timezoneState, onChange }) => {
const { isMobile } = useDeviceDetect();
const timezones = getTimezoneList();
const [openList, setOpenList] = useState(false);
@ -92,8 +93,14 @@ const Timezones: FC<TimezonesProps> = ({ timezoneState, onChange }) => {
placement="bottom-left"
onClose={handleCloseList}
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__search">
<TextField

View file

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

View file

@ -6,6 +6,7 @@
align-items: center;
gap: $padding-medium;
width: 600px;
padding-bottom: $padding-medium;
&_mobile {
grid-auto-rows: min-content;
@ -20,12 +21,6 @@
&__input {
width: 100%;
&_server {
display: grid;
grid-template-columns: 1fr auto;
gap: 0 $padding-small;
}
}
&__title {
@ -37,20 +32,4 @@
font-weight: bold;
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 TextField from "../../../Main/TextField/TextField";
import Switch from "../../../Main/Switch/Switch";
import useDeviceDetect from "../../../../hooks/useDeviceDetect";
import classNames from "classnames";
interface AxesLimitsConfiguratorProps {
yaxis: YaxisState,
@ -12,6 +14,7 @@ interface AxesLimitsConfiguratorProps {
}
const AxesLimitsConfigurator: FC<AxesLimitsConfiguratorProps> = ({ yaxis, setYaxisLimits, toggleEnableLimits }) => {
const { isMobile } = useDeviceDetect();
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);
};
return <div className="vm-axes-limits">
return <div
className={classNames({
"vm-axes-limits": true,
"vm-axes-limits_mobile": isMobile
})}
>
<Switch
value={yaxis.limits.enable}
onChange={toggleEnableLimits}
label="Fix the limits for y-axis"
fullWidth={isMobile}
/>
<div className="vm-axes-limits-list">
{axes.map(axis => (

View file

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

View file

@ -1,9 +1,8 @@
import React, { FC, useRef, useState } from "preact/compat";
import AxesLimitsConfigurator from "./AxesLimitsConfigurator/AxesLimitsConfigurator";
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 useClickOutside from "../../../hooks/useClickOutside";
import Popper from "../../Main/Popper/Popper";
import "./style.scss";
import Tooltip from "../../Main/Tooltip/Tooltip";
@ -20,7 +19,6 @@ const GraphSettings: FC<GraphSettingsProps> = ({ yaxis, setYaxisLimits, toggleEn
const popperRef = useRef<HTMLDivElement>(null);
const [openPopper, setOpenPopper] = useState(false);
const buttonRef = useRef<HTMLDivElement>(null);
useClickOutside(popperRef, () => setOpenPopper(false), buttonRef);
const toggleOpen = () => {
setOpenPopper(prev => !prev);
@ -46,22 +44,12 @@ const GraphSettings: FC<GraphSettingsProps> = ({ yaxis, setYaxisLimits, toggleEn
buttonRef={buttonRef}
placement="bottom-right"
onClose={handleClose}
title={title}
>
<div
className="vm-graph-settings-popper"
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">
<AxesLimitsConfigurator
yaxis={yaxis}

View file

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

View file

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

View file

@ -11,10 +11,6 @@
&__value {
display: inline;
margin-left: 3px;
@media (max-width: 500px) {
display: none;
}
}
&-popper {
@ -26,6 +22,16 @@
padding: $padding-global;
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 {
font-size: $font-size-small;
line-height: 1.6;

View file

@ -10,5 +10,6 @@
&_mobile &__toggle {
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 { getAppModeEnable } from "../../../../utils/app-mode";
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 "./style.scss";
import classNames from "classnames";
import Tooltip from "../../../Main/Tooltip/Tooltip";
import useResize from "../../../../hooks/useResize";
import useDeviceDetect from "../../../../hooks/useDeviceDetect";
interface AutoRefreshOption {
seconds: number
@ -30,7 +30,7 @@ const delayOptions: AutoRefreshOption[] = [
];
export const ExecutionControls: FC = () => {
const windowSize = useResize(document.body);
const { isMobile } = useDeviceDetect();
const dispatch = useTimeDispatch();
const appModeEnable = getAppModeEnable();
@ -85,11 +85,11 @@ export const ExecutionControls: FC = () => {
<div
className={classNames({
"vm-execution-controls-buttons": true,
"vm-execution-controls-buttons_mobile": isMobile,
"vm-header-button": !appModeEnable,
"vm-execution-controls-buttons_short": windowSize.width <= 360
})}
>
{windowSize.width > 360 && (
{!isMobile && (
<Tooltip title="Refresh dashboard">
<Button
variant="contained"
@ -99,28 +99,42 @@ export const ExecutionControls: FC = () => {
/>
</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>
{isMobile ? (
<div
className="vm-mobile-option"
onClick={toggleOpenOptions}
>
<span className="vm-mobile-option__icon"><RestartIcon/></span>
<div className="vm-mobile-option-text">
<span className="vm-mobile-option-text__label">Auto-refresh</span>
<span className="vm-mobile-option-text__value">{selectedDelay.title}</span>
</div>
<span className="vm-mobile-option__arrow"><ArrowDownIcon/></span>
</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>
<Popper
@ -128,12 +142,19 @@ export const ExecutionControls: FC = () => {
placement="bottom-right"
onClose={handleCloseOptions}
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 => (
<div
className={classNames({
"vm-list-item": true,
"vm-list-item_mobile": isMobile,
"vm-list-item_active": d.seconds === selectedDelay.seconds
})}
key={d.seconds}

View file

@ -9,8 +9,9 @@
border-radius: calc($button-radius + 1px);
min-width: 107px;
&_short {
min-width: auto;
&_mobile {
flex-direction: column;
gap: $padding-medium;
}
&__arrow {
@ -32,5 +33,11 @@
overflow: auto;
padding: $padding-small 0;
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 "./style.scss";
import classNames from "classnames";
import useDeviceDetect from "../../../../hooks/useDeviceDetect";
interface TimeDurationSelector {
setDuration: ({ duration, until, id }: {duration: string, until: Date, id: string}) => void;
@ -9,17 +10,24 @@ interface TimeDurationSelector {
}
const TimeDurationSelector: FC<TimeDurationSelector> = ({ relativeTime, setDuration }) => {
const { isMobile } = useDeviceDetect();
const createHandlerClick = (value: { duration: string, until: Date, id: string }) => () => {
setDuration(value);
};
return (
<div className="vm-time-duration">
<div
className={classNames({
"vm-time-duration": true,
"vm-time-duration_mobile": isMobile,
})}
>
{relativeTimeOptions.map(({ id, duration, until, title }) => (
<div
className={classNames({
"vm-list-item": true,
"vm-list-item_mobile": isMobile,
"vm-list-item_active": id === relativeTime
})}
key={id}

View file

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

View file

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

View file

@ -5,9 +5,18 @@
grid-template-columns: repeat(2, 230px);
padding: $padding-global 0;
@media (max-width: 500px) {
&_mobile {
grid-template-columns: 1fr;
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 {
@ -17,12 +26,6 @@
border-right: $border-divider;
padding: 0 $padding-global;
@media (max-width: 500px) {
border-right: none;
border-bottom: $border-divider;
padding-bottom: $padding-global;
}
&-inputs {
flex-grow: 1;
display: grid;
@ -62,6 +65,7 @@
grid-column: 1/3;
font-size: $font-size-small;
color: $color-text-secondary;
user-select: none;
}
svg {

View file

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

View file

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

View file

@ -10,6 +10,7 @@ interface ExploreMetricItemProps {
job: string
instance: string
index: number
length: number
size: GraphSize
onRemoveItem: (name: string) => void
onChangeOrder: (name: string, oldIndex: number, newIndex: number) => void
@ -20,6 +21,7 @@ const ExploreMetricItem: FC<ExploreMetricItemProps> = ({
job,
instance,
index,
length,
size,
onRemoveItem,
onChangeOrder,
@ -42,6 +44,7 @@ const ExploreMetricItem: FC<ExploreMetricItemProps> = ({
<ExploreMetricItemHeader
name={name}
index={index}
length={length}
isBucket={isBucket}
rateEnabled={rateEnabled}
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 Switch from "../../Main/Switch/Switch";
import Tooltip from "../../Main/Tooltip/Tooltip";
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 {
name: string
index: number
length: number
isBucket: boolean
rateEnabled: boolean
size: string
@ -19,12 +22,15 @@ interface ExploreMetricItemControlsProps {
const ExploreMetricItemHeader: FC<ExploreMetricItemControlsProps> = ({
name,
index,
length,
isBucket,
rateEnabled,
onChangeRate,
onRemoveItem,
onChangeOrder,
}) => {
const { isMobile } = useDeviceDetect();
const [openOptions, setOpenOptions] = useState(false);
const handleClickRemove = () => {
onRemoveItem(name);
@ -38,6 +44,76 @@ const ExploreMetricItemHeader: FC<ExploreMetricItemControlsProps> = ({
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 (
<div className="vm-explore-metrics-item-header">
<div className="vm-explore-metrics-item-header-order">
@ -65,15 +141,17 @@ const ExploreMetricItemHeader: FC<ExploreMetricItemControlsProps> = ({
</div>
<div className="vm-explore-metrics-item-header__name">{name}</div>
{!isBucket && (
<Tooltip title="calculates the average per-second speed of metric's change">
<Switch
label={<span>enable <code>rate()</code></span>}
value={rateEnabled}
onChange={onChangeRate}
/>
</Tooltip>
<div className="vm-explore-metrics-item-header__rate">
<Tooltip title="calculates the average per-second speed of metric's change">
<Switch
label={<span>enable <code>rate()</code></span>}
value={rateEnabled}
onChange={onChangeRate}
/>
</Tooltip>
</div>
)}
<div className="vm-explore-metrics-item-header__layout">
<div className="vm-explore-metrics-item-header__close">
<Tooltip title="close graph">
<Button
startIcon={<CloseIcon/>}

View file

@ -1,14 +1,19 @@
@use "src/styles/variables" as *;
.vm-explore-metrics-item-header {
display: flex;
flex-wrap: wrap;
display: grid;
grid-template-columns: auto 1fr auto auto;
align-items: center;
justify-content: flex-start;
padding: $padding-global;
border-bottom: $border-divider;
gap: $padding-global;
&_mobile {
grid-template-columns: 1fr auto;
padding: $padding-small $padding-global;
}
&__index {
color: $color-text-secondary;
font-size: $font-size-small;
@ -17,9 +22,14 @@
&__name {
flex-grow: 1;
font-weight: bold;
max-width: 100%;
text-overflow: ellipsis;
overflow: hidden;
line-height: 130%;
}
&-order {
grid-column: 1;
display: grid;
grid-template-columns: auto 20px auto;
align-items: center;
@ -31,7 +41,13 @@
}
}
&__layout {
&__rate {
grid-column: 3;
}
&__close {
grid-row: 1;
grid-column: 4;
display: grid;
align-items: center;
}
@ -42,4 +58,35 @@
background-color: $color-hover-black;
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 "./style.scss";
import { GRAPH_SIZES } from "../../../constants/graph";
import classNames from "classnames";
import useDeviceDetect from "../../../hooks/useDeviceDetect";
interface ExploreMetricsHeaderProps {
jobs: string[]
@ -34,9 +36,17 @@ const ExploreMetricsHeader: FC<ExploreMetricsHeaderProps> = ({
}) => {
const noInstanceText = useMemo(() => job ? "" : "No instances. Please select job", [job]);
const noMetricsText = useMemo(() => job ? "" : "No metric names. Please select job", [job]);
const { isMobile } = useDeviceDetect();
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">
<Select
value={job}
@ -45,6 +55,7 @@ const ExploreMetricsHeader: FC<ExploreMetricsHeaderProps> = ({
placeholder="Please select job"
onChange={onChangeJob}
autofocus={!job}
searchable
/>
</div>
<div className="vm-explore-metrics-header__instance">
@ -56,6 +67,7 @@ const ExploreMetricsHeader: FC<ExploreMetricsHeaderProps> = ({
onChange={onChangeInstance}
noOptionsText={noInstanceText}
clearable
searchable
/>
</div>
<div className="vm-explore-metrics-header__size">
@ -68,12 +80,14 @@ const ExploreMetricsHeader: FC<ExploreMetricsHeaderProps> = ({
</div>
<div className="vm-explore-metrics-header-metrics">
<Select
label={"Metrics"}
value={selectedMetrics}
list={names}
placeholder="Search metric name"
onChange={onToggleMetric}
noOptionsText={noMetricsText}
clearable
searchable
/>
</div>
</div>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -21,6 +21,12 @@
padding: $padding-small;
}
&_mobile {
display: grid;
grid-template-columns: 33px 1fr 33px;
justify-content: space-between;
}
&_dark {
.vm-header-button,
button:before,
@ -50,18 +56,16 @@
max-width: 65px;
min-width: 65px;
}
&_mobile {
max-width: 65px;
min-width: 65px;
margin: 0 auto;
}
}
&-nav {
font-size: $font-size-small;
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 { routerOptions } from "../../router";
import { useFetchDashboards } from "../../pages/PredefinedPanels/hooks/useFetchDashboards";
import useDeviceDetect from "../../hooks/useDeviceDetect";
const Layout: FC = () => {
const appModeEnable = getAppModeEnable();
const { isMobile } = useDeviceDetect();
useFetchDashboards();
const { pathname } = useLocation();
@ -24,6 +26,7 @@ const Layout: FC = () => {
<div
className={classNames({
"vm-container-body": true,
"vm-container-body_mobile": isMobile,
"vm-container-body_app": appModeEnable
})}
>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -9,6 +9,10 @@
background-color: $color-background-block;
border-radius: $border-radius-medium;
&_mobile {
padding: 0 $padding-global;
}
&__tabs {
margin: 0 0-$padding-global 0-$padding-global;
border-top: $border-divider;
@ -61,6 +65,8 @@
&__prev,
&__next {
margin: -8px;
padding: 8px;
cursor: pointer;
transition: opacity 200ms ease-in-out;
@ -87,6 +93,11 @@
justify-content: center;
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 {
display: flex;
align-items: center;

View file

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

View file

@ -125,17 +125,6 @@ export const ArrowDropDownIcon = () => (
</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 = () => (
<svg
viewBox="0 0 24 24"
@ -181,15 +170,6 @@ export const KeyboardIcon = () => (
</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 = () => (
<svg
viewBox="0 0 24 24"
@ -257,6 +237,15 @@ export const PlusIcon = () => (
</svg>
);
export const MinusIcon = () => (
<svg
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M19 13H5v-2h14v2z"></path>
</svg>
);
export const DoneIcon = () => (
<svg
viewBox="0 0 24 24"
@ -310,30 +299,6 @@ export const DragIcon = () => (
</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 = () => (
<svg
viewBox="0 0 24 24"
@ -401,13 +366,24 @@ export const StorageIcon = () => (
</svg>
);
export const MenuIcon = () => (
export const MoreIcon = () => (
<svg
viewBox="0 0 24 24"
fill="currentColor"
>
<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>
</svg>
);

View file

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

View file

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

View file

@ -14,16 +14,31 @@ $padding-modal: 22px;
justify-content: center;
background: rgba($color-black, 0.55);
&_mobile &-content {
&_mobile {
align-items: flex-start;
min-height: calc($vh * 100);
max-height: calc($vh * 100);
overflow: auto;
}
&_mobile &-content {
width: 100vw;
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 {
display: grid;
align-items: flex-start;
min-height: 100%;
padding: 0 $padding-global $padding-modal;
}
}
@ -31,7 +46,6 @@ $padding-modal: 22px;
display: grid;
grid-template-rows: auto 1fr;
align-items: flex-start;
padding: $padding-modal;
background: $color-background-block;
box-shadow: 0 0 24px rgba($color-black, 0.07);
border-radius: $border-radius-small;
@ -39,14 +53,25 @@ $padding-modal: 22px;
overflow: auto;
&-header {
position: sticky;
top: 0;
display: grid;
grid-template-columns: 1fr auto;
gap: $padding-small;
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 {
font-weight: bold;
font-size: $font-size-medium;
user-select: none;
}
&__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 ReactDOM from "react-dom";
import "./style.scss";
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 {
children: ReactNode
@ -14,6 +18,8 @@ interface PopperProps {
offset?: {top: number, left: number}
clickOutside?: boolean,
fullWidth?: boolean
title?: string
disabledFullScreen?: boolean
}
const Popper: FC<PopperProps> = ({
@ -24,10 +30,14 @@ const Popper: FC<PopperProps> = ({
onClose,
offset = { top: 6, left: 0 },
clickOutside = true,
fullWidth
fullWidth,
title,
disabledFullScreen
}) => {
const [isOpen, setIsOpen] = useState(true);
const { isMobile } = useDeviceDetect();
const navigate = useNavigate();
const location = useLocation();
const [isOpen, setIsOpen] = useState(false);
const [popperSize, setPopperSize] = useState({ width: 0, height: 0 });
const popperRef = useRef<HTMLDivElement>(null);
@ -50,6 +60,13 @@ const Popper: FC<PopperProps> = ({
useEffect(() => {
if (!isOpen && onClose) onClose();
if (isOpen && isMobile && !disabledFullScreen) {
document.body.style.overflow = "hidden";
}
return () => {
document.body.style.overflow = "auto";
};
}, [isOpen]);
useEffect(() => {
@ -63,7 +80,7 @@ const Popper: FC<PopperProps> = ({
const popperStyle = useMemo(() => {
const buttonEl = buttonRef.current;
if (!buttonEl|| !isOpen) return {};
if (!buttonEl || !isOpen) return {};
const buttonPos = buttonEl.getBoundingClientRect();
@ -104,28 +121,63 @@ const Popper: FC<PopperProps> = ({
return position;
},[buttonRef, placement, isOpen, children, fullWidth]);
const handleClickClose = (e: ReactMouseEvent<HTMLButtonElement, MouseEvent>) => {
e.stopPropagation();
onClose();
};
if (clickOutside) useClickOutside(popperRef, () => setIsOpen(false), buttonRef);
useEffect(() => {
if (!popperRef.current || !isOpen) return;
if (!popperRef.current || !isOpen || (isMobile && !disabledFullScreen)) return;
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]);
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({
"vm-popper": true,
"vm-popper_open": isOpen,
"vm-popper_mobile": isMobile && !disabledFullScreen,
"vm-popper_open": (isMobile || Object.keys(popperStyle).length) && isOpen,
});
return (
<>
{isOpen && ReactDOM.createPortal((
{(isOpen || !popperSize.width) && ReactDOM.createPortal((
<div
className={popperClasses}
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}
</div>), document.body)}
</>

View file

@ -17,6 +17,38 @@
animation: vm-slider 150ms cubic-bezier(0.280, 0.840, 0.420, 1.1);
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 {

View file

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

View file

@ -19,6 +19,16 @@
flex-wrap: wrap;
gap: $padding-small;
width: 100%;
max-width: calc(100% - ($padding-global + 61px));
&_mobile {
flex-wrap: nowrap;
}
&__counter {
font-size: $font-size;
line-height: $font-size;
}
&__selected {
display: inline-flex;
@ -29,6 +39,13 @@
border-radius: $border-radius-small;
font-size: $font-size;
line-height: $font-size;
max-width: 100%;
span {
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
}
svg {
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"
disabled?: boolean
label?: string | ReactNode
fullWidth?: boolean
onChange: (value: boolean) => void
}
const Switch: FC<SwitchProps> = ({
value = false, disabled = false, label, color = "secondary", onChange
value = false,
disabled = false,
label,
color = "secondary",
fullWidth,
onChange
}) => {
const toggleSwitch = () => {
if (disabled) return;
@ -21,6 +27,7 @@ const Switch: FC<SwitchProps> = ({
const switchClasses = classNames({
"vm-switch": true,
"vm-switch_full-width": fullWidth,
"vm-switch_disabled": disabled,
"vm-switch_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;
justify-content: flex-start;
cursor: pointer;
user-select: none;
&_full-width {
justify-content: space-between;
flex-direction: row-reverse;
}
&_full-width &__label {
margin-left: 0;
}
&_disabled {
opacity: 0.6;

View file

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

View file

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

View file

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

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