Compare commits

..

4 Commits

Author SHA1 Message Date
dmitryk-dk
e54c3ecfb6 vmctl: fix CHANGELOG.md after resolving conflicts 2026-07-30 13:07:56 +02:00
dmitryk-dk
d98336e581 vmctl: update docs 2026-07-30 13:05:26 +02:00
dmitryk-dk
13ef9c3ba8 vmctl: update changelogs and documentation 2026-07-30 13:05:23 +02:00
dmitryk-dk
5a87c85093 vmctl: add support of the migration from the influx v2 2026-07-30 13:04:52 +02:00
290 changed files with 2763 additions and 48821 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -52,7 +52,6 @@ func setUp() {
func tearDown() {
protoparserutil.StopUnmarshalWorkers()
remotewrite.Stop()
srv.Close()
logger.ResetOutputForTest()
tmpDataDir := flag.Lookup("remoteWrite.tmpDataPath").Value.String()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

@@ -3,7 +3,7 @@
"name": "vmui",
"icons": [
{
"src": "./assets/favicon.svg",
"src": "favicon.svg",
"sizes": "any",
"type": "image/svg+xml"
}

View File

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

View File

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

View File

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

View 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

View File

@@ -3,7 +3,7 @@
"name": "vmui",
"icons": [
{
"src": "./assets/favicon.svg",
"src": "favicon.svg",
"sizes": "any",
"type": "image/svg+xml"
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,7 +4,7 @@
&__toggle {
display: inline-flex;
width: 100%;
min-width: 300px;
text-transform: capitalize;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,14 +0,0 @@
export const faviconColors = [
"#A1A1AA",
"#71717A",
"#020202",
"#E94600",
"#FF7A00",
"#F2B705",
"#84CC16",
"#16B86A",
"#00AFAF",
"#2979FF",
"#8B5CF6",
"#E83E9A",
] as const;

View File

@@ -11,7 +11,6 @@
&_mobile {
display: grid;
grid-template-columns: 1fr;
gap: 0;
padding: 0;
flex-grow: initial;

View File

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

View File

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

View File

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

View File

@@ -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(/\/+$/, "") || "/";

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 :/ __)

View File

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

View File

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

View File

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

View File

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