mirror of
https://github.com/VictoriaMetrics/VictoriaMetrics.git
synced 2026-05-26 13:07:42 +03:00
Compare commits
73 Commits
fs-paralle
...
improve-lo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6714ba184 | ||
|
|
2a83eab3ad | ||
|
|
7158d8f18e | ||
|
|
1b7f0172d2 | ||
|
|
1c77ee9527 | ||
|
|
2a0e382a99 | ||
|
|
02c8ea5a48 | ||
|
|
34f242a6b8 | ||
|
|
bc8f6c5688 | ||
|
|
c0fe67c2db | ||
|
|
ede1c2cde9 | ||
|
|
ad34a5eb53 | ||
|
|
eaf7a68c92 | ||
|
|
c5e43e1c91 | ||
|
|
b343f541f0 | ||
|
|
a23a902953 | ||
|
|
54c60706ca | ||
|
|
cd2e11b7cf | ||
|
|
5423d5e93a | ||
|
|
48819b6781 | ||
|
|
c4bff27f46 | ||
|
|
432b313a48 | ||
|
|
7bd5d19f62 | ||
|
|
8d18bc288f | ||
|
|
ff6e5c2983 | ||
|
|
23af0086d8 | ||
|
|
8657470068 | ||
|
|
3f16bc7cb2 | ||
|
|
655a0eb0c3 | ||
|
|
7cbd2a8600 | ||
|
|
5f67f04f6b | ||
|
|
2056e5b46d | ||
|
|
4d1f262ec4 | ||
|
|
afca599a46 | ||
|
|
d667f694bc | ||
|
|
fe2c60c79b | ||
|
|
36460f6297 | ||
|
|
d107dee9c7 | ||
|
|
b33d7c3ef9 | ||
|
|
d3848f6802 | ||
|
|
415ff27c74 | ||
|
|
90f59383b2 | ||
|
|
8fec7005d0 | ||
|
|
4d42b291e5 | ||
|
|
50f4fbf28e | ||
|
|
a5da6afb88 | ||
|
|
71f9e7f2c4 | ||
|
|
eb7c5df65e | ||
|
|
5af493297a | ||
|
|
2f61fa867e | ||
|
|
729b1099d8 | ||
|
|
945ca569b9 | ||
|
|
7fb8a8a0b2 | ||
|
|
89f95f74ed | ||
|
|
46e13fe0ca | ||
|
|
50d8ad6733 | ||
|
|
3b8550adb1 | ||
|
|
1708b73312 | ||
|
|
57defe7ab4 | ||
|
|
d58cfb7f36 | ||
|
|
a244750bc6 | ||
|
|
f06e7f9a6e | ||
|
|
7a5003212e | ||
|
|
846392405e | ||
|
|
37c3d8c26b | ||
|
|
8bc0475ee7 | ||
|
|
89414062bf | ||
|
|
67c51b009d | ||
|
|
e8160fc8fb | ||
|
|
e3a4ceaef3 | ||
|
|
e9cedca8c8 | ||
|
|
b720e55c13 | ||
|
|
ab1429c896 |
@@ -9,14 +9,14 @@ import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/VictoriaMetrics/metrics"
|
||||
"gopkg.in/yaml.v2"
|
||||
|
||||
"github.com/VictoriaMetrics/VictoriaMetrics/lib/fasttime"
|
||||
"github.com/VictoriaMetrics/VictoriaMetrics/lib/flagutil"
|
||||
"github.com/VictoriaMetrics/VictoriaMetrics/lib/logger"
|
||||
"github.com/VictoriaMetrics/VictoriaMetrics/lib/prompb"
|
||||
"github.com/VictoriaMetrics/VictoriaMetrics/lib/promrelabel"
|
||||
"go.yaml.in/yaml/v3"
|
||||
|
||||
"github.com/VictoriaMetrics/metrics"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -139,6 +139,7 @@ func loadRelabelConfigs() (*relabelConfigs, error) {
|
||||
remoteWriteRelabelConfigData.Store(&rawCfg)
|
||||
rcs.global = global
|
||||
}
|
||||
|
||||
if len(*relabelConfigPaths) > len(*remoteWriteURLs) {
|
||||
return nil, fmt.Errorf("too many -remoteWrite.urlRelabelConfig args: %d; it mustn't exceed the number of -remoteWrite.url args: %d",
|
||||
len(*relabelConfigPaths), (len(*remoteWriteURLs)))
|
||||
@@ -176,19 +177,9 @@ type relabelConfigs struct {
|
||||
perURL []*promrelabel.ParsedConfigs
|
||||
}
|
||||
|
||||
// isSet indicates whether (global or per-URL) command-line flags is set
|
||||
func (rcs *relabelConfigs) isSet() bool {
|
||||
if rcs == nil {
|
||||
return false
|
||||
}
|
||||
if rcs.global.Len() > 0 {
|
||||
return true
|
||||
}
|
||||
for _, pc := range rcs.perURL {
|
||||
if pc.Len() > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
return *relabelConfigPathGlobal != "" || len(*relabelConfigPaths) > 0
|
||||
}
|
||||
|
||||
// initLabelsGlobal must be called after parsing command-line flags.
|
||||
|
||||
@@ -80,14 +80,15 @@ func (as AlertState) String() string {
|
||||
|
||||
// AlertTplData is used to execute templating
|
||||
type AlertTplData struct {
|
||||
Type string
|
||||
Labels map[string]string
|
||||
Value float64
|
||||
Expr string
|
||||
AlertID uint64
|
||||
GroupID uint64
|
||||
ActiveAt time.Time
|
||||
For time.Duration
|
||||
Type string
|
||||
Labels map[string]string
|
||||
Value float64
|
||||
Expr string
|
||||
AlertID uint64
|
||||
GroupID uint64
|
||||
ActiveAt time.Time
|
||||
For time.Duration
|
||||
IsPartial bool
|
||||
}
|
||||
|
||||
var tplHeaders = []string{
|
||||
@@ -101,6 +102,7 @@ var tplHeaders = []string{
|
||||
"{{ $groupID := .GroupID }}",
|
||||
"{{ $activeAt := .ActiveAt }}",
|
||||
"{{ $for := .For }}",
|
||||
"{{ $isPartial := .IsPartial }}",
|
||||
}
|
||||
|
||||
// ExecTemplate executes the Alert template for given
|
||||
|
||||
@@ -346,6 +346,8 @@ func (ar *AlertingRule) toLabels(m datasource.Metric, qFn templates.QueryFn) (*l
|
||||
ls.processed[l.Name] = l.Value
|
||||
}
|
||||
|
||||
// labels only support limited templating variables,
|
||||
// including `labels`, `value` and `expr`, to avoid breaking alert states or causing cardinality issue with results
|
||||
extraLabels, err := notifier.ExecTemplate(qFn, ar.Labels, notifier.AlertTplData{
|
||||
Labels: ls.origin,
|
||||
Value: m.Values[0],
|
||||
@@ -387,11 +389,7 @@ func (ar *AlertingRule) execRange(ctx context.Context, start, end time.Time) ([]
|
||||
return nil, err
|
||||
}
|
||||
alertID := hash(ls.processed)
|
||||
as, err := ar.expandAnnotationTemplates(s, qFn, time.Time{}, ls)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a := ar.newAlert(s, time.Time{}, ls.processed, as) // initial alert
|
||||
a := ar.newAlert(s, time.Time{}, ls.processed, nil) // initial alert
|
||||
|
||||
prevT := time.Time{}
|
||||
for i := range s.Values {
|
||||
@@ -407,8 +405,6 @@ func (ar *AlertingRule) execRange(ctx context.Context, start, end time.Time) ([]
|
||||
// reset to Pending if there are gaps > EvalInterval between DPs
|
||||
a.State = notifier.StatePending
|
||||
a.ActiveAt = at
|
||||
// re-template the annotations as active timestamp is changed
|
||||
a.Annotations, _ = ar.expandAnnotationTemplates(s, qFn, at, ls)
|
||||
a.Start = time.Time{}
|
||||
} else if at.Sub(a.ActiveAt) >= ar.For && a.State != notifier.StateFiring {
|
||||
a.State = notifier.StateFiring
|
||||
@@ -463,7 +459,8 @@ func (ar *AlertingRule) exec(ctx context.Context, ts time.Time, limit int) ([]pr
|
||||
return nil, fmt.Errorf("failed to execute query %q: %w", ar.Expr, err)
|
||||
}
|
||||
|
||||
ar.logDebugf(ts, nil, "query returned %d series (elapsed: %s, isPartial: %t)", curState.Samples, curState.Duration, isPartialResponse(res))
|
||||
isPartial := isPartialResponse(res)
|
||||
ar.logDebugf(ts, nil, "query returned %d series (elapsed: %s, isPartial: %t)", curState.Samples, curState.Duration, isPartial)
|
||||
qFn := func(query string) ([]datasource.Metric, error) {
|
||||
res, _, err := ar.q.Query(ctx, query, ts)
|
||||
return res.Data, err
|
||||
@@ -489,7 +486,7 @@ func (ar *AlertingRule) exec(ctx context.Context, ts time.Time, limit int) ([]pr
|
||||
at = a.ActiveAt
|
||||
}
|
||||
}
|
||||
as, err := ar.expandAnnotationTemplates(m, qFn, at, ls)
|
||||
as, err := ar.expandAnnotationTemplates(m, qFn, at, ls, isPartial)
|
||||
if err != nil {
|
||||
// only set error in current state, but do not break alert processing
|
||||
curState.Err = err
|
||||
@@ -607,16 +604,17 @@ func (ar *AlertingRule) expandLabelTemplates(m datasource.Metric, qFn templates.
|
||||
return ls, nil
|
||||
}
|
||||
|
||||
func (ar *AlertingRule) expandAnnotationTemplates(m datasource.Metric, qFn templates.QueryFn, activeAt time.Time, ls *labelSet) (map[string]string, error) {
|
||||
func (ar *AlertingRule) expandAnnotationTemplates(m datasource.Metric, qFn templates.QueryFn, activeAt time.Time, ls *labelSet, isPartial bool) (map[string]string, error) {
|
||||
tplData := notifier.AlertTplData{
|
||||
Value: m.Values[0],
|
||||
Type: ar.Type.String(),
|
||||
Labels: ls.origin,
|
||||
Expr: ar.Expr,
|
||||
AlertID: hash(ls.processed),
|
||||
GroupID: ar.GroupID,
|
||||
ActiveAt: activeAt,
|
||||
For: ar.For,
|
||||
Value: m.Values[0],
|
||||
Type: ar.Type.String(),
|
||||
Labels: ls.origin,
|
||||
Expr: ar.Expr,
|
||||
AlertID: hash(ls.processed),
|
||||
GroupID: ar.GroupID,
|
||||
ActiveAt: activeAt,
|
||||
For: ar.For,
|
||||
IsPartial: isPartial,
|
||||
}
|
||||
as, err := notifier.ExecTemplate(qFn, ar.Annotations, tplData)
|
||||
if err != nil {
|
||||
|
||||
@@ -664,7 +664,7 @@ func TestAlertingRuleExecRange(t *testing.T) {
|
||||
Name: "for-pending",
|
||||
Type: config.NewPrometheusType().String(),
|
||||
Labels: map[string]string{"alertname": "for-pending"},
|
||||
Annotations: map[string]string{"activeAt": "5000"},
|
||||
Annotations: map[string]string{},
|
||||
State: notifier.StatePending,
|
||||
ActiveAt: time.Unix(5, 0),
|
||||
Value: 1,
|
||||
@@ -684,7 +684,7 @@ func TestAlertingRuleExecRange(t *testing.T) {
|
||||
Name: "for-firing",
|
||||
Type: config.NewPrometheusType().String(),
|
||||
Labels: map[string]string{"alertname": "for-firing"},
|
||||
Annotations: map[string]string{"activeAt": "1000"},
|
||||
Annotations: map[string]string{},
|
||||
State: notifier.StateFiring,
|
||||
ActiveAt: time.Unix(1, 0),
|
||||
Start: time.Unix(5, 0),
|
||||
@@ -705,7 +705,7 @@ func TestAlertingRuleExecRange(t *testing.T) {
|
||||
Name: "for-hold-pending",
|
||||
Type: config.NewPrometheusType().String(),
|
||||
Labels: map[string]string{"alertname": "for-hold-pending"},
|
||||
Annotations: map[string]string{"activeAt": "5000"},
|
||||
Annotations: map[string]string{},
|
||||
State: notifier.StatePending,
|
||||
ActiveAt: time.Unix(5, 0),
|
||||
Value: 1,
|
||||
@@ -1120,7 +1120,7 @@ func TestAlertingRuleLimit_Success(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAlertingRule_Template(t *testing.T) {
|
||||
f := func(rule *AlertingRule, metrics []datasource.Metric, alertsExpected map[uint64]*notifier.Alert) {
|
||||
f := func(rule *AlertingRule, metrics []datasource.Metric, isResponsePartial bool, alertsExpected map[uint64]*notifier.Alert) {
|
||||
t.Helper()
|
||||
|
||||
fakeGroup := Group{
|
||||
@@ -1133,6 +1133,7 @@ func TestAlertingRule_Template(t *testing.T) {
|
||||
entries: make([]StateEntry, 10),
|
||||
}
|
||||
fq.Add(metrics...)
|
||||
fq.SetPartialResponse(isResponsePartial)
|
||||
|
||||
if _, err := rule.exec(context.TODO(), time.Now(), 0); err != nil {
|
||||
t.Fatalf("unexpected error: %s", err)
|
||||
@@ -1163,7 +1164,7 @@ func TestAlertingRule_Template(t *testing.T) {
|
||||
}, []datasource.Metric{
|
||||
metricWithValueAndLabels(t, 1, "instance", "foo"),
|
||||
metricWithValueAndLabels(t, 1, "instance", "bar"),
|
||||
}, map[uint64]*notifier.Alert{
|
||||
}, false, map[uint64]*notifier.Alert{
|
||||
hash(map[string]string{alertNameLabel: "common", "region": "east", "instance": "foo"}): {
|
||||
Annotations: map[string]string{
|
||||
"summary": `common: Too high connection number for "foo"`,
|
||||
@@ -1192,14 +1193,14 @@ func TestAlertingRule_Template(t *testing.T) {
|
||||
"instance": "{{ $labels.instance }}",
|
||||
},
|
||||
Annotations: map[string]string{
|
||||
"summary": `{{ $labels.__name__ }}: Too high connection number for "{{ $labels.instance }}"`,
|
||||
"summary": `{{ $labels.__name__ }}: Too high connection number for "{{ $labels.instance }}".{{ if $isPartial }} WARNING: Partial response detected - this alert may be incomplete. Please verify the results manually.{{ end }}`,
|
||||
"description": `{{ $labels.alertname}}: It is {{ $value }} connections for "{{ $labels.instance }}"`,
|
||||
},
|
||||
alerts: make(map[uint64]*notifier.Alert),
|
||||
}, []datasource.Metric{
|
||||
metricWithValueAndLabels(t, 2, "__name__", "first", "instance", "foo", alertNameLabel, "override"),
|
||||
metricWithValueAndLabels(t, 10, "__name__", "second", "instance", "bar", alertNameLabel, "override"),
|
||||
}, map[uint64]*notifier.Alert{
|
||||
}, false, map[uint64]*notifier.Alert{
|
||||
hash(map[string]string{alertNameLabel: "override label", "exported_alertname": "override", "instance": "foo"}): {
|
||||
Labels: map[string]string{
|
||||
alertNameLabel: "override label",
|
||||
@@ -1207,7 +1208,7 @@ func TestAlertingRule_Template(t *testing.T) {
|
||||
"instance": "foo",
|
||||
},
|
||||
Annotations: map[string]string{
|
||||
"summary": `first: Too high connection number for "foo"`,
|
||||
"summary": `first: Too high connection number for "foo".`,
|
||||
"description": `override: It is 2 connections for "foo"`,
|
||||
},
|
||||
},
|
||||
@@ -1218,7 +1219,7 @@ func TestAlertingRule_Template(t *testing.T) {
|
||||
"instance": "bar",
|
||||
},
|
||||
Annotations: map[string]string{
|
||||
"summary": `second: Too high connection number for "bar"`,
|
||||
"summary": `second: Too high connection number for "bar".`,
|
||||
"description": `override: It is 10 connections for "bar"`,
|
||||
},
|
||||
},
|
||||
@@ -1231,7 +1232,7 @@ func TestAlertingRule_Template(t *testing.T) {
|
||||
"instance": "{{ $labels.instance }}",
|
||||
},
|
||||
Annotations: map[string]string{
|
||||
"summary": `Alert "{{ $labels.alertname }}({{ $labels.alertgroup }})" for instance {{ $labels.instance }}`,
|
||||
"summary": `Alert "{{ $labels.alertname }}({{ $labels.alertgroup }})" for instance {{ $labels.instance }}.{{ if $isPartial }} WARNING: Partial response detected - this alert may be incomplete. Please verify the results manually.{{ end }}`,
|
||||
},
|
||||
alerts: make(map[uint64]*notifier.Alert),
|
||||
}, []datasource.Metric{
|
||||
@@ -1239,7 +1240,7 @@ func TestAlertingRule_Template(t *testing.T) {
|
||||
alertNameLabel, "originAlertname",
|
||||
alertGroupNameLabel, "originGroupname",
|
||||
"instance", "foo"),
|
||||
}, map[uint64]*notifier.Alert{
|
||||
}, true, map[uint64]*notifier.Alert{
|
||||
hash(map[string]string{
|
||||
alertNameLabel: "OriginLabels",
|
||||
"exported_alertname": "originAlertname",
|
||||
@@ -1255,7 +1256,7 @@ func TestAlertingRule_Template(t *testing.T) {
|
||||
"instance": "foo",
|
||||
},
|
||||
Annotations: map[string]string{
|
||||
"summary": `Alert "originAlertname(originGroupname)" for instance foo`,
|
||||
"summary": `Alert "originAlertname(originGroupname)" for instance foo. WARNING: Partial response detected - this alert may be incomplete. Please verify the results manually.`,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1385,7 +1386,7 @@ func TestAlertingRule_ToLabels(t *testing.T) {
|
||||
"group": "vmalert",
|
||||
"alertname": "ConfigurationReloadFailure",
|
||||
"alertgroup": "vmalert",
|
||||
"invalid_label": `error evaluating template: template: :1:268: executing "" at <.Values.mustRuntimeFail>: can't evaluate field Values in type notifier.tplData`,
|
||||
"invalid_label": `error evaluating template: template: :1:298: executing "" at <.Values.mustRuntimeFail>: can't evaluate field Values in type notifier.tplData`,
|
||||
}
|
||||
|
||||
expectedProcessedLabels := map[string]string{
|
||||
@@ -1395,7 +1396,7 @@ func TestAlertingRule_ToLabels(t *testing.T) {
|
||||
"exported_alertname": "ConfigurationReloadFailure",
|
||||
"group": "vmalert",
|
||||
"alertgroup": "vmalert",
|
||||
"invalid_label": `error evaluating template: template: :1:268: executing "" at <.Values.mustRuntimeFail>: can't evaluate field Values in type notifier.tplData`,
|
||||
"invalid_label": `error evaluating template: template: :1:298: executing "" at <.Values.mustRuntimeFail>: can't evaluate field Values in type notifier.tplData`,
|
||||
}
|
||||
|
||||
ls, err := ar.toLabels(metric, nil)
|
||||
|
||||
@@ -394,7 +394,7 @@ func (bu *backendURL) runHealthCheck() {
|
||||
if errors.Is(bu.healthCheckContext.Err(), context.Canceled) {
|
||||
return
|
||||
}
|
||||
logger.Warnf("ignoring the backend at %s for %s becasue of dial error: %s", addr, *failTimeout, err)
|
||||
logger.Warnf("ignoring the backend at %s for %s because of dial error: %s", addr, *failTimeout, err)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -809,7 +809,7 @@ func reloadAuthConfig() (bool, error) {
|
||||
|
||||
ok, err := reloadAuthConfigData(data)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to pars -auth.config=%q: %w", *authConfigPath, err)
|
||||
return false, fmt.Errorf("failed to parse -auth.config=%q: %w", *authConfigPath, err)
|
||||
}
|
||||
if !ok {
|
||||
return false, nil
|
||||
|
||||
@@ -349,14 +349,17 @@ func tryProcessingRequest(w http.ResponseWriter, r *http.Request, targetURL *url
|
||||
err = ctxErr
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
// Do not retry canceled or timed out requests
|
||||
// Do not retry canceled
|
||||
if errors.Is(err, context.Canceled) {
|
||||
clientCanceledRequests.Inc()
|
||||
return true, false
|
||||
}
|
||||
// Do not retry timed out requests
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
remoteAddr := httpserver.GetQuotedRemoteAddr(r)
|
||||
requestURI := httpserver.GetRequestURI(r)
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
// Timed out request must be counted as errors, since this usually means that the backend is slow.
|
||||
logger.Warnf("remoteAddr: %s; requestURI: %s; timeout while proxying the response from %s: %s", remoteAddr, requestURI, targetURL, err)
|
||||
}
|
||||
// Timed out request must be counted as errors, since this usually means that the backend is slow.
|
||||
logger.Warnf("remoteAddr: %s; requestURI: %s; timeout while proxying the response from %s: %s", remoteAddr, requestURI, targetURL, err)
|
||||
return false, false
|
||||
}
|
||||
if !rtbOK || !rtb.canRetry() {
|
||||
@@ -413,7 +416,10 @@ func tryProcessingRequest(w http.ResponseWriter, r *http.Request, targetURL *url
|
||||
|
||||
err = copyStreamToClient(w, res.Body)
|
||||
_ = res.Body.Close()
|
||||
if err != nil && !netutil.IsTrivialNetworkError(err) && !errors.Is(err, context.Canceled) {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
clientCanceledRequests.Inc()
|
||||
return true, false
|
||||
} else if err != nil && !netutil.IsTrivialNetworkError(err) {
|
||||
remoteAddr := httpserver.GetQuotedRemoteAddr(r)
|
||||
requestURI := httpserver.GetRequestURI(r)
|
||||
|
||||
@@ -546,6 +552,7 @@ var (
|
||||
configReloadRequests = metrics.NewCounter(`vmauth_http_requests_total{path="/-/reload"}`)
|
||||
invalidAuthTokenRequests = metrics.NewCounter(`vmauth_http_request_errors_total{reason="invalid_auth_token"}`)
|
||||
missingRouteRequests = metrics.NewCounter(`vmauth_http_request_errors_total{reason="missing_route"}`)
|
||||
clientCanceledRequests = metrics.NewCounter(`vmauth_http_request_errors_total{reason="client_canceled"}`)
|
||||
)
|
||||
|
||||
func newRoundTripper(caFileOpt, certFileOpt, keyFileOpt, serverNameOpt string, insecureSkipVerifyP *bool) (http.RoundTripper, error) {
|
||||
@@ -633,6 +640,7 @@ func handleConcurrencyLimitError(w http.ResponseWriter, r *http.Request, err err
|
||||
if errors.Is(ctx.Err(), context.Canceled) {
|
||||
// Do not return any response for the request canceled by the client,
|
||||
// since the connection to the client is already closed.
|
||||
clientCanceledRequests.Inc()
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -468,7 +468,7 @@ var (
|
||||
Name: vmNativeFilterMatch,
|
||||
Usage: "Time series selector to match series for export. For example, select {instance!=\"localhost\"} will " +
|
||||
"match all series with \"instance\" label different to \"localhost\".\n" +
|
||||
" See more details here https://github.com/VictoriaMetrics/VictoriaMetrics#how-to-export-data-in-native-format",
|
||||
" See more details here https://docs.victoriametrics.com/victoriametrics/single-server-victoriametrics/#how-to-export-data-in-native-format",
|
||||
Value: `{__name__!=""}`,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
|
||||
@@ -4,8 +4,10 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/prometheus/prometheus/model/labels"
|
||||
"github.com/prometheus/prometheus/tsdb"
|
||||
"github.com/prometheus/prometheus/tsdb/chunkenc"
|
||||
|
||||
@@ -61,19 +63,19 @@ func (pp *prometheusProcessor) do(b tsdb.BlockReader) error {
|
||||
var it chunkenc.Iterator
|
||||
for ss.Next() {
|
||||
var name string
|
||||
var labels []vm.LabelPair
|
||||
var labelPairs []vm.LabelPair
|
||||
series := ss.At()
|
||||
|
||||
for _, label := range series.Labels() {
|
||||
series.Labels().Range(func(label labels.Label) {
|
||||
if label.Name == "__name__" {
|
||||
name = label.Value
|
||||
continue
|
||||
return
|
||||
}
|
||||
labels = append(labels, vm.LabelPair{
|
||||
Name: label.Name,
|
||||
Value: label.Value,
|
||||
labelPairs = append(labelPairs, vm.LabelPair{
|
||||
Name: strings.Clone(label.Name),
|
||||
Value: strings.Clone(label.Value),
|
||||
})
|
||||
}
|
||||
})
|
||||
if name == "" {
|
||||
return fmt.Errorf("failed to find `__name__` label in labelset for block %v", b.Meta().ULID)
|
||||
}
|
||||
@@ -99,7 +101,7 @@ func (pp *prometheusProcessor) do(b tsdb.BlockReader) error {
|
||||
}
|
||||
ts := vm.TimeSeries{
|
||||
Name: name,
|
||||
LabelPairs: labels,
|
||||
LabelPairs: labelPairs,
|
||||
Timestamps: timestamps,
|
||||
Values: values,
|
||||
}
|
||||
|
||||
@@ -232,7 +232,7 @@ func RequestHandler(w http.ResponseWriter, r *http.Request) bool {
|
||||
}
|
||||
firehose.WriteSuccessResponse(w, r)
|
||||
return true
|
||||
case "zabbixconnector/api/v1/history":
|
||||
case "/zabbixconnector/api/v1/history":
|
||||
zabbixconnectorHistoryRequests.Inc()
|
||||
if err := zabbixconnector.InsertHandlerForHTTP(r); err != nil {
|
||||
zabbixconnectorHistoryErrors.Inc()
|
||||
@@ -241,7 +241,7 @@ func RequestHandler(w http.ResponseWriter, r *http.Request) bool {
|
||||
fmt.Fprintf(w, `{"error":%q}`, err.Error())
|
||||
return true
|
||||
}
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return true
|
||||
case "/newrelic":
|
||||
newrelicCheckRequest.Inc()
|
||||
|
||||
@@ -520,7 +520,7 @@ func handleStaticAndSimpleRequests(w http.ResponseWriter, r *http.Request, path
|
||||
fmt.Fprintf(w, "%s", `{"status":"error","msg":"for accessing vmalert flag '-vmalert.proxyURL' must be configured"}`)
|
||||
return true
|
||||
}
|
||||
proxyVMAlertRequests(w, r)
|
||||
proxyVMAlertRequests(w, r, path)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -558,7 +558,7 @@ func handleStaticAndSimpleRequests(w http.ResponseWriter, r *http.Request, path
|
||||
case "/api/v1/rules", "/rules":
|
||||
rulesRequests.Inc()
|
||||
if len(*vmalertProxyURL) > 0 {
|
||||
proxyVMAlertRequests(w, r)
|
||||
proxyVMAlertRequests(w, r, path)
|
||||
return true
|
||||
}
|
||||
// Return dumb placeholder for https://prometheus.io/docs/prometheus/latest/querying/api/#rules
|
||||
@@ -568,7 +568,7 @@ func handleStaticAndSimpleRequests(w http.ResponseWriter, r *http.Request, path
|
||||
case "/api/v1/alerts", "/alerts":
|
||||
alertsRequests.Inc()
|
||||
if len(*vmalertProxyURL) > 0 {
|
||||
proxyVMAlertRequests(w, r)
|
||||
proxyVMAlertRequests(w, r, path)
|
||||
return true
|
||||
}
|
||||
// Return dumb placeholder for https://prometheus.io/docs/prometheus/latest/querying/api/#alerts
|
||||
@@ -578,7 +578,7 @@ func handleStaticAndSimpleRequests(w http.ResponseWriter, r *http.Request, path
|
||||
case "/api/v1/notifiers", "/notifiers":
|
||||
notifiersRequests.Inc()
|
||||
if len(*vmalertProxyURL) > 0 {
|
||||
proxyVMAlertRequests(w, r)
|
||||
proxyVMAlertRequests(w, r, path)
|
||||
return true
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@@ -725,7 +725,7 @@ var (
|
||||
metricNamesStatsResetErrors = metrics.NewCounter(`vm_http_request_errors_total{path="/api/v1/admin/status/metric_names_stats/reset"}`)
|
||||
)
|
||||
|
||||
func proxyVMAlertRequests(w http.ResponseWriter, r *http.Request) {
|
||||
func proxyVMAlertRequests(w http.ResponseWriter, r *http.Request, path string) {
|
||||
defer func() {
|
||||
err := recover()
|
||||
if err == nil || err == http.ErrAbortHandler {
|
||||
@@ -736,8 +736,10 @@ func proxyVMAlertRequests(w http.ResponseWriter, r *http.Request) {
|
||||
// Forward other panics to the caller.
|
||||
panic(err)
|
||||
}()
|
||||
r.Host = vmalertProxyHost
|
||||
vmalertProxy.ServeHTTP(w, r)
|
||||
req := r.Clone(r.Context())
|
||||
req.URL.Path = strings.TrimPrefix(path, "prometheus")
|
||||
req.Host = vmalertProxyHost
|
||||
vmalertProxy.ServeHTTP(w, req)
|
||||
}
|
||||
|
||||
var (
|
||||
|
||||
@@ -785,7 +785,8 @@ func getRollupExprArg(arg metricsql.Expr) *metricsql.RollupExpr {
|
||||
// - rollupFunc(m) if iafc is nil
|
||||
// - aggrFunc(rollupFunc(m)) if iafc isn't nil
|
||||
func evalRollupFunc(qt *querytracer.Tracer, ec *EvalConfig, funcName string, rf rollupFunc, expr metricsql.Expr,
|
||||
re *metricsql.RollupExpr, iafc *incrementalAggrFuncContext) ([]*timeseries, error) {
|
||||
re *metricsql.RollupExpr, iafc *incrementalAggrFuncContext,
|
||||
) ([]*timeseries, error) {
|
||||
if re.At == nil {
|
||||
return evalRollupFuncWithoutAt(qt, ec, funcName, rf, expr, re, iafc)
|
||||
}
|
||||
@@ -835,7 +836,8 @@ func evalRollupFunc(qt *querytracer.Tracer, ec *EvalConfig, funcName string, rf
|
||||
}
|
||||
|
||||
func evalRollupFuncWithoutAt(qt *querytracer.Tracer, ec *EvalConfig, funcName string, rf rollupFunc,
|
||||
expr metricsql.Expr, re *metricsql.RollupExpr, iafc *incrementalAggrFuncContext) ([]*timeseries, error) {
|
||||
expr metricsql.Expr, re *metricsql.RollupExpr, iafc *incrementalAggrFuncContext,
|
||||
) ([]*timeseries, error) {
|
||||
funcName = strings.ToLower(funcName)
|
||||
ecNew := ec
|
||||
var offset int64
|
||||
@@ -1058,7 +1060,8 @@ func removeNanValues(dstValues []float64, dstTimestamps []int64, values []float6
|
||||
|
||||
// evalInstantRollup evaluates instant rollup where ec.Start == ec.End.
|
||||
func evalInstantRollup(qt *querytracer.Tracer, ec *EvalConfig, funcName string, rf rollupFunc,
|
||||
expr metricsql.Expr, me *metricsql.MetricExpr, iafc *incrementalAggrFuncContext, window int64) ([]*timeseries, error) {
|
||||
expr metricsql.Expr, me *metricsql.MetricExpr, iafc *incrementalAggrFuncContext, window int64,
|
||||
) ([]*timeseries, error) {
|
||||
if ec.Start != ec.End {
|
||||
logger.Panicf("BUG: evalInstantRollup cannot be called on non-empty time range; got %s", ec.timeRangeString())
|
||||
}
|
||||
@@ -1083,10 +1086,12 @@ func evalInstantRollup(qt *querytracer.Tracer, ec *EvalConfig, funcName string,
|
||||
rollupResultCacheV.DeleteInstantValues(qt, expr, window, ec.Step, ec.EnforcedTagFilterss)
|
||||
}
|
||||
getCachedSeries := func(qt *querytracer.Tracer) ([]*timeseries, int64, error) {
|
||||
rollupResultCacheV.rollupResultCacheRequests.Inc()
|
||||
again:
|
||||
offset := int64(0)
|
||||
tssCached := rollupResultCacheV.GetInstantValues(qt, expr, window, ec.Step, ec.EnforcedTagFilterss)
|
||||
if len(tssCached) == 0 {
|
||||
rollupResultCacheV.rollupResultCacheMisses.Inc()
|
||||
// Cache miss. Re-populate the missing data.
|
||||
start := int64(fasttime.UnixTimestamp()*1000) - cacheTimestampOffset.Milliseconds()
|
||||
offset = timestamp - start
|
||||
@@ -1129,6 +1134,7 @@ func evalInstantRollup(qt *querytracer.Tracer, ec *EvalConfig, funcName string,
|
||||
deleteCachedSeries(qt)
|
||||
goto again
|
||||
}
|
||||
rollupResultCacheV.rollupResultCachePartialHits.Inc()
|
||||
ec.QueryStats.addSeriesFetched(len(tssCached))
|
||||
return tssCached, offset, nil
|
||||
}
|
||||
@@ -1537,16 +1543,11 @@ func assertInstantValues(tss []*timeseries) {
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
rollupResultCacheFullHits = metrics.NewCounter(`vm_rollup_result_cache_full_hits_total`)
|
||||
rollupResultCachePartialHits = metrics.NewCounter(`vm_rollup_result_cache_partial_hits_total`)
|
||||
rollupResultCacheMiss = metrics.NewCounter(`vm_rollup_result_cache_miss_total`)
|
||||
|
||||
memoryIntensiveQueries = metrics.NewCounter(`vm_memory_intensive_queries_total`)
|
||||
)
|
||||
var memoryIntensiveQueries = metrics.NewCounter(`vm_memory_intensive_queries_total`)
|
||||
|
||||
func evalRollupFuncWithMetricExpr(qt *querytracer.Tracer, ec *EvalConfig, funcName string, rf rollupFunc,
|
||||
expr metricsql.Expr, me *metricsql.MetricExpr, iafc *incrementalAggrFuncContext, windowExpr *metricsql.DurationExpr) ([]*timeseries, error) {
|
||||
expr metricsql.Expr, me *metricsql.MetricExpr, iafc *incrementalAggrFuncContext, windowExpr *metricsql.DurationExpr,
|
||||
) ([]*timeseries, error) {
|
||||
window, err := windowExpr.NonNegativeDuration(ec.Step)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot parse lookbehind window in square brackets at %s: %w", expr.AppendString(nil), err)
|
||||
@@ -1582,19 +1583,20 @@ func evalRollupFuncWithMetricExpr(qt *querytracer.Tracer, ec *EvalConfig, funcNa
|
||||
}
|
||||
|
||||
// Search for cached results.
|
||||
rollupResultCacheV.rollupResultCacheRequests.Inc()
|
||||
tssCached, start := rollupResultCacheV.GetSeries(qt, ec, expr, window)
|
||||
ec.QueryStats.addSeriesFetched(len(tssCached))
|
||||
if start > ec.End {
|
||||
qt.Printf("the result is fully cached")
|
||||
rollupResultCacheFullHits.Inc()
|
||||
rollupResultCacheV.rollupResultCacheFullHits.Inc()
|
||||
return tssCached, nil
|
||||
}
|
||||
if start > ec.Start {
|
||||
qt.Printf("partial cache hit")
|
||||
rollupResultCachePartialHits.Inc()
|
||||
rollupResultCacheV.rollupResultCachePartialHits.Inc()
|
||||
} else {
|
||||
qt.Printf("cache miss")
|
||||
rollupResultCacheMiss.Inc()
|
||||
rollupResultCacheV.rollupResultCacheMisses.Inc()
|
||||
}
|
||||
|
||||
// Fetch missing results, which aren't cached yet.
|
||||
@@ -1630,7 +1632,8 @@ func evalRollupFuncWithMetricExpr(qt *querytracer.Tracer, ec *EvalConfig, funcNa
|
||||
//
|
||||
// pointsPerSeries is used only for estimating the needed memory for query processing
|
||||
func evalRollupFuncNoCache(qt *querytracer.Tracer, ec *EvalConfig, funcName string, rf rollupFunc,
|
||||
expr metricsql.Expr, me *metricsql.MetricExpr, iafc *incrementalAggrFuncContext, window, pointsPerSeries int64) ([]*timeseries, error) {
|
||||
expr metricsql.Expr, me *metricsql.MetricExpr, iafc *incrementalAggrFuncContext, window, pointsPerSeries int64,
|
||||
) ([]*timeseries, error) {
|
||||
if qt.Enabled() {
|
||||
qt = qt.NewChild("rollup %s: timeRange=%s, step=%d, window=%d", expr.AppendString(nil), ec.timeRangeString(), ec.Step, window)
|
||||
defer qt.Done()
|
||||
@@ -1753,7 +1756,8 @@ func maxSilenceInterval() int64 {
|
||||
|
||||
func evalRollupWithIncrementalAggregate(qt *querytracer.Tracer, funcName string, keepMetricNames bool,
|
||||
iafc *incrementalAggrFuncContext, rss *netstorage.Results, rcs []*rollupConfig,
|
||||
preFunc func(values []float64, timestamps []int64), sharedTimestamps []int64) ([]*timeseries, error) {
|
||||
preFunc func(values []float64, timestamps []int64), sharedTimestamps []int64,
|
||||
) ([]*timeseries, error) {
|
||||
qt = qt.NewChild("rollup %s() with incremental aggregation %s() over %d series; rollupConfigs=%s", funcName, iafc.ae.Name, rss.Len(), rcs)
|
||||
defer qt.Done()
|
||||
var samplesScannedTotal atomic.Uint64
|
||||
@@ -1792,7 +1796,8 @@ func evalRollupWithIncrementalAggregate(qt *querytracer.Tracer, funcName string,
|
||||
}
|
||||
|
||||
func evalRollupNoIncrementalAggregate(qt *querytracer.Tracer, funcName string, keepMetricNames bool, rss *netstorage.Results, rcs []*rollupConfig,
|
||||
preFunc func(values []float64, timestamps []int64), sharedTimestamps []int64) ([]*timeseries, error) {
|
||||
preFunc func(values []float64, timestamps []int64), sharedTimestamps []int64,
|
||||
) ([]*timeseries, error) {
|
||||
qt = qt.NewChild("rollup %s() over %d series; rollupConfigs=%s", funcName, rss.Len(), rcs)
|
||||
defer qt.Done()
|
||||
|
||||
@@ -1832,7 +1837,8 @@ func evalRollupNoIncrementalAggregate(qt *querytracer.Tracer, funcName string, k
|
||||
}
|
||||
|
||||
func doRollupForTimeseries(funcName string, keepMetricNames bool, rc *rollupConfig, tsDst *timeseries, mnSrc *storage.MetricName,
|
||||
valuesSrc []float64, timestampsSrc []int64, sharedTimestamps []int64) uint64 {
|
||||
valuesSrc []float64, timestampsSrc []int64, sharedTimestamps []int64,
|
||||
) uint64 {
|
||||
tsDst.MetricName.CopyFrom(mnSrc)
|
||||
if len(rc.TagValue) > 0 {
|
||||
tsDst.MetricName.AddTag("rollup", rc.TagValue)
|
||||
|
||||
@@ -869,17 +869,17 @@ func getScrapeInterval(timestamps []int64, defaultInterval int64) int64 {
|
||||
return defaultInterval
|
||||
}
|
||||
|
||||
// Estimate scrape interval as 0.6 quantile for the first 20 intervals.
|
||||
tsPrev := timestamps[0]
|
||||
timestamps = timestamps[1:]
|
||||
// Estimate scrape interval as 0.6 quantile of the last 20 intervals.
|
||||
tsPrev := timestamps[len(timestamps)-1]
|
||||
timestamps = timestamps[:len(timestamps)-1]
|
||||
if len(timestamps) > 20 {
|
||||
timestamps = timestamps[:20]
|
||||
timestamps = timestamps[len(timestamps)-20:]
|
||||
}
|
||||
a := getFloat64s()
|
||||
intervals := a.A[:0]
|
||||
for _, ts := range timestamps {
|
||||
intervals = append(intervals, float64(ts-tsPrev))
|
||||
tsPrev = ts
|
||||
for i := len(timestamps) - 1; i >= 0; i-- {
|
||||
intervals = append(intervals, float64(tsPrev-timestamps[i]))
|
||||
tsPrev = timestamps[i]
|
||||
}
|
||||
scrapeInterval := int64(quantile(0.6, intervals))
|
||||
a.A = intervals
|
||||
|
||||
@@ -83,9 +83,11 @@ func checkRollupResultCacheReset() {
|
||||
|
||||
const checkRollupResultCacheResetInterval = 5 * time.Second
|
||||
|
||||
var needRollupResultCacheReset atomic.Bool
|
||||
var checkRollupResultCacheResetOnce sync.Once
|
||||
var rollupResultResetMetricRowSample atomic.Pointer[storage.MetricRow]
|
||||
var (
|
||||
needRollupResultCacheReset atomic.Bool
|
||||
checkRollupResultCacheResetOnce sync.Once
|
||||
rollupResultResetMetricRowSample atomic.Pointer[storage.MetricRow]
|
||||
)
|
||||
|
||||
var rollupResultCacheV = &rollupResultCache{
|
||||
c: workingsetcache.New(1024 * 1024), // This is a cache for testing.
|
||||
@@ -178,6 +180,12 @@ func InitRollupResultCache(cachePath string) {
|
||||
|
||||
rollupResultCacheV = &rollupResultCache{
|
||||
c: c,
|
||||
|
||||
rollupResultCacheRequests: metrics.GetOrCreateCounter(`vm_rollup_result_cache_requests_total`),
|
||||
rollupResultCacheFullHits: metrics.GetOrCreateCounter(`vm_rollup_result_cache_full_hits_total`),
|
||||
rollupResultCachePartialHits: metrics.GetOrCreateCounter(`vm_rollup_result_cache_partial_hits_total`),
|
||||
rollupResultCacheMisses: metrics.GetOrCreateCounter(`vm_rollup_result_cache_miss_total`),
|
||||
rollupResultCacheResets: metrics.GetOrCreateCounter(`vm_rollup_result_cache_resets_total`),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,13 +201,18 @@ func StopRollupResultCache() {
|
||||
|
||||
type rollupResultCache struct {
|
||||
c *workingsetcache.Cache
|
||||
}
|
||||
|
||||
var rollupResultCacheResets = metrics.NewCounter(`vm_cache_resets_total{type="promql/rollupResult"}`)
|
||||
rollupResultCacheRequests *metrics.Counter
|
||||
rollupResultCacheFullHits *metrics.Counter
|
||||
rollupResultCachePartialHits *metrics.Counter
|
||||
rollupResultCacheMisses *metrics.Counter
|
||||
|
||||
rollupResultCacheResets *metrics.Counter
|
||||
}
|
||||
|
||||
// ResetRollupResultCache resets rollup result cache.
|
||||
func ResetRollupResultCache() {
|
||||
rollupResultCacheResets.Inc()
|
||||
rollupResultCacheV.rollupResultCacheResets.Inc()
|
||||
rollupResultCacheKeyPrefix.Add(1)
|
||||
logger.Infof("rollupResult cache has been cleared")
|
||||
}
|
||||
|
||||
209
app/vmselect/vmui/assets/index-B6lol36n.js
Normal file
209
app/vmselect/vmui/assets/index-B6lol36n.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
app/vmselect/vmui/assets/index-VQRcNK83.css
Normal file
1
app/vmselect/vmui/assets/index-VQRcNK83.css
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
66
app/vmselect/vmui/assets/vendor-EZef-S_8.js
Normal file
66
app/vmselect/vmui/assets/vendor-EZef-S_8.js
Normal file
File diff suppressed because one or more lines are too long
@@ -37,10 +37,10 @@
|
||||
<meta property="og:title" content="UI for VictoriaMetrics">
|
||||
<meta property="og:url" content="https://victoriametrics.com/">
|
||||
<meta property="og:description" content="Explore and troubleshoot your VictoriaMetrics data">
|
||||
<script type="module" crossorigin src="./assets/index-Clpj_g75.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="./assets/vendor-D5YL0cqB.js">
|
||||
<script type="module" crossorigin src="./assets/index-B6lol36n.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="./assets/vendor-EZef-S_8.js">
|
||||
<link rel="stylesheet" crossorigin href="./assets/vendor-D1GxaB_c.css">
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-jEWkrqzO.css">
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-VQRcNK83.css">
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
|
||||
@@ -29,7 +29,8 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
retentionPeriod = flagutil.NewRetentionDuration("retentionPeriod", "1", "Data with timestamps outside the retentionPeriod is automatically deleted. The minimum retentionPeriod is 24h or 1d. See also -retentionFilter")
|
||||
retentionPeriod = flagutil.NewRetentionDuration("retentionPeriod", "1M", "Data with timestamps outside the retentionPeriod is automatically deleted. The minimum retentionPeriod is 24h or 1d. "+
|
||||
"See https://docs.victoriametrics.com/victoriametrics/single-server-victoriametrics/#retention. See also -retentionFilter")
|
||||
snapshotAuthKey = flagutil.NewPassword("snapshotAuthKey", "authKey, which must be passed in query string to /snapshot* pages. It overrides -httpAuth.*")
|
||||
forceMergeAuthKey = flagutil.NewPassword("forceMergeAuthKey", "authKey, which must be passed in query string to /internal/force_merge pages. It overrides -httpAuth.*")
|
||||
forceFlushAuthKey = flagutil.NewPassword("forceFlushAuthKey", "authKey, which must be passed in query string to /internal/force_flush pages. It overrides -httpAuth.*")
|
||||
@@ -388,11 +389,23 @@ func RequestHandler(w http.ResponseWriter, r *http.Request) bool {
|
||||
case "/create":
|
||||
snapshotsCreateTotal.Inc()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
snapshotPath := Storage.MustCreateSnapshot()
|
||||
snapshotName := Storage.MustCreateSnapshot()
|
||||
|
||||
// Verify whether the client already closed the connection.
|
||||
// In this case it is better to drop the created snapshot, since the client isn't interested in it.
|
||||
if err := r.Context().Err(); err != nil {
|
||||
logger.Infof("deleting already created snapshot at %s because the client canceled the request", snapshotName)
|
||||
if err := deleteSnapshot(snapshotName); err != nil {
|
||||
logger.Infof("cannot delete just created snapshot: %s", err)
|
||||
return true
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if prometheusCompatibleResponse {
|
||||
fmt.Fprintf(w, `{"status":"success","data":{"name":%s}}`, stringsutil.JSONString(snapshotPath))
|
||||
fmt.Fprintf(w, `{"status":"success","data":{"name":%s}}`, stringsutil.JSONString(snapshotName))
|
||||
} else {
|
||||
fmt.Fprintf(w, `{"status":"ok","snapshot":%s}`, stringsutil.JSONString(snapshotPath))
|
||||
fmt.Fprintf(w, `{"status":"ok","snapshot":%s}`, stringsutil.JSONString(snapshotName))
|
||||
}
|
||||
return true
|
||||
case "/list":
|
||||
@@ -412,23 +425,12 @@ func RequestHandler(w http.ResponseWriter, r *http.Request) bool {
|
||||
snapshotsDeleteTotal.Inc()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
snapshotName := r.FormValue("snapshot")
|
||||
|
||||
snapshots := Storage.MustListSnapshots()
|
||||
for _, snName := range snapshots {
|
||||
if snName == snapshotName {
|
||||
if err := Storage.DeleteSnapshot(snName); err != nil {
|
||||
err = fmt.Errorf("cannot delete snapshot %q: %w", snName, err)
|
||||
jsonResponseError(w, err)
|
||||
snapshotsDeleteErrorsTotal.Inc()
|
||||
return true
|
||||
}
|
||||
fmt.Fprintf(w, `{"status":"ok"}`)
|
||||
return true
|
||||
}
|
||||
if err := deleteSnapshot(snapshotName); err != nil {
|
||||
jsonResponseError(w, err)
|
||||
snapshotsDeleteErrorsTotal.Inc()
|
||||
return true
|
||||
}
|
||||
|
||||
err := fmt.Errorf("cannot find snapshot %q", snapshotName)
|
||||
jsonResponseError(w, err)
|
||||
fmt.Fprintf(w, `{"status":"ok"}`)
|
||||
return true
|
||||
case "/delete_all":
|
||||
snapshotsDeleteAllTotal.Inc()
|
||||
@@ -449,6 +451,19 @@ func RequestHandler(w http.ResponseWriter, r *http.Request) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func deleteSnapshot(snapshotName string) error {
|
||||
snapshots := Storage.MustListSnapshots()
|
||||
for _, snName := range snapshots {
|
||||
if snName == snapshotName {
|
||||
if err := Storage.DeleteSnapshot(snName); err != nil {
|
||||
return fmt.Errorf("cannot delete snapshot %q: %w", snName, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("cannot find snapshot %q", snapshotName)
|
||||
}
|
||||
|
||||
func initStaleSnapshotsRemover(strg *storage.Storage) {
|
||||
staleSnapshotsRemoverCh = make(chan struct{})
|
||||
if snapshotsMaxAge.Duration() <= 0 {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM golang:1.25.5 AS build-web-stage
|
||||
FROM golang:1.25.6 AS build-web-stage
|
||||
COPY build /build
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
@@ -14,14 +14,6 @@ vmui-build: copy-metricsql-docs vmui-package-base-image
|
||||
--entrypoint=/bin/bash \
|
||||
vmui-builder-image -c "npm install && npm run build"
|
||||
|
||||
vmui-anomaly-build: vmui-package-base-image
|
||||
docker run --rm \
|
||||
--user $(shell id -u):$(shell id -g) \
|
||||
--mount type=bind,src="$(shell pwd)/app/vmui",dst=/build \
|
||||
-w /build/packages/vmui \
|
||||
--entrypoint=/bin/bash \
|
||||
vmui-builder-image -c "npm install && npm run build:anomaly"
|
||||
|
||||
vmui-release: vmui-build
|
||||
docker build -t ${DOCKER_NAMESPACE}/vmui:latest -f app/vmui/Dockerfile-web ./app/vmui/packages/vmui
|
||||
docker tag ${DOCKER_NAMESPACE}/vmui:latest ${DOCKER_NAMESPACE}/vmui:${PKG_TAG}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
VITE_APP_TYPE=vmanomaly
|
||||
@@ -1,23 +0,0 @@
|
||||
import { readFile } from "fs/promises";
|
||||
import { IndexHtmlTransform } from "vite";
|
||||
|
||||
/**
|
||||
* Vite plugin to dynamically load index.html based on the current mode.
|
||||
* If a specific mode-based index file (e.g., index.vmanomaly.html) exists, it is used.
|
||||
* Otherwise, the default index.html is loaded.
|
||||
*/
|
||||
export default function dynamicIndexHtmlPlugin({ mode }) {
|
||||
return {
|
||||
name: "vm-dynamic-index-html",
|
||||
transformIndexHtml: {
|
||||
order: "pre",
|
||||
handler: async () => {
|
||||
try {
|
||||
return await readFile(`./index.${mode}.html`, "utf8");
|
||||
} catch (error) {
|
||||
return await readFile("./index.html", "utf8");
|
||||
}
|
||||
}
|
||||
} as IndexHtmlTransform
|
||||
};
|
||||
}
|
||||
@@ -46,7 +46,7 @@ export default [...compat.extends(
|
||||
settings: {
|
||||
react: {
|
||||
pragma: "React",
|
||||
version: "detect",
|
||||
version: "19.0",
|
||||
},
|
||||
|
||||
linkComponents: ["Hyperlink", {
|
||||
@@ -69,10 +69,11 @@ export default [...compat.extends(
|
||||
"varsIgnorePattern": "^_",
|
||||
"ignoreRestSiblings": true
|
||||
}],
|
||||
|
||||
|
||||
"unused-imports/no-unused-imports": "error",
|
||||
|
||||
"react/jsx-closing-bracket-location": [1, "line-aligned"],
|
||||
"object-curly-spacing": [2, "always"],
|
||||
|
||||
"react/jsx-max-props-per-line": [1, {
|
||||
maximum: 1,
|
||||
@@ -81,13 +82,23 @@ export default [...compat.extends(
|
||||
"react/jsx-first-prop-new-line": [1, "multiline"],
|
||||
|
||||
// Disable core indent rule due to recursion issues in ESLint 9; use JSX-specific rules instead
|
||||
indent: "off",
|
||||
indent: ["error", 2, {
|
||||
SwitchCase: 1,
|
||||
ignoredNodes: [
|
||||
"JSXElement",
|
||||
"JSXElement *",
|
||||
"JSXFragment",
|
||||
"JSXFragment *",
|
||||
],
|
||||
}],
|
||||
"react/jsx-indent": ["error", 2],
|
||||
"react/jsx-indent-props": ["error", 2],
|
||||
|
||||
"linebreak-style": ["error", "unix"],
|
||||
quotes: ["error", "double"],
|
||||
semi: ["error", "always"],
|
||||
// Formatting rules moved out of ESLint core; omit here to avoid deprecation noise
|
||||
"react/prop-types": 0,
|
||||
"react/react-in-jsx-scope": "off",
|
||||
|
||||
},
|
||||
}];
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<link rel="icon" href="/favicon.svg" />
|
||||
<link rel="apple-touch-icon" href="/favicon.svg" />
|
||||
<link rel="mask-icon" href="/favicon.svg" color="#000000">
|
||||
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=5"/>
|
||||
<meta name="theme-color" content="#000000"/>
|
||||
<meta name="description" content="Detect anomalies in your metrics with VictoriaMetrics Anomaly Detection UI"/>
|
||||
<!--
|
||||
manifest.json provides metadata used when your web app is installed on a
|
||||
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||
-->
|
||||
<link rel="manifest" href="/manifest.json" crossorigin="use-credentials"/>
|
||||
<!--
|
||||
Notice the use of in the tags above.
|
||||
It will be replaced with the URL of the `public` folder during the build.
|
||||
Only files inside the `public` folder can be referenced from the HTML.
|
||||
|
||||
Unlike "/favicon.ico" or "favicon.ico", "/favicon.ico" will
|
||||
work correctly both with client-side routing and a non-root public URL.
|
||||
Learn how to configure a non-root public URL by running `npm run build`.
|
||||
-->
|
||||
<title>UI for VictoriaMetrics Anomaly Detection</title>
|
||||
|
||||
<meta name="twitter:card" content="summary">
|
||||
<meta name="twitter:title" content="UI for VictoriaMetrics Anomaly Detection">
|
||||
<meta name="twitter:site" content="@https://victoriametrics.com/products/enterprise/anomaly-detection/">
|
||||
<meta name="twitter:description" content="Detect anomalies in your metrics with VictoriaMetrics Anomaly Detection UI">
|
||||
<meta name="twitter:image" content="/preview.jpg">
|
||||
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:title" content="UI for VictoriaMetrics Anomaly Detection">
|
||||
<meta property="og:url" content="https://victoriametrics.com/products/enterprise/anomaly-detection/">
|
||||
<meta property="og:description" content="Detect anomalies in your metrics with VictoriaMetrics Anomaly Detection UI">
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<!--
|
||||
This HTML file is a template.
|
||||
If you open it directly in the browser, you will see an empty page.
|
||||
|
||||
You can add webfonts, meta tags, or analytics to this file.
|
||||
The build step will place the bundled scripts into the <body> tag.
|
||||
|
||||
To begin the development, run `npm start` or `yarn start`.
|
||||
To create a production bundle, use `npm run build` or `yarn build`.
|
||||
-->
|
||||
<script type="module" src="/src/index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
1491
app/vmui/packages/vmui/package-lock.json
generated
1491
app/vmui/packages/vmui/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -7,10 +7,8 @@
|
||||
"scripts": {
|
||||
"prestart": "npm run copy-metricsql-docs",
|
||||
"start": "vite",
|
||||
"start:playground": "cross-env PLAYGROUND=METRICS npm run start",
|
||||
"start:anomaly": "vite --mode vmanomaly",
|
||||
"start:playground": "cross-env PLAYGROUND=true npm run start",
|
||||
"build": "vite build",
|
||||
"build:anomaly": "vite build --mode vmanomaly",
|
||||
"lint": "eslint --output-file vmui-lint-report.json --format json 'src/**/*.{ts,tsx}'",
|
||||
"lint:local": "eslint --ext .ts,.tsx -f stylish src",
|
||||
"lint:fix": "eslint 'src/**/*.{ts,tsx}' --fix",
|
||||
@@ -18,47 +16,48 @@
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:dev": "vitest"
|
||||
"test:dev": "vitest",
|
||||
"precommit": "npm run lint:local && npm run typecheck && npm run test"
|
||||
},
|
||||
"dependencies": {
|
||||
"classnames": "^2.5.1",
|
||||
"dayjs": "^1.11.13",
|
||||
"dayjs": "^1.11.19",
|
||||
"lodash.debounce": "^4.0.8",
|
||||
"marked": "^16.0.0",
|
||||
"preact": "^10.26.9",
|
||||
"qs": "^6.14.0",
|
||||
"marked": "^17.0.1",
|
||||
"preact": "^10.28.2",
|
||||
"qs": "^6.14.1",
|
||||
"react-input-mask": "^2.0.4",
|
||||
"react-router-dom": "^7.6.3",
|
||||
"react-router-dom": "^7.12.0",
|
||||
"uplot": "^1.6.32",
|
||||
"vite": "^7.1.11",
|
||||
"web-vitals": "^5.0.3"
|
||||
"vite": "^7.3.1",
|
||||
"web-vitals": "^5.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.3.1",
|
||||
"@eslint/js": "^9.30.1",
|
||||
"@eslint/eslintrc": "^3.3.3",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@preact/preset-vite": "^2.10.2",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/preact": "^3.2.4",
|
||||
"@types/lodash.debounce": "^4.0.9",
|
||||
"@types/node": "^24.0.12",
|
||||
"@types/node": "^25.0.8",
|
||||
"@types/qs": "^6.14.0",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react": "^19.2.8",
|
||||
"@types/react-input-mask": "^3.0.6",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"@typescript-eslint/eslint-plugin": "^8.36.0",
|
||||
"@typescript-eslint/parser": "^8.36.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^9.30.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.53.0",
|
||||
"@typescript-eslint/parser": "^8.53.0",
|
||||
"cross-env": "^10.1.0",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-unused-imports": "^4.1.4",
|
||||
"globals": "^16.3.0",
|
||||
"eslint-plugin-unused-imports": "^4.3.0",
|
||||
"globals": "^17.0.0",
|
||||
"http-proxy-middleware": "^3.0.5",
|
||||
"jsdom": "^26.1.0",
|
||||
"jsdom": "^27.4.0",
|
||||
"postcss": "^8.5.6",
|
||||
"rollup-plugin-visualizer": "^6.0.3",
|
||||
"sass-embedded": "^1.89.2",
|
||||
"typescript": "^5.8.3",
|
||||
"vitest": "^3.2.4"
|
||||
"rollup-plugin-visualizer": "^6.0.5",
|
||||
"sass-embedded": "^1.97.2",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.0.17"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import { FC, useState } from "preact/compat";
|
||||
import { HashRouter, Route, Routes } from "react-router-dom";
|
||||
import AppContextProvider from "./contexts/AppContextProvider";
|
||||
import ThemeProvider from "./components/Main/ThemeProvider/ThemeProvider";
|
||||
import AnomalyLayout from "./layouts/AnomalyLayout/AnomalyLayout";
|
||||
import ExploreAnomaly from "./pages/ExploreAnomaly/ExploreAnomaly";
|
||||
import router from "./router";
|
||||
import CustomPanel from "./pages/CustomPanel";
|
||||
|
||||
const AppAnomaly: FC = () => {
|
||||
const [loadedTheme, setLoadedTheme] = useState(false);
|
||||
|
||||
return <>
|
||||
<HashRouter>
|
||||
<AppContextProvider>
|
||||
<>
|
||||
<ThemeProvider onLoaded={setLoadedTheme}/>
|
||||
{loadedTheme && (
|
||||
<Routes>
|
||||
<Route
|
||||
path={"/"}
|
||||
element={<AnomalyLayout/>}
|
||||
>
|
||||
<Route
|
||||
path={"/"}
|
||||
element={<ExploreAnomaly/>}
|
||||
/>
|
||||
<Route
|
||||
path={router.query}
|
||||
element={<CustomPanel/>}
|
||||
/>
|
||||
</Route>
|
||||
</Routes>
|
||||
)}
|
||||
</>
|
||||
</AppContextProvider>
|
||||
</HashRouter>
|
||||
</>;
|
||||
};
|
||||
|
||||
export default AppAnomaly;
|
||||
@@ -14,12 +14,11 @@ export type QueryGroup = {
|
||||
interface LegendProps {
|
||||
labels: LegendItemType[];
|
||||
query: string[];
|
||||
isAnomalyView?: boolean;
|
||||
isPredefinedPanel?: boolean;
|
||||
onChange: (item: LegendItemType, metaKey: boolean) => void;
|
||||
}
|
||||
|
||||
const Legend: FC<LegendProps> = ({ labels, query, isAnomalyView, isPredefinedPanel, onChange }) => {
|
||||
const Legend: FC<LegendProps> = ({ labels, query, isPredefinedPanel, onChange }) => {
|
||||
const { groupByLabel } = useLegendGroup();
|
||||
const groupSeries = useGroupSeries({ labels, query, groupByLabel });
|
||||
|
||||
@@ -33,7 +32,6 @@ const Legend: FC<LegendProps> = ({ labels, query, isAnomalyView, isPredefinedPan
|
||||
key={group}
|
||||
labels={items}
|
||||
group={group}
|
||||
isAnomalyView={isAnomalyView}
|
||||
onChange={onChange}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -13,7 +13,6 @@ import { getFromStorage } from "../../../../utils/storage";
|
||||
|
||||
export type LegendProps = {
|
||||
labels: LegendItemType[];
|
||||
isAnomalyView?: boolean;
|
||||
duplicateFields?: string[];
|
||||
onChange: (item: LegendItemType, metaKey: boolean) => void;
|
||||
}
|
||||
@@ -22,7 +21,7 @@ interface LegendGroupProps extends LegendProps {
|
||||
group: string | number;
|
||||
}
|
||||
|
||||
const LegendGroup: FC<LegendGroupProps> = ({ labels, group, isAnomalyView, onChange }) => {
|
||||
const LegendGroup: FC<LegendGroupProps> = ({ labels, group, onChange }) => {
|
||||
const { isTableView } = useLegendView();
|
||||
const { groupByLabel } = useLegendGroup();
|
||||
const copyToClipboard = useCopyToClipboard();
|
||||
@@ -39,14 +38,14 @@ const LegendGroup: FC<LegendGroupProps> = ({ labels, group, isAnomalyView, onCha
|
||||
|
||||
const Content = isTableView ? LegendTable : LegendLines;
|
||||
|
||||
const disableAutoCollapse = getFromStorage("LEGEND_AUTO_COLLAPSE") === "false"
|
||||
const defaultExpanded = disableAutoCollapse ? true : sortedLabels.length <= LEGEND_COLLAPSE_SERIES_LIMIT
|
||||
const disableAutoCollapse = getFromStorage("LEGEND_AUTO_COLLAPSE") === "false";
|
||||
const defaultExpanded = disableAutoCollapse ? true : sortedLabels.length <= LEGEND_COLLAPSE_SERIES_LIMIT;
|
||||
|
||||
const expandedWarning = (
|
||||
<span className="vm-legend-group-header__warning">
|
||||
Legend collapsed by default ({sortedLabels.length} series) — click to expand.
|
||||
</span>
|
||||
)
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -81,7 +80,6 @@ const LegendGroup: FC<LegendGroupProps> = ({ labels, group, isAnomalyView, onCha
|
||||
>
|
||||
<Content
|
||||
labels={sortedLabels}
|
||||
isAnomalyView={isAnomalyView}
|
||||
duplicateFields={duplicateFields}
|
||||
onChange={onChange}
|
||||
/>
|
||||
|
||||
@@ -13,11 +13,10 @@ import { getLabelAlias } from "../../../../../utils/metric";
|
||||
interface LegendItemProps {
|
||||
legend: LegendItemType;
|
||||
onChange?: (item: LegendItemType, metaKey: boolean) => void;
|
||||
isAnomalyView?: boolean;
|
||||
duplicateFields?: string[];
|
||||
}
|
||||
|
||||
const LegendItem: FC<LegendItemProps> = ({ legend, onChange, duplicateFields, isAnomalyView }) => {
|
||||
const LegendItem: FC<LegendItemProps> = ({ legend, onChange, duplicateFields }) => {
|
||||
const copyToClipboard = useCopyToClipboard();
|
||||
const { hideStats } = useShowStats();
|
||||
|
||||
@@ -52,12 +51,10 @@ const LegendItem: FC<LegendItemProps> = ({ legend, onChange, duplicateFields, is
|
||||
})}
|
||||
onClick={createHandlerClick(legend)}
|
||||
>
|
||||
{!isAnomalyView && (
|
||||
<div
|
||||
className="vm-legend-item__marker"
|
||||
style={{ backgroundColor: legend.color }}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className="vm-legend-item__marker"
|
||||
style={{ backgroundColor: legend.color }}
|
||||
/>
|
||||
<div className="vm-legend-item-info">
|
||||
<span className="vm-legend-item-info__label">
|
||||
{legend.hasAlias && legend.label}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { FC } from "preact/compat";
|
||||
import LegendItem from "../LegendItem/LegendItem";
|
||||
import { LegendProps } from "../LegendGroup";
|
||||
|
||||
const LegendLines: FC<LegendProps> = ({ labels, isAnomalyView, duplicateFields, onChange }) => {
|
||||
const LegendLines: FC<LegendProps> = ({ labels, duplicateFields, onChange }) => {
|
||||
|
||||
return (
|
||||
<div className="vm-legend-item-container">
|
||||
@@ -10,7 +10,6 @@ const LegendLines: FC<LegendProps> = ({ labels, isAnomalyView, duplicateFields,
|
||||
<LegendItem
|
||||
key={legendItem.label}
|
||||
legend={legendItem}
|
||||
isAnomalyView={isAnomalyView}
|
||||
duplicateFields={duplicateFields}
|
||||
onChange={onChange}
|
||||
/>
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
import { FC, useMemo } from "preact/compat";
|
||||
import { ForecastType, SeriesItem } from "../../../../types";
|
||||
import { anomalyColors } from "../../../../utils/color";
|
||||
import "./style.scss";
|
||||
|
||||
type Props = {
|
||||
series: SeriesItem[];
|
||||
};
|
||||
|
||||
const titles: Partial<Record<ForecastType, string>> = {
|
||||
[ForecastType.yhat]: "yhat",
|
||||
[ForecastType.yhatLower]: "yhat_upper - yhat_lower",
|
||||
[ForecastType.yhatUpper]: "yhat_upper - yhat_lower",
|
||||
[ForecastType.anomaly]: "anomalies",
|
||||
[ForecastType.training]: "training data",
|
||||
[ForecastType.actual]: "y"
|
||||
};
|
||||
|
||||
const LegendAnomaly: FC<Props> = ({ series }) => {
|
||||
|
||||
const uniqSeriesStyles = useMemo(() => {
|
||||
const uniqSeries = series.reduce((accumulator, currentSeries) => {
|
||||
const hasForecast = Object.prototype.hasOwnProperty.call(currentSeries, "forecast");
|
||||
const isNotUpper = currentSeries.forecast !== ForecastType.yhatUpper;
|
||||
const isUniqForecast = !accumulator.find(s => s.forecast === currentSeries.forecast);
|
||||
if (hasForecast && isUniqForecast && isNotUpper) {
|
||||
accumulator.push(currentSeries);
|
||||
}
|
||||
return accumulator;
|
||||
}, [] as SeriesItem[]);
|
||||
|
||||
const trainingSeries = {
|
||||
...uniqSeries[0],
|
||||
forecast: ForecastType.training,
|
||||
color: anomalyColors[ForecastType.training],
|
||||
};
|
||||
uniqSeries.splice(1, 0, trainingSeries);
|
||||
|
||||
return uniqSeries.map(s => ({
|
||||
...s,
|
||||
color: typeof s.stroke === "string" ? s.stroke : anomalyColors[s.forecast || ForecastType.actual],
|
||||
}));
|
||||
}, [series]);
|
||||
|
||||
return <>
|
||||
<div className="vm-legend-anomaly">
|
||||
{/* TODO: remove .filter() after the correct training data has been added */}
|
||||
{uniqSeriesStyles.filter(f => f.forecast !== ForecastType.training).map((s, i) => (
|
||||
<div
|
||||
key={`${i}_${s.forecast}`}
|
||||
className="vm-legend-anomaly-item"
|
||||
>
|
||||
<svg>
|
||||
{s.forecast === ForecastType.anomaly ? (
|
||||
<circle
|
||||
cx="15"
|
||||
cy="7"
|
||||
r="4"
|
||||
fill={s.color}
|
||||
stroke={s.color}
|
||||
strokeWidth="1.4"
|
||||
/>
|
||||
) : (
|
||||
<line
|
||||
x1="0"
|
||||
y1="7"
|
||||
x2="30"
|
||||
y2="7"
|
||||
stroke={s.color}
|
||||
strokeWidth={s.width || 1}
|
||||
strokeDasharray={s.dash?.join(",")}
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
<div className="vm-legend-anomaly-item__title">{titles[s.forecast || ForecastType.actual]}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>;
|
||||
};
|
||||
|
||||
export default LegendAnomaly;
|
||||
@@ -1,23 +0,0 @@
|
||||
@use "src/styles/variables" as *;
|
||||
|
||||
.vm-legend-anomaly {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: calc($padding-large * 2);
|
||||
cursor: default;
|
||||
|
||||
&-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: $padding-small;
|
||||
|
||||
svg {
|
||||
width: 30px;
|
||||
height: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
getRangeY,
|
||||
getScales,
|
||||
handleDestroy,
|
||||
setBand,
|
||||
setSelect
|
||||
} from "../../../../utils/uplot";
|
||||
import { MetricResult } from "../../../../api/types";
|
||||
@@ -40,7 +39,6 @@ export interface LineChartProps {
|
||||
setPeriod: ({ from, to }: { from: Date, to: Date }) => void;
|
||||
layoutSize: ElementSize;
|
||||
height?: number;
|
||||
isAnomalyView?: boolean;
|
||||
spanGaps?: boolean;
|
||||
showAllPoints?: boolean;
|
||||
}
|
||||
@@ -55,7 +53,6 @@ const LineChart: FC<LineChartProps> = ({
|
||||
setPeriod,
|
||||
layoutSize,
|
||||
height,
|
||||
isAnomalyView,
|
||||
spanGaps = false,
|
||||
showAllPoints = false,
|
||||
}) => {
|
||||
@@ -75,7 +72,7 @@ const LineChart: FC<LineChartProps> = ({
|
||||
seriesFocus,
|
||||
setCursor,
|
||||
resetTooltips
|
||||
} = useLineTooltip({ u: uPlotInst, metrics, series, unit, isAnomalyView });
|
||||
} = useLineTooltip({ u: uPlotInst, metrics, series, unit });
|
||||
|
||||
const options: uPlotOptions = {
|
||||
...getDefaultOptions({ width: layoutSize.width, height }),
|
||||
@@ -111,7 +108,6 @@ const LineChart: FC<LineChartProps> = ({
|
||||
if (!uPlotInst) return;
|
||||
delSeries(uPlotInst);
|
||||
addSeries(uPlotInst, series, spanGaps, showAllPoints);
|
||||
setBand(uPlotInst, series);
|
||||
uPlotInst.redraw();
|
||||
}, [series, spanGaps, showAllPoints]);
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ const LimitsConfigurator = forwardRef<ChildComponentHandle, ServerConfiguratorPr
|
||||
const { seriesLimits } = useCustomPanelState();
|
||||
const customPanelDispatch = useCustomPanelDispatch();
|
||||
|
||||
const storageCollapse = getFromStorage("LEGEND_AUTO_COLLAPSE")
|
||||
const storageCollapse = getFromStorage("LEGEND_AUTO_COLLAPSE");
|
||||
const [legendCollapse, setLegendCollapse] = useState(storageCollapse ? storageCollapse === "true" : true);
|
||||
|
||||
const [limits, setLimits] = useState(seriesLimits);
|
||||
@@ -58,7 +58,7 @@ const LimitsConfigurator = forwardRef<ChildComponentHandle, ServerConfiguratorPr
|
||||
}, [limits]);
|
||||
|
||||
useEffect(() => {
|
||||
saveToStorage("LEGEND_AUTO_COLLAPSE", `${legendCollapse}`)
|
||||
saveToStorage("LEGEND_AUTO_COLLAPSE", `${legendCollapse}`);
|
||||
}, [legendCollapse]);
|
||||
|
||||
useImperativeHandle(ref, () => ({ handleApply }), [handleApply]);
|
||||
|
||||
@@ -9,7 +9,6 @@ import { getFromStorage, removeFromStorage, saveToStorage } from "../../../../ut
|
||||
import useBoolean from "../../../../hooks/useBoolean";
|
||||
import { ChildComponentHandle } from "../GlobalSettings";
|
||||
import { useAppDispatch, useAppState } from "../../../../state/common/StateContext";
|
||||
import { getTenantIdFromUrl } from "../../../../utils/tenants";
|
||||
|
||||
interface ServerConfiguratorProps {
|
||||
onClose: () => void;
|
||||
@@ -39,10 +38,6 @@ const ServerConfigurator = forwardRef<ChildComponentHandle, ServerConfiguratorPr
|
||||
};
|
||||
|
||||
const handleApply = useCallback(() => {
|
||||
const tenantIdFromUrl = getTenantIdFromUrl(serverUrl);
|
||||
if (tenantIdFromUrl !== "") {
|
||||
dispatch({ type: "SET_TENANT_ID", payload: tenantIdFromUrl });
|
||||
}
|
||||
dispatch({ type: "SET_SERVER", payload: serverUrl });
|
||||
onClose();
|
||||
}, [serverUrl]);
|
||||
@@ -60,12 +55,6 @@ const ServerConfigurator = forwardRef<ChildComponentHandle, ServerConfiguratorPr
|
||||
}
|
||||
}, [enabledStorage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (enabledStorage) {
|
||||
saveToStorage("SERVER_URL", serverUrl);
|
||||
}
|
||||
}, [serverUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
// the tenant selector can change the serverUrl
|
||||
if (stateServerUrl === serverUrl) return;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FC, useState, useRef, useEffect, useMemo } from "preact/compat";
|
||||
import { FC, useState, useRef, useMemo } from "preact/compat";
|
||||
import { useAppDispatch, useAppState } from "../../../../state/common/StateContext";
|
||||
import { useTimeDispatch } from "../../../../state/time/TimeStateContext";
|
||||
import { ArrowDownIcon, StorageIcon } from "../../../Main/Icons";
|
||||
@@ -10,14 +10,14 @@ import { getAppModeEnable } from "../../../../utils/app-mode";
|
||||
import Tooltip from "../../../Main/Tooltip/Tooltip";
|
||||
import useDeviceDetect from "../../../../hooks/useDeviceDetect";
|
||||
import TextField from "../../../Main/TextField/TextField";
|
||||
import { getTenantIdFromUrl, replaceTenantId } from "../../../../utils/tenants";
|
||||
import { replaceTenantId } from "../../../../utils/tenants";
|
||||
import useBoolean from "../../../../hooks/useBoolean";
|
||||
|
||||
const TenantsConfiguration: FC<{accountIds: string[]}> = ({ accountIds }) => {
|
||||
const appModeEnable = getAppModeEnable();
|
||||
const { isMobile } = useDeviceDetect();
|
||||
|
||||
const { tenantId: tenantIdState, serverUrl } = useAppState();
|
||||
const { tenantId, serverUrl } = useAppState();
|
||||
const dispatch = useAppDispatch();
|
||||
const timeDispatch = useTimeDispatch();
|
||||
|
||||
@@ -48,10 +48,8 @@ const TenantsConfiguration: FC<{accountIds: string[]}> = ({ accountIds }) => {
|
||||
}, [accountIds]);
|
||||
|
||||
const createHandlerChange = (value: string) => () => {
|
||||
const tenant = value;
|
||||
dispatch({ type: "SET_TENANT_ID", payload: tenant });
|
||||
if (serverUrl) {
|
||||
const updateServerUrl = replaceTenantId(serverUrl, tenant);
|
||||
const updateServerUrl = replaceTenantId(serverUrl, value);
|
||||
if (updateServerUrl === serverUrl) return;
|
||||
dispatch({ type: "SET_SERVER", payload: updateServerUrl });
|
||||
timeDispatch({ type: "RUN_QUERY" });
|
||||
@@ -59,16 +57,6 @@ const TenantsConfiguration: FC<{accountIds: string[]}> = ({ accountIds }) => {
|
||||
handleCloseOptions();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const id = getTenantIdFromUrl(serverUrl);
|
||||
|
||||
if (tenantIdState && tenantIdState !== id) {
|
||||
createHandlerChange(tenantIdState)();
|
||||
} else {
|
||||
createHandlerChange(id)();
|
||||
}
|
||||
}, [serverUrl]);
|
||||
|
||||
if (!showTenantSelector) return null;
|
||||
|
||||
return (
|
||||
@@ -83,7 +71,7 @@ const TenantsConfiguration: FC<{accountIds: string[]}> = ({ accountIds }) => {
|
||||
<span className="vm-mobile-option__icon"><StorageIcon/></span>
|
||||
<div className="vm-mobile-option-text">
|
||||
<span className="vm-mobile-option-text__label">Tenant ID</span>
|
||||
<span className="vm-mobile-option-text__value">{tenantIdState}</span>
|
||||
<span className="vm-mobile-option-text__value">{tenantId}</span>
|
||||
</div>
|
||||
<span className="vm-mobile-option__arrow"><ArrowDownIcon/></span>
|
||||
</div>
|
||||
@@ -106,7 +94,7 @@ const TenantsConfiguration: FC<{accountIds: string[]}> = ({ accountIds }) => {
|
||||
)}
|
||||
onClick={toggleOpenOptions}
|
||||
>
|
||||
{tenantIdState}
|
||||
{tenantId}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -138,7 +126,7 @@ const TenantsConfiguration: FC<{accountIds: string[]}> = ({ accountIds }) => {
|
||||
className={classNames({
|
||||
"vm-list-item": true,
|
||||
"vm-list-item_mobile": isMobile,
|
||||
"vm-list-item_active": id === tenantIdState
|
||||
"vm-list-item_active": id === tenantId
|
||||
})}
|
||||
key={id}
|
||||
onClick={createHandlerChange(id)}
|
||||
|
||||
@@ -3,19 +3,18 @@ import { useEffect, useMemo, useState } from "preact/compat";
|
||||
import { ErrorTypes } from "../../../../../types";
|
||||
import { getAccountIds } from "../../../../../api/accountId";
|
||||
import { getAppModeEnable, getAppModeParams } from "../../../../../utils/app-mode";
|
||||
import { getTenantIdFromUrl } from "../../../../../utils/tenants";
|
||||
|
||||
export const useFetchAccountIds = () => {
|
||||
const { useTenantID } = getAppModeParams();
|
||||
const appModeEnable = getAppModeEnable();
|
||||
const { serverUrl } = useAppState();
|
||||
const { tenantId, serverUrl } = useAppState();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<ErrorTypes | string>();
|
||||
const [accountIds, setAccountIds] = useState<string[]>([]);
|
||||
|
||||
const fetchUrl = useMemo(() => getAccountIds(serverUrl), [serverUrl]);
|
||||
const isServerUrlWithTenant = useMemo(() => !!getTenantIdFromUrl(serverUrl), [serverUrl]);
|
||||
const isServerUrlWithTenant = useMemo(() => !!tenantId, [tenantId]);
|
||||
const preventFetch = appModeEnable ? !useTenantID : !isServerUrlWithTenant;
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -17,4 +17,4 @@ export const formatDuration = (raw: number) => {
|
||||
export const formatEventTime = (raw: string) => {
|
||||
const t = dayjs(raw);
|
||||
return t.year() <= 1 ? "Never" : t.format("DD MMM YYYY HH:mm:ss");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
import { FC, useState } from "preact/compat";
|
||||
import Button from "../Main/Button/Button";
|
||||
import TextField from "../Main/TextField/TextField";
|
||||
import Modal from "../Main/Modal/Modal";
|
||||
import Spinner from "../Main/Spinner/Spinner";
|
||||
import { DownloadIcon, ErrorIcon } from "../Main/Icons";
|
||||
import useBoolean from "../../hooks/useBoolean";
|
||||
import useDeviceDetect from "../../hooks/useDeviceDetect";
|
||||
import { useAppState } from "../../state/common/StateContext";
|
||||
import classNames from "classnames";
|
||||
import "./style.scss";
|
||||
import { useQueryState } from "../../state/query/QueryStateContext";
|
||||
import { useTimeState } from "../../state/time/TimeStateContext";
|
||||
import { getStepFromDuration } from "../../utils/time";
|
||||
|
||||
const AnomalyConfig: FC = () => {
|
||||
const { serverUrl } = useAppState();
|
||||
const { isMobile } = useDeviceDetect();
|
||||
|
||||
const {
|
||||
value: isModalOpen,
|
||||
setTrue: setOpenModal,
|
||||
setFalse: setCloseModal,
|
||||
} = useBoolean(false);
|
||||
|
||||
const { query } = useQueryState();
|
||||
const { period } = useTimeState();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [textConfig, setTextConfig] = useState<string>("");
|
||||
const [downloadUrl, setDownloadUrl] = useState<string>("");
|
||||
const [error, setError] = useState<string>("");
|
||||
|
||||
const fetchConfig = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const queryParam = encodeURIComponent(query[0] || "");
|
||||
const stepParam = encodeURIComponent(period.step || getStepFromDuration(period.end - period.start, false));
|
||||
|
||||
const url = `${serverUrl}/api/vmanomaly/config.yaml?query=${queryParam}&step=${stepParam}`;
|
||||
const response = await fetch(url);
|
||||
const contentType = response.headers.get("Content-Type");
|
||||
if (!response.ok) {
|
||||
const bodyText = await response.text();
|
||||
setError(` ${response.status} ${response.statusText}: ${bodyText}`);
|
||||
} else if (contentType == "application/yaml") {
|
||||
const blob = await response.blob();
|
||||
const yamlAsString = await blob.text();
|
||||
setTextConfig(yamlAsString);
|
||||
setDownloadUrl(URL.createObjectURL(blob));
|
||||
} else {
|
||||
setError("Response Content-Type is not YAML, does `Server URL` point to VMAnomaly server?");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setError(String(error));
|
||||
}
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const handleOpenModal = () => {
|
||||
setOpenModal();
|
||||
setError("");
|
||||
URL.revokeObjectURL(downloadUrl);
|
||||
setTextConfig("");
|
||||
setDownloadUrl("");
|
||||
return fetchConfig();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
color="secondary"
|
||||
variant="outlined"
|
||||
onClick={handleOpenModal}
|
||||
>
|
||||
Open Config
|
||||
</Button>
|
||||
{isModalOpen && (
|
||||
<Modal
|
||||
title="Download config"
|
||||
onClose={setCloseModal}
|
||||
>
|
||||
<div
|
||||
className={classNames({
|
||||
"vm-anomaly-config": true,
|
||||
"vm-anomaly-config_mobile": isMobile,
|
||||
})}
|
||||
>
|
||||
{isLoading && (
|
||||
<Spinner
|
||||
containerStyles={{ position: "relative" }}
|
||||
message={"Loading config..."}
|
||||
/>
|
||||
)}
|
||||
{!isLoading && error && (
|
||||
<div className="vm-anomaly-config-error">
|
||||
<div className="vm-anomaly-config-error__icon"><ErrorIcon/></div>
|
||||
<h3 className="vm-anomaly-config-error__title">Cannot download config</h3>
|
||||
<p className="vm-anomaly-config-error__text">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && textConfig && (
|
||||
<TextField
|
||||
value={textConfig}
|
||||
label={"config.yaml"}
|
||||
type="textarea"
|
||||
disabled={true}
|
||||
/>
|
||||
)}
|
||||
<div className="vm-anomaly-config-footer">
|
||||
{downloadUrl && (
|
||||
<a
|
||||
href={downloadUrl}
|
||||
download={"config.yaml"}
|
||||
>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<DownloadIcon/>}
|
||||
>
|
||||
download
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AnomalyConfig;
|
||||
@@ -1,61 +0,0 @@
|
||||
@use "src/styles/variables" as *;
|
||||
|
||||
.vm-anomaly-config {
|
||||
display: grid;
|
||||
grid-template-rows: calc(($vh * 70) - 78px - ($padding-medium*3)) auto;
|
||||
gap: $padding-global;
|
||||
min-width: 400px;
|
||||
max-width: 80vw;
|
||||
min-height: 300px;
|
||||
|
||||
&_mobile {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
min-height: 100%;
|
||||
grid-template-rows: calc(($vh * 100) - 78px - ($padding-global*3)) auto;
|
||||
}
|
||||
|
||||
textarea {
|
||||
overflow: auto;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-height: 900px;
|
||||
}
|
||||
|
||||
&-error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
gap: $padding-small;
|
||||
text-align: center;
|
||||
|
||||
&__icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
margin-bottom: $padding-small;
|
||||
color: $color-error;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: $font-size-medium;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
&__text {
|
||||
max-width: 700px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
}
|
||||
|
||||
&-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: $padding-small;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
@use "src/styles/variables" as *;
|
||||
@use 'sass:meta';
|
||||
|
||||
$button-radius: 6px;
|
||||
|
||||
@@ -42,6 +43,8 @@ $button-radius: 6px;
|
||||
|
||||
svg {
|
||||
width: 14px;
|
||||
min-width: 14px;
|
||||
max-width: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +54,8 @@ $button-radius: 6px;
|
||||
|
||||
svg {
|
||||
width: 16px;
|
||||
min-width: 16px;
|
||||
max-width: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +65,8 @@ $button-radius: 6px;
|
||||
|
||||
svg {
|
||||
width: 18px;
|
||||
min-width: 18px;
|
||||
max-width: 18px;
|
||||
line-height: 16px;
|
||||
}
|
||||
}
|
||||
@@ -128,8 +135,14 @@ $button-radius: 6px;
|
||||
);
|
||||
|
||||
@each $name, $color in $button-colors {
|
||||
@include contained-button($name, $color, if($name == white, $color-black, $color-white));
|
||||
@include outlined-button($name, $color, if($name == white, $color-white, $color));
|
||||
@include text-button($name, if($name == white, $color-white, $color));
|
||||
@if $name == white {
|
||||
@include contained-button($name, $color, $color-black);
|
||||
@include outlined-button($name, $color, $color-white);
|
||||
@include text-button($name, $color-white);
|
||||
} @else {
|
||||
@include contained-button($name, $color, $color-white);
|
||||
@include outlined-button($name, $color, $color);
|
||||
@include text-button($name, $color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,4 +1,8 @@
|
||||
import { getFromStorage, removeFromStorage, saveToStorage, StorageKeys } from "../../utils/storage";
|
||||
import {
|
||||
getFromStorage,
|
||||
saveToStorage,
|
||||
StorageKeys,
|
||||
} from "../../utils/storage";
|
||||
import { QueryHistoryType } from "../../state/query/reducer";
|
||||
import { MAX_QUERIES_HISTORY, MAX_QUERY_FIELDS } from "../../constants/graph";
|
||||
|
||||
@@ -73,17 +77,3 @@ export const getUpdatedHistory = (query: string, queryHistory?: QueryHistoryType
|
||||
values: newValues
|
||||
};
|
||||
};
|
||||
|
||||
const migrateMetricsQueryHistoryToHistoryByKey = () => {
|
||||
const migrateHistory = (type: HistoryType) => {
|
||||
const queryList = getFromStorage(type) as string;
|
||||
if (queryList) {
|
||||
const queryHistory: string[][] = JSON.parse(queryList);
|
||||
saveHistoryToStorage("METRICS_QUERY_HISTORY", type, queryHistory);
|
||||
removeFromStorage([type]);
|
||||
}
|
||||
};
|
||||
migrateHistory("QUERY_HISTORY");
|
||||
migrateHistory("QUERY_FAVORITES");
|
||||
};
|
||||
migrateMetricsQueryHistoryToHistoryByKey();
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
getMinMaxBuffer,
|
||||
getTimeSeries,
|
||||
} from "../../../utils/uplot";
|
||||
import { TimeParams, SeriesItem, LegendItemType } from "../../../types";
|
||||
import { TimeParams, LegendItemType } from "../../../types";
|
||||
import { AxisRange, YaxisState } from "../../../state/graph/reducer";
|
||||
import { getMathStats } from "../../../utils/math";
|
||||
import classNames from "classnames";
|
||||
@@ -23,8 +23,6 @@ import { promValueToNumber } from "../../../utils/metric";
|
||||
import useDeviceDetect from "../../../hooks/useDeviceDetect";
|
||||
import useElementSize from "../../../hooks/useElementSize";
|
||||
import { ChartTooltipProps } from "../../Chart/ChartTooltip/ChartTooltip";
|
||||
import LegendAnomaly from "../../Chart/Line/LegendAnomaly/LegendAnomaly";
|
||||
import { groupByMultipleKeys } from "../../../utils/array";
|
||||
import { useGraphDispatch } from "../../../state/graph/GraphStateContext";
|
||||
import { sameTs } from "../../../utils/time";
|
||||
import { useLocation } from "react-router-dom";
|
||||
@@ -44,7 +42,6 @@ export interface GraphViewProps {
|
||||
fullWidth?: boolean;
|
||||
height?: number;
|
||||
isHistogram?: boolean;
|
||||
isAnomalyView?: boolean;
|
||||
isPredefinedPanel?: boolean;
|
||||
spanGaps?: boolean;
|
||||
showAllPoints?: boolean;
|
||||
@@ -64,7 +61,6 @@ const GraphView: FC<GraphViewProps> = ({
|
||||
fullWidth = true,
|
||||
height,
|
||||
isHistogram,
|
||||
isAnomalyView,
|
||||
isPredefinedPanel,
|
||||
spanGaps,
|
||||
showAllPoints
|
||||
@@ -89,8 +85,8 @@ const GraphView: FC<GraphViewProps> = ({
|
||||
const [legendValue, setLegendValue] = useState<ChartTooltipProps | null>(null);
|
||||
|
||||
const getSeriesItem = useMemo(() => {
|
||||
return getSeriesItemContext(data, hideSeries, alias, showAllPoints, isAnomalyView, isRawQuery);
|
||||
}, [data, hideSeries, alias, showAllPoints, isAnomalyView, isRawQuery]);
|
||||
return getSeriesItemContext(data, hideSeries, alias, showAllPoints, isRawQuery);
|
||||
}, [data, hideSeries, alias, showAllPoints, isRawQuery]);
|
||||
|
||||
const setLimitsYaxis = (minVal: number, maxVal: number) => {
|
||||
let min = Number.isFinite(minVal) ? minVal : 0;
|
||||
@@ -102,7 +98,7 @@ const GraphView: FC<GraphViewProps> = ({
|
||||
};
|
||||
|
||||
const onChangeLegend = (legend: LegendItemType, metaKey: boolean) => {
|
||||
setHideSeries(getHideSeries({ hideSeries, legend, metaKey, series, isAnomalyView }));
|
||||
setHideSeries(getHideSeries({ hideSeries, legend, metaKey, series }));
|
||||
};
|
||||
|
||||
const prepareHistogramData = (data: (number | null)[][]) => {
|
||||
@@ -127,20 +123,6 @@ const GraphView: FC<GraphViewProps> = ({
|
||||
return [null, [xs, ys, counts]];
|
||||
};
|
||||
|
||||
const prepareAnomalyLegend = (legend: LegendItemType[]): LegendItemType[] => {
|
||||
if (!isAnomalyView) return legend;
|
||||
|
||||
// For vmanomaly: Only select the first series per group (due to API specs) and clear __name__ in freeFormFields.
|
||||
const grouped = groupByMultipleKeys(legend, ["group", "label"]);
|
||||
return grouped.map((group) => {
|
||||
const firstEl = group.values[0];
|
||||
return {
|
||||
...firstEl,
|
||||
freeFormFields: { ...firstEl.freeFormFields, __name__: "" }
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const dLen = data.length;
|
||||
|
||||
@@ -155,7 +137,7 @@ const GraphView: FC<GraphViewProps> = ({
|
||||
|
||||
for (let i = 0; i < dLen; i++) {
|
||||
const d = data[i];
|
||||
const seriesItem = getSeriesItem(d, i);
|
||||
const seriesItem = getSeriesItem(d);
|
||||
tempSeries[i + 1] = seriesItem;
|
||||
tempLegend[i] = getLegendItem(seriesItem, d.group);
|
||||
|
||||
@@ -206,7 +188,7 @@ const GraphView: FC<GraphViewProps> = ({
|
||||
const avg = Math.abs(Number(avgRaw));
|
||||
const range = getMinMaxBuffer(min, max);
|
||||
const rangeStep = Math.abs(range[1] - range[0]);
|
||||
const needStabilize = (avg > rangeStep * 1e10) && !isAnomalyView;
|
||||
const needStabilize = (avg > rangeStep * 1e10);
|
||||
|
||||
return needStabilize ? results.fill(avg) : results;
|
||||
});
|
||||
@@ -214,13 +196,11 @@ const GraphView: FC<GraphViewProps> = ({
|
||||
timeDataSeries.unshift(timeSeries);
|
||||
|
||||
const result = isHistogram ? prepareHistogramData(timeDataSeries) : timeDataSeries;
|
||||
const legend = prepareAnomalyLegend(tempLegend);
|
||||
|
||||
setLimitsYaxis(minVal, maxVal);
|
||||
setDataChart(result as uPlotData);
|
||||
setSeries(tempSeries);
|
||||
setLegend(legend);
|
||||
isAnomalyView && setHideSeries(legend.map(s => s.label || "").slice(1));
|
||||
setLegend(tempLegend);
|
||||
}, [data, timezone, isHistogram, currentStep, isRawQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -232,13 +212,13 @@ const GraphView: FC<GraphViewProps> = ({
|
||||
|
||||
for (let i = 0; i < dLen; i++) {
|
||||
const d = data[i];
|
||||
const seriesItem = getSeriesItem(d, i);
|
||||
const seriesItem = getSeriesItem(d);
|
||||
tempSeries[i + 1] = seriesItem;
|
||||
tempLegend[i] = getLegendItem(seriesItem, d.group);
|
||||
}
|
||||
|
||||
setSeries(tempSeries);
|
||||
setLegend(prepareAnomalyLegend(tempLegend));
|
||||
setLegend(tempLegend);
|
||||
}, [hideSeries]);
|
||||
|
||||
const hasTimeData = dataChart[0]?.length > 0;
|
||||
@@ -281,7 +261,6 @@ const GraphView: FC<GraphViewProps> = ({
|
||||
setPeriod={setPeriod}
|
||||
layoutSize={containerSize}
|
||||
height={height}
|
||||
isAnomalyView={isAnomalyView}
|
||||
spanGaps={spanGaps}
|
||||
showAllPoints={isRawQuery ? true : showAllPoints}
|
||||
/>
|
||||
@@ -298,12 +277,10 @@ const GraphView: FC<GraphViewProps> = ({
|
||||
onChangeLegend={setLegendValue}
|
||||
/>
|
||||
)}
|
||||
{isAnomalyView && showLegend && (<LegendAnomaly series={series as SeriesItem[]}/>)}
|
||||
{!isHistogram && showLegend && (
|
||||
<Legend
|
||||
labels={legend}
|
||||
query={query}
|
||||
isAnomalyView={isAnomalyView}
|
||||
onChange={onChangeLegend}
|
||||
isPredefinedPanel={isPredefinedPanel}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useEffect } from "react";
|
||||
import { StorageErrorCode } from "./types";
|
||||
import { useSnack } from "../../contexts/Snackbar";
|
||||
import { storageErrorInfo } from "./storageErrors";
|
||||
import "./style.scss";
|
||||
|
||||
const classifyStorageException = (e: unknown): StorageErrorCode => {
|
||||
if (!(e instanceof DOMException)) return StorageErrorCode.UNKNOWN;
|
||||
|
||||
switch (e.name) {
|
||||
case "QuotaExceededError":
|
||||
return StorageErrorCode.QUOTA_EXCEEDED;
|
||||
case "SecurityError":
|
||||
return StorageErrorCode.SECURITY_ERROR;
|
||||
default:
|
||||
return StorageErrorCode.UNKNOWN;
|
||||
}
|
||||
};
|
||||
|
||||
const getStorageError = (storage: Storage | null | undefined): StorageErrorCode | null => {
|
||||
if (!storage) {
|
||||
return StorageErrorCode.NO_STORAGE;
|
||||
}
|
||||
|
||||
try {
|
||||
const key = "__vmui_test__";
|
||||
storage.setItem(key, "1");
|
||||
storage.removeItem(key);
|
||||
return null;
|
||||
} catch (e) {
|
||||
return classifyStorageException(e);
|
||||
}
|
||||
};
|
||||
|
||||
const WebStorageCheck = () => {
|
||||
const { showInfoMessage } = useSnack();
|
||||
|
||||
useEffect(() => {
|
||||
const error = getStorageError(window.localStorage);
|
||||
|
||||
if (error) {
|
||||
const { title, description, fix } = storageErrorInfo[error];
|
||||
|
||||
const text = (
|
||||
<div className="vm-storage-check">
|
||||
<h3>{title}</h3>
|
||||
<p>{description}</p>
|
||||
|
||||
{!!fix?.length && (
|
||||
<div className="vm-storage-check__fix">
|
||||
<div>Try this:</div>
|
||||
<ul>
|
||||
{fix.map((step, i) => (
|
||||
<li key={`${i}-${step}`}>{step}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
showInfoMessage({ text: text, type: "error", timeout: 600000 });
|
||||
}
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default WebStorageCheck;
|
||||
@@ -0,0 +1,47 @@
|
||||
import { StorageError, StorageErrorCode } from "./types";
|
||||
|
||||
export const storageErrorInfo: Record<StorageErrorCode, StorageError> = {
|
||||
[StorageErrorCode.NO_STORAGE]: {
|
||||
title: "Storage unavailable",
|
||||
description:
|
||||
"Browser storage is not available for this website.",
|
||||
fix: [
|
||||
"Disable Private/Incognito mode and reload the page.",
|
||||
"Disable privacy or ad-blocking extensions for this site and reload.",
|
||||
"Open the site in another browser.",
|
||||
],
|
||||
},
|
||||
|
||||
[StorageErrorCode.SECURITY_ERROR]: {
|
||||
title: "Storage access blocked",
|
||||
description:
|
||||
"Browser settings or an extension are blocking access to browser storage.",
|
||||
fix: [
|
||||
"Disable Private/Incognito mode and reload the page.",
|
||||
"Disable privacy or ad-blocking extensions for this site and reload.",
|
||||
"Open the site in a regular browser tab (not embedded).",
|
||||
],
|
||||
},
|
||||
|
||||
[StorageErrorCode.QUOTA_EXCEEDED]: {
|
||||
title: "Storage quota exceeded",
|
||||
description:
|
||||
"The storage limit for this website has been reached.",
|
||||
fix: [
|
||||
"Clear this website’s stored data and reload the page.",
|
||||
"Close other tabs for this website and try again.",
|
||||
"Use another browser or browser profile.",
|
||||
],
|
||||
},
|
||||
|
||||
[StorageErrorCode.UNKNOWN]: {
|
||||
title: "Storage error",
|
||||
description:
|
||||
"An unexpected error occurred while accessing browser storage.",
|
||||
fix: [
|
||||
"Reload the page.",
|
||||
"Update the browser and try again.",
|
||||
"Disable browser extensions and reload.",
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
@use "src/styles/variables" as *;
|
||||
|
||||
.vm-storage-check {
|
||||
h3 {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-bottom: $padding-global
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
|
||||
export enum StorageErrorCode {
|
||||
NO_STORAGE = "NO_STORAGE",
|
||||
SECURITY_ERROR = "SECURITY_ERROR",
|
||||
QUOTA_EXCEEDED = "QUOTA_EXCEEDED",
|
||||
UNKNOWN = "UNKNOWN",
|
||||
}
|
||||
|
||||
export type StorageError = {
|
||||
title: string;
|
||||
description: string;
|
||||
fix: string[]
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
export enum AppType {
|
||||
victoriametrics = "victoriametrics",
|
||||
vmanomaly = "vmanomaly",
|
||||
}
|
||||
|
||||
export const APP_TYPE = import.meta.env.VITE_APP_TYPE;
|
||||
export const APP_TYPE_VM = APP_TYPE === AppType.victoriametrics;
|
||||
export const APP_TYPE_ANOMALY = APP_TYPE === AppType.vmanomaly;
|
||||
@@ -13,10 +13,9 @@ interface LineTooltipHook {
|
||||
metrics: MetricResult[];
|
||||
series: uPlotSeries[];
|
||||
unit?: string;
|
||||
isAnomalyView?: boolean;
|
||||
}
|
||||
|
||||
const useLineTooltip = ({ u, metrics, series, unit, isAnomalyView }: LineTooltipHook) => {
|
||||
const useLineTooltip = ({ u, metrics, series, unit }: LineTooltipHook) => {
|
||||
const [showTooltip, setShowTooltip] = useState(false);
|
||||
const [tooltipIdx, setTooltipIdx] = useState({ seriesIdx: -1, dataIdx: -1 });
|
||||
const [stickyTooltips, setStickyToolTips] = useState<ChartTooltipProps[]>([]);
|
||||
@@ -79,7 +78,7 @@ const useLineTooltip = ({ u, metrics, series, unit, isAnomalyView }: LineTooltip
|
||||
point,
|
||||
u: u,
|
||||
id: `${seriesIdx}_${dataIdx}`,
|
||||
title: groups.size > 1 && !isAnomalyView ? `Query ${group}` : "",
|
||||
title: groups.size > 1 ? `Query ${group}` : "",
|
||||
dates: [date ? dayjs(date * 1000).tz().format(DATE_FULL_TIMEZONE_FORMAT) : "-"],
|
||||
value: formatPrettyNumber(value, min, max),
|
||||
info: getMetricName(metricItem, seriesItem),
|
||||
@@ -87,7 +86,7 @@ const useLineTooltip = ({ u, metrics, series, unit, isAnomalyView }: LineTooltip
|
||||
marker: `${seriesItem?.stroke}`,
|
||||
duplicateCount,
|
||||
};
|
||||
}, [u, tooltipIdx, metrics, series, unit, isAnomalyView]);
|
||||
}, [u, tooltipIdx, metrics, series, unit]);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (!showTooltip) return;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useAppDispatch, useAppState } from "../state/common/StateContext";
|
||||
import { useEffect, useState } from "preact/compat";
|
||||
import { ErrorTypes } from "../types";
|
||||
import { APP_TYPE_VM } from "../constants/appType";
|
||||
|
||||
const useFetchAppConfig = () => {
|
||||
const { serverUrl } = useAppState();
|
||||
@@ -12,7 +11,6 @@ const useFetchAppConfig = () => {
|
||||
|
||||
useEffect(() => {
|
||||
const fetchAppConfig = async () => {
|
||||
if (!APP_TYPE_VM) return;
|
||||
setError("");
|
||||
setIsLoading(true);
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useTimeDispatch } from "../state/time/TimeStateContext";
|
||||
import { getFromStorage } from "../utils/storage";
|
||||
import dayjs from "dayjs";
|
||||
import { getBrowserTimezone } from "../utils/time";
|
||||
import { APP_TYPE_VM } from "../constants/appType";
|
||||
|
||||
const disabledDefaultTimezone = Boolean(getFromStorage("DISABLED_DEFAULT_TIMEZONE"));
|
||||
|
||||
@@ -29,7 +28,7 @@ const useFetchDefaultTimezone = () => {
|
||||
};
|
||||
|
||||
const fetchDefaultTimezone = async () => {
|
||||
if (!serverUrl || !APP_TYPE_VM) return;
|
||||
if (!serverUrl) return;
|
||||
setError("");
|
||||
setIsLoading(true);
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ import { isHistogramData } from "../utils/metric";
|
||||
import { useGraphState } from "../state/graph/GraphStateContext";
|
||||
import { getStepFromDuration } from "../utils/time";
|
||||
import { getQueryStringValue } from "../utils/query-string";
|
||||
import { APP_TYPE_ANOMALY } from "../constants/appType";
|
||||
|
||||
interface FetchQueryParams {
|
||||
predefinedQuery?: string[]
|
||||
@@ -135,7 +134,7 @@ export const useFetchQuery = ({
|
||||
}
|
||||
|
||||
const preventChangeType = !!getQueryStringValue("display_mode", null);
|
||||
isHistogramResult = !APP_TYPE_ANOMALY && isDisplayChart && !preventChangeType && isHistogramData(resp.data.result);
|
||||
isHistogramResult = isDisplayChart && !preventChangeType && isHistogramData(resp.data.result);
|
||||
seriesLimit = isHistogramResult ? Infinity : defaultLimit;
|
||||
const freeTempSize = Math.max(0, seriesLimit - tempData.length);
|
||||
resp.data.result.slice(0, freeTempSize).forEach((d: MetricBase) => {
|
||||
|
||||
@@ -3,20 +3,9 @@ import "./constants/dayjsPlugins";
|
||||
import App from "./App";
|
||||
import reportWebVitals from "./reportWebVitals";
|
||||
import "./styles/style.scss";
|
||||
import { APP_TYPE, AppType } from "./constants/appType";
|
||||
import AppAnomaly from "./AppAnomaly";
|
||||
|
||||
const getAppComponent = () => {
|
||||
switch (APP_TYPE) {
|
||||
case AppType.vmanomaly:
|
||||
return <AppAnomaly/>;
|
||||
default:
|
||||
return <App/>;
|
||||
}
|
||||
};
|
||||
|
||||
const root = document.getElementById("root");
|
||||
if (root) render(getAppComponent(), root);
|
||||
if (root) render(<App/>, root);
|
||||
|
||||
|
||||
// If you want to start measuring performance in your app, pass a function
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
import Header from "../Header/Header";
|
||||
import { FC, useEffect } from "preact/compat";
|
||||
import { Outlet, useSearchParams } from "react-router-dom";
|
||||
import qs from "qs";
|
||||
import "../MainLayout/style.scss";
|
||||
import { getAppModeEnable } from "../../utils/app-mode";
|
||||
import classNames from "classnames";
|
||||
import Footer from "../Footer/Footer";
|
||||
import useFetchDefaultTimezone from "../../hooks/useFetchDefaultTimezone";
|
||||
import useDeviceDetect from "../../hooks/useDeviceDetect";
|
||||
import ControlsAnomalyLayout from "./ControlsAnomalyLayout";
|
||||
|
||||
const AnomalyLayout: FC = () => {
|
||||
const appModeEnable = getAppModeEnable();
|
||||
const { isMobile } = useDeviceDetect();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
useFetchDefaultTimezone();
|
||||
|
||||
// for support old links with search params
|
||||
const redirectSearchToHashParams = () => {
|
||||
const { search, href } = window.location;
|
||||
if (search) {
|
||||
const query = qs.parse(search, { ignoreQueryPrefix: true });
|
||||
Object.entries(query).forEach(([key, value]) => searchParams.set(key, value as string));
|
||||
setSearchParams(searchParams);
|
||||
window.location.search = "";
|
||||
}
|
||||
const newHref = href.replace(/\/\?#\//, "/#/");
|
||||
if (newHref !== href) window.location.replace(newHref);
|
||||
};
|
||||
|
||||
useEffect(redirectSearchToHashParams, []);
|
||||
|
||||
return <section className="vm-container">
|
||||
<Header controlsComponent={ControlsAnomalyLayout}/>
|
||||
<div
|
||||
className={classNames({
|
||||
"vm-container-body": true,
|
||||
"vm-container-body_mobile": isMobile,
|
||||
"vm-container-body_app": appModeEnable
|
||||
})}
|
||||
>
|
||||
<Outlet/>
|
||||
</div>
|
||||
{!appModeEnable && <Footer/>}
|
||||
</section>;
|
||||
};
|
||||
|
||||
export default AnomalyLayout;
|
||||
@@ -1,43 +0,0 @@
|
||||
import { FC } from "preact/compat";
|
||||
import classNames from "classnames";
|
||||
import TenantsConfiguration
|
||||
from "../../components/Configurators/GlobalSettings/TenantsConfiguration/TenantsConfiguration";
|
||||
import StepConfigurator from "../../components/Configurators/StepConfigurator/StepConfigurator";
|
||||
import { TimeSelector } from "../../components/Configurators/TimeRangeSettings/TimeSelector/TimeSelector";
|
||||
import CardinalityDatePicker from "../../components/Configurators/CardinalityDatePicker/CardinalityDatePicker";
|
||||
import { ExecutionControls } from "../../components/Configurators/TimeRangeSettings/ExecutionControls/ExecutionControls";
|
||||
import GlobalSettings from "../../components/Configurators/GlobalSettings/GlobalSettings";
|
||||
import ShortcutKeys from "../../components/Main/ShortcutKeys/ShortcutKeys";
|
||||
import { ControlsProps } from "../Header/HeaderControls/HeaderControls";
|
||||
|
||||
const ControlsAnomalyLayout: FC<ControlsProps> = ({
|
||||
displaySidebar,
|
||||
isMobile,
|
||||
headerSetup,
|
||||
accountIds,
|
||||
closeModal,
|
||||
}) => {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames({
|
||||
"vm-header-controls": true,
|
||||
"vm-header-controls_mobile": isMobile,
|
||||
})}
|
||||
>
|
||||
{headerSetup?.tenant && <TenantsConfiguration accountIds={accountIds || []}/>}
|
||||
{headerSetup?.stepControl && <StepConfigurator/>}
|
||||
{headerSetup?.timeSelector && <TimeSelector/>}
|
||||
{headerSetup?.cardinalityDatePicker && <CardinalityDatePicker/>}
|
||||
{headerSetup?.executionControls && <ExecutionControls
|
||||
tooltip={headerSetup?.executionControls?.tooltip}
|
||||
useAutorefresh={headerSetup?.executionControls?.useAutorefresh}
|
||||
closeModal={closeModal}
|
||||
/>}
|
||||
<GlobalSettings/>
|
||||
{!displaySidebar && <ShortcutKeys/>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ControlsAnomalyLayout;
|
||||
@@ -2,7 +2,7 @@ import { FC, useMemo } from "preact/compat";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import router from "../../router";
|
||||
import { getAppModeEnable, getAppModeParams } from "../../utils/app-mode";
|
||||
import { LogoAnomalyIcon, LogoIcon } from "../../components/Main/Icons";
|
||||
import { LogoIcon } from "../../components/Main/Icons";
|
||||
import { getCssVariable } from "../../utils/theme";
|
||||
import "./style.scss";
|
||||
import classNames from "classnames";
|
||||
@@ -13,19 +13,10 @@ import HeaderControls, { ControlsProps } from "./HeaderControls/HeaderControls";
|
||||
import useDeviceDetect from "../../hooks/useDeviceDetect";
|
||||
import useWindowSize from "../../hooks/useWindowSize";
|
||||
import { ComponentType } from "react";
|
||||
import { APP_TYPE, AppType } from "../../constants/appType";
|
||||
|
||||
export interface HeaderProps {
|
||||
controlsComponent: ComponentType<ControlsProps>
|
||||
}
|
||||
const Logo = () => {
|
||||
switch (APP_TYPE) {
|
||||
case AppType.vmanomaly:
|
||||
return <LogoAnomalyIcon/>;
|
||||
default:
|
||||
return <LogoIcon/>;
|
||||
}
|
||||
};
|
||||
|
||||
const Header: FC<HeaderProps> = ({ controlsComponent }) => {
|
||||
const { isMobile } = useDeviceDetect();
|
||||
@@ -75,7 +66,7 @@ const Header: FC<HeaderProps> = ({ controlsComponent }) => {
|
||||
onClick={onClickLogo}
|
||||
style={{ color }}
|
||||
>
|
||||
{<Logo/>}
|
||||
{<LogoIcon/>}
|
||||
</div>
|
||||
|
||||
{displaySidebar ? (
|
||||
|
||||
@@ -12,6 +12,8 @@ import useDeviceDetect from "../../hooks/useDeviceDetect";
|
||||
import ControlsMainLayout from "./ControlsMainLayout";
|
||||
import useFetchDefaultTimezone from "../../hooks/useFetchDefaultTimezone";
|
||||
import useFetchAppConfig from "../../hooks/useFetchAppConfig";
|
||||
import WebStorageCheck from "../../components/WebStorageCheck/WebStorageCheck";
|
||||
import { migrateStorageToPrefixedKeys } from "../../utils/storage";
|
||||
|
||||
const MainLayout: FC = () => {
|
||||
const appModeEnable = getAppModeEnable();
|
||||
@@ -45,6 +47,13 @@ const MainLayout: FC = () => {
|
||||
useEffect(setDocumentTitle, [pathname]);
|
||||
useEffect(redirectSearchToHashParams, []);
|
||||
|
||||
useEffect(() => {
|
||||
const migrateStorage = migrateStorageToPrefixedKeys();
|
||||
if (migrateStorage.removed.length || migrateStorage.migrated.length) {
|
||||
console.info(migrateStorage);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return <section className="vm-container">
|
||||
<Header controlsComponent={ControlsMainLayout}/>
|
||||
<div
|
||||
@@ -57,6 +66,8 @@ const MainLayout: FC = () => {
|
||||
<Outlet/>
|
||||
</div>
|
||||
{!appModeEnable && <Footer/>}
|
||||
|
||||
<WebStorageCheck/>
|
||||
</section>;
|
||||
};
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import AppConfigurator from "../appConfigurator";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import dayjs from "dayjs";
|
||||
import { DATE_FORMAT } from "../../../constants/date";
|
||||
import { getTenantIdFromUrl } from "../../../utils/tenants";
|
||||
import usePrevious from "../../../hooks/usePrevious";
|
||||
|
||||
export const useFetchQuery = (): {
|
||||
@@ -27,7 +26,7 @@ export const useFetchQuery = (): {
|
||||
const prevDate = usePrevious(date);
|
||||
const prevTotal = useRef<{ data: TSDBStatus }>();
|
||||
|
||||
const { serverUrl } = useAppState();
|
||||
const { tenantId, serverUrl } = useAppState();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<ErrorTypes | string>();
|
||||
const [tsdbStatus, setTSDBStatus] = useState<TSDBStatus>(appConfigurator.defaultTSDBStatus);
|
||||
@@ -158,9 +157,8 @@ export const useFetchQuery = (): {
|
||||
}, [error]);
|
||||
|
||||
useEffect(() => {
|
||||
const id = getTenantIdFromUrl(serverUrl);
|
||||
setIsCluster(!!id);
|
||||
}, [serverUrl]);
|
||||
setIsCluster(!!tenantId);
|
||||
}, [tenantId]);
|
||||
|
||||
|
||||
appConfigurator.tsdbStatusData = tsdbStatus;
|
||||
|
||||
@@ -13,10 +13,9 @@ type Props = {
|
||||
isHistogram: boolean;
|
||||
graphData: MetricResult[];
|
||||
controlsRef: RefObject<HTMLDivElement>;
|
||||
isAnomalyView?: boolean;
|
||||
}
|
||||
|
||||
const GraphTab: FC<Props> = ({ isHistogram, graphData, controlsRef, isAnomalyView }) => {
|
||||
const GraphTab: FC<Props> = ({ isHistogram, graphData, controlsRef }) => {
|
||||
const { isMobile } = useDeviceDetect();
|
||||
|
||||
const { customStep, yaxis, spanGaps, showAllPoints } = useGraphState();
|
||||
@@ -74,7 +73,6 @@ const GraphTab: FC<Props> = ({ isHistogram, graphData, controlsRef, isAnomalyVie
|
||||
setPeriod={setPeriod}
|
||||
height={isMobile ? window.innerHeight * 0.5 : 500}
|
||||
isHistogram={isHistogram}
|
||||
isAnomalyView={isAnomalyView}
|
||||
spanGaps={spanGaps}
|
||||
showAllPoints={showAllPoints}
|
||||
/>
|
||||
|
||||
@@ -26,7 +26,6 @@ import useSearchParamsFromObject from "../../../hooks/useSearchParamsFromObject"
|
||||
import { QueryStats } from "../../../api/types";
|
||||
import { usePrettifyQuery } from "./hooks/usePrettifyQuery";
|
||||
import QueryHistory from "../../../components/QueryHistory/QueryHistory";
|
||||
import AnomalyConfig from "../../../components/ExploreAnomaly/AnomalyConfig";
|
||||
import QueryEditorAutocomplete from "../../../components/Configurators/QueryEditor/QueryEditorAutocomplete";
|
||||
import { getUpdatedHistory } from "../../../components/QueryHistory/utils";
|
||||
|
||||
@@ -46,7 +45,6 @@ export interface QueryConfiguratorProps {
|
||||
prettify?: boolean;
|
||||
autocomplete?: boolean;
|
||||
traceQuery?: boolean;
|
||||
anomalyConfig?: boolean;
|
||||
disableCache?: boolean;
|
||||
reduceMemUsage?: boolean;
|
||||
}
|
||||
@@ -278,7 +276,6 @@ const QueryConfigurator: FC<QueryConfiguratorProps> = ({
|
||||
handleSelectQuery={handleSelectHistory}
|
||||
historyKey={"METRICS_QUERY_HISTORY"}
|
||||
/>
|
||||
{hideButtons?.anomalyConfig && <AnomalyConfig/>}
|
||||
{!hideButtons?.addQuery && stateQuery.length < MAX_QUERY_FIELDS && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTimeDispatch, useTimeState } from "../../../state/time/TimeStateContext";
|
||||
import { useCustomPanelDispatch, useCustomPanelState } from "../../../state/customPanel/CustomPanelStateContext";
|
||||
import { useAppDispatch, useAppState } from "../../../state/common/StateContext";
|
||||
import { useQueryDispatch, useQueryState } from "../../../state/query/QueryStateContext";
|
||||
import { displayTypeTabs } from "../DisplayTypeSwitch";
|
||||
import { useGraphDispatch, useGraphState } from "../../../state/graph/GraphStateContext";
|
||||
@@ -15,14 +14,12 @@ import { arrayEquals } from "../../../utils/array";
|
||||
import { isEqualURLSearchParams } from "../../../utils/url";
|
||||
|
||||
export const useSetQueryParams = () => {
|
||||
const { tenantId } = useAppState();
|
||||
const { displayType } = useCustomPanelState();
|
||||
const { query } = useQueryState();
|
||||
const { duration, relativeTime, period: { date, step } } = useTimeState();
|
||||
const { customStep } = useGraphState();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const timeDispatch = useTimeDispatch();
|
||||
const graphDispatch = useGraphDispatch();
|
||||
const queryDispatch = useQueryDispatch();
|
||||
@@ -72,10 +69,6 @@ export const useSetQueryParams = () => {
|
||||
if (searchParams.get(`${group}.tab`) !== displayTypeCode) {
|
||||
newSearchParams.set(`${group}.tab`, `${displayTypeCode}`);
|
||||
}
|
||||
|
||||
if (searchParams.get(`${group}.tenantID`) !== tenantId && tenantId) {
|
||||
newSearchParams.set(`${group}.tenantID`, tenantId);
|
||||
}
|
||||
});
|
||||
|
||||
// Remove extra parameters that exceed the request size
|
||||
@@ -89,7 +82,7 @@ export const useSetQueryParams = () => {
|
||||
|
||||
if (isEqualURLSearchParams(newSearchParams, searchParams) || !newSearchParams.size) return;
|
||||
setSearchParams(newSearchParams);
|
||||
}, [tenantId, displayType, query, duration, relativeTime, date, step, customStep]);
|
||||
}, [displayType, query, duration, relativeTime, date, step, customStep]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(setterSearchParams, 200);
|
||||
@@ -114,11 +107,6 @@ export const useSetQueryParams = () => {
|
||||
customPanelDispatch({ type: "SET_DISPLAY_TYPE", payload: displayTypeFromUrl });
|
||||
}
|
||||
|
||||
const tenantIdFromUrl = searchParams.get("g0.tenantID") || "";
|
||||
if (tenantIdFromUrl !== tenantId) {
|
||||
dispatch({ type: "SET_TENANT_ID", payload: tenantIdFromUrl });
|
||||
}
|
||||
|
||||
const queryFromUrl = getQueryArray();
|
||||
if (!arrayEquals(queryFromUrl, query)) {
|
||||
queryDispatch({ type: "SET_QUERY", payload: queryFromUrl });
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
import { FC, useMemo, useRef, useState } from "preact/compat";
|
||||
import classNames from "classnames";
|
||||
import useDeviceDetect from "../../hooks/useDeviceDetect";
|
||||
import { ForecastType } from "../../types";
|
||||
import { useSetQueryParams } from "../CustomPanel/hooks/useSetQueryParams";
|
||||
import QueryConfigurator from "../CustomPanel/QueryConfigurator/QueryConfigurator";
|
||||
import "../CustomPanel/style.scss";
|
||||
import { useQueryState } from "../../state/query/QueryStateContext";
|
||||
import { useFetchQuery } from "../../hooks/useFetchQuery";
|
||||
import { useGraphState } from "../../state/graph/GraphStateContext";
|
||||
import Spinner from "../../components/Main/Spinner/Spinner";
|
||||
import Alert from "../../components/Main/Alert/Alert";
|
||||
import WarningLimitSeries from "../CustomPanel/WarningLimitSeries/WarningLimitSeries";
|
||||
import GraphTab from "../CustomPanel/CustomPanelTabs/GraphTab";
|
||||
import { extractFields, isForecast } from "../../utils/uplot";
|
||||
import { MetricResult } from "../../api/types";
|
||||
import { promValueToNumber } from "../../utils/metric";
|
||||
|
||||
// Hardcoded to 1.0 for now; consider adding a UI slider for threshold adjustment in the future.
|
||||
const ANOMALY_SCORE_THRESHOLD = 1;
|
||||
|
||||
const ExploreAnomaly: FC = () => {
|
||||
useSetQueryParams();
|
||||
const { isMobile } = useDeviceDetect();
|
||||
|
||||
const { query } = useQueryState();
|
||||
const { customStep } = useGraphState();
|
||||
|
||||
const controlsRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [hideQuery] = useState<number[]>([]);
|
||||
const [hideError, setHideError] = useState(!query[0]);
|
||||
const [showAllSeries, setShowAllSeries] = useState(false);
|
||||
|
||||
const {
|
||||
isLoading,
|
||||
graphData,
|
||||
error,
|
||||
queryErrors,
|
||||
setQueryErrors,
|
||||
queryStats,
|
||||
warning,
|
||||
} = useFetchQuery({
|
||||
visible: true,
|
||||
customStep,
|
||||
hideQuery,
|
||||
showAllSeries
|
||||
});
|
||||
|
||||
const data = useMemo(() => {
|
||||
if (!graphData) return [];
|
||||
const detectedData = graphData.map(d => ({ ...isForecast(d.metric), ...d }));
|
||||
const realData = detectedData.filter(d => d.value === ForecastType.actual);
|
||||
const anomalyScoreData = detectedData.filter(d => d.value === ForecastType.anomaly);
|
||||
const anomalyData: MetricResult[] = realData.map((d) => {
|
||||
const id = extractFields(d.metric);
|
||||
const anomalyScoreDataByLabels = anomalyScoreData.find(du => extractFields(du.metric) === id);
|
||||
|
||||
return {
|
||||
group: 1,
|
||||
metric: { ...d.metric, __name__: ForecastType.anomaly },
|
||||
values: d.values.filter(([t]) => {
|
||||
if (!anomalyScoreDataByLabels) return false;
|
||||
const anomalyScore = anomalyScoreDataByLabels.values.find(([tMax]) => tMax === t) as [number, string];
|
||||
return anomalyScore && promValueToNumber(anomalyScore[1]) > ANOMALY_SCORE_THRESHOLD;
|
||||
})
|
||||
};
|
||||
});
|
||||
const filterData = detectedData.filter(d => (d.value !== ForecastType.anomaly) && d.value) as MetricResult[];
|
||||
return filterData.concat(anomalyData);
|
||||
}, [graphData]);
|
||||
|
||||
const handleRunQuery = () => {
|
||||
setHideError(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames({
|
||||
"vm-custom-panel": true,
|
||||
"vm-custom-panel_mobile": isMobile,
|
||||
})}
|
||||
>
|
||||
<QueryConfigurator
|
||||
queryErrors={!hideError ? queryErrors : []}
|
||||
setQueryErrors={setQueryErrors}
|
||||
setHideError={setHideError}
|
||||
stats={queryStats}
|
||||
onRunQuery={handleRunQuery}
|
||||
hideButtons={{
|
||||
addQuery: true,
|
||||
prettify: false,
|
||||
autocomplete: false,
|
||||
traceQuery: true,
|
||||
anomalyConfig: true,
|
||||
reduceMemUsage: true,
|
||||
}}
|
||||
/>
|
||||
{isLoading && <Spinner/>}
|
||||
{(!hideError && error) && <Alert variant="error">{error}</Alert>}
|
||||
{warning && (
|
||||
<WarningLimitSeries
|
||||
warning={warning}
|
||||
query={query}
|
||||
onChange={setShowAllSeries}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={classNames({
|
||||
"vm-custom-panel-body": true,
|
||||
"vm-custom-panel-body_mobile": isMobile,
|
||||
"vm-block": true,
|
||||
"vm-block_mobile": isMobile,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className="vm-custom-panel-body-header"
|
||||
ref={controlsRef}
|
||||
>
|
||||
<div/>
|
||||
</div>
|
||||
{data && (
|
||||
<GraphTab
|
||||
graphData={data}
|
||||
isHistogram={false}
|
||||
controlsRef={controlsRef}
|
||||
isAnomalyView={true}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExploreAnomaly;
|
||||
@@ -3,7 +3,6 @@ import { DashboardSettings, ErrorTypes } from "../../../types";
|
||||
import { useAppState } from "../../../state/common/StateContext";
|
||||
import { useDashboardsDispatch } from "../../../state/dashboards/DashboardsStateContext";
|
||||
import { getAppModeEnable } from "../../../utils/app-mode";
|
||||
import { APP_TYPE_VM } from "../../../constants/appType";
|
||||
|
||||
const importModule = async (filename: string) => {
|
||||
const data = await fetch(`./dashboards/${filename}`);
|
||||
@@ -35,7 +34,7 @@ export const useFetchDashboards = (): {
|
||||
};
|
||||
|
||||
const fetchRemoteDashboards = async () => {
|
||||
if (!serverUrl || !APP_TYPE_VM) return;
|
||||
if (!serverUrl) return;
|
||||
setError("");
|
||||
setIsLoading(true);
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { APP_TYPE, AppType } from "../constants/appType";
|
||||
|
||||
const router = {
|
||||
home: "/",
|
||||
@@ -12,7 +11,6 @@ const router = {
|
||||
activeQueries: "/active-queries",
|
||||
queryAnalyzer: "/query-analyzer",
|
||||
icons: "/icons",
|
||||
anomaly: "/anomaly",
|
||||
query: "/query",
|
||||
rawQuery: "/raw-query",
|
||||
downsamplingDebug: "/downsampling-filters-debug",
|
||||
@@ -52,23 +50,11 @@ const routerOptionsDefault = {
|
||||
},
|
||||
};
|
||||
|
||||
const getDefaultOptions = (appType: AppType) => {
|
||||
switch (appType) {
|
||||
case AppType.vmanomaly:
|
||||
return {
|
||||
title: "Anomaly exploration",
|
||||
...routerOptionsDefault,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
title: "Query",
|
||||
...routerOptionsDefault,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const routerOptions: { [key: string]: RouterOptions } = {
|
||||
[router.home]: getDefaultOptions(APP_TYPE),
|
||||
[router.home]: {
|
||||
title: "Query",
|
||||
...routerOptionsDefault,
|
||||
},
|
||||
[router.rawQuery]: {
|
||||
title: "Raw query",
|
||||
header: {
|
||||
@@ -148,7 +134,6 @@ export const routerOptions: { [key: string]: RouterOptions } = {
|
||||
title: "Icons",
|
||||
header: {},
|
||||
},
|
||||
[router.anomaly]: getDefaultOptions(AppType.vmanomaly),
|
||||
[router.query]: {
|
||||
title: "Query",
|
||||
...routerOptionsDefault,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import router, { routerOptions } from "./index";
|
||||
import router from "./index";
|
||||
|
||||
export enum NavigationItemType {
|
||||
internalLink,
|
||||
@@ -66,13 +66,3 @@ export const getDefaultNavigation = ({
|
||||
{ value: router.dashboards, hide: !showPredefinedDashboards },
|
||||
{ value: "Alerting", submenu: getAlertingNav(), hide: !showAlerting },
|
||||
];
|
||||
|
||||
/**
|
||||
* vmanomaly navigation menu
|
||||
*/
|
||||
export const getAnomalyNavigation = (): NavigationItem[] => [
|
||||
{
|
||||
label: routerOptions[router.anomaly].title,
|
||||
value: router.home,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -3,8 +3,7 @@ import { useDashboardsState } from "../state/dashboards/DashboardsStateContext";
|
||||
import { useAppState } from "../state/common/StateContext";
|
||||
import { useMemo } from "preact/compat";
|
||||
import { processNavigationItems } from "./utils";
|
||||
import { getAnomalyNavigation, getDefaultNavigation } from "./navigation";
|
||||
import { APP_TYPE, AppType } from "../constants/appType";
|
||||
import { getDefaultNavigation } from "./navigation";
|
||||
|
||||
const useNavigationMenu = () => {
|
||||
const appModeEnable = getAppModeEnable();
|
||||
@@ -23,12 +22,7 @@ const useNavigationMenu = () => {
|
||||
|
||||
|
||||
const menu = useMemo(() => {
|
||||
switch (APP_TYPE) {
|
||||
case AppType.vmanomaly:
|
||||
return getAnomalyNavigation();
|
||||
default:
|
||||
return getDefaultNavigation(navigationConfig);
|
||||
}
|
||||
return getDefaultNavigation(navigationConfig);
|
||||
}, [navigationConfig]);
|
||||
|
||||
return processNavigationItems(menu);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { createContext, FC, useContext, useMemo, useReducer } from "preact/compat";
|
||||
import { createContext, FC, useContext, useEffect, useMemo, useReducer } from "preact/compat";
|
||||
import { Action, AppState, initialState, reducer } from "./reducer";
|
||||
import { getQueryStringValue } from "../../utils/query-string";
|
||||
import { Dispatch } from "react";
|
||||
import { getFromStorage, removeFromStorage, saveToStorage } from "../../utils/storage";
|
||||
|
||||
type StateContextType = { state: AppState, dispatch: Dispatch<Action> };
|
||||
|
||||
@@ -23,6 +24,17 @@ export const AppStateProvider: FC = ({ children }) => {
|
||||
return { state, dispatch };
|
||||
}, [state, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!state.serverUrl) return;
|
||||
const enabledStorage = !!getFromStorage("SERVER_URL");
|
||||
|
||||
if (enabledStorage) {
|
||||
saveToStorage("SERVER_URL", state.serverUrl);
|
||||
} else {
|
||||
removeFromStorage(["SERVER_URL"]);
|
||||
}
|
||||
}, [state.serverUrl]);
|
||||
|
||||
return <StateContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</StateContext.Provider>;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { getDefaultServer } from "../../utils/default-server-url";
|
||||
import { getQueryStringValue } from "../../utils/query-string";
|
||||
import { getFromStorage, saveToStorage } from "../../utils/storage";
|
||||
import { AppConfig, Theme } from "../../types";
|
||||
import { isDarkTheme } from "../../utils/theme";
|
||||
import { removeTrailingSlash } from "../../utils/url";
|
||||
import { getTenantIdFromUrl } from "../../utils/tenants";
|
||||
|
||||
export interface AppState {
|
||||
serverUrl: string;
|
||||
@@ -16,15 +16,14 @@ export interface AppState {
|
||||
export type Action =
|
||||
| { type: "SET_SERVER", payload: string }
|
||||
| { type: "SET_THEME", payload: Theme }
|
||||
| { type: "SET_TENANT_ID", payload: string }
|
||||
| { type: "SET_APP_CONFIG", payload: AppConfig }
|
||||
| { type: "SET_DARK_THEME" }
|
||||
|
||||
const tenantId = getQueryStringValue("g0.tenantID", "") as string;
|
||||
const serverUrl = removeTrailingSlash(getDefaultServer());
|
||||
|
||||
export const initialState: AppState = {
|
||||
serverUrl: removeTrailingSlash(getDefaultServer(tenantId)),
|
||||
tenantId,
|
||||
serverUrl,
|
||||
tenantId: getTenantIdFromUrl(serverUrl),
|
||||
theme: (getFromStorage("THEME") || Theme.system) as Theme,
|
||||
isDarkTheme: null,
|
||||
appConfig: {}
|
||||
@@ -35,13 +34,9 @@ export function reducer(state: AppState, action: Action): AppState {
|
||||
case "SET_SERVER":
|
||||
return {
|
||||
...state,
|
||||
tenantId: getTenantIdFromUrl(action.payload),
|
||||
serverUrl: removeTrailingSlash(action.payload)
|
||||
};
|
||||
case "SET_TENANT_ID":
|
||||
return {
|
||||
...state,
|
||||
tenantId: action.payload
|
||||
};
|
||||
case "SET_THEME":
|
||||
saveToStorage("THEME", action.payload);
|
||||
return {
|
||||
|
||||
@@ -1,15 +1,5 @@
|
||||
import { Axis, Series } from "uplot";
|
||||
|
||||
export enum ForecastType {
|
||||
yhat = "yhat",
|
||||
yhatUpper = "yhat_upper",
|
||||
yhatLower = "yhat_lower",
|
||||
anomaly = "vmui_anomalies_points",
|
||||
training = "vmui_training_data",
|
||||
actual = "actual",
|
||||
anomalyScore = "anomaly_score",
|
||||
}
|
||||
|
||||
export interface SeriesItemStatsFormatted {
|
||||
min: string,
|
||||
max: string,
|
||||
@@ -20,8 +10,6 @@ export interface SeriesItem extends Series {
|
||||
freeFormFields: {[key: string]: string};
|
||||
statsFormatted: SeriesItemStatsFormatted;
|
||||
median: number;
|
||||
forecast?: ForecastType | null;
|
||||
forecastGroup?: string;
|
||||
hasAlias?: boolean;
|
||||
}
|
||||
|
||||
@@ -30,7 +18,6 @@ export interface HideSeriesArgs {
|
||||
legend: LegendItemType,
|
||||
metaKey: boolean,
|
||||
series: Series[],
|
||||
isAnomalyView?: boolean,
|
||||
}
|
||||
|
||||
export type MinMax = { min: number, max: number }
|
||||
|
||||
@@ -2,21 +2,6 @@ export const arrayEquals = (a: (string | number)[], b: (string | number)[]) => {
|
||||
return a.length === b.length && a.every((val, index) => val === b[index]);
|
||||
};
|
||||
|
||||
export function groupByMultipleKeys<T>(items: T[], keys: (keyof T)[]): { keys: string[], values: T[] }[] {
|
||||
const groups = items.reduce((result, item) => {
|
||||
const compositeKey = keys.map(key => `${String(key)}: ${item[key] || "-"}`).join("|");
|
||||
|
||||
(result[compositeKey] = result[compositeKey] || []).push(item);
|
||||
|
||||
return result;
|
||||
}, {} as { [key: string]: T[] });
|
||||
|
||||
return Object.entries(groups).map(([keyString, values]) => ({
|
||||
keys: keyString.split("|"),
|
||||
values
|
||||
}));
|
||||
}
|
||||
|
||||
export const isDecreasing = (arr: number[]): boolean => {
|
||||
if (arr.length < 2) return false;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ArrayRGB, ForecastType } from "../types";
|
||||
import { ArrayRGB } from "../types";
|
||||
|
||||
export const baseContrastColors = [
|
||||
"#e54040",
|
||||
@@ -21,16 +21,6 @@ export const hexToRGB = (hex: string): string => {
|
||||
return `${r}, ${g}, ${b}`;
|
||||
};
|
||||
|
||||
export const anomalyColors: Record<ForecastType, string> = {
|
||||
[ForecastType.yhatUpper]: "#7126a1",
|
||||
[ForecastType.yhatLower]: "#7126a1",
|
||||
[ForecastType.yhat]: "#da42a6",
|
||||
[ForecastType.anomaly]: "#da4242",
|
||||
[ForecastType.anomalyScore]: "#7126a1",
|
||||
[ForecastType.actual]: "#203ea9",
|
||||
[ForecastType.training]: `rgba(${hexToRGB("#203ea9")}, 0.2)`,
|
||||
};
|
||||
|
||||
export const getColorFromString = (text: string): string => {
|
||||
const SEED = 16777215;
|
||||
const FACTOR = 49979693;
|
||||
|
||||
@@ -1,23 +1,13 @@
|
||||
import { getAppModeParams } from "./app-mode";
|
||||
import { replaceTenantId } from "./tenants";
|
||||
import { APP_TYPE, AppType } from "../constants/appType";
|
||||
import { getFromStorage } from "./storage";
|
||||
|
||||
export const getDefaultURL = (u: string) => {
|
||||
return u.replace(/(\/(?:prometheus\/)?(?:graph|vmui)\/.*|\/#\/.*)/, "/prometheus");
|
||||
};
|
||||
|
||||
export const getDefaultServer = (tenantId?: string): string => {
|
||||
export const getDefaultServer = (): string => {
|
||||
const { serverURL } = getAppModeParams();
|
||||
const storageURL = getFromStorage("SERVER_URL") as string;
|
||||
const anomalyURL = `${window.location.origin}${window.location.pathname.replace(/^\/vmui/, "")}`;
|
||||
const defaultURL = getDefaultURL(window.location.href);
|
||||
const url = serverURL || storageURL || defaultURL;
|
||||
|
||||
switch (APP_TYPE) {
|
||||
case AppType.vmanomaly:
|
||||
return storageURL || anomalyURL;
|
||||
default:
|
||||
return tenantId ? replaceTenantId(url, tenantId) : url;
|
||||
}
|
||||
return serverURL || storageURL || defaultURL;
|
||||
};
|
||||
|
||||
@@ -1,48 +1,105 @@
|
||||
/**
|
||||
* Do not use this type in local storage type
|
||||
* @deprecated
|
||||
* */
|
||||
type DeprecatedStorageKeys = "QUERY_HISTORY" | "QUERY_FAVORITES";
|
||||
const STORAGE_PREFIX = "VMUI:" as const;
|
||||
|
||||
export type StorageKeys = "AUTOCOMPLETE"
|
||||
| "NO_CACHE"
|
||||
| "QUERY_TRACING"
|
||||
| "SERIES_LIMITS"
|
||||
| "LEGEND_AUTO_COLLAPSE"
|
||||
| "TABLE_COMPACT"
|
||||
| "TIMEZONE"
|
||||
| "DISABLED_DEFAULT_TIMEZONE"
|
||||
| "THEME"
|
||||
| "EXPLORE_METRICS_TIPS"
|
||||
| "METRICS_QUERY_HISTORY"
|
||||
| "SERVER_URL"
|
||||
| "RAW_JSON_LIVE_VIEW"
|
||||
| "POINTS_SHOW_ALL"
|
||||
| DeprecatedStorageKeys;
|
||||
export const ALL_STORAGE_KEYS = [
|
||||
"AUTOCOMPLETE",
|
||||
"NO_CACHE",
|
||||
"QUERY_TRACING",
|
||||
"SERIES_LIMITS",
|
||||
"LEGEND_AUTO_COLLAPSE",
|
||||
"TABLE_COMPACT",
|
||||
"TIMEZONE",
|
||||
"DISABLED_DEFAULT_TIMEZONE",
|
||||
"THEME",
|
||||
"EXPLORE_METRICS_TIPS",
|
||||
"METRICS_QUERY_HISTORY",
|
||||
"SERVER_URL",
|
||||
"POINTS_SHOW_ALL",
|
||||
] as const;
|
||||
|
||||
export type StorageKeys = (typeof ALL_STORAGE_KEYS)[number];
|
||||
|
||||
export const saveToStorage = (key: StorageKeys, value: string | boolean | Record<string, unknown>): void => {
|
||||
if (value) {
|
||||
// keeping object in storage so that keeping the string is not different from keeping
|
||||
window.localStorage.setItem(key, JSON.stringify({ value }));
|
||||
} else {
|
||||
removeFromStorage([key]);
|
||||
}
|
||||
window.dispatchEvent(new Event("storage"));
|
||||
type PrefixedStorageKeys = `${typeof STORAGE_PREFIX}${StorageKeys}`;
|
||||
|
||||
const toPrefixedKey = (key: StorageKeys): PrefixedStorageKeys => {
|
||||
return `${STORAGE_PREFIX}${key}`;
|
||||
};
|
||||
|
||||
// TODO: make this aware of data type that is stored
|
||||
export const getFromStorage = (key: StorageKeys): undefined | boolean | string | Record<string, unknown> => {
|
||||
const valueObj = window.localStorage.getItem(key);
|
||||
if (valueObj === null) {
|
||||
return undefined;
|
||||
} else {
|
||||
try {
|
||||
return JSON.parse(valueObj)?.value; // see comment in "saveToStorage"
|
||||
} catch (e) {
|
||||
return valueObj; // fallback for corrupted json
|
||||
type StorageValue = string | boolean | Record<string, unknown>;
|
||||
|
||||
export const saveToStorage = (key: StorageKeys, value: StorageValue, withPrefix = true): void => {
|
||||
try {
|
||||
const storageKey = withPrefix ? toPrefixedKey(key) : key;
|
||||
|
||||
if (value) {
|
||||
// keeping object in storage so that keeping the string is not different from keeping
|
||||
window.localStorage.setItem(storageKey, JSON.stringify({ value }));
|
||||
} else {
|
||||
window.localStorage.removeItem(storageKey);
|
||||
}
|
||||
window.dispatchEvent(new Event("storage"));
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
export const removeFromStorage = (keys: StorageKeys[]): void => keys.forEach(k => window.localStorage.removeItem(k));
|
||||
export const getFromStorage = (key: StorageKeys, withPrefix = true): undefined | StorageValue => {
|
||||
const storageKey = withPrefix ? toPrefixedKey(key) : key;
|
||||
const valueObj = window.localStorage.getItem(storageKey);
|
||||
|
||||
if (valueObj === null) return undefined;
|
||||
|
||||
try {
|
||||
return JSON.parse(valueObj)?.value; // see comment in "saveToStorage"
|
||||
} catch (e) {
|
||||
return valueObj; // fallback for corrupted json
|
||||
}
|
||||
};
|
||||
|
||||
export const removeFromStorage = (keys: StorageKeys[], withPrefix = true): void => {
|
||||
const storageKeys = withPrefix ? keys.map(toPrefixedKey) : keys;
|
||||
storageKeys.forEach(k => window.localStorage.removeItem(k));
|
||||
};
|
||||
|
||||
/**
|
||||
* Migrates legacy (unprefixed) localStorage keys to the new prefixed format (`${STORAGE_PREFIX}*`).
|
||||
* Keeps the prefixed value if it already exists, then removes the legacy key.
|
||||
*/
|
||||
|
||||
type StorageMigrationResult = {
|
||||
migrated: StorageKeys[];
|
||||
removed: StorageKeys[];
|
||||
skipped: StorageKeys[];
|
||||
};
|
||||
|
||||
export const migrateStorageToPrefixedKeys = (): StorageMigrationResult => {
|
||||
const res: StorageMigrationResult = {
|
||||
migrated: [],
|
||||
removed: [],
|
||||
skipped: [],
|
||||
};
|
||||
|
||||
for (const key of ALL_STORAGE_KEYS) {
|
||||
const legacyKey = key as StorageKeys; // unprefixed
|
||||
const legacyValue = getFromStorage(legacyKey, false);
|
||||
const prefixedValue = getFromStorage(legacyKey, true);
|
||||
|
||||
if (legacyValue === undefined) {
|
||||
res.skipped.push(legacyKey);
|
||||
continue;
|
||||
}
|
||||
|
||||
// prefixed exists -> keep it, just remove legacy
|
||||
if (prefixedValue !== undefined) {
|
||||
removeFromStorage([legacyKey], false);
|
||||
res.removed.push(legacyKey);
|
||||
continue;
|
||||
}
|
||||
|
||||
// prefixed missing -> copy legacy -> prefixed, then remove legacy
|
||||
saveToStorage(legacyKey, legacyValue, true);
|
||||
removeFromStorage([legacyKey], false);
|
||||
res.migrated.push(legacyKey);
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
89
app/vmui/packages/vmui/src/utils/tenants.test.ts
Normal file
89
app/vmui/packages/vmui/src/utils/tenants.test.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import {
|
||||
replaceTenantId,
|
||||
getTenantIdFromUrl,
|
||||
getUrlWithoutTenant,
|
||||
} from "./tenants";
|
||||
|
||||
describe("tenant url helpers", () => {
|
||||
describe("getTenantIdFromUrl", () => {
|
||||
it("returns accountID", () => {
|
||||
expect(getTenantIdFromUrl("http://vmselect:8481/select/0/vmui/")).toBe("0");
|
||||
});
|
||||
|
||||
it("returns accountID:projectID", () => {
|
||||
expect(getTenantIdFromUrl("http://vmselect:8481/select/12:7/vmui/")).toBe("12:7");
|
||||
});
|
||||
|
||||
it("returns empty string if tenant is missing", () => {
|
||||
expect(getTenantIdFromUrl("http://vmselect:8481/select/vmui/")).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty string for unrelated paths", () => {
|
||||
expect(getTenantIdFromUrl("http://vmselect:8481/foo/bar")).toBe("");
|
||||
});
|
||||
|
||||
it("returns accountID when url ends right after tenant", () => {
|
||||
expect(getTenantIdFromUrl("http://vmselect:8481/select/0")).toBe("0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("replaceTenantId", () => {
|
||||
it("replaces accountID with another accountID", () => {
|
||||
expect(
|
||||
replaceTenantId("http://vmselect:8481/select/0/vmui/", "2")
|
||||
).toBe("http://vmselect:8481/select/2/vmui/");
|
||||
});
|
||||
|
||||
it("replaces accountID with accountID:projectID", () => {
|
||||
expect(
|
||||
replaceTenantId("http://vmselect:8481/select/0/prometheus/", "1:9")
|
||||
).toBe("http://vmselect:8481/select/1:9/prometheus/");
|
||||
});
|
||||
|
||||
it("keeps the rest of the path intact", () => {
|
||||
expect(
|
||||
replaceTenantId("http://vmselect:8481/select/3:4/prometheus/api/v1/query", "7")
|
||||
).toBe("http://vmselect:8481/select/7/prometheus/api/v1/query");
|
||||
});
|
||||
|
||||
it("does not change url if it doesn't match expected pattern", () => {
|
||||
expect(
|
||||
replaceTenantId("http://vmselect:8481/foo/bar", "2")
|
||||
).toBe("http://vmselect:8481/foo/bar");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getUrlWithoutTenant", () => {
|
||||
it("removes /select/<tenant>/... and returns base url", () => {
|
||||
expect(
|
||||
getUrlWithoutTenant("http://vmselect:8481/select/0/vmui/")
|
||||
).toBe("http://vmselect:8481");
|
||||
});
|
||||
|
||||
it("removes /select/<tenant>/... for accountID:projectID and returns base url", () => {
|
||||
expect(
|
||||
getUrlWithoutTenant("http://vmselect:8481/select/5:6/prometheus/")
|
||||
).toBe("http://vmselect:8481");
|
||||
});
|
||||
|
||||
it("works with deep paths and returns base url", () => {
|
||||
expect(
|
||||
getUrlWithoutTenant("http://vmselect:8481/select/1:2/prometheus/api/v1/query")
|
||||
).toBe("http://vmselect:8481");
|
||||
});
|
||||
|
||||
it("does not change url if it doesn't match expected pattern", () => {
|
||||
expect(
|
||||
getUrlWithoutTenant("http://vmselect:8481/foo/bar")
|
||||
).toBe("http://vmselect:8481/foo/bar");
|
||||
});
|
||||
|
||||
it("removes url ending right after tenant", () => {
|
||||
expect(
|
||||
getUrlWithoutTenant("http://vmselect:8481/select/0")
|
||||
).toBe("http://vmselect:8481");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,21 @@
|
||||
const regexp = /(\/select\/)([^/])(\/)(.+)/;
|
||||
const TENANT_REGEXP = /(\/select\/)(\d+(?::\d+)?)(\/.*)?$/;
|
||||
|
||||
export const replaceTenantId = (serverUrl: string, tenantId: string) => {
|
||||
return serverUrl.replace(regexp, `$1${tenantId}/$4`);
|
||||
return serverUrl.replace(TENANT_REGEXP, `$1${tenantId}$3`);
|
||||
};
|
||||
|
||||
export const getTenantIdFromUrl = (url: string): string => {
|
||||
return url.match(regexp)?.[2] || "";
|
||||
return url.match(TENANT_REGEXP)?.[2] ?? "";
|
||||
};
|
||||
|
||||
export const getUrlWithoutTenant = (url: string): string => {
|
||||
return url.replace(regexp, "");
|
||||
return url.replace(TENANT_REGEXP, "");
|
||||
};
|
||||
|
||||
export const updateBrowserUrlTenant = (tenantId: string) => {
|
||||
const base = `${window.location.origin}${window.location.pathname}${window.location.search}`;
|
||||
const nextBase = replaceTenantId(base, tenantId);
|
||||
|
||||
const nextUrl = `${nextBase}${window.location.hash}`;
|
||||
window.history.replaceState(null, "", nextUrl);
|
||||
};
|
||||
|
||||
@@ -31,7 +31,7 @@ const shortDurations = supportedDurations.map(d => d.short);
|
||||
|
||||
export const sameTs = (a: number, b: number) => {
|
||||
return roundToThousandths(a) === roundToThousandths(b);
|
||||
}
|
||||
};
|
||||
|
||||
export const humanizeSeconds = (num: number): string => {
|
||||
return getDurationFromMilliseconds(dayjs.duration(num, "seconds").asMilliseconds());
|
||||
|
||||
@@ -44,7 +44,7 @@ export const getTimeSeries = (
|
||||
const tStart = roundToThousandths(period.start);
|
||||
const tEnd = roundToThousandths(period.end);
|
||||
const baseStep = getSecondsFromDuration(stepDuration) || 0.001;
|
||||
const step = Math.max(0.001, roundToThousandths(baseStep))
|
||||
const step = Math.max(0.001, roundToThousandths(baseStep));
|
||||
|
||||
const anchor = roundToThousandths(tsAnchor ?? tStart);
|
||||
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import uPlot, { Series as uPlotSeries } from "uplot";
|
||||
import { ForecastType, SeriesItem } from "../../types";
|
||||
import { anomalyColors, hexToRGB } from "../color";
|
||||
|
||||
export const setBand = (plot: uPlot, series: uPlotSeries[]) => {
|
||||
// First, remove any existing bands
|
||||
plot.delBand();
|
||||
|
||||
// If there aren't at least two series, we can't create a band
|
||||
if (series.length < 2) return;
|
||||
|
||||
// Cast and enrich each series item with its index
|
||||
const seriesItems = (series as SeriesItem[]).map((s, index) => ({ ...s, index }));
|
||||
|
||||
const upperSeries = seriesItems.filter(s => s.forecast === ForecastType.yhatUpper);
|
||||
const lowerSeries = seriesItems.filter(s => s.forecast === ForecastType.yhatLower);
|
||||
|
||||
// Create bands by matching upper and lower series based on their freeFormFields
|
||||
const bands = upperSeries.map((upper) => {
|
||||
const correspondingLower = lowerSeries.find(lower => lower.forecastGroup === upper.forecastGroup);
|
||||
if (!correspondingLower) return null;
|
||||
return {
|
||||
series: [upper.index, correspondingLower.index] as [number, number],
|
||||
fill: createBandFill(ForecastType.yhatUpper),
|
||||
};
|
||||
}).filter(band => band !== null) as uPlot.Band[]; // Filter out any nulls from failed matches
|
||||
|
||||
// If there are no bands to add, exit the function
|
||||
if (!bands.length) return;
|
||||
|
||||
// Add each band to the plot
|
||||
bands.forEach(band => {
|
||||
plot.addBand(band);
|
||||
});
|
||||
};
|
||||
|
||||
// Helper function to create the fill color for a band
|
||||
function createBandFill(forecastType: ForecastType): string {
|
||||
const rgb = hexToRGB(anomalyColors[forecastType]);
|
||||
return `rgba(${rgb}, 0.05)`;
|
||||
}
|
||||
@@ -23,7 +23,18 @@ export const countsToFills = (u: uPlot, seriesIdx: number) => {
|
||||
}
|
||||
}
|
||||
|
||||
// no valid counts
|
||||
if (!isFinite(minCount) || !isFinite(maxCount)) {
|
||||
return counts.map(() => -1);
|
||||
}
|
||||
|
||||
const range = maxCount - minCount;
|
||||
|
||||
// all counts are the same
|
||||
if (range === 0) {
|
||||
return counts.map(c => (c > hideThreshold ? 0 : -1));
|
||||
}
|
||||
|
||||
const paletteSize = palette.length;
|
||||
const indexedFills = Array(counts.length);
|
||||
|
||||
@@ -40,9 +51,9 @@ export const heatmapPaths = () => (u: uPlot, seriesIdx: number) => {
|
||||
const cellGap = Math.round(devicePixelRatio);
|
||||
|
||||
uPlot.orient(u, seriesIdx, (
|
||||
series,
|
||||
dataX,
|
||||
dataY,
|
||||
_series,
|
||||
_dataX,
|
||||
_dataY,
|
||||
scaleX,
|
||||
scaleY,
|
||||
valToPosX,
|
||||
@@ -51,8 +62,8 @@ export const heatmapPaths = () => (u: uPlot, seriesIdx: number) => {
|
||||
yOff,
|
||||
xDim,
|
||||
yDim,
|
||||
moveTo,
|
||||
lineTo,
|
||||
_moveTo,
|
||||
_lineTo,
|
||||
rect
|
||||
) => {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
@@ -80,7 +91,7 @@ export const heatmapPaths = () => (u: uPlot, seriesIdx: number) => {
|
||||
const cys = ys.slice(0, yBinQty).map((y: number) => {
|
||||
return Math.round(valToPosY(y, scaleY, yDim, yOff) - ySize / 2);
|
||||
});
|
||||
const cxs = Array.from({ length: xBinQty }, (v, i) => {
|
||||
const cxs = Array.from({ length: xBinQty }, (_v, i) => {
|
||||
return Math.round(valToPosX(xs[i * yBinQty], scaleX, xDim, xOff) - xSize);
|
||||
});
|
||||
|
||||
@@ -114,7 +125,7 @@ export const heatmapPaths = () => (u: uPlot, seriesIdx: number) => {
|
||||
export const convertPrometheusToVictoriaMetrics = (buckets: MetricResult[]): MetricResult[] => {
|
||||
if (!buckets.every(a => a.metric.le)) return buckets;
|
||||
|
||||
const sortedBuckets = buckets.sort((a,b) => parseFloat(a.metric.le) - parseFloat(b.metric.le));
|
||||
const sortedBuckets = buckets.sort((a, b) => parseFloat(a.metric.le) - parseFloat(b.metric.le));
|
||||
const group = buckets[0]?.group || 1;
|
||||
let prevBucket: MetricResult = { metric: { le: "" }, values: [], group };
|
||||
const result: MetricResult[] = [];
|
||||
@@ -169,5 +180,29 @@ export const normalizeData = (buckets: MetricResult[], isHistogram?: boolean): M
|
||||
return { ...bucket, values };
|
||||
}) as MetricResult[];
|
||||
|
||||
return result.filter(r => !r.values.every(v => v[1] === "0"));
|
||||
// Indices of buckets that have any non-zero values
|
||||
const idxsWithData = result
|
||||
.map((r, i) => (r.values.every(v => v[1] === "0") ? -1 : i))
|
||||
.filter(i => i !== -1);
|
||||
|
||||
const countWithData = idxsWithData.length;
|
||||
|
||||
// No data at all, or too few buckets to bother slicing
|
||||
if (countWithData === 0 || result.length <= 3) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// More than one non-empty bucket: keep only buckets with data
|
||||
if (countWithData > 1) {
|
||||
return result.filter((_, i) => idxsWithData.includes(i));
|
||||
}
|
||||
|
||||
// Keep the only non-empty bucket plus its adjacent buckets (if available)
|
||||
const idx = idxsWithData[0];
|
||||
const keep = new Set<number>([idx]);
|
||||
|
||||
if (idx - 1 >= 0) keep.add(idx - 1);
|
||||
if (idx + 1 < result.length) keep.add(idx + 1);
|
||||
|
||||
return result.filter((_, i) => keep.has(i));
|
||||
};
|
||||
|
||||
@@ -5,4 +5,3 @@ export * from "./hooks";
|
||||
export * from "./instance";
|
||||
export * from "./scales";
|
||||
export * from "./series";
|
||||
export * from "./bands";
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import uPlot, { Range, Scale, Scales } from "uplot";
|
||||
import { getMinMaxBuffer } from "./axes";
|
||||
import { YaxisState } from "../../state/graph/reducer";
|
||||
import { ForecastType, MinMax, SetMinMax } from "../../types";
|
||||
import { anomalyColors } from "../color";
|
||||
import { MinMax, SetMinMax } from "../../types";
|
||||
|
||||
export const getRangeX = ({ min, max }: MinMax): Range.MinMax => [min, max];
|
||||
|
||||
@@ -25,80 +24,3 @@ export const setSelect = (setPlotScale: SetMinMax) => (u: uPlot) => {
|
||||
const max = u.posToVal(u.select.left + u.select.width, "x");
|
||||
setPlotScale({ min, max });
|
||||
};
|
||||
|
||||
export const scaleGradient = (
|
||||
scaleKey: string,
|
||||
ori: number,
|
||||
scaleStops: [number, string][],
|
||||
discrete = false
|
||||
) => (u: uPlot): CanvasGradient | string => {
|
||||
const can = document.createElement("canvas");
|
||||
const ctx = can.getContext("2d");
|
||||
if (!ctx) return "";
|
||||
|
||||
const scale = u.scales[scaleKey];
|
||||
|
||||
// we want the stop below or at the scaleMax
|
||||
// and the stop below or at the scaleMin, else the stop above scaleMin
|
||||
let minStopIdx = 0;
|
||||
let maxStopIdx = 1;
|
||||
|
||||
for (let i = 0; i < scaleStops.length; i++) {
|
||||
const stopVal = scaleStops[i][0];
|
||||
|
||||
if (stopVal <= (scale.min || 0) || minStopIdx == null)
|
||||
minStopIdx = i;
|
||||
|
||||
maxStopIdx = i;
|
||||
|
||||
if (stopVal >= (scale.max || 1))
|
||||
break;
|
||||
}
|
||||
|
||||
if (minStopIdx == maxStopIdx)
|
||||
return scaleStops[minStopIdx][1];
|
||||
|
||||
let minStopVal = scaleStops[minStopIdx][0];
|
||||
let maxStopVal = scaleStops[maxStopIdx][0];
|
||||
|
||||
if (minStopVal == -Infinity)
|
||||
minStopVal = scale.min || 0;
|
||||
|
||||
if (maxStopVal == Infinity)
|
||||
maxStopVal = scale.max || 1;
|
||||
|
||||
const minStopPos = u.valToPos(minStopVal, scaleKey, true) || 0;
|
||||
const maxStopPos = u.valToPos(maxStopVal, scaleKey, true) || 1;
|
||||
|
||||
const range = minStopPos - maxStopPos;
|
||||
|
||||
let x0, y0, x1, y1;
|
||||
|
||||
if (ori == 1) {
|
||||
x0 = x1 = 0;
|
||||
y0 = minStopPos;
|
||||
y1 = maxStopPos;
|
||||
} else {
|
||||
y0 = y1 = 0;
|
||||
x0 = minStopPos;
|
||||
x1 = maxStopPos;
|
||||
}
|
||||
|
||||
const grd = ctx.createLinearGradient(x0, y0, x1, y1);
|
||||
|
||||
let prevColor = anomalyColors[ForecastType.actual];
|
||||
|
||||
for (let i = minStopIdx; i <= maxStopIdx; i++) {
|
||||
const s = scaleStops[i];
|
||||
|
||||
const stopPos = i == minStopIdx ? minStopPos : i == maxStopIdx ? maxStopPos : u.valToPos(s[0], scaleKey, true) | 1;
|
||||
const pct = Math.min(1, Math.max(0, (minStopPos - stopPos) / range));
|
||||
if (discrete && i > minStopIdx) {
|
||||
grd.addColorStop(pct, prevColor);
|
||||
}
|
||||
|
||||
grd.addColorStop(pct, prevColor = s[1]);
|
||||
}
|
||||
|
||||
return grd;
|
||||
};
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { MetricBase, MetricResult } from "../../api/types";
|
||||
import uPlot, { Series as uPlotSeries } from "uplot";
|
||||
import { getNameForMetric, promValueToNumber } from "../metric";
|
||||
import { ForecastType, HideSeriesArgs, LegendItemType, SeriesItem } from "../../types";
|
||||
import { anomalyColors, baseContrastColors, getColorFromString } from "../color";
|
||||
import { HideSeriesArgs, LegendItemType, SeriesItem } from "../../types";
|
||||
import { baseContrastColors, getColorFromString } from "../color";
|
||||
import { getMathStats } from "../math";
|
||||
import { formatPrettyNumber } from "./helpers";
|
||||
import { drawPoints } from "./scatter";
|
||||
@@ -15,47 +15,26 @@ export const extractFields = (metric: MetricBase["metric"]): string => {
|
||||
.map(([key, value]) => `${key}: ${value}`).join(",");
|
||||
};
|
||||
|
||||
type ForecastMetricInfo = {
|
||||
value: ForecastType | null;
|
||||
group: string;
|
||||
}
|
||||
|
||||
export const isForecast = (metric: MetricBase["metric"]): ForecastMetricInfo => {
|
||||
const metricName = metric?.__name__ || "";
|
||||
const forecastRegex = new RegExp(`(${Object.values(ForecastType).join("|")})$`);
|
||||
const match = metricName.match(forecastRegex);
|
||||
const value = match && match[0] as ForecastType;
|
||||
const isY = /(?:^|[^a-zA-Z0-9_])y(?:$|[^a-zA-Z0-9_])/.test(metricName);
|
||||
return {
|
||||
value: isY ? ForecastType.actual : value,
|
||||
group: extractFields(metric)
|
||||
};
|
||||
};
|
||||
|
||||
export const getSeriesItemContext = (data: MetricResult[], hideSeries: string[], alias: string[], showPoints?: boolean, isAnomalyUI?: boolean, isRawQuery?: boolean) => {
|
||||
export const getSeriesItemContext = (data: MetricResult[], hideSeries: string[], alias: string[], showPoints?: boolean, isRawQuery?: boolean) => {
|
||||
const colorState: {[key: string]: string} = {};
|
||||
const maxColors = isAnomalyUI ? 0 : Math.min(data.length, baseContrastColors.length);
|
||||
const maxColors = Math.min(data.length, baseContrastColors.length);
|
||||
|
||||
for (let i = 0; i < maxColors; i++) {
|
||||
const label = getNameForMetric(data[i], alias[data[i].group - 1]);
|
||||
colorState[label] = baseContrastColors[i];
|
||||
}
|
||||
|
||||
return (d: MetricResult, i: number): SeriesItem => {
|
||||
const metricInfo = isAnomalyUI ? isForecast(data[i].metric) : null;
|
||||
return (d: MetricResult): SeriesItem => {
|
||||
const aliasValue = alias[d.group - 1];
|
||||
const label = isAnomalyUI ? metricInfo?.group || "" : getNameForMetric(d, aliasValue);
|
||||
const label = getNameForMetric(d, aliasValue);
|
||||
|
||||
return {
|
||||
label,
|
||||
hasAlias: Boolean(aliasValue),
|
||||
dash: getDashSeries(metricInfo),
|
||||
width: getWidthSeries(metricInfo),
|
||||
stroke: getStrokeSeries({ metricInfo, label, isAnomalyUI, colorState }),
|
||||
points: getPointsSeries(metricInfo, showPoints, isRawQuery),
|
||||
width: 1.4,
|
||||
stroke: colorState[label] || getColorFromString(label),
|
||||
points: getPointsSeries(showPoints, isRawQuery),
|
||||
spanGaps: false,
|
||||
forecast: metricInfo?.value,
|
||||
forecastGroup: metricInfo?.group,
|
||||
freeFormFields: d.metric,
|
||||
show: !includesHideSeries(label, hideSeries),
|
||||
scale: "1",
|
||||
@@ -91,16 +70,11 @@ export const getLegendItem = (s: SeriesItem, group: number): LegendItemType => (
|
||||
hasAlias: s.hasAlias || false,
|
||||
});
|
||||
|
||||
export const getHideSeries = ({ hideSeries, legend, metaKey, series, isAnomalyView }: HideSeriesArgs): string[] => {
|
||||
export const getHideSeries = ({ hideSeries, legend, metaKey, series }: HideSeriesArgs): string[] => {
|
||||
const { label } = legend;
|
||||
const include = includesHideSeries(label, hideSeries);
|
||||
const labels = series.map(getLabelForSeries);
|
||||
|
||||
// if anomalyView is true, always return all series except the one specified by `label`
|
||||
if (isAnomalyView) {
|
||||
return labels.filter(l => l !== label);
|
||||
}
|
||||
|
||||
if (metaKey) {
|
||||
return include ? hideSeries.filter(l => l !== label) : [...hideSeries, label];
|
||||
} else if (hideSeries.length) {
|
||||
@@ -128,43 +102,7 @@ export const addSeries = (u: uPlot, series: uPlotSeries[], spanGaps = false, sho
|
||||
});
|
||||
};
|
||||
|
||||
// Helpers
|
||||
|
||||
const getDashSeries = (metricInfo: ForecastMetricInfo | null): number[] => {
|
||||
const isLower = metricInfo?.value === ForecastType.yhatLower;
|
||||
const isUpper = metricInfo?.value === ForecastType.yhatUpper;
|
||||
const isYhat = metricInfo?.value === ForecastType.yhat;
|
||||
|
||||
if (isLower || isUpper) {
|
||||
return [10, 5];
|
||||
} else if (isYhat) {
|
||||
return [10, 2];
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const getWidthSeries = (metricInfo: ForecastMetricInfo | null): number => {
|
||||
const isLower = metricInfo?.value === ForecastType.yhatLower;
|
||||
const isUpper = metricInfo?.value === ForecastType.yhatUpper;
|
||||
const isYhat = metricInfo?.value === ForecastType.yhat;
|
||||
const isAnomalyMetric = metricInfo?.value === ForecastType.anomaly;
|
||||
|
||||
if (isUpper || isLower) {
|
||||
return 0.7;
|
||||
} else if (isYhat) {
|
||||
return 1;
|
||||
} else if (isAnomalyMetric) {
|
||||
return 0;
|
||||
}
|
||||
return 1.4;
|
||||
};
|
||||
|
||||
const getPointsSeries = (metricInfo: ForecastMetricInfo | null, showPoints: boolean = false, isRawQuery?: boolean): uPlotSeries.Points => {
|
||||
const isAnomalyMetric = metricInfo?.value === ForecastType.anomaly;
|
||||
|
||||
if (isAnomalyMetric) {
|
||||
return { size: 8, width: 4, space: 0 };
|
||||
}
|
||||
const getPointsSeries = (showPoints: boolean = false, isRawQuery?: boolean): uPlotSeries.Points => {
|
||||
return {
|
||||
size: isRawQuery ? 0 : 4,
|
||||
width: 0,
|
||||
@@ -187,31 +125,3 @@ const filterPoints = (self: uPlot, seriesIdx: number): number[] | null => {
|
||||
|
||||
return indices;
|
||||
};
|
||||
|
||||
type GetStrokeSeriesArgs = {
|
||||
metricInfo: ForecastMetricInfo | null,
|
||||
label: string,
|
||||
colorState: {[p: string]: string},
|
||||
isAnomalyUI?: boolean
|
||||
}
|
||||
|
||||
const getStrokeSeries = ({ metricInfo, label, isAnomalyUI, colorState }: GetStrokeSeriesArgs): uPlotSeries.Stroke => {
|
||||
const stroke: uPlotSeries.Stroke = colorState[label] || getColorFromString(label);
|
||||
const isAnomalyMetric = metricInfo?.value === ForecastType.anomaly;
|
||||
|
||||
if (isAnomalyUI && isAnomalyMetric) {
|
||||
return anomalyColors[ForecastType.anomaly];
|
||||
} else if (isAnomalyUI && !isAnomalyMetric && !metricInfo?.value) {
|
||||
// TODO add stroke for training data
|
||||
// const hzGrad: [number, string][] = [
|
||||
// [time, anomalyColors[ForecastType.actual]],
|
||||
// [time, anomalyColors[ForecastType.training]],
|
||||
// [time, anomalyColors[ForecastType.actual]],
|
||||
// ];
|
||||
// stroke = scaleGradient("x", 0, hzGrad, true);
|
||||
return anomalyColors[ForecastType.actual];
|
||||
} else if (metricInfo?.value) {
|
||||
return metricInfo?.value ? anomalyColors[metricInfo?.value] : stroke;
|
||||
}
|
||||
return colorState[label] || getColorFromString(label);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"types": ["vite/client", "vitest/globals"],
|
||||
"types": ["vite/client", "vitest/globals", "node"],
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
|
||||
@@ -2,44 +2,40 @@ import * as path from "path";
|
||||
|
||||
import { defineConfig, ProxyOptions } from "vite";
|
||||
import preact from "@preact/preset-vite";
|
||||
import dynamicIndexHtmlPlugin from "./config/plugins/dynamicIndexHtml";
|
||||
|
||||
const getProxy = (): Record<string, ProxyOptions> | undefined => {
|
||||
const playground = process.env.PLAYGROUND;
|
||||
const playground = process.env.PLAYGROUND.toLowerCase();
|
||||
|
||||
switch (playground) {
|
||||
case "METRICS": {
|
||||
return {
|
||||
"^/(api|vmalert)/.*": {
|
||||
target: "https://play.victoriametrics.com/select/0/prometheus",
|
||||
changeOrigin: true,
|
||||
configure: (proxy) => {
|
||||
proxy.on("error", (err) => {
|
||||
console.error("[proxy error]", err.message);
|
||||
});
|
||||
},
|
||||
},
|
||||
"/vmui/config.json": {
|
||||
target: "https://play.victoriametrics.com/select/0",
|
||||
changeOrigin: true,
|
||||
configure: (proxy) => {
|
||||
proxy.on("error", (err) => {
|
||||
console.error("[proxy error]", err.message);
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
default: {
|
||||
return undefined;
|
||||
}
|
||||
if (playground !== "true") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
"^/(api|vmalert)/.*": {
|
||||
target: "https://play.victoriametrics.com/select/0/prometheus",
|
||||
changeOrigin: true,
|
||||
configure: (proxy) => {
|
||||
proxy.on("error", (err) => {
|
||||
console.error("[proxy error]", err.message);
|
||||
});
|
||||
},
|
||||
},
|
||||
"/prometheus/vmui/config.json": {
|
||||
target: "https://play.victoriametrics.com/select/0",
|
||||
changeOrigin: true,
|
||||
configure: (proxy) => {
|
||||
proxy.on("error", (err) => {
|
||||
console.error("[proxy error]", err.message);
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
export default defineConfig(() => {
|
||||
return {
|
||||
base: "",
|
||||
plugins: [preact(), dynamicIndexHtmlPlugin({ mode })],
|
||||
plugins: [preact()],
|
||||
assetsInclude: ["**/*.md"],
|
||||
server: {
|
||||
open: true,
|
||||
|
||||
@@ -193,9 +193,10 @@ func testDeduplication(tc *apptest.TestCase, sut apptest.PrometheusWriteQuerier,
|
||||
}},
|
||||
{Metric: map[string]string{"__name__": "metric4"}, Samples: []*apptest.Sample{
|
||||
// If multiple raw samples have the same timestamp on the
|
||||
// given -dedup.minScrapeInterval discrete interval, then
|
||||
// stale markers are preferred over any other value.
|
||||
{Timestamp: ts10, Value: decimal.StaleNaN},
|
||||
// given -dedup.minScrapeInterval discrete interval,
|
||||
// always prefer a non-decimal.StaleNaN value,
|
||||
// see https://github.com/VictoriaMetrics/VictoriaMetrics/issues/10196
|
||||
{Timestamp: ts10, Value: 50},
|
||||
}},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -265,6 +265,36 @@ func TestSingleIngestionProtocols(t *testing.T) {
|
||||
{Timestamp: 1707123456800, Value: 20}, // 2024-02-05T08:57:36.700Z
|
||||
},
|
||||
})
|
||||
|
||||
// zabbixconnector format
|
||||
sut.ZabbixConnectorHistory(t,
|
||||
[]string{
|
||||
`{"host":{"host":"h1","name":"n1"},"item_tags":[], "itemid":1,"name":"zabbixconnector_series","clock":1707123456,"ns":700000000,"value":10,"type":0}`,
|
||||
`{"host":{"host":"h2","name":"n2"},"item_tags":[{"tag":"foo2","value":"value1"}], "itemid":1,"name":"zabbixconnector_series2","clock":1707123456,"ns":800000000,"value":20,"type":0}`,
|
||||
},
|
||||
apptest.QueryOpts{})
|
||||
sut.ForceFlush(t)
|
||||
f(sut, &opts{
|
||||
query: `{__name__=~"zabbixconnector.+"}`,
|
||||
wantMetrics: []map[string]string{
|
||||
{
|
||||
"__name__": "zabbixconnector_series",
|
||||
"host": "h1",
|
||||
"hostname": "n1",
|
||||
},
|
||||
{
|
||||
"__name__": "zabbixconnector_series2",
|
||||
"host": "h2",
|
||||
"hostname": "n2",
|
||||
"tag_foo2": "value1",
|
||||
},
|
||||
},
|
||||
wantSamples: []*apptest.Sample{
|
||||
{Timestamp: 1707123456700, Value: 10}, // 2024-02-05T08:57:36.700Z
|
||||
{Timestamp: 1707123456800, Value: 20}, // 2024-02-05T08:57:36.700Z
|
||||
},
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
func TestClusterIngestionProtocols(t *testing.T) {
|
||||
@@ -531,4 +561,33 @@ func TestClusterIngestionProtocols(t *testing.T) {
|
||||
{Timestamp: 1707123456800, Value: 20}, // 2024-02-05T08:57:36.700Z
|
||||
},
|
||||
})
|
||||
// zabbixconnector format
|
||||
vminsert.ZabbixConnectorHistory(t,
|
||||
[]string{
|
||||
`{"host":{"host":"h1","name":"n1"},"item_tags":[], "itemid":1,"name":"zabbixconnector_series","clock":1707123456,"ns":700000000,"value":10,"type":0}`,
|
||||
`{"host":{"host":"h2","name":"n2"},"item_tags":[{"tag":"foo2","value":"value1"}], "itemid":1,"name":"zabbixconnector_series2","clock":1707123456,"ns":800000000,"value":20,"type":0}`,
|
||||
},
|
||||
apptest.QueryOpts{})
|
||||
vmstorage.ForceFlush(t)
|
||||
f(&opts{
|
||||
query: `{__name__=~"zabbixconnector.+"}`,
|
||||
wantMetrics: []map[string]string{
|
||||
{
|
||||
"__name__": "zabbixconnector_series",
|
||||
"host": "h1",
|
||||
"hostname": "n1",
|
||||
},
|
||||
{
|
||||
"__name__": "zabbixconnector_series2",
|
||||
"host": "h2",
|
||||
"hostname": "n2",
|
||||
"tag_foo2": "value1",
|
||||
},
|
||||
},
|
||||
wantSamples: []*apptest.Sample{
|
||||
{Timestamp: 1707123456700, Value: 10}, // 2024-02-05T08:57:36.700Z
|
||||
{Timestamp: 1707123456800, Value: 20}, // 2024-02-05T08:57:36.700Z
|
||||
},
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
@@ -22,7 +22,17 @@ func NewPrometheusMockStorage(series []*prompb.TimeSeries) *PrometheusMockStorag
|
||||
return &PrometheusMockStorage{store: series}
|
||||
}
|
||||
|
||||
// Read implements the storage.Storage interface for reading time series data.
|
||||
// ReadMultiple implemnets the storage.ReadClient interface for reading time series data.
|
||||
func (ms *PrometheusMockStorage) ReadMultiple(ctx context.Context, queries []*prompb.Query, sortSeries bool) (storage.SeriesSet, error) {
|
||||
if len(queries) != 1 {
|
||||
panic(fmt.Errorf("reading multiple queries isn't implemented"))
|
||||
}
|
||||
|
||||
query := queries[0]
|
||||
return ms.Read(ctx, query, sortSeries)
|
||||
}
|
||||
|
||||
// Read implements the storage.ReadClient interface for reading time series data.
|
||||
func (ms *PrometheusMockStorage) Read(_ context.Context, query *prompb.Query, sortSeries bool) (storage.SeriesSet, error) {
|
||||
if ms.query != nil {
|
||||
return nil, fmt.Errorf("expected only one call to remote client got: %v", query)
|
||||
|
||||
@@ -162,7 +162,7 @@ func (rrs *RemoteReadServer) getStreamReadHandler(t *testing.T) http.Handler {
|
||||
var matchers []*labels.Matcher
|
||||
cb := func() (int64, error) { return 0, nil }
|
||||
|
||||
c := remote.NewSampleAndChunkQueryableClient(rrs.storage, nil, matchers, true, cb)
|
||||
c := remote.NewSampleAndChunkQueryableClient(rrs.storage, labels.New(), matchers, true, cb)
|
||||
|
||||
q, err := c.ChunkQuerier(startTs, endTs)
|
||||
if err != nil {
|
||||
@@ -317,13 +317,13 @@ func generateRemoteReadSamples(idx int, startTime, endTime, numOfSamples int64)
|
||||
return samples
|
||||
}
|
||||
|
||||
func labelsToLabelsProto(labels labels.Labels) []prompb.Label {
|
||||
result := make([]prompb.Label, 0, len(labels))
|
||||
for _, l := range labels {
|
||||
func labelsToLabelsProto(ls labels.Labels) []prompb.Label {
|
||||
result := make([]prompb.Label, 0, ls.Len())
|
||||
ls.Range(func(l labels.Label) {
|
||||
result = append(result, prompb.Label{
|
||||
Name: l.Name,
|
||||
Value: l.Value,
|
||||
Name: strings.Clone(l.Name),
|
||||
Value: strings.Clone(l.Value),
|
||||
})
|
||||
}
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -255,6 +255,28 @@ func (app *Vminsert) PrometheusAPIV1ImportPrometheus(t *testing.T, records []str
|
||||
})
|
||||
}
|
||||
|
||||
// ZabbixConnectorHistory is a test helper function that inserts a
|
||||
// collection of records in zabbixconnector format by sending a HTTP
|
||||
// POST request to /zabbixconnector/api/v1/history vmsingle endpoint.
|
||||
func (app *Vminsert) ZabbixConnectorHistory(t *testing.T, records []string, opts QueryOpts) {
|
||||
t.Helper()
|
||||
|
||||
url := fmt.Sprintf("http://%s/insert/%s/zabbixconnector/api/v1/history", app.httpListenAddr, opts.getTenant())
|
||||
uv := opts.asURLValues()
|
||||
uvs := uv.Encode()
|
||||
if len(uvs) > 0 {
|
||||
url += "?" + uvs
|
||||
}
|
||||
data := []byte(strings.Join(records, "\n"))
|
||||
app.sendBlocking(t, len(records), func() {
|
||||
_, statusCode := app.cli.Post(t, url, "application/json", data)
|
||||
if statusCode != http.StatusOK {
|
||||
t.Fatalf("unexpected status code: got %d, want %d", statusCode, http.StatusOK)
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
// String returns the string representation of the vminsert app state.
|
||||
func (app *Vminsert) String() string {
|
||||
return fmt.Sprintf("{app: %s httpListenAddr: %q}", app.app, app.httpListenAddr)
|
||||
|
||||
@@ -597,8 +597,27 @@ func (app *Vmsingle) APIV1StatusTSDB(t *testing.T, matchQuery string, date strin
|
||||
return status
|
||||
}
|
||||
|
||||
// HTTPAddr returns the address at which the vmstorage process is listening
|
||||
// for http connections.
|
||||
// ZabbixConnectorHistory is a test helper function that inserts a
|
||||
// collection of records in zabbixconnector format by sending a HTTP
|
||||
// POST request to /zabbixconnector/api/v1/history vmsingle endpoint.
|
||||
func (app *Vmsingle) ZabbixConnectorHistory(t *testing.T, records []string, opts QueryOpts) {
|
||||
t.Helper()
|
||||
|
||||
url := fmt.Sprintf("http://%s/zabbixconnector/api/v1/history", app.httpListenAddr)
|
||||
uv := opts.asURLValues()
|
||||
uvs := uv.Encode()
|
||||
if len(uvs) > 0 {
|
||||
url += "?" + uvs
|
||||
}
|
||||
data := []byte(strings.Join(records, "\n"))
|
||||
_, statusCode := app.cli.Post(t, url, "application/json", data)
|
||||
if statusCode != http.StatusOK {
|
||||
t.Fatalf("unexpected status code: got %d, want %d", statusCode, http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
// HTTPAddr returns the address at which the vminsert process is
|
||||
// listening for incoming HTTP requests.
|
||||
func (app *Vmsingle) HTTPAddr() string {
|
||||
return app.httpListenAddr
|
||||
}
|
||||
|
||||
@@ -452,7 +452,7 @@
|
||||
"uid": "$ds"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": "sum(increase(vm_backup_errors_total{job=~\"$job\", instance=~\"$instance\"}[1h]))",
|
||||
"expr": "sum(increase(vm_backup_errors_total{job=~\"$job\", instance=~\"$instance\"}[$__range]))",
|
||||
"legendFormat": "__auto",
|
||||
"range": true,
|
||||
"refId": "A"
|
||||
@@ -605,7 +605,7 @@
|
||||
"uid": "$ds"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": "sum(increase(vm_retention_errors_total{job=~\"$job\", instance=~\"$instance\"}[1h]))",
|
||||
"expr": "sum(increase(vm_retention_errors_total{job=~\"$job\", instance=~\"$instance\"}[$__range]))",
|
||||
"legendFormat": "__auto",
|
||||
"range": true,
|
||||
"refId": "A"
|
||||
|
||||
@@ -8966,6 +8966,113 @@
|
||||
],
|
||||
"title": "Network usage: vmstorage ($instance)",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "$ds"
|
||||
},
|
||||
"description": "Shows the [rollup result cache](https://docs.victoriametrics.com/victoriametrics/single-server-victoriametrics/#rollup-result-cache) miss ratio for query when cache is enabled. \nRollup cache is typically hit in two scenarios:\n1. Repeated [range queries](https://docs.victoriametrics.com/victoriametrics/keyconcepts/#range-query) with increasing time, start and end arguments;\n2. Repeated [instant queries](https://docs.victoriametrics.com/victoriametrics/keyconcepts/#instant-query) containing rollup functions with lookbehind window exceeding `-search.minWindowForInstantRollupOptimization`.\n\nA lower value indicates high cache utilization, suggesting that most queries are repeated from stable clients such as vmalert rules or Grafana dashboards.",
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"custom": {
|
||||
"axisBorderShow": false,
|
||||
"axisCenteredZero": false,
|
||||
"axisColorMode": "text",
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"barWidthFactor": 0.6,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 0,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": {
|
||||
"legend": false,
|
||||
"tooltip": false,
|
||||
"viz": false
|
||||
},
|
||||
"insertNulls": false,
|
||||
"lineInterpolation": "linear",
|
||||
"lineWidth": 1,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": {
|
||||
"type": "linear"
|
||||
},
|
||||
"showPoints": "never",
|
||||
"showValues": false,
|
||||
"spanNulls": false,
|
||||
"stacking": {
|
||||
"group": "A",
|
||||
"mode": "none"
|
||||
},
|
||||
"thresholdsStyle": {
|
||||
"mode": "off"
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"mappings": [],
|
||||
"max": 1,
|
||||
"min": 0,
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": 0
|
||||
}
|
||||
]
|
||||
},
|
||||
"unit": "percentunit"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 12,
|
||||
"x": 0,
|
||||
"y": 8424
|
||||
},
|
||||
"id": 226,
|
||||
"options": {
|
||||
"legend": {
|
||||
"calcs": [
|
||||
"mean",
|
||||
"lastNotNull",
|
||||
"max"
|
||||
],
|
||||
"displayMode": "table",
|
||||
"placement": "bottom",
|
||||
"showLegend": true,
|
||||
"sortBy": "Last *",
|
||||
"sortDesc": true
|
||||
},
|
||||
"tooltip": {
|
||||
"hideZeros": false,
|
||||
"mode": "multi",
|
||||
"sort": "desc"
|
||||
}
|
||||
},
|
||||
"pluginVersion": "12.3.0",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "$ds"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": "sum(rate(vm_rollup_result_cache_miss_total{job=~\"$job_select\", instance=~\"$instance\"}[$__rate_interval]))\n/\nsum(rate(vm_rollup_result_cache_requests_total{job=~\"$job_select\", instance=~\"$instance\"}[$__rate_interval]))",
|
||||
"format": "time_series",
|
||||
"intervalFactor": 1,
|
||||
"legendFormat": "miss",
|
||||
"range": true,
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Rollup result cache miss ratio ($instance)",
|
||||
"type": "timeseries"
|
||||
}
|
||||
],
|
||||
"title": "vmselect ($instance)",
|
||||
@@ -11351,4 +11458,4 @@
|
||||
"title": "VictoriaMetrics - cluster",
|
||||
"uid": "oS7Bi_0Wz",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -453,7 +453,7 @@
|
||||
"uid": "$ds"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": "sum(increase(vm_backup_errors_total{job=~\"$job\", instance=~\"$instance\"}[1h]))",
|
||||
"expr": "sum(increase(vm_backup_errors_total{job=~\"$job\", instance=~\"$instance\"}[$__range]))",
|
||||
"legendFormat": "__auto",
|
||||
"range": true,
|
||||
"refId": "A"
|
||||
@@ -606,7 +606,7 @@
|
||||
"uid": "$ds"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": "sum(increase(vm_retention_errors_total{job=~\"$job\", instance=~\"$instance\"}[1h]))",
|
||||
"expr": "sum(increase(vm_retention_errors_total{job=~\"$job\", instance=~\"$instance\"}[$__range]))",
|
||||
"legendFormat": "__auto",
|
||||
"range": true,
|
||||
"refId": "A"
|
||||
|
||||
@@ -8967,6 +8967,113 @@
|
||||
],
|
||||
"title": "Network usage: vmstorage ($instance)",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "victoriametrics-metrics-datasource",
|
||||
"uid": "$ds"
|
||||
},
|
||||
"description": "Shows the [rollup result cache](https://docs.victoriametrics.com/victoriametrics/single-server-victoriametrics/#rollup-result-cache) miss ratio for query when cache is enabled. \nRollup cache is typically hit in two scenarios:\n1. Repeated [range queries](https://docs.victoriametrics.com/victoriametrics/keyconcepts/#range-query) with increasing time, start and end arguments;\n2. Repeated [instant queries](https://docs.victoriametrics.com/victoriametrics/keyconcepts/#instant-query) containing rollup functions with lookbehind window exceeding `-search.minWindowForInstantRollupOptimization`.\n\nA lower value indicates high cache utilization, suggesting that most queries are repeated from stable clients such as vmalert rules or Grafana dashboards.",
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"custom": {
|
||||
"axisBorderShow": false,
|
||||
"axisCenteredZero": false,
|
||||
"axisColorMode": "text",
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"barWidthFactor": 0.6,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 0,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": {
|
||||
"legend": false,
|
||||
"tooltip": false,
|
||||
"viz": false
|
||||
},
|
||||
"insertNulls": false,
|
||||
"lineInterpolation": "linear",
|
||||
"lineWidth": 1,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": {
|
||||
"type": "linear"
|
||||
},
|
||||
"showPoints": "never",
|
||||
"showValues": false,
|
||||
"spanNulls": false,
|
||||
"stacking": {
|
||||
"group": "A",
|
||||
"mode": "none"
|
||||
},
|
||||
"thresholdsStyle": {
|
||||
"mode": "off"
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"mappings": [],
|
||||
"max": 1,
|
||||
"min": 0,
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": 0
|
||||
}
|
||||
]
|
||||
},
|
||||
"unit": "percentunit"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 12,
|
||||
"x": 0,
|
||||
"y": 8424
|
||||
},
|
||||
"id": 226,
|
||||
"options": {
|
||||
"legend": {
|
||||
"calcs": [
|
||||
"mean",
|
||||
"lastNotNull",
|
||||
"max"
|
||||
],
|
||||
"displayMode": "table",
|
||||
"placement": "bottom",
|
||||
"showLegend": true,
|
||||
"sortBy": "Last *",
|
||||
"sortDesc": true
|
||||
},
|
||||
"tooltip": {
|
||||
"hideZeros": false,
|
||||
"mode": "multi",
|
||||
"sort": "desc"
|
||||
}
|
||||
},
|
||||
"pluginVersion": "12.3.0",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "victoriametrics-metrics-datasource",
|
||||
"uid": "$ds"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": "sum(rate(vm_rollup_result_cache_miss_total{job=~\"$job_select\", instance=~\"$instance\"}[$__rate_interval]))\n/\nsum(rate(vm_rollup_result_cache_requests_total{job=~\"$job_select\", instance=~\"$instance\"}[$__rate_interval]))",
|
||||
"format": "time_series",
|
||||
"intervalFactor": 1,
|
||||
"legendFormat": "miss",
|
||||
"range": true,
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Rollup result cache miss ratio ($instance)",
|
||||
"type": "timeseries"
|
||||
}
|
||||
],
|
||||
"title": "vmselect ($instance)",
|
||||
@@ -11352,4 +11459,4 @@
|
||||
"title": "VictoriaMetrics - cluster (VM)",
|
||||
"uid": "oS7Bi_0Wz_vm",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user