mirror of
https://github.com/VictoriaMetrics/VictoriaMetrics.git
synced 2026-06-26 03:57:43 +03:00
Compare commits
2 Commits
weakpointe
...
test-tmp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d7d17d192 | ||
|
|
0a8b4281e5 |
10
Makefile
10
Makefile
@@ -513,19 +513,19 @@ check-all: fmt vet golangci-lint govulncheck
|
|||||||
clean-checkers: remove-golangci-lint remove-govulncheck
|
clean-checkers: remove-golangci-lint remove-govulncheck
|
||||||
|
|
||||||
test:
|
test:
|
||||||
DISABLE_FSYNC_FOR_TESTING=1 go test ./lib/... ./app/...
|
go test ./lib/... ./app/...
|
||||||
|
|
||||||
test-race:
|
test-race:
|
||||||
DISABLE_FSYNC_FOR_TESTING=1 go test -race ./lib/... ./app/...
|
go test -race ./lib/... ./app/...
|
||||||
|
|
||||||
test-pure:
|
test-pure:
|
||||||
DISABLE_FSYNC_FOR_TESTING=1 CGO_ENABLED=0 go test ./lib/... ./app/...
|
CGO_ENABLED=0 go test ./lib/... ./app/...
|
||||||
|
|
||||||
test-full:
|
test-full:
|
||||||
DISABLE_FSYNC_FOR_TESTING=1 go test -coverprofile=coverage.txt -covermode=atomic ./lib/... ./app/...
|
go test -coverprofile=coverage.txt -covermode=atomic ./lib/... ./app/...
|
||||||
|
|
||||||
test-full-386:
|
test-full-386:
|
||||||
DISABLE_FSYNC_FOR_TESTING=1 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
|
integration-test: victoria-metrics vmagent vmalert vmauth
|
||||||
go test ./apptest/... -skip="^TestCluster.*"
|
go test ./apptest/... -skip="^TestCluster.*"
|
||||||
|
|||||||
@@ -199,8 +199,8 @@ func (lmp *logMessageProcessor) AddRow(timestamp int64, fields, streamFields []l
|
|||||||
lmp.bytesIngestedTotal.Add(n)
|
lmp.bytesIngestedTotal.Add(n)
|
||||||
|
|
||||||
if len(fields) > *MaxFieldsPerLine {
|
if len(fields) > *MaxFieldsPerLine {
|
||||||
rf := logstorage.RowFormatter(fields)
|
line := logstorage.MarshalFieldsToJSON(nil, fields)
|
||||||
logger.Warnf("dropping log line with %d fields; it exceeds -insert.maxFieldsPerLine=%d; %s", len(fields), *MaxFieldsPerLine, rf)
|
logger.Warnf("dropping log line with %d fields; it exceeds -insert.maxFieldsPerLine=%d; %s", len(fields), *MaxFieldsPerLine, line)
|
||||||
rowsDroppedTotalTooManyFields.Inc()
|
rowsDroppedTotalTooManyFields.Inc()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ import (
|
|||||||
|
|
||||||
var (
|
var (
|
||||||
// MaxLineSizeBytes is the maximum length of a single line for /insert/* handlers
|
// MaxLineSizeBytes is the maximum length of a single line for /insert/* handlers
|
||||||
MaxLineSizeBytes = flagutil.NewBytes("insert.maxLineSizeBytes", 256*1024, "The maximum size of a single line, which can be read by /insert/* handlers")
|
MaxLineSizeBytes = flagutil.NewBytes("insert.maxLineSizeBytes", 256*1024, "The maximum size of a single line, which can be read by /insert/* handlers; "+
|
||||||
|
"see https://docs.victoriametrics.com/victorialogs/faq/#what-length-a-log-record-is-expected-to-have")
|
||||||
|
|
||||||
// MaxFieldsPerLine is the maximum number of fields per line for /insert/* handlers
|
// MaxFieldsPerLine is the maximum number of fields per line for /insert/* handlers
|
||||||
MaxFieldsPerLine = flag.Int("insert.maxFieldsPerLine", 1000, "The maximum number of log fields per line, which can be read by /insert/* handlers")
|
MaxFieldsPerLine = flag.Int("insert.maxFieldsPerLine", 1000, "The maximum number of log fields per line, which can be read by /insert/* handlers; "+
|
||||||
|
"see https://docs.victoriametrics.com/victorialogs/faq/#how-many-fields-a-single-log-entry-may-contain")
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,20 +2,19 @@ package insertutils
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/VictoriaMetrics/VictoriaMetrics/lib/logstorage"
|
"github.com/VictoriaMetrics/VictoriaMetrics/lib/logstorage"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ExtractTimestampRFC3339NanoFromFields extracts RFC3339 timestamp in nanoseconds from the field with the name timeField at fields.
|
// ExtractTimestampFromFields extracts timestamp in nanoseconds from the field with the name timeField at fields.
|
||||||
//
|
//
|
||||||
// The value for the timeField is set to empty string after returning from the function,
|
// The value for the timeField is set to empty string after returning from the function,
|
||||||
// so it could be ignored during data ingestion.
|
// so it could be ignored during data ingestion.
|
||||||
//
|
//
|
||||||
// The current timestamp is returned if fields do not contain a field with timeField name or if the timeField value is empty.
|
// The current timestamp is returned if fields do not contain a field with timeField name or if the timeField value is empty.
|
||||||
func ExtractTimestampRFC3339NanoFromFields(timeField string, fields []logstorage.Field) (int64, error) {
|
func ExtractTimestampFromFields(timeField string, fields []logstorage.Field) (int64, error) {
|
||||||
for i := range fields {
|
for i := range fields {
|
||||||
f := &fields[i]
|
f := &fields[i]
|
||||||
if f.Name != timeField {
|
if f.Name != timeField {
|
||||||
@@ -48,22 +47,24 @@ func parseTimestamp(s string) (int64, error) {
|
|||||||
return nsecs, nil
|
return nsecs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseUnixTimestamp parses s as unix timestamp in either seconds or milliseconds and returns the parsed timestamp in nanoseconds.
|
// ParseUnixTimestamp parses s as unix timestamp in seconds, milliseconds, microseconds or nanoseconds and returns the parsed timestamp in nanoseconds.
|
||||||
func ParseUnixTimestamp(s string) (int64, error) {
|
func ParseUnixTimestamp(s string) (int64, error) {
|
||||||
n, err := strconv.ParseInt(s, 10, 64)
|
n, err := strconv.ParseInt(s, 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("cannot parse unix timestamp from %q: %w", s, err)
|
return 0, fmt.Errorf("cannot parse unix timestamp from %q: %w", s, err)
|
||||||
}
|
}
|
||||||
if n < (1<<31) && n >= (-1<<31) {
|
if n < (1<<31) && n >= (-1<<31) {
|
||||||
// The timestamp is in seconds. Convert it to milliseconds
|
// The timestamp is in seconds.
|
||||||
n *= 1e3
|
return n * 1e9, nil
|
||||||
}
|
}
|
||||||
if n > int64(math.MaxInt64)/1e6 {
|
if n < 1e3*(1<<31) && n >= 1e3*(-1<<31) {
|
||||||
return 0, fmt.Errorf("too big timestamp in milliseconds: %d; mustn't exceed %d", n, int64(math.MaxInt64)/1e6)
|
// The timestamp is in milliseconds.
|
||||||
|
return n * 1e6, nil
|
||||||
}
|
}
|
||||||
if n < int64(math.MinInt64)/1e6 {
|
if n < 1e6*(1<<31) && n >= 1e6*(-1<<31) {
|
||||||
return 0, fmt.Errorf("too small timestamp in milliseconds: %d; must be bigger than %d", n, int64(math.MinInt64)/1e6)
|
// The timestamp is in microseconds.
|
||||||
|
return n * 1e3, nil
|
||||||
}
|
}
|
||||||
n *= 1e6
|
// The timestamp is in nanoseconds
|
||||||
return n, nil
|
return n, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,11 +6,11 @@ import (
|
|||||||
"github.com/VictoriaMetrics/VictoriaMetrics/lib/logstorage"
|
"github.com/VictoriaMetrics/VictoriaMetrics/lib/logstorage"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestExtractTimestampRFC3339NanoFromFields_Success(t *testing.T) {
|
func TestExtractTimestampFromFields_Success(t *testing.T) {
|
||||||
f := func(timeField string, fields []logstorage.Field, nsecsExpected int64) {
|
f := func(timeField string, fields []logstorage.Field, nsecsExpected int64) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
nsecs, err := ExtractTimestampRFC3339NanoFromFields(timeField, fields)
|
nsecs, err := ExtractTimestampFromFields(timeField, fields)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %s", err)
|
t.Fatalf("unexpected error: %s", err)
|
||||||
}
|
}
|
||||||
@@ -51,6 +51,18 @@ func TestExtractTimestampRFC3339NanoFromFields_Success(t *testing.T) {
|
|||||||
{Name: "foo", Value: "bar"},
|
{Name: "foo", Value: "bar"},
|
||||||
}, 1718773640123456789)
|
}, 1718773640123456789)
|
||||||
|
|
||||||
|
// Unix timestamp in nanoseconds
|
||||||
|
f("time", []logstorage.Field{
|
||||||
|
{Name: "foo", Value: "bar"},
|
||||||
|
{Name: "time", Value: "1718773640123456789"},
|
||||||
|
}, 1718773640123456789)
|
||||||
|
|
||||||
|
// Unix timestamp in microseconds
|
||||||
|
f("time", []logstorage.Field{
|
||||||
|
{Name: "foo", Value: "bar"},
|
||||||
|
{Name: "time", Value: "1718773640123456"},
|
||||||
|
}, 1718773640123456000)
|
||||||
|
|
||||||
// Unix timestamp in milliseconds
|
// Unix timestamp in milliseconds
|
||||||
f("time", []logstorage.Field{
|
f("time", []logstorage.Field{
|
||||||
{Name: "foo", Value: "bar"},
|
{Name: "foo", Value: "bar"},
|
||||||
@@ -64,14 +76,14 @@ func TestExtractTimestampRFC3339NanoFromFields_Success(t *testing.T) {
|
|||||||
}, 1718773640000000000)
|
}, 1718773640000000000)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExtractTimestampRFC3339NanoFromFields_Error(t *testing.T) {
|
func TestExtractTimestampFromFields_Error(t *testing.T) {
|
||||||
f := func(s string) {
|
f := func(s string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
fields := []logstorage.Field{
|
fields := []logstorage.Field{
|
||||||
{Name: "time", Value: s},
|
{Name: "time", Value: s},
|
||||||
}
|
}
|
||||||
nsecs, err := ExtractTimestampRFC3339NanoFromFields("time", fields)
|
nsecs, err := ExtractTimestampFromFields("time", fields)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatalf("expecting non-nil error")
|
t.Fatalf("expecting non-nil error")
|
||||||
}
|
}
|
||||||
@@ -80,6 +92,7 @@ func TestExtractTimestampRFC3339NanoFromFields_Error(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// invalid time
|
||||||
f("foobar")
|
f("foobar")
|
||||||
|
|
||||||
// incomplete time
|
// incomplete time
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ func readLine(lr *insertutils.LineReader, timeField string, msgFields []string,
|
|||||||
if err := p.ParseLogMessage(line); err != nil {
|
if err := p.ParseLogMessage(line); err != nil {
|
||||||
return false, fmt.Errorf("cannot parse json-encoded log entry: %w", err)
|
return false, fmt.Errorf("cannot parse json-encoded log entry: %w", err)
|
||||||
}
|
}
|
||||||
ts, err := insertutils.ExtractTimestampRFC3339NanoFromFields(timeField, p.Fields)
|
ts, err := insertutils.ExtractTimestampFromFields(timeField, p.Fields)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, fmt.Errorf("cannot get timestamp: %w", err)
|
return false, fmt.Errorf("cannot get timestamp: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -560,7 +560,7 @@ func processLine(line []byte, currentYear int, timezone *time.Location, useLocal
|
|||||||
if useLocalTimestamp {
|
if useLocalTimestamp {
|
||||||
ts = time.Now().UnixNano()
|
ts = time.Now().UnixNano()
|
||||||
} else {
|
} else {
|
||||||
nsecs, err := insertutils.ExtractTimestampRFC3339NanoFromFields("timestamp", p.Fields)
|
nsecs, err := insertutils.ExtractTimestampFromFields("timestamp", p.Fields)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("cannot get timestamp from syslog line %q: %w", line, err)
|
return fmt.Errorf("cannot get timestamp from syslog line %q: %w", line, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ func writeCompactObject(w io.Writer, fields []logstorage.Field) error {
|
|||||||
_, err := fmt.Fprintf(w, "%s\n", fields[0].Value)
|
_, err := fmt.Fprintf(w, "%s\n", fields[0].Value)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if len(fields) == 2 && fields[0].Name == "_time" || fields[1].Name == "_time" {
|
if len(fields) == 2 && (fields[0].Name == "_time" || fields[1].Name == "_time") {
|
||||||
// Write _time\tfieldValue as is
|
// Write _time\tfieldValue as is
|
||||||
if fields[0].Name == "_time" {
|
if fields[0].Name == "_time" {
|
||||||
_, err := fmt.Fprintf(w, "%s\t%s\n", fields[0].Value, fields[1].Value)
|
_, err := fmt.Fprintf(w, "%s\t%s\n", fields[0].Value, fields[1].Value)
|
||||||
|
|||||||
@@ -270,7 +270,7 @@ func printCommandsHelp(w io.Writer) {
|
|||||||
\h - show this help
|
\h - show this help
|
||||||
\s - singleline json output mode
|
\s - singleline json output mode
|
||||||
\m - multiline json output mode
|
\m - multiline json output mode
|
||||||
\c - compact output
|
\c - compact output mode
|
||||||
\logfmt - logfmt output mode
|
\logfmt - logfmt output mode
|
||||||
\wrap_long_lines - toggles wrapping long lines
|
\wrap_long_lines - toggles wrapping long lines
|
||||||
\tail <query> - live tail <query> results
|
\tail <query> - live tail <query> results
|
||||||
|
|||||||
@@ -688,13 +688,13 @@ func ProcessStatsQueryRangeRequest(ctx context.Context, w http.ResponseWriter, r
|
|||||||
m := make(map[string]*statsSeries)
|
m := make(map[string]*statsSeries)
|
||||||
var mLock sync.Mutex
|
var mLock sync.Mutex
|
||||||
|
|
||||||
|
timestamp := q.GetTimestamp()
|
||||||
writeBlock := func(_ uint, timestamps []int64, columns []logstorage.BlockColumn) {
|
writeBlock := func(_ uint, timestamps []int64, columns []logstorage.BlockColumn) {
|
||||||
clonedColumnNames := make([]string, len(columns))
|
clonedColumnNames := make([]string, len(columns))
|
||||||
for i, c := range columns {
|
for i, c := range columns {
|
||||||
clonedColumnNames[i] = strings.Clone(c.Name)
|
clonedColumnNames[i] = strings.Clone(c.Name)
|
||||||
}
|
}
|
||||||
for i := range timestamps {
|
for i := range timestamps {
|
||||||
timestamp := q.GetTimestamp()
|
|
||||||
labels := make([]logstorage.Field, 0, len(byFields))
|
labels := make([]logstorage.Field, 0, len(byFields))
|
||||||
for j, c := range columns {
|
for j, c := range columns {
|
||||||
if c.Name == "_time" {
|
if c.Name == "_time" {
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ func TestParseExtraFilters_Success(t *testing.T) {
|
|||||||
// LogsQL filter
|
// LogsQL filter
|
||||||
f(`foobar`, `foobar`)
|
f(`foobar`, `foobar`)
|
||||||
f(`foo:bar`, `foo:bar`)
|
f(`foo:bar`, `foo:bar`)
|
||||||
f(`foo:(bar or baz) error _time:5m {"foo"=bar,baz="z"}`, `(foo:bar or foo:baz) error _time:5m {foo="bar",baz="z"}`)
|
f(`foo:(bar or baz) error _time:5m {"foo"=bar,baz="z"}`, `{foo="bar",baz="z"} (foo:bar or foo:baz) error _time:5m`)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseExtraFilters_Failure(t *testing.T) {
|
func TestParseExtraFilters_Failure(t *testing.T) {
|
||||||
@@ -77,7 +77,7 @@ func TestParseExtraStreamFilters_Success(t *testing.T) {
|
|||||||
// LogsQL filter
|
// LogsQL filter
|
||||||
f(`foobar`, `foobar`)
|
f(`foobar`, `foobar`)
|
||||||
f(`foo:bar`, `foo:bar`)
|
f(`foo:bar`, `foo:bar`)
|
||||||
f(`foo:(bar or baz) error _time:5m {"foo"=bar,baz="z"}`, `(foo:bar or foo:baz) error _time:5m {foo="bar",baz="z"}`)
|
f(`foo:(bar or baz) error _time:5m {"foo"=bar,baz="z"}`, `{foo="bar",baz="z"} (foo:bar or foo:baz) error _time:5m`)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseExtraStreamFilters_Failure(t *testing.T) {
|
func TestParseExtraStreamFilters_Failure(t *testing.T) {
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"files": {
|
"files": {
|
||||||
"main.css": "./static/css/main.4aacd559.css",
|
"main.css": "./static/css/main.02a1c6cb.css",
|
||||||
"main.js": "./static/js/main.5ce54a05.js",
|
"main.js": "./static/js/main.55c8060b.js",
|
||||||
"static/js/685.f772060c.chunk.js": "./static/js/685.f772060c.chunk.js",
|
"static/js/685.f772060c.chunk.js": "./static/js/685.f772060c.chunk.js",
|
||||||
"index.html": "./index.html"
|
"index.html": "./index.html"
|
||||||
},
|
},
|
||||||
"entrypoints": [
|
"entrypoints": [
|
||||||
"static/css/main.4aacd559.css",
|
"static/css/main.02a1c6cb.css",
|
||||||
"static/js/main.5ce54a05.js"
|
"static/js/main.55c8060b.js"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1 +1 @@
|
|||||||
<!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="icon" href="./favicon.svg"/><link rel="apple-touch-icon" href="./favicon.svg"/><link rel="mask-icon" href="./favicon.svg" color="#000000"><meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=5"/><meta name="theme-color" content="#000000"/><meta name="description" content="Explore your log data with VictoriaLogs UI"/><link rel="manifest" href="./manifest.json"/><title>UI for VictoriaLogs</title><meta name="twitter:card" content="summary"><meta name="twitter:title" content="UI for VictoriaLogs"><meta name="twitter:site" content="@https://victoriametrics.com/products/victorialogs/"><meta name="twitter:description" content="Explore your log data with VictoriaLogs UI"><meta name="twitter:image" content="./preview.jpg"><meta property="og:type" content="website"><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 defer="defer" src="./static/js/main.5ce54a05.js"></script><link href="./static/css/main.4aacd559.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>
|
<!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="icon" href="./favicon.svg"/><link rel="apple-touch-icon" href="./favicon.svg"/><link rel="mask-icon" href="./favicon.svg" color="#000000"><meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=5"/><meta name="theme-color" content="#000000"/><meta name="description" content="Explore your log data with VictoriaLogs UI"/><link rel="manifest" href="./manifest.json"/><title>UI for VictoriaLogs</title><meta name="twitter:card" content="summary"><meta name="twitter:title" content="UI for VictoriaLogs"><meta name="twitter:site" content="@https://victoriametrics.com/products/victorialogs/"><meta name="twitter:description" content="Explore your log data with VictoriaLogs UI"><meta name="twitter:image" content="./preview.jpg"><meta property="og:type" content="website"><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 defer="defer" src="./static/js/main.55c8060b.js"></script><link href="./static/css/main.02a1c6cb.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>
|
||||||
1
app/vlselect/vmui/static/css/main.02a1c6cb.css
Normal file
1
app/vlselect/vmui/static/css/main.02a1c6cb.css
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
2
app/vlselect/vmui/static/js/main.55c8060b.js
Normal file
2
app/vlselect/vmui/static/js/main.55c8060b.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -36,7 +36,7 @@ var (
|
|||||||
|
|
||||||
maxQueueSize = flag.Int("remoteWrite.maxQueueSize", defaultMaxQueueSize, "Defines the max number of pending datapoints to remote write endpoint")
|
maxQueueSize = flag.Int("remoteWrite.maxQueueSize", defaultMaxQueueSize, "Defines the max number of pending datapoints to remote write endpoint")
|
||||||
maxBatchSize = flag.Int("remoteWrite.maxBatchSize", defaultMaxBatchSize, "Defines max number of timeseries to be flushed at once")
|
maxBatchSize = flag.Int("remoteWrite.maxBatchSize", defaultMaxBatchSize, "Defines max number of timeseries to be flushed at once")
|
||||||
concurrency = flag.Int("remoteWrite.concurrency", defaultConcurrency, "Defines number of writers for concurrent writing into remote write endpoint")
|
concurrency = flag.Int("remoteWrite.concurrency", defaultConcurrency, "Defines number of writers for concurrent writing into remote write endpoint. Default value depends on the number of available CPU cores.")
|
||||||
flushInterval = flag.Duration("remoteWrite.flushInterval", defaultFlushInterval, "Defines interval of flushes to remote write endpoint")
|
flushInterval = flag.Duration("remoteWrite.flushInterval", defaultFlushInterval, "Defines interval of flushes to remote write endpoint")
|
||||||
|
|
||||||
tlsInsecureSkipVerify = flag.Bool("remoteWrite.tlsInsecureSkipVerify", false, "Whether to skip tls verification when connecting to -remoteWrite.url")
|
tlsInsecureSkipVerify = flag.Bool("remoteWrite.tlsInsecureSkipVerify", false, "Whether to skip tls verification when connecting to -remoteWrite.url")
|
||||||
|
|||||||
@@ -31,7 +31,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
httpListenAddrs = flagutil.NewArrayString("httpListenAddr", "TCP address to listen for incoming http requests. See also -tls and -httpListenAddr.useProxyProtocol")
|
httpListenAddrs = flagutil.NewArrayString("httpListenAddr", "TCP address to listen for incoming http requests. "+
|
||||||
|
"By default, serves internal API and proxy requests. "+
|
||||||
|
" See also -tls, -httpListenAddr.useProxyProtocol and -httpInternalListenAddr.")
|
||||||
|
httpInternalListenAddr = flagutil.NewArrayString("httpInternalListenAddr", "TCP address to listen for incoming internal API http requests. Such as /health, /-/reload, /debug/pprof, etc. "+
|
||||||
|
"If flag is set, vmauth no longer serves internal API at -httpListenAddr.")
|
||||||
useProxyProtocol = flagutil.NewArrayBool("httpListenAddr.useProxyProtocol", "Whether to use proxy protocol for connections accepted at the corresponding -httpListenAddr . "+
|
useProxyProtocol = flagutil.NewArrayBool("httpListenAddr.useProxyProtocol", "Whether to use proxy protocol for connections accepted at the corresponding -httpListenAddr . "+
|
||||||
"See https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt . "+
|
"See https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt . "+
|
||||||
"With enabled proxy protocol http server cannot serve regular /metrics endpoint. Use -pushmetrics.url for metrics pushing")
|
"With enabled proxy protocol http server cannot serve regular /metrics endpoint. Use -pushmetrics.url for metrics pushing")
|
||||||
@@ -91,7 +95,21 @@ func main() {
|
|||||||
logger.Infof("starting vmauth at %q...", listenAddrs)
|
logger.Infof("starting vmauth at %q...", listenAddrs)
|
||||||
startTime := time.Now()
|
startTime := time.Now()
|
||||||
initAuthConfig()
|
initAuthConfig()
|
||||||
go httpserver.Serve(listenAddrs, useProxyProtocol, requestHandler)
|
disableInternalRoutes := len(*httpInternalListenAddr) > 0
|
||||||
|
rh := requestHandlerWithInternalRoutes
|
||||||
|
if disableInternalRoutes {
|
||||||
|
rh = requestHandler
|
||||||
|
}
|
||||||
|
|
||||||
|
serveOpts := httpserver.ServeOptions{
|
||||||
|
UseProxyProtocol: useProxyProtocol,
|
||||||
|
DisableBuiltinRoutes: disableInternalRoutes,
|
||||||
|
}
|
||||||
|
go httpserver.ServeWithOpts(listenAddrs, rh, serveOpts)
|
||||||
|
|
||||||
|
if len(*httpInternalListenAddr) > 0 {
|
||||||
|
go httpserver.Serve(*httpInternalListenAddr, nil, internalRequestHandler)
|
||||||
|
}
|
||||||
logger.Infof("started vmauth in %.3f seconds", time.Since(startTime).Seconds())
|
logger.Infof("started vmauth in %.3f seconds", time.Since(startTime).Seconds())
|
||||||
|
|
||||||
pushmetrics.Init()
|
pushmetrics.Init()
|
||||||
@@ -109,7 +127,7 @@ func main() {
|
|||||||
logger.Infof("successfully stopped vmauth in %.3f seconds", time.Since(startTime).Seconds())
|
logger.Infof("successfully stopped vmauth in %.3f seconds", time.Since(startTime).Seconds())
|
||||||
}
|
}
|
||||||
|
|
||||||
func requestHandler(w http.ResponseWriter, r *http.Request) bool {
|
func internalRequestHandler(w http.ResponseWriter, r *http.Request) bool {
|
||||||
switch r.URL.Path {
|
switch r.URL.Path {
|
||||||
case "/-/reload":
|
case "/-/reload":
|
||||||
if !httpserver.CheckAuthFlag(w, r, reloadAuthKey) {
|
if !httpserver.CheckAuthFlag(w, r, reloadAuthKey) {
|
||||||
@@ -120,6 +138,17 @@ func requestHandler(w http.ResponseWriter, r *http.Request) bool {
|
|||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestHandlerWithInternalRoutes(w http.ResponseWriter, r *http.Request) bool {
|
||||||
|
if internalRequestHandler(w, r) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return requestHandler(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestHandler(w http.ResponseWriter, r *http.Request) bool {
|
||||||
|
|
||||||
ats := getAuthTokensFromRequest(r)
|
ats := getAuthTokensFromRequest(r)
|
||||||
if len(ats) == 0 {
|
if len(ats) == 0 {
|
||||||
@@ -222,8 +251,7 @@ func processRequest(w http.ResponseWriter, r *http.Request, ui *UserInfo) {
|
|||||||
isDefault = true
|
isDefault = true
|
||||||
}
|
}
|
||||||
|
|
||||||
rtb := getReadTrackingBody(r.Body, maxRequestBodySizeToRetry.IntN())
|
rtb := newReadTrackingBody(r.Body, maxRequestBodySizeToRetry.IntN())
|
||||||
defer putReadTrackingBody(rtb)
|
|
||||||
r.Body = rtb
|
r.Body = rtb
|
||||||
|
|
||||||
maxAttempts := up.getBackendsCount()
|
maxAttempts := up.getBackendsCount()
|
||||||
@@ -559,22 +587,11 @@ type readTrackingBody struct {
|
|||||||
bufComplete bool
|
bufComplete bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (rtb *readTrackingBody) reset() {
|
func newReadTrackingBody(r io.ReadCloser, maxBodySize int) *readTrackingBody {
|
||||||
rtb.maxBodySize = 0
|
// do not use sync.Pool there
|
||||||
rtb.r = nil
|
// since http.RoundTrip may still use request body after return
|
||||||
rtb.buf = rtb.buf[:0]
|
// See this issue for details https://github.com/VictoriaMetrics/VictoriaMetrics/issues/8051
|
||||||
rtb.readBuf = nil
|
rtb := &readTrackingBody{}
|
||||||
rtb.cannotRetry = false
|
|
||||||
rtb.bufComplete = false
|
|
||||||
}
|
|
||||||
|
|
||||||
func getReadTrackingBody(r io.ReadCloser, maxBodySize int) *readTrackingBody {
|
|
||||||
v := readTrackingBodyPool.Get()
|
|
||||||
if v == nil {
|
|
||||||
v = &readTrackingBody{}
|
|
||||||
}
|
|
||||||
rtb := v.(*readTrackingBody)
|
|
||||||
|
|
||||||
if maxBodySize < 0 {
|
if maxBodySize < 0 {
|
||||||
maxBodySize = 0
|
maxBodySize = 0
|
||||||
}
|
}
|
||||||
@@ -597,13 +614,6 @@ func (r *zeroReader) Close() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func putReadTrackingBody(rtb *readTrackingBody) {
|
|
||||||
rtb.reset()
|
|
||||||
readTrackingBodyPool.Put(rtb)
|
|
||||||
}
|
|
||||||
|
|
||||||
var readTrackingBodyPool sync.Pool
|
|
||||||
|
|
||||||
// Read implements io.Reader interface.
|
// Read implements io.Reader interface.
|
||||||
func (rtb *readTrackingBody) Read(p []byte) (int, error) {
|
func (rtb *readTrackingBody) Read(p []byte) (int, error) {
|
||||||
if len(rtb.readBuf) > 0 {
|
if len(rtb.readBuf) > 0 {
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ func TestRequestHandler(t *testing.T) {
|
|||||||
r.Header.Set("Pass-Header", "abc")
|
r.Header.Set("Pass-Header", "abc")
|
||||||
|
|
||||||
w := &fakeResponseWriter{}
|
w := &fakeResponseWriter{}
|
||||||
if !requestHandler(w, r) {
|
if !requestHandlerWithInternalRoutes(w, r) {
|
||||||
t.Fatalf("unexpected false is returned from requestHandler")
|
t.Fatalf("unexpected false is returned from requestHandler")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,7 +195,7 @@ unauthorized_user:
|
|||||||
}
|
}
|
||||||
responseExpected = `
|
responseExpected = `
|
||||||
statusCode=401
|
statusCode=401
|
||||||
The provided authKey doesn't match -reloadAuthKey`
|
Expected to receive non-empty authKey when -reloadAuthKey is set`
|
||||||
f(cfgStr, requestURL, backendHandler, responseExpected)
|
f(cfgStr, requestURL, backendHandler, responseExpected)
|
||||||
if err := reloadAuthKey.Set(origAuthKey); err != nil {
|
if err := reloadAuthKey.Set(origAuthKey); err != nil {
|
||||||
t.Fatalf("unexpected error: %s", err)
|
t.Fatalf("unexpected error: %s", err)
|
||||||
@@ -545,8 +545,7 @@ func TestReadTrackingBody_RetrySuccess(t *testing.T) {
|
|||||||
f := func(s string, maxBodySize int) {
|
f := func(s string, maxBodySize int) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
rtb := getReadTrackingBody(io.NopCloser(bytes.NewBufferString(s)), maxBodySize)
|
rtb := newReadTrackingBody(io.NopCloser(bytes.NewBufferString(s)), maxBodySize)
|
||||||
defer putReadTrackingBody(rtb)
|
|
||||||
|
|
||||||
if !rtb.canRetry() {
|
if !rtb.canRetry() {
|
||||||
t.Fatalf("canRetry() must return true before reading anything")
|
t.Fatalf("canRetry() must return true before reading anything")
|
||||||
@@ -581,8 +580,7 @@ func TestReadTrackingBody_RetrySuccessPartialRead(t *testing.T) {
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
// Check the case with partial read
|
// Check the case with partial read
|
||||||
rtb := getReadTrackingBody(io.NopCloser(bytes.NewBufferString(s)), maxBodySize)
|
rtb := newReadTrackingBody(io.NopCloser(bytes.NewBufferString(s)), maxBodySize)
|
||||||
defer putReadTrackingBody(rtb)
|
|
||||||
|
|
||||||
for i := 0; i < len(s); i++ {
|
for i := 0; i < len(s); i++ {
|
||||||
buf := make([]byte, i)
|
buf := make([]byte, i)
|
||||||
@@ -631,8 +629,7 @@ func TestReadTrackingBody_RetryFailureTooBigBody(t *testing.T) {
|
|||||||
f := func(s string, maxBodySize int) {
|
f := func(s string, maxBodySize int) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
rtb := getReadTrackingBody(io.NopCloser(bytes.NewBufferString(s)), maxBodySize)
|
rtb := newReadTrackingBody(io.NopCloser(bytes.NewBufferString(s)), maxBodySize)
|
||||||
defer putReadTrackingBody(rtb)
|
|
||||||
|
|
||||||
if !rtb.canRetry() {
|
if !rtb.canRetry() {
|
||||||
t.Fatalf("canRetry() must return true before reading anything")
|
t.Fatalf("canRetry() must return true before reading anything")
|
||||||
@@ -681,8 +678,7 @@ func TestReadTrackingBody_RetryFailureZeroOrNegativeMaxBodySize(t *testing.T) {
|
|||||||
f := func(s string, maxBodySize int) {
|
f := func(s string, maxBodySize int) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
rtb := getReadTrackingBody(io.NopCloser(bytes.NewBufferString(s)), maxBodySize)
|
rtb := newReadTrackingBody(io.NopCloser(bytes.NewBufferString(s)), maxBodySize)
|
||||||
defer putReadTrackingBody(rtb)
|
|
||||||
|
|
||||||
if !rtb.canRetry() {
|
if !rtb.canRetry() {
|
||||||
t.Fatalf("canRetry() must return true before reading anything")
|
t.Fatalf("canRetry() must return true before reading anything")
|
||||||
|
|||||||
@@ -596,7 +596,8 @@ var (
|
|||||||
&cli.Int64Flag{
|
&cli.Int64Flag{
|
||||||
Name: vmRateLimit,
|
Name: vmRateLimit,
|
||||||
Usage: "Optional data transfer rate limit in bytes per second.\n" +
|
Usage: "Optional data transfer rate limit in bytes per second.\n" +
|
||||||
"By default, the rate limit is disabled. It can be useful for limiting load on source or destination databases.",
|
"By default, the rate limit is disabled. It can be useful for limiting load on source or destination databases. \n" +
|
||||||
|
"Rate limit is applied per worker, see `--vm-concurrency`.",
|
||||||
},
|
},
|
||||||
&cli.BoolFlag{
|
&cli.BoolFlag{
|
||||||
Name: vmInterCluster,
|
Name: vmInterCluster,
|
||||||
|
|||||||
@@ -29,13 +29,13 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
deleteAuthKey = flagutil.NewPassword("deleteAuthKey", "authKey for metrics' deletion via /api/v1/admin/tsdb/delete_series and /tags/delSeries. It overrides -httpAuth.*")
|
deleteAuthKey = flagutil.NewPassword("deleteAuthKey", "authKey for metrics' deletion via /api/v1/admin/tsdb/delete_series and /tags/delSeries. It could be passed via authKey query arg. It overrides -httpAuth.*")
|
||||||
maxConcurrentRequests = flag.Int("search.maxConcurrentRequests", getDefaultMaxConcurrentRequests(), "The maximum number of concurrent search requests. "+
|
maxConcurrentRequests = flag.Int("search.maxConcurrentRequests", getDefaultMaxConcurrentRequests(), "The maximum number of concurrent search requests. "+
|
||||||
"It shouldn't be high, since a single request can saturate all the CPU cores, while many concurrently executed requests may require high amounts of memory. "+
|
"It shouldn't be high, since a single request can saturate all the CPU cores, while many concurrently executed requests may require high amounts of memory. "+
|
||||||
"See also -search.maxQueueDuration and -search.maxMemoryPerQuery")
|
"See also -search.maxQueueDuration and -search.maxMemoryPerQuery")
|
||||||
maxQueueDuration = flag.Duration("search.maxQueueDuration", 10*time.Second, "The maximum time the request waits for execution when -search.maxConcurrentRequests "+
|
maxQueueDuration = flag.Duration("search.maxQueueDuration", 10*time.Second, "The maximum time the request waits for execution when -search.maxConcurrentRequests "+
|
||||||
"limit is reached; see also -search.maxQueryDuration")
|
"limit is reached; see also -search.maxQueryDuration")
|
||||||
resetCacheAuthKey = flagutil.NewPassword("search.resetCacheAuthKey", "Optional authKey for resetting rollup cache via /internal/resetRollupResultCache call. It overrides -httpAuth.*")
|
resetCacheAuthKey = flagutil.NewPassword("search.resetCacheAuthKey", "Optional authKey for resetting rollup cache via /internal/resetRollupResultCache call. It could be passed via authKey query arg. It overrides -httpAuth.*")
|
||||||
logSlowQueryDuration = flag.Duration("search.logSlowQueryDuration", 5*time.Second, "Log queries with execution time exceeding this value. Zero disables slow query logging. "+
|
logSlowQueryDuration = flag.Duration("search.logSlowQueryDuration", 5*time.Second, "Log queries with execution time exceeding this value. Zero disables slow query logging. "+
|
||||||
"See also -search.logQueryMemoryUsage")
|
"See also -search.logQueryMemoryUsage")
|
||||||
vmalertProxyURL = flag.String("vmalert.proxyURL", "", "Optional URL for proxying requests to vmalert. For example, if -vmalert.proxyURL=http://vmalert:8880 , then alerting API requests such as /api/v1/rules from Grafana will be proxied to http://vmalert:8880/api/v1/rules")
|
vmalertProxyURL = flag.String("vmalert.proxyURL", "", "Optional URL for proxying requests to vmalert. For example, if -vmalert.proxyURL=http://vmalert:8880 , then alerting API requests such as /api/v1/rules from Grafana will be proxied to http://vmalert:8880/api/v1/rules")
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
|
||||||
"unsafe"
|
"unsafe"
|
||||||
|
|
||||||
"github.com/VictoriaMetrics/metrics"
|
"github.com/VictoriaMetrics/metrics"
|
||||||
@@ -1002,9 +1001,7 @@ func ExportBlocks(qt *querytracer.Tracer, sq *storage.SearchQuery, deadline sear
|
|||||||
|
|
||||||
sr := getStorageSearch()
|
sr := getStorageSearch()
|
||||||
defer putStorageSearch(sr)
|
defer putStorageSearch(sr)
|
||||||
startTime := time.Now()
|
|
||||||
sr.Init(qt, vmstorage.Storage, tfss, tr, sq.MaxMetrics, deadline.Deadline())
|
sr.Init(qt, vmstorage.Storage, tfss, tr, sq.MaxMetrics, deadline.Deadline())
|
||||||
indexSearchDuration.UpdateDuration(startTime)
|
|
||||||
|
|
||||||
// Start workers that call f in parallel on available CPU cores.
|
// Start workers that call f in parallel on available CPU cores.
|
||||||
workCh := make(chan *exportWork, gomaxprocs*8)
|
workCh := make(chan *exportWork, gomaxprocs*8)
|
||||||
@@ -1142,9 +1139,7 @@ func ProcessSearchQuery(qt *querytracer.Tracer, sq *storage.SearchQuery, deadlin
|
|||||||
defer vmstorage.WG.Done()
|
defer vmstorage.WG.Done()
|
||||||
|
|
||||||
sr := getStorageSearch()
|
sr := getStorageSearch()
|
||||||
startTime := time.Now()
|
|
||||||
maxSeriesCount := sr.Init(qt, vmstorage.Storage, tfss, tr, sq.MaxMetrics, deadline.Deadline())
|
maxSeriesCount := sr.Init(qt, vmstorage.Storage, tfss, tr, sq.MaxMetrics, deadline.Deadline())
|
||||||
indexSearchDuration.UpdateDuration(startTime)
|
|
||||||
type blockRefs struct {
|
type blockRefs struct {
|
||||||
brs []blockRef
|
brs []blockRef
|
||||||
}
|
}
|
||||||
@@ -1296,8 +1291,6 @@ func ProcessSearchQuery(qt *querytracer.Tracer, sq *storage.SearchQuery, deadlin
|
|||||||
return &rss, nil
|
return &rss, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var indexSearchDuration = metrics.NewHistogram(`vm_index_search_duration_seconds`)
|
|
||||||
|
|
||||||
type blockRef struct {
|
type blockRef struct {
|
||||||
partRef storage.PartRef
|
partRef storage.PartRef
|
||||||
addr tmpBlockAddr
|
addr tmpBlockAddr
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import (
|
|||||||
"github.com/VictoriaMetrics/VictoriaMetrics/lib/httputils"
|
"github.com/VictoriaMetrics/VictoriaMetrics/lib/httputils"
|
||||||
"github.com/VictoriaMetrics/VictoriaMetrics/lib/logger"
|
"github.com/VictoriaMetrics/VictoriaMetrics/lib/logger"
|
||||||
"github.com/VictoriaMetrics/VictoriaMetrics/lib/memory"
|
"github.com/VictoriaMetrics/VictoriaMetrics/lib/memory"
|
||||||
|
"github.com/VictoriaMetrics/VictoriaMetrics/lib/netutil"
|
||||||
"github.com/VictoriaMetrics/VictoriaMetrics/lib/querytracer"
|
"github.com/VictoriaMetrics/VictoriaMetrics/lib/querytracer"
|
||||||
"github.com/VictoriaMetrics/VictoriaMetrics/lib/storage"
|
"github.com/VictoriaMetrics/VictoriaMetrics/lib/storage"
|
||||||
)
|
)
|
||||||
@@ -142,10 +143,13 @@ func FederateHandler(startTime time.Time, w http.ResponseWriter, r *http.Request
|
|||||||
WriteFederate(bb, rs)
|
WriteFederate(bb, rs)
|
||||||
return sw.maybeFlushBuffer(bb)
|
return sw.maybeFlushBuffer(bb)
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err == nil {
|
||||||
|
err = sw.flush()
|
||||||
|
}
|
||||||
|
if err != nil && !netutil.IsTrivialNetworkError(err) {
|
||||||
return fmt.Errorf("error during sending data to remote client: %w", err)
|
return fmt.Errorf("error during sending data to remote client: %w", err)
|
||||||
}
|
}
|
||||||
return sw.flush()
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var federateDuration = metrics.NewSummary(`vm_request_duration_seconds{path="/federate"}`)
|
var federateDuration = metrics.NewSummary(`vm_request_duration_seconds{path="/federate"}`)
|
||||||
@@ -226,10 +230,13 @@ func ExportCSVHandler(startTime time.Time, w http.ResponseWriter, r *http.Reques
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
err = <-doneCh
|
err = <-doneCh
|
||||||
if err != nil {
|
if err == nil {
|
||||||
|
err = sw.flush()
|
||||||
|
}
|
||||||
|
if err != nil && !netutil.IsTrivialNetworkError(err) {
|
||||||
return fmt.Errorf("error during sending the exported csv data to remote client: %w", err)
|
return fmt.Errorf("error during sending the exported csv data to remote client: %w", err)
|
||||||
}
|
}
|
||||||
return sw.flush()
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var exportCSVDuration = metrics.NewSummary(`vm_request_duration_seconds{path="/api/v1/export/csv"}`)
|
var exportCSVDuration = metrics.NewSummary(`vm_request_duration_seconds{path="/api/v1/export/csv"}`)
|
||||||
@@ -281,10 +288,13 @@ func ExportNativeHandler(startTime time.Time, w http.ResponseWriter, r *http.Req
|
|||||||
bb.B = dst
|
bb.B = dst
|
||||||
return sw.maybeFlushBuffer(bb)
|
return sw.maybeFlushBuffer(bb)
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err == nil {
|
||||||
|
err = sw.flush()
|
||||||
|
}
|
||||||
|
if err != nil && !netutil.IsTrivialNetworkError(err) {
|
||||||
return fmt.Errorf("error during sending native data to remote client: %w", err)
|
return fmt.Errorf("error during sending native data to remote client: %w", err)
|
||||||
}
|
}
|
||||||
return sw.flush()
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var exportNativeDuration = metrics.NewSummary(`vm_request_duration_seconds{path="/api/v1/export/native"}`)
|
var exportNativeDuration = metrics.NewSummary(`vm_request_duration_seconds{path="/api/v1/export/native"}`)
|
||||||
@@ -441,16 +451,19 @@ func exportHandler(qt *querytracer.Tracer, w http.ResponseWriter, cp *commonPara
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
err := <-doneCh
|
err := <-doneCh
|
||||||
if err != nil {
|
if err == nil {
|
||||||
|
err = sw.flush()
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
if format == "promapi" {
|
||||||
|
WriteExportPromAPIFooter(bw, qt)
|
||||||
|
}
|
||||||
|
err = bw.Flush()
|
||||||
|
}
|
||||||
|
if err != nil && !netutil.IsTrivialNetworkError(err) {
|
||||||
return fmt.Errorf("cannot send data to remote client: %w", err)
|
return fmt.Errorf("cannot send data to remote client: %w", err)
|
||||||
}
|
}
|
||||||
if err := sw.flush(); err != nil {
|
return nil
|
||||||
return fmt.Errorf("cannot send data to remote client: %w", err)
|
|
||||||
}
|
|
||||||
if format == "promapi" {
|
|
||||||
WriteExportPromAPIFooter(bw, qt)
|
|
||||||
}
|
|
||||||
return bw.Flush()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type exportBlock struct {
|
type exportBlock struct {
|
||||||
@@ -481,6 +494,8 @@ func DeleteHandler(startTime time.Time, r *http.Request) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
cp.deadline = searchutils.GetDeadlineForDelete(r, startTime)
|
||||||
|
|
||||||
if !cp.IsDefaultTimeRange() {
|
if !cp.IsDefaultTimeRange() {
|
||||||
return fmt.Errorf("start=%d and end=%d args aren't supported. Remove these args from the query in order to delete all the matching metrics", cp.start, cp.end)
|
return fmt.Errorf("start=%d and end=%d args aren't supported. Remove these args from the query in order to delete all the matching metrics", cp.start, cp.end)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -483,8 +483,11 @@ func binaryOpOr(bfa *binaryOpFuncArg) ([]*timeseries, error) {
|
|||||||
mLeft, mRight := createTimeseriesMapByTagSet(bfa.be, bfa.left, bfa.right)
|
mLeft, mRight := createTimeseriesMapByTagSet(bfa.be, bfa.left, bfa.right)
|
||||||
var rvs []*timeseries
|
var rvs []*timeseries
|
||||||
|
|
||||||
for _, tss := range mLeft {
|
for k, tss := range mLeft {
|
||||||
rvs = append(rvs, tss...)
|
tssLeft := removeEmptySeries(tss)
|
||||||
|
// re-assign modified slice to map, since it can be referred later
|
||||||
|
mLeft[k] = tssLeft
|
||||||
|
rvs = append(rvs, tssLeft...)
|
||||||
}
|
}
|
||||||
// Sort left-hand-side series by metric name as Prometheus does.
|
// Sort left-hand-side series by metric name as Prometheus does.
|
||||||
// See https://github.com/VictoriaMetrics/VictoriaMetrics/issues/5393
|
// See https://github.com/VictoriaMetrics/VictoriaMetrics/issues/5393
|
||||||
@@ -497,7 +500,10 @@ func binaryOpOr(bfa *binaryOpFuncArg) ([]*timeseries, error) {
|
|||||||
rvs = append(rvs, tssRight...)
|
rvs = append(rvs, tssRight...)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
fillLeftNaNsWithRightValues(tssLeft, tssRight)
|
fillLeftNaNsWithRightValuesOrMerge(tssLeft, tssRight)
|
||||||
|
// tssRight might be filled with NaNs after merge
|
||||||
|
tssRight = removeEmptySeries(tssRight)
|
||||||
|
rvs = append(rvs, tssRight...)
|
||||||
}
|
}
|
||||||
// Sort the added right-hand-side series by metric name as Prometheus does.
|
// Sort the added right-hand-side series by metric name as Prometheus does.
|
||||||
// See https://github.com/VictoriaMetrics/VictoriaMetrics/issues/5393
|
// See https://github.com/VictoriaMetrics/VictoriaMetrics/issues/5393
|
||||||
@@ -526,6 +532,35 @@ func fillLeftNaNsWithRightValues(tssLeft, tssRight []*timeseries) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fill gaps in tssLeft with values from tssRight when labels match
|
||||||
|
// Set NaNs to tssRight when tssLeft has corresponding values
|
||||||
|
// or if tssLeft and tssRight can be merged.
|
||||||
|
//
|
||||||
|
// See https://github.com/VictoriaMetrics/VictoriaMetrics/issues/7759
|
||||||
|
// https://github.com/VictoriaMetrics/VictoriaMetrics/issues/7640
|
||||||
|
func fillLeftNaNsWithRightValuesOrMerge(tssLeft, tssRight []*timeseries) {
|
||||||
|
for _, tsLeft := range tssLeft {
|
||||||
|
valuesLeft := tsLeft.Values
|
||||||
|
nameLeft := tsLeft.MetricName.String()
|
||||||
|
for i, v := range valuesLeft {
|
||||||
|
leftIsNaN := math.IsNaN(v)
|
||||||
|
for _, tsRight := range tssRight {
|
||||||
|
canBeMerged := nameLeft == tsRight.MetricName.String()
|
||||||
|
valueRight := tsRight.Values[i]
|
||||||
|
if leftIsNaN && canBeMerged {
|
||||||
|
// fill NaNs with valueRight if labels match
|
||||||
|
valuesLeft[i] = valueRight
|
||||||
|
}
|
||||||
|
if !leftIsNaN || canBeMerged {
|
||||||
|
// set NaN to valueRight if valueLeft is not NaN
|
||||||
|
// or if left and right can be merged
|
||||||
|
tsRight.Values[i] = nan
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func binaryOpIfnot(bfa *binaryOpFuncArg) ([]*timeseries, error) {
|
func binaryOpIfnot(bfa *binaryOpFuncArg) ([]*timeseries, error) {
|
||||||
mLeft, mRight := createTimeseriesMapByTagSet(bfa.be, bfa.left, bfa.right)
|
mLeft, mRight := createTimeseriesMapByTagSet(bfa.be, bfa.left, bfa.right)
|
||||||
var rvs []*timeseries
|
var rvs []*timeseries
|
||||||
|
|||||||
@@ -4461,9 +4461,9 @@ func TestExecSuccess(t *testing.T) {
|
|||||||
t.Run(`histogram_quantile(nan-bucket-count-some)`, func(t *testing.T) {
|
t.Run(`histogram_quantile(nan-bucket-count-some)`, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
q := `round(histogram_quantile(0.6,
|
q := `round(histogram_quantile(0.6,
|
||||||
label_set(90, "foo", "bar", "le", "10")
|
union(label_set(90, "foo", "bar", "le", "10"),
|
||||||
or label_set(NaN, "foo", "bar", "le", "30")
|
label_set(NaN, "foo", "bar", "le", "30"),
|
||||||
or label_set(300, "foo", "bar", "le", "+Inf")
|
label_set(300, "foo", "bar", "le", "+Inf"))
|
||||||
),0.01)`
|
),0.01)`
|
||||||
r := netstorage.Result{
|
r := netstorage.Result{
|
||||||
MetricName: metricNameExpected,
|
MetricName: metricNameExpected,
|
||||||
@@ -9409,7 +9409,384 @@ func TestExecSuccess(t *testing.T) {
|
|||||||
resultExpected := []netstorage.Result{r}
|
resultExpected := []netstorage.Result{r}
|
||||||
f(q, resultExpected)
|
f(q, resultExpected)
|
||||||
})
|
})
|
||||||
|
t.Run(`nan or on() series`, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
// left side returns NaNs only, so the right side should replace its values and labels
|
||||||
|
// https://github.com/VictoriaMetrics/VictoriaMetrics/issues/7759
|
||||||
|
q := `(label_set(1, "a", "a", "b", "b1") == 0) or on(a) label_set(2, "a", "a", "b", "b2")`
|
||||||
|
r := netstorage.Result{
|
||||||
|
MetricName: metricNameExpected,
|
||||||
|
Values: []float64{2, 2, 2, 2, 2, 2},
|
||||||
|
Timestamps: timestampsExpected,
|
||||||
|
}
|
||||||
|
r.MetricName.Tags = []storage.Tag{{
|
||||||
|
Key: []byte("a"),
|
||||||
|
Value: []byte("a"),
|
||||||
|
}, {
|
||||||
|
Key: []byte("b"),
|
||||||
|
Value: []byte("b2"),
|
||||||
|
}}
|
||||||
|
resultExpected := []netstorage.Result{r}
|
||||||
|
f(q, resultExpected)
|
||||||
|
})
|
||||||
|
t.Run(`series with NaNs or scalar`, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
q := `(label_set(time() >= 1600, "a", "a", "b", "b1")) or 1`
|
||||||
|
r1 := netstorage.Result{
|
||||||
|
MetricName: metricNameExpected,
|
||||||
|
Values: []float64{nan, nan, nan, 1600, 1800, 2000},
|
||||||
|
Timestamps: timestampsExpected,
|
||||||
|
}
|
||||||
|
r1.MetricName.Tags = []storage.Tag{{
|
||||||
|
Key: []byte("a"),
|
||||||
|
Value: []byte("a"),
|
||||||
|
}, {
|
||||||
|
Key: []byte("b"),
|
||||||
|
Value: []byte("b1"),
|
||||||
|
}}
|
||||||
|
|
||||||
|
r2 := netstorage.Result{
|
||||||
|
MetricName: metricNameExpected,
|
||||||
|
Values: []float64{1, 1, 1, 1, 1, 1},
|
||||||
|
Timestamps: timestampsExpected,
|
||||||
|
}
|
||||||
|
resultExpected := []netstorage.Result{r1, r2}
|
||||||
|
f(q, resultExpected)
|
||||||
|
})
|
||||||
|
t.Run(`series or on() scalar`, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
// https://github.com/VictoriaMetrics/VictoriaMetrics/issues/7640
|
||||||
|
q := `(label_set(time() > 1200, "a", "a", "b", "b1")) or on() vector(0)`
|
||||||
|
r1 := netstorage.Result{
|
||||||
|
MetricName: metricNameExpected,
|
||||||
|
Values: []float64{nan, nan, 1400, 1600, 1800, 2000},
|
||||||
|
Timestamps: timestampsExpected,
|
||||||
|
}
|
||||||
|
r1.MetricName.Tags = []storage.Tag{{
|
||||||
|
Key: []byte("a"),
|
||||||
|
Value: []byte("a"),
|
||||||
|
}, {
|
||||||
|
Key: []byte("b"),
|
||||||
|
Value: []byte("b1"),
|
||||||
|
}}
|
||||||
|
|
||||||
|
r2 := netstorage.Result{
|
||||||
|
MetricName: metricNameExpected,
|
||||||
|
Values: []float64{0, 0, nan, nan, nan, nan},
|
||||||
|
Timestamps: timestampsExpected,
|
||||||
|
}
|
||||||
|
resultExpected := []netstorage.Result{r1, r2}
|
||||||
|
f(q, resultExpected)
|
||||||
|
})
|
||||||
|
t.Run(`series or on() series`, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
// left side + right side
|
||||||
|
q := `(label_set(time() <= 1200, "a", "a", "b", "b1")) or on(a) label_set(time() > 1200, "a", "a", "b", "b2")`
|
||||||
|
r1 := netstorage.Result{
|
||||||
|
MetricName: metricNameExpected,
|
||||||
|
Values: []float64{1000, 1200, nan, nan, nan, nan},
|
||||||
|
Timestamps: timestampsExpected,
|
||||||
|
}
|
||||||
|
r1.MetricName.Tags = []storage.Tag{{
|
||||||
|
Key: []byte("a"),
|
||||||
|
Value: []byte("a"),
|
||||||
|
}, {
|
||||||
|
Key: []byte("b"),
|
||||||
|
Value: []byte("b1"),
|
||||||
|
}}
|
||||||
|
|
||||||
|
r2 := netstorage.Result{
|
||||||
|
MetricName: metricNameExpected,
|
||||||
|
Values: []float64{nan, nan, 1400, 1600, 1800, 2000},
|
||||||
|
Timestamps: timestampsExpected,
|
||||||
|
}
|
||||||
|
r2.MetricName.Tags = []storage.Tag{{
|
||||||
|
Key: []byte("a"),
|
||||||
|
Value: []byte("a"),
|
||||||
|
}, {
|
||||||
|
Key: []byte("b"),
|
||||||
|
Value: []byte("b2"),
|
||||||
|
}}
|
||||||
|
resultExpected := []netstorage.Result{r1, r2}
|
||||||
|
f(q, resultExpected)
|
||||||
|
})
|
||||||
|
t.Run(`series with no NaNs or on() series`, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
// left side contains all needed values, so the right side should be dropped
|
||||||
|
q := `(label_set(time() < 3000, "a", "a", "b", "b1")) or on(a) label_set(time() > 3000, "a", "a", "b", "b2")`
|
||||||
|
r := netstorage.Result{
|
||||||
|
MetricName: metricNameExpected,
|
||||||
|
Values: []float64{1000, 1200, 1400, 1600, 1800, 2000},
|
||||||
|
Timestamps: timestampsExpected,
|
||||||
|
}
|
||||||
|
r.MetricName.Tags = []storage.Tag{{
|
||||||
|
Key: []byte("a"),
|
||||||
|
Value: []byte("a"),
|
||||||
|
}, {
|
||||||
|
Key: []byte("b"),
|
||||||
|
Value: []byte("b1"),
|
||||||
|
}}
|
||||||
|
|
||||||
|
resultExpected := []netstorage.Result{r}
|
||||||
|
f(q, resultExpected)
|
||||||
|
})
|
||||||
|
t.Run(`series or on() series with overlap`, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
// left overlap with right
|
||||||
|
q := `(label_set(time() <= 1500, "a", "a", "b", "b1")) or on(a) label_set(time() > 1100, "a", "a", "b", "b2")`
|
||||||
|
r1 := netstorage.Result{
|
||||||
|
MetricName: metricNameExpected,
|
||||||
|
Values: []float64{1000, 1200, 1400, nan, nan, nan},
|
||||||
|
Timestamps: timestampsExpected,
|
||||||
|
}
|
||||||
|
r1.MetricName.Tags = []storage.Tag{{
|
||||||
|
Key: []byte("a"),
|
||||||
|
Value: []byte("a"),
|
||||||
|
}, {
|
||||||
|
Key: []byte("b"),
|
||||||
|
Value: []byte("b1"),
|
||||||
|
}}
|
||||||
|
|
||||||
|
r2 := netstorage.Result{
|
||||||
|
MetricName: metricNameExpected,
|
||||||
|
Values: []float64{nan, nan, nan, 1600, 1800, 2000},
|
||||||
|
Timestamps: timestampsExpected,
|
||||||
|
}
|
||||||
|
r2.MetricName.Tags = []storage.Tag{{
|
||||||
|
Key: []byte("a"),
|
||||||
|
Value: []byte("a"),
|
||||||
|
}, {
|
||||||
|
Key: []byte("b"),
|
||||||
|
Value: []byte("b2"),
|
||||||
|
}}
|
||||||
|
|
||||||
|
resultExpected := []netstorage.Result{r1, r2}
|
||||||
|
f(q, resultExpected)
|
||||||
|
})
|
||||||
|
t.Run(`series or on() series merge`, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
// left + right for same series
|
||||||
|
q := `(label_set(time() <= 1200, "a", "a", "b", "b1")) or on(a) label_set(time() > 1400, "a", "a", "b", "b1")`
|
||||||
|
r := netstorage.Result{
|
||||||
|
MetricName: metricNameExpected,
|
||||||
|
Values: []float64{1000, 1200, nan, 1600, 1800, 2000},
|
||||||
|
Timestamps: timestampsExpected,
|
||||||
|
}
|
||||||
|
r.MetricName.Tags = []storage.Tag{{
|
||||||
|
Key: []byte("a"),
|
||||||
|
Value: []byte("a"),
|
||||||
|
}, {
|
||||||
|
Key: []byte("b"),
|
||||||
|
Value: []byte("b1"),
|
||||||
|
}}
|
||||||
|
|
||||||
|
resultExpected := []netstorage.Result{r}
|
||||||
|
f(q, resultExpected)
|
||||||
|
})
|
||||||
|
t.Run(`scalar or timeseries`, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
q := `time() > 1400 or label_set(123, "foo", "bar")`
|
||||||
|
r1 := netstorage.Result{
|
||||||
|
MetricName: metricNameExpected,
|
||||||
|
Values: []float64{nan, nan, nan, 1600, 1800, 2000},
|
||||||
|
Timestamps: timestampsExpected,
|
||||||
|
}
|
||||||
|
r2 := netstorage.Result{
|
||||||
|
MetricName: metricNameExpected,
|
||||||
|
Values: []float64{123, 123, 123, 123, 123, 123},
|
||||||
|
Timestamps: timestampsExpected,
|
||||||
|
}
|
||||||
|
r2.MetricName.Tags = []storage.Tag{{
|
||||||
|
Key: []byte("foo"),
|
||||||
|
Value: []byte("bar"),
|
||||||
|
}}
|
||||||
|
resultExpected := []netstorage.Result{r1, r2}
|
||||||
|
f(q, resultExpected)
|
||||||
|
})
|
||||||
|
t.Run(`series or many series`, func(t *testing.T) {
|
||||||
|
//load 1m
|
||||||
|
// foo{a="a", b="1"} 1 0 1 1 1
|
||||||
|
// bar{a="a", b="2"} 2 2 2 2 2
|
||||||
|
// bar{a="a", b="3"} 3 3 3 3 3
|
||||||
|
//
|
||||||
|
//eval range from 0 to 4m step 1m foo!=0 or on (a) bar
|
||||||
|
// foo{a="a", b="1"} 1 _ 1 1 1
|
||||||
|
// bar{a="a", b="2"} _ 2 _ _ _
|
||||||
|
// bar{a="a", b="3"} _ 3 _ _ _
|
||||||
|
// https://github.com/prometheus/prometheus/tree/main/promql/promqltest
|
||||||
|
t.Parallel()
|
||||||
|
q := `(
|
||||||
|
label_set(time()!=1200, "x", "foo"),
|
||||||
|
) or on(x) (
|
||||||
|
label_set(time()+1, "x", "foo", "y", "bar"),
|
||||||
|
label_set(time()+2, "y", "baz", "x", "foo"),
|
||||||
|
)`
|
||||||
|
r1 := netstorage.Result{
|
||||||
|
MetricName: metricNameExpected,
|
||||||
|
Values: []float64{1000, nan, 1400, 1600, 1800, 2000},
|
||||||
|
Timestamps: timestampsExpected,
|
||||||
|
}
|
||||||
|
r1.MetricName.Tags = []storage.Tag{
|
||||||
|
{Key: []byte("x"), Value: []byte("foo")},
|
||||||
|
}
|
||||||
|
r2 := netstorage.Result{
|
||||||
|
MetricName: metricNameExpected,
|
||||||
|
Values: []float64{nan, 1201, nan, nan, nan, nan},
|
||||||
|
Timestamps: timestampsExpected,
|
||||||
|
}
|
||||||
|
r2.MetricName.Tags = []storage.Tag{
|
||||||
|
{Key: []byte("x"), Value: []byte("foo")},
|
||||||
|
{Key: []byte("y"), Value: []byte("bar")},
|
||||||
|
}
|
||||||
|
r3 := netstorage.Result{
|
||||||
|
MetricName: metricNameExpected,
|
||||||
|
Values: []float64{nan, 1202, nan, nan, nan, nan},
|
||||||
|
Timestamps: timestampsExpected,
|
||||||
|
}
|
||||||
|
r3.MetricName.Tags = []storage.Tag{
|
||||||
|
{Key: []byte("x"), Value: []byte("foo")},
|
||||||
|
{Key: []byte("y"), Value: []byte("baz")},
|
||||||
|
}
|
||||||
|
resultExpected := []netstorage.Result{r1, r2, r3}
|
||||||
|
f(q, resultExpected)
|
||||||
|
})
|
||||||
|
t.Run(`many series or series`, func(t *testing.T) {
|
||||||
|
//load 1m
|
||||||
|
// foo{a="a", b="1"} 1 0 1 1 1
|
||||||
|
// foo{a="a", b="2"} 2 2 2 2 2
|
||||||
|
// bar{a="a", b="3"} 3 3 3 3 3
|
||||||
|
//
|
||||||
|
//eval range from 0 to 4m step 1m foo!=0 or on (a) bar
|
||||||
|
// foo{a="a", b="1"} 1 _ 1 1 1
|
||||||
|
// foo{a="a", b="2"} 2 2 2 2 2
|
||||||
|
// https://github.com/prometheus/prometheus/tree/main/promql/promqltest
|
||||||
|
t.Parallel()
|
||||||
|
q := `(
|
||||||
|
label_set(time()!=1200, "x", "foo"),
|
||||||
|
label_set(time()+1, "x", "foo", "y","baz"),
|
||||||
|
) or on(x) (
|
||||||
|
label_set(time()+2, "x", "foo", "y", "bar"),
|
||||||
|
)`
|
||||||
|
r1 := netstorage.Result{
|
||||||
|
MetricName: metricNameExpected,
|
||||||
|
Values: []float64{1000, nan, 1400, 1600, 1800, 2000},
|
||||||
|
Timestamps: timestampsExpected,
|
||||||
|
}
|
||||||
|
r1.MetricName.Tags = []storage.Tag{
|
||||||
|
{Key: []byte("x"), Value: []byte("foo")},
|
||||||
|
}
|
||||||
|
r2 := netstorage.Result{
|
||||||
|
MetricName: metricNameExpected,
|
||||||
|
Values: []float64{1001, 1201, 1401, 1601, 1801, 2001},
|
||||||
|
Timestamps: timestampsExpected,
|
||||||
|
}
|
||||||
|
r2.MetricName.Tags = []storage.Tag{
|
||||||
|
{Key: []byte("x"), Value: []byte("foo")},
|
||||||
|
{Key: []byte("y"), Value: []byte("baz")},
|
||||||
|
}
|
||||||
|
resultExpected := []netstorage.Result{r1, r2}
|
||||||
|
f(q, resultExpected)
|
||||||
|
})
|
||||||
|
t.Run(`many series or series with no merge`, func(t *testing.T) {
|
||||||
|
// load 1m
|
||||||
|
// foo{job="a1", a="a"} 0 0 1 1 0
|
||||||
|
// foo{job="a2", a="a"} 1 1 0 0 0
|
||||||
|
// foo{job="a3", a="a"} 1 1 1 1 1
|
||||||
|
// foo{job="a4", a="a"} 1 1 1 1 1
|
||||||
|
//
|
||||||
|
//eval range from 0 to 4m step 1m (foo{job=~"a1|a2"} == 0) or on (a) (foo{job=~"a3|a4"} == 1)
|
||||||
|
// foo{job="a1", a="a"} 0 0 _ _ 0
|
||||||
|
// foo{job="a2", a="a"} _ _ 0 0 0
|
||||||
|
// https://github.com/prometheus/prometheus/tree/main/promql/promqltest
|
||||||
|
t.Parallel()
|
||||||
|
q := `(
|
||||||
|
label_set(time()!=1400, "job", "a1", "a", "a"),
|
||||||
|
label_set(time()>=1400, "job", "a2", "a", "a"),
|
||||||
|
) or on(a) (
|
||||||
|
label_set(time(), "job", "a3", "a", "a"),
|
||||||
|
label_set(time(), "job", "a4", "a", "a"),
|
||||||
|
)`
|
||||||
|
r1 := netstorage.Result{
|
||||||
|
MetricName: metricNameExpected,
|
||||||
|
Values: []float64{1000, 1200, nan, 1600, 1800, 2000},
|
||||||
|
Timestamps: timestampsExpected,
|
||||||
|
}
|
||||||
|
r1.MetricName.Tags = []storage.Tag{
|
||||||
|
{Key: []byte("a"), Value: []byte("a")},
|
||||||
|
{Key: []byte("job"), Value: []byte("a1")},
|
||||||
|
}
|
||||||
|
r2 := netstorage.Result{
|
||||||
|
MetricName: metricNameExpected,
|
||||||
|
Values: []float64{nan, nan, 1400, 1600, 1800, 2000},
|
||||||
|
Timestamps: timestampsExpected,
|
||||||
|
}
|
||||||
|
r2.MetricName.Tags = []storage.Tag{
|
||||||
|
{Key: []byte("a"), Value: []byte("a")},
|
||||||
|
{Key: []byte("job"), Value: []byte("a2")},
|
||||||
|
}
|
||||||
|
resultExpected := []netstorage.Result{r1, r2}
|
||||||
|
f(q, resultExpected)
|
||||||
|
})
|
||||||
|
t.Run(`many series or series with merge`, func(t *testing.T) {
|
||||||
|
// load 1m
|
||||||
|
// foo{job="a1", a="a"} 0 0 1 1 0
|
||||||
|
// foo{job="a2", a="a"} 1 1 1 0 0
|
||||||
|
// foo{job="a3", a="a"} 1 1 1 1 1
|
||||||
|
// foo{job="a4", a="a"} 1 1 1 1 1
|
||||||
|
//
|
||||||
|
//eval range from 0 to 4m step 1m (foo{job=~"a1|a2"} == 0) or on (a) (foo{job=~"a3|a4"} == 1)
|
||||||
|
// foo{job="a1", a="a"} 0 0 _ _ 0
|
||||||
|
// foo{job="a2", a="a"} _ _ _ 0 0
|
||||||
|
// foo{job="a3", a="a"} _ _ 1 _ _
|
||||||
|
// foo{job="a4", a="a"} _ _ 1 _ _
|
||||||
|
// https://github.com/prometheus/prometheus/tree/main/promql/promqltest
|
||||||
|
t.Parallel()
|
||||||
|
q := `(
|
||||||
|
label_set(time()!=1400, "job", "a1", "a", "a"),
|
||||||
|
label_set(time()>=1600, "job", "a2", "a", "a"),
|
||||||
|
) or on(a) (
|
||||||
|
label_set(time(), "job", "a3", "a", "a"),
|
||||||
|
label_set(time(), "job", "a4", "a", "a"),
|
||||||
|
)`
|
||||||
|
r1 := netstorage.Result{
|
||||||
|
MetricName: metricNameExpected,
|
||||||
|
Values: []float64{1000, 1200, nan, 1600, 1800, 2000},
|
||||||
|
Timestamps: timestampsExpected,
|
||||||
|
}
|
||||||
|
r1.MetricName.Tags = []storage.Tag{
|
||||||
|
{Key: []byte("a"), Value: []byte("a")},
|
||||||
|
{Key: []byte("job"), Value: []byte("a1")},
|
||||||
|
}
|
||||||
|
r2 := netstorage.Result{
|
||||||
|
MetricName: metricNameExpected,
|
||||||
|
Values: []float64{nan, nan, nan, 1600, 1800, 2000},
|
||||||
|
Timestamps: timestampsExpected,
|
||||||
|
}
|
||||||
|
r2.MetricName.Tags = []storage.Tag{
|
||||||
|
{Key: []byte("a"), Value: []byte("a")},
|
||||||
|
{Key: []byte("job"), Value: []byte("a2")},
|
||||||
|
}
|
||||||
|
r3 := netstorage.Result{
|
||||||
|
MetricName: metricNameExpected,
|
||||||
|
Values: []float64{nan, nan, 1400, nan, nan, nan},
|
||||||
|
Timestamps: timestampsExpected,
|
||||||
|
}
|
||||||
|
r3.MetricName.Tags = []storage.Tag{
|
||||||
|
{Key: []byte("a"), Value: []byte("a")},
|
||||||
|
{Key: []byte("job"), Value: []byte("a3")},
|
||||||
|
}
|
||||||
|
r4 := netstorage.Result{
|
||||||
|
MetricName: metricNameExpected,
|
||||||
|
Values: []float64{nan, nan, 1400, nan, nan, nan},
|
||||||
|
Timestamps: timestampsExpected,
|
||||||
|
}
|
||||||
|
r4.MetricName.Tags = []storage.Tag{
|
||||||
|
{Key: []byte("a"), Value: []byte("a")},
|
||||||
|
{Key: []byte("job"), Value: []byte("a4")},
|
||||||
|
}
|
||||||
|
resultExpected := []netstorage.Result{r1, r2, r3, r4}
|
||||||
|
f(q, resultExpected)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExecError(t *testing.T) {
|
func TestExecError(t *testing.T) {
|
||||||
|
|||||||
@@ -374,8 +374,8 @@ func getRollupConfigs(funcName string, rf rollupFunc, expr metricsql.Expr, start
|
|||||||
preFunc := func(_ []float64, _ []int64) {}
|
preFunc := func(_ []float64, _ []int64) {}
|
||||||
funcName = strings.ToLower(funcName)
|
funcName = strings.ToLower(funcName)
|
||||||
if rollupFuncsRemoveCounterResets[funcName] {
|
if rollupFuncsRemoveCounterResets[funcName] {
|
||||||
preFunc = func(values []float64, _ []int64) {
|
preFunc = func(values []float64, timestamps []int64) {
|
||||||
removeCounterResets(values)
|
removeCounterResets(values, timestamps, lookbackDelta)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
samplesScannedPerCall := rollupFuncsSamplesScannedPerCall[funcName]
|
samplesScannedPerCall := rollupFuncsSamplesScannedPerCall[funcName]
|
||||||
@@ -486,8 +486,8 @@ func getRollupConfigs(funcName string, rf rollupFunc, expr metricsql.Expr, start
|
|||||||
for _, aggrFuncName := range aggrFuncNames {
|
for _, aggrFuncName := range aggrFuncNames {
|
||||||
if rollupFuncsRemoveCounterResets[aggrFuncName] {
|
if rollupFuncsRemoveCounterResets[aggrFuncName] {
|
||||||
// There is no need to save the previous preFunc, since it is either empty or the same.
|
// There is no need to save the previous preFunc, since it is either empty or the same.
|
||||||
preFunc = func(values []float64, _ []int64) {
|
preFunc = func(values []float64, timestamps []int64) {
|
||||||
removeCounterResets(values)
|
removeCounterResets(values, timestamps, lookbackDelta)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
rf := rollupAggrFuncs[aggrFuncName]
|
rf := rollupAggrFuncs[aggrFuncName]
|
||||||
@@ -521,7 +521,7 @@ type rollupFuncArg struct {
|
|||||||
timestamps []int64
|
timestamps []int64
|
||||||
|
|
||||||
// Real value preceding values.
|
// Real value preceding values.
|
||||||
// Is populated if preceding value is within the staleness interval.
|
// Is populated if preceding value is within the -search.maxStalenessInterval (rc.LookbackDelta).
|
||||||
realPrevValue float64
|
realPrevValue float64
|
||||||
|
|
||||||
// Real value which goes after values.
|
// Real value which goes after values.
|
||||||
@@ -768,7 +768,13 @@ func (rc *rollupConfig) doInternal(dstValues []float64, tsm *timeseriesMap, valu
|
|||||||
rfa.realPrevValue = nan
|
rfa.realPrevValue = nan
|
||||||
if i > 0 {
|
if i > 0 {
|
||||||
prevValue, prevTimestamp := values[i-1], timestamps[i-1]
|
prevValue, prevTimestamp := values[i-1], timestamps[i-1]
|
||||||
if (tEnd - prevTimestamp) < maxPrevInterval {
|
// set realPrevValue if rc.LookbackDelta == 0
|
||||||
|
// or if distance between datapoint in prev interval and beginning of this interval
|
||||||
|
// doesn't exceed LookbackDelta.
|
||||||
|
// https://github.com/VictoriaMetrics/VictoriaMetrics/pull/1381
|
||||||
|
// https://github.com/VictoriaMetrics/VictoriaMetrics/issues/894
|
||||||
|
// https://github.com/VictoriaMetrics/VictoriaMetrics/issues/8045
|
||||||
|
if rc.LookbackDelta == 0 || (tStart-prevTimestamp) < rc.LookbackDelta {
|
||||||
rfa.realPrevValue = prevValue
|
rfa.realPrevValue = prevValue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -894,7 +900,7 @@ func getMaxPrevInterval(scrapeInterval int64) int64 {
|
|||||||
return scrapeInterval + scrapeInterval/8
|
return scrapeInterval + scrapeInterval/8
|
||||||
}
|
}
|
||||||
|
|
||||||
func removeCounterResets(values []float64) {
|
func removeCounterResets(values []float64, timestamps []int64, maxStalenessInterval int64) {
|
||||||
// There is no need in handling NaNs here, since they are impossible
|
// There is no need in handling NaNs here, since they are impossible
|
||||||
// on values from vmstorage.
|
// on values from vmstorage.
|
||||||
if len(values) == 0 {
|
if len(values) == 0 {
|
||||||
@@ -913,6 +919,16 @@ func removeCounterResets(values []float64) {
|
|||||||
correction += prevValue
|
correction += prevValue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if i > 0 && maxStalenessInterval > 0 {
|
||||||
|
gap := timestamps[i] - timestamps[i-1]
|
||||||
|
if gap > maxStalenessInterval {
|
||||||
|
// reset correction if gap between samples exceeds staleness interval
|
||||||
|
// see https://github.com/VictoriaMetrics/VictoriaMetrics/issues/8072
|
||||||
|
correction = 0
|
||||||
|
prevValue = v
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
prevValue = v
|
prevValue = v
|
||||||
values[i] = v + correction
|
values[i] = v + correction
|
||||||
// Check again, there could be precision error in float operations,
|
// Check again, there could be precision error in float operations,
|
||||||
|
|||||||
@@ -117,31 +117,49 @@ func TestRollupIderivDuplicateTimestamps(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRemoveCounterResets(t *testing.T) {
|
func TestRemoveCounterResets(t *testing.T) {
|
||||||
removeCounterResets(nil)
|
removeCounterResets(nil, nil, 0)
|
||||||
|
|
||||||
values := append([]float64{}, testValues...)
|
values := append([]float64{}, testValues...)
|
||||||
removeCounterResets(values)
|
timestamps := append([]int64{}, testTimestamps...)
|
||||||
|
removeCounterResets(values, timestamps, 0)
|
||||||
valuesExpected := []float64{123, 157, 167, 188, 221, 255, 320, 332, 364, 396, 398, 398}
|
valuesExpected := []float64{123, 157, 167, 188, 221, 255, 320, 332, 364, 396, 398, 398}
|
||||||
testRowsEqual(t, values, testTimestamps, valuesExpected, testTimestamps)
|
testRowsEqual(t, values, testTimestamps, valuesExpected, testTimestamps)
|
||||||
|
|
||||||
// removeCounterResets doesn't expect negative values, so it doesn't work properly with them.
|
// removeCounterResets doesn't expect negative values, so it doesn't work properly with them.
|
||||||
values = []float64{-100, -200, -300, -400}
|
values = []float64{-100, -200, -300, -400}
|
||||||
removeCounterResets(values)
|
|
||||||
valuesExpected = []float64{-100, -100, -100, -100}
|
|
||||||
timestampsExpected := []int64{0, 1, 2, 3}
|
timestampsExpected := []int64{0, 1, 2, 3}
|
||||||
|
removeCounterResets(values, timestampsExpected, 0)
|
||||||
|
valuesExpected = []float64{-100, -100, -100, -100}
|
||||||
testRowsEqual(t, values, timestampsExpected, valuesExpected, timestampsExpected)
|
testRowsEqual(t, values, timestampsExpected, valuesExpected, timestampsExpected)
|
||||||
|
|
||||||
// verify how partial counter reset is handled.
|
// verify how partial counter reset is handled.
|
||||||
// See https://github.com/VictoriaMetrics/VictoriaMetrics/issues/2787
|
// See https://github.com/VictoriaMetrics/VictoriaMetrics/issues/2787
|
||||||
values = []float64{100, 95, 120, 119, 139, 50}
|
values = []float64{100, 95, 120, 119, 139, 50}
|
||||||
removeCounterResets(values)
|
|
||||||
valuesExpected = []float64{100, 100, 125, 125, 145, 195}
|
|
||||||
timestampsExpected = []int64{0, 1, 2, 3, 4, 5}
|
timestampsExpected = []int64{0, 1, 2, 3, 4, 5}
|
||||||
|
removeCounterResets(values, timestampsExpected, 0)
|
||||||
|
valuesExpected = []float64{100, 100, 125, 125, 145, 195}
|
||||||
testRowsEqual(t, values, timestampsExpected, valuesExpected, timestampsExpected)
|
testRowsEqual(t, values, timestampsExpected, valuesExpected, timestampsExpected)
|
||||||
|
|
||||||
|
// verify that staleness interval is respected during resets
|
||||||
|
// see https://github.com/VictoriaMetrics/VictoriaMetrics/issues/8072
|
||||||
|
values = []float64{10, 12, 14, 4, 6, 8, 6, 8, 4, 6}
|
||||||
|
timestamps = []int64{10, 20, 30, 60, 70, 80, 90, 100, 120, 130}
|
||||||
|
valuesExpected = []float64{10, 12, 14, 4, 6, 8, 14, 16, 4, 6}
|
||||||
|
removeCounterResets(values, timestamps, 10)
|
||||||
|
testRowsEqual(t, values, timestamps, valuesExpected, timestamps)
|
||||||
|
|
||||||
|
// verify that staleness is respected if there was no counter reset
|
||||||
|
// but correction was made previously
|
||||||
|
values = []float64{10, 12, 2, 4}
|
||||||
|
timestamps = []int64{10, 20, 30, 60}
|
||||||
|
valuesExpected = []float64{10, 12, 14, 4}
|
||||||
|
removeCounterResets(values, timestamps, 10)
|
||||||
|
testRowsEqual(t, values, timestamps, valuesExpected, timestamps)
|
||||||
|
|
||||||
// verify results always increase monotonically with possible float operations precision error
|
// verify results always increase monotonically with possible float operations precision error
|
||||||
values = []float64{34.094223, 2.7518, 2.140669, 0.044878, 1.887095, 2.546569, 2.490149, 0.045, 0.035684, 0.062454, 0.058296}
|
values = []float64{34.094223, 2.7518, 2.140669, 0.044878, 1.887095, 2.546569, 2.490149, 0.045, 0.035684, 0.062454, 0.058296}
|
||||||
removeCounterResets(values)
|
timestampsExpected = []int64{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
|
||||||
|
removeCounterResets(values, timestampsExpected, 0)
|
||||||
var prev float64
|
var prev float64
|
||||||
for i, v := range values {
|
for i, v := range values {
|
||||||
if v < prev {
|
if v < prev {
|
||||||
@@ -166,7 +184,7 @@ func TestDeltaValues(t *testing.T) {
|
|||||||
|
|
||||||
// remove counter resets
|
// remove counter resets
|
||||||
values = append([]float64{}, testValues...)
|
values = append([]float64{}, testValues...)
|
||||||
removeCounterResets(values)
|
removeCounterResets(values, testTimestamps, 0)
|
||||||
deltaValues(values)
|
deltaValues(values)
|
||||||
valuesExpected = []float64{34, 10, 21, 33, 34, 65, 12, 32, 32, 2, 0, 0}
|
valuesExpected = []float64{34, 10, 21, 33, 34, 65, 12, 32, 32, 2, 0, 0}
|
||||||
testRowsEqual(t, values, testTimestamps, valuesExpected, testTimestamps)
|
testRowsEqual(t, values, testTimestamps, valuesExpected, testTimestamps)
|
||||||
@@ -188,7 +206,7 @@ func TestDerivValues(t *testing.T) {
|
|||||||
|
|
||||||
// remove counter resets
|
// remove counter resets
|
||||||
values = append([]float64{}, testValues...)
|
values = append([]float64{}, testValues...)
|
||||||
removeCounterResets(values)
|
removeCounterResets(values, testTimestamps, 0)
|
||||||
derivValues(values, testTimestamps)
|
derivValues(values, testTimestamps)
|
||||||
valuesExpected = []float64{3400, 1111.111111111111, 1750, 2538.4615384615386, 3090.909090909091, 3611.1111111111113,
|
valuesExpected = []float64{3400, 1111.111111111111, 1750, 2538.4615384615386, 3090.909090909091, 3611.1111111111113,
|
||||||
6000, 1882.3529411764705, 1777.7777777777778, 400, 0, 0}
|
6000, 1882.3529411764705, 1777.7777777777778, 400, 0, 0}
|
||||||
@@ -219,7 +237,7 @@ func testRollupFunc(t *testing.T, funcName string, args []any, vExpected float64
|
|||||||
rfa.timestamps = append(rfa.timestamps, testTimestamps...)
|
rfa.timestamps = append(rfa.timestamps, testTimestamps...)
|
||||||
rfa.window = rfa.timestamps[len(rfa.timestamps)-1] - rfa.timestamps[0]
|
rfa.window = rfa.timestamps[len(rfa.timestamps)-1] - rfa.timestamps[0]
|
||||||
if rollupFuncsRemoveCounterResets[funcName] {
|
if rollupFuncsRemoveCounterResets[funcName] {
|
||||||
removeCounterResets(rfa.values)
|
removeCounterResets(rfa.values, rfa.timestamps, 0)
|
||||||
}
|
}
|
||||||
for i := 0; i < 5; i++ {
|
for i := 0; i < 5; i++ {
|
||||||
v := rf(&rfa)
|
v := rf(&rfa)
|
||||||
@@ -1590,17 +1608,60 @@ func TestRollupDelta(t *testing.T) {
|
|||||||
f(100, nan, nan, nil, 0)
|
f(100, nan, nan, nil, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRollupIncreaseWithStaleness(t *testing.T) {
|
func TestRollupDeltaWithStaleness(t *testing.T) {
|
||||||
// there is a gap between samples in the dataset below
|
// there is a gap between samples in the dataset below
|
||||||
timestamps := []int64{0, 15000, 30000, 70000}
|
timestamps := []int64{0, 15000, 30000, 70000}
|
||||||
values := []float64{1, 1, 1, 1}
|
values := []float64{1, 1, 1, 1}
|
||||||
|
|
||||||
t.Run("step > gap", func(t *testing.T) {
|
// if step > gap, then delta will always respect value before gap
|
||||||
|
t.Run("step>gap", func(t *testing.T) {
|
||||||
rc := rollupConfig{
|
rc := rollupConfig{
|
||||||
Func: rollupDelta,
|
Func: rollupDelta,
|
||||||
Start: 0,
|
Start: 0,
|
||||||
End: 70000,
|
End: 70000,
|
||||||
Step: 35000,
|
Step: 45000,
|
||||||
|
Window: 0,
|
||||||
|
MaxPointsPerSeries: 1e4,
|
||||||
|
}
|
||||||
|
rc.Timestamps = rc.getTimestamps()
|
||||||
|
gotValues, samplesScanned := rc.Do(nil, values, timestamps)
|
||||||
|
if samplesScanned != 7 {
|
||||||
|
t.Fatalf("expecting 8 samplesScanned from rollupConfig.Do; got %d", samplesScanned)
|
||||||
|
}
|
||||||
|
valuesExpected := []float64{1, 0}
|
||||||
|
timestampsExpected := []int64{0, 45e3}
|
||||||
|
testRowsEqual(t, gotValues, rc.Timestamps, valuesExpected, timestampsExpected)
|
||||||
|
})
|
||||||
|
// even if LookbackDelta < gap
|
||||||
|
t.Run("step>gap;LookbackDelta<gap", func(t *testing.T) {
|
||||||
|
rc := rollupConfig{
|
||||||
|
Func: rollupDelta,
|
||||||
|
Start: 0,
|
||||||
|
End: 70000,
|
||||||
|
Step: 45000,
|
||||||
|
LookbackDelta: 10e3,
|
||||||
|
Window: 0,
|
||||||
|
MaxPointsPerSeries: 1e4,
|
||||||
|
}
|
||||||
|
rc.Timestamps = rc.getTimestamps()
|
||||||
|
gotValues, samplesScanned := rc.Do(nil, values, timestamps)
|
||||||
|
if samplesScanned != 7 {
|
||||||
|
t.Fatalf("expecting 8 samplesScanned from rollupConfig.Do; got %d", samplesScanned)
|
||||||
|
}
|
||||||
|
valuesExpected := []float64{1, 0}
|
||||||
|
timestampsExpected := []int64{0, 45e3}
|
||||||
|
testRowsEqual(t, gotValues, rc.Timestamps, valuesExpected, timestampsExpected)
|
||||||
|
})
|
||||||
|
|
||||||
|
// if step < gap and LookbackDelta==0 then delta will always respect value before gap
|
||||||
|
// as LookbackDelta=0 ignores staleness
|
||||||
|
t.Run("step<gap;LookbackDelta=0", func(t *testing.T) {
|
||||||
|
rc := rollupConfig{
|
||||||
|
Func: rollupDelta,
|
||||||
|
Start: 0,
|
||||||
|
End: 70000,
|
||||||
|
Step: 10000,
|
||||||
|
LookbackDelta: 0,
|
||||||
Window: 0,
|
Window: 0,
|
||||||
MaxPointsPerSeries: 1e4,
|
MaxPointsPerSeries: 1e4,
|
||||||
}
|
}
|
||||||
@@ -1609,12 +1670,14 @@ func TestRollupIncreaseWithStaleness(t *testing.T) {
|
|||||||
if samplesScanned != 8 {
|
if samplesScanned != 8 {
|
||||||
t.Fatalf("expecting 8 samplesScanned from rollupConfig.Do; got %d", samplesScanned)
|
t.Fatalf("expecting 8 samplesScanned from rollupConfig.Do; got %d", samplesScanned)
|
||||||
}
|
}
|
||||||
valuesExpected := []float64{1, 0, 0}
|
valuesExpected := []float64{1, 0, 0, 0, 0, 0, 0, 0}
|
||||||
timestampsExpected := []int64{0, 35e3, 70e3}
|
timestampsExpected := []int64{0, 10e3, 20e3, 30e3, 40e3, 50e3, 60e3, 70e3}
|
||||||
testRowsEqual(t, gotValues, rc.Timestamps, valuesExpected, timestampsExpected)
|
testRowsEqual(t, gotValues, rc.Timestamps, valuesExpected, timestampsExpected)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("step < gap", func(t *testing.T) {
|
// if step < gap and LookbackDelta>0 then delta will respect value before gap
|
||||||
|
// only if it is not stale according to LookbackDelta
|
||||||
|
t.Run("step<gap;LookbackDelta>0", func(t *testing.T) {
|
||||||
rc := rollupConfig{
|
rc := rollupConfig{
|
||||||
Func: rollupDelta,
|
Func: rollupDelta,
|
||||||
Start: 0,
|
Start: 0,
|
||||||
@@ -1622,6 +1685,7 @@ func TestRollupIncreaseWithStaleness(t *testing.T) {
|
|||||||
Step: 10000,
|
Step: 10000,
|
||||||
Window: 0,
|
Window: 0,
|
||||||
MaxPointsPerSeries: 1e4,
|
MaxPointsPerSeries: 1e4,
|
||||||
|
LookbackDelta: 30e3,
|
||||||
}
|
}
|
||||||
rc.Timestamps = rc.getTimestamps()
|
rc.Timestamps = rc.getTimestamps()
|
||||||
gotValues, samplesScanned := rc.Do(nil, values, timestamps)
|
gotValues, samplesScanned := rc.Do(nil, values, timestamps)
|
||||||
@@ -1656,3 +1720,116 @@ func TestRollupIncreaseWithStaleness(t *testing.T) {
|
|||||||
testRowsEqual(t, gotValues, rc.Timestamps, valuesExpected, timestampsExpected)
|
testRowsEqual(t, gotValues, rc.Timestamps, valuesExpected, timestampsExpected)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRollupIncreasePureWithStaleness(t *testing.T) {
|
||||||
|
// there is a gap between samples in the dataset below
|
||||||
|
timestamps := []int64{0, 15000, 30000, 70000}
|
||||||
|
values := []float64{1, 1, 1, 1}
|
||||||
|
|
||||||
|
// if step > gap, then delta will always respect value before gap
|
||||||
|
t.Run("step>gap", func(t *testing.T) {
|
||||||
|
rc := rollupConfig{
|
||||||
|
Func: rollupIncreasePure,
|
||||||
|
Start: 0,
|
||||||
|
End: 70000,
|
||||||
|
Step: 45000,
|
||||||
|
Window: 0,
|
||||||
|
MaxPointsPerSeries: 1e4,
|
||||||
|
}
|
||||||
|
rc.Timestamps = rc.getTimestamps()
|
||||||
|
gotValues, samplesScanned := rc.Do(nil, values, timestamps)
|
||||||
|
if samplesScanned != 7 {
|
||||||
|
t.Fatalf("expecting 8 samplesScanned from rollupConfig.Do; got %d", samplesScanned)
|
||||||
|
}
|
||||||
|
valuesExpected := []float64{1, 0}
|
||||||
|
timestampsExpected := []int64{0, 45e3}
|
||||||
|
testRowsEqual(t, gotValues, rc.Timestamps, valuesExpected, timestampsExpected)
|
||||||
|
})
|
||||||
|
// even if LookbackDelta < gap
|
||||||
|
t.Run("step>gap;LookbackDelta<gap", func(t *testing.T) {
|
||||||
|
rc := rollupConfig{
|
||||||
|
Func: rollupIncreasePure,
|
||||||
|
Start: 0,
|
||||||
|
End: 70000,
|
||||||
|
Step: 45000,
|
||||||
|
LookbackDelta: 10e3,
|
||||||
|
Window: 0,
|
||||||
|
MaxPointsPerSeries: 1e4,
|
||||||
|
}
|
||||||
|
rc.Timestamps = rc.getTimestamps()
|
||||||
|
gotValues, samplesScanned := rc.Do(nil, values, timestamps)
|
||||||
|
if samplesScanned != 7 {
|
||||||
|
t.Fatalf("expecting 8 samplesScanned from rollupConfig.Do; got %d", samplesScanned)
|
||||||
|
}
|
||||||
|
valuesExpected := []float64{1, 0}
|
||||||
|
timestampsExpected := []int64{0, 45e3}
|
||||||
|
testRowsEqual(t, gotValues, rc.Timestamps, valuesExpected, timestampsExpected)
|
||||||
|
})
|
||||||
|
|
||||||
|
// if step < gap and LookbackDelta==0 then delta will always respect value before gap
|
||||||
|
// as LookbackDelta=0 ignores staleness
|
||||||
|
t.Run("step<gap;LookbackDelta=0", func(t *testing.T) {
|
||||||
|
rc := rollupConfig{
|
||||||
|
Func: rollupIncreasePure,
|
||||||
|
Start: 0,
|
||||||
|
End: 70000,
|
||||||
|
Step: 10000,
|
||||||
|
LookbackDelta: 0,
|
||||||
|
Window: 0,
|
||||||
|
MaxPointsPerSeries: 1e4,
|
||||||
|
}
|
||||||
|
rc.Timestamps = rc.getTimestamps()
|
||||||
|
gotValues, samplesScanned := rc.Do(nil, values, timestamps)
|
||||||
|
if samplesScanned != 8 {
|
||||||
|
t.Fatalf("expecting 8 samplesScanned from rollupConfig.Do; got %d", samplesScanned)
|
||||||
|
}
|
||||||
|
valuesExpected := []float64{1, 0, 0, 0, 0, 0, 0, 0}
|
||||||
|
timestampsExpected := []int64{0, 10e3, 20e3, 30e3, 40e3, 50e3, 60e3, 70e3}
|
||||||
|
testRowsEqual(t, gotValues, rc.Timestamps, valuesExpected, timestampsExpected)
|
||||||
|
})
|
||||||
|
|
||||||
|
// if step < gap and LookbackDelta>0 then delta will respect value before gap
|
||||||
|
// only if it is not stale according to LookbackDelta
|
||||||
|
t.Run("step<gap;LookbackDelta>0", func(t *testing.T) {
|
||||||
|
rc := rollupConfig{
|
||||||
|
Func: rollupIncreasePure,
|
||||||
|
Start: 0,
|
||||||
|
End: 70000,
|
||||||
|
Step: 10000,
|
||||||
|
Window: 0,
|
||||||
|
MaxPointsPerSeries: 1e4,
|
||||||
|
LookbackDelta: 30e3,
|
||||||
|
}
|
||||||
|
rc.Timestamps = rc.getTimestamps()
|
||||||
|
gotValues, samplesScanned := rc.Do(nil, values, timestamps)
|
||||||
|
if samplesScanned != 8 {
|
||||||
|
t.Fatalf("expecting 8 samplesScanned from rollupConfig.Do; got %d", samplesScanned)
|
||||||
|
}
|
||||||
|
valuesExpected := []float64{1, 0, 0, 0, 0, 0, 0, 1}
|
||||||
|
timestampsExpected := []int64{0, 10e3, 20e3, 30e3, 40e3, 50e3, 60e3, 70e3}
|
||||||
|
testRowsEqual(t, gotValues, rc.Timestamps, valuesExpected, timestampsExpected)
|
||||||
|
})
|
||||||
|
|
||||||
|
// there is a staleness marker between samples in the dataset below
|
||||||
|
timestamps = []int64{0, 10000, 20000, 30000, 40000}
|
||||||
|
values = []float64{1, 1, 1, decimal.StaleNaN, 1}
|
||||||
|
|
||||||
|
t.Run("staleness marker", func(t *testing.T) {
|
||||||
|
rc := rollupConfig{
|
||||||
|
Func: rollupIncreasePure,
|
||||||
|
Start: 0,
|
||||||
|
End: 40000,
|
||||||
|
Step: 10000,
|
||||||
|
Window: 0,
|
||||||
|
MaxPointsPerSeries: 1e4,
|
||||||
|
}
|
||||||
|
rc.Timestamps = rc.getTimestamps()
|
||||||
|
gotValues, samplesScanned := rc.Do(nil, values, timestamps)
|
||||||
|
if samplesScanned != 10 {
|
||||||
|
t.Fatalf("expecting 10 samplesScanned from rollupConfig.Do; got %d", samplesScanned)
|
||||||
|
}
|
||||||
|
valuesExpected := []float64{1, 0, 0, nan, 1}
|
||||||
|
timestampsExpected := []int64{0, 10e3, 20e3, 30e3, 40e3}
|
||||||
|
testRowsEqual(t, gotValues, rc.Timestamps, valuesExpected, timestampsExpected)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
|
|
||||||
var (
|
var (
|
||||||
maxExportDuration = flag.Duration("search.maxExportDuration", time.Hour*24*30, "The maximum duration for /api/v1/export call")
|
maxExportDuration = flag.Duration("search.maxExportDuration", time.Hour*24*30, "The maximum duration for /api/v1/export call")
|
||||||
|
maxDeleteDuration = flag.Duration("search.maxDeleteDuration", time.Minute*5, "The maximum duration for /api/v1/admin/tsdb/delete_series call")
|
||||||
maxQueryDuration = flag.Duration("search.maxQueryDuration", time.Second*30, "The maximum duration for query execution. It can be overridden to a smaller value on a per-query basis via 'timeout' query arg")
|
maxQueryDuration = flag.Duration("search.maxQueryDuration", time.Second*30, "The maximum duration for query execution. It can be overridden to a smaller value on a per-query basis via 'timeout' query arg")
|
||||||
maxStatusRequestDuration = flag.Duration("search.maxStatusRequestDuration", time.Minute*5, "The maximum duration for /api/v1/status/* requests")
|
maxStatusRequestDuration = flag.Duration("search.maxStatusRequestDuration", time.Minute*5, "The maximum duration for /api/v1/status/* requests")
|
||||||
maxLabelsAPIDuration = flag.Duration("search.maxLabelsAPIDuration", time.Second*5, "The maximum duration for /api/v1/labels, /api/v1/label/.../values and /api/v1/series requests. "+
|
maxLabelsAPIDuration = flag.Duration("search.maxLabelsAPIDuration", time.Second*5, "The maximum duration for /api/v1/labels, /api/v1/label/.../values and /api/v1/series requests. "+
|
||||||
@@ -58,6 +59,12 @@ func GetDeadlineForLabelsAPI(r *http.Request, startTime time.Time) Deadline {
|
|||||||
return getDeadlineWithMaxDuration(r, startTime, dMax, "-search.maxLabelsAPIDuration")
|
return getDeadlineWithMaxDuration(r, startTime, dMax, "-search.maxLabelsAPIDuration")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetDeadlineForDelete returns deadline for the given request to /api/v1/admin/tsdb/delete_series.
|
||||||
|
func GetDeadlineForDelete(r *http.Request, startTime time.Time) Deadline {
|
||||||
|
dMax := maxDeleteDuration.Milliseconds()
|
||||||
|
return getDeadlineWithMaxDuration(r, startTime, dMax, "-search.maxDeleteDuration")
|
||||||
|
}
|
||||||
|
|
||||||
func getDeadlineWithMaxDuration(r *http.Request, startTime time.Time, dMax int64, flagHint string) Deadline {
|
func getDeadlineWithMaxDuration(r *http.Request, startTime time.Time, dMax int64, flagHint string) Deadline {
|
||||||
d, err := httputils.GetDuration(r, "timeout", 0)
|
d, err := httputils.GetDuration(r, "timeout", 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -99,7 +99,8 @@ func TestParseMetricSelectorSuccess(t *testing.T) {
|
|||||||
f(`{foo="bar"}`)
|
f(`{foo="bar"}`)
|
||||||
f(`{:f:oo=~"bar.+"}`)
|
f(`{:f:oo=~"bar.+"}`)
|
||||||
f(`foo {bar != "baz"}`)
|
f(`foo {bar != "baz"}`)
|
||||||
f(` foo { bar !~ "^ddd(x+)$", a="ss", __name__="sffd"} `)
|
f(` { bar !~ "^ddd(x+)$", a="ss", __name__="sffd"} `)
|
||||||
|
f(` { bar !~ "^ddd(x+)$", a="ss", "foo"} `)
|
||||||
f(`(foo)`)
|
f(`(foo)`)
|
||||||
f(`\п\р\и\в\е\т{\ы="111"}`)
|
f(`\п\р\и\в\е\т{\ы="111"}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
{
|
{
|
||||||
"files": {
|
"files": {
|
||||||
"main.css": "./static/css/main.63479b72.css",
|
"main.css": "./static/css/main.7fa18e1b.css",
|
||||||
"main.js": "./static/js/main.256ee243.js",
|
"main.js": "./static/js/main.ba08300f.js",
|
||||||
"static/js/685.f772060c.chunk.js": "./static/js/685.f772060c.chunk.js",
|
"static/js/685.f772060c.chunk.js": "./static/js/685.f772060c.chunk.js",
|
||||||
"static/media/MetricsQL.md": "./static/media/MetricsQL.a00044c91d9781cf8557.md",
|
"static/media/MetricsQL.md": "./static/media/MetricsQL.a00044c91d9781cf8557.md",
|
||||||
"index.html": "./index.html"
|
"index.html": "./index.html"
|
||||||
},
|
},
|
||||||
"entrypoints": [
|
"entrypoints": [
|
||||||
"static/css/main.63479b72.css",
|
"static/css/main.7fa18e1b.css",
|
||||||
"static/js/main.256ee243.js"
|
"static/js/main.ba08300f.js"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1 +1 @@
|
|||||||
<!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="icon" href="./favicon.svg"/><link rel="apple-touch-icon" href="./favicon.svg"/><link rel="mask-icon" href="./favicon.svg" color="#000000"><meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=5"/><meta name="theme-color" content="#000000"/><meta name="description" content="Explore and troubleshoot your VictoriaMetrics data"/><link rel="manifest" href="./manifest.json"/><title>vmui</title><script src="./dashboards/index.js" type="module"></script><meta name="twitter:card" content="summary"><meta name="twitter:title" content="UI for VictoriaMetrics"><meta name="twitter:site" content="@https://victoriametrics.com/"><meta name="twitter:description" content="Explore and troubleshoot your VictoriaMetrics data"><meta name="twitter:image" content="./preview.jpg"><meta property="og:type" content="website"><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 defer="defer" src="./static/js/main.256ee243.js"></script><link href="./static/css/main.63479b72.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>
|
<!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="icon" href="./favicon.svg"/><link rel="apple-touch-icon" href="./favicon.svg"/><link rel="mask-icon" href="./favicon.svg" color="#000000"><meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=5"/><meta name="theme-color" content="#000000"/><meta name="description" content="Explore and troubleshoot your VictoriaMetrics data"/><link rel="manifest" href="./manifest.json"/><title>vmui</title><script src="./dashboards/index.js" type="module"></script><meta name="twitter:card" content="summary"><meta name="twitter:title" content="UI for VictoriaMetrics"><meta name="twitter:site" content="@https://victoriametrics.com/"><meta name="twitter:description" content="Explore and troubleshoot your VictoriaMetrics data"><meta name="twitter:image" content="./preview.jpg"><meta property="og:type" content="website"><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 defer="defer" src="./static/js/main.ba08300f.js"></script><link href="./static/css/main.7fa18e1b.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>
|
||||||
File diff suppressed because one or more lines are too long
1
app/vmselect/vmui/static/css/main.7fa18e1b.css
Normal file
1
app/vmselect/vmui/static/css/main.7fa18e1b.css
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
2
app/vmselect/vmui/static/js/main.ba08300f.js
Normal file
2
app/vmselect/vmui/static/js/main.ba08300f.js
Normal file
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
|
|||||||
FROM golang:1.23.4 AS build-web-stage
|
FROM golang:1.23.6 AS build-web-stage
|
||||||
COPY build /build
|
COPY build /build
|
||||||
|
|
||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
@@ -6,7 +6,7 @@ COPY web/ /build/
|
|||||||
RUN GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o web-amd64 github.com/VictoriMetrics/vmui/ && \
|
RUN GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o web-amd64 github.com/VictoriMetrics/vmui/ && \
|
||||||
GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -o web-windows github.com/VictoriMetrics/vmui/
|
GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -o web-windows github.com/VictoriMetrics/vmui/
|
||||||
|
|
||||||
FROM alpine:3.21.0
|
FROM alpine:3.21.2
|
||||||
USER root
|
USER root
|
||||||
|
|
||||||
COPY --from=build-web-stage /build/web-amd64 /app/web
|
COPY --from=build-web-stage /build/web-amd64 /app/web
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import uPlot from "uplot";
|
||||||
|
import { ReactNode } from "react";
|
||||||
|
|
||||||
export interface MetricBase {
|
export interface MetricBase {
|
||||||
group: number;
|
group: number;
|
||||||
metric: {
|
metric: {
|
||||||
@@ -6,13 +9,13 @@ export interface MetricBase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface MetricResult extends MetricBase {
|
export interface MetricResult extends MetricBase {
|
||||||
values: [number, string][]
|
values: [number, string][];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export interface InstantMetricResult extends MetricBase {
|
export interface InstantMetricResult extends MetricBase {
|
||||||
value?: [number, string]
|
value?: [number, string];
|
||||||
values?: [number, string][]
|
values?: [number, string][];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExportMetricResult extends MetricBase {
|
export interface ExportMetricResult extends MetricBase {
|
||||||
@@ -43,10 +46,24 @@ export interface Logs {
|
|||||||
export interface LogHits {
|
export interface LogHits {
|
||||||
timestamps: string[];
|
timestamps: string[];
|
||||||
values: number[];
|
values: number[];
|
||||||
total?: number;
|
total: number;
|
||||||
fields: {
|
fields: { [key: string]: string; };
|
||||||
[key: string]: string;
|
_isOther: boolean;
|
||||||
};
|
}
|
||||||
|
|
||||||
|
export interface LegendLogHits {
|
||||||
|
label: string;
|
||||||
|
total: number;
|
||||||
|
totalHits: number;
|
||||||
|
isOther: boolean;
|
||||||
|
fields: { [key: string]: string; };
|
||||||
|
stroke?: uPlot.Series.Stroke;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LegendLogHitsMenu {
|
||||||
|
title: string;
|
||||||
|
icon?: ReactNode;
|
||||||
|
handler?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReportMetaData {
|
export interface ReportMetaData {
|
||||||
|
|||||||
@@ -1,22 +1,23 @@
|
|||||||
import React, { FC, useMemo, useRef, useState } from "preact/compat";
|
import React, { FC, useCallback, useMemo, useRef, useState } from "preact/compat";
|
||||||
import "./style.scss";
|
import "./style.scss";
|
||||||
import "uplot/dist/uPlot.min.css";
|
import "uplot/dist/uPlot.min.css";
|
||||||
import useElementSize from "../../../hooks/useElementSize";
|
import useElementSize from "../../../hooks/useElementSize";
|
||||||
import uPlot, { AlignedData } from "uplot";
|
import uPlot, { AlignedData } from "uplot";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import useBarHitsOptions from "./hooks/useBarHitsOptions";
|
import useBarHitsOptions, { getLabelFromLogHit } from "./hooks/useBarHitsOptions";
|
||||||
import BarHitsTooltip from "./BarHitsTooltip/BarHitsTooltip";
|
import BarHitsTooltip from "./BarHitsTooltip/BarHitsTooltip";
|
||||||
import { TimeParams } from "../../../types";
|
import { TimeParams } from "../../../types";
|
||||||
import usePlotScale from "../../../hooks/uplot/usePlotScale";
|
import usePlotScale from "../../../hooks/uplot/usePlotScale";
|
||||||
import useReadyChart from "../../../hooks/uplot/useReadyChart";
|
import useReadyChart from "../../../hooks/uplot/useReadyChart";
|
||||||
import useZoomChart from "../../../hooks/uplot/useZoomChart";
|
import useZoomChart from "../../../hooks/uplot/useZoomChart";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { LogHits } from "../../../api/types";
|
import { LegendLogHits, LogHits } from "../../../api/types";
|
||||||
import { addSeries, delSeries, setBand } from "../../../utils/uplot";
|
import { addSeries, delSeries, setBand } from "../../../utils/uplot";
|
||||||
import { GraphOptions, GRAPH_STYLES } from "./types";
|
import { GraphOptions, GRAPH_STYLES } from "./types";
|
||||||
import BarHitsOptions from "./BarHitsOptions/BarHitsOptions";
|
import BarHitsOptions from "./BarHitsOptions/BarHitsOptions";
|
||||||
import stack from "../../../utils/uplot/stack";
|
import stack from "../../../utils/uplot/stack";
|
||||||
import BarHitsLegend from "./BarHitsLegend/BarHitsLegend";
|
import BarHitsLegend from "./BarHitsLegend/BarHitsLegend";
|
||||||
|
import { calculateTotalHits, sortLogHits } from "../../../utils/logs";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
logHits: LogHits[];
|
logHits: LogHits[];
|
||||||
@@ -57,6 +58,29 @@ const BarHitsChart: FC<Props> = ({ logHits, data: _data, period, setPeriod, onAp
|
|||||||
graphOptions
|
graphOptions
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const prepareLegend = useCallback((hits: LogHits[], totalHits: number): LegendLogHits[] => {
|
||||||
|
return hits.map((hit) => {
|
||||||
|
const label = getLabelFromLogHit(hit);
|
||||||
|
|
||||||
|
const legendItem: LegendLogHits = {
|
||||||
|
label,
|
||||||
|
isOther: hit._isOther,
|
||||||
|
fields: hit.fields,
|
||||||
|
total: hit.total || 0,
|
||||||
|
totalHits,
|
||||||
|
stroke: series.find((s) => s.label === label)?.stroke,
|
||||||
|
};
|
||||||
|
|
||||||
|
return legendItem;
|
||||||
|
}).sort(sortLogHits("total"));
|
||||||
|
}, [series]);
|
||||||
|
|
||||||
|
|
||||||
|
const legendDetails: LegendLogHits[] = useMemo(() => {
|
||||||
|
const totalHits = calculateTotalHits(logHits);
|
||||||
|
return prepareLegend(logHits, totalHits);
|
||||||
|
}, [logHits, prepareLegend]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!uPlotInst) return;
|
if (!uPlotInst) return;
|
||||||
delSeries(uPlotInst);
|
delSeries(uPlotInst);
|
||||||
@@ -121,6 +145,7 @@ const BarHitsChart: FC<Props> = ({ logHits, data: _data, period, setPeriod, onAp
|
|||||||
<BarHitsLegend
|
<BarHitsLegend
|
||||||
uPlotInst={uPlotInst}
|
uPlotInst={uPlotInst}
|
||||||
onApplyFilter={onApplyFilter}
|
onApplyFilter={onApplyFilter}
|
||||||
|
legendDetails={legendDetails}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,83 +1,53 @@
|
|||||||
import React, { FC, useCallback, useEffect, useState } from "preact/compat";
|
import React, { FC, useEffect, useState } from "preact/compat";
|
||||||
import uPlot, { Series } from "uplot";
|
import uPlot, { Series } from "uplot";
|
||||||
import "./style.scss";
|
import "./style.scss";
|
||||||
import "../../Line/Legend/style.scss";
|
import "../../Line/Legend/style.scss";
|
||||||
import classNames from "classnames";
|
import BarHitsLegendItem from "./BarHitsLegendItem";
|
||||||
import { MouseEvent } from "react";
|
import { LegendLogHits } from "../../../../api/types";
|
||||||
import { isMacOs } from "../../../../utils/detect-device";
|
|
||||||
import Tooltip from "../../../Main/Tooltip/Tooltip";
|
|
||||||
import { getStreamPairs } from "../../../../utils/logs";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
uPlotInst: uPlot;
|
uPlotInst: uPlot;
|
||||||
|
legendDetails: LegendLogHits[];
|
||||||
onApplyFilter: (value: string) => void;
|
onApplyFilter: (value: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BarHitsLegend: FC<Props> = ({ uPlotInst, onApplyFilter }) => {
|
const BarHitsLegend: FC<Props> = ({ uPlotInst, legendDetails, onApplyFilter }) => {
|
||||||
const [series, setSeries] = useState<Series[]>([]);
|
const [series, setSeries] = useState<Series[]>([]);
|
||||||
const [pairs, setPairs] = useState<string[][]>([]);
|
const totalHits = legendDetails[0]?.totalHits || 0;
|
||||||
|
|
||||||
const updateSeries = useCallback(() => {
|
const getSeries = () => {
|
||||||
const series = uPlotInst.series.filter(s => s.scale !== "x");
|
return uPlotInst.series.filter(s => s.scale !== "x");
|
||||||
setSeries(series);
|
};
|
||||||
setPairs(series.map(s => getStreamPairs(s.label || "")));
|
|
||||||
|
const handleRedrawGraph = () => {
|
||||||
|
uPlotInst.redraw();
|
||||||
|
setSeries(getSeries());
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSeries(getSeries());
|
||||||
}, [uPlotInst]);
|
}, [uPlotInst]);
|
||||||
|
|
||||||
const handleClickByValue = (value: string) => (e: MouseEvent<HTMLDivElement>) => {
|
|
||||||
const metaKey = e.metaKey || e.ctrlKey;
|
|
||||||
if (!metaKey) return;
|
|
||||||
onApplyFilter(`{${value}}` || "");
|
|
||||||
updateSeries();
|
|
||||||
uPlotInst.redraw();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClickByStream = (target: Series) => (e: MouseEvent<HTMLDivElement>) => {
|
|
||||||
const metaKey = e.metaKey || e.ctrlKey;
|
|
||||||
if (metaKey) return;
|
|
||||||
target.show = !target.show;
|
|
||||||
updateSeries();
|
|
||||||
uPlotInst.redraw();
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(updateSeries, [uPlotInst]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="vm-bar-hits-legend">
|
<div className="vm-bar-hits-legend">
|
||||||
{series.map((s, i) => (
|
{legendDetails.map((legend) => (
|
||||||
<Tooltip
|
<BarHitsLegendItem
|
||||||
key={s.label}
|
key={legend.label}
|
||||||
title={(
|
legend={legend}
|
||||||
<ul className="vm-bar-hits-legend-info">
|
series={series}
|
||||||
<li>Click to {s.show ? "hide" : "show"} the _stream.</li>
|
onRedrawGraph={handleRedrawGraph}
|
||||||
<li>{isMacOs() ? "Cmd" : "Ctrl"} + Click to filter by the _stream.</li>
|
onApplyFilter={onApplyFilter}
|
||||||
</ul>
|
/>
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={classNames({
|
|
||||||
"vm-bar-hits-legend-item": true,
|
|
||||||
"vm-bar-hits-legend-item_hide": !s.show,
|
|
||||||
})}
|
|
||||||
onClick={handleClickByStream(s)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="vm-bar-hits-legend-item__marker"
|
|
||||||
style={{ backgroundColor: `${(s?.stroke as () => string)?.()}` }}
|
|
||||||
/>
|
|
||||||
<div className="vm-bar-hits-legend-item-pairs">
|
|
||||||
{pairs[i].map(value => (
|
|
||||||
<span
|
|
||||||
className="vm-bar-hits-legend-item-pairs__value"
|
|
||||||
key={value}
|
|
||||||
onClick={handleClickByValue(value)}
|
|
||||||
>
|
|
||||||
{value}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Tooltip>
|
|
||||||
))}
|
))}
|
||||||
|
<div className="vm-bar-hits-legend-info">
|
||||||
|
<div>
|
||||||
|
Total hits: <b>{totalHits.toLocaleString("en-US")}</b>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<code>L-Click</code> toggles visibility.
|
||||||
|
<code>R-Click</code> opens menu.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import React, { FC, useMemo, useRef, useState } from "preact/compat";
|
||||||
|
import classNames from "classnames";
|
||||||
|
import { Series } from "uplot";
|
||||||
|
import { MouseEvent } from "react";
|
||||||
|
import { LegendLogHits } from "../../../../api/types";
|
||||||
|
import { getStreamPairs } from "../../../../utils/logs";
|
||||||
|
import { formatNumberShort } from "../../../../utils/math";
|
||||||
|
import Popper from "../../../Main/Popper/Popper";
|
||||||
|
import useBoolean from "../../../../hooks/useBoolean";
|
||||||
|
import LegendHitsMenu from "../LegendHitsMenu/LegendHitsMenu";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
legend: LegendLogHits;
|
||||||
|
series: Series[];
|
||||||
|
onRedrawGraph: () => void;
|
||||||
|
onApplyFilter: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BarHitsLegendItem: FC<Props> = ({ legend, series, onRedrawGraph, onApplyFilter }) => {
|
||||||
|
const {
|
||||||
|
value: openContextMenu,
|
||||||
|
setTrue: handleOpenContextMenu,
|
||||||
|
setFalse: handleCloseContextMenu,
|
||||||
|
} = useBoolean(false);
|
||||||
|
|
||||||
|
const legendRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [clickPosition, setClickPosition] = useState<{ top: number; left: number } | null>(null);
|
||||||
|
|
||||||
|
const targetSeries = useMemo(() => series.find(s => s.label === legend.label), [series]);
|
||||||
|
|
||||||
|
const fields = useMemo(() => getStreamPairs(legend.label), [legend.label]);
|
||||||
|
|
||||||
|
const label = fields.join(", ");
|
||||||
|
const totalShortFormatted = formatNumberShort(legend.total);
|
||||||
|
|
||||||
|
const handleClickByStream = (e: MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (!targetSeries) return;
|
||||||
|
|
||||||
|
if (e.metaKey || e.ctrlKey) {
|
||||||
|
targetSeries.show = !targetSeries.show;
|
||||||
|
} else {
|
||||||
|
const isOnlyTargetVisible = series.every(s => s === targetSeries || !s.show);
|
||||||
|
series.forEach(s => {
|
||||||
|
s.show = isOnlyTargetVisible || (s === targetSeries);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onRedrawGraph();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleContextMenu = (e: MouseEvent<HTMLDivElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setClickPosition({ top: e.clientY, left: e.clientX });
|
||||||
|
handleOpenContextMenu();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={legendRef}
|
||||||
|
className={classNames({
|
||||||
|
"vm-bar-hits-legend-item": true,
|
||||||
|
"vm-bar-hits-legend-item_other": legend.isOther,
|
||||||
|
"vm-bar-hits-legend-item_hide": !targetSeries?.show,
|
||||||
|
})}
|
||||||
|
onClick={handleClickByStream}
|
||||||
|
onContextMenu={handleContextMenu}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="vm-bar-hits-legend-item__marker"
|
||||||
|
style={{ backgroundColor: `${legend.stroke}` }}
|
||||||
|
/>
|
||||||
|
<div className="vm-bar-hits-legend-item__label">{label}</div>
|
||||||
|
<span className="vm-bar-hits-legend-item__total">({totalShortFormatted})</span>
|
||||||
|
<Popper
|
||||||
|
placement="fixed"
|
||||||
|
open={openContextMenu}
|
||||||
|
buttonRef={legendRef}
|
||||||
|
placementPosition={clickPosition}
|
||||||
|
onClose={handleCloseContextMenu}
|
||||||
|
>
|
||||||
|
<LegendHitsMenu
|
||||||
|
legend={legend}
|
||||||
|
fields={fields}
|
||||||
|
onApplyFilter={onApplyFilter}
|
||||||
|
onClose={handleCloseContextMenu}
|
||||||
|
/>
|
||||||
|
</Popper>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BarHitsLegendItem;
|
||||||
@@ -3,16 +3,16 @@
|
|||||||
.vm-bar-hits-legend {
|
.vm-bar-hits-legend {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: $padding-small;
|
|
||||||
padding: 0 $padding-small $padding-small;
|
padding: 0 $padding-small $padding-small;
|
||||||
|
color: $color-text;
|
||||||
|
|
||||||
&-item {
|
&-item {
|
||||||
display: grid;
|
max-width: 50%;
|
||||||
grid-template-columns: auto 1fr;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: $padding-small;
|
gap: $padding-small;
|
||||||
font-size: 12px;
|
font-size: $font-size-small;
|
||||||
padding: 0 $padding-small;
|
padding: $padding-small $padding-global;
|
||||||
border-radius: $border-radius-small;
|
border-radius: $border-radius-small;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: 0.2s;
|
transition: 0.2s;
|
||||||
@@ -27,34 +27,44 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
&__marker {
|
&__marker {
|
||||||
width: 14px;
|
min-width: 14px;
|
||||||
|
max-width: 14px;
|
||||||
height: 14px;
|
height: 14px;
|
||||||
border: $color-background-block;
|
border: $color-background-block;
|
||||||
}
|
}
|
||||||
|
|
||||||
&-pairs {
|
&__label {
|
||||||
display: flex;
|
white-space: nowrap;
|
||||||
gap: $padding-small;
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
&__value {
|
&__total {
|
||||||
padding: $padding-small 0;
|
color: $color-text-secondary;
|
||||||
|
font-style: italic;
|
||||||
&:hover {
|
grid-column: 2;
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:after {
|
|
||||||
content: ",";
|
|
||||||
}
|
|
||||||
|
|
||||||
&:last-child:after {
|
|
||||||
content: "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&-info {
|
&-info {
|
||||||
list-style-position: inside;
|
flex-grow: 1;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding-top: $padding-small;
|
||||||
|
color: $color-text-secondary;
|
||||||
|
font-size: $font-size-small;
|
||||||
|
|
||||||
|
code {
|
||||||
|
display: inline-block;
|
||||||
|
padding: calc($padding-small / 2) $padding-small;
|
||||||
|
font-size: $font-size-small;
|
||||||
|
text-align: center;
|
||||||
|
background-color: $color-background-body;
|
||||||
|
background-repeat: repeat-x;
|
||||||
|
border: $border-divider;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import "./style.scss";
|
|||||||
import useStateSearchParams from "../../../../hooks/useStateSearchParams";
|
import useStateSearchParams from "../../../../hooks/useStateSearchParams";
|
||||||
import { useSearchParams } from "react-router-dom";
|
import { useSearchParams } from "react-router-dom";
|
||||||
import Button from "../../../Main/Button/Button";
|
import Button from "../../../Main/Button/Button";
|
||||||
import classNames from "classnames";
|
|
||||||
import { SettingsIcon, VisibilityIcon, VisibilityOffIcon } from "../../../Main/Icons";
|
import { SettingsIcon, VisibilityIcon, VisibilityOffIcon } from "../../../Main/Icons";
|
||||||
import Tooltip from "../../../Main/Tooltip/Tooltip";
|
import Tooltip from "../../../Main/Tooltip/Tooltip";
|
||||||
import Popper from "../../../Main/Popper/Popper";
|
import Popper from "../../../Main/Popper/Popper";
|
||||||
@@ -24,27 +23,20 @@ const BarHitsOptions: FC<Props> = ({ onChange }) => {
|
|||||||
setFalse: handleCloseOptions,
|
setFalse: handleCloseOptions,
|
||||||
} = useBoolean(false);
|
} = useBoolean(false);
|
||||||
|
|
||||||
const [graphStyle, setGraphStyle] = useStateSearchParams(GRAPH_STYLES.LINE_STEPPED, "graph");
|
|
||||||
const [stacked, setStacked] = useStateSearchParams(false, "stacked");
|
const [stacked, setStacked] = useStateSearchParams(false, "stacked");
|
||||||
const [fill, setFill] = useStateSearchParams(false, "fill");
|
const [fill, setFill] = useStateSearchParams("true", "fill");
|
||||||
const [hideChart, setHideChart] = useStateSearchParams(false, "hide_chart");
|
const [hideChart, setHideChart] = useStateSearchParams(false, "hide_chart");
|
||||||
|
|
||||||
const options: GraphOptions = useMemo(() => ({
|
const options: GraphOptions = useMemo(() => ({
|
||||||
graphStyle,
|
graphStyle: GRAPH_STYLES.BAR,
|
||||||
stacked,
|
stacked,
|
||||||
fill,
|
fill: fill === "true",
|
||||||
hideChart,
|
hideChart,
|
||||||
}), [graphStyle, stacked, fill, hideChart]);
|
}), [stacked, fill, hideChart]);
|
||||||
|
|
||||||
const handleChangeGraphStyle = (val: string) => () => {
|
|
||||||
setGraphStyle(val as GRAPH_STYLES);
|
|
||||||
searchParams.set("graph", val);
|
|
||||||
setSearchParams(searchParams);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleChangeFill = (val: boolean) => {
|
const handleChangeFill = (val: boolean) => {
|
||||||
setFill(val);
|
setFill(`${val}`);
|
||||||
val ? searchParams.set("fill", "true") : searchParams.delete("fill");
|
searchParams.set("fill", `${val}`);
|
||||||
setSearchParams(searchParams);
|
setSearchParams(searchParams);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -97,21 +89,6 @@ const BarHitsOptions: FC<Props> = ({ onChange }) => {
|
|||||||
title={"Graph settings"}
|
title={"Graph settings"}
|
||||||
>
|
>
|
||||||
<div className="vm-bar-hits-options-settings">
|
<div className="vm-bar-hits-options-settings">
|
||||||
<div className="vm-bar-hits-options-settings-item vm-bar-hits-options-settings-item_list">
|
|
||||||
<p className="vm-bar-hits-options-settings-item__title">Graph style:</p>
|
|
||||||
{Object.values(GRAPH_STYLES).map(style => (
|
|
||||||
<div
|
|
||||||
key={style}
|
|
||||||
className={classNames({
|
|
||||||
"vm-list-item": true,
|
|
||||||
"vm-list-item_active": graphStyle === style,
|
|
||||||
})}
|
|
||||||
onClick={handleChangeGraphStyle(style)}
|
|
||||||
>
|
|
||||||
{style}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="vm-bar-hits-options-settings-item">
|
<div className="vm-bar-hits-options-settings-item">
|
||||||
<Switch
|
<Switch
|
||||||
label={"Stacked"}
|
label={"Stacked"}
|
||||||
@@ -122,7 +99,7 @@ const BarHitsOptions: FC<Props> = ({ onChange }) => {
|
|||||||
<div className="vm-bar-hits-options-settings-item">
|
<div className="vm-bar-hits-options-settings-item">
|
||||||
<Switch
|
<Switch
|
||||||
label={"Fill"}
|
label={"Fill"}
|
||||||
value={fill}
|
value={fill === "true"}
|
||||||
onChange={handleChangeFill}
|
onChange={handleChangeFill}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,12 +11,12 @@
|
|||||||
&-settings {
|
&-settings {
|
||||||
display: grid;
|
display: grid;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: $padding-global;
|
|
||||||
min-width: 200px;
|
min-width: 200px;
|
||||||
|
gap: $padding-global;
|
||||||
|
padding-bottom: $padding-global;
|
||||||
|
|
||||||
&-item {
|
&-item {
|
||||||
border-bottom: $border-divider;
|
padding: 0 $padding-global;
|
||||||
padding: 0 $padding-global $padding-global;
|
|
||||||
|
|
||||||
&_list {
|
&_list {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { DATE_TIME_FORMAT } from "../../../../constants/date";
|
|||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import "./style.scss";
|
import "./style.scss";
|
||||||
import "../../ChartTooltip/style.scss";
|
import "../../ChartTooltip/style.scss";
|
||||||
|
import { sortLogHits } from "../../../../utils/logs";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
data: AlignedData;
|
data: AlignedData;
|
||||||
@@ -26,7 +27,7 @@ const BarHitsTooltip: FC<Props> = ({ data, focusDataIdx, uPlotInst }) => {
|
|||||||
const tooltipItems = values.map((value, i) => {
|
const tooltipItems = values.map((value, i) => {
|
||||||
const targetSeries = series[i + 1];
|
const targetSeries = series[i + 1];
|
||||||
const stroke = (targetSeries?.stroke as () => string)?.();
|
const stroke = (targetSeries?.stroke as () => string)?.();
|
||||||
const label = targetSeries?.label || "other";
|
const label = targetSeries?.label;
|
||||||
const show = targetSeries?.show;
|
const show = targetSeries?.show;
|
||||||
return {
|
return {
|
||||||
label,
|
label,
|
||||||
@@ -34,7 +35,7 @@ const BarHitsTooltip: FC<Props> = ({ data, focusDataIdx, uPlotInst }) => {
|
|||||||
value,
|
value,
|
||||||
show
|
show
|
||||||
};
|
};
|
||||||
}).filter(item => item.value > 0 && item.show).sort((a, b) => b.value - a.value);
|
}).filter(item => item.value > 0 && item.show).sort(sortLogHits("value"));
|
||||||
|
|
||||||
const point = {
|
const point = {
|
||||||
top: tooltipItems[0] ? uPlotInst?.valToPos?.(tooltipItems[0].value, "y") || 0 : 0,
|
top: tooltipItems[0] ? uPlotInst?.valToPos?.(tooltipItems[0].value, "y") || 0 : 0,
|
||||||
@@ -104,21 +105,24 @@ const BarHitsTooltip: FC<Props> = ({ data, focusDataIdx, uPlotInst }) => {
|
|||||||
className="vm-chart-tooltip-data__marker"
|
className="vm-chart-tooltip-data__marker"
|
||||||
style={{ background: item.stroke }}
|
style={{ background: item.stroke }}
|
||||||
/>
|
/>
|
||||||
<p>
|
<p className="vm-bar-hits-tooltip-item">
|
||||||
{item.label}: <b>{item.value}</b>
|
<span className="vm-bar-hits-tooltip-item__label">{item.label}</span>
|
||||||
|
<span>{item.value.toLocaleString("en-US")}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{tooltipData.values.length > 1 && (
|
{tooltipData.values.length > 1 && (
|
||||||
<div className="vm-chart-tooltip-data">
|
<div className="vm-chart-tooltip-data">
|
||||||
<p>
|
<span/>
|
||||||
Total records: <b>{tooltipData.total}</b>
|
<p className="vm-bar-hits-tooltip-item">
|
||||||
|
<span className="vm-bar-hits-tooltip-item__label">Total</span>
|
||||||
|
<span>{tooltipData.total.toLocaleString("en-US")}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="vm-chart-tooltip-header">
|
<div className="vm-chart-tooltip-header">
|
||||||
<div className="vm-chart-tooltip-header__title">
|
<div className="vm-chart-tooltip-header__title vm-bar-hits-tooltip__date">
|
||||||
{tooltipData.timestamp}
|
{tooltipData.timestamp}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,4 +9,23 @@
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: $padding-global;
|
||||||
|
max-width: 100%;
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
display: inline-block;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__date {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import React, { FC } from "preact/compat";
|
||||||
|
import "./style.scss";
|
||||||
|
import { LegendLogHits } from "../../../../api/types";
|
||||||
|
import LegendHitsMenuStats from "./LegendHitsMenuStats";
|
||||||
|
import LegendHitsMenuBase from "./LegendHitsMenuBase";
|
||||||
|
import LegendHitsMenuRow from "./LegendHitsMenuRow";
|
||||||
|
import LegendHitsMenuFields from "./LegendHitsMenuFields";
|
||||||
|
import { LOGS_LIMIT_HITS } from "../../../../constants/logs";
|
||||||
|
|
||||||
|
const otherDescription = `aggregated results for fields not in the top ${LOGS_LIMIT_HITS}`;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
legend: LegendLogHits;
|
||||||
|
fields: string[];
|
||||||
|
onApplyFilter: (value: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LegendHitsMenu: FC<Props> = ({ legend, fields, onApplyFilter, onClose }) => {
|
||||||
|
return (
|
||||||
|
<div className="vm-legend-hits-menu">
|
||||||
|
<div className="vm-legend-hits-menu-section">
|
||||||
|
<LegendHitsMenuRow
|
||||||
|
className="vm-legend-hits-menu-row_info"
|
||||||
|
title={legend.isOther ? otherDescription : legend.label}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!legend.isOther && (
|
||||||
|
<LegendHitsMenuBase
|
||||||
|
legend={legend}
|
||||||
|
onApplyFilter={onApplyFilter}
|
||||||
|
onClose={onClose}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!legend.isOther && (
|
||||||
|
<LegendHitsMenuFields
|
||||||
|
fields={fields}
|
||||||
|
onApplyFilter={onApplyFilter}
|
||||||
|
onClose={onClose}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<LegendHitsMenuStats legend={legend}/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LegendHitsMenu;
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import React, { FC } from "preact/compat";
|
||||||
|
import LegendHitsMenuRow from "./LegendHitsMenuRow";
|
||||||
|
import useCopyToClipboard from "../../../../hooks/useCopyToClipboard";
|
||||||
|
import { CopyIcon, FilterIcon, FilterOffIcon } from "../../../Main/Icons";
|
||||||
|
import { LegendLogHits, LegendLogHitsMenu } from "../../../../api/types";
|
||||||
|
import { LOGS_GROUP_BY } from "../../../../constants/logs";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
legend: LegendLogHits;
|
||||||
|
onApplyFilter: (value: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LegendHitsMenuBase: FC<Props> = ({ legend, onApplyFilter, onClose }) => {
|
||||||
|
const copyToClipboard = useCopyToClipboard();
|
||||||
|
|
||||||
|
const handleAddStreamToFilter = () => {
|
||||||
|
onApplyFilter(`${LOGS_GROUP_BY}: ${legend.label}`);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExcludeStreamToFilter = () => {
|
||||||
|
onApplyFilter(`(NOT ${LOGS_GROUP_BY}: ${legend.label})`);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlerCopyLabel = async () => {
|
||||||
|
await copyToClipboard(legend.label, `${legend.label} has been copied`);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const options: LegendLogHitsMenu[] = [
|
||||||
|
{
|
||||||
|
title: `Copy ${LOGS_GROUP_BY} name`,
|
||||||
|
icon: <CopyIcon/>,
|
||||||
|
handler: handlerCopyLabel,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: `Add ${LOGS_GROUP_BY} to filter`,
|
||||||
|
icon: <FilterIcon/>,
|
||||||
|
handler: handleAddStreamToFilter,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: `Exclude ${LOGS_GROUP_BY} to filter`,
|
||||||
|
icon: <FilterOffIcon/>,
|
||||||
|
handler: handleExcludeStreamToFilter,
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="vm-legend-hits-menu-section">
|
||||||
|
{options.map(({ icon, title, handler }) => (
|
||||||
|
<LegendHitsMenuRow
|
||||||
|
key={title}
|
||||||
|
iconStart={icon}
|
||||||
|
title={title}
|
||||||
|
handler={handler}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LegendHitsMenuBase;
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import React, { FC, useMemo } from "preact/compat";
|
||||||
|
import LegendHitsMenuRow from "./LegendHitsMenuRow";
|
||||||
|
import { CopyIcon, FilterIcon, FilterOffIcon } from "../../../Main/Icons";
|
||||||
|
import { convertToFieldFilter } from "../../../../utils/logs";
|
||||||
|
import { LegendLogHitsMenu } from "../../../../api/types";
|
||||||
|
import useCopyToClipboard from "../../../../hooks/useCopyToClipboard";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
fields: string[];
|
||||||
|
onApplyFilter: (value: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LegendHitsMenuFields: FC<Props> = ({ fields, onApplyFilter, onClose }) => {
|
||||||
|
const copyToClipboard = useCopyToClipboard();
|
||||||
|
|
||||||
|
const handleCopy = (field: string) => async () => {
|
||||||
|
await copyToClipboard(field, `${field} has been copied`);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddToFilter = (field: string) => () => {
|
||||||
|
onApplyFilter(field);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExcludeToFilter = (field: string) => () => {
|
||||||
|
onApplyFilter(`-${field}`);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateFieldMenu = (field: string): LegendLogHitsMenu[] => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
title: "Copy",
|
||||||
|
icon: <CopyIcon/>,
|
||||||
|
handler: handleCopy(field),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Add to filter",
|
||||||
|
icon: <FilterIcon/>,
|
||||||
|
handler: handleAddToFilter(field),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Exclude to filter",
|
||||||
|
icon: <FilterOffIcon/>,
|
||||||
|
handler: handleExcludeToFilter(field),
|
||||||
|
}
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
const fieldsWithMenu: LegendLogHitsMenu[] = useMemo(() => {
|
||||||
|
return fields.map(field => {
|
||||||
|
const title = convertToFieldFilter(field);
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
submenu: generateFieldMenu(title),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [fields]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="vm-legend-hits-menu-section">
|
||||||
|
{fieldsWithMenu?.map((field) => (
|
||||||
|
<LegendHitsMenuRow
|
||||||
|
key={field.title}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LegendHitsMenuFields;
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import React, { FC, useRef, useState } from "preact/compat";
|
||||||
|
import classNames from "classnames";
|
||||||
|
import { ReactNode, useEffect } from "react";
|
||||||
|
import Tooltip from "../../../Main/Tooltip/Tooltip";
|
||||||
|
import { LegendLogHitsMenu } from "../../../../api/types";
|
||||||
|
import { ArrowDropDownIcon } from "../../../Main/Icons";
|
||||||
|
import useClickOutside from "../../../../hooks/useClickOutside";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title: string | ReactNode;
|
||||||
|
handler?: () => void;
|
||||||
|
iconStart?: ReactNode;
|
||||||
|
iconEnd?: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
submenu?: LegendLogHitsMenu[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const LegendHitsMenuRow: FC<Props> = ({ title, handler, iconStart, iconEnd, className, submenu }) => {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const titleRef = useRef<HTMLDivElement>(null);
|
||||||
|
const submenuRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const [isOverflownTitle, setIsOverflownTitle] = useState(false);
|
||||||
|
|
||||||
|
const [openSubmenu, setOpenSubmenu] = useState(false);
|
||||||
|
const [posSubmenuLeft, setPosSubmenuLeft] = useState(false);
|
||||||
|
const hasSubmenu = !!submenu?.length;
|
||||||
|
|
||||||
|
const handleToggleContextMenu = () => {
|
||||||
|
setOpenSubmenu(prev => !prev);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseContextMenu = () => {
|
||||||
|
setOpenSubmenu(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
handler && handler();
|
||||||
|
hasSubmenu && handleToggleContextMenu();
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!titleRef.current) return;
|
||||||
|
setIsOverflownTitle(titleRef.current.scrollWidth > titleRef.current.clientWidth);
|
||||||
|
}, [title, titleRef]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (!openSubmenu || !submenuRef.current) {
|
||||||
|
setPosSubmenuLeft(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { left, width } = submenuRef.current.getBoundingClientRect();
|
||||||
|
setPosSubmenuLeft(left + width > window.innerWidth);
|
||||||
|
});
|
||||||
|
}, [submenuRef, openSubmenu]);
|
||||||
|
|
||||||
|
useClickOutside(containerRef, handleCloseContextMenu);
|
||||||
|
|
||||||
|
const titleContent = (
|
||||||
|
<div
|
||||||
|
ref={titleRef}
|
||||||
|
className="vm-legend-hits-menu-row__title"
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className={classNames({
|
||||||
|
"vm-legend-hits-menu-row": true,
|
||||||
|
"vm-legend-hits-menu-row_interactive": !!handler || hasSubmenu,
|
||||||
|
[`${className}`]: className
|
||||||
|
})}
|
||||||
|
onClick={handleClick}
|
||||||
|
>
|
||||||
|
{iconStart && <div className="vm-legend-hits-menu-row__icon">{iconStart}</div>}
|
||||||
|
{isOverflownTitle ? (<Tooltip title={title}>{titleContent}</Tooltip>) : titleContent}
|
||||||
|
{iconEnd && !hasSubmenu && <div className="vm-legend-hits-menu-row__icon">{iconEnd}</div>}
|
||||||
|
|
||||||
|
{hasSubmenu && (
|
||||||
|
<div className="vm-legend-hits-menu-row__icon vm-legend-hits-menu-row__icon_drop">
|
||||||
|
<ArrowDropDownIcon/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{openSubmenu && submenu && (
|
||||||
|
<div
|
||||||
|
ref={submenuRef}
|
||||||
|
className={classNames({
|
||||||
|
"vm-legend-hits-menu": true,
|
||||||
|
"vm-legend-hits-menu_submenu": true,
|
||||||
|
"vm-legend-hits-menu_submenu_left": posSubmenuLeft
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<div className="vm-legend-hits-menu-section">
|
||||||
|
{submenu.map(({ icon, title, handler }) => (
|
||||||
|
<LegendHitsMenuRow
|
||||||
|
key={title}
|
||||||
|
iconStart={icon}
|
||||||
|
title={title}
|
||||||
|
handler={handler}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LegendHitsMenuRow;
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import React, { FC } from "preact/compat";
|
||||||
|
import { LegendLogHits } from "../../../../api/types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
legend: LegendLogHits;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LegendHitsMenuStats: FC<Props> = ({ legend }) => {
|
||||||
|
const totalFormatted = legend.total.toLocaleString("en-US");
|
||||||
|
const percentage = Math.round((legend.total / legend.totalHits) * 100);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="vm-legend-hits-menu-section">
|
||||||
|
<div className="vm-legend-hits-menu-row">
|
||||||
|
<div className="vm-legend-hits-menu-row__title">
|
||||||
|
Total: {totalFormatted} ({percentage}%)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LegendHitsMenuStats;
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
@use "src/styles/variables" as *;
|
||||||
|
|
||||||
|
.vm-legend-hits-menu {
|
||||||
|
min-width: 160px;
|
||||||
|
z-index: 1;
|
||||||
|
|
||||||
|
&_submenu {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(-1 * $padding-small);
|
||||||
|
background-color: $color-background-block;
|
||||||
|
left: calc(100% + ($padding-small / 2));
|
||||||
|
box-shadow: $box-shadow-popper;
|
||||||
|
border-radius: $border-radius-small;
|
||||||
|
animation: vm-submenu-show 150ms cubic-bezier(0.280, 0.840, 0.2, 1);
|
||||||
|
transform-origin: top left;
|
||||||
|
|
||||||
|
&_left {
|
||||||
|
left: auto;
|
||||||
|
right: calc(100% + ($padding-small / 2));
|
||||||
|
transform-origin: top right;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&-section {
|
||||||
|
border-bottom: $border-divider;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&-row {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
gap: $padding-small;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
padding: 0 $padding-global;
|
||||||
|
transition: background-color 0.3s;
|
||||||
|
color: $color-text;
|
||||||
|
|
||||||
|
&_interactive {
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&_info {
|
||||||
|
font-size: $font-size-small;
|
||||||
|
font-weight: 500;
|
||||||
|
padding-block: $padding-small;
|
||||||
|
}
|
||||||
|
|
||||||
|
&_info &__icon {
|
||||||
|
color: $color-info;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
|
||||||
|
&_drop {
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
flex-grow: 1;
|
||||||
|
padding: $padding-global 0;
|
||||||
|
position: relative;
|
||||||
|
max-width: 400px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&-other-list {
|
||||||
|
width: 80vw;
|
||||||
|
height: 80vh;
|
||||||
|
overflow: auto;
|
||||||
|
|
||||||
|
&__search {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
padding: $padding-small 0;
|
||||||
|
background-color: $color-background-block;
|
||||||
|
border-bottom: $border-divider;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-row {
|
||||||
|
border-bottom: $border-divider;
|
||||||
|
|
||||||
|
&_header {
|
||||||
|
border-bottom: none;
|
||||||
|
position: sticky;
|
||||||
|
top: 65px;
|
||||||
|
background-color: $color-background-block;
|
||||||
|
z-index: 1;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
&:after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 1px;
|
||||||
|
border-bottom: $border-divider;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&-cell {
|
||||||
|
padding: calc($padding-small / 2) 0;
|
||||||
|
text-align: left;
|
||||||
|
|
||||||
|
&_header {
|
||||||
|
padding: $padding-small;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
&_number {
|
||||||
|
padding: $padding-small;
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
&_fields {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&-fields {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
|
||||||
|
&__field {
|
||||||
|
padding: calc($padding-small / 2) $padding-small;
|
||||||
|
border-radius: $border-radius-small;
|
||||||
|
transition: background-color 0.3s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: $color-hover-black;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:not(:last-child) {
|
||||||
|
&:after {
|
||||||
|
content: ',';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes vm-submenu-show {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,6 +36,14 @@ interface UseGetBarHitsOptionsArgs {
|
|||||||
graphOptions: GraphOptions;
|
graphOptions: GraphOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const OTHER_HITS_LABEL = "other";
|
||||||
|
|
||||||
|
export const getLabelFromLogHit = (logHit: LogHits) => {
|
||||||
|
if (logHit?._isOther) return OTHER_HITS_LABEL;
|
||||||
|
const fields = Object.values(logHit?.fields || {});
|
||||||
|
return fields.map((value) => value || "\"\"").join(", ");
|
||||||
|
};
|
||||||
|
|
||||||
const useBarHitsOptions = ({
|
const useBarHitsOptions = ({
|
||||||
data,
|
data,
|
||||||
logHits,
|
logHits,
|
||||||
@@ -59,16 +67,16 @@ const useBarHitsOptions = ({
|
|||||||
let colorN = 0;
|
let colorN = 0;
|
||||||
return data.map((_d, i) => {
|
return data.map((_d, i) => {
|
||||||
if (i === 0) return {}; // 0 index is xAxis(timestamps)
|
if (i === 0) return {}; // 0 index is xAxis(timestamps)
|
||||||
const fields = Object.values(logHits?.[i - 1]?.fields || {});
|
const target = logHits?.[i - 1];
|
||||||
const label = fields.map((value) => value || "\"\"").join(", ");
|
const label = getLabelFromLogHit(target);
|
||||||
const color = getCssVariable(label ? seriesColors[colorN] : "color-log-hits-bar-0");
|
const color = getCssVariable(target?._isOther ? "color-log-hits-bar-0" : seriesColors[colorN]);
|
||||||
if (label) colorN++;
|
if (!target?._isOther) colorN++;
|
||||||
return {
|
return {
|
||||||
label: label || "other",
|
label,
|
||||||
width: strokeWidth[graphOptions.graphStyle],
|
width: strokeWidth[graphOptions.graphStyle],
|
||||||
spanGaps: true,
|
spanGaps: true,
|
||||||
stroke: color,
|
stroke: color,
|
||||||
fill: graphOptions.fill ? color + "80" : "",
|
fill: graphOptions.fill ? color + (target?._isOther ? "" : "80") : "",
|
||||||
paths: getSeriesPaths(graphOptions.graphStyle),
|
paths: getSeriesPaths(graphOptions.graphStyle),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ $chart-tooltip-y: -1 * ($padding-global + $chart-tooltip-half-icon);
|
|||||||
max-width: calc(100vw/3);
|
max-width: calc(100vw/3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&_hits &-data {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: $font-size 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
&_sticky {
|
&_sticky {
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
z-index: 99;
|
z-index: 99;
|
||||||
@@ -90,6 +95,8 @@ $chart-tooltip-y: -1 * ($padding-global + $chart-tooltip-half-icon);
|
|||||||
}
|
}
|
||||||
|
|
||||||
&__marker {
|
&__marker {
|
||||||
|
min-width: $font-size;
|
||||||
|
max-width: $font-size;
|
||||||
width: $font-size;
|
width: $font-size;
|
||||||
height: $font-size;
|
height: $font-size;
|
||||||
border: 1px solid rgba($color-white, 0.5);
|
border: 1px solid rgba($color-white, 0.5);
|
||||||
|
|||||||
@@ -36,35 +36,40 @@ const AxesLimitsConfigurator: FC<AxesLimitsConfiguratorProps> = ({ yaxis, setYax
|
|||||||
"vm-axes-limits_mobile": isMobile
|
"vm-axes-limits_mobile": isMobile
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Switch
|
<div className="vm-graph-settings-row">
|
||||||
value={yaxis.limits.enable}
|
<span className="vm-graph-settings-row__label">Fixed Y-axis limits</span>
|
||||||
onChange={toggleEnableLimits}
|
<Switch
|
||||||
label="Fix the limits for y-axis"
|
value={yaxis.limits.enable}
|
||||||
fullWidth={isMobile}
|
onChange={toggleEnableLimits}
|
||||||
/>
|
label={`${yaxis.limits.enable ? "Fixed" : "Auto"} limits`}
|
||||||
<div className="vm-axes-limits-list">
|
fullWidth={isMobile}
|
||||||
{axes.map(axis => (
|
/>
|
||||||
<div
|
|
||||||
className="vm-axes-limits-list__inputs"
|
|
||||||
key={axis}
|
|
||||||
>
|
|
||||||
<TextField
|
|
||||||
label={`Min ${axis}`}
|
|
||||||
type="number"
|
|
||||||
disabled={!yaxis.limits.enable}
|
|
||||||
value={yaxis.limits.range[axis][0]}
|
|
||||||
onChange={createHandlerOnchangeAxis(axis, 0)}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
label={`Max ${axis}`}
|
|
||||||
type="number"
|
|
||||||
disabled={!yaxis.limits.enable}
|
|
||||||
value={yaxis.limits.range[axis][1]}
|
|
||||||
onChange={createHandlerOnchangeAxis(axis, 1)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
{yaxis.limits.enable && (
|
||||||
|
<div className="vm-axes-limits-list">
|
||||||
|
{axes.map(axis => (
|
||||||
|
<div
|
||||||
|
className="vm-axes-limits-list__inputs"
|
||||||
|
key={axis}
|
||||||
|
>
|
||||||
|
<TextField
|
||||||
|
label={`Min ${axis}`}
|
||||||
|
type="number"
|
||||||
|
disabled={!yaxis.limits.enable}
|
||||||
|
value={yaxis.limits.range[axis][0]}
|
||||||
|
onChange={createHandlerOnchangeAxis(axis, 0)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label={`Max ${axis}`}
|
||||||
|
type="number"
|
||||||
|
disabled={!yaxis.limits.enable}
|
||||||
|
value={yaxis.limits.range[axis][1]}
|
||||||
|
onChange={createHandlerOnchangeAxis(axis, 1)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>;
|
</div>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -8,10 +8,14 @@ import "./style.scss";
|
|||||||
import Tooltip from "../../Main/Tooltip/Tooltip";
|
import Tooltip from "../../Main/Tooltip/Tooltip";
|
||||||
import useBoolean from "../../../hooks/useBoolean";
|
import useBoolean from "../../../hooks/useBoolean";
|
||||||
import LinesConfigurator from "./LinesConfigurator/LinesConfigurator";
|
import LinesConfigurator from "./LinesConfigurator/LinesConfigurator";
|
||||||
|
import GraphTypeSwitcher from "./GraphTypeSwitcher/GraphTypeSwitcher";
|
||||||
|
import { MetricResult } from "../../../api/types";
|
||||||
|
import { isHistogramData } from "../../../utils/metric";
|
||||||
|
|
||||||
const title = "Graph settings";
|
const title = "Graph settings";
|
||||||
|
|
||||||
interface GraphSettingsProps {
|
interface GraphSettingsProps {
|
||||||
|
data: MetricResult[],
|
||||||
yaxis: YaxisState,
|
yaxis: YaxisState,
|
||||||
setYaxisLimits: (limits: AxisRange) => void,
|
setYaxisLimits: (limits: AxisRange) => void,
|
||||||
toggleEnableLimits: () => void,
|
toggleEnableLimits: () => void,
|
||||||
@@ -19,11 +23,13 @@ interface GraphSettingsProps {
|
|||||||
value: boolean,
|
value: boolean,
|
||||||
onChange: (value: boolean) => void,
|
onChange: (value: boolean) => void,
|
||||||
},
|
},
|
||||||
|
isHistogram?: boolean,
|
||||||
}
|
}
|
||||||
|
|
||||||
const GraphSettings: FC<GraphSettingsProps> = ({ yaxis, setYaxisLimits, toggleEnableLimits, spanGaps }) => {
|
const GraphSettings: FC<GraphSettingsProps> = ({ data, yaxis, setYaxisLimits, toggleEnableLimits, spanGaps }) => {
|
||||||
const popperRef = useRef<HTMLDivElement>(null);
|
const popperRef = useRef<HTMLDivElement>(null);
|
||||||
const buttonRef = useRef<HTMLDivElement>(null);
|
const buttonRef = useRef<HTMLDivElement>(null);
|
||||||
|
const displayHistogramMode = isHistogramData(data);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
value: openPopper,
|
value: openPopper,
|
||||||
@@ -64,6 +70,7 @@ const GraphSettings: FC<GraphSettingsProps> = ({ yaxis, setYaxisLimits, toggleEn
|
|||||||
spanGaps={spanGaps.value}
|
spanGaps={spanGaps.value}
|
||||||
onChange={spanGaps.onChange}
|
onChange={spanGaps.onChange}
|
||||||
/>
|
/>
|
||||||
|
{displayHistogramMode && <GraphTypeSwitcher onChange={handleClose}/>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Popper>
|
</Popper>
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import React, { FC } from "preact/compat";
|
||||||
|
import Switch from "../../../Main/Switch/Switch";
|
||||||
|
import useDeviceDetect from "../../../../hooks/useDeviceDetect";
|
||||||
|
import { useSearchParams } from "react-router-dom";
|
||||||
|
import { useChangeDisplayMode } from "./useChangeDisplayMode";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onChange: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GraphTypeSwitcher: FC<Props> = ({ onChange }) => {
|
||||||
|
const { isMobile } = useDeviceDetect();
|
||||||
|
|
||||||
|
const { handleChange } = useChangeDisplayMode();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
|
const value = !searchParams.get("display_mode");
|
||||||
|
|
||||||
|
const handleChangeMode = (val: boolean) => {
|
||||||
|
handleChange(val, onChange);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="vm-graph-settings-row">
|
||||||
|
<span className="vm-graph-settings-row__label">Histogram mode</span>
|
||||||
|
<Switch
|
||||||
|
value={value}
|
||||||
|
onChange={handleChangeMode}
|
||||||
|
label={value ? "Enabled" : "Disabled"}
|
||||||
|
fullWidth={isMobile}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GraphTypeSwitcher;
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { useTimeDispatch } from "../../../../state/time/TimeStateContext";
|
||||||
|
import { useSearchParams } from "react-router-dom";
|
||||||
|
|
||||||
|
export const useChangeDisplayMode = () => {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const dispatch = useTimeDispatch();
|
||||||
|
|
||||||
|
const handleChange = (val: boolean, callback?: () => void) => {
|
||||||
|
val ? searchParams.delete("display_mode") : searchParams.set("display_mode", "lines");
|
||||||
|
setSearchParams(searchParams);
|
||||||
|
dispatch({ type: "RUN_QUERY" });
|
||||||
|
callback && callback();
|
||||||
|
};
|
||||||
|
|
||||||
|
return { handleChange };
|
||||||
|
};
|
||||||
@@ -10,14 +10,17 @@ interface Props {
|
|||||||
const LinesConfigurator: FC<Props> = ({ spanGaps, onChange }) => {
|
const LinesConfigurator: FC<Props> = ({ spanGaps, onChange }) => {
|
||||||
const { isMobile } = useDeviceDetect();
|
const { isMobile } = useDeviceDetect();
|
||||||
|
|
||||||
return <div>
|
return (
|
||||||
<Switch
|
<div className="vm-graph-settings-row">
|
||||||
value={spanGaps}
|
<span className="vm-graph-settings-row__label">Connect null values</span>
|
||||||
onChange={onChange}
|
<Switch
|
||||||
label="Connect null values"
|
value={spanGaps}
|
||||||
fullWidth={isMobile}
|
onChange={onChange}
|
||||||
/>
|
label={spanGaps ? "Enabled" : "Disabled"}
|
||||||
</div>;
|
fullWidth={isMobile}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default LinesConfigurator;
|
export default LinesConfigurator;
|
||||||
|
|||||||
@@ -1,15 +1,31 @@
|
|||||||
@use "src/styles/variables" as *;
|
@use "src/styles/variables" as *;
|
||||||
|
|
||||||
.vm-graph-settings {
|
.vm-graph-settings {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $padding-small;
|
||||||
|
|
||||||
&-popper {
|
&-popper {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: $padding-global;
|
gap: $padding-global;
|
||||||
padding: 0 0 $padding-global;
|
padding: $padding-small $padding-large $padding-large;
|
||||||
|
min-width: 300px;
|
||||||
|
|
||||||
&__body {
|
&__body {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: $padding-large;
|
gap: $padding-large;
|
||||||
padding: 0 $padding-global;
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&-row {
|
||||||
|
display: grid;
|
||||||
|
gap: $padding-small;
|
||||||
|
grid-template-columns: minmax(150px, max-content) 1fr;
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
&:after{
|
||||||
|
content: ":";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ const QueryEditor: FC<QueryEditorProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setOpenAutocomplete(!!AutocompleteEl);
|
setOpenAutocomplete(!!AutocompleteEl && autocompleteQuick);
|
||||||
}, [autocompleteQuick]);
|
}, [autocompleteQuick]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -0,0 +1,244 @@
|
|||||||
|
import React, { FC, useMemo, useState } from "preact/compat";
|
||||||
|
import useBoolean from "../../../hooks/useBoolean";
|
||||||
|
import { RestartIcon, SettingsIcon } from "../../Main/Icons";
|
||||||
|
import Button from "../../Main/Button/Button";
|
||||||
|
import Modal from "../../Main/Modal/Modal";
|
||||||
|
import Tooltip from "../../Main/Tooltip/Tooltip";
|
||||||
|
import { Logs } from "../../../api/types";
|
||||||
|
import Select from "../../Main/Select/Select";
|
||||||
|
import { useSearchParams } from "react-router-dom";
|
||||||
|
import "./style.scss";
|
||||||
|
import Switch from "../../Main/Switch/Switch";
|
||||||
|
import TextField from "../../Main/TextField/TextField";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import Hyperlink from "../../Main/Hyperlink/Hyperlink";
|
||||||
|
import {
|
||||||
|
LOGS_DISPLAY_FIELDS,
|
||||||
|
LOGS_GROUP_BY,
|
||||||
|
LOGS_DATE_FORMAT,
|
||||||
|
LOGS_URL_PARAMS,
|
||||||
|
WITHOUT_GROUPING
|
||||||
|
} from "../../../constants/logs";
|
||||||
|
|
||||||
|
const {
|
||||||
|
GROUP_BY,
|
||||||
|
NO_WRAP_LINES,
|
||||||
|
COMPACT_GROUP_HEADER,
|
||||||
|
DISPLAY_FIELDS,
|
||||||
|
DATE_FORMAT
|
||||||
|
} = LOGS_URL_PARAMS;
|
||||||
|
|
||||||
|
const title = "Group view settings";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
logs: Logs[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const GroupLogsConfigurators: FC<Props> = ({ logs }) => {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
||||||
|
const groupBy = searchParams.get(GROUP_BY) || LOGS_GROUP_BY;
|
||||||
|
const noWrapLines = searchParams.get(NO_WRAP_LINES) === "true";
|
||||||
|
const compactGroupHeader = searchParams.get(COMPACT_GROUP_HEADER) === "true";
|
||||||
|
const displayFieldsString = searchParams.get(DISPLAY_FIELDS) || "";
|
||||||
|
const displayFields = displayFieldsString ? displayFieldsString.split(",") : [LOGS_DISPLAY_FIELDS];
|
||||||
|
|
||||||
|
const [dateFormat, setDateFormat] = useState(searchParams.get(DATE_FORMAT) || LOGS_DATE_FORMAT);
|
||||||
|
const [errorFormat, setErrorFormat] = useState("");
|
||||||
|
|
||||||
|
const isGroupChanged = groupBy !== LOGS_GROUP_BY;
|
||||||
|
const isDisplayFieldsChanged = displayFields.length !== 1 || displayFields[0] !== LOGS_DISPLAY_FIELDS;
|
||||||
|
const isTimeChanged = searchParams.get(DATE_FORMAT) !== LOGS_DATE_FORMAT;
|
||||||
|
const hasChanges = [
|
||||||
|
isGroupChanged,
|
||||||
|
isDisplayFieldsChanged,
|
||||||
|
noWrapLines,
|
||||||
|
compactGroupHeader,
|
||||||
|
isTimeChanged
|
||||||
|
].some(Boolean);
|
||||||
|
|
||||||
|
const logsKeys = useMemo(() => {
|
||||||
|
return Array.from(new Set(logs.map(l => Object.keys(l)).flat()));
|
||||||
|
}, [logs]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
value: openModal,
|
||||||
|
toggle: toggleOpen,
|
||||||
|
setFalse: handleClose,
|
||||||
|
} = useBoolean(false);
|
||||||
|
|
||||||
|
const handleSelectGroupBy = (key: string) => {
|
||||||
|
searchParams.set(GROUP_BY, key);
|
||||||
|
setSearchParams(searchParams);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectDisplayField = (value: string) => {
|
||||||
|
const prev = displayFields;
|
||||||
|
const newDisplayFields = prev.includes(value) ? prev.filter(v => v !== value) : [...prev, value];
|
||||||
|
searchParams.set(DISPLAY_FIELDS, newDisplayFields.join(","));
|
||||||
|
setSearchParams(searchParams);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResetDisplayFields = () => {
|
||||||
|
searchParams.delete(DISPLAY_FIELDS);
|
||||||
|
setSearchParams(searchParams);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleWrapLines = () => {
|
||||||
|
searchParams.set(NO_WRAP_LINES, String(!noWrapLines));
|
||||||
|
setSearchParams(searchParams);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleCompactGroupHeader = () => {
|
||||||
|
searchParams.set(COMPACT_GROUP_HEADER, String(!compactGroupHeader));
|
||||||
|
setSearchParams(searchParams);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChangeDateFormat = (format: string) => {
|
||||||
|
const date = new Date();
|
||||||
|
if (!dayjs(date, format, true).isValid()) {
|
||||||
|
setErrorFormat("Invalid date format");
|
||||||
|
}
|
||||||
|
setDateFormat(format);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveAndClose = () => {
|
||||||
|
searchParams.set(DATE_FORMAT, dateFormat);
|
||||||
|
setSearchParams(searchParams);
|
||||||
|
handleClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const tooltipContent = () => {
|
||||||
|
if (!hasChanges) return title;
|
||||||
|
return (
|
||||||
|
<div className="vm-group-logs-configurator__tooltip">
|
||||||
|
<p>{title}</p>
|
||||||
|
<hr/>
|
||||||
|
<ul>
|
||||||
|
{isGroupChanged && <li>Group by <code>{`"${groupBy}"`}</code></li>}
|
||||||
|
{isDisplayFieldsChanged && <li>Display fields: {displayFields.length || 1}</li>}
|
||||||
|
{noWrapLines && <li>Single-line text is enabled</li>}
|
||||||
|
{compactGroupHeader && <li>Compact group header is enabled</li>}
|
||||||
|
{isTimeChanged && <li>Date format: <code>{dateFormat}</code></li>}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="vm-group-logs-configurator-button">
|
||||||
|
<Tooltip title={tooltipContent()}>
|
||||||
|
<Button
|
||||||
|
variant="text"
|
||||||
|
startIcon={<SettingsIcon/>}
|
||||||
|
onClick={toggleOpen}
|
||||||
|
ariaLabel={title}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
{hasChanges && <span className="vm-group-logs-configurator-button__marker"/>}
|
||||||
|
</div>
|
||||||
|
{openModal && (
|
||||||
|
<Modal
|
||||||
|
title={title}
|
||||||
|
onClose={handleSaveAndClose}
|
||||||
|
>
|
||||||
|
<div className="vm-group-logs-configurator">
|
||||||
|
<div className="vm-group-logs-configurator-item">
|
||||||
|
<Select
|
||||||
|
value={groupBy}
|
||||||
|
list={[WITHOUT_GROUPING, ...logsKeys]}
|
||||||
|
label="Group by field"
|
||||||
|
placeholder="Group by field"
|
||||||
|
onChange={handleSelectGroupBy}
|
||||||
|
searchable
|
||||||
|
/>
|
||||||
|
<Tooltip title={"Reset grouping"}>
|
||||||
|
<Button
|
||||||
|
variant="text"
|
||||||
|
color="primary"
|
||||||
|
startIcon={<RestartIcon/>}
|
||||||
|
onClick={() => handleSelectGroupBy(LOGS_GROUP_BY)}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
<span className="vm-group-logs-configurator-item__info">
|
||||||
|
Select a field to group logs by (default: <code>{LOGS_GROUP_BY}</code>).
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="vm-group-logs-configurator-item">
|
||||||
|
<Select
|
||||||
|
value={displayFields}
|
||||||
|
list={logsKeys}
|
||||||
|
label="Display fields"
|
||||||
|
placeholder="Display fields"
|
||||||
|
onChange={handleSelectDisplayField}
|
||||||
|
searchable
|
||||||
|
/>
|
||||||
|
<Tooltip title={"Clear fields"}>
|
||||||
|
<Button
|
||||||
|
variant="text"
|
||||||
|
color="primary"
|
||||||
|
startIcon={<RestartIcon/>}
|
||||||
|
onClick={handleResetDisplayFields}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
<span className="vm-group-logs-configurator-item__info">
|
||||||
|
Select fields to display instead of the message (default: <code>{LOGS_DISPLAY_FIELDS}</code>).
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="vm-group-logs-configurator-item">
|
||||||
|
<TextField
|
||||||
|
autofocus
|
||||||
|
label="Date format"
|
||||||
|
value={dateFormat}
|
||||||
|
onChange={handleChangeDateFormat}
|
||||||
|
error={errorFormat}
|
||||||
|
/>
|
||||||
|
<Tooltip title={"Reset format"}>
|
||||||
|
<Button
|
||||||
|
variant="text"
|
||||||
|
color="primary"
|
||||||
|
startIcon={<RestartIcon/>}
|
||||||
|
onClick={() => setDateFormat(LOGS_DATE_FORMAT)}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
<span className="vm-group-logs-configurator-item__info vm-group-logs-configurator-item__info_input">
|
||||||
|
Set the date format (e.g., <code>YYYY-MM-DD HH:mm:ss</code>).
|
||||||
|
Learn more in <Hyperlink
|
||||||
|
href="https://day.js.org/docs/en/display/format"
|
||||||
|
>this documentation</Hyperlink>. <br/>
|
||||||
|
Your current date format: <code>{dayjs().format(dateFormat || LOGS_DATE_FORMAT)}</code>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="vm-group-logs-configurator-item">
|
||||||
|
<Switch
|
||||||
|
value={noWrapLines}
|
||||||
|
onChange={toggleWrapLines}
|
||||||
|
label="Single-line message"
|
||||||
|
/>
|
||||||
|
<span className="vm-group-logs-configurator-item__info">
|
||||||
|
Displays message in a single line and truncates it with an ellipsis if it exceeds the available space
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="vm-group-logs-configurator-item">
|
||||||
|
<Switch
|
||||||
|
value={compactGroupHeader}
|
||||||
|
onChange={toggleCompactGroupHeader}
|
||||||
|
label="Compact group header"
|
||||||
|
/>
|
||||||
|
<span className="vm-group-logs-configurator-item__info">
|
||||||
|
Shows group headers in one line with a "+N more" badge for extra fields.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GroupLogsConfigurators;
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
@use "src/styles/variables" as *;
|
||||||
|
|
||||||
|
.vm-group-logs-configurator {
|
||||||
|
display: grid;
|
||||||
|
gap: calc($padding-large * 2);
|
||||||
|
padding: $padding-global 0;
|
||||||
|
width: 600px;
|
||||||
|
|
||||||
|
&-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 31px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: stretch;
|
||||||
|
gap: 0 $padding-small;
|
||||||
|
|
||||||
|
&__info {
|
||||||
|
margin-top: $padding-small;
|
||||||
|
grid-column: 1/span 2;
|
||||||
|
font-size: $font-size-small;
|
||||||
|
color: $color-text-secondary;
|
||||||
|
line-height: 130%;
|
||||||
|
|
||||||
|
&_input {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&-button {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&__marker {
|
||||||
|
position: absolute;
|
||||||
|
top: 6px;
|
||||||
|
left: 6px;
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: $color-secondary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__tooltip {
|
||||||
|
ul {
|
||||||
|
list-style-position: inside;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,10 @@ const Accordion: FC<AccordionProps> = ({
|
|||||||
onChange && onChange(isOpen);
|
onChange && onChange(isOpen);
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsOpen(defaultExpanded);
|
||||||
|
}, [defaultExpanded]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<header
|
<header
|
||||||
|
|||||||
@@ -4,13 +4,16 @@ import { useState } from "react";
|
|||||||
import Tooltip from "../Tooltip/Tooltip";
|
import Tooltip from "../Tooltip/Tooltip";
|
||||||
import Button from "../Button/Button";
|
import Button from "../Button/Button";
|
||||||
import { CopyIcon } from "../Icons";
|
import { CopyIcon } from "../Icons";
|
||||||
|
import useCopyToClipboard from "../../../hooks/useCopyToClipboard";
|
||||||
|
|
||||||
enum CopyState { copy = "Copy", copied = "Copied" }
|
enum CopyState { copy = "Copy", copied = "Copied" }
|
||||||
|
|
||||||
const CodeExample: FC<{code: string}> = ({ code }) => {
|
const CodeExample: FC<{code: string}> = ({ code }) => {
|
||||||
|
const copyToClipboard = useCopyToClipboard();
|
||||||
|
|
||||||
const [tooltip, setTooltip] = useState(CopyState.copy);
|
const [tooltip, setTooltip] = useState(CopyState.copy);
|
||||||
const handlerCopy = () => {
|
const handlerCopy = async () => {
|
||||||
navigator.clipboard.writeText(code);
|
await copyToClipboard(code);
|
||||||
setTooltip(CopyState.copied);
|
setTooltip(CopyState.copied);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -581,3 +581,45 @@ export const CommentIcon = () => (
|
|||||||
></path>
|
></path>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const FilterIcon = () => (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M4.25 5.61C6.27 8.2 10 13 10 13v6c0 .55.45 1 1 1h2c.55 0 1-.45 1-1v-6s3.72-4.8 5.74-7.39c.51-.66.04-1.61-.79-1.61H5.04c-.83 0-1.3.95-.79 1.61"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const FilterOffIcon = () => (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M19.79 5.61C20.3 4.95 19.83 4 19 4H6.83l7.97 7.97zM2.81 2.81 1.39 4.22 10 13v6c0 .55.45 1 1 1h2c.55 0 1-.45 1-1v-2.17l5.78 5.78 1.41-1.41z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const OpenNewIcon = () => (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M19 19H5V5h7V3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2v-7h-2zM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ModalIcon = () => (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<path d="M19 4H5c-1.11 0-2 .9-2 2v12c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V6c0-1.1-.89-2-2-2m0 14H5V8h14z"></path>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|||||||
@@ -67,11 +67,11 @@ const Modal: FC<ModalProps> = ({
|
|||||||
})}
|
})}
|
||||||
onMouseDown={onClose}
|
onMouseDown={onClose}
|
||||||
>
|
>
|
||||||
<div className="vm-modal-content">
|
<div
|
||||||
<div
|
className="vm-modal-content"
|
||||||
className="vm-modal-content-header"
|
onMouseDown={handleMouseDown}
|
||||||
onMouseDown={handleMouseDown}
|
>
|
||||||
>
|
<div className="vm-modal-content-header">
|
||||||
{title && (
|
{title && (
|
||||||
<div className="vm-modal-content-header__title">
|
<div className="vm-modal-content-header__title">
|
||||||
{title}
|
{title}
|
||||||
@@ -91,7 +91,6 @@ const Modal: FC<ModalProps> = ({
|
|||||||
{/* tabIndex to fix Ctrl-A */}
|
{/* tabIndex to fix Ctrl-A */}
|
||||||
<div
|
<div
|
||||||
className="vm-modal-content-body"
|
className="vm-modal-content-body"
|
||||||
onMouseDown={handleMouseDown}
|
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -15,9 +15,10 @@ interface PopperProps {
|
|||||||
open: boolean
|
open: boolean
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
buttonRef: React.RefObject<HTMLElement>
|
buttonRef: React.RefObject<HTMLElement>
|
||||||
placement?: "bottom-right" | "bottom-left" | "top-left" | "top-right"
|
placement?: "bottom-right" | "bottom-left" | "top-left" | "top-right" | "fixed"
|
||||||
|
placementPosition?: { top: number, left: number } | null
|
||||||
animation?: string
|
animation?: string
|
||||||
offset?: {top: number, left: number}
|
offset?: { top: number, left: number }
|
||||||
clickOutside?: boolean,
|
clickOutside?: boolean,
|
||||||
fullWidth?: boolean
|
fullWidth?: boolean
|
||||||
title?: string
|
title?: string
|
||||||
@@ -29,6 +30,7 @@ const Popper: FC<PopperProps> = ({
|
|||||||
children,
|
children,
|
||||||
buttonRef,
|
buttonRef,
|
||||||
placement = "bottom-left",
|
placement = "bottom-left",
|
||||||
|
placementPosition,
|
||||||
open = false,
|
open = false,
|
||||||
onClose,
|
onClose,
|
||||||
offset = { top: 6, left: 0 },
|
offset = { top: 6, left: 0 },
|
||||||
@@ -92,13 +94,18 @@ const Popper: FC<PopperProps> = ({
|
|||||||
if (needAlignRight) position.left = buttonPos.right - popperSize.width;
|
if (needAlignRight) position.left = buttonPos.right - popperSize.width;
|
||||||
if (needAlignTop) position.top = buttonPos.top - popperSize.height - offsetTop;
|
if (needAlignTop) position.top = buttonPos.top - popperSize.height - offsetTop;
|
||||||
|
|
||||||
const { innerWidth, innerHeight } = window;
|
if (placement === "fixed" && placementPosition) {
|
||||||
const margin = 20;
|
position.top = Math.max(placementPosition.top + offset.top, 0);
|
||||||
|
position.left = Math.max(placementPosition.left + offset.left, 0);
|
||||||
|
return position;
|
||||||
|
}
|
||||||
|
|
||||||
const isOverflowBottom = (position.top + popperSize.height + margin) > innerHeight;
|
const { innerWidth, innerHeight } = window;
|
||||||
const isOverflowTop = (position.top - margin) < 0;
|
|
||||||
const isOverflowRight = (position.left + popperSize.width + margin) > innerWidth;
|
const isOverflowBottom = (position.top + popperSize.height) > innerHeight;
|
||||||
const isOverflowLeft = (position.left - margin) < 0;
|
const isOverflowTop = (position.top) < 0;
|
||||||
|
const isOverflowRight = (position.left + popperSize.width) > innerWidth;
|
||||||
|
const isOverflowLeft = (position.left) < 0;
|
||||||
|
|
||||||
if (isOverflowBottom) position.top = buttonPos.top - popperSize.height - offsetTop;
|
if (isOverflowBottom) position.top = buttonPos.top - popperSize.height - offsetTop;
|
||||||
if (isOverflowTop) position.top = buttonPos.height + buttonPos.top + offsetTop;
|
if (isOverflowTop) position.top = buttonPos.height + buttonPos.top + offsetTop;
|
||||||
@@ -106,11 +113,11 @@ const Popper: FC<PopperProps> = ({
|
|||||||
if (isOverflowLeft) position.left = buttonPos.left + offsetLeft;
|
if (isOverflowLeft) position.left = buttonPos.left + offsetLeft;
|
||||||
|
|
||||||
if (fullWidth) position.width = `${buttonPos.width}px`;
|
if (fullWidth) position.width = `${buttonPos.width}px`;
|
||||||
if (position.top < 0) position.top = 20;
|
if (position.top < 0) position.top = 0;
|
||||||
if (position.left < 0) position.left = 20;
|
if (position.left < 0) position.left = 0;
|
||||||
|
|
||||||
return position;
|
return position;
|
||||||
},[buttonRef, placement, isOpen, children, fullWidth]);
|
}, [buttonRef, placement, isOpen, children, fullWidth]);
|
||||||
|
|
||||||
const handleClickClose = (e: ReactMouseEvent<HTMLButtonElement, MouseEvent>) => {
|
const handleClickClose = (e: ReactMouseEvent<HTMLButtonElement, MouseEvent>) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -131,10 +138,10 @@ const Popper: FC<PopperProps> = ({
|
|||||||
if (!popperRef.current || !isOpen || (isMobile && !disabledFullScreen)) return;
|
if (!popperRef.current || !isOpen || (isMobile && !disabledFullScreen)) return;
|
||||||
const { right, width } = popperRef.current.getBoundingClientRect();
|
const { right, width } = popperRef.current.getBoundingClientRect();
|
||||||
if (right > window.innerWidth) {
|
if (right > window.innerWidth) {
|
||||||
const left = window.innerWidth - 20 - width;
|
const left = window.innerWidth - width;
|
||||||
popperRef.current.style.left = left < window.innerWidth ? "0" : `${left}px`;
|
popperRef.current.style.left = `${left}px`;
|
||||||
}
|
}
|
||||||
}, [isOpen, popperRef]);
|
}, [isOpen, popperRef, placementPosition]);
|
||||||
|
|
||||||
const handlePopstate = useCallback(() => {
|
const handlePopstate = useCallback(() => {
|
||||||
if (isOpen && isMobile && !disabledFullScreen) {
|
if (isOpen && isMobile && !disabledFullScreen) {
|
||||||
|
|||||||
@@ -33,9 +33,9 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background-color: $color-hover-black;
|
background-color: $color-hover-black;
|
||||||
padding: 2px 2px 2px 6px;
|
padding: 2px 2px 2px $padding-small;
|
||||||
border-radius: $border-radius-small;
|
border-radius: $border-radius-small;
|
||||||
font-size: $font-size;
|
font-size: $font-size-small;
|
||||||
line-height: $font-size;
|
line-height: $font-size;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { getComparator, stableSort } from "./helpers";
|
|||||||
import Tooltip from "../Main/Tooltip/Tooltip";
|
import Tooltip from "../Main/Tooltip/Tooltip";
|
||||||
import Button from "../Main/Button/Button";
|
import Button from "../Main/Button/Button";
|
||||||
import { useEffect } from "preact/compat";
|
import { useEffect } from "preact/compat";
|
||||||
|
import useCopyToClipboard from "../../hooks/useCopyToClipboard";
|
||||||
|
|
||||||
type OrderDir = "asc" | "desc"
|
type OrderDir = "asc" | "desc"
|
||||||
|
|
||||||
@@ -22,6 +23,8 @@ interface TableProps<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const Table = <T extends object>({ rows, columns, defaultOrderBy, defaultOrderDir, copyToClipboard, paginationOffset }: TableProps<T>) => {
|
const Table = <T extends object>({ rows, columns, defaultOrderBy, defaultOrderDir, copyToClipboard, paginationOffset }: TableProps<T>) => {
|
||||||
|
const handleCopyToClipboard = useCopyToClipboard();
|
||||||
|
|
||||||
const [orderBy, setOrderBy] = useState<keyof T>(defaultOrderBy);
|
const [orderBy, setOrderBy] = useState<keyof T>(defaultOrderBy);
|
||||||
const [orderDir, setOrderDir] = useState<OrderDir>(defaultOrderDir || "desc");
|
const [orderDir, setOrderDir] = useState<OrderDir>(defaultOrderDir || "desc");
|
||||||
const [copied, setCopied] = useState<number | null>(null);
|
const [copied, setCopied] = useState<number | null>(null);
|
||||||
@@ -42,7 +45,7 @@ const Table = <T extends object>({ rows, columns, defaultOrderBy, defaultOrderDi
|
|||||||
const createCopyHandler = (copyValue: string | number, rowIndex: number) => async () => {
|
const createCopyHandler = (copyValue: string | number, rowIndex: number) => async () => {
|
||||||
if (copied === rowIndex) return;
|
if (copied === rowIndex) return;
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(String(copyValue));
|
await handleCopyToClipboard(String(copyValue));
|
||||||
setCopied(rowIndex);
|
setCopied(rowIndex);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import useBoolean from "../../../hooks/useBoolean";
|
|||||||
import TextField from "../../Main/TextField/TextField";
|
import TextField from "../../Main/TextField/TextField";
|
||||||
import { KeyboardEvent, useState } from "react";
|
import { KeyboardEvent, useState } from "react";
|
||||||
import Modal from "../../Main/Modal/Modal";
|
import Modal from "../../Main/Modal/Modal";
|
||||||
import { getFromStorage, removeFromStorage, saveToStorage } from "../../../utils/storage";
|
import { useSearchParams } from "react-router-dom";
|
||||||
|
|
||||||
const title = "Table settings";
|
const title = "Table settings";
|
||||||
|
|
||||||
@@ -30,6 +30,8 @@ const TableSettings: FC<TableSettingsProps> = ({
|
|||||||
onChangeColumns,
|
onChangeColumns,
|
||||||
toggleTableCompact
|
toggleTableCompact
|
||||||
}) => {
|
}) => {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
||||||
const buttonRef = useRef<HTMLDivElement>(null);
|
const buttonRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -38,11 +40,6 @@ const TableSettings: FC<TableSettingsProps> = ({
|
|||||||
setFalse: handleClose,
|
setFalse: handleClose,
|
||||||
} = useBoolean(false);
|
} = useBoolean(false);
|
||||||
|
|
||||||
const {
|
|
||||||
value: saveColumns,
|
|
||||||
toggle: toggleSaveColumns,
|
|
||||||
} = useBoolean(Boolean(getFromStorage("TABLE_COLUMNS")));
|
|
||||||
|
|
||||||
const [searchColumn, setSearchColumn] = useState("");
|
const [searchColumn, setSearchColumn] = useState("");
|
||||||
const [indexFocusItem, setIndexFocusItem] = useState(-1);
|
const [indexFocusItem, setIndexFocusItem] = useState(-1);
|
||||||
|
|
||||||
@@ -60,15 +57,34 @@ const TableSettings: FC<TableSettingsProps> = ({
|
|||||||
return filteredColumns.every(col => selectedColumns.includes(col));
|
return filteredColumns.every(col => selectedColumns.includes(col));
|
||||||
}, [selectedColumns, filteredColumns]);
|
}, [selectedColumns, filteredColumns]);
|
||||||
|
|
||||||
|
const handleChangeDisplayColumns = (displayColumns: string[]) => {
|
||||||
|
onChangeColumns(displayColumns);
|
||||||
|
|
||||||
|
const updatedParams = new URLSearchParams(searchParams.toString());
|
||||||
|
const isAllCheck = displayColumns.length === columns.length;
|
||||||
|
|
||||||
|
if (isAllCheck) {
|
||||||
|
updatedParams.delete("columns");
|
||||||
|
} else {
|
||||||
|
updatedParams.set("columns", displayColumns.map(encodeURIComponent).join(","));
|
||||||
|
}
|
||||||
|
|
||||||
|
setSearchParams(updatedParams);
|
||||||
|
};
|
||||||
|
|
||||||
const handleChange = (key: string) => {
|
const handleChange = (key: string) => {
|
||||||
onChangeColumns(selectedColumns.includes(key) ? selectedColumns.filter(col => col !== key) : [...selectedColumns, key]);
|
const displayColumns = selectedColumns.includes(key)
|
||||||
|
? selectedColumns.filter(col => col !== key)
|
||||||
|
: [...selectedColumns, key];
|
||||||
|
|
||||||
|
handleChangeDisplayColumns(displayColumns);
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleAllColumns = () => {
|
const toggleAllColumns = () => {
|
||||||
if (isAllChecked) {
|
if (isAllChecked) {
|
||||||
onChangeColumns(selectedColumns.filter(col => !filteredColumns.includes(col)));
|
handleChangeDisplayColumns(selectedColumns.filter(col => !filteredColumns.includes(col)));
|
||||||
} else {
|
} else {
|
||||||
onChangeColumns(filteredColumns);
|
handleChangeDisplayColumns(filteredColumns);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -95,22 +111,16 @@ const TableSettings: FC<TableSettingsProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (arrayEquals(columns, selectedColumns) || saveColumns) return;
|
if (arrayEquals(columns, selectedColumns) || searchParams.has("columns")) return;
|
||||||
onChangeColumns(columns);
|
onChangeColumns(columns);
|
||||||
}, [columns]);
|
}, [columns]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!saveColumns) {
|
const hasColumns = searchParams.has("columns");
|
||||||
removeFromStorage(["TABLE_COLUMNS"]);
|
if (!hasColumns) return;
|
||||||
} else if (selectedColumns.length) {
|
const columnsParam = searchParams.get("columns") || "";
|
||||||
saveToStorage("TABLE_COLUMNS", selectedColumns.join(","));
|
const columnsArray = columnsParam.split(",").map(decodeURIComponent).filter(Boolean);
|
||||||
}
|
onChangeColumns(columnsArray);
|
||||||
}, [saveColumns, selectedColumns]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const saveColumns = getFromStorage("TABLE_COLUMNS") as string;
|
|
||||||
if (!saveColumns) return;
|
|
||||||
onChangeColumns(saveColumns.split(","));
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -183,19 +193,6 @@ const TableSettings: FC<TableSettingsProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="vm-table-settings-modal-preserve">
|
|
||||||
<Checkbox
|
|
||||||
checked={saveColumns}
|
|
||||||
onChange={toggleSaveColumns}
|
|
||||||
label={"Preserve column settings"}
|
|
||||||
disabled={tableCompact}
|
|
||||||
color={"primary"}
|
|
||||||
/>
|
|
||||||
<p className="vm-table-settings-modal-preserve__info">
|
|
||||||
This label indicates that when the checkbox is activated,
|
|
||||||
the current column configurations will not be reset.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="vm-table-settings-modal-section">
|
<div className="vm-table-settings-modal-section">
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
.vm-table-settings {
|
.vm-table-settings {
|
||||||
&-modal {
|
&-modal {
|
||||||
.vm-modal-content-body {
|
.vm-modal-content-body {
|
||||||
|
min-width: clamp(300px, 600px, 90vw);
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,16 +84,5 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&-preserve {
|
|
||||||
padding: $padding-global;
|
|
||||||
|
|
||||||
&__info {
|
|
||||||
padding-top: $padding-small;
|
|
||||||
font-size: $font-size-small;
|
|
||||||
color: $color-text-secondary;
|
|
||||||
line-height: 130%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import useElementSize from "../../../hooks/useElementSize";
|
|||||||
import { ChartTooltipProps } from "../../Chart/ChartTooltip/ChartTooltip";
|
import { ChartTooltipProps } from "../../Chart/ChartTooltip/ChartTooltip";
|
||||||
import LegendAnomaly from "../../Chart/Line/LegendAnomaly/LegendAnomaly";
|
import LegendAnomaly from "../../Chart/Line/LegendAnomaly/LegendAnomaly";
|
||||||
import { groupByMultipleKeys } from "../../../utils/array";
|
import { groupByMultipleKeys } from "../../../utils/array";
|
||||||
|
import { useGraphDispatch } from "../../../state/graph/GraphStateContext";
|
||||||
|
|
||||||
export interface GraphViewProps {
|
export interface GraphViewProps {
|
||||||
data?: MetricResult[];
|
data?: MetricResult[];
|
||||||
@@ -62,6 +63,8 @@ const GraphView: FC<GraphViewProps> = ({
|
|||||||
isAnomalyView,
|
isAnomalyView,
|
||||||
spanGaps
|
spanGaps
|
||||||
}) => {
|
}) => {
|
||||||
|
const graphDispatch = useGraphDispatch();
|
||||||
|
|
||||||
const { isMobile } = useDeviceDetect();
|
const { isMobile } = useDeviceDetect();
|
||||||
const { timezone } = useTimeState();
|
const { timezone } = useTimeState();
|
||||||
const currentStep = useMemo(() => customStep || period.step || "1s", [period.step, customStep]);
|
const currentStep = useMemo(() => customStep || period.step || "1s", [period.step, customStep]);
|
||||||
@@ -196,6 +199,26 @@ const GraphView: FC<GraphViewProps> = ({
|
|||||||
|
|
||||||
const [containerRef, containerSize] = useElementSize();
|
const [containerRef, containerSize] = useElementSize();
|
||||||
|
|
||||||
|
const hasTimeData = dataChart[0]?.length > 0;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const checkEmptyHistogram = () => {
|
||||||
|
if (!isHistogram || !data[1]) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const values = (dataChart?.[1]?.[2] || []) as (number | null)[];
|
||||||
|
return values.every(v => v === null);
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isEmpty = checkEmptyHistogram();
|
||||||
|
graphDispatch({ type: "SET_IS_EMPTY_HISTOGRAM", payload: isEmpty });
|
||||||
|
}, [dataChart, isHistogram]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={classNames({
|
className={classNames({
|
||||||
@@ -205,7 +228,7 @@ const GraphView: FC<GraphViewProps> = ({
|
|||||||
})}
|
})}
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
>
|
>
|
||||||
{!isHistogram && (
|
{!isHistogram && hasTimeData && (
|
||||||
<LineChart
|
<LineChart
|
||||||
data={dataChart}
|
data={dataChart}
|
||||||
series={series}
|
series={series}
|
||||||
|
|||||||
@@ -1,2 +1,22 @@
|
|||||||
|
import { DATE_TIME_FORMAT } from "./date";
|
||||||
|
|
||||||
export const LOGS_ENTRIES_LIMIT = 50;
|
export const LOGS_ENTRIES_LIMIT = 50;
|
||||||
export const LOGS_BARS_VIEW = 100;
|
export const LOGS_BARS_VIEW = 100;
|
||||||
|
export const LOGS_LIMIT_HITS = 5;
|
||||||
|
|
||||||
|
// "Ungrouped" is a string that is used as a value for the "groupBy" parameter.
|
||||||
|
export const WITHOUT_GROUPING = "Ungrouped";
|
||||||
|
|
||||||
|
// Default values for the logs configurators.
|
||||||
|
export const LOGS_GROUP_BY = "_stream";
|
||||||
|
export const LOGS_DISPLAY_FIELDS = "_msg";
|
||||||
|
export const LOGS_DATE_FORMAT = `${DATE_TIME_FORMAT}.SSS`;
|
||||||
|
|
||||||
|
// URL parameters for the logs page.
|
||||||
|
export const LOGS_URL_PARAMS = {
|
||||||
|
GROUP_BY: "groupBy",
|
||||||
|
DISPLAY_FIELDS: "displayFields",
|
||||||
|
NO_WRAP_LINES: "noWrapLines",
|
||||||
|
COMPACT_GROUP_HEADER: "compactGroupHeader",
|
||||||
|
DATE_FORMAT: "dateFormat",
|
||||||
|
};
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const useClickOutside = <T extends HTMLElement = HTMLElement>(
|
|||||||
handler(event); // Call the handler only if the click is outside of the element passed.
|
handler(event); // Call the handler only if the click is outside of the element passed.
|
||||||
}, [ref, handler]);
|
}, [ref, handler]);
|
||||||
|
|
||||||
useEventListener("mousedown", listener);
|
useEventListener("mouseup", listener);
|
||||||
useEventListener("touchstart", listener);
|
useEventListener("touchstart", listener);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { isHistogramData } from "../utils/metric";
|
|||||||
import { useGraphState } from "../state/graph/GraphStateContext";
|
import { useGraphState } from "../state/graph/GraphStateContext";
|
||||||
import { getStepFromDuration } from "../utils/time";
|
import { getStepFromDuration } from "../utils/time";
|
||||||
import { AppType } from "../types/appType";
|
import { AppType } from "../types/appType";
|
||||||
|
import { getQueryStringValue } from "../utils/query-string";
|
||||||
|
|
||||||
interface FetchQueryParams {
|
interface FetchQueryParams {
|
||||||
predefinedQuery?: string[]
|
predefinedQuery?: string[]
|
||||||
@@ -132,7 +133,8 @@ export const useFetchQuery = ({
|
|||||||
tempTraces.push(trace);
|
tempTraces.push(trace);
|
||||||
}
|
}
|
||||||
|
|
||||||
isHistogramResult = !isAnomalyUI && isDisplayChart && isHistogramData(resp.data.result);
|
const preventChangeType = !!getQueryStringValue("display_mode", null);
|
||||||
|
isHistogramResult = !isAnomalyUI && isDisplayChart && !preventChangeType && isHistogramData(resp.data.result);
|
||||||
seriesLimit = isHistogramResult ? Infinity : defaultLimit;
|
seriesLimit = isHistogramResult ? Infinity : defaultLimit;
|
||||||
const freeTempSize = seriesLimit - tempData.length;
|
const freeTempSize = seriesLimit - tempData.length;
|
||||||
resp.data.result.slice(0, freeTempSize).forEach((d: MetricBase) => {
|
resp.data.result.slice(0, freeTempSize).forEach((d: MetricBase) => {
|
||||||
|
|||||||
@@ -47,7 +47,9 @@ const GraphTab: FC<Props> = ({ isHistogram, graphData, controlsRef, isAnomalyVie
|
|||||||
<div className="vm-custom-panel-body-header__graph-controls">
|
<div className="vm-custom-panel-body-header__graph-controls">
|
||||||
<GraphTips/>
|
<GraphTips/>
|
||||||
<GraphSettings
|
<GraphSettings
|
||||||
|
data={graphData}
|
||||||
yaxis={yaxis}
|
yaxis={yaxis}
|
||||||
|
isHistogram={isHistogram}
|
||||||
setYaxisLimits={setYaxisLimits}
|
setYaxisLimits={setYaxisLimits}
|
||||||
toggleEnableLimits={toggleEnableLimits}
|
toggleEnableLimits={toggleEnableLimits}
|
||||||
spanGaps={{ value: spanGaps, onChange: setSpanGaps }}
|
spanGaps={{ value: spanGaps, onChange: setSpanGaps }}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import React, { FC } from "preact/compat";
|
||||||
|
import Alert from "../../../components/Main/Alert/Alert";
|
||||||
|
import { useGraphState } from "../../../state/graph/GraphStateContext";
|
||||||
|
import {
|
||||||
|
useChangeDisplayMode
|
||||||
|
} from "../../../components/Configurators/GraphSettings/GraphTypeSwitcher/useChangeDisplayMode";
|
||||||
|
import Button from "../../../components/Main/Button/Button";
|
||||||
|
import "./style.scss";
|
||||||
|
|
||||||
|
const WarningHeatmapToLine:FC = () => {
|
||||||
|
const { isEmptyHistogram } = useGraphState();
|
||||||
|
const { handleChange } = useChangeDisplayMode();
|
||||||
|
|
||||||
|
if (!isEmptyHistogram) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Alert variant="warning">
|
||||||
|
<div className="vm-warning-heatmap-to-line">
|
||||||
|
<p className="vm-warning-heatmap-to-line__text">
|
||||||
|
The expression cannot be displayed as a heatmap.
|
||||||
|
To make the graph work, disable the heatmap in the "Graph settings" or modify the expression.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
color="primary"
|
||||||
|
variant="text"
|
||||||
|
onClick={() => handleChange(false)}
|
||||||
|
>
|
||||||
|
Switch to line chart
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default WarningHeatmapToLine;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
@use "src/styles/variables" as *;
|
||||||
|
|
||||||
|
.vm-warning-heatmap-to-line {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import WarningLimitSeries from "./WarningLimitSeries/WarningLimitSeries";
|
|||||||
import CustomPanelTabs from "./CustomPanelTabs";
|
import CustomPanelTabs from "./CustomPanelTabs";
|
||||||
import { DisplayType } from "../../types";
|
import { DisplayType } from "../../types";
|
||||||
import DownloadReport from "./DownloadReport/DownloadReport";
|
import DownloadReport from "./DownloadReport/DownloadReport";
|
||||||
|
import WarningHeatmapToLine from "./WarningHeatmapToLine/WarningHeatmapToLine";
|
||||||
|
|
||||||
const CustomPanel: FC = () => {
|
const CustomPanel: FC = () => {
|
||||||
useSetQueryParams();
|
useSetQueryParams();
|
||||||
@@ -93,6 +94,7 @@ const CustomPanel: FC = () => {
|
|||||||
/>
|
/>
|
||||||
{showError && <Alert variant="error">{error}</Alert>}
|
{showError && <Alert variant="error">{error}</Alert>}
|
||||||
{showInstantQueryTip && <Alert variant="info"><InstantQueryTip/></Alert>}
|
{showInstantQueryTip && <Alert variant="info"><InstantQueryTip/></Alert>}
|
||||||
|
<WarningHeatmapToLine/>
|
||||||
{warning && (
|
{warning && (
|
||||||
<WarningLimitSeries
|
<WarningLimitSeries
|
||||||
warning={warning}
|
warning={warning}
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ const ExploreLogs: FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleApplyFilter = (val: string) => {
|
const handleApplyFilter = (val: string) => {
|
||||||
setQuery(prev => `_stream: ${val === "other" ? "{}" : val} AND (${prev})`);
|
setQuery(prev => `${val} AND (${prev})`);
|
||||||
setIsUpdatingQuery(true);
|
setIsUpdatingQuery(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +1,19 @@
|
|||||||
import React, { FC, useCallback, useEffect, useMemo, useRef } from "preact/compat";
|
import React, { FC, useCallback, useEffect, useMemo } from "preact/compat";
|
||||||
import { MouseEvent, useState } from "react";
|
import { useState } from "react";
|
||||||
import "./style.scss";
|
import "./style.scss";
|
||||||
import { Logs } from "../../../api/types";
|
import { Logs } from "../../../api/types";
|
||||||
import Accordion from "../../../components/Main/Accordion/Accordion";
|
import Accordion from "../../../components/Main/Accordion/Accordion";
|
||||||
import { groupByMultipleKeys } from "../../../utils/array";
|
import { groupByMultipleKeys } from "../../../utils/array";
|
||||||
import Tooltip from "../../../components/Main/Tooltip/Tooltip";
|
import Tooltip from "../../../components/Main/Tooltip/Tooltip";
|
||||||
import useCopyToClipboard from "../../../hooks/useCopyToClipboard";
|
|
||||||
import GroupLogsItem from "./GroupLogsItem";
|
import GroupLogsItem from "./GroupLogsItem";
|
||||||
import { useAppState } from "../../../state/common/StateContext";
|
|
||||||
import classNames from "classnames";
|
|
||||||
import Button from "../../../components/Main/Button/Button";
|
import Button from "../../../components/Main/Button/Button";
|
||||||
import { CollapseIcon, ExpandIcon, StorageIcon } from "../../../components/Main/Icons";
|
import { CollapseIcon, ExpandIcon } from "../../../components/Main/Icons";
|
||||||
import Popper from "../../../components/Main/Popper/Popper";
|
|
||||||
import TextField from "../../../components/Main/TextField/TextField";
|
|
||||||
import useBoolean from "../../../hooks/useBoolean";
|
|
||||||
import useStateSearchParams from "../../../hooks/useStateSearchParams";
|
|
||||||
import { useSearchParams } from "react-router-dom";
|
import { useSearchParams } from "react-router-dom";
|
||||||
import { getStreamPairs } from "../../../utils/logs";
|
import { getStreamPairs } from "../../../utils/logs";
|
||||||
|
import GroupLogsConfigurators
|
||||||
const WITHOUT_GROUPING = "No Grouping";
|
from "../../../components/LogsConfigurators/GroupLogsConfigurators/GroupLogsConfigurators";
|
||||||
|
import GroupLogsHeader from "./GroupLogsHeader";
|
||||||
|
import { LOGS_DISPLAY_FIELDS, LOGS_GROUP_BY, LOGS_URL_PARAMS, WITHOUT_GROUPING } from "../../../constants/logs";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
logs: Logs[];
|
logs: Logs[];
|
||||||
@@ -26,73 +21,31 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const GroupLogs: FC<Props> = ({ logs, settingsRef }) => {
|
const GroupLogs: FC<Props> = ({ logs, settingsRef }) => {
|
||||||
const { isDarkTheme } = useAppState();
|
const [searchParams] = useSearchParams();
|
||||||
const copyToClipboard = useCopyToClipboard();
|
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
|
||||||
|
|
||||||
const [expandGroups, setExpandGroups] = useState<boolean[]>([]);
|
const [expandGroups, setExpandGroups] = useState<boolean[]>([]);
|
||||||
const [groupBy, setGroupBy] = useStateSearchParams("_stream", "groupBy");
|
|
||||||
const [copied, setCopied] = useState<string | null>(null);
|
|
||||||
const [searchKey, setSearchKey] = useState("");
|
|
||||||
const optionsButtonRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const {
|
const groupBy = searchParams.get(LOGS_URL_PARAMS.GROUP_BY) || LOGS_GROUP_BY;
|
||||||
value: openOptions,
|
const displayFieldsString = searchParams.get(LOGS_URL_PARAMS.DISPLAY_FIELDS) || LOGS_DISPLAY_FIELDS;
|
||||||
toggle: toggleOpenOptions,
|
const displayFields = displayFieldsString.split(",");
|
||||||
setFalse: handleCloseOptions,
|
|
||||||
} = useBoolean(false);
|
|
||||||
|
|
||||||
const expandAll = useMemo(() => expandGroups.every(Boolean), [expandGroups]);
|
const expandAll = useMemo(() => expandGroups.every(Boolean), [expandGroups]);
|
||||||
|
|
||||||
const logsKeys = useMemo(() => {
|
|
||||||
const excludeKeys = ["_msg", "_time"];
|
|
||||||
const uniqKeys = Array.from(new Set(logs.map(l => Object.keys(l)).flat()));
|
|
||||||
return [WITHOUT_GROUPING, ...uniqKeys.filter(k => !excludeKeys.includes(k))];
|
|
||||||
}, [logs]);
|
|
||||||
|
|
||||||
const filteredLogsKeys = useMemo(() => {
|
|
||||||
if (!searchKey) return logsKeys;
|
|
||||||
try {
|
|
||||||
const regexp = new RegExp(searchKey, "i");
|
|
||||||
return logsKeys.filter(item => regexp.test(item))
|
|
||||||
.sort((a, b) => (a.match(regexp)?.index || 0) - (b.match(regexp)?.index || 0));
|
|
||||||
} catch (e) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}, [logsKeys, searchKey]);
|
|
||||||
|
|
||||||
const groupData = useMemo(() => {
|
const groupData = useMemo(() => {
|
||||||
return groupByMultipleKeys(logs, [groupBy]).map((item) => {
|
return groupByMultipleKeys(logs, [groupBy]).map((item) => {
|
||||||
const streamValue = item.values[0]?.[groupBy] || "";
|
const streamValue = item.values[0]?.[groupBy] || "";
|
||||||
const pairs = getStreamPairs(streamValue);
|
const pairs = getStreamPairs(streamValue);
|
||||||
// values sorting by time
|
// values sorting by time
|
||||||
const values = item.values.sort((a,b) => new Date(b._time).getTime() - new Date(a._time).getTime());
|
const values = item.values.sort((a, b) => new Date(b._time).getTime() - new Date(a._time).getTime());
|
||||||
return {
|
return {
|
||||||
keys: item.keys,
|
keys: item.keys,
|
||||||
keysString: item.keys.join(""),
|
keysString: item.keys.join(""),
|
||||||
values,
|
values,
|
||||||
pairs,
|
pairs,
|
||||||
};
|
};
|
||||||
}).sort((a, b) => a.keysString.localeCompare(b.keysString)); // groups sorting
|
}).sort((a, b) => b.values.length - a.values.length); // groups sorting
|
||||||
}, [logs, groupBy]);
|
}, [logs, groupBy]);
|
||||||
|
|
||||||
const handleClickByPair = (value: string) => async (e: MouseEvent<HTMLDivElement>) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
const isKeyValue = /(.+)?=(".+")/.test(value);
|
|
||||||
const copyValue = isKeyValue ? `${value.replace(/=/, ": ")}` : `${groupBy}: "${value}"`;
|
|
||||||
const isCopied = await copyToClipboard(copyValue);
|
|
||||||
if (isCopied) {
|
|
||||||
setCopied(value);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSelectGroupBy = (key: string) => () => {
|
|
||||||
setGroupBy(key);
|
|
||||||
searchParams.set("groupBy", key);
|
|
||||||
setSearchParams(searchParams);
|
|
||||||
handleCloseOptions();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleToggleExpandAll = useCallback(() => {
|
const handleToggleExpandAll = useCallback(() => {
|
||||||
setExpandGroups(new Array(groupData.length).fill(!expandAll));
|
setExpandGroups(new Array(groupData.length).fill(!expandAll));
|
||||||
}, [expandAll, groupData.length]);
|
}, [expandAll, groupData.length]);
|
||||||
@@ -105,11 +58,6 @@ const GroupLogs: FC<Props> = ({ logs, settingsRef }) => {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (copied === null) return;
|
|
||||||
const timeout = setTimeout(() => setCopied(null), 2000);
|
|
||||||
return () => clearTimeout(timeout);
|
|
||||||
}, [copied]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setExpandGroups(new Array(groupData.length).fill(true));
|
setExpandGroups(new Array(groupData.length).fill(true));
|
||||||
@@ -124,38 +72,16 @@ const GroupLogs: FC<Props> = ({ logs, settingsRef }) => {
|
|||||||
key={item.keysString}
|
key={item.keysString}
|
||||||
>
|
>
|
||||||
<Accordion
|
<Accordion
|
||||||
key={String(expandGroups[i])}
|
|
||||||
defaultExpanded={expandGroups[i]}
|
defaultExpanded={expandGroups[i]}
|
||||||
onChange={handleChangeExpand(i)}
|
onChange={handleChangeExpand(i)}
|
||||||
title={groupBy !== WITHOUT_GROUPING && (
|
title={groupBy !== WITHOUT_GROUPING && <GroupLogsHeader group={item}/>}
|
||||||
<div className="vm-group-logs-section-keys">
|
|
||||||
<span className="vm-group-logs-section-keys__title">Group by <code>{groupBy}</code>:</span>
|
|
||||||
{item.pairs.map((pair) => (
|
|
||||||
<Tooltip
|
|
||||||
title={copied === pair ? "Copied" : "Copy to clipboard"}
|
|
||||||
key={`${item.keysString}_${pair}`}
|
|
||||||
placement={"top-center"}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={classNames({
|
|
||||||
"vm-group-logs-section-keys__pair": true,
|
|
||||||
"vm-group-logs-section-keys__pair_dark": isDarkTheme
|
|
||||||
})}
|
|
||||||
onClick={handleClickByPair(pair)}
|
|
||||||
>
|
|
||||||
{pair}
|
|
||||||
</div>
|
|
||||||
</Tooltip>
|
|
||||||
))}
|
|
||||||
<span className="vm-group-logs-section-keys__count">{item.values.length} entries</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<div className="vm-group-logs-section-rows">
|
<div className="vm-group-logs-section-rows">
|
||||||
{item.values.map((value) => (
|
{item.values.map((value) => (
|
||||||
<GroupLogsItem
|
<GroupLogsItem
|
||||||
key={`${value._msg}${value._time}`}
|
key={`${value._msg}${value._time}`}
|
||||||
log={value}
|
log={value}
|
||||||
|
displayFields={displayFields}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -175,47 +101,7 @@ const GroupLogs: FC<Props> = ({ logs, settingsRef }) => {
|
|||||||
ariaLabel={expandAll ? "Collapse All" : "Expand All"}
|
ariaLabel={expandAll ? "Collapse All" : "Expand All"}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip title={"Group by"}>
|
<GroupLogsConfigurators logs={logs}/>
|
||||||
<div ref={optionsButtonRef}>
|
|
||||||
<Button
|
|
||||||
variant="text"
|
|
||||||
startIcon={<StorageIcon/>}
|
|
||||||
onClick={toggleOpenOptions}
|
|
||||||
ariaLabel={"Group by"}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Tooltip>
|
|
||||||
{
|
|
||||||
<Popper
|
|
||||||
open={openOptions}
|
|
||||||
placement="bottom-right"
|
|
||||||
onClose={handleCloseOptions}
|
|
||||||
buttonRef={optionsButtonRef}
|
|
||||||
>
|
|
||||||
<div className="vm-list vm-group-logs-header-keys">
|
|
||||||
<div className="vm-group-logs-header-keys__search">
|
|
||||||
<TextField
|
|
||||||
label="Search key"
|
|
||||||
value={searchKey}
|
|
||||||
onChange={setSearchKey}
|
|
||||||
type="search"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{filteredLogsKeys.map(id => (
|
|
||||||
<div
|
|
||||||
className={classNames({
|
|
||||||
"vm-list-item": true,
|
|
||||||
"vm-list-item_active": id === groupBy
|
|
||||||
})}
|
|
||||||
key={id}
|
|
||||||
onClick={handleSelectGroupBy(id)}
|
|
||||||
>
|
|
||||||
{id}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Popper>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
), settingsRef.current)}
|
), settingsRef.current)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import React, { FC, memo, useCallback, useEffect, useState } from "preact/compat";
|
import React, { FC, memo, useCallback, useEffect, useState } from "preact/compat";
|
||||||
import Tooltip from "../../../components/Main/Tooltip/Tooltip";
|
import Tooltip from "../../../components/Main/Tooltip/Tooltip";
|
||||||
import Button from "../../../components/Main/Button/Button";
|
import Button from "../../../components/Main/Button/Button";
|
||||||
import { CopyIcon } from "../../../components/Main/Icons";
|
import { CopyIcon, StorageIcon, VisibilityIcon } from "../../../components/Main/Icons";
|
||||||
import useCopyToClipboard from "../../../hooks/useCopyToClipboard";
|
import useCopyToClipboard from "../../../hooks/useCopyToClipboard";
|
||||||
|
import { useSearchParams } from "react-router-dom";
|
||||||
|
import { LOGS_GROUP_BY, LOGS_URL_PARAMS } from "../../../constants/logs";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
field: string;
|
field: string;
|
||||||
@@ -11,8 +13,17 @@ interface Props {
|
|||||||
|
|
||||||
const GroupLogsFieldRow: FC<Props> = ({ field, value }) => {
|
const GroupLogsFieldRow: FC<Props> = ({ field, value }) => {
|
||||||
const copyToClipboard = useCopyToClipboard();
|
const copyToClipboard = useCopyToClipboard();
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
||||||
const [copied, setCopied] = useState<boolean>(false);
|
const [copied, setCopied] = useState<boolean>(false);
|
||||||
|
|
||||||
|
const groupBy = searchParams.get(LOGS_URL_PARAMS.GROUP_BY) || LOGS_GROUP_BY;
|
||||||
|
const displayFieldsString = searchParams.get(LOGS_URL_PARAMS.DISPLAY_FIELDS) || "";
|
||||||
|
const displayFields = displayFieldsString ? displayFieldsString.split(",") : [];
|
||||||
|
|
||||||
|
const isSelectedField = displayFields.includes(field);
|
||||||
|
const isGroupByField = groupBy === field;
|
||||||
|
|
||||||
const handleCopy = useCallback(async () => {
|
const handleCopy = useCallback(async () => {
|
||||||
if (copied) return;
|
if (copied) return;
|
||||||
try {
|
try {
|
||||||
@@ -23,6 +34,18 @@ const GroupLogsFieldRow: FC<Props> = ({ field, value }) => {
|
|||||||
}
|
}
|
||||||
}, [copied, copyToClipboard]);
|
}, [copied, copyToClipboard]);
|
||||||
|
|
||||||
|
const handleSelectDisplayField = () => {
|
||||||
|
const prev = displayFields;
|
||||||
|
const newDisplayFields = prev.includes(field) ? prev.filter(v => v !== field) : [...prev, field];
|
||||||
|
searchParams.set(LOGS_URL_PARAMS.DISPLAY_FIELDS, newDisplayFields.join(","));
|
||||||
|
setSearchParams(searchParams);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectGroupBy = () => {
|
||||||
|
isGroupByField ? searchParams.delete(LOGS_URL_PARAMS.GROUP_BY) : searchParams.set(LOGS_URL_PARAMS.GROUP_BY, field);
|
||||||
|
setSearchParams(searchParams);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (copied === null) return;
|
if (copied === null) return;
|
||||||
const timeout = setTimeout(() => setCopied(false), 2000);
|
const timeout = setTimeout(() => setCopied(false), 2000);
|
||||||
@@ -35,6 +58,7 @@ const GroupLogsFieldRow: FC<Props> = ({ field, value }) => {
|
|||||||
<div className="vm-group-logs-row-fields-item-controls__wrapper">
|
<div className="vm-group-logs-row-fields-item-controls__wrapper">
|
||||||
<Tooltip title={copied ? "Copied" : "Copy to clipboard"}>
|
<Tooltip title={copied ? "Copied" : "Copy to clipboard"}>
|
||||||
<Button
|
<Button
|
||||||
|
className="vm-group-logs-row-fields-item-controls__button"
|
||||||
variant="text"
|
variant="text"
|
||||||
color="gray"
|
color="gray"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -43,6 +67,34 @@ const GroupLogsFieldRow: FC<Props> = ({ field, value }) => {
|
|||||||
ariaLabel="copy to clipboard"
|
ariaLabel="copy to clipboard"
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
<Tooltip
|
||||||
|
key={`${field}_${isSelectedField}_${isGroupByField}`}
|
||||||
|
title={isSelectedField ? "Hide this field" : "Show this field instead of the message"}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
className="vm-group-logs-row-fields-item-controls__button"
|
||||||
|
variant="text"
|
||||||
|
color={isSelectedField ? "secondary" : "gray"}
|
||||||
|
size="small"
|
||||||
|
startIcon={isSelectedField ? <VisibilityIcon/> : <VisibilityIcon/>}
|
||||||
|
onClick={handleSelectDisplayField}
|
||||||
|
ariaLabel="copy to clipboard"
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip
|
||||||
|
key={`${field}_${isSelectedField}_${isGroupByField}`}
|
||||||
|
title={isGroupByField ? "Ungroup this field" : "Group by this field"}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
className="vm-group-logs-row-fields-item-controls__button"
|
||||||
|
variant="text"
|
||||||
|
color={isGroupByField ? "secondary" : "gray"}
|
||||||
|
size="small"
|
||||||
|
startIcon={<StorageIcon/>}
|
||||||
|
onClick={handleSelectGroupBy}
|
||||||
|
ariaLabel="copy to clipboard"
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="vm-group-logs-row-fields-item__key">{field}</td>
|
<td className="vm-group-logs-row-fields-item__key">{field}</td>
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import React, { FC, useCallback, useEffect, useRef } from "preact/compat";
|
||||||
|
import classNames from "classnames";
|
||||||
|
import { useSearchParams } from "react-router-dom";
|
||||||
|
import { MouseEvent, useState } from "react";
|
||||||
|
import { useAppState } from "../../../state/common/StateContext";
|
||||||
|
import { Logs } from "../../../api/types";
|
||||||
|
import useEventListener from "../../../hooks/useEventListener";
|
||||||
|
import Popper from "../../../components/Main/Popper/Popper";
|
||||||
|
import useBoolean from "../../../hooks/useBoolean";
|
||||||
|
import GroupLogsHeaderItem from "./GroupLogsHeaderItem";
|
||||||
|
import { LOGS_GROUP_BY, LOGS_URL_PARAMS } from "../../../constants/logs";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
group: {
|
||||||
|
keys: string[]
|
||||||
|
keysString: string
|
||||||
|
values: Logs[]
|
||||||
|
pairs: string[]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const GroupLogsHeader: FC<Props> = ({ group }) => {
|
||||||
|
const { isDarkTheme } = useAppState();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const moreRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
value: openMore,
|
||||||
|
toggle: handleToggleMore,
|
||||||
|
setFalse: handleCloseMore,
|
||||||
|
} = useBoolean(false);
|
||||||
|
|
||||||
|
const [hideParisCount, setHideParisCount] = useState<number>(0);
|
||||||
|
|
||||||
|
const groupBy = searchParams.get(LOGS_URL_PARAMS.GROUP_BY) || LOGS_GROUP_BY;
|
||||||
|
const compactGroupHeader = searchParams.get(LOGS_URL_PARAMS.COMPACT_GROUP_HEADER) === "true";
|
||||||
|
|
||||||
|
const pairs = group.pairs;
|
||||||
|
const hideAboveIndex = pairs.length - hideParisCount - 1;
|
||||||
|
|
||||||
|
const handleClickMore = (e: MouseEvent<HTMLDivElement>) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleToggleMore();
|
||||||
|
};
|
||||||
|
|
||||||
|
const calcVisiblePairsCount = useCallback(() => {
|
||||||
|
if (!compactGroupHeader || !containerRef.current) {
|
||||||
|
setHideParisCount(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = containerRef.current;
|
||||||
|
const containerSize = container.getBoundingClientRect();
|
||||||
|
const selector = ".vm-group-logs-section-keys__pair:not(.vm-group-logs-section-keys__pair_more)";
|
||||||
|
const children = Array.from(container.querySelectorAll(selector));
|
||||||
|
let count = 0;
|
||||||
|
|
||||||
|
for (const child of children) {
|
||||||
|
const { right } = (child as HTMLElement).getBoundingClientRect();
|
||||||
|
|
||||||
|
if ((right + 220) > containerSize.width) {
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setHideParisCount(count);
|
||||||
|
}, [compactGroupHeader, containerRef]);
|
||||||
|
|
||||||
|
useEffect(calcVisiblePairsCount, [group.pairs, compactGroupHeader, containerRef]);
|
||||||
|
|
||||||
|
useEventListener("resize", calcVisiblePairsCount);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={classNames({
|
||||||
|
"vm-group-logs-section-keys": true,
|
||||||
|
"vm-group-logs-section-keys_compact": compactGroupHeader,
|
||||||
|
})}
|
||||||
|
ref={containerRef}
|
||||||
|
>
|
||||||
|
<span className="vm-group-logs-section-keys__title">Group by <code>{groupBy}</code>:</span>
|
||||||
|
{pairs.map((pair, i) => (
|
||||||
|
<GroupLogsHeaderItem
|
||||||
|
key={`${group.keysString}_${pair}`}
|
||||||
|
pair={pair}
|
||||||
|
isHide={hideParisCount ? i > hideAboveIndex : false}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{hideParisCount > 0 && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={classNames({
|
||||||
|
"vm-group-logs-section-keys__pair": true,
|
||||||
|
"vm-group-logs-section-keys__pair_more": true,
|
||||||
|
"vm-group-logs-section-keys__pair_dark": isDarkTheme
|
||||||
|
})}
|
||||||
|
ref={moreRef}
|
||||||
|
onClick={handleClickMore}
|
||||||
|
>
|
||||||
|
+{hideParisCount} more
|
||||||
|
</div>
|
||||||
|
<Popper
|
||||||
|
open={openMore}
|
||||||
|
buttonRef={moreRef}
|
||||||
|
placement="bottom-left"
|
||||||
|
onClose={handleCloseMore}
|
||||||
|
>
|
||||||
|
<div className="vm-group-logs-section-keys vm-group-logs-section-keys_popper">
|
||||||
|
{pairs.slice(hideAboveIndex + 1).map((pair) => (
|
||||||
|
<GroupLogsHeaderItem
|
||||||
|
key={`${group.keysString}_${pair}`}
|
||||||
|
pair={pair}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Popper>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<span className="vm-group-logs-section-keys__count">{group.values.length} entries</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GroupLogsHeader;
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import React, { FC, useEffect } from "preact/compat";
|
||||||
|
import { useAppState } from "../../../state/common/StateContext";
|
||||||
|
import Tooltip from "../../../components/Main/Tooltip/Tooltip";
|
||||||
|
import classNames from "classnames";
|
||||||
|
import { MouseEvent, useState } from "react";
|
||||||
|
import useCopyToClipboard from "../../../hooks/useCopyToClipboard";
|
||||||
|
import { useSearchParams } from "react-router-dom";
|
||||||
|
import { LOGS_GROUP_BY, LOGS_URL_PARAMS } from "../../../constants/logs";
|
||||||
|
import { convertToFieldFilter } from "../../../utils/logs";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
pair: string;
|
||||||
|
isHide?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GroupLogsHeaderItem: FC<Props> = ({ pair, isHide }) => {
|
||||||
|
const { isDarkTheme } = useAppState();
|
||||||
|
const copyToClipboard = useCopyToClipboard();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
|
const [copied, setCopied] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const groupBy = searchParams.get(LOGS_URL_PARAMS.GROUP_BY) || LOGS_GROUP_BY;
|
||||||
|
|
||||||
|
const handleClickByPair = (value: string) => async (e: MouseEvent<HTMLDivElement>) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const copyValue = convertToFieldFilter(value, groupBy);
|
||||||
|
const isCopied = await copyToClipboard(copyValue);
|
||||||
|
if (isCopied) {
|
||||||
|
setCopied(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (copied === null) return;
|
||||||
|
const timeout = setTimeout(() => setCopied(null), 2000);
|
||||||
|
return () => clearTimeout(timeout);
|
||||||
|
}, [copied]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
title={copied === pair ? "Copied" : "Copy to clipboard"}
|
||||||
|
placement={"top-center"}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={classNames({
|
||||||
|
"vm-group-logs-section-keys__pair": true,
|
||||||
|
"vm-group-logs-section-keys__pair_hide": isHide,
|
||||||
|
"vm-group-logs-section-keys__pair_dark": isDarkTheme
|
||||||
|
})}
|
||||||
|
onClick={handleClickByPair(pair)}
|
||||||
|
>
|
||||||
|
{pair}
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GroupLogsHeaderItem;
|
||||||
@@ -6,38 +6,52 @@ import { ArrowDownIcon } from "../../../components/Main/Icons";
|
|||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { useLogsState } from "../../../state/logsPanel/LogsStateContext";
|
import { useLogsState } from "../../../state/logsPanel/LogsStateContext";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import { DATE_TIME_FORMAT } from "../../../constants/date";
|
|
||||||
import { useTimeState } from "../../../state/time/TimeStateContext";
|
import { useTimeState } from "../../../state/time/TimeStateContext";
|
||||||
import GroupLogsFieldRow from "./GroupLogsFieldRow";
|
import GroupLogsFieldRow from "./GroupLogsFieldRow";
|
||||||
import { marked } from "marked";
|
import { marked } from "marked";
|
||||||
|
import { useSearchParams } from "react-router-dom";
|
||||||
|
import { LOGS_DATE_FORMAT, LOGS_URL_PARAMS } from "../../../constants/logs";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
log: Logs;
|
log: Logs;
|
||||||
|
displayFields?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const GroupLogsItem: FC<Props> = ({ log }) => {
|
const GroupLogsItem: FC<Props> = ({ log, displayFields = ["_msg"] }) => {
|
||||||
const {
|
const {
|
||||||
value: isOpenFields,
|
value: isOpenFields,
|
||||||
toggle: toggleOpenFields,
|
toggle: toggleOpenFields,
|
||||||
} = useBoolean(false);
|
} = useBoolean(false);
|
||||||
|
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
const { markdownParsing } = useLogsState();
|
const { markdownParsing } = useLogsState();
|
||||||
const { timezone } = useTimeState();
|
const { timezone } = useTimeState();
|
||||||
|
|
||||||
|
const noWrapLines = searchParams.get(LOGS_URL_PARAMS.NO_WRAP_LINES) === "true";
|
||||||
|
const dateFormat = searchParams.get(LOGS_URL_PARAMS.DATE_FORMAT) || LOGS_DATE_FORMAT;
|
||||||
|
|
||||||
const formattedTime = useMemo(() => {
|
const formattedTime = useMemo(() => {
|
||||||
if (!log._time) return "";
|
if (!log._time) return "";
|
||||||
return dayjs(log._time).tz().format(`${DATE_TIME_FORMAT}.SSS`);
|
return dayjs(log._time).tz().format(dateFormat);
|
||||||
}, [log._time, timezone]);
|
}, [log._time, timezone, dateFormat]);
|
||||||
|
|
||||||
const formattedMarkdown = useMemo(() => {
|
const formattedMarkdown = useMemo(() => {
|
||||||
if (!markdownParsing || !log._msg) return "";
|
if (!markdownParsing || !log._msg) return "";
|
||||||
return marked(log._msg.replace(/```/g, "\n```\n")) as string;
|
return marked(log._msg.replace(/```/g, "\n```\n")) as string;
|
||||||
}, [log._msg, markdownParsing]);
|
}, [log._msg, markdownParsing]);
|
||||||
|
|
||||||
const fields = useMemo(() => Object.entries(log).filter(([key]) => key !== "_msg"), [log]);
|
const fields = useMemo(() => Object.entries(log), [log]);
|
||||||
const hasFields = fields.length > 0;
|
const hasFields = fields.length > 0;
|
||||||
|
|
||||||
const displayMessage = useMemo(() => {
|
const displayMessage = useMemo(() => {
|
||||||
|
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 (log._msg) return log._msg;
|
if (log._msg) return log._msg;
|
||||||
if (!hasFields) return;
|
if (!hasFields) return;
|
||||||
const dataObject = fields.reduce<{ [key: string]: string }>((obj, [key, value]) => {
|
const dataObject = fields.reduce<{ [key: string]: string }>((obj, [key, value]) => {
|
||||||
@@ -45,7 +59,7 @@ const GroupLogsItem: FC<Props> = ({ log }) => {
|
|||||||
return obj;
|
return obj;
|
||||||
}, {});
|
}, {});
|
||||||
return JSON.stringify(dataObject);
|
return JSON.stringify(dataObject);
|
||||||
}, [log, fields, hasFields]);
|
}, [log, fields, hasFields, displayFields]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="vm-group-logs-row">
|
<div className="vm-group-logs-row">
|
||||||
@@ -76,7 +90,8 @@ const GroupLogsItem: FC<Props> = ({ log }) => {
|
|||||||
className={classNames({
|
className={classNames({
|
||||||
"vm-group-logs-row-content__msg": true,
|
"vm-group-logs-row-content__msg": true,
|
||||||
"vm-group-logs-row-content__msg_empty-msg": !log._msg,
|
"vm-group-logs-row-content__msg_empty-msg": !log._msg,
|
||||||
"vm-group-logs-row-content__msg_missing": !displayMessage
|
"vm-group-logs-row-content__msg_missing": !displayMessage,
|
||||||
|
"vm-group-logs-row-content__msg_single-line": noWrapLines,
|
||||||
})}
|
})}
|
||||||
dangerouslySetInnerHTML={(markdownParsing && formattedMarkdown) ? { __html: formattedMarkdown } : undefined}
|
dangerouslySetInnerHTML={(markdownParsing && formattedMarkdown) ? { __html: formattedMarkdown } : undefined}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
@use "src/styles/variables" as *;
|
@use "src/styles/variables" as *;
|
||||||
|
|
||||||
|
$font-size-logs: var(--font-size-logs, $font-size-small);
|
||||||
|
|
||||||
.vm-group-logs {
|
.vm-group-logs {
|
||||||
margin-top: calc(-1 * $padding-medium);
|
margin-top: calc(-1 * $padding-medium);
|
||||||
|
|
||||||
@@ -19,22 +21,44 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
&-section {
|
&-section {
|
||||||
|
border-bottom: $border-divider;
|
||||||
|
|
||||||
&-keys {
|
&-keys {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: $padding-small;
|
gap: $padding-small;
|
||||||
border-bottom: $border-divider;
|
padding: $padding-small 120px $padding-small 0;
|
||||||
padding: $padding-small 0;
|
font-size: $font-size-logs;
|
||||||
|
|
||||||
|
&_compact {
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
&_popper {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: flex-start;
|
||||||
|
padding: $padding-global;
|
||||||
|
max-height: 400px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
&__title {
|
&__title {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
code {
|
code {
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
|
|
||||||
&:before {
|
&:before {
|
||||||
content: "\"";
|
content: "\"";
|
||||||
}
|
}
|
||||||
|
|
||||||
&:after {
|
&:after {
|
||||||
content: "\"";
|
content: "\"";
|
||||||
}
|
}
|
||||||
@@ -42,19 +66,35 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
&__count {
|
&__count {
|
||||||
|
position: absolute;
|
||||||
|
top: auto;
|
||||||
|
right: 0;
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
font-size: $font-size-small;
|
font-size: $font-size-logs;
|
||||||
color: $color-text-secondary;
|
color: $color-text-secondary;
|
||||||
padding-right: calc($padding-large * 3);
|
padding-right: calc($padding-large * 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
&__pair {
|
&__pair {
|
||||||
|
order: 0;
|
||||||
padding: calc($padding-global / 2) $padding-global;
|
padding: calc($padding-global / 2) $padding-global;
|
||||||
background-color: lighten($color-tropical-blue, 6%);
|
background-color: lighten($color-tropical-blue, 6%);
|
||||||
color: darken($color-dodger-blue, 20%);
|
color: darken($color-dodger-blue, 20%);
|
||||||
border-radius: $border-radius-medium;
|
border-radius: $border-radius-medium;
|
||||||
transition: background-color 0.3s ease-in, transform 0.1s ease-in, opacity 0.3s ease-in;
|
transition: background-color 0.3s ease-in, transform 0.1s ease-in, opacity 0.3s ease-in;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
&_hide {
|
||||||
|
order: 2;
|
||||||
|
visibility: hidden;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&_more {
|
||||||
|
order: 1;
|
||||||
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: $color-tropical-blue;
|
background-color: $color-tropical-blue;
|
||||||
@@ -84,13 +124,19 @@
|
|||||||
|
|
||||||
&-row {
|
&-row {
|
||||||
position: relative;
|
position: relative;
|
||||||
border-bottom: $border-divider;
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: $padding-small;
|
||||||
|
}
|
||||||
|
|
||||||
&-content {
|
&-content {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: auto minmax(180px, max-content) 1fr;
|
grid-template-columns: auto max-content 1fr;
|
||||||
padding: $padding-global 0;
|
padding: calc($padding-small / 4) 0;
|
||||||
|
font-size: $font-size-logs;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
line-height: 1.3;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background-color 0.2s ease-in;
|
transition: background-color 0.2s ease-in;
|
||||||
|
|
||||||
@@ -116,8 +162,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
margin-right: $padding-small;
|
padding: 0 $padding-global 0 $padding-small;
|
||||||
line-height: 1;
|
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|
||||||
&_missing {
|
&_missing {
|
||||||
@@ -130,7 +175,12 @@
|
|||||||
&__msg {
|
&__msg {
|
||||||
font-family: $font-family-monospace;
|
font-family: $font-family-monospace;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
line-height: 1.1;
|
|
||||||
|
&_single-line {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
&_empty-msg {
|
&_empty-msg {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -158,7 +208,7 @@
|
|||||||
border-radius: $border-radius-small;
|
border-radius: $border-radius-small;
|
||||||
tab-size: 4;
|
tab-size: 4;
|
||||||
font-variant-ligatures: none;
|
font-variant-ligatures: none;
|
||||||
margin: calc($padding-small/4) 0;
|
margin: calc($padding-small / 4) 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
p {
|
p {
|
||||||
@@ -171,7 +221,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
code {
|
code {
|
||||||
font-size: $font-size-small;
|
font-size: $font-size-logs;
|
||||||
padding: calc($padding-small / 4) calc($padding-small / 2);
|
padding: calc($padding-small / 4) calc($padding-small / 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,25 +244,35 @@
|
|||||||
|
|
||||||
blockquote {
|
blockquote {
|
||||||
border-left: 4px solid $color-hover-black;
|
border-left: 4px solid $color-hover-black;
|
||||||
margin: calc($padding-small/2) $padding-small;
|
margin: calc($padding-small / 2) $padding-small;
|
||||||
padding: calc($padding-small/2) $padding-small;
|
padding: calc($padding-small / 2) $padding-small;
|
||||||
}
|
}
|
||||||
|
|
||||||
ul, ol {
|
ul, ol {
|
||||||
list-style-position: inside;
|
list-style-position: inside;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* end styles for markdown */
|
/* end styles for markdown */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__sub-msg {
|
||||||
|
padding-right: $padding-global;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&-fields {
|
&-fields {
|
||||||
|
position: relative;
|
||||||
grid-row: 2;
|
grid-row: 2;
|
||||||
padding: $padding-small 0;
|
padding: $padding-small 0;
|
||||||
margin-bottom: $padding-small;
|
margin: $padding-small 0 $padding-small calc($padding-global * 2);
|
||||||
border: $border-divider;
|
border: $border-divider;
|
||||||
border-radius: $border-radius-small;
|
border-radius: $border-radius-small;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
max-height: 300px;
|
max-height: 300px;
|
||||||
|
resize: vertical;
|
||||||
|
font-family: $font-family-monospace;
|
||||||
|
font-size: $font-size-logs;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
|
||||||
&-item {
|
&-item {
|
||||||
border-radius: $border-radius-small;
|
border-radius: $border-radius-small;
|
||||||
@@ -223,19 +283,26 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
&-controls {
|
&-controls {
|
||||||
padding: 0;
|
padding: 0 calc($padding-small / 2);
|
||||||
|
|
||||||
&__wrapper {
|
&__wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__button.vm-button_small {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
min-height: 22px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__key,
|
&__key,
|
||||||
&__value {
|
&__value {
|
||||||
vertical-align: top;
|
vertical-align: top;
|
||||||
padding: calc($padding-small / 2) $padding-global;
|
line-height: $font-size;
|
||||||
|
padding: calc($padding-small / 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
&__key {
|
&__key {
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { ErrorTypes, TimeParams } from "../../../types";
|
|||||||
import { LogHits } from "../../../api/types";
|
import { LogHits } from "../../../api/types";
|
||||||
import { useSearchParams } from "react-router-dom";
|
import { useSearchParams } from "react-router-dom";
|
||||||
import { getHitsTimeParams } from "../../../utils/logs";
|
import { getHitsTimeParams } from "../../../utils/logs";
|
||||||
|
import { LOGS_GROUP_BY, LOGS_LIMIT_HITS } from "../../../constants/logs";
|
||||||
|
import { isEmptyObject } from "../../../utils/object";
|
||||||
|
|
||||||
export const useFetchLogHits = (server: string, query: string) => {
|
export const useFetchLogHits = (server: string, query: string) => {
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
@@ -30,46 +32,12 @@ export const useFetchLogHits = (server: string, query: string) => {
|
|||||||
step: `${step}ms`,
|
step: `${step}ms`,
|
||||||
start: start.toISOString(),
|
start: start.toISOString(),
|
||||||
end: end.toISOString(),
|
end: end.toISOString(),
|
||||||
field: "_stream" // In the future, this field can be made configurable
|
fields_limit: `${LOGS_LIMIT_HITS}`,
|
||||||
|
field: LOGS_GROUP_BY,
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const accumulateHits = (resultHit: LogHits, hit: LogHits) => {
|
|
||||||
resultHit.total = (resultHit.total || 0) + (hit.total || 0);
|
|
||||||
hit.timestamps.forEach((timestamp, i) => {
|
|
||||||
const index = resultHit.timestamps.findIndex(t => t === timestamp);
|
|
||||||
if (index === -1) {
|
|
||||||
resultHit.timestamps.push(timestamp);
|
|
||||||
resultHit.values.push(hit.values[i]);
|
|
||||||
} else {
|
|
||||||
resultHit.values[index] += hit.values[i];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return resultHit;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getHitsWithTop = (hits: LogHits[]) => {
|
|
||||||
const topN = 5;
|
|
||||||
const defaultHit = { fields: {}, timestamps: [], values: [], total: 0 };
|
|
||||||
|
|
||||||
const hitsByTotal = hits.sort((a, b) => (b.total || 0) - (a.total || 0));
|
|
||||||
const result = [];
|
|
||||||
|
|
||||||
const otherHits: LogHits = hitsByTotal.slice(topN).reduce(accumulateHits, defaultHit);
|
|
||||||
if (otherHits.total) {
|
|
||||||
result.push(otherHits);
|
|
||||||
}
|
|
||||||
|
|
||||||
const topHits: LogHits[] = hitsByTotal.slice(0, topN);
|
|
||||||
if (topHits.length) {
|
|
||||||
result.push(...topHits);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchLogHits = useCallback(async (period: TimeParams) => {
|
const fetchLogHits = useCallback(async (period: TimeParams) => {
|
||||||
abortControllerRef.current.abort();
|
abortControllerRef.current.abort();
|
||||||
abortControllerRef.current = new AbortController();
|
abortControllerRef.current = new AbortController();
|
||||||
@@ -98,7 +66,7 @@ export const useFetchLogHits = (server: string, query: string) => {
|
|||||||
setError(error);
|
setError(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
setLogHits(!hits ? [] : getHitsWithTop(hits));
|
setLogHits(hits.map(markIsOther).sort(sortHits));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof Error && e.name !== "AbortError") {
|
if (e instanceof Error && e.name !== "AbortError") {
|
||||||
setError(String(e));
|
setError(String(e));
|
||||||
@@ -117,3 +85,18 @@ export const useFetchLogHits = (server: string, query: string) => {
|
|||||||
abortController: abortControllerRef.current
|
abortController: abortControllerRef.current
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// Helper function to check if a hit is "other"
|
||||||
|
const markIsOther = (hit: LogHits) => ({
|
||||||
|
...hit,
|
||||||
|
_isOther: isEmptyObject(hit.fields)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Comparison function for sorting hits
|
||||||
|
const sortHits = (a: LogHits, b: LogHits) => {
|
||||||
|
if (a._isOther !== b._isOther) {
|
||||||
|
return a._isOther ? -1 : 1; // "Other" hits first to avoid graph overlap
|
||||||
|
}
|
||||||
|
return b.total - a.total; // Sort remaining by total for better visibility
|
||||||
|
};
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ const PredefinedPanel: FC<PredefinedPanelsProps> = ({
|
|||||||
{title || ""}
|
{title || ""}
|
||||||
</h3>
|
</h3>
|
||||||
<GraphSettings
|
<GraphSettings
|
||||||
|
data={graphData || []}
|
||||||
yaxis={yaxis}
|
yaxis={yaxis}
|
||||||
setYaxisLimits={setYaxisLimits}
|
setYaxisLimits={setYaxisLimits}
|
||||||
toggleEnableLimits={toggleEnableLimits}
|
toggleEnableLimits={toggleEnableLimits}
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ import TableSettings from "../../../components/Table/TableSettings/TableSettings
|
|||||||
import { getColumns } from "../../../hooks/useSortedCategories";
|
import { getColumns } from "../../../hooks/useSortedCategories";
|
||||||
import { useCustomPanelDispatch, useCustomPanelState } from "../../../state/customPanel/CustomPanelStateContext";
|
import { useCustomPanelDispatch, useCustomPanelState } from "../../../state/customPanel/CustomPanelStateContext";
|
||||||
import TableView from "../../../components/Views/TableView/TableView";
|
import TableView from "../../../components/Views/TableView/TableView";
|
||||||
|
import { useSearchParams } from "react-router-dom";
|
||||||
|
import WarningHeatmapToLine from "../../CustomPanel/WarningHeatmapToLine/WarningHeatmapToLine";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
data: DataAnalyzerType[];
|
data: DataAnalyzerType[];
|
||||||
@@ -28,6 +30,8 @@ type Props = {
|
|||||||
|
|
||||||
const QueryAnalyzerView: FC<Props> = ({ data, period }) => {
|
const QueryAnalyzerView: FC<Props> = ({ data, period }) => {
|
||||||
const { isMobile } = useDeviceDetect();
|
const { isMobile } = useDeviceDetect();
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
||||||
const { tableCompact } = useCustomPanelState();
|
const { tableCompact } = useCustomPanelState();
|
||||||
const customPanelDispatch = useCustomPanelDispatch();
|
const customPanelDispatch = useCustomPanelDispatch();
|
||||||
|
|
||||||
@@ -101,11 +105,16 @@ const QueryAnalyzerView: FC<Props> = ({ data, period }) => {
|
|||||||
setQueries(tempQueries);
|
setQueries(tempQueries);
|
||||||
setGraphData(tempGraphData);
|
setGraphData(tempGraphData);
|
||||||
setLiveData(tempLiveData);
|
setLiveData(tempLiveData);
|
||||||
|
|
||||||
|
// reset display mode
|
||||||
|
searchParams.delete("display_mode");
|
||||||
|
setSearchParams(searchParams);
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsHistogram(!!graphData && isHistogramData(graphData));
|
const noSpecificDisplayMode = !searchParams.get("display_mode");
|
||||||
}, [graphData]);
|
setIsHistogram(!!graphData && noSpecificDisplayMode && isHistogramData(graphData));
|
||||||
|
}, [graphData, searchParams]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -120,6 +129,7 @@ const QueryAnalyzerView: FC<Props> = ({ data, period }) => {
|
|||||||
onDeleteClick={handleTraceDelete}
|
onDeleteClick={handleTraceDelete}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<WarningHeatmapToLine/>
|
||||||
<div
|
<div
|
||||||
className={classNames({
|
className={classNames({
|
||||||
"vm-block": true,
|
"vm-block": true,
|
||||||
@@ -138,7 +148,9 @@ const QueryAnalyzerView: FC<Props> = ({ data, period }) => {
|
|||||||
{displayType === "chart" && <GraphTips/>}
|
{displayType === "chart" && <GraphTips/>}
|
||||||
{displayType === "chart" && (
|
{displayType === "chart" && (
|
||||||
<GraphSettings
|
<GraphSettings
|
||||||
|
data={graphData || []}
|
||||||
yaxis={yaxis}
|
yaxis={yaxis}
|
||||||
|
isHistogram={isHistogram}
|
||||||
setYaxisLimits={setYaxisLimits}
|
setYaxisLimits={setYaxisLimits}
|
||||||
toggleEnableLimits={toggleEnableLimits}
|
toggleEnableLimits={toggleEnableLimits}
|
||||||
spanGaps={{ value: spanGaps, onChange: setSpanGaps }}
|
spanGaps={{ value: spanGaps, onChange: setSpanGaps }}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export interface GraphState {
|
|||||||
customStep: string
|
customStep: string
|
||||||
yaxis: YaxisState
|
yaxis: YaxisState
|
||||||
isHistogram: boolean
|
isHistogram: boolean
|
||||||
|
isEmptyHistogram: boolean
|
||||||
/** when true, null data values will not cause line breaks */
|
/** when true, null data values will not cause line breaks */
|
||||||
spanGaps: boolean
|
spanGaps: boolean
|
||||||
}
|
}
|
||||||
@@ -24,6 +25,7 @@ export type GraphAction =
|
|||||||
| { type: "SET_YAXIS_LIMITS", payload: AxisRange }
|
| { type: "SET_YAXIS_LIMITS", payload: AxisRange }
|
||||||
| { type: "SET_CUSTOM_STEP", payload: string}
|
| { type: "SET_CUSTOM_STEP", payload: string}
|
||||||
| { type: "SET_IS_HISTOGRAM", payload: boolean }
|
| { type: "SET_IS_HISTOGRAM", payload: boolean }
|
||||||
|
| { type: "SET_IS_EMPTY_HISTOGRAM", payload: boolean }
|
||||||
| { type: "SET_SPAN_GAPS", payload: boolean }
|
| { type: "SET_SPAN_GAPS", payload: boolean }
|
||||||
|
|
||||||
export const initialGraphState: GraphState = {
|
export const initialGraphState: GraphState = {
|
||||||
@@ -32,6 +34,7 @@ export const initialGraphState: GraphState = {
|
|||||||
limits: { enable: false, range: { "1": [0, 0] } }
|
limits: { enable: false, range: { "1": [0, 0] } }
|
||||||
},
|
},
|
||||||
isHistogram: false,
|
isHistogram: false,
|
||||||
|
isEmptyHistogram: false,
|
||||||
spanGaps: false,
|
spanGaps: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -69,6 +72,11 @@ export function reducer(state: GraphState, action: GraphAction): GraphState {
|
|||||||
...state,
|
...state,
|
||||||
isHistogram: action.payload
|
isHistogram: action.payload
|
||||||
};
|
};
|
||||||
|
case "SET_IS_EMPTY_HISTOGRAM":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
isEmptyHistogram: action.payload
|
||||||
|
};
|
||||||
case "SET_SPAN_GAPS":
|
case "SET_SPAN_GAPS":
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { TimeParams } from "../types";
|
import { TimeParams } from "../types";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import { LOGS_BARS_VIEW } from "../constants/logs";
|
import { LOGS_BARS_VIEW, LOGS_GROUP_BY } from "../constants/logs";
|
||||||
|
import { LogHits } from "../api/types";
|
||||||
|
import { OTHER_HITS_LABEL } from "../components/Chart/BarHitsChart/hooks/useBarHitsOptions";
|
||||||
|
|
||||||
export const getStreamPairs = (value: string): string[] => {
|
export const getStreamPairs = (value: string): string[] => {
|
||||||
const pairs = /^{.+}$/.test(value) ? value.slice(1, -1).split(",") : [value];
|
const pairs = /^{.+}$/.test(value) ? value.slice(1, -1).split(",") : [value];
|
||||||
@@ -14,3 +16,27 @@ export const getHitsTimeParams = (period: TimeParams) => {
|
|||||||
const step = Math.ceil(totalSeconds / LOGS_BARS_VIEW) || 1;
|
const step = Math.ceil(totalSeconds / LOGS_BARS_VIEW) || 1;
|
||||||
return { start, end, step };
|
return { start, end, step };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const convertToFieldFilter = (value: string, field = LOGS_GROUP_BY) => {
|
||||||
|
const isKeyValue = /(.+)?=(".+")/.test(value);
|
||||||
|
|
||||||
|
if (isKeyValue) {
|
||||||
|
return value.replace(/=/, ": ");
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${field}: "${value}"`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const calculateTotalHits = (hits: LogHits[]): number => {
|
||||||
|
return hits.reduce((acc, item) => acc + (item.total || 0), 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const sortLogHits = <T extends { label?: string }>(key: keyof T) => (a: T, b: T): number => {
|
||||||
|
if (a.label === OTHER_HITS_LABEL) return 1;
|
||||||
|
if (b.label === OTHER_HITS_LABEL) return -1;
|
||||||
|
|
||||||
|
const aValue = a[key] as unknown as number;
|
||||||
|
const bValue = b[key] as unknown as number;
|
||||||
|
|
||||||
|
return bValue - aValue;
|
||||||
|
};
|
||||||
|
|||||||
@@ -57,3 +57,15 @@ 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"; // Миллиарды
|
||||||
|
} else if (value >= 1_000_000) {
|
||||||
|
return (value / 1_000_000).toFixed(1).replace(/\.0$/, "") + "M"; // Миллионы
|
||||||
|
} else if (value >= 1_000) {
|
||||||
|
return (value / 1_000).toFixed(1).replace(/\.0$/, "") + "K"; // Тысячи
|
||||||
|
} else {
|
||||||
|
return value.toString(); // Для чисел меньше 1000
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -14,3 +14,7 @@ export function filterObject<T extends object>(
|
|||||||
export function compactObject<T extends object>(obj: T) {
|
export function compactObject<T extends object>(obj: T) {
|
||||||
return filterObject(obj, (entry) => !!entry[1] || typeof entry[1] === "number");
|
return filterObject(obj, (entry) => !!entry[1] || typeof entry[1] === "number");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isEmptyObject(obj: object) {
|
||||||
|
return Object.keys(obj).length === 0;
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ export type StorageKeys = "AUTOCOMPLETE"
|
|||||||
| "QUERY_TRACING"
|
| "QUERY_TRACING"
|
||||||
| "SERIES_LIMITS"
|
| "SERIES_LIMITS"
|
||||||
| "TABLE_COMPACT"
|
| "TABLE_COMPACT"
|
||||||
| "TABLE_COLUMNS"
|
|
||||||
| "TIMEZONE"
|
| "TIMEZONE"
|
||||||
| "DISABLED_DEFAULT_TIMEZONE"
|
| "DISABLED_DEFAULT_TIMEZONE"
|
||||||
| "THEME"
|
| "THEME"
|
||||||
|
|||||||
@@ -153,7 +153,10 @@ export const normalizeData = (buckets: MetricResult[], isHistogram?: boolean): M
|
|||||||
const totalHitsPerTimestamp: { [timestamp: number]: number } = {};
|
const totalHitsPerTimestamp: { [timestamp: number]: number } = {};
|
||||||
vmBuckets.forEach(bucket =>
|
vmBuckets.forEach(bucket =>
|
||||||
bucket.values.forEach(([timestamp, value]) => {
|
bucket.values.forEach(([timestamp, value]) => {
|
||||||
totalHitsPerTimestamp[timestamp] = (totalHitsPerTimestamp[timestamp] || 0) + +value;
|
const valueNum = Number(value);
|
||||||
|
const number = isNaN(valueNum) ? 0 : valueNum;
|
||||||
|
const prevTotal = totalHitsPerTimestamp[timestamp] || 0;
|
||||||
|
totalHitsPerTimestamp[timestamp] = prevTotal + number;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
// specific files
|
// specific files
|
||||||
// static content
|
// static content
|
||||||
//
|
//
|
||||||
//go:embed favicon-32x32.png robots.txt index.html manifest.json asset-manifest.json
|
//go:embed favicon.svg robots.txt index.html manifest.json asset-manifest.json
|
||||||
//go:embed static
|
//go:embed static
|
||||||
var files embed.FS
|
var files embed.FS
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package apptest
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -134,6 +136,23 @@ func (c *vmcluster) ForceFlush(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MustStartVmauth is a test helper function that starts an instance of
|
||||||
|
// vmauth and fails the test if the app fails to start.
|
||||||
|
func (tc *TestCase) MustStartVmauth(instance string, flags []string, configFileYAML string) *Vmauth {
|
||||||
|
tc.t.Helper()
|
||||||
|
|
||||||
|
configFilePath := path.Join(tc.t.TempDir(), "config.yaml")
|
||||||
|
if err := os.WriteFile(configFilePath, []byte(configFileYAML), os.ModePerm); err != nil {
|
||||||
|
tc.t.Fatalf("cannot init vmauth: config file write failed: %s", err)
|
||||||
|
}
|
||||||
|
app, err := StartVmauth(instance, flags, tc.cli, configFilePath)
|
||||||
|
if err != nil {
|
||||||
|
tc.t.Fatalf("Could not start %s: %v", instance, err)
|
||||||
|
}
|
||||||
|
tc.addApp(instance, app)
|
||||||
|
return app
|
||||||
|
}
|
||||||
|
|
||||||
// MustStartDefaultCluster starts a typical cluster configuration with default
|
// MustStartDefaultCluster starts a typical cluster configuration with default
|
||||||
// flags.
|
// flags.
|
||||||
func (tc *TestCase) MustStartDefaultCluster() PrometheusWriteQuerier {
|
func (tc *TestCase) MustStartDefaultCluster() PrometheusWriteQuerier {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user