Compare commits

..

20 Commits

Author SHA1 Message Date
Nikhil Soni
60e949d8d8 chore: add rule for avoid commit amend 2026-08-19 22:34:30 +05:30
Nikhil Soni
474aabfd36 chore: make commands for running on worktree discoverable 2026-08-18 15:31:10 +05:30
Nikhil Soni
887b8afd60 docs(contributing): document running multiple signoz instances 2026-08-18 15:21:00 +05:30
Nikhil Soni
37364f611b Revert "chore(hooks): replace husky with worktree-safe .githooks installed automatically"
This reverts commit 1accf43a95.
2026-08-18 15:13:44 +05:30
Nikhil Soni
2b4e1ba382 chore: add rule for running gofmt 2026-08-18 15:13:44 +05:30
Nikhil Soni
9ba63c0a06 chore(hooks): replace husky with worktree-safe .githooks installed automatically
Husky wired hook installation to pnpm install, which go-only contributors
never run. The installer is plain shell so both the Makefile and the frontend
postinstall can call it, and each check is gated on staged paths so neither
side needs the other's toolchain.
2026-08-18 15:13:44 +05:30
Nikhil Soni
206c6f4627 revert(hooks): drop root .husky move and the staged-go gofmt step
Hook installation strategy still needs deciding; reverting to keep it out of
this PR.
2026-08-18 15:13:44 +05:30
Nikhil Soni
342465e715 chore(makefile): poll go-stop once a second so the timeout reads as one number 2026-08-18 15:13:44 +05:30
Nikhil Soni
3bf6aca021 fix(makefile): make go-stop wait for the server to actually exit
kill only signals; the process keeps every port bound while it shuts down
gracefully, so an immediate restart failed on the prometheus exporter port.
2026-08-18 15:13:44 +05:30
Nikhil Soni
0680edd23f chore(makefile): rely on env inheritance for signoz overrides in go-run targets 2026-08-18 15:13:44 +05:30
Nikhil Soni
fd71c555ed chore(hooks): move git hooks from frontend/.husky to root .husky 2026-08-18 15:13:44 +05:30
Nikhil Soni
8bb9fa1a49 chore(makefile): hint at overriding SIGNOZ_APISERVER_ADDRESS when go-stop finds nothing 2026-08-18 15:13:44 +05:30
Nikhil Soni
572fc3e2e2 chore(makefile): rename HTTP_HOST_PORT to SIGNOZ_APISERVER_ADDRESS 2026-08-18 15:13:44 +05:30
Nikhil Soni
bcb315009d chore(hooks): gofmt staged Go files on commit 2026-08-18 15:13:44 +05:30
Nikhil Soni
371a8459ff revert(config): keep opamp websocket port hardcoded, only apiserver port is configurable 2026-08-18 15:13:44 +05:30
Nikhil Soni
7f77681a2b refactor(config): move signoz port config from raw env vars to structured config 2026-08-18 15:13:44 +05:30
Nikhil Soni
70f192f779 chore: add opinionated contrib rule 2026-08-18 15:13:44 +05:30
Nikhil Soni
45158fa046 chore: include commit message instruction as well 2026-08-18 15:13:44 +05:30
Nikhil Soni
78eee04b18 chore: add local claude settings in ignore 2026-08-18 15:13:43 +05:30
Nikhil Soni
84a9b9f346 chore(dev): make signoz http/private/opamp ports and sqlite path configurable 2026-08-18 15:13:43 +05:30
159 changed files with 3610 additions and 5217 deletions

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 repeatative 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,8 @@ sqlstore:
##################### APIServer #####################
apiserver:
# The TCP address the API server listens on, in the form "host:port".
address: 0.0.0.0:8080
timeout:
# Default request timeout.
default: 60s

View File

@@ -2646,54 +2646,6 @@ components:
repeatVariable:
type: string
type: object
DashboardtypesAreaChartAppearance:
properties:
fillMode:
$ref: '#/components/schemas/DashboardtypesAreaFillMode'
fillOpacity:
$ref: '#/components/schemas/DashboardtypesFillOpacity'
lineInterpolation:
$ref: '#/components/schemas/DashboardtypesLineInterpolation'
lineStyle:
$ref: '#/components/schemas/DashboardtypesLineStyle'
showPoints:
type: boolean
spanGaps:
$ref: '#/components/schemas/DashboardtypesSpanGaps'
type: object
DashboardtypesAreaChartPanelSpec:
properties:
axes:
$ref: '#/components/schemas/DashboardtypesAxes'
chartAppearance:
$ref: '#/components/schemas/DashboardtypesAreaChartAppearance'
formatting:
$ref: '#/components/schemas/DashboardtypesPanelFormatting'
legend:
$ref: '#/components/schemas/DashboardtypesLegend'
thresholds:
items:
$ref: '#/components/schemas/DashboardtypesThresholdWithLabel'
nullable: true
type: array
visualization:
$ref: '#/components/schemas/DashboardtypesAreaChartVisualization'
type: object
DashboardtypesAreaChartVisualization:
properties:
fillSpans:
type: boolean
stack:
$ref: '#/components/schemas/DashboardtypesStackMode'
timePreference:
$ref: '#/components/schemas/DashboardtypesTimePreference'
type: object
DashboardtypesAreaFillMode:
enum:
- solid
- gradient
- none
type: string
DashboardtypesAxes:
properties:
isLogScale:
@@ -2925,11 +2877,6 @@ components:
- gradient
- none
type: string
DashboardtypesFillOpacity:
maximum: 1
minimum: 0
nullable: true
type: number
DashboardtypesGettableDashboardV2:
properties:
createdAt:
@@ -3347,7 +3294,6 @@ components:
DashboardtypesPanelPlugin:
discriminator:
mapping:
signoz/AreaChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec'
signoz/BarChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec'
signoz/HistogramPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec'
signoz/ListPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
@@ -3359,7 +3305,6 @@ components:
oneOf:
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec'
@@ -3370,25 +3315,12 @@ components:
enum:
- signoz/TimeSeriesPanel
- signoz/BarChartPanel
- signoz/AreaChartPanel
- signoz/NumberPanel
- signoz/PieChartPanel
- signoz/TablePanel
- signoz/HistogramPanel
- signoz/ListPanel
type: string
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec:
properties:
kind:
enum:
- signoz/AreaChartPanel
type: string
spec:
$ref: '#/components/schemas/DashboardtypesAreaChartPanelSpec'
required:
- kind
- spec
type: object
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec:
properties:
kind:
@@ -3707,12 +3639,6 @@ components:
are connected.
type: boolean
type: object
DashboardtypesStackMode:
enum:
- none
- normal
- percent
type: string
DashboardtypesStorableDashboardData:
additionalProperties: {}
type: object
@@ -8098,6 +8024,13 @@ components:
- schemaVersion
- spec
type: object
ServiceaccounttypesDeprecatedPostableServiceAccountRole:
properties:
id:
type: string
required:
- id
type: object
ServiceaccounttypesGettableFactorAPIKey:
properties:
createdAt:
@@ -13329,6 +13262,117 @@ paths:
summary: Gets service account roles
tags:
- serviceaccount
post:
deprecated: true
description: This endpoint assigns a role to a service account
operationId: CreateServiceAccountRoleDeprecated
parameters:
- in: path
name: id
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/ServiceaccounttypesDeprecatedPostableServiceAccountRole'
responses:
"201":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/TypesIdentifiable'
status:
type: string
required:
- status
- data
type: object
description: Created
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- serviceaccount:attach
- role:attach
- tokenizer:
- serviceaccount:attach
- role:attach
summary: Create service account role
tags:
- serviceaccount
/api/v1/service_accounts/{id}/roles/{rid}:
delete:
deprecated: true
description: This endpoint revokes a role from service account
operationId: DeleteServiceAccountRoleDeprecated
parameters:
- in: path
name: id
required: true
schema:
type: string
- in: path
name: rid
required: true
schema:
type: string
responses:
"204":
description: No Content
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- serviceaccount:detach
- role:detach
- tokenizer:
- serviceaccount:detach
- role:detach
summary: Delete service account role
tags:
- serviceaccount
/api/v1/service_accounts/me:
get:
deprecated: false
@@ -14332,9 +14376,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- auth-domain:list
- ADMIN
- tokenizer:
- auth-domain:list
- ADMIN
summary: List all auth domains
tags:
- authdomains
@@ -14394,13 +14438,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- auth-domain:create
- auth-domain:attach
- role:attach
- ADMIN
- tokenizer:
- auth-domain:create
- auth-domain:attach
- role:attach
- ADMIN
summary: Create auth domain
tags:
- authdomains
@@ -14444,9 +14484,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- auth-domain:delete
- ADMIN
- tokenizer:
- auth-domain:delete
- ADMIN
summary: Delete auth domain
tags:
- authdomains
@@ -14501,9 +14541,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- auth-domain:read
- ADMIN
- tokenizer:
- auth-domain:read
- ADMIN
summary: Get auth domain by ID
tags:
- authdomains
@@ -14557,17 +14597,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- auth-domain:update
- auth-domain:attach
- auth-domain:detach
- role:attach
- role:detach
- ADMIN
- tokenizer:
- auth-domain:update
- auth-domain:attach
- auth-domain:detach
- role:attach
- role:detach
- ADMIN
summary: Update auth domain
tags:
- authdomains

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

@@ -130,7 +130,7 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
s := &Server{
config: config,
signoz: signoz,
httpHostPort: baseconst.HTTPHostPort,
httpHostPort: config.APIServer.Address,
unavailableChannel: make(chan healthcheck.Status),
usageManager: usageManager,
}
@@ -234,7 +234,7 @@ func (s *Server) initListeners() error {
var err error
publicHostPort := s.httpHostPort
if publicHostPort == "" {
return fmt.Errorf("baseconst.HTTPHostPort is required")
return fmt.Errorf("apiserver.address is required")
}
s.httpConn, err = net.Listen("tcp", publicHostPort)

View File

@@ -22,7 +22,10 @@ import type {
CreateServiceAccountKey201,
CreateServiceAccountKeyPathParameters,
CreateServiceAccountRole201,
CreateServiceAccountRoleDeprecated201,
CreateServiceAccountRoleDeprecatedPathParameters,
DeleteServiceAccountPathParameters,
DeleteServiceAccountRoleDeprecatedPathParameters,
DeleteServiceAccountRolePathParameters,
GetMyServiceAccount200,
GetServiceAccount200,
@@ -36,6 +39,7 @@ import type {
ListServiceAccounts200,
RenderErrorResponseDTO,
RevokeServiceAccountKeyPathParameters,
ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO,
ServiceaccounttypesPostableFactorAPIKeyDTO,
ServiceaccounttypesPostableServiceAccountDTO,
ServiceaccounttypesPostableServiceAccountRoleDTO,
@@ -1249,6 +1253,194 @@ export const invalidateGetServiceAccountRoles = async (
return queryClient;
};
/**
* This endpoint assigns a role to a service account
* @deprecated
* @summary Create service account role
*/
export const createServiceAccountRoleDeprecated = (
{ id }: CreateServiceAccountRoleDeprecatedPathParameters,
serviceaccounttypesDeprecatedPostableServiceAccountRoleDTO?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<CreateServiceAccountRoleDeprecated201>({
url: `/api/v1/service_accounts/${id}/roles`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: serviceaccounttypesDeprecatedPostableServiceAccountRoleDTO,
signal,
});
};
export const getCreateServiceAccountRoleDeprecatedMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createServiceAccountRoleDeprecated>>,
TError,
{
pathParams: CreateServiceAccountRoleDeprecatedPathParameters;
data?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof createServiceAccountRoleDeprecated>>,
TError,
{
pathParams: CreateServiceAccountRoleDeprecatedPathParameters;
data?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>;
},
TContext
> => {
const mutationKey = ['createServiceAccountRoleDeprecated'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof createServiceAccountRoleDeprecated>>,
{
pathParams: CreateServiceAccountRoleDeprecatedPathParameters;
data?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
return createServiceAccountRoleDeprecated(pathParams, data);
};
return { mutationFn, ...mutationOptions };
};
export type CreateServiceAccountRoleDeprecatedMutationResult = NonNullable<
Awaited<ReturnType<typeof createServiceAccountRoleDeprecated>>
>;
export type CreateServiceAccountRoleDeprecatedMutationBody =
| BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>
| undefined;
export type CreateServiceAccountRoleDeprecatedMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @deprecated
* @summary Create service account role
*/
export const useCreateServiceAccountRoleDeprecated = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createServiceAccountRoleDeprecated>>,
TError,
{
pathParams: CreateServiceAccountRoleDeprecatedPathParameters;
data?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof createServiceAccountRoleDeprecated>>,
TError,
{
pathParams: CreateServiceAccountRoleDeprecatedPathParameters;
data?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>;
},
TContext
> => {
return useMutation(
getCreateServiceAccountRoleDeprecatedMutationOptions(options),
);
};
/**
* This endpoint revokes a role from service account
* @deprecated
* @summary Delete service account role
*/
export const deleteServiceAccountRoleDeprecated = (
{ id, rid }: DeleteServiceAccountRoleDeprecatedPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v1/service_accounts/${id}/roles/${rid}`,
method: 'DELETE',
signal,
});
};
export const getDeleteServiceAccountRoleDeprecatedMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteServiceAccountRoleDeprecated>>,
TError,
{ pathParams: DeleteServiceAccountRoleDeprecatedPathParameters },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof deleteServiceAccountRoleDeprecated>>,
TError,
{ pathParams: DeleteServiceAccountRoleDeprecatedPathParameters },
TContext
> => {
const mutationKey = ['deleteServiceAccountRoleDeprecated'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof deleteServiceAccountRoleDeprecated>>,
{ pathParams: DeleteServiceAccountRoleDeprecatedPathParameters }
> = (props) => {
const { pathParams } = props ?? {};
return deleteServiceAccountRoleDeprecated(pathParams);
};
return { mutationFn, ...mutationOptions };
};
export type DeleteServiceAccountRoleDeprecatedMutationResult = NonNullable<
Awaited<ReturnType<typeof deleteServiceAccountRoleDeprecated>>
>;
export type DeleteServiceAccountRoleDeprecatedMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @deprecated
* @summary Delete service account role
*/
export const useDeleteServiceAccountRoleDeprecated = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteServiceAccountRoleDeprecated>>,
TError,
{ pathParams: DeleteServiceAccountRoleDeprecatedPathParameters },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof deleteServiceAccountRoleDeprecated>>,
TError,
{ pathParams: DeleteServiceAccountRoleDeprecatedPathParameters },
TContext
> => {
return useMutation(
getDeleteServiceAccountRoleDeprecatedMutationOptions(options),
);
};
/**
* This endpoint gets my service account
* @summary Gets my service account

View File

@@ -9092,6 +9092,13 @@ export interface SavedviewtypesUpdatableSavedViewDTO {
spec: SavedviewtypesSavedViewSpecDTO;
}
export interface ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO {
/**
* @type string
*/
id: string;
}
export interface ServiceaccounttypesGettableFactorAPIKeyDTO {
/**
* @type string
@@ -11013,6 +11020,21 @@ export type GetServiceAccountRoles200 = {
status: string;
};
export type CreateServiceAccountRoleDeprecatedPathParameters = {
id: string;
};
export type CreateServiceAccountRoleDeprecated201 = {
data: TypesIdentifiableDTO;
/**
* @type string
*/
status: string;
};
export type DeleteServiceAccountRoleDeprecatedPathParameters = {
id: string;
rid: string;
};
export type GetMyServiceAccount200 = {
data: ServiceaccounttypesServiceAccountWithRolesDTO;
/**

View File

@@ -1,3 +0,0 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path fill="#0078D4" d="M22.379 23.343a1.62 1.62 0 0 0 1.536-2.14v.002L17.35 1.76A1.62 1.62 0 0 0 15.816.657H8.184A1.62 1.62 0 0 0 6.65 1.76L.086 21.204a1.62 1.62 0 0 0 1.536 2.139h4.741a1.62 1.62 0 0 0 1.535-1.103l.977-2.892 4.947 3.675c.28.208.618.32.966.32m-3.084-12.531 3.624 10.739a.54.54 0 0 1-.51.713v-.001h-.03a.54.54 0 0 1-.322-.106l-9.287-6.9h4.853m6.313 7.006c.116-.326.13-.694.007-1.058L9.79 1.76a1.722 1.722 0 0 0-.007-.02h6.034a.54.54 0 0 1 .512.366l6.562 19.445a.54.54 0 0 1-.338.684"/>
</svg>

Before

Width:  |  Height:  |  Size: 583 B

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 7.3 KiB

View File

@@ -1,5 +0,0 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<title>Dify</title>
<path d="M7.043 6.487c1.635 0 2.241-1.003 2.241-2.243S8.681 2 7.044 2C5.405 2 4.801 3.003 4.801 4.244c0 1.24.604 2.243 2.241 2.243z" fill="#03F" />
<path d="M14.883 6.97v1.443h-3.679v3.203h3.68v8.012H8.801V8.41h-8v3.203h4.48v8.012H0v3.203h24v-3.203h-5.6v-8.012H24V8.41h-5.6V5.206H24V2.003h-4.161a4.97 4.97 0 00-4.961 4.967h.005z" fill="#03F" />
</svg>

Before

Width:  |  Height:  |  Size: 447 B

View File

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 115.28 122.88"><path d="M25.38 57h64.88V37.34H69.59c-2.17 0-5.19-1.17-6.62-2.6s-2.3-4.01-2.3-6.17V7.64H8.15c-.18 0-.32.09-.41.18-.15.1-.19.23-.19.42v106.45c0 .14.09.32.18.41.09.14.28.18.41.18h81.51c.18 0 .17-.09.27-.18.14-.09.33-.28.33-.41v-11.16H25.38c-4.14 0-7.56-3.4-7.56-7.56V64.55c0-4.15 3.4-7.55 7.56-7.55m4.11 11.38h7.43v18.15h11.63v5.92H29.49zm20.4 12.05c0-3.93 1.09-6.99 3.28-9.17 2.19-2.19 5.24-3.28 9.15-3.28 4.01 0 7.09 1.08 9.26 3.22 2.17 2.15 3.25 5.16 3.25 9.04 0 2.81-.47 5.11-1.42 6.91q-1.425 2.7-4.11 4.2t-6.69 1.5c-2.71 0-4.96-.43-6.74-1.29-1.78-.87-3.22-2.23-4.32-4.11-1.11-1.87-1.66-4.21-1.66-7.02m7.42.01c0 2.43.45 4.17 1.36 5.23s2.14 1.59 3.7 1.59c1.6 0 2.84-.52 3.71-1.56.88-1.04 1.32-2.9 1.32-5.6 0-2.26-.46-3.92-1.37-4.96-.92-1.05-2.16-1.57-3.73-1.57-1.5 0-2.71.53-3.62 1.59-.91 1.08-1.37 2.83-1.37 5.28m33.11 3.3v-5.01h11.49v10.23c-2.2 1.5-4.15 2.53-5.83 3.07-1.69.54-3.7.81-6.02.81-2.86 0-5.19-.49-6.99-1.46s-3.19-2.42-4.18-4.35c-.99-1.92-1.48-4.13-1.48-6.63 0-2.63.54-4.91 1.62-6.85s2.67-3.41 4.76-4.42c1.63-.78 3.83-1.17 6.58-1.17 2.66 0 4.64.24 5.96.72s2.41 1.23 3.28 2.24 1.52 2.3 1.96 3.85l-7.16 1.29c-.3-.91-.8-1.61-1.5-2.09-.71-.49-1.6-.73-2.7-.73-1.62 0-2.92.57-3.89 1.7s-1.45 2.92-1.45 5.37c0 2.6.49 4.46 1.47 5.57.97 1.11 2.34 1.68 4.09 1.68q1.245 0 2.37-.36c.75-.24 1.61-.65 2.59-1.22v-2.25h-4.97zM97.79 57h9.93c4.16 0 7.56 3.41 7.56 7.56v31.42c0 4.15-3.41 7.56-7.56 7.56h-9.93v13.55c0 1.61-.65 3.04-1.7 4.1a5.74 5.74 0 0 1-4.1 1.7H5.81a5.74 5.74 0 0 1-4.1-1.7 5.74 5.74 0 0 1-1.7-4.1V5.85c0-1.61.65-3.04 1.7-4.1a5.8 5.8 0 0 1 4.1-1.7h58.72c.13-.05.27-.05.41-.05.64 0 1.29.28 1.75.69h.09c.09.05.14.09.23.18L97 31.23c.51.51.88 1.2.88 1.98 0 .23-.05.41-.09.65zM67.52 27.97V8.94l21.43 21.7H70.19c-.74 0-1.38-.32-1.89-.78-.46-.46-.78-1.15-.78-1.89" style="fill:#D1D5DB;fill-rule:evenodd;clip-rule:evenodd"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 115.28 122.88"><path d="M25.38 57h64.88V37.34H69.59c-2.17 0-5.19-1.17-6.62-2.6s-2.3-4.01-2.3-6.17V7.64H8.15c-.18 0-.32.09-.41.18-.15.1-.19.23-.19.42v106.45c0 .14.09.32.18.41.09.14.28.18.41.18h81.51c.18 0 .17-.09.27-.18.14-.09.33-.28.33-.41v-11.16H25.38c-4.14 0-7.56-3.4-7.56-7.56V64.55c0-4.15 3.4-7.55 7.56-7.55m4.11 11.38h7.43v18.15h11.63v5.92H29.49zm20.4 12.05c0-3.93 1.09-6.99 3.28-9.17 2.19-2.19 5.24-3.28 9.15-3.28 4.01 0 7.09 1.08 9.26 3.22 2.17 2.15 3.25 5.16 3.25 9.04 0 2.81-.47 5.11-1.42 6.91q-1.425 2.7-4.11 4.2t-6.69 1.5c-2.71 0-4.96-.43-6.74-1.29-1.78-.87-3.22-2.23-4.32-4.11-1.11-1.87-1.66-4.21-1.66-7.02m7.42.01c0 2.43.45 4.17 1.36 5.23s2.14 1.59 3.7 1.59c1.6 0 2.84-.52 3.71-1.56.88-1.04 1.32-2.9 1.32-5.6 0-2.26-.46-3.92-1.37-4.96-.92-1.05-2.16-1.57-3.73-1.57-1.5 0-2.71.53-3.62 1.59-.91 1.08-1.37 2.83-1.37 5.28m33.11 3.3v-5.01h11.49v10.23c-2.2 1.5-4.15 2.53-5.83 3.07-1.69.54-3.7.81-6.02.81-2.86 0-5.19-.49-6.99-1.46s-3.19-2.42-4.18-4.35c-.99-1.92-1.48-4.13-1.48-6.63 0-2.63.54-4.91 1.62-6.85s2.67-3.41 4.76-4.42c1.63-.78 3.83-1.17 6.58-1.17 2.66 0 4.64.24 5.96.72s2.41 1.23 3.28 2.24 1.52 2.3 1.96 3.85l-7.16 1.29c-.3-.91-.8-1.61-1.5-2.09-.71-.49-1.6-.73-2.7-.73-1.62 0-2.92.57-3.89 1.7s-1.45 2.92-1.45 5.37c0 2.6.49 4.46 1.47 5.57.97 1.11 2.34 1.68 4.09 1.68q1.245 0 2.37-.36c.75-.24 1.61-.65 2.59-1.22v-2.25h-4.97zM97.79 57h9.93c4.16 0 7.56 3.41 7.56 7.56v31.42c0 4.15-3.41 7.56-7.56 7.56h-9.93v13.55c0 1.61-.65 3.04-1.7 4.1a5.74 5.74 0 0 1-4.1 1.7H5.81a5.74 5.74 0 0 1-4.1-1.7 5.74 5.74 0 0 1-1.7-4.1V5.85c0-1.61.65-3.04 1.7-4.1a5.8 5.8 0 0 1 4.1-1.7h58.72c.13-.05.27-.05.41-.05.64 0 1.29.28 1.75.69h.09c.09.05.14.09.23.18L97 31.23c.51.51.88 1.2.88 1.98 0 .23-.05.41-.09.65zM67.52 27.97V8.94l21.43 21.7H70.19c-.74 0-1.38-.32-1.89-.78-.46-.46-.78-1.15-.78-1.89" style="fill-rule:evenodd;clip-rule:evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -1,3 +0,0 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path fill="#9CA3AF" d="M23.922 16.997C23.061 18.492 18.063 22.02 12 22.02 5.937 22.02.939 18.492.078 16.997A.641.641 0 0 1 0 16.741v-2.869a.883.883 0 0 1 .053-.22c.372-.935 1.347-2.292 2.605-2.656.167-.429.414-1.055.644-1.517a10.098 10.098 0 0 1-.052-1.086c0-1.331.282-2.499 1.132-3.368.397-.406.89-.717 1.474-.952C7.255 2.937 9.248 1.98 11.978 1.98c2.731 0 4.767.957 6.166 2.093.584.235 1.077.546 1.474.952.85.869 1.132 2.037 1.132 3.368 0 .368-.014.733-.052 1.086.23.462.477 1.088.644 1.517 1.258.364 2.233 1.721 2.605 2.656a.841.841 0 0 1 .053.22v2.869a.641.641 0 0 1-.078.256Zm-11.75-5.992h-.344a4.359 4.359 0 0 1-.355.508c-.77.947-1.918 1.492-3.508 1.492-1.725 0-2.989-.359-3.782-1.259a2.137 2.137 0 0 1-.085-.104L4 11.746v6.585c1.435.779 4.514 2.179 8 2.179 3.486 0 6.565-1.4 8-2.179v-6.585l-.098-.104s-.033.045-.085.104c-.793.9-2.057 1.259-3.782 1.259-1.59 0-2.738-.545-3.508-1.492a4.359 4.359 0 0 1-.355-.508Zm2.328 3.25c.549 0 1 .451 1 1v2c0 .549-.451 1-1 1-.549 0-1-.451-1-1v-2c0-.549.451-1 1-1Zm-5 0c.549 0 1 .451 1 1v2c0 .549-.451 1-1 1-.549 0-1-.451-1-1v-2c0-.549.451-1 1-1Zm3.313-6.185c.136 1.057.403 1.913.878 2.497.442.544 1.134.938 2.344.938 1.573 0 2.292-.337 2.657-.751.384-.435.558-1.15.558-2.361 0-1.14-.243-1.847-.705-2.319-.477-.488-1.319-.862-2.824-1.025-1.487-.161-2.192.138-2.533.529-.269.307-.437.808-.438 1.578v.021c0 .265.021.562.063.893Zm-1.626 0c.042-.331.063-.628.063-.894v-.02c-.001-.77-.169-1.271-.438-1.578-.341-.391-1.046-.69-2.533-.529-1.505.163-2.347.537-2.824 1.025-.462.472-.705 1.179-.705 2.319 0 1.211.175 1.926.558 2.361.365.414 1.084.751 2.657.751 1.21 0 1.902-.394 2.344-.938.475-.584.742-1.44.878-2.497Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -1,24 +0,0 @@
<svg viewBox="0 0 44.8 40" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="kong-a" x1="38.204" x2="8.732" y1="18.417" y2="48.543" gradientUnits="userSpaceOnUse">
<stop stop-color="#11A06B" />
<stop offset="1" stop-color="#286FEB" />
</linearGradient>
<linearGradient id="kong-b" x1="38.107" x2="8.635" y1="18.322" y2="48.448" gradientUnits="userSpaceOnUse">
<stop stop-color="#11A06B" />
<stop offset="1" stop-color="#286FEB" />
</linearGradient>
<linearGradient id="kong-c" x1="29.439" x2="-0.033" y1="9.842" y2="39.968" gradientUnits="userSpaceOnUse">
<stop stop-color="#11A06B" />
<stop offset="1" stop-color="#286FEB" />
</linearGradient>
<linearGradient id="kong-d" x1="30.291" x2="0.819" y1="10.676" y2="40.801" gradientUnits="userSpaceOnUse">
<stop stop-color="#11A06B" />
<stop offset="1" stop-color="#286FEB" />
</linearGradient>
</defs>
<path d="m14.7 32.9-1.1 1.4 2.5 3.9-.3 1.8h10.6l.7-1.8-4.2-5.3z" fill="url(#kong-a)" />
<path d="M20.5 9.4 16.7 16l18.6 22-.5 2h8.5l1.5-7.1L24.9 9.4z" fill="url(#kong-b)" />
<path d="m23 4.4-1.8 3.3h4.5l7.7 9.2 4.6-3.8v-2.4l-1.6-2.2 1.2-1.2L28.4 0z" fill="url(#kong-c)" />
<path d="M9.1 22.9H6.6L0 31.3V40h7.1l1.3-1.6 5.5-7.1h7.9l2.4-3.7-8.6-10.2z" fill="url(#kong-d)" />
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -1,3 +0,0 @@
<svg fill="none" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
<path d="M63 0.018v63.535L38.418 42.25v21.303H0V0l63 .018ZM7.723 55.839h22.972V25.323l24.583 21.725V7.729L7.723 7.716v48.123Z" fill="#37C38F" />
</svg>

Before

Width:  |  Height:  |  Size: 226 B

View File

@@ -8,19 +8,12 @@ import {
import ChangelogRenderer from '../components/ChangelogRenderer';
// Mock react-markdown to render children as plain text and a sample
// anchor through the `components.a` override
// Mock react-markdown to just render children as plain text
jest.mock(
'react-markdown',
() =>
function ReactMarkdown({ children, components }: any) {
const Anchor = components?.a;
return (
<div>
{children}
{Anchor && <Anchor href="https://signoz.io/docs">docs</Anchor>}
</div>
);
function ReactMarkdown({ children }: any) {
return <div>{children}</div>;
},
);
@@ -69,14 +62,4 @@ describe('ChangelogRenderer', () => {
expect(screen.getByAltText('Media')).toBeInTheDocument();
expect(screen.getByText('Description for feature 1')).toBeInTheDocument();
});
it('renders markdown links that open in a new tab', () => {
render(<ChangelogRenderer changelog={mockChangelog} />);
const links = screen.getAllByRole('link', { name: 'docs' });
expect(links.length).toBeGreaterThan(0);
links.forEach((link) => {
expect(link).toHaveAttribute('target', '_blank');
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
});
});
});

View File

@@ -13,19 +13,6 @@ interface Props {
changelog: ChangelogSchema;
}
interface LinkProps {
href?: string;
children?: React.ReactNode;
}
function Link({ href, children }: LinkProps): JSX.Element {
return (
<a href={href} target="_blank" rel="noopener noreferrer">
{children}
</a>
);
}
function renderMedia(media: Media): JSX.Element | null {
if (SupportedImageTypes.includes(media.ext)) {
return (
@@ -75,9 +62,7 @@ function ChangelogRenderer({ changelog }: Props): JSX.Element {
<div key={feature.id}>
<div className="changelog-renderer-section-title">{feature.title}</div>
{feature.media && renderMedia(feature.media)}
<ReactMarkdown components={{ a: Link }}>
{feature.description}
</ReactMarkdown>
<ReactMarkdown>{feature.description}</ReactMarkdown>
</div>
))}
</div>
@@ -86,9 +71,7 @@ function ChangelogRenderer({ changelog }: Props): JSX.Element {
<div className="changelog-renderer-bug-fixes">
<div className="changelog-renderer-section-title">Bug Fixes</div>
{changelog.bug_fixes && (
<ReactMarkdown components={{ a: Link }}>
{changelog.bug_fixes}
</ReactMarkdown>
<ReactMarkdown>{changelog.bug_fixes}</ReactMarkdown>
)}
</div>
)}
@@ -96,9 +79,7 @@ function ChangelogRenderer({ changelog }: Props): JSX.Element {
<div className="changelog-renderer-maintenance">
<div className="changelog-renderer-section-title">Maintenance</div>
{changelog.maintenance && (
<ReactMarkdown components={{ a: Link }}>
{changelog.maintenance}
</ReactMarkdown>
<ReactMarkdown>{changelog.maintenance}</ReactMarkdown>
)}
</div>
)}

View File

@@ -0,0 +1,5 @@
.client-side-qb-search {
.ant-select-selection-search {
width: max-content !important;
}
}

View File

@@ -0,0 +1,661 @@
/* eslint-disable sonarjs/cognitive-complexity */
import {
KeyboardEvent,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { Color } from '@signozhq/design-tokens';
import { Select, Tooltip } from 'antd';
import {
OPERATORS,
QUERY_BUILDER_OPERATORS_BY_TYPES,
QUERY_BUILDER_SEARCH_VALUES,
} from 'constants/queryBuilder';
import { CustomTagProps } from 'container/QueryBuilder/filters/QueryBuilderSearch';
import { selectStyle } from 'container/QueryBuilder/filters/QueryBuilderSearch/config';
import { PLACEHOLDER } from 'container/QueryBuilder/filters/QueryBuilderSearch/constant';
import { TypographyText } from 'container/QueryBuilder/filters/QueryBuilderSearch/style';
import {
checkCommaInValue,
getOperatorFromValue,
getOperatorValue,
getTagToken,
isInNInOperator,
} from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
import {
DropdownState,
ITag,
Option,
} from 'container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2';
import Suggestions from 'container/QueryBuilder/filters/QueryBuilderSearchV2/Suggestions';
import { WhereClauseConfig } from 'hooks/queryBuilder/useAutoComplete';
import { validationMapper } from 'hooks/queryBuilder/useIsValidTag';
import { operatorTypeMapper } from 'hooks/queryBuilder/useOperatorType';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { isArray, isEmpty, isEqual, isObject } from 'lodash-es';
import { ChevronDown, ChevronUp } from '@signozhq/icons';
import type { BaseSelectRef } from 'rc-select';
import {
BaseAutocompleteData,
DataTypes,
} from 'types/api/queryBuilder/queryAutocompleteResponse';
import {
IBuilderQuery,
TagFilter,
} from 'types/api/queryBuilder/queryBuilderData';
import { popupContainer } from 'utils/selectPopupContainer';
import { v4 as uuid } from 'uuid';
import './ClientSideQBSearch.styles.scss';
import { Badge } from '@signozhq/ui/badge';
export interface AttributeKey {
key: string;
}
export interface AttributeValuesMap {
[key: string]: AttributeValue;
}
interface ClientSideQBSearchProps {
filters: TagFilter;
onChange: (value: TagFilter) => void;
whereClauseConfig?: WhereClauseConfig;
placeholder?: string;
className?: string;
suffixIcon?: React.ReactNode;
attributeValuesMap?: AttributeValuesMap;
attributeKeys: AttributeKey[];
}
interface AttributeValue {
stringAttributeValues: string[] | [];
numberAttributeValues: number[] | [];
boolAttributeValues: boolean[] | [];
}
function ClientSideQBSearch(
props: ClientSideQBSearchProps,
): React.ReactElement {
const {
onChange,
placeholder,
className,
suffixIcon,
whereClauseConfig,
attributeValuesMap,
attributeKeys,
filters,
} = props;
const isDarkMode = useIsDarkMode();
const selectRef = useRef<BaseSelectRef>(null);
const [isOpen, setIsOpen] = useState<boolean>(false);
// create the tags from the initial query here, this should only be computed on the first load as post that tags and query will be always in sync.
const [tags, setTags] = useState<ITag[]>(filters.items as ITag[]);
// this will maintain the current state of in process filter item
const [currentFilterItem, setCurrentFilterItem] = useState<ITag | undefined>();
const [currentState, setCurrentState] = useState<DropdownState>(
DropdownState.ATTRIBUTE_KEY,
);
// to maintain the current running state until the tokenization happens for the tag
const [searchValue, setSearchValue] = useState<string>('');
const [dropdownOptions, setDropdownOptions] = useState<Option[]>([]);
const attributeValues = useMemo(() => {
if (currentFilterItem?.key?.key) {
return attributeValuesMap?.[currentFilterItem.key.key];
}
return {
stringAttributeValues: [],
numberAttributeValues: [],
boolAttributeValues: [],
};
}, [attributeValuesMap, currentFilterItem?.key?.key]);
const handleDropdownSelect = useCallback(
(value: string) => {
let parsedValue: BaseAutocompleteData | string;
try {
parsedValue = JSON.parse(value);
} catch {
parsedValue = value;
}
if (currentState === DropdownState.ATTRIBUTE_KEY) {
setCurrentFilterItem((prev) => ({
...prev,
key: parsedValue as BaseAutocompleteData,
op: '',
value: '',
}));
setCurrentState(DropdownState.OPERATOR);
setSearchValue((parsedValue as BaseAutocompleteData)?.key);
} else if (currentState === DropdownState.OPERATOR) {
if (value === OPERATORS.EXISTS || value === OPERATORS.NOT_EXISTS) {
setTags((prev) => [
...prev,
{
key: currentFilterItem?.key,
op: value,
value: '',
} as ITag,
]);
setCurrentFilterItem(undefined);
setSearchValue('');
setCurrentState(DropdownState.ATTRIBUTE_KEY);
} else {
setCurrentFilterItem((prev) => ({
key: prev?.key as BaseAutocompleteData,
op: value as string,
value: '',
}));
setCurrentState(DropdownState.ATTRIBUTE_VALUE);
setSearchValue(`${currentFilterItem?.key?.key} ${value}`);
}
} else if (currentState === DropdownState.ATTRIBUTE_VALUE) {
const operatorType =
operatorTypeMapper[currentFilterItem?.op || ''] || 'NOT_VALID';
const isMulti = operatorType === QUERY_BUILDER_SEARCH_VALUES.MULTIPLY;
if (isMulti) {
const { tagKey, tagOperator, tagValue } = getTagToken(searchValue);
// this condition takes care of adding the IN/NIN multi values when pressed enter on an already existing value.
// not the best interaction but in sync with what we have today!
if (tagValue.includes(String(value))) {
setSearchValue('');
setCurrentState(DropdownState.ATTRIBUTE_KEY);
setCurrentFilterItem(undefined);
setTags((prev) => [
...prev,
{
key: currentFilterItem?.key,
op: currentFilterItem?.op,
value: tagValue,
} as ITag,
]);
return;
}
// this is for adding subsequent comma seperated values
const newSearch = [...tagValue];
newSearch[newSearch.length === 0 ? 0 : newSearch.length - 1] = value;
const newSearchValue = newSearch.join(',');
setSearchValue(`${tagKey} ${tagOperator} ${newSearchValue},`);
} else {
setSearchValue('');
setCurrentState(DropdownState.ATTRIBUTE_KEY);
setCurrentFilterItem(undefined);
setTags((prev) => [
...prev,
{
key: currentFilterItem?.key,
op: currentFilterItem?.op,
value,
} as ITag,
]);
}
}
},
[currentFilterItem?.key, currentFilterItem?.op, currentState, searchValue],
);
const handleSearch = useCallback((value: string) => {
setSearchValue(value);
}, []);
const onInputKeyDownHandler = useCallback(
(event: KeyboardEvent<Element>): void => {
if (event.key === 'Backspace' && !searchValue) {
event.stopPropagation();
setTags((prev) => prev.slice(0, -1));
}
},
[searchValue],
);
const handleOnBlur = useCallback((): void => {
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
) {
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
) {
// is exists and not exists operator is present then convert directly to tag! no need of value here
setTags((prev) => [
...prev,
{
key: currentFilterItem?.key,
op: currentFilterItem?.op,
value: '',
},
]);
setCurrentFilterItem(undefined);
setSearchValue('');
setCurrentState(DropdownState.ATTRIBUTE_KEY);
} else if (
// if the current state is in sync with the kind of operator used then convert into a tag
validationMapper[operatorType]?.(
isArray(currentFilterItem?.value)
? currentFilterItem?.value.length || 0
: 1,
)
) {
setTags((prev) => [
...prev,
{
key: currentFilterItem?.key as BaseAutocompleteData,
op: currentFilterItem?.op as string,
value: currentFilterItem?.value || '',
},
]);
setCurrentFilterItem(undefined);
setSearchValue('');
setCurrentState(DropdownState.ATTRIBUTE_KEY);
}
}
}, [
currentFilterItem?.key,
currentFilterItem?.op,
currentFilterItem?.value,
searchValue,
whereClauseConfig?.customKey,
whereClauseConfig?.customOp,
]);
// this useEffect takes care of tokenisation based on the search state
useEffect(() => {
// if there is no search value reset to the default state
if (!searchValue) {
setCurrentFilterItem(undefined);
setCurrentState(DropdownState.ATTRIBUTE_KEY);
}
// split the current search value based on delimiters
const { tagKey, tagOperator, tagValue } = getTagToken(searchValue);
if (
// Case 1 - if key is defined but the search text doesn't match with the set key,
// can happen when user selects from dropdown and then deletes a few characters
currentFilterItem?.key &&
currentFilterItem?.key?.key !== tagKey.split(' ')[0]
) {
setCurrentFilterItem(undefined);
setCurrentState(DropdownState.ATTRIBUTE_KEY);
} else if (tagOperator && isEmpty(currentFilterItem?.op)) {
// Case 2 -> key is set and now typing for the operator
if (
tagOperator === OPERATORS.EXISTS ||
tagOperator === OPERATORS.NOT_EXISTS
) {
setTags((prev) => [
...prev,
{
key: currentFilterItem?.key,
op: tagOperator,
value: '',
} as ITag,
]);
setCurrentFilterItem(undefined);
setSearchValue('');
setCurrentState(DropdownState.ATTRIBUTE_KEY);
} else {
setCurrentFilterItem((prev) => ({
key: prev?.key as BaseAutocompleteData,
op: tagOperator,
value: '',
}));
setCurrentState(DropdownState.ATTRIBUTE_VALUE);
}
} else if (
// Case 3 -> selected operator from dropdown and then erased a part of it
!isEmpty(currentFilterItem?.op) &&
tagOperator !== currentFilterItem?.op
) {
setCurrentFilterItem((prev) => ({
key: prev?.key as BaseAutocompleteData,
op: '',
value: '',
}));
setCurrentState(DropdownState.OPERATOR);
} else if (currentState === DropdownState.ATTRIBUTE_VALUE) {
// Case 4 -> the final value state where we set the current filter values and the tokenisation happens on either
// dropdown click or blur event
const currentValue = {
key: currentFilterItem?.key as BaseAutocompleteData,
op: currentFilterItem?.op as string,
value: tagValue,
};
if (!isEqual(currentValue, currentFilterItem)) {
setCurrentFilterItem((prev) => ({
key: prev?.key as BaseAutocompleteData,
op: prev?.op as string,
value: tagValue,
}));
}
}
}, [
currentFilterItem,
currentFilterItem?.key,
currentFilterItem?.op,
searchValue,
currentState,
]);
// the useEffect takes care of setting the dropdown values correctly on change of the current state
useEffect(() => {
if (currentState === DropdownState.ATTRIBUTE_KEY) {
const filteredAttributeKeys = attributeKeys.filter((key) =>
key.key.startsWith(searchValue),
);
setDropdownOptions(
filteredAttributeKeys?.map(
(key) =>
({
label: key.key,
value: key,
}) as Option,
) || [],
);
}
if (currentState === DropdownState.OPERATOR) {
const keyOperator = searchValue.split(' ');
const partialOperator = keyOperator?.[1];
const strippedKey = keyOperator?.[0];
let operatorOptions;
if (currentFilterItem?.key?.dataType) {
operatorOptions = QUERY_BUILDER_OPERATORS_BY_TYPES[
currentFilterItem.key
.dataType as keyof typeof QUERY_BUILDER_OPERATORS_BY_TYPES
].map((operator) => ({
label: operator,
value: operator,
}));
if (partialOperator) {
operatorOptions = operatorOptions.filter((op) =>
op.label.startsWith(partialOperator.toLocaleUpperCase()),
);
}
setDropdownOptions(operatorOptions);
} else if (strippedKey.endsWith('[*]') && strippedKey.startsWith('body.')) {
operatorOptions = [OPERATORS.HAS, OPERATORS.NHAS].map((operator) => ({
label: operator,
value: operator,
}));
setDropdownOptions(operatorOptions);
} else {
operatorOptions = QUERY_BUILDER_OPERATORS_BY_TYPES.universal.map(
(operator) => ({
label: operator,
value: operator,
}),
);
if (partialOperator) {
operatorOptions = operatorOptions.filter((op) =>
op.label.startsWith(partialOperator.toLocaleUpperCase()),
);
}
setDropdownOptions(operatorOptions);
}
}
if (currentState === DropdownState.ATTRIBUTE_VALUE) {
const values: Array<string | number | boolean> = [];
const { tagValue } = getTagToken(searchValue);
if (isArray(tagValue)) {
if (!isEmpty(tagValue[tagValue.length - 1])) {
values.push(tagValue[tagValue.length - 1]);
}
} else if (!isEmpty(tagValue)) {
values.push(tagValue);
}
const currentAttributeValues =
attributeValues?.stringAttributeValues ||
attributeValues?.numberAttributeValues ||
attributeValues?.boolAttributeValues ||
[];
values.push(...currentAttributeValues);
if (attributeValuesMap) {
setDropdownOptions(
values.map(
(val) =>
({
label: checkCommaInValue(String(val)),
value: val,
}) as Option,
),
);
} else {
// If attributeValuesMap is not provided, don't set dropdown options
setDropdownOptions([]);
}
}
}, [
attributeValues,
currentFilterItem?.key?.dataType,
currentState,
attributeKeys,
searchValue,
attributeValuesMap,
]);
useEffect(() => {
const filterTags: IBuilderQuery['filters'] = {
op: 'AND',
items: [],
};
tags.forEach((tag) => {
const computedTagValue =
tag.value &&
Array.isArray(tag.value) &&
tag.value[tag.value.length - 1] === ''
? tag.value?.slice(0, -1)
: (tag.value ?? '');
filterTags.items.push({
id: tag.id || uuid().slice(0, 8),
key: tag.key,
op: getOperatorValue(tag.op),
value: computedTagValue,
});
});
if (!isEqual(filters, filterTags)) {
onChange(filterTags);
setTags(
filterTags.items.map((tag) => ({
...tag,
op: getOperatorFromValue(tag.op),
})) as ITag[],
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tags]);
const queryTags = useMemo(
() => tags.map((tag) => `${tag.key.key} ${tag.op} ${tag.value}`),
[tags],
);
const onTagRender = ({
value,
closable,
onClose,
}: CustomTagProps): React.ReactElement => {
const { tagOperator } = getTagToken(value);
const isInNin = isInNInOperator(tagOperator);
const chipValue = isInNin
? value?.trim()?.replace(/,\s*$/, '')
: value?.trim();
const indexInQueryTags = queryTags.findIndex((qTag) => isEqual(qTag, value));
const tagDetails = tags[indexInQueryTags];
const onCloseHandler = (): void => {
onClose();
setSearchValue('');
setTags((prev) => prev.filter((t) => !isEqual(t, tagDetails)));
};
const tagEditHandler = (value: string): void => {
setCurrentFilterItem(tagDetails);
setSearchValue(value);
setCurrentState(DropdownState.ATTRIBUTE_VALUE);
setTags((prev) => prev.filter((t) => !isEqual(t, tagDetails)));
};
const isDisabled = !!searchValue;
return (
<span className="qb-search-bar-tokenised-tags">
<Badge
color="vanilla"
className={tagDetails?.key?.type || ''}
closable={!searchValue && closable}
onClose={(e): void => {
e.preventDefault();
onCloseHandler();
}}
>
<Tooltip title={chipValue}>
<TypographyText
$isInNin={isInNin}
$isEnabled={!!searchValue}
$disabled={isDisabled}
onClick={(): void => {
if (!isDisabled) {
tagEditHandler(value);
}
}}
>
{chipValue}
</TypographyText>
</Tooltip>
</Badge>
</span>
);
};
const suffixIconContent = useMemo(() => {
if (suffixIcon) {
return suffixIcon;
}
return isOpen ? (
<ChevronUp
size={14}
color={isDarkMode ? Color.TEXT_VANILLA_100 : Color.TEXT_INK_100}
/>
) : (
<ChevronDown
size={14}
color={isDarkMode ? Color.TEXT_VANILLA_100 : Color.TEXT_INK_100}
/>
);
}, [isDarkMode, isOpen, suffixIcon]);
return (
<div className="query-builder-search-v2 ">
<Select
ref={selectRef}
getPopupContainer={popupContainer}
virtual={false}
showSearch
tagRender={onTagRender}
transitionName=""
choiceTransitionName=""
filterOption={false}
open={isOpen}
suffixIcon={suffixIconContent}
onDropdownVisibleChange={setIsOpen}
autoClearSearchValue={false}
mode="multiple"
placeholder={placeholder}
value={queryTags}
searchValue={searchValue}
className={className}
rootClassName="query-builder-search client-side-qb-search"
disabled={!attributeKeys.length}
style={selectStyle}
onSearch={handleSearch}
onSelect={handleDropdownSelect}
onInputKeyDown={onInputKeyDownHandler}
notFoundContent={null}
showAction={['focus']}
onBlur={handleOnBlur}
>
{dropdownOptions.map((option) => {
let val = option.value;
try {
if (isObject(option.value)) {
val = JSON.stringify(option.value);
} else {
val = option.value;
}
} catch {
val = option.value;
}
return (
<Select.Option key={isObject(val) ? `select-option` : val} value={val}>
<Suggestions
label={option.label}
value={option.value}
option={currentState}
searchValue={searchValue}
/>
</Select.Option>
);
})}
</Select>
</div>
);
}
ClientSideQBSearch.defaultProps = {
placeholder: PLACEHOLDER,
className: '',
suffixIcon: null,
whereClauseConfig: {},
attributeValuesMap: {},
};
export default ClientSideQBSearch;

View File

@@ -19,7 +19,6 @@ export type LogDetailProps = {
onScrollToLog?: (logId: string) => void;
handleOpenInExplorer?: MouseEventHandler;
getContainer?: DrawerProps['getContainer'];
onApplyLogFilter?: (expression: string) => void;
} & Pick<AddToQueryHOCProps, 'onAddToQuery'> &
Partial<Pick<ActionItemProps, 'onClickActionItem'>> &
Pick<DrawerProps, 'onClose'>;

View File

@@ -16,7 +16,6 @@ import {
Link,
} from '@signozhq/icons';
import { useTimezone } from 'providers/Timezone';
import { normalizeTimeToMs } from 'utils/timeUtils';
import { ILog } from 'types/api/logs/log';
import { MouseEvent, MouseEventHandler } from 'react';
import { useCopyToClipboard } from 'react-use';
@@ -68,11 +67,6 @@ function LogDetailsHeader({
},
];
const rawTimestamp = log.date ?? log.timestamp;
const displayTimestamp = Number.isNaN(Number(rawTimestamp))
? rawTimestamp
: normalizeTimeToMs(rawTimestamp);
return (
<div className={styles.header} data-log-detail-ignore="true">
<div className={styles.leftSection}>
@@ -82,7 +76,7 @@ function LogDetailsHeader({
data-testid="log-details-header-timestamp"
>
{formatTimezoneAdjustedTimestamp(
displayTimestamp,
log.date ?? log.timestamp,
DATE_TIME_FORMATS.DASH_DATETIME,
)}
</Typography.Text>

View File

@@ -18,9 +18,10 @@ jest.mock('periscope/components/DataViewer', () => ({
DataViewer: (): JSX.Element => <div data-testid="overview-data-viewer" />,
}));
// Force v2 for these tests regardless of route.
jest.mock('../useIsLogDetailsV2', () => ({
useIsLogDetailsV2: (): boolean => true,
// The flag to be removed later
jest.mock('../constants', () => ({
...jest.requireActual('../constants'),
isLogDetailsV2: true,
}));
const mockLog: ILog = {
@@ -91,24 +92,6 @@ describe('LogDetail drawer — header (isLogDetailsV2)', () => {
);
});
it('normalizes a nanosecond-epoch timestamp in the header', () => {
localStorage.setItem(LOCALSTORAGE.PREFERRED_TIMEZONE, 'UTC');
// Same instant as mockLog but as epoch nanoseconds (e.g. dashboard list panel).
// Must scale to ms, not render a wildly wrong date.
renderDrawer({
log: {
...mockLog,
date: '1705311930000000000',
timestamp: 1705311930000000000,
},
});
expect(screen.getByTestId('log-details-header-timestamp')).toHaveTextContent(
'Jan 15, 2024 ⎯ 09:45:30',
);
});
it('copies the log link from the ⋯ menu', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });

View File

@@ -1,3 +1,10 @@
import getLocalStorage from 'api/browser/localstorage/get';
import { LOCALSTORAGE } from 'constants/localStorage';
// Temp feature flag before actual roll-out
export const isLogDetailsV2 =
getLocalStorage(LOCALSTORAGE.LOG_DETAILS_V2) === 'true';
export const VIEW_TYPES = {
OVERVIEW: 'OVERVIEW',
JSON: 'JSON',

View File

@@ -51,12 +51,11 @@ import { ILogBody } from 'types/api/logs/log';
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource, StringOperators } from 'types/common/queryBuilder';
import { RESOURCE_KEYS, VIEW_TYPES, VIEWS } from './constants';
import { isLogDetailsV2, RESOURCE_KEYS, VIEW_TYPES, VIEWS } from './constants';
import { LogDetailInnerProps, LogDetailProps } from './LogDetail.interfaces';
import LogDetailsHeader from './LogDetailsHeader/LogDetailsHeader';
import { useLogNavigation } from './LogDetailsHeader/useLogNavigation';
import LogHighlights from './LogHighlights/LogHighlights';
import { useIsLogDetailsV2 } from './useIsLogDetailsV2';
import './LogDetails.styles.scss';
@@ -75,7 +74,6 @@ function LogDetailInner({
onScrollToLog,
handleOpenInExplorer,
getContainer,
onApplyLogFilter,
}: LogDetailInnerProps): JSX.Element {
const initialContextQuery = useInitialQuery(log);
const [contextQuery, setContextQuery] = useState<Query | undefined>(
@@ -94,8 +92,6 @@ function LogDetailInner({
const [isEdit, setIsEdit] = useState<boolean>(false);
const { stagedQuery } = useQueryBuilder();
const isLogDetailsV2 = useIsLogDetailsV2();
// Handle clicks outside to close drawer, except on explicitly ignored regions
useEffect(() => {
const handleClickOutside = (e: MouseEvent): void => {
@@ -104,7 +100,6 @@ function LogDetailInner({
// Don't close if clicking on drawer content, overlays, or portal elements
if (
target.closest('[data-log-detail-ignore="true"]') ||
target.closest('.log-detail-drawer') ||
target.closest('.cm-tooltip-autocomplete') ||
target.closest('.drawer-popover') ||
target.closest('.query-status-popover') ||
@@ -520,7 +515,6 @@ function LogDetailInner({
selectedOptions={options}
listViewPanelSelectedFields={listViewPanelSelectedFields}
handleChangeSelectedView={handleChangeSelectedView}
onApplyLogFilter={onApplyLogFilter}
/>
)}
{!isLogDetailsV2 && selectedView === VIEW_TYPES.JSON && (

View File

@@ -1,11 +0,0 @@
import ROUTES from 'constants/routes';
import { useLocation } from 'react-router-dom';
export function useIsLogDetailsV2(): boolean {
const { pathname } = useLocation();
return (
pathname === ROUTES.LOGS_EXPLORER ||
pathname.startsWith(ROUTES.INFRASTRUCTURE_MONITORING_BASE) ||
pathname.startsWith(`${ROUTES.ALL_DASHBOARD}/`)
);
}

View File

@@ -8,7 +8,7 @@ import {
OPERATORS,
QUERY_BUILDER_FUNCTIONS,
} from 'constants/antlrQueryConstants';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearchV2/utils';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
import FilterQueryLexer from 'parser/FilterQueryLexer';
import FilterQueryParser, {
AndExpressionContext,

View File

@@ -5,7 +5,7 @@ import {
QuickFiltersSource,
} from 'components/QuickFilters/types';
import { OPERATORS } from 'constants/antlrQueryConstants';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearchV2/utils';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
import { cloneDeep, isArray } from 'lodash-es';
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
import { v4 as uuid } from 'uuid';

View File

@@ -1,280 +0,0 @@
import { renderHook } from '@testing-library/react';
import {
useRecoverFromEmptyPage,
UseRecoverFromEmptyPageParams,
} from '../useRecoverFromEmptyPage';
const REPLACE = { history: 'replace' };
function renderRecovery(
overrides: Partial<UseRecoverFromEmptyPageParams> = {},
): { setPage: jest.Mock; rerender: (next?: unknown) => void } {
const setPage = jest.fn();
const props: UseRecoverFromEmptyPageParams = {
page: 1,
pageSize: 10,
rowCount: 10,
total: 100,
isFetching: false,
setPage,
...overrides,
};
const { rerender } = renderHook(
(next: UseRecoverFromEmptyPageParams) => useRecoverFromEmptyPage(next),
{ initialProps: props },
);
return {
setPage,
rerender: (next?: unknown): void =>
rerender({ ...props, ...(next as Partial<UseRecoverFromEmptyPageParams>) }),
};
}
describe('useRecoverFromEmptyPage', () => {
it('leaves the page alone while it still holds rows', () => {
const { setPage } = renderRecovery({ page: 3, rowCount: 10 });
expect(setPage).not.toHaveBeenCalled();
});
it('leaves the page alone on page 1 with no rows at all', () => {
const { setPage } = renderRecovery({ page: 1, rowCount: 0, total: 0 });
expect(setPage).not.toHaveBeenCalled();
});
it('jumps to the last page that holds data when the page is out of range', () => {
const { setPage } = renderRecovery({
page: 7,
pageSize: 10,
rowCount: 0,
total: 25,
});
expect(setPage).toHaveBeenCalledWith(3, REPLACE);
});
it('replaces the history entry so the back button does not return to the empty page', () => {
const { setPage } = renderRecovery({ page: 4, rowCount: 0, total: 10 });
expect(setPage).toHaveBeenCalledWith(1, REPLACE);
});
it('falls back to page 1 when the total is unknown', () => {
const { setPage } = renderRecovery({ page: 5, rowCount: 0, total: 0 });
expect(setPage).toHaveBeenCalledWith(1, REPLACE);
});
it('steps back one page when the total claims the page should have data', () => {
// total says 100 rows exist, yet page 5 came back empty — step back rather
// than stall on a page the query cannot actually serve.
const { setPage } = renderRecovery({
page: 5,
pageSize: 10,
rowCount: 0,
total: 100,
});
expect(setPage).toHaveBeenCalledWith(4, REPLACE);
});
it('clamps a page below the first one', () => {
const { setPage } = renderRecovery({ page: 0, rowCount: 10 });
expect(setPage).toHaveBeenCalledWith(1, REPLACE);
});
it('falls back to page 1 when pageSize is zero', () => {
const { setPage } = renderRecovery({
page: 5,
pageSize: 0,
rowCount: 0,
total: 100,
});
expect(setPage).toHaveBeenCalledWith(1, REPLACE);
});
it('waits for the request to settle before moving the user', () => {
const { setPage, rerender } = renderRecovery({
page: 3,
rowCount: 0,
total: 10,
isFetching: true,
});
expect(setPage).not.toHaveBeenCalled();
rerender({ page: 3, rowCount: 0, total: 10, isFetching: false });
expect(setPage).toHaveBeenCalledWith(1, REPLACE);
});
it('clamps a page below the first one even when the query failed', () => {
// A negative offset is what made the request fail (400 "offset cannot be
// negative"), so retrying the same page loops forever — clamp regardless.
const { setPage } = renderRecovery({
page: 0,
rowCount: 0,
total: 0,
isDisabled: true,
});
expect(setPage).toHaveBeenCalledWith(1, REPLACE);
});
it('clamps a page below the first one while the query is still in flight', () => {
const { setPage } = renderRecovery({
page: -2,
rowCount: 0,
isFetching: true,
});
expect(setPage).toHaveBeenCalledWith(1, REPLACE);
});
it('keeps the page when the query failed so a retry lands where the user was', () => {
const { setPage } = renderRecovery({
page: 3,
rowCount: 0,
total: 0,
isDisabled: true,
});
expect(setPage).not.toHaveBeenCalled();
});
it('clamps a page below the first one exactly once while the request settles', () => {
// The clamp runs ahead of both gates, so a request settling underneath an
// uncorrected page must not re-issue the same history rewrite.
const { setPage, rerender } = renderRecovery({
page: 0,
rowCount: 0,
total: 0,
isFetching: true,
});
expect(setPage).toHaveBeenCalledTimes(1);
rerender({ page: 0, rowCount: 0, total: 0, isFetching: false });
expect(setPage).toHaveBeenCalledTimes(1);
});
it('stops correcting once the corrected page comes back with rows', () => {
const { setPage, rerender } = renderRecovery({
page: 7,
pageSize: 10,
rowCount: 0,
total: 25,
});
expect(setPage).toHaveBeenCalledWith(3, REPLACE);
// The correction lands: the query refetches, then resolves with the rows page 3 holds.
rerender({ page: 3, pageSize: 10, rowCount: 0, total: 25, isFetching: true });
rerender({
page: 3,
pageSize: 10,
rowCount: 5,
total: 25,
isFetching: false,
});
expect(setPage).toHaveBeenCalledTimes(1);
});
it('does not correct again while the same page is still being observed', () => {
const { setPage, rerender } = renderRecovery({
page: 5,
pageSize: 10,
rowCount: 0,
total: 100,
});
expect(setPage).toHaveBeenCalledTimes(1);
// A refetch cycle that leaves the page untouched — the correction is already in flight.
rerender({
page: 5,
pageSize: 10,
rowCount: 0,
total: 100,
isFetching: true,
});
rerender({
page: 5,
pageSize: 10,
rowCount: 0,
total: 100,
isFetching: false,
});
expect(setPage).toHaveBeenCalledTimes(1);
});
it('gives up on step-backs and jumps to page 1 when the total keeps lying', () => {
// `total` claims 400 rows exist, but every page comes back empty. Walking back one
// page at a time would cost a request per hop, so bail out to page 1 instead.
const { setPage, rerender } = renderRecovery({
page: 40,
pageSize: 10,
rowCount: 0,
total: 400,
});
expect(setPage).toHaveBeenNthCalledWith(1, 39, REPLACE);
rerender({ page: 39, pageSize: 10, rowCount: 0, total: 400 });
expect(setPage).toHaveBeenNthCalledWith(2, 38, REPLACE);
rerender({ page: 38, pageSize: 10, rowCount: 0, total: 400 });
expect(setPage).toHaveBeenNthCalledWith(3, 1, REPLACE);
expect(setPage).toHaveBeenCalledTimes(3);
});
it('corrects again when the user returns to a page that is still empty', () => {
const { setPage, rerender } = renderRecovery({
page: 3,
pageSize: 10,
rowCount: 0,
total: 10,
});
expect(setPage).toHaveBeenNthCalledWith(1, 1, REPLACE);
rerender({ page: 1, pageSize: 10, rowCount: 10, total: 10 });
rerender({ page: 3, pageSize: 10, rowCount: 0, total: 10 });
expect(setPage).toHaveBeenNthCalledWith(2, 1, REPLACE);
});
it('does not re-run the correction when setPage is a fresh function each render', () => {
// The hook reads setPage through a ref, so an inline arrow must not turn the
// ungated `page < 1` clamp into a per-render history rewrite.
const setPage = jest.fn();
const { rerender } = renderHook(
() =>
useRecoverFromEmptyPage({
page: 0,
pageSize: 10,
rowCount: 0,
total: 0,
isFetching: false,
setPage: (nextPage, options): void => setPage(nextPage, options),
}),
{ initialProps: undefined },
);
rerender(undefined);
rerender(undefined);
expect(setPage).toHaveBeenCalledTimes(1);
});
});

View File

@@ -1,91 +0,0 @@
import { renderHook } from '@testing-library/react';
import {
useStableTotalCount,
UseStableTotalCountParams,
} from '../useStableTotalCount';
function renderStableTotalCount(initial: UseStableTotalCountParams): {
result: { current: number };
rerender: (next: UseStableTotalCountParams) => void;
} {
const { result, rerender } = renderHook(
(params: UseStableTotalCountParams) => useStableTotalCount(params),
{ initialProps: initial },
);
return { result, rerender };
}
describe('useStableTotalCount', () => {
it('reports the total once the load settles', () => {
const { result } = renderStableTotalCount({
total: 100,
isLoading: false,
resetKey: 'pods',
});
expect(result.current).toBe(100);
});
it('keeps the last count while the same list refetches', () => {
const { result, rerender } = renderStableTotalCount({
total: 100,
isLoading: false,
resetKey: 'pods',
});
rerender({ total: 0, isLoading: true, resetKey: 'pods' });
expect(result.current).toBe(100);
});
it('forgets the count when the list changes', () => {
const { result, rerender } = renderStableTotalCount({
total: 100,
isLoading: false,
resetKey: 'pods',
});
rerender({ total: 0, isLoading: true, resetKey: 'nodes' });
expect(result.current).toBe(0);
});
it('reports the new list total once it arrives', () => {
const { result, rerender } = renderStableTotalCount({
total: 100,
isLoading: false,
resetKey: 'pods',
});
rerender({ total: 0, isLoading: true, resetKey: 'nodes' });
rerender({ total: 25, isLoading: false, resetKey: 'nodes' });
expect(result.current).toBe(25);
});
it('reports zero for a list that is genuinely empty', () => {
const { result } = renderStableTotalCount({
total: 0,
isLoading: false,
resetKey: 'pods',
});
expect(result.current).toBe(0);
});
it('holds nothing back when no reset key is given', () => {
const { result, rerender } = renderStableTotalCount({
total: 100,
isLoading: false,
resetKey: undefined,
});
// Without a key there is no context to compare, so the cache behaves as it
// always did: it survives the refetch.
rerender({ total: 0, isLoading: true, resetKey: undefined });
expect(result.current).toBe(100);
});
});

View File

@@ -9,7 +9,6 @@ export * from './useCalculatedPageSize';
export * from './useColumnState';
export * from './useColumnStore';
export * from './usePreferredPageSize.store';
export * from './useRecoverFromEmptyPage';
export * from './useTableParams';
/**
@@ -286,51 +285,6 @@ export * from './useTableParams';
*
* **Pagination shows "Auto" option** when `calculatedPageSize` is passed, allowing users
* to reset to auto-calculated size.
*
* **`setPage` accepts history options**: `setPage(page, { history: 'replace' })` rewrites the
* current history entry instead of pushing a new one. Use `replace` for corrections the user
* did not ask for — otherwise the back button walks straight back into the state that was just
* corrected. Only applies when the page is synced to the URL; local (non-URL) pages ignore it.
*
* @example useRecoverFromEmptyPage — send the user back to a page that has data
*
* When rows disappear underneath the current page (filters narrowed, time range moved, items
* deleted), the user is stranded on an empty page they cannot leave by scrolling. This hook
* watches the fetched result and corrects the page with `history: 'replace'`, so the back
* button does not return to the empty page.
*
* Correction rules:
* - `page < 1` → jump to page 1, even while fetching or disabled. Such a page usually maps to a
* negative offset the API rejects (400 `offset cannot be negative`), so the response can never
* confirm the page is empty — deferring to it would strand the user on a permanent error.
* - Page is empty and not page 1 → go to `min(ceil(total / pageSize), page - 1)`. When `total`
* is trustworthy that lands on the last page holding data; when `total` is unknown or zero it
* lands on page 1; and when `total` claims this page should have had rows it steps back a
* single page. Repeated step-backs give up and jump to page 1 after the second one, so a
* badly inflated `total` cannot walk the user down one request at a time.
* - Page has rows, or the user is already on page 1 → do nothing (an empty page 1 means there
* is genuinely nothing to show).
*
* Pass `isFetching` so the hook waits for the request to settle, and `isDisabled` so a failed
* request is not mistaken for an empty page. Neither gate suppresses the `page < 1` clamp.
*
* ```tsx
* import { useRecoverFromEmptyPage, useTableParams } from 'components/TanStackTableView';
*
* const { page, limit, setPage } = useTableParams(QUERY_PARAMS, { page: 1, limit: 20 });
* const { data, isLoading, isFetching, isError } = useListQuery({ page, limit });
*
* useRecoverFromEmptyPage({
* page,
* pageSize: limit,
* rowCount: data?.rows.length ?? 0,
* total: data?.total ?? 0,
* isFetching: isLoading || isFetching,
* // Skip correction on errors — no rows there means "request failed", not "page is empty".
* isDisabled: isError,
* setPage,
* });
* ```
*/
const TanStackTable = Object.assign(TanStackTableBase, {
Text: TanStackTableText,

View File

@@ -1,107 +0,0 @@
import { useEffect, useRef } from 'react';
import { SetPageOptions } from './useTableParams';
const FIRST_PAGE = 1;
const REPLACE_HISTORY: SetPageOptions = { history: 'replace' };
/**
* How many single-page step-backs to attempt before giving up and going to page 1.
*
* A step-back only happens when `total` claims the current page should hold data but the
* response came back empty. Each hop costs a request, so an inflated `total` on a high page
* number would otherwise walk the user down one page at a time behind a spinner.
*/
const MAX_STEP_BACKS = 2;
type Correction = {
from: number;
to: number;
};
export type UseRecoverFromEmptyPageParams = {
page: number;
pageSize: number;
rowCount: number;
total: number;
isFetching: boolean;
isDisabled?: boolean;
setPage: (page: number, options?: SetPageOptions) => void;
};
export function useRecoverFromEmptyPage({
page,
pageSize,
rowCount,
total,
isFetching,
isDisabled = false,
setPage,
}: UseRecoverFromEmptyPageParams): void {
const setPageRef = useRef(setPage);
const lastCorrectionRef = useRef<Correction | null>(null);
const stepBacksRef = useRef(0);
useEffect(() => {
setPageRef.current = setPage;
});
useEffect(() => {
if (lastCorrectionRef.current && lastCorrectionRef.current.from !== page) {
lastCorrectionRef.current = null;
}
const correctTo = (nextPage: number): boolean => {
if (lastCorrectionRef.current?.to === nextPage) {
return false;
}
lastCorrectionRef.current = { from: page, to: nextPage };
setPageRef.current(nextPage, REPLACE_HISTORY);
return true;
};
// A page below the first one is invalid on its own terms — it usually maps to a
// negative offset the API rejects outright, so waiting for a response that will
// never arrive (or trusting a failed one) would strand the user for good.
if (page < FIRST_PAGE) {
stepBacksRef.current = 0;
void correctTo(FIRST_PAGE);
return;
}
if (isFetching || isDisabled) {
return;
}
// The page has data, or there is genuinely nothing to show anywhere.
if (rowCount > 0 || page === FIRST_PAGE) {
stepBacksRef.current = 0;
return;
}
const currentPage = Math.floor(page);
const lastPageWithData =
pageSize > 0 && total > 0 ? Math.ceil(total / pageSize) : FIRST_PAGE;
const nextPage = Math.max(
FIRST_PAGE,
Math.min(lastPageWithData, currentPage - 1),
);
// `total` disagrees with the response: it says this page should have rows, so the
// only safe move is one page back. Cap how often that repeats — every hop is a
// request, and a badly inflated `total` would otherwise crawl down from page 40.
const isStepBack = nextPage === currentPage - 1;
if (isStepBack && stepBacksRef.current >= MAX_STEP_BACKS) {
if (correctTo(FIRST_PAGE)) {
stepBacksRef.current = 0;
}
return;
}
if (correctTo(nextPage) && isStepBack) {
stepBacksRef.current += 1;
}
}, [isFetching, isDisabled, page, pageSize, rowCount, total]);
}

View File

@@ -1,35 +0,0 @@
import { useRef } from 'react';
export type UseStableTotalCountParams = {
total: number | undefined;
isLoading: boolean;
/**
* Identifies the list being counted. When it changes, the cached count is
* dropped so the previous list's page count cannot outlive it.
*/
resetKey: string | undefined;
};
/**
* Holds on to the last non-zero total so the pagination does not flash while the
* same list refetches, and forgets it as soon as `resetKey` moves to another list.
*/
export function useStableTotalCount({
total,
isLoading,
resetKey,
}: UseStableTotalCountParams): number {
const prevTotalRef = useRef(total || 0);
const prevResetKeyRef = useRef(resetKey);
if (prevResetKeyRef.current !== resetKey) {
prevResetKeyRef.current = resetKey;
prevTotalRef.current = 0;
}
if (total && total > 0) {
prevTotalRef.current = total;
}
return isLoading ? prevTotalRef.current : total || 0;
}

View File

@@ -29,16 +29,12 @@ type Defaults = {
cleanupOnUnmount?: boolean;
};
export type SetPageOptions = {
history?: 'push' | 'replace';
};
export type TableParamsResult = {
page: number;
limit: number;
orderBy: SortState | null;
expanded: ExpandedState;
setPage: (p: number, options?: SetPageOptions) => void;
setPage: (p: number) => void;
setLimit: (l: number) => void;
setOrderBy: (s: SortState | null) => void;
setExpanded: (updaterOrValue: Updater<ExpandedState>) => void;
@@ -253,17 +249,6 @@ export function useTableParams(
[],
);
const setUrlPageWithOptions = useCallback(
(page: number, options?: SetPageOptions): void => {
void setUrlPage(page, options);
},
[setUrlPage],
);
const setLocalPageValue = useCallback((page: number): void => {
setLocalPage(page);
}, []);
const orderByUrlMemoKey = `${urlOrderBy?.columnName}${urlOrderBy?.order}`;
const prevOrderByRef = useRef<string | null>(null);
@@ -318,7 +303,7 @@ export function useTableParams(
limit: useUrlForLimit ? urlLimit : localLimit,
orderBy: (useUrlForOrderBy ? urlOrderBy : localOrderBy) as SortState | null,
expanded: useUrlForExpanded ? urlExpanded : localExpanded,
setPage: useUrlForPage ? setUrlPageWithOptions : setLocalPageValue,
setPage: useUrlForPage ? setUrlPage : setLocalPage,
setLimit: useUrlForLimit ? setUrlLimit : setLocalLimitWithPersist,
setOrderBy: useUrlForOrderBy ? setUrlOrderBy : setLocalOrderBy,
setExpanded: useUrlForExpanded ? setUrlExpanded : handleSetLocalExpanded,

View File

@@ -13,6 +13,7 @@ export enum LOCALSTORAGE {
TRACES_LIST_COLUMNS = 'TRACES_LIST_COLUMNS',
LOGS_LIST_COLUMNS = 'LOGS_LIST_COLUMNS',
LOGS_LIST_COLUMN_SIZING = 'LOGS_LIST_COLUMN_SIZING',
LOG_DETAILS_V2 = 'LOG_DETAILS_V2',
LOGGED_IN_USER_NAME = 'LOGGED_IN_USER_NAME',
LOGGED_IN_USER_EMAIL = 'LOGGED_IN_USER_EMAIL',
CHAT_SUPPORT = 'CHAT_SUPPORT',

View File

@@ -2,7 +2,7 @@ import { memo, useMemo } from 'react';
import { Select, Spin } from 'antd';
import { OrderByFilterProps } from 'container/QueryBuilder/filters/OrderByFilter/OrderByFilter.interfaces';
import { useOrderByFilter } from 'container/QueryBuilder/filters/OrderByFilter/useOrderByFilter';
import { selectStyle } from 'container/QueryBuilder/filters/QueryBuilderSearchV2/config';
import { selectStyle } from 'container/QueryBuilder/filters/QueryBuilderSearch/config';
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { StringOperators } from 'types/common/queryBuilder';

View File

@@ -1,91 +0,0 @@
import { renderHook } from '@testing-library/react';
import { PANEL_TYPES } from 'constants/queryBuilder';
import MockQueryClientProvider from 'providers/test/MockQueryClientProvider';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import useUpdatedQuery from '../useResolveQuery';
const mockGetSubstituteVars = jest.fn();
const mockDynamicVariables: unknown[] = [];
jest.mock('api/dashboard/substitute_vars', () => ({
getSubstituteVars: (...args: unknown[]): unknown =>
mockGetSubstituteVars(...args),
}));
jest.mock('api/v5/v5', () => ({
prepareQueryRangePayloadV5: (): { queryPayload: unknown } => ({
queryPayload: { start: 0, end: 1 },
}),
}));
jest.mock(
'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi',
() => ({
mapQueryDataFromApi: (): Query => ({ resolved: true }) as unknown as Query,
}),
);
jest.mock('hooks/dashboard/useDashboardVariablesByType', () => ({
useDashboardVariablesByType: (): unknown[] => mockDynamicVariables,
}));
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
useSelector: (): unknown => ({
selectedTime: 'GLOBAL_TIME',
}),
}));
const QUERY = { builder: { queryData: [] } } as unknown as Query;
const WIDGET_CONFIG = {
query: QUERY,
panelTypes: PANEL_TYPES.TIME_SERIES,
timePreferance: 'GLOBAL_TIME' as const,
};
describe('useResolveQuery', () => {
beforeEach(() => {
jest.clearAllMocks();
mockDynamicVariables.length = 0;
});
it('skips the substitute_vars round-trip when there are no variables', async () => {
const { result } = renderHook(() => useUpdatedQuery(), {
wrapper: MockQueryClientProvider,
});
const resolved = await result.current.getUpdatedQuery({
widgetConfig: WIDGET_CONFIG,
});
expect(mockGetSubstituteVars).not.toHaveBeenCalled();
expect(resolved).toBe(QUERY);
});
it('resolves through substitute_vars when the dashboard has variables', async () => {
mockGetSubstituteVars.mockResolvedValue({
httpStatusCode: 200,
data: { compositeQuery: {} },
});
const { result } = renderHook(() => useUpdatedQuery(), {
wrapper: MockQueryClientProvider,
});
const resolved = await result.current.getUpdatedQuery({
widgetConfig: WIDGET_CONFIG,
dashboardData: {
data: {
variables: {
env: { name: 'env', selectedValue: 'prod' },
},
},
},
});
expect(mockGetSubstituteVars).toHaveBeenCalledTimes(1);
expect(resolved).toStrictEqual({ resolved: true });
});
});

View File

@@ -2,7 +2,6 @@ import { useCallback } from 'react';
import { useMutation } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { isEmpty } from 'lodash-es';
import { getSubstituteVars } from 'api/dashboard/substitute_vars';
import { prepareQueryRangePayloadV5 } from 'api/v5/v5';
import { PANEL_TYPES } from 'constants/queryBuilder';
@@ -47,21 +46,13 @@ function useUpdatedQuery(): UseUpdatedQueryResult {
widgetConfig,
dashboardData,
}: UseUpdatedQueryOptions): Promise<Query> => {
const variables = getDashboardVariables(dashboardData?.data?.variables);
// `/substitute_vars` only rewrites `$variable` references, so on surfaces with no
// dashboard behind them (APM, Celery, API monitoring) the round-trip is a no-op.
if (isEmpty(variables) && isEmpty(dashboardDynamicVariables)) {
return widgetConfig.query;
}
// Prepare query payload with resolved variables
const { queryPayload } = prepareQueryRangePayloadV5({
query: widgetConfig.query,
graphType: getGraphType(widgetConfig.panelTypes),
selectedTime: widgetConfig.timePreferance,
globalSelectedInterval,
variables,
variables: getDashboardVariables(dashboardData?.data?.variables),
originalGraphType: widgetConfig.panelTypes,
dynamicVariables: dashboardDynamicVariables,
});

View File

@@ -7,7 +7,6 @@ import TanStackTable, {
TableColumnDef,
useCalculatedPageSize,
useHiddenColumnIds,
useRecoverFromEmptyPage,
useTableParams,
} from 'components/TanStackTableView';
import { InfraMonitoringEvents } from 'constants/events';
@@ -137,7 +136,6 @@ export function K8sBaseList<
page: currentPage,
limit: currentPageSize,
setLimit,
setPage,
} = useTableParams(
{
page: INFRA_MONITORING_K8S_PARAMS_KEYS.PAGE,
@@ -245,16 +243,6 @@ export function K8sBaseList<
const totalCount = data?.total || 0;
const hasFilters = !!expression?.trim();
useRecoverFromEmptyPage({
page: currentPage,
pageSize: currentPageSize,
rowCount: pageData.length,
total: totalCount,
isFetching: isLoading || isFetching,
isDisabled: isError || Boolean(data?.error),
setPage,
});
const getGroupKeyFn = useCallback(
(item: T) => getGroupedByMeta(item, groupBy),
[groupBy],

View File

@@ -37,7 +37,7 @@ import { useInfraMonitoringFontSize } from './useInfraMonitoringTablePreferences
import styles from './K8sExpandedRow.module.scss';
import { buildExpressionFromGroupMeta } from './utils';
import { logInfraColumnSortedEvent } from 'container/InfraMonitoringK8sV2/Base/events';
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
import { getUnstableCurrentSearchParams } from 'container/TopNav/DateTimeSelectionV2/utils/getUnstableCurrentSearchParams';
import { QueryParams } from 'constants/query';
const EXPANDED_ROW_LIMIT = 10;

View File

@@ -591,14 +591,12 @@ describe('K8sBaseList', () => {
});
describe('with empty data', () => {
const onUrlUpdateMock = jest.fn<void, [UrlUpdateEvent]>();
const fetchListDataMock = jest.fn<
ReturnType<NonNullable<K8sBaseListProps<TestItem>['fetchListData']>>,
Parameters<NonNullable<K8sBaseListProps<TestItem>['fetchListData']>>
>();
beforeEach(() => {
onUrlUpdateMock.mockClear();
fetchListDataMock.mockClear();
fetchListDataMock.mockResolvedValue({
data: [],
@@ -607,7 +605,6 @@ describe('K8sBaseList', () => {
});
renderComponent<TestItem>({
onUrlUpdate: onUrlUpdateMock,
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
@@ -628,177 +625,6 @@ describe('K8sBaseList', () => {
expect(fetchListDataMock).toHaveBeenCalled();
});
});
it('should not rewrite the page when already on the first page', async () => {
await waitFor(() => {
expect(fetchListDataMock).toHaveBeenCalled();
});
const pageUpdates = onUrlUpdateMock.mock.calls
.map((call) => call[0].searchParams.get('page'))
.filter(Boolean);
expect(pageUpdates).toHaveLength(0);
});
});
describe('with a page beyond the end of the list', () => {
const onUrlUpdateMock = jest.fn<void, [UrlUpdateEvent]>();
const fetchListDataMock = jest.fn<
ReturnType<NonNullable<K8sBaseListProps<TestItem>['fetchListData']>>,
Parameters<NonNullable<K8sBaseListProps<TestItem>['fetchListData']>>
>();
// 25 rows exist, so pages 1-3 serve data and page 7 of 10 comes back empty.
const rows: TestItem[] = Array.from({ length: 25 }, (_, index) => ({
id: `pod-${index + 1}`,
}));
beforeEach(() => {
onUrlUpdateMock.mockClear();
fetchListDataMock.mockClear();
// Offset-aware on purpose: a mock that answers empty for every offset would let
// the assertions pass against a page the recovery has already moved on from.
fetchListDataMock.mockImplementation(async ({ offset = 0, limit = 10 }) => ({
data: rows.slice(offset, offset + limit),
total: rows.length,
error: null,
}));
renderComponent<TestItem>({
onUrlUpdate: onUrlUpdateMock,
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
queryParams: { page: '7', pageSize: '10' },
tableColumns: createTestColumns(),
getRowKey: (row): string => row.id,
getItemKey: (row): string => row.id,
});
});
it('should send the user back to the last page holding data', async () => {
// The rows of page 3 on screen are what proves the recovery settled there,
// rather than passing through on its way somewhere else.
await expect(screen.findByText('pod-21')).resolves.toBeInTheDocument();
const pageUpdates = onUrlUpdateMock.mock.calls
.map((call) => call[0].searchParams.get('page'))
.filter(Boolean);
expect(pageUpdates).toStrictEqual(['3']);
});
it('should correct the page in a single hop', async () => {
await expect(screen.findByText('pod-21')).resolves.toBeInTheDocument();
// Only the original out-of-range page and the corrected one are requested.
expect(
fetchListDataMock.mock.calls.map((call) => call[0].offset),
).toStrictEqual([60, 20]);
});
it('should replace the history entry instead of pushing the correction', async () => {
await expect(screen.findByText('pod-21')).resolves.toBeInTheDocument();
const pageCorrection = onUrlUpdateMock.mock.calls.find(
(call) => call[0].searchParams.get('page') === '3',
);
expect(pageCorrection?.[0].options.history).toBe('replace');
});
});
describe('with a page below the first one', () => {
const onUrlUpdateMock = jest.fn<void, [UrlUpdateEvent]>();
const fetchListDataMock = jest.fn<
ReturnType<NonNullable<K8sBaseListProps<TestItem>['fetchListData']>>,
Parameters<NonNullable<K8sBaseListProps<TestItem>['fetchListData']>>
>();
beforeEach(() => {
onUrlUpdateMock.mockClear();
fetchListDataMock.mockClear();
// page=0 turns into offset=-10, which the API rejects outright — the list
// can only recover by clamping the page, never by reading the response.
fetchListDataMock.mockImplementation(async ({ offset = 0 }) => {
if (offset < 0) {
throw new APIError({
httpStatusCode: 400,
error: {
code: 'invalid_input',
message: 'offset cannot be negative',
url: '',
errors: [],
},
});
}
return { data: [{ id: 'pod-1' }], total: 1, error: null };
});
renderComponent<TestItem>({
onUrlUpdate: onUrlUpdateMock,
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
queryParams: { page: '0', pageSize: '10' },
tableColumns: createTestColumns(),
getRowKey: (row): string => row.id,
getItemKey: (row): string => row.id,
});
});
it('should reject the request that carried the negative offset', async () => {
await waitFor(() => {
expect(
fetchListDataMock.mock.calls.some((call) => call[0].offset === -10),
).toBe(true);
});
await expect(
fetchListDataMock.mock.results[0].value as Promise<unknown>,
).rejects.toThrow('offset cannot be negative');
});
it('should clamp the page to the first one even though the request failed', async () => {
await waitFor(() => {
expect(onUrlUpdateMock).toHaveBeenCalled();
});
// Page 1 is the default, so the correction drops the param rather than
// writing `page=1`.
const pageCorrection = onUrlUpdateMock.mock.calls.find(
(call) => call[0].searchParams.get('page') === null,
);
expect(pageCorrection).toBeDefined();
expect(pageCorrection?.[0].queryString).toBe('?pageSize=10');
});
it('should replace the history entry instead of pushing the correction', async () => {
await waitFor(() => {
expect(onUrlUpdateMock).toHaveBeenCalled();
});
const pageCorrection = onUrlUpdateMock.mock.calls.find(
(call) => call[0].searchParams.get('page') === null,
);
expect(pageCorrection?.[0].options.history).toBe('replace');
});
it('should refetch with a non-negative offset after clamping', async () => {
await waitFor(() => {
expect(
fetchListDataMock.mock.calls.some((call) => call[0].offset === 0),
).toBe(true);
});
await waitFor(() => {
expect(screen.getByText('pod-1')).toBeInTheDocument();
});
});
});
describe('with error response', () => {

View File

@@ -82,7 +82,6 @@ function EntityLogsContent({
const { activeLog, selectedTab, handleSetActiveLog, handleCloseLogDetail } =
useLogDetailHandlers();
// TODO: Move away from using onAddToQuery after old drawer cleanup
const onAddToQuery = useCallback(
(fieldKey: string, fieldValue: string, operator: string): void => {
handleCloseLogDetail();
@@ -105,21 +104,6 @@ function EntityLogsContent({
[userExpression, querySearchOnRun, handleCloseLogDetail, category],
);
const onApplyLogFilter = useCallback(
(expression: string): void => {
handleCloseLogDetail();
const newUser = userExpression.trim()
? `${userExpression} AND ${expression}`
: expression;
querySearchOnRun(newUser);
logInfraDrawerFilterCustomizedEvent(category, 'logs', newUser, 'logs');
},
[userExpression, querySearchOnRun, handleCloseLogDetail, category],
);
const {
logs,
loadMoreLogs,
@@ -344,7 +328,6 @@ function EntityLogsContent({
selectedTab={selectedTab}
onAddToQuery={onAddToQuery}
onClickActionItem={onAddToQuery}
onApplyLogFilter={onApplyLogFilter}
onScrollToLog={handleScrollToLog}
handleOpenInExplorer={(e) => handleOpenInExplorer(e, activeLog)}
getContainer={(): HTMLElement =>

View File

@@ -47,7 +47,6 @@ import {
useInfraMonitoringCategory,
useInfraMonitoringGroupBy,
useInfraMonitoringOrderBy,
useInfraMonitoringPageListing,
useInfraMonitoringSelectedItemParams,
} from './hooks';
@@ -68,7 +67,6 @@ export default function InfraMonitoringK8s(): JSX.Element {
const [, setGroupBy] = useInfraMonitoringGroupBy();
const [, setOrderBy] = useInfraMonitoringOrderBy();
const [, setSelectedItemParams] = useInfraMonitoringSelectedItemParams();
const [, setCurrentPage] = useInfraMonitoringPageListing();
const compositeQuery = useGetCompositeQueryParam();
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
@@ -220,7 +218,6 @@ export default function InfraMonitoringK8s(): JSX.Element {
void setSelectedCategory(key as string);
void setOrderBy(null);
void setGroupBy(null);
void setCurrentPage(null);
setSelectedItemParams(null);
redirectWithQueryBuilderData({
...currentQuery,

View File

@@ -1,128 +0,0 @@
import React from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
import { MemoryRouter as MemoryRouterV5 } from 'react-router-dom-v5-compat';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { NuqsTestingAdapter, UrlUpdateEvent } from 'nuqs/adapters/testing';
import { AppProvider } from 'providers/App/App';
import { QueryBuilderProvider } from 'providers/QueryBuilder';
import TimezoneProvider from 'providers/Timezone';
import store from 'store';
import { K8sCategories } from '../constants';
import InfraMonitoringK8s from '../InfraMonitoringK8s';
// Quick filters fire their own field APIs and are irrelevant to pagination.
jest.mock('components/QuickFilters/QuickFilters', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="quick-filters" />,
}));
// The list owns its own page recovery; stubbing it keeps the page param under the
// sole control of the category handler being tested here.
jest.mock('../Base/K8sDynamicList', () => ({
__esModule: true,
K8sDynamicList: (): JSX.Element => <div data-testid="k8s-dynamic-list" />,
default: (): JSX.Element => <div data-testid="k8s-dynamic-list" />,
}));
// Analytics only; jsdom lacks the Performance navigation entries it reads.
jest.mock('lib/navigation', () => ({
getNavigationReferrer: (): string => 'direct',
}));
function renderPage(
queryParams: Record<string, string>,
onUrlUpdate: jest.Mock<void, [UrlUpdateEvent]>,
): void {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
render(
<MemoryRouter>
<MemoryRouterV5>
<TimezoneProvider>
<QueryClientProvider client={queryClient}>
<AppProvider>
<Provider store={store}>
<NuqsTestingAdapter
searchParams={queryParams}
onUrlUpdate={onUrlUpdate}
>
<TooltipProvider>
<QueryBuilderProvider>
<InfraMonitoringK8s />
</QueryBuilderProvider>
</TooltipProvider>
</NuqsTestingAdapter>
</Provider>
</AppProvider>
</QueryClientProvider>
</TimezoneProvider>
</MemoryRouterV5>
</MemoryRouter>,
);
}
describe('InfraMonitoringK8s', () => {
describe('when the category changes from a page other than the first', () => {
const onUrlUpdateMock = jest.fn<void, [UrlUpdateEvent]>();
beforeEach(async () => {
onUrlUpdateMock.mockClear();
renderPage(
{ category: K8sCategories.PODS, page: '3', pageSize: '10' },
onUrlUpdateMock,
);
await screen.findByTestId(`category-${K8sCategories.NODES}`);
});
it('should drop the page so the new category starts at the first one', async () => {
fireEvent.click(screen.getByTestId(`category-${K8sCategories.NODES}`));
// Page 3 of pods says nothing about nodes — keeping it asks the new entity
// for an offset it may not have. The param is cleared rather than set to 1,
// since an absent page already means the first one.
await waitFor(() => {
const categorySwitch = onUrlUpdateMock.mock.calls.find(
(call) => call[0].searchParams.get('category') === K8sCategories.NODES,
);
expect(categorySwitch).toBeDefined();
expect(categorySwitch?.[0].searchParams.get('page')).toBeNull();
});
});
it('should keep the page size, which is not category specific', async () => {
fireEvent.click(screen.getByTestId(`category-${K8sCategories.NODES}`));
await waitFor(() => {
const categorySwitch = onUrlUpdateMock.mock.calls.find(
(call) => call[0].searchParams.get('category') === K8sCategories.NODES,
);
expect(categorySwitch?.[0].searchParams.get('pageSize')).toBe('10');
});
});
it('should leave the page alone when the same category is clicked again', async () => {
fireEvent.click(screen.getByTestId(`category-${K8sCategories.PODS}`));
await waitFor(() => {
expect(screen.getByTestId('k8s-dynamic-list')).toBeInTheDocument();
});
const droppedPage = onUrlUpdateMock.mock.calls.some(
(call) => !call[0].searchParams.has('page'),
);
expect(droppedPage).toBe(false);
});
});
});

View File

@@ -34,9 +34,6 @@ export const useInfraMonitoringPageListing = (): UseQueryStateReturn<
> =>
useQueryState(
INFRA_MONITORING_K8S_PARAMS_KEYS.PAGE,
// do not use .withDefault here, this can cause bugs when
// two hooks of nuqs define default twice, this is also
// defined at useTableParams
parseAsInteger.withOptions(defaultNuqsOptions),
);

View File

@@ -9,11 +9,7 @@ function Overview(): JSX.Element {
return (
<div className={styles.overview} data-testid="llm-observability-overview">
<DashboardContainer
dashboard={dashboard}
refetch={refetch}
canEditDashboardOverride={false}
/>
<DashboardContainer dashboard={dashboard} refetch={refetch} />
</div>
);
}

View File

@@ -1,7 +1,7 @@
{
"id": "llm-observability-overview",
"orgId": "",
"locked": false,
"locked": true,
"name": "AI Observability Overview",
"schemaVersion": "v6",
"source": "system",
@@ -1146,4 +1146,4 @@
}
]
}
}
}

View File

@@ -13,7 +13,7 @@ import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
import { OptionsQuery } from 'container/OptionsMenu/types';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { ChevronDown, ChevronRight, Search } from '@signozhq/icons';
import { useIsLogDetailsV2 } from 'components/LogDetail/useIsLogDetailsV2';
import { isLogDetailsV2 } from 'components/LogDetail/constants';
import { DataViewer } from 'periscope/components/DataViewer';
import { IField } from 'types/api/logs/fields';
import { ILog } from 'types/api/logs/log';
@@ -23,9 +23,9 @@ import { useLogAttributeActions } from './hooks/useLogAttributeActions';
import TableView from './TableView';
import {
aggregateAttributesResourcesToObject,
buildPrettyViewData,
getBodyDisplayString,
getSanitizedLogBody,
parseJsonStringBody,
removeEscapeCharacters,
} from './utils';
@@ -41,7 +41,6 @@ interface OverviewProps {
selectedOptions: OptionsQuery;
listViewPanelSelectedFields?: IField[] | null;
handleChangeSelectedView?: ChangeViewFunctionType;
onApplyLogFilter?: (expression: string) => void;
}
type Props = OverviewProps &
@@ -56,7 +55,6 @@ function Overview({
selectedOptions,
listViewPanelSelectedFields,
handleChangeSelectedView,
onApplyLogFilter,
}: Props): JSX.Element {
const [isWrapWord, setIsWrapWord] = useState<boolean>(true);
const [isSearchVisible, setIsSearchVisible] = useState<boolean>(true);
@@ -69,14 +67,15 @@ function Overview({
const { actions, visibleActions } = useLogAttributeActions({
handleChangeSelectedView,
isListViewPanel,
onApplyLogFilter,
});
const isLogDetailsV2 = useIsLogDetailsV2();
if (isLogDetailsV2) {
const raw = aggregateAttributesResourcesToObject(logData);
const prettyData = buildPrettyViewData(raw);
const prettyData = Object.fromEntries(
Object.entries({ ...raw, body: parseJsonStringBody(raw.body) }).filter(
([, value]) => value !== undefined,
),
);
return (
<div className="overview-container">
<DataViewer

View File

@@ -1,6 +1,6 @@
export enum LogAttributeBucket {
ATTRIBUTES = 'attributes',
RESOURCES = 'resource',
RESOURCES = 'resources',
SCOPE = 'scope',
}

View File

@@ -1,7 +1,6 @@
import { useCallback, useMemo } from 'react';
import { useLocation } from 'react-router-dom';
import { CircleMinus, CirclePlus, Layers, RefreshCw } from '@signozhq/icons';
import { convertFiltersToExpression } from 'components/QueryBuilderV2/utils';
import { FeatureKeys } from 'constants/features';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
@@ -16,7 +15,6 @@ import {
VisibleActionsConfig,
} from 'periscope/components/PrettyView/PrettyView';
import { useAppContext } from 'providers/App/App';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { LogDetailsAction } from '../constants';
import {
@@ -29,7 +27,6 @@ import {
interface UseLogAttributeActionsParams {
handleChangeSelectedView?: ChangeViewFunctionType;
isListViewPanel?: boolean;
onApplyLogFilter?: (expression: string) => void;
}
interface UseLogAttributeActionsResult {
@@ -53,7 +50,6 @@ const ALL_LEAF_ACTIONS = [
export function useLogAttributeActions({
handleChangeSelectedView,
isListViewPanel = false,
onApplyLogFilter,
}: UseLogAttributeActionsParams): UseLogAttributeActionsResult {
const { pathname } = useLocation();
const { stagedQuery, updateQueriesData } = useQueryBuilder();
@@ -69,6 +65,9 @@ export function useLogAttributeActions({
const filterFor = useCallback(
(context: FieldContext, isFilterIn: boolean): void => {
if (!stagedQuery) {
return;
}
const target = buildLogFilterTarget(
context.fieldKeyPath,
context.fieldValue,
@@ -78,29 +77,6 @@ export function useLogAttributeActions({
? target.filterInOperator
: target.filterOutOperator;
// Non-explorer surfaces (infra monitoring, etc.) apply a ready v5
// expression fragment to their own query.
if (onApplyLogFilter) {
const base = {
filters: { items: [], op: 'AND' },
} as unknown as IBuilderQuery;
const nextFilters = getFilterQueryData(
base,
target,
context.fieldValue,
operator,
).filters ?? { items: [], op: 'AND' };
const { expression } = convertFiltersToExpression(nextFilters);
if (expression) {
onApplyLogFilter(expression);
}
return;
}
if (!stagedQuery) {
return;
}
const updatedQuery = updateQueriesData(
stagedQuery,
'queryData',
@@ -123,7 +99,6 @@ export function useLogAttributeActions({
updateQueriesData,
viewName,
handleChangeSelectedView,
onApplyLogFilter,
],
);
@@ -204,25 +179,20 @@ export function useLogAttributeActions({
buildLogFilterTarget(fieldKeyPath, undefined, isBodyJsonQueryEnabled)
.isRestricted;
// The using surface must provide an apply path.
const canApplyFilter = !!handleChangeSelectedView || !!onApplyLogFilter;
return [
{
key: LogDetailsAction.FILTER_IN,
label: 'Filter for value',
icon: <CirclePlus size={12} />,
onClick: (context): void => filterFor(context, true),
shouldHide: (_key, fieldKeyPath): boolean =>
!canApplyFilter || isRestricted(fieldKeyPath),
shouldHide: (_key, fieldKeyPath): boolean => isRestricted(fieldKeyPath),
},
{
key: LogDetailsAction.FILTER_OUT,
label: 'Filter out value',
icon: <CircleMinus size={12} />,
onClick: (context): void => filterFor(context, false),
shouldHide: (_key, fieldKeyPath): boolean =>
!canApplyFilter || isRestricted(fieldKeyPath),
shouldHide: (_key, fieldKeyPath): boolean => isRestricted(fieldKeyPath),
},
{
key: LogDetailsAction.GROUP_BY,
@@ -230,10 +200,8 @@ export function useLogAttributeActions({
icon: <Layers size={12} />,
onClick: groupBy,
shouldHide: (_key, fieldKeyPath): boolean =>
!handleChangeSelectedView ||
!buildLogFilterTarget(fieldKeyPath, undefined, isBodyJsonQueryEnabled)
.groupBySupported ||
isOldExplorerOrLive,
.groupBySupported || isOldExplorerOrLive,
},
{
key: LogDetailsAction.REPLACE_FILTER,
@@ -241,9 +209,7 @@ export function useLogAttributeActions({
icon: <RefreshCw size={12} />,
onClick: replaceFilter,
shouldHide: (_key, fieldKeyPath): boolean =>
!handleChangeSelectedView ||
isRestricted(fieldKeyPath) ||
isOldExplorerOrLive,
isRestricted(fieldKeyPath) || isOldExplorerOrLive,
},
];
}, [
@@ -252,8 +218,6 @@ export function useLogAttributeActions({
replaceFilter,
isBodyJsonQueryEnabled,
isOldExplorerOrLive,
handleChangeSelectedView,
onApplyLogFilter,
]);
const visibleActions = useMemo<VisibleActionsConfig>(

View File

@@ -33,7 +33,7 @@ describe('buildLogFilterTarget', () => {
it('maps `resources` with Resource type', () => {
expect(
buildLogFilterTarget(['resource', 'service.name'], 'api', true),
buildLogFilterTarget(['resources', 'service.name'], 'api', true),
).toMatchObject({
fieldKey: 'service.name',
metricsType: MetricsType.Resource,
@@ -53,30 +53,6 @@ describe('buildLogFilterTarget', () => {
});
});
describe('nested attribute values (parsed JSON)', () => {
it('marks a sub-field of a parsed attribute copy-only (restricted, no group-by)', () => {
const t = buildLogFilterTarget(['attributes', 'payload', 'x'], 1, true);
expect(t.isRestricted).toBe(true);
expect(t.groupBySupported).toBe(false);
expect(t.groupByKey).toBeUndefined();
});
it('leaves a top-level attribute (depth 2) filterable', () => {
const t = buildLogFilterTarget(['attributes', 'payload'], 'v', true);
expect(t.isRestricted).toBe(false);
expect(t.groupBySupported).toBe(true);
});
it('does not restrict nested resource/scope values', () => {
expect(
buildLogFilterTarget(['resource', 'k8s', 'pod'], 'p', true).isRestricted,
).toBe(false);
expect(
buildLogFilterTarget(['scope', 'a', 'b'], 'v', true).isRestricted,
).toBe(false);
});
});
describe('restricted fields (timestamp / id)', () => {
it.each(['timestamp', 'id'])(
'marks %s restricted with no group-by',
@@ -89,30 +65,6 @@ describe('buildLogFilterTarget', () => {
);
});
describe('group-by-restricted fields (trace_id)', () => {
it('allows filtering but not group-by on top-level trace_id', () => {
const t = buildLogFilterTarget(['trace_id'], 'abc123', true);
expect(t.isRestricted).toBe(false);
expect(t.filterInOperator).toBe('=');
expect(t.groupBySupported).toBe(false);
expect(t.groupByKey).toBeUndefined();
});
it.each([
['resource', ['resource', 'trace_id']],
['attributes', ['attributes', 'trace_id']],
])(
'blocks group-by on a %s field named trace_id, keeping filter',
(_bucket, path) => {
const t = buildLogFilterTarget(path as string[], 'abc123', true);
expect(t.isRestricted).toBe(false);
expect(t.filterInOperator).toBe('=');
expect(t.groupBySupported).toBe(false);
expect(t.groupByKey).toBeUndefined();
},
);
});
describe('body scalars', () => {
it('maps a top-level body scalar to body.<key> with =/!=, groupable when json body on', () => {
const t = buildLogFilterTarget(['body', 'message'], 'hello', true);

View File

@@ -5,12 +5,9 @@ import {
QUERY_BUILDER_FUNCTIONS,
} from 'constants/antlrQueryConstants';
import { OPERATORS as QUERY_BUILDER_OPERATORS } from 'constants/queryBuilder';
import {
RESTRICTED_GROUP_BY_FIELDS,
RESTRICTED_SELECTED_FIELDS,
} from 'container/LogsFilters/config';
import { RESTRICTED_SELECTED_FIELDS } from 'container/LogsFilters/config';
import { MetricsType } from 'container/MetricsApplication/constant';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearchV2/utils';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
import { chooseAutocompleteFromCustomValue } from 'lib/newQueryBuilder/chooseAutocompleteFromCustomValue';
import {
BaseAutocompleteData,
@@ -86,24 +83,15 @@ export const buildLogFilterTarget = (
if (root !== 'body') {
const fieldKey =
fieldKeyPath.length > 1 ? fieldKeyPath.slice(1).join('.') : String(root);
// Temporarily removing filter/group-by support for nested attributes.
// This will be removed once backend starts to support these actions.
const isNestedAttributeValue =
root === LogAttributeBucket.ATTRIBUTES && fieldKeyPath.length > 2;
const isRestricted =
RESTRICTED_SELECTED_FIELDS.includes(fieldKey) || isNestedAttributeValue;
const groupBySupported =
!isRestricted && !RESTRICTED_GROUP_BY_FIELDS.includes(fieldKey);
const isRestricted = RESTRICTED_SELECTED_FIELDS.includes(fieldKey);
return {
fieldKey,
filterInOperator: OPERATORS['='],
filterOutOperator: OPERATORS['!='],
dataType: getDataTypes(value),
metricsType: metricsTypeForRoot(root),
groupBySupported,
groupByKey: groupBySupported ? fieldKey : undefined,
groupBySupported: !isRestricted,
groupByKey: isRestricted ? undefined : fieldKey,
isRestricted,
};
}

View File

@@ -3,79 +3,45 @@ import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import {
aggregateAttributesResourcesToObject,
buildPrettyViewData,
flattenObject,
getDataTypes,
getSanitizedLogBody,
parseJsonStringValue,
parseJsonStringBody,
recursiveParseJSON,
} from './utils';
describe('parseJsonStringValue', () => {
describe('parseJsonStringBody', () => {
it('parses a JSON-object string into an object', () => {
expect(parseJsonStringValue('{"a":1,"b":{"c":2}}')).toStrictEqual({
expect(parseJsonStringBody('{"a":1,"b":{"c":2}}')).toStrictEqual({
a: 1,
b: { c: 2 },
});
});
it('parses a JSON-array string into an array', () => {
expect(parseJsonStringValue('[1,2,3]')).toStrictEqual([1, 2, 3]);
expect(parseJsonStringBody('[1,2,3]')).toStrictEqual([1, 2, 3]);
});
it('returns a plain (non-JSON) string unchanged', () => {
expect(parseJsonStringValue('plain log line')).toBe('plain log line');
expect(parseJsonStringBody('plain log line')).toBe('plain log line');
});
it('returns a string that is not object/array-looking unchanged', () => {
expect(parseJsonStringValue('42')).toBe('42');
expect(parseJsonStringBody('42')).toBe('42');
});
it('returns an invalid JSON string unchanged', () => {
expect(parseJsonStringValue('{not valid}')).toBe('{not valid}');
expect(parseJsonStringBody('{not valid}')).toBe('{not valid}');
});
it('returns an already-object value unchanged (same reference)', () => {
const value = { message: 'hi', a: 1 };
expect(parseJsonStringValue(value)).toBe(value);
it('returns an already-object body unchanged (same reference)', () => {
const body = { message: 'hi', a: 1 };
expect(parseJsonStringBody(body)).toBe(body);
});
it('leaves a value larger than the 128KB parse guard as a string', () => {
it('leaves a body larger than the 128KB parse guard as a string', () => {
const huge = `{"x":"${'a'.repeat(130 * 1024)}"}`;
expect(parseJsonStringValue(huge)).toBe(huge);
});
});
describe('buildPrettyViewData', () => {
const baseRaw = {
id: 'log-1',
timestamp: 1234,
body: 'hello',
attributes: {},
resource: {},
scope: {},
} as any;
it('parses a JSON-string body into a tree', () => {
const result = buildPrettyViewData({ ...baseRaw, body: '{"a":1}' });
expect(result.body).toStrictEqual({ a: 1 });
});
it('parses attribute values that are JSON strings, leaves others as-is', () => {
const result = buildPrettyViewData({
...baseRaw,
attributes: { payload: '{"x":1}', name: 'cart', count: 3 },
});
expect(result.attributes).toStrictEqual({
payload: { x: 1 },
name: 'cart',
count: 3,
});
});
it('drops undefined fields so they do not render as empty rows', () => {
const result = buildPrettyViewData({ ...baseRaw, trace_id: undefined });
expect('trace_id' in result).toBe(false);
expect(parseJsonStringBody(huge)).toBe(huge);
});
});
@@ -99,7 +65,7 @@ describe('aggregateAttributesResourcesToObject', () => {
'http.method': 'GET',
retries: 3,
});
expect(result.resource).toStrictEqual({ 'service.name': 'cart' });
expect(result.resources).toStrictEqual({ 'service.name': 'cart' });
expect(result.scope).toStrictEqual({ lib: 'otel' });
expect(result.body).toBe('hello');
expect(result.id).toBe('log-1');

View File

@@ -276,7 +276,7 @@ export const aggregateAttributesResourcesToObject = (
traceFlags: logData.traceFlags,
traceId: logData.traceId,
attributes: {},
resource: {},
resources: {},
scope: {},
severity_text: logData.severity_text,
severity_number: logData.severity_number,
@@ -290,8 +290,8 @@ export const aggregateAttributesResourcesToObject = (
outputJson.attributes = outputJson.attributes || {};
Object.assign(outputJson.attributes, logData[key as keyof ILog]);
} else if (key.startsWith('resources_')) {
outputJson.resource = outputJson.resource || {};
Object.assign(outputJson.resource, logData[key as keyof ILog]);
outputJson.resources = outputJson.resources || {};
Object.assign(outputJson.resources, logData[key as keyof ILog]);
} else if (key.startsWith('scope_string')) {
outputJson.scope = outputJson.scope || {};
Object.assign(outputJson.scope, logData[key as keyof ILog]);
@@ -315,57 +315,30 @@ export const aggregateAttributesResourcesToString = (logData: ILog): string => {
}
};
const MAX_JSON_PARSE_BYTES = 128 * 1024;
const MAX_JSON_BODY_PARSE_BYTES = 128 * 1024;
// A JSON-encoded object/array string is parsed so DataViewer renders it as a tree
// instead of one escaped string; non-JSON / plain-text values are returned unchanged.
// A JSON-encoded object/array `body` is parsed so DataViewer renders it as a
// tree instead of one escaped string; plain-text bodies are returned unchanged.
// Guarded against very large payloads.
export const parseJsonStringValue = (value: unknown): unknown => {
if (typeof value !== 'string') {
return value;
export const parseJsonStringBody = (body: ILog['body']): ILog['body'] => {
if (typeof body !== 'string') {
return body;
}
const trimmed = value.trim();
const trimmed = body.trim();
const looksLikeJson = trimmed.startsWith('{') || trimmed.startsWith('[');
if (!looksLikeJson || trimmed.length > MAX_JSON_PARSE_BYTES) {
return value;
if (!looksLikeJson || trimmed.length > MAX_JSON_BODY_PARSE_BYTES) {
return body;
}
try {
const parsed = JSON.parse(trimmed);
return parsed !== null && typeof parsed === 'object' ? parsed : value;
return parsed !== null && typeof parsed === 'object'
? (parsed as ILogBody)
: body;
} catch {
return value;
return body;
}
};
// Parse each attribute value that's a stringified JSON string into an object
// Non-JSON values are left unchanged.
const parseAttributeJsonValues = (
attributes: Record<string, unknown>,
): Record<string, unknown> => {
const parsed: Record<string, unknown> = {};
Object.keys(attributes).forEach((key) => {
parsed[key] = parseJsonStringValue(attributes[key]);
});
return parsed;
};
export const buildPrettyViewData = (
raw: ILogAggregateAttributesResources,
): Record<string, unknown> => {
const prettyData: Record<string, unknown> = { ...raw };
prettyData.body = parseJsonStringValue(raw.body);
prettyData.attributes = parseAttributeJsonValues(raw.attributes);
// drop undefined fields so they don't render as empty rows
Object.keys(prettyData).forEach((key) => {
if (prettyData[key] === undefined) {
delete prettyData[key];
}
});
return prettyData;
};
const isFloat = (num: number): boolean => num % 1 !== 0;
const isBooleanString = (str: string): boolean =>

View File

@@ -2,9 +2,6 @@ import { blue, red } from '@ant-design/colors';
export const RESTRICTED_SELECTED_FIELDS = ['timestamp', 'id'];
// Fields that can be filtered on but not grouped by in the log details view.
export const RESTRICTED_GROUP_BY_FIELDS = ['body', 'trace_id'];
export const ICON_STYLE = {
PLUS: { color: blue[5] },
CLOSE: { color: red[5] },

View File

@@ -124,9 +124,6 @@ function Application(): JSX.Element {
start: minTime,
end: maxTime,
}),
// the time range is part of the key, so without this every window change blanks the
// operations list and the widgets below are rebuilt with an empty `operation in []`
keepPreviousData: true,
});
const selectedTraceTags: string = JSON.stringify(

View File

@@ -42,7 +42,7 @@ jest.mock('hooks/useDebounce', () => ({
}));
jest.mock(
'container/QueryBuilder/filters/OptionRenderer/OptionRenderer',
'container/QueryBuilder/filters/QueryBuilderSearch/OptionRenderer',
() => ({
__esModule: true,
default: ({ value }: { value: string }): JSX.Element => <span>{value}</span>,

View File

@@ -21,7 +21,6 @@ import azureMysqlUrl from '@/assets/Logos/azure-mysql.svg';
import azureOpenaiUrl from '@/assets/Logos/azure-openai.svg';
import azureSqlDatabaseMetricsUrl from '@/assets/Logos/azure-sql-database-metrics.svg';
import azureVmUrl from '@/assets/Logos/azure-vm.svg';
import azureUrl from '@/assets/Logos/azure.svg';
import basetenUrl from '@/assets/Logos/baseten.svg';
import cassandraUrl from '@/assets/Logos/cassandra.svg';
import celeryUrl from '@/assets/Logos/celery.svg';
@@ -29,7 +28,6 @@ import certManagerUrl from '@/assets/Logos/cert-manager.svg';
import claudeCodeUrl from '@/assets/Logos/claude-code.svg';
import clickhouseUrl from '@/assets/Logos/clickhouse.svg';
import cloudflareUrl from '@/assets/Logos/cloudflare.svg';
import cloudnativePgUrl from '@/assets/Logos/cloudnative-pg.svg';
import cloudwatchLogsUrl from '@/assets/Logos/cloudwatch-logs.svg';
import cohereUrl from '@/assets/Logos/cohere.svg';
import confluentKafkaUrl from '@/assets/Logos/confluent-kafka.svg';
@@ -41,7 +39,6 @@ import datadogUrl from '@/assets/Logos/datadog.svg';
import dbosUrl from '@/assets/Logos/dbos.svg';
import deepseekUrl from '@/assets/Logos/deepseek.svg';
import denoUrl from '@/assets/Logos/deno.svg';
import difyUrl from '@/assets/Logos/dify.svg';
import dockerUrl from '@/assets/Logos/docker.svg';
import documentLoadUrl from '@/assets/Logos/document-load.svg';
import dotnetUrl from '@/assets/Logos/dotnet.svg';
@@ -73,8 +70,6 @@ import gcpCloudStorageUrl from '@/assets/Logos/gcp-cloud-storage.svg';
import gcpComputeEngineUrl from '@/assets/Logos/gcp-compute-engine.svg';
import gcpGkeUrl from '@/assets/Logos/gcp-gke.svg';
import gcpVpcUrl from '@/assets/Logos/gcp-vpc.svg';
import gcpUrl from '@/assets/Logos/gcp.svg';
import githubCopilotUrl from '@/assets/Logos/github-copilot.svg';
import githubUrl from '@/assets/Logos/github.svg';
import goUrl from '@/assets/Logos/go.svg';
import googleAdkUrl from '@/assets/Logos/google-adk.svg';
@@ -101,8 +96,6 @@ import javascriptUrl from '@/assets/Logos/javascript.svg';
import jbossUrl from '@/assets/Logos/jboss.svg';
import jenkinsUrl from '@/assets/Logos/jenkins.svg';
import kafkaUrl from '@/assets/Logos/kafka.svg';
import kedaUrl from '@/assets/Logos/keda.svg';
import kongUrl from '@/assets/Logos/kong.svg';
import kubernetesUrl from '@/assets/Logos/kubernetes.svg';
import lambdaUrl from '@/assets/Logos/lambda.svg';
import langchainUrl from '@/assets/Logos/langchain.svg';
@@ -121,7 +114,6 @@ import microsoftSqlServerUrl from '@/assets/Logos/microsoft-sql-server.svg';
import mistralUrl from '@/assets/Logos/mistral.svg';
import mongoUrl from '@/assets/Logos/mongo.svg';
import n8nUrl from '@/assets/Logos/n8n.svg';
import neonUrl from '@/assets/Logos/neon.svg';
import newrelicUrl from '@/assets/Logos/newrelic.svg';
import nextjsUrl from '@/assets/Logos/nextjs.svg';
import nginxUrl from '@/assets/Logos/nginx.svg';
@@ -2867,25 +2859,6 @@ const onboardingConfigWithLinks = [
label: 'Traces',
imgUrl: lambdaUrl,
link: '/docs/aws-monitoring/lambda/lambda-traces/',
question: {
desc: 'Which runtime does your Lambda function use?',
helpText:
'Python, Node.js, Java, and Ruby use the OpenTelemetry auto-instrumentation layer. Go has no layer, so you add the SDK to your code.',
options: [
{
key: 'aws-lambda-traces-auto',
label: 'Python, Node.js, Java, Ruby',
imgUrl: lambdaUrl,
link: '/docs/aws-monitoring/lambda/lambda-traces/',
},
{
key: 'aws-lambda-traces-golang',
label: 'Go',
imgUrl: goUrl,
link: '/docs/aws-monitoring/lambda/lambda-traces-golang/',
},
],
},
},
{
key: 'aws-lambda-metrics',
@@ -5537,10 +5510,8 @@ const onboardingConfigWithLinks = [
module: 'metrics',
relatedSearchKeywords: [
'integrations',
'logs',
'metrics',
'supabase',
'supabase logs',
'supabase metrics',
'supabase monitoring',
'supabase observability',
@@ -5574,23 +5545,43 @@ const onboardingConfigWithLinks = [
label: 'Traefik',
imgUrl: opentelemetryUrl,
tags: ['infrastructure monitoring'],
module: 'apm',
module: 'infrastructure',
relatedSearchKeywords: [
'infrastructure',
'traefik',
'traefik access logs',
'traefik logs',
'traefik metrics',
'traefik monitoring',
'traefik observability',
'traefik tracing',
],
link: '/docs/tutorial/traefik-observability/',
question: {
desc: 'Which Traefik signals do you want to send to SigNoz?',
type: 'select',
options: [
{
key: 'traefik-metrics-traces',
label: 'Metrics & Traces',
imgUrl: opentelemetryUrl,
link: '/docs/tutorial/traefik-observability/',
},
{
key: 'traefik-logs',
label: 'Access Logs',
imgUrl: opentelemetryUrl,
link: '/docs/integrations/opentelemetry-traefik/',
},
],
},
},
{
dataSource: 'mongodb-atlas',
label: 'MongoDB (Atlas)',
imgUrl: mongoUrl,
tags: ['database'],
module: 'metrics',
module: 'database',
relatedSearchKeywords: [
'atlas',
'atlas metrics',
@@ -5609,15 +5600,36 @@ const onboardingConfigWithLinks = [
label: 'MySQL',
imgUrl: opentelemetryUrl,
tags: ['database'],
module: 'metrics',
module: 'database',
relatedSearchKeywords: [
'database',
'mysql',
'mysql error log',
'mysql logs',
'mysql metrics',
'mysql monitoring',
'mysql observability',
'mysql slow query log',
],
link: '/docs/metrics-management/mysql-metrics/',
question: {
desc: 'Which MySQL signals do you want to send to SigNoz?',
type: 'select',
options: [
{
key: 'mysql-metrics',
label: 'Metrics',
imgUrl: opentelemetryUrl,
link: '/docs/metrics-management/mysql-metrics/',
},
{
key: 'mysql-logs',
label: 'Logs',
imgUrl: opentelemetryUrl,
link: '/docs/integrations/opentelemetry-mysql/',
},
],
},
},
{
dataSource: 'jmx',
@@ -6980,514 +6992,5 @@ const onboardingConfigWithLinks = [
id: 'dspy',
link: '/docs/dspy-observability/',
},
{
dataSource: 'grok-build',
label: 'Grok Build',
imgUrl: grokUrl,
tags: ['LLM Monitoring'],
module: 'metrics',
relatedSearchKeywords: [
'coding agent',
'grok build',
'grok build events',
'grok build logs',
'grok build metrics',
'grok build monitoring',
'grok build observability',
'llm',
'llm monitoring',
'metrics',
'monitoring',
'observability',
'otel grok build integration',
'terminal coding agent',
'token usage',
'xai',
],
id: 'grok-build',
link: '/docs/grok-build-observability/',
},
{
dataSource: 'neon',
label: 'Neon',
imgUrl: neonUrl,
tags: ['database'],
module: 'metrics',
relatedSearchKeywords: [
'database',
'neon',
'neon database',
'neon db',
'neon logs',
'neon metrics',
'neon monitoring',
'neon observability',
'neondb',
'opentelemetry neon',
'postgres',
'postgresql',
'serverless postgres',
],
id: 'neon',
link: '/docs/integrations/opentelemetry-neondb/',
},
{
dataSource: 'dify',
label: 'Dify',
imgUrl: difyUrl,
tags: ['LLM Monitoring'],
module: 'apm',
relatedSearchKeywords: [
'ai agent',
'dify',
'dify monitoring',
'dify observability',
'dify traces',
'llm',
'llm monitoring',
'metrics',
'no code ai',
'observability',
'opentelemetry dify',
'traces',
],
id: 'dify',
link: '/docs/dify-observability/',
},
{
dataSource: 'firecrawl',
label: 'Firecrawl',
imgUrl: llmMonitoringUrl,
tags: ['LLM Monitoring'],
module: 'apm',
relatedSearchKeywords: [
'crawl',
'firecrawl',
'firecrawl metrics',
'firecrawl monitoring',
'firecrawl observability',
'firecrawl traces',
'llm',
'llm monitoring',
'opentelemetry firecrawl',
'scrape',
'traces',
'web scraping',
],
id: 'firecrawl',
link: '/docs/firecrawl-monitoring/',
},
{
dataSource: 'keda',
label: 'KEDA',
imgUrl: kedaUrl,
tags: ['infrastructure monitoring', 'metrics'],
module: 'metrics',
relatedSearchKeywords: [
'autoscaling',
'event driven autoscaling',
'keda',
'keda metrics',
'keda monitoring',
'kubernetes',
'kubernetes autoscaling',
'metrics',
'opentelemetry keda',
'scaled jobs',
'scaled objects',
],
id: 'keda',
link: '/docs/metrics-management/keda-metrics/',
},
{
dataSource: 'opentelemetry-collector-metrics',
label: 'OpenTelemetry Collector Metrics',
imgUrl: opentelemetryUrl,
tags: ['infrastructure monitoring', 'metrics'],
module: 'metrics',
relatedSearchKeywords: [
'collector health',
'collector internal metrics',
'collector metrics',
'metrics',
'opentelemetry',
'opentelemetry collector',
'otel collector',
'otelcol metrics',
'pipeline health',
],
id: 'opentelemetry-collector-metrics',
link: '/docs/metrics-management/opentelemetry-collector-metrics/',
},
{
dataSource: 'cloudnative-pg',
label: 'CloudNativePG',
imgUrl: cloudnativePgUrl,
tags: ['database'],
module: 'metrics',
relatedSearchKeywords: [
'cloud native postgres',
'cloudnativepg',
'cnpg',
'cnpg metrics',
'database',
'kubernetes postgres',
'metrics',
'opentelemetry cloudnativepg',
'postgres',
'postgresql',
],
id: 'cloudnative-pg',
link: '/docs/metrics-management/opentelemetry-cloudnative-pg/',
},
{
dataSource: 'kong-gateway',
label: 'Kong Gateway',
imgUrl: kongUrl,
tags: ['infrastructure monitoring'],
module: 'apm',
relatedSearchKeywords: [
'api gateway',
'kong',
'kong gateway',
'kong logs',
'kong metrics',
'kong monitoring',
'kong observability',
'kong traces',
'opentelemetry kong',
'proxy',
'traces',
],
id: 'kong-gateway',
link: '/docs/integrations/kong-gateway/',
},
{
dataSource: 'github-copilot',
label: 'GitHub Copilot',
imgUrl: githubCopilotUrl,
tags: ['LLM Monitoring'],
module: 'apm',
relatedSearchKeywords: [
'coding agent',
'copilot',
'copilot chat',
'github copilot',
'github copilot metrics',
'github copilot monitoring',
'github copilot observability',
'github copilot traces',
'llm',
'llm monitoring',
'monitoring',
'observability',
'otel github copilot integration',
'traces',
'tracing',
'vs code',
],
id: 'github-copilot',
link: '/docs/github-copilot-monitoring/',
},
{
dataSource: 'serilog',
label: 'Serilog',
imgUrl: dotnetUrl,
tags: ['logs'],
module: 'logs',
relatedSearchKeywords: [
'.net',
'asp.net',
'c#',
'csharp',
'dotnet',
'dotnet logs',
'logging',
'logs',
'monitoring',
'observability',
'otel serilog integration',
'serilog',
'serilog logs',
'serilog sink',
'structured logging',
],
id: 'serilog',
link: '/docs/logs-management/send-logs/serilog-to-signoz/',
},
{
dataSource: 'gcp-integration',
label: 'GCP Integration',
imgUrl: gcpUrl,
tags: ['GCP'],
module: 'metrics',
relatedSearchKeywords: [
'cloud monitoring metrics',
'connect gcp account',
'gcp',
'gcp integration',
'gcp manual setup',
'gcp metrics',
'gcp monitoring',
'gcp observability',
'gcp service account',
'google cloud',
'google cloud integration',
'metrics',
'monitoring',
'observability',
'opentelemetry collector gcp',
],
id: 'gcp-integration',
link: '/docs/integrations/gcp/gcp-integration/',
},
{
dataSource: 'azure-cosmos-db',
label: 'Azure Cosmos DB',
imgUrl: azureUrl,
tags: ['Azure'],
module: 'dashboards',
relatedSearchKeywords: [
'azure',
'azure cosmos db',
'azure integration',
'cosmos db',
'cosmosdb',
'database',
'metrics',
'monitoring',
'nosql',
'observability',
'one click azure',
'request units',
],
id: 'azure-cosmos-db',
link: '/integrations/azure?service=cosmosdb',
internalRedirect: true,
},
{
dataSource: 'azure-mongodb',
label: 'Azure MongoDB vCore',
imgUrl: mongoUrl,
tags: ['Azure'],
module: 'dashboards',
relatedSearchKeywords: [
'azure',
'azure integration',
'azure mongodb',
'azure mongodb vcore',
'database',
'metrics',
'mongodb',
'mongodb vcore',
'monitoring',
'nosql',
'observability',
'one click azure',
],
id: 'azure-mongodb',
link: '/integrations/azure?service=mongodb',
internalRedirect: true,
},
{
dataSource: 'azure-postgresql-flexible-server',
label: 'Azure PostgreSQL Flexible Server',
imgUrl: postgresqlUrl,
tags: ['Azure'],
module: 'dashboards',
relatedSearchKeywords: [
'azure',
'azure integration',
'azure postgresql',
'azure postgresql flexible server',
'database',
'metrics',
'monitoring',
'observability',
'one click azure',
'postgres',
'postgresql',
'postgresql flexible server',
],
id: 'azure-postgresql-flexible-server',
link: '/integrations/azure?service=postgresqlflexibleserver',
internalRedirect: true,
},
{
dataSource: 'azure-cache-redis',
label: 'Azure Cache for Redis',
imgUrl: redisUrl,
tags: ['Azure'],
module: 'dashboards',
relatedSearchKeywords: [
'azure',
'azure cache for redis',
'azure integration',
'azure redis',
'cache',
'database',
'metrics',
'monitoring',
'observability',
'one click azure',
'redis',
],
id: 'azure-cache-redis',
link: '/integrations/azure?service=redis',
internalRedirect: true,
},
{
dataSource: 'azure-sql-managed-instance',
label: 'Azure SQL Managed Instance',
imgUrl: azureSqlDatabaseMetricsUrl,
tags: ['Azure'],
module: 'dashboards',
relatedSearchKeywords: [
'azure',
'azure integration',
'azure sql',
'azure sql managed instance',
'database',
'managed instance',
'metrics',
'monitoring',
'observability',
'one click azure',
'sql server',
],
id: 'azure-sql-managed-instance',
link: '/integrations/azure?service=sqldatabasemi',
internalRedirect: true,
},
{
dataSource: 'azure-cassandra-db',
label: 'Azure Managed Instance for Apache Cassandra',
imgUrl: cassandraUrl,
tags: ['Azure'],
module: 'dashboards',
relatedSearchKeywords: [
'apache cassandra',
'azure',
'azure cassandra',
'azure integration',
'azure managed instance for apache cassandra',
'cassandra',
'database',
'metrics',
'monitoring',
'observability',
'one click azure',
],
id: 'azure-cassandra-db',
link: '/integrations/azure?service=cassandradb',
internalRedirect: true,
},
{
dataSource: 'gcp-cloud-sql-postgresql',
label: 'GCP Cloud SQL for PostgreSQL',
imgUrl: gcpCloudSqlUrl,
tags: ['GCP'],
module: 'dashboards',
relatedSearchKeywords: [
'cloud sql',
'cloud sql for postgresql',
'database',
'gcp',
'gcp integration',
'google cloud',
'metrics',
'monitoring',
'observability',
'postgres',
'postgresql',
],
id: 'gcp-cloud-sql-postgresql',
link: '/integrations/gcp?service=cloudsql_postgres',
internalRedirect: true,
},
{
dataSource: 'gcp-memorystore-redis',
label: 'GCP Memorystore for Redis',
imgUrl: redisUrl,
tags: ['GCP'],
module: 'dashboards',
relatedSearchKeywords: [
'cache',
'database',
'gcp',
'gcp integration',
'google cloud',
'memorystore',
'memorystore for redis',
'metrics',
'monitoring',
'observability',
'redis',
],
id: 'gcp-memorystore-redis',
link: '/integrations/gcp?service=memorystore_redis',
internalRedirect: true,
},
{
dataSource: 'supabase-logs',
label: 'Supabase Logs',
imgUrl: supabaseUrl,
tags: ['logs'],
module: 'logs',
relatedSearchKeywords: [
'database',
'logging',
'logs',
'postgres',
'postgresql',
'send supabase logs',
'supabase',
'supabase log drains',
'supabase logs',
'supabase observability',
],
id: 'supabase-logs',
link: '/docs/logs-management/send-logs/supabase-logs/',
},
{
dataSource: 'traefik-logs',
label: 'Traefik Access Logs',
imgUrl: opentelemetryUrl,
tags: ['logs'],
module: 'logs',
relatedSearchKeywords: [
'access logs',
'logging',
'logs',
'opentelemetry traefik',
'proxy',
'reverse proxy',
'traefik',
'traefik access logs',
'traefik logs',
],
id: 'traefik-logs',
link: '/docs/integrations/opentelemetry-traefik/',
},
{
dataSource: 'mysql-logs',
label: 'MySQL Logs',
imgUrl: opentelemetryUrl,
tags: ['logs'],
module: 'logs',
relatedSearchKeywords: [
'database',
'logging',
'logs',
'mysql',
'mysql error log',
'mysql general query log',
'mysql logs',
'mysql slow query log',
'opentelemetry mysql',
],
id: 'mysql-logs',
link: '/docs/integrations/opentelemetry-mysql/',
},
];
export default onboardingConfigWithLinks;

View File

@@ -1,15 +1,11 @@
import { ReactNode } from 'react';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { WhereClauseConfig } from 'hooks/queryBuilder/useAutoComplete';
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;

View File

@@ -0,0 +1,45 @@
import { useMemo } from 'react';
import { InputNumber, InputNumberProps } from 'antd';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { selectStyle } from '../QueryBuilderSearch/config';
function AggregateEveryFilter({
onChange,
query,
disabled,
}: AggregateEveryFilterProps): JSX.Element {
const isMetricsDataSource = useMemo(
() => query.dataSource === DataSource.METRICS,
[query.dataSource],
);
const onChangeHandler: InputNumberProps<number>['onChange'] = (event) => {
if (event && event >= 0) {
onChange(event);
}
};
const isDisabled =
(isMetricsDataSource && !query.aggregateAttribute?.key) || disabled;
return (
<InputNumber
placeholder="Enter in seconds"
disabled={isDisabled}
style={selectStyle}
value={query?.stepInterval}
onChange={onChangeHandler}
min={0}
/>
);
}
interface AggregateEveryFilterProps {
onChange: (values: number) => void;
query: IBuilderQuery;
disabled: boolean;
}
export default AggregateEveryFilter;

View File

@@ -24,8 +24,8 @@ import { DataSource } from 'types/common/queryBuilder';
import { ExtendedSelectOption } from 'types/common/select';
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
import { selectStyle } from '../QueryBuilderSearchV2/config';
import OptionRenderer from '../OptionRenderer/OptionRenderer';
import { selectStyle } from '../QueryBuilderSearch/config';
import OptionRenderer from '../QueryBuilderSearch/OptionRenderer';
// ** Types
import { AgregatorFilterProps } from './AggregatorFilter.intefaces';

View File

@@ -1,6 +1,6 @@
import { InputNumber } from 'antd';
import { selectStyle } from '../../QueryBuilderSearchV2/config';
import { selectStyle } from '../../QueryBuilderSearch/config';
import { handleKeyDownLimitFilter } from '../../utils';
import { LimitFilterProps } from './types';

View File

@@ -4,7 +4,7 @@ import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
import { MetricAggregateOperator } from 'types/common/queryBuilder';
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
import { selectStyle } from '../../QueryBuilderSearchV2/config';
import { selectStyle } from '../../QueryBuilderSearch/config';
import { OrderByProps } from './types';
import { useOrderByFormulaFilter } from './useOrderByFormulaFilter';

View File

@@ -16,7 +16,7 @@ import {
mapLabelValuePairs,
orderByValueDelimiter,
} from '../../OrderByFilter/utils';
import { getRemoveOrderFromValue } from '../../QueryBuilderSearchV2/utils';
import { getRemoveOrderFromValue } from '../../QueryBuilderSearch/utils';
import { getUniqueOrderByValues, getValidOrderByResult } from '../../utils';
import { IOrderByFormulaFilterProps } from './types';
import { transformToOrderByStringValuesByFormula } from './utils';

View File

@@ -23,8 +23,8 @@ import { DataSource } from 'types/common/queryBuilder';
import { SelectOption } from 'types/common/select';
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
import { selectStyle } from '../QueryBuilderSearchV2/config';
import OptionRenderer from '../OptionRenderer/OptionRenderer';
import { selectStyle } from '../QueryBuilderSearch/config';
import OptionRenderer from '../QueryBuilderSearch/OptionRenderer';
import { GroupByFilterProps } from './GroupByFilter.interfaces';
export const GroupByFilter = memo(function GroupByFilter({

View File

@@ -0,0 +1,31 @@
import { InputNumber } from 'antd';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { selectStyle } from '../QueryBuilderSearch/config';
import { handleKeyDownLimitFilter } from '../utils';
function LimitFilter({ onChange, query }: LimitFilterProps): JSX.Element {
const isMetricsDataSource = query.dataSource === DataSource.METRICS;
const isDisabled = isMetricsDataSource && !query.aggregateAttribute?.key;
return (
<InputNumber
min={1}
type="number"
value={query.limit}
style={selectStyle}
disabled={isDisabled}
onChange={onChange}
onKeyDown={handleKeyDownLimitFilter}
/>
);
}
interface LimitFilterProps {
onChange: (values: number | null) => void;
query: IBuilderQuery;
}
export default LimitFilter;

View File

@@ -31,7 +31,7 @@ jest.mock('hooks/useDebounce', () => ({
default: <T,>(value: T): T => value,
}));
jest.mock('../OptionRenderer/OptionRenderer', () => ({
jest.mock('../QueryBuilderSearch/OptionRenderer', () => ({
__esModule: true,
default: ({ value }: { value: string }): JSX.Element => <span>{value}</span>,
}));

View File

@@ -16,8 +16,8 @@ import { MetricAggregation } from 'types/api/v5/queryRange';
import { ExtendedSelectOption } from 'types/common/select';
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
import { selectStyle } from '../QueryBuilderSearchV2/config';
import OptionRenderer from '../OptionRenderer/OptionRenderer';
import { selectStyle } from '../QueryBuilderSearch/config';
import OptionRenderer from '../QueryBuilderSearch/OptionRenderer';
import './MetricNameSelector.styles.scss';

View File

@@ -2,7 +2,7 @@ import { memo } from 'react';
import { Select } from 'antd';
// ** Types
import { selectStyle } from '../QueryBuilderSearchV2/config';
import { selectStyle } from '../QueryBuilderSearch/config';
import { OperatorsSelectProps } from './OperatorsSelect.interfaces';
export const OperatorsSelect = memo(function OperatorsSelect({

View File

@@ -1,22 +0,0 @@
import { Badge } from '@signozhq/ui/badge';
import styled from 'styled-components';
export const TagContainer = styled(Badge).attrs({
color: 'secondary',
variant: 'outline',
})`
&&& {
display: flex;
font-weight: 300;
font-size: 0.6rem;
}
`;
export const TagLabel = styled.span`
font-weight: 400;
`;
export const TagValue = styled.span`
text-transform: capitalize;
font-weight: 400;
`;

View File

@@ -5,7 +5,7 @@ import { DataSource, MetricAggregateOperator } from 'types/common/queryBuilder';
import { getParsedAggregationOptionsForOrderBy } from 'utils/aggregationConverter';
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
import { selectStyle } from '../QueryBuilderSearchV2/config';
import { selectStyle } from '../QueryBuilderSearch/config';
import { OrderByFilterProps } from './OrderByFilter.interfaces';
import { useOrderByFilter } from './useOrderByFilter';

View File

@@ -7,7 +7,7 @@ import { parse } from 'papaparse';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { OrderByPayload } from 'types/api/queryBuilder/queryBuilderData';
import { getRemoveOrderFromValue } from '../QueryBuilderSearchV2/utils';
import { getRemoveOrderFromValue } from '../QueryBuilderSearch/utils';
import { getUniqueOrderByValues, getValidOrderByResult } from '../utils';
import { ORDERBY_FILTERS } from './config';
import { SIGNOZ_VALUE } from './constants';

View File

@@ -1,5 +1,7 @@
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import './QueryBuilderSearch.styles.scss';
function ExampleQueriesRendererForLogs({
label,
value,

View File

@@ -0,0 +1,70 @@
import { Color } from '@signozhq/design-tokens';
import { Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import { Zap } from '@signozhq/icons';
import { getOptionType } from './utils';
import './QueryBuilderSearch.styles.scss';
function OptionRendererForLogs({
label,
value,
dataType,
isIndexed,
setDynamicPlaceholder,
}: OptionRendererProps): JSX.Element {
const optionType = getOptionType(label);
return (
<span
className="option"
onMouseEnter={(): void => setDynamicPlaceholder(value)}
onFocus={(): void => setDynamicPlaceholder(value)}
>
{optionType ? (
<Tooltip title={value} placement="topLeft">
<div className="logs-options-select">
<section className="left-section">
{isIndexed ? (
<Zap size={12} fill={Color.BG_AMBER_500} />
) : (
<div className="dot" />
)}
<Typography.Text className="text value" truncate={1}>
{value}
</Typography.Text>
</section>
<section className="right-section">
<div className="text tags data-type-tag">{dataType}</div>
<div className={cx('text tags option-type-tag', optionType)}>
<div className="dot" />
{optionType}
</div>
</section>
</div>
</Tooltip>
) : (
<Tooltip title={label} placement="topLeft">
<div className="without-option-type">
<div className="dot" />
<Typography.Text className="text" truncate={1}>
{label}
</Typography.Text>
</div>
</Tooltip>
)}
</span>
);
}
interface OptionRendererProps {
label: string;
value: string;
dataType: string;
isIndexed: boolean;
setDynamicPlaceholder: React.Dispatch<React.SetStateAction<string>>;
}
export default OptionRendererForLogs;

View File

@@ -0,0 +1,298 @@
.query-builder-search-container {
position: relative;
display: flex;
align-items: center;
gap: 12px;
}
.logs-popup {
&.hide-scroll {
.rc-virtual-list-holder {
height: 100px;
}
}
}
.logs-explorer-popup {
padding: 0px;
.ant-select-item-group {
padding: 12px 14px 8px 14px;
color: var(--muted-foreground);
font-family: Inter;
font-size: 11px;
font-style: normal;
font-weight: 500;
line-height: 18px; /* 163.636% */
letter-spacing: 0.88px;
text-transform: uppercase;
}
.show-all-filter-props {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 13px;
width: 100%;
cursor: pointer;
.content {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
.left-section {
display: flex;
align-items: center;
gap: 4px;
.text {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
.text:hover {
color: var(--l1-foreground);
}
}
.right-section {
display: flex;
align-items: center;
gap: 4px;
.keyboard-shortcut-slash {
width: 16px;
height: 16px;
flex-shrink: 0;
border-radius: 2.286px;
border-top: 1.143px solid var(--l1-border);
border-right: 1.143px solid var(--l1-border);
border-bottom: 2.286px solid var(--l1-border);
border-left: 1.143px solid var(--l1-border);
background: var(--l2-background);
}
}
}
}
.show-all-filter-props:hover {
background: color-mix(
in srgb,
var(--l1-foreground) 4%,
transparent
) !important;
}
.example-queries {
cursor: default;
.heading {
padding: 12px 14px 8px 14px;
color: var(--muted-foreground);
font-family: Inter;
font-size: 11px;
font-style: normal;
font-weight: 500;
line-height: 18px; /* 163.636% */
letter-spacing: 0.88px;
text-transform: uppercase;
}
.query-container {
display: flex;
flex-direction: column;
gap: 12px;
padding: 0px 12px 12px 12px;
cursor: pointer;
.example-query {
display: flex;
padding: 4px 8px;
justify-content: center;
align-items: center;
gap: 10px;
border-radius: 2px;
background: var(--l3-background);
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: normal;
letter-spacing: -0.07px;
width: fit-content;
}
.example-query:hover {
color: var(--l1-foreground);
}
}
}
.ant-select-item-option-grouped {
padding-inline-start: 0px;
padding: 7px 13px;
}
.keyboard-shortcuts {
display: flex;
align-items: center;
border-radius: 0px 0px 4px 4px;
border: 1px solid var(--l1-border);
background: var(--l2-background);
padding: 11px 16px;
cursor: default;
.icons {
width: 16px;
height: 16px;
flex-shrink: 0;
border-radius: 2.286px;
border-top: 1.143px solid var(--l3-background);
border-right: 1.143px solid var(--l3-background);
border-bottom: 2.286px solid var(--l3-background);
border-left: 1.143px solid var(--l3-background);
background: var(--l2-background);
}
.keyboard-text {
color: var(--l2-foreground);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 142.857% */
letter-spacing: -0.07px;
}
.navigate {
display: flex;
align-items: center;
padding-right: 12px;
gap: 4px;
border-right: 1px solid var(--l1-border);
}
.update-query {
display: flex;
align-items: center;
margin-left: 12px;
gap: 4px;
}
}
.without-option-type {
display: flex;
gap: 8px;
align-items: center;
.dot {
height: 5px;
width: 5px;
border-radius: 50%;
background-color: var(--l3-background);
}
}
.logs-options-select {
display: flex;
align-items: center;
justify-content: space-between;
.text {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
.tags {
display: flex;
height: 20px;
padding: 4px 8px;
justify-content: center;
align-items: center;
gap: 4px;
border-radius: 20px;
}
.dot {
height: 5px;
width: 5px;
border-radius: 50%;
flex-shrink: 0;
}
.left-section {
display: flex;
align-items: center;
gap: 8px;
width: 90%;
.dot {
background-color: var(--l3-background);
}
.value {
width: 100%;
}
}
.right-section {
display: flex;
align-items: center;
gap: 4px;
.data-type-tag {
background: color-mix(in srgb, var(--l1-foreground) 8%, transparent);
}
.option-type-tag {
display: flex;
gap: 4px;
align-items: center;
padding: 0px 6px;
text-transform: capitalize;
}
.tag {
border-radius: 50px;
background: color-mix(in srgb, var(--bg-sienna-400) 10%, transparent);
color: var(--bg-sienna-400);
.dot {
background-color: var(--bg-sienna-400);
}
}
.resource {
border-radius: 50px;
background: color-mix(in srgb, var(--bg-sakura-400) 10%, transparent);
color: var(--bg-sakura-400);
.dot {
background-color: var(--bg-sakura-400);
}
}
}
}
.ant-select-item-option-active {
.logs-options-select {
.left-section {
.value {
color: var(--l1-foreground);
}
}
}
}
}
.span-scope-selector {
width: 160px;
}

View File

@@ -0,0 +1,558 @@
import {
KeyboardEvent,
ReactElement,
ReactNode,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { useLocation } from 'react-router-dom';
import { Button, Select, Spin, Tooltip } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import {
INFRA_LONG_TO_SHORT_OPERATOR_MAP,
OPERATORS,
} from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { LogsExplorerShortcuts } from 'constants/shortcuts/logsExplorerShortcuts';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import { getDataTypes } from 'container/LogDetailedView/utils';
import { useKeyboardHotkeys } from 'hooks/hotkeys/useKeyboardHotkeys';
import {
useAutoComplete,
WhereClauseConfig,
} from 'hooks/queryBuilder/useAutoComplete';
import { useFetchKeysAndValues } from 'hooks/queryBuilder/useFetchKeysAndValues';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { isEqual, isUndefined } from 'lodash-es';
import {
ArrowDown,
ArrowUp,
ChevronDown,
ChevronUp,
Command,
CornerDownLeft,
Filter,
Slash,
} from '@signozhq/icons';
import type { BaseSelectRef } from 'rc-select';
import {
BaseAutocompleteData,
DataTypes,
} from 'types/api/queryBuilder/queryAutocompleteResponse';
import {
IBuilderQuery,
TagFilter,
} from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { getUserOperatingSystem, UserOperatingSystem } from 'utils/getUserOS';
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
import { v4 as uuid } from 'uuid';
import { selectStyle } from './config';
import { PLACEHOLDER } from './constant';
import ExampleQueriesRendererForLogs from './ExampleQueriesRendererForLogs';
import OptionRenderer from './OptionRenderer';
import OptionRendererForLogs from './OptionRendererForLogs';
import { StyledCheckOutlined, TypographyText } from './style';
import {
convertExampleQueriesToOptions,
getOperatorValue,
getRemovePrefixFromKey,
getTagToken,
isExistsNotExistsOperator,
isInNInOperator,
} from './utils';
import './QueryBuilderSearch.styles.scss';
function getOperatorValueForContext(
op: string,
isInfraMonitoring?: boolean,
): string {
const mappedOp =
isInfraMonitoring && INFRA_LONG_TO_SHORT_OPERATOR_MAP[op]
? INFRA_LONG_TO_SHORT_OPERATOR_MAP[op]
: op;
return getOperatorValue(mappedOp);
}
function QueryBuilderSearch({
query,
onChange,
whereClauseConfig,
className,
placeholder,
suffixIcon,
isInfraMonitoring,
isMetricsExplorer,
disableNavigationShortcuts,
entity,
}: QueryBuilderSearchProps): JSX.Element {
const getPopupContainer = useSelectPopupContainer();
const { pathname } = useLocation();
const isLogsExplorerPage = useMemo(
() => pathname === ROUTES.LOGS_EXPLORER,
[pathname],
);
const [isEditingTag, setIsEditingTag] = useState(false);
const {
updateTag,
handleClearTag,
handleKeyDown,
handleOnBlur,
handleSearch,
handleSelect,
tags,
options,
searchValue,
isMulti,
isFetching,
setSearchKey,
setSearchValue,
searchKey,
key,
exampleQueries,
} = useAutoComplete(
query,
whereClauseConfig,
isLogsExplorerPage,
isInfraMonitoring,
entity,
isMetricsExplorer,
);
const [isOpen, setIsOpen] = useState<boolean>(false);
const [showAllFilters, setShowAllFilters] = useState<boolean>(false);
const [dynamicPlacholder, setDynamicPlaceholder] = useState<string>(
placeholder || '',
);
const selectRef = useRef<BaseSelectRef>(null);
const { sourceKeys, handleRemoveSourceKey } = useFetchKeysAndValues(
searchValue,
query,
searchKey,
isLogsExplorerPage,
isInfraMonitoring,
entity,
isMetricsExplorer,
);
const { registerShortcut, deregisterShortcut } = useKeyboardHotkeys();
const { handleRunQuery, currentQuery } = useQueryBuilder();
const toggleEditMode = useCallback(
(value: boolean) => {
// Editing mode is required only in infra monitoring or metrics explorer
if (isInfraMonitoring || isMetricsExplorer) {
setIsEditingTag(value);
}
},
[isInfraMonitoring, isMetricsExplorer],
);
const onTagRender = ({
value,
closable,
onClose,
}: CustomTagProps): ReactElement => {
const { tagOperator } = getTagToken(value);
const isInNin = isInNInOperator(tagOperator);
const chipValue = isInNin
? value?.trim()?.replace(/,\s*$/, '')
: value?.trim();
const onCloseHandler = (): void => {
onClose();
// Editing is done after closing a tag
toggleEditMode(false);
handleSearch('');
setSearchKey('');
};
const tagEditHandler = (value: string): void => {
updateTag(value);
// Editing starts
toggleEditMode(true);
if (isInfraMonitoring || isMetricsExplorer) {
setSearchValue(value);
} else {
handleSearch(value);
}
};
const isDisabled = !!searchValue;
return (
<Badge
color="vanilla"
closable={!searchValue && closable}
onClose={(e): void => {
e.preventDefault();
onCloseHandler();
}}
>
<Tooltip title={chipValue}>
<TypographyText
$isInNin={isInNin}
$isEnabled={!!searchValue}
onClick={(): void => {
if (!isDisabled) {
tagEditHandler(value);
}
}}
>
{chipValue}
</TypographyText>
</Tooltip>
</Badge>
);
};
const onChangeHandler = (value: string[]): void => {
if (!isMulti) {
handleSearch(value[value.length - 1]);
}
};
const onInputKeyDownHandler = (event: KeyboardEvent<Element>): void => {
if (isMulti || event.key === 'Backspace') {
handleKeyDown(event);
}
if (isExistsNotExistsOperator(searchValue)) {
handleKeyDown(event);
}
// Editing is done after enter key press
if (event.key === 'Enter') {
toggleEditMode(false);
}
if (
!disableNavigationShortcuts &&
(event.ctrlKey || event.metaKey) &&
event.key === 'Enter'
) {
event.preventDefault();
event.stopPropagation();
handleRunQuery();
setIsOpen(false);
}
if (
!disableNavigationShortcuts &&
(event.ctrlKey || event.metaKey) &&
event.key === '/'
) {
event.preventDefault();
event.stopPropagation();
setShowAllFilters((prev) => !prev);
}
};
const handleDeselect = useCallback(
(deselectedItem: string) => {
handleClearTag(deselectedItem);
handleRemoveSourceKey(deselectedItem);
},
[handleClearTag, handleRemoveSourceKey],
);
const isMetricsDataSource = useMemo(
() =>
query.dataSource === DataSource.METRICS &&
!isInfraMonitoring &&
!isMetricsExplorer,
[query.dataSource, isInfraMonitoring, isMetricsExplorer],
);
const fetchValueDataType = (value: unknown, operator: string): DataTypes => {
if (operator === OPERATORS.HAS || operator === OPERATORS.NHAS) {
return getDataTypes([value]);
}
return DataTypes.EMPTY;
};
const queryTags = useMemo(() => {
if (!query.aggregateAttribute?.key && isMetricsDataSource) {
return [];
}
return tags;
}, [isMetricsDataSource, query.aggregateAttribute?.key, tags]);
useEffect(() => {
const initialTagFilters: TagFilter = { items: [], op: 'AND' };
const initialSourceKeys = query.filters?.items?.map(
(item) => item.key as BaseAutocompleteData,
);
initialTagFilters.items = tags.map((tag) => {
const { tagKey, tagOperator, tagValue } = getTagToken(tag);
const filterAttribute = [
...(initialSourceKeys || []),
...(sourceKeys || []),
].find((key) => key?.key === getRemovePrefixFromKey(tagKey));
const computedTagValue =
tagValue && Array.isArray(tagValue) && tagValue[tagValue.length - 1] === ''
? tagValue?.slice(0, -1)
: (tagValue ?? '');
return {
id: uuid().slice(0, 8),
key: filterAttribute ?? {
key: tagKey,
dataType: fetchValueDataType(computedTagValue, tagOperator),
type: '',
},
op: getOperatorValueForContext(tagOperator, isInfraMonitoring),
value: computedTagValue,
};
});
// If in infra monitoring or metrics explorer, only run the onChange query when editing is finsished.
if (isInfraMonitoring || isMetricsExplorer) {
if (!isEditingTag) {
onChange(initialTagFilters);
}
} else {
onChange(initialTagFilters);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sourceKeys]);
const isLastQuery = useMemo(
() =>
isEqual(
currentQuery.builder.queryData[currentQuery.builder.queryData.length - 1],
query,
),
[currentQuery, query],
);
useEffect(() => {
if (isLastQuery && !disableNavigationShortcuts) {
registerShortcut(LogsExplorerShortcuts.FocusTheSearchBar, () => {
// set timeout is needed here else the select treats the hotkey as input value
setTimeout(() => {
selectRef.current?.focus();
}, 0);
});
}
return (): void =>
deregisterShortcut(LogsExplorerShortcuts.FocusTheSearchBar);
}, [
deregisterShortcut,
disableNavigationShortcuts,
isLastQuery,
registerShortcut,
]);
useEffect(() => {
if (!isOpen) {
setDynamicPlaceholder(placeholder || '');
}
}, [isOpen, placeholder]);
const userOs = getUserOperatingSystem();
// conditional changes here to use a seperate component to render the example queries based on the option group label
const customRendererForLogsExplorer = options.map((option) => (
<Select.Option
key={`${option.label}-${option.type || ''}-${option.dataType || ''}`}
value={option.value}
>
<OptionRendererForLogs
label={option.label}
value={option.value}
dataType={option.dataType || ''}
isIndexed={option.isIndexed || false}
setDynamicPlaceholder={setDynamicPlaceholder}
/>
{option.selected && <StyledCheckOutlined />}
</Select.Option>
));
return (
<div className="query-builder-search-container">
<Select
data-testid={'qb-search-select'}
ref={selectRef}
getPopupContainer={getPopupContainer}
transitionName=""
choiceTransitionName=""
virtual={false}
showSearch
tagRender={onTagRender}
filterOption={false}
open={isOpen}
onDropdownVisibleChange={setIsOpen}
autoClearSearchValue={false}
mode="multiple"
placeholder={dynamicPlacholder}
value={queryTags}
searchValue={searchValue}
className={cx(
className,
isLogsExplorerPage ? 'logs-popup' : '',
!showAllFilters && options.length > 3 && !key ? 'hide-scroll' : '',
)}
rootClassName="query-builder-search"
disabled={isMetricsDataSource && !query.aggregateAttribute?.key}
style={selectStyle}
onSearch={handleSearch}
onChange={onChangeHandler}
onSelect={handleSelect}
onDeselect={handleDeselect}
onInputKeyDown={onInputKeyDownHandler}
notFoundContent={isFetching ? <Spin size="small" /> : null}
suffixIcon={
!isUndefined(suffixIcon) ? (
suffixIcon
) : isOpen ? (
<ChevronUp size={14} />
) : (
<ChevronDown size={14} />
)
}
showAction={['focus']}
onBlur={(e: React.FocusEvent<HTMLInputElement>): void => {
handleOnBlur(e);
// Editing is done after tapping out of the input
toggleEditMode(false);
}}
popupClassName={isLogsExplorerPage ? 'logs-explorer-popup' : ''}
dropdownRender={(menu): ReactElement => (
<div>
{!searchKey && isLogsExplorerPage && (
<div className="ant-select-item-group ">Suggested Filters</div>
)}
{menu}
{isLogsExplorerPage && (
<div>
{!searchKey && tags.length === 0 && (
<div className="example-queries">
<div className="heading"> Example Queries </div>
<div className="query-container">
{convertExampleQueriesToOptions(exampleQueries).map((query) => (
<ExampleQueriesRendererForLogs
key={query.label}
label={query.label}
value={query.value}
handleAddTag={onChange}
/>
))}
</div>
</div>
)}
{!key && !isFetching && !showAllFilters && options.length > 3 && (
<Button
type="text"
className="show-all-filter-props"
onClick={(): void => {
setShowAllFilters(true);
// when clicking on the button the search bar looses the focus
selectRef?.current?.focus();
}}
>
<div className="content">
<section className="left-section">
<Filter size={14} />
<Typography.Text className="text">
Show all filters properties
</Typography.Text>
</section>
<section className="right-section">
{userOs === UserOperatingSystem.MACOS ? (
<Command size={14} className="keyboard-shortcut-slash" />
) : (
<ChevronUp size={14} className="keyboard-shortcut-slash" />
)}
+
<Slash size={14} className="keyboard-shortcut-slash" />
</section>
</div>
</Button>
)}
<div className="keyboard-shortcuts">
<section className="navigate">
<ArrowDown size={10} className="icons" />
<ArrowUp size={10} className="icons" />
<span className="keyboard-text">to navigate</span>
</section>
<section className="update-query">
<CornerDownLeft size={10} className="icons" />
<span className="keyboard-text">to update query</span>
</section>
</div>
</div>
)}
</div>
)}
>
{isLogsExplorerPage
? customRendererForLogsExplorer
: options.map((option) => (
<Select.Option
key={`${option.label}-${option.type || ''}-${option.dataType || ''}`}
value={option.value}
>
<OptionRenderer
label={option.label}
value={option.value}
dataType={option.dataType || ''}
type={option.type || ''}
/>
{option.selected && <StyledCheckOutlined />}
</Select.Option>
))}
</Select>
</div>
);
}
interface QueryBuilderSearchProps {
query: IBuilderQuery;
onChange: (value: TagFilter) => void;
whereClauseConfig?: WhereClauseConfig;
className?: string;
placeholder?: string;
suffixIcon?: React.ReactNode;
isInfraMonitoring?: boolean;
disableNavigationShortcuts?: boolean;
// TODO: Remove the dependency of InfraMonitoring from this code
entity?: InfraMonitoringEntity | null;
isMetricsExplorer?: boolean;
}
QueryBuilderSearch.defaultProps = {
whereClauseConfig: undefined,
className: '',
placeholder: PLACEHOLDER,
suffixIcon: undefined,
isInfraMonitoring: false,
disableNavigationShortcuts: false,
entity: null,
isMetricsExplorer: false,
};
export interface CustomTagProps {
label: ReactNode;
value: string;
disabled: boolean;
onClose: () => void;
closable: boolean;
}
export default QueryBuilderSearch;

View File

@@ -1,3 +1,5 @@
import { Check } from '@signozhq/icons';
import { Badge } from '@signozhq/ui/badge';
import styled from 'styled-components';
export const TypographyText = styled.span<{
@@ -15,3 +17,27 @@ export const TypographyText = styled.span<{
padding-left: 8px;
${({ $disabled }): string => ($disabled ? 'opacity: 0.6' : '')}
`;
export const StyledCheckOutlined = styled(Check)`
float: right;
`;
export const TagContainer = styled(Badge).attrs({
color: 'secondary',
variant: 'outline',
})`
&&& {
display: flex;
font-weight: 300;
font-size: 0.6rem;
}
`;
export const TagLabel = styled.span`
font-weight: 400;
`;
export const TagValue = styled.span`
text-transform: capitalize;
font-weight: 400;
`;

View File

@@ -1,7 +1,8 @@
import { OPERATORS } from 'constants/queryBuilder';
import { MetricsType } from 'container/MetricsApplication/constant';
import { queryFilterTags } from 'hooks/queryBuilder/useTag';
import { parse } from 'papaparse';
import type { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { orderByValueDelimiter } from '../OrderByFilter/utils';
@@ -190,6 +191,18 @@ export function getRemoveOrderFromValue(tag: string): string {
return tag;
}
export function getOptionType(label: string): MetricsType | undefined {
let optionType;
if (label.startsWith('tag_')) {
optionType = MetricsType.Tag;
} else if (label.startsWith('resource_')) {
optionType = MetricsType.Resource;
}
return optionType;
}
/**
*
* @param exampleQueries the example queries based on recommendation engine

View File

@@ -11,8 +11,8 @@ import {
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { getUserOperatingSystem, UserOperatingSystem } from 'utils/getUserOS';
import ExampleQueriesRendererForLogs from './ExampleQueriesRendererForLogs';
import { convertExampleQueriesToOptions } from './utils';
import ExampleQueriesRendererForLogs from '../QueryBuilderSearch/ExampleQueriesRendererForLogs';
import { convertExampleQueriesToOptions } from '../QueryBuilderSearch/utils';
import { ITag, Option } from './QueryBuilderSearchV2';
import './QueryBuilderSearchV2.styles.scss';

View File

@@ -19,10 +19,10 @@ 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 { useDashboardVariablesByType } from 'hooks/dashboard/useDashboardVariablesByType';
import { useKeyboardHotkeys } from 'hooks/hotkeys/useKeyboardHotkeys';
import { WhereClauseConfig } from 'hooks/queryBuilder/useAutoComplete';
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
import { useGetAggregateValues } from 'hooks/queryBuilder/useGetAggregateValues';
import { useGetAttributeSuggestions } from 'hooks/queryBuilder/useGetAttributeSuggestions';
@@ -53,16 +53,16 @@ import { DataSource } from 'types/common/queryBuilder';
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
import { v4 as uuid } from 'uuid';
import { selectStyle } from './config';
import { PLACEHOLDER } from './constant';
import { TypographyText } from './style';
import { selectStyle } from '../QueryBuilderSearch/config';
import { PLACEHOLDER } from '../QueryBuilderSearch/constant';
import { TypographyText } from '../QueryBuilderSearch/style';
import {
checkCommaInValue,
getOperatorFromValue,
getOperatorValue,
getTagToken,
isInNInOperator,
} from './utils';
} from '../QueryBuilderSearch/utils';
import { filterByOperatorConfig } from '../utils';
import QueryBuilderSearchDropdown from './QueryBuilderSearchDropdown';
import SpanScopeSelector from './SpanScopeSelector';

View File

@@ -7,7 +7,7 @@ import { isEmpty, isObject } from 'lodash-es';
import { Check, Zap } from '@signozhq/icons';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { getTagToken } from './utils';
import { getTagToken } from '../QueryBuilderSearch/utils';
import { DropdownState } from './QueryBuilderSearchV2';
import './Suggestions.styles.scss';

View File

@@ -1,3 +1,4 @@
import { AttributeValuesMap } from 'components/ClientSideQBSearch/ClientSideQBSearch';
import { OperatorConfigKeys, OPERATORS_CONFIG } from 'constants/queryBuilder';
import { HAVING_FILTER_REGEXP } from 'constants/regExp';
import { IOption } from 'hooks/useResourceAttribute/types';
@@ -10,7 +11,7 @@ import {
orderByValueDelimiter,
splitOrderByFromString,
} from './OrderByFilter/utils';
import { getRemoveOrderFromValue } from './QueryBuilderSearchV2/utils';
import { getRemoveOrderFromValue } from './QueryBuilderSearch/utils';
export const handleKeyDownLimitFilter: React.KeyboardEventHandler<
HTMLInputElement
@@ -100,6 +101,23 @@ export const getValidOrderByResult = (result: IOption[]): IOption[] =>
return acc;
}, []);
export const transformKeyValuesToAttributeValuesMap = (
attributeValuesMap: Record<string, string[] | number[] | boolean[]>,
): AttributeValuesMap =>
Object.fromEntries(
Object.entries(attributeValuesMap || {}).map(([key, values]) => [
key,
{
stringAttributeValues:
typeof values[0] === 'string' ? (values as string[]) : [],
numberAttributeValues:
typeof values[0] === 'number' ? (values as number[]) : [],
boolAttributeValues:
typeof values[0] === 'boolean' ? (values as boolean[]) : [],
},
]),
);
export const filterByOperatorConfig = (
options: IOption[],
key?: OperatorConfigKeys,

View File

@@ -1,88 +0,0 @@
import { renderHook, waitFor } from '@testing-library/react';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import useBaseAggregateOptions from '../useBaseAggregateOptions';
const mockGetUpdatedQuery = jest.fn();
const mockNotificationsError = jest.fn();
jest.mock('container/GridCardLayout/useResolveQuery', () => ({
__esModule: true,
default: (): unknown => ({
getUpdatedQuery: mockGetUpdatedQuery,
isLoading: false,
}),
}));
jest.mock('hooks/useNotifications', () => ({
useNotifications: (): unknown => ({
notifications: { error: mockNotificationsError },
}),
}));
jest.mock('providers/Dashboard/store/useDashboardStore', () => ({
useDashboardStore: (): unknown => ({ dashboardData: undefined }),
}));
jest.mock('hooks/dashboard/useContextVariables', () => ({
__esModule: true,
default: (): unknown => ({ processedVariables: {} }),
}));
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): unknown => ({ safeNavigate: jest.fn() }),
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: (): { pathname: string } => ({ pathname: '/services/socky-api' }),
}));
const QUERY = {
builder: {
queryData: [{ queryName: 'A', dataSource: 'traces', aggregations: [] }],
},
} as unknown as Query;
const AGGREGATE_DATA = { queryName: 'A', filters: [] };
const renderOptions = (): ReturnType<typeof renderHook> =>
renderHook(() =>
useBaseAggregateOptions({
query: QUERY,
onClose: jest.fn(),
subMenu: '',
setSubMenu: jest.fn(),
aggregateData: AGGREGATE_DATA,
fieldVariables: {},
}),
);
describe('useBaseAggregateOptions', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('notifies and keeps the unresolved query when variable resolution fails', async () => {
mockGetUpdatedQuery.mockRejectedValue(
new Error('syntax errors in expression'),
);
renderOptions();
await waitFor(() =>
expect(mockNotificationsError).toHaveBeenCalledWith({
message: 'Unable to resolve variables',
}),
);
});
it('does not notify when variable resolution succeeds', async () => {
mockGetUpdatedQuery.mockResolvedValue(QUERY);
renderOptions();
await waitFor(() => expect(mockGetUpdatedQuery).toHaveBeenCalled());
expect(mockNotificationsError).not.toHaveBeenCalled();
});
});

View File

@@ -3,7 +3,7 @@ import {
formatValueForExpression,
removeKeysFromExpression,
} from 'components/QueryBuilderV2/utils';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearchV2/utils';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
import { IQueryPair } from 'types/antlrQueryTypes';
import { extractQueryPairs } from 'utils/queryContextUtils';
import { isQuoted, unquote } from 'utils/stringUtils';

View File

@@ -6,7 +6,6 @@ import { PANEL_TYPES } from 'constants/queryBuilder';
import useUpdatedQuery from 'container/GridCardLayout/useResolveQuery';
import { processContextLinks } from 'container/NewWidget/RightContainer/ContextLinks/utils';
import useContextVariables from 'hooks/dashboard/useContextVariables';
import { useNotifications } from 'hooks/useNotifications';
import ContextMenu from 'periscope/components/ContextMenu';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import { ContextLinksData } from 'types/api/dashboard/getAll';
@@ -51,25 +50,23 @@ const useBaseAggregateOptions = ({
const { getUpdatedQuery, isLoading: isResolveQueryLoading } =
useUpdatedQuery();
const { dashboardData } = useDashboardStore();
const { notifications } = useNotifications();
useEffect(() => {
if (!aggregateData) {
return;
}
getUpdatedQuery({
widgetConfig: {
query,
panelTypes: panelType || PANEL_TYPES.TIME_SERIES,
timePreferance: 'GLOBAL_TIME',
},
dashboardData,
})
.then(setResolvedQuery)
.catch(() => {
setResolvedQuery(query);
notifications.error({ message: 'Unable to resolve variables' });
const resolveQuery = async (): Promise<void> => {
const updatedQuery = await getUpdatedQuery({
widgetConfig: {
query,
panelTypes: panelType || PANEL_TYPES.TIME_SERIES,
timePreferance: 'GLOBAL_TIME',
},
dashboardData,
});
setResolvedQuery(updatedQuery);
};
resolveQuery();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [query, aggregateData, panelType]);

View File

@@ -10,7 +10,7 @@ import DateTimeSelection from '../index';
import {
__resetSearchParamsGetter,
__setSearchParamsGetterForTest,
} from 'utils/getUnstableCurrentSearchParams';
} from '../utils/getUnstableCurrentSearchParams';
import { queryClient, TestWrapper } from './testUtils';
const mockSafeNavigate = jest.fn();

View File

@@ -10,7 +10,7 @@ import DateTimeSelection from '../index';
import {
__resetSearchParamsGetter,
__setSearchParamsGetterForTest,
} from 'utils/getUnstableCurrentSearchParams';
} from '../utils/getUnstableCurrentSearchParams';
import { queryClient, TestWrapper } from './testUtils';
const mockSafeNavigate = jest.fn();

View File

@@ -24,7 +24,7 @@ import DateTimeSelection from '../index';
import {
__resetSearchParamsGetter,
__setSearchParamsGetterForTest,
} from 'utils/getUnstableCurrentSearchParams';
} from '../utils/getUnstableCurrentSearchParams';
const queryClient = new QueryClient({
defaultOptions: {

View File

@@ -5,7 +5,7 @@ import DateTimeSelection from '../index';
import {
__resetSearchParamsGetter,
__setSearchParamsGetterForTest,
} from 'utils/getUnstableCurrentSearchParams';
} from '../utils/getUnstableCurrentSearchParams';
import { queryClient, TestWrapper } from './testUtils';
const mockSafeNavigate = jest.fn();

View File

@@ -10,7 +10,7 @@ import DateTimeSelection from '../index';
import {
__resetSearchParamsGetter,
__setSearchParamsGetterForTest,
} from 'utils/getUnstableCurrentSearchParams';
} from '../utils/getUnstableCurrentSearchParams';
import { queryClient, TestWrapper, createMockMoment } from './testUtils';
const mockSafeNavigate = jest.fn();

View File

@@ -54,7 +54,7 @@ import {
Time,
TimeRange,
} from './types';
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
import { getUnstableCurrentSearchParams } from './utils/getUnstableCurrentSearchParams';
import './DateTimeSelectionV2.styles.scss';

View File

@@ -9,7 +9,7 @@ import { QueryParams } from 'constants/query';
import { OPERATORS, QueryBuilderKeys } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { MetricsType } from 'container/MetricsApplication/constant';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearchV2/utils';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useNotifications } from 'hooks/useNotifications';
import useUrlQuery from 'hooks/useUrlQuery';

View File

@@ -0,0 +1,185 @@
import { KeyboardEvent, useCallback, useState } from 'react';
import { OPERATORS } from 'constants/queryBuilder';
import type { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import {
getRemovePrefixFromKey,
getTagToken,
replaceStringWithMaxLength,
tagRegexp,
} from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
import { Option } from 'container/QueryBuilder/type';
import { parse } from 'papaparse';
import {
IBuilderQuery,
TagFilter,
} from 'types/api/queryBuilder/queryBuilderData';
import { useFetchKeysAndValues } from './useFetchKeysAndValues';
import { useOptions, WHERE_CLAUSE_CUSTOM_SUFFIX } from './useOptions';
import { useSetCurrentKeyAndOperator } from './useSetCurrentKeyAndOperator';
import { useTag } from './useTag';
import { useTagValidation } from './useTagValidation';
export type WhereClauseConfig = {
customKey: string;
customOp: string;
};
export const useAutoComplete = (
query: IBuilderQuery,
whereClauseConfig?: WhereClauseConfig,
shouldUseSuggestions?: boolean,
isInfraMonitoring?: boolean,
entity?: InfraMonitoringEntity | null,
isMetricsExplorer?: boolean,
): IAutoComplete => {
const [searchValue, setSearchValue] = useState<string>('');
const [searchKey, setSearchKey] = useState<string>('');
const { keys, results, isFetching, exampleQueries } = useFetchKeysAndValues(
searchValue,
query,
searchKey,
shouldUseSuggestions,
isInfraMonitoring,
entity,
isMetricsExplorer,
);
const [key, operator, result] = useSetCurrentKeyAndOperator(searchValue, keys);
const handleSearch = (value: string): void => {
const prefixFreeValue = getRemovePrefixFromKey(getTagToken(value).tagKey);
setSearchValue(value);
setSearchKey(prefixFreeValue);
};
const { isValidTag, isExist, isValidOperator, isMulti } = useTagValidation(
operator,
result,
);
const { handleAddTag, handleClearTag, tags, updateTag } = useTag(
isValidTag,
handleSearch,
query,
setSearchKey,
whereClauseConfig,
isInfraMonitoring,
);
const handleSelect = useCallback(
(value: string): void => {
if (isMulti) {
setSearchValue((prev: string) => {
const matches = prev?.matchAll(tagRegexp);
const [match] = matches ? Array.from(matches) : [];
const [, , , matchTagValue] = match;
const data = parse(matchTagValue).data.flat();
return replaceStringWithMaxLength(prev, data as string[], value);
});
}
if (!isMulti) {
handleAddTag(value);
}
},
[handleAddTag, isMulti],
);
const handleKeyDown = useCallback(
(event: KeyboardEvent): void => {
if (
event.key === ' ' &&
(searchValue.endsWith(' ') || searchValue.length === 0)
) {
event.preventDefault();
}
if (event.key === 'Enter' && searchValue && isValidTag) {
if (isMulti) {
event.stopPropagation();
}
event.preventDefault();
handleAddTag(searchValue);
}
if (event.key === 'Backspace' && !searchValue) {
event.stopPropagation();
const last = tags[tags.length - 1];
handleClearTag(last);
}
},
[handleAddTag, handleClearTag, isMulti, isValidTag, searchValue, tags],
);
const handleOnBlur = (event: React.FocusEvent<HTMLInputElement>): void => {
event.preventDefault();
if (searchValue) {
if (
key &&
!operator &&
whereClauseConfig?.customKey === 'body' &&
whereClauseConfig.customOp === OPERATORS.CONTAINS
) {
const value = `${searchValue}${WHERE_CLAUSE_CUSTOM_SUFFIX}`;
handleAddTag(value);
return;
}
handleAddTag(searchValue);
}
};
const options = useOptions(
key,
keys,
operator,
searchValue,
isMulti,
isValidOperator,
isExist,
results,
result,
isFetching,
whereClauseConfig,
isInfraMonitoring,
);
return {
updateTag,
handleSearch,
handleClearTag,
handleSelect,
handleKeyDown,
handleOnBlur,
options,
tags,
searchValue,
isMulti,
isFetching,
setSearchKey,
setSearchValue,
searchKey,
key,
exampleQueries,
};
};
interface IAutoComplete {
updateTag: (value: string) => void;
handleSearch: (value: string) => void;
handleClearTag: (value: string) => void;
handleSelect: (value: string) => void;
handleKeyDown: (event: React.KeyboardEvent) => void;
handleOnBlur: (event: React.FocusEvent<HTMLInputElement>) => void;
options: Option[];
tags: string[];
searchValue: string;
isMulti: boolean;
isFetching: boolean;
setSearchKey: (value: string) => void;
setSearchValue: (value: string) => void;
searchKey: string;
key: string;
exampleQueries: TagFilter[];
isInfraMonitoring?: boolean;
}

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