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

This commit is contained in:
Aliaksandr Valialkin 2023-02-09 13:08:34 -08:00
commit a39140baef
No known key found for this signature in database
GPG key ID: A72BEC6CD3D0DED1
296 changed files with 8482 additions and 2780 deletions

View file

@ -17,7 +17,7 @@ jobs:
- name: Setup Go
uses: actions/setup-go@main
with:
go-version: 1.19.5
go-version: 1.20.0
id: go
- name: Code checkout
uses: actions/checkout@master

View file

@ -13,6 +13,10 @@ on:
schedule:
- cron: "30 18 * * 2"
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
analyze:
name: Analyze

View file

@ -15,6 +15,7 @@ on:
push:
branches: [master, cluster]
paths-ignore:
- "docs/**"
- "**.md"
- "**.txt"
- "**.js"
@ -22,12 +23,17 @@ on:
# The branches below must be a subset of the branches above
branches: [master, cluster]
paths-ignore:
- "docs/**"
- "**.md"
- "**.txt"
- "**.js"
schedule:
- cron: "30 18 * * 2"
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
analyze:
name: Analyze
@ -51,7 +57,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v3
with:
go-version: 1.19.5
go-version: 1.20.0
check-latest: true
cache: true
if: ${{ matrix.language == 'go' }}

View file

@ -1,66 +0,0 @@
name: main - test
on:
push:
branches:
- master
- cluster
paths-ignore:
- "docs/**"
- "**.md"
pull_request:
branches:
- master
- cluster
paths-ignore:
- "docs/**"
- "**.md"
permissions:
contents: read
jobs:
lint:
name: lint
runs-on: ubuntu-latest
steps:
- name: Code checkout
uses: actions/checkout@v3
- name: Setup Go
uses: actions/setup-go@v3
with:
go-version: 1.19.5
check-latest: true
cache: true
- name: Dependencies
run: |
make install-golangci-lint
make check-all
git diff --exit-code
test:
needs: lint
strategy:
matrix:
scenario: ["test-full", "test-pure", "test-full-386"]
name: test
runs-on: ubuntu-latest
steps:
- name: Code checkout
uses: actions/checkout@v3
- name: Setup Go
uses: actions/setup-go@v3
with:
go-version: 1.19.5
check-latest: true
cache: true
- name: run tests
run: |
make ${{ matrix.scenario}}
- name: Publish coverage
uses: codecov/codecov-action@v3
with:
file: ./coverage.txt

View file

@ -1,30 +1,95 @@
name: main
on:
workflow_run:
workflows: ["main - test"]
types:
- completed
push:
branches:
- master
- cluster
paths-ignore:
- "docs/**"
- "**.md"
pull_request:
branches:
- master
- cluster
paths-ignore:
- "docs/**"
- "**.md"
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
build:
name: Build
lint:
name: lint
runs-on: ubuntu-latest
steps:
- name: Code checkout
uses: actions/checkout@v3
with:
ref: ${{ github.event.workflow_run.head_branch }}
- name: Setup Go
uses: actions/setup-go@v3
with:
go-version: 1.19.5
go-version: 1.20.0
check-latest: true
cache: true
- name: Dependencies
run: |
make install-golangci-lint
make check-all
git diff --exit-code
test:
needs: lint
strategy:
matrix:
scenario: ["test-full", "test-pure", "test-full-386"]
name: test
runs-on: ubuntu-latest
steps:
- name: Code checkout
uses: actions/checkout@v3
- name: Setup Go
uses: actions/setup-go@v3
with:
go-version: 1.20.0
check-latest: true
cache: true
- name: run tests
run: |
make ${{ matrix.scenario}}
- name: Publish coverage
uses: codecov/codecov-action@v3
with:
file: ./coverage.txt
build:
needs: test
name: build
runs-on: ubuntu-latest
steps:
- name: Code checkout
uses: actions/checkout@v3
- name: Setup Go
id: go
uses: actions/setup-go@v3
with:
go-version: 1.20.0
check-latest: true
cache: true
- uses: actions/cache@v3
with:
path: gocache-for-docker
key: gocache-docker-${{ runner.os }}-${{ steps.go.outputs.go-version }}-${{ hashFiles('go.mod') }}
- name: Build
run: |
make victoria-metrics-crossbuild

View file

@ -12,28 +12,37 @@ jobs:
name: Build
runs-on: ubuntu-latest
steps:
-
name: Login to Docker Hub
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
-
name: Setup Go
- name: Setup Go
uses: actions/setup-go@main
with:
go-version: 1.19.5
go-version: 1.20.0
id: go
-
name: Setup docker scan
- name: Setup docker scan
run: |
mkdir -p ~/.docker/cli-plugins && \
curl https://github.com/docker/scan-cli-plugin/releases/latest/download/docker-scan_linux_amd64 -L -s -S -o ~/.docker/cli-plugins/docker-scan &&\
chmod +x ~/.docker/cli-plugins/docker-scan
-
name: Code checkout
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Code checkout
uses: actions/checkout@master
-
name: Publish
- uses: actions/cache@v3
with:
path: gocache-for-docker
key: gocache-docker-${{ runner.os }}-${{ steps.go.outputs.go-version }}-${{ hashFiles('go.mod') }}
- name: build & publish
run: |
docker scan --login --token "$SNYK_TOKEN" --accept-license
LATEST_TAG=nightly PKG_TAG=nightly make publish
env:
SNYK_TOKEN: ${{ secrets.SNYK_AUTH_TOKEN }}

View file

@ -380,7 +380,7 @@ golangci-lint: install-golangci-lint
golangci-lint run
install-golangci-lint:
which golangci-lint || curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(shell go env GOPATH)/bin v1.50.1
which golangci-lint || curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(shell go env GOPATH)/bin v1.51.1
govulncheck: install-govulncheck
govulncheck ./...

View file

@ -324,7 +324,7 @@ Extra labels can be added to metrics collected by `vmagent` via the following me
## Automatically generated metrics
`vmagent` automatically generates the following metrics per each scrape of every [Prometheus-compatible target](#how-to-collect-metrics-in-prometheus-format)
and attaches target-specific `instance` and `job` labels to these metrics:
and attaches `instance`, `job` and other target-specific labels to these metrics:
* `up` - this metric exposes `1` value on successful scrape and `0` value on unsuccessful scrape. This allows monitoring
failing scrapes with the following [MetricsQL query](https://docs.victoriametrics.com/MetricsQL.html):

View file

@ -907,10 +907,6 @@ The shortlist of configuration flags is the following:
Address to listen for http connections. See also -httpListenAddr.useProxyProtocol (default ":8880")
-httpListenAddr.useProxyProtocol
Whether to use proxy protocol for connections accepted at -httpListenAddr . See https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt
-insert.maxQueueDuration duration
The maximum duration to wait in the queue when -maxConcurrentInserts concurrent insert requests are executed (default 1m0s)
-internStringMaxLen int
The maximum length for strings to intern. Lower limit may save memory at the cost of higher CPU usage. See https://en.wikipedia.org/wiki/String_interning (default 300)
-loggerDisableTimestamps
Whether to disable writing timestamps in logs
-loggerErrorsPerSecondLimit int
@ -927,13 +923,6 @@ The shortlist of configuration flags is the following:
Timezone to use for timestamps in logs. Timezone must be a valid IANA Time Zone. For example: America/New_York, Europe/Berlin, Etc/GMT+3 or Local (default "UTC")
-loggerWarnsPerSecondLimit int
Per-second limit on the number of WARN messages. If more than the given number of warns are emitted per second, then the remaining warns are suppressed. Zero values disable the rate limit
-maxConcurrentInserts int
The maximum number of concurrent insert requests. Default value should work for most cases, since it minimizes the memory usage. The default value can be increased when clients send data over slow networks. See also -insert.maxQueueDuration (default 8)
-memory.allowedBytes size
Allowed size of system memory VictoriaMetrics caches may occupy. This option overrides -memory.allowedPercent if set to a non-zero value. Too low a value may increase the cache miss rate usually resulting in higher CPU and disk IO usage. Too high a value may evict too much data from OS page cache resulting in higher disk IO usage
Supports the following optional suffixes for size values: KB, MB, GB, TB, KiB, MiB, GiB, TiB (default 0)
-memory.allowedPercent float
Allowed percent of system memory VictoriaMetrics caches may occupy. See also -memory.allowedBytes. Too low a value may increase cache miss rate usually resulting in higher CPU and disk IO usage. Too high a value may evict too much data from OS page cache which will result in higher disk IO usage (default 60)
-metricsAuthKey string
Auth key for /metrics endpoint. It must be passed via authKey query arg. It overrides httpAuth.* settings
-notifier.basicAuth.password array
@ -1023,7 +1012,7 @@ The shortlist of configuration flags is the following:
-remoteRead.headers string
Optional HTTP headers to send with each request to the corresponding -remoteRead.url. For example, -remoteRead.headers='My-Auth:foobar' would send 'My-Auth: foobar' HTTP header with every request to the corresponding -remoteRead.url. Multiple headers must be delimited by '^^': -remoteRead.headers='header1:value1^^header2:value2'
-remoteRead.ignoreRestoreErrors
Whether to ignore errors from remote storage when restoring alerts state on startup. (default true)
Whether to ignore errors from remote storage when restoring alerts state on startup. DEPRECATED - this flag has no effect and will be removed in the next releases. (default true)
-remoteRead.lookback duration
Lookback defines how far to look into past for alerts timeseries. For example, if lookback=1h then range from now() to now()-1h will be scanned. (default 1h0m0s)
-remoteRead.oauth2.clientID string
@ -1101,7 +1090,7 @@ The shortlist of configuration flags is the following:
-replay.disableProgressBar
Whether to disable rendering progress bars during the replay. Progress bar rendering might be verbose or break the logs parsing, so it is recommended to be disabled when not used in interactive mode.
-replay.maxDatapointsPerQuery int
Max number of data points expected in one request. The higher the value, the less requests will be made during replay. (default 1000)
Max number of data points expected in one request. It affects the max time range for every `/query_range` request during the replay. The higher the value, the less requests will be made during replay. (default 1000)
-replay.ruleRetryAttempts int
Defines how many retries to make before giving up on rule if request for it returns an error. (default 5)
-replay.rulesDelay duration

View file

@ -421,7 +421,9 @@ func (ar *AlertingRule) UpdateWith(r Rule) error {
ar.Labels = nr.Labels
ar.Annotations = nr.Annotations
ar.EvalInterval = nr.EvalInterval
ar.Debug = nr.Debug
ar.q = nr.q
ar.state = nr.state
return nil
}
@ -498,6 +500,7 @@ func (ar *AlertingRule) ToAPI() APIRule {
LastSamples: lastState.samples,
MaxUpdates: ar.state.size(),
Updates: ar.state.getAll(),
Debug: ar.Debug,
// encode as strings to avoid rounding in JSON
ID: fmt.Sprintf("%d", ar.ID()),
@ -604,54 +607,59 @@ func alertForToTimeSeries(a *notifier.Alert, timestamp int64) prompbmarshal.Time
return newTimeSeries([]float64{float64(a.ActiveAt.Unix())}, []int64{timestamp}, labels)
}
// Restore restores the state of active alerts basing on previously written time series.
// Restore restores only ActiveAt field. Field State will be always Pending and supposed
// to be updated on next Exec, as well as Value field.
// Only rules with For > 0 will be restored.
func (ar *AlertingRule) Restore(ctx context.Context, q datasource.Querier, lookback time.Duration, labels map[string]string) error {
if q == nil {
return fmt.Errorf("querier is nil")
// Restore restores the value of ActiveAt field for active alerts,
// based on previously written time series `alertForStateMetricName`.
// Only rules with For > 0 can be restored.
func (ar *AlertingRule) Restore(ctx context.Context, q datasource.Querier, ts time.Time, lookback time.Duration) error {
if ar.For < 1 {
return nil
}
ts := time.Now()
qFn := func(query string) ([]datasource.Metric, error) {
res, _, err := ar.q.Query(ctx, query, ts)
return res, err
ar.alertsMu.Lock()
defer ar.alertsMu.Unlock()
if len(ar.alerts) < 1 {
return nil
}
// account for external labels in filter
var labelsFilter string
for k, v := range labels {
labelsFilter += fmt.Sprintf(",%s=%q", k, v)
}
expr := fmt.Sprintf("last_over_time(%s{alertname=%q%s}[%ds])",
alertForStateMetricName, ar.Name, labelsFilter, int(lookback.Seconds()))
qMetrics, _, err := q.Query(ctx, expr, ts)
if err != nil {
return err
}
for _, m := range qMetrics {
ls := &labelSet{
origin: make(map[string]string, len(m.Labels)),
processed: make(map[string]string, len(m.Labels)),
for _, a := range ar.alerts {
if a.Restored || a.State != notifier.StatePending {
continue
}
for _, l := range m.Labels {
if l.Name == "__name__" {
continue
}
ls.origin[l.Name] = l.Value
ls.processed[l.Name] = l.Value
var labelsFilter []string
for k, v := range a.Labels {
labelsFilter = append(labelsFilter, fmt.Sprintf("%s=%q", k, v))
}
a, err := ar.newAlert(m, ls, time.Unix(int64(m.Values[0]), 0), qFn)
sort.Strings(labelsFilter)
expr := fmt.Sprintf("last_over_time(%s{%s}[%ds])",
alertForStateMetricName, strings.Join(labelsFilter, ","), int(lookback.Seconds()))
ar.logDebugf(ts, nil, "restoring alert state via query %q", expr)
qMetrics, _, err := q.Query(ctx, expr, ts)
if err != nil {
return fmt.Errorf("failed to create alert: %w", err)
return err
}
a.ID = hash(ls.processed)
a.State = notifier.StatePending
if len(qMetrics) < 1 {
ar.logDebugf(ts, nil, "no response was received from restore query")
continue
}
// only one series expected in response
m := qMetrics[0]
// __name__ supposed to be alertForStateMetricName
m.DelLabel("__name__")
// we assume that restore query contains all label matchers,
// so all received labels will match anyway if their number is equal.
if len(m.Labels) != len(a.Labels) {
ar.logDebugf(ts, nil, "state restore query returned not expected label-set %v", m.Labels)
continue
}
a.ActiveAt = time.Unix(int64(m.Values[0]), 0)
a.Restored = true
ar.alerts[a.ID] = a
logger.Infof("alert %q (%d) restored to state at %v", a.Name, a.ID, a.ActiveAt)
}
return nil

View file

@ -6,12 +6,15 @@ import (
"reflect"
"sort"
"strings"
"sync"
"testing"
"time"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vmalert/config"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vmalert/datasource"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vmalert/notifier"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/prompbmarshal"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/promutils"
)
func TestAlertingRule_ToTimeSeries(t *testing.T) {
@ -502,118 +505,156 @@ func TestAlertingRule_ExecRange(t *testing.T) {
}
}
func TestAlertingRule_Restore(t *testing.T) {
testCases := []struct {
rule *AlertingRule
metrics []datasource.Metric
expAlerts map[uint64]*notifier.Alert
}{
{
newTestRuleWithLabels("no extra labels"),
[]datasource.Metric{
metricWithValueAndLabels(t, float64(time.Now().Truncate(time.Hour).Unix()),
"__name__", alertForStateMetricName,
),
},
map[uint64]*notifier.Alert{
hash(nil): {State: notifier.StatePending,
ActiveAt: time.Now().Truncate(time.Hour)},
},
},
{
newTestRuleWithLabels("metric labels"),
[]datasource.Metric{
metricWithValueAndLabels(t, float64(time.Now().Truncate(time.Hour).Unix()),
"__name__", alertForStateMetricName,
alertNameLabel, "metric labels",
alertGroupNameLabel, "groupID",
"foo", "bar",
"namespace", "baz",
),
},
map[uint64]*notifier.Alert{
hash(map[string]string{
alertNameLabel: "metric labels",
alertGroupNameLabel: "groupID",
"foo": "bar",
"namespace": "baz",
}): {State: notifier.StatePending,
ActiveAt: time.Now().Truncate(time.Hour)},
},
},
{
newTestRuleWithLabels("rule labels", "source", "vm"),
[]datasource.Metric{
metricWithValueAndLabels(t, float64(time.Now().Truncate(time.Hour).Unix()),
"__name__", alertForStateMetricName,
"foo", "bar",
"namespace", "baz",
// extra labels set by rule
"source", "vm",
),
},
map[uint64]*notifier.Alert{
hash(map[string]string{
"foo": "bar",
"namespace": "baz",
"source": "vm",
}): {State: notifier.StatePending,
ActiveAt: time.Now().Truncate(time.Hour)},
},
},
{
newTestRuleWithLabels("multiple alerts"),
[]datasource.Metric{
metricWithValueAndLabels(t, float64(time.Now().Truncate(time.Hour).Unix()),
"__name__", alertForStateMetricName,
"host", "localhost-1",
),
metricWithValueAndLabels(t, float64(time.Now().Truncate(2*time.Hour).Unix()),
"__name__", alertForStateMetricName,
"host", "localhost-2",
),
metricWithValueAndLabels(t, float64(time.Now().Truncate(3*time.Hour).Unix()),
"__name__", alertForStateMetricName,
"host", "localhost-3",
),
},
map[uint64]*notifier.Alert{
hash(map[string]string{"host": "localhost-1"}): {State: notifier.StatePending,
ActiveAt: time.Now().Truncate(time.Hour)},
hash(map[string]string{"host": "localhost-2"}): {State: notifier.StatePending,
ActiveAt: time.Now().Truncate(2 * time.Hour)},
hash(map[string]string{"host": "localhost-3"}): {State: notifier.StatePending,
ActiveAt: time.Now().Truncate(3 * time.Hour)},
},
},
func TestGroup_Restore(t *testing.T) {
defaultTS := time.Now()
fqr := &fakeQuerierWithRegistry{}
fn := func(rules []config.Rule, expAlerts map[uint64]*notifier.Alert) {
t.Helper()
defer fqr.reset()
for _, r := range rules {
fqr.set(r.Expr, metricWithValueAndLabels(t, 0, "__name__", r.Alert))
}
fg := newGroup(config.Group{Name: "TestRestore", Rules: rules}, fqr, time.Second, nil)
wg := sync.WaitGroup{}
wg.Add(1)
go func() {
nts := func() []notifier.Notifier { return []notifier.Notifier{&fakeNotifier{}} }
fg.start(context.Background(), nts, nil, fqr)
wg.Done()
}()
fg.close()
wg.Wait()
gotAlerts := make(map[uint64]*notifier.Alert)
for _, rs := range fg.Rules {
alerts := rs.(*AlertingRule).alerts
for k, v := range alerts {
if !v.Restored {
// set not restored alerts to predictable timestamp
v.ActiveAt = defaultTS
}
gotAlerts[k] = v
}
}
if len(gotAlerts) != len(expAlerts) {
t.Fatalf("expected %d alerts; got %d", len(expAlerts), len(gotAlerts))
}
for key, exp := range expAlerts {
got, ok := gotAlerts[key]
if !ok {
t.Fatalf("expected to have key %d", key)
}
if got.State != notifier.StatePending {
t.Fatalf("expected state %d; got %d", notifier.StatePending, got.State)
}
if got.ActiveAt != exp.ActiveAt {
t.Fatalf("expected ActiveAt %v; got %v", exp.ActiveAt, got.ActiveAt)
}
}
}
fakeGroup := Group{Name: "TestRule_Exec"}
for _, tc := range testCases {
t.Run(tc.rule.Name, func(t *testing.T) {
fq := &fakeQuerier{}
tc.rule.GroupID = fakeGroup.ID()
tc.rule.q = fq
fq.add(tc.metrics...)
if err := tc.rule.Restore(context.TODO(), fq, time.Hour, nil); err != nil {
t.Fatalf("unexpected err: %s", err)
}
if len(tc.rule.alerts) != len(tc.expAlerts) {
t.Fatalf("expected %d alerts; got %d", len(tc.expAlerts), len(tc.rule.alerts))
}
for key, exp := range tc.expAlerts {
got, ok := tc.rule.alerts[key]
if !ok {
t.Fatalf("expected to have key %d", key)
}
if got.State != exp.State {
t.Fatalf("expected state %d; got %d", exp.State, got.State)
}
if got.ActiveAt != exp.ActiveAt {
t.Fatalf("expected ActiveAt %v; got %v", exp.ActiveAt, got.ActiveAt)
}
}
stateMetric := func(name string, value time.Time, labels ...string) datasource.Metric {
labels = append(labels, "__name__", alertForStateMetricName)
labels = append(labels, alertNameLabel, name)
labels = append(labels, alertGroupNameLabel, "TestRestore")
return metricWithValueAndLabels(t, float64(value.Unix()), labels...)
}
// one active alert, no previous state
fn(
[]config.Rule{{Alert: "foo", Expr: "foo", For: promutils.NewDuration(time.Second)}},
map[uint64]*notifier.Alert{
hash(map[string]string{alertNameLabel: "foo", alertGroupNameLabel: "TestRestore"}): {
ActiveAt: defaultTS,
},
})
fqr.reset()
// one active alert with state restore
ts := time.Now().Truncate(time.Hour)
fqr.set(`last_over_time(ALERTS_FOR_STATE{alertgroup="TestRestore",alertname="foo"}[3600s])`,
stateMetric("foo", ts))
fn(
[]config.Rule{{Alert: "foo", Expr: "foo", For: promutils.NewDuration(time.Second)}},
map[uint64]*notifier.Alert{
hash(map[string]string{alertNameLabel: "foo", alertGroupNameLabel: "TestRestore"}): {
ActiveAt: ts},
})
// two rules, two active alerts, one with state restored
ts = time.Now().Truncate(time.Hour)
fqr.set(`last_over_time(ALERTS_FOR_STATE{alertgroup="TestRestore",alertname="bar"}[3600s])`,
stateMetric("foo", ts))
fn(
[]config.Rule{
{Alert: "foo", Expr: "foo", For: promutils.NewDuration(time.Second)},
{Alert: "bar", Expr: "bar", For: promutils.NewDuration(time.Second)},
},
map[uint64]*notifier.Alert{
hash(map[string]string{alertNameLabel: "foo", alertGroupNameLabel: "TestRestore"}): {
ActiveAt: defaultTS,
},
hash(map[string]string{alertNameLabel: "bar", alertGroupNameLabel: "TestRestore"}): {
ActiveAt: ts},
})
// two rules, two active alerts, two with state restored
ts = time.Now().Truncate(time.Hour)
fqr.set(`last_over_time(ALERTS_FOR_STATE{alertgroup="TestRestore",alertname="foo"}[3600s])`,
stateMetric("foo", ts))
fqr.set(`last_over_time(ALERTS_FOR_STATE{alertgroup="TestRestore",alertname="bar"}[3600s])`,
stateMetric("bar", ts))
fn(
[]config.Rule{
{Alert: "foo", Expr: "foo", For: promutils.NewDuration(time.Second)},
{Alert: "bar", Expr: "bar", For: promutils.NewDuration(time.Second)},
},
map[uint64]*notifier.Alert{
hash(map[string]string{alertNameLabel: "foo", alertGroupNameLabel: "TestRestore"}): {
ActiveAt: ts,
},
hash(map[string]string{alertNameLabel: "bar", alertGroupNameLabel: "TestRestore"}): {
ActiveAt: ts},
})
// one active alert but wrong state restore
ts = time.Now().Truncate(time.Hour)
fqr.set(`last_over_time(ALERTS_FOR_STATE{alertname="bar",alertgroup="TestRestore"}[3600s])`,
stateMetric("wrong alert", ts))
fn(
[]config.Rule{{Alert: "foo", Expr: "foo", For: promutils.NewDuration(time.Second)}},
map[uint64]*notifier.Alert{
hash(map[string]string{alertNameLabel: "foo", alertGroupNameLabel: "TestRestore"}): {
ActiveAt: defaultTS,
},
})
// one active alert with labels
ts = time.Now().Truncate(time.Hour)
fqr.set(`last_over_time(ALERTS_FOR_STATE{alertgroup="TestRestore",alertname="foo",env="dev"}[3600s])`,
stateMetric("foo", ts, "env", "dev"))
fn(
[]config.Rule{{Alert: "foo", Expr: "foo", Labels: map[string]string{"env": "dev"}, For: promutils.NewDuration(time.Second)}},
map[uint64]*notifier.Alert{
hash(map[string]string{alertNameLabel: "foo", alertGroupNameLabel: "TestRestore", "env": "dev"}): {
ActiveAt: ts,
},
})
// one active alert with restore labels missmatch
ts = time.Now().Truncate(time.Hour)
fqr.set(`last_over_time(ALERTS_FOR_STATE{alertgroup="TestRestore",alertname="foo",env="dev"}[3600s])`,
stateMetric("foo", ts, "env", "dev", "team", "foo"))
fn(
[]config.Rule{{Alert: "foo", Expr: "foo", Labels: map[string]string{"env": "dev"}, For: promutils.NewDuration(time.Second)}},
map[uint64]*notifier.Alert{
hash(map[string]string{alertNameLabel: "foo", alertGroupNameLabel: "TestRestore", "env": "dev"}): {
ActiveAt: defaultTS,
},
})
}
}
func TestAlertingRule_Exec_Negative(t *testing.T) {

View file

@ -72,6 +72,15 @@ func (m *Metric) AddLabel(key, value string) {
m.Labels = append(m.Labels, Label{Name: key, Value: value})
}
// DelLabel deletes the given label from the label set
func (m *Metric) DelLabel(key string) {
for i, l := range m.Labels {
if l.Name == key {
m.Labels = append(m.Labels[:i], m.Labels[i+1:]...)
}
}
}
// Label returns the given label value.
// If label is missing empty string will be returned
func (m *Metric) Label(key string) string {

View file

@ -158,23 +158,23 @@ func (g *Group) ID() uint64 {
}
// Restore restores alerts state for group rules
func (g *Group) Restore(ctx context.Context, qb datasource.QuerierBuilder, lookback time.Duration, labels map[string]string) error {
labels = mergeLabels(g.Name, "", labels, g.Labels)
func (g *Group) Restore(ctx context.Context, qb datasource.QuerierBuilder, ts time.Time, lookback time.Duration) error {
for _, rule := range g.Rules {
rr, ok := rule.(*AlertingRule)
ar, ok := rule.(*AlertingRule)
if !ok {
continue
}
if rr.For < 1 {
if ar.For < 1 {
continue
}
// ignore QueryParams on purpose, because they could contain
// query filters. This may affect the restore procedure.
q := qb.BuildWithParams(datasource.QuerierParams{
DataSourceType: g.Type.String(),
Headers: g.Headers,
DataSourceType: g.Type.String(),
EvaluationInterval: g.Interval,
QueryParams: g.Params,
Headers: g.Headers,
Debug: ar.Debug,
})
if err := rr.Restore(ctx, q, lookback, labels); err != nil {
if err := ar.Restore(ctx, q, ts, lookback); err != nil {
return fmt.Errorf("error while restoring rule %q: %w", rule, err)
}
}
@ -251,7 +251,7 @@ func (g *Group) close() {
var skipRandSleepOnGroupStart bool
func (g *Group) start(ctx context.Context, nts func() []notifier.Notifier, rw *remotewrite.Client) {
func (g *Group) start(ctx context.Context, nts func() []notifier.Notifier, rw *remotewrite.Client, rr datasource.QuerierBuilder) {
defer func() { close(g.finishedCh) }()
e := &executor{
@ -259,26 +259,6 @@ func (g *Group) start(ctx context.Context, nts func() []notifier.Notifier, rw *r
notifiers: nts,
previouslySentSeriesToRW: make(map[uint64]map[string][]prompbmarshal.Label)}
// Spread group rules evaluation over time in order to reduce load on VictoriaMetrics.
if !skipRandSleepOnGroupStart {
randSleep := uint64(float64(g.Interval) * (float64(g.ID()) / (1 << 64)))
sleepOffset := uint64(time.Now().UnixNano()) % uint64(g.Interval)
if randSleep < sleepOffset {
randSleep += uint64(g.Interval)
}
randSleep -= sleepOffset
sleepTimer := time.NewTimer(time.Duration(randSleep))
select {
case <-ctx.Done():
sleepTimer.Stop()
return
case <-g.doneCh:
sleepTimer.Stop()
return
case <-sleepTimer.C:
}
}
evalTS := time.Now()
logger.Infof("group %q started; interval=%v; concurrency=%d", g.Name, g.Interval, g.Concurrency)
@ -309,6 +289,16 @@ func (g *Group) start(ctx context.Context, nts func() []notifier.Notifier, rw *r
t := time.NewTicker(g.Interval)
defer t.Stop()
// restore the rules state after the first evaluation
// so only active alerts can be restored.
if rr != nil {
err := g.Restore(ctx, rr, evalTS, *remoteReadLookBack)
if err != nil {
logger.Errorf("error while restoring ruleState for group %q: %s", g.Name, err)
}
}
for {
select {
case <-ctx.Done():

View file

@ -209,7 +209,7 @@ func TestGroupStart(t *testing.T) {
fs.add(m1)
fs.add(m2)
go func() {
g.start(context.Background(), func() []notifier.Notifier { return []notifier.Notifier{fn} }, nil)
g.start(context.Background(), func() []notifier.Notifier { return []notifier.Notifier{fn} }, nil, fs)
close(finished)
}()

View file

@ -61,6 +61,49 @@ func (fq *fakeQuerier) Query(_ context.Context, _ string, _ time.Time) ([]dataso
return cp, req, nil
}
type fakeQuerierWithRegistry struct {
sync.Mutex
registry map[string][]datasource.Metric
}
func (fqr *fakeQuerierWithRegistry) set(key string, metrics ...datasource.Metric) {
fqr.Lock()
if fqr.registry == nil {
fqr.registry = make(map[string][]datasource.Metric)
}
fqr.registry[key] = metrics
fqr.Unlock()
}
func (fqr *fakeQuerierWithRegistry) reset() {
fqr.Lock()
fqr.registry = nil
fqr.Unlock()
}
func (fqr *fakeQuerierWithRegistry) BuildWithParams(_ datasource.QuerierParams) datasource.Querier {
return fqr
}
func (fqr *fakeQuerierWithRegistry) QueryRange(ctx context.Context, q string, _, _ time.Time) ([]datasource.Metric, error) {
req, _, err := fqr.Query(ctx, q, time.Now())
return req, err
}
func (fqr *fakeQuerierWithRegistry) Query(_ context.Context, expr string, _ time.Time) ([]datasource.Metric, *http.Request, error) {
fqr.Lock()
defer fqr.Unlock()
req, _ := http.NewRequest(http.MethodPost, "foo.com", nil)
metrics, ok := fqr.registry[expr]
if !ok {
return nil, req, nil
}
cp := make([]datasource.Metric, len(metrics))
copy(cp, metrics)
return cp, req, nil
}
type fakeNotifier struct {
sync.Mutex
alerts []notifier.Alert

View file

@ -73,7 +73,7 @@ absolute path to all .tpl files in root.`)
remoteReadLookBack = flag.Duration("remoteRead.lookback", time.Hour, "Lookback defines how far to look into past for alerts timeseries."+
" For example, if lookback=1h then range from now() to now()-1h will be scanned.")
remoteReadIgnoreRestoreErrors = flag.Bool("remoteRead.ignoreRestoreErrors", true, "Whether to ignore errors from remote storage when restoring alerts state on startup.")
remoteReadIgnoreRestoreErrors = flag.Bool("remoteRead.ignoreRestoreErrors", true, "Whether to ignore errors from remote storage when restoring alerts state on startup. DEPRECATED - this flag has no effect and will be removed in the next releases.")
disableAlertGroupLabel = flag.Bool("disableAlertgroupLabel", false, "Whether to disable adding group's Name as label to generated alerts and time series.")
@ -94,6 +94,10 @@ func main() {
logger.Init()
pushmetrics.Init()
if !*remoteReadIgnoreRestoreErrors {
logger.Warnf("flag `remoteRead.ignoreRestoreErrors` is deprecated and will be removed in next releases.")
}
err := templates.Load(*ruleTemplatesPath, true)
if err != nil {
logger.Fatalf("failed to parse %q: %s", *ruleTemplatesPath, err)

View file

@ -6,6 +6,7 @@ import (
"net/url"
"sort"
"sync"
"time"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vmalert/config"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vmalert/datasource"
@ -82,24 +83,38 @@ func (m *manager) close() {
m.wg.Wait()
}
func (m *manager) startGroup(ctx context.Context, group *Group, restore bool) error {
if restore && m.rr != nil {
err := group.Restore(ctx, m.rr, *remoteReadLookBack, m.labels)
if err != nil {
if !*remoteReadIgnoreRestoreErrors {
return fmt.Errorf("failed to restore ruleState for group %q: %w", group.Name, err)
}
logger.Errorf("error while restoring ruleState for group %q: %s", group.Name, err)
}
}
func (m *manager) startGroup(ctx context.Context, g *Group, restore bool) error {
m.wg.Add(1)
id := group.ID()
id := g.ID()
go func() {
group.start(ctx, m.notifiers, m.rw)
// Spread group rules evaluation over time in order to reduce load on VictoriaMetrics.
if !skipRandSleepOnGroupStart {
randSleep := uint64(float64(g.Interval) * (float64(g.ID()) / (1 << 64)))
sleepOffset := uint64(time.Now().UnixNano()) % uint64(g.Interval)
if randSleep < sleepOffset {
randSleep += uint64(g.Interval)
}
randSleep -= sleepOffset
sleepTimer := time.NewTimer(time.Duration(randSleep))
select {
case <-ctx.Done():
sleepTimer.Stop()
return
case <-g.doneCh:
sleepTimer.Stop()
return
case <-sleepTimer.C:
}
}
if restore {
g.start(ctx, m.notifiers, m.rw, m.rr)
} else {
g.start(ctx, m.notifiers, m.rw, nil)
}
m.wg.Done()
}()
m.groups[id] = group
m.groups[id] = g
return nil
}

View file

@ -26,7 +26,7 @@ var (
"and processing need to wait for previous rule results to be persisted by remote storage before evaluating the next rule."+
"Keep it equal or bigger than -remoteWrite.flushInterval.")
replayMaxDatapoints = flag.Int("replay.maxDatapointsPerQuery", 1e3,
"Max number of data points expected in one request. The higher the value, the less requests will be made during replay.")
"Max number of data points expected in one request. It affects the max time range for every `/query_range` request during the replay. The higher the value, the less requests will be made during replay.")
replayRuleRetryAttempts = flag.Int("replay.ruleRetryAttempts", 5,
"Defines how many retries to make before giving up on rule if request for it returns an error.")
disableProgressBar = flag.Bool("replay.disableProgressBar", false, "Whether to disable rendering progress bars during the replay. "+

View file

@ -37,8 +37,6 @@ type ruleState struct {
sync.RWMutex
entries []ruleStateEntry
cur int
// disabled defines whether ruleState tracks ruleStateEntry
disabled bool
}
type ruleStateEntry struct {
@ -61,7 +59,7 @@ type ruleStateEntry struct {
func newRuleState(size int) *ruleState {
if size < 1 {
return &ruleState{disabled: true}
size = 1
}
return &ruleState{
entries: make([]ruleStateEntry, size),
@ -69,10 +67,6 @@ func newRuleState(size int) *ruleState {
}
func (s *ruleState) getLast() ruleStateEntry {
if s.disabled {
return ruleStateEntry{}
}
s.RLock()
defer s.RUnlock()
return s.entries[s.cur]
@ -85,10 +79,6 @@ func (s *ruleState) size() int {
}
func (s *ruleState) getAll() []ruleStateEntry {
if s.disabled {
return nil
}
entries := make([]ruleStateEntry, 0)
s.RLock()
@ -111,10 +101,6 @@ func (s *ruleState) getAll() []ruleStateEntry {
}
func (s *ruleState) add(e ruleStateEntry) {
if s.disabled {
return
}
s.Lock()
defer s.Unlock()

View file

@ -14,13 +14,13 @@ func TestRule_stateDisabled(t *testing.T) {
}
state.add(ruleStateEntry{at: time.Now()})
if !e.at.IsZero() {
t.Fatalf("expected entry to be zero")
}
state.add(ruleStateEntry{at: time.Now()})
state.add(ruleStateEntry{at: time.Now()})
if len(state.getAll()) != 0 {
if len(state.getAll()) != 1 {
// state should store at least one update at any circumstances
t.Fatalf("expected for state to have %d entries; got %d",
0, len(state.getAll()),
1, len(state.getAll()),
)
}
}

View file

@ -40,9 +40,9 @@
for _, g := range groups {
for _, r := range g.Rules {
if r.LastError != "" {
rNotOk[g.Name]++
rNotOk[g.ID]++
} else {
rOk[g.Name]++
rOk[g.ID]++
}
}
}
@ -50,11 +50,11 @@
<a class="btn btn-primary" role="button" onclick="collapseAll()">Collapse All</a>
<a class="btn btn-primary" role="button" onclick="expandAll()">Expand All</a>
{% for _, g := range groups %}
<div class="group-heading{% if rNotOk[g.Name] > 0 %} alert-danger{% endif %}" data-bs-target="rules-{%s g.ID %}">
<div class="group-heading{% if rNotOk[g.ID] > 0 %} alert-danger{% endif %}" data-bs-target="rules-{%s g.ID %}">
<span class="anchor" id="group-{%s g.ID %}"></span>
<a href="#group-{%s g.ID %}">{%s g.Name %}{% if g.Type != "prometheus" %} ({%s g.Type %}){% endif %} (every {%f.0 g.Interval %}s)</a>
{% if rNotOk[g.Name] > 0 %}<span class="badge bg-danger" title="Number of rules with status Error">{%d rNotOk[g.Name] %}</span> {% endif %}
<span class="badge bg-success" title="Number of rules withs status Ok">{%d rOk[g.Name] %}</span>
{% if rNotOk[g.ID] > 0 %}<span class="badge bg-danger" title="Number of rules with status Error">{%d rNotOk[g.ID] %}</span> {% endif %}
<span class="badge bg-success" title="Number of rules withs status Ok">{%d rOk[g.ID] %}</span>
<p class="fs-6 fw-lighter">{%s g.File %}</p>
{% if len(g.Params) > 0 %}
<div class="fs-6 fw-lighter">Extra params
@ -427,6 +427,16 @@
</div>
</div>
</div>
<div class="container border-bottom p-2">
<div class="row">
<div class="col-2">
Debug
</div>
<div class="col">
{%v rule.Debug %}
</div>
</div>
</div>
{% endif %}
<div class="container border-bottom p-2">
<div class="row">

View file

@ -171,9 +171,9 @@ func StreamListGroups(qw422016 *qt422016.Writer, r *http.Request, groups []APIGr
for _, g := range groups {
for _, r := range g.Rules {
if r.LastError != "" {
rNotOk[g.Name]++
rNotOk[g.ID]++
} else {
rOk[g.Name]++
rOk[g.ID]++
}
}
}
@ -189,7 +189,7 @@ func StreamListGroups(qw422016 *qt422016.Writer, r *http.Request, groups []APIGr
qw422016.N().S(`
<div class="group-heading`)
//line app/vmalert/web.qtpl:53
if rNotOk[g.Name] > 0 {
if rNotOk[g.ID] > 0 {
//line app/vmalert/web.qtpl:53
qw422016.N().S(` alert-danger`)
//line app/vmalert/web.qtpl:53
@ -230,11 +230,11 @@ func StreamListGroups(qw422016 *qt422016.Writer, r *http.Request, groups []APIGr
qw422016.N().S(`s)</a>
`)
//line app/vmalert/web.qtpl:56
if rNotOk[g.Name] > 0 {
if rNotOk[g.ID] > 0 {
//line app/vmalert/web.qtpl:56
qw422016.N().S(`<span class="badge bg-danger" title="Number of rules with status Error">`)
//line app/vmalert/web.qtpl:56
qw422016.N().D(rNotOk[g.Name])
qw422016.N().D(rNotOk[g.ID])
//line app/vmalert/web.qtpl:56
qw422016.N().S(`</span> `)
//line app/vmalert/web.qtpl:56
@ -243,7 +243,7 @@ func StreamListGroups(qw422016 *qt422016.Writer, r *http.Request, groups []APIGr
qw422016.N().S(`
<span class="badge bg-success" title="Number of rules withs status Ok">`)
//line app/vmalert/web.qtpl:57
qw422016.N().D(rOk[g.Name])
qw422016.N().D(rOk[g.ID])
//line app/vmalert/web.qtpl:57
qw422016.N().S(`</span>
<p class="fs-6 fw-lighter">`)
@ -1313,10 +1313,24 @@ func StreamRuleDetails(qw422016 *qt422016.Writer, r *http.Request, rule APIRule)
</div>
</div>
</div>
<div class="container border-bottom p-2">
<div class="row">
<div class="col-2">
Debug
</div>
<div class="col">
`)
//line app/vmalert/web.qtpl:436
qw422016.E().V(rule.Debug)
//line app/vmalert/web.qtpl:436
qw422016.N().S(`
</div>
</div>
</div>
`)
//line app/vmalert/web.qtpl:430
//line app/vmalert/web.qtpl:440
}
//line app/vmalert/web.qtpl:430
//line app/vmalert/web.qtpl:440
qw422016.N().S(`
<div class="container border-bottom p-2">
<div class="row">
@ -1325,17 +1339,17 @@ func StreamRuleDetails(qw422016 *qt422016.Writer, r *http.Request, rule APIRule)
</div>
<div class="col">
<a target="_blank" href="`)
//line app/vmalert/web.qtpl:437
//line app/vmalert/web.qtpl:447
qw422016.E().S(prefix)
//line app/vmalert/web.qtpl:437
//line app/vmalert/web.qtpl:447
qw422016.N().S(`groups#group-`)
//line app/vmalert/web.qtpl:437
//line app/vmalert/web.qtpl:447
qw422016.E().S(rule.GroupID)
//line app/vmalert/web.qtpl:437
//line app/vmalert/web.qtpl:447
qw422016.N().S(`">`)
//line app/vmalert/web.qtpl:437
//line app/vmalert/web.qtpl:447
qw422016.E().S(rule.GroupID)
//line app/vmalert/web.qtpl:437
//line app/vmalert/web.qtpl:447
qw422016.N().S(`</a>
</div>
</div>
@ -1343,13 +1357,13 @@ func StreamRuleDetails(qw422016 *qt422016.Writer, r *http.Request, rule APIRule)
<br>
<div class="display-6 pb-3">Last `)
//line app/vmalert/web.qtpl:443
//line app/vmalert/web.qtpl:453
qw422016.N().D(len(rule.Updates))
//line app/vmalert/web.qtpl:443
//line app/vmalert/web.qtpl:453
qw422016.N().S(`/`)
//line app/vmalert/web.qtpl:443
//line app/vmalert/web.qtpl:453
qw422016.N().D(rule.MaxUpdates)
//line app/vmalert/web.qtpl:443
//line app/vmalert/web.qtpl:453
qw422016.N().S(` updates</span>:</div>
<table class="table table-striped table-hover table-sm">
<thead>
@ -1364,201 +1378,201 @@ func StreamRuleDetails(qw422016 *qt422016.Writer, r *http.Request, rule APIRule)
<tbody>
`)
//line app/vmalert/web.qtpl:456
//line app/vmalert/web.qtpl:466
for _, u := range rule.Updates {
//line app/vmalert/web.qtpl:456
//line app/vmalert/web.qtpl:466
qw422016.N().S(`
<tr`)
//line app/vmalert/web.qtpl:457
//line app/vmalert/web.qtpl:467
if u.err != nil {
//line app/vmalert/web.qtpl:457
//line app/vmalert/web.qtpl:467
qw422016.N().S(` class="alert-danger"`)
//line app/vmalert/web.qtpl:457
//line app/vmalert/web.qtpl:467
}
//line app/vmalert/web.qtpl:457
//line app/vmalert/web.qtpl:467
qw422016.N().S(`>
<td>
<span class="badge bg-primary rounded-pill me-3" title="Updated at">`)
//line app/vmalert/web.qtpl:459
//line app/vmalert/web.qtpl:469
qw422016.E().S(u.time.Format(time.RFC3339))
//line app/vmalert/web.qtpl:459
//line app/vmalert/web.qtpl:469
qw422016.N().S(`</span>
</td>
<td class="text-center" wi>`)
//line app/vmalert/web.qtpl:461
//line app/vmalert/web.qtpl:471
qw422016.N().D(u.samples)
//line app/vmalert/web.qtpl:461
//line app/vmalert/web.qtpl:471
qw422016.N().S(`</td>
<td class="text-center">`)
//line app/vmalert/web.qtpl:462
//line app/vmalert/web.qtpl:472
qw422016.N().FPrec(u.duration.Seconds(), 3)
//line app/vmalert/web.qtpl:462
//line app/vmalert/web.qtpl:472
qw422016.N().S(`s</td>
<td class="text-center">`)
//line app/vmalert/web.qtpl:463
//line app/vmalert/web.qtpl:473
qw422016.E().S(u.at.Format(time.RFC3339))
//line app/vmalert/web.qtpl:463
//line app/vmalert/web.qtpl:473
qw422016.N().S(`</td>
<td>
<textarea class="curl-area" rows="1" onclick="this.focus();this.select()">`)
//line app/vmalert/web.qtpl:465
//line app/vmalert/web.qtpl:475
qw422016.E().S(u.curl)
//line app/vmalert/web.qtpl:465
//line app/vmalert/web.qtpl:475
qw422016.N().S(`</textarea>
</td>
</tr>
</li>
`)
//line app/vmalert/web.qtpl:469
//line app/vmalert/web.qtpl:479
if u.err != nil {
//line app/vmalert/web.qtpl:469
//line app/vmalert/web.qtpl:479
qw422016.N().S(`
<tr`)
//line app/vmalert/web.qtpl:470
//line app/vmalert/web.qtpl:480
if u.err != nil {
//line app/vmalert/web.qtpl:470
//line app/vmalert/web.qtpl:480
qw422016.N().S(` class="alert-danger"`)
//line app/vmalert/web.qtpl:470
//line app/vmalert/web.qtpl:480
}
//line app/vmalert/web.qtpl:470
//line app/vmalert/web.qtpl:480
qw422016.N().S(`>
<td colspan="5">
<span class="alert-danger">`)
//line app/vmalert/web.qtpl:472
//line app/vmalert/web.qtpl:482
qw422016.E().V(u.err)
//line app/vmalert/web.qtpl:472
//line app/vmalert/web.qtpl:482
qw422016.N().S(`</span>
</td>
</tr>
`)
//line app/vmalert/web.qtpl:475
//line app/vmalert/web.qtpl:485
}
//line app/vmalert/web.qtpl:475
//line app/vmalert/web.qtpl:485
qw422016.N().S(`
`)
//line app/vmalert/web.qtpl:476
//line app/vmalert/web.qtpl:486
}
//line app/vmalert/web.qtpl:476
//line app/vmalert/web.qtpl:486
qw422016.N().S(`
`)
//line app/vmalert/web.qtpl:478
//line app/vmalert/web.qtpl:488
tpl.StreamFooter(qw422016, r)
//line app/vmalert/web.qtpl:478
//line app/vmalert/web.qtpl:488
qw422016.N().S(`
`)
//line app/vmalert/web.qtpl:479
//line app/vmalert/web.qtpl:489
}
//line app/vmalert/web.qtpl:479
//line app/vmalert/web.qtpl:489
func WriteRuleDetails(qq422016 qtio422016.Writer, r *http.Request, rule APIRule) {
//line app/vmalert/web.qtpl:479
//line app/vmalert/web.qtpl:489
qw422016 := qt422016.AcquireWriter(qq422016)
//line app/vmalert/web.qtpl:479
//line app/vmalert/web.qtpl:489
StreamRuleDetails(qw422016, r, rule)
//line app/vmalert/web.qtpl:479
//line app/vmalert/web.qtpl:489
qt422016.ReleaseWriter(qw422016)
//line app/vmalert/web.qtpl:479
//line app/vmalert/web.qtpl:489
}
//line app/vmalert/web.qtpl:479
//line app/vmalert/web.qtpl:489
func RuleDetails(r *http.Request, rule APIRule) string {
//line app/vmalert/web.qtpl:479
//line app/vmalert/web.qtpl:489
qb422016 := qt422016.AcquireByteBuffer()
//line app/vmalert/web.qtpl:479
//line app/vmalert/web.qtpl:489
WriteRuleDetails(qb422016, r, rule)
//line app/vmalert/web.qtpl:479
//line app/vmalert/web.qtpl:489
qs422016 := string(qb422016.B)
//line app/vmalert/web.qtpl:479
//line app/vmalert/web.qtpl:489
qt422016.ReleaseByteBuffer(qb422016)
//line app/vmalert/web.qtpl:479
//line app/vmalert/web.qtpl:489
return qs422016
//line app/vmalert/web.qtpl:479
//line app/vmalert/web.qtpl:489
}
//line app/vmalert/web.qtpl:483
//line app/vmalert/web.qtpl:493
func streambadgeState(qw422016 *qt422016.Writer, state string) {
//line app/vmalert/web.qtpl:483
//line app/vmalert/web.qtpl:493
qw422016.N().S(`
`)
//line app/vmalert/web.qtpl:485
//line app/vmalert/web.qtpl:495
badgeClass := "bg-warning text-dark"
if state == "firing" {
badgeClass = "bg-danger"
}
//line app/vmalert/web.qtpl:489
//line app/vmalert/web.qtpl:499
qw422016.N().S(`
<span class="badge `)
//line app/vmalert/web.qtpl:490
//line app/vmalert/web.qtpl:500
qw422016.E().S(badgeClass)
//line app/vmalert/web.qtpl:490
//line app/vmalert/web.qtpl:500
qw422016.N().S(`">`)
//line app/vmalert/web.qtpl:490
//line app/vmalert/web.qtpl:500
qw422016.E().S(state)
//line app/vmalert/web.qtpl:490
//line app/vmalert/web.qtpl:500
qw422016.N().S(`</span>
`)
//line app/vmalert/web.qtpl:491
//line app/vmalert/web.qtpl:501
}
//line app/vmalert/web.qtpl:491
//line app/vmalert/web.qtpl:501
func writebadgeState(qq422016 qtio422016.Writer, state string) {
//line app/vmalert/web.qtpl:491
//line app/vmalert/web.qtpl:501
qw422016 := qt422016.AcquireWriter(qq422016)
//line app/vmalert/web.qtpl:491
//line app/vmalert/web.qtpl:501
streambadgeState(qw422016, state)
//line app/vmalert/web.qtpl:491
//line app/vmalert/web.qtpl:501
qt422016.ReleaseWriter(qw422016)
//line app/vmalert/web.qtpl:491
//line app/vmalert/web.qtpl:501
}
//line app/vmalert/web.qtpl:491
//line app/vmalert/web.qtpl:501
func badgeState(state string) string {
//line app/vmalert/web.qtpl:491
//line app/vmalert/web.qtpl:501
qb422016 := qt422016.AcquireByteBuffer()
//line app/vmalert/web.qtpl:491
//line app/vmalert/web.qtpl:501
writebadgeState(qb422016, state)
//line app/vmalert/web.qtpl:491
//line app/vmalert/web.qtpl:501
qs422016 := string(qb422016.B)
//line app/vmalert/web.qtpl:491
//line app/vmalert/web.qtpl:501
qt422016.ReleaseByteBuffer(qb422016)
//line app/vmalert/web.qtpl:491
//line app/vmalert/web.qtpl:501
return qs422016
//line app/vmalert/web.qtpl:491
//line app/vmalert/web.qtpl:501
}
//line app/vmalert/web.qtpl:493
//line app/vmalert/web.qtpl:503
func streambadgeRestored(qw422016 *qt422016.Writer) {
//line app/vmalert/web.qtpl:493
//line app/vmalert/web.qtpl:503
qw422016.N().S(`
<span class="badge bg-warning text-dark" title="Alert state was restored after the service restart from remote storage">restored</span>
`)
//line app/vmalert/web.qtpl:495
//line app/vmalert/web.qtpl:505
}
//line app/vmalert/web.qtpl:495
//line app/vmalert/web.qtpl:505
func writebadgeRestored(qq422016 qtio422016.Writer) {
//line app/vmalert/web.qtpl:495
//line app/vmalert/web.qtpl:505
qw422016 := qt422016.AcquireWriter(qq422016)
//line app/vmalert/web.qtpl:495
//line app/vmalert/web.qtpl:505
streambadgeRestored(qw422016)
//line app/vmalert/web.qtpl:495
//line app/vmalert/web.qtpl:505
qt422016.ReleaseWriter(qw422016)
//line app/vmalert/web.qtpl:495
//line app/vmalert/web.qtpl:505
}
//line app/vmalert/web.qtpl:495
//line app/vmalert/web.qtpl:505
func badgeRestored() string {
//line app/vmalert/web.qtpl:495
//line app/vmalert/web.qtpl:505
qb422016 := qt422016.AcquireByteBuffer()
//line app/vmalert/web.qtpl:495
//line app/vmalert/web.qtpl:505
writebadgeRestored(qb422016)
//line app/vmalert/web.qtpl:495
//line app/vmalert/web.qtpl:505
qs422016 := string(qb422016.B)
//line app/vmalert/web.qtpl:495
//line app/vmalert/web.qtpl:505
qt422016.ReleaseByteBuffer(qb422016)
//line app/vmalert/web.qtpl:495
//line app/vmalert/web.qtpl:505
return qs422016
//line app/vmalert/web.qtpl:495
//line app/vmalert/web.qtpl:505
}

View file

@ -113,18 +113,20 @@ type APIRule struct {
// Additional fields
// Type of the rule: recording or alerting
// DatasourceType of the rule: prometheus or graphite
DatasourceType string `json:"datasourceType"`
LastSamples int `json:"lastSamples"`
// ID is a unique Alert's ID within a group
ID string `json:"id"`
// GroupID is an unique Group's ID
GroupID string `json:"group_id"`
// Debug shows whether debug mode is enabled
Debug bool `json:"debug"`
// MaxUpdates is the max number of recorded ruleStateEntry objects
MaxUpdates int `json:"max_updates_entries"`
// Updates contains the ordered list of recorded ruleStateEntry objects
Updates []ruleStateEntry `json:"updates"`
Updates []ruleStateEntry `json:"-"`
}
// WebLink returns a link to the alert which can be used in UI.

View file

@ -1,14 +1,14 @@
{
"files": {
"main.css": "./static/css/main.3f9cb68f.css",
"main.js": "./static/js/main.b1572032.js",
"main.css": "./static/css/main.b9c2d13c.css",
"main.js": "./static/js/main.40a4969a.js",
"static/js/27.c1ccfd29.chunk.js": "./static/js/27.c1ccfd29.chunk.js",
"static/media/Lato-Regular.ttf": "./static/media/Lato-Regular.d714fec1633b69a9c2e9.ttf",
"static/media/Lato-Bold.ttf": "./static/media/Lato-Bold.32360ba4b57802daa4d6.ttf",
"index.html": "./index.html"
},
"entrypoints": [
"static/css/main.3f9cb68f.css",
"static/js/main.b1572032.js"
"static/css/main.b9c2d13c.css",
"static/js/main.40a4969a.js"
]
}

View file

@ -1 +1 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="icon" href="./favicon.ico"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><meta name="description" content="UI for VictoriaMetrics"/><link rel="apple-touch-icon" href="./apple-touch-icon.png"/><link rel="icon" type="image/png" sizes="32x32" href="./favicon-32x32.png"><link rel="manifest" href="./manifest.json"/><title>VM UI</title><script src="./dashboards/index.js" type="module"></script><meta name="twitter:card" content="summary_large_image"><meta name="twitter:image" content="./preview.jpg"><meta name="twitter:title" content="UI for VictoriaMetrics"><meta name="twitter:description" content="Explore and troubleshoot your VictoriaMetrics data"><meta name="twitter:site" content="@VictoriaMetrics"><meta property="og:title" content="Metric explorer for VictoriaMetrics"><meta property="og:description" content="Explore and troubleshoot your VictoriaMetrics data"><meta property="og:image" content="./preview.jpg"><meta property="og:type" content="website"><script defer="defer" src="./static/js/main.b1572032.js"></script><link href="./static/css/main.3f9cb68f.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>
<!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="icon" href="./favicon.ico"/><meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no"/><meta name="theme-color" content="#000000"/><meta name="description" content="UI for VictoriaMetrics"/><link rel="apple-touch-icon" href="./apple-touch-icon.png"/><link rel="icon" type="image/png" sizes="32x32" href="./favicon-32x32.png"><link rel="manifest" href="./manifest.json"/><title>VM UI</title><script src="./dashboards/index.js" type="module"></script><meta name="twitter:card" content="summary_large_image"><meta name="twitter:image" content="./preview.jpg"><meta name="twitter:title" content="UI for VictoriaMetrics"><meta name="twitter:description" content="Explore and troubleshoot your VictoriaMetrics data"><meta name="twitter:site" content="@VictoriaMetrics"><meta property="og:title" content="Metric explorer for VictoriaMetrics"><meta property="og:description" content="Explore and troubleshoot your VictoriaMetrics data"><meta property="og:image" content="./preview.jpg"><meta property="og:type" content="website"><script defer="defer" src="./static/js/main.40a4969a.js"></script><link href="./static/css/main.b9c2d13c.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>

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

@ -60,7 +60,7 @@ VMUI can be used to paste into other applications
| Name | Default | Description |
|:------------------------|:-----------:|--------------------------------------------------------------------------------------:|
| serverURL | domain name | Can't be changed from the UI |
| inputTenantID | - | If the flag is present, the "Tenant ID" field is displayed |
| useTenantID | - | If the flag is present, the "Tenant ID" select is displayed |
| headerStyles.background | `#FFFFFF` | Header background color |
| headerStyles.color | `#3F51B5` | Header font color |
| palette.primary | `#3F51B5` | used to represent primary interface elements for a user |
@ -74,7 +74,7 @@ VMUI can be used to paste into other applications
```json
{
"serverURL": "http://localhost:8428",
"inputTenantID": "true",
"useTenantID": true,
"headerStyles": {
"background": "#FFFFFF",
"color": "#538DE8"
@ -93,7 +93,7 @@ VMUI can be used to paste into other applications
#### HTML example:
```html
<div id="root" data-params='{"serverURL":"http://localhost:8428","inputTenantID":"true","headerStyles":{"background":"#FFFFFF","color":"#538DE8"},"palette":{"primary":"#538DE8","secondary":"#F76F8E","error":"#FD151B","warning":"#FFB30F","success":"#7BE622","info":"#0F5BFF"}}'></div>
<div id="root" data-params='{"serverURL":"http://localhost:8428","useTenantID":true,"headerStyles":{"background":"#FFFFFF","color":"#538DE8"},"palette":{"primary":"#538DE8","secondary":"#F76F8E","error":"#FD151B","warning":"#FFB30F","success":"#7BE622","info":"#0F5BFF"}}'></div>
```

View file

@ -3,7 +3,7 @@
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta name="theme-color" content="#000000" />
<meta
name="description"

View file

@ -1,5 +1,5 @@
import React, { FC, useEffect, useMemo, useRef, useState } from "preact/compat";
import uPlot, { Series } from "uplot";
import uPlot from "uplot";
import { MetricResult } from "../../../api/types";
import { formatPrettyNumber } from "../../../utils/uplot/helpers";
import dayjs from "dayjs";
@ -11,12 +11,13 @@ import { CloseIcon, DragIcon } from "../../Main/Icons";
import classNames from "classnames";
import { MouseEvent as ReactMouseEvent } from "react";
import "./style.scss";
import { SeriesItem } from "../../../utils/uplot/series";
export interface ChartTooltipProps {
id: string,
u: uPlot,
metrics: MetricResult[],
series: Series[],
series: SeriesItem[],
yRange: number[];
unit?: string,
isSticky?: boolean,
@ -55,15 +56,16 @@ const ChartTooltip: FC<ChartTooltipProps> = ({
const color = series[seriesIdx]?.stroke+"";
const calculations = series[seriesIdx]?.calculations || {};
const groups = new Set(metrics.map(m => m.group));
const showQueryNum = groups.size > 1;
const group = metrics[seriesIdx-1]?.group || 0;
const metric = metrics[seriesIdx-1]?.metric || {};
const labelNames = Object.keys(metric).filter(x => x != "__name__");
const metricName = metric["__name__"] || "value";
const fields = useMemo(() => {
const labelNames = Object.keys(metric).filter(x => x != "__name__");
return labelNames.map(key => `${key}=${JSON.stringify(metric[key])}`);
}, [metrics, seriesIdx]);
@ -100,10 +102,15 @@ const ChartTooltip: FC<ChartTooltipProps> = ({
const overflowX = leftOnChart + tooltipWidth >= width ? tooltipWidth + (2 * margin) : 0;
const overflowY = topOnChart + tooltipHeight >= height ? tooltipHeight + (2 * margin) : 0;
setPosition({
const position = {
top: topOnChart + tooltipOffset.top + margin - overflowY,
left: leftOnChart + tooltipOffset.left + margin - overflowX
});
};
if (position.left < 0) position.left = 20;
if (position.top < 0) position.top = 20;
setPosition(position);
};
useEffect(calcPosition, [u, value, dataTime, seriesIdx, tooltipOffset, tooltipRef]);
@ -169,19 +176,21 @@ const ChartTooltip: FC<ChartTooltipProps> = ({
className="vm-chart-tooltip-data__marker"
style={{ background: color }}
/>
<p>
{metricName}:
<b className="vm-chart-tooltip-data__value">{valueFormat}</b>
{unit}
</p>
</div>
{!!fields.length && (
<div className="vm-chart-tooltip-info">
{fields.map((f, i) => (
<div key={`${f}_${i}`}>{f}</div>
))}
<div>
curr:<b>{valueFormat}{unit}</b>, avg:<b>{calculations.avg}</b><br/>
min:<b>{calculations.min}</b>, max:<b>{calculations.max}</b>, last:<b>{calculations.last}</b>
</div>
)}
</div>
<div className="vm-chart-tooltip-info">
{metric["__name__"]}
&#123;
{fields.map((f, i) => (
<span key="{i}">
{f}{i +1 < fields.length && ","}
</span>
))}
&#125;
</div>
</div>
), targetPortal);
};

View file

@ -61,11 +61,6 @@ $chart-tooltip-y: -1 * ($padding-small + $chart-tooltip-half-icon);
word-break: break-all;
line-height: 12px;
&__value {
padding: 4px;
font-weight: bold;
}
&__marker {
width: 12px;
height: 12px;

View file

@ -14,6 +14,7 @@ interface LegendItemProps {
const LegendItem: FC<LegendItemProps> = ({ legend, onChange }) => {
const [copiedValue, setCopiedValue] = useState("");
const freeFormFields = useMemo(() => getFreeFields(legend), [legend]);
const calculations = legend.calculations;
const handleClickFreeField = async (val: string, id: string) => {
await navigator.clipboard.writeText(val);
@ -30,11 +31,11 @@ const LegendItem: FC<LegendItemProps> = ({ legend, onChange }) => {
handleClickFreeField(freeField, id);
};
return (
<div
className={classNames({
"vm-legend-item": true,
"vm-legend-row": true,
"vm-legend-item_hide": !legend.checked,
})}
onClick={createHandlerClick(legend)}
@ -45,30 +46,30 @@ const LegendItem: FC<LegendItemProps> = ({ legend, onChange }) => {
/>
<div className="vm-legend-item-info">
<span className="vm-legend-item-info__label">
{legend.freeFormFields["__name__"] || (freeFormFields.length == 0 ? "{}" : "")}
</span>
{freeFormFields.length > 0 &&
<span>
&#123;
{freeFormFields.map(f => (
<Tooltip
key={f.id}
open={copiedValue === f.id}
title={"Copied!"}
placement="top-center"
{legend.freeFormFields["__name__"]}
&#123;
{freeFormFields.map((f, i) => (
<Tooltip
key={f.id}
open={copiedValue === f.id}
title={"copied!"}
placement="top-center"
>
<span
className="vm-legend-item-info__free-fields"
key={f.key}
onClick={createHandlerCopy(f.freeField, f.id)}
title="copy to clipboard"
>
<span
className="vm-legend-item-info__free-fields"
key={f.key}
onClick={createHandlerCopy(f.freeField, f.id)}
>
{f.freeField}
</span>
</Tooltip>
))}
&#125;
</span>
}
{f.freeField}{i + 1 < freeFormFields.length && ","}
</span>
</Tooltip>
))}
&#125;
</span>
</div>
<div className="vm-legend-item-values">
avg:{calculations.avg}, min:{calculations.min}, max:{calculations.max}, last:{calculations.last}
</div>
</div>
);

View file

@ -6,10 +6,11 @@
grid-gap: $padding-small;
align-items: start;
justify-content: start;
padding: $padding-small $padding-large $padding-small $padding-small;
padding: $padding-small;
background-color: $color-background-block;
cursor: pointer;
transition: 0.2s ease;
margin-bottom: $padding-small;
&:hover {
background-color: rgba(0, 0, 0, 0.1);
@ -30,22 +31,26 @@
&-info {
font-weight: normal;
word-break: break-all;
&__label {
margin-right: 2px;
}
&__free-fields {
padding: 3px;
padding: 2px;
cursor: pointer;
&:hover {
text-decoration: underline;
}
&:not(:last-child):after {
content: ",";
}
}
}
&-values {
grid-column: 2;
display: flex;
align-items: center;
gap: $padding-small;
}
}

View file

@ -9,12 +9,13 @@
&-group {
min-width: 23%;
width: 100%;
margin: 0 $padding-global $padding-global 0;
&-title {
display: flex;
align-items: center;
padding: 0 $padding-small $padding-small;
padding: $padding-small;
margin-bottom: 1px;
border-bottom: $border-divider;

View file

@ -22,6 +22,7 @@ import classNames from "classnames";
import ChartTooltip, { ChartTooltipProps } from "../ChartTooltip/ChartTooltip";
import dayjs from "dayjs";
import { useAppState } from "../../../state/common/StateContext";
import { SeriesItem } from "../../../utils/uplot/series";
export interface LineChartProps {
metrics: MetricResult[];
@ -55,6 +56,7 @@ const LineChart: FC<LineChartProps> = ({
const [xRange, setXRange] = useState({ min: period.start, max: period.end });
const [yRange, setYRange] = useState([0, 1]);
const [uPlotInst, setUPlotInst] = useState<uPlot>();
const [startTouchDistance, setStartTouchDistance] = useState(0);
const layoutSize = useResize(container);
const [showTooltip, setShowTooltip] = useState(false);
@ -84,6 +86,7 @@ const LineChart: FC<LineChartProps> = ({
left: parseFloat(u.over.style.left),
top: parseFloat(u.over.style.top)
});
u.over.addEventListener("mousedown", e => {
const { ctrlKey, metaKey, button } = e;
const leftClick = button === 0;
@ -94,6 +97,10 @@ const LineChart: FC<LineChartProps> = ({
}
});
u.over.addEventListener("touchstart", e => {
dragChart({ u, e, setPanning, setPlotScale, factor });
});
u.over.addEventListener("wheel", e => {
if (!e.ctrlKey && !e.metaKey) return;
e.preventDefault();
@ -235,6 +242,47 @@ const LineChart: FC<LineChartProps> = ({
};
}, [xRange]);
const handleTouchStart = (e: TouchEvent) => {
if (e.touches.length !== 2) return;
e.preventDefault();
const dx = e.touches[0].clientX - e.touches[1].clientX;
const dy = e.touches[0].clientY - e.touches[1].clientY;
setStartTouchDistance(Math.sqrt(dx * dx + dy * dy));
};
const handleTouchMove = (e: TouchEvent) => {
if (e.touches.length !== 2 || !uPlotInst) return;
e.preventDefault();
const dx = e.touches[0].clientX - e.touches[1].clientX;
const dy = e.touches[0].clientY - e.touches[1].clientY;
const endTouchDistance = Math.sqrt(dx * dx + dy * dy);
const diffDistance = startTouchDistance - endTouchDistance;
const max = (uPlotInst.scales.x.max || xRange.max);
const min = (uPlotInst.scales.x.min || xRange.min);
const dur = max - min;
const dir = (diffDistance > 0 ? -1 : 1);
const zoomFactor = dur / 50 * dir;
uPlotInst.batch(() => setPlotScale({
u: uPlotInst,
min: min + zoomFactor,
max: max - zoomFactor
}));
};
useEffect(() => {
window.addEventListener("touchmove", handleTouchMove);
window.addEventListener("touchstart", handleTouchStart);
return () => {
window.removeEventListener("touchmove", handleTouchMove);
window.removeEventListener("touchstart", handleTouchStart);
};
}, [uPlotInst, startTouchDistance]);
useEffect(() => updateChart(typeChartUpdate.data), [data]);
useEffect(() => updateChart(typeChartUpdate.xRange), [xRange]);
useEffect(() => updateChart(typeChartUpdate.yRange), [yaxis]);
@ -256,6 +304,10 @@ const LineChart: FC<LineChartProps> = ({
"vm-line-chart": true,
"vm-line-chart_panning": isPanning
})}
style={{
minWidth: `${layoutSize.width || 400}px`,
minHeight: `${height || 500}px`
}}
>
<div
className="vm-line-chart__u-plot"
@ -265,7 +317,7 @@ const LineChart: FC<LineChartProps> = ({
<ChartTooltip
unit={unit}
u={uPlotInst}
series={series}
series={series as SeriesItem[]}
metrics={metrics}
yRange={yRange}
tooltipIdx={tooltipIdx}

View file

@ -14,10 +14,12 @@ import classNames from "classnames";
import Timezones from "./Timezones/Timezones";
import { useTimeDispatch, useTimeState } from "../../../state/time/TimeStateContext";
import ThemeControl from "../ThemeControl/ThemeControl";
import useDeviceDetect from "../../../hooks/useDeviceDetect";
const title = "Settings";
const GlobalSettings: FC = () => {
const GlobalSettings: FC<{showTitle?: boolean}> = ({ showTitle }) => {
const { isMobile } = useDeviceDetect();
const appModeEnable = getAppModeEnable();
const { serverUrl: stateServerUrl } = useAppState();
@ -49,7 +51,10 @@ const GlobalSettings: FC = () => {
}, [stateServerUrl]);
return <>
<Tooltip title={title}>
<Tooltip
open={showTitle === true ? false : undefined}
title={title}
>
<Button
className={classNames({
"vm-header-button": !appModeEnable
@ -58,14 +63,21 @@ const GlobalSettings: FC = () => {
color="primary"
startIcon={<SettingsIcon/>}
onClick={handleOpen}
/>
>
{showTitle && title}
</Button>
</Tooltip>
{open && (
<Modal
title={title}
onClose={handleClose}
>
<div className="vm-server-configurator">
<div
className={classNames({
"vm-server-configurator": true,
"vm-server-configurator_mobile": isMobile
})}
>
{!appModeEnable && (
<div className="vm-server-configurator__input">
<ServerConfigurator
@ -88,9 +100,11 @@ const GlobalSettings: FC = () => {
onChange={setTimezone}
/>
</div>
<div className="vm-server-configurator__input">
<ThemeControl/>
</div>
{!appModeEnable && (
<div className="vm-server-configurator__input">
<ThemeControl/>
</div>
)}
<div className="vm-server-configurator__footer">
<Button
variant="outlined"

View file

@ -70,15 +70,16 @@ const LimitsConfigurator: FC<ServerConfiguratorProps> = ({ limits, onChange , on
</div>
<div className="vm-limits-configurator__inputs">
{fields.map(f => (
<TextField
key={f.type}
label={f.label}
value={limits[f.type]}
error={error[f.type]}
onChange={createChangeHandler(f.type)}
onEnter={onEnter}
type="number"
/>
<div key={f.type}>
<TextField
label={f.label}
value={limits[f.type]}
error={error[f.type]}
onChange={createChangeHandler(f.type)}
onEnter={onEnter}
type="number"
/>
</div>
))}
</div>
</div>

View file

@ -12,10 +12,14 @@
}
&__inputs {
display: grid;
grid-template-columns: repeat(3, 1fr);
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: $padding-global;
div {
flex-grow: 1;
}
}
}

View file

@ -1,7 +1,7 @@
import React, { FC, useState, useRef, useEffect, useMemo } from "preact/compat";
import { useAppDispatch, useAppState } from "../../../../state/common/StateContext";
import { useTimeDispatch } from "../../../../state/time/TimeStateContext";
import { ArrowDownIcon, StorageIcons } from "../../../Main/Icons";
import { ArrowDownIcon, StorageIcon } from "../../../Main/Icons";
import Button from "../../../Main/Button/Button";
import "./style.scss";
import { replaceTenantId } from "../../../../utils/default-server-url";
@ -9,17 +9,32 @@ import classNames from "classnames";
import Popper from "../../../Main/Popper/Popper";
import { getAppModeEnable } from "../../../../utils/app-mode";
import Tooltip from "../../../Main/Tooltip/Tooltip";
import useDeviceDetect from "../../../../hooks/useDeviceDetect";
import TextField from "../../../Main/TextField/TextField";
const TenantsConfiguration: FC<{accountIds: string[]}> = ({ accountIds }) => {
const appModeEnable = getAppModeEnable();
const { isMobile } = useDeviceDetect();
const { tenantId: tenantIdState, serverUrl } = useAppState();
const dispatch = useAppDispatch();
const timeDispatch = useTimeDispatch();
const [search, setSearch] = useState("");
const [openOptions, setOpenOptions] = useState(false);
const optionsButtonRef = useRef<HTMLDivElement>(null);
const accountIdsFiltered = useMemo(() => {
if (!search) return accountIds;
try {
const regexp = new RegExp(search, "i");
const found = accountIds.filter((item) => regexp.test(item));
return found.sort((a,b) => (a.match(regexp)?.index || 0) - (b.match(regexp)?.index || 0));
} catch (e) {
return [];
}
}, [search, accountIds]);
const getTenantIdFromUrl = (url: string) => {
const regexp = /(\/select\/)(\d+|\d.+)(\/)(.+)/;
return (url.match(regexp) || [])[2];
@ -71,8 +86,8 @@ const TenantsConfiguration: FC<{accountIds: string[]}> = ({ accountIds }) => {
variant="contained"
color="primary"
fullWidth
startIcon={<StorageIcons/>}
endIcon={(
startIcon={<StorageIcon/>}
endIcon={!isMobile ? (
<div
className={classNames({
"vm-execution-controls-buttons__arrow": true,
@ -81,22 +96,29 @@ const TenantsConfiguration: FC<{accountIds: string[]}> = ({ accountIds }) => {
>
<ArrowDownIcon/>
</div>
)}
) : undefined}
onClick={toggleOpenOptions}
>
{tenantIdState}
{!isMobile && tenantIdState}
</Button>
</div>
</Tooltip>
<Popper
open={openOptions}
placement="bottom-left"
placement="bottom-right"
onClose={handleCloseOptions}
buttonRef={optionsButtonRef}
fullWidth
>
<div className="vm-list">
{accountIds.map(id => (
<div className="vm-list vm-tenant-input-list">
<div className="vm-tenant-input-list__search">
<TextField
autofocus
label="Search"
value={search}
onChange={setSearch}
/>
</div>
{accountIdsFiltered.map(id => (
<div
className={classNames({
"vm-list-item": true,

View file

@ -2,8 +2,10 @@ import { useAppState } from "../../../../../state/common/StateContext";
import { useEffect, useMemo, useState } from "preact/compat";
import { ErrorTypes } from "../../../../../types";
import { getAccountIds } from "../../../../../api/accountId";
import { getAppModeParams } from "../../../../../utils/app-mode";
export const useFetchAccountIds = () => {
const { useTenantID } = getAppModeParams();
const { serverUrl } = useAppState();
const [isLoading, setIsLoading] = useState(false);
@ -13,13 +15,14 @@ export const useFetchAccountIds = () => {
const fetchUrl = useMemo(() => getAccountIds(serverUrl), [serverUrl]);
useEffect(() => {
if (!useTenantID) return;
const fetchData = async () => {
setIsLoading(true);
try {
const response = await fetch(fetchUrl);
const resp = await response.json();
const data = (resp.data || []) as string[];
setAccountIds(data);
setAccountIds(data.sort((a, b) => a.localeCompare(b)));
if (response.ok) {
setError(undefined);

View file

@ -2,4 +2,18 @@
.vm-tenant-input {
position: relative;
&-list {
max-height: 300px;
overflow: auto;
overscroll-behavior: none;
border-radius: $border-radius-medium;
&__search {
position: sticky;
top: 0;
padding: $padding-small;
background-color: $color-background-block;
}
}
}

View file

@ -91,6 +91,7 @@ const Timezones: FC<TimezonesProps> = ({ timezoneState, onChange }) => {
buttonRef={targetRef}
placement="bottom-left"
onClose={handleCloseList}
fullWidth
>
<div className="vm-timezones-list">
<div className="vm-timezones-list-header">

View file

@ -46,7 +46,6 @@
}
&-list {
min-width: 600px;
max-height: 200px;
background-color: $color-background-block;
border-radius: $border-radius-medium;

View file

@ -1,12 +1,25 @@
@use "src/styles/variables" as *;
.vm-server-configurator {
display: grid;
display: flex;
flex-direction: column;
align-items: center;
gap: $padding-medium;
width: 600px;
&_mobile {
grid-auto-rows: min-content;
align-items: flex-start;
height: 100%;
width: 100%;
}
@media (max-width: 768px) {
width: 100%;
}
&__input {
width: 100%;
&_server {
display: grid;
@ -34,4 +47,10 @@
margin-left: auto;
margin-right: 0;
}
&_mobile &__footer {
align-items: flex-end;
flex-grow: 1;
width: 100%;
}
}

View file

@ -111,7 +111,12 @@ const StepConfigurator: FC = () => {
startIcon={<TimelineIcon/>}
onClick={toggleOpenOptions}
>
STEP {customStep}
<p>
STEP
<p className="vm-step-control__value">
{customStep}
</p>
</p>
</Button>
</Tooltip>
<Popper

View file

@ -8,6 +8,15 @@
text-transform: none;
}
&__value {
display: inline;
margin-left: 3px;
@media (max-width: 500px) {
display: none;
}
}
&-popper {
display: grid;
gap: $padding-small;

View file

@ -3,9 +3,12 @@ import "./style.scss";
import { useAppDispatch, useAppState } from "../../../state/common/StateContext";
import { Theme } from "../../../types";
import Toggle from "../../Main/Toggle/Toggle";
import useDeviceDetect from "../../../hooks/useDeviceDetect";
import classNames from "classnames";
const options = Object.values(Theme).map(value => ({ title: value, value }));
const ThemeControl = () => {
const { isMobile } = useDeviceDetect();
const { theme } = useAppState();
const dispatch = useAppDispatch();
@ -14,11 +17,19 @@ const ThemeControl = () => {
};
return (
<div className="vm-theme-control">
<div
className={classNames({
"vm-theme-control": true,
"vm-theme-control_mobile": isMobile
})}
>
<div className="vm-server-configurator__title">
Theme preferences
</div>
<div className="vm-theme-control__toggle">
<div
className="vm-theme-control__toggle"
key={`${isMobile}`}
>
<Toggle
options={options}
value={theme}

View file

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

View file

@ -7,6 +7,7 @@ import Popper from "../../../Main/Popper/Popper";
import "./style.scss";
import classNames from "classnames";
import Tooltip from "../../../Main/Tooltip/Tooltip";
import useResize from "../../../../hooks/useResize";
interface AutoRefreshOption {
seconds: number
@ -29,6 +30,7 @@ const delayOptions: AutoRefreshOption[] = [
];
export const ExecutionControls: FC = () => {
const windowSize = useResize(document.body);
const dispatch = useTimeDispatch();
const appModeEnable = getAppModeEnable();
@ -83,17 +85,20 @@ export const ExecutionControls: FC = () => {
<div
className={classNames({
"vm-execution-controls-buttons": true,
"vm-header-button": !appModeEnable
"vm-header-button": !appModeEnable,
"vm-execution-controls-buttons_short": windowSize.width <= 360
})}
>
<Tooltip title="Refresh dashboard">
<Button
variant="contained"
color="primary"
onClick={handleUpdate}
startIcon={<RefreshIcon/>}
/>
</Tooltip>
{windowSize.width > 360 && (
<Tooltip title="Refresh dashboard">
<Button
variant="contained"
color="primary"
onClick={handleUpdate}
startIcon={<RefreshIcon/>}
/>
</Tooltip>
)}
<Tooltip title="Auto-refresh control">
<div ref={optionsButtonRef}>
<Button

View file

@ -9,6 +9,10 @@
border-radius: calc($button-radius + 1px);
min-width: 107px;
&_short {
min-width: auto;
}
&__arrow {
display: flex;
align-items: center;

View file

@ -5,6 +5,11 @@
grid-template-columns: repeat(2, 230px);
padding: $padding-global 0;
@media (max-width: 500px) {
grid-template-columns: 1fr;
min-width: 250px;
}
&-left {
display: flex;
flex-direction: column;
@ -12,6 +17,12 @@
border-right: $border-divider;
padding: 0 $padding-global;
@media (max-width: 500px) {
border-right: none;
border-bottom: $border-divider;
padding-bottom: $padding-global;
}
&-inputs {
flex-grow: 1;
display: grid;

View file

@ -5,16 +5,18 @@
flex-wrap: wrap;
align-items: center;
justify-content: flex-start;
gap: $padding-small calc($padding-small + 10px);
gap: $padding-global calc($padding-small + 10px);
&__job {
flex-grow: 0.5;
min-width: 200px;
flex-grow: 1;
}
&__instance {
flex-grow: 2;
}
&__size {
flex-grow: 1;
min-width: 300px;
}
&-metrics {

View file

@ -2,6 +2,7 @@
.vm-footer {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
padding: $padding-medium;
@ -21,6 +22,11 @@
&__website {
margin-right: $padding-global;
@media (max-width: 768px) {
margin-right: 0;
width: 100%;
}
}
&__link {
@ -30,5 +36,10 @@
&__copyright {
text-align: right;
flex-grow: 1;
@media (max-width: 768px) {
width: 100%;
text-align: center;
}
}
}

View file

@ -17,8 +17,13 @@ import { useAppState } from "../../../state/common/StateContext";
import HeaderNav from "./HeaderNav/HeaderNav";
import TenantsConfiguration from "../../Configurators/GlobalSettings/TenantsConfiguration/TenantsConfiguration";
import { useFetchAccountIds } from "../../Configurators/GlobalSettings/TenantsConfiguration/hooks/useFetchAccountIds";
import useResize from "../../../hooks/useResize";
import SidebarHeader from "./SidebarNav/SidebarHeader";
const Header: FC = () => {
const windowSize = useResize(document.body);
const displaySidebar = useMemo(() => window.innerWidth < 1000, [windowSize]);
const { isDarkTheme } = useAppState();
const appModeEnable = getAppModeEnable();
const { accountIds } = useFetchAccountIds();
@ -58,27 +63,37 @@ const Header: FC = () => {
})}
style={{ background, color }}
>
{!appModeEnable && (
<div
className="vm-header-logo"
onClick={onClickLogo}
style={{ color }}
>
<LogoFullIcon/>
</div>
{displaySidebar ? (
<SidebarHeader
background={background}
color={color}
onClickLogo={onClickLogo}
/>
) : (
<>
{!appModeEnable && (
<div
className="vm-header-logo"
onClick={onClickLogo}
style={{ color }}
>
<LogoFullIcon/>
</div>
)}
<HeaderNav
color={color}
background={background}
/>
</>
)}
<HeaderNav
color={color}
background={background}
/>
<div className="vm-header__settings">
{headerSetup?.tenant && <TenantsConfiguration accountIds={accountIds}/>}
{headerSetup?.stepControl && <StepConfigurator/>}
{headerSetup?.timeSelector && <TimeSelector/>}
{headerSetup?.cardinalityDatePicker && <CardinalityDatePicker/>}
{headerSetup?.executionControls && <ExecutionControls/>}
<GlobalSettings/>
<ShortcutKeys/>
{!displaySidebar && <GlobalSettings/>}
{!displaySidebar && <ShortcutKeys/>}
</div>
</header>;
};

View file

@ -7,13 +7,15 @@ import { useEffect } from "react";
import "./style.scss";
import NavItem from "./NavItem";
import NavSubItem from "./NavSubItem";
import classNames from "classnames";
interface HeaderNavProps {
color: string
background: string
direction?: "row" | "column"
}
const HeaderNav: FC<HeaderNavProps> = ({ color, background }) => {
const HeaderNav: FC<HeaderNavProps> = ({ color, background, direction }) => {
const appModeEnable = getAppModeEnable();
const { dashboardsSettings } = useDashboardsState();
const { pathname } = useLocation();
@ -59,7 +61,12 @@ const HeaderNav: FC<HeaderNavProps> = ({ color, background }) => {
return (
<nav className="vm-header-nav">
<nav
className={classNames({
"vm-header-nav": true,
[`vm-header-nav_${direction}`]: direction
})}
>
{menu.map(m => (
m.submenu
? (
@ -70,6 +77,7 @@ const HeaderNav: FC<HeaderNavProps> = ({ color, background }) => {
submenu={m.submenu}
color={color}
background={background}
direction={direction}
/>
)
: (

View file

@ -12,6 +12,7 @@ interface NavItemProps {
submenu: {label: string | undefined, value: string}[],
color?: string
background?: string
direction?: "row" | "column"
}
const NavSubItem: FC<NavItemProps> = ({
@ -19,7 +20,8 @@ const NavSubItem: FC<NavItemProps> = ({
label,
color,
background,
submenu
submenu,
direction
}) => {
const { pathname } = useLocation();
@ -50,6 +52,21 @@ const NavSubItem: FC<NavItemProps> = ({
handleCloseSubmenu();
}, [pathname]);
if (direction === "column") {
return (
<>
{submenu.map(sm => (
<NavItem
key={sm.value}
activeMenu={activeMenu}
value={sm.value}
label={sm.label || ""}
/>
))}
</>
);
}
return (
<div
className={classNames({
@ -85,6 +102,7 @@ const NavSubItem: FC<NavItemProps> = ({
activeMenu={activeMenu}
value={sm.value}
label={sm.label || ""}
color={color}
/>
))}
</div>

View file

@ -8,6 +8,20 @@
font-size: $font-size-small;
font-weight: bold;
&_column {
flex-direction: column;
align-items: stretch;
gap: $padding-small;
}
&_column &-item {
padding: $padding-global 0;
&_sub {
justify-content: stretch;
}
}
&-item {
position: relative;
padding: $padding-global $padding-small;

View file

@ -0,0 +1,85 @@
import React, { FC, useEffect, useRef, useState } from "preact/compat";
import GlobalSettings from "../../../Configurators/GlobalSettings/GlobalSettings";
import { useLocation } from "react-router-dom";
import ShortcutKeys from "../../../Main/ShortcutKeys/ShortcutKeys";
import { LogoFullIcon } from "../../../Main/Icons";
import classNames from "classnames";
import HeaderNav from "../HeaderNav/HeaderNav";
import useClickOutside from "../../../../hooks/useClickOutside";
import MenuBurger from "../../../Main/MenuBurger/MenuBurger";
import useDeviceDetect from "../../../../hooks/useDeviceDetect";
import "./style.scss";
interface SidebarHeaderProps {
background: string
color: string
onClickLogo: () => void
}
const SidebarHeader: FC<SidebarHeaderProps> = ({
background,
color,
onClickLogo,
}) => {
const { pathname } = useLocation();
const { isMobile } = useDeviceDetect();
const sidebarRef = useRef<HTMLDivElement>(null);
const [openMenu, setOpenMenu] = useState(false);
const handleToggleMenu = () => {
setOpenMenu(prev => !prev);
};
const handleCloseMenu = () => {
setOpenMenu(false);
};
useEffect(handleCloseMenu, [pathname]);
useClickOutside(sidebarRef, handleCloseMenu);
return <div
className="vm-header-sidebar"
ref={sidebarRef}
>
<div
className={classNames({
"vm-header-sidebar-button": true,
"vm-header-sidebar-button_open": openMenu
})}
>
<MenuBurger
open={openMenu}
onClick={handleToggleMenu}
/>
</div>
<div
className={classNames({
"vm-header-sidebar-menu": true,
"vm-header-sidebar-menu_open": openMenu
})}
>
<div
className="vm-header-sidebar-menu__logo"
onClick={onClickLogo}
style={{ color }}
>
<LogoFullIcon/>
</div>
<div>
<HeaderNav
color={color}
background={background}
direction="column"
/>
</div>
<div className="vm-header-sidebar-menu-settings">
<GlobalSettings showTitle={true}/>
{!isMobile && <ShortcutKeys showTitle={true}/>}
</div>
</div>
</div>;
};
export default SidebarHeader;

View file

@ -0,0 +1,58 @@
@use "src/styles/variables" as *;
.vm-header-sidebar {
width: 24px;
height: 24px;
color: inherit;
background-color: inherit;
&-button {
position: absolute;
left: $padding-global;
top: $padding-global;
transition: left 300ms cubic-bezier(0.280, 0.840, 0.420, 1);
&_open {
position: fixed;
left: calc(182px - $padding-global);
z-index: 102;
}
}
&-menu {
position: fixed;
top: 0;
left: 0;
display: grid;
gap: $padding-global;
padding: $padding-global;
grid-template-rows: auto 1fr auto;
width: 200px;
height: 100%;
background-color: inherit;
z-index: 101;
transform-origin: left;
transform: translateX(-100%);
transition: transform 300ms cubic-bezier(0.280, 0.840, 0.420, 1);
box-shadow: $box-shadow-popper;
&_open {
transform: translateX(0);
}
&__logo {
position: relative;
display: flex;
align-items: center;
justify-content: flex-start;
cursor: pointer;
width: 65px;
}
&-settings {
display: grid;
align-items: center;
gap: $padding-small;
}
}
}

View file

@ -7,12 +7,20 @@
justify-content: flex-start;
padding: $padding-small $padding-medium;
gap: 0 $padding-large;
min-height: 51px;
z-index: 99;
&_app {
padding: $padding-small 0;
}
@media (max-width: 1000px) {
position: sticky;
top: 0;
gap: $padding-small;
padding: $padding-small;
}
&_dark {
.vm-header-button,
button:before,

View file

@ -3,7 +3,7 @@
.vm-container {
display: flex;
flex-direction: column;
min-height: calc(100vh - var(--scrollbar-height));
min-height: calc(($vh * 100) - var(--scrollbar-height));
&-body {
flex-grow: 1;
@ -11,6 +11,10 @@
padding: $padding-medium;
background-color: $color-background-body;
@media (max-width: 768px) {
padding: 0;
}
&_app {
padding: $padding-small 0;
background-color: transparent;

View file

@ -390,7 +390,7 @@ export const QuestionIcon = () => (
);
export const StorageIcons = () => (
export const StorageIcon = () => (
<svg
viewBox="0 0 24 24"
fill="currentColor"
@ -400,3 +400,14 @@ export const StorageIcons = () => (
></path>
</svg>
);
export const MenuIcon = () => (
<svg
viewBox="0 0 24 24"
fill="currentColor"
>
<path
d="M4 18h16c.55 0 1-.45 1-1s-.45-1-1-1H4c-.55 0-1 .45-1 1s.45 1 1 1zm0-5h16c.55 0 1-.45 1-1s-.45-1-1-1H4c-.55 0-1 .45-1 1s.45 1 1 1zM3 7c0 .55.45 1 1 1h16c.55 0 1-.45 1-1s-.45-1-1-1H4c-.55 0-1 .45-1 1z"
></path>
</svg>
);

View file

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

View file

@ -0,0 +1,133 @@
@use "src/styles/variables" as *;
$width-line: 2px;
.vm-menu-burger {
position: relative;
border: none;
background: none;
width: 18px;
height: 18px;
padding: 0;
outline: none;
cursor: pointer;
transform-style: preserve-3d;
&:after {
content: '';
position: absolute;
left: -6px;
top: -6px;
width: calc(100% + 12px);
height: calc(100% + 12px);
background-color: rgba($color-black, 0.1);
border-radius: 50%;
transform: scale(0) translateZ(-2px);
transition: transform 140ms ease-in-out;
}
&:hover {
&:after {
transform: scale(1) translateZ(-2px);
}
}
span {
display: block;
top: 50%;
transform: translateY(-50%);
border-top: $width-line solid #fff;
transition: transform 0.3s ease, border-color 0.3s ease;
&,
&:before,
&:after {
position: absolute;
left: 0;
width: 100%;
height: $width-line;
border-radius: 6px;
}
&:before,
&:after {
content: '';
top: 0;
background: $color-white;
animation-duration: 0.6s;
animation-timing-function: cubic-bezier(0.645, 0.045, 0.355, 1);
animation-fill-mode: forwards;
}
&:before {
animation-name: topLineBurger;
}
&:after {
animation-name: bottomLineBurger;
}
}
&_opened span {
border-color: transparent;
}
&_opened span:before {
animation-name: topLineCross;
}
&_opened span:after {
animation-name: bottomLineCross;
}
}
@keyframes topLineCross {
0% {
transform: translateY(-7px);
}
50% {
transform: translateY(0px);
}
100% {
width: 60%;
transform: translateY(-2px) translateX(30%) rotate(45deg);
}
}
@keyframes bottomLineCross {
0% {
transform: translateY(3px);
}
50% {
transform: translateY(0px);
}
100% {
width: 60%;
transform: translateY(-2px) translateX(30%) rotate(-45deg);
}
}
@keyframes topLineBurger {
0% {
transform: translateY(0px) rotate(45deg);
}
50% {
transform: rotate(0deg);
}
100% {
transform: translateY(-7px) rotate(0deg);
}
}
@keyframes bottomLineBurger {
0% {
transform: translateY(0px) rotate(-45deg);
}
50% {
transform: rotate(0deg);
}
100% {
transform: translateY(3px) rotate(0deg);
}
}

View file

@ -4,6 +4,8 @@ import { CloseIcon } from "../Icons";
import Button from "../Button/Button";
import { ReactNode, MouseEvent } from "react";
import "./style.scss";
import useDeviceDetect from "../../../hooks/useDeviceDetect";
import classNames from "classnames";
interface ModalProps {
title?: string
@ -12,6 +14,7 @@ interface ModalProps {
}
const Modal: FC<ModalProps> = ({ title, children, onClose }) => {
const { isMobile } = useDeviceDetect();
const handleKeyUp = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
@ -22,16 +25,21 @@ const Modal: FC<ModalProps> = ({ title, children, onClose }) => {
};
useEffect(() => {
document.body.style.overflow = "hidden";
window.addEventListener("keyup", handleKeyUp);
return () => {
document.body.style.overflow = "auto";
window.removeEventListener("keyup", handleKeyUp);
};
}, []);
return ReactDOM.createPortal((
<div
className="vm-modal"
className={classNames({
"vm-modal": true,
"vm-modal_mobile": isMobile
})}
onMouseDown={onClose}
>
<div className="vm-modal-content">

View file

@ -14,12 +14,28 @@ $padding-modal: 22px;
justify-content: center;
background: rgba($color-black, 0.55);
&_mobile &-content {
min-height: calc($vh * 100);
max-height: calc($vh * 100);
width: 100vw;
border-radius: 0;
&-body {
display: grid;
align-items: flex-start;
min-height: 100%;
}
}
&-content {
display: grid;
grid-template-rows: auto 1fr;
align-items: flex-start;
padding: $padding-modal;
background: $color-background-block;
box-shadow: 0 0 24px rgba($color-black, 0.07);
border-radius: $border-radius-small;
max-height: 90vh;
max-height: calc($vh * 90);
overflow: auto;
&-header {
@ -44,6 +60,8 @@ $padding-modal: 22px;
cursor: pointer;
}
}
&-body {}
}
}

View file

@ -99,12 +99,20 @@ const Popper: FC<PopperProps> = ({
if (isOverflowLeft) position.left = buttonPos.left + offsetLeft;
if (fullWidth) position.width = `${buttonPos.width}px`;
if (position.top < 0) position.top = 20;
return position;
},[buttonRef, placement, isOpen, children, fullWidth]);
if (clickOutside) useClickOutside(popperRef, () => setIsOpen(false), buttonRef);
useEffect(() => {
if (!popperRef.current || !isOpen) return;
const { right, width } = popperRef.current.getBoundingClientRect();
if (right > window.innerWidth) popperRef.current.style.left = `${window.innerWidth - 20 -width}px`;
}, [isOpen, popperRef]);
const popperClasses = classNames({
"vm-popper": true,
"vm-popper_open": isOpen,

View file

@ -1,5 +1,5 @@
import React, { FC, useState } from "preact/compat";
import { isMacOs } from "../../../utils/detect-os";
import { isMacOs } from "../../../utils/detect-device";
import { getAppModeEnable } from "../../../utils/app-mode";
import Button from "../Button/Button";
import { KeyboardIcon } from "../Icons";
@ -69,7 +69,9 @@ const keyList = [
}
];
const ShortcutKeys: FC = () => {
const title = "Shortcut keys";
const ShortcutKeys: FC<{showTitle?: boolean}> = ({ showTitle }) => {
const [openList, setOpenList] = useState(false);
const appModeEnable = getAppModeEnable();
@ -83,7 +85,8 @@ const ShortcutKeys: FC = () => {
return <>
<Tooltip
title="Shortcut keys"
open={showTitle === true ? false : undefined}
title={title}
placement="bottom-center"
>
<Button
@ -92,7 +95,9 @@ const ShortcutKeys: FC = () => {
color="primary"
startIcon={<KeyboardIcon/>}
onClick={handleOpen}
/>
>
{showTitle && title}
</Button>
</Tooltip>
{openList && (

View file

@ -3,6 +3,10 @@
.vm-shortcuts {
min-width: 400px;
@media (max-width: 500px) {
min-width: 100%;
}
&-section {
margin-bottom: $padding-medium;
@ -17,12 +21,20 @@
display: grid;
gap: $padding-global;
@media (max-width: 500px) {
gap: $padding-medium;
}
&-item {
display: grid;
grid-template-columns: 210px 1fr;
align-items: center;
gap: $padding-small;
@media (max-width: 500px) {
grid-template-columns: 1fr;
}
&__key {
display: flex;
align-items: center;

View file

@ -1,7 +1,7 @@
import { FC, useEffect, useState } from "preact/compat";
import { getContrastColor } from "../../../utils/color";
import { getCssVariable, isSystemDark, setCssVariable } from "../../../utils/theme";
import { AppParams, getAppModeParams } from "../../../utils/app-mode";
import { AppParams, getAppModeEnable, getAppModeParams } from "../../../utils/app-mode";
import { getFromStorage } from "../../../utils/storage";
import { darkPalette, lightPalette } from "../../../constants/palette";
import { Theme } from "../../../types";
@ -23,6 +23,7 @@ const colorVariables = [
export const ThemeProvider: FC<ThemeProviderProps> = ({ onLoaded }) => {
const appModeEnable = getAppModeEnable();
const { palette: paletteAppMode = {} } = getAppModeParams();
const { theme } = useAppState();
const isDarkTheme = useSystemTheme();
@ -39,6 +40,7 @@ export const ThemeProvider: FC<ThemeProviderProps> = ({ onLoaded }) => {
const { clientWidth, clientHeight } = document.documentElement;
setCssVariable("scrollbar-width", `${innerWidth - clientWidth}px`);
setCssVariable("scrollbar-height", `${innerHeight - clientHeight}px`);
setCssVariable("vh", `${innerHeight * 0.01}px`);
};
const setContrastText = () => {
@ -70,6 +72,8 @@ export const ThemeProvider: FC<ThemeProviderProps> = ({ onLoaded }) => {
setCssVariable(variable, value);
});
setContrastText();
if (appModeEnable) setAppModePalette();
};
const updatePalette = () => {
@ -85,13 +89,18 @@ export const ThemeProvider: FC<ThemeProviderProps> = ({ onLoaded }) => {
};
useEffect(() => {
setAppModePalette();
setScrollbarSize();
setTheme();
}, [palette]);
useEffect(updatePalette, [theme, isDarkTheme]);
useEffect(() => {
if (appModeEnable) {
dispatch({ type: "SET_THEME", payload: Theme.light });
}
}, []);
return null;
};

View file

@ -3,6 +3,7 @@ import ReactDOM from "react-dom";
import "./style.scss";
import { ReactNode } from "react";
import { ExoticComponent } from "react";
import useDeviceDetect from "../../../hooks/useDeviceDetect";
interface TooltipProps {
children: ReactNode
@ -19,6 +20,7 @@ const Tooltip: FC<TooltipProps> = ({
placement = "bottom-center",
offset = { top: 6, left: 0 }
}) => {
const { isMobile } = useDeviceDetect();
const [isOpen, setIsOpen] = useState(false);
const [popperSize, setPopperSize] = useState({ width: 0, height: 0 });
@ -121,7 +123,7 @@ const Tooltip: FC<TooltipProps> = ({
{children}
</Fragment>
{isOpen && ReactDOM.createPortal((
{!isMobile && isOpen && ReactDOM.createPortal((
<div
className="vm-tooltip"
ref={popperRef}

View file

@ -12,6 +12,7 @@ import { getAvgFromArray, getMaxFromArray, getMinFromArray } from "../../../util
import classNames from "classnames";
import { useTimeState } from "../../../state/time/TimeStateContext";
import "./style.scss";
import { promValueToNumber } from "../../../utils/metric";
export interface GraphViewProps {
data?: MetricResult[];
@ -28,21 +29,6 @@ export interface GraphViewProps {
height?: number
}
const promValueToNumber = (s: string): number => {
// See https://prometheus.io/docs/prometheus/latest/querying/api/#expression-query-result-formats
switch (s) {
case "NaN":
return NaN;
case "Inf":
case "+Inf":
return Infinity;
case "-Inf":
return -Infinity;
default:
return parseFloat(s);
}
};
const GraphView: FC<GraphViewProps> = ({
data = [],
period,

View file

@ -1,10 +1,13 @@
@use "src/styles/variables" as *;
$all-paddings: $padding-medium * 4;
.vm-graph-view {
width: 100%;
&_full-width {
width: calc(100vw - $all-paddings - var(--scrollbar-width));
width: calc(100vw - ($padding-medium * 4) - var(--scrollbar-width));
@media (max-width: 768px) {
width: calc(100vw - ($padding-medium * 2) - var(--scrollbar-width));
}
}
}

View file

@ -0,0 +1,16 @@
import { useEffect, useState } from "react";
import { isMobileAgent } from "../utils/detect-device";
import useResize from "./useResize";
export default function useDeviceDetect() {
const windowSize = useResize(document.body);
const [isMobile, setMobile] = useState(false);
useEffect(() => {
const mobileAgent = isMobileAgent();
const smallWidth = window.innerWidth < 500;
setMobile(mobileAgent || smallWidth);
}, [windowSize]);
return { isMobile };
}

View file

@ -155,8 +155,11 @@ export const useFetchQuery = ({
},
[serverUrl, period, displayType, customStep, hideQuery]);
const [prevUrl, setPrevUrl] = useState<string[]>([]);
useEffect(() => {
if (!visible || !fetchUrl?.length) return;
const isLazyPredefined = (fetchUrl === prevUrl && !!predefinedQuery);
if (!visible || !fetchUrl?.length || isLazyPredefined) return;
setIsLoading(true);
const expr = predefinedQuery ?? query;
throttledFetchData({
@ -168,6 +171,7 @@ export const useFetchQuery = ({
showAllSeries,
hideQuery,
});
setPrevUrl(fetchUrl);
}, [fetchUrl, visible, stateSeriesLimits, showAllSeries]);
useEffect(() => {

View file

@ -14,7 +14,7 @@ const useResize = (node: HTMLElement | null): {width: number, height: number} =>
return () => {
if (node) observer.unobserve(node);
};
}, []);
}, [node]);
return windowSize;
};

View file

@ -118,24 +118,26 @@ const CardinalityConfigurator: FC<CardinalityConfiguratorProps> = ({
at <b>{date}</b>{match && <span> for series selector <b>{match}</b></span>}.
Show top {topN} entries per table.
</div>
<a
className="vm-link vm-link_with-icon"
target="_blank"
href="https://docs.victoriametrics.com/#cardinality-explorer"
rel="help noreferrer"
>
<WikiIcon/>
Documentation
</a>
<a
className="vm-link vm-link_with-icon"
target="_blank"
href="https://victoriametrics.com/blog/cardinality-explorer/"
rel="help noreferrer"
>
<QuestionIcon/>
Example of using
</a>
<div className="vm-cardinality-configurator-bottom__docs">
<a
className="vm-link vm-link_with-icon"
target="_blank"
href="https://docs.victoriametrics.com/#cardinality-explorer"
rel="help noreferrer"
>
<WikiIcon/>
Documentation
</a>
<a
className="vm-link vm-link_with-icon"
target="_blank"
href="https://victoriametrics.com/blog/cardinality-explorer/"
rel="help noreferrer"
>
<QuestionIcon/>
Example of using
</a>
</div>
<Button
startIcon={<PlayIcon/>}
onClick={onRunQuery}

View file

@ -12,6 +12,10 @@
gap: 0 $padding-medium;
&__query {
flex-grow: 8;
}
&__item {
flex-grow: 1;
}
}
@ -19,13 +23,21 @@
&-additional {
display: flex;
align-items: center;
margin-bottom: $padding-small;
}
&-bottom {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: $padding-global;
&__docs {
display: flex;
align-items: center;
gap: $padding-global;
}
&__info {
flex-grow: 1;
font-size: $font-size;
@ -34,5 +46,9 @@
a {
color: $color-text-secondary;
}
button {
margin: 0 0 0 auto;
}
}
}

View file

@ -65,7 +65,10 @@ const MetricsContent: FC<MetricsProperties> = ({
/>
</div>
</div>
<div ref={chartContainer}>
<div
ref={chartContainer}
className="vm-metrics-content__table"
>
{activeTab === 0 && (
<EnhancedTable
rows={rows}

View file

@ -2,6 +2,20 @@
.vm-metrics-content {
&-header {
margin: -$padding-medium 0-$padding-medium $padding-medium;
margin: -$padding-medium 0-$padding-medium 0;
}
&__table {
padding-top: $padding-medium;
width: calc(100vw - ($padding-medium * 4) - var(--scrollbar-width));
overflow: auto;
@media (max-width: 768px) {
width: calc(100vw - ($padding-medium * 2) - var(--scrollbar-width));
}
.vm-table-cell_header {
white-space: nowrap;
}
}
}

View file

@ -28,14 +28,27 @@
&-settings {
display: flex;
align-items: flex-end;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: $padding-medium;
@media (max-width: 500px) {
display: grid;
grid-template-columns: 1fr 1fr;
align-items: flex-end;
}
&__buttons {
flex-grow: 1;
display: grid;
grid-template-columns: repeat(2, auto);
gap: $padding-small;
justify-content: flex-end;
@media (max-width: 500px) {
grid-template-columns: 1fr;
}
}
}
}

View file

@ -40,6 +40,10 @@
gap: $padding-global;
padding: 0;
@media (max-width: 1000px) {
grid-template-columns: 1fr;
}
&-panel {
position: relative;
border-radius: $border-radius-medium;

View file

@ -32,7 +32,7 @@ const PredefinedPanel: FC<PredefinedPanelsProps> = ({
const dispatch = useTimeDispatch();
const containerRef = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(true);
const [visible, setVisible] = useState(false);
const [yaxis, setYaxis] = useState<YaxisState>({
limits: {
enable: false,
@ -73,7 +73,7 @@ const PredefinedPanel: FC<PredefinedPanelsProps> = ({
return () => {
if (containerRef.current) observer.unobserve(containerRef.current);
};
}, []);
}, [containerRef]);
if (!validExpr) return (
<Alert variant="error">

View file

@ -37,5 +37,6 @@
&-body {
padding: $padding-small $padding-small*2;
min-height: 500px;
}
}

View file

@ -26,7 +26,8 @@ export const useFetchDashboards = (): {
const fetchLocalDashboards = async () => {
const filenames = window.__VMUI_PREDEFINED_DASHBOARDS__;
if (!filenames?.length) return [];
return await Promise.all(filenames.map(async f => importModule(f)));
const dashboards = await Promise.all(filenames.map(async f => importModule(f)));
setDashboards((prevDash) => [...dashboards, ...prevDash]);
};
const fetchRemoteDashboards = async () => {
@ -45,19 +46,20 @@ export const useFetchDashboards = (): {
}
setIsLoading(false);
} else {
await fetchLocalDashboards();
setError(resp.error);
setIsLoading(false);
}
} catch (e) {
setIsLoading(false);
if (e instanceof Error) setError(`${e.name}: ${e.message}`);
await fetchLocalDashboards();
}
};
useEffect(() => {
if (appModeEnable) return;
setDashboards([]);
fetchLocalDashboards().then(d => d.length && setDashboards((prevDash) => [...d, ...prevDash]));
fetchRemoteDashboards();
}, [serverUrl]);

View file

@ -5,6 +5,10 @@
gap: $padding-global;
align-items: flex-start;
@media (max-width: 768px) {
padding: $padding-medium 0;
}
&-tabs.vm-block {
padding: $padding-global;
}

View file

@ -40,7 +40,7 @@ const TopQueryPanel: FC<TopQueryPanelProps> = ({ rows, title, columns, defaultOr
</div>
</div>
<div>
<div className="vm-top-queries-panel__table">
{activeTab === 0 && (
<TopQueryTable
rows={rows}

View file

@ -2,6 +2,20 @@
.vm-top-queries-panel {
&-header {
margin: -$padding-medium 0-$padding-medium $padding-medium;
margin: -$padding-medium 0-$padding-medium 0;
}
&__table {
padding-top: $padding-medium;
width: calc(100vw - ($padding-medium * 4) - var(--scrollbar-width));
overflow: auto;
@media (max-width: 768px) {
width: calc(100vw - ($padding-medium * 2) - var(--scrollbar-width));
}
.vm-table-cell_header {
white-space: nowrap;
}
}
}

View file

@ -40,7 +40,7 @@ const Index: FC = () => {
const getQueryStatsTitle = (key: keyof TopQueryStats) => {
if (!data) return key;
const value = data[key];
if (typeof value === "number") return formatPrettyNumber(value);
if (typeof value === "number") return formatPrettyNumber(value, value, value);
return value || key;
};
@ -71,23 +71,27 @@ const Index: FC = () => {
{loading && <Spinner containerStyles={{ height: "500px" }}/>}
<div className="vm-top-queries-controls vm-block">
<div className="vm-top-queries-controls__fields">
<TextField
label="Max lifetime"
value={maxLifetime}
error={errorMaxLife}
helperText={`For example ${exampleDuration}`}
onChange={onMaxLifetimeChange}
onKeyDown={onKeyDown}
/>
<TextField
label="Number of returned queries"
type="number"
value={topN || ""}
error={errorTopN}
onChange={onTopNChange}
onKeyDown={onKeyDown}
/>
<div className="vm-top-queries-controls-fields">
<div className="vm-top-queries-controls-fields__item">
<TextField
label="Max lifetime"
value={maxLifetime}
error={errorMaxLife}
helperText={`For example ${exampleDuration}`}
onChange={onMaxLifetimeChange}
onKeyDown={onKeyDown}
/>
</div>
<div className="vm-top-queries-controls-fields__item">
<TextField
label="Number of returned queries"
type="number"
value={topN || ""}
error={errorTopN}
onChange={onTopNChange}
onKeyDown={onKeyDown}
/>
</div>
</div>
<div className="vm-top-queries-controls-bottom">
<div className="vm-top-queries-controls-bottom__info">

View file

@ -9,10 +9,16 @@
display: grid;
gap: $padding-small;
&__fields {
display: grid;
grid-template-columns: 1fr auto;
&-fields {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: $padding-medium;
&__item {
flex-grow: 1;
min-width: 200px;
}
}
&-bottom {

View file

@ -7,6 +7,7 @@ import { ErrorTypes } from "../../../types";
import classNames from "classnames";
import { useSnack } from "../../../contexts/Snackbar";
import { CopyIcon, RestartIcon } from "../../../components/Main/Icons";
import useDeviceDetect from "../../../hooks/useDeviceDetect";
interface JsonFormProps {
defaultJson?: string
@ -28,6 +29,7 @@ const JsonForm: FC<JsonFormProps> = ({
onUpload,
}) => {
const { showInfoMessage } = useSnack();
const { isMobile } = useDeviceDetect();
const [json, setJson] = useState(defaultJson);
const [title, setTitle] = useState(defaultTile);
@ -77,7 +79,8 @@ const JsonForm: FC<JsonFormProps> = ({
<div
className={classNames({
"vm-json-form": true,
"vm-json-form_one-field": !displayTitle
"vm-json-form_one-field": !displayTitle,
"vm-json-form_mobile": isMobile
})}
>
{displayTitle && (

View file

@ -2,15 +2,21 @@
.vm-json-form {
display: grid;
grid-template-rows: auto calc(70vh - 78px - ($padding-medium*3)) auto;
grid-template-rows: auto calc(($vh * 70) - 78px - ($padding-medium*3)) auto;
gap: $padding-global;
width: 70vw;
max-width: 1000px;
max-height: 900px;
overflow: hidden;
&_mobile {
width: 100%;
min-height: 100%;
grid-template-rows: auto 1fr auto;
}
&_one-field {
grid-template-rows: calc(70vh - 78px - ($padding-medium*3)) auto;
grid-template-rows: calc(($vh * 70) - 78px - ($padding-medium*3)) auto;
}
.vm-text-field_textarea {
@ -29,6 +35,14 @@
justify-content: space-between;
gap: $padding-small;
@media (max-width: 500px) {
flex-direction: column;
button {
flex-grow: 1;
}
}
&__controls {
flex-grow: 1;
display: flex;
@ -36,10 +50,22 @@
justify-content: flex-start;
gap: $padding-small;
@media (max-width: 500px) {
grid-template-columns: repeat(2, 1fr);
justify-content: center;
width: 100%;
}
&_right {
display: grid;
grid-template-columns: repeat(2, 90px);
justify-content: flex-end;
@media (max-width: 500px) {
grid-template-columns: repeat(2, 1fr);
justify-content: center;
width: 100%;
}
}
}
}

View file

@ -3,9 +3,12 @@
.vm-trace-page {
display: flex;
flex-direction: column;
padding: $padding-global;
min-height: 100%;
@media (max-width: 768px) {
padding: $padding-medium 0;
}
&-controls {
display: grid;
grid-template-columns: 1fr 1fr;
@ -21,6 +24,11 @@
gap: $padding-global;
margin-bottom: $padding-medium;
@media (max-width: 768px) {
grid-template-columns: 1fr;
padding: 0 $padding-medium;
}
&-errors {
display: grid;
align-items: flex-start;
@ -28,6 +36,10 @@
grid-template-columns: 1fr;
gap: $padding-medium;
@media (max-width: 768px) {
grid-row: 2;
}
&-item {
position: relative;
display: grid;

View file

@ -16,9 +16,6 @@
}
&_header {
position: sticky;
top: 0;
z-index: 2;
}
&_selected {

View file

@ -13,12 +13,13 @@ html, body, #root {
}
body {
overflow: scroll;
overflow: auto;
}
* {
font: inherit;
cursor: inherit;
touch-action: pan-x pan-y;
}
code {

View file

@ -61,3 +61,4 @@ $box-shadow: var(--box-shadow);
$box-shadow-popper: var(--box-shadow-popper);
$color-hover-black: var(--color-hover-black);
$vh: var(--vh);

View file

@ -1,6 +1,6 @@
export interface AppParams {
serverURL?: string
inputTenantID?: boolean
useTenantID?: boolean
headerStyles?: {
background?: string
color?: string

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