Compare commits

..

1 Commits

Author SHA1 Message Date
Nikhil Soni
c9ae10b1c0 feat(apiserver): move apiserver to registry and make it configurable (#12493)
Some checks are pending
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
cacheci / tests (push) Waiting to run
Release Drafter / update_release_draft (push) Waiting to run
#### Description

- Make server port configurable so multiple instances can be started for
agentic development and testing.
- Add `make go-stop` to make it easier to restart server by agents. It
does a graceful stop to allow the Prometheus metrics exporter port to
shutdown otherwise that port remain occupied.
- Add make target for generating the OpenAPI specs.
- Documents the above in `docs/contributing/development.md` under "How
do I run more than one instance?", including `make go-stop` in the basic
backend flow.

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-09 17:12:05 +00:00
274 changed files with 3062 additions and 15402 deletions

7
.claude/opencode.json Normal file
View File

@@ -0,0 +1,7 @@
{
"$schema": "https://opencode.ai/config.json",
"lsp": true,
"experimental": {
"disable_paste_summary": true
}
}

View File

@@ -7,5 +7,6 @@ Applies to everything in the repo — code, config, workflows.
- **Rationale goes in prose, not source.** Why a version is pinned, why a job exists, how a subsystem fits together — that belongs in the README or the PR.
- **Never remove pre-existing comments** when editing code. The bar above applies to comments you write, not comments already there.
- **Never talk to the reviewer.** No comments about where a change came from, what was changed, or why the change is correct — that belongs in the PR description and is noise the moment it merges.
- **Less is more.** When writing something intended for human consumption, (comment, commit message, reply to prompt) use as few words as possible. Pick every word meticulously to reduce the volume to a strict minimum. Be down to the point. Less is more.
Language rules build on this one: [`go-comments`](go-comments.md), [`py-comments`](py-comments.md).

View File

@@ -0,0 +1,16 @@
---
paths:
- "**/*.go"
---
# Contribution guidelines
- When making Go changes, always ensure they follow the contributing guildelines in [`docs/contributing/go/`](../../docs/contributing/go/).
- Look for existing patterns in the codebase for any change before implementing the changes.
- If any API contract is modified, generate the OpenAPI specs with `make gen-openapi-specs`.
- Always keep the OpenAPI spec generated in a separate commit, so the whole commit can be dropped in case of conflicts during merge. Do not try to resolve conflict in generated files, instead just generate them again.
- Avoid breaking function calls unncessarily into multilines for couple of arguments.
- Try to keep most computational only logic in types package itself related to a domain type, use modules as the orchestraction layer cordinating different layers and all db queries in store layer. Check the serviceaccount modules for inspiration when confused.
- When defining types, keep the structure of file to have any constants and variables first, then exported types and exported methods and then finally the unexported types and methods.
- Never import types or other modules in migration files, duplicate the required type or method to keep migration free from changes.
- Always run the gofmt tool for formating beforing commiting any changes.

View File

@@ -2,6 +2,10 @@
- **Follow the template** (`.github/pull_request_template.md`): fill in its headings (Description / Issues closed by this PR / Screenshots / Additional Information). Don't add sections the template doesn't have.
- **Keep only the headings that apply.** Delete every heading that has nothing under it, along with its `<!--...-->` placeholder comment. The body must never contain an empty heading — if only Description applies, the body has exactly that one heading.
- **Keep the description concise and human-readable.** A few plain bullets saying what changed and why, for a reviewer skimming it — not a wall of text, not a restatement of the diff, not generated boilerplate.
- **Keep the description concise and human-readable.** A few non repetitive bullets saying what changed and why, for a reviewer skimming it — not a wall of text, not a restatement of the diff, not generated boilerplate and not the user agent conversation details.
- **Reference issues with `Closes #issue-number`** under "Issues closed by this PR" so they auto-close on merge. This goes in the PR description only — never in commit messages.
- **Breaking changes can be added in additional information section** if any.
- **AI assistance in commits may optionally be disclosed with an `Assisted-by:` trailer** naming the model (e.g. `Assisted-by: Claude Opus 4.5`) — do NOT use a `Co-authored-by:` trailer for this.
- **Keep the commit body short and human readable** focused on decision made if any. Commit body must not re-iterate the changes done, skip if title is sufficient in conveying the change.
- **Use convensional commit format** for commits and PR title.
- **Do not amend the commits once pushed.** Always create a new commit once changes are pushed to remote.

1
.gitignore vendored
View File

@@ -232,3 +232,4 @@ pyrightconfig.json
# dev
.dev/
.claude/worktrees/
.claude/settings.local.json

View File

@@ -81,10 +81,13 @@ devenv-clickhouse-clean: ## Clean all ClickHouse data from filesystem
##############################################################
# go commands
##############################################################
SIGNOZ_SQLSTORE_SQLITE_PATH ?= signoz.db
SIGNOZ_APISERVER_ADDRESS ?= 0.0.0.0:8080
.PHONY: go-run-enterprise
go-run-enterprise: ## Runs the enterprise go backend server
@SIGNOZ_INSTRUMENTATION_LOGS_LEVEL=debug \
SIGNOZ_SQLSTORE_SQLITE_PATH=signoz.db \
SIGNOZ_SQLSTORE_SQLITE_PATH=$(SIGNOZ_SQLSTORE_SQLITE_PATH) \
SIGNOZ_WEB_ENABLED=false \
SIGNOZ_TOKENIZER_JWT_SECRET=secret \
SIGNOZ_ALERTMANAGER_PROVIDER=signoz \
@@ -101,7 +104,7 @@ go-test: ## Runs go unit tests
.PHONY: go-run-community
go-run-community: ## Runs the community go backend server
@SIGNOZ_INSTRUMENTATION_LOGS_LEVEL=debug \
SIGNOZ_SQLSTORE_SQLITE_PATH=signoz.db \
SIGNOZ_SQLSTORE_SQLITE_PATH=$(SIGNOZ_SQLSTORE_SQLITE_PATH) \
SIGNOZ_WEB_ENABLED=false \
SIGNOZ_TOKENIZER_JWT_SECRET=secret \
SIGNOZ_ALERTMANAGER_PROVIDER=signoz \
@@ -111,6 +114,28 @@ go-run-community: ## Runs the community go backend server
go run -race \
$(GO_BUILD_CONTEXT_COMMUNITY)/*.go server
.PHONY: go-stop
go-stop: ## Stops the go backend server listening on SIGNOZ_APISERVER_ADDRESS, waiting for it to release every port it holds
@PORT=$(lastword $(subst :, ,$(SIGNOZ_APISERVER_ADDRESS))); \
PIDS=$$(lsof -ti tcp:$$PORT); \
if [ -z "$$PIDS" ]; then \
echo "No signoz server running on port $$PORT."; \
echo "If it's running on a different port, rerun as: make go-stop SIGNOZ_APISERVER_ADDRESS=host:port"; \
exit 0; \
fi; \
kill $$PIDS 2>/dev/null; \
for i in $$(seq 1 10); do \
alive=$$(for p in $$PIDS; do kill -0 $$p 2>/dev/null && echo $$p; done); \
[ -z "$$alive" ] && break; \
sleep 1; \
done; \
alive=$$(for p in $$PIDS; do kill -0 $$p 2>/dev/null && echo $$p; done); \
if [ -n "$$alive" ]; then \
echo "Graceful shutdown did not finish in 10s, sending SIGKILL to $$alive"; \
kill -9 $$alive 2>/dev/null; \
fi; \
echo "Stopped signoz server on port $$PORT (pid $$PIDS)"
.PHONY: go-build-community $(GO_BUILD_ARCHS_COMMUNITY)
go-build-community: ## Builds the go backend server for community
go-build-community: $(GO_BUILD_ARCHS_COMMUNITY)
@@ -241,3 +266,8 @@ semconv-generate: ## Regenerate semantic-convention families for Go and TypeScri
gen-mocks:
@echo ">> Generating mocks"
@mockery --config .mockery.yml
.PHONY: gen-openapi-specs
gen-openapi-specs:
@go run cmd/enterprise/*.go generate openapi
cd frontend && pnpm generate:api && cd -

View File

@@ -138,6 +138,12 @@ sqlstore:
##################### APIServer #####################
apiserver:
# The TCP address the API server listens on, in the form "host:port".
address: 0.0.0.0:8080
# Maximum duration for reading an entire request, including the body.
read_timeout: 60s
# Keep at 0; any value cuts off streaming endpoints (livetail, SSE, export_raw_data).
write_timeout: 0
timeout:
# Default request timeout.
default: 60s

View File

@@ -3474,79 +3474,6 @@ components:
- tags
- spec
type: object
DashboardtypesHeatmapAxes:
properties:
yScale:
$ref: '#/components/schemas/DashboardtypesHeatmapYScale'
type: object
DashboardtypesHeatmapChartAppearance:
properties:
colors:
$ref: '#/components/schemas/DashboardtypesHeatmapColors'
type: object
DashboardtypesHeatmapColorMode:
enum:
- palette
- opacity
type: string
DashboardtypesHeatmapColorScale:
enum:
- log
- sqrt
- linear
type: string
DashboardtypesHeatmapColors:
properties:
fill:
type: string
maxCount:
nullable: true
type: number
minCount:
nullable: true
type: number
mode:
$ref: '#/components/schemas/DashboardtypesHeatmapColorMode'
palette:
$ref: '#/components/schemas/DashboardtypesHeatmapPalette'
scale:
$ref: '#/components/schemas/DashboardtypesHeatmapColorScale'
steps:
type: integer
type: object
DashboardtypesHeatmapPalette:
enum:
- ice
- moss
- rust
- graphite
- ember
- lagoon
- orchid
- verdant
- lava
- beacon
type: string
DashboardtypesHeatmapPanelSpec:
properties:
axes:
$ref: '#/components/schemas/DashboardtypesHeatmapAxes'
chartAppearance:
$ref: '#/components/schemas/DashboardtypesHeatmapChartAppearance'
formatting:
$ref: '#/components/schemas/DashboardtypesPanelFormatting'
legend:
$ref: '#/components/schemas/DashboardtypesLegend'
visualization:
$ref: '#/components/schemas/DashboardtypesBasicVisualization'
type: object
DashboardtypesHeatmapYScale:
enum:
- auto
- linear
- log
- symlog
type: string
DashboardtypesHistogramBuckets:
properties:
bucketCount:
@@ -3899,7 +3826,6 @@ components:
discriminator:
mapping:
signoz/BarChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec'
signoz/HeatmapPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec'
signoz/HistogramPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec'
signoz/ListPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
signoz/NumberPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec'
@@ -3915,7 +3841,6 @@ components:
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec'
type: object
DashboardtypesPanelPluginKind:
enum:
@@ -3926,7 +3851,6 @@ components:
- signoz/TablePanel
- signoz/HistogramPanel
- signoz/ListPanel
- signoz/HeatmapPanel
type: string
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec:
properties:
@@ -3940,18 +3864,6 @@ components:
- kind
- spec
type: object
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec:
properties:
kind:
enum:
- signoz/HeatmapPanel
type: string
spec:
$ref: '#/components/schemas/DashboardtypesHeatmapPanelSpec'
required:
- kind
- spec
type: object
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec:
properties:
kind:
@@ -7483,7 +7395,10 @@ components:
$ref: '#/components/schemas/Querybuildertypesv5TimeSeries'
type: array
meta:
$ref: '#/components/schemas/Querybuildertypesv5AggregationMeta'
properties:
unit:
type: string
type: object
predictedSeries:
items:
$ref: '#/components/schemas/Querybuildertypesv5TimeSeries'
@@ -7498,51 +7413,12 @@ components:
$ref: '#/components/schemas/Querybuildertypesv5TimeSeries'
type: array
type: object
Querybuildertypesv5AggregationMeta:
Querybuildertypesv5Bucket:
properties:
buckets:
items:
format: double
type: number
type: array
unit:
type: string
step:
format: double
type: number
type: object
Querybuildertypesv5BucketOptions:
discriminator:
mapping:
linear: '#/components/schemas/Querybuildertypesv5BucketOptionsLinear'
log: '#/components/schemas/Querybuildertypesv5BucketOptionsLog'
propertyName: kind
oneOf:
- $ref: '#/components/schemas/Querybuildertypesv5BucketOptionsLinear'
- $ref: '#/components/schemas/Querybuildertypesv5BucketOptionsLog'
type: object
Querybuildertypesv5BucketOptionsLinear:
properties:
kind:
$ref: '#/components/schemas/Querybuildertypesv5BucketsKind'
spec:
$ref: '#/components/schemas/Querybuildertypesv5LinearBucketsSpec'
required:
- kind
- spec
type: object
Querybuildertypesv5BucketOptionsLog:
properties:
kind:
$ref: '#/components/schemas/Querybuildertypesv5BucketsKind'
spec:
$ref: '#/components/schemas/Querybuildertypesv5LogBucketsSpec'
required:
- kind
- spec
type: object
Querybuildertypesv5BucketsKind:
enum:
- linear
- log
type: string
Querybuildertypesv5BuilderQuerySpec:
discriminator:
mapping:
@@ -7723,16 +7599,6 @@ components:
value:
type: string
type: object
Querybuildertypesv5LinearBucketsSpec:
properties:
maxValue:
format: double
type: number
numBuckets:
type: integer
required:
- maxValue
type: object
Querybuildertypesv5LogAggregation:
properties:
alias:
@@ -7740,12 +7606,6 @@ components:
expression:
type: string
type: object
Querybuildertypesv5LogBucketsSpec:
properties:
scale:
nullable: true
type: integer
type: object
Querybuildertypesv5MetricAggregation:
properties:
comparisonSpaceAggregationParam:
@@ -8204,8 +8064,6 @@ components:
queries (traces, logs, metrics), formulas, joins, trace operators, PromQL,
and ClickHouse SQL queries.
properties:
bucketOptions:
$ref: '#/components/schemas/Querybuildertypesv5BucketOptions'
compositeQuery:
$ref: '#/components/schemas/Querybuildertypesv5CompositeQuery'
end:
@@ -8305,7 +8163,6 @@ components:
- raw
- raw_stream
- trace
- heatmap
type: string
Querybuildertypesv5ScalarData:
properties:
@@ -8380,6 +8237,8 @@ components:
type: object
Querybuildertypesv5TimeSeriesValue:
properties:
bucket:
$ref: '#/components/schemas/Querybuildertypesv5Bucket'
partial:
type: boolean
timestamp:

View File

@@ -83,7 +83,13 @@ This command:
You should see: `{"status":"ok"}`
> 💡 **Tip**: The API server runs at `http://localhost:8080/` by default
3. Stop it when you're done:
```bash
make go-stop
```
> 💡 **Tip**: The API server runs at `http://localhost:8080/` by default. You can configure this using `apiserver.address` configuration option. See
> [running more than one instance](#how-do-i-run-more-than-one-instance) if you need that for agentic testing.
### 4. Setting up the Frontend
@@ -119,6 +125,36 @@ To verify everything is working correctly:
3. **Check Backend**: `curl http://localhost:8080/api/v1/health` (should return `{"status":"ok"}`)
4. **Check Frontend**: Open `http://localhost:3301` in your browser
## How do I run more than one instance?
Handy when you keep several branches checked out as separate git worktrees. Every port
and path below is read from the environment, so set them on the `make` call:
```bash
SIGNOZ_APISERVER_ADDRESS=0.0.0.0:8081 \
SIGNOZ_SQLSTORE_SQLITE_PATH=/path/to/main/sqlite.db \
SIGNOZ_INSTRUMENTATION_METRICS_READERS_PULL_EXPORTER_PROMETHEUS_PORT=9091 \
make go-run-community
```
| Variable | Default | Why you'd change it |
| --- | --- | --- |
| `SIGNOZ_APISERVER_ADDRESS` | `0.0.0.0:8080` | Address the API server listens on |
| `SIGNOZ_SQLSTORE_SQLITE_PATH` | `signoz.db` in worktree | To reuse same database |
| `SIGNOZ_INSTRUMENTATION_METRICS_READERS_PULL_EXPORTER_PROMETHEUS_PORT` | `9090` | Bound by the Prometheus metrics exporter on startup |
Point the frontend at whichever backend you want, in `frontend/.env`:
```env
VITE_FRONTEND_API_ENDPOINT=http://localhost:8081
```
Stop an instance using the address it was started on:
```bash
make go-stop SIGNOZ_APISERVER_ADDRESS=0.0.0.0:8081
```
## How to send test data?
You can now send telemetry data to your local SigNoz instance:

View File

@@ -3,56 +3,29 @@ package app
import (
"context"
"fmt"
"net"
"net/http"
"slices"
"go.opentelemetry.io/contrib/instrumentation/github.com/gorilla/mux/otelmux"
"go.opentelemetry.io/otel/propagation"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/gorilla/handlers"
"github.com/rs/cors"
"github.com/soheilhy/cmux"
"github.com/SigNoz/signoz/ee/query-service/app/api"
"github.com/SigNoz/signoz/ee/query-service/usage"
"github.com/SigNoz/signoz/pkg/http/middleware"
"github.com/SigNoz/signoz/pkg/signoz"
"github.com/SigNoz/signoz/pkg/web"
"log/slog"
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
baseapp "github.com/SigNoz/signoz/pkg/query-service/app"
"github.com/SigNoz/signoz/pkg/query-service/app/clickhouseReader"
"github.com/SigNoz/signoz/pkg/query-service/app/integrations"
"github.com/SigNoz/signoz/pkg/query-service/app/logparsingpipeline"
"github.com/SigNoz/signoz/pkg/query-service/app/opamp"
opAmpModel "github.com/SigNoz/signoz/pkg/query-service/app/opamp/model"
baseconst "github.com/SigNoz/signoz/pkg/query-service/constants"
"github.com/SigNoz/signoz/pkg/query-service/healthcheck"
"github.com/SigNoz/signoz/pkg/query-service/utils"
)
// Server runs HTTP, Mux and a grpc server
// Server runs auxiliary servers (opamp) alongside the signoz apiserver
type Server struct {
config signoz.Config
signoz *signoz.SigNoz
// public http router
httpConn net.Listener
httpServer *http.Server
httpHostPort string
opampServer *opamp.Server
// Usage manager
usageManager *usage.Manager
unavailableChannel chan healthcheck.Status
}
// NewServer creates and initializes Server
@@ -127,57 +100,11 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
return nil, err
}
s := &Server{
config: config,
signoz: signoz,
httpHostPort: baseconst.HTTPHostPort,
unavailableChannel: make(chan healthcheck.Status),
usageManager: usageManager,
}
httpServer, err := s.createPublicServer(apiHandler, signoz.Web)
if err != nil {
return nil, err
}
s.httpServer = httpServer
s.opampServer = opamp.InitializeServer(
&opAmpModel.AllAgents, agentConfMgr, signoz.Instrumentation,
)
return s, nil
}
// HealthCheckStatus returns health check status channel a client can subscribe to
func (s Server) HealthCheckStatus() chan healthcheck.Status {
return s.unavailableChannel
}
func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*http.Server, error) {
r := baseapp.NewRouter()
am := middleware.NewAuthZ(s.signoz.Instrumentation.Logger(), s.signoz.Modules.OrgGetter, s.signoz.Authz)
r.Use(middleware.NewRecovery(s.signoz.Instrumentation.Logger()).Wrap)
r.Use(otelmux.Middleware(
"apiserver",
otelmux.WithMeterProvider(s.signoz.Instrumentation.MeterProvider()),
otelmux.WithTracerProvider(s.signoz.Instrumentation.TracerProvider()),
otelmux.WithPropagators(propagation.NewCompositeTextMapPropagator(propagation.Baggage{}, propagation.TraceContext{})),
otelmux.WithFilter(func(r *http.Request) bool {
return !slices.Contains([]string{"/api/v1/health"}, r.URL.Path)
}),
))
r.Use(middleware.NewIdentN(s.signoz.IdentNResolver, s.signoz.Sharder, s.signoz.Instrumentation.Logger()).Wrap)
r.Use(middleware.NewTimeout(s.signoz.Instrumentation.Logger(),
s.config.APIServer.Timeout.ExcludedRoutes,
s.config.APIServer.Timeout.Default,
s.config.APIServer.Timeout.Max,
).Wrap)
r.Use(middleware.NewResource(s.signoz.Instrumentation.Logger()).Wrap)
r.Use(middleware.NewAudit(s.signoz.Instrumentation.Logger(), s.config.APIServer.Logging.ExcludedRoutes, s.signoz.Auditor).Wrap)
r.Use(middleware.NewComment().Wrap)
// Register the legacy query-service routes on the apiserver router. The
// apiserver owns the HTTP server and applies the middleware chain at serve
// time, so these routes get the same treatment as the apiserver routes.
r := signoz.APIServer.Router()
am := middleware.NewAuthZ(signoz.Instrumentation.Logger(), signoz.Modules.OrgGetter, signoz.Authz)
apiHandler.RegisterRoutes(r, am)
apiHandler.RegisterLogsRoutes(r, am)
@@ -188,107 +115,29 @@ func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*h
apiHandler.RegisterThirdPartyApiRoutes(r, am)
apiHandler.RegisterTraceFunnelsRoutes(r, am)
err := s.signoz.APIServer.AddToRouter(r)
if err != nil {
return nil, err
s := &Server{
usageManager: usageManager,
}
c := cors.New(cors.Options{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{"GET", "DELETE", "POST", "PUT", "PATCH", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "cache-control"},
})
s.opampServer = opamp.InitializeServer(
&opAmpModel.AllAgents, agentConfMgr, signoz.Instrumentation,
)
handler := c.Handler(r)
handler = handlers.CompressHandler(handler)
err = web.AddToRouter(r)
if err != nil {
return nil, err
}
routePrefix := s.config.Global.ExternalPath()
if routePrefix != "" {
prefixed := http.StripPrefix(routePrefix, handler)
handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
switch req.URL.Path {
case "/api/v1/health", "/api/v2/healthz", "/api/v2/readyz", "/api/v2/livez":
r.ServeHTTP(w, req)
return
}
prefixed.ServeHTTP(w, req)
})
}
return &http.Server{
Handler: handler,
}, nil
return s, nil
}
// initListeners initialises listeners of the server
func (s *Server) initListeners() error {
// listen on public port
var err error
publicHostPort := s.httpHostPort
if publicHostPort == "" {
return fmt.Errorf("baseconst.HTTPHostPort is required")
}
s.httpConn, err = net.Listen("tcp", publicHostPort)
if err != nil {
return err
}
slog.Info(fmt.Sprintf("Query server started listening on %s...", s.httpHostPort))
return nil
}
// Start listening on http and private http port concurrently
// Start starts the opamp websocket server. The HTTP API server is started by
// the signoz registry.
func (s *Server) Start(ctx context.Context) error {
err := s.initListeners()
if err != nil {
slog.Info("Starting OpAmp Websocket server", "addr", baseconst.OpAmpWsEndpoint)
if err := s.opampServer.Start(baseconst.OpAmpWsEndpoint); err != nil {
return err
}
var httpPort int
if port, err := utils.GetPort(s.httpConn.Addr()); err == nil {
httpPort = port
}
go func() {
slog.Info("Starting HTTP server", "port", httpPort, "addr", s.httpHostPort)
switch err := s.httpServer.Serve(s.httpConn); err {
case nil, http.ErrServerClosed, cmux.ErrListenerClosed:
// normal exit, nothing to do
default:
slog.Error("Could not start HTTP server", errors.Attr(err))
}
s.unavailableChannel <- healthcheck.Unavailable
}()
go func() {
slog.Info("Starting OpAmp Websocket server", "addr", baseconst.OpAmpWsEndpoint)
err := s.opampServer.Start(baseconst.OpAmpWsEndpoint)
if err != nil {
slog.Error("opamp ws server failed to start", errors.Attr(err))
s.unavailableChannel <- healthcheck.Unavailable
}
}()
return nil
}
func (s *Server) Stop(ctx context.Context) error {
if s.httpServer != nil {
if err := s.httpServer.Shutdown(ctx); err != nil {
return err
}
}
s.opampServer.Stop()
// stop usage manager

View File

@@ -2,8 +2,6 @@
// Mock for uplot library used in tests
export interface MockUPlotInstance {
/** Consumers read `root.parentElement` to detect a re-mounted container. */
root: HTMLDivElement;
setData: jest.Mock;
setSize: jest.Mock;
destroy: jest.Mock;
@@ -19,20 +17,13 @@ export interface MockUPlotPaths {
}
// Create mock instance methods
const createMockUPlotInstance = (target?: HTMLElement): MockUPlotInstance => {
const root = document.createElement('div');
// Real uPlot mounts its root inside the target; without it a re-render reads
// `root.parentElement` off undefined and throws.
target?.appendChild(root);
return {
root,
setData: jest.fn(),
setSize: jest.fn(),
destroy: jest.fn(),
redraw: jest.fn(),
setSeries: jest.fn(),
};
};
const createMockUPlotInstance = (): MockUPlotInstance => ({
setData: jest.fn(),
setSize: jest.fn(),
destroy: jest.fn(),
redraw: jest.fn(),
setSeries: jest.fn(),
});
// Path builder: (self, seriesIdx, idx0, idx1) => paths or null
const createMockPathBuilder = (name: string): jest.Mock =>
@@ -62,16 +53,14 @@ const mockTzDate = jest.fn(
function MockUPlot(
_options: unknown,
_data: unknown,
target: HTMLElement,
_target: HTMLElement,
): MockUPlotInstance {
return createMockUPlotInstance(target);
return createMockUPlotInstance();
}
// Add static methods to the constructor
MockUPlot.tzDate = mockTzDate;
MockUPlot.paths = mockPaths;
// Pinned so canvas-space maths in draw hooks is deterministic under jsdom.
MockUPlot.pxRatio = 1;
// Export the constructor as default
export default MockUPlot;

View File

@@ -4914,83 +4914,6 @@ export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDa
spec: DashboardtypesListPanelSpecDTO;
}
export enum DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpecDTOKind {
'signoz/HeatmapPanel' = 'signoz/HeatmapPanel',
}
export enum DashboardtypesHeatmapYScaleDTO {
auto = 'auto',
linear = 'linear',
log = 'log',
symlog = 'symlog',
}
export interface DashboardtypesHeatmapAxesDTO {
yScale?: DashboardtypesHeatmapYScaleDTO;
}
export enum DashboardtypesHeatmapColorModeDTO {
palette = 'palette',
opacity = 'opacity',
}
export enum DashboardtypesHeatmapPaletteDTO {
ice = 'ice',
moss = 'moss',
rust = 'rust',
graphite = 'graphite',
ember = 'ember',
lagoon = 'lagoon',
orchid = 'orchid',
verdant = 'verdant',
lava = 'lava',
beacon = 'beacon',
}
export enum DashboardtypesHeatmapColorScaleDTO {
log = 'log',
sqrt = 'sqrt',
linear = 'linear',
}
export interface DashboardtypesHeatmapColorsDTO {
/**
* @type string
*/
fill?: string;
/**
* @type number,null
*/
maxCount?: number | null;
/**
* @type number,null
*/
minCount?: number | null;
mode?: DashboardtypesHeatmapColorModeDTO;
palette?: DashboardtypesHeatmapPaletteDTO;
scale?: DashboardtypesHeatmapColorScaleDTO;
/**
* @type integer
*/
steps?: number;
}
export interface DashboardtypesHeatmapChartAppearanceDTO {
colors?: DashboardtypesHeatmapColorsDTO;
}
export interface DashboardtypesHeatmapPanelSpecDTO {
axes?: DashboardtypesHeatmapAxesDTO;
chartAppearance?: DashboardtypesHeatmapChartAppearanceDTO;
formatting?: DashboardtypesPanelFormattingDTO;
legend?: DashboardtypesLegendDTO;
visualization?: DashboardtypesBasicVisualizationDTO;
}
export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpecDTO {
/**
* @enum signoz/HeatmapPanel
* @type string
*/
kind: DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpecDTOKind;
spec: DashboardtypesHeatmapPanelSpecDTO;
}
export type DashboardtypesPanelPluginDTO =
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpecDTO
@@ -4998,8 +4921,7 @@ export type DashboardtypesPanelPluginDTO =
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpecDTO;
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpecDTO;
export enum Querybuildertypesv5RequestTypeDTO {
scalar = 'scalar',
@@ -5007,7 +4929,6 @@ export enum Querybuildertypesv5RequestTypeDTO {
raw = 'raw',
raw_stream = 'raw_stream',
trace = 'trace',
heatmap = 'heatmap',
}
export enum DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBuilderQuerySpecDTOKind {
'signoz/BuilderQuery' = 'signoz/BuilderQuery',
@@ -5922,7 +5843,6 @@ export enum DashboardtypesPanelPluginKindDTO {
'signoz/TablePanel' = 'signoz/TablePanel',
'signoz/HistogramPanel' = 'signoz/HistogramPanel',
'signoz/ListPanel' = 'signoz/ListPanel',
'signoz/HeatmapPanel' = 'signoz/HeatmapPanel',
}
/**
* @nullable
@@ -8622,7 +8542,16 @@ export interface Querybuildertypesv5LabelDTO {
value?: Querybuildertypesv5LabelDTOValue;
}
export interface Querybuildertypesv5BucketDTO {
/**
* @type number
* @format double
*/
step?: number;
}
export interface Querybuildertypesv5TimeSeriesValueDTO {
bucket?: Querybuildertypesv5BucketDTO;
/**
* @type boolean
*/
@@ -9173,16 +9102,12 @@ export interface PromotetypesPromotePathDTO {
promote?: boolean;
}
export interface Querybuildertypesv5AggregationMetaDTO {
/**
* @type array
*/
buckets?: number[];
export type Querybuildertypesv5AggregationBucketDTOMeta = {
/**
* @type string
*/
unit?: string;
}
};
export interface Querybuildertypesv5AggregationBucketDTO {
/**
@@ -9201,7 +9126,10 @@ export interface Querybuildertypesv5AggregationBucketDTO {
* @type array
*/
lowerBoundSeries?: Querybuildertypesv5TimeSeriesDTO[];
meta?: Querybuildertypesv5AggregationMetaDTO;
/**
* @type object
*/
meta?: Querybuildertypesv5AggregationBucketDTOMeta;
/**
* @type array
*/
@@ -9216,57 +9144,6 @@ export interface Querybuildertypesv5AggregationBucketDTO {
upperBoundSeries?: Querybuildertypesv5TimeSeriesDTO[];
}
export enum Querybuildertypesv5BucketOptionsLinearDTOKind {
linear = 'linear',
}
export interface Querybuildertypesv5LinearBucketsSpecDTO {
/**
* @type number
* @format double
*/
maxValue: number;
/**
* @type integer
*/
numBuckets?: number;
}
export interface Querybuildertypesv5BucketOptionsLinearDTO {
/**
* @type string
* @enum linear
*/
kind: Querybuildertypesv5BucketOptionsLinearDTOKind;
spec: Querybuildertypesv5LinearBucketsSpecDTO;
}
export enum Querybuildertypesv5BucketOptionsLogDTOKind {
log = 'log',
}
export interface Querybuildertypesv5LogBucketsSpecDTO {
/**
* @type integer,null
*/
scale?: number | null;
}
export interface Querybuildertypesv5BucketOptionsLogDTO {
/**
* @type string
* @enum log
*/
kind: Querybuildertypesv5BucketOptionsLogDTOKind;
spec: Querybuildertypesv5LogBucketsSpecDTO;
}
export type Querybuildertypesv5BucketOptionsDTO =
| Querybuildertypesv5BucketOptionsLinearDTO
| Querybuildertypesv5BucketOptionsLogDTO;
export enum Querybuildertypesv5BucketsKindDTO {
linear = 'linear',
log = 'log',
}
export type Querybuildertypesv5ColumnDescriptorDTOMeta = {
/**
* @type string
@@ -9616,7 +9493,6 @@ export type Querybuildertypesv5QueryRangeRequestDTOVariables = {
* Request body for the v5 query range endpoint. Supports builder queries (traces, logs, metrics), formulas, joins, trace operators, PromQL, and ClickHouse SQL queries.
*/
export interface Querybuildertypesv5QueryRangeRequestDTO {
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;
compositeQuery?: Querybuildertypesv5CompositeQueryDTO;
/**
* @type integer

View File

@@ -17,7 +17,6 @@ function InputWithLabel({
onChange,
className,
closeIcon,
disabled,
}: {
label: string;
initialValue?: string | number | null;
@@ -28,7 +27,6 @@ function InputWithLabel({
onChange: (value: string) => void;
className?: string;
closeIcon?: React.ReactNode;
disabled?: boolean;
}): JSX.Element {
const [inputValue, setInputValue] = useState<string>(
initialValue ? initialValue.toString() : '',
@@ -55,7 +53,6 @@ function InputWithLabel({
type={type}
value={inputValue}
onChange={handleChange}
disabled={disabled}
name={label.toLowerCase()}
data-testid={`input-${label}`}
/>

View File

@@ -1,18 +1,11 @@
import { memo, useCallback, useEffect, useMemo, useRef } from 'react';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { OPERATORS, PANEL_TYPES } from 'constants/queryBuilder';
import { Formula } from 'container/QueryBuilder/components/Formula';
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { IBuilderTraceOperator } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { QueryBuilderField } from './queryBuilderFields.types';
import {
mergeQueryBuilderFieldsConfig,
RAW_QUERY_FIELDS,
resolveQueryBuilderField,
} from './queryBuilderFields.utils';
import { QueryBuilderV2Provider } from './QueryBuilderV2Context';
import { clearPreviousQuery } from './QueryV2/previousQuery.utils';
import QueryFooter from './QueryV2/QueryFooter/QueryFooter';
@@ -21,18 +14,12 @@ import TraceOperator from './QueryV2/TraceOperator/TraceOperator';
import './QueryBuilderV2.styles.scss';
// Raw rows come from logs or spans; metrics only exist aggregated.
const RAW_QUERY_SIGNALS = [
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
];
export const QueryBuilderV2 = memo(function QueryBuilderV2({
config,
panelType: newPanelType,
fieldsConfig,
allowedDataSources,
isRawQuery = false,
filterConfigs = {},
queryComponents,
isListViewPanel = false,
showOnlyWhereClause = false,
showTraceOperator = false,
version,
@@ -84,48 +71,55 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
};
}, []);
const resolvedConfig = useMemo(
() =>
mergeQueryBuilderFieldsConfig(
isRawQuery ? RAW_QUERY_FIELDS : undefined,
fieldsConfig,
),
[isRawQuery, fieldsConfig],
);
const additionalQueries = useMemo(
() =>
resolveQueryBuilderField(
QueryBuilderField.AdditionalQueries,
resolvedConfig,
),
[resolvedConfig],
);
const formula = useMemo(
() => resolveQueryBuilderField(QueryBuilderField.Formula, resolvedConfig),
[resolvedConfig],
);
const isMultiQueryAllowed = useMemo(
() => !additionalQueries.hidden && (!isRawQuery || showTraceOperator),
[additionalQueries.hidden, showTraceOperator, isRawQuery],
() => !isListViewPanel || showTraceOperator,
[showTraceOperator, isListViewPanel],
);
const queryDataSources = useMemo(
() => allowedDataSources ?? (isRawQuery ? RAW_QUERY_SIGNALS : undefined),
[allowedDataSources, isRawQuery],
);
const listViewLogFilterConfigs: QueryBuilderProps['filterConfigs'] =
useMemo(() => {
const config: QueryBuilderProps['filterConfigs'] = {
stepInterval: { isHidden: true, isDisabled: true },
having: { isHidden: true, isDisabled: true },
filters: {
customKey: 'body',
customOp: OPERATORS.CONTAINS,
},
};
// What the editor renders. A single-query builder edits the first query alone, so
// the query list beside it must not advertise ones there is no way to reach.
const renderedQueries = useMemo(
() =>
isMultiQueryAllowed
? currentQuery.builder.queryData
: currentQuery.builder.queryData.slice(0, 1),
[isMultiQueryAllowed, currentQuery.builder.queryData],
);
return config;
}, []);
const listViewTracesFilterConfigs: QueryBuilderProps['filterConfigs'] =
useMemo(() => {
const config: QueryBuilderProps['filterConfigs'] = {
stepInterval: { isHidden: true, isDisabled: true },
having: { isHidden: true, isDisabled: true },
limit: { isHidden: true, isDisabled: true },
filters: {
customKey: 'body',
customOp: OPERATORS.CONTAINS,
},
};
return config;
}, []);
const queryFilterConfigs = useMemo(() => {
if (isListViewPanel) {
return currentQuery.builder.queryData[0].dataSource === DataSource.TRACES
? listViewTracesFilterConfigs
: listViewLogFilterConfigs;
}
return filterConfigs;
}, [
isListViewPanel,
filterConfigs,
currentQuery.builder.queryData,
listViewLogFilterConfigs,
listViewTracesFilterConfigs,
]);
const traceOperator = useMemo((): IBuilderTraceOperator | undefined => {
if (
@@ -151,46 +145,31 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
[showTraceOperator, traceOperator, hasAtLeastOneTraceQuery],
);
const shouldShowFooter = useMemo(
() =>
(!showOnlyWhereClause && !isListViewPanel) ||
(currentDataSource === DataSource.TRACES && showTraceOperator),
[isListViewPanel, showTraceOperator, showOnlyWhereClause, currentDataSource],
);
const showQueryList = useMemo(
() => (!showOnlyWhereClause && !isRawQuery) || showTraceOperator,
[isRawQuery, showOnlyWhereClause, showTraceOperator],
() => (!showOnlyWhereClause && !isListViewPanel) || showTraceOperator,
[isListViewPanel, showOnlyWhereClause, showTraceOperator],
);
const showFormula = useMemo(() => {
if (formula.hidden) {
return false;
}
if (currentDataSource === DataSource.TRACES) {
return !isRawQuery;
return !isListViewPanel;
}
return true;
}, [formula.hidden, isRawQuery, currentDataSource]);
}, [isListViewPanel, currentDataSource]);
const showAddTraceOperator = useMemo(
() => showTraceOperator && !traceOperator && hasAtLeastOneTraceQuery,
[showTraceOperator, traceOperator, hasAtLeastOneTraceQuery],
);
// Nothing left to add means no footer at all, rather than an empty bar under the
// last query.
const shouldShowFooter = useMemo(
() =>
(!additionalQueries.hidden || showFormula || showAddTraceOperator) &&
((!showOnlyWhereClause && !isRawQuery) ||
(currentDataSource === DataSource.TRACES && showTraceOperator)),
[
additionalQueries.hidden,
showFormula,
showAddTraceOperator,
isRawQuery,
showTraceOperator,
showOnlyWhereClause,
currentDataSource,
],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLDivElement>): void => {
const target = e.target as HTMLElement | null;
@@ -220,8 +199,8 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
key={currentQuery.builder.queryData[0].queryName}
index={0}
query={currentQuery.builder.queryData[0]}
fieldsConfig={fieldsConfig}
allowedDataSources={queryDataSources}
filterConfigs={queryFilterConfigs}
queryComponents={queryComponents}
isMultiQueryAllowed={isMultiQueryAllowed}
showTraceOperator={showTraceOperator}
hasTraceOperator={hasTraceOperator}
@@ -229,7 +208,7 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
isAvailableToDisable={false}
queryVariant={config?.queryVariant || 'dropdown'}
showOnlyWhereClause={showOnlyWhereClause}
isRawQuery={isRawQuery}
isListViewPanel={isListViewPanel}
signalSource={currentQuery.builder.queryData[0].source as 'meter' | ''}
onSignalSourceChange={onSignalSourceChange || ((): void => {})}
signalSourceChangeEnabled={signalSourceChangeEnabled}
@@ -237,14 +216,14 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
savePreviousQuery={savePreviousQuery}
/>
) : (
renderedQueries.map((query, index) => (
currentQuery.builder.queryData.map((query, index) => (
<QueryV2
ref={containerRef}
key={query.queryName}
index={index}
query={query}
fieldsConfig={fieldsConfig}
allowedDataSources={queryDataSources}
filterConfigs={queryFilterConfigs}
queryComponents={queryComponents}
version={version}
isMultiQueryAllowed={isMultiQueryAllowed}
isAvailableToDisable={false}
@@ -252,7 +231,7 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
hasTraceOperator={hasTraceOperator}
queryVariant={config?.queryVariant || 'dropdown'}
showOnlyWhereClause={showOnlyWhereClause}
isRawQuery={isRawQuery}
isListViewPanel={isListViewPanel}
signalSource={query.source as 'meter' | ''}
onSignalSourceChange={onSignalSourceChange || ((): void => {})}
signalSourceChangeEnabled={signalSourceChangeEnabled}
@@ -272,7 +251,14 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
return (
<div key={formula.queryName} className="qb-formula">
<Formula query={query} formula={formula} index={index} isQBV2 />
<Formula
filterConfigs={filterConfigs}
query={query}
formula={formula}
index={index}
isAdditionalFilterEnable={false}
isQBV2
/>
</div>
);
})}
@@ -281,13 +267,8 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
{shouldShowFooter && (
<QueryFooter
showAddQuery={!additionalQueries.hidden}
showAddFormula={showFormula}
addFormulaDisabled={formula.disabled}
addFormulaDisabledReason={formula.reason}
addNewBuilderQuery={addNewBuilderQuery}
addQueryDisabled={additionalQueries.disabled}
addQueryDisabledReason={additionalQueries.reason}
addNewFormula={addNewFormula}
addTraceOperator={addTraceOperator}
showAddTraceOperator={showAddTraceOperator}
@@ -296,8 +277,7 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
{hasTraceOperator && (
<TraceOperator
isRawQuery={isRawQuery}
fieldsConfig={resolvedConfig}
isListViewPanel={isListViewPanel}
traceOperator={traceOperator as IBuilderTraceOperator}
/>
)}
@@ -305,7 +285,7 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
{showQueryList && (
<div className="query-names-section">
{renderedQueries.map((query) => (
{currentQuery.builder.queryData.map((query) => (
<div key={query.queryName} className="query-name">
{query.queryName}
</div>

View File

@@ -23,11 +23,6 @@
align-items: center;
justify-content: center;
gap: var(--margin-2);
&--disabled {
opacity: 0.45;
cursor: not-allowed;
}
}
}

View File

@@ -1,6 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { Button, Tooltip } from 'antd';
import cx from 'classnames';
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
import InputWithLabel from 'components/InputWithLabel/InputWithLabel';
import { PANEL_TYPES } from 'constants/queryBuilder';
@@ -15,16 +14,6 @@ import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { MetricAggregation } from 'types/api/v5/queryRange';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import {
QueryBuilderField,
QueryBuilderFieldsConfig,
} from '../../queryBuilderFields.types';
import {
mergeQueryBuilderFieldsConfig,
RAW_QUERY_FIELDS,
resolveQueryBuilderFields,
} from '../../queryBuilderFields.utils';
import HavingFilter from './HavingFilter/HavingFilter';
import { buildDefaultLegendFromGroupBy } from './utils';
@@ -33,25 +22,34 @@ import './QueryAddOns.styles.scss';
interface AddOn {
icon: React.ReactNode;
label: string;
key: QueryBuilderField;
key: string;
description?: string;
docLink?: string;
}
const ADD_ONS_KEYS_TO_QUERY_PATH: Partial<Record<QueryBuilderField, string>> = {
[QueryBuilderField.GroupBy]: 'groupBy',
[QueryBuilderField.Having]: 'having.expression',
[QueryBuilderField.OrderBy]: 'orderBy',
[QueryBuilderField.Limit]: 'limit',
[QueryBuilderField.Legend]: 'legend',
[QueryBuilderField.ReduceTo]: 'reduceTo',
const ADD_ONS_KEYS = {
GROUP_BY: 'group_by',
HAVING: 'having',
ORDER_BY: 'order_by',
LIMIT: 'limit',
LEGEND_FORMAT: 'legend_format',
REDUCE_TO: 'reduce_to',
};
const ADD_ONS: AddOn[] = [
const ADD_ONS_KEYS_TO_QUERY_PATH = {
[ADD_ONS_KEYS.GROUP_BY]: 'groupBy',
[ADD_ONS_KEYS.HAVING]: 'having.expression',
[ADD_ONS_KEYS.ORDER_BY]: 'orderBy',
[ADD_ONS_KEYS.LIMIT]: 'limit',
[ADD_ONS_KEYS.LEGEND_FORMAT]: 'legend',
[ADD_ONS_KEYS.REDUCE_TO]: 'reduceTo',
};
const ADD_ONS = [
{
icon: <BarChart size={14} />,
label: 'Group By',
key: QueryBuilderField.GroupBy,
key: ADD_ONS_KEYS.GROUP_BY,
description:
'Break down data by attributes like service name, endpoint, status code, or region. Essential for spotting patterns and comparing performance across different segments.',
docLink: 'https://signoz.io/docs/querying/aggregation-grouping/#grouping',
@@ -59,7 +57,7 @@ const ADD_ONS: AddOn[] = [
{
icon: <ScrollText size={14} />,
label: 'Having',
key: QueryBuilderField.Having,
key: ADD_ONS_KEYS.HAVING,
description:
'Filter grouped results based on aggregate conditions. Show only groups meeting specific criteria, like error rates > 5% or p99 latency > 500',
docLink:
@@ -68,7 +66,7 @@ const ADD_ONS: AddOn[] = [
{
icon: <ScrollText size={14} />,
label: 'Order By',
key: QueryBuilderField.OrderBy,
key: ADD_ONS_KEYS.ORDER_BY,
description:
'Sort results to surface what matters most. Quickly identify slowest operations, most frequent errors, or highest resource consumers.',
docLink:
@@ -77,7 +75,7 @@ const ADD_ONS: AddOn[] = [
{
icon: <ScrollText size={14} />,
label: 'Limit',
key: QueryBuilderField.Limit,
key: ADD_ONS_KEYS.LIMIT,
description:
'Show only the top/bottom N results. Perfect for focusing on outliers, reducing noise, and improving dashboard performance.',
docLink:
@@ -86,7 +84,7 @@ const ADD_ONS: AddOn[] = [
{
icon: <ScrollText size={14} />,
label: 'Legend format',
key: QueryBuilderField.Legend,
key: ADD_ONS_KEYS.LEGEND_FORMAT,
description:
'Customize series labels using variables like {{service.name}}-{{endpoint}}. Makes charts readable at a glance during incident investigation.',
docLink:
@@ -94,10 +92,10 @@ const ADD_ONS: AddOn[] = [
},
];
const REDUCE_TO: AddOn = {
const REDUCE_TO = {
icon: <ScrollText size={14} />,
label: 'Reduce to',
key: QueryBuilderField.ReduceTo,
key: ADD_ONS_KEYS.REDUCE_TO,
description:
'Apply mathematical operations like sum, average, min, max, or percentiles to reduce multiple time series into a single value.',
docLink:
@@ -156,26 +154,26 @@ function TooltipContent({
function QueryAddOns({
query,
version,
isRawQuery,
isListViewPanel,
showReduceTo,
panelType,
index,
fieldsConfig,
isForTraceOperator = false,
}: {
query: IBuilderQuery;
version: string;
isRawQuery: boolean;
isListViewPanel: boolean;
showReduceTo: boolean;
panelType: PANEL_TYPES | null;
index: number;
fieldsConfig?: QueryBuilderFieldsConfig;
isForTraceOperator?: boolean;
}): JSX.Element {
const [addOns, setAddOns] = useState<AddOn[]>(ADD_ONS);
const [selectedViews, setSelectedViews] = useState<AddOn[]>([]);
const initializedRef = useRef(false);
const prevAvailableKeysRef = useRef<Set<QueryBuilderField> | null>(null);
const prevAvailableKeysRef = useRef<Set<string> | null>(null);
const { handleChangeQueryData } = useQueryOperations({
index,
@@ -186,62 +184,40 @@ function QueryAddOns({
const { handleSetQueryData } = useQueryBuilder();
const supportedAddOns = useMemo((): AddOn[] => {
let addOns: AddOn[];
useEffect(() => {
if (isListViewPanel) {
setAddOns([]);
if (panelType === PANEL_TYPES.VALUE) {
addOns = ADD_ONS.filter((addOn) => addOn.key === QueryBuilderField.Legend);
} else if (query.dataSource === DataSource.METRICS) {
// Group by for metrics is offered by MetricsAggregateSection instead.
addOns = ADD_ONS.filter((addOn) => addOn.key !== QueryBuilderField.GroupBy);
} else {
addOns = [...ADD_ONS];
setSelectedViews([
ADD_ONS.find((addOn) => addOn.key === ADD_ONS_KEYS.ORDER_BY) as AddOn,
]);
return;
}
return showReduceTo ? [...addOns, REDUCE_TO] : addOns;
}, [panelType, query.dataSource, showReduceTo]);
let filteredAddOns: AddOn[];
if (panelType === PANEL_TYPES.VALUE) {
// Filter out all add-ons except legend format
filteredAddOns = ADD_ONS.filter(
(addOn) => addOn.key === ADD_ONS_KEYS.LEGEND_FORMAT,
);
} else {
filteredAddOns = Object.values(ADD_ONS);
const resolvedFields = useMemo(
() =>
resolveQueryBuilderFields(
supportedAddOns.map((addOn) => addOn.key),
mergeQueryBuilderFieldsConfig(
isRawQuery ? RAW_QUERY_FIELDS : undefined,
fieldsConfig,
),
),
[supportedAddOns, fieldsConfig, isRawQuery],
);
if (query.dataSource === DataSource.METRICS) {
// Filter out group_by for metrics data source (handled in MetricsAggregateSection)
filteredAddOns = filteredAddOns.filter(
(addOn) => addOn.key !== ADD_ONS_KEYS.GROUP_BY,
);
}
}
const offeredAddOns = useMemo(
() =>
supportedAddOns.filter((addOn) => !resolvedFields.get(addOn.key)?.hidden),
[supportedAddOns, resolvedFields],
);
if (showReduceTo) {
filteredAddOns = [...filteredAddOns, REDUCE_TO];
}
setAddOns(filteredAddOns);
const pinnedAddOns = useMemo(
() => offeredAddOns.filter((addOn) => resolvedFields.get(addOn.key)?.pinned),
[offeredAddOns, resolvedFields],
);
const togglableAddOns = useMemo(
() => offeredAddOns.filter((addOn) => !resolvedFields.get(addOn.key)?.pinned),
[offeredAddOns, resolvedFields],
);
const isPinned = useCallback(
(key: QueryBuilderField): boolean => Boolean(resolvedFields.get(key)?.pinned),
[resolvedFields],
);
const isDisabled = useCallback(
(key: QueryBuilderField): boolean =>
Boolean(resolvedFields.get(key)?.disabled),
[resolvedFields],
);
useEffect(() => {
const availableAddOnKeys = new Set(offeredAddOns.map((a) => a.key));
const availableAddOnKeys = new Set(filteredAddOns.map((a) => a.key));
const previousKeys = prevAvailableKeysRef.current;
const hasAvailabilityItemsChanged =
previousKeys !== null &&
@@ -255,39 +231,27 @@ function QueryAddOns({
const activeAddOnKeys = new Set(
Object.entries(ADD_ONS_KEYS_TO_QUERY_PATH)
.filter(([, path]) => hasValue(get(query, path)))
.map(([key]) => key as QueryBuilderField),
.map(([key]) => key),
);
// Initial seeding from query values on mount. A disabled field never opens.
// Initial seeding from query values on mount
setSelectedViews(
offeredAddOns.filter((addOn) => {
const resolved = resolvedFields.get(addOn.key);
return (
resolved?.pinned ||
(activeAddOnKeys.has(addOn.key) && !resolved?.disabled)
);
}),
filteredAddOns.filter(
(addOn) =>
activeAddOnKeys.has(addOn.key) && availableAddOnKeys.has(addOn.key),
),
);
return;
}
setSelectedViews((prev) => {
const kept = prev.filter((view) => availableAddOnKeys.has(view.key));
const reopenedPinned = pinnedAddOns.filter(
(addOn) => !kept.some((view) => view.key === addOn.key),
);
return [...kept, ...reopenedPinned];
});
}, [offeredAddOns, pinnedAddOns, query]);
setSelectedViews((prev) =>
prev.filter((view) =>
filteredAddOns.some((addOn) => addOn.key === view.key),
),
);
}, [panelType, isListViewPanel, query, showReduceTo]);
const handleOptionClick = (clickedAddOn: AddOn): void => {
if (isDisabled(clickedAddOn.key)) {
return;
}
const isAlreadySelected = selectedViews.some(
(view) => view.key === clickedAddOn.key,
);
@@ -301,7 +265,7 @@ function QueryAddOns({
// and existing group-by keys, prefill the legend using all group-by keys.
// This keeps existing custom legends intact and only helps seed a sensible default.
if (
clickedAddOn.key === QueryBuilderField.Legend &&
clickedAddOn.key === ADD_ONS_KEYS.LEGEND_FORMAT &&
isEmpty(query?.legend) &&
Array.isArray(query.groupBy) &&
query.groupBy.length > 0
@@ -346,16 +310,9 @@ function QueryAddOns({
[handleSetQueryData, index, query],
);
const handleRemoveView = useCallback(
(key: QueryBuilderField): void => {
if (isPinned(key)) {
return;
}
setSelectedViews((prev) => prev.filter((view) => view.key !== key));
},
[isPinned],
);
const handleRemoveView = useCallback((key: string): void => {
setSelectedViews((prev) => prev.filter((view) => view.key !== key));
}, []);
const handleChangeQueryLegend = useCallback(
(value: string) => {
@@ -384,7 +341,7 @@ function QueryAddOns({
<div className="query-add-ons" data-testid="query-add-ons">
{selectedViews.length > 0 && (
<div className="selected-add-ons-content">
{selectedViews.find((view) => view.key === QueryBuilderField.GroupBy) && (
{selectedViews.find((view) => view.key === 'group_by') && (
<div className="add-on-content" data-testid="group-by-content">
<div className="periscope-input-with-label">
<Tooltip
@@ -412,17 +369,15 @@ function QueryAddOns({
onChange={handleChangeGroupByKeys}
/>
</div>
{!isPinned(QueryBuilderField.GroupBy) && (
<Button
className="close-btn periscope-btn ghost"
icon={<ChevronUp size={16} />}
onClick={(): void => handleRemoveView(QueryBuilderField.GroupBy)}
/>
)}
<Button
className="close-btn periscope-btn ghost"
icon={<ChevronUp size={16} />}
onClick={(): void => handleRemoveView('group_by')}
/>
</div>
</div>
)}
{selectedViews.find((view) => view.key === QueryBuilderField.Having) && (
{selectedViews.find((view) => view.key === 'having') && (
<div className="add-on-content" data-testid="having-content">
<div className="periscope-input-with-label">
<Tooltip
@@ -442,7 +397,11 @@ function QueryAddOns({
</Tooltip>
<div className="input">
<HavingFilter
onClose={(): void => handleRemoveView(QueryBuilderField.Having)}
onClose={(): void => {
setSelectedViews((prev) =>
prev.filter((view) => view.key !== 'having'),
);
}}
onChange={handleChangeHaving}
queryData={query}
/>
@@ -450,7 +409,7 @@ function QueryAddOns({
</div>
</div>
)}
{selectedViews.find((view) => view.key === QueryBuilderField.Limit) && (
{selectedViews.find((view) => view.key === 'limit') && (
<div className="add-on-content" data-testid="limit-content">
<InputWithLabel
label="Limit"
@@ -458,12 +417,16 @@ function QueryAddOns({
onChange={handleChangeLimit}
initialValue={query?.limit ?? undefined}
placeholder="Enter limit"
onClose={(): void => handleRemoveView(QueryBuilderField.Limit)}
onClose={(): void => {
setSelectedViews((prev) =>
prev.filter((view) => view.key !== 'limit'),
);
}}
closeIcon={<ChevronUp size={16} />}
/>
</div>
)}
{selectedViews.find((view) => view.key === QueryBuilderField.OrderBy) && (
{selectedViews.find((view) => view.key === 'order_by') && (
<div className="add-on-content" data-testid="order-by-content">
<div className="periscope-input-with-label">
<Tooltip
@@ -486,22 +449,22 @@ function QueryAddOns({
entityVersion={version}
query={query}
onChange={handleChangeOrderByKeys}
isRawQuery={isRawQuery}
isListViewPanel={isListViewPanel}
isNewQueryV2
/>
</div>
{!isPinned(QueryBuilderField.OrderBy) && (
{!isListViewPanel && (
<Button
className="close-btn periscope-btn ghost"
icon={<ChevronUp size={16} />}
onClick={(): void => handleRemoveView(QueryBuilderField.OrderBy)}
onClick={(): void => handleRemoveView('order_by')}
/>
)}
</div>
</div>
)}
{selectedViews.find((view) => view.key === QueryBuilderField.ReduceTo) &&
{selectedViews.find((view) => view.key === 'reduce_to') &&
showReduceTo && (
<div className="add-on-content" data-testid="reduce-to-content">
<div className="periscope-input-with-label">
@@ -524,25 +487,27 @@ function QueryAddOns({
<ReduceToFilter query={query} onChange={handleChangeReduceToV5} />
</div>
{!isPinned(QueryBuilderField.ReduceTo) && (
<Button
className="close-btn periscope-btn ghost"
icon={<ChevronUp size={16} />}
onClick={(): void => handleRemoveView(QueryBuilderField.ReduceTo)}
/>
)}
<Button
className="close-btn periscope-btn ghost"
icon={<ChevronUp size={16} />}
onClick={(): void => handleRemoveView('reduce_to')}
/>
</div>
</div>
)}
{selectedViews.find((view) => view.key === QueryBuilderField.Legend) && (
{selectedViews.find((view) => view.key === 'legend_format') && (
<div className="add-on-content" data-testid="legend-format-content">
<InputWithLabel
label="Legend format"
placeholder="Write legend format"
onChange={handleChangeQueryLegend}
initialValue={isEmpty(query?.legend) ? undefined : query?.legend}
onClose={(): void => handleRemoveView(QueryBuilderField.Legend)}
onClose={(): void => {
setSelectedViews((prev) =>
prev.filter((view) => view.key !== 'legend_format'),
);
}}
closeIcon={<ChevronUp size={16} />}
/>
</div>
@@ -555,49 +520,42 @@ function QueryAddOns({
className="add-ons-tabs"
value={selectedViews.map((view) => view.key)}
onChange={(newKeys: string[]): void => {
const oldKeys: string[] = selectedViews.map((view) => view.key);
const oldKeys = selectedViews.map((view) => view.key);
const toggledKey =
newKeys.find((key) => !oldKeys.includes(key)) ??
oldKeys.find((key) => !newKeys.includes(key));
newKeys.find((k) => !oldKeys.includes(k)) ??
oldKeys.find((k) => !newKeys.includes(k));
if (!toggledKey) {
return;
}
const clickedAddOn = togglableAddOns.find((a) => a.key === toggledKey);
const clickedAddOn = addOns.find((a) => a.key === toggledKey);
if (clickedAddOn) {
handleOptionClick(clickedAddOn);
}
}}
items={togglableAddOns.map((addOn) => {
const resolved = resolvedFields.get(addOn.key);
return {
value: addOn.key,
label: (
<Tooltip
title={
<TooltipContent
label={addOn.label}
description={resolved?.reason ?? addOn.description}
docLink={resolved?.disabled ? undefined : addOn.docLink}
/>
}
placement="top"
mouseEnterDelay={0.5}
items={addOns.map((addOn) => ({
value: addOn.key,
label: (
<Tooltip
title={
<TooltipContent
label={addOn.label}
description={addOn.description}
docLink={addOn.docLink}
/>
}
placement="top"
mouseEnterDelay={0.5}
>
<span
className="add-on-tab-title"
data-testid={`query-add-on-${addOn.key}`}
>
<span
className={cx('add-on-tab-title', {
'add-on-tab-title--disabled': resolved?.disabled,
})}
aria-disabled={resolved?.disabled}
data-testid={`query-add-on-${addOn.key}`}
>
{addOn.icon}
{addOn.label}
</span>
</Tooltip>
),
};
})}
{addOn.icon}
{addOn.label}
</span>
</Tooltip>
),
}))}
/>
</div>
);

View File

@@ -8,12 +8,6 @@ import {
} from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import {
QueryBuilderField,
QueryBuilderFieldsConfig,
} from '../../queryBuilderFields.types';
import { resolveQueryBuilderField } from '../../queryBuilderFields.utils';
import QueryAggregationSelect from './QueryAggregationSelect';
import './QueryAggregation.styles.scss';
@@ -24,32 +18,24 @@ function QueryAggregationOptions({
onAggregationIntervalChange,
onChange,
queryData,
fieldsConfig,
}: {
dataSource: DataSource;
panelType?: string;
onAggregationIntervalChange: (value: number) => void;
onChange?: (value: string) => void;
queryData: IBuilderQuery | IBuilderTraceOperator;
fieldsConfig?: QueryBuilderFieldsConfig;
}): JSX.Element {
const stepInterval = useMemo(() => {
const showAggregationInterval = useMemo(() => {
if (panelType === PANEL_TYPES.VALUE) {
return { hidden: true, disabled: false, reason: undefined };
return false;
}
const isNonMetricSource =
dataSource === DataSource.TRACES || dataSource === DataSource.LOGS;
if (
isNonMetricSource &&
(panelType === PANEL_TYPES.TABLE || panelType === PANEL_TYPES.PIE)
) {
return { hidden: true, disabled: false, reason: undefined };
if (dataSource === DataSource.TRACES || dataSource === DataSource.LOGS) {
return !(panelType === PANEL_TYPES.TABLE || panelType === PANEL_TYPES.PIE);
}
return resolveQueryBuilderField(QueryBuilderField.StepInterval, fieldsConfig);
}, [dataSource, panelType, fieldsConfig]);
return true;
}, [dataSource, panelType]);
const handleAggregationIntervalChange = (value: string): void => {
onAggregationIntervalChange(Number(value));
@@ -71,24 +57,22 @@ function QueryAggregationOptions({
}
/>
{!stepInterval.hidden && (
{showAggregationInterval && (
<div className="query-aggregation-interval">
<Tooltip
title={
stepInterval.reason ?? (
<div>
Set the time interval for aggregation
<br />
<a
href="https://signoz.io/docs/userguide/query-builder-v5/#temporal-aggregation-within-each-time-series"
target="_blank"
rel="noopener noreferrer"
style={{ color: '#1890ff', textDecoration: 'underline' }}
>
Learn about step intervals
</a>
</div>
)
<div>
Set the time interval for aggregation
<br />
<a
href="https://signoz.io/docs/userguide/query-builder-v5/#temporal-aggregation-within-each-time-series"
target="_blank"
rel="noopener noreferrer"
style={{ color: '#1890ff', textDecoration: 'underline' }}
>
Learn about step intervals
</a>
</div>
}
placement="top"
>
@@ -108,7 +92,6 @@ function QueryAggregationOptions({
placeholder="Auto"
type="number"
onChange={handleAggregationIntervalChange}
disabled={stepInterval.disabled}
labelAfter
/>
</div>
@@ -122,7 +105,6 @@ function QueryAggregationOptions({
QueryAggregationOptions.defaultProps = {
panelType: null,
onChange: undefined,
fieldsConfig: undefined,
};
export default QueryAggregationOptions;

View File

@@ -17,13 +17,13 @@ function TraceOperatorSection({
const { currentQuery, panelType } = useQueryBuilder();
const showTraceOperatorWarning = useMemo(() => {
const isRawQueryPanel =
const isListViewPanel =
panelType === PANEL_TYPES.LIST || panelType === PANEL_TYPES.TRACE;
const hasMultipleQueries = currentQuery.builder.queryData.length > 1;
const hasTraceOperator =
currentQuery.builder.queryTraceOperator &&
currentQuery.builder.queryTraceOperator.length > 0;
return isRawQueryPanel && hasMultipleQueries && !hasTraceOperator;
return isListViewPanel && hasMultipleQueries && !hasTraceOperator;
}, [
currentQuery?.builder?.queryData,
currentQuery?.builder?.queryTraceOperator,
@@ -77,74 +77,50 @@ export default function QueryFooter({
addNewBuilderQuery,
addNewFormula,
addTraceOperator,
showAddQuery = true,
showAddFormula = true,
showAddTraceOperator = false,
addQueryDisabled = false,
addQueryDisabledReason,
addFormulaDisabled = false,
addFormulaDisabledReason,
}: {
addNewBuilderQuery: () => void;
addNewFormula: () => void;
addTraceOperator?: () => void;
showAddTraceOperator: boolean;
showAddQuery?: boolean;
showAddFormula?: boolean;
addQueryDisabled?: boolean;
addQueryDisabledReason?: string;
addFormulaDisabled?: boolean;
addFormulaDisabledReason?: string;
}): JSX.Element {
return (
<div className="qb-footer">
<div className="qb-footer-container">
{showAddQuery && (
<div className="qb-add-new-query">
<Tooltip
title={
addQueryDisabledReason ?? (
<div style={{ textAlign: 'center' }}>Add New Query</div>
)
}
>
<Button
className="add-new-query-button periscope-btn "
data-testid="add-new-query-button"
icon={<Plus size={16} />}
onClick={addNewBuilderQuery}
disabled={addQueryDisabled}
/>
</Tooltip>
</div>
)}
<div className="qb-add-new-query">
<Tooltip title={<div style={{ textAlign: 'center' }}>Add New Query</div>}>
<Button
className="add-new-query-button periscope-btn "
icon={<Plus size={16} />}
onClick={addNewBuilderQuery}
/>
</Tooltip>
</div>
{showAddFormula && (
<div className="qb-add-formula">
<Tooltip
title={
addFormulaDisabledReason ?? (
<div style={{ textAlign: 'center' }}>
Add New Formula
<Typography.Link
href="https://signoz.io/docs/querying/multi-query-analysis/#advanced-comparisons"
target="_blank"
style={{ textDecoration: 'underline' }}
>
{' '}
<br />
Learn more
</Typography.Link>
</div>
)
<div style={{ textAlign: 'center' }}>
Add New Formula
<Typography.Link
href="https://signoz.io/docs/querying/multi-query-analysis/#advanced-comparisons"
target="_blank"
style={{ textDecoration: 'underline' }}
>
{' '}
<br />
Learn more
</Typography.Link>
</div>
}
>
<Button
className="add-formula-button periscope-btn "
data-testid="add-formula-button"
icon={<Sigma size={16} />}
onClick={addNewFormula}
disabled={addFormulaDisabled}
>
Add Formula
</Button>

View File

@@ -20,13 +20,6 @@ import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { HandleChangeQueryDataV5 } from 'types/common/operations.types';
import { DataSource } from 'types/common/queryBuilder';
import { QueryBuilderField } from '../queryBuilderFields.types';
import {
mergeQueryBuilderFieldsConfig,
RAW_QUERY_FIELDS,
resolveQueryBuilderField,
} from '../queryBuilderFields.utils';
import MetricsAggregateSection from './MerticsAggregateSection/MetricsAggregateSection';
import { MetricsSelect } from './MetricsSelect/MetricsSelect';
import QueryAddOns from './QueryAddOns/QueryAddOns';
@@ -38,7 +31,8 @@ export const QueryV2 = forwardRef(function QueryV2(
index,
queryVariant,
query,
isRawQuery = false,
filterConfigs,
isListViewPanel = false,
showTraceOperator = false,
hasTraceOperator = false,
version,
@@ -49,8 +43,6 @@ export const QueryV2 = forwardRef(function QueryV2(
signalSourceChangeEnabled = false,
queriesCount = 1,
savePreviousQuery = false,
fieldsConfig,
allowedDataSources,
}: QueryProps & {
onSignalSourceChange: (value: string) => void;
signalSourceChangeEnabled: boolean;
@@ -61,7 +53,7 @@ export const QueryV2 = forwardRef(function QueryV2(
): JSX.Element {
const { cloneQuery, panelType } = useQueryBuilder();
const hasQueryFunctions = query?.functions?.length > 0;
const showFunctions = query?.functions?.length > 0;
const { dataSource, builderQueryType } = query;
const [isCollapsed, setIsCollapsed] = useState(false);
@@ -74,7 +66,8 @@ export const QueryV2 = forwardRef(function QueryV2(
} = useQueryOperations({
index,
query,
isRawQuery,
filterConfigs,
isListViewPanel,
entityVersion: version,
savePreviousQuery,
});
@@ -106,31 +99,14 @@ export const QueryV2 = forwardRef(function QueryV2(
[dataSource, builderQueryType],
);
const resolvedConfig = useMemo(
() =>
mergeQueryBuilderFieldsConfig(
isRawQuery ? RAW_QUERY_FIELDS : undefined,
fieldsConfig,
),
[isRawQuery, fieldsConfig],
);
const aggregation = useMemo(
() => resolveQueryBuilderField(QueryBuilderField.Aggregation, resolvedConfig),
[resolvedConfig],
);
const functions = useMemo(
() => resolveQueryBuilderField(QueryBuilderField.Functions, resolvedConfig),
[resolvedConfig],
);
const showInlineQuerySearch = useMemo(() => {
if (!showTraceOperator) {
return false;
}
return dataSource === DataSource.TRACES && (hasTraceOperator || isRawQuery);
}, [hasTraceOperator, isRawQuery, showTraceOperator, dataSource]);
return (
dataSource === DataSource.TRACES && (hasTraceOperator || isListViewPanel)
);
}, [hasTraceOperator, isListViewPanel, showTraceOperator, dataSource]);
const handleChangeAggregateEvery = useCallback(
(value: IBuilderQuery['stepInterval']) => {
@@ -173,15 +149,12 @@ export const QueryV2 = forwardRef(function QueryV2(
hasTraceOperator={hasTraceOperator}
isMetricsDataSource={dataSource === DataSource.METRICS}
showFunctions={
!functions.hidden &&
((version && version === ENTITY_VERSION_V4) ||
query.dataSource === DataSource.LOGS ||
query.dataSource === DataSource.METRICS ||
hasQueryFunctions ||
false)
(version && version === ENTITY_VERSION_V4) ||
query.dataSource === DataSource.LOGS ||
query.dataSource === DataSource.METRICS ||
showFunctions ||
false
}
functionsDisabled={functions.disabled}
functionsDisabledReason={functions.reason}
isCollapsed={isCollapsed}
showTraceOperator={showTraceOperator}
entityType="query"
@@ -194,8 +167,7 @@ export const QueryV2 = forwardRef(function QueryV2(
onQueryFunctionsUpdates={handleQueryFunctionsUpdates}
showDeleteButton={false}
showCloneOption={false}
isRawQuery={isRawQuery}
allowedDataSources={allowedDataSources}
isListViewPanel={isListViewPanel}
index={index}
queryVariant={queryVariant}
onChangeDataSource={handleChangeDataSource}
@@ -295,7 +267,7 @@ export const QueryV2 = forwardRef(function QueryV2(
</div>
{!showOnlyWhereClause &&
!aggregation.hidden &&
!isListViewPanel &&
!(hasTraceOperator && dataSource === DataSource.TRACES) &&
dataSource !== DataSource.METRICS && (
<QueryAggregation
@@ -305,7 +277,6 @@ export const QueryV2 = forwardRef(function QueryV2(
onAggregationIntervalChange={handleChangeAggregateEvery}
onChange={handleChangeAggregation}
queryData={query}
fieldsConfig={fieldsConfig}
/>
)}
@@ -326,10 +297,9 @@ export const QueryV2 = forwardRef(function QueryV2(
index={index}
query={query}
version="v3"
isRawQuery={isRawQuery}
isListViewPanel={isListViewPanel}
showReduceTo={showReduceTo}
panelType={panelType}
fieldsConfig={fieldsConfig}
/>
)}
</div>

View File

@@ -11,7 +11,6 @@ import {
} from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { QueryBuilderFieldsConfig } from '../../queryBuilderFields.types';
import QueryAddOns from '../QueryAddOns/QueryAddOns';
import QueryAggregation from '../QueryAggregation/QueryAggregation';
import TraceOperatorEditor from './TraceOperatorEditor';
@@ -20,12 +19,10 @@ import './TraceOperator.styles.scss';
export default function TraceOperator({
traceOperator,
isRawQuery = false,
fieldsConfig,
isListViewPanel = false,
}: {
traceOperator: IBuilderTraceOperator;
isRawQuery?: boolean;
fieldsConfig?: QueryBuilderFieldsConfig;
isListViewPanel?: boolean;
}): JSX.Element {
const { panelType, removeTraceOperator } = useQueryBuilder();
const { handleChangeQueryData } = useQueryOperations({
@@ -61,12 +58,12 @@ export default function TraceOperator({
);
return (
<div className={cx('qb-trace-operator', !isRawQuery && 'non-list-view')}>
<div className={cx('qb-trace-operator', !isListViewPanel && 'non-list-view')}>
<div className="qb-trace-operator-container">
<div
className={cx(
'qb-trace-operator-label-with-input',
!isRawQuery && 'qb-trace-operator-arrow',
!isListViewPanel && 'qb-trace-operator-arrow',
)}
>
<Typography.Text className="label">Trace Operator</Typography.Text>
@@ -79,9 +76,9 @@ export default function TraceOperator({
</div>
</div>
{!isRawQuery && (
{!isListViewPanel && (
<div className="qb-trace-operator-aggregation-container">
<div className={cx(!isRawQuery && 'qb-trace-operator-arrow')}>
<div className={cx(!isListViewPanel && 'qb-trace-operator-arrow')}>
<QueryAggregation
dataSource={DataSource.TRACES}
key={`query-search-${traceOperator.queryName}`}
@@ -89,13 +86,12 @@ export default function TraceOperator({
onAggregationIntervalChange={handleChangeAggregateEvery}
onChange={handleChangeAggregation}
queryData={traceOperator}
fieldsConfig={fieldsConfig}
/>
</div>
<div
className={cx(
'qb-trace-operator-add-ons-container',
!isRawQuery && 'qb-trace-operator-arrow',
!isListViewPanel && 'qb-trace-operator-arrow',
)}
>
<QueryAddOns
@@ -103,10 +99,9 @@ export default function TraceOperator({
query={traceOperator}
version="v3"
isForTraceOperator
isRawQuery={false}
isListViewPanel={false}
showReduceTo={false}
panelType={panelType}
fieldsConfig={fieldsConfig}
/>
</div>
</div>

View File

@@ -142,6 +142,7 @@ describe('QueryBuilderV2 + QueryV2 - base render', () => {
isMetricsDataSource: false,
operators: [],
spaceAggregationOptions: [],
listOfAdditionalFilters: [],
handleChangeOperator: jest.fn(),
handleSpaceAggregationChange: jest.fn(),
handleChangeAggregatorAttribute: jest.fn(),
@@ -151,6 +152,7 @@ describe('QueryBuilderV2 + QueryV2 - base render', () => {
jest.fn() as unknown as ReturnType<UseQueryOperations>['handleChangeQueryData'],
handleChangeFormulaData: jest.fn(),
handleQueryFunctionsUpdates: handleQueryFunctionsUpdatesMock,
listOfAdditionalFormulaFilters: [],
});
});

View File

@@ -95,7 +95,7 @@ describe('QueryAddOns', () => {
<QueryAddOns
query={baseQuery()}
version="v5"
isRawQuery={false}
isListViewPanel={false}
showReduceTo
panelType={PANEL_TYPES.VALUE}
index={0}
@@ -119,7 +119,7 @@ describe('QueryAddOns', () => {
groupBy: ['service.name'],
})}
version="v5"
isRawQuery={false}
isListViewPanel={false}
showReduceTo={false}
panelType={PANEL_TYPES.TIME_SERIES}
index={0}
@@ -135,7 +135,7 @@ describe('QueryAddOns', () => {
<QueryAddOns
query={baseQuery()}
version="v5"
isRawQuery
isListViewPanel
showReduceTo={false}
panelType={PANEL_TYPES.LIST}
index={0}
@@ -151,7 +151,7 @@ describe('QueryAddOns', () => {
<QueryAddOns
query={baseQuery({ limit: 5 })}
version="v5"
isRawQuery={false}
isListViewPanel={false}
showReduceTo={false}
panelType={PANEL_TYPES.TIME_SERIES}
index={0}
@@ -176,7 +176,7 @@ describe('QueryAddOns', () => {
<QueryAddOns
query={query}
version="v5"
isRawQuery={false}
isListViewPanel={false}
showReduceTo={false}
panelType={PANEL_TYPES.TIME_SERIES}
index={0}
@@ -195,7 +195,7 @@ describe('QueryAddOns', () => {
<QueryAddOns
query={baseQuery()}
version="v5"
isRawQuery={false}
isListViewPanel={false}
showReduceTo
panelType={PANEL_TYPES.TIME_SERIES}
index={0}
@@ -211,7 +211,7 @@ describe('QueryAddOns', () => {
<QueryAddOns
query={baseQuery({ reduceTo: ReduceOperators.SUM })}
version="v5"
isRawQuery={false}
isListViewPanel={false}
showReduceTo
panelType={PANEL_TYPES.TIME_SERIES}
index={0}
@@ -234,7 +234,7 @@ describe('QueryAddOns', () => {
<QueryAddOns
query={query}
version="v5"
isRawQuery={false}
isListViewPanel={false}
showReduceTo
panelType={PANEL_TYPES.TIME_SERIES}
index={0}
@@ -286,7 +286,7 @@ describe('QueryAddOns', () => {
<QueryAddOns
query={query}
version="v5"
isRawQuery={false}
isListViewPanel={false}
showReduceTo={false}
panelType={PANEL_TYPES.TIME_SERIES}
index={0}
@@ -314,7 +314,7 @@ describe('QueryAddOns', () => {
<QueryAddOns
query={query}
version="v5"
isRawQuery={false}
isListViewPanel={false}
showReduceTo={false}
panelType={PANEL_TYPES.TIME_SERIES}
index={0}

View File

@@ -1,46 +0,0 @@
import { render, screen } from 'tests/test-utils';
import QueryFooter from '../QueryV2/QueryFooter/QueryFooter';
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: (): {
currentQuery: { builder: { queryData: unknown[] } };
panelType: string;
} => ({
currentQuery: { builder: { queryData: [] } },
panelType: 'time_series',
}),
}));
const noop = (): void => {};
describe('QueryFooter', () => {
it('offers both buttons by default', () => {
render(
<QueryFooter
addNewBuilderQuery={noop}
addNewFormula={noop}
showAddTraceOperator={false}
/>,
);
expect(screen.getByTestId('add-new-query-button')).toBeInTheDocument();
expect(screen.getByTestId('add-formula-button')).toBeInTheDocument();
});
// A kind whose request takes a single query (Heatmap) hides the button outright
// rather than disabling it — a query it adds is one the builder cannot render.
it('drops the Add New Query button when the caller withholds it', () => {
render(
<QueryFooter
addNewBuilderQuery={noop}
addNewFormula={noop}
showAddQuery={false}
showAddTraceOperator={false}
/>,
);
expect(screen.queryByTestId('add-new-query-button')).not.toBeInTheDocument();
expect(screen.getByTestId('add-formula-button')).toBeInTheDocument();
});
});

View File

@@ -1,140 +0,0 @@
import { QueryBuilderField } from '../queryBuilderFields.types';
import {
mergeQueryBuilderFieldsConfig,
RAW_QUERY_FIELDS,
resolveQueryBuilderField,
resolveQueryBuilderFields,
} from '../queryBuilderFields.utils';
const SUPPORTED = [
QueryBuilderField.GroupBy,
QueryBuilderField.Having,
QueryBuilderField.OrderBy,
QueryBuilderField.Limit,
QueryBuilderField.Legend,
];
describe('resolveQueryBuilderField', () => {
it('leaves an unconfigured field available', () => {
expect(resolveQueryBuilderField(QueryBuilderField.Having)).toStrictEqual({
hidden: false,
disabled: false,
pinned: false,
});
});
it('hides a field configured hidden', () => {
const resolved = resolveQueryBuilderField(QueryBuilderField.Having, {
[QueryBuilderField.Having]: { state: 'hidden' },
});
expect(resolved.hidden).toBe(true);
expect(resolved.disabled).toBe(false);
});
it('carries the reason through on a disabled field', () => {
const resolved = resolveQueryBuilderField(QueryBuilderField.Having, {
[QueryBuilderField.Having]: {
state: 'disabled',
reason: 'Having filters aggregated results.',
},
});
expect(resolved).toStrictEqual({
hidden: false,
disabled: true,
reason: 'Having filters aggregated results.',
pinned: false,
});
});
it('pins a field configured pinned', () => {
const resolved = resolveQueryBuilderField(QueryBuilderField.OrderBy, {
[QueryBuilderField.OrderBy]: { state: 'pinned' },
});
expect(resolved.pinned).toBe(true);
expect(resolved.hidden).toBe(false);
});
it('only ever resolves one state at a time', () => {
const resolved = resolveQueryBuilderField(QueryBuilderField.Limit, {
[QueryBuilderField.Limit]: { state: 'disabled', reason: 'why' },
});
expect([resolved.hidden, resolved.disabled, resolved.pinned]).toStrictEqual([
false,
true,
false,
]);
});
});
describe('resolveQueryBuilderFields', () => {
it('resolves every supported field and nothing else', () => {
const resolved = resolveQueryBuilderFields(SUPPORTED);
expect([...resolved.keys()]).toStrictEqual(SUPPORTED);
});
it('cannot widen beyond what the builder supports', () => {
const resolved = resolveQueryBuilderFields([QueryBuilderField.Legend], {
[QueryBuilderField.ReduceTo]: { state: 'pinned' },
});
expect(resolved.has(QueryBuilderField.ReduceTo)).toBe(false);
});
});
describe('mergeQueryBuilderFieldsConfig', () => {
it('returns the override when there is no baseline', () => {
const override = { [QueryBuilderField.Limit]: { state: 'hidden' } } as const;
expect(mergeQueryBuilderFieldsConfig(undefined, override)).toBe(override);
});
it('returns the baseline when there is no override', () => {
expect(mergeQueryBuilderFieldsConfig(RAW_QUERY_FIELDS, undefined)).toBe(
RAW_QUERY_FIELDS,
);
});
it('lets the override win per field, leaving the rest of the baseline intact', () => {
const merged = mergeQueryBuilderFieldsConfig(RAW_QUERY_FIELDS, {
[QueryBuilderField.Having]: { state: 'disabled', reason: 'no aggregation' },
});
expect(merged?.[QueryBuilderField.Having]).toStrictEqual({
state: 'disabled',
reason: 'no aggregation',
});
expect(merged?.[QueryBuilderField.GroupBy]).toStrictEqual({
state: 'hidden',
});
expect(merged?.[QueryBuilderField.OrderBy]).toStrictEqual({
state: 'pinned',
});
});
});
describe('RAW_QUERY_FIELDS', () => {
it('reduces an aggregate surface to a pinned order by', () => {
const resolved = resolveQueryBuilderFields(SUPPORTED, RAW_QUERY_FIELDS);
const visible = [...resolved.entries()]
.filter(([, field]) => !field.hidden)
.map(([key]) => key);
expect(visible).toStrictEqual([QueryBuilderField.OrderBy]);
expect(resolved.get(QueryBuilderField.OrderBy)?.pinned).toBe(true);
});
it('leaves additional queries alone, so trace matching still allows several', () => {
expect(
resolveQueryBuilderField(
QueryBuilderField.AdditionalQueries,
RAW_QUERY_FIELDS,
).hidden,
).toBe(false);
});
});

View File

@@ -1,36 +0,0 @@
/**
* Everything the query builder can surface.
*
* The per-query values double as the add-on identities the builder renders
* (`data-testid="query-add-on-<value>"`), so they are part of the DOM contract and must
* not be renamed to match the member names.
*/
export enum QueryBuilderField {
// Per query
Aggregation = 'aggregation',
StepInterval = 'step_interval',
Functions = 'functions',
GroupBy = 'group_by',
Having = 'having',
OrderBy = 'order_by',
Limit = 'limit',
Legend = 'legend_format',
ReduceTo = 'reduce_to',
// Builder level
Formula = 'formula',
AdditionalQueries = 'additional_queries',
}
/** `reason` is required on `disabled`: an inert control the user can see has to explain itself. */
export type QueryBuilderFieldRule =
| { state: 'hidden' }
| { state: 'disabled'; reason: string }
| { state: 'pinned' };
/**
* A caller's narrowing of the builder's surface. The builder works out which fields suit
* the current data source and panel type first; this can only take away from that set.
*/
export type QueryBuilderFieldsConfig = Partial<
Record<QueryBuilderField, QueryBuilderFieldRule>
>;

View File

@@ -1,88 +0,0 @@
import {
QueryBuilderField,
QueryBuilderFieldRule,
QueryBuilderFieldsConfig,
} from './queryBuilderFields.types';
export interface ResolvedQueryBuilderField {
hidden: boolean;
disabled: boolean;
reason?: string;
/** Rendered open, not dismissable, and kept out of the add-on toggle bar. */
pinned: boolean;
}
const AVAILABLE: ResolvedQueryBuilderField = {
hidden: false,
disabled: false,
pinned: false,
};
function fromRule(rule: QueryBuilderFieldRule): ResolvedQueryBuilderField {
switch (rule.state) {
case 'hidden':
return { hidden: true, disabled: false, pinned: false };
case 'disabled':
return {
hidden: false,
disabled: true,
reason: rule.reason,
pinned: false,
};
case 'pinned':
return { hidden: false, disabled: false, pinned: true };
default:
return AVAILABLE;
}
}
export function resolveQueryBuilderField(
field: QueryBuilderField,
config?: QueryBuilderFieldsConfig,
): ResolvedQueryBuilderField {
const rule = config?.[field];
return rule ? fromRule(rule) : AVAILABLE;
}
/**
* Fields absent from `supported` are hidden whatever the config says, so a config can
* only ever take away.
*/
export function resolveQueryBuilderFields(
supported: readonly QueryBuilderField[],
config?: QueryBuilderFieldsConfig,
): Map<QueryBuilderField, ResolvedQueryBuilderField> {
return new Map(
supported.map((field) => [field, resolveQueryBuilderField(field, config)]),
);
}
/**
* The surface a raw-row builder starts from, layered under a caller's own config.
* `AdditionalQueries` is deliberately absent — a raw trace builder still takes several
* queries when trace matching is on.
*/
export const RAW_QUERY_FIELDS: QueryBuilderFieldsConfig = {
[QueryBuilderField.Aggregation]: { state: 'hidden' },
[QueryBuilderField.StepInterval]: { state: 'hidden' },
[QueryBuilderField.Functions]: { state: 'hidden' },
[QueryBuilderField.GroupBy]: { state: 'hidden' },
[QueryBuilderField.Having]: { state: 'hidden' },
[QueryBuilderField.Limit]: { state: 'hidden' },
[QueryBuilderField.Legend]: { state: 'hidden' },
[QueryBuilderField.ReduceTo]: { state: 'hidden' },
[QueryBuilderField.Formula]: { state: 'hidden' },
[QueryBuilderField.OrderBy]: { state: 'pinned' },
};
export function mergeQueryBuilderFieldsConfig(
baseline: QueryBuilderFieldsConfig | undefined,
override: QueryBuilderFieldsConfig | undefined,
): QueryBuilderFieldsConfig | undefined {
if (!baseline) {
return override;
}
return override ? { ...baseline, ...override } : baseline;
}

View File

@@ -31,8 +31,6 @@ export const getComponentForPanelType = (
[PANEL_TYPES.BAR]: Uplot,
[PANEL_TYPES.PIE]: null,
[PANEL_TYPES.HISTOGRAM]: Uplot,
// V2-only kind; it renders through the V2 panel registry.
[PANEL_TYPES.HEATMAP]: null,
[PANEL_TYPES.EMPTY_WIDGET]: null,
};

View File

@@ -32,6 +32,7 @@ import {
MeterAggregateOperator,
MetricAggregateOperator,
NumberOperators,
QueryAdditionalFilter,
QueryBuilderData,
ReduceOperators,
StringOperators,
@@ -103,6 +104,43 @@ export const metricsSpaceAggregationOperatorsByType = {
ExponentialHistogram: metricsHistogramSpaceAggregateOperatorOptions,
};
export const mapOfQueryFilters: Record<DataSource, QueryAdditionalFilter[]> = {
metrics: [
{ text: 'Aggregation interval', field: 'stepInterval' },
{ text: 'Having', field: 'having' },
],
logs: [
{ text: 'Order by', field: 'orderBy' },
{ text: 'Limit', field: 'limit' },
{ text: 'Having', field: 'having' },
{ text: 'Aggregation interval', field: 'stepInterval' },
],
traces: [
{ text: 'Order by', field: 'orderBy' },
{ text: 'Limit', field: 'limit' },
{ text: 'Having', field: 'having' },
{ text: 'Aggregation interval', field: 'stepInterval' },
],
};
const commonFormulaFilters: QueryAdditionalFilter[] = [
{
text: 'Having',
field: 'having',
},
{ text: 'Order by', field: 'orderBy' },
{ text: 'Limit', field: 'limit' },
];
export const mapOfFormulaToFilters: Record<
DataSource,
QueryAdditionalFilter[]
> = {
metrics: commonFormulaFilters,
logs: commonFormulaFilters,
traces: commonFormulaFilters,
};
export const REDUCE_TO_VALUES: SelectOption<ReduceOperators, string>[] = [
{ value: ReduceOperators.LAST, label: 'Latest of values in timeframe' },
{ value: ReduceOperators.SUM, label: 'Sum of values in timeframe' },
@@ -338,7 +376,6 @@ export enum PANEL_TYPES {
BAR = 'bar',
PIE = 'pie',
HISTOGRAM = 'histogram',
HEATMAP = 'heatmap',
EMPTY_WIDGET = 'EMPTY_WIDGET',
}
@@ -586,7 +623,6 @@ export const PANEL_TYPES_INITIAL_QUERY: Record<PANEL_TYPES, Query> = {
[PANEL_TYPES.BAR]: initialQueriesMap.metrics,
[PANEL_TYPES.PIE]: initialQueriesMap.metrics,
[PANEL_TYPES.HISTOGRAM]: initialQueriesMap.metrics,
[PANEL_TYPES.HEATMAP]: initialQueriesMap.metrics,
[PANEL_TYPES.EMPTY_WIDGET]: initialQueriesMap.metrics,
};

View File

@@ -527,21 +527,6 @@ export const metricsHistogramSpaceAggregateOperatorOptions: SelectOption<
},
];
/**
* A heatmap's Y axis is the `le` labels themselves, so every percentile draws the grid a
* count already draws. Sum is also what the statement builder forces on a histogram
* heatmap whatever is asked for, so it is the only honest option to offer.
*/
export const metricsHeatmapHistogramSpaceAggregateOperatorOptions: SelectOption<
string,
string
>[] = [
{
value: MetricAggregateOperator.COUNT,
label: 'Count',
},
];
export const metricsEmptyTimeAggregateOperatorOptions: SelectOption<
string,
string

View File

@@ -1,23 +1,35 @@
import { memo, useMemo } from 'react';
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
import { DataSource } from 'types/common/queryBuilder';
function QuerySection(): JSX.Element {
const panelTypes = useGetPanelTypesQueryParam(PANEL_TYPES.LIST);
const isRawQuery = useMemo(
// Only reaches the builder for timeseries/table; list/trace panels use QueryBuilderV2's listViewTracesFilterConfigs.
const filterConfigs: QueryBuilderProps['filterConfigs'] = useMemo(
() => ({
stepInterval: { isHidden: false, isDisabled: false },
limit: { isHidden: false, isDisabled: true },
having: { isHidden: false, isDisabled: true },
}),
[],
);
const isListViewPanel = useMemo(
() => panelTypes === PANEL_TYPES.LIST || panelTypes === PANEL_TYPES.TRACE,
[panelTypes],
);
return (
<QueryBuilderV2
isRawQuery={isRawQuery}
isListViewPanel={isListViewPanel}
config={{ initialDataSource: DataSource.TRACES, queryVariant: 'static' }}
panelType={panelTypes}
showOnlyWhereClause={isRawQuery}
filterConfigs={filterConfigs}
showOnlyWhereClause={isListViewPanel}
version="v3" // setting this to v3 as we this is rendered in logs explorer
/>
);

View File

@@ -1,6 +1,13 @@
import { memo, useMemo } from 'react';
import { memo, useCallback, useMemo } from 'react';
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import {
initialQueriesMap,
OPERATORS,
PANEL_TYPES,
} from 'constants/queryBuilder';
import ExplorerOrderBy from 'container/ExplorerOrderBy';
import { OrderByFilterProps } from 'container/QueryBuilder/filters/OrderByFilter/OrderByFilter.interfaces';
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
@@ -29,11 +36,42 @@ function LogExplorerQuerySection({
useShareBuilderUrl({ defaultValue });
const filterConfigs: QueryBuilderProps['filterConfigs'] = useMemo(() => {
const isTable = panelTypes === PANEL_TYPES.TABLE;
const isList = panelTypes === PANEL_TYPES.LIST;
const config: QueryBuilderProps['filterConfigs'] = {
stepInterval: { isHidden: isTable, isDisabled: false },
having: { isHidden: isList, isDisabled: true },
filters: {
customKey: 'body',
customOp: OPERATORS.CONTAINS,
},
};
return config;
}, [panelTypes]);
const renderOrderBy = useCallback(
({ query, onChange }: OrderByFilterProps): JSX.Element => (
<ExplorerOrderBy query={query} onChange={onChange} />
),
[],
);
const queryComponents = useMemo(
(): QueryBuilderProps['queryComponents'] => ({
...(panelTypes === PANEL_TYPES.LIST ? { renderOrderBy } : {}),
}),
[panelTypes, renderOrderBy],
);
return (
<QueryBuilderV2
isRawQuery={panelTypes === PANEL_TYPES.LIST}
isListViewPanel={panelTypes === PANEL_TYPES.LIST}
config={{ initialDataSource: DataSource.LOGS, queryVariant: 'static' }}
panelType={panelTypes}
filterConfigs={filterConfigs}
queryComponents={queryComponents}
showOnlyWhereClause={selectedView === ExplorerViews.LIST}
version="v3" // setting this to v3 as we this is rendered in logs explorer
/>

View File

@@ -11,6 +11,7 @@ import { initialQueryMeterWithType, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import ExplorerOptionWrapper from 'container/ExplorerOptions/ExplorerOptionWrapper';
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
import DateTimeSelector from 'container/TopNav/DateTimeSelectionV2';
import { ExportDashboard } from 'hooks/dashboard/useExportDashboards';
import { useGetExportToDashboardLink } from 'hooks/dashboard/useGetExportToDashboardLink';
@@ -117,6 +118,11 @@ function Explorer(): JSX.Element {
});
}, []);
const queryComponents = useMemo(
(): QueryBuilderProps['queryComponents'] => ({}),
[],
);
return (
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
<div
@@ -172,6 +178,7 @@ function Explorer(): JSX.Element {
signalSource: 'meter',
}}
panelType={PANEL_TYPES.TIME_SERIES}
queryComponents={queryComponents}
showFunctions={false}
version="v3"
/>

View File

@@ -12,6 +12,7 @@ import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { usePageActions } from 'container/AIAssistant/pageActions/usePageActions';
import ExplorerOptionWrapper from 'container/ExplorerOptions/ExplorerOptionWrapper';
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
import DateTimeSelector from 'container/TopNav/DateTimeSelectionV2';
import { ExportDashboard } from 'hooks/dashboard/useExportDashboards';
import { useGetExportToDashboardLink } from 'hooks/dashboard/useGetExportToDashboardLink';
@@ -322,6 +323,11 @@ function Explorer(): JSX.Element {
});
}, []);
const queryComponents = useMemo(
(): QueryBuilderProps['queryComponents'] => ({}),
[],
);
const [warning, setWarning] = useState<Warning | undefined>();
const oneChartPerQueryDisabledTooltip = useMemo(() => {
@@ -375,6 +381,7 @@ function Explorer(): JSX.Element {
<QueryBuilderV2
config={{ initialDataSource: DataSource.METRICS, queryVariant: 'static' }}
panelType={PANEL_TYPES.TIME_SERIES}
queryComponents={queryComponents}
showFunctions={false}
version="v3"
/>

View File

@@ -1,9 +1,22 @@
import { ReactNode } from 'react';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { QueryBuilderFieldsConfig } from 'components/QueryBuilderV2/queryBuilderFields.types';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { OrderByFilterProps } from './filters/OrderByFilter/OrderByFilter.interfaces';
export type WhereClauseConfig = {
customKey: string;
customOp: string;
};
type FilterConfigs = {
[Key in keyof Omit<IBuilderQuery, 'filters'>]: {
isHidden: boolean;
isDisabled: boolean;
};
} & { filters: WhereClauseConfig };
export type QueryBuilderConfig =
| {
queryVariant: 'static';
@@ -16,16 +29,9 @@ export type QueryBuilderProps = {
config?: QueryBuilderConfig;
panelType: PANEL_TYPES;
actions?: ReactNode;
fieldsConfig?: QueryBuilderFieldsConfig;
/**
* The builder edits raw rows rather than an aggregation: a single query unless trace
* matching is on, no formulas, data-source switches reset to the raw-query template,
* and order by resolves keys without an aggregate attribute. Supplies the defaults for
* `fieldsConfig` and `allowedDataSources`, which override it per field.
*/
isRawQuery?: boolean;
/** Defaults to every signal. */
allowedDataSources?: TelemetrytypesSignalDTO[];
filterConfigs?: Partial<FilterConfigs>;
queryComponents?: { renderOrderBy?: (props: OrderByFilterProps) => ReactNode };
isListViewPanel?: boolean;
showFunctions?: boolean;
showOnlyWhereClause?: boolean;
showOnlyTraceOperator?: boolean;

View File

@@ -0,0 +1,6 @@
import { ReactNode } from 'react';
export type AdditionalFiltersProps = {
listOfAdditionalFilter: string[];
children: ReactNode;
};

View File

@@ -0,0 +1,38 @@
import { SquareMinus, SquarePlus } from '@signozhq/icons';
import { Color } from '@signozhq/design-tokens';
import { Col } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import styled, { css } from 'styled-components';
const IconCss = css`
margin-right: 0.6875rem;
transition: all 0.2s ease;
`;
export const StyledIconOpen = styled(SquarePlus)`
${IconCss}
`;
export const StyledIconClose = styled(SquareMinus)`
${IconCss}
`;
export const StyledInner = styled(Col)`
width: fit-content;
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 0.875rem;
min-height: 1.375rem;
cursor: pointer;
&:hover {
${StyledIconOpen}, ${StyledIconClose} {
opacity: 0.7;
}
}
`;
export const StyledLink = styled(Typography.Link)`
pointer-events: none;
color: ${Color.BG_ROBIN_400} !important;
`;

View File

@@ -0,0 +1,15 @@
.filter-toggler {
margin-right: 8px;
}
.additinal-filters-container {
.action-btn {
background: var(--primary-background);
width: 16px;
height: 16px;
border-radius: 3px;
display: flex;
justify-content: center;
align-items: center;
}
}

View File

@@ -0,0 +1,66 @@
import { Fragment, memo, ReactNode, useState } from 'react';
import { Color } from '@signozhq/design-tokens';
import { Col, Row } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { Minus, Plus } from '@signozhq/icons';
// ** Types
import { AdditionalFiltersProps } from './AdditionalFiltersToggler.interfaces';
// ** Styles
import { StyledInner, StyledLink } from './AdditionalFiltersToggler.styled';
import './AdditionalFiltersToggler.styles.scss';
export const AdditionalFiltersToggler = memo(function AdditionalFiltersToggler({
children,
listOfAdditionalFilter,
}: AdditionalFiltersProps): JSX.Element {
const [isOpenedFilters, setIsOpenedFilters] = useState<boolean>(false);
const handleToggleOpenFilters = (): void => {
setIsOpenedFilters((prevState) => !prevState);
};
const filtersTexts: ReactNode = listOfAdditionalFilter?.map((str, index) => {
const isNextLast = index + 1 === listOfAdditionalFilter.length - 1;
if (index === listOfAdditionalFilter.length - 1) {
return (
<Fragment key={str}>
{listOfAdditionalFilter?.length > 1 && 'and'}{' '}
<StyledLink>{str.toUpperCase()}</StyledLink>
</Fragment>
);
}
return (
<span key={str}>
<StyledLink>{str.toUpperCase()}</StyledLink>
{isNextLast ? ' ' : ', '}
</span>
);
});
return (
<Row className="additinal-filters-container">
<Col span={24}>
<StyledInner onClick={handleToggleOpenFilters} style={{ marginBottom: 0 }}>
{isOpenedFilters ? (
<span className="action-btn">
<Minus size={14} color={Color.BG_INK_500} />
</span>
) : (
<span className="action-btn">
<Plus size={14} color={Color.BG_INK_500} />
</span>
)}
{!isOpenedFilters && (
<Typography>Add conditions for {filtersTexts}</Typography>
)}
</StyledInner>
</Col>
{isOpenedFilters && <Col span={24}>{children}</Col>}
</Row>
);
});

View File

@@ -0,0 +1 @@
export { AdditionalFiltersToggler } from './AdditionalFiltersToggler';

View File

@@ -1,10 +1,8 @@
import { SelectProps } from 'antd';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { DataSource } from 'types/common/queryBuilder';
export type QueryLabelProps = {
onChange: (value: DataSource) => void;
/** Defaults to every signal. */
allowedDataSources?: TelemetrytypesSignalDTO[];
isListViewPanel?: boolean;
'data-testid'?: string;
} & Omit<SelectProps, 'onChange'>;

View File

@@ -1,6 +1,5 @@
import { memo } from 'react';
import { Select } from 'antd';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { DataSource } from 'types/common/queryBuilder';
import { SelectOption } from 'types/common/select';
// ** Helpers
@@ -8,24 +7,25 @@ import { transformToUpperCase } from 'utils/transformToUpperCase';
// ** Types
import { QueryLabelProps } from './DataSourceDropdown.interfaces';
import { signalsToDataSources } from './DataSourceDropdown.utils';
const ALL_SIGNALS = [
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.metrics,
TelemetrytypesSignalDTO.traces,
];
const dataSourceMap = [DataSource.LOGS, DataSource.METRICS, DataSource.TRACES];
const exploreDataSourceMap = [DataSource.LOGS, DataSource.TRACES];
export const DataSourceDropdown = memo(function DataSourceDropdown(
props: QueryLabelProps,
): JSX.Element {
const { onChange, value, style, allowedDataSources = ALL_SIGNALS } = props;
const { onChange, value, style, isListViewPanel = false } = props;
const dataSourceOptions: SelectOption<DataSource, string>[] =
signalsToDataSources(allowedDataSources).map((source) => ({
label: transformToUpperCase(source),
value: source,
}));
const dataSourceOptions: SelectOption<DataSource, string>[] = isListViewPanel
? exploreDataSourceMap.map((source) => ({
label: transformToUpperCase(source),
value: source,
}))
: dataSourceMap.map((source) => ({
label: transformToUpperCase(source),
value: source,
}));
return (
<Select

View File

@@ -1,20 +0,0 @@
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { DataSource } from 'types/common/queryBuilder';
// Partial because the signal enum also carries an empty "unset" member, which is not a
// data source a query can be built against.
const SIGNAL_TO_DATA_SOURCE: Partial<
Record<TelemetrytypesSignalDTO, DataSource>
> = {
[TelemetrytypesSignalDTO.logs]: DataSource.LOGS,
[TelemetrytypesSignalDTO.metrics]: DataSource.METRICS,
[TelemetrytypesSignalDTO.traces]: DataSource.TRACES,
};
export function signalsToDataSources(
signals: readonly TelemetrytypesSignalDTO[],
): DataSource[] {
return signals
.map((signal) => SIGNAL_TO_DATA_SOURCE[signal])
.filter((dataSource): dataSource is DataSource => Boolean(dataSource));
}

View File

@@ -1,79 +0,0 @@
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { render, screen, userEvent } from 'tests/test-utils';
import { DataSource } from 'types/common/queryBuilder';
import { DataSourceDropdown } from '../DataSourceDropdown';
const TEST_ID = 'query-data-source-selector';
async function openDropdown(): Promise<void> {
const user = userEvent.setup();
const trigger = screen.getByTestId(TEST_ID);
await user.click(trigger.querySelector('.ant-select-selector') as HTMLElement);
}
describe('DataSourceDropdown', () => {
// antd's virtual list renders only the first couple of options into jsdom, so
// each case asserts what the restriction admits and excludes, not the full list.
it('offers the signals beyond the current one when nothing restricts it', async () => {
render(
<DataSourceDropdown
data-testid={TEST_ID}
value={DataSource.METRICS}
onChange={jest.fn()}
/>,
);
await openDropdown();
await expect(
screen.findByRole('option', { name: 'Logs' }),
).resolves.toBeInTheDocument();
expect(screen.getByRole('option', { name: 'Metrics' })).toBeInTheDocument();
});
it('offers only the signals the caller can visualize', async () => {
render(
<DataSourceDropdown
data-testid={TEST_ID}
value={DataSource.METRICS}
allowedDataSources={[TelemetrytypesSignalDTO.metrics]}
onChange={jest.fn()}
/>,
);
await openDropdown();
await expect(
screen.findByRole('option', { name: 'Metrics' }),
).resolves.toBeInTheDocument();
expect(
screen.queryByRole('option', { name: 'Logs' }),
).not.toBeInTheDocument();
expect(
screen.queryByRole('option', { name: 'Traces' }),
).not.toBeInTheDocument();
});
it('drops a signal that is not a data source a query can be built against', async () => {
render(
<DataSourceDropdown
data-testid={TEST_ID}
value={DataSource.LOGS}
allowedDataSources={[
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
TelemetrytypesSignalDTO[''],
]}
onChange={jest.fn()}
/>,
);
await openDropdown();
await expect(
screen.findByRole('option', { name: 'Logs' }),
).resolves.toBeInTheDocument();
expect(screen.getByRole('option', { name: 'Traces' })).toBeInTheDocument();
expect(
screen.queryByRole('option', { name: 'Metrics' }),
).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,6 @@
import { CSSProperties } from 'react';
export type FilterLabelProps = {
label: string;
style?: CSSProperties;
};

View File

@@ -0,0 +1,16 @@
import styled from 'styled-components';
interface Props {
isDarkMode: boolean;
children?: React.ReactNode;
}
export const StyledLabel = styled.div<Props>`
padding: 0 0.6875rem;
min-height: 2rem;
min-width: 5.625rem;
display: inline-flex;
white-space: nowrap;
align-items: center;
border-radius: 0.125rem;
`;

View File

@@ -0,0 +1,26 @@
import { memo } from 'react';
import { Typography } from '@signozhq/ui/typography';
import { useIsDarkMode } from 'hooks/useDarkMode';
// ** Types
import { FilterLabelProps } from './FilterLabel.interfaces';
// ** Styles
import { StyledLabel } from './FilterLabel.styled';
export const FilterLabel = memo(function FilterLabel({
label,
}: FilterLabelProps): JSX.Element {
const isDarkMode = useIsDarkMode();
return (
<StyledLabel isDarkMode={isDarkMode}>
<Typography
style={{
color: 'var(--bg-vanilla-400)',
}}
>
{label}
</Typography>
</StyledLabel>
);
});

View File

@@ -0,0 +1 @@
export { FilterLabel } from './FilterLabel';

View File

@@ -1,3 +1,4 @@
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
import {
IBuilderFormula,
IBuilderQuery,
@@ -7,5 +8,7 @@ export type FormulaProps = {
formula: IBuilderFormula;
index: number;
query: IBuilderQuery;
filterConfigs: Partial<QueryBuilderProps['filterConfigs']>;
isAdditionalFilterEnable: boolean;
isQBV2?: boolean;
};

View File

@@ -2,6 +2,11 @@ import { ChangeEvent, useCallback, useMemo, useState } from 'react';
import { Col, Input, Row, Select } from 'antd';
import InputWithLabel from 'components/InputWithLabel/InputWithLabel';
import { LEGEND } from 'constants/global';
// ** Components
import { FilterLabel } from 'container/QueryBuilder/components';
import HavingFilter from 'container/QueryBuilder/filters/Formula/Having/HavingFilter';
import LimitFilter from 'container/QueryBuilder/filters/Formula/Limit/Limit';
import OrderByFilter from 'container/QueryBuilder/filters/Formula/OrderBy/OrderByFilter';
// ** Hooks
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
@@ -12,6 +17,7 @@ import {
import { getFormatedLegend } from 'utils/getFormatedLegend';
import { popupContainer } from 'utils/selectPopupContainer';
import { AdditionalFiltersToggler } from '../AdditionalFiltersToggler';
import QBEntityOptions from '../QBEntityOptions/QBEntityOptions';
// ** Types
import { FormulaProps } from './Formula.interfaces';
@@ -21,18 +27,22 @@ import './Formula.styles.scss';
export function Formula({
index,
formula,
filterConfigs,
query,
isAdditionalFilterEnable,
isQBV2,
}: FormulaProps): JSX.Element {
const { removeQueryBuilderEntityByIndex, handleSetFormulaData } =
useQueryBuilder();
const { handleChangeFormulaData } = useQueryOperations({
index,
query,
formula,
entityVersion: '',
});
const { listOfAdditionalFormulaFilters, handleChangeFormulaData } =
useQueryOperations({
index,
query,
filterConfigs,
formula,
entityVersion: '',
});
const [isCollapse, setIsCollapsed] = useState(false);
@@ -73,6 +83,20 @@ export function Formula({
[handleChangeFormulaData],
);
const handleChangeHavingFilter = useCallback(
(value: IBuilderFormula['having']) => {
handleChangeFormulaData('having', value);
},
[handleChangeFormulaData],
);
const handleChangeOrderByFilter = useCallback(
(value: IBuilderFormula['orderBy']) => {
handleChangeFormulaData('orderBy', value);
},
[handleChangeFormulaData],
);
const handleQBV2OrderByChange = useCallback(
(value: string) => {
const [columnName, order] = value.split(' ');
@@ -98,6 +122,54 @@ export function Formula({
[formula.orderBy],
);
const renderAdditionalFilters = useMemo(
() => (
<>
<Col span={11}>
<Row gutter={[11, 5]}>
<Col flex="5.93rem">
<FilterLabel label="Limit" />
</Col>
<Col flex="1 1 12.5rem">
<LimitFilter formula={formula} onChange={handleChangeLimit} />
</Col>
</Row>
</Col>
<Col span={11}>
<Row gutter={[11, 5]}>
<Col flex="5.93rem">
<FilterLabel label="HAVING" />
</Col>
<Col flex="1 1 12.5rem">
<HavingFilter formula={formula} onChange={handleChangeHavingFilter} />
</Col>
</Row>
</Col>
<Col span={11}>
<Row gutter={[11, 5]}>
<Col flex="5.93rem">
<FilterLabel label="Order by" />
</Col>
<Col flex="1 1 12.5rem">
<OrderByFilter
query={query}
formula={formula}
onChange={handleChangeOrderByFilter}
/>
</Col>
</Row>
</Col>
</>
),
[
formula,
handleChangeHavingFilter,
handleChangeLimit,
handleChangeOrderByFilter,
query,
],
);
return (
<Row gutter={[0, 15]}>
<QBEntityOptions
@@ -134,6 +206,17 @@ export function Formula({
addonBefore="Legend Format"
/>
</Col>
{isAdditionalFilterEnable && (
<Col span={24}>
<AdditionalFiltersToggler
listOfAdditionalFilter={listOfAdditionalFormulaFilters}
>
<Row gutter={[0, 11]} justify="space-between">
{renderAdditionalFilters}
</Row>
</AdditionalFiltersToggler>
</Col>
)}
{isQBV2 && (
<Col span={24}>
<div className="formula-qbv2-container">

View File

@@ -84,14 +84,5 @@
.options-group {
max-width: 100%;
}
.query-functions-container--disabled {
opacity: 0.45;
cursor: not-allowed;
> * {
pointer-events: none;
}
}
}
}

View File

@@ -1,6 +1,5 @@
import { useLocation } from 'react-router-dom';
import { Button, Col, Tooltip } from 'antd';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import cx from 'classnames';
import ROUTES from 'constants/routes';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
@@ -27,8 +26,6 @@ interface QBEntityOptionsProps {
query?: IBuilderQuery;
isMetricsDataSource?: boolean;
showFunctions?: boolean;
functionsDisabled?: boolean;
functionsDisabledReason?: string;
isCollapsed: boolean;
entityType: string;
entityData: any;
@@ -39,8 +36,7 @@ interface QBEntityOptionsProps {
onQueryFunctionsUpdates?: (functions: QueryFunction[]) => void;
showDeleteButton?: boolean;
showCloneOption?: boolean;
isRawQuery?: boolean;
allowedDataSources?: TelemetrytypesSignalDTO[];
isListViewPanel?: boolean;
index?: number;
showTraceOperator?: boolean;
hasTraceOperator?: boolean;
@@ -54,15 +50,12 @@ export default function QBEntityOptions({
isMetricsDataSource,
isCollapsed,
showFunctions,
functionsDisabled,
functionsDisabledReason,
entityType,
entityData,
onToggleVisibility,
onCollapseEntity,
onQueryFunctionsUpdates,
isRawQuery,
allowedDataSources,
isListViewPanel,
onDelete,
showDeleteButton,
showCloneOption,
@@ -107,7 +100,7 @@ export default function QBEntityOptions({
value="query-builder"
className="periscope-btn visibility-toggle"
onClick={onToggleVisibility}
disabled={isRawQuery && !showTraceOperator}
disabled={isListViewPanel && !showTraceOperator}
>
{entityData.disabled ? <EyeOff size={16} /> : <Eye size={16} />}
</Button>
@@ -126,7 +119,7 @@ export default function QBEntityOptions({
'periscope-btn',
entityType === 'query' ? 'query-name' : 'formula-name',
query?.dataSource === DataSource.TRACES &&
(hasTraceOperator || (showTraceOperator && isRawQuery))
(hasTraceOperator || (showTraceOperator && isListViewPanel))
? 'has-trace-operator'
: '',
isLogsExplorerPage && lastUsedQuery === index ? 'sync-btn' : '',
@@ -145,33 +138,24 @@ export default function QBEntityOptions({
}}
data-testid={`query-data-source-selector-${index}`}
value={query?.dataSource || DataSource.METRICS}
allowedDataSources={allowedDataSources}
isListViewPanel={isListViewPanel}
className="query-data-source-dropdown"
/>
</div>
)}
{showFunctions &&
!isRawQuery &&
!isListViewPanel &&
(isMetricsDataSource || isLogsDataSource) &&
query &&
onQueryFunctionsUpdates && (
<Tooltip title={functionsDisabledReason}>
<div
className={cx('query-functions-container', {
'query-functions-container--disabled': functionsDisabled,
})}
aria-disabled={functionsDisabled}
>
<QueryFunctions
query={query}
queryFunctions={query.functions || []}
key={query.functions?.toString()}
onChange={onQueryFunctionsUpdates}
maxFunctions={isLogsDataSource ? 1 : 3}
/>
</div>
</Tooltip>
<QueryFunctions
query={query}
queryFunctions={query.functions || []}
key={query.functions?.toString()}
onChange={onQueryFunctionsUpdates}
maxFunctions={isLogsDataSource ? 1 : 3}
/>
)}
</Button.Group>
</div>
@@ -184,7 +168,7 @@ export default function QBEntityOptions({
)}
</div>
{showDeleteButton && !isRawQuery && (
{showDeleteButton && !isListViewPanel && (
<Button className="periscope-btn ghost" onClick={onDelete}>
<Trash2 size={14} />
</Button>
@@ -195,14 +179,11 @@ export default function QBEntityOptions({
}
QBEntityOptions.defaultProps = {
isRawQuery: false,
allowedDataSources: undefined,
isListViewPanel: false,
query: undefined,
isMetricsDataSource: false,
onQueryFunctionsUpdates: undefined,
showFunctions: false,
functionsDisabled: false,
functionsDisabledReason: undefined,
onCloneQuery: noop,
index: 0,
onDelete: noop,

View File

@@ -1,4 +1,6 @@
export { AdditionalFiltersToggler } from './AdditionalFiltersToggler';
export { DataSourceDropdown } from './DataSourceDropdown';
export { FilterLabel } from './FilterLabel';
export { Formula } from './Formula';
export { HavingFilterTag } from './HavingFilterTag';
export { ListItemWrapper } from './ListItemWrapper';

View File

@@ -0,0 +1,198 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Select } from 'antd';
import { HAVING_OPERATORS, initialHavingValues } from 'constants/queryBuilder';
import { HavingFilterTag } from 'container/QueryBuilder/components';
import { useTagValidation } from 'hooks/queryBuilder/useTagValidation';
import {
transformFromStringToHaving,
transformHavingToStringValue,
} from 'lib/query/transformQueryBuilderData';
import { Having, HavingForm } from 'types/api/queryBuilder/queryBuilderData';
import { SelectOption } from 'types/common/select';
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
import { getHavingObject, isValidHavingValue } from '../../utils';
import { HavingFilterProps, HavingTagRenderProps } from './types';
function HavingFilter({ formula, onChange }: HavingFilterProps): JSX.Element {
const getPopupContainer = useSelectPopupContainer();
const { having } = formula;
const [searchText, setSearchText] = useState<string>('');
const [localValues, setLocalValues] = useState<string[]>([]);
const [currentFormValue, setCurrentFormValue] =
useState<HavingForm>(initialHavingValues);
const [options, setOptions] = useState<SelectOption<string, string>[]>([]);
const { isMulti } = useTagValidation(
currentFormValue.op,
currentFormValue.value,
);
const columnName = formula.expression.replace(/ /g, '').toUpperCase();
const aggregatorOptions: SelectOption<string, string>[] = useMemo(
() => [{ label: columnName, value: columnName }],
[columnName],
);
const handleUpdateTag = useCallback(
(value: string) => {
const filteredValues = localValues.filter(
(currentValue) => currentValue !== value,
);
const having: Having[] = filteredValues.map(transformFromStringToHaving);
onChange(having);
setSearchText(value);
},
[localValues, onChange],
);
const generateOptions = useCallback(
(currentString: string) => {
const [aggregator = '', op = '', ...restValue] = currentString.split(' ');
let newOptions: SelectOption<string, string>[] = [];
const isAggregatorExist = columnName
.toLowerCase()
.includes(currentString.toLowerCase());
const isAggregatorChosen = aggregator === columnName;
if (isAggregatorExist || aggregator === '') {
newOptions = aggregatorOptions;
}
if ((isAggregatorChosen && op === '') || op) {
const filteredOperators = HAVING_OPERATORS.filter((num) =>
num.toLowerCase().includes(op.toLowerCase()),
);
newOptions = filteredOperators.map((opt) => ({
label: `${columnName} ${opt} ${restValue && restValue.join(' ')}`,
value: `${columnName} ${opt} ${restValue && restValue.join(' ')}`,
}));
}
setOptions(newOptions);
},
[aggregatorOptions, columnName],
);
const parseSearchText = useCallback(
(text: string) => {
const { columnName, op, value } = getHavingObject(text);
setCurrentFormValue({ columnName, op, value });
generateOptions(text);
},
[generateOptions],
);
const tagRender = ({
label,
value,
closable,
disabled,
onClose,
}: HavingTagRenderProps): JSX.Element => {
const handleClose = (): void => {
onClose();
setSearchText('');
};
return (
<HavingFilterTag
label={label}
value={value}
closable={closable}
disabled={disabled}
onClose={handleClose}
onUpdate={handleUpdateTag}
/>
);
};
const handleSearch = (search: string): void => {
const trimmedSearch = search.replace(/\s\s+/g, ' ').trimStart();
const currentSearch = isMulti
? trimmedSearch
: trimmedSearch.split(' ').slice(0, 3).join(' ');
const isValidSearch = isValidHavingValue(currentSearch);
if (isValidSearch) {
setSearchText(currentSearch);
}
};
useEffect(() => {
setLocalValues(transformHavingToStringValue(having || []));
}, [having]);
useEffect(() => {
parseSearchText(searchText);
}, [searchText, parseSearchText]);
const resetChanges = (): void => {
setSearchText('');
setCurrentFormValue(initialHavingValues);
setOptions(aggregatorOptions);
};
const handleDeselect = (value: string): void => {
const result = localValues.filter((item) => item !== value);
const having: Having[] = result.map(transformFromStringToHaving);
onChange(having);
resetChanges();
};
const handleSelect = (currentValue: string): void => {
const { columnName, op, value } = getHavingObject(currentValue);
const isCompletedValue = value.every((item) => !!item);
const isClearSearch = isCompletedValue && columnName && op;
setSearchText(isClearSearch ? '' : currentValue);
};
const handleChange = (values: string[]): void => {
const having: Having[] = values.map(transformFromStringToHaving);
const isSelectable =
currentFormValue.value.length > 0 &&
currentFormValue.value.every((value) => !!value);
if (isSelectable) {
onChange(having);
resetChanges();
}
};
return (
<Select
getPopupContainer={getPopupContainer}
autoClearSearchValue={false}
mode="multiple"
onSearch={handleSearch}
searchValue={searchText}
data-testid="havingSelectFormula"
placeholder="Count(operation) > 5"
style={{ width: '100%' }}
tagRender={tagRender}
onDeselect={handleDeselect}
onSelect={handleSelect}
onChange={handleChange}
value={localValues}
>
{options.map((opt) => (
<Select.Option key={opt.value} value={opt.value} title="havingOption">
{opt.label}
</Select.Option>
))}
</Select>
);
}
export default HavingFilter;

View File

@@ -0,0 +1,12 @@
import { HavingFilterTagProps } from 'container/QueryBuilder/components/HavingFilterTag/HavingFilterTag.interfaces';
import {
Having,
IBuilderFormula,
} from 'types/api/queryBuilder/queryBuilderData';
export type HavingFilterProps = {
formula: IBuilderFormula;
onChange: (having: Having[]) => void;
};
export type HavingTagRenderProps = Omit<HavingFilterTagProps, 'onUpdate'>;

View File

@@ -0,0 +1,20 @@
import { InputNumber } from 'antd';
import { selectStyle } from '../../QueryBuilderSearchV2/config';
import { handleKeyDownLimitFilter } from '../../utils';
import { LimitFilterProps } from './types';
function LimitFilter({ onChange, formula }: LimitFilterProps): JSX.Element {
return (
<InputNumber
min={1}
type="number"
value={formula.limit}
style={selectStyle}
onChange={onChange}
onKeyDown={handleKeyDownLimitFilter}
/>
);
}
export default LimitFilter;

View File

@@ -0,0 +1,6 @@
import { IBuilderFormula } from 'types/api/queryBuilder/queryBuilderData';
export interface LimitFilterProps {
onChange: (values: number | null) => void;
formula: IBuilderFormula;
}

View File

@@ -0,0 +1,85 @@
import { useMemo } from 'react';
import { Select, Spin } from 'antd';
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
import { MetricAggregateOperator } from 'types/common/queryBuilder';
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
import { selectStyle } from '../../QueryBuilderSearchV2/config';
import { OrderByProps } from './types';
import { useOrderByFormulaFilter } from './useOrderByFormulaFilter';
function OrderByFilter({
formula,
onChange,
query,
}: OrderByProps): JSX.Element {
const getPopupContainer = useSelectPopupContainer();
const {
debouncedSearchText,
createOptions,
aggregationOptions,
handleChange,
handleSearchKeys,
selectedValue,
generateOptions,
} = useOrderByFormulaFilter({
query,
onChange,
formula,
});
const { data, isFetching } = useGetAggregateKeys(
{
aggregateAttribute: query.aggregateAttribute?.key || '',
dataSource: query.dataSource,
aggregateOperator: query.aggregateOperator || '',
searchText: debouncedSearchText,
},
{
enabled: !!query.aggregateAttribute?.key,
keepPreviousData: true,
},
);
const optionsData = useMemo(() => {
const keyOptions = createOptions(data?.payload?.attributeKeys || []);
const groupByOptions = createOptions(query.groupBy);
const options =
query.aggregateOperator === MetricAggregateOperator.NOOP
? keyOptions
: [...groupByOptions, ...aggregationOptions];
return generateOptions(options);
}, [
aggregationOptions,
createOptions,
data?.payload?.attributeKeys,
generateOptions,
query.aggregateOperator,
query.groupBy,
]);
const isDisabledSelect =
!query.aggregateAttribute?.key ||
query.aggregateOperator === MetricAggregateOperator.NOOP;
return (
<Select
getPopupContainer={getPopupContainer}
mode="tags"
style={selectStyle}
onSearch={handleSearchKeys}
showSearch
disabled={isDisabledSelect}
showArrow={false}
value={selectedValue}
labelInValue
filterOption={false}
options={optionsData}
notFoundContent={isFetching ? <Spin size="small" /> : null}
onChange={handleChange}
/>
);
}
export default OrderByFilter;

View File

@@ -0,0 +1,12 @@
import {
IBuilderFormula,
IBuilderQuery,
} from 'types/api/queryBuilder/queryBuilderData';
export interface OrderByProps {
formula: IBuilderFormula;
query: IBuilderQuery;
onChange: (value: IBuilderFormula['orderBy']) => void;
}
export type IOrderByFormulaFilterProps = OrderByProps;

View File

@@ -0,0 +1,129 @@
import { useMemo, useState } from 'react';
import { DEBOUNCE_DELAY } from 'constants/queryBuilderFilterConfig';
import useDebounce from 'hooks/useDebounce';
import { IOption } from 'hooks/useResourceAttribute/types';
import isEqual from 'lodash-es/isEqual';
import uniqWith from 'lodash-es/uniqWith';
import { parse } from 'papaparse';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { OrderByPayload } from 'types/api/queryBuilder/queryBuilderData';
import { ORDERBY_FILTERS } from '../../OrderByFilter/config';
import { SIGNOZ_VALUE } from '../../OrderByFilter/constants';
import { UseOrderByFilterResult } from '../../OrderByFilter/useOrderByFilter';
import {
getLabelFromValue,
mapLabelValuePairs,
orderByValueDelimiter,
} from '../../OrderByFilter/utils';
import { getRemoveOrderFromValue } from '../../QueryBuilderSearchV2/utils';
import { getUniqueOrderByValues, getValidOrderByResult } from '../../utils';
import { IOrderByFormulaFilterProps } from './types';
import { transformToOrderByStringValuesByFormula } from './utils';
export const useOrderByFormulaFilter = ({
onChange,
formula,
}: IOrderByFormulaFilterProps): UseOrderByFilterResult => {
const [searchText, setSearchText] = useState<string>('');
const debouncedSearchText = useDebounce(searchText, DEBOUNCE_DELAY);
const handleSearchKeys = (searchText: string): void =>
setSearchText(searchText);
const handleChange = (values: IOption[]): void => {
const validResult = getValidOrderByResult(values);
const result = getUniqueOrderByValues(validResult);
const orderByValues: OrderByPayload[] = result.map((item) => {
const match = parse(item.value, { delimiter: orderByValueDelimiter });
if (!match) {
return {
columnName: item.value,
order: ORDERBY_FILTERS.ASC,
};
}
const [columnName, order] = match.data.flat() as string[];
const columnNameValue =
columnName === SIGNOZ_VALUE ? SIGNOZ_VALUE : columnName;
const orderValue = order ?? ORDERBY_FILTERS.ASC;
return {
columnName: columnNameValue,
order: orderValue,
};
});
setSearchText('');
onChange(orderByValues);
};
const aggregationOptions = [
{
label: `${formula.expression} ${ORDERBY_FILTERS.ASC}`,
value: `${SIGNOZ_VALUE}${orderByValueDelimiter}${ORDERBY_FILTERS.ASC}`,
},
{
label: `${formula.expression} ${ORDERBY_FILTERS.DESC}`,
value: `${SIGNOZ_VALUE}${orderByValueDelimiter}${ORDERBY_FILTERS.DESC}`,
},
];
const selectedValue = transformToOrderByStringValuesByFormula(formula);
const createOptions = (data: BaseAutocompleteData[]): IOption[] =>
mapLabelValuePairs(data).flat();
const customValue: IOption[] = useMemo(() => {
if (!searchText) {
return [];
}
return [
{
label: `${searchText} ${ORDERBY_FILTERS.ASC}`,
value: `${searchText}${orderByValueDelimiter}${ORDERBY_FILTERS.ASC}`,
},
{
label: `${searchText} ${ORDERBY_FILTERS.DESC}`,
value: `${searchText}${orderByValueDelimiter}${ORDERBY_FILTERS.DESC}`,
},
];
}, [searchText]);
const generateOptions = (options: IOption[]): IOption[] => {
const currentCustomValue = options.find(
(keyOption) =>
getRemoveOrderFromValue(keyOption.value) === debouncedSearchText,
)
? []
: customValue;
const result = [...currentCustomValue, ...options];
const uniqResult = uniqWith(result, isEqual);
return uniqResult.filter(
(option) =>
!getLabelFromValue(selectedValue).includes(
getRemoveOrderFromValue(option.value),
),
);
};
return {
searchText,
debouncedSearchText,
selectedValue,
aggregationOptions,
createOptions,
handleChange,
handleSearchKeys,
generateOptions,
};
};

View File

@@ -0,0 +1,26 @@
import { IOption } from 'hooks/useResourceAttribute/types';
import { IBuilderFormula } from 'types/api/queryBuilder/queryBuilderData';
import { SIGNOZ_VALUE } from '../../OrderByFilter/constants';
import { orderByValueDelimiter } from '../../OrderByFilter/utils';
export const transformToOrderByStringValuesByFormula = (
formula: IBuilderFormula,
): IOption[] => {
const prepareSelectedValue: IOption[] =
formula?.orderBy?.map((item) => {
if (item.columnName === SIGNOZ_VALUE) {
return {
label: `${formula.expression} ${item.order}`,
value: `${item.columnName}${orderByValueDelimiter}${item.order}`,
};
}
return {
label: `${item.columnName} ${item.order}`,
value: `${item.columnName}${orderByValueDelimiter}${item.order}`,
};
}) || [];
return prepareSelectedValue;
};

View File

@@ -6,7 +6,7 @@ import {
export type OrderByFilterProps = {
query: IBuilderQuery;
onChange: (values: OrderByPayload[]) => void;
isRawQuery?: boolean;
isListViewPanel?: boolean;
entityVersion?: string;
isNewQueryV2?: boolean;
};

View File

@@ -12,7 +12,7 @@ import { useOrderByFilter } from './useOrderByFilter';
export function OrderByFilter({
query,
onChange,
isRawQuery = false,
isListViewPanel = false,
entityVersion,
isNewQueryV2 = false,
}: OrderByFilterProps): JSX.Element {
@@ -35,7 +35,7 @@ export function OrderByFilter({
searchText: debouncedSearchText,
},
{
enabled: !!query.aggregateAttribute?.key || isRawQuery,
enabled: !!query.aggregateAttribute?.key || isListViewPanel,
keepPreviousData: true,
},
);

View File

@@ -19,6 +19,7 @@ import {
QUERY_BUILDER_SEARCH_VALUES,
} from 'constants/queryBuilder';
import { DEBOUNCE_DELAY } from 'constants/queryBuilderFilterConfig';
import type { WhereClauseConfig } from 'container/QueryBuilder/QueryBuilder.interfaces';
import { LogsExplorerShortcuts } from 'constants/shortcuts/logsExplorerShortcuts';
import { useDynamicVariableSuggestions } from 'hooks/dashboard/useDynamicVariableSuggestions';
import { useKeyboardHotkeys } from 'hooks/hotkeys/useKeyboardHotkeys';
@@ -87,6 +88,7 @@ interface CustomTagProps {
interface QueryBuilderSearchV2Props {
query: IBuilderQuery;
onChange: (value: TagFilter) => void;
whereClauseConfig?: WhereClauseConfig;
placeholder?: string;
className?: string;
suffixIcon?: React.ReactNode;
@@ -143,6 +145,7 @@ function QueryBuilderSearchV2(
placeholder,
className,
suffixIcon,
whereClauseConfig,
hardcodedAttributeKeys,
hasPopupContainer,
rootClassName,
@@ -474,7 +477,31 @@ function QueryBuilderSearchV2(
if (searchValue) {
const operatorType =
operatorTypeMapper[currentFilterItem?.op || ''] || 'NOT_VALID';
// if key is added and operator is not present then convert to body CONTAINS key
if (
currentFilterItem?.key &&
isEmpty(currentFilterItem?.op) &&
whereClauseConfig?.customKey === 'body' &&
whereClauseConfig?.customOp === OPERATORS.CONTAINS
) {
// eslint-disable-next-line sonarjs/no-identical-functions
setTags((prev) => [
...prev,
{
key: {
key: 'body',
dataType: DataTypes.String,
type: '',
id: 'body--string----true',
},
op: OPERATORS.CONTAINS,
value: currentFilterItem?.key?.key,
},
]);
setCurrentFilterItem(undefined);
setSearchValue('');
setCurrentState(DropdownState.ATTRIBUTE_KEY);
} else if (
currentFilterItem?.op === OPERATORS.EXISTS ||
currentFilterItem?.op === OPERATORS.NOT_EXISTS
) {
@@ -516,6 +543,8 @@ function QueryBuilderSearchV2(
currentFilterItem?.op,
currentFilterItem?.value,
searchValue,
whereClauseConfig?.customKey,
whereClauseConfig?.customOp,
]);
// this useEffect takes care of tokenisation based on the search state
@@ -1056,6 +1085,7 @@ QueryBuilderSearchV2.defaultProps = {
placeholder: PLACEHOLDER,
className: '',
suffixIcon: null,
whereClauseConfig: {},
hasPopupContainer: true,
rootClassName: '',
hardcodedAttributeKeys: undefined,

View File

@@ -26,7 +26,7 @@ export type QueryProps = {
isAvailableToDisable: boolean;
query: IBuilderQuery;
queryVariant?: 'static' | 'dropdown';
isRawQuery?: boolean;
isListViewPanel?: boolean;
showFunctions?: boolean;
version: string;
showSpanScopeSelector?: boolean;
@@ -35,4 +35,4 @@ export type QueryProps = {
hasTraceOperator?: boolean;
signalSource?: string;
isMultiQueryAllowed?: boolean;
} & Pick<QueryBuilderProps, 'fieldsConfig' | 'allowedDataSources'>;
} & Pick<QueryBuilderProps, 'filterConfigs' | 'queryComponents'>;

View File

@@ -1,23 +1,55 @@
import { memo, useMemo } from 'react';
import { memo, useCallback, useMemo } from 'react';
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ExplorerOrderBy from 'container/ExplorerOrderBy';
import { OrderByFilterProps } from 'container/QueryBuilder/filters/OrderByFilter/OrderByFilter.interfaces';
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
import { DataSource } from 'types/common/queryBuilder';
function QuerySection(): JSX.Element {
const panelTypes = useGetPanelTypesQueryParam(PANEL_TYPES.LIST);
const isRawQuery = useMemo(
const filterConfigs: QueryBuilderProps['filterConfigs'] = useMemo(() => {
const isList = panelTypes === PANEL_TYPES.LIST;
const config: QueryBuilderProps['filterConfigs'] = {
stepInterval: { isHidden: false, isDisabled: false },
limit: { isHidden: isList, isDisabled: true },
having: { isHidden: isList, isDisabled: true },
};
return config;
}, [panelTypes]);
const renderOrderBy = useCallback(
({ query, onChange }: OrderByFilterProps) => (
<ExplorerOrderBy query={query} onChange={onChange} />
),
[],
);
const queryComponents = useMemo((): QueryBuilderProps['queryComponents'] => {
const shouldRenderCustomOrderBy =
panelTypes === PANEL_TYPES.LIST || panelTypes === PANEL_TYPES.TRACE;
return {
...(shouldRenderCustomOrderBy ? { renderOrderBy } : {}),
};
}, [panelTypes, renderOrderBy]);
const isListViewPanel = useMemo(
() => panelTypes === PANEL_TYPES.LIST || panelTypes === PANEL_TYPES.TRACE,
[panelTypes],
);
return (
<QueryBuilderV2
isRawQuery={isRawQuery}
isListViewPanel={isListViewPanel}
showTraceOperator
config={{ initialDataSource: DataSource.TRACES, queryVariant: 'static' }}
queryComponents={queryComponents}
panelType={panelTypes}
filterConfigs={filterConfigs}
showOnlyWhereClause={
panelTypes === PANEL_TYPES.LIST || panelTypes === PANEL_TYPES.TRACE
}

View File

@@ -29,6 +29,5 @@ export const PANEL_TYPES_VS_FULL_VIEW_TABLE: PanelTypeAndGraphManagerVisibilityP
BAR: true,
PIE: false,
HISTOGRAM: false,
HEATMAP: false,
EMPTY_WIDGET: false,
};

View File

@@ -316,7 +316,7 @@ function FullView({
<QueryBuilderV2
panelType={selectedPanelType}
version="v3"
isRawQuery={selectedPanelType === PANEL_TYPES.LIST}
isListViewPanel={selectedPanelType === PANEL_TYPES.LIST}
signalSourceChangeEnabled
// filterConfigs={filterConfigs}
// queryComponents={queryComponents}

View File

@@ -18,6 +18,4 @@ export const PanelTypeVsPanelWrapper = {
[PANEL_TYPES.PIE]: PiePanelWrapper,
[PANEL_TYPES.BAR]: BarPanel,
[PANEL_TYPES.HISTOGRAM]: HistogramPanel,
// V2-only kind; it renders through the V2 panel registry.
[PANEL_TYPES.HEATMAP]: null,
};

View File

@@ -62,14 +62,14 @@ describe('useQueryBuilderOperations - Empty Aggregate Attribute Type', () => {
legend: '',
};
const setupMockQueryBuilder = (panelType = 'time_series'): void => {
const setupMockQueryBuilder = (): void => {
(useQueryBuilder as jest.Mock).mockReturnValue({
handleSetQueryData: mockHandleSetQueryData,
handleSetFormulaData: mockHandleSetFormulaData,
removeQueryBuilderEntityByIndex: mockRemoveQueryBuilderEntityByIndex,
setLastUsedQuery: mockSetLastUsedQuery,
redirectWithQueryBuilderData: mockRedirectWithQueryBuilderData,
panelType,
panelType: 'time_series',
currentQuery: {
builder: {
queryData: [defaultMockQuery, defaultMockQuery],
@@ -332,85 +332,4 @@ describe('useQueryBuilderOperations - Empty Aggregate Attribute Type', () => {
);
});
});
describe('spaceAggregationOptions for a histogram metric', () => {
const histogramQuery: IBuilderQuery = {
...defaultMockQuery,
aggregateAttribute: {
key: 'signoz_latency',
dataType: DataTypes.Float64,
type: ATTRIBUTE_TYPES.HISTOGRAM,
} as BaseAutocompleteData,
};
it('offers the percentiles on a time series panel', () => {
const result = renderHookWithProps({ query: histogramQuery });
expect(
result.current.spaceAggregationOptions.map((o) => o.value),
).toStrictEqual([
MetricAggregateOperator.P50,
MetricAggregateOperator.P75,
MetricAggregateOperator.P90,
MetricAggregateOperator.P95,
MetricAggregateOperator.P99,
]);
});
it('offers count alone on a heatmap panel, whose Y axis is the `le` labels', () => {
setupMockQueryBuilder('heatmap');
const result = renderHookWithProps({ query: histogramQuery });
expect(
result.current.spaceAggregationOptions.map((o) => o.value),
).toStrictEqual([MetricAggregateOperator.COUNT]);
});
});
describe('picking a histogram metric', () => {
const histogramAttribute: BaseAutocompleteData = {
key: 'http.client.duration.bucket',
dataType: DataTypes.Float64,
type: ATTRIBUTE_TYPES.HISTOGRAM,
};
it('defaults the spatial aggregation to p90 on a time series panel', () => {
const result = renderHookWithProps({ entityVersion: ENTITY_VERSION_V5 });
act(() => {
result.current.handleChangeAggregatorAttribute(histogramAttribute);
});
expect(mockHandleSetQueryData).toHaveBeenLastCalledWith(
0,
expect.objectContaining({
aggregations: [
expect.objectContaining({
spaceAggregation: MetricAggregateOperator.P90,
}),
],
}),
);
});
it('defaults it to count on a heatmap panel, which offers nothing else', () => {
setupMockQueryBuilder('heatmap');
const result = renderHookWithProps({ entityVersion: ENTITY_VERSION_V5 });
act(() => {
result.current.handleChangeAggregatorAttribute(histogramAttribute);
});
expect(mockHandleSetQueryData).toHaveBeenLastCalledWith(
0,
expect.objectContaining({
aggregations: [
expect.objectContaining({
spaceAggregation: MetricAggregateOperator.COUNT,
}),
],
}),
);
});
});
});

View File

@@ -14,11 +14,12 @@ import {
initialQueryBuilderFormValuesMap,
listViewInitialLogQuery,
listViewInitialTraceQuery,
mapOfFormulaToFilters,
mapOfQueryFilters,
PANEL_TYPES,
} from 'constants/queryBuilder';
import {
metricsGaugeSpaceAggregateOperatorOptions,
metricsHeatmapHistogramSpaceAggregateOperatorOptions,
metricsHistogramSpaceAggregateOperatorOptions,
metricsSumSpaceAggregateOperatorOptions,
metricsUnknownSpaceAggregateOperatorOptions,
@@ -58,8 +59,9 @@ import { getFormatedLegend } from 'utils/getFormatedLegend';
export const useQueryOperations: UseQueryOperations = ({
query,
index,
filterConfigs,
formula,
isRawQuery = false,
isListViewPanel = false,
entityVersion,
isForTraceOperator = false,
savePreviousQuery = false,
@@ -103,7 +105,46 @@ export const useQueryOperations: UseQueryOperations = ({
}
}, [query]);
const { dataSource } = query;
const { dataSource, aggregateOperator } = query;
const getNewListOfAdditionalFilters = useCallback(
(dataSource: DataSource, isQuery: boolean): string[] => {
const additionalFiltersKeys: (keyof Pick<
IBuilderQuery,
'orderBy' | 'limit' | 'having' | 'stepInterval'
>)[] = ['having', 'limit', 'orderBy', 'stepInterval'];
const mapsOfFilters = isQuery ? mapOfQueryFilters : mapOfFormulaToFilters;
const result: string[] = mapsOfFilters[dataSource]?.reduce<string[]>(
(acc, item) => {
if (
filterConfigs &&
filterConfigs[item.field as (typeof additionalFiltersKeys)[number]]
?.isHidden
) {
return acc;
}
acc.push(item.text);
return acc;
},
[],
);
return result;
},
[filterConfigs],
);
const [listOfAdditionalFilters, setListOfAdditionalFilters] = useState<
string[]
>(getNewListOfAdditionalFilters(dataSource, true));
const [listOfAdditionalFormulaFilters, setListOfAdditionalFormulaFilters] =
useState<string[]>(getNewListOfAdditionalFilters(dataSource, false));
const handleChangeOperator = useCallback(
(value: string): void => {
@@ -177,11 +218,6 @@ export const useQueryOperations: UseQueryOperations = ({
(aggregateAttribute?.type as ATTRIBUTE_TYPES) || ATTRIBUTE_TYPES.GAUGE,
});
const histogramSpaceAggregationOptions =
panelType === PANEL_TYPES.HEATMAP
? metricsHeatmapHistogramSpaceAggregateOperatorOptions
: metricsHistogramSpaceAggregateOperatorOptions;
switch (aggregateAttribute?.type) {
case ATTRIBUTE_TYPES.SUM:
setSpaceAggregationOptions(metricsSumSpaceAggregateOperatorOptions);
@@ -191,11 +227,11 @@ export const useQueryOperations: UseQueryOperations = ({
break;
case ATTRIBUTE_TYPES.HISTOGRAM:
setSpaceAggregationOptions(histogramSpaceAggregationOptions);
setSpaceAggregationOptions(metricsHistogramSpaceAggregateOperatorOptions);
break;
case ATTRIBUTE_TYPES.EXPONENTIAL_HISTOGRAM:
setSpaceAggregationOptions(histogramSpaceAggregationOptions);
setSpaceAggregationOptions(metricsHistogramSpaceAggregateOperatorOptions);
break;
default:
setSpaceAggregationOptions(metricsUnknownSpaceAggregateOperatorOptions);
@@ -304,13 +340,7 @@ export const useQueryOperations: UseQueryOperations = ({
timeAggregation: '',
metricName: newQuery.aggregateAttribute?.key || '',
temporality: '',
// A heatmap cell holds a count of observations per `le` band, which is
// the one option the kind offers — a percentile default would sit in the
// selector with nothing behind it.
spaceAggregation:
panelType === PANEL_TYPES.HEATMAP
? MetricAggregateOperator.COUNT
: MetricAggregateOperator.P90,
spaceAggregation: MetricAggregateOperator.P90,
reduceTo: ReduceOperators.AVG,
},
];
@@ -400,7 +430,6 @@ export const useQueryOperations: UseQueryOperations = ({
index,
handleMetricAggregateAtributeTypes,
previousMetricInfo,
panelType,
],
);
@@ -431,7 +460,7 @@ export const useQueryOperations: UseQueryOperations = ({
removeKeyFromPreviousQuery(newKey);
}
if (isRawQuery) {
if (isListViewPanel) {
let listPanelQuery: Query | null = null;
if (nextSource === DataSource.LOGS) {
@@ -477,7 +506,7 @@ export const useQueryOperations: UseQueryOperations = ({
handleSetQueryData(index, newQueryData);
},
[
isRawQuery,
isListViewPanel,
panelType,
query,
handleSetQueryData,
@@ -596,18 +625,32 @@ export const useQueryOperations: UseQueryOperations = ({
handleMetricAggregateAtributeTypes,
]);
useEffect(() => {
const additionalFilters = getNewListOfAdditionalFilters(dataSource, true);
setListOfAdditionalFilters(additionalFilters);
}, [dataSource, aggregateOperator, getNewListOfAdditionalFilters]);
useEffect(() => {
const additionalFilters = getNewListOfAdditionalFilters(dataSource, false);
setListOfAdditionalFormulaFilters(additionalFilters);
}, [dataSource, aggregateOperator, getNewListOfAdditionalFilters]);
return {
isTracePanelType,
isMetricsDataSource,
isLogsDataSource,
operators,
spaceAggregationOptions,
listOfAdditionalFilters,
handleChangeOperator,
handleSpaceAggregationChange,
handleChangeAggregatorAttribute,
handleChangeDataSource,
handleDeleteQuery,
handleChangeQueryData,
listOfAdditionalFormulaFilters,
handleChangeFormulaData,
handleQueryFunctionsUpdates,
};

View File

@@ -222,31 +222,6 @@ describe('useGetYAxisUnit', () => {
expect(result.current.isError).toBe(false);
});
it('resolves the unit on the first render, without a settling pass', () => {
// The real `useGetMetrics` rebuilds its array on every render; a hook that
// stored the unit would need an extra render to settle, and would schedule one
// after every render of the panel editor.
mockUseGetMetrics.mockImplementation(() => ({
isLoading: false,
isError: false,
metrics: [MOCK_METRIC_1],
}));
let renderCount = 0;
const { result, rerender } = renderHook(() => {
renderCount += 1;
return useGetYAxisUnit();
});
expect(result.current.yAxisUnit).toBe(UniversalYAxisUnit.BYTES);
expect(renderCount).toBe(1);
rerender();
expect(result.current.yAxisUnit).toBe(UniversalYAxisUnit.BYTES);
expect(renderCount).toBe(2);
});
it('should return undefined when metrics have different units', async () => {
mockUseGetMetrics.mockReturnValueOnce({
isLoading: false,

View File

@@ -1,4 +1,4 @@
import { useMemo } from 'react';
import { useEffect, useMemo, useState } from 'react';
import {
getMetricUnits,
useGetMetrics,
@@ -46,6 +46,7 @@ function useGetYAxisUnit(
},
): UseGetYAxisUnitResult {
const { stagedQuery } = useQueryBuilder();
const [yAxisUnit, setYAxisUnit] = useState<string | undefined>();
const metricNames: string[] | null = useMemo(() => {
// If the query type is not QUERY_BUILDER, return null
@@ -94,16 +95,27 @@ function useGetYAxisUnit(
[units],
);
// Derived, not stored: `useGetMetrics` rebuilds its array on every render, so a
// state-and-effect version schedules an update after every render — the shape
// React reports as "Maximum update depth exceeded".
const yAxisUnit = useMemo(() => {
// A single shared unit is the only thing a single axis can carry; metrics that
// disagree, or that carry no unit at all, leave the axis unitless.
if (units.length === 0 || !areAllMetricUnitsSame) {
return undefined;
useEffect(() => {
// If there are no metrics, set the y-axis unit to undefined
if (units.length === 0) {
setYAxisUnit(undefined);
// If there is one metric and it has a non-empty unit, set the y-axis unit to it
} else if (units.length === 1 && units[0] !== '') {
setYAxisUnit(units[0]);
// If all metrics have the same non-empty unit, set the y-axis unit to it
} else if (areAllMetricUnitsSame) {
if (units[0] !== '') {
setYAxisUnit(units[0]);
} else {
setYAxisUnit(undefined);
}
// If there is more than one metric and they have different units, set the y-axis unit to undefined
} else if (units.length > 1 && !areAllMetricUnitsSame) {
setYAxisUnit(undefined);
// If there is one metric and it has an empty unit, set the y-axis unit to undefined
} else if (units.length === 1 && units[0] === '') {
setYAxisUnit(undefined);
}
return units[0] || undefined;
}, [units, areAllMetricUnitsSame]);
return { yAxisUnit, isLoading, isError };

View File

@@ -99,7 +99,6 @@ export type PartialPanelTypes = {
[PANEL_TYPES.VALUE]: 'value';
[PANEL_TYPES.PIE]: 'pie';
[PANEL_TYPES.HISTOGRAM]: 'histogram';
[PANEL_TYPES.HEATMAP]: 'heatmap';
};
export const panelTypeDataSourceFormValuesMap: Record<
@@ -307,74 +306,6 @@ export const panelTypeDataSourceFormValuesMap: Record<
},
},
},
// `functions` and `having` are dropped rather than carried: the heatmap request
// rejects both. Every signal is listed because the map is keyed by the query's
// own, which a switch can still be holding.
[PANEL_TYPES.HEATMAP]: {
[DataSource.LOGS]: {
builder: {
queryData: [
'aggregateAttribute',
'aggregateOperator',
'timeAggregation',
'filters',
'filter',
'spaceAggregation',
'groupBy',
'limit',
'orderBy',
'stepInterval',
'legend',
'queryName',
'disabled',
'expression',
'aggregations',
],
},
},
[DataSource.METRICS]: {
builder: {
queryData: [
'aggregateAttribute',
'aggregateOperator',
'timeAggregation',
'filters',
'filter',
'spaceAggregation',
'groupBy',
'limit',
'orderBy',
'stepInterval',
'legend',
'queryName',
'disabled',
'expression',
'aggregations',
],
},
},
[DataSource.TRACES]: {
builder: {
queryData: [
'aggregateAttribute',
'aggregateOperator',
'timeAggregation',
'filters',
'filter',
'spaceAggregation',
'groupBy',
'limit',
'orderBy',
'stepInterval',
'legend',
'queryName',
'disabled',
'expression',
'aggregations',
],
},
},
},
[PANEL_TYPES.TABLE]: {
[DataSource.LOGS]: {
builder: {

View File

@@ -1,78 +0,0 @@
.container {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 4px 12px 8px;
box-sizing: border-box;
}
.label {
flex: 0 0 auto;
font-size: 11px;
line-height: 16px;
color: var(--muted-foreground);
font-variant-numeric: tabular-nums;
}
.track {
position: relative;
flex: 1 1 auto;
height: 8px;
border-radius: 2px;
border: 1px solid var(--l2-border);
}
.marker {
position: absolute;
top: -3px;
bottom: -3px;
width: 2px;
transform: translateX(-1px);
// Reads against the panel through the 3px it overhangs the track at either end,
// which is what carries it where the ramp happens to match it.
background: var(--popover-foreground);
border-radius: 1px;
}
.caption {
flex: 0 0 auto;
font-size: 10px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--muted-foreground);
}
.keys {
display: flex;
flex: 0 0 auto;
gap: 12px;
align-items: center;
}
.key {
display: flex;
gap: 5px;
align-items: center;
font-size: 11px;
color: var(--muted-foreground);
}
.swatch,
.hatchSwatch {
width: 11px;
height: 11px;
border-radius: 2px;
border: 1px solid var(--l2-border);
box-sizing: border-box;
}
// Approximates the canvas hatch painted over null cells, which `createHatchPattern`
// strokes in the theme's own direction — light on dark, dark on light.
.hatchSwatch {
background-image: repeating-linear-gradient(
45deg,
transparent 0 2px,
var(--muted-foreground) 2px 3px
);
}

View File

@@ -1,81 +0,0 @@
import { useMemo } from 'react';
import Styles from './ColorBar.module.scss';
export interface ColorBarProps {
/** Low to high, drawn as hard-edged segments so the bar shows the same set of
* colours as the cells. */
ramp: string[];
minLabel: string;
maxLabel: string;
/** 0..1. `null` hides the marker. */
markerPosition?: number | null;
/** What the colour encodes, e.g. "count". */
label?: string;
/** Keys for the two states a ramp cannot express: a hatched data gap, and a
* genuine zero at the bottom. Without them the difference is guesswork. */
showStateKeys?: boolean;
'data-testid'?: string;
}
/** What a colour means, plus a marker for the value under the cursor. */
export default function ColorBar({
ramp,
minLabel,
maxLabel,
markerPosition = null,
label,
showStateKeys = true,
'data-testid': testId = 'color-bar',
}: ColorBarProps): JSX.Element | null {
const gradient = useMemo(() => {
if (ramp.length === 0) {
return undefined;
}
if (ramp.length === 1) {
return ramp[0];
}
const stops = ramp.flatMap((color, index) => {
const from = (index / ramp.length) * 100;
const to = ((index + 1) / ramp.length) * 100;
return [`${color} ${from}%`, `${color} ${to}%`];
});
return `linear-gradient(to right, ${stops.join(', ')})`;
}, [ramp]);
if (gradient === undefined) {
return null;
}
const clampedMarker =
markerPosition === null ? null : Math.min(Math.max(markerPosition, 0), 1);
return (
<div className={Styles.container} data-testid={testId}>
{label && <span className={Styles.caption}>{label}</span>}
<span className={Styles.label}>{minLabel}</span>
<div className={Styles.track} style={{ background: gradient }}>
{clampedMarker !== null && (
<span
className={Styles.marker}
style={{ left: `${clampedMarker * 100}%` }}
data-testid={`${testId}-marker`}
/>
)}
</div>
<span className={Styles.label}>{maxLabel}</span>
{showStateKeys && (
<div className={Styles.keys} data-testid={`${testId}-state-keys`}>
<span className={Styles.key}>
<span className={Styles.hatchSwatch} />
no data
</span>
<span className={Styles.key}>
<span className={Styles.swatch} style={{ background: ramp[0] }} />
count 0
</span>
</div>
)}
</div>
);
}

View File

@@ -1,94 +0,0 @@
import { render, screen } from '@testing-library/react';
import ColorBar from '../ColorBar';
const RAMP = ['#111111', '#555555', '#999999', '#dddddd'];
describe('ColorBar', () => {
it('renders the domain labels', () => {
render(<ColorBar ramp={RAMP} minLabel="0" maxLabel="1,204" />);
expect(screen.getByText('0')).toBeInTheDocument();
expect(screen.getByText('1,204')).toBeInTheDocument();
});
it('renders nothing without a ramp', () => {
const { container } = render(
<ColorBar ramp={[]} minLabel="0" maxLabel="0" />,
);
expect(container).toBeEmptyDOMElement();
});
it('hides the marker when nothing is hovered', () => {
render(<ColorBar ramp={RAMP} minLabel="0" maxLabel="10" />);
expect(screen.queryByTestId('color-bar-marker')).not.toBeInTheDocument();
});
it('positions the marker at the hovered value', () => {
render(
<ColorBar ramp={RAMP} minLabel="0" maxLabel="10" markerPosition={0.25} />,
);
expect(screen.getByTestId('color-bar-marker')).toHaveStyle({ left: '25%' });
});
it('clamps a marker outside the ramp to its ends', () => {
const { rerender } = render(
<ColorBar ramp={RAMP} minLabel="0" maxLabel="10" markerPosition={-2} />,
);
expect(screen.getByTestId('color-bar-marker')).toHaveStyle({ left: '0%' });
rerender(
<ColorBar ramp={RAMP} minLabel="0" maxLabel="10" markerPosition={4} />,
);
expect(screen.getByTestId('color-bar-marker')).toHaveStyle({ left: '100%' });
});
it('keys the two states a colour ramp cannot express', () => {
render(<ColorBar ramp={RAMP} minLabel="0" maxLabel="10" />);
expect(screen.getByText('no data')).toBeInTheDocument();
expect(screen.getByText('count 0')).toBeInTheDocument();
});
it('draws the count-0 key with the bottom of the ramp', () => {
render(<ColorBar ramp={RAMP} minLabel="0" maxLabel="10" />);
expect(screen.getByText('count 0').firstChild).toHaveStyle({
background: RAMP[0],
});
});
it('hides the state keys when asked', () => {
render(
<ColorBar ramp={RAMP} minLabel="0" maxLabel="10" showStateKeys={false} />,
);
expect(screen.queryByText('no data')).not.toBeInTheDocument();
});
it('captions what the colour encodes', () => {
render(<ColorBar ramp={RAMP} minLabel="0" maxLabel="10" label="count" />);
expect(screen.getByText('count')).toBeInTheDocument();
});
it('renders hard-edged segments so the bar matches the drawn cells', () => {
render(
<ColorBar
ramp={['#111111', '#dddddd']}
minLabel="0"
maxLabel="10"
data-testid="scale"
/>,
);
const track = screen.getByTestId('scale').querySelector('div');
expect(track).toHaveStyle({
background:
'linear-gradient(to right, #111111 0%, #111111 50%, #dddddd 50%, #dddddd 100%)',
});
});
});

View File

@@ -1,29 +0,0 @@
import cx from 'classnames';
import { formatCount, HeatmapBucketRow } from './heatmapTooltipContent';
import Styles from './HeatmapTooltip.module.scss';
/** The buckets either side of the hovered one, so a mode reads as a shape rather
* than a single number. */
export default function HeatmapBucketList({
rows,
}: {
rows: HeatmapBucketRow[];
}): JSX.Element {
return (
<div className={Styles.rows} data-testid="heatmap-tooltip-buckets">
{rows.map((bucket) => (
<div
key={bucket.row}
className={cx(Styles.row, { [Styles.rowHovered]: bucket.isHovered })}
data-hovered={bucket.isHovered}
data-testid="heatmap-tooltip-bucket-row"
>
<span className={Styles.rowLabel}>{bucket.label}</span>
<span className={Styles.rowValue}>{formatCount(bucket.count)}</span>
</div>
))}
</div>
);
}

View File

@@ -1,39 +0,0 @@
import {
formatCount,
formatPercent,
HeatmapContributionRow,
} from './heatmapTooltipContent';
import Styles from './HeatmapTooltip.module.scss';
/** Only shown when the cell sums more than one group. */
export default function HeatmapContributionList({
rows,
groupByLabel,
}: {
rows: HeatmapContributionRow[];
/** The `groupBy` keys these rows are by. */
groupByLabel: string;
}): JSX.Element {
return (
<div className={Styles.rows} data-testid="heatmap-tooltip-contribution">
{groupByLabel && <span className={Styles.section}>{groupByLabel}</span>}
{rows.map((row) => (
<div
key={row.label}
className={Styles.row}
data-testid="heatmap-tooltip-contribution-row"
>
<span
className={Styles.marker}
style={{ background: row.color }}
data-is-legend-marker={true}
/>
<span className={Styles.rowLabel}>{row.label}</span>
<span className={Styles.rowValue}>{formatCount(row.count)}</span>
<span className={Styles.rowPercent}>{formatPercent(row.percent)}</span>
</div>
))}
</div>
);
}

View File

@@ -1,159 +0,0 @@
@use '../../../../../../styles/scrollbar' as *;
// Surface matches the shared Tooltip exactly — same tokens, same radius, no
// shadow (the plugin's portal wrapper is transparent and paints nothing). Text
// follows the theme through the popover/muted pair; the fixed vanilla ramp reads
// as white-on-white in light mode.
//
// Padding lives on the sections rather than here, also matching the shared
// tooltip: TooltipFooter draws its own dashed top border, background and bottom
// corner radius, so it has to reach the container edges.
.container {
font-family: 'Inter';
font-size: 12px;
background: var(--l2-background);
-webkit-font-smoothing: antialiased;
color: var(--l2-foreground);
border-radius: 6px;
border: 1px solid var(--l2-border);
display: flex;
flex-direction: column;
min-width: 220px;
&.pinned {
border-color: var(--ring);
}
}
// Separates the cell identity from whichever question the second block answers.
.divider {
display: block;
width: 100%;
height: 1px;
background-color: var(--l2-border);
}
.identity {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
padding: var(--spacing-4) var(--spacing-4) var(--spacing-3);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-6);
font-size: 11px;
color: var(--muted-foreground);
font-variant-numeric: tabular-nums;
}
.filter {
display: flex;
align-items: center;
gap: 5px;
min-width: 0;
}
// Hollow ring, matching the legend's unselected marker — this names the filter the
// grid is under, it is not a colour key.
.filterMarker {
width: 9px;
height: 9px;
border-radius: 50%;
border: 2px solid currentColor;
flex-shrink: 0;
}
.filterLabel {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.title {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--spacing-8);
}
.titleBucket {
font-size: 13px;
font-weight: 600;
color: var(--popover-foreground);
}
.titleCount {
font-size: 13px;
font-weight: 600;
color: var(--popover-foreground);
font-variant-numeric: tabular-nums;
}
.rows {
display: flex;
flex-direction: column;
gap: var(--spacing-1);
padding: var(--spacing-3) var(--spacing-4);
max-height: 320px;
overflow-y: auto;
@include custom-scrollbar;
}
.section {
font-size: 10px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--muted-foreground);
padding: 0 var(--spacing-2) var(--spacing-1);
}
.row {
display: flex;
align-items: center;
gap: var(--spacing-4);
padding: var(--spacing-1) var(--spacing-2);
border-radius: 3px;
font-size: 12px;
color: var(--muted-foreground);
font-variant-numeric: tabular-nums;
}
// The hovered bucket is the one the cursor is on; lift it out of the neighbours.
.rowHovered {
background: var(--l3-background);
color: var(--popover-foreground);
font-weight: 500;
}
.rowLabel {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rowValue {
flex: 0 0 auto;
text-align: right;
}
.rowPercent {
flex: 0 0 auto;
min-width: 40px;
text-align: right;
color: var(--muted-foreground);
opacity: 0.75;
}
.marker {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}

View File

@@ -1,181 +0,0 @@
import { useMemo } from 'react';
import cx from 'classnames';
import {
resolveColumnIndex,
resolveRowIndex,
} from 'lib/uPlotV2/plugins/HeatmapPlugin/geometry';
import { useTimezone } from 'providers/Timezone';
import { HeatmapTooltipProps } from '../../../types';
import HeatmapBucketList from './HeatmapBucketList';
import HeatmapContributionList from './HeatmapContributionList';
import {
buildBucketRows,
buildContributionRows,
formatBucketLabel,
formatColumnRange,
formatCount,
formatGroupFilter,
HeatmapTooltipBody,
resolveGroupByLabel,
resolveTooltipBody,
} from './heatmapTooltipContent';
import Styles from './HeatmapTooltip.module.scss';
/**
* The cell identity is the same in every state; the second block answers whichever
* question the panel state leaves open (see `resolveTooltipBody`). Purpose-built
* rather than composed from the shared `Tooltip`, which renders a flat list of
* series values — none of these states is that shape.
*
* The cell comes from the live cursor, not a prop: uPlot's `cursor.idx` snaps to
* the nearest timestamp, so half of every column would report its neighbour.
*/
export default function HeatmapTooltip({
uPlotInstance,
yAxis,
step,
series,
visibleGroups,
groupColor,
yAxisUnit,
decimalPrecision,
timezone,
isPinned,
dismiss,
renderTooltipFooter,
}: HeatmapTooltipProps): JSX.Element | null {
const { timezone: userTimezone } = useTimezone();
const resolvedTimezone = timezone?.value ?? userTimezone.value;
// Read outside the memo: uPlot mutates the same instance on every move, so
// keying off the instance alone would freeze the cell.
const { left = -10, top = -10 } = uPlotInstance.cursor;
const cell = useMemo(() => {
if (left < 0 || top < 0) {
return null;
}
const timestamps = uPlotInstance.data[0] as ArrayLike<number>;
const column = resolveColumnIndex(
timestamps,
uPlotInstance.posToVal(left, 'x'),
step,
);
const row = resolveRowIndex(yAxis.edges, uPlotInstance.posToVal(top, 'y'));
if (column === null || row === null) {
return null;
}
return {
row,
column,
timestamp: timestamps[column],
count:
(uPlotInstance.data[row + 1] as Array<number | null> | undefined)?.[
column
] ?? null,
};
}, [left, top, uPlotInstance, yAxis, step]);
// The cell sums the enabled groups, so those are what a breakdown must cover.
const visible = useMemo(
() => series.filter((entry) => visibleGroups.includes(entry.label)),
[series, visibleGroups],
);
const body = resolveTooltipBody(visible.length);
const bucketRows = useMemo(() => {
if (!cell || body !== HeatmapTooltipBody.Buckets) {
return [];
}
return buildBucketRows({
counts: uPlotInstance.data.slice(1) as Array<
ArrayLike<number | null> | undefined
>,
yAxis,
row: cell.row,
column: cell.column,
yAxisUnit,
decimalPrecision,
});
}, [cell, body, uPlotInstance, yAxis, yAxisUnit, decimalPrecision]);
const contributionRows = useMemo(() => {
if (!cell || body !== HeatmapTooltipBody.Contribution) {
return [];
}
return buildContributionRows({
series: visible,
timestamp: cell.timestamp,
row: cell.row,
color: groupColor,
});
}, [cell, body, visible, groupColor]);
if (!cell) {
return null;
}
// A single enabled group out of several means the legend has isolated it.
const isolated =
series.length > 1 && visible.length === 1 ? visible[0] : undefined;
const filterLabel = formatGroupFilter(isolated);
return (
<div
className={cx(Styles.container, { [Styles.pinned]: isPinned })}
data-pinned={isPinned}
data-testid="heatmap-tooltip"
>
<div className={Styles.identity}>
<div className={Styles.header}>
<span data-testid="heatmap-tooltip-range">
{formatColumnRange({
start: cell.timestamp,
step,
timezone: resolvedTimezone,
})}
</span>
{filterLabel && (
<span
className={Styles.filter}
style={{ color: groupColor }}
data-testid="heatmap-tooltip-filter"
>
<span className={Styles.filterMarker} />
<span className={Styles.filterLabel}>{filterLabel}</span>
</span>
)}
</div>
<div className={Styles.title}>
<span className={Styles.titleBucket} data-testid="heatmap-tooltip-bucket">
{formatBucketLabel({
yAxis,
row: cell.row,
yAxisUnit,
decimalPrecision,
})}
</span>
<span className={Styles.titleCount} data-testid="heatmap-tooltip-count">
{formatCount(cell.count)}
</span>
</div>
</div>
<span className={Styles.divider} data-testid="heatmap-tooltip-divider" />
{body === HeatmapTooltipBody.Contribution ? (
<HeatmapContributionList
rows={contributionRows}
groupByLabel={resolveGroupByLabel(series)}
/>
) : (
<HeatmapBucketList rows={bucketRows} />
)}
{renderTooltipFooter?.({ isPinned, dismiss })}
</div>
);
}

View File

@@ -1,289 +0,0 @@
import { resolveHeatmapYAxis } from 'lib/uPlotV2/plugins/HeatmapPlugin/geometry';
import {
HeatmapAxisScale,
HeatmapSeries,
} from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
import { render, RenderResult, screen } from 'tests/test-utils';
import type uPlot from 'uplot';
import HeatmapTooltip from '../HeatmapTooltip';
const BOUNDS = [100, 500, 1000, 2500];
const Y_AXIS = resolveHeatmapYAxis(BOUNDS, HeatmapAxisScale.Log);
const TIMESTAMPS = [1_700_000_000, 1_700_000_300];
const STEP = 300;
const PLOT_SIZE = 500;
const ROW_COUNT = BOUNDS.length + 1;
/** Row 2 is the 500ms1s bucket the design mock hovers. */
const HOVERED_ROW = 2;
function seriesFor(
group: string,
countsAtHoveredRow: [number, number],
): HeatmapSeries {
return {
label: `service.name=${group}`,
labels: [{ key: 'service.name', value: group }],
points: TIMESTAMPS.map((timestamp, column) => ({
timestamp,
counts: Array.from({ length: ROW_COUNT }, (_, row) =>
row === HOVERED_ROW ? countsAtHoveredRow[column] : row * 10,
),
})),
};
}
const GROUPED: HeatmapSeries[] = [
seriesFor('checkout', [355, 300]),
seriesFor('frontend', [86, 80]),
seriesFor('cart', [14, 10]),
seriesFor('payments', [0, 0]),
];
/** Grid counts, matching what the renderer would have been handed. */
function gridData(rowTotals: number[]): uPlot.AlignedData {
return [
TIMESTAMPS,
...Array.from({ length: ROW_COUNT }, (_, row) => [
rowTotals[row] ?? row * 40,
rowTotals[row] ?? row * 40,
]),
] as unknown as uPlot.AlignedData;
}
// Totals chosen to match the mock: 2 / 92 / 455 / 269 / 10 bottom-up.
const ROW_TOTALS = [10, 269, 455, 92, 2];
function createFakePlot(): uPlot {
const xSpan = TIMESTAMPS[TIMESTAMPS.length - 1] + STEP - TIMESTAMPS[0];
const ySpan = Y_AXIS.max - Y_AXIS.min;
// Aim the cursor at the middle of the hovered row, first column.
const rowMid = (Y_AXIS.edges[HOVERED_ROW] + Y_AXIS.edges[HOVERED_ROW + 1]) / 2;
const top = PLOT_SIZE * (1 - (rowMid - Y_AXIS.min) / ySpan);
return {
data: gridData(ROW_TOTALS),
cursor: { left: PLOT_SIZE * 0.25, top },
posToVal: (pos: number, scaleKey: string): number =>
scaleKey === 'x'
? TIMESTAMPS[0] + (pos / PLOT_SIZE) * xSpan
: Y_AXIS.min + ((PLOT_SIZE - pos) / PLOT_SIZE) * ySpan,
} as unknown as uPlot;
}
function renderTooltip(
overrides: Partial<React.ComponentProps<typeof HeatmapTooltip>> = {},
): RenderResult {
return render(
<HeatmapTooltip
id="panel-1"
uPlotInstance={createFakePlot()}
dataIndexes={[]}
seriesIndex={null}
isPinned={false}
dismiss={jest.fn()}
viaSync={false}
yAxis={Y_AXIS}
step={STEP}
series={GROUPED}
visibleGroups={GROUPED.map((entry) => entry.label)}
groupColor="#fcfdbf"
yAxisUnit="ms"
{...overrides}
/>,
);
}
describe('HeatmapTooltip — cell identity', () => {
it('heads with the time span the column covers, not a single instant', () => {
renderTooltip();
expect(screen.getByTestId('heatmap-tooltip-range').textContent).toMatch(
/^\d{2}\/\d{2} \d{2}:\d{2} → \d{2}\/\d{2} \d{2}:\d{2}$/,
);
});
it('names the hovered bucket and its count', () => {
renderTooltip();
expect(screen.getByTestId('heatmap-tooltip-bucket')).toHaveTextContent(
'500 ms 1 s',
);
expect(screen.getByTestId('heatmap-tooltip-count')).toHaveTextContent('455');
});
it('marks the surface as pinned so the border picks up the ring', () => {
renderTooltip({ isPinned: true });
expect(screen.getByTestId('heatmap-tooltip')).toHaveAttribute(
'data-pinned',
'true',
);
});
it('is unpinned by default', () => {
renderTooltip();
expect(screen.getByTestId('heatmap-tooltip')).toHaveAttribute(
'data-pinned',
'false',
);
});
it('separates the cell identity from the block below it', () => {
renderTooltip();
expect(screen.getByTestId('heatmap-tooltip-divider')).toBeInTheDocument();
});
it('renders a footer when the panel supplies one', () => {
renderTooltip({
renderTooltipFooter: ({ isPinned }): JSX.Element => (
<div data-testid="footer">{isPinned ? 'pinned' : 'press P'}</div>
),
});
expect(screen.getByTestId('footer')).toHaveTextContent('press P');
});
it('tells the footer when the tooltip is pinned', () => {
renderTooltip({
isPinned: true,
renderTooltipFooter: ({ isPinned }): JSX.Element => (
<div data-testid="footer">{isPinned ? 'pinned' : 'press P'}</div>
),
});
expect(screen.getByTestId('footer')).toHaveTextContent('pinned');
});
it('renders nothing when the cursor is off the plot', () => {
const plot = createFakePlot();
(plot as { cursor: unknown }).cursor = { left: -10, top: -10 };
const { container } = renderTooltip({ uPlotInstance: plot });
expect(container).toBeEmptyDOMElement();
});
});
describe('HeatmapTooltip — grouped, nothing selected', () => {
it('breaks the cell down by group instead of showing neighbours', () => {
renderTooltip();
expect(
screen.getByTestId('heatmap-tooltip-contribution'),
).toBeInTheDocument();
expect(
screen.queryByTestId('heatmap-tooltip-buckets'),
).not.toBeInTheDocument();
});
it('heads the breakdown with the groupBy key', () => {
renderTooltip();
expect(screen.getByText('service.name')).toBeInTheDocument();
});
it('names each row by value alone and orders by contribution', () => {
renderTooltip();
const rows = screen
.getAllByTestId('heatmap-tooltip-contribution-row')
.map((row) => row.textContent);
expect(rows[0]).toContain('checkout');
expect(rows[0]).toContain('355');
expect(rows[1]).toContain('frontend');
expect(rows[2]).toContain('cart');
});
it('shows each group"s share of the cell', () => {
renderTooltip();
const rows = screen.getAllByTestId('heatmap-tooltip-contribution-row');
// 355 / 455 = 78%, 86 / 455 = 19%, 14 / 455 = 3.1%
expect(rows[0]).toHaveTextContent('78%');
expect(rows[1]).toHaveTextContent('19%');
expect(rows[2]).toHaveTextContent('3.1%');
});
it('still lists a group that contributed nothing', () => {
renderTooltip();
const rows = screen.getAllByTestId('heatmap-tooltip-contribution-row');
expect(rows).toHaveLength(GROUPED.length);
expect(rows[3]).toHaveTextContent('payments');
expect(rows[3]).toHaveTextContent('0.0%');
});
it('does not name a filter when every group is enabled', () => {
renderTooltip();
expect(
screen.queryByTestId('heatmap-tooltip-filter'),
).not.toBeInTheDocument();
});
});
describe('HeatmapTooltip — grouped, one enabled', () => {
const selected = { visibleGroups: ['service.name=checkout'] };
it('returns to neighbouring buckets, since contribution is already answered', () => {
renderTooltip(selected);
expect(screen.getByTestId('heatmap-tooltip-buckets')).toBeInTheDocument();
expect(
screen.queryByTestId('heatmap-tooltip-contribution'),
).not.toBeInTheDocument();
});
it('names the active filter', () => {
renderTooltip(selected);
expect(screen.getByTestId('heatmap-tooltip-filter')).toHaveTextContent(
'service.name = checkout',
);
});
});
describe('HeatmapTooltip — no grouping', () => {
const ungrouped = {
series: [{ label: '', points: GROUPED[0].points }],
visibleGroups: [''],
};
it('shows neighbouring buckets, highest first', () => {
renderTooltip(ungrouped);
const rows = screen
.getAllByTestId('heatmap-tooltip-bucket-row')
.map((row) => row.textContent);
// Two buckets either side of 500ms 1s, reading down the y axis.
expect(rows).toHaveLength(5);
expect(rows[0]).toContain('> 2.5 s');
expect(rows[2]).toContain('500 ms 1 s');
expect(rows[4]).toContain('≤ 100 ms');
});
it('marks the hovered bucket among its neighbours', () => {
renderTooltip(ungrouped);
const hovered = screen
.getAllByTestId('heatmap-tooltip-bucket-row')
.filter((row) => row.dataset.hovered === 'true');
expect(hovered).toHaveLength(1);
expect(hovered[0]).toHaveTextContent('500 ms 1 s');
});
it('never breaks down a single series', () => {
renderTooltip(ungrouped);
expect(
screen.queryByTestId('heatmap-tooltip-contribution'),
).not.toBeInTheDocument();
});
});

View File

@@ -1,65 +0,0 @@
import { resolveHeatmapYAxis } from 'lib/uPlotV2/plugins/HeatmapPlugin/geometry';
import { HeatmapAxisScale } from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
import { buildBucketRows, formatColumnRange } from '../heatmapTooltipContent';
const TIMEZONE = 'UTC';
/** 2026-09-02T05:30:00Z. */
const START = 1_788_327_000;
/** Two decimals round every one of these to `0.06 ms` or `0.07 ms`. */
const CLOSE_BOUNDS = [
0.05731275270029195, 0.059850205043660856, 0.0625, 0.06526711140171336,
0.0681567332915786,
];
const CLOSE_Y_AXIS = resolveHeatmapYAxis(CLOSE_BOUNDS, HeatmapAxisScale.Log);
const COUNTS = CLOSE_Y_AXIS.rows.map((_, row) => [row]);
describe('formatColumnRange', () => {
it('dates both ends of the column', () => {
expect(
formatColumnRange({ start: START, step: 9 * 3600, timezone: TIMEZONE }),
).toBe('09/02 05:30 → 09/02 14:30');
});
it('carries the date across a column that spans days', () => {
expect(
formatColumnRange({ start: START, step: 2 * 86_400, timezone: TIMEZONE }),
).toBe('09/02 05:30 → 09/04 05:30');
});
it('adds seconds for a sub-minute column, which times alone cannot separate', () => {
expect(
formatColumnRange({ start: START, step: 30, timezone: TIMEZONE }),
).toBe('09/02 05:30:00 → 09/02 05:30:30');
});
it('reads the day in the panel timezone, not UTC', () => {
// 05:30Z is the previous evening in Los Angeles, so the same column reads as
// crossing a date boundary there and not in UTC.
expect(
formatColumnRange({
start: START,
step: 9 * 3600,
timezone: 'America/Los_Angeles',
}),
).toBe('09/01 22:30 → 09/02 07:30');
});
});
describe('buildBucketRows', () => {
it('identifies a row by its place on the axis, which its label cannot', () => {
const rows = buildBucketRows({
counts: COUNTS,
yAxis: CLOSE_Y_AXIS,
row: 3,
column: 0,
yAxisUnit: 'ms',
decimalPrecision: 2,
});
expect(new Set(rows.map((row) => row.label)).size).toBeLessThan(rows.length);
expect(rows.map((row) => row.row)).toStrictEqual([5, 4, 3, 2, 1]);
expect(rows.map((row) => row.count)).toStrictEqual([5, 4, 3, 2, 1]);
});
});

View File

@@ -1,210 +0,0 @@
import { PrecisionOption } from 'components/Graph/types';
import { getToolTipValue } from 'components/Graph/yAxisConfig';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import dayjs from 'dayjs';
import timezonePlugin from 'dayjs/plugin/timezone';
import utc from 'dayjs/plugin/utc';
import { formatRowLabel } from 'lib/uPlotV2/plugins/HeatmapPlugin/geometry';
import {
HeatmapSeries,
HeatmapYAxis,
} from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
dayjs.extend(utc);
dayjs.extend(timezonePlugin);
/** Rows shown either side of the hovered one. */
const NEIGHBOUR_SPAN = 2;
/** Below this share a percentage needs a decimal to stay informative. */
const PERCENT_DECIMAL_THRESHOLD = 10;
/** Below this, the header needs seconds to distinguish columns. */
const SUB_MINUTE_STEP = 60;
export const NO_DATA_LABEL = 'no data';
/**
* Which question the second block answers. A cell summed across several groups begs
* "which group?"; a cell that is already one series begs "how does this bucket
* compare with its neighbours?".
*/
export enum HeatmapTooltipBody {
Buckets = 'buckets',
Contribution = 'contribution',
}
export interface HeatmapBucketRow {
/** The bucket's row on the y axis. Labels are not unique — two boundaries can
* round to the same text — so this is what identifies a row. */
row: number;
label: string;
count: number | null;
isHovered: boolean;
}
export interface HeatmapContributionRow {
label: string;
color: string;
count: number;
/** Share of the cell's total, 0..100. */
percent: number;
}
export function resolveTooltipBody(visibleCount: number): HeatmapTooltipBody {
// One enabled group contributes the whole cell, so there is nothing to break
// down — whether the query is ungrouped or the legend has isolated a group.
return visibleCount > 1
? HeatmapTooltipBody.Contribution
: HeatmapTooltipBody.Buckets;
}
/** A cell is an interval, so a single instant would misreport which observations
* it contains. Both ends carry the date: the x axis prints one only where the day
* turns over, so on a wide window a bare time does not say which day it is in. */
export function formatColumnRange({
start,
step,
timezone,
}: {
/** Column start, in seconds. */
start: number;
/** Column width, in seconds. */
step: number;
timezone: string;
}): string {
const time =
step < SUB_MINUTE_STEP
? DATE_TIME_FORMATS.TIME_SECONDS
: DATE_TIME_FORMATS.TIME;
const format = `${DATE_TIME_FORMATS.DATE_SHORT} ${time}`;
const from = dayjs(start * 1000).tz(timezone);
const to = dayjs((start + step) * 1000).tz(timezone);
return `${from.format(format)}${to.format(format)}`;
}
/** Formatted with the panel's unit. */
export function formatBucketLabel({
yAxis,
row,
yAxisUnit,
decimalPrecision,
}: {
yAxis: HeatmapYAxis;
row: number;
yAxisUnit?: string;
decimalPrecision?: PrecisionOption;
}): string {
const bucket = yAxis.rows[row];
if (!bucket) {
return '';
}
return formatRowLabel(bucket, (value) =>
getToolTipValue(String(value), yAxisUnit, decimalPrecision),
);
}
export function formatCount(count: number | null): string {
return count === null ? NO_DATA_LABEL : count.toLocaleString();
}
export function formatPercent(percent: number): string {
return percent >= PERCENT_DECIMAL_THRESHOLD
? `${Math.round(percent)}%`
: `${percent.toFixed(1)}%`;
}
/** Names the group the grid is currently isolated to. */
export function formatGroupFilter(series: HeatmapSeries | undefined): string {
if (!series) {
return '';
}
if (!series.labels?.length) {
return series.label;
}
return series.labels
.map((label) => `${label.key} = ${label.value}`)
.join(', ');
}
/** The `groupBy` keys the breakdown is by. */
export function resolveGroupByLabel(series: HeatmapSeries[]): string {
const keys = series[0]?.labels?.map((label) => label.key) ?? [];
return keys.join(', ');
}
function formatSeriesValue(series: HeatmapSeries): string {
if (!series.labels?.length) {
return series.label;
}
return series.labels.map((label) => label.value).join(', ');
}
/** Highest first, so the list reads in the same direction as the y axis. */
export function buildBucketRows({
counts,
yAxis,
row,
column,
yAxisUnit,
decimalPrecision,
}: {
/** Row-major, as the renderer draws them. */
counts: Array<ArrayLike<number | null> | undefined>;
yAxis: HeatmapYAxis;
row: number;
column: number;
yAxisUnit?: string;
decimalPrecision?: PrecisionOption;
}): HeatmapBucketRow[] {
const formatBucketValue = (value: number): string =>
getToolTipValue(String(value), yAxisUnit, decimalPrecision);
const rows: HeatmapBucketRow[] = [];
for (let offset = NEIGHBOUR_SPAN; offset >= -NEIGHBOUR_SPAN; offset -= 1) {
const index = row + offset;
const bucket = yAxis.rows[index];
if (!bucket) {
continue;
}
rows.push({
row: index,
label: formatRowLabel(bucket, formatBucketValue),
count: counts[index]?.[column] ?? null,
isHovered: offset === 0,
});
}
return rows;
}
/**
* Largest first. Groups that contributed nothing are still listed — that is an
* answer, and dropping the row makes the list look truncated.
*/
export function buildContributionRows({
series,
timestamp,
row,
color,
}: {
/** Only the groups the legend has enabled — they are what the cell sums. */
series: HeatmapSeries[];
/** Column start, in seconds. */
timestamp: number;
row: number;
color: string;
}): HeatmapContributionRow[] {
const counts = series.map((entry) => {
const point = entry.points.find((item) => item.timestamp === timestamp);
// Absent or null contributed nothing to the sum, which is what this breaks down.
return point?.counts[row] ?? 0;
});
const total = counts.reduce((sum, count) => sum + count, 0);
return series
.map((entry, index) => ({
label: formatSeriesValue(entry),
color,
count: counts[index],
percent: total > 0 ? (counts[index] / total) * 100 : 0,
}))
.sort((a, b) => b.count - a.count);
}

View File

@@ -5,7 +5,6 @@ import uPlot from 'uplot';
import { UPlotConfigBuilder } from '../config/UPlotConfigBuilder';
import { LegendItem } from '../config/types';
import { HeatmapSeries, HeatmapYAxis } from '../plugins/HeatmapPlugin/types';
import { SyncTooltipFilterMode } from '../plugins/TooltipPlugin/types';
/**
@@ -104,21 +103,6 @@ export interface BarTooltipProps extends BaseTooltipProps, TooltipRenderArgs {
export interface HistogramTooltipProps
extends BaseTooltipProps, TooltipRenderArgs {}
/** Not part of `TooltipProps`: it renders its own container, since none of its
* states is the flat series list the shared `Tooltip` draws. */
export interface HeatmapTooltipProps
extends BaseTooltipProps, TooltipRenderArgs {
yAxis: HeatmapYAxis;
/** Column width in seconds. */
step: number;
/** Needed to break a summed cell down by contribution. */
series: HeatmapSeries[];
/** Groups the legend has enabled; the cell sums exactly these. */
visibleGroups: string[];
/** Same colour the legend and the densest cells use. */
groupColor: string;
}
export type TooltipProps =
| TimeSeriesTooltipProps
| BarTooltipProps

View File

@@ -148,7 +148,6 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
show = true,
side = 2, // bottom by default
space,
splits,
gap = 5, // default gap is 5
} = this.props;
@@ -180,9 +179,6 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
if (values) {
axisConfig.values = values;
}
if (splits) {
axisConfig.splits = splits;
}
if (gap !== undefined) {
axisConfig.gap = gap;
}

View File

@@ -46,13 +46,6 @@ export class UPlotScaleBuilder extends ConfigBuilder<
// Special handling for time scales (X axis)
if (time) {
// An explicit range wins: the alignment below trims the tail of the window
// to whole minutes, which is right for point-based series but drops the
// final column of any chart whose marks span an interval.
if (range) {
return { [scaleKey]: { time: true, auto: false, range } };
}
let minTime = this.min ?? 0;
let maxTime = this.max ?? 0;

View File

@@ -78,8 +78,6 @@ export interface AxisProps {
};
/** Explicit tick formatter, replacing the scale's default (time / unit-formatted). */
values?: uPlot.Axis.Values;
/** Explicit axis splits, overriding the default tick calculation. */
splits?: uPlot.Axis.Splits;
/** Pixels between the ticks and their labels; also feeds the y axis width calculation. */
gap?: number;
/** Explicit axis thickness. Left unset, the y axis sizes itself to its widest label. */

View File

@@ -1,268 +0,0 @@
import {
clampColorSteps,
createHeatmapColorResolver,
DEFAULT_COLOR_STEPS,
DEFAULT_HEATMAP_COLORS,
getMaxCount,
getSmallestPositiveCount,
MAX_COLOR_STEPS,
MIN_OPACITY_ALPHA,
normalizeCount,
resolveCountDomain,
} from '../colorScale';
import { HeatmapColorMode, HeatmapColorScale } from '../types';
const SERIES_COLOR = '#4e74f8';
describe('getMaxCount', () => {
it('ignores null cells', () => {
expect(
getMaxCount([
[1, null, 9],
[null, 4],
]),
).toBe(9);
});
it('returns 0 for an empty or all-null grid', () => {
expect(getMaxCount([])).toBe(0);
expect(getMaxCount([[null, null]])).toBe(0);
});
it('ignores non-finite counts', () => {
expect(getMaxCount([[3, Number.POSITIVE_INFINITY, Number.NaN]])).toBe(3);
});
});
describe('getSmallestPositiveCount', () => {
it('ignores nulls, zeros and non-finite counts', () => {
expect(
getSmallestPositiveCount([
[0, null, 4],
[Number.NaN, 2, -3],
]),
).toBe(2);
});
it('returns null when nothing is above zero', () => {
expect(getSmallestPositiveCount([[0, null]])).toBeNull();
});
});
describe('resolveCountDomain', () => {
it('floors at 0 on auto so a zero count sits at the bottom of the scale', () => {
expect(
resolveCountDomain({ minCount: null, maxCount: null }, [[5, 20]]),
).toStrictEqual({
min: 0,
max: 20,
logFloor: 5,
});
});
it('honours explicit clamps', () => {
expect(
resolveCountDomain({ minCount: 10, maxCount: 100 }, [[5, 20]]),
).toStrictEqual({
min: 10,
max: 100,
logFloor: 5,
});
});
it('collapses a max at or below min', () => {
expect(
resolveCountDomain({ minCount: 50, maxCount: 10 }, [[5]]),
).toStrictEqual({
min: 50,
max: 50,
logFloor: 5,
});
});
it('takes the log floor from the smallest positive count, below 1 included', () => {
expect(
resolveCountDomain({ minCount: null, maxCount: null }, [[0, 0.02, 0.8]])
.logFloor,
).toBeCloseTo(0.02, 6);
});
it('keeps the log floor within MAX_LOG_DECADES of the max', () => {
expect(
resolveCountDomain({ minCount: null, maxCount: null }, [[1, 1e9]]).logFloor,
).toBe(1e3);
});
it('falls back to a floor of 1 for a grid without a positive count', () => {
expect(
resolveCountDomain({ minCount: null, maxCount: null }, [[null, 0]]).logFloor,
).toBe(1);
});
});
describe('normalizeCount', () => {
const domain = { min: 0, max: 1000, logFloor: 1 };
it('spreads low counts on a log scale where a linear one washes them out', () => {
const log = (count: number): number =>
normalizeCount({ count, domain, scale: HeatmapColorScale.Log });
expect(log(10)).toBeCloseTo(1 / 3, 5);
expect(log(20)).toBeCloseTo(Math.log10(20) / 3, 5);
expect(
normalizeCount({ count: 10, domain, scale: HeatmapColorScale.Linear }),
).toBeCloseTo(0.01, 5);
});
it('puts 0 and 1 at the bottom of a log scale', () => {
expect(
normalizeCount({ count: 0, domain, scale: HeatmapColorScale.Log }),
).toBe(0);
expect(
normalizeCount({ count: 1, domain, scale: HeatmapColorScale.Log }),
).toBe(0);
});
it('reaches the top of the scale at max on every scale', () => {
[
HeatmapColorScale.Log,
HeatmapColorScale.Sqrt,
HeatmapColorScale.Linear,
].forEach((scale) => {
expect(normalizeCount({ count: 1000, domain, scale })).toBeCloseTo(1, 6);
});
});
it('takes the square root of the linear position on a sqrt scale', () => {
expect(
normalizeCount({
count: 250,
domain: { min: 0, max: 1000, logFloor: 1 },
scale: HeatmapColorScale.Sqrt,
}),
).toBeCloseTo(0.5, 6);
});
it('clamps counts outside the domain', () => {
const scale = HeatmapColorScale.Linear;
expect(normalizeCount({ count: -5, domain, scale })).toBe(0);
expect(normalizeCount({ count: 5000, domain, scale })).toBe(1);
});
it('returns the bottom of the scale when min equals max', () => {
expect(
normalizeCount({
count: 7,
domain: { min: 7, max: 7, logFloor: 1 },
scale: HeatmapColorScale.Log,
}),
).toBe(0);
});
it('spreads a log domain that sits entirely below a count of 1', () => {
const fractional = { min: 0, max: 1, logFloor: 0.001 };
const log = (count: number): number =>
normalizeCount({
count,
domain: fractional,
scale: HeatmapColorScale.Log,
});
expect(log(0.001)).toBe(0);
expect(log(0.1)).toBeCloseTo(2 / 3, 5);
expect(log(1)).toBeCloseTo(1, 6);
});
it('reads a log scale linearly when the floor reaches the top of the domain', () => {
const domainAtFloor = { min: 0, max: 1, logFloor: 1 };
expect(
normalizeCount({
count: 1,
domain: domainAtFloor,
scale: HeatmapColorScale.Log,
}),
).toBe(1);
expect(
normalizeCount({
count: 0.5,
domain: domainAtFloor,
scale: HeatmapColorScale.Log,
}),
).toBe(0.5);
});
});
describe('clampColorSteps', () => {
it('clamps to the supported range', () => {
expect(clampColorSteps(1)).toBe(2);
expect(clampColorSteps(500)).toBe(MAX_COLOR_STEPS);
expect(clampColorSteps(32)).toBe(32);
});
it('falls back to the default for a non-finite value', () => {
expect(clampColorSteps(Number.NaN)).toBe(DEFAULT_COLOR_STEPS);
});
});
describe('createHeatmapColorResolver', () => {
const build = (
overrides: Partial<typeof DEFAULT_HEATMAP_COLORS> = {},
isDarkMode = true,
): ReturnType<typeof createHeatmapColorResolver> =>
createHeatmapColorResolver({
options: { ...DEFAULT_HEATMAP_COLORS, ...overrides },
domain: { min: 0, max: 1000, logFloor: 1 },
isDarkMode,
seriesColor: SERIES_COLOR,
});
it('leaves null cells uncoloured so they can be hatched', () => {
const resolver = build();
expect(resolver.colorFor(null)).toBeNull();
expect(resolver.positionOf(null)).toBeNull();
});
it('gives a zero count the bottom colour, not the null treatment', () => {
const resolver = build();
expect(resolver.colorFor(0)).toBe(resolver.ramp[0]);
});
it('emits one ramp entry per step', () => {
expect(build({ steps: 8 }).ramp).toHaveLength(8);
});
it('maps the max count to the top of the ramp', () => {
const resolver = build({ steps: 8 });
expect(resolver.colorFor(1000)).toBe(resolver.ramp[7]);
});
it('picks different stops per theme so low counts stay near the surface', () => {
expect(build({}, true).ramp[0]).not.toBe(build({}, false).ramp[0]);
});
it('varies alpha in opacity mode, never below the visibility floor', () => {
const resolver = build({ mode: HeatmapColorMode.Opacity, steps: 4 });
expect(resolver.ramp[0]).toBe(`rgba(78, 116, 248, ${MIN_OPACITY_ALPHA})`);
// `color` drops the alpha channel from the string once it reaches 1.
expect(resolver.ramp[3]).toBe('rgb(78, 116, 248)');
});
it('prefers an explicit opacity fill over the series colour', () => {
const resolver = build({
mode: HeatmapColorMode.Opacity,
fill: '#e5484d',
steps: 2,
});
expect(resolver.ramp[1]).toBe('rgb(229, 72, 77)');
});
it('reports the domain it applied', () => {
expect(build().domain).toStrictEqual({ min: 0, max: 1000, logFloor: 1 });
});
});

View File

@@ -1,634 +0,0 @@
import {
canUseLogAxis,
decimateAxisSplits,
formatRowLabel,
resolveColumnAlignedSplits,
resolveColumnIndex,
resolveHeatmapYAxis,
resolveRowIndex,
} from '../geometry';
import { HeatmapAxisScale } from '../types';
const BOUNDS = [128, 256, 1024, 4096];
describe('canUseLogAxis', () => {
it('accepts strictly positive bounds', () => {
expect(canUseLogAxis(BOUNDS)).toBe(true);
});
it('rejects a zero or negative bound', () => {
expect(canUseLogAxis([0, 128])).toBe(false);
expect(canUseLogAxis([-1, 128])).toBe(false);
});
it('rejects empty bounds', () => {
expect(canUseLogAxis([])).toBe(false);
});
});
describe('resolveHeatmapYAxis', () => {
it('turns N bounds into N+1 rows with underflow and overflow at the ends', () => {
const { rows } = resolveHeatmapYAxis(BOUNDS, HeatmapAxisScale.Log);
expect(rows).toHaveLength(BOUNDS.length + 1);
expect(rows[0]).toMatchObject({
upper: 128,
isUnderflow: true,
isOverflow: false,
});
expect(rows[1]).toMatchObject({ lower: 128, upper: 256 });
expect(rows[4]).toMatchObject({
lower: 4096,
isOverflow: true,
isUnderflow: false,
});
});
it('exposes one edge per row boundary, ascending', () => {
const { rows, edges } = resolveHeatmapYAxis(BOUNDS, HeatmapAxisScale.Log);
expect(edges).toHaveLength(rows.length + 1);
expect([...edges].sort((a, b) => a - b)).toStrictEqual(edges);
});
it('places bounds in log space so row heights are log-proportional', () => {
const { splits, min, max } = resolveHeatmapYAxis(
BOUNDS,
HeatmapAxisScale.Log,
);
expect(splits).toStrictEqual(BOUNDS.map((bound) => Math.log10(bound)));
// Outer edges extend by the geometric mean ratio, (4096/128)^(1/3) = 3.174…
expect(10 ** min).toBeCloseTo(128 / (4096 / 128) ** (1 / 3), 6);
expect(10 ** max).toBeCloseTo(4096 * (4096 / 128) ** (1 / 3), 6);
});
it('keeps bounds in value space on a linear axis', () => {
const { splits, min } = resolveHeatmapYAxis(
[10, 20, 30],
HeatmapAxisScale.Linear,
);
expect(splits).toStrictEqual([10, 20, 30]);
// Mean gap is 10, and the underflow edge never crosses zero.
expect(min).toBe(0);
});
it('sorts and de-duplicates bounds', () => {
const { rows, splits } = resolveHeatmapYAxis(
[256, 128, 256, Number.NaN],
HeatmapAxisScale.Log,
);
expect(splits).toStrictEqual([Math.log10(128), Math.log10(256)]);
expect(rows).toHaveLength(3);
});
it('gives a single bound an underflow and an overflow row', () => {
const { rows, edges } = resolveHeatmapYAxis([100], HeatmapAxisScale.Log);
expect(rows).toHaveLength(2);
expect(rows[0].isUnderflow).toBe(true);
expect(rows[1].isOverflow).toBe(true);
expect(edges).toHaveLength(3);
});
it('degrades to an empty axis with no bounds', () => {
expect(resolveHeatmapYAxis([], HeatmapAxisScale.Log).rows).toStrictEqual([]);
});
it('puts the overflow label on the row"s upper edge, clear of the last boundary', () => {
const { overflowSplit, edges } = resolveHeatmapYAxis(
BOUNDS,
HeatmapAxisScale.Log,
);
// A full row above the last boundary tick, so the two labels cannot collide.
expect(overflowSplit).toBe(edges[edges.length - 1]);
});
});
describe('resolveRowIndex', () => {
const { edges } = resolveHeatmapYAxis(BOUNDS, HeatmapAxisScale.Linear);
it('finds the row containing a value', () => {
expect(resolveRowIndex(edges, 200)).toBe(1);
expect(resolveRowIndex(edges, 2000)).toBe(3);
});
it('assigns a boundary to the row it opens', () => {
expect(resolveRowIndex(edges, 256)).toBe(2);
});
it('returns the last row on the top edge', () => {
expect(resolveRowIndex(edges, edges[edges.length - 1])).toBe(
edges.length - 2,
);
});
it('returns null outside the grid', () => {
expect(resolveRowIndex(edges, edges[0] - 1)).toBeNull();
expect(resolveRowIndex(edges, edges[edges.length - 1] + 1)).toBeNull();
});
it('returns null without at least one row', () => {
expect(resolveRowIndex([5], 5)).toBeNull();
});
});
describe('resolveColumnIndex', () => {
const timestamps = [100, 160, 220, 280];
const step = 60;
it('resolves by containment, not proximity', () => {
// 155 is nearer to 160, but the observations at 155 belong to column 0.
expect(resolveColumnIndex(timestamps, 155, step)).toBe(0);
expect(resolveColumnIndex(timestamps, 160, step)).toBe(1);
});
it('includes the column start and excludes its end', () => {
expect(resolveColumnIndex(timestamps, 100, step)).toBe(0);
expect(resolveColumnIndex(timestamps, 159.9, step)).toBe(0);
});
it('covers the trailing column using the step, not the next timestamp', () => {
expect(resolveColumnIndex(timestamps, 330, step)).toBe(3);
expect(resolveColumnIndex(timestamps, 340, step)).toBeNull();
});
it('returns null before the first column', () => {
expect(resolveColumnIndex(timestamps, 99, step)).toBeNull();
});
it('returns null with no columns', () => {
expect(resolveColumnIndex([], 100, step)).toBeNull();
});
it('leaves the last column open when the step is unknown', () => {
expect(resolveColumnIndex(timestamps, 10_000, 0)).toBe(3);
});
});
describe('formatRowLabel', () => {
const format = (value: number): string => `${value}ms`;
const { rows } = resolveHeatmapYAxis(BOUNDS, HeatmapAxisScale.Log);
it('labels the underflow row by its only real bound', () => {
expect(formatRowLabel(rows[0], format)).toBe('≤ 128ms');
});
it('labels the overflow row by its only real bound', () => {
expect(formatRowLabel(rows[rows.length - 1], format)).toBe('> 4096ms');
});
it('labels an interior row as a range', () => {
expect(formatRowLabel(rows[1], format)).toBe('128ms 256ms');
});
});
describe('decimateAxisSplits', () => {
const splits = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
const domain = { min: 0, max: 10 };
it('keeps every tick when they all fit', () => {
expect(
decimateAxisSplits({ ...domain, splits, plotHeight: 400, minGapPx: 18 }),
).toStrictEqual(splits);
});
it('thins to whatever fits at the available height', () => {
// 11 ticks over 100px is 10px apart; an 18px floor keeps every other one.
expect(
decimateAxisSplits({ ...domain, splits, plotHeight: 100, minGapPx: 18 }),
).toStrictEqual([0, 2, 4, 6, 8, 10]);
});
it('always keeps the topmost tick, so the overflow edge survives thinning', () => {
const thinned = decimateAxisSplits({
...domain,
splits,
plotHeight: 40,
minGapPx: 18,
});
expect(thinned[thinned.length - 1]).toBe(10);
});
it('returns ascending positions', () => {
const thinned = decimateAxisSplits({
...domain,
splits,
plotHeight: 60,
minGapPx: 18,
});
expect([...thinned].sort((a, b) => a - b)).toStrictEqual(thinned);
});
it('thins by pixel distance, not index, so uneven rows are handled', () => {
// Three boundaries bunched at the bottom of a wide linear domain: only the
// first and the far-away last are far enough apart to both get labels.
expect(
decimateAxisSplits({
splits: [1, 2, 3, 1000],
min: 0,
max: 1000,
plotHeight: 200,
minGapPx: 18,
}),
).toStrictEqual([3, 1000]);
});
it('leaves the tick set alone when it cannot measure', () => {
expect(
decimateAxisSplits({ ...domain, splits, plotHeight: 0, minGapPx: 18 }),
).toStrictEqual(splits);
expect(
decimateAxisSplits({
splits,
min: 5,
max: 5,
plotHeight: 400,
minGapPx: 18,
}),
).toStrictEqual(splits);
});
});
describe('resolveHeatmapYAxis — the scale auto picks', () => {
// The OTel SDK default explicit bucket boundaries, which start at zero.
const OTEL = [
0, 5, 10, 25, 50, 75, 100, 250, 500, 750, 1000, 2500, 5000, 7500, 10000,
];
// Clock skew in ms — a logs/traces field that straddles zero.
const SKEW = [-1000, -100, -10, -1, 0, 1, 10, 100, 1000];
const PLOT_HEIGHT = 250;
/** Row heights in axis units, which map linearly to pixels. */
function rowHeights(bounds: number[]): number[] {
const { edges } = resolveHeatmapYAxis(bounds, HeatmapAxisScale.Auto);
return edges.slice(1).map((edge, index) => edge - edges[index]);
}
/** Shortest row, in pixels, for a plot of `PLOT_HEIGHT`. */
function shortestRowPx(bounds: number[], scale: HeatmapAxisScale): number {
const { edges } = resolveHeatmapYAxis(bounds, scale);
const span = edges[edges.length - 1] - edges[0];
const heights = edges
.slice(1)
.map((edge, index) => ((edge - edges[index]) / span) * PLOT_HEIGHT);
return Math.min(...heights);
}
it('keeps a zero boundary on a log axis instead of giving up to linear', () => {
const { splits } = resolveHeatmapYAxis([0, 5, 10], HeatmapAxisScale.Auto);
// A linear fallback would leave the boundaries untransformed.
expect(splits).not.toStrictEqual([0, 5, 10]);
});
it('is what an all-positive layout does NOT get — that stays a plain log', () => {
const { splits } = resolveHeatmapYAxis(BOUNDS, HeatmapAxisScale.Auto);
expect(splits).toStrictEqual(BOUNDS.map((bound) => Math.log10(bound)));
});
it('gives every row a usable height for the OTel default boundaries', () => {
// Linear squeezes the 0100ms buckets — where the data is — under a pixel.
expect(shortestRowPx(OTEL, HeatmapAxisScale.Linear)).toBeLessThan(1);
expect(shortestRowPx(OTEL, HeatmapAxisScale.Auto)).toBeGreaterThan(4);
});
it('gives the zero-crossing row a full decade, since it cannot be compressed', () => {
const heights = rowHeights(OTEL);
const { rows } = resolveHeatmapYAxis(OTEL, HeatmapAxisScale.Auto);
const nearZero = rows.findIndex((row) => row.lower === 0 && row.upper === 5);
// One axis unit — the same space a decade gets above the threshold.
expect(heights[nearZero]).toBeCloseTo(1, 6);
});
it('places boundaries either side of zero symmetrically', () => {
const heights = rowHeights(SKEW);
expect(Math.max(...heights) - Math.min(...heights)).toBeCloseTo(0, 6);
});
it('keeps negative boundaries ascending', () => {
const { edges } = resolveHeatmapYAxis(SKEW, HeatmapAxisScale.Auto);
expect([...edges].sort((a, b) => a - b)).toStrictEqual(edges);
});
it('round-trips a boundary back to its bucket value', () => {
const { splits, toBucketValue } = resolveHeatmapYAxis(
SKEW,
HeatmapAxisScale.Auto,
);
expect(
splits.map((split) => Math.round(toBucketValue(split) * 1e6) / 1e6),
).toStrictEqual(SKEW);
});
it('derives the linear threshold from the smallest non-zero boundary', () => {
// Threshold 10 puts -10 at -1 and 0 at 0 in axis space.
const { edges, rows } = resolveHeatmapYAxis(
[-100, -10, 0, 10, 100],
HeatmapAxisScale.Auto,
);
const crossing = rows.findIndex(
(row) => row.lower === -10 && row.upper === 0,
);
expect(edges[crossing]).toBeCloseTo(-1, 6);
expect(edges[crossing + 1]).toBeCloseTo(0, 6);
});
it('leaves an all-positive layout on a plain log axis', () => {
const { splits } = resolveHeatmapYAxis(
[128, 256, 1024],
HeatmapAxisScale.Auto,
);
expect(splits).toStrictEqual([128, 256, 1024].map((b) => Math.log10(b)));
});
it('falls back to linear when every boundary is zero', () => {
const { splits } = resolveHeatmapYAxis([0], HeatmapAxisScale.Auto);
expect(splits).toStrictEqual([0]);
});
});
describe('resolveHeatmapYAxis — an explicitly chosen scale', () => {
// The low end of the OTel SDK defaults: a zero bucket, then positive bounds.
const ZERO_HEAD = [0, 5, 10, 25];
// Clock skew in ms — a field that straddles zero.
const SKEW = [-100, -10, 0, 10, 100];
/** Row heights in axis units, which map linearly to pixels. */
function rowHeights(bounds: number[], scale: HeatmapAxisScale): number[] {
const { edges } = resolveHeatmapYAxis(bounds, scale);
return edges.slice(1).map((edge, index) => edge - edges[index]);
}
it('log stays a plain log10 for a positive layout', () => {
const { splits } = resolveHeatmapYAxis(BOUNDS, HeatmapAxisScale.Log);
expect(splits).toStrictEqual(BOUNDS.map((bound) => Math.log10(bound)));
});
it('log keeps its own answer for a zero bucket rather than becoming a symlog', () => {
const { splits } = resolveHeatmapYAxis(ZERO_HEAD, HeatmapAxisScale.Log);
// One bucket below the smallest positive bound, where a symlog would put it
// a whole decade below.
const gap = (Math.log10(25) - Math.log10(5)) / 2;
expect(splits[0]).toBeCloseTo(Math.log10(5) - gap, 6);
expect(splits.slice(1)).toStrictEqual([5, 10, 25].map((b) => Math.log10(b)));
});
it('log spends a bucket on the zero-crossing row where symlog spends a decade', () => {
const gap = (Math.log10(25) - Math.log10(5)) / 2;
// Row 1 is (0, 5] — the row above the zero bucket.
expect(rowHeights(ZERO_HEAD, HeatmapAxisScale.Log)[1]).toBeCloseTo(gap, 6);
expect(rowHeights(ZERO_HEAD, HeatmapAxisScale.Symlog)[1]).toBeCloseTo(1, 6);
});
it('log leaves every edge ascending with a zero bucket in the layout', () => {
const { edges } = resolveHeatmapYAxis(ZERO_HEAD, HeatmapAxisScale.Log);
expect([...edges].sort((a, b) => a - b)).toStrictEqual(edges);
});
it('log squashes several non-positive boundaries onto one edge — what symlog is for', () => {
const { edges } = resolveHeatmapYAxis(SKEW, HeatmapAxisScale.Log);
// -100, -10 and 0 have no logarithm and share the floor.
expect(edges[1]).toBe(edges[2]);
expect(edges[2]).toBe(edges[3]);
expect([...edges].sort((a, b) => a - b)).toStrictEqual(edges);
});
it('log has nothing to compress without a positive boundary and stays linear', () => {
const { splits } = resolveHeatmapYAxis([-5, 0], HeatmapAxisScale.Log);
expect(splits).toStrictEqual([-5, 0]);
});
it('symlog is a choice for a positive layout too, and is not the log axis', () => {
const positive = [1, 10, 100];
expect(
resolveHeatmapYAxis(positive, HeatmapAxisScale.Log).splits,
).toStrictEqual([0, 1, 2]);
// Threshold 1: the boundaries sit a decade apart, one unit above the linear band.
expect(
resolveHeatmapYAxis(positive, HeatmapAxisScale.Symlog).splits,
).toStrictEqual([1, 2, 3]);
});
it('symlog places boundaries either side of zero symmetrically', () => {
const heights = rowHeights(SKEW, HeatmapAxisScale.Symlog);
expect(Math.max(...heights) - Math.min(...heights)).toBeCloseTo(0, 6);
});
it('symlog has no magnitude to scale against when every boundary is zero', () => {
const { splits } = resolveHeatmapYAxis([0], HeatmapAxisScale.Symlog);
expect(splits).toStrictEqual([0]);
});
});
describe('resolveColumnAlignedSplits', () => {
const MINUTE = 60;
const HOUR = 3600;
const DAY = 86400;
/** uPlot's `tzDate` for a fixed-offset zone: the returned date's local fields
* read as that zone's wall clock, whatever the machine's own zone is. */
const zoneAt =
(offsetSeconds: number) =>
(timestamp: number): Date => {
const browserOffset =
-new Date(timestamp * 1e3).getTimezoneOffset() * MINUTE;
return new Date((timestamp + offsetSeconds - browserOffset) * 1e3);
};
const UTC = zoneAt(0);
/** IST, whose half-hour offset is what pulls ticks off round local times. */
const IST = zoneAt(5.5 * HOUR);
/** 2024-03-11T00:00:00Z, a Monday. */
const MIDNIGHT_UTC = 1_710_115_200;
it('lands every tick on a column edge', () => {
const step = 90;
const anchor = 1_700_000_010;
const splits = resolveColumnAlignedSplits({
anchor,
step,
incr: 5 * MINUTE,
min: anchor,
max: anchor + 40 * step,
});
expect(splits.length).toBeGreaterThan(1);
splits.forEach((split) => {
expect((split - anchor) % step).toBe(0);
});
});
it('rounds the increment up to a whole number of columns', () => {
const splits = resolveColumnAlignedSplits({
anchor: 0,
step: 90,
incr: 5 * MINUTE,
min: 0,
max: HOUR,
toDate: UTC,
});
// 300s asked for, 360s is the next multiple of the 90s column.
expect(splits[1] - splits[0]).toBe(360);
});
it('covers the visible range without overshooting it', () => {
const splits = resolveColumnAlignedSplits({
anchor: 1000,
step: 100,
incr: 200,
min: 1050,
max: 1650,
toDate: UTC,
});
expect(splits[0]).toBeGreaterThanOrEqual(1050);
expect(splits[splits.length - 1]).toBeLessThanOrEqual(1650);
expect(splits[0] - 200).toBeLessThan(1050);
});
it("starts hourly ticks on the timezone's own hour, not the epoch's", () => {
const args = {
anchor: MIDNIGHT_UTC,
step: 5 * MINUTE,
incr: HOUR,
min: MIDNIGHT_UTC,
max: MIDNIGHT_UTC + 3 * HOUR,
};
// 05:30 IST is midnight UTC, so the two disagree by the half hour.
expect(resolveColumnAlignedSplits({ ...args, toDate: UTC })).toStrictEqual([
MIDNIGHT_UTC,
MIDNIGHT_UTC + HOUR,
MIDNIGHT_UTC + 2 * HOUR,
MIDNIGHT_UTC + 3 * HOUR,
]);
expect(resolveColumnAlignedSplits({ ...args, toDate: IST })).toStrictEqual([
MIDNIGHT_UTC + 0.5 * HOUR,
MIDNIGHT_UTC + 1.5 * HOUR,
MIDNIGHT_UTC + 2.5 * HOUR,
]);
});
it('gives up the round local time when no column edge carries one', () => {
// Hour-wide columns start on the UTC hour, so 00:00 IST is mid-cell and
// the nearest edge — 00:30 IST — is as close as the grid gets.
const splits = resolveColumnAlignedSplits({
anchor: MIDNIGHT_UTC,
step: HOUR,
incr: HOUR,
min: MIDNIGHT_UTC,
max: MIDNIGHT_UTC + 2 * HOUR,
toDate: IST,
});
expect(splits).toStrictEqual([
MIDNIGHT_UTC,
MIDNIGHT_UTC + HOUR,
MIDNIGHT_UTC + 2 * HOUR,
]);
});
it('puts a daily tick on the local day boundary', () => {
const splits = resolveColumnAlignedSplits({
anchor: MIDNIGHT_UTC,
step: 15 * MINUTE,
incr: DAY,
min: MIDNIGHT_UTC,
max: MIDNIGHT_UTC + 3 * DAY,
toDate: IST,
});
// 18:30 UTC the previous day is IST midnight, and 15m columns carry it.
expect(splits).toHaveLength(3);
splits.forEach((split) => {
expect(IST(split).getHours()).toBe(0);
expect(IST(split).getMinutes()).toBe(0);
});
});
it('walks month ticks as calendar dates, snapped to column edges', () => {
const splits = resolveColumnAlignedSplits({
anchor: MIDNIGHT_UTC,
step: DAY,
incr: 28 * DAY,
min: MIDNIGHT_UTC,
max: MIDNIGHT_UTC + 120 * DAY,
toDate: UTC,
});
// Month starts, not a drifting 28-day cadence that repeats a month name.
expect(
splits.map((split) => new Date(split * 1e3).toISOString()),
).toStrictEqual([
'2024-04-01T00:00:00.000Z',
'2024-05-01T00:00:00.000Z',
'2024-06-01T00:00:00.000Z',
'2024-07-01T00:00:00.000Z',
]);
});
it("falls back to uPlot's own increment without a column width", () => {
expect(
resolveColumnAlignedSplits({
anchor: MIDNIGHT_UTC,
step: 0,
incr: 5 * MINUTE,
min: MIDNIGHT_UTC,
max: MIDNIGHT_UTC + 15 * MINUTE,
toDate: UTC,
}),
).toStrictEqual([
MIDNIGHT_UTC,
MIDNIGHT_UTC + 5 * MINUTE,
MIDNIGHT_UTC + 10 * MINUTE,
MIDNIGHT_UTC + 15 * MINUTE,
]);
});
it('has nothing to place on an empty or inverted range', () => {
expect(
resolveColumnAlignedSplits({
anchor: 0,
step: MINUTE,
incr: MINUTE,
min: 10,
max: 10,
}),
).toStrictEqual([]);
expect(
resolveColumnAlignedSplits({
anchor: 0,
step: MINUTE,
incr: 0,
min: 0,
max: 100,
}),
).toStrictEqual([]);
});
});

View File

@@ -1,176 +0,0 @@
import { resolveHeatmapGrid } from '../grid';
import { HeatmapSeries } from '../types';
const BUCKETS = [10, 20];
const STEP = 60;
/** Two groups over two columns, each missing a value the other reports. */
const TWO_GROUPS: HeatmapSeries[] = [
{
label: 'cart',
points: [
{ timestamp: 60, counts: [1, 2, 3] },
{ timestamp: 120, counts: [null, 5, 6] },
],
},
{
label: 'checkout',
points: [
{ timestamp: 60, counts: [10, 20, 30] },
{ timestamp: 120, counts: [40, null, 60] },
],
},
];
function resolve(
overrides: Partial<Parameters<typeof resolveHeatmapGrid>[0]> = {},
): ReturnType<typeof resolveHeatmapGrid> {
return resolveHeatmapGrid({
buckets: BUCKETS,
step: STEP,
series: TWO_GROUPS,
...overrides,
});
}
describe('resolveHeatmapGrid', () => {
it('pivots per-timestamp count arrays into one row per bucket', () => {
const { counts } = resolve({ series: [TWO_GROUPS[0]] });
// 2 boundaries describe 3 rows; each row spans both columns.
expect(counts).toStrictEqual([
[1, null],
[2, 5],
[3, 6],
]);
});
it('carries the bounds and step through untouched', () => {
const { bounds, step } = resolve();
expect(bounds).toStrictEqual(BUCKETS);
expect(step).toBe(STEP);
});
it('sums every group for the combined view', () => {
const { counts } = resolve();
expect(counts[0]).toStrictEqual([11, 40]);
expect(counts[2]).toStrictEqual([33, 66]);
});
it('keeps one group"s count where the other has no data', () => {
const { counts } = resolve();
// cart is null at 120 in row 0 while checkout reports 40.
expect(counts[0][1]).toBe(40);
// checkout is null at 120 in row 1 while cart reports 5.
expect(counts[1][1]).toBe(5);
});
it('reports a cell as no-data only when every group is missing it', () => {
const { counts } = resolve({
buckets: [10],
series: [
{ label: 'a', points: [{ timestamp: 60, counts: [null, null] }] },
{ label: 'b', points: [{ timestamp: 60, counts: [null, null] }] },
],
});
expect(counts).toStrictEqual([[null], [null]]);
});
it('distinguishes a zero count from no data', () => {
const { counts } = resolve({
buckets: [10],
series: [{ label: 'a', points: [{ timestamp: 60, counts: [0, null] }] }],
});
expect(counts[0][0]).toBe(0);
expect(counts[1][0]).toBeNull();
});
it('sums only the groups the legend has enabled', () => {
const { counts } = resolve({ visibleGroups: ['cart'] });
expect(counts[0]).toStrictEqual([1, null]);
expect(counts[2]).toStrictEqual([3, 6]);
});
it('sums every group when the legend passes nothing', () => {
const { counts } = resolve({ visibleGroups: undefined });
expect(counts[0]).toStrictEqual([11, 40]);
});
it('ignores an enabled label that left the result', () => {
const { counts } = resolve({ visibleGroups: ['cart', 'gone'] });
expect(counts[0]).toStrictEqual([1, null]);
});
it('empties the grid when every group is excluded', () => {
const { timestamps, counts } = resolve({ visibleGroups: [] });
expect(timestamps).toStrictEqual([]);
expect(counts.every((row) => row.length === 0)).toBe(true);
});
it('unions timestamps when groups do not align', () => {
const { timestamps, counts } = resolve({
buckets: [10],
series: [
{ label: 'a', points: [{ timestamp: 60, counts: [1, 2] }] },
{ label: 'b', points: [{ timestamp: 180, counts: [3, 4] }] },
],
});
expect(timestamps).toStrictEqual([60, 180]);
expect(counts[0]).toStrictEqual([1, 3]);
});
it('sorts columns ascending regardless of response order', () => {
const { timestamps } = resolve({
buckets: [10],
series: [
{
label: 'a',
points: [
{ timestamp: 180, counts: [1, 2] },
{ timestamp: 60, counts: [3, 4] },
],
},
],
});
expect(timestamps).toStrictEqual([60, 180]);
});
it('pads rows the response left short', () => {
const { counts } = resolve({
buckets: [10, 20, 30],
series: [{ label: 'a', points: [{ timestamp: 60, counts: [1, 2] }] }],
});
expect(counts).toStrictEqual([[1], [2], [null], [null]]);
});
it('ignores counts beyond the bucket rows', () => {
const { counts } = resolve({
buckets: [10],
series: [{ label: 'a', points: [{ timestamp: 60, counts: [1, 2, 99] }] }],
});
expect(counts).toStrictEqual([[1], [2]]);
});
it('degrades to an empty grid with no buckets or no series', () => {
expect(resolve({ buckets: [] })).toStrictEqual({
bounds: [],
timestamps: [],
step: 0,
counts: [],
});
expect(resolve({ series: [] }).counts).toStrictEqual([]);
});
});

View File

@@ -1,304 +0,0 @@
import type uPlot from 'uplot';
import { DEFAULT_HEATMAP_COLORS } from '../colorScale';
import { resolveHeatmapYAxis } from '../geometry';
import { createHeatmapHooks } from '../heatmapPlugin';
import { HeatmapAxisScale, HeatmapCell } from '../types';
const BOUNDS = [100, 1000];
const Y_AXIS = resolveHeatmapYAxis(BOUNDS, HeatmapAxisScale.Linear);
const TIMESTAMPS = [1000, 1060, 1120];
const STEP = 60;
const PLOT_WIDTH = 300;
const PLOT_HEIGHT = 300;
// Three rows for two bounds, three columns; row 1 column 1 is a data gap.
const DATA = [
TIMESTAMPS,
[1, 2, 3],
[4, null, 6],
[7, 8, 9],
] as unknown as uPlot.AlignedData;
interface FakeContext {
fillRect: jest.Mock;
fills: string[];
}
interface FakePlot {
plot: uPlot;
context: FakeContext;
setSeries: jest.Mock;
over: HTMLDivElement;
}
function createFakePlot(cursor: { left: number; top: number }): FakePlot {
const over = document.createElement('div');
Object.defineProperty(over, 'clientWidth', { value: PLOT_WIDTH });
Object.defineProperty(over, 'clientHeight', { value: PLOT_HEIGHT });
const fills: string[] = [];
const fillRect = jest.fn();
const context = { fills, fillRect };
const setSeries = jest.fn();
const xSpan = TIMESTAMPS[TIMESTAMPS.length - 1] + STEP - TIMESTAMPS[0];
const ySpan = Y_AXIS.max - Y_AXIS.min;
const ctx = {
save: jest.fn(),
restore: jest.fn(),
beginPath: jest.fn(),
rect: jest.fn(),
clip: jest.fn(),
moveTo: jest.fn(),
lineTo: jest.fn(),
stroke: jest.fn(),
setLineDash: jest.fn(),
createPattern: jest.fn(() => null),
set fillStyle(value: string) {
fills.push(value);
},
fillRect: (...args: number[]): void => {
fillRect(...args);
},
};
const plot = {
data: DATA,
cursor,
over,
setSeries,
ctx,
bbox: { left: 0, top: 0, width: PLOT_WIDTH, height: PLOT_HEIGHT },
scales: { x: { min: TIMESTAMPS[0], max: TIMESTAMPS[2] + STEP } },
// x grows left to right; y is inverted, so the highest bucket is at the top.
valToPos: (value: number, scaleKey: string): number =>
scaleKey === 'x'
? ((value - TIMESTAMPS[0]) / xSpan) * PLOT_WIDTH
: PLOT_HEIGHT - ((value - Y_AXIS.min) / ySpan) * PLOT_HEIGHT,
posToVal: (pos: number, scaleKey: string): number =>
scaleKey === 'x'
? TIMESTAMPS[0] + (pos / PLOT_WIDTH) * xSpan
: Y_AXIS.min + ((PLOT_HEIGHT - pos) / PLOT_HEIGHT) * ySpan,
};
return { plot: plot as unknown as uPlot, context, setSeries, over };
}
function createHooks(
onHoverChange?: (cell: HeatmapCell | null) => void,
dimOnHover = true,
): ReturnType<typeof createHeatmapHooks> {
return createHeatmapHooks({
yAxis: Y_AXIS,
step: STEP,
colors: DEFAULT_HEATMAP_COLORS,
isDarkMode: true,
seriesColor: '#4e74f8',
dimOnHover,
onHoverChange,
});
}
describe('heatmap renderer — lifecycle', () => {
it('mounts the hover overlay into the plot overlay and tears it down', () => {
const hooks = createHooks();
const { plot, over } = createFakePlot({ left: -10, top: -10 });
hooks.init(plot);
expect(
over.querySelector('[data-testid="heatmap-hover-overlay"]'),
).not.toBeNull();
hooks.destroy(plot);
expect(
over.querySelector('[data-testid="heatmap-hover-overlay"]'),
).toBeNull();
});
});
describe('heatmap renderer — draw', () => {
it('paints every cell of every visible column', () => {
const hooks = createHooks();
const { plot, context } = createFakePlot({ left: -10, top: -10 });
hooks.init(plot);
hooks.draw(plot);
// 3 rows x 3 columns, less the one null cell that has no hatch pattern
// available under jsdom.
expect(context.fillRect).toHaveBeenCalledTimes(8);
});
it('gives a zero count the bottom-of-scale fill rather than skipping it', () => {
const hooks = createHooks();
const zeroed = [TIMESTAMPS, [0, 0, 0], [0, 0, 0], [0, 0, 0]];
const { plot, context } = createFakePlot({ left: -10, top: -10 });
(plot as { data: unknown }).data = zeroed;
hooks.init(plot);
hooks.draw(plot);
expect(context.fillRect).toHaveBeenCalledTimes(9);
expect(new Set(context.fills).size).toBe(1);
});
it('skips columns outside the current x range', () => {
const hooks = createHooks();
const { plot, context } = createFakePlot({ left: -10, top: -10 });
(plot as { scales: unknown }).scales = {
x: { min: TIMESTAMPS[0], max: TIMESTAMPS[0] + STEP },
};
hooks.init(plot);
hooks.draw(plot);
// Only the first two columns overlap the range; the third starts past its end.
// 2 columns x 3 rows, less the null cell in column 1.
expect(context.fillRect).toHaveBeenCalledTimes(5);
});
it('draws nothing without columns', () => {
const hooks = createHooks();
const { plot, context } = createFakePlot({ left: -10, top: -10 });
(plot as { data: unknown }).data = [[]];
hooks.init(plot);
hooks.draw(plot);
expect(context.fillRect).not.toHaveBeenCalled();
});
});
describe('heatmap renderer — hover', () => {
it('focuses the hovered row and reports the cell under the cursor', () => {
const onHoverChange = jest.fn();
const hooks = createHooks(onHoverChange);
// Left third of the plot is column 0; the top third is the overflow row.
const { plot, setSeries } = createFakePlot({ left: 10, top: 10 });
hooks.init(plot);
hooks.setCursor(plot);
expect(onHoverChange).toHaveBeenCalledWith({ row: 2, column: 0, count: 7 });
expect(setSeries).toHaveBeenCalledWith(3, { focus: true });
});
it('reports a data gap as a null count instead of zero', () => {
const onHoverChange = jest.fn();
const hooks = createHooks(onHoverChange);
const { plot } = createFakePlot({
left: PLOT_WIDTH / 2,
top: PLOT_HEIGHT / 2,
});
hooks.init(plot);
hooks.setCursor(plot);
expect(onHoverChange).toHaveBeenCalledWith({
row: 1,
column: 1,
count: null,
});
});
it('does not re-report the same cell', () => {
const onHoverChange = jest.fn();
const hooks = createHooks(onHoverChange);
const { plot } = createFakePlot({ left: 10, top: 10 });
hooks.init(plot);
hooks.setCursor(plot);
hooks.setCursor(plot);
expect(onHoverChange).toHaveBeenCalledTimes(1);
});
it('shows the overlay over the hovered cell and dims around it', () => {
const hooks = createHooks(undefined, true);
const { plot, over } = createFakePlot({ left: 10, top: 10 });
hooks.init(plot);
hooks.setCursor(plot);
const overlay = over.querySelector<HTMLDivElement>(
'[data-testid="heatmap-hover-overlay"]',
);
expect(overlay?.style.display).toBe('block');
// Column 0 spans the left third of a 300px plot.
expect(overlay?.lastElementChild).toHaveStyle({
left: '0px',
width: '100px',
});
});
it('clips the overlay to the plot area', () => {
const hooks = createHooks();
const { plot, over } = createFakePlot({ left: 10, top: 10 });
hooks.init(plot);
hooks.setCursor(plot);
// An end cell whose bucket or time slice is only partly in view is positioned
// past the axis; the plot area's edge is where the highlight has to stop.
const overlay = over.querySelector<HTMLDivElement>(
'[data-testid="heatmap-hover-overlay"]',
);
expect(overlay?.style.overflow).toBe('hidden');
});
it('collapses the dim rects when dimming is off', () => {
const hooks = createHooks(undefined, false);
const { plot, over } = createFakePlot({ left: 10, top: 10 });
hooks.init(plot);
hooks.setCursor(plot);
const overlay = over.querySelector<HTMLDivElement>(
'[data-testid="heatmap-hover-overlay"]',
);
expect(overlay?.firstElementChild).toHaveStyle({
width: '0px',
height: '0px',
});
});
it('releases focus and hides the overlay when the cursor leaves', () => {
const onHoverChange = jest.fn();
const hooks = createHooks(onHoverChange);
const { plot, over, setSeries } = createFakePlot({ left: 10, top: 10 });
hooks.init(plot);
hooks.setCursor(plot);
(plot as { cursor: { left: number; top: number } }).cursor = {
left: -10,
top: -10,
};
hooks.setCursor(plot);
expect(onHoverChange).toHaveBeenLastCalledWith(null);
expect(setSeries).toHaveBeenLastCalledWith(null, { focus: true });
expect(
over.querySelector<HTMLDivElement>('[data-testid="heatmap-hover-overlay"]')
?.style.display,
).toBe('none');
});
it('clears the hover when the cursor is inside the plot but past the last column', () => {
const onHoverChange = jest.fn();
const hooks = createHooks(onHoverChange);
const { plot } = createFakePlot({ left: 10, top: 10 });
hooks.init(plot);
hooks.setCursor(plot);
(plot as { data: unknown }).data = [[], [], [], []];
(plot as { cursor: { left: number; top: number } }).cursor = {
left: 10,
top: 10,
};
hooks.setCursor(plot);
expect(onHoverChange).toHaveBeenLastCalledWith(null);
});
});

View File

@@ -1,75 +0,0 @@
import { getPaletteStops } from '../palettes';
import { HeatmapColorPalette } from '../types';
const ALL_PALETTES = Object.values(HeatmapColorPalette);
/** Perceived brightness, good enough to tell a ramp's ends apart. */
function luminance(hex: string): number {
const value = parseInt(hex.slice(1), 16);
// eslint-disable-next-line no-bitwise
const [r, g, b] = [(value >> 16) & 255, (value >> 8) & 255, value & 255];
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
describe('getPaletteStops', () => {
it.each(ALL_PALETTES)('%s is a full ramp of valid colours', (palette) => {
const stops = getPaletteStops(palette, true);
expect(stops).toHaveLength(9);
stops.forEach((stop) => expect(stop).toMatch(/^#[0-9a-f]{6}$/));
});
it.each(ALL_PALETTES)(
'%s climbs from dark to bright on a dark panel',
(palette) => {
const stops = getPaletteStops(palette, true);
// Low counts must sit near the surface, whichever direction the ramp is
// stored in — otherwise empty cells become the loudest thing on screen.
expect(luminance(stops[0])).toBeLessThan(luminance(stops[stops.length - 1]));
},
);
it.each(ALL_PALETTES)(
'%s falls from pale to saturated on a light panel',
(palette) => {
const stops = getPaletteStops(palette, false);
expect(luminance(stops[0])).toBeGreaterThan(
luminance(stops[stops.length - 1]),
);
},
);
it.each(ALL_PALETTES)('%s uses the same colours in both themes', (palette) => {
// Only the polarity flips; the palette itself is theme-independent.
expect([...getPaletteStops(palette, false)].reverse()).toStrictEqual(
getPaletteStops(palette, true),
);
});
it('never mutates the stored ramp when reversing it', () => {
const first = getPaletteStops(HeatmapColorPalette.Lava, false);
const second = getPaletteStops(HeatmapColorPalette.Lava, false);
expect(first).toStrictEqual(second);
});
it('falls back to the first ramp for an unknown palette', () => {
const unknown = 'nope' as HeatmapColorPalette;
expect(getPaletteStops(unknown, true)).toStrictEqual(
getPaletteStops(HeatmapColorPalette.Ice, true),
);
});
it('offers a neutral ramp for panels that already spend colour elsewhere', () => {
const stops = getPaletteStops(HeatmapColorPalette.Graphite, true);
// Every stop is a grey: red, green and blue channels stay equal.
stops.forEach((stop) => {
expect(stop.slice(1, 3)).toBe(stop.slice(3, 5));
expect(stop.slice(3, 5)).toBe(stop.slice(5, 7));
});
});
});

View File

@@ -1,244 +0,0 @@
import { Color as DesignToken } from '@signozhq/design-tokens';
import Color from 'color';
import { getPaletteStops } from './palettes';
import {
HeatmapColorMode,
HeatmapColorOptions,
HeatmapColorScale,
HeatmapColorPalette,
} from './types';
export const MIN_COLOR_STEPS = 2;
export const MAX_COLOR_STEPS = 128;
export const DEFAULT_COLOR_STEPS = 64;
/** Without a floor, the lowest counts read as "no data". */
export const MIN_OPACITY_ALPHA = 0.1;
/** Log's bottom when the grid holds no positive count to take it from. */
const DEFAULT_LOG_FLOOR = 1;
/** Widest span a single ramp is stretched over on a log scale. */
const MAX_LOG_DECADES = 6;
/** Used when neither an explicit fill nor a series colour is available. */
export const DEFAULT_OPACITY_FILL = DesignToken.BG_ROBIN_500;
export const DEFAULT_HEATMAP_COLORS: HeatmapColorOptions = {
mode: HeatmapColorMode.Palette,
scale: HeatmapColorScale.Log,
minCount: null,
maxCount: null,
palette: HeatmapColorPalette.Lava,
steps: DEFAULT_COLOR_STEPS,
fill: '',
};
export interface CountDomain {
min: number;
max: number;
/** Bottom decade of the log scale: the smallest count it separates from zero.
* Always positive, since log has no bottom otherwise. */
logFloor: number;
}
/** Highest count, ignoring `null`. 0 for an empty grid. */
export function getMaxCount(counts: Array<Array<number | null>>): number {
let max = 0;
for (const row of counts) {
for (const count of row) {
if (count !== null && Number.isFinite(count) && count > max) {
max = count;
}
}
}
return max;
}
/** Smallest count above zero, ignoring `null`. `null` for a grid without one. */
export function getSmallestPositiveCount(
counts: Array<Array<number | null>>,
): number | null {
let smallest: number | null = null;
for (const row of counts) {
for (const count of row) {
if (
count !== null &&
Number.isFinite(count) &&
count > 0 &&
(smallest === null || count < smallest)
) {
smallest = count;
}
}
}
return smallest;
}
/**
* The grid's own resolution: whole counts floor at 1, while a heatmap of rates or
* ratios can live entirely below it, where a fixed floor of 1 would flatten every
* cell onto the bottom colour. Capped at `MAX_LOG_DECADES` so one stray tiny cell
* cannot stretch the ramp over a range nothing else occupies.
*/
function resolveLogFloor(
counts: Array<Array<number | null>>,
max: number,
): number {
const smallest = getSmallestPositiveCount(counts) ?? DEFAULT_LOG_FLOOR;
return Math.max(smallest, max / 10 ** MAX_LOG_DECADES);
}
/** Explicit clamps win; otherwise 0 to the grid's highest count. */
export function resolveCountDomain(
options: Pick<HeatmapColorOptions, 'minCount' | 'maxCount'>,
counts: Array<Array<number | null>>,
): CountDomain {
const min = options.minCount ?? 0;
const max = options.maxCount ?? getMaxCount(counts);
const logFloor = resolveLogFloor(counts, max);
return max > min ? { min, max, logFloor } : { min, max: min, logFloor };
}
/** Position on the colour scale, 0..1. A degenerate domain collapses to 0 so an
* all-zero grid renders at the bottom rather than disappearing. */
export function normalizeCount({
count,
domain,
scale,
}: {
count: number;
domain: CountDomain;
scale: HeatmapColorScale;
}): number {
const { min, max } = domain;
if (!(max > min)) {
return 0;
}
const clamped = Math.min(Math.max(count, min), max);
if (scale === HeatmapColorScale.Log) {
// Anything at or below the floor sits at the bottom; log cannot place it.
const bottom = Math.max(min, domain.logFloor);
const logMin = Math.log10(bottom);
const logMax = Math.log10(max);
if (logMax > logMin) {
return (Math.log10(Math.max(clamped, bottom)) - logMin) / (logMax - logMin);
}
// The floor already reaches the top of the domain, so there is no span to
// spread logarithmically. Fall through rather than flatten the whole grid.
}
const linear = (clamped - min) / (max - min);
return scale === HeatmapColorScale.Sqrt ? Math.sqrt(linear) : linear;
}
export function clampColorSteps(steps: number): number {
if (!Number.isFinite(steps)) {
return DEFAULT_COLOR_STEPS;
}
return Math.min(Math.max(Math.round(steps), MIN_COLOR_STEPS), MAX_COLOR_STEPS);
}
/** Colour at `t` (0..1) along a multi-stop ramp. */
function sampleStops(stops: string[], t: number): string {
if (stops.length === 0) {
return 'transparent';
}
if (stops.length === 1) {
return stops[0];
}
const scaled = Math.min(Math.max(t, 0), 1) * (stops.length - 1);
const lower = Math.min(Math.floor(scaled), stops.length - 2);
return Color(stops[lower])
.mix(Color(stops[lower + 1]), scaled - lower)
.hex();
}
/**
* Colour the densest cells are drawn with — the palette's extreme, or the opacity
* fill at full strength. Depends only on the options, not on the data, so callers
* can read it before a grid exists.
*/
export function resolveExtremeColor({
options,
isDarkMode,
seriesColor,
}: {
options: HeatmapColorOptions;
isDarkMode: boolean;
seriesColor: string;
}): string {
if (options.mode === HeatmapColorMode.Opacity) {
return options.fill || seriesColor || DEFAULT_OPACITY_FILL;
}
const stops = getPaletteStops(options.palette, isDarkMode);
return stops[stops.length - 1] ?? DEFAULT_OPACITY_FILL;
}
export interface HeatmapColorResolver {
/** `null` for a `null` count, which must be hatched. */
colorFor: (count: number | null) => string | null;
/** 0..1, or `null` for a `null` count. */
positionOf: (count: number | null) => number | null;
/** Low to high. The colour bar renders exactly these. */
ramp: string[];
domain: CountDomain;
}
/** Palette mode walks a sequential ramp; opacity mode varies the alpha of one
* fill, so the grid matches its group's legend swatch. */
export function createHeatmapColorResolver({
options,
domain,
isDarkMode,
seriesColor,
}: {
options: HeatmapColorOptions;
domain: CountDomain;
isDarkMode: boolean;
/** Opacity-mode fill when `options.fill` is empty. */
seriesColor: string;
}): HeatmapColorResolver {
const steps = clampColorSteps(options.steps);
const positions = Array.from({ length: steps }, (_, index) =>
steps === 1 ? 0 : index / (steps - 1),
);
let ramp: string[];
if (options.mode === HeatmapColorMode.Opacity) {
const base = Color(options.fill || seriesColor || DEFAULT_OPACITY_FILL);
ramp = positions.map((t) =>
base
.alpha(MIN_OPACITY_ALPHA + t * (1 - MIN_OPACITY_ALPHA))
.rgb()
.string(),
);
} else {
const stops = getPaletteStops(options.palette, isDarkMode);
ramp = positions.map((t) => sampleStops(stops, t));
}
const positionOf = (count: number | null): number | null => {
if (count === null || !Number.isFinite(count)) {
return null;
}
return normalizeCount({ count, domain, scale: options.scale });
};
return {
positionOf,
colorFor: (count): string | null => {
const t = positionOf(count);
if (t === null) {
return null;
}
const index = Math.min(Math.floor(t * steps), steps - 1);
return ramp[index];
},
ramp,
domain,
};
}

View File

@@ -1,489 +0,0 @@
import { HeatmapAxisScale, HeatmapRow, HeatmapYAxis } from './types';
/** Used when the ratio cannot be inferred, i.e. a single boundary. */
const FALLBACK_LOG_RATIO = 2;
const EMPTY_Y_AXIS: HeatmapYAxis = {
rows: [],
edges: [],
splits: [],
overflowSplit: null,
toBucketValue: (axisValue: number): number => axisValue,
min: 0,
max: 1,
};
/** Ascending, finite, de-duplicated boundaries. */
function normalizeBounds(bounds: number[]): number[] {
const sorted = bounds
.filter((bound) => Number.isFinite(bound))
.sort((a, b) => a - b);
return sorted.filter(
(bound, index) => index === 0 || bound !== sorted[index - 1],
);
}
/** True when a plain log axis can place every boundary. */
export function canUseLogAxis(bounds: number[]): boolean {
return bounds.length > 0 && bounds.every((bound) => bound > 0);
}
interface AxisTransform {
toAxisValue: (value: number) => number;
toBucketValue: (axisValue: number) => number;
}
const LINEAR_TRANSFORM: AxisTransform = {
toAxisValue: (value) => value,
toBucketValue: (axisValue) => axisValue,
};
const LOG_TRANSFORM: AxisTransform = {
toAxisValue: (value) => Math.log10(value),
toBucketValue: (axisValue) => 10 ** axisValue,
};
/**
* Where "near zero" starts, taken as the smallest non-zero boundary magnitude. The
* bucket layout already declares it, so it never needs to be configured.
*/
function resolveLinearThreshold(bounds: number[]): number {
let threshold = Number.POSITIVE_INFINITY;
for (const bound of bounds) {
const magnitude = Math.abs(bound);
if (magnitude > 0 && magnitude < threshold) {
threshold = magnitude;
}
}
return Number.isFinite(threshold) ? threshold : 1;
}
/**
* Symmetric log: linear within ±threshold, logarithmic beyond, mirrored across
* zero. Bucketing an arbitrary logs/traces field can straddle zero — clock skew,
* deltas, balances — which a plain log cannot place at all, and which a linear axis
* squeezes into sub-pixel rows exactly where the interesting data sits.
*
* The gradient kink at ±threshold is invisible here: the threshold *is* a boundary,
* so it lands on a row edge, and row edges are already discrete.
*/
function createSymlogTransform(threshold: number): AxisTransform {
return {
toAxisValue: (value) =>
Math.abs(value) <= threshold
? value / threshold
: Math.sign(value) * (1 + Math.log10(Math.abs(value) / threshold)),
toBucketValue: (axisValue) =>
Math.abs(axisValue) <= 1
? axisValue * threshold
: Math.sign(axisValue) * threshold * 10 ** (Math.abs(axisValue) - 1),
};
}
/** Symmetric log about the threshold the bucket layout implies. All-zero
* boundaries have no magnitude to scale against and stay linear. */
function resolveSymlogTransform(bounds: number[]): AxisTransform {
if (!bounds.some((bound) => bound !== 0)) {
return LINEAR_TRANSFORM;
}
return createSymlogTransform(resolveLinearThreshold(bounds));
}
/** One typical bucket, in axis space — the mean ratio between adjacent positive
* boundaries, which on a geometric layout is exactly one bucket. */
function resolveLogGap(positive: number[]): number {
const axisFirst = Math.log10(positive[0]);
const axisLast = Math.log10(positive[positive.length - 1]);
const gap =
positive.length > 1
? (axisLast - axisFirst) / (positive.length - 1)
: Math.log10(FALLBACK_LOG_RATIO);
return gap > 0 ? gap : Math.log10(FALLBACK_LOG_RATIO);
}
/**
* Plain log10, with the boundaries a logarithm has no answer for — zero and
* below — pinned one bucket beneath the smallest positive one. They keep their
* own rows, ticks and labels; only their height is synthetic, and it is the
* height of a bucket rather than the decade a symmetric log would spend on them.
*
* Several of them share that one edge, which squashes them together: a layout
* that straddles zero wants `Symlog`. This is the scale for the one non-positive
* boundary an explicit-bounds histogram routinely carries — its zero bucket.
*/
function createFloorLogTransform(positive: number[]): AxisTransform {
const floor = Math.log10(positive[0]) - resolveLogGap(positive);
return {
toAxisValue: (value) => (value > 0 ? Math.log10(value) : floor),
toBucketValue: (axisValue) => 10 ** axisValue,
};
}
function resolveAxisTransform(
bounds: number[],
scale: HeatmapAxisScale,
): AxisTransform {
if (scale === HeatmapAxisScale.Linear) {
return LINEAR_TRANSFORM;
}
if (scale === HeatmapAxisScale.Symlog) {
return resolveSymlogTransform(bounds);
}
if (canUseLogAxis(bounds)) {
return LOG_TRANSFORM;
}
// A plain log is still a plain log where the boundaries allow one; `Auto`
// instead reads the layout and answers with the scale that fits it.
if (scale === HeatmapAxisScale.Log) {
const positive = bounds.filter((bound) => bound > 0);
return positive.length > 0
? createFloorLogTransform(positive)
: LINEAR_TRANSFORM;
}
return resolveSymlogTransform(bounds);
}
/**
* The open-ended rows still need a height, so each gets the grid's typical bucket
* width — the mean gap in axis space, which on a geometric layout is exactly one
* bucket ratio. Linear stays in value space so it can refuse to cross zero.
*/
function resolveOuterEdges(
bounds: number[],
transform: AxisTransform,
isLinear: boolean,
): { lower: number; upper: number } {
const first = bounds[0];
const last = bounds[bounds.length - 1];
if (isLinear) {
const gap = bounds.length > 1 ? (last - first) / (bounds.length - 1) : 0;
const safeGap = gap > 0 ? gap : Math.abs(first) || 1;
// Never extend below zero unless the boundaries already do.
const lower = first > 0 ? Math.max(0, first - safeGap) : first - safeGap;
return { lower, upper: last + safeGap };
}
const axisFirst = transform.toAxisValue(first);
const axisLast = transform.toAxisValue(last);
const fallback = Math.log10(FALLBACK_LOG_RATIO);
const gap =
bounds.length > 1 ? (axisLast - axisFirst) / (bounds.length - 1) : fallback;
const safeGap = gap > 0 ? gap : fallback;
return {
lower: transform.toBucketValue(axisFirst - safeGap),
upper: transform.toBucketValue(axisLast + safeGap),
};
}
/** N boundaries produce N+1 rows: an underflow row below the first, and the
* `+Inf` overflow row above the last. */
export function resolveHeatmapYAxis(
bounds: number[],
scale: HeatmapAxisScale,
): HeatmapYAxis {
const normalized = normalizeBounds(bounds);
if (normalized.length === 0) {
return EMPTY_Y_AXIS;
}
const transform = resolveAxisTransform(normalized, scale);
const isLinear = transform === LINEAR_TRANSFORM;
const { toAxisValue, toBucketValue } = transform;
const { lower, upper } = resolveOuterEdges(normalized, transform, isLinear);
const last = normalized[normalized.length - 1];
const rows: HeatmapRow[] = [
{ lower, upper: normalized[0], isUnderflow: true, isOverflow: false },
];
for (let index = 1; index < normalized.length; index += 1) {
rows.push({
lower: normalized[index - 1],
upper: normalized[index],
isUnderflow: false,
isOverflow: false,
});
}
rows.push({ lower: last, upper, isUnderflow: false, isOverflow: true });
const edges = [
toAxisValue(lower),
...normalized.map(toAxisValue),
toAxisValue(upper),
];
return {
rows,
edges,
splits: normalized.map(toAxisValue),
overflowSplit: toAxisValue(upper),
toBucketValue,
min: edges[0],
max: edges[edges.length - 1],
};
}
/** Row containing `axisValue`, or `null` when it falls outside the grid. */
export function resolveRowIndex(
edges: number[],
axisValue: number,
): number | null {
if (edges.length < 2) {
return null;
}
if (axisValue < edges[0] || axisValue > edges[edges.length - 1]) {
return null;
}
let low = 0;
let high = edges.length - 2;
while (low <= high) {
const mid = (low + high) >> 1;
if (axisValue < edges[mid]) {
high = mid - 1;
} else if (axisValue >= edges[mid + 1]) {
low = mid + 1;
} else {
return mid;
}
}
// Exactly on the top edge.
return edges.length - 2;
}
/**
* A containment test, not a nearest-timestamp lookup: uPlot's own `cursor.idx`
* snaps to the closest boundary and would report the next column as soon as the
* cursor passed a cell's midpoint.
*/
export function resolveColumnIndex(
timestamps: ArrayLike<number>,
xValue: number,
step: number,
): number | null {
if (timestamps.length === 0) {
return null;
}
let low = 0;
let high = timestamps.length - 1;
let candidate = -1;
while (low <= high) {
const mid = (low + high) >> 1;
if (timestamps[mid] <= xValue) {
candidate = mid;
low = mid + 1;
} else {
high = mid - 1;
}
}
if (candidate < 0) {
return null;
}
const width = step > 0 ? step : Number.POSITIVE_INFINITY;
return xValue < timestamps[candidate] + width ? candidate : null;
}
/** The open-ended rows are labelled by their one real boundary; the synthetic
* edge is a drawing device, not a value. */
export function formatRowLabel(
row: HeatmapRow,
formatValue: (value: number) => string,
): string {
if (row.isOverflow) {
return `> ${formatValue(row.lower)}`;
}
if (row.isUnderflow) {
return `${formatValue(row.upper)}`;
}
return `${formatValue(row.lower)} ${formatValue(row.upper)}`;
}
/**
* Drops boundary ticks that would overlap. Filters by pixel distance rather than
* index, since linear rows are not the same height, and walks down from the top
* so the `∞` edge survives whatever else is dropped.
*/
export function decimateAxisSplits({
splits,
min,
max,
plotHeight,
minGapPx,
}: {
/** Candidates in axis space, ascending. */
splits: number[];
min: number;
max: number;
/** Plotting area height, in CSS pixels. */
plotHeight: number;
minGapPx: number;
}): number[] {
if (splits.length < 2 || plotHeight <= 0 || minGapPx <= 0 || !(max > min)) {
return splits;
}
const pixelsPerUnit = plotHeight / (max - min);
const kept: number[] = [];
let lastPosition = 0;
for (let index = splits.length - 1; index >= 0; index -= 1) {
// Axis values grow upward, pixel offsets downward.
const position = (max - splits[index]) * pixelsPerUnit;
if (kept.length === 0 || position - lastPosition >= minGapPx) {
kept.push(splits[index]);
lastPosition = position;
}
}
return kept.reverse();
}
/** Where uPlot switches from a fixed increment to a calendar walk. */
const MONTH_INCR_SECONDS = 3600 * 24 * 28;
const YEAR_INCR_SECONDS = 3600 * 24 * 365;
/** Shifts a timestamp into the axis timezone, as uPlot's `tzDate` does: the
* returned date's *local* fields read as that timezone's wall clock. */
type ToAxisDate = (timestamp: number) => Date;
const BROWSER_DATE: ToAxisDate = (timestamp) => new Date(timestamp * 1e3);
/**
* Real epoch seconds of the midnight at or before `timestamp`, in the axis
* timezone. The browser's own offset cancels: it is inside the shifted date's
* fields and inside the correction.
*/
function resolveDayOrigin(timestamp: number, toDate: ToAxisDate): number {
const shifted = toDate(timestamp);
const midnight = new Date(
shifted.getFullYear(),
shifted.getMonth(),
shifted.getDate(),
);
const correction = Math.floor(timestamp) - Math.floor(shifted.getTime() / 1e3);
return Math.floor(midnight.getTime() / 1e3) + correction;
}
function fromAxisDate(wall: Date, toDate: ToAxisDate): number {
const wallTs = Math.floor(wall.getTime() / 1e3);
return wallTs + (wallTs - Math.floor(toDate(wallTs).getTime() / 1e3));
}
function snapToColumnEdge(value: number, phase: number, width: number): number {
return phase + Math.round((value - phase) / width) * width;
}
/**
* Month and year ticks, walked as calendar dates the way uPlot walks them — no
* fixed increment expresses a month. Their spacing is uneven to begin with, so
* each tick is snapped to its own nearest column edge.
*/
function resolveCalendarSplits({
incr,
min,
max,
toDate,
phase,
columnWidth,
}: {
incr: number;
min: number;
max: number;
toDate: ToAxisDate;
phase: number;
columnWidth: number;
}): number[] {
const isYear = incr >= YEAR_INCR_SECONDS;
const monthsPerTick = Math.max(
1,
isYear
? Math.round(incr / YEAR_INCR_SECONDS) * 12
: Math.round(incr / MONTH_INCR_SECONDS),
);
const start = toDate(min);
const baseYear = start.getFullYear();
const baseMonth = isYear ? 0 : start.getMonth();
const splits: number[] = [];
for (let index = 0; ; index += 1) {
const wall = new Date(baseYear, baseMonth + monthsPerTick * index, 1);
const value = snapToColumnEdge(
fromAxisDate(wall, toDate),
phase,
columnWidth,
);
if (value > max) {
break;
}
if (value >= min && value !== splits[splits.length - 1]) {
splits.push(value);
}
}
return splits;
}
/**
* Time ticks placed on column edges, so a vertical grid line falls in the gap
* between two cells instead of through one. uPlot's increment is rounded up to a
* whole number of columns, and the sequence starts at the column edge nearest
* the timezone's midnight — the closest the grid can get to the ticks uPlot
* would have drawn. Where midnight is itself an edge, they are those ticks.
*/
export function resolveColumnAlignedSplits({
anchor,
step,
incr,
min,
max,
toDate = BROWSER_DATE,
}: {
/** Any column start: every edge sits at `anchor + n * step`. */
anchor: number;
/** Column width in seconds. */
step: number;
/** Increment uPlot picked for the axis, in seconds. */
incr: number;
min: number;
max: number;
toDate?: ToAxisDate;
}): number[] {
if (!(incr > 0) || !(max > min)) {
return [];
}
const columnWidth = step > 0 ? step : incr;
const phase = step > 0 ? ((anchor % step) + step) % step : 0;
if (incr >= MONTH_INCR_SECONDS) {
return resolveCalendarSplits({
incr,
min,
max,
toDate,
phase,
columnWidth,
});
}
const tickIncr = Math.ceil(incr / columnWidth) * columnWidth;
const origin = snapToColumnEdge(
resolveDayOrigin(min, toDate),
phase,
columnWidth,
);
const splits: number[] = [];
for (let index = Math.ceil((min - origin) / tickIncr); ; index += 1) {
const value = origin + index * tickIncr;
if (value > max) {
break;
}
splits.push(value);
}
return splits;
}

View File

@@ -1,77 +0,0 @@
import { HeatmapGrid, HeatmapSeries } from './types';
const EMPTY_GRID: HeatmapGrid = {
bounds: [],
timestamps: [],
step: 0,
counts: [],
};
/** Groups the legend currently has enabled. `undefined` means all of them. */
function resolveVisible(
series: HeatmapSeries[],
visibleGroups: string[] | undefined,
): HeatmapSeries[] {
if (visibleGroups === undefined) {
return series;
}
const allowed = new Set(visibleGroups);
return series.filter((entry) => allowed.has(entry.label));
}
/**
* Pivots the response's column-major counts into the row-major grid the renderer
* draws, and sums the enabled groups — counts are additive, so the sum is exact and
* needs no extra request. A cell is `null` only when no group contributed to it.
*/
export function resolveHeatmapGrid({
buckets,
step,
series,
visibleGroups,
}: {
buckets: number[];
/** Column width in seconds. */
step: number;
series: HeatmapSeries[];
/** Labels the legend has enabled. `undefined` sums every group. */
visibleGroups?: string[];
}): HeatmapGrid {
if (buckets.length === 0 || series.length === 0) {
return EMPTY_GRID;
}
const selected = resolveVisible(series, visibleGroups);
// Groups are not guaranteed to share timestamps, so the columns are their union.
const timestampSet = new Set<number>();
selected.forEach((entry) => {
entry.points.forEach((point) => timestampSet.add(point.timestamp));
});
const timestamps = Array.from(timestampSet).sort((a, b) => a - b);
const columnOf = new Map(timestamps.map((value, index) => [value, index]));
// N boundaries describe N+1 rows: the underflow row and the `+Inf` overflow row.
const rowCount = buckets.length + 1;
const counts: Array<Array<number | null>> = Array.from(
{ length: rowCount },
() => new Array<number | null>(timestamps.length).fill(null),
);
selected.forEach((entry) => {
entry.points.forEach((point) => {
const column = columnOf.get(point.timestamp);
if (column === undefined) {
return;
}
point.counts.forEach((count, row) => {
if (row >= rowCount || count === null || count === undefined) {
return;
}
counts[row][column] = (counts[row][column] ?? 0) + count;
});
});
});
return { bounds: buckets, timestamps, step, counts };
}

View File

@@ -1,178 +0,0 @@
import uPlot from 'uplot';
import {
createHeatmapColorResolver,
HeatmapColorResolver,
resolveCountDomain,
} from './colorScale';
import { resolveColumnIndex, resolveRowIndex } from './geometry';
import {
createHoverOverlay,
HeatmapHoverOverlay,
showHoverOverlay,
} from './hoverOverlay';
import { createHatchPattern, drawCells, drawOverflowBoundary } from './paint';
import { HeatmapCell, HeatmapColorOptions, HeatmapYAxis } from './types';
export interface HeatmapRenderOptions {
yAxis: HeatmapYAxis;
/** Column width in seconds. */
step: number;
colors: HeatmapColorOptions;
isDarkMode: boolean;
/** Opacity-mode fill when `colors.fill` is empty. */
seriesColor: string;
/** Default true. */
dimOnHover?: boolean;
/** `null` when the cursor leaves. */
onHoverChange?: (cell: HeatmapCell | null) => void;
}
/**
* Registered through `UPlotConfigBuilder.addHook`, not as a `uPlot.Plugin`: uPlot
* appends plugin hooks *after* the hook arrays, and `setCursor` must run before
* TooltipPlugin's so the focused row is resolved when the tooltip positions
* itself. As a plugin it trails a frame and the tooltip flashes at the origin.
*/
export interface HeatmapHooks {
init: (u: uPlot) => void;
draw: (u: uPlot) => void;
setCursor: (u: uPlot) => void;
destroy: (u: uPlot) => void;
}
export function createHeatmapHooks({
yAxis,
step,
colors,
isDarkMode,
seriesColor,
dimOnHover = true,
onHoverChange,
}: HeatmapRenderOptions): HeatmapHooks {
let overlay: HeatmapHoverOverlay | null = null;
let hovered: HeatmapCell | null = null;
let hatchPattern: CanvasPattern | null = null;
// On auto, the domain comes from the data, but these hooks are captured once at
// config-build time. Resolving lazily keeps a refetch on uPlot's `setData` path
// rather than forcing a rebuild.
let cachedData: uPlot.AlignedData | null = null;
let cachedResolver: HeatmapColorResolver | null = null;
function getResolver(u: uPlot): HeatmapColorResolver {
if (cachedResolver && cachedData === u.data) {
return cachedResolver;
}
cachedResolver = createHeatmapColorResolver({
options: colors,
domain: resolveCountDomain(
colors,
u.data.slice(1) as Array<Array<number | null>>,
),
isDarkMode,
seriesColor,
});
cachedData = u.data;
return cachedResolver;
}
function clearHover(u: uPlot): void {
if (overlay) {
overlay.container.style.display = 'none';
}
if (hovered === null) {
return;
}
hovered = null;
u.setSeries(null, { focus: true });
onHoverChange?.(null);
}
return {
init: (u: uPlot): void => {
overlay = createHoverOverlay(isDarkMode);
u.over.appendChild(overlay.container);
},
draw: (u: uPlot): void => {
const timestamps = u.data[0] as ArrayLike<number> | undefined;
if (!timestamps?.length || yAxis.rows.length === 0) {
return;
}
const { ctx } = u;
hatchPattern ??= createHatchPattern(ctx, isDarkMode);
ctx.save();
ctx.beginPath();
ctx.rect(u.bbox.left, u.bbox.top, u.bbox.width, u.bbox.height);
ctx.clip();
drawCells({ u, yAxis, step, resolver: getResolver(u), hatchPattern });
ctx.restore();
drawOverflowBoundary({ u, yAxis, isDarkMode });
},
setCursor: (u: uPlot): void => {
const { left = -10, top = -10 } = u.cursor;
if (left < 0 || top < 0) {
clearHover(u);
return;
}
const column = resolveColumnIndex(
u.data[0] as ArrayLike<number>,
u.posToVal(left, 'x'),
step,
);
const row = resolveRowIndex(yAxis.edges, u.posToVal(top, 'y'));
if (column === null || row === null) {
clearHover(u);
return;
}
const count =
(u.data[row + 1] as Array<number | null> | undefined)?.[column] ?? null;
// The count is part of the identity: a refetch swaps the data under a
// stationary cursor, and the cell it points at then means something else.
if (
hovered?.row === row &&
hovered?.column === column &&
hovered?.count === count
) {
return;
}
hovered = { row, column, count };
// Drives TooltipPlugin, which only shows a tooltip for a focused series.
// uPlot's own focus is disabled here: it picks the series nearest in value
// space, and a heatmap's value is a colour, not a y coordinate.
u.setSeries(row + 1, { focus: true });
if (overlay) {
showHoverOverlay({
overlay,
u,
yAxis,
step,
row,
column,
dim: dimOnHover,
});
}
onHoverChange?.(hovered);
},
destroy: (): void => {
overlay?.container.remove();
overlay = null;
hatchPattern = null;
cachedData = null;
cachedResolver = null;
// A rebuilt plot starts with no cursor, so a listener still holding this
// cell would keep drawing a hover that no longer exists.
if (hovered !== null) {
hovered = null;
onHoverChange?.(null);
}
},
};
}

View File

@@ -1,122 +0,0 @@
import { Color } from '@signozhq/design-tokens';
import uPlot from 'uplot';
import { HeatmapYAxis } from './types';
const HIGHLIGHT_BORDER_WIDTH = 1;
/** ~55% alpha. */
const DIM_ALPHA = '8C';
export interface HeatmapHoverOverlay {
container: HTMLDivElement;
highlight: HTMLDivElement;
/** Four corner rects whose complement is the hovered row/column cross. */
dims: HTMLDivElement[];
}
function createOverlayElement(): HTMLDivElement {
const element = document.createElement('div');
element.style.position = 'absolute';
element.style.pointerEvents = 'none';
return element;
}
function setRect(
element: HTMLDivElement,
left: number,
top: number,
width: number,
height: number,
): void {
element.style.left = `${left}px`;
element.style.top = `${top}px`;
element.style.width = `${Math.max(0, width)}px`;
element.style.height = `${Math.max(0, height)}px`;
}
/** Kept out of the canvas so moving between cells repositions a few nodes
* instead of repainting the grid. */
export function createHoverOverlay(isDarkMode: boolean): HeatmapHoverOverlay {
const container = createOverlayElement();
container.style.inset = '0';
container.style.display = 'none';
// The plot area's edge, as the canvas clip is to the cells: a cell at either end
// runs past the axis when its bucket or its time slice is only partly in view,
// and the highlight would otherwise be drawn over the axis and the panel.
container.style.overflow = 'hidden';
container.setAttribute('data-testid', 'heatmap-hover-overlay');
const dimColor = `${
isDarkMode ? Color.BG_INK_500 : Color.BG_VANILLA_100
}${DIM_ALPHA}`;
const dims = Array.from({ length: 4 }, () => {
const dim = createOverlayElement();
dim.style.background = dimColor;
container.appendChild(dim);
return dim;
});
const highlight = createOverlayElement();
highlight.style.border = `${HIGHLIGHT_BORDER_WIDTH}px solid ${
isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_300
}`;
highlight.style.boxSizing = 'border-box';
container.appendChild(highlight);
return { container, highlight, dims };
}
/** Positions the highlight, and the four corner rects so only the hovered row
* and column stay at full contrast. */
export function showHoverOverlay({
overlay,
u,
yAxis,
step,
row,
column,
dim,
}: {
overlay: HeatmapHoverOverlay;
u: uPlot;
yAxis: HeatmapYAxis;
step: number;
row: number;
column: number;
dim: boolean;
}): void {
const timestamps = u.data[0] as ArrayLike<number>;
const width = u.over.clientWidth;
const height = u.over.clientHeight;
const cellLeft = u.valToPos(timestamps[column], 'x');
const cellRight = u.valToPos(timestamps[column] + step, 'x');
const cellTop = u.valToPos(yAxis.edges[row + 1], 'y');
const cellBottom = u.valToPos(yAxis.edges[row], 'y');
setRect(
overlay.highlight,
cellLeft,
cellTop,
cellRight - cellLeft,
cellBottom - cellTop,
);
const [topLeft, topRight, bottomLeft, bottomRight] = overlay.dims;
if (dim) {
setRect(topLeft, 0, 0, cellLeft, cellTop);
setRect(topRight, cellRight, 0, width - cellRight, cellTop);
setRect(bottomLeft, 0, cellBottom, cellLeft, height - cellBottom);
setRect(
bottomRight,
cellRight,
cellBottom,
width - cellRight,
height - cellBottom,
);
} else {
overlay.dims.forEach((element) => setRect(element, 0, 0, 0, 0));
}
overlay.container.style.display = 'block';
}

View File

@@ -1,128 +0,0 @@
import { Color } from '@signozhq/design-tokens';
import uPlot from 'uplot';
import { HeatmapColorResolver } from './colorScale';
import { HeatmapYAxis } from './types';
/** Cells at least this wide/tall keep a hairline separator. */
const MIN_CELL_SIZE_FOR_GAP = 4;
const HATCH_TILE_SIZE = 6;
const OVERFLOW_DASH: [number, number] = [4, 3];
/** Hatch for `null` cells: a gap must never share the bottom-of-scale fill, or a
* scrape outage reads as a quiet period. */
export function createHatchPattern(
ctx: CanvasRenderingContext2D,
isDarkMode: boolean,
): CanvasPattern | null {
const pxRatio = uPlot.pxRatio;
const size = Math.max(2, Math.round(HATCH_TILE_SIZE * pxRatio));
const tile = document.createElement('canvas');
tile.width = size;
tile.height = size;
const tileCtx = tile.getContext('2d');
if (!tileCtx) {
return null;
}
tileCtx.strokeStyle = isDarkMode
? `${Color.BG_VANILLA_400}59`
: `${Color.BG_INK_300}40`;
tileCtx.lineWidth = Math.max(1, pxRatio);
tileCtx.beginPath();
// Three strokes keep the pattern continuous across tile seams.
tileCtx.moveTo(0, size);
tileCtx.lineTo(size, 0);
tileCtx.moveTo(-size / 2, size / 2);
tileCtx.lineTo(size / 2, -size / 2);
tileCtx.moveTo(size / 2, size * 1.5);
tileCtx.lineTo(size * 1.5, size / 2);
tileCtx.stroke();
return ctx.createPattern(tile, 'repeat');
}
/** One canvas pass. Offscreen columns are skipped rather than clipped. */
// eslint-disable-next-line sonarjs/cognitive-complexity
export function drawCells({
u,
yAxis,
step,
resolver,
hatchPattern,
}: {
u: uPlot;
yAxis: HeatmapYAxis;
step: number;
resolver: HeatmapColorResolver;
hatchPattern: CanvasPattern | null;
}): void {
const { ctx } = u;
const timestamps = u.data[0] as ArrayLike<number>;
const { rows, edges } = yAxis;
const pxRatio = uPlot.pxRatio;
const xMin = u.scales.x.min ?? timestamps[0];
const xMax = u.scales.x.max ?? timestamps[timestamps.length - 1] + step;
const rowEdgePositions = edges.map((edge) => u.valToPos(edge, 'y', true));
for (let column = 0; column < timestamps.length; column += 1) {
const columnStart = timestamps[column];
const columnEnd = columnStart + step;
if (columnEnd < xMin || columnStart > xMax) {
continue;
}
const left = u.valToPos(columnStart, 'x', true);
const rawWidth = u.valToPos(columnEnd, 'x', true) - left;
const gapX = rawWidth > MIN_CELL_SIZE_FOR_GAP * pxRatio ? pxRatio : 0;
const width = Math.max(1, rawWidth - gapX);
for (let row = 0; row < rows.length; row += 1) {
const top = rowEdgePositions[row + 1];
const rawHeight = rowEdgePositions[row] - top;
const gapY = rawHeight > MIN_CELL_SIZE_FOR_GAP * pxRatio ? pxRatio : 0;
const count = (u.data[row + 1] as Array<number | null> | undefined)?.[
column
];
const fill = resolver.colorFor(count ?? null);
if (fill === null && hatchPattern === null) {
continue;
}
ctx.fillStyle = fill ?? (hatchPattern as CanvasPattern);
ctx.fillRect(left, top, width, Math.max(1, rawHeight - gapY));
}
}
}
/** The `+Inf` row is unbounded, so its height is a drawing convenience and
* should not be compared with the real buckets. */
export function drawOverflowBoundary({
u,
yAxis,
isDarkMode,
}: {
u: uPlot;
yAxis: HeatmapYAxis;
isDarkMode: boolean;
}): void {
const overflowIndex = yAxis.rows.length - 1;
if (overflowIndex < 1 || !yAxis.rows[overflowIndex].isOverflow) {
return;
}
const { ctx } = u;
const y = Math.round(u.valToPos(yAxis.edges[overflowIndex], 'y', true));
ctx.save();
ctx.setLineDash(OVERFLOW_DASH);
ctx.lineWidth = Math.max(1, uPlot.pxRatio);
ctx.strokeStyle = isDarkMode ? Color.BG_VANILLA_400 : Color.BG_INK_300;
ctx.beginPath();
ctx.moveTo(u.bbox.left, y);
ctx.lineTo(u.bbox.left + u.bbox.width, y);
ctx.stroke();
ctx.restore();
}

View File

@@ -1,167 +0,0 @@
import { HeatmapColorPalette } from './types';
interface PaletteDefinition {
/** Evenly spaced, one end of the ramp to the other. */
stops: string[];
/** `true` when `stops[0]` is the dark end. */
darkFirst: boolean;
}
/**
* Stop values come from the long-established public palette families —
* ColorBrewer for the hue ramps, matplotlib's perceptual set for the rest.
*/
const PALETTES: Record<HeatmapColorPalette, PaletteDefinition> = {
[HeatmapColorPalette.Ice]: {
darkFirst: false,
stops: [
'#f7fbff',
'#deebf7',
'#c3dbee',
'#9cc8e2',
'#6daed5',
'#4391c6',
'#2271b4',
'#0c5198',
'#08306b',
],
},
[HeatmapColorPalette.Moss]: {
darkFirst: false,
stops: [
'#f7fcf5',
'#e3f4de',
'#c6e8bf',
'#a0d89b',
'#73c378',
'#45aa5d',
'#228b45',
'#066b2d',
'#00441b',
],
},
[HeatmapColorPalette.Rust]: {
darkFirst: false,
stops: [
'#fff5f0',
'#feddcf',
'#fcbaa1',
'#fc9273',
'#f9694c',
'#eb3d2f',
'#cb1c1e',
'#a10e15',
'#67000d',
],
},
[HeatmapColorPalette.Graphite]: {
darkFirst: false,
stops: [
'#ffffff',
'#efefef',
'#d8d8d8',
'#bbbbbb',
'#979797',
'#737373',
'#505050',
'#262626',
'#000000',
],
},
[HeatmapColorPalette.Ember]: {
darkFirst: false,
stops: [
'#ffffcc',
'#ffeda0',
'#fed676',
'#feb250',
'#fd893c',
'#f8502b',
'#e11e20',
'#b90424',
'#800026',
],
},
[HeatmapColorPalette.Lagoon]: {
darkFirst: false,
stops: [
'#ffffd9',
'#eaf7b8',
'#c1e7b5',
'#81cebb',
'#45b4c2',
'#248fbd',
'#2260a9',
'#20378d',
'#081d58',
],
},
[HeatmapColorPalette.Orchid]: {
darkFirst: false,
stops: [
'#fff7f3',
'#fddfdc',
'#fcc3c3',
'#fa9cb4',
'#f369a3',
'#da3495',
'#ad0a81',
'#7b0176',
'#49006a',
],
},
[HeatmapColorPalette.Verdant]: {
darkFirst: true,
stops: [
'#440154',
'#472d7b',
'#3b528b',
'#2c728e',
'#21918c',
'#28ae80',
'#5ec962',
'#addc30',
'#fde725',
],
},
[HeatmapColorPalette.Lava]: {
darkFirst: true,
stops: [
'#000004',
'#1d1147',
'#51127c',
'#832681',
'#b73779',
'#e75263',
'#fc8961',
'#fec488',
'#fcfdbf',
],
},
[HeatmapColorPalette.Beacon]: {
darkFirst: true,
stops: [
'#002051',
'#11366c',
'#3c4d6e',
'#62646f',
'#7f7c75',
'#9a9478',
'#bbaf71',
'#e2cb5c',
'#fdea45',
],
},
};
/** Stops oriented low-count first for the active theme. At the wrong polarity,
* empty cells become the loudest thing on screen. */
export function getPaletteStops(
palette: HeatmapColorPalette,
isDarkMode: boolean,
): string[] {
const definition = PALETTES[palette] ?? PALETTES[HeatmapColorPalette.Ice];
return definition.darkFirst === isDarkMode
? definition.stops
: [...definition.stops].reverse();
}

View File

@@ -1,122 +0,0 @@
export enum HeatmapColorScale {
Log = 'log',
Sqrt = 'sqrt',
Linear = 'linear',
}
export enum HeatmapColorMode {
Palette = 'palette',
Opacity = 'opacity',
}
/** Sequential ramps only: colour means "count", so a midpoint or hue cycle would
* read as a threshold that does not exist. */
export enum HeatmapColorPalette {
Ice = 'ice',
Moss = 'moss',
Rust = 'rust',
Graphite = 'graphite',
Ember = 'ember',
Lagoon = 'lagoon',
Orchid = 'orchid',
Verdant = 'verdant',
Lava = 'lava',
Beacon = 'beacon',
}
export interface HeatmapColorOptions {
mode: HeatmapColorMode;
scale: HeatmapColorScale;
/** `null` derives it, which is always 0 — a count of 0 belongs at the bottom. */
minCount: number | null;
/** `null` derives it from the grid's highest count. */
maxCount: number | null;
palette: HeatmapColorPalette;
/** Colour steps the ramp is quantised into, 2..128. Unrelated to `step`, the
* column width in seconds. */
steps: number;
/** Opacity mode. Empty falls back to the caller's series colour. */
fill: string;
}
/** Row-height distribution of the bucket axis. */
export enum HeatmapAxisScale {
/** Whichever of the three below the boundaries admit: log when they are all
* positive, symmetric log when they cross zero, linear when they are all
* zero. The choice is a property of the data, so this is the default. */
Auto = 'auto',
Linear = 'linear',
/** Plain log10. A boundary at or below zero has no logarithm, so it is pinned
* one bucket below the smallest positive one — see `resolveHeatmapYAxis`. */
Log = 'log',
/** Linear within ±the smallest non-zero boundary, logarithmic beyond,
* mirrored across zero. The scale for boundaries that straddle zero. */
Symlog = 'symlog',
}
export interface HeatmapSeriesPoint {
/** Column start, in seconds. */
timestamp: number;
/** One per bucket row, lowest first. `null` is "no data", never `0`. */
counts: Array<number | null>;
}
export interface HeatmapSeriesLabel {
key: string;
value: string;
}
export interface HeatmapSeries {
/** Group label, as the legend names it. Empty when there is no grouping. */
label: string;
/** The pairs behind `label`, letting the tooltip name rows by value alone. */
labels?: HeatmapSeriesLabel[];
points: HeatmapSeriesPoint[];
}
/** Counts pivoted into rows and aligned to one column axis. Internal to the
* chart, which resolves it from `buckets` and `series`. */
export interface HeatmapGrid {
/** Ascending. N boundaries describe N+1 rows, including the `+Inf` overflow. */
bounds: number[];
/** Column starts, in seconds. */
timestamps: number[];
/** Column width in seconds. Cells span `[timestamps[j], timestamps[j] + step)`,
* and the last column has no successor to infer it from. */
step: number;
/** `counts[row][column]`, row 0 lowest. `null` (no data) renders hatched, `0`
* at the bottom of the scale — conflating them hides an outage. */
counts: Array<Array<number | null>>;
}
export interface HeatmapRow {
/** Synthetic on the underflow row. */
lower: number;
/** Synthetic on the overflow row. */
upper: number;
isUnderflow: boolean;
isOverflow: boolean;
}
/** The bucket axis in uPlot y-scale space. A log axis is log10 values on a
* *linear* scale, not uPlot's log distribution, so boundaries stay exactly on
* ticks and uPlot's decade-only label filter cannot hide them. */
export interface HeatmapYAxis {
rows: HeatmapRow[];
/** Row edges, ascending. Length is `rows.length + 1`. */
edges: number[];
/** Real bucket boundaries — one tick each. */
splits: number[];
/** Where the `∞` tick goes: the overflow row's upper edge, not its centre,
* which would sit half a row from the last boundary and collide with it. */
overflowSplit: number | null;
toBucketValue: (axisValue: number) => number;
min: number;
max: number;
}
export interface HeatmapCell {
row: number;
column: number;
count: number | null;
}

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