Compare commits

..

1 Commits

Author SHA1 Message Date
Yury Molodov
8fe88c2ca5 app/vmui: display vmselect version in footer
Signed-off-by: Yury Molodov <yurymolodov@gmail.com>
2025-09-05 12:03:50 +02:00
246 changed files with 7958 additions and 7859 deletions

View File

@@ -63,7 +63,7 @@ jobs:
- name: Setup Go
id: go
uses: actions/setup-go@v6
uses: actions/setup-go@v5
with:
cache-dependency-path: |
go.sum

View File

@@ -19,7 +19,7 @@ jobs:
- name: Setup Go
id: go
uses: actions/setup-go@v6
uses: actions/setup-go@v5
with:
go-version: stable
cache: false

View File

@@ -33,7 +33,7 @@ jobs:
- name: Set up Go
id: go
uses: actions/setup-go@v6
uses: actions/setup-go@v5
with:
cache: false
go-version: stable

View File

@@ -36,7 +36,7 @@ jobs:
- name: Setup Go
id: go
uses: actions/setup-go@v6
uses: actions/setup-go@v5
with:
cache-dependency-path: |
go.sum
@@ -75,7 +75,7 @@ jobs:
- name: Setup Go
id: go
uses: actions/setup-go@v6
uses: actions/setup-go@v5
with:
cache-dependency-path: |
go.sum
@@ -101,7 +101,7 @@ jobs:
- name: Setup Go
id: go
uses: actions/setup-go@v6
uses: actions/setup-go@v5
with:
cache-dependency-path: |
go.sum

View File

@@ -4,11 +4,12 @@
The following versions of VictoriaMetrics receive regular security fixes:
| Version | Supported |
|--------------------------------------------------------------------------------|--------------------|
| [Latest release](https://docs.victoriametrics.com/victoriametrics/changelog/) | :white_check_mark: |
| [LTS releases](https://docs.victoriametrics.com/victoriametrics/lts-releases/) | :white_check_mark: |
| other releases | :x: |
| Version | Supported |
|---------|--------------------|
| [latest release](https://docs.victoriametrics.com/victoriametrics/changelog/) | :white_check_mark: |
| v1.102.x [LTS line](https://docs.victoriametrics.com/victoriametrics/lts-releases/) | :white_check_mark: |
| v1.110.x [LTS line](https://docs.victoriametrics.com/victoriametrics/lts-releases/) | :white_check_mark: |
| other releases | :x: |
See [this page](https://victoriametrics.com/security/) for more details.

View File

@@ -169,7 +169,7 @@ func usage() {
const s = `
victoria-metrics is a time series database and monitoring solution.
See the docs at https://docs.victoriametrics.com/victoriametrics/
See the docs at https://docs.victoriametrics.com/
`
flagutil.Usage(s)
}

View File

@@ -31,7 +31,7 @@ type Group struct {
// EvalDelay will adjust the `time` parameter of rule evaluation requests to compensate intentional query delay from datasource.
// see https://github.com/VictoriaMetrics/VictoriaMetrics/issues/5155
EvalDelay *promutil.Duration `yaml:"eval_delay,omitempty"`
Limit *int `yaml:"limit,omitempty"`
Limit int `yaml:"limit,omitempty"`
Rules []Rule `yaml:"rules"`
Concurrency int `yaml:"concurrency"`
// Labels is a set of label value pairs, that will be added to every rule.
@@ -91,8 +91,8 @@ func (g *Group) Validate(validateTplFn ValidateTplFn, validateExpressions bool)
if g.EvalOffset != nil && g.EvalDelay != nil {
return fmt.Errorf("eval_offset cannot be used with eval_delay")
}
if g.Limit != nil && *g.Limit < 0 {
return fmt.Errorf("invalid limit %d, shouldn't be less than 0", *g.Limit)
if g.Limit < 0 {
return fmt.Errorf("invalid limit %d, shouldn't be less than 0", g.Limit)
}
if g.Concurrency < 0 {
return fmt.Errorf("invalid concurrency %d, shouldn't be less than 0", g.Concurrency)

View File

@@ -181,10 +181,9 @@ func TestGroupValidate_Failure(t *testing.T) {
EvalOffset: promutil.NewDuration(2 * time.Minute),
}, false, "eval_offset should be smaller than interval")
limit := -1
f(&Group{
Name: "wrong limit",
Limit: &limit,
Limit: -1,
}, false, "invalid limit")
f(&Group{

View File

@@ -38,7 +38,7 @@ func (m *manager) groupAPI(gID uint64) (*rule.ApiGroup, error) {
if !ok {
return nil, fmt.Errorf("can't find group with id %d", gID)
}
return g.ToAPI(), nil
return rule.GroupToAPI(g), nil
}
// ruleAPI generates apiRule object from alert by its ID(hash)
@@ -52,7 +52,7 @@ func (m *manager) ruleAPI(gID, rID uint64) (rule.ApiRule, error) {
}
for _, r := range g.Rules {
if r.ID() == rID {
return r.ToAPI(), nil
return rule.RuleToAPI(r), nil
}
}
return rule.ApiRule{}, fmt.Errorf("can't find rule with id %d in group %q", rID, g.Name)
@@ -72,7 +72,7 @@ func (m *manager) alertAPI(gID, aID uint64) (*rule.ApiAlert, error) {
if !ok {
continue
}
if apiAlert := ar.AlertToAPI(aID); apiAlert != nil {
if apiAlert := rule.AlertToAPI(ar, aID); apiAlert != nil {
return apiAlert, nil
}
}

View File

@@ -187,54 +187,6 @@ func (ar *AlertingRule) ID() uint64 {
return ar.RuleID
}
// ToAPI returns ApiRule representation of ar
func (ar *AlertingRule) ToAPI() ApiRule {
state := ar.state
lastState := state.getLast()
r := ApiRule{
Type: TypeAlerting,
DatasourceType: ar.Type.String(),
Name: ar.Name,
Query: ar.Expr,
Duration: ar.For.Seconds(),
KeepFiringFor: ar.KeepFiringFor.Seconds(),
Labels: ar.Labels,
Annotations: ar.Annotations,
LastEvaluation: lastState.Time,
EvaluationTime: lastState.Duration.Seconds(),
Health: "ok",
State: "inactive",
Alerts: ar.AlertsToAPI(),
LastSamples: lastState.Samples,
LastSeriesFetched: lastState.SeriesFetched,
MaxUpdates: state.size(),
Updates: state.getAll(),
Debug: ar.Debug,
// encode as strings to avoid rounding in JSON
ID: fmt.Sprintf("%d", ar.ID()),
GroupID: fmt.Sprintf("%d", ar.GroupID),
GroupName: ar.GroupName,
File: ar.File,
}
if lastState.Err != nil {
r.LastError = lastState.Err.Error()
r.Health = "err"
}
// satisfy apiRule.State logic
if len(r.Alerts) > 0 {
r.State = notifier.StatePending.String()
stateFiring := notifier.StateFiring.String()
for _, a := range r.Alerts {
if a.State == stateFiring {
r.State = stateFiring
break
}
}
}
return r
}
// GetAlerts returns active alerts of rule
func (ar *AlertingRule) GetAlerts() []*notifier.Alert {
ar.alertsMu.RLock()
@@ -389,7 +341,7 @@ func (ar *AlertingRule) execRange(ctx context.Context, start, end time.Time) ([]
return []datasource.Metric{{Timestamps: []int64{0}, Values: []float64{math.NaN()}}}, nil
}
for _, s := range res.Data {
ls, err := ar.expandLabelTemplates(s, qFn)
ls, err := ar.expandLabelTemplates(s)
if err != nil {
return nil, err
}
@@ -482,7 +434,7 @@ func (ar *AlertingRule) exec(ctx context.Context, ts time.Time, limit int) ([]pr
expandedLabels := make([]*labelSet, len(res.Data))
expandedAnnotations := make([]map[string]string, len(res.Data))
for i, m := range res.Data {
ls, err := ar.expandLabelTemplates(m, qFn)
ls, err := ar.expandLabelTemplates(m)
if err != nil {
curState.Err = err
return nil, curState.Err
@@ -604,7 +556,10 @@ func (ar *AlertingRule) exec(ctx context.Context, ts time.Time, limit int) ([]pr
return append(tss, ar.toTimeSeries(ts.Unix())...), nil
}
func (ar *AlertingRule) expandLabelTemplates(m datasource.Metric, qFn templates.QueryFn) (*labelSet, error) {
func (ar *AlertingRule) expandLabelTemplates(m datasource.Metric) (*labelSet, error) {
qFn := func(_ string) ([]datasource.Metric, error) {
return nil, fmt.Errorf("`query` template isn't supported in rule label")
}
ls, err := ar.toLabels(m, qFn)
if err != nil {
return nil, fmt.Errorf("failed to expand label templates: %s", err)

View File

@@ -10,7 +10,6 @@ import (
"strings"
"sync"
"testing"
"testing/synctest"
"time"
"github.com/VictoriaMetrics/metrics"
@@ -1430,142 +1429,3 @@ func TestAlertingRuleExec_Partial(t *testing.T) {
t.Fatalf("unexpected error: %s", err)
}
}
func TestAlertingRule_QueryTemplateInLabels(t *testing.T) {
fq := &datasource.FakeQuerier{}
fakeGroup := Group{
Name: "TestQueryTemplateInLabels",
}
ar := &AlertingRule{
Name: "test_alert",
Labels: map[string]string{
"suppress_for_mass_alert": `{{ if (printf "ALERTS{alertname='SomeAlert', alertstate='firing', device='%s'} == 1" $labels.device | query) }}true{{ else }}false{{ end }}`,
},
Annotations: map[string]string{
"summary": "Test alert with query template in labels",
},
alerts: make(map[uint64]*notifier.Alert),
}
ar.GroupID = fakeGroup.GetID()
ar.q = fq
ar.state = &ruleState{
entries: make([]StateEntry, 10),
}
// Add a metric that should trigger the alert
fq.Add(metricWithValueAndLabels(t, 1, "device", "sda1"))
ts := time.Now()
_, err := ar.exec(context.TODO(), ts, 0)
if err != nil {
t.Fatalf("unexpected error with query template in labels: %s", err)
}
// Verify that the alert was created and the query template was executed
if len(ar.alerts) != 1 {
t.Fatalf("expected 1 alert, got %d", len(ar.alerts))
}
alert := ar.GetAlerts()[0]
suppressLabel, exists := alert.Labels["suppress_for_mass_alert"]
if !exists {
t.Fatalf("expected 'suppress_for_mass_alert' label to exist")
}
// The query template should have been executed (even if it returns false due to mock data)
if suppressLabel != "true" && suppressLabel != "false" {
t.Fatalf("expected 'suppress_for_mass_alert' label to be 'true' or 'false', got '%s'", suppressLabel)
}
}
// TestAlertingRule_ActiveAtPreservedInAnnotations ensures that the fix for
// https://github.com/VictoriaMetrics/VictoriaMetrics/issues/9543 is preserved
// while allowing query templates in labels (https://github.com/VictoriaMetrics/VictoriaMetrics/issues/9783)
func TestAlertingRule_ActiveAtPreservedInAnnotations(t *testing.T) {
// wrap into synctest because of time manipulations
synctest.Test(t, func(t *testing.T) {
fq := &datasource.FakeQuerier{}
ar := &AlertingRule{
Name: "TestActiveAtPreservation",
Labels: map[string]string{
"test_query_in_label": `{{ "static_value" }}`,
},
Annotations: map[string]string{
"description": "Alert active since {{ $activeAt }}",
},
alerts: make(map[uint64]*notifier.Alert),
q: fq,
state: &ruleState{
entries: make([]StateEntry, 10),
},
}
// Mock query result - return empty result to make suppress_for_mass_alert = false
// (no need to add anything to fq for empty result)
// Add a metric that should trigger the alert
fq.Add(metricWithValueAndLabels(t, 1, "instance", "server1"))
// First execution - creates new alert
ts1 := time.Now()
_, err := ar.exec(context.TODO(), ts1, 0)
if err != nil {
t.Fatalf("unexpected error on first exec: %s", err)
}
if len(ar.alerts) != 1 {
t.Fatalf("expected 1 alert, got %d", len(ar.alerts))
}
firstAlert := ar.GetAlerts()[0]
// Verify first execution: activeAt should be ts1 and annotation should reflect it
if !firstAlert.ActiveAt.Equal(ts1) {
t.Fatalf("expected activeAt to be %v, got %v", ts1, firstAlert.ActiveAt)
}
// Extract time from annotation (format will be like "Alert active since 2025-09-30 08:55:13.638551611 -0400 EDT m=+0.002928464")
expectedTimeStr := ts1.Format("2006-01-02 15:04:05")
if !strings.Contains(firstAlert.Annotations["description"], expectedTimeStr) {
t.Fatalf("first exec annotation should contain time %s, got: %s", expectedTimeStr, firstAlert.Annotations["description"])
}
// Second execution - should preserve activeAt in annotation
// Ensure different timestamp with different seconds
// sleep is non-blocking thanks to synctest
time.Sleep(2 * time.Second)
ts2 := time.Now()
_, err = ar.exec(context.TODO(), ts2, 0)
if err != nil {
t.Fatalf("unexpected error on second exec: %s", err)
}
// Get the alert again (should be the same alert)
if len(ar.alerts) != 1 {
t.Fatalf("expected 1 alert, got %d", len(ar.alerts))
}
secondAlert := ar.GetAlerts()[0]
// Critical test: activeAt should still be ts1, not ts2
if !secondAlert.ActiveAt.Equal(ts1) {
t.Fatalf("activeAt should be preserved as %v, but got %v", ts1, secondAlert.ActiveAt)
}
// Critical test: annotation should still contain ts1 time, not ts2
if !strings.Contains(secondAlert.Annotations["description"], expectedTimeStr) {
t.Fatalf("second exec annotation should still contain original time %s, got: %s", expectedTimeStr, secondAlert.Annotations["description"])
}
// Additional verification: annotation should NOT contain ts2 time
ts2TimeStr := ts2.Format("2006-01-02 15:04:05")
if strings.Contains(secondAlert.Annotations["description"], ts2TimeStr) {
t.Fatalf("annotation should NOT contain new eval time %s, got: %s", ts2TimeStr, secondAlert.Annotations["description"])
}
// Verify query template in labels still works (this would fail if query templates were broken)
if firstAlert.Labels["test_query_in_label"] != "static_value" {
t.Fatalf("expected test_query_in_label=static_value, got %s", firstAlert.Labels["test_query_in_label"])
}
})
}

View File

@@ -24,10 +24,6 @@ import (
)
var (
ruleResultsLimit = flag.Int("rule.resultsLimit", 0, "Limits the number of alerts or recording results a single rule can produce. "+
"Can be overridden by the limit option under group if specified. "+
"If exceeded, the rule will be marked with an error and all its results will be discarded. "+
"0 means no limit.")
ruleUpdateEntriesLimit = flag.Int("rule.updateEntriesLimit", 20, "Defines the max number of rule's state updates stored in-memory. "+
"Rule's updates are available on rule's Details page and are used for debugging purposes. The number of stored updates can be overridden per rule via update_entries_limit param.")
resendDelay = flag.Duration("rule.resendDelay", 0, "MiniMum amount of time to wait before resending an alert to notifier.")
@@ -115,6 +111,7 @@ func NewGroup(cfg config.Group, qb datasource.QuerierBuilder, defaultInterval ti
Name: cfg.Name,
File: cfg.File,
Interval: cfg.Interval.Duration(),
Limit: cfg.Limit,
Concurrency: cfg.Concurrency,
checksum: cfg.Checksum,
Params: cfg.Params,
@@ -131,11 +128,6 @@ func NewGroup(cfg config.Group, qb datasource.QuerierBuilder, defaultInterval ti
if g.Interval == 0 {
g.Interval = defaultInterval
}
if cfg.Limit != nil {
g.Limit = *cfg.Limit
} else {
g.Limit = *ruleResultsLimit
}
if g.Concurrency < 1 {
g.Concurrency = 1
}
@@ -296,7 +288,7 @@ func (g *Group) InterruptEval() {
}
}
// Close stops the group and its rules, unregisters group metrics
// Close stops the group and it's rules, unregisters group metrics
func (g *Group) Close() {
if g.doneCh == nil {
return
@@ -305,6 +297,10 @@ func (g *Group) Close() {
g.InterruptEval()
<-g.finishedCh
g.closeGroupMetrics()
}
func (g *Group) closeGroupMetrics() {
metrics.UnregisterSet(g.metrics.set, true)
}
@@ -334,7 +330,7 @@ func (g *Group) Start(ctx context.Context, nts func() []notifier.Notifier, rw re
defer func() { close(g.finishedCh) }()
evalTS := time.Now()
// sleep random duration to spread group rules evaluation
// over time to reduce the load on datasource.
// over time in order to reduce load on datasource.
if !SkipRandSleepOnGroupStart {
sleepBeforeStart := delayBeforeStart(evalTS, g.GetID(), g.Interval, g.EvalOffset)
g.infof("will start in %v", sleepBeforeStart)

View File

@@ -81,37 +81,6 @@ func (rr *RecordingRule) ID() uint64 {
return rr.RuleID
}
// ToAPI returns ApiRule representation of rr
func (rr *RecordingRule) ToAPI() ApiRule {
state := rr.state
lastState := state.getLast()
r := ApiRule{
Type: TypeRecording,
DatasourceType: rr.Type.String(),
Name: rr.Name,
Query: rr.Expr,
Labels: rr.Labels,
LastEvaluation: lastState.Time,
EvaluationTime: lastState.Duration.Seconds(),
Health: "ok",
LastSamples: lastState.Samples,
LastSeriesFetched: lastState.SeriesFetched,
MaxUpdates: state.size(),
Updates: state.getAll(),
// encode as strings to avoid rounding
ID: fmt.Sprintf("%d", rr.ID()),
GroupID: fmt.Sprintf("%d", rr.GroupID),
GroupName: rr.GroupName,
File: rr.File,
}
if lastState.Err != nil {
r.LastError = lastState.Err.Error()
r.Health = "err"
}
return r
}
// NewRecordingRule creates a new RecordingRule
func NewRecordingRule(qb datasource.QuerierBuilder, group *Group, cfg config.Rule) *RecordingRule {
debug := group.Debug

View File

@@ -21,8 +21,6 @@ type Rule interface {
// ID returns unique ID that may be used for
// identifying this Rule among others.
ID() uint64
// ToAPI returns ApiRule representation of Rule
ToAPI() ApiRule
// exec executes the rule with given context at the given timestamp and limit.
// returns an err if number of resulting time series exceeds the limit.
exec(ctx context.Context, ts time.Time, limit int) ([]prompb.TimeSeries, error)
@@ -70,6 +68,39 @@ type StateEntry struct {
Curl string `json:"curl"`
}
// GetLastEntry returns latest stateEntry of rule
func GetLastEntry(r Rule) StateEntry {
if rule, ok := r.(*AlertingRule); ok {
return rule.state.getLast()
}
if rule, ok := r.(*RecordingRule); ok {
return rule.state.getLast()
}
return StateEntry{}
}
// GetRuleStateSize returns size of rule stateEntry
func GetRuleStateSize(r Rule) int {
if rule, ok := r.(*AlertingRule); ok {
return rule.state.size()
}
if rule, ok := r.(*RecordingRule); ok {
return rule.state.size()
}
return 0
}
// GetAllRuleState returns rule entire stateEntries
func GetAllRuleState(r Rule) []StateEntry {
if rule, ok := r.(*AlertingRule); ok {
return rule.state.getAll()
}
if rule, ok := r.(*RecordingRule); ok {
return rule.state.getAll()
}
return []StateEntry{}
}
func (s *ruleState) size() int {
s.RLock()
defer s.RUnlock()

View File

@@ -18,10 +18,8 @@ const (
// ParamRuleID is rule id key in url parameter
ParamRuleID = "rule_id"
// TypeRecording is a RecordingRule type
TypeRecording = "recording"
// TypeAlerting is an AlertingRule type
TypeAlerting = "alerting"
RuleTypeRecording = "recording"
RuleTypeAlerting = "alerting"
)
// ApiGroup represents a Group for web view
@@ -197,8 +195,94 @@ func (ar ApiRule) WebLink() string {
ParamGroupID, ar.GroupID, ParamRuleID, ar.ID)
}
// AlertsToAPI returns list of ApiAlert objects from existing alerts
func (ar *AlertingRule) AlertsToAPI() []*ApiAlert {
func RuleToAPI(r any) ApiRule {
if ar, ok := r.(*AlertingRule); ok {
return alertingToAPI(ar)
}
if rr, ok := r.(*RecordingRule); ok {
return recordingToAPI(rr)
}
return ApiRule{}
}
func recordingToAPI(rr *RecordingRule) ApiRule {
lastState := GetLastEntry(rr)
r := ApiRule{
Type: RuleTypeRecording,
DatasourceType: rr.Type.String(),
Name: rr.Name,
Query: rr.Expr,
Labels: rr.Labels,
LastEvaluation: lastState.Time,
EvaluationTime: lastState.Duration.Seconds(),
Health: "ok",
LastSamples: lastState.Samples,
LastSeriesFetched: lastState.SeriesFetched,
MaxUpdates: GetRuleStateSize(rr),
Updates: GetAllRuleState(rr),
// encode as strings to avoid rounding
ID: fmt.Sprintf("%d", rr.ID()),
GroupID: fmt.Sprintf("%d", rr.GroupID),
GroupName: rr.GroupName,
File: rr.File,
}
if lastState.Err != nil {
r.LastError = lastState.Err.Error()
r.Health = "err"
}
return r
}
// alertingToAPI returns Rule representation in form of ApiRule
func alertingToAPI(ar *AlertingRule) ApiRule {
lastState := GetLastEntry(ar)
r := ApiRule{
Type: RuleTypeAlerting,
DatasourceType: ar.Type.String(),
Name: ar.Name,
Query: ar.Expr,
Duration: ar.For.Seconds(),
KeepFiringFor: ar.KeepFiringFor.Seconds(),
Labels: ar.Labels,
Annotations: ar.Annotations,
LastEvaluation: lastState.Time,
EvaluationTime: lastState.Duration.Seconds(),
Health: "ok",
State: "inactive",
Alerts: RuleToAPIAlert(ar),
LastSamples: lastState.Samples,
LastSeriesFetched: lastState.SeriesFetched,
MaxUpdates: GetRuleStateSize(ar),
Updates: GetAllRuleState(ar),
Debug: ar.Debug,
// encode as strings to avoid rounding in JSON
ID: fmt.Sprintf("%d", ar.ID()),
GroupID: fmt.Sprintf("%d", ar.GroupID),
GroupName: ar.GroupName,
File: ar.File,
}
if lastState.Err != nil {
r.LastError = lastState.Err.Error()
r.Health = "err"
}
// satisfy apiRule.State logic
if len(r.Alerts) > 0 {
r.State = notifier.StatePending.String()
stateFiring := notifier.StateFiring.String()
for _, a := range r.Alerts {
if a.State == stateFiring {
r.State = stateFiring
break
}
}
}
return r
}
// RuleToAPIAlert generates list of apiAlert objects from existing alerts
func RuleToAPIAlert(ar *AlertingRule) []*ApiAlert {
var alerts []*ApiAlert
for _, a := range ar.GetAlerts() {
if a.State == notifier.StateInactive {
@@ -210,7 +294,7 @@ func (ar *AlertingRule) AlertsToAPI() []*ApiAlert {
}
// AlertToAPI generates apiAlert object from alert by its id(hash)
func (ar *AlertingRule) AlertToAPI(id uint64) *ApiAlert {
func AlertToAPI(ar *AlertingRule, id uint64) *ApiAlert {
a := ar.GetAlert(id)
if a == nil {
return nil
@@ -244,8 +328,7 @@ func NewAlertAPI(ar *AlertingRule, a *notifier.Alert) *ApiAlert {
return aa
}
// ToAPI returns ApiGroup representation of g
func (g *Group) ToAPI() *ApiGroup {
func GroupToAPI(g *Group) *ApiGroup {
g.mu.RLock()
defer g.mu.RUnlock()
ag := ApiGroup{
@@ -270,7 +353,7 @@ func (g *Group) ToAPI() *ApiGroup {
}
ag.Rules = make([]ApiRule, 0)
for _, r := range g.Rules {
ag.Rules = append(ag.Rules, r.ToAPI())
ag.Rules = append(ag.Rules, RuleToAPI(r))
}
return &ag
}

View File

@@ -37,7 +37,7 @@ func TestRecordingToApi(t *testing.T) {
Query: "up",
Labels: map[string]string{"label": "value"},
Health: "ok",
Type: TypeRecording,
Type: RuleTypeRecording,
DatasourceType: "prometheus",
ID: "1248",
GroupID: fmt.Sprintf("%d", g.CreateID()),
@@ -47,7 +47,7 @@ func TestRecordingToApi(t *testing.T) {
Updates: make([]StateEntry, 0),
}
res := rr.ToAPI()
res := recordingToAPI(rr)
if !reflect.DeepEqual(res, expectedRes) {
t.Fatalf("expected to have: \n%v;\ngot: \n%v", expectedRes, res)

View File

@@ -47,8 +47,8 @@ var (
{Name: "Docs", URL: "https://docs.victoriametrics.com/victoriametrics/vmalert/"},
}
ruleTypeMap = map[string]string{
"alert": rule.TypeAlerting,
"record": rule.TypeRecording,
"alert": rule.RuleTypeAlerting,
"record": rule.RuleTypeRecording,
}
)
@@ -347,7 +347,7 @@ func (rh *requestHandler) groups(rf *rulesFilter) []*rule.ApiGroup {
if !rf.matchesGroup(group) {
continue
}
g := group.ToAPI()
g := rule.GroupToAPI(group)
// the returned list should always be non-nil
// https://github.com/VictoriaMetrics/VictoriaMetrics/issues/4221
filteredRules := make([]rule.ApiRule, 0)
@@ -419,11 +419,11 @@ func (rh *requestHandler) groupAlerts() []rule.GroupAlerts {
if !ok {
continue
}
alerts = append(alerts, a.AlertsToAPI()...)
alerts = append(alerts, rule.RuleToAPIAlert(a)...)
}
if len(alerts) > 0 {
gAlerts = append(gAlerts, rule.GroupAlerts{
Group: g.ToAPI(),
Group: rule.GroupToAPI(g),
Alerts: alerts,
})
}
@@ -449,7 +449,7 @@ func (rh *requestHandler) listAlerts(rf *rulesFilter) ([]byte, error) {
if !ok {
continue
}
lr.Data.Alerts = append(lr.Data.Alerts, a.AlertsToAPI()...)
lr.Data.Alerts = append(lr.Data.Alerts, rule.RuleToAPIAlert(a)...)
}
}

View File

@@ -85,13 +85,13 @@ func TestHandler(t *testing.T) {
})
t.Run("/vmalert/rule", func(t *testing.T) {
a := ar.ToAPI()
a := rule.RuleToAPI(ar)
getResp(t, ts.URL+"/vmalert/"+a.WebLink(), nil, 200)
r := rr.ToAPI()
r := rule.RuleToAPI(rr)
getResp(t, ts.URL+"/vmalert/"+r.WebLink(), nil, 200)
})
t.Run("/vmalert/alert", func(t *testing.T) {
alerts := ar.AlertsToAPI()
alerts := rule.RuleToAPIAlert(ar)
for _, a := range alerts {
getResp(t, ts.URL+"/vmalert/"+a.WebLink(), nil, 200)
}
@@ -170,7 +170,7 @@ func TestHandler(t *testing.T) {
}
})
t.Run("/api/v1/rule?ruleID&groupID", func(t *testing.T) {
expRule := ar.ToAPI()
expRule := rule.RuleToAPI(ar)
gotRule := rule.ApiRule{}
getResp(t, ts.URL+"/"+expRule.APILink(), &gotRule, 200)
@@ -194,7 +194,7 @@ func TestHandler(t *testing.T) {
t.Run("/api/v1/group?groupID", func(t *testing.T) {
id := groupIDs[0]
g := m.groups[id]
expGroup := g.ToAPI()
expGroup := rule.GroupToAPI(g)
gotGroup := rule.ApiGroup{}
getResp(t, ts.URL+"/"+expGroup.APILink(), &gotGroup, 200)
if expGroup.ID != gotGroup.ID {

View File

@@ -372,54 +372,20 @@ func tryProcessingRequest(w http.ResponseWriter, r *http.Request, targetURL *url
updateHeadersByConfig(w.Header(), hc.ResponseHeaders)
w.WriteHeader(res.StatusCode)
err = copyStreamToClient(w, res.Body)
copyBuf := copyBufPool.Get()
copyBuf.B = bytesutil.ResizeNoCopyNoOverallocate(copyBuf.B, 16*1024)
_, err = io.CopyBuffer(w, res.Body, copyBuf.B)
copyBufPool.Put(copyBuf)
_ = res.Body.Close()
if err != nil && !netutil.IsTrivialNetworkError(err) && !errors.Is(err, context.Canceled) {
if err != nil && !netutil.IsTrivialNetworkError(err) {
remoteAddr := httpserver.GetQuotedRemoteAddr(r)
requestURI := httpserver.GetRequestURI(r)
logger.Warnf("remoteAddr: %s; requestURI: %s; error when proxying response body from %s: %s", remoteAddr, requestURI, targetURL, err)
return true, false
}
return true, false
}
func copyStreamToClient(client io.Writer, backend io.Reader) error {
copyBuf := copyBufPool.Get()
copyBuf.B = bytesutil.ResizeNoCopyNoOverallocate(copyBuf.B, 16*1024)
defer copyBufPool.Put(copyBuf)
buf := copyBuf.B
flusher, ok := client.(http.Flusher)
if !ok {
logger.Panicf("BUG: client must implement net/http.Flusher interface; got %T", client)
}
for {
n, backendErr := backend.Read(buf)
if n > 0 {
data := buf[:n]
n, clientErr := client.Write(data)
if clientErr != nil {
return fmt.Errorf("cannot write data to client: %w", clientErr)
}
if n != len(data) {
logger.Panicf("BUG: unexpected number of bytes written returned by client.Write; got %d; want %d", n, len(data))
}
// Flush the read data from the backend to the client as fast as possible
// in order to reduce delays for data propagation.
// See https://github.com/VictoriaMetrics/VictoriaLogs/issues/667
flusher.Flush()
}
if backendErr != nil {
if backendErr == io.EOF {
return nil
}
return fmt.Errorf("cannot read data from backend: %w", backendErr)
}
}
}
var copyBufPool bytesutil.ByteBufferPool
func copyHeader(dst, src http.Header) {

View File

@@ -514,11 +514,6 @@ func (w *fakeResponseWriter) getResponse() string {
return w.bb.String()
}
// Flush implements net/http.Flusher
func (w *fakeResponseWriter) Flush() {
// Nothing to do.
}
func (w *fakeResponseWriter) Header() http.Header {
if w.h == nil {
w.h = http.Header{}

View File

@@ -115,7 +115,7 @@ func main() {
if err != nil {
logger.Fatalf("cannot create backup: %s", err)
}
pushmetrics.StopAndPush()
pushmetrics.Stop()
startTime := time.Now()
logger.Infof("gracefully shutting down http server for metrics at %q", listenAddrs)

View File

@@ -424,84 +424,6 @@ var (
}
)
const (
mimirPath = "mimir-path"
mimirTenantID = "mimir-tenant-id"
mimirConcurrency = "mimir-concurrency"
mimirFilterTimeStart = "mimir-filter-time-start"
mimirFilterTimeEnd = "mimir-filter-time-end"
mimirFilterLabel = "mimir-filter-label"
mimirFilterLabelValue = "mimir-filter-label-value"
mimirCredsFilePath = "mimir-creds-file-path"
mimirConfigFilePath = "mimir-config-file-path"
mimirConfigProfile = "mimir-config-profile"
mimirCustomS3Endpoint = "mimir-custom-s3-endpoint"
mimirS3ForcePathStyle = "mimir-s3-force-path-style"
mimirS3TLSInsecureSkipVerify = "mimir-s3-tls-insecure-skip-verify"
)
var (
mimirFlags = []cli.Flag{
&cli.StringFlag{
Name: mimirPath,
Usage: "Path to Mimir storage bucket or local folder.",
Required: true,
},
&cli.StringFlag{
Name: mimirTenantID,
Usage: "Tenant ID for Mimir storage",
},
&cli.IntFlag{
Name: mimirConcurrency,
Usage: "Number of concurrently running block readers",
},
&cli.StringFlag{
Name: mimirFilterTimeStart,
Usage: "The time filter in RFC3339 format to select timeseries with timestamp equal or higher than provided value. E.g. '2020-01-01T20:07:00Z'",
Required: true,
},
&cli.StringFlag{
Name: mimirFilterTimeEnd,
Usage: "The time filter in RFC3339 format to select timeseries with timestamp equal or lower than provided value. E.g. '2020-01-01T20:07:00Z'",
Required: true,
},
&cli.StringFlag{
Name: mimirFilterLabel,
Usage: "Prometheus label name to filter timeseries by. E.g. '__name__' will filter timeseries by name.",
},
&cli.StringFlag{
Name: mimirFilterLabelValue,
Usage: fmt.Sprintf("Prometheus regular expression to filter label from %q flag.", promFilterLabel),
Value: ".*",
},
&cli.StringFlag{
Name: mimirCredsFilePath,
Usage: "Path to file with GCS or S3 credentials. Credentials are loaded from default locations if not set. See https://cloud.google.com/iam/docs/creating-managing-service-account-keys and https://docs.aws.amazon.com/general/latest/gr/aws-security-credentials.html",
},
&cli.StringFlag{
Name: mimirConfigFilePath,
Usage: "Path to file with S3 configs. Configs are loaded from default location if not set. See https://docs.aws.amazon.com/general/latest/gr/aws-security-credentials.html",
},
&cli.StringFlag{
Name: mimirConfigProfile,
Usage: "Profile name for S3 configs. If no set, the value of the environment variable will be loaded (AWS_PROFILE or AWS_DEFAULT_PROFILE), or if both not set, DefaultSharedConfigProfile is used",
},
&cli.StringFlag{
Name: mimirCustomS3Endpoint,
Usage: "Custom S3 endpoint for use with S3-compatible storages (e.g. MinIO). S3 is used if not set",
},
&cli.BoolFlag{
Name: mimirS3ForcePathStyle,
Usage: "Prefixing endpoint with bucket name when set false, true by default.",
},
&cli.BoolFlag{
Name: mimirS3TLSInsecureSkipVerify,
Usage: "Whether to skip TLS verification when connecting to the S3 endpoint.",
},
}
)
const (
vmNativeFilterMatch = "vm-native-filter-match"
vmNativeFilterTimeStart = "vm-native-filter-time-start"

View File

@@ -17,7 +17,6 @@ import (
"github.com/VictoriaMetrics/VictoriaMetrics/app/vmctl/auth"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vmctl/backoff"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vmctl/barpool"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vmctl/mimir"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vmctl/native"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vmctl/remoteread"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/netutil"
@@ -272,54 +271,7 @@ func main() {
cc: c.Int(promConcurrency),
isVerbose: c.Bool(globalVerbose),
}
return pp.run(ctx)
},
},
{
Name: "mimir",
Usage: "Migrate time series from Mimir object storage or local filesystem",
Flags: mergeFlags(globalFlags, mimirFlags, vmFlags),
Before: beforeFn,
Action: func(c *cli.Context) error {
fmt.Println("Mimir import mode")
vmCfg, err := initConfigVM(c)
if err != nil {
return fmt.Errorf("failed to init VM configuration: %s", err)
}
importer, err = vm.NewImporter(ctx, vmCfg)
if err != nil {
return fmt.Errorf("failed to create VM importer: %s", err)
}
mCfg := mimir.Config{
Filter: mimir.Filter{
TimeMin: c.String(mimirFilterTimeStart),
TimeMax: c.String(mimirFilterTimeEnd),
Label: c.String(mimirFilterLabel),
LabelValue: c.String(mimirFilterLabelValue),
},
Path: c.String(mimirPath),
TenantID: c.String(mimirTenantID),
CredsFilePath: c.String(mimirCredsFilePath),
ConfigFilePath: c.String(mimirConfigFilePath),
ConfigProfile: c.String(mimirConfigProfile),
CustomS3Endpoint: c.String(mimirCustomS3Endpoint),
S3ForcePathStyle: c.Bool(mimirS3ForcePathStyle),
S3TLSInsecureSkipVerify: c.Bool(mimirS3TLSInsecureSkipVerify),
}
cl, err := mimir.NewClient(ctx, mCfg)
if err != nil {
return fmt.Errorf("failed to create mimir client: %s", err)
}
pp := prometheusProcessor{
cl: cl,
im: importer,
cc: c.Int(mimirConcurrency),
isVerbose: c.Bool(globalVerbose),
}
return pp.run(ctx)
return pp.run()
},
},
{

View File

@@ -1,184 +0,0 @@
package mimir
import (
"fmt"
"log"
"os"
"path/filepath"
"sync"
"github.com/oklog/ulid/v2"
"github.com/prometheus/prometheus/tsdb"
"github.com/prometheus/prometheus/tsdb/tombstones"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/backup/common"
)
var _ tsdb.BlockReader = (*LazyBlockReader)(nil)
// LazyBlockReader is stores block id and segment num information.
// It is used to lazily fetch and parse block data.
// It implements tsdb.BlockReader interface.
type LazyBlockReader struct {
// Block ID.
ID ulid.ULID
// SegmentsNum stores the number of chunks segments in the block.
SegmentsNum int
mu sync.Mutex
reader tsdb.BlockReader
fs common.RemoteFS
err error
}
// NewLazyBlockReader returns a new LazyBlockReader for the given block.
func NewLazyBlockReader(block *Block, fs common.RemoteFS) (*LazyBlockReader, error) {
if block.SegmentsFormat != "1b6d" {
return nil, fmt.Errorf("unsupported segments format: %s", block.SegmentsFormat)
}
return &LazyBlockReader{
ID: block.ID,
SegmentsNum: block.SegmentsNum,
fs: fs,
}, nil
}
func (lbr *LazyBlockReader) initialize() error {
lbr.mu.Lock()
defer lbr.mu.Unlock()
if lbr.reader != nil {
return nil
}
// fetching block and parse it and store it in lbr.reader
temp, err := lbr.mkTempDir()
if err != nil {
return fmt.Errorf("failed to create temp dir: %s", err)
}
defer func() {
if err := os.RemoveAll(temp); err != nil {
log.Printf("failed to remove temp dir: %s", err)
}
log.Printf("removed temp dir: %s", temp)
}()
meta, err := lbr.fetchFile(metaFilename)
if err != nil {
return err
}
if err := lbr.writeFile(temp, metaFilename, meta); err != nil {
log.Printf("failed to write meta file: %s", err)
return err
}
idx, err := lbr.fetchFile(indexFilename)
if err != nil {
log.Printf("failed to fetch index file %q: %s", indexFilename, err)
return err
}
if err := lbr.writeFile(temp, indexFilename, idx); err != nil {
return err
}
for i := 1; i <= lbr.SegmentsNum; i++ {
// segments formats has format 1b06d
// https://github.com/grafana/mimir/blob/main/pkg/storage/tsdb/bucketindex/index.go#L32
chunkName := fmt.Sprintf("%06d", i)
blockChunkPath := filepath.Join("chunks", chunkName)
chunk, err := lbr.fetchFile(blockChunkPath)
if err != nil {
log.Printf("failed to fetch chunk file: %q: %s", chunkName, err)
return err
}
if err := lbr.writeFile(temp, blockChunkPath, chunk); err != nil {
log.Printf("failed to write chunk file: %q: %s", chunkName, err)
return err
}
}
// Set postingDecoder to nil because
// If it is nil then a default decoder is used, compatible with Prometheus v2.
pb, err := tsdb.OpenBlock(nil, temp, nil, nil)
if err != nil {
return fmt.Errorf("failed to open block %q: %s", lbr.ID, err)
}
lbr.reader = pb
return nil
}
// Index returns an IndexReader over the block's data.
func (lbr *LazyBlockReader) Index() (tsdb.IndexReader, error) {
if err := lbr.initialize(); err != nil {
return nil, err
}
return lbr.reader.Index()
}
// Chunks returns a ChunkReader over the block's data.
func (lbr *LazyBlockReader) Chunks() (tsdb.ChunkReader, error) {
if err := lbr.initialize(); err != nil {
return nil, err
}
return lbr.reader.Chunks()
}
// Tombstones returns a tombstones.Reader over the block's deleted data.
func (lbr *LazyBlockReader) Tombstones() (tombstones.Reader, error) {
if err := lbr.initialize(); err != nil {
return nil, err
}
return lbr.reader.Tombstones()
}
// Meta provides meta information about the block reader.
func (lbr *LazyBlockReader) Meta() tsdb.BlockMeta {
if err := lbr.initialize(); err != nil {
lbr.err = fmt.Errorf("error get Block Meta: %s; return empty block", err)
return tsdb.BlockMeta{}
}
return lbr.reader.Meta()
}
// Size returns the number of bytes that the block takes up on disk.
func (lbr *LazyBlockReader) Size() int64 {
if err := lbr.initialize(); err != nil {
lbr.err = fmt.Errorf("error get Size of the block: %s, return zero size", err)
return 0
}
return lbr.reader.Size()
}
// Err returns the last error that occurred on the block reader.
func (lbr *LazyBlockReader) Err() error {
return lbr.err
}
func (lbr *LazyBlockReader) mkTempDir() (string, error) {
temp, err := os.MkdirTemp("", lbr.ID.String())
if err != nil {
return "", fmt.Errorf("failed to create temp dir: %s", err)
}
err = os.Mkdir(filepath.Join(temp, "chunks"), 0755)
if err != nil {
return "", fmt.Errorf("failed to create temp dir: %s", err)
}
return temp, nil
}
func (lbr *LazyBlockReader) fetchFile(filePath string) ([]byte, error) {
blockID := lbr.ID.String()
blockPath := filepath.Join(blockID, filePath)
has, err := lbr.fs.HasFile(blockPath)
if err != nil {
return nil, err
}
if !has {
return nil, fmt.Errorf("block meta %s not found", blockID)
}
return lbr.fs.ReadFile(blockPath)
}
func (lbr *LazyBlockReader) writeFile(folder string, filename string, file []byte) error {
fileName := filepath.Join(folder, filename)
return os.WriteFile(fileName, file, 0644)
}

View File

@@ -1,241 +0,0 @@
package mimir
import (
"bytes"
"compress/gzip"
"context"
"encoding/json"
"fmt"
"log"
"github.com/oklog/ulid/v2"
"github.com/prometheus/prometheus/model/labels"
"github.com/prometheus/prometheus/storage"
"github.com/prometheus/prometheus/tsdb"
utils "github.com/VictoriaMetrics/VictoriaMetrics/app/vmctl/vmctlutil"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/backup/common"
)
const (
bucketIndex = "bucket-index.json"
bucketIndexCompressedFilename = bucketIndex + ".gz"
metaFilename = "meta.json"
indexFilename = "index"
)
// BlockDeletionMark holds the information about a block's deletion mark in the index.
// This type was copied from the mimir repository https://github.com/grafana/mimir/blob/main/pkg/storage/tsdb/bucketindex/index.go#L234.
type BlockDeletionMark struct {
// Block ID.
ID ulid.ULID `json:"block_id"`
// DeletionTime is a unix timestamp (seconds precision) of when the block was marked to be deleted.
DeletionTime int64 `json:"deletion_time"`
}
// Block holds the information about a block in the index.
// This is a partial implementation of the https://github.com/grafana/mimir/blob/main/pkg/storage/tsdb/bucketindex/index.go#L73
type Block struct {
// Block ID.
ID ulid.ULID `json:"block_id"`
// MinTime and MaxTime specify the time range all samples in the block are in (millis precision).
MinTime int64 `json:"min_time"`
MaxTime int64 `json:"max_time"`
// SegmentsFormat and SegmentsNum stores the format and number of chunks segments
// in the block.
SegmentsFormat string `json:"segments_format,omitempty"`
SegmentsNum int `json:"segments_num,omitempty"`
}
// Index contains all known blocks and markers of a tenant.
// This is a partial implementation pof the https://github.com/grafana/mimir/blob/main/pkg/storage/tsdb/bucketindex/index.go#L36
type Index struct {
// Version of the index format.
Version int `json:"version"`
// List of complete blocks (partial blocks are excluded from the index).
Blocks []*Block `json:"blocks"`
}
// Config contains a list of params needed
// for reading Prometheus snapshots
type Config struct {
// Path to remote storage bucket
Path string
// TenantID is the tenant id for the storage
TenantID string
Filter Filter
CredsFilePath string
ConfigFilePath string
ConfigProfile string
CustomS3Endpoint string
S3ForcePathStyle bool
S3TLSInsecureSkipVerify bool
}
// Filter contains configuration for filtering
// the timeseries
type Filter struct {
TimeMin string
TimeMax string
Label string
LabelValue string
}
// Client is a wrapper over Prometheus tsdb.DBReader
type Client struct {
common.RemoteFS
filter filter
}
type filter struct {
min, max int64
label string
labelValue string
}
func (f filter) inRange(minTime, maxTime int64) bool {
fmin, fmax := f.min, f.max
if minTime == 0 {
fmin = minTime
}
if fmax == 0 {
fmax = maxTime
}
return minTime <= fmax && fmin <= maxTime
}
// NewClient creates and validates new Client
// with given Config
func NewClient(ctx context.Context, cfg Config) (*Client, error) {
if cfg.Path == "" {
return nil, fmt.Errorf("path cannot be empty")
}
if cfg.TenantID != "" {
cfg.Path = fmt.Sprintf("%s/%s", cfg.Path, cfg.TenantID)
}
var c Client
rfs, err := NewRemoteFS(ctx, cfg)
if err != nil {
return nil, fmt.Errorf("cannot parse `-src`=%q: %w", cfg.Path, err)
}
c.RemoteFS = rfs
timeMin, err := utils.ParseTime(cfg.Filter.TimeMin)
if err != nil {
return nil, fmt.Errorf("failed to parse min time in filter: %s", err)
}
timeMax, err := utils.ParseTime(cfg.Filter.TimeMax)
if err != nil {
return nil, fmt.Errorf("failed to parse max time in filter: %s", err)
}
c.filter = filter{
min: timeMin.UnixMilli(),
max: timeMax.UnixMilli(),
label: cfg.Filter.Label,
labelValue: cfg.Filter.LabelValue,
}
return &c, nil
}
// Explore a fetches bucket-index.json file from a remote storage or local filesystem
// and filter blocks via the defined time range, but does not take into account label filters.
func (c *Client) Explore() ([]tsdb.BlockReader, error) {
s := &utils.Stats{
Filtered: c.filter.min != 0 || c.filter.max != 0 || c.filter.label != "",
}
log.Printf("Fetching blocks from remote storage")
indexFile, err := c.fetchIndexFile()
if err != nil {
return nil, fmt.Errorf("failed to fetch index file: %s", err)
}
var blocksToImport []tsdb.BlockReader
for _, block := range indexFile.Blocks {
if !c.filter.inRange(block.MinTime, block.MaxTime) {
// Skipping block outside of time range
continue
}
if block.ID.String() == "" {
continue
}
lazyBlockReader, err := NewLazyBlockReader(block, c.RemoteFS)
if err != nil {
return nil, fmt.Errorf("failed to create lazy block reader: %s", err)
}
blocksToImport = append(blocksToImport, lazyBlockReader)
}
s.Blocks = len(blocksToImport)
return blocksToImport, nil
}
// Read reads the given BlockReader according to configured
// time and label filters.
func (c *Client) Read(ctx context.Context, block tsdb.BlockReader) (storage.SeriesSet, error) {
meta := block.Meta()
if b, ok := block.(*LazyBlockReader); ok && b.Err() != nil {
return nil, fmt.Errorf("failed to read block: %s", b.Err())
}
if meta.ULID.String() == "" {
log.Printf("got block without the id. it is empty")
return nil, fmt.Errorf("block without id")
}
minTime, maxTime := meta.MinTime, meta.MaxTime
if c.filter.min != 0 {
minTime = c.filter.min
}
if c.filter.max != 0 {
maxTime = c.filter.max
}
q, err := tsdb.NewBlockQuerier(block, minTime, maxTime)
if err != nil {
return nil, err
}
ss := q.Select(ctx, false, nil, labels.MustNewMatcher(labels.MatchRegexp, c.filter.label, c.filter.labelValue))
return ss, nil
}
func (c *Client) fetchIndexFile() (*Index, error) {
has, err := c.HasFile(bucketIndexCompressedFilename)
if err != nil {
return nil, err
}
if !has {
return nil, fmt.Errorf("bucket-index.json.gz not found")
}
file, err := c.ReadFile(bucketIndexCompressedFilename)
if err != nil {
return nil, fmt.Errorf("failed to read bucket index: %s", err)
}
r := bytes.NewReader(file)
// Read all the content.
gzipReader, err := gzip.NewReader(r)
if err != nil {
return nil, fmt.Errorf("failed to create gzip reader: %s", err)
}
var indexFile Index
err = json.NewDecoder(gzipReader).Decode(&indexFile)
if err != nil {
return nil, fmt.Errorf("failed to decode bucket index: %s", err)
}
return &indexFile, nil
}

View File

@@ -1,91 +0,0 @@
package mimir
import (
"context"
"fmt"
"path/filepath"
"strings"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/backup/azremote"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/backup/common"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/backup/fsremote"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/backup/gcsremote"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/backup/s3remote"
)
// NewRemoteFS returns new remote fs from the given Config.
func NewRemoteFS(ctx context.Context, cfg Config) (common.RemoteFS, error) {
if len(cfg.Path) == 0 {
return nil, fmt.Errorf("path cannot be empty")
}
n := strings.Index(cfg.Path, "://")
if n < 0 {
return nil, fmt.Errorf("missing scheme in path %q. Supported schemes: `gs://`, `s3://`, `azblob://`, `fs://`", cfg.Path)
}
scheme := cfg.Path[:n]
dir := cfg.Path[n+len("://"):]
switch scheme {
case "fs":
if !filepath.IsAbs(dir) {
return nil, fmt.Errorf("dir must be absolute; got %q", dir)
}
fsr := &fsremote.FS{
Dir: filepath.Clean(dir),
}
return fsr, nil
case "gcs", "gs":
n := strings.Index(dir, "/")
if n < 0 {
return nil, fmt.Errorf("missing directory on the gcs bucket %q", dir)
}
bucket := dir[:n]
dir = dir[n:]
fsr := &gcsremote.FS{
CredsFilePath: cfg.CredsFilePath,
Bucket: bucket,
Dir: dir,
}
if err := fsr.Init(ctx); err != nil {
return nil, fmt.Errorf("cannot initialize connection to gcs: %w", err)
}
return fsr, nil
case "azblob":
n := strings.Index(dir, "/")
if n < 0 {
return nil, fmt.Errorf("missing directory on the AZBlob container %q", dir)
}
bucket := dir[:n]
dir = dir[n:]
fsr := &azremote.FS{
Container: bucket,
Dir: dir,
}
if err := fsr.Init(ctx); err != nil {
return nil, fmt.Errorf("cannot initialize connection to AZBlob: %w", err)
}
return fsr, nil
case "s3":
n := strings.Index(dir, "/")
if n < 0 {
return nil, fmt.Errorf("missing directory on the s3 bucket %q", dir)
}
bucket := dir[:n]
dir = dir[n:]
fsr := &s3remote.FS{
CredsFilePath: cfg.CredsFilePath,
ConfigFilePath: cfg.ConfigFilePath,
CustomEndpoint: cfg.CustomS3Endpoint,
TLSInsecureSkipVerify: cfg.S3TLSInsecureSkipVerify,
S3ForcePathStyle: cfg.S3ForcePathStyle,
ProfileName: cfg.ConfigProfile,
Bucket: bucket,
Dir: dir,
}
if err := fsr.Init(ctx); err != nil {
return nil, fmt.Errorf("cannot initialize connection to s3: %w", err)
}
return fsr, nil
default:
return nil, fmt.Errorf("unsupported scheme %q", scheme)
}
}

View File

@@ -1,30 +1,22 @@
package main
import (
"context"
"fmt"
"log"
"sync"
"github.com/prometheus/prometheus/storage"
"github.com/prometheus/prometheus/tsdb"
"github.com/prometheus/prometheus/tsdb/chunkenc"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vmctl/barpool"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vmctl/prometheus"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vmctl/vm"
)
// Runner is an interface for fetching and reading
// snapshot blocks
type Runner interface {
Explore() ([]tsdb.BlockReader, error)
Read(context.Context, tsdb.BlockReader) (storage.SeriesSet, error)
}
type prometheusProcessor struct {
// Runner fetches and reads
// prometheus client fetches and reads
// snapshot blocks
cl Runner
cl *prometheus.Client
// importer performs import requests
// for timeseries data returned from
// snapshot blocks
@@ -38,7 +30,7 @@ type prometheusProcessor struct {
isVerbose bool
}
func (pp *prometheusProcessor) run(ctx context.Context) error {
func (pp *prometheusProcessor) run() error {
blocks, err := pp.cl.Explore()
if err != nil {
return fmt.Errorf("explore failed: %s", err)
@@ -51,7 +43,7 @@ func (pp *prometheusProcessor) run(ctx context.Context) error {
return nil
}
if err := pp.processBlocks(ctx, blocks); err != nil {
if err := pp.processBlocks(blocks); err != nil {
return fmt.Errorf("migration failed: %s", err)
}
@@ -60,8 +52,8 @@ func (pp *prometheusProcessor) run(ctx context.Context) error {
return nil
}
func (pp *prometheusProcessor) do(ctx context.Context, b tsdb.BlockReader) error {
ss, err := pp.cl.Read(ctx, b)
func (pp *prometheusProcessor) do(b tsdb.BlockReader) error {
ss, err := pp.cl.Read(b)
if err != nil {
return fmt.Errorf("failed to read block: %s", err)
}
@@ -117,7 +109,7 @@ func (pp *prometheusProcessor) do(ctx context.Context, b tsdb.BlockReader) error
return ss.Err()
}
func (pp *prometheusProcessor) processBlocks(ctx context.Context, blocks []tsdb.BlockReader) error {
func (pp *prometheusProcessor) processBlocks(blocks []tsdb.BlockReader) error {
bar := barpool.AddWithTemplate(fmt.Sprintf(barTpl, "Processing blocks"), len(blocks))
if err := barpool.Start(); err != nil {
return err
@@ -134,7 +126,7 @@ func (pp *prometheusProcessor) processBlocks(ctx context.Context, blocks []tsdb.
go func() {
defer wg.Done()
for br := range blockReadersCh {
if err := pp.do(ctx, br); err != nil {
if err := pp.do(br); err != nil {
errCh <- fmt.Errorf("read failed for block %q: %s", br.Meta().ULID, err)
return
}

View File

@@ -8,8 +8,6 @@ import (
"github.com/prometheus/prometheus/model/labels"
"github.com/prometheus/prometheus/storage"
"github.com/prometheus/prometheus/tsdb"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vmctl/vmctlutil"
)
// Config contains a list of params needed
@@ -62,13 +60,13 @@ func NewClient(cfg Config) (*Client, error) {
return nil, fmt.Errorf("failed to open snapshot %q: %s", cfg.Snapshot, err)
}
c := &Client{DBReadOnly: db}
timeMin, timeMax, err := parseTime(cfg.Filter.TimeMin, cfg.Filter.TimeMax)
minTime, maxTime, err := parseTime(cfg.Filter.TimeMin, cfg.Filter.TimeMax)
if err != nil {
return nil, fmt.Errorf("failed to parse time in filter: %s", err)
}
c.filter = filter{
min: timeMin,
max: timeMax,
min: minTime,
max: maxTime,
label: cfg.Filter.Label,
labelValue: cfg.Filter.LabelValue,
}
@@ -85,7 +83,7 @@ func (c *Client) Explore() ([]tsdb.BlockReader, error) {
if err != nil {
return nil, fmt.Errorf("failed to fetch blocks: %s", err)
}
s := &vmctlutil.Stats{
s := &Stats{
Filtered: c.filter.min != 0 || c.filter.max != 0 || c.filter.label != "",
Blocks: len(blocks),
}
@@ -112,7 +110,7 @@ func (c *Client) Explore() ([]tsdb.BlockReader, error) {
// Read reads the given BlockReader according to configured
// time and label filters.
func (c *Client) Read(ctx context.Context, block tsdb.BlockReader) (storage.SeriesSet, error) {
func (c *Client) Read(block tsdb.BlockReader) (storage.SeriesSet, error) {
minTime, maxTime := block.Meta().MinTime, block.Meta().MaxTime
if c.filter.min != 0 {
minTime = c.filter.min
@@ -124,7 +122,7 @@ func (c *Client) Read(ctx context.Context, block tsdb.BlockReader) (storage.Seri
if err != nil {
return nil, err
}
ss := q.Select(ctx, false, nil, labels.MustNewMatcher(labels.MatchRegexp, c.filter.label, c.filter.labelValue))
ss := q.Select(context.Background(), false, nil, labels.MustNewMatcher(labels.MatchRegexp, c.filter.label, c.filter.labelValue))
return ss, nil
}

View File

@@ -1,4 +1,4 @@
package vmctlutil
package prometheus
import (
"fmt"

View File

@@ -207,6 +207,7 @@ func (im *Importer) Input(ts *TimeSeries) error {
// and waits until they are finished
func (im *Importer) Close() {
im.once.Do(func() {
close(im.close)
close(im.input)
im.wg.Wait()
close(im.errors)
@@ -236,17 +237,7 @@ func (im *Importer) startWorker(ctx context.Context, bar barpool.Bar, batchSize,
return
case ts, ok := <-im.input:
if !ok {
// drain all batches before exit
exitErr := &ImportError{
Batch: batch,
}
retryableFunc := func() error { return im.Import(batch) }
_, err := im.backoff.Retry(ctx, retryableFunc)
if err != nil {
exitErr.Err = err
}
im.errors <- exitErr
return
continue
}
// init waitForBatch when first
// value was received

View File

@@ -68,7 +68,7 @@ func main() {
if err := a.Run(ctx); err != nil {
logger.Fatalf("cannot restore from backup: %s", err)
}
pushmetrics.StopAndPush()
pushmetrics.Stop()
srcFS.MustStop()
dstFS.MustStop()

View File

@@ -197,13 +197,13 @@ func newNextSeriesForSearchQuery(ec *evalConfig, sq *storage.SearchQuery, expr g
}
s.summarize(aggrAvg, ec.startTime, ec.endTime, ec.storageStep, 0)
t := timerpool.Get(30 * time.Second)
defer timerpool.Put(t)
select {
case seriesCh <- s:
case <-t.C:
logger.Errorf("resource leak when processing the %s (full query: %s); please report this error to VictoriaMetrics developers",
expr.AppendString(nil), ec.originalQuery)
}
timerpool.Put(t)
return nil
})
close(seriesCh)

View File

@@ -11,8 +11,6 @@ import (
"strings"
"time"
"github.com/VictoriaMetrics/metrics"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vmselect/graphite"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vmselect/netstorage"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vmselect/prometheus"
@@ -20,7 +18,6 @@ import (
"github.com/VictoriaMetrics/VictoriaMetrics/app/vmselect/searchutil"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vmselect/stats"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vmstorage"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/buildinfo"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/cgroup"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/flagutil"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/fs"
@@ -30,6 +27,7 @@ import (
"github.com/VictoriaMetrics/VictoriaMetrics/lib/promscrape"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/querytracer"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/timerpool"
"github.com/VictoriaMetrics/metrics"
)
var (
@@ -742,7 +740,6 @@ var (
func initVMUIConfig() {
var cfg struct {
Version string `json:"version"`
License struct {
Type string `json:"type"`
} `json:"license"`
@@ -758,11 +755,6 @@ func initVMUIConfig() {
if err != nil {
logger.Fatalf("cannot parse vmui default config: %s", err)
}
cfg.Version = buildinfo.ShortVersion()
if cfg.Version == "" {
// buildinfo.ShortVersion() may return empty result for builds without tags
cfg.Version = buildinfo.Version
}
cfg.VMAlert.Enabled = len(*vmalertProxyURL) != 0
data, err = json.Marshal(&cfg)
if err != nil {

View File

@@ -1150,23 +1150,15 @@ func evalInstantRollup(qt *querytracer.Tracer, ec *EvalConfig, funcName string,
}
qt.Printf("optimized calculation for instant rollup avg_over_time(m[d]) as (sum_over_time(m[d]) / count_over_time(m[d]))")
fe := expr.(*metricsql.FuncExpr)
// copy RollupExpr to drop possible offset,
// see https://github.com/VictoriaMetrics/VictoriaMetrics/issues/9762
newArg := copyRollupExpr(fe.Args[0].(*metricsql.RollupExpr))
newArg.Offset = nil
feSum := *fe
feSum.Name = "sum_over_time"
feCount := *fe
feCount.Name = "count_over_time"
be := &metricsql.BinaryOpExpr{
Op: "/",
KeepMetricNames: fe.KeepMetricNames,
Left: &metricsql.FuncExpr{
Name: "sum_over_time",
Args: []metricsql.Expr{newArg},
KeepMetricNames: fe.KeepMetricNames,
},
Right: &metricsql.FuncExpr{
Name: "count_over_time",
Args: []metricsql.Expr{newArg},
KeepMetricNames: fe.KeepMetricNames,
},
Left: &feSum,
Right: &feCount,
}
return evalExpr(qt, ec, be)
case "rate":
@@ -1180,12 +1172,8 @@ func evalInstantRollup(qt *querytracer.Tracer, ec *EvalConfig, funcName string,
fe := afe.Args[0].(*metricsql.FuncExpr)
feIncrease := *fe
feIncrease.Name = "increase"
// copy RollupExpr to drop possible offset,
// see https://github.com/VictoriaMetrics/VictoriaMetrics/issues/9762
newArg := copyRollupExpr(fe.Args[0].(*metricsql.RollupExpr))
newArg.Offset = nil
feIncrease.Args = []metricsql.Expr{newArg}
d := newArg.Window.Duration(ec.Step)
re := fe.Args[0].(*metricsql.RollupExpr)
d := re.Window.Duration(ec.Step)
if d == 0 {
d = ec.Step
}
@@ -1205,12 +1193,8 @@ func evalInstantRollup(qt *querytracer.Tracer, ec *EvalConfig, funcName string,
fe := expr.(*metricsql.FuncExpr)
feIncrease := *fe
feIncrease.Name = "increase"
// copy RollupExpr to drop possible offset,
// see https://github.com/VictoriaMetrics/VictoriaMetrics/issues/9762
newArg := copyRollupExpr(fe.Args[0].(*metricsql.RollupExpr))
newArg.Offset = nil
feIncrease.Args = []metricsql.Expr{newArg}
d := newArg.Window.Duration(ec.Step)
re := fe.Args[0].(*metricsql.RollupExpr)
d := re.Window.Duration(ec.Step)
if d == 0 {
d = ec.Step
}
@@ -2015,23 +1999,3 @@ func dropStaleNaNs(funcName string, values []float64, timestamps []int64) ([]flo
}
return dstValues, dstTimestamps
}
func copyRollupExpr(re *metricsql.RollupExpr) *metricsql.RollupExpr {
var newRe metricsql.RollupExpr
newRe.Expr = re.Expr
newRe.InheritStep = re.InheritStep
newRe.At = re.At
if re.Window != nil {
newRe.Window = &metricsql.DurationExpr{}
*newRe.Window = *re.Window
}
if re.Offset != nil {
newRe.Offset = &metricsql.DurationExpr{}
*newRe.Offset = *re.Offset
}
if re.Step != nil {
newRe.Step = &metricsql.DurationExpr{}
*newRe.Step = *re.Step
}
return &newRe
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -36,10 +36,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-DQcPcJrn.js"></script>
<link rel="modulepreload" crossorigin href="./assets/vendor-DY9kCvzk.js">
<script type="module" crossorigin src="./assets/index-DK22yiEQ.js"></script>
<link rel="modulepreload" crossorigin href="./assets/vendor-DBOs1yKE.js">
<link rel="stylesheet" crossorigin href="./assets/vendor-D1GxaB_c.css">
<link rel="stylesheet" crossorigin href="./assets/index-dWApsAEM.css">
<link rel="stylesheet" crossorigin href="./assets/index-Ccv_zSYG.css">
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>

View File

@@ -1,4 +1,4 @@
FROM golang:1.25.1 AS build-web-stage
FROM golang:1.25.0 AS build-web-stage
COPY build /build
WORKDIR /build

View File

@@ -6,7 +6,6 @@
<link rel="apple-touch-icon" href="/favicon.svg"/>
<link rel="mask-icon" href="/favicon.svg" color="#000000">
<meta name="robots" content="noindex">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=5"/>
<meta name="theme-color" content="#000000"/>
<meta name="description" content="Explore and troubleshoot your VictoriaMetrics data"/>

View File

@@ -17,7 +17,7 @@
"react-input-mask": "^2.0.4",
"react-router-dom": "^7.6.3",
"uplot": "^1.6.32",
"vite": "^7.1.5",
"vite": "^7.0.4",
"web-vitals": "^5.0.3"
},
"devDependencies": {
@@ -7321,13 +7321,13 @@
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
"version": "0.2.14",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz",
"integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==",
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
"picomatch": "^4.0.3"
"fdir": "^6.4.4",
"picomatch": "^4.0.2"
},
"engines": {
"node": ">=12.0.0"
@@ -7337,13 +7337,10 @@
}
},
"node_modules/tinyglobby/node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"version": "6.4.6",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz",
"integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==",
"license": "MIT",
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": {
"picomatch": "^3 || ^4"
},
@@ -7354,9 +7351,9 @@
}
},
"node_modules/tinyglobby/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
"license": "MIT",
"engines": {
"node": ">=12"
@@ -7660,17 +7657,17 @@
"license": "MIT"
},
"node_modules/vite": {
"version": "7.1.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.5.tgz",
"integrity": "sha512-4cKBO9wR75r0BeIWWWId9XK9Lj6La5X846Zw9dFfzMRw38IlTk2iCcUt6hsyiDRcPidc55ZParFYDXi0nXOeLQ==",
"version": "7.0.4",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.0.4.tgz",
"integrity": "sha512-SkaSguuS7nnmV7mfJ8l81JGBFV7Gvzp8IzgE8A8t23+AxuNX61Q5H1Tpz5efduSN7NHC8nQXD3sKQKZAu5mNEA==",
"license": "MIT",
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
"picomatch": "^4.0.3",
"fdir": "^6.4.6",
"picomatch": "^4.0.2",
"postcss": "^8.5.6",
"rollup": "^4.43.0",
"tinyglobby": "^0.2.15"
"rollup": "^4.40.0",
"tinyglobby": "^0.2.14"
},
"bin": {
"vite": "bin/vite.js"
@@ -7775,13 +7772,10 @@
}
},
"node_modules/vite/node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"version": "6.4.6",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz",
"integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==",
"license": "MIT",
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": {
"picomatch": "^3 || ^4"
},
@@ -7792,9 +7786,9 @@
}
},
"node_modules/vite/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
"license": "MIT",
"engines": {
"node": ">=12"

View File

@@ -29,7 +29,7 @@
"react-input-mask": "^2.0.4",
"react-router-dom": "^7.6.3",
"uplot": "^1.6.32",
"vite": "^7.1.5",
"vite": "^7.0.4",
"web-vitals": "^5.0.3"
},
"devDependencies": {

View File

@@ -1,9 +1,8 @@
import { useMemo } from "preact/compat";
import "./style.scss";
import { Alert as APIAlert } from "../../../types";
import { createSearchParams } from "react-router-dom";
import Button from "../../Main/Button/Button";
import Badges, { BadgeColor } from "../Badges";
import Badges from "../Badges";
import {
SearchIcon,
} from "../../Main/Icons";
@@ -16,13 +15,6 @@ interface BaseAlertProps {
const BaseAlert = ({ item }: BaseAlertProps) => {
const query = item?.expression;
const alertLabels = item?.labels || {};
const alertLabelsItems = useMemo(() => {
return Object.fromEntries(Object.entries(alertLabels).map(([name, value]) => [name, {
color: "passive" as BadgeColor,
value: value,
}]));
}, [alertLabels]);
const openQueryLink = () => {
const params = {
@@ -35,10 +27,6 @@ const BaseAlert = ({ item }: BaseAlertProps) => {
return (
<div className="vm-explore-alerts-alert-item">
<table>
<colgroup>
<col className="vm-col-md"/>
<col/>
</colgroup>
<tbody>
<tr>
<td
@@ -57,7 +45,7 @@ const BaseAlert = ({ item }: BaseAlertProps) => {
</td>
</tr>
<tr>
<td>Query</td>
<td className="vm-col-md">Query</td>
<td>
<CodeExample
code={query}
@@ -65,15 +53,18 @@ const BaseAlert = ({ item }: BaseAlertProps) => {
</td>
</tr>
<tr>
<td>Active at</td>
<td className="vm-col-md">Active at</td>
<td>{dayjs(item.activeAt).format("DD MMM YYYY HH:mm:ss")}</td>
</tr>
{!!Object.keys(alertLabels).length && (
{!!Object.keys(item?.labels || {}).length && (
<tr>
<td>Labels</td>
<td className="vm-col-md">Labels</td>
<td>
<Badges
items={alertLabelsItems}
items={Object.fromEntries(Object.entries(item.labels).map(([name, value]) => [name, {
color: "passive",
value: value,
}]))}
/>
</td>
</tr>
@@ -84,14 +75,10 @@ const BaseAlert = ({ item }: BaseAlertProps) => {
<>
<span className="title">Annotations</span>
<table>
<colgroup>
<col className="vm-col-md"/>
<col/>
</colgroup>
<tbody>
{Object.entries(item.annotations || {}).map(([name, value]) => (
<tr key={name}>
<td>{name}</td>
<td className="vm-col-md">{name}</td>
<td>{value}</td>
</tr>
))}

View File

@@ -1,14 +1,16 @@
@use "src/styles/variables" as *;
.vm-modal.vm-explore-alerts {
.vm-modal-content {
overflow-y: scroll;
.vm-modal {
.vm-explore-alerts-alert-item {
table {
width: auto;
}
}
}
.vm-explore-alerts-alert-item {
row-gap: $padding-global;
margin: $padding-global;
margin-right: $padding-global;
display: flex;
flex-direction: column;
@@ -17,35 +19,36 @@
text-align: center;
}
.vm-col-sm {
width: 10%;
white-space: nowrap;
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
}
.vm-col-md {
width: 20%;
width: 15%;
white-space: nowrap;
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
}
.vm-button {
color: $color-passive;
border: 1px solid $color-passive;
}
.vm-code-example {
.vm-button {
background-color: $color-code;
}
border: 1px solid var(--color-passive);
}
table {
word-break: break-word;
table-layout: fixed;
width: 100%;
td, th {
line-height: 30px;
padding: 4px $padding-small;
white-space: nowrap;
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
}
th {
font-weight: bold;
text-align: center;
padding: 0 $padding-small;
}
}

View File

@@ -1,45 +1,17 @@
import { useMemo } from "preact/compat";
import "./style.scss";
import { Group as APIGroup } from "../../../types";
import dayjs from "dayjs";
import { formatDuration } from "../helpers";
import Badges, { BadgeColor } from "../Badges";
import Badges from "../Badges";
interface BaseGroupProps {
group: APIGroup;
}
const BaseGroup = ({ group }: BaseGroupProps) => {
const groupLabels = group?.labels || {};
const groupLabelsItems = useMemo(() => {
return Object.fromEntries(Object.entries(groupLabels).map(([name, value]) => [name, {
color: "passive" as BadgeColor,
value: value,
}]));
}, [groupLabels]);
const groupParams = group?.params || [];
const groupParamsItems = useMemo(() => {
return Object.fromEntries(groupParams.map(value => [value, {
color: "passive" as BadgeColor,
}]));
}, [groupParams]);
const groupHeaders = group?.headers || [];
const groupHeadersItems = useMemo(() => {
return Object.fromEntries(groupHeaders.map(value => [value, {
color: "passive" as BadgeColor,
}]));
}, [groupHeaders]);
const groupNotifierHeaders = group?.notifier_headers || [];
const groupNotifierHeadersItems = useMemo(() => {
return Object.fromEntries(groupNotifierHeaders.map(value => [value, {
color: "passive" as BadgeColor,
}]));
}, [groupNotifierHeaders]);
return (
<div className="vm-explore-alerts-group">
<div></div>
<table>
<tbody>
{!!group.interval && (
@@ -78,42 +50,51 @@ const BaseGroup = ({ group }: BaseGroupProps) => {
<td>{group.concurrency}</td>
</tr>
)}
{!!Object.keys(groupLabels).length && (
{!!group?.labels?.length && (
<tr>
<td className="vm-col-md">Labels</td>
<td>
<Badges
items={groupLabelsItems}
items={Object.fromEntries(Object.entries(group.labels).map(([name, value]) => [name, {
color: "passive",
value: value,
}]))}
/>
</td>
</tr>
)}
{!!groupParams.length && (
{!!group?.params?.length && (
<tr>
<td className="vm-col-md">Params</td>
<td>
<Badges
items={groupParamsItems}
items={Object.fromEntries(group.params.map(value => [value, {
color: "passive",
}]))}
/>
</td>
</tr>
)}
{!!groupHeaders.length && (
{!!group?.headers?.length && (
<tr>
<td className="vm-col-md">Headers</td>
<td>
<Badges
items={groupHeadersItems}
items={Object.fromEntries(group.headers.map(value => [value, {
color: "passive",
}]))}
/>
</td>
</tr>
)}
{!!groupNotifierHeaders.length && (
{!!group?.notifier_headers?.length && (
<tr>
<td className="vm-col-md">Notifier headers</td>
<td>
<Badges
items={groupNotifierHeadersItems}
items={Object.fromEntries(group.notifier_headers.map(value => [value, {
color: "passive",
}]))}
/>
</td>
</tr>

View File

@@ -1,14 +1,16 @@
@use "src/styles/variables" as *;
.vm-modal.vm-explore-alerts {
.vm-modal-content {
overflow-y: scroll;
.vm-modal {
.vm-explore-alerts-group {
table {
width: auto;
}
}
}
.vm-explore-alerts-group {
row-gap: $padding-global;
margin: $padding-global;
margin-right: $padding-global;
display: flex;
flex-direction: column;
@@ -39,13 +41,24 @@
}
}
.vm-col-sm {
width: 10%;
white-space: nowrap;
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
}
.vm-col-md {
width: 20%;
width: 15%;
white-space: nowrap;
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
}
table {
width: 100%;
table-layout: fixed;
tr.hoverable {
cursor: pointer;
&:hover {
@@ -55,10 +68,6 @@
td, th {
line-height: 30px;
padding: 4px $padding-small;
white-space: nowrap;
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
}
th {
font-weight: bold;

View File

@@ -1,4 +1,3 @@
import { useMemo } from "preact/compat";
import "./style.scss";
import { Rule as APIRule } from "../../../types";
import { useNavigate, createSearchParams } from "react-router-dom";
@@ -26,14 +25,6 @@ const BaseRule = ({ item }: BaseRuleProps) => {
};
};
const ruleLabels = item?.labels || {};
const ruleLabelsItems = useMemo(() => {
return Object.fromEntries(Object.entries(ruleLabels).map(([name, value]) => [name, {
color: "passive" as BadgeColor,
value: value,
}]));
}, [ruleLabels]);
const openQueryLink = () => {
const params = {
"g0.expr": query,
@@ -44,11 +35,8 @@ const BaseRule = ({ item }: BaseRuleProps) => {
return (
<div className="vm-explore-alerts-rule-item">
<div></div>
<table>
<colgroup>
<col className="vm-col-md"/>
<col/>
</colgroup>
<tbody>
<tr>
<td
@@ -67,7 +55,7 @@ const BaseRule = ({ item }: BaseRuleProps) => {
</td>
</tr>
<tr>
<td>Query</td>
<td className="vm-col-md">Query</td>
<td>
<CodeExample
code={query}
@@ -76,30 +64,33 @@ const BaseRule = ({ item }: BaseRuleProps) => {
</tr>
{!!item.duration && (
<tr>
<td>For</td>
<td className="vm-col-md">For</td>
<td>{formatDuration(item.duration)}</td>
</tr>
)}
{!!item.lastEvaluation && (
<tr>
<td>Last evaluation</td>
<td className="vm-col-md">Last evaluation</td>
<td>{dayjs(item.lastEvaluation).format("DD MMM YYYY HH:mm:ss")}</td>
</tr>
)}
{!!item.lastError && item.health !== "ok" && (
<tr>
<td>Last error</td>
<td className="vm-col-md">Last error</td>
<td>
<Alert variant="error">{item.lastError}</Alert>
</td>
</tr>
)}
{!!Object.keys(ruleLabelsItems).length && (
{!!Object.keys(item?.labels || {}).length && (
<tr>
<td>Labels</td>
<td className="vm-col-md">Labels</td>
<td>
<Badges
items={ruleLabelsItems}
items={Object.fromEntries(Object.entries(item.labels).map(([name, value]) => [name, {
color: "passive",
value: value,
}]))}
/>
</td>
</tr>
@@ -109,15 +100,11 @@ const BaseRule = ({ item }: BaseRuleProps) => {
{!!Object.keys(item?.annotations || {}).length && (
<>
<span className="title">Annotations</span>
<table>
<colgroup>
<col className="vm-col-md"/>
<col/>
</colgroup>
<table className="fixed">
<tbody>
{Object.entries(item.annotations || {}).map(([name, value]) => (
<tr key={name}>
<td>{name}</td>
<td className="vm-col-md">{name}</td>
<td>{value}</td>
</tr>
))}
@@ -128,14 +115,14 @@ const BaseRule = ({ item }: BaseRuleProps) => {
{!!item?.updates?.length && (
<>
<span className="title">{`Last updates ${item.updates.length}/${item.max_updates_entries}`}</span>
<table>
<table className="fixed">
<thead>
<tr>
<th>Updated at</th>
<th>Series returned</th>
<th>Series fetched</th>
<th>Duration</th>
<th>Executed at</th>
<th className="vm-col-md">Updated at</th>
<th className="vm-col-md">Series returned</th>
<th className="vm-col-md">Series fetched</th>
<th className="vm-col-md">Duration</th>
<th className="vm-col-md">Executed at</th>
</tr>
</thead>
<tbody>
@@ -143,11 +130,11 @@ const BaseRule = ({ item }: BaseRuleProps) => {
<tr
key={update.at}
>
<td>{dayjs(update.time).format("DD MMM YYYY HH:mm:ss")}</td>
<td>{update.samples}</td>
<td>{update.series_fetched}</td>
<td>{formatDuration(update.duration / 1e9)}</td>
<td>{dayjs(update.at).format("DD MMM YYYY HH:mm:ss")}</td>
<td className="vm-col-md">{dayjs(update.time).format("DD MMM YYYY HH:mm:ss")}</td>
<td className="vm-col-md">{update.samples}</td>
<td className="vm-col-md">{update.series_fetched}</td>
<td className="vm-col-md">{formatDuration(update.duration / 1e9)}</td>
<td className="vm-col-md">{dayjs(update.at).format("DD MMM YYYY HH:mm:ss")}</td>
</tr>
))}
</tbody>
@@ -157,21 +144,14 @@ const BaseRule = ({ item }: BaseRuleProps) => {
{!!item?.alerts?.length && (
<>
<span className="title">Alerts</span>
<table>
<colgroup>
<col className="vm-col-sm"/>
<col className="vm-col-sm"/>
<col className="vm-col-sm"/>
<col/>
<col className="vm-col-hidden"/>
</colgroup>
<table className="fixed">
<thead>
<tr>
<th>Active since</th>
<th>State</th>
<th>Value</th>
<th className="title">Labels</th>
<th></th>
<th className="vm-col-sm">Active since</th>
<th className="vm-col-sm">State</th>
<th className="vm-col-sm">Value</th>
<th>Labels</th>
<th className="vm-col-hidden"></th>
</tr>
</thead>
<tbody>
@@ -180,15 +160,15 @@ const BaseRule = ({ item }: BaseRuleProps) => {
id={`alert-${alert.id}`}
key={alert.id}
>
<td>
<td className="vm-col-sm">
{dayjs(alert.activeAt).format("DD MMM YYYY HH:mm:ss")}
</td>
<td>
<td className="vm-col-sm">
<Badges
items={{ [alert.state]: { color: alert.state as BadgeColor } }}
/>
</td>
<td>
<td className="vm-col-sm">
<Badges
items={{ [alert.value]: { color: "passive" } }}
/>
@@ -202,7 +182,7 @@ const BaseRule = ({ item }: BaseRuleProps) => {
}]))}
/>
</td>
<td>
<td className="vm-col-hidden">
<Button
className="vm-button-borderless"
size="small"

View File

@@ -1,14 +1,16 @@
@use "src/styles/variables" as *;
.vm-modal.vm-explore-alerts {
.vm-modal-content {
overflow-y: scroll;
.vm-modal {
.vm-explore-alerts-rule-item {
table {
width: auto;
}
}
}
.vm-explore-alerts-rule-item {
row-gap: $padding-global;
margin: $padding-global;
margin-right: $padding-global;
display: flex;
flex-direction: column;
@@ -18,46 +20,46 @@
}
.vm-col-hidden {
width: 40px;
width: 30px;
}
.vm-button {
color: $color-passive;
border: 1px solid $color-passive;
}
.vm-code-example {
.vm-button {
background-color: $color-code;
}
border: 1px solid var(--color-passive);
}
.vm-col-sm {
width: 15%;
width: 10%;
white-space: nowrap;
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
}
.vm-col-md {
width: 20%;
width: 15%;
white-space: nowrap;
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
}
table {
word-break: break-word;
table-layout: fixed;
&.fixed {
table-layout: fixed;
}
width: 100%;
td, th {
line-height: 30px;
padding: 4px $padding-small;
vertical-align: middle;
white-space: nowrap;
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
}
td.align-center {
text-align: center
}
th {
font-weight: bold;
text-align: center;
padding: 0 $padding-small;
}
}

View File

@@ -35,14 +35,12 @@ const GroupHeaderHeader: FC<GroupHeaderControlsProps> = ({ group }) => {
<div className="vm-explore-alerts-group-header__file">{group.file}</div>
)}
</div>
<div className="vm-explore-alerts-controls">
<Badges
align="end"
items={Object.fromEntries(Object.entries(group.states || {}).map(([name, value]) => [name.toLowerCase(), {
color: name.toLowerCase().replace(" ", "-") as BadgeColor,
value: value,
}]))}
/>
<Badges
items={Object.fromEntries(Object.entries(group.states || {}).map(([name, value]) => [name.toLowerCase(), {
color: name.toLowerCase().replace(" ", "-") as BadgeColor,
value: value,
}]))}
>
<Button
className="vm-button-borderless"
size="small"
@@ -51,7 +49,7 @@ const GroupHeaderHeader: FC<GroupHeaderControlsProps> = ({ group }) => {
startIcon={<DetailsIcon />}
onClick={openGroupModal}
/>
</div>
</Badges>
</div>
);
};

View File

@@ -58,8 +58,3 @@
border-radius: 6px;
}
}
.vm-explore-alerts-controls {
display: flex;
column-gap: $padding-global;
}

View File

@@ -1,4 +1,4 @@
import { FC, useMemo } from "preact/compat";
import { FC } from "preact/compat";
import "./style.scss";
import useDeviceDetect from "../../../hooks/useDeviceDetect";
import useCopyToClipboard from "../../../hooks/useCopyToClipboard";
@@ -83,13 +83,6 @@ const ItemHeader: FC<ItemHeaderControlsProps> = ({ name, id, groupId, entity, ty
}
};
const badgesItems = useMemo(() => {
return Object.fromEntries(Object.entries(states || {}).map(([name, value]) => [name, {
color: name.toLowerCase().replace(" ", "-") as BadgeColor,
value: value == 1 ? 0 : value,
}]));
}, [states]);
return (
<div
className={headerClasses}
@@ -99,11 +92,12 @@ const ItemHeader: FC<ItemHeaderControlsProps> = ({ name, id, groupId, entity, ty
{renderIcon()}
<div className="vm-explore-alerts-item-header__name">{name}</div>
</div>
<div className="vm-explore-alerts-controls">
<Badges
align="end"
items={badgesItems}
/>
<Badges
items={Object.fromEntries(Object.entries(states || {}).map(([name, value]) => [name, {
color: name.toLowerCase().replace(" ", "-") as BadgeColor,
value: value == 1 ? 0 : value,
}]))}
>
{onClose ? (
<Button
className="vm-back-button"
@@ -125,7 +119,7 @@ const ItemHeader: FC<ItemHeaderControlsProps> = ({ name, id, groupId, entity, ty
onClick={openItemLink}
/>
)}
</div>
</Badges>
</div>
);
};

View File

@@ -2,6 +2,7 @@
.vm-explore-alerts-item-header {
display: flex;
grid-template-columns: auto 1fr auto auto;
align-items: center;
justify-content: space-between;
gap: $padding-global;
@@ -27,6 +28,7 @@
}
&_mobile {
grid-template-columns: 1fr auto;
.vm-button-text {
display: none;
}
@@ -49,11 +51,9 @@
&__title {
display: flex;
column-gap: $padding-global;
overflow: hidden;
svg {
fill: $color-text-disabled;
width: 14px;
min-width: 14px;
}
}
@@ -68,8 +68,3 @@
border-radius: 6px;
}
}
.vm-explore-alerts-controls {
display: flex;
column-gap: $padding-global;
}

View File

@@ -2,12 +2,14 @@
.vm-explore-alerts-notifier-header {
display: flex;
grid-template-columns: auto 1fr auto auto;
align-items: center;
padding: $padding-global;
justify-content: space-between;
gap: $padding-global;
&_mobile {
grid-template-columns: 1fr auto;
padding: $padding-small $padding-global;
}

View File

@@ -21,6 +21,26 @@
min-width: 150px;
}
&-description {
display: grid;
grid-template-columns: 1fr auto;
align-items: flex-start;
gap: $padding-small;
ul {
list-style-position: inside;
}
button {
color: inherit;
min-height: 29px;
}
code {
margin: 0 3px;
}
}
&-search {
flex-grow: 1;
.vm-text-field__input {

View File

@@ -1,9 +1,9 @@
import { FC, useMemo } from "preact/compat";
import { FC } from "preact/compat";
import "./style.scss";
import { Target as APITarget } from "../../../types";
import Alert from "../../Main/Alert/Alert";
import Accordion from "../../Main/Accordion/Accordion";
import Badges, { BadgeColor } from "../Badges";
import Badges from "../Badges";
interface TargetProps {
target: APITarget;
@@ -11,13 +11,6 @@ interface TargetProps {
const Target: FC<TargetProps> = ({ target }) => {
const state = target?.lastError ? "unhealthy" : "ok";
const targetLabels = target?.labels || {};
const badgesItems = useMemo(() => {
return Object.fromEntries(Object.entries(targetLabels).map(([name, value]) => [name, {
value: value,
color: "passive" as BadgeColor,
}]));
}, [targetLabels]);
return (
<div className={`vm-explore-alerts-target vm-badge-item ${state.replace(" ", "-")}`}>
{(!!target?.labels?.length || !!target?.lastError) ? (
@@ -30,12 +23,15 @@ const Target: FC<TargetProps> = ({ target }) => {
<div className="vm-explore-alerts-target-item">
<table>
<tbody>
{!!Object.keys(targetLabels).length && (
{!!target?.labels?.length && (
<tr>
<td className="vm-col-md">Labels</td>
<td>
<Badges
items={badgesItems}
items={Object.fromEntries(Object.entries(target.labels).map(([name, value]) => [name, {
value: value,
color: "passive",
}]))}
/>
</td>
</tr>

View File

@@ -6,9 +6,8 @@
padding: $padding-global;
white-space: pre-wrap;
border-radius: $border-radius-small;
background-color: $color-code;
background-color: rgba($color-black, 0.05);
overflow: auto;
text-overflow: ellipsis;
&__copy {
position: absolute;

View File

@@ -13,8 +13,6 @@ import Button from "../../Button/Button";
interface DatePickerProps {
date: Date | Dayjs
format?: string
minDate?: Date | Dayjs
maxDate?: Date | Dayjs
onChange: (date: string) => void
}
@@ -26,8 +24,6 @@ enum CalendarTypeView {
const Calendar: FC<DatePickerProps> = ({
date,
minDate,
maxDate,
format = DATE_TIME_FORMAT,
onChange,
}) => {
@@ -38,8 +34,6 @@ const Calendar: FC<DatePickerProps> = ({
const today = dayjs.tz();
const viewDateIsToday = today.format(DATE_FORMAT) === viewDate.format(DATE_FORMAT);
const { isMobile } = useDeviceDetect();
const min = minDate ? dayjs(minDate) : undefined;
const max = maxDate ? dayjs(maxDate) : undefined;
const toggleDisplayYears = () => {
setViewType(prev => prev === CalendarTypeView.years ? CalendarTypeView.days : CalendarTypeView.years);
@@ -81,13 +75,9 @@ const Calendar: FC<DatePickerProps> = ({
onChangeViewDate={handleChangeViewDate}
toggleDisplayYears={toggleDisplayYears}
showArrowNav={viewType === CalendarTypeView.days}
hasPrev={viewType === CalendarTypeView.days && (!min || viewDate.startOf("month").isAfter(min))}
hasNext={viewType === CalendarTypeView.days && (!max || viewDate.endOf("month").isBefore(max))}
/>
{viewType === CalendarTypeView.days && (
<CalendarBody
minDate={min}
maxDate={max}
viewDate={viewDate}
selectDate={selectDate}
onChangeSelectDate={handleChangeSelectDate}
@@ -95,16 +85,12 @@ const Calendar: FC<DatePickerProps> = ({
)}
{viewType === CalendarTypeView.years && (
<YearsList
minDate={min}
maxDate={max}
viewDate={viewDate}
onChangeViewDate={handleChangeViewDate}
/>
)}
{viewType === CalendarTypeView.months && (
<MonthsList
minDate={min}
maxDate={max}
selectDate={selectDate}
viewDate={viewDate}
onChangeViewDate={handleChangeViewDate}

View File

@@ -4,8 +4,6 @@ import classNames from "classnames";
import Tooltip from "../../../Tooltip/Tooltip";
interface CalendarBodyProps {
minDate?: Dayjs
maxDate?: Dayjs
viewDate: Dayjs
selectDate: Dayjs
onChangeSelectDate: (date: Dayjs) => void
@@ -13,7 +11,7 @@ interface CalendarBodyProps {
const weekday = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
const CalendarBody: FC<CalendarBodyProps> = ({ minDate, maxDate, viewDate: date, selectDate, onChangeSelectDate }) => {
const CalendarBody: FC<CalendarBodyProps> = ({ viewDate: date, selectDate, onChangeSelectDate }) => {
const format = "YYYY-MM-DD";
const today = dayjs.tz();
const viewDate = dayjs(date.format(format));
@@ -46,25 +44,21 @@ const CalendarBody: FC<CalendarBodyProps> = ({ minDate, maxDate, viewDate: date,
</Tooltip>
))}
{days.map((d, i) => {
const isDisabled = d && ((minDate && d.isBefore(minDate)) || (maxDate && d.isAfter(maxDate)));
return (
<div
className={classNames({
"vm-calendar-body-cell": true,
"vm-calendar-body-cell_day": true,
"vm-calendar-body-cell_day_empty": !d,
"vm-calendar-body-cell_day_active": (d && d.format(format)) === selectDate.format(format),
"vm-calendar-body-cell_day_today": (d && d.format(format)) === today.format(format),
"vm-calendar-body-cell_day_disabled": isDisabled,
})}
key={d ? d.format(format) : i}
onClick={createHandlerSelectDate(d)}
>
{d && d.format("D")}
</div>
);
})}
{days.map((d, i) => (
<div
className={classNames({
"vm-calendar-body-cell": true,
"vm-calendar-body-cell_day": true,
"vm-calendar-body-cell_day_empty": !d,
"vm-calendar-body-cell_day_active": (d && d.format(format)) === selectDate.format(format),
"vm-calendar-body-cell_day_today": (d && d.format(format)) === today.format(format)
})}
key={d ? d.format(format) : i}
onClick={createHandlerSelectDate(d)}
>
{d && d.format("D")}
</div>
))}
</div>
);
};

View File

@@ -1,18 +1,15 @@
import { FC } from "preact/compat";
import { Dayjs } from "dayjs";
import { ArrowDownIcon, ArrowDropDownIcon } from "../../../Icons";
import classNames from "classnames";
interface CalendarHeaderProps {
viewDate: Dayjs
onChangeViewDate: (date: Dayjs) => void
showArrowNav: boolean
toggleDisplayYears: () => void
hasNext: boolean
hasPrev: boolean
}
const CalendarHeader: FC<CalendarHeaderProps> = ({ hasPrev, hasNext, viewDate, showArrowNav, onChangeViewDate, toggleDisplayYears }) => {
const CalendarHeader: FC<CalendarHeaderProps> = ({ viewDate, showArrowNav, onChangeViewDate, toggleDisplayYears }) => {
const setPrevMonth = () => {
onChangeViewDate(viewDate.subtract(1, "month"));
@@ -38,20 +35,14 @@ const CalendarHeader: FC<CalendarHeaderProps> = ({ hasPrev, hasNext, viewDate, s
{showArrowNav && (
<div className="vm-calendar-header-right">
<div
className={classNames({
"vm-calendar-header-right__prev": true,
"vm-calendar-header-right_disabled": !hasPrev,
})}
onClick={hasPrev ? setPrevMonth : undefined}
className="vm-calendar-header-right__prev"
onClick={setPrevMonth}
>
<ArrowDownIcon/>
</div>
<div
className={classNames({
"vm-calendar-header-right__next": true,
"vm-calendar-header-right_disabled": !hasNext,
})}
onClick={hasNext ? setNextMonth : undefined}
className="vm-calendar-header-right__next"
onClick={setNextMonth}
>
<ArrowDownIcon/>
</div>

View File

@@ -3,14 +3,13 @@ import dayjs, { Dayjs } from "dayjs";
import classNames from "classnames";
interface CalendarMonthsProps {
minDate?: Dayjs
maxDate?: Dayjs
viewDate: Dayjs,
selectDate: Dayjs
onChangeViewDate: (date: Dayjs) => void
}
const MonthsList: FC<CalendarMonthsProps> = ({ minDate, maxDate, viewDate, selectDate, onChangeViewDate }) => {
const MonthsList: FC<CalendarMonthsProps> = ({ viewDate, selectDate, onChangeViewDate }) => {
const today = dayjs().format("MM");
const currentMonths = useMemo(() => selectDate.format("MM"), [selectDate]);
@@ -30,24 +29,20 @@ const MonthsList: FC<CalendarMonthsProps> = ({ minDate, maxDate, viewDate, selec
return (
<div className="vm-calendar-years">
{months.map(m => {
const isDisabled = m && ((minDate && m.isBefore(minDate)) || (maxDate && m.isAfter(maxDate)));
return (
<div
className={classNames({
"vm-calendar-years__year": true,
"vm-calendar-years__year_selected": m.format("MM") === currentMonths,
"vm-calendar-years__year_today": m.format("MM") === today,
"vm-calendar-years__year_disabled": isDisabled,
})}
id={`vm-calendar-year-${m.format("MM")}`}
key={m.format("MM")}
onClick={isDisabled ? undefined : createHandlerClick(m)}
>
{m.format("MMMM")}
</div>
);
})}
{months.map(m => (
<div
className={classNames({
"vm-calendar-years__year": true,
"vm-calendar-years__year_selected": m.format("MM") === currentMonths,
"vm-calendar-years__year_today": m.format("MM") === today
})}
id={`vm-calendar-year-${m.format("MM")}`}
key={m.format("MM")}
onClick={createHandlerClick(m)}
>
{m.format("MMMM")}
</div>
))}
</div>
);
};

View File

@@ -3,13 +3,11 @@ import dayjs, { Dayjs } from "dayjs";
import classNames from "classnames";
interface CalendarYearsProps {
minDate?: Dayjs
maxDate?: Dayjs
viewDate: Dayjs
onChangeViewDate: (date: Dayjs) => void
}
const YearsList: FC<CalendarYearsProps> = ({ minDate, maxDate, viewDate, onChangeViewDate }) => {
const YearsList: FC<CalendarYearsProps> = ({ viewDate, onChangeViewDate }) => {
const today = dayjs().format("YYYY");
const currentYear = useMemo(() => viewDate.format("YYYY"), [viewDate]);
@@ -32,24 +30,20 @@ const YearsList: FC<CalendarYearsProps> = ({ minDate, maxDate, viewDate, onChang
return (
<div className="vm-calendar-years">
{years.map(y => {
const isDisabled = y && (minDate && y.isBefore(minDate)) || (maxDate && y.isAfter(maxDate));
return (
<div
className={classNames({
"vm-calendar-years__year": true,
"vm-calendar-years__year_selected": y.format("YYYY") === currentYear,
"vm-calendar-years__year_today": y.format("YYYY") === today,
"vm-calendar-years__year_disabled": isDisabled,
})}
id={`vm-calendar-year-${y.format("YYYY")}`}
key={y.format("YYYY")}
onClick={isDisabled ? undefined : createHandlerClick(y)}
>
{y.format("YYYY")}
</div>
);
})}
{years.map(y => (
<div
className={classNames({
"vm-calendar-years__year": true,
"vm-calendar-years__year_selected": y.format("YYYY") === currentYear,
"vm-calendar-years__year_today": y.format("YYYY") === today
})}
id={`vm-calendar-year-${y.format("YYYY")}`}
key={y.format("YYYY")}
onClick={createHandlerClick(y)}
>
{y.format("YYYY")}
</div>
))}
</div>
);
};

View File

@@ -69,10 +69,6 @@
}
}
&_disabled {
color: $color-text-disabled;
}
&__prev {
transform: rotate(90deg);
}
@@ -112,12 +108,7 @@
cursor: pointer;
transition: color 200ms ease, background-color 300ms ease-in-out;
&_disabled {
cursor: unset;
color: $color-text-disabled;
}
&:not(&_disabled):hover {
&:hover {
background-color: $color-hover-black;
}
@@ -157,12 +148,7 @@
cursor: pointer;
transition: color 200ms ease, background-color 300ms ease-in-out;
&_disabled {
cursor: unset;
color: $color-text-disabled;
}
&:not(&_disabled):hover {
&:hover {
background-color: $color-hover-black;
}

View File

@@ -10,10 +10,8 @@ import useEventListener from "../../../hooks/useEventListener";
interface DatePickerProps {
date: string | Date | Dayjs,
targetRef: React.RefObject<HTMLElement>;
format?: string;
label?: string;
minDate?: Date | Dayjs;
maxDate?: Date | Dayjs;
format?: string
label?: string
onChange: (val: string) => void
}
@@ -22,9 +20,7 @@ const DatePicker = forwardRef<HTMLDivElement, DatePickerProps>(({
targetRef,
format = DATE_TIME_FORMAT,
onChange,
label,
minDate,
maxDate
label
}, ref) => {
const dateDayjs = useMemo(() => dayjs(date).isValid() ? dayjs.tz(date) : dayjs().tz(), [date]);
const { isMobile } = useDeviceDetect();
@@ -60,8 +56,6 @@ const DatePicker = forwardRef<HTMLDivElement, DatePickerProps>(({
date={dateDayjs}
format={format}
onChange={handleChangeDate}
minDate={minDate}
maxDate={maxDate}
/>
</div>
</Popper>

View File

@@ -3,52 +3,37 @@ import { ChangeEvent, KeyboardEvent } from "react";
import { CalendarIcon } from "../../Icons";
import DatePicker from "../DatePicker";
import Button from "../../Button/Button";
import { DATE_ISO_FORMAT, DATE_FORMAT, DATE_TIME_FORMAT } from "../../../../constants/date";
import { DATE_TIME_FORMAT } from "../../../../constants/date";
import InputMask from "react-input-mask";
import dayjs, { Dayjs } from "dayjs";
import dayjs from "dayjs";
import classNames from "classnames";
import "./style.scss";
const formatStringDate = (val: string, format: string) => {
return dayjs(val).isValid() ? dayjs.tz(val).format(format) : val;
const formatStringDate = (val: string) => {
return dayjs(val).isValid() ? dayjs.tz(val).format(DATE_TIME_FORMAT) : val;
};
interface DateTimeInputProps {
value?: string;
label: string;
pickerLabel: string;
format?: string;
pickerRef: React.RefObject<HTMLDivElement>;
onChange: (date: string) => void;
onEnter: () => void;
disabled?: boolean;
minDate?: Date | Dayjs;
maxDate?: Date | Dayjs;
}
const masks: Record<string, string> = {
[DATE_ISO_FORMAT]: "9999-99-99T99:99:99",
[DATE_FORMAT]: "9999-99-99",
[DATE_TIME_FORMAT]: "9999-99-99 99:99:99"
};
const DateTimeInput: FC<DateTimeInputProps> = ({
value = "",
format = DATE_TIME_FORMAT,
minDate,
maxDate,
label,
pickerLabel,
pickerRef,
onChange,
onEnter,
disabled
onEnter
}) => {
const wrapperRef = useRef<HTMLDivElement>(null);
const [inputRef, setInputRef] = useState<HTMLInputElement | null>(null);
const mask = masks[format];
const [maskedValue, setMaskedValue] = useState(formatStringDate(value, format));
const [maskedValue, setMaskedValue] = useState(formatStringDate(value));
const [focusToTime, setFocusToTime] = useState(false);
const [awaitChangeForEnter, setAwaitChangeForEnter] = useState(false);
const error = dayjs(maskedValue).isValid() ? "" : "Invalid date format";
@@ -74,7 +59,7 @@ const DateTimeInput: FC<DateTimeInputProps> = ({
};
useEffect(() => {
const newValue = formatStringDate(value, format);
const newValue = formatStringDate(value);
if (newValue !== maskedValue) {
setMaskedValue(newValue);
}
@@ -97,16 +82,15 @@ const DateTimeInput: FC<DateTimeInputProps> = ({
<div
className={classNames({
"vm-date-time-input": true,
"vm-date-time-input_error": error,
"vm-date-time-input_disabled": disabled,
"vm-date-time-input_error": error
})}
>
<label>{label}</label>
<InputMask
tabIndex={1}
inputRef={setInputRef}
mask={mask}
placeholder={format}
mask="9999-99-99 99:99:99"
placeholder="YYYY-MM-DD HH:mm:ss"
value={maskedValue}
autoCapitalize={"none"}
inputMode={"numeric"}
@@ -114,7 +98,6 @@ const DateTimeInput: FC<DateTimeInputProps> = ({
onChange={handleMaskedChange}
onBlur={handleBlur}
onKeyUp={handleKeyUp}
disabled={disabled}
/>
{error && (
<span className="vm-date-time-input__error-text">{error}</span>
@@ -129,7 +112,6 @@ const DateTimeInput: FC<DateTimeInputProps> = ({
size="small"
startIcon={<CalendarIcon/>}
ariaLabel="calendar"
disabled={disabled}
/>
</div>
<DatePicker
@@ -138,9 +120,6 @@ const DateTimeInput: FC<DateTimeInputProps> = ({
date={maskedValue}
onChange={handleChangeDate}
targetRef={wrapperRef}
minDate={minDate}
maxDate={maxDate}
format={format}
/>
</div>
);

View File

@@ -23,14 +23,6 @@
user-select: none;
}
&_disabled {
cursor: default;
pointer-events: none;
* {
color: $color-text-disabled !important;
}
}
&__icon {
position: absolute;
bottom: 2px;

View File

@@ -22,8 +22,8 @@ interface PopperProps {
children: ReactNode
open: boolean
onClose: () => void
buttonRef?: React.RefObject<HTMLElement>
placement?: "bottom-right" | "bottom-left" | "top-left" | "top-right" | "center-left" | "center-right" | "fixed"
buttonRef: React.RefObject<HTMLElement>
placement?: "bottom-right" | "bottom-left" | "top-left" | "top-right" | "fixed"
placementPosition?: { top: number, left: number } | null
animation?: string
offset?: { top: number, left: number }
@@ -32,7 +32,6 @@ interface PopperProps {
title?: string
disabledFullScreen?: boolean
variant?: "default" | "dark"
classes?: string[]
}
const Popper: FC<PopperProps> = ({
@@ -47,7 +46,6 @@ const Popper: FC<PopperProps> = ({
fullWidth,
title,
disabledFullScreen,
classes,
variant
}) => {
const { isMobile } = useDeviceDetect();
@@ -80,7 +78,6 @@ const Popper: FC<PopperProps> = ({
}, [popperRef]);
const popperStyle = useMemo(() => {
if (!buttonRef) return {};
const buttonEl = buttonRef.current;
if (!buttonEl || !isOpen) return {};
@@ -93,9 +90,8 @@ const Popper: FC<PopperProps> = ({
width: "auto"
};
const needAlignRight = placement?.includes("right");
const needAlignRight = placement === "bottom-right" || placement === "top-right";
const needAlignTop = placement?.includes("top");
const needAlignCenter = placement?.includes("center");
const offsetTop = offset?.top || 0;
const offsetLeft = offset?.left || 0;
@@ -105,7 +101,6 @@ const Popper: FC<PopperProps> = ({
if (needAlignRight) position.left = buttonPos.right - popperSize.width;
if (needAlignTop) position.top = buttonPos.top - popperSize.height - offsetTop;
if (needAlignCenter) position.top = buttonPos.top + (buttonPos.height - popperSize.height) / 2 - offsetTop;
if (placement === "fixed" && placementPosition) {
position.top = Math.max(placementPosition.top + offset.top, 0);
@@ -166,10 +161,6 @@ const Popper: FC<PopperProps> = ({
useEventListener("scroll", handleClose);
useEventListener("popstate", handlePopstate);
useClickOutside(popperRef, handleClickOutside, buttonRef);
const classMap: Record<string, boolean> = {};
(classes || []).forEach((cls) => {
classMap[cls] = true;
});
return (
<>
@@ -180,7 +171,6 @@ const Popper: FC<PopperProps> = ({
[`vm-popper_${variant}`]: variant,
"vm-popper_mobile": isMobile && !disabledFullScreen,
"vm-popper_open": (isMobile || Object.keys(popperStyle).length) && isOpen,
...classMap,
})}
ref={popperRef}
style={(isMobile && !disabledFullScreen) ? {} : popperStyle}

View File

@@ -46,12 +46,11 @@ const Select: FC<SelectProps> = ({
const autocompleteAnchorEl = useRef<HTMLDivElement>(null);
const [wrapperRef, setWrapperRef] = useState<React.RefObject<HTMLElement> | null>(null);
const [openList, setOpenList] = useState(false);
const resultList = [...list];
const inputRef = useRef<HTMLInputElement>(null);
const isMultiple = Array.isArray(value);
let selectedValues = Array.isArray(value) ? value.slice() : [];
const selectedValues = Array.isArray(value) ? value.slice() : [];
const hideInput = isMobile && isMultiple && !!selectedValues?.length;
const textFieldValue = useMemo(() => {
@@ -78,7 +77,7 @@ const Select: FC<SelectProps> = ({
};
const handleBlur = () => {
resultList.includes(search) && onChange(search);
list.includes(search) && onChange(search);
};
const handleToggleList = (e: MouseEvent<HTMLDivElement>) => {
@@ -124,10 +123,8 @@ const Select: FC<SelectProps> = ({
useEventListener("keyup", handleKeyUp);
useClickOutside(autocompleteAnchorEl, handleCloseList, wrapperRef);
if (includeAll && !resultList.includes("All")) resultList.push("All");
if (includeAll && (!selectedValues?.length || selectedValues?.length === resultList?.length)) {
selectedValues = ["All"];
}
includeAll && !list.includes("All") && list.push("All");
includeAll && !selectedValues?.length && selectedValues.push("All");
return (
<div
@@ -158,7 +155,6 @@ const Select: FC<SelectProps> = ({
onInput={handleChange}
onFocus={handleFocus}
onBlur={handleBlur}
disabled={disabled}
ref={inputRef}
readOnly={isMobile || !searchable}
/>
@@ -186,7 +182,7 @@ const Select: FC<SelectProps> = ({
itemClassName={itemClassName}
label={label}
value={autocompleteValue}
options={resultList.map(l => ({ value: l }))}
options={list.map(l => ({ value: l }))}
anchor={autocompleteAnchorEl}
selected={selectedValues}
minLength={1}

View File

@@ -128,14 +128,8 @@
}
&_disabled {
pointer-events: none;
* {
color: var(--color-text-disabled);
cursor: default;
}
input::placeholder {
color: var(--color-text-disabled);
cursor: not-allowed;
}
.vm-select-input {

View File

@@ -9,7 +9,6 @@ $switch-border-radius: $switch-handle-size + ($switch-padding * 2);
.vm-switch {
font-size: $font-size-small;
display: flex;
column-gap: $padding-small;
align-items: center;
justify-content: flex-start;
cursor: pointer;
@@ -20,6 +19,10 @@ $switch-border-radius: $switch-handle-size + ($switch-padding * 2);
flex-direction: row-reverse;
}
&_full-width &__label {
margin-left: 0;
}
&_disabled {
opacity: 0.6;
cursor: default;
@@ -71,6 +74,7 @@ $switch-border-radius: $switch-handle-size + ($switch-padding * 2);
&__label {
white-space: nowrap;
font-size: inherit;
margin-left: $padding-small;
transition: color 200ms ease;
color: $color-text-secondary;
}

View File

@@ -72,6 +72,7 @@ const TextField: FC<TextFieldProps> = ({
"vm-text-field__input_error": error,
"vm-text-field__input_warning": !error && warning,
"vm-text-field__input_icon-start": startIcon,
"vm-text-field__input_disabled": disabled,
"vm-text-field__input_textarea": type === "textarea",
});
@@ -135,8 +136,7 @@ const TextField: FC<TextFieldProps> = ({
className={classNames({
"vm-text-field": true,
"vm-text-field_textarea": type === "textarea",
"vm-text-field_dark": isDarkTheme,
"vm-text-field_disabled": disabled
"vm-text-field_dark": isDarkTheme
})}
data-replicated-value={value}
>

View File

@@ -6,15 +6,6 @@
margin: 6px 0;
width: 100%;
&_disabled {
color: $color-text-disabled;
pointer-events: none;
}
&:is(&_disabled) > &__label {
color: $color-text-disabled;
}
&_textarea:after {
content: attr(data-replicated-value) " ";
white-space: pre-wrap;

View File

@@ -1,28 +1,20 @@
import { createRef, useEffect, useState, useMemo } from "react";
import { useState, useMemo } from "react";
import classNames from "classnames";
import { ArrowDropDownIcon, CopyIcon, DoneIcon } from "../Main/Icons";
import { getComparator, stableSort } from "./helpers";
import Tooltip from "../Main/Tooltip/Tooltip";
import Button from "../Main/Button/Button";
import { useEffect } from "preact/compat";
import useCopyToClipboard from "../../hooks/useCopyToClipboard";
type OrderDir = "asc" | "desc"
export interface TableColumn<T> {
key: keyof T;
title?: string;
format?: (obj: T) => string;
className?: string;
}
interface TableProps<T> {
rows: T[];
columns: TableColumn<T>[];
columns: { title?: string, key: keyof Partial<T>, className?: string }[];
defaultOrderBy: keyof T;
copyToClipboard?: keyof T;
defaultOrderDir?: OrderDir;
rowClasses?: (obj: T) => Record<string, boolean>;
rowAction?: (ref: React.RefObject<HTMLElement>) => () => void;
// TODO: Remove when pagination is implemented on the backend.
paginationOffset: {
startIndex: number;
@@ -30,34 +22,12 @@ interface TableProps<T> {
}
}
interface TableRow {
id: string;
}
const Table = <T extends TableRow>({
rows,
columns,
defaultOrderBy,
defaultOrderDir,
copyToClipboard,
paginationOffset,
rowClasses,
rowAction = (_ref: React.RefObject<HTMLElement>) => () => {},
}: TableProps<T>) => {
const Table = <T extends object>({ rows, columns, defaultOrderBy, defaultOrderDir, copyToClipboard, paginationOffset }: TableProps<T>) => {
const handleCopyToClipboard = useCopyToClipboard();
const [orderBy, setOrderBy] = useState<keyof T>(defaultOrderBy);
const [orderDir, setOrderDir] = useState<OrderDir>(defaultOrderDir || "desc");
const [copied, setCopied] = useState<number | null>(null);
const [rowRefs, setRowRefs] = useState(new Map());
useEffect(() => {
const newRowRefs = new Map();
rows.forEach(row => {
newRowRefs.set(row.id, createRef());
});
setRowRefs(newRowRefs);
}, [rows]);
// const sortedList = useMemo(() => stableSort(rows as [], getComparator(orderDir, orderBy)),
// [rows, orderBy, orderDir]);
@@ -88,8 +58,6 @@ const Table = <T extends TableRow>({
return () => clearTimeout(timeout);
}, [copied]);
const copyCol = copyToClipboard && columns.find((col) => col.key == copyToClipboard);
return (
<table className="vm-table">
<thead className="vm-table-header">
@@ -122,14 +90,8 @@ const Table = <T extends TableRow>({
<tbody className="vm-table-body">
{sortedList.map((row, rowIndex) => (
<tr
className={classNames({
"vm-table__row": true,
...(rowClasses ? rowClasses(row) : {}),
})}
id={row.id}
className="vm-table__row"
key={rowIndex}
ref={rowRefs.get(row.id)}
onClick={rowAction(rowRefs.get(row.id))}
>
{columns.map((col) => (
<td
@@ -139,10 +101,10 @@ const Table = <T extends TableRow>({
})}
key={String(col.key)}
>
{(col.format ? col.format(row) : String(row[col.key])) || "-"}
{row[col.key] || "-"}
</td>
))}
{copyToClipboard && copyCol && (
{copyToClipboard && (
<td className="vm-table-cell vm-table-cell_right">
{row[copyToClipboard] && (
<div className="vm-table-cell__content">
@@ -152,7 +114,7 @@ const Table = <T extends TableRow>({
color={copied === rowIndex ? "success" : "gray"}
size="small"
startIcon={copied === rowIndex ? <DoneIcon/> : <CopyIcon/>}
onClick={createCopyHandler(copyCol.format ? copyCol.format(row) : String(row[copyToClipboard]), rowIndex)}
onClick={createCopyHandler(row[copyToClipboard], rowIndex)}
ariaLabel="copy row"
/>
</Tooltip>

View File

@@ -41,12 +41,12 @@ export function descendingComparator<T>(a: T, b: T, orderBy: keyof T) {
return 0;
}
export function getComparator<T extends object>(
export function getComparator<Key extends (string | number | symbol)>(
order: Order,
orderBy: keyof T,
orderBy: Key,
): (
a: T,
b: T,
a: { [key in Key]: number | string },
b: { [key in Key]: number | string },
) => number {
return order === "desc"
? (a, b) => descendingComparator(a, b, orderBy)
@@ -55,7 +55,7 @@ export function getComparator<T extends object>(
// This method is created for cross-browser compatibility, if you don't
// need to support IE11, you can use Array.prototype.sort() directly
export function stableSort<T>(array: readonly T[], comparator: (a: T, b: T) => number): T[] {
export function stableSort<T>(array: readonly T[], comparator: (a: T, b: T) => number) {
const stabilizedThis = array.map((el, index) => [el, index] as [T, number]);
stabilizedThis.sort((a, b) => {
const order = comparator(a[0], b[0]);

View File

@@ -10,7 +10,6 @@ export const darkPalette = {
"color-success": "#57ab5a",
"color-background-success": "#0e1b0e",
"color-passive": "#a7acb3",
"color-code": "#4e5a6a",
"color-background-body": "#22272e",
"color-background-block": "#2d333b",
"color-background-tooltip": "rgba(22, 22, 22, 0.8)",
@@ -45,7 +44,6 @@ export const lightPalette = {
"color-success": "#4caf50",
"color-background-success": "#d4ecd5",
"color-passive": "#5d6267",
"color-code": "ecedee",
"color-background-body": "#FEFEFF",
"color-background-block": "#FFFFFF",
"color-background-tooltip": "rgba(80,80,80,0.9)",

View File

@@ -41,7 +41,7 @@ const Footer: FC<Props> = memo(({ links = footerLinksByDefault }) => {
))}
<div className="vm-footer__copyright">
&copy; {copyrightYears} VictoriaMetrics.
{version && <span className="vm-footer__version">&nbsp;Version: {version}</span>}
{version && <span className="vm-footer__version">&nbsp;{version}</span>}
</div>
</footer>;
});

View File

@@ -155,18 +155,15 @@ const ExploreRules: FC = () => {
[groups, types, states, searchInput]
);
const selectedTypes = allTypes.size === types.length ? [] : types;
const selectedStates = allStates.size === states.length ? [] : states;
return (
<>
{modalOpen && getModal()}
{(!modalOpen || !!allStates?.size) && (
<div className="vm-explore-alerts">
<RulesHeader
types={selectedTypes}
types={types}
allTypes={Array.from(allTypes)}
states={selectedStates}
states={states}
allStates={Array.from(allStates)}
onChangeTypes={handleChangeTypes}
onChangeStates={handleChangeStates}

View File

@@ -15,7 +15,7 @@ const TopQueryTable:FC<TopQueryPanelProps> = ({ rows, columns, defaultOrderBy })
const [orderBy, setOrderBy] = useState<keyof TopQuery>(defaultOrderBy || "count");
const [orderDir, setOrderDir] = useState<"asc" | "desc">("desc");
const sortedList = useMemo(() => stableSort(rows, getComparator(orderDir, orderBy)),
const sortedList = useMemo(() => stableSort(rows as [], getComparator(orderDir, orderBy)) as TopQuery[],
[rows, orderBy, orderDir]);
const onSortHandler = (key: keyof TopQuery) => {

View File

@@ -15,7 +15,7 @@
&-fields {
display: flex;
align-items: flex-start;
align-items: center;
flex-wrap: wrap;
gap: $padding-medium;

View File

@@ -20,7 +20,6 @@ $color-error-text: var(--color-error-text);
$color-warning-text: var(--color-warning-text);
$color-info-text: var(--color-info-text);
$color-success-text: var(--color-success-text);
$color-code: var(--color-code);
$color-text: var(--color-text);
$color-text-secondary: var(--color-text-secondary);

View File

@@ -1,26 +0,0 @@
import { describe, it, expect } from "vitest";
import { getDefaultURL } from "./default-server-url";
describe("test server urls", () => {
describe("getDefaultURL()", () => {
it("/select/0/vmui/", () => {
const result = getDefaultURL("https://localhost:1111/select/0/vmui/");
expect(result).toBe("https://localhost:1111/select/0/prometheus");
});
it("/any/path/prefix/select/multitenant/vmui/#/rules?q=test", () => {
const result = getDefaultURL("http://test/any/path/prefix/select/multitenant/vmui/#/rules?q=test");
expect(result).toBe("http://test/any/path/prefix/select/multitenant/prometheus");
});
it("/test/select/1:1/prometheus/graph/", () => {
const result = getDefaultURL("https://domain.com/test/select/1:1/prometheus/graph/");
expect(result).toBe("https://domain.com/test/select/1:1/prometheus");
});
it("https://play.vm.com/#/rules?q=test", () => {
const result = getDefaultURL("https://play.vm.com/#/rules?q=test");
expect(result).toBe("https://play.vm.com");
});
});
});

View File

@@ -3,15 +3,12 @@ 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)\/.*|\/#\/.*)/, "").replace(/(\/select\/[^/]+)$/, "$1/prometheus");
};
export const getDefaultServer = (tenantId?: string): 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 baseURL = window.location.href.replace(/(\/(?:prometheus\/)?(?:graph|vmui)\/.*|\/#\/.*)/, "");
const defaultURL = baseURL.replace(/(\/select\/[\d:]+)$/, "$1/prometheus");
const url = serverURL || storageURL || defaultURL;
switch (APP_TYPE) {

View File

@@ -1,4 +1,4 @@
const regexp = /(\/select\/)([^/])(\/)(.+)/;
const regexp = /(\/select\/)(\d+|\d.+)(\/)(.+)/;
export const replaceTenantId = (serverUrl: string, tenantId: string) => {
return serverUrl.replace(regexp, `$1${tenantId}/$4`);

View File

@@ -1,8 +1,11 @@
package apptest
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"net/url"
"slices"
@@ -500,3 +503,44 @@ func sortTSDBStatusResponseEntries(entries []TSDBStatusResponseEntry) {
return left.Count < right.Count
})
}
// LogsQLQueryResponse is an in-memory representation of the
// /select/logsql/query response.
type LogsQLQueryResponse struct {
LogLines []string
}
// NewLogsQLQueryResponse is a test helper function that creates a new
// instance of LogsQLQueryResponse by unmarshalling a json string.
func NewLogsQLQueryResponse(t *testing.T, s string) *LogsQLQueryResponse {
t.Helper()
res := &LogsQLQueryResponse{}
if len(s) == 0 {
return res
}
bs := bytes.NewBufferString(s)
for {
logLine, err := bs.ReadString('\n')
if err != nil {
if errors.Is(err, io.EOF) {
if len(logLine) > 0 {
t.Fatalf("BUG: unexpected non-empty line=%q with io.EOF", logLine)
}
break
}
t.Fatalf("BUG: cannot read logline from buffer: %s", err)
}
var lv map[string]any
if err := json.Unmarshal([]byte(logLine), &lv); err != nil {
t.Fatalf("cannot parse log line=%q: %s", logLine, err)
}
delete(lv, "_stream_id")
normalizedLine, err := json.Marshal(lv)
if err != nil {
t.Fatalf("cannot marshal parsed logline=%q: %s", logLine, err)
}
res.LogLines = append(res.LogLines, string(normalizedLine))
}
return res
}

View File

@@ -52,7 +52,6 @@ func testSpecialQueryRegression(tc *apptest.TestCase, sut apptest.PrometheusWrit
testTooBigLookbehindWindow(tc, sut)
testMatchSeries(tc, sut)
testNegativeIncrease(tc, sut)
testInstantQueryWithOffsetUsingCache(tc, sut)
// graphite
testComparisonNotInfNotNan(tc, sut)
@@ -293,45 +292,6 @@ func testNegativeIncrease(tc *apptest.TestCase, sut apptest.PrometheusWriteQueri
})
}
func testInstantQueryWithOffsetUsingCache(tc *apptest.TestCase, sut apptest.PrometheusWriteQuerier) {
t := tc.T()
// unexpected /api/v1/query response due to wrong applied offset to request range
// see https://github.com/VictoriaMetrics/VictoriaMetrics/issues/9762
sut.PrometheusAPIV1ImportPrometheus(t, []string{
`vm_http_requests_total 1 1758196800000`, // 2025-09-18 12:00:00
`vm_http_requests_total 2 1758218400000`, // 2025-09-18 18:00:00
`vm_http_requests_total 3 1758240000000`, // 2025-09-19 00:00:00
`vm_http_requests_total 4 1758261600000`, // 2025-09-19 06:00:00
`vm_http_requests_total 5 1758283200000`, // 2025-09-19 12:00:00
`vm_http_requests_total 6 1758304800000`, // 2025-09-19 18:00:00
`vm_http_requests_total 7 1758326400000`, // 2025-09-20 00:00:00
}, apptest.QueryOpts{})
sut.ForceFlush(t)
tc.Assert(&apptest.AssertOptions{
Msg: "unexpected /api/v1/query response",
DoNotRetry: true,
Got: func() any {
return sut.PrometheusAPIV1Query(t, `avg_over_time(vm_http_requests_total[1d] offset 12h)`, apptest.QueryOpts{
Time: "2025-09-20T12:00:00.000Z",
})
},
Want: &apptest.PrometheusAPIV1QueryResponse{
Status: "success",
Data: &apptest.QueryData{
ResultType: "vector",
Result: []*apptest.QueryResult{
{
Metric: map[string]string{},
Sample: &apptest.Sample{Timestamp: 1758369600000, Value: 5.5},
},
},
},
},
})
}
func testComparisonNotInfNotNan(tc *apptest.TestCase, sut apptest.PrometheusWriteQuerier) {
t := tc.T()

View File

@@ -1,51 +0,0 @@
{
"ulid": "01JFJBS3YP1SHZ3PJQ6HK76EC3",
"minTime": 1734709200000,
"maxTime": 1734709320000,
"stats": {
"numSamples": 400,
"numSeries": 100,
"numChunks": 100
},
"compaction": {
"level": 1,
"sources": [
"01JFJBS3YP1SHZ3PJQ6HK76EC3"
],
"parents": [
{
"ulid": "00000000000000000000000000",
"minTime": 0,
"maxTime": 0
}
],
"hints": [
"from-out-of-order"
]
},
"version": 1,
"out_of_order": false,
"thanos": {
"labels": {},
"downsample": {
"resolution": 0
},
"source": "receive",
"segment_files": [
"000001"
],
"files": [
{
"rel_path": "chunks/000001",
"size_bytes": 4808
},
{
"rel_path": "index",
"size_bytes": 55021
},
{
"rel_path": "meta.json"
}
]
}
}

File diff suppressed because one or more lines are too long

View File

@@ -1,139 +0,0 @@
package tests
import (
"encoding/json"
"fmt"
"io"
"os"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/VictoriaMetrics/VictoriaMetrics/apptest"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/fs"
)
const (
testMimirPath = "testdata/mimir-tsdb"
expectedMimirResponseFile = "./testdata/mimir-tsdb/expected_response.json"
)
func TestSingleVmctlMimirProtocol(t *testing.T) {
fs.MustRemoveDir(t.Name())
tc := apptest.NewTestCase(t)
defer tc.Stop()
vmsingleDst := tc.MustStartDefaultVmsingle()
vmAddr := fmt.Sprintf("http://%s/", vmsingleDst.HTTPAddr())
dir, err := os.Getwd()
if err != nil {
t.Fatalf("cannot get current working directory: %s", err)
}
path := fmt.Sprintf("fs://%s/%s", dir, testMimirPath)
vmctlFlags := []string{
`mimir`,
`--mimir-tenant-id=anonymous`,
`--mimir-filter-time-start=2024-12-01T00:00:00Z`,
`--mimir-filter-time-end=2024-12-31T23:59:59Z`,
`--mimir-custom-s3-endpoint=http://localhost:9000`,
`--mimir-path=` + path,
`--vm-addr=` + vmAddr,
`--disable-progress-bar=true`,
`--vm-concurrency=6`,
`--mimir-concurrency=6`,
}
testMimirProtocol(tc, vmsingleDst, vmctlFlags)
}
func TestClusterVmctlMimirProtocol(t *testing.T) {
fs.MustRemoveDir(t.Name())
tc := apptest.NewTestCase(t)
defer tc.Stop()
cluster := tc.MustStartDefaultCluster()
vmAddr := fmt.Sprintf("http://%s/", cluster.Vminsert.HTTPAddr())
dir, err := os.Getwd()
if err != nil {
t.Fatalf("cannot get current working directory: %s", err)
}
path := fmt.Sprintf("fs://%s/%s", dir, testMimirPath)
vmctlFlags := []string{
`mimir`,
`--mimir-tenant-id=anonymous`,
`--mimir-filter-time-start=2024-12-01T00:00:00Z`,
`--mimir-filter-time-end=2024-12-31T23:59:59Z`,
`--mimir-custom-s3-endpoint=http://localhost:9000`,
`--mimir-path=` + path,
`--vm-addr=` + vmAddr,
`--disable-progress-bar=true`,
`--vm-concurrency=6`,
`--mimir-concurrency=6`,
}
testMimirProtocol(tc, cluster, vmctlFlags)
}
func testMimirProtocol(tc *apptest.TestCase, sut apptest.PrometheusWriteQuerier, vmctlFlags []string) {
t := tc.T()
t.Helper()
cmpOpt := cmpopts.IgnoreFields(apptest.PrometheusAPIV1QueryResponse{}, "Status", "Data.ResultType")
// test for empty data request
got := sut.PrometheusAPIV1Query(t, `{__name__=~".*"}`, apptest.QueryOpts{
Step: "5m",
Time: "2025-06-02T17:14:00Z",
})
want := apptest.NewPrometheusAPIV1QueryResponse(t, `{"data":{"result":[]}}`)
if diff := cmp.Diff(want, got, cmpOpt); diff != "" {
t.Errorf("unexpected response (-want, +got):\n%s", diff)
}
tc.MustStartVmctl("vmctl", vmctlFlags)
sut.ForceFlush(t)
// open the expected series response file
file, err := os.Open(expectedMimirResponseFile)
if err != nil {
t.Fatalf("cannot open expected series response file: %s", err)
}
defer file.Close()
bytes, err := io.ReadAll(file)
if err != nil {
t.Fatalf("cannot read expected series response file: %s", err)
}
var wantResponse apptest.PrometheusAPIV1QueryResponse
if err := json.Unmarshal(bytes, &wantResponse); err != nil {
t.Fatalf("cannot unmarshal expected series response file: %s", err)
}
wantResponse.Sort()
tc.Assert(&apptest.AssertOptions{
// For cluster version, we need to wait longer for the metrics to be stored
Retries: 300,
Msg: `unexpected metrics stored on vmsingle via the prometheus protocol`,
Got: func() any {
expected := sut.PrometheusAPIV1Export(t, `{__name__=~".*"}`, apptest.QueryOpts{
Start: "2024-12-01T15:31:10Z",
End: "2024-12-31T15:32:20Z",
})
expected.Sort()
return expected.Data.Result
},
Want: wantResponse.Data.Result,
CmpOpts: []cmp.Option{
cmpopts.IgnoreFields(apptest.PrometheusAPIV1QueryResponse{}, "Status", "Data.ResultType"),
},
})
}

View File

@@ -1786,4 +1786,4 @@
"uid": "gF-lxRdVz",
"version": 1,
"weekStart": ""
}
}

View File

@@ -1168,7 +1168,7 @@
"uid": "$ds"
},
"editorMode": "code",
"expr": "histogram_quantile(0.99, sum(rate(controller_runtime_reconcile_time_seconds_bucket{job=~\"$job\"}[$__rate_interval])) by (le, controller) )",
"expr": "histogram_quantile(0.99,sum(rate(controller_runtime_reconcile_time_seconds_bucket{job=~\"$job\"}[$__rate_interval])) by(le,controller) )",
"legendFormat": "q.99 {{controller}}",
"range": true,
"refId": "A"
@@ -1265,7 +1265,7 @@
"uid": "$ds"
},
"editorMode": "code",
"expr": "sum(rate(rest_client_requests_total{job=~\"$job\"}[$__interval])) by (method, code)",
"expr": "sum(rate(rest_client_requests_total{job=~\"$job\"}[$__interval])) by (method,code)",
"instant": false,
"legendFormat": "{{method}} {{code}}",
"range": true,
@@ -1489,7 +1489,7 @@
"uid": "$ds"
},
"editorMode": "code",
"expr": "max(histogram_quantile(0.99, sum(rate(go_sched_latencies_seconds_bucket{job=~\"$job\"}[$__rate_interval])) by (job, instance, le))) by (job)",
"expr": "max(histogram_quantile(0.99, sum(rate(go_sched_latencies_seconds_bucket{job=~\"$job\"}[$__rate_interval])) by (job, instance, le))) by(job)",
"instant": false,
"legendFormat": "__auto",
"range": true,
@@ -1588,7 +1588,7 @@
"uid": "$ds"
},
"editorMode": "code",
"expr": "histogram_quantile(0.99, sum(rate(rest_client_request_duration_seconds_bucket{job=~\"$job\"}[$__rate_interval])) by (le, method, api))",
"expr": "histogram_quantile(0.99,sum(rate(rest_client_request_duration_seconds_bucket{job=~\"$job\"})) by(le,method,api) )",
"instant": false,
"legendFormat": "{{method}} {{api}}",
"range": true,
@@ -2135,16 +2135,6 @@
"skipUrlSync": false,
"sort": 2,
"type": "query"
},
{
"baseFilters": [],
"datasource": {
"type": "prometheus",
"uid": "$ds"
},
"filters": [],
"name": "adhoc",
"type": "adhoc"
}
]
},

View File

@@ -1950,16 +1950,6 @@
],
"query": "*",
"type": "textbox"
},
{
"baseFilters": [],
"datasource": {
"type": "victoriametrics-logs-datasource",
"uid": "$ds"
},
"filters": [],
"name": "adhoc",
"type": "adhoc"
}
]
},
@@ -1972,4 +1962,4 @@
"title": "Query Stats (cluster)",
"uid": "feg3od1zt1fy8e",
"version": 1
}
}

View File

@@ -1787,4 +1787,4 @@
"uid": "gF-lxRdVz_vm",
"version": 1,
"weekStart": ""
}
}

View File

@@ -1994,7 +1994,7 @@
"baseFilters": [],
"datasource": {
"type": "victoriametrics-metrics-datasource",
"uid": "$ds"
"uid": "PE8D8DB4BEE4E4B22"
},
"filters": [],
"name": "adhoc",

View File

@@ -2136,16 +2136,6 @@
"skipUrlSync": false,
"sort": 2,
"type": "query"
},
{
"baseFilters": [],
"datasource": {
"type": "victoriametrics-metrics-datasource",
"uid": "$ds"
},
"filters": [],
"name": "adhoc",
"type": "adhoc"
}
]
},

View File

@@ -4238,4 +4238,4 @@
"title": "VictoriaMetrics - vmalert (VM)",
"uid": "LzldHAVnz_vm",
"version": 1
}
}

View File

@@ -2652,7 +2652,7 @@
{
"datasource": {
"type": "victoriametrics-datasource",
"uid": "$ds"
"uid": "P38648FE0F8C5BEA2"
},
"filters": [],
"hide": 0,

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