Compare commits

...

47 Commits

Author SHA1 Message Date
Yunus M
f004293bcf chore: fmt ListLogView.styles.scss 2025-12-22 16:01:08 +05:30
Vinícius Lourenço
917c7be6c8 perf: remove typography.text that was causing style recalculation during scroll 2025-12-22 14:36:26 +05:30
Abhi kumar
edcae53b64 fix: added fix for quick filter reset not working (#9824)
* fix: added fix for quick filter reset not working

* chore: added fix for quick filter reset issue

* chore: removed non-required code

* test: added tests for checkbox component

* test: added updated test for querysearch

* chore: updated querysearch test
2025-12-19 18:34:51 +05:30
Vikrant Gupta
72fda90ec2 fix(apikey): batch last seen sql update for api-key middleware (#9833)
* fix(apikey): batch last seen sql update for api-key middleware

* fix(apikey): remove debug statement

* fix(apikey): remove debug statement
2025-12-19 14:56:33 +05:30
Yunus M
8acfc3c9f7 Update CODEOWNERS (#9828)
Update codeowners for frontend repo from individuals to frontend-maintainers team
2025-12-18 23:51:36 +05:30
Shaheer Kochai
463ae443f9 feat: add support for truncating long status_message in trace details and showing expandable popover on hover (#9630)
* feat: add support for showing truncated status_message and showing expandable popover on hover

* test: add tests for status message truncation and expandable popover functionality

* test: update expand button interaction to use fireEvent for status message modal
2025-12-18 15:18:40 +00:00
Abhi kumar
f72535a15f fix: added fix for prefix units not rendering in value panel (#9750)
* fix: added fix for prefix units not rendering in value panel

* chore: updated snapshot for valuepanelwrapper

* chore: added changes to stop unit from recomputation

* chore: added support for scientific notation as well

* chore: pr comments fixes
2025-12-18 12:32:23 +00:00
Abhi kumar
e21e99ce64 fix: clear search term when closing widget header search (#9663)
* fix: clear search term when closing widget header search

* test: added test for widgetheader component

* fix: added fixes for pr comments

* chore: updated test to use mock antd

---------

Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
2025-12-18 12:22:12 +00:00
Abhi kumar
d1559a3262 test: added test for testing codemirror state when switching tabs (#9766)
* test: added test for testing codemirror state when switching tabs

* chore: added test for verify query-add-on and query-aggregation being rendered
2025-12-18 12:11:57 +00:00
Abhi kumar
1ccb9bb4c2 fix: added fix for free text with quick filter issue (#9768)
* fix: added fix free text with quick filter issue

* chore: removed debug console log

* chore: pr review changes
2025-12-18 12:00:08 +00:00
Vikrant Gupta
0c059df327 feat(global): add global config support (#9826)
* feat(global): add global config support

* feat(global): revert factory name changes

* feat(global): add global config support
2025-12-18 11:40:37 +00:00
Nikhil Mantri
8a5539679c chore(metrics-explorer): API for the alerts with metric_name (#9640) 2025-12-18 10:10:51 +00:00
Ashwin Bhatkal
89b188f73d fix: unable to edit panels when dashboard path has trailing slash (#9816)
* fix: unable to edit panels when dashboard path has trailing slash

* fix: name better

* fix: add tests

* fix: resolve comment

* fix: itch - variable rename

* fix: typo
2025-12-18 09:42:43 +05:30
Pandey
bb4d6117ac test: add integration tests for preferences and add --with-web flag (#9821)
* test: add integration test for preferences

* test: add flag --with-web
2025-12-18 00:05:27 +05:30
primus-bot[bot]
1110864549 chore(release): bump to v0.105.1 (#9819)
Co-authored-by: primus-bot[bot] <171087277+primus-bot[bot]@users.noreply.github.com>
2025-12-17 14:17:54 +05:30
Vikrant Gupta
5cb515cade fix(apiserver): preference routes registeration before user routes (#9818) 2025-12-17 14:01:28 +05:30
primus-bot[bot]
41d5f6a00c chore(release): bump to v0.105.0 (#9815)
Co-authored-by: primus-bot[bot] <171087277+primus-bot[bot]@users.noreply.github.com>
2025-12-17 12:33:56 +05:30
shubham-signoz
61ec1ef28e chore(onboardingv2/config): python traces changes (#9808)
Signed-off-by: Shubham Dubey <shubham@signoz.io>
Co-authored-by: Vishal Sharma <makeavish786@gmail.com>
2025-12-17 08:35:53 +05:30
Yunus M
529a9e7009 fix: handle default columns in logs and traces explorer (#9722)
* fix: handle default columns in logs and traces explorer

* fix: filter out selected columns based on signal in logs and traces explorer
2025-12-16 13:32:18 +05:30
Nikhil Mantri
b00687b43f chore(metrics-explorer): API for the dashboards with metric_name (#9638) 2025-12-16 12:08:00 +05:30
Pandey
8771919de6 feat(gen): add cobra command for generating openapi spec (#9803)
add cobra command for auto-generating openapi spec
2025-12-15 17:48:30 +05:30
Nikhil Mantri
497972f23c chore(metrics-explorer): address follow-up comments (#9730) 2025-12-15 14:59:30 +05:30
swapnil-signoz
a9e30919d1 Refactor/aws api gateway dashboard (#9763)
* refactor: updating api gateway dashboard to support multiple types of APIs i.e. REST, HTTP and Websocket.
2025-12-15 14:34:39 +05:30
Ishan
925c4c4a3d fix: UI/UX fixes on Global Actions (CMD / CTRL + K) (#9739)
* feat: command K palette , removed kbar

* chore: updated cmdk for login checks and icons

* feat: updated icons and test cases

* adding more llm monitoring sources to onbaording(frontend) (#9623)

* feat: code update, PR comment fix, package.json update

* feat: code update, removed expand icon, moved keyboard func

* feat: css variable update

* feat: removed kbar from applayout

* feat: updated cursor bot comments

* feat: updated cursor bot and test case file

* feat: scss formatted

* feat: deleted unwanted merge change

---------

Co-authored-by: gkarthi-signoz <goutham@signoz.io>
Co-authored-by: Aditya Singh <adityasinghssj1@gmail.com>
2025-12-15 08:41:00 +05:30
Piyush Singariya
e66bfe5961 feat(JSON): JSON Body Metadata (#9593)
* feat: json Body Keys

* feat: telemetry types

* feat: change ExtractBodyPaths

* chore: minor comment change

* chore: func rename, file rename

* chore: change table names

* chore: reflect changes from the overhaul

* test: fixing test 1

* fix: test TestQueryToKeys

* fix: test TestPrepareLogsQuery

* chore: remove db

* chore: go mod

* chore: changes based on review

* chore: changes based on review

* fix: in LIKE operation

* chore: addressed few changes

* revert: test file

* fix: comparison fix

* test: add TestBuildListLogsJSONIndexesQuery

* fix: in test TestBuildListLogsJSONIndexesQuery

* fix: pull promoted paths in single db call

* fix: reducing db calls

* test: fix TestBuildListLogsJSONIndexesQuery

* fix: test TestConditionForJSONBodySearch

* fix: lint try 1

* chore: review changes based on cursor

* fix: use enums only

---------

Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
Co-authored-by: Nityananda Gohain <nityanandagohain@gmail.com>
2025-12-09 20:47:26 +07:00
Nikhil Mantri
42943f72b7 chore(metric-metadata): do not delete rows, keep inserting, pick latest 2025-12-09 05:46:48 +00:00
Nikhil Mantri
7a72a209e5 chore: metric highlights API for detailed view (#9679) 2025-12-09 00:09:23 +05:30
Karan Balani
44f00943a8 fix: tokenizer cache ttls and guardrails for config (#9776) 2025-12-05 11:01:05 +05:30
Nikhil Mantri
8867e1ef38 chore: metric Metadata Seperate Attributes API (#9622) 2025-12-04 19:38:37 +00:00
Abhi kumar
c08e520941 chore: removed alertRuleProvider from global state (#9648) 2025-12-04 10:27:50 +00:00
Ishan
139cc4452d style: metrics custom function css overflow issues (#9660)
* style: metrics custom function css overflow issues

* feat: add support for recovery threshold (#9428)

* feat: created common component for overflowing input tooltip

* feat: updated function.tsx to have useMemo for debounce

* feat: removed unwanted useEffect and moved inline css to separate file

* feat: re-applied useEffect due to css issues

* feat: removed inline styling

* feat: updated mirror ref to be in common component along with css updates

* feat: reverted prom_rule.go

* feat: code cleanup - input ref/forwardref cleanup

* feat: code cleanup - updated test file

* feat: extracted mirror-ref outside of tooltip

* feat: removed unwanted css

* feat: code optmized

* feat: test file updated

* feat: snapshot update

---------

Co-authored-by: Ishan Uniyal <ishan@Ishans-MacBook-Pro.local>
Co-authored-by: Abhishek Kumar Singh <hritik6058@gmail.com>
2025-12-04 15:04:39 +05:30
Nityananda Gohain
2f3baeb302 fix: fix third party api filtering (#9770) 2025-12-03 23:36:05 +05:30
Abhishek Kumar Singh
3d42b0058e chore: Query filter extraction API (#9617) 2025-12-03 13:13:32 +00:00
Srikanth Chekuri
ed70e3c5f5 chore: update .github/CODEOWNERS (#9759) 2025-12-03 11:06:30 +00:00
Ishan
7d6918f8b6 style: updated subdomain already exists UI error on 409 (#9661)
* style: updated subdomain already exists UI error on 409

* style: updated subdomain default message

---------

Co-authored-by: Ishan Uniyal <ishan@Ishans-MacBook-Pro.local>
2025-12-03 15:12:44 +05:30
primus-bot[bot]
2885bc851e chore(release): bump to v0.104.0 (#9765)
Co-authored-by: primus-bot[bot] <171087277+primus-bot[bot]@users.noreply.github.com>
2025-12-03 12:16:07 +05:30
Vishal Sharma
857258f8c3 feat: expand related search keywords (#9728)
* feat: expand related search keywords and add React Native option to onboarding configurations

* feat: enhance onboarding data source configurations
With nested questions for migration and log collection, and add new related logos.

* chore: update React Native doc links to absolute URLs and remove 404

* feat: revert datasource changes
2025-12-03 06:07:57 +00:00
Yunus M
ece5c2b7ad fix: error details container - add padding and improve typography of title and desc (#9753)
* fix: add padding to error details container

* fix: update typography of title and description
2025-12-03 05:47:30 +00:00
Shaheer Kochai
1078f98388 feat: add support for copying individual JSON tree nodes in log details (#9657)
* feat: implement copy functionality for individual JSON tree nodes in log details

* chore: add tests for individual json tree nodes in log details

* test: enhance copy button tests for BodyTitleRenderer

* feat: add support for copying any node in json tree in log details

* test: update BodyTitleRenderer tests to verify copy functionality for JSON tree nodes
2025-12-03 02:50:02 +00:00
Abhi kumar
b4e2326f38 fix: added y-axis unit selector in traces view (#9761) 2025-12-03 00:43:58 +05:30
Abhi kumar
c79b154215 fix: added fix for duplicate y-axis selector in metrics explorer (#9758) 2025-12-02 23:45:48 +05:30
Yunus M
a59c0188cc feat: enable / revoke public access to a dashboard (#9642) 2025-12-02 22:30:24 +05:30
Shaheer Kochai
3df426625a fix: remove isRoot and isEntrypoint from the list of selectable columns in the columns menu in traces explorer and traces list panel in dashboard (#9629)
* fix: hide isRoot and isEntryPoint options from columns options

* test: add tests to ensure isRoot and isEntryPoint are hidden in column options

* refactor: improve the columns exclusion logic + update test
2025-12-02 16:20:04 +00:00
Karan Balani
646f359f33 feat: add openfga instrumentation configuration (#9754) 2025-12-02 15:28:42 +00:00
Vikrant Gupta
81167c6947 fix(dashboard): send public dashboard id on create (#9755) 2025-12-02 19:57:10 +05:30
Karan Balani
bc1295b93a feat: trace span for binary marshal and unmarshal operations (#9740) 2025-12-02 10:43:11 +00:00
swapnil-signoz
3db0e1f66a feat: enhanced container insights dashboard (#9742)
* feat: adding enhanced container insights dashboard for task level ECS metrics
2025-12-02 14:41:24 +05:30
255 changed files with 22881 additions and 4132 deletions

42
.github/CODEOWNERS vendored
View File

@@ -2,52 +2,12 @@
# Owners are automatically requested for review for PRs that changes code
# that they own.
/frontend/ @YounixM @aks07
/frontend/src/container/MetricsApplication @srikanthccv
/frontend/src/container/NewWidget/RightContainer/types.ts @srikanthccv
/frontend/ @SigNoz/frontend-maintainers
# Onboarding
/frontend/src/container/OnboardingV2Container/onboarding-configs/onboarding-config-with-links.json @makeavish
/frontend/src/container/OnboardingV2Container/AddDataSource/AddDataSource.tsx @makeavish
# Dashboard, Alert, Metrics, Service Map, Services
/frontend/src/container/ListOfDashboard/ @srikanthccv
/frontend/src/container/NewDashboard/ @srikanthccv
/frontend/src/pages/DashboardsListPage/ @srikanthccv
/frontend/src/pages/DashboardWidget/ @srikanthccv
/frontend/src/pages/NewDashboard/ @srikanthccv
/frontend/src/providers/Dashboard/ @srikanthccv
# Alerts
/frontend/src/container/AlertHistory/ @srikanthccv
/frontend/src/container/AllAlertChannels/ @srikanthccv
/frontend/src/container/AnomalyAlertEvaluationView/ @srikanthccv
/frontend/src/container/CreateAlertChannels/ @srikanthccv
/frontend/src/container/CreateAlertRule/ @srikanthccv
/frontend/src/container/EditAlertChannels/ @srikanthccv
/frontend/src/container/FormAlertChannels/ @srikanthccv
/frontend/src/container/FormAlertRules/ @srikanthccv
/frontend/src/container/ListAlertRules/ @srikanthccv
/frontend/src/container/TriggeredAlerts/ @srikanthccv
/frontend/src/pages/AlertChannelCreate/ @srikanthccv
/frontend/src/pages/AlertDetails/ @srikanthccv
/frontend/src/pages/AlertHistory/ @srikanthccv
/frontend/src/pages/AlertList/ @srikanthccv
/frontend/src/pages/CreateAlert/ @srikanthccv
/frontend/src/providers/Alert.tsx @srikanthccv
# Metrics
/frontend/src/container/MetricsExplorer/ @srikanthccv
/frontend/src/pages/MetricsApplication/ @srikanthccv
/frontend/src/pages/MetricsExplorer/ @srikanthccv
# Services and Service Map
/frontend/src/container/ServiceApplication/ @srikanthccv
/frontend/src/container/ServiceTable/ @srikanthccv
/frontend/src/pages/Services/ @srikanthccv
/frontend/src/pages/ServiceTopLevelOperations/ @srikanthccv
/frontend/src/container/Home/Services/ @srikanthccv
/deploy/ @SigNoz/devops
.github @SigNoz/devops

View File

@@ -73,3 +73,19 @@ jobs:
shell: bash
run: |
make docker-build-enterprise
openapi:
if: |
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))
runs-on: ubuntu-latest
steps:
- name: self-checkout
uses: actions/checkout@v4
- name: go-install
uses: actions/setup-go@v5
with:
go-version: "1.24"
- name: generate-openapi
run: |
go run cmd/enterprise/*.go generate openapi
git diff --compact-summary --exit-code || (echo; echo "Unexpected difference in openapi spec. Run go run cmd/enterprise/*.go generate openapi locally and commit."; exit 1)

View File

@@ -9,6 +9,29 @@ on:
- labeled
jobs:
fmtlint:
if: |
((github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))) && contains(github.event.pull_request.labels.*.name, 'safe-to-integrate')
runs-on: ubuntu-latest
steps:
- name: checkout
uses: actions/checkout@v4
- name: python
uses: actions/setup-python@v5
with:
python-version: 3.13
- name: poetry
run: |
python -m pip install poetry==2.1.2
python -m poetry config virtualenvs.in-project true
cd tests/integration && poetry install --no-root
- name: fmt
run: |
make py-fmt
- name: lint
run: |
make py-lint
test:
strategy:
fail-fast: false
@@ -21,6 +44,7 @@ jobs:
- dashboard
- querier
- ttl
- preference
sqlstore-provider:
- postgres
- sqlite

View File

@@ -13,6 +13,7 @@ func main() {
// register a list of commands to the root command
registerServer(cmd.RootCmd, logger)
cmd.RegisterGenerate(cmd.RootCmd, logger)
cmd.Execute(logger)
}

View File

@@ -1,11 +1,3 @@
FROM node:18-bullseye AS build
WORKDIR /opt/
COPY ./frontend/ ./
ENV NODE_OPTIONS=--max-old-space-size=8192
RUN CI=1 yarn install
RUN CI=1 yarn build
FROM golang:1.24-bullseye
ARG OS="linux"
@@ -40,8 +32,6 @@ COPY Makefile Makefile
RUN TARGET_DIR=/root ARCHS=${TARGETARCH} ZEUS_URL=${ZEUSURL} LICENSE_URL=${ZEUSURL}/api/v1 make go-build-enterprise-race
RUN mv /root/linux-${TARGETARCH}/signoz /root/signoz
COPY --from=build /opt/build ./web/
RUN chmod 755 /root /root/signoz
ENTRYPOINT ["/root/signoz", "server"]

View File

@@ -0,0 +1,47 @@
FROM node:18-bullseye AS build
WORKDIR /opt/
COPY ./frontend/ ./
ENV NODE_OPTIONS=--max-old-space-size=8192
RUN CI=1 yarn install
RUN CI=1 yarn build
FROM golang:1.24-bullseye
ARG OS="linux"
ARG TARGETARCH
ARG ZEUSURL
# This path is important for stacktraces
WORKDIR $GOPATH/src/github.com/signoz/signoz
WORKDIR /root
RUN set -eux; \
apt-get update; \
apt-get install -y --no-install-recommends \
g++ \
gcc \
libc6-dev \
make \
pkg-config \
; \
rm -rf /var/lib/apt/lists/*
COPY go.mod go.sum ./
RUN go mod download
COPY ./cmd/ ./cmd/
COPY ./ee/ ./ee/
COPY ./pkg/ ./pkg/
COPY ./templates/email /root/templates
COPY Makefile Makefile
RUN TARGET_DIR=/root ARCHS=${TARGETARCH} ZEUS_URL=${ZEUSURL} LICENSE_URL=${ZEUSURL}/api/v1 make go-build-enterprise-race
RUN mv /root/linux-${TARGETARCH}/signoz /root/signoz
COPY --from=build /opt/build ./web/
RUN chmod 755 /root /root/signoz
ENTRYPOINT ["/root/signoz", "server"]

View File

@@ -13,6 +13,7 @@ func main() {
// register a list of commands to the root command
registerServer(cmd.RootCmd, logger)
cmd.RegisterGenerate(cmd.RootCmd, logger)
cmd.Execute(logger)
}

21
cmd/generate.go Normal file
View File

@@ -0,0 +1,21 @@
package cmd
import (
"log/slog"
"github.com/spf13/cobra"
)
func RegisterGenerate(parentCmd *cobra.Command, logger *slog.Logger) {
var generateCmd = &cobra.Command{
Use: "generate",
Short: "Generate artifacts",
SilenceUsage: true,
SilenceErrors: true,
CompletionOptions: cobra.CompletionOptions{DisableDefaultCmd: true},
}
registerGenerateOpenAPI(generateCmd)
parentCmd.AddCommand(generateCmd)
}

41
cmd/openapi.go Normal file
View File

@@ -0,0 +1,41 @@
package cmd
import (
"context"
"log/slog"
"github.com/SigNoz/signoz/pkg/instrumentation"
"github.com/SigNoz/signoz/pkg/signoz"
"github.com/SigNoz/signoz/pkg/version"
"github.com/spf13/cobra"
)
func registerGenerateOpenAPI(parentCmd *cobra.Command) {
openapiCmd := &cobra.Command{
Use: "openapi",
Short: "Generate OpenAPI schema for SigNoz",
RunE: func(currCmd *cobra.Command, args []string) error {
return runGenerateOpenAPI(currCmd.Context())
},
}
parentCmd.AddCommand(openapiCmd)
}
func runGenerateOpenAPI(ctx context.Context) error {
instrumentation, err := instrumentation.New(ctx, instrumentation.Config{Logs: instrumentation.LogsConfig{Level: slog.LevelInfo}}, version.Info, "signoz")
if err != nil {
return err
}
openapi, err := signoz.NewOpenAPI(ctx, instrumentation)
if err != nil {
return err
}
if err := openapi.CreateAndWrite("docs/api/openapi.yml"); err != nil {
return err
}
return nil
}

View File

@@ -3,6 +3,13 @@
# Do not modify this file
#
##################### Global #####################
global:
# the url under which the signoz apiserver is externally reachable.
external_url: <unset>
# the url where the SigNoz backend receives telemetry data (traces, metrics, logs) from instrumented applications.
ingestion_url: <unset>
##################### Version #####################
version:
banner:

View File

@@ -176,7 +176,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.103.1
image: signoz/signoz:v0.105.1
command:
- --config=/root/config/prometheus.yml
ports:

View File

@@ -117,7 +117,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.103.1
image: signoz/signoz:v0.105.1
command:
- --config=/root/config/prometheus.yml
ports:

View File

@@ -179,7 +179,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.103.1}
image: signoz/signoz:${VERSION:-v0.105.1}
container_name: signoz
command:
- --config=/root/config/prometheus.yml

View File

@@ -111,7 +111,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.103.1}
image: signoz/signoz:${VERSION:-v0.105.1}
container_name: signoz
command:
- --config=/root/config/prometheus.yml

2343
docs/api/openapi.yml Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,179 @@
# Handler
Handlers in SigNoz are responsible for exposing module functionality over HTTP. They are thin adapters that:
- Decode incoming HTTP requests
- Call the appropriate module layer
- Return structured responses (or errors) in a consistent format
- Describe themselves for OpenAPI generation
They are **not** the place for complex business logic; that belongs in modules (for example, `pkg/modules/user`, `pkg/modules/session`, etc).
## How are handlers structured?
At a high level, a typical flow looks like this:
1. A `Handler` interface is defined in the module (for example, `user.Handler`, `session.Handler`, `organization.Handler`).
2. The `apiserver` provider wires those handlers into HTTP routes using Gorilla `mux.Router`.
Each route wraps a module handler method with the following:
- Authorization middleware (from `pkg/http/middleware`)
- A generic HTTP `handler.Handler` (from `pkg/http/handler`)
- An `OpenAPIDef` that describes the operation for OpenAPI generation
For example, in `pkg/apiserver/signozapiserver`:
```go
if err := router.Handle("/api/v1/invite", handler.New(
provider.authZ.AdminAccess(provider.userHandler.CreateInvite),
handler.OpenAPIDef{
ID: "CreateInvite",
Tags: []string{"users"},
Summary: "Create invite",
Description: "This endpoint creates an invite for a user",
Request: new(types.PostableInvite),
RequestContentType: "application/json",
Response: new(types.Invite),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
},
)).Methods(http.MethodPost).GetError(); err != nil {
return err
}
```
In this pattern:
- `provider.userHandler.CreateInvite` is a handler method.
- `provider.authZ.AdminAccess(...)` wraps that method with authorization checks and context setup.
- `handler.New` converts it into an HTTP handler and wires it to OpenAPI via the `OpenAPIDef`.
## How to write a new handler method?
When adding a new endpoint:
1. Add a method to the appropriate module `Handler` interface.
2. Implement that method in the module.
3. Register the method in `signozapiserver` with the correct route, HTTP method, auth, and `OpenAPIDef`.
### 1. Extend an existing `Handler` interface or create a new one
Find the module in `pkg/modules/<name>` and extend its `Handler` interface with a new method that receives an `http.ResponseWriter` and `*http.Request`. For example:
```go
type Handler interface {
// existing methods...
CreateThing(rw http.ResponseWriter, req *http.Request)
}
```
Keep the method focused on HTTP concerns and delegate business logic to the module.
### 2. Implement the handler method
In the module implementation, implement the new method. A typical implementation:
- Extracts authentication and organization context from `req.Context()`
- Decodes the request body into a `types.*` struct using the `binding` package
- Calls module functions
- Uses the `render` package to write responses or errors
```go
func (h *handler) CreateThing(rw http.ResponseWriter, req *http.Request) {
// Extract authentication and organization context from req.Context()
claims, err := authtypes.ClaimsFromContext(req.Context())
if err != nil {
render.Error(rw, err)
return
}
// Decode the request body into a `types.*` struct using the `binding` package
var in types.PostableThing
if err := binding.JSON.BindBody(req.Body, &in); err != nil {
render.Error(rw, err)
return
}
// Call module functions
out, err := h.module.CreateThing(req.Context(), claims.OrgID, &in)
if err != nil {
render.Error(rw, err)
return
}
// Use the `render` package to write responses or errors
render.Success(rw, http.StatusCreated, out)
}
```
### 3. Register the handler in `signozapiserver`
In `pkg/apiserver/signozapiserver`, add a route in the appropriate `add*Routes` function (`addUserRoutes`, `addSessionRoutes`, `addOrgRoutes`, etc.). The pattern is:
```go
if err := router.Handle("/api/v1/things", handler.New(
provider.authZ.AdminAccess(provider.thingHandler.CreateThing),
handler.OpenAPIDef{
ID: "CreateThing",
Tags: []string{"things"},
Summary: "Create thing",
Description: "This endpoint creates a thing",
Request: new(types.PostableThing),
RequestContentType: "application/json",
Response: new(types.GettableThing),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
},
)).Methods(http.MethodPost).GetError(); err != nil {
return err
}
```
### 4. Update the OpenAPI spec
Run the following command to update the OpenAPI spec:
```bash
go run cmd/enterprise/*.go generate openapi
```
This will update the OpenAPI spec in `docs/api/openapi.yml` to reflect the new endpoint.
## How does OpenAPI integration work?
The `handler.New` function ties the HTTP handler to OpenAPI metadata via `OpenAPIDef`. This drives the generated OpenAPI document.
- **ID**: A unique identifier for the operation (used as the `operationId`).
- **Tags**: Logical grouping for the operation (for example, `"users"`, `"sessions"`, `"orgs"`).
- **Summary / Description**: Human-friendly documentation.
- **Request / RequestContentType**:
- `Request` is a Go type that describes the request body or form.
- `RequestContentType` is usually `"application/json"` or `"application/x-www-form-urlencoded"` (for callbacks like SAML).
- **Response / ResponseContentType**:
- `Response` is the Go type for the successful response payload.
- `ResponseContentType` is usually `"application/json"`; use `""` for responses without a body.
- **SuccessStatusCode**: The HTTP status for successful responses (for example, `http.StatusOK`, `http.StatusCreated`, `http.StatusNoContent`).
- **ErrorStatusCodes**: Additional error status codes beyond the standard ones automatically added by `handler.New`.
- **SecuritySchemes**: Auth mechanisms and scopes required by the operation.
The generic handler:
- Automatically appends `401`, `403`, and `500` to `ErrorStatusCodes` when appropriate.
- Registers request and response schemas with the OpenAPI reflector so they appear in `docs/api/openapi.yml`.
See existing examples in:
- `addUserRoutes` (for typical JSON request/response)
- `addSessionRoutes` (for form-encoded and redirect flows)
## What should I remember?
- **Keep handlers thin**: focus on HTTP concerns and delegate logic to modules/services.
- **Always register routes through `signozapiserver`** using `handler.New` and a complete `OpenAPIDef`.
- **Choose accurate request/response types** from the `types` packages so OpenAPI schemas are correct.

View File

@@ -19,6 +19,7 @@ import (
"github.com/SigNoz/signoz/pkg/query-service/interfaces"
basemodel "github.com/SigNoz/signoz/pkg/query-service/model"
rules "github.com/SigNoz/signoz/pkg/query-service/rules"
"github.com/SigNoz/signoz/pkg/queryparser"
"github.com/SigNoz/signoz/pkg/signoz"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
@@ -60,6 +61,7 @@ func NewAPIHandler(opts APIHandlerOptions, signoz *signoz.SigNoz) (*APIHandler,
FieldsAPI: fields.NewAPI(signoz.Instrumentation.ToProviderSettings(), signoz.TelemetryStore),
Signoz: signoz,
QuerierAPI: querierAPI.NewAPI(signoz.Instrumentation.ToProviderSettings(), signoz.Querier, signoz.Analytics),
QueryParserAPI: queryparser.NewAPI(signoz.Instrumentation.ToProviderSettings(), signoz.QueryParser),
})
if err != nil {
@@ -92,10 +94,6 @@ func (ah *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) {
// routes available only in ee version
router.HandleFunc("/api/v1/features", am.ViewAccess(ah.getFeatureFlags)).Methods(http.MethodGet)
// paid plans specific routes
router.HandleFunc("/api/v1/complete/saml", am.OpenAccess(ah.Signoz.Handlers.Session.CreateSessionBySAMLCallback)).Methods(http.MethodPost)
router.HandleFunc("/api/v1/complete/oidc", am.OpenAccess(ah.Signoz.Handlers.Session.CreateSessionByOIDCCallback)).Methods(http.MethodGet)
// base overrides
router.HandleFunc("/api/v1/version", am.OpenAccess(ah.getVersion)).Methods(http.MethodGet)

View File

@@ -3,13 +3,14 @@ package app
import (
"context"
"fmt"
"log/slog"
"net"
"net/http"
_ "net/http/pprof" // http profiler
"slices"
"github.com/SigNoz/signoz/pkg/cache/memorycache"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/queryparser"
"github.com/SigNoz/signoz/pkg/ruler/rulestore/sqlrulestore"
"go.opentelemetry.io/contrib/instrumentation/github.com/gorilla/mux/otelmux"
"go.opentelemetry.io/otel/propagation"
@@ -106,7 +107,8 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
signoz.Prometheus,
signoz.Modules.OrgGetter,
signoz.Querier,
signoz.Instrumentation.Logger(),
signoz.Instrumentation.ToProviderSettings(),
signoz.QueryParser,
)
if err != nil {
@@ -243,6 +245,11 @@ func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*h
apiHandler.MetricExplorerRoutes(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"},
@@ -253,7 +260,7 @@ func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*h
handler = handlers.CompressHandler(handler)
err := web.AddToRouter(r)
err = web.AddToRouter(r)
if err != nil {
return nil, err
}
@@ -348,8 +355,8 @@ func (s *Server) Stop(ctx context.Context) error {
return nil
}
func makeRulesManager(ch baseint.Reader, cache cache.Cache, alertmanager alertmanager.Alertmanager, sqlstore sqlstore.SQLStore, telemetryStore telemetrystore.TelemetryStore, prometheus prometheus.Prometheus, orgGetter organization.Getter, querier querier.Querier, logger *slog.Logger) (*baserules.Manager, error) {
ruleStore := sqlrulestore.NewRuleStore(sqlstore)
func makeRulesManager(ch baseint.Reader, cache cache.Cache, alertmanager alertmanager.Alertmanager, sqlstore sqlstore.SQLStore, telemetryStore telemetrystore.TelemetryStore, prometheus prometheus.Prometheus, orgGetter organization.Getter, querier querier.Querier, providerSettings factory.ProviderSettings, queryParser queryparser.QueryParser) (*baserules.Manager, error) {
ruleStore := sqlrulestore.NewRuleStore(sqlstore, queryParser, providerSettings)
maintenanceStore := sqlrulestore.NewMaintenanceStore(sqlstore)
// create manager opts
managerOpts := &baserules.ManagerOptions{
@@ -359,7 +366,7 @@ func makeRulesManager(ch baseint.Reader, cache cache.Cache, alertmanager alertma
Logger: zap.L(),
Reader: ch,
Querier: querier,
SLogger: logger,
SLogger: providerSettings.Logger,
Cache: cache,
EvalDelay: baseconst.GetEvalDelay(),
PrepareTaskFunc: rules.PrepareTaskFunc,

View File

@@ -47,6 +47,8 @@
"@signozhq/button": "0.0.2",
"@signozhq/calendar": "0.0.0",
"@signozhq/callout": "0.0.2",
"@signozhq/checkbox": "0.0.2",
"@signozhq/command": "0.0.0",
"@signozhq/design-tokens": "1.1.4",
"@signozhq/input": "0.0.2",
"@signozhq/popover": "0.0.0",
@@ -103,7 +105,6 @@
"i18next-http-backend": "^1.3.2",
"jest": "^27.5.1",
"js-base64": "^3.7.2",
"kbar": "0.1.0-beta.48",
"less": "^4.1.2",
"less-loader": "^10.2.0",
"lodash-es": "^4.17.21",

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="150" height="150" fill="none" viewBox="0 0 150 150"><circle cx="75" cy="75" r="70" fill="#f3f4f6"/><g transform="translate(25 25)"><rect width="60" height="55" fill="#fcd34d" rx="6"/><path stroke="#d97706" stroke-linecap="round" stroke-linejoin="round" stroke-width="4" d="m10 40 15-15 15 8 15-23"/><rect width="35" height="55" x="65" fill="#6ee7b7" rx="6"/><rect width="20" height="6" x="73" y="10" fill="#059669" rx="3"/><rect width="15" height="6" x="73" y="22" fill="#059669" opacity=".7" rx="3"/><rect width="18" height="6" x="73" y="34" fill="#059669" opacity=".7" rx="3"/><rect width="100" height="40" y="60" fill="#a5b4fc" rx="6"/><rect width="80" height="8" x="10" y="70" fill="#4f46e5" rx="4"/><rect width="50" height="8" x="20" y="83" fill="#6366f1" rx="4"/></g></svg>

After

Width:  |  Height:  |  Size: 826 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><path fill="#b31aab" d="m33.172 61.48.176 7.325 7.797 4.785-.176-7.324Zm19.031 30.504-.176-7.18-6.84-4.206c-.085-.059-.203-.145-.289-.203l.176 7.207Zm-24.355 9.688L10.012 90.715l-.438-18.367 8.758-3.746-.172-7.352-13.969 5.969c-1.074.46-1.714 1.441-1.687 2.566l.523 22.055c.032 1.125.73 2.25 1.836 2.941l21.383 13.149c.992.605 2.215.78 3.203.46a.8.8 0 0 0 .29-.113l13.124-5.593-7.129-4.383Zm0 0"/><path fill="#d163ce" d="M85.488 61.047c-.031-1.328-.843-2.625-2.125-3.43L57.38 41.672l-.813.344.176 7.726 20.57 12.63.493 20.648 7.86 4.812.433-.172ZM54.383 97.289 30.262 82.47l-.582-24.883 11-4.7-.203-8.562-17.082 7.293c-1.25.547-2.008 1.672-1.977 3l.668 29.18c.031 1.324.844 2.625 2.125 3.402l28.281 17.387c1.164.723 2.563.894 3.754.547.117-.028.234-.086.348-.145l16.703-7.12-8.32-5.102Zm0 0"/><path fill="#e13eaf" d="M122.234 40.633 85.98 18.343c-1.335-.808-2.937-1.038-4.304-.605-.145.028-.262.086-.406.145l-35.383 15.11c-1.426.605-2.297 1.902-2.27 3.429l.903 37.371c.03 1.527.96 2.996 2.445 3.89l36.254 22.262c1.336.805 2.937 1.035 4.277.606.145-.031.262-.09.406-.145l35.383-15.11c1.426-.605 2.297-1.933 2.27-3.433l-.875-37.367c-.028-1.473-.961-2.969-2.446-3.863M85.398 91.64 53.891 72.293l-.79-32.496 30.727-13.121 31.512 19.347.785 32.497Zm0 0"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.5 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="150" height="150" fill="none" viewBox="0 0 150 150"><circle cx="75" cy="75" r="70" fill="#f3f4f6"/><g transform="translate(25 40)"><rect width="12" height="12" fill="#059669" opacity=".8" rx="3"/><rect width="80" height="12" x="20" fill="#10b981" rx="3"/><rect width="12" height="12" y="22" fill="#059669" opacity=".8" rx="3"/><rect width="65" height="12" x="20" y="22" fill="#10b981" rx="3"/><rect width="12" height="12" y="44" fill="#059669" opacity=".8" rx="3"/><rect width="75" height="12" x="20" y="44" fill="#10b981" rx="3"/><rect width="12" height="12" y="66" fill="#059669" opacity=".8" rx="3"/><rect width="50" height="12" x="20" y="66" fill="#10b981" rx="3"/></g></svg>

After

Width:  |  Height:  |  Size: 726 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="150" height="150" fill="none" viewBox="0 0 150 150"><circle cx="75" cy="75" r="70" fill="#f3f4f6"/><defs><linearGradient id="a" x1="0" x2="0" y1="0" y2="1"><stop offset="0%" stop-color="#f59e0b" stop-opacity=".5"/><stop offset="100%" stop-color="#f59e0b" stop-opacity=".05"/></linearGradient></defs><g transform="translate(25 35)"><path stroke="#d1d5db" stroke-linecap="round" stroke-width="2" d="M0 80h100M0 80V0"/><path fill="url(#a)" d="M2 78c18 0 28-28 48-23s30-35 48-40v63z"/><path stroke="#d97706" stroke-linecap="round" stroke-width="5" d="M2 78c18 0 28-28 48-23s30-35 48-40"/><circle cx="50" cy="55" r="4" fill="#f59e0b"/><circle cx="98" cy="15" r="4" fill="#f59e0b"/></g></svg>

After

Width:  |  Height:  |  Size: 733 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="150" height="150" fill="none" viewBox="0 0 150 150"><circle cx="75" cy="75" r="70" fill="#f3f4f6"/><rect width="100" height="14" x="25" y="45" fill="#4f46e5" rx="4"/><rect width="60" height="14" x="40" y="68" fill="#6366f1" rx="4"/><rect width="20" height="14" x="105" y="91" fill="#818cf8" rx="4"/><path stroke="#c7d2fe" stroke-width="2" d="M35 59v16m5 0h-5M100 59v39m5 0h-5"/></svg>

After

Width:  |  Height:  |  Size: 431 B

View File

@@ -245,6 +245,14 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
history.replace(newLocation);
return;
}
// if the current route is public dashboard then don't redirect to login
const isPublicDashboard = currentRoute?.path === ROUTES.PUBLIC_DASHBOARD;
if (isPublicDashboard) {
return;
}
// if the current route
if (currentRoute) {
const { isPrivate, key } = currentRoute;

View File

@@ -4,7 +4,7 @@ import getLocalStorageApi from 'api/browser/localstorage/get';
import setLocalStorageApi from 'api/browser/localstorage/set';
import logEvent from 'api/common/logEvent';
import AppLoading from 'components/AppLoading/AppLoading';
import KBarCommandPalette from 'components/KBarCommandPalette/KBarCommandPalette';
import { CmdKPalette } from 'components/cmdKPalette/cmdKPalette';
import NotFound from 'components/NotFound';
import Spinner from 'components/Spinner';
import { FeatureKeys } from 'constants/features';
@@ -22,12 +22,11 @@ import { StatusCodes } from 'http-status-codes';
import history from 'lib/history';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import posthog from 'posthog-js';
import AlertRuleProvider from 'providers/Alert';
import { useAppContext } from 'providers/App/App';
import { IUser } from 'providers/App/types';
import { CmdKProvider } from 'providers/cmdKProvider';
import { DashboardProvider } from 'providers/Dashboard/Dashboard';
import { ErrorModalProvider } from 'providers/ErrorModalProvider';
import { KBarCommandPaletteProvider } from 'providers/KBarCommandPaletteProvider';
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
import { QueryBuilderProvider } from 'providers/QueryBuilder';
import { Suspense, useCallback, useEffect, useState } from 'react';
@@ -214,7 +213,10 @@ function App(): JSX.Element {
]);
useEffect(() => {
if (pathname === ROUTES.ONBOARDING) {
if (
pathname === ROUTES.ONBOARDING ||
pathname.startsWith('/public/dashboard/')
) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
window.Pylon('hideChatBubble');
@@ -362,35 +364,33 @@ function App(): JSX.Element {
<ConfigProvider theme={themeConfig}>
<Router history={history}>
<CompatRouter>
<KBarCommandPaletteProvider>
<KBarCommandPalette />
<CmdKProvider>
<NotificationProvider>
<ErrorModalProvider>
{isLoggedInState && <CmdKPalette userRole={user.role} />}
<PrivateRoute>
<ResourceProvider>
<QueryBuilderProvider>
<DashboardProvider>
<KeyboardHotkeysProvider>
<AlertRuleProvider>
<AppLayout>
<PreferenceContextProvider>
<Suspense fallback={<Spinner size="large" tip="Loading..." />}>
<Switch>
{routes.map(({ path, component, exact }) => (
<Route
key={`${path}`}
exact={exact}
path={path}
component={component}
/>
))}
<Route exact path="/" component={Home} />
<Route path="*" component={NotFound} />
</Switch>
</Suspense>
</PreferenceContextProvider>
</AppLayout>
</AlertRuleProvider>
<AppLayout>
<PreferenceContextProvider>
<Suspense fallback={<Spinner size="large" tip="Loading..." />}>
<Switch>
{routes.map(({ path, component, exact }) => (
<Route
key={`${path}`}
exact={exact}
path={path}
component={component}
/>
))}
<Route exact path="/" component={Home} />
<Route path="*" component={NotFound} />
</Switch>
</Suspense>
</PreferenceContextProvider>
</AppLayout>
</KeyboardHotkeysProvider>
</DashboardProvider>
</QueryBuilderProvider>
@@ -398,7 +398,7 @@ function App(): JSX.Element {
</PrivateRoute>
</ErrorModalProvider>
</NotificationProvider>
</KBarCommandPaletteProvider>
</CmdKProvider>
</CompatRouter>
</Router>
</ConfigProvider>

View File

@@ -295,3 +295,10 @@ export const MetricsExplorer = Loadable(
export const ApiMonitoring = Loadable(
() => import(/* webpackChunkName: "ApiMonitoring" */ 'pages/ApiMonitoring'),
);
export const PublicDashboardPage = Loadable(
() =>
import(
/* webpackChunkName: "Public Dashboard Page" */ 'pages/PublicDashboard'
),
);

View File

@@ -34,6 +34,7 @@ import {
OrgOnboarding,
PasswordReset,
PipelinePage,
PublicDashboardPage,
ServiceMapPage,
ServiceMetricsPage,
ServicesTablePage,
@@ -169,6 +170,13 @@ const routes: AppRoutes[] = [
isPrivate: true,
key: 'DASHBOARD',
},
{
path: ROUTES.PUBLIC_DASHBOARD,
exact: false,
component: PublicDashboardPage,
isPrivate: false,
key: 'PUBLIC_DASHBOARD',
},
{
path: ROUTES.DASHBOARD_WIDGET,
exact: true,

View File

@@ -0,0 +1,28 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { CreatePublicDashboardProps } from 'types/api/dashboard/public/create';
const createPublicDashboard = async (
props: CreatePublicDashboardProps,
): Promise<SuccessResponseV2<CreatePublicDashboardProps>> => {
const { dashboardId, timeRangeEnabled = false, defaultTimeRange = '30m' } = props;
try {
const response = await axios.post(
`/dashboards/${dashboardId}/public`,
{ timeRangeEnabled, defaultTimeRange },
);
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default createPublicDashboard;

View File

@@ -0,0 +1,20 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { GetPublicDashboardDataProps, PayloadProps,PublicDashboardDataProps } from 'types/api/dashboard/public/get';
const getPublicDashboardData = async (props: GetPublicDashboardDataProps): Promise<SuccessResponseV2<PublicDashboardDataProps>> => {
try {
const response = await axios.get<PayloadProps>(`/public/dashboards/${props.id}`);
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default getPublicDashboardData;

View File

@@ -0,0 +1,20 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { GetPublicDashboardMetaProps, PayloadProps,PublicDashboardMetaProps } from 'types/api/dashboard/public/getMeta';
const getPublicDashboardMeta = async (props: GetPublicDashboardMetaProps): Promise<SuccessResponseV2<PublicDashboardMetaProps>> => {
try {
const response = await axios.get<PayloadProps>(`/dashboards/${props.id}/public`);
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default getPublicDashboardMeta;

View File

@@ -0,0 +1,27 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { MetricRangePayloadV5 } from 'api/v5/v5';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { GetPublicDashboardWidgetDataProps } from 'types/api/dashboard/public/getWidgetData';
const getPublicDashboardWidgetData = async (props: GetPublicDashboardWidgetDataProps): Promise<SuccessResponseV2<MetricRangePayloadV5>> => {
try {
const response = await axios.get(`/public/dashboards/${props.id}/widgets/${props.index}/query_range`, {
params: {
startTime: props.startTime,
endTime: props.endTime,
},
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default getPublicDashboardWidgetData;

View File

@@ -0,0 +1,22 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps,RevokePublicDashboardAccessProps } from 'types/api/dashboard/public/delete';
const revokePublicDashboardAccess = async (
props: RevokePublicDashboardAccessProps,
): Promise<SuccessResponseV2<PayloadProps>> => {
try {
const response = await axios.delete<PayloadProps>(`/dashboards/${props.id}/public`);
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default revokePublicDashboardAccess;

View File

@@ -0,0 +1,28 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { UpdatePublicDashboardProps } from 'types/api/dashboard/public/update';
const updatePublicDashboard = async (
props: UpdatePublicDashboardProps,
): Promise<SuccessResponseV2<UpdatePublicDashboardProps>> => {
const { dashboardId, timeRangeEnabled = false, defaultTimeRange = '30m' } = props;
try {
const response = await axios.put(
`/dashboards/${dashboardId}/public`,
{ timeRangeEnabled, defaultTimeRange },
);
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default updatePublicDashboard;

View File

@@ -14,6 +14,8 @@ import '@signozhq/badge';
import '@signozhq/button';
import '@signozhq/calendar';
import '@signozhq/callout';
import '@signozhq/checkbox';
import '@signozhq/command';
import '@signozhq/design-tokens';
import '@signozhq/input';
import '@signozhq/popover';

View File

@@ -62,6 +62,8 @@ interface CustomTimePickerProps {
showLiveLogs?: boolean;
onGoLive?: () => void;
onExitLiveLogs?: () => void;
/** When false, hides the "Recently Used" time ranges section */
showRecentlyUsed?: boolean;
}
function CustomTimePicker({
@@ -81,6 +83,7 @@ function CustomTimePicker({
onGoLive,
onExitLiveLogs,
showLiveLogs,
showRecentlyUsed = true,
}: CustomTimePickerProps): JSX.Element {
const [
selectedTimePlaceholderValue,
@@ -395,6 +398,7 @@ function CustomTimePicker({
setActiveView={setActiveView}
setIsOpenedFromFooter={setIsOpenedFromFooter}
isOpenedFromFooter={isOpenedFromFooter}
showRecentlyUsed={showRecentlyUsed}
/>
) : (
content
@@ -464,4 +468,5 @@ CustomTimePicker.defaultProps = {
onCustomTimeStatusUpdate: noop,
onExitLiveLogs: noop,
showLiveLogs: false,
showRecentlyUsed: true,
};

View File

@@ -47,6 +47,7 @@ interface CustomTimePickerPopoverContentProps {
isOpenedFromFooter: boolean;
setIsOpenedFromFooter: Dispatch<SetStateAction<boolean>>;
onExitLiveLogs: () => void;
showRecentlyUsed: boolean;
}
interface RecentlyUsedDateTimeRange {
@@ -72,6 +73,7 @@ function CustomTimePickerPopoverContent({
isOpenedFromFooter,
setIsOpenedFromFooter,
onExitLiveLogs,
showRecentlyUsed = true,
}: CustomTimePickerPopoverContentProps): JSX.Element {
const { pathname } = useLocation();
@@ -224,33 +226,35 @@ function CustomTimePickerPopoverContent({
<div>{getTimeChips(RelativeDurationSuggestionOptions)}</div>
</div>
<div className="recently-used-container">
<div className="time-heading">RECENTLY USED</div>
<div className="recently-used-range">
{recentlyUsedTimeRanges.map((range: RecentlyUsedDateTimeRange) => (
<div
className="recently-used-range-item"
role="button"
tabIndex={0}
onKeyDown={(e): void => {
if (e.key === 'Enter' || e.key === ' ') {
{showRecentlyUsed && (
<div className="recently-used-container">
<div className="time-heading">RECENTLY USED</div>
<div className="recently-used-range">
{recentlyUsedTimeRanges.map((range: RecentlyUsedDateTimeRange) => (
<div
className="recently-used-range-item"
role="button"
tabIndex={0}
onKeyDown={(e): void => {
if (e.key === 'Enter' || e.key === ' ') {
handleExitLiveLogs();
onCustomDateHandler([dayjs(range.from), dayjs(range.to)]);
setIsOpen(false);
}
}}
key={range.value}
onClick={(): void => {
handleExitLiveLogs();
onCustomDateHandler([dayjs(range.from), dayjs(range.to)]);
setIsOpen(false);
}
}}
key={range.value}
onClick={(): void => {
handleExitLiveLogs();
onCustomDateHandler([dayjs(range.from), dayjs(range.to)]);
setIsOpen(false);
}}
>
{range.label}
</div>
))}
}}
>
{range.label}
</div>
))}
</div>
</div>
</div>
)}
</div>
)}
</div>

View File

@@ -1,152 +0,0 @@
.kbar-command-palette__positioner {
position: fixed;
inset: 0;
display: flex;
align-items: flex-start;
justify-content: center;
padding: 1rem;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(6px);
z-index: 50;
}
.kbar-command-palette__animator {
width: 100%;
max-width: 600px;
}
.kbar-command-palette__card {
background: var(--bg-ink-500);
color: var(--text-vanilla-100);
border-radius: 3px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15);
overflow: hidden;
display: flex;
flex-direction: column;
}
.kbar-command-palette__search {
padding: 12px 16px;
font-size: 13px;
border: none;
border-bottom: 1px solid var(--border-ink-200);
color: var(--text-vanilla-100);
outline: none;
background-color: var(--bg-ink-500);
}
.kbar-command-palette__section {
padding: 8px 16px 4px;
font-size: 12px;
font-weight: 600;
color: var(--text-robin-500);
font-family: 'Inter', sans-serif;
text-transform: uppercase;
letter-spacing: 0.02em;
}
.kbar-command-palette__item {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
font-size: 13px;
cursor: pointer;
transition: background 0.15s ease;
}
.kbar-command-palette__item:hover,
.kbar-command-palette__item--active {
background: var(--bg-ink-400);
}
.kbar-command-palette__icon {
flex-shrink: 0;
width: 18px;
height: 18px;
color: #444;
}
.kbar-command-palette__shortcut {
margin-left: auto;
display: flex;
gap: 4px;
}
.kbar-command-palette__key {
padding: 2px 6px;
font-size: 12px;
border-radius: 4px;
background: var(--bg-ink-300);
color: var(--text-vanilla-300);
text-transform: uppercase;
font-family: 'Space Mono', monospace;
}
.kbar-command-palette__results-container {
div {
&::-webkit-scrollbar {
width: 0.3rem;
height: 0.3rem;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--bg-slate-300);
}
&::-webkit-scrollbar-thumb:hover {
background: var(--bg-slate-200);
}
}
}
.lightMode {
.kbar-command-palette__positioner {
background: rgba(0, 0, 0, 0.5);
}
.kbar-command-palette__card {
background: #fff;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15);
}
.kbar-command-palette__search {
border-bottom: 1px solid #e5e5e5;
color: var(--text-ink-500);
background-color: var(--bg-vanilla-100);
}
.kbar-command-palette__item {
color: var(--text-ink-500);
}
.kbar-command-palette__item:hover,
.kbar-command-palette__item--active {
background: #f5f5f5;
}
.kbar-command-palette__icon {
color: #444;
}
.kbar-command-palette__key {
background: #eee;
color: #555;
}
.kbar-command-palette__results-container {
div {
&::-webkit-scrollbar-thumb {
background: var(--bg-vanilla-300);
}
&::-webkit-scrollbar-thumb:hover {
background: var(--bg-vanilla-300);
}
}
}
}

View File

@@ -1,69 +0,0 @@
import './KBarCommandPalette.scss';
import {
KBarAnimator,
KBarPortal,
KBarPositioner,
KBarResults,
KBarSearch,
useMatches,
} from 'kbar';
function Results(): JSX.Element {
const { results } = useMatches();
const renderResults = ({
item,
active,
}: {
item: any;
active: boolean;
}): JSX.Element =>
typeof item === 'string' ? (
<div className="kbar-command-palette__section">{item}</div>
) : (
<div
className={`kbar-command-palette__item ${
active ? 'kbar-command-palette__item--active' : ''
}`}
>
{item.icon}
<span>{item.name}</span>
{item.shortcut?.length ? (
<span className="kbar-command-palette__shortcut">
{item.shortcut.map((sc: string) => (
<kbd key={sc} className="kbar-command-palette__key">
{sc}
</kbd>
))}
</span>
) : null}
</div>
);
return (
<div className="kbar-command-palette__results-container">
<KBarResults items={results} onRender={renderResults} />
</div>
);
}
function KBarCommandPalette(): JSX.Element {
return (
<KBarPortal>
<KBarPositioner className="kbar-command-palette__positioner">
<KBarAnimator className="kbar-command-palette__animator">
<div className="kbar-command-palette__card">
<KBarSearch
className="kbar-command-palette__search"
placeholder="Search or type a command..."
/>
<Results />
</div>
</KBarAnimator>
</KBarPositioner>
</KBarPortal>
);
}
export default KBarCommandPalette;

View File

@@ -1,11 +1,15 @@
.log-field-key {
padding-right: 5px;
.log-field-container {
display: flex;
overflow: hidden;
width: 100%;
align-items: baseline;
}
.log-field-key,
.log-field-key-colon {
color: var(--text-vanilla-400, #c0c1c3);
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 128.571% */
letter-spacing: -0.07px;
&.small {
font-size: 11px;
@@ -22,6 +26,20 @@
line-height: 24px;
}
}
.log-field-key {
line-height: 18px; /* 128.571% */
letter-spacing: -0.07px;
white-space: nowrap;
display: inline-block;
max-width: 20vw;
text-overflow: ellipsis;
overflow: hidden;
margin: 0;
}
.log-field-key-colon {
min-width: 0.8rem;
flex-shrink: 0;
}
.log-value {
color: var(--text-vanilla-400, #c0c1c3);
font-size: 14px;
@@ -158,7 +176,8 @@
}
.lightMode {
.log-field-key {
.log-field-key,
.log-field-key-colon {
color: var(--text-slate-400);
}
.log-value {
@@ -170,3 +189,10 @@
}
}
}
.dark {
.log-field-key,
.log-field-key-colon {
color: rgba(255, 255, 255, 0.45);
}
}

View File

@@ -25,13 +25,7 @@ import LogLinesActionButtons from '../LogLinesActionButtons/LogLinesActionButton
import LogStateIndicator from '../LogStateIndicator/LogStateIndicator';
import { getLogIndicatorType } from '../LogStateIndicator/utils';
// styles
import {
Container,
LogContainer,
LogText,
Text,
TextContainer,
} from './styles';
import { Container, LogContainer, LogText } from './styles';
import { isValidLogField } from './util';
interface LogFieldProps {
@@ -58,16 +52,18 @@ function LogGeneralField({
);
return (
<TextContainer>
<Text ellipsis type="secondary" className={cx('log-field-key', fontSize)}>
{`${fieldKey} : `}
</Text>
<div className="log-field-container">
<p className={cx('log-field-key', fontSize)} title={fieldKey}>
{fieldKey}
</p>
<span className={cx('log-field-key-colon', fontSize)}>&nbsp;:&nbsp;</span>
<LogText
dangerouslySetInnerHTML={html}
className={cx('log-value', fontSize)}
title={fieldValue}
linesPerRow={linesPerRow > 1 ? linesPerRow : undefined}
/>
</TextContainer>
</div>
);
}

View File

@@ -1,5 +1,5 @@
/* eslint-disable no-nested-ternary */
import { Card, Typography } from 'antd';
import { Card } from 'antd';
import { FontSize } from 'container/OptionsMenu/types';
import styled from 'styled-components';
import { getActiveLogBackground } from 'utils/logs';
@@ -46,19 +46,6 @@ export const Container = styled(Card)<{
getActiveLogBackground($isActiveLog, $isDarkMode, $logType)}
`;
export const Text = styled(Typography.Text)`
&&& {
min-width: 2.5rem;
white-space: nowrap;
}
`;
export const TextContainer = styled.div`
display: flex;
overflow: hidden;
width: 100%;
`;
export const LogContainer = styled.div<LogContainerProps>`
margin-left: 0.5rem;
display: flex;

View File

@@ -0,0 +1,16 @@
.overflow-input {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.overflow-input-mirror {
position: absolute;
visibility: hidden;
white-space: pre;
pointer-events: none;
font: inherit;
letter-spacing: inherit;
height: 0;
overflow: hidden;
}

View File

@@ -0,0 +1,119 @@
import { render, screen, userEvent, waitFor, within } from 'tests/test-utils';
import OverflowInputToolTip from './OverflowInputToolTip';
const TOOLTIP_INNER_SELECTOR = '.ant-tooltip-inner';
// Utility to mock overflow behaviour on inputs / elements.
// Stubs HTMLElement.prototype.clientWidth, scrollWidth and offsetWidth used by component.
function mockOverflow(clientWidth: number, scrollWidth: number): void {
Object.defineProperty(HTMLElement.prototype, 'clientWidth', {
configurable: true,
value: clientWidth,
});
Object.defineProperty(HTMLElement.prototype, 'scrollWidth', {
configurable: true,
value: scrollWidth,
});
// mirror.offsetWidth is used to compute mirrorWidth = offsetWidth + 24.
// Use clientWidth so the mirror measurement aligns with the mocked client width in tests.
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', {
configurable: true,
value: clientWidth,
});
}
function queryTooltipInner(): HTMLElement | null {
// find element that has role="tooltip" (could be the inner itself)
const tooltip = document.querySelector<HTMLElement>('[role="tooltip"]');
if (!tooltip) return document.querySelector(TOOLTIP_INNER_SELECTOR);
// if the role element is already the inner, return it; otherwise return its descendant
if (tooltip.classList.contains('ant-tooltip-inner')) return tooltip;
return (
(tooltip.querySelector(TOOLTIP_INNER_SELECTOR) as HTMLElement) ??
document.querySelector(TOOLTIP_INNER_SELECTOR)
);
}
describe('OverflowInputToolTip', () => {
beforeEach(() => {
jest.restoreAllMocks();
});
test('shows tooltip when content overflows and input is clamped at maxAutoWidth', async () => {
mockOverflow(150, 250); // clientWidth >= maxAutoWidth (150), scrollWidth > clientWidth
render(<OverflowInputToolTip value="Very long overflowing text" />);
await userEvent.hover(screen.getByRole('textbox'));
await waitFor(() => {
expect(queryTooltipInner()).not.toBeNull();
});
const tooltipInner = queryTooltipInner();
if (!tooltipInner) throw new Error('Tooltip inner not found');
expect(
within(tooltipInner).getByText('Very long overflowing text'),
).toBeInTheDocument();
});
test('does NOT show tooltip when content does not overflow', async () => {
mockOverflow(150, 100); // content fits (scrollWidth <= clientWidth)
render(<OverflowInputToolTip value="Short text" />);
await userEvent.hover(screen.getByRole('textbox'));
await waitFor(() => {
expect(queryTooltipInner()).toBeNull();
});
});
test('does NOT show tooltip when content overflows but input is NOT at maxAutoWidth', async () => {
mockOverflow(100, 250); // clientWidth < maxAutoWidth (150), scrollWidth > clientWidth
render(<OverflowInputToolTip value="Long but input not clamped" />);
await userEvent.hover(screen.getByRole('textbox'));
await waitFor(() => {
expect(queryTooltipInner()).toBeNull();
});
});
test('uncontrolled input allows typing', async () => {
render(<OverflowInputToolTip defaultValue="Init" />);
const input = screen.getByRole('textbox') as HTMLInputElement;
await userEvent.type(input, 'ABC');
expect(input).toHaveValue('InitABC');
});
test('disabled input never shows tooltip even if overflowing', async () => {
mockOverflow(150, 300);
render(<OverflowInputToolTip value="Overflowing!" disabled />);
await userEvent.hover(screen.getByRole('textbox'));
await waitFor(() => {
expect(queryTooltipInner()).toBeNull();
});
});
test('renders mirror span and input correctly (structural assertions instead of snapshot)', () => {
const { container } = render(<OverflowInputToolTip value="Snapshot" />);
const mirror = container.querySelector('.overflow-input-mirror');
const input = container.querySelector('input') as HTMLInputElement | null;
expect(mirror).toBeTruthy();
expect(mirror?.textContent).toBe('Snapshot');
expect(input).toBeTruthy();
expect(input?.value).toBe('Snapshot');
// width should be set inline (component calculates width on mount)
expect(input?.getAttribute('style')).toContain('width:');
});
});

View File

@@ -0,0 +1,73 @@
/* eslint-disable react/require-default-props */
/* eslint-disable react/jsx-props-no-spreading */
import './OverflowInputToolTip.scss';
import { Input, InputProps, InputRef, Tooltip } from 'antd';
import cx from 'classnames';
import { useEffect, useRef, useState } from 'react';
export interface OverflowTooltipInputProps extends InputProps {
tooltipPlacement?: 'top' | 'bottom' | 'left' | 'right';
minAutoWidth?: number;
maxAutoWidth?: number;
}
function OverflowInputToolTip({
value,
defaultValue,
onChange,
disabled = false,
tooltipPlacement = 'top',
className,
minAutoWidth = 70,
maxAutoWidth = 150,
...rest
}: OverflowTooltipInputProps): JSX.Element {
const inputRef = useRef<InputRef>(null);
const mirrorRef = useRef<HTMLSpanElement | null>(null);
const [isOverflowing, setIsOverflowing] = useState<boolean>(false);
useEffect(() => {
const input = inputRef.current?.input;
const mirror = mirrorRef.current;
if (!input || !mirror) {
setIsOverflowing(false);
return;
}
mirror.textContent = String(value ?? '') || ' ';
const mirrorWidth = mirror.offsetWidth + 24;
const newWidth = Math.min(maxAutoWidth, Math.max(minAutoWidth, mirrorWidth));
input.style.width = `${newWidth}px`;
// consider clamped when mirrorWidth reaches maxAutoWidth (allow -5px tolerance)
const isClamped = mirrorWidth >= maxAutoWidth - 5;
const overflow = input.scrollWidth > input.clientWidth && isClamped;
setIsOverflowing(overflow);
}, [value, disabled, minAutoWidth, maxAutoWidth]);
const tooltipTitle = !disabled && isOverflowing ? String(value ?? '') : '';
return (
<>
<span ref={mirrorRef} aria-hidden className="overflow-input-mirror" />
<Tooltip title={tooltipTitle} placement={tooltipPlacement}>
<Input
{...rest}
value={value}
defaultValue={defaultValue}
onChange={onChange}
disabled={disabled}
ref={inputRef}
className={cx('overflow-input', className)}
/>
</Tooltip>
</>
);
}
OverflowInputToolTip.displayName = 'OverflowInputToolTip';
export default OverflowInputToolTip;

View File

@@ -0,0 +1,3 @@
import OverflowInputToolTip from './OverflowInputToolTip';
export default OverflowInputToolTip;

View File

@@ -300,7 +300,7 @@ function QueryAddOns({
);
return (
<div className="query-add-ons">
<div className="query-add-ons" data-testid="query-add-ons">
{selectedViews.length > 0 && (
<div className="selected-add-ons-content">
{selectedViews.find((view) => view.key === 'group_by') && (

View File

@@ -43,7 +43,10 @@ function QueryAggregationOptions({
};
return (
<div className="query-aggregation-container">
<div
className="query-aggregation-container"
data-testid="query-aggregation-container"
>
<div className="aggregation-container">
<QueryAggregationSelect
onChange={onChange}

View File

@@ -114,9 +114,9 @@ function QuerySearch({
const [isFocused, setIsFocused] = useState(false);
const editorRef = useRef<EditorView | null>(null);
const handleQueryValidation = useCallback((newQuery: string): void => {
const handleQueryValidation = useCallback((newExpression: string): void => {
try {
const validationResponse = validateQuery(newQuery);
const validationResponse = validateQuery(newExpression);
setValidation(validationResponse);
} catch (error) {
setValidation({
@@ -127,7 +127,7 @@ function QuerySearch({
}
}, []);
const getCurrentQuery = useCallback(
const getCurrentExpression = useCallback(
(): string => editorRef.current?.state.doc.toString() || '',
[],
);
@@ -167,19 +167,14 @@ function QuerySearch({
() => {
if (!isEditorReady) return;
const newQuery = queryData.filter?.expression || '';
const currentQuery = getCurrentQuery();
const newExpression = queryData.filter?.expression || '';
const currentExpression = getCurrentExpression();
/* eslint-disable-next-line sonarjs/no-collapsible-if */
if (newQuery !== currentQuery && !isFocused) {
// Prevent clearing a non-empty editor when queryData becomes empty temporarily
// Only update if newQuery has a value, or if both are empty (initial state)
if (newQuery || !currentQuery) {
updateEditorValue(newQuery, { skipOnChange: true });
if (newQuery) {
handleQueryValidation(newQuery);
}
// Do not update codemirror editor if the expression is the same
if (newExpression !== currentExpression && !isFocused) {
updateEditorValue(newExpression, { skipOnChange: true });
if (newExpression) {
handleQueryValidation(newExpression);
}
}
},
@@ -613,8 +608,8 @@ function QuerySearch({
};
const handleBlur = (): void => {
const currentQuery = getCurrentQuery();
handleQueryValidation(currentQuery);
const currentExpression = getCurrentExpression();
handleQueryValidation(currentExpression);
setIsFocused(false);
};
@@ -633,11 +628,11 @@ function QuerySearch({
const handleExampleClick = (exampleQuery: string): void => {
// If there's an existing query, append the example with AND
const currentQuery = getCurrentQuery();
const newQuery = currentQuery
? `${currentQuery} AND ${exampleQuery}`
const currentExpression = getCurrentExpression();
const newExpression = currentExpression
? `${currentExpression} AND ${exampleQuery}`
: exampleQuery;
updateEditorValue(newQuery);
updateEditorValue(newExpression);
};
// Helper function to render a badge for the current context mode
@@ -673,9 +668,9 @@ function QuerySearch({
if (word?.from === word?.to && !context.explicit) return null;
// Get current query from editor
const currentQuery = editorRef.current?.state.doc.toString() || '';
const currentExpression = getCurrentExpression();
// Get the query context at the cursor position
const queryContext = getQueryContextAtCursor(currentQuery, cursorPos.ch);
const queryContext = getQueryContextAtCursor(currentExpression, cursorPos.ch);
// Define autocomplete options based on the context
let options: {
@@ -1171,8 +1166,8 @@ function QuerySearch({
if (queryContext.isInParenthesis) {
// Different suggestions based on the context within parenthesis or bracket
const currentQuery = editorRef.current?.state.doc.toString() || '';
const curChar = currentQuery.charAt(cursorPos.ch - 1) || '';
const currentExpression = getCurrentExpression();
const curChar = currentExpression.charAt(cursorPos.ch - 1) || '';
if (curChar === '(' || curChar === '[') {
// Right after opening parenthesis/bracket
@@ -1321,7 +1316,7 @@ function QuerySearch({
style={{
position: 'absolute',
top: 8,
right: validation.isValid === false && getCurrentQuery() ? 40 : 8, // Move left when error shown
right: validation.isValid === false && getCurrentExpression() ? 40 : 8, // Move left when error shown
cursor: 'help',
zIndex: 10,
transition: 'right 0.2s ease',
@@ -1383,7 +1378,7 @@ function QuerySearch({
// Mod-Enter is usually Ctrl-Enter or Cmd-Enter based on OS
run: (): boolean => {
if (onRun && typeof onRun === 'function') {
onRun(getCurrentQuery());
onRun(getCurrentExpression());
} else {
handleRunQuery();
}
@@ -1409,7 +1404,7 @@ function QuerySearch({
onBlur={handleBlur}
/>
{getCurrentQuery() && validation.isValid === false && !isFocused && (
{getCurrentExpression() && validation.isValid === false && !isFocused && (
<div
className={cx('query-status-container', {
hasErrors: validation.errors.length > 0,

View File

@@ -1,6 +1,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable sonarjs/cognitive-complexity */
/* eslint-disable import/named */
import { EditorView } from '@uiw/react-codemirror';
import { getKeySuggestions } from 'api/querySuggestions/getKeySuggestions';
import { getValueSuggestions } from 'api/querySuggestions/getValueSuggestion';
import { initialQueriesMap } from 'constants/queryBuilder';
@@ -151,8 +152,6 @@ describe('QuerySearch (Integration with Real CodeMirror)', () => {
>;
mockedGetKeys.mockClear();
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<QuerySearch
onChange={jest.fn() as jest.MockedFunction<(v: string) => void>}
@@ -171,8 +170,8 @@ describe('QuerySearch (Integration with Real CodeMirror)', () => {
const editor = document.querySelector(CM_EDITOR_SELECTOR) as HTMLElement;
// Focus and type into the editor
await user.click(editor);
await user.type(editor, SAMPLE_KEY_TYPING);
await userEvent.click(editor);
await userEvent.type(editor, SAMPLE_KEY_TYPING);
// Wait for debounced API call (300ms debounce + some buffer)
await waitFor(() => expect(mockedGetKeys).toHaveBeenCalled(), {
@@ -187,8 +186,6 @@ describe('QuerySearch (Integration with Real CodeMirror)', () => {
>;
mockedGetValues.mockClear();
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<QuerySearch
onChange={jest.fn() as jest.MockedFunction<(v: string) => void>}
@@ -204,8 +201,8 @@ describe('QuerySearch (Integration with Real CodeMirror)', () => {
});
const editor = document.querySelector(CM_EDITOR_SELECTOR) as HTMLElement;
await user.click(editor);
await user.type(editor, SAMPLE_VALUE_TYPING_INCOMPLETE);
await userEvent.click(editor);
await userEvent.type(editor, SAMPLE_VALUE_TYPING_INCOMPLETE);
// Wait for debounced API call (300ms debounce + some buffer)
await waitFor(() => expect(mockedGetValues).toHaveBeenCalled(), {
@@ -241,7 +238,6 @@ describe('QuerySearch (Integration with Real CodeMirror)', () => {
it('calls provided onRun on Mod-Enter', async () => {
const onRun = jest.fn() as jest.MockedFunction<(q: string) => void>;
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<QuerySearch
@@ -259,8 +255,8 @@ describe('QuerySearch (Integration with Real CodeMirror)', () => {
});
const editor = document.querySelector(CM_EDITOR_SELECTOR) as HTMLElement;
await user.click(editor);
await user.type(editor, SAMPLE_STATUS_QUERY);
await userEvent.click(editor);
await userEvent.type(editor, SAMPLE_STATUS_QUERY);
// Use fireEvent for keyboard shortcuts as userEvent might not work well with CodeMirror
const modKey = navigator.platform.includes('Mac') ? 'metaKey' : 'ctrlKey';
@@ -280,8 +276,6 @@ describe('QuerySearch (Integration with Real CodeMirror)', () => {
>;
mockedHandleRunQuery.mockClear();
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<QuerySearch
onChange={jest.fn() as jest.MockedFunction<(v: string) => void>}
@@ -297,8 +291,8 @@ describe('QuerySearch (Integration with Real CodeMirror)', () => {
});
const editor = document.querySelector(CM_EDITOR_SELECTOR) as HTMLElement;
await user.click(editor);
await user.type(editor, SAMPLE_VALUE_TYPING_COMPLETE);
await userEvent.click(editor);
await userEvent.type(editor, SAMPLE_VALUE_TYPING_COMPLETE);
// Use fireEvent for keyboard shortcuts as userEvent might not work well with CodeMirror
const modKey = navigator.platform.includes('Mac') ? 'metaKey' : 'ctrlKey';
@@ -348,4 +342,73 @@ describe('QuerySearch (Integration with Real CodeMirror)', () => {
{ timeout: 3000 },
);
});
it('handles queryData.filter.expression changes without triggering onChange', async () => {
// Spy on CodeMirror's EditorView.dispatch, which is invoked when updateEditorValue
// applies a programmatic change to the editor.
const dispatchSpy = jest.spyOn(EditorView.prototype, 'dispatch');
const initialExpression = "service.name = 'frontend'";
const updatedExpression = "service.name = 'backend'";
const onChange = jest.fn() as jest.MockedFunction<(v: string) => void>;
const initialQueryData = {
...initialQueriesMap.logs.builder.queryData[0],
filter: {
expression: initialExpression,
},
};
const { rerender } = render(
<QuerySearch
onChange={onChange}
queryData={initialQueryData}
dataSource={DataSource.LOGS}
/>,
);
// Wait for CodeMirror to initialize with the initial expression
await waitFor(
() => {
const editorContent = document.querySelector(
CM_EDITOR_SELECTOR,
) as HTMLElement;
expect(editorContent).toBeInTheDocument();
const textContent = editorContent.textContent || '';
expect(textContent).toBe(initialExpression);
},
{ timeout: 3000 },
);
// Ensure the editor is explicitly blurred (not focused)
// Blur the actual CodeMirror editor container so that QuerySearch's onBlur handler runs.
// Note: In jsdom + CodeMirror we can't reliably assert the DOM text content changes when
// the expression is updated programmatically, but we can assert that:
// 1) The component continues to render, and
// 2) No onChange is fired for programmatic updates.
const updatedQueryData = {
...initialQueryData,
filter: {
expression: updatedExpression,
},
};
// Re-render with updated queryData.filter.expression
rerender(
<QuerySearch
onChange={onChange}
queryData={updatedQueryData}
dataSource={DataSource.LOGS}
/>,
);
// updateEditorValue should have resulted in a dispatch call + onChange should not have been called
await waitFor(() => {
expect(dispatchSpy).toHaveBeenCalled();
expect(onChange).not.toHaveBeenCalled();
});
dispatchSpy.mockRestore();
});
});

View File

@@ -163,6 +163,10 @@ function formatSingleValueForFilter(
if (trimmed === 'true' || trimmed === 'false') {
return trimmed === 'true';
}
if (isQuoted(value)) {
return unquote(value);
}
}
// Return non-string values as-is, or string values that couldn't be converted

View File

@@ -1,3 +1,4 @@
import userEvent from '@testing-library/user-event';
import { FiltersType, QuickFiltersSource } from 'components/QuickFilters/types';
import { useGetAggregateValues } from 'hooks/queryBuilder/useGetAggregateValues';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
@@ -5,7 +6,7 @@ import { useGetQueryKeyValueSuggestions } from 'hooks/querySuggestions/useGetQue
import { quickFiltersAttributeValuesResponse } from 'mocks-server/__mockdata__/customQuickFilters';
import { rest, server } from 'mocks-server/server';
import { UseQueryResult } from 'react-query';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { render, screen, waitFor } from 'tests/test-utils';
import { SuccessResponse } from 'types/api';
import { IAttributeValuesResponse } from 'types/api/queryBuilder/getAttributesValues';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
@@ -41,13 +42,15 @@ interface MockFilterConfig {
type: FiltersType;
}
const SERVICE_NAME_KEY = 'service.name';
const createMockFilter = (
overrides: Partial<MockFilterConfig> = {},
): MockFilterConfig => ({
// eslint-disable-next-line sonarjs/no-duplicate-string
title: 'Service Name',
attributeKey: {
key: 'service.name',
key: SERVICE_NAME_KEY,
dataType: DataTypes.String,
type: 'resource',
},
@@ -68,7 +71,7 @@ const createMockQueryBuilderData = (hasActiveFilters = false): any => ({
? [
{
key: {
key: 'service.name',
key: SERVICE_NAME_KEY,
dataType: DataTypes.String,
type: 'resource',
},
@@ -188,4 +191,222 @@ describe('CheckboxFilter - User Flows', () => {
expect(screen.getByPlaceholderText('Filter values')).toBeInTheDocument();
});
});
it('should update query filters when a checkbox is clicked', async () => {
const redirectWithQueryBuilderData = jest.fn();
// Start with no active filters so clicking a checkbox creates one
mockUseQueryBuilder.mockReturnValue({
...createMockQueryBuilderData(false),
redirectWithQueryBuilderData,
} as any);
const mockFilter = createMockFilter({ defaultOpen: true });
render(
<CheckboxFilter
filter={mockFilter}
source={QuickFiltersSource.LOGS_EXPLORER}
/>,
);
// Wait for checkboxes to render
await waitFor(() => {
expect(screen.getAllByRole('checkbox')).toHaveLength(4);
});
const checkboxes = screen.getAllByRole('checkbox');
// User unchecks the first value (`mq-kafka`)
await userEvent.click(checkboxes[0]);
// Composite query params (query builder data) should be updated via redirectWithQueryBuilderData
expect(redirectWithQueryBuilderData).toHaveBeenCalledTimes(1);
const [updatedQuery] = redirectWithQueryBuilderData.mock.calls[0];
const updatedFilters = updatedQuery.builder.queryData[0].filters;
expect(updatedFilters.items).toHaveLength(1);
expect(updatedFilters.items[0].key.key).toBe(SERVICE_NAME_KEY);
// When unchecking from an "all selected" state, we use a NOT_IN filter for that value
expect(updatedFilters.items[0].op).toBe('not in');
expect(updatedFilters.items[0].value).toBe('mq-kafka');
});
it('should set an IN filter with only the clicked value when using Only', async () => {
const redirectWithQueryBuilderData = jest.fn();
// Existing filter: service.name IN ['mq-kafka', 'otel-demo']
mockUseQueryBuilder.mockReturnValue({
lastUsedQuery: 0,
currentQuery: {
builder: {
queryData: [
{
filters: {
items: [
{
key: {
key: SERVICE_NAME_KEY,
dataType: DataTypes.String,
type: 'resource',
},
op: 'in',
value: ['mq-kafka', 'otel-demo'],
},
],
op: 'AND',
},
},
],
},
},
redirectWithQueryBuilderData,
} as any);
const mockFilter = createMockFilter({ defaultOpen: true });
render(
<CheckboxFilter
filter={mockFilter}
source={QuickFiltersSource.LOGS_EXPLORER}
/>,
);
// Wait for values to render
await waitFor(() => {
expect(screen.getByText('mq-kafka')).toBeInTheDocument();
});
// Click on the value label to trigger the "Only" behavior
await userEvent.click(screen.getByText('mq-kafka'));
expect(redirectWithQueryBuilderData).toHaveBeenCalledTimes(1);
const [updatedQuery] = redirectWithQueryBuilderData.mock.calls[0];
const updatedFilters = updatedQuery.builder.queryData[0].filters;
expect(updatedFilters.items).toHaveLength(1);
expect(updatedFilters.items[0].key.key).toBe(SERVICE_NAME_KEY);
expect(updatedFilters.items[0].op).toBe('in');
expect(updatedFilters.items[0].value).toBe('mq-kafka');
});
it('should clear filters for the attribute when using All', async () => {
const redirectWithQueryBuilderData = jest.fn();
// Existing filter: service.name IN ['mq-kafka']
mockUseQueryBuilder.mockReturnValue({
lastUsedQuery: 0,
currentQuery: {
builder: {
queryData: [
{
filters: {
items: [
{
key: {
key: SERVICE_NAME_KEY,
dataType: DataTypes.String,
type: 'resource',
},
op: 'in',
value: ['mq-kafka'],
},
],
op: 'AND',
},
},
],
},
},
redirectWithQueryBuilderData,
} as any);
const mockFilter = createMockFilter({ defaultOpen: true });
render(
<CheckboxFilter
filter={mockFilter}
source={QuickFiltersSource.LOGS_EXPLORER}
/>,
);
await waitFor(() => {
expect(screen.getByText('mq-kafka')).toBeInTheDocument();
});
// Only one value is selected, so clicking it should switch to "All" (no filter for this key)
await userEvent.click(screen.getByText('mq-kafka'));
expect(redirectWithQueryBuilderData).toHaveBeenCalledTimes(1);
const [updatedQuery] = redirectWithQueryBuilderData.mock.calls[0];
const updatedFilters = updatedQuery.builder.queryData[0].filters;
const filtersForServiceName = updatedFilters.items.filter(
(item: any) => item.key?.key === SERVICE_NAME_KEY,
);
expect(filtersForServiceName).toHaveLength(0);
});
it('should extend an existing IN filter when checking an additional value', async () => {
const redirectWithQueryBuilderData = jest.fn();
// Existing filter: service.name IN 'mq-kafka'
mockUseQueryBuilder.mockReturnValue({
lastUsedQuery: 0,
currentQuery: {
builder: {
queryData: [
{
filters: {
items: [
{
key: {
key: SERVICE_NAME_KEY,
dataType: DataTypes.String,
type: 'resource',
},
op: 'in',
value: 'mq-kafka',
},
],
op: 'AND',
},
},
],
},
},
redirectWithQueryBuilderData,
} as any);
const mockFilter = createMockFilter({ defaultOpen: true });
render(
<CheckboxFilter
filter={mockFilter}
source={QuickFiltersSource.LOGS_EXPLORER}
/>,
);
// Wait for checkboxes to render
await waitFor(() => {
expect(screen.getAllByRole('checkbox')).toHaveLength(4);
});
const checkboxes = screen.getAllByRole('checkbox');
// First checkbox corresponds to 'mq-kafka' (already selected),
// second will be 'otel-demo' which we now select additionally.
await userEvent.click(checkboxes[1]);
expect(redirectWithQueryBuilderData).toHaveBeenCalledTimes(1);
const [updatedQuery] = redirectWithQueryBuilderData.mock.calls[0];
const updatedFilters = updatedQuery.builder.queryData[0].filters;
const [filterForServiceName] = updatedFilters.items;
expect(filterForServiceName.key.key).toBe(SERVICE_NAME_KEY);
expect(filterForServiceName.op).toBe('in');
expect(filterForServiceName.value).toEqual(['mq-kafka', 'otel-demo']);
});
});

View File

@@ -0,0 +1,205 @@
import { screen } from '@testing-library/react';
import { render } from 'tests/test-utils';
import ValueGraph from '../index';
import { getBackgroundColorAndThresholdCheck } from '../utils';
// Mock the utils module
jest.mock('../utils', () => ({
getBackgroundColorAndThresholdCheck: jest.fn(() => ({
threshold: {} as any,
isConflictingThresholds: false,
})),
}));
const mockGetBackgroundColorAndThresholdCheck = getBackgroundColorAndThresholdCheck as jest.MockedFunction<
typeof getBackgroundColorAndThresholdCheck
>;
const TEST_ID_VALUE_GRAPH_TEXT = 'value-graph-text';
const TEST_ID_VALUE_GRAPH_PREFIX_UNIT = 'value-graph-prefix-unit';
const TEST_ID_VALUE_GRAPH_SUFFIX_UNIT = 'value-graph-suffix-unit';
describe('ValueGraph', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('renders the numeric value correctly', () => {
const { getByTestId } = render(
<ValueGraph value="42" rawValue={42} thresholds={[]} />,
);
expect(getByTestId(TEST_ID_VALUE_GRAPH_TEXT)).toHaveTextContent('42');
});
it('renders value with suffix unit', () => {
const { getByTestId } = render(
<ValueGraph value="42ms" rawValue={42} thresholds={[]} />,
);
expect(getByTestId(TEST_ID_VALUE_GRAPH_TEXT)).toHaveTextContent('42');
expect(getByTestId(TEST_ID_VALUE_GRAPH_SUFFIX_UNIT)).toHaveTextContent('ms');
});
it('renders value with prefix unit', () => {
const { getByTestId } = render(
<ValueGraph value="$100" rawValue={100} thresholds={[]} />,
);
expect(getByTestId(TEST_ID_VALUE_GRAPH_TEXT)).toHaveTextContent('100');
expect(getByTestId(TEST_ID_VALUE_GRAPH_PREFIX_UNIT)).toHaveTextContent('$');
});
it('renders value with both prefix and suffix units', () => {
const { getByTestId } = render(
<ValueGraph value="$100USD" rawValue={100} thresholds={[]} />,
);
expect(getByTestId(TEST_ID_VALUE_GRAPH_TEXT)).toHaveTextContent('100');
expect(getByTestId(TEST_ID_VALUE_GRAPH_PREFIX_UNIT)).toHaveTextContent('$');
expect(getByTestId(TEST_ID_VALUE_GRAPH_SUFFIX_UNIT)).toHaveTextContent('USD');
});
it('renders value with K suffix', () => {
const { getByTestId } = render(
<ValueGraph value="1.5K" rawValue={1500} thresholds={[]} />,
);
expect(getByTestId(TEST_ID_VALUE_GRAPH_TEXT)).toHaveTextContent('1.5K');
});
it('applies text color when threshold format is Text', () => {
mockGetBackgroundColorAndThresholdCheck.mockReturnValue({
threshold: {
thresholdFormat: 'Text',
thresholdColor: 'red',
} as any,
isConflictingThresholds: false,
});
const { getByTestId } = render(
<ValueGraph value="42" rawValue={42} thresholds={[]} />,
);
expect(getByTestId(TEST_ID_VALUE_GRAPH_TEXT)).toHaveStyle({ color: 'red' });
});
it('applies background color when threshold format is Background', () => {
mockGetBackgroundColorAndThresholdCheck.mockReturnValue({
threshold: {
thresholdFormat: 'Background',
thresholdColor: 'blue',
} as any,
isConflictingThresholds: false,
});
const { container } = render(
<ValueGraph value="42" rawValue={42} thresholds={[]} />,
);
const containerElement = container.querySelector('.value-graph-container');
expect(containerElement).toHaveStyle({ backgroundColor: 'blue' });
});
it('displays conflicting thresholds indicator when multiple thresholds match', () => {
mockGetBackgroundColorAndThresholdCheck.mockReturnValue({
threshold: {
thresholdFormat: 'Text',
thresholdColor: 'red',
} as any,
isConflictingThresholds: true,
});
const { getByTestId } = render(
<ValueGraph value="42" rawValue={42} thresholds={[]} />,
);
expect(getByTestId('conflicting-thresholds')).toBeInTheDocument();
});
it('does not display conflicting thresholds indicator when no conflict', () => {
mockGetBackgroundColorAndThresholdCheck.mockReturnValue({
threshold: {} as any,
isConflictingThresholds: false,
});
render(<ValueGraph value="42" rawValue={42} thresholds={[]} />);
expect(
screen.queryByTestId('conflicting-thresholds'),
).not.toBeInTheDocument();
});
it('applies text color to units when threshold format is Text', () => {
mockGetBackgroundColorAndThresholdCheck.mockReturnValue({
threshold: {
thresholdFormat: 'Text',
thresholdColor: 'green',
} as any,
isConflictingThresholds: false,
});
render(<ValueGraph value="42ms" rawValue={42} thresholds={[]} />);
const unitElement = screen.getByText('ms');
expect(unitElement).toHaveStyle({ color: 'green' });
});
it('renders decimal values correctly', () => {
const { getByTestId } = render(
<ValueGraph value="42.5" rawValue={42.5} thresholds={[]} />,
);
expect(getByTestId(TEST_ID_VALUE_GRAPH_TEXT)).toHaveTextContent('42.5');
});
it('handles values with M suffix', () => {
const { getByTestId } = render(
<ValueGraph value="1.2M" rawValue={1200000} thresholds={[]} />,
);
expect(getByTestId(TEST_ID_VALUE_GRAPH_TEXT)).toHaveTextContent('1.2M');
});
it('handles values with B suffix', () => {
const { getByTestId } = render(
<ValueGraph value="2.3B" rawValue={2300000000} thresholds={[]} />,
);
expect(getByTestId(TEST_ID_VALUE_GRAPH_TEXT)).toHaveTextContent('2.3B');
});
it('handles scientific notation values', () => {
const { getByTestId } = render(
<ValueGraph value="1e-9" rawValue={1e-9} thresholds={[]} />,
);
expect(getByTestId(TEST_ID_VALUE_GRAPH_TEXT)).toHaveTextContent('1e-9');
});
it('handles scientific notation with suffix unit', () => {
const { getByTestId } = render(
<ValueGraph value="1e-9%" rawValue={1e-9} thresholds={[]} />,
);
expect(getByTestId(TEST_ID_VALUE_GRAPH_TEXT)).toHaveTextContent('1e-9');
expect(getByTestId(TEST_ID_VALUE_GRAPH_SUFFIX_UNIT)).toHaveTextContent('%');
});
it('handles scientific notation with uppercase E', () => {
const { getByTestId } = render(
<ValueGraph value="1E-9" rawValue={1e-9} thresholds={[]} />,
);
expect(getByTestId(TEST_ID_VALUE_GRAPH_TEXT)).toHaveTextContent('1E-9');
});
it('handles scientific notation with positive exponent', () => {
const { getByTestId } = render(
<ValueGraph value="1e+9" rawValue={1e9} thresholds={[]} />,
);
expect(getByTestId(TEST_ID_VALUE_GRAPH_TEXT)).toHaveTextContent('1e+9');
});
});

View File

@@ -3,11 +3,39 @@ import './ValueGraph.styles.scss';
import { ExclamationCircleFilled } from '@ant-design/icons';
import { Tooltip, Typography } from 'antd';
import { ThresholdProps } from 'container/NewWidget/RightContainer/Threshold/types';
import { useEffect, useRef, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { getBackgroundColorAndThresholdCheck } from './utils';
function Unit({
type,
unit,
threshold,
fontSize,
}: {
type: 'prefix' | 'suffix';
unit: string;
threshold: ThresholdProps;
fontSize: string;
}): JSX.Element {
return (
<Typography.Text
className="value-graph-unit"
data-testid={`value-graph-${type}-unit`}
style={{
color:
threshold.thresholdFormat === 'Text'
? threshold.thresholdColor
: undefined,
fontSize: `calc(${fontSize} * 0.7)`,
}}
>
{unit}
</Typography.Text>
);
}
function ValueGraph({
value,
rawValue,
@@ -17,10 +45,16 @@ function ValueGraph({
const containerRef = useRef<HTMLDivElement>(null);
const [fontSize, setFontSize] = useState('2.5vw');
// Parse value to separate number and unit (assuming unit is at the end)
const matches = value.match(/([\d.]+[KMB]?)(.*)$/);
const numericValue = matches?.[1] || value;
const unit = matches?.[2]?.trim() || '';
const { numericValue, prefixUnit, suffixUnit } = useMemo(() => {
const matches = value.match(
/^([^\d.]*)?([\d.]+(?:[eE][+-]?[\d]+)?[KMB]?)([^\d.]*)?$/,
);
return {
numericValue: matches?.[2] || value,
prefixUnit: matches?.[1]?.trim() || '',
suffixUnit: matches?.[3]?.trim() || '',
};
}, [value]);
// Adjust font size based on container size
useEffect(() => {
@@ -65,8 +99,17 @@ function ValueGraph({
}}
>
<div className="value-text-container">
{prefixUnit && (
<Unit
type="prefix"
unit={prefixUnit}
threshold={threshold}
fontSize={fontSize}
/>
)}
<Typography.Text
className="value-graph-text"
data-testid="value-graph-text"
style={{
color:
threshold.thresholdFormat === 'Text'
@@ -77,19 +120,13 @@ function ValueGraph({
>
{numericValue}
</Typography.Text>
{unit && (
<Typography.Text
className="value-graph-unit"
style={{
color:
threshold.thresholdFormat === 'Text'
? threshold.thresholdColor
: undefined,
fontSize: `calc(${fontSize} * 0.7)`,
}}
>
{unit}
</Typography.Text>
{suffixUnit && (
<Unit
type="suffix"
unit={suffixUnit}
threshold={threshold}
fontSize={fontSize}
/>
)}
</div>
{isConflictingThresholds && (

View File

@@ -0,0 +1,208 @@
/**
* src/components/cmdKPalette/__test__/cmdkPalette.test.tsx
*/
import '@testing-library/jest-dom/extend-expect';
// ---- Mocks (must run BEFORE importing the component) ----
import ROUTES from 'constants/routes';
import history from 'lib/history';
import { render, screen, userEvent } from 'tests/test-utils';
import { CmdKPalette } from '../cmdKPalette';
const HOME_LABEL = 'Go to Home';
beforeAll(() => {
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
configurable: true,
value: jest.fn(),
});
});
afterAll(() => {
// restore
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
delete (HTMLElement.prototype as any).scrollIntoView;
});
// mock history.push / replace / go / location
jest.mock('lib/history', () => {
const location = { pathname: '/', search: '', hash: '' };
const stack: { pathname: string; search: string }[] = [
{ pathname: '/', search: '' },
];
const push = jest.fn((path: string) => {
const [rawPath, rawQuery] = path.split('?');
const pathname = rawPath || '/';
const search = path.includes('?') ? `?${rawQuery || ''}` : '';
location.pathname = pathname;
location.search = search;
stack.push({ pathname, search });
return undefined;
});
const replace = jest.fn((path: string) => {
const [rawPath, rawQuery] = path.split('?');
const pathname = rawPath || '/';
const search = path.includes('?') ? `?${rawQuery || ''}` : '';
location.pathname = pathname;
location.search = search;
if (stack.length > 0) {
stack[stack.length - 1] = { pathname, search };
} else {
stack.push({ pathname, search });
}
return undefined;
});
const listen = jest.fn();
const go = jest.fn((n: number) => {
if (n < 0 && stack.length > 1) {
stack.pop();
}
const top = stack[stack.length - 1] || { pathname: '/', search: '' };
location.pathname = top.pathname;
location.search = top.search;
});
return {
push,
replace,
listen,
go,
location,
__stack: stack,
};
});
// Mock ResizeObserver for Jest/jsdom
class ResizeObserver {
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type, class-methods-use-this
observe() {}
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type, class-methods-use-this
unobserve() {}
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type, class-methods-use-this
disconnect() {}
}
(global as any).ResizeObserver = ResizeObserver;
// mock cmdK provider hook (open state + setter)
const mockSetOpen = jest.fn();
jest.mock('providers/cmdKProvider', (): unknown => ({
useCmdK: (): {
open: boolean;
setOpen: jest.Mock;
openCmdK: jest.Mock;
closeCmdK: jest.Mock;
} => ({
open: true,
setOpen: mockSetOpen,
openCmdK: jest.fn(),
closeCmdK: jest.fn(),
}),
}));
// mock notifications hook
jest.mock('hooks/useNotifications', (): unknown => ({
useNotifications: (): { notifications: [] } => ({ notifications: [] }),
}));
// mock theme hook
jest.mock('hooks/useDarkMode', (): unknown => ({
useThemeMode: (): {
setAutoSwitch: jest.Mock;
setTheme: jest.Mock;
theme: string;
} => ({
setAutoSwitch: jest.fn(),
setTheme: jest.fn(),
theme: 'dark',
}),
}));
// mock updateUserPreference API and react-query mutation
jest.mock('api/v1/user/preferences/name/update', (): jest.Mock => jest.fn());
jest.mock('react-query', (): unknown => {
const actual = jest.requireActual('react-query');
return {
...actual,
useMutation: (): { mutate: jest.Mock } => ({ mutate: jest.fn() }),
};
});
// mock other side-effecty modules
jest.mock('api/common/logEvent', () => jest.fn());
jest.mock('api/browser/localstorage/set', () => jest.fn());
jest.mock('utils/error', () => ({ showErrorNotification: jest.fn() }));
// ---- Tests ----
describe('CmdKPalette', () => {
beforeEach(() => {
jest.clearAllMocks();
});
test('renders navigation and settings groups and items', () => {
render(<CmdKPalette userRole="ADMIN" />);
expect(screen.getByText('Navigation')).toBeInTheDocument();
expect(screen.getByText('Settings')).toBeInTheDocument();
expect(screen.getByText(HOME_LABEL)).toBeInTheDocument();
expect(screen.getByText('Go to Dashboards')).toBeInTheDocument();
expect(screen.getByText('Open Sidebar')).toBeInTheDocument();
expect(screen.getByText('Switch to Dark Mode')).toBeInTheDocument();
});
test('clicking a navigation item calls history.push with correct route', async () => {
render(<CmdKPalette userRole="ADMIN" />);
const homeItem = screen.getByText(HOME_LABEL);
await userEvent.click(homeItem);
expect(history.push).toHaveBeenCalledWith(ROUTES.HOME);
});
test('role-based filtering (basic smoke)', () => {
render(<CmdKPalette userRole="VIEWER" />);
// VIEWER still sees basic navigation items
expect(screen.getByText(HOME_LABEL)).toBeInTheDocument();
});
test('keyboard shortcut opens palette via setOpen', () => {
render(<CmdKPalette userRole="ADMIN" />);
const event = new KeyboardEvent('keydown', { key: 'k', ctrlKey: true });
window.dispatchEvent(event);
expect(mockSetOpen).toHaveBeenCalledWith(true);
});
test('items render with icons when provided', () => {
render(<CmdKPalette userRole="ADMIN" />);
const iconHolders = document.querySelectorAll('.cmd-item-icon');
expect(iconHolders.length).toBeGreaterThan(0);
expect(screen.getByText(HOME_LABEL)).toBeInTheDocument();
});
test('closing the palette via handleInvoke sets open to false', async () => {
render(<CmdKPalette userRole="ADMIN" />);
const dashItem = screen.getByText('Go to Dashboards');
await userEvent.click(dashItem);
// last call from handleInvoke should set open to false
expect(mockSetOpen).toHaveBeenCalledWith(false);
});
});

View File

@@ -0,0 +1,55 @@
/* Overlay stays below content */
[data-slot='dialog-overlay'] {
z-index: 50;
}
/* Dialog content always above overlay */
[data-slot='dialog-content'] {
position: fixed;
z-index: 60;
}
.cmdk-section-heading [cmdk-group-heading] {
text-transform: uppercase;
color: var(--bg-slate-100);
}
/* Hide scrollbar but keep scroll */
.cmdk-list-scroll {
scrollbar-width: none; /* Firefox */
}
.cmdk-list-scroll::-webkit-scrollbar {
display: none; /* Chrome, Safari, Edge */
}
.cmdk-list-scroll {
-webkit-overflow-scrolling: touch;
}
.cmdk-input-wrapper {
margin-left: 8px;
}
.cmdk-item-light:hover {
cursor: pointer;
background-color: var(--bg-vanilla-200) !important;
}
.cmdk-item-light[data-selected='true'] {
background-color: var(--bg-vanilla-300) !important;
color: var(--bg-ink-500);
}
.cmdk-item {
cursor: pointer;
}
[cmdk-item] svg {
width: auto;
height: auto;
}
.cmd-item-icon {
margin-right: 8px;
}

View File

@@ -0,0 +1,336 @@
import './cmdKPalette.scss';
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandShortcut,
} from '@signozhq/command';
import setLocalStorageApi from 'api/browser/localstorage/set';
import logEvent from 'api/common/logEvent';
import updateUserPreference from 'api/v1/user/preferences/name/update';
import { AxiosError } from 'axios';
import ROUTES from 'constants/routes';
import { USER_PREFERENCES } from 'constants/userPreferences';
import { useThemeMode } from 'hooks/useDarkMode';
import { THEME_MODE } from 'hooks/useDarkMode/constant';
import { useNotifications } from 'hooks/useNotifications';
import history from 'lib/history';
import {
BellDot,
BugIcon,
DraftingCompass,
Expand,
HardDrive,
Home,
LayoutGrid,
ListMinus,
ScrollText,
Settings,
} from 'lucide-react';
import React, { useEffect } from 'react';
import { useMutation } from 'react-query';
import { UserPreference } from 'types/api/preferences/preference';
import { showErrorNotification } from 'utils/error';
import { useAppContext } from '../../providers/App/App';
import { useCmdK } from '../../providers/cmdKProvider';
type CmdAction = {
id: string;
name: string;
shortcut?: string[];
keywords?: string;
section?: string;
icon?: React.ReactNode;
roles?: UserRole[];
perform: () => void;
};
type UserRole = 'ADMIN' | 'EDITOR' | 'AUTHOR' | 'VIEWER';
export function CmdKPalette({
userRole,
}: {
userRole: UserRole;
}): JSX.Element | null {
const { open, setOpen } = useCmdK();
const { updateUserPreferenceInContext } = useAppContext();
const { notifications } = useNotifications();
const { setAutoSwitch, setTheme, theme } = useThemeMode();
const { mutate: updateUserPreferenceMutation } = useMutation(
updateUserPreference,
{
onError: (error) => {
showErrorNotification(notifications, error as AxiosError);
},
},
);
// toggle palette with ⌘/Ctrl+K
function handleGlobalCmdK(
e: KeyboardEvent,
setOpen: React.Dispatch<React.SetStateAction<boolean>>,
): void {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
e.preventDefault();
setOpen(true);
}
}
const cmdKEffect = (): void | (() => void) => {
const listener = (e: KeyboardEvent): void => {
handleGlobalCmdK(e, setOpen);
};
window.addEventListener('keydown', listener);
return (): void => {
window.removeEventListener('keydown', listener);
setOpen(false);
};
};
useEffect(cmdKEffect, [setOpen]);
function handleThemeChange(value: string): void {
logEvent('Account Settings: Theme Changed', { theme: value });
if (value === 'auto') {
setAutoSwitch(true);
} else {
setAutoSwitch(false);
setTheme(value);
}
}
function onClickHandler(key: string): void {
history.push(key);
}
function handleOpenSidebar(): void {
setLocalStorageApi(USER_PREFERENCES.SIDENAV_PINNED, 'true');
const save = { name: USER_PREFERENCES.SIDENAV_PINNED, value: true };
updateUserPreferenceInContext(save as UserPreference);
updateUserPreferenceMutation({
name: USER_PREFERENCES.SIDENAV_PINNED,
value: true,
});
}
function handleCloseSidebar(): void {
setLocalStorageApi(USER_PREFERENCES.SIDENAV_PINNED, 'false');
const save = { name: USER_PREFERENCES.SIDENAV_PINNED, value: false };
updateUserPreferenceInContext(save as UserPreference);
updateUserPreferenceMutation({
name: USER_PREFERENCES.SIDENAV_PINNED,
value: false,
});
}
const actions: CmdAction[] = [
{
id: 'home',
name: 'Go to Home',
shortcut: ['shift + h'],
keywords: 'home',
section: 'Navigation',
icon: <Home size={14} />,
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
perform: (): void => onClickHandler(ROUTES.HOME),
},
{
id: 'dashboards',
name: 'Go to Dashboards',
shortcut: ['shift + d'],
keywords: 'dashboards',
section: 'Navigation',
icon: <LayoutGrid size={14} />,
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
perform: (): void => onClickHandler(ROUTES.ALL_DASHBOARD),
},
{
id: 'services',
name: 'Go to Services',
shortcut: ['shift + s'],
keywords: 'services monitoring',
section: 'Navigation',
icon: <HardDrive size={14} />,
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
perform: (): void => onClickHandler(ROUTES.APPLICATION),
},
{
id: 'traces',
name: 'Go to Traces',
shortcut: ['shift + t'],
keywords: 'traces',
section: 'Navigation',
icon: <DraftingCompass size={14} />,
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
perform: (): void => onClickHandler(ROUTES.TRACES_EXPLORER),
},
{
id: 'logs',
name: 'Go to Logs',
shortcut: ['shift + l'],
keywords: 'logs',
section: 'Navigation',
icon: <ScrollText size={14} />,
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
perform: (): void => onClickHandler(ROUTES.LOGS),
},
{
id: 'alerts',
name: 'Go to Alerts',
shortcut: ['shift + a'],
keywords: 'alerts',
section: 'Navigation',
icon: <BellDot size={14} />,
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
perform: (): void => onClickHandler(ROUTES.LIST_ALL_ALERT),
},
{
id: 'exceptions',
name: 'Go to Exceptions',
shortcut: ['shift + e'],
keywords: 'exceptions errors',
section: 'Navigation',
icon: <BugIcon size={14} />,
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
perform: (): void => onClickHandler(ROUTES.ALL_ERROR),
},
{
id: 'messaging-queues',
name: 'Go to Messaging Queues',
shortcut: ['shift + m'],
keywords: 'messaging queues mq',
section: 'Navigation',
icon: <ListMinus size={14} />,
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
perform: (): void => onClickHandler(ROUTES.MESSAGING_QUEUES_OVERVIEW),
},
{
id: 'my-settings',
name: 'Go to Account Settings',
keywords: 'account settings',
section: 'Navigation',
icon: <Settings size={14} />,
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
perform: (): void => onClickHandler(ROUTES.MY_SETTINGS),
},
// Settings
{
id: 'open-sidebar',
name: 'Open Sidebar',
keywords: 'sidebar navigation menu expand',
section: 'Settings',
icon: <Expand size={14} />,
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
perform: (): void => handleOpenSidebar(),
},
{
id: 'collapse-sidebar',
name: 'Collapse Sidebar',
keywords: 'sidebar navigation menu collapse',
section: 'Settings',
icon: <Expand size={14} />,
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
perform: (): void => handleCloseSidebar(),
},
{
id: 'dark-mode',
name: 'Switch to Dark Mode',
keywords: 'theme dark mode appearance',
section: 'Settings',
icon: <Expand size={14} />,
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
perform: (): void => handleThemeChange(THEME_MODE.DARK),
},
{
id: 'light-mode',
name: 'Switch to Light Mode [Beta]',
keywords: 'theme light mode appearance',
section: 'Settings',
icon: <Expand size={14} />,
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
perform: (): void => handleThemeChange(THEME_MODE.LIGHT),
},
{
id: 'system-theme',
name: 'Switch to System Theme',
keywords: 'system theme appearance',
section: 'Settings',
icon: <Expand size={14} />,
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
perform: (): void => handleThemeChange(THEME_MODE.SYSTEM),
},
];
// RBAC filter: show action if no roles set OR current user role is included
const permitted = actions.filter(
(a) => !a.roles || a.roles.includes(userRole),
);
// group permitted actions by section
const grouped: [string, CmdAction[]][] = ((): [string, CmdAction[]][] => {
const map = new Map<string, CmdAction[]>();
permitted.forEach((a) => {
const section = a.section ?? 'Other';
const existing = map.get(section);
if (existing) {
existing.push(a);
} else {
map.set(section, [a]);
}
});
return Array.from(map.entries());
})();
const handleInvoke = (action: CmdAction): void => {
try {
action.perform();
} catch (e) {
console.error('Error invoking action', e);
} finally {
setOpen(false);
}
};
return (
<CommandDialog open={open} onOpenChange={setOpen} position="top" offset={110}>
<CommandInput placeholder="Search…" className="cmdk-input-wrapper" />
<CommandList className="cmdk-list-scroll">
<CommandEmpty>No results</CommandEmpty>
{grouped.map(([section, items]) => (
<CommandGroup
key={section}
heading={section}
className="cmdk-section-heading"
>
{items.map((it) => (
<CommandItem
key={it.id}
onSelect={(): void => handleInvoke(it)}
value={it.name}
className={theme === 'light' ? 'cmdk-item-light' : 'cmdk-item'}
>
<span className="cmd-item-icon">{it.icon}</span>
{it.name}
{it.shortcut && it.shortcut.length > 0 && (
<CommandShortcut>{it.shortcut.join(' • ')}</CommandShortcut>
)}
</CommandItem>
))}
</CommandGroup>
))}
</CommandList>
</CommandDialog>
);
}

View File

@@ -1,4 +1,7 @@
export const REACT_QUERY_KEY = {
GET_PUBLIC_DASHBOARD: 'GET_PUBLIC_DASHBOARD',
GET_PUBLIC_DASHBOARD_META: 'GET_PUBLIC_DASHBOARD_META',
GET_PUBLIC_DASHBOARD_WIDGET_DATA: 'GET_PUBLIC_DASHBOARD_WIDGET_DATA',
GET_ALL_LICENCES: 'GET_ALL_LICENCES',
GET_QUERY_RANGE: 'GET_QUERY_RANGE',
GET_ALL_DASHBOARDS: 'GET_ALL_DASHBOARDS',

View File

@@ -81,6 +81,7 @@ const ROUTES = {
METER_EXPLORER: '/meter/explorer',
METER_EXPLORER_VIEWS: '/meter/explorer/views',
HOME_PAGE: '/',
PUBLIC_DASHBOARD: '/public/dashboard/:dashboardId',
} as const;
export default ROUTES;

View File

@@ -391,6 +391,9 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
const routeKey = useMemo(() => getRouteKey(pathname), [pathname]);
const pageTitle = t(routeKey);
const isPublicDashboard = pathname.startsWith('/public/dashboard/');
const renderFullScreen =
pathname === ROUTES.GET_STARTED ||
pathname === ROUTES.ONBOARDING ||
@@ -399,7 +402,8 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
pathname === ROUTES.GET_STARTED_INFRASTRUCTURE_MONITORING ||
pathname === ROUTES.GET_STARTED_LOGS_MANAGEMENT ||
pathname === ROUTES.GET_STARTED_AWS_MONITORING ||
pathname === ROUTES.GET_STARTED_AZURE_MONITORING;
pathname === ROUTES.GET_STARTED_AZURE_MONITORING ||
isPublicDashboard;
const [showTrialExpiryBanner, setShowTrialExpiryBanner] = useState(false);

View File

@@ -266,7 +266,10 @@ export default function CustomDomainSettings(): JSX.Element {
<div className="custom-domain-settings-modal-error">
{updateDomainError.status === 409 ? (
<Alert
message="Youve already updated the custom domain once today. To make further changes, please contact our support team for assistance."
message={
(updateDomainError?.response?.data as { error?: string })?.error ||
'Youve already updated the custom domain once today. To make further changes, please contact our support team for assistance.'
}
type="warning"
className="update-limit-reached-error"
/>

View File

@@ -138,9 +138,9 @@ function ErrorDetails(props: ErrorDetailsProps): JSX.Element {
const { formatTimezoneAdjustedTimestamp } = useTimezone();
return (
<>
<Typography>{errorDetail.exceptionType}</Typography>
<Typography>{errorDetail.exceptionMessage}</Typography>
<div className="error-details-container">
<Typography.Title level={4}>{errorDetail.exceptionType}</Typography.Title>
<Typography.Text>{errorDetail.exceptionMessage}</Typography.Text>
<Divider />
<EventContainer>
@@ -200,7 +200,7 @@ function ErrorDetails(props: ErrorDetailsProps): JSX.Element {
<ResizeTable columns={columns} tableLayout="fixed" dataSource={data} />
</Space>
</EditorContainer>
</>
</div>
);
}

View File

@@ -1,3 +1,7 @@
.error-details-container {
padding: 16px;
}
.error-container {
height: 50vh;
}

View File

@@ -393,15 +393,21 @@ function ExplorerOptions({
backwardCompatibleOptions = omit(options, 'version');
}
// Use the correct default columns based on the current data source
const defaultColumns =
sourcepage === DataSource.TRACES
? defaultTraceSelectedColumns
: defaultLogsSelectedColumns;
if (extraData.selectColumns?.length) {
handleOptionsChange({
...backwardCompatibleOptions,
selectColumns: extraData.selectColumns,
});
} else if (!isEqual(defaultTraceSelectedColumns, options.selectColumns)) {
} else if (!isEqual(defaultColumns, options.selectColumns)) {
handleOptionsChange({
...backwardCompatibleOptions,
selectColumns: defaultTraceSelectedColumns,
selectColumns: defaultColumns,
});
}
};

View File

@@ -0,0 +1,469 @@
import { render as rtlRender, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { RowData } from 'lib/query/createTableColumnsFromQuery';
import { AppContext } from 'providers/App/App';
import { IAppContext } from 'providers/App/types';
import React, { MutableRefObject } from 'react';
import { QueryClient, QueryClientProvider, UseQueryResult } from 'react-query';
import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
import configureStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import { SuccessResponse, Warning } from 'types/api';
import { Widgets } from 'types/api/dashboard/getAll';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { EQueryType } from 'types/common/dashboard';
import { ROLES } from 'types/roles';
import { MenuItemKeys } from '../contants';
import WidgetHeader from '../index';
const TEST_WIDGET_TITLE = 'Test Widget';
const TABLE_WIDGET_TITLE = 'Table Widget';
const WIDGET_HEADER_SEARCH = 'widget-header-search';
const WIDGET_HEADER_SEARCH_INPUT = 'widget-header-search-input';
const TEST_WIDGET_TITLE_RESOLVED = 'Test Widget Title';
const mockStore = configureStore([thunk]);
const createMockStore = (): ReturnType<typeof mockStore> =>
mockStore({
app: {
role: 'ADMIN',
user: {
userId: 'test-user-id',
email: 'test@signoz.io',
name: 'TestUser',
},
isLoggedIn: true,
org: [],
},
globalTime: {
minTime: '2023-01-01T00:00:00Z',
maxTime: '2023-01-02T00:00:00Z',
},
});
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
retry: false,
},
},
});
const createMockAppContext = (): Partial<IAppContext> => ({
user: {
accessJwt: '',
refreshJwt: '',
id: '',
email: '',
displayName: '',
createdAt: 0,
organization: '',
orgId: '',
role: 'ADMIN' as ROLES,
},
});
const render = (ui: React.ReactElement): ReturnType<typeof rtlRender> =>
rtlRender(
<MemoryRouter>
<QueryClientProvider client={queryClient}>
<Provider store={createMockStore()}>
<AppContext.Provider value={createMockAppContext() as IAppContext}>
{ui}
</AppContext.Provider>
</Provider>
</QueryClientProvider>
</MemoryRouter>,
);
jest.mock('hooks/queryBuilder/useCreateAlerts', () => ({
__esModule: true,
default: jest.fn(() => jest.fn()),
}));
jest.mock('hooks/dashboard/useGetResolvedText', () => {
// eslint-disable-next-line sonarjs/no-duplicate-string
const TEST_WIDGET_TITLE_RESOLVED = 'Test Widget Title';
return {
__esModule: true,
default: jest.fn(() => ({
truncatedText: TEST_WIDGET_TITLE_RESOLVED,
fullText: TEST_WIDGET_TITLE_RESOLVED,
})),
};
});
jest.mock('lucide-react', () => ({
CircleX: (): JSX.Element => <svg data-testid="lucide-circle-x" />,
TriangleAlert: (): JSX.Element => <svg data-testid="lucide-triangle-alert" />,
X: (): JSX.Element => <svg data-testid="lucide-x" />,
}));
jest.mock('antd', () => ({
...jest.requireActual('antd'),
Spin: (): JSX.Element => <div data-testid="antd-spin" />,
}));
const mockWidget: Widgets = {
id: 'test-widget-id',
title: TEST_WIDGET_TITLE,
description: 'Test Description',
panelTypes: PANEL_TYPES.TIME_SERIES,
query: {
builder: {
queryData: [],
queryFormulas: [],
queryTraceOperator: [],
},
promql: [],
clickhouse_sql: [],
id: 'query-id',
queryType: 'builder' as EQueryType,
},
timePreferance: 'GLOBAL_TIME',
opacity: '',
nullZeroValues: '',
yAxisUnit: '',
fillSpans: false,
softMin: null,
softMax: null,
selectedLogFields: [],
selectedTracesFields: [],
};
const mockQueryResponse = ({
data: {
payload: {
data: {
result: [],
resultType: '',
},
},
statusCode: 200,
message: 'success',
error: null,
},
isLoading: false,
isError: false,
error: null,
isFetching: false,
} as unknown) as UseQueryResult<
SuccessResponse<MetricRangePayloadProps, unknown> & {
warning?: Warning;
},
Error
>;
describe('WidgetHeader', () => {
const mockOnView = jest.fn();
const mockSetSearchTerm = jest.fn();
const tableProcessedDataRef: MutableRefObject<RowData[]> = {
current: [
{
timestamp: 1234567890,
key: 'key1',
col1: 'val1',
col2: 'val2',
},
],
};
beforeEach(() => {
jest.clearAllMocks();
});
it('renders widget header with title', () => {
render(
<WidgetHeader
title={TEST_WIDGET_TITLE}
widget={mockWidget}
onView={mockOnView}
parentHover={false}
queryResponse={mockQueryResponse}
isWarning={false}
isFetchingResponse={false}
tableProcessedDataRef={tableProcessedDataRef}
setSearchTerm={mockSetSearchTerm}
/>,
);
expect(screen.getByText(TEST_WIDGET_TITLE_RESOLVED)).toBeInTheDocument();
});
it('returns null for empty widget', () => {
const emptyWidget = {
...mockWidget,
id: PANEL_TYPES.EMPTY_WIDGET,
};
const { container } = render(
<WidgetHeader
title="Empty Widget"
widget={emptyWidget}
onView={mockOnView}
parentHover={false}
queryResponse={mockQueryResponse}
isWarning={false}
isFetchingResponse={false}
tableProcessedDataRef={tableProcessedDataRef}
setSearchTerm={mockSetSearchTerm}
/>,
);
expect(container.innerHTML).toBe('');
});
it('shows search input for table panels', async () => {
const tableWidget = {
...mockWidget,
panelTypes: PANEL_TYPES.TABLE,
};
render(
<WidgetHeader
title={TABLE_WIDGET_TITLE}
widget={tableWidget}
onView={mockOnView}
parentHover={false}
queryResponse={mockQueryResponse}
isWarning={false}
isFetchingResponse={false}
tableProcessedDataRef={tableProcessedDataRef}
setSearchTerm={mockSetSearchTerm}
/>,
);
const searchIcon = screen.getByTestId(WIDGET_HEADER_SEARCH);
expect(searchIcon).toBeInTheDocument();
await userEvent.click(searchIcon);
expect(screen.getByTestId(WIDGET_HEADER_SEARCH_INPUT)).toBeInTheDocument();
});
it('handles search input changes and closing', async () => {
const tableWidget = {
...mockWidget,
panelTypes: PANEL_TYPES.TABLE,
};
render(
<WidgetHeader
title={TABLE_WIDGET_TITLE}
widget={tableWidget}
onView={mockOnView}
parentHover={false}
queryResponse={mockQueryResponse}
isWarning={false}
isFetchingResponse={false}
tableProcessedDataRef={tableProcessedDataRef}
setSearchTerm={mockSetSearchTerm}
/>,
);
const searchIcon = screen.getByTestId(`${WIDGET_HEADER_SEARCH}`);
await userEvent.click(searchIcon);
const searchInput = screen.getByTestId(WIDGET_HEADER_SEARCH_INPUT);
await userEvent.type(searchInput, 'test search');
expect(mockSetSearchTerm).toHaveBeenCalledWith('test search');
const closeButton = screen
.getByTestId(WIDGET_HEADER_SEARCH_INPUT)
.parentElement?.querySelector('.search-header-icons');
if (closeButton) {
await userEvent.click(closeButton);
expect(mockSetSearchTerm).toHaveBeenCalledWith('');
}
});
it('shows error icon when query has error', () => {
const errorResponse = {
...mockQueryResponse,
isError: true as const,
error: { message: 'Test error' } as Error,
data: undefined,
} as UseQueryResult<
SuccessResponse<MetricRangePayloadProps, unknown> & {
warning?: Warning;
},
Error
>;
render(
<WidgetHeader
title={TEST_WIDGET_TITLE}
widget={mockWidget}
onView={mockOnView}
parentHover={false}
queryResponse={errorResponse}
isWarning={false}
isFetchingResponse={false}
tableProcessedDataRef={tableProcessedDataRef}
setSearchTerm={mockSetSearchTerm}
/>,
);
// check if CircleX icon is rendered
const circleXIcon = screen.getByTestId('lucide-circle-x');
expect(circleXIcon).toBeInTheDocument();
});
it('shows warning icon when query has warning', () => {
const warningData = mockQueryResponse.data
? {
...mockQueryResponse.data,
warning: {
code: 'WARNING_CODE',
message: 'Test warning',
url: 'https://example.com',
warnings: [{ message: 'Test warning' }],
} as Warning,
}
: undefined;
const warningResponse = {
...mockQueryResponse,
data: warningData,
} as UseQueryResult<
SuccessResponse<MetricRangePayloadProps, unknown> & {
warning?: Warning;
},
Error
>;
render(
<WidgetHeader
title={TEST_WIDGET_TITLE}
widget={mockWidget}
onView={mockOnView}
parentHover={false}
queryResponse={warningResponse}
isWarning
isFetchingResponse={false}
tableProcessedDataRef={tableProcessedDataRef}
setSearchTerm={mockSetSearchTerm}
/>,
);
const triangleAlertIcon = screen.getByTestId('lucide-triangle-alert');
expect(triangleAlertIcon).toBeInTheDocument();
});
it('shows spinner when fetching response', () => {
const fetchingResponse = {
...mockQueryResponse,
isFetching: true,
isLoading: true,
} as UseQueryResult<
SuccessResponse<MetricRangePayloadProps, unknown> & {
warning?: Warning;
},
Error
>;
render(
<WidgetHeader
title={TEST_WIDGET_TITLE}
widget={mockWidget}
onView={mockOnView}
parentHover={false}
queryResponse={fetchingResponse}
isWarning={false}
isFetchingResponse
tableProcessedDataRef={tableProcessedDataRef}
setSearchTerm={mockSetSearchTerm}
/>,
);
const antSpin = screen.getByTestId('antd-spin');
expect(antSpin).toBeInTheDocument();
});
it('renders menu options icon', () => {
render(
<WidgetHeader
title={TEST_WIDGET_TITLE}
widget={mockWidget}
onView={mockOnView}
parentHover={false}
queryResponse={mockQueryResponse}
isWarning={false}
isFetchingResponse={false}
tableProcessedDataRef={tableProcessedDataRef}
setSearchTerm={mockSetSearchTerm}
headerMenuList={[MenuItemKeys.View]}
/>,
);
const moreOptionsIcon = screen.getByTestId('widget-header-options');
expect(moreOptionsIcon).toBeInTheDocument();
});
it('shows search icon for table panels', () => {
const tableWidget = {
...mockWidget,
panelTypes: PANEL_TYPES.TABLE,
};
render(
<WidgetHeader
title={TABLE_WIDGET_TITLE}
widget={tableWidget}
onView={mockOnView}
parentHover={false}
queryResponse={mockQueryResponse}
isWarning={false}
isFetchingResponse={false}
tableProcessedDataRef={tableProcessedDataRef}
setSearchTerm={mockSetSearchTerm}
/>,
);
const searchIcon = screen.getByTestId(WIDGET_HEADER_SEARCH);
expect(searchIcon).toBeInTheDocument();
});
it('does not show search icon for non-table panels', () => {
render(
<WidgetHeader
title={TEST_WIDGET_TITLE}
widget={mockWidget}
onView={mockOnView}
parentHover={false}
queryResponse={mockQueryResponse}
isWarning={false}
isFetchingResponse={false}
tableProcessedDataRef={tableProcessedDataRef}
setSearchTerm={mockSetSearchTerm}
/>,
);
const searchIcon = screen.queryByTestId(WIDGET_HEADER_SEARCH);
expect(searchIcon).not.toBeInTheDocument();
});
it('renders threshold when provided', () => {
const threshold = <div data-testid="threshold">Threshold Component</div>;
render(
<WidgetHeader
title={TEST_WIDGET_TITLE}
widget={mockWidget}
onView={mockOnView}
parentHover={false}
queryResponse={mockQueryResponse}
isWarning={false}
isFetchingResponse={false}
tableProcessedDataRef={tableProcessedDataRef}
setSearchTerm={mockSetSearchTerm}
threshold={threshold}
/>,
);
expect(screen.getByTestId('threshold')).toBeInTheDocument();
});
});

View File

@@ -35,6 +35,7 @@ import { SuccessResponse, Warning } from 'types/api';
import { Widgets } from 'types/api/dashboard/getAll';
import APIError from 'types/api/error';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { buildAbsolutePath } from 'utils/app';
import { errorTooltipPosition } from './config';
import { MENUITEM_KEYS_VS_LABELS, MenuItemKeys } from './contants';
@@ -87,7 +88,10 @@ function WidgetHeader({
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(widget.query)),
);
const generatedUrl = `${window.location.pathname}/new?${urlQuery}`;
const generatedUrl = buildAbsolutePath({
relativePath: 'new',
urlQueryString: urlQuery.toString(),
});
safeNavigate(generatedUrl);
}, [safeNavigate, urlQuery, widget.id, widget.panelTypes, widget.query]);
@@ -240,6 +244,7 @@ function WidgetHeader({
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
setSearchTerm('');
setShowGlobalSearch(false);
}}
className="search-header-icons"
@@ -304,14 +309,19 @@ function WidgetHeader({
data-testid="widget-header-search"
/>
)}
<Dropdown menu={menu} trigger={['hover']} placement="bottomRight">
<MoreOutlined
data-testid="widget-header-options"
className={`widget-header-more-options ${
parentHover ? 'widget-header-hover' : ''
} ${globalSearchAvailable ? 'widget-header-more-options-visible' : ''}`}
/>
</Dropdown>
{menu && Array.isArray(menu.items) && menu.items.length > 0 && (
<Dropdown menu={menu} trigger={['hover']} placement="bottomRight">
<MoreOutlined
data-testid="widget-header-options"
className={`widget-header-more-options ${
parentHover ? 'widget-header-hover' : ''
} ${
globalSearchAvailable ? 'widget-header-more-options-visible' : ''
}`}
/>
</Dropdown>
)}
</div>
</>
)}

View File

@@ -7,6 +7,9 @@ import {
QUERY_BUILDER_FUNCTIONS,
} from 'constants/antlrQueryConstants';
import { useActiveLog } from 'hooks/logs/useActiveLog';
import { useNotifications } from 'hooks/useNotifications';
import { useCallback } from 'react';
import { useCopyToClipboard } from 'react-use';
import { TitleWrapper } from './BodyTitleRenderer.styles';
import { DROPDOWN_KEY } from './constant';
@@ -24,6 +27,8 @@ function BodyTitleRenderer({
value,
}: BodyTitleRendererProps): JSX.Element {
const { onAddToQuery } = useActiveLog();
const [, setCopy] = useCopyToClipboard();
const { notifications } = useNotifications();
const filterHandler = (isFilterIn: boolean) => (): void => {
if (parentIsArray) {
@@ -75,18 +80,53 @@ function BodyTitleRenderer({
onClick: onClickHandler,
};
const handleTextSelection = (e: React.MouseEvent): void => {
// Prevent tree node click when user is trying to select text
e.stopPropagation();
};
const handleNodeClick = useCallback(
(e: React.MouseEvent): void => {
// Prevent tree node expansion/collapse
e.stopPropagation();
const cleanedKey = removeObjectFromString(nodeKey);
let copyText: string;
// Check if value is an object or array
const isObject = typeof value === 'object' && value !== null;
if (isObject) {
// For objects/arrays, stringify the entire structure
copyText = `"${cleanedKey}": ${JSON.stringify(value, null, 2)}`;
} else if (parentIsArray) {
// For array elements, copy just the value
copyText = `"${cleanedKey}": ${value}`;
} else {
// For primitive values, format as JSON key-value pair
const valueStr = typeof value === 'string' ? `"${value}"` : String(value);
copyText = `"${cleanedKey}": ${valueStr}`;
}
setCopy(copyText);
if (copyText) {
const notificationMessage = isObject
? `${cleanedKey} object copied to clipboard`
: `${cleanedKey} copied to clipboard`;
notifications.success({
message: notificationMessage,
key: notificationMessage,
});
}
},
[nodeKey, parentIsArray, setCopy, value, notifications],
);
return (
<TitleWrapper onMouseDown={handleTextSelection}>
<Dropdown menu={menu} trigger={['click']}>
<SettingOutlined style={{ marginRight: 8 }} className="hover-reveal" />
</Dropdown>
<TitleWrapper onClick={handleNodeClick}>
{typeof value !== 'object' && (
<Dropdown menu={menu} trigger={['click']}>
<SettingOutlined style={{ marginRight: 8 }} className="hover-reveal" />
</Dropdown>
)}
{title.toString()}{' '}
{!parentIsArray && (
{!parentIsArray && typeof value !== 'object' && (
<span>
: <span style={{ color: orange[6] }}>{`${value}`}</span>
</span>

View File

@@ -202,9 +202,7 @@ export default function TableViewActions(
if (record.field === 'body') {
return (
<div className={cx('value-field', isOpen ? 'open-popover' : '')}>
<CopyClipboardHOC entityKey={fieldFilterKey} textToCopy={textToCopy}>
<BodyContent fieldData={fieldData} record={record} bodyHtml={bodyHtml} />
</CopyClipboardHOC>
<BodyContent fieldData={fieldData} record={record} bodyHtml={bodyHtml} />
{!isListViewPanel && !RESTRICTED_SELECTED_FIELDS.includes(fieldFilterKey) && (
<span className="action-btn">
<Tooltip title="Filter for value">

View File

@@ -0,0 +1,109 @@
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import BodyTitleRenderer from '../BodyTitleRenderer';
let mockSetCopy: jest.Mock;
const mockNotification = jest.fn();
jest.mock('hooks/logs/useActiveLog', () => ({
useActiveLog: (): any => ({
onAddToQuery: jest.fn(),
}),
}));
jest.mock('react-use', () => ({
useCopyToClipboard: (): any => {
mockSetCopy = jest.fn();
return [{ value: null }, mockSetCopy];
},
}));
jest.mock('hooks/useNotifications', () => ({
useNotifications: (): any => ({
notifications: {
success: mockNotification,
error: jest.fn(),
info: jest.fn(),
warning: jest.fn(),
open: jest.fn(),
destroy: jest.fn(),
},
}),
}));
describe('BodyTitleRenderer', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should copy primitive value when node is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<BodyTitleRenderer
title="name"
nodeKey="user.name"
value="John"
parentIsArray={false}
/>,
);
await user.click(screen.getByText('name'));
await waitFor(() => {
expect(mockSetCopy).toHaveBeenCalledWith('"user.name": "John"');
expect(mockNotification).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringContaining('user.name'),
}),
);
});
});
it('should copy array element value when clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<BodyTitleRenderer
title="0"
nodeKey="items[*].0"
value="arrayElement"
parentIsArray
/>,
);
await user.click(screen.getByText('0'));
await waitFor(() => {
expect(mockSetCopy).toHaveBeenCalledWith('"items[*].0": arrayElement');
});
});
it('should copy entire object when object node is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const testObject = { id: 123, active: true };
render(
<BodyTitleRenderer
title="metadata"
nodeKey="user.metadata"
value={testObject}
parentIsArray={false}
/>,
);
await user.click(screen.getByText('metadata'));
await waitFor(() => {
const callArg = mockSetCopy.mock.calls[0][0];
expect(callArg).toContain('"user.metadata":');
expect(callArg).toContain('"id": 123');
expect(callArg).toContain('"active": true');
expect(mockNotification).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringContaining('object copied'),
}),
);
});
});
});

View File

@@ -39,9 +39,17 @@ export const computeDataNode = (
valueIsArray: boolean,
value: unknown,
nodeKey: string,
parentIsArray: boolean,
): DataNode => ({
key: uniqueId(),
title: `${key} ${valueIsArray ? '[...]' : ''}`,
title: (
<BodyTitleRenderer
title={`${key} ${valueIsArray ? '[...]' : ''}`}
nodeKey={nodeKey}
value={value}
parentIsArray={parentIsArray}
/>
),
// eslint-disable-next-line @typescript-eslint/no-use-before-define
children: jsonToDataNodes(
value as Record<string, unknown>,
@@ -67,7 +75,7 @@ export function jsonToDataNodes(
if (parentIsArray) {
if (typeof value === 'object' && value !== null) {
return computeDataNode(key, valueIsArray, value, nodeKey);
return computeDataNode(key, valueIsArray, value, nodeKey, parentIsArray);
}
return {
@@ -85,7 +93,7 @@ export function jsonToDataNodes(
}
if (typeof value === 'object' && value !== null) {
return computeDataNode(key, valueIsArray, value, nodeKey);
return computeDataNode(key, valueIsArray, value, nodeKey, parentIsArray);
}
return {
key: uniqueId(),

View File

@@ -0,0 +1,366 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
import { ExplorerViews } from 'pages/LogsExplorer/utils';
import { cleanup, render, screen, waitFor } from 'tests/test-utils';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Query, QueryState } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { DataSource, QueryBuilderContextType } from 'types/common/queryBuilder';
import { explorerViewToPanelType } from 'utils/explorerUtils';
import LogExplorerQuerySection from './index';
const CM_EDITOR_SELECTOR = '.cm-editor .cm-content';
const QUERY_AGGREGATION_TEST_ID = 'query-aggregation-container';
const QUERY_ADDON_TEST_ID = 'query-add-ons';
// Mock DOM APIs that CodeMirror needs
beforeAll(() => {
// Mock getClientRects and getBoundingClientRect for Range objects
const mockRect: DOMRect = {
width: 100,
height: 20,
top: 0,
left: 0,
right: 100,
bottom: 20,
x: 0,
y: 0,
toJSON: (): DOMRect => mockRect,
} as DOMRect;
// Create a minimal Range mock with only what CodeMirror actually uses
const createMockRange = (): Range => {
let startContainer: Node = document.createTextNode('');
let endContainer: Node = document.createTextNode('');
let startOffset = 0;
let endOffset = 0;
const rectList = {
length: 1,
item: (index: number): DOMRect | null => (index === 0 ? mockRect : null),
0: mockRect,
};
const mockRange = {
// CodeMirror uses these for text measurement
getClientRects: (): DOMRectList => (rectList as unknown) as DOMRectList,
getBoundingClientRect: (): DOMRect => mockRect,
// CodeMirror calls these to set up text ranges
setStart: (node: Node, offset: number): void => {
startContainer = node;
startOffset = offset;
},
setEnd: (node: Node, offset: number): void => {
endContainer = node;
endOffset = offset;
},
// Minimal Range properties (TypeScript requires these)
get startContainer(): Node {
return startContainer;
},
get endContainer(): Node {
return endContainer;
},
get startOffset(): number {
return startOffset;
},
get endOffset(): number {
return endOffset;
},
get collapsed(): boolean {
return startContainer === endContainer && startOffset === endOffset;
},
commonAncestorContainer: document.body,
};
return (mockRange as unknown) as Range;
};
// Mock document.createRange to return a new Range instance each time
document.createRange = (): Range => createMockRange();
// Mock getBoundingClientRect for elements
Element.prototype.getBoundingClientRect = (): DOMRect => mockRect;
});
jest.mock('hooks/useDarkMode', () => ({
useIsDarkMode: (): boolean => false,
}));
jest.mock('providers/Dashboard/Dashboard', () => ({
useDashboard: (): { selectedDashboard: undefined } => ({
selectedDashboard: undefined,
}),
}));
jest.mock('api/querySuggestions/getKeySuggestions', () => ({
getKeySuggestions: jest.fn().mockResolvedValue({
data: {
data: { keys: {} },
},
}),
}));
jest.mock('api/querySuggestions/getValueSuggestion', () => ({
getValueSuggestions: jest.fn().mockResolvedValue({
data: { data: { values: { stringValues: [], numberValues: [] } } },
}),
}));
// Mock the hooks
jest.mock('hooks/queryBuilder/useGetPanelTypesQueryParam');
jest.mock('hooks/queryBuilder/useShareBuilderUrl');
const mockUseGetPanelTypesQueryParam = jest.mocked(useGetPanelTypesQueryParam);
const mockUseShareBuilderUrl = jest.mocked(useShareBuilderUrl);
const mockUpdateAllQueriesOperators = jest.fn() as jest.MockedFunction<
(query: Query, panelType: PANEL_TYPES, dataSource: DataSource) => Query
>;
const mockResetQuery = jest.fn() as jest.MockedFunction<
(newCurrentQuery?: QueryState) => void
>;
const mockRedirectWithQueryBuilderData = jest.fn() as jest.MockedFunction<
(query: Query) => void
>;
// Create a mock query that we'll use to verify persistence
const createMockQuery = (filterExpression?: string): Query => ({
id: 'test-query-id',
queryType: EQueryType.QUERY_BUILDER,
builder: {
queryData: [
{
aggregateAttribute: {
id: 'body--string----false',
dataType: DataTypes.String,
key: 'body',
type: '',
},
aggregateOperator: 'count',
dataSource: DataSource.LOGS,
disabled: false,
expression: 'A',
filters: {
items: [],
op: 'AND',
},
filter: filterExpression
? {
expression: filterExpression,
}
: undefined,
functions: [],
groupBy: [
{
key: 'cloud.account.id',
type: 'tag',
},
],
having: [],
legend: '',
limit: null,
orderBy: [{ columnName: 'timestamp', order: 'desc' }],
pageSize: 0,
queryName: 'A',
reduceTo: 'avg',
stepInterval: 60,
},
],
queryFormulas: [],
queryTraceOperator: [],
},
clickhouse_sql: [],
promql: [],
});
// Helper function to verify CodeMirror content
const verifyCodeMirrorContent = async (
expectedFilterExpression: string,
): Promise<void> => {
await waitFor(
() => {
const editorContent = document.querySelector(
CM_EDITOR_SELECTOR,
) as HTMLElement;
expect(editorContent).toBeInTheDocument();
const textContent = editorContent.textContent || '';
expect(textContent).toBe(expectedFilterExpression);
},
{ timeout: 3000 },
);
};
const VIEWS_TO_TEST = [
ExplorerViews.LIST,
ExplorerViews.TIMESERIES,
ExplorerViews.TABLE,
];
describe('LogExplorerQuerySection', () => {
let mockQuery: Query;
let mockQueryBuilderContext: Partial<QueryBuilderContextType>;
beforeEach(() => {
jest.clearAllMocks();
mockQuery = createMockQuery();
// Mock the return value of updateAllQueriesOperators to return the same query
mockUpdateAllQueriesOperators.mockReturnValue(mockQuery);
// Setup query builder context mock
mockQueryBuilderContext = {
currentQuery: mockQuery,
updateAllQueriesOperators: mockUpdateAllQueriesOperators,
resetQuery: mockResetQuery,
redirectWithQueryBuilderData: mockRedirectWithQueryBuilderData,
panelType: PANEL_TYPES.LIST,
initialDataSource: DataSource.LOGS,
addNewBuilderQuery: jest.fn() as jest.MockedFunction<() => void>,
addNewFormula: jest.fn() as jest.MockedFunction<() => void>,
handleSetConfig: jest.fn() as jest.MockedFunction<
(panelType: PANEL_TYPES, dataSource: DataSource | null) => void
>,
addTraceOperator: jest.fn() as jest.MockedFunction<() => void>,
};
// Mock useGetPanelTypesQueryParam
mockUseGetPanelTypesQueryParam.mockReturnValue(PANEL_TYPES.LIST);
// Mock useShareBuilderUrl
mockUseShareBuilderUrl.mockImplementation(() => {});
});
afterEach(() => {
jest.clearAllMocks();
});
it('should maintain query state across multiple view changes', () => {
const { rerender } = render(
<LogExplorerQuerySection selectedView={ExplorerViews.LIST} />,
undefined,
{
queryBuilderOverrides: mockQueryBuilderContext as QueryBuilderContextType,
},
);
const initialQuery = mockQueryBuilderContext.currentQuery;
VIEWS_TO_TEST.forEach((view) => {
rerender(<LogExplorerQuerySection selectedView={view} />);
expect(mockQueryBuilderContext.currentQuery).toEqual(initialQuery);
});
});
it('should persist filter expressions across view changes', async () => {
// Test with a more complex filter expression
const complexFilter =
"(service.name = 'api-gateway' OR service.name = 'backend') AND http.status_code IN [500, 502, 503] AND NOT error = 'timeout'";
const queryWithComplexFilter = createMockQuery(complexFilter);
const contextWithComplexFilter: Partial<QueryBuilderContextType> = {
...mockQueryBuilderContext,
currentQuery: queryWithComplexFilter,
};
const { rerender } = render(
<LogExplorerQuerySection selectedView={ExplorerViews.LIST} />,
undefined,
{
queryBuilderOverrides: contextWithComplexFilter as QueryBuilderContextType,
},
);
VIEWS_TO_TEST.forEach(async (view) => {
rerender(<LogExplorerQuerySection selectedView={view} />);
await verifyCodeMirrorContent(complexFilter);
});
});
it('should render QueryAggregation and QueryAddOns when switching from LIST to TIMESERIES or TABLE view', async () => {
// Helper function to verify components are rendered
const verifyComponentsRendered = async (): Promise<void> => {
await waitFor(
() => {
expect(screen.getByTestId(QUERY_AGGREGATION_TEST_ID)).toBeInTheDocument();
},
{ timeout: 3000 },
);
await waitFor(
() => {
expect(screen.getByTestId(QUERY_ADDON_TEST_ID)).toBeInTheDocument();
},
{ timeout: 3000 },
);
};
// Start with LIST view - QueryAggregation and QueryAddOns should NOT be rendered
mockUseGetPanelTypesQueryParam.mockReturnValue(PANEL_TYPES.LIST);
const contextWithList: Partial<QueryBuilderContextType> = {
...mockQueryBuilderContext,
panelType: PANEL_TYPES.LIST,
};
render(
<LogExplorerQuerySection selectedView={ExplorerViews.LIST} />,
undefined,
{
queryBuilderOverrides: contextWithList as QueryBuilderContextType,
},
);
// Verify QueryAggregation is NOT rendered in LIST view
expect(
screen.queryByTestId(QUERY_AGGREGATION_TEST_ID),
).not.toBeInTheDocument();
// Verify QueryAddOns is NOT rendered in LIST view (check for one of the add-on tabs)
expect(screen.queryByTestId(QUERY_ADDON_TEST_ID)).not.toBeInTheDocument();
cleanup();
// Switch to TIMESERIES view
const timeseriesPanelType = explorerViewToPanelType[ExplorerViews.TIMESERIES];
mockUseGetPanelTypesQueryParam.mockReturnValue(timeseriesPanelType);
const contextWithTimeseries: Partial<QueryBuilderContextType> = {
...mockQueryBuilderContext,
panelType: timeseriesPanelType,
};
render(
<LogExplorerQuerySection selectedView={ExplorerViews.TIMESERIES} />,
undefined,
{
queryBuilderOverrides: contextWithTimeseries as QueryBuilderContextType,
},
);
// Verify QueryAggregation and QueryAddOns are rendered
await verifyComponentsRendered();
cleanup();
// Switch to TABLE view
const tablePanelType = explorerViewToPanelType[ExplorerViews.TABLE];
mockUseGetPanelTypesQueryParam.mockReturnValue(tablePanelType);
const contextWithTable: Partial<QueryBuilderContextType> = {
...mockQueryBuilderContext,
panelType: tablePanelType,
};
render(
<LogExplorerQuerySection selectedView={ExplorerViews.TABLE} />,
undefined,
{
queryBuilderOverrides: contextWithTable as QueryBuilderContextType,
},
);
// Verify QueryAggregation and QueryAddOns are still rendered in TABLE view
await verifyComponentsRendered();
});
});

View File

@@ -209,6 +209,15 @@
}
}
}
.time-series-view-container {
.time-series-view-container-header {
display: flex;
justify-content: flex-start;
align-items: center;
padding: 12px;
}
}
}
}

View File

@@ -22,6 +22,7 @@ import {
getListQuery,
getQueryByPanelType,
} from 'container/LogsExplorerViews/explorerUtils';
import { BuilderUnitsFilter } from 'container/QueryBuilder/filters/BuilderUnitsFilter';
import TimeSeriesView from 'container/TimeSeriesView/TimeSeriesView';
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
import { useGetExplorerQueryRange } from 'hooks/queryBuilder/useGetExplorerQueryRange';
@@ -110,6 +111,8 @@ function LogsExplorerViewsContainer({
const [orderBy, setOrderBy] = useState<string>('timestamp:desc');
const [yAxisUnit, setYAxisUnit] = useState<string>('');
const listQuery = useMemo(() => getListQuery(stagedQuery) || null, [
stagedQuery,
]);
@@ -350,6 +353,10 @@ function LogsExplorerViewsContainer({
orderBy,
]);
const onUnitChangeHandler = useCallback((value: string): void => {
setYAxisUnit(value);
}, []);
const chartData = useMemo(() => {
if (!stagedQuery) return [];
@@ -457,15 +464,24 @@ function LogsExplorerViewsContainer({
)}
{selectedPanelType === PANEL_TYPES.TIME_SERIES && !showLiveLogs && (
<TimeSeriesView
isLoading={isLoading || isFetching}
data={data}
isError={isError}
error={error as APIError}
isFilterApplied={!isEmpty(listQuery?.filters?.items)}
dataSource={DataSource.LOGS}
setWarning={setWarning}
/>
<div className="time-series-view-container">
<div className="time-series-view-container-header">
<BuilderUnitsFilter
onChange={onUnitChangeHandler}
yAxisUnit={yAxisUnit}
/>
</div>
<TimeSeriesView
isLoading={isLoading || isFetching}
data={data}
isError={isError}
error={error as APIError}
yAxisUnit={yAxisUnit}
isFilterApplied={!isEmpty(listQuery?.filters?.items)}
dataSource={DataSource.LOGS}
setWarning={setWarning}
/>
</div>
)}
{selectedPanelType === PANEL_TYPES.TABLE && !showLiveLogs && (

View File

@@ -68,7 +68,7 @@
display: flex;
gap: 6px;
align-items: center;
max-width: 100%;
max-width: 80%;
.dashboard-btn {
display: flex;
@@ -130,7 +130,6 @@
.left-section {
display: flex;
align-items: center;
gap: 8px;
width: 45%;
@@ -148,16 +147,17 @@
font-weight: 500;
line-height: 24px; /* 150% */
letter-spacing: -0.08px;
flex-shrink: 0;
flex: 1;
min-width: fit-content;
max-width: 80%;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.public-dashboard-icon {
margin-right: 4px;
}
}
.right-section {

View File

@@ -17,7 +17,7 @@ function SettingsDrawer({ drawerTitle }: { drawerTitle: string }): JSX.Element {
setVisible(true);
};
const onClose = (): void => {
const handleClose = (): void => {
setVisible(false);
variableViewModeRef?.current?.();
};
@@ -38,7 +38,7 @@ function SettingsDrawer({ drawerTitle }: { drawerTitle: string }): JSX.Element {
title={drawerTitle}
placement="right"
width="50%"
onClose={onClose}
onClose={handleClose}
open={visible}
rootClassName="settings-container-root"
>

View File

@@ -17,6 +17,7 @@ import { PANEL_GROUP_TYPES, PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { DeleteButton } from 'container/ListOfDashboard/TableComponents/DeleteButton';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import { useGetPublicDashboardMeta } from 'hooks/dashboard/useGetPublicDashboardMeta';
import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
import useComponentPermission from 'hooks/useComponentPermission';
import { useNotifications } from 'hooks/useNotifications';
@@ -29,6 +30,7 @@ import {
FileJson,
FolderKanban,
Fullscreen,
Globe,
LayoutGrid,
LockKeyhole,
PenLine,
@@ -128,6 +130,8 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
false,
);
const [isPublicDashboard, setIsPublicDashboard] = useState<boolean>(false);
let isAuthor = false;
if (selectedDashboard && user && user.email) {
@@ -297,6 +301,38 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
safeNavigate(generatedUrl);
}
const {
data: publicDashboardResponse,
// refetch: refetchPublicDashboardData,
isLoading: isLoadingPublicDashboardData,
isFetching: isFetchingPublicDashboardData,
error: errorPublicDashboardData,
isError: isErrorPublicDashboardData,
} = useGetPublicDashboardMeta(selectedDashboard?.id || '');
useEffect(() => {
if (!isLoadingPublicDashboardData && !isFetchingPublicDashboardData) {
if (isErrorPublicDashboardData) {
const errorDetails = errorPublicDashboardData?.getErrorDetails();
if (errorDetails?.error?.code === 'public_dashboard_not_found') {
setIsPublicDashboard(false);
}
} else {
const publicDashboardData = publicDashboardResponse?.data;
if (publicDashboardData?.publicPath) {
setIsPublicDashboard(true);
}
}
}
}, [
isLoadingPublicDashboardData,
isFetchingPublicDashboardData,
isErrorPublicDashboardData,
errorPublicDashboardData,
publicDashboardResponse?.data,
]);
return (
<Card className="dashboard-description-container">
<div className="dashboard-header">
@@ -333,11 +369,21 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
className="dashboard-title"
data-testid="dashboard-title"
>
{' '}
{title}
</Typography.Text>
</Tooltip>
{isDashboardLocked && <LockKeyhole size={14} />}
{isPublicDashboard && (
<Tooltip title="This dashboard is publicly accessible">
<Globe size={14} className="public-dashboard-icon" />
</Tooltip>
)}
{isDashboardLocked && (
<Tooltip title="This dashboard is locked">
<LockKeyhole size={14} className="lock-dashboard-icon" />
</Tooltip>
)}
</div>
<div className="right-section">
<DateTimeSelectionV2 showAutoRefresh hideShareModal />

View File

@@ -1,6 +1,5 @@
.settings-tabs {
.ant-tabs-nav-list {
width: 228px;
height: 32px;
flex-shrink: 0;
border-radius: 2px;
@@ -13,6 +12,10 @@
margin: 0px;
}
.ant-tabs-tab:not(:last-child) {
border-right: 1px solid var(--bg-slate-400) !important;
}
.overview-btn {
width: 114px;
display: flex;
@@ -27,6 +30,13 @@
justify-content: center;
}
.public-dashboard-btn {
width: 150px;
display: flex;
align-items: center;
justify-content: center;
}
.ant-tabs-ink-bar {
display: none;
}
@@ -41,6 +51,11 @@
border-radius: 2px 0px 0px 2px;
background: var(--bg-slate-400);
}
.public-dashboard-btn {
border-radius: 2px 0px 0px 2px;
background: var(--bg-slate-400);
}
}
}
@@ -63,6 +78,10 @@
.variables-btn {
background: var(--bg-vanilla-300);
}
.public-dashboard-btn {
background: var(--bg-vanilla-300);
}
}
}
}

View File

@@ -0,0 +1,132 @@
.public-dashboard-setting-container {
border-radius: 3px;
border: 1px solid var(--bg-slate-500);
padding: 16px !important;
.public-dashboard-setting-content {
display: flex;
flex-direction: column;
gap: 8px;
.public-dashboard-setting-content-title {
margin-bottom: 16px;
}
.timerange-enabled-checkbox {
margin-bottom: 8px;
}
.default-time-range-select {
margin-bottom: 8px;
.default-time-range-select-label {
margin-bottom: 4px;
.default-time-range-select-label-text {
font-size: 12px;
font-weight: 500;
}
}
.ant-select-selector {
display: flex;
height: 32px;
padding: 6px;
justify-content: center;
align-items: center;
border-radius: 2px 0px 0px 2px;
border: 1px solid var(--bg-slate-400);
background: var(--bg-ink-300);
.ant-select-selection-item {
display: flex;
align-items: center;
.list-item-image {
height: 16px;
width: 16px;
}
}
}
}
.public-dashboard-url {
.url-label-container {
margin-bottom: 4px;
.url-label {
font-size: 12px;
font-weight: 500;
}
}
.url-container {
display: flex;
align-items: center;
gap: 8px;
border: 1px solid var(--bg-slate-400);
background: var(--bg-ink-300);
border-radius: 4px;
padding: 0px 4px;
.url-text {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
.default-time-range-select-dropdown {
width: 200px;
}
.public-dashboard-setting-callout {
margin-top: 12px;
background: color-mix(in srgb, var(--bg-robin-500) 10%, transparent);
padding: 12px 8px;
border-radius: 3px;
.public-dashboard-setting-callout-text {
display: flex;
align-items: center;
gap: 8px;
font-size: 11px;
font-weight: 400;
color: var(--text-robin-300);
}
}
}
.public-dashboard-setting-actions {
margin-top: 32px;
display: flex;
gap: 8px;
justify-content: flex-end;
}
}
.lightMode {
.public-dashboard-setting-container {
border: 1px solid var(--bg-vanilla-300);
background: var(--bg-vanilla-100);
.public-dashboard-setting-content {
.default-time-range-select {
.ant-select-selector {
border: 1px solid var(--bg-vanilla-300);
background: var(--bg-vanilla-100);
}
}
.public-dashboard-url {
.url-container {
border: 1px solid var(--bg-vanilla-300);
background: var(--bg-vanilla-100);
}
}
}
}
}

View File

@@ -0,0 +1,382 @@
import { toast } from '@signozhq/sonner';
import { fireEvent, within } from '@testing-library/react';
import { StatusCodes } from 'http-status-codes';
import {
publishedPublicDashboardMeta,
unpublishedPublicDashboardMeta,
} from 'mocks-server/__mockdata__/publicDashboard';
import { rest, server } from 'mocks-server/server';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { useCopyToClipboard } from 'react-use';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import PublicDashboardSetting from '../index';
// Mock dependencies
jest.mock('providers/Dashboard/Dashboard');
jest.mock('react-use', () => ({
...jest.requireActual('react-use'),
useCopyToClipboard: jest.fn(),
}));
jest.mock('@signozhq/sonner', () => ({
toast: {
success: jest.fn(),
error: jest.fn(),
},
}));
const mockUseDashboard = jest.mocked(useDashboard);
const mockUseCopyToClipboard = jest.mocked(useCopyToClipboard);
const mockToast = jest.mocked(toast);
// Test constants
const MOCK_DASHBOARD_ID = 'test-dashboard-id';
const MOCK_PUBLIC_PATH = '/public/dashboard/test-dashboard-id';
const DEFAULT_TIME_RANGE = '30m';
const DASHBOARD_VARIABLES_WARNING =
"Dashboard variables won't work in public dashboards";
// Use wildcard pattern to match both relative and absolute URLs in MSW
const publicDashboardURL = `*/api/v1/dashboards/${MOCK_DASHBOARD_ID}/public`;
const mockSelectedDashboard = {
id: MOCK_DASHBOARD_ID,
data: {
title: 'Test Dashboard',
widgets: [],
layout: [],
panelMap: {},
variables: {},
},
};
beforeAll(() => {
server.listen();
});
afterAll(() => {
server.close();
});
const mockSetCopyPublicDashboardURL = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
// Mock window.open
window.open = jest.fn();
// Mock useDashboard
mockUseDashboard.mockReturnValue(({
selectedDashboard: mockSelectedDashboard,
} as unknown) as ReturnType<typeof useDashboard>);
// Mock useCopyToClipboard
mockUseCopyToClipboard.mockReturnValue(([
undefined,
mockSetCopyPublicDashboardURL,
] as unknown) as ReturnType<typeof useCopyToClipboard>);
});
afterEach(() => {
server.resetHandlers();
jest.clearAllMocks();
});
describe('PublicDashboardSetting', () => {
describe('Unpublished Dashboard', () => {
it('Unpublished dashboard should be handled correctly', async () => {
server.use(
rest.get(
`*/api/v1/dashboards/${MOCK_DASHBOARD_ID}/public`,
(_req, res, ctx) =>
res(
ctx.status(StatusCodes.NOT_FOUND),
ctx.json(unpublishedPublicDashboardMeta),
),
),
);
render(<PublicDashboardSetting />);
await waitFor(() => {
expect(
screen.getByText(
/This dashboard is private. Publish it to make it accessible to anyone with the link./i,
),
).toBeInTheDocument();
});
await waitFor(() => {
expect(
screen.getByRole('checkbox', { name: /enable time range/i }),
).toBeInTheDocument();
});
await waitFor(() => {
expect(screen.getByText(/default time range/i)).toBeInTheDocument();
});
expect(screen.getByText(/Last 30 minutes/i)).toBeInTheDocument();
await waitFor(() => {
expect(
screen.getByText(new RegExp(DASHBOARD_VARIABLES_WARNING, 'i')),
).toBeInTheDocument();
});
expect(
screen.getByRole('button', { name: /publish dashboard/i }),
).toBeInTheDocument();
});
});
describe('Published Dashboard', () => {
it('Published dashboard should be handled correctly', async () => {
server.use(
rest.get(
`*/api/v1/dashboards/${MOCK_DASHBOARD_ID}/public`,
(_req, res, ctx) =>
res(ctx.status(StatusCodes.OK), ctx.json(publishedPublicDashboardMeta)),
),
);
render(<PublicDashboardSetting />);
await waitFor(() => {
expect(
screen.getByText(
/This dashboard is publicly accessible. Anyone with the link can view it./i,
),
).toBeInTheDocument();
});
await waitFor(() => {
expect(
screen.getByRole('checkbox', { name: /enable time range/i }),
).toBeChecked();
});
await waitFor(() => {
expect(screen.getByText(/default time range/i)).toBeInTheDocument();
});
expect(screen.getByText(/Last 30 minutes/i)).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText(/Public Dashboard URL/i)).toBeInTheDocument();
});
await waitFor(() => {
expect(
screen.getByRole('button', { name: /update published dashboard/i }),
).toBeInTheDocument();
});
await waitFor(() => {
expect(
screen.getByRole('button', { name: /unpublish dashboard/i }),
).toBeInTheDocument();
});
});
});
describe('Time Range Settings', () => {
beforeEach(() => {
server.use(
rest.get(
`*/api/v1/dashboards/${MOCK_DASHBOARD_ID}/public`,
(_req, res, ctx) =>
res(ctx.status(StatusCodes.OK), ctx.json(publishedPublicDashboardMeta)),
),
);
});
it('should toggle time range enabled when checkbox is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<PublicDashboardSetting />);
// Wait for checkbox to be rendered and verify initial state
const checkbox = await screen.findByRole('checkbox', {
name: /enable time range/i,
});
expect(checkbox).toBeChecked();
await user.click(checkbox);
await waitFor(() => {
expect(checkbox).not.toBeChecked();
});
});
it('should update default time range when select value changes', async () => {
render(<PublicDashboardSetting />);
const selectContainer = await screen.findByTestId(
'default-time-range-select-dropdown',
);
const combobox = within(selectContainer).getByRole('combobox');
fireEvent.mouseDown(combobox);
await screen.findByRole('listbox');
const option = await screen.findByText(/Last 1 hour/i, {
selector: '.ant-select-item-option-content',
});
fireEvent.click(option);
await waitFor(() => {
expect(
within(selectContainer).getByText(/Last 1 hour/i),
).toBeInTheDocument();
});
});
});
describe('Create Public Dashboard', () => {
it('should call create API when publish button is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
let createApiCalled = false;
server.use(
rest.get(publicDashboardURL, (_req, res, ctx) =>
res(
ctx.status(StatusCodes.OK),
ctx.json({
data: {
timeRangeEnabled: true,
defaultTimeRange: DEFAULT_TIME_RANGE,
publicPath: '',
},
}),
),
),
rest.post(publicDashboardURL, async (req, res, ctx) => {
const body = await req.json();
createApiCalled = true;
expect(body).toEqual({
timeRangeEnabled: true,
defaultTimeRange: DEFAULT_TIME_RANGE,
});
return res(
ctx.status(StatusCodes.CREATED),
ctx.json({
data: {
timeRangeEnabled: true,
defaultTimeRange: DEFAULT_TIME_RANGE,
publicPath: MOCK_PUBLIC_PATH,
},
}),
);
}),
);
render(<PublicDashboardSetting />);
// Find and click publish button
const publishButton = await screen.findByRole('button', {
name: /publish dashboard/i,
});
await user.click(publishButton);
await waitFor(() => {
expect(createApiCalled).toBe(true);
expect(mockToast.success).toHaveBeenCalledWith(
'Public dashboard created successfully',
);
});
});
});
describe('Update Public Dashboard', () => {
it('should call update API when update button is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
let updateApiCalled = false;
let capturedRequestBody: {
timeRangeEnabled: boolean;
defaultTimeRange: string;
} | null = null;
server.use(
rest.get(
`*/api/v1/dashboards/${MOCK_DASHBOARD_ID}/public`,
(_req, res, ctx) =>
res(ctx.status(StatusCodes.OK), ctx.json(publishedPublicDashboardMeta)),
),
rest.put(publicDashboardURL, async (req, res, ctx) => {
const body = await req.json();
updateApiCalled = true;
capturedRequestBody = body;
return res(ctx.status(StatusCodes.NO_CONTENT), ctx.json({}));
}),
);
render(<PublicDashboardSetting />);
// Wait for API response and component update
const updateButton = await screen.findByRole(
'button',
{ name: /update published dashboard/i },
{ timeout: 5000 },
);
await user.click(updateButton);
await waitFor(() => {
expect(updateApiCalled).toBe(true);
expect(capturedRequestBody).toEqual({
timeRangeEnabled: true,
defaultTimeRange: DEFAULT_TIME_RANGE,
});
expect(mockToast.success).toHaveBeenCalledWith(
'Public dashboard updated successfully',
);
});
});
});
describe('Revoke Public Dashboard Access', () => {
it('should call revoke API when unpublish button is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
let revokeApiCalled = false;
let capturedDashboardId: string | null = null;
server.use(
rest.get(
`*/api/v1/dashboards/${MOCK_DASHBOARD_ID}/public`,
(_req, res, ctx) =>
res(ctx.status(StatusCodes.OK), ctx.json(publishedPublicDashboardMeta)),
),
rest.delete(publicDashboardURL, (req, res, ctx) => {
revokeApiCalled = true;
// Extract dashboard ID from URL: /api/v1/dashboards/{id}/public
const urlMatch = req.url.pathname.match(
/\/api\/v1\/dashboards\/([^/]+)\/public/,
);
capturedDashboardId = urlMatch ? urlMatch[1] : null;
return res(ctx.status(StatusCodes.NO_CONTENT), ctx.json({}));
}),
);
render(<PublicDashboardSetting />);
// Wait for API response and component update
const unpublishButton = await screen.findByRole(
'button',
{ name: /unpublish dashboard/i },
{ timeout: 5000 },
);
await user.click(unpublishButton);
await waitFor(() => {
expect(revokeApiCalled).toBe(true);
expect(capturedDashboardId).toBe(MOCK_DASHBOARD_ID);
expect(mockToast.success).toHaveBeenCalledWith(
'Dashboard unpublished successfully',
);
});
});
});
});

View File

@@ -0,0 +1,338 @@
/* eslint-disable import/no-extraneous-dependencies */
import './PublicDashboard.styles.scss';
import { Checkbox } from '@signozhq/checkbox';
import { toast } from '@signozhq/sonner';
import { Button, Select, Typography } from 'antd';
import createPublicDashboardAPI from 'api/dashboard/public/createPublicDashboard';
import revokePublicDashboardAccessAPI from 'api/dashboard/public/revokePublicDashboardAccess';
import updatePublicDashboardAPI from 'api/dashboard/public/updatePublicDashboard';
import { useGetPublicDashboardMeta } from 'hooks/dashboard/useGetPublicDashboardMeta';
import { Copy, ExternalLink, Globe, Info, Loader2, Trash } from 'lucide-react';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useMutation } from 'react-query';
import { useCopyToClipboard } from 'react-use';
import { PublicDashboardMetaProps } from 'types/api/dashboard/public/getMeta';
export const TIME_RANGE_PRESETS_OPTIONS = [
{
label: 'Last 5 minutes',
value: '5m',
},
{
label: 'Last 15 minutes',
value: '15m',
},
{
label: 'Last 30 minutes',
value: '30m',
},
{
label: 'Last 1 hour',
value: '1h',
},
{
label: 'Last 6 hours',
value: '6h',
},
{
label: 'Last 1 day',
value: '24h',
},
];
function PublicDashboardSetting(): JSX.Element {
const [publicDashboardData, setPublicDashboardData] = useState<
PublicDashboardMetaProps | undefined
>(undefined);
const [timeRangeEnabled, setTimeRangeEnabled] = useState(true);
const [defaultTimeRange, setDefaultTimeRange] = useState('30m');
const [, setCopyPublicDashboardURL] = useCopyToClipboard();
const { selectedDashboard } = useDashboard();
const handleDefaultTimeRange = useCallback((value: string): void => {
setDefaultTimeRange(value);
}, []);
const handleTimeRangeEnabled = useCallback((): void => {
setTimeRangeEnabled((prev) => !prev);
}, []);
const {
data: publicDashboardResponse,
isLoading: isLoadingPublicDashboard,
isFetching: isFetchingPublicDashboard,
refetch: refetchPublicDashboard,
error: errorPublicDashboard,
} = useGetPublicDashboardMeta(selectedDashboard?.id || '');
const isPublicDashboardEnabled = !!publicDashboardData?.publicPath;
useEffect(() => {
if (publicDashboardResponse?.data) {
setPublicDashboardData(publicDashboardResponse?.data);
}
if (errorPublicDashboard) {
console.error('Error getting public dashboard', errorPublicDashboard);
setPublicDashboardData(undefined);
setTimeRangeEnabled(true);
setDefaultTimeRange('30m');
}
}, [publicDashboardResponse, errorPublicDashboard]);
useEffect(() => {
if (publicDashboardResponse?.data) {
setTimeRangeEnabled(
publicDashboardResponse?.data?.timeRangeEnabled || false,
);
setDefaultTimeRange(
publicDashboardResponse?.data?.defaultTimeRange || '30m',
);
}
}, [publicDashboardResponse]);
const {
mutate: createPublicDashboard,
isLoading: isLoadingCreatePublicDashboard,
data: createPublicDashboardResponse,
} = useMutation(createPublicDashboardAPI, {
onSuccess: () => {
toast.success('Public dashboard created successfully');
},
onError: () => {
toast.error('Failed to create public dashboard');
},
});
const {
mutate: updatePublicDashboard,
isLoading: isLoadingUpdatePublicDashboard,
data: updatePublicDashboardResponse,
} = useMutation(updatePublicDashboardAPI, {
onSuccess: () => {
toast.success('Public dashboard updated successfully');
},
onError: () => {
toast.error('Failed to update public dashboard');
},
});
const {
mutate: revokePublicDashboardAccess,
isLoading: isLoadingRevokePublicDashboardAccess,
data: revokePublicDashboardAccessResponse,
} = useMutation(revokePublicDashboardAccessAPI, {
onSuccess: () => {
toast.success('Dashboard unpublished successfully');
},
onError: () => {
toast.error('Failed to unpublish dashboard');
},
});
const handleCreatePublicDashboard = (): void => {
if (!selectedDashboard) return;
createPublicDashboard({
dashboardId: selectedDashboard.id,
timeRangeEnabled,
defaultTimeRange,
});
};
const handleUpdatePublicDashboard = (): void => {
if (!selectedDashboard) return;
updatePublicDashboard({
dashboardId: selectedDashboard.id,
timeRangeEnabled,
defaultTimeRange,
});
};
const handleRevokePublicDashboardAccess = (): void => {
if (!selectedDashboard) return;
revokePublicDashboardAccess({
id: selectedDashboard.id,
});
};
useEffect(() => {
if (
(createPublicDashboardResponse &&
createPublicDashboardResponse.httpStatusCode === 201) ||
(updatePublicDashboardResponse &&
updatePublicDashboardResponse.httpStatusCode === 204) ||
(revokePublicDashboardAccessResponse &&
revokePublicDashboardAccessResponse.httpStatusCode === 204)
) {
refetchPublicDashboard();
}
}, [
createPublicDashboardResponse,
updatePublicDashboardResponse,
revokePublicDashboardAccessResponse,
refetchPublicDashboard,
]);
const handleCopyPublicDashboardURL = (): void => {
if (!publicDashboardResponse?.data?.publicPath) return;
try {
setCopyPublicDashboardURL(
`${window.location.origin}${publicDashboardResponse?.data?.publicPath}`,
);
toast.success('Copied Public Dashboard URL successfully');
} catch (error) {
console.error('Error copying public dashboard URL', error);
}
};
const publicDashboardURL = useMemo(
() => `${window.location.origin}${publicDashboardResponse?.data?.publicPath}`,
[publicDashboardResponse],
);
const isLoading =
isLoadingCreatePublicDashboard ||
isLoadingUpdatePublicDashboard ||
isLoadingRevokePublicDashboardAccess ||
isLoadingPublicDashboard;
return (
<div className="public-dashboard-setting-container">
<div className="public-dashboard-setting-content">
<Typography.Title
level={5}
className="public-dashboard-setting-content-title"
>
{isPublicDashboardEnabled
? 'This dashboard is publicly accessible. Anyone with the link can view it.'
: 'This dashboard is private. Publish it to make it accessible to anyone with the link.'}
</Typography.Title>
<div className="timerange-enabled-checkbox">
<Checkbox
id="enable-time-range"
checked={timeRangeEnabled}
onCheckedChange={handleTimeRangeEnabled}
labelName="Enable time range"
/>
</div>
<div className="default-time-range-select">
<div className="default-time-range-select-label">
<Typography.Text className="default-time-range-select-label-text">
Default time range
</Typography.Text>
</div>
<Select
placeholder="Select default time range"
options={TIME_RANGE_PRESETS_OPTIONS}
value={defaultTimeRange}
onChange={handleDefaultTimeRange}
data-testid="default-time-range-select-dropdown"
className="default-time-range-select-dropdown"
/>
</div>
{isPublicDashboardEnabled && (
<div className="public-dashboard-url">
<div className="url-label-container">
<Typography.Text className="url-label">
Public Dashboard URL
</Typography.Text>
</div>
<div className="url-container">
<Typography.Text className="url-text">
{publicDashboardURL}
</Typography.Text>
<Button
type="link"
className="url-copy-btn periscope-btn ghost"
icon={<Copy size={12} />}
onClick={handleCopyPublicDashboardURL}
/>
<Button
type="link"
className="periscope-btn ghost"
icon={<ExternalLink size={12} />}
onClick={(): void => {
if (publicDashboardURL) {
window.open(publicDashboardURL, '_blank');
}
}}
/>
</div>
</div>
)}
<div className="public-dashboard-setting-callout">
<Typography.Text className="public-dashboard-setting-callout-text">
<Info size={12} className="public-dashboard-setting-callout-icon" />{' '}
Dashboard variables won&apos;t work in public dashboards
</Typography.Text>
</div>
<div className="public-dashboard-setting-actions">
{!isPublicDashboardEnabled ? (
<Button
type="primary"
className="create-public-dashboard-btn periscope-btn primary"
disabled={isLoading}
onClick={handleCreatePublicDashboard}
loading={
isLoadingCreatePublicDashboard ||
isFetchingPublicDashboard ||
isLoadingPublicDashboard
}
icon={
isLoadingCreatePublicDashboard ||
isFetchingPublicDashboard ||
isLoadingPublicDashboard ? (
<Loader2 className="animate-spin" size={14} />
) : (
<Globe size={14} />
)
}
>
Publish dashboard
</Button>
) : (
<>
<Button
type="default"
className="periscope-btn secondary"
disabled={isLoading}
onClick={handleRevokePublicDashboardAccess}
loading={isLoadingRevokePublicDashboardAccess}
icon={<Trash size={14} />}
>
Unpublish dashboard
</Button>
<Button
type="primary"
className="create-public-dashboard-btn periscope-btn primary"
disabled={isLoading}
onClick={handleUpdatePublicDashboard}
loading={isLoadingUpdatePublicDashboard}
icon={<Globe size={14} />}
>
Update published dashboard
</Button>
</>
)}
</div>
</div>
</div>
);
}
export default PublicDashboardSetting;

View File

@@ -1,9 +1,10 @@
import './DashboardSettingsContent.styles.scss';
import { Button, Tabs } from 'antd';
import { Braces, Table } from 'lucide-react';
import { Braces, Globe, Table } from 'lucide-react';
import GeneralDashboardSettings from './General';
import PublicDashboardSetting from './PublicDashboard';
import VariablesSetting from './Variables';
function DashboardSettingsContent({
@@ -30,6 +31,19 @@ function DashboardSettingsContent({
key: 'variables',
children: <VariablesSetting variableViewModeRef={variableViewModeRef} />,
},
{
label: (
<Button
type="text"
icon={<Globe size={14} />}
className="public-dashboard-btn"
>
Publish
</Button>
),
key: 'public-dashboard',
children: <PublicDashboardSetting />,
},
];
return <Tabs items={items} animated className="settings-tabs" />;

View File

@@ -1,12 +1,17 @@
import { Checkbox, Empty } from 'antd';
import { AxiosResponse } from 'axios';
import Spinner from 'components/Spinner';
import { EXCLUDED_COLUMNS } from 'container/OptionsMenu/constants';
import { QueryKeySuggestionsResponseProps } from 'types/api/querySuggestions/types';
import { DataSource } from 'types/common/queryBuilder';
type ExplorerAttributeColumnsProps = {
isLoading: boolean;
data: any;
data: AxiosResponse<QueryKeySuggestionsResponseProps> | undefined;
searchText: string;
isAttributeKeySelected: (key: string) => boolean;
handleCheckboxChange: (key: string) => void;
dataSource: DataSource;
};
function ExplorerAttributeColumns({
@@ -15,6 +20,7 @@ function ExplorerAttributeColumns({
searchText,
isAttributeKeySelected,
handleCheckboxChange,
dataSource,
}: ExplorerAttributeColumnsProps): JSX.Element {
if (isLoading) {
return (
@@ -27,8 +33,10 @@ function ExplorerAttributeColumns({
const filteredAttributeKeys =
Object.values(data?.data?.data?.keys || {})
?.flat()
?.filter((attributeKey: any) =>
attributeKey.name.toLowerCase().includes(searchText.toLowerCase()),
?.filter(
(attributeKey) =>
attributeKey.name.toLowerCase().includes(searchText.toLowerCase()) &&
!EXCLUDED_COLUMNS[dataSource].includes(attributeKey.name),
) || [];
if (filteredAttributeKeys.length === 0) {
return (

View File

@@ -183,6 +183,7 @@ function ExplorerColumnsRenderer({
searchText={searchText}
isAttributeKeySelected={isAttributeKeySelected}
handleCheckboxChange={handleCheckboxChange}
dataSource={initialDataSource}
/>
),
},

View File

@@ -450,4 +450,58 @@ describe('ExplorerColumnsRenderer', () => {
}
});
});
it('does not show isRoot or isEntryPoint in add column dropdown (traces, dashboard table panel)', async () => {
(useQueryBuilder as jest.Mock).mockReturnValue({
currentQuery: {
builder: {
queryData: [
{
dataSource: DataSource.TRACES,
aggregateOperator: 'count',
},
],
},
},
});
(useGetQueryKeySuggestions as jest.Mock).mockReturnValue({
data: {
data: {
data: {
keys: {
attributeKeys: [
{ name: 'isRoot', dataType: 'bool', type: '' },
{ name: 'isEntryPoint', dataType: 'bool', type: '' },
{ name: 'duration', dataType: 'number', type: '' },
{ name: 'serviceName', dataType: 'string', type: '' },
],
},
},
},
},
isLoading: false,
isError: false,
});
render(
<Wrapper>
<ExplorerColumnsRenderer
selectedLogFields={[]}
setSelectedLogFields={mockSetSelectedLogFields}
selectedTracesFields={[]}
setSelectedTracesFields={mockSetSelectedTracesFields}
/>
</Wrapper>,
);
await userEvent.click(screen.getByTestId('add-columns-button'));
// Visible columns should appear
expect(screen.getByText('duration')).toBeInTheDocument();
expect(screen.getByText('serviceName')).toBeInTheDocument();
// Hidden columns should NOT appear
expect(screen.queryByText('isRoot')).not.toBeInTheDocument();
expect(screen.queryByText('isEntryPoint')).not.toBeInTheDocument();
});
});

View File

@@ -501,7 +501,7 @@ function NewWidget({
stackedBarChart: selectedWidget?.stackedBarChart || false,
yAxisUnit: selectedWidget?.yAxisUnit,
decimalPrecision:
selectedWidget?.decimalPrecision || PrecisionOptionsEnum.TWO,
selectedWidget?.decimalPrecision ?? PrecisionOptionsEnum.TWO,
panelTypes: graphType,
query: adjustedQueryForV5,
thresholds: selectedWidget?.thresholds,
@@ -532,7 +532,7 @@ function NewWidget({
stackedBarChart: selectedWidget?.stackedBarChart || false,
yAxisUnit: selectedWidget?.yAxisUnit,
decimalPrecision:
selectedWidget?.decimalPrecision || PrecisionOptionsEnum.TWO,
selectedWidget?.decimalPrecision ?? PrecisionOptionsEnum.TWO,
panelTypes: graphType,
query: adjustedQueryForV5,
thresholds: selectedWidget?.thresholds,

View File

@@ -0,0 +1,235 @@
import { renderHook } from '@testing-library/react';
import { useGetQueryKeySuggestions } from 'hooks/querySuggestions/useGetQueryKeySuggestions';
import { useNotifications } from 'hooks/useNotifications';
import useUrlQueryData from 'hooks/useUrlQueryData';
import { usePreferenceContext } from 'providers/preferences/context/PreferenceContextProvider';
import { useQueries } from 'react-query';
import { DataSource } from 'types/common/queryBuilder';
import useOptionsMenu from '../useOptionsMenu';
// Mock all dependencies
jest.mock('hooks/useNotifications');
jest.mock('providers/preferences/context/PreferenceContextProvider');
jest.mock('hooks/useUrlQueryData');
jest.mock('hooks/querySuggestions/useGetQueryKeySuggestions');
jest.mock('react-query', () => ({
...jest.requireActual('react-query'),
useQueries: jest.fn(),
}));
describe('useOptionsMenu', () => {
const mockNotifications = { error: jest.fn(), success: jest.fn() };
const mockUpdateColumns = jest.fn();
const mockUpdateFormatting = jest.fn();
const mockRedirectWithQuery = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
(useNotifications as jest.Mock).mockReturnValue({
notifications: mockNotifications,
});
(usePreferenceContext as jest.Mock).mockReturnValue({
traces: {
preferences: {
columns: [],
formatting: {
format: 'raw',
maxLines: 2,
fontSize: 'small',
},
},
updateColumns: mockUpdateColumns,
updateFormatting: mockUpdateFormatting,
},
logs: {
preferences: {
columns: [],
formatting: {
format: 'raw',
maxLines: 2,
fontSize: 'small',
},
},
updateColumns: mockUpdateColumns,
updateFormatting: mockUpdateFormatting,
},
});
(useUrlQueryData as jest.Mock).mockReturnValue({
query: null,
redirectWithQuery: mockRedirectWithQuery,
});
(useQueries as jest.Mock).mockReturnValue([]);
});
it('does not show isRoot or isEntryPoint in column options when dataSource is TRACES', () => {
// Mock the query key suggestions to return data including isRoot and isEntryPoint
(useGetQueryKeySuggestions as jest.Mock).mockReturnValue({
data: {
data: {
data: {
keys: {
attributeKeys: [
{
name: 'isRoot',
signal: 'traces',
fieldDataType: 'bool',
fieldContext: '',
},
{
name: 'isEntryPoint',
signal: 'traces',
fieldDataType: 'bool',
fieldContext: '',
},
{
name: 'duration',
signal: 'traces',
fieldDataType: 'float64',
fieldContext: '',
},
{
name: 'serviceName',
signal: 'traces',
fieldDataType: 'string',
fieldContext: '',
},
],
},
},
},
},
isFetching: false,
});
const { result } = renderHook(() =>
useOptionsMenu({
dataSource: DataSource.TRACES,
aggregateOperator: 'count',
}),
);
// Get the column options from the config
const columnOptions = result.current.config.addColumn?.options ?? [];
const optionNames = columnOptions.map((option) => option.label);
// isRoot and isEntryPoint should NOT be in the options
expect(optionNames).not.toContain('isRoot');
expect(optionNames).not.toContain('body');
expect(optionNames).not.toContain('isEntryPoint');
// Other attributes should be present
expect(optionNames).toContain('duration');
expect(optionNames).toContain('serviceName');
});
it('does not show body in column options when dataSource is METRICS', () => {
// Mock the query key suggestions to return data including body
(useGetQueryKeySuggestions as jest.Mock).mockReturnValue({
data: {
data: {
data: {
keys: {
attributeKeys: [
{
name: 'body',
signal: 'logs',
fieldDataType: 'string',
fieldContext: '',
},
{
name: 'status',
signal: 'metrics',
fieldDataType: 'int64',
fieldContext: '',
},
{
name: 'value',
signal: 'metrics',
fieldDataType: 'float64',
fieldContext: '',
},
],
},
},
},
},
isFetching: false,
});
const { result } = renderHook(() =>
useOptionsMenu({
dataSource: DataSource.METRICS,
aggregateOperator: 'count',
}),
);
// Get the column options from the config
const columnOptions = result.current.config.addColumn?.options ?? [];
const optionNames = columnOptions.map((option) => option.label);
// body should NOT be in the options
expect(optionNames).not.toContain('body');
// Other attributes should be present
expect(optionNames).toContain('status');
expect(optionNames).toContain('value');
});
it('does not show body in column options when dataSource is LOGS', () => {
// Mock the query key suggestions to return data including body
(useGetQueryKeySuggestions as jest.Mock).mockReturnValue({
data: {
data: {
data: {
keys: {
attributeKeys: [
{
name: 'body',
signal: 'logs',
fieldDataType: 'string',
fieldContext: '',
},
{
name: 'level',
signal: 'logs',
fieldDataType: 'string',
fieldContext: '',
},
{
name: 'timestamp',
signal: 'logs',
fieldDataType: 'int64',
fieldContext: '',
},
],
},
},
},
},
isFetching: false,
});
const { result } = renderHook(() =>
useOptionsMenu({
dataSource: DataSource.LOGS,
aggregateOperator: 'count',
}),
);
// Get the column options from the config
const columnOptions = result.current.config.addColumn?.options ?? [];
const optionNames = columnOptions.map((option) => option.label);
// body should be in the options
expect(optionNames).toContain('body');
// Other attributes should be present
expect(optionNames).toContain('level');
expect(optionNames).toContain('timestamp');
});
});

View File

@@ -1,4 +1,5 @@
import { TelemetryFieldKey } from 'api/v5/v5';
import { DataSource } from 'types/common/queryBuilder';
import { FontSize, OptionsQuery } from './types';
@@ -11,6 +12,12 @@ export const defaultOptionsQuery: OptionsQuery = {
fontSize: FontSize.SMALL,
};
export const EXCLUDED_COLUMNS: Record<DataSource, string[]> = {
[DataSource.TRACES]: ['body', 'isRoot', 'isEntryPoint'],
[DataSource.METRICS]: ['body'],
[DataSource.LOGS]: [],
};
export const defaultLogsSelectedColumns: TelemetryFieldKey[] = [
{
name: 'timestamp',

View File

@@ -27,6 +27,7 @@ import {
defaultLogsSelectedColumns,
defaultOptionsQuery,
defaultTraceSelectedColumns,
EXCLUDED_COLUMNS,
URL_OPTIONS,
} from './constants';
import {
@@ -267,8 +268,9 @@ const useOptionsMenu = ({
const optionsFromAttributeKeys = useMemo(() => {
const filteredAttributeKeys = searchedAttributeKeys.filter((item) => {
if (dataSource !== DataSource.LOGS) {
return item.name !== 'body';
const exclusions = EXCLUDED_COLUMNS[dataSource];
if (exclusions) {
return !exclusions.includes(item.name);
}
return true;
});

View File

@@ -49,12 +49,14 @@ exports[`Value panel wrappper tests should render tooltip when there are conflic
>
<span
class="ant-typography value-graph-text css-dev-only-do-not-override-2i2tap"
data-testid="value-graph-text"
style="color: Blue; font-size: 16px;"
>
295.43
</span>
<span
class="ant-typography value-graph-unit css-dev-only-do-not-override-2i2tap"
data-testid="value-graph-suffix-unit"
style="color: Blue; font-size: calc(16px * 0.7);"
>
ms

View File

@@ -0,0 +1,146 @@
import { ENTITY_VERSION_V5 } from 'constants/app';
import { PANEL_TYPES } from 'constants/queryBuilder';
import EmptyWidget from 'container/GridCardLayout/EmptyWidget';
import WidgetGraphComponent from 'container/GridCardLayout/GridCard/WidgetGraphComponent';
import { populateMultipleResults } from 'container/NewWidget/LeftContainer/WidgetGraph/util';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import { memo, useCallback, useMemo, useRef } from 'react';
import { Widgets } from 'types/api/dashboard/getAll';
import { DataSource } from 'types/common/queryBuilder';
import { getGraphType } from 'utils/getGraphType';
import { getSortedSeriesData } from 'utils/getSortedSeriesData';
function Panel({
widget,
index,
dashboardId,
startTime,
endTime,
}: {
widget: Widgets;
index: number;
dashboardId: string;
startTime: number;
endTime: number;
}): JSX.Element {
const graphRef = useRef<HTMLDivElement>(null);
const updatedQuery = widget?.query;
const requestData: GetQueryResultsProps = useMemo(() => {
if (widget.panelTypes !== PANEL_TYPES.LIST) {
return {
selectedTime: widget?.timePreferance,
graphType: getGraphType(widget.panelTypes),
query: updatedQuery,
variables: {}, // we are not supporting variables in public dashboards
fillGaps: widget.fillSpans,
formatForWeb: widget.panelTypes === PANEL_TYPES.TABLE,
start: startTime,
end: endTime,
originalGraphType: widget.panelTypes,
};
}
updatedQuery.builder.queryData[0].pageSize = 10;
const initialDataSource = updatedQuery.builder.queryData[0].dataSource;
return {
query: updatedQuery,
graphType: PANEL_TYPES.LIST,
selectedTime: widget.timePreferance || 'GLOBAL_TIME',
tableParams: {
pagination: {
offset: 0,
limit: updatedQuery.builder.queryData[0].limit || 0,
},
// we do not need select columns in case of logs
selectColumns:
initialDataSource === DataSource.TRACES && widget.selectedTracesFields,
},
fillGaps: widget.fillSpans,
start: startTime,
end: endTime,
};
}, [widget, updatedQuery, startTime, endTime]);
const queryResponse = useGetQueryRange(
{
...requestData,
originalGraphType: widget?.panelTypes,
},
ENTITY_VERSION_V5,
{
queryKey: [
widget?.query,
widget?.panelTypes,
requestData,
startTime,
endTime,
],
retry(failureCount, error): boolean {
if (
String(error).includes('status: error') &&
String(error).includes('i/o timeout')
) {
return false;
}
return failureCount < 2;
},
keepPreviousData: true,
enabled: !!widget?.query,
refetchOnMount: false,
},
{},
{
isPublic: true,
widgetIndex: index,
publicDashboardId: dashboardId,
},
);
const isEmptyLayout = widget?.id === PANEL_TYPES.EMPTY_WIDGET;
if (queryResponse.data && widget.panelTypes === PANEL_TYPES.BAR) {
const sortedSeriesData = getSortedSeriesData(
queryResponse.data?.payload.data.result,
);
queryResponse.data.payload.data.result = sortedSeriesData;
}
if (queryResponse.data && widget.panelTypes === PANEL_TYPES.PIE) {
const transformedData = populateMultipleResults(queryResponse?.data);
// eslint-disable-next-line no-param-reassign
queryResponse.data = transformedData;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const onDragSelect = useCallback((_start: number, _end: number): void => {
// Handle drag select if needed - no-op for public dashboards
}, []);
return (
<div
className="panel-container"
style={{ height: '100%', width: '100%' }}
ref={graphRef}
>
{isEmptyLayout ? (
<EmptyWidget />
) : (
<WidgetGraphComponent
widget={widget}
queryResponse={queryResponse}
errorMessage={undefined}
headerMenuList={[]}
isWarning={false}
isFetchingResponse={queryResponse.isFetching || queryResponse.isLoading}
onDragSelect={onDragSelect}
/>
)}
</div>
);
}
export default memo(Panel);

View File

@@ -0,0 +1,109 @@
.public-dashboard-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
width: 100%;
.public-dashboard-header {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
padding: 8px 16px;
border-bottom: 1px solid var(--bg-slate-400);
background: var(--bg-ink-300);
position: sticky;
top: 0;
z-index: 999;
.public-dashboard-header-left {
display: flex;
align-items: center;
gap: 16px;
width: 50%;
.brand-logo {
display: flex;
align-items: center;
gap: 8px;
width: 100px;
}
.public-dashboard-header-title {
flex: 1;
display: flex;
align-items: center;
gap: 8px;
width: calc(100% - 100px);
.public-dashboard-header-title-text {
font-family: 'Work Sans', sans-serif;
font-size: 14px;
font-style: normal;
font-weight: 500;
line-height: 18px;
// ellipsis text
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
}
}
.public-dashboard-header-right {
display: flex;
align-items: center;
gap: 16px;
.datetime-section {
.time-range-select-dropdown {
width: 200px;
}
}
}
.brand-logo {
display: flex;
align-items: center;
gap: 8px;
}
.brand-logo-name {
font-family: 'Work Sans', sans-serif;
font-size: 14px;
font-style: normal;
font-weight: 500;
line-height: 18px;
}
.brand-logo-img {
height: 24px;
width: 24px;
}
}
.public-dashboard-content {
width: 100%;
height: 100%;
}
.fullscreen-grid-container {
margin: 0px 8px;
}
}
.lightMode {
.public-dashboard-container {
.public-dashboard-header {
background: var(--bg-vanilla-100);
border-bottom: 1px solid var(--bg-vanilla-300);
}
}
}

View File

@@ -0,0 +1,241 @@
import './PublicDashboardContainer.styles.scss';
import { Typography } from 'antd';
import cx from 'classnames';
import { PANEL_GROUP_TYPES, PANEL_TYPES } from 'constants/queryBuilder';
import { themeColors } from 'constants/theme';
import { Card, CardContainer } from 'container/GridCardLayout/styles';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import {
CustomTimeType,
Time,
} from 'container/TopNav/DateTimeSelectionV2/config';
import dayjs from 'dayjs';
import { useIsDarkMode } from 'hooks/useDarkMode';
import GetMinMax from 'lib/getMinMax';
import { useMemo, useState } from 'react';
import RGL, { WidthProvider } from 'react-grid-layout';
import { SuccessResponseV2 } from 'types/api';
import { Widgets } from 'types/api/dashboard/getAll';
import { PublicDashboardDataProps } from 'types/api/dashboard/public/get';
import Panel from './Panel';
const ReactGridLayoutComponent = WidthProvider(RGL);
const CUSTOM_TIME_REGEX = /^(\d+)([mhdw])$/;
const getStartTimeAndEndTimeFromTimeRange = (
timeRange: string,
): { startTime: number; endTime: number } => {
const isValidFormat = CUSTOM_TIME_REGEX.test(timeRange);
if (isValidFormat) {
const match = timeRange.match(CUSTOM_TIME_REGEX) as RegExpMatchArray;
const timeValue = parseInt(match[1] as string, 10);
const timeUnit = match[2] as string;
switch (timeUnit) {
case 'm':
return {
startTime: dayjs().subtract(timeValue, 'minutes').unix(),
endTime: dayjs().unix(),
};
case 'h':
return {
startTime: dayjs().subtract(timeValue, 'hours').unix(),
endTime: dayjs().unix(),
};
case 'd':
return {
startTime: dayjs().subtract(timeValue, 'days').unix(),
endTime: dayjs().unix(),
};
case 'w':
return {
startTime: dayjs().subtract(timeValue, 'weeks').unix(),
endTime: dayjs().unix(),
};
default:
return { startTime: dayjs().unix(), endTime: dayjs().unix() };
}
}
return {
startTime: dayjs().subtract(30, 'minutes').unix(),
endTime: dayjs().unix(),
};
};
function PublicDashboardContainer({
publicDashboardId,
publicDashboardData,
}: {
publicDashboardId: string;
publicDashboardData: SuccessResponseV2<PublicDashboardDataProps>;
}): JSX.Element {
const isDarkMode = useIsDarkMode();
const { dashboard, publicDashboard } = publicDashboardData?.data || {};
const { widgets } = dashboard?.data || {};
const [selectedTimeRangeLabel, setSelectedTimeRangeLabel] = useState<string>(
publicDashboard?.defaultTimeRange || '30m',
);
const [selectedTimeRange, setSelectedTimeRange] = useState<{
startTime: number;
endTime: number;
}>(
getStartTimeAndEndTimeFromTimeRange(
publicDashboard?.defaultTimeRange || '30m',
),
);
const isTimeRangeEnabled = publicDashboard?.timeRangeEnabled || false;
// Memoize dashboardLayout to prevent array recreation on every render
const dashboardLayout = useMemo(() => dashboard?.data?.layout || [], [
dashboard?.data?.layout,
]);
const currentPanelMap = useMemo(() => dashboard?.data?.panelMap || {}, [
dashboard?.data?.panelMap,
]);
const handleTimeChange = (
interval: Time | CustomTimeType,
dateTimeRange?: [number, number],
): void => {
if (dateTimeRange) {
setSelectedTimeRange({
startTime: Math.floor(dateTimeRange[0] / 1000),
endTime: Math.floor(dateTimeRange[1] / 1000),
});
} else if (interval !== 'custom') {
const { maxTime, minTime } = GetMinMax(interval);
setSelectedTimeRange({
startTime: Math.floor(minTime / 1000000000),
endTime: Math.floor(maxTime / 1000000000),
});
}
setSelectedTimeRangeLabel(interval as string);
};
return (
<div className="public-dashboard-container">
<div className="public-dashboard-header">
<div className="public-dashboard-header-left">
<div className="brand-logo">
<img
src="/Logos/signoz-brand-logo.svg"
alt="SigNoz"
className="brand-logo-img"
/>
<Typography className="brand-logo-name">SigNoz</Typography>
</div>
<div className="public-dashboard-header-title">
<Typography.Text className="public-dashboard-header-title-text">
{dashboard?.data?.title}
</Typography.Text>
</div>
</div>
{isTimeRangeEnabled && (
<div className="public-dashboard-header-right">
<div className="datetime-section">
<DateTimeSelectionV2
showAutoRefresh={false}
showRefreshText={false}
hideShareModal
onTimeChange={handleTimeChange}
defaultRelativeTime={publicDashboard?.defaultTimeRange as Time}
isModalTimeSelection
modalSelectedInterval={selectedTimeRangeLabel as Time}
disableUrlSync
showRecentlyUsed={false}
/>
</div>
</div>
)}
</div>
<div className="public-dashboard-content fullscreen-grid-container">
<ReactGridLayoutComponent
cols={12}
rowHeight={45}
autoSize
width={100}
useCSSTransforms
isDraggable={false}
isDroppable={false}
isResizable={false}
allowOverlap={false}
layout={dashboardLayout}
style={{ backgroundColor: isDarkMode ? '' : themeColors.snowWhite }}
>
{dashboardLayout?.map((layout) => {
const { i: id } = layout;
const currentWidget = (widgets || [])?.find((e) => e.id === id);
const currentWidgetIndex = (widgets || [])?.findIndex((e) => e.id === id);
if (currentWidget?.panelTypes === PANEL_GROUP_TYPES.ROW) {
const rowWidgetProperties = currentPanelMap[id] || {};
let { title } = currentWidget;
if (rowWidgetProperties.collapsed) {
const widgetCount = rowWidgetProperties.widgets?.length || 0;
const collapsedText = `(${widgetCount} widget${
widgetCount > 1 ? 's' : ''
})`;
title += ` ${collapsedText}`;
}
return (
<CardContainer
isDarkMode={isDarkMode}
className="row-card"
key={id}
data-grid={JSON.stringify(currentWidget)}
>
<div className={cx('row-panel')}>
<div style={{ display: 'flex', gap: '6px', alignItems: 'center' }}>
<Typography.Text className="section-title">{title}</Typography.Text>
</div>
</div>
</CardContainer>
);
}
return (
<CardContainer
isDarkMode={isDarkMode}
key={id}
data-grid={JSON.stringify(currentWidget)}
>
<Card
className="grid-item"
isDarkMode={isDarkMode}
$panelType={currentWidget?.panelTypes || PANEL_TYPES.TIME_SERIES}
>
<Panel
dashboardId={publicDashboardId}
widget={(currentWidget as Widgets) || ({ id, query: {} } as Widgets)}
index={currentWidgetIndex}
startTime={selectedTimeRange.startTime}
endTime={selectedTimeRange.endTime}
/>
</Card>
</CardContainer>
);
})}
</ReactGridLayoutComponent>
</div>
</div>
);
}
export default PublicDashboardContainer;

View File

@@ -0,0 +1,802 @@
import { PANEL_GROUP_TYPES, PANEL_TYPES } from 'constants/queryBuilder';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { StatusCodes } from 'http-status-codes';
import {
publicDashboardResponse,
publicDashboardWidgetData,
} from 'mocks-server/__mockdata__/publicDashboard';
import { rest, server } from 'mocks-server/server';
import { Layout } from 'react-grid-layout';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { SuccessResponseV2 } from 'types/api';
import { Widgets } from 'types/api/dashboard/getAll';
import { PublicDashboardDataProps } from 'types/api/dashboard/public/get';
import { EQueryType } from 'types/common/dashboard';
import PublicDashboardContainer from '../PublicDashboardContainer';
// Mock dependencies
jest.mock('hooks/useDarkMode', () => ({
useIsDarkMode: jest.fn(() => false),
}));
jest.mock('lib/getMinMax', () => ({
__esModule: true,
default: jest.fn((interval: string) => {
if (interval === '1h') {
return {
minTime: 1000000000000,
maxTime: 2000000000000,
};
}
return {
minTime: 500000000000,
maxTime: 1000000000000,
};
}),
}));
jest.mock('container/TopNav/DateTimeSelectionV2', () => ({
__esModule: true,
default: ({
onTimeChange,
}: {
onTimeChange: (interval: string, dateTimeRange?: [number, number]) => void;
}): JSX.Element => (
<div data-testid="datetime-selection">
<button
type="button"
onClick={(): void => onTimeChange('1h')}
aria-label="Change time to 1 hour"
>
Change Time
</button>
<button
type="button"
onClick={(): void => onTimeChange('custom', [1000000, 2000000])}
aria-label="Set custom time range"
>
Custom Time
</button>
</div>
),
}));
jest.mock('../Panel', () => ({
__esModule: true,
default: ({
widget,
startTime,
endTime,
}: {
widget: Widgets;
startTime: number;
endTime: number;
}): JSX.Element => (
<div data-testid={`panel-${widget.id}`}>
<span>
Panel: {widget.id} ({startTime}-{endTime})
</span>
</div>
),
}));
jest.mock('react-grid-layout', () => ({
__esModule: true,
default: ({
children,
layout,
style,
}: {
children: React.ReactNode;
layout: Layout[];
style?: React.CSSProperties;
}): JSX.Element => (
<div
data-testid="grid-layout"
data-layout={JSON.stringify(layout)}
style={style}
>
{children}
</div>
),
WidthProvider: (
Component: React.ComponentType<unknown>,
): React.ComponentType<unknown> => Component,
}));
// Mock dayjs
jest.mock('dayjs', () => {
const actualDayjs = jest.requireActual('dayjs');
const mockUnix = jest.fn(() => 1000);
const mockUtcOffset = jest.fn(() => 0);
const mockTzMethod = jest.fn(() => ({
utcOffset: mockUtcOffset,
}));
const mockSubtract = jest.fn(() => ({
subtract: jest.fn(),
unix: mockUnix,
tz: mockTzMethod,
}));
const mockDayjs = jest.fn(() => ({
subtract: mockSubtract,
unix: mockUnix,
tz: mockTzMethod,
}));
Object.keys(actualDayjs).forEach((key) => {
((mockDayjs as unknown) as Record<string, unknown>)[
key
] = (actualDayjs as Record<string, unknown>)[key];
});
((mockDayjs as unknown) as { extend: jest.Mock }).extend = jest.fn();
((mockDayjs as unknown) as { tz: { guess: jest.Mock } }).tz = {
guess: jest.fn(() => 'UTC'),
};
return mockDayjs;
});
const mockUseIsDarkMode = jest.mocked(useIsDarkMode);
// MSW setup
beforeAll(() => {
server.listen();
});
afterAll(() => {
server.close();
});
afterEach(() => {
server.resetHandlers();
});
// Test constants
const MOCK_PUBLIC_DASHBOARD_ID = 'test-dashboard-id';
const MOCK_PUBLIC_PATH = '/public/dashboard/test';
const DEFAULT_TIME_RANGE = '30m';
// Use title from mock data
const TEST_DASHBOARD_TITLE = publicDashboardResponse.data.dashboard.data.title;
// Use widget ID from mock data
const WIDGET_1_ID =
publicDashboardResponse.data.dashboard.data.widgets?.[0]?.id || 'widget-1';
const WIDGET_1_TITLE = 'Widget 1';
const ROW_PANEL_ID = 'row-1';
const ROW_PANEL_TITLE = 'Row Panel';
// Type definitions
interface MockWidget {
id: string;
panelTypes: PANEL_TYPES | PANEL_GROUP_TYPES;
title: string;
query?: Widgets['query'];
description?: string;
opacity?: string;
nullZeroValues?: string;
timePreferance?: string;
softMin?: number | null;
softMax?: number | null;
selectedLogFields?: null;
selectedTracesFields?: null;
}
interface MockPublicDashboardData {
dashboard: {
data: {
title: string;
widgets?: MockWidget[];
layout?: Layout[];
panelMap?: Record<string, { widgets: Layout[]; collapsed: boolean }>;
variables?: Record<string, unknown>;
};
};
publicDashboard: {
timeRangeEnabled: boolean;
defaultTimeRange: string;
publicPath: string;
};
}
// Helper function to create mock query
const createMockQuery = (): Widgets['query'] => ({
builder: {
queryData: [],
queryFormulas: [],
queryTraceOperator: [],
},
clickhouse_sql: [],
promql: [],
id: 'query-1',
queryType: EQueryType.QUERY_BUILDER,
});
// Base mock data - transform publicDashboardResponse to match component's expected format
const baseMockData: SuccessResponseV2<PublicDashboardDataProps> = {
data: (publicDashboardResponse.data as unknown) as PublicDashboardDataProps,
httpStatusCode: StatusCodes.OK,
};
// Helper function to create mock data with optional overrides
const createMockData = (
overrides?: Partial<MockPublicDashboardData>,
): SuccessResponseV2<PublicDashboardDataProps> => {
if (!overrides) {
return baseMockData;
}
const baseData = baseMockData.data;
// Apply overrides if provided
const mergedData: PublicDashboardDataProps = {
dashboard:
(overrides?.dashboard as PublicDashboardDataProps['dashboard']) ||
baseData.dashboard,
publicDashboard: overrides?.publicDashboard || baseData.publicDashboard,
};
return {
data: mergedData,
httpStatusCode: StatusCodes.OK,
};
};
describe('Public Dashboard Container', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseIsDarkMode.mockReturnValue(false);
// Set up default MSW handler for widget query range API
server.use(
rest.get(
'*/public/dashboards/:dashboardId/widgets/:widgetIndex/query_range',
(_req, res, ctx) =>
res(ctx.status(StatusCodes.OK), ctx.json(publicDashboardWidgetData)),
),
);
});
describe('Rendering', () => {
it('should render dashboard with title and brand logo', () => {
render(
<PublicDashboardContainer
publicDashboardId={MOCK_PUBLIC_DASHBOARD_ID}
publicDashboardData={baseMockData}
/>,
);
expect(screen.getByText(TEST_DASHBOARD_TITLE)).toBeInTheDocument();
expect(screen.getByText('SigNoz')).toBeInTheDocument();
expect(screen.getByAltText('SigNoz')).toBeInTheDocument();
});
it('should render time range selector when timeRangeEnabled is true', () => {
const mockData = createMockData({
publicDashboard: {
timeRangeEnabled: true,
defaultTimeRange: DEFAULT_TIME_RANGE,
publicPath: MOCK_PUBLIC_PATH,
},
});
render(
<PublicDashboardContainer
publicDashboardId={MOCK_PUBLIC_DASHBOARD_ID}
publicDashboardData={mockData}
/>,
);
expect(screen.getByTestId('datetime-selection')).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /change time to 1 hour/i }),
).toBeInTheDocument();
});
it('should not render time range selector when timeRangeEnabled is false', () => {
const mockData = createMockData({
publicDashboard: {
timeRangeEnabled: false,
defaultTimeRange: DEFAULT_TIME_RANGE,
publicPath: MOCK_PUBLIC_PATH,
},
});
render(
<PublicDashboardContainer
publicDashboardId={MOCK_PUBLIC_DASHBOARD_ID}
publicDashboardData={mockData}
/>,
);
expect(screen.queryByTestId('datetime-selection')).not.toBeInTheDocument();
});
it('should render widgets in grid layout', () => {
render(
<PublicDashboardContainer
publicDashboardId={MOCK_PUBLIC_DASHBOARD_ID}
publicDashboardData={baseMockData}
/>,
);
expect(screen.getByTestId('grid-layout')).toBeInTheDocument();
expect(screen.getByTestId(`panel-${WIDGET_1_ID}`)).toBeInTheDocument();
expect(
screen.getByText(new RegExp(`Panel: ${WIDGET_1_ID}`)),
).toBeInTheDocument();
});
it('should handle empty dashboard data gracefully', () => {
const mockData = createMockData({
dashboard: {
data: {
title: 'Empty Dashboard',
widgets: [],
layout: [],
panelMap: {},
variables: {},
},
},
});
render(
<PublicDashboardContainer
publicDashboardId={MOCK_PUBLIC_DASHBOARD_ID}
publicDashboardData={mockData}
/>,
);
expect(screen.getByText('Empty Dashboard')).toBeInTheDocument();
expect(screen.getByTestId('grid-layout')).toBeInTheDocument();
});
});
describe('Time Range Handling', () => {
it('should initialize with default time range from publicDashboard', () => {
const mockData = createMockData({
publicDashboard: {
timeRangeEnabled: true,
defaultTimeRange: '1h',
publicPath: MOCK_PUBLIC_PATH,
},
});
render(
<PublicDashboardContainer
publicDashboardId={MOCK_PUBLIC_DASHBOARD_ID}
publicDashboardData={mockData}
/>,
);
// Panel should receive the initial time range
expect(screen.getByTestId(`panel-${WIDGET_1_ID}`)).toBeInTheDocument();
});
it('should update time range when time change handler is called with interval', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const mockData = createMockData({
publicDashboard: {
timeRangeEnabled: true,
defaultTimeRange: DEFAULT_TIME_RANGE,
publicPath: MOCK_PUBLIC_PATH,
},
});
render(
<PublicDashboardContainer
publicDashboardId={MOCK_PUBLIC_DASHBOARD_ID}
publicDashboardData={mockData}
/>,
);
const timeChangeButton = screen.getByRole('button', {
name: /change time to 1 hour/i,
});
await user.click(timeChangeButton);
await waitFor(() => {
const panel = screen.getByTestId(`panel-${WIDGET_1_ID}`);
expect(panel).toBeInTheDocument();
});
});
it('should update time range when time change handler is called with custom dateTimeRange', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const mockData = createMockData({
publicDashboard: {
timeRangeEnabled: true,
defaultTimeRange: DEFAULT_TIME_RANGE,
publicPath: MOCK_PUBLIC_PATH,
},
});
render(
<PublicDashboardContainer
publicDashboardId={MOCK_PUBLIC_DASHBOARD_ID}
publicDashboardData={mockData}
/>,
);
const customTimeButton = screen.getByRole('button', {
name: /set custom time range/i,
});
await user.click(customTimeButton);
await waitFor(() => {
// Panel should receive updated time range
expect(screen.getByTestId(`panel-${WIDGET_1_ID}`)).toBeInTheDocument();
});
});
it('should use default time range of 30m when defaultTimeRange is not provided', () => {
const mockData = createMockData({
publicDashboard: {
timeRangeEnabled: true,
defaultTimeRange: (undefined as unknown) as string,
publicPath: MOCK_PUBLIC_PATH,
},
});
render(
<PublicDashboardContainer
publicDashboardId={MOCK_PUBLIC_DASHBOARD_ID}
publicDashboardData={mockData}
/>,
);
expect(screen.getByTestId(`panel-${WIDGET_1_ID}`)).toBeInTheDocument();
});
});
describe('Panel Rendering', () => {
it('should render row panel when widget panelTypes is ROW', () => {
const mockData = createMockData({
dashboard: {
data: {
title: TEST_DASHBOARD_TITLE,
widgets: [
{
id: ROW_PANEL_ID,
panelTypes: PANEL_GROUP_TYPES.ROW,
title: ROW_PANEL_TITLE,
},
],
layout: [
{
i: ROW_PANEL_ID,
x: 0,
y: 0,
w: 12,
h: 2,
},
],
panelMap: {},
variables: {},
},
},
});
render(
<PublicDashboardContainer
publicDashboardId={MOCK_PUBLIC_DASHBOARD_ID}
publicDashboardData={mockData}
/>,
);
expect(screen.getByText(ROW_PANEL_TITLE)).toBeInTheDocument();
});
it('should render collapsed row panel with widget count', () => {
const mockData = createMockData({
dashboard: {
data: {
title: TEST_DASHBOARD_TITLE,
widgets: [
{
id: ROW_PANEL_ID,
panelTypes: PANEL_GROUP_TYPES.ROW,
title: ROW_PANEL_TITLE,
},
],
layout: [
{
i: ROW_PANEL_ID,
x: 0,
y: 0,
w: 12,
h: 2,
},
],
panelMap: {
[ROW_PANEL_ID]: {
widgets: [
{ i: 'w1', x: 0, y: 0, w: 6, h: 6 },
{ i: 'w2', x: 6, y: 0, w: 6, h: 6 },
],
collapsed: true,
},
},
variables: {},
},
},
});
render(
<PublicDashboardContainer
publicDashboardId={MOCK_PUBLIC_DASHBOARD_ID}
publicDashboardData={mockData}
/>,
);
expect(screen.getByText(/Row Panel \(2 widgets\)/)).toBeInTheDocument();
});
it('should render collapsed row panel with singular widget count', () => {
const mockData = createMockData({
dashboard: {
data: {
title: TEST_DASHBOARD_TITLE,
widgets: [
{
id: ROW_PANEL_ID,
panelTypes: PANEL_GROUP_TYPES.ROW,
title: ROW_PANEL_TITLE,
},
],
layout: [
{
i: ROW_PANEL_ID,
x: 0,
y: 0,
w: 12,
h: 2,
},
],
panelMap: {
[ROW_PANEL_ID]: {
widgets: [{ i: 'w1', x: 0, y: 0, w: 6, h: 6 }],
collapsed: true,
},
},
variables: {},
},
},
});
render(
<PublicDashboardContainer
publicDashboardId={MOCK_PUBLIC_DASHBOARD_ID}
publicDashboardData={mockData}
/>,
);
expect(screen.getByText(/Row Panel \(1 widget\)/)).toBeInTheDocument();
});
it('should render regular panel for non-ROW widget types', () => {
const mockData = createMockData({
dashboard: {
data: {
title: TEST_DASHBOARD_TITLE,
widgets: [
{
id: WIDGET_1_ID,
panelTypes: PANEL_TYPES.TIME_SERIES,
title: WIDGET_1_TITLE,
query: createMockQuery(),
},
],
layout: [
{
i: WIDGET_1_ID,
x: 0,
y: 0,
w: 6,
h: 6,
},
],
panelMap: {},
variables: {},
},
},
});
render(
<PublicDashboardContainer
publicDashboardId={MOCK_PUBLIC_DASHBOARD_ID}
publicDashboardData={mockData}
/>,
);
expect(screen.getByTestId(`panel-${WIDGET_1_ID}`)).toBeInTheDocument();
expect(
screen.getByText(new RegExp(`Panel: ${WIDGET_1_ID}`)),
).toBeInTheDocument();
});
it('should handle missing widget in layout gracefully', () => {
const mockData = createMockData({
dashboard: {
data: {
title: TEST_DASHBOARD_TITLE,
widgets: [
{
id: WIDGET_1_ID,
panelTypes: PANEL_TYPES.TIME_SERIES,
title: WIDGET_1_TITLE,
query: createMockQuery(),
},
],
layout: [
{
i: 'missing-widget',
x: 0,
y: 0,
w: 6,
h: 6,
},
],
panelMap: {},
variables: {},
},
},
});
render(
<PublicDashboardContainer
publicDashboardId={MOCK_PUBLIC_DASHBOARD_ID}
publicDashboardData={mockData}
/>,
);
// Should render panel with fallback widget data
expect(screen.getByTestId('panel-missing-widget')).toBeInTheDocument();
expect(screen.getByText(/Panel: missing-widget/)).toBeInTheDocument();
});
});
describe('Dark Mode', () => {
it('should apply dark mode styles when isDarkMode is true', () => {
mockUseIsDarkMode.mockReturnValue(true);
const { container } = render(
<PublicDashboardContainer
publicDashboardId={MOCK_PUBLIC_DASHBOARD_ID}
publicDashboardData={baseMockData}
/>,
);
const gridLayout = container.querySelector('[data-testid="grid-layout"]');
expect(gridLayout).toBeInTheDocument();
if (gridLayout) {
expect(gridLayout).toHaveStyle({ backgroundColor: '' });
}
});
it('should apply light mode styles when isDarkMode is false', () => {
mockUseIsDarkMode.mockReturnValue(false);
const { container } = render(
<PublicDashboardContainer
publicDashboardId={MOCK_PUBLIC_DASHBOARD_ID}
publicDashboardData={baseMockData}
/>,
);
const gridLayout = container.querySelector('[data-testid="grid-layout"]');
expect(gridLayout).toBeInTheDocument();
if (gridLayout) {
// themeColors.snowWhite is '#fafafa' which computes to 'rgb(250, 250, 250)'
expect(gridLayout).toHaveStyle({
backgroundColor: 'rgb(250, 250, 250)',
});
}
});
});
describe('Edge Cases', () => {
it('should handle undefined dashboard data', () => {
const mockData: SuccessResponseV2<PublicDashboardDataProps> = {
data: {
dashboard: (undefined as unknown) as PublicDashboardDataProps['dashboard'],
publicDashboard: {
timeRangeEnabled: false,
defaultTimeRange: DEFAULT_TIME_RANGE,
publicPath: MOCK_PUBLIC_PATH,
},
},
httpStatusCode: StatusCodes.OK,
};
render(
<PublicDashboardContainer
publicDashboardId={MOCK_PUBLIC_DASHBOARD_ID}
publicDashboardData={mockData}
/>,
);
expect(screen.getByText('SigNoz')).toBeInTheDocument();
});
it('should handle missing layout data', () => {
const mockData = createMockData({
dashboard: {
data: {
title: TEST_DASHBOARD_TITLE,
widgets: [
{
id: WIDGET_1_ID,
panelTypes: PANEL_TYPES.TIME_SERIES,
title: WIDGET_1_TITLE,
query: createMockQuery(),
},
],
layout: (undefined as unknown) as Layout[],
panelMap: {},
variables: {},
},
},
});
render(
<PublicDashboardContainer
publicDashboardId={MOCK_PUBLIC_DASHBOARD_ID}
publicDashboardData={mockData}
/>,
);
// Component should render without errors even with missing layout
expect(screen.getByText(TEST_DASHBOARD_TITLE)).toBeInTheDocument();
});
it('should handle multiple widgets in layout', () => {
const mockData = createMockData({
dashboard: {
data: {
title: TEST_DASHBOARD_TITLE,
widgets: [
{
id: WIDGET_1_ID,
panelTypes: PANEL_TYPES.TIME_SERIES,
title: WIDGET_1_TITLE,
query: createMockQuery(),
},
{
id: 'widget-2',
panelTypes: PANEL_TYPES.TABLE,
title: 'Widget 2',
query: createMockQuery(),
},
],
layout: [
{
i: WIDGET_1_ID,
x: 0,
y: 0,
w: 6,
h: 6,
},
{
i: 'widget-2',
x: 6,
y: 0,
w: 6,
h: 6,
},
],
panelMap: {},
variables: {},
},
},
});
render(
<PublicDashboardContainer
publicDashboardId={MOCK_PUBLIC_DASHBOARD_ID}
publicDashboardData={mockData}
/>,
);
expect(screen.getByTestId(`panel-${WIDGET_1_ID}`)).toBeInTheDocument();
expect(screen.getByTestId('panel-widget-2')).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,3 @@
import PublicDashboardContainer from './PublicDashboardContainer';
export default PublicDashboardContainer;

View File

@@ -1,6 +1,8 @@
/* eslint-disable react/jsx-props-no-spreading */
import { Button, Flex, Input, Select } from 'antd';
import { Button, Flex, Select } from 'antd';
import cx from 'classnames';
import OverflowInputToolTip from 'components/OverflowInputToolTip';
import {
logsQueryFunctionOptions,
metricQueryFunctionOptions,
@@ -9,6 +11,7 @@ import {
import { useIsDarkMode } from 'hooks/useDarkMode';
import { debounce, isNil } from 'lodash-es';
import { X } from 'lucide-react';
import { useMemo, useState } from 'react';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { QueryFunction } from 'types/api/v5/queryRange';
import { DataSource, QueryFunctionsTypes } from 'types/common/queryBuilder';
@@ -47,9 +50,13 @@ export default function Function({
functionValue = funcData.args?.[0]?.value;
}
const debouncedhandleUpdateFunctionArgs = debounce(
handleUpdateFunctionArgs,
500,
const [value, setValue] = useState<string>(
functionValue !== undefined ? String(functionValue) : '',
);
const debouncedhandleUpdateFunctionArgs = useMemo(
() => debounce(handleUpdateFunctionArgs, 500),
[handleUpdateFunctionArgs],
);
// update the logic when we start supporting functions for traces
@@ -89,13 +96,18 @@ export default function Function({
/>
{showInput && (
<Input
className="query-function-value"
<OverflowInputToolTip
autoFocus
defaultValue={functionValue}
value={value}
onChange={(event): void => {
const newVal = event.target.value;
setValue(newVal);
debouncedhandleUpdateFunctionArgs(funcData, index, event.target.value);
}}
tooltipPlacement="top"
minAutoWidth={70}
maxAutoWidth={150}
className="query-function-value"
/>
)}

View File

@@ -99,7 +99,7 @@
}
.query-function-value {
width: 55px;
width: 70px;
border-left: 0;
background: var(--bg-ink-200);
border-radius: 0;

View File

@@ -68,6 +68,7 @@ import { USER_ROLES } from 'types/roles';
import { checkVersionState } from 'utils/app';
import { showErrorNotification } from 'utils/error';
import { useCmdK } from '../../providers/cmdKProvider';
import { routeConfig } from './config';
import { getQueryString } from './helper';
import {
@@ -120,6 +121,7 @@ function SortableFilter({ item }: { item: SidebarItem }): JSX.Element {
// eslint-disable-next-line sonarjs/cognitive-complexity
function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
const { openCmdK } = useCmdK();
const { pathname, search } = useLocation();
const { currentVersion, latestVersion, isCurrentVersionError } = useSelector<
AppState,
@@ -637,6 +639,8 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
} else {
history.push(settingsRoute);
}
} else if (item.key === 'quick-search') {
openCmdK();
} else if (item) {
onClickHandler(item?.key as string, event);
}

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