mirror of
https://github.com/VictoriaMetrics/VictoriaMetrics.git
synced 2026-08-23 11:49:19 +03:00
Compare commits
4 Commits
export-tra
...
issue-5914
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e54c3ecfb6 | ||
|
|
d98336e581 | ||
|
|
13ef9c3ba8 | ||
|
|
5a87c85093 |
16
.github/workflows/build.yml
vendored
16
.github/workflows/build.yml
vendored
@@ -73,19 +73,11 @@ jobs:
|
||||
id: go
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
cache: false
|
||||
cache-dependency-path: |
|
||||
go.sum
|
||||
Makefile
|
||||
app/**/Makefile
|
||||
go-version-file: 'go.mod'
|
||||
|
||||
- name: Cache Go build artifacts
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: |
|
||||
~/.cache/go-build
|
||||
~/go/pkg/mod
|
||||
key: go-build-${{ matrix.os }}-${{ matrix.arch }}-${{ hashFiles('go.sum', 'Makefile') }}
|
||||
restore-keys: |
|
||||
go-build-${{ matrix.os }}-${{ matrix.arch }}-
|
||||
|
||||
- run: go version
|
||||
|
||||
- name: Build victoria-metrics for ${{ matrix.os }}-${{ matrix.arch }}
|
||||
|
||||
@@ -41,8 +41,8 @@ VictoriaMetrics is optimized for timeseries data, even when old time series are
|
||||
* **Ideal for big data**: Works well with large amounts of time series data from APM, Kubernetes, IoT sensors, connected cars, industrial telemetry, financial data and various [Enterprise workloads](https://docs.victoriametrics.com/victoriametrics/enterprise/).
|
||||
* **Query language**: Supports both PromQL and the more performant MetricsQL.
|
||||
* **Easy to setup**: No dependencies, single [small binary](https://medium.com/@valyala/stripping-dependency-bloat-in-victoriametrics-docker-image-983fb5912b0d), configuration through command-line flags, but the default is also fine-tuned; backup and restore with [instant snapshots](https://medium.com/@valyala/how-victoriametrics-makes-instant-snapshots-for-multi-terabyte-time-series-data-e1f3fb0e0282).
|
||||
* **Global query view**: Multiple Prometheus instances or any other data sources may ingest data into VictoriaMetrics and be queried via a single query.
|
||||
* **Various Protocols**: Support metric scraping, ingestion and backfilling in various protocols.
|
||||
* **Global query view**: Multiple Prometheus instances or any other data sources may ingest data into VictoriaMetrics and queried via a single query.
|
||||
* **Various Protocols**: Support metric scraping, ingestion and backfilling in various protocol.
|
||||
* [Prometheus exporters](https://docs.victoriametrics.com/victoriametrics/single-server-victoriametrics/#how-to-scrape-prometheus-exporters-such-as-node-exporter), [Prometheus remote write API](https://docs.victoriametrics.com/victoriametrics/integrations/prometheus/), [Prometheus exposition format](https://docs.victoriametrics.com/victoriametrics/single-server-victoriametrics/#how-to-import-data-in-prometheus-exposition-format).
|
||||
* [InfluxDB line protocol](https://docs.victoriametrics.com/victoriametrics/integrations/influxdb/) over HTTP, TCP and UDP.
|
||||
* [Graphite plaintext protocol](https://docs.victoriametrics.com/victoriametrics/integrations/graphite/#ingesting) with [tags](https://graphite.readthedocs.io/en/latest/tags.html#carbon).
|
||||
@@ -78,7 +78,7 @@ We strictly apply security measures in everything we do. VictoriaMetrics has ach
|
||||
Some good benchmarks VictoriaMetrics achieved:
|
||||
|
||||
* **Minimal memory footprint**: handling millions of unique timeseries with [10x less RAM](https://medium.com/@valyala/insert-benchmarks-with-inch-influxdb-vs-victoriametrics-e31a41ae2893) than InfluxDB, up to [7x less RAM](https://valyala.medium.com/prometheus-vs-victoriametrics-benchmark-on-node-exporter-metrics-4ca29c75590f) than Prometheus, Thanos or Cortex.
|
||||
* **Highly scalable and performant** for [data ingestion](https://medium.com/@valyala/high-cardinality-tsdb-benchmarks-victoriametrics-vs-timescaledb-vs-influxdb-13e6ee64dd6b) and [querying](https://medium.com/@valyala/when-size-matters-benchmarking-victoriametrics-vs-timescale-and-influxdb-6035811952d4), [20x outperforms](https://medium.com/@valyala/insert-benchmarks-with-inch-influxdb-vs-victoriametrics-e31a41ae2893) InfluxDB and TimescaleDB.
|
||||
* **Highly scalable and performance** for [data ingestion](https://medium.com/@valyala/high-cardinality-tsdb-benchmarks-victoriametrics-vs-timescaledb-vs-influxdb-13e6ee64dd6b) and [querying](https://medium.com/@valyala/when-size-matters-benchmarking-victoriametrics-vs-timescale-and-influxdb-6035811952d4), [20x outperforms](https://medium.com/@valyala/insert-benchmarks-with-inch-influxdb-vs-victoriametrics-e31a41ae2893) InfluxDB and TimescaleDB.
|
||||
* **High data compression**: [70x more data points](https://medium.com/@valyala/when-size-matters-benchmarking-victoriametrics-vs-timescale-and-influxdb-6035811952d4) may be stored into limited storage than TimescaleDB, [7x less storage](https://valyala.medium.com/prometheus-vs-victoriametrics-benchmark-on-node-exporter-metrics-4ca29c75590f) space is required than Prometheus, Thanos or Cortex.
|
||||
* **Reducing storage costs**: [10x more effective](https://docs.victoriametrics.com/victoriametrics/casestudies/#grammarly) than Graphite according to the Grammarly case study.
|
||||
* **A single-node VictoriaMetrics** can replace medium-sized clusters built with competing solutions such as Thanos, M3DB, Cortex, InfluxDB or TimescaleDB. See [VictoriaMetrics vs Thanos](https://medium.com/@valyala/comparing-thanos-to-victoriametrics-cluster-b193bea1683), [Measuring vertical scalability](https://medium.com/@valyala/measuring-vertical-scalability-for-time-series-databases-in-google-cloud-92550d78d8ae), [Remote write storage wars - PromCon 2019](https://promcon.io/2019-munich/talks/remote-write-storage-wars/).
|
||||
@@ -86,7 +86,7 @@ Some good benchmarks VictoriaMetrics achieved:
|
||||
|
||||
## Community and contributions
|
||||
|
||||
Feel free to ask any questions regarding VictoriaMetrics:
|
||||
Feel free asking any questions regarding VictoriaMetrics:
|
||||
|
||||
* [Slack Inviter](https://slack.victoriametrics.com/) and [Slack channel](https://victoriametrics.slack.com/)
|
||||
* [X (Twitter)](https://x.com/VictoriaMetrics/)
|
||||
|
||||
@@ -22,7 +22,6 @@ import (
|
||||
"github.com/VictoriaMetrics/VictoriaMetrics/lib/procutil"
|
||||
"github.com/VictoriaMetrics/VictoriaMetrics/lib/promscrape"
|
||||
"github.com/VictoriaMetrics/VictoriaMetrics/lib/pushmetrics"
|
||||
"github.com/VictoriaMetrics/VictoriaMetrics/lib/querytracer/push"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -64,7 +63,6 @@ func main() {
|
||||
flag.CommandLine.SetOutput(os.Stdout)
|
||||
flag.Usage = usage
|
||||
envflag.Parse()
|
||||
initSecretFlags()
|
||||
buildinfo.Init()
|
||||
logger.Init()
|
||||
|
||||
@@ -104,10 +102,8 @@ func main() {
|
||||
logger.Infof("started VictoriaMetrics in %.3f seconds", time.Since(startTime).Seconds())
|
||||
|
||||
pushmetrics.Init()
|
||||
push.Init()
|
||||
sig := procutil.WaitForSigterm()
|
||||
logger.Infof("received signal %s", sig)
|
||||
push.Stop()
|
||||
pushmetrics.Stop()
|
||||
|
||||
stopSelfScraper()
|
||||
@@ -174,9 +170,3 @@ See the docs at https://docs.victoriametrics.com/victoriametrics/
|
||||
`
|
||||
flagutil.Usage(s)
|
||||
}
|
||||
|
||||
// initSecretFlags manages the secret flags for this app and must be called after flag parsing and before logger init.
|
||||
func initSecretFlags() {
|
||||
pushmetrics.InitSecretFlags()
|
||||
vmselect.InitSecretFlags()
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ var (
|
||||
maxLabelNameLen = flag.Int("maxLabelNameLen", 0, "The maximum length of label names in the accepted time series. Series with longer label name are ignored. In this case the vm_rows_ignored_total{reason=\"too_long_label_name\"} metric at /metrics page is incremented")
|
||||
maxLabelValueLen = flag.Int("maxLabelValueLen", 0, "The maximum length of label values in the accepted time series. Series with longer label value are ignored. In this case the vm_rows_ignored_total{reason=\"too_long_label_value\"} metric at /metrics page is incremented")
|
||||
|
||||
enableMultitenancyViaHeaders = flag.Bool("enableMultitenancyViaHeaders", true, "Enables multitenancy via HTTP headers. "+
|
||||
enableMultitenancyViaHeaders = flag.Bool("enableMultitenancyViaHeaders", false, "Enables multitenancy via HTTP headers. "+
|
||||
"See https://docs.victoriametrics.com/victoriametrics/vmagent/#multitenancy")
|
||||
)
|
||||
|
||||
@@ -115,7 +115,7 @@ func main() {
|
||||
flag.CommandLine.SetOutput(os.Stdout)
|
||||
flag.Usage = usage
|
||||
envflag.Parse()
|
||||
initSecretFlags()
|
||||
remotewrite.InitSecretFlags()
|
||||
buildinfo.Init()
|
||||
logger.Init()
|
||||
opentelemetry.Init()
|
||||
@@ -843,9 +843,3 @@ See the docs at https://docs.victoriametrics.com/victoriametrics/vmagent/ .
|
||||
`
|
||||
flagutil.Usage(s)
|
||||
}
|
||||
|
||||
// initSecretFlags manages the secret flags for this app and must be called after flag parsing and before logger init.
|
||||
func initSecretFlags() {
|
||||
remotewrite.InitSecretFlags()
|
||||
pushmetrics.InitSecretFlags()
|
||||
}
|
||||
|
||||
@@ -52,7 +52,6 @@ func setUp() {
|
||||
|
||||
func tearDown() {
|
||||
protoparserutil.StopUnmarshalWorkers()
|
||||
remotewrite.Stop()
|
||||
srv.Close()
|
||||
logger.ResetOutputForTest()
|
||||
tmpDataDir := flag.Lookup("remoteWrite.tmpDataPath").Value.String()
|
||||
|
||||
@@ -156,8 +156,7 @@ var maxQueues = cgroup.AvailableCPUs() * 16
|
||||
|
||||
const persistentQueueDirname = "persistent-queue"
|
||||
|
||||
// InitSecretFlags manages the secret flags for this pkg and must be called by app-level initSecretFlags.
|
||||
// It should run before logger initialization and package Init() (if exists).
|
||||
// InitSecretFlags must be called after flag.Parse and before any logging.
|
||||
func InitSecretFlags() {
|
||||
if !*showRemoteWriteURL {
|
||||
// remoteWrite.url can contain authentication codes, so hide it at `/metrics` output.
|
||||
@@ -246,8 +245,6 @@ func Init() {
|
||||
dropDanglingQueues()
|
||||
|
||||
// Start config reloader.
|
||||
configReloaderStopCh = make(chan struct{})
|
||||
configReloaderWG = sync.WaitGroup{}
|
||||
configReloaderWG.Go(func() {
|
||||
for {
|
||||
select {
|
||||
@@ -334,7 +331,7 @@ func initRemoteWriteCtxs(urls []string) {
|
||||
}
|
||||
|
||||
var (
|
||||
configReloaderStopCh chan struct{}
|
||||
configReloaderStopCh = make(chan struct{})
|
||||
configReloaderWG sync.WaitGroup
|
||||
)
|
||||
|
||||
|
||||
@@ -60,8 +60,7 @@ var (
|
||||
`Only valid for VictoriaMetrics as the datasource.`)
|
||||
)
|
||||
|
||||
// InitSecretFlags manages the secret flags for this pkg and must be called by app-level initSecretFlags.
|
||||
// It should run before logger initialization and package Init() (if exists).
|
||||
// InitSecretFlags must be called after flag.Parse and before any logging
|
||||
func InitSecretFlags() {
|
||||
if !*showDatasourceURL {
|
||||
flagutil.RegisterSecretFlag("datasource.url")
|
||||
|
||||
@@ -88,7 +88,10 @@ func main() {
|
||||
flag.CommandLine.SetOutput(os.Stdout)
|
||||
flag.Usage = usage
|
||||
envflag.Parse()
|
||||
initSecretFlags()
|
||||
remoteread.InitSecretFlags()
|
||||
remotewrite.InitSecretFlags()
|
||||
datasource.InitSecretFlags()
|
||||
notifier.InitSecretFlags()
|
||||
buildinfo.Init()
|
||||
logger.Init()
|
||||
|
||||
@@ -435,12 +438,3 @@ func getLastConfigError() error {
|
||||
defer lastConfigErrMu.RUnlock()
|
||||
return lastConfigErr
|
||||
}
|
||||
|
||||
// initSecretFlags manages the secret flags for this app and must be called after flag parsing and before logger init.
|
||||
func initSecretFlags() {
|
||||
remoteread.InitSecretFlags()
|
||||
remotewrite.InitSecretFlags()
|
||||
datasource.InitSecretFlags()
|
||||
notifier.InitSecretFlags()
|
||||
pushmetrics.InitSecretFlags()
|
||||
}
|
||||
|
||||
@@ -189,8 +189,7 @@ func Init(extLabels map[string]string, extURL string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// InitSecretFlags manages the secret flags for this pkg and must be called by app-level initSecretFlags.
|
||||
// It should run before logger initialization and package Init() (if exists).
|
||||
// InitSecretFlags must be called after flag.Parse and before any logging
|
||||
func InitSecretFlags() {
|
||||
if !*showNotifierURL {
|
||||
flagutil.RegisterSecretFlag("notifier.url")
|
||||
|
||||
@@ -56,8 +56,7 @@ var (
|
||||
oauth2Scopes = flag.String("remoteRead.oauth2.scopes", "", "Optional OAuth2 scopes to use for -remoteRead.url. Scopes must be delimited by ';'.")
|
||||
)
|
||||
|
||||
// InitSecretFlags manages the secret flags for this pkg and must be called by app-level initSecretFlags.
|
||||
// It should run before logger initialization and package Init() (if exists).
|
||||
// InitSecretFlags must be called after flag.Parse and before any logging
|
||||
func InitSecretFlags() {
|
||||
if !*showRemoteReadURL {
|
||||
flagutil.RegisterSecretFlag("remoteRead.url")
|
||||
|
||||
@@ -284,15 +284,7 @@ func (c *Client) flush(ctx context.Context, wr *prompb.WriteRequest) {
|
||||
bb := writeRequestBufPool.Get()
|
||||
bb.B = wr.MarshalProtobuf(bb.B[:0])
|
||||
zb := compressBufPool.Get()
|
||||
// A failed send may leave the http transport still reading zb.B in a separate goroutine
|
||||
// even after send returns, so zb is returned to the pool only if no send attempt has failed.
|
||||
// See https://pkg.go.dev/net/http#RoundTripper
|
||||
sendFailed := false
|
||||
defer func() {
|
||||
if !sendFailed {
|
||||
compressBufPool.Put(zb)
|
||||
}
|
||||
}()
|
||||
defer compressBufPool.Put(zb)
|
||||
if c.isVMRemoteWrite.Load() {
|
||||
zb.B = zstd.CompressLevel(zb.B[:0], bb.B, 0)
|
||||
} else {
|
||||
@@ -311,13 +303,10 @@ func (c *Client) flush(ctx context.Context, wr *prompb.WriteRequest) {
|
||||
L:
|
||||
for {
|
||||
err := c.send(ctx, zb.B)
|
||||
if err != nil {
|
||||
sendFailed = true
|
||||
if errors.Is(err, io.EOF) || netutil.IsTrivialNetworkError(err) {
|
||||
// Something in the middle between client and destination might be closing
|
||||
// the connection. So we do a one more attempt in hope request will succeed.
|
||||
err = c.send(ctx, zb.B)
|
||||
}
|
||||
if err != nil && (errors.Is(err, io.EOF) || netutil.IsTrivialNetworkError(err)) {
|
||||
// Something in the middle between client and destination might be closing
|
||||
// the connection. So we do a one more attempt in hope request will succeed.
|
||||
err = c.send(ctx, zb.B)
|
||||
}
|
||||
if err == nil {
|
||||
sentRows.Add(len(wr.Timeseries))
|
||||
|
||||
@@ -36,13 +36,6 @@ func NewDebugClient() (*DebugClient, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create transport for -remoteWrite.url=%q: %w", *addr, err)
|
||||
}
|
||||
tr.IdleConnTimeout = *idleConnectionTimeout
|
||||
// DebugClient sends every series in a separate request, so it needs more idle
|
||||
// connections than the two http.DefaultTransport keeps per host.
|
||||
tr.MaxIdleConnsPerHost = *maxIdleConnections
|
||||
if tr.MaxIdleConns != 0 && tr.MaxIdleConns < tr.MaxIdleConnsPerHost {
|
||||
tr.MaxIdleConns = tr.MaxIdleConnsPerHost
|
||||
}
|
||||
c := &DebugClient{
|
||||
c: &http.Client{
|
||||
Timeout: *sendTimeout,
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
package remotewrite
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestDebugClient_IdleConns makes sure DebugClient keeps enough idle connections
|
||||
// to -remoteWrite.url. Every series is pushed in a separate request, so with the
|
||||
// two idle connections per host of http.DefaultTransport most of the concurrent
|
||||
// requests would open a new connection and leave a socket in TIME_WAIT state.
|
||||
func TestDebugClient_IdleConns(t *testing.T) {
|
||||
f := func(maxIdle int) {
|
||||
t.Helper()
|
||||
|
||||
oldAddr, oldMaxIdle := *addr, *maxIdleConnections
|
||||
*addr, *maxIdleConnections = "http://localhost:8428", maxIdle
|
||||
defer func() {
|
||||
*addr, *maxIdleConnections = oldAddr, oldMaxIdle
|
||||
}()
|
||||
|
||||
client, err := NewDebugClient()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create debug client: %s", err)
|
||||
}
|
||||
tr, ok := client.c.Transport.(*http.Transport)
|
||||
if !ok {
|
||||
t.Fatalf("unexpected transport type %T", client.c.Transport)
|
||||
}
|
||||
if tr.MaxIdleConnsPerHost != maxIdle {
|
||||
t.Fatalf("unexpected MaxIdleConnsPerHost; got %d; want %d", tr.MaxIdleConnsPerHost, maxIdle)
|
||||
}
|
||||
if tr.MaxIdleConns != 0 && tr.MaxIdleConns < maxIdle {
|
||||
t.Fatalf("MaxIdleConns=%d is lower than MaxIdleConnsPerHost=%d", tr.MaxIdleConns, maxIdle)
|
||||
}
|
||||
if tr.IdleConnTimeout != *idleConnectionTimeout {
|
||||
t.Fatalf("unexpected IdleConnTimeout; got %s; want %s", tr.IdleConnTimeout, *idleConnectionTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
f(100)
|
||||
|
||||
// the number of idle connections must be raised together with the total limit
|
||||
f(1000)
|
||||
}
|
||||
@@ -34,7 +34,6 @@ var (
|
||||
bearerTokenFile = flag.String("remoteWrite.bearerTokenFile", "", "Optional path to bearer token file to use for -remoteWrite.url.")
|
||||
|
||||
idleConnectionTimeout = flag.Duration("remoteWrite.idleConnTimeout", 50*time.Second, `Defines a duration for idle (keep-alive connections) to exist. Consider settings this value less to the value of "-http.idleConnTimeout". It must prevent possible "write: broken pipe" and "read: connection reset by peer" errors.`)
|
||||
maxIdleConnections = flag.Int("remoteWrite.maxIdleConnections", 100, `Defines the number of idle (keep-alive connections) to -remoteWrite.url for the vmalert-tool debug writer, which sends every series in a separate request. Too low a value may result in a high number of sockets in TIME_WAIT state.`)
|
||||
|
||||
maxQueueSize = flag.Int("remoteWrite.maxQueueSize", defaultMaxQueueSize, "Defines the max number of pending datapoints to remote write endpoint")
|
||||
maxBatchSize = flag.Int("remoteWrite.maxBatchSize", defaultMaxBatchSize, "Defines max number of timeseries to be flushed at once")
|
||||
@@ -58,8 +57,7 @@ var (
|
||||
oauth2Scopes = flag.String("remoteWrite.oauth2.scopes", "", "Optional OAuth2 scopes to use for -notifier.url. Scopes must be delimited by ';'.")
|
||||
)
|
||||
|
||||
// InitSecretFlags manages the secret flags for this pkg and must be called by app-level initSecretFlags.
|
||||
// It should run before logger initialization and package Init() (if exists).
|
||||
// InitSecretFlags must be called after flag.Parse and before any logging
|
||||
func InitSecretFlags() {
|
||||
if !*showRemoteWriteURL {
|
||||
flagutil.RegisterSecretFlag("remoteWrite.url")
|
||||
|
||||
@@ -30,8 +30,7 @@ var (
|
||||
"Progress bar rendering might be verbose or break the logs parsing, so it is recommended to be disabled when not used in interactive mode.")
|
||||
ruleEvaluationConcurrency = flag.Int("replay.ruleEvaluationConcurrency", 1, "The maximum number of concurrent '/query_range' requests when replay recording rule or alerting rule with for=0. "+
|
||||
"Increasing this value when replaying for a long time, since each request is limited by -replay.maxDatapointsPerQuery.")
|
||||
continueWithExecutionErr = flag.Bool("replay.continueWithExecutionErr", false, "Whether to continue replaying other rules if a rule execution fails with a 400 or 422 response code, "+
|
||||
"which can happen due to an expression syntax error or a resource limit being hit.")
|
||||
continueWithExecutionErr = flag.Bool("replay.continueWithExecutionErr", false, "Whether to continue replaying other rules if a rule execution fails with a 422 response code, which can happen due to an expression syntax error or a resource limit being hit.")
|
||||
)
|
||||
|
||||
func replay(groupsCfg []config.Group, qb datasource.QuerierBuilder, rw remotewrite.RWClient) (totalRows, droppedRows int, err error) {
|
||||
|
||||
@@ -462,11 +462,7 @@ func (ar *AlertingRule) exec(ctx context.Context, ts time.Time, limit int) ([]pr
|
||||
}
|
||||
|
||||
isPartial := isPartialResponse(res)
|
||||
seriesFetched := 0
|
||||
if res.SeriesFetched != nil {
|
||||
seriesFetched = *res.SeriesFetched
|
||||
}
|
||||
ar.logDebugf(ts, nil, "query returned %d series (series_fetched: %d, elapsed: %s, isPartial: %t)", curState.Samples, seriesFetched, curState.Duration, isPartial)
|
||||
ar.logDebugf(ts, nil, "query returned %d series (elapsed: %s, isPartial: %t)", curState.Samples, curState.Duration, isPartial)
|
||||
qFn := func(query string) ([]datasource.Metric, error) {
|
||||
res, _, err := ar.q.Query(ctx, query, ts)
|
||||
return res.Data, err
|
||||
|
||||
@@ -290,8 +290,6 @@ func (g *Group) updateWith(newGroup *Group) error {
|
||||
g.Headers = newGroup.Headers
|
||||
g.NotifierHeaders = newGroup.NotifierHeaders
|
||||
g.Labels = newGroup.Labels
|
||||
g.EvalDelay = newGroup.EvalDelay
|
||||
g.evalAlignment = newGroup.evalAlignment
|
||||
g.Limit = newGroup.Limit
|
||||
g.checksum = newGroup.checksum
|
||||
g.Rules = newRules
|
||||
@@ -339,7 +337,7 @@ func (g *Group) Init() {
|
||||
i := g.Interval.Seconds()
|
||||
return i
|
||||
})
|
||||
g.metrics.iterationLimit = g.metrics.set.NewGauge(fmt.Sprintf(`vmalert_group_rule_results_limit{%s}`, labels), func() float64 {
|
||||
g.metrics.iterationLimit = g.metrics.set.NewGauge(fmt.Sprintf(`vmalert_rule_group_results_limit{%s}`, labels), func() float64 {
|
||||
g.mu.RLock()
|
||||
limit := g.Limit
|
||||
g.mu.RUnlock()
|
||||
@@ -375,7 +373,7 @@ func (g *Group) Start(ctx context.Context, rw remotewrite.RWClient, rr datasourc
|
||||
g.mu.Lock()
|
||||
err := g.updateWith(ng)
|
||||
if err != nil {
|
||||
logger.Errorf("group %q (file=%q): failed to update: %s", g.Name, g.File, err)
|
||||
logger.Errorf("group %q: failed to update: %s", g.Name, err)
|
||||
g.mu.Unlock()
|
||||
continue
|
||||
}
|
||||
@@ -414,7 +412,7 @@ func (g *Group) Start(ctx context.Context, rw remotewrite.RWClient, rr datasourc
|
||||
errs := e.execConcurrently(ctx, g.Rules, ts, g.Concurrency, resolveDuration, g.Limit)
|
||||
for err := range errs {
|
||||
if err != nil {
|
||||
logger.Errorf("group %q (file=%q): %s", g.Name, g.File, err)
|
||||
logger.Errorf("group %q: %s", g.Name, err)
|
||||
}
|
||||
}
|
||||
g.metrics.iterationDuration.UpdateDuration(start)
|
||||
@@ -443,17 +441,17 @@ func (g *Group) Start(ctx context.Context, rw remotewrite.RWClient, rr datasourc
|
||||
if rr != nil {
|
||||
err := g.restore(ctx, rr, realEvalTS, *remoteReadLookBack)
|
||||
if err != nil {
|
||||
logger.Errorf("error while restoring ruleState for group %q (file=%q): %s", g.Name, g.File, err)
|
||||
logger.Errorf("error while restoring ruleState for group %q: %s", g.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
logger.Infof("group %q (file=%q): context cancelled", g.Name, g.File)
|
||||
logger.Infof("group %q: context cancelled", g.Name)
|
||||
return
|
||||
case <-g.doneCh:
|
||||
logger.Infof("group %q (file=%q): received stop signal", g.Name, g.File)
|
||||
logger.Infof("group %q: received stop signal", g.Name)
|
||||
return
|
||||
case ng := <-g.updateCh:
|
||||
g.mu.Lock()
|
||||
@@ -467,7 +465,7 @@ func (g *Group) Start(ctx context.Context, rw remotewrite.RWClient, rr datasourc
|
||||
|
||||
err := g.updateWith(ng)
|
||||
if err != nil {
|
||||
logger.Errorf("group %q (file=%q): failed to update: %s", g.Name, g.File, err)
|
||||
logger.Errorf("group %q: failed to update: %s", g.Name, err)
|
||||
g.mu.Unlock()
|
||||
continue
|
||||
}
|
||||
@@ -545,8 +543,8 @@ func (g *Group) delayBeforeStart(ts time.Time, maxDelay time.Duration) time.Dura
|
||||
|
||||
func (g *Group) infof(format string, args ...any) {
|
||||
msg := fmt.Sprintf(format, args...)
|
||||
logger.Infof("group %q (file=%q; interval=%v; eval_offset=%v; concurrency=%d) %s",
|
||||
g.Name, g.File, g.Interval, g.EvalOffset, g.Concurrency, msg)
|
||||
logger.Infof("group %q %s; interval=%v; eval_offset=%v; concurrency=%d",
|
||||
g.Name, msg, g.Interval, g.EvalOffset, g.Concurrency)
|
||||
}
|
||||
|
||||
// Replay performs group replay
|
||||
|
||||
@@ -78,12 +78,6 @@ func TestUpdateWith(t *testing.T) {
|
||||
if g.Debug != expect.Debug {
|
||||
t.Fatalf("expected to have debug %v; got %v", expect.Debug, g.Debug)
|
||||
}
|
||||
if !durationPtrEqual(g.EvalDelay, expect.EvalDelay) {
|
||||
t.Fatalf("expected to have eval_delay %v; got %v", expect.EvalDelay, g.EvalDelay)
|
||||
}
|
||||
if !boolPtrEqual(g.evalAlignment, expect.evalAlignment) {
|
||||
t.Fatalf("expected to have eval_alignment %v; got %v", expect.evalAlignment, g.evalAlignment)
|
||||
}
|
||||
}
|
||||
|
||||
// new rule
|
||||
@@ -243,37 +237,6 @@ func TestUpdateWith(t *testing.T) {
|
||||
{Alert: "foo1", Debug: &debug},
|
||||
},
|
||||
})
|
||||
|
||||
// update group evaluation settings
|
||||
evalDelay := promutil.NewDuration(time.Minute)
|
||||
evalAlignment := false
|
||||
f(config.Group{
|
||||
Rules: []config.Rule{{
|
||||
Record: "foo",
|
||||
Expr: "max(up)",
|
||||
}},
|
||||
}, config.Group{
|
||||
EvalDelay: evalDelay,
|
||||
EvalAlignment: &evalAlignment,
|
||||
Rules: []config.Rule{{
|
||||
Record: "foo",
|
||||
Expr: "min(up)",
|
||||
}},
|
||||
})
|
||||
}
|
||||
|
||||
func durationPtrEqual(a, b *time.Duration) bool {
|
||||
if a == nil || b == nil {
|
||||
return a == b
|
||||
}
|
||||
return *a == *b
|
||||
}
|
||||
|
||||
func boolPtrEqual(a, b *bool) bool {
|
||||
if a == nil || b == nil {
|
||||
return a == b
|
||||
}
|
||||
return *a == *b
|
||||
}
|
||||
|
||||
func TestUpdateDuringRandSleep(t *testing.T) {
|
||||
|
||||
@@ -208,11 +208,7 @@ func (rr *RecordingRule) exec(ctx context.Context, ts time.Time, limit int) ([]p
|
||||
return nil, curState.Err
|
||||
}
|
||||
|
||||
seriesFetched := 0
|
||||
if res.SeriesFetched != nil {
|
||||
seriesFetched = *res.SeriesFetched
|
||||
}
|
||||
rr.logDebugf(ts, "query returned %d samples (series_fetched: %d, elapsed: %s, isPartial: %t)", curState.Samples, seriesFetched, curState.Duration, isPartialResponse(res))
|
||||
rr.logDebugf(ts, "query returned %d samples (elapsed: %s, isPartial: %t)", curState.Samples, curState.Duration, isPartialResponse(res))
|
||||
|
||||
qMetrics := res.Data
|
||||
numSeries := len(qMetrics)
|
||||
|
||||
@@ -132,10 +132,9 @@ func replayRule(r Rule, start, end time.Time, rw remotewrite.RWClient, replayRul
|
||||
var esc *httpserver.ErrorWithStatusCode
|
||||
if errors.As(err, &esc) {
|
||||
statusCode := esc.StatusCode
|
||||
// if the status code is 400 or 422, the query failed due to reasons such as an expression syntax error or a resource limit being hit,
|
||||
// rather than datasource unavailability.
|
||||
// Continue replaying but skip the problematic execution if continueWithExecutionErr is true, otherwise, return the error without retry.
|
||||
if statusCode == http.StatusUnprocessableEntity || statusCode == http.StatusBadRequest {
|
||||
// if the status code is 422, it means that the query was executed but failed due to an expression syntax error or a the resource limit being hit,
|
||||
// continue replaying but skip the problematic execution if continueWithExecutionErr is true, otherwise, return the error without retry.
|
||||
if statusCode == http.StatusUnprocessableEntity {
|
||||
if continueWithExecutionErr {
|
||||
logger.Errorf("rule %q: %s", r, err)
|
||||
return 0, nil
|
||||
|
||||
@@ -96,7 +96,6 @@ func main() {
|
||||
flag.CommandLine.SetOutput(os.Stdout)
|
||||
flag.Usage = usage
|
||||
envflag.Parse()
|
||||
initSecretFlags()
|
||||
buildinfo.Init()
|
||||
logger.Init()
|
||||
|
||||
@@ -912,8 +911,3 @@ func slowdownUnauthorizedResponse(r *http.Request) {
|
||||
}
|
||||
timerpool.Put(t)
|
||||
}
|
||||
|
||||
// initSecretFlags manages the secret flags for this app and must be called after flag parsing and before logger init.
|
||||
func initSecretFlags() {
|
||||
pushmetrics.InitSecretFlags()
|
||||
}
|
||||
|
||||
@@ -47,8 +47,9 @@ func main() {
|
||||
// Write flags and help message to stdout, since it is easier to grep or pipe.
|
||||
flag.CommandLine.SetOutput(os.Stdout)
|
||||
flag.Usage = usage
|
||||
flagutil.RegisterSecretFlag("snapshot.createURL")
|
||||
flagutil.RegisterSecretFlag("snapshot.deleteURL")
|
||||
envflag.Parse()
|
||||
initSecretFlags()
|
||||
buildinfo.Init()
|
||||
logger.Init()
|
||||
|
||||
@@ -272,10 +273,3 @@ func newRemoteOriginFS(ctx context.Context) (common.RemoteFS, error) {
|
||||
}
|
||||
return fs, nil
|
||||
}
|
||||
|
||||
// initSecretFlags manages the secret flags for this app and must be called after flag parsing and before logger init.
|
||||
func initSecretFlags() {
|
||||
flagutil.RegisterSecretFlag("snapshot.createURL")
|
||||
flagutil.RegisterSecretFlag("snapshot.deleteURL")
|
||||
pushmetrics.InitSecretFlags()
|
||||
}
|
||||
|
||||
@@ -311,6 +311,8 @@ var (
|
||||
|
||||
const (
|
||||
influxAddr = "influx-addr"
|
||||
influxVersion = "influx-version"
|
||||
influxToken = "influx-token"
|
||||
influxUser = "influx-user"
|
||||
influxPassword = "influx-password"
|
||||
influxDB = "influx-database"
|
||||
@@ -337,6 +339,19 @@ var (
|
||||
Value: "http://localhost:8086",
|
||||
Usage: "InfluxDB server addr",
|
||||
},
|
||||
&cli.IntFlag{
|
||||
Name: influxVersion,
|
||||
Usage: "Major version of the source InfluxDB: 1 or 2.\n" +
|
||||
"InfluxDB 2.x is migrated via its InfluxDB 1.x compatibility API, which requires -influx-token " +
|
||||
"and a database/retention policy mapping (DBRP) pointing at the bucket to migrate.\n" +
|
||||
"See https://docs.influxdata.com/influxdb/v2/api-guide/influxdb-1x/",
|
||||
Value: 1,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: influxToken,
|
||||
Usage: "InfluxDB v2 API token. Requires -influx-version=2",
|
||||
EnvVars: []string{"INFLUX_TOKEN"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: influxUser,
|
||||
Usage: "InfluxDB user",
|
||||
@@ -353,9 +368,11 @@ var (
|
||||
Required: true,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: influxRetention,
|
||||
Usage: "InfluxDB retention policy",
|
||||
Value: "autogen",
|
||||
Name: influxRetention,
|
||||
Usage: "InfluxDB retention policy.\n" +
|
||||
"Defaults to 'autogen' for -influx-version=1.\n" +
|
||||
"For -influx-version=2 it is the retention policy of a DBRP mapping; if empty, " +
|
||||
"the default mapping of -influx-database is used",
|
||||
},
|
||||
&cli.IntFlag{
|
||||
Name: influxChunkSize,
|
||||
|
||||
@@ -11,6 +11,27 @@ import (
|
||||
influx "github.com/influxdata/influxdb/client/v2"
|
||||
)
|
||||
|
||||
const (
|
||||
// VersionV1 is InfluxDB 1.x, queried via its native /query endpoint.
|
||||
VersionV1 = 1
|
||||
|
||||
// VersionV2 is InfluxDB 2.x, queried via its InfluxDB 1.x compatibility
|
||||
// API. It authenticates with an API token instead of a password.
|
||||
//
|
||||
// See https://docs.influxdata.com/influxdb/v2/api-guide/influxdb-1x/
|
||||
VersionV2 = 2
|
||||
|
||||
// defaultV1CompatUser is sent as the username when migrating from InfluxDB 2.x
|
||||
// with an API token.
|
||||
//
|
||||
// The InfluxDB 1.x compatibility API requires a username whenever an API token
|
||||
// is used as the password, but the value itself is ignored.
|
||||
// See https://docs.influxdata.com/influxdb/v2/api-guide/influxdb-1x/
|
||||
defaultV1CompatUser = "vmctl"
|
||||
|
||||
defaultRetentionV1 = "autogen"
|
||||
)
|
||||
|
||||
// Client represents a wrapper over
|
||||
// influx HTTP client
|
||||
type Client struct {
|
||||
@@ -27,7 +48,9 @@ type Client struct {
|
||||
// Config contains fields required
|
||||
// for Client configuration
|
||||
type Config struct {
|
||||
Version int
|
||||
Addr string
|
||||
Token string
|
||||
Username string
|
||||
Password string
|
||||
Database string
|
||||
@@ -88,10 +111,18 @@ type LabelPair struct {
|
||||
// NewClient creates and returns influx client
|
||||
// configured with passed Config
|
||||
func NewClient(cfg Config) (*Client, error) {
|
||||
if err := cfg.validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// InfluxDB 2.x authenticates with an API token passed as the password
|
||||
// of its InfluxDB 1.x compatibility API.
|
||||
username, password := resolveAuth(cfg.Username, cfg.Password, cfg.Token)
|
||||
|
||||
c := influx.HTTPConfig{
|
||||
Addr: cfg.Addr,
|
||||
Username: cfg.Username,
|
||||
Password: cfg.Password,
|
||||
Username: username,
|
||||
Password: password,
|
||||
TLSConfig: cfg.TLSConfig,
|
||||
}
|
||||
hc, err := influx.NewHTTPClient(c)
|
||||
@@ -99,6 +130,7 @@ func NewClient(cfg Config) (*Client, error) {
|
||||
return nil, fmt.Errorf("failed to establish conn: %w", err)
|
||||
}
|
||||
if _, _, err := hc.Ping(time.Second); err != nil {
|
||||
_ = hc.Close()
|
||||
return nil, fmt.Errorf("ping failed: %w", err)
|
||||
}
|
||||
|
||||
@@ -110,7 +142,7 @@ func NewClient(cfg Config) (*Client, error) {
|
||||
client := &Client{
|
||||
Client: hc,
|
||||
database: cfg.Database,
|
||||
retention: cfg.Retention,
|
||||
retention: resolveRetention(cfg.Version, cfg.Retention),
|
||||
chunkSize: chunkSize,
|
||||
filterTime: timeFilter(cfg.Filter.TimeStart, cfg.Filter.TimeEnd),
|
||||
filterSeries: cfg.Filter.Series,
|
||||
@@ -464,3 +496,52 @@ func (c *Client) do(q influx.Query) ([]queryValues, error) {
|
||||
}
|
||||
return parseResult(res.Results[0])
|
||||
}
|
||||
|
||||
// resolveAuth returns the credentials to authenticate with.
|
||||
//
|
||||
// InfluxDB 2.x is queried via its InfluxDB 1.x compatibility API, which accepts
|
||||
// an API token in place of the password. Therefore a non-empty token replaces
|
||||
// the password, and a placeholder username is substituted when none is given.
|
||||
func resolveAuth(username, password, token string) (string, string) {
|
||||
if token == "" {
|
||||
return username, password
|
||||
}
|
||||
if username == "" {
|
||||
username = defaultV1CompatUser
|
||||
}
|
||||
return username, token
|
||||
}
|
||||
|
||||
// resolveRetention returns the retention policy to query.
|
||||
//
|
||||
// In InfluxDB 1.x `autogen` is the retention policy created together with a
|
||||
// database, so it is a meaningful default. In InfluxDB 2.x the retention policy
|
||||
// is one half of a DBRP mapping and its name is arbitrary, so no default can be
|
||||
// assumed: an empty value makes InfluxDB use the default mapping of the
|
||||
// database instead of failing on a non-existent one.
|
||||
func resolveRetention(version int, retention string) string {
|
||||
if retention == "" && version == VersionV1 {
|
||||
return defaultRetentionV1
|
||||
}
|
||||
return retention
|
||||
}
|
||||
|
||||
// validate checks that the configuration is self-consistent.
|
||||
func (cfg *Config) validate() error {
|
||||
if cfg.Version != VersionV1 && cfg.Version != VersionV2 {
|
||||
return fmt.Errorf("unsupported InfluxDB version %d; supported versions are %d and %d",
|
||||
cfg.Version, VersionV1, VersionV2)
|
||||
}
|
||||
if cfg.Version == VersionV2 && cfg.Token == "" {
|
||||
return fmt.Errorf("-influx-token is required for InfluxDB v2")
|
||||
}
|
||||
if cfg.Version == VersionV1 && cfg.Token != "" {
|
||||
return fmt.Errorf("-influx-token is only supported for InfluxDB v2; pass -influx-version=2 to use it")
|
||||
}
|
||||
// The database is the `db` parameter of the query API. For InfluxDB 2.x
|
||||
// it is the database name of a DBRP mapping, which points at a bucket.
|
||||
if cfg.Database == "" {
|
||||
return fmt.Errorf("-influx-database cannot be empty")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
package influx
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFetchQuery(t *testing.T) {
|
||||
f := func(s *Series, timeFilter, resultExpected string) {
|
||||
@@ -126,3 +132,255 @@ func TestGetSeriesCommand(t *testing.T) {
|
||||
f("from cpu where arch='x86'", "time >= '2020-01-01T20:07:00Z'", "show series from cpu where arch='x86' AND time >= '2020-01-01T20:07:00Z'")
|
||||
f("from cpu where arch='x86' AND hostname='host_2753'", "time >= '2020-01-01T20:07:00Z'", "show series from cpu where arch='x86' AND hostname='host_2753' AND time >= '2020-01-01T20:07:00Z'")
|
||||
}
|
||||
|
||||
func TestResolveAuth(t *testing.T) {
|
||||
f := func(username, password, token, userExpected, passExpected string) {
|
||||
t.Helper()
|
||||
|
||||
user, pass := resolveAuth(username, password, token)
|
||||
if user != userExpected || pass != passExpected {
|
||||
t.Fatalf("unexpected credentials for (username=%q, password=%q, token=%q)\ngot\n(%q, %q)\nwant\n(%q, %q)",
|
||||
username, password, token, user, pass, userExpected, passExpected)
|
||||
}
|
||||
}
|
||||
|
||||
// InfluxDB 1.x: no token, credentials are passed through unchanged.
|
||||
f("", "", "", "", "")
|
||||
f("user", "pass", "", "user", "pass")
|
||||
|
||||
// InfluxDB 2.x: the API token is sent as the password. The username is
|
||||
// required by the v1 compatibility API but may be any value, so a
|
||||
// placeholder is used when the caller did not provide one.
|
||||
f("", "", "token", defaultV1CompatUser, "token")
|
||||
|
||||
// An explicitly provided username is preserved.
|
||||
f("myuser", "", "token", "myuser", "token")
|
||||
|
||||
// The token takes precedence over a password.
|
||||
f("user", "pass", "token", "user", "token")
|
||||
}
|
||||
|
||||
func TestConfigValidateSuccess(t *testing.T) {
|
||||
f := func(cfg Config) {
|
||||
t.Helper()
|
||||
|
||||
if err := cfg.validate(); err != nil {
|
||||
t.Fatalf("unexpected error for config %+v: %s", cfg, err)
|
||||
}
|
||||
}
|
||||
|
||||
// InfluxDB 1.x with and without credentials.
|
||||
f(Config{Version: 1, Database: "mydb"})
|
||||
f(Config{Version: 1, Database: "mydb", Username: "user", Password: "pass"})
|
||||
|
||||
// InfluxDB 2.x requires a token.
|
||||
f(Config{Version: 2, Database: "mydb", Token: "my-token"})
|
||||
}
|
||||
|
||||
func TestConfigValidateFailure(t *testing.T) {
|
||||
f := func(cfg Config, errStrExpected string) {
|
||||
t.Helper()
|
||||
|
||||
err := cfg.validate()
|
||||
if err == nil {
|
||||
t.Fatalf("expecting non-nil error for config %+v", cfg)
|
||||
}
|
||||
if !strings.Contains(err.Error(), errStrExpected) {
|
||||
t.Fatalf("unexpected error for config %+v\ngot\n%s\nwant it to contain\n%s", cfg, err, errStrExpected)
|
||||
}
|
||||
}
|
||||
|
||||
// unsupported versions
|
||||
f(Config{Version: 0, Database: "mydb"}, "unsupported InfluxDB version")
|
||||
f(Config{Version: 3, Database: "mydb"}, "unsupported InfluxDB version")
|
||||
|
||||
// InfluxDB 2.x without a token cannot authenticate
|
||||
f(Config{Version: 2, Database: "mydb"}, "influx-token")
|
||||
|
||||
// a token is meaningless without opting into v2
|
||||
f(Config{Version: 1, Database: "mydb", Token: "my-token"}, "influx-version=2")
|
||||
|
||||
// the database is mandatory for both versions, since it is the `db`
|
||||
// parameter of the query API
|
||||
f(Config{Version: 1}, "influx-database")
|
||||
f(Config{Version: 2, Token: "my-token"}, "influx-database")
|
||||
}
|
||||
|
||||
func TestResolveRetention(t *testing.T) {
|
||||
f := func(version int, retention, resultExpected string) {
|
||||
t.Helper()
|
||||
|
||||
result := resolveRetention(version, retention)
|
||||
if result != resultExpected {
|
||||
t.Fatalf("unexpected retention policy for (version=%d, retention=%q)\ngot\n%q\nwant\n%q",
|
||||
version, retention, result, resultExpected)
|
||||
}
|
||||
}
|
||||
|
||||
// InfluxDB 1.x keeps its historical default.
|
||||
f(VersionV1, "", "autogen")
|
||||
f(VersionV1, "all_data", "all_data")
|
||||
|
||||
// For InfluxDB 2.x the retention policy is part of a DBRP mapping whose
|
||||
// name is arbitrary, so no default may be assumed: an empty value lets
|
||||
// InfluxDB pick the default mapping of the database.
|
||||
f(VersionV2, "", "")
|
||||
f(VersionV2, "myrp", "myrp")
|
||||
}
|
||||
|
||||
// TestNewClientValidatesConfig ensures an invalid configuration is rejected
|
||||
// before any connection to InfluxDB is attempted.
|
||||
func TestNewClientValidatesConfig(t *testing.T) {
|
||||
f := func(cfg Config, errStrExpected string) {
|
||||
t.Helper()
|
||||
|
||||
cfg.Addr = "http://127.0.0.1:1"
|
||||
|
||||
c, err := NewClient(cfg)
|
||||
if err == nil {
|
||||
t.Fatalf("expecting non-nil error for config %+v", cfg)
|
||||
}
|
||||
if c != nil {
|
||||
t.Fatalf("expecting nil client for config %+v", cfg)
|
||||
}
|
||||
if !strings.Contains(err.Error(), errStrExpected) {
|
||||
t.Fatalf("unexpected error for config %+v\ngot\n%s\nwant it to contain\n%s", cfg, err, errStrExpected)
|
||||
}
|
||||
}
|
||||
|
||||
f(Config{Version: 0, Database: "mydb"}, "unsupported InfluxDB version")
|
||||
f(Config{Version: 2, Database: "mydb"}, "influx-token")
|
||||
f(Config{Version: 1, Database: "mydb", Token: "my-token"}, "influx-version=2")
|
||||
}
|
||||
|
||||
// TestNewClientTokenAuth ensures the API token is used as the password when
|
||||
// migrating from InfluxDB 2.x.
|
||||
func TestNewClientTokenAuth(t *testing.T) {
|
||||
f := func(cfg Config, userExpected, passExpected string) {
|
||||
t.Helper()
|
||||
|
||||
user, pass := resolveAuth(cfg.Username, cfg.Password, cfg.Token)
|
||||
if user != userExpected || pass != passExpected {
|
||||
t.Fatalf("unexpected credentials for config %+v\ngot\n(%q, %q)\nwant\n(%q, %q)",
|
||||
cfg, user, pass, userExpected, passExpected)
|
||||
}
|
||||
}
|
||||
|
||||
f(Config{Version: 2, Database: "mydb", Token: "my-token"}, defaultV1CompatUser, "my-token")
|
||||
f(Config{Version: 1, Database: "mydb", Username: "user", Password: "pass"}, "user", "pass")
|
||||
}
|
||||
|
||||
func TestQueryRequestAuthAndRetention(t *testing.T) {
|
||||
f := func(cfg Config, userExpected, passExpected, rpExpected string) {
|
||||
t.Helper()
|
||||
|
||||
var lastQuery queryRequest
|
||||
s := newTestServer(t, &lastQuery)
|
||||
defer s.Close()
|
||||
|
||||
cfg.Addr = s.URL
|
||||
c, err := NewClient(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error creating client: %s", err)
|
||||
}
|
||||
|
||||
if _, err := c.fieldsByMeasurement(); err != nil {
|
||||
t.Fatalf("unexpected error querying field keys: %s", err)
|
||||
}
|
||||
|
||||
got := lastQuery.get()
|
||||
if !got.seen {
|
||||
t.Fatalf("the client did not issue a /query request")
|
||||
}
|
||||
if !got.hasAuth {
|
||||
t.Fatalf("expecting basic auth to be set on the request")
|
||||
}
|
||||
if got.user != userExpected || got.password != passExpected {
|
||||
t.Fatalf("unexpected credentials on the wire\ngot\n(%q, %q)\nwant\n(%q, %q)",
|
||||
got.user, got.password, userExpected, passExpected)
|
||||
}
|
||||
if got.rp != rpExpected {
|
||||
t.Fatalf("unexpected rp parameter\ngot\n%q\nwant\n%q", got.rp, rpExpected)
|
||||
}
|
||||
if got.db != cfg.Database {
|
||||
t.Fatalf("unexpected db parameter\ngot\n%q\nwant\n%q", got.db, cfg.Database)
|
||||
}
|
||||
}
|
||||
|
||||
// InfluxDB 1.x: credentials are passed through and `autogen` is the
|
||||
// default retention policy.
|
||||
f(Config{Version: VersionV1, Database: "mydb", Username: "user", Password: "pass"},
|
||||
"user", "pass", "autogen")
|
||||
|
||||
// An explicit retention policy is honoured.
|
||||
f(Config{Version: VersionV1, Database: "mydb", Username: "user", Password: "pass", Retention: "all_data"},
|
||||
"user", "pass", "all_data")
|
||||
|
||||
// InfluxDB 2.x: the API token is sent as the password with a placeholder
|
||||
// username, and no retention policy is assumed so that the default DBRP
|
||||
// mapping of the database applies.
|
||||
f(Config{Version: VersionV2, Database: "mydb", Token: "my-token"},
|
||||
defaultV1CompatUser, "my-token", "")
|
||||
|
||||
// An explicit retention policy names the DBRP mapping to query.
|
||||
f(Config{Version: VersionV2, Database: "mydb", Token: "my-token", Retention: "myrp"},
|
||||
defaultV1CompatUser, "my-token", "myrp")
|
||||
}
|
||||
|
||||
// queryRequest holds what a /query request carried on the wire. Only the values
|
||||
// under test are kept, so nothing of the *http.Request outlives the handler.
|
||||
type queryRequest struct {
|
||||
mu sync.Mutex
|
||||
|
||||
seen bool
|
||||
user string
|
||||
password string
|
||||
hasAuth bool
|
||||
rp string
|
||||
db string
|
||||
}
|
||||
|
||||
func (q *queryRequest) set(r *http.Request) {
|
||||
user, password, hasAuth := r.BasicAuth()
|
||||
params := r.URL.Query()
|
||||
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
q.seen = true
|
||||
q.user, q.password, q.hasAuth = user, password, hasAuth
|
||||
q.rp = params.Get("rp")
|
||||
q.db = params.Get("db")
|
||||
}
|
||||
|
||||
func (q *queryRequest) get() queryRequest {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
return queryRequest{
|
||||
seen: q.seen,
|
||||
user: q.user,
|
||||
password: q.password,
|
||||
hasAuth: q.hasAuth,
|
||||
rp: q.rp,
|
||||
db: q.db,
|
||||
}
|
||||
}
|
||||
|
||||
func newTestServer(t *testing.T, lastQuery *queryRequest) *httptest.Server {
|
||||
t.Helper()
|
||||
|
||||
const fieldKeysResponse = `{"results":[{"statement_id":0,"series":[{"name":"cpu","columns":["fieldKey","fieldType"],"values":[["value","float"]]}]}]}`
|
||||
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-Influxdb-Version", "test")
|
||||
switch r.URL.Path {
|
||||
case "/ping":
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
case "/query":
|
||||
lastQuery.set(r)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(fieldKeysResponse))
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -47,7 +47,6 @@ func main() {
|
||||
start := time.Now()
|
||||
beforeFn := func(c *cli.Context) error {
|
||||
flag.Parse()
|
||||
initSecretFlags()
|
||||
logger.Init()
|
||||
isSilent = c.Bool(globalSilent)
|
||||
if c.Bool(globalDisableProgressBar) {
|
||||
@@ -142,7 +141,9 @@ func main() {
|
||||
}
|
||||
|
||||
iCfg := influx.Config{
|
||||
Version: c.Int(influxVersion),
|
||||
Addr: c.String(influxAddr),
|
||||
Token: c.String(influxToken),
|
||||
Username: c.String(influxUser),
|
||||
Password: c.String(influxPassword),
|
||||
Database: c.String(influxDB),
|
||||
@@ -620,8 +621,3 @@ func initConfigVM(c *cli.Context) (vm.Config, error) {
|
||||
Backoff: bf,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// initSecretFlags manages the secret flags for this app and must be called after flag parsing and before logger init.
|
||||
func initSecretFlags() {
|
||||
pushmetrics.InitSecretFlags()
|
||||
}
|
||||
|
||||
@@ -38,7 +38,6 @@ func main() {
|
||||
flag.CommandLine.SetOutput(os.Stdout)
|
||||
flag.Usage = usage
|
||||
envflag.Parse()
|
||||
initSecretFlags()
|
||||
buildinfo.Init()
|
||||
logger.Init()
|
||||
|
||||
@@ -113,8 +112,3 @@ func newSrcFS(ctx context.Context) (common.RemoteFS, error) {
|
||||
}
|
||||
return fs, nil
|
||||
}
|
||||
|
||||
// initSecretFlags manages the secret flags for this app and must be called after flag parsing and before logger init.
|
||||
func initSecretFlags() {
|
||||
pushmetrics.InitSecretFlags()
|
||||
}
|
||||
|
||||
@@ -26,7 +26,6 @@ import (
|
||||
"github.com/VictoriaMetrics/VictoriaMetrics/lib/logger"
|
||||
"github.com/VictoriaMetrics/VictoriaMetrics/lib/promscrape"
|
||||
"github.com/VictoriaMetrics/VictoriaMetrics/lib/querytracer"
|
||||
"github.com/VictoriaMetrics/VictoriaMetrics/lib/querytracer/push"
|
||||
"github.com/VictoriaMetrics/VictoriaMetrics/lib/timerpool"
|
||||
"github.com/VictoriaMetrics/VictoriaMetrics/lib/vmalertproxy"
|
||||
)
|
||||
@@ -60,12 +59,6 @@ func Init(vmselectMaxConcurrentRequests int, vmselectMaxQueueDuration time.Durat
|
||||
initVMUIConfig()
|
||||
|
||||
vmalertproxy.Init(*vmalertProxyURL)
|
||||
|
||||
}
|
||||
|
||||
// InitSecretFlags manages the secret flags for this pkg and must be called by app-level initSecretFlags.
|
||||
// It should run before logger initialization and package Init() (if exists).
|
||||
func InitSecretFlags() {
|
||||
flagutil.RegisterSecretFlag("vmalert.proxyURL")
|
||||
}
|
||||
|
||||
@@ -118,15 +111,8 @@ func RequestHandler(w http.ResponseWriter, r *http.Request) bool {
|
||||
// Handle non-trivial dynamic requests, which may take big amounts of time and resources.
|
||||
startTime := time.Now()
|
||||
defer requestDuration.UpdateDuration(startTime)
|
||||
tracerEnabled := httputil.GetBool(r, "trace") || push.IsEnabled()
|
||||
tracerEnabled := httputil.GetBool(r, "trace")
|
||||
qt := querytracer.New(tracerEnabled, "%s", r.URL.Path)
|
||||
if push.IsEnabled() {
|
||||
defer func() {
|
||||
if time.Since(startTime) >= push.MinTraceDuration() {
|
||||
push.Push(qt)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Limit the number of concurrent queries.
|
||||
select {
|
||||
|
||||
@@ -516,7 +516,7 @@ func DeleteHandler(startTime time.Time, r *http.Request) error {
|
||||
cp.deadline = searchutil.GetDeadlineForDelete(r, startTime)
|
||||
|
||||
if !cp.IsDefaultTimeRange() {
|
||||
return fmt.Errorf("delete API does not support specific time ranges using start and end args, the series can only be deleted completely")
|
||||
return fmt.Errorf("start=%d and end=%d args aren't supported. Remove these args from the query in order to delete all the matching metrics", cp.start, cp.end)
|
||||
}
|
||||
sq := storage.NewSearchQuery(cp.start, cp.end, cp.filterss, *maxDeleteSeries)
|
||||
deletedCount, err := netstorage.DeleteSeries(nil, sq, cp.deadline)
|
||||
@@ -540,11 +540,11 @@ func LabelValuesHandler(qt *querytracer.Tracer, startTime time.Time, labelName s
|
||||
|
||||
cp, err := getCommonParamsForLabelsAPI(r, startTime, false)
|
||||
if err != nil {
|
||||
return httpserver.InvalidParamError(err)
|
||||
return err
|
||||
}
|
||||
limit, err := httputil.GetInt(r, "limit")
|
||||
if err != nil {
|
||||
return httpserver.InvalidParamError(err)
|
||||
return err
|
||||
}
|
||||
sq := storage.NewSearchQuery(cp.start, cp.end, cp.filterss, *maxLabelsAPISeries)
|
||||
|
||||
@@ -584,7 +584,7 @@ func TSDBStatusHandler(qt *querytracer.Tracer, startTime time.Time, w http.Respo
|
||||
|
||||
cp, err := getCommonParams(r, startTime, false)
|
||||
if err != nil {
|
||||
return httpserver.InvalidParamError(err)
|
||||
return err
|
||||
}
|
||||
cp.deadline = searchutil.GetDeadlineForStatusRequest(r, startTime)
|
||||
|
||||
@@ -596,7 +596,7 @@ func TSDBStatusHandler(qt *querytracer.Tracer, startTime time.Time, w http.Respo
|
||||
} else {
|
||||
t, err := time.Parse("2006-01-02", dateStr)
|
||||
if err != nil {
|
||||
return httpserver.InvalidParamError(fmt.Errorf("cannot parse `date` arg %q: %w", dateStr, err))
|
||||
return fmt.Errorf("cannot parse `date` arg %q: %w", dateStr, err)
|
||||
}
|
||||
date = uint64(t.Unix()) / secsPerDay
|
||||
}
|
||||
@@ -607,7 +607,7 @@ func TSDBStatusHandler(qt *querytracer.Tracer, startTime time.Time, w http.Respo
|
||||
if len(topNStr) > 0 {
|
||||
n, err := strconv.Atoi(topNStr)
|
||||
if err != nil {
|
||||
return httpserver.InvalidParamError(fmt.Errorf("cannot parse `topN` arg %q: %w", topNStr, err))
|
||||
return fmt.Errorf("cannot parse `topN` arg %q: %w", topNStr, err)
|
||||
}
|
||||
if n <= 0 {
|
||||
n = 1
|
||||
@@ -645,11 +645,11 @@ func LabelsHandler(qt *querytracer.Tracer, startTime time.Time, w http.ResponseW
|
||||
|
||||
cp, err := getCommonParamsForLabelsAPI(r, startTime, false)
|
||||
if err != nil {
|
||||
return httpserver.InvalidParamError(err)
|
||||
return err
|
||||
}
|
||||
limit, err := httputil.GetInt(r, "limit")
|
||||
if err != nil {
|
||||
return httpserver.InvalidParamError(err)
|
||||
return err
|
||||
}
|
||||
sq := storage.NewSearchQuery(cp.start, cp.end, cp.filterss, *maxLabelsAPISeries)
|
||||
labels, err := netstorage.LabelNames(qt, sq, limit, cp.deadline)
|
||||
@@ -671,9 +671,10 @@ func LabelsHandler(qt *querytracer.Tracer, startTime time.Time, w http.ResponseW
|
||||
//
|
||||
// See https://prometheus.io/docs/prometheus/latest/querying/api/#querying-metric-metadata
|
||||
func MetadataHandler(qt *querytracer.Tracer, startTime time.Time, w http.ResponseWriter, r *http.Request) error {
|
||||
|
||||
limit, err := httputil.GetInt(r, "limit")
|
||||
if err != nil {
|
||||
return httpserver.InvalidParamError(err)
|
||||
return err
|
||||
}
|
||||
if limit < 0 {
|
||||
limit = 0
|
||||
@@ -733,11 +734,11 @@ func SeriesHandler(qt *querytracer.Tracer, startTime time.Time, w http.ResponseW
|
||||
// See https://github.com/VictoriaMetrics/VictoriaMetrics/issues/91
|
||||
cp, err := getCommonParamsForLabelsAPI(r, startTime, true)
|
||||
if err != nil {
|
||||
return httpserver.InvalidParamError(err)
|
||||
return err
|
||||
}
|
||||
limit, err := httputil.GetInt(r, "limit")
|
||||
if err != nil {
|
||||
return httpserver.InvalidParamError(err)
|
||||
return err
|
||||
}
|
||||
|
||||
sq := storage.NewSearchQuery(cp.start, cp.end, cp.filterss, *maxSeriesLimit)
|
||||
@@ -771,19 +772,19 @@ func QueryHandler(qt *querytracer.Tracer, startTime time.Time, w http.ResponseWr
|
||||
mayCache := !httputil.GetBool(r, "nocache")
|
||||
query := r.FormValue("query")
|
||||
if len(query) == 0 {
|
||||
return httpserver.InvalidParamError(fmt.Errorf("missing `query` arg"))
|
||||
return fmt.Errorf("missing `query` arg")
|
||||
}
|
||||
start, err := httputil.GetTime(r, "time", ct)
|
||||
if err != nil {
|
||||
return httpserver.InvalidParamError(err)
|
||||
return err
|
||||
}
|
||||
lookbackDelta, err := getMaxLookback(r)
|
||||
if err != nil {
|
||||
return httpserver.InvalidParamError(err)
|
||||
return err
|
||||
}
|
||||
step, err := httputil.GetDuration(r, "step", lookbackDelta)
|
||||
if err != nil {
|
||||
return httpserver.InvalidParamError(err)
|
||||
return err
|
||||
}
|
||||
if step <= 0 {
|
||||
step = defaultStep
|
||||
@@ -791,16 +792,16 @@ func QueryHandler(qt *querytracer.Tracer, startTime time.Time, w http.ResponseWr
|
||||
|
||||
maxLen := searchutil.GetMaxQueryLen()
|
||||
if len(query) > maxLen {
|
||||
return httpserver.InvalidParamError(fmt.Errorf("too long query; got %d bytes; mustn't exceed `-search.maxQueryLen=%d` bytes", len(query), maxLen))
|
||||
return fmt.Errorf("too long query; got %d bytes; mustn't exceed `-search.maxQueryLen=%d` bytes", len(query), maxLen)
|
||||
}
|
||||
etfs, err := searchutil.GetExtraTagFilters(r)
|
||||
if err != nil {
|
||||
return httpserver.InvalidParamError(err)
|
||||
return err
|
||||
}
|
||||
if childQuery, windowExpr, offsetExpr := promql.IsMetricSelectorWithRollup(query); childQuery != "" {
|
||||
window, err := windowExpr.NonNegativeDuration(step)
|
||||
if err != nil {
|
||||
return httpserver.InvalidParamError(fmt.Errorf("cannot parse lookbehind window in square brackets at %s: %w", query, err))
|
||||
return fmt.Errorf("cannot parse lookbehind window in square brackets at %s: %w", query, err)
|
||||
}
|
||||
offset := offsetExpr.Duration(step)
|
||||
start -= offset
|
||||
@@ -814,7 +815,7 @@ func QueryHandler(qt *querytracer.Tracer, startTime time.Time, w http.ResponseWr
|
||||
|
||||
tagFilterss, err := getTagFilterssFromMatches([]string{childQuery})
|
||||
if err != nil {
|
||||
return httpserver.InvalidParamError(err)
|
||||
return err
|
||||
}
|
||||
filterss := searchutil.JoinTagFilterss(tagFilterss, etfs)
|
||||
|
||||
@@ -830,25 +831,22 @@ func QueryHandler(qt *querytracer.Tracer, startTime time.Time, w http.ResponseWr
|
||||
return nil
|
||||
}
|
||||
if childQuery, windowExpr, stepExpr, offsetExpr := promql.IsRollup(query); childQuery != "" {
|
||||
if len(childQuery) > maxLen {
|
||||
return httpserver.InvalidParamError(fmt.Errorf("too long query; got %d bytes; mustn't exceed `-search.maxQueryLen=%d` bytes", len(childQuery), maxLen))
|
||||
}
|
||||
newStep, err := stepExpr.NonNegativeDuration(step)
|
||||
if err != nil {
|
||||
return httpserver.InvalidParamError(fmt.Errorf("cannot parse step in square brackets at %s: %w", query, err))
|
||||
return fmt.Errorf("cannot parse step in square brackets at %s: %w", query, err)
|
||||
}
|
||||
if newStep > 0 {
|
||||
step = newStep
|
||||
}
|
||||
window, err := windowExpr.NonNegativeDuration(step)
|
||||
if err != nil {
|
||||
return httpserver.InvalidParamError(fmt.Errorf("cannot parse lookbehind window in square brackets at %s: %w", query, err))
|
||||
return fmt.Errorf("cannot parse lookbehind window in square brackets at %s: %w", query, err)
|
||||
}
|
||||
offset := offsetExpr.Duration(step)
|
||||
start -= offset
|
||||
end := start
|
||||
start = end - window
|
||||
if err := queryRangeHandler(qt, startTime, w, childQuery, start, end, step, lookbackDelta, r, ct, etfs); err != nil {
|
||||
if err := queryRangeHandler(qt, startTime, w, childQuery, start, end, step, r, ct, etfs); err != nil {
|
||||
return fmt.Errorf("error when executing query=%q on the time range (start=%d, end=%d, step=%d): %w", childQuery, start, end, step, err)
|
||||
}
|
||||
return nil
|
||||
@@ -856,7 +854,7 @@ func QueryHandler(qt *querytracer.Tracer, startTime time.Time, w http.ResponseWr
|
||||
|
||||
queryOffset, err := getLatencyOffsetMilliseconds(r)
|
||||
if err != nil {
|
||||
return httpserver.InvalidParamError(err)
|
||||
return err
|
||||
}
|
||||
if !httputil.GetBool(r, "nocache") && ct-start < queryOffset && start-ct < queryOffset {
|
||||
// Adjust start time only if `nocache` arg isn't set.
|
||||
@@ -930,43 +928,45 @@ func QueryRangeHandler(qt *querytracer.Tracer, startTime time.Time, w http.Respo
|
||||
ct := startTime.UnixNano() / 1e6
|
||||
query := r.FormValue("query")
|
||||
if len(query) == 0 {
|
||||
return httpserver.InvalidParamError(fmt.Errorf("missing `query` arg"))
|
||||
}
|
||||
maxLen := searchutil.GetMaxQueryLen()
|
||||
if len(query) > maxLen {
|
||||
return httpserver.InvalidParamError(fmt.Errorf("too long query; got %d bytes; mustn't exceed `-search.maxQueryLen=%d` bytes", len(query), maxLen))
|
||||
return fmt.Errorf("missing `query` arg")
|
||||
}
|
||||
start, err := httputil.GetTime(r, "start", ct-defaultStep)
|
||||
if err != nil {
|
||||
return httpserver.InvalidParamError(err)
|
||||
return err
|
||||
}
|
||||
end, err := httputil.GetTime(r, "end", ct)
|
||||
if err != nil {
|
||||
return httpserver.InvalidParamError(err)
|
||||
return err
|
||||
}
|
||||
step, err := httputil.GetDuration(r, "step", defaultStep)
|
||||
if err != nil {
|
||||
return httpserver.InvalidParamError(err)
|
||||
return err
|
||||
}
|
||||
etfs, err := searchutil.GetExtraTagFilters(r)
|
||||
if err != nil {
|
||||
return httpserver.InvalidParamError(err)
|
||||
return err
|
||||
}
|
||||
lookbackDelta, err := getMaxLookback(r)
|
||||
if err != nil {
|
||||
return httpserver.InvalidParamError(err)
|
||||
}
|
||||
if err := queryRangeHandler(qt, startTime, w, query, start, end, step, lookbackDelta, r, ct, etfs); err != nil {
|
||||
if err := queryRangeHandler(qt, startTime, w, query, start, end, step, r, ct, etfs); err != nil {
|
||||
return fmt.Errorf("error when executing query=%q on the time range (start=%d, end=%d, step=%d): %w", query, start, end, step, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func queryRangeHandler(qt *querytracer.Tracer, startTime time.Time, w http.ResponseWriter, query string,
|
||||
start, end, step, lookbackDelta int64, r *http.Request, ct int64, etfs [][]storage.TagFilter) error {
|
||||
start, end, step int64, r *http.Request, ct int64, etfs [][]storage.TagFilter) error {
|
||||
deadline := searchutil.GetDeadlineForQuery(r, startTime)
|
||||
mayCache := !httputil.GetBool(r, "nocache")
|
||||
optimizeRepeatedBinaryOpSubexprs := httputil.GetBool(r, "optimize_repeated_binary_op_subexprs")
|
||||
lookbackDelta, err := getMaxLookback(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Validate input args.
|
||||
maxLen := searchutil.GetMaxQueryLen()
|
||||
if len(query) > maxLen {
|
||||
return fmt.Errorf("too long query; got %d bytes; mustn't exceed `-search.maxQueryLen=%d` bytes", len(query), maxLen)
|
||||
}
|
||||
if start > end {
|
||||
end = start + defaultStep
|
||||
}
|
||||
@@ -1005,7 +1005,7 @@ func queryRangeHandler(qt *querytracer.Tracer, startTime time.Time, w http.Respo
|
||||
if step < maxStepForPointsAdjustment.Milliseconds() {
|
||||
queryOffset, err := getLatencyOffsetMilliseconds(r)
|
||||
if err != nil {
|
||||
return httpserver.InvalidParamError(err)
|
||||
return err
|
||||
}
|
||||
if ct-queryOffset < end {
|
||||
result = adjustLastPoints(result, ct-queryOffset, ct+step)
|
||||
@@ -1156,13 +1156,13 @@ func QueryStatsHandler(w http.ResponseWriter, r *http.Request) error {
|
||||
if len(topNStr) > 0 {
|
||||
n, err := strconv.Atoi(topNStr)
|
||||
if err != nil {
|
||||
return httpserver.InvalidParamError(fmt.Errorf("cannot parse `topN` arg %q: %w", topNStr, err))
|
||||
return fmt.Errorf("cannot parse `topN` arg %q: %w", topNStr, err)
|
||||
}
|
||||
topN = n
|
||||
}
|
||||
maxLifetimeMsecs, err := httputil.GetDuration(r, "maxLifetime", 10*60*1000)
|
||||
if err != nil {
|
||||
return httpserver.InvalidParamError(fmt.Errorf("cannot parse `maxLifetime` arg: %w", err))
|
||||
return fmt.Errorf("cannot parse `maxLifetime` arg: %w", err)
|
||||
}
|
||||
maxLifetime := time.Duration(maxLifetimeMsecs) * time.Millisecond
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"github.com/VictoriaMetrics/VictoriaMetrics/app/vmselect/netstorage"
|
||||
"github.com/VictoriaMetrics/VictoriaMetrics/app/vmselect/querystats"
|
||||
"github.com/VictoriaMetrics/VictoriaMetrics/lib/decimal"
|
||||
"github.com/VictoriaMetrics/VictoriaMetrics/lib/httpserver"
|
||||
"github.com/VictoriaMetrics/VictoriaMetrics/lib/logger"
|
||||
"github.com/VictoriaMetrics/VictoriaMetrics/lib/querytracer"
|
||||
"github.com/VictoriaMetrics/VictoriaMetrics/lib/storage"
|
||||
@@ -47,15 +46,15 @@ func Exec(qt *querytracer.Tracer, ec *EvalConfig, q string, isFirstPointOnly boo
|
||||
|
||||
e, err := parsePromQLWithCache(q)
|
||||
if err != nil {
|
||||
return nil, httpserver.InvalidParamError(err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if *disableImplicitConversion || *logImplicitConversion {
|
||||
isInvalid := metricsql.IsLikelyInvalid(e)
|
||||
if isInvalid && *disableImplicitConversion {
|
||||
// we don't add query=%q to err message as it will be added by the caller
|
||||
return nil, httpserver.InvalidParamError(fmt.Errorf("query requires implicit conversion and is rejected according to -search.disableImplicitConversion command-line flag. " +
|
||||
"See https://docs.victoriametrics.com/victoriametrics/metricsql/#implicit-query-conversions for details"))
|
||||
return nil, fmt.Errorf("query requires implicit conversion and is rejected according to -search.disableImplicitConversion command-line flag. " +
|
||||
"See https://docs.victoriametrics.com/victoriametrics/metricsql/#implicit-query-conversions for details")
|
||||
}
|
||||
if isInvalid && *logImplicitConversion {
|
||||
logger.Warnf("query=%q requires implicit conversion, see https://docs.victoriametrics.com/victoriametrics/metricsql/#implicit-query-conversions for details", e.AppendString(nil))
|
||||
|
||||
@@ -2,7 +2,6 @@ package promql
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"math/rand"
|
||||
@@ -2567,11 +2566,6 @@ func isDecimalChar(ch byte) bool {
|
||||
func mustParseNum(s string) float64 {
|
||||
f, err := strconv.ParseFloat(s, 64)
|
||||
if err != nil {
|
||||
if errors.Is(err, strconv.ErrRange) {
|
||||
// The number is too large to fit into float64; ParseFloat returns ±Inf in this case.
|
||||
// Use ±Inf for sorting purposes — it is semantically correct.
|
||||
return f
|
||||
}
|
||||
logger.Panicf("BUG: unexpected error when parsing the number %q: %s", s, err)
|
||||
}
|
||||
return f
|
||||
|
||||
@@ -385,12 +385,4 @@ func TestNumericLess(t *testing.T) {
|
||||
f("12.9", "12.56", false)
|
||||
f("12.56", "12.9", true)
|
||||
f("12.9", "12.9", false)
|
||||
|
||||
// 309-digit numbers - must not panic (regression test for GHSA-9g98-8jgr-x2vv)
|
||||
big := strings.Repeat("9", 309)
|
||||
f(big, "1", false)
|
||||
f("1", big, true)
|
||||
f(big, big, false)
|
||||
f("-"+big, big, true)
|
||||
f(big, "-"+big, false)
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
<svg width="48" height="48" fill="#020202" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M24.5475 0C10.3246.0265251 1.11379 3.06365 4.40623 6.10077c0 0 12.32997 11.23333 16.58217 14.84083.8131.6896 2.1728 1.1936 3.5191 1.2201h.1199c1.3463-.0265 2.706-.5305 3.5191-1.2201 4.2522-3.5942 16.5422-14.84083 16.5422-14.84083C48.0478 3.06365 38.8636.0265251 24.6674 0"/>
|
||||
<path d="M28.1579 27.0159c-.8131.6896-2.1728 1.1936-3.5191 1.2201h-.12c-1.3463-.0265-2.7059-.5305-3.519-1.2201-2.9725-2.5067-13.35639-11.87-17.26201-15.3979v5.4112c0 .5968.22661 1.3793.6265 1.7506C7.00358 21.1936 17.2675 30.5437 20.9731 33.6737c.8132.6896 2.1728 1.1936 3.5191 1.2201h.12c1.3463-.0265 2.7059-.5305 3.519-1.2201 3.679-3.13 13.9429-12.4536 16.6089-14.8939.4132-.3713.6265-1.1538.6265-1.7506V11.618c-3.9323 3.5411-14.3162 12.931-17.2354 15.3979h.0267Z"/>
|
||||
<path d="M28.1579 39.748c-.8131.6897-2.1728 1.1937-3.5191 1.2202h-.12c-1.3463-.0265-2.7059-.5305-3.519-1.2202-2.9725-2.4933-13.35639-11.8567-17.26201-15.3978v5.4111c0 .5969.22661 1.3793.6265 1.7507C7.00358 33.9258 17.2675 43.2759 20.9731 46.4058c.8132.6897 2.1728 1.1937 3.5191 1.2202h.12c1.3463-.0265 2.7059-.5305 3.519-1.2202 3.679-3.1299 13.9429-12.4535 16.6089-14.8938.4132-.3714.6265-1.1538.6265-1.7507v-5.4111c-3.9323 3.5411-14.3162 12.931-17.2354 15.3978h.0267Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB |
1
app/vmselect/vmui/assets/index-BJqoElx2.css
Normal file
1
app/vmselect/vmui/assets/index-BJqoElx2.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
File diff suppressed because one or more lines are too long
197
app/vmselect/vmui/assets/index-D5egN2id.js
Normal file
197
app/vmselect/vmui/assets/index-D5egN2id.js
Normal file
File diff suppressed because one or more lines are too long
1
app/vmselect/vmui/favicon.svg
Normal file
1
app/vmselect/vmui/favicon.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg width="48" height="48" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M24.5475 0C10.3246.0265251 1.11379 3.06365 4.40623 6.10077c0 0 12.32997 11.23333 16.58217 14.84083.8131.6896 2.1728 1.1936 3.5191 1.2201h.1199c1.3463-.0265 2.706-.5305 3.5191-1.2201 4.2522-3.5942 16.5422-14.84083 16.5422-14.84083C48.0478 3.06365 38.8636.0265251 24.6674 0" fill="#020202"/><path d="M28.1579 27.0159c-.8131.6896-2.1728 1.1936-3.5191 1.2201h-.12c-1.3463-.0265-2.7059-.5305-3.519-1.2201-2.9725-2.5067-13.35639-11.87-17.26201-15.3979v5.4112c0 .5968.22661 1.3793.6265 1.7506C7.00358 21.1936 17.2675 30.5437 20.9731 33.6737c.8132.6896 2.1728 1.1936 3.5191 1.2201h.12c1.3463-.0265 2.7059-.5305 3.519-1.2201 3.679-3.13 13.9429-12.4536 16.6089-14.8939.4132-.3713.6265-1.1538.6265-1.7506V11.618c-3.9323 3.5411-14.3162 12.931-17.2354 15.3979h.0267Z" fill="#020202"/><path d="M28.1579 39.748c-.8131.6897-2.1728 1.1937-3.5191 1.2202h-.12c-1.3463-.0265-2.7059-.5305-3.519-1.2202-2.9725-2.4933-13.35639-11.8567-17.26201-15.3978v5.4111c0 .5969.22661 1.3793.6265 1.7507C7.00358 33.9258 17.2675 43.2759 20.9731 46.4058c.8132.6897 2.1728 1.1937 3.5191 1.2202h.12c1.3463-.0265 2.7059-.5305 3.519-1.2202 3.679-3.1299 13.9429-12.4535 16.6089-14.8938.4132-.3714.6265-1.1538.6265-1.7507v-5.4111c-3.9323 3.5411-14.3162 12.931-17.2354 15.3978h.0267Z" fill="#020202"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -2,9 +2,9 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<link id="favicon" rel="icon" href="./assets/favicon.svg" />
|
||||
<link rel="apple-touch-icon" href="./assets/favicon.svg" />
|
||||
<link id="mask-icon" rel="mask-icon" href="./assets/favicon.svg" color="#000000">
|
||||
<link rel="icon" href="./favicon.svg"/>
|
||||
<link rel="apple-touch-icon" href="./favicon.svg"/>
|
||||
<link rel="mask-icon" href="./favicon.svg" color="#000000">
|
||||
|
||||
<meta name="robots" content="noindex">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=5"/>
|
||||
@@ -37,11 +37,11 @@
|
||||
<meta property="og:title" content="UI for VictoriaMetrics">
|
||||
<meta property="og:url" content="https://victoriametrics.com/">
|
||||
<meta property="og:description" content="Explore and troubleshoot your VictoriaMetrics data">
|
||||
<script type="module" crossorigin src="./assets/index-BiDX4bB6.js"></script>
|
||||
<script type="module" crossorigin src="./assets/index-D5egN2id.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="./assets/rolldown-runtime-CNC7AqOf.js">
|
||||
<link rel="modulepreload" crossorigin href="./assets/vendor-DwJYpOdw.js">
|
||||
<link rel="stylesheet" crossorigin href="./assets/vendor-CnsZ1jie.css">
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-CymA7XYg.css">
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-BJqoElx2.css">
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "vmui",
|
||||
"icons": [
|
||||
{
|
||||
"src": "./assets/favicon.svg",
|
||||
"src": "favicon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml"
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM golang:1.26.6 AS build-web-stage
|
||||
FROM golang:1.26.5 AS build-web-stage
|
||||
COPY build /build
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
<svg width="48" height="48" fill="#020202" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M24.5475 0C10.3246.0265251 1.11379 3.06365 4.40623 6.10077c0 0 12.32997 11.23333 16.58217 14.84083.8131.6896 2.1728 1.1936 3.5191 1.2201h.1199c1.3463-.0265 2.706-.5305 3.5191-1.2201 4.2522-3.5942 16.5422-14.84083 16.5422-14.84083C48.0478 3.06365 38.8636.0265251 24.6674 0"/>
|
||||
<path d="M28.1579 27.0159c-.8131.6896-2.1728 1.1936-3.5191 1.2201h-.12c-1.3463-.0265-2.7059-.5305-3.519-1.2201-2.9725-2.5067-13.35639-11.87-17.26201-15.3979v5.4112c0 .5968.22661 1.3793.6265 1.7506C7.00358 21.1936 17.2675 30.5437 20.9731 33.6737c.8132.6896 2.1728 1.1936 3.5191 1.2201h.12c1.3463-.0265 2.7059-.5305 3.519-1.2201 3.679-3.13 13.9429-12.4536 16.6089-14.8939.4132-.3713.6265-1.1538.6265-1.7506V11.618c-3.9323 3.5411-14.3162 12.931-17.2354 15.3979h.0267Z"/>
|
||||
<path d="M28.1579 39.748c-.8131.6897-2.1728 1.1937-3.5191 1.2202h-.12c-1.3463-.0265-2.7059-.5305-3.519-1.2202-2.9725-2.4933-13.35639-11.8567-17.26201-15.3978v5.4111c0 .5969.22661 1.3793.6265 1.7507C7.00358 33.9258 17.2675 43.2759 20.9731 46.4058c.8132.6897 2.1728 1.1937 3.5191 1.2202h.12c1.3463-.0265 2.7059-.5305 3.519-1.2202 3.679-3.1299 13.9429-12.4535 16.6089-14.8938.4132-.3714.6265-1.1538.6265-1.7507v-5.4111c-3.9323 3.5411-14.3162 12.931-17.2354 15.3978h.0267Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB |
@@ -2,9 +2,9 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<link id="favicon" rel="icon" href="/assets/favicon.svg" />
|
||||
<link rel="apple-touch-icon" href="/assets/favicon.svg" />
|
||||
<link id="mask-icon" rel="mask-icon" href="/assets/favicon.svg?no-inline" color="#000000">
|
||||
<link rel="icon" href="/favicon.svg"/>
|
||||
<link rel="apple-touch-icon" href="/favicon.svg"/>
|
||||
<link rel="mask-icon" href="/favicon.svg" color="#000000">
|
||||
|
||||
<meta name="robots" content="noindex">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=5"/>
|
||||
|
||||
1
app/vmui/packages/vmui/public/favicon.svg
Normal file
1
app/vmui/packages/vmui/public/favicon.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg width="48" height="48" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M24.5475 0C10.3246.0265251 1.11379 3.06365 4.40623 6.10077c0 0 12.32997 11.23333 16.58217 14.84083.8131.6896 2.1728 1.1936 3.5191 1.2201h.1199c1.3463-.0265 2.706-.5305 3.5191-1.2201 4.2522-3.5942 16.5422-14.84083 16.5422-14.84083C48.0478 3.06365 38.8636.0265251 24.6674 0" fill="#020202"/><path d="M28.1579 27.0159c-.8131.6896-2.1728 1.1936-3.5191 1.2201h-.12c-1.3463-.0265-2.7059-.5305-3.519-1.2201-2.9725-2.5067-13.35639-11.87-17.26201-15.3979v5.4112c0 .5968.22661 1.3793.6265 1.7506C7.00358 21.1936 17.2675 30.5437 20.9731 33.6737c.8132.6896 2.1728 1.1936 3.5191 1.2201h.12c1.3463-.0265 2.7059-.5305 3.519-1.2201 3.679-3.13 13.9429-12.4536 16.6089-14.8939.4132-.3713.6265-1.1538.6265-1.7506V11.618c-3.9323 3.5411-14.3162 12.931-17.2354 15.3979h.0267Z" fill="#020202"/><path d="M28.1579 39.748c-.8131.6897-2.1728 1.1937-3.5191 1.2202h-.12c-1.3463-.0265-2.7059-.5305-3.519-1.2202-2.9725-2.4933-13.35639-11.8567-17.26201-15.3978v5.4111c0 .5969.22661 1.3793.6265 1.7507C7.00358 33.9258 17.2675 43.2759 20.9731 46.4058c.8132.6897 2.1728 1.1937 3.5191 1.2202h.12c1.3463-.0265 2.7059-.5305 3.519-1.2202 3.679-3.1299 13.9429-12.4535 16.6089-14.8938.4132-.3714.6265-1.1538.6265-1.7507v-5.4111c-3.9323 3.5411-14.3162 12.931-17.2354 15.3978h.0267Z" fill="#020202"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -3,7 +3,7 @@
|
||||
"name": "vmui",
|
||||
"icons": [
|
||||
{
|
||||
"src": "./assets/favicon.svg",
|
||||
"src": "favicon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml"
|
||||
}
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import { FC, useMemo } from "preact/compat";
|
||||
import "./style.scss";
|
||||
import { createFaviconUrl } from "../../../../utils/favicon";
|
||||
import classNames from "classnames";
|
||||
import { useBrowserTabSync } from "./hooks/useBrowserTabSync";
|
||||
import { CloseIcon } from "../../../Main/Icons";
|
||||
import { faviconColors } from "../../../../constants/faviconColors";
|
||||
|
||||
const BrowserTabController: FC = () => {
|
||||
const { faviconColor, changeFaviconColor } = useBrowserTabSync();
|
||||
|
||||
const faviconUrl = useMemo(() => {
|
||||
return createFaviconUrl(faviconColor);
|
||||
}, [faviconColor]);
|
||||
|
||||
const createHandlerClick = (color?: string) => () => {
|
||||
changeFaviconColor(color);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="vm-browser-tab-controller">
|
||||
<p className="vm-server-configurator__title">Favicon color</p>
|
||||
|
||||
<div className="vm-browser-tab-controller-palette">
|
||||
<div className="vm-browser-tab-controller-palette-list">
|
||||
<button
|
||||
className="vm-browser-tab-controller-palette-list__item vm-browser-tab-controller-palette-list__item_reset"
|
||||
type="button"
|
||||
onClick={createHandlerClick()}
|
||||
aria-label="Reset favicon color"
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
|
||||
{faviconColors.map(color => (
|
||||
<button
|
||||
className={classNames({
|
||||
"vm-browser-tab-controller-palette-list__item": true,
|
||||
"vm-browser-tab-controller-palette-list__item_selected": faviconColor === color
|
||||
})}
|
||||
key={color}
|
||||
type="button"
|
||||
style={{ color }}
|
||||
onClick={createHandlerClick(color)}
|
||||
aria-label={`Set favicon color to ${color}`}
|
||||
aria-pressed={faviconColor === color}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<img
|
||||
className="vm-browser-tab-controller-palette__preview"
|
||||
src={faviconUrl}
|
||||
alt="Favicon preview"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BrowserTabController;
|
||||
@@ -1,41 +0,0 @@
|
||||
import useEventListener from "../../../../../hooks/useEventListener";
|
||||
import { useEffect, useState } from "preact/compat";
|
||||
import { getFromStorage, removeFromStorage, saveToStorage } from "../../../../../utils/storage";
|
||||
import { getFaviconStorageKey, updateFaviconColor } from "../../../../../utils/favicon";
|
||||
|
||||
const storageKey = `FAVICON_COLOR:${getFaviconStorageKey()}` as const;
|
||||
|
||||
const getColorFromStorage = () => {
|
||||
return getFromStorage(storageKey) as string | undefined;
|
||||
};
|
||||
|
||||
export const useBrowserTabSync = () => {
|
||||
const [faviconColor, setFaviconColor] = useState(getColorFromStorage);
|
||||
|
||||
const handleUpdateColor = () => {
|
||||
setFaviconColor(getColorFromStorage());
|
||||
};
|
||||
|
||||
const changeFaviconColor = (color?: string) => {
|
||||
if (color) {
|
||||
saveToStorage(storageKey, color);
|
||||
} else {
|
||||
removeFromStorage([storageKey]);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
handleUpdateColor();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
updateFaviconColor(faviconColor);
|
||||
}, [faviconColor]);
|
||||
|
||||
useEventListener("storage", handleUpdateColor);
|
||||
|
||||
return {
|
||||
faviconColor,
|
||||
changeFaviconColor,
|
||||
};
|
||||
};
|
||||
@@ -1,71 +0,0 @@
|
||||
@use "src/styles/variables" as *;
|
||||
|
||||
$color-item-size: 28px;
|
||||
$outline-width: 2px;
|
||||
$outline-offset: 2px;
|
||||
$outline-space: $outline-width + $outline-offset;
|
||||
|
||||
.vm-browser-tab-controller {
|
||||
.vm-server-configurator__title {
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
&-palette {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: calc($padding-large * 2);
|
||||
|
||||
&-list {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
flex-wrap: wrap;
|
||||
gap: $padding-small;
|
||||
|
||||
&__item {
|
||||
width: $color-item-size;
|
||||
height: $color-item-size;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 50%;
|
||||
background-color: currentColor;
|
||||
cursor: pointer;
|
||||
transition-property: transform;
|
||||
transition-duration: 0.15s;
|
||||
transition-timing-function: linear;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
&_reset {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: $border-divider;
|
||||
color: $color-text-disabled;
|
||||
padding: calc($padding-small / 2);
|
||||
}
|
||||
|
||||
&_selected {
|
||||
width: $color-item-size - 2 * $outline-space;
|
||||
height: $color-item-size - 2 * $outline-space;
|
||||
margin: $outline-space;
|
||||
outline: $outline-width solid currentColor;
|
||||
outline-offset: $outline-offset;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__preview {
|
||||
width: $color-item-size;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { forwardRef, useImperativeHandle, useRef } from "preact/compat";
|
||||
import { FC, useRef } from "preact/compat";
|
||||
import ServerConfigurator from "./ServerConfigurator/ServerConfigurator";
|
||||
import { ArrowDownIcon, SettingsIcon } from "../../Main/Icons";
|
||||
import Button from "../../Main/Button/Button";
|
||||
@@ -8,12 +8,10 @@ import Tooltip from "../../Main/Tooltip/Tooltip";
|
||||
import LimitsConfigurator from "./LimitsConfigurator/LimitsConfigurator";
|
||||
import { getAppModeEnable } from "../../../utils/app-mode";
|
||||
import classNames from "classnames";
|
||||
import TimezonesPicker from "./Timezones/TimezonesPicker";
|
||||
import Timezones from "./Timezones/Timezones";
|
||||
import ThemeControl from "../ThemeControl/ThemeControl";
|
||||
import useDeviceDetect from "../../../hooks/useDeviceDetect";
|
||||
import useBoolean from "../../../hooks/useBoolean";
|
||||
import BrowserTabController from "./BrowserTabController/BrowserTabController";
|
||||
import LegendCollapseController from "./LegendCollapseController/LegendCollapseController";
|
||||
|
||||
const title = "Settings";
|
||||
|
||||
@@ -21,17 +19,14 @@ export interface ChildComponentHandle {
|
||||
handleApply: () => void;
|
||||
}
|
||||
|
||||
export interface GlobalSettingsHandle {
|
||||
open: () => void;
|
||||
}
|
||||
|
||||
const GlobalSettings = forwardRef<GlobalSettingsHandle>((_, ref) => {
|
||||
const GlobalSettings: FC = () => {
|
||||
const { isMobile } = useDeviceDetect();
|
||||
|
||||
const appModeEnable = getAppModeEnable();
|
||||
|
||||
const serverSettingRef = useRef<ChildComponentHandle>(null);
|
||||
const limitsSettingRef = useRef<ChildComponentHandle>(null);
|
||||
const timezoneSettingRef = useRef<ChildComponentHandle>(null);
|
||||
|
||||
const {
|
||||
value: open,
|
||||
@@ -42,6 +37,7 @@ const GlobalSettings = forwardRef<GlobalSettingsHandle>((_, ref) => {
|
||||
const handleApply = () => {
|
||||
serverSettingRef.current && serverSettingRef.current.handleApply();
|
||||
limitsSettingRef.current && limitsSettingRef.current.handleApply();
|
||||
timezoneSettingRef.current && timezoneSettingRef.current.handleApply();
|
||||
handleClose();
|
||||
};
|
||||
|
||||
@@ -53,10 +49,6 @@ const GlobalSettings = forwardRef<GlobalSettingsHandle>((_, ref) => {
|
||||
onClose={handleClose}
|
||||
/>
|
||||
},
|
||||
{
|
||||
show: true,
|
||||
component: <TimezonesPicker/>
|
||||
},
|
||||
{
|
||||
show: true,
|
||||
component: <LimitsConfigurator
|
||||
@@ -66,22 +58,14 @@ const GlobalSettings = forwardRef<GlobalSettingsHandle>((_, ref) => {
|
||||
},
|
||||
{
|
||||
show: true,
|
||||
component: <LegendCollapseController/>
|
||||
component: <Timezones ref={timezoneSettingRef}/>
|
||||
},
|
||||
{
|
||||
show: !appModeEnable,
|
||||
component: <ThemeControl/>
|
||||
},
|
||||
{
|
||||
show: true,
|
||||
component: <BrowserTabController/>
|
||||
},
|
||||
}
|
||||
].filter(control => control.show);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
open: handleOpen,
|
||||
}));
|
||||
|
||||
return <>
|
||||
{isMobile ? (
|
||||
<div
|
||||
@@ -147,6 +131,6 @@ const GlobalSettings = forwardRef<GlobalSettingsHandle>((_, ref) => {
|
||||
</Modal>
|
||||
)}
|
||||
</>;
|
||||
});
|
||||
};
|
||||
|
||||
export default GlobalSettings;
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import { FC, useEffect, useState } from "preact/compat";
|
||||
import { getFromStorage, saveToStorage } from "../../../../utils/storage";
|
||||
import Switch from "../../../Main/Switch/Switch";
|
||||
import { LEGEND_COLLAPSE_SERIES_LIMIT } from "../../../../constants/graph";
|
||||
import "./style.scss";
|
||||
|
||||
const LegendCollapseController: FC = () => {
|
||||
const storageCollapse = getFromStorage("LEGEND_AUTO_COLLAPSE");
|
||||
const [legendCollapse, setLegendCollapse] = useState(storageCollapse ? storageCollapse === "true" : true);
|
||||
|
||||
useEffect(() => {
|
||||
saveToStorage("LEGEND_AUTO_COLLAPSE", `${legendCollapse}`);
|
||||
}, [legendCollapse]);
|
||||
|
||||
return (
|
||||
<div className="vm-legend-collapse-controller">
|
||||
<Switch
|
||||
fullWidth
|
||||
color="neutral"
|
||||
value={legendCollapse}
|
||||
onChange={setLegendCollapse}
|
||||
label={<span className="vm-server-configurator__title">Auto-collapse legend</span>}
|
||||
/>
|
||||
<span className="vm-legend-collapse-controller__description">
|
||||
Collapses the legend when series count exceeds {LEGEND_COLLAPSE_SERIES_LIMIT} to reduce UI load.
|
||||
</span>
|
||||
</div>);
|
||||
};
|
||||
|
||||
export default LegendCollapseController;
|
||||
@@ -1,19 +0,0 @@
|
||||
@use "src/styles/variables" as *;
|
||||
|
||||
.vm-legend-collapse-controller {
|
||||
background-color: $color-hover-black;
|
||||
border-radius: $border-radius-medium;
|
||||
padding: $padding-large;
|
||||
border: $border-divider;
|
||||
|
||||
.vm-graph-settings-row__label {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__description {
|
||||
padding-top: $padding-global;
|
||||
font-size: $font-size-small;
|
||||
line-height: 1.3;
|
||||
text-wrap: pretty;
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,23 @@
|
||||
import { forwardRef, useCallback, useImperativeHandle, useState } from "preact/compat";
|
||||
import { forwardRef, useCallback, useEffect, useImperativeHandle, useState } from "preact/compat";
|
||||
import { DisplayType, ErrorTypes } from "../../../../types";
|
||||
import TextField from "../../../Main/TextField/TextField";
|
||||
import Tooltip from "../../../Main/Tooltip/Tooltip";
|
||||
import { InfoIcon, RestartIcon } from "../../../Main/Icons";
|
||||
import Button from "../../../Main/Button/Button";
|
||||
import { DEFAULT_MAX_SERIES } from "../../../../constants/graph";
|
||||
import { DEFAULT_MAX_SERIES, LEGEND_COLLAPSE_SERIES_LIMIT } from "../../../../constants/graph";
|
||||
import "./style.scss";
|
||||
import classNames from "classnames";
|
||||
import useDeviceDetect from "../../../../hooks/useDeviceDetect";
|
||||
import { ChildComponentHandle } from "../GlobalSettings";
|
||||
import { useCustomPanelDispatch, useCustomPanelState } from "../../../../state/customPanel/CustomPanelStateContext";
|
||||
import Switch from "../../../Main/Switch/Switch";
|
||||
import { getFromStorage, saveToStorage } from "../../../../utils/storage";
|
||||
|
||||
interface ServerConfiguratorProps {
|
||||
onClose: () => void;
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const fields: { label: string, type: DisplayType }[] = [
|
||||
const fields: {label: string, type: DisplayType}[] = [
|
||||
{ label: "Graph", type: DisplayType.chart },
|
||||
{ label: "JSON", type: DisplayType.code },
|
||||
{ label: "Table", type: DisplayType.table }
|
||||
@@ -27,7 +29,8 @@ const LimitsConfigurator = forwardRef<ChildComponentHandle, ServerConfiguratorPr
|
||||
const { seriesLimits } = useCustomPanelState();
|
||||
const customPanelDispatch = useCustomPanelDispatch();
|
||||
|
||||
|
||||
const storageCollapse = getFromStorage("LEGEND_AUTO_COLLAPSE");
|
||||
const [legendCollapse, setLegendCollapse] = useState(storageCollapse ? storageCollapse === "true" : true);
|
||||
|
||||
const [limits, setLimits] = useState(seriesLimits);
|
||||
const [error, setError] = useState({
|
||||
@@ -40,7 +43,7 @@ const LimitsConfigurator = forwardRef<ChildComponentHandle, ServerConfiguratorPr
|
||||
setLimits(DEFAULT_MAX_SERIES);
|
||||
};
|
||||
|
||||
const createChangeHandler = (type: DisplayType) => (val: string) => {
|
||||
const createChangeHandler = (type: DisplayType) => (val: string) => {
|
||||
const value = val || "";
|
||||
setError(prev => ({ ...prev, [type]: +value < 0 ? ErrorTypes.positiveNumber : "" }));
|
||||
setLimits({
|
||||
@@ -54,6 +57,10 @@ const LimitsConfigurator = forwardRef<ChildComponentHandle, ServerConfiguratorPr
|
||||
onClose();
|
||||
}, [limits]);
|
||||
|
||||
useEffect(() => {
|
||||
saveToStorage("LEGEND_AUTO_COLLAPSE", `${legendCollapse}`);
|
||||
}, [legendCollapse]);
|
||||
|
||||
useImperativeHandle(ref, () => ({ handleApply }), [handleApply]);
|
||||
|
||||
return (
|
||||
@@ -99,6 +106,19 @@ const LimitsConfigurator = forwardRef<ChildComponentHandle, ServerConfiguratorPr
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="vm-graph-settings-row">
|
||||
<span className="vm-graph-settings-row__label">Auto-collapse legend</span>
|
||||
<Switch
|
||||
value={legendCollapse}
|
||||
onChange={setLegendCollapse}
|
||||
label={legendCollapse ? "Enabled" : "Disabled"}
|
||||
fullWidth={isMobile}
|
||||
/>
|
||||
<span className="vm-legend-configs-item__info">
|
||||
Collapses the legend when series count exceeds {LEGEND_COLLAPSE_SERIES_LIMIT} to reduce UI load.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import { FC } from "preact/compat";
|
||||
import Button from "../../../Main/Button/Button";
|
||||
import { useTimeState } from "../../../../state/time/TimeStateContext";
|
||||
import useDeviceDetect from "../../../../hooks/useDeviceDetect";
|
||||
import { getUTCByTimezone } from "../../../../utils/time";
|
||||
import { useMemo } from "react";
|
||||
import { ArrowDownIcon, PlanetIcon } from "../../../Main/Icons";
|
||||
|
||||
type Props = {
|
||||
onOpenSettings?: () => void;
|
||||
}
|
||||
|
||||
const TimeZonePreview: FC<Props> = ({ onOpenSettings }) => {
|
||||
const { isMobile } = useDeviceDetect();
|
||||
|
||||
const { timezone } = useTimeState();
|
||||
const utcOffset = useMemo(() => getUTCByTimezone(timezone), [timezone]);
|
||||
|
||||
const handleOpenSettings = () => {
|
||||
onOpenSettings && onOpenSettings();
|
||||
};
|
||||
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<button
|
||||
className="vm-mobile-option"
|
||||
onClick={handleOpenSettings}
|
||||
>
|
||||
<span className="vm-mobile-option__icon"><PlanetIcon/></span>
|
||||
<div className="vm-mobile-option-text">
|
||||
<span className="vm-mobile-option-text__label">Time zone</span>
|
||||
<span className="vm-mobile-option-text__value">{utcOffset}</span>
|
||||
</div>
|
||||
<span className="vm-mobile-option__arrow"><ArrowDownIcon/></span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
className="vm-header-button"
|
||||
onClick={handleOpenSettings}
|
||||
startIcon={<PlanetIcon/>}
|
||||
>
|
||||
{utcOffset}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
export default TimeZonePreview;
|
||||
@@ -0,0 +1,183 @@
|
||||
import { FC, forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from "preact/compat";
|
||||
import { getBrowserTimezone, getTimezoneList, getUTCByTimezone } from "../../../../utils/time";
|
||||
import { ArrowDropDownIcon } from "../../../Main/Icons";
|
||||
import classNames from "classnames";
|
||||
import Popper from "../../../Main/Popper/Popper";
|
||||
import Accordion from "../../../Main/Accordion/Accordion";
|
||||
import TextField from "../../../Main/TextField/TextField";
|
||||
import { Timezone } from "../../../../types";
|
||||
import "./style.scss";
|
||||
import useDeviceDetect from "../../../../hooks/useDeviceDetect";
|
||||
import useBoolean from "../../../../hooks/useBoolean";
|
||||
import WarningTimezone from "./WarningTimezone";
|
||||
import { useTimeDispatch, useTimeState } from "../../../../state/time/TimeStateContext";
|
||||
|
||||
interface PinnedTimezone extends Timezone {
|
||||
title: string;
|
||||
isInvalid?: boolean;
|
||||
}
|
||||
|
||||
const browserTimezone = getBrowserTimezone();
|
||||
|
||||
const Timezones: FC = forwardRef((props, ref) => {
|
||||
const { isMobile } = useDeviceDetect();
|
||||
const timezones = getTimezoneList();
|
||||
|
||||
const { timezone: stateTimezone, defaultTimezone } = useTimeState();
|
||||
const timeDispatch = useTimeDispatch();
|
||||
|
||||
const [timezone, setTimezone] = useState(stateTimezone);
|
||||
const [search, setSearch] = useState("");
|
||||
const targetRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const {
|
||||
value: openList,
|
||||
toggle: toggleOpenList,
|
||||
setFalse: handleCloseList,
|
||||
} = useBoolean(false);
|
||||
|
||||
const pinnedTimezones = useMemo(() => [
|
||||
{
|
||||
title: `Default time (${defaultTimezone})`,
|
||||
region: defaultTimezone,
|
||||
utc: defaultTimezone ? getUTCByTimezone(defaultTimezone) : "UTC"
|
||||
},
|
||||
{
|
||||
title: browserTimezone.title,
|
||||
region: browserTimezone.region,
|
||||
utc: getUTCByTimezone(browserTimezone.region),
|
||||
isInvalid: !browserTimezone.isValid
|
||||
},
|
||||
{
|
||||
title: "UTC (Coordinated Universal Time)",
|
||||
region: "UTC",
|
||||
utc: "UTC"
|
||||
},
|
||||
].filter(t => t.region) as PinnedTimezone[], [defaultTimezone]);
|
||||
|
||||
const searchTimezones = useMemo(() => {
|
||||
if (!search) return timezones;
|
||||
try {
|
||||
return getTimezoneList(search);
|
||||
} catch (e) {
|
||||
return {};
|
||||
}
|
||||
}, [search, timezones]);
|
||||
|
||||
const timezonesGroups = useMemo(() => Object.keys(searchTimezones), [searchTimezones]);
|
||||
|
||||
const activeTimezone = useMemo(() => ({
|
||||
region: timezone,
|
||||
utc: getUTCByTimezone(timezone)
|
||||
}), [timezone]);
|
||||
|
||||
const handleChangeSearch = (val: string) => {
|
||||
setSearch(val);
|
||||
};
|
||||
|
||||
const handleSetTimezone = (val: Timezone) => {
|
||||
setTimezone(val.region);
|
||||
setSearch("");
|
||||
handleCloseList();
|
||||
};
|
||||
|
||||
const createHandlerSetTimezone = (val: Timezone) => () => {
|
||||
handleSetTimezone(val);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setTimezone(stateTimezone);
|
||||
}, [stateTimezone]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
handleApply: () => {
|
||||
timeDispatch({ type: "SET_TIMEZONE", payload: timezone });
|
||||
}
|
||||
}), [timezone]);
|
||||
|
||||
return (
|
||||
<div className="vm-timezones">
|
||||
<div className="vm-server-configurator__title">
|
||||
Time zone
|
||||
</div>
|
||||
<div
|
||||
className="vm-timezones-item vm-timezones-item_selected"
|
||||
onClick={toggleOpenList}
|
||||
ref={targetRef}
|
||||
>
|
||||
<div className="vm-timezones-item__title">{activeTimezone.region}</div>
|
||||
<div className="vm-timezones-item__utc">{activeTimezone.utc}</div>
|
||||
<div
|
||||
className={classNames({
|
||||
"vm-timezones-item__icon": true,
|
||||
"vm-timezones-item__icon_open": openList
|
||||
})}
|
||||
>
|
||||
<ArrowDropDownIcon/>
|
||||
</div>
|
||||
</div>
|
||||
<Popper
|
||||
open={openList}
|
||||
buttonRef={targetRef}
|
||||
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-header">
|
||||
<div className="vm-timezones-list-header__search">
|
||||
<TextField
|
||||
autofocus
|
||||
label="Search"
|
||||
value={search}
|
||||
onChange={handleChangeSearch}
|
||||
/>
|
||||
</div>
|
||||
{pinnedTimezones.map((t, i) => t && (
|
||||
<div
|
||||
key={`${i}_${t.region}`}
|
||||
className="vm-timezones-item vm-timezones-list-group-options__item"
|
||||
onClick={createHandlerSetTimezone(t)}
|
||||
>
|
||||
<div className="vm-timezones-item__title">{t.title}{t.isInvalid && <WarningTimezone/>}</div>
|
||||
<div className="vm-timezones-item__utc">{t.utc}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{timezonesGroups.map(t => (
|
||||
<div
|
||||
className="vm-timezones-list-group"
|
||||
key={t}
|
||||
>
|
||||
<Accordion
|
||||
defaultExpanded={true}
|
||||
title={<div className="vm-timezones-list-group__title">{t}</div>}
|
||||
>
|
||||
<div className="vm-timezones-list-group-options">
|
||||
{searchTimezones[t] && searchTimezones[t].map(item => (
|
||||
<div
|
||||
className="vm-timezones-item vm-timezones-list-group-options__item"
|
||||
onClick={createHandlerSetTimezone(item)}
|
||||
key={item.search}
|
||||
>
|
||||
<div className="vm-timezones-item__title">{item.region}</div>
|
||||
<div className="vm-timezones-item__utc">{item.utc}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Accordion>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Popper>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default Timezones;
|
||||
@@ -1,129 +0,0 @@
|
||||
import { FC, useMemo, useState } from "preact/compat";
|
||||
import { getBrowserTimezone, getTimezoneList, getUTCByTimezone } from "../../../../utils/time";
|
||||
import classNames from "classnames";
|
||||
import Accordion from "../../../Main/Accordion/Accordion";
|
||||
import TextField from "../../../Main/TextField/TextField";
|
||||
import { Timezone } from "../../../../types";
|
||||
import "./style.scss";
|
||||
import useDeviceDetect from "../../../../hooks/useDeviceDetect";
|
||||
import WarningTimezone from "./WarningTimezone";
|
||||
import { useTimeState } from "../../../../state/time/TimeStateContext";
|
||||
|
||||
interface PinnedTimezone extends Timezone {
|
||||
title: string;
|
||||
isInvalid?: boolean;
|
||||
}
|
||||
|
||||
type Props = {
|
||||
onChange: (tz: Timezone) => void;
|
||||
}
|
||||
|
||||
const browserTimezone = getBrowserTimezone();
|
||||
|
||||
const TimezonesList: FC<Props> = ({ onChange }) => {
|
||||
const { isMobile } = useDeviceDetect();
|
||||
const { defaultTimezone } = useTimeState();
|
||||
|
||||
const timezones = useMemo(() => getTimezoneList(), []);
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const pinnedTimezones = useMemo(() => [
|
||||
{
|
||||
title: `Default time (${defaultTimezone})`,
|
||||
region: defaultTimezone,
|
||||
utc: defaultTimezone ? getUTCByTimezone(defaultTimezone) : "UTC"
|
||||
},
|
||||
{
|
||||
title: browserTimezone.title,
|
||||
region: browserTimezone.region,
|
||||
utc: getUTCByTimezone(browserTimezone.region),
|
||||
isInvalid: !browserTimezone.isValid
|
||||
},
|
||||
{
|
||||
title: "UTC (Coordinated Universal Time)",
|
||||
region: "UTC",
|
||||
utc: "UTC"
|
||||
},
|
||||
].filter(t => t.region) as PinnedTimezone[], [defaultTimezone]);
|
||||
|
||||
const searchTimezones = useMemo(() => {
|
||||
if (!search) return timezones;
|
||||
try {
|
||||
return getTimezoneList(search);
|
||||
} catch (e) {
|
||||
return {};
|
||||
}
|
||||
}, [search, timezones]);
|
||||
|
||||
const timezonesGroups = useMemo(() => Object.keys(searchTimezones), [searchTimezones]);
|
||||
|
||||
const handleChangeSearch = (val: string) => {
|
||||
setSearch(val);
|
||||
};
|
||||
|
||||
const handleSetTimezone = (tz: Timezone) => {
|
||||
onChange(tz);
|
||||
setSearch("");
|
||||
};
|
||||
|
||||
const createHandlerSetTimezone = (val: Timezone) => () => {
|
||||
handleSetTimezone(val);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames({
|
||||
"vm-list": true,
|
||||
"vm-timezones-list": true,
|
||||
"vm-timezones-list_mobile": isMobile,
|
||||
})}
|
||||
>
|
||||
<div className="vm-timezones-list-header">
|
||||
<div className="vm-timezones-list-header__search">
|
||||
<TextField
|
||||
label="Search"
|
||||
value={search}
|
||||
onChange={handleChangeSearch}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{pinnedTimezones.map((t, i) => t && (
|
||||
<div
|
||||
key={`${i}_${t.region}`}
|
||||
className="vm-list-item vm-timezones-item vm-timezones-list-group-options__item"
|
||||
onClick={createHandlerSetTimezone(t)}
|
||||
>
|
||||
<div className="vm-timezones-item__title">{t.title}{t.isInvalid && <WarningTimezone/>}</div>
|
||||
<div className="vm-timezones-item__utc">{t.utc}</div>
|
||||
</div>
|
||||
))}
|
||||
{timezonesGroups.map(t => (
|
||||
<div
|
||||
className="vm-timezones-list-group"
|
||||
key={t}
|
||||
>
|
||||
<Accordion
|
||||
defaultExpanded={true}
|
||||
title={<div className="vm-timezones-list-group__title">{t}</div>}
|
||||
>
|
||||
<div className="vm-timezones-list-group-options">
|
||||
{searchTimezones[t] && searchTimezones[t].map(item => (
|
||||
<div
|
||||
className="vm-list-item vm-timezones-item vm-timezones-list-group-options__item"
|
||||
onClick={createHandlerSetTimezone(item)}
|
||||
key={item.search}
|
||||
>
|
||||
<div className="vm-timezones-item__title">{item.region}</div>
|
||||
<div className="vm-timezones-item__utc">{item.utc}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Accordion>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TimezonesList;
|
||||
@@ -1,71 +0,0 @@
|
||||
import { FC, useMemo, useRef } from "preact/compat";
|
||||
import { getUTCByTimezone } from "../../../../utils/time";
|
||||
import { ArrowDropDownIcon } from "../../../Main/Icons";
|
||||
import classNames from "classnames";
|
||||
import { Timezone } from "../../../../types";
|
||||
import "./style.scss";
|
||||
import useBoolean from "../../../../hooks/useBoolean";
|
||||
import { useTimeDispatch, useTimeState } from "../../../../state/time/TimeStateContext";
|
||||
import TimezonesList from "./TimezonesList";
|
||||
import Popper from "../../../Main/Popper/Popper";
|
||||
import useDeviceDetect from "../../../../hooks/useDeviceDetect";
|
||||
|
||||
const TimezonesPicker: FC = () => {
|
||||
const { isMobile } = useDeviceDetect();
|
||||
const { timezone: stateTimezone } = useTimeState();
|
||||
const timeDispatch = useTimeDispatch();
|
||||
|
||||
const triggerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const {
|
||||
value: isOpenList,
|
||||
toggle: toggleOpenList,
|
||||
setFalse: handleCloseList,
|
||||
} = useBoolean(false);
|
||||
|
||||
const activeTimezone = useMemo(() => ({
|
||||
region: stateTimezone,
|
||||
utc: getUTCByTimezone(stateTimezone)
|
||||
}), [stateTimezone]);
|
||||
|
||||
const handleSetTimezone = (tz: Timezone) => {
|
||||
timeDispatch({ type: "SET_TIMEZONE", payload: tz.region });
|
||||
handleCloseList();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="vm-timezones">
|
||||
<div className="vm-server-configurator__title">
|
||||
Time zone
|
||||
</div>
|
||||
<div
|
||||
className="vm-timezones-item vm-timezones-item_selected"
|
||||
onClick={toggleOpenList}
|
||||
ref={triggerRef}
|
||||
>
|
||||
<div className="vm-timezones-item__title">{activeTimezone.region}</div>
|
||||
<div className="vm-timezones-item__utc">{activeTimezone.utc}</div>
|
||||
<div
|
||||
className={classNames({
|
||||
"vm-timezones-item__icon": true,
|
||||
"vm-timezones-item__icon_open": isOpenList
|
||||
})}
|
||||
>
|
||||
<ArrowDropDownIcon/>
|
||||
</div>
|
||||
</div>
|
||||
<Popper
|
||||
open={isOpenList}
|
||||
buttonRef={triggerRef}
|
||||
placement="bottom-left"
|
||||
onClose={handleCloseList}
|
||||
fullWidth
|
||||
title={isMobile ? "Time zone" : undefined}
|
||||
>
|
||||
<TimezonesList onChange={handleSetTimezone}/>
|
||||
</Popper>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TimezonesPicker;
|
||||
@@ -16,7 +16,6 @@
|
||||
}
|
||||
|
||||
&__title {
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $padding-small;
|
||||
@@ -35,7 +34,6 @@
|
||||
background-color: $color-hover-black;
|
||||
padding: calc($padding-small/2);
|
||||
border-radius: $border-radius-small;
|
||||
font-size: $font-size-small;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
@@ -56,11 +54,9 @@
|
||||
}
|
||||
|
||||
&-list {
|
||||
padding-top: 0;
|
||||
max-height: 300px;
|
||||
background-color: $color-background-block;
|
||||
border-radius: $border-radius-medium;
|
||||
font-size: $font-size-small;
|
||||
overflow: auto;
|
||||
|
||||
&_mobile {
|
||||
@@ -76,9 +72,10 @@
|
||||
top: 0;
|
||||
background-color: $color-background-block;
|
||||
z-index: 2;
|
||||
border-bottom: $border-divider;
|
||||
|
||||
&__search {
|
||||
padding: $padding-small $padding-small calc($padding-small / 2);
|
||||
padding: $padding-small;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,7 +91,6 @@
|
||||
font-weight: bold;
|
||||
color: $color-text-secondary;
|
||||
padding: $padding-small $padding-global;
|
||||
font-size: $font-size-small;
|
||||
}
|
||||
|
||||
&-options {
|
||||
@@ -102,7 +98,7 @@
|
||||
align-items: flex-start;
|
||||
|
||||
&__item {
|
||||
padding: calc($padding-small / 2) $padding-global;
|
||||
padding: $padding-small $padding-global;
|
||||
transition: background-color 200ms ease;
|
||||
|
||||
&:hover {
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: calc($padding-global * 2);
|
||||
gap: $padding-large;
|
||||
width: 600px;
|
||||
padding-inline: $padding-large;
|
||||
padding-bottom: $padding-medium;
|
||||
|
||||
&_mobile {
|
||||
grid-auto-rows: min-content;
|
||||
@@ -62,7 +62,6 @@
|
||||
justify-content: flex-end;
|
||||
gap: $padding-small;
|
||||
width: 100%;
|
||||
padding-block: $padding-global;
|
||||
}
|
||||
|
||||
&_mobile &-footer {
|
||||
|
||||
@@ -22,10 +22,12 @@ const StepConfigurator: FC = () => {
|
||||
const { isMobile } = useDeviceDetect();
|
||||
|
||||
const { customStep: value, isHistogram } = useGraphState();
|
||||
const { period: { end, start } } = useTimeState();
|
||||
const { period: { step, end, start } } = useTimeState();
|
||||
const graphDispatch = useGraphDispatch();
|
||||
const { displayType } = useCustomPanelState();
|
||||
|
||||
const prevDuration = usePrevious(end - start);
|
||||
|
||||
const defaultStep = useMemo(() => {
|
||||
return getStepFromDuration(end - start, isHistogram, displayType);
|
||||
}, [end, start, isHistogram, displayType]);
|
||||
@@ -104,16 +106,16 @@ const StepConfigurator: FC = () => {
|
||||
}, [defaultStep]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!prevDefaultStep) return;
|
||||
if (value !== prevDefaultStep) return;
|
||||
if (value === defaultStep) return;
|
||||
const dur = end - start;
|
||||
if (dur === prevDuration || !prevDuration || value !== prevDefaultStep) return;
|
||||
if (defaultStep) {
|
||||
handleApply(defaultStep);
|
||||
}
|
||||
}, [prevDuration, defaultStep]);
|
||||
|
||||
graphDispatch({ type: "SET_CUSTOM_STEP", payload: defaultStep });
|
||||
setCustomStep(defaultStep);
|
||||
setError("");
|
||||
}, [defaultStep, prevDefaultStep, value, graphDispatch]);
|
||||
|
||||
const textValue = isAutoStep ? `auto (${customStep})` : customStep;
|
||||
useEffect(() => {
|
||||
if (step === value || step === defaultStep) handleApply(defaultStep);
|
||||
}, [isHistogram, displayType]);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -128,7 +130,7 @@ const StepConfigurator: FC = () => {
|
||||
<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">{textValue}</span>
|
||||
<span className="vm-mobile-option-text__value">{customStep}</span>
|
||||
</div>
|
||||
<span className="vm-mobile-option__arrow"><ArrowDownIcon/></span>
|
||||
</div>
|
||||
@@ -140,7 +142,7 @@ const StepConfigurator: FC = () => {
|
||||
startIcon={<TimelineIcon/>}
|
||||
onClick={toggleOpenOptions}
|
||||
>
|
||||
Step: {textValue}
|
||||
Step: {isAutoStep ? `auto (${customStep})` : customStep}
|
||||
</Button>
|
||||
)}
|
||||
<Popper
|
||||
|
||||
@@ -5,20 +5,8 @@ import useDeviceDetect from "../../../hooks/useDeviceDetect";
|
||||
import classNames from "classnames";
|
||||
import { FC } from "preact/compat";
|
||||
import { useAppDispatch, useAppState } from "../../../state/common/StateContext";
|
||||
import { DarkIcon, LightIcon, SystemIcon } from "../../Main/Icons";
|
||||
|
||||
const themeIcons = {
|
||||
[Theme.system]: <SystemIcon/>,
|
||||
[Theme.light]: <LightIcon/>,
|
||||
[Theme.dark]: <DarkIcon/>,
|
||||
};
|
||||
|
||||
const options = Object.values(Theme).map(value => ({
|
||||
title: value,
|
||||
value,
|
||||
icon: themeIcons[value],
|
||||
}));
|
||||
|
||||
const options = Object.values(Theme).map(value => ({ title: value, value }));
|
||||
const ThemeControl: FC = () => {
|
||||
const { isMobile } = useDeviceDetect();
|
||||
const dispatch = useAppDispatch();
|
||||
@@ -37,14 +25,13 @@ const ThemeControl: FC = () => {
|
||||
})}
|
||||
>
|
||||
<div className="vm-server-configurator__title">
|
||||
Theme
|
||||
Theme preferences
|
||||
</div>
|
||||
<div
|
||||
className="vm-theme-control__toggle"
|
||||
key={`${isMobile}`}
|
||||
>
|
||||
<Toggle
|
||||
size="large"
|
||||
options={options}
|
||||
value={theme}
|
||||
onChange={handleClickItem}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
&__toggle {
|
||||
display: inline-flex;
|
||||
width: 100%;
|
||||
min-width: 300px;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
|
||||
@@ -19,11 +19,7 @@ import useBoolean from "../../../../hooks/useBoolean";
|
||||
import useWindowSize from "../../../../hooks/useWindowSize";
|
||||
import usePrevious from "../../../../hooks/usePrevious";
|
||||
|
||||
type Props = {
|
||||
onOpenSettings?: () => void;
|
||||
}
|
||||
|
||||
export const TimeSelector: FC<Props> = ({ onOpenSettings }) => {
|
||||
export const TimeSelector: FC = () => {
|
||||
const { isMobile } = useDeviceDetect();
|
||||
const { isDarkTheme } = useAppState();
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
@@ -57,7 +53,7 @@ export const TimeSelector: FC<Props> = ({ onOpenSettings }) => {
|
||||
setFrom(formatDateForNativeInput(dateFromSeconds(start)));
|
||||
}, [timezone, start]);
|
||||
|
||||
const setDuration = ({ duration, until, id }: { duration: string, until: Date, id: string }) => {
|
||||
const setDuration = ({ duration, until, id }: {duration: string, until: Date, id: string}) => {
|
||||
dispatch({ type: "SET_RELATIVE_TIME", payload: { duration, until, id } });
|
||||
handleCloseOptions();
|
||||
};
|
||||
@@ -79,23 +75,16 @@ export const TimeSelector: FC<Props> = ({ onOpenSettings }) => {
|
||||
|
||||
const setTimeAndClosePicker = () => {
|
||||
if (from && until) {
|
||||
dispatch({
|
||||
type: "SET_PERIOD", payload: {
|
||||
from: dayjs.tz(from).toDate(),
|
||||
to: dayjs.tz(until).toDate()
|
||||
}
|
||||
});
|
||||
dispatch({ type: "SET_PERIOD", payload: {
|
||||
from: dayjs.tz(from).toDate(),
|
||||
to: dayjs.tz(until).toDate()
|
||||
} });
|
||||
}
|
||||
handleCloseOptions();
|
||||
};
|
||||
|
||||
const onSwitchToNow = () => dispatch({ type: "RUN_QUERY_TO_NOW" });
|
||||
|
||||
const handleOpenSettings = () => {
|
||||
onOpenSettings && onOpenSettings();
|
||||
handleCloseOptions();
|
||||
};
|
||||
|
||||
const onCancelClick = () => {
|
||||
setUntil(formatDateForNativeInput(dateFromSeconds(end)));
|
||||
setFrom(formatDateForNativeInput(dateFromSeconds(start)));
|
||||
@@ -151,7 +140,6 @@ export const TimeSelector: FC<Props> = ({ onOpenSettings }) => {
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Popper
|
||||
open={openOptions}
|
||||
buttonRef={buttonRef}
|
||||
@@ -191,17 +179,13 @@ export const TimeSelector: FC<Props> = ({ onOpenSettings }) => {
|
||||
onEnter={setTimeAndClosePicker}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="vm-time-selector-left-timezone"
|
||||
onClick={handleOpenSettings}
|
||||
>
|
||||
<span className="vm-time-selector-left-timezone__title">{activeTimezone.region}</span>
|
||||
<span className="vm-time-selector-left-timezone__utc">{activeTimezone.utc}</span>
|
||||
</button>
|
||||
<div className="vm-time-selector-left-timezone">
|
||||
<div className="vm-time-selector-left-timezone__title">{activeTimezone.region}</div>
|
||||
<div className="vm-time-selector-left-timezone__utc">{activeTimezone.utc}</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="text"
|
||||
startIcon={<AlarmIcon/>}
|
||||
startIcon={<AlarmIcon />}
|
||||
onClick={onSwitchToNow}
|
||||
>
|
||||
switch to now
|
||||
|
||||
@@ -40,13 +40,8 @@
|
||||
gap: $padding-small;
|
||||
font-size: $font-size-small;
|
||||
margin-bottom: $padding-small;
|
||||
color: $color-text;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: $color-primary;
|
||||
text-decoration: underline;
|
||||
}
|
||||
&__title {}
|
||||
|
||||
&__utc {
|
||||
display: inline-flex;
|
||||
|
||||
@@ -633,71 +633,3 @@ export const DebugIcon = () => (
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const PlanetIcon = () => (
|
||||
<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 2M4 12c0-.61.08-1.21.21-1.78L8.99 15v1c0 1.1.9 2 2 2v1.93C7.06 19.43 4 16.07 4 12m13.89 5.4c-.26-.81-1-1.4-1.9-1.4h-1v-3c0-.55-.45-1-1-1h-6v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41C17.92 5.77 20 8.65 20 12c0 2.08-.81 3.98-2.11 5.4"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const SystemIcon = () => (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M18 4C18.7957 4 19.5595 4.3163 20.1221 4.87891C20.6845 5.44148 21 6.20452 21 7V15.5264L21.0069 15.6426C21.0203 15.7579 21.0542 15.8702 21.1065 15.9746L22.1729 18.0996C22.3271 18.4056 22.4009 18.7466 22.3858 19.0889C22.3705 19.431 22.2675 19.7637 22.0869 20.0547C21.9063 20.3457 21.6534 20.5855 21.3535 20.751C21.0555 20.9154 20.7202 21.0003 20.3799 20.999L3.62013 21C3.28002 21.0012 2.94535 20.9153 2.64748 20.751C2.34748 20.5855 2.09477 20.3458 1.91408 20.0547C1.73343 19.7636 1.63047 19.4311 1.61525 19.0889C1.60006 18.7466 1.67297 18.4056 1.82716 18.0996L2.89455 15.9746L2.94045 15.8682C2.9801 15.7589 3.00007 15.6432 3.00002 15.5264V7C3.00002 6.20442 3.3164 5.4415 3.87892 4.87891C4.44146 4.31636 5.20447 4.00007 6.00002 4H18ZM4.62404 16.9873L3.61427 18.999L3.6133 19H20.3877L20.3867 18.999L19.376 16.9873H4.62404ZM6.00002 6C5.7349 6.00007 5.48045 6.1055 5.29298 6.29297C5.10554 6.48049 5.00002 6.73485 5.00002 7V14.9873H19V7C19 6.73478 18.8946 6.48051 18.707 6.29297C18.5195 6.10552 18.2652 6 18 6H6.00002Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const LightIcon = () => (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M12 19C12.5523 19 13 19.4477 13 20V22C13 22.5523 12.5523 23 12 23C11.4477 23 11 22.5523 11 22V20C11 19.4477 11.4477 19 12 19Z"
|
||||
/>
|
||||
<path
|
||||
d="M5.63281 16.9531C6.02334 16.5627 6.65638 16.5626 7.04688 16.9531C7.43717 17.3436 7.43725 17.9767 7.04688 18.3672L5.63672 19.7773C5.24625 20.1676 4.61313 20.1676 4.22266 19.7773C3.8322 19.3869 3.83233 18.7538 4.22266 18.3633L5.63281 16.9531Z"
|
||||
/>
|
||||
<path
|
||||
d="M16.9531 16.9531C17.3436 16.5626 17.9767 16.5626 18.3672 16.9531L19.7773 18.3633C20.1676 18.7538 20.1678 19.3869 19.7773 19.7773C19.3869 20.1677 18.7538 20.1675 18.3633 19.7773L16.9531 18.3672C16.5626 17.9767 16.5627 17.3437 16.9531 16.9531Z"
|
||||
/>
|
||||
<path
|
||||
d="M12 7C14.7614 7 17 9.23858 17 12C17 14.7614 14.7614 17 12 17C9.23858 17 7 14.7614 7 12C7 9.23858 9.23858 7 12 7ZM12 9C10.3431 9 9 10.3431 9 12C9 13.6569 10.3431 15 12 15C13.6569 15 15 13.6569 15 12C15 10.3431 13.6569 9 12 9Z"
|
||||
/>
|
||||
<path
|
||||
d="M4 11C4.55228 11 5 11.4477 5 12C5 12.5523 4.55228 13 4 13H2C1.44772 13 1 12.5523 1 12C1 11.4477 1.44772 11 2 11H4Z"
|
||||
/>
|
||||
<path
|
||||
d="M22 11C22.5523 11 23 11.4477 23 12C23 12.5523 22.5523 13 22 13H20C19.4477 13 19 12.5523 19 12C19 11.4477 19.4477 11 20 11H22Z"
|
||||
/>
|
||||
<path
|
||||
d="M4.22266 4.22266C4.61315 3.83229 5.24623 3.83229 5.63672 4.22266L7.04688 5.63281C7.4372 6.02331 7.43723 6.65639 7.04688 7.04688C6.6564 7.43735 6.02335 7.43724 5.63281 7.04688L4.22266 5.63672C3.83225 5.24618 3.83217 4.61314 4.22266 4.22266Z"
|
||||
/>
|
||||
<path
|
||||
d="M18.3633 4.22266C18.7538 3.83237 19.3869 3.83232 19.7773 4.22266C20.1677 4.61312 20.1676 5.2462 19.7773 5.63672L18.3672 7.04688C17.9767 7.4373 17.3436 7.4373 16.9531 7.04688C16.5627 6.65637 16.5627 6.0233 16.9531 5.63281L18.3633 4.22266Z"
|
||||
/>
|
||||
<path
|
||||
d="M12 1C12.5523 1 13 1.44772 13 2V4C13 4.55228 12.5523 5 12 5C11.4477 5 11 4.55228 11 4V2C11 1.44772 11.4477 1 12 1Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const DarkIcon = () => (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M11.5809 2.01318C12.1851 2.02907 12.6373 2.40742 12.8475 2.84717C13.0627 3.29758 13.0619 3.86893 12.7615 4.34814L12.7606 4.34717C12.1616 5.30581 11.9061 6.43987 12.034 7.56299C12.162 8.68628 12.6672 9.73328 13.4666 10.5327C14.2661 11.332 15.3131 11.8364 16.4363 11.9644C17.5596 12.0922 18.6934 11.836 19.6522 11.2368L19.8348 11.1382C20.2701 10.9405 20.7563 10.9617 21.1512 11.1499C21.6217 11.3742 22.0194 11.8752 21.9832 12.5405C21.8789 14.4693 21.2181 16.3268 20.0809 17.8882C18.9435 19.4496 17.378 20.6484 15.574 21.3394C13.7701 22.0302 11.8042 22.184 9.91485 21.7817C8.02549 21.3794 6.29356 20.4376 4.92754 19.0718C3.56149 17.7059 2.62012 15.9739 2.21758 14.0845C1.81507 12.195 1.96826 10.2294 2.65899 8.42529C3.34975 6.62115 4.5487 5.05596 6.11016 3.91846C7.6716 2.781 9.52882 2.11947 11.4578 2.01514L11.5809 2.01318ZM10.6209 4.12061C9.42038 4.33038 8.27873 4.81214 7.28692 5.53467C6.03798 6.44459 5.07973 7.69705 4.52715 9.14014C3.97456 10.5834 3.85165 12.156 4.17364 13.6675C4.49565 15.179 5.24872 16.5649 6.34161 17.6577C7.43448 18.7505 8.82026 19.5038 10.3318 19.8257C11.8434 20.1475 13.416 20.0239 14.8592 19.4712C16.3024 18.9184 17.5548 17.9597 18.4647 16.7104C19.1869 15.7188 19.6671 14.5776 19.8768 13.3774C18.7333 13.8927 17.4674 14.0949 16.2098 13.9517C14.637 13.7725 13.1709 13.0661 12.0516 11.9468C10.9324 10.8275 10.2258 9.36126 10.0467 7.78857C9.90352 6.53075 10.1054 5.26417 10.6209 4.12061Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { FC, ReactNode } from "preact/compat";
|
||||
import { ReactNode } from "react";
|
||||
import classNames from "classnames";
|
||||
import "./style.scss";
|
||||
import { FC } from "preact/compat";
|
||||
|
||||
interface SwitchProps {
|
||||
value: boolean
|
||||
color?: "primary" | "secondary" | "error" | "neutral"
|
||||
color?: "primary" | "secondary" | "error"
|
||||
disabled?: boolean
|
||||
label?: string | ReactNode
|
||||
fullWidth?: boolean
|
||||
|
||||
@@ -29,10 +29,6 @@ $switch-border-radius: $switch-handle-size + ($switch-padding * 2);
|
||||
background-color: $color-secondary;
|
||||
}
|
||||
|
||||
&_neutral_active &-track {
|
||||
background-color: $color-text;
|
||||
}
|
||||
|
||||
&_primary_active &-track {
|
||||
background-color: $color-primary;
|
||||
}
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
import { FC, useEffect, useRef, useState, ReactNode } from "preact/compat";
|
||||
import { FC, useEffect, useRef, useState } from "preact/compat";
|
||||
import classNames from "classnames";
|
||||
import { ReactNode } from "react";
|
||||
import "./style.scss";
|
||||
|
||||
interface ToggleProps {
|
||||
options: { value: string, title?: string, icon?: ReactNode }[];
|
||||
value: string;
|
||||
onChange: (val: string) => void;
|
||||
label?: string;
|
||||
size?: "medium" | "large";
|
||||
options: {value: string, title?: string, icon?: ReactNode}[]
|
||||
value: string
|
||||
onChange: (val: string) => void
|
||||
label?: string
|
||||
}
|
||||
|
||||
const Toggle: FC<ToggleProps> = ({ options, value, label, size = "medium", onChange }) => {
|
||||
const Toggle: FC<ToggleProps> = ({ options, value, label, onChange }) => {
|
||||
|
||||
const activeRef = useRef<HTMLDivElement>(null);
|
||||
const [position, setPosition] = useState({
|
||||
width: "0px",
|
||||
left: "0px",
|
||||
borderRadius: "0px"
|
||||
});
|
||||
|
||||
const createHandlerChange = (value: string) => () => {
|
||||
@@ -27,25 +28,35 @@ const Toggle: FC<ToggleProps> = ({ options, value, label, size = "medium", onCha
|
||||
setPosition({
|
||||
width: "0px",
|
||||
left: "0px",
|
||||
borderRadius: "0px"
|
||||
});
|
||||
return;
|
||||
}
|
||||
const index = options.findIndex(o => o.value === value);
|
||||
const { width: widthRect } = activeRef.current.getBoundingClientRect();
|
||||
|
||||
const width = widthRect;
|
||||
const left = index * width;
|
||||
let width = widthRect;
|
||||
let left = index * width;
|
||||
let borderRadius = "0";
|
||||
if (index === 0) borderRadius = "16px 0 0 16px";
|
||||
|
||||
setPosition({ width: `${width}px`, left: `${left}px` });
|
||||
if (index === options.length - 1) {
|
||||
borderRadius = "10px";
|
||||
left -= 1;
|
||||
borderRadius = "0 16px 16px 0";
|
||||
}
|
||||
|
||||
if (index !== 0 && (index !== options.length - 1)) {
|
||||
width += 1;
|
||||
left -= 1;
|
||||
}
|
||||
|
||||
|
||||
setPosition({ width: `${width}px`, left: `${left}px`, borderRadius });
|
||||
}, [activeRef, value, options]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames({
|
||||
"vm-toggles": true,
|
||||
[`vm-toggles_${size}`]: size,
|
||||
})}
|
||||
>
|
||||
<div className="vm-toggles">
|
||||
{label && (
|
||||
<label className="vm-toggles__label">
|
||||
{label}
|
||||
@@ -55,14 +66,15 @@ const Toggle: FC<ToggleProps> = ({ options, value, label, size = "medium", onCha
|
||||
className="vm-toggles-group"
|
||||
style={{ gridTemplateColumns: `repeat(${options.length}, 1fr)` }}
|
||||
>
|
||||
<div
|
||||
{position.borderRadius && <div
|
||||
className="vm-toggles-group__highlight"
|
||||
style={position}
|
||||
/>
|
||||
{options.map((option) => (
|
||||
/>}
|
||||
{options.map((option, i) => (
|
||||
<div
|
||||
className={classNames({
|
||||
"vm-toggles-group-item": true,
|
||||
"vm-toggles-group-item_first": i === 0,
|
||||
"vm-toggles-group-item_active": option.value === value,
|
||||
"vm-toggles-group-item_icon": option.icon && option.title
|
||||
})}
|
||||
|
||||
@@ -20,8 +20,6 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
border-radius: $border-radius-small;
|
||||
background: $color-hover-black;
|
||||
|
||||
&-item {
|
||||
position: relative;
|
||||
@@ -29,68 +27,55 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: $padding-small;
|
||||
border-right: $border-divider;
|
||||
border-top: $border-divider;
|
||||
border-bottom: $border-divider;
|
||||
font-size: $font-size-small;
|
||||
color: $color-text-secondary;
|
||||
font-weight: 500;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
transition: opacity 150ms ease-in, color 150ms ease-in;
|
||||
transition: color 150ms ease-in;
|
||||
z-index: 2;
|
||||
user-select: none;
|
||||
|
||||
&_icon {
|
||||
grid-template-columns: 14px auto;
|
||||
gap: calc($padding-small / 2);
|
||||
&_first {
|
||||
border-radius: 16px 0 0 16px;
|
||||
border-left: $border-divider
|
||||
}
|
||||
|
||||
&:hover:not(&_active) {
|
||||
opacity: 0.8;
|
||||
&:last-child {
|
||||
border-radius: 0 16px 16px 0;
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
&_icon {
|
||||
grid-template-columns: 14px auto;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: $color-primary;
|
||||
}
|
||||
|
||||
&_active {
|
||||
color: $color-text;
|
||||
font-weight: 600;
|
||||
color: $color-primary;
|
||||
border-color: transparent;
|
||||
|
||||
&:hover {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__highlight {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 3px;
|
||||
height: 100%;
|
||||
background-color: rgba($color-primary, 0.08);
|
||||
border: 1px solid $color-primary;
|
||||
transition: left 200ms cubic-bezier(0.280, 0.840, 0.420, 1), border-radius 200ms linear;
|
||||
z-index: 1;
|
||||
|
||||
&:after {
|
||||
content: '';
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background-color: $color-background-block;
|
||||
border-radius: $border-radius-small;
|
||||
box-shadow: $box-shadow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&_large &-group {
|
||||
border-radius: $border-radius-medium;
|
||||
|
||||
&-item {
|
||||
padding: $padding-global $padding-small;
|
||||
|
||||
&_icon {
|
||||
grid-template-columns: 16px auto;
|
||||
gap: $padding-small;
|
||||
}
|
||||
}
|
||||
|
||||
&__highlight {
|
||||
padding: 4px;
|
||||
border-radius: $border-radius-medium;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
export const faviconColors = [
|
||||
"#A1A1AA",
|
||||
"#71717A",
|
||||
"#020202",
|
||||
"#E94600",
|
||||
"#FF7A00",
|
||||
"#F2B705",
|
||||
"#84CC16",
|
||||
"#16B86A",
|
||||
"#00AFAF",
|
||||
"#2979FF",
|
||||
"#8B5CF6",
|
||||
"#E83E9A",
|
||||
] as const;
|
||||
@@ -11,7 +11,6 @@
|
||||
&_mobile {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0;
|
||||
padding: 0;
|
||||
flex-grow: initial;
|
||||
|
||||
|
||||
@@ -6,11 +6,9 @@ import StepConfigurator from "../../components/Configurators/StepConfigurator/St
|
||||
import { TimeSelector } from "../../components/Configurators/TimeRangeSettings/TimeSelector/TimeSelector";
|
||||
import CardinalityDatePicker from "../../components/Configurators/CardinalityDatePicker/CardinalityDatePicker";
|
||||
import { ExecutionControls } from "../../components/Configurators/TimeRangeSettings/ExecutionControls/ExecutionControls";
|
||||
import GlobalSettings, { GlobalSettingsHandle } from "../../components/Configurators/GlobalSettings/GlobalSettings";
|
||||
import GlobalSettings from "../../components/Configurators/GlobalSettings/GlobalSettings";
|
||||
import ShortcutKeys from "../../components/Main/ShortcutKeys/ShortcutKeys";
|
||||
import { ControlsProps } from "../Header/HeaderControls/HeaderControls";
|
||||
import { useRef } from "react";
|
||||
import TimeZonePreview from "../../components/Configurators/GlobalSettings/TimeZonePreview/TimeZonePreview";
|
||||
|
||||
const ControlsMainLayout: FC<ControlsProps> = ({
|
||||
displaySidebar,
|
||||
@@ -19,7 +17,6 @@ const ControlsMainLayout: FC<ControlsProps> = ({
|
||||
accountIds,
|
||||
closeModal,
|
||||
}) => {
|
||||
const settingsRef = useRef<GlobalSettingsHandle>(null);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -30,15 +27,14 @@ const ControlsMainLayout: FC<ControlsProps> = ({
|
||||
>
|
||||
{headerSetup?.tenant && <TenantsConfiguration accountIds={accountIds || []}/>}
|
||||
{headerSetup?.stepControl && <StepConfigurator/>}
|
||||
{headerSetup?.timeSelector && <TimeSelector onOpenSettings={() => settingsRef.current?.open()}/>}
|
||||
{headerSetup?.timeSelector && <TimeSelector/>}
|
||||
{headerSetup?.cardinalityDatePicker && <CardinalityDatePicker/>}
|
||||
<TimeZonePreview onOpenSettings={() => settingsRef.current?.open()}/>
|
||||
{headerSetup?.executionControls && <ExecutionControls
|
||||
tooltip={headerSetup?.executionControls?.tooltip}
|
||||
useAutorefresh={headerSetup?.executionControls?.useAutorefresh}
|
||||
closeModal={closeModal}
|
||||
/>}
|
||||
<GlobalSettings ref={settingsRef}/>
|
||||
<GlobalSettings/>
|
||||
{!displaySidebar && <ShortcutKeys/>}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -14,9 +14,6 @@ import useFetchDefaultTimezone from "../../hooks/useFetchDefaultTimezone";
|
||||
import useFetchAppConfig from "../../hooks/useFetchAppConfig";
|
||||
import WebStorageCheck from "../../components/WebStorageCheck/WebStorageCheck";
|
||||
import { migrateStorageToPrefixedKeys } from "../../utils/storage";
|
||||
import {
|
||||
useBrowserTabSync
|
||||
} from "../../components/Configurators/GlobalSettings/BrowserTabController/hooks/useBrowserTabSync";
|
||||
|
||||
const MainLayout: FC = () => {
|
||||
const appModeEnable = getAppModeEnable();
|
||||
@@ -24,7 +21,6 @@ const MainLayout: FC = () => {
|
||||
const { pathname } = useLocation();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
useBrowserTabSync();
|
||||
useFetchDashboards();
|
||||
useFetchDefaultTimezone();
|
||||
useFetchAppConfig();
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
@use "src/styles/variables" as *;
|
||||
|
||||
.vm-mobile-option {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: $padding-global;
|
||||
padding: $padding-global $padding-small;
|
||||
gap: $padding-small;
|
||||
padding: calc($padding-medium/2) 0;
|
||||
width: 100%;
|
||||
user-select: none;
|
||||
|
||||
@@ -18,33 +17,14 @@
|
||||
}
|
||||
|
||||
&__icon {
|
||||
position: relative;
|
||||
display: flex;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
color: $color-primary;
|
||||
|
||||
&:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0.1;
|
||||
background-color: currentColor;
|
||||
border-radius: $border-radius-medium;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 21px;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
&__arrow {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
transform: rotate(-90deg);
|
||||
color: $color-primary;
|
||||
}
|
||||
@@ -52,13 +32,11 @@
|
||||
&-text {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
gap: calc($padding-small / 2);
|
||||
gap: 2px;
|
||||
flex-grow: 1;
|
||||
text-align: left;
|
||||
|
||||
&__label {
|
||||
font-weight: 600;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
&__value {
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import faviconRaw from "../../assets/favicon.svg?raw";
|
||||
|
||||
export const createFaviconUrl = (color = "#020202"): string => {
|
||||
const svgDocument = new DOMParser().parseFromString(faviconRaw, "image/svg+xml");
|
||||
const svg = svgDocument.documentElement;
|
||||
|
||||
if (svg.localName !== "svg") {
|
||||
throw new Error("Invalid favicon SVG");
|
||||
}
|
||||
|
||||
svg.setAttribute("fill", color);
|
||||
|
||||
const serializedSvg = new XMLSerializer().serializeToString(svg);
|
||||
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(serializedSvg)}`;
|
||||
};
|
||||
|
||||
export const updateFaviconColor = (color = "#020202"): void => {
|
||||
const favicon = document.querySelector<HTMLLinkElement>("#favicon");
|
||||
if (favicon) {
|
||||
favicon.href = createFaviconUrl(color);
|
||||
}
|
||||
|
||||
const maskIcon = document.querySelector<HTMLLinkElement>("#mask-icon");
|
||||
if (maskIcon) {
|
||||
maskIcon.setAttribute("color", color);
|
||||
}
|
||||
};
|
||||
|
||||
export const getFaviconStorageKey = () => window.location.pathname.replace(/\/+$/, "") || "/";
|
||||
@@ -17,11 +17,7 @@ export const ALL_STORAGE_KEYS = [
|
||||
"POINTS_SHOW_ALL",
|
||||
] as const;
|
||||
|
||||
export type FaviconStorageKey = `FAVICON_COLOR:${string}`;
|
||||
|
||||
export type StorageKeys =
|
||||
| (typeof ALL_STORAGE_KEYS)[number]
|
||||
| FaviconStorageKey;
|
||||
export type StorageKeys = (typeof ALL_STORAGE_KEYS)[number];
|
||||
|
||||
type PrefixedStorageKeys = `${typeof STORAGE_PREFIX}${StorageKeys}`;
|
||||
|
||||
@@ -62,10 +58,7 @@ export const getFromStorage = (key: StorageKeys, withPrefix = true): undefined |
|
||||
|
||||
export const removeFromStorage = (keys: StorageKeys[], withPrefix = true): void => {
|
||||
const storageKeys = withPrefix ? keys.map(toPrefixedKey) : keys;
|
||||
storageKeys.forEach(k => {
|
||||
window.localStorage.removeItem(k);
|
||||
window.dispatchEvent(new StorageEvent("storage", { key: k }));
|
||||
});
|
||||
storageKeys.forEach(k => window.localStorage.removeItem(k));
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -205,21 +205,19 @@ export const getUTCByTimezone = (timezone: string) => {
|
||||
};
|
||||
|
||||
export const getTimezoneList = (search = "") => {
|
||||
const normalizedSearch = search.toLowerCase();
|
||||
const regexp = new RegExp(search, "i");
|
||||
|
||||
return supportedTimezones.reduce((acc: { [key: string]: Timezone[] }, region) => {
|
||||
return supportedTimezones.reduce((acc: {[key: string]: Timezone[]}, region) => {
|
||||
const zone = (region.match(/^(.*?)\//) || [])[1] || "unknown";
|
||||
const utc = getUTCByTimezone(region);
|
||||
const utcForSearch = utc.replace(/^UTC/, "");
|
||||
const utcForSearch = utc.replace(/UTC|0/, "");
|
||||
const regionForSearch = region.replace(/[/_]/g, " ");
|
||||
|
||||
const item = {
|
||||
region,
|
||||
utc,
|
||||
search: `${region} ${utc} ${regionForSearch} ${utcForSearch}`
|
||||
};
|
||||
|
||||
const includeZone = !normalizedSearch || item.search.toLowerCase().includes(normalizedSearch);
|
||||
const includeZone = !search || (search && regexp.test(item.search));
|
||||
|
||||
if (includeZone && acc[zone]) {
|
||||
acc[zone].push(item);
|
||||
|
||||
@@ -50,13 +50,6 @@ export default defineConfig(() => {
|
||||
return "vendor";
|
||||
}
|
||||
},
|
||||
assetFileNames: (assetInfo) => {
|
||||
if (assetInfo.names.includes("favicon.svg")) {
|
||||
return "assets/favicon.svg";
|
||||
}
|
||||
|
||||
return "assets/[name]-[hash][extname]";
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -135,7 +135,7 @@ func tenantViaURL(addr, prefix, tenant, suffix string) string {
|
||||
}
|
||||
|
||||
// tenantViaHeaders returns path in cluster's URL format where tenant is omitted in URL
|
||||
// Only supported if -enableMultitenancyViaHeaders is enabled
|
||||
// Only supported if -enableMultitenancyViaHeaders is specified
|
||||
func tenantViaHeaders(addr, prefix, suffix string) string {
|
||||
return fmt.Sprintf("http://%s/%s/%s", addr, prefix, suffix)
|
||||
}
|
||||
|
||||
342
apptest/tests/influx_server_test.go
Normal file
342
apptest/tests/influx_server_test.go
Normal file
@@ -0,0 +1,342 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// influxPoint is a single field value at a single timestamp of a single series,
|
||||
// as served by the mock InfluxDB server.
|
||||
type influxPoint struct {
|
||||
Measurement string
|
||||
Tags map[string]string
|
||||
Field string
|
||||
FieldType string
|
||||
Timestamp int64 // unix seconds
|
||||
Value any // float64, int64, bool or string
|
||||
}
|
||||
|
||||
// influxRequest is a /query request recorded by the mock server, used to assert
|
||||
// how vmctl authenticates and which database and retention policy it asks for.
|
||||
type influxRequest struct {
|
||||
Query url.Values
|
||||
User string
|
||||
Password string
|
||||
HasAuth bool
|
||||
}
|
||||
|
||||
// influxMockServer implements the subset of the InfluxDB 1.x query API used by
|
||||
// `vmctl influx`: /ping plus /query serving `SHOW FIELD KEYS`, `SHOW TAG KEYS`,
|
||||
// `SHOW SERIES` and `SELECT`.
|
||||
//
|
||||
// InfluxDB 2.x is migrated through the very same API - its 1.x compatibility
|
||||
// endpoint - so one mock covers both -influx-version=1 and -influx-version=2.
|
||||
type influxMockServer struct {
|
||||
server *httptest.Server
|
||||
points []influxPoint
|
||||
|
||||
mu sync.Mutex
|
||||
requests []influxRequest
|
||||
}
|
||||
|
||||
// newInfluxMockServer starts an httptest server serving the given points.
|
||||
func newInfluxMockServer(t *testing.T, points []influxPoint) *influxMockServer {
|
||||
t.Helper()
|
||||
s := &influxMockServer{points: points}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/ping", s.handlePing)
|
||||
mux.HandleFunc("/query", s.handleQuery)
|
||||
s.server = httptest.NewServer(mux)
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *influxMockServer) close() { s.server.Close() }
|
||||
|
||||
func (s *influxMockServer) httpAddr() string { return s.server.URL }
|
||||
|
||||
// recordQuery stores a /query request for later assertions.
|
||||
//
|
||||
// Every request is kept, not just the last one: vmctl issues several kinds of
|
||||
// query - `SHOW FIELD KEYS` unchunked, `SHOW TAG KEYS` and `SHOW SERIES`
|
||||
// chunked, and one `SELECT` per series - and the assertions verify that all of
|
||||
// them authenticate and address the database the same way. The assertions
|
||||
// cannot live in this handler because it runs on a server goroutine, where
|
||||
// t.Fatalf must not be called.
|
||||
func (s *influxMockServer) recordQuery(r *http.Request) {
|
||||
user, pass, ok := r.BasicAuth()
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.requests = append(s.requests, influxRequest{
|
||||
Query: r.URL.Query(),
|
||||
User: user,
|
||||
Password: pass,
|
||||
HasAuth: ok,
|
||||
})
|
||||
}
|
||||
|
||||
// queryRequests returns a copy of the recorded /query requests.
|
||||
func (s *influxMockServer) queryRequests() []influxRequest {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return slices.Clone(s.requests)
|
||||
}
|
||||
|
||||
// handlePing must answer 204: the client library treats any other status as a
|
||||
// failed ping, and vmctl pings before querying.
|
||||
//
|
||||
// X-Influxdb-Version is set because real InfluxDB sets it. The client returns
|
||||
// the value from Ping, but vmctl discards it, so it is not asserted anywhere and
|
||||
// its content is arbitrary.
|
||||
//
|
||||
// The request is not recorded: /ping carries no db or rp to assert.
|
||||
func (s *influxMockServer) handlePing(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("X-Influxdb-Version", "1.8.0-mock")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// influxResponse mirrors the InfluxDB 1.x JSON query response.
|
||||
type influxResponse struct {
|
||||
Results []influxResult `json:"results"`
|
||||
}
|
||||
|
||||
type influxResult struct {
|
||||
StatementID int `json:"statement_id"`
|
||||
Series []influxRow `json:"series,omitempty"`
|
||||
Err string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type influxRow struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Columns []string `json:"columns"`
|
||||
Values [][]any `json:"values,omitempty"`
|
||||
}
|
||||
|
||||
// parseSelect splits a SELECT built by vmctl into its field, measurement and
|
||||
// WHERE clause. The statement shape is fixed by Series.fetchQuery:
|
||||
//
|
||||
// select "value" from "cpu" where "host"::tag='h1' and "env"::tag=''
|
||||
//
|
||||
// Identifiers containing a double quote are not supported, which is fine
|
||||
// because no test fixture uses one.
|
||||
func parseSelect(q string) (field, measurement, where string, err error) {
|
||||
rest, ok := strings.CutPrefix(q, `select "`)
|
||||
if !ok {
|
||||
return "", "", "", fmt.Errorf("cannot parse select statement: %s", q)
|
||||
}
|
||||
field, rest, ok = strings.Cut(rest, `" from "`)
|
||||
if !ok {
|
||||
return "", "", "", fmt.Errorf("cannot parse measurement in: %s", q)
|
||||
}
|
||||
measurement, rest, ok = strings.Cut(rest, `"`)
|
||||
if !ok {
|
||||
return "", "", "", fmt.Errorf("unterminated measurement in: %s", q)
|
||||
}
|
||||
return field, measurement, strings.TrimPrefix(rest, " where "), nil
|
||||
}
|
||||
|
||||
// handleQuery serves the InfluxQL statements vmctl issues. Both the plain and
|
||||
// the chunked code paths of the client accept a single JSON response object,
|
||||
// so one implementation covers both.
|
||||
func (s *influxMockServer) handleQuery(w http.ResponseWriter, r *http.Request) {
|
||||
s.recordQuery(r)
|
||||
|
||||
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||
lower := strings.ToLower(q)
|
||||
|
||||
// Content-Type is load-bearing: the client rejects any response that is not
|
||||
// application/json. X-Influxdb-Version only matters for 5xx replies, where
|
||||
// the client uses its absence to report a downstream proxy error instead of
|
||||
// an InfluxDB one; it is set here to mirror real InfluxDB.
|
||||
w.Header().Set("X-Influxdb-Version", "1.8.0-mock")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
var resp influxResponse
|
||||
switch {
|
||||
case strings.HasPrefix(lower, "show field keys"):
|
||||
resp = influxResponse{Results: []influxResult{{Series: s.fieldKeys()}}}
|
||||
case strings.HasPrefix(lower, "show tag keys"):
|
||||
resp = influxResponse{Results: []influxResult{{Series: s.tagKeys()}}}
|
||||
case strings.HasPrefix(lower, "show series"):
|
||||
resp = influxResponse{Results: []influxResult{{Series: s.series()}}}
|
||||
case strings.HasPrefix(lower, "select"):
|
||||
rows, err := s.selectRows(q)
|
||||
if err != nil {
|
||||
resp = influxResponse{Results: []influxResult{{Err: err.Error()}}}
|
||||
break
|
||||
}
|
||||
resp = influxResponse{Results: []influxResult{{Series: rows}}}
|
||||
default:
|
||||
resp = influxResponse{Results: []influxResult{{Err: fmt.Sprintf("unsupported query: %s", q)}}}
|
||||
}
|
||||
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
// fieldKeys serves `SHOW FIELD KEYS`: one row per measurement listing every
|
||||
// field key together with its type. vmctl uses the type to skip string fields.
|
||||
//
|
||||
// Rows are built by walking the points in order, so the output is deterministic
|
||||
// without sorting.
|
||||
func (s *influxMockServer) fieldKeys() []influxRow {
|
||||
rows := make(map[string]*influxRow)
|
||||
var order []string
|
||||
seen := make(map[string]struct{})
|
||||
for _, p := range s.points {
|
||||
row, ok := rows[p.Measurement]
|
||||
if !ok {
|
||||
row = &influxRow{Name: p.Measurement, Columns: []string{"fieldKey", "fieldType"}}
|
||||
rows[p.Measurement] = row
|
||||
order = append(order, p.Measurement)
|
||||
}
|
||||
key := p.Measurement + "\x00" + p.Field
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
row.Values = append(row.Values, []any{p.Field, p.FieldType})
|
||||
}
|
||||
out := make([]influxRow, 0, len(order))
|
||||
for _, m := range order {
|
||||
out = append(out, *rows[m])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// tagKeys serves `SHOW TAG KEYS`: one row per measurement listing its tag keys.
|
||||
//
|
||||
// Tag keys come from a map, so each row is sorted to keep the output stable.
|
||||
func (s *influxMockServer) tagKeys() []influxRow {
|
||||
rows := make(map[string]*influxRow)
|
||||
var order []string
|
||||
seen := make(map[string]struct{})
|
||||
for _, p := range s.points {
|
||||
row, ok := rows[p.Measurement]
|
||||
if !ok {
|
||||
row = &influxRow{Name: p.Measurement, Columns: []string{"tagKey"}}
|
||||
rows[p.Measurement] = row
|
||||
order = append(order, p.Measurement)
|
||||
}
|
||||
for k := range p.Tags {
|
||||
key := p.Measurement + "\x00" + k
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
row.Values = append(row.Values, []any{k})
|
||||
}
|
||||
}
|
||||
out := make([]influxRow, 0, len(order))
|
||||
for _, m := range order {
|
||||
row := *rows[m]
|
||||
sort.Slice(row.Values, func(a, b int) bool {
|
||||
return row.Values[a][0].(string) < row.Values[b][0].(string)
|
||||
})
|
||||
out = append(out, row)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// series serves `SHOW SERIES`: a single row of series keys in the
|
||||
// `measurement,tag=value,...` form.
|
||||
func (s *influxMockServer) series() []influxRow {
|
||||
seen := make(map[string]struct{})
|
||||
var keys []string
|
||||
for _, p := range s.points {
|
||||
key := influxSeriesKey(p.Measurement, p.Tags)
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
row := influxRow{Columns: []string{"key"}}
|
||||
for _, k := range keys {
|
||||
row.Values = append(row.Values, []any{k})
|
||||
}
|
||||
return []influxRow{row}
|
||||
}
|
||||
|
||||
// selectRows serves the per-series SELECT issued for every measurement/field
|
||||
// combination, returning the `time` and field columns.
|
||||
func (s *influxMockServer) selectRows(q string) ([]influxRow, error) {
|
||||
field, measurement, where, err := parseSelect(q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Tags the series must carry. An empty value means the tag must be absent,
|
||||
// which is how vmctl addresses series that lack a tag of the measurement.
|
||||
wantTags := make(map[string]string)
|
||||
for _, cond := range strings.Split(where, " and ") {
|
||||
// Conditions that are not tag comparisons - such as the time filter
|
||||
// added by -influx-filter-time-start/-end - are not series selectors.
|
||||
name, value, ok := strings.Cut(cond, "::tag=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
wantTags[strings.Trim(name, `"`)] = strings.Trim(value, `'`)
|
||||
}
|
||||
|
||||
row := influxRow{Name: measurement, Columns: []string{"time", field}}
|
||||
for _, p := range s.points {
|
||||
if p.Measurement != measurement || p.Field != field {
|
||||
continue
|
||||
}
|
||||
if !influxTagsMatch(p.Tags, wantTags) {
|
||||
continue
|
||||
}
|
||||
ts := time.Unix(p.Timestamp, 0).UTC().Format(time.RFC3339)
|
||||
row.Values = append(row.Values, []any{ts, p.Value})
|
||||
}
|
||||
if len(row.Values) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return []influxRow{row}, nil
|
||||
}
|
||||
|
||||
// influxTagsMatch reports whether the series tags satisfy the conditions of a
|
||||
// SELECT built by vmctl. Every requested tag must match exactly; a requested
|
||||
// empty value requires the tag to be absent from the series.
|
||||
func influxTagsMatch(got, want map[string]string) bool {
|
||||
for k, v := range want {
|
||||
if v == "" {
|
||||
if _, ok := got[k]; ok {
|
||||
return false
|
||||
}
|
||||
continue
|
||||
}
|
||||
if got[k] != v {
|
||||
return false
|
||||
}
|
||||
}
|
||||
// Every tag of the series must be constrained, otherwise the SELECT would
|
||||
// address more than one series.
|
||||
for k := range got {
|
||||
if _, ok := want[k]; !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// influxSeriesKey builds the `measurement,tag=value,...` key used by SHOW SERIES.
|
||||
//
|
||||
// The tags must be sorted: the key doubles as the deduplication key, so an
|
||||
// unstable order would report the same series more than once. tagsKey already
|
||||
// sorts, so it is reused here.
|
||||
func influxSeriesKey(measurement string, tags map[string]string) string {
|
||||
if len(tags) == 0 {
|
||||
return measurement
|
||||
}
|
||||
return measurement + "," + tagsKey(tags)
|
||||
}
|
||||
@@ -25,10 +25,12 @@ func TestClusterMultiTenantSelectViaHeaders(t *testing.T) {
|
||||
})
|
||||
vminsert := tc.MustStartVminsert("vminsert", []string{
|
||||
"-storageNode=" + vmstorage.VminsertAddr(),
|
||||
"-enableMultitenancyViaHeaders",
|
||||
})
|
||||
vmselect := tc.MustStartVmselect("vmselect", []string{
|
||||
"-storageNode=" + vmstorage.VmselectAddr(),
|
||||
"-search.tenantCacheExpireDuration=0",
|
||||
"-enableMultitenancyViaHeaders",
|
||||
})
|
||||
|
||||
multitenant := make(http.Header)
|
||||
|
||||
@@ -26,20 +26,33 @@ func TestClusterSearchWithDisabledPerDayIndex(t *testing.T) {
|
||||
defer tc.Stop()
|
||||
|
||||
testSearchWithDisabledPerDayIndex(tc, func(name string, disablePerDayIndex bool) apptest.PrometheusWriteQuerier {
|
||||
vmstorage := tc.MustStartVmstorage("vmstorage-"+name, []string{
|
||||
"-storageDataPath=" + tc.Dir() + "/vmstorage",
|
||||
// Using static ports for vmstorage because random ports may cause
|
||||
// changes in how data is sharded.
|
||||
vmstorage1 := tc.MustStartVmstorage("vmstorage1-"+name, []string{
|
||||
"-storageDataPath=" + tc.Dir() + "/vmstorage1",
|
||||
"-retentionPeriod=100y",
|
||||
"-httpListenAddr=127.0.0.1:61001",
|
||||
"-vminsertAddr=127.0.0.1:61002",
|
||||
"-vmselectAddr=127.0.0.1:61003",
|
||||
fmt.Sprintf("-disablePerDayIndex=%t", disablePerDayIndex),
|
||||
})
|
||||
vmstorage2 := tc.MustStartVmstorage("vmstorage2-"+name, []string{
|
||||
"-storageDataPath=" + tc.Dir() + "/vmstorage2",
|
||||
"-retentionPeriod=100y",
|
||||
"-httpListenAddr=127.0.0.1:62001",
|
||||
"-vminsertAddr=127.0.0.1:62002",
|
||||
"-vmselectAddr=127.0.0.1:62003",
|
||||
fmt.Sprintf("-disablePerDayIndex=%t", disablePerDayIndex),
|
||||
})
|
||||
vminsert := tc.MustStartVminsert("vminsert-"+name, []string{
|
||||
"-storageNode=" + vmstorage.VminsertAddr(),
|
||||
"-storageNode=" + vmstorage1.VminsertAddr() + "," + vmstorage2.VminsertAddr(),
|
||||
})
|
||||
vmselect := tc.MustStartVmselect("vmselect"+name, []string{
|
||||
"-storageNode=" + vmstorage.VmselectAddr(),
|
||||
"-storageNode=" + vmstorage1.VmselectAddr() + "," + vmstorage2.VmselectAddr(),
|
||||
"-search.maxStalenessInterval=1m",
|
||||
})
|
||||
return &apptest.Vmcluster{
|
||||
Vmstorages: []*apptest.Vmstorage{vmstorage},
|
||||
Vmstorages: []*apptest.Vmstorage{vmstorage1, vmstorage2},
|
||||
Vminsert: vminsert,
|
||||
Vmselect: vmselect,
|
||||
}
|
||||
|
||||
@@ -594,6 +594,7 @@ func TestSingleVMAgentMultitenancy(t *testing.T) {
|
||||
fmt.Sprintf(`-remoteWrite.url=%s/api/v1/write`, remoteWriteSrv.URL),
|
||||
"-remoteWrite.tmpDataPath=" + tc.Dir() + "/vmagent-multitenancy",
|
||||
"-enableMultitenantHandlers",
|
||||
"-enableMultitenancyViaHeaders",
|
||||
})
|
||||
|
||||
vmagent.APIV1ImportPrometheus(t, []string{
|
||||
|
||||
272
apptest/tests/vmctl_influx_migration_test.go
Normal file
272
apptest/tests/vmctl_influx_migration_test.go
Normal file
@@ -0,0 +1,272 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/google/go-cmp/cmp/cmpopts"
|
||||
|
||||
"github.com/VictoriaMetrics/VictoriaMetrics/apptest"
|
||||
"github.com/VictoriaMetrics/VictoriaMetrics/lib/fs"
|
||||
)
|
||||
|
||||
const (
|
||||
influxTestDatabase = "testdb"
|
||||
influxTestToken = "my-secret-token"
|
||||
// defaultV1CompatUser mirrors the username vmctl sends when authenticating
|
||||
// to InfluxDB 2.x with an API token. The 1.x compatibility API requires a
|
||||
// username but ignores its value.
|
||||
influxV1CompatUser = "vmctl"
|
||||
)
|
||||
|
||||
func TestSingleVmctlInfluxV2Migration(t *testing.T) {
|
||||
fs.MustRemoveDir(t.Name())
|
||||
|
||||
tc := apptest.NewTestCase(t)
|
||||
defer tc.Stop()
|
||||
|
||||
vmsingleDst := tc.MustStartDefaultVmsingle()
|
||||
vmAddr := fmt.Sprintf("http://%s/", vmsingleDst.HTTPAddr())
|
||||
|
||||
baseTS := time.Now().Add(-2 * time.Hour).Truncate(time.Minute).Unix()
|
||||
points := newInfluxTestPoints(baseTS)
|
||||
|
||||
influx := newInfluxMockServer(t, points)
|
||||
defer influx.close()
|
||||
|
||||
vmctlFlags := []string{
|
||||
`influx`,
|
||||
`--influx-version=2`,
|
||||
`--influx-addr=` + influx.httpAddr(),
|
||||
`--influx-token=` + influxTestToken,
|
||||
`--influx-database=` + influxTestDatabase,
|
||||
`--vm-addr=` + vmAddr,
|
||||
`--disable-progress-bar=true`,
|
||||
`-s`,
|
||||
}
|
||||
|
||||
testVmctlInfluxMigration(tc, vmsingleDst, vmctlFlags, points, baseTS)
|
||||
|
||||
assertInfluxRequests(t, influx, influxV1CompatUser, influxTestToken, "")
|
||||
}
|
||||
|
||||
func TestSingleVmctlInfluxV1Migration(t *testing.T) {
|
||||
fs.MustRemoveDir(t.Name())
|
||||
|
||||
tc := apptest.NewTestCase(t)
|
||||
defer tc.Stop()
|
||||
|
||||
vmsingleDst := tc.MustStartDefaultVmsingle()
|
||||
vmAddr := fmt.Sprintf("http://%s/", vmsingleDst.HTTPAddr())
|
||||
|
||||
baseTS := time.Now().Add(-2 * time.Hour).Truncate(time.Minute).Unix()
|
||||
points := newInfluxTestPoints(baseTS)
|
||||
|
||||
influx := newInfluxMockServer(t, points)
|
||||
defer influx.close()
|
||||
|
||||
vmctlFlags := []string{
|
||||
`influx`,
|
||||
`--influx-addr=` + influx.httpAddr(),
|
||||
`--influx-user=user`,
|
||||
`--influx-password=pass`,
|
||||
`--influx-database=` + influxTestDatabase,
|
||||
`--vm-addr=` + vmAddr,
|
||||
`--disable-progress-bar=true`,
|
||||
`-s`,
|
||||
}
|
||||
|
||||
testVmctlInfluxMigration(tc, vmsingleDst, vmctlFlags, points, baseTS)
|
||||
assertInfluxRequests(t, influx, "user", "pass", "autogen")
|
||||
}
|
||||
|
||||
func TestClusterVmctlInfluxV2Migration(t *testing.T) {
|
||||
fs.MustRemoveDir(t.Name())
|
||||
|
||||
tc := apptest.NewTestCase(t)
|
||||
defer tc.Stop()
|
||||
|
||||
cluster := tc.MustStartDefaultCluster()
|
||||
vmAddr := fmt.Sprintf("http://%s/", cluster.Vminsert.HTTPAddr())
|
||||
|
||||
baseTS := time.Now().Add(-2 * time.Hour).Truncate(time.Minute).Unix()
|
||||
points := newInfluxTestPoints(baseTS)
|
||||
|
||||
influx := newInfluxMockServer(t, points)
|
||||
defer influx.close()
|
||||
|
||||
vmctlFlags := []string{
|
||||
`influx`,
|
||||
`--influx-version=2`,
|
||||
`--influx-addr=` + influx.httpAddr(),
|
||||
`--influx-token=` + influxTestToken,
|
||||
`--influx-database=` + influxTestDatabase,
|
||||
`--vm-addr=` + vmAddr,
|
||||
`--vm-account-id=0`,
|
||||
`--disable-progress-bar=true`,
|
||||
`-s`,
|
||||
}
|
||||
|
||||
testVmctlInfluxMigration(tc, cluster, vmctlFlags, points, baseTS)
|
||||
assertInfluxRequests(t, influx, influxV1CompatUser, influxTestToken, "")
|
||||
}
|
||||
|
||||
func testVmctlInfluxMigration(
|
||||
tc *apptest.TestCase,
|
||||
queries apptest.PrometheusWriteQuerier,
|
||||
vmctlFlags []string,
|
||||
points []influxPoint,
|
||||
baseTS int64,
|
||||
) {
|
||||
t := tc.T()
|
||||
t.Helper()
|
||||
|
||||
queryStart := time.Unix(baseTS-3600, 0).UTC().Format(time.RFC3339)
|
||||
queryEnd := time.Unix(baseTS+7200, 0).UTC().Format(time.RFC3339)
|
||||
|
||||
cmpOpt := cmpopts.IgnoreFields(apptest.PrometheusAPIV1QueryResponse{}, "Status", "Data.ResultType")
|
||||
|
||||
// Nothing is stored before the migration runs.
|
||||
got := queries.PrometheusAPIV1Query(t, `{__name__=~".*"}`, apptest.QueryOpts{
|
||||
Step: "5m",
|
||||
Time: queryStart,
|
||||
})
|
||||
want := apptest.NewPrometheusAPIV1QueryResponse(t, `{"data":{"result":[]}}`)
|
||||
if diff := cmp.Diff(want, got, cmpOpt); diff != "" {
|
||||
t.Errorf("unexpected response before migration (-want, +got):\n%s", diff)
|
||||
}
|
||||
|
||||
tc.MustStartVmctl("vmctl", vmctlFlags)
|
||||
queries.ForceFlush(t)
|
||||
|
||||
tc.Assert(&apptest.AssertOptions{
|
||||
Retries: 300,
|
||||
Msg: `unexpected metrics migrated from influx`,
|
||||
Got: func() any {
|
||||
r := queries.PrometheusAPIV1Export(t, `{__name__!=""}`, apptest.QueryOpts{
|
||||
Start: queryStart,
|
||||
End: queryEnd,
|
||||
})
|
||||
r.Sort()
|
||||
return r.Data.Result
|
||||
},
|
||||
Want: buildExpectedInfluxResult(t, points),
|
||||
CmpOpts: []cmp.Option{
|
||||
cmpopts.IgnoreFields(apptest.PrometheusAPIV1QueryResponse{}, "Status", "Data.ResultType"),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// buildExpectedInfluxResult derives the expected VictoriaMetrics contents from
|
||||
// the same points the mock server serves: metric name is
|
||||
// `<measurement>_<field>`, tags become labels, the source database is added as
|
||||
// the `db` label, booleans become 1/0 and string fields are skipped.
|
||||
func buildExpectedInfluxResult(t *testing.T, points []influxPoint) []*apptest.QueryResult {
|
||||
t.Helper()
|
||||
|
||||
grouped := map[string]*apptest.QueryResult{}
|
||||
for _, p := range points {
|
||||
if p.FieldType == "string" {
|
||||
continue
|
||||
}
|
||||
name := fmt.Sprintf("%s_%s", p.Measurement, p.Field)
|
||||
metric := map[string]string{
|
||||
"__name__": name,
|
||||
"db": influxTestDatabase,
|
||||
}
|
||||
for k, v := range p.Tags {
|
||||
metric[k] = v
|
||||
}
|
||||
key := tagsKey(metric)
|
||||
if _, ok := grouped[key]; !ok {
|
||||
grouped[key] = &apptest.QueryResult{Metric: metric}
|
||||
}
|
||||
grouped[key].Samples = append(grouped[key].Samples, &apptest.Sample{
|
||||
Timestamp: p.Timestamp * 1000,
|
||||
Value: influxExpectedValue(t, p.Value),
|
||||
})
|
||||
}
|
||||
out := make([]*apptest.QueryResult, 0, len(grouped))
|
||||
for _, v := range grouped {
|
||||
out = append(out, v)
|
||||
}
|
||||
resp := apptest.PrometheusAPIV1QueryResponse{
|
||||
Data: &apptest.QueryData{Result: out},
|
||||
}
|
||||
resp.Sort()
|
||||
return resp.Data.Result
|
||||
}
|
||||
|
||||
// influxExpectedValue converts a field value the way vmctl does: numbers pass
|
||||
// through and booleans become 1/0.
|
||||
func influxExpectedValue(t *testing.T, v any) float64 {
|
||||
t.Helper()
|
||||
|
||||
switch val := v.(type) {
|
||||
case float64:
|
||||
return val
|
||||
case int64:
|
||||
return float64(val)
|
||||
case bool:
|
||||
if val {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
default:
|
||||
t.Fatalf("unexpected field value type %T in test fixture; only float64, int64 and bool are supported", v)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// assertInfluxRequests verifies how vmctl authenticated to InfluxDB and which
|
||||
// database and retention policy it requested.
|
||||
func assertInfluxRequests(t *testing.T, s *influxMockServer, wantUser, wantPass, wantRP string) {
|
||||
t.Helper()
|
||||
|
||||
reqs := s.queryRequests()
|
||||
if len(reqs) == 0 {
|
||||
t.Fatalf("vmctl issued no /query requests")
|
||||
}
|
||||
for _, r := range reqs {
|
||||
if !r.HasAuth {
|
||||
t.Fatalf("no basic auth on %s", r.Query)
|
||||
}
|
||||
if r.User != wantUser || r.Password != wantPass {
|
||||
t.Fatalf("unexpected credentials for q=%q\ngot\n(%q, %q)\nwant\n(%q, %q)",
|
||||
r.Query.Get("q"), r.User, r.Password, wantUser, wantPass)
|
||||
}
|
||||
if rp := r.Query.Get("rp"); rp != wantRP {
|
||||
t.Fatalf("unexpected rp for q=%q\ngot\n%q\nwant\n%q", r.Query.Get("q"), rp, wantRP)
|
||||
}
|
||||
if db := r.Query.Get("db"); db != influxTestDatabase {
|
||||
t.Fatalf("unexpected db for q=%q\ngot\n%q\nwant\n%q", r.Query.Get("q"), db, influxTestDatabase)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// newInfluxTestPoints returns points covering the cases that matter for the
|
||||
// migration: numeric field types, a boolean, a string field which must be
|
||||
// skipped, a series missing one of the measurement tags, and a measurement name
|
||||
// containing special characters.
|
||||
func newInfluxTestPoints(baseTS int64) []influxPoint {
|
||||
var points []influxPoint
|
||||
for i := range 10 {
|
||||
ts := baseTS + int64(i*60)
|
||||
|
||||
// Two series of `cpu`: the second one has no `env` tag, which exercises
|
||||
// the empty-tag conditions vmctl adds to its SELECT statements.
|
||||
points = append(points,
|
||||
influxPoint{"cpu", map[string]string{"host": "h1", "env": "prod"}, "value", "float", ts, float64(i) + 0.5},
|
||||
influxPoint{"cpu", map[string]string{"host": "h1", "env": "prod"}, "count", "integer", ts, int64(i)},
|
||||
influxPoint{"cpu", map[string]string{"host": "h1", "env": "prod"}, "flag", "boolean", ts, i%2 == 0},
|
||||
influxPoint{"cpu", map[string]string{"host": "h1", "env": "prod"}, "note", "string", ts, "ignored"},
|
||||
influxPoint{"cpu", map[string]string{"host": "h2"}, "value", "float", ts, float64(i) - 2.25},
|
||||
influxPoint{"cpu", map[string]string{"host": "h2"}, "note", "string", ts, "ignored"},
|
||||
// Special characters in a measurement name, cf. issue #10892.
|
||||
influxPoint{"user_percent.mem.zwickel+", map[string]string{"host": "h1"}, "value", "float", ts, float64(i * 3)},
|
||||
)
|
||||
}
|
||||
return points
|
||||
}
|
||||
@@ -59,19 +59,6 @@
|
||||
},
|
||||
"type": "dashboard"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "$ds"
|
||||
},
|
||||
"enable": true,
|
||||
"expr": "sum by(version) (\n label_replace(vm_app_version{job=~\"$job\", instance=~\"$instance\", short_version!=\"\"}, \"version\", \"$1\", \"short_version\", \"(.*)\")\n OR\n vm_app_version{job=~\"$job\", instance=~\"$instance\", short_version=\"\"}\n) \nunless \n(\n sum by(version) (\n label_replace(vm_app_version{job=~\"$job\", instance=~\"$instance\", short_version!=\"\"}, \"version\", \"$1\", \"short_version\", \"(.*)\")\n OR\n vm_app_version{job=~\"$job\", instance=~\"$instance\", short_version=\"\"}\n ) offset $__interval\n)",
|
||||
"hide": true,
|
||||
"iconColor": "dark-blue",
|
||||
"name": "version",
|
||||
"textFormat": "{{version}}",
|
||||
"titleFormat": "Version change"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
"uid": "$ds"
|
||||
},
|
||||
"enable": true,
|
||||
"expr": "sum by(version) (\n label_replace(vm_app_version{job=~\"$job\", instance=~\"$instance\", short_version!=\"\"}, \"version\", \"$1\", \"short_version\", \"(.*)\")\n OR\n vm_app_version{job=~\"$job\", instance=~\"$instance\", short_version=\"\"}\n) \nunless \n(\n sum by(version) (\n label_replace(vm_app_version{job=~\"$job\", instance=~\"$instance\", short_version!=\"\"}, \"version\", \"$1\", \"short_version\", \"(.*)\")\n OR\n vm_app_version{job=~\"$job\", instance=~\"$instance\", short_version=\"\"}\n ) offset $__interval\n)",
|
||||
"expr": "sum(vm_app_version{job=~\"$job\", instance=~\"$instance\"}) by(version) unless (sum(vm_app_version{job=~\"$job\", instance=~\"$instance\"} offset $__interval) by(version))",
|
||||
"hide": true,
|
||||
"iconColor": "dark-blue",
|
||||
"name": "version change",
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
"uid": "$ds"
|
||||
},
|
||||
"enable": true,
|
||||
"expr": "sum by(version) (\n label_replace(vm_app_version{job=~\"$job\", instance=~\"$instance\", short_version!=\"\"}, \"version\", \"$1\", \"short_version\", \"(.*)\")\n OR\n vm_app_version{job=~\"$job\", instance=~\"$instance\", short_version=\"\"}\n) \nunless \n(\n sum by(version) (\n label_replace(vm_app_version{job=~\"$job\", instance=~\"$instance\", short_version!=\"\"}, \"version\", \"$1\", \"short_version\", \"(.*)\")\n OR\n vm_app_version{job=~\"$job\", instance=~\"$instance\", short_version=\"\"}\n ) offset $__interval\n)",
|
||||
"expr": "sum(vm_app_version{job=~\"$job\", instance=~\"$instance\"}) by(version) unless (sum(vm_app_version{job=~\"$job\", instance=~\"$instance\"} offset $__interval) by(version))",
|
||||
"hide": true,
|
||||
"iconColor": "dark-blue",
|
||||
"name": "version",
|
||||
|
||||
@@ -60,19 +60,6 @@
|
||||
},
|
||||
"type": "dashboard"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "victoriametrics-metrics-datasource",
|
||||
"uid": "$ds"
|
||||
},
|
||||
"enable": true,
|
||||
"expr": "sum by(version) (\n label_replace(vm_app_version{job=~\"$job\", instance=~\"$instance\", short_version!=\"\"}, \"version\", \"$1\", \"short_version\", \"(.*)\")\n OR\n vm_app_version{job=~\"$job\", instance=~\"$instance\", short_version=\"\"}\n) \nunless \n(\n sum by(version) (\n label_replace(vm_app_version{job=~\"$job\", instance=~\"$instance\", short_version!=\"\"}, \"version\", \"$1\", \"short_version\", \"(.*)\")\n OR\n vm_app_version{job=~\"$job\", instance=~\"$instance\", short_version=\"\"}\n ) offset $__interval\n)",
|
||||
"hide": true,
|
||||
"iconColor": "dark-blue",
|
||||
"name": "version",
|
||||
"textFormat": "{{version}}",
|
||||
"titleFormat": "Version change"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "victoriametrics-metrics-datasource",
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
"uid": "$ds"
|
||||
},
|
||||
"enable": true,
|
||||
"expr": "sum by(version) (\n label_replace(vm_app_version{job=~\"$job\", instance=~\"$instance\", short_version!=\"\"}, \"version\", \"$1\", \"short_version\", \"(.*)\")\n OR\n vm_app_version{job=~\"$job\", instance=~\"$instance\", short_version=\"\"}\n) \nunless \n(\n sum by(version) (\n label_replace(vm_app_version{job=~\"$job\", instance=~\"$instance\", short_version!=\"\"}, \"version\", \"$1\", \"short_version\", \"(.*)\")\n OR\n vm_app_version{job=~\"$job\", instance=~\"$instance\", short_version=\"\"}\n ) offset $__interval\n)",
|
||||
"expr": "sum(vm_app_version{job=~\"$job\", instance=~\"$instance\"}) by(version) unless (sum(vm_app_version{job=~\"$job\", instance=~\"$instance\"} offset $__interval) by(version))",
|
||||
"hide": true,
|
||||
"iconColor": "dark-blue",
|
||||
"name": "version change",
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
"uid": "$ds"
|
||||
},
|
||||
"enable": true,
|
||||
"expr": "sum by(version) (\n label_replace(vm_app_version{job=~\"$job\", instance=~\"$instance\", short_version!=\"\"}, \"version\", \"$1\", \"short_version\", \"(.*)\")\n OR\n vm_app_version{job=~\"$job\", instance=~\"$instance\", short_version=\"\"}\n) \nunless \n(\n sum by(version) (\n label_replace(vm_app_version{job=~\"$job\", instance=~\"$instance\", short_version!=\"\"}, \"version\", \"$1\", \"short_version\", \"(.*)\")\n OR\n vm_app_version{job=~\"$job\", instance=~\"$instance\", short_version=\"\"}\n ) offset $__interval\n)",
|
||||
"expr": "sum(vm_app_version{job=~\"$job\", instance=~\"$instance\"}) by(version) unless (sum(vm_app_version{job=~\"$job\", instance=~\"$instance\"} offset $__interval) by(version))",
|
||||
"hide": true,
|
||||
"iconColor": "dark-blue",
|
||||
"name": "version",
|
||||
|
||||
@@ -26,11 +26,11 @@
|
||||
"uid": "$ds"
|
||||
},
|
||||
"enable": true,
|
||||
"expr": "sum by(version) (\n label_replace(vm_app_version{job=~\"$job\", instance=~\"$instance\", short_version!=\"\"}, \"version\", \"$1\", \"short_version\", \"(.*)\")\n OR\n vm_app_version{job=~\"$job\", instance=~\"$instance\", short_version=\"\"}\n) \nunless \n(\n sum by(version) (\n label_replace(vm_app_version{job=~\"$job\", instance=~\"$instance\", short_version!=\"\"}, \"version\", \"$1\", \"short_version\", \"(.*)\")\n OR\n vm_app_version{job=~\"$job\", instance=~\"$instance\", short_version=\"\"}\n ) offset $__interval\n)",
|
||||
"expr": "sum(vm_app_version{job=~\"$job\", instance=~\"$instance\"}) by(short_version) unless (sum(vm_app_version{job=~\"$job\", instance=~\"$instance\"} offset $__interval) by(short_version))",
|
||||
"hide": true,
|
||||
"iconColor": "dark-blue",
|
||||
"name": "version",
|
||||
"textFormat": "{{version}}",
|
||||
"textFormat": "{{short_version}}",
|
||||
"titleFormat": "Version change"
|
||||
},
|
||||
{
|
||||
|
||||
@@ -26,11 +26,11 @@
|
||||
"uid": "$ds"
|
||||
},
|
||||
"enable": true,
|
||||
"expr": "sum by(version) (\n label_replace(vm_app_version{job=~\"$job\", instance=~\"$instance\", short_version!=\"\"}, \"version\", \"$1\", \"short_version\", \"(.*)\")\n OR\n vm_app_version{job=~\"$job\", instance=~\"$instance\", short_version=\"\"}\n) \nunless \n(\n sum by(version) (\n label_replace(vm_app_version{job=~\"$job\", instance=~\"$instance\", short_version!=\"\"}, \"version\", \"$1\", \"short_version\", \"(.*)\")\n OR\n vm_app_version{job=~\"$job\", instance=~\"$instance\", short_version=\"\"}\n ) offset $__interval\n)",
|
||||
"expr": "sum(vm_app_version{job=~\"$job\", instance=~\"$instance\"}) by(short_version) unless (sum(vm_app_version{job=~\"$job\", instance=~\"$instance\"} offset $__interval) by(short_version))",
|
||||
"hide": true,
|
||||
"iconColor": "dark-blue",
|
||||
"name": "version",
|
||||
"textFormat": "{{version}}",
|
||||
"textFormat": "{{short_version}}",
|
||||
"titleFormat": "Version change"
|
||||
},
|
||||
{
|
||||
|
||||
@@ -54,19 +54,6 @@
|
||||
},
|
||||
"type": "dashboard"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "victoriametrics-metrics-datasource",
|
||||
"uid": "$ds"
|
||||
},
|
||||
"enable": true,
|
||||
"expr": "sum by(version) (\n label_replace(vm_app_version{job=~\"$job\", instance=~\"$instance\", short_version!=\"\"}, \"version\", \"$1\", \"short_version\", \"(.*)\")\n OR\n vm_app_version{job=~\"$job\", instance=~\"$instance\", short_version=\"\"}\n) \nunless \n(\n sum by(version) (\n label_replace(vm_app_version{job=~\"$job\", instance=~\"$instance\", short_version!=\"\"}, \"version\", \"$1\", \"short_version\", \"(.*)\")\n OR\n vm_app_version{job=~\"$job\", instance=~\"$instance\", short_version=\"\"}\n ) offset $__interval\n)",
|
||||
"hide": true,
|
||||
"iconColor": "dark-blue",
|
||||
"name": "version",
|
||||
"textFormat": "{{version}}",
|
||||
"titleFormat": "Version change"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "victoriametrics-metrics-datasource",
|
||||
|
||||
@@ -25,11 +25,11 @@
|
||||
"uid": "$ds"
|
||||
},
|
||||
"enable": true,
|
||||
"expr": "sum by(version) (\n label_replace(vm_app_version{job=~\"$job\", instance=~\"$instance\", short_version!=\"\"}, \"version\", \"$1\", \"short_version\", \"(.*)\")\n OR\n vm_app_version{job=~\"$job\", instance=~\"$instance\", short_version=\"\"}\n) \nunless \n(\n sum by(version) (\n label_replace(vm_app_version{job=~\"$job\", instance=~\"$instance\", short_version!=\"\"}, \"version\", \"$1\", \"short_version\", \"(.*)\")\n OR\n vm_app_version{job=~\"$job\", instance=~\"$instance\", short_version=\"\"}\n ) offset $__interval\n)",
|
||||
"expr": "sum(vm_app_version{job=~\"$job\", instance=~\"$instance\"}) by(short_version) unless (sum(vm_app_version{job=~\"$job\", instance=~\"$instance\"} offset $__interval) by(short_version))",
|
||||
"hide": true,
|
||||
"iconColor": "dark-blue",
|
||||
"name": "version",
|
||||
"textFormat": "{{version}}",
|
||||
"textFormat": "{{short_version}}",
|
||||
"titleFormat": "Version change"
|
||||
},
|
||||
{
|
||||
|
||||
@@ -25,11 +25,11 @@
|
||||
"uid": "$ds"
|
||||
},
|
||||
"enable": true,
|
||||
"expr": "sum by(version) (\n label_replace(vm_app_version{job=~\"$job\", instance=~\"$instance\", short_version!=\"\"}, \"version\", \"$1\", \"short_version\", \"(.*)\")\n OR\n vm_app_version{job=~\"$job\", instance=~\"$instance\", short_version=\"\"}\n) \nunless \n(\n sum by(version) (\n label_replace(vm_app_version{job=~\"$job\", instance=~\"$instance\", short_version!=\"\"}, \"version\", \"$1\", \"short_version\", \"(.*)\")\n OR\n vm_app_version{job=~\"$job\", instance=~\"$instance\", short_version=\"\"}\n ) offset $__interval\n)",
|
||||
"expr": "sum(vm_app_version{job=~\"$job\", instance=~\"$instance\"}) by(short_version) unless (sum(vm_app_version{job=~\"$job\", instance=~\"$instance\"} offset $__interval) by(short_version))",
|
||||
"hide": true,
|
||||
"iconColor": "dark-blue",
|
||||
"name": "version",
|
||||
"textFormat": "{{version}}",
|
||||
"textFormat": "{{short_version}}",
|
||||
"titleFormat": "Version change"
|
||||
},
|
||||
{
|
||||
|
||||
@@ -53,19 +53,6 @@
|
||||
},
|
||||
"type": "dashboard"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "$ds"
|
||||
},
|
||||
"enable": true,
|
||||
"expr": "sum by(version) (\n label_replace(vm_app_version{job=~\"$job\", instance=~\"$instance\", short_version!=\"\"}, \"version\", \"$1\", \"short_version\", \"(.*)\")\n OR\n vm_app_version{job=~\"$job\", instance=~\"$instance\", short_version=\"\"}\n) \nunless \n(\n sum by(version) (\n label_replace(vm_app_version{job=~\"$job\", instance=~\"$instance\", short_version!=\"\"}, \"version\", \"$1\", \"short_version\", \"(.*)\")\n OR\n vm_app_version{job=~\"$job\", instance=~\"$instance\", short_version=\"\"}\n ) offset $__interval\n)",
|
||||
"hide": true,
|
||||
"iconColor": "dark-blue",
|
||||
"name": "version",
|
||||
"textFormat": "{{version}}",
|
||||
"titleFormat": "Version change"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
|
||||
@@ -7,7 +7,7 @@ ROOT_IMAGE ?= alpine:3.24.1
|
||||
ROOT_IMAGE_SCRATCH ?= scratch
|
||||
CERTS_IMAGE := alpine:3.24.1
|
||||
|
||||
GO_BUILDER_IMAGE := golang:1.26.6
|
||||
GO_BUILDER_IMAGE := golang:1.26.5
|
||||
|
||||
BUILDER_IMAGE := local/builder:2.0.0-$(shell echo $(GO_BUILDER_IMAGE) | tr :/ __)-1
|
||||
BASE_IMAGE := local/base:1.1.4-$(shell echo $(ROOT_IMAGE) | tr :/ __)-$(shell echo $(CERTS_IMAGE) | tr :/ __)
|
||||
|
||||
@@ -3,7 +3,7 @@ services:
|
||||
# It scrapes targets defined in --promscrape.config
|
||||
# And forward them to --remoteWrite.url
|
||||
vmagent:
|
||||
image: victoriametrics/vmagent:v1.150.0
|
||||
image: victoriametrics/vmagent:v1.148.0
|
||||
depends_on:
|
||||
- "vmauth"
|
||||
ports:
|
||||
@@ -42,14 +42,14 @@ services:
|
||||
# vmstorage shards. Each shard receives 1/N of all metrics sent to vminserts,
|
||||
# where N is number of vmstorages (2 in this case).
|
||||
vmstorage-1:
|
||||
image: victoriametrics/vmstorage:v1.150.0-cluster
|
||||
image: victoriametrics/vmstorage:v1.148.0-cluster
|
||||
volumes:
|
||||
- strgdata-1:/storage
|
||||
command:
|
||||
- "--storageDataPath=/storage"
|
||||
restart: always
|
||||
vmstorage-2:
|
||||
image: victoriametrics/vmstorage:v1.150.0-cluster
|
||||
image: victoriametrics/vmstorage:v1.148.0-cluster
|
||||
volumes:
|
||||
- strgdata-2:/storage
|
||||
command:
|
||||
@@ -59,7 +59,7 @@ services:
|
||||
# vminsert is ingestion frontend. It receives metrics pushed by vmagent,
|
||||
# pre-process them and distributes across configured vmstorage shards.
|
||||
vminsert-1:
|
||||
image: victoriametrics/vminsert:v1.150.0-cluster
|
||||
image: victoriametrics/vminsert:v1.148.0-cluster
|
||||
depends_on:
|
||||
- "vmstorage-1"
|
||||
- "vmstorage-2"
|
||||
@@ -68,7 +68,7 @@ services:
|
||||
- "--storageNode=vmstorage-2:8400"
|
||||
restart: always
|
||||
vminsert-2:
|
||||
image: victoriametrics/vminsert:v1.150.0-cluster
|
||||
image: victoriametrics/vminsert:v1.148.0-cluster
|
||||
depends_on:
|
||||
- "vmstorage-1"
|
||||
- "vmstorage-2"
|
||||
@@ -80,7 +80,7 @@ services:
|
||||
# vmselect is a query fronted. It serves read queries in MetricsQL or PromQL.
|
||||
# vmselect collects results from configured `--storageNode` shards.
|
||||
vmselect-1:
|
||||
image: victoriametrics/vmselect:v1.150.0-cluster
|
||||
image: victoriametrics/vmselect:v1.148.0-cluster
|
||||
depends_on:
|
||||
- "vmstorage-1"
|
||||
- "vmstorage-2"
|
||||
@@ -90,7 +90,7 @@ services:
|
||||
- "--vmalert.proxyURL=http://vmalert:8880"
|
||||
restart: always
|
||||
vmselect-2:
|
||||
image: victoriametrics/vmselect:v1.150.0-cluster
|
||||
image: victoriametrics/vmselect:v1.148.0-cluster
|
||||
depends_on:
|
||||
- "vmstorage-1"
|
||||
- "vmstorage-2"
|
||||
@@ -105,7 +105,7 @@ services:
|
||||
# read requests from Grafana, vmui, vmalert among vmselects.
|
||||
# It can be used as an authentication proxy.
|
||||
vmauth:
|
||||
image: victoriametrics/vmauth:v1.150.0
|
||||
image: victoriametrics/vmauth:v1.148.0
|
||||
depends_on:
|
||||
- "vmselect-1"
|
||||
- "vmselect-2"
|
||||
@@ -119,7 +119,7 @@ services:
|
||||
|
||||
# vmalert executes alerting and recording rules
|
||||
vmalert:
|
||||
image: victoriametrics/vmalert:v1.150.0
|
||||
image: victoriametrics/vmalert:v1.148.0
|
||||
depends_on:
|
||||
- "vmauth"
|
||||
ports:
|
||||
|
||||
@@ -3,7 +3,7 @@ services:
|
||||
# It scrapes targets defined in --promscrape.config
|
||||
# And forward them to --remoteWrite.url
|
||||
vmagent:
|
||||
image: victoriametrics/vmagent:v1.150.0
|
||||
image: victoriametrics/vmagent:v1.148.0
|
||||
depends_on:
|
||||
- "victoriametrics"
|
||||
ports:
|
||||
@@ -18,7 +18,7 @@ services:
|
||||
# VictoriaMetrics instance, a single process responsible for
|
||||
# storing metrics and serve read requests.
|
||||
victoriametrics:
|
||||
image: victoriametrics/victoria-metrics:v1.150.0
|
||||
image: victoriametrics/victoria-metrics:v1.148.0
|
||||
ports:
|
||||
- 8428:8428
|
||||
- 8089:8089
|
||||
@@ -59,7 +59,7 @@ services:
|
||||
|
||||
# vmalert executes alerting and recording rules
|
||||
vmalert:
|
||||
image: victoriametrics/vmalert:v1.150.0
|
||||
image: victoriametrics/vmalert:v1.148.0
|
||||
depends_on:
|
||||
- "victoriametrics"
|
||||
- "alertmanager"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
vmagent:
|
||||
image: victoriametrics/vmagent:v1.150.0
|
||||
image: victoriametrics/vmagent:v1.148.0
|
||||
depends_on:
|
||||
- "victoriametrics"
|
||||
ports:
|
||||
@@ -14,7 +14,7 @@ services:
|
||||
restart: always
|
||||
|
||||
victoriametrics:
|
||||
image: victoriametrics/victoria-metrics:v1.150.0
|
||||
image: victoriametrics/victoria-metrics:v1.148.0
|
||||
ports:
|
||||
- 8428:8428
|
||||
volumes:
|
||||
@@ -40,7 +40,7 @@ services:
|
||||
restart: always
|
||||
|
||||
vmalert:
|
||||
image: victoriametrics/vmalert:v1.150.0
|
||||
image: victoriametrics/vmalert:v1.148.0
|
||||
depends_on:
|
||||
- "victoriametrics"
|
||||
ports:
|
||||
@@ -59,7 +59,7 @@ services:
|
||||
- '--external.alert.source=explore?orgId=1&left=["now-1h","now","VictoriaMetrics",{"expr": },{"mode":"Metrics"},{"ui":[true,true,true,"none"]}]'
|
||||
restart: always
|
||||
vmanomaly:
|
||||
image: victoriametrics/vmanomaly:v1.30.2
|
||||
image: victoriametrics/vmanomaly:v1.30.0
|
||||
depends_on:
|
||||
- "victoriametrics"
|
||||
ports:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
schedulers:
|
||||
periodic:
|
||||
infer_every: "1m"
|
||||
fit_every: "1000d" # bootstrap-only schedule; use a finite cadence if accumulated state must be reset
|
||||
fit_every: "100w" # the online model keeps learning during inference
|
||||
fit_window: "2w"
|
||||
|
||||
models:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user