Compare commits

..

3 Commits

Author SHA1 Message Date
dmitry-shur
1d27079ec8 Improvements for backup description and configuration for single node, cluster , quick start 2025-07-16 17:58:49 +02:00
dmitry-shur
26ce7316a0 Adding "Note: If custom S3 endpoint is used, URL should contain only name of the bucket, while hostname of S3 server must be specified via the -customS3Endpoint command-line flag." across flags and docs 2025-07-04 14:02:01 +02:00
dmitry-shur
4c825bf31c Adding note for -dst config.
Adding additional reference for snapshot troubleshooting for better accessibility
2025-07-01 15:21:21 +02:00
415 changed files with 8345 additions and 29906 deletions

1
.gitignore vendored
View File

@@ -27,4 +27,3 @@ _site
coverage.txt
cspell.json
*~
deployment/docker/provisioning/plugins/

View File

@@ -504,7 +504,7 @@ fmt:
gofmt -l -w -s ./apptest
vet:
GOEXPERIMENT=synctest go vet ./lib/...
go vet ./lib/...
go vet ./app/...
go vet ./apptest/...
@@ -513,29 +513,29 @@ check-all: fmt vet golangci-lint govulncheck
clean-checkers: remove-golangci-lint remove-govulncheck
test:
GOEXPERIMENT=synctest go test ./lib/... ./app/...
go test ./lib/... ./app/...
test-race:
GOEXPERIMENT=synctest go test -race ./lib/... ./app/...
go test -race ./lib/... ./app/...
test-pure:
GOEXPERIMENT=synctest CGO_ENABLED=0 go test ./lib/... ./app/...
CGO_ENABLED=0 go test ./lib/... ./app/...
test-full:
GOEXPERIMENT=synctest go test -coverprofile=coverage.txt -covermode=atomic ./lib/... ./app/...
go test -coverprofile=coverage.txt -covermode=atomic ./lib/... ./app/...
test-full-386:
GOEXPERIMENT=synctest GOARCH=386 go test -coverprofile=coverage.txt -covermode=atomic ./lib/... ./app/...
GOARCH=386 go test -coverprofile=coverage.txt -covermode=atomic ./lib/... ./app/...
integration-test: victoria-metrics vmagent vmalert vmauth
go test ./apptest/... -skip="^TestCluster.*"
benchmark:
GOEXPERIMENT=synctest go test -bench=. ./lib/...
go test -bench=. ./lib/...
go test -bench=. ./app/...
benchmark-pure:
GOEXPERIMENT=synctest CGO_ENABLED=0 go test -bench=. ./lib/...
CGO_ENABLED=0 go test -bench=. ./lib/...
CGO_ENABLED=0 go test -bench=. ./app/...
vendor-update:
@@ -564,7 +564,7 @@ install-qtc:
golangci-lint: install-golangci-lint
GOEXPERIMENT=synctest golangci-lint run
golangci-lint run
install-golangci-lint:
which golangci-lint || curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(shell go env GOPATH)/bin v1.64.7

View File

@@ -219,42 +219,6 @@ func (lmp *logMessageProcessor) AddRow(timestamp int64, fields, streamFields []l
}
}
// InsertRowProcessor is used by native data ingestion protocol parser.
type InsertRowProcessor interface {
// AddInsertRow must add r to the underlying storage.
AddInsertRow(r *logstorage.InsertRow)
}
// AddInsertRow adds r to lmp.
func (lmp *logMessageProcessor) AddInsertRow(r *logstorage.InsertRow) {
lmp.rowsIngestedTotal.Inc()
n := logstorage.EstimatedJSONRowLen(r.Fields)
lmp.bytesIngestedTotal.Add(n)
if len(r.Fields) > *MaxFieldsPerLine {
line := logstorage.MarshalFieldsToJSON(nil, r.Fields)
logger.Warnf("dropping log line with %d fields; it exceeds -insert.maxFieldsPerLine=%d; %s", len(r.Fields), *MaxFieldsPerLine, line)
rowsDroppedTotalTooManyFields.Inc()
return
}
lmp.mu.Lock()
defer lmp.mu.Unlock()
lmp.lr.MustAddInsertRow(r)
if lmp.cp.Debug {
s := lmp.lr.GetRowString(0)
lmp.lr.ResetKeepSettings()
logger.Infof("remoteAddr=%s; requestURI=%s; ignoring log entry because of `debug` arg: %s", lmp.cp.DebugRemoteAddr, lmp.cp.DebugRequestURI, s)
rowsDroppedTotalDebug.Inc()
return
}
if lmp.lr.NeedFlush() {
lmp.flushLocked()
}
}
// flushLocked must be called under locked lmp.mu.
func (lmp *logMessageProcessor) flushLocked() {
lmp.lastFlushTime = time.Now()

View File

@@ -1,96 +0,0 @@
package internalinsert
import (
"flag"
"fmt"
"net/http"
"time"
"github.com/VictoriaMetrics/metrics"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vlinsert/insertutil"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vlstorage"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vlstorage/netinsert"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/flagutil"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/httpserver"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/logstorage"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/protoparser/protoparserutil"
)
var (
disableInsert = flag.Bool("internalinsert.disable", false, "Whether to disable /internal/insert HTTP endpoint")
maxRequestSize = flagutil.NewBytes("internalinsert.maxRequestSize", 64*1024*1024, "The maximum size in bytes of a single request, which can be accepted at /internal/insert HTTP endpoint")
)
// RequestHandler processes /internal/insert requests.
func RequestHandler(w http.ResponseWriter, r *http.Request) {
if *disableInsert {
httpserver.Errorf(w, r, "requests to /internal/insert are disabled with -internalinsert.disable command-line flag")
return
}
startTime := time.Now()
if r.Method != "POST" {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
version := r.FormValue("version")
if version != netinsert.ProtocolVersion {
httpserver.Errorf(w, r, "unsupported protocol version=%q; want %q", version, netinsert.ProtocolVersion)
return
}
requestsTotal.Inc()
cp, err := insertutil.GetCommonParams(r)
if err != nil {
httpserver.Errorf(w, r, "%s", err)
return
}
if err := vlstorage.CanWriteData(); err != nil {
httpserver.Errorf(w, r, "%s", err)
return
}
encoding := r.Header.Get("Content-Encoding")
err = protoparserutil.ReadUncompressedData(r.Body, encoding, maxRequestSize, func(data []byte) error {
lmp := cp.NewLogMessageProcessor("internalinsert", false)
irp := lmp.(insertutil.InsertRowProcessor)
err := parseData(irp, data)
lmp.MustClose()
return err
})
if err != nil {
errorsTotal.Inc()
httpserver.Errorf(w, r, "cannot parse internal insert request: %s", err)
return
}
requestDuration.UpdateDuration(startTime)
}
func parseData(irp insertutil.InsertRowProcessor, data []byte) error {
r := logstorage.GetInsertRow()
src := data
i := 0
for len(src) > 0 {
tail, err := r.UnmarshalInplace(src)
if err != nil {
return fmt.Errorf("cannot parse row #%d: %s", i, err)
}
src = tail
i++
irp.AddInsertRow(r)
}
logstorage.PutInsertRow(r)
return nil
}
var (
requestsTotal = metrics.NewCounter(`vl_http_requests_total{path="/internal/insert"}`)
errorsTotal = metrics.NewCounter(`vl_http_errors_total{path="/internal/insert"}`)
requestDuration = metrics.NewHistogram(`vl_http_request_duration_seconds{path="/internal/insert"}`)
)

View File

@@ -7,7 +7,6 @@ import (
"github.com/VictoriaMetrics/VictoriaMetrics/app/vlinsert/datadog"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vlinsert/elasticsearch"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vlinsert/internalinsert"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vlinsert/journald"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vlinsert/jsonline"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vlinsert/loki"
@@ -29,11 +28,6 @@ func Stop() {
func RequestHandler(w http.ResponseWriter, r *http.Request) bool {
path := r.URL.Path
if path == "/internal/insert" {
internalinsert.RequestHandler(w, r)
return true
}
if !strings.HasPrefix(path, "/insert/") {
// Skip requests, which do not start with /insert/, since these aren't our requests.
return false

View File

@@ -41,26 +41,6 @@ func TestPushProtoOk(t *testing.T) {
`{"_msg":"log-line-message","severity":"Trace"}`,
)
// severities mapping
f([]pb.ResourceLogs{
{
ScopeLogs: []pb.ScopeLogs{
{
LogRecords: []pb.LogRecord{
{Attributes: []*pb.KeyValue{}, TimeUnixNano: 1234, SeverityNumber: 1, Body: pb.AnyValue{StringValue: ptrTo("log-line-message")}},
{Attributes: []*pb.KeyValue{}, TimeUnixNano: 1234, SeverityNumber: 13, Body: pb.AnyValue{StringValue: ptrTo("log-line-message")}},
{Attributes: []*pb.KeyValue{}, TimeUnixNano: 1234, SeverityNumber: 24, Body: pb.AnyValue{StringValue: ptrTo("log-line-message")}},
},
},
},
},
},
[]int64{1234, 1234, 1234},
`{"_msg":"log-line-message","severity":"Trace"}
{"_msg":"log-line-message","severity":"Warn"}
{"_msg":"log-line-message","severity":"Fatal4"}`,
)
// multi-line with resource attributes
f([]pb.ResourceLogs{
{
@@ -80,7 +60,7 @@ func TestPushProtoOk(t *testing.T) {
{
LogRecords: []pb.LogRecord{
{Attributes: []*pb.KeyValue{}, TimeUnixNano: 1234, SeverityNumber: 1, Body: pb.AnyValue{StringValue: ptrTo("log-line-message")}},
{Attributes: []*pb.KeyValue{}, TimeUnixNano: 1235, SeverityNumber: 25, Body: pb.AnyValue{StringValue: ptrTo("log-line-message-msg-2")}},
{Attributes: []*pb.KeyValue{}, TimeUnixNano: 1235, SeverityNumber: 21, Body: pb.AnyValue{StringValue: ptrTo("log-line-message-msg-2")}},
{Attributes: []*pb.KeyValue{}, TimeUnixNano: 1236, SeverityNumber: -1, Body: pb.AnyValue{StringValue: ptrTo("log-line-message-msg-2")}},
},
},

View File

@@ -1,324 +0,0 @@
package internalselect
import (
"context"
"flag"
"fmt"
"net/http"
"strconv"
"sync"
"time"
"github.com/VictoriaMetrics/metrics"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vlstorage"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vlstorage/netselect"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/atomicutil"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/bytesutil"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/encoding"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/encoding/zstd"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/httpserver"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/logstorage"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/netutil"
)
var disableSelect = flag.Bool("internalselect.disable", false, "Whether to disable /internal/select/* HTTP endpoints")
// RequestHandler processes requests to /internal/select/*
func RequestHandler(ctx context.Context, w http.ResponseWriter, r *http.Request) {
if *disableSelect {
httpserver.Errorf(w, r, "requests to /internal/select/* are disabled with -internalselect.disable command-line flag")
return
}
startTime := time.Now()
path := r.URL.Path
rh := requestHandlers[path]
if rh == nil {
httpserver.Errorf(w, r, "unsupported endpoint requested: %s", path)
return
}
metrics.GetOrCreateCounter(fmt.Sprintf(`vl_http_requests_total{path=%q}`, path)).Inc()
if err := rh(ctx, w, r); err != nil && !netutil.IsTrivialNetworkError(err) {
metrics.GetOrCreateCounter(fmt.Sprintf(`vl_http_request_errors_total{path=%q}`, path)).Inc()
httpserver.Errorf(w, r, "%s", err)
// The return is skipped intentionally in order to track the duration of failed queries.
}
metrics.GetOrCreateSummary(fmt.Sprintf(`vl_http_request_duration_seconds{path=%q}`, path)).UpdateDuration(startTime)
}
var requestHandlers = map[string]func(ctx context.Context, w http.ResponseWriter, r *http.Request) error{
"/internal/select/query": processQueryRequest,
"/internal/select/field_names": processFieldNamesRequest,
"/internal/select/field_values": processFieldValuesRequest,
"/internal/select/stream_field_names": processStreamFieldNamesRequest,
"/internal/select/stream_field_values": processStreamFieldValuesRequest,
"/internal/select/streams": processStreamsRequest,
"/internal/select/stream_ids": processStreamIDsRequest,
}
func processQueryRequest(ctx context.Context, w http.ResponseWriter, r *http.Request) error {
cp, err := getCommonParams(r, netselect.QueryProtocolVersion)
if err != nil {
return err
}
w.Header().Set("Content-Type", "application/octet-stream")
var wLock sync.Mutex
var dataLenBuf []byte
sendBuf := func(bb *bytesutil.ByteBuffer) error {
if len(bb.B) == 0 {
return nil
}
data := bb.B
if !cp.DisableCompression {
bufLen := len(bb.B)
bb.B = zstd.CompressLevel(bb.B, bb.B, 1)
data = bb.B[bufLen:]
}
wLock.Lock()
dataLenBuf = encoding.MarshalUint64(dataLenBuf[:0], uint64(len(data)))
_, err := w.Write(dataLenBuf)
if err == nil {
_, err = w.Write(data)
}
wLock.Unlock()
// Reset the sent buf
bb.Reset()
return err
}
var bufs atomicutil.Slice[bytesutil.ByteBuffer]
var errGlobalLock sync.Mutex
var errGlobal error
writeBlock := func(workerID uint, db *logstorage.DataBlock) {
if errGlobal != nil {
return
}
bb := bufs.Get(workerID)
bb.B = db.Marshal(bb.B)
if len(bb.B) < 1024*1024 {
// Fast path - the bb is too small to be sent to the client yet.
return
}
// Slow path - the bb must be sent to the client.
if err := sendBuf(bb); err != nil {
errGlobalLock.Lock()
if errGlobal != nil {
errGlobal = err
}
errGlobalLock.Unlock()
}
}
if err := vlstorage.RunQuery(ctx, cp.TenantIDs, cp.Query, writeBlock); err != nil {
return err
}
if errGlobal != nil {
return errGlobal
}
// Send the remaining data
for _, bb := range bufs.GetSlice() {
if err := sendBuf(bb); err != nil {
return err
}
}
return nil
}
func processFieldNamesRequest(ctx context.Context, w http.ResponseWriter, r *http.Request) error {
cp, err := getCommonParams(r, netselect.FieldNamesProtocolVersion)
if err != nil {
return err
}
fieldNames, err := vlstorage.GetFieldNames(ctx, cp.TenantIDs, cp.Query)
if err != nil {
return fmt.Errorf("cannot obtain field names: %w", err)
}
return writeValuesWithHits(w, fieldNames, cp.DisableCompression)
}
func processFieldValuesRequest(ctx context.Context, w http.ResponseWriter, r *http.Request) error {
cp, err := getCommonParams(r, netselect.FieldValuesProtocolVersion)
if err != nil {
return err
}
fieldName := r.FormValue("field")
limit, err := getInt64FromRequest(r, "limit")
if err != nil {
return err
}
fieldValues, err := vlstorage.GetFieldValues(ctx, cp.TenantIDs, cp.Query, fieldName, uint64(limit))
if err != nil {
return fmt.Errorf("cannot obtain field values: %w", err)
}
return writeValuesWithHits(w, fieldValues, cp.DisableCompression)
}
func processStreamFieldNamesRequest(ctx context.Context, w http.ResponseWriter, r *http.Request) error {
cp, err := getCommonParams(r, netselect.StreamFieldNamesProtocolVersion)
if err != nil {
return err
}
fieldNames, err := vlstorage.GetStreamFieldNames(ctx, cp.TenantIDs, cp.Query)
if err != nil {
return fmt.Errorf("cannot obtain stream field names: %w", err)
}
return writeValuesWithHits(w, fieldNames, cp.DisableCompression)
}
func processStreamFieldValuesRequest(ctx context.Context, w http.ResponseWriter, r *http.Request) error {
cp, err := getCommonParams(r, netselect.StreamFieldValuesProtocolVersion)
if err != nil {
return err
}
fieldName := r.FormValue("field")
limit, err := getInt64FromRequest(r, "limit")
if err != nil {
return err
}
fieldValues, err := vlstorage.GetStreamFieldValues(ctx, cp.TenantIDs, cp.Query, fieldName, uint64(limit))
if err != nil {
return fmt.Errorf("cannot obtain stream field values: %w", err)
}
return writeValuesWithHits(w, fieldValues, cp.DisableCompression)
}
func processStreamsRequest(ctx context.Context, w http.ResponseWriter, r *http.Request) error {
cp, err := getCommonParams(r, netselect.StreamsProtocolVersion)
if err != nil {
return err
}
limit, err := getInt64FromRequest(r, "limit")
if err != nil {
return err
}
streams, err := vlstorage.GetStreams(ctx, cp.TenantIDs, cp.Query, uint64(limit))
if err != nil {
return fmt.Errorf("cannot obtain streams: %w", err)
}
return writeValuesWithHits(w, streams, cp.DisableCompression)
}
func processStreamIDsRequest(ctx context.Context, w http.ResponseWriter, r *http.Request) error {
cp, err := getCommonParams(r, netselect.StreamIDsProtocolVersion)
if err != nil {
return err
}
limit, err := getInt64FromRequest(r, "limit")
if err != nil {
return err
}
streamIDs, err := vlstorage.GetStreamIDs(ctx, cp.TenantIDs, cp.Query, uint64(limit))
if err != nil {
return fmt.Errorf("cannot obtain streams: %w", err)
}
return writeValuesWithHits(w, streamIDs, cp.DisableCompression)
}
type commonParams struct {
TenantIDs []logstorage.TenantID
Query *logstorage.Query
DisableCompression bool
}
func getCommonParams(r *http.Request, expectedProtocolVersion string) (*commonParams, error) {
version := r.FormValue("version")
if version != expectedProtocolVersion {
return nil, fmt.Errorf("unexpected version=%q; want %q", version, expectedProtocolVersion)
}
tenantIDsStr := r.FormValue("tenant_ids")
tenantIDs, err := logstorage.UnmarshalTenantIDs([]byte(tenantIDsStr))
if err != nil {
return nil, fmt.Errorf("cannot unmarshal tenant_ids=%q: %w", tenantIDsStr, err)
}
timestamp, err := getInt64FromRequest(r, "timestamp")
if err != nil {
return nil, err
}
qStr := r.FormValue("query")
q, err := logstorage.ParseQueryAtTimestamp(qStr, timestamp)
if err != nil {
return nil, fmt.Errorf("cannot unmarshal query=%q: %w", qStr, err)
}
s := r.FormValue("disable_compression")
disableCompression, err := strconv.ParseBool(s)
if err != nil {
return nil, fmt.Errorf("cannot parse disable_compression=%q: %w", s, err)
}
cp := &commonParams{
TenantIDs: tenantIDs,
Query: q,
DisableCompression: disableCompression,
}
return cp, nil
}
func writeValuesWithHits(w http.ResponseWriter, vhs []logstorage.ValueWithHits, disableCompression bool) error {
var b []byte
for i := range vhs {
b = vhs[i].Marshal(b)
}
if !disableCompression {
b = zstd.CompressLevel(nil, b, 1)
}
w.Header().Set("Content-Type", "application/octet-stream")
if _, err := w.Write(b); err != nil {
return fmt.Errorf("cannot send response to the client: %w", err)
}
return nil
}
func getInt64FromRequest(r *http.Request, argName string) (int64, error) {
s := r.FormValue(argName)
n, err := strconv.ParseInt(s, 10, 64)
if err != nil {
return 0, fmt.Errorf("cannot parse %s=%q: %w", argName, s, err)
}
return n, nil
}

View File

@@ -0,0 +1,47 @@
package logsql
import (
"bufio"
"io"
"sync"
)
func getBufferedWriter(w io.Writer) *bufferedWriter {
v := bufferedWriterPool.Get()
if v == nil {
return &bufferedWriter{
bw: bufio.NewWriter(w),
}
}
bw := v.(*bufferedWriter)
bw.bw.Reset(w)
return bw
}
func putBufferedWriter(bw *bufferedWriter) {
bw.reset()
bufferedWriterPool.Put(bw)
}
var bufferedWriterPool sync.Pool
type bufferedWriter struct {
mu sync.Mutex
bw *bufio.Writer
}
func (bw *bufferedWriter) reset() {
// nothing to do
}
func (bw *bufferedWriter) WriteIgnoreErrors(p []byte) {
bw.mu.Lock()
_, _ = bw.bw.Write(p)
bw.mu.Unlock()
}
func (bw *bufferedWriter) FlushIgnoreErrors() {
bw.mu.Lock()
_ = bw.bw.Flush()
bw.mu.Unlock()
}

View File

@@ -3,7 +3,6 @@ package logsql
import (
"context"
"fmt"
"io"
"math"
"net/http"
"regexp"
@@ -12,14 +11,12 @@ import (
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/VictoriaMetrics/metrics"
"github.com/valyala/fastjson"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vlstorage"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/atomicutil"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/bytesutil"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/httpserver"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/httputil"
@@ -60,13 +57,10 @@ func ProcessFacetsRequest(ctx context.Context, w http.ResponseWriter, r *http.Re
var mLock sync.Mutex
m := make(map[string][]facetEntry)
writeBlock := func(_ uint, db *logstorage.DataBlock) {
rowsCount := db.RowsCount()
if rowsCount == 0 {
writeBlock := func(_ uint, _ []int64, columns []logstorage.BlockColumn) {
if len(columns) == 0 || len(columns[0].Values) == 0 {
return
}
columns := db.Columns
if len(columns) != 3 {
logger.Panicf("BUG: expecting 3 columns; got %d columns", len(columns))
}
@@ -162,19 +156,17 @@ func ProcessHitsRequest(ctx context.Context, w http.ResponseWriter, r *http.Requ
var mLock sync.Mutex
m := make(map[string]*hitsSeries)
writeBlock := func(_ uint, db *logstorage.DataBlock) {
rowsCount := db.RowsCount()
if rowsCount == 0 {
writeBlock := func(_ uint, timestamps []int64, columns []logstorage.BlockColumn) {
if len(columns) == 0 || len(columns[0].Values) == 0 {
return
}
columns := db.Columns
timestampValues := columns[0].Values
hitsValues := columns[len(columns)-1].Values
columns = columns[1 : len(columns)-1]
bb := blockResultPool.Get()
for i := 0; i < rowsCount; i++ {
for i := range timestamps {
timestampStr := strings.Clone(timestampValues[i])
hitsStr := strings.Clone(hitsValues[i])
hits, err := strconv.ParseUint(hitsStr, 10, 64)
@@ -213,8 +205,6 @@ func ProcessHitsRequest(ctx context.Context, w http.ResponseWriter, r *http.Requ
WriteHitsSeries(w, m)
}
var blockResultPool bytesutil.ByteBufferPool
func getTopHitsSeries(m map[string]*hitsSeries, fieldsLimit int) map[string]*hitsSeries {
if fieldsLimit <= 0 || fieldsLimit >= len(m) {
return m
@@ -546,7 +536,7 @@ var liveTailRequests = metrics.NewCounter(`vl_live_tailing_requests`)
const tailOffsetNsecs = 5e9
type logRow struct {
timestamp string
timestamp int64
fields []logstorage.Field
}
@@ -562,7 +552,7 @@ type tailProcessor struct {
mu sync.Mutex
perStreamRows map[string][]logRow
lastTimestamps map[string]string
lastTimestamps map[string]int64
err error
}
@@ -572,12 +562,12 @@ func newTailProcessor(cancel func()) *tailProcessor {
cancel: cancel,
perStreamRows: make(map[string][]logRow),
lastTimestamps: make(map[string]string),
lastTimestamps: make(map[string]int64),
}
}
func (tp *tailProcessor) writeBlock(_ uint, db *logstorage.DataBlock) {
if db.RowsCount() == 0 {
func (tp *tailProcessor) writeBlock(_ uint, timestamps []int64, columns []logstorage.BlockColumn) {
if len(timestamps) == 0 {
return
}
@@ -589,8 +579,14 @@ func (tp *tailProcessor) writeBlock(_ uint, db *logstorage.DataBlock) {
}
// Make sure columns contain _time field, since it is needed for proper tail work.
timestamps, ok := db.GetTimestamps()
if !ok {
hasTime := false
for _, c := range columns {
if c.Name == "_time" {
hasTime = true
break
}
}
if !hasTime {
tp.err = fmt.Errorf("missing _time field")
tp.cancel()
return
@@ -599,8 +595,8 @@ func (tp *tailProcessor) writeBlock(_ uint, db *logstorage.DataBlock) {
// Copy block rows to tp.perStreamRows
for i, timestamp := range timestamps {
streamID := ""
fields := make([]logstorage.Field, len(db.Columns))
for j, c := range db.Columns {
fields := make([]logstorage.Field, len(columns))
for j, c := range columns {
name := strings.Clone(c.Name)
value := strings.Clone(c.Values[i])
@@ -692,15 +688,12 @@ func ProcessStatsQueryRangeRequest(ctx context.Context, w http.ResponseWriter, r
m := make(map[string]*statsSeries)
var mLock sync.Mutex
writeBlock := func(_ uint, db *logstorage.DataBlock) {
rowsCount := db.RowsCount()
columns := db.Columns
writeBlock := func(_ uint, timestamps []int64, columns []logstorage.BlockColumn) {
clonedColumnNames := make([]string, len(columns))
for i, c := range columns {
clonedColumnNames[i] = strings.Clone(c.Name)
}
for i := 0; i < rowsCount; i++ {
for i := range timestamps {
// Do not move q.GetTimestamp() outside writeBlock, since ts
// must be initialized to query timestamp for every processed log row.
// See https://github.com/VictoriaMetrics/VictoriaMetrics/issues/8312
@@ -809,14 +802,12 @@ func ProcessStatsQueryRequest(ctx context.Context, w http.ResponseWriter, r *htt
var rowsLock sync.Mutex
timestamp := q.GetTimestamp()
writeBlock := func(_ uint, db *logstorage.DataBlock) {
rowsCount := db.RowsCount()
columns := db.Columns
writeBlock := func(_ uint, timestamps []int64, columns []logstorage.BlockColumn) {
clonedColumnNames := make([]string, len(columns))
for i, c := range columns {
clonedColumnNames[i] = strings.Clone(c.Name)
}
for i := 0; i < rowsCount; i++ {
for i := range timestamps {
labels := make([]logstorage.Field, 0, len(byFields))
for j, c := range columns {
if slices.Contains(byFields, c.Name) {
@@ -878,21 +869,11 @@ func ProcessQueryRequest(ctx context.Context, w http.ResponseWriter, r *http.Req
return
}
sw := &syncWriter{
w: w,
}
var bwShards atomicutil.Slice[bufferedWriter]
bwShards.Init = func(shard *bufferedWriter) {
shard.sw = sw
}
bw := getBufferedWriter(w)
defer func() {
shards := bwShards.GetSlice()
for _, shard := range shards {
shard.FlushIgnoreErrors()
}
bw.FlushIgnoreErrors()
putBufferedWriter(bw)
}()
w.Header().Set("Content-Type", "application/stream+json")
if limit > 0 {
@@ -902,34 +883,32 @@ func ProcessQueryRequest(ctx context.Context, w http.ResponseWriter, r *http.Req
httpserver.Errorf(w, r, "%s", err)
return
}
bw := bwShards.Get(0)
bb := blockResultPool.Get()
b := bb.B
for i := range rows {
bw.buf = logstorage.MarshalFieldsToJSON(bw.buf, rows[i].fields)
bw.buf = append(bw.buf, '\n')
if len(bw.buf) > 16*1024 {
bw.FlushIgnoreErrors()
}
b = logstorage.MarshalFieldsToJSON(b[:0], rows[i].fields)
b = append(b, '\n')
bw.WriteIgnoreErrors(b)
}
bb.B = b
blockResultPool.Put(bb)
return
}
q.AddPipeLimit(uint64(limit))
}
writeBlock := func(workerID uint, db *logstorage.DataBlock) {
rowsCount := db.RowsCount()
if rowsCount == 0 {
writeBlock := func(_ uint, timestamps []int64, columns []logstorage.BlockColumn) {
if len(columns) == 0 || len(columns[0].Values) == 0 {
return
}
columns := db.Columns
bw := bwShards.Get(workerID)
for i := 0; i < rowsCount; i++ {
WriteJSONRow(bw, columns, i)
if len(bw.buf) > 16*1024 {
bw.FlushIgnoreErrors()
}
bb := blockResultPool.Get()
for i := range timestamps {
WriteJSONRow(bb, columns, i)
}
bw.WriteIgnoreErrors(bb.B)
blockResultPool.Put(bb)
}
if err := vlstorage.RunQuery(ctx, tenantIDs, q, writeBlock); err != nil {
@@ -938,37 +917,14 @@ func ProcessQueryRequest(ctx context.Context, w http.ResponseWriter, r *http.Req
}
}
type syncWriter struct {
mu sync.Mutex
w io.Writer
var blockResultPool bytesutil.ByteBufferPool
type row struct {
timestamp int64
fields []logstorage.Field
}
func (sw *syncWriter) Write(p []byte) (int, error) {
sw.mu.Lock()
n, err := sw.w.Write(p)
sw.mu.Unlock()
return n, err
}
type bufferedWriter struct {
buf []byte
sw *syncWriter
}
func (bw *bufferedWriter) Write(p []byte) (int, error) {
bw.buf = append(bw.buf, p...)
// Do not send bw.buf to bw.sw here, since the data at bw.buf may be incomplete (it must end with '\n')
return len(p), nil
}
func (bw *bufferedWriter) FlushIgnoreErrors() {
_, _ = bw.sw.Write(bw.buf)
bw.buf = bw.buf[:0]
}
func getLastNQueryResults(ctx context.Context, tenantIDs []logstorage.TenantID, q *logstorage.Query, limit int) ([]logRow, error) {
func getLastNQueryResults(ctx context.Context, tenantIDs []logstorage.TenantID, q *logstorage.Query, limit int) ([]row, error) {
limitUpper := 2 * limit
q.AddPipeLimit(uint64(limitUpper))
@@ -1037,7 +993,7 @@ func getLastNQueryResults(ctx context.Context, tenantIDs []logstorage.TenantID,
}
}
func getLastNRows(rows []logRow, limit int) []logRow {
func getLastNRows(rows []row, limit int) []row {
sort.Slice(rows, func(i, j int) bool {
return rows[i].timestamp < rows[j].timestamp
})
@@ -1047,31 +1003,18 @@ func getLastNRows(rows []logRow, limit int) []logRow {
return rows
}
func getQueryResultsWithLimit(ctx context.Context, tenantIDs []logstorage.TenantID, q *logstorage.Query, limit int) ([]logRow, error) {
func getQueryResultsWithLimit(ctx context.Context, tenantIDs []logstorage.TenantID, q *logstorage.Query, limit int) ([]row, error) {
ctxWithCancel, cancel := context.WithCancel(ctx)
defer cancel()
var missingTimeColumn atomic.Bool
var rows []logRow
var rows []row
var rowsLock sync.Mutex
writeBlock := func(_ uint, db *logstorage.DataBlock) {
if missingTimeColumn.Load() {
return
}
columns := db.Columns
writeBlock := func(_ uint, timestamps []int64, columns []logstorage.BlockColumn) {
clonedColumnNames := make([]string, len(columns))
for i, c := range columns {
clonedColumnNames[i] = strings.Clone(c.Name)
}
timestamps, ok := db.GetTimestamps()
if !ok {
missingTimeColumn.Store(true)
cancel()
return
}
for i, timestamp := range timestamps {
fields := make([]logstorage.Field, len(columns))
for j := range columns {
@@ -1081,7 +1024,7 @@ func getQueryResultsWithLimit(ctx context.Context, tenantIDs []logstorage.Tenant
}
rowsLock.Lock()
rows = append(rows, logRow{
rows = append(rows, row{
timestamp: timestamp,
fields: fields,
})
@@ -1092,13 +1035,11 @@ func getQueryResultsWithLimit(ctx context.Context, tenantIDs []logstorage.Tenant
cancel()
}
}
err := vlstorage.RunQuery(ctxWithCancel, tenantIDs, q, writeBlock)
if missingTimeColumn.Load() {
return nil, fmt.Errorf("missing _time column in the result for the query [%s]", q)
if err := vlstorage.RunQuery(ctxWithCancel, tenantIDs, q, writeBlock); err != nil {
return nil, err
}
return rows, err
return rows, nil
}
func parseCommonArgs(r *http.Request) (*logstorage.Query, []logstorage.TenantID, error) {

View File

@@ -9,7 +9,6 @@ import (
"strings"
"time"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vlselect/internalselect"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vlselect/logsql"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/cgroup"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/httpserver"
@@ -73,7 +72,7 @@ var vmuiFileServer = http.FileServer(http.FS(vmuiFiles))
func RequestHandler(w http.ResponseWriter, r *http.Request) bool {
path := r.URL.Path
if !strings.HasPrefix(path, "/select/") && !strings.HasPrefix(path, "/internal/select/") {
if !strings.HasPrefix(path, "/select/") {
// Skip requests, which do not start with /select/, since these aren't our requests.
return false
}
@@ -120,24 +119,12 @@ func RequestHandler(w http.ResponseWriter, r *http.Request) bool {
}
defer decRequestConcurrency()
if strings.HasPrefix(path, "/internal/select/") {
// Process internal request from vlselect without timeout (e.g. use ctx instead of ctxWithTimeout),
// since the timeout must be controlled by the vlselect.
internalselect.RequestHandler(ctx, w, r)
return true
}
ok := processSelectRequest(ctxWithTimeout, w, r, path)
if !ok {
return false
}
logRequestErrorIfNeeded(ctxWithTimeout, w, r, startTime)
return true
}
func logRequestErrorIfNeeded(ctx context.Context, w http.ResponseWriter, r *http.Request, startTime time.Time) {
err := ctx.Err()
err := ctxWithTimeout.Err()
switch err {
case nil:
// nothing to do
@@ -153,6 +140,8 @@ func logRequestErrorIfNeeded(ctx context.Context, w http.ResponseWriter, r *http
default:
httpserver.Errorf(w, r, "unexpected error: %s", err)
}
return true
}
func incRequestConcurrency(ctx context.Context, w http.ResponseWriter, r *http.Request) bool {

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -35,10 +35,10 @@
<meta property="og:title" content="UI for VictoriaLogs">
<meta property="og:url" content="https://victoriametrics.com/products/victorialogs/">
<meta property="og:description" content="Explore your log data with VictoriaLogs UI">
<script type="module" crossorigin src="./assets/index-C1OmTur2.js"></script>
<link rel="modulepreload" crossorigin href="./assets/vendor-BSp13qCn.js">
<script type="module" crossorigin src="./assets/index-BgdvCSTM.js"></script>
<link rel="modulepreload" crossorigin href="./assets/vendor-DojlIpLz.js">
<link rel="stylesheet" crossorigin href="./assets/vendor-D1GxaB_c.css">
<link rel="stylesheet" crossorigin href="./assets/index-DoEyHMxg.css">
<link rel="stylesheet" crossorigin href="./assets/index-u4IOGr0E.css">
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>

View File

@@ -10,14 +10,11 @@ import (
"github.com/VictoriaMetrics/metrics"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vlstorage/netinsert"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vlstorage/netselect"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/flagutil"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/fs"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/httpserver"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/logger"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/logstorage"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/promauth"
)
var (
@@ -42,55 +39,14 @@ var (
"the storage stops accepting new data")
forceMergeAuthKey = flagutil.NewPassword("forceMergeAuthKey", "authKey, which must be passed in query string to /internal/force_merge pages. It overrides -httpAuth.*")
storageNodeAddrs = flagutil.NewArrayString("storageNode", "Comma-separated list of TCP addresses for storage nodes to route the ingested logs to and to send select queries to. "+
"If the list is empty, then the ingested logs are stored and queried locally from -storageDataPath")
insertConcurrency = flag.Int("insert.concurrency", 2, "The average number of concurrent data ingestion requests, which can be sent to every -storageNode")
insertDisableCompression = flag.Bool("insert.disableCompression", false, "Whether to disable compression when sending the ingested data to -storageNode nodes. "+
"Disabled compression reduces CPU usage at the cost of higher network usage")
selectDisableCompression = flag.Bool("select.disableCompression", false, "Whether to disable compression for select query responses received from -storageNode nodes. "+
"Disabled compression reduces CPU usage at the cost of higher network usage")
storageNodeUsername = flagutil.NewArrayString("storageNode.username", "Optional basic auth username to use for the corresponding -storageNode")
storageNodePassword = flagutil.NewArrayString("storageNode.password", "Optional basic auth password to use for the corresponding -storageNode")
storageNodePasswordFile = flagutil.NewArrayString("storageNode.passwordFile", "Optional path to basic auth password to use for the corresponding -storageNode. "+
"The file is re-read every second")
storageNodeBearerToken = flagutil.NewArrayString("storageNode.bearerToken", "Optional bearer auth token to use for the corresponding -storageNode")
storageNodeBearerTokenFile = flagutil.NewArrayString("storageNode.bearerTokenFile", "Optional path to bearer token file to use for the corresponding -storageNode. "+
"The token is re-read from the file every second")
storageNodeTLS = flagutil.NewArrayBool("storageNode.tls", "Whether to use TLS (HTTPS) protocol for communicating with the corresponding -storageNode. "+
"By default communication is performed via HTTP")
storageNodeTLSCAFile = flagutil.NewArrayString("storageNode.tlsCAFile", "Optional path to TLS CA file to use for verifying connections to the corresponding -storageNode. "+
"By default, system CA is used")
storageNodeTLSCertFile = flagutil.NewArrayString("storageNode.tlsCertFile", "Optional path to client-side TLS certificate file to use when connecting "+
"to the corresponding -storageNode")
storageNodeTLSKeyFile = flagutil.NewArrayString("storageNode.tlsKeyFile", "Optional path to client-side TLS certificate key to use when connecting to the corresponding -storageNode")
storageNodeTLSServerName = flagutil.NewArrayString("storageNode.tlsServerName", "Optional TLS server name to use for connections to the corresponding -storageNode. "+
"By default, the server name from -storageNode is used")
storageNodeTLSInsecureSkipVerify = flagutil.NewArrayBool("storageNode.tlsInsecureSkipVerify", "Whether to skip tls verification when connecting to the corresponding -storageNode")
)
var localStorage *logstorage.Storage
var localStorageMetrics *metrics.Set
var netstorageInsert *netinsert.Storage
var netstorageSelect *netselect.Storage
// Init initializes vlstorage.
//
// Stop must be called when vlstorage is no longer needed
func Init() {
if len(*storageNodeAddrs) == 0 {
initLocalStorage()
} else {
initNetworkStorage()
}
}
func initLocalStorage() {
if localStorage != nil {
logger.Panicf("BUG: initLocalStorage() has been already called")
if strg != nil {
logger.Panicf("BUG: Init() has been already called")
}
if retentionPeriod.Duration() < 24*time.Hour {
@@ -107,139 +63,60 @@ func initLocalStorage() {
}
logger.Infof("opening storage at -storageDataPath=%s", *storageDataPath)
startTime := time.Now()
localStorage = logstorage.MustOpenStorage(*storageDataPath, cfg)
strg = logstorage.MustOpenStorage(*storageDataPath, cfg)
var ss logstorage.StorageStats
localStorage.UpdateStats(&ss)
strg.UpdateStats(&ss)
logger.Infof("successfully opened storage in %.3f seconds; smallParts: %d; bigParts: %d; smallPartBlocks: %d; bigPartBlocks: %d; smallPartRows: %d; bigPartRows: %d; "+
"smallPartSize: %d bytes; bigPartSize: %d bytes",
time.Since(startTime).Seconds(), ss.SmallParts, ss.BigParts, ss.SmallPartBlocks, ss.BigPartBlocks, ss.SmallPartRowsCount, ss.BigPartRowsCount,
ss.CompressedSmallPartSize, ss.CompressedBigPartSize)
// register local storage metrics
localStorageMetrics = metrics.NewSet()
localStorageMetrics.RegisterMetricsWriter(func(w io.Writer) {
writeStorageMetrics(w, localStorage)
// register storage metrics
storageMetrics = metrics.NewSet()
storageMetrics.RegisterMetricsWriter(func(w io.Writer) {
writeStorageMetrics(w, strg)
})
metrics.RegisterSet(localStorageMetrics)
}
func initNetworkStorage() {
if netstorageInsert != nil || netstorageSelect != nil {
logger.Panicf("BUG: initNetworkStorage() has been already called")
}
authCfgs := make([]*promauth.Config, len(*storageNodeAddrs))
isTLSs := make([]bool, len(*storageNodeAddrs))
for i := range authCfgs {
authCfgs[i] = newAuthConfigForStorageNode(i)
isTLSs[i] = storageNodeTLS.GetOptionalArg(i)
}
logger.Infof("starting insert service for nodes %s", *storageNodeAddrs)
netstorageInsert = netinsert.NewStorage(*storageNodeAddrs, authCfgs, isTLSs, *insertConcurrency, *insertDisableCompression)
logger.Infof("initializing select service for nodes %s", *storageNodeAddrs)
netstorageSelect = netselect.NewStorage(*storageNodeAddrs, authCfgs, isTLSs, *selectDisableCompression)
logger.Infof("initialized all the network services")
}
func newAuthConfigForStorageNode(argIdx int) *promauth.Config {
username := storageNodeUsername.GetOptionalArg(argIdx)
password := storageNodePassword.GetOptionalArg(argIdx)
passwordFile := storageNodePasswordFile.GetOptionalArg(argIdx)
var basicAuthCfg *promauth.BasicAuthConfig
if username != "" || password != "" || passwordFile != "" {
basicAuthCfg = &promauth.BasicAuthConfig{
Username: username,
Password: promauth.NewSecret(password),
PasswordFile: passwordFile,
}
}
token := storageNodeBearerToken.GetOptionalArg(argIdx)
tokenFile := storageNodeBearerTokenFile.GetOptionalArg(argIdx)
tlsCfg := &promauth.TLSConfig{
CAFile: storageNodeTLSCAFile.GetOptionalArg(argIdx),
CertFile: storageNodeTLSCertFile.GetOptionalArg(argIdx),
KeyFile: storageNodeTLSKeyFile.GetOptionalArg(argIdx),
ServerName: storageNodeTLSServerName.GetOptionalArg(argIdx),
InsecureSkipVerify: storageNodeTLSInsecureSkipVerify.GetOptionalArg(argIdx),
}
opts := &promauth.Options{
BasicAuth: basicAuthCfg,
BearerToken: token,
BearerTokenFile: tokenFile,
TLSConfig: tlsCfg,
}
ac, err := opts.NewConfig()
if err != nil {
logger.Panicf("FATAL: cannot populate auth config for storage node #%d: %s", argIdx, err)
}
return ac
metrics.RegisterSet(storageMetrics)
}
// Stop stops vlstorage.
func Stop() {
if localStorage != nil {
metrics.UnregisterSet(localStorageMetrics, true)
localStorageMetrics = nil
metrics.UnregisterSet(storageMetrics, true)
storageMetrics = nil
localStorage.MustClose()
localStorage = nil
} else {
netstorageInsert.MustStop()
netstorageInsert = nil
netstorageSelect.MustStop()
netstorageSelect = nil
}
strg.MustClose()
strg = nil
}
// RequestHandler is a storage request handler.
func RequestHandler(w http.ResponseWriter, r *http.Request) bool {
path := r.URL.Path
if path == "/internal/force_merge" {
return processForceMerge(w, r)
if !httpserver.CheckAuthFlag(w, r, forceMergeAuthKey) {
return true
}
// Run force merge in background
partitionNamePrefix := r.FormValue("partition_prefix")
go func() {
activeForceMerges.Inc()
defer activeForceMerges.Dec()
logger.Infof("forced merge for partition_prefix=%q has been started", partitionNamePrefix)
startTime := time.Now()
strg.MustForceMerge(partitionNamePrefix)
logger.Infof("forced merge for partition_prefix=%q has been successfully finished in %.3f seconds", partitionNamePrefix, time.Since(startTime).Seconds())
}()
return true
}
return false
}
func processForceMerge(w http.ResponseWriter, r *http.Request) bool {
if localStorage == nil {
// Force merge isn't supported by non-local storage
return false
}
var strg *logstorage.Storage
var storageMetrics *metrics.Set
if !httpserver.CheckAuthFlag(w, r, forceMergeAuthKey) {
return true
}
// Run force merge in background
partitionNamePrefix := r.FormValue("partition_prefix")
go func() {
activeForceMerges.Inc()
defer activeForceMerges.Dec()
logger.Infof("forced merge for partition_prefix=%q has been started", partitionNamePrefix)
startTime := time.Now()
localStorage.MustForceMerge(partitionNamePrefix)
logger.Infof("forced merge for partition_prefix=%q has been successfully finished in %.3f seconds", partitionNamePrefix, time.Since(startTime).Seconds())
}()
return true
}
// CanWriteData returns non-nil error if it cannot write data to vlstorage
// CanWriteData returns non-nil error if it cannot write data to vlstorage.
func CanWriteData() error {
if localStorage == nil {
// The data can be always written in non-local mode.
return nil
}
if localStorage.IsReadOnly() {
if strg.IsReadOnly() {
return &httpserver.ErrorWithStatusCode{
Err: fmt.Errorf("cannot add rows into storage in read-only mode; the storage can be in read-only mode "+
"because of lack of free disk space at -storageDataPath=%s", *storageDataPath),
@@ -253,77 +130,50 @@ func CanWriteData() error {
//
// It is advised to call CanWriteData() before calling MustAddRows()
func MustAddRows(lr *logstorage.LogRows) {
if localStorage != nil {
// Store lr in the local storage.
localStorage.MustAddRows(lr)
} else {
// Store lr across the remote storage nodes.
lr.ForEachRow(netstorageInsert.AddRow)
}
strg.MustAddRows(lr)
}
// RunQuery runs the given q and calls writeBlock for the returned data blocks
func RunQuery(ctx context.Context, tenantIDs []logstorage.TenantID, q *logstorage.Query, writeBlock logstorage.WriteDataBlockFunc) error {
if localStorage != nil {
return localStorage.RunQuery(ctx, tenantIDs, q, writeBlock)
}
return netstorageSelect.RunQuery(ctx, tenantIDs, q, writeBlock)
func RunQuery(ctx context.Context, tenantIDs []logstorage.TenantID, q *logstorage.Query, writeBlock logstorage.WriteBlockFunc) error {
return strg.RunQuery(ctx, tenantIDs, q, writeBlock)
}
// GetFieldNames executes q and returns field names seen in results.
func GetFieldNames(ctx context.Context, tenantIDs []logstorage.TenantID, q *logstorage.Query) ([]logstorage.ValueWithHits, error) {
if localStorage != nil {
return localStorage.GetFieldNames(ctx, tenantIDs, q)
}
return netstorageSelect.GetFieldNames(ctx, tenantIDs, q)
return strg.GetFieldNames(ctx, tenantIDs, q)
}
// GetFieldValues executes q and returns unique values for the fieldName seen in results.
//
// If limit > 0, then up to limit unique values are returned.
func GetFieldValues(ctx context.Context, tenantIDs []logstorage.TenantID, q *logstorage.Query, fieldName string, limit uint64) ([]logstorage.ValueWithHits, error) {
if localStorage != nil {
return localStorage.GetFieldValues(ctx, tenantIDs, q, fieldName, limit)
}
return netstorageSelect.GetFieldValues(ctx, tenantIDs, q, fieldName, limit)
return strg.GetFieldValues(ctx, tenantIDs, q, fieldName, limit)
}
// GetStreamFieldNames executes q and returns stream field names seen in results.
func GetStreamFieldNames(ctx context.Context, tenantIDs []logstorage.TenantID, q *logstorage.Query) ([]logstorage.ValueWithHits, error) {
if localStorage != nil {
return localStorage.GetStreamFieldNames(ctx, tenantIDs, q)
}
return netstorageSelect.GetStreamFieldNames(ctx, tenantIDs, q)
return strg.GetStreamFieldNames(ctx, tenantIDs, q)
}
// GetStreamFieldValues executes q and returns stream field values for the given fieldName seen in results.
//
// If limit > 0, then up to limit unique stream field values are returned.
func GetStreamFieldValues(ctx context.Context, tenantIDs []logstorage.TenantID, q *logstorage.Query, fieldName string, limit uint64) ([]logstorage.ValueWithHits, error) {
if localStorage != nil {
return localStorage.GetStreamFieldValues(ctx, tenantIDs, q, fieldName, limit)
}
return netstorageSelect.GetStreamFieldValues(ctx, tenantIDs, q, fieldName, limit)
return strg.GetStreamFieldValues(ctx, tenantIDs, q, fieldName, limit)
}
// GetStreams executes q and returns streams seen in query results.
//
// If limit > 0, then up to limit unique streams are returned.
func GetStreams(ctx context.Context, tenantIDs []logstorage.TenantID, q *logstorage.Query, limit uint64) ([]logstorage.ValueWithHits, error) {
if localStorage != nil {
return localStorage.GetStreams(ctx, tenantIDs, q, limit)
}
return netstorageSelect.GetStreams(ctx, tenantIDs, q, limit)
return strg.GetStreams(ctx, tenantIDs, q, limit)
}
// GetStreamIDs executes q and returns streamIDs seen in query results.
//
// If limit > 0, then up to limit unique streamIDs are returned.
func GetStreamIDs(ctx context.Context, tenantIDs []logstorage.TenantID, q *logstorage.Query, limit uint64) ([]logstorage.ValueWithHits, error) {
if localStorage != nil {
return localStorage.GetStreamIDs(ctx, tenantIDs, q, limit)
}
return netstorageSelect.GetStreamIDs(ctx, tenantIDs, q, limit)
return strg.GetStreamIDs(ctx, tenantIDs, q, limit)
}
func writeStorageMetrics(w io.Writer, strg *logstorage.Storage) {

View File

@@ -1,369 +0,0 @@
package netinsert
import (
"errors"
"fmt"
"io"
"net/http"
"net/url"
"sync"
"sync/atomic"
"time"
"github.com/valyala/fastrand"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/bytesutil"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/contextutil"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/encoding/zstd"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/fasttime"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/httputil"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/logger"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/logstorage"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/promauth"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/timerpool"
)
// the maximum size of a single data block sent to storage node.
const maxInsertBlockSize = 2 * 1024 * 1024
// ProtocolVersion is the version of the data ingestion protocol.
//
// It must be changed every time the data encoding at /internal/insert HTTP endpoint is changed.
const ProtocolVersion = "v1"
// Storage is a network storage for sending data to remote storage nodes in the cluster.
type Storage struct {
sns []*storageNode
disableCompression bool
srt *streamRowsTracker
pendingDataBuffers chan *bytesutil.ByteBuffer
stopCh chan struct{}
wg sync.WaitGroup
}
type storageNode struct {
// scheme is http or https scheme to communicate with addr
scheme string
// addr is TCP address of storage node to send the ingested data to
addr string
// s is a storage, which holds the given storageNode
s *Storage
// c is an http client used for sending data blocks to addr.
c *http.Client
// ac is auth config used for setting request headers such as Authorization and Host.
ac *promauth.Config
// pendingData contains pending data, which must be sent to the storage node at the addr.
pendingDataMu sync.Mutex
pendingData *bytesutil.ByteBuffer
pendingDataLastFlush time.Time
// the unix timestamp until the storageNode is disabled for data writing.
disabledUntil atomic.Uint64
}
func newStorageNode(s *Storage, addr string, ac *promauth.Config, isTLS bool) *storageNode {
tr := httputil.NewTransport(false, "vlinsert_backend")
tr.TLSHandshakeTimeout = 20 * time.Second
tr.DisableCompression = true
scheme := "http"
if isTLS {
scheme = "https"
}
sn := &storageNode{
scheme: scheme,
addr: addr,
s: s,
c: &http.Client{
Transport: ac.NewRoundTripper(tr),
},
ac: ac,
pendingData: &bytesutil.ByteBuffer{},
}
s.wg.Add(1)
go func() {
defer s.wg.Done()
sn.backgroundFlusher()
}()
return sn
}
func (sn *storageNode) backgroundFlusher() {
t := time.NewTicker(time.Second)
defer t.Stop()
for {
select {
case <-sn.s.stopCh:
return
case <-t.C:
sn.flushPendingData()
}
}
}
func (sn *storageNode) flushPendingData() {
sn.pendingDataMu.Lock()
if time.Since(sn.pendingDataLastFlush) < time.Second {
// nothing to flush
sn.pendingDataMu.Unlock()
return
}
pendingData := sn.grabPendingDataForFlushLocked()
sn.pendingDataMu.Unlock()
sn.mustSendInsertRequest(pendingData)
}
func (sn *storageNode) addRow(r *logstorage.InsertRow) {
bb := bbPool.Get()
b := bb.B
b = r.Marshal(b)
if len(b) > maxInsertBlockSize {
logger.Warnf("skipping too long log entry, since its length exceeds %d bytes; the actual log entry length is %d bytes; log entry contents: %s", maxInsertBlockSize, len(b), b)
return
}
var pendingData *bytesutil.ByteBuffer
sn.pendingDataMu.Lock()
if sn.pendingData.Len()+len(b) > maxInsertBlockSize {
pendingData = sn.grabPendingDataForFlushLocked()
}
sn.pendingData.MustWrite(b)
sn.pendingDataMu.Unlock()
bb.B = b
bbPool.Put(bb)
if pendingData != nil {
sn.mustSendInsertRequest(pendingData)
}
}
var bbPool bytesutil.ByteBufferPool
func (sn *storageNode) grabPendingDataForFlushLocked() *bytesutil.ByteBuffer {
sn.pendingDataLastFlush = time.Now()
pendingData := sn.pendingData
sn.pendingData = <-sn.s.pendingDataBuffers
return pendingData
}
func (sn *storageNode) mustSendInsertRequest(pendingData *bytesutil.ByteBuffer) {
defer func() {
pendingData.Reset()
sn.s.pendingDataBuffers <- pendingData
}()
err := sn.sendInsertRequest(pendingData)
if err == nil {
return
}
if !errors.Is(err, errTemporarilyDisabled) {
logger.Warnf("%s; re-routing the data block to the remaining nodes", err)
}
for !sn.s.sendInsertRequestToAnyNode(pendingData) {
logger.Errorf("cannot send pending data to all storage nodes, since all of them are unavailable; re-trying to send the data in a second")
t := timerpool.Get(time.Second)
select {
case <-sn.s.stopCh:
timerpool.Put(t)
logger.Errorf("dropping %d bytes of data, since there are no available storage nodes", pendingData.Len())
return
case <-t.C:
timerpool.Put(t)
}
}
}
func (sn *storageNode) sendInsertRequest(pendingData *bytesutil.ByteBuffer) error {
dataLen := pendingData.Len()
if dataLen == 0 {
// Nothing to send.
return nil
}
if sn.disabledUntil.Load() > fasttime.UnixTimestamp() {
return errTemporarilyDisabled
}
ctx, cancel := contextutil.NewStopChanContext(sn.s.stopCh)
defer cancel()
var body io.Reader
if !sn.s.disableCompression {
bb := zstdBufPool.Get()
defer zstdBufPool.Put(bb)
bb.B = zstd.CompressLevel(bb.B[:0], pendingData.B, 1)
body = bb.NewReader()
} else {
body = pendingData.NewReader()
}
reqURL := sn.getRequestURL("/internal/insert")
req, err := http.NewRequestWithContext(ctx, "POST", reqURL, body)
if err != nil {
logger.Panicf("BUG: unexpected error when creating an http request: %s", err)
}
req.Header.Set("Content-Type", "application/octet-stream")
if !sn.s.disableCompression {
req.Header.Set("Content-Encoding", "zstd")
}
if err := sn.ac.SetHeaders(req, true); err != nil {
return fmt.Errorf("cannot set auth headers for %q: %w", reqURL, err)
}
resp, err := sn.c.Do(req)
if err != nil {
// Disable sn for data writing for 10 seconds.
sn.disabledUntil.Store(fasttime.UnixTimestamp() + 10)
return fmt.Errorf("cannot send data block with the length %d to %q: %s", pendingData.Len(), reqURL, err)
}
defer resp.Body.Close()
if resp.StatusCode/100 == 2 {
return nil
}
respBody, err := io.ReadAll(resp.Body)
if err != nil {
respBody = []byte(fmt.Sprintf("%s", err))
}
// Disable sn for data writing for 10 seconds.
sn.disabledUntil.Store(fasttime.UnixTimestamp() + 10)
return fmt.Errorf("unexpected status code returned when sending data block to %q: %d; want 2xx; response body: %q", reqURL, resp.StatusCode, respBody)
}
func (sn *storageNode) getRequestURL(path string) string {
return fmt.Sprintf("%s://%s%s?version=%s", sn.scheme, sn.addr, path, url.QueryEscape(ProtocolVersion))
}
var zstdBufPool bytesutil.ByteBufferPool
// NewStorage returns new Storage for the given addrs with the given authCfgs.
//
// The concurrency is the average number of concurrent connections per every addr.
//
// If disableCompression is set, then the data is sent uncompressed to the remote storage.
//
// Call MustStop on the returned storage when it is no longer needed.
func NewStorage(addrs []string, authCfgs []*promauth.Config, isTLSs []bool, concurrency int, disableCompression bool) *Storage {
pendingDataBuffers := make(chan *bytesutil.ByteBuffer, concurrency*len(addrs))
for i := 0; i < cap(pendingDataBuffers); i++ {
pendingDataBuffers <- &bytesutil.ByteBuffer{}
}
s := &Storage{
disableCompression: disableCompression,
pendingDataBuffers: pendingDataBuffers,
stopCh: make(chan struct{}),
}
sns := make([]*storageNode, len(addrs))
for i, addr := range addrs {
sns[i] = newStorageNode(s, addr, authCfgs[i], isTLSs[i])
}
s.sns = sns
s.srt = newStreamRowsTracker(len(sns))
return s
}
// MustStop stops the s.
func (s *Storage) MustStop() {
close(s.stopCh)
s.wg.Wait()
s.sns = nil
}
// AddRow adds the given log row into s.
func (s *Storage) AddRow(streamHash uint64, r *logstorage.InsertRow) {
idx := s.srt.getNodeIdx(streamHash)
sn := s.sns[idx]
sn.addRow(r)
}
func (s *Storage) sendInsertRequestToAnyNode(pendingData *bytesutil.ByteBuffer) bool {
startIdx := int(fastrand.Uint32n(uint32(len(s.sns))))
for i := range s.sns {
idx := (startIdx + i) % len(s.sns)
sn := s.sns[idx]
err := sn.sendInsertRequest(pendingData)
if err == nil {
return true
}
if !errors.Is(err, errTemporarilyDisabled) {
logger.Warnf("cannot send pending data to the storage node %q: %s; trying to send it to another storage node", sn.addr, err)
}
}
return false
}
var errTemporarilyDisabled = fmt.Errorf("writing to the node is temporarily disabled")
type streamRowsTracker struct {
mu sync.Mutex
nodesCount int64
rowsPerStream map[uint64]uint64
}
func newStreamRowsTracker(nodesCount int) *streamRowsTracker {
return &streamRowsTracker{
nodesCount: int64(nodesCount),
rowsPerStream: make(map[uint64]uint64),
}
}
func (srt *streamRowsTracker) getNodeIdx(streamHash uint64) uint64 {
if srt.nodesCount == 1 {
// Fast path for a single node.
return 0
}
srt.mu.Lock()
defer srt.mu.Unlock()
streamRows := srt.rowsPerStream[streamHash] + 1
srt.rowsPerStream[streamHash] = streamRows
if streamRows <= 1000 {
// Write the initial rows for the stream to a single storage node for better locality.
// This should work great for log streams containing small number of logs, since will be distributed
// evenly among available storage nodes because they have different streamHash.
return streamHash % uint64(srt.nodesCount)
}
// The log stream contains more than 1000 rows. Distribute them among storage nodes at random
// in order to improve query performance over this stream (the data for the log stream
// can be processed in parallel on all the storage nodes).
//
// The random distribution is preferred over round-robin distribution in order to avoid possible
// dependency between the order of the ingested logs and the number of storage nodes,
// which may lead to non-uniform distribution of logs among storage nodes.
return uint64(fastrand.Uint32n(uint32(srt.nodesCount)))
}

View File

@@ -1,57 +0,0 @@
package netinsert
import (
"fmt"
"math"
"math/rand"
"testing"
"github.com/cespare/xxhash/v2"
)
func TestStreamRowsTracker(t *testing.T) {
f := func(rowsCount, streamsCount, nodesCount int) {
t.Helper()
// generate stream hashes
streamHashes := make([]uint64, streamsCount)
for i := range streamHashes {
streamHashes[i] = xxhash.Sum64([]byte(fmt.Sprintf("stream %d.", i)))
}
srt := newStreamRowsTracker(nodesCount)
rng := rand.New(rand.NewSource(0))
rowsPerNode := make([]uint64, nodesCount)
for i := 0; i < rowsCount; i++ {
streamIdx := rng.Intn(streamsCount)
h := streamHashes[streamIdx]
nodeIdx := srt.getNodeIdx(h)
rowsPerNode[nodeIdx]++
}
// Verify that rows are uniformly distributed among nodes.
expectedRowsPerNode := float64(rowsCount) / float64(nodesCount)
for nodeIdx, nodeRows := range rowsPerNode {
if math.Abs(float64(nodeRows)-expectedRowsPerNode)/expectedRowsPerNode > 0.15 {
t.Fatalf("non-uniform distribution of rows among nodes; node %d has %d rows, while it must have %v rows; rowsPerNode=%d",
nodeIdx, nodeRows, expectedRowsPerNode, rowsPerNode)
}
}
}
rowsCount := 10000
streamsCount := 9
nodesCount := 2
f(rowsCount, streamsCount, nodesCount)
rowsCount = 10000
streamsCount = 100
nodesCount = 2
f(rowsCount, streamsCount, nodesCount)
rowsCount = 100000
streamsCount = 1000
nodesCount = 9
f(rowsCount, streamsCount, nodesCount)
}

View File

@@ -1,469 +0,0 @@
package netselect
import (
"context"
"errors"
"fmt"
"io"
"math"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/bytesutil"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/contextutil"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/encoding"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/encoding/zstd"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/httputil"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/logger"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/logstorage"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/promauth"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/slicesutil"
)
const (
// FieldNamesProtocolVersion is the version of the protocol used for /internal/select/field_names HTTP endpoint.
//
// It must be updated every time the protocol changes.
FieldNamesProtocolVersion = "v1"
// FieldValuesProtocolVersion is the version of the protocol used for /internal/select/field_values HTTP endpoint.
//
// It must be updated every time the protocol changes.
FieldValuesProtocolVersion = "v1"
// StreamFieldNamesProtocolVersion is the version of the protocol used for /internal/select/stream_field_names HTTP endpoint.
//
// It must be updated every time the protocol changes.
StreamFieldNamesProtocolVersion = "v1"
// StreamFieldValuesProtocolVersion is the version of the protocol used for /internal/select/stream_field_values HTTP endpoint.
//
// It must be updated every time the protocol changes.
StreamFieldValuesProtocolVersion = "v1"
// StreamsProtocolVersion is the version of the protocol used for /internal/select/streams HTTP endpoint.
//
// It must be updated every time the protocol changes.
StreamsProtocolVersion = "v1"
// StreamIDsProtocolVersion is the version of the protocol used for /internal/select/stream_ids HTTP endpoint.
//
// It must be updated every time the protocol changes.
StreamIDsProtocolVersion = "v1"
// QueryProtocolVersion is the version of the protocol used for /internal/select/query HTTP endpoint.
//
// It must be updated every time the protocol changes.
QueryProtocolVersion = "v1"
)
// Storage is a network storage for querying remote storage nodes in the cluster.
type Storage struct {
sns []*storageNode
disableCompression bool
}
type storageNode struct {
// scheme is http or https scheme to communicate with addr
scheme string
// addr is TCP address of the storage node to query
addr string
// s is a storage, which holds the given storageNode
s *Storage
// c is an http client used for querying storage node at addr.
c *http.Client
// ac is auth config used for setting request headers such as Authorization and Host.
ac *promauth.Config
}
func newStorageNode(s *Storage, addr string, ac *promauth.Config, isTLS bool) *storageNode {
tr := httputil.NewTransport(false, "vlselect_backend")
tr.TLSHandshakeTimeout = 20 * time.Second
tr.DisableCompression = true
scheme := "http"
if isTLS {
scheme = "https"
}
sn := &storageNode{
scheme: scheme,
addr: addr,
s: s,
c: &http.Client{
Transport: ac.NewRoundTripper(tr),
},
ac: ac,
}
return sn
}
func (sn *storageNode) runQuery(ctx context.Context, tenantIDs []logstorage.TenantID, q *logstorage.Query, processBlock func(db *logstorage.DataBlock)) error {
args := sn.getCommonArgs(QueryProtocolVersion, tenantIDs, q)
reqURL := sn.getRequestURL("/internal/select/query", args)
req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil)
if err != nil {
logger.Panicf("BUG: unexpected error when creating a request: %s", err)
}
if err := sn.ac.SetHeaders(req, true); err != nil {
return fmt.Errorf("cannot set auth headers for %q: %w", reqURL, err)
}
// send the request to the storage node
resp, err := sn.c.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
responseBody = []byte(err.Error())
}
return fmt.Errorf("unexpected status code for the request to %q: %d; want %d; response: %q", reqURL, resp.StatusCode, http.StatusOK, responseBody)
}
// read the response
var dataLenBuf [8]byte
var buf []byte
var db logstorage.DataBlock
var valuesBuf []string
for {
if _, err := io.ReadFull(resp.Body, dataLenBuf[:]); err != nil {
if errors.Is(err, io.EOF) {
// The end of response stream
return nil
}
return fmt.Errorf("cannot read block size from %q: %w", reqURL, err)
}
blockLen := encoding.UnmarshalUint64(dataLenBuf[:])
if blockLen > math.MaxInt {
return fmt.Errorf("too big data block: %d bytes; mustn't exceed %v bytes", blockLen, math.MaxInt)
}
buf = slicesutil.SetLength(buf, int(blockLen))
if _, err := io.ReadFull(resp.Body, buf); err != nil {
return fmt.Errorf("cannot read block with size of %d bytes from %q: %w", blockLen, reqURL, err)
}
src := buf
if !sn.s.disableCompression {
bufLen := len(buf)
var err error
buf, err = zstd.Decompress(buf, buf)
if err != nil {
return fmt.Errorf("cannot decompress data block: %w", err)
}
src = buf[bufLen:]
}
for len(src) > 0 {
tail, vb, err := db.UnmarshalInplace(src, valuesBuf[:0])
if err != nil {
return fmt.Errorf("cannot unmarshal data block received from %q: %w", reqURL, err)
}
valuesBuf = vb
src = tail
processBlock(&db)
clear(valuesBuf)
}
}
}
func (sn *storageNode) getFieldNames(ctx context.Context, tenantIDs []logstorage.TenantID, q *logstorage.Query) ([]logstorage.ValueWithHits, error) {
args := sn.getCommonArgs(FieldNamesProtocolVersion, tenantIDs, q)
return sn.getValuesWithHits(ctx, "/internal/select/field_names", args)
}
func (sn *storageNode) getFieldValues(ctx context.Context, tenantIDs []logstorage.TenantID, q *logstorage.Query, fieldName string, limit uint64) ([]logstorage.ValueWithHits, error) {
args := sn.getCommonArgs(FieldValuesProtocolVersion, tenantIDs, q)
args.Set("field", fieldName)
args.Set("limit", fmt.Sprintf("%d", limit))
return sn.getValuesWithHits(ctx, "/internal/select/field_values", args)
}
func (sn *storageNode) getStreamFieldNames(ctx context.Context, tenantIDs []logstorage.TenantID, q *logstorage.Query) ([]logstorage.ValueWithHits, error) {
args := sn.getCommonArgs(StreamFieldNamesProtocolVersion, tenantIDs, q)
return sn.getValuesWithHits(ctx, "/internal/select/stream_field_names", args)
}
func (sn *storageNode) getStreamFieldValues(ctx context.Context, tenantIDs []logstorage.TenantID, q *logstorage.Query, fieldName string, limit uint64) ([]logstorage.ValueWithHits, error) {
args := sn.getCommonArgs(StreamFieldValuesProtocolVersion, tenantIDs, q)
args.Set("field", fieldName)
args.Set("limit", fmt.Sprintf("%d", limit))
return sn.getValuesWithHits(ctx, "/internal/select/stream_field_values", args)
}
func (sn *storageNode) getStreams(ctx context.Context, tenantIDs []logstorage.TenantID, q *logstorage.Query, limit uint64) ([]logstorage.ValueWithHits, error) {
args := sn.getCommonArgs(StreamsProtocolVersion, tenantIDs, q)
args.Set("limit", fmt.Sprintf("%d", limit))
return sn.getValuesWithHits(ctx, "/internal/select/streams", args)
}
func (sn *storageNode) getStreamIDs(ctx context.Context, tenantIDs []logstorage.TenantID, q *logstorage.Query, limit uint64) ([]logstorage.ValueWithHits, error) {
args := sn.getCommonArgs(StreamIDsProtocolVersion, tenantIDs, q)
args.Set("limit", fmt.Sprintf("%d", limit))
return sn.getValuesWithHits(ctx, "/internal/select/stream_ids", args)
}
func (sn *storageNode) getCommonArgs(version string, tenantIDs []logstorage.TenantID, q *logstorage.Query) url.Values {
args := url.Values{}
args.Set("version", version)
args.Set("tenant_ids", string(logstorage.MarshalTenantIDs(nil, tenantIDs)))
args.Set("query", q.String())
args.Set("timestamp", fmt.Sprintf("%d", q.GetTimestamp()))
args.Set("disable_compression", fmt.Sprintf("%v", sn.s.disableCompression))
return args
}
func (sn *storageNode) getValuesWithHits(ctx context.Context, path string, args url.Values) ([]logstorage.ValueWithHits, error) {
data, err := sn.executeRequestAt(ctx, path, args)
if err != nil {
return nil, err
}
return unmarshalValuesWithHits(data)
}
func (sn *storageNode) executeRequestAt(ctx context.Context, path string, args url.Values) ([]byte, error) {
reqURL := sn.getRequestURL(path, args)
req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil)
if err != nil {
logger.Panicf("BUG: unexpected error when creating a request: %s", err)
}
// send the request to the storage node
resp, err := sn.c.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
responseBody = []byte(err.Error())
}
return nil, fmt.Errorf("unexpected status code for the request to %q: %d; want %d; response: %q", reqURL, resp.StatusCode, http.StatusOK, responseBody)
}
// read the response
var bb bytesutil.ByteBuffer
if _, err := bb.ReadFrom(resp.Body); err != nil {
return nil, fmt.Errorf("cannot read response from %q: %w", reqURL, err)
}
if sn.s.disableCompression {
return bb.B, nil
}
bbLen := len(bb.B)
bb.B, err = zstd.Decompress(bb.B, bb.B)
if err != nil {
return nil, err
}
return bb.B[bbLen:], nil
}
func (sn *storageNode) getRequestURL(path string, args url.Values) string {
return fmt.Sprintf("%s://%s%s?%s", sn.scheme, sn.addr, path, args.Encode())
}
// NewStorage returns new Storage for the given addrs and the given authCfgs.
//
// If disableCompression is set, then uncompressed responses are received from storage nodes.
//
// Call MustStop on the returned storage when it is no longer needed.
func NewStorage(addrs []string, authCfgs []*promauth.Config, isTLSs []bool, disableCompression bool) *Storage {
s := &Storage{
disableCompression: disableCompression,
}
sns := make([]*storageNode, len(addrs))
for i, addr := range addrs {
sns[i] = newStorageNode(s, addr, authCfgs[i], isTLSs[i])
}
s.sns = sns
return s
}
// MustStop stops the s.
func (s *Storage) MustStop() {
s.sns = nil
}
// RunQuery runs the given q and calls writeBlock for the returned data blocks
func (s *Storage) RunQuery(ctx context.Context, tenantIDs []logstorage.TenantID, q *logstorage.Query, writeBlock logstorage.WriteDataBlockFunc) error {
nqr, err := logstorage.NewNetQueryRunner(ctx, tenantIDs, q, s.RunQuery, writeBlock)
if err != nil {
return err
}
search := func(stopCh <-chan struct{}, q *logstorage.Query, writeBlock logstorage.WriteDataBlockFunc) error {
return s.runQuery(stopCh, tenantIDs, q, writeBlock)
}
concurrency := q.GetConcurrency()
return nqr.Run(ctx, concurrency, search)
}
func (s *Storage) runQuery(stopCh <-chan struct{}, tenantIDs []logstorage.TenantID, q *logstorage.Query, writeBlock logstorage.WriteDataBlockFunc) error {
ctxWithCancel, cancel := contextutil.NewStopChanContext(stopCh)
defer cancel()
errs := make([]error, len(s.sns))
var wg sync.WaitGroup
for i := range s.sns {
wg.Add(1)
go func(nodeIdx int) {
defer wg.Done()
sn := s.sns[nodeIdx]
err := sn.runQuery(ctxWithCancel, tenantIDs, q, func(db *logstorage.DataBlock) {
writeBlock(uint(nodeIdx), db)
})
if err != nil {
// Cancel the remaining parallel queries
cancel()
}
errs[nodeIdx] = err
}(i)
}
wg.Wait()
return getFirstNonCancelError(errs)
}
// GetFieldNames executes q and returns field names seen in results.
func (s *Storage) GetFieldNames(ctx context.Context, tenantIDs []logstorage.TenantID, q *logstorage.Query) ([]logstorage.ValueWithHits, error) {
return s.getValuesWithHits(ctx, 0, false, func(ctx context.Context, sn *storageNode) ([]logstorage.ValueWithHits, error) {
return sn.getFieldNames(ctx, tenantIDs, q)
})
}
// GetFieldValues executes q and returns unique values for the fieldName seen in results.
//
// If limit > 0, then up to limit unique values are returned.
func (s *Storage) GetFieldValues(ctx context.Context, tenantIDs []logstorage.TenantID, q *logstorage.Query, fieldName string, limit uint64) ([]logstorage.ValueWithHits, error) {
return s.getValuesWithHits(ctx, limit, true, func(ctx context.Context, sn *storageNode) ([]logstorage.ValueWithHits, error) {
return sn.getFieldValues(ctx, tenantIDs, q, fieldName, limit)
})
}
// GetStreamFieldNames executes q and returns stream field names seen in results.
func (s *Storage) GetStreamFieldNames(ctx context.Context, tenantIDs []logstorage.TenantID, q *logstorage.Query) ([]logstorage.ValueWithHits, error) {
return s.getValuesWithHits(ctx, 0, false, func(ctx context.Context, sn *storageNode) ([]logstorage.ValueWithHits, error) {
return sn.getStreamFieldNames(ctx, tenantIDs, q)
})
}
// GetStreamFieldValues executes q and returns stream field values for the given fieldName seen in results.
//
// If limit > 0, then up to limit unique stream field values are returned.
func (s *Storage) GetStreamFieldValues(ctx context.Context, tenantIDs []logstorage.TenantID, q *logstorage.Query, fieldName string, limit uint64) ([]logstorage.ValueWithHits, error) {
return s.getValuesWithHits(ctx, limit, true, func(ctx context.Context, sn *storageNode) ([]logstorage.ValueWithHits, error) {
return sn.getStreamFieldValues(ctx, tenantIDs, q, fieldName, limit)
})
}
// GetStreams executes q and returns streams seen in query results.
//
// If limit > 0, then up to limit unique streams are returned.
func (s *Storage) GetStreams(ctx context.Context, tenantIDs []logstorage.TenantID, q *logstorage.Query, limit uint64) ([]logstorage.ValueWithHits, error) {
return s.getValuesWithHits(ctx, limit, true, func(ctx context.Context, sn *storageNode) ([]logstorage.ValueWithHits, error) {
return sn.getStreams(ctx, tenantIDs, q, limit)
})
}
// GetStreamIDs executes q and returns streamIDs seen in query results.
//
// If limit > 0, then up to limit unique streamIDs are returned.
func (s *Storage) GetStreamIDs(ctx context.Context, tenantIDs []logstorage.TenantID, q *logstorage.Query, limit uint64) ([]logstorage.ValueWithHits, error) {
return s.getValuesWithHits(ctx, limit, true, func(ctx context.Context, sn *storageNode) ([]logstorage.ValueWithHits, error) {
return sn.getStreamIDs(ctx, tenantIDs, q, limit)
})
}
func (s *Storage) getValuesWithHits(ctx context.Context, limit uint64, resetHitsOnLimitExceeded bool,
callback func(ctx context.Context, sn *storageNode) ([]logstorage.ValueWithHits, error)) ([]logstorage.ValueWithHits, error) {
ctxWithCancel, cancel := context.WithCancel(ctx)
defer cancel()
results := make([][]logstorage.ValueWithHits, len(s.sns))
errs := make([]error, len(s.sns))
var wg sync.WaitGroup
for i := range s.sns {
wg.Add(1)
go func(nodeIdx int) {
defer wg.Done()
sn := s.sns[nodeIdx]
vhs, err := callback(ctxWithCancel, sn)
results[nodeIdx] = vhs
errs[nodeIdx] = err
if err != nil {
// Cancel the remaining parallel requests
cancel()
}
}(i)
}
wg.Wait()
if err := getFirstNonCancelError(errs); err != nil {
return nil, err
}
vhs := logstorage.MergeValuesWithHits(results, limit, resetHitsOnLimitExceeded)
return vhs, nil
}
func getFirstNonCancelError(errs []error) error {
for _, err := range errs {
if err != nil && !errors.Is(err, context.Canceled) {
return err
}
}
return nil
}
func unmarshalValuesWithHits(src []byte) ([]logstorage.ValueWithHits, error) {
var vhs []logstorage.ValueWithHits
for len(src) > 0 {
var vh logstorage.ValueWithHits
tail, err := vh.UnmarshalInplace(src)
if err != nil {
return nil, fmt.Errorf("cannot unmarshal ValueWithHits #%d: %w", len(vhs), err)
}
src = tail
// Clone vh.Value, since it points to src.
vh.Value = strings.Clone(vh.Value)
vhs = append(vhs, vh)
}
return vhs, nil
}

View File

@@ -10,22 +10,20 @@ import (
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/awsapi"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/encoding"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/encoding/zstd"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/flagutil"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/httputil"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/logger"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/persistentqueue"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/promauth"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/protoparser/protoparserutil"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/ratelimiter"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/timerpool"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/timeutil"
"github.com/VictoriaMetrics/metrics"
"github.com/golang/snappy"
)
var (
@@ -90,8 +88,7 @@ type client struct {
remoteWriteURL string
// Whether to use VictoriaMetrics remote write protocol for sending the data to remoteWriteURL
useVMProto atomic.Bool
canDowngradeVMProto atomic.Bool
useVMProto bool
fq *persistentqueue.FastQueue
hc *http.Client
@@ -170,11 +167,17 @@ func newHTTPClient(argIdx int, remoteWriteURL, sanitizedURL string, fq *persiste
logger.Fatalf("-remoteWrite.useVMProto and -remoteWrite.usePromProto cannot be set simultaneously for -remoteWrite.url=%s", sanitizedURL)
}
if !useVMProto && !usePromProto {
// The VM protocol could be downgraded later at runtime if unsupported media type response status is received.
useVMProto = true
c.canDowngradeVMProto.Store(true)
// Auto-detect whether the remote storage supports VictoriaMetrics remote write protocol.
doRequest := func(url string) (*http.Response, error) {
return c.doRequest(url, nil)
}
useVMProto = protoparserutil.HandleVMProtoClientHandshake(c.remoteWriteURL, doRequest)
if !useVMProto {
logger.Infof("the remote storage at %q doesn't support VictoriaMetrics remote write protocol. Switching to Prometheus remote write protocol. "+
"See https://docs.victoriametrics.com/vmagent/#victoriametrics-remote-write-protocol", sanitizedURL)
}
}
c.useVMProto.Store(useVMProto)
c.useVMProto = useVMProto
return c
}
@@ -418,7 +421,7 @@ again:
if retryDuration > maxRetryDuration {
retryDuration = maxRetryDuration
}
remoteWriteRetryLogger.Warnf("couldn't send a block with size %d bytes to %q: %s; re-sending the block in %.3f seconds",
logger.Warnf("couldn't send a block with size %d bytes to %q: %s; re-sending the block in %.3f seconds",
len(block), c.sanitizedURL, err, retryDuration.Seconds())
t := timerpool.Get(retryDuration)
select {
@@ -431,7 +434,6 @@ again:
c.retriesCount.Inc()
goto again
}
statusCode := resp.StatusCode
if statusCode/100 == 2 {
_ = resp.Body.Close()
@@ -440,43 +442,21 @@ again:
c.blocksSent.Inc()
return true
}
metrics.GetOrCreateCounter(fmt.Sprintf(`vmagent_remotewrite_requests_total{url=%q, status_code="%d"}`, c.sanitizedURL, statusCode)).Inc()
if statusCode == 409 {
logBlockRejected(block, c.sanitizedURL, resp)
// Just drop block on 409 status code like Prometheus does.
// See https://github.com/VictoriaMetrics/VictoriaMetrics/issues/873
// and https://github.com/VictoriaMetrics/VictoriaMetrics/issues/1149
if statusCode == 409 || statusCode == 400 {
body, err := io.ReadAll(resp.Body)
_ = resp.Body.Close()
c.packetsDropped.Inc()
return true
// - Remote Write v1 specification implicitly expects a `400 Bad Request` when the encoding is not supported.
// - Remote Write v2 specification explicitly specifies a `415 Unsupported Media Type` for unsupported encodings.
// - Real-world implementations of v1 use both 400 and 415 status codes.
// See more in research: https://github.com/VictoriaMetrics/VictoriaMetrics/pull/8462#issuecomment-2786918054
} else if statusCode == 415 || statusCode == 400 {
if c.canDowngradeVMProto.Swap(false) {
logger.Infof("received unsupported media type or bad request from remote storage at %q. Downgrading protocol from VictoriaMetrics to Prometheus remote write for all future requests. "+
"See https://docs.victoriametrics.com/vmagent/#victoriametrics-remote-write-protocol", c.sanitizedURL)
c.useVMProto.Store(false)
if err != nil {
remoteWriteRejectedLogger.Errorf("sending a block with size %d bytes to %q was rejected (skipping the block): status code %d; "+
"failed to read response body: %s",
len(block), c.sanitizedURL, statusCode, err)
} else {
remoteWriteRejectedLogger.Errorf("sending a block with size %d bytes to %q was rejected (skipping the block): status code %d; response body: %s",
len(block), c.sanitizedURL, statusCode, string(body))
}
if encoding.IsZstd(block) {
logger.Infof("received unsupported media type or bad request from remote storage at %q. Re-packing the block to Prometheus remote write and retrying."+
"See https://docs.victoriametrics.com/vmagent/#victoriametrics-remote-write-protocol", c.sanitizedURL)
block = mustRepackBlockFromZstdToSnappy(block)
c.retriesCount.Inc()
_ = resp.Body.Close()
goto again
}
// Just drop snappy blocks on 400 or 415 status codes like Prometheus does.
// Just drop block on 409 and 400 status codes like Prometheus does.
// See https://github.com/VictoriaMetrics/VictoriaMetrics/issues/873
// and https://github.com/VictoriaMetrics/VictoriaMetrics/issues/1149
logBlockRejected(block, c.sanitizedURL, resp)
_ = resp.Body.Close()
c.packetsDropped.Inc()
return true
@@ -509,7 +489,6 @@ again:
}
var remoteWriteRejectedLogger = logger.WithThrottler("remoteWriteRejected", 5*time.Second)
var remoteWriteRetryLogger = logger.WithThrottler("remoteWriteRetry", 5*time.Second)
// getRetryDuration returns retry duration.
// retryAfterDuration has the highest priority.
@@ -532,28 +511,6 @@ func getRetryDuration(retryAfterDuration, retryDuration, maxRetryDuration time.D
return retryDuration
}
func mustRepackBlockFromZstdToSnappy(zstdBlock []byte) []byte {
plainBlock := make([]byte, 0, len(zstdBlock)*2)
plainBlock, err := zstd.Decompress(plainBlock, zstdBlock)
if err != nil {
logger.Panicf("FATAL: cannot re-pack block with size %d bytes from Zstd to Snappy: %s", len(zstdBlock), err)
}
return snappy.Encode(nil, plainBlock)
}
func logBlockRejected(block []byte, sanitizedURL string, resp *http.Response) {
body, err := io.ReadAll(resp.Body)
if err != nil {
remoteWriteRejectedLogger.Errorf("sending a block with size %d bytes to %q was rejected (skipping the block): status code %d; "+
"failed to read response body: %s",
len(block), sanitizedURL, resp.StatusCode, err)
} else {
remoteWriteRejectedLogger.Errorf("sending a block with size %d bytes to %q was rejected (skipping the block): status code %d; response body: %s",
len(block), sanitizedURL, resp.StatusCode, string(body))
}
}
// parseRetryAfterHeader parses `Retry-After` value retrieved from HTTP response header.
// retryAfterString should be in either HTTP-date or a number of seconds.
// It will return time.Duration(0) if `retryAfterString` does not follow RFC 7231.

View File

@@ -5,9 +5,6 @@ import (
"net/http"
"testing"
"time"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/encoding"
"github.com/golang/snappy"
)
func TestCalculateRetryDuration(t *testing.T) {
@@ -100,19 +97,3 @@ func helper(d time.Duration) time.Duration {
return d + dv
}
func TestRepackBlockFromZstdToSnappy(t *testing.T) {
expectedPlainBlock := []byte(`foobar`)
zstdBlock := encoding.CompressZSTDLevel(nil, expectedPlainBlock, 1)
snappyBlock := mustRepackBlockFromZstdToSnappy(zstdBlock)
actualPlainBlock, err := snappy.Decode(nil, snappyBlock)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
if string(actualPlainBlock) != string(expectedPlainBlock) {
t.Fatalf("unexpected plain block; got %q; want %q", actualPlainBlock, expectedPlainBlock)
}
}

View File

@@ -40,7 +40,7 @@ type pendingSeries struct {
periodicFlusherWG sync.WaitGroup
}
func newPendingSeries(fq *persistentqueue.FastQueue, isVMRemoteWrite *atomic.Bool, significantFigures, roundDigits int) *pendingSeries {
func newPendingSeries(fq *persistentqueue.FastQueue, isVMRemoteWrite bool, significantFigures, roundDigits int) *pendingSeries {
var ps pendingSeries
ps.wr.fq = fq
ps.wr.isVMRemoteWrite = isVMRemoteWrite
@@ -100,7 +100,7 @@ type writeRequest struct {
fq *persistentqueue.FastQueue
// Whether to encode the write request with VictoriaMetrics remote write protocol.
isVMRemoteWrite *atomic.Bool
isVMRemoteWrite bool
// How many significant figures must be left before sending the writeRequest to fq.
significantFigures int
@@ -138,7 +138,7 @@ func (wr *writeRequest) reset() {
// This is needed in order to properly save in-memory data to persistent queue on graceful shutdown.
func (wr *writeRequest) mustFlushOnStop() {
wr.wr.Timeseries = wr.tss
if !tryPushWriteRequest(&wr.wr, wr.mustWriteBlock, wr.isVMRemoteWrite.Load()) {
if !tryPushWriteRequest(&wr.wr, wr.mustWriteBlock, wr.isVMRemoteWrite) {
logger.Panicf("BUG: final flush must always return true")
}
wr.reset()
@@ -152,7 +152,7 @@ func (wr *writeRequest) mustWriteBlock(block []byte) bool {
func (wr *writeRequest) tryFlush() bool {
wr.wr.Timeseries = wr.tss
wr.lastFlushTime.Store(fasttime.UnixTimestamp())
if !tryPushWriteRequest(&wr.wr, wr.fq.TryWriteBlock, wr.isVMRemoteWrite.Load()) {
if !tryPushWriteRequest(&wr.wr, wr.fq.TryWriteBlock, wr.isVMRemoteWrite) {
return false
}
wr.reset()

View File

@@ -807,7 +807,7 @@ func newRemoteWriteCtx(argIdx int, remoteWriteURL *url.URL, maxInmemoryBlocks in
}
pss := make([]*pendingSeries, pssLen)
for i := range pss {
pss[i] = newPendingSeries(fq, &c.useVMProto, sf, rd)
pss[i] = newPendingSeries(fq, c.useVMProto, sf, rd)
}
rwctx := &remoteWriteCtx{

View File

@@ -4,7 +4,6 @@ import (
"fmt"
"math"
"reflect"
"sync/atomic"
"testing"
"time"
@@ -69,9 +68,7 @@ func TestRemoteWriteContext_TryPush_ImmutableTimeseries(t *testing.T) {
allRelabelConfigs.Store(rcs)
pss := make([]*pendingSeries, 1)
isVMProto := &atomic.Bool{}
isVMProto.Store(true)
pss[0] = newPendingSeries(nil, isVMProto, 0, 100)
pss[0] = newPendingSeries(nil, true, 0, 100)
rwctx := &remoteWriteCtx{
idx: 0,
streamAggrKeepInput: keepInput,

View File

@@ -2,6 +2,7 @@ package config
import (
"bytes"
"crypto/md5"
"flag"
"fmt"
"hash/fnv"
@@ -10,12 +11,11 @@ import (
"sort"
"strings"
"gopkg.in/yaml.v2"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vmalert/config/log"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vmalert/vmalertutil"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/envtemplate"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/promutil"
"gopkg.in/yaml.v2"
)
var (
@@ -67,7 +67,7 @@ func (g *Group) UnmarshalYAML(unmarshal func(any) error) error {
if g.Type.Get() == "" {
g.Type = NewRawType(*defaultRuleType)
}
h := fnv.New64a()
h := md5.New()
h.Write(b)
g.Checksum = fmt.Sprintf("%x", h.Sum(nil))
return nil

View File

@@ -1,8 +1,8 @@
package notifier
import (
"crypto/md5"
"fmt"
"hash/fnv"
"net/url"
"os"
"path"
@@ -99,7 +99,7 @@ func (cfg *Config) UnmarshalYAML(unmarshal func(any) error) error {
if err != nil {
return fmt.Errorf("failed to marshal configuration for checksum: %w", err)
}
h := fnv.New64a()
h := md5.New()
h.Write(b)
cfg.Checksum = fmt.Sprintf("%x", h.Sum(nil))
return nil

View File

@@ -146,13 +146,8 @@ func (c *Client) Close() error {
return fmt.Errorf("client is already closed")
}
close(c.input)
start := time.Now()
logger.Infof("shutting down remote write client: flushing remained series")
close(c.doneCh)
c.wg.Wait()
logger.Infof("shutting down remote write client: finished in %v", time.Since(start))
return nil
}
@@ -161,16 +156,21 @@ func (c *Client) run(ctx context.Context) {
wr := &prompbmarshal.WriteRequest{}
shutdown := func() {
lastCtx, cancel := context.WithTimeout(context.Background(), defaultWriteTimeout)
logger.Infof("shutting down remote write client and flushing remained series")
shutdownFlushCnt := 0
for ts := range c.input {
wr.Timeseries = append(wr.Timeseries, ts)
if len(wr.Timeseries) >= c.maxBatchSize {
shutdownFlushCnt += len(wr.Timeseries)
c.flush(lastCtx, wr)
}
}
// flush the last batch. `flush` will re-check and avoid flushing empty batch.
shutdownFlushCnt += len(wr.Timeseries)
c.flush(lastCtx, wr)
logger.Infof("shutting down remote write client flushed %d series", shutdownFlushCnt)
cancel()
}
c.wg.Add(1)

View File

@@ -36,178 +36,145 @@ func TestMain(m *testing.M) {
}
func TestUpdateWith(t *testing.T) {
f := func(oldG, newG config.Group) {
f := func(currentRules, newRules []config.Rule) {
t.Helper()
ns := metrics.NewSet()
g := &Group{
Name: "test",
metrics: &groupMetrics{set: ns},
}
qb := &datasource.FakeQuerier{}
for i := range oldG.Rules {
oldG.Rules[i].ID = config.HashRule(oldG.Rules[i])
}
for i := range newG.Rules {
newG.Rules[i].ID = config.HashRule(newG.Rules[i])
for _, r := range currentRules {
r.ID = config.HashRule(r)
g.Rules = append(g.Rules, g.newRule(qb, r))
}
g := NewGroup(oldG, qb, 0, nil)
g.metrics = &groupMetrics{set: ns}
expect := NewGroup(newG, qb, 0, nil)
ng := &Group{
Name: "test",
}
for _, r := range newRules {
r.ID = config.HashRule(r)
ng.Rules = append(ng.Rules, ng.newRule(qb, r))
}
err := g.updateWith(expect)
err := g.updateWith(ng)
if err != nil {
t.Fatalf("cannot update rule: %s", err)
}
if len(g.Rules) != len(expect.Rules) {
t.Fatalf("expected to have %d rules; got: %d", len(expect.Rules), len(g.Rules))
if len(g.Rules) != len(newRules) {
t.Fatalf("expected to have %d rules; got: %d", len(g.Rules), len(newRules))
}
sort.Slice(g.Rules, func(i, j int) bool {
return g.Rules[i].ID() < g.Rules[j].ID()
})
sort.Slice(expect.Rules, func(i, j int) bool {
return expect.Rules[i].ID() < expect.Rules[j].ID()
sort.Slice(ng.Rules, func(i, j int) bool {
return ng.Rules[i].ID() < ng.Rules[j].ID()
})
for i, r := range g.Rules {
got, want := r, expect.Rules[i]
got, want := r, ng.Rules[i]
if got.ID() != want.ID() {
t.Fatalf("expected to have rule %q; got %q", want, got)
}
if err := CompareRules(t, got, want); err != nil {
t.Fatalf("comparison1 error: %s", err)
t.Fatalf("comparison error: %s", err)
}
}
}
// new rule
f(config.Group{}, config.Group{
Rules: []config.Rule{
{Alert: "bar"},
}})
f(nil, []config.Rule{
{Alert: "bar"},
})
// update alerting rule
f(config.Group{
Rules: []config.Rule{
{
Alert: "foo",
Expr: "up > 0",
For: promutil.NewDuration(time.Second),
Labels: map[string]string{
"bar": "baz",
},
Annotations: map[string]string{
"summary": "{{ $value|humanize }}",
"description": "{{$labels}}",
},
},
{
Alert: "bar",
Expr: "up > 0",
For: promutil.NewDuration(time.Second),
Labels: map[string]string{
"bar": "baz",
},
},
}}, config.Group{
Rules: []config.Rule{
{
Alert: "foo",
Expr: "up > 10",
For: promutil.NewDuration(time.Second),
Labels: map[string]string{
"baz": "bar",
},
Annotations: map[string]string{
"summary": "none",
},
},
{
Alert: "bar",
Expr: "up > 0",
For: promutil.NewDuration(2 * time.Second),
KeepFiringFor: promutil.NewDuration(time.Minute),
Labels: map[string]string{
"bar": "baz",
},
},
}})
// update recording rule
f(config.Group{
Rules: []config.Rule{{
Record: "foo",
Expr: "max(up)",
f([]config.Rule{
{
Alert: "foo",
Expr: "up > 0",
For: promutil.NewDuration(time.Second),
Labels: map[string]string{
"bar": "baz",
},
}}}, config.Group{
Rules: []config.Rule{{
Record: "foo",
Expr: "min(up)",
Debug: true,
Annotations: map[string]string{
"summary": "{{ $value|humanize }}",
"description": "{{$labels}}",
},
},
{
Alert: "bar",
Expr: "up > 0",
For: promutil.NewDuration(time.Second),
Labels: map[string]string{
"bar": "baz",
},
},
}, []config.Rule{
{
Alert: "foo",
Expr: "up > 10",
For: promutil.NewDuration(time.Second),
Labels: map[string]string{
"baz": "bar",
},
}}})
Annotations: map[string]string{
"summary": "none",
},
},
{
Alert: "bar",
Expr: "up > 0",
For: promutil.NewDuration(2 * time.Second),
KeepFiringFor: promutil.NewDuration(time.Minute),
Labels: map[string]string{
"bar": "baz",
},
},
})
// update debug
f(config.Group{
Rules: []config.Rule{
{
Record: "foo",
Expr: "max(up)",
},
{
Alert: "foo",
Expr: "up > 0",
Debug: true,
For: promutil.NewDuration(time.Second),
},
}}, config.Group{
Rules: []config.Rule{
{
Record: "foo",
Expr: "max(up)",
Debug: true,
},
{
Alert: "foo",
Expr: "up > 0",
For: promutil.NewDuration(time.Second),
},
}})
// update recording rule
f([]config.Rule{{
Record: "foo",
Expr: "max(up)",
Labels: map[string]string{
"bar": "baz",
},
}}, []config.Rule{{
Record: "foo",
Expr: "min(up)",
Debug: true,
Labels: map[string]string{
"baz": "bar",
},
}})
// empty rule
f(config.Group{
Rules: []config.Rule{{Alert: "foo"}, {Record: "bar"}}}, config.Group{})
f([]config.Rule{{Alert: "foo"}, {Record: "bar"}}, nil)
// multiple rules
f(config.Group{
Rules: []config.Rule{
{Alert: "bar"},
{Alert: "baz"},
{Alert: "foo"},
}}, config.Group{
Rules: []config.Rule{
{Alert: "baz"},
{Record: "foo"},
}})
f([]config.Rule{
{Alert: "bar"},
{Alert: "baz"},
{Alert: "foo"},
}, []config.Rule{
{Alert: "baz"},
{Record: "foo"},
})
// replace rule
f(config.Group{
Rules: []config.Rule{{Alert: "foo1"}}}, config.Group{
Rules: []config.Rule{{Alert: "foo2"}}})
f([]config.Rule{{Alert: "foo1"}}, []config.Rule{{Alert: "foo2"}})
// replace multiple rules
f(config.Group{
Rules: []config.Rule{
{Alert: "foo1"},
{Record: "foo2"},
{Alert: "foo3"},
}}, config.Group{
Rules: []config.Rule{
{Alert: "foo3"},
{Alert: "foo4"},
{Record: "foo5"},
}})
f([]config.Rule{
{Alert: "foo1"},
{Record: "foo2"},
{Alert: "foo3"},
}, []config.Rule{
{Alert: "foo3"},
{Alert: "foo4"},
{Record: "foo5"},
})
}
func TestUpdateDuringRandSleep(t *testing.T) {

View File

@@ -278,7 +278,6 @@ func (rr *RecordingRule) updateWith(r Rule) error {
rr.Expr = nr.Expr
rr.Labels = nr.Labels
rr.q = nr.q
rr.Debug = nr.Debug
return nil
}

View File

@@ -33,7 +33,8 @@ var (
"All created snapshots will be automatically deleted. Example: http://victoriametrics:8428/snapshot/delete")
dst = flag.String("dst", "", "Where to put the backup on the remote storage. "+
"Example: gs://bucket/path/to/backup, s3://bucket/path/to/backup, azblob://container/path/to/backup or fs:///path/to/local/backup/dir\n"+
"-dst can point to the previous backup. In this case incremental backup is performed, i.e. only changed data is uploaded")
"-dst can point to the previous backup. In this case incremental backup is performed, i.e. only changed data is uploaded\n"+
"Note: If custom S3 endpoint is used, URL should contain only name of the bucket, while hostname of S3 server must be specified via the -customS3Endpoint command-line flag.")
origin = flag.String("origin", "", "Optional origin directory on the remote storage with old backup for server-side copying when performing full backup. This speeds up full backups")
concurrency = flag.Int("concurrency", 10, "The number of concurrent workers. Higher concurrency may reduce backup duration")
maxBytesPerSecond = flagutil.NewBytes("maxBytesPerSecond", 0, "The maximum upload speed. There is no limit if it is set to 0")

View File

@@ -20,7 +20,8 @@ import (
var (
httpListenAddr = flag.String("httpListenAddr", ":8421", "TCP address for exporting metrics at /metrics page")
src = flag.String("src", "", "Source path with backup on the remote storage. "+
"Example: gs://bucket/path/to/backup, s3://bucket/path/to/backup, azblob://container/path/to/backup or fs:///path/to/local/backup")
"Example: gs://bucket/path/to/backup, s3://bucket/path/to/backup, azblob://container/path/to/backup or fs:///path/to/local/backup\n"+
"Note: If custom S3 endpoint is used, URL should contain only name of the bucket, while hostname of S3 server must be specified via the -customS3Endpoint command-line flag.")
storageDataPath = flag.String("storageDataPath", "victoria-metrics-data", "Destination path where backup must be restored. "+
"VictoriaMetrics must be stopped when restoring from backup. -storageDataPath dir can be non-empty. In this case the contents of -storageDataPath dir "+
"is synchronized with -src contents, i.e. it works like 'rsync --delete'")

View File

@@ -806,6 +806,7 @@ func QueryHandler(qt *querytracer.Tracer, startTime time.Time, w http.ResponseWr
} else {
queryOffset = 0
}
qs := &promql.QueryStats{}
ec := &promql.EvalConfig{
Start: start,
End: start,
@@ -821,10 +822,9 @@ func QueryHandler(qt *querytracer.Tracer, startTime time.Time, w http.ResponseWr
GetRequestURI: func() string {
return httpserver.GetRequestURI(r)
},
}
qs := promql.NewQueryStats(query, nil, ec)
ec.QueryStats = qs
QueryStats: qs,
}
result, err := promql.Exec(qt, ec, query, true)
if err != nil {
return fmt.Errorf("error when executing query=%q for (time=%d, step=%d): %w", query, start, step, err)
@@ -853,7 +853,6 @@ func QueryHandler(qt *querytracer.Tracer, startTime time.Time, w http.ResponseWr
if err := bw.Flush(); err != nil {
return fmt.Errorf("cannot flush query response to remote client: %w", err)
}
return nil
}
@@ -915,6 +914,7 @@ func queryRangeHandler(qt *querytracer.Tracer, startTime time.Time, w http.Respo
start, end = promql.AdjustStartEnd(start, end, step)
}
qs := &promql.QueryStats{}
ec := &promql.EvalConfig{
Start: start,
End: end,
@@ -930,10 +930,9 @@ func queryRangeHandler(qt *querytracer.Tracer, startTime time.Time, w http.Respo
GetRequestURI: func() string {
return httpserver.GetRequestURI(r)
},
}
qs := promql.NewQueryStats(query, nil, ec)
ec.QueryStats = qs
QueryStats: qs,
}
result, err := promql.Exec(qt, ec, query, false)
if err != nil {
return err
@@ -962,7 +961,6 @@ func queryRangeHandler(qt *querytracer.Tracer, startTime time.Time, w http.Respo
if err := bw.Flush(); err != nil {
return fmt.Errorf("cannot send query range response to remote client: %w", err)
}
return nil
}

View File

@@ -34,7 +34,7 @@ See https://prometheus.io/docs/prometheus/latest/querying/api/#range-queries
// It cannot be converted to int without breaking backwards compatibility at vmalert :(
%}
"seriesFetched": "{%dl qs.SeriesFetched.Load() %}",
"executionTimeMsec": {%dl qs.ExecutionDuration.Load().Milliseconds() %}
"executionTimeMsec": {%dl qs.ExecutionTimeMsec.Load() %}
}
{% code
qt.Printf("generate /api/v1/query_range response for series=%d, points=%d", seriesCount, pointsCount)

View File

@@ -71,9 +71,9 @@ func StreamQueryRangeResponse(qw422016 *qt422016.Writer, rs []netstorage.Result,
qw422016.N().DL(qs.SeriesFetched.Load())
//line app/vmselect/prometheus/query_range_response.qtpl:36
qw422016.N().S(`","executionTimeMsec":`)
//line app/vmselect/prometheus/query_range_response.qtpl:38
qw422016.N().DL(qs.ExecutionDuration.Load().Milliseconds())
//line app/vmselect/prometheus/query_range_response.qtpl:38
//line app/vmselect/prometheus/query_range_response.qtpl:37
qw422016.N().DL(qs.ExecutionTimeMsec.Load())
//line app/vmselect/prometheus/query_range_response.qtpl:37
qw422016.N().S(`}`)
//line app/vmselect/prometheus/query_range_response.qtpl:40
qt.Printf("generate /api/v1/query_range response for series=%d, points=%d", seriesCount, pointsCount)

View File

@@ -36,7 +36,7 @@ See https://prometheus.io/docs/prometheus/latest/querying/api/#instant-queries
// It cannot be converted to int without breaking backwards compatibility at vmalert :(
%}
"seriesFetched": "{%dl qs.SeriesFetched.Load() %}",
"executionTimeMsec": {%dl qs.ExecutionDuration.Load().Milliseconds() %}
"executionTimeMsec": {%dl qs.ExecutionTimeMsec.Load() %}
}
{% code
qt.Printf("generate /api/v1/query response for series=%d", seriesCount)

View File

@@ -81,9 +81,9 @@ func StreamQueryResponse(qw422016 *qt422016.Writer, rs []netstorage.Result, qt *
qw422016.N().DL(qs.SeriesFetched.Load())
//line app/vmselect/prometheus/query_response.qtpl:38
qw422016.N().S(`","executionTimeMsec":`)
//line app/vmselect/prometheus/query_response.qtpl:40
qw422016.N().DL(qs.ExecutionDuration.Load().Milliseconds())
//line app/vmselect/prometheus/query_response.qtpl:40
//line app/vmselect/prometheus/query_response.qtpl:39
qw422016.N().DL(qs.ExecutionTimeMsec.Load())
//line app/vmselect/prometheus/query_response.qtpl:39
qw422016.N().S(`}`)
//line app/vmselect/prometheus/query_response.qtpl:42
qt.Printf("generate /api/v1/query response for series=%d", seriesCount)

View File

@@ -11,7 +11,7 @@ TSDBStatusResponse generates response for /api/v1/status/tsdb .
"data":{
"totalSeries": {%dul= status.TotalSeries %},
"totalLabelValuePairs": {%dul= status.TotalLabelValuePairs %},
"seriesCountByMetricName":{%= tsdbStatusMetricNameEntries(status.SeriesCountByMetricName,status.SeriesQueryStatsByMetricName) %},
"seriesCountByMetricName":{%= tsdbStatusEntries(status.SeriesCountByMetricName) %},
"seriesCountByLabelName":{%= tsdbStatusEntries(status.SeriesCountByLabelName) %},
"seriesCountByFocusLabelValue":{%= tsdbStatusEntries(status.SeriesCountByFocusLabelValue) %},
"seriesCountByLabelValuePair":{%= tsdbStatusEntries(status.SeriesCountByLabelValuePair) %},
@@ -34,32 +34,4 @@ TSDBStatusResponse generates response for /api/v1/status/tsdb .
]
{% endfunc %}
{% func tsdbStatusMetricNameEntries(a []storage.TopHeapEntry, queryStats []storage.MetricNamesStatsRecord) %}
{% code
queryStatsByMetricName := make(map[string]storage.MetricNamesStatsRecord,len(queryStats))
for _, record := range queryStats{
queryStatsByMetricName[record.MetricName] = record
}
%}
[
{% for i, e := range a %}
{
{% code
entry, ok := queryStatsByMetricName[e.Name]
%}
"name":{%q= e.Name %},
{% if !ok %}
"value":{%d= int(e.Count) %}
{% else %}
"value":{%d= int(e.Count) %},
"requestsCount":{%d= int(entry.RequestsCount) %},
"lastRequestTimestamp":{%d= int(entry.LastRequestTs) %}
{% endif %}
}
{% if i+1 < len(a) %},{% endif %}
{% endfor %}
]
{% endfunc %}
{% endstripspace %}

View File

@@ -37,9 +37,9 @@ func StreamTSDBStatusResponse(qw422016 *qt422016.Writer, status *storage.TSDBSta
qw422016.N().DUL(status.TotalLabelValuePairs)
//line app/vmselect/prometheus/tsdb_status_response.qtpl:13
qw422016.N().S(`,"seriesCountByMetricName":`)
//line app/vmselect/prometheus/tsdb_status_response.qtpl:15
streamtsdbStatusMetricNameEntries(qw422016, status.SeriesCountByMetricName, status.SeriesQueryStatsByMetricName)
//line app/vmselect/prometheus/tsdb_status_response.qtpl:15
//line app/vmselect/prometheus/tsdb_status_response.qtpl:14
streamtsdbStatusEntries(qw422016, status.SeriesCountByMetricName)
//line app/vmselect/prometheus/tsdb_status_response.qtpl:14
qw422016.N().S(`,"seriesCountByLabelName":`)
//line app/vmselect/prometheus/tsdb_status_response.qtpl:15
streamtsdbStatusEntries(qw422016, status.SeriesCountByLabelName)
@@ -147,89 +147,3 @@ func tsdbStatusEntries(a []storage.TopHeapEntry) string {
return qs422016
//line app/vmselect/prometheus/tsdb_status_response.qtpl:35
}
//line app/vmselect/prometheus/tsdb_status_response.qtpl:38
func streamtsdbStatusMetricNameEntries(qw422016 *qt422016.Writer, a []storage.TopHeapEntry, queryStats []storage.MetricNamesStatsRecord) {
//line app/vmselect/prometheus/tsdb_status_response.qtpl:40
queryStatsByMetricName := make(map[string]storage.MetricNamesStatsRecord, len(queryStats))
for _, record := range queryStats {
queryStatsByMetricName[record.MetricName] = record
}
//line app/vmselect/prometheus/tsdb_status_response.qtpl:44
qw422016.N().S(`[`)
//line app/vmselect/prometheus/tsdb_status_response.qtpl:46
for i, e := range a {
//line app/vmselect/prometheus/tsdb_status_response.qtpl:46
qw422016.N().S(`{`)
//line app/vmselect/prometheus/tsdb_status_response.qtpl:49
entry, ok := queryStatsByMetricName[e.Name]
//line app/vmselect/prometheus/tsdb_status_response.qtpl:50
qw422016.N().S(`"name":`)
//line app/vmselect/prometheus/tsdb_status_response.qtpl:51
qw422016.N().Q(e.Name)
//line app/vmselect/prometheus/tsdb_status_response.qtpl:51
qw422016.N().S(`,`)
//line app/vmselect/prometheus/tsdb_status_response.qtpl:52
if !ok {
//line app/vmselect/prometheus/tsdb_status_response.qtpl:52
qw422016.N().S(`"value":`)
//line app/vmselect/prometheus/tsdb_status_response.qtpl:53
qw422016.N().D(int(e.Count))
//line app/vmselect/prometheus/tsdb_status_response.qtpl:54
} else {
//line app/vmselect/prometheus/tsdb_status_response.qtpl:54
qw422016.N().S(`"value":`)
//line app/vmselect/prometheus/tsdb_status_response.qtpl:55
qw422016.N().D(int(e.Count))
//line app/vmselect/prometheus/tsdb_status_response.qtpl:55
qw422016.N().S(`,"requestsCount":`)
//line app/vmselect/prometheus/tsdb_status_response.qtpl:56
qw422016.N().D(int(entry.RequestsCount))
//line app/vmselect/prometheus/tsdb_status_response.qtpl:56
qw422016.N().S(`,"lastRequestTimestamp":`)
//line app/vmselect/prometheus/tsdb_status_response.qtpl:57
qw422016.N().D(int(entry.LastRequestTs))
//line app/vmselect/prometheus/tsdb_status_response.qtpl:58
}
//line app/vmselect/prometheus/tsdb_status_response.qtpl:58
qw422016.N().S(`}`)
//line app/vmselect/prometheus/tsdb_status_response.qtpl:60
if i+1 < len(a) {
//line app/vmselect/prometheus/tsdb_status_response.qtpl:60
qw422016.N().S(`,`)
//line app/vmselect/prometheus/tsdb_status_response.qtpl:60
}
//line app/vmselect/prometheus/tsdb_status_response.qtpl:61
}
//line app/vmselect/prometheus/tsdb_status_response.qtpl:61
qw422016.N().S(`]`)
//line app/vmselect/prometheus/tsdb_status_response.qtpl:63
}
//line app/vmselect/prometheus/tsdb_status_response.qtpl:63
func writetsdbStatusMetricNameEntries(qq422016 qtio422016.Writer, a []storage.TopHeapEntry, queryStats []storage.MetricNamesStatsRecord) {
//line app/vmselect/prometheus/tsdb_status_response.qtpl:63
qw422016 := qt422016.AcquireWriter(qq422016)
//line app/vmselect/prometheus/tsdb_status_response.qtpl:63
streamtsdbStatusMetricNameEntries(qw422016, a, queryStats)
//line app/vmselect/prometheus/tsdb_status_response.qtpl:63
qt422016.ReleaseWriter(qw422016)
//line app/vmselect/prometheus/tsdb_status_response.qtpl:63
}
//line app/vmselect/prometheus/tsdb_status_response.qtpl:63
func tsdbStatusMetricNameEntries(a []storage.TopHeapEntry, queryStats []storage.MetricNamesStatsRecord) string {
//line app/vmselect/prometheus/tsdb_status_response.qtpl:63
qb422016 := qt422016.AcquireByteBuffer()
//line app/vmselect/prometheus/tsdb_status_response.qtpl:63
writetsdbStatusMetricNameEntries(qb422016, a, queryStats)
//line app/vmselect/prometheus/tsdb_status_response.qtpl:63
qs422016 := string(qb422016.B)
//line app/vmselect/prometheus/tsdb_status_response.qtpl:63
qt422016.ReleaseByteBuffer(qb422016)
//line app/vmselect/prometheus/tsdb_status_response.qtpl:63
return qs422016
//line app/vmselect/prometheus/tsdb_status_response.qtpl:63
}

View File

@@ -172,6 +172,30 @@ func copyEvalConfig(src *EvalConfig) *EvalConfig {
return &ec
}
// QueryStats contains various stats for the query.
type QueryStats struct {
// SeriesFetched contains the number of series fetched from storage during the query evaluation.
SeriesFetched atomic.Int64
// ExecutionTimeMsec contains the number of milliseconds the query took to execute.
ExecutionTimeMsec atomic.Int64
}
func (qs *QueryStats) addSeriesFetched(n int) {
if qs == nil {
return
}
qs.SeriesFetched.Add(int64(n))
}
func (qs *QueryStats) addExecutionTimeMsec(startTime time.Time) {
if qs == nil {
return
}
d := time.Since(startTime).Milliseconds()
qs.ExecutionTimeMsec.Add(d)
}
func (ec *EvalConfig) validate() {
if ec.Start > ec.End {
logger.Panicf("BUG: start cannot exceed end; got %d vs %d", ec.Start, ec.End)
@@ -1697,13 +1721,12 @@ func evalRollupFuncNoCache(qt *querytracer.Tracer, ec *EvalConfig, funcName stri
if err != nil {
return nil, err
}
qs := ec.QueryStats
rssLen := rss.Len()
if rssLen == 0 {
rss.Cancel()
return nil, nil
}
qs.addSeriesFetched(rssLen)
ec.QueryStats.addSeriesFetched(rssLen)
// Verify timeseries fit available memory during rollup calculations.
timeseriesLen := rssLen

View File

@@ -1,55 +0,0 @@
package promql
import (
"sync/atomic"
"time"
"github.com/VictoriaMetrics/VictoriaMetrics/lib/auth"
)
// QueryStats contains various stats of the query evaluation.
type QueryStats struct {
// ExecutionDuration contains the time duration the query took to execute.
ExecutionDuration atomic.Pointer[time.Duration]
// SeriesFetched contains the number of series fetched from storage or cache.
SeriesFetched atomic.Int64
at *auth.Token
query string
queryType string
start int64
end int64
step int64
}
// NewQueryStats creates a new QueryStats object.
func NewQueryStats(query string, at *auth.Token, ec *EvalConfig) *QueryStats {
qs := &QueryStats{
at: at,
query: query,
step: ec.Step,
start: ec.Start,
end: ec.End,
queryType: "range",
}
if qs.start == qs.end {
qs.queryType = "instant"
}
return qs
}
func (qs *QueryStats) addSeriesFetched(n int) {
if qs == nil {
return
}
qs.SeriesFetched.Add(int64(n))
}
func (qs *QueryStats) addExecutionTimeMsec(startTime time.Time) {
if qs == nil {
return
}
d := time.Since(startTime)
qs.ExecutionDuration.Store(&d)
}

View File

@@ -3,7 +3,6 @@ package stats
import (
"fmt"
"net/http"
"regexp"
"strconv"
"github.com/VictoriaMetrics/VictoriaMetrics/app/vmselect/netstorage"
@@ -34,12 +33,6 @@ func MetricNamesStatsHandler(qt *querytracer.Tracer, w http.ResponseWriter, r *h
le = n
}
matchPattern := r.FormValue("match_pattern")
if len(matchPattern) > 0 {
_, err := regexp.Compile(matchPattern)
if err != nil {
return fmt.Errorf("match_pattern=%q must be valid regex: %w", matchPattern, err)
}
}
stats, err := netstorage.GetMetricNamesStats(qt, limit, le, matchPattern)
if err != nil {
return err

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -36,10 +36,10 @@
<meta property="og:title" content="UI for VictoriaMetrics">
<meta property="og:url" content="https://victoriametrics.com/">
<meta property="og:description" content="Explore and troubleshoot your VictoriaMetrics data">
<script type="module" crossorigin src="./assets/index-BF2w5kzJ.js"></script>
<link rel="modulepreload" crossorigin href="./assets/vendor-BSp13qCn.js">
<script type="module" crossorigin src="./assets/index-Clv2OTUl.js"></script>
<link rel="modulepreload" crossorigin href="./assets/vendor-PQqNLyna.js">
<link rel="stylesheet" crossorigin href="./assets/vendor-D1GxaB_c.css">
<link rel="stylesheet" crossorigin href="./assets/index-sXHL6qTd.css">
<link rel="stylesheet" crossorigin href="./assets/index-u4IOGr0E.css">
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>

File diff suppressed because it is too large Load Diff

View File

@@ -8,21 +8,21 @@
"@types/lodash.debounce": "^4.0.9",
"@types/lodash.get": "^4.4.9",
"@types/qs": "^6.9.18",
"@types/react": "^19.1.2",
"@types/react": "^19.0.12",
"@types/react-input-mask": "^3.0.6",
"@types/react-router-dom": "^5.3.3",
"classnames": "^2.5.1",
"dayjs": "^1.11.13",
"lodash.debounce": "^4.0.8",
"lodash.get": "^4.4.2",
"marked": "^15.0.8",
"marked": "^15.0.7",
"marked-emoji": "^2.0.0",
"preact": "^10.26.5",
"preact": "^10.26.4",
"qs": "^6.14.0",
"react-input-mask": "^2.0.4",
"react-router-dom": "^7.5.0",
"react-router-dom": "^7.4.0",
"uplot": "^1.6.32",
"vite": "^6.2.6",
"vite": "^6.2.3",
"web-vitals": "^4.2.4"
},
"scripts": {
@@ -55,25 +55,25 @@
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.18.6",
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "^9.24.0",
"@eslint/js": "^9.23.0",
"@preact/preset-vite": "^2.10.1",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/preact": "^3.2.4",
"@types/node": "^22.14.1",
"@typescript-eslint/eslint-plugin": "^8.30.1",
"@typescript-eslint/parser": "^8.30.1",
"@types/node": "^22.13.13",
"@typescript-eslint/eslint-plugin": "^8.28.0",
"@typescript-eslint/parser": "^8.28.0",
"cross-env": "^7.0.3",
"eslint": "^9.24.0",
"eslint-plugin-react": "^7.37.5",
"eslint": "^9.23.0",
"eslint-plugin-react": "^7.37.4",
"globals": "^16.0.0",
"http-proxy-middleware": "^3.0.5",
"jsdom": "^26.1.0",
"http-proxy-middleware": "^3.0.3",
"jsdom": "^26.0.0",
"postcss": "^8.5.3",
"rollup-plugin-visualizer": "^5.14.0",
"sass": "^1.86.3",
"sass-embedded": "^1.86.3",
"typescript": "^5.8.3",
"vitest": "^3.1.1",
"webpack": "^5.99.5"
"sass": "^1.86.0",
"sass-embedded": "^1.86.0",
"typescript": "^5.8.2",
"vitest": "^3.0.9",
"webpack": "^5.98.0"
}
}

View File

@@ -1,5 +1,5 @@
import { useMemo, useState } from "preact/compat";
import { getAxes, getMinMaxBuffer, handleDestroy, setSelect } from "../../../../utils/uplot";
import { getAxes, handleDestroy, setSelect } from "../../../../utils/uplot";
import dayjs from "dayjs";
import { dateFromSeconds, formatDateForNativeInput } from "../../../../utils/time";
import uPlot, { AlignedData, Band, Options, Series } from "uplot";
@@ -9,7 +9,6 @@ import { MinMax, SetMinMax } from "../../../../types";
import { LogHits } from "../../../../api/types";
import getSeriesPaths from "../../../../utils/uplot/paths";
import { GraphOptions, GRAPH_STYLES } from "../types";
import { getMaxFromArray } from "../../../../utils/math";
const seriesColors = [
"color-log-hits-bar-1",
@@ -45,12 +44,6 @@ export const getLabelFromLogHit = (logHit: LogHits) => {
return fields.map((value) => value || "\"\"").join(", ");
};
const getYRange = (u: uPlot, _initMin = 0, initMax = 1) => {
const maxValues = u.series.filter(({ scale }) => scale === "y").map(({ max }) => max || initMax);
const max = getMaxFromArray(maxValues);
return getMinMaxBuffer(0, max || initMax);
};
const useBarHitsOptions = ({
data,
logHits,
@@ -106,9 +99,6 @@ const useBarHitsOptions = ({
x: {
time: true,
range: () => [xRange.min, xRange.max]
},
y: {
range: getYRange
}
},
hooks: {

View File

@@ -12,6 +12,7 @@ import Timezones from "./Timezones/Timezones";
import ThemeControl from "../ThemeControl/ThemeControl";
import useDeviceDetect from "../../../hooks/useDeviceDetect";
import useBoolean from "../../../hooks/useBoolean";
import SwitchMarkdownParsing from "../LogsSettings/MarkdownParsing/SwitchMarkdownParsing";
import { APP_TYPE_LOGS } from "../../../constants/appType";
const title = "Settings";
@@ -57,6 +58,10 @@ const GlobalSettings: FC = () => {
onClose={handleClose}
/>
},
{
show: APP_TYPE_LOGS,
component: <SwitchMarkdownParsing/>
},
{
show: true,
component: <Timezones ref={timezoneSettingRef}/>

View File

@@ -1,57 +0,0 @@
import React, { FC } from "preact/compat";
import Switch from "../../Main/Switch/Switch";
import useDeviceDetect from "../../../hooks/useDeviceDetect";
import { useLogsDispatch, useLogsState } from "../../../state/logsPanel/LogsStateContext";
const LogParsingSwitches: FC = () => {
const { isMobile } = useDeviceDetect();
const { markdownParsing, ansiParsing } = useLogsState();
const dispatch = useLogsDispatch();
const handleChangeMarkdownParsing = (val: boolean) => {
dispatch({ type: "SET_MARKDOWN_PARSING", payload: val });
if (ansiParsing) {
dispatch({ type: "SET_ANSI_PARSING", payload: false });
}
};
const handleChangeAnsiParsing = (val: boolean) => {
dispatch({ type: "SET_ANSI_PARSING", payload: val });
if (markdownParsing) {
dispatch({ type: "SET_MARKDOWN_PARSING", payload: false });
}
};
return (
<>
<div className="vm-group-logs-configurator-item">
<Switch
label={"Enable markdown parsing"}
value={markdownParsing}
onChange={handleChangeMarkdownParsing}
fullWidth={isMobile}
/>
<div className="vm-group-logs-configurator-item__info">
Toggle this switch to enable or disable the Markdown formatting for log entries.
Enabling this will parse log texts to Markdown.
</div>
</div>
<div className="vm-group-logs-configurator-item">
<Switch
label={"Enable ANSI parsing"}
value={ansiParsing}
onChange={handleChangeAnsiParsing}
fullWidth={isMobile}
/>
<div className="vm-group-logs-configurator-item__info">
Toggle this switch to enable or disable ANSI escape sequence parsing for log entries.
Enabling this will interpret ANSI codes to render colored log output.
</div>
</div>
</>
);
};
export default LogParsingSwitches;

View File

@@ -0,0 +1,35 @@
import React, { FC } from "preact/compat";
import Switch from "../../../Main/Switch/Switch";
import useDeviceDetect from "../../../../hooks/useDeviceDetect";
import { useLogsDispatch, useLogsState } from "../../../../state/logsPanel/LogsStateContext";
const SwitchMarkdownParsing: FC = () => {
const { isMobile } = useDeviceDetect();
const { markdownParsing } = useLogsState();
const dispatch = useLogsDispatch();
const handleChangeMarkdownParsing = (val: boolean) => {
dispatch({ type: "SET_MARKDOWN_PARSING", payload: val });
};
return (
<div>
<div className="vm-server-configurator__title">
Markdown Parsing for Logs
</div>
<Switch
label={markdownParsing ? "Disable markdown parsing" : "Enable markdown parsing"}
value={markdownParsing}
onChange={handleChangeMarkdownParsing}
fullWidth={isMobile}
/>
<div className="vm-server-configurator__info">
Toggle this switch to enable or disable the Markdown formatting for log entries.
Enabling this will parse log texts to Markdown.
</div>
</div>
);
};
export default SwitchMarkdownParsing;

View File

@@ -1,6 +1,7 @@
import React, { FC, useState, useEffect, useMemo, useCallback } from "preact/compat";
import Autocomplete from "../../Main/Autocomplete/Autocomplete";
import { useFetchQueryOptions } from "../../../hooks/useFetchQueryOptions";
import { escapeRegexp, hasUnclosedQuotes } from "../../../utils/regexp";
import useGetMetricsQL from "../../../hooks/useGetMetricsQL";
import { QueryContextType } from "../../../types";
import { AUTOCOMPLETE_LIMITS } from "../../../constants/queryAutocomplete";

View File

@@ -85,7 +85,7 @@ export function getContext(
);
const endOfClosedQuotes =
!hasUnclosedQuotes(valueBeforeCursor) &&
["`", "'", "\""].some((char) => valueBeforeCursor.endsWith(char));
["`", "'", '"'].some((char) => valueBeforeCursor.endsWith(char));
if (
!valueBeforeCursor ||
endOfClosedBrackets ||

View File

@@ -1,81 +0,0 @@
import React, { FC, useCallback, useState } from "preact/compat";
import Tooltip from "../Main/Tooltip/Tooltip";
import Button from "../Main/Button/Button";
import { DownloadIcon } from "../Main/Icons";
import Popper from "../Main/Popper/Popper";
import { useRef } from "react";
import "./style.scss";
import useBoolean from "../../hooks/useBoolean";
interface DownloadButtonProps {
title: string;
downloadFormatOptions?: string[];
onDownload: (format?: string) => void;
}
const DownloadButton: FC<DownloadButtonProps> = ({ title, downloadFormatOptions, onDownload }) => {
const {
value: isPopupOpen,
setTrue: onOpenPopup,
setFalse: onClosePopup,
} = useBoolean(false);
const downloadButtonRef = useRef<HTMLDivElement>(null);
const onDownloadClick = useCallback(() => {
if (isPopupOpen) {
onClosePopup();
return;
}
if (downloadFormatOptions && downloadFormatOptions.length > 0) {
onOpenPopup();
} else {
onDownload();
onClosePopup();
}
}, [onDownload, onClosePopup, isPopupOpen, onOpenPopup]);
const onDownloadFormatClick = useCallback((event: Event) => {
const button = event.currentTarget as HTMLButtonElement;
onDownload(button.textContent ?? undefined);
}, [onDownload]);
return (
<>
<div ref={downloadButtonRef}>
<Tooltip
title={title}
>
<Button
variant="text"
startIcon={<DownloadIcon/>}
onClick={onDownloadClick}
ariaLabel={title}
/>
</Tooltip>
</div>
{downloadFormatOptions && downloadFormatOptions.length > 0 && (
<Popper
open={isPopupOpen}
onClose={onClosePopup}
buttonRef={downloadButtonRef}
placement={"bottom-right"}
>
{downloadFormatOptions.map((option) =>
<div
key={option}
className={"vm-download-button__format-option"}
>
<Button
variant="text"
onClick={onDownloadFormatClick}
className={"vm-download-button__format-option-button"}
>
{option}
</Button>
</div>)}
</Popper>)}
</>
);
};
export default DownloadButton;

View File

@@ -1,15 +0,0 @@
@use "src/styles/variables" as *;
.vm-download-button {
&__format-option {
padding: 4px;
&:first-child {
padding-bottom: 0;
}
&-button {
width: 100%;
justify-content: flex-start;
}
}
}

View File

@@ -20,7 +20,6 @@ import {
WITHOUT_GROUPING
} from "../../../constants/logs";
import { getFromStorage, saveToStorage } from "../../../utils/storage";
import LogParsingSwitches from "../../Configurators/LogsSettings/LogParsingSwitches";
const {
GROUP_BY,
@@ -226,8 +225,6 @@ const GroupLogsConfigurators: FC<Props> = ({ logs }) => {
</span>
</div>
<LogParsingSwitches/>
<div className="vm-group-logs-configurator-item">
<Switch
value={noWrapLines}

View File

@@ -2,9 +2,9 @@
.vm-group-logs-configurator {
display: grid;
gap: calc($padding-global * 2);
gap: calc($padding-large * 2);
padding: $padding-global 0;
max-width: 600px;
width: 600px;
&-item {
display: grid;

View File

@@ -37,9 +37,10 @@ const Button: FC<ButtonProps> = ({
"vm-button": true,
[`vm-button_${variant}_${color}`]: true,
[`vm-button_${size}`]: size,
"vm-button_icon_only": (startIcon || endIcon) && !children,
"vm-button_icon": (startIcon || endIcon) && !children,
"vm-button_full-width": fullWidth,
"vm-button_with-icons": startIcon || endIcon,
"vm-button_with-icon": startIcon || endIcon,
"vm-button_disabled": disabled,
[className || ""]: className
});
@@ -51,7 +52,11 @@ const Button: FC<ButtonProps> = ({
onClick={onClick}
onMouseDown={onMouseDown}
>
{startIcon}{children}{endIcon}
<>
{startIcon && <span className="vm-button__start-icon">{startIcon}</span>}
{children && <span>{children}</span>}
{endIcon && <span className="vm-button__end-icon">{endIcon}</span>}
</>
</button>
);
};

View File

@@ -3,133 +3,225 @@
$button-radius: 6px;
.vm-button {
display: inline-flex;
gap: 6px;
position: relative;
display: flex;
align-items: center;
justify-content: center;
padding: 6px 14px;
font-size: $font-size-small;
line-height: calc($font-size + 1px);
font-weight: normal;
text-transform: capitalize;
color: $color-white;
background: transparent;
border: 1px solid transparent;
min-height: 31px;
border-radius: $button-radius;
color: $color-white;
transform-style: preserve-3d;
cursor: pointer;
text-transform: uppercase;
user-select: none;
white-space: nowrap;
transition: transform 0.2s, opacity 0.2s, background-color 0.2s;
&:focus-visible {
outline: 2px solid #006FEEFF;
outline-offset: 2px;
&:hover:after {
background-color: $color-hover-black;
}
&:active {
transform: scale(0.97);
opacity: 0.9;
&:before,
&:after {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
transition: background-color 200ms ease;
border-radius: $button-radius;
}
&:disabled {
&:before {
transform: translateZ(-2px);
}
&:after {
background-color: transparent;
transform: translateZ(-1px);
}
&:active:after {
transform: scale(0.9);
}
span {
display: grid;
align-items: center;
justify-content: center;
transform: translateZ(1px);
svg {
width: calc($font-size + 1px);
}
}
&__start-icon {
margin-right: 6px;
}
&__end-icon {
margin-left: 6px;
}
&_disabled {
opacity: 0.3;
pointer-events: none;
cursor: not-allowed;
}
// Sizes
&_icon {
padding: 6px $padding-small;
}
&_icon &__start-icon,
&_icon &__end-icon {
margin: 0;
}
/* size SMALL */
&_small {
padding: 4px 10px;
line-height: 14px;
padding: 4px 8px;
min-height: 25px;
svg {
width: 14px;
}
}
&_medium {
padding: 6px 14px;
line-height: 14px;
svg {
width: 16px;
}
}
&_large {
padding: 8px 20px;
font-size: $font-size-medium;
svg {
width: 18px;
line-height: 16px;
}
}
// Icon only
&_icon_only {
aspect-ratio: 1;
}
$icon-sizes: (
small: 4px,
medium: 6px,
large: 8px
);
@each $size, $padding in $icon-sizes {
&_#{$size}.vm-button_icon_only {
padding: $padding;
}
}
// Color variants
@mixin contained-button($name, $color, $text-color: $color-white) {
&_contained_#{$name} {
background-color: $color;
color: $text-color;
&:hover:not(:disabled) {
opacity: 0.8;
span {
svg {
width: 13px;
}
}
}
@mixin outlined-button($name, $border-color, $text-color: $border-color) {
&_outlined_#{$name} {
background-color: transparent;
border-color: $border-color;
color: $text-color;
&:hover:not(:disabled) {
background-color: $color-hover-black;
opacity: 0.8;
}
/* variant CONTAINED */
&_contained_primary {
color: $color-primary-text;
background-color: $color-primary;
&:before {
background-color: $color-primary;
}
&:hover:after {
background-color: rgba($color-black, 0.2)
}
}
@mixin text-button($name, $text-color: $color-white) {
&_text_#{$name} {
background-color: transparent;
color: $text-color;
&_contained_secondary {
color: $color-secondary-text;
&:hover:not(:disabled) {
background-color: $color-hover-black;
}
&:before {
background-color: $color-secondary;
}
&:hover:after {
background-color: rgba($color-black, 0.2)
}
}
$button-colors: (
primary: $color-primary,
secondary: $color-secondary,
success: $color-success,
warning: $color-warning,
error: $color-error,
gray: $color-text-secondary,
white: $color-white
);
&_contained_success {
color: $color-success-text;
@each $name, $color in $button-colors {
@include contained-button($name, $color, if($name == white, $color-black, $color-white));
@include outlined-button($name, $color, if($name == white, $color-white, $color));
@include text-button($name, if($name == white, $color-white, $color));
&:before {
background-color: $color-success;
}
&:hover:after {
background-color: rgba($color-black, 0.2)
}
}
&_contained_error {
color: $color-error-text;
&:before {
background-color: $color-error;
}
}
&_contained_gray {
color: $color-text-secondary;
&:before {
background-color: $color-text-secondary;
}
}
&_contained_warning {
color: $color-warning;
&:before {
background-color: $color-warning;
opacity: 0.2;
}
}
/* variant TEXT */
&_text_primary {
color: $color-primary;
}
&_text_secondary {
color: $color-secondary;
}
&_text_success {
color: $color-success;
}
&_text_error {
color: $color-error;
}
&_text_gray {
color: $color-text-secondary;
}
&_text_white {
color: $color-white;
}
&_text_warning {
color: $color-warning;
}
/* variant OUTLINED */
&_outlined_primary {
border: 1px solid $color-primary;
color: $color-primary;
}
&_outlined_error {
border: 1px solid $color-error;
color: $color-error;
}
&_outlined_secondary {
border: 1px solid $color-secondary;
color: $color-secondary;
}
&_outlined_success {
border: 1px solid $color-success;
color: $color-success;
}
&_outlined_gray {
border: 1px solid $color-text-secondary;
color: $color-text-secondary;
}
&_outlined_white {
border: 1px solid $color-white;
color: $color-white;
}
&_outlined_warning {
border: 1px solid $color-warning;
color: $color-warning;
}
}

View File

@@ -1,121 +0,0 @@
import { describe, it, expect, vi, Mock, afterEach } from "vitest";
import { getFromStorage, saveToStorage } from "../../utils/storage";
import { getUpdatedHistory, setQueriesToStorage } from "./utils";
import { MAX_QUERIES_HISTORY, MAX_QUERY_FIELDS } from "../../constants/graph";
vi.mock("../../utils/storage", () => ({
getFromStorage: vi.fn(),
saveToStorage: vi.fn(),
}));
describe("utils", () => {
afterEach(() => {
vi.resetAllMocks();
});
describe("setQueriesToStorage", () => {
it("should not change QUERY_HISTORY ", () => {
const getFromStorageMock = getFromStorage as Mock;
const saveToStorageMock = saveToStorage as Mock;
getFromStorageMock.mockReturnValue(JSON.stringify({
"QUERY_HISTORY": [],
}));
setQueriesToStorage("LOGS_QUERY_HISTORY", []);
expect(saveToStorageMock).toHaveBeenCalledWith(
"LOGS_QUERY_HISTORY",
"{\"QUERY_HISTORY\":[[]]}"
);
});
it("should not change QUERY_HISTORY cause add the same query", () => {
const getFromStorageMock = getFromStorage as Mock;
const saveToStorageMock = saveToStorage as Mock;
getFromStorageMock.mockReturnValue(JSON.stringify({
"QUERY_HISTORY": [["first_query"]],
}));
setQueriesToStorage("LOGS_QUERY_HISTORY", [{ index: 0, values: ["first_query"] }]);
expect(saveToStorageMock).toHaveBeenCalledWith(
"LOGS_QUERY_HISTORY",
"{\"QUERY_HISTORY\":[[\"first_query\"]]}"
);
});
it("should add new query to the first position to QUERY_HISTORY", () => {
const getFromStorageMock = getFromStorage as Mock;
const saveToStorageMock = saveToStorage as Mock;
getFromStorageMock.mockReturnValue(JSON.stringify({
"QUERY_HISTORY": [["first_query"]],
}));
setQueriesToStorage("LOGS_QUERY_HISTORY", [{ index: 0, values: ["new_query"] }]);
expect(saveToStorageMock).toHaveBeenCalledWith(
"LOGS_QUERY_HISTORY",
"{\"QUERY_HISTORY\":[[\"new_query\",\"first_query\"]]}"
);
});
it("should limit the QUERY_HISTORY if add extra query", () => {
const getFromStorageMock = getFromStorage as Mock;
const saveToStorageMock = saveToStorage as Mock;
const maxQueries = MAX_QUERIES_HISTORY * MAX_QUERY_FIELDS;
const currentHistory = (new Array(maxQueries)).fill(1).map((_, i) => `${i}_query`);
getFromStorageMock.mockReturnValue(JSON.stringify({
"QUERY_HISTORY": [currentHistory],
}));
setQueriesToStorage("LOGS_QUERY_HISTORY", [{ index: 0, values: ["extra_query"] }]);
const calls = saveToStorageMock.mock.calls;
const firstCallArgs = calls[0];
expect(firstCallArgs[0]).toStrictEqual("LOGS_QUERY_HISTORY");
const savedQueries = JSON.parse(firstCallArgs[1]);
expect(savedQueries["QUERY_HISTORY"][0][0]).toStrictEqual("extra_query");
expect(savedQueries["QUERY_HISTORY"][0].length).toStrictEqual(maxQueries);
});
});
describe("getUpdatedHistory", () => {
it("should add new query to the end of array", () => {
const updatedHistory = getUpdatedHistory("new_query", {
index: 2,
values: ["first_query", "second_query"]
});
expect(updatedHistory).toStrictEqual({
index: 2,
values: [
"first_query",
"second_query",
"new_query",
],
});
});
it("should not add new query if the last query is the same", () => {
const updatedHistory = getUpdatedHistory("new_query", {
index: 2,
values: ["first_query", "new_query"]
});
expect(updatedHistory).toStrictEqual({
index: 1,
values: [
"first_query",
"new_query",
],
});
});
it("should remove the first query if the maximum number of query is reached", () => {
const maxQueries = MAX_QUERIES_HISTORY * MAX_QUERY_FIELDS;
const values = (new Array(maxQueries)).fill(1).map((_, i) => `${i}_query`);
const updatedHistory = getUpdatedHistory("new_query", {
index: 2,
values: values
});
expect(updatedHistory.index).toStrictEqual(maxQueries);
expect(updatedHistory.values.length).toStrictEqual(maxQueries);
expect(updatedHistory.values[0]).toStrictEqual("1_query");
expect(updatedHistory.values[updatedHistory.values.length - 1]).toStrictEqual("new_query");
});
});
});

View File

@@ -1,89 +0,0 @@
import { getFromStorage, removeFromStorage, saveToStorage, StorageKeys } from "../../utils/storage";
import { QueryHistoryType } from "../../state/query/reducer";
import { MAX_QUERIES_HISTORY, MAX_QUERY_FIELDS } from "../../constants/graph";
export type HistoryKey = Extract<StorageKeys, "LOGS_QUERY_HISTORY" | "METRICS_QUERY_HISTORY">;
export type HistoryType = "QUERY_HISTORY" | "QUERY_FAVORITES";
const getHistoryFromStorage = (key: HistoryKey) => {
const list = getFromStorage(key) as string;
const history: Record<HistoryType, string[][]> = list ? JSON.parse(list) : {};
return history;
};
const saveHistoryToStorage = (key: HistoryKey, historyType: HistoryType, history: string[][]) => {
const storageHistory = getHistoryFromStorage(key);
saveToStorage(key, JSON.stringify({
...storageHistory,
[historyType]: history
}));
};
export const getQueriesFromStorage = (key: HistoryKey, historyType: HistoryType) => {
return getHistoryFromStorage(key)[historyType] || [];
};
export const setQueriesToStorage = (key: HistoryKey, history: QueryHistoryType[]) => {
// For localStorage, avoid splitting into query fields because when working from multiple tabs can cause confusion.
// For convenience, we maintain the original structure of `string[][]`
const lastValues = history.map(h => h.values[h.index]);
const storageHistory = getHistoryFromStorage(key);
const storageValues = storageHistory["QUERY_HISTORY"] || [];
if (!storageValues[0]) storageValues[0] = [];
const values = storageValues[0];
const TOTAL_LIMIT = MAX_QUERIES_HISTORY * MAX_QUERY_FIELDS;
lastValues.forEach((v) => {
const already = values.includes(v);
if (!already && v) values.unshift(v);
if (values.length > TOTAL_LIMIT) values.pop();
});
const newStorageHistory = {
...storageHistory,
QUERY_HISTORY: [values]
};
saveToStorage(key, JSON.stringify(newStorageHistory));
};
export const setFavoriteQueriesToStorage = (key: HistoryKey, favoriteQueries: string[][]) => {
saveHistoryToStorage(key, "QUERY_FAVORITES", favoriteQueries);
};
export const clearQueryHistoryStorage = (key: HistoryKey, historyType: HistoryType) => {
const history = getHistoryFromStorage(key);
saveToStorage(key, JSON.stringify({
...history,
[historyType]: [],
}));
};
export const getUpdatedHistory = (query: string, queryHistory?: QueryHistoryType): QueryHistoryType => {
const h = queryHistory || { values: [] };
const queryEqual = query === h.values[h.values.length - 1];
const newValues = !queryEqual && query ? [...h.values, query] : h.values;
// limit the history
if (newValues.length > MAX_QUERIES_HISTORY) newValues.shift();
return {
index: h.values.length - Number(queryEqual),
values: newValues
};
};
const migrateMetricsQueryHistoryToHistoryByKey = () => {
const migrateHistory = (type: HistoryType) => {
const queryList = getFromStorage(type) as string;
if (queryList) {
const queryHistory: string[][] = JSON.parse(queryList);
saveHistoryToStorage("METRICS_QUERY_HISTORY", type, queryHistory);
removeFromStorage([type]);
}
};
migrateHistory("QUERY_HISTORY");
migrateHistory("QUERY_FAVORITES");
};
migrateMetricsQueryHistoryToHistoryByKey();

View File

@@ -37,10 +37,6 @@
overflow: auto;
margin-bottom: $padding-global;
@media(max-width: 500px) {
width: 100vw;
}
&__item {
width: 100%;
font-size: $font-size;

View File

@@ -1,48 +0,0 @@
import { describe, it, expect } from "vitest";
import { descendingComparator } from "./helpers";
import { getNanoTimestamp } from "../../utils/time"; // используем реальную реализацию
describe("descendingComparator", () => {
it("returns 0 for equal numbers", () => {
const result = descendingComparator({ value: 42 }, { value: 42 }, "value");
expect(result).toBe(0);
});
it("sorts numbers descending", () => {
const result = descendingComparator({ value: 100 }, { value: 50 }, "value");
expect(result).toBeLessThan(0);
});
it("sorts null below any value", () => {
expect(descendingComparator({ value: null }, { value: 10 }, "value")).toBe(1);
expect(descendingComparator({ value: 10 }, { value: null }, "value")).toBe(-1);
expect(descendingComparator({ value: null }, { value: null }, "value")).toBe(0);
});
it("sorts strings descending", () => {
const result = descendingComparator({ name: "zzz" }, { name: "aaa" }, "name");
expect(result).toBe(-1);
});
it("sorts numeric strings as numbers when possible", () => {
const result = descendingComparator({ value: "200" }, { value: "50" }, "value");
expect(result).toBeLessThan(0);
});
it("sorts date strings via getNanoTimestamp", () => {
const a = { timestamp: "2024-01-01T00:00:00.200Z" };
const b = { timestamp: "2023-01-01T00:00:00.100Z" };
const nanoA = getNanoTimestamp(a.timestamp);
const nanoB = getNanoTimestamp(b.timestamp);
expect(nanoA).toBeGreaterThan(nanoB);
const result = descendingComparator(a, b, "timestamp");
expect(result).toBe(-1);
});
it("handles booleans and undefined safely", () => {
expect(descendingComparator({ value: true }, { value: false }, "value")).toBe(-1);
expect(descendingComparator({ value: undefined }, { value: false }, "value")).toBe(1);
});
});

View File

@@ -6,38 +6,11 @@ const dateColumns = ["date", "timestamp", "time"];
export function descendingComparator<T>(a: T, b: T, orderBy: keyof T) {
const valueA = a[orderBy];
const valueB = b[orderBy];
const parsedValueA = dateColumns.includes(String(orderBy)) ? getNanoTimestamp(`${valueA}`) : valueA;
const parsedValueB = dateColumns.includes(String(orderBy)) ? getNanoTimestamp(`${valueB}`) : valueB;
// null/undefined
if (valueA == null && valueB == null) return 0;
if (valueA == null) return 1;
if (valueB == null) return -1;
const strA = String(valueA);
const strB = String(valueB);
// Dates
const isDate = dateColumns.includes(String(orderBy));
if (isDate) {
const timeA = getNanoTimestamp(strA);
const timeB = getNanoTimestamp(strB);
if (timeB < timeA) return -1;
if (timeB > timeA) return 1;
return 0;
}
// Numbers
const numA = Number(strA);
const numB = Number(strB);
const isNumeric = !isNaN(numA) && !isNaN(numB);
if (isNumeric) {
return numB - numA;
}
// Strings
if (strB < strA) return -1;
if (strB > strA) return 1;
if (parsedValueB < parsedValueA) return -1;
if (parsedValueB > parsedValueA) return 1;
return 0;
}

View File

@@ -10,7 +10,6 @@ import Modal from "../Main/Modal/Modal";
import JsonForm from "../../pages/TracePage/JsonForm/JsonForm";
import classNames from "classnames";
import useDeviceDetect from "../../hooks/useDeviceDetect";
import { downloadJSON } from "../../utils/file";
interface TraceViewProps {
traces: Trace[];
@@ -55,7 +54,17 @@ const TracingsView: FC<TraceViewProps> = ({ traces, jsonEditor = false, onDelete
};
const handleSaveToFile = (tracingData: Trace) => () => {
downloadJSON(tracingData.originalJSON, `vmui_trace_${tracingData.queryValue}.json`);
const blob = new Blob([tracingData.originalJSON], { type: "application/json" });
const href = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = href;
link.download = `vmui_trace_${tracingData.queryValue}.json`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(href);
};
const handleExpandAll = (tracingData: Trace) => () => {

View File

@@ -58,8 +58,8 @@
}
&_mobile {
max-width: 75px;
min-width: 75px;
max-width: 65px;
min-width: 65px;
margin: 0 auto;
}
}

View File

@@ -20,7 +20,6 @@ import { parseLineToJSON } from "../../../utils/json";
import { ExportMetricResult, ReportMetaData } from "../../../api/types";
import { getApiEndpoint } from "../../../utils/url";
import MarkdownEditor from "../../../components/Main/MarkdownEditor/MarkdownEditor";
import { downloadJSON } from "../../../utils/file";
export enum ReportType {
QUERY_DATA,
@@ -101,7 +100,17 @@ const DownloadReport: FC<Props> = ({ fetchUrl, reportType = ReportType.QUERY_DAT
const generateFile = useCallback((data: unknown) => {
const json = JSON.stringify(data, null, 2);
downloadJSON(json, `${filename || defaultFilename}.json`);
const blob = new Blob([json], { type: "application/json" });
const href = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = href;
link.download = `${filename || defaultFilename}.json`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(href);
handleClose();
}, [filename]);

View File

@@ -23,10 +23,9 @@ import { arrayEquals } from "../../../utils/array";
import useDeviceDetect from "../../../hooks/useDeviceDetect";
import { QueryStats } from "../../../api/types";
import { usePrettifyQuery } from "./hooks/usePrettifyQuery";
import QueryHistory from "../../../components/QueryHistory/QueryHistory";
import QueryHistory from "../QueryHistory/QueryHistory";
import AnomalyConfig from "../../../components/ExploreAnomaly/AnomalyConfig";
import QueryEditorAutocomplete from "../../../components/Configurators/QueryEditor/QueryEditorAutocomplete";
import { getUpdatedHistory } from "../../../components/QueryHistory/utils";
export interface QueryConfiguratorProps {
queryErrors: string[];
@@ -80,10 +79,19 @@ const QueryConfigurator: FC<QueryConfiguratorProps> = ({
const updateHistory = () => {
queryDispatch({
type: "SET_QUERY_HISTORY",
payload: {
key: "METRICS_QUERY_HISTORY",
history: stateQuery.map((q, i) => getUpdatedHistory(q, queryHistory[i]))
}
payload: stateQuery.map((q, i) => {
const h = queryHistory[i] || { values: [] };
const queryEqual = q === h.values[h.values.length - 1];
const newValues = !queryEqual && q ? [...h.values, q] : h.values;
// limit the history
if (newValues.length > MAX_QUERIES_HISTORY) newValues.shift();
return {
index: h.values.length - Number(queryEqual),
values: newValues
};
})
});
};
@@ -267,10 +275,7 @@ const QueryConfigurator: FC<QueryConfiguratorProps> = ({
<div className="vm-query-configurator-settings">
<AdditionalSettings hideButtons={hideButtons}/>
<div className="vm-query-configurator-settings__buttons">
<QueryHistory
handleSelectQuery={handleSelectHistory}
historyKey={"METRICS_QUERY_HISTORY"}
/>
<QueryHistory handleSelectQuery={handleSelectHistory}/>
{hideButtons?.anomalyConfig && <AnomalyConfig/>}
{!hideButtons?.addQuery && stateQuery.length < MAX_QUERY_FIELDS && (
<Button

View File

@@ -1,23 +1,22 @@
import React, { FC, useEffect, useMemo, useState } from "preact/compat";
import Button from "../Main/Button/Button";
import { ClockIcon, DeleteIcon } from "../Main/Icons";
import Tooltip from "../Main/Tooltip/Tooltip";
import useBoolean from "../../hooks/useBoolean";
import Modal from "../Main/Modal/Modal";
import Tabs from "../Main/Tabs/Tabs";
import useDeviceDetect from "../../hooks/useDeviceDetect";
import useEventListener from "../../hooks/useEventListener";
import { useQueryState } from "../../state/query/QueryStateContext";
import { clearQueryHistoryStorage, getQueriesFromStorage, setFavoriteQueriesToStorage } from "./utils";
import Button from "../../../components/Main/Button/Button";
import { ClockIcon, DeleteIcon } from "../../../components/Main/Icons";
import Tooltip from "../../../components/Main/Tooltip/Tooltip";
import useBoolean from "../../../hooks/useBoolean";
import Modal from "../../../components/Main/Modal/Modal";
import Tabs from "../../../components/Main/Tabs/Tabs";
import useDeviceDetect from "../../../hooks/useDeviceDetect";
import useEventListener from "../../../hooks/useEventListener";
import { useQueryState } from "../../../state/query/QueryStateContext";
import { getQueriesFromStorage } from "./utils";
import QueryHistoryItem from "./QueryHistoryItem";
import classNames from "classnames";
import "./style.scss";
import { saveToStorage, StorageKeys } from "../../utils/storage";
import { arrayEquals } from "../../utils/array";
import { saveToStorage } from "../../../utils/storage";
import { arrayEquals } from "../../../utils/array";
interface Props {
handleSelectQuery: (query: string, index: number) => void
historyKey: Extract<StorageKeys, "LOGS_QUERY_HISTORY" | "METRICS_QUERY_HISTORY">;
}
export const HistoryTabTypes = {
@@ -32,7 +31,7 @@ export const historyTabs = [
{ label: "Favorite queries", value: HistoryTabTypes.favorite },
];
const QueryHistory: FC<Props> = ({ handleSelectQuery, historyKey }) => {
const QueryHistory: FC<Props> = ({ handleSelectQuery }) => {
const { queryHistory: historyState } = useQueryState();
const { isMobile } = useDeviceDetect();
@@ -43,8 +42,8 @@ const QueryHistory: FC<Props> = ({ handleSelectQuery, historyKey }) => {
} = useBoolean(false);
const [activeTab, setActiveTab] = useState(historyTabs[0].value);
const [historyStorage, setHistoryStorage] = useState(getQueriesFromStorage(historyKey, "QUERY_HISTORY"));
const [historyFavorites, setHistoryFavorites] = useState(getQueriesFromStorage(historyKey, "QUERY_FAVORITES"));
const [historyStorage, setHistoryStorage] = useState(getQueriesFromStorage("QUERY_HISTORY"));
const [historyFavorites, setHistoryFavorites] = useState(getQueriesFromStorage("QUERY_FAVORITES"));
const historySession = useMemo(() => {
return historyState.map((h) => h.values.filter(q => q).reverse());
@@ -87,20 +86,20 @@ const QueryHistory: FC<Props> = ({ handleSelectQuery, historyKey }) => {
};
const updateStageHistory = () => {
setHistoryStorage(getQueriesFromStorage(historyKey, "QUERY_HISTORY"));
setHistoryFavorites(getQueriesFromStorage(historyKey, "QUERY_FAVORITES"));
setHistoryStorage(getQueriesFromStorage("QUERY_HISTORY"));
setHistoryFavorites(getQueriesFromStorage("QUERY_FAVORITES"));
};
const handleClearStorage = () => {
clearQueryHistoryStorage(historyKey, "QUERY_HISTORY");
saveToStorage("QUERY_HISTORY", "");
};
useEffect(() => {
const nextValue = historyFavorites[0] || [];
const prevValue = getQueriesFromStorage(historyKey, "QUERY_FAVORITES")[0] || [];
const prevValue = getQueriesFromStorage("QUERY_FAVORITES")[0] || [];
const isEqual = arrayEquals(nextValue, prevValue);
if (isEqual) return;
setFavoriteQueriesToStorage(historyKey, historyFavorites);
saveToStorage("QUERY_FAVORITES", JSON.stringify(historyFavorites));
}, [historyFavorites]);
useEventListener("storage", updateStageHistory);
@@ -175,7 +174,7 @@ const QueryHistory: FC<Props> = ({ handleSelectQuery, historyKey }) => {
startIcon={<DeleteIcon/>}
onClick={handleClearStorage}
>
clear history
clear history
</Button>
</div>
)}

View File

@@ -1,8 +1,8 @@
import React, { FC, useMemo } from "preact/compat";
import Button from "../Main/Button/Button";
import { CopyIcon, PlayCircleOutlineIcon, StarBorderIcon, StarIcon } from "../Main/Icons";
import Tooltip from "../Main/Tooltip/Tooltip";
import useCopyToClipboard from "../../hooks/useCopyToClipboard";
import Button from "../../../components/Main/Button/Button";
import { CopyIcon, PlayCircleOutlineIcon, StarBorderIcon, StarIcon } from "../../../components/Main/Icons";
import Tooltip from "../../../components/Main/Tooltip/Tooltip";
import useCopyToClipboard from "../../../hooks/useCopyToClipboard";
import "./style.scss";
interface Props {

View File

@@ -0,0 +1,27 @@
import { getFromStorage, saveToStorage, StorageKeys } from "../../../utils/storage";
import { QueryHistoryType } from "../../../state/query/reducer";
import { MAX_QUERIES_HISTORY, MAX_QUERY_FIELDS } from "../../../constants/graph";
export const getQueriesFromStorage = (key: StorageKeys) => {
const list = getFromStorage(key) as string;
return list ? JSON.parse(list) as string[][] : [];
};
export const setQueriesToStorage = (history: QueryHistoryType[]) => {
// For localStorage, avoid splitting into query fields because when working from multiple tabs can cause confusion.
// For convenience, we maintain the original structure of `string[][]`
const lastValues = history.map(h => h.values[h.index]);
const storageValues = getQueriesFromStorage("QUERY_HISTORY");
if (!storageValues[0]) storageValues[0] = [];
const values = storageValues[0];
const TOTAL_LIMIT = MAX_QUERIES_HISTORY * MAX_QUERY_FIELDS;
lastValues.forEach((v) => {
const already = values.includes(v);
if (!already && v) values.unshift(v);
if (values.length > TOTAL_LIMIT) values.shift();
});
saveToStorage("QUERY_HISTORY", JSON.stringify(storageValues));
};

View File

@@ -1,56 +0,0 @@
import React, { FC, useMemo } from "preact/compat";
import { useCallback } from "react";
import dayjs from "dayjs";
import DownloadButton from "../../../components/DownloadButton/DownloadButton";
import { DATE_FILENAME_FORMAT } from "../../../constants/date";
import { downloadCSV, downloadJSON } from "../../../utils/file";
import { Logs } from "../../../api/types";
interface DownloadLogsButtonProps {
logs: Logs[];
}
const DownloadLogsButton: FC<DownloadLogsButtonProps> = ({ logs }) => {
const { fileExtensions, getDownloaderByExtension } = useMemo(() => {
const downloadFileOptions: {
extension: string;
downloader: (data: Record<string,string>[], filename: string) => void;
}[] = [
{ extension: "csv", downloader: downloadCSV },
{
extension: "json",
downloader: (data: Record<string,string>[], filename: string) => {
const json = JSON.stringify(data, null, 2);
downloadJSON(json, filename);
}
}
];
const getDownloaderByExtension = (extension: string) => {
return downloadFileOptions.find(({ extension: optionExtension }) => optionExtension === extension)?.downloader;
};
const fileExtensions = downloadFileOptions.map(({ extension }) => extension);
return { fileExtensions, getDownloaderByExtension };
}, []);
const onDownload = useCallback((fileExtension?: string) => {
if (!fileExtension){
return;
}
const downloader = getDownloaderByExtension(fileExtension);
if (downloader){
const timestamp = dayjs().utc().format(DATE_FILENAME_FORMAT);
downloader(logs, `vmui_logs_${timestamp}.${fileExtension}`);
}
}, [logs]);
return <DownloadButton
title={"Download logs"}
onDownload={onDownload}
downloadFormatOptions={fileExtensions}
/>;
};
export default DownloadLogsButton;

View File

@@ -15,16 +15,12 @@ import { useFetchLogHits } from "./hooks/useFetchLogHits";
import { LOGS_ENTRIES_LIMIT } from "../../constants/logs";
import { getTimeperiodForDuration, relativeTimeOptions } from "../../utils/time";
import { useSearchParams } from "react-router-dom";
import { useQueryDispatch, useQueryState } from "../../state/query/QueryStateContext";
import { getUpdatedHistory } from "../../components/QueryHistory/utils";
const storageLimit = Number(getFromStorage("LOGS_LIMIT"));
const defaultLimit = isNaN(storageLimit) ? LOGS_ENTRIES_LIMIT : storageLimit;
const ExploreLogs: FC = () => {
const { serverUrl } = useAppState();
const { queryHistory } = useQueryState();
const queryDispatch = useQueryDispatch();
const { duration, relativeTime, period: periodState } = useTimeState();
const { setSearchParamsFromKeys } = useSearchParamsFromObject();
const [searchParams] = useSearchParams();
@@ -32,18 +28,6 @@ const ExploreLogs: FC = () => {
const [limit, setLimit] = useStateSearchParams(defaultLimit, "limit");
const [query, setQuery] = useStateSearchParams("*", "query");
const updateHistory = () => {
const history = getUpdatedHistory(query, queryHistory[0]);
queryDispatch({
type: "SET_QUERY_HISTORY",
payload: {
key: "LOGS_QUERY_HISTORY",
history: [history],
}
});
};
const [isUpdatingQuery, setIsUpdatingQuery] = useState(false);
const [period, setPeriod] = useState<TimeParams>(periodState);
const [queryError, setQueryError] = useState<ErrorTypes | string>("");
@@ -76,7 +60,6 @@ const ExploreLogs: FC = () => {
"g0.end_input": newPeriod.date,
"g0.relative_time": relativeTime || "none",
});
updateHistory();
};
const handleChangeLimit = (limit: number) => {

View File

@@ -14,7 +14,6 @@ import GroupLogs from "../GroupLogs/GroupLogs";
import JsonView from "../../../components/Views/JsonView/JsonView";
import LineLoader from "../../../components/Main/LineLoader/LineLoader";
import SelectLimit from "../../../components/Main/Pagination/SelectLimit/SelectLimit";
import DownloadLogsButton from "../DownloadLogsButton/DownloadLogsButton";
const MemoizedTableLogs = React.memo(TableLogs);
const MemoizedGroupLogs = React.memo(GroupLogs);
@@ -84,12 +83,7 @@ const ExploreLogsBody: FC<ExploreLogBodyProps> = ({ data, isLoading }) => {
"vm-explore-logs-body-header_mobile": isMobile,
})}
>
<div
className={classNames({
"vm-section-header__tabs": true,
"vm-explore-logs-body-header__tabs_mobile": isMobile,
})}
>
<div className="vm-section-header__tabs">
<Tabs
activeItem={String(activeTab)}
items={tabs}
@@ -105,28 +99,20 @@ const ExploreLogsBody: FC<ExploreLogBodyProps> = ({ data, isLoading }) => {
limit={rowsPerPage}
onChange={handleSetRowsPerPage}
/>
<div className="vm-explore-logs-body-header__table-settings">
{data.length > 0 && <DownloadLogsButton logs={data} />}
<TableSettings
columns={columns}
selectedColumns={displayColumns}
onChangeColumns={setDisplayColumns}
tableCompact={tableCompact}
toggleTableCompact={toggleTableCompact}
/>
</div>
<TableSettings
columns={columns}
selectedColumns={displayColumns}
onChangeColumns={setDisplayColumns}
tableCompact={tableCompact}
toggleTableCompact={toggleTableCompact}
/>
</div>
)}
{activeTab === DisplayType.group && (
<>
<div
className="vm-explore-logs-body-header__settings"
ref={groupSettingsRef}
/>
</>
)}
{activeTab === DisplayType.json && data.length > 0 && (
<DownloadLogsButton logs={data} />
<div
className="vm-explore-logs-body-header__settings"
ref={groupSettingsRef}
/>
)}
</div>

View File

@@ -8,20 +8,12 @@
&_mobile {
margin: -$padding-global 0-$padding-global 0;
display: block;
border-bottom: none;
}
&__settings {
display: flex;
align-items: center;
gap: $padding-small;
justify-content: flex-end;
}
&__table-settings {
display: flex;
flex-direction: row;
}
&__log-info {
@@ -31,12 +23,6 @@
color: $color-text-secondary;
font-size: $font-size-small;
}
&__tabs {
&_mobile {
border-bottom: var(--border-divider);
}
}
}
&__empty {
@@ -59,7 +45,6 @@
&_mobile {
width: calc(100vw - ($padding-global * 2) - var(--scrollbar-width));
padding-top: $padding-large;
}
.vm-table {

View File

@@ -9,8 +9,6 @@ import TextField from "../../../components/Main/TextField/TextField";
import LogsQueryEditorAutocomplete from "../../../components/Configurators/QueryEditor/LogsQL/LogsQueryEditorAutocomplete";
import { useQueryDispatch, useQueryState } from "../../../state/query/QueryStateContext";
import Switch from "../../../components/Main/Switch/Switch";
import QueryHistory from "../../../components/QueryHistory/QueryHistory";
import useBoolean from "../../../hooks/useBoolean";
export interface ExploreLogHeaderProps {
query: string;
@@ -32,12 +30,11 @@ const ExploreLogsHeader: FC<ExploreLogHeaderProps> = ({
onRun,
}) => {
const { isMobile } = useDeviceDetect();
const { autocomplete, queryHistory } = useQueryState();
const { autocomplete } = useQueryState();
const queryDispatch = useQueryDispatch();
const [errorLimit, setErrorLimit] = useState("");
const [limitInput, setLimitInput] = useState(limit);
const { value: awaitQuery, setValue: setAwaitQuery } = useBoolean(false);
const handleChangeLimit = (val: string) => {
const number = +val;
@@ -58,33 +55,6 @@ const ExploreLogsHeader: FC<ExploreLogHeaderProps> = ({
setLimitInput(limit);
}, [limit]);
const handleHistoryChange = (step: number) => {
const { values, index } = queryHistory[0];
const newIndexHistory = index + step;
if (newIndexHistory < 0 || newIndexHistory >= values.length) return;
onChange(values[newIndexHistory] || "");
queryDispatch({
type: "SET_QUERY_HISTORY_BY_INDEX",
payload: { value: { values, index: newIndexHistory }, queryNumber: 0 }
});
};
const handleSelectHistory = (value: string) => {
onChange(value);
setAwaitQuery(true);
};
const createHandlerArrow = (step: number) => () => {
handleHistoryChange(step);
};
useEffect(() => {
if (awaitQuery) {
onRun();
setAwaitQuery(false);
}
}, [query, awaitQuery]);
return (
<div
className={classNames({
@@ -98,8 +68,8 @@ const ExploreLogsHeader: FC<ExploreLogHeaderProps> = ({
value={query}
autocomplete={autocomplete}
autocompleteEl={LogsQueryEditorAutocomplete}
onArrowUp={createHandlerArrow(-1)}
onArrowDown={createHandlerArrow(1)}
onArrowUp={() => null}
onArrowDown={() => null}
onEnter={onRun}
onChange={onChange}
label={"Log query"}
@@ -143,22 +113,16 @@ const ExploreLogsHeader: FC<ExploreLogHeaderProps> = ({
Documentation
</a>
</div>
<QueryHistory
handleSelectQuery={handleSelectHistory}
historyKey={"LOGS_QUERY_HISTORY"}
/>
<div className="vm-explore-logs-header-bottom-execute">
<Button
startIcon={isLoading ? <SpinnerIcon/> : <PlayIcon/>}
onClick={onRun}
fullWidth
>
<div>
<span className="vm-explore-logs-header-bottom-execute__text">
{isLoading ? "Cancel Query" : "Execute Query"}
</span>
<span className="vm-explore-logs-header-bottom-execute__text_hidden">Execute Query</span>
</div>
<span className="vm-explore-logs-header-bottom-execute__text">
{isLoading ? "Cancel Query" : "Execute Query"}
</span>
<span className="vm-explore-logs-header-bottom-execute__text_hidden">Execute Query</span>
</Button>
</div>
</div>

View File

@@ -18,8 +18,6 @@ import SelectLimit from "../../../components/Main/Pagination/SelectLimit/SelectL
import { usePaginateGroups } from "../hooks/usePaginateGroups";
import { GroupLogsType } from "../../../types";
import { getNanoTimestamp } from "../../../utils/time";
import useDeviceDetect from "../../../hooks/useDeviceDetect";
import DownloadLogsButton from "../DownloadLogsButton/DownloadLogsButton";
interface Props {
logs: Logs[];
@@ -27,7 +25,6 @@ interface Props {
}
const GroupLogs: FC<Props> = ({ logs, settingsRef }) => {
const { isMobile } = useDeviceDetect();
const [searchParams, setSearchParams] = useSearchParams();
const [page, setPage] = useState(1);
@@ -97,7 +94,7 @@ const GroupLogs: FC<Props> = ({ logs, settingsRef }) => {
};
useEffect(() => {
setExpandGroups(new Array(groupData.length).fill(!isMobile));
setExpandGroups(new Array(groupData.length).fill(true));
}, [groupData]);
useEffect(() => {
@@ -162,7 +159,6 @@ const GroupLogs: FC<Props> = ({ logs, settingsRef }) => {
ariaLabel={expandAll ? "Collapse All" : "Expand All"}
/>
</Tooltip>
<DownloadLogsButton logs={logs} />
<GroupLogsConfigurators logs={logs}/>
</div>
), settingsRef.current)}

View File

@@ -13,7 +13,6 @@ import { useSearchParams } from "react-router-dom";
import { LOGS_DATE_FORMAT, LOGS_URL_PARAMS } from "../../../constants/logs";
import useEventListener from "../../../hooks/useEventListener";
import { getFromStorage } from "../../../utils/storage";
import { parseAnsiToHtml } from "../../../utils/ansiParser";
interface Props {
log: Logs;
@@ -27,7 +26,7 @@ const GroupLogsItem: FC<Props> = ({ log, displayFields = ["_msg"] }) => {
} = useBoolean(false);
const [searchParams] = useSearchParams();
const { markdownParsing, ansiParsing } = useLogsState();
const { markdownParsing } = useLogsState();
const { timezone } = useTimeState();
const noWrapLines = searchParams.get(LOGS_URL_PARAMS.NO_WRAP_LINES) === "true";
@@ -47,25 +46,22 @@ const GroupLogsItem: FC<Props> = ({ log, displayFields = ["_msg"] }) => {
const hasFields = fields.length > 0;
const displayMessage = useMemo(() => {
const values: (string | React.ReactNode)[] = [];
if (!hasFields) {
values.push("-");
if (displayFields.length) {
return displayFields.filter(field => log[field]).map((field, i) => (
<span
className="vm-group-logs-row-content__sub-msg"
key={field + i}
>{log[field]}</span>
));
}
if (displayFields.some(field => log[field])) {
displayFields.filter(field => log[field]).forEach((field) => {
const value = field === "_msg" && ansiParsing ? parseAnsiToHtml(log[field]) : log[field];
values.push(value);
});
} else {
fields.forEach(([key, value]) => {
values.push(`${key}: ${value}`);
});
}
return values;
}, [log, fields, hasFields, displayFields, ansiParsing]);
if (log._msg) return log._msg;
if (!hasFields) return;
const dataObject = fields.reduce<{ [key: string]: string }>((obj, [key, value]) => {
obj[key] = value;
return obj;
}, {});
return JSON.stringify(dataObject);
}, [log, fields, hasFields, displayFields]);
const [disabledHovers, setDisabledHovers] = useState(!!getFromStorage("LOGS_DISABLED_HOVERS"));
@@ -112,16 +108,9 @@ const GroupLogsItem: FC<Props> = ({ log, displayFields = ["_msg"] }) => {
"vm-group-logs-row-content__msg_missing": !displayMessage,
"vm-group-logs-row-content__msg_single-line": noWrapLines,
})}
dangerouslySetInnerHTML={formattedMarkdown ? { __html: formattedMarkdown } : undefined}
dangerouslySetInnerHTML={(markdownParsing && formattedMarkdown) ? { __html: formattedMarkdown } : undefined}
>
{displayMessage.map((msg, i) => (
<span
className="vm-group-logs-row-content__sub-msg"
key={`${msg}_${i}`}
>
{msg}
</span>
))}
{displayMessage || "-"}
</div>
</div>
{hasFields && isOpenFields && (

View File

@@ -16,7 +16,7 @@ const MetricsQL = () => (
const NodeExporterFull = () => (
<a
className="vm-link vm-link_colored"
href="https://grafana.com/grafana/dashboards/1860"
href="https://grafana.com/grafana/dashboards/1860-node-exporter-full/"
target="_blank"
rel="help noreferrer"
>

View File

@@ -4,19 +4,16 @@ import { AUTOCOMPLETE_LIMITS } from "../../constants/queryAutocomplete";
export interface LogsState {
markdownParsing: boolean;
ansiParsing: boolean;
autocompleteCache: Map<string, LogsFiledValues[]>;
}
export type LogsAction =
| { type: "SET_MARKDOWN_PARSING", payload: boolean }
| { type: "SET_ANSI_PARSING", payload: boolean }
| { type: "SET_AUTOCOMPLETE_CACHE", payload: { key: string, value: LogsFiledValues[] } }
export const initialLogsState: LogsState = {
markdownParsing: getFromStorage("LOGS_MARKDOWN") === "true",
ansiParsing: getFromStorage("LOGS_ANSI") === "true",
autocompleteCache: new Map<string, LogsFiledValues[]>(),
};
@@ -28,12 +25,6 @@ export function reducer(state: LogsState, action: LogsAction): LogsState {
...state,
markdownParsing: action.payload
};
case "SET_ANSI_PARSING":
saveToStorage("LOGS_ANSI", `${ action.payload}`);
return {
...state,
ansiParsing: action.payload
};
case "SET_AUTOCOMPLETE_CACHE": {
if (state.autocompleteCache.size >= AUTOCOMPLETE_LIMITS.cacheLimit) {
const firstKey = state.autocompleteCache.keys().next().value;

View File

@@ -1,6 +1,6 @@
import { getFromStorage, saveToStorage } from "../../utils/storage";
import { getQueryArray } from "../../utils/query-string";
import { HistoryKey, setQueriesToStorage } from "../../components/QueryHistory/utils";
import { setQueriesToStorage } from "../../pages/CustomPanel/QueryHistory/utils";
import {
QueryAutocompleteCache,
QueryAutocompleteCacheItem
@@ -24,7 +24,7 @@ export interface QueryState {
export type QueryAction =
| { type: "SET_QUERY", payload: string[] }
| { type: "SET_QUERY_HISTORY_BY_INDEX", payload: { value: QueryHistoryType, queryNumber: number } }
| { type: "SET_QUERY_HISTORY", payload: { key: HistoryKey, history: QueryHistoryType[] } }
| { type: "SET_QUERY_HISTORY", payload: QueryHistoryType[] }
| { type: "TOGGLE_AUTOCOMPLETE" }
| { type: "SET_AUTOCOMPLETE_QUICK", payload: boolean }
| { type: "SET_AUTOCOMPLETE_CACHE", payload: { key: QueryAutocompleteCacheItem, value: string[] } }
@@ -48,10 +48,10 @@ export function reducer(state: QueryState, action: QueryAction): QueryState {
query: action.payload.map(q => q)
};
case "SET_QUERY_HISTORY":
setQueriesToStorage(action.payload.key, action.payload.history);
setQueriesToStorage(action.payload);
return {
...state,
queryHistory: action.payload.history
queryHistory: action.payload
};
case "SET_QUERY_HISTORY_BY_INDEX":
state.queryHistory.splice(action.payload.queryNumber, 1, action.payload.value);

View File

@@ -1,120 +0,0 @@
import { parseAnsiToHtml, getAnsiColor } from "./ansiParser";
import { render } from "@testing-library/preact";
describe("ANSI Parser", () => {
// Test getAnsiColor for standard color values.
test("getAnsiColor should return correct color for standard values", () => {
expect(getAnsiColor(0)).toBe("#000000");
expect(getAnsiColor(1)).toBe("#AA0000");
expect(getAnsiColor(15)).toBe("#FFFFFF");
expect(getAnsiColor(255)).toBe("rgb(238,238,238)");
});
// Test getAnsiColor for invalid color codes.
test("getAnsiColor should return null for invalid color codes", () => {
expect(getAnsiColor(-1)).toBeNull();
expect(getAnsiColor(300)).toBeNull();
});
// Test that parseAnsiToHtml renders plain text without ANSI codes.
test("parseAnsiToHtml should render text without ANSI codes correctly", () => {
const { container } = render(parseAnsiToHtml("Hello, World!"));
expect(container.textContent).toBe("Hello, World!");
});
// Test that parseAnsiToHtml applies correct foreground color.
test("parseAnsiToHtml should apply correct styles for color codes", () => {
const { container } = render(parseAnsiToHtml("\u001B[31mRed Text\u001B[0m"));
const span = container.querySelector("span");
expect(span).toHaveStyle("color: #AA0000"); // Red color
});
// Test that parseAnsiToHtml resets styles when ANSI code 0 is used.
test("parseAnsiToHtml should reset styles with ANSI code 0", () => {
const { container } = render(parseAnsiToHtml("\u001B[31mRed \u001B[0mNormal"));
const spans = container.querySelectorAll("span");
expect(spans.length).toBe(2);
expect(spans[0]).toHaveStyle("color: #AA0000");
expect(spans[1]).toHaveStyle("color: inherit");
});
// Test that parseAnsiToHtml correctly parses bold text.
test("parseAnsiToHtml should correctly parse bold text", () => {
const { container } = render(parseAnsiToHtml("\u001B[1mBold Text\u001B[0m"));
const span = container.querySelector("span");
expect(span).toHaveStyle("font-weight: bold");
});
// Test that parseAnsiToHtml correctly parses underlined text.
test("parseAnsiToHtml should correctly parse underline text", () => {
const { container } = render(parseAnsiToHtml("\u001B[4mUnderlined\u001B[0m"));
const span = container.querySelector("span");
expect(span).toHaveStyle("text-decoration: underline");
});
// Test that parseAnsiToHtml correctly applies background colors.
test("parseAnsiToHtml should correctly parse background colors", () => {
const { container } = render(parseAnsiToHtml("\u001B[44mBlue Background\u001B[0m"));
const span = container.querySelector("span");
expect(span).toHaveStyle("background-color: #0000AA");
});
// Edge case: Test that parseAnsiToHtml returns empty output for an empty input string.
test("parseAnsiToHtml should return empty output for empty input", () => {
const { container } = render(parseAnsiToHtml(""));
expect(container.textContent).toBe("");
});
// Edge case: Test combined ANSI codes (e.g., bold and red text).
test("parseAnsiToHtml should correctly parse combined ANSI codes", () => {
const { container } = render(parseAnsiToHtml("\u001B[31;1mBold Red Text\u001B[0m"));
const span = container.querySelector("span");
expect(span).toHaveStyle("color: #AA0000");
expect(span).toHaveStyle("font-weight: bold");
});
// Edge case: Test extended foreground color using the ANSI sequence "38;5;n".
test("parseAnsiToHtml should correctly parse extended foreground color", () => {
// Using extended color code 82 for this test.
const { container } = render(parseAnsiToHtml("\u001B[38;5;82mExtended Color\u001B[0m"));
const span = container.querySelector("span");
expect(span).toHaveStyle(`color: ${getAnsiColor(82)}`);
});
// Edge case: Test cancelling bold, italic, and underline styles.
test("parseAnsiToHtml should correctly cancel bold, italic, and underline styles", () => {
const input =
"\u001B[1mBold\u001B[22m Normal " +
"\u001B[3mItalic\u001B[23m Normal " +
"\u001B[4mUnderline\u001B[24m Normal";
const { container } = render(parseAnsiToHtml(input));
const spans = container.querySelectorAll("span");
// Check that after the cancellation codes, the style properties are set to 'inherit'
spans.forEach(span => {
if (span.textContent?.includes("Normal")) {
expect(span).toHaveStyle("font-weight: inherit");
expect(span).toHaveStyle("font-style: inherit");
expect(span).toHaveStyle("text-decoration: inherit");
}
});
});
// Edge case: Test swapping foreground and background colors using codes 7 and 27.
test("parseAnsiToHtml should correctly swap foreground and background colors", () => {
// Set foreground to red (31) and background to blue (44), then swap with code 7.
const { container } = render(parseAnsiToHtml("\u001B[31m\u001B[44m\u001B[7mSwapped Colors\u001B[0m"));
const span = container.querySelector("span");
// After swap, the color should be blue and the background should be red.
expect(span).toHaveStyle("color: #0000AA");
expect(span).toHaveStyle("background-color: #AA0000");
});
// Edge case: Test that unknown ANSI codes do not change the current styles.
test("parseAnsiToHtml should ignore unknown ANSI codes", () => {
const { container } = render(parseAnsiToHtml("\u001B[999mText with unknown code\u001B[0m"));
const span = container.querySelector("span");
expect(span).toHaveStyle("color: inherit");
});
});

View File

@@ -1,185 +0,0 @@
import React from "react";
// Define a specific interface for the ANSI style properties.
interface AnsiStyles {
color: string | null;
fontWeight: string | null;
fontStyle: string | null;
textDecoration: string | null;
backgroundColor: string | null;
}
const getDefaultColors = (): Record<number, string> => {
const colors: Record<number, string> = {
0: "#000000", 1: "#AA0000", 2: "#00AA00", 3: "#AA5500",
4: "#0000AA", 5: "#AA00AA", 6: "#00AAAA", 7: "#AAAAAA",
8: "#555555", 9: "#FF5555", 10: "#55FF55", 11: "#FFFF55",
12: "#5555FF", 13: "#FF55FF", 14: "#55FFFF", 15: "#FFFFFF"
};
for (let r = 0; r < 6; r++) {
for (let g = 0; g < 6; g++) {
for (let b = 0; b < 6; b++) {
const index = 16 + (r * 36) + (g * 6) + b;
const red = r > 0 ? r * 40 + 55 : 0;
const green = g > 0 ? g * 40 + 55 : 0;
const blue = b > 0 ? b * 40 + 55 : 0;
colors[index] = `rgb(${red},${green},${blue})`;
}
}
}
for (let i = 0; i < 24; i++) {
const index = 232 + i;
const value = 8 + i * 10;
colors[index] = `rgb(${value},${value},${value})`;
}
return colors;
};
const ansiColors = getDefaultColors();
const getAnsiColor = (code: number): string | null => ansiColors[code] || null;
const ST = "(?:\\u0007|\\u001B\\u005C|\\u009C)";
const ansiPattern = [
`[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]+)*|[a-zA-Z\\d]+(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?${ST})`,
"(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]))"
].join("|");
const ansiRegex = new RegExp(ansiPattern, "g");
/**
* Updates the style object based on a given ANSI code.
*
* @param styles - The current styles.
* @param code - The ANSI code.
* @param codes - All ANSI codes in the current escape sequence.
* @returns The updated style object.
*/
const updateStyles = (
styles: AnsiStyles,
code: number,
codes: number[]
): AnsiStyles => {
switch (code) {
case 0:
// Reset all styles.
return { color: null, fontWeight: null, fontStyle: null, textDecoration: null, backgroundColor: null };
case 30: case 31: case 32: case 33:
case 34: case 35: case 36: case 37:
// Set foreground color.
return { ...styles, color: getAnsiColor(code - 30) };
case 90: case 91: case 92: case 93:
case 94: case 95: case 96: case 97:
// Set bright foreground color.
return { ...styles, color: getAnsiColor(8 + (code - 90)) };
case 38:
// Extended foreground color: expects additional parameters, e.g., "38;5;{n}".
if (codes.length > 2 && codes[1] === 5) {
return { ...styles, color: getAnsiColor(codes[2]) };
}
return styles;
case 40: case 41: case 42: case 43:
case 44: case 45: case 46: case 47:
// Set background color.
return { ...styles, backgroundColor: getAnsiColor(code - 40) };
case 100: case 101: case 102: case 103:
case 104: case 105: case 106: case 107:
// Set bright background color.
return { ...styles, backgroundColor: getAnsiColor(8 + (code - 100)) };
case 1:
// Bold text.
return { ...styles, fontWeight: "bold" };
case 3:
// Italic text.
return { ...styles, fontStyle: "italic" };
case 4:
// Underline text.
return { ...styles, textDecoration: "underline" };
case 7: case 27:
// Swap foreground and background colors.
return { ...styles, color: styles.backgroundColor, backgroundColor: styles.color };
case 22:
// Normal intensity (cancel bold).
return { ...styles, fontWeight: null };
case 23:
// Cancel italic.
return { ...styles, fontStyle: null };
case 24:
// Cancel underline.
return { ...styles, textDecoration: null };
default:
return styles;
}
};
/**
* Parses a string containing ANSI escape codes and returns an array of React elements with inline styles.
*
* @param input - The string to parse.
* @returns An array of React.ReactNode elements.
*/
export const parseAnsiToHtml = (input: string): React.ReactNode[] => {
let lastIndex = 0;
const result: React.ReactNode[] = [];
let currentStyles: AnsiStyles = {
color: null,
fontWeight: null,
fontStyle: null,
textDecoration: null,
backgroundColor: null
};
let match;
while ((match = ansiRegex.exec(input)) !== null) {
// Process text before the ANSI escape sequence.
const plainText = input.slice(lastIndex, match.index);
if (plainText) {
result.push(
<span
key={lastIndex}
style={{
color: currentStyles.color || "inherit",
fontWeight: currentStyles.fontWeight || "inherit",
fontStyle: currentStyles.fontStyle || "inherit",
textDecoration: currentStyles.textDecoration || "inherit",
backgroundColor: currentStyles.backgroundColor || "inherit"
}}
>
{plainText}
</span>
);
}
// Extract ANSI codes from the escape sequence and update styles accordingly.
const codes = match[0].match(/\d+/g)?.map(Number) || [];
codes.forEach(code => {
currentStyles = updateStyles(currentStyles, code, codes);
});
lastIndex = ansiRegex.lastIndex;
}
// Process any remaining text after the last ANSI escape sequence.
if (lastIndex < input.length) {
result.push(
<span
key={lastIndex}
style={{
color: currentStyles.color || "inherit",
fontWeight: currentStyles.fontWeight || "inherit",
fontStyle: currentStyles.fontStyle || "inherit",
textDecoration: currentStyles.textDecoration || "inherit",
backgroundColor: currentStyles.backgroundColor || "inherit"
}}
>
{input.slice(lastIndex)}
</span>
);
}
return result;
};
export { ansiColors, getAnsiColor, getDefaultColors };

View File

@@ -1,48 +0,0 @@
export const downloadFile = (data: Blob, filename: string) => {
const link = document.createElement("a");
const url = URL.createObjectURL(data);
link.setAttribute("href", url);
link.setAttribute("download", filename);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
export const downloadCSV = (data: Record<string, string>[], filename: string) => {
const getHeader = (data: Record<string, string>[]) => {
const headersObj = data.reduce<Record<string, boolean>>((headers, row) => {
Object.keys(row).forEach((key) => {
if(key && !headers[key]){
headers[key] = true;
}
});
return headers;
}, {});
return Object.keys(headersObj);
};
const formatValueToCSV= (value: string) =>
(value.includes(",") || value.includes("\n") || value.includes("\""))
? "\"" + value.replace(/"/g, "\"\"") + "\""
: value;
const convertToCSV = (data: Record<string, string>[]): string => {
const header = getHeader(data);
const rows = data.map(item =>
header.map(fieldName => item[fieldName] ? formatValueToCSV(item[fieldName]): "").join(",")
);
return [header.map(formatValueToCSV).join(","), ...rows].join("\r\n");
};
const csvContent = convertToCSV(data);
const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" });
downloadFile(blob, filename);
};
export const downloadJSON = (data: string, filename: string) => {
const blob = new Blob([data], { type: "application/json" });
downloadFile(blob, filename);
};

View File

@@ -60,12 +60,12 @@ export const getLastFromArray = (a: number[]) => {
export const formatNumberShort = (value: number) => {
if (value >= 1_000_000_000) {
return (value / 1_000_000_000).toFixed(1).replace(/\.0$/, "") + "B";
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";
return (value / 1_000_000).toFixed(1).replace(/\.0$/, "") + "M"; // Миллионы
} else if (value >= 1_000) {
return (value / 1_000).toFixed(1).replace(/\.0$/, "") + "K";
return (value / 1_000).toFixed(1).replace(/\.0$/, "") + "K"; // Тысячи
} else {
return value.toString();
return value.toString(); // Для чисел меньше 1000
}
};

View File

@@ -1,26 +1,18 @@
/**
* Do not use this type in local storage type
* @deprecated
* */
type DeprecatedStorageKeys = "QUERY_HISTORY" | "QUERY_FAVORITES";
export type StorageKeys = "AUTOCOMPLETE"
| "NO_CACHE"
| "QUERY_TRACING"
| "SERIES_LIMITS"
| "TABLE_COMPACT"
| "TIMEZONE"
| "DISABLED_DEFAULT_TIMEZONE"
| "THEME"
| "LOGS_LIMIT"
| "LOGS_MARKDOWN"
| "LOGS_DISABLED_HOVERS"
| "EXPLORE_METRICS_TIPS"
| "LOGS_QUERY_HISTORY"
| "METRICS_QUERY_HISTORY"
| "SERVER_URL"
| DeprecatedStorageKeys;
| "NO_CACHE"
| "QUERY_TRACING"
| "SERIES_LIMITS"
| "TABLE_COMPACT"
| "TIMEZONE"
| "DISABLED_DEFAULT_TIMEZONE"
| "THEME"
| "LOGS_LIMIT"
| "LOGS_MARKDOWN"
| "LOGS_DISABLED_HOVERS"
| "EXPLORE_METRICS_TIPS"
| "QUERY_HISTORY"
| "QUERY_FAVORITES"
| "SERVER_URL"
export const saveToStorage = (key: StorageKeys, value: string | boolean | Record<string, unknown>): void => {
if (value) {

View File

@@ -344,53 +344,3 @@ type SnapshotDeleteResponse struct {
type SnapshotDeleteAllResponse struct {
Status string
}
// TSDBStatusResponse is an in-memory reprensentation of the json response
// returned by the /prometheus/api/v1/status/tsdb endpoint.
type TSDBStatusResponse struct {
IsPartial bool
Data TSDBStatusResponseData
}
// Sort performs sorting of stats entries
func (tsr *TSDBStatusResponse) Sort() {
sortTSDBStatusResponseEntries(tsr.Data.SeriesCountByLabelName)
sortTSDBStatusResponseEntries(tsr.Data.SeriesCountByFocusLabelValue)
sortTSDBStatusResponseEntries(tsr.Data.SeriesCountByLabelValuePair)
sortTSDBStatusResponseEntries(tsr.Data.LabelValueCountByLabelName)
}
// TSDBStatusResponseData is a part of TSDBStatusResponse
type TSDBStatusResponseData struct {
TotalSeries int
TotalLabelValuePairs int
SeriesCountByMetricName []TSDBStatusResponseMetricNameEntry
SeriesCountByLabelName []TSDBStatusResponseEntry
SeriesCountByFocusLabelValue []TSDBStatusResponseEntry
SeriesCountByLabelValuePair []TSDBStatusResponseEntry
LabelValueCountByLabelName []TSDBStatusResponseEntry
}
// TSDBStatusResponseEntry defines stats entry for TSDBStatusResponseData
type TSDBStatusResponseEntry struct {
Name string
Count int
}
// TSDBStatusResponseMetricNameEntry defines metric names stats entry for TSDBStatusResponseData
type TSDBStatusResponseMetricNameEntry struct {
Name string
Count int
RequestsCount int
LastRequestTimestamp int
}
func sortTSDBStatusResponseEntries(entries []TSDBStatusResponseEntry) {
sort.Slice(entries, func(i, j int) bool {
left, right := entries[i], entries[j]
if left.Count == right.Count {
return left.Name < right.Name
}
return left.Count < right.Count
})
}

View File

@@ -3,11 +3,9 @@ package tests
import (
"fmt"
"os"
"strings"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/VictoriaMetrics/VictoriaMetrics/apptest"
at "github.com/VictoriaMetrics/VictoriaMetrics/apptest"
@@ -21,7 +19,6 @@ func TestSingleMetricNamesStats(t *testing.T) {
const ingestDateTime = `2024-02-05T08:57:36.700Z`
const ingestTimestamp = ` 1707123456700`
const date = `2024-02-05`
dataSet := []string{
`metric_name_1{label="foo"} 10`,
`metric_name_1{label="bar"} 10`,
@@ -29,13 +26,9 @@ func TestSingleMetricNamesStats(t *testing.T) {
`metric_name_1{label="baz"} 10`,
`metric_name_3{label="baz"} 30`,
}
largeMetricName := strings.Repeat("large_metric_name_", 32) + "1"
dataSet = append(dataSet, largeMetricName+`{label="bar"} 50`)
for idx := range dataSet {
dataSet[idx] += ingestTimestamp
}
tsdbMetricNameEntryCmpOpts := cmpopts.IgnoreFields(apptest.TSDBStatusResponseMetricNameEntry{}, "LastRequestTimestamp")
sut.PrometheusAPIV1ImportPrometheus(t, dataSet, at.QueryOpts{})
sut.ForceFlush(t)
@@ -43,7 +36,6 @@ func TestSingleMetricNamesStats(t *testing.T) {
// verify ingest request correctly registered
expected := apptest.MetricNamesStatsResponse{
Records: []at.MetricNamesStatsRecord{
{MetricName: largeMetricName},
{MetricName: "metric_name_1"},
{MetricName: "metric_name_2"},
{MetricName: "metric_name_3"},
@@ -58,7 +50,6 @@ func TestSingleMetricNamesStats(t *testing.T) {
sut.PrometheusAPIV1Query(t, `{__name__!=""}`, at.QueryOpts{Time: ingestDateTime})
expected = apptest.MetricNamesStatsResponse{
Records: []at.MetricNamesStatsRecord{
{MetricName: largeMetricName, QueryRequestsCount: 1},
{MetricName: "metric_name_1", QueryRequestsCount: 3},
{MetricName: "metric_name_2", QueryRequestsCount: 1},
{MetricName: "metric_name_3", QueryRequestsCount: 1},
@@ -69,38 +60,10 @@ func TestSingleMetricNamesStats(t *testing.T) {
t.Errorf("unexpected response (-want, +got):\n%s", diff)
}
expectedStatsResponse := apptest.TSDBStatusResponse{
Data: at.TSDBStatusResponseData{
TotalSeries: 6,
TotalLabelValuePairs: 12,
SeriesCountByMetricName: []apptest.TSDBStatusResponseMetricNameEntry{
{Name: "metric_name_1", RequestsCount: 3},
{Name: largeMetricName, RequestsCount: 1},
{Name: "metric_name_2", RequestsCount: 1},
{Name: "metric_name_3", RequestsCount: 1},
},
SeriesCountByLabelName: []apptest.TSDBStatusResponseEntry{{Name: "__name__"}, {Name: "label"}},
SeriesCountByFocusLabelValue: []apptest.TSDBStatusResponseEntry{},
SeriesCountByLabelValuePair: []apptest.TSDBStatusResponseEntry{
{Name: "__name__=" + largeMetricName},
{Name: "__name__=metric_name_1"}, {Name: "label=baz"},
{Name: "__name__=metric_name_2"}, {Name: "__name__=metric_name_3"},
{Name: "label=bar"}, {Name: "label=foo"},
},
LabelValueCountByLabelName: []apptest.TSDBStatusResponseEntry{{Name: "__name__"}, {Name: "label"}},
},
}
expectedStatsResponse.Sort()
gotStatus := sut.APIV1StatusTSDB(t, "", date, "", apptest.QueryOpts{})
if diff := cmp.Diff(expectedStatsResponse, gotStatus, tsdbMetricNameEntryCmpOpts); diff != "" {
t.Errorf("unexpected APIV1StatusTSDB response (-want, +got):\n%s", diff)
}
// perform query request for single metric and check counter increase
sut.PrometheusAPIV1Query(t, `metric_name_2`, at.QueryOpts{Time: ingestDateTime})
expected = apptest.MetricNamesStatsResponse{
Records: []at.MetricNamesStatsRecord{
{MetricName: largeMetricName, QueryRequestsCount: 1},
{MetricName: "metric_name_1", QueryRequestsCount: 3},
{MetricName: "metric_name_2", QueryRequestsCount: 2},
{MetricName: "metric_name_3", QueryRequestsCount: 1},
@@ -114,7 +77,6 @@ func TestSingleMetricNamesStats(t *testing.T) {
// verify le filter
expected = apptest.MetricNamesStatsResponse{
Records: []at.MetricNamesStatsRecord{
{MetricName: largeMetricName, QueryRequestsCount: 1},
{MetricName: "metric_name_2", QueryRequestsCount: 2},
{MetricName: "metric_name_3", QueryRequestsCount: 1},
},
@@ -167,7 +129,6 @@ func TestClusterMetricNamesStats(t *testing.T) {
const ingestDateTime = `2024-02-05T08:57:36.700Z`
const ingestTimestamp = ` 1707123456700`
const date = `2024-02-05`
dataSet := []string{
`metric_name_1{label="foo"} 10`,
`metric_name_1{label="bar"} 10`,
@@ -175,15 +136,10 @@ func TestClusterMetricNamesStats(t *testing.T) {
`metric_name_1{label="baz"} 10`,
`metric_name_3{label="baz"} 30`,
}
largeMetricName := strings.Repeat("large_metric_name_", 32) + "1"
dataSet = append(dataSet, largeMetricName+`{label="bar"} 50`)
for idx := range dataSet {
dataSet[idx] += ingestTimestamp
}
tsdbMetricNameEntryCmpOpts := cmpopts.IgnoreFields(apptest.TSDBStatusResponseMetricNameEntry{}, "LastRequestTimestamp")
// ingest per tenant data and verify it with search
tenantIDs := []string{"1:1", "1:15", "15:15"}
for _, tenantID := range tenantIDs {
@@ -194,7 +150,6 @@ func TestClusterMetricNamesStats(t *testing.T) {
// verify ingest request correctly registered
expected := apptest.MetricNamesStatsResponse{
Records: []at.MetricNamesStatsRecord{
{MetricName: largeMetricName},
{MetricName: "metric_name_1"},
{MetricName: "metric_name_2"},
{MetricName: "metric_name_3"},
@@ -212,7 +167,6 @@ func TestClusterMetricNamesStats(t *testing.T) {
expected = apptest.MetricNamesStatsResponse{
Records: []at.MetricNamesStatsRecord{
{MetricName: largeMetricName, QueryRequestsCount: 1},
{MetricName: "metric_name_2", QueryRequestsCount: 1},
{MetricName: "metric_name_3", QueryRequestsCount: 1},
{MetricName: "metric_name_1", QueryRequestsCount: 3},
@@ -222,39 +176,11 @@ func TestClusterMetricNamesStats(t *testing.T) {
if diff := cmp.Diff(expected, gotStats); diff != "" {
t.Errorf("unexpected response tenant: %s (-want, +got):\n%s", tenantID, diff)
}
expectedStatsResponse := apptest.TSDBStatusResponse{
Data: at.TSDBStatusResponseData{
TotalSeries: 6,
TotalLabelValuePairs: 12,
SeriesCountByMetricName: []apptest.TSDBStatusResponseMetricNameEntry{
{Name: "metric_name_1", RequestsCount: 3},
{Name: largeMetricName, RequestsCount: 1},
{Name: "metric_name_2", RequestsCount: 1},
{Name: "metric_name_3", RequestsCount: 1},
},
SeriesCountByLabelName: []apptest.TSDBStatusResponseEntry{{Name: "__name__"}, {Name: "label"}},
SeriesCountByFocusLabelValue: []apptest.TSDBStatusResponseEntry{},
SeriesCountByLabelValuePair: []apptest.TSDBStatusResponseEntry{
{Name: "__name__=" + largeMetricName},
{Name: "__name__=metric_name_1"}, {Name: "label=baz"},
{Name: "__name__=metric_name_2"}, {Name: "__name__=metric_name_3"},
{Name: "label=bar"}, {Name: "label=foo"},
},
LabelValueCountByLabelName: []apptest.TSDBStatusResponseEntry{{Name: "__name__"}, {Name: "label"}},
},
}
expectedStatsResponse.Sort()
gotStatus := vmselect.APIV1StatusTSDB(t, "", date, "", apptest.QueryOpts{Tenant: tenantID})
if diff := cmp.Diff(expectedStatsResponse, gotStatus, tsdbMetricNameEntryCmpOpts); diff != "" {
t.Errorf("unexpected APIV1StatusTSDB response tenant: %s (-want, +got):\n%s", tenantID, diff)
}
}
// verify multitenant stats
expected := apptest.MetricNamesStatsResponse{
Records: []at.MetricNamesStatsRecord{
{MetricName: largeMetricName, QueryRequestsCount: 3},
{MetricName: "metric_name_2", QueryRequestsCount: 3},
{MetricName: "metric_name_3", QueryRequestsCount: 3},
{MetricName: "metric_name_1", QueryRequestsCount: 9},

View File

@@ -2,9 +2,6 @@ package tests
import (
"fmt"
"net/http"
"net/http/httptest"
"sync"
"testing"
"github.com/VictoriaMetrics/VictoriaMetrics/apptest"
@@ -33,7 +30,6 @@ func testSingleVMAgentRemoteWrite(t *testing.T, forcePromProto bool) {
`-remoteWrite.flushInterval=50ms`,
fmt.Sprintf(`-remoteWrite.forcePromProto=%v`, forcePromProto),
fmt.Sprintf(`-remoteWrite.url=http://%s/api/v1/write`, vmsingle.HTTPAddr()),
"-remoteWrite.tmpDataPath=" + tc.Dir() + "/vmagent",
}, ``)
vmagent.APIV1ImportPrometheus(t, []string{
@@ -56,123 +52,3 @@ func testSingleVMAgentRemoteWrite(t *testing.T, forcePromProto bool) {
},
})
}
// TestSingleVMAgentUnsupportedMediaTypeDropIfSnappy verifies that the remote write process:
// - Starts with Prometheus remote write protocol using `snappy`.
// - Does not retry `snappy`-encoded requests if they fail; instead, they are dropped.
func TestSingleVMAgentUnsupportedMediaTypeDropIfSnappy(t *testing.T) {
tc := apptest.NewTestCase(t)
defer tc.Stop()
var remoteWriteContentEncodingsMux sync.Mutex
var remoteWriteContentEncodings []string
// remoteWriteSrv is a stub HTTP server simulate a remote write endpoint with the following behavior:
// - Fail all requests with `415 Unsupported Media Type`.
// - Records received `Content-Encoding` header.
remoteWriteSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
remoteWriteContentEncodingsMux.Lock()
remoteWriteContentEncodings = append(remoteWriteContentEncodings, r.Header.Get(`Content-Encoding`))
remoteWriteContentEncodingsMux.Unlock()
w.WriteHeader(http.StatusUnsupportedMediaType)
}))
defer remoteWriteSrv.Close()
vmagent := tc.MustStartVmagent("vmagent", []string{
`-remoteWrite.flushInterval=50ms`,
`-remoteWrite.forcePromProto=true`,
fmt.Sprintf(`-remoteWrite.url=%s/api/v1/write`, remoteWriteSrv.URL),
"-remoteWrite.tmpDataPath=" + tc.Dir() + "/vmagent",
}, ``)
vmagent.APIV1ImportPrometheusNoWaitFlush(t, []string{
"foo_bar 1 1652169600000", // 2022-05-10T08:00:00Z
}, apptest.QueryOpts{})
vmagent.APIV1ImportPrometheusNoWaitFlush(t, []string{
"foo_bar 1 1652169600000", // 2022-05-10T08:00:00Z
}, apptest.QueryOpts{})
tc.Assert(&at.AssertOptions{
Msg: `unexpected content encoding headers sent to remote write server; expected zstd`,
Got: func() any {
remoteWriteContentEncodingsMux.Lock()
defer remoteWriteContentEncodingsMux.Unlock()
return append([]string(nil), remoteWriteContentEncodings...)
},
Want: []string{`snappy`, `snappy`},
})
expectedRetriesCount := 0
if actualRetriesCount := vmagent.RemoteWriteRequestsRetriesCountTotal(t); actualRetriesCount != expectedRetriesCount {
t.Fatalf("unexpected number of retries; got %d, want %d", actualRetriesCount, expectedRetriesCount)
}
expectedPacketsDroppedTotal := 2
if actualPacketsDroppedCount := vmagent.RemoteWritePacketsDroppedTotal(t); actualPacketsDroppedCount != expectedPacketsDroppedTotal {
t.Fatalf("unexpected number of dropped packets; got %d, want %d", actualPacketsDroppedCount, expectedPacketsDroppedTotal)
}
}
// TestSingleVMAgentDowngradeRemoteWriteProtocol verifies that the remote write process:
// - Starts with VictoriaMetrics remote write protocol using `zstd`.
// - Upon receiving `415 Unsupported Media Type`, downgrades to Prometheus remote write with `snappy`.
// - Re-packs and retries failed requests.
// - Sends all subsequent requests using `snappy`.
func TestSingleVMAgentDowngradeRemoteWriteProtocol(t *testing.T) {
tc := apptest.NewTestCase(t)
defer tc.Stop()
var remoteWriteContentEncodings []string
// remoteWriteSrv is a stub HTTP server that simulates a remote write endpoint with the following behavior:
// - Rejects requests with `zstd` encoding by responding with `415 Unsupported Media Type`.
// - Accepts requests with `snappy` encoding.
// - Records the `Content-Encoding` header of incoming requests.
remoteWriteSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
remoteWriteContentEncodings = append(remoteWriteContentEncodings, r.Header.Get(`Content-Encoding`))
if r.Header.Get(`Content-Encoding`) == `zstd` {
w.WriteHeader(http.StatusUnsupportedMediaType)
_, _ = w.Write([]byte(`zstd not supported`))
return
}
w.WriteHeader(http.StatusNoContent)
}))
defer remoteWriteSrv.Close()
vmagent := tc.MustStartVmagent("vmagent", []string{
`-remoteWrite.flushInterval=50ms`,
fmt.Sprintf(`-remoteWrite.url=%s/api/v1/write`, remoteWriteSrv.URL),
"-remoteWrite.tmpDataPath=" + tc.Dir() + "/vmagent",
}, ``)
// Send request encoded with `zstd`; it fails, gets repacked as `snappy`, and retries successfully.
vmagent.APIV1ImportPrometheus(t, []string{
"foo_bar 1 1652169600000", // 2022-05-10T08:00:00Z
}, apptest.QueryOpts{})
// Send request encoded with `snappy` immediately; it succeeds without retries.
vmagent.APIV1ImportPrometheus(t, []string{
"foo_bar 1 1652169600000", // 2022-05-10T08:00:00Z
}, apptest.QueryOpts{})
tc.Assert(&at.AssertOptions{
Msg: `unexpected content encoding headers sent to remote write server`,
Got: func() any {
return remoteWriteContentEncodings
},
Want: []string{`zstd`, `snappy`, `snappy`},
DoNotRetry: true,
})
expectedRetriesCount := 1
if actualRetriesCount := vmagent.RemoteWriteRequestsRetriesCountTotal(t); actualRetriesCount != expectedRetriesCount {
t.Fatalf("unexpected number of retries; got %d, want %d", actualRetriesCount, expectedRetriesCount)
}
expectedPacketsDroppedTotal := 0
if actualPacketsDroppedCount := vmagent.RemoteWritePacketsDroppedTotal(t); actualPacketsDroppedCount != expectedPacketsDroppedTotal {
t.Fatalf("unexpected number of dropped packets; got %d, want %d", actualPacketsDroppedCount, expectedPacketsDroppedTotal)
}
}

View File

@@ -3,7 +3,6 @@ package apptest
import (
"fmt"
"net/http"
"os"
"regexp"
"strings"
"testing"
@@ -29,9 +28,8 @@ func StartVmagent(instance string, flags []string, cli *Client, promScrapeConfig
app, stderrExtracts, err := startApp(instance, "../../bin/vmagent", flags, &appOptions{
defaultFlags: map[string]string{
"-httpListenAddr": "127.0.0.1:0",
"-promscrape.config": promScrapeConfigFilePath,
"-remoteWrite.tmpDataPath": fmt.Sprintf("%s/%s-%d", os.TempDir(), instance, time.Now().UnixNano()),
"-httpListenAddr": "127.0.0.1:0",
"-promscrape.config": promScrapeConfigFilePath,
},
extractREs: extractREs,
})
@@ -57,48 +55,16 @@ func StartVmagent(instance string, flags []string, cli *Client, promScrapeConfig
// The call is blocked until the data is flushed to vmstorage or the timeout is reached.
//
// See https://docs.victoriametrics.com/url-examples/#apiv1importprometheus
func (app *Vmagent) APIV1ImportPrometheus(t *testing.T, records []string, opts QueryOpts) {
t.Helper()
app.sendBlocking(t, len(records), func() {
app.APIV1ImportPrometheusNoWaitFlush(t, records, opts)
})
}
// APIV1ImportPrometheusNoWaitFlush is a test helper function that inserts a
// collection of records in Prometheus text exposition format for the given
// tenant by sending a HTTP POST request to /api/v1/import/prometheus vmagent endpoint.
//
// The call accepts the records but does not guarantee successful flush to vmstorage.
// Flushing may still be in progress on the function return.
//
// See https://docs.victoriametrics.com/url-examples/#apiv1importprometheus
func (app *Vmagent) APIV1ImportPrometheusNoWaitFlush(t *testing.T, records []string, _ QueryOpts) {
func (app *Vmagent) APIV1ImportPrometheus(t *testing.T, records []string, _ QueryOpts) {
t.Helper()
data := []byte(strings.Join(records, "\n"))
_, statusCode := app.cli.Post(t, app.apiV1ImportPrometheusURL, "text/plain", data)
if statusCode != http.StatusNoContent {
t.Fatalf("unexpected status code: got %d, want %d", statusCode, http.StatusNoContent)
}
}
// RemoteWriteRequestsRetriesCountTotal sums up the total retries for remote write requests.
func (app *Vmagent) RemoteWriteRequestsRetriesCountTotal(t *testing.T) int {
total := 0.0
for _, v := range app.GetMetricsByPrefix(t, "vmagent_remotewrite_retries_count_total") {
total += v
}
return int(total)
}
// RemoteWritePacketsDroppedTotal sums up the total number of dropped remote write packets.
func (app *Vmagent) RemoteWritePacketsDroppedTotal(t *testing.T) int {
total := 0.0
for _, v := range app.GetMetricsByPrefix(t, "vmagent_remotewrite_packets_dropped_total") {
total += v
}
return int(total)
app.sendBlocking(t, len(records), func() {
_, statusCode := app.cli.Post(t, app.apiV1ImportPrometheusURL, "text/plain", data)
if statusCode != http.StatusNoContent {
t.Fatalf("unexpected status code: got %d, want %d", statusCode, http.StatusNoContent)
}
})
}
// sendBlocking sends the data to vmstorage by executing `send` function and

View File

@@ -174,37 +174,6 @@ func (app *Vmselect) MetricNamesStatsReset(t *testing.T, opts QueryOpts) {
}
}
// APIV1StatusTSDB sends a query to a /prometheus/api/v1/status/tsdb
// //
// See https://docs.victoriametrics.com/#tsdb-stats
func (app *Vmselect) APIV1StatusTSDB(t *testing.T, matchQuery string, date string, topN string, opts QueryOpts) TSDBStatusResponse {
t.Helper()
seriesURL := fmt.Sprintf("http://%s/select/%s/prometheus/api/v1/status/tsdb", app.httpListenAddr, opts.getTenant())
values := opts.asURLValues()
addNonEmpty := func(name, value string) {
if len(value) == 0 {
return
}
values.Add(name, value)
}
addNonEmpty("match[]", matchQuery)
addNonEmpty("topN", topN)
addNonEmpty("date", date)
res, statusCode := app.cli.PostForm(t, seriesURL, values)
if statusCode != http.StatusOK {
t.Fatalf("unexpected status code: got %d, want %d, resp text=%q", statusCode, http.StatusOK, res)
}
var status TSDBStatusResponse
if err := json.Unmarshal([]byte(res), &status); err != nil {
t.Fatalf("could not unmarshal tsdb status response data:\n%s\n err: %v", res, err)
}
status.Sort()
return status
}
// String returns the string representation of the vmselect app state.
func (app *Vmselect) String() string {
return fmt.Sprintf("{app: %s httpListenAddr: %q}", app.app, app.httpListenAddr)

View File

@@ -350,37 +350,6 @@ func (app *Vmsingle) SnapshotDeleteAll(t *testing.T) *SnapshotDeleteAllResponse
return &res
}
// APIV1StatusTSDB sends a query to a /prometheus/api/v1/status/tsdb
// //
// See https://docs.victoriametrics.com/#tsdb-stats
func (app *Vmsingle) APIV1StatusTSDB(t *testing.T, matchQuery string, date string, topN string, opts QueryOpts) TSDBStatusResponse {
t.Helper()
seriesURL := fmt.Sprintf("http://%s/prometheus/api/v1/status/tsdb", app.httpListenAddr)
values := opts.asURLValues()
addNonEmpty := func(name, value string) {
if len(value) == 0 {
return
}
values.Add(name, value)
}
addNonEmpty("match[]", matchQuery)
addNonEmpty("topN", topN)
addNonEmpty("date", date)
res, statusCode := app.cli.PostForm(t, seriesURL, values)
if statusCode != http.StatusOK {
t.Fatalf("unexpected status code: got %d, want %d, resp text=%q", statusCode, http.StatusOK, res)
}
var status TSDBStatusResponse
if err := json.Unmarshal([]byte(res), &status); err != nil {
t.Fatalf("could not unmarshal tsdb status response data:\n%s\n err: %v", res, err)
}
status.Sort()
return status
}
// HTTPAddr returns the address at which the vmstorage process is listening
// for http connections.
func (app *Vmsingle) HTTPAddr() string {

View File

@@ -19,4 +19,3 @@ dashboards-sync:
SRC=backupmanager.json D_UID=gF-lxRdVz TITLE="VictoriaMetrics - backupmanager" $(MAKE) dashboard-copy
SRC=clusterbytenant.json D_UID=IZFqd3lMz TITLE="VictoriaMetrics Cluster Per Tenant Statistic" $(MAKE) dashboard-copy
SRC=victorialogs.json D_UID=OqPIZTX4z TITLE="VictoriaLogs" $(MAKE) dashboard-copy
SRC=victorialogs-cluster.json D_UID=XqCOFEX4z TITLE="VictoriaLogs - cluster" $(MAKE) dashboard-copy

View File

@@ -1,4 +1,38 @@
{
"__inputs": [],
"__elements": {},
"__requires": [
{
"type": "grafana",
"id": "grafana",
"name": "Grafana",
"version": "10.4.0"
},
{
"type": "panel",
"id": "piechart",
"name": "Pie chart",
"version": ""
},
{
"type": "datasource",
"id": "prometheus",
"name": "Prometheus",
"version": "1.0.0"
},
{
"type": "panel",
"id": "table",
"name": "Table",
"version": ""
},
{
"type": "panel",
"id": "timeseries",
"name": "Time series",
"version": ""
}
],
"annotations": {
"list": [
{
@@ -24,9 +58,11 @@
"description": "Overview for enterprise cluster VictoriaMetrics v1.56.0 or higher",
"editable": true,
"fiscalYearStartMonth": 0,
"gnetId": 16399,
"graphTooltip": 1,
"id": 3,
"id": null,
"links": [],
"liveNow": false,
"panels": [
{
"collapsed": false,
@@ -59,7 +95,6 @@
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
@@ -91,7 +126,8 @@
"mode": "absolute",
"steps": [
{
"color": "green"
"color": "green",
"value": null
},
{
"color": "red",
@@ -123,12 +159,11 @@
"sortDesc": true
},
"tooltip": {
"hideZeros": false,
"mode": "multi",
"sort": "desc"
}
},
"pluginVersion": "11.6.0",
"pluginVersion": "9.3.11",
"targets": [
{
"datasource": {
@@ -164,7 +199,6 @@
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
@@ -196,7 +230,8 @@
"mode": "absolute",
"steps": [
{
"color": "green"
"color": "green",
"value": null
},
{
"color": "red",
@@ -228,12 +263,11 @@
"sortDesc": true
},
"tooltip": {
"hideZeros": false,
"mode": "multi",
"sort": "desc"
}
},
"pluginVersion": "11.6.0",
"pluginVersion": "9.3.11",
"targets": [
{
"datasource": {
@@ -271,7 +305,6 @@
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
@@ -303,7 +336,8 @@
"mode": "absolute",
"steps": [
{
"color": "green"
"color": "green",
"value": null
},
{
"color": "red",
@@ -342,12 +376,11 @@
"sortDesc": true
},
"tooltip": {
"hideZeros": false,
"mode": "multi",
"sort": "desc"
}
},
"pluginVersion": "11.6.0",
"pluginVersion": "9.3.11",
"targets": [
{
"datasource": {
@@ -385,7 +418,6 @@
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
@@ -417,7 +449,8 @@
"mode": "absolute",
"steps": [
{
"color": "green"
"color": "green",
"value": null
},
{
"color": "red",
@@ -449,12 +482,11 @@
"sortDesc": true
},
"tooltip": {
"hideZeros": false,
"mode": "multi",
"sort": "desc"
}
},
"pluginVersion": "11.6.0",
"pluginVersion": "9.3.11",
"targets": [
{
"datasource": {
@@ -492,7 +524,6 @@
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
@@ -524,7 +555,8 @@
"mode": "absolute",
"steps": [
{
"color": "green"
"color": "green",
"value": null
},
{
"color": "red",
@@ -555,12 +587,11 @@
"sortDesc": true
},
"tooltip": {
"hideZeros": false,
"mode": "multi",
"sort": "desc"
}
},
"pluginVersion": "11.6.0",
"pluginVersion": "9.3.11",
"targets": [
{
"datasource": {
@@ -598,7 +629,6 @@
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
@@ -630,7 +660,8 @@
"mode": "absolute",
"steps": [
{
"color": "green"
"color": "green",
"value": null
},
{
"color": "red",
@@ -662,12 +693,11 @@
"sortDesc": true
},
"tooltip": {
"hideZeros": false,
"mode": "multi",
"sort": "desc"
}
},
"pluginVersion": "11.6.0",
"pluginVersion": "9.3.11",
"targets": [
{
"datasource": {
@@ -685,118 +715,13 @@
"title": "New series over 24h ",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "$ds"
},
"description": "The higher churn rate is, the more resources required to handle it. Consider to keep the churn rate as low as possible.\n\nTo investigate stats about most expensive series use `api/v1/status/tsdb` handler. More details here https://docs.victoriametrics.com/cluster-victoriametrics/#url-format\n\nGood references to read:\n* https://www.robustperception.io/cardinality-is-key\n* https://valyala.medium.com/high-cardinality-tsdb-benchmarks-victoriametrics-vs-timescaledb-vs-influxdb-13e6ee64dd6b",
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "never",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"links": [],
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green"
},
{
"color": "red",
"value": 80
}
]
},
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 25
},
"id": 34,
"options": {
"legend": {
"calcs": [
"mean",
"lastNotNull"
],
"displayMode": "table",
"placement": "bottom",
"showLegend": true,
"sortBy": "Last *",
"sortDesc": true
},
"tooltip": {
"hideZeros": false,
"mode": "multi",
"sort": "desc"
}
},
"pluginVersion": "11.6.0",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "$ds"
},
"editorMode": "code",
"expr": "sum(rate(vm_tenant_timeseries_created_total{accountID=~\"$account\", projectID=~\"$project\"}[$__rate_interval])) by(accountID,projectID)",
"interval": "",
"legendFormat": "{{accountID}}:{{projectID}}",
"range": true,
"refId": "A"
}
],
"title": "Instant Churn Rate",
"type": "timeseries"
},
{
"collapsed": false,
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 33
"y": 25
},
"id": 14,
"panels": [],
@@ -829,7 +754,7 @@
"h": 14,
"w": 4,
"x": 0,
"y": 34
"y": 26
},
"id": 16,
"options": {
@@ -851,12 +776,11 @@
"values": false
},
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "11.6.0",
"pluginVersion": "9.3.11",
"targets": [
{
"datasource": {
@@ -899,7 +823,7 @@
"h": 14,
"w": 4,
"x": 4,
"y": 34
"y": 26
},
"id": 18,
"options": {
@@ -921,12 +845,10 @@
"values": false
},
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "11.6.0",
"targets": [
{
"datasource": {
@@ -969,7 +891,7 @@
"h": 14,
"w": 4,
"x": 8,
"y": 34
"y": 26
},
"id": 32,
"options": {
@@ -991,12 +913,10 @@
"values": false
},
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "11.6.0",
"targets": [
{
"datasource": {
@@ -1039,7 +959,7 @@
"h": 14,
"w": 4,
"x": 12,
"y": 34
"y": 26
},
"id": 21,
"options": {
@@ -1063,12 +983,10 @@
"values": false
},
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "11.6.0",
"targets": [
{
"datasource": {
@@ -1111,7 +1029,7 @@
"h": 14,
"w": 4,
"x": 16,
"y": 34
"y": 26
},
"id": 22,
"options": {
@@ -1133,12 +1051,10 @@
"values": false
},
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "11.6.0",
"targets": [
{
"datasource": {
@@ -1181,7 +1097,7 @@
"h": 14,
"w": 4,
"x": 20,
"y": 34
"y": 26
},
"id": 24,
"options": {
@@ -1203,12 +1119,10 @@
"values": false
},
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "11.6.0",
"targets": [
{
"datasource": {
@@ -1247,7 +1161,8 @@
"mode": "absolute",
"steps": [
{
"color": "green"
"color": "green",
"value": null
},
{
"color": "red",
@@ -1300,7 +1215,7 @@
"h": 8,
"w": 4,
"x": 0,
"y": 48
"y": 40
},
"id": 25,
"options": {
@@ -1322,7 +1237,7 @@
}
]
},
"pluginVersion": "11.6.0",
"pluginVersion": "10.4.0",
"targets": [
{
"datasource": {
@@ -1364,7 +1279,8 @@
"mode": "absolute",
"steps": [
{
"color": "green"
"color": "green",
"value": null
},
{
"color": "red",
@@ -1417,7 +1333,7 @@
"h": 8,
"w": 4,
"x": 4,
"y": 48
"y": 40
},
"id": 26,
"options": {
@@ -1439,7 +1355,7 @@
}
]
},
"pluginVersion": "11.6.0",
"pluginVersion": "10.4.0",
"targets": [
{
"datasource": {
@@ -1481,7 +1397,8 @@
"mode": "absolute",
"steps": [
{
"color": "green"
"color": "green",
"value": null
},
{
"color": "red",
@@ -1534,7 +1451,7 @@
"h": 8,
"w": 4,
"x": 8,
"y": 48
"y": 40
},
"id": 33,
"options": {
@@ -1556,7 +1473,7 @@
}
]
},
"pluginVersion": "11.6.0",
"pluginVersion": "10.4.0",
"targets": [
{
"datasource": {
@@ -1598,7 +1515,8 @@
"mode": "absolute",
"steps": [
{
"color": "green"
"color": "green",
"value": null
},
{
"color": "red",
@@ -1651,7 +1569,7 @@
"h": 8,
"w": 4,
"x": 12,
"y": 48
"y": 40
},
"id": 27,
"options": {
@@ -1673,7 +1591,7 @@
}
]
},
"pluginVersion": "11.6.0",
"pluginVersion": "10.4.0",
"targets": [
{
"datasource": {
@@ -1715,7 +1633,8 @@
"mode": "absolute",
"steps": [
{
"color": "green"
"color": "green",
"value": null
},
{
"color": "red",
@@ -1768,7 +1687,7 @@
"h": 8,
"w": 4,
"x": 16,
"y": 48
"y": 40
},
"id": 28,
"options": {
@@ -1790,7 +1709,7 @@
}
]
},
"pluginVersion": "11.6.0",
"pluginVersion": "10.4.0",
"targets": [
{
"datasource": {
@@ -1832,7 +1751,8 @@
"mode": "absolute",
"steps": [
{
"color": "green"
"color": "green",
"value": null
},
{
"color": "red",
@@ -1885,7 +1805,7 @@
"h": 8,
"w": 4,
"x": 20,
"y": 48
"y": 40
},
"id": 30,
"options": {
@@ -1907,7 +1827,7 @@
}
]
},
"pluginVersion": "11.6.0",
"pluginVersion": "10.4.0",
"targets": [
{
"datasource": {
@@ -1928,9 +1848,8 @@
"type": "table"
}
],
"preload": false,
"refresh": false,
"schemaVersion": 41,
"schemaVersion": 39,
"tags": [
"victoriametrics"
],
@@ -1938,15 +1857,20 @@
"list": [
{
"current": {
"selected": false,
"text": "cluster-monitoring",
"value": "cluster-monitoring"
},
"hide": 0,
"includeAll": false,
"multi": false,
"name": "ds",
"options": [],
"query": "prometheus",
"queryValue": "",
"refresh": 1,
"regex": "",
"skipUrlSync": false,
"type": "datasource"
},
{
@@ -1957,6 +1881,7 @@
"uid": "$ds"
},
"definition": "label_values(vm_tenant_active_timeseries, accountID)",
"hide": 0,
"includeAll": true,
"multi": true,
"name": "account",
@@ -1967,7 +1892,12 @@
},
"refresh": 1,
"regex": "",
"type": "query"
"skipUrlSync": false,
"sort": 0,
"tagValuesQuery": "",
"tagsQuery": "",
"type": "query",
"useTags": false
},
{
"allValue": ".*",
@@ -1977,6 +1907,7 @@
"uid": "$ds"
},
"definition": "label_values(vm_tenant_active_timeseries{accountID=~\"$accountID\"},projectID)",
"hide": 0,
"includeAll": true,
"multi": true,
"name": "project",
@@ -1987,16 +1918,22 @@
},
"refresh": 1,
"regex": "",
"type": "query"
"skipUrlSync": false,
"sort": 0,
"tagValuesQuery": "",
"tagsQuery": "",
"type": "query",
"useTags": false
},
{
"baseFilters": [],
"datasource": {
"type": "prometheus",
"uid": "PE8D8DB4BEE4E4B22"
},
"filters": [],
"hide": 0,
"name": "adhoc",
"skipUrlSync": false,
"type": "adhoc"
}
]
@@ -2009,5 +1946,6 @@
"timezone": "",
"title": "VictoriaMetrics Cluster Per Tenant Statistic",
"uid": "IZFqd3lMz",
"version": 8
"version": 1,
"weekStart": ""
}

File diff suppressed because it is too large Load Diff

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