mirror of
https://github.com/VictoriaMetrics/VictoriaMetrics.git
synced 2026-06-09 03:43:58 +03:00
Compare commits
130 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84658e77da | ||
|
|
4dc32ff1d7 | ||
|
|
08a1b2e75c | ||
|
|
7e5b68fc1f | ||
|
|
dcc130603c | ||
|
|
9842ad2299 | ||
|
|
63c0cf673f | ||
|
|
7f51bb4ce7 | ||
|
|
38df52ea08 | ||
|
|
023a13435c | ||
|
|
1ddcbed6d7 | ||
|
|
edd02cdb5b | ||
|
|
4cd727a511 | ||
|
|
19c0477976 | ||
|
|
4fdd8f0906 | ||
|
|
9897872ca9 | ||
|
|
b8bbb07431 | ||
|
|
eb1c8dd67d | ||
|
|
50fc48ac47 | ||
|
|
3bd9c75acc | ||
|
|
9c0683f8d1 | ||
|
|
bf4660912f | ||
|
|
bb54b5e661 | ||
|
|
200a729565 | ||
|
|
7303495ae1 | ||
|
|
98b5288e9c | ||
|
|
d7f9cd971d | ||
|
|
2cb08095c6 | ||
|
|
8bc41f4c79 | ||
|
|
bb1e0d8f3b | ||
|
|
70be2e7ea3 | ||
|
|
61796e355a | ||
|
|
2ae3fd47eb | ||
|
|
ebad7e5496 | ||
|
|
e52de06ee5 | ||
|
|
38dd971f58 | ||
|
|
aad6ab009e | ||
|
|
2c125e14c7 | ||
|
|
13dc60e257 | ||
|
|
ed64c90e7a | ||
|
|
49b0a4fb16 | ||
|
|
5141496c43 | ||
|
|
24fac64875 | ||
|
|
8250f469a7 | ||
|
|
7fb0f0e015 | ||
|
|
563dbeaea1 | ||
|
|
7e6468c1e3 | ||
|
|
328f33202f | ||
|
|
951331db80 | ||
|
|
e6139be8ba | ||
|
|
77e5920014 | ||
|
|
78049e991b | ||
|
|
c972d70f00 | ||
|
|
b947562f2b | ||
|
|
344a81fa20 | ||
|
|
4b022ea8a8 | ||
|
|
04c24fc831 | ||
|
|
d2f78e4b2b | ||
|
|
3995837c58 | ||
|
|
1d53496f98 | ||
|
|
73a1ce2dd6 | ||
|
|
daa88f6a43 | ||
|
|
7bff73b0f7 | ||
|
|
bf3b1cf6b6 | ||
|
|
a10ff67354 | ||
|
|
9a8463df42 | ||
|
|
7e22b169f1 | ||
|
|
80c1af5af1 | ||
|
|
5a587f2006 | ||
|
|
847cd1e336 | ||
|
|
c86857b269 | ||
|
|
c93937101c | ||
|
|
cca7380dd3 | ||
|
|
ca3b9b18b5 | ||
|
|
10f7cd2ffc | ||
|
|
fa85726a82 | ||
|
|
567c084d6d | ||
|
|
12a1388fbc | ||
|
|
62c19b386a | ||
|
|
a84586a246 | ||
|
|
24867a042b | ||
|
|
9baade2898 | ||
|
|
6117b2ead9 | ||
|
|
0df2993cf4 | ||
|
|
14f1bda8fc | ||
|
|
f96f4709f6 | ||
|
|
96c1392b45 | ||
|
|
8dd905c7a9 | ||
|
|
1c7abd3137 | ||
|
|
e8114806aa | ||
|
|
8f1c1cc7c9 | ||
|
|
68f670cbc5 | ||
|
|
dac7e8d554 | ||
|
|
3db6c40b70 | ||
|
|
90c69a07a9 | ||
|
|
f5b1092e07 | ||
|
|
3e6fc445a9 | ||
|
|
1130adebad | ||
|
|
1fb3f105c3 | ||
|
|
38d3033e66 | ||
|
|
cfba80ed4d | ||
|
|
ee0eff0ca2 | ||
|
|
181f95aaf6 | ||
|
|
58485df033 | ||
|
|
30641b201b | ||
|
|
b2cd3bf1f2 | ||
|
|
5336091785 | ||
|
|
5b11f6f384 | ||
|
|
e8975e560d | ||
|
|
3b5822398c | ||
|
|
742a3384dc | ||
|
|
8a1beef46d | ||
|
|
22158b7272 | ||
|
|
4a18f4e49d | ||
|
|
85cbbfe0bc | ||
|
|
d5705a9647 | ||
|
|
c90c7c3123 | ||
|
|
1b11031ec8 | ||
|
|
e7b7015eb1 | ||
|
|
6ee1edeb4d | ||
|
|
9725ee50ec | ||
|
|
780cb1bf05 | ||
|
|
a34d0d6056 | ||
|
|
5e98e0cff5 | ||
|
|
51b44afd34 | ||
|
|
5a8d7984ca | ||
|
|
57752ca2c0 | ||
|
|
171cdf0614 | ||
|
|
7d19ec2e4d | ||
|
|
a9b5033d50 |
48
.github/scripts/lint-changelog-tip.sh
vendored
Executable file
48
.github/scripts/lint-changelog-tip.sh
vendored
Executable file
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env sh
|
||||
|
||||
set -e
|
||||
|
||||
CHANGELOG_FILE="docs/victoriametrics/changelog/CHANGELOG.md"
|
||||
|
||||
GITHUB_BASE_REF=${GITHUB_BASE_REF:-"master"}
|
||||
GIT_REMOTE=${GIT_REMOTE:-"origin"}
|
||||
|
||||
git diff "${GIT_REMOTE}/${GITHUB_BASE_REF}"...HEAD -- $CHANGELOG_FILE > diff.txt
|
||||
if ! grep -q "^+" diff.txt; then
|
||||
echo "No additions in CHANGELOG.md"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
ADDED_LINES=$(grep "^+\S" diff.txt | sed 's/^+//')
|
||||
|
||||
START_TIP=$(grep -n "^## tip" "$CHANGELOG_FILE" | head -1 | cut -d: -f1)
|
||||
if [ -z "$START_TIP" ]; then
|
||||
echo "ERROR: ${CHANGELOG_FILE} does not contain a ## tip section"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
END_TIP=$(awk "NR>$START_TIP && /^## / {print NR; exit}" "${CHANGELOG_FILE}")
|
||||
if [ -z "$END_TIP" ]; then
|
||||
END_TIP=$(wc -l < "$CHANGELOG_FILE")
|
||||
fi
|
||||
|
||||
BAD=0
|
||||
while IFS= read -r line; do
|
||||
# Grep exact line inside the file and get line numbers
|
||||
MATCHES=$(grep -n -F "$line" "$CHANGELOG_FILE" | cut -d: -f1)
|
||||
for m in $MATCHES; do
|
||||
if [ "$m" -lt "$START_TIP" ] || [ "$m" -gt "$END_TIP" ]; then
|
||||
echo "'$line' on line ${m} is outside ## tip section (lines ${START_TIP}-${END_TIP})"
|
||||
BAD=1
|
||||
fi
|
||||
done
|
||||
done << EOF
|
||||
$ADDED_LINES
|
||||
EOF
|
||||
|
||||
if [ "$BAD" -ne 0 ]; then
|
||||
echo "CHANGELOG modifications must be placed inside the ## tip section."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "CHANGELOG modifications are valid."
|
||||
2
.github/workflows/build.yml
vendored
2
.github/workflows/build.yml
vendored
@@ -61,7 +61,7 @@ jobs:
|
||||
arch: amd64
|
||||
steps:
|
||||
- name: Code checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Go
|
||||
id: go
|
||||
|
||||
19
.github/workflows/changelog-linter.yml
vendored
Normal file
19
.github/workflows/changelog-linter.yml
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
name: 'changelog-linter'
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "docs/victoriametrics/changelog/CHANGELOG.md"
|
||||
|
||||
jobs:
|
||||
tip-lint:
|
||||
runs-on: 'ubuntu-latest'
|
||||
steps:
|
||||
- uses: 'actions/checkout@v4'
|
||||
with:
|
||||
# needed for proper diff
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 'Validate that changelog changes are under ## tip'
|
||||
run: |
|
||||
GITHUB_BASE_REF=${{ github.base_ref }} ./.github/scripts/lint-changelog-tip.sh
|
||||
2
.github/workflows/check-commit-signed.yml
vendored
2
.github/workflows/check-commit-signed.yml
vendored
@@ -8,7 +8,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0 # we need full history for commit verification
|
||||
|
||||
|
||||
2
.github/workflows/codeql-analysis-go.yml
vendored
2
.github/workflows/codeql-analysis-go.yml
vendored
@@ -29,7 +29,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Go
|
||||
id: go
|
||||
|
||||
4
.github/workflows/docs.yaml
vendored
4
.github/workflows/docs.yaml
vendored
@@ -16,12 +16,12 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Code checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
path: __vm
|
||||
|
||||
- name: Checkout private code
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
repository: VictoriaMetrics/vmdocs
|
||||
token: ${{ secrets.VM_BOT_GH_TOKEN }}
|
||||
|
||||
6
.github/workflows/test.yml
vendored
6
.github/workflows/test.yml
vendored
@@ -32,7 +32,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Code checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Go
|
||||
id: go
|
||||
@@ -71,7 +71,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Code checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Go
|
||||
id: go
|
||||
@@ -97,7 +97,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Code checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Go
|
||||
id: go
|
||||
|
||||
2
.github/workflows/vmui.yml
vendored
2
.github/workflows/vmui.yml
vendored
@@ -32,7 +32,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Code checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
|
||||
3
Makefile
3
Makefile
@@ -500,7 +500,8 @@ app-local-windows-goarch:
|
||||
CGO_ENABLED=0 GOOS=windows GOARCH=$(GOARCH) go build $(RACE) -ldflags "$(GO_BUILDINFO)" -tags "$(EXTRA_GO_BUILD_TAGS)" -o bin/$(APP_NAME)-windows-$(GOARCH)$(RACE).exe $(PKG_PREFIX)/app/$(APP_NAME)
|
||||
|
||||
quicktemplate-gen: install-qtc
|
||||
qtc
|
||||
qtc -dir=lib
|
||||
qtc -dir=app
|
||||
|
||||
install-qtc:
|
||||
which qtc || go install github.com/valyala/quicktemplate/qtc@latest
|
||||
|
||||
@@ -111,7 +111,6 @@ func main() {
|
||||
flag.CommandLine.SetOutput(os.Stdout)
|
||||
flag.Usage = usage
|
||||
envflag.Parse()
|
||||
flagutil.ApplySecretFlags()
|
||||
remotewrite.InitSecretFlags()
|
||||
buildinfo.Init()
|
||||
logger.Init()
|
||||
|
||||
@@ -116,7 +116,7 @@ func TestParse_Failure(t *testing.T) {
|
||||
|
||||
f([]string{"testdata/rules/rules_interval_bad.rules"}, "eval_offset should be smaller than interval")
|
||||
f([]string{"testdata/rules/rules0-bad.rules"}, "unexpected token")
|
||||
f([]string{"testdata/dir/rules0-bad.rules"}, "error parsing annotation")
|
||||
f([]string{"testdata/dir/rules0-bad.rules"}, "invalid annotations")
|
||||
f([]string{"testdata/dir/rules1-bad.rules"}, "duplicate in file")
|
||||
f([]string{"testdata/dir/rules2-bad.rules"}, "function \"unknown\" not defined")
|
||||
f([]string{"testdata/dir/rules3-bad.rules"}, "either `record` or `alert` must be set")
|
||||
@@ -343,7 +343,6 @@ func TestGroupValidate_Failure(t *testing.T) {
|
||||
},
|
||||
},
|
||||
}, true, "bad prometheus expr")
|
||||
|
||||
}
|
||||
|
||||
func TestGroupValidate_Success(t *testing.T) {
|
||||
|
||||
@@ -76,7 +76,7 @@ absolute path to all .tpl files in root.
|
||||
`Link to VMUI: -external.alert.source='vmui/#/?g0.expr={{.Expr|queryEscape}}'. `+
|
||||
`If empty 'vmalert/alert?group_id={{.GroupID}}&alert_id={{.AlertID}}' is used.`)
|
||||
externalLabels = flagutil.NewArrayString("external.label", "Optional label in the form 'Name=value' to add to all generated recording rules and alerts. "+
|
||||
"In case of conflicts, original labels are kept with prefix `exported_`.")
|
||||
"In case of conflicts, original labels are kept with prefix 'exported_'.")
|
||||
|
||||
dryRun = flag.Bool("dryRun", false, "Whether to check only config files without running vmalert. The rules file are validated. The -rule flag must be specified.")
|
||||
)
|
||||
@@ -90,7 +90,6 @@ func main() {
|
||||
flag.CommandLine.SetOutput(os.Stdout)
|
||||
flag.Usage = usage
|
||||
envflag.Parse()
|
||||
flagutil.ApplySecretFlags()
|
||||
remoteread.InitSecretFlags()
|
||||
remotewrite.InitSecretFlags()
|
||||
datasource.InitSecretFlags()
|
||||
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
"github.com/VictoriaMetrics/VictoriaMetrics/app/vmalert/config"
|
||||
@@ -45,13 +46,15 @@ func (m *manager) ruleAPI(gID, rID uint64) (rule.ApiRule, error) {
|
||||
m.groupsMu.RLock()
|
||||
defer m.groupsMu.RUnlock()
|
||||
|
||||
g, ok := m.groups[gID]
|
||||
group, ok := m.groups[gID]
|
||||
if !ok {
|
||||
return rule.ApiRule{}, fmt.Errorf("can't find group with id %d", gID)
|
||||
}
|
||||
g := group.ToAPI()
|
||||
ruleID := strconv.FormatUint(rID, 10)
|
||||
for _, r := range g.Rules {
|
||||
if r.ID() == rID {
|
||||
return r.ToAPI(), nil
|
||||
if r.ID == ruleID {
|
||||
return r, nil
|
||||
}
|
||||
}
|
||||
return rule.ApiRule{}, fmt.Errorf("can't find rule with id %d in group %q", rID, g.Name)
|
||||
@@ -62,17 +65,20 @@ func (m *manager) alertAPI(gID, aID uint64) (*rule.ApiAlert, error) {
|
||||
m.groupsMu.RLock()
|
||||
defer m.groupsMu.RUnlock()
|
||||
|
||||
g, ok := m.groups[gID]
|
||||
group, ok := m.groups[gID]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("can't find group with id %d", gID)
|
||||
}
|
||||
g := group.ToAPI()
|
||||
for _, r := range g.Rules {
|
||||
ar, ok := r.(*rule.AlertingRule)
|
||||
if !ok {
|
||||
if r.Type != rule.TypeAlerting {
|
||||
continue
|
||||
}
|
||||
if apiAlert := ar.AlertToAPI(aID); apiAlert != nil {
|
||||
return apiAlert, nil
|
||||
alertID := strconv.FormatUint(aID, 10)
|
||||
for _, a := range r.Alerts {
|
||||
if a.ID == alertID {
|
||||
return a, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("can't find alert with id %d in group %q", aID, g.Name)
|
||||
|
||||
@@ -166,8 +166,8 @@ func templateAnnotations(annotations map[string]string, data AlertTplData, tmpl
|
||||
ctmpl, _ := tmpl.Clone()
|
||||
ctmpl = ctmpl.Option("missingkey=zero")
|
||||
if err := templateAnnotation(&buf, builder.String(), tData, ctmpl, execute); err != nil {
|
||||
r[key] = text
|
||||
eg.Add(fmt.Errorf("key %q, template %q: %w", key, text, err))
|
||||
r[key] = err.Error()
|
||||
eg.Add(fmt.Errorf("(key: %q, value: %q): %w", key, text, err))
|
||||
continue
|
||||
}
|
||||
r[key] = buf.String()
|
||||
@@ -184,13 +184,13 @@ type tplData struct {
|
||||
func templateAnnotation(dst io.Writer, text string, data tplData, tpl *textTpl.Template, execute bool) error {
|
||||
tpl, err := tpl.Parse(text)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error parsing annotation template: %w", err)
|
||||
return fmt.Errorf("error parsing template: %w", err)
|
||||
}
|
||||
if !execute {
|
||||
return nil
|
||||
}
|
||||
if err = tpl.Execute(dst, data); err != nil {
|
||||
return fmt.Errorf("error evaluating annotation template: %w", err)
|
||||
return fmt.Errorf("error evaluating template: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package notifier
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -86,6 +87,11 @@ func (am *AlertManager) Send(ctx context.Context, alerts []Alert, alertLabels []
|
||||
err := am.send(ctx, alerts, alertLabels, headers)
|
||||
am.metrics.alertsSendDuration.UpdateDuration(startTime)
|
||||
if err != nil {
|
||||
// the context can be cancelled on graceful shutdown
|
||||
// or on group update. So no need to handle the error as usual.
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return nil
|
||||
}
|
||||
am.metrics.alertsSendErrors.Add(len(alerts))
|
||||
am.lastError = err.Error()
|
||||
} else {
|
||||
|
||||
@@ -14,9 +14,9 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
addr = flag.String("remoteRead.url", "", "Optional URL to datasource compatible with MetricsQL. It can be single node VictoriaMetrics or vmselect."+
|
||||
"Remote read is used to restore alerts state."+
|
||||
"This configuration makes sense only if `vmalert` was configured with `remoteWrite.url` before and has been successfully persisted its state. "+
|
||||
addr = flag.String("remoteRead.url", "", "Optional URL to datasource compatible with MetricsQL. It can be single node VictoriaMetrics or vmselect. "+
|
||||
"Remote read is used to restore alerts state. "+
|
||||
"This configuration makes sense only if vmalert was configured with '-remoteWrite.url' before and has been successfully persisted its state. "+
|
||||
"Supports address in the form of IP address with a port (e.g., http://127.0.0.1:8428) or DNS SRV record. "+
|
||||
"See also '-remoteRead.disablePathAppend', '-remoteRead.showURL'.")
|
||||
|
||||
|
||||
@@ -246,16 +246,6 @@ func (ar *AlertingRule) GetAlerts() []*notifier.Alert {
|
||||
return alerts
|
||||
}
|
||||
|
||||
// GetAlert returns alert if id exists
|
||||
func (ar *AlertingRule) GetAlert(id uint64) *notifier.Alert {
|
||||
ar.alertsMu.RLock()
|
||||
defer ar.alertsMu.RUnlock()
|
||||
if ar.alerts == nil {
|
||||
return nil
|
||||
}
|
||||
return ar.alerts[id]
|
||||
}
|
||||
|
||||
func (ar *AlertingRule) logDebugf(at time.Time, a *notifier.Alert, format string, args ...any) {
|
||||
if !ar.Debug {
|
||||
return
|
||||
@@ -321,6 +311,11 @@ type labelSet struct {
|
||||
// On k conflicts in origin set, the original value is preferred and copied
|
||||
// to processed with `exported_%k` key. The copy happens only if passed v isn't equal to origin[k] value.
|
||||
func (ls *labelSet) add(k, v string) {
|
||||
// do not add label with empty value, since it has no meaning.
|
||||
// see https://github.com/VictoriaMetrics/VictoriaMetrics/issues/9984
|
||||
if v == "" {
|
||||
return
|
||||
}
|
||||
ls.processed[k] = v
|
||||
ov, ok := ls.origin[k]
|
||||
if !ok {
|
||||
@@ -355,9 +350,6 @@ func (ar *AlertingRule) toLabels(m datasource.Metric, qFn templates.QueryFn) (*l
|
||||
Value: m.Values[0],
|
||||
Expr: ar.Expr,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to expand labels: %w", err)
|
||||
}
|
||||
for k, v := range extraLabels {
|
||||
ls.add(k, v)
|
||||
}
|
||||
@@ -368,7 +360,7 @@ func (ar *AlertingRule) toLabels(m datasource.Metric, qFn templates.QueryFn) (*l
|
||||
if !*disableAlertGroupLabel && ar.GroupName != "" {
|
||||
ls.add(alertGroupNameLabel, ar.GroupName)
|
||||
}
|
||||
return ls, nil
|
||||
return ls, err
|
||||
}
|
||||
|
||||
// execRange executes alerting rule on the given time range similarly to exec.
|
||||
@@ -484,8 +476,9 @@ func (ar *AlertingRule) exec(ctx context.Context, ts time.Time, limit int) ([]pr
|
||||
for i, m := range res.Data {
|
||||
ls, err := ar.expandLabelTemplates(m, qFn)
|
||||
if err != nil {
|
||||
// only set error in current state, but do not break alert processing
|
||||
curState.Err = err
|
||||
return nil, curState.Err
|
||||
logger.Errorf("got templating error in rule %s: %q", ar.Name, err)
|
||||
}
|
||||
at := ts
|
||||
alertID := hash(ls.processed)
|
||||
@@ -497,8 +490,9 @@ func (ar *AlertingRule) exec(ctx context.Context, ts time.Time, limit int) ([]pr
|
||||
}
|
||||
as, err := ar.expandAnnotationTemplates(m, qFn, at, ls)
|
||||
if err != nil {
|
||||
// only set error in current state, but do not break alert processing
|
||||
curState.Err = err
|
||||
return nil, curState.Err
|
||||
logger.Errorf("got templating error in rule %s: %q", ar.Name, err)
|
||||
}
|
||||
expandedLabels[i] = ls
|
||||
expandedAnnotations[i] = as
|
||||
@@ -607,7 +601,7 @@ func (ar *AlertingRule) exec(ctx context.Context, ts time.Time, limit int) ([]pr
|
||||
func (ar *AlertingRule) expandLabelTemplates(m datasource.Metric, qFn templates.QueryFn) (*labelSet, error) {
|
||||
ls, err := ar.toLabels(m, qFn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to expand label templates: %s", err)
|
||||
return ls, fmt.Errorf("failed to expand label templates: %s", err)
|
||||
}
|
||||
return ls, nil
|
||||
}
|
||||
@@ -625,7 +619,7 @@ func (ar *AlertingRule) expandAnnotationTemplates(m datasource.Metric, qFn templ
|
||||
}
|
||||
as, err := notifier.ExecTemplate(qFn, ar.Annotations, tplData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to expand annotation templates: %s", err)
|
||||
return as, fmt.Errorf("failed to expand annotation templates: %s", err)
|
||||
}
|
||||
return as, nil
|
||||
}
|
||||
|
||||
@@ -1370,8 +1370,10 @@ func TestAlertingRule_ToLabels(t *testing.T) {
|
||||
|
||||
ar := &AlertingRule{
|
||||
Labels: map[string]string{
|
||||
"instance": "override", // this should override instance with new value
|
||||
"group": "vmalert", // this shouldn't have effect since value in metric is equal
|
||||
"instance": "override", // this should override instance with new value
|
||||
"group": "vmalert", // this shouldn't have effect since value in metric is equal
|
||||
"invalid_label": "{{ .Values.mustRuntimeFail }}",
|
||||
"empty_label": "", // this should be dropped
|
||||
},
|
||||
Expr: "sum(vmalert_alerting_rules_error) by(instance, group, alertname) > 0",
|
||||
Name: "AlertingRulesError",
|
||||
@@ -1379,10 +1381,11 @@ func TestAlertingRule_ToLabels(t *testing.T) {
|
||||
}
|
||||
|
||||
expectedOriginLabels := map[string]string{
|
||||
"instance": "0.0.0.0:8800",
|
||||
"group": "vmalert",
|
||||
"alertname": "ConfigurationReloadFailure",
|
||||
"alertgroup": "vmalert",
|
||||
"instance": "0.0.0.0:8800",
|
||||
"group": "vmalert",
|
||||
"alertname": "ConfigurationReloadFailure",
|
||||
"alertgroup": "vmalert",
|
||||
"invalid_label": `error evaluating template: template: :1:268: executing "" at <.Values.mustRuntimeFail>: can't evaluate field Values in type notifier.tplData`,
|
||||
}
|
||||
|
||||
expectedProcessedLabels := map[string]string{
|
||||
@@ -1392,11 +1395,12 @@ func TestAlertingRule_ToLabels(t *testing.T) {
|
||||
"exported_alertname": "ConfigurationReloadFailure",
|
||||
"group": "vmalert",
|
||||
"alertgroup": "vmalert",
|
||||
"invalid_label": `error evaluating template: template: :1:268: executing "" at <.Values.mustRuntimeFail>: can't evaluate field Values in type notifier.tplData`,
|
||||
}
|
||||
|
||||
ls, err := ar.toLabels(metric, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %s", err)
|
||||
if err == nil || !strings.Contains(err.Error(), "error evaluating template") {
|
||||
t.Fatalf("unexpected error %q", err.Error())
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(ls.origin, expectedOriginLabels) {
|
||||
|
||||
@@ -236,7 +236,8 @@ func (rr *RecordingRule) exec(ctx context.Context, ts time.Time, limit int) ([]p
|
||||
Labels: stringToLabels(k),
|
||||
Samples: []prompb.Sample{
|
||||
{Value: decimal.StaleNaN, Timestamp: ts.UnixNano() / 1e6},
|
||||
}})
|
||||
},
|
||||
})
|
||||
}
|
||||
rr.lastEvaluation = curEvaluation
|
||||
return tss, nil
|
||||
@@ -291,6 +292,11 @@ func (rr *RecordingRule) toTimeSeries(m datasource.Metric) prompb.TimeSeries {
|
||||
}
|
||||
// add extra labels configured by user
|
||||
for k := range rr.Labels {
|
||||
// do not add label with empty value, since it has no meaning.
|
||||
// see https://github.com/VictoriaMetrics/VictoriaMetrics/issues/9984
|
||||
if rr.Labels[k] == "" {
|
||||
continue
|
||||
}
|
||||
existingLabel := promrelabel.GetLabelByName(m.Labels, k)
|
||||
if existingLabel != nil { // there is a conflict between extra and existing label
|
||||
if existingLabel.Value == rr.Labels[k] {
|
||||
|
||||
@@ -209,15 +209,6 @@ func (ar *AlertingRule) AlertsToAPI() []*ApiAlert {
|
||||
return alerts
|
||||
}
|
||||
|
||||
// AlertToAPI generates apiAlert object from alert by its id(hash)
|
||||
func (ar *AlertingRule) AlertToAPI(id uint64) *ApiAlert {
|
||||
a := ar.GetAlert(id)
|
||||
if a == nil {
|
||||
return nil
|
||||
}
|
||||
return NewAlertAPI(ar, a)
|
||||
}
|
||||
|
||||
// NewAlertAPI creates apiAlert for notifier.Alert
|
||||
func NewAlertAPI(ar *AlertingRule, a *notifier.Alert) *ApiAlert {
|
||||
aa := &ApiAlert{
|
||||
|
||||
@@ -412,18 +412,18 @@ func (rh *requestHandler) groupAlerts() []rule.GroupAlerts {
|
||||
defer rh.m.groupsMu.RUnlock()
|
||||
|
||||
var gAlerts []rule.GroupAlerts
|
||||
for _, g := range rh.m.groups {
|
||||
for _, group := range rh.m.groups {
|
||||
var alerts []*rule.ApiAlert
|
||||
g := group.ToAPI()
|
||||
for _, r := range g.Rules {
|
||||
a, ok := r.(*rule.AlertingRule)
|
||||
if !ok {
|
||||
if r.Type != rule.TypeAlerting {
|
||||
continue
|
||||
}
|
||||
alerts = append(alerts, a.AlertsToAPI()...)
|
||||
alerts = append(alerts, r.Alerts...)
|
||||
}
|
||||
if len(alerts) > 0 {
|
||||
gAlerts = append(gAlerts, rule.GroupAlerts{
|
||||
Group: g.ToAPI(),
|
||||
Group: g,
|
||||
Alerts: alerts,
|
||||
})
|
||||
}
|
||||
@@ -444,12 +444,12 @@ func (rh *requestHandler) listAlerts(rf *rulesFilter) ([]byte, error) {
|
||||
if !rf.matchesGroup(group) {
|
||||
continue
|
||||
}
|
||||
for _, r := range group.Rules {
|
||||
a, ok := r.(*rule.AlertingRule)
|
||||
if !ok {
|
||||
g := group.ToAPI()
|
||||
for _, r := range g.Rules {
|
||||
if r.Type != rule.TypeAlerting {
|
||||
continue
|
||||
}
|
||||
lr.Data.Alerts = append(lr.Data.Alerts, a.AlertsToAPI()...)
|
||||
lr.Data.Alerts = append(lr.Data.Alerts, r.Alerts...)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -602,11 +602,11 @@
|
||||
<table class="table table-striped table-hover table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" title="The time when event was created">Updated at</th>
|
||||
<th scope="col" title="The time when the rule was executed">Updated at</th>
|
||||
<th scope="col" class="w-10 text-center" title="How many series expression returns. Each series will represent an alert.">Series returned</th>
|
||||
{% if seriesFetchedEnabled %}<th scope="col" class="w-10 text-center" title="How many series were scanned by datasource during the evaluation">Series fetched</th>{% endif %}
|
||||
<th scope="col" class="w-10 text-center" title="How many seconds request took">Duration</th>
|
||||
<th scope="col" class="text-center" title="Time used for rule execution">Executed at</th>
|
||||
<th scope="col" class="text-center" title="The time used in execution query request">Execution timestamp</th>
|
||||
<th scope="col" class="text-center" title="cURL command with request example">cURL</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
@@ -1717,7 +1717,7 @@ func StreamRuleDetails(qw422016 *qt422016.Writer, r *http.Request, rule rule.Api
|
||||
<table class="table table-striped table-hover table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" title="The time when event was created">Updated at</th>
|
||||
<th scope="col" title="The time when the rule was executed">Updated at</th>
|
||||
<th scope="col" class="w-10 text-center" title="How many series expression returns. Each series will represent an alert.">Series returned</th>
|
||||
`)
|
||||
//line app/vmalert/web.qtpl:607
|
||||
@@ -1729,7 +1729,7 @@ func StreamRuleDetails(qw422016 *qt422016.Writer, r *http.Request, rule rule.Api
|
||||
//line app/vmalert/web.qtpl:607
|
||||
qw422016.N().S(`
|
||||
<th scope="col" class="w-10 text-center" title="How many seconds request took">Duration</th>
|
||||
<th scope="col" class="text-center" title="Time used for rule execution">Executed at</th>
|
||||
<th scope="col" class="text-center" title="The time used in execution query request">Execution timestamp</th>
|
||||
<th scope="col" class="text-center" title="cURL command with request example">cURL</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
@@ -212,7 +212,7 @@ func newSrcFS() (*fslocal.FS, error) {
|
||||
}
|
||||
|
||||
func newDstFS(ctx context.Context) (common.RemoteFS, error) {
|
||||
fs, err := actions.NewRemoteFS(ctx, *dst)
|
||||
fs, err := actions.NewRemoteFS(ctx, *dst, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot parse `-dst`=%q: %w", *dst, err)
|
||||
}
|
||||
@@ -255,7 +255,7 @@ func newOriginFS(ctx context.Context) (common.OriginFS, error) {
|
||||
if len(*origin) == 0 {
|
||||
return &fsnil.FS{}, nil
|
||||
}
|
||||
fs, err := actions.NewRemoteFS(ctx, *origin)
|
||||
fs, err := actions.NewRemoteFS(ctx, *origin, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot parse `-origin`=%q: %w", *origin, err)
|
||||
}
|
||||
@@ -266,7 +266,7 @@ func newRemoteOriginFS(ctx context.Context) (common.RemoteFS, error) {
|
||||
if len(*origin) == 0 {
|
||||
return nil, fmt.Errorf("-origin cannot be empty when -snapshotName and -snapshot.createURL aren't set")
|
||||
}
|
||||
fs, err := actions.NewRemoteFS(ctx, *origin)
|
||||
fs, err := actions.NewRemoteFS(ctx, *origin, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot parse `-origin`=%q: %w", *origin, err)
|
||||
}
|
||||
|
||||
@@ -11,9 +11,11 @@ import (
|
||||
"github.com/VictoriaMetrics/VictoriaMetrics/lib/bytesutil"
|
||||
"github.com/VictoriaMetrics/VictoriaMetrics/lib/httpserver"
|
||||
"github.com/VictoriaMetrics/VictoriaMetrics/lib/prompb"
|
||||
"github.com/VictoriaMetrics/VictoriaMetrics/lib/protoparser/prometheus"
|
||||
"github.com/VictoriaMetrics/VictoriaMetrics/lib/ratelimiter"
|
||||
"github.com/VictoriaMetrics/VictoriaMetrics/lib/slicesutil"
|
||||
"github.com/VictoriaMetrics/VictoriaMetrics/lib/storage"
|
||||
"github.com/VictoriaMetrics/VictoriaMetrics/lib/storage/metricsmetadata"
|
||||
"github.com/VictoriaMetrics/VictoriaMetrics/lib/timeserieslimits"
|
||||
)
|
||||
|
||||
@@ -50,8 +52,9 @@ var (
|
||||
type InsertCtx struct {
|
||||
Labels sortedLabels
|
||||
|
||||
mrs []storage.MetricRow
|
||||
metricNamesBuf []byte
|
||||
mrs []storage.MetricRow
|
||||
mms []metricsmetadata.Row
|
||||
metricNameBuf []byte
|
||||
|
||||
relabelCtx relabel.Ctx
|
||||
streamAggrCtx streamAggrCtx
|
||||
@@ -73,8 +76,13 @@ func (ctx *InsertCtx) Reset(rowsLen int) {
|
||||
}
|
||||
mrs = slicesutil.SetLength(mrs, rowsLen)
|
||||
ctx.mrs = mrs[:0]
|
||||
mms := ctx.mms
|
||||
for i := range mms {
|
||||
cleanMetricMetadata(&mms[i])
|
||||
}
|
||||
ctx.mms = mms[:0]
|
||||
|
||||
ctx.metricNamesBuf = ctx.metricNamesBuf[:0]
|
||||
ctx.metricNameBuf = ctx.metricNameBuf[:0]
|
||||
ctx.relabelCtx.Reset()
|
||||
ctx.streamAggrCtx.Reset()
|
||||
ctx.skipStreamAggr = false
|
||||
@@ -84,11 +92,20 @@ func cleanMetricRow(mr *storage.MetricRow) {
|
||||
mr.MetricNameRaw = nil
|
||||
}
|
||||
|
||||
func cleanMetricMetadata(mm *metricsmetadata.Row) {
|
||||
mm.MetricFamilyName = nil
|
||||
mm.Unit = nil
|
||||
mm.Help = nil
|
||||
mm.Type = 0
|
||||
mm.ProjectID = 0
|
||||
mm.AccountID = 0
|
||||
}
|
||||
|
||||
func (ctx *InsertCtx) marshalMetricNameRaw(prefix []byte, labels []prompb.Label) []byte {
|
||||
start := len(ctx.metricNamesBuf)
|
||||
ctx.metricNamesBuf = append(ctx.metricNamesBuf, prefix...)
|
||||
ctx.metricNamesBuf = storage.MarshalMetricNameRaw(ctx.metricNamesBuf, labels)
|
||||
metricNameRaw := ctx.metricNamesBuf[start:]
|
||||
start := len(ctx.metricNameBuf)
|
||||
ctx.metricNameBuf = append(ctx.metricNameBuf, prefix...)
|
||||
ctx.metricNameBuf = storage.MarshalMetricNameRaw(ctx.metricNameBuf, labels)
|
||||
metricNameRaw := ctx.metricNameBuf[start:]
|
||||
return metricNameRaw[:len(metricNameRaw):len(metricNameRaw)]
|
||||
}
|
||||
|
||||
@@ -143,7 +160,7 @@ func (ctx *InsertCtx) addRow(metricNameRaw []byte, timestamp int64, value float6
|
||||
mr.MetricNameRaw = metricNameRaw
|
||||
mr.Timestamp = timestamp
|
||||
mr.Value = value
|
||||
if len(ctx.metricNamesBuf) > 16*1024*1024 {
|
||||
if len(ctx.metricNameBuf) > 16*1024*1024 {
|
||||
if err := ctx.FlushBufs(); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -151,6 +168,55 @@ func (ctx *InsertCtx) addRow(metricNameRaw []byte, timestamp int64, value float6
|
||||
return nil
|
||||
}
|
||||
|
||||
// WriteMetadata writes given prometheus protobuf metadata into the storage.
|
||||
func (ctx *InsertCtx) WriteMetadata(mmpbs []prompb.MetricMetadata) error {
|
||||
if len(mmpbs) == 0 {
|
||||
return nil
|
||||
}
|
||||
mms := ctx.mms
|
||||
mms = slicesutil.SetLength(mms, len(mmpbs))
|
||||
for idx, mmpb := range mmpbs {
|
||||
mm := &mms[idx]
|
||||
mm.MetricFamilyName = bytesutil.ToUnsafeBytes(mmpb.MetricFamilyName)
|
||||
mm.Help = bytesutil.ToUnsafeBytes(mmpb.Help)
|
||||
mm.Type = mmpb.Type
|
||||
mm.Unit = bytesutil.ToUnsafeBytes(mmpb.Unit)
|
||||
}
|
||||
|
||||
err := vmstorage.AddMetadataRows(mms)
|
||||
if err != nil {
|
||||
return &httpserver.ErrorWithStatusCode{
|
||||
Err: fmt.Errorf("cannot store metrics metadata: %w", err),
|
||||
StatusCode: http.StatusServiceUnavailable,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// WritePromMetadata writes given prometheus metric metadata into the storage
|
||||
func (ctx *InsertCtx) WritePromMetadata(mmps []prometheus.Metadata) error {
|
||||
if len(mmps) == 0 {
|
||||
return nil
|
||||
}
|
||||
mms := ctx.mms
|
||||
mms = slicesutil.SetLength(mms, len(mmps))
|
||||
for idx, mmpb := range mmps {
|
||||
mm := &mms[idx]
|
||||
mm.MetricFamilyName = bytesutil.ToUnsafeBytes(mmpb.Metric)
|
||||
mm.Help = bytesutil.ToUnsafeBytes(mmpb.Help)
|
||||
mm.Type = mmpb.Type
|
||||
}
|
||||
|
||||
err := vmstorage.AddMetadataRows(mms)
|
||||
if err != nil {
|
||||
return &httpserver.ErrorWithStatusCode{
|
||||
Err: fmt.Errorf("cannot store prometheus metrics metadata: %w", err),
|
||||
StatusCode: http.StatusServiceUnavailable,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddLabelBytes adds (name, value) label to ctx.Labels.
|
||||
//
|
||||
// name and value must exist until ctx.Labels is used.
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
"github.com/VictoriaMetrics/VictoriaMetrics/app/vminsert/common"
|
||||
"github.com/VictoriaMetrics/VictoriaMetrics/app/vminsert/relabel"
|
||||
"github.com/VictoriaMetrics/VictoriaMetrics/lib/prommetadata"
|
||||
"github.com/VictoriaMetrics/VictoriaMetrics/lib/prompb"
|
||||
"github.com/VictoriaMetrics/VictoriaMetrics/lib/protoparser/opentelemetry/firehose"
|
||||
"github.com/VictoriaMetrics/VictoriaMetrics/lib/protoparser/opentelemetry/stream"
|
||||
@@ -14,8 +15,9 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
rowsInserted = metrics.NewCounter(`vm_rows_inserted_total{type="opentelemetry"}`)
|
||||
rowsPerInsert = metrics.NewHistogram(`vm_rows_per_insert{type="opentelemetry"}`)
|
||||
rowsInserted = metrics.NewCounter(`vm_rows_inserted_total{type="opentelemetry"}`)
|
||||
rowsPerInsert = metrics.NewHistogram(`vm_rows_per_insert{type="opentelemetry"}`)
|
||||
metadataInserted = metrics.NewCounter(`vm_metadata_rows_inserted_total{type="opentelemetry"}`)
|
||||
)
|
||||
|
||||
// InsertHandler processes opentelemetry metrics.
|
||||
@@ -33,12 +35,12 @@ func InsertHandler(req *http.Request) error {
|
||||
return fmt.Errorf("json encoding isn't supported for opentelemetry format. Use protobuf encoding")
|
||||
}
|
||||
}
|
||||
return stream.ParseStream(req.Body, encoding, processBody, func(tss []prompb.TimeSeries, _ []prompb.MetricMetadata) error {
|
||||
return insertRows(tss, extraLabels)
|
||||
return stream.ParseStream(req.Body, encoding, processBody, func(tss []prompb.TimeSeries, mms []prompb.MetricMetadata) error {
|
||||
return insertRows(tss, mms, extraLabels)
|
||||
})
|
||||
}
|
||||
|
||||
func insertRows(tss []prompb.TimeSeries, extraLabels []prompb.Label) error {
|
||||
func insertRows(tss []prompb.TimeSeries, mms []prompb.MetricMetadata, extraLabels []prompb.Label) error {
|
||||
ctx := common.GetInsertCtx()
|
||||
defer common.PutInsertCtx(ctx)
|
||||
|
||||
@@ -75,5 +77,14 @@ func insertRows(tss []prompb.TimeSeries, extraLabels []prompb.Label) error {
|
||||
}
|
||||
rowsInserted.Add(rowsTotal)
|
||||
rowsPerInsert.Update(float64(rowsTotal))
|
||||
return ctx.FlushBufs()
|
||||
if err := ctx.FlushBufs(); err != nil {
|
||||
return fmt.Errorf("cannot flush metric bufs: %w", err)
|
||||
}
|
||||
if prommetadata.IsEnabled() {
|
||||
if err := ctx.WriteMetadata(mms); err != nil {
|
||||
return err
|
||||
}
|
||||
metadataInserted.Add(len(mms))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package prometheusimport
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/VictoriaMetrics/VictoriaMetrics/app/vminsert/common"
|
||||
@@ -15,8 +16,9 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
rowsInserted = metrics.NewCounter(`vm_rows_inserted_total{type="prometheus"}`)
|
||||
rowsPerInsert = metrics.NewHistogram(`vm_rows_per_insert{type="prometheus"}`)
|
||||
rowsInserted = metrics.NewCounter(`vm_rows_inserted_total{type="prometheus"}`)
|
||||
rowsPerInsert = metrics.NewHistogram(`vm_rows_per_insert{type="prometheus"}`)
|
||||
metadataInserted = metrics.NewCounter(`vm_metadata_rows_inserted_total{type="prometheus"}`)
|
||||
)
|
||||
|
||||
// InsertHandler processes `/api/v1/import/prometheus` request.
|
||||
@@ -30,14 +32,14 @@ func InsertHandler(req *http.Request) error {
|
||||
return err
|
||||
}
|
||||
encoding := req.Header.Get("Content-Encoding")
|
||||
return stream.Parse(req.Body, defaultTimestamp, encoding, true, prommetadata.IsEnabled(), func(rows []prometheus.Row, _ []prometheus.Metadata) error {
|
||||
return insertRows(rows, extraLabels)
|
||||
return stream.Parse(req.Body, defaultTimestamp, encoding, true, prommetadata.IsEnabled(), func(rows []prometheus.Row, mms []prometheus.Metadata) error {
|
||||
return insertRows(rows, mms, extraLabels)
|
||||
}, func(s string) {
|
||||
httpserver.LogError(req, s)
|
||||
})
|
||||
}
|
||||
|
||||
func insertRows(rows []prometheus.Row, extraLabels []prompb.Label) error {
|
||||
func insertRows(rows []prometheus.Row, mms []prometheus.Metadata, extraLabels []prompb.Label) error {
|
||||
ctx := common.GetInsertCtx()
|
||||
defer common.PutInsertCtx(ctx)
|
||||
|
||||
@@ -64,5 +66,15 @@ func insertRows(rows []prometheus.Row, extraLabels []prompb.Label) error {
|
||||
}
|
||||
rowsInserted.Add(len(rows))
|
||||
rowsPerInsert.Update(float64(len(rows)))
|
||||
return ctx.FlushBufs()
|
||||
if err := ctx.FlushBufs(); err != nil {
|
||||
return fmt.Errorf("cannot flush metric bufs: %w", err)
|
||||
}
|
||||
|
||||
if prommetadata.IsEnabled() {
|
||||
if err := ctx.WritePromMetadata(mms); err != nil {
|
||||
return err
|
||||
}
|
||||
metadataInserted.Add(len(mms))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package promremotewrite
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/VictoriaMetrics/VictoriaMetrics/app/vminsert/common"
|
||||
"github.com/VictoriaMetrics/VictoriaMetrics/app/vminsert/relabel"
|
||||
"github.com/VictoriaMetrics/VictoriaMetrics/lib/prommetadata"
|
||||
"github.com/VictoriaMetrics/VictoriaMetrics/lib/prompb"
|
||||
"github.com/VictoriaMetrics/VictoriaMetrics/lib/protoparser/promremotewrite/stream"
|
||||
"github.com/VictoriaMetrics/VictoriaMetrics/lib/protoparser/protoparserutil"
|
||||
@@ -12,8 +14,9 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
rowsInserted = metrics.NewCounter(`vm_rows_inserted_total{type="promremotewrite"}`)
|
||||
rowsPerInsert = metrics.NewHistogram(`vm_rows_per_insert{type="promremotewrite"}`)
|
||||
rowsInserted = metrics.NewCounter(`vm_rows_inserted_total{type="promremotewrite"}`)
|
||||
rowsPerInsert = metrics.NewHistogram(`vm_rows_per_insert{type="promremotewrite"}`)
|
||||
metadataInserted = metrics.NewCounter(`vm_metadata_rows_inserted_total{type="promremotewrite"}`)
|
||||
)
|
||||
|
||||
// InsertHandler processes remote write for prometheus.
|
||||
@@ -23,12 +26,12 @@ func InsertHandler(req *http.Request) error {
|
||||
return err
|
||||
}
|
||||
isVMRemoteWrite := req.Header.Get("Content-Encoding") == "zstd"
|
||||
return stream.Parse(req.Body, isVMRemoteWrite, func(tss []prompb.TimeSeries, _ []prompb.MetricMetadata) error {
|
||||
return insertRows(tss, extraLabels)
|
||||
return stream.Parse(req.Body, isVMRemoteWrite, func(tss []prompb.TimeSeries, mms []prompb.MetricMetadata) error {
|
||||
return insertRows(tss, mms, extraLabels)
|
||||
})
|
||||
}
|
||||
|
||||
func insertRows(timeseries []prompb.TimeSeries, extraLabels []prompb.Label) error {
|
||||
func insertRows(timeseries []prompb.TimeSeries, mms []prompb.MetricMetadata, extraLabels []prompb.Label) error {
|
||||
ctx := common.GetInsertCtx()
|
||||
defer common.PutInsertCtx(ctx)
|
||||
|
||||
@@ -68,5 +71,15 @@ func insertRows(timeseries []prompb.TimeSeries, extraLabels []prompb.Label) erro
|
||||
}
|
||||
rowsInserted.Add(rowsTotal)
|
||||
rowsPerInsert.Update(float64(rowsTotal))
|
||||
return ctx.FlushBufs()
|
||||
|
||||
if err := ctx.FlushBufs(); err != nil {
|
||||
return fmt.Errorf("cannot flush metric bufs: %w", err)
|
||||
}
|
||||
if prommetadata.IsEnabled() {
|
||||
if err := ctx.WriteMetadata(mms); err != nil {
|
||||
return err
|
||||
}
|
||||
metadataInserted.Add(len(mms))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -104,7 +104,7 @@ func newDstFS() (*fslocal.FS, error) {
|
||||
}
|
||||
|
||||
func newSrcFS(ctx context.Context) (common.RemoteFS, error) {
|
||||
fs, err := actions.NewRemoteFS(ctx, *src)
|
||||
fs, err := actions.NewRemoteFS(ctx, *src, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot parse `-src`=%q: %w", *src, err)
|
||||
}
|
||||
|
||||
@@ -421,6 +421,16 @@ func RequestHandler(w http.ResponseWriter, r *http.Request) bool {
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return true
|
||||
case "/api/v1/metadata":
|
||||
// Return dumb placeholder for https://prometheus.io/docs/prometheus/latest/querying/api/#querying-metric-metadata
|
||||
metadataRequests.Inc()
|
||||
if err := prometheus.MetadataHandler(qt, startTime, w, r); err != nil {
|
||||
metadataErrors.Inc()
|
||||
httpserver.SendPrometheusError(w, r, err)
|
||||
return true
|
||||
}
|
||||
return true
|
||||
|
||||
default:
|
||||
return false
|
||||
}
|
||||
@@ -574,12 +584,6 @@ func handleStaticAndSimpleRequests(w http.ResponseWriter, r *http.Request, path
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"status":"success","data":{"notifiers":[]}}`)
|
||||
return true
|
||||
case "/api/v1/metadata":
|
||||
// Return dumb placeholder for https://prometheus.io/docs/prometheus/latest/querying/api/#querying-metric-metadata
|
||||
metadataRequests.Inc()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprintf(w, "%s", `{"status":"success","data":{}}`)
|
||||
return true
|
||||
case "/api/v1/status/buildinfo":
|
||||
buildInfoRequests.Inc()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@@ -708,7 +712,9 @@ var (
|
||||
alertsRequests = metrics.NewCounter(`vm_http_requests_total{path="/api/v1/alerts"}`)
|
||||
notifiersRequests = metrics.NewCounter(`vm_http_requests_total{path="/api/v1/notifiers"}`)
|
||||
|
||||
metadataRequests = metrics.NewCounter(`vm_http_requests_total{path="/api/v1/metadata"}`)
|
||||
metadataRequests = metrics.NewCounter(`vm_http_requests_total{path="/api/v1/metadata"}`)
|
||||
metadataErrors = metrics.NewCounter(`vm_http_request_errors_total{path="/api/v1/metadata"}`)
|
||||
|
||||
buildInfoRequests = metrics.NewCounter(`vm_http_requests_total{path="/api/v1/buildinfo"}`)
|
||||
queryExemplarsRequests = metrics.NewCounter(`vm_http_requests_total{path="/api/v1/query_exemplars"}`)
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"github.com/VictoriaMetrics/VictoriaMetrics/lib/fasttime"
|
||||
"github.com/VictoriaMetrics/VictoriaMetrics/lib/querytracer"
|
||||
"github.com/VictoriaMetrics/VictoriaMetrics/lib/storage"
|
||||
"github.com/VictoriaMetrics/VictoriaMetrics/lib/storage/metricsmetadata"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -865,6 +866,23 @@ func LabelValues(qt *querytracer.Tracer, labelName string, sq *storage.SearchQue
|
||||
return labelValues, nil
|
||||
}
|
||||
|
||||
// GetMetricsMetadata returns time series metric names metadata for the given args
|
||||
func GetMetricsMetadata(qt *querytracer.Tracer, limit int, metricName string) ([]*metricsmetadata.Row, error) {
|
||||
qt = qt.NewChild("get metrics metadata: limit=%d, metric_name=%q", limit, metricName)
|
||||
defer qt.Done()
|
||||
|
||||
metadata := vmstorage.Storage.GetMetadataRows(qt, limit, metricName)
|
||||
|
||||
sort.Slice(metadata, func(i, j int) bool {
|
||||
return string(metadata[i].MetricFamilyName) < string(metadata[j].MetricFamilyName)
|
||||
})
|
||||
if limit > 0 && len(metadata) >= limit {
|
||||
metadata = metadata[:limit]
|
||||
}
|
||||
|
||||
return metadata, nil
|
||||
}
|
||||
|
||||
// GraphiteTagValues returns tag values for the given tagName until the given deadline.
|
||||
func GraphiteTagValues(qt *querytracer.Tracer, tagName, filter string, limit int, deadline searchutil.Deadline) ([]string, error) {
|
||||
qt = qt.NewChild("get graphite tag values for tagName=%s, filter=%s, limit=%d", tagName, filter, limit)
|
||||
|
||||
36
app/vmselect/prometheus/metadata_response.qtpl
Normal file
36
app/vmselect/prometheus/metadata_response.qtpl
Normal file
@@ -0,0 +1,36 @@
|
||||
{% import (
|
||||
"github.com/VictoriaMetrics/VictoriaMetrics/lib/storage/metricsmetadata"
|
||||
"github.com/VictoriaMetrics/VictoriaMetrics/lib/prompb"
|
||||
"github.com/VictoriaMetrics/VictoriaMetrics/lib/querytracer"
|
||||
) %}
|
||||
|
||||
{% stripspace %}
|
||||
MetadataResponse generates response for /api/v1/metadata
|
||||
See https://prometheus.io/docs/prometheus/latest/querying/api/#querying-metric-metadata
|
||||
{% func MetadataResponse( result []*metricsmetadata.Row, qt *querytracer.Tracer) %}
|
||||
{
|
||||
"status":"success",
|
||||
"data": {
|
||||
{% code
|
||||
mapItems := len(result)
|
||||
currentItem := 0
|
||||
%}
|
||||
{% for _, row := range result %}
|
||||
"{%s string(row.MetricFamilyName) %}": [
|
||||
{
|
||||
"type": {%q= prompb.MetricMetadataTypeToString(row.Type) %},
|
||||
{% if len(row.Unit) > 0 -%}
|
||||
"unit": {%q= string(row.Unit) %},
|
||||
{% endif -%}
|
||||
"help": {%q= string(row.Help) %}
|
||||
}
|
||||
]
|
||||
{% if currentItem != mapItems-1 %},{% endif %}
|
||||
{% code currentItem++ %}
|
||||
{% endfor %}
|
||||
}
|
||||
{%= dumpQueryTrace(qt) %}
|
||||
}
|
||||
{% endfunc %}
|
||||
|
||||
{% endstripspace %}
|
||||
109
app/vmselect/prometheus/metadata_response.qtpl.go
Normal file
109
app/vmselect/prometheus/metadata_response.qtpl.go
Normal file
@@ -0,0 +1,109 @@
|
||||
// Code generated by qtc from "metadata_response.qtpl". DO NOT EDIT.
|
||||
// See https://github.com/valyala/quicktemplate for details.
|
||||
|
||||
//line app/vmselect/prometheus/metadata_response.qtpl:1
|
||||
package prometheus
|
||||
|
||||
//line app/vmselect/prometheus/metadata_response.qtpl:1
|
||||
import (
|
||||
"github.com/VictoriaMetrics/VictoriaMetrics/lib/prompb"
|
||||
"github.com/VictoriaMetrics/VictoriaMetrics/lib/querytracer"
|
||||
"github.com/VictoriaMetrics/VictoriaMetrics/lib/storage/metricsmetadata"
|
||||
)
|
||||
|
||||
// MetadataResponse generates response for /api/v1/metadataSee https://prometheus.io/docs/prometheus/latest/querying/api/#querying-metric-metadata
|
||||
|
||||
//line app/vmselect/prometheus/metadata_response.qtpl:10
|
||||
import (
|
||||
qtio422016 "io"
|
||||
|
||||
qt422016 "github.com/valyala/quicktemplate"
|
||||
)
|
||||
|
||||
//line app/vmselect/prometheus/metadata_response.qtpl:10
|
||||
var (
|
||||
_ = qtio422016.Copy
|
||||
_ = qt422016.AcquireByteBuffer
|
||||
)
|
||||
|
||||
//line app/vmselect/prometheus/metadata_response.qtpl:10
|
||||
func StreamMetadataResponse(qw422016 *qt422016.Writer, result []*metricsmetadata.Row, qt *querytracer.Tracer) {
|
||||
//line app/vmselect/prometheus/metadata_response.qtpl:10
|
||||
qw422016.N().S(`{"status":"success","data": {`)
|
||||
//line app/vmselect/prometheus/metadata_response.qtpl:15
|
||||
mapItems := len(result)
|
||||
currentItem := 0
|
||||
|
||||
//line app/vmselect/prometheus/metadata_response.qtpl:18
|
||||
for _, row := range result {
|
||||
//line app/vmselect/prometheus/metadata_response.qtpl:18
|
||||
qw422016.N().S(`"`)
|
||||
//line app/vmselect/prometheus/metadata_response.qtpl:19
|
||||
qw422016.E().S(string(row.MetricFamilyName))
|
||||
//line app/vmselect/prometheus/metadata_response.qtpl:19
|
||||
qw422016.N().S(`": [{"type":`)
|
||||
//line app/vmselect/prometheus/metadata_response.qtpl:21
|
||||
qw422016.N().Q(prompb.MetricMetadataTypeToString(row.Type))
|
||||
//line app/vmselect/prometheus/metadata_response.qtpl:21
|
||||
qw422016.N().S(`,`)
|
||||
//line app/vmselect/prometheus/metadata_response.qtpl:22
|
||||
if len(row.Unit) > 0 {
|
||||
//line app/vmselect/prometheus/metadata_response.qtpl:22
|
||||
qw422016.N().S(`"unit":`)
|
||||
//line app/vmselect/prometheus/metadata_response.qtpl:23
|
||||
qw422016.N().Q(string(row.Unit))
|
||||
//line app/vmselect/prometheus/metadata_response.qtpl:23
|
||||
qw422016.N().S(`,`)
|
||||
//line app/vmselect/prometheus/metadata_response.qtpl:24
|
||||
}
|
||||
//line app/vmselect/prometheus/metadata_response.qtpl:24
|
||||
qw422016.N().S(`"help":`)
|
||||
//line app/vmselect/prometheus/metadata_response.qtpl:25
|
||||
qw422016.N().Q(string(row.Help))
|
||||
//line app/vmselect/prometheus/metadata_response.qtpl:25
|
||||
qw422016.N().S(`}]`)
|
||||
//line app/vmselect/prometheus/metadata_response.qtpl:28
|
||||
if currentItem != mapItems-1 {
|
||||
//line app/vmselect/prometheus/metadata_response.qtpl:28
|
||||
qw422016.N().S(`,`)
|
||||
//line app/vmselect/prometheus/metadata_response.qtpl:28
|
||||
}
|
||||
//line app/vmselect/prometheus/metadata_response.qtpl:29
|
||||
currentItem++
|
||||
|
||||
//line app/vmselect/prometheus/metadata_response.qtpl:30
|
||||
}
|
||||
//line app/vmselect/prometheus/metadata_response.qtpl:30
|
||||
qw422016.N().S(`}`)
|
||||
//line app/vmselect/prometheus/metadata_response.qtpl:32
|
||||
streamdumpQueryTrace(qw422016, qt)
|
||||
//line app/vmselect/prometheus/metadata_response.qtpl:32
|
||||
qw422016.N().S(`}`)
|
||||
//line app/vmselect/prometheus/metadata_response.qtpl:34
|
||||
}
|
||||
|
||||
//line app/vmselect/prometheus/metadata_response.qtpl:34
|
||||
func WriteMetadataResponse(qq422016 qtio422016.Writer, result []*metricsmetadata.Row, qt *querytracer.Tracer) {
|
||||
//line app/vmselect/prometheus/metadata_response.qtpl:34
|
||||
qw422016 := qt422016.AcquireWriter(qq422016)
|
||||
//line app/vmselect/prometheus/metadata_response.qtpl:34
|
||||
StreamMetadataResponse(qw422016, result, qt)
|
||||
//line app/vmselect/prometheus/metadata_response.qtpl:34
|
||||
qt422016.ReleaseWriter(qw422016)
|
||||
//line app/vmselect/prometheus/metadata_response.qtpl:34
|
||||
}
|
||||
|
||||
//line app/vmselect/prometheus/metadata_response.qtpl:34
|
||||
func MetadataResponse(result []*metricsmetadata.Row, qt *querytracer.Tracer) string {
|
||||
//line app/vmselect/prometheus/metadata_response.qtpl:34
|
||||
qb422016 := qt422016.AcquireByteBuffer()
|
||||
//line app/vmselect/prometheus/metadata_response.qtpl:34
|
||||
WriteMetadataResponse(qb422016, result, qt)
|
||||
//line app/vmselect/prometheus/metadata_response.qtpl:34
|
||||
qs422016 := string(qb422016.B)
|
||||
//line app/vmselect/prometheus/metadata_response.qtpl:34
|
||||
qt422016.ReleaseByteBuffer(qb422016)
|
||||
//line app/vmselect/prometheus/metadata_response.qtpl:34
|
||||
return qs422016
|
||||
//line app/vmselect/prometheus/metadata_response.qtpl:34
|
||||
}
|
||||
@@ -56,7 +56,7 @@ var (
|
||||
maxTSDBStatusSeries = flag.Int("search.maxTSDBStatusSeries", 10e6, "The maximum number of time series, which can be processed during the call to /api/v1/status/tsdb. This option allows limiting memory usage")
|
||||
maxSeriesLimit = flag.Int("search.maxSeries", 30e3, "The maximum number of time series, which can be returned from /api/v1/series. This option allows limiting memory usage")
|
||||
maxDeleteSeries = flag.Int("search.maxDeleteSeries", 1e6, "The maximum number of time series, which can be deleted using /api/v1/admin/tsdb/delete_series. This option allows limiting memory usage")
|
||||
maxTSDBStatusTopNSeries = flag.Int("search.maxTSDBStatusTopNSeries", 1000, "The maximum value of `topN` argument that can be passed to /api/v1/status/tsdb API. This option allows limiting memory usage. See https://docs.victoriametrics.com/victoriametrics/single-server-victoriametrics/#tsdb-stats")
|
||||
maxTSDBStatusTopNSeries = flag.Int("search.maxTSDBStatusTopNSeries", 1000, "The maximum value of 'topN' argument that can be passed to /api/v1/status/tsdb API. This option allows limiting memory usage. See https://docs.victoriametrics.com/victoriametrics/single-server-victoriametrics/#tsdb-stats")
|
||||
maxLabelsAPISeries = flag.Int("search.maxLabelsAPISeries", 1e6, "The maximum number of time series, which could be scanned when searching for the matching time series "+
|
||||
"at /api/v1/labels and /api/v1/label/.../values. This option allows limiting memory usage and CPU usage. See also -search.maxLabelsAPIDuration, "+
|
||||
"-search.maxTagKeys, -search.maxTagValues and -search.ignoreExtraFiltersAtLabelsAPI")
|
||||
@@ -639,6 +639,37 @@ func LabelsHandler(qt *querytracer.Tracer, startTime time.Time, w http.ResponseW
|
||||
return nil
|
||||
}
|
||||
|
||||
// MetadataHandler processes /api/v1/metadata request.
|
||||
//
|
||||
// See https://prometheus.io/docs/prometheus/latest/querying/api/#querying-metric-metadata
|
||||
func MetadataHandler(qt *querytracer.Tracer, startTime time.Time, w http.ResponseWriter, r *http.Request) error {
|
||||
|
||||
limit, err := httputil.GetInt(r, "limit")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if limit < 0 {
|
||||
limit = 0
|
||||
}
|
||||
|
||||
metricName := r.FormValue("metric")
|
||||
|
||||
metadata, err := netstorage.GetMetricsMetadata(qt, limit, metricName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot get metadata: %w", err)
|
||||
}
|
||||
qt.Done()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
bw := bufferedwriter.Get(w)
|
||||
defer bufferedwriter.Put(bw)
|
||||
WriteMetadataResponse(bw, metadata, qt)
|
||||
if err := bw.Flush(); err != nil {
|
||||
return fmt.Errorf("cannot send metadata response to remote client: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var labelsDuration = metrics.NewSummary(`vm_request_duration_seconds{path="/api/v1/labels"}`)
|
||||
|
||||
// SeriesCountHandler processes /api/v1/series/count request.
|
||||
|
||||
@@ -49,7 +49,7 @@ var (
|
||||
minWindowForInstantRollupOptimization = flag.Duration("search.minWindowForInstantRollupOptimization", time.Hour*3, "Enable cache-based optimization for repeated queries "+
|
||||
"to /api/v1/query (aka instant queries), which contain rollup functions with lookbehind window exceeding the given value")
|
||||
maxBinaryOpPushdownLabelValues = flag.Int("search.maxBinaryOpPushdownLabelValues", 100, "The maximum number of values for a label in the first expression that can be extracted as a common label filter and pushed down to the second expression in a binary operation. "+
|
||||
"A larger value makes the pushed-down filter more complex but fewer time series will be returned. This flag is useful when selective label contains numerous values, for example `instance`, and storage resources are abundant.")
|
||||
"A larger value makes the pushed-down filter more complex but fewer time series will be returned. This flag is useful when selective label (e.g., 'instance') contains numerous values, and storage resources are abundant.")
|
||||
)
|
||||
|
||||
// The minimum number of points per timeseries for enabling time rounding.
|
||||
|
||||
@@ -183,24 +183,12 @@ func InitRollupResultCache(cachePath string) {
|
||||
|
||||
// StopRollupResultCache closes the rollupResult cache.
|
||||
func StopRollupResultCache() {
|
||||
if len(rollupResultCachePath) == 0 {
|
||||
rollupResultCacheV.c.Stop()
|
||||
rollupResultCacheV.c = nil
|
||||
return
|
||||
if rollupResultCachePath != "" {
|
||||
rollupResultCacheV.c.MustSave(rollupResultCachePath)
|
||||
mustSaveRollupResultCacheKeyPrefix(rollupResultCachePath)
|
||||
}
|
||||
logger.Infof("saving rollupResult cache to %q...", rollupResultCachePath)
|
||||
startTime := time.Now()
|
||||
if err := rollupResultCacheV.c.Save(rollupResultCachePath); err != nil {
|
||||
logger.Errorf("cannot save rollupResult cache at %q: %s", rollupResultCachePath, err)
|
||||
return
|
||||
}
|
||||
mustSaveRollupResultCacheKeyPrefix(rollupResultCachePath)
|
||||
var fcs fastcache.Stats
|
||||
rollupResultCacheV.c.UpdateStats(&fcs)
|
||||
rollupResultCacheV.c.Stop()
|
||||
rollupResultCacheV.c = nil
|
||||
logger.Infof("saved rollupResult cache to %q in %.3f seconds; entriesCount: %d, sizeBytes: %d",
|
||||
rollupResultCachePath, time.Since(startTime).Seconds(), fcs.EntriesCount, fcs.BytesSize)
|
||||
}
|
||||
|
||||
type rollupResultCache struct {
|
||||
|
||||
209
app/vmselect/vmui/assets/index-C4E6lDpP.js
Normal file
209
app/vmselect/vmui/assets/index-C4E6lDpP.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
app/vmselect/vmui/assets/index-DACH7WjD.css
Normal file
1
app/vmselect/vmui/assets/index-DACH7WjD.css
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
80
app/vmselect/vmui/assets/vendor-D5YL0cqB.js
Normal file
80
app/vmselect/vmui/assets/vendor-D5YL0cqB.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -37,10 +37,10 @@
|
||||
<meta property="og:title" content="UI for VictoriaMetrics">
|
||||
<meta property="og:url" content="https://victoriametrics.com/">
|
||||
<meta property="og:description" content="Explore and troubleshoot your VictoriaMetrics data">
|
||||
<script type="module" crossorigin src="./assets/index-zpalCSif.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="./assets/vendor-DY9kCvzk.js">
|
||||
<script type="module" crossorigin src="./assets/index-C4E6lDpP.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="./assets/vendor-D5YL0cqB.js">
|
||||
<link rel="stylesheet" crossorigin href="./assets/vendor-D1GxaB_c.css">
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-CBxdwuZH.css">
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-DACH7WjD.css">
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"github.com/VictoriaMetrics/VictoriaMetrics/lib/mergeset"
|
||||
"github.com/VictoriaMetrics/VictoriaMetrics/lib/querytracer"
|
||||
"github.com/VictoriaMetrics/VictoriaMetrics/lib/storage"
|
||||
"github.com/VictoriaMetrics/VictoriaMetrics/lib/storage/metricsmetadata"
|
||||
"github.com/VictoriaMetrics/VictoriaMetrics/lib/stringsutil"
|
||||
"github.com/VictoriaMetrics/VictoriaMetrics/lib/syncwg"
|
||||
"github.com/VictoriaMetrics/VictoriaMetrics/lib/timeutil"
|
||||
@@ -90,6 +91,9 @@ var (
|
||||
"In most cases, this value should not be changed. The maximum allowed value is 23h.")
|
||||
|
||||
logNewSeriesAuthKey = flagutil.NewPassword("logNewSeriesAuthKey", "authKey, which must be passed in query string to /internal/log_new_series. It overrides -httpAuth.*")
|
||||
|
||||
metadataStorageSize = flagutil.NewBytes("storage.maxMetadataStorageSize", 0, "Overrides max size for metrics metadata entries in-memory storage. "+
|
||||
"If set to 0 or a negative value, defaults to 1% of allowed memory.")
|
||||
)
|
||||
|
||||
// CheckTimeRange returns true if the given tr is denied for querying.
|
||||
@@ -120,6 +124,7 @@ func Init(resetCacheIfNeeded func(mrs []storage.MetricRow)) {
|
||||
storage.SetTagFiltersCacheSize(cacheSizeIndexDBTagFilters.IntN())
|
||||
storage.SetMetricNamesStatsCacheSize(cacheSizeMetricNamesStats.IntN())
|
||||
storage.SetMetricNameCacheSize(cacheSizeStorageMetricName.IntN())
|
||||
storage.SetMetadataStorageSize(metadataStorageSize.IntN())
|
||||
mergeset.SetIndexBlocksCacheSize(cacheSizeIndexDBIndexBlocks.IntN())
|
||||
mergeset.SetDataBlocksCacheSize(cacheSizeIndexDBDataBlocks.IntN())
|
||||
mergeset.SetDataBlocksSparseCacheSize(cacheSizeIndexDBDataBlocksSparse.IntN())
|
||||
@@ -194,6 +199,19 @@ func AddRows(mrs []storage.MetricRow) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddMetadataRows adds mrs to the storage.
|
||||
//
|
||||
// The caller should limit the number of concurrent calls to AddMetadataRows() in order to limit memory usage.
|
||||
func AddMetadataRows(mms []metricsmetadata.Row) error {
|
||||
if Storage.IsReadOnly() {
|
||||
return errReadOnly
|
||||
}
|
||||
WG.Add(1)
|
||||
Storage.AddMetadataRows(mms)
|
||||
WG.Done()
|
||||
return nil
|
||||
}
|
||||
|
||||
var errReadOnly = errors.New("the storage is in read-only mode; check -storage.minFreeDiskSpaceBytes command-line flag value")
|
||||
|
||||
// RegisterMetricNames registers all the metrics from mrs in the storage.
|
||||
@@ -610,13 +628,13 @@ func writeStorageMetrics(w io.Writer, strg *storage.Storage) {
|
||||
|
||||
metrics.WriteCounterUint64(w, `vm_missing_metric_names_for_metric_id_total`, idbm.MissingMetricNamesForMetricID)
|
||||
|
||||
metrics.WriteCounterUint64(w, `vm_date_metric_id_cache_syncs_total`, m.DateMetricIDCacheSyncsCount)
|
||||
metrics.WriteCounterUint64(w, `vm_date_metric_id_cache_resets_total`, m.DateMetricIDCacheResetsCount)
|
||||
metrics.WriteCounterUint64(w, `vm_date_metric_id_cache_syncs_total`, idbm.DateMetricIDCacheSyncsCount)
|
||||
metrics.WriteCounterUint64(w, `vm_date_metric_id_cache_resets_total`, idbm.DateMetricIDCacheResetsCount)
|
||||
|
||||
metrics.WriteGaugeUint64(w, `vm_cache_entries{type="storage/tsid"}`, m.TSIDCacheSize)
|
||||
metrics.WriteGaugeUint64(w, `vm_cache_entries{type="storage/metricIDs"}`, m.MetricIDCacheSize)
|
||||
metrics.WriteGaugeUint64(w, `vm_cache_entries{type="storage/metricName"}`, m.MetricNameCacheSize)
|
||||
metrics.WriteGaugeUint64(w, `vm_cache_entries{type="storage/date_metricID"}`, m.DateMetricIDCacheSize)
|
||||
metrics.WriteGaugeUint64(w, `vm_cache_entries{type="storage/date_metricID"}`, idbm.DateMetricIDCacheSize)
|
||||
metrics.WriteGaugeUint64(w, `vm_cache_entries{type="storage/hour_metric_ids"}`, m.HourMetricIDCacheSize)
|
||||
metrics.WriteGaugeUint64(w, `vm_cache_entries{type="storage/next_day_metric_ids"}`, m.NextDayMetricIDCacheSize)
|
||||
metrics.WriteGaugeUint64(w, `vm_cache_entries{type="storage/indexBlocks"}`, tm.IndexBlocksCacheSize)
|
||||
@@ -634,12 +652,12 @@ func writeStorageMetrics(w io.Writer, strg *storage.Storage) {
|
||||
metrics.WriteGaugeUint64(w, `vm_cache_size_bytes{type="indexdb/dataBlocks"}`, idbm.DataBlocksCacheSizeBytes)
|
||||
metrics.WriteGaugeUint64(w, `vm_cache_size_bytes{type="indexdb/dataBlocksSparse"}`, idbm.DataBlocksSparseCacheSizeBytes)
|
||||
metrics.WriteGaugeUint64(w, `vm_cache_size_bytes{type="indexdb/indexBlocks"}`, idbm.IndexBlocksCacheSizeBytes)
|
||||
metrics.WriteGaugeUint64(w, `vm_cache_size_bytes{type="storage/date_metricID"}`, m.DateMetricIDCacheSizeBytes)
|
||||
metrics.WriteGaugeUint64(w, `vm_cache_size_bytes{type="storage/date_metricID"}`, idbm.DateMetricIDCacheSizeBytes)
|
||||
metrics.WriteGaugeUint64(w, `vm_cache_size_bytes{type="storage/hour_metric_ids"}`, m.HourMetricIDCacheSizeBytes)
|
||||
metrics.WriteGaugeUint64(w, `vm_cache_size_bytes{type="storage/next_day_metric_ids"}`, m.NextDayMetricIDCacheSizeBytes)
|
||||
metrics.WriteGaugeUint64(w, `vm_cache_size_bytes{type="indexdb/tagFiltersToMetricIDs"}`, idbm.TagFiltersToMetricIDsCacheSizeBytes)
|
||||
metrics.WriteGaugeUint64(w, `vm_cache_size_bytes{type="storage/regexps"}`, uint64(storage.RegexpCacheSizeBytes()))
|
||||
metrics.WriteGaugeUint64(w, `vm_cache_size_bytes{type="storage/regexpPrefixes"}`, uint64(storage.RegexpPrefixesCacheSizeBytes()))
|
||||
metrics.WriteGaugeUint64(w, `vm_cache_size_bytes{type="storage/regexps"}`, storage.RegexpCacheSizeBytes())
|
||||
metrics.WriteGaugeUint64(w, `vm_cache_size_bytes{type="storage/regexpPrefixes"}`, storage.RegexpPrefixesCacheSizeBytes())
|
||||
|
||||
metrics.WriteGaugeUint64(w, `vm_cache_size_max_bytes{type="storage/tsid"}`, m.TSIDCacheSizeMaxBytes)
|
||||
metrics.WriteGaugeUint64(w, `vm_cache_size_max_bytes{type="storage/metricIDs"}`, m.MetricIDCacheSizeMaxBytes)
|
||||
@@ -649,8 +667,8 @@ func writeStorageMetrics(w io.Writer, strg *storage.Storage) {
|
||||
metrics.WriteGaugeUint64(w, `vm_cache_size_max_bytes{type="indexdb/dataBlocksSparse"}`, idbm.DataBlocksSparseCacheSizeMaxBytes)
|
||||
metrics.WriteGaugeUint64(w, `vm_cache_size_max_bytes{type="indexdb/indexBlocks"}`, idbm.IndexBlocksCacheSizeMaxBytes)
|
||||
metrics.WriteGaugeUint64(w, `vm_cache_size_max_bytes{type="indexdb/tagFiltersToMetricIDs"}`, idbm.TagFiltersToMetricIDsCacheSizeMaxBytes)
|
||||
metrics.WriteGaugeUint64(w, `vm_cache_size_max_bytes{type="storage/regexps"}`, uint64(storage.RegexpCacheMaxSizeBytes()))
|
||||
metrics.WriteGaugeUint64(w, `vm_cache_size_max_bytes{type="storage/regexpPrefixes"}`, uint64(storage.RegexpPrefixesCacheMaxSizeBytes()))
|
||||
metrics.WriteGaugeUint64(w, `vm_cache_size_max_bytes{type="storage/regexps"}`, storage.RegexpCacheMaxSizeBytes())
|
||||
metrics.WriteGaugeUint64(w, `vm_cache_size_max_bytes{type="storage/regexpPrefixes"}`, storage.RegexpPrefixesCacheMaxSizeBytes())
|
||||
|
||||
metrics.WriteCounterUint64(w, `vm_cache_requests_total{type="storage/tsid"}`, m.TSIDCacheRequests)
|
||||
metrics.WriteCounterUint64(w, `vm_cache_requests_total{type="storage/metricIDs"}`, m.MetricIDCacheRequests)
|
||||
@@ -673,15 +691,8 @@ func writeStorageMetrics(w io.Writer, strg *storage.Storage) {
|
||||
metrics.WriteCounterUint64(w, `vm_cache_misses_total{type="indexdb/tagFiltersToMetricIDs"}`, idbm.TagFiltersToMetricIDsCacheMisses)
|
||||
metrics.WriteCounterUint64(w, `vm_cache_misses_total{type="storage/regexps"}`, storage.RegexpCacheMisses())
|
||||
metrics.WriteCounterUint64(w, `vm_cache_misses_total{type="storage/regexpPrefixes"}`, storage.RegexpPrefixesCacheMisses())
|
||||
metrics.WriteCounterUint64(w, `vm_cache_eviction_bytes_total{type="storage/tsid", reason="cache_size"}`, m.TSIDCacheSizeEvictionBytes)
|
||||
metrics.WriteCounterUint64(w, `vm_cache_eviction_bytes_total{type="storage/tsid", reason="miss_percentage"}`, m.TSIDCacheMissEvictionBytes)
|
||||
metrics.WriteCounterUint64(w, `vm_cache_eviction_bytes_total{type="storage/tsid", reason="expiration"}`, m.TSIDCacheExpireEvictionBytes)
|
||||
metrics.WriteCounterUint64(w, `vm_cache_eviction_bytes_total{type="storage/metricName", reason="cache_size"}`, m.MetricNameCacheSizeEvictionBytes)
|
||||
metrics.WriteCounterUint64(w, `vm_cache_eviction_bytes_total{type="storage/metricName", reason="miss_percentage"}`, m.MetricNameCacheMissEvictionBytes)
|
||||
metrics.WriteCounterUint64(w, `vm_cache_eviction_bytes_total{type="storage/metricName", reason="expiration"}`, m.MetricNameCacheExpireEvictionBytes)
|
||||
metrics.WriteCounterUint64(w, `vm_cache_eviction_bytes_total{type="storage/metricIDs", reason="cache_size"}`, m.MetricIDCacheSizeEvictionBytes)
|
||||
metrics.WriteCounterUint64(w, `vm_cache_eviction_bytes_total{type="storage/metricIDs", reason="miss_percentage"}`, m.MetricIDCacheMissEvictionBytes)
|
||||
metrics.WriteCounterUint64(w, `vm_cache_eviction_bytes_total{type="storage/metricIDs", reason="expiration"}`, m.MetricIDCacheExpireEvictionBytes)
|
||||
|
||||
metrics.WriteCounterUint64(w, `vm_cache_resets_total{type="indexdb/tagFiltersToMetricIDs"}`, idbm.TagFiltersToMetricIDsCacheResets)
|
||||
|
||||
metrics.WriteCounterUint64(w, `vm_deleted_metrics_total{type="indexdb"}`, m.DeletedMetricsCount)
|
||||
|
||||
@@ -698,6 +709,11 @@ func writeStorageMetrics(w io.Writer, strg *storage.Storage) {
|
||||
|
||||
metrics.WriteGaugeUint64(w, `vm_downsampling_partitions_scheduled`, tm.ScheduledDownsamplingPartitions)
|
||||
metrics.WriteGaugeUint64(w, `vm_downsampling_partitions_scheduled_size_bytes`, tm.ScheduledDownsamplingPartitionsSize)
|
||||
|
||||
metrics.WriteGaugeUint64(w, `vm_metrics_metadata_storage_items`, m.MetadataStorageItemsCurrent)
|
||||
metrics.WriteCounterUint64(w, `vm_metrics_metadata_storage_size_bytes`, m.MetadataStorageCurrentSizeBytes)
|
||||
metrics.WriteCounterUint64(w, `vm_metrics_metadata_storage_max_size_bytes`, m.MetadataStorageMaxSizeBytes)
|
||||
|
||||
}
|
||||
|
||||
func jsonResponseError(w http.ResponseWriter, err error) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM golang:1.25.3 AS build-web-stage
|
||||
FROM golang:1.25.4 AS build-web-stage
|
||||
COPY build /build
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
1461
app/vmui/packages/vmui/package-lock.json
generated
1461
app/vmui/packages/vmui/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,7 @@
|
||||
"build": "vite build",
|
||||
"build:anomaly": "vite build --mode vmanomaly",
|
||||
"lint": "eslint --output-file vmui-lint-report.json --format json 'src/**/*.{ts,tsx}'",
|
||||
"lint:local": "eslint --ext .ts,.tsx -f stylish src",
|
||||
"lint:fix": "eslint 'src/**/*.{ts,tsx}' --fix",
|
||||
"copy-metricsql-docs": "cp ../../../../docs/MetricsQL.md src/assets/MetricsQL.md || true",
|
||||
"preview": "vite preview",
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface ChartTooltipProps {
|
||||
info?: ReactNode;
|
||||
marker?: string;
|
||||
show?: boolean;
|
||||
duplicateCount?: number;
|
||||
onClose?: (id: string) => void;
|
||||
}
|
||||
|
||||
@@ -35,6 +36,7 @@ const ChartTooltip: FC<ChartTooltipProps> = ({
|
||||
statsFormatted,
|
||||
isSticky,
|
||||
marker,
|
||||
duplicateCount = 0,
|
||||
onClose
|
||||
}) => {
|
||||
const tooltipRef = useRef<HTMLDivElement>(null);
|
||||
@@ -156,6 +158,7 @@ const ChartTooltip: FC<ChartTooltipProps> = ({
|
||||
<p className="vm-chart-tooltip-data__value">
|
||||
<b>{value}</b>{unit}
|
||||
</p>
|
||||
{duplicateCount > 1 && <p>(overlapping points: {duplicateCount})</p>}
|
||||
</div>
|
||||
{statsFormatted && (
|
||||
<table className="vm-chart-tooltip-stats">
|
||||
|
||||
@@ -21,8 +21,9 @@ const LegendHeatmap: FC<LegendHeatmapProps> = ({
|
||||
const [maxFormat, setMaxFormat] = useState("");
|
||||
|
||||
const value = useMemo(() => {
|
||||
return parseFloat(String(legendValue?.value || 0).replace("%", ""));
|
||||
}, [legendValue]);
|
||||
const n = Number(String(legendValue?.value ?? "").replace("%","").replace(",", "."));
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}, [legendValue?.value]);
|
||||
|
||||
useEffect(() => {
|
||||
setPercent(value ? (value - min) / (max - min) * 100 : 0);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { FC, MouseEvent, useMemo } from "react";
|
||||
import { FC, useMemo } from "react";
|
||||
import { TargetedMouseEvent } from "preact";
|
||||
import { LegendItemType } from "../../../../types";
|
||||
import { useLegendView } from "./hooks/useLegendView";
|
||||
import LegendLines from "./LegendViews/LegendLines";
|
||||
@@ -7,6 +8,7 @@ import { useHideDuplicateFields } from "./hooks/useHideDuplicateFields";
|
||||
import Accordion from "../../../Main/Accordion/Accordion";
|
||||
import { useLegendGroup } from "./hooks/useLegendGroup";
|
||||
import useCopyToClipboard from "../../../../hooks/useCopyToClipboard";
|
||||
import { DEFAULT_MAX_SERIES } from "../../../../constants/graph";
|
||||
|
||||
export type LegendProps = {
|
||||
labels: LegendItemType[];
|
||||
@@ -29,7 +31,7 @@ const LegendGroup: FC<LegendGroupProps> = ({ labels, group, isAnomalyView, onCha
|
||||
return labels.sort((x, y) => (y.median || 0) - (x.median || 0));
|
||||
}, [labels]);
|
||||
|
||||
const createHandlerCopy = (value: string) => async (e: MouseEvent<HTMLDivElement>) => {
|
||||
const createHandlerCopy = (value: string) => async (e: TargetedMouseEvent<HTMLDivElement>) => {
|
||||
e.stopPropagation();
|
||||
await copyToClipboard(value, `${value} has been copied`);
|
||||
};
|
||||
@@ -42,7 +44,7 @@ const LegendGroup: FC<LegendGroupProps> = ({ labels, group, isAnomalyView, onCha
|
||||
key={group}
|
||||
>
|
||||
<Accordion
|
||||
defaultExpanded={true}
|
||||
defaultExpanded={sortedLabels.length < DEFAULT_MAX_SERIES.chart}
|
||||
title={(
|
||||
<div className="vm-legend-group-header">
|
||||
<div className="vm-legend-group-header-title">
|
||||
|
||||
@@ -42,6 +42,7 @@ export interface LineChartProps {
|
||||
height?: number;
|
||||
isAnomalyView?: boolean;
|
||||
spanGaps?: boolean;
|
||||
showAllPoints?: boolean;
|
||||
}
|
||||
|
||||
const LineChart: FC<LineChartProps> = ({
|
||||
@@ -55,7 +56,8 @@ const LineChart: FC<LineChartProps> = ({
|
||||
layoutSize,
|
||||
height,
|
||||
isAnomalyView,
|
||||
spanGaps = false
|
||||
spanGaps = false,
|
||||
showAllPoints = false,
|
||||
}) => {
|
||||
const { isDarkTheme } = useAppState();
|
||||
|
||||
@@ -108,10 +110,10 @@ const LineChart: FC<LineChartProps> = ({
|
||||
useEffect(() => {
|
||||
if (!uPlotInst) return;
|
||||
delSeries(uPlotInst);
|
||||
addSeries(uPlotInst, series, spanGaps);
|
||||
addSeries(uPlotInst, series, spanGaps, showAllPoints);
|
||||
setBand(uPlotInst, series);
|
||||
uPlotInst.redraw();
|
||||
}, [series, spanGaps]);
|
||||
}, [series, spanGaps, showAllPoints]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!uPlotInst) return;
|
||||
|
||||
@@ -14,6 +14,7 @@ import LegendConfigs from "../../Chart/Line/Legend/LegendConfigs/LegendConfigs";
|
||||
import Modal from "../../Main/Modal/Modal";
|
||||
import { useGraphDispatch, useGraphState } from "../../../state/graph/GraphStateContext";
|
||||
import { useEffect } from "react";
|
||||
import PointsConfigurator from "./PointsConfigurator/PointsConfigurator";
|
||||
|
||||
const title = "Graph & Legend Settings";
|
||||
|
||||
@@ -26,10 +27,14 @@ interface GraphSettingsProps {
|
||||
value: boolean,
|
||||
onChange: (value: boolean) => void,
|
||||
},
|
||||
showAllPoints: {
|
||||
value: boolean,
|
||||
onChange: (value: boolean) => void,
|
||||
},
|
||||
isHistogram?: boolean,
|
||||
}
|
||||
|
||||
const GraphSettings: FC<GraphSettingsProps> = ({ data, yaxis, setYaxisLimits, toggleEnableLimits, spanGaps }) => {
|
||||
const GraphSettings: FC<GraphSettingsProps> = ({ data, yaxis, setYaxisLimits, toggleEnableLimits, spanGaps, showAllPoints }) => {
|
||||
const { openSettings } = useGraphState();
|
||||
const graphDispatch = useGraphDispatch();
|
||||
|
||||
@@ -84,6 +89,10 @@ const GraphSettings: FC<GraphSettingsProps> = ({ data, yaxis, setYaxisLimits, to
|
||||
spanGaps={spanGaps.value}
|
||||
onChange={spanGaps.onChange}
|
||||
/>
|
||||
<PointsConfigurator
|
||||
showAllPoints={showAllPoints.value}
|
||||
onChangeShow={showAllPoints.onChange}
|
||||
/>
|
||||
{displayHistogramMode && <GraphTypeSwitcher onChange={handleClose}/>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -20,7 +20,7 @@ const LinesConfigurator: FC<Props> = ({ spanGaps, onChange }) => {
|
||||
fullWidth={isMobile}
|
||||
/>
|
||||
<span className="vm-legend-configs-item__info">
|
||||
Connects data points by skipping null values instead of creating gaps.
|
||||
Connects data points by skipping null values instead of gaps.
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { FC } from "preact/compat";
|
||||
import Switch from "../../../Main/Switch/Switch";
|
||||
import useDeviceDetect from "../../../../hooks/useDeviceDetect";
|
||||
|
||||
interface Props {
|
||||
showAllPoints: boolean;
|
||||
onChangeShow: (value: boolean) => void;
|
||||
}
|
||||
|
||||
const PointsConfigurator: FC<Props> = ({ showAllPoints, onChangeShow }) => {
|
||||
const { isMobile } = useDeviceDetect();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="vm-graph-settings-row">
|
||||
<span className="vm-graph-settings-row__label">Show all data points</span>
|
||||
<Switch
|
||||
value={showAllPoints}
|
||||
onChange={onChangeShow}
|
||||
label={showAllPoints ? "Enabled" : "Disabled"}
|
||||
fullWidth={isMobile}
|
||||
/>
|
||||
<span className="vm-legend-configs-item__info">
|
||||
Display every data point, even when no line can be drawn.
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PointsConfigurator;
|
||||
@@ -46,5 +46,6 @@
|
||||
align-items: center;
|
||||
justify-content: stretch;
|
||||
grid-template-columns: 1fr 100px;
|
||||
gap: 0 $padding-large;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,7 +132,7 @@ const BaseRule = ({ item }: BaseRuleProps) => {
|
||||
<th>Series returned</th>
|
||||
<th>Series fetched</th>
|
||||
<th>Duration</th>
|
||||
<th>Executed at</th>
|
||||
<th>Execution timestamp</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -154,7 +154,7 @@ const BaseRule = ({ item }: BaseRuleProps) => {
|
||||
{!!item?.alerts?.length && (
|
||||
<>
|
||||
<span className="vm-alerts-title">Alerts</span>
|
||||
<table>
|
||||
<table className="vm-alerts-table">
|
||||
<colgroup>
|
||||
<col className="vm-col-sm"/>
|
||||
<col className="vm-col-sm"/>
|
||||
@@ -190,7 +190,7 @@ const BaseRule = ({ item }: BaseRuleProps) => {
|
||||
</td>
|
||||
<td>
|
||||
<Badges
|
||||
align="center"
|
||||
align="start"
|
||||
items={Object.fromEntries(Object.entries(alert.labels || {}).map(([name, value]) => [name, {
|
||||
color: "passive",
|
||||
value: value,
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
word-break: break-word;
|
||||
table-layout: fixed;
|
||||
width: 100%;
|
||||
|
||||
td, th {
|
||||
line-height: 30px;
|
||||
padding: 4px $padding-small;
|
||||
@@ -52,15 +53,33 @@
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
th {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
td.align-center {
|
||||
text-align: center
|
||||
}
|
||||
|
||||
th {
|
||||
font-weight: bold;
|
||||
padding: 0 $padding-small;
|
||||
}
|
||||
}
|
||||
|
||||
.vm-alerts-table {
|
||||
tr {
|
||||
border-bottom: $border-divider;
|
||||
|
||||
&:hover {
|
||||
background: $color-background-hover;
|
||||
}
|
||||
}
|
||||
|
||||
td {
|
||||
vertical-align: top;
|
||||
padding-block: $padding-small;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,9 +46,6 @@
|
||||
.vm-text-field__input {
|
||||
padding: 11px 28px;
|
||||
}
|
||||
.vm-text-field__icon-start {
|
||||
height: 42px;
|
||||
}
|
||||
}
|
||||
|
||||
&__clear-icon {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
|
||||
&:has(>details[open]) {
|
||||
&:has(>.vm-accordion-header_open) {
|
||||
background-color: $color-background-item;
|
||||
}
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ const RulesHeader: FC<RulesHeaderProps> = ({
|
||||
value={states}
|
||||
list={allStates}
|
||||
label="State"
|
||||
placeholder="Please rule state"
|
||||
placeholder="Please select rule state"
|
||||
onChange={onChangeStates}
|
||||
noOptionsText={noStateText}
|
||||
includeAll
|
||||
|
||||
@@ -26,9 +26,6 @@
|
||||
.vm-text-field__input {
|
||||
padding: 11px 28px;
|
||||
}
|
||||
.vm-text-field__icon-start {
|
||||
height: 42px;
|
||||
}
|
||||
}
|
||||
|
||||
&__clear-icon {
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
position: relative;
|
||||
border-radius: $border-radius-small;
|
||||
|
||||
&:has(>details[open]) {
|
||||
&:has(>.vm-accordion-header_open) {
|
||||
background-color: $color-background-item;
|
||||
}
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ const ExploreMetricItem: FC<ExploreMetricItemGraphProps> = ({
|
||||
const step = isHeatmap && customStep === defaultStep ? heatmapStep : customStep;
|
||||
|
||||
|
||||
const query = useMemo(() => {
|
||||
const queries = useMemo(() => {
|
||||
const params = Object.entries({ job, instance })
|
||||
.filter(val => val[1])
|
||||
.map(([key, val]) => `${key}=${JSON.stringify(val)}`);
|
||||
@@ -55,19 +55,19 @@ const ExploreMetricItem: FC<ExploreMetricItemGraphProps> = ({
|
||||
|
||||
const base = `{${params.join(",")}}`;
|
||||
if (isBucket) {
|
||||
return `sum(rate(${base})) by (vmrange, le)`;
|
||||
return [`sum(rate(${base})) by (vmrange, le)`];
|
||||
}
|
||||
const queryBase = rateEnabled ? `rollup_rate(${base})` : `rollup(${base})`;
|
||||
return `
|
||||
return [`
|
||||
with (q = ${queryBase}) (
|
||||
alias(min(label_match(q, "rollup", "min")), "min"),
|
||||
alias(max(label_match(q, "rollup", "max")), "max"),
|
||||
alias(avg(label_match(q, "rollup", "avg")), "avg"),
|
||||
)`;
|
||||
)`];
|
||||
}, [name, job, instance, rateEnabled, isBucket]);
|
||||
|
||||
const { isLoading, graphData, error, queryErrors, warning, isHistogram } = useFetchQuery({
|
||||
predefinedQuery: [query],
|
||||
predefinedQuery: queries,
|
||||
visible: true,
|
||||
customStep: step,
|
||||
showAllSeries
|
||||
@@ -98,7 +98,7 @@ with (q = ${queryBase}) (
|
||||
{warning && (
|
||||
<WarningLimitSeries
|
||||
warning={warning}
|
||||
query={[query]}
|
||||
query={queries}
|
||||
onChange={setShowAllSeries}
|
||||
/>
|
||||
)}
|
||||
@@ -107,7 +107,7 @@ with (q = ${queryBase}) (
|
||||
data={graphData}
|
||||
period={period}
|
||||
customStep={step}
|
||||
query={[query]}
|
||||
query={queries}
|
||||
yaxis={yaxis}
|
||||
setYaxisLimits={setYaxisLimits}
|
||||
setPeriod={setPeriod}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { FC, useState, useEffect } from "preact/compat";
|
||||
import classNames from "classnames";
|
||||
import { JSX } from "preact";
|
||||
import { ArrowDownIcon } from "../Icons";
|
||||
import "./style.scss";
|
||||
@@ -31,9 +32,12 @@ const Accordion: FC<AccordionProps> = ({
|
||||
event.preventDefault();
|
||||
return; // If the text is selected, cancel the execution of toggle.
|
||||
}
|
||||
const details = event.currentTarget.parentElement as HTMLDetailsElement;
|
||||
onChange && onChange(details.open);
|
||||
setIsOpen(details.open);
|
||||
|
||||
setIsOpen((prev) => {
|
||||
const newState = !prev;
|
||||
onChange && onChange(newState);
|
||||
return newState;
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -42,23 +46,32 @@ const Accordion: FC<AccordionProps> = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<details
|
||||
className="vm-accordion-section"
|
||||
key="content"
|
||||
open={isOpen}
|
||||
<header
|
||||
className={classNames({
|
||||
"vm-accordion-header": true,
|
||||
"vm-accordion-header_open": isOpen,
|
||||
})}
|
||||
onClick={toggleOpen}
|
||||
id={id}
|
||||
>
|
||||
<summary
|
||||
className="vm-accordion-header"
|
||||
onClick={toggleOpen}
|
||||
{title}
|
||||
<div
|
||||
className={classNames({
|
||||
"vm-accordion-header__arrow": true,
|
||||
"vm-accordion-header__arrow_open": isOpen,
|
||||
})}
|
||||
>
|
||||
{title}
|
||||
<div className="vm-accordion-header__arrow">
|
||||
<ArrowDownIcon />
|
||||
</div>
|
||||
</summary>
|
||||
{children}
|
||||
</details>
|
||||
<ArrowDownIcon />
|
||||
</div>
|
||||
</header>
|
||||
{isOpen && (
|
||||
<section
|
||||
className="vm-accordion-section"
|
||||
key="content"
|
||||
>
|
||||
{children}
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -17,6 +17,10 @@
|
||||
transform: rotate(0);
|
||||
transition: transform 200ms ease-in-out;
|
||||
|
||||
&_open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 14px;
|
||||
height: auto;
|
||||
@@ -24,14 +28,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.vm-accordion-section[open] > summary {
|
||||
& > .vm-accordion-header {
|
||||
&__arrow {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.accordion-section {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
@use "src/styles/variables" as *;
|
||||
|
||||
.vm-alert {
|
||||
z-index: 20;
|
||||
position: sticky;
|
||||
top: $padding-global;
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 20px 1fr;
|
||||
align-items: center;
|
||||
|
||||
@@ -137,6 +137,7 @@ const Select: FC<SelectProps> = ({
|
||||
"vm-select_disabled": disabled
|
||||
})}
|
||||
>
|
||||
{label && <span className="vm-text-field__label">{label}</span>}
|
||||
<div
|
||||
className="vm-select-input"
|
||||
onClick={handleToggleList}
|
||||
@@ -150,7 +151,7 @@ const Select: FC<SelectProps> = ({
|
||||
onRemoveItem={handleSelected}
|
||||
/>
|
||||
)}
|
||||
{!hideInput && !selectedValues?.length && (
|
||||
{!hideInput && (
|
||||
<input
|
||||
value={textFieldValue}
|
||||
type="text"
|
||||
@@ -164,7 +165,6 @@ const Select: FC<SelectProps> = ({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{label && <span className="vm-text-field__label">{label}</span>}
|
||||
{clearable && value && (
|
||||
<div
|
||||
className="vm-select-input__icon"
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
@use "src/styles/variables" as *;
|
||||
|
||||
.vm-select {
|
||||
position: relative;
|
||||
display: grid;
|
||||
&-input {
|
||||
position: relative;
|
||||
display: flex;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
@use "src/styles/variables" as *;
|
||||
|
||||
.vm-text-field {
|
||||
position: relative;
|
||||
display: grid;
|
||||
margin: 6px 0;
|
||||
width: 100%;
|
||||
@@ -156,10 +157,10 @@
|
||||
}
|
||||
|
||||
&__icon-start {
|
||||
left: $padding-small;
|
||||
left: $padding-global;
|
||||
}
|
||||
|
||||
&__icon-end {
|
||||
right: $padding-small;
|
||||
right: $padding-global;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { descendingComparator } from "./helpers";
|
||||
import { getNanoTimestamp } from "../../utils/time"; // используем реальную реализацию
|
||||
import { getNanoTimestamp } from "../../utils/time";
|
||||
|
||||
describe("descendingComparator", () => {
|
||||
it("returns 0 for equal numbers", () => {
|
||||
|
||||
@@ -9,13 +9,12 @@ import {
|
||||
getLegendItem,
|
||||
getSeriesItemContext,
|
||||
normalizeData,
|
||||
getLimitsYAxis,
|
||||
getMinMaxBuffer,
|
||||
getTimeSeries,
|
||||
} from "../../../utils/uplot";
|
||||
import { TimeParams, SeriesItem, LegendItemType } from "../../../types";
|
||||
import { AxisRange, YaxisState } from "../../../state/graph/reducer";
|
||||
import { getAvgFromArray, getMaxFromArray, getMinFromArray } from "../../../utils/math";
|
||||
import { getMathStats } from "../../../utils/math";
|
||||
import classNames from "classnames";
|
||||
import { useTimeState } from "../../../state/time/TimeStateContext";
|
||||
import HeatmapChart from "../../Chart/Heatmap/HeatmapChart/HeatmapChart";
|
||||
@@ -27,6 +26,9 @@ import { ChartTooltipProps } from "../../Chart/ChartTooltip/ChartTooltip";
|
||||
import LegendAnomaly from "../../Chart/Line/LegendAnomaly/LegendAnomaly";
|
||||
import { groupByMultipleKeys } from "../../../utils/array";
|
||||
import { useGraphDispatch } from "../../../state/graph/GraphStateContext";
|
||||
import { sameTs } from "../../../utils/time";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import router from "../../../router";
|
||||
|
||||
export interface GraphViewProps {
|
||||
data?: MetricResult[];
|
||||
@@ -45,6 +47,7 @@ export interface GraphViewProps {
|
||||
isAnomalyView?: boolean;
|
||||
isPredefinedPanel?: boolean;
|
||||
spanGaps?: boolean;
|
||||
showAllPoints?: boolean;
|
||||
}
|
||||
|
||||
const GraphView: FC<GraphViewProps> = ({
|
||||
@@ -63,10 +66,16 @@ const GraphView: FC<GraphViewProps> = ({
|
||||
isHistogram,
|
||||
isAnomalyView,
|
||||
isPredefinedPanel,
|
||||
spanGaps
|
||||
spanGaps,
|
||||
showAllPoints
|
||||
}) => {
|
||||
const location = useLocation();
|
||||
const isRawQuery = useMemo(() => location.pathname === router.rawQuery, [location.pathname]);
|
||||
|
||||
const graphDispatch = useGraphDispatch();
|
||||
|
||||
const [containerRef, containerSize] = useElementSize();
|
||||
|
||||
const { isMobile } = useDeviceDetect();
|
||||
const { timezone } = useTimeState();
|
||||
const currentStep = useMemo(() => customStep || period.step || "1s", [period.step, customStep]);
|
||||
@@ -80,12 +89,16 @@ const GraphView: FC<GraphViewProps> = ({
|
||||
const [legendValue, setLegendValue] = useState<ChartTooltipProps | null>(null);
|
||||
|
||||
const getSeriesItem = useMemo(() => {
|
||||
return getSeriesItemContext(data, hideSeries, alias, isAnomalyView);
|
||||
}, [data, hideSeries, alias, isAnomalyView]);
|
||||
return getSeriesItemContext(data, hideSeries, alias, showAllPoints, isAnomalyView, isRawQuery);
|
||||
}, [data, hideSeries, alias, showAllPoints, isAnomalyView, isRawQuery]);
|
||||
|
||||
const setLimitsYaxis = (values: { [key: string]: number[] }) => {
|
||||
const limits = getLimitsYAxis(values, !isHistogram);
|
||||
setYaxisLimits(limits);
|
||||
const setLimitsYaxis = (minVal: number, maxVal: number) => {
|
||||
let min = Number.isFinite(minVal) ? minVal : 0;
|
||||
let max = Number.isFinite(maxVal) ? maxVal : 1;
|
||||
|
||||
if (min > max) [min, max] = [max, min];
|
||||
|
||||
setYaxisLimits({ "1": isHistogram ? [min, max] : getMinMaxBuffer(min, max) });
|
||||
};
|
||||
|
||||
const onChangeLegend = (legend: LegendItemType, metaKey: boolean) => {
|
||||
@@ -129,78 +142,105 @@ const GraphView: FC<GraphViewProps> = ({
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const tempTimes: number[] = [];
|
||||
const tempValues: { [key: string]: number[] } = {};
|
||||
const tempLegend: LegendItemType[] = [];
|
||||
const tempSeries: uPlotSeries[] = [{}];
|
||||
const dLen = data.length;
|
||||
|
||||
data?.forEach((d, i) => {
|
||||
const tsAnchor = data?.[0]?.values?.[0]?.[0];
|
||||
const tsArray: number[] = [];
|
||||
const tempLegend = new Array<LegendItemType>(dLen);
|
||||
const tempSeries = new Array<uPlotSeries>(dLen + 1);
|
||||
tempSeries[0] = {};
|
||||
|
||||
let minVal = Infinity;
|
||||
let maxVal = -Infinity;
|
||||
|
||||
for (let i = 0; i < dLen; i++) {
|
||||
const d = data[i];
|
||||
const seriesItem = getSeriesItem(d, i);
|
||||
tempSeries[i + 1] = seriesItem;
|
||||
tempLegend[i] = getLegendItem(seriesItem, d.group);
|
||||
|
||||
tempSeries.push(seriesItem);
|
||||
tempLegend.push(getLegendItem(seriesItem, d.group));
|
||||
const tmpValues = tempValues[d.group] || [];
|
||||
for (const v of d.values) {
|
||||
tempTimes.push(v[0]);
|
||||
tmpValues.push(promValueToNumber(v[1]));
|
||||
}
|
||||
tempValues[d.group] = tmpValues;
|
||||
});
|
||||
|
||||
const timeSeries = getTimeSeries(tempTimes, currentStep, period);
|
||||
const timeDataSeries = data.map(d => {
|
||||
const results = [];
|
||||
const values = d.values;
|
||||
const length = values.length;
|
||||
let j = 0;
|
||||
for (const t of timeSeries) {
|
||||
while (j < length && values[j][0] < t) j++;
|
||||
let v = null;
|
||||
if (j < length && values[j][0] == t) {
|
||||
v = promValueToNumber(values[j][1]);
|
||||
if (!Number.isFinite(v)) {
|
||||
// Treat special values as nulls in order to satisfy uPlot.
|
||||
// Otherwise it may draw unexpected graphs.
|
||||
v = null;
|
||||
}
|
||||
const vals = d.values;
|
||||
for (let j = 0, vLen = vals.length; j < vLen; j++) {
|
||||
const v = vals[j];
|
||||
if (isRawQuery) tsArray.push(v[0]);
|
||||
const num = promValueToNumber(v[1]);
|
||||
if (Number.isFinite(num)) {
|
||||
if (num < minVal) minVal = num;
|
||||
if (num > maxVal) maxVal = num;
|
||||
}
|
||||
results.push(v);
|
||||
}
|
||||
}
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const widthPx = containerSize.width || window.innerWidth || 4096;
|
||||
const pixels = Math.max(1, Math.floor(widthPx * Math.max(1, dpr)));
|
||||
|
||||
const timeSeries = isRawQuery
|
||||
? tsArray.sort((a, b) => a - b)
|
||||
: getTimeSeries(currentStep, period, pixels, tsAnchor);
|
||||
|
||||
const timeDataSeries: (number | null)[][] = data.map(d => {
|
||||
const tsLen = timeSeries.length;
|
||||
const results = new Array<number | null>(tsLen);
|
||||
const values = d.values;
|
||||
const vLen = values.length;
|
||||
|
||||
let j = 0;
|
||||
for (let k = 0; k < tsLen; k++) {
|
||||
const t = timeSeries[k];
|
||||
while (j < vLen && values[j][0] < t) j++;
|
||||
let v: number | null = null;
|
||||
if (j < vLen && sameTs(values[j][0], t)) {
|
||||
const num = promValueToNumber(values[j][1]);
|
||||
// Treat special values as nulls in order to satisfy uPlot.
|
||||
// Otherwise it may draw unexpected graphs.
|
||||
v = Number.isFinite(num) ? num : null;
|
||||
// Advance to next value
|
||||
j++;
|
||||
}
|
||||
results[k] = v;
|
||||
}
|
||||
|
||||
// stabilize float numbers
|
||||
const resultAsNumber = results.filter(s => s !== null) as number[];
|
||||
const avg = Math.abs(getAvgFromArray(resultAsNumber));
|
||||
const range = getMinMaxBuffer(getMinFromArray(resultAsNumber), getMaxFromArray(resultAsNumber));
|
||||
// // stabilize float numbers
|
||||
const { min, max, avg: avgRaw } = getMathStats(results, { min: true, max: true, avg: true });
|
||||
const avg = Math.abs(Number(avgRaw));
|
||||
const range = getMinMaxBuffer(min, max);
|
||||
const rangeStep = Math.abs(range[1] - range[0]);
|
||||
const needStabilize = (avg > rangeStep * 1e10) && !isAnomalyView;
|
||||
|
||||
return (avg > rangeStep * 1e10) && !isAnomalyView ? results.map(() => avg) : results;
|
||||
return needStabilize ? results.fill(avg) : results;
|
||||
});
|
||||
|
||||
timeDataSeries.unshift(timeSeries);
|
||||
setLimitsYaxis(tempValues);
|
||||
|
||||
const result = isHistogram ? prepareHistogramData(timeDataSeries) : timeDataSeries;
|
||||
const legend = prepareAnomalyLegend(tempLegend);
|
||||
|
||||
setLimitsYaxis(minVal, maxVal);
|
||||
setDataChart(result as uPlotData);
|
||||
setSeries(tempSeries);
|
||||
const legend = prepareAnomalyLegend(tempLegend);
|
||||
setLegend(legend);
|
||||
if (isAnomalyView) {
|
||||
setHideSeries(legend.map(s => s.label || "").slice(1));
|
||||
}
|
||||
}, [data, timezone, isHistogram, currentStep]);
|
||||
isAnomalyView && setHideSeries(legend.map(s => s.label || "").slice(1));
|
||||
}, [data, timezone, isHistogram, currentStep, isRawQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
const tempLegend: LegendItemType[] = [];
|
||||
const tempSeries: uPlotSeries[] = [{}];
|
||||
data?.forEach((d, i) => {
|
||||
const dLen = data.length;
|
||||
|
||||
const tempLegend = new Array<LegendItemType>(dLen);
|
||||
const tempSeries = new Array<uPlotSeries>(dLen + 1);
|
||||
tempSeries[0] = {};
|
||||
|
||||
for (let i = 0; i < dLen; i++) {
|
||||
const d = data[i];
|
||||
const seriesItem = getSeriesItem(d, i);
|
||||
tempSeries.push(seriesItem);
|
||||
tempLegend.push(getLegendItem(seriesItem, d.group));
|
||||
});
|
||||
tempSeries[i + 1] = seriesItem;
|
||||
tempLegend[i] = getLegendItem(seriesItem, d.group);
|
||||
}
|
||||
|
||||
setSeries(tempSeries);
|
||||
setLegend(prepareAnomalyLegend(tempLegend));
|
||||
}, [hideSeries]);
|
||||
|
||||
const [containerRef, containerSize] = useElementSize();
|
||||
|
||||
const hasTimeData = dataChart[0]?.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -243,6 +283,7 @@ const GraphView: FC<GraphViewProps> = ({
|
||||
height={height}
|
||||
isAnomalyView={isAnomalyView}
|
||||
spanGaps={spanGaps}
|
||||
showAllPoints={isRawQuery ? true : showAllPoints}
|
||||
/>
|
||||
)}
|
||||
{isHistogram && (
|
||||
|
||||
@@ -49,6 +49,26 @@ const useLineTooltip = ({ u, metrics, series, unit, isAnomalyView }: LineTooltip
|
||||
const max = u?.scales?.[1]?.max || 1;
|
||||
const date = u?.data?.[0]?.[dataIdx] || 0;
|
||||
|
||||
let duplicateCount = 1;
|
||||
|
||||
if (u && seriesIdx > 0 && dataIdx >= 0) {
|
||||
const xs = u.data[0] as (number | null)[];
|
||||
const ys = u.data[seriesIdx] as (number | null)[];
|
||||
|
||||
const xVal = xs[dataIdx];
|
||||
const yVal = ys[dataIdx];
|
||||
|
||||
if (xVal != null && yVal != null) {
|
||||
duplicateCount = 0;
|
||||
|
||||
for (let i = 0; i < xs.length; i++) {
|
||||
if (xs[i] === xVal && ys[i] === yVal) {
|
||||
duplicateCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const point = {
|
||||
top: u ? u.valToPos((value || 0), seriesItem?.scale || "1") : 0,
|
||||
left: u ? u.valToPos(date, "x") : 0,
|
||||
@@ -65,6 +85,7 @@ const useLineTooltip = ({ u, metrics, series, unit, isAnomalyView }: LineTooltip
|
||||
info: getMetricName(metricItem, seriesItem),
|
||||
statsFormatted: seriesItem?.statsFormatted,
|
||||
marker: `${seriesItem?.stroke}`,
|
||||
duplicateCount,
|
||||
};
|
||||
}, [u, tooltipIdx, metrics, series, unit, isAnomalyView]);
|
||||
|
||||
|
||||
@@ -41,7 +41,6 @@ interface FetchQueryReturn {
|
||||
|
||||
interface FetchDataParams {
|
||||
fetchUrl: string[],
|
||||
fetchQueue: AbortController[],
|
||||
displayType: DisplayType,
|
||||
query: string[],
|
||||
stateSeriesLimits: SeriesLimits,
|
||||
@@ -81,21 +80,25 @@ export const useFetchQuery = ({
|
||||
|
||||
const fetchData = async ({
|
||||
fetchUrl,
|
||||
fetchQueue,
|
||||
displayType,
|
||||
query,
|
||||
stateSeriesLimits,
|
||||
showAllSeries,
|
||||
hideQuery,
|
||||
}: FetchDataParams) => {
|
||||
|
||||
const controller = new AbortController();
|
||||
setFetchQueue([...fetchQueue, controller]);
|
||||
setFetchQueue(prev => [...prev, controller]);
|
||||
|
||||
try {
|
||||
const isDisplayChart = displayType === DisplayType.chart;
|
||||
const defaultLimit = showAllSeries ? Infinity : (+stateSeriesLimits[displayType] || Infinity);
|
||||
let seriesLimit = defaultLimit;
|
||||
const tempData: MetricBase[] = [];
|
||||
const tempTraces: Trace[] = [];
|
||||
const tempStats: QueryStats[] = [];
|
||||
const tempErrors: string[] = [];
|
||||
|
||||
let counter = 1;
|
||||
let totalLength = 0;
|
||||
let isHistogramResult = false;
|
||||
@@ -104,8 +107,8 @@ export const useFetchQuery = ({
|
||||
|
||||
const isHideQuery = hideQuery?.includes(counter - 1);
|
||||
if (isHideQuery) {
|
||||
setQueryErrors(prev => [...prev, ""]);
|
||||
setQueryStats(prev => [...prev, {}]);
|
||||
tempErrors.push("");
|
||||
tempStats.push({});
|
||||
counter++;
|
||||
continue;
|
||||
}
|
||||
@@ -119,12 +122,12 @@ export const useFetchQuery = ({
|
||||
const resp = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
setQueryStats(prev => [...prev, {
|
||||
tempStats.push({
|
||||
...resp?.stats,
|
||||
isPartial: resp?.isPartial,
|
||||
resultLength: resp.data.result.length,
|
||||
}]);
|
||||
setQueryErrors(prev => [...prev, ""]);
|
||||
});
|
||||
tempErrors.push("");
|
||||
|
||||
if (resp.trace) {
|
||||
const trace = new Trace(resp.trace, query[counter - 1]);
|
||||
@@ -134,7 +137,7 @@ export const useFetchQuery = ({
|
||||
const preventChangeType = !!getQueryStringValue("display_mode", null);
|
||||
isHistogramResult = !APP_TYPE_ANOMALY && isDisplayChart && !preventChangeType && isHistogramData(resp.data.result);
|
||||
seriesLimit = isHistogramResult ? Infinity : defaultLimit;
|
||||
const freeTempSize = seriesLimit - tempData.length;
|
||||
const freeTempSize = Math.max(0, seriesLimit - tempData.length);
|
||||
resp.data.result.slice(0, freeTempSize).forEach((d: MetricBase) => {
|
||||
d.group = counter;
|
||||
tempData.push(d);
|
||||
@@ -146,14 +149,20 @@ export const useFetchQuery = ({
|
||||
const errorType = resp.errorType || ErrorTypes.unknownType;
|
||||
const errorMessage = resp?.error || resp?.message || "see console for more details";
|
||||
const error = [errorType, errorMessage].join(",\r\n");
|
||||
setQueryErrors(prev => [...prev, `${error}`]);
|
||||
tempErrors.push(error);
|
||||
console.error(`Fetch query error: ${errorType}`, resp);
|
||||
}
|
||||
counter++;
|
||||
}
|
||||
setQueryErrors(tempErrors);
|
||||
setQueryStats(tempStats);
|
||||
|
||||
const shownSeries = tempData.length;
|
||||
setWarning(shownSeries < totalLength
|
||||
? `Showing ${shownSeries} series out of ${totalLength} series due to performance reasons. Please narrow down the query, so it returns fewer series`
|
||||
: ""
|
||||
);
|
||||
|
||||
const limitText = `Showing ${tempData.length} series out of ${totalLength} series due to performance reasons. Please narrow down the query, so it returns fewer series`;
|
||||
setWarning(totalLength > seriesLimit ? limitText : "");
|
||||
isDisplayChart ? setGraphData(tempData as MetricResult[]) : setLiveData(tempData as InstantMetricResult[]);
|
||||
setTraces(tempTraces);
|
||||
setIsHistogram(prev => totalLength ? isHistogramResult : prev);
|
||||
@@ -177,30 +186,20 @@ export const useFetchQuery = ({
|
||||
const throttledFetchData = useCallback(debounce(fetchData, 300), []);
|
||||
|
||||
const fetchUrl = useMemo(() => {
|
||||
setError("");
|
||||
setQueryErrors([]);
|
||||
setQueryStats([]);
|
||||
const expr = predefinedQuery ?? query;
|
||||
const expr = (predefinedQuery ?? query).filter(Boolean);
|
||||
const displayChart = (display || displayType) === DisplayType.chart;
|
||||
if (!period) return;
|
||||
if (!serverUrl) {
|
||||
setError(ErrorTypes.emptyServer);
|
||||
} else if (expr.every(q => !q.trim())) {
|
||||
setQueryErrors(expr.map(() => ErrorTypes.validQuery));
|
||||
} else if (isValidHttpUrl(serverUrl)) {
|
||||
const updatedPeriod = { ...period };
|
||||
updatedPeriod.step = customStep;
|
||||
return expr.map(q => displayChart
|
||||
? getQueryRangeUrl(serverUrl, q, updatedPeriod, nocache, isTracingEnabled)
|
||||
: getQueryUrl(serverUrl, q, updatedPeriod, nocache, isTracingEnabled));
|
||||
} else {
|
||||
setError(ErrorTypes.validServer);
|
||||
}
|
||||
},
|
||||
[serverUrl, period, displayType, customStep, hideQuery]);
|
||||
|
||||
if (!period || !serverUrl || !isValidHttpUrl(serverUrl) || !expr.length) return;
|
||||
|
||||
const updatedPeriod = { ...period, step: customStep };
|
||||
return expr.map(q => displayChart
|
||||
? getQueryRangeUrl(serverUrl, q, updatedPeriod, nocache, isTracingEnabled)
|
||||
: getQueryUrl(serverUrl, q, updatedPeriod, nocache, isTracingEnabled));
|
||||
}, [serverUrl, period, displayType, customStep, nocache, isTracingEnabled, display, predefinedQuery, query]);
|
||||
|
||||
const abortFetch = useCallback(() => {
|
||||
fetchQueue.map(f => f.abort());
|
||||
fetchQueue.forEach(f => f.abort());
|
||||
|
||||
setFetchQueue([]);
|
||||
setGraphData([]);
|
||||
setLiveData([]);
|
||||
@@ -215,7 +214,6 @@ export const useFetchQuery = ({
|
||||
const expr = predefinedQuery ?? query;
|
||||
throttledFetchData({
|
||||
fetchUrl,
|
||||
fetchQueue,
|
||||
displayType: display || displayType,
|
||||
query: expr,
|
||||
stateSeriesLimits,
|
||||
@@ -229,13 +227,26 @@ export const useFetchQuery = ({
|
||||
const fetchPast = fetchQueue.slice(0, -1);
|
||||
if (!fetchPast.length) return;
|
||||
fetchPast.map(f => f.abort());
|
||||
setFetchQueue(fetchQueue.filter(f => !f.signal.aborted));
|
||||
|
||||
setFetchQueue(prev => prev.filter(f => !f.signal.aborted));
|
||||
}, [fetchQueue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (defaultStep === customStep) setGraphData([]);
|
||||
}, [isHistogram]);
|
||||
|
||||
useEffect(() => {
|
||||
setError("");
|
||||
setQueryErrors([]);
|
||||
setQueryStats([]);
|
||||
|
||||
const expr = predefinedQuery ?? query;
|
||||
if (!period) return;
|
||||
if (!serverUrl) { setError(ErrorTypes.emptyServer); return; }
|
||||
if (!isValidHttpUrl(serverUrl)) { setError(ErrorTypes.validServer); return; }
|
||||
if (expr.every(q => !q.trim())) { setQueryErrors(expr.map(() => ErrorTypes.validQuery)); }
|
||||
}, [serverUrl, period, displayType, customStep, nocache, isTracingEnabled, display, predefinedQuery, query]);
|
||||
|
||||
return {
|
||||
fetchUrl,
|
||||
isLoading,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FC } from "react";
|
||||
import { FC, createPortal, useCallback, RefObject } from "preact/compat";
|
||||
import GraphView from "../../../components/Views/GraphView/GraphView";
|
||||
import GraphTips from "../../../components/Chart/GraphTips/GraphTips";
|
||||
import GraphSettings from "../../../components/Configurators/GraphSettings/GraphSettings";
|
||||
@@ -8,40 +8,43 @@ import { useGraphDispatch, useGraphState } from "../../../state/graph/GraphState
|
||||
import useDeviceDetect from "../../../hooks/useDeviceDetect";
|
||||
import { useQueryState } from "../../../state/query/QueryStateContext";
|
||||
import { MetricResult } from "../../../api/types";
|
||||
import { createPortal } from "preact/compat";
|
||||
|
||||
type Props = {
|
||||
isHistogram: boolean;
|
||||
graphData: MetricResult[];
|
||||
controlsRef: React.RefObject<HTMLDivElement>;
|
||||
controlsRef: RefObject<HTMLDivElement>;
|
||||
isAnomalyView?: boolean;
|
||||
}
|
||||
|
||||
const GraphTab: FC<Props> = ({ isHistogram, graphData, controlsRef, isAnomalyView }) => {
|
||||
const { isMobile } = useDeviceDetect();
|
||||
|
||||
const { customStep, yaxis, spanGaps } = useGraphState();
|
||||
const { customStep, yaxis, spanGaps, showAllPoints } = useGraphState();
|
||||
const { period } = useTimeState();
|
||||
const { query } = useQueryState();
|
||||
|
||||
const timeDispatch = useTimeDispatch();
|
||||
const graphDispatch = useGraphDispatch();
|
||||
|
||||
const setYaxisLimits = (limits: AxisRange) => {
|
||||
const setYaxisLimits = useCallback((limits: AxisRange) => {
|
||||
graphDispatch({ type: "SET_YAXIS_LIMITS", payload: limits });
|
||||
};
|
||||
}, [graphDispatch]);
|
||||
|
||||
const toggleEnableLimits = () => {
|
||||
const toggleEnableLimits = useCallback(() => {
|
||||
graphDispatch({ type: "TOGGLE_ENABLE_YAXIS_LIMITS" });
|
||||
};
|
||||
}, [graphDispatch]);
|
||||
|
||||
const setSpanGaps = (value: boolean) => {
|
||||
const setSpanGaps = useCallback((value: boolean) => {
|
||||
graphDispatch({ type: "SET_SPAN_GAPS", payload: value });
|
||||
};
|
||||
}, [graphDispatch]);
|
||||
|
||||
const setPeriod = ({ from, to }: {from: Date, to: Date}) => {
|
||||
const setPeriod = useCallback(({ from, to }: { from: Date; to: Date }) => {
|
||||
timeDispatch({ type: "SET_PERIOD", payload: { from, to } });
|
||||
};
|
||||
}, [timeDispatch]);
|
||||
|
||||
const setShowPoints = useCallback((value: boolean) => {
|
||||
graphDispatch({ type: "SET_SHOW_POINTS", payload: value });
|
||||
}, [graphDispatch]);
|
||||
|
||||
const controls = (
|
||||
<div className="vm-custom-panel-body-header__graph-controls">
|
||||
@@ -53,6 +56,7 @@ const GraphTab: FC<Props> = ({ isHistogram, graphData, controlsRef, isAnomalyVie
|
||||
setYaxisLimits={setYaxisLimits}
|
||||
toggleEnableLimits={toggleEnableLimits}
|
||||
spanGaps={{ value: spanGaps, onChange: setSpanGaps }}
|
||||
showAllPoints={{ value: showAllPoints, onChange: setShowPoints }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -72,6 +76,7 @@ const GraphTab: FC<Props> = ({ isHistogram, graphData, controlsRef, isAnomalyVie
|
||||
isHistogram={isHistogram}
|
||||
isAnomalyView={isAnomalyView}
|
||||
spanGaps={spanGaps}
|
||||
showAllPoints={showAllPoints}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -31,7 +31,7 @@ import QueryEditorAutocomplete from "../../../components/Configurators/QueryEdit
|
||||
import { getUpdatedHistory } from "../../../components/QueryHistory/utils";
|
||||
|
||||
export interface QueryConfiguratorProps {
|
||||
queryErrors: string[];
|
||||
queryErrors?: string[];
|
||||
setQueryErrors: Dispatch<SetStateAction<string[]>>;
|
||||
setHideError: Dispatch<SetStateAction<boolean>>;
|
||||
stats: QueryStats[];
|
||||
@@ -217,7 +217,7 @@ const QueryConfigurator: FC<QueryConfiguratorProps> = ({
|
||||
value={stateQuery[i]}
|
||||
autocomplete={!hideButtons?.autocomplete && (autocomplete || autocompleteQuick)}
|
||||
autocompleteEl={QueryEditorAutocomplete}
|
||||
error={queryErrors[i]}
|
||||
error={queryErrors && queryErrors[i]}
|
||||
stats={stats[i]}
|
||||
onArrowUp={createHandlerArrow(-1, i)}
|
||||
onArrowDown={createHandlerArrow(1, i)}
|
||||
|
||||
@@ -83,17 +83,17 @@ const CustomPanel: FC = () => {
|
||||
const showInstantQueryTip = !liveData?.length && (displayType !== DisplayType.chart);
|
||||
const showError = !hideError && error;
|
||||
|
||||
const handleHideQuery = (queries: number[]) => {
|
||||
const handleHideQuery = useCallback((queries: number[]) => {
|
||||
setHideQuery(queries);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleRunQuery = () => {
|
||||
const handleRunQuery = useCallback(() => {
|
||||
setHideError(false);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
graphDispatch({ type: "SET_IS_HISTOGRAM", payload: isHistogram });
|
||||
}, [graphData]);
|
||||
}, [isHistogram]);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -103,7 +103,7 @@ const CustomPanel: FC = () => {
|
||||
})}
|
||||
>
|
||||
<QueryConfigurator
|
||||
queryErrors={!hideError ? queryErrors : []}
|
||||
queryErrors={!hideError ? queryErrors : undefined}
|
||||
setQueryErrors={setQueryErrors}
|
||||
setHideError={setHideError}
|
||||
stats={queryStats}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { FC, useEffect, useState } from "preact/compat";
|
||||
import { useLocation } from "react-router";
|
||||
import { FC, useState } from "preact/compat";
|
||||
import { useNotifiersSetQueryParams as useSetQueryParams } from "./hooks/useSetQueryParams";
|
||||
import Spinner from "../../components/Main/Spinner/Spinner";
|
||||
import Alert from "../../components/Main/Alert/Alert";
|
||||
@@ -33,37 +32,6 @@ const ExploreNotifiers: FC = () => {
|
||||
search: searchInput,
|
||||
});
|
||||
|
||||
const location = useLocation();
|
||||
const pageLoaded = !isLoading && !error && !!notifiers?.length;
|
||||
const savedScrollTop = localStorage.getItem("scrollTop");
|
||||
|
||||
useEffect(() => {
|
||||
if (!pageLoaded) return;
|
||||
if (location.hash) {
|
||||
const target = document.querySelector(location.hash);
|
||||
if (target) {
|
||||
let parent = target.closest("details");
|
||||
while (parent) {
|
||||
parent.open = true;
|
||||
if (!parent?.parentElement) return;
|
||||
parent = parent.parentElement.closest("details");
|
||||
}
|
||||
target.scrollIntoView();
|
||||
}
|
||||
} else {
|
||||
if (savedScrollTop) {
|
||||
window.scrollTo(0, parseInt(savedScrollTop));
|
||||
}
|
||||
const handleBeforeUnload = () => {
|
||||
localStorage.setItem("scrollTop", (window.scrollY || 0).toString());
|
||||
};
|
||||
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||
return () => {
|
||||
window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||
};
|
||||
}
|
||||
}, [location, savedScrollTop, pageLoaded]);
|
||||
|
||||
const handleChangeSearch = (input: string) => {
|
||||
if (!input) {
|
||||
setSearchInput("");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { FC, useEffect, useMemo, useState, useCallback } from "preact/compat";
|
||||
import { useNavigate, useLocation, useSearchParams } from "react-router";
|
||||
import { useSearchParams } from "react-router";
|
||||
import { useRulesSetQueryParams as useSetQueryParams } from "./hooks/useSetQueryParams";
|
||||
import Spinner from "../../components/Main/Spinner/Spinner";
|
||||
import Alert from "../../components/Main/Alert/Alert";
|
||||
@@ -33,16 +33,9 @@ const ExploreRules: FC = () => {
|
||||
const [modalOpen, setModalOpen] = useState(true);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
if (!location.hash && groupId) {
|
||||
setModalOpen(true);
|
||||
} else {
|
||||
setModalOpen(false);
|
||||
}
|
||||
}, [location.hash, groupId]);
|
||||
setModalOpen(!!groupId);
|
||||
}, [groupId]);
|
||||
|
||||
useSetQueryParams({
|
||||
types: types.join("&"),
|
||||
@@ -62,29 +55,29 @@ const ExploreRules: FC = () => {
|
||||
}, [searchInput]);
|
||||
|
||||
const getModal = () => {
|
||||
if (ruleId !== "") {
|
||||
if (ruleId) {
|
||||
return (
|
||||
<ExploreRule
|
||||
groupId={groupId}
|
||||
id={ruleId}
|
||||
mode={ruleId !== "" ? "rule" : "alert"}
|
||||
onClose={handleClose(`rule-${ruleId}`)}
|
||||
mode={ruleId ? "rule" : "alert"}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
);
|
||||
} else if (alertId !== "") {
|
||||
} else if (alertId) {
|
||||
return (
|
||||
<ExploreAlert
|
||||
groupId={groupId}
|
||||
id={alertId}
|
||||
mode={ruleId !== "" ? "rule" : "alert"}
|
||||
onClose={handleClose(`alert-${alertId}`)}
|
||||
mode={ruleId ? "rule" : "alert"}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
);
|
||||
} else if (groupId !== "") {
|
||||
} else if (groupId) {
|
||||
return (
|
||||
<ExploreGroup
|
||||
id={groupId}
|
||||
onClose={handleClose(`group-${groupId}`)}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -92,18 +85,13 @@ const ExploreRules: FC = () => {
|
||||
|
||||
const noRuleFound = "No rules found!";
|
||||
|
||||
const handleClose = (id: string) => {
|
||||
return () => {
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
newParams.delete("group_id");
|
||||
newParams.delete("rule_id");
|
||||
newParams.delete("alert_id");
|
||||
setSearchParams(newParams);
|
||||
setModalOpen(false);
|
||||
navigate({
|
||||
hash: `#${id}`,
|
||||
});
|
||||
};
|
||||
const handleClose = () => {
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
newParams.delete("group_id");
|
||||
newParams.delete("rule_id");
|
||||
newParams.delete("alert_id");
|
||||
setSearchParams(newParams);
|
||||
setModalOpen(false);
|
||||
};
|
||||
|
||||
const {
|
||||
@@ -112,36 +100,6 @@ const ExploreRules: FC = () => {
|
||||
error,
|
||||
} = useFetchGroups({ blockFetch: modalOpen });
|
||||
|
||||
const pageLoaded = !isLoading && !error && !!groups?.length;
|
||||
const savedScrollTop = localStorage.getItem("scrollTop");
|
||||
|
||||
useEffect(() => {
|
||||
if (!pageLoaded) return;
|
||||
if (location.hash) {
|
||||
const target = document.querySelector(location.hash);
|
||||
if (target) {
|
||||
let parent = target.closest("details");
|
||||
while (parent) {
|
||||
parent.open = true;
|
||||
if (!parent?.parentElement) return;
|
||||
parent = parent.parentElement.closest("details");
|
||||
}
|
||||
target.scrollIntoView();
|
||||
}
|
||||
} else {
|
||||
if (savedScrollTop) {
|
||||
window.scrollTo(0, parseInt(savedScrollTop));
|
||||
}
|
||||
const updateScrollPosition = () => {
|
||||
localStorage.setItem("scrollTop", (window.scrollY || 0).toString());
|
||||
};
|
||||
window.addEventListener("scroll", updateScrollPosition);
|
||||
return () => {
|
||||
window.removeEventListener("scroll", updateScrollPosition);
|
||||
};
|
||||
}
|
||||
}, [location, savedScrollTop, pageLoaded]);
|
||||
|
||||
const { filteredGroups, allTypes, allStates } = useMemo(
|
||||
() => filterGroups(groups || [], types, states, searchInput),
|
||||
[groups, types, states, searchInput]
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
@use "src/styles/variables" as *;
|
||||
|
||||
.vm-explore-alert-group {
|
||||
content-visibility: auto;
|
||||
width: 100%;
|
||||
&:has(.vm-accordion-header_open) {
|
||||
border: $border-divider;
|
||||
border-radius: $border-radius-small;
|
||||
}
|
||||
}
|
||||
|
||||
.vm-explore-alerts.vm-modal {
|
||||
|
||||
@@ -35,6 +35,7 @@ const PredefinedPanel: FC<PredefinedPanelsProps> = ({
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [spanGaps, setSpanGaps] = useState(false);
|
||||
const [showAllPoints, setShowPoints] = useState(false);
|
||||
const [yaxis, setYaxis] = useState<YaxisState>({
|
||||
limits: {
|
||||
enable: false,
|
||||
@@ -124,6 +125,7 @@ const PredefinedPanel: FC<PredefinedPanelsProps> = ({
|
||||
setYaxisLimits={setYaxisLimits}
|
||||
toggleEnableLimits={toggleEnableLimits}
|
||||
spanGaps={{ value: spanGaps, onChange: setSpanGaps }}
|
||||
showAllPoints={{ value: showAllPoints, onChange: setShowPoints }}
|
||||
/>
|
||||
</div>
|
||||
<div className="vm-predefined-panel-body">
|
||||
@@ -145,6 +147,7 @@ const PredefinedPanel: FC<PredefinedPanelsProps> = ({
|
||||
fullWidth={false}
|
||||
height={isMobile ? window.innerHeight * 0.5 : 500}
|
||||
spanGaps={spanGaps}
|
||||
showAllPoints={showAllPoints}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -53,7 +53,7 @@ const QueryAnalyzerView: FC<Props> = ({ data, period }) => {
|
||||
}, [data]);
|
||||
const [displayType, setDisplayType] = useState(tabs[0].value);
|
||||
|
||||
const { yaxis, spanGaps } = useGraphState();
|
||||
const { yaxis, spanGaps, showAllPoints } = useGraphState();
|
||||
const graphDispatch = useGraphDispatch();
|
||||
|
||||
const setYaxisLimits = (limits: AxisRange) => {
|
||||
@@ -68,6 +68,10 @@ const QueryAnalyzerView: FC<Props> = ({ data, period }) => {
|
||||
graphDispatch({ type: "SET_SPAN_GAPS", payload: value });
|
||||
};
|
||||
|
||||
const setShowPoints = (value: boolean) => {
|
||||
graphDispatch({ type: "SET_SHOW_POINTS", payload: value });
|
||||
};
|
||||
|
||||
const handleChangeDisplayType = (newValue: string) => {
|
||||
setDisplayType(newValue as DisplayType);
|
||||
};
|
||||
@@ -154,6 +158,7 @@ const QueryAnalyzerView: FC<Props> = ({ data, period }) => {
|
||||
setYaxisLimits={setYaxisLimits}
|
||||
toggleEnableLimits={toggleEnableLimits}
|
||||
spanGaps={{ value: spanGaps, onChange: setSpanGaps }}
|
||||
showAllPoints={{ value: showAllPoints, onChange: setShowPoints }}
|
||||
/>
|
||||
)}
|
||||
{displayType === "table" && (
|
||||
@@ -179,6 +184,7 @@ const QueryAnalyzerView: FC<Props> = ({ data, period }) => {
|
||||
height={isMobile ? window.innerHeight * 0.5 : 500}
|
||||
isHistogram={isHistogram}
|
||||
spanGaps={spanGaps}
|
||||
showAllPoints={showAllPoints}
|
||||
/>
|
||||
)}
|
||||
{liveData && (displayType === "code") && (
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useFetchTopQueries } from "./hooks/useFetchTopQueries";
|
||||
import Spinner from "../../components/Main/Spinner/Spinner";
|
||||
import TopQueryPanel from "./TopQueryPanel/TopQueryPanel";
|
||||
import { formatPrettyNumber } from "../../utils/uplot";
|
||||
import { isSupportedDuration } from "../../utils/time";
|
||||
import { parseSupportedDuration } from "../../utils/time";
|
||||
import dayjs from "dayjs";
|
||||
import { TopQueryStats } from "../../types";
|
||||
import Button from "../../components/Main/Button/Button";
|
||||
@@ -29,7 +29,7 @@ const TopQueries: FC = () => {
|
||||
const maxLifetimeValid = useMemo(() => {
|
||||
const durItems = maxLifetime.trim().split(" ");
|
||||
const durObject = durItems.reduce((prev, curr) => {
|
||||
const dur = isSupportedDuration(curr);
|
||||
const dur = parseSupportedDuration(curr);
|
||||
return dur ? { ...prev, ...dur } : { ...prev };
|
||||
}, {});
|
||||
const delta = dayjs.duration(durObject).asMilliseconds();
|
||||
|
||||
@@ -71,7 +71,15 @@ export const routerOptions: { [key: string]: RouterOptions } = {
|
||||
[router.home]: getDefaultOptions(APP_TYPE),
|
||||
[router.rawQuery]: {
|
||||
title: "Raw query",
|
||||
...routerOptionsDefault,
|
||||
header: {
|
||||
tenant: true,
|
||||
stepControl: false,
|
||||
timeSelector: true,
|
||||
executionControls: {
|
||||
tooltip: "Refresh dashboard",
|
||||
useAutorefresh: true,
|
||||
}
|
||||
},
|
||||
},
|
||||
[router.metrics]: {
|
||||
title: "Explore Prometheus metrics",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getQueryStringValue } from "../../utils/query-string";
|
||||
import { getFromStorage, saveToStorage } from "../../utils/storage";
|
||||
|
||||
export interface AxisRange {
|
||||
[key: string]: [number, number]
|
||||
@@ -18,6 +19,7 @@ export interface GraphState {
|
||||
isEmptyHistogram: boolean
|
||||
/** when true, null data values will not cause line breaks */
|
||||
spanGaps: boolean
|
||||
showAllPoints: boolean
|
||||
openSettings: boolean
|
||||
}
|
||||
|
||||
@@ -28,6 +30,7 @@ export type GraphAction =
|
||||
| { type: "SET_IS_HISTOGRAM", payload: boolean }
|
||||
| { type: "SET_IS_EMPTY_HISTOGRAM", payload: boolean }
|
||||
| { type: "SET_SPAN_GAPS", payload: boolean }
|
||||
| { type: "SET_SHOW_POINTS", payload: boolean }
|
||||
| { type: "SET_OPEN_SETTINGS", payload: boolean }
|
||||
|
||||
export const initialGraphState: GraphState = {
|
||||
@@ -38,6 +41,7 @@ export const initialGraphState: GraphState = {
|
||||
isHistogram: false,
|
||||
isEmptyHistogram: false,
|
||||
spanGaps: false,
|
||||
showAllPoints: Boolean(getFromStorage("POINTS_SHOW_ALL")),
|
||||
openSettings: false
|
||||
};
|
||||
|
||||
@@ -85,6 +89,12 @@ export function reducer(state: GraphState, action: GraphAction): GraphState {
|
||||
...state,
|
||||
spanGaps: action.payload
|
||||
};
|
||||
case "SET_SHOW_POINTS":
|
||||
saveToStorage("POINTS_SHOW_ALL", action.payload);
|
||||
return {
|
||||
...state,
|
||||
showAllPoints: action.payload
|
||||
};
|
||||
case "SET_OPEN_SETTINGS":
|
||||
return {
|
||||
...state,
|
||||
|
||||
@@ -14,7 +14,6 @@ export interface SeriesItemStatsFormatted {
|
||||
min: string,
|
||||
max: string,
|
||||
median: string,
|
||||
last: string
|
||||
}
|
||||
|
||||
export interface SeriesItem extends Series {
|
||||
|
||||
144
app/vmui/packages/vmui/src/utils/math.test.ts
Normal file
144
app/vmui/packages/vmui/src/utils/math.test.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { getMathStats } from "./math";
|
||||
|
||||
const finite = (x: number) => Number.isFinite(x);
|
||||
|
||||
const expectedMin = (arr: number[]): number | null => {
|
||||
const vals = arr.filter(finite);
|
||||
return vals.length ? Math.min(...vals) : null;
|
||||
};
|
||||
|
||||
const expectedMax = (arr: number[]): number | null => {
|
||||
const vals = arr.filter(finite);
|
||||
return vals.length ? Math.max(...vals) : null;
|
||||
};
|
||||
|
||||
const expectedAvg = (arr: number[]): number | null => {
|
||||
const vals = arr.filter(finite);
|
||||
if (!vals.length) return null;
|
||||
const sum = vals.reduce((s, x) => s + x, 0);
|
||||
return sum / vals.length;
|
||||
};
|
||||
|
||||
const expectedMedian = (arr: number[]): number | null => {
|
||||
const vals = arr.filter(finite).slice().sort((a, b) => a - b);
|
||||
const m = vals.length;
|
||||
if (!m) return null;
|
||||
const k = m >> 1;
|
||||
return m & 1 ? vals[k] : (vals[k - 1] + vals[k]) / 2;
|
||||
};
|
||||
|
||||
describe("getMathStats — basics", () => {
|
||||
it("returns all nulls when no options are requested", () => {
|
||||
const a = [1, 2, 3];
|
||||
const r = getMathStats(a, {});
|
||||
expect(r).toEqual({ min: null, max: null, median: null, avg: null });
|
||||
});
|
||||
|
||||
it("does not mutate the input array", () => {
|
||||
const a = [7, 3, 10, -2, 5];
|
||||
const before = a.slice();
|
||||
getMathStats(a, { min: true, max: true, median: true, avg: true });
|
||||
expect(a).toEqual(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getMathStats — individual metrics", () => {
|
||||
const arrays = [
|
||||
[7, 3, 10, -2, 5],
|
||||
[0, -0, 0, 0.5, 0.25, -1.25],
|
||||
[100],
|
||||
[NaN, Infinity, -Infinity, 42],
|
||||
[NaN, Infinity, -Infinity],
|
||||
[],
|
||||
];
|
||||
|
||||
it("min only", () => {
|
||||
for (const a of arrays) {
|
||||
const r = getMathStats(a, { min: true });
|
||||
expect(r.min).toBe(expectedMin(a));
|
||||
expect(r.max).toBeNull();
|
||||
expect(r.avg).toBeNull();
|
||||
expect(r.median).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("max only", () => {
|
||||
for (const a of arrays) {
|
||||
const r = getMathStats(a, { max: true });
|
||||
expect(r.max).toBe(expectedMax(a));
|
||||
expect(r.min).toBeNull();
|
||||
expect(r.avg).toBeNull();
|
||||
expect(r.median).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("average only", () => {
|
||||
for (const a of arrays) {
|
||||
const r = getMathStats(a, { avg: true });
|
||||
const exp = expectedAvg(a);
|
||||
if (exp == null) {
|
||||
expect(r.avg).toBeNull();
|
||||
} else {
|
||||
expect(r.avg!).toBeCloseTo(exp, 12);
|
||||
}
|
||||
expect(r.min).toBeNull();
|
||||
expect(r.max).toBeNull();
|
||||
expect(r.median).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("median only (odd/even, with non-finite filtered)", () => {
|
||||
const cases = [
|
||||
[7, 3, 10, -2, 5], // odd
|
||||
[7, 3, 10, -2, 5, 8], // even
|
||||
[NaN, Infinity, 3, 3, 3, 1], // duplicates + non-finite
|
||||
[100], // single
|
||||
[], // empty
|
||||
[NaN, Infinity, -Infinity], // only non-finite
|
||||
];
|
||||
for (const a of cases) {
|
||||
const r = getMathStats(a, { median: true });
|
||||
const exp = expectedMedian(a);
|
||||
if (exp == null) {
|
||||
expect(r.median).toBeNull();
|
||||
} else {
|
||||
expect(r.median!).toBeCloseTo(exp, 12);
|
||||
}
|
||||
expect(r.min).toBeNull();
|
||||
expect(r.max).toBeNull();
|
||||
expect(r.avg).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("getMathStats — combined metrics", () => {
|
||||
it("all metrics together", () => {
|
||||
const a = [7, 3, 10, -2, 5, NaN, Infinity];
|
||||
const r = getMathStats(a, { min: true, max: true, avg: true, median: true });
|
||||
|
||||
// expected values computed independently
|
||||
const expMin = expectedMin(a);
|
||||
const expMax = expectedMax(a);
|
||||
const expAvg = expectedAvg(a);
|
||||
const expMedian = expectedMedian(a);
|
||||
|
||||
expect(r.min).toBe(expMin);
|
||||
expect(r.max).toBe(expMax);
|
||||
if (expAvg == null) expect(r.avg).toBeNull();
|
||||
else expect(r.avg!).toBeCloseTo(expAvg, 12);
|
||||
if (expMedian == null) expect(r.median).toBeNull();
|
||||
else expect(r.median!).toBeCloseTo(expMedian, 12);
|
||||
});
|
||||
|
||||
it("does not return median when not requested", () => {
|
||||
const a = [9, 1, 5, 3, 7, 2, 11, 4];
|
||||
const r = getMathStats(a, { min: true, max: true, avg: true /* median: false */ });
|
||||
expect(r.min).toBe(expectedMin(a));
|
||||
expect(r.max).toBe(expectedMax(a));
|
||||
const expAvg = expectedAvg(a)!;
|
||||
expect(r.avg!).toBeCloseTo(expAvg, 12);
|
||||
// IMPORTANT: median should be null if not requested
|
||||
expect(r.median).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,71 +1,86 @@
|
||||
export const getMaxFromArray = (a: number[]) => {
|
||||
let len = a.length;
|
||||
let max = -Infinity;
|
||||
while (len--) {
|
||||
const v = a[len];
|
||||
if (Number.isFinite(v) && v > max) {
|
||||
max = v;
|
||||
}
|
||||
}
|
||||
return Number.isFinite(max) ? max : null;
|
||||
import quickselect from "./quickselect";
|
||||
|
||||
export const roundToThousandths = (num: number): number => Math.round(num*1000)/1000;
|
||||
|
||||
type MathStatsOptions = {
|
||||
min?: boolean;
|
||||
max?: boolean;
|
||||
median?: boolean;
|
||||
avg?: boolean;
|
||||
};
|
||||
|
||||
export const getMinFromArray = (a: number[]) => {
|
||||
let len = a.length;
|
||||
let min = Infinity;
|
||||
while (len--) {
|
||||
const v = a[len];
|
||||
if (Number.isFinite(v) && v < min) {
|
||||
min = v;
|
||||
}
|
||||
}
|
||||
return Number.isFinite(min) ? min : null;
|
||||
type MathStatsResult = {
|
||||
min: number | null;
|
||||
max: number | null;
|
||||
median: number | null;
|
||||
avg: number | null;
|
||||
};
|
||||
|
||||
export const getAvgFromArray = (a: number[]) => {
|
||||
let mean = a[0];
|
||||
let n = 1;
|
||||
for (let i = 1; i < a.length; i++) {
|
||||
const v = a[i];
|
||||
if (Number.isFinite(v)) {
|
||||
mean = mean * (n-1)/n + v / n;
|
||||
n++;
|
||||
}
|
||||
/**
|
||||
* Returns median of finite numbers in `vals`.
|
||||
* MUTATES `vals` in place (uses quickselect).
|
||||
*/
|
||||
const medianFromFiniteInPlace = (vals: number[]): number | null => {
|
||||
const m = vals.length;
|
||||
if (m === 0) return null;
|
||||
|
||||
const k = m >> 1;
|
||||
quickselect(vals, k); // place upper median at vals[k]
|
||||
const upper = vals[k];
|
||||
|
||||
if (m & 1) return upper; // odd length
|
||||
|
||||
// even length: take max of the left half [0..k-1]
|
||||
let lower = -Infinity;
|
||||
for (let i = 0; i < k; i++) {
|
||||
const v = vals[i];
|
||||
if (v > lower) lower = v;
|
||||
}
|
||||
return mean;
|
||||
return (lower + upper) / 2;
|
||||
};
|
||||
|
||||
export const getMedianFromArray = (a: number[]) => {
|
||||
let len = a.length;
|
||||
const aCopy = [];
|
||||
while (len--) {
|
||||
const v = a[len];
|
||||
if (Number.isFinite(v)) {
|
||||
aCopy.push(v);
|
||||
}
|
||||
}
|
||||
aCopy.sort();
|
||||
return aCopy[aCopy.length>>1];
|
||||
};
|
||||
export const getMathStats = (
|
||||
a: (number | null)[],
|
||||
ops: MathStatsOptions
|
||||
): MathStatsResult => {
|
||||
const needMin = !!ops.min;
|
||||
const needMax = !!ops.max;
|
||||
const needAvg = !!ops.avg;
|
||||
const needMedian = !!ops.median;
|
||||
|
||||
export const getLastFromArray = (a: number[]) => {
|
||||
let len = a.length;
|
||||
while (len--) {
|
||||
const v = a[len];
|
||||
if (Number.isFinite(v)) {
|
||||
return v;
|
||||
}
|
||||
if (!needMin && !needMax && !needAvg && !needMedian) {
|
||||
return { min: null, max: null, median: null, avg: null };
|
||||
}
|
||||
};
|
||||
|
||||
export const formatNumberShort = (value: number) => {
|
||||
if (value >= 1_000_000_000) {
|
||||
return (value / 1_000_000_000).toFixed(1).replace(/\.0$/, "") + "B";
|
||||
} else if (value >= 1_000_000) {
|
||||
return (value / 1_000_000).toFixed(1).replace(/\.0$/, "") + "M";
|
||||
} else if (value >= 1_000) {
|
||||
return (value / 1_000).toFixed(1).replace(/\.0$/, "") + "K";
|
||||
} else {
|
||||
return value.toString();
|
||||
// min & max
|
||||
let minVal = Infinity;
|
||||
let maxVal = -Infinity;
|
||||
|
||||
// average
|
||||
let avgVal = 0;
|
||||
let avgCount = 0;
|
||||
|
||||
// collect finite values for median
|
||||
const vals: number[] = [];
|
||||
|
||||
for (const v of a) {
|
||||
if (v == null || !Number.isFinite(v)) continue;
|
||||
|
||||
if (needMin && v < minVal) minVal = v;
|
||||
if (needMax && v > maxVal) maxVal = v;
|
||||
|
||||
if (needAvg) {
|
||||
avgCount++;
|
||||
avgVal += (v - avgVal) / avgCount;
|
||||
}
|
||||
|
||||
if (needMedian) vals.push(v);
|
||||
}
|
||||
|
||||
return {
|
||||
min: Number.isFinite(minVal) ? minVal : null,
|
||||
max: Number.isFinite(maxVal) ? maxVal : null,
|
||||
avg: avgCount ? avgVal : null,
|
||||
median: (vals && needMedian) ? medianFromFiniteInPlace(vals) : null,
|
||||
};
|
||||
};
|
||||
|
||||
207
app/vmui/packages/vmui/src/utils/quickselect.test.ts
Normal file
207
app/vmui/packages/vmui/src/utils/quickselect.test.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import quickselect from "./quickselect";
|
||||
|
||||
// Helper: verifies partition property around k using given comparator
|
||||
function checkPartition<T>(
|
||||
arr: T[],
|
||||
k: number,
|
||||
compare: (a: T, b: T) => number
|
||||
) {
|
||||
const pivot = arr[k];
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
if (i < k) {
|
||||
expect(compare(arr[i], pivot)).toBeLessThanOrEqual(0);
|
||||
} else if (i > k) {
|
||||
expect(compare(arr[i], pivot)).toBeGreaterThanOrEqual(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const numCmp = (a: number, b: number) => (a < b ? -1 : a > b ? 1 : 0);
|
||||
|
||||
describe("quickselect (numbers)", () => {
|
||||
it("finds k-th smallest on small array", () => {
|
||||
const arr = [7, 2, 9, 1, 5, 3];
|
||||
const k = 3;
|
||||
const orig = arr.slice();
|
||||
quickselect(arr, k);
|
||||
const sorted = orig.slice().sort(numCmp);
|
||||
expect(arr[k]).toBe(sorted[k]);
|
||||
checkPartition(arr, k, numCmp);
|
||||
});
|
||||
|
||||
it("works for k = 0 (minimum)", () => {
|
||||
const arr = [10, -1, 4, 4, 2];
|
||||
const orig = arr.slice();
|
||||
quickselect(arr, 0);
|
||||
expect(arr[0]).toBe(Math.min(...orig));
|
||||
checkPartition(arr, 0, numCmp);
|
||||
});
|
||||
|
||||
it("works for k = n-1 (maximum)", () => {
|
||||
const arr = [10, -1, 4, 4, 2];
|
||||
const k = arr.length - 1;
|
||||
const orig = arr.slice();
|
||||
quickselect(arr, k);
|
||||
expect(arr[k]).toBe(Math.max(...orig));
|
||||
checkPartition(arr, k, numCmp);
|
||||
});
|
||||
|
||||
it("handles duplicates correctly", () => {
|
||||
const arr = [5, 1, 3, 3, 3, 2, 5, 4];
|
||||
const k = 4;
|
||||
const orig = arr.slice();
|
||||
quickselect(arr, k);
|
||||
const sorted = orig.slice().sort(numCmp);
|
||||
expect(arr[k]).toBe(sorted[k]);
|
||||
checkPartition(arr, k, numCmp);
|
||||
});
|
||||
|
||||
it("handles negative numbers and mixed values", () => {
|
||||
const arr = [0, -100, 50, -3, 7, 7, 2, -1];
|
||||
const k = 2;
|
||||
const orig = arr.slice();
|
||||
quickselect(arr, k);
|
||||
const sorted = orig.slice().sort(numCmp);
|
||||
expect(arr[k]).toBe(sorted[k]);
|
||||
checkPartition(arr, k, numCmp);
|
||||
});
|
||||
|
||||
it("matches fully sorted array at many random ks", () => {
|
||||
for (let t = 0; t < 25; t++) {
|
||||
const n = 50;
|
||||
const arr = Array.from({ length: n }, () => Math.floor(Math.random() * 1000) - 500);
|
||||
const k = Math.floor(Math.random() * n);
|
||||
const orig = arr.slice();
|
||||
quickselect(arr, k);
|
||||
const sorted = orig.slice().sort(numCmp);
|
||||
expect(arr[k]).toBe(sorted[k]);
|
||||
checkPartition(arr, k, numCmp);
|
||||
}
|
||||
});
|
||||
|
||||
it("handles already sorted and reverse-sorted", () => {
|
||||
const asc = [1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
const k1 = 4;
|
||||
const origAsc = asc.slice();
|
||||
quickselect(asc, k1);
|
||||
expect(asc[k1]).toBe(origAsc[k1]);
|
||||
checkPartition(asc, k1, numCmp);
|
||||
|
||||
const desc = [9, 8, 7, 6, 5, 4, 3, 2, 1];
|
||||
const k2 = 3;
|
||||
const origDesc = desc.slice();
|
||||
quickselect(desc, k2);
|
||||
const sortedDesc = origDesc.slice().sort(numCmp);
|
||||
expect(desc[k2]).toBe(sortedDesc[k2]);
|
||||
checkPartition(desc, k2, numCmp);
|
||||
});
|
||||
|
||||
it("handles all-equal values", () => {
|
||||
const arr = Array(100).fill(42);
|
||||
const k = 50;
|
||||
quickselect(arr, k);
|
||||
expect(arr[k]).toBe(42);
|
||||
checkPartition(arr, k, numCmp);
|
||||
});
|
||||
});
|
||||
|
||||
describe("quickselect (custom comparator)", () => {
|
||||
it("works with strings (lexicographic)", () => {
|
||||
const arr = ["pear", "apple", "banana", "apricot", "kiwi"];
|
||||
const k = 2;
|
||||
const cmp = (a: string, b: string) => a.localeCompare(b);
|
||||
const orig = arr.slice();
|
||||
quickselect(arr, k, 0, arr.length - 1, cmp);
|
||||
const sorted = orig.slice().sort(cmp);
|
||||
expect(arr[k]).toBe(sorted[k]);
|
||||
checkPartition(arr, k, cmp);
|
||||
});
|
||||
|
||||
it("works with objects via custom comparator (by score asc)", () => {
|
||||
type Item = { id: string; score: number };
|
||||
const items: Item[] = [
|
||||
{ id: "a", score: 10 },
|
||||
{ id: "b", score: -3 },
|
||||
{ id: "c", score: 7 },
|
||||
{ id: "d", score: 7 },
|
||||
{ id: "e", score: 0 },
|
||||
];
|
||||
const k = 3;
|
||||
const cmp = (x: Item, y: Item) => (x.score < y.score ? -1 : x.score > y.score ? 1 : 0);
|
||||
|
||||
const orig = items.slice();
|
||||
quickselect(items, k, 0, items.length - 1, cmp);
|
||||
|
||||
const sorted = orig.slice().sort(cmp);
|
||||
expect(items[k].score).toBe(sorted[k].score);
|
||||
checkPartition(items, k, cmp);
|
||||
});
|
||||
});
|
||||
|
||||
describe("quickselect (bounds/segments)", () => {
|
||||
it("respects left/right boundaries (partial region)", () => {
|
||||
const arr = [9, 8, 7, 6, 5, 4, 3, 2, 1];
|
||||
// We'll only work inside [2..6], so k=4 is within that range.
|
||||
const left = 2;
|
||||
const right = 6;
|
||||
const k = 4;
|
||||
|
||||
const before = arr.slice();
|
||||
quickselect(arr, k, left, right);
|
||||
|
||||
// Elements outside [left..right] remain untouched
|
||||
expect(arr.slice(0, left)).toEqual(before.slice(0, left));
|
||||
expect(arr.slice(right + 1)).toEqual(before.slice(right + 1));
|
||||
|
||||
// Inside the segment, property holds
|
||||
const seg = arr.slice(left, right + 1);
|
||||
const segCmp = numCmp;
|
||||
checkPartition(seg, k - left, segCmp);
|
||||
|
||||
// And k-th inside the segment matches the k-th of the sorted segment
|
||||
const segSorted = before.slice(left, right + 1).sort(segCmp);
|
||||
expect(arr[k]).toBe(segSorted[k - left]);
|
||||
});
|
||||
|
||||
it("single-element segment is a no-op", () => {
|
||||
const arr = [5, 4, 3, 2, 1];
|
||||
const left = 2, right = 2, k = 2;
|
||||
const before = arr.slice();
|
||||
quickselect(arr, k, left, right);
|
||||
expect(arr).toEqual(before);
|
||||
});
|
||||
|
||||
it("k at segment boundaries (left/right)", () => {
|
||||
const arr1 = [7, 4, 6, 1, 9, 2, 5, 0];
|
||||
const left1 = 2, right1 = 6, k1 = left1;
|
||||
const segSorted1 = arr1.slice(left1, right1 + 1).slice().sort(numCmp);
|
||||
quickselect(arr1, k1, left1, right1);
|
||||
expect(arr1[k1]).toBe(segSorted1[0]);
|
||||
checkPartition(arr1.slice(left1, right1 + 1), k1 - left1, numCmp);
|
||||
|
||||
const arr2 = [7, 4, 6, 1, 9, 2, 5, 0];
|
||||
const left2 = 1, right2 = 5, k2 = right2;
|
||||
const segSorted2 = arr2.slice(left2, right2 + 1).slice().sort(numCmp);
|
||||
quickselect(arr2, k2, left2, right2);
|
||||
expect(arr2[k2]).toBe(segSorted2[segSorted2.length - 1]);
|
||||
checkPartition(arr2.slice(left2, right2 + 1), k2 - left2, numCmp);
|
||||
});
|
||||
});
|
||||
|
||||
describe("quickselect (Floyd–Rivest path)", () => {
|
||||
it("covers the large-array acceleration branch", () => {
|
||||
const n = 2000; // right - left = 1999 > 600 -> triggers acceleration
|
||||
const base = 2654435761; // Knuth multiplicative hash (32-bit after >>> 0)
|
||||
const arr = Array.from({ length: n }, (_, i) => ((i * base) >>> 0))
|
||||
.map(x => (x % 2000) - 1000); // keep numbers in a small range to include duplicates
|
||||
const k = Math.floor(n * 0.63);
|
||||
|
||||
const orig = arr.slice();
|
||||
quickselect(arr, k);
|
||||
const sorted = orig.slice().sort(numCmp);
|
||||
|
||||
expect(arr[k]).toBe(sorted[k]);
|
||||
checkPartition(arr, k, numCmp);
|
||||
});
|
||||
});
|
||||
58
app/vmui/packages/vmui/src/utils/quickselect.ts
Normal file
58
app/vmui/packages/vmui/src/utils/quickselect.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
// In-place quickselect: reorders the array so arr[k] is the k-th smallest (avg O(n));
|
||||
// uses a Floyd–Rivest speedup.
|
||||
|
||||
export default function quickselect<T>(
|
||||
arr: T[],
|
||||
k: number,
|
||||
left: number = 0,
|
||||
right: number = arr.length - 1,
|
||||
compare: (a: T, b: T) => number = defaultCompare as (a: T, b: T) => number
|
||||
): void {
|
||||
while (right > left) {
|
||||
if (right - left > 600) {
|
||||
const n = right - left + 1;
|
||||
const m = k - left + 1;
|
||||
const z = Math.log(n);
|
||||
const s = 0.5 * Math.exp((2 * z) / 3);
|
||||
const sd = 0.5 * Math.sqrt((z * s * (n - s)) / n) * (m - n / 2 < 0 ? -1 : 1);
|
||||
const newLeft = Math.max(left, Math.floor(k - (m * s) / n + sd));
|
||||
const newRight = Math.min(right, Math.floor(k + ((n - m) * s) / n + sd));
|
||||
quickselect(arr, k, newLeft, newRight, compare);
|
||||
}
|
||||
|
||||
const t = arr[k];
|
||||
let i = left;
|
||||
let j: number = right;
|
||||
|
||||
swap(arr, left, k);
|
||||
if (compare(arr[right], t) > 0) swap(arr, left, right);
|
||||
|
||||
while (i < j) {
|
||||
swap(arr, i, j);
|
||||
i++;
|
||||
j--;
|
||||
while (compare(arr[i], t) < 0) i++;
|
||||
while (compare(arr[j], t) > 0) j--;
|
||||
}
|
||||
|
||||
if (compare(arr[left], t) === 0) {
|
||||
swap(arr, left, j);
|
||||
} else {
|
||||
j++;
|
||||
swap(arr, j, right);
|
||||
}
|
||||
|
||||
if (j <= k) left = j + 1;
|
||||
if (k <= j) right = j - 1;
|
||||
}
|
||||
}
|
||||
|
||||
function swap<T>(arr: T[], i: number, j: number): void {
|
||||
const tmp = arr[i];
|
||||
arr[i] = arr[j];
|
||||
arr[j] = tmp;
|
||||
}
|
||||
|
||||
function defaultCompare(a: number, b: number): number {
|
||||
return a < b ? -1 : a > b ? 1 : 0;
|
||||
}
|
||||
@@ -16,6 +16,7 @@ export type StorageKeys = "AUTOCOMPLETE"
|
||||
| "METRICS_QUERY_HISTORY"
|
||||
| "SERVER_URL"
|
||||
| "RAW_JSON_LIVE_VIEW"
|
||||
| "POINTS_SHOW_ALL"
|
||||
| DeprecatedStorageKeys;
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import dayjs from "dayjs";
|
||||
import { getNanoTimestamp } from "./time";
|
||||
import { getNanoTimestamp, parseSupportedDuration } from "./time";
|
||||
|
||||
describe("Time utils", () => {
|
||||
describe("getNanoTimestamp", () => {
|
||||
@@ -50,4 +50,36 @@ describe("Time utils", () => {
|
||||
expect(getNanoTimestamp(dateStr)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseSupportedDuration (multi-unit)", () => {
|
||||
it("should parse single units", () => {
|
||||
expect(parseSupportedDuration("1h")).toEqual({ h: "1" });
|
||||
expect(parseSupportedDuration("1.5h")).toEqual({ h: "1.5" });
|
||||
expect(parseSupportedDuration(".5h")).toEqual({ h: ".5" });
|
||||
});
|
||||
|
||||
it("should parse multiple units", () => {
|
||||
expect(parseSupportedDuration("1h30m")).toEqual({ h: "1", m: "30" });
|
||||
expect(parseSupportedDuration("1h 30m")).toEqual({ h: "1", m: "30" });
|
||||
expect(parseSupportedDuration("1h30.333m")).toEqual({ h: "1", m: "30.333" });
|
||||
expect(parseSupportedDuration("2h15s")).toEqual({ h: "2", s: "15" });
|
||||
expect(parseSupportedDuration("1.5h10m30s")).toEqual({ h: "1.5", m: "10", s: "30" });
|
||||
});
|
||||
|
||||
it("should handle signs and commas", () => {
|
||||
expect(parseSupportedDuration("-2m")).toEqual({ m: "2" });
|
||||
expect(parseSupportedDuration("1,25h")).toEqual({ h: "1.25" });
|
||||
});
|
||||
|
||||
it("should ignore spaces", () => {
|
||||
expect(parseSupportedDuration(" 1h 30m")).toEqual({ h: "1", m: "30" });
|
||||
});
|
||||
|
||||
it("should return undefined for unsupported or invalid input", () => {
|
||||
expect(parseSupportedDuration("1x")).toBeUndefined();
|
||||
expect(parseSupportedDuration("abc")).toBeUndefined();
|
||||
expect(parseSupportedDuration("")).toBeUndefined();
|
||||
expect(parseSupportedDuration(" ")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import dayjs, { UnitTypeShort } from "dayjs";
|
||||
import { getQueryStringValue } from "./query-string";
|
||||
import { DATE_ISO_FORMAT } from "../constants/date";
|
||||
import timezones from "../constants/timezones";
|
||||
import { roundToThousandths } from "./math";
|
||||
|
||||
const MAX_ITEMS_PER_CHART = window.innerWidth / 4;
|
||||
const MAX_ITEMS_PER_HISTOGRAM = window.innerWidth / 40;
|
||||
@@ -28,14 +29,16 @@ export const supportedDurations = [
|
||||
|
||||
const shortDurations = supportedDurations.map(d => d.short);
|
||||
|
||||
export const roundToMilliseconds = (num: number): number => Math.round(num*1000)/1000;
|
||||
export const sameTs = (a: number, b: number) => {
|
||||
return roundToThousandths(a) === roundToThousandths(b);
|
||||
}
|
||||
|
||||
export const humanizeSeconds = (num: number): string => {
|
||||
return getDurationFromMilliseconds(dayjs.duration(num, "seconds").asMilliseconds());
|
||||
};
|
||||
|
||||
export const roundStep = (step: number): string => {
|
||||
let result = roundToMilliseconds(step);
|
||||
let result = roundToThousandths(step);
|
||||
const integerStep = Math.round(step);
|
||||
|
||||
if (step >= 100) {
|
||||
@@ -54,14 +57,24 @@ export const roundStep = (step: number): string => {
|
||||
return humanize.replace(/\s/g, "");
|
||||
};
|
||||
|
||||
export const isSupportedDuration = (str: string): Partial<Record<UnitTypeShort, string>> | undefined => {
|
||||
export const parseSupportedDuration = (str: string): Partial<Record<UnitTypeShort, string>> | undefined => {
|
||||
if (!str) return;
|
||||
|
||||
const digits = str.match(/\d+/g);
|
||||
const words = str.match(/[a-zA-Z]+/g);
|
||||
const s = str.trim().replace(/,/g, ".");
|
||||
const pairs = s.match(/\d*\.?\d+\s*[a-zA-Z]+/g);
|
||||
if (!pairs) return;
|
||||
|
||||
if (words && digits && shortDurations.includes(words[0])) {
|
||||
return { [words[0]]: digits[0] };
|
||||
const result: Partial<Record<UnitTypeShort, string>> = {};
|
||||
|
||||
for (const pair of pairs) {
|
||||
const digits = pair.match(/\d*\.?\d+/)?.[0];
|
||||
const word = pair.match(/[a-zA-Z]+/)?.[0]?.toLowerCase() as UnitTypeShort;
|
||||
|
||||
if (!digits || !word || !shortDurations.includes(word)) return;
|
||||
result[word] = digits;
|
||||
}
|
||||
|
||||
return Object.keys(result).length ? result : undefined;
|
||||
};
|
||||
|
||||
export const getSecondsFromDuration = (dur: string) => {
|
||||
@@ -71,7 +84,7 @@ export const getSecondsFromDuration = (dur: string) => {
|
||||
|
||||
const durObject = durItems.reduce((prev, curr) => {
|
||||
|
||||
const dur = isSupportedDuration(curr);
|
||||
const dur = parseSupportedDuration(curr);
|
||||
if (dur) {
|
||||
return {
|
||||
...prev,
|
||||
@@ -139,21 +152,6 @@ export const getDurationFromPeriod = (p: TimePeriod): string => {
|
||||
return getDurationFromMilliseconds(ms);
|
||||
};
|
||||
|
||||
export const checkDurationLimit = (dur: string): string => {
|
||||
const durItems = dur.trim().split(" ");
|
||||
|
||||
const durObject = durItems.reduce((prev, curr) => {
|
||||
const dur = isSupportedDuration(curr);
|
||||
return dur ? { ...prev, ...dur } : { ...prev };
|
||||
}, {});
|
||||
|
||||
const delta = dayjs.duration(durObject).asMilliseconds();
|
||||
|
||||
if (delta < limitsDurations.min) return getDurationFromMilliseconds(limitsDurations.min);
|
||||
if (delta > limitsDurations.max) return getDurationFromMilliseconds(limitsDurations.max);
|
||||
return dur;
|
||||
};
|
||||
|
||||
export const dateFromSeconds = (epochTimeInSeconds: number): Date => {
|
||||
const date = dayjs(epochTimeInSeconds * 1000);
|
||||
return date.isValid() ? date.toDate() : new Date();
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import uPlot, { Axis, Series } from "uplot";
|
||||
import { getMaxFromArray, getMinFromArray } from "../math";
|
||||
import { getSecondsFromDuration, roundToMilliseconds } from "../time";
|
||||
import { AxisRange } from "../../state/graph/reducer";
|
||||
import { roundToThousandths } from "../math";
|
||||
import { getSecondsFromDuration } from "../time";
|
||||
import { formatTicks, getTextWidth } from "./helpers";
|
||||
import { TimeParams } from "../../types";
|
||||
import { getCssVariable } from "../theme";
|
||||
@@ -19,45 +18,59 @@ const timeValues = [
|
||||
[0.001, ":{ss}.{fff}", "\n{YYYY}-{MM}-{DD} {HH}:{mm}", null, "\n{MM}-{DD} {HH}:{mm}", null, "\n{HH}:{mm}", null, 1],
|
||||
];
|
||||
|
||||
export const getAxes = (series: Series[], unit?: string): Axis[] => Array.from(new Set(series.map(s => s.scale))).map(a => {
|
||||
const font = "10px Arial";
|
||||
const stroke = getCssVariable("color-text");
|
||||
const axis = {
|
||||
scale: a,
|
||||
show: true,
|
||||
size: sizeAxis,
|
||||
stroke,
|
||||
font,
|
||||
values: (u: uPlot, ticks: number[]) => formatTicks(u, ticks, unit)
|
||||
};
|
||||
if (!a) return { space: 80, values: timeValues, stroke, font };
|
||||
if (!(Number(a) % 2) && a !== "y") return { ...axis, side: 1 };
|
||||
return axis;
|
||||
});
|
||||
export const getAxes = (series: Series[], unit?: string): Axis[] =>
|
||||
Array.from(new Set(series.map(s => s.scale))).map(a => {
|
||||
const font = "10px Arial";
|
||||
const stroke = getCssVariable("color-text");
|
||||
const axis = {
|
||||
scale: a,
|
||||
show: true,
|
||||
size: sizeAxis,
|
||||
stroke,
|
||||
font,
|
||||
values: (u: uPlot, ticks: number[]) => formatTicks(u, ticks, unit)
|
||||
};
|
||||
if (!a) return { space: 80, values: timeValues, stroke, font };
|
||||
if (!(Number(a) % 2) && a !== "y") return { ...axis, side: 1 };
|
||||
return axis;
|
||||
});
|
||||
|
||||
export const getTimeSeries = (times: number[], stepDuration: string, period: TimeParams): number[] => {
|
||||
const step = getSecondsFromDuration(stepDuration) || 1;
|
||||
const allTimes = Array.from(new Set(times)).sort((a, b) => a - b);
|
||||
let t = period.start;
|
||||
const tEnd = roundToMilliseconds(period.end + step);
|
||||
let j = 0;
|
||||
const results: number[] = [];
|
||||
while (t <= tEnd) {
|
||||
while (j < allTimes.length && allTimes[j] <= t) {
|
||||
t = allTimes[j];
|
||||
j++;
|
||||
results.push(t);
|
||||
}
|
||||
t = roundToMilliseconds(t + step);
|
||||
if (j >= allTimes.length || allTimes[j] > t) {
|
||||
results.push(t);
|
||||
}
|
||||
export const getTimeSeries = (
|
||||
stepDuration: string,
|
||||
period: TimeParams,
|
||||
pixels: number,
|
||||
tsAnchor?: number,
|
||||
) => {
|
||||
const tStart = roundToThousandths(period.start);
|
||||
const tEnd = roundToThousandths(period.end);
|
||||
const baseStep = getSecondsFromDuration(stepDuration) || 0.001;
|
||||
const step = Math.max(0.001, roundToThousandths(baseStep))
|
||||
|
||||
const anchor = roundToThousandths(tsAnchor ?? tStart);
|
||||
|
||||
const posMod = (a: number, s: number) => {
|
||||
const r = a % s;
|
||||
return r < 0 ? r + s : r;
|
||||
};
|
||||
|
||||
const phase = posMod(anchor, step);
|
||||
let firstTick = roundToThousandths(tStart + posMod(phase - posMod(tStart, step), step));
|
||||
if (firstTick < tStart) firstTick = roundToThousandths(firstTick + step);
|
||||
if (firstTick > tEnd) return [tStart, tEnd];
|
||||
|
||||
const fullCount = Math.floor((tEnd - firstTick) / step) + 1;
|
||||
|
||||
const stride = Math.max(1, Math.ceil(fullCount / pixels));
|
||||
const stepOut = Math.max(0.001, roundToThousandths(step * stride));
|
||||
|
||||
const totalPoints = Math.min(pixels, Math.floor((tEnd - firstTick) / stepOut) + 1);
|
||||
const out = new Array<number>(totalPoints);
|
||||
|
||||
for (let k = 0; k < totalPoints; k++) {
|
||||
out[k] = roundToThousandths(firstTick + k * stepOut);
|
||||
}
|
||||
while (results.length < 2) {
|
||||
results.push(t);
|
||||
t = roundToMilliseconds(t + step);
|
||||
}
|
||||
return results;
|
||||
|
||||
return out;
|
||||
};
|
||||
|
||||
export const getMinMaxBuffer = (min: number | null, max: number | null): [number, number] => {
|
||||
@@ -65,20 +78,10 @@ export const getMinMaxBuffer = (min: number | null, max: number | null): [number
|
||||
return [-1, 1];
|
||||
}
|
||||
const valueRange = Math.abs(max - min) || Math.abs(min) || 1;
|
||||
const padding = 0.02*valueRange;
|
||||
const padding = 0.02 * valueRange;
|
||||
return [min - padding, max + padding];
|
||||
};
|
||||
|
||||
export const getLimitsYAxis = (values: { [key: string]: number[] }, buffer: boolean): AxisRange => {
|
||||
const result: AxisRange = {};
|
||||
const numbers = Object.values(values).flat();
|
||||
const key = "1";
|
||||
const min = getMinFromArray(numbers) || 0;
|
||||
const max = getMaxFromArray(numbers) || 1;
|
||||
result[key] = buffer ? getMinMaxBuffer(min, max) : [min, max];
|
||||
return result;
|
||||
};
|
||||
|
||||
export const sizeAxis = (u: uPlot, values: string[], axisIdx: number, cycleNum: number): number => {
|
||||
const axis = u.axes[axisIdx] as AxisExtend;
|
||||
|
||||
|
||||
111
app/vmui/packages/vmui/src/utils/uplot/scatter.ts
Normal file
111
app/vmui/packages/vmui/src/utils/uplot/scatter.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import uPlot, { OrientCallback } from "uplot";
|
||||
|
||||
const deg360 = 2 * Math.PI;
|
||||
|
||||
// Base point size multiplier (in device pixels)
|
||||
const BASE_POINT_SIZE = 4;
|
||||
|
||||
// Square size scale relative to circle size
|
||||
const SQUARE_SIZE_SCALE = 1.2;
|
||||
|
||||
export const drawPoints = (u: uPlot, seriesIdx: number) => {
|
||||
const size = BASE_POINT_SIZE * uPlot.pxRatio;
|
||||
const r = size / 2;
|
||||
const squareSize = size * SQUARE_SIZE_SCALE;
|
||||
const squareHalf = squareSize / 2;
|
||||
|
||||
const orientCallback: OrientCallback = (
|
||||
series,
|
||||
dataX,
|
||||
dataY,
|
||||
scaleX,
|
||||
scaleY,
|
||||
valToPosX,
|
||||
valToPosY,
|
||||
xOff,
|
||||
yOff,
|
||||
xDim,
|
||||
yDim,
|
||||
_moveTo,
|
||||
_lineTo,
|
||||
rect,
|
||||
arc,
|
||||
) => {
|
||||
const stroke = series?.stroke as unknown;
|
||||
if (typeof stroke === "function") {
|
||||
u.ctx.fillStyle = (stroke as () => string)();
|
||||
}
|
||||
|
||||
const circlesPath = new Path2D();
|
||||
const squaresPath = new Path2D();
|
||||
|
||||
const xMin = Number(scaleX.min);
|
||||
const xMax = Number(scaleX.max);
|
||||
const yMin = Number(scaleY.min);
|
||||
const yMax = Number(scaleY.max);
|
||||
|
||||
const counts = new Map<string, number>();
|
||||
const len = dataX.length;
|
||||
|
||||
for (let i = 0; i < len; i++) {
|
||||
const xv = dataX[i];
|
||||
const yv = dataY[i];
|
||||
|
||||
if (xv == null || yv == null) continue;
|
||||
|
||||
const xVal = Number(xv);
|
||||
const yVal = Number(yv);
|
||||
|
||||
if (!Number.isFinite(xVal) || !Number.isFinite(yVal)) continue;
|
||||
|
||||
const key = `${xVal}|${yVal}`;
|
||||
counts.set(key, (counts.get(key) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const duplicates = new Set<string>();
|
||||
for (const [key, count] of counts) {
|
||||
if (count > 1) duplicates.add(key);
|
||||
}
|
||||
|
||||
for (let i = 0; i < len; i++) {
|
||||
const xv = dataX[i];
|
||||
const yv = dataY[i];
|
||||
|
||||
if (xv == null || yv == null) continue;
|
||||
|
||||
const xVal = Number(xv);
|
||||
const yVal = Number(yv);
|
||||
|
||||
if (
|
||||
!Number.isFinite(xVal) ||
|
||||
!Number.isFinite(yVal) ||
|
||||
xVal < xMin || xVal > xMax ||
|
||||
yVal < yMin || yVal > yMax
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const cx = valToPosX(xVal, scaleX, xDim, xOff);
|
||||
const cy = valToPosY(yVal, scaleY, yDim, yOff);
|
||||
|
||||
const key = `${xVal}|${yVal}`;
|
||||
const isDuplicate = duplicates.has(key);
|
||||
|
||||
if (isDuplicate) {
|
||||
rect(squaresPath, cx - squareHalf, cy - squareHalf, squareSize, squareSize);
|
||||
} else {
|
||||
circlesPath.moveTo(cx + r, cy);
|
||||
arc(circlesPath, cx, cy, r, 0, deg360);
|
||||
}
|
||||
}
|
||||
|
||||
u.ctx.fill(circlesPath);
|
||||
u.ctx.lineWidth = 1.4 * uPlot.pxRatio;
|
||||
u.ctx.strokeStyle = u.ctx.fillStyle;
|
||||
u.ctx.stroke(squaresPath);
|
||||
};
|
||||
|
||||
uPlot.orient(u, seriesIdx, orientCallback);
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -1,10 +1,11 @@
|
||||
import { MetricBase, MetricResult } from "../../api/types";
|
||||
import uPlot, { Series as uPlotSeries } from "uplot";
|
||||
import { getNameForMetric, promValueToNumber } from "../metric";
|
||||
import { BarSeriesItem, Disp, Fill, ForecastType, HideSeriesArgs, LegendItemType, SeriesItem, Stroke } from "../../types";
|
||||
import { ForecastType, HideSeriesArgs, LegendItemType, SeriesItem } from "../../types";
|
||||
import { anomalyColors, baseContrastColors, getColorFromString } from "../color";
|
||||
import { getLastFromArray, getMaxFromArray, getMedianFromArray, getMinFromArray } from "../math";
|
||||
import { getMathStats } from "../math";
|
||||
import { formatPrettyNumber } from "./helpers";
|
||||
import { drawPoints } from "./scatter";
|
||||
|
||||
// Helper function to extract freeFormFields values as a comma-separated string
|
||||
export const extractFields = (metric: MetricBase["metric"]): string => {
|
||||
@@ -31,7 +32,7 @@ export const isForecast = (metric: MetricBase["metric"]): ForecastMetricInfo =>
|
||||
};
|
||||
};
|
||||
|
||||
export const getSeriesItemContext = (data: MetricResult[], hideSeries: string[], alias: string[], isAnomalyUI?: boolean) => {
|
||||
export const getSeriesItemContext = (data: MetricResult[], hideSeries: string[], alias: string[], showPoints?: boolean, isAnomalyUI?: boolean, isRawQuery?: boolean) => {
|
||||
const colorState: {[key: string]: string} = {};
|
||||
const maxColors = isAnomalyUI ? 0 : Math.min(data.length, baseContrastColors.length);
|
||||
|
||||
@@ -51,13 +52,14 @@ export const getSeriesItemContext = (data: MetricResult[], hideSeries: string[],
|
||||
dash: getDashSeries(metricInfo),
|
||||
width: getWidthSeries(metricInfo),
|
||||
stroke: getStrokeSeries({ metricInfo, label, isAnomalyUI, colorState }),
|
||||
points: getPointsSeries(metricInfo),
|
||||
points: getPointsSeries(metricInfo, showPoints, isRawQuery),
|
||||
spanGaps: false,
|
||||
forecast: metricInfo?.value,
|
||||
forecastGroup: metricInfo?.group,
|
||||
freeFormFields: d.metric,
|
||||
show: !includesHideSeries(label, hideSeries),
|
||||
scale: "1",
|
||||
paths: isRawQuery ? drawPoints : undefined,
|
||||
...getSeriesStatistics(d),
|
||||
};
|
||||
};
|
||||
@@ -65,19 +67,13 @@ export const getSeriesItemContext = (data: MetricResult[], hideSeries: string[],
|
||||
|
||||
const getSeriesStatistics = (d: MetricResult) => {
|
||||
const values = d.values.map(v => promValueToNumber(v[1]));
|
||||
const { min, max, median, last } = {
|
||||
min: getMinFromArray(values),
|
||||
max: getMaxFromArray(values),
|
||||
median: getMedianFromArray(values),
|
||||
last: getLastFromArray(values),
|
||||
};
|
||||
const { min, max, median } = getMathStats(values, { min: true, max: true, median: true });
|
||||
return {
|
||||
median,
|
||||
median: Number(median),
|
||||
statsFormatted: {
|
||||
min: formatPrettyNumber(min, min, max),
|
||||
max: formatPrettyNumber(max, min, max),
|
||||
median: formatPrettyNumber(median, min, max),
|
||||
last: formatPrettyNumber(last, min, max),
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -118,37 +114,16 @@ export const includesHideSeries = (label: string, hideSeries: string[]): boolean
|
||||
return hideSeries.includes(`${label}`);
|
||||
};
|
||||
|
||||
export const getBarSeries = (
|
||||
which: number[],
|
||||
ori: number,
|
||||
dir: number,
|
||||
radius: number,
|
||||
disp: Disp): BarSeriesItem => {
|
||||
return {
|
||||
which: which,
|
||||
ori: ori,
|
||||
dir: dir,
|
||||
radius: radius,
|
||||
disp: disp,
|
||||
};
|
||||
};
|
||||
|
||||
export const barDisp = (stroke: Stroke, fill: Fill): Disp => {
|
||||
return {
|
||||
stroke: stroke,
|
||||
fill: fill
|
||||
};
|
||||
};
|
||||
|
||||
export const delSeries = (u: uPlot) => {
|
||||
for (let i = u.series.length - 1; i >= 0; i--) {
|
||||
i && u.delSeries(i);
|
||||
}
|
||||
};
|
||||
|
||||
export const addSeries = (u: uPlot, series: uPlotSeries[], spanGaps = false) => {
|
||||
export const addSeries = (u: uPlot, series: uPlotSeries[], spanGaps = false, showPoints = false, isRawQuery?: boolean) => {
|
||||
series.forEach((s,i) => {
|
||||
if (s.label) s.spanGaps = spanGaps;
|
||||
if (s.points) s.points.filter = showPoints || isRawQuery ? undefined : filterPoints;
|
||||
i && u.addSeries(s);
|
||||
});
|
||||
};
|
||||
@@ -184,17 +159,17 @@ const getWidthSeries = (metricInfo: ForecastMetricInfo | null): number => {
|
||||
return 1.4;
|
||||
};
|
||||
|
||||
const getPointsSeries = (metricInfo: ForecastMetricInfo | null): uPlotSeries.Points => {
|
||||
const getPointsSeries = (metricInfo: ForecastMetricInfo | null, showPoints: boolean = false, isRawQuery?: boolean): uPlotSeries.Points => {
|
||||
const isAnomalyMetric = metricInfo?.value === ForecastType.anomaly;
|
||||
|
||||
if (isAnomalyMetric) {
|
||||
return { size: 8, width: 4, space: 0 };
|
||||
}
|
||||
return {
|
||||
size: 4,
|
||||
size: isRawQuery ? 0 : 4,
|
||||
width: 0,
|
||||
show: true,
|
||||
filter: filterPoints,
|
||||
filter: showPoints || isRawQuery ? null : filterPoints,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -205,7 +180,7 @@ const filterPoints = (self: uPlot, seriesIdx: number): number[] | null => {
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const prev = data[i - 1];
|
||||
const next = data[i + 1];
|
||||
if (prev === null && next === null) {
|
||||
if (prev === null || next === null) {
|
||||
indices.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ type PrometheusQuerier interface {
|
||||
PrometheusAPIV1Labels(t *testing.T, query string, opts QueryOpts) *PrometheusAPIV1LabelsResponse
|
||||
PrometheusAPIV1LabelValues(t *testing.T, labelName, query string, opts QueryOpts) *PrometheusAPIV1LabelValuesResponse
|
||||
PrometheusAPIV1ExportNative(t *testing.T, query string, opts QueryOpts) []byte
|
||||
PrometheusAPIV1Metadata(t *testing.T, metric string, limit int, opts QueryOpts) *PrometheusAPIV1Metadata
|
||||
|
||||
APIV1AdminTSDBDeleteSeries(t *testing.T, matchQuery string, opts QueryOpts)
|
||||
|
||||
@@ -37,7 +38,7 @@ type PrometheusQuerier interface {
|
||||
// Writer contains methods for writing new data
|
||||
type Writer interface {
|
||||
// Prometheus APIs
|
||||
PrometheusAPIV1Write(t *testing.T, records []prompb.TimeSeries, opts QueryOpts)
|
||||
PrometheusAPIV1Write(t *testing.T, wr prompb.WriteRequest, opts QueryOpts)
|
||||
PrometheusAPIV1ImportPrometheus(t *testing.T, records []string, opts QueryOpts)
|
||||
PrometheusAPIV1ImportCSV(t *testing.T, records []string, opts QueryOpts)
|
||||
PrometheusAPIV1ImportNative(t *testing.T, data []byte, opts QueryOpts)
|
||||
@@ -350,6 +351,33 @@ func NewPrometheusAPIV1LabelValuesResponse(t *testing.T, s string) *PrometheusAP
|
||||
return res
|
||||
}
|
||||
|
||||
// PrometheusAPIV1Metadata is an inmemory representation of the
|
||||
// /prometheus/api/v1/metadata response.
|
||||
type PrometheusAPIV1Metadata struct {
|
||||
Status string
|
||||
IsPartial bool
|
||||
Data map[string][]MetadataEntry
|
||||
Trace *Trace
|
||||
}
|
||||
|
||||
type MetadataEntry struct {
|
||||
Type string
|
||||
Help string
|
||||
Unit string
|
||||
}
|
||||
|
||||
// NewPrometheusAPIV1Metadata is a test helper function that creates a new
|
||||
// instance of PrometheusAPIV1Metadata by unmarshalling a json string.
|
||||
func NewPrometheusAPIV1Metadata(t *testing.T, s string) *PrometheusAPIV1Metadata {
|
||||
t.Helper()
|
||||
|
||||
res := &PrometheusAPIV1Metadata{}
|
||||
if err := json.Unmarshal([]byte(s), res); err != nil {
|
||||
t.Fatalf("could not unmarshal series response data:\n%s\n err: %v", string(s), err)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// Trace provides the description and the duration of some unit of work that has
|
||||
// been performed during the request processing.
|
||||
type Trace struct {
|
||||
|
||||
@@ -99,37 +99,39 @@ func testDeduplication(tc *apptest.TestCase, sut apptest.PrometheusWriteQuerier,
|
||||
ts3 := start.Add(3 * time.Second).UnixMilli()
|
||||
ts5 := start.Add(5 * time.Second).UnixMilli()
|
||||
ts10 := start.Add(10 * time.Second).UnixMilli()
|
||||
data := []prompb.TimeSeries{
|
||||
{
|
||||
Labels: []prompb.Label{{Name: "__name__", Value: "metric1"}},
|
||||
Samples: []prompb.Sample{
|
||||
{Timestamp: ts1, Value: 3},
|
||||
{Timestamp: ts3, Value: 10},
|
||||
{Timestamp: ts5, Value: 5},
|
||||
data := prompb.WriteRequest{
|
||||
Timeseries: []prompb.TimeSeries{
|
||||
{
|
||||
Labels: []prompb.Label{{Name: "__name__", Value: "metric1"}},
|
||||
Samples: []prompb.Sample{
|
||||
{Timestamp: ts1, Value: 3},
|
||||
{Timestamp: ts3, Value: 10},
|
||||
{Timestamp: ts5, Value: 5},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Labels: []prompb.Label{{Name: "__name__", Value: "metric2"}},
|
||||
Samples: []prompb.Sample{
|
||||
{Timestamp: ts1, Value: 3},
|
||||
{Timestamp: ts3, Value: decimal.StaleNaN},
|
||||
{Timestamp: ts5, Value: 5},
|
||||
{
|
||||
Labels: []prompb.Label{{Name: "__name__", Value: "metric2"}},
|
||||
Samples: []prompb.Sample{
|
||||
{Timestamp: ts1, Value: 3},
|
||||
{Timestamp: ts3, Value: decimal.StaleNaN},
|
||||
{Timestamp: ts5, Value: 5},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Labels: []prompb.Label{{Name: "__name__", Value: "metric3"}},
|
||||
Samples: []prompb.Sample{
|
||||
{Timestamp: ts10, Value: 30},
|
||||
{Timestamp: ts10, Value: 100},
|
||||
{Timestamp: ts10, Value: 50},
|
||||
{
|
||||
Labels: []prompb.Label{{Name: "__name__", Value: "metric3"}},
|
||||
Samples: []prompb.Sample{
|
||||
{Timestamp: ts10, Value: 30},
|
||||
{Timestamp: ts10, Value: 100},
|
||||
{Timestamp: ts10, Value: 50},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Labels: []prompb.Label{{Name: "__name__", Value: "metric4"}},
|
||||
Samples: []prompb.Sample{
|
||||
{Timestamp: ts10, Value: 30},
|
||||
{Timestamp: ts10, Value: decimal.StaleNaN},
|
||||
{Timestamp: ts10, Value: 50},
|
||||
{
|
||||
Labels: []prompb.Label{{Name: "__name__", Value: "metric4"}},
|
||||
Samples: []prompb.Sample{
|
||||
{Timestamp: ts10, Value: 30},
|
||||
{Timestamp: ts10, Value: decimal.StaleNaN},
|
||||
{Timestamp: ts10, Value: 50},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -158,7 +158,11 @@ func TestSingleIngestionProtocols(t *testing.T) {
|
||||
|
||||
// prometheus text exposition format
|
||||
sut.PrometheusAPIV1ImportPrometheus(t, []string{
|
||||
`importprometheus_series 10 1707123456700`, // 2024-02-05T08:57:36.700Z
|
||||
`# HELP importprometheus_series some help message`,
|
||||
`# TYPE importprometheus_series gauge`,
|
||||
`importprometheus_series 10 1707123456700`, // 2024-02-05T08:57:36.700Z
|
||||
`# HELP importprometheus_series2 some help message second one`,
|
||||
`# TYPE importprometheus_series2 gauge`,
|
||||
`importprometheus_series2{label="foo",label1="value1"} 20 1707123456800`, // 2024-02-05T08:57:36.800Z
|
||||
}, apptest.QueryOpts{
|
||||
ExtraLabels: []string{"el1=elv1", "el2=elv2"},
|
||||
@@ -187,42 +191,58 @@ func TestSingleIngestionProtocols(t *testing.T) {
|
||||
})
|
||||
|
||||
// prometheus remote write format
|
||||
pbData := []prompb.TimeSeries{
|
||||
{
|
||||
Labels: []prompb.Label{
|
||||
{
|
||||
Name: "__name__",
|
||||
Value: "prometheusrw_series",
|
||||
pbData := prompb.WriteRequest{
|
||||
Timeseries: []prompb.TimeSeries{
|
||||
{
|
||||
Labels: []prompb.Label{
|
||||
{
|
||||
Name: "__name__",
|
||||
Value: "prometheusrw_series",
|
||||
},
|
||||
},
|
||||
Samples: []prompb.Sample{
|
||||
{
|
||||
Value: 10,
|
||||
Timestamp: 1707123456700, // 2024-02-05T08:57:36.700Z
|
||||
|
||||
},
|
||||
},
|
||||
},
|
||||
Samples: []prompb.Sample{
|
||||
{
|
||||
Value: 10,
|
||||
Timestamp: 1707123456700, // 2024-02-05T08:57:36.700Z
|
||||
|
||||
{
|
||||
Labels: []prompb.Label{
|
||||
{
|
||||
Name: "__name__",
|
||||
Value: "prometheusrw_series2",
|
||||
},
|
||||
{
|
||||
Name: "label",
|
||||
Value: "foo2",
|
||||
},
|
||||
{
|
||||
Name: "label1",
|
||||
Value: "value1",
|
||||
},
|
||||
},
|
||||
Samples: []prompb.Sample{
|
||||
{
|
||||
Value: 20,
|
||||
Timestamp: 1707123456800, // 2024-02-05T08:57:36.800Z
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Labels: []prompb.Label{
|
||||
{
|
||||
Name: "__name__",
|
||||
Value: "prometheusrw_series2",
|
||||
},
|
||||
{
|
||||
Name: "label",
|
||||
Value: "foo2",
|
||||
},
|
||||
{
|
||||
Name: "label1",
|
||||
Value: "value1",
|
||||
},
|
||||
Metadata: []prompb.MetricMetadata{
|
||||
{
|
||||
Type: 1,
|
||||
MetricFamilyName: "prometheusrw_series",
|
||||
Help: "some help",
|
||||
Unit: "",
|
||||
},
|
||||
Samples: []prompb.Sample{
|
||||
{
|
||||
Value: 20,
|
||||
Timestamp: 1707123456800, // 2024-02-05T08:57:36.800Z
|
||||
},
|
||||
{
|
||||
Type: 1,
|
||||
MetricFamilyName: "prometheusrw_series2",
|
||||
Help: "some help2",
|
||||
Unit: "",
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -245,7 +265,6 @@ func TestSingleIngestionProtocols(t *testing.T) {
|
||||
{Timestamp: 1707123456800, Value: 20}, // 2024-02-05T08:57:36.700Z
|
||||
},
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
func TestClusterIngestionProtocols(t *testing.T) {
|
||||
@@ -297,7 +316,11 @@ func TestClusterIngestionProtocols(t *testing.T) {
|
||||
|
||||
// prometheus text exposition format
|
||||
vminsert.PrometheusAPIV1ImportPrometheus(t, []string{
|
||||
`importprometheus_series 10 1707123456700`, // 2024-02-05T08:57:36.700Z
|
||||
`# HELP importprometheus_series some help message`,
|
||||
`# TYPE importprometheus_series gauge`,
|
||||
`importprometheus_series 10 1707123456700`, // 2024-02-05T08:57:36.700Z
|
||||
`# HELP importprometheus_series2 some help message second one`,
|
||||
`# TYPE importprometheus_series2 gauge`,
|
||||
`importprometheus_series2{label="foo",label1="value1"} 20 1707123456800`, // 2024-02-05T08:57:36.800Z
|
||||
}, apptest.QueryOpts{
|
||||
ExtraLabels: []string{"el1=elv1", "el2=elv2"},
|
||||
@@ -434,42 +457,58 @@ func TestClusterIngestionProtocols(t *testing.T) {
|
||||
})
|
||||
|
||||
// prometheus remote write format
|
||||
pbData := []prompb.TimeSeries{
|
||||
{
|
||||
Labels: []prompb.Label{
|
||||
{
|
||||
Name: "__name__",
|
||||
Value: "prometheusrw_series",
|
||||
pbData := prompb.WriteRequest{
|
||||
Timeseries: []prompb.TimeSeries{
|
||||
{
|
||||
Labels: []prompb.Label{
|
||||
{
|
||||
Name: "__name__",
|
||||
Value: "prometheusrw_series",
|
||||
},
|
||||
},
|
||||
Samples: []prompb.Sample{
|
||||
{
|
||||
Value: 10,
|
||||
Timestamp: 1707123456700, // 2024-02-05T08:57:36.700Z
|
||||
|
||||
},
|
||||
},
|
||||
},
|
||||
Samples: []prompb.Sample{
|
||||
{
|
||||
Value: 10,
|
||||
Timestamp: 1707123456700, // 2024-02-05T08:57:36.700Z
|
||||
|
||||
{
|
||||
Labels: []prompb.Label{
|
||||
{
|
||||
Name: "__name__",
|
||||
Value: "prometheusrw_series2",
|
||||
},
|
||||
{
|
||||
Name: "label",
|
||||
Value: "foo2",
|
||||
},
|
||||
{
|
||||
Name: "label1",
|
||||
Value: "value1",
|
||||
},
|
||||
},
|
||||
Samples: []prompb.Sample{
|
||||
{
|
||||
Value: 20,
|
||||
Timestamp: 1707123456800, // 2024-02-05T08:57:36.800Z
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Labels: []prompb.Label{
|
||||
{
|
||||
Name: "__name__",
|
||||
Value: "prometheusrw_series2",
|
||||
},
|
||||
{
|
||||
Name: "label",
|
||||
Value: "foo2",
|
||||
},
|
||||
{
|
||||
Name: "label1",
|
||||
Value: "value1",
|
||||
},
|
||||
Metadata: []prompb.MetricMetadata{
|
||||
{
|
||||
Type: 1,
|
||||
MetricFamilyName: "prometheusrw_series",
|
||||
Help: "some help",
|
||||
Unit: "",
|
||||
},
|
||||
Samples: []prompb.Sample{
|
||||
{
|
||||
Value: 20,
|
||||
Timestamp: 1707123456800, // 2024-02-05T08:57:36.800Z
|
||||
},
|
||||
{
|
||||
Type: 1,
|
||||
MetricFamilyName: "prometheusrw_series2",
|
||||
Help: "some help2",
|
||||
Unit: "",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
225
apptest/tests/metricsmetadata_test.go
Normal file
225
apptest/tests/metricsmetadata_test.go
Normal file
@@ -0,0 +1,225 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
|
||||
"github.com/VictoriaMetrics/VictoriaMetrics/apptest"
|
||||
"github.com/VictoriaMetrics/VictoriaMetrics/lib/fs"
|
||||
"github.com/VictoriaMetrics/VictoriaMetrics/lib/prompb"
|
||||
)
|
||||
|
||||
func TestSingleMetricsMetadata(t *testing.T) {
|
||||
fs.MustRemoveDir(t.Name())
|
||||
|
||||
tc := apptest.NewTestCase(t)
|
||||
defer tc.Stop()
|
||||
sut := tc.MustStartVmsingle("vmsingle", []string{
|
||||
"-storageDataPath=" + tc.Dir(),
|
||||
"-retentionPeriod=100y",
|
||||
"-enableMetadata",
|
||||
})
|
||||
// verify empty stats
|
||||
resp := sut.PrometheusAPIV1Metadata(t, "", 0, apptest.QueryOpts{})
|
||||
if len(resp.Data) != 0 {
|
||||
t.Fatalf("unexpected resp Records: %d, want: %d", len(resp.Data), 0)
|
||||
}
|
||||
|
||||
const ingestTimestamp = 1707123456700
|
||||
prometheusTextDataSet := []string{
|
||||
`# HELP metric_name_1 some help message`,
|
||||
`# TYPE metric_name_1 gauge`,
|
||||
`metric_name_1{label="foo"} 10`,
|
||||
`metric_name_1{label="bar"} 10`,
|
||||
`metric_name_1{label="baz"} 10`,
|
||||
`# HELP metric_name_2 some help message`,
|
||||
`# TYPE metric_name_2 counter`,
|
||||
`metric_name_2{label="baz"} 20`,
|
||||
`# HELP metric_name_3 some help message`,
|
||||
`# TYPE metric_name_3 gauge`,
|
||||
`metric_name_3{label="baz"} 30`,
|
||||
}
|
||||
prometheusRemoteWriteDataSet := prompb.WriteRequest{
|
||||
Timeseries: []prompb.TimeSeries{
|
||||
{Labels: []prompb.Label{{Name: "__name__", Value: "metric_name_4"}}, Samples: []prompb.Sample{{Value: 40, Timestamp: ingestTimestamp}}},
|
||||
{Labels: []prompb.Label{{Name: "__name__", Value: "metric_name_5"}}, Samples: []prompb.Sample{{Value: 40, Timestamp: ingestTimestamp}}},
|
||||
{Labels: []prompb.Label{{Name: "__name__", Value: "metric_name_6"}}, Samples: []prompb.Sample{{Value: 40, Timestamp: ingestTimestamp}}},
|
||||
},
|
||||
Metadata: []prompb.MetricMetadata{
|
||||
{MetricFamilyName: "metric_name_4", Help: "some help message", Type: uint32(prompb.MetricMetadataSUMMARY)},
|
||||
{MetricFamilyName: "metric_name_5", Help: "some help message", Type: uint32(prompb.MetricMetadataSUMMARY)},
|
||||
{MetricFamilyName: "metric_name_6", Help: "some help message", Type: uint32(prompb.MetricMetadataSTATESET)},
|
||||
},
|
||||
}
|
||||
|
||||
sut.PrometheusAPIV1ImportPrometheus(t, prometheusTextDataSet, apptest.QueryOpts{})
|
||||
sut.PrometheusAPIV1Write(t, prometheusRemoteWriteDataSet, apptest.QueryOpts{})
|
||||
sut.ForceFlush(t)
|
||||
expected := &apptest.PrometheusAPIV1Metadata{
|
||||
Status: "success",
|
||||
Data: map[string][]apptest.MetadataEntry{
|
||||
"metric_name_1": {{Help: "some help message", Type: "gauge"}},
|
||||
"metric_name_2": {{Help: "some help message", Type: "counter"}},
|
||||
"metric_name_3": {{Help: "some help message", Type: "gauge"}},
|
||||
"metric_name_4": {{Help: "some help message", Type: "summary"}},
|
||||
"metric_name_5": {{Help: "some help message", Type: "summary"}},
|
||||
"metric_name_6": {{Help: "some help message", Type: "stateset"}},
|
||||
},
|
||||
}
|
||||
gotStats := sut.PrometheusAPIV1Metadata(t, "", 0, apptest.QueryOpts{})
|
||||
if diff := cmp.Diff(expected, gotStats); diff != "" {
|
||||
t.Errorf("unexpected response (-want, +got):\n%s", diff)
|
||||
}
|
||||
|
||||
// check query metric name filter
|
||||
tc.Assert(&apptest.AssertOptions{
|
||||
Msg: "unexpected /api/v1/metadata response",
|
||||
Got: func() any {
|
||||
return sut.PrometheusAPIV1Metadata(t, "metric_name_4", 0, apptest.QueryOpts{})
|
||||
},
|
||||
Want: &apptest.PrometheusAPIV1Metadata{
|
||||
Status: "success",
|
||||
Data: map[string][]apptest.MetadataEntry{
|
||||
"metric_name_4": {{Help: "some help message", Type: "summary"}},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// check query limit filter
|
||||
tc.Assert(&apptest.AssertOptions{
|
||||
Msg: "unexpected /api/v1/metadata response",
|
||||
Got: func() any {
|
||||
return sut.PrometheusAPIV1Metadata(t, "", 3, apptest.QueryOpts{})
|
||||
},
|
||||
Want: &apptest.PrometheusAPIV1Metadata{
|
||||
Status: "success",
|
||||
Data: map[string][]apptest.MetadataEntry{
|
||||
"metric_name_1": {{Help: "some help message", Type: "gauge"}},
|
||||
"metric_name_2": {{Help: "some help message", Type: "counter"}},
|
||||
"metric_name_3": {{Help: "some help message", Type: "gauge"}},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestClusterMetricsMetadata(t *testing.T) {
|
||||
fs.MustRemoveDir(t.Name())
|
||||
|
||||
tc := apptest.NewTestCase(t)
|
||||
defer tc.Stop()
|
||||
vmstorage1 := tc.MustStartVmstorage("vmstorage-1", []string{
|
||||
"-storageDataPath=" + tc.Dir() + "/vmstorage-1",
|
||||
"-retentionPeriod=100y",
|
||||
})
|
||||
vmstorage2 := tc.MustStartVmstorage("vmstorage-2", []string{
|
||||
"-storageDataPath=" + tc.Dir() + "/vmstorage-2",
|
||||
"-retentionPeriod=100y",
|
||||
})
|
||||
|
||||
vminsert1 := tc.MustStartVminsert("vminsert1", []string{
|
||||
fmt.Sprintf("-storageNode=%s,%s", vmstorage1.VminsertAddr(), vmstorage2.VminsertAddr()),
|
||||
"-enableMetadata",
|
||||
})
|
||||
vminsert2 := tc.MustStartVminsert("vminsert-2", []string{
|
||||
fmt.Sprintf("-storageNode=%s,%s", vmstorage1.VminsertAddr(), vmstorage2.VminsertAddr()),
|
||||
"-enableMetadata",
|
||||
})
|
||||
vminsertGlobal := tc.MustStartVminsert("vminsert-global", []string{
|
||||
fmt.Sprintf("-storageNode=%s,%s", vminsert1.ClusternativeListenAddr(), vminsert2.ClusternativeListenAddr()),
|
||||
"-enableMetadata",
|
||||
})
|
||||
vmselect := tc.MustStartVmselect("vmselect", []string{
|
||||
fmt.Sprintf("-storageNode=%s,%s", vmstorage1.VmselectAddr(), vmstorage2.VmselectAddr()),
|
||||
})
|
||||
// verify empty stats
|
||||
resp := vmselect.PrometheusAPIV1Metadata(t, "", 0, apptest.QueryOpts{Tenant: "0:0"})
|
||||
if len(resp.Data) != 0 {
|
||||
t.Fatalf("unexpected resp Records: %d, want: %d", len(resp.Data), 0)
|
||||
}
|
||||
|
||||
const ingestTimestamp = 1707123456700
|
||||
prometheusTextDataSet := []string{
|
||||
`# HELP metric_name_1 some help message`,
|
||||
`# TYPE metric_name_1 gauge`,
|
||||
`metric_name_1{label="foo"} 10`,
|
||||
`metric_name_1{label="bar"} 10`,
|
||||
`metric_name_1{label="baz"} 10`,
|
||||
`# HELP metric_name_2 some help message`,
|
||||
`# TYPE metric_name_2 counter`,
|
||||
`metric_name_2{label="baz"} 20`,
|
||||
`# HELP metric_name_3 some help message`,
|
||||
`# TYPE metric_name_3 gauge`,
|
||||
`metric_name_3{label="baz"} 30`,
|
||||
}
|
||||
prometheusRemoteWriteDataSet := prompb.WriteRequest{
|
||||
Timeseries: []prompb.TimeSeries{
|
||||
{Labels: []prompb.Label{{Name: "__name__", Value: "metric_name_4"}}, Samples: []prompb.Sample{{Value: 40, Timestamp: ingestTimestamp}}},
|
||||
{Labels: []prompb.Label{{Name: "__name__", Value: "metric_name_5"}}, Samples: []prompb.Sample{{Value: 40, Timestamp: ingestTimestamp}}},
|
||||
{Labels: []prompb.Label{{Name: "__name__", Value: "metric_name_6"}}, Samples: []prompb.Sample{{Value: 40, Timestamp: ingestTimestamp}}},
|
||||
},
|
||||
Metadata: []prompb.MetricMetadata{
|
||||
{MetricFamilyName: "metric_name_4", Help: "some help message", Type: uint32(prompb.MetricMetadataSUMMARY)},
|
||||
{MetricFamilyName: "metric_name_5", Help: "some help message", Type: uint32(prompb.MetricMetadataSUMMARY)},
|
||||
{MetricFamilyName: "metric_name_6", Help: "some help message", Type: uint32(prompb.MetricMetadataSTATESET)},
|
||||
},
|
||||
}
|
||||
|
||||
assertMetadataIngestOn := func(t *testing.T, vminsert *apptest.Vminsert, tenantID string) {
|
||||
t.Helper()
|
||||
vminsert.PrometheusAPIV1ImportPrometheus(t, prometheusTextDataSet, apptest.QueryOpts{Tenant: tenantID})
|
||||
vminsert.PrometheusAPIV1Write(t, prometheusRemoteWriteDataSet, apptest.QueryOpts{Tenant: tenantID})
|
||||
vmstorage1.ForceFlush(t)
|
||||
vmstorage2.ForceFlush(t)
|
||||
expected := &apptest.PrometheusAPIV1Metadata{
|
||||
Status: "success",
|
||||
Data: map[string][]apptest.MetadataEntry{
|
||||
"metric_name_1": {{Help: "some help message", Type: "gauge"}},
|
||||
"metric_name_2": {{Help: "some help message", Type: "counter"}},
|
||||
"metric_name_3": {{Help: "some help message", Type: "gauge"}},
|
||||
"metric_name_4": {{Help: "some help message", Type: "summary"}},
|
||||
"metric_name_5": {{Help: "some help message", Type: "summary"}},
|
||||
"metric_name_6": {{Help: "some help message", Type: "stateset"}},
|
||||
},
|
||||
}
|
||||
gotStats := vmselect.PrometheusAPIV1Metadata(t, "", 0, apptest.QueryOpts{Tenant: tenantID})
|
||||
if diff := cmp.Diff(expected, gotStats); diff != "" {
|
||||
t.Errorf("unexpected response (-want, +got):\n%s", diff)
|
||||
}
|
||||
}
|
||||
|
||||
assertMetadataIngestOn(t, vminsert1, "2:2")
|
||||
assertMetadataIngestOn(t, vminsert2, "3:3")
|
||||
assertMetadataIngestOn(t, vminsertGlobal, "5:5")
|
||||
|
||||
// check query metric name filter
|
||||
tc.Assert(&apptest.AssertOptions{
|
||||
Msg: "unexpected /api/v1/metadata response",
|
||||
Got: func() any {
|
||||
return vmselect.PrometheusAPIV1Metadata(t, "metric_name_4", 0, apptest.QueryOpts{Tenant: "multitenant"})
|
||||
},
|
||||
Want: &apptest.PrometheusAPIV1Metadata{
|
||||
Status: "success",
|
||||
Data: map[string][]apptest.MetadataEntry{
|
||||
"metric_name_4": {{Help: "some help message", Type: "summary"}},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// check query limit filter
|
||||
tc.Assert(&apptest.AssertOptions{
|
||||
Msg: "unexpected /api/v1/metadata response",
|
||||
Got: func() any {
|
||||
return vmselect.PrometheusAPIV1Metadata(t, "", 3, apptest.QueryOpts{Tenant: "5:5"})
|
||||
},
|
||||
Want: &apptest.PrometheusAPIV1Metadata{
|
||||
Status: "success",
|
||||
Data: map[string][]apptest.MetadataEntry{
|
||||
"metric_name_1": {{Help: "some help message", Type: "gauge"}},
|
||||
"metric_name_2": {{Help: "some help message", Type: "counter"}},
|
||||
"metric_name_3": {{Help: "some help message", Type: "gauge"}},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -47,14 +47,16 @@ func TestClusterInstantQuery(t *testing.T) {
|
||||
}
|
||||
|
||||
func testInstantQueryWithUTFNames(t *testing.T, sut apptest.PrometheusWriteQuerier) {
|
||||
data := []prompb.TimeSeries{
|
||||
{
|
||||
Labels: []prompb.Label{
|
||||
{Name: "__name__", Value: "3fooµ¥"},
|
||||
{Name: "3👋tfにちは", Value: "漢©®€£"},
|
||||
},
|
||||
Samples: []prompb.Sample{
|
||||
{Value: 1, Timestamp: millis("2024-01-01T00:01:00Z")},
|
||||
data := prompb.WriteRequest{
|
||||
Timeseries: []prompb.TimeSeries{
|
||||
{
|
||||
Labels: []prompb.Label{
|
||||
{Name: "__name__", Value: "3fooµ¥"},
|
||||
{Name: "3👋tfにちは", Value: "漢©®€£"},
|
||||
},
|
||||
Samples: []prompb.Sample{
|
||||
{Value: 1, Timestamp: millis("2024-01-01T00:01:00Z")},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -89,23 +91,25 @@ func testInstantQueryWithUTFNames(t *testing.T, sut apptest.PrometheusWriteQueri
|
||||
fn(`{"3👋tfにちは"="漢©®€£"}`)
|
||||
}
|
||||
|
||||
var staleNaNsData = func() []prompb.TimeSeries {
|
||||
return []prompb.TimeSeries{
|
||||
{
|
||||
Labels: []prompb.Label{
|
||||
{
|
||||
Name: "__name__",
|
||||
Value: "metric",
|
||||
var staleNaNsData = func() prompb.WriteRequest {
|
||||
return prompb.WriteRequest{
|
||||
Timeseries: []prompb.TimeSeries{
|
||||
{
|
||||
Labels: []prompb.Label{
|
||||
{
|
||||
Name: "__name__",
|
||||
Value: "metric",
|
||||
},
|
||||
},
|
||||
},
|
||||
Samples: []prompb.Sample{
|
||||
{
|
||||
Value: 1,
|
||||
Timestamp: millis("2024-01-01T00:01:00Z"),
|
||||
},
|
||||
{
|
||||
Value: decimal.StaleNaN,
|
||||
Timestamp: millis("2024-01-01T00:02:00Z"),
|
||||
Samples: []prompb.Sample{
|
||||
{
|
||||
Value: 1,
|
||||
Timestamp: millis("2024-01-01T00:01:00Z"),
|
||||
},
|
||||
{
|
||||
Value: decimal.StaleNaN,
|
||||
Timestamp: millis("2024-01-01T00:02:00Z"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -185,21 +189,23 @@ func testInstantQueryDoesNotReturnStaleNaNs(t *testing.T, sut apptest.Prometheus
|
||||
// However, conversion of math.NaN to int64 could behave differently depending on platform and Go version.
|
||||
// Hence, this test could succeed for some platforms even if fix is rolled back.
|
||||
func testQueryRangeWithAtModifier(t *testing.T, sut apptest.PrometheusWriteQuerier) {
|
||||
data := []prompb.TimeSeries{
|
||||
{
|
||||
Labels: []prompb.Label{
|
||||
{Name: "__name__", Value: "up"},
|
||||
data := prompb.WriteRequest{
|
||||
Timeseries: []prompb.TimeSeries{
|
||||
{
|
||||
Labels: []prompb.Label{
|
||||
{Name: "__name__", Value: "up"},
|
||||
},
|
||||
Samples: []prompb.Sample{
|
||||
{Value: 1, Timestamp: millis("2025-01-01T00:01:00Z")},
|
||||
},
|
||||
},
|
||||
Samples: []prompb.Sample{
|
||||
{Value: 1, Timestamp: millis("2025-01-01T00:01:00Z")},
|
||||
},
|
||||
},
|
||||
{
|
||||
Labels: []prompb.Label{
|
||||
{Name: "__name__", Value: "metricNaN"},
|
||||
},
|
||||
Samples: []prompb.Sample{
|
||||
{Value: decimal.StaleNaN, Timestamp: millis("2025-01-01T00:01:00Z")},
|
||||
{
|
||||
Labels: []prompb.Label{
|
||||
{Name: "__name__", Value: "metricNaN"},
|
||||
},
|
||||
Samples: []prompb.Sample{
|
||||
{Value: decimal.StaleNaN, Timestamp: millis("2025-01-01T00:01:00Z")},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user