mirror of
https://github.com/SigNoz/signoz.git
synced 2026-09-11 05:50:41 +01:00
Compare commits
19 Commits
feat/text-
...
qf-naming-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1087691b4e | ||
|
|
af2bfbabfd | ||
|
|
11030d1b91 | ||
|
|
ee3d075952 | ||
|
|
6db43f0ba7 | ||
|
|
c9ae10b1c0 | ||
|
|
84a802edba | ||
|
|
f78bd492d8 | ||
|
|
99dcd79979 | ||
|
|
d5af6f6d6b | ||
|
|
e7c48e964f | ||
|
|
fe8c9d8cf4 | ||
|
|
2ad3057fa6 | ||
|
|
9fc73fc0b1 | ||
|
|
3931c7163f | ||
|
|
fdfbf77d2b | ||
|
|
56e7388677 | ||
|
|
861380dc65 | ||
|
|
391f685e57 |
7
.claude/opencode.json
Normal file
7
.claude/opencode.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"lsp": true,
|
||||
"experimental": {
|
||||
"disable_paste_summary": true
|
||||
}
|
||||
}
|
||||
@@ -7,5 +7,6 @@ Applies to everything in the repo — code, config, workflows.
|
||||
- **Rationale goes in prose, not source.** Why a version is pinned, why a job exists, how a subsystem fits together — that belongs in the README or the PR.
|
||||
- **Never remove pre-existing comments** when editing code. The bar above applies to comments you write, not comments already there.
|
||||
- **Never talk to the reviewer.** No comments about where a change came from, what was changed, or why the change is correct — that belongs in the PR description and is noise the moment it merges.
|
||||
- **Less is more.** When writing something intended for human consumption, (comment, commit message, reply to prompt) use as few words as possible. Pick every word meticulously to reduce the volume to a strict minimum. Be down to the point. Less is more.
|
||||
|
||||
Language rules build on this one: [`go-comments`](go-comments.md), [`py-comments`](py-comments.md).
|
||||
|
||||
16
.claude/rules/go-contrib.md
Normal file
16
.claude/rules/go-contrib.md
Normal 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.
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
- **Follow the template** (`.github/pull_request_template.md`): fill in its headings (Description / Issues closed by this PR / Screenshots / Additional Information). Don't add sections the template doesn't have.
|
||||
- **Keep only the headings that apply.** Delete every heading that has nothing under it, along with its `<!--...-->` placeholder comment. The body must never contain an empty heading — if only Description applies, the body has exactly that one heading.
|
||||
- **Keep the description concise and human-readable.** A few plain bullets saying what changed and why, for a reviewer skimming it — not a wall of text, not a restatement of the diff, not generated boilerplate.
|
||||
- **Keep the description concise and human-readable.** A few non repetitive bullets saying what changed and why, for a reviewer skimming it — not a wall of text, not a restatement of the diff, not generated boilerplate and not the user agent conversation details.
|
||||
- **Reference issues with `Closes #issue-number`** under "Issues closed by this PR" so they auto-close on merge. This goes in the PR description only — never in commit messages.
|
||||
- **Breaking changes can be added in additional information section** if any.
|
||||
- **AI assistance in commits may optionally be disclosed with an `Assisted-by:` trailer** naming the model (e.g. `Assisted-by: Claude Opus 4.5`) — do NOT use a `Co-authored-by:` trailer for this.
|
||||
- **Keep the commit body short and human readable** focused on decision made if any. Commit body must not re-iterate the changes done, skip if title is sufficient in conveying the change.
|
||||
- **Use convensional commit format** for commits and PR title.
|
||||
- **Do not amend the commits once pushed.** Always create a new commit once changes are pushed to remote.
|
||||
|
||||
4
.github/CODEOWNERS
vendored
4
.github/CODEOWNERS
vendored
@@ -15,6 +15,10 @@
|
||||
.github @therealpandey
|
||||
go.mod @therealpandey
|
||||
|
||||
# Security
|
||||
|
||||
/SECURITY.md @therealpandey
|
||||
|
||||
# Scaffold Owners
|
||||
|
||||
/pkg/config/ @therealpandey
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -232,3 +232,4 @@ pyrightconfig.json
|
||||
# dev
|
||||
.dev/
|
||||
.claude/worktrees/
|
||||
.claude/settings.local.json
|
||||
|
||||
34
Makefile
34
Makefile
@@ -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 -
|
||||
|
||||
17
SECURITY.md
17
SECURITY.md
@@ -1,17 +1,26 @@
|
||||
# Security Policy
|
||||
|
||||
SigNoz is looking forward to working with security researchers across the world to keep SigNoz and our users safe. If you have found an issue in our systems/applications, please reach out to us.
|
||||
SigNoz is looking forward to working with security researchers across the world to keep SigNoz and our users safe. If you have found an issue in our systems/applications, please report it to us privately.
|
||||
|
||||
## Supported Versions
|
||||
We always recommend using the latest version of SigNoz to ensure you get all security updates
|
||||
|
||||
We always recommend using the latest version of SigNoz to ensure you get all security updates.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
If you believe you have found a security vulnerability within SigNoz, please let us know right away. We'll try and fix the problem as soon as possible.
|
||||
|
||||
**Do not report vulnerabilities using public GitHub issues**. Instead, email <security@signoz.io> with a detailed account of the issue. Please submit one issue per email, this helps us triage vulnerabilities.
|
||||
**Do not report vulnerabilities using public GitHub issues, discussions, or pull requests.**
|
||||
|
||||
Once we've received your email we'll keep you updated as we fix the vulnerability.
|
||||
Instead, report it privately through GitHub's private vulnerability reporting:
|
||||
|
||||
1. Go to the [**Security** tab](https://github.com/SigNoz/signoz/security) of this repository.
|
||||
2. Click **Report a vulnerability**, or use [this link](https://github.com/SigNoz/signoz/security/advisories/new).
|
||||
3. Describe the issue with as much detail as you can — affected version, impact, and steps to reproduce help us triage faster. Please submit one report per vulnerability.
|
||||
|
||||
This opens a private advisory visible only to you and the SigNoz maintainers. We'll respond there, keep you updated as we work on a fix, and coordinate disclosure. If the report is valid we'll credit you on the published advisory and request a CVE.
|
||||
|
||||
If you're unable to use GitHub's private reporting, you can email <security@signoz.io> instead.
|
||||
|
||||
## Thanks
|
||||
|
||||
|
||||
@@ -97,6 +97,7 @@ func runGenerateAuthz(_ context.Context) error {
|
||||
coretypes.NewResourceRef(coretypes.ResourceRole).String(): true,
|
||||
coretypes.NewResourceRef(coretypes.ResourceMetaResourceFactorAPIKey).String(): true,
|
||||
coretypes.NewResourceRef(coretypes.ResourceMetaResourceLicense).String(): true,
|
||||
coretypes.NewResourceRef(coretypes.ResourceMetaResourceSubscription).String(): true,
|
||||
coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceLogs).String(): true,
|
||||
coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceTraces).String(): true,
|
||||
coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceMetrics).String(): true,
|
||||
|
||||
@@ -138,6 +138,12 @@ sqlstore:
|
||||
|
||||
##################### APIServer #####################
|
||||
apiserver:
|
||||
# The TCP address the API server listens on, in the form "host:port".
|
||||
address: 0.0.0.0:8080
|
||||
# Maximum duration for reading an entire request, including the body.
|
||||
read_timeout: 60s
|
||||
# Keep at 0; any value cuts off streaming endpoints (livetail, SSE, export_raw_data).
|
||||
write_timeout: 0
|
||||
timeout:
|
||||
# Default request timeout.
|
||||
default: 60s
|
||||
@@ -397,7 +403,6 @@ identn:
|
||||
# headers to use for tokenizer identN resolver
|
||||
headers:
|
||||
- Authorization
|
||||
- Sec-WebSocket-Protocol
|
||||
apikey:
|
||||
# toggle apikey identN
|
||||
enabled: true
|
||||
|
||||
@@ -25,6 +25,379 @@ components:
|
||||
- data
|
||||
- orgId
|
||||
type: object
|
||||
AlertmanagertypesChannelConfig:
|
||||
discriminator:
|
||||
mapping:
|
||||
email: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelEmailConfig'
|
||||
googlechat: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelGoogleChatConfig'
|
||||
incidentio: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelIncidentIOConfig'
|
||||
jira: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJiraConfig'
|
||||
jsmops: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJSMOpsConfig'
|
||||
msteams: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelMSTeamsConfig'
|
||||
opsgenie: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelOpsgenieConfig'
|
||||
pagerduty: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelPagerdutyConfig'
|
||||
slack: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelSlackConfig'
|
||||
webhook: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelWebhookConfig'
|
||||
propertyName: kind
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelSlackConfig'
|
||||
- $ref: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelEmailConfig'
|
||||
- $ref: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelWebhookConfig'
|
||||
- $ref: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelPagerdutyConfig'
|
||||
- $ref: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelOpsgenieConfig'
|
||||
- $ref: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelMSTeamsConfig'
|
||||
- $ref: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelGoogleChatConfig'
|
||||
- $ref: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJiraConfig'
|
||||
- $ref: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJSMOpsConfig'
|
||||
- $ref: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelIncidentIOConfig'
|
||||
type: object
|
||||
AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelEmailConfig:
|
||||
properties:
|
||||
kind:
|
||||
enum:
|
||||
- email
|
||||
type: string
|
||||
spec:
|
||||
$ref: '#/components/schemas/AlertmanagertypesChannelEmailConfig'
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelGoogleChatConfig:
|
||||
properties:
|
||||
kind:
|
||||
enum:
|
||||
- googlechat
|
||||
type: string
|
||||
spec:
|
||||
$ref: '#/components/schemas/AlertmanagertypesChannelGoogleChatConfig'
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelIncidentIOConfig:
|
||||
properties:
|
||||
kind:
|
||||
enum:
|
||||
- incidentio
|
||||
type: string
|
||||
spec:
|
||||
$ref: '#/components/schemas/AlertmanagertypesChannelIncidentIOConfig'
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJSMOpsConfig:
|
||||
properties:
|
||||
kind:
|
||||
enum:
|
||||
- jsmops
|
||||
type: string
|
||||
spec:
|
||||
$ref: '#/components/schemas/AlertmanagertypesChannelJSMOpsConfig'
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJiraConfig:
|
||||
properties:
|
||||
kind:
|
||||
enum:
|
||||
- jira
|
||||
type: string
|
||||
spec:
|
||||
$ref: '#/components/schemas/AlertmanagertypesChannelJiraConfig'
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelMSTeamsConfig:
|
||||
properties:
|
||||
kind:
|
||||
enum:
|
||||
- msteams
|
||||
type: string
|
||||
spec:
|
||||
$ref: '#/components/schemas/AlertmanagertypesChannelMSTeamsConfig'
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelOpsgenieConfig:
|
||||
properties:
|
||||
kind:
|
||||
enum:
|
||||
- opsgenie
|
||||
type: string
|
||||
spec:
|
||||
$ref: '#/components/schemas/AlertmanagertypesChannelOpsgenieConfig'
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelPagerdutyConfig:
|
||||
properties:
|
||||
kind:
|
||||
enum:
|
||||
- pagerduty
|
||||
type: string
|
||||
spec:
|
||||
$ref: '#/components/schemas/AlertmanagertypesChannelPagerdutyConfig'
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelSlackConfig:
|
||||
properties:
|
||||
kind:
|
||||
enum:
|
||||
- slack
|
||||
type: string
|
||||
spec:
|
||||
$ref: '#/components/schemas/AlertmanagertypesChannelSlackConfig'
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelWebhookConfig:
|
||||
properties:
|
||||
kind:
|
||||
enum:
|
||||
- webhook
|
||||
type: string
|
||||
spec:
|
||||
$ref: '#/components/schemas/AlertmanagertypesChannelWebhookConfig'
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
AlertmanagertypesChannelEmailConfig:
|
||||
properties:
|
||||
headers:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
html:
|
||||
type: string
|
||||
sendResolved:
|
||||
nullable: true
|
||||
type: boolean
|
||||
to:
|
||||
type: string
|
||||
required:
|
||||
- to
|
||||
type: object
|
||||
AlertmanagertypesChannelGoogleChatConfig:
|
||||
properties:
|
||||
sendResolved:
|
||||
nullable: true
|
||||
type: boolean
|
||||
text:
|
||||
type: string
|
||||
title:
|
||||
type: string
|
||||
webhookUrl:
|
||||
type: string
|
||||
required:
|
||||
- webhookUrl
|
||||
type: object
|
||||
AlertmanagertypesChannelIncidentIOConfig:
|
||||
properties:
|
||||
description:
|
||||
type: string
|
||||
metadata:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
sendResolved:
|
||||
nullable: true
|
||||
type: boolean
|
||||
title:
|
||||
type: string
|
||||
token:
|
||||
type: string
|
||||
url:
|
||||
type: string
|
||||
required:
|
||||
- url
|
||||
- token
|
||||
type: object
|
||||
AlertmanagertypesChannelJSMOpsConfig:
|
||||
properties:
|
||||
apiKey:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
message:
|
||||
type: string
|
||||
priority:
|
||||
type: string
|
||||
sendResolved:
|
||||
nullable: true
|
||||
type: boolean
|
||||
tags:
|
||||
type: string
|
||||
required:
|
||||
- apiKey
|
||||
type: object
|
||||
AlertmanagertypesChannelJiraConfig:
|
||||
properties:
|
||||
apiToken:
|
||||
type: string
|
||||
customFields:
|
||||
additionalProperties: {}
|
||||
type: object
|
||||
description:
|
||||
type: string
|
||||
email:
|
||||
type: string
|
||||
issueType:
|
||||
type: string
|
||||
labels:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
priority:
|
||||
type: string
|
||||
project:
|
||||
type: string
|
||||
reopenDuration:
|
||||
type: string
|
||||
reopenTransition:
|
||||
type: string
|
||||
resolveTransition:
|
||||
type: string
|
||||
sendResolved:
|
||||
nullable: true
|
||||
type: boolean
|
||||
site:
|
||||
type: string
|
||||
summary:
|
||||
type: string
|
||||
wontFixResolution:
|
||||
type: string
|
||||
required:
|
||||
- site
|
||||
- project
|
||||
- issueType
|
||||
- email
|
||||
- apiToken
|
||||
type: object
|
||||
AlertmanagertypesChannelKind:
|
||||
enum:
|
||||
- slack
|
||||
- email
|
||||
- webhook
|
||||
- pagerduty
|
||||
- opsgenie
|
||||
- msteams
|
||||
- googlechat
|
||||
- jira
|
||||
- jsmops
|
||||
- incidentio
|
||||
type: string
|
||||
AlertmanagertypesChannelMSTeamsConfig:
|
||||
properties:
|
||||
sendResolved:
|
||||
nullable: true
|
||||
type: boolean
|
||||
text:
|
||||
type: string
|
||||
title:
|
||||
type: string
|
||||
webhookUrl:
|
||||
type: string
|
||||
required:
|
||||
- webhookUrl
|
||||
type: object
|
||||
AlertmanagertypesChannelOpsgenieConfig:
|
||||
properties:
|
||||
apiKey:
|
||||
type: string
|
||||
apiUrl:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
details:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
message:
|
||||
type: string
|
||||
priority:
|
||||
type: string
|
||||
sendResolved:
|
||||
nullable: true
|
||||
type: boolean
|
||||
source:
|
||||
type: string
|
||||
required:
|
||||
- apiKey
|
||||
type: object
|
||||
AlertmanagertypesChannelPagerdutyConfig:
|
||||
properties:
|
||||
class:
|
||||
type: string
|
||||
client:
|
||||
type: string
|
||||
clientUrl:
|
||||
type: string
|
||||
component:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
details:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
group:
|
||||
type: string
|
||||
routingKey:
|
||||
type: string
|
||||
sendResolved:
|
||||
nullable: true
|
||||
type: boolean
|
||||
severity:
|
||||
type: string
|
||||
source:
|
||||
type: string
|
||||
url:
|
||||
type: string
|
||||
required:
|
||||
- routingKey
|
||||
type: object
|
||||
AlertmanagertypesChannelSlackConfig:
|
||||
properties:
|
||||
apiUrl:
|
||||
type: string
|
||||
channel:
|
||||
type: string
|
||||
sendResolved:
|
||||
nullable: true
|
||||
type: boolean
|
||||
text:
|
||||
type: string
|
||||
title:
|
||||
type: string
|
||||
required:
|
||||
- apiUrl
|
||||
type: object
|
||||
AlertmanagertypesChannelWebhookConfig:
|
||||
properties:
|
||||
bearerToken:
|
||||
type: string
|
||||
password:
|
||||
type: string
|
||||
sendResolved:
|
||||
nullable: true
|
||||
type: boolean
|
||||
url:
|
||||
type: string
|
||||
username:
|
||||
type: string
|
||||
required:
|
||||
- url
|
||||
type: object
|
||||
AlertmanagertypesDeprecatedGettableAlert:
|
||||
properties:
|
||||
annotations:
|
||||
@@ -54,6 +427,30 @@ components:
|
||||
- rule
|
||||
- policy
|
||||
type: string
|
||||
AlertmanagertypesGettableNotificationChannel:
|
||||
properties:
|
||||
config:
|
||||
$ref: '#/components/schemas/AlertmanagertypesChannelConfig'
|
||||
createdAt:
|
||||
format: date-time
|
||||
type: string
|
||||
displayName:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
updatedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
required:
|
||||
- name
|
||||
- displayName
|
||||
- config
|
||||
- id
|
||||
- createdAt
|
||||
- updatedAt
|
||||
type: object
|
||||
AlertmanagertypesGettableRoutePolicy:
|
||||
properties:
|
||||
channels:
|
||||
@@ -356,6 +753,19 @@ components:
|
||||
required:
|
||||
- name
|
||||
type: object
|
||||
AlertmanagertypesPostableNotificationChannel:
|
||||
properties:
|
||||
config:
|
||||
$ref: '#/components/schemas/AlertmanagertypesChannelConfig'
|
||||
displayName:
|
||||
type: string
|
||||
generateName:
|
||||
type: boolean
|
||||
name:
|
||||
type: string
|
||||
required:
|
||||
- config
|
||||
type: object
|
||||
AlertmanagertypesPostablePlannedMaintenance:
|
||||
properties:
|
||||
alertIds:
|
||||
@@ -3064,11 +3474,6 @@ components:
|
||||
- tags
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesHeaderOptions:
|
||||
properties:
|
||||
hide:
|
||||
type: boolean
|
||||
type: object
|
||||
DashboardtypesHistogramBuckets:
|
||||
properties:
|
||||
bucketCount:
|
||||
@@ -3426,7 +3831,6 @@ components:
|
||||
signoz/NumberPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec'
|
||||
signoz/PieChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpec'
|
||||
signoz/TablePanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec'
|
||||
signoz/TextPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
|
||||
signoz/TimeSeriesPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec'
|
||||
propertyName: kind
|
||||
oneOf:
|
||||
@@ -3437,7 +3841,6 @@ components:
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
|
||||
type: object
|
||||
DashboardtypesPanelPluginKind:
|
||||
enum:
|
||||
@@ -3448,7 +3851,6 @@ components:
|
||||
- signoz/TablePanel
|
||||
- signoz/HistogramPanel
|
||||
- signoz/ListPanel
|
||||
- signoz/TextPanel
|
||||
type: string
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec:
|
||||
properties:
|
||||
@@ -3522,18 +3924,6 @@ components:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec:
|
||||
properties:
|
||||
kind:
|
||||
enum:
|
||||
- signoz/TextPanel
|
||||
type: string
|
||||
spec:
|
||||
$ref: '#/components/schemas/DashboardtypesTextPanelSpec'
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec:
|
||||
properties:
|
||||
kind:
|
||||
@@ -3825,37 +4215,6 @@ components:
|
||||
- color
|
||||
- columnName
|
||||
type: object
|
||||
DashboardtypesTextAlign:
|
||||
enum:
|
||||
- left
|
||||
- center
|
||||
- right
|
||||
type: string
|
||||
DashboardtypesTextMode:
|
||||
enum:
|
||||
- markdown
|
||||
type: string
|
||||
DashboardtypesTextPanelSpec:
|
||||
properties:
|
||||
headerOptions:
|
||||
$ref: '#/components/schemas/DashboardtypesHeaderOptions'
|
||||
mode:
|
||||
$ref: '#/components/schemas/DashboardtypesTextMode'
|
||||
presentation:
|
||||
$ref: '#/components/schemas/DashboardtypesTextPresentation'
|
||||
text:
|
||||
type: string
|
||||
type: object
|
||||
DashboardtypesTextPresentation:
|
||||
properties:
|
||||
background:
|
||||
nullable: true
|
||||
type: string
|
||||
textAlign:
|
||||
$ref: '#/components/schemas/DashboardtypesTextAlign'
|
||||
verticalAlign:
|
||||
$ref: '#/components/schemas/DashboardtypesVerticalAlign'
|
||||
type: object
|
||||
DashboardtypesTextVariableSpec:
|
||||
properties:
|
||||
constant:
|
||||
@@ -4065,12 +4424,6 @@ components:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesVerticalAlign:
|
||||
enum:
|
||||
- top
|
||||
- center
|
||||
- bottom
|
||||
type: string
|
||||
ErrorsJSON:
|
||||
properties:
|
||||
code:
|
||||
@@ -19366,6 +19719,69 @@ paths:
|
||||
summary: Get metrics treemap
|
||||
tags:
|
||||
- metrics
|
||||
/api/v2/notification_channels:
|
||||
post:
|
||||
deprecated: false
|
||||
description: This endpoint creates a notification channel
|
||||
operationId: CreateNotificationChannel
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AlertmanagertypesPostableNotificationChannel'
|
||||
responses:
|
||||
"201":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/AlertmanagertypesGettableNotificationChannel'
|
||||
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
|
||||
"409":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Conflict
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- notification-channel:create
|
||||
- tokenizer:
|
||||
- notification-channel:create
|
||||
summary: Create notification channel
|
||||
tags:
|
||||
- channels
|
||||
/api/v2/orgs/me:
|
||||
get:
|
||||
deprecated: false
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
package httplicensing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/http/render"
|
||||
"github.com/SigNoz/signoz/pkg/licensing"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/licensetypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type licensingAPI struct {
|
||||
licensing licensing.Licensing
|
||||
}
|
||||
|
||||
func NewLicensingAPI(licensing licensing.Licensing) licensing.API {
|
||||
return &licensingAPI{licensing: licensing}
|
||||
}
|
||||
|
||||
func (api *licensingAPI) Checkout(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
orgID, err := valuer.NewUUID(claims.OrgID)
|
||||
if err != nil {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "orgId is invalid"))
|
||||
return
|
||||
}
|
||||
|
||||
req := new(licensetypes.PostableSubscription)
|
||||
if err := json.NewDecoder(r.Body).Decode(req); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
gettableSubscription, err := api.licensing.Checkout(ctx, orgID, req)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusCreated, gettableSubscription)
|
||||
}
|
||||
|
||||
func (api *licensingAPI) Portal(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
orgID, err := valuer.NewUUID(claims.OrgID)
|
||||
if err != nil {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "orgId is invalid"))
|
||||
return
|
||||
}
|
||||
|
||||
req := new(licensetypes.PostableSubscription)
|
||||
if err := json.NewDecoder(r.Body).Decode(req); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
gettableSubscription, err := api.licensing.Portal(ctx, orgID, req)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusCreated, gettableSubscription)
|
||||
}
|
||||
@@ -2,12 +2,9 @@ package httplicensing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
|
||||
"github.com/SigNoz/signoz/ee/licensing/licensingstore/sqllicensingstore"
|
||||
"github.com/SigNoz/signoz/pkg/analytics"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
@@ -228,47 +225,6 @@ func (provider *provider) Refresh(ctx context.Context, organizationID valuer.UUI
|
||||
return nil
|
||||
}
|
||||
|
||||
func (provider *provider) Checkout(ctx context.Context, organizationID valuer.UUID, postableSubscription *licensetypes.PostableSubscription) (*licensetypes.GettableSubscription, error) {
|
||||
activeLicense, err := provider.GetActive(ctx, organizationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
body, err := json.Marshal(postableSubscription)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to marshal checkout payload")
|
||||
}
|
||||
|
||||
response, err := provider.zeus.GetCheckoutURL(ctx, activeLicense.Key, body)
|
||||
if err != nil {
|
||||
if errors.Ast(err, errors.TypeAlreadyExists) {
|
||||
return nil, errors.WithAdditionalf(err, "checkout has already been completed for this account. Please click 'Refresh Status' to sync your subscription")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &licensetypes.GettableSubscription{RedirectURL: gjson.GetBytes(response, "url").String()}, nil
|
||||
}
|
||||
|
||||
func (provider *provider) Portal(ctx context.Context, organizationID valuer.UUID, postableSubscription *licensetypes.PostableSubscription) (*licensetypes.GettableSubscription, error) {
|
||||
activeLicense, err := provider.GetActive(ctx, organizationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
body, err := json.Marshal(postableSubscription)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to marshal portal payload")
|
||||
}
|
||||
|
||||
response, err := provider.zeus.GetPortalURL(ctx, activeLicense.Key, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &licensetypes.GettableSubscription{RedirectURL: gjson.GetBytes(response, "url").String()}, nil
|
||||
}
|
||||
|
||||
func (provider *provider) GetFeatureFlags(ctx context.Context, organizationID valuer.UUID) ([]*licensetypes.Feature, error) {
|
||||
license, err := provider.GetActive(ctx, organizationID)
|
||||
if err != nil {
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/ee/licensing/httplicensing"
|
||||
"github.com/SigNoz/signoz/ee/query-service/usage"
|
||||
"github.com/SigNoz/signoz/pkg/global"
|
||||
"github.com/SigNoz/signoz/pkg/http/middleware"
|
||||
@@ -42,7 +41,6 @@ func NewAPIHandler(opts APIHandlerOptions, signoz *signoz.SigNoz, config signoz.
|
||||
IntegrationsController: opts.IntegrationsController,
|
||||
LogsParsingPipelineController: opts.LogsParsingPipelineController,
|
||||
FluxInterval: opts.FluxInterval,
|
||||
LicensingAPI: httplicensing.NewLicensingAPI(signoz.Licensing),
|
||||
Signoz: signoz,
|
||||
QueryParserAPI: queryparser.NewAPI(signoz.Instrumentation.ToProviderSettings(), signoz.QueryParser),
|
||||
}, config)
|
||||
@@ -72,10 +70,6 @@ func (ah *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) {
|
||||
// base overrides
|
||||
router.HandleFunc("/api/v1/version", am.OpenAccess(ah.getVersion)).Methods(http.MethodGet)
|
||||
|
||||
router.HandleFunc("/api/v1/checkout", am.AdminAccess(ah.LicensingAPI.Checkout)).Methods(http.MethodPost)
|
||||
router.HandleFunc("/api/v1/billing", am.AdminAccess(ah.getBilling)).Methods(http.MethodGet)
|
||||
router.HandleFunc("/api/v1/portal", am.AdminAccess(ah.LicensingAPI.Portal)).Methods(http.MethodPost)
|
||||
|
||||
// v4
|
||||
router.HandleFunc("/api/v4/query_range", am.ViewAccess(ah.queryRangeV4)).Methods(http.MethodPost)
|
||||
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/ee/query-service/model"
|
||||
)
|
||||
|
||||
type DayWiseBreakdown struct {
|
||||
Type string `json:"type"`
|
||||
Breakdown []DayWiseData `json:"breakdown"`
|
||||
}
|
||||
|
||||
type DayWiseData struct {
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
Count float64 `json:"count"`
|
||||
Size float64 `json:"size"`
|
||||
UnitPrice float64 `json:"unitPrice"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
Total float64 `json:"total"`
|
||||
}
|
||||
|
||||
type tierBreakdown struct {
|
||||
UnitPrice float64 `json:"unitPrice"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
TierStart int64 `json:"tierStart"`
|
||||
TierEnd int64 `json:"tierEnd"`
|
||||
TierCost float64 `json:"tierCost"`
|
||||
}
|
||||
|
||||
type usageResponse struct {
|
||||
Type string `json:"type"`
|
||||
Unit string `json:"unit"`
|
||||
Tiers []tierBreakdown `json:"tiers"`
|
||||
DayWiseBreakdown DayWiseBreakdown `json:"dayWiseBreakdown"`
|
||||
}
|
||||
|
||||
type details struct {
|
||||
Total float64 `json:"total"`
|
||||
Breakdown []usageResponse `json:"breakdown"`
|
||||
BaseFee float64 `json:"baseFee"`
|
||||
BillTotal float64 `json:"billTotal"`
|
||||
}
|
||||
|
||||
type billingData struct {
|
||||
BillingPeriodStart int64 `json:"billingPeriodStart"`
|
||||
BillingPeriodEnd int64 `json:"billingPeriodEnd"`
|
||||
Details details `json:"details"`
|
||||
Discount float64 `json:"discount"`
|
||||
SubscriptionStatus string `json:"subscriptionStatus"`
|
||||
}
|
||||
|
||||
func (ah *APIHandler) getBilling(w http.ResponseWriter, r *http.Request) {
|
||||
licenseKey := r.URL.Query().Get("licenseKey")
|
||||
|
||||
if licenseKey == "" {
|
||||
RespondError(w, model.BadRequest(fmt.Errorf("license key is required")), nil)
|
||||
return
|
||||
}
|
||||
|
||||
data, err := ah.Signoz.Zeus.GetMeters(r.Context(), licenseKey)
|
||||
if err != nil {
|
||||
RespondError(w, model.InternalError(err), nil)
|
||||
return
|
||||
}
|
||||
|
||||
var billing billingData
|
||||
if err := json.Unmarshal(data, &billing); err != nil {
|
||||
RespondError(w, model.InternalError(err), nil)
|
||||
return
|
||||
}
|
||||
|
||||
ah.Respond(w, billing)
|
||||
}
|
||||
@@ -3,56 +3,29 @@ package app
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"slices"
|
||||
|
||||
"go.opentelemetry.io/contrib/instrumentation/github.com/gorilla/mux/otelmux"
|
||||
"go.opentelemetry.io/otel/propagation"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
|
||||
"github.com/gorilla/handlers"
|
||||
|
||||
"github.com/rs/cors"
|
||||
"github.com/soheilhy/cmux"
|
||||
|
||||
"github.com/SigNoz/signoz/ee/query-service/app/api"
|
||||
"github.com/SigNoz/signoz/ee/query-service/usage"
|
||||
"github.com/SigNoz/signoz/pkg/http/middleware"
|
||||
"github.com/SigNoz/signoz/pkg/signoz"
|
||||
"github.com/SigNoz/signoz/pkg/web"
|
||||
|
||||
"log/slog"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
|
||||
baseapp "github.com/SigNoz/signoz/pkg/query-service/app"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/app/clickhouseReader"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/app/integrations"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/app/logparsingpipeline"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/app/opamp"
|
||||
opAmpModel "github.com/SigNoz/signoz/pkg/query-service/app/opamp/model"
|
||||
baseconst "github.com/SigNoz/signoz/pkg/query-service/constants"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/healthcheck"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/utils"
|
||||
)
|
||||
|
||||
// Server runs HTTP, Mux and a grpc server
|
||||
// Server runs auxiliary servers (opamp) alongside the signoz apiserver
|
||||
type Server struct {
|
||||
config signoz.Config
|
||||
signoz *signoz.SigNoz
|
||||
|
||||
// public http router
|
||||
httpConn net.Listener
|
||||
httpServer *http.Server
|
||||
httpHostPort string
|
||||
|
||||
opampServer *opamp.Server
|
||||
|
||||
// Usage manager
|
||||
usageManager *usage.Manager
|
||||
|
||||
unavailableChannel chan healthcheck.Status
|
||||
}
|
||||
|
||||
// NewServer creates and initializes Server
|
||||
@@ -127,22 +100,25 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Register the legacy query-service routes on the apiserver router. The
|
||||
// apiserver owns the HTTP server and applies the middleware chain at serve
|
||||
// time, so these routes get the same treatment as the apiserver routes.
|
||||
r := signoz.APIServer.Router()
|
||||
am := middleware.NewAuthZ(signoz.Instrumentation.Logger(), signoz.Modules.OrgGetter, signoz.Authz)
|
||||
|
||||
apiHandler.RegisterRoutes(r, am)
|
||||
apiHandler.RegisterLogsRoutes(r, am)
|
||||
apiHandler.RegisterIntegrationRoutes(r, am)
|
||||
apiHandler.RegisterQueryRangeV3Routes(r, am)
|
||||
apiHandler.RegisterQueryRangeV4Routes(r, am)
|
||||
apiHandler.RegisterMessagingQueuesRoutes(r, am)
|
||||
apiHandler.RegisterThirdPartyApiRoutes(r, am)
|
||||
apiHandler.RegisterTraceFunnelsRoutes(r, am)
|
||||
|
||||
s := &Server{
|
||||
config: config,
|
||||
signoz: signoz,
|
||||
httpHostPort: baseconst.HTTPHostPort,
|
||||
unavailableChannel: make(chan healthcheck.Status),
|
||||
usageManager: usageManager,
|
||||
usageManager: usageManager,
|
||||
}
|
||||
|
||||
httpServer, err := s.createPublicServer(apiHandler, signoz.Web)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.httpServer = httpServer
|
||||
|
||||
s.opampServer = opamp.InitializeServer(
|
||||
&opAmpModel.AllAgents, agentConfMgr, signoz.Instrumentation,
|
||||
)
|
||||
@@ -150,146 +126,18 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// HealthCheckStatus returns health check status channel a client can subscribe to
|
||||
func (s Server) HealthCheckStatus() chan healthcheck.Status {
|
||||
return s.unavailableChannel
|
||||
}
|
||||
|
||||
func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*http.Server, error) {
|
||||
r := baseapp.NewRouter()
|
||||
am := middleware.NewAuthZ(s.signoz.Instrumentation.Logger(), s.signoz.Modules.OrgGetter, s.signoz.Authz)
|
||||
|
||||
r.Use(middleware.NewRecovery(s.signoz.Instrumentation.Logger()).Wrap)
|
||||
r.Use(otelmux.Middleware(
|
||||
"apiserver",
|
||||
otelmux.WithMeterProvider(s.signoz.Instrumentation.MeterProvider()),
|
||||
otelmux.WithTracerProvider(s.signoz.Instrumentation.TracerProvider()),
|
||||
otelmux.WithPropagators(propagation.NewCompositeTextMapPropagator(propagation.Baggage{}, propagation.TraceContext{})),
|
||||
otelmux.WithFilter(func(r *http.Request) bool {
|
||||
return !slices.Contains([]string{"/api/v1/health"}, r.URL.Path)
|
||||
}),
|
||||
))
|
||||
r.Use(middleware.NewIdentN(s.signoz.IdentNResolver, s.signoz.Sharder, s.signoz.Instrumentation.Logger()).Wrap)
|
||||
r.Use(middleware.NewTimeout(s.signoz.Instrumentation.Logger(),
|
||||
s.config.APIServer.Timeout.ExcludedRoutes,
|
||||
s.config.APIServer.Timeout.Default,
|
||||
s.config.APIServer.Timeout.Max,
|
||||
).Wrap)
|
||||
r.Use(middleware.NewResource(s.signoz.Instrumentation.Logger()).Wrap)
|
||||
r.Use(middleware.NewAudit(s.signoz.Instrumentation.Logger(), s.config.APIServer.Logging.ExcludedRoutes, s.signoz.Auditor).Wrap)
|
||||
r.Use(middleware.NewComment().Wrap)
|
||||
|
||||
apiHandler.RegisterRoutes(r, am)
|
||||
apiHandler.RegisterLogsRoutes(r, am)
|
||||
apiHandler.RegisterIntegrationRoutes(r, am)
|
||||
apiHandler.RegisterQueryRangeV3Routes(r, am)
|
||||
apiHandler.RegisterQueryRangeV4Routes(r, am)
|
||||
apiHandler.RegisterWebSocketPaths(r, am)
|
||||
apiHandler.RegisterMessagingQueuesRoutes(r, am)
|
||||
apiHandler.RegisterThirdPartyApiRoutes(r, am)
|
||||
apiHandler.RegisterTraceFunnelsRoutes(r, am)
|
||||
|
||||
err := s.signoz.APIServer.AddToRouter(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c := cors.New(cors.Options{
|
||||
AllowedOrigins: []string{"*"},
|
||||
AllowedMethods: []string{"GET", "DELETE", "POST", "PUT", "PATCH", "OPTIONS"},
|
||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "cache-control", "X-SIGNOZ-QUERY-ID", "Sec-WebSocket-Protocol"},
|
||||
})
|
||||
|
||||
handler := c.Handler(r)
|
||||
|
||||
handler = handlers.CompressHandler(handler)
|
||||
|
||||
err = web.AddToRouter(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
routePrefix := s.config.Global.ExternalPath()
|
||||
if routePrefix != "" {
|
||||
prefixed := http.StripPrefix(routePrefix, handler)
|
||||
handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
switch req.URL.Path {
|
||||
case "/api/v1/health", "/api/v2/healthz", "/api/v2/readyz", "/api/v2/livez":
|
||||
r.ServeHTTP(w, req)
|
||||
return
|
||||
}
|
||||
|
||||
prefixed.ServeHTTP(w, req)
|
||||
})
|
||||
}
|
||||
|
||||
return &http.Server{
|
||||
Handler: handler,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// initListeners initialises listeners of the server
|
||||
func (s *Server) initListeners() error {
|
||||
// listen on public port
|
||||
var err error
|
||||
publicHostPort := s.httpHostPort
|
||||
if publicHostPort == "" {
|
||||
return fmt.Errorf("baseconst.HTTPHostPort is required")
|
||||
}
|
||||
|
||||
s.httpConn, err = net.Listen("tcp", publicHostPort)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
slog.Info(fmt.Sprintf("Query server started listening on %s...", s.httpHostPort))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Start listening on http and private http port concurrently
|
||||
// Start starts the opamp websocket server. The HTTP API server is started by
|
||||
// the signoz registry.
|
||||
func (s *Server) Start(ctx context.Context) error {
|
||||
err := s.initListeners()
|
||||
if err != nil {
|
||||
slog.Info("Starting OpAmp Websocket server", "addr", baseconst.OpAmpWsEndpoint)
|
||||
if err := s.opampServer.Start(baseconst.OpAmpWsEndpoint); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var httpPort int
|
||||
if port, err := utils.GetPort(s.httpConn.Addr()); err == nil {
|
||||
httpPort = port
|
||||
}
|
||||
|
||||
go func() {
|
||||
slog.Info("Starting HTTP server", "port", httpPort, "addr", s.httpHostPort)
|
||||
|
||||
switch err := s.httpServer.Serve(s.httpConn); err {
|
||||
case nil, http.ErrServerClosed, cmux.ErrListenerClosed:
|
||||
// normal exit, nothing to do
|
||||
default:
|
||||
slog.Error("Could not start HTTP server", errors.Attr(err))
|
||||
}
|
||||
s.unavailableChannel <- healthcheck.Unavailable
|
||||
}()
|
||||
|
||||
go func() {
|
||||
slog.Info("Starting OpAmp Websocket server", "addr", baseconst.OpAmpWsEndpoint)
|
||||
err := s.opampServer.Start(baseconst.OpAmpWsEndpoint)
|
||||
if err != nil {
|
||||
slog.Error("opamp ws server failed to start", errors.Attr(err))
|
||||
s.unavailableChannel <- healthcheck.Unavailable
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) Stop(ctx context.Context) error {
|
||||
if s.httpServer != nil {
|
||||
if err := s.httpServer.Shutdown(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
s.opampServer.Stop()
|
||||
|
||||
// stop usage manager
|
||||
|
||||
@@ -56,10 +56,10 @@ const config: Config.InitialOptions = {
|
||||
transformIgnorePatterns: [
|
||||
// @chenglou/pretext is ESM-only; @signozhq/ui pulls it in via text-ellipsis.
|
||||
// Pattern 1: allow .pnpm virtual store through (handled by pattern 2), plus root-level ESM packages.
|
||||
'node_modules/(?!(\\.pnpm|react-json-tree|react-base16-styling|lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou/pretext|@signozhq/design-tokens|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard|react-markdown|vfile|vfile-message|unist-util-stringify-position|unified|bail|is-plain-obj|trough|remark-parse|remark-gfm|mdast-util-gfm|mdast-util-gfm-autolink-literal|mdast-util-gfm-footnote|mdast-util-gfm-strikethrough|mdast-util-gfm-table|mdast-util-gfm-task-list-item|mdast-util-find-and-replace|mdast-util-phrasing|mdast-util-to-markdown|markdown-table|longest-streak|ccount|escape-string-regexp|mdast-util-from-markdown|mdast-util-to-string|micromark|micromark-core-commonmark|micromark-extension-gfm|micromark-extension-gfm-autolink-literal|micromark-extension-gfm-footnote|micromark-extension-gfm-strikethrough|micromark-extension-gfm-table|micromark-extension-gfm-tagfilter|micromark-extension-gfm-task-list-item|micromark-factory-destination|micromark-factory-label|micromark-factory-space|micromark-factory-title|micromark-factory-whitespace|micromark-util-character|micromark-util-chunked|micromark-util-classify-character|micromark-util-combine-extensions|micromark-util-decode-numeric-character-reference|micromark-util-decode-string|micromark-util-encode|micromark-util-html-tag-name|micromark-util-normalize-identifier|micromark-util-resolve-all|micromark-util-sanitize-uri|micromark-util-subtokenize|micromark-util-symbol|micromark-util-types|decode-named-character-reference|remark-rehype|mdast-util-to-hast|unist-util-position|trim-lines|unist-util-visit|unist-util-visit-parents|unist-util-is|unist-util-generated|mdast-util-definitions|property-information|hast-util-whitespace|space-separated-tokens|comma-separated-tokens|rehype-raw|hast-util-raw|hast-util-from-parse5|devlop|hastscript|hast-util-parse-selector|vfile-location|web-namespaces|hast-util-to-parse5|zwitch|html-void-elements)/)',
|
||||
'node_modules/(?!(\\.pnpm|react-json-tree|react-base16-styling|lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou/pretext|@signozhq/design-tokens|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard|react-markdown|vfile|vfile-message|unist-util-stringify-position|unified|bail|is-plain-obj|trough|remark-parse|mdast-util-from-markdown|mdast-util-to-string|micromark|micromark-core-commonmark|micromark-extension-gfm|micromark-extension-gfm-autolink-literal|micromark-extension-gfm-footnote|micromark-extension-gfm-strikethrough|micromark-extension-gfm-table|micromark-extension-gfm-tagfilter|micromark-extension-gfm-task-list-item|micromark-factory-destination|micromark-factory-label|micromark-factory-space|micromark-factory-title|micromark-factory-whitespace|micromark-util-character|micromark-util-chunked|micromark-util-classify-character|micromark-util-combine-extensions|micromark-util-decode-numeric-character-reference|micromark-util-decode-string|micromark-util-encode|micromark-util-html-tag-name|micromark-util-normalize-identifier|micromark-util-resolve-all|micromark-util-sanitize-uri|micromark-util-subtokenize|micromark-util-symbol|micromark-util-types|decode-named-character-reference|remark-rehype|mdast-util-to-hast|unist-util-position|trim-lines|unist-util-visit|unist-util-visit-parents|unist-util-is|unist-util-generated|mdast-util-definitions|property-information|hast-util-whitespace|space-separated-tokens|comma-separated-tokens|rehype-raw|hast-util-raw|hast-util-from-parse5|devlop|hastscript|hast-util-parse-selector|vfile-location|web-namespaces|hast-util-to-parse5|zwitch|html-void-elements)/)',
|
||||
// Pattern 2: pnpm virtual store — ignore everything except ESM-only packages.
|
||||
// pnpm encodes scoped packages as @scope+name@version, so match on scope prefix.
|
||||
'node_modules/\\.pnpm/(?!(react-json-tree|react-base16-styling|lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard|react-markdown|vfile|vfile-message|unist-util-stringify-position|unified|bail|is-plain-obj|trough|remark-parse|remark-gfm|mdast-util-gfm|mdast-util-gfm-autolink-literal|mdast-util-gfm-footnote|mdast-util-gfm-strikethrough|mdast-util-gfm-table|mdast-util-gfm-task-list-item|mdast-util-find-and-replace|mdast-util-phrasing|mdast-util-to-markdown|markdown-table|longest-streak|ccount|escape-string-regexp|mdast-util-from-markdown|mdast-util-to-string|micromark|decode-named-character-reference|remark-rehype|mdast-util-to-hast|unist-util-position|trim-lines|unist-util-visit|unist-util-visit-parents|unist-util-is|unist-util-generated|mdast-util-definitions|property-information|hast-util-whitespace|space-separated-tokens|comma-separated-tokens|rehype-raw|hast-util-raw|hast-util-from-parse5|devlop|hastscript|hast-util-parse-selector|vfile-location|web-namespaces|hast-util-to-parse5|zwitch|html-void-elements)[^/]*/node_modules)',
|
||||
'node_modules/\\.pnpm/(?!(react-json-tree|react-base16-styling|lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard|react-markdown|vfile|vfile-message|unist-util-stringify-position|unified|bail|is-plain-obj|trough|remark-parse|mdast-util-from-markdown|mdast-util-to-string|micromark|decode-named-character-reference|remark-rehype|mdast-util-to-hast|unist-util-position|trim-lines|unist-util-visit|unist-util-visit-parents|unist-util-is|unist-util-generated|mdast-util-definitions|property-information|hast-util-whitespace|space-separated-tokens|comma-separated-tokens|rehype-raw|hast-util-raw|hast-util-from-parse5|devlop|hastscript|hast-util-parse-selector|vfile-location|web-namespaces|hast-util-to-parse5|zwitch|html-void-elements)[^/]*/node_modules)',
|
||||
],
|
||||
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
|
||||
testPathIgnorePatterns: ['/node_modules/', '/public/'],
|
||||
|
||||
@@ -169,12 +169,12 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
|
||||
// Check for workspace blocked (trial expired)
|
||||
if (!isFetchingActiveLicense && isCloudPlatform && trialInfo?.workSpaceBlock) {
|
||||
const isRouteEnabledForWorkspaceBlockedState =
|
||||
isAdmin &&
|
||||
(pathname === ROUTES.SETTINGS ||
|
||||
pathname === ROUTES.ORG_SETTINGS ||
|
||||
pathname === ROUTES.MEMBERS_SETTINGS ||
|
||||
pathname === ROUTES.BILLING ||
|
||||
pathname === ROUTES.MY_SETTINGS);
|
||||
pathname === ROUTES.SETTINGS ||
|
||||
pathname === ROUTES.BILLING ||
|
||||
(isAdmin &&
|
||||
(pathname === ROUTES.ORG_SETTINGS ||
|
||||
pathname === ROUTES.MEMBERS_SETTINGS ||
|
||||
pathname === ROUTES.MY_SETTINGS));
|
||||
|
||||
if (
|
||||
pathname !== ROUTES.WORKSPACE_LOCKED &&
|
||||
|
||||
@@ -739,7 +739,7 @@ describe('PrivateRoute', () => {
|
||||
assertStaysOnRoute(ROUTES.MY_SETTINGS);
|
||||
});
|
||||
|
||||
it('should redirect VIEWER to workspace locked even when trying to access settings', async () => {
|
||||
it('should allow VIEWER to access /settings when workspace is blocked', () => {
|
||||
renderPrivateRoute({
|
||||
initialRoute: ROUTES.SETTINGS,
|
||||
appContext: {
|
||||
@@ -752,10 +752,10 @@ describe('PrivateRoute', () => {
|
||||
isCloudUser: true,
|
||||
});
|
||||
|
||||
await assertRedirectsTo(ROUTES.WORKSPACE_LOCKED);
|
||||
assertStaysOnRoute(ROUTES.SETTINGS);
|
||||
});
|
||||
|
||||
it('should redirect VIEWER to workspace locked when trying to access billing', async () => {
|
||||
it('should allow VIEWER to access /settings/billing when workspace is blocked', () => {
|
||||
renderPrivateRoute({
|
||||
initialRoute: ROUTES.BILLING,
|
||||
appContext: {
|
||||
@@ -768,7 +768,7 @@ describe('PrivateRoute', () => {
|
||||
isCloudUser: true,
|
||||
});
|
||||
|
||||
await assertRedirectsTo(ROUTES.WORKSPACE_LOCKED);
|
||||
assertStaysOnRoute(ROUTES.BILLING);
|
||||
});
|
||||
|
||||
it('should redirect VIEWER to workspace locked when trying to access org-settings', async () => {
|
||||
@@ -819,7 +819,7 @@ describe('PrivateRoute', () => {
|
||||
await assertRedirectsTo(ROUTES.WORKSPACE_LOCKED);
|
||||
});
|
||||
|
||||
it('should redirect EDITOR to workspace locked when trying to access settings', async () => {
|
||||
it('should allow EDITOR to access /settings when workspace is blocked', () => {
|
||||
renderPrivateRoute({
|
||||
initialRoute: ROUTES.SETTINGS,
|
||||
appContext: {
|
||||
@@ -832,7 +832,7 @@ describe('PrivateRoute', () => {
|
||||
isCloudUser: true,
|
||||
});
|
||||
|
||||
await assertRedirectsTo(ROUTES.WORKSPACE_LOCKED);
|
||||
assertStaysOnRoute(ROUTES.SETTINGS);
|
||||
});
|
||||
|
||||
it('should not redirect when already on workspace locked page', () => {
|
||||
@@ -1626,6 +1626,7 @@ describe('PrivateRoute', () => {
|
||||
path: ROUTES.WORKSPACE_ACCESS_RESTRICTED,
|
||||
deniedRoles: DENIED_ROLES,
|
||||
},
|
||||
BILLING: { path: ROUTES.BILLING, deniedRoles: DENIED_ROLES },
|
||||
};
|
||||
|
||||
const authzRouteRolePairs: [string, string, ROLES][] = Object.entries(
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
|
||||
export interface DayBreakdownEntry {
|
||||
timestamp: number;
|
||||
total: number;
|
||||
quantity: number;
|
||||
count: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface TierEntry {
|
||||
quantity: number;
|
||||
unitPrice: number;
|
||||
tierCost: number;
|
||||
}
|
||||
|
||||
export interface BreakdownEntry {
|
||||
type: string;
|
||||
unit: string;
|
||||
dayWiseBreakdown: {
|
||||
breakdown: DayBreakdownEntry[];
|
||||
};
|
||||
tiers?: TierEntry[];
|
||||
}
|
||||
|
||||
export interface UsageResponsePayloadProps {
|
||||
billingPeriodStart: number;
|
||||
billingPeriodEnd: number;
|
||||
details: {
|
||||
total: number;
|
||||
baseFee: number;
|
||||
breakdown: BreakdownEntry[];
|
||||
billTotal: number;
|
||||
};
|
||||
discount: number;
|
||||
subscriptionStatus?: string;
|
||||
}
|
||||
|
||||
const getUsage = async (
|
||||
licenseKey: string,
|
||||
): Promise<SuccessResponse<UsageResponsePayloadProps> | ErrorResponse> => {
|
||||
try {
|
||||
const response = await axios.get(`/billing?licenseKey=${licenseKey}`);
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: response.data.status,
|
||||
payload: response.data.data,
|
||||
};
|
||||
} catch (error) {
|
||||
return ErrorResponseHandler(error as AxiosError);
|
||||
}
|
||||
};
|
||||
|
||||
export default getUsage;
|
||||
@@ -19,8 +19,10 @@ import type {
|
||||
|
||||
import type {
|
||||
AlertmanagertypesPostableChannelDTO,
|
||||
AlertmanagertypesPostableNotificationChannelDTO,
|
||||
AlertmanagertypesReceiverDTO,
|
||||
CreateChannel201,
|
||||
CreateNotificationChannel201,
|
||||
DeleteChannelByIDPathParameters,
|
||||
GetChannelByID200,
|
||||
GetChannelByIDPathParameters,
|
||||
@@ -647,3 +649,87 @@ export const useTestChannelDeprecated = <
|
||||
> => {
|
||||
return useMutation(getTestChannelDeprecatedMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint creates a notification channel
|
||||
* @summary Create notification channel
|
||||
*/
|
||||
export const createNotificationChannel = (
|
||||
alertmanagertypesPostableNotificationChannelDTO?: BodyType<AlertmanagertypesPostableNotificationChannelDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<CreateNotificationChannel201>({
|
||||
url: `/api/v2/notification_channels`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: alertmanagertypesPostableNotificationChannelDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getCreateNotificationChannelMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createNotificationChannel>>,
|
||||
TError,
|
||||
{ data?: BodyType<AlertmanagertypesPostableNotificationChannelDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createNotificationChannel>>,
|
||||
TError,
|
||||
{ data?: BodyType<AlertmanagertypesPostableNotificationChannelDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['createNotificationChannel'];
|
||||
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 createNotificationChannel>>,
|
||||
{ data?: BodyType<AlertmanagertypesPostableNotificationChannelDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return createNotificationChannel(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type CreateNotificationChannelMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof createNotificationChannel>>
|
||||
>;
|
||||
export type CreateNotificationChannelMutationBody =
|
||||
| BodyType<AlertmanagertypesPostableNotificationChannelDTO>
|
||||
| undefined;
|
||||
export type CreateNotificationChannelMutationError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Create notification channel
|
||||
*/
|
||||
export const useCreateNotificationChannel = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createNotificationChannel>>,
|
||||
TError,
|
||||
{ data?: BodyType<AlertmanagertypesPostableNotificationChannelDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof createNotificationChannel>>,
|
||||
TError,
|
||||
{ data?: BodyType<AlertmanagertypesPostableNotificationChannelDTO> },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getCreateNotificationChannelMutationOptions(options));
|
||||
};
|
||||
|
||||
@@ -37,6 +37,476 @@ export interface AlertmanagertypesChannelDTO {
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelSlackConfigDTOKind {
|
||||
slack = 'slack',
|
||||
}
|
||||
export interface AlertmanagertypesChannelSlackConfigDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
apiUrl: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
channel?: string;
|
||||
/**
|
||||
* @type boolean,null
|
||||
*/
|
||||
sendResolved?: boolean | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
text?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelSlackConfigDTO {
|
||||
/**
|
||||
* @enum slack
|
||||
* @type string
|
||||
*/
|
||||
kind: AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelSlackConfigDTOKind;
|
||||
spec: AlertmanagertypesChannelSlackConfigDTO;
|
||||
}
|
||||
|
||||
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelEmailConfigDTOKind {
|
||||
email = 'email',
|
||||
}
|
||||
export type AlertmanagertypesChannelEmailConfigDTOHeaders = {
|
||||
[key: string]: string;
|
||||
};
|
||||
|
||||
export interface AlertmanagertypesChannelEmailConfigDTO {
|
||||
/**
|
||||
* @type object
|
||||
*/
|
||||
headers?: AlertmanagertypesChannelEmailConfigDTOHeaders;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
html?: string;
|
||||
/**
|
||||
* @type boolean,null
|
||||
*/
|
||||
sendResolved?: boolean | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
to: string;
|
||||
}
|
||||
|
||||
export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelEmailConfigDTO {
|
||||
/**
|
||||
* @enum email
|
||||
* @type string
|
||||
*/
|
||||
kind: AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelEmailConfigDTOKind;
|
||||
spec: AlertmanagertypesChannelEmailConfigDTO;
|
||||
}
|
||||
|
||||
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelWebhookConfigDTOKind {
|
||||
webhook = 'webhook',
|
||||
}
|
||||
export interface AlertmanagertypesChannelWebhookConfigDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
bearerToken?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
password?: string;
|
||||
/**
|
||||
* @type boolean,null
|
||||
*/
|
||||
sendResolved?: boolean | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
url: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
username?: string;
|
||||
}
|
||||
|
||||
export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelWebhookConfigDTO {
|
||||
/**
|
||||
* @enum webhook
|
||||
* @type string
|
||||
*/
|
||||
kind: AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelWebhookConfigDTOKind;
|
||||
spec: AlertmanagertypesChannelWebhookConfigDTO;
|
||||
}
|
||||
|
||||
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelPagerdutyConfigDTOKind {
|
||||
pagerduty = 'pagerduty',
|
||||
}
|
||||
export type AlertmanagertypesChannelPagerdutyConfigDTODetails = {
|
||||
[key: string]: string;
|
||||
};
|
||||
|
||||
export interface AlertmanagertypesChannelPagerdutyConfigDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
class?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
client?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
clientUrl?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
component?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* @type object
|
||||
*/
|
||||
details?: AlertmanagertypesChannelPagerdutyConfigDTODetails;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
group?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
routingKey: string;
|
||||
/**
|
||||
* @type boolean,null
|
||||
*/
|
||||
sendResolved?: boolean | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
severity?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
source?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelPagerdutyConfigDTO {
|
||||
/**
|
||||
* @enum pagerduty
|
||||
* @type string
|
||||
*/
|
||||
kind: AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelPagerdutyConfigDTOKind;
|
||||
spec: AlertmanagertypesChannelPagerdutyConfigDTO;
|
||||
}
|
||||
|
||||
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelOpsgenieConfigDTOKind {
|
||||
opsgenie = 'opsgenie',
|
||||
}
|
||||
export type AlertmanagertypesChannelOpsgenieConfigDTODetails = {
|
||||
[key: string]: string;
|
||||
};
|
||||
|
||||
export interface AlertmanagertypesChannelOpsgenieConfigDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
apiKey: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
apiUrl?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* @type object
|
||||
*/
|
||||
details?: AlertmanagertypesChannelOpsgenieConfigDTODetails;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
message?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
priority?: string;
|
||||
/**
|
||||
* @type boolean,null
|
||||
*/
|
||||
sendResolved?: boolean | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
source?: string;
|
||||
}
|
||||
|
||||
export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelOpsgenieConfigDTO {
|
||||
/**
|
||||
* @enum opsgenie
|
||||
* @type string
|
||||
*/
|
||||
kind: AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelOpsgenieConfigDTOKind;
|
||||
spec: AlertmanagertypesChannelOpsgenieConfigDTO;
|
||||
}
|
||||
|
||||
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelMSTeamsConfigDTOKind {
|
||||
msteams = 'msteams',
|
||||
}
|
||||
export interface AlertmanagertypesChannelMSTeamsConfigDTO {
|
||||
/**
|
||||
* @type boolean,null
|
||||
*/
|
||||
sendResolved?: boolean | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
text?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
title?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
webhookUrl: string;
|
||||
}
|
||||
|
||||
export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelMSTeamsConfigDTO {
|
||||
/**
|
||||
* @enum msteams
|
||||
* @type string
|
||||
*/
|
||||
kind: AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelMSTeamsConfigDTOKind;
|
||||
spec: AlertmanagertypesChannelMSTeamsConfigDTO;
|
||||
}
|
||||
|
||||
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelGoogleChatConfigDTOKind {
|
||||
googlechat = 'googlechat',
|
||||
}
|
||||
export interface AlertmanagertypesChannelGoogleChatConfigDTO {
|
||||
/**
|
||||
* @type boolean,null
|
||||
*/
|
||||
sendResolved?: boolean | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
text?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
title?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
webhookUrl: string;
|
||||
}
|
||||
|
||||
export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelGoogleChatConfigDTO {
|
||||
/**
|
||||
* @enum googlechat
|
||||
* @type string
|
||||
*/
|
||||
kind: AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelGoogleChatConfigDTOKind;
|
||||
spec: AlertmanagertypesChannelGoogleChatConfigDTO;
|
||||
}
|
||||
|
||||
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJiraConfigDTOKind {
|
||||
jira = 'jira',
|
||||
}
|
||||
export type AlertmanagertypesChannelJiraConfigDTOCustomFields = {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export interface AlertmanagertypesChannelJiraConfigDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
apiToken: string;
|
||||
/**
|
||||
* @type object
|
||||
*/
|
||||
customFields?: AlertmanagertypesChannelJiraConfigDTOCustomFields;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
email: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
issueType: string;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
labels?: string[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
priority?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
project: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
reopenDuration?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
reopenTransition?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
resolveTransition?: string;
|
||||
/**
|
||||
* @type boolean,null
|
||||
*/
|
||||
sendResolved?: boolean | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
site: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
summary?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
wontFixResolution?: string;
|
||||
}
|
||||
|
||||
export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJiraConfigDTO {
|
||||
/**
|
||||
* @enum jira
|
||||
* @type string
|
||||
*/
|
||||
kind: AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJiraConfigDTOKind;
|
||||
spec: AlertmanagertypesChannelJiraConfigDTO;
|
||||
}
|
||||
|
||||
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJSMOpsConfigDTOKind {
|
||||
jsmops = 'jsmops',
|
||||
}
|
||||
export interface AlertmanagertypesChannelJSMOpsConfigDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
apiKey: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
message?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
priority?: string;
|
||||
/**
|
||||
* @type boolean,null
|
||||
*/
|
||||
sendResolved?: boolean | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
tags?: string;
|
||||
}
|
||||
|
||||
export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJSMOpsConfigDTO {
|
||||
/**
|
||||
* @enum jsmops
|
||||
* @type string
|
||||
*/
|
||||
kind: AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJSMOpsConfigDTOKind;
|
||||
spec: AlertmanagertypesChannelJSMOpsConfigDTO;
|
||||
}
|
||||
|
||||
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelIncidentIOConfigDTOKind {
|
||||
incidentio = 'incidentio',
|
||||
}
|
||||
export type AlertmanagertypesChannelIncidentIOConfigDTOMetadata = {
|
||||
[key: string]: string;
|
||||
};
|
||||
|
||||
export interface AlertmanagertypesChannelIncidentIOConfigDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* @type object
|
||||
*/
|
||||
metadata?: AlertmanagertypesChannelIncidentIOConfigDTOMetadata;
|
||||
/**
|
||||
* @type boolean,null
|
||||
*/
|
||||
sendResolved?: boolean | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
title?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
token: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelIncidentIOConfigDTO {
|
||||
/**
|
||||
* @enum incidentio
|
||||
* @type string
|
||||
*/
|
||||
kind: AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelIncidentIOConfigDTOKind;
|
||||
spec: AlertmanagertypesChannelIncidentIOConfigDTO;
|
||||
}
|
||||
|
||||
export type AlertmanagertypesChannelConfigDTO =
|
||||
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelSlackConfigDTO
|
||||
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelEmailConfigDTO
|
||||
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelWebhookConfigDTO
|
||||
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelPagerdutyConfigDTO
|
||||
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelOpsgenieConfigDTO
|
||||
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelMSTeamsConfigDTO
|
||||
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelGoogleChatConfigDTO
|
||||
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJiraConfigDTO
|
||||
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJSMOpsConfigDTO
|
||||
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelIncidentIOConfigDTO;
|
||||
|
||||
export enum AlertmanagertypesChannelKindDTO {
|
||||
slack = 'slack',
|
||||
email = 'email',
|
||||
webhook = 'webhook',
|
||||
pagerduty = 'pagerduty',
|
||||
opsgenie = 'opsgenie',
|
||||
msteams = 'msteams',
|
||||
googlechat = 'googlechat',
|
||||
jira = 'jira',
|
||||
jsmops = 'jsmops',
|
||||
incidentio = 'incidentio',
|
||||
}
|
||||
export interface ModelLabelSetDTO {
|
||||
[key: string]: string;
|
||||
}
|
||||
@@ -88,6 +558,32 @@ export enum AlertmanagertypesExpressionKindDTO {
|
||||
rule = 'rule',
|
||||
policy = 'policy',
|
||||
}
|
||||
export interface AlertmanagertypesGettableNotificationChannelDTO {
|
||||
config: AlertmanagertypesChannelConfigDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
displayName: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface AlertmanagertypesGettableRoutePolicyDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
@@ -1748,6 +2244,22 @@ export type AlertmanagertypesPostableChannelDTO = unknown & {
|
||||
wechat_configs?: ConfigWechatConfigDTO[];
|
||||
};
|
||||
|
||||
export interface AlertmanagertypesPostableNotificationChannelDTO {
|
||||
config: AlertmanagertypesChannelConfigDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
displayName?: string;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
generateName?: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface AlertmanagertypesPostablePlannedMaintenanceDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
@@ -4402,57 +4914,6 @@ export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDa
|
||||
spec: DashboardtypesListPanelSpecDTO;
|
||||
}
|
||||
|
||||
export enum DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpecDTOKind {
|
||||
'signoz/TextPanel' = 'signoz/TextPanel',
|
||||
}
|
||||
export interface DashboardtypesHeaderOptionsDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
hide?: boolean;
|
||||
}
|
||||
|
||||
export enum DashboardtypesTextModeDTO {
|
||||
markdown = 'markdown',
|
||||
}
|
||||
export enum DashboardtypesTextAlignDTO {
|
||||
left = 'left',
|
||||
center = 'center',
|
||||
right = 'right',
|
||||
}
|
||||
export enum DashboardtypesVerticalAlignDTO {
|
||||
top = 'top',
|
||||
center = 'center',
|
||||
bottom = 'bottom',
|
||||
}
|
||||
export interface DashboardtypesTextPresentationDTO {
|
||||
/**
|
||||
* @type string,null
|
||||
*/
|
||||
background?: string | null;
|
||||
textAlign?: DashboardtypesTextAlignDTO;
|
||||
verticalAlign?: DashboardtypesVerticalAlignDTO;
|
||||
}
|
||||
|
||||
export interface DashboardtypesTextPanelSpecDTO {
|
||||
headerOptions?: DashboardtypesHeaderOptionsDTO;
|
||||
mode?: DashboardtypesTextModeDTO;
|
||||
presentation?: DashboardtypesTextPresentationDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpecDTO {
|
||||
/**
|
||||
* @enum signoz/TextPanel
|
||||
* @type string
|
||||
*/
|
||||
kind: DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpecDTOKind;
|
||||
spec: DashboardtypesTextPanelSpecDTO;
|
||||
}
|
||||
|
||||
export type DashboardtypesPanelPluginDTO =
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpecDTO
|
||||
@@ -4460,8 +4921,7 @@ export type DashboardtypesPanelPluginDTO =
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpecDTO;
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpecDTO;
|
||||
|
||||
export enum Querybuildertypesv5RequestTypeDTO {
|
||||
scalar = 'scalar',
|
||||
@@ -5383,7 +5843,6 @@ export enum DashboardtypesPanelPluginKindDTO {
|
||||
'signoz/TablePanel' = 'signoz/TablePanel',
|
||||
'signoz/HistogramPanel' = 'signoz/HistogramPanel',
|
||||
'signoz/ListPanel' = 'signoz/ListPanel',
|
||||
'signoz/TextPanel' = 'signoz/TextPanel',
|
||||
}
|
||||
/**
|
||||
* @nullable
|
||||
@@ -12703,6 +13162,14 @@ export type GetMetricsTreemap200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type CreateNotificationChannel201 = {
|
||||
data: AlertmanagertypesGettableNotificationChannelDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetMyOrganization200 = {
|
||||
data: TypesOrganizationDTO;
|
||||
/**
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import { PayloadProps, Props } from 'types/api/quickFilters/getCustomFilters';
|
||||
|
||||
const getCustomFilters = async (
|
||||
props: Props,
|
||||
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
|
||||
const { signal } = props;
|
||||
try {
|
||||
const response = await axios.get(`/orgs/me/filters/${signal}`);
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: 'Success',
|
||||
payload: response.data.data,
|
||||
};
|
||||
} catch (error) {
|
||||
return ErrorResponseHandler(error as AxiosError);
|
||||
}
|
||||
};
|
||||
|
||||
export default getCustomFilters;
|
||||
@@ -1,13 +0,0 @@
|
||||
import axios from 'api';
|
||||
import { AxiosError } from 'axios';
|
||||
import { SuccessResponse } from 'types/api';
|
||||
import { UpdateCustomFiltersProps } from 'types/api/quickFilters/updateCustomFilters';
|
||||
|
||||
const updateCustomFiltersAPI = async (
|
||||
props: UpdateCustomFiltersProps,
|
||||
): Promise<SuccessResponse<void> | AxiosError> =>
|
||||
axios.put(`/orgs/me/filters`, {
|
||||
...props.data,
|
||||
});
|
||||
|
||||
export default updateCustomFiltersAPI;
|
||||
@@ -1,28 +0,0 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import {
|
||||
CheckoutRequestPayloadProps,
|
||||
CheckoutSuccessPayloadProps,
|
||||
PayloadProps,
|
||||
} from 'types/api/billing/checkout';
|
||||
|
||||
const updateCreditCardApi = async (
|
||||
props: CheckoutRequestPayloadProps,
|
||||
): Promise<SuccessResponseV2<CheckoutSuccessPayloadProps>> => {
|
||||
try {
|
||||
const response = await axios.post<PayloadProps>('/checkout', {
|
||||
url: props.url,
|
||||
});
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: response.data.data,
|
||||
};
|
||||
} catch (error) {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
}
|
||||
};
|
||||
|
||||
export default updateCreditCardApi;
|
||||
@@ -1,28 +0,0 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import {
|
||||
CheckoutRequestPayloadProps,
|
||||
CheckoutSuccessPayloadProps,
|
||||
PayloadProps,
|
||||
} from 'types/api/billing/checkout';
|
||||
|
||||
const manageCreditCardApi = async (
|
||||
props: CheckoutRequestPayloadProps,
|
||||
): Promise<SuccessResponseV2<CheckoutSuccessPayloadProps>> => {
|
||||
try {
|
||||
const response = await axios.post<PayloadProps>('/portal', {
|
||||
url: props.url,
|
||||
});
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: response.data.data,
|
||||
};
|
||||
} catch (error) {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
}
|
||||
};
|
||||
|
||||
export default manageCreditCardApi;
|
||||
@@ -2,8 +2,10 @@ import { cloneDeep, isEmpty } from 'lodash-es';
|
||||
import { SuccessResponse, Warning } from 'types/api';
|
||||
import { MetricRangePayloadV3 } from 'types/api/metrics/getQueryRange';
|
||||
import {
|
||||
BuilderQuery,
|
||||
DistributionData,
|
||||
MetricRangePayloadV5,
|
||||
QueryEnvelope,
|
||||
QueryRangeRequestV5,
|
||||
RawData,
|
||||
ScalarData,
|
||||
@@ -11,6 +13,11 @@ import {
|
||||
} from 'types/api/v5/queryRange';
|
||||
import { QueryDataV3 } from 'types/api/widgets/getQuery';
|
||||
|
||||
const isBuilderQueryEnvelope = (
|
||||
envelope: QueryEnvelope,
|
||||
): envelope is QueryEnvelope & { spec: BuilderQuery } =>
|
||||
envelope.type === 'builder_query' || envelope.type === 'builder_ai_query';
|
||||
|
||||
function getColName(
|
||||
col: ScalarData['columns'][number],
|
||||
legendMap: Record<string, string>,
|
||||
@@ -409,21 +416,19 @@ export function convertV5ResponseToLegacy(
|
||||
const v5Data = payload?.data;
|
||||
|
||||
const aggregationPerQuery =
|
||||
params?.compositeQuery?.queries
|
||||
?.filter((query) => query.type === 'builder_query')
|
||||
.reduce(
|
||||
(acc, query) => {
|
||||
if (
|
||||
query.type === 'builder_query' &&
|
||||
'aggregations' in query.spec &&
|
||||
query.spec.name
|
||||
) {
|
||||
acc[query.spec.name] = query.spec.aggregations;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, any>,
|
||||
) || {};
|
||||
params?.compositeQuery?.queries?.filter(isBuilderQueryEnvelope).reduce(
|
||||
(acc, query) => {
|
||||
if (
|
||||
isBuilderQueryEnvelope(query) &&
|
||||
'aggregations' in query.spec &&
|
||||
query.spec.name
|
||||
) {
|
||||
acc[query.spec.name] = query.spec.aggregations;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, any>,
|
||||
) || {};
|
||||
|
||||
// clickhouse_sql queries have no aggregation metadata; their value columns
|
||||
// are named/keyed by the real SQL alias the response carries (see getColId).
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
QueryBuilderFormula as V5QueryBuilderFormula,
|
||||
QueryEnvelope,
|
||||
QueryRangePayloadV5,
|
||||
RequestType,
|
||||
} from 'types/api/v5/queryRange';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
|
||||
@@ -935,3 +936,41 @@ describe('convertBuilderQueriesToV5 having normalization', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertBuilderQueriesToV5 builder query type', () => {
|
||||
const buildEnvelope = (
|
||||
builderQueryType: IBuilderQuery['builderQueryType'],
|
||||
requestType: RequestType,
|
||||
): QueryEnvelope => {
|
||||
const [envelope] = convertBuilderQueriesToV5(
|
||||
{
|
||||
A: {
|
||||
dataSource: DataSource.TRACES,
|
||||
queryName: 'A',
|
||||
builderQueryType,
|
||||
} as unknown as IBuilderQuery,
|
||||
},
|
||||
requestType,
|
||||
);
|
||||
return envelope;
|
||||
};
|
||||
|
||||
it.each<[RequestType]>([
|
||||
['trace'],
|
||||
['raw'],
|
||||
['time_series'],
|
||||
['scalar'],
|
||||
['distribution'],
|
||||
])('sends builder_ai_query for the %s request type', (requestType) => {
|
||||
expect(buildEnvelope('builder_ai_query', requestType).type).toBe(
|
||||
'builder_ai_query',
|
||||
);
|
||||
});
|
||||
|
||||
it.each<[string, IBuilderQuery['builderQueryType']]>([
|
||||
['an unmarked query', undefined],
|
||||
['an explicitly generic query', 'builder_query'],
|
||||
])('sends builder_query for %s', (_label, builderQueryType) => {
|
||||
expect(buildEnvelope(builderQueryType, 'trace').type).toBe('builder_query');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -365,7 +365,7 @@ export function convertBuilderQueriesToV5(
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'builder_query' as QueryType,
|
||||
type: queryData.builderQueryType ?? 'builder_query',
|
||||
spec,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -4,11 +4,12 @@ import { useLocation } from 'react-router-dom';
|
||||
import { Button, Modal } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import updateCreditCardApi from 'api/v1/checkout/create';
|
||||
import { createSubscription } from 'api/generated/services/subscriptions';
|
||||
import type { CreateSubscription201 } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import { SubscriptionCreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/subscription.permissions';
|
||||
import { CreditCard, MessageSquareText, X } from '@signozhq/icons';
|
||||
import { SuccessResponseV2 } from 'types/api';
|
||||
import { CheckoutSuccessPayloadProps } from 'types/api/billing/checkout';
|
||||
import APIError from 'types/api/error';
|
||||
import { getBaseUrl } from 'utils/basePath';
|
||||
|
||||
@@ -18,9 +19,7 @@ export default function ChatSupportGateway(): JSX.Element {
|
||||
const [isAddCreditCardModalOpen, setIsAddCreditCardModalOpen] =
|
||||
useState(false);
|
||||
|
||||
const handleBillingOnSuccess = (
|
||||
data: SuccessResponseV2<CheckoutSuccessPayloadProps>,
|
||||
): void => {
|
||||
const handleBillingOnSuccess = (data: CreateSubscription201): void => {
|
||||
if (data?.data?.redirectURL) {
|
||||
const newTab = document.createElement('a');
|
||||
newTab.href = data.data.redirectURL;
|
||||
@@ -38,7 +37,7 @@ export default function ChatSupportGateway(): JSX.Element {
|
||||
};
|
||||
|
||||
const { mutate: updateCreditCard, isLoading: isLoadingBilling } = useMutation(
|
||||
updateCreditCardApi,
|
||||
createSubscription,
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
handleBillingOnSuccess(data);
|
||||
@@ -94,18 +93,23 @@ export default function ChatSupportGateway(): JSX.Element {
|
||||
>
|
||||
Cancel
|
||||
</Button>,
|
||||
<Button
|
||||
<AuthZTooltip
|
||||
key="submit"
|
||||
type="primary"
|
||||
icon={<CreditCard size={16} />}
|
||||
size="middle"
|
||||
loading={isLoadingBilling}
|
||||
disabled={isLoadingBilling}
|
||||
onClick={handleAddCreditCard}
|
||||
className="add-credit-card-btn"
|
||||
checks={[SubscriptionCreatePermission]}
|
||||
withPortal={false}
|
||||
>
|
||||
Add Credit Card
|
||||
</Button>,
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<CreditCard size={16} />}
|
||||
size="middle"
|
||||
loading={isLoadingBilling}
|
||||
disabled={isLoadingBilling}
|
||||
onClick={handleAddCreditCard}
|
||||
className="add-credit-card-btn"
|
||||
>
|
||||
Add Credit Card
|
||||
</Button>
|
||||
</AuthZTooltip>,
|
||||
]}
|
||||
>
|
||||
<Typography.Text className="add-credit-card-text">
|
||||
|
||||
@@ -4,16 +4,17 @@ import { useLocation } from 'react-router-dom';
|
||||
import { Button, Modal, Tooltip } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import updateCreditCardApi from 'api/v1/checkout/create';
|
||||
import { createSubscription } from 'api/generated/services/subscriptions';
|
||||
import type { CreateSubscription201 } from 'api/generated/services/sigNoz.schemas';
|
||||
import cx from 'classnames';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import { SubscriptionCreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/subscription.permissions';
|
||||
import { defaultTo } from 'lodash-es';
|
||||
import { CircleHelp, CreditCard, X } from '@signozhq/icons';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { SuccessResponseV2 } from 'types/api';
|
||||
import { CheckoutSuccessPayloadProps } from 'types/api/billing/checkout';
|
||||
import APIError from 'types/api/error';
|
||||
import { getBaseUrl } from 'utils/basePath';
|
||||
|
||||
@@ -118,9 +119,7 @@ function LaunchChatSupport({
|
||||
}
|
||||
};
|
||||
|
||||
const handleBillingOnSuccess = (
|
||||
data: SuccessResponseV2<CheckoutSuccessPayloadProps>,
|
||||
): void => {
|
||||
const handleBillingOnSuccess = (data: CreateSubscription201): void => {
|
||||
if (data?.data?.redirectURL) {
|
||||
const newTab = document.createElement('a');
|
||||
newTab.href = data.data.redirectURL;
|
||||
@@ -138,7 +137,7 @@ function LaunchChatSupport({
|
||||
};
|
||||
|
||||
const { mutate: updateCreditCard, isLoading: isLoadingBilling } = useMutation(
|
||||
updateCreditCardApi,
|
||||
createSubscription,
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
handleBillingOnSuccess(data);
|
||||
@@ -193,18 +192,23 @@ function LaunchChatSupport({
|
||||
>
|
||||
Cancel
|
||||
</Button>,
|
||||
<Button
|
||||
<AuthZTooltip
|
||||
key="submit"
|
||||
type="primary"
|
||||
icon={<CreditCard size={16} />}
|
||||
size="middle"
|
||||
loading={isLoadingBilling}
|
||||
disabled={isLoadingBilling}
|
||||
onClick={handleAddCreditCard}
|
||||
className="add-credit-card-btn"
|
||||
checks={[SubscriptionCreatePermission]}
|
||||
withPortal={false}
|
||||
>
|
||||
Add Credit Card
|
||||
</Button>,
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<CreditCard size={16} />}
|
||||
size="middle"
|
||||
loading={isLoadingBilling}
|
||||
disabled={isLoadingBilling}
|
||||
onClick={handleAddCreditCard}
|
||||
className="add-credit-card-btn"
|
||||
>
|
||||
Add Credit Card
|
||||
</Button>
|
||||
</AuthZTooltip>,
|
||||
]}
|
||||
>
|
||||
<Typography.Text className="add-credit-card-text">
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
|
||||
import type { CursorPosition } from './types';
|
||||
|
||||
import styles from './MarkdownEditor.module.scss';
|
||||
|
||||
interface EditorStatusBarProps {
|
||||
cursor: CursorPosition;
|
||||
length: number;
|
||||
maxLength: number;
|
||||
hint?: ReactNode;
|
||||
}
|
||||
|
||||
function EditorStatusBar({
|
||||
cursor,
|
||||
length,
|
||||
maxLength,
|
||||
hint,
|
||||
}: EditorStatusBarProps): JSX.Element {
|
||||
const isOverLimit = length > maxLength;
|
||||
|
||||
return (
|
||||
<div className={styles.statusBar} data-testid="markdown-editor-status">
|
||||
<Typography.Text className={styles.statusPosition}>
|
||||
{`Ln ${cursor.line}, Col ${cursor.column}`}
|
||||
<span className={styles.statusSeparator}>·</span>
|
||||
<span
|
||||
className={cx(styles.statusCount, {
|
||||
[styles.statusCountOverLimit]: isOverLimit,
|
||||
})}
|
||||
data-testid="markdown-editor-char-count"
|
||||
>
|
||||
{isOverLimit
|
||||
? `${length} / ${maxLength} chars`
|
||||
: `${length} chars`}
|
||||
</span>
|
||||
</Typography.Text>
|
||||
{hint && (
|
||||
<Typography.Text className={styles.statusHint}>{hint}</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default EditorStatusBar;
|
||||
@@ -1,93 +0,0 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import {
|
||||
Bold,
|
||||
CodeXml,
|
||||
Heading,
|
||||
Italic,
|
||||
Link,
|
||||
List,
|
||||
ListOrdered,
|
||||
Table,
|
||||
Type,
|
||||
} from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import InsertVariableMenu from './InsertVariableMenu';
|
||||
import MarkdownHelp from './MarkdownHelp';
|
||||
import type { EditorCommand, EditorVariable } from './types';
|
||||
|
||||
import styles from './MarkdownEditor.module.scss';
|
||||
|
||||
const COMMAND_ICONS: Record<string, ReactNode> = {
|
||||
heading: <Heading size={14} />,
|
||||
bold: <Bold size={14} />,
|
||||
italic: <Italic size={14} />,
|
||||
'bulleted-list': <List size={14} />,
|
||||
'numbered-list': <ListOrdered size={14} />,
|
||||
link: <Link size={14} />,
|
||||
code: <CodeXml size={14} />,
|
||||
table: <Table size={14} />,
|
||||
};
|
||||
|
||||
interface EditorToolbarProps {
|
||||
formatLabel: string;
|
||||
commands: EditorCommand[];
|
||||
onRunCommand: (command: EditorCommand) => void;
|
||||
variables: EditorVariable[];
|
||||
onInsertVariable: (name: string) => void;
|
||||
disabled: boolean;
|
||||
extra?: ReactNode;
|
||||
}
|
||||
|
||||
function EditorToolbar({
|
||||
formatLabel,
|
||||
commands,
|
||||
onRunCommand,
|
||||
variables,
|
||||
onInsertVariable,
|
||||
disabled,
|
||||
extra,
|
||||
}: EditorToolbarProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.toolbar} data-testid="markdown-editor-toolbar">
|
||||
<span className={styles.formatChip}>
|
||||
<Type size={14} />
|
||||
<Typography.Text className={styles.formatLabel}>
|
||||
{formatLabel}
|
||||
</Typography.Text>
|
||||
</span>
|
||||
<span className={styles.toolbarDivider} />
|
||||
<div className={styles.commands}>
|
||||
{commands.map((command) => (
|
||||
<TooltipSimple key={command.id} title={command.label}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
disabled={disabled}
|
||||
aria-label={command.label}
|
||||
data-testid={`markdown-command-${command.id}`}
|
||||
onClick={(): void => onRunCommand(command)}
|
||||
>
|
||||
{COMMAND_ICONS[command.id]}
|
||||
</Button>
|
||||
</TooltipSimple>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.toolbarEnd}>
|
||||
{extra}
|
||||
<InsertVariableMenu
|
||||
variables={variables}
|
||||
onSelect={onInsertVariable}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<MarkdownHelp />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default EditorToolbar;
|
||||
@@ -1,91 +0,0 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { ChevronDown, DollarSign, Search } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DropdownMenuSimple, type MenuItem } from '@signozhq/ui/dropdown-menu';
|
||||
|
||||
import type { EditorVariable } from './types';
|
||||
|
||||
import styles from './MarkdownEditor.module.scss';
|
||||
|
||||
interface InsertVariableMenuProps {
|
||||
variables: EditorVariable[];
|
||||
/** Receives the variable name; the caller decides the token syntax. */
|
||||
onSelect: (name: string) => void;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
function toMenuItems(
|
||||
variables: EditorVariable[],
|
||||
onSelect: (name: string) => void,
|
||||
): MenuItem[] {
|
||||
return variables.map((variable) => ({
|
||||
key: variable.name,
|
||||
label: (
|
||||
<span
|
||||
className={styles.variableRow}
|
||||
data-testid={`markdown-variable-${variable.name}`}
|
||||
>
|
||||
<span className={styles.variableName}>{`$${variable.name}`}</span>
|
||||
{variable.badge && (
|
||||
<span className={styles.variableBadge}>{variable.badge}</span>
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
onClick: (): void => onSelect(variable.name),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Searchable variable picker; hidden entirely when there is nothing to insert. */
|
||||
function InsertVariableMenu({
|
||||
variables,
|
||||
onSelect,
|
||||
disabled,
|
||||
}: InsertVariableMenuProps): JSX.Element | null {
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const matches = useMemo(() => {
|
||||
const query = search.trim().toLowerCase();
|
||||
return query
|
||||
? variables.filter((variable) => variable.name.toLowerCase().includes(query))
|
||||
: variables;
|
||||
}, [variables, search]);
|
||||
|
||||
const items = useMemo(
|
||||
() => toMenuItems(matches, onSelect),
|
||||
[matches, onSelect],
|
||||
);
|
||||
|
||||
if (variables.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenuSimple
|
||||
className={styles.variableMenu}
|
||||
menu={{
|
||||
items,
|
||||
search: {
|
||||
placeholder: 'Search variables',
|
||||
searchIcon: <Search size={14} />,
|
||||
onSearchChange: setSearch,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
prefix={<DollarSign size={14} className={styles.insertVariableIcon} />}
|
||||
suffix={<ChevronDown size={14} />}
|
||||
className={styles.insertVariable}
|
||||
data-testid="markdown-insert-variable"
|
||||
>
|
||||
Insert variable
|
||||
</Button>
|
||||
</DropdownMenuSimple>
|
||||
);
|
||||
}
|
||||
|
||||
export default InsertVariableMenu;
|
||||
@@ -1,253 +0,0 @@
|
||||
@use '../../styles/scrollbar' as *;
|
||||
|
||||
.container {
|
||||
// Read by the decoration theme in `markdownHighlight`, which can't see SCSS.
|
||||
--md-syntax-heading: var(--text-vanilla-100);
|
||||
--md-syntax-strong: var(--text-vanilla-100);
|
||||
--md-syntax-emphasis: var(--text-vanilla-300);
|
||||
--md-syntax-quote: var(--text-vanilla-400);
|
||||
--md-syntax-marker: var(--text-robin-300);
|
||||
--md-syntax-code: var(--text-forest-400);
|
||||
--md-syntax-link: var(--text-robin-400);
|
||||
--md-syntax-variable: var(--text-amber-400);
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
background: var(--l1-background);
|
||||
}
|
||||
|
||||
:global(body.lightMode) .container {
|
||||
--md-syntax-heading: var(--text-ink-400);
|
||||
--md-syntax-strong: var(--text-ink-400);
|
||||
--md-syntax-emphasis: var(--text-ink-200);
|
||||
--md-syntax-quote: var(--text-neutral-light-100);
|
||||
--md-syntax-marker: var(--text-robin-500);
|
||||
--md-syntax-code: var(--text-forest-700);
|
||||
--md-syntax-link: var(--text-robin-500);
|
||||
--md-syntax-variable: var(--text-sienna-500);
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
.formatChip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 2px;
|
||||
color: var(--text-sienna-400);
|
||||
}
|
||||
|
||||
.formatLabel {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.toolbarDivider {
|
||||
width: 1px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
background: var(--l1-border);
|
||||
}
|
||||
|
||||
.commands {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.toolbarEnd {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.insertVariable {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.insertVariableIcon {
|
||||
color: var(--text-amber-400);
|
||||
}
|
||||
|
||||
// The ui library's dropdown assumes a global border-box reset this app doesn't
|
||||
// have (`box-sizing` is set on `body` only and doesn't inherit): its items are
|
||||
// `width: 100%` + padding, so in the portal they lay out content-box and
|
||||
// overflow the popup by the padding — clipping the flush-right badge.
|
||||
.variableMenu,
|
||||
.variableMenu * {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.variableMenu {
|
||||
width: 320px;
|
||||
}
|
||||
|
||||
// Shrinkable, so a clamped popup truncates the name instead of clipping the
|
||||
// badge at the content's `overflow: hidden` edge.
|
||||
.variableRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.variableName {
|
||||
font-family: var(--font-family-sf-mono);
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.variableBadge {
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
padding: 2px 6px;
|
||||
border: 1px solid color-mix(in srgb, var(--text-amber-400) 40%, transparent);
|
||||
border-radius: 4px;
|
||||
font-family: var(--font-family-sf-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-amber-400);
|
||||
}
|
||||
|
||||
.editorArea {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.codeMirror {
|
||||
height: 100%;
|
||||
font-family: var(--font-family-sf-mono);
|
||||
font-size: 13px;
|
||||
|
||||
:global(.cm-editor) {
|
||||
height: 100%;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
:global(.cm-editor.cm-focused) {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
:global(.cm-gutters) {
|
||||
background: transparent;
|
||||
border-right: none;
|
||||
color: var(--text-neutral-dark-200);
|
||||
}
|
||||
|
||||
:global(.cm-scroller) {
|
||||
line-height: 20px;
|
||||
padding: 0 12px;
|
||||
@include custom-scrollbar;
|
||||
}
|
||||
|
||||
:global(.cm-content) {
|
||||
padding: 8px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.statusBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
padding: 6px 12px;
|
||||
border-top: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
.statusPosition {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: var(--font-family-sf-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-neutral-dark-200);
|
||||
}
|
||||
|
||||
.statusSeparator {
|
||||
color: var(--l1-border);
|
||||
}
|
||||
|
||||
.statusCount {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.statusCountOverLimit {
|
||||
color: var(--text-cherry-400);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.statusHint {
|
||||
font-size: 11px;
|
||||
color: var(--text-neutral-dark-200);
|
||||
}
|
||||
|
||||
.helpContent {
|
||||
width: 280px;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
@include custom-scrollbar;
|
||||
}
|
||||
|
||||
.helpTitle {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-neutral-dark-200);
|
||||
}
|
||||
|
||||
.helpList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.helpRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
|
||||
dt {
|
||||
margin: 0;
|
||||
|
||||
code {
|
||||
font-family: var(--font-family-sf-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-forest-400);
|
||||
}
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
color: var(--text-neutral-dark-200);
|
||||
}
|
||||
}
|
||||
|
||||
// The help popover portals out of `.container`, so it can't inherit its tokens.
|
||||
:global(body.lightMode) .helpRow dt code {
|
||||
color: var(--text-forest-700);
|
||||
}
|
||||
@@ -1,245 +0,0 @@
|
||||
import {
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { copilot } from '@uiw/codemirror-theme-copilot';
|
||||
import { githubLight } from '@uiw/codemirror-theme-github';
|
||||
import CodeMirror, {
|
||||
type BasicSetupOptions,
|
||||
EditorView,
|
||||
type ViewUpdate,
|
||||
} from '@uiw/react-codemirror';
|
||||
import cx from 'classnames';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
|
||||
import { formatVariableToken, MARKDOWN_MAX_LENGTH } from './constants';
|
||||
import EditorStatusBar from './EditorStatusBar';
|
||||
import EditorToolbar from './EditorToolbar';
|
||||
import { applyTransform, replaceDocument } from './editorDocument';
|
||||
import { insertText, MARKDOWN_COMMANDS } from './markdownCommands';
|
||||
import { markdownHighlight } from './markdownHighlight';
|
||||
import type {
|
||||
CursorPosition,
|
||||
EditorCommand,
|
||||
EditorTransform,
|
||||
EditorVariable,
|
||||
} from './types';
|
||||
|
||||
import styles from './MarkdownEditor.module.scss';
|
||||
|
||||
/** What the status bar reports. */
|
||||
type DocumentStatus = CursorPosition & { length: number };
|
||||
|
||||
// No language grammar is loaded, so bracket/indent/completion behaviour would only
|
||||
// get in the way of prose. `indentWithTab` stays off so Tab keeps moving focus.
|
||||
const BASIC_SETUP: BasicSetupOptions = {
|
||||
lineNumbers: true,
|
||||
highlightActiveLine: true,
|
||||
highlightActiveLineGutter: true,
|
||||
foldGutter: false,
|
||||
autocompletion: false,
|
||||
bracketMatching: false,
|
||||
closeBrackets: false,
|
||||
indentOnInput: false,
|
||||
syntaxHighlighting: false,
|
||||
highlightSelectionMatches: false,
|
||||
rectangularSelection: false,
|
||||
crosshairCursor: false,
|
||||
searchKeymap: false,
|
||||
foldKeymap: false,
|
||||
lintKeymap: false,
|
||||
completionKeymap: false,
|
||||
closeBracketsKeymap: false,
|
||||
};
|
||||
|
||||
const EMPTY_VARIABLES: EditorVariable[] = [];
|
||||
|
||||
export interface MarkdownEditorProps {
|
||||
/** Seeds the document; replaced only from outside. See the sync effect. */
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
/** Offered by the "Insert variable" menu; the button disables when empty. */
|
||||
variables?: EditorVariable[];
|
||||
/** What the character counter reports against. */
|
||||
maxLength?: number;
|
||||
placeholder?: string;
|
||||
readOnly?: boolean;
|
||||
/** Shown on the toolbar chip. */
|
||||
formatLabel?: string;
|
||||
/** Rendered before the "Insert variable" menu. */
|
||||
toolbarExtra?: ReactNode;
|
||||
/** Right-hand status-bar note, e.g. "Preview updates as you type". */
|
||||
statusHint?: ReactNode;
|
||||
autoFocus?: boolean;
|
||||
className?: string;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Source editor for Markdown bodies. Source-only: it neither parses nor renders
|
||||
* the body, so the preview surface and its sanitisation stay the caller's concern.
|
||||
*/
|
||||
function MarkdownEditor({
|
||||
value,
|
||||
onChange,
|
||||
variables = EMPTY_VARIABLES,
|
||||
maxLength = MARKDOWN_MAX_LENGTH,
|
||||
placeholder = 'Write Markdown…',
|
||||
readOnly = false,
|
||||
formatLabel = 'Markdown',
|
||||
toolbarExtra,
|
||||
statusHint,
|
||||
autoFocus = false,
|
||||
className,
|
||||
testId = 'markdown-editor',
|
||||
}: MarkdownEditorProps): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const viewRef = useRef<EditorView | null>(null);
|
||||
// Set while a programmatic replacement is in flight, so the caller isn't told
|
||||
// about a change it asked for. `dispatch` runs listeners synchronously, so the
|
||||
// window is exactly one call.
|
||||
const isSyncingRef = useRef(false);
|
||||
const previousValueRef = useRef(value);
|
||||
const hasSeededRef = useRef(false);
|
||||
const [isEditorReady, setIsEditorReady] = useState(false);
|
||||
const [status, setStatus] = useState<DocumentStatus>(() => ({
|
||||
line: 1,
|
||||
column: 1,
|
||||
length: value.length,
|
||||
}));
|
||||
|
||||
const syncDocument = useCallback((view: EditorView, next: string): void => {
|
||||
isSyncingRef.current = true;
|
||||
replaceDocument(view, next);
|
||||
isSyncingRef.current = false;
|
||||
}, []);
|
||||
|
||||
const onCreateEditor = useCallback((view: EditorView): void => {
|
||||
viewRef.current = view;
|
||||
setIsEditorReady(true);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Seeds the document, then applies external replacements — nothing else. Keeping
|
||||
* keystrokes out of this round-trip is what stops a stale `value` from replacing
|
||||
* the document and resetting the caret when typing outpaces React.
|
||||
*
|
||||
* The seed can't go in `onCreateEditor`: the wrapper defaults its own `value` to
|
||||
* `''` and reconciles against it once the view exists, wiping anything written
|
||||
* before that. `isEditorReady` puts this effect after that pass, since a parent's
|
||||
* effects flush after its children's.
|
||||
*
|
||||
* Focus marks ownership: a replacement arriving mid-typing is dropped rather than
|
||||
* applied over the author.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const view = viewRef.current;
|
||||
if (!view) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previous = previousValueRef.current;
|
||||
previousValueRef.current = value;
|
||||
const isSeeding = !hasSeededRef.current;
|
||||
hasSeededRef.current = true;
|
||||
|
||||
if (!isSeeding && (value === previous || view.hasFocus)) {
|
||||
return;
|
||||
}
|
||||
if (view.state.doc.toString() !== value) {
|
||||
syncDocument(view, value);
|
||||
}
|
||||
}, [value, isEditorReady, syncDocument]);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(next: string): void => {
|
||||
if (!isSyncingRef.current) {
|
||||
onChange(next);
|
||||
}
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const runTransform = useCallback((transform: EditorTransform): void => {
|
||||
const view = viewRef.current;
|
||||
if (view) {
|
||||
applyTransform(view, transform);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onRunCommand = useCallback(
|
||||
(command: EditorCommand): void => runTransform(command.run),
|
||||
[runTransform],
|
||||
);
|
||||
|
||||
const onInsertVariable = useCallback(
|
||||
(name: string): void =>
|
||||
runTransform((snapshot) => insertText(snapshot, formatVariableToken(name))),
|
||||
[runTransform],
|
||||
);
|
||||
|
||||
const extensions = useMemo(
|
||||
() => [markdownHighlight(), EditorView.lineWrapping],
|
||||
[],
|
||||
);
|
||||
|
||||
// From the document, not `value`: the caller may debounce or drop a change, and
|
||||
// the counter has to match what the author sees.
|
||||
const onUpdate = useCallback((update: ViewUpdate): void => {
|
||||
if (!update.selectionSet && !update.docChanged) {
|
||||
return;
|
||||
}
|
||||
const { head } = update.state.selection.main;
|
||||
const line = update.state.doc.lineAt(head);
|
||||
setStatus({
|
||||
line: line.number,
|
||||
column: head - line.from + 1,
|
||||
length: update.state.doc.length,
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={cx(styles.container, className)} data-testid={testId}>
|
||||
<EditorToolbar
|
||||
formatLabel={formatLabel}
|
||||
commands={MARKDOWN_COMMANDS}
|
||||
onRunCommand={onRunCommand}
|
||||
variables={variables}
|
||||
onInsertVariable={onInsertVariable}
|
||||
disabled={readOnly}
|
||||
extra={toolbarExtra}
|
||||
/>
|
||||
<div className={styles.editorArea}>
|
||||
<CodeMirror
|
||||
className={styles.codeMirror}
|
||||
// No `value`: passing it re-enables the wrapper's own reconciliation,
|
||||
// and with it the caret reset.
|
||||
onCreateEditor={onCreateEditor}
|
||||
onChange={handleChange}
|
||||
onUpdate={onUpdate}
|
||||
theme={isDarkMode ? copilot : githubLight}
|
||||
basicSetup={BASIC_SETUP}
|
||||
placeholder={placeholder}
|
||||
editable={!readOnly}
|
||||
readOnly={readOnly}
|
||||
indentWithTab={false}
|
||||
autoFocus={autoFocus}
|
||||
extensions={extensions}
|
||||
height="100%"
|
||||
/>
|
||||
</div>
|
||||
<EditorStatusBar
|
||||
cursor={status}
|
||||
length={status.length}
|
||||
maxLength={maxLength}
|
||||
hint={statusHint}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MarkdownEditor;
|
||||
@@ -1,44 +0,0 @@
|
||||
import { CircleHelp } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@signozhq/ui/popover';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import { MARKDOWN_HELP_ITEMS } from './constants';
|
||||
|
||||
import styles from './MarkdownEditor.module.scss';
|
||||
|
||||
function MarkdownHelp(): JSX.Element {
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
aria-label="Markdown syntax help"
|
||||
data-testid="markdown-help-trigger"
|
||||
>
|
||||
<CircleHelp size={14} />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className={styles.helpContent}>
|
||||
<Typography.Text className={styles.helpTitle}>
|
||||
Markdown syntax
|
||||
</Typography.Text>
|
||||
<dl className={styles.helpList}>
|
||||
{MARKDOWN_HELP_ITEMS.map((item) => (
|
||||
<div key={item.syntax} className={styles.helpRow}>
|
||||
<dt>
|
||||
<code>{item.syntax}</code>
|
||||
</dt>
|
||||
<dd>{item.label}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export default MarkdownHelp;
|
||||
@@ -1,270 +0,0 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { EditorView } from '@uiw/react-codemirror';
|
||||
import { mockCodeMirrorDomApis } from 'components/QueryBuilderV2/QueryV2/__tests__/codemirrorDomMocks';
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
userEvent,
|
||||
waitFor,
|
||||
} from 'tests/test-utils';
|
||||
|
||||
import MarkdownEditor from '../MarkdownEditor';
|
||||
import type { EditorVariable } from '../types';
|
||||
|
||||
beforeAll(() => {
|
||||
mockCodeMirrorDomApis();
|
||||
});
|
||||
|
||||
jest.mock('hooks/useDarkMode', () => ({
|
||||
useIsDarkMode: (): boolean => true,
|
||||
}));
|
||||
|
||||
const VARIABLES: EditorVariable[] = [
|
||||
{ name: 'environment', badge: 'QUERY' },
|
||||
{ name: 'service', badge: 'CUSTOM' },
|
||||
];
|
||||
|
||||
/** A caller whose state trails the editor by one keystroke. */
|
||||
function LaggingHarness(): JSX.Element {
|
||||
const [value, setValue] = useState('');
|
||||
const previousRef = useRef('');
|
||||
const onChange = useCallback((next: string): void => {
|
||||
setValue(previousRef.current);
|
||||
previousRef.current = next;
|
||||
}, []);
|
||||
|
||||
return <MarkdownEditor value={value} onChange={onChange} />;
|
||||
}
|
||||
|
||||
/** Pushes a replacement in from outside the editor. */
|
||||
function ExternalHarness(): JSX.Element {
|
||||
const [value, setValue] = useState('before');
|
||||
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={(): void => setValue('after')}>
|
||||
push
|
||||
</button>
|
||||
<MarkdownEditor value={value} onChange={setValue} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Harness({
|
||||
initialValue = '',
|
||||
maxLength,
|
||||
variables = VARIABLES,
|
||||
}: {
|
||||
initialValue?: string;
|
||||
maxLength?: number;
|
||||
variables?: EditorVariable[];
|
||||
}): JSX.Element {
|
||||
const [value, setValue] = useState(initialValue);
|
||||
return (
|
||||
<MarkdownEditor
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
variables={variables}
|
||||
maxLength={maxLength}
|
||||
statusHint="Preview updates as you type"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const getView = (): EditorView => {
|
||||
const dom = document.querySelector('.cm-editor');
|
||||
const view = dom ? EditorView.findFromDOM(dom as HTMLElement) : null;
|
||||
if (!view) {
|
||||
throw new Error('editor view not mounted');
|
||||
}
|
||||
return view;
|
||||
};
|
||||
|
||||
const select = (from: number, to: number): void => {
|
||||
act(() => {
|
||||
getView().dispatch({ selection: { anchor: from, head: to } });
|
||||
});
|
||||
};
|
||||
|
||||
const documentText = (): string => getView().state.doc.toString();
|
||||
|
||||
describe('MarkdownEditor', () => {
|
||||
it('reports the caret position and character count', async () => {
|
||||
render(<Harness initialValue={'one\ntwo'} />);
|
||||
|
||||
select(5, 5);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('markdown-editor-status')).toHaveTextContent(
|
||||
'Ln 2, Col 2',
|
||||
);
|
||||
});
|
||||
expect(screen.getByTestId('markdown-editor-char-count')).toHaveTextContent(
|
||||
'7 chars',
|
||||
);
|
||||
});
|
||||
|
||||
it('flags a body over the character cap', async () => {
|
||||
render(<Harness initialValue="123456" maxLength={5} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('markdown-editor-char-count')).toHaveTextContent(
|
||||
'6 / 5 chars',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('applies a toolbar command to the selection', async () => {
|
||||
render(<Harness initialValue="a word b" />);
|
||||
|
||||
select(2, 6);
|
||||
await userEvent.click(screen.getByTestId('markdown-command-bold'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(documentText()).toBe('a **word** b');
|
||||
});
|
||||
});
|
||||
|
||||
it('inserts a variable token at the caret', async () => {
|
||||
render(<Harness initialValue="env: " />);
|
||||
|
||||
select(5, 5);
|
||||
await userEvent.click(screen.getByTestId('markdown-insert-variable'));
|
||||
|
||||
// The row shows the name and kind badge.
|
||||
const row = await screen.findByTestId('markdown-variable-environment');
|
||||
expect(row).toHaveTextContent('$environment');
|
||||
expect(row).toHaveTextContent('QUERY');
|
||||
|
||||
// fireEvent: userEvent's pointer-down path walks DOM selection APIs the
|
||||
// CodeMirror mocks stub out.
|
||||
fireEvent.click(row);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(documentText()).toBe('env: $environment');
|
||||
});
|
||||
});
|
||||
|
||||
it('colours Markdown syntax and variable tokens in the source', async () => {
|
||||
render(<Harness initialValue={'## Runbook\nowner {{team}}'} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector('.cm-md-heading')).toBeInTheDocument();
|
||||
});
|
||||
expect(document.querySelector('.cm-md-variable')).toHaveTextContent(
|
||||
'{{team}}',
|
||||
);
|
||||
});
|
||||
|
||||
describe('uncontrolled document', () => {
|
||||
const type = (at: number, text: string): void => {
|
||||
act(() => {
|
||||
getView().dispatch({
|
||||
changes: { from: at, insert: text },
|
||||
selection: { anchor: at + text.length },
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const focusEditor = (): void => {
|
||||
act(() => {
|
||||
getView().focus();
|
||||
});
|
||||
};
|
||||
|
||||
it('keeps the document and caret while the caller lags behind the typing', () => {
|
||||
render(<LaggingHarness />);
|
||||
focusEditor();
|
||||
|
||||
type(0, 'a');
|
||||
type(1, 'b');
|
||||
type(2, 'c');
|
||||
|
||||
expect(documentText()).toBe('abc');
|
||||
expect(getView().state.selection.main.head).toBe(3);
|
||||
});
|
||||
|
||||
it('reports every keystroke to the caller', () => {
|
||||
const onChange = jest.fn();
|
||||
render(<MarkdownEditor value="ab" onChange={onChange} />);
|
||||
|
||||
type(2, 'c');
|
||||
|
||||
expect(onChange).toHaveBeenLastCalledWith('abc');
|
||||
});
|
||||
|
||||
it('does not report the seed back as a change', () => {
|
||||
const onChange = jest.fn();
|
||||
render(<MarkdownEditor value="seeded" onChange={onChange} />);
|
||||
|
||||
expect(documentText()).toBe('seeded');
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('applies an external replacement while the editor is unfocused', async () => {
|
||||
render(<ExternalHarness />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'push' }));
|
||||
|
||||
expect(documentText()).toBe('after');
|
||||
});
|
||||
|
||||
it('ignores a replacement that arrives while the author is still typing', () => {
|
||||
render(<ExternalHarness />);
|
||||
focusEditor();
|
||||
|
||||
// fireEvent: a real click would blur the editor first. This covers an update
|
||||
// arriving on its own, while the author is still in the document.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'push' }));
|
||||
|
||||
expect(documentText()).toBe('before');
|
||||
});
|
||||
|
||||
it('counts characters from the document, not from the lagging value', async () => {
|
||||
render(<MarkdownEditor value="ab" onChange={jest.fn()} />);
|
||||
|
||||
type(2, 'cde');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('markdown-editor-char-count')).toHaveTextContent(
|
||||
'5 chars',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('offers both list kinds in the toolbar', () => {
|
||||
render(<Harness />);
|
||||
|
||||
expect(
|
||||
screen.getByTestId('markdown-command-bulleted-list'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('markdown-command-numbered-list'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables authoring affordances when read-only', () => {
|
||||
render(
|
||||
<MarkdownEditor
|
||||
value="body"
|
||||
onChange={jest.fn()}
|
||||
variables={VARIABLES}
|
||||
readOnly
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('markdown-command-bold')).toBeDisabled();
|
||||
expect(screen.getByTestId('markdown-insert-variable')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('hides the insert-variable control when none are available', () => {
|
||||
render(<Harness variables={[]} />);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('markdown-insert-variable'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,258 +0,0 @@
|
||||
import { insertText, MARKDOWN_COMMANDS } from '../markdownCommands';
|
||||
import type { EditorSnapshot, EditorTransform } from '../types';
|
||||
|
||||
const commandById = (id: string): EditorTransform => {
|
||||
const command = MARKDOWN_COMMANDS.find((entry) => entry.id === id);
|
||||
if (!command) {
|
||||
throw new Error(`unknown command: ${id}`);
|
||||
}
|
||||
return command.run;
|
||||
};
|
||||
|
||||
const heading = commandById('heading');
|
||||
const bold = commandById('bold');
|
||||
const italic = commandById('italic');
|
||||
const bulletedList = commandById('bulleted-list');
|
||||
const numberedList = commandById('numbered-list');
|
||||
const link = commandById('link');
|
||||
const code = commandById('code');
|
||||
const table = commandById('table');
|
||||
|
||||
/** `|` marks a caret, `[...]` a range, so expectations read like the editor looks. */
|
||||
const snapshot = (marked: string): EditorSnapshot => {
|
||||
if (marked.includes('|')) {
|
||||
const caret = marked.indexOf('|');
|
||||
return {
|
||||
text: marked.replace('|', ''),
|
||||
selectionStart: caret,
|
||||
selectionEnd: caret,
|
||||
};
|
||||
}
|
||||
const start = marked.indexOf('[');
|
||||
const end = marked.indexOf(']') - 1;
|
||||
return {
|
||||
text: marked.replace('[', '').replace(']', ''),
|
||||
selectionStart: start,
|
||||
selectionEnd: end,
|
||||
};
|
||||
};
|
||||
|
||||
const selectionOf = (result: EditorSnapshot): string =>
|
||||
result.text.slice(result.selectionStart, result.selectionEnd);
|
||||
|
||||
describe('heading', () => {
|
||||
it('prefixes the caret line and keeps the caret on the same character', () => {
|
||||
const result = heading(snapshot('Chec|kout'));
|
||||
|
||||
expect(result.text).toBe('## Checkout');
|
||||
expect(result.selectionStart).toBe(7);
|
||||
});
|
||||
|
||||
it('strips the prefix when every selected line already has one', () => {
|
||||
const result = heading({
|
||||
text: '## one\n### two',
|
||||
selectionStart: 0,
|
||||
selectionEnd: 13,
|
||||
});
|
||||
|
||||
expect(result.text).toBe('one\ntwo');
|
||||
});
|
||||
|
||||
it('adds the prefix when only some selected lines have one', () => {
|
||||
const result = heading({
|
||||
text: '## one\ntwo',
|
||||
selectionStart: 0,
|
||||
selectionEnd: 10,
|
||||
});
|
||||
|
||||
expect(result.text).toBe('## ## one\n## two');
|
||||
});
|
||||
|
||||
it('does not pull in the line after a selection ending on a line break', () => {
|
||||
const result = heading({
|
||||
text: 'one\ntwo',
|
||||
selectionStart: 0,
|
||||
selectionEnd: 4,
|
||||
});
|
||||
|
||||
expect(result.text).toBe('## one\ntwo');
|
||||
});
|
||||
});
|
||||
|
||||
describe('bulleted list', () => {
|
||||
it('bullets every line of a multi-line selection', () => {
|
||||
const result = bulletedList({
|
||||
text: 'one\ntwo',
|
||||
selectionStart: 0,
|
||||
selectionEnd: 7,
|
||||
});
|
||||
|
||||
expect(result.text).toBe('- one\n- two');
|
||||
expect(selectionOf(result)).toBe('- one\n- two');
|
||||
});
|
||||
|
||||
it('unbullets a list written with a different marker', () => {
|
||||
const result = bulletedList({
|
||||
text: '* one\n+ two',
|
||||
selectionStart: 0,
|
||||
selectionEnd: 11,
|
||||
});
|
||||
|
||||
expect(result.text).toBe('one\ntwo');
|
||||
});
|
||||
});
|
||||
|
||||
describe('numbered list', () => {
|
||||
it('numbers each line of the selection in order', () => {
|
||||
const result = numberedList({
|
||||
text: 'one\ntwo\nthree',
|
||||
selectionStart: 0,
|
||||
selectionEnd: 13,
|
||||
});
|
||||
|
||||
expect(result.text).toBe('1. one\n2. two\n3. three');
|
||||
});
|
||||
|
||||
it('unnumbers a list whose numbering is not sequential', () => {
|
||||
const result = numberedList({
|
||||
text: '1. one\n5. two',
|
||||
selectionStart: 0,
|
||||
selectionEnd: 13,
|
||||
});
|
||||
|
||||
expect(result.text).toBe('one\ntwo');
|
||||
});
|
||||
});
|
||||
|
||||
describe('switching between list kinds', () => {
|
||||
it('converts bullets to numbers rather than marking them twice', () => {
|
||||
const result = numberedList({
|
||||
text: '- one\n- two',
|
||||
selectionStart: 0,
|
||||
selectionEnd: 11,
|
||||
});
|
||||
|
||||
expect(result.text).toBe('1. one\n2. two');
|
||||
});
|
||||
|
||||
it('converts numbers to bullets', () => {
|
||||
const result = bulletedList({
|
||||
text: '1. one\n2. two',
|
||||
selectionStart: 0,
|
||||
selectionEnd: 13,
|
||||
});
|
||||
|
||||
expect(result.text).toBe('- one\n- two');
|
||||
});
|
||||
|
||||
it('keeps indentation so nested items stay nested', () => {
|
||||
const result = numberedList({
|
||||
text: 'one\n - nested',
|
||||
selectionStart: 0,
|
||||
selectionEnd: 16,
|
||||
});
|
||||
|
||||
expect(result.text).toBe('1. one\n 2. nested');
|
||||
});
|
||||
});
|
||||
|
||||
describe('bold and italic', () => {
|
||||
it('wraps the selection and keeps the original text selected', () => {
|
||||
const result = bold(snapshot('a [word] b'));
|
||||
|
||||
expect(result.text).toBe('a **word** b');
|
||||
expect(selectionOf(result)).toBe('word');
|
||||
});
|
||||
|
||||
it('unwraps when the markers sit inside the selection', () => {
|
||||
const result = bold({
|
||||
text: 'a **word** b',
|
||||
selectionStart: 2,
|
||||
selectionEnd: 10,
|
||||
});
|
||||
|
||||
expect(result.text).toBe('a word b');
|
||||
expect(selectionOf(result)).toBe('word');
|
||||
});
|
||||
|
||||
it('unwraps when the markers sit just outside the selection', () => {
|
||||
const result = bold({
|
||||
text: 'a **word** b',
|
||||
selectionStart: 4,
|
||||
selectionEnd: 8,
|
||||
});
|
||||
|
||||
expect(result.text).toBe('a word b');
|
||||
expect(selectionOf(result)).toBe('word');
|
||||
});
|
||||
|
||||
it('leaves the caret between the markers when nothing is selected', () => {
|
||||
const result = italic(snapshot('a |b'));
|
||||
|
||||
expect(result.text).toBe('a __b');
|
||||
expect(result.selectionStart).toBe(3);
|
||||
expect(result.selectionEnd).toBe(3);
|
||||
});
|
||||
|
||||
it('does not mistake a leading document boundary for a marker', () => {
|
||||
const result = bold(snapshot('[word] tail'));
|
||||
|
||||
expect(result.text).toBe('**word** tail');
|
||||
});
|
||||
});
|
||||
|
||||
describe('link', () => {
|
||||
it('selects the url when the label came from the selection', () => {
|
||||
const result = link(snapshot('see [docs] now'));
|
||||
|
||||
expect(result.text).toBe('see [docs](https://) now');
|
||||
expect(selectionOf(result)).toBe('https://');
|
||||
});
|
||||
|
||||
it('selects the label placeholder when nothing was selected', () => {
|
||||
const result = link(snapshot('see |'));
|
||||
|
||||
expect(result.text).toBe('see [text](https://)');
|
||||
expect(selectionOf(result)).toBe('text');
|
||||
});
|
||||
});
|
||||
|
||||
describe('code', () => {
|
||||
it('uses backticks for a single-line selection', () => {
|
||||
const result = code(snapshot('run [npm] here'));
|
||||
|
||||
expect(result.text).toBe('run `npm` here');
|
||||
});
|
||||
|
||||
it('fences a multi-line selection and selects its content', () => {
|
||||
const result = code({
|
||||
text: 'one\ntwo',
|
||||
selectionStart: 0,
|
||||
selectionEnd: 7,
|
||||
});
|
||||
|
||||
expect(result.text).toBe('```\none\ntwo\n```');
|
||||
expect(selectionOf(result)).toBe('one\ntwo');
|
||||
});
|
||||
});
|
||||
|
||||
describe('table', () => {
|
||||
it('starts the skeleton on its own line and selects the first header cell', () => {
|
||||
const result = table(snapshot('intro|'));
|
||||
|
||||
expect(result.text).toBe(
|
||||
'intro\n| Column | Column |\n| --- | --- |\n| | |',
|
||||
);
|
||||
expect(selectionOf(result)).toBe('Column');
|
||||
});
|
||||
});
|
||||
|
||||
describe('insertText', () => {
|
||||
it('replaces the selection and leaves the caret after the insertion', () => {
|
||||
const result = insertText(snapshot('env is [old]'), '{{env}}');
|
||||
|
||||
expect(result.text).toBe('env is {{env}}');
|
||||
expect(result.selectionStart).toBe(14);
|
||||
expect(result.selectionEnd).toBe(14);
|
||||
});
|
||||
});
|
||||
@@ -1,24 +0,0 @@
|
||||
// The body is persisted inline in the dashboard JSON, so its length is capped.
|
||||
export const MARKDOWN_MAX_LENGTH = 16000;
|
||||
|
||||
/** The canonical syntax; the renderer resolves the other three too. */
|
||||
export const formatVariableToken = (name: string): string => `$${name}`;
|
||||
|
||||
export const MARKDOWN_HELP_ITEMS: { syntax: string; label: string }[] = [
|
||||
// First: consecutive lines joining into one paragraph is the CommonMark rule
|
||||
// authors trip over before any of the formatting syntax.
|
||||
{ syntax: 'blank line', label: 'New paragraph' },
|
||||
{ syntax: '2 spaces + ⏎', label: 'Line break' },
|
||||
{ syntax: '# Heading', label: 'Heading (1–6 #)' },
|
||||
{ syntax: '**bold**', label: 'Bold' },
|
||||
{ syntax: '_italic_', label: 'Italic' },
|
||||
{ syntax: '- item', label: 'Bulleted list' },
|
||||
{ syntax: '1. item', label: 'Numbered list' },
|
||||
{ syntax: '- [ ] task', label: 'Task list' },
|
||||
{ syntax: '[label](url)', label: 'Link' },
|
||||
{ syntax: '', label: 'Image' },
|
||||
{ syntax: '`code`', label: 'Inline code' },
|
||||
{ syntax: '```lang', label: 'Code block' },
|
||||
{ syntax: '> quote', label: 'Blockquote' },
|
||||
{ syntax: '| a | b |', label: 'Table' },
|
||||
];
|
||||
@@ -1,69 +0,0 @@
|
||||
import { EditorView } from '@uiw/react-codemirror';
|
||||
|
||||
import type { EditorSnapshot, EditorTransform } from './types';
|
||||
|
||||
// Narrows a whole-document replacement to the range that changed, so a toolbar
|
||||
// action doesn't invalidate the document's decorations or scroll position.
|
||||
function toChangeSpec(
|
||||
previous: string,
|
||||
next: string,
|
||||
): { from: number; to: number; insert: string } | null {
|
||||
if (previous === next) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const shorter = Math.min(previous.length, next.length);
|
||||
let start = 0;
|
||||
while (start < shorter && previous[start] === next[start]) {
|
||||
start += 1;
|
||||
}
|
||||
|
||||
let previousEnd = previous.length;
|
||||
let nextEnd = next.length;
|
||||
while (
|
||||
previousEnd > start &&
|
||||
nextEnd > start &&
|
||||
previous[previousEnd - 1] === next[nextEnd - 1]
|
||||
) {
|
||||
previousEnd -= 1;
|
||||
nextEnd -= 1;
|
||||
}
|
||||
|
||||
return { from: start, to: previousEnd, insert: next.slice(start, nextEnd) };
|
||||
}
|
||||
|
||||
export function readSnapshot(view: EditorView): EditorSnapshot {
|
||||
const range = view.state.selection.main;
|
||||
return {
|
||||
text: view.state.doc.toString(),
|
||||
selectionStart: range.from,
|
||||
selectionEnd: range.to,
|
||||
};
|
||||
}
|
||||
|
||||
/** Returns whether the transform ran, as CodeMirror's keymap contract expects. */
|
||||
export function applyTransform(
|
||||
view: EditorView,
|
||||
transform: EditorTransform,
|
||||
): boolean {
|
||||
if (view.state.readOnly) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const next = transform(readSnapshot(view));
|
||||
const changes = toChangeSpec(view.state.doc.toString(), next.text);
|
||||
view.dispatch({
|
||||
...(changes ? { changes } : {}),
|
||||
selection: { anchor: next.selectionStart, head: next.selectionEnd },
|
||||
scrollIntoView: true,
|
||||
});
|
||||
view.focus();
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Replaces the whole document, for seeding and external replacements. */
|
||||
export function replaceDocument(view: EditorView, next: string): void {
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: view.state.doc.length, insert: next },
|
||||
});
|
||||
}
|
||||
@@ -1,269 +0,0 @@
|
||||
import type { EditorCommand, EditorSnapshot, EditorTransform } from './types';
|
||||
|
||||
const BOLD_MARKER = '**';
|
||||
const ITALIC_MARKER = '_';
|
||||
const INLINE_CODE_MARKER = '`';
|
||||
const CODE_FENCE = '```';
|
||||
const HEADING_PREFIX = '## ';
|
||||
const BULLET_MARKER = '- ';
|
||||
|
||||
const HEADING_PATTERN = /^ {0,3}#{1,6} /;
|
||||
const BULLET_LIST_PATTERN = /^[ \t]*[-*+] /;
|
||||
const ORDERED_LIST_PATTERN = /^[ \t]*\d+\. /;
|
||||
// Either kind of marker, matched after the indent has been split off.
|
||||
const LIST_MARKER_PATTERN = /^(?:[-*+]|\d+\.) /;
|
||||
const INDENT_PATTERN = /^[ \t]*/;
|
||||
|
||||
const LINK_LABEL_PLACEHOLDER = 'text';
|
||||
const LINK_URL_PLACEHOLDER = 'https://';
|
||||
const TABLE_CELL_PLACEHOLDER = 'Column';
|
||||
const TABLE_SNIPPET = [
|
||||
`| ${TABLE_CELL_PLACEHOLDER} | ${TABLE_CELL_PLACEHOLDER} |`,
|
||||
'| --- | --- |',
|
||||
'| | |',
|
||||
].join('\n');
|
||||
|
||||
interface LineRange {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
// A selection ending exactly on a line break stops there rather than pulling in
|
||||
// the next line, so "select the line, hit list" doesn't bullet the line below too.
|
||||
function expandToLines(text: string, from: number, to: number): LineRange {
|
||||
const end = to > from && text[to - 1] === '\n' ? to - 1 : to;
|
||||
const breakBefore = from === 0 ? -1 : text.lastIndexOf('\n', from - 1);
|
||||
const breakAfter = text.indexOf('\n', end);
|
||||
return {
|
||||
start: breakBefore + 1,
|
||||
end: breakAfter === -1 ? text.length : breakAfter,
|
||||
};
|
||||
}
|
||||
|
||||
/** Pads `block` so it starts and ends on its own line. */
|
||||
function replaceWithBlock(
|
||||
snapshot: EditorSnapshot,
|
||||
block: string,
|
||||
): { text: string; blockStart: number } {
|
||||
const { text, selectionStart, selectionEnd } = snapshot;
|
||||
const before = text.slice(0, selectionStart);
|
||||
const after = text.slice(selectionEnd);
|
||||
const lead = before === '' || before.endsWith('\n') ? '' : '\n';
|
||||
const trail = after === '' || after.startsWith('\n') ? '' : '\n';
|
||||
return {
|
||||
text: before + lead + block + trail + after,
|
||||
blockStart: before.length + lead.length,
|
||||
};
|
||||
}
|
||||
|
||||
/** Rewrites every line the selection touches. */
|
||||
function replaceLines(
|
||||
snapshot: EditorSnapshot,
|
||||
mapLines: (lines: string[]) => string[],
|
||||
): EditorSnapshot {
|
||||
const { text, selectionStart, selectionEnd } = snapshot;
|
||||
const { start, end } = expandToLines(text, selectionStart, selectionEnd);
|
||||
const lines = text.slice(start, end).split('\n');
|
||||
const nextLines = mapLines(lines);
|
||||
const block = nextLines.join('\n');
|
||||
const nextText = text.slice(0, start) + block + text.slice(end);
|
||||
|
||||
if (selectionStart !== selectionEnd) {
|
||||
return {
|
||||
text: nextText,
|
||||
selectionStart: start,
|
||||
selectionEnd: start + block.length,
|
||||
};
|
||||
}
|
||||
|
||||
// Caret-only: the range covers one line, so shift by that line's delta.
|
||||
const shifted = selectionStart + nextLines[0].length - lines[0].length;
|
||||
const caret = Math.min(Math.max(shifted, start), start + nextLines[0].length);
|
||||
return { text: nextText, selectionStart: caret, selectionEnd: caret };
|
||||
}
|
||||
|
||||
/** Strips `prefix` when every selected line already matches `pattern`, else adds it. */
|
||||
function toggleLinePrefix(prefix: string, pattern: RegExp): EditorTransform {
|
||||
return (snapshot): EditorSnapshot =>
|
||||
replaceLines(snapshot, (lines) => {
|
||||
const isApplied = lines.every((line) => pattern.test(line));
|
||||
return lines.map((line) =>
|
||||
isApplied ? line.replace(pattern, '') : `${prefix}${line}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles this kind of list marker. A line carrying the *other* kind is converted
|
||||
* rather than marked twice, and indentation is preserved so nesting survives.
|
||||
* `markerAt` takes the line's position, which is what lets an ordered list number.
|
||||
*/
|
||||
function toggleList(
|
||||
pattern: RegExp,
|
||||
markerAt: (index: number) => string,
|
||||
): EditorTransform {
|
||||
return (snapshot): EditorSnapshot =>
|
||||
replaceLines(snapshot, (lines) => {
|
||||
const isApplied = lines.every((line) => pattern.test(line));
|
||||
return lines.map((line, index) => {
|
||||
const indent = INDENT_PATTERN.exec(line)?.[0] ?? '';
|
||||
const body = line.slice(indent.length).replace(LIST_MARKER_PATTERN, '');
|
||||
return isApplied
|
||||
? `${indent}${body}`
|
||||
: `${indent}${markerAt(index)}${body}`;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Unwraps when the markers are already there, whether they sit inside the selection
|
||||
* (`**bold**` selected whole) or just outside it (only `bold` selected).
|
||||
*/
|
||||
function toggleWrap(marker: string): EditorTransform {
|
||||
return ({ text, selectionStart, selectionEnd }): EditorSnapshot => {
|
||||
const selected = text.slice(selectionStart, selectionEnd);
|
||||
const width = marker.length;
|
||||
|
||||
if (
|
||||
selected.length >= width * 2 &&
|
||||
selected.startsWith(marker) &&
|
||||
selected.endsWith(marker)
|
||||
) {
|
||||
const inner = selected.slice(width, -width);
|
||||
return {
|
||||
text: text.slice(0, selectionStart) + inner + text.slice(selectionEnd),
|
||||
selectionStart,
|
||||
selectionEnd: selectionStart + inner.length,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
selectionStart >= width &&
|
||||
text.slice(selectionStart - width, selectionStart) === marker &&
|
||||
text.slice(selectionEnd, selectionEnd + width) === marker
|
||||
) {
|
||||
return {
|
||||
text:
|
||||
text.slice(0, selectionStart - width) +
|
||||
selected +
|
||||
text.slice(selectionEnd + width),
|
||||
selectionStart: selectionStart - width,
|
||||
selectionEnd: selectionStart - width + selected.length,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
text:
|
||||
text.slice(0, selectionStart) +
|
||||
marker +
|
||||
selected +
|
||||
marker +
|
||||
text.slice(selectionEnd),
|
||||
selectionStart: selectionStart + width,
|
||||
selectionEnd: selectionStart + width + selected.length,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/** Lands the selection on whichever half is still a placeholder. */
|
||||
const insertLink: EditorTransform = ({
|
||||
text,
|
||||
selectionStart,
|
||||
selectionEnd,
|
||||
}): EditorSnapshot => {
|
||||
const selected = text.slice(selectionStart, selectionEnd);
|
||||
const label = selected || LINK_LABEL_PLACEHOLDER;
|
||||
const snippet = `[${label}](${LINK_URL_PLACEHOLDER})`;
|
||||
const nextText =
|
||||
text.slice(0, selectionStart) + snippet + text.slice(selectionEnd);
|
||||
// `[` + label + `](` is label.length + 3 characters.
|
||||
const target = selected
|
||||
? {
|
||||
from: selectionStart + label.length + 3,
|
||||
length: LINK_URL_PLACEHOLDER.length,
|
||||
}
|
||||
: { from: selectionStart + 1, length: label.length };
|
||||
|
||||
return {
|
||||
text: nextText,
|
||||
selectionStart: target.from,
|
||||
selectionEnd: target.from + target.length,
|
||||
};
|
||||
};
|
||||
|
||||
/** Backticks for a single-line selection, a fence for a multi-line one. */
|
||||
const insertCode: EditorTransform = (snapshot): EditorSnapshot => {
|
||||
const { text, selectionStart, selectionEnd } = snapshot;
|
||||
const selected = text.slice(selectionStart, selectionEnd);
|
||||
if (!selected.includes('\n')) {
|
||||
return toggleWrap(INLINE_CODE_MARKER)(snapshot);
|
||||
}
|
||||
|
||||
const { text: nextText, blockStart } = replaceWithBlock(
|
||||
snapshot,
|
||||
`${CODE_FENCE}\n${selected}\n${CODE_FENCE}`,
|
||||
);
|
||||
const contentStart = blockStart + CODE_FENCE.length + 1;
|
||||
return {
|
||||
text: nextText,
|
||||
selectionStart: contentStart,
|
||||
selectionEnd: contentStart + selected.length,
|
||||
};
|
||||
};
|
||||
|
||||
/** Selects the first header cell, for immediate typing. */
|
||||
const insertTable: EditorTransform = (snapshot): EditorSnapshot => {
|
||||
const { text, blockStart } = replaceWithBlock(snapshot, TABLE_SNIPPET);
|
||||
const firstCell = blockStart + TABLE_SNIPPET.indexOf(TABLE_CELL_PLACEHOLDER);
|
||||
return {
|
||||
text,
|
||||
selectionStart: firstCell,
|
||||
selectionEnd: firstCell + TABLE_CELL_PLACEHOLDER.length,
|
||||
};
|
||||
};
|
||||
|
||||
/** Replaces the selection and leaves the caret after the insertion. */
|
||||
export function insertText(
|
||||
snapshot: EditorSnapshot,
|
||||
value: string,
|
||||
): EditorSnapshot {
|
||||
const { text, selectionStart, selectionEnd } = snapshot;
|
||||
const caret = selectionStart + value.length;
|
||||
return {
|
||||
text: text.slice(0, selectionStart) + value + text.slice(selectionEnd),
|
||||
selectionStart: caret,
|
||||
selectionEnd: caret,
|
||||
};
|
||||
}
|
||||
|
||||
/** Display order. A new action is an entry here plus an icon in `EditorToolbar`. */
|
||||
export const MARKDOWN_COMMANDS: EditorCommand[] = [
|
||||
{
|
||||
id: 'heading',
|
||||
label: 'Heading',
|
||||
run: toggleLinePrefix(HEADING_PREFIX, HEADING_PATTERN),
|
||||
},
|
||||
{
|
||||
id: 'bold',
|
||||
label: 'Bold',
|
||||
run: toggleWrap(BOLD_MARKER),
|
||||
},
|
||||
{
|
||||
id: 'italic',
|
||||
label: 'Italic',
|
||||
run: toggleWrap(ITALIC_MARKER),
|
||||
},
|
||||
{
|
||||
id: 'bulleted-list',
|
||||
label: 'Bulleted list',
|
||||
run: toggleList(BULLET_LIST_PATTERN, () => BULLET_MARKER),
|
||||
},
|
||||
{
|
||||
id: 'numbered-list',
|
||||
label: 'Numbered list',
|
||||
run: toggleList(ORDERED_LIST_PATTERN, (index) => `${index + 1}. `),
|
||||
},
|
||||
{ id: 'link', label: 'Link', run: insertLink },
|
||||
{ id: 'code', label: 'Code', run: insertCode },
|
||||
{ id: 'table', label: 'Table', run: insertTable },
|
||||
];
|
||||
@@ -1,149 +0,0 @@
|
||||
import type { Extension, Line, Range } from '@codemirror/state';
|
||||
import {
|
||||
Decoration,
|
||||
type DecorationSet,
|
||||
EditorView,
|
||||
ViewPlugin,
|
||||
type ViewUpdate,
|
||||
} from '@codemirror/view';
|
||||
|
||||
const FENCE_PATTERN = /^ {0,3}(```|~~~)/;
|
||||
const HEADING_PATTERN = /^ {0,3}#{1,6} /;
|
||||
const QUOTE_PATTERN = /^ {0,3}> ?/;
|
||||
const LIST_MARKER_PATTERN = /^ {0,3}([-*+]|\d+\.) /;
|
||||
|
||||
/**
|
||||
* Convention: capture group 1, when present, is a left guard the token excludes —
|
||||
* the token runs from the end of that group to the end of the match. Lookbehind is
|
||||
* avoided for Safari compatibility, so guards are captured rather than asserted.
|
||||
*/
|
||||
const INLINE_PATTERNS: { pattern: RegExp; className: string }[] = [
|
||||
{ pattern: /`[^`\n]+`/g, className: 'cm-md-code' },
|
||||
{ pattern: /\*\*[^*\n]+\*\*/g, className: 'cm-md-strong' },
|
||||
{ pattern: /(^|[^\w*_`])_[^_\n]+_(?![\w_])/g, className: 'cm-md-emphasis' },
|
||||
{ pattern: /!?\[[^\]\n]*\]\([^)\n]*\)/g, className: 'cm-md-link' },
|
||||
{
|
||||
// The four variable syntaxes a dashboard body may carry.
|
||||
pattern:
|
||||
/\{\{\s*\.?[\w.-]+\s*\}\}|\[\[\s*[\w.-]+\s*\]\]|\$(?!__)[A-Za-z_]\w*(?:\.\w+)*/g,
|
||||
className: 'cm-md-variable',
|
||||
},
|
||||
];
|
||||
|
||||
const MARKS = {
|
||||
heading: Decoration.mark({ class: 'cm-md-heading' }),
|
||||
quote: Decoration.mark({ class: 'cm-md-quote' }),
|
||||
listMarker: Decoration.mark({ class: 'cm-md-list-marker' }),
|
||||
code: Decoration.mark({ class: 'cm-md-code' }),
|
||||
} as const;
|
||||
|
||||
const INLINE_MARKS = INLINE_PATTERNS.map(({ pattern, className }) => ({
|
||||
pattern,
|
||||
mark: Decoration.mark({ class: className }),
|
||||
}));
|
||||
|
||||
function pushInlineMarks(
|
||||
lineText: string,
|
||||
lineFrom: number,
|
||||
ranges: Range<Decoration>[],
|
||||
): void {
|
||||
INLINE_MARKS.forEach(({ pattern, mark }) => {
|
||||
pattern.lastIndex = 0;
|
||||
let match = pattern.exec(lineText);
|
||||
while (match !== null) {
|
||||
const guardLength = match[1]?.length ?? 0;
|
||||
const from = lineFrom + match.index + guardLength;
|
||||
const to = lineFrom + match.index + match[0].length;
|
||||
if (to > from) {
|
||||
ranges.push(mark.range(from, to));
|
||||
}
|
||||
match = pattern.exec(lineText);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function pushBlockMark(line: Line, ranges: Range<Decoration>[]): void {
|
||||
if (HEADING_PATTERN.test(line.text)) {
|
||||
ranges.push(MARKS.heading.range(line.from, line.to));
|
||||
return;
|
||||
}
|
||||
if (QUOTE_PATTERN.test(line.text)) {
|
||||
ranges.push(MARKS.quote.range(line.from, line.to));
|
||||
return;
|
||||
}
|
||||
const listMarker = LIST_MARKER_PATTERN.exec(line.text);
|
||||
if (listMarker) {
|
||||
ranges.push(
|
||||
MARKS.listMarker.range(line.from, line.from + listMarker[0].length),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Scans the whole document rather than the viewport: fenced blocks opening above
|
||||
// the visible range would otherwise be mis-detected. Bounded by the length cap.
|
||||
function buildDecorations(view: EditorView): DecorationSet {
|
||||
const { doc } = view.state;
|
||||
const ranges: Range<Decoration>[] = [];
|
||||
let isInsideFence = false;
|
||||
|
||||
for (let lineNumber = 1; lineNumber <= doc.lines; lineNumber += 1) {
|
||||
const line = doc.line(lineNumber);
|
||||
const isFenceDelimiter = FENCE_PATTERN.test(line.text);
|
||||
|
||||
if (isFenceDelimiter || isInsideFence) {
|
||||
if (line.to > line.from) {
|
||||
ranges.push(MARKS.code.range(line.from, line.to));
|
||||
}
|
||||
isInsideFence = isFenceDelimiter ? !isInsideFence : isInsideFence;
|
||||
} else {
|
||||
pushBlockMark(line, ranges);
|
||||
pushInlineMarks(line.text, line.from, ranges);
|
||||
}
|
||||
}
|
||||
|
||||
return Decoration.set(ranges, true);
|
||||
}
|
||||
|
||||
// Colours come from custom properties so the SCSS module owns light/dark.
|
||||
const syntaxTheme = EditorView.theme({
|
||||
'.cm-md-heading': {
|
||||
color: 'var(--md-syntax-heading)',
|
||||
fontWeight: '600',
|
||||
},
|
||||
'.cm-md-quote': { color: 'var(--md-syntax-quote)', fontStyle: 'italic' },
|
||||
'.cm-md-list-marker': { color: 'var(--md-syntax-marker)' },
|
||||
'.cm-md-code': { color: 'var(--md-syntax-code)' },
|
||||
'.cm-md-strong': { color: 'var(--md-syntax-strong)', fontWeight: '600' },
|
||||
'.cm-md-emphasis': {
|
||||
color: 'var(--md-syntax-emphasis)',
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
'.cm-md-link': { color: 'var(--md-syntax-link)' },
|
||||
'.cm-md-variable': { color: 'var(--md-syntax-variable)' },
|
||||
});
|
||||
|
||||
const highlightPlugin = ViewPlugin.fromClass(
|
||||
class {
|
||||
decorations: DecorationSet;
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.decorations = buildDecorations(view);
|
||||
}
|
||||
|
||||
update(update: ViewUpdate): void {
|
||||
if (update.docChanged || update.viewportChanged) {
|
||||
this.decorations = buildDecorations(update.view);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ decorations: (plugin): DecorationSet => plugin.decorations },
|
||||
);
|
||||
|
||||
/**
|
||||
* Decorations rather than a grammar, so the editor stays on the CodeMirror packages
|
||||
* already bundled — no `@codemirror/lang-markdown` / `@lezer` for what is only a
|
||||
* colouring pass over a body the renderer parses for real.
|
||||
*/
|
||||
export function markdownHighlight(): Extension {
|
||||
return [highlightPlugin, syntaxTheme];
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
/** The value every editor command reads and returns. */
|
||||
export interface EditorSnapshot {
|
||||
text: string;
|
||||
selectionStart: number;
|
||||
selectionEnd: number;
|
||||
}
|
||||
|
||||
export type EditorTransform = (snapshot: EditorSnapshot) => EditorSnapshot;
|
||||
|
||||
export interface EditorVariable {
|
||||
name: string;
|
||||
/** Short tag for the variable's kind, e.g. "QUERY". */
|
||||
badge?: string;
|
||||
}
|
||||
|
||||
export interface EditorCommand {
|
||||
id: string;
|
||||
/** Accessible name and tooltip for the toolbar button. */
|
||||
label: string;
|
||||
run: EditorTransform;
|
||||
}
|
||||
|
||||
/** 1-based, as the status bar reports it. */
|
||||
export interface CursorPosition {
|
||||
line: number;
|
||||
column: number;
|
||||
}
|
||||
@@ -16,8 +16,6 @@ import { githubLight } from '@uiw/codemirror-theme-github';
|
||||
import CodeMirror, { EditorView, keymap, Prec } from '@uiw/react-codemirror';
|
||||
import { Button, Card, Collapse, Popover, Tooltip } from 'antd';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { getKeySuggestions } from 'api/querySuggestions/getKeySuggestions';
|
||||
import { getValueSuggestions } from 'api/querySuggestions/getValueSuggestion';
|
||||
import cx from 'classnames';
|
||||
import {
|
||||
negationQueryOperatorSuggestions,
|
||||
@@ -54,6 +52,12 @@ import {
|
||||
SUGGESTION_FETCH_DEBOUNCE_MS,
|
||||
SUGGESTIONS_SECTION,
|
||||
} from './constants';
|
||||
import {
|
||||
fetchFieldKeysForQuery,
|
||||
fetchFieldValuesForQuery,
|
||||
SuggestedFieldKey,
|
||||
SuggestedFieldKeysByName,
|
||||
} from './fieldSuggestions';
|
||||
import {
|
||||
combineInitialAndUserExpression,
|
||||
dedupeOptionsByLabel,
|
||||
@@ -261,10 +265,8 @@ function QuerySearch({
|
||||
const dashboardDynamicVariables = useDynamicVariableSuggestions();
|
||||
|
||||
// Add back the generateOptions function and useEffect
|
||||
const generateOptions = (keys: {
|
||||
[key: string]: QueryKeyDataSuggestionsProps[];
|
||||
}): any[] =>
|
||||
Object.values(keys).flatMap((items: QueryKeyDataSuggestionsProps[]) =>
|
||||
const generateOptions = (keys: SuggestedFieldKeysByName): any[] =>
|
||||
Object.values(keys).flatMap((items: SuggestedFieldKey[]) =>
|
||||
items.map(({ name, fieldDataType, fieldContext }) => ({
|
||||
label: name,
|
||||
type: fieldDataType === 'string' ? 'keyword' : fieldDataType,
|
||||
@@ -317,8 +319,9 @@ function QuerySearch({
|
||||
|
||||
lastFetchedKeyRef.current = searchText || '';
|
||||
|
||||
const response = await getKeySuggestions({
|
||||
signal: dataSource,
|
||||
const response = await fetchFieldKeysForQuery({
|
||||
builderQueryType: queryData.builderQueryType,
|
||||
dataSource,
|
||||
searchText: searchText || '',
|
||||
metricName: debouncedMetricName ?? undefined,
|
||||
signalSource: signalSource as 'meter' | '',
|
||||
@@ -360,6 +363,7 @@ function QuerySearch({
|
||||
hardcodedAttributeKeys,
|
||||
showFilterSuggestionsWithoutMetric,
|
||||
metricNamespace,
|
||||
queryData.builderQueryType,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -493,10 +497,11 @@ function QuerySearch({
|
||||
try {
|
||||
const values = valueSuggestionsOverride
|
||||
? await valueSuggestionsOverride(key, sanitizedSearchText)
|
||||
: await getValueSuggestions({
|
||||
: await fetchFieldValuesForQuery({
|
||||
builderQueryType: queryData.builderQueryType,
|
||||
dataSource,
|
||||
key,
|
||||
searchText: sanitizedSearchText,
|
||||
signal: dataSource,
|
||||
signalSource: signalSource as 'meter' | '',
|
||||
metricName: debouncedMetricName ?? undefined,
|
||||
}).then((response) => {
|
||||
@@ -601,6 +606,7 @@ function QuerySearch({
|
||||
signalSource,
|
||||
toggleSuggestions,
|
||||
valueSuggestionsOverride,
|
||||
queryData.builderQueryType,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import {
|
||||
getAIObservabilityFieldsKeys,
|
||||
getAIObservabilityFieldsValues,
|
||||
} from 'api/generated/services/ai-observability';
|
||||
import { getKeySuggestions } from 'api/querySuggestions/getKeySuggestions';
|
||||
import { getValueSuggestions } from 'api/querySuggestions/getValueSuggestion';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import {
|
||||
fetchFieldKeysForQuery,
|
||||
fetchFieldValuesForQuery,
|
||||
} from '../fieldSuggestions';
|
||||
|
||||
jest.mock('api/generated/services/ai-observability', () => ({
|
||||
getAIObservabilityFieldsKeys: jest.fn(),
|
||||
getAIObservabilityFieldsValues: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('api/querySuggestions/getKeySuggestions', () => ({
|
||||
getKeySuggestions: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('api/querySuggestions/getValueSuggestion', () => ({
|
||||
getValueSuggestions: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockedAIKeys = getAIObservabilityFieldsKeys as jest.MockedFunction<
|
||||
typeof getAIObservabilityFieldsKeys
|
||||
>;
|
||||
const mockedGenericKeys = getKeySuggestions as jest.MockedFunction<
|
||||
typeof getKeySuggestions
|
||||
>;
|
||||
const mockedAIValues = getAIObservabilityFieldsValues as jest.MockedFunction<
|
||||
typeof getAIObservabilityFieldsValues
|
||||
>;
|
||||
const mockedGenericValues = getValueSuggestions as jest.MockedFunction<
|
||||
typeof getValueSuggestions
|
||||
>;
|
||||
|
||||
const aiValuesResponse = (
|
||||
values: { stringValues?: string[]; numberValues?: number[] } | null,
|
||||
complete = true,
|
||||
): Awaited<ReturnType<typeof getAIObservabilityFieldsValues>> =>
|
||||
({
|
||||
status: 'success',
|
||||
data: { complete, values },
|
||||
}) as Awaited<ReturnType<typeof getAIObservabilityFieldsValues>>;
|
||||
|
||||
describe('fetchFieldKeysForQuery', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('reads the ai_observability endpoint for a builder_ai_query', async () => {
|
||||
mockedAIKeys.mockResolvedValue({
|
||||
status: 'success',
|
||||
data: {
|
||||
complete: true,
|
||||
keys: { llm_call_count: [{ name: 'llm_call_count' }] },
|
||||
},
|
||||
} as Awaited<ReturnType<typeof getAIObservabilityFieldsKeys>>);
|
||||
|
||||
const keys = await fetchFieldKeysForQuery({
|
||||
builderQueryType: 'builder_ai_query',
|
||||
dataSource: DataSource.TRACES,
|
||||
searchText: 'llm',
|
||||
});
|
||||
|
||||
expect(mockedAIKeys).toHaveBeenCalledWith({ searchText: 'llm' });
|
||||
expect(mockedGenericKeys).not.toHaveBeenCalled();
|
||||
expect(keys.data.data).toStrictEqual({
|
||||
complete: true,
|
||||
keys: { llm_call_count: [{ name: 'llm_call_count' }] },
|
||||
});
|
||||
});
|
||||
|
||||
it.each<[string, 'builder_query' | undefined]>([
|
||||
['an unmarked query', undefined],
|
||||
['an explicitly generic query', 'builder_query'],
|
||||
])('reads the generic endpoint for %s', async (_label, builderQueryType) => {
|
||||
mockedGenericKeys.mockResolvedValue({
|
||||
data: { status: 'success', data: { complete: true, keys: {} } },
|
||||
} as Awaited<ReturnType<typeof getKeySuggestions>>);
|
||||
|
||||
await fetchFieldKeysForQuery({
|
||||
builderQueryType,
|
||||
dataSource: DataSource.TRACES,
|
||||
searchText: 'svc',
|
||||
});
|
||||
|
||||
expect(mockedAIKeys).not.toHaveBeenCalled();
|
||||
expect(mockedGenericKeys).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ signal: DataSource.TRACES, searchText: 'svc' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('normalizes a null ai_observability keys payload to an empty map', async () => {
|
||||
mockedAIKeys.mockResolvedValue({
|
||||
status: 'success',
|
||||
data: { complete: false, keys: null },
|
||||
} as Awaited<ReturnType<typeof getAIObservabilityFieldsKeys>>);
|
||||
|
||||
const response = await fetchFieldKeysForQuery({
|
||||
builderQueryType: 'builder_ai_query',
|
||||
dataSource: DataSource.TRACES,
|
||||
searchText: '',
|
||||
});
|
||||
|
||||
expect(response.data.data).toStrictEqual({ complete: false, keys: {} });
|
||||
});
|
||||
|
||||
it('passes the generic response through untouched', async () => {
|
||||
const genericResponse = {
|
||||
data: { status: 'success', data: { complete: true, keys: {} } },
|
||||
} as unknown as Awaited<ReturnType<typeof getKeySuggestions>>;
|
||||
mockedGenericKeys.mockResolvedValue(genericResponse);
|
||||
|
||||
await expect(
|
||||
fetchFieldKeysForQuery({
|
||||
builderQueryType: 'builder_query',
|
||||
dataSource: DataSource.TRACES,
|
||||
searchText: '',
|
||||
}),
|
||||
).resolves.toBe(genericResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchFieldValuesForQuery', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('reads the ai_observability endpoint for a builder_ai_query', async () => {
|
||||
mockedAIValues.mockResolvedValue(
|
||||
aiValuesResponse({ stringValues: ['gpt-4o'], numberValues: [] }),
|
||||
);
|
||||
|
||||
const response = await fetchFieldValuesForQuery({
|
||||
builderQueryType: 'builder_ai_query',
|
||||
dataSource: DataSource.TRACES,
|
||||
key: 'gen_ai.request.model',
|
||||
searchText: 'gpt',
|
||||
});
|
||||
|
||||
expect(mockedGenericValues).not.toHaveBeenCalled();
|
||||
expect(response).toStrictEqual({
|
||||
data: {
|
||||
data: {
|
||||
complete: true,
|
||||
values: { stringValues: ['gpt-4o'], numberValues: [] },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('forwards the key as the name the endpoint expects', async () => {
|
||||
mockedAIValues.mockResolvedValue(aiValuesResponse({}));
|
||||
|
||||
await fetchFieldValuesForQuery({
|
||||
builderQueryType: 'builder_ai_query',
|
||||
dataSource: DataSource.TRACES,
|
||||
key: 'total_tokens',
|
||||
searchText: '',
|
||||
});
|
||||
|
||||
expect(mockedAIValues).toHaveBeenCalledWith({
|
||||
name: 'total_tokens',
|
||||
searchText: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('wraps the ai_observability payload in the envelope the call site unwraps', async () => {
|
||||
mockedAIValues.mockResolvedValue(aiValuesResponse(null, false));
|
||||
|
||||
await expect(
|
||||
fetchFieldValuesForQuery({
|
||||
builderQueryType: 'builder_ai_query',
|
||||
dataSource: DataSource.TRACES,
|
||||
key: 'llm_call_count',
|
||||
searchText: '',
|
||||
}),
|
||||
).resolves.toStrictEqual({
|
||||
data: { data: { complete: false, values: null } },
|
||||
});
|
||||
});
|
||||
|
||||
it.each<[string, 'builder_query' | undefined]>([
|
||||
['an unmarked query', undefined],
|
||||
['an explicitly generic query', 'builder_query'],
|
||||
])('reads the generic endpoint for %s', async (_label, builderQueryType) => {
|
||||
const genericResponse = {
|
||||
data: {
|
||||
data: { complete: false, values: { stringValues: ['frontend'] } },
|
||||
},
|
||||
} as unknown as Awaited<ReturnType<typeof getValueSuggestions>>;
|
||||
mockedGenericValues.mockResolvedValue(genericResponse);
|
||||
|
||||
const response = await fetchFieldValuesForQuery({
|
||||
builderQueryType,
|
||||
dataSource: DataSource.TRACES,
|
||||
key: 'service.name',
|
||||
searchText: 'front',
|
||||
});
|
||||
|
||||
expect(mockedAIValues).not.toHaveBeenCalled();
|
||||
expect(mockedGenericValues).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
signal: DataSource.TRACES,
|
||||
key: 'service.name',
|
||||
searchText: 'front',
|
||||
}),
|
||||
);
|
||||
expect(response).toBe(genericResponse);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import {
|
||||
getAIObservabilityFieldsKeys,
|
||||
getAIObservabilityFieldsValues,
|
||||
} from 'api/generated/services/ai-observability';
|
||||
import { getKeySuggestions } from 'api/querySuggestions/getKeySuggestions';
|
||||
import { getValueSuggestions } from 'api/querySuggestions/getValueSuggestion';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
export interface SuggestedFieldKey {
|
||||
name: string;
|
||||
fieldContext?: string;
|
||||
fieldDataType?: string;
|
||||
}
|
||||
|
||||
export type SuggestedFieldKeysByName = Record<string, SuggestedFieldKey[]>;
|
||||
|
||||
export interface SuggestedFieldKeysPayload {
|
||||
complete: boolean;
|
||||
keys: SuggestedFieldKeysByName;
|
||||
}
|
||||
|
||||
export interface SuggestedFieldKeysResponse {
|
||||
data: { data?: SuggestedFieldKeysPayload };
|
||||
}
|
||||
|
||||
export interface SuggestedFieldValuesPayload {
|
||||
complete?: boolean;
|
||||
values?: {
|
||||
stringValues?: string[] | null;
|
||||
numberValues?: number[] | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface SuggestedFieldValuesResponse {
|
||||
data: { data?: SuggestedFieldValuesPayload };
|
||||
}
|
||||
|
||||
interface FetchFieldKeysParams {
|
||||
builderQueryType: IBuilderQuery['builderQueryType'];
|
||||
dataSource: DataSource;
|
||||
searchText: string;
|
||||
metricName?: string;
|
||||
signalSource?: 'meter' | '';
|
||||
metricNamespace?: string;
|
||||
}
|
||||
|
||||
interface FetchFieldValuesParams {
|
||||
builderQueryType: IBuilderQuery['builderQueryType'];
|
||||
dataSource: DataSource;
|
||||
key: string;
|
||||
searchText: string;
|
||||
metricName?: string;
|
||||
signalSource?: 'meter' | '';
|
||||
}
|
||||
|
||||
export const fetchFieldKeysForQuery = async ({
|
||||
builderQueryType,
|
||||
dataSource,
|
||||
searchText,
|
||||
metricName,
|
||||
signalSource,
|
||||
metricNamespace,
|
||||
}: FetchFieldKeysParams): Promise<SuggestedFieldKeysResponse> => {
|
||||
if (builderQueryType === 'builder_ai_query') {
|
||||
const response = await getAIObservabilityFieldsKeys({ searchText });
|
||||
|
||||
return {
|
||||
data: {
|
||||
data: response.data
|
||||
? { complete: response.data.complete, keys: response.data.keys ?? {} }
|
||||
: undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return getKeySuggestions({
|
||||
signal: dataSource,
|
||||
searchText,
|
||||
metricName,
|
||||
signalSource,
|
||||
metricNamespace,
|
||||
});
|
||||
};
|
||||
|
||||
export const fetchFieldValuesForQuery = async ({
|
||||
builderQueryType,
|
||||
dataSource,
|
||||
key,
|
||||
searchText,
|
||||
metricName,
|
||||
signalSource,
|
||||
}: FetchFieldValuesParams): Promise<SuggestedFieldValuesResponse> => {
|
||||
if (builderQueryType === 'builder_ai_query') {
|
||||
const response = await getAIObservabilityFieldsValues({
|
||||
name: key,
|
||||
searchText,
|
||||
});
|
||||
|
||||
return { data: { data: response.data } };
|
||||
}
|
||||
|
||||
// getValueSuggestions' declared response type does not match what the endpoint returns.
|
||||
return getValueSuggestions({
|
||||
signal: dataSource,
|
||||
key,
|
||||
searchText,
|
||||
signalSource,
|
||||
metricName,
|
||||
}) as unknown as Promise<SuggestedFieldValuesResponse>;
|
||||
};
|
||||
@@ -54,7 +54,7 @@ export const QueryV2 = forwardRef(function QueryV2(
|
||||
const { cloneQuery, panelType } = useQueryBuilder();
|
||||
|
||||
const showFunctions = query?.functions?.length > 0;
|
||||
const { dataSource } = query;
|
||||
const { dataSource, builderQueryType } = query;
|
||||
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
|
||||
@@ -94,8 +94,9 @@ export const QueryV2 = forwardRef(function QueryV2(
|
||||
);
|
||||
|
||||
const showSpanScopeSelector = useMemo(
|
||||
() => dataSource === DataSource.TRACES,
|
||||
[dataSource],
|
||||
() =>
|
||||
dataSource === DataSource.TRACES && builderQueryType !== 'builder_ai_query',
|
||||
[dataSource, builderQueryType],
|
||||
);
|
||||
|
||||
const showInlineQuerySearch = useMemo(() => {
|
||||
|
||||
@@ -143,7 +143,7 @@ describe('CheckboxFilter - User Flows', () => {
|
||||
render(
|
||||
<CheckboxFilter
|
||||
filter={mockFilter}
|
||||
source={QuickFiltersSource.LOGS_EXPLORER}
|
||||
pageSource={QuickFiltersSource.LOGS_EXPLORER}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -178,7 +178,7 @@ describe('CheckboxFilter - User Flows', () => {
|
||||
render(
|
||||
<CheckboxFilter
|
||||
filter={mockFilter}
|
||||
source={QuickFiltersSource.LOGS_EXPLORER}
|
||||
pageSource={QuickFiltersSource.LOGS_EXPLORER}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -218,7 +218,7 @@ describe('CheckboxFilter - User Flows', () => {
|
||||
render(
|
||||
<CheckboxFilter
|
||||
filter={mockFilter}
|
||||
source={QuickFiltersSource.LOGS_EXPLORER}
|
||||
pageSource={QuickFiltersSource.LOGS_EXPLORER}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -281,7 +281,7 @@ describe('CheckboxFilter - User Flows', () => {
|
||||
render(
|
||||
<CheckboxFilter
|
||||
filter={mockFilter}
|
||||
source={QuickFiltersSource.LOGS_EXPLORER}
|
||||
pageSource={QuickFiltersSource.LOGS_EXPLORER}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -339,7 +339,7 @@ describe('CheckboxFilter - User Flows', () => {
|
||||
render(
|
||||
<CheckboxFilter
|
||||
filter={mockFilter}
|
||||
source={QuickFiltersSource.LOGS_EXPLORER}
|
||||
pageSource={QuickFiltersSource.LOGS_EXPLORER}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -397,7 +397,7 @@ describe('CheckboxFilter - User Flows', () => {
|
||||
render(
|
||||
<CheckboxFilter
|
||||
filter={mockFilter}
|
||||
source={QuickFiltersSource.LOGS_EXPLORER}
|
||||
pageSource={QuickFiltersSource.LOGS_EXPLORER}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -449,7 +449,7 @@ describe('CheckboxFilter - User Flows', () => {
|
||||
render(
|
||||
<CheckboxFilter
|
||||
filter={mockFilter}
|
||||
source={QuickFiltersSource.LOGS_EXPLORER}
|
||||
pageSource={QuickFiltersSource.LOGS_EXPLORER}
|
||||
/>,
|
||||
);
|
||||
|
||||
|
||||
@@ -27,17 +27,17 @@ const SOURCES_WITH_EMPTY_STATE_ENABLED = [QuickFiltersSource.LOGS_EXPLORER];
|
||||
|
||||
interface ICheckboxProps {
|
||||
filter: IQuickFiltersConfig;
|
||||
source: QuickFiltersSource;
|
||||
pageSource: QuickFiltersSource;
|
||||
onFilterChange?: (query: Query) => void;
|
||||
onQuickFilterChange?: (data: QuickFilterChangeEventData) => void;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
|
||||
const { source, filter, onFilterChange, onQuickFilterChange } = props;
|
||||
const { pageSource, filter, onFilterChange, onQuickFilterChange } = props;
|
||||
const [searchText, setSearchText] = useState<string>('');
|
||||
|
||||
const activeQueryIndex = useActiveQueryIndex(source);
|
||||
const activeQueryIndex = useActiveQueryIndex(pageSource);
|
||||
|
||||
const {
|
||||
isOpen,
|
||||
@@ -49,7 +49,7 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
|
||||
|
||||
const { attributeValues, isLoading } = useCheckboxFilterValues({
|
||||
filter,
|
||||
source,
|
||||
pageSource,
|
||||
searchText,
|
||||
isOpen,
|
||||
});
|
||||
@@ -59,7 +59,7 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
|
||||
|
||||
const { onChange, onClear } = useCheckboxFilterActions({
|
||||
filter,
|
||||
source,
|
||||
pageSource,
|
||||
attributeValues,
|
||||
activeQueryIndex,
|
||||
onFilterChange,
|
||||
@@ -88,7 +88,7 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
|
||||
);
|
||||
|
||||
const isEmptyStateWithDocsEnabled =
|
||||
SOURCES_WITH_EMPTY_STATE_ENABLED.includes(source) &&
|
||||
SOURCES_WITH_EMPTY_STATE_ENABLED.includes(pageSource) &&
|
||||
!searchText &&
|
||||
!attributeValues.length;
|
||||
|
||||
|
||||
@@ -97,7 +97,7 @@ interface ToggleAction {
|
||||
isOnlyOrAllClicked?: boolean;
|
||||
previousState?: CheckedState;
|
||||
sectionType?: SectionType;
|
||||
source?: QuickFiltersSource;
|
||||
pageSource?: QuickFiltersSource;
|
||||
attributeValues?: string[];
|
||||
}
|
||||
|
||||
@@ -117,7 +117,7 @@ function runToggle(c: ToggleCase): { items: SimpleItem[]; expression: string } {
|
||||
currentQuery: buildQuery(initialItems, initialExpression),
|
||||
activeQueryIndex: 0,
|
||||
filter: { attributeKey: { key: KEY, type: 'tag' } } as never,
|
||||
source: c.action.source ?? QuickFiltersSource.LOGS_EXPLORER,
|
||||
pageSource: c.action.pageSource ?? QuickFiltersSource.LOGS_EXPLORER,
|
||||
attributeValues: c.action.attributeValues ?? ['a', 'b', 'c'],
|
||||
value: c.action.value,
|
||||
checked: c.action.checked,
|
||||
@@ -162,7 +162,7 @@ const TOGGLE_CASES: ToggleCase[] = [
|
||||
action: {
|
||||
value: 'a',
|
||||
checked: false,
|
||||
source: QuickFiltersSource.INFRA_MONITORING,
|
||||
pageSource: QuickFiltersSource.INFRA_MONITORING,
|
||||
},
|
||||
// `nin` is what the source asks for, but re-deriving the expression
|
||||
// normalises it. Nothing observes the difference: both infra pages send
|
||||
@@ -313,7 +313,7 @@ const TOGGLE_CASES: ToggleCase[] = [
|
||||
action: {
|
||||
value: 'b',
|
||||
checked: false,
|
||||
source: QuickFiltersSource.INFRA_MONITORING,
|
||||
pageSource: QuickFiltersSource.INFRA_MONITORING,
|
||||
},
|
||||
expected: {
|
||||
items: [{ key: KEY, op: 'not in', value: ['a', 'b'] }],
|
||||
|
||||
@@ -47,8 +47,8 @@ const SOURCES_WITH_SHORT_OPERATORS = [QuickFiltersSource.INFRA_MONITORING];
|
||||
* Returns the correct NOT_IN operator value based on source.
|
||||
* InfraMonitoring backend expects 'nin', others expect 'not in'.
|
||||
*/
|
||||
export function getNotInOperator(source: QuickFiltersSource): string {
|
||||
if (SOURCES_WITH_SHORT_OPERATORS.includes(source)) {
|
||||
export function getNotInOperator(pageSource: QuickFiltersSource): string {
|
||||
if (SOURCES_WITH_SHORT_OPERATORS.includes(pageSource)) {
|
||||
return 'nin';
|
||||
}
|
||||
return getOperatorValue('NOT_IN');
|
||||
@@ -172,7 +172,7 @@ export function applyCheckboxToggle({
|
||||
currentQuery,
|
||||
activeQueryIndex,
|
||||
filter,
|
||||
source,
|
||||
pageSource,
|
||||
attributeValues,
|
||||
value,
|
||||
checked,
|
||||
@@ -183,7 +183,7 @@ export function applyCheckboxToggle({
|
||||
currentQuery: Query;
|
||||
activeQueryIndex: number;
|
||||
filter: IQuickFiltersConfig;
|
||||
source: QuickFiltersSource;
|
||||
pageSource: QuickFiltersSource;
|
||||
attributeValues: string[];
|
||||
value: string;
|
||||
checked: boolean;
|
||||
@@ -278,7 +278,7 @@ export function applyCheckboxToggle({
|
||||
if (sectionType === SectionType.RELATED) {
|
||||
const newFilter: TagFilterItem = {
|
||||
id: uuid(),
|
||||
op: getNotInOperator(source),
|
||||
op: getNotInOperator(pageSource),
|
||||
key: filter.attributeKey,
|
||||
value,
|
||||
};
|
||||
@@ -418,7 +418,7 @@ export function applyCheckboxToggle({
|
||||
if (!checked) {
|
||||
const newFilter = {
|
||||
...currentFilter,
|
||||
op: getNotInOperator(source),
|
||||
op: getNotInOperator(pageSource),
|
||||
value: [currentFilter.value as string, value],
|
||||
};
|
||||
query.filters.items = query.filters.items.map((item) => {
|
||||
@@ -442,7 +442,7 @@ export function applyCheckboxToggle({
|
||||
// checked=true → user wants to select (IN), checked=false → exclude (NOT IN)
|
||||
const newFilterItem: TagFilterItem = {
|
||||
id: uuid(),
|
||||
op: checked ? getOperatorValue(OPERATORS.IN) : getNotInOperator(source),
|
||||
op: checked ? getOperatorValue(OPERATORS.IN) : getNotInOperator(pageSource),
|
||||
key: filter.attributeKey,
|
||||
value,
|
||||
};
|
||||
|
||||
@@ -10,18 +10,18 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
* In ListView most sources use index 0; TRACES_EXPLORER and every non-ListView
|
||||
* mode track the last focused query.
|
||||
*/
|
||||
function useActiveQueryIndex(source: QuickFiltersSource): number {
|
||||
function useActiveQueryIndex(pageSource: QuickFiltersSource): number {
|
||||
const { lastUsedQuery, panelType } = useQueryBuilder();
|
||||
const isListView = panelType === PANEL_TYPES.LIST;
|
||||
|
||||
return useMemo(() => {
|
||||
if (isListView) {
|
||||
return source === QuickFiltersSource.TRACES_EXPLORER
|
||||
return pageSource === QuickFiltersSource.TRACES_EXPLORER
|
||||
? lastUsedQuery || 0
|
||||
: 0;
|
||||
}
|
||||
return lastUsedQuery || 0;
|
||||
}, [isListView, source, lastUsedQuery]);
|
||||
}, [isListView, pageSource, lastUsedQuery]);
|
||||
}
|
||||
|
||||
export default useActiveQueryIndex;
|
||||
|
||||
@@ -16,7 +16,7 @@ import { SectionType } from './v2/itemRules';
|
||||
|
||||
interface UseCheckboxFilterActionsProps {
|
||||
filter: IQuickFiltersConfig;
|
||||
source: QuickFiltersSource;
|
||||
pageSource: QuickFiltersSource;
|
||||
attributeValues: string[];
|
||||
activeQueryIndex: number;
|
||||
onFilterChange?: ((query: Query) => void) | null;
|
||||
@@ -40,7 +40,7 @@ interface UseCheckboxFilterActionsReturn {
|
||||
*/
|
||||
function useCheckboxFilterActions({
|
||||
filter,
|
||||
source,
|
||||
pageSource,
|
||||
attributeValues,
|
||||
activeQueryIndex,
|
||||
onFilterChange,
|
||||
@@ -67,7 +67,7 @@ function useCheckboxFilterActions({
|
||||
currentQuery,
|
||||
activeQueryIndex,
|
||||
filter,
|
||||
source,
|
||||
pageSource,
|
||||
attributeValues,
|
||||
value,
|
||||
checked,
|
||||
|
||||
@@ -11,7 +11,7 @@ import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
interface UseCheckboxFilterValuesProps {
|
||||
filter: IQuickFiltersConfig;
|
||||
source: QuickFiltersSource;
|
||||
pageSource: QuickFiltersSource;
|
||||
searchText: string;
|
||||
isOpen: boolean;
|
||||
}
|
||||
@@ -23,7 +23,7 @@ interface UseCheckboxFilterValuesReturn {
|
||||
|
||||
function useCheckboxFilterValues({
|
||||
filter,
|
||||
source,
|
||||
pageSource,
|
||||
searchText,
|
||||
isOpen,
|
||||
}: UseCheckboxFilterValuesProps): UseCheckboxFilterValuesReturn {
|
||||
@@ -38,7 +38,7 @@ function useCheckboxFilterValues({
|
||||
searchText: searchText ?? '',
|
||||
},
|
||||
{
|
||||
enabled: isOpen && source !== QuickFiltersSource.METER_EXPLORER,
|
||||
enabled: isOpen && pageSource !== QuickFiltersSource.METER_EXPLORER,
|
||||
keepPreviousData: true,
|
||||
},
|
||||
);
|
||||
@@ -49,7 +49,7 @@ function useCheckboxFilterValues({
|
||||
signal: filter.dataSource || DataSource.LOGS,
|
||||
signalSource: 'meter',
|
||||
options: {
|
||||
enabled: isOpen && source === QuickFiltersSource.METER_EXPLORER,
|
||||
enabled: isOpen && pageSource === QuickFiltersSource.METER_EXPLORER,
|
||||
keepPreviousData: true,
|
||||
},
|
||||
});
|
||||
@@ -57,7 +57,7 @@ function useCheckboxFilterValues({
|
||||
const attributeValues: string[] = useMemo(() => {
|
||||
const dataType = filter.attributeKey.dataType || DataTypes.String;
|
||||
|
||||
if (source === QuickFiltersSource.METER_EXPLORER && keyValueSuggestions) {
|
||||
if (pageSource === QuickFiltersSource.METER_EXPLORER && keyValueSuggestions) {
|
||||
// Process the response data
|
||||
const responseData = keyValueSuggestions?.data as any;
|
||||
const values = responseData.data?.values || {};
|
||||
@@ -88,7 +88,12 @@ function useCheckboxFilterValues({
|
||||
return (data?.payload?.[key] || []).filter(
|
||||
(val) => val !== undefined && val !== null,
|
||||
);
|
||||
}, [data?.payload, filter.attributeKey.dataType, keyValueSuggestions, source]);
|
||||
}, [
|
||||
data?.payload,
|
||||
filter.attributeKey.dataType,
|
||||
keyValueSuggestions,
|
||||
pageSource,
|
||||
]);
|
||||
|
||||
return {
|
||||
attributeValues,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { render, RenderResult } from 'tests/test-utils';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { server, rest } from 'mocks-server/server';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
@@ -25,6 +26,7 @@ export const DEFAULT_FILTER: IQuickFiltersConfig = {
|
||||
};
|
||||
|
||||
export const DEFAULT_USE_FIELD_APIS: QuickFilterCheckboxUseFieldApis = {
|
||||
signal: TelemetrytypesSignalDTO.traces,
|
||||
startUnixMilli: 1700000000000,
|
||||
endUnixMilli: 1700003600000,
|
||||
existingQuery: null,
|
||||
@@ -34,6 +36,7 @@ export function mockFieldsValuesAPI(response: {
|
||||
relatedValues?: (string | null)[];
|
||||
stringValues?: (string | null)[];
|
||||
numberValues?: (number | null)[];
|
||||
boolValues?: (boolean | null)[];
|
||||
}): void {
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v1/fields/values', (_, res, ctx) =>
|
||||
@@ -46,6 +49,7 @@ export function mockFieldsValuesAPI(response: {
|
||||
relatedValues: response.relatedValues ?? [],
|
||||
stringValues: response.stringValues ?? [],
|
||||
numberValues: response.numberValues ?? [],
|
||||
boolValues: response.boolValues ?? [],
|
||||
},
|
||||
},
|
||||
}),
|
||||
@@ -68,6 +72,13 @@ export function setupServer(): void {
|
||||
afterAll(() => server.close());
|
||||
}
|
||||
|
||||
// Components read currentQuery for the checkbox state and stagedQuery for the
|
||||
// values fetch; in the app both are set by the same URL sync, so tests pass one
|
||||
// query as both.
|
||||
export function buildQueryBuilderOverrides(query: unknown): never {
|
||||
return { currentQuery: query, stagedQuery: query } as unknown as never;
|
||||
}
|
||||
|
||||
export interface FilterItemConfig {
|
||||
op: string;
|
||||
value: string | string[];
|
||||
@@ -90,7 +101,7 @@ export function renderWithFilter(
|
||||
return render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
pageSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: 'service.name = "api"',
|
||||
@@ -99,18 +110,16 @@ export function renderWithFilter(
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: { items, op: 'AND' },
|
||||
filter: { expression: 'service.name = "api"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
queryBuilderOverrides: buildQueryBuilderOverrides({
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: { items, op: 'AND' },
|
||||
filter: { expression: 'service.name = "api"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
} as never,
|
||||
}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ import styles from './CheckboxFilterV2.module.scss';
|
||||
|
||||
interface CheckboxFilterV2Props {
|
||||
filter: IQuickFiltersConfig;
|
||||
source: QuickFiltersSource;
|
||||
pageSource: QuickFiltersSource;
|
||||
onFilterChange?: (query: Query) => void;
|
||||
onQuickFilterChange?: (data: QuickFilterChangeEventData) => void;
|
||||
useFieldApis: QuickFilterCheckboxUseFieldApis;
|
||||
@@ -41,13 +41,18 @@ interface CheckboxFilterV2Props {
|
||||
export default function CheckboxFilterV2(
|
||||
props: CheckboxFilterV2Props,
|
||||
): JSX.Element {
|
||||
const { source, filter, onFilterChange, onQuickFilterChange, useFieldApis } =
|
||||
props;
|
||||
const {
|
||||
pageSource,
|
||||
filter,
|
||||
onFilterChange,
|
||||
onQuickFilterChange,
|
||||
useFieldApis,
|
||||
} = props;
|
||||
const [searchText, setSearchText] = useState<string>('');
|
||||
const [userToggleState, setUserToggleState] = useState<boolean | null>(null);
|
||||
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
const activeQueryIndex = useActiveQueryIndex(source);
|
||||
const activeQueryIndex = useActiveQueryIndex(pageSource);
|
||||
|
||||
const {
|
||||
isOpen,
|
||||
@@ -74,6 +79,8 @@ export default function CheckboxFilterV2(
|
||||
searchText,
|
||||
existingQuery,
|
||||
metricNamespace: useFieldApis.metricNamespace,
|
||||
signal: useFieldApis.signal,
|
||||
source: useFieldApis.source,
|
||||
startUnixMilli: useFieldApis.startUnixMilli,
|
||||
endUnixMilli: useFieldApis.endUnixMilli,
|
||||
enabled: isOpen,
|
||||
@@ -102,7 +109,7 @@ export default function CheckboxFilterV2(
|
||||
|
||||
const { onChange, onClear } = useCheckboxFilterActions({
|
||||
filter,
|
||||
source,
|
||||
pageSource,
|
||||
attributeValues,
|
||||
activeQueryIndex,
|
||||
onFilterChange,
|
||||
@@ -153,6 +160,7 @@ export default function CheckboxFilterV2(
|
||||
isSomeFilterPresentForCurrentAttribute,
|
||||
isNotInOperator,
|
||||
hasExistingQuery,
|
||||
isRelatedValuesSupported: useFieldApis.existingQuery !== null,
|
||||
visibleItemsCount,
|
||||
relatedExclusions,
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import { QuickFiltersSource } from '../../../../types';
|
||||
|
||||
import CheckboxFilterV2 from '../CheckboxFilterV2';
|
||||
import {
|
||||
buildQueryBuilderOverrides,
|
||||
DEFAULT_FILTER,
|
||||
DEFAULT_USE_FIELD_APIS,
|
||||
setupServer,
|
||||
@@ -49,7 +50,7 @@ describe('CheckboxFilterV2 - existingQuery calculation', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
pageSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: 'custom.query = "value"',
|
||||
@@ -57,18 +58,16 @@ describe('CheckboxFilterV2 - existingQuery calculation', () => {
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: { items: [], op: 'AND' },
|
||||
filter: { expression: 'should.be.ignored = "yes"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
queryBuilderOverrides: buildQueryBuilderOverrides({
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: { items: [], op: 'AND' },
|
||||
filter: { expression: 'should.be.ignored = "yes"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
} as never,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -83,7 +82,7 @@ describe('CheckboxFilterV2 - existingQuery calculation', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
pageSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: null,
|
||||
@@ -91,18 +90,16 @@ describe('CheckboxFilterV2 - existingQuery calculation', () => {
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: { items: [], op: 'AND' },
|
||||
filter: { expression: 'should.be.ignored = "yes"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
queryBuilderOverrides: buildQueryBuilderOverrides({
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: { items: [], op: 'AND' },
|
||||
filter: { expression: 'should.be.ignored = "yes"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
} as never,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -119,32 +116,30 @@ describe('CheckboxFilterV2 - existingQuery calculation', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
pageSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={USE_FIELD_APIS_AUTO_DERIVE}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: { key: 'service.name', dataType: 'string', type: 'tag' },
|
||||
op: '=',
|
||||
value: 'from-v3-items',
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
filter: { expression: 'v5.expression = "preferred"' },
|
||||
queryBuilderOverrides: buildQueryBuilderOverrides({
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: { key: 'service.name', dataType: 'string', type: 'tag' },
|
||||
op: '=',
|
||||
value: 'from-v3-items',
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
],
|
||||
},
|
||||
filter: { expression: 'v5.expression = "preferred"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
} as never,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -159,23 +154,21 @@ describe('CheckboxFilterV2 - existingQuery calculation', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
pageSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={USE_FIELD_APIS_AUTO_DERIVE}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: { items: [], op: 'AND' },
|
||||
filter: { expression: 'only.v5 = "expression"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
queryBuilderOverrides: buildQueryBuilderOverrides({
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: { items: [], op: 'AND' },
|
||||
filter: { expression: 'only.v5 = "expression"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
} as never,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -192,31 +185,29 @@ describe('CheckboxFilterV2 - existingQuery calculation', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
pageSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={USE_FIELD_APIS_AUTO_DERIVE}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: { key: 'service.name', dataType: 'string', type: 'tag' },
|
||||
op: '=',
|
||||
value: 'api-service',
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
queryBuilderOverrides: buildQueryBuilderOverrides({
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: { key: 'service.name', dataType: 'string', type: 'tag' },
|
||||
op: '=',
|
||||
value: 'api-service',
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
} as never,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -231,36 +222,34 @@ describe('CheckboxFilterV2 - existingQuery calculation', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
pageSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={USE_FIELD_APIS_AUTO_DERIVE}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: { key: 'service.name', dataType: 'string', type: 'tag' },
|
||||
op: '=',
|
||||
value: 'api',
|
||||
},
|
||||
{
|
||||
key: { key: 'env', dataType: 'string', type: 'tag' },
|
||||
op: '=',
|
||||
value: 'prod',
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
queryBuilderOverrides: buildQueryBuilderOverrides({
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: { key: 'service.name', dataType: 'string', type: 'tag' },
|
||||
op: '=',
|
||||
value: 'api',
|
||||
},
|
||||
{
|
||||
key: { key: 'env', dataType: 'string', type: 'tag' },
|
||||
op: '=',
|
||||
value: 'prod',
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
} as never,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -275,22 +264,20 @@ describe('CheckboxFilterV2 - existingQuery calculation', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
pageSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={USE_FIELD_APIS_AUTO_DERIVE}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: { items: [], op: 'AND' },
|
||||
},
|
||||
],
|
||||
},
|
||||
queryBuilderOverrides: buildQueryBuilderOverrides({
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: { items: [], op: 'AND' },
|
||||
},
|
||||
],
|
||||
},
|
||||
} as never,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { QuickFiltersSource } from '../../../../types';
|
||||
|
||||
import CheckboxFilterV2 from '../CheckboxFilterV2';
|
||||
import {
|
||||
buildQueryBuilderOverrides,
|
||||
DEFAULT_FILTER,
|
||||
DEFAULT_USE_FIELD_APIS,
|
||||
getFilterFromCall,
|
||||
@@ -51,7 +52,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
pageSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
@@ -117,7 +118,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
pageSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: 'service.name = "api"',
|
||||
@@ -125,18 +126,16 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: { items: [], op: 'AND' },
|
||||
filter: { expression: 'service.name = "api"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
queryBuilderOverrides: buildQueryBuilderOverrides({
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: { items: [], op: 'AND' },
|
||||
filter: { expression: 'service.name = "api"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
} as never,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -183,7 +182,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
pageSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: 'service.name = "api"',
|
||||
@@ -230,7 +229,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
pageSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
@@ -272,7 +271,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
pageSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: 'service.name = "api"',
|
||||
@@ -334,7 +333,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
pageSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: 'service.name = "api"',
|
||||
@@ -380,7 +379,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
pageSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
@@ -408,7 +407,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={{ ...DEFAULT_FILTER, defaultOpen: false }}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
pageSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
@@ -434,7 +433,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
pageSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
@@ -460,7 +459,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
pageSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
@@ -485,31 +484,29 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
pageSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: { key: 'deployment.environment' },
|
||||
op: 'in',
|
||||
value: ['production'],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
queryBuilderOverrides: buildQueryBuilderOverrides({
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: { key: 'deployment.environment' },
|
||||
op: 'in',
|
||||
value: ['production'],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
} as never,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -526,7 +523,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
pageSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
@@ -549,32 +546,30 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
pageSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
onFilterChange={onFilterChange}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: { key: 'deployment.environment' },
|
||||
op: 'in',
|
||||
value: ['production'],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
queryBuilderOverrides: buildQueryBuilderOverrides({
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: { key: 'deployment.environment' },
|
||||
op: 'in',
|
||||
value: ['production'],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
} as never,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -598,7 +593,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
pageSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
onFilterChange={onFilterChange}
|
||||
/>,
|
||||
@@ -637,7 +632,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
expect(filter?.value).toBe('valueA');
|
||||
});
|
||||
|
||||
it('converts NOT IN to IN when toggling unchecked (other) item', async () => {
|
||||
it('adds to NOT IN when unchecking a non-excluded (other) item', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onFilterChange = jest.fn();
|
||||
|
||||
@@ -646,18 +641,70 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
stringValues: ['valueB'],
|
||||
});
|
||||
|
||||
// Clicking unchecked "Other" item with NOT IN filter should convert to IN [B]
|
||||
// valueB is not excluded, so under NOT IN [valueA] it is still included
|
||||
// and renders checked. Unchecking it excludes it too → NOT IN [A, B].
|
||||
renderWithFilter(onFilterChange, { op: 'not in', value: ['valueA'] });
|
||||
|
||||
const rowB = await screen.findByTestId('checkbox-value-row-valueB');
|
||||
expect(rowB).toHaveAttribute('data-state', 'unchecked');
|
||||
expect(rowB).toHaveAttribute('data-state', 'checked');
|
||||
|
||||
await user.click(within(rowB).getByRole('checkbox'));
|
||||
|
||||
expect(onFilterChange).toHaveBeenCalledTimes(1);
|
||||
const filter = getFilterFromCall(onFilterChange);
|
||||
expect(filter?.op).toBe('in');
|
||||
expect(filter?.value).toBe('valueB');
|
||||
expect(filter?.op).toBe('not in');
|
||||
expect(filter?.value).toStrictEqual(['valueA', 'valueB']);
|
||||
});
|
||||
|
||||
it('adds to NOT IN when unchecking a non-excluded item without related values', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onFilterChange = jest.fn();
|
||||
|
||||
mockFieldsValuesAPI({
|
||||
stringValues: ['valueA', 'valueB'],
|
||||
});
|
||||
|
||||
// Without related values the display follows the clause: valueB is not
|
||||
// excluded, so it renders checked; unchecking it excludes it too.
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
pageSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
onFilterChange={onFilterChange}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: buildQueryBuilderOverrides({
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: { key: 'deployment.environment' },
|
||||
op: 'not in',
|
||||
value: ['valueA'],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
const rowB = await screen.findByTestId('checkbox-value-row-valueB');
|
||||
expect(rowB).toHaveAttribute('data-state', 'checked');
|
||||
|
||||
await user.click(within(rowB).getByRole('checkbox'));
|
||||
|
||||
expect(onFilterChange).toHaveBeenCalledTimes(1);
|
||||
const filter = getFilterFromCall(onFilterChange);
|
||||
expect(filter?.op).toBe('not in');
|
||||
expect(filter?.value).toStrictEqual(['valueA', 'valueB']);
|
||||
});
|
||||
|
||||
it('accumulates both values in IN when toggling checked (related) then unchecked (other)', async () => {
|
||||
@@ -756,7 +803,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={{ ...DEFAULT_FILTER, customRendererForValue: customRenderer }}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
pageSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { QuickFiltersSource } from '../../../../types';
|
||||
|
||||
import CheckboxFilterV2 from '../CheckboxFilterV2';
|
||||
import {
|
||||
buildQueryBuilderOverrides,
|
||||
DEFAULT_FILTER,
|
||||
DEFAULT_USE_FIELD_APIS,
|
||||
mockFieldsValuesAPI,
|
||||
@@ -14,6 +15,88 @@ import {
|
||||
setupServer();
|
||||
|
||||
describe('CheckboxFilterV2 - item rules', () => {
|
||||
describe('related values unsupported (existingQuery: null)', () => {
|
||||
it('renders a single flat section even when the api returns related values', async () => {
|
||||
mockFieldsValuesAPI({
|
||||
relatedValues: ['production'],
|
||||
stringValues: ['staging'],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
pageSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
|
||||
const productionRow = await screen.findByTestId(
|
||||
'checkbox-value-row-production',
|
||||
);
|
||||
expect(productionRow).toHaveAttribute('data-state', 'checked');
|
||||
expect(screen.getByTestId('checkbox-value-row-staging')).toHaveAttribute(
|
||||
'data-state',
|
||||
'checked',
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('section-divider-related'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('section-divider-all-values'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('splits clause values and the rest into selected and all values sections', async () => {
|
||||
mockFieldsValuesAPI({
|
||||
stringValues: ['production', 'staging'],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
pageSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: buildQueryBuilderOverrides({
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: { key: 'deployment.environment' },
|
||||
op: 'in',
|
||||
value: ['production'],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
const productionRow = await screen.findByTestId(
|
||||
'checkbox-value-row-production',
|
||||
);
|
||||
expect(productionRow).toHaveAttribute('data-state', 'checked');
|
||||
expect(screen.getByTestId('checkbox-value-row-staging')).toHaveAttribute(
|
||||
'data-state',
|
||||
'unchecked',
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('section-divider-all-values')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('section-divider-related'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('no existing query', () => {
|
||||
it('all values show as checked with no badge when no query exists', async () => {
|
||||
mockFieldsValuesAPI({
|
||||
@@ -23,7 +106,7 @@ describe('CheckboxFilterV2 - item rules', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
pageSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
@@ -57,7 +140,7 @@ describe('CheckboxFilterV2 - item rules', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
pageSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: 'service.name = "api"',
|
||||
@@ -65,18 +148,16 @@ describe('CheckboxFilterV2 - item rules', () => {
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: { items: [], op: 'AND' },
|
||||
filter: { expression: 'service.name = "api"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
queryBuilderOverrides: buildQueryBuilderOverrides({
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: { items: [], op: 'AND' },
|
||||
filter: { expression: 'service.name = "api"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
} as never,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -104,7 +185,7 @@ describe('CheckboxFilterV2 - item rules', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
pageSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: 'service.name = "api"',
|
||||
@@ -112,18 +193,16 @@ describe('CheckboxFilterV2 - item rules', () => {
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: { items: [], op: 'AND' },
|
||||
filter: { expression: 'service.name = "api"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
queryBuilderOverrides: buildQueryBuilderOverrides({
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: { items: [], op: 'AND' },
|
||||
filter: { expression: 'service.name = "api"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
} as never,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -142,7 +221,7 @@ describe('CheckboxFilterV2 - item rules', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
pageSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: 'service.name = "api"',
|
||||
@@ -150,27 +229,25 @@ describe('CheckboxFilterV2 - item rules', () => {
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: { key: 'deployment.environment' },
|
||||
op: 'in',
|
||||
value: ['production'],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
filter: { expression: 'service.name = "api"' },
|
||||
queryBuilderOverrides: buildQueryBuilderOverrides({
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: { key: 'deployment.environment' },
|
||||
op: 'in',
|
||||
value: ['production'],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
],
|
||||
},
|
||||
filter: { expression: 'service.name = "api"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
} as never,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -196,31 +273,29 @@ describe('CheckboxFilterV2 - item rules', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
pageSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: { key: 'deployment.environment' },
|
||||
op: 'in',
|
||||
value: ['production'],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
queryBuilderOverrides: buildQueryBuilderOverrides({
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: { key: 'deployment.environment' },
|
||||
op: 'in',
|
||||
value: ['production'],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
} as never,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -246,34 +321,33 @@ describe('CheckboxFilterV2 - item rules', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
pageSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: { key: 'deployment.environment' },
|
||||
op: 'not in',
|
||||
value: ['production'],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
queryBuilderOverrides: buildQueryBuilderOverrides({
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: { key: 'deployment.environment' },
|
||||
op: 'not in',
|
||||
value: ['production'],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
} as never,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
// The excluded value renders unchecked.
|
||||
const productionRow = await screen.findByTestId(
|
||||
'checkbox-value-row-production',
|
||||
);
|
||||
@@ -282,8 +356,9 @@ describe('CheckboxFilterV2 - item rules', () => {
|
||||
within(productionRow).queryByTestId(/^badge-/),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// The non-excluded value is still included by NOT IN, so it stays checked.
|
||||
const stagingRow = screen.getByTestId('checkbox-value-row-staging');
|
||||
expect(stagingRow).toHaveAttribute('data-state', 'unchecked');
|
||||
expect(stagingRow).toHaveAttribute('data-state', 'checked');
|
||||
expect(within(stagingRow).queryByTestId(/^badge-/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -298,7 +373,7 @@ describe('CheckboxFilterV2 - item rules', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
pageSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: 'service.name = "api"',
|
||||
@@ -306,27 +381,25 @@ describe('CheckboxFilterV2 - item rules', () => {
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: { key: 'deployment.environment' },
|
||||
op: 'in',
|
||||
value: ['selected-value'],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
filter: { expression: 'service.name = "api"' },
|
||||
queryBuilderOverrides: buildQueryBuilderOverrides({
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: { key: 'deployment.environment' },
|
||||
op: 'in',
|
||||
value: ['selected-value'],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
],
|
||||
},
|
||||
filter: { expression: 'service.name = "api"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
} as never,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -351,7 +424,7 @@ describe('CheckboxFilterV2 - item rules', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
pageSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: 'service.name = "api"',
|
||||
@@ -359,18 +432,16 @@ describe('CheckboxFilterV2 - item rules', () => {
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: { items: [], op: 'AND' },
|
||||
filter: { expression: 'service.name = "api"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
queryBuilderOverrides: buildQueryBuilderOverrides({
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: { items: [], op: 'AND' },
|
||||
filter: { expression: 'service.name = "api"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
} as never,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -395,7 +466,7 @@ describe('CheckboxFilterV2 - item rules', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
pageSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: 'service.name = "api"',
|
||||
@@ -403,27 +474,25 @@ describe('CheckboxFilterV2 - item rules', () => {
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: { key: 'deployment.environment' },
|
||||
op: 'in',
|
||||
value: ['selected-env'],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
filter: { expression: 'service.name = "api"' },
|
||||
queryBuilderOverrides: buildQueryBuilderOverrides({
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: { key: 'deployment.environment' },
|
||||
op: 'in',
|
||||
value: ['selected-env'],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
],
|
||||
},
|
||||
filter: { expression: 'service.name = "api"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
} as never,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -452,7 +521,7 @@ describe('CheckboxFilterV2 - item rules', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
pageSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: 'service.name = "api"',
|
||||
@@ -460,27 +529,25 @@ describe('CheckboxFilterV2 - item rules', () => {
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: { key: 'deployment.environment' },
|
||||
op: 'not in',
|
||||
value: ['excluded-env'],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
filter: { expression: 'service.name = "api"' },
|
||||
queryBuilderOverrides: buildQueryBuilderOverrides({
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: { key: 'deployment.environment' },
|
||||
op: 'not in',
|
||||
value: ['excluded-env'],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
],
|
||||
},
|
||||
filter: { expression: 'service.name = "api"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
} as never,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ describe('CheckboxFilterV2 - states', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
pageSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
@@ -46,7 +46,7 @@ describe('CheckboxFilterV2 - states', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={closedFilter}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
pageSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
@@ -103,7 +103,7 @@ describe('CheckboxFilterV2 - states', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
pageSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
@@ -132,7 +132,7 @@ describe('CheckboxFilterV2 - states', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
pageSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
@@ -151,7 +151,7 @@ describe('CheckboxFilterV2 - states', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
pageSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
@@ -171,7 +171,7 @@ describe('CheckboxFilterV2 - states', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
pageSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
@@ -194,7 +194,7 @@ describe('CheckboxFilterV2 - states', () => {
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
pageSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -8,6 +8,7 @@ describe('itemRules', () => {
|
||||
isInRelatedValues: true,
|
||||
isNotInOperator: false,
|
||||
hasExistingQuery: false,
|
||||
isRelatedValuesSupported: true,
|
||||
hasFilterForThisKey: false,
|
||||
};
|
||||
|
||||
@@ -23,6 +24,7 @@ describe('itemRules', () => {
|
||||
isInRelatedValues: true,
|
||||
isNotInOperator: false,
|
||||
hasExistingQuery: true,
|
||||
isRelatedValuesSupported: true,
|
||||
hasFilterForThisKey: true,
|
||||
};
|
||||
|
||||
@@ -38,6 +40,7 @@ describe('itemRules', () => {
|
||||
isInRelatedValues: false,
|
||||
isNotInOperator: true,
|
||||
hasExistingQuery: true,
|
||||
isRelatedValuesSupported: true,
|
||||
hasFilterForThisKey: true,
|
||||
};
|
||||
|
||||
@@ -48,12 +51,46 @@ describe('itemRules', () => {
|
||||
expect(result.checkedState).toBe('unchecked');
|
||||
});
|
||||
|
||||
it('NOT IN filter, value not excluded, not related → all_values, checked', () => {
|
||||
const ctx: ItemContext = {
|
||||
isSelectedOnFilter: false,
|
||||
isInRelatedValues: false,
|
||||
isNotInOperator: true,
|
||||
hasExistingQuery: true,
|
||||
isRelatedValuesSupported: true,
|
||||
hasFilterForThisKey: true,
|
||||
};
|
||||
|
||||
const result = deriveItemConfig(ctx);
|
||||
|
||||
expect(result.section).toBe(SectionType.ALL_VALUES);
|
||||
expect(result.badge).toBeNull();
|
||||
expect(result.checkedState).toBe('checked');
|
||||
});
|
||||
|
||||
it('NOT IN filter, value not excluded but related → related wins, checked', () => {
|
||||
const ctx: ItemContext = {
|
||||
isSelectedOnFilter: false,
|
||||
isInRelatedValues: true,
|
||||
isNotInOperator: true,
|
||||
hasExistingQuery: true,
|
||||
isRelatedValuesSupported: true,
|
||||
hasFilterForThisKey: true,
|
||||
};
|
||||
|
||||
const result = deriveItemConfig(ctx);
|
||||
|
||||
expect(result.section).toBe(SectionType.RELATED);
|
||||
expect(result.checkedState).toBe('checked');
|
||||
});
|
||||
|
||||
it('has query, not selected, in related → section related, checked', () => {
|
||||
const ctx: ItemContext = {
|
||||
isSelectedOnFilter: false,
|
||||
isInRelatedValues: true,
|
||||
isNotInOperator: false,
|
||||
hasExistingQuery: true,
|
||||
isRelatedValuesSupported: true,
|
||||
hasFilterForThisKey: false,
|
||||
};
|
||||
|
||||
@@ -70,6 +107,7 @@ describe('itemRules', () => {
|
||||
isInRelatedValues: true,
|
||||
isNotInOperator: false,
|
||||
hasExistingQuery: true,
|
||||
isRelatedValuesSupported: true,
|
||||
hasFilterForThisKey: true,
|
||||
};
|
||||
|
||||
@@ -86,6 +124,7 @@ describe('itemRules', () => {
|
||||
isInRelatedValues: false,
|
||||
isNotInOperator: false,
|
||||
hasExistingQuery: true,
|
||||
isRelatedValuesSupported: true,
|
||||
hasFilterForThisKey: false,
|
||||
};
|
||||
|
||||
@@ -102,6 +141,7 @@ describe('itemRules', () => {
|
||||
isInRelatedValues: false,
|
||||
isNotInOperator: false,
|
||||
hasExistingQuery: true,
|
||||
isRelatedValuesSupported: true,
|
||||
hasFilterForThisKey: true,
|
||||
};
|
||||
|
||||
@@ -118,6 +158,7 @@ describe('itemRules', () => {
|
||||
isInRelatedValues: false,
|
||||
isNotInOperator: false,
|
||||
hasExistingQuery: false,
|
||||
isRelatedValuesSupported: true,
|
||||
hasFilterForThisKey: true,
|
||||
};
|
||||
|
||||
@@ -128,4 +169,70 @@ describe('itemRules', () => {
|
||||
expect(result.checkedState).toBe('checked');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deriveItemConfig with related values unsupported', () => {
|
||||
const baseCtx: Omit<ItemContext, 'isSelectedOnFilter' | 'isNotInOperator'> = {
|
||||
isInRelatedValues: false,
|
||||
hasExistingQuery: true,
|
||||
hasFilterForThisKey: true,
|
||||
isRelatedValuesSupported: false,
|
||||
};
|
||||
|
||||
it('no filter on this key → selected, checked, even with an existing query', () => {
|
||||
const result = deriveItemConfig({
|
||||
...baseCtx,
|
||||
hasFilterForThisKey: false,
|
||||
isSelectedOnFilter: false,
|
||||
isNotInOperator: false,
|
||||
});
|
||||
|
||||
expect(result.section).toBe(SectionType.SELECTED);
|
||||
expect(result.checkedState).toBe('checked');
|
||||
});
|
||||
|
||||
it('excluded by NOT IN → selected, unchecked', () => {
|
||||
const result = deriveItemConfig({
|
||||
...baseCtx,
|
||||
isSelectedOnFilter: true,
|
||||
isNotInOperator: true,
|
||||
});
|
||||
|
||||
expect(result.section).toBe(SectionType.SELECTED);
|
||||
expect(result.checkedState).toBe('unchecked');
|
||||
});
|
||||
|
||||
it('selected by IN → selected, checked', () => {
|
||||
const result = deriveItemConfig({
|
||||
...baseCtx,
|
||||
isSelectedOnFilter: true,
|
||||
isNotInOperator: false,
|
||||
});
|
||||
|
||||
expect(result.section).toBe(SectionType.SELECTED);
|
||||
expect(result.checkedState).toBe('checked');
|
||||
});
|
||||
|
||||
it('NOT IN complement → all_values, checked, related values ignored', () => {
|
||||
const result = deriveItemConfig({
|
||||
...baseCtx,
|
||||
isSelectedOnFilter: false,
|
||||
isNotInOperator: true,
|
||||
});
|
||||
|
||||
expect(result.section).toBe(SectionType.ALL_VALUES);
|
||||
expect(result.checkedState).toBe('checked');
|
||||
});
|
||||
|
||||
it('IN complement → all_values, unchecked, never related', () => {
|
||||
const result = deriveItemConfig({
|
||||
...baseCtx,
|
||||
isInRelatedValues: true,
|
||||
isSelectedOnFilter: false,
|
||||
isNotInOperator: false,
|
||||
});
|
||||
|
||||
expect(result.section).toBe(SectionType.ALL_VALUES);
|
||||
expect(result.checkedState).toBe('unchecked');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@ describe('useSectionedValues', () => {
|
||||
isSomeFilterPresentForCurrentAttribute: false,
|
||||
isNotInOperator: false,
|
||||
hasExistingQuery: false,
|
||||
isRelatedValuesSupported: true,
|
||||
visibleItemsCount: 10,
|
||||
relatedExclusions: [] as string[],
|
||||
};
|
||||
@@ -26,6 +27,7 @@ describe('useSectionedValues', () => {
|
||||
useSectionedValues({
|
||||
...baseInput,
|
||||
hasExistingQuery: false,
|
||||
isRelatedValuesSupported: true,
|
||||
isSomeFilterPresentForCurrentAttribute: false,
|
||||
}),
|
||||
);
|
||||
@@ -43,6 +45,7 @@ describe('useSectionedValues', () => {
|
||||
useSectionedValues({
|
||||
...baseInput,
|
||||
hasExistingQuery: true,
|
||||
isRelatedValuesSupported: true,
|
||||
isSomeFilterPresentForCurrentAttribute: false,
|
||||
}),
|
||||
);
|
||||
@@ -71,6 +74,7 @@ describe('useSectionedValues', () => {
|
||||
useSectionedValues({
|
||||
...baseInput,
|
||||
hasExistingQuery: true,
|
||||
isRelatedValuesSupported: true,
|
||||
isSomeFilterPresentForCurrentAttribute: true,
|
||||
currentFilterState: { val1: true, val2: false, val3: false },
|
||||
}),
|
||||
@@ -88,6 +92,7 @@ describe('useSectionedValues', () => {
|
||||
useSectionedValues({
|
||||
...baseInput,
|
||||
hasExistingQuery: true,
|
||||
isRelatedValuesSupported: true,
|
||||
isSomeFilterPresentForCurrentAttribute: true,
|
||||
isNotInOperator: true,
|
||||
currentFilterState: { val1: false, val2: true, val3: true },
|
||||
@@ -110,6 +115,7 @@ describe('useSectionedValues', () => {
|
||||
relatedValues: ['zebra', 'apple', 'mango'],
|
||||
allValues: ['zebra', 'apple', 'mango'],
|
||||
hasExistingQuery: false,
|
||||
isRelatedValuesSupported: true,
|
||||
isSomeFilterPresentForCurrentAttribute: false,
|
||||
}),
|
||||
);
|
||||
@@ -126,6 +132,7 @@ describe('useSectionedValues', () => {
|
||||
useSectionedValues({
|
||||
...baseInput,
|
||||
hasExistingQuery: true,
|
||||
isRelatedValuesSupported: true,
|
||||
isSomeFilterPresentForCurrentAttribute: true,
|
||||
currentFilterState: { val1: true },
|
||||
}),
|
||||
@@ -143,6 +150,7 @@ describe('useSectionedValues', () => {
|
||||
relatedValues: [],
|
||||
allValues: [],
|
||||
hasExistingQuery: true,
|
||||
isRelatedValuesSupported: true,
|
||||
isSomeFilterPresentForCurrentAttribute: false,
|
||||
currentFilterState: {},
|
||||
}),
|
||||
@@ -159,6 +167,7 @@ describe('useSectionedValues', () => {
|
||||
relatedValues: [],
|
||||
allValues: ['other1', 'other2', 'other3'],
|
||||
hasExistingQuery: true,
|
||||
isRelatedValuesSupported: true,
|
||||
isSomeFilterPresentForCurrentAttribute: false,
|
||||
}),
|
||||
);
|
||||
@@ -178,6 +187,7 @@ describe('useSectionedValues', () => {
|
||||
relatedValues: ['pod-a-1', 'pod-b-1', 'pod-c-1'],
|
||||
allValues: ['pod-a-2', 'pod-b-2', 'pod-c-2'],
|
||||
hasExistingQuery: true,
|
||||
isRelatedValuesSupported: true,
|
||||
isSomeFilterPresentForCurrentAttribute: false,
|
||||
}),
|
||||
);
|
||||
@@ -218,6 +228,7 @@ describe('useSectionedValues', () => {
|
||||
currentFilterState: { newValue: true },
|
||||
isSomeFilterPresentForCurrentAttribute: true,
|
||||
hasExistingQuery: true,
|
||||
isRelatedValuesSupported: true,
|
||||
// stale API data kept via keepPreviousData
|
||||
relatedValues: ['oldSelected', 'otherRelated'],
|
||||
allValues: ['newValue'],
|
||||
@@ -246,6 +257,7 @@ describe('useSectionedValues', () => {
|
||||
currentFilterState: { newValue: true },
|
||||
isSomeFilterPresentForCurrentAttribute: true,
|
||||
hasExistingQuery: true,
|
||||
isRelatedValuesSupported: true,
|
||||
// oldSelected was just de-selected; the rest are genuinely related
|
||||
relatedValues: ['oldSelected', 'relatedA', 'relatedB', 'relatedC'],
|
||||
allValues: ['newValue'],
|
||||
@@ -275,6 +287,7 @@ describe('useSectionedValues', () => {
|
||||
currentFilterState: { newValue: true },
|
||||
isSomeFilterPresentForCurrentAttribute: true,
|
||||
hasExistingQuery: true,
|
||||
isRelatedValuesSupported: true,
|
||||
relatedValues: ['oldSelected', 'otherRelated'],
|
||||
allValues: ['newValue'],
|
||||
relatedExclusions: ['oldSelected'],
|
||||
@@ -293,6 +306,7 @@ describe('useSectionedValues', () => {
|
||||
currentFilterState: { newValue: true },
|
||||
isSomeFilterPresentForCurrentAttribute: true,
|
||||
hasExistingQuery: true,
|
||||
isRelatedValuesSupported: true,
|
||||
relatedValues: ['oldSelected', 'otherRelated'],
|
||||
allValues: ['newValue'],
|
||||
relatedExclusions: [],
|
||||
@@ -314,6 +328,7 @@ describe('useSectionedValues', () => {
|
||||
relatedValues: ['related1'],
|
||||
allValues: ['all1'],
|
||||
hasExistingQuery: true,
|
||||
isRelatedValuesSupported: true,
|
||||
isSomeFilterPresentForCurrentAttribute: true,
|
||||
currentFilterState: { selected1: true },
|
||||
}),
|
||||
@@ -337,6 +352,7 @@ describe('useSectionedValues', () => {
|
||||
relatedValues: ['r1', 'r2', 'r3', 'r4', 'r5'],
|
||||
allValues: ['a1', 'a2', 'a3', 'a4', 'a5'],
|
||||
hasExistingQuery: true,
|
||||
isRelatedValuesSupported: true,
|
||||
isSomeFilterPresentForCurrentAttribute: false,
|
||||
visibleItemsCount: 100,
|
||||
}),
|
||||
@@ -355,6 +371,7 @@ describe('useSectionedValues', () => {
|
||||
relatedValues: ['r1', 'r2', 'r3'],
|
||||
allValues: ['a1', 'a2', 'a3'],
|
||||
hasExistingQuery: true,
|
||||
isRelatedValuesSupported: true,
|
||||
isSomeFilterPresentForCurrentAttribute: false,
|
||||
visibleItemsCount: 4,
|
||||
}),
|
||||
|
||||
@@ -24,6 +24,7 @@ export interface ItemContext {
|
||||
isNotInOperator: boolean;
|
||||
hasExistingQuery: boolean;
|
||||
hasFilterForThisKey: boolean;
|
||||
isRelatedValuesSupported: boolean;
|
||||
}
|
||||
|
||||
export interface DerivedItem extends ItemConfig {
|
||||
@@ -35,7 +36,7 @@ interface ItemRule {
|
||||
config: ItemConfig;
|
||||
}
|
||||
|
||||
const ITEM_RULES: ItemRule[] = [
|
||||
const RELATED_SUPPORTED_RULES: ItemRule[] = [
|
||||
// No existing query and no filter → all checked (selected section)
|
||||
{
|
||||
condition: (ctx): boolean =>
|
||||
@@ -73,6 +74,16 @@ const ITEM_RULES: ItemRule[] = [
|
||||
checkedState: 'checked',
|
||||
},
|
||||
},
|
||||
// filterKey present in query with NOT IN and value not in the list → checked
|
||||
{
|
||||
condition: (ctx): boolean =>
|
||||
ctx.hasFilterForThisKey && ctx.isNotInOperator && !ctx.isSelectedOnFilter,
|
||||
config: {
|
||||
section: SectionType.ALL_VALUES,
|
||||
badge: null,
|
||||
checkedState: 'checked',
|
||||
},
|
||||
},
|
||||
// All values (has existing query but not related) → unchecked
|
||||
{
|
||||
condition: (ctx): boolean => ctx.hasExistingQuery,
|
||||
@@ -84,6 +95,54 @@ const ITEM_RULES: ItemRule[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const RELATED_UNSUPPORTED_RULES: ItemRule[] = [
|
||||
// No filter on this key → included by default
|
||||
{
|
||||
condition: (ctx): boolean => !ctx.hasFilterForThisKey,
|
||||
config: {
|
||||
section: SectionType.SELECTED,
|
||||
badge: null,
|
||||
checkedState: 'checked',
|
||||
},
|
||||
},
|
||||
// Explicitly excluded by NOT IN
|
||||
{
|
||||
condition: (ctx): boolean => ctx.isSelectedOnFilter && ctx.isNotInOperator,
|
||||
config: {
|
||||
section: SectionType.SELECTED,
|
||||
badge: null,
|
||||
checkedState: 'unchecked',
|
||||
},
|
||||
},
|
||||
// Explicitly selected by IN
|
||||
{
|
||||
condition: (ctx): boolean => ctx.isSelectedOnFilter && !ctx.isNotInOperator,
|
||||
config: {
|
||||
section: SectionType.SELECTED,
|
||||
badge: null,
|
||||
checkedState: 'checked',
|
||||
},
|
||||
},
|
||||
// Not listed in the key's NOT IN clause → not excluded, still in results
|
||||
{
|
||||
condition: (ctx): boolean => ctx.isNotInOperator,
|
||||
config: {
|
||||
section: SectionType.ALL_VALUES,
|
||||
badge: null,
|
||||
checkedState: 'checked',
|
||||
},
|
||||
},
|
||||
// Not listed in the key's IN clause → filtered out of results
|
||||
{
|
||||
condition: (): boolean => true,
|
||||
config: {
|
||||
section: SectionType.ALL_VALUES,
|
||||
badge: null,
|
||||
checkedState: 'unchecked',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Fallback when no rule matches
|
||||
const DEFAULT_CONFIG: ItemConfig = {
|
||||
section: SectionType.SELECTED,
|
||||
@@ -92,7 +151,10 @@ const DEFAULT_CONFIG: ItemConfig = {
|
||||
};
|
||||
|
||||
export function deriveItemConfig(ctx: ItemContext): ItemConfig {
|
||||
for (const rule of ITEM_RULES) {
|
||||
const rules = ctx.isRelatedValuesSupported
|
||||
? RELATED_SUPPORTED_RULES
|
||||
: RELATED_UNSUPPORTED_RULES;
|
||||
for (const rule of rules) {
|
||||
if (rule.condition(ctx)) {
|
||||
return rule.config;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ export function useExistingQuery({
|
||||
useFieldApis,
|
||||
activeQueryIndex,
|
||||
}: UseExistingQueryParams): UseExistingQueryResult {
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
const { stagedQuery } = useQueryBuilder();
|
||||
|
||||
const existingQuery = useMemo(() => {
|
||||
if (useFieldApis.existingQuery === null) {
|
||||
@@ -28,7 +28,7 @@ export function useExistingQuery({
|
||||
return useFieldApis.existingQuery;
|
||||
}
|
||||
|
||||
const queryData = currentQuery.builder.queryData?.[activeQueryIndex];
|
||||
const queryData = stagedQuery?.builder.queryData?.[activeQueryIndex];
|
||||
|
||||
// Prefer V5 filter.expression
|
||||
if (queryData?.filter?.expression) {
|
||||
@@ -43,7 +43,7 @@ export function useExistingQuery({
|
||||
return undefined;
|
||||
}, [
|
||||
useFieldApis.existingQuery,
|
||||
currentQuery.builder.queryData,
|
||||
stagedQuery?.builder.queryData,
|
||||
activeQueryIndex,
|
||||
]);
|
||||
|
||||
@@ -51,11 +51,11 @@ export function useExistingQuery({
|
||||
// This is separate from existingQuery because existingQuery can be explicitly
|
||||
// disabled (null) while filters still exist in the query for UI purposes
|
||||
const hasExistingQuery = useMemo(() => {
|
||||
const queryData = currentQuery.builder.queryData?.[activeQueryIndex];
|
||||
const queryData = stagedQuery?.builder.queryData?.[activeQueryIndex];
|
||||
const hasV3Items = (queryData?.filters?.items?.length ?? 0) > 0;
|
||||
const hasV5Expression = !!queryData?.filter?.expression;
|
||||
return hasV3Items || hasV5Expression || !!existingQuery;
|
||||
}, [currentQuery.builder.queryData, activeQueryIndex, existingQuery]);
|
||||
}, [stagedQuery?.builder.queryData, activeQueryIndex, existingQuery]);
|
||||
|
||||
return { existingQuery, hasExistingQuery };
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useGetFieldsValues } from 'api/generated/services/fields';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
TelemetrytypesSignalDTO,
|
||||
TelemetrytypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { IQuickFiltersConfig } from 'components/QuickFilters/types';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { FIELD_API_CACHE_TIME } from 'constants/queryCacheTime';
|
||||
|
||||
interface UseFieldValuesProps {
|
||||
@@ -10,6 +12,8 @@ interface UseFieldValuesProps {
|
||||
searchText: string;
|
||||
existingQuery?: string;
|
||||
metricNamespace?: string;
|
||||
signal?: TelemetrytypesSignalDTO;
|
||||
source?: TelemetrytypesSourceDTO;
|
||||
startUnixMilli?: number;
|
||||
endUnixMilli?: number;
|
||||
enabled: boolean;
|
||||
@@ -22,30 +26,25 @@ interface UseFieldValuesReturn {
|
||||
isFetching: boolean;
|
||||
}
|
||||
|
||||
const DATA_SOURCE_TO_SIGNAL: Record<DataSource, TelemetrytypesSignalDTO> = {
|
||||
[DataSource.METRICS]: TelemetrytypesSignalDTO.metrics,
|
||||
[DataSource.TRACES]: TelemetrytypesSignalDTO.traces,
|
||||
[DataSource.LOGS]: TelemetrytypesSignalDTO.logs,
|
||||
};
|
||||
|
||||
export function useFieldValues({
|
||||
filter,
|
||||
searchText,
|
||||
existingQuery,
|
||||
metricNamespace,
|
||||
signal,
|
||||
source,
|
||||
startUnixMilli,
|
||||
endUnixMilli,
|
||||
enabled,
|
||||
}: UseFieldValuesProps): UseFieldValuesReturn {
|
||||
const { data, isLoading, isFetching } = useGetFieldsValues(
|
||||
{
|
||||
signal: filter.dataSource
|
||||
? DATA_SOURCE_TO_SIGNAL[filter.dataSource]
|
||||
: undefined,
|
||||
signal,
|
||||
name: filter.attributeKey.key,
|
||||
searchText,
|
||||
existingQuery,
|
||||
metricNamespace,
|
||||
source,
|
||||
startUnixMilli,
|
||||
// This field does not affect the backend but I wanted to keep it here
|
||||
// in case we add the support in the future
|
||||
@@ -89,8 +88,12 @@ export function useFieldValues({
|
||||
values.numberValues
|
||||
?.filter((value): value is number => value !== null && value !== undefined)
|
||||
.map((value) => value.toString()) || [];
|
||||
const boolValues =
|
||||
values.boolValues
|
||||
?.filter((value): value is boolean => value !== null && value !== undefined)
|
||||
.map((value) => value.toString()) || [];
|
||||
|
||||
return [...stringValues, ...numberValues];
|
||||
return [...stringValues, ...numberValues, ...boolValues];
|
||||
}, [data]);
|
||||
|
||||
return { relatedValues, allValues, isLoading, isFetching };
|
||||
|
||||
@@ -10,6 +10,7 @@ interface SectionedValuesInput {
|
||||
isSomeFilterPresentForCurrentAttribute: boolean;
|
||||
isNotInOperator: boolean;
|
||||
hasExistingQuery: boolean;
|
||||
isRelatedValuesSupported: boolean;
|
||||
visibleItemsCount: number;
|
||||
relatedExclusions: string[];
|
||||
}
|
||||
@@ -65,6 +66,7 @@ export function useSectionedValues({
|
||||
isSomeFilterPresentForCurrentAttribute,
|
||||
isNotInOperator,
|
||||
hasExistingQuery,
|
||||
isRelatedValuesSupported,
|
||||
visibleItemsCount,
|
||||
relatedExclusions,
|
||||
}: SectionedValuesInput): SectionedValuesOutput {
|
||||
@@ -95,6 +97,7 @@ export function useSectionedValues({
|
||||
isNotInOperator,
|
||||
hasExistingQuery,
|
||||
hasFilterForThisKey: isSomeFilterPresentForCurrentAttribute,
|
||||
isRelatedValuesSupported,
|
||||
});
|
||||
}, [
|
||||
relatedValues,
|
||||
@@ -103,6 +106,7 @@ export function useSectionedValues({
|
||||
isSomeFilterPresentForCurrentAttribute,
|
||||
isNotInOperator,
|
||||
hasExistingQuery,
|
||||
isRelatedValuesSupported,
|
||||
relatedExclusions,
|
||||
]);
|
||||
|
||||
|
||||
@@ -47,10 +47,10 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
|
||||
className,
|
||||
config,
|
||||
handleFilterVisibilityChange,
|
||||
source,
|
||||
pageSource,
|
||||
onFilterChange,
|
||||
onQuickFilterChange,
|
||||
signal,
|
||||
quickFilterSignal,
|
||||
showFilterCollapse = true,
|
||||
showQueryName = true,
|
||||
useFieldApis,
|
||||
@@ -67,7 +67,7 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
|
||||
customFilters,
|
||||
refetchCustomFilters,
|
||||
isCustomFiltersLoading,
|
||||
} = useFilterConfig({ signal, config });
|
||||
} = useFilterConfig({ signal: quickFilterSignal, config });
|
||||
|
||||
const {
|
||||
currentQuery,
|
||||
@@ -105,7 +105,7 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
|
||||
|
||||
// Show dropdown in ListView only for TRACES_EXPLORER source
|
||||
const shouldShowDropdownInListView =
|
||||
isListView && source === QuickFiltersSource.TRACES_EXPLORER;
|
||||
isListView && pageSource === QuickFiltersSource.TRACES_EXPLORER;
|
||||
|
||||
const showAnnouncementTooltip = useMemo(() => {
|
||||
const localStorageValue = getLocalStorageKey(
|
||||
@@ -119,12 +119,12 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
|
||||
|
||||
const activeQueryIndex = useMemo(() => {
|
||||
if (isListView) {
|
||||
return source === QuickFiltersSource.TRACES_EXPLORER
|
||||
return pageSource === QuickFiltersSource.TRACES_EXPLORER
|
||||
? lastUsedQuery || 0
|
||||
: 0;
|
||||
}
|
||||
return lastUsedQuery || 0;
|
||||
}, [isListView, source, lastUsedQuery]);
|
||||
}, [isListView, pageSource, lastUsedQuery]);
|
||||
|
||||
// clear all the filters for the query which is in sync with filters
|
||||
const handleReset = (): void => {
|
||||
@@ -281,7 +281,7 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
|
||||
|
||||
const renderContent = (): JSX.Element => (
|
||||
<>
|
||||
{source === QuickFiltersSource.API_MONITORING && (
|
||||
{pageSource === QuickFiltersSource.API_MONITORING && (
|
||||
<div className="api-quick-filters-header">
|
||||
<Typography.Text>Show IP addresses</Typography.Text>
|
||||
<Switch
|
||||
@@ -303,7 +303,7 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
|
||||
return useFieldApis ? (
|
||||
<CheckboxV2
|
||||
key={filter.attributeKey.key}
|
||||
source={source}
|
||||
pageSource={pageSource}
|
||||
filter={filter}
|
||||
onFilterChange={onFilterChange}
|
||||
onQuickFilterChange={onQuickFilterChange}
|
||||
@@ -312,7 +312,7 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
|
||||
) : (
|
||||
<Checkbox
|
||||
key={filter.attributeKey.key}
|
||||
source={source}
|
||||
pageSource={pageSource}
|
||||
filter={filter}
|
||||
onFilterChange={onFilterChange}
|
||||
onQuickFilterChange={onQuickFilterChange}
|
||||
@@ -333,7 +333,7 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
|
||||
return useFieldApis ? (
|
||||
<CheckboxV2
|
||||
key={filter.attributeKey.key}
|
||||
source={source}
|
||||
pageSource={pageSource}
|
||||
filter={filter}
|
||||
onFilterChange={onFilterChange}
|
||||
onQuickFilterChange={onQuickFilterChange}
|
||||
@@ -342,7 +342,7 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
|
||||
) : (
|
||||
<Checkbox
|
||||
key={filter.attributeKey.key}
|
||||
source={source}
|
||||
pageSource={pageSource}
|
||||
filter={filter}
|
||||
onFilterChange={onFilterChange}
|
||||
onQuickFilterChange={onQuickFilterChange}
|
||||
@@ -364,7 +364,7 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
|
||||
return (
|
||||
<div className="quick-filters-container">
|
||||
<div className="quick-filters">
|
||||
{source !== QuickFiltersSource.INFRA_MONITORING && (
|
||||
{pageSource !== QuickFiltersSource.INFRA_MONITORING && (
|
||||
<section className="header">
|
||||
{renderLeftActions()}
|
||||
{renderRightActions()}
|
||||
@@ -394,7 +394,7 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
|
||||
>
|
||||
{isSettingsOpen && (
|
||||
<QuickFiltersSettings
|
||||
signal={signal}
|
||||
signal={quickFilterSignal}
|
||||
setIsSettingsOpen={setIsSettingsOpen}
|
||||
customFilters={customFilters}
|
||||
refetchCustomFilters={refetchCustomFilters}
|
||||
@@ -408,7 +408,7 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
|
||||
|
||||
QuickFilters.defaultProps = {
|
||||
onFilterChange: null,
|
||||
signal: '',
|
||||
quickFilterSignal: '',
|
||||
config: [],
|
||||
showFilterCollapse: true,
|
||||
showQueryName: true,
|
||||
|
||||
@@ -17,7 +17,7 @@ import { CSS } from '@dnd-kit/utilities';
|
||||
import { Button } from 'antd';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import { GripVertical } from '@signozhq/icons';
|
||||
import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
|
||||
function SortableFilter({
|
||||
filter,
|
||||
@@ -25,13 +25,13 @@ function SortableFilter({
|
||||
allowDrag,
|
||||
allowRemove,
|
||||
}: {
|
||||
filter: FilterType;
|
||||
onRemove: (filter: FilterType) => void;
|
||||
filter: TelemetryFieldKey;
|
||||
onRemove: (filter: TelemetryFieldKey) => void;
|
||||
allowDrag: boolean;
|
||||
allowRemove: boolean;
|
||||
}): JSX.Element {
|
||||
const { attributes, listeners, setNodeRef, transform, transition } =
|
||||
useSortable({ id: filter.key });
|
||||
useSortable({ id: filter.key as string });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
@@ -46,14 +46,14 @@ function SortableFilter({
|
||||
>
|
||||
<div {...attributes} {...listeners} className="drag-handle">
|
||||
{allowDrag && <GripVertical size={16} />}
|
||||
{filter.key}
|
||||
{filter.name}
|
||||
</div>
|
||||
{allowRemove && (
|
||||
<Button
|
||||
className="remove-filter-btn periscope-btn"
|
||||
size="small"
|
||||
onClick={(): void => {
|
||||
onRemove(filter as FilterType);
|
||||
onRemove(filter);
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
@@ -69,8 +69,8 @@ function AddedFilters({
|
||||
setAddedFilters,
|
||||
}: {
|
||||
inputValue: string;
|
||||
addedFilters: FilterType[];
|
||||
setAddedFilters: React.Dispatch<React.SetStateAction<FilterType[]>>;
|
||||
addedFilters: TelemetryFieldKey[];
|
||||
setAddedFilters: React.Dispatch<React.SetStateAction<TelemetryFieldKey[]>>;
|
||||
}): JSX.Element {
|
||||
const sensors = useSensors(useSensor(PointerSensor));
|
||||
|
||||
@@ -90,12 +90,12 @@ function AddedFilters({
|
||||
const filteredAddedFilters = useMemo(
|
||||
() =>
|
||||
addedFilters.filter((filter) =>
|
||||
filter.key.toLowerCase().includes(inputValue.toLowerCase()),
|
||||
filter.name.toLowerCase().includes(inputValue.toLowerCase()),
|
||||
),
|
||||
[addedFilters, inputValue],
|
||||
);
|
||||
|
||||
const handleRemoveFilter = (filter: FilterType): void => {
|
||||
const handleRemoveFilter = (filter: TelemetryFieldKey): void => {
|
||||
setAddedFilters((prev) => prev.filter((f) => f.key !== filter.key));
|
||||
};
|
||||
|
||||
@@ -116,7 +116,7 @@ function AddedFilters({
|
||||
<div className="no-values-found">No values found</div>
|
||||
) : (
|
||||
<SortableContext
|
||||
items={addedFilters.map((f) => f.key)}
|
||||
items={addedFilters.map((f) => f.key as string)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
disabled={!allowDrag}
|
||||
>
|
||||
|
||||
@@ -1,17 +1,26 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Button, Skeleton } from 'antd';
|
||||
import { useGetFieldsKeys } from 'api/generated/services/fields';
|
||||
import {
|
||||
TelemetrytypesSignalDTO,
|
||||
TelemetrytypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import { SIGNAL_DATA_SOURCE_MAP } from 'components/QuickFilters/QuickFiltersSettings/constants';
|
||||
import { SignalType } from 'components/QuickFilters/types';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
|
||||
import { useGetAttributeSuggestions } from 'hooks/queryBuilder/useGetAttributeSuggestions';
|
||||
import { useGetQueryKeySuggestions } from 'hooks/querySuggestions/useGetQueryKeySuggestions';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { QueryKeyDataSuggestionsProps } from 'types/api/querySuggestions/types';
|
||||
import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { buildCompositeKey } from 'container/OptionsMenu/utils';
|
||||
import {
|
||||
FieldContext,
|
||||
FieldDataType,
|
||||
TelemetryFieldKey,
|
||||
} from 'types/api/v5/queryRange';
|
||||
|
||||
const SIGNAL_TYPE_TO_SIGNAL: Record<SignalType, TelemetrytypesSignalDTO> = {
|
||||
[SignalType.LOGS]: TelemetrytypesSignalDTO.logs,
|
||||
[SignalType.TRACES]: TelemetrytypesSignalDTO.traces,
|
||||
[SignalType.EXCEPTIONS]: TelemetrytypesSignalDTO.traces,
|
||||
[SignalType.API_MONITORING]: TelemetrytypesSignalDTO.traces,
|
||||
[SignalType.METER_EXPLORER]: TelemetrytypesSignalDTO.metrics,
|
||||
};
|
||||
|
||||
function OtherFiltersSkeleton(): JSX.Element {
|
||||
return (
|
||||
@@ -37,106 +46,46 @@ function OtherFilters({
|
||||
}: {
|
||||
signal: SignalType | undefined;
|
||||
inputValue: string;
|
||||
addedFilters: FilterType[];
|
||||
setAddedFilters: React.Dispatch<React.SetStateAction<FilterType[]>>;
|
||||
addedFilters: TelemetryFieldKey[];
|
||||
setAddedFilters: React.Dispatch<React.SetStateAction<TelemetryFieldKey[]>>;
|
||||
}): JSX.Element {
|
||||
const isLogDataSource = useMemo(
|
||||
() => SIGNAL_DATA_SOURCE_MAP[signal as SignalType] === DataSource.LOGS,
|
||||
[signal],
|
||||
);
|
||||
const isMeterDataSource = useMemo(
|
||||
() => signal && signal === SignalType.METER_EXPLORER,
|
||||
[signal],
|
||||
const isMeterDataSource = signal === SignalType.METER_EXPLORER;
|
||||
|
||||
const { data, isFetching } = useGetFieldsKeys(
|
||||
{
|
||||
searchText: inputValue,
|
||||
signal: signal ? SIGNAL_TYPE_TO_SIGNAL[signal] : undefined,
|
||||
source: isMeterDataSource ? TelemetrytypesSourceDTO.meter : undefined,
|
||||
},
|
||||
{ query: { enabled: !!signal } },
|
||||
);
|
||||
|
||||
const { data: suggestionsData, isFetching: isFetchingSuggestions } =
|
||||
useGetAttributeSuggestions(
|
||||
{
|
||||
searchText: inputValue,
|
||||
dataSource: SIGNAL_DATA_SOURCE_MAP[signal as SignalType],
|
||||
filters: {} as TagFilter,
|
||||
},
|
||||
{
|
||||
queryKey: [REACT_QUERY_KEY.GET_OTHER_FILTERS, inputValue],
|
||||
enabled: !!signal && isLogDataSource,
|
||||
},
|
||||
);
|
||||
const otherFilters = useMemo<TelemetryFieldKey[]>(() => {
|
||||
const rawSuggestions = Object.values(data?.data?.keys ?? {}).flat();
|
||||
// Normalize: synthesize the composite `key` once so downstream reads (dedupe,
|
||||
// add, render) can trust it.
|
||||
const suggestions: TelemetryFieldKey[] = rawSuggestions.map((attr) => ({
|
||||
name: attr.name,
|
||||
signal: attr.signal as TelemetryFieldKey['signal'],
|
||||
fieldContext: attr.fieldContext as FieldContext,
|
||||
fieldDataType: attr.fieldDataType as FieldDataType,
|
||||
key: buildCompositeKey(attr.name, attr.fieldContext, attr.fieldDataType),
|
||||
}));
|
||||
|
||||
const { data: aggregateKeysData, isFetching: isFetchingAggregateKeys } =
|
||||
useGetAggregateKeys(
|
||||
{
|
||||
searchText: inputValue,
|
||||
dataSource: SIGNAL_DATA_SOURCE_MAP[signal as SignalType],
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: '',
|
||||
tagType: '',
|
||||
},
|
||||
{
|
||||
queryKey: [REACT_QUERY_KEY.GET_OTHER_FILTERS, inputValue],
|
||||
enabled: !!signal && !isLogDataSource && !isMeterDataSource,
|
||||
},
|
||||
const addedKeys = new Set(
|
||||
addedFilters.map((filter) =>
|
||||
buildCompositeKey(filter.name, filter.fieldContext, filter.fieldDataType),
|
||||
),
|
||||
);
|
||||
return suggestions.filter((attr) => !addedKeys.has(attr.key as string));
|
||||
}, [data, addedFilters]);
|
||||
|
||||
const { data: fieldKeysData, isLoading: isLoadingFieldKeys } =
|
||||
useGetQueryKeySuggestions(
|
||||
{
|
||||
searchText: inputValue,
|
||||
signal: SIGNAL_DATA_SOURCE_MAP[signal as SignalType],
|
||||
signalSource: 'meter',
|
||||
},
|
||||
{
|
||||
queryKey: [REACT_QUERY_KEY.GET_OTHER_FILTERS, inputValue],
|
||||
enabled: !!signal && isMeterDataSource,
|
||||
},
|
||||
);
|
||||
|
||||
const otherFilters = useMemo(() => {
|
||||
let filterAttributes;
|
||||
if (isLogDataSource) {
|
||||
filterAttributes = suggestionsData?.payload?.attributes || [];
|
||||
} else if (isMeterDataSource) {
|
||||
const fieldKeys: QueryKeyDataSuggestionsProps[] = Object.values(
|
||||
fieldKeysData?.data?.data?.keys || {},
|
||||
)?.flat();
|
||||
filterAttributes = fieldKeys.map(
|
||||
(attr) =>
|
||||
({
|
||||
key: attr.name,
|
||||
dataType: attr.fieldDataType,
|
||||
type: attr.fieldContext,
|
||||
signal: attr.signal,
|
||||
}) as BaseAutocompleteData,
|
||||
);
|
||||
} else {
|
||||
filterAttributes = aggregateKeysData?.payload?.attributeKeys || [];
|
||||
}
|
||||
return filterAttributes?.filter(
|
||||
(attr) => !addedFilters.some((filter) => filter.key === attr.key),
|
||||
);
|
||||
}, [
|
||||
suggestionsData,
|
||||
aggregateKeysData,
|
||||
addedFilters,
|
||||
isLogDataSource,
|
||||
fieldKeysData,
|
||||
isMeterDataSource,
|
||||
]);
|
||||
|
||||
const handleAddFilter = (filter: FilterType): void => {
|
||||
setAddedFilters((prev) => [
|
||||
...prev,
|
||||
{
|
||||
key: filter.key,
|
||||
dataType: filter.dataType,
|
||||
type: filter.type,
|
||||
},
|
||||
]);
|
||||
const handleAddFilter = (filter: TelemetryFieldKey): void => {
|
||||
setAddedFilters((prev) => [...prev, filter]);
|
||||
};
|
||||
|
||||
const renderFilters = (): React.ReactNode => {
|
||||
const isLoading =
|
||||
isFetchingSuggestions || isFetchingAggregateKeys || isLoadingFieldKeys;
|
||||
if (isLoading) {
|
||||
if (isFetching) {
|
||||
return <OtherFiltersSkeleton />;
|
||||
}
|
||||
if (!otherFilters?.length) {
|
||||
@@ -145,11 +94,11 @@ function OtherFilters({
|
||||
|
||||
return otherFilters.map((filter) => (
|
||||
<div key={filter.key} className="qf-filter-item other-filters-item">
|
||||
<div className="qf-filter-key">{filter.key}</div>
|
||||
<div className="qf-filter-key">{filter.name}</div>
|
||||
<Button
|
||||
className="add-filter-btn periscope-btn"
|
||||
size="small"
|
||||
onClick={(): void => handleAddFilter(filter as FilterType)}
|
||||
onClick={(): void => handleAddFilter(filter)}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Button } from 'antd';
|
||||
import { Check, TableColumnsSplit, X } from '@signozhq/icons';
|
||||
import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
|
||||
import { SignalType } from '../types';
|
||||
import AddedFilters from './AddedFilters';
|
||||
@@ -19,7 +18,7 @@ function QuickFiltersSettings({
|
||||
}: {
|
||||
signal: SignalType | undefined;
|
||||
setIsSettingsOpen: (isSettingsOpen: boolean) => void;
|
||||
customFilters: FilterType[];
|
||||
customFilters: TelemetryFieldKey[];
|
||||
refetchCustomFilters: () => void;
|
||||
}): JSX.Element {
|
||||
const {
|
||||
@@ -28,6 +27,7 @@ function QuickFiltersSettings({
|
||||
addedFilters,
|
||||
setAddedFilters,
|
||||
handleSaveChanges,
|
||||
hasUnsavedChanges,
|
||||
isUpdatingCustomFilters,
|
||||
inputValue,
|
||||
handleInputChange,
|
||||
@@ -39,18 +39,6 @@ function QuickFiltersSettings({
|
||||
signal,
|
||||
});
|
||||
|
||||
const hasUnsavedChanges = useMemo(
|
||||
() =>
|
||||
// check if both arrays have the same length and same order of elements
|
||||
!(
|
||||
addedFilters.length === customFilters.length &&
|
||||
addedFilters.every(
|
||||
(filter, index) => filter.key === customFilters[index].key,
|
||||
)
|
||||
),
|
||||
[addedFilters, customFilters],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="qf-header">
|
||||
|
||||
@@ -1,27 +1,31 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useMutation } from 'react-query';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useUpdateQuickFilters } from 'api/generated/services/quick-filter';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import updateCustomFiltersAPI from 'api/quickFilters/updateCustomFilters';
|
||||
import axios, { AxiosError } from 'axios';
|
||||
import {
|
||||
TelemetrytypesFieldContextDTO,
|
||||
TelemetrytypesFieldDataTypeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { SignalType } from 'components/QuickFilters/types';
|
||||
import { SOMETHING_WENT_WRONG } from 'constants/api';
|
||||
import { buildCompositeKey } from 'container/OptionsMenu/utils';
|
||||
import useDebouncedFn from 'hooks/useDebouncedFunction';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
|
||||
interface UseQuickFilterSettingsProps {
|
||||
setIsSettingsOpen: (isSettingsOpen: boolean) => void;
|
||||
customFilters: FilterType[];
|
||||
customFilters: TelemetryFieldKey[];
|
||||
refetchCustomFilters: () => void;
|
||||
signal?: SignalType;
|
||||
}
|
||||
|
||||
interface UseQuickFilterSettingsReturn {
|
||||
addedFilters: FilterType[];
|
||||
setAddedFilters: React.Dispatch<React.SetStateAction<FilterType[]>>;
|
||||
addedFilters: TelemetryFieldKey[];
|
||||
setAddedFilters: React.Dispatch<React.SetStateAction<TelemetryFieldKey[]>>;
|
||||
handleSettingsClose: () => void;
|
||||
handleDiscardChanges: () => void;
|
||||
handleSaveChanges: () => void;
|
||||
hasUnsavedChanges: boolean;
|
||||
isUpdatingCustomFilters: boolean;
|
||||
inputValue: string;
|
||||
setInputValue: React.Dispatch<React.SetStateAction<string>>;
|
||||
@@ -37,27 +41,43 @@ const useQuickFilterSettings = ({
|
||||
}: UseQuickFilterSettingsProps): UseQuickFilterSettingsReturn => {
|
||||
const [inputValue, setInputValue] = useState<string>('');
|
||||
const [debouncedInputValue, setDebouncedInputValue] = useState<string>('');
|
||||
const [addedFilters, setAddedFilters] = useState<FilterType[]>(customFilters);
|
||||
const normalizedCustomFilters = useMemo<TelemetryFieldKey[]>(
|
||||
() =>
|
||||
customFilters.map((filter) => ({
|
||||
...filter,
|
||||
key: buildCompositeKey(
|
||||
filter.name,
|
||||
filter.fieldContext,
|
||||
filter.fieldDataType,
|
||||
),
|
||||
})),
|
||||
[customFilters],
|
||||
);
|
||||
const [addedFilters, setAddedFilters] = useState<TelemetryFieldKey[]>(
|
||||
normalizedCustomFilters,
|
||||
);
|
||||
const { notifications } = useNotifications();
|
||||
|
||||
const { mutate: updateCustomFilters, isLoading: isUpdatingCustomFilters } =
|
||||
useMutation(updateCustomFiltersAPI, {
|
||||
onSuccess: () => {
|
||||
setIsSettingsOpen(false);
|
||||
refetchCustomFilters();
|
||||
logEvent('Quick Filters Settings: changes saved', {
|
||||
addedFilters,
|
||||
});
|
||||
notifications.success({
|
||||
message: 'Quick filters updated successfully',
|
||||
placement: 'bottomRight',
|
||||
});
|
||||
},
|
||||
onError: (error: AxiosError) => {
|
||||
notifications.error({
|
||||
message: axios.isAxiosError(error) ? error.message : SOMETHING_WENT_WRONG,
|
||||
placement: 'bottomRight',
|
||||
});
|
||||
useUpdateQuickFilters({
|
||||
mutation: {
|
||||
onSuccess: () => {
|
||||
setIsSettingsOpen(false);
|
||||
refetchCustomFilters();
|
||||
void logEvent('Quick Filters Settings: changes saved', {
|
||||
addedFilters,
|
||||
});
|
||||
notifications.success({
|
||||
message: 'Quick filters updated successfully',
|
||||
placement: 'bottomRight',
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
notifications.error({
|
||||
message: error.message || SOMETHING_WENT_WRONG,
|
||||
placement: 'bottomRight',
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
const debouncedUpdate = useDebouncedFn((value) => {
|
||||
@@ -78,19 +98,32 @@ const useQuickFilterSettings = ({
|
||||
}, [setIsSettingsOpen]);
|
||||
|
||||
const handleDiscardChanges = useCallback((): void => {
|
||||
setAddedFilters(customFilters);
|
||||
}, [customFilters, setAddedFilters]);
|
||||
setAddedFilters(normalizedCustomFilters);
|
||||
}, [normalizedCustomFilters, setAddedFilters]);
|
||||
|
||||
const hasUnsavedChanges = useMemo(
|
||||
() =>
|
||||
!(
|
||||
addedFilters.length === normalizedCustomFilters.length &&
|
||||
addedFilters.every(
|
||||
(filter, index) => filter.key === normalizedCustomFilters[index].key,
|
||||
)
|
||||
),
|
||||
[addedFilters, normalizedCustomFilters],
|
||||
);
|
||||
|
||||
const handleSaveChanges = useCallback((): void => {
|
||||
if (signal) {
|
||||
updateCustomFilters({
|
||||
pathParams: { source: signal },
|
||||
data: {
|
||||
// Send only the stored TelemetryFieldKey fields; the composite `key`
|
||||
// is UI-only.
|
||||
filters: addedFilters.map((filter) => ({
|
||||
key: filter.key,
|
||||
datatype: filter.dataType,
|
||||
type: filter.type,
|
||||
name: filter.name,
|
||||
fieldContext: filter.fieldContext as TelemetrytypesFieldContextDTO,
|
||||
fieldDataType: filter.fieldDataType as TelemetrytypesFieldDataTypeDTO,
|
||||
})),
|
||||
signal,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -102,6 +135,7 @@ const useQuickFilterSettings = ({
|
||||
addedFilters,
|
||||
setAddedFilters,
|
||||
handleSaveChanges,
|
||||
hasUnsavedChanges,
|
||||
isUpdatingCustomFilters,
|
||||
inputValue,
|
||||
setInputValue,
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import getCustomFilters from 'api/quickFilters/getCustomFilters';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';
|
||||
import { useGetQuickFilters } from 'api/generated/services/quick-filter';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
|
||||
import { IQuickFiltersConfig, SignalType } from '../types';
|
||||
import { getFilterConfig } from '../utils';
|
||||
@@ -13,7 +11,7 @@ interface UseFilterConfigProps {
|
||||
}
|
||||
interface UseFilterConfigReturn {
|
||||
filterConfig: IQuickFiltersConfig[];
|
||||
customFilters: FilterType[];
|
||||
customFilters: TelemetryFieldKey[];
|
||||
isCustomFiltersLoading: boolean;
|
||||
isDynamicFilters: boolean;
|
||||
refetchCustomFilters: () => void;
|
||||
@@ -25,17 +23,16 @@ const useFilterConfig = ({
|
||||
}: UseFilterConfigProps): UseFilterConfigReturn => {
|
||||
const {
|
||||
isFetching: isCustomFiltersLoading,
|
||||
data: customFilters = [],
|
||||
data,
|
||||
refetch,
|
||||
} = useQuery<FilterType[], Error>(
|
||||
[REACT_QUERY_KEY.GET_CUSTOM_FILTERS, signal],
|
||||
async () => {
|
||||
const res = await getCustomFilters({ signal: signal || '' });
|
||||
return 'payload' in res && res.payload?.filters ? res.payload.filters : [];
|
||||
},
|
||||
{
|
||||
enabled: !!signal,
|
||||
},
|
||||
} = useGetQuickFilters(
|
||||
{ source: signal ?? '' },
|
||||
{ query: { enabled: !!signal } },
|
||||
);
|
||||
|
||||
const customFilters = useMemo<TelemetryFieldKey[]>(
|
||||
() => (data?.data?.filters ?? []) as TelemetryFieldKey[],
|
||||
[data],
|
||||
);
|
||||
|
||||
const isDynamicFilters = useMemo(
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useMemo } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import {
|
||||
TelemetrytypesSignalDTO,
|
||||
TelemetrytypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import { QuickFilterCheckboxUseFieldApis } from '../types';
|
||||
|
||||
export function useSignalFieldApis(
|
||||
signal: TelemetrytypesSignalDTO,
|
||||
source?: TelemetrytypesSourceDTO,
|
||||
): QuickFilterCheckboxUseFieldApis {
|
||||
const { minTime, maxTime } = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
signal,
|
||||
source,
|
||||
startUnixMilli: Math.floor(minTime / NANO_SECOND_MULTIPLIER),
|
||||
endUnixMilli: Math.floor(maxTime / NANO_SECOND_MULTIPLIER),
|
||||
existingQuery: null,
|
||||
}),
|
||||
[signal, source, minTime, maxTime],
|
||||
);
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from 'mocks-server/__mockdata__/customQuickFilters';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import { render, screen, userEvent, waitFor, within } from 'tests/test-utils';
|
||||
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
@@ -34,9 +34,9 @@ const mockUseApiMonitoringParams = jest.mocked(useApiMonitoringParams);
|
||||
|
||||
const BASE_URL = ENVIRONMENT.baseURL;
|
||||
const SIGNAL = SignalType.LOGS;
|
||||
const quickFiltersListURL = `${BASE_URL}/api/v1/orgs/me/filters/${SIGNAL}`;
|
||||
const saveQuickFiltersURL = `${BASE_URL}/api/v1/orgs/me/filters`;
|
||||
const quickFiltersSuggestionsURL = `${BASE_URL}/api/v3/filter_suggestions`;
|
||||
const quickFiltersListURL = `${BASE_URL}/api/v2/quick_filters/${SIGNAL}`;
|
||||
const saveQuickFiltersURL = `${BASE_URL}/api/v2/quick_filters/${SIGNAL}`;
|
||||
const quickFiltersSuggestionsURL = `${BASE_URL}/api/v1/fields/keys`;
|
||||
const quickFiltersAttributeValuesURL = `${BASE_URL}/api/v3/autocomplete/attribute_values`;
|
||||
const fieldsValuesURL = `${BASE_URL}/api/v1/fields/values`;
|
||||
|
||||
@@ -72,46 +72,46 @@ const setupServer = (): void => {
|
||||
};
|
||||
|
||||
function TestQuickFilters({
|
||||
signal = SignalType.LOGS,
|
||||
quickFilterSignal = SignalType.LOGS,
|
||||
config = QuickFiltersConfig,
|
||||
}: {
|
||||
signal?: SignalType;
|
||||
quickFilterSignal?: SignalType;
|
||||
config?: IQuickFiltersConfig[];
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<QuickFilters
|
||||
source={QuickFiltersSource.EXCEPTIONS}
|
||||
pageSource={QuickFiltersSource.EXCEPTIONS}
|
||||
config={config}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
signal={signal}
|
||||
quickFilterSignal={quickFilterSignal}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
TestQuickFilters.defaultProps = {
|
||||
signal: '',
|
||||
quickFilterSignal: '',
|
||||
config: QuickFiltersConfig,
|
||||
};
|
||||
|
||||
function TestQuickFiltersApiMonitoring({
|
||||
signal = SignalType.LOGS,
|
||||
quickFilterSignal = SignalType.LOGS,
|
||||
config = QuickFiltersConfig,
|
||||
}: {
|
||||
signal?: SignalType;
|
||||
quickFilterSignal?: SignalType;
|
||||
config?: IQuickFiltersConfig[];
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<QuickFilters
|
||||
source={QuickFiltersSource.API_MONITORING}
|
||||
pageSource={QuickFiltersSource.API_MONITORING}
|
||||
config={config}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
signal={signal}
|
||||
quickFilterSignal={quickFilterSignal}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
TestQuickFiltersApiMonitoring.defaultProps = {
|
||||
signal: '',
|
||||
quickFilterSignal: '',
|
||||
config: QuickFiltersConfig,
|
||||
};
|
||||
|
||||
@@ -310,7 +310,7 @@ describe('Quick Filters with custom filters', () => {
|
||||
it('loads the custom filters correctly', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<TestQuickFilters signal={SIGNAL} />);
|
||||
render(<TestQuickFilters quickFilterSignal={SIGNAL} />);
|
||||
|
||||
expect(screen.getByText('Filters for')).toBeInTheDocument();
|
||||
expect(screen.getByText(QUERY_NAME)).toBeInTheDocument();
|
||||
@@ -338,10 +338,67 @@ describe('Quick Filters with custom filters', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps same-name fields with different context as distinct entries', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
server.use(
|
||||
rest.get(quickFiltersSuggestionsURL, (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
complete: true,
|
||||
keys: {
|
||||
level: [
|
||||
{
|
||||
name: 'level',
|
||||
fieldContext: 'attribute',
|
||||
fieldDataType: 'string',
|
||||
signal: 'logs',
|
||||
},
|
||||
{
|
||||
name: 'level',
|
||||
fieldContext: 'span',
|
||||
fieldDataType: 'string',
|
||||
signal: 'logs',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
render(<TestQuickFilters quickFilterSignal={SIGNAL} />);
|
||||
await screen.findByText(FILTER_SERVICE_NAME);
|
||||
|
||||
const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID);
|
||||
const settingsButton = icon.closest('button') ?? icon;
|
||||
await user.click(settingsButton);
|
||||
|
||||
const otherSection = screen.getByText(OTHER_FILTERS_LABEL).parentElement!;
|
||||
// Both `level` variants are shown despite sharing a name.
|
||||
await waitFor(() =>
|
||||
expect(within(otherSection).getAllByText('level')).toHaveLength(2),
|
||||
);
|
||||
|
||||
// Adding one variant removes only that one; the other stays.
|
||||
const firstLevel = within(otherSection).getAllByText('level')[0];
|
||||
const addButton = firstLevel.parentElement?.querySelector('button');
|
||||
await user.click(addButton as HTMLButtonElement);
|
||||
|
||||
const addedSection = screen.getByText(ADDED_FILTERS_LABEL).parentElement!;
|
||||
await waitFor(() => {
|
||||
expect(within(addedSection).getAllByText('level')).toHaveLength(1);
|
||||
expect(within(otherSection).getAllByText('level')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('adds a filter from OTHER FILTERS to ADDED FILTERS when clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<TestQuickFilters signal={SIGNAL} />);
|
||||
render(<TestQuickFilters quickFilterSignal={SIGNAL} />);
|
||||
await screen.findByText(FILTER_SERVICE_NAME);
|
||||
|
||||
const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID);
|
||||
@@ -362,7 +419,7 @@ describe('Quick Filters with custom filters', () => {
|
||||
it('removes a filter from ADDED FILTERS and moves it to OTHER FILTERS', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<TestQuickFilters signal={SIGNAL} />);
|
||||
render(<TestQuickFilters quickFilterSignal={SIGNAL} />);
|
||||
await screen.findByText(FILTER_SERVICE_NAME);
|
||||
|
||||
const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID);
|
||||
@@ -391,7 +448,7 @@ describe('Quick Filters with custom filters', () => {
|
||||
it('restores original filter state on Discard', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<TestQuickFilters signal={SIGNAL} />);
|
||||
render(<TestQuickFilters quickFilterSignal={SIGNAL} />);
|
||||
await screen.findByText(FILTER_SERVICE_NAME);
|
||||
|
||||
const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID);
|
||||
@@ -433,7 +490,7 @@ describe('Quick Filters with custom filters', () => {
|
||||
it('saves the updated filters by calling PUT with correct payload', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<TestQuickFilters signal={SIGNAL} />);
|
||||
render(<TestQuickFilters quickFilterSignal={SIGNAL} />);
|
||||
await screen.findByText(FILTER_SERVICE_NAME);
|
||||
|
||||
const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID);
|
||||
@@ -456,12 +513,10 @@ describe('Quick Filters with custom filters', () => {
|
||||
});
|
||||
|
||||
const requestBody = putHandler.mock.calls[0][0];
|
||||
expect(requestBody.filters).toStrictEqual(
|
||||
expect.arrayContaining([
|
||||
expect.not.objectContaining({ key: FILTER_OS_DESCRIPTION }),
|
||||
]),
|
||||
expect(requestBody.filters).not.toContainEqual(
|
||||
expect.objectContaining({ name: FILTER_OS_DESCRIPTION }),
|
||||
);
|
||||
expect(requestBody.signal).toBe(SIGNAL);
|
||||
expect(requestBody.filters).toHaveLength(10);
|
||||
});
|
||||
|
||||
it('should render duration slider for duration_nono filter', async () => {
|
||||
@@ -472,7 +527,9 @@ describe('Quick Filters with custom filters', () => {
|
||||
pointerEventsCheck: 0,
|
||||
});
|
||||
|
||||
const { getByTestId } = render(<TestQuickFilters signal={SIGNAL} />);
|
||||
const { getByTestId } = render(
|
||||
<TestQuickFilters quickFilterSignal={SIGNAL} />,
|
||||
);
|
||||
await screen.findByText(FILTER_SERVICE_NAME);
|
||||
expect(screen.getByText('Duration')).toBeInTheDocument();
|
||||
|
||||
@@ -536,14 +593,14 @@ describe('Quick Filters refetch behavior', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const { unmount } = render(<TestQuickFilters signal={SIGNAL} />);
|
||||
const { unmount } = render(<TestQuickFilters quickFilterSignal={SIGNAL} />);
|
||||
await expect(
|
||||
screen.findByText(FILTER_SERVICE_NAME),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
unmount();
|
||||
|
||||
render(<TestQuickFilters signal={SIGNAL} />);
|
||||
render(<TestQuickFilters quickFilterSignal={SIGNAL} />);
|
||||
await expect(
|
||||
screen.findByText(FILTER_SERVICE_NAME),
|
||||
).resolves.toBeInTheDocument();
|
||||
@@ -561,7 +618,7 @@ describe('Quick Filters refetch behavior', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
render(<TestQuickFilters signal={undefined} />);
|
||||
render(<TestQuickFilters quickFilterSignal={undefined} />);
|
||||
|
||||
await waitFor(() => expect(getCalls).toBe(0));
|
||||
});
|
||||
@@ -582,7 +639,7 @@ describe('Quick Filters refetch behavior', () => {
|
||||
);
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<TestQuickFilters signal={SIGNAL} />);
|
||||
render(<TestQuickFilters quickFilterSignal={SIGNAL} />);
|
||||
|
||||
await expect(
|
||||
screen.findByText(FILTER_SERVICE_NAME),
|
||||
@@ -612,9 +669,9 @@ describe('Quick Filters refetch behavior', () => {
|
||||
filters: [
|
||||
...(quickFiltersListResponse.data.filters ?? []),
|
||||
{
|
||||
key: 'new.custom.filter',
|
||||
dataType: 'string',
|
||||
type: 'resource',
|
||||
name: 'new.custom.filter',
|
||||
fieldDataType: 'string',
|
||||
fieldContext: 'resource',
|
||||
} as const,
|
||||
],
|
||||
},
|
||||
@@ -634,7 +691,7 @@ describe('Quick Filters refetch behavior', () => {
|
||||
);
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<TestQuickFilters signal={SIGNAL} />);
|
||||
render(<TestQuickFilters quickFilterSignal={SIGNAL} />);
|
||||
|
||||
await expect(
|
||||
screen.findByText(FILTER_SERVICE_NAME),
|
||||
@@ -665,7 +722,7 @@ describe('Quick Filters refetch behavior', () => {
|
||||
),
|
||||
);
|
||||
|
||||
render(<TestQuickFilters signal={SIGNAL} config={[]} />);
|
||||
render(<TestQuickFilters quickFilterSignal={SIGNAL} config={[]} />);
|
||||
|
||||
await expect(
|
||||
screen.findByText('No filters found'),
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
TelemetrytypesSignalDTO,
|
||||
TelemetrytypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
@@ -51,10 +55,10 @@ export interface QuickFilterChangeEventData {
|
||||
export interface IQuickFiltersProps {
|
||||
config: IQuickFiltersConfig[];
|
||||
handleFilterVisibilityChange: () => void;
|
||||
source: QuickFiltersSource;
|
||||
pageSource: QuickFiltersSource;
|
||||
onFilterChange?: (query: Query) => void;
|
||||
onQuickFilterChange?: (data: QuickFilterChangeEventData) => void;
|
||||
signal?: SignalType;
|
||||
quickFilterSignal?: SignalType;
|
||||
className?: string;
|
||||
showFilterCollapse?: boolean;
|
||||
showQueryName?: boolean;
|
||||
@@ -74,6 +78,9 @@ export enum QuickFiltersSource {
|
||||
* Opt-in: fetch values from the /v1/fields/values API instead of /v3/autocomplete/attribute_values
|
||||
*/
|
||||
export type QuickFilterCheckboxUseFieldApis = {
|
||||
/** Telemetry signal and source sent to the fields APIs, declared by the page. */
|
||||
signal?: TelemetrytypesSignalDTO;
|
||||
source?: TelemetrytypesSourceDTO;
|
||||
startUnixMilli: number;
|
||||
endUnixMilli: number;
|
||||
/**
|
||||
|
||||
@@ -1,17 +1,33 @@
|
||||
import { TelemetrytypesFieldContextDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { SIGNAL_DATA_SOURCE_MAP } from 'components/QuickFilters/QuickFiltersSettings/constants';
|
||||
import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
import { fieldDataTypeToDataType } from 'utils/fieldDataType';
|
||||
|
||||
import { FiltersType, IQuickFiltersConfig, SignalType } from './types';
|
||||
|
||||
const FILTER_TITLE_MAP: Record<string, string> = {
|
||||
duration_nano: 'Duration',
|
||||
hasError: 'Has Error (Status)',
|
||||
has_error: 'Has Error (Status)',
|
||||
};
|
||||
|
||||
const FILTER_TYPE_MAP: Record<string, FiltersType> = {
|
||||
duration_nano: FiltersType.DURATION,
|
||||
};
|
||||
|
||||
// The map below exists only for the old v3 attribute-values fetch
|
||||
// (useCheckboxFilterValues), the sole reader of attributeKey.dataType/type.
|
||||
// Once the values fetch moves to fields/values, remove this and reduce
|
||||
// attributeKey to { id, key }.
|
||||
|
||||
const FIELD_CONTEXT_TO_ATTRIBUTE_TYPE: Record<string, string> = {
|
||||
[TelemetrytypesFieldContextDTO.attribute]: 'tag',
|
||||
[TelemetrytypesFieldContextDTO.resource]: 'resource',
|
||||
};
|
||||
|
||||
const mapFieldContext = (fieldContext?: string): string =>
|
||||
(fieldContext && FIELD_CONTEXT_TO_ATTRIBUTE_TYPE[fieldContext]) || '';
|
||||
|
||||
const getFilterName = (str: string): string => {
|
||||
if (FILTER_TITLE_MAP[str]) {
|
||||
return FILTER_TITLE_MAP[str];
|
||||
@@ -26,16 +42,16 @@ const getFilterName = (str: string): string => {
|
||||
.join(' ');
|
||||
};
|
||||
|
||||
const getFilterType = (att: FilterType): FiltersType => {
|
||||
if (FILTER_TYPE_MAP[att.key]) {
|
||||
return FILTER_TYPE_MAP[att.key];
|
||||
const getFilterType = (att: TelemetryFieldKey): FiltersType => {
|
||||
if (FILTER_TYPE_MAP[att.name]) {
|
||||
return FILTER_TYPE_MAP[att.name];
|
||||
}
|
||||
return FiltersType.CHECKBOX;
|
||||
};
|
||||
|
||||
export const getFilterConfig = (
|
||||
signal?: SignalType,
|
||||
customFilters?: FilterType[],
|
||||
customFilters?: TelemetryFieldKey[],
|
||||
config?: IQuickFiltersConfig[],
|
||||
): IQuickFiltersConfig[] => {
|
||||
if (!customFilters?.length || !signal) {
|
||||
@@ -46,13 +62,13 @@ export const getFilterConfig = (
|
||||
(att, index) =>
|
||||
({
|
||||
type: getFilterType(att),
|
||||
title: getFilterName(att.key),
|
||||
title: getFilterName(att.name),
|
||||
dataSource: SIGNAL_DATA_SOURCE_MAP[signal],
|
||||
attributeKey: {
|
||||
id: att.key,
|
||||
key: att.key,
|
||||
dataType: att.dataType,
|
||||
type: att.type,
|
||||
id: att.name,
|
||||
key: att.name,
|
||||
dataType: fieldDataTypeToDataType(att.fieldDataType),
|
||||
type: mapFieldContext(att.fieldContext),
|
||||
},
|
||||
defaultOpen: index < 2,
|
||||
}) as IQuickFiltersConfig,
|
||||
|
||||
@@ -4,14 +4,18 @@ import { refreshLicense } from 'api/generated/services/licenses';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { RefreshCcw } from '@signozhq/icons';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import { buildLicenseUpdatePermission } from 'lib/authz/hooks/useAuthZ/permissions/license.permissions';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
|
||||
function RefreshPaymentStatus({
|
||||
type,
|
||||
className,
|
||||
withPortal,
|
||||
}: {
|
||||
type?: 'button' | 'text' | 'tooltip';
|
||||
className?: string;
|
||||
withPortal?: false;
|
||||
}): JSX.Element {
|
||||
const { t } = useTranslation(['failedPayment']);
|
||||
const { activeLicense, activeLicenseRefetch } = useAppContext();
|
||||
@@ -36,17 +40,25 @@ function RefreshPaymentStatus({
|
||||
};
|
||||
|
||||
const button = (
|
||||
<Button
|
||||
variant="link"
|
||||
color={type === 'text' ? 'none' : 'secondary'}
|
||||
size="md"
|
||||
className={className}
|
||||
onClick={handleRefreshPaymentStatus}
|
||||
prefix={<RefreshCcw size={14} />}
|
||||
loading={isLoading}
|
||||
<AuthZTooltip
|
||||
checks={
|
||||
activeLicense ? [buildLicenseUpdatePermission(activeLicense.id)] : []
|
||||
}
|
||||
enabled={!!activeLicense}
|
||||
withPortal={withPortal}
|
||||
>
|
||||
{type !== 'tooltip' ? t('refreshPaymentStatus') : ''}
|
||||
</Button>
|
||||
<Button
|
||||
variant="link"
|
||||
color={type === 'text' ? 'none' : 'secondary'}
|
||||
size="md"
|
||||
className={className}
|
||||
onClick={handleRefreshPaymentStatus}
|
||||
prefix={<RefreshCcw size={14} />}
|
||||
loading={isLoading}
|
||||
>
|
||||
{type !== 'tooltip' ? t('refreshPaymentStatus') : ''}
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -62,6 +74,7 @@ function RefreshPaymentStatus({
|
||||
RefreshPaymentStatus.defaultProps = {
|
||||
type: 'button',
|
||||
className: undefined,
|
||||
withPortal: undefined,
|
||||
};
|
||||
|
||||
export default RefreshPaymentStatus;
|
||||
|
||||
@@ -31,8 +31,6 @@ export const getComponentForPanelType = (
|
||||
[PANEL_TYPES.BAR]: Uplot,
|
||||
[PANEL_TYPES.PIE]: null,
|
||||
[PANEL_TYPES.HISTOGRAM]: Uplot,
|
||||
// Dashboards v2 renders this kind; nothing reaches the V1 chart map for it.
|
||||
[PANEL_TYPES.TEXT]: null,
|
||||
[PANEL_TYPES.EMPTY_WIDGET]: null,
|
||||
};
|
||||
|
||||
|
||||
@@ -348,6 +348,19 @@ export const initialQueryMeterWithType: Query = {
|
||||
},
|
||||
};
|
||||
|
||||
export const initialQueryAIWithType: Query = {
|
||||
...initialQueryWithType,
|
||||
builder: {
|
||||
...initialQueryWithType.builder,
|
||||
queryData: [
|
||||
{
|
||||
...initialQueryBuilderFormValuesMap.traces,
|
||||
builderQueryType: 'builder_ai_query',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const operatorsByTypes: Record<LocalDataType, string[]> = {
|
||||
string: Object.values(StringOperators),
|
||||
number: Object.values(NumberOperators),
|
||||
@@ -363,7 +376,6 @@ export enum PANEL_TYPES {
|
||||
BAR = 'bar',
|
||||
PIE = 'pie',
|
||||
HISTOGRAM = 'histogram',
|
||||
TEXT = 'text',
|
||||
EMPTY_WIDGET = 'EMPTY_WIDGET',
|
||||
}
|
||||
|
||||
@@ -611,8 +623,6 @@ export const PANEL_TYPES_INITIAL_QUERY: Record<PANEL_TYPES, Query> = {
|
||||
[PANEL_TYPES.BAR]: initialQueriesMap.metrics,
|
||||
[PANEL_TYPES.PIE]: initialQueriesMap.metrics,
|
||||
[PANEL_TYPES.HISTOGRAM]: initialQueriesMap.metrics,
|
||||
// No query of its own: a text panel renders an authored body.
|
||||
[PANEL_TYPES.TEXT]: initialQueriesMap.metrics,
|
||||
[PANEL_TYPES.EMPTY_WIDGET]: initialQueriesMap.metrics,
|
||||
};
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ export const REACT_QUERY_KEY = {
|
||||
GET_ALL_DASHBOARDS: 'GET_ALL_DASHBOARDS',
|
||||
GET_TRIGGERED_ALERTS: 'GET_TRIGGERED_ALERTS',
|
||||
DASHBOARD_BY_ID: 'DASHBOARD_BY_ID',
|
||||
GET_BILLING_USAGE: 'GET_BILLING_USAGE',
|
||||
GET_FEATURES_FLAGS: 'GET_FEATURES_FLAGS',
|
||||
DELETE_DASHBOARD: 'DELETE_DASHBOARD',
|
||||
LOGS_PIPELINE_PREVIEW: 'LOGS_PIPELINE_PREVIEW',
|
||||
|
||||
@@ -3,6 +3,8 @@ import * as Sentry from '@sentry/react';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import cx from 'classnames';
|
||||
import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useSignalFieldApis } from 'components/QuickFilters/hooks/useSignalFieldApis';
|
||||
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
|
||||
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
|
||||
|
||||
@@ -11,6 +13,10 @@ import DomainList from './Domains/DomainList';
|
||||
import './Explorer.styles.scss';
|
||||
|
||||
function Explorer(): JSX.Element {
|
||||
const quickFilterFieldApis = useSignalFieldApis(
|
||||
TelemetrytypesSignalDTO.traces,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
logEvent('API Monitoring: Landing page visited', {});
|
||||
}, []);
|
||||
@@ -21,11 +27,12 @@ function Explorer(): JSX.Element {
|
||||
<section className="api-quick-filter-left-section">
|
||||
<QuickFilters
|
||||
className="qf-api-monitoring"
|
||||
source={QuickFiltersSource.API_MONITORING}
|
||||
signal={SignalType.API_MONITORING}
|
||||
pageSource={QuickFiltersSource.API_MONITORING}
|
||||
quickFilterSignal={SignalType.API_MONITORING}
|
||||
showFilterCollapse={false}
|
||||
showQueryName={false}
|
||||
handleFilterVisibilityChange={(): void => {}}
|
||||
useFieldApis={quickFilterFieldApis}
|
||||
/>
|
||||
</section>
|
||||
<DomainList />
|
||||
|
||||
@@ -184,6 +184,11 @@
|
||||
text-decoration-thickness: 2px;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
&[disabled] {
|
||||
opacity: 0.6;
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
.workspace-restricted-banner,
|
||||
|
||||
@@ -20,7 +20,8 @@ import getLocalStorageApi from 'api/browser/localstorage/get';
|
||||
import setLocalStorageApi from 'api/browser/localstorage/set';
|
||||
import getChangelogByVersion from 'api/changelog/getChangelogByVersion';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import manageCreditCardApi from 'api/v1/portal/create';
|
||||
import { updateSubscription } from 'api/generated/services/subscriptions';
|
||||
import type { UpdateSubscription200 } from 'api/generated/services/sigNoz.schemas';
|
||||
import updateUserPreference from 'api/v1/user/preferences/name/update';
|
||||
import getUserVersion from 'api/v1/version/get';
|
||||
import getUserLatestVersion from 'api/v1/version/getLatestVersion';
|
||||
@@ -30,6 +31,8 @@ import ChangelogModal from 'components/ChangelogModal/ChangelogModal';
|
||||
import ChatSupportGateway from 'components/ChatSupportGateway/ChatSupportGateway';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import RefreshPaymentStatus from 'components/RefreshPaymentStatus/RefreshPaymentStatus';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import { SubscriptionManagePermissions } from 'lib/authz/hooks/useAuthZ/permissions/subscription.permissions';
|
||||
import { MIN_ACCOUNT_AGE_FOR_CHANGELOG } from 'constants/changelog';
|
||||
import { Events } from 'constants/events';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
@@ -63,8 +66,7 @@ import {
|
||||
UPDATE_LATEST_VERSION,
|
||||
UPDATE_LATEST_VERSION_ERROR,
|
||||
} from 'types/actions/app';
|
||||
import { ErrorResponse, SuccessResponse, SuccessResponseV2 } from 'types/api';
|
||||
import { CheckoutSuccessPayloadProps } from 'types/api/billing/checkout';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import {
|
||||
ChangelogSchema,
|
||||
DeploymentType,
|
||||
@@ -77,7 +79,6 @@ import {
|
||||
} from 'types/api/licensesV3/getActive';
|
||||
import { UserPreference } from 'types/api/preferences/preference';
|
||||
import AppReducer from 'types/reducer/app';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
import { getBaseUrl } from 'utils/basePath';
|
||||
import { showErrorNotification } from 'utils/error';
|
||||
import { eventEmitter } from 'utils/getEventEmitter';
|
||||
@@ -166,9 +167,7 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
|
||||
return Math.abs(currentDate.diff(userCreationDate, 'day'));
|
||||
}, [user.createdAt]);
|
||||
|
||||
const handleBillingOnSuccess = (
|
||||
data: SuccessResponseV2<CheckoutSuccessPayloadProps>,
|
||||
): void => {
|
||||
const handleBillingOnSuccess = (data: UpdateSubscription200): void => {
|
||||
if (data?.data?.redirectURL) {
|
||||
const newTab = document.createElement('a');
|
||||
newTab.href = data.data.redirectURL;
|
||||
@@ -186,7 +185,7 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
|
||||
};
|
||||
|
||||
const { mutate: manageCreditCard, isLoading: isLoadingManageBilling } =
|
||||
useMutation(manageCreditCardApi, {
|
||||
useMutation(updateSubscription, {
|
||||
onSuccess: (data) => {
|
||||
handleBillingOnSuccess(data);
|
||||
},
|
||||
@@ -469,10 +468,8 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
|
||||
}, [isLoggedIn]);
|
||||
|
||||
const handleUpgrade = useCallback((): void => {
|
||||
if (user.role === USER_ROLES.ADMIN) {
|
||||
history.push(ROUTES.BILLING);
|
||||
}
|
||||
}, [user.role]);
|
||||
history.push(ROUTES.BILLING);
|
||||
}, []);
|
||||
|
||||
const handleFailedPayment = useCallback((): void => {
|
||||
manageCreditCard({
|
||||
@@ -586,25 +583,21 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
|
||||
<div>
|
||||
Our systems are taking longer than expected for your trial workspace.
|
||||
Please{' '}
|
||||
{user.role === USER_ROLES.ADMIN ? (
|
||||
<span>
|
||||
<a
|
||||
className="upgrade-link"
|
||||
onClick={(): void => {
|
||||
notifications.destroy('slow-api-warning');
|
||||
<span>
|
||||
<a
|
||||
className="upgrade-link"
|
||||
onClick={(): void => {
|
||||
notifications.destroy('slow-api-warning');
|
||||
|
||||
logEvent(`Slow API Banner: Upgrade clicked`, {});
|
||||
logEvent(`Slow API Banner: Upgrade clicked`, {});
|
||||
|
||||
handleUpgrade();
|
||||
}}
|
||||
>
|
||||
upgrade
|
||||
</a>
|
||||
your workspace for a smoother experience.
|
||||
</span>
|
||||
) : (
|
||||
'contact your administrator for upgrading to a paid plan for a smoother experience.'
|
||||
)}
|
||||
handleUpgrade();
|
||||
}}
|
||||
>
|
||||
upgrade
|
||||
</a>
|
||||
your workspace for a smoother experience.
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
duration: 60000,
|
||||
@@ -794,22 +787,18 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
|
||||
<div className="trial-expiry-banner">
|
||||
You are in free trial period. Your free trial will end on{' '}
|
||||
<span>{getFormattedDate(trialInfo?.trialEnd || Date.now())}.</span>
|
||||
{user.role === USER_ROLES.ADMIN ? (
|
||||
<span>
|
||||
<span>
|
||||
{' '}
|
||||
Please{' '}
|
||||
<a className="upgrade-link" onClick={handleUpgrade}>
|
||||
upgrade
|
||||
</a>
|
||||
to continue using SigNoz features.
|
||||
<span className="refresh-payment-status">
|
||||
{' '}
|
||||
Please{' '}
|
||||
<a className="upgrade-link" onClick={handleUpgrade}>
|
||||
upgrade
|
||||
</a>
|
||||
to continue using SigNoz features.
|
||||
<span className="refresh-payment-status">
|
||||
{' '}
|
||||
| Already upgraded? <RefreshPaymentStatus type="text" />
|
||||
</span>
|
||||
| Already upgraded? <RefreshPaymentStatus type="text" />
|
||||
</span>
|
||||
) : (
|
||||
'Please contact your administrator for upgrading to a paid plan.'
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -826,22 +815,20 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
|
||||
)}
|
||||
.
|
||||
</span>
|
||||
{user.role === USER_ROLES.ADMIN ? (
|
||||
<span>
|
||||
{' '}
|
||||
Please{' '}
|
||||
<span>
|
||||
{' '}
|
||||
Please{' '}
|
||||
<AuthZTooltip checks={SubscriptionManagePermissions}>
|
||||
<a className="upgrade-link" onClick={handleFailedPayment}>
|
||||
pay the bill
|
||||
</a>
|
||||
to continue using SigNoz features.
|
||||
<span className="refresh-payment-status">
|
||||
{' '}
|
||||
| Already paid? <RefreshPaymentStatus type="text" />
|
||||
</span>
|
||||
</AuthZTooltip>
|
||||
to continue using SigNoz features.
|
||||
<span className="refresh-payment-status">
|
||||
{' '}
|
||||
| Already paid? <RefreshPaymentStatus type="text" />
|
||||
</span>
|
||||
) : (
|
||||
' Please contact your administrator to pay the bill.'
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import {
|
||||
SubscriptionCreatePermission,
|
||||
SubscriptionReadPermission,
|
||||
SubscriptionUpdatePermission,
|
||||
} from 'lib/authz/hooks/useAuthZ/permissions/subscription.permissions';
|
||||
import {
|
||||
setupAuthzAdmin,
|
||||
setupAuthzAllow,
|
||||
setupAuthzDeny,
|
||||
} from 'lib/authz/utils/authz-test-utils';
|
||||
import { trialConvertedToSubscriptionResponse } from 'mocks-server/__mockdata__/licenses';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { render, screen, waitFor } from 'tests/test-utils';
|
||||
|
||||
import BillingContainer from './BillingContainer';
|
||||
|
||||
window.ResizeObserver =
|
||||
window.ResizeObserver ||
|
||||
jest.fn().mockImplementation(() => ({
|
||||
disconnect: jest.fn(),
|
||||
observe: jest.fn(),
|
||||
unobserve: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('BillingContainer - AuthZ', () => {
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('renders usage and enables actions when all subscription permissions are granted', async () => {
|
||||
server.use(setupAuthzAdmin());
|
||||
|
||||
render(<BillingContainer />);
|
||||
|
||||
await expect(
|
||||
screen.findByRole('columnheader', { name: /data ingested/i }),
|
||||
).resolves.toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('header-billing-button')).toBeEnabled();
|
||||
});
|
||||
expect(screen.queryByText(/not authorized/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('blocks the usage section when subscription read is denied', async () => {
|
||||
server.use(setupAuthzDeny(SubscriptionReadPermission));
|
||||
|
||||
render(<BillingContainer />);
|
||||
|
||||
await expect(
|
||||
screen.findByText(/not authorized/i),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(screen.getByTestId('header-billing-button')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('columnheader', { name: /data ingested/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables upgrade when subscription create is denied', async () => {
|
||||
server.use(setupAuthzAllow(SubscriptionReadPermission));
|
||||
|
||||
render(<BillingContainer />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('header-billing-button')).toBeDisabled();
|
||||
});
|
||||
expect(screen.getByTestId('upgrade-plan-button')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('disables manage billing when subscription update is denied', async () => {
|
||||
server.use(
|
||||
setupAuthzAllow(SubscriptionReadPermission, SubscriptionCreatePermission),
|
||||
);
|
||||
|
||||
render(
|
||||
<BillingContainer />,
|
||||
{},
|
||||
{
|
||||
appContextOverrides: {
|
||||
trialInfo: trialConvertedToSubscriptionResponse.data,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('header-billing-button')).toBeDisabled();
|
||||
});
|
||||
expect(screen.queryByTestId('upgrade-plan-button')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables manage billing when subscription list is denied', async () => {
|
||||
server.use(
|
||||
setupAuthzAllow(
|
||||
SubscriptionReadPermission,
|
||||
SubscriptionCreatePermission,
|
||||
SubscriptionUpdatePermission,
|
||||
),
|
||||
);
|
||||
|
||||
render(
|
||||
<BillingContainer />,
|
||||
{},
|
||||
{
|
||||
appContextOverrides: {
|
||||
trialInfo: trialConvertedToSubscriptionResponse.data,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('header-billing-button')).toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@
|
||||
margin: 0 auto var(--spacing-20);
|
||||
|
||||
.pageHeader {
|
||||
margin-bottom: var(--spacing-8);
|
||||
margin-bottom: var(--spacing-4);
|
||||
|
||||
.pageHeaderTitle {
|
||||
font-weight: var(--label-medium-500-font-weight);
|
||||
@@ -41,6 +41,8 @@
|
||||
}
|
||||
|
||||
.pageInfo {
|
||||
margin-bottom: var(--spacing-4);
|
||||
|
||||
:global(.ant-card) {
|
||||
padding: var(--padding-3);
|
||||
}
|
||||
@@ -58,8 +60,12 @@
|
||||
margin: var(--spacing-12) var(--spacing-4);
|
||||
}
|
||||
|
||||
.usageDenied {
|
||||
margin-bottom: var(--spacing-4);
|
||||
}
|
||||
|
||||
.billingDetails {
|
||||
margin: var(--spacing-12) 0;
|
||||
margin: var(--spacing-4) 0;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
@@ -128,7 +134,7 @@
|
||||
}
|
||||
|
||||
.upgradePlanBenefits {
|
||||
margin: 0 var(--spacing-4);
|
||||
margin: 0;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 5px;
|
||||
padding: 0 var(--padding-12);
|
||||
@@ -176,7 +182,7 @@
|
||||
}
|
||||
|
||||
.billingUpdateNote {
|
||||
margin-top: var(--spacing-8);
|
||||
margin-top: var(--spacing-4);
|
||||
font-family: var(--font-family-inter);
|
||||
font-size: var(--font-size-sm);
|
||||
font-style: normal;
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { setupAuthzAdmin } from 'lib/authz/utils/authz-test-utils';
|
||||
import { billingSuccessResponse } from 'mocks-server/__mockdata__/billing';
|
||||
import {
|
||||
licensesSuccessResponse,
|
||||
notOfTrailResponse,
|
||||
trialConvertedToSubscriptionResponse,
|
||||
} from 'mocks-server/__mockdata__/licenses';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { act, render, screen, getAppContextMock } from 'tests/test-utils';
|
||||
import APIError from 'types/api/error';
|
||||
import {
|
||||
@@ -15,11 +17,6 @@ import { getFormattedDate } from 'utils/timeUtils';
|
||||
|
||||
import BillingContainer from './BillingContainer';
|
||||
|
||||
jest.mock('hooks/useActiveLicenseKey/useActiveLicenseKey', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => ({ licenseKey: 'test-key', isLoading: false })),
|
||||
}));
|
||||
|
||||
window.ResizeObserver =
|
||||
window.ResizeObserver ||
|
||||
jest.fn().mockImplementation(() => ({
|
||||
@@ -31,14 +28,22 @@ window.ResizeObserver =
|
||||
describe('BillingContainer', () => {
|
||||
jest.setTimeout(30000);
|
||||
|
||||
beforeEach(() => {
|
||||
server.use(setupAuthzAdmin());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('Component should render', async () => {
|
||||
render(<BillingContainer />);
|
||||
|
||||
const dataInjection = screen.getByRole('columnheader', {
|
||||
const dataInjection = await screen.findByRole('columnheader', {
|
||||
name: /data ingested/i,
|
||||
});
|
||||
expect(dataInjection).toBeInTheDocument();
|
||||
const pricePerUnit = screen.getByRole('columnheader', {
|
||||
const pricePerUnit = await screen.findByRole('columnheader', {
|
||||
name: /price per unit/i,
|
||||
});
|
||||
expect(pricePerUnit).toBeInTheDocument();
|
||||
@@ -49,13 +54,15 @@ describe('BillingContainer', () => {
|
||||
|
||||
const dayRemainingInBillingPeriod = await screen.findByText(
|
||||
/Please upgrade plan now to retain your data./i,
|
||||
{},
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
expect(dayRemainingInBillingPeriod).toBeInTheDocument();
|
||||
|
||||
const upgradePlanButton = screen.getByTestId('upgrade-plan-button');
|
||||
expect(upgradePlanButton).toBeInTheDocument();
|
||||
|
||||
const dollar = await screen.findByText(/\$1,278.3/i);
|
||||
const dollar = await screen.findByText(/\$1,278.3/i, {}, { timeout: 5000 });
|
||||
expect(dollar).toBeInTheDocument();
|
||||
|
||||
const currentBill = await screen.findByText('billing');
|
||||
@@ -86,7 +93,9 @@ describe('BillingContainer', () => {
|
||||
|
||||
await expect(screen.findByText('Free Trial')).resolves.toBeInTheDocument();
|
||||
await expect(screen.findByText('billing')).resolves.toBeInTheDocument();
|
||||
await expect(screen.findByText(/\$0/i)).resolves.toBeInTheDocument();
|
||||
await expect(
|
||||
screen.findByText(/\$0/i, {}, { timeout: 5000 }),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
await expect(
|
||||
screen.findByText(
|
||||
@@ -132,7 +141,7 @@ describe('BillingContainer', () => {
|
||||
const currentBill = await screen.findByText('billing');
|
||||
expect(currentBill).toBeInTheDocument();
|
||||
|
||||
const dollar0 = await screen.findByText(/\$0/i);
|
||||
const dollar0 = await screen.findByText(/\$0/i, {}, { timeout: 5000 });
|
||||
expect(dollar0).toBeInTheDocument();
|
||||
|
||||
const onTrail = await screen.findByText(
|
||||
@@ -250,7 +259,11 @@ describe('BillingContainer', () => {
|
||||
billingSuccessResponse.data.billingPeriodStart,
|
||||
)} to ${getFormattedDate(billingSuccessResponse.data.billingPeriodEnd)}`;
|
||||
|
||||
const billingPeriod = await findByText(billingPeriodText);
|
||||
const billingPeriod = await findByText(
|
||||
billingPeriodText,
|
||||
{},
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
expect(billingPeriod).toBeInTheDocument();
|
||||
|
||||
const currentBill = await screen.findByText('billing');
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
import { useMutation } from 'react-query';
|
||||
import { CircleCheck, Landmark, MonitorDown } from '@signozhq/icons';
|
||||
import {
|
||||
Card,
|
||||
@@ -15,25 +15,36 @@ import {
|
||||
TableColumnsType as ColumnsType,
|
||||
} from 'antd';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import getUsage, {
|
||||
BreakdownEntry,
|
||||
UsageResponsePayloadProps,
|
||||
} from 'api/billing/getUsage';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import updateCreditCardApi from 'api/v1/checkout/create';
|
||||
import manageCreditCardApi from 'api/v1/portal/create';
|
||||
import type {
|
||||
CreateSubscription201,
|
||||
GetSubscription200,
|
||||
SubscriptiontypesGettableSubscriptionUsageDTO,
|
||||
SubscriptiontypesSubscriptionUsageBreakdownDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
createSubscription,
|
||||
updateSubscription,
|
||||
useGetSubscription,
|
||||
} from 'api/generated/services/subscriptions';
|
||||
import RefreshPaymentStatus from 'components/RefreshPaymentStatus/RefreshPaymentStatus';
|
||||
import Spinner from 'components/Spinner';
|
||||
import { SOMETHING_WENT_WRONG } from 'constants/api';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import useAxiosError from 'hooks/useAxiosError';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { isEmpty, pick } from 'lodash-es';
|
||||
import useActiveLicenseKey from 'hooks/useActiveLicenseKey/useActiveLicenseKey';
|
||||
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import { AuthZGuardContent } from 'lib/authz/components/AuthZGuard/AuthZGuardContent';
|
||||
import PermissionDeniedCallout from 'lib/authz/components/PermissionDeniedCallout/PermissionDeniedCallout';
|
||||
import {
|
||||
SubscriptionCreatePermission,
|
||||
SubscriptionManagePermissions,
|
||||
SubscriptionReadPermission,
|
||||
} from 'lib/authz/hooks/useAuthZ/permissions/subscription.permissions';
|
||||
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { ErrorResponse, SuccessResponse, SuccessResponseV2 } from 'types/api';
|
||||
import { CheckoutSuccessPayloadProps } from 'types/api/billing/checkout';
|
||||
import { getBaseUrl } from 'utils/basePath';
|
||||
import { getFormattedDate, getRemainingDays } from 'utils/timeUtils';
|
||||
|
||||
@@ -135,7 +146,7 @@ export default function BillingContainer(): JSX.Element {
|
||||
const [isFreeTrial, setIsFreeTrial] = useState(false);
|
||||
const [data, setData] = useState<DataType[]>([]);
|
||||
const [apiResponse, setApiResponse] = useState<
|
||||
Partial<UsageResponsePayloadProps>
|
||||
Partial<SubscriptiontypesGettableSubscriptionUsageDTO>
|
||||
>({});
|
||||
|
||||
const {
|
||||
@@ -146,7 +157,8 @@ export default function BillingContainer(): JSX.Element {
|
||||
activeLicense,
|
||||
activeLicenseFetchError,
|
||||
} = useAppContext();
|
||||
const { licenseKey } = useActiveLicenseKey();
|
||||
const { allowed: canReadSubscription, error: subscriptionAuthZError } =
|
||||
useAuthZ([SubscriptionReadPermission]);
|
||||
const { notifications } = useNotifications();
|
||||
|
||||
const handleError = useAxiosError();
|
||||
@@ -154,33 +166,34 @@ export default function BillingContainer(): JSX.Element {
|
||||
const { isCloudUser: isCloudUserVal } = useGetTenantLicense();
|
||||
|
||||
const processUsageData = useCallback(
|
||||
(data: SuccessResponse<UsageResponsePayloadProps> | ErrorResponse): void => {
|
||||
if (isEmpty(data?.payload)) {
|
||||
(response: GetSubscription200): void => {
|
||||
const usage = response?.data;
|
||||
if (isEmpty(usage)) {
|
||||
return;
|
||||
}
|
||||
const {
|
||||
details: { breakdown = [], billTotal },
|
||||
billingPeriodStart,
|
||||
billingPeriodEnd,
|
||||
} = (data as SuccessResponse<UsageResponsePayloadProps>).payload;
|
||||
const breakdown = usage.details?.breakdown ?? [];
|
||||
const billTotal = usage.details?.billTotal ?? 0;
|
||||
const billingPeriodStart = usage.billingPeriodStart ?? 0;
|
||||
const billingPeriodEnd = usage.billingPeriodEnd ?? 0;
|
||||
const formattedUsageData: DataType[] = [];
|
||||
|
||||
if (breakdown && Array.isArray(breakdown)) {
|
||||
for (let index = 0; index < breakdown.length; index += 1) {
|
||||
const element: BreakdownEntry = breakdown[index];
|
||||
|
||||
element?.tiers?.forEach((tier, i: number) => {
|
||||
breakdown.forEach(
|
||||
(
|
||||
element: SubscriptiontypesSubscriptionUsageBreakdownDTO,
|
||||
index: number,
|
||||
) => {
|
||||
element?.tiers?.forEach((tier, tierIndex: number) => {
|
||||
formattedUsageData.push({
|
||||
key: `${index}${i}`,
|
||||
name: i === 0 ? element?.type : '',
|
||||
key: `${index}${tierIndex}`,
|
||||
name: tierIndex === 0 ? (element?.type ?? '') : '',
|
||||
unit: element?.unit ?? '',
|
||||
dataIngested: `${tier.quantity} ${element?.unit}`,
|
||||
pricePerUnit: String(tier.unitPrice),
|
||||
cost: `$ ${tier.tierCost}`,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
setData(formattedUsageData);
|
||||
|
||||
@@ -196,7 +209,7 @@ export default function BillingContainer(): JSX.Element {
|
||||
setBillAmount(billTotal);
|
||||
}
|
||||
|
||||
setApiResponse(data?.payload || {});
|
||||
setApiResponse(usage);
|
||||
},
|
||||
[trialInfo?.onTrial],
|
||||
);
|
||||
@@ -208,11 +221,12 @@ export default function BillingContainer(): JSX.Element {
|
||||
isLoading,
|
||||
isFetching: isFetchingBillingData,
|
||||
data: billingData,
|
||||
} = useQuery([REACT_QUERY_KEY.GET_BILLING_USAGE, user?.id], {
|
||||
queryFn: () => getUsage(licenseKey || ''),
|
||||
onError: handleError,
|
||||
enabled: !!licenseKey,
|
||||
onSuccess: processUsageData,
|
||||
} = useGetSubscription({
|
||||
query: {
|
||||
enabled: canReadSubscription || !!subscriptionAuthZError,
|
||||
onError: handleError,
|
||||
onSuccess: processUsageData,
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -284,9 +298,7 @@ export default function BillingContainer(): JSX.Element {
|
||||
/>
|
||||
);
|
||||
|
||||
const handleBillingOnSuccess = (
|
||||
data: SuccessResponseV2<CheckoutSuccessPayloadProps>,
|
||||
): void => {
|
||||
const handleBillingOnSuccess = (data: CreateSubscription201): void => {
|
||||
if (data?.data?.redirectURL) {
|
||||
const newTab = document.createElement('a');
|
||||
newTab.href = data.data.redirectURL;
|
||||
@@ -303,7 +315,7 @@ export default function BillingContainer(): JSX.Element {
|
||||
};
|
||||
|
||||
const { mutate: updateCreditCard, isLoading: isLoadingBilling } = useMutation(
|
||||
updateCreditCardApi,
|
||||
createSubscription,
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
handleBillingOnSuccess(data);
|
||||
@@ -313,7 +325,7 @@ export default function BillingContainer(): JSX.Element {
|
||||
);
|
||||
|
||||
const { mutate: manageCreditCard, isLoading: isLoadingManageBilling } =
|
||||
useMutation(manageCreditCardApi, {
|
||||
useMutation(updateSubscription, {
|
||||
onSuccess: (data) => {
|
||||
handleBillingOnSuccess(data);
|
||||
},
|
||||
@@ -348,15 +360,21 @@ export default function BillingContainer(): JSX.Element {
|
||||
updateCreditCard,
|
||||
]);
|
||||
|
||||
const billingActionPermissions = trialInfo?.trialConvertedToSubscription
|
||||
? SubscriptionManagePermissions
|
||||
: [SubscriptionCreatePermission];
|
||||
|
||||
const subscriptionPastDueMessage = (): JSX.Element => (
|
||||
<Typography>
|
||||
{`We were not able to process payments for your account. Please update your card details `}
|
||||
<Typography.Link
|
||||
onClick={handleBilling}
|
||||
style={{ cursor: 'pointer', color: 'var(--bg-cherry-500)' }}
|
||||
>
|
||||
{t('here')}
|
||||
</Typography.Link>
|
||||
<AuthZTooltip checks={billingActionPermissions}>
|
||||
<Typography.Link
|
||||
onClick={handleBilling}
|
||||
style={{ cursor: 'pointer', color: 'var(--bg-cherry-500)' }}
|
||||
>
|
||||
{t('here')}
|
||||
</Typography.Link>
|
||||
</AuthZTooltip>
|
||||
{` if your payment information has changed. Email us at `}
|
||||
<Typography.Text color="muted">cloud-support@signoz.io</Typography.Text>
|
||||
{` otherwise. Be sure to provide this information immediately to avoid interruption to your service.`}
|
||||
@@ -411,11 +429,7 @@ export default function BillingContainer(): JSX.Element {
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
|
||||
<Card
|
||||
bordered={false}
|
||||
style={{ minHeight: 150, marginBottom: 16 }}
|
||||
className={styles.pageInfo}
|
||||
>
|
||||
<Card bordered={false} className={styles.pageInfo}>
|
||||
<Flex justify="space-between" align="center">
|
||||
<Flex vertical gap={8}>
|
||||
<p className={styles.pageInfoTitle}>
|
||||
@@ -423,13 +437,14 @@ export default function BillingContainer(): JSX.Element {
|
||||
{isFreeTrial ? <Badge color="success"> Free Trial </Badge> : ''}
|
||||
</p>
|
||||
|
||||
{!isLoading && !isFetchingBillingData && !showGracePeriodMessage ? (
|
||||
{billingData && !isFetchingBillingData && !showGracePeriodMessage ? (
|
||||
<p className={styles.pageInfoSubtitle}>
|
||||
{daysRemaining} {daysRemainingStr}
|
||||
</p>
|
||||
) : null}
|
||||
</Flex>
|
||||
<Button
|
||||
<AuthZButton
|
||||
checks={billingActionPermissions}
|
||||
testId="header-billing-button"
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
@@ -443,7 +458,7 @@ export default function BillingContainer(): JSX.Element {
|
||||
{trialInfo?.trialConvertedToSubscription
|
||||
? t('manage_billing')
|
||||
: t('upgrade_plan')}
|
||||
</Button>
|
||||
</AuthZButton>
|
||||
</Flex>
|
||||
|
||||
{trialInfo?.onTrial && trialInfo?.trialConvertedToSubscription && (
|
||||
@@ -495,66 +510,81 @@ export default function BillingContainer(): JSX.Element {
|
||||
))}
|
||||
</Card>
|
||||
|
||||
<div className={styles.billingGraphSection}>
|
||||
{!isLoading && !isFetchingBillingData ? (
|
||||
<BillingUsageGraph data={apiResponse} billAmount={billAmount} />
|
||||
) : (
|
||||
<Card className={styles.emptyGraphCard} bordered={false}>
|
||||
<Spinner size="large" tip="Loading..." height="35vh" />
|
||||
</Card>
|
||||
)}
|
||||
{!isLoading && !isFetchingBillingData && (
|
||||
<div className={styles.billingGraphFooter}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="md"
|
||||
onClick={handleCsvDownload}
|
||||
prefix={<MonitorDown size={14} />}
|
||||
testId="download-csv-button"
|
||||
className={styles.billingFooterBtn}
|
||||
>
|
||||
Download CSV
|
||||
</Button>
|
||||
<RefreshPaymentStatus type="button" className={styles.billingFooterBtn} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!isLoading && !isFetchingBillingData && (
|
||||
<Callout type="info" size="small" className={styles.billingUpdateNote}>
|
||||
Billing metrics are updated once every 24 hours.
|
||||
</Callout>
|
||||
)}
|
||||
|
||||
<div className={styles.billingDetails}>
|
||||
{!isLoading && !isFetchingBillingData && (
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
pagination={false}
|
||||
bordered={false}
|
||||
components={{
|
||||
header: {
|
||||
cell: ({
|
||||
style,
|
||||
...props
|
||||
}: React.ThHTMLAttributes<HTMLTableCellElement>): JSX.Element => {
|
||||
const { background: _, boxShadow: __, ...safeStyle } = style ?? {};
|
||||
return (
|
||||
<th
|
||||
{...props}
|
||||
style={safeStyle}
|
||||
className={`${props.className ?? ''} ${styles.billingDetailsHeaderCell}`}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
}}
|
||||
<AuthZGuardContent
|
||||
checks={[SubscriptionReadPermission]}
|
||||
fallback={({ deniedPermissions }): JSX.Element => (
|
||||
<PermissionDeniedCallout
|
||||
deniedPermissions={deniedPermissions}
|
||||
className={styles.usageDenied}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<>
|
||||
<div className={styles.billingGraphSection}>
|
||||
{!isLoading && !isFetchingBillingData ? (
|
||||
<BillingUsageGraph data={apiResponse} billAmount={billAmount} />
|
||||
) : (
|
||||
<Card className={styles.emptyGraphCard} bordered={false}>
|
||||
<Spinner size="large" tip="Loading..." height="35vh" />
|
||||
</Card>
|
||||
)}
|
||||
{!isLoading && !isFetchingBillingData && (
|
||||
<div className={styles.billingGraphFooter}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="md"
|
||||
onClick={handleCsvDownload}
|
||||
prefix={<MonitorDown size={14} />}
|
||||
testId="download-csv-button"
|
||||
className={styles.billingFooterBtn}
|
||||
>
|
||||
Download CSV
|
||||
</Button>
|
||||
<RefreshPaymentStatus
|
||||
type="button"
|
||||
className={styles.billingFooterBtn}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!isLoading && !isFetchingBillingData && (
|
||||
<Callout type="info" size="small" className={styles.billingUpdateNote}>
|
||||
Billing metrics are updated once every 24 hours.
|
||||
</Callout>
|
||||
)}
|
||||
|
||||
{(isLoading || isFetchingBillingData) && renderTableSkeleton()}
|
||||
</div>
|
||||
<div className={styles.billingDetails}>
|
||||
{!isLoading && !isFetchingBillingData && (
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
pagination={false}
|
||||
bordered={false}
|
||||
components={{
|
||||
header: {
|
||||
cell: ({
|
||||
style,
|
||||
...props
|
||||
}: React.ThHTMLAttributes<HTMLTableCellElement>): JSX.Element => {
|
||||
const { background: _, boxShadow: __, ...safeStyle } = style ?? {};
|
||||
return (
|
||||
<th
|
||||
{...props}
|
||||
style={safeStyle}
|
||||
className={`${props.className ?? ''} ${styles.billingDetailsHeaderCell}`}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(isLoading || isFetchingBillingData) && renderTableSkeleton()}
|
||||
</div>
|
||||
</>
|
||||
</AuthZGuardContent>
|
||||
|
||||
{isCloudUserVal && activeLicense?.state === LicenseState.ACTIVATED && (
|
||||
<CancelSubscriptionBanner />
|
||||
@@ -597,7 +627,8 @@ export default function BillingContainer(): JSX.Element {
|
||||
</Typography.Text>
|
||||
</Col>
|
||||
<Col span={4} style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
<AuthZButton
|
||||
checks={[SubscriptionCreatePermission]}
|
||||
testId="upgrade-plan-button"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
@@ -606,7 +637,7 @@ export default function BillingContainer(): JSX.Element {
|
||||
onClick={handleBilling}
|
||||
>
|
||||
{t('upgrade_plan')}
|
||||
</Button>
|
||||
</AuthZButton>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from 'lib/uPlotV2/components/types';
|
||||
import type { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import type uPlot from 'uplot';
|
||||
import type { UsageResponsePayloadProps } from 'api/billing/getUsage';
|
||||
import type { SubscriptiontypesGettableSubscriptionUsageDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { BillingBarChartTooltip } from './BillingBarChartTooltip';
|
||||
import { prepareBillingBarConfig } from './prepareBillingBarConfig';
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
import styles from './BillingUsageGraph.module.scss';
|
||||
|
||||
interface BillingUsageGraphProps {
|
||||
data: Partial<UsageResponsePayloadProps>;
|
||||
data: Partial<SubscriptiontypesGettableSubscriptionUsageDTO>;
|
||||
billAmount: number;
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ export function BillingUsageGraph(props: BillingUsageGraphProps): JSX.Element {
|
||||
const currentDay = breakdown.dayWiseBreakdown.breakdown[0];
|
||||
const nextDay = {
|
||||
...currentDay,
|
||||
timestamp: currentDay.timestamp + 86400,
|
||||
timestamp: (currentDay.timestamp ?? 0) + 86400,
|
||||
count: 0,
|
||||
size: 0,
|
||||
quantity: 0,
|
||||
@@ -94,7 +94,9 @@ export function BillingUsageGraph(props: BillingUsageGraphProps): JSX.Element {
|
||||
|
||||
const { startTime, endTime } = useMemo(
|
||||
() =>
|
||||
calculateStartEndTime(normalizedData as Partial<UsageResponsePayloadProps>),
|
||||
calculateStartEndTime(
|
||||
normalizedData as Partial<SubscriptiontypesGettableSubscriptionUsageDTO>,
|
||||
),
|
||||
[normalizedData],
|
||||
);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { UsageResponsePayloadProps } from 'api/billing/getUsage';
|
||||
import { SubscriptiontypesGettableSubscriptionUsageDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import dayjs from 'dayjs';
|
||||
import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils';
|
||||
@@ -117,7 +117,9 @@ export function csvFileName(csvData: QuantityData[]): string {
|
||||
return `billing_usage_(${startDate}-${endDate}).csv`;
|
||||
}
|
||||
|
||||
export function prepareCsvData(data: Partial<UsageResponsePayloadProps>): {
|
||||
export function prepareCsvData(
|
||||
data: Partial<SubscriptiontypesGettableSubscriptionUsageDTO>,
|
||||
): {
|
||||
csvData: string;
|
||||
fileName: string;
|
||||
} {
|
||||
@@ -135,12 +137,14 @@ export function prepareCsvData(data: Partial<UsageResponsePayloadProps>): {
|
||||
}
|
||||
|
||||
export function calculateStartEndTime(
|
||||
data: Partial<UsageResponsePayloadProps>,
|
||||
data: Partial<SubscriptiontypesGettableSubscriptionUsageDTO>,
|
||||
): { startTime: number | undefined; endTime: number | undefined } {
|
||||
const timestamps: number[] = [];
|
||||
data?.details?.breakdown?.forEach((breakdown) => {
|
||||
breakdown?.dayWiseBreakdown?.breakdown?.forEach((entry) => {
|
||||
timestamps.push(entry.timestamp);
|
||||
if (typeof entry.timestamp === 'number') {
|
||||
timestamps.push(entry.timestamp);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background-color: var(--l2-background);
|
||||
margin: var(--spacing-4) 0 var(--spacing-12);
|
||||
margin: var(--spacing-4) 0;
|
||||
}
|
||||
|
||||
.info {
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
import { SubscriptionDeletePermission } from 'lib/authz/hooks/useAuthZ/permissions/subscription.permissions';
|
||||
import {
|
||||
setupAuthzAdmin,
|
||||
setupAuthzDeny,
|
||||
} from 'lib/authz/utils/authz-test-utils';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import CancelSubscriptionBanner from './CancelSubscriptionBanner';
|
||||
@@ -36,10 +42,24 @@ function mockMailto(): {
|
||||
}
|
||||
|
||||
describe('CancelSubscriptionBanner', () => {
|
||||
beforeEach(() => {
|
||||
server.use(setupAuthzAdmin());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('disables Cancel Subscription when subscription delete is denied', async () => {
|
||||
server.use(setupAuthzDeny(SubscriptionDeletePermission));
|
||||
render(<CancelSubscriptionBanner />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('cancel-subscription-btn')).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders banner with title and subtitle', () => {
|
||||
render(<CancelSubscriptionBanner />);
|
||||
expect(
|
||||
@@ -56,9 +76,10 @@ describe('CancelSubscriptionBanner', () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<CancelSubscriptionBanner />);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /cancel subscription/i }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('cancel-subscription-btn')).toBeEnabled();
|
||||
});
|
||||
await user.click(screen.getByTestId('cancel-subscription-btn'));
|
||||
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
expect(
|
||||
@@ -76,9 +97,10 @@ describe('CancelSubscriptionBanner', () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<CancelSubscriptionBanner />);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /cancel subscription/i }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('cancel-subscription-btn')).toBeEnabled();
|
||||
});
|
||||
await user.click(screen.getByTestId('cancel-subscription-btn'));
|
||||
|
||||
const confirmButton = screen.getByTestId('cancel-subscription-confirm-btn');
|
||||
expect(confirmButton).toBeDisabled();
|
||||
@@ -95,9 +117,10 @@ describe('CancelSubscriptionBanner', () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<CancelSubscriptionBanner />);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /cancel subscription/i }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('cancel-subscription-btn')).toBeEnabled();
|
||||
});
|
||||
await user.click(screen.getByTestId('cancel-subscription-btn'));
|
||||
|
||||
const input = screen.getByTestId('cancel-confirm-input');
|
||||
await user.type(input, 'cancel');
|
||||
@@ -107,9 +130,10 @@ describe('CancelSubscriptionBanner', () => {
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument(),
|
||||
);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /cancel subscription/i }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('cancel-subscription-btn')).toBeEnabled();
|
||||
});
|
||||
await user.click(screen.getByTestId('cancel-subscription-btn'));
|
||||
expect(screen.getByTestId('cancel-confirm-input')).toHaveValue('');
|
||||
});
|
||||
|
||||
@@ -119,9 +143,10 @@ describe('CancelSubscriptionBanner', () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<CancelSubscriptionBanner />);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /cancel subscription/i }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('cancel-subscription-btn')).toBeEnabled();
|
||||
});
|
||||
await user.click(screen.getByTestId('cancel-subscription-btn'));
|
||||
await user.type(screen.getByTestId('cancel-confirm-input'), 'cancel');
|
||||
await user.click(screen.getByTestId('cancel-subscription-confirm-btn'));
|
||||
|
||||
@@ -151,9 +176,10 @@ describe('CancelSubscriptionBanner', () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<CancelSubscriptionBanner />);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /cancel subscription/i }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('cancel-subscription-btn')).toBeEnabled();
|
||||
});
|
||||
await user.click(screen.getByTestId('cancel-subscription-btn'));
|
||||
await user.type(screen.getByTestId('cancel-confirm-input'), 'cancel');
|
||||
await user.click(screen.getByTestId('cancel-subscription-confirm-btn'));
|
||||
|
||||
@@ -172,9 +198,10 @@ describe('CancelSubscriptionBanner', () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<CancelSubscriptionBanner />);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /cancel subscription/i }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('cancel-subscription-btn')).toBeEnabled();
|
||||
});
|
||||
await user.click(screen.getByTestId('cancel-subscription-btn'));
|
||||
await user.type(screen.getByTestId('cancel-confirm-input'), 'cancel');
|
||||
await user.click(screen.getByTestId('cancel-subscription-confirm-btn'));
|
||||
|
||||
@@ -192,9 +219,10 @@ describe('CancelSubscriptionBanner', () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<CancelSubscriptionBanner />);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /cancel subscription/i }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('cancel-subscription-btn')).toBeEnabled();
|
||||
});
|
||||
await user.click(screen.getByTestId('cancel-subscription-btn'));
|
||||
await user.type(screen.getByTestId('cancel-confirm-input'), 'cancel');
|
||||
await user.click(screen.getByTestId('cancel-subscription-confirm-btn'));
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ import { Button } from '@signozhq/ui/button';
|
||||
import { DialogWrapper } from '@signozhq/ui/dialog';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
|
||||
import { SubscriptionDeletePermission } from 'lib/authz/hooks/useAuthZ/permissions/subscription.permissions';
|
||||
import { pick } from 'lodash-es';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
@@ -178,15 +180,17 @@ function CancelSubscriptionBanner(): JSX.Element {
|
||||
immediately and removed from our servers.
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
<AuthZButton
|
||||
checks={[SubscriptionDeletePermission]}
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
prefix={<X size={12} />}
|
||||
onClick={handleOpenCancelDialog}
|
||||
className={styles.cancelButton}
|
||||
testId="cancel-subscription-btn"
|
||||
>
|
||||
Cancel Subscription
|
||||
</Button>
|
||||
</AuthZButton>
|
||||
</div>
|
||||
<DialogWrapper
|
||||
open={dialogView !== null}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
@@ -243,10 +244,11 @@ function Hosts(): JSX.Element {
|
||||
</Tooltip>
|
||||
</div>
|
||||
<QuickFilters
|
||||
source={QuickFiltersSource.INFRA_MONITORING}
|
||||
pageSource={QuickFiltersSource.INFRA_MONITORING}
|
||||
config={getHostsQuickFiltersConfig()}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
useFieldApis={{
|
||||
signal: TelemetrytypesSignalDTO.metrics,
|
||||
metricNamespace:
|
||||
METRIC_NAMESPACE_BY_ENTITY[InfraMonitoringEntity.HOSTS],
|
||||
startUnixMilli,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import * as Sentry from '@sentry/react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
@@ -89,6 +90,7 @@ export default function InfraMonitoringK8s(): JSX.Element {
|
||||
|
||||
const getUseFieldApis = useCallback(
|
||||
(entity: InfraMonitoringEntity): QuickFilterCheckboxUseFieldApis => ({
|
||||
signal: TelemetrytypesSignalDTO.metrics,
|
||||
metricNamespace: METRIC_NAMESPACE_BY_ENTITY[entity],
|
||||
startUnixMilli,
|
||||
endUnixMilli,
|
||||
@@ -319,7 +321,7 @@ export default function InfraMonitoringK8s(): JSX.Element {
|
||||
</div>
|
||||
{selectedCategoryConfig && (
|
||||
<QuickFilters
|
||||
source={QuickFiltersSource.INFRA_MONITORING}
|
||||
pageSource={QuickFiltersSource.INFRA_MONITORING}
|
||||
config={selectedCategoryConfig}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
useFieldApis={selectedCategoryUseFieldApis}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
.container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.3rem;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.optionsTrigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { memo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Settings } from '@signozhq/icons';
|
||||
import FieldsSelector from 'components/FieldsSelector';
|
||||
import Controls, { ControlsProps } from 'container/Controls';
|
||||
import { OptionsMenuConfig } from 'container/OptionsMenu/types';
|
||||
import useQueryPagination from 'hooks/queryPagination/useQueryPagination';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import styles from './Controls.module.scss';
|
||||
|
||||
function TraceExplorerControls({
|
||||
isLoading,
|
||||
totalCount,
|
||||
perPageOptions,
|
||||
config,
|
||||
showSizeChanger = true,
|
||||
}: TraceExplorerControlsProps): JSX.Element | null {
|
||||
const { t } = useTranslation(['trace']);
|
||||
const [isFieldsSelectorOpen, setIsFieldsSelectorOpen] = useState(false);
|
||||
|
||||
const {
|
||||
pagination,
|
||||
handleCountItemsPerPageChange,
|
||||
handleNavigateNext,
|
||||
handleNavigatePrevious,
|
||||
} = useQueryPagination(totalCount, perPageOptions);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{config?.fieldsSelector && (
|
||||
<>
|
||||
<div
|
||||
className={styles.optionsTrigger}
|
||||
onClick={(): void => setIsFieldsSelectorOpen(true)}
|
||||
>
|
||||
{t('options_menu.options')}
|
||||
<Settings size="md" />
|
||||
</div>
|
||||
<FieldsSelector
|
||||
isOpen={isFieldsSelectorOpen}
|
||||
title="Edit columns"
|
||||
fields={config.fieldsSelector.value}
|
||||
onFieldsChange={config.fieldsSelector.onFieldsChange}
|
||||
onClose={(): void => setIsFieldsSelectorOpen(false)}
|
||||
signal={DataSource.TRACES}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Controls
|
||||
isLoading={isLoading}
|
||||
totalCount={totalCount}
|
||||
offset={pagination.offset}
|
||||
countPerPage={pagination.limit}
|
||||
perPageOptions={perPageOptions}
|
||||
handleCountItemsPerPageChange={handleCountItemsPerPageChange}
|
||||
handleNavigateNext={handleNavigateNext}
|
||||
handleNavigatePrevious={handleNavigatePrevious}
|
||||
showSizeChanger={showSizeChanger}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
TraceExplorerControls.defaultProps = {
|
||||
config: null,
|
||||
};
|
||||
|
||||
type TraceExplorerControlsProps = Pick<
|
||||
ControlsProps,
|
||||
'isLoading' | 'totalCount' | 'perPageOptions'
|
||||
> & {
|
||||
config?: OptionsMenuConfig | null;
|
||||
showSizeChanger?: boolean;
|
||||
};
|
||||
|
||||
TraceExplorerControls.defaultProps = {
|
||||
showSizeChanger: true,
|
||||
};
|
||||
|
||||
export default memo(TraceExplorerControls);
|
||||
@@ -11,17 +11,13 @@ import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
|
||||
import WarningPopover from 'components/WarningPopover/WarningPopover';
|
||||
import { AVAILABLE_EXPORT_PANEL_TYPES } from 'constants/panelTypes';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { initialQueryAIWithType, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { usePageActions } from 'container/AIAssistant/pageActions/usePageActions';
|
||||
import ExplorerOptionWrapper from 'container/ExplorerOptions/ExplorerOptionWrapper';
|
||||
import { useOptionsMenu } from 'container/OptionsMenu';
|
||||
import LeftToolbarActions from 'container/QueryBuilder/components/ToolbarActions/LeftToolbarActions';
|
||||
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
|
||||
import Toolbar from 'container/Toolbar/Toolbar';
|
||||
import {
|
||||
getExportQueryData,
|
||||
getQueryByPanelType,
|
||||
} from 'container/TracesExplorer/explorerUtils';
|
||||
import { ExportDashboard } from 'hooks/dashboard/useExportDashboards';
|
||||
import { useGetExportToDashboardLink } from 'hooks/dashboard/useGetExportToDashboardLink';
|
||||
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
|
||||
@@ -52,6 +48,7 @@ import {
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import { TOOLBAR_VIEWS } from './constants';
|
||||
import { getExportQueryData, getQueryByPanelType } from './explorerUtils';
|
||||
import ListView from './ListView/ListView';
|
||||
import { defaultSelectedColumns } from './ListView/configs';
|
||||
import QuerySection from './QuerySection/QuerySection';
|
||||
@@ -118,7 +115,7 @@ function Explorer(): JSX.Element {
|
||||
const defaultQuery = useMemo(
|
||||
(): Query =>
|
||||
updateAllQueriesOperators(
|
||||
initialQueriesMap.traces,
|
||||
initialQueryAIWithType,
|
||||
PANEL_TYPES.LIST,
|
||||
DataSource.TRACES,
|
||||
),
|
||||
@@ -185,7 +182,7 @@ function Explorer(): JSX.Element {
|
||||
const exportDefaultQuery = useMemo(
|
||||
() =>
|
||||
getQueryByPanelType(
|
||||
stagedQuery || initialQueriesMap.traces,
|
||||
stagedQuery || initialQueryAIWithType,
|
||||
panelType || PANEL_TYPES.LIST,
|
||||
),
|
||||
[stagedQuery, panelType],
|
||||
@@ -263,8 +260,8 @@ function Explorer(): JSX.Element {
|
||||
<Card className="filter" hidden={!isOpen}>
|
||||
<QuickFilters
|
||||
className="qf-traces-explorer"
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
signal={SignalType.TRACES}
|
||||
pageSource={QuickFiltersSource.TRACES_EXPLORER}
|
||||
quickFilterSignal={SignalType.TRACES}
|
||||
handleFilterVisibilityChange={(): void => {
|
||||
setOpen(!isOpen);
|
||||
}}
|
||||
|
||||
@@ -17,12 +17,11 @@ import ListViewOrderBy from 'components/OrderBy/ListViewOrderBy';
|
||||
import type { TableColumnDef } from 'components/TanStackTableView/types';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { initialQueryAIWithType, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { useOptionsMenu } from 'container/OptionsMenu';
|
||||
import { CustomTimeType } from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import TraceExplorerControls from 'container/TracesExplorer/Controls';
|
||||
import { getListViewQuery } from 'container/TracesExplorer/explorerUtils';
|
||||
import {
|
||||
getTraceLink,
|
||||
transformSpanRows,
|
||||
@@ -43,6 +42,7 @@ import { Warning } from 'types/api';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import { getListViewQuery } from '../explorerUtils';
|
||||
import {
|
||||
defaultSelectedColumns,
|
||||
PER_PAGE_OPTIONS,
|
||||
@@ -94,7 +94,7 @@ function ListView({
|
||||
paginationQueryData ?? getDefaultPaginationConfig(PER_PAGE_OPTIONS);
|
||||
|
||||
const requestQuery = useMemo(
|
||||
() => getListViewQuery(stagedQuery || initialQueriesMap.traces, orderBy),
|
||||
() => getListViewQuery(stagedQuery || initialQueryAIWithType, orderBy),
|
||||
[stagedQuery, orderBy],
|
||||
);
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user