mirror of
https://github.com/VictoriaMetrics/VictoriaMetrics.git
synced 2025-01-10 15:14:09 +00:00
Merge branch 'public-single-node' into pmm-6401-read-prometheus-data-files
This commit is contained in:
commit
be94882ada
252 changed files with 4517 additions and 1340 deletions
16
README.md
16
README.md
|
@ -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)
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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,10 +134,7 @@ 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),
|
||||
},
|
||||
hc: hc,
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
c.sendBlock = c.sendBlockHTTP
|
||||
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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":
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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{
|
||||
|
|
60
app/vmctl/backoff/backoff.go
Normal file
60
app/vmctl/backoff/backoff.go
Normal 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)
|
||||
}
|
96
app/vmctl/backoff/backoff_test.go
Normal file
96
app/vmctl/backoff/backoff_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
|
@ -76,6 +77,7 @@ type Importer struct {
|
|||
once sync.Once
|
||||
|
||||
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
|
||||
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)
|
||||
}
|
||||
im.s.Lock()
|
||||
im.s.retries++
|
||||
im.s.retries = attempts
|
||||
im.s.Unlock()
|
||||
backoff := float64(backoffMinDuration) * math.Pow(backoffFactor, float64(i))
|
||||
time.Sleep(time.Duration(backoff))
|
||||
}
|
||||
return fmt.Errorf("import failed with %d retries: %s", backoffRetries, err)
|
||||
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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -35,7 +35,8 @@ 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")
|
||||
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")
|
||||
)
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
]
|
||||
}
|
|
@ -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>
|
1
app/vmselect/vmui/static/css/main.5c28f4a7.css
Normal file
1
app/vmselect/vmui/static/css/main.5c28f4a7.css
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
2
app/vmselect/vmui/static/js/main.0be86920.js
Normal file
2
app/vmselect/vmui/static/js/main.0be86920.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
@ -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">
|
||||
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>;
|
||||
</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;
|
||||
|
|
|
@ -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%;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,6 +26,16 @@ const CardinalityDatePicker: FC = () => {
|
|||
return (
|
||||
<div>
|
||||
<div ref={buttonRef}>
|
||||
{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"}
|
||||
|
@ -34,8 +46,10 @@ const CardinalityDatePicker: FC = () => {
|
|||
{dateFormatted}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<DatePicker
|
||||
label="Date control"
|
||||
date={date || ""}
|
||||
format={DATE_FORMAT}
|
||||
onChange={handleChangeDate}
|
||||
|
|
|
@ -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,10 +50,19 @@ const GlobalSettings: FC<{showTitle?: boolean}> = ({ showTitle }) => {
|
|||
}, [stateServerUrl]);
|
||||
|
||||
return <>
|
||||
<Tooltip
|
||||
open={showTitle === true ? false : undefined}
|
||||
title={title}
|
||||
{isMobile ? (
|
||||
<div
|
||||
className="vm-mobile-option"
|
||||
onClick={handleOpen}
|
||||
>
|
||||
<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
|
||||
|
@ -63,10 +71,9 @@ const GlobalSettings: FC<{showTitle?: boolean}> = ({ showTitle }) => {
|
|||
color="primary"
|
||||
startIcon={<SettingsIcon/>}
|
||||
onClick={handleOpen}
|
||||
>
|
||||
{showTitle && title}
|
||||
</Button>
|
||||
/>
|
||||
</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>
|
||||
)}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -18,6 +18,10 @@
|
|||
justify-content: space-between;
|
||||
gap: $padding-global;
|
||||
|
||||
&_mobile {
|
||||
gap: $padding-small;
|
||||
}
|
||||
|
||||
div {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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,13 +81,26 @@ 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}>
|
||||
{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>
|
||||
<span className="vm-mobile-option__arrow"><ArrowDownIcon/></span>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
className={appModeEnable ? "" : "vm-header-button"}
|
||||
variant="contained"
|
||||
color="primary"
|
||||
fullWidth
|
||||
startIcon={<StorageIcon/>}
|
||||
endIcon={!isMobile ? (
|
||||
endIcon={(
|
||||
<div
|
||||
className={classNames({
|
||||
"vm-execution-controls-buttons__arrow": true,
|
||||
|
@ -96,11 +109,12 @@ const TenantsConfiguration: FC<{accountIds: string[]}> = ({ accountIds }) => {
|
|||
>
|
||||
<ArrowDownIcon/>
|
||||
</div>
|
||||
) : undefined}
|
||||
)}
|
||||
onClick={toggleOpenOptions}
|
||||
>
|
||||
{!isMobile && tenantIdState}
|
||||
{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={classNames({
|
||||
"vm-list vm-tenant-input-list": true,
|
||||
"vm-list vm-tenant-input-list_mobile": isMobile,
|
||||
})}
|
||||
>
|
||||
<div className="vm-list vm-tenant-input-list">
|
||||
<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}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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={classNames({
|
||||
"vm-timezones-list": true,
|
||||
"vm-timezones-list_mobile": isMobile,
|
||||
})}
|
||||
>
|
||||
<div className="vm-timezones-list">
|
||||
<div className="vm-timezones-list-header">
|
||||
<div className="vm-timezones-list-header__search">
|
||||
<TextField
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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%;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 => (
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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,6 +106,19 @@ const StepConfigurator: FC = () => {
|
|||
className="vm-step-control"
|
||||
ref={buttonRef}
|
||||
>
|
||||
{isMobile ? (
|
||||
<div
|
||||
className="vm-mobile-option"
|
||||
onClick={toggleOpenOptions}
|
||||
>
|
||||
<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"}
|
||||
|
@ -119,13 +135,20 @@ const StepConfigurator: FC = () => {
|
|||
</p>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Popper
|
||||
open={openOptions}
|
||||
placement="bottom-right"
|
||||
onClose={handleCloseOptions}
|
||||
buttonRef={buttonRef}
|
||||
title={isMobile ? "Query resolution step width" : undefined}
|
||||
>
|
||||
<div
|
||||
className={classNames({
|
||||
"vm-step-control-popper": true,
|
||||
"vm-step-control-popper_mobile": isMobile,
|
||||
})}
|
||||
>
|
||||
<div className="vm-step-control-popper">
|
||||
<TextField
|
||||
autofocus
|
||||
label="Step value"
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -10,5 +10,6 @@
|
|||
|
||||
&_mobile &__toggle {
|
||||
display: flex;
|
||||
min-width: 100%;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,6 +99,19 @@ export const ExecutionControls: FC = () => {
|
|||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
{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 title="Auto-refresh control">
|
||||
<div ref={optionsButtonRef}>
|
||||
<Button
|
||||
|
@ -121,6 +134,7 @@ export const ExecutionControls: FC = () => {
|
|||
</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={classNames({
|
||||
"vm-execution-controls-list": true,
|
||||
"vm-execution-controls-list_mobile": isMobile,
|
||||
})}
|
||||
>
|
||||
<div className="vm-execution-controls-list">
|
||||
{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}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -4,4 +4,8 @@
|
|||
max-height: 200px;
|
||||
overflow: auto;
|
||||
font-size: $font-size;
|
||||
|
||||
&_mobile {
|
||||
max-height: 100%
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,6 +126,19 @@ export const TimeSelector: FC = () => {
|
|||
|
||||
return <>
|
||||
<div ref={buttonRef}>
|
||||
{isMobile ? (
|
||||
<div
|
||||
className="vm-mobile-option"
|
||||
onClick={toggleOpenOptions}
|
||||
>
|
||||
<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"}
|
||||
|
@ -134,6 +150,7 @@ export const TimeSelector: FC = () => {
|
|||
{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}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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,6 +141,7 @@ const ExploreMetricItemHeader: FC<ExploreMetricItemControlsProps> = ({
|
|||
</div>
|
||||
<div className="vm-explore-metrics-item-header__name">{name}</div>
|
||||
{!isBucket && (
|
||||
<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>}
|
||||
|
@ -72,8 +149,9 @@ const ExploreMetricItemHeader: FC<ExploreMetricItemControlsProps> = ({
|
|||
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/>}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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">
|
||||
© {copyrightYears} VictoriaMetrics
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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/>}
|
||||
{isMobile && (
|
||||
<div
|
||||
className="vm-header-logo vm-header-logo_mobile"
|
||||
onClick={onClickLogo}
|
||||
style={{ color }}
|
||||
>
|
||||
<LogoFullIcon/>
|
||||
</div>
|
||||
)}
|
||||
<HeaderControls
|
||||
displaySidebar={displaySidebar}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
</header>;
|
||||
};
|
||||
|
||||
|
|
|
@ -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;
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
})}
|
||||
>
|
||||
<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>
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
})}
|
||||
>
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -93,6 +93,7 @@ $button-radius: 6px;
|
|||
/* variant CONTAINED */
|
||||
&_contained_primary {
|
||||
color: $color-primary-text;
|
||||
background-color: $color-primary;
|
||||
|
||||
&:before {
|
||||
background-color: $color-primary;
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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)}
|
||||
</>
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,17 +124,23 @@ 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>
|
||||
))}
|
||||
{isMobile && !!selectedValues?.length && (
|
||||
<span className="vm-select-input-content__counter">
|
||||
selected {selectedValues.length}
|
||||
</span>
|
||||
)}
|
||||
{!isMobile || (isMobile && (!selectedValues || !selectedValues?.length)) && (
|
||||
<input
|
||||
value={textFieldValue}
|
||||
type="text"
|
||||
|
@ -138,7 +148,9 @@ const Select: FC<SelectProps> = ({
|
|||
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}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
Loading…
Reference in a new issue