mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-08 23:50:40 +01:00
Compare commits
43 Commits
issue_5601
...
feat/quick
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c275aae9b8 | ||
|
|
15de8650f4 | ||
|
|
8059687bc8 | ||
|
|
50031ac2f8 | ||
|
|
5a806583eb | ||
|
|
176dc6d3bc | ||
|
|
8902a157c1 | ||
|
|
a3349c3885 | ||
|
|
dce786b0c7 | ||
|
|
18cdd8c56f | ||
|
|
d1545f3136 | ||
|
|
d372b92c48 | ||
|
|
6f579bef35 | ||
|
|
81e3e930bd | ||
|
|
af984faf7a | ||
|
|
885f25f670 | ||
|
|
1b9d55b324 | ||
|
|
91938925ec | ||
|
|
e2588e6fff | ||
|
|
832a6cc794 | ||
|
|
5948b5b970 | ||
|
|
84834b087b | ||
|
|
d490126162 | ||
|
|
526a7d1c46 | ||
|
|
9f72338c87 | ||
|
|
e7ef4c6bdb | ||
|
|
ef9b8eec8a | ||
|
|
94a5e568fd | ||
|
|
25cd3fc705 | ||
|
|
5c3fdb64ed | ||
|
|
bac68a6707 | ||
|
|
766a05e24b | ||
|
|
1a84503f4b | ||
|
|
9454427b47 | ||
|
|
124d76068c | ||
|
|
704b8bf917 | ||
|
|
3e6339019e | ||
|
|
372d304a6f | ||
|
|
dbb4eb9574 | ||
|
|
c36226050e | ||
|
|
a72484f12c | ||
|
|
71eabac1e7 | ||
|
|
fea3be7c51 |
5
.github/CODEOWNERS
vendored
5
.github/CODEOWNERS
vendored
@@ -109,10 +109,7 @@ go.mod @therealpandey
|
||||
/pkg/modules/role/ @therealpandey
|
||||
/pkg/types/coretypes/ @therealpandey @vikrantgupta25
|
||||
|
||||
/frontend/src/hooks/useAuthZ/ @H4ad
|
||||
/frontend/src/components/GuardAuthZ/ @H4ad
|
||||
/frontend/src/components/AuthZTooltip/ @H4ad
|
||||
/frontend/src/components/createGuardedRoute/ @H4ad
|
||||
/frontend/src/lib/authz/ @H4ad
|
||||
/frontend/src/container/RolesSettings/ @H4ad
|
||||
/frontend/src/components/RolesSelect/ @H4ad
|
||||
/frontend/src/pages/MembersSettings/ @H4ad
|
||||
|
||||
7
.github/workflows/build-enterprise.yaml
vendored
7
.github/workflows/build-enterprise.yaml
vendored
@@ -61,13 +61,6 @@ jobs:
|
||||
echo 'VITE_SENTRY_AUTH_TOKEN="${{ secrets.SENTRY_AUTH_TOKEN }}"' >> frontend/.env
|
||||
echo 'VITE_SENTRY_ORG="${{ secrets.SENTRY_ORG }}"' >> frontend/.env
|
||||
echo 'VITE_SENTRY_PROJECT_ID="${{ secrets.SENTRY_PROJECT_ID }}"' >> frontend/.env
|
||||
echo 'VITE_SENTRY_DSN="${{ secrets.SENTRY_DSN }}"' >> frontend/.env
|
||||
echo 'VITE_TUNNEL_URL="${{ secrets.TUNNEL_URL }}"' >> frontend/.env
|
||||
echo 'VITE_TUNNEL_DOMAIN="${{ secrets.TUNNEL_DOMAIN }}"' >> frontend/.env
|
||||
echo 'VITE_POSTHOG_KEY="${{ secrets.POSTHOG_KEY }}"' >> frontend/.env
|
||||
echo 'VITE_PYLON_APP_ID="${{ secrets.PYLON_APP_ID }}"' >> frontend/.env
|
||||
echo 'VITE_APPCUES_APP_ID="${{ secrets.APPCUES_APP_ID }}"' >> frontend/.env
|
||||
echo 'VITE_PYLON_IDENTITY_SECRET="${{ secrets.PYLON_IDENTITY_SECRET }}"' >> frontend/.env
|
||||
echo 'VITE_DOCS_BASE_URL="https://signoz.io"' >> frontend/.env
|
||||
echo 'VITE_ENVIRONMENT="production"' >> frontend/.env
|
||||
echo 'VITE_VERSION="${{ steps.build-info.outputs.version }}"' >> frontend/.env
|
||||
|
||||
6
.github/workflows/build-staging.yaml
vendored
6
.github/workflows/build-staging.yaml
vendored
@@ -67,12 +67,6 @@ jobs:
|
||||
echo 'VITE_SENTRY_AUTH_TOKEN="${{ secrets.SENTRY_AUTH_TOKEN }}"' >> frontend/.env
|
||||
echo 'VITE_SENTRY_ORG="${{ secrets.SENTRY_ORG }}"' >> frontend/.env
|
||||
echo 'VITE_SENTRY_PROJECT_ID="${{ secrets.SENTRY_PROJECT_ID }}"' >> frontend/.env
|
||||
echo 'VITE_SENTRY_DSN="${{ secrets.SENTRY_DSN }}"' >> frontend/.env
|
||||
echo 'VITE_TUNNEL_URL="${{ secrets.NP_TUNNEL_URL }}"' >> frontend/.env
|
||||
echo 'VITE_TUNNEL_DOMAIN="${{ secrets.NP_TUNNEL_DOMAIN }}"' >> frontend/.env
|
||||
echo 'VITE_PYLON_APP_ID="${{ secrets.NP_PYLON_APP_ID }}"' >> frontend/.env
|
||||
echo 'VITE_APPCUES_APP_ID="${{ secrets.NP_APPCUES_APP_ID }}"' >> frontend/.env
|
||||
echo 'VITE_PYLON_IDENTITY_SECRET="${{ secrets.NP_PYLON_IDENTITY_SECRET }}"' >> frontend/.env
|
||||
echo 'VITE_DOCS_BASE_URL="https://staging.signoz.io"' >> frontend/.env
|
||||
echo 'VITE_ENVIRONMENT="staging"' >> frontend/.env
|
||||
echo 'VITE_VERSION="${{ steps.build-info.outputs.version }}"' >> frontend/.env
|
||||
|
||||
7
.github/workflows/gor-signoz.yaml
vendored
7
.github/workflows/gor-signoz.yaml
vendored
@@ -27,13 +27,6 @@ jobs:
|
||||
echo 'VITE_SENTRY_AUTH_TOKEN="${{ secrets.SENTRY_AUTH_TOKEN }}"' >> .env
|
||||
echo 'VITE_SENTRY_ORG="${{ secrets.SENTRY_ORG }}"' >> .env
|
||||
echo 'VITE_SENTRY_PROJECT_ID="${{ secrets.SENTRY_PROJECT_ID }}"' >> .env
|
||||
echo 'VITE_SENTRY_DSN="${{ secrets.SENTRY_DSN }}"' >> .env
|
||||
echo 'VITE_TUNNEL_URL="${{ secrets.TUNNEL_URL }}"' >> .env
|
||||
echo 'VITE_TUNNEL_DOMAIN="${{ secrets.TUNNEL_DOMAIN }}"' >> .env
|
||||
echo 'VITE_POSTHOG_KEY="${{ secrets.POSTHOG_KEY }}"' >> .env
|
||||
echo 'VITE_PYLON_APP_ID="${{ secrets.PYLON_APP_ID }}"' >> .env
|
||||
echo 'VITE_APPCUES_APP_ID="${{ secrets.APPCUES_APP_ID }}"' >> .env
|
||||
echo 'VITE_PYLON_IDENTITY_SECRET="${{ secrets.PYLON_IDENTITY_SECRET }}"' >> .env
|
||||
echo 'VITE_DOCS_BASE_URL="https://signoz.io"' >> .env
|
||||
echo 'VITE_ENVIRONMENT="production"' >> .env
|
||||
echo 'VITE_VERSION="${{ github.ref_name }}"' >> .env
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const permissionsTypePath = "frontend/src/hooks/useAuthZ/permissions.type.ts"
|
||||
const permissionsTypePath = "frontend/src/lib/authz/hooks/useAuthZ/permissions.type.ts"
|
||||
|
||||
var permissionsTypeTemplate = template.Must(template.New("permissions").Parse(
|
||||
`// AUTO GENERATED FILE - DO NOT EDIT - GENERATED BY cmd/enterprise/*.go generate authz
|
||||
|
||||
@@ -2917,6 +2917,13 @@ components:
|
||||
publicDashboard:
|
||||
$ref: '#/components/schemas/DashboardtypesGettablePublicDasbhboard'
|
||||
type: object
|
||||
DashboardtypesGettablePublicDashboardDataV2:
|
||||
properties:
|
||||
dashboard:
|
||||
$ref: '#/components/schemas/DashboardtypesGettableDashboardV2'
|
||||
publicDashboard:
|
||||
$ref: '#/components/schemas/DashboardtypesGettablePublicDasbhboard'
|
||||
type: object
|
||||
DashboardtypesHistogramBuckets:
|
||||
properties:
|
||||
bucketCount:
|
||||
@@ -3071,6 +3078,10 @@ components:
|
||||
items:
|
||||
$ref: '#/components/schemas/DashboardtypesListedDashboardForUserV2'
|
||||
type: array
|
||||
reservedKeywords:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
tags:
|
||||
items:
|
||||
$ref: '#/components/schemas/TagtypesGettableTag'
|
||||
@@ -3082,6 +3093,7 @@ components:
|
||||
- dashboards
|
||||
- total
|
||||
- tags
|
||||
- reservedKeywords
|
||||
type: object
|
||||
DashboardtypesListableDashboardV2:
|
||||
properties:
|
||||
@@ -3089,6 +3101,10 @@ components:
|
||||
items:
|
||||
$ref: '#/components/schemas/DashboardtypesListedDashboardV2'
|
||||
type: array
|
||||
reservedKeywords:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
tags:
|
||||
items:
|
||||
$ref: '#/components/schemas/TagtypesGettableTag'
|
||||
@@ -3100,6 +3116,7 @@ components:
|
||||
- dashboards
|
||||
- total
|
||||
- tags
|
||||
- reservedKeywords
|
||||
type: object
|
||||
DashboardtypesListableDashboardView:
|
||||
properties:
|
||||
@@ -5214,6 +5231,16 @@ components:
|
||||
- offset
|
||||
- limit
|
||||
type: object
|
||||
LlmpricingruletypesGettableUnmappedModels:
|
||||
properties:
|
||||
items:
|
||||
items:
|
||||
$ref: '#/components/schemas/LlmpricingruletypesUnmappedModel'
|
||||
nullable: true
|
||||
type: array
|
||||
required:
|
||||
- items
|
||||
type: object
|
||||
LlmpricingruletypesLLMPricingCacheCosts:
|
||||
properties:
|
||||
mode:
|
||||
@@ -5303,6 +5330,19 @@ components:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
LlmpricingruletypesUnmappedModel:
|
||||
properties:
|
||||
modelName:
|
||||
type: string
|
||||
provider:
|
||||
type: string
|
||||
spanCount:
|
||||
minimum: 0
|
||||
type: integer
|
||||
required:
|
||||
- modelName
|
||||
- spanCount
|
||||
type: object
|
||||
LlmpricingruletypesUpdatableLLMPricingRule:
|
||||
properties:
|
||||
enabled:
|
||||
@@ -11180,6 +11220,60 @@ paths:
|
||||
summary: Get a pricing rule
|
||||
tags:
|
||||
- llmpricingrules
|
||||
/api/v1/llm_pricing_rules/unmapped_models:
|
||||
get:
|
||||
deprecated: false
|
||||
description: Returns models seen in the last hour of trace data (gen_ai.request.model)
|
||||
that no pricing rule pattern matches, so the user can add them to an existing
|
||||
rule or create a new one.
|
||||
operationId: ListUnmappedLLMModels
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/LlmpricingruletypesGettableUnmappedModels'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
summary: List unmapped models
|
||||
tags:
|
||||
- llmpricingrules
|
||||
/api/v1/logs/promote_paths:
|
||||
get:
|
||||
deprecated: false
|
||||
@@ -17431,6 +17525,138 @@ paths:
|
||||
summary: Update my organization
|
||||
tags:
|
||||
- orgs
|
||||
/api/v2/public/dashboards/{id}:
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoint returns the sanitized v2-shape dashboard data for
|
||||
public access. Each panel query is reduced to a safe field subset, so filters
|
||||
and raw query strings are not exposed.
|
||||
operationId: GetPublicDashboardDataV2
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/DashboardtypesGettablePublicDashboardDataV2'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- anonymous:
|
||||
- public-dashboard:read
|
||||
summary: Get public dashboard data (v2)
|
||||
tags:
|
||||
- dashboard
|
||||
/api/v2/public/dashboards/{id}/panels/{key}/query_range:
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoint returns query range results for a panel of a v2-shape
|
||||
public dashboard. The panel is addressed by its key in spec.panels.
|
||||
operationId: GetPublicDashboardPanelQueryRangeV2
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- in: path
|
||||
name: key
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5QueryRangeResponse'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- anonymous:
|
||||
- public-dashboard:read
|
||||
summary: Get query range result (v2)
|
||||
tags:
|
||||
- dashboard
|
||||
/api/v2/readyz:
|
||||
get:
|
||||
operationId: Readyz
|
||||
|
||||
@@ -29,6 +29,7 @@ type module struct {
|
||||
settings factory.ScopedProviderSettings
|
||||
querier querier.Querier
|
||||
licensing licensing.Licensing
|
||||
tagModule tag.Module
|
||||
}
|
||||
|
||||
func NewModule(store dashboardtypes.Store, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, querier querier.Querier, licensing licensing.Licensing, tagModule tag.Module) dashboard.Module {
|
||||
@@ -41,6 +42,7 @@ func NewModule(store dashboardtypes.Store, settings factory.ProviderSettings, an
|
||||
settings: scopedProviderSettings,
|
||||
querier: querier,
|
||||
licensing: licensing,
|
||||
tagModule: tagModule,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,6 +134,55 @@ func (module *module) GetPublicWidgetQueryRange(ctx context.Context, id valuer.U
|
||||
return module.querier.QueryRange(ctx, dashboard.OrgID, query)
|
||||
}
|
||||
|
||||
func (module *module) GetDashboardByPublicIDV2(ctx context.Context, id valuer.UUID) (*dashboardtypes.DashboardV2, error) {
|
||||
storableDashboard, err := module.store.GetDashboardByPublicID(ctx, id.StringValue())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tags, err := module.tagModule.ListForResource(ctx, storableDashboard.OrgID, coretypes.KindDashboard, storableDashboard.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return storableDashboard.ToDashboardV2(tags)
|
||||
}
|
||||
|
||||
func (module *module) GetPublicWidgetQueryRangeV2(ctx context.Context, id valuer.UUID, panelKey, startTimeRaw, endTimeRaw string) (*querybuildertypesv5.QueryRangeResponse, error) {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.CodeNamespace: "dashboard",
|
||||
instrumentationtypes.CodeFunctionName: "GetPublicWidgetQueryRangeV2",
|
||||
})
|
||||
|
||||
storableDashboard, err := module.store.GetDashboardByPublicID(ctx, id.StringValue())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// tags are not needed for query range.
|
||||
dashboard, err := storableDashboard.ToDashboardV2(nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
publicDashboard, err := module.GetPublic(ctx, dashboard.OrgID, dashboard.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
startTime, endTime, err := publicDashboard.ResolveTimeRange(startTimeRaw, endTimeRaw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
query, err := dashboard.GetPanelQuery(startTime, endTime, panelKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return module.querier.QueryRange(ctx, dashboard.OrgID, query)
|
||||
}
|
||||
|
||||
func (module *module) UpdatePublic(ctx context.Context, orgID valuer.UUID, publicDashboard *dashboardtypes.PublicDashboard) error {
|
||||
_, err := module.licensing.GetActive(ctx, orgID)
|
||||
if err != nil {
|
||||
|
||||
@@ -293,6 +293,8 @@
|
||||
// Forces subpath imports (@signozhq/ui/<component>) instead of the eagerly-loaded barrel
|
||||
"signoz/no-css-module-bracket-access": "warn",
|
||||
// Prevents bracket access on CSS modules (styles['kebab-case']) which fails with camelCaseOnly config
|
||||
"signoz/no-dashboard-fetch-outside-root": "error",
|
||||
// Forces useDashboardFetchRequired() outside the root V2 pages (allowlisted in overrides below)
|
||||
"no-restricted-globals": [
|
||||
"error",
|
||||
{
|
||||
@@ -328,6 +330,11 @@
|
||||
{
|
||||
"name": "immer",
|
||||
"message": "[State mgmt] Direct immer usage is deprecated. Use Zustand (which integrates immer via the immer middleware) instead."
|
||||
},
|
||||
{
|
||||
"name": "api/generated/services/dashboard",
|
||||
"importNames": ["patchDashboardV2", "usePatchDashboardV2"],
|
||||
"message": "[dashboard-v2] Don't call patchDashboardV2/usePatchDashboardV2 directly — use useOptimisticPatch().patchAsync so spec edits update the react-query cache optimistically and reconcile on settle."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -553,6 +560,18 @@
|
||||
"rules": {
|
||||
"signoz/no-zustand-getstate-in-hooks": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
// Root V2 pages own the dashboard fetch lifecycle; useDashboardFetchRequired wraps it.
|
||||
// Everywhere else must use useDashboardFetchRequired().
|
||||
"files": [
|
||||
"src/pages/DashboardPageV2/DashboardPageV2.tsx",
|
||||
"src/pages/DashboardPageV2/PanelEditorPage/PanelEditorPage.tsx",
|
||||
"src/pages/DashboardPageV2/DashboardContainer/hooks/useDashboardFetchRequired.ts"
|
||||
],
|
||||
"rules": {
|
||||
"signoz/no-dashboard-fetch-outside-root": "off"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,105 +1,116 @@
|
||||
# Configuring Over Local
|
||||
1. Docker
|
||||
1. Without Docker
|
||||
<p align="center">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="../docs/readme-assets/signoz-hero-dark.png" width="700">
|
||||
<source media="(prefers-color-scheme: light)" srcset="../docs/readme-assets/signoz-hero-light.png" width="700">
|
||||
<img alt="SigNoz - Observability on Your Terms" src="../docs/readme-assets/signoz-hero-light.png" width="700">
|
||||
</picture>
|
||||
</p>
|
||||
|
||||
## With Docker
|
||||
<p align="center">
|
||||
<a href="https://github.com/SigNoz/signoz/issues"><img alt="GitHub issues" src="https://img.shields.io/github/issues/SigNoz/signoz"></a>
|
||||
<a href="https://signoz.io/slack"><img alt="Slack community" src="https://img.shields.io/badge/slack-community-4A154B?logo=slack&logoColor=white"></a>
|
||||
</p>
|
||||
|
||||
**Building image**
|
||||
# SigNoz Frontend
|
||||
|
||||
``docker compose up`
|
||||
/ This will also run
|
||||
React-based web interface for [SigNoz](https://signoz.io), the open-source observability platform.
|
||||
|
||||
or
|
||||
`docker build . -t tagname`
|
||||
## Tech Stack
|
||||
|
||||
**Tag to remote url- Introduce versioning later on**
|
||||
- **Framework:** React 18 + TypeScript
|
||||
- **Build:** Vite
|
||||
- **State:** React Query, Zustand, Redux Toolkit (legacy)
|
||||
- **Styling:** CSS Modules, Ant Design (legacy)
|
||||
- **Charts:** uPlot
|
||||
- **Testing:** Jest
|
||||
|
||||
```
|
||||
docker tag signoz/frontend:latest 7296823551/signoz:latest
|
||||
## Local Development Setup
|
||||
|
||||
1. Run SigNoz backend locally — see [Self-Host Docs](https://signoz.io/docs/install/self-host/)
|
||||
|
||||
2. Configure environment:
|
||||
```bash
|
||||
cp example.env .env
|
||||
```
|
||||
|
||||
Key variables in `.env`:
|
||||
```bash
|
||||
# Backend API endpoint (required)
|
||||
VITE_FRONTEND_API_ENDPOINT="http://localhost:8080"
|
||||
|
||||
# Enable bundle analyzer (optional)
|
||||
BUNDLE_ANALYSER="true"
|
||||
```
|
||||
|
||||
3. Install and run:
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
```
|
||||
docker compose up
|
||||
Opens [http://localhost:3301](http://localhost:3301).
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
```
|
||||
|
||||
## Without Docker
|
||||
Follow the steps below
|
||||
Output in `build/` folder.
|
||||
|
||||
1. ```git clone https://github.com/SigNoz/signoz.git && cd signoz/frontend```
|
||||
1. change baseURL to ```<test environment URL>``` in file ```src/constants/env.ts```
|
||||
## Bundle Size Analysis
|
||||
|
||||
1. ```pnpm install```
|
||||
1. ```pnpm dev```
|
||||
Set in `.env`:
|
||||
```bash
|
||||
BUNDLE_ANALYSER="true"
|
||||
```
|
||||
|
||||
```Note: Please ping us in #contributing channel in our slack community and we will DM you with <test environment URL>```
|
||||
Then run build:
|
||||
```bash
|
||||
pnpm build
|
||||
```
|
||||
|
||||
# Getting Started with Create React App
|
||||
Opens bundle analyzer visualization automatically.
|
||||
|
||||
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
|
||||
## Testing
|
||||
|
||||
## Available Scripts
|
||||
```bash
|
||||
# Unit tests
|
||||
pnpm test
|
||||
|
||||
In the project directory, you can run:
|
||||
# Type checking
|
||||
pnpm tsgo --noEmit
|
||||
```
|
||||
|
||||
### `pnpm start`
|
||||
## Linting
|
||||
|
||||
Runs the app in the development mode.\
|
||||
Open [http://localhost:3301](http://localhost:3301) to view it in the browser.
|
||||
```bash
|
||||
# Run all linters (oxlint + stylelint)
|
||||
pnpm lint
|
||||
```
|
||||
|
||||
The page will reload if you make edits.\
|
||||
You will also see any lint errors in the console.
|
||||
## Project Structure
|
||||
|
||||
### `pnpm test`
|
||||
```
|
||||
src/
|
||||
├── api/ # API clients and react-query hooks
|
||||
├── components/ # Shared UI components
|
||||
├── container/ # Page-level containers
|
||||
├── hooks/ # Custom React hooks
|
||||
├── pages/ # Route pages
|
||||
├── providers/ # React context providers
|
||||
├── store/ # Redux store
|
||||
└── types/ # TypeScript definitions
|
||||
```
|
||||
|
||||
Launches the test runner in the interactive watch mode.\
|
||||
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
|
||||
## Contributing
|
||||
|
||||
### `pnpm build`
|
||||
See [CONTRIBUTING.md](../CONTRIBUTING.md) in the root repo.
|
||||
|
||||
Builds the app for production to the `build` folder.\
|
||||
It correctly bundles React in production mode and optimizes the build for the best performance.
|
||||
|
||||
The build is minified and the filenames include the hashes.\
|
||||
Your app is ready to be deployed!
|
||||
|
||||
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
|
||||
|
||||
### `pnpm eject`
|
||||
|
||||
**Note: this is a one-way operation. Once you `eject`, you can’t go back!**
|
||||
|
||||
If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
|
||||
|
||||
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own.
|
||||
|
||||
You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it.
|
||||
|
||||
## Learn More
|
||||
|
||||
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
|
||||
|
||||
To learn React, check out the [React documentation](https://reactjs.org/).
|
||||
|
||||
### Code Splitting
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting)
|
||||
|
||||
### Analyzing the Bundle Size
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size)
|
||||
|
||||
### Making a Progressive Web App
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app)
|
||||
|
||||
### Advanced Configuration
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration)
|
||||
|
||||
### Deployment
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment)
|
||||
|
||||
### `pnpm build` fails to minify
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify)
|
||||
Questions? Join our [Slack community](https://signoz.io/slack).
|
||||
|
||||
@@ -1,8 +1,38 @@
|
||||
NODE_ENV="development"
|
||||
BUNDLE_ANALYSER="true"
|
||||
VITE_FRONTEND_API_ENDPOINT="http://localhost:8080"
|
||||
VITE_PYLON_APP_ID="pylon-app-id"
|
||||
VITE_APPCUES_APP_ID="appcess-app-id"
|
||||
VITE_PYLON_IDENTITY_SECRET="pylon-identity-secret"
|
||||
|
||||
CI="1"
|
||||
|
||||
# API
|
||||
VITE_BASE_PATH=""
|
||||
VITE_FRONTEND_API_ENDPOINT="http://localhost:8080"
|
||||
VITE_WEBSOCKET_API_ENDPOINT=""
|
||||
|
||||
# Pylon
|
||||
VITE_PYLON_ENABLED="false"
|
||||
VITE_PYLON_APP_ID=""
|
||||
VITE_PYLON_IDENTITY_SECRET=""
|
||||
|
||||
# Appcues
|
||||
VITE_APPCUES_ENABLED="false"
|
||||
VITE_APPCUES_APP_ID=""
|
||||
|
||||
# PostHog
|
||||
VITE_POSTHOG_ENABLED="false"
|
||||
VITE_POSTHOG_API_HOST=""
|
||||
VITE_POSTHOG_KEY=""
|
||||
VITE_POSTHOG_UI_HOST=""
|
||||
|
||||
# Sentry
|
||||
VITE_SENTRY_ENABLED="false"
|
||||
VITE_SENTRY_AUTH_TOKEN=""
|
||||
VITE_SENTRY_ORG=""
|
||||
VITE_SENTRY_PROJECT_ID=""
|
||||
VITE_SENTRY_TUNNEL=""
|
||||
VITE_SENTRY_DSN=""
|
||||
|
||||
# Docs
|
||||
VITE_DOCS_BASE_URL="https://signoz.io"
|
||||
|
||||
# Build info
|
||||
VITE_ENVIRONMENT="development"
|
||||
VITE_VERSION=""
|
||||
|
||||
@@ -111,11 +111,10 @@
|
||||
<div id="root"></div>
|
||||
|
||||
<script>
|
||||
var PYLON_APP_ID = '<%- PYLON_APP_ID %>';
|
||||
var pylonSettings =
|
||||
((window.signozBootData || {}).settings || {}).pylon || {};
|
||||
var pylonEnabled = pylonSettings.enabled !== false;
|
||||
if (PYLON_APP_ID && pylonEnabled) {
|
||||
var pylonEnabled = pylonSettings.enabled === true;
|
||||
if (pylonSettings.appId && pylonEnabled) {
|
||||
(function () {
|
||||
var e = window;
|
||||
var t = document;
|
||||
@@ -133,7 +132,7 @@
|
||||
e.setAttribute('async', 'true');
|
||||
e.setAttribute(
|
||||
'src',
|
||||
'https://widget.usepylon.com/widget/' + PYLON_APP_ID,
|
||||
'https://widget.usepylon.com/widget/' + pylonSettings.appId,
|
||||
);
|
||||
var n = t.getElementsByTagName('script')[0];
|
||||
n.parentNode.insertBefore(e, n);
|
||||
@@ -150,15 +149,14 @@
|
||||
window.AppcuesSettings = { enableURLDetection: true };
|
||||
</script>
|
||||
<script>
|
||||
var APPCUES_APP_ID = '<%- APPCUES_APP_ID %>';
|
||||
var appcuesSettings =
|
||||
((window.signozBootData || {}).settings || {}).appcues || {};
|
||||
var appcuesEnabled = appcuesSettings.enabled !== false;
|
||||
if (APPCUES_APP_ID && appcuesEnabled) {
|
||||
var appcuesEnabled = appcuesSettings.enabled === true;
|
||||
if (appcuesSettings.appId && appcuesEnabled) {
|
||||
(function (d, t) {
|
||||
var a = d.createElement(t);
|
||||
a.async = 1;
|
||||
a.src = '//fast.appcues.com/' + APPCUES_APP_ID + '.js';
|
||||
a.src = '//fast.appcues.com/' + appcuesSettings.appId + '.js';
|
||||
var s = d.getElementsByTagName(t)[0];
|
||||
s.parentNode.insertBefore(a, s);
|
||||
})(document, 'script');
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
"project": ["src/**/*.ts", "src/**/*.tsx"],
|
||||
"ignore": ["src/api/generated/**/*.ts", "src/typings/*.ts"],
|
||||
"ignoreDependencies": [
|
||||
"http-proxy-middleware",
|
||||
"@typescript/native-preview"
|
||||
]
|
||||
}
|
||||
@@ -79,7 +79,6 @@
|
||||
"event-source-polyfill": "1.0.31",
|
||||
"eventemitter3": "5.0.1",
|
||||
"history": "4.10.1",
|
||||
"http-proxy-middleware": "4.1.1",
|
||||
"http-status-codes": "2.3.0",
|
||||
"i18next": "^21.6.12",
|
||||
"i18next-browser-languagedetector": "^6.1.3",
|
||||
|
||||
48
frontend/plugins/rules/no-dashboard-fetch-outside-root.mjs
Normal file
48
frontend/plugins/rules/no-dashboard-fetch-outside-root.mjs
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Rule: no-dashboard-fetch-outside-root
|
||||
*
|
||||
* `useDashboardFetch` owns the V2 dashboard fetch and exposes its loading/error/refetch
|
||||
* lifecycle. That lifecycle is a root-page concern: DashboardPageV2 and PanelEditorPage
|
||||
* gate their subtree on a resolved dashboard before mounting anything below them.
|
||||
*
|
||||
* Every other component therefore renders inside an already-loaded subtree and must use
|
||||
* `useDashboardFetchRequired`, which reuses the same react-query cache entry but guarantees
|
||||
* a non-undefined dashboard (throwing otherwise) — so consumers don't re-handle states the
|
||||
* root page already owns.
|
||||
*
|
||||
* This rule flags `useDashboardFetch` imports. It is disabled via an override in
|
||||
* .oxlintrc.json for the two root pages and for useDashboardFetchRequired.ts (which wraps it).
|
||||
*/
|
||||
|
||||
const FETCH_HOOK = 'useDashboardFetch';
|
||||
|
||||
export default {
|
||||
meta: {
|
||||
type: 'suggestion',
|
||||
docs: {
|
||||
description:
|
||||
'Disallow useDashboardFetch outside the root dashboard pages; use useDashboardFetchRequired instead',
|
||||
category: 'Dashboard V2',
|
||||
},
|
||||
schema: [],
|
||||
messages: {
|
||||
useRequired:
|
||||
'Use useDashboardFetchRequired() instead of useDashboardFetch — the latter is reserved for the root pages (DashboardPageV2, PanelEditorPage) that own the fetch lifecycle. Components below them render inside a loaded subtree and should assume a guaranteed dashboard.',
|
||||
},
|
||||
},
|
||||
|
||||
create(context) {
|
||||
return {
|
||||
ImportDeclaration(node) {
|
||||
node.specifiers.forEach((specifier) => {
|
||||
if (
|
||||
specifier.type === 'ImportSpecifier' &&
|
||||
specifier.imported.name === FETCH_HOOK
|
||||
) {
|
||||
context.report({ node: specifier, messageId: 'useRequired' });
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -12,6 +12,7 @@ import noRawAbsolutePath from './rules/no-raw-absolute-path.mjs';
|
||||
import noAntdComponents from './rules/no-antd-components.mjs';
|
||||
import noSignozhqUiBarrel from './rules/no-signozhq-ui-barrel.mjs';
|
||||
import noCssModuleBracketAccess from './rules/no-css-module-bracket-access.mjs';
|
||||
import noDashboardFetchOutsideRoot from './rules/no-dashboard-fetch-outside-root.mjs';
|
||||
|
||||
export default {
|
||||
meta: {
|
||||
@@ -25,5 +26,6 @@ export default {
|
||||
'no-antd-components': noAntdComponents,
|
||||
'no-signozhq-ui-barrel': noSignozhqUiBarrel,
|
||||
'no-css-module-bracket-access': noCssModuleBracketAccess,
|
||||
'no-dashboard-fetch-outside-root': noDashboardFetchOutsideRoot,
|
||||
},
|
||||
};
|
||||
|
||||
22
frontend/pnpm-lock.yaml
generated
22
frontend/pnpm-lock.yaml
generated
@@ -164,9 +164,6 @@ importers:
|
||||
history:
|
||||
specifier: 4.10.1
|
||||
version: 4.10.1
|
||||
http-proxy-middleware:
|
||||
specifier: 4.1.1
|
||||
version: 4.1.1
|
||||
http-status-codes:
|
||||
specifier: 2.3.0
|
||||
version: 2.3.0
|
||||
@@ -5461,10 +5458,6 @@ packages:
|
||||
resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
http-proxy-middleware@4.1.1:
|
||||
resolution: {integrity: sha512-KX5ZofGXLFXqFAkQoOWZ+rTtaLTut7m0gyL+QzJrdejtIZ+F4bPPDoe7reISg2+v0CAz5OfVwEJEhty7X+e57g==}
|
||||
engines: {node: ^22.15.0 || ^24.0.0 || >=26.0.0}
|
||||
|
||||
http-status-codes@2.3.0:
|
||||
resolution: {integrity: sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==}
|
||||
|
||||
@@ -5472,9 +5465,6 @@ packages:
|
||||
resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
httpxy@0.5.3:
|
||||
resolution: {integrity: sha512-SMS9V6Sn7VWaS11lYhoAr0ceoaiolTWf4jYdJn0NJhCdKMu9R2H9Fh0LBDWBHQF6HRLI1PmaePYsjanSpE5PEw==}
|
||||
|
||||
human-signals@2.1.0:
|
||||
resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==}
|
||||
engines: {node: '>=10.17.0'}
|
||||
@@ -14515,16 +14505,6 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
http-proxy-middleware@4.1.1:
|
||||
dependencies:
|
||||
debug: 4.3.4(supports-color@5.5.0)
|
||||
httpxy: 0.5.3
|
||||
is-glob: 4.0.3
|
||||
is-plain-obj: 4.1.0
|
||||
micromatch: 4.0.8
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
http-status-codes@2.3.0: {}
|
||||
|
||||
https-proxy-agent@5.0.1:
|
||||
@@ -14534,8 +14514,6 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
httpxy@0.5.3: {}
|
||||
|
||||
human-signals@2.1.0: {}
|
||||
|
||||
human-signals@8.0.1: {}
|
||||
|
||||
@@ -292,10 +292,10 @@ function App(): JSX.Element {
|
||||
isChatSupportEnabled &&
|
||||
!showAddCreditCardModal &&
|
||||
(isCloudUser || isEnterpriseSelfHostedUser) &&
|
||||
(window.signozBootData?.settings?.pylon.enabled ?? true)
|
||||
window.signozBootData?.settings?.pylon?.enabled
|
||||
) {
|
||||
const email = user.email || '';
|
||||
const secret = process.env.PYLON_IDENTITY_SECRET || '';
|
||||
const secret = window.signozBootData?.settings?.pylon?.identitySecret || '';
|
||||
let emailHash = '';
|
||||
|
||||
if (email && secret) {
|
||||
@@ -304,7 +304,7 @@ function App(): JSX.Element {
|
||||
|
||||
window.pylon = {
|
||||
chat_settings: {
|
||||
app_id: process.env.PYLON_APP_ID,
|
||||
app_id: window.signozBootData?.settings?.pylon?.appId,
|
||||
email: user.email,
|
||||
name: user.displayName || user.email,
|
||||
email_hash: emailHash,
|
||||
@@ -335,22 +335,23 @@ function App(): JSX.Element {
|
||||
useEffect(() => {
|
||||
if (isCloudUser || isEnterpriseSelfHostedUser) {
|
||||
if (
|
||||
(window.signozBootData?.settings?.posthog.enabled ?? true) &&
|
||||
process.env.POSTHOG_KEY
|
||||
window.signozBootData?.settings?.posthog?.enabled &&
|
||||
window.signozBootData?.settings?.posthog?.key
|
||||
) {
|
||||
posthog.init(process.env.POSTHOG_KEY, {
|
||||
api_host: 'https://us.i.posthog.com',
|
||||
posthog.init(window.signozBootData.settings.posthog.key, {
|
||||
api_host: window.signozBootData.settings.posthog.apiHost,
|
||||
ui_host: window.signozBootData.settings.posthog.uiHost,
|
||||
person_profiles: 'identified_only', // or 'always' to create profiles for anonymous users as well
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
!isSentryInitialized &&
|
||||
(window.signozBootData?.settings?.sentry.enabled ?? true)
|
||||
window.signozBootData?.settings?.sentry?.enabled
|
||||
) {
|
||||
Sentry.init({
|
||||
dsn: process.env.SENTRY_DSN,
|
||||
tunnel: process.env.TUNNEL_URL,
|
||||
dsn: window.signozBootData.settings.sentry.dsn,
|
||||
tunnel: window.signozBootData.settings.sentry.tunnel,
|
||||
environment: process.env.ENVIRONMENT,
|
||||
release: process.env.VERSION,
|
||||
integrations: [
|
||||
|
||||
@@ -38,6 +38,10 @@ import type {
|
||||
GetPublicDashboard200,
|
||||
GetPublicDashboardData200,
|
||||
GetPublicDashboardDataPathParameters,
|
||||
GetPublicDashboardDataV2200,
|
||||
GetPublicDashboardDataV2PathParameters,
|
||||
GetPublicDashboardPanelQueryRangeV2200,
|
||||
GetPublicDashboardPanelQueryRangeV2PathParameters,
|
||||
GetPublicDashboardPathParameters,
|
||||
GetPublicDashboardWidgetQueryRange200,
|
||||
GetPublicDashboardWidgetQueryRangePathParameters,
|
||||
@@ -1799,6 +1803,217 @@ export const useLockDashboardV2 = <
|
||||
> => {
|
||||
return useMutation(getLockDashboardV2MutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint returns the sanitized v2-shape dashboard data for public access. Each panel query is reduced to a safe field subset, so filters and raw query strings are not exposed.
|
||||
* @summary Get public dashboard data (v2)
|
||||
*/
|
||||
export const getPublicDashboardDataV2 = (
|
||||
{ id }: GetPublicDashboardDataV2PathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetPublicDashboardDataV2200>({
|
||||
url: `/api/v2/public/dashboards/${id}`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetPublicDashboardDataV2QueryKey = ({
|
||||
id,
|
||||
}: GetPublicDashboardDataV2PathParameters) => {
|
||||
return [`/api/v2/public/dashboards/${id}`] as const;
|
||||
};
|
||||
|
||||
export const getGetPublicDashboardDataV2QueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getPublicDashboardDataV2>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ id }: GetPublicDashboardDataV2PathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getPublicDashboardDataV2>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getGetPublicDashboardDataV2QueryKey({ id });
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof getPublicDashboardDataV2>>
|
||||
> = ({ signal }) => getPublicDashboardDataV2({ id }, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: !!id,
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getPublicDashboardDataV2>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetPublicDashboardDataV2QueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getPublicDashboardDataV2>>
|
||||
>;
|
||||
export type GetPublicDashboardDataV2QueryError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Get public dashboard data (v2)
|
||||
*/
|
||||
|
||||
export function useGetPublicDashboardDataV2<
|
||||
TData = Awaited<ReturnType<typeof getPublicDashboardDataV2>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ id }: GetPublicDashboardDataV2PathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getPublicDashboardDataV2>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetPublicDashboardDataV2QueryOptions({ id }, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get public dashboard data (v2)
|
||||
*/
|
||||
export const invalidateGetPublicDashboardDataV2 = async (
|
||||
queryClient: QueryClient,
|
||||
{ id }: GetPublicDashboardDataV2PathParameters,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetPublicDashboardDataV2QueryKey({ id }) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint returns query range results for a panel of a v2-shape public dashboard. The panel is addressed by its key in spec.panels.
|
||||
* @summary Get query range result (v2)
|
||||
*/
|
||||
export const getPublicDashboardPanelQueryRangeV2 = (
|
||||
{ id, key }: GetPublicDashboardPanelQueryRangeV2PathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetPublicDashboardPanelQueryRangeV2200>({
|
||||
url: `/api/v2/public/dashboards/${id}/panels/${key}/query_range`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetPublicDashboardPanelQueryRangeV2QueryKey = ({
|
||||
id,
|
||||
key,
|
||||
}: GetPublicDashboardPanelQueryRangeV2PathParameters) => {
|
||||
return [`/api/v2/public/dashboards/${id}/panels/${key}/query_range`] as const;
|
||||
};
|
||||
|
||||
export const getGetPublicDashboardPanelQueryRangeV2QueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getPublicDashboardPanelQueryRangeV2>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ id, key }: GetPublicDashboardPanelQueryRangeV2PathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getPublicDashboardPanelQueryRangeV2>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ??
|
||||
getGetPublicDashboardPanelQueryRangeV2QueryKey({ id, key });
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof getPublicDashboardPanelQueryRangeV2>>
|
||||
> = ({ signal }) => getPublicDashboardPanelQueryRangeV2({ id, key }, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: !!(id && key),
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getPublicDashboardPanelQueryRangeV2>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetPublicDashboardPanelQueryRangeV2QueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getPublicDashboardPanelQueryRangeV2>>
|
||||
>;
|
||||
export type GetPublicDashboardPanelQueryRangeV2QueryError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Get query range result (v2)
|
||||
*/
|
||||
|
||||
export function useGetPublicDashboardPanelQueryRangeV2<
|
||||
TData = Awaited<ReturnType<typeof getPublicDashboardPanelQueryRangeV2>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ id, key }: GetPublicDashboardPanelQueryRangeV2PathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getPublicDashboardPanelQueryRangeV2>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetPublicDashboardPanelQueryRangeV2QueryOptions(
|
||||
{ id, key },
|
||||
options,
|
||||
);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get query range result (v2)
|
||||
*/
|
||||
export const invalidateGetPublicDashboardPanelQueryRangeV2 = async (
|
||||
queryClient: QueryClient,
|
||||
{ id, key }: GetPublicDashboardPanelQueryRangeV2PathParameters,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetPublicDashboardPanelQueryRangeV2QueryKey({ id, key }) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* Same as ListDashboardsV2 but personalized for the calling user: each dashboard carries the caller's `pinned` state, and pinned dashboards float to the top of the requested ordering. Supports the same filter DSL, sort, order, and pagination.
|
||||
* @summary List dashboards for the current user (v2)
|
||||
|
||||
@@ -23,6 +23,7 @@ import type {
|
||||
GetLLMPricingRulePathParameters,
|
||||
ListLLMPricingRules200,
|
||||
ListLLMPricingRulesParams,
|
||||
ListUnmappedLLMModels200,
|
||||
LlmpricingruletypesUpdatableLLMPricingRulesDTO,
|
||||
RenderErrorResponseDTO,
|
||||
} from '../sigNoz.schemas';
|
||||
@@ -393,3 +394,87 @@ export const invalidateGetLLMPricingRule = async (
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns models seen in the last hour of trace data (gen_ai.request.model) that no pricing rule pattern matches, so the user can add them to an existing rule or create a new one.
|
||||
* @summary List unmapped models
|
||||
*/
|
||||
export const listUnmappedLLMModels = (signal?: AbortSignal) => {
|
||||
return GeneratedAPIInstance<ListUnmappedLLMModels200>({
|
||||
url: `/api/v1/llm_pricing_rules/unmapped_models`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getListUnmappedLLMModelsQueryKey = () => {
|
||||
return [`/api/v1/llm_pricing_rules/unmapped_models`] as const;
|
||||
};
|
||||
|
||||
export const getListUnmappedLLMModelsQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof listUnmappedLLMModels>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listUnmappedLLMModels>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
}) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getListUnmappedLLMModelsQueryKey();
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof listUnmappedLLMModels>>
|
||||
> = ({ signal }) => listUnmappedLLMModels(signal);
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listUnmappedLLMModels>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type ListUnmappedLLMModelsQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof listUnmappedLLMModels>>
|
||||
>;
|
||||
export type ListUnmappedLLMModelsQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary List unmapped models
|
||||
*/
|
||||
|
||||
export function useListUnmappedLLMModels<
|
||||
TData = Awaited<ReturnType<typeof listUnmappedLLMModels>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listUnmappedLLMModels>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getListUnmappedLLMModelsQueryOptions(options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary List unmapped models
|
||||
*/
|
||||
export const invalidateListUnmappedLLMModels = async (
|
||||
queryClient: QueryClient,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getListUnmappedLLMModelsQueryKey() },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
@@ -4941,6 +4941,11 @@ export interface DashboardtypesGettablePublicDashboardDataDTO {
|
||||
publicDashboard?: DashboardtypesGettablePublicDasbhboardDTO;
|
||||
}
|
||||
|
||||
export interface DashboardtypesGettablePublicDashboardDataV2DTO {
|
||||
dashboard?: DashboardtypesGettableDashboardV2DTO;
|
||||
publicDashboard?: DashboardtypesGettablePublicDasbhboardDTO;
|
||||
}
|
||||
|
||||
export enum DashboardtypesPatchOpDTO {
|
||||
add = 'add',
|
||||
remove = 'remove',
|
||||
@@ -5031,6 +5036,10 @@ export interface DashboardtypesListableDashboardForUserV2DTO {
|
||||
* @type array
|
||||
*/
|
||||
dashboards: DashboardtypesListedDashboardForUserV2DTO[];
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
reservedKeywords: string[];
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
@@ -5098,6 +5107,10 @@ export interface DashboardtypesListableDashboardV2DTO {
|
||||
* @type array
|
||||
*/
|
||||
dashboards: DashboardtypesListedDashboardV2DTO[];
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
reservedKeywords: string[];
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
@@ -6810,6 +6823,29 @@ export interface LlmpricingruletypesGettablePricingRulesDTO {
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface LlmpricingruletypesUnmappedModelDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
modelName: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
provider?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
spanCount: number;
|
||||
}
|
||||
|
||||
export interface LlmpricingruletypesGettableUnmappedModelsDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
items: LlmpricingruletypesUnmappedModelDTO[] | null;
|
||||
}
|
||||
|
||||
export interface LlmpricingruletypesUpdatableLLMPricingRuleDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
@@ -10160,6 +10196,14 @@ export type GetLLMPricingRule200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListUnmappedLLMModels200 = {
|
||||
data: LlmpricingruletypesGettableUnmappedModelsDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListPromotedAndIndexedPaths200 = {
|
||||
/**
|
||||
* @type array,null
|
||||
@@ -11153,6 +11197,29 @@ export type GetMyOrganization200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetPublicDashboardDataV2PathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type GetPublicDashboardDataV2200 = {
|
||||
data: DashboardtypesGettablePublicDashboardDataV2DTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetPublicDashboardPanelQueryRangeV2PathParameters = {
|
||||
id: string;
|
||||
key: string;
|
||||
};
|
||||
export type GetPublicDashboardPanelQueryRangeV2200 = {
|
||||
data: Querybuildertypesv5QueryRangeResponseDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type Readyz200 = {
|
||||
data: FactoryResponseDTO;
|
||||
/**
|
||||
|
||||
@@ -2,8 +2,8 @@ import { Controller, useForm } from 'react-hook-form';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { X } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
|
||||
import { SACreatePermission } from 'hooks/useAuthZ/permissions/service-account.permissions';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import { SACreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/service-account.permissions';
|
||||
import { DialogFooter, DialogWrapper } from '@signozhq/ui/dialog';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
|
||||
import CreateServiceAccountModal from '../CreateServiceAccountModal';
|
||||
|
||||
jest.mock('components/AuthZTooltip/AuthZTooltip', () => ({
|
||||
jest.mock('lib/authz/components/AuthZTooltip/AuthZTooltip', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
children,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { QueryParams } from 'constants/query';
|
||||
|
||||
export const ExploreHeaderToolTip = {
|
||||
url: 'https://signoz.io/docs/userguide/query-builder/?utm_source=product&utm_medium=new-query-builder',
|
||||
url: 'https://signoz.io/docs/querying/overview/?utm_source=product&utm_medium=new-query-builder',
|
||||
text: 'More details on how to use query builder',
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import { MutableRefObject } from 'react';
|
||||
import { Chart, ChartConfiguration, ChartData, Color } from 'chart.js';
|
||||
import {
|
||||
ActiveElement,
|
||||
Chart,
|
||||
ChartConfiguration,
|
||||
ChartData,
|
||||
ChartEvent,
|
||||
ChartType,
|
||||
Color,
|
||||
TooltipItem,
|
||||
} from 'chart.js';
|
||||
import * as chartjsAdapter from 'chartjs-adapter-date-fns';
|
||||
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
@@ -60,184 +69,189 @@ export const getGraphOptions = (
|
||||
minTime?: number,
|
||||
maxTime?: number,
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
): CustomChartOptions => ({
|
||||
animation: {
|
||||
duration: animate ? 200 : 0,
|
||||
},
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: 'index',
|
||||
intersect: true,
|
||||
},
|
||||
plugins: {
|
||||
...(staticLine
|
||||
? {
|
||||
annotation: {
|
||||
annotations: [
|
||||
{
|
||||
type: 'line',
|
||||
yMin: staticLine.yMin,
|
||||
yMax: staticLine.yMax,
|
||||
borderColor: staticLine.borderColor,
|
||||
borderWidth: staticLine.borderWidth,
|
||||
label: {
|
||||
content: staticLine.lineText,
|
||||
enabled: true,
|
||||
font: {
|
||||
size: 10,
|
||||
): CustomChartOptions =>
|
||||
({
|
||||
animation: {
|
||||
duration: animate ? 200 : 0,
|
||||
},
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: 'index',
|
||||
intersect: true,
|
||||
},
|
||||
plugins: {
|
||||
...(staticLine
|
||||
? {
|
||||
annotation: {
|
||||
annotations: [
|
||||
{
|
||||
type: 'line',
|
||||
yMin: staticLine.yMin,
|
||||
yMax: staticLine.yMax,
|
||||
borderColor: staticLine.borderColor,
|
||||
borderWidth: staticLine.borderWidth,
|
||||
label: {
|
||||
content: staticLine.lineText,
|
||||
enabled: true,
|
||||
font: {
|
||||
size: 10,
|
||||
},
|
||||
borderWidth: 0,
|
||||
position: 'start',
|
||||
backgroundColor: 'transparent',
|
||||
color: staticLine.textColor,
|
||||
},
|
||||
borderWidth: 0,
|
||||
position: 'start',
|
||||
backgroundColor: 'transparent',
|
||||
color: staticLine.textColor,
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
title: {
|
||||
display: title !== undefined,
|
||||
text: title,
|
||||
},
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
title(context: TooltipItem<'line'>[]): string | string[] {
|
||||
const date = dayjs(context[0].parsed.x);
|
||||
return date
|
||||
.tz(timezone.value)
|
||||
.format(DATE_TIME_FORMATS.MONTH_DATETIME_FULL_SECONDS);
|
||||
},
|
||||
label(context: TooltipItem<'line'>): string | string[] {
|
||||
let label = context.dataset.label || '';
|
||||
|
||||
if (label) {
|
||||
label += ': ';
|
||||
}
|
||||
if (context.parsed.y !== null) {
|
||||
label += getToolTipValue(context.parsed.y.toString(), yAxisUnit);
|
||||
}
|
||||
|
||||
return label;
|
||||
},
|
||||
labelTextColor(labelData: TooltipItem<'line'>): Color {
|
||||
if (labelData.datasetIndex === nearestDatasetIndex.current) {
|
||||
return 'rgba(255, 255, 255, 1)';
|
||||
}
|
||||
|
||||
return 'rgba(255, 255, 255, 0.75)';
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
title: {
|
||||
display: title !== undefined,
|
||||
text: title,
|
||||
},
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
title(context): string | string[] {
|
||||
const date = dayjs(context[0].parsed.x);
|
||||
return date
|
||||
.tz(timezone.value)
|
||||
.format(DATE_TIME_FORMATS.MONTH_DATETIME_FULL_SECONDS);
|
||||
},
|
||||
label(context): string | string[] {
|
||||
let label = context.dataset.label || '';
|
||||
|
||||
if (label) {
|
||||
label += ': ';
|
||||
}
|
||||
if (context.parsed.y !== null) {
|
||||
label += getToolTipValue(context.parsed.y.toString(), yAxisUnit);
|
||||
}
|
||||
|
||||
return label;
|
||||
},
|
||||
labelTextColor(labelData): Color {
|
||||
if (labelData.datasetIndex === nearestDatasetIndex.current) {
|
||||
return 'rgba(255, 255, 255, 1)';
|
||||
}
|
||||
|
||||
return 'rgba(255, 255, 255, 0.75)';
|
||||
position: 'custom',
|
||||
itemSort(item1: TooltipItem<'line'>, item2: TooltipItem<'line'>): number {
|
||||
return item2.parsed.y - item1.parsed.y;
|
||||
},
|
||||
},
|
||||
position: 'custom',
|
||||
itemSort(item1, item2): number {
|
||||
return item2.parsed.y - item1.parsed.y;
|
||||
},
|
||||
[dragSelectPluginId]: createDragSelectPluginOptions(
|
||||
!!onDragSelect,
|
||||
onDragSelect,
|
||||
dragSelectColor,
|
||||
),
|
||||
[intersectionCursorPluginId]: createIntersectionCursorPluginOptions(
|
||||
!!onDragSelect,
|
||||
currentTheme === 'dark' ? 'white' : 'black',
|
||||
),
|
||||
},
|
||||
[dragSelectPluginId]: createDragSelectPluginOptions(
|
||||
!!onDragSelect,
|
||||
onDragSelect,
|
||||
dragSelectColor,
|
||||
),
|
||||
[intersectionCursorPluginId]: createIntersectionCursorPluginOptions(
|
||||
!!onDragSelect,
|
||||
currentTheme === 'dark' ? 'white' : 'black',
|
||||
),
|
||||
},
|
||||
layout: {
|
||||
padding: 0,
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
stacked: isStacked,
|
||||
offset: false,
|
||||
grid: {
|
||||
layout: {
|
||||
padding: 0,
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
stacked: isStacked,
|
||||
offset: false,
|
||||
grid: {
|
||||
display: true,
|
||||
color: getGridColor(),
|
||||
drawTicks: true,
|
||||
},
|
||||
adapters: {
|
||||
date: chartjsAdapter,
|
||||
},
|
||||
time: {
|
||||
unit: xAxisTimeUnit?.unitName || 'minute',
|
||||
stepSize: xAxisTimeUnit?.stepSize || 1,
|
||||
displayFormats: {
|
||||
millisecond: DATE_TIME_FORMATS.TIME_SECONDS,
|
||||
second: DATE_TIME_FORMATS.TIME_SECONDS,
|
||||
minute: DATE_TIME_FORMATS.TIME,
|
||||
hour: DATE_TIME_FORMATS.SLASH_SHORT,
|
||||
day: DATE_TIME_FORMATS.DATE_SHORT,
|
||||
week: DATE_TIME_FORMATS.DATE_SHORT,
|
||||
month: DATE_TIME_FORMATS.YEAR_MONTH,
|
||||
year: DATE_TIME_FORMATS.YEAR_SHORT,
|
||||
},
|
||||
},
|
||||
type: 'time',
|
||||
ticks: { color: getAxisLabelColor(currentTheme) },
|
||||
...(minTime && {
|
||||
min: dayjs(minTime).tz(timezone.value).format(),
|
||||
}),
|
||||
...(maxTime && {
|
||||
max: dayjs(maxTime).tz(timezone.value).format(),
|
||||
}),
|
||||
},
|
||||
y: {
|
||||
stacked: isStacked,
|
||||
display: true,
|
||||
color: getGridColor(),
|
||||
drawTicks: true,
|
||||
},
|
||||
adapters: {
|
||||
date: chartjsAdapter,
|
||||
},
|
||||
time: {
|
||||
unit: xAxisTimeUnit?.unitName || 'minute',
|
||||
stepSize: xAxisTimeUnit?.stepSize || 1,
|
||||
displayFormats: {
|
||||
millisecond: DATE_TIME_FORMATS.TIME_SECONDS,
|
||||
second: DATE_TIME_FORMATS.TIME_SECONDS,
|
||||
minute: DATE_TIME_FORMATS.TIME,
|
||||
hour: DATE_TIME_FORMATS.SLASH_SHORT,
|
||||
day: DATE_TIME_FORMATS.DATE_SHORT,
|
||||
week: DATE_TIME_FORMATS.DATE_SHORT,
|
||||
month: DATE_TIME_FORMATS.YEAR_MONTH,
|
||||
year: DATE_TIME_FORMATS.YEAR_SHORT,
|
||||
grid: {
|
||||
display: true,
|
||||
color: getGridColor(),
|
||||
},
|
||||
},
|
||||
type: 'time',
|
||||
ticks: { color: getAxisLabelColor(currentTheme) },
|
||||
...(minTime && {
|
||||
min: dayjs(minTime).tz(timezone.value).format(),
|
||||
}),
|
||||
...(maxTime && {
|
||||
max: dayjs(maxTime).tz(timezone.value).format(),
|
||||
}),
|
||||
},
|
||||
y: {
|
||||
stacked: isStacked,
|
||||
display: true,
|
||||
grid: {
|
||||
display: true,
|
||||
color: getGridColor(),
|
||||
},
|
||||
ticks: {
|
||||
color: getAxisLabelColor(currentTheme),
|
||||
// Include a dollar sign in the ticks
|
||||
callback(value): string {
|
||||
return getYAxisFormattedValue(value.toString(), yAxisUnit);
|
||||
ticks: {
|
||||
color: getAxisLabelColor(currentTheme),
|
||||
// Include a dollar sign in the ticks
|
||||
callback(value: number | string): string {
|
||||
return getYAxisFormattedValue(value.toString(), yAxisUnit);
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
elements: {
|
||||
line: {
|
||||
tension: 0,
|
||||
cubicInterpolationMode: 'monotone',
|
||||
},
|
||||
point: {
|
||||
hoverBackgroundColor: (ctx: any): string => {
|
||||
if (ctx?.element?.options?.borderColor) {
|
||||
return ctx.element.options.borderColor;
|
||||
}
|
||||
return 'rgba(0,0,0,0.1)';
|
||||
elements: {
|
||||
line: {
|
||||
tension: 0,
|
||||
cubicInterpolationMode: 'monotone',
|
||||
},
|
||||
hoverRadius: 5,
|
||||
},
|
||||
},
|
||||
onClick: (event, element, chart): void => {
|
||||
if (onClickHandler) {
|
||||
onClickHandler(event, element, chart, data);
|
||||
}
|
||||
},
|
||||
onHover: (event, _, chart): void => {
|
||||
if (event.native) {
|
||||
const interactions = chart.getElementsAtEventForMode(
|
||||
event.native,
|
||||
'nearest',
|
||||
{
|
||||
intersect: false,
|
||||
point: {
|
||||
hoverBackgroundColor: (ctx: any): string => {
|
||||
if (ctx?.element?.options?.borderColor) {
|
||||
return ctx.element.options.borderColor;
|
||||
}
|
||||
return 'rgba(0,0,0,0.1)';
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
if (interactions[0]) {
|
||||
nearestDatasetIndex.current = interactions[0].datasetIndex;
|
||||
hoverRadius: 5,
|
||||
},
|
||||
},
|
||||
onClick: (
|
||||
event: ChartEvent,
|
||||
element: ActiveElement[],
|
||||
chart: Chart,
|
||||
): void => {
|
||||
if (onClickHandler) {
|
||||
onClickHandler(event, element, chart, data);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
onHover: (event: ChartEvent, _: ActiveElement[], chart: Chart): void => {
|
||||
if (event.native) {
|
||||
const interactions = chart.getElementsAtEventForMode(
|
||||
event.native,
|
||||
'nearest',
|
||||
{
|
||||
intersect: false,
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
if (interactions[0]) {
|
||||
nearestDatasetIndex.current = interactions[0].datasetIndex;
|
||||
}
|
||||
}
|
||||
},
|
||||
}) as CustomChartOptions;
|
||||
|
||||
declare module 'chart.js' {
|
||||
interface TooltipPositionerMap {
|
||||
|
||||
@@ -131,7 +131,7 @@ const MetricsAggregateSection = memo(function MetricsAggregateSection({
|
||||
Set the time interval for aggregation
|
||||
<br />
|
||||
<a
|
||||
href="https://signoz.io/docs/userguide/query-builder-v5/#time-aggregation-windows"
|
||||
href="https://signoz.io/docs/userguide/query-builder-v5/#temporal-aggregation-within-each-time-series"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: '#1890ff', textDecoration: 'underline' }}
|
||||
@@ -254,7 +254,7 @@ const MetricsAggregateSection = memo(function MetricsAggregateSection({
|
||||
Set the time interval for aggregation
|
||||
<br />
|
||||
<a
|
||||
href="https://signoz.io/docs/userguide/query-builder-v5/#time-aggregation-windows"
|
||||
href="https://signoz.io/docs/userguide/query-builder-v5/#temporal-aggregation-within-each-time-series"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: '#1890ff', textDecoration: 'underline' }}
|
||||
|
||||
@@ -52,7 +52,7 @@ const ADD_ONS = [
|
||||
key: ADD_ONS_KEYS.GROUP_BY,
|
||||
description:
|
||||
'Break down data by attributes like service name, endpoint, status code, or region. Essential for spotting patterns and comparing performance across different segments.',
|
||||
docLink: 'https://signoz.io/docs/userguide/query-builder-v5/#grouping',
|
||||
docLink: 'https://signoz.io/docs/querying/aggregation-grouping/#grouping',
|
||||
},
|
||||
{
|
||||
icon: <ScrollText size={14} />,
|
||||
@@ -61,7 +61,7 @@ const ADD_ONS = [
|
||||
description:
|
||||
'Filter grouped results based on aggregate conditions. Show only groups meeting specific criteria, like error rates > 5% or p99 latency > 500',
|
||||
docLink:
|
||||
'https://signoz.io/docs/userguide/query-builder-v5/#conditional-filtering-with-having',
|
||||
'https://signoz.io/docs/querying/result-manipulation/#conditional-filtering-with-having',
|
||||
},
|
||||
{
|
||||
icon: <ScrollText size={14} />,
|
||||
@@ -70,7 +70,7 @@ const ADD_ONS = [
|
||||
description:
|
||||
'Sort results to surface what matters most. Quickly identify slowest operations, most frequent errors, or highest resource consumers.',
|
||||
docLink:
|
||||
'https://signoz.io/docs/userguide/query-builder-v5/#sorting--limiting',
|
||||
'https://signoz.io/docs/querying/result-manipulation/#sorting--limiting',
|
||||
},
|
||||
{
|
||||
icon: <ScrollText size={14} />,
|
||||
@@ -79,7 +79,7 @@ const ADD_ONS = [
|
||||
description:
|
||||
'Show only the top/bottom N results. Perfect for focusing on outliers, reducing noise, and improving dashboard performance.',
|
||||
docLink:
|
||||
'https://signoz.io/docs/userguide/query-builder-v5/#sorting--limiting',
|
||||
'https://signoz.io/docs/querying/result-manipulation/#how-limit-works-for-time-series',
|
||||
},
|
||||
{
|
||||
icon: <ScrollText size={14} />,
|
||||
@@ -88,7 +88,7 @@ const ADD_ONS = [
|
||||
description:
|
||||
'Customize series labels using variables like {{service.name}}-{{endpoint}}. Makes charts readable at a glance during incident investigation.',
|
||||
docLink:
|
||||
'https://signoz.io/docs/userguide/query-builder-v5/#legend-formatting',
|
||||
'https://signoz.io/docs/querying/aggregation-grouping/#legend-formatting',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -99,7 +99,7 @@ const REDUCE_TO = {
|
||||
description:
|
||||
'Apply mathematical operations like sum, average, min, max, or percentiles to reduce multiple time series into a single value.',
|
||||
docLink:
|
||||
'https://signoz.io/docs/userguide/query-builder-v5/#reduce-operations',
|
||||
'https://signoz.io/docs/userguide/query-builder-v5/#result-manipulation',
|
||||
};
|
||||
|
||||
const hasValue = (value: unknown): boolean =>
|
||||
@@ -349,7 +349,7 @@ function QueryAddOns({
|
||||
<TooltipContent
|
||||
label="Group By"
|
||||
description="Break down data by attributes like service name, endpoint, status code, or region. Essential for spotting patterns and comparing performance across different segments."
|
||||
docLink="https://signoz.io/docs/userguide/query-builder-v5/#grouping"
|
||||
docLink="https://signoz.io/docs/querying/aggregation-grouping/#grouping"
|
||||
/>
|
||||
}
|
||||
placement="top"
|
||||
@@ -385,7 +385,7 @@ function QueryAddOns({
|
||||
<TooltipContent
|
||||
label="Having"
|
||||
description="Filter grouped results based on aggregate conditions. Show only groups meeting specific criteria, like error rates > 5% or p99 latency > 500"
|
||||
docLink="https://signoz.io/docs/userguide/query-builder-v5/#conditional-filtering-with-having"
|
||||
docLink="https://signoz.io/docs/querying/result-manipulation/#conditional-filtering-with-having"
|
||||
/>
|
||||
}
|
||||
placement="top"
|
||||
@@ -434,7 +434,7 @@ function QueryAddOns({
|
||||
<TooltipContent
|
||||
label="Order By"
|
||||
description="Sort results to surface what matters most. Quickly identify slowest operations, most frequent errors, or highest resource consumers."
|
||||
docLink="https://signoz.io/docs/userguide/query-builder-v5/#sorting--limiting"
|
||||
docLink="https://signoz.io/docs/querying/result-manipulation/#sorting--limiting"
|
||||
/>
|
||||
}
|
||||
placement="top"
|
||||
@@ -473,7 +473,7 @@ function QueryAddOns({
|
||||
<TooltipContent
|
||||
label="Reduce to"
|
||||
description="Apply mathematical operations like sum, average, min, max, or percentiles to reduce multiple time series into a single value."
|
||||
docLink="https://signoz.io/docs/userguide/query-builder-v5/#reduce-operations"
|
||||
docLink="https://signoz.io/docs/userguide/query-builder-v5/#result-manipulation"
|
||||
/>
|
||||
}
|
||||
placement="top"
|
||||
|
||||
@@ -65,7 +65,7 @@ function QueryAggregationOptions({
|
||||
Set the time interval for aggregation
|
||||
<br />
|
||||
<a
|
||||
href="https://signoz.io/docs/userguide/query-builder-v5/#time-aggregation-windows"
|
||||
href="https://signoz.io/docs/userguide/query-builder-v5/#temporal-aggregation-within-each-time-series"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: '#1890ff', textDecoration: 'underline' }}
|
||||
|
||||
@@ -676,7 +676,7 @@ function QueryAggregationSelect({
|
||||
</span>
|
||||
<br />
|
||||
<a
|
||||
href="https://signoz.io/docs/userguide/query-builder-v5/#core-aggregation-functions"
|
||||
href="https://signoz.io/docs/querying/aggregation-grouping/#core-aggregation-functions-logs--traces"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: '#1890ff', textDecoration: 'underline' }}
|
||||
|
||||
@@ -44,7 +44,7 @@ function TraceOperatorSection({
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
Add Trace Matching
|
||||
<Typography.Link
|
||||
href="https://signoz.io/docs/userguide/query-builder-v5/#multi-query-analysis-trace-operators"
|
||||
href="https://signoz.io/docs/querying/multi-query-analysis/#trace-matching"
|
||||
target="_blank"
|
||||
style={{ textDecoration: 'underline' }}
|
||||
>
|
||||
@@ -106,7 +106,7 @@ export default function QueryFooter({
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
Add New Formula
|
||||
<Typography.Link
|
||||
href="https://signoz.io/docs/userguide/query-builder-v5/#multi-query-analysis-advanced-comparisons"
|
||||
href="https://signoz.io/docs/querying/multi-query-analysis/#advanced-comparisons"
|
||||
target="_blank"
|
||||
style={{ textDecoration: 'underline' }}
|
||||
>
|
||||
|
||||
@@ -11,6 +11,8 @@ import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { isKeyMatch } from './utils';
|
||||
import { CheckedState } from '../../types';
|
||||
import { SectionType } from './v2/itemRules';
|
||||
|
||||
export const SELECTED_OPERATORS = [OPERATORS['='], 'in'];
|
||||
export const NON_SELECTED_OPERATORS = [OPERATORS['!='], 'not in', 'nin'];
|
||||
@@ -148,6 +150,8 @@ export function applyCheckboxToggle({
|
||||
value,
|
||||
checked,
|
||||
isOnlyOrAllClicked,
|
||||
previousState,
|
||||
sectionType,
|
||||
}: {
|
||||
currentQuery: Query;
|
||||
activeQueryIndex: number;
|
||||
@@ -157,6 +161,8 @@ export function applyCheckboxToggle({
|
||||
value: string;
|
||||
checked: boolean;
|
||||
isOnlyOrAllClicked: boolean;
|
||||
previousState?: CheckedState;
|
||||
sectionType?: SectionType;
|
||||
}): Query {
|
||||
const activeItems =
|
||||
currentQuery.builder.queryData?.[activeQueryIndex]?.filters?.items;
|
||||
@@ -216,6 +222,7 @@ export function applyCheckboxToggle({
|
||||
);
|
||||
if (currentFilter) {
|
||||
const runningOperator = currentFilter?.op;
|
||||
|
||||
switch (runningOperator) {
|
||||
case 'in':
|
||||
if (checked) {
|
||||
@@ -246,9 +253,29 @@ export function applyCheckboxToggle({
|
||||
});
|
||||
}
|
||||
} else if (!checked) {
|
||||
// if we are removing some value when the running operator is IN we filter.
|
||||
// example - key IN [value1,currentSelectedValue] becomes key IN [value1] in case of array
|
||||
if (isArray(currentFilter.value)) {
|
||||
// Related section: clicking to exclude creates NOT_IN for just this value
|
||||
if (sectionType === SectionType.RELATED) {
|
||||
const newFilter: TagFilterItem = {
|
||||
id: uuid(),
|
||||
op: getNotInOperator(source),
|
||||
key: filter.attributeKey,
|
||||
value,
|
||||
};
|
||||
query.filters.items = query.filters.items.map((item) => {
|
||||
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
|
||||
return newFilter;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
if (query.filter?.expression) {
|
||||
query.filter.expression = removeKeysFromExpression(
|
||||
query.filter.expression,
|
||||
[filter.attributeKey.key],
|
||||
);
|
||||
}
|
||||
} else if (isArray(currentFilter.value)) {
|
||||
// if we are removing some value when the running operator is IN we filter.
|
||||
// example - key IN [value1,currentSelectedValue] becomes key IN [value1] in case of array
|
||||
const newFilter = {
|
||||
...currentFilter,
|
||||
value: currentFilter.value.filter((val) => val !== value),
|
||||
@@ -275,11 +302,38 @@ export function applyCheckboxToggle({
|
||||
}
|
||||
break;
|
||||
case 'nin':
|
||||
case 'not in':
|
||||
// if the current running operator is NIN then when unchecking the value it gets
|
||||
// added to the clause like key NIN [value1 , currentUnselectedValue]
|
||||
if (!checked) {
|
||||
// in case of array add the currentUnselectedValue to the list.
|
||||
case 'not in': {
|
||||
// NOT IN means "exclude these values"
|
||||
// Check if value is currently in the exclusion list
|
||||
const isValueInFilter = isArray(currentFilter.value)
|
||||
? currentFilter.value.includes(value)
|
||||
: currentFilter.value === value;
|
||||
|
||||
// When clicking unchecked "Other" item, user wants to SELECT it
|
||||
// Replace NOT IN filter with IN [value]
|
||||
if (previousState === 'unchecked' && checked) {
|
||||
const newFilter: TagFilterItem = {
|
||||
id: uuid(),
|
||||
op: getOperatorValue(OPERATORS.IN),
|
||||
key: filter.attributeKey,
|
||||
value,
|
||||
};
|
||||
query.filters.items = query.filters.items.map((item) => {
|
||||
if (isKeyMatch(item.key?.key, filter.attributeKey.key)) {
|
||||
return newFilter;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
if (query.filter?.expression) {
|
||||
query.filter.expression = removeKeysFromExpression(
|
||||
query.filter.expression,
|
||||
[filter.attributeKey.key],
|
||||
);
|
||||
}
|
||||
} else if (!checked || !isValueInFilter) {
|
||||
// Add to NOT IN when:
|
||||
// - checked=false (user explicitly unchecked to exclude)
|
||||
// - checked=true but value not in filter (clicking "other" value to exclude)
|
||||
if (isArray(currentFilter.value)) {
|
||||
const newFilter = {
|
||||
...currentFilter,
|
||||
@@ -292,7 +346,6 @@ export function applyCheckboxToggle({
|
||||
return item;
|
||||
});
|
||||
} else {
|
||||
// in case of not an array make it one!
|
||||
const newFilter = {
|
||||
...currentFilter,
|
||||
value: [currentFilter.value as string, value],
|
||||
@@ -304,8 +357,9 @@ export function applyCheckboxToggle({
|
||||
return item;
|
||||
});
|
||||
}
|
||||
} else if (checked) {
|
||||
// opposite of above!
|
||||
} else {
|
||||
// Remove from NOT IN when value IS in filter and checked=true
|
||||
// (user wants to include this value back)
|
||||
if (isArray(currentFilter.value)) {
|
||||
const newFilter = {
|
||||
...currentFilter,
|
||||
@@ -346,6 +400,7 @@ export function applyCheckboxToggle({
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case '=':
|
||||
if (checked) {
|
||||
const newFilter = {
|
||||
@@ -389,10 +444,11 @@ export function applyCheckboxToggle({
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// case - when there is no filter for the current key that means all are selected right now.
|
||||
// No filter for this key - all are visually selected.
|
||||
// checked=true → user wants to select (IN), checked=false → exclude (NOT IN)
|
||||
const newFilterItem: TagFilterItem = {
|
||||
id: uuid(),
|
||||
op: getNotInOperator(source),
|
||||
op: checked ? getOperatorValue(OPERATORS.IN) : getNotInOperator(source),
|
||||
key: filter.attributeKey,
|
||||
value,
|
||||
};
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
applyCheckboxToggle,
|
||||
clearFilterFromQuery,
|
||||
} from './checkboxFilterQuery';
|
||||
import { CheckedState } from '../../types';
|
||||
import { SectionType } from './v2/itemRules';
|
||||
|
||||
interface UseCheckboxFilterActionsProps {
|
||||
filter: IQuickFiltersConfig;
|
||||
@@ -24,6 +26,8 @@ interface UseCheckboxFilterActionsReturn {
|
||||
value: string,
|
||||
checked: boolean,
|
||||
isOnlyOrAllClicked: boolean,
|
||||
previousState?: CheckedState,
|
||||
sectionType?: SectionType,
|
||||
) => void;
|
||||
onClear: () => void;
|
||||
}
|
||||
@@ -53,6 +57,8 @@ function useCheckboxFilterActions({
|
||||
value: string,
|
||||
checked: boolean,
|
||||
isOnlyOrAllClicked: boolean,
|
||||
previousState?: CheckedState,
|
||||
sectionType?: SectionType,
|
||||
): void => {
|
||||
dispatch(
|
||||
applyCheckboxToggle({
|
||||
@@ -64,6 +70,8 @@ function useCheckboxFilterActions({
|
||||
value,
|
||||
checked,
|
||||
isOnlyOrAllClicked,
|
||||
previousState,
|
||||
sectionType,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
import { screen } from '@testing-library/react';
|
||||
import { server, rest } from 'mocks-server/server';
|
||||
import { render } from 'tests/test-utils';
|
||||
|
||||
import { QuickFiltersSource } from '../../../types';
|
||||
|
||||
import CheckboxFilterV2 from './CheckboxFilterV2';
|
||||
import {
|
||||
DEFAULT_FILTER,
|
||||
DEFAULT_USE_FIELD_APIS,
|
||||
setupServer,
|
||||
} from './CheckboxFilterV2.testUtils';
|
||||
|
||||
const USE_FIELD_APIS_AUTO_DERIVE = {
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: undefined,
|
||||
};
|
||||
|
||||
setupServer();
|
||||
|
||||
describe('CheckboxFilterV2 - existingQuery calculation', () => {
|
||||
const captureExistingQuery = (): Promise<string | null> =>
|
||||
new Promise((resolve) => {
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v1/fields/values', (req, res, ctx) => {
|
||||
const existingQuery = req.url.searchParams.get('existingQuery');
|
||||
resolve(existingQuery);
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
values: {
|
||||
relatedValues: [],
|
||||
stringValues: ['test'],
|
||||
numberValues: [],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
describe('useFieldApis.existingQuery takes precedence', () => {
|
||||
it('uses useFieldApis.existingQuery when provided', async () => {
|
||||
const queryPromise = captureExistingQuery();
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: 'custom.query = "value"',
|
||||
}}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: { items: [], op: 'AND' },
|
||||
filter: { expression: 'should.be.ignored = "yes"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-test');
|
||||
const capturedQuery = await queryPromise;
|
||||
expect(capturedQuery).toBe('custom.query = "value"');
|
||||
});
|
||||
|
||||
it('returns undefined when useFieldApis.existingQuery is null', async () => {
|
||||
const queryPromise = captureExistingQuery();
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: null,
|
||||
}}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: { items: [], op: 'AND' },
|
||||
filter: { expression: 'should.be.ignored = "yes"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-test');
|
||||
const capturedQuery = await queryPromise;
|
||||
expect(capturedQuery).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('V5 filter.expression preferred over V3 filters.items', () => {
|
||||
it('uses V5 filter.expression when both exist', async () => {
|
||||
const queryPromise = captureExistingQuery();
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={USE_FIELD_APIS_AUTO_DERIVE}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: { key: 'service.name', dataType: 'string', type: 'tag' },
|
||||
op: '=',
|
||||
value: 'from-v3-items',
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
filter: { expression: 'v5.expression = "preferred"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-test');
|
||||
const capturedQuery = await queryPromise;
|
||||
expect(capturedQuery).toBe('v5.expression = "preferred"');
|
||||
});
|
||||
|
||||
it('uses V5 filter.expression when no V3 items exist', async () => {
|
||||
const queryPromise = captureExistingQuery();
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={USE_FIELD_APIS_AUTO_DERIVE}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: { items: [], op: 'AND' },
|
||||
filter: { expression: 'only.v5 = "expression"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-test');
|
||||
const capturedQuery = await queryPromise;
|
||||
expect(capturedQuery).toBe('only.v5 = "expression"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('V3 filters.items fallback', () => {
|
||||
it('converts V3 filters.items to expression when no V5 expression exists', async () => {
|
||||
const queryPromise = captureExistingQuery();
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={USE_FIELD_APIS_AUTO_DERIVE}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: { key: 'service.name', dataType: 'string', type: 'tag' },
|
||||
op: '=',
|
||||
value: 'api-service',
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-test');
|
||||
const capturedQuery = await queryPromise;
|
||||
expect(capturedQuery).toBe("service.name = 'api-service'");
|
||||
});
|
||||
|
||||
it('converts multiple V3 filters.items with AND operator', async () => {
|
||||
const queryPromise = captureExistingQuery();
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={USE_FIELD_APIS_AUTO_DERIVE}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: { key: 'service.name', dataType: 'string', type: 'tag' },
|
||||
op: '=',
|
||||
value: 'api',
|
||||
},
|
||||
{
|
||||
key: { key: 'env', dataType: 'string', type: 'tag' },
|
||||
op: '=',
|
||||
value: 'prod',
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-test');
|
||||
const capturedQuery = await queryPromise;
|
||||
expect(capturedQuery).toBe("service.name = 'api' AND env = 'prod'");
|
||||
});
|
||||
|
||||
it('returns undefined when no filters exist', async () => {
|
||||
const queryPromise = captureExistingQuery();
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={USE_FIELD_APIS_AUTO_DERIVE}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: { items: [], op: 'AND' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-test');
|
||||
const capturedQuery = await queryPromise;
|
||||
expect(capturedQuery).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,768 @@
|
||||
import { screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { server, rest } from 'mocks-server/server';
|
||||
import { render } from 'tests/test-utils';
|
||||
|
||||
import { QuickFiltersSource } from '../../../types';
|
||||
|
||||
import CheckboxFilterV2 from './CheckboxFilterV2';
|
||||
import {
|
||||
DEFAULT_FILTER,
|
||||
DEFAULT_USE_FIELD_APIS,
|
||||
getFilterFromCall,
|
||||
mockFieldsValuesAPI,
|
||||
renderWithFilter,
|
||||
setupServer,
|
||||
} from './CheckboxFilterV2.testUtils';
|
||||
|
||||
setupServer();
|
||||
|
||||
describe('CheckboxFilterV2 - interactions', () => {
|
||||
describe('search functionality', () => {
|
||||
it('filters values based on search text', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
let searchTextReceived = '';
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v1/fields/values', (req, res, ctx) => {
|
||||
searchTextReceived = req.url.searchParams.get('searchText') || '';
|
||||
|
||||
const values =
|
||||
searchTextReceived === ''
|
||||
? ['production', 'staging', 'development']
|
||||
: ['production'];
|
||||
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
values: {
|
||||
relatedValues: [],
|
||||
stringValues: values,
|
||||
numberValues: [],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-production');
|
||||
expect(screen.getByTestId('checkbox-value-row-staging')).toBeInTheDocument();
|
||||
|
||||
const searchInput = screen.getByTestId('checkbox-filter-search');
|
||||
await user.type(searchInput, 'prod');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(searchTextReceived).toBe('prod');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByTestId('checkbox-value-row-staging'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('filters values via search while preserving existingQuery context', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
let requestCount = 0;
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v1/fields/values', (req, res, ctx) => {
|
||||
requestCount += 1;
|
||||
const searchText = req.url.searchParams.get('searchText') || '';
|
||||
|
||||
if (requestCount === 1) {
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
values: {
|
||||
relatedValues: ['production'],
|
||||
stringValues: ['staging', 'development'],
|
||||
numberValues: [],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
values: {
|
||||
relatedValues: searchText === 'prod' ? ['production'] : [],
|
||||
stringValues: searchText === 'prod' ? ['production'] : ['staging'],
|
||||
numberValues: [],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: 'service.name = "api"',
|
||||
}}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: { items: [], op: 'AND' },
|
||||
filter: { expression: 'service.name = "api"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-production');
|
||||
// Related values now appear in "Related" section (no badge, uses divider instead)
|
||||
expect(screen.getByTestId('section-divider-related')).toBeInTheDocument();
|
||||
|
||||
const searchInput = screen.getByTestId('checkbox-filter-search');
|
||||
await user.type(searchInput, 'prod');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByTestId('checkbox-value-row-staging'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('checkbox-value-row-production'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows filtered results in all_values section when searching with existingQuery', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v1/fields/values', (req, res, ctx) => {
|
||||
const searchText = req.url.searchParams.get('searchText') || '';
|
||||
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
values: {
|
||||
relatedValues: [],
|
||||
stringValues: searchText === '' ? ['prod', 'staging'] : ['prod-match'],
|
||||
numberValues: [],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: 'service.name = "api"',
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-prod');
|
||||
|
||||
const searchInput = screen.getByTestId('checkbox-filter-search');
|
||||
await user.type(searchInput, 'prod');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('section-all_values')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('checkbox-value-row-prod-match'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows empty search results message when no matches found', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v1/fields/values', (req, res, ctx) => {
|
||||
const searchText = req.url.searchParams.get('searchText') || '';
|
||||
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
values: {
|
||||
relatedValues: [],
|
||||
stringValues: searchText === '' ? ['prod', 'staging'] : [],
|
||||
numberValues: [],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-prod');
|
||||
|
||||
const searchInput = screen.getByTestId('checkbox-filter-search');
|
||||
await user.type(searchInput, 'xyz-no-match');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId('checkbox-filter-no-search-results'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows both RELATED and ALL_VALUES sections with non-overlapping values', async () => {
|
||||
// This tests the bug fix where different pod names in relatedValues vs stringValues
|
||||
// caused all items to go to ALL_VALUES instead of showing RELATED section
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v1/fields/values', (req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
values: {
|
||||
// Non-overlapping: different pod instances
|
||||
relatedValues: ['pod-a-instance-1', 'pod-b-instance-1'],
|
||||
stringValues: ['pod-a-instance-2', 'pod-b-instance-2'],
|
||||
numberValues: [],
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: 'service.name = "api"',
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Wait for values to load
|
||||
await screen.findByTestId('checkbox-value-row-pod-a-instance-1');
|
||||
|
||||
// RELATED section should exist with relatedValues
|
||||
expect(screen.getByTestId('section-related')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('checkbox-value-row-pod-a-instance-1'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('checkbox-value-row-pod-b-instance-1'),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// ALL_VALUES section should exist with stringValues
|
||||
expect(screen.getByTestId('section-all_values')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('checkbox-value-row-pod-a-instance-2'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('checkbox-value-row-pod-b-instance-2'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows both sections during search with filtered non-overlapping values', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v1/fields/values', (req, res, ctx) => {
|
||||
const searchText = req.url.searchParams.get('searchText') || '';
|
||||
|
||||
// Simulate API filtering - both arrays filtered but remain non-overlapping
|
||||
const relatedValues =
|
||||
searchText === '' ? ['pod-a-v1', 'pod-b-v1', 'pod-c-v1'] : ['pod-a-v1']; // filtered to match 'pod-a'
|
||||
const stringValues =
|
||||
searchText === '' ? ['pod-a-v2', 'pod-b-v2', 'pod-c-v2'] : ['pod-a-v2']; // filtered to match 'pod-a'
|
||||
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
values: {
|
||||
relatedValues,
|
||||
stringValues,
|
||||
numberValues: [],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: 'service.name = "api"',
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-pod-a-v1');
|
||||
|
||||
const searchInput = screen.getByTestId('checkbox-filter-search');
|
||||
await user.type(searchInput, 'pod-a');
|
||||
|
||||
// After search, both sections should still appear with filtered results
|
||||
await waitFor(() => {
|
||||
// RELATED section with filtered relatedValues
|
||||
expect(screen.getByTestId('section-related')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('checkbox-value-row-pod-a-v1'),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// ALL_VALUES section with filtered stringValues
|
||||
expect(screen.getByTestId('section-all_values')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('checkbox-value-row-pod-a-v2'),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Other values should be filtered out
|
||||
expect(
|
||||
screen.queryByTestId('checkbox-value-row-pod-b-v1'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('header interactions', () => {
|
||||
it('collapses when header clicked on open filter', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
mockFieldsValuesAPI({
|
||||
stringValues: ['production'],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-production');
|
||||
|
||||
const header = screen.getByTestId('checkbox-filter-header');
|
||||
expect(header).toHaveAttribute('data-state', 'open');
|
||||
|
||||
await user.click(header);
|
||||
|
||||
expect(header).toHaveAttribute('data-state', 'closed');
|
||||
expect(
|
||||
screen.queryByTestId('checkbox-value-row-production'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('expands when header clicked on closed filter', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
mockFieldsValuesAPI({
|
||||
stringValues: ['production'],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={{ ...DEFAULT_FILTER, defaultOpen: false }}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
|
||||
const header = screen.getByTestId('checkbox-filter-header');
|
||||
expect(header).toHaveAttribute('data-state', 'closed');
|
||||
|
||||
await user.click(header);
|
||||
|
||||
expect(header).toHaveAttribute('data-state', 'open');
|
||||
await screen.findByTestId('checkbox-value-row-production');
|
||||
});
|
||||
});
|
||||
|
||||
describe('show more functionality', () => {
|
||||
it('shows "Show More..." when more than 10 values', async () => {
|
||||
const values = Array.from(
|
||||
{ length: 15 },
|
||||
(_, i) => `value-${String(i).padStart(2, '0')}`,
|
||||
);
|
||||
mockFieldsValuesAPI({ stringValues: values });
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-value-00');
|
||||
|
||||
expect(screen.getByTestId('checkbox-filter-show-more')).toBeInTheDocument();
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('checkbox-value-row-value-10'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('loads more values when "Show More..." clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
const values = Array.from(
|
||||
{ length: 15 },
|
||||
(_, i) => `value-${String(i).padStart(2, '0')}`,
|
||||
);
|
||||
mockFieldsValuesAPI({ stringValues: values });
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-value-00');
|
||||
|
||||
await user.click(screen.getByTestId('checkbox-filter-show-more'));
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-value-10');
|
||||
expect(
|
||||
screen.getByTestId('checkbox-value-row-value-14'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('clear functionality', () => {
|
||||
it('shows clear button when filter is open and has filter applied', async () => {
|
||||
mockFieldsValuesAPI({
|
||||
stringValues: ['production'],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: { key: 'deployment.environment' },
|
||||
op: 'in',
|
||||
value: ['production'],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-production');
|
||||
|
||||
expect(screen.getByTestId('checkbox-filter-clear-all')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides clear button when no filter applied for attribute', async () => {
|
||||
mockFieldsValuesAPI({
|
||||
stringValues: ['production'],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-production');
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('checkbox-filter-clear-all'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onFilterChange when clear clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onFilterChange = jest.fn();
|
||||
|
||||
mockFieldsValuesAPI({
|
||||
stringValues: ['production'],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
onFilterChange={onFilterChange}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: { key: 'deployment.environment' },
|
||||
op: 'in',
|
||||
value: ['production'],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-production');
|
||||
|
||||
await user.click(screen.getByTestId('checkbox-filter-clear-all'));
|
||||
|
||||
expect(onFilterChange).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('value row interactions', () => {
|
||||
it('calls onFilterChange when checkbox value clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onFilterChange = jest.fn();
|
||||
|
||||
mockFieldsValuesAPI({
|
||||
stringValues: ['production', 'staging'],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
onFilterChange={onFilterChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
const productionRow = await screen.findByTestId(
|
||||
'checkbox-value-row-production',
|
||||
);
|
||||
|
||||
await user.click(within(productionRow).getByText('production'));
|
||||
|
||||
expect(onFilterChange).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('creates NOT IN filter when unchecking related item with no existing filter', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onFilterChange = jest.fn();
|
||||
|
||||
mockFieldsValuesAPI({
|
||||
relatedValues: ['valueA'],
|
||||
stringValues: ['valueB'],
|
||||
});
|
||||
|
||||
// Start with no filter, related items show as checked
|
||||
// Clicking unchecks them → creates NOT IN filter to exclude
|
||||
renderWithFilter(onFilterChange);
|
||||
|
||||
const rowA = await screen.findByTestId('checkbox-value-row-valueA');
|
||||
expect(rowA).toHaveAttribute('data-state', 'checked');
|
||||
|
||||
await user.click(within(rowA).getByRole('checkbox'));
|
||||
|
||||
expect(onFilterChange).toHaveBeenCalledTimes(1);
|
||||
const filter = getFilterFromCall(onFilterChange);
|
||||
expect(filter?.op).toBe('not in');
|
||||
expect(filter?.value).toBe('valueA');
|
||||
});
|
||||
|
||||
it('converts NOT IN to IN when toggling unchecked (other) item', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onFilterChange = jest.fn();
|
||||
|
||||
mockFieldsValuesAPI({
|
||||
relatedValues: ['valueA'],
|
||||
stringValues: ['valueB'],
|
||||
});
|
||||
|
||||
// Clicking unchecked "Other" item with NOT IN filter should convert to IN [B]
|
||||
renderWithFilter(onFilterChange, { op: 'not in', value: ['valueA'] });
|
||||
|
||||
const rowB = await screen.findByTestId('checkbox-value-row-valueB');
|
||||
expect(rowB).toHaveAttribute('data-state', 'unchecked');
|
||||
|
||||
await user.click(within(rowB).getByRole('checkbox'));
|
||||
|
||||
expect(onFilterChange).toHaveBeenCalledTimes(1);
|
||||
const filter = getFilterFromCall(onFilterChange);
|
||||
expect(filter?.op).toBe('in');
|
||||
expect(filter?.value).toBe('valueB');
|
||||
});
|
||||
|
||||
it('accumulates both values in IN when toggling checked (related) then unchecked (other)', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onFilterChange = jest.fn();
|
||||
|
||||
mockFieldsValuesAPI({
|
||||
relatedValues: ['valueA'],
|
||||
stringValues: ['valueB'],
|
||||
});
|
||||
|
||||
// Start with IN filter for valueA
|
||||
renderWithFilter(onFilterChange, { op: 'in', value: ['valueA'] });
|
||||
|
||||
const rowA = await screen.findByTestId('checkbox-value-row-valueA');
|
||||
expect(rowA).toHaveAttribute('data-state', 'checked');
|
||||
|
||||
const rowB = screen.getByTestId('checkbox-value-row-valueB');
|
||||
expect(rowB).toHaveAttribute('data-state', 'unchecked');
|
||||
|
||||
// Toggle B (unchecked -> should add to IN)
|
||||
await user.click(within(rowB).getByRole('checkbox'));
|
||||
|
||||
expect(onFilterChange).toHaveBeenCalledTimes(1);
|
||||
const filter = getFilterFromCall(onFilterChange);
|
||||
expect(filter?.op).toBe('in');
|
||||
expect(filter?.value).toStrictEqual(['valueA', 'valueB']);
|
||||
});
|
||||
|
||||
it('adds to NOT IN when toggling checked (related) with existing NOT IN filter', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onFilterChange = jest.fn();
|
||||
|
||||
mockFieldsValuesAPI({
|
||||
relatedValues: ['valueA'],
|
||||
stringValues: ['valueB'],
|
||||
});
|
||||
|
||||
// Start with NOT IN filter for valueB
|
||||
renderWithFilter(onFilterChange, { op: 'not in', value: ['valueB'] });
|
||||
|
||||
const rowA = await screen.findByTestId('checkbox-value-row-valueA');
|
||||
// Related values show as checked
|
||||
expect(rowA).toHaveAttribute('data-state', 'checked');
|
||||
|
||||
// Toggle A (checked -> should add to NOT IN)
|
||||
await user.click(within(rowA).getByRole('checkbox'));
|
||||
|
||||
expect(onFilterChange).toHaveBeenCalledTimes(1);
|
||||
const filter = getFilterFromCall(onFilterChange);
|
||||
expect(filter?.op).toBe('not in');
|
||||
expect(filter?.value).toStrictEqual(['valueB', 'valueA']);
|
||||
});
|
||||
|
||||
it('creates NOT IN for single value when toggling related item with existing IN filter', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onFilterChange = jest.fn();
|
||||
|
||||
mockFieldsValuesAPI({
|
||||
relatedValues: ['relatedValue'],
|
||||
stringValues: ['otherValue'],
|
||||
});
|
||||
|
||||
// Start with IN filter for otherValue (Selected section)
|
||||
// relatedValue is in Related section (checked visually)
|
||||
renderWithFilter(onFilterChange, { op: 'in', value: ['otherValue'] });
|
||||
|
||||
const selectedRow = await screen.findByTestId(
|
||||
'checkbox-value-row-otherValue',
|
||||
);
|
||||
expect(selectedRow).toHaveAttribute('data-state', 'checked');
|
||||
|
||||
const relatedRow = screen.getByTestId('checkbox-value-row-relatedValue');
|
||||
expect(relatedRow).toHaveAttribute('data-state', 'checked');
|
||||
|
||||
// Toggle related item (checked -> should create NOT IN with just this value)
|
||||
await user.click(within(relatedRow).getByRole('checkbox'));
|
||||
|
||||
expect(onFilterChange).toHaveBeenCalledTimes(1);
|
||||
const filter = getFilterFromCall(onFilterChange);
|
||||
expect(filter?.op).toBe('not in');
|
||||
expect(filter?.value).toBe('relatedValue');
|
||||
});
|
||||
});
|
||||
|
||||
describe('custom renderer', () => {
|
||||
it('uses customRendererForValue when provided', async () => {
|
||||
mockFieldsValuesAPI({
|
||||
stringValues: ['production'],
|
||||
});
|
||||
|
||||
const customRenderer = (value: string): JSX.Element => (
|
||||
<span data-testid="custom-rendered">{`ENV: ${value}`}</span>
|
||||
);
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={{ ...DEFAULT_FILTER, customRendererForValue: customRenderer }}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByTestId('custom-rendered');
|
||||
expect(screen.getByText('ENV: production')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,499 @@
|
||||
import { screen, within } from '@testing-library/react';
|
||||
import { render } from 'tests/test-utils';
|
||||
|
||||
import { QuickFiltersSource } from '../../../types';
|
||||
|
||||
import CheckboxFilterV2 from './CheckboxFilterV2';
|
||||
import {
|
||||
DEFAULT_FILTER,
|
||||
DEFAULT_USE_FIELD_APIS,
|
||||
mockFieldsValuesAPI,
|
||||
setupServer,
|
||||
} from './CheckboxFilterV2.testUtils';
|
||||
|
||||
setupServer();
|
||||
|
||||
describe('CheckboxFilterV2 - item rules', () => {
|
||||
describe('no existing query', () => {
|
||||
it('all values show as checked with no badge when no query exists', async () => {
|
||||
mockFieldsValuesAPI({
|
||||
stringValues: ['production', 'staging'],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
|
||||
const productionRow = await screen.findByTestId(
|
||||
'checkbox-value-row-production',
|
||||
);
|
||||
expect(within(productionRow).getByText('production')).toBeInTheDocument();
|
||||
const stagingRow = screen.getByTestId('checkbox-value-row-staging');
|
||||
|
||||
expect(productionRow).toHaveAttribute('data-state', 'checked');
|
||||
expect(stagingRow).toHaveAttribute('data-state', 'checked');
|
||||
|
||||
// No section dividers when no existing query (all in "selected" section)
|
||||
expect(
|
||||
screen.queryByTestId('section-divider-related'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('section-divider-all-values'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('with existing query (related values)', () => {
|
||||
it('shows related values in "Related" section with checked state', async () => {
|
||||
mockFieldsValuesAPI({
|
||||
relatedValues: ['production'],
|
||||
stringValues: ['staging', 'development'],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: 'service.name = "api"',
|
||||
}}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: { items: [], op: 'AND' },
|
||||
filter: { expression: 'service.name = "api"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
|
||||
const productionRow = await screen.findByTestId(
|
||||
'checkbox-value-row-production',
|
||||
);
|
||||
expect(within(productionRow).getByText('production')).toBeInTheDocument();
|
||||
|
||||
// Related values show as checked in "Related" section
|
||||
expect(screen.getByTestId('section-divider-related')).toBeInTheDocument();
|
||||
expect(productionRow).toHaveAttribute('data-state', 'checked');
|
||||
|
||||
// Other values show as unchecked in "All values" section
|
||||
expect(screen.getByTestId('section-divider-all-values')).toBeInTheDocument();
|
||||
const stagingRow = screen.getByTestId('checkbox-value-row-staging');
|
||||
expect(stagingRow).toHaveAttribute('data-state', 'unchecked');
|
||||
});
|
||||
|
||||
it('shows no badge for values not in relatedValues (unchecked state in All values section)', async () => {
|
||||
mockFieldsValuesAPI({
|
||||
relatedValues: ['production'],
|
||||
stringValues: ['staging'],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: 'service.name = "api"',
|
||||
}}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: { items: [], op: 'AND' },
|
||||
filter: { expression: 'service.name = "api"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
|
||||
const stagingRow = await screen.findByTestId('checkbox-value-row-staging');
|
||||
expect(within(stagingRow).getByText('staging')).toBeInTheDocument();
|
||||
expect(stagingRow).toHaveAttribute('data-state', 'unchecked');
|
||||
expect(within(stagingRow).queryByTestId(/^badge-/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows related values as checked when hasFilterForThisKey=true and isInRelatedValues=true', async () => {
|
||||
mockFieldsValuesAPI({
|
||||
relatedValues: ['production', 'staging'],
|
||||
stringValues: ['development'],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: 'service.name = "api"',
|
||||
}}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: { key: 'deployment.environment' },
|
||||
op: 'in',
|
||||
value: ['production'],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
filter: { expression: 'service.name = "api"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
|
||||
// production is selected - in selected section
|
||||
const productionRow = await screen.findByTestId(
|
||||
'checkbox-value-row-production',
|
||||
);
|
||||
expect(productionRow).toHaveAttribute('data-state', 'checked');
|
||||
|
||||
// staging is related - in related section with checked state
|
||||
const stagingRow = screen.getByTestId('checkbox-value-row-staging');
|
||||
expect(stagingRow).toHaveAttribute('data-state', 'checked');
|
||||
expect(screen.getByTestId('section-divider-related')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('selected values with IN operator', () => {
|
||||
it('shows checked state with no badge for IN-selected values', async () => {
|
||||
mockFieldsValuesAPI({
|
||||
stringValues: ['production', 'staging'],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: { key: 'deployment.environment' },
|
||||
op: 'in',
|
||||
value: ['production'],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
|
||||
const productionRow = await screen.findByTestId(
|
||||
'checkbox-value-row-production',
|
||||
);
|
||||
expect(productionRow).toHaveAttribute('data-state', 'checked');
|
||||
expect(
|
||||
within(productionRow).queryByTestId(/^badge-/),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
const stagingRow = screen.getByTestId('checkbox-value-row-staging');
|
||||
expect(stagingRow).toHaveAttribute('data-state', 'unchecked');
|
||||
});
|
||||
});
|
||||
|
||||
describe('selected values with NOT IN operator', () => {
|
||||
it('shows unchecked state with no badge for NOT_IN-selected values', async () => {
|
||||
mockFieldsValuesAPI({
|
||||
stringValues: ['production', 'staging'],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: { key: 'deployment.environment' },
|
||||
op: 'not in',
|
||||
value: ['production'],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
|
||||
const productionRow = await screen.findByTestId(
|
||||
'checkbox-value-row-production',
|
||||
);
|
||||
expect(productionRow).toHaveAttribute('data-state', 'unchecked');
|
||||
expect(
|
||||
within(productionRow).queryByTestId(/^badge-/),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
const stagingRow = screen.getByTestId('checkbox-value-row-staging');
|
||||
expect(stagingRow).toHaveAttribute('data-state', 'unchecked');
|
||||
expect(within(stagingRow).queryByTestId(/^badge-/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('section ordering', () => {
|
||||
it('orders selected section before related section before other section', async () => {
|
||||
mockFieldsValuesAPI({
|
||||
relatedValues: ['related-value'],
|
||||
stringValues: ['other-value', 'selected-value'],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: 'service.name = "api"',
|
||||
}}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: { key: 'deployment.environment' },
|
||||
op: 'in',
|
||||
value: ['selected-value'],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
filter: { expression: 'service.name = "api"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-selected-value');
|
||||
|
||||
const allRows = screen.getAllByTestId(/^checkbox-value-row-/);
|
||||
const values = allRows.map((row) =>
|
||||
row.getAttribute('data-testid')?.replace('checkbox-value-row-', ''),
|
||||
);
|
||||
|
||||
expect(values[0]).toBe('selected-value');
|
||||
expect(values[1]).toBe('related-value');
|
||||
expect(values[2]).toBe('other-value');
|
||||
});
|
||||
|
||||
it('sorts alphabetically within same section', async () => {
|
||||
mockFieldsValuesAPI({
|
||||
relatedValues: ['zebra', 'alpha', 'mike'],
|
||||
stringValues: [],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: 'service.name = "api"',
|
||||
}}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: { items: [], op: 'AND' },
|
||||
filter: { expression: 'service.name = "api"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-alpha');
|
||||
|
||||
const allRows = screen.getAllByTestId(/^checkbox-value-row-/);
|
||||
const values = allRows.map((row) =>
|
||||
row.getAttribute('data-testid')?.replace('checkbox-value-row-', ''),
|
||||
);
|
||||
|
||||
expect(values).toStrictEqual(['alpha', 'mike', 'zebra']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mixed state scenarios', () => {
|
||||
it('handles mixed state: IN-selected + related + other in same list', async () => {
|
||||
mockFieldsValuesAPI({
|
||||
relatedValues: ['related-env'],
|
||||
stringValues: ['other-env', 'selected-env'],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: 'service.name = "api"',
|
||||
}}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: { key: 'deployment.environment' },
|
||||
op: 'in',
|
||||
value: ['selected-env'],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
filter: { expression: 'service.name = "api"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
|
||||
const selectedRow = await screen.findByTestId(
|
||||
'checkbox-value-row-selected-env',
|
||||
);
|
||||
expect(selectedRow).toHaveAttribute('data-state', 'checked');
|
||||
expect(within(selectedRow).queryByTestId(/^badge-/)).not.toBeInTheDocument();
|
||||
|
||||
// Related values now show as checked
|
||||
const relatedRow = screen.getByTestId('checkbox-value-row-related-env');
|
||||
expect(relatedRow).toHaveAttribute('data-state', 'checked');
|
||||
expect(screen.getByTestId('section-divider-related')).toBeInTheDocument();
|
||||
|
||||
const otherRow = screen.getByTestId('checkbox-value-row-other-env');
|
||||
expect(otherRow).toHaveAttribute('data-state', 'unchecked');
|
||||
expect(within(otherRow).queryByTestId(/^badge-/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles NOT_IN-selected alongside related values', async () => {
|
||||
mockFieldsValuesAPI({
|
||||
relatedValues: ['related-env'],
|
||||
stringValues: ['other-env', 'excluded-env'],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: 'service.name = "api"',
|
||||
}}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: [
|
||||
{
|
||||
key: { key: 'deployment.environment' },
|
||||
op: 'not in',
|
||||
value: ['excluded-env'],
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
},
|
||||
filter: { expression: 'service.name = "api"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
|
||||
const excludedRow = await screen.findByTestId(
|
||||
'checkbox-value-row-excluded-env',
|
||||
);
|
||||
expect(excludedRow).toHaveAttribute('data-state', 'unchecked');
|
||||
expect(within(excludedRow).queryByTestId(/^badge-/)).not.toBeInTheDocument();
|
||||
|
||||
// Related values show as checked
|
||||
const relatedRow = screen.getByTestId('checkbox-value-row-related-env');
|
||||
expect(relatedRow).toHaveAttribute('data-state', 'checked');
|
||||
expect(screen.getByTestId('section-divider-related')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
.checkboxFilter {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: var(--spacing-6);
|
||||
gap: var(--spacing-6);
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
.search {
|
||||
--input-background: var(--l2-background);
|
||||
--input-hover-background: var(--l2-background);
|
||||
--input-focus-background: var(--l2-background);
|
||||
--input-border-color: var(--l2-border);
|
||||
--input-focus-border-color: var(--l2-border);
|
||||
}
|
||||
|
||||
.searchSpinner {
|
||||
color: var(--l2-foreground);
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.values {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sectionValues {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.loadingValues {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--spacing-6) 0;
|
||||
}
|
||||
|
||||
.loadingMore {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.noData {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.showMore {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.showMoreText {
|
||||
color: var(--accent-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.goToDocs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 44px;
|
||||
}
|
||||
|
||||
.goToDocsContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
margin-top: var(--spacing-2);
|
||||
}
|
||||
|
||||
.goToDocsMessage {
|
||||
color: var(--l2-foreground);
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.goToDocsButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
cursor: pointer;
|
||||
margin: 0 0 var(--spacing-2);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.goToDocsButtonText {
|
||||
color: var(--bg-robin-400);
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import { screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { server, rest } from 'mocks-server/server';
|
||||
import { render } from 'tests/test-utils';
|
||||
|
||||
import { QuickFiltersSource } from '../../../types';
|
||||
|
||||
import CheckboxFilterV2 from './CheckboxFilterV2';
|
||||
import {
|
||||
DEFAULT_FILTER,
|
||||
DEFAULT_USE_FIELD_APIS,
|
||||
mockFieldsValuesAPI,
|
||||
mockFieldsValuesAPILoading,
|
||||
setupServer,
|
||||
} from './CheckboxFilterV2.testUtils';
|
||||
|
||||
setupServer();
|
||||
|
||||
describe('CheckboxFilterV2 - states', () => {
|
||||
describe('loading states', () => {
|
||||
it('shows skeleton while loading initial data', async () => {
|
||||
mockFieldsValuesAPILoading();
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('checkbox-filter-v2')).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId('checkbox-filter-v2').querySelector('.ant-skeleton'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows skeleton when initially closed filter is opened for the first time', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockFieldsValuesAPILoading();
|
||||
|
||||
const closedFilter = { ...DEFAULT_FILTER, defaultOpen: false };
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={closedFilter}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Filter starts closed - no skeleton, no content
|
||||
expect(
|
||||
screen.getByTestId('checkbox-filter-v2').querySelector('.ant-skeleton'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('checkbox-filter-empty'),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// Click header to open
|
||||
const header = screen.getByTestId('checkbox-filter-header');
|
||||
await user.click(header);
|
||||
|
||||
// Should show skeleton while loading, NOT "No values found"
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId('checkbox-filter-v2').querySelector('.ant-skeleton'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
expect(
|
||||
screen.queryByTestId('checkbox-filter-empty'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows search spinner when fetching after initial load', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
let requestCount = 0;
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v1/fields/values', (req, res, ctx) => {
|
||||
requestCount += 1;
|
||||
if (requestCount === 1) {
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
values: {
|
||||
relatedValues: [],
|
||||
stringValues: ['production', 'staging'],
|
||||
numberValues: [],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
return res(ctx.delay(10000));
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-production');
|
||||
|
||||
const searchInput = screen.getByTestId('checkbox-filter-search');
|
||||
await user.type(searchInput, 'prod');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId('checkbox-filter-search-loading'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('empty states', () => {
|
||||
it('shows "No values found" when API returns empty arrays', async () => {
|
||||
mockFieldsValuesAPI({
|
||||
relatedValues: [],
|
||||
stringValues: [],
|
||||
numberValues: [],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
|
||||
const emptySection = await screen.findByTestId('checkbox-filter-empty');
|
||||
expect(emptySection).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('value rendering', () => {
|
||||
it('renders values from API response', async () => {
|
||||
mockFieldsValuesAPI({
|
||||
stringValues: ['production', 'staging', 'development'],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-production');
|
||||
expect(screen.getByTestId('checkbox-value-row-staging')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('checkbox-value-row-development'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders number values converted to strings', async () => {
|
||||
mockFieldsValuesAPI({
|
||||
numberValues: [200, 404, 500],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
|
||||
const row200 = await screen.findByTestId('checkbox-value-row-200');
|
||||
expect(within(row200).getByText('200')).toBeInTheDocument();
|
||||
expect(
|
||||
within(screen.getByTestId('checkbox-value-row-404')).getByText('404'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(screen.getByTestId('checkbox-value-row-500')).getByText('500'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filters null/undefined values from response', async () => {
|
||||
mockFieldsValuesAPI({
|
||||
stringValues: ['valid', null, '', undefined as unknown as string],
|
||||
});
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={DEFAULT_USE_FIELD_APIS}
|
||||
/>,
|
||||
);
|
||||
|
||||
const validRow = await screen.findByTestId('checkbox-value-row-valid');
|
||||
expect(within(validRow).getByText('valid')).toBeInTheDocument();
|
||||
expect(screen.queryAllByTestId(/^checkbox-value-row-/)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import { render, RenderResult } from 'tests/test-utils';
|
||||
import { server, rest } from 'mocks-server/server';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import {
|
||||
FiltersType,
|
||||
IQuickFiltersConfig,
|
||||
QuickFilterCheckboxUseFieldApis,
|
||||
QuickFiltersSource,
|
||||
} from '../../../types';
|
||||
import CheckboxFilterV2 from './CheckboxFilterV2';
|
||||
|
||||
export const DEFAULT_FILTER: IQuickFiltersConfig = {
|
||||
type: FiltersType.CHECKBOX,
|
||||
title: 'Environment',
|
||||
attributeKey: {
|
||||
key: 'deployment.environment',
|
||||
dataType: DataTypes.String,
|
||||
type: 'tag',
|
||||
},
|
||||
dataSource: DataSource.TRACES,
|
||||
defaultOpen: true,
|
||||
};
|
||||
|
||||
export const DEFAULT_USE_FIELD_APIS: QuickFilterCheckboxUseFieldApis = {
|
||||
startUnixMilli: 1700000000000,
|
||||
endUnixMilli: 1700003600000,
|
||||
existingQuery: null,
|
||||
};
|
||||
|
||||
export function mockFieldsValuesAPI(response: {
|
||||
relatedValues?: (string | null)[];
|
||||
stringValues?: (string | null)[];
|
||||
numberValues?: (number | null)[];
|
||||
}): void {
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v1/fields/values', (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
values: {
|
||||
relatedValues: response.relatedValues ?? [],
|
||||
stringValues: response.stringValues ?? [],
|
||||
numberValues: response.numberValues ?? [],
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function mockFieldsValuesAPILoading(): void {
|
||||
server.use(
|
||||
rest.get('http://localhost/api/v1/fields/values', (_, res, ctx) =>
|
||||
res(ctx.delay(10000)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function setupServer(): void {
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' }));
|
||||
afterEach(() => server.resetHandlers());
|
||||
afterAll(() => server.close());
|
||||
}
|
||||
|
||||
export interface FilterItemConfig {
|
||||
op: string;
|
||||
value: string | string[];
|
||||
}
|
||||
|
||||
export function renderWithFilter(
|
||||
onFilterChange: jest.Mock,
|
||||
filterItem?: FilterItemConfig,
|
||||
): RenderResult {
|
||||
const items: TagFilterItem[] = filterItem
|
||||
? [
|
||||
{
|
||||
key: { key: 'deployment.environment' },
|
||||
op: filterItem.op,
|
||||
value: filterItem.value,
|
||||
} as TagFilterItem,
|
||||
]
|
||||
: [];
|
||||
|
||||
return render(
|
||||
<CheckboxFilterV2
|
||||
filter={DEFAULT_FILTER}
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
useFieldApis={{
|
||||
...DEFAULT_USE_FIELD_APIS,
|
||||
existingQuery: 'service.name = "api"',
|
||||
}}
|
||||
onFilterChange={onFilterChange}
|
||||
/>,
|
||||
undefined,
|
||||
{
|
||||
queryBuilderOverrides: {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: { items, op: 'AND' },
|
||||
filter: { expression: 'service.name = "api"' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function getFilterFromCall(
|
||||
onFilterChange: jest.Mock,
|
||||
callIndex = 0,
|
||||
): TagFilterItem | undefined {
|
||||
const query = onFilterChange.mock.calls[callIndex]?.[0] as Query | undefined;
|
||||
return query?.builder.queryData[0]?.filters?.items?.find(
|
||||
(item) => item.key?.key === 'deployment.environment',
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Skeleton } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { LoaderCircle } from '@signozhq/icons';
|
||||
import {
|
||||
IQuickFiltersConfig,
|
||||
QuickFilterCheckboxUseFieldApis,
|
||||
QuickFiltersSource,
|
||||
} from 'components/QuickFilters/types';
|
||||
import { DEBOUNCE_DELAY } from 'constants/queryBuilderFilterConfig';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import useDebouncedFn from 'hooks/useDebouncedFunction';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { NON_SELECTED_OPERATORS } from '../checkboxFilterQuery';
|
||||
import useActiveQueryIndex from '../useActiveQueryIndex';
|
||||
import useCheckboxDisclosure from '../useCheckboxDisclosure';
|
||||
import useCheckboxFilterActions from '../useCheckboxFilterActions';
|
||||
import useCheckboxFilterState from '../useCheckboxFilterState';
|
||||
import { useFieldValues } from './useFieldValues';
|
||||
import { useExistingQuery } from './useExistingQuery';
|
||||
import { isKeyMatch } from '../utils';
|
||||
|
||||
import { CheckboxFilterV2Header } from './CheckboxFilterV2Header';
|
||||
import { CheckboxFilterV2Section } from './CheckboxFilterV2Section';
|
||||
import { useSectionedValues } from './useSectionedValues';
|
||||
|
||||
import styles from './CheckboxFilterV2.module.scss';
|
||||
|
||||
interface CheckboxFilterV2Props {
|
||||
filter: IQuickFiltersConfig;
|
||||
source: QuickFiltersSource;
|
||||
onFilterChange?: (query: Query) => void;
|
||||
useFieldApis: QuickFilterCheckboxUseFieldApis;
|
||||
}
|
||||
|
||||
export default function CheckboxFilterV2(
|
||||
props: CheckboxFilterV2Props,
|
||||
): JSX.Element {
|
||||
const { source, filter, onFilterChange, useFieldApis } = props;
|
||||
const [searchText, setSearchText] = useState<string>('');
|
||||
const [userToggleState, setUserToggleState] = useState<boolean | null>(null);
|
||||
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
const activeQueryIndex = useActiveQueryIndex(source);
|
||||
|
||||
const {
|
||||
isOpen,
|
||||
isSomeFilterPresentForCurrentAttribute,
|
||||
visibleItemsCount,
|
||||
onToggleOpen,
|
||||
onShowMore,
|
||||
} = useCheckboxDisclosure({ filter, activeQueryIndex });
|
||||
|
||||
// Auto-preserve open state when filter is present
|
||||
useEffect(() => {
|
||||
if (isSomeFilterPresentForCurrentAttribute && userToggleState === null) {
|
||||
setUserToggleState(true);
|
||||
}
|
||||
}, [isSomeFilterPresentForCurrentAttribute, userToggleState]);
|
||||
|
||||
const { existingQuery, hasExistingQuery } = useExistingQuery({
|
||||
useFieldApis,
|
||||
activeQueryIndex,
|
||||
});
|
||||
|
||||
const { relatedValues, allValues, isLoading, isFetching } = useFieldValues({
|
||||
filter,
|
||||
searchText,
|
||||
existingQuery,
|
||||
metricNamespace: useFieldApis.metricNamespace,
|
||||
startUnixMilli: useFieldApis.startUnixMilli,
|
||||
endUnixMilli: useFieldApis.endUnixMilli,
|
||||
enabled: isOpen,
|
||||
});
|
||||
|
||||
// Track if initial load completed (don't show skeleton after first load)
|
||||
// Must track if loading ever started, otherwise hasLoadedOnce gets set
|
||||
// immediately on first render when query is disabled (isLoading=false)
|
||||
const hasLoadedOnce = useRef(false);
|
||||
const wasLoading = useRef(false);
|
||||
if (isLoading) {
|
||||
wasLoading.current = true;
|
||||
}
|
||||
if (!isLoading && wasLoading.current && !hasLoadedOnce.current) {
|
||||
hasLoadedOnce.current = true;
|
||||
}
|
||||
|
||||
// Combine for state derivation
|
||||
const attributeValues = useMemo(() => {
|
||||
const combined = [...relatedValues, ...allValues];
|
||||
return [...new Set(combined)];
|
||||
}, [relatedValues, allValues]);
|
||||
|
||||
const { currentFilterState, isFilterDisabled, isMultipleValuesTrueForTheKey } =
|
||||
useCheckboxFilterState({ filter, attributeValues, activeQueryIndex });
|
||||
|
||||
const { onChange, onClear } = useCheckboxFilterActions({
|
||||
filter,
|
||||
source,
|
||||
attributeValues,
|
||||
activeQueryIndex,
|
||||
onFilterChange,
|
||||
});
|
||||
|
||||
const setSearchTextDebounced = useDebouncedFn((...args) => {
|
||||
setSearchText(args[0] as string);
|
||||
}, DEBOUNCE_DELAY);
|
||||
|
||||
const currentFilterOp = useMemo(() => {
|
||||
const filterSync = currentQuery?.builder.queryData?.[
|
||||
activeQueryIndex
|
||||
]?.filters?.items.find((item) =>
|
||||
isKeyMatch(item.key?.key, filter.attributeKey.key),
|
||||
);
|
||||
return filterSync?.op;
|
||||
}, [
|
||||
currentQuery?.builder.queryData,
|
||||
activeQueryIndex,
|
||||
filter.attributeKey.key,
|
||||
]);
|
||||
|
||||
const isNotInOperator = NON_SELECTED_OPERATORS.includes(currentFilterOp || '');
|
||||
|
||||
const { sections, totalCount } = useSectionedValues({
|
||||
relatedValues,
|
||||
allValues,
|
||||
currentFilterState,
|
||||
isSomeFilterPresentForCurrentAttribute,
|
||||
isNotInOperator,
|
||||
hasExistingQuery,
|
||||
visibleItemsCount,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={styles.checkboxFilter} data-testid="checkbox-filter-v2">
|
||||
<CheckboxFilterV2Header
|
||||
title={filter.title}
|
||||
isOpen={isOpen}
|
||||
showClearAll={!!attributeValues.length}
|
||||
onToggleOpen={onToggleOpen}
|
||||
onClear={onClear}
|
||||
isSomeFilterPresentForCurrentAttribute={
|
||||
isSomeFilterPresentForCurrentAttribute
|
||||
}
|
||||
/>
|
||||
{isOpen && isLoading && !hasLoadedOnce.current && (
|
||||
<section>
|
||||
<Skeleton paragraph={{ rows: 4 }} />
|
||||
</section>
|
||||
)}
|
||||
{isOpen && (!isLoading || hasLoadedOnce.current) && (
|
||||
<>
|
||||
<section className={styles.search}>
|
||||
<Input
|
||||
placeholder="Filter values"
|
||||
onChange={(e): void => setSearchTextDebounced(e.target.value)}
|
||||
disabled={isFilterDisabled}
|
||||
data-testid="checkbox-filter-search"
|
||||
suffix={
|
||||
isFetching ? (
|
||||
<LoaderCircle
|
||||
size={14}
|
||||
className={styles.searchSpinner}
|
||||
data-testid="checkbox-filter-search-loading"
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{(totalCount > 0 || searchText) && (
|
||||
<section className={styles.values}>
|
||||
{sections.map((section, index) => (
|
||||
<CheckboxFilterV2Section
|
||||
key={section.type}
|
||||
section={section}
|
||||
index={index}
|
||||
isFilterDisabled={isFilterDisabled}
|
||||
filter={filter}
|
||||
isSomeFilterPresentForCurrentAttribute={
|
||||
isSomeFilterPresentForCurrentAttribute
|
||||
}
|
||||
isMultipleValuesTrueForTheKey={isMultipleValuesTrueForTheKey}
|
||||
onChange={onChange}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{totalCount === 0 && hasLoadedOnce.current && !isFetching && (
|
||||
<section
|
||||
className={styles.noData}
|
||||
data-testid={
|
||||
searchText
|
||||
? 'checkbox-filter-no-search-results'
|
||||
: 'checkbox-filter-empty'
|
||||
}
|
||||
>
|
||||
<Typography.Text>No values found</Typography.Text>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{visibleItemsCount < totalCount &&
|
||||
!(searchText && (isLoading || isFetching)) && (
|
||||
<section className={styles.showMore}>
|
||||
<Typography.Text
|
||||
className={styles.showMoreText}
|
||||
onClick={onShowMore}
|
||||
data-testid="checkbox-filter-show-more"
|
||||
>
|
||||
Show More...
|
||||
</Typography.Text>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.leftAction {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
}
|
||||
|
||||
.title {
|
||||
color: var(--l2-foreground);
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
letter-spacing: -0.07px;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.rightAction {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 48px;
|
||||
}
|
||||
|
||||
.clearAll {
|
||||
font-size: 12px;
|
||||
color: var(--accent-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { CheckboxFilterV2Header } from './CheckboxFilterV2Header';
|
||||
|
||||
describe('CheckboxFilterV2Header', () => {
|
||||
const defaultProps = {
|
||||
title: 'Environment',
|
||||
isOpen: false,
|
||||
showClearAll: true,
|
||||
isSomeFilterPresentForCurrentAttribute: true,
|
||||
onToggleOpen: jest.fn(),
|
||||
onClear: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('collapsed state', () => {
|
||||
it('renders title', () => {
|
||||
render(<CheckboxFilterV2Header {...defaultProps} isOpen={false} />);
|
||||
|
||||
expect(screen.getByText('Environment')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sets data-state="closed" when collapsed', () => {
|
||||
render(<CheckboxFilterV2Header {...defaultProps} isOpen={false} />);
|
||||
|
||||
const header = screen.getByTestId('checkbox-filter-header');
|
||||
expect(header).toHaveAttribute('data-state', 'closed');
|
||||
});
|
||||
|
||||
it('does not show clear button when collapsed', () => {
|
||||
render(
|
||||
<CheckboxFilterV2Header {...defaultProps} isOpen={false} showClearAll />,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('checkbox-filter-clear-all'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('expanded state', () => {
|
||||
it('sets data-state="open" when expanded', () => {
|
||||
render(<CheckboxFilterV2Header {...defaultProps} isOpen />);
|
||||
|
||||
const header = screen.getByTestId('checkbox-filter-header');
|
||||
expect(header).toHaveAttribute('data-state', 'open');
|
||||
});
|
||||
|
||||
it('shows clear button when expanded + showClearAll=true', () => {
|
||||
render(<CheckboxFilterV2Header {...defaultProps} isOpen showClearAll />);
|
||||
|
||||
expect(screen.getByTestId('checkbox-filter-clear-all')).toBeInTheDocument();
|
||||
expect(screen.getByText('Clear')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides clear button when showClearAll=false', () => {
|
||||
render(
|
||||
<CheckboxFilterV2Header {...defaultProps} isOpen showClearAll={false} />,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('checkbox-filter-clear-all'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides clear button when no filter present for attribute', () => {
|
||||
render(
|
||||
<CheckboxFilterV2Header
|
||||
{...defaultProps}
|
||||
isOpen
|
||||
showClearAll
|
||||
isSomeFilterPresentForCurrentAttribute={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('checkbox-filter-clear-all'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('interactions', () => {
|
||||
it('calls onToggleOpen on header click', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onToggleOpen = jest.fn();
|
||||
render(
|
||||
<CheckboxFilterV2Header {...defaultProps} onToggleOpen={onToggleOpen} />,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('checkbox-filter-header'));
|
||||
|
||||
expect(onToggleOpen).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls onToggleOpen on Enter key', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onToggleOpen = jest.fn();
|
||||
render(
|
||||
<CheckboxFilterV2Header {...defaultProps} onToggleOpen={onToggleOpen} />,
|
||||
);
|
||||
|
||||
screen.getByTestId('checkbox-filter-header').focus();
|
||||
await user.keyboard('{Enter}');
|
||||
|
||||
expect(onToggleOpen).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls onToggleOpen on Space key', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onToggleOpen = jest.fn();
|
||||
render(
|
||||
<CheckboxFilterV2Header {...defaultProps} onToggleOpen={onToggleOpen} />,
|
||||
);
|
||||
|
||||
screen.getByTestId('checkbox-filter-header').focus();
|
||||
await user.keyboard(' ');
|
||||
|
||||
expect(onToggleOpen).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls onClear on clear button click', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClear = jest.fn();
|
||||
render(
|
||||
<CheckboxFilterV2Header {...defaultProps} isOpen onClear={onClear} />,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('checkbox-filter-clear-all'));
|
||||
|
||||
expect(onClear).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('clear button click does not trigger onToggleOpen', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onToggleOpen = jest.fn();
|
||||
const onClear = jest.fn();
|
||||
render(
|
||||
<CheckboxFilterV2Header
|
||||
{...defaultProps}
|
||||
isOpen
|
||||
onToggleOpen={onToggleOpen}
|
||||
onClear={onClear}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('checkbox-filter-clear-all'));
|
||||
|
||||
expect(onClear).toHaveBeenCalledTimes(1);
|
||||
expect(onToggleOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { ChevronDown, ChevronRight } from '@signozhq/icons';
|
||||
|
||||
import styles from './CheckboxFilterV2Header.module.scss';
|
||||
|
||||
interface CheckboxFilterHeaderProps {
|
||||
title: string;
|
||||
isOpen: boolean;
|
||||
showClearAll: boolean;
|
||||
onToggleOpen: () => void;
|
||||
onClear: () => void;
|
||||
isSomeFilterPresentForCurrentAttribute: boolean;
|
||||
}
|
||||
|
||||
export function CheckboxFilterV2Header({
|
||||
title,
|
||||
isOpen,
|
||||
showClearAll,
|
||||
onToggleOpen,
|
||||
onClear,
|
||||
isSomeFilterPresentForCurrentAttribute,
|
||||
}: CheckboxFilterHeaderProps): JSX.Element {
|
||||
return (
|
||||
<section
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={styles.header}
|
||||
onClick={onToggleOpen}
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
onToggleOpen();
|
||||
}
|
||||
}}
|
||||
data-testid="checkbox-filter-header"
|
||||
data-state={isOpen ? 'open' : 'closed'}
|
||||
>
|
||||
<section className={styles.leftAction}>
|
||||
{isOpen ? (
|
||||
<ChevronDown size={13} cursor="pointer" />
|
||||
) : (
|
||||
<ChevronRight size={13} cursor="pointer" />
|
||||
)}
|
||||
<Typography.Text className={styles.title}>{title}</Typography.Text>
|
||||
</section>
|
||||
<section className={styles.rightAction}>
|
||||
{isOpen && showClearAll && isSomeFilterPresentForCurrentAttribute && (
|
||||
<Typography.Text
|
||||
className={styles.clearAll}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
onClear();
|
||||
}}
|
||||
data-testid="checkbox-filter-clear-all"
|
||||
>
|
||||
Clear
|
||||
</Typography.Text>
|
||||
)}
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import {
|
||||
IQuickFiltersConfig,
|
||||
CheckedState,
|
||||
} from 'components/QuickFilters/types';
|
||||
|
||||
import { CheckboxFilterV2ValueRow } from './CheckboxFilterV2ValueRow';
|
||||
import { SectionDivider } from './SectionDivider';
|
||||
import { Section } from './useSectionedValues';
|
||||
import { SectionType } from './itemRules';
|
||||
|
||||
import styles from './CheckboxFilterV2.module.scss';
|
||||
|
||||
interface SectionConfig {
|
||||
label: string;
|
||||
tooltip?: string;
|
||||
}
|
||||
|
||||
function getSectionConfig(type: SectionType): SectionConfig | null {
|
||||
switch (type) {
|
||||
case SectionType.SELECTED:
|
||||
return { label: 'Selected' };
|
||||
case SectionType.RELATED:
|
||||
return {
|
||||
label: 'Related',
|
||||
tooltip: 'Values that are filtered by your current selection.',
|
||||
};
|
||||
case SectionType.ALL_VALUES:
|
||||
return { label: 'All values' };
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
interface CheckboxFilterV2SectionProps {
|
||||
section: Section;
|
||||
index: number;
|
||||
isFilterDisabled: boolean;
|
||||
filter: IQuickFiltersConfig;
|
||||
isSomeFilterPresentForCurrentAttribute: boolean;
|
||||
isMultipleValuesTrueForTheKey: boolean;
|
||||
onChange: (
|
||||
value: string,
|
||||
checked: boolean,
|
||||
isOnly: boolean,
|
||||
previousState?: CheckedState,
|
||||
sectionType?: SectionType,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export function CheckboxFilterV2Section(
|
||||
props: CheckboxFilterV2SectionProps,
|
||||
): JSX.Element | null {
|
||||
const {
|
||||
section,
|
||||
index,
|
||||
isFilterDisabled,
|
||||
filter,
|
||||
isSomeFilterPresentForCurrentAttribute,
|
||||
isMultipleValuesTrueForTheKey,
|
||||
onChange,
|
||||
} = props;
|
||||
|
||||
if (section.items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const config = getSectionConfig(section.type);
|
||||
|
||||
// Show divider for all sections except first SELECTED section
|
||||
const showDivider =
|
||||
config !== null && (index > 0 || section.type !== SectionType.SELECTED);
|
||||
|
||||
return (
|
||||
<div data-testid={`section-${section.type}`} className={styles.sectionValues}>
|
||||
{showDivider && config && (
|
||||
<SectionDivider label={config.label} tooltip={config.tooltip} />
|
||||
)}
|
||||
{section.items.map(({ value, badge, checkedState }) => {
|
||||
const isChecked = checkedState === 'checked';
|
||||
|
||||
return (
|
||||
<CheckboxFilterV2ValueRow
|
||||
key={value}
|
||||
value={value}
|
||||
checkedState={checkedState}
|
||||
disabled={isFilterDisabled}
|
||||
title={filter.title}
|
||||
badge={badge}
|
||||
onlyButtonLabel={
|
||||
isSomeFilterPresentForCurrentAttribute
|
||||
? isChecked && !isMultipleValuesTrueForTheKey
|
||||
? 'All'
|
||||
: 'Only'
|
||||
: 'Only'
|
||||
}
|
||||
customRendererForValue={filter.customRendererForValue}
|
||||
onCheckboxChange={(checked, previousState): void =>
|
||||
onChange(value, checked, false, previousState, section.type)
|
||||
}
|
||||
onOnlyOrAllClick={(): void => onChange(value, isChecked, true)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
.valueRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.valueButton {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
width: calc(100% - 24px);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.valueLabel {
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
justify-items: end;
|
||||
|
||||
// Stack badge / only / toggle in a single cell so the crossfade overlaps
|
||||
// instead of laying them side-by-side mid-transition.
|
||||
> * {
|
||||
grid-area: 1 / 1;
|
||||
}
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
opacity: 1;
|
||||
transition:
|
||||
opacity 0.16s ease,
|
||||
display 0.16s allow-discrete;
|
||||
}
|
||||
|
||||
.onlyButton {
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transform: translateX(4px);
|
||||
transition:
|
||||
opacity 0.16s ease,
|
||||
transform 0.16s ease,
|
||||
display 0.16s allow-discrete;
|
||||
--button-height: 21px;
|
||||
--button-padding: var(--spacing-5);
|
||||
|
||||
&:hover {
|
||||
background-color: unset;
|
||||
}
|
||||
}
|
||||
|
||||
.toggleButton {
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transform: translateX(4px);
|
||||
transition:
|
||||
opacity 0.16s ease,
|
||||
transform 0.16s ease,
|
||||
display 0.16s allow-discrete;
|
||||
--button-height: 21px;
|
||||
--button-padding: var(--spacing-5);
|
||||
|
||||
&:hover {
|
||||
background-color: unset;
|
||||
}
|
||||
}
|
||||
|
||||
.isDisabled {
|
||||
cursor: not-allowed;
|
||||
|
||||
.valueLabel {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.onlyButton {
|
||||
cursor: not-allowed;
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.toggleButton {
|
||||
cursor: not-allowed;
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.valueButton:hover {
|
||||
.onlyButton {
|
||||
display: flex;
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
|
||||
@starting-style {
|
||||
opacity: 0;
|
||||
transform: translateX(4px);
|
||||
}
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: none;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.checkbox:hover ~ .valueButton {
|
||||
.toggleButton {
|
||||
display: flex;
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
|
||||
@starting-style {
|
||||
opacity: 0;
|
||||
transform: translateX(4px);
|
||||
}
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: none;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.badge,
|
||||
.onlyButton,
|
||||
.toggleButton {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.indicatorFalse {
|
||||
width: 2px;
|
||||
height: 11px;
|
||||
border-radius: 2px;
|
||||
background: var(--danger-background);
|
||||
}
|
||||
|
||||
.indicatorTrue {
|
||||
width: 2px;
|
||||
height: 11px;
|
||||
border-radius: 2px;
|
||||
background: var(--bg-forest-500);
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { BadgeConfig } from './itemRules';
|
||||
import { CheckedState } from '../../../types';
|
||||
import { CheckboxFilterV2ValueRow } from './CheckboxFilterV2ValueRow';
|
||||
|
||||
describe('CheckboxFilterV2ValueRow', () => {
|
||||
const defaultProps = {
|
||||
value: 'production',
|
||||
checkedState: 'unchecked' as CheckedState,
|
||||
disabled: false,
|
||||
title: 'Environment',
|
||||
onlyButtonLabel: 'Only',
|
||||
onCheckboxChange: jest.fn(),
|
||||
onOnlyOrAllClick: jest.fn(),
|
||||
badge: null as BadgeConfig | null,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('checked states', () => {
|
||||
it('sets data-state="unchecked" for unchecked state', () => {
|
||||
render(
|
||||
<CheckboxFilterV2ValueRow {...defaultProps} checkedState="unchecked" />,
|
||||
);
|
||||
|
||||
const row = screen.getByTestId('checkbox-value-row-production');
|
||||
expect(row).toHaveAttribute('data-state', 'unchecked');
|
||||
});
|
||||
|
||||
it('sets data-state="checked" for checked state', () => {
|
||||
render(
|
||||
<CheckboxFilterV2ValueRow {...defaultProps} checkedState="checked" />,
|
||||
);
|
||||
|
||||
const row = screen.getByTestId('checkbox-value-row-production');
|
||||
expect(row).toHaveAttribute('data-state', 'checked');
|
||||
});
|
||||
});
|
||||
|
||||
describe('badge variations', () => {
|
||||
it('renders no badge when badge=null', () => {
|
||||
render(<CheckboxFilterV2ValueRow {...defaultProps} badge={null} />);
|
||||
|
||||
expect(screen.queryByTestId(/^badge-/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders "Not in" warning badge', () => {
|
||||
render(
|
||||
<CheckboxFilterV2ValueRow
|
||||
{...defaultProps}
|
||||
badge={{ key: 'not_in', label: 'Not in', color: 'warning' }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('badge-not_in')).toBeInTheDocument();
|
||||
expect(screen.getByText('Not in')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders "Related" robin badge', () => {
|
||||
render(
|
||||
<CheckboxFilterV2ValueRow
|
||||
{...defaultProps}
|
||||
badge={{ key: 'related', label: 'Related', color: 'robin' }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('badge-related')).toBeInTheDocument();
|
||||
expect(screen.getByText('Related')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders "Other" secondary badge', () => {
|
||||
render(
|
||||
<CheckboxFilterV2ValueRow
|
||||
{...defaultProps}
|
||||
badge={{ key: 'other', label: 'Other', color: 'secondary' }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('badge-other')).toBeInTheDocument();
|
||||
expect(screen.getByText('Other')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('only/all button label', () => {
|
||||
it('shows "Only" label by default', () => {
|
||||
render(
|
||||
<CheckboxFilterV2ValueRow {...defaultProps} onlyButtonLabel="Only" />,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Only')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "All" label when appropriate', () => {
|
||||
render(<CheckboxFilterV2ValueRow {...defaultProps} onlyButtonLabel="All" />);
|
||||
|
||||
expect(screen.getByText('All')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('disabled state', () => {
|
||||
it('sets data-disabled=true when disabled', () => {
|
||||
render(<CheckboxFilterV2ValueRow {...defaultProps} disabled />);
|
||||
|
||||
const row = screen.getByTestId('checkbox-value-row-production');
|
||||
expect(row).toHaveAttribute('data-disabled', 'true');
|
||||
});
|
||||
|
||||
it('does not call onOnlyOrAllClick when disabled + clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onOnlyOrAllClick = jest.fn();
|
||||
render(
|
||||
<CheckboxFilterV2ValueRow
|
||||
{...defaultProps}
|
||||
disabled
|
||||
onOnlyOrAllClick={onOnlyOrAllClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByText('production'));
|
||||
|
||||
expect(onOnlyOrAllClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not call onOnlyOrAllClick on keydown when disabled', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onOnlyOrAllClick = jest.fn();
|
||||
render(
|
||||
<CheckboxFilterV2ValueRow
|
||||
{...defaultProps}
|
||||
disabled
|
||||
onOnlyOrAllClick={onOnlyOrAllClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
screen.getByText('production').focus();
|
||||
await user.keyboard('{Enter}');
|
||||
|
||||
expect(onOnlyOrAllClick).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('special value indicators', () => {
|
||||
it('renders row for "true" value', () => {
|
||||
render(<CheckboxFilterV2ValueRow {...defaultProps} value="true" />);
|
||||
|
||||
expect(screen.getByTestId('checkbox-value-row-true')).toBeInTheDocument();
|
||||
expect(screen.getByText('true')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders row for "false" value', () => {
|
||||
render(<CheckboxFilterV2ValueRow {...defaultProps} value="false" />);
|
||||
|
||||
expect(screen.getByTestId('checkbox-value-row-false')).toBeInTheDocument();
|
||||
expect(screen.getByText('false')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders row for regular values', () => {
|
||||
render(<CheckboxFilterV2ValueRow {...defaultProps} value="production" />);
|
||||
|
||||
expect(
|
||||
screen.getByTestId('checkbox-value-row-production'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('production')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('interactions', () => {
|
||||
it('renders checkbox with correct testId', () => {
|
||||
render(
|
||||
<CheckboxFilterV2ValueRow {...defaultProps} checkedState="unchecked" />,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByTestId('checkbox-Environment-production'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onOnlyOrAllClick on value text click', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onOnlyOrAllClick = jest.fn();
|
||||
render(
|
||||
<CheckboxFilterV2ValueRow
|
||||
{...defaultProps}
|
||||
onOnlyOrAllClick={onOnlyOrAllClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByText('production'));
|
||||
|
||||
expect(onOnlyOrAllClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls onOnlyOrAllClick on Enter key', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onOnlyOrAllClick = jest.fn();
|
||||
render(
|
||||
<CheckboxFilterV2ValueRow
|
||||
{...defaultProps}
|
||||
onOnlyOrAllClick={onOnlyOrAllClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
const valueButton = screen
|
||||
.getByText('production')
|
||||
.closest('[role="button"]');
|
||||
await user.tab();
|
||||
await user.tab();
|
||||
if (valueButton && document.activeElement === valueButton) {
|
||||
await user.keyboard('{Enter}');
|
||||
}
|
||||
|
||||
expect(onOnlyOrAllClick).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onOnlyOrAllClick on Space key', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onOnlyOrAllClick = jest.fn();
|
||||
render(
|
||||
<CheckboxFilterV2ValueRow
|
||||
{...defaultProps}
|
||||
onOnlyOrAllClick={onOnlyOrAllClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
const valueButton = screen
|
||||
.getByText('production')
|
||||
.closest('[role="button"]');
|
||||
await user.tab();
|
||||
await user.tab();
|
||||
if (valueButton && document.activeElement === valueButton) {
|
||||
await user.keyboard(' ');
|
||||
}
|
||||
|
||||
expect(onOnlyOrAllClick).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows Toggle button', () => {
|
||||
render(<CheckboxFilterV2ValueRow {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText('Toggle')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('custom renderer', () => {
|
||||
it('uses customRendererForValue when provided', () => {
|
||||
const customRenderer = (value: string): JSX.Element => (
|
||||
<span data-testid="custom-render">{`Custom: ${value}`}</span>
|
||||
);
|
||||
|
||||
render(
|
||||
<CheckboxFilterV2ValueRow
|
||||
{...defaultProps}
|
||||
customRendererForValue={customRenderer}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('custom-render')).toBeInTheDocument();
|
||||
expect(screen.getByText('Custom: production')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows default value text when no custom renderer', () => {
|
||||
render(<CheckboxFilterV2ValueRow {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText('production')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('state combinations', () => {
|
||||
it('checked + not_in badge', () => {
|
||||
render(
|
||||
<CheckboxFilterV2ValueRow
|
||||
{...defaultProps}
|
||||
checkedState="unchecked"
|
||||
badge={{ key: 'not_in', label: 'Not in', color: 'warning' }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('badge-not_in')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disabled + badge still shows badge', () => {
|
||||
render(
|
||||
<CheckboxFilterV2ValueRow
|
||||
{...defaultProps}
|
||||
disabled
|
||||
badge={{ key: 'other', label: 'Other', color: 'secondary' }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('badge-other')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Checkbox } from '@signozhq/ui/checkbox';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
|
||||
import { BadgeConfig } from './itemRules';
|
||||
import { CheckedState } from '../../../types';
|
||||
import styles from './CheckboxFilterV2ValueRow.module.scss';
|
||||
|
||||
interface ValueRowProps {
|
||||
value: string;
|
||||
checkedState: CheckedState;
|
||||
disabled: boolean;
|
||||
title: string;
|
||||
onlyButtonLabel: string;
|
||||
customRendererForValue?: (value: string) => JSX.Element;
|
||||
onCheckboxChange: (checked: boolean, previousState: CheckedState) => void;
|
||||
onOnlyOrAllClick: () => void;
|
||||
badge: BadgeConfig | null;
|
||||
}
|
||||
|
||||
function toCheckboxValue(state: CheckedState): boolean | 'indeterminate' {
|
||||
if (state === 'indeterminate') {
|
||||
return 'indeterminate';
|
||||
}
|
||||
return state === 'checked';
|
||||
}
|
||||
|
||||
const INDICATOR_CLASS_MAP = {
|
||||
false: styles.indicatorFalse,
|
||||
true: styles.indicatorTrue,
|
||||
} as Record<string, string>;
|
||||
|
||||
export function CheckboxFilterV2ValueRow({
|
||||
value,
|
||||
checkedState,
|
||||
disabled,
|
||||
title,
|
||||
onlyButtonLabel,
|
||||
customRendererForValue,
|
||||
onCheckboxChange,
|
||||
onOnlyOrAllClick,
|
||||
badge,
|
||||
}: ValueRowProps): JSX.Element {
|
||||
const indicatorClass = INDICATOR_CLASS_MAP[value];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.valueRow}
|
||||
data-testid={`checkbox-value-row-${value}`}
|
||||
data-state={checkedState}
|
||||
data-disabled={disabled}
|
||||
>
|
||||
<div className={styles.checkbox}>
|
||||
<Checkbox
|
||||
onChange={(isChecked): void =>
|
||||
onCheckboxChange(isChecked === true, checkedState)
|
||||
}
|
||||
value={toCheckboxValue(checkedState)}
|
||||
disabled={disabled}
|
||||
color="primary"
|
||||
testId={`checkbox-${title}-${value}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
className={cx(styles.valueButton, disabled && styles.isDisabled)}
|
||||
onClick={(): void => {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
onOnlyOrAllClick();
|
||||
}}
|
||||
onKeyDown={(e): void => {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
onOnlyOrAllClick();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className={styles.content}>
|
||||
{indicatorClass && <div className={indicatorClass} />}
|
||||
{customRendererForValue ? (
|
||||
customRendererForValue(value)
|
||||
) : (
|
||||
<Typography.Text title={value} className={styles.valueLabel}>
|
||||
{value}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.actions}>
|
||||
{badge && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
color={badge.color}
|
||||
className={styles.badge}
|
||||
testId={`badge-${badge.key}`}
|
||||
>
|
||||
{badge.label}
|
||||
</Badge>
|
||||
)}
|
||||
<Button variant="ghost" color="secondary" className={styles.onlyButton}>
|
||||
{onlyButtonLabel}
|
||||
</Button>
|
||||
<Button variant="ghost" color="secondary" className={styles.toggleButton}>
|
||||
Toggle
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
.divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-2) 0;
|
||||
|
||||
margin-bottom: calc(-1 * var(--spacing-4));
|
||||
}
|
||||
|
||||
.line {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--l1-border);
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: var(--periscope-font-size-small);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--l3-foreground);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.infoIcon {
|
||||
color: var(--l3-foreground);
|
||||
cursor: help;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Tooltip } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Info } from '@signozhq/icons';
|
||||
|
||||
import styles from './SectionDivider.module.scss';
|
||||
|
||||
interface SectionDividerProps {
|
||||
label: string;
|
||||
tooltip?: string;
|
||||
}
|
||||
|
||||
export function SectionDivider({
|
||||
label,
|
||||
tooltip,
|
||||
}: SectionDividerProps): JSX.Element {
|
||||
const testId = label.toLowerCase().replace(/\s+/g, '-');
|
||||
return (
|
||||
<div className={styles.divider} data-testid={`section-divider-${testId}`}>
|
||||
<div className={styles.line} />
|
||||
<Typography.Text className={styles.label}>{label}</Typography.Text>
|
||||
{tooltip && (
|
||||
<Tooltip title={tooltip}>
|
||||
<Info
|
||||
size={12}
|
||||
className={styles.infoIcon}
|
||||
data-testid="section-divider-info"
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
<div className={styles.line} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { deriveItemConfig, ItemContext, SectionType } from './itemRules';
|
||||
|
||||
describe('itemRules', () => {
|
||||
describe('deriveItemConfig', () => {
|
||||
it('no query at all → section selected, no badge', () => {
|
||||
const ctx: ItemContext = {
|
||||
isSelectedOnFilter: false,
|
||||
isInRelatedValues: true,
|
||||
isNotInOperator: false,
|
||||
hasExistingQuery: false,
|
||||
hasFilterForThisKey: false,
|
||||
};
|
||||
|
||||
const result = deriveItemConfig(ctx);
|
||||
|
||||
expect(result.section).toBe(SectionType.SELECTED);
|
||||
expect(result.badge).toBeNull();
|
||||
});
|
||||
|
||||
it('selected + IN operator → section selected, no badge', () => {
|
||||
const ctx: ItemContext = {
|
||||
isSelectedOnFilter: true,
|
||||
isInRelatedValues: true,
|
||||
isNotInOperator: false,
|
||||
hasExistingQuery: true,
|
||||
hasFilterForThisKey: true,
|
||||
};
|
||||
|
||||
const result = deriveItemConfig(ctx);
|
||||
|
||||
expect(result.section).toBe(SectionType.SELECTED);
|
||||
expect(result.badge).toBeNull();
|
||||
});
|
||||
|
||||
it('selected + NOT IN operator → section selected, no badge, unchecked', () => {
|
||||
const ctx: ItemContext = {
|
||||
isSelectedOnFilter: true,
|
||||
isInRelatedValues: false,
|
||||
isNotInOperator: true,
|
||||
hasExistingQuery: true,
|
||||
hasFilterForThisKey: true,
|
||||
};
|
||||
|
||||
const result = deriveItemConfig(ctx);
|
||||
|
||||
expect(result.section).toBe(SectionType.SELECTED);
|
||||
expect(result.badge).toBeNull();
|
||||
expect(result.checkedState).toBe('unchecked');
|
||||
});
|
||||
|
||||
it('has query, not selected, in related → section related, checked', () => {
|
||||
const ctx: ItemContext = {
|
||||
isSelectedOnFilter: false,
|
||||
isInRelatedValues: true,
|
||||
isNotInOperator: false,
|
||||
hasExistingQuery: true,
|
||||
hasFilterForThisKey: false,
|
||||
};
|
||||
|
||||
const result = deriveItemConfig(ctx);
|
||||
|
||||
expect(result.section).toBe(SectionType.RELATED);
|
||||
expect(result.badge).toBeNull();
|
||||
expect(result.checkedState).toBe('checked');
|
||||
});
|
||||
|
||||
it('has query, has filter for this key, in related → section related, checked', () => {
|
||||
const ctx: ItemContext = {
|
||||
isSelectedOnFilter: false,
|
||||
isInRelatedValues: true,
|
||||
isNotInOperator: false,
|
||||
hasExistingQuery: true,
|
||||
hasFilterForThisKey: true,
|
||||
};
|
||||
|
||||
const result = deriveItemConfig(ctx);
|
||||
|
||||
expect(result.section).toBe(SectionType.RELATED);
|
||||
expect(result.badge).toBeNull();
|
||||
expect(result.checkedState).toBe('checked');
|
||||
});
|
||||
|
||||
it('has query, not in related → section all_values, unchecked', () => {
|
||||
const ctx: ItemContext = {
|
||||
isSelectedOnFilter: false,
|
||||
isInRelatedValues: false,
|
||||
isNotInOperator: false,
|
||||
hasExistingQuery: true,
|
||||
hasFilterForThisKey: false,
|
||||
};
|
||||
|
||||
const result = deriveItemConfig(ctx);
|
||||
|
||||
expect(result.section).toBe(SectionType.ALL_VALUES);
|
||||
expect(result.badge).toBeNull();
|
||||
expect(result.checkedState).toBe('unchecked');
|
||||
});
|
||||
|
||||
it('has query + filter for key, not selected, not in related → section all_values, unchecked', () => {
|
||||
const ctx: ItemContext = {
|
||||
isSelectedOnFilter: false,
|
||||
isInRelatedValues: false,
|
||||
isNotInOperator: false,
|
||||
hasExistingQuery: true,
|
||||
hasFilterForThisKey: true,
|
||||
};
|
||||
|
||||
const result = deriveItemConfig(ctx);
|
||||
|
||||
expect(result.section).toBe(SectionType.ALL_VALUES);
|
||||
expect(result.badge).toBeNull();
|
||||
expect(result.checkedState).toBe('unchecked');
|
||||
});
|
||||
|
||||
it('no query but has filter for key, not selected → fallback to checked (DEFAULT_CONFIG)', () => {
|
||||
const ctx: ItemContext = {
|
||||
isSelectedOnFilter: false,
|
||||
isInRelatedValues: false,
|
||||
isNotInOperator: false,
|
||||
hasExistingQuery: false,
|
||||
hasFilterForThisKey: true,
|
||||
};
|
||||
|
||||
const result = deriveItemConfig(ctx);
|
||||
|
||||
expect(result.section).toBe(SectionType.SELECTED);
|
||||
expect(result.badge).toBeNull();
|
||||
expect(result.checkedState).toBe('checked');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import { CheckedState } from '../../../types';
|
||||
|
||||
export enum SectionType {
|
||||
SELECTED = 'selected',
|
||||
RELATED = 'related',
|
||||
ALL_VALUES = 'all_values',
|
||||
}
|
||||
|
||||
export interface BadgeConfig {
|
||||
key: string;
|
||||
label: string;
|
||||
color: 'robin' | 'warning' | 'secondary';
|
||||
}
|
||||
|
||||
export interface ItemConfig {
|
||||
section: SectionType;
|
||||
badge: BadgeConfig | null;
|
||||
checkedState: CheckedState;
|
||||
}
|
||||
|
||||
export interface ItemContext {
|
||||
isSelectedOnFilter: boolean;
|
||||
isInRelatedValues: boolean;
|
||||
isNotInOperator: boolean;
|
||||
hasExistingQuery: boolean;
|
||||
hasFilterForThisKey: boolean;
|
||||
}
|
||||
|
||||
export interface DerivedItem extends ItemConfig {
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface ItemRule {
|
||||
condition: (ctx: ItemContext) => boolean;
|
||||
config: ItemConfig;
|
||||
}
|
||||
|
||||
const ITEM_RULES: ItemRule[] = [
|
||||
// No existing query and no filter → all checked (selected section)
|
||||
{
|
||||
condition: (ctx): boolean =>
|
||||
!ctx.hasExistingQuery && !ctx.hasFilterForThisKey,
|
||||
config: {
|
||||
section: SectionType.SELECTED,
|
||||
badge: null,
|
||||
checkedState: 'checked',
|
||||
},
|
||||
},
|
||||
// Selected with NOT IN operator → unchecked, no badge
|
||||
{
|
||||
condition: (ctx): boolean => ctx.isSelectedOnFilter && ctx.isNotInOperator,
|
||||
config: {
|
||||
section: SectionType.SELECTED,
|
||||
badge: null,
|
||||
checkedState: 'unchecked',
|
||||
},
|
||||
},
|
||||
// Selected with IN operator → checked
|
||||
{
|
||||
condition: (ctx): boolean => ctx.isSelectedOnFilter && !ctx.isNotInOperator,
|
||||
config: {
|
||||
section: SectionType.SELECTED,
|
||||
badge: null,
|
||||
checkedState: 'checked',
|
||||
},
|
||||
},
|
||||
// Related values (from existing query) → checked
|
||||
{
|
||||
condition: (ctx): boolean => ctx.hasExistingQuery && ctx.isInRelatedValues,
|
||||
config: {
|
||||
section: SectionType.RELATED,
|
||||
badge: null,
|
||||
checkedState: 'checked',
|
||||
},
|
||||
},
|
||||
// All values (has existing query but not related) → unchecked
|
||||
{
|
||||
condition: (ctx): boolean => ctx.hasExistingQuery,
|
||||
config: {
|
||||
section: SectionType.ALL_VALUES,
|
||||
badge: null,
|
||||
checkedState: 'unchecked',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Fallback when no rule matches
|
||||
const DEFAULT_CONFIG: ItemConfig = {
|
||||
section: SectionType.SELECTED,
|
||||
badge: null,
|
||||
checkedState: 'checked',
|
||||
};
|
||||
|
||||
export function deriveItemConfig(ctx: ItemContext): ItemConfig {
|
||||
for (const rule of ITEM_RULES) {
|
||||
if (rule.condition(ctx)) {
|
||||
return rule.config;
|
||||
}
|
||||
}
|
||||
return DEFAULT_CONFIG;
|
||||
}
|
||||
|
||||
export function deriveItems(
|
||||
values: string[],
|
||||
relatedSet: Set<string>,
|
||||
selectedOnFilterSet: Set<string>,
|
||||
ctx: Omit<ItemContext, 'isSelectedOnFilter' | 'isInRelatedValues'>,
|
||||
): DerivedItem[] {
|
||||
return values.map((value) => {
|
||||
const itemCtx: ItemContext = {
|
||||
...ctx,
|
||||
isSelectedOnFilter: selectedOnFilterSet.has(value),
|
||||
isInRelatedValues: relatedSet.has(value),
|
||||
};
|
||||
const config = deriveItemConfig(itemCtx);
|
||||
return { value, ...config };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { convertFiltersToExpression } from 'components/QueryBuilderV2/utils';
|
||||
import { QuickFilterCheckboxUseFieldApis } from 'components/QuickFilters/types';
|
||||
|
||||
interface UseExistingQueryParams {
|
||||
useFieldApis: QuickFilterCheckboxUseFieldApis;
|
||||
activeQueryIndex: number;
|
||||
}
|
||||
|
||||
interface UseExistingQueryResult {
|
||||
existingQuery: string | undefined;
|
||||
hasExistingQuery: boolean;
|
||||
}
|
||||
|
||||
export function useExistingQuery({
|
||||
useFieldApis,
|
||||
activeQueryIndex,
|
||||
}: UseExistingQueryParams): UseExistingQueryResult {
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
|
||||
const existingQuery = useMemo(() => {
|
||||
if (useFieldApis.existingQuery === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (useFieldApis.existingQuery) {
|
||||
return useFieldApis.existingQuery;
|
||||
}
|
||||
|
||||
const queryData = currentQuery.builder.queryData?.[activeQueryIndex];
|
||||
|
||||
// Prefer V5 filter.expression
|
||||
if (queryData?.filter?.expression) {
|
||||
return queryData.filter.expression;
|
||||
}
|
||||
|
||||
// Fall back to V3 filters.items
|
||||
if (queryData?.filters?.items?.length) {
|
||||
return convertFiltersToExpression(queryData.filters).expression;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}, [
|
||||
useFieldApis.existingQuery,
|
||||
currentQuery.builder.queryData,
|
||||
activeQueryIndex,
|
||||
]);
|
||||
|
||||
// Check if ANY filters exist in query (V3 items or V5 expression)
|
||||
// This is separate from existingQuery because existingQuery can be explicitly
|
||||
// disabled (null) while filters still exist in the query for UI purposes
|
||||
const hasExistingQuery = useMemo(() => {
|
||||
const queryData = currentQuery.builder.queryData?.[activeQueryIndex];
|
||||
const hasV3Items = (queryData?.filters?.items?.length ?? 0) > 0;
|
||||
const hasV5Expression = !!queryData?.filter?.expression;
|
||||
return hasV3Items || hasV5Expression || !!existingQuery;
|
||||
}, [currentQuery.builder.queryData, activeQueryIndex, existingQuery]);
|
||||
|
||||
return { existingQuery, hasExistingQuery };
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useGetFieldsValues } from 'api/generated/services/fields';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { IQuickFiltersConfig } from 'components/QuickFilters/types';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { FIELD_API_CACHE_TIME } from 'constants/queryCacheTime';
|
||||
|
||||
interface UseFieldValuesProps {
|
||||
filter: IQuickFiltersConfig;
|
||||
searchText: string;
|
||||
existingQuery?: string;
|
||||
metricNamespace?: string;
|
||||
startUnixMilli?: number;
|
||||
endUnixMilli?: number;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
interface UseFieldValuesReturn {
|
||||
relatedValues: string[];
|
||||
allValues: string[];
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
}
|
||||
|
||||
const DATA_SOURCE_TO_SIGNAL: Record<DataSource, TelemetrytypesSignalDTO> = {
|
||||
[DataSource.METRICS]: TelemetrytypesSignalDTO.metrics,
|
||||
[DataSource.TRACES]: TelemetrytypesSignalDTO.traces,
|
||||
[DataSource.LOGS]: TelemetrytypesSignalDTO.logs,
|
||||
};
|
||||
|
||||
export function useFieldValues({
|
||||
filter,
|
||||
searchText,
|
||||
existingQuery,
|
||||
metricNamespace,
|
||||
startUnixMilli,
|
||||
endUnixMilli,
|
||||
enabled,
|
||||
}: UseFieldValuesProps): UseFieldValuesReturn {
|
||||
const { data, isLoading, isFetching } = useGetFieldsValues(
|
||||
{
|
||||
signal: filter.dataSource
|
||||
? DATA_SOURCE_TO_SIGNAL[filter.dataSource]
|
||||
: undefined,
|
||||
name: filter.attributeKey.key,
|
||||
searchText,
|
||||
existingQuery,
|
||||
metricNamespace,
|
||||
startUnixMilli,
|
||||
// This field does not affect the backend but I wanted to keep it here
|
||||
// in case we add the support in the future
|
||||
endUnixMilli,
|
||||
},
|
||||
{
|
||||
query: {
|
||||
enabled,
|
||||
cacheTime: FIELD_API_CACHE_TIME,
|
||||
keepPreviousData: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const relatedValues: string[] = useMemo(() => {
|
||||
const values = data?.data?.values;
|
||||
if (!values) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return (
|
||||
values.relatedValues?.filter(
|
||||
(value): value is string =>
|
||||
value !== null && value !== undefined && value !== '',
|
||||
) || []
|
||||
);
|
||||
}, [data]);
|
||||
|
||||
const allValues: string[] = useMemo(() => {
|
||||
const values = data?.data?.values;
|
||||
if (!values) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const stringValues =
|
||||
values.stringValues?.filter(
|
||||
(value): value is string =>
|
||||
value !== null && value !== undefined && value !== '',
|
||||
) || [];
|
||||
const numberValues =
|
||||
values.numberValues
|
||||
?.filter((value): value is number => value !== null && value !== undefined)
|
||||
.map((value) => value.toString()) || [];
|
||||
|
||||
return [...stringValues, ...numberValues];
|
||||
}, [data]);
|
||||
|
||||
return { relatedValues, allValues, isLoading, isFetching };
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
|
||||
import { SectionType } from './itemRules';
|
||||
import { useSectionedValues, SectionedItem } from './useSectionedValues';
|
||||
|
||||
function flattenSections(
|
||||
sections: { type: SectionType; items: SectionedItem[] }[],
|
||||
): SectionedItem[] {
|
||||
return sections.flatMap((s) => s.items);
|
||||
}
|
||||
|
||||
describe('useSectionedValues', () => {
|
||||
const baseInput = {
|
||||
relatedValues: ['val1', 'val2'],
|
||||
allValues: ['val1', 'val2', 'val3'],
|
||||
currentFilterState: {},
|
||||
isSomeFilterPresentForCurrentAttribute: false,
|
||||
isNotInOperator: false,
|
||||
hasExistingQuery: false,
|
||||
visibleItemsCount: 10,
|
||||
};
|
||||
|
||||
it('no query at all → all items in selected section, no badges', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useSectionedValues({
|
||||
...baseInput,
|
||||
hasExistingQuery: false,
|
||||
isSomeFilterPresentForCurrentAttribute: false,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.sections).toHaveLength(1);
|
||||
expect(result.current.sections[0].type).toBe(SectionType.SELECTED);
|
||||
expect(result.current.sections[0].items).toHaveLength(3);
|
||||
result.current.sections[0].items.forEach((item) => {
|
||||
expect(item.badge).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('has query, no filter for key → related and all_values sections', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useSectionedValues({
|
||||
...baseInput,
|
||||
hasExistingQuery: true,
|
||||
isSomeFilterPresentForCurrentAttribute: false,
|
||||
}),
|
||||
);
|
||||
|
||||
const allItems = flattenSections(result.current.sections);
|
||||
const relatedItems = allItems.filter(
|
||||
(item) => item.value === 'val1' || item.value === 'val2',
|
||||
);
|
||||
const otherItems = allItems.filter((item) => item.value === 'val3');
|
||||
|
||||
// Related values should be in related section, checked
|
||||
relatedItems.forEach((item) => {
|
||||
expect(item.section).toBe(SectionType.RELATED);
|
||||
expect(item.checkedState).toBe('checked');
|
||||
});
|
||||
|
||||
// Other values should be in all_values section, unchecked
|
||||
otherItems.forEach((item) => {
|
||||
expect(item.section).toBe(SectionType.ALL_VALUES);
|
||||
expect(item.checkedState).toBe('unchecked');
|
||||
});
|
||||
});
|
||||
|
||||
it('has query + filter for key, selected value → in selected section', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useSectionedValues({
|
||||
...baseInput,
|
||||
hasExistingQuery: true,
|
||||
isSomeFilterPresentForCurrentAttribute: true,
|
||||
currentFilterState: { val1: true, val2: false, val3: false },
|
||||
}),
|
||||
);
|
||||
|
||||
const allItems = flattenSections(result.current.sections);
|
||||
const selectedItem = allItems.find((item) => item.value === 'val1');
|
||||
|
||||
expect(selectedItem?.section).toBe(SectionType.SELECTED);
|
||||
expect(selectedItem?.badge).toBeNull();
|
||||
});
|
||||
|
||||
it('has query + filter for key, NOT IN operator → excluded values in selected section, no badge', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useSectionedValues({
|
||||
...baseInput,
|
||||
hasExistingQuery: true,
|
||||
isSomeFilterPresentForCurrentAttribute: true,
|
||||
isNotInOperator: true,
|
||||
currentFilterState: { val1: false, val2: true, val3: true },
|
||||
}),
|
||||
);
|
||||
|
||||
const allItems = flattenSections(result.current.sections);
|
||||
// val1 is unchecked + NOT IN = excluded
|
||||
const excludedItem = allItems.find((item) => item.value === 'val1');
|
||||
|
||||
expect(excludedItem?.section).toBe(SectionType.SELECTED);
|
||||
expect(excludedItem?.badge).toBeNull();
|
||||
expect(excludedItem?.checkedState).toBe('unchecked');
|
||||
});
|
||||
|
||||
it('items within same section sorted alphabetically', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useSectionedValues({
|
||||
...baseInput,
|
||||
relatedValues: ['zebra', 'apple', 'mango'],
|
||||
allValues: ['zebra', 'apple', 'mango'],
|
||||
hasExistingQuery: false,
|
||||
isSomeFilterPresentForCurrentAttribute: false,
|
||||
}),
|
||||
);
|
||||
|
||||
// All items have section selected, should be sorted alphabetically
|
||||
const allItems = flattenSections(result.current.sections);
|
||||
const values = allItems.map((item) => item.value);
|
||||
expect(values).toStrictEqual(['apple', 'mango', 'zebra']);
|
||||
});
|
||||
|
||||
describe('filtered results from API', () => {
|
||||
it('keeps items in their natural sections with filtered API results', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useSectionedValues({
|
||||
...baseInput,
|
||||
hasExistingQuery: true,
|
||||
isSomeFilterPresentForCurrentAttribute: true,
|
||||
currentFilterState: { val1: true },
|
||||
}),
|
||||
);
|
||||
|
||||
const sectionTypes = result.current.sections.map((s) => s.type);
|
||||
expect(sectionTypes).toContain(SectionType.SELECTED);
|
||||
expect(sectionTypes).toContain(SectionType.RELATED);
|
||||
});
|
||||
|
||||
it('returns empty sections array when no values', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useSectionedValues({
|
||||
...baseInput,
|
||||
relatedValues: [],
|
||||
allValues: [],
|
||||
hasExistingQuery: true,
|
||||
isSomeFilterPresentForCurrentAttribute: false,
|
||||
currentFilterState: {},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.sections).toHaveLength(0);
|
||||
expect(result.current.totalCount).toBe(0);
|
||||
});
|
||||
|
||||
it('non-related items go to ALL_VALUES section', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useSectionedValues({
|
||||
...baseInput,
|
||||
relatedValues: [],
|
||||
allValues: ['other1', 'other2', 'other3'],
|
||||
hasExistingQuery: true,
|
||||
isSomeFilterPresentForCurrentAttribute: false,
|
||||
}),
|
||||
);
|
||||
|
||||
const allValuesSection = result.current.sections.find(
|
||||
(s) => s.type === SectionType.ALL_VALUES,
|
||||
);
|
||||
expect(allValuesSection?.items).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('handles non-overlapping relatedValues and allValues correctly', () => {
|
||||
// This tests the bug where different pod names in relatedValues vs allValues
|
||||
// caused all items to go to ALL_VALUES instead of RELATED
|
||||
const { result } = renderHook(() =>
|
||||
useSectionedValues({
|
||||
...baseInput,
|
||||
relatedValues: ['pod-a-1', 'pod-b-1', 'pod-c-1'],
|
||||
allValues: ['pod-a-2', 'pod-b-2', 'pod-c-2'],
|
||||
hasExistingQuery: true,
|
||||
isSomeFilterPresentForCurrentAttribute: false,
|
||||
}),
|
||||
);
|
||||
|
||||
const sectionTypes = result.current.sections.map((s) => s.type);
|
||||
|
||||
// RELATED section should exist with relatedValues items
|
||||
expect(sectionTypes).toContain(SectionType.RELATED);
|
||||
const relatedSection = result.current.sections.find(
|
||||
(s) => s.type === SectionType.RELATED,
|
||||
);
|
||||
expect(relatedSection?.items).toHaveLength(3);
|
||||
expect(relatedSection?.items.map((i) => i.value)).toStrictEqual(
|
||||
expect.arrayContaining(['pod-a-1', 'pod-b-1', 'pod-c-1']),
|
||||
);
|
||||
|
||||
// ALL_VALUES section should exist with allValues items
|
||||
expect(sectionTypes).toContain(SectionType.ALL_VALUES);
|
||||
const allValuesSection = result.current.sections.find(
|
||||
(s) => s.type === SectionType.ALL_VALUES,
|
||||
);
|
||||
expect(allValuesSection?.items).toHaveLength(3);
|
||||
expect(allValuesSection?.items.map((i) => i.value)).toStrictEqual(
|
||||
expect.arrayContaining(['pod-a-2', 'pod-b-2', 'pod-c-2']),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('section ordering', () => {
|
||||
it('sections appear in order: SELECTED → RELATED → ALL_VALUES', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useSectionedValues({
|
||||
...baseInput,
|
||||
relatedValues: ['related1'],
|
||||
allValues: ['all1'],
|
||||
hasExistingQuery: true,
|
||||
isSomeFilterPresentForCurrentAttribute: true,
|
||||
currentFilterState: { selected1: true },
|
||||
}),
|
||||
);
|
||||
|
||||
const sectionTypes = result.current.sections.map((s) => s.type);
|
||||
|
||||
// Verify order
|
||||
const selectedIdx = sectionTypes.indexOf(SectionType.SELECTED);
|
||||
const relatedIdx = sectionTypes.indexOf(SectionType.RELATED);
|
||||
const allValuesIdx = sectionTypes.indexOf(SectionType.ALL_VALUES);
|
||||
|
||||
expect(selectedIdx).toBeLessThan(relatedIdx);
|
||||
expect(relatedIdx).toBeLessThan(allValuesIdx);
|
||||
});
|
||||
|
||||
it('RELATED section appears before ALL_VALUES even with many items', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useSectionedValues({
|
||||
...baseInput,
|
||||
relatedValues: ['r1', 'r2', 'r3', 'r4', 'r5'],
|
||||
allValues: ['a1', 'a2', 'a3', 'a4', 'a5'],
|
||||
hasExistingQuery: true,
|
||||
isSomeFilterPresentForCurrentAttribute: false,
|
||||
visibleItemsCount: 100,
|
||||
}),
|
||||
);
|
||||
|
||||
const sectionTypes = result.current.sections.map((s) => s.type);
|
||||
|
||||
expect(sectionTypes[0]).toBe(SectionType.RELATED);
|
||||
expect(sectionTypes[1]).toBe(SectionType.ALL_VALUES);
|
||||
});
|
||||
|
||||
it('visibleItemsCount limits total items across sections', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useSectionedValues({
|
||||
...baseInput,
|
||||
relatedValues: ['r1', 'r2', 'r3'],
|
||||
allValues: ['a1', 'a2', 'a3'],
|
||||
hasExistingQuery: true,
|
||||
isSomeFilterPresentForCurrentAttribute: false,
|
||||
visibleItemsCount: 4,
|
||||
}),
|
||||
);
|
||||
|
||||
const totalItems = result.current.sections.reduce(
|
||||
(sum, s) => sum + s.items.length,
|
||||
0,
|
||||
);
|
||||
expect(totalItems).toBe(4);
|
||||
|
||||
// RELATED section should get priority (3 items)
|
||||
const relatedSection = result.current.sections.find(
|
||||
(s) => s.type === SectionType.RELATED,
|
||||
);
|
||||
expect(relatedSection?.items).toHaveLength(3);
|
||||
|
||||
// ALL_VALUES gets remaining (1 item)
|
||||
const allValuesSection = result.current.sections.find(
|
||||
(s) => s.type === SectionType.ALL_VALUES,
|
||||
);
|
||||
expect(allValuesSection?.items).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { BadgeConfig, deriveItems, SectionType } from './itemRules';
|
||||
import { CheckedState } from '../../../types';
|
||||
|
||||
interface SectionedValuesInput {
|
||||
relatedValues: string[];
|
||||
allValues: string[];
|
||||
currentFilterState: Record<string, boolean>;
|
||||
isSomeFilterPresentForCurrentAttribute: boolean;
|
||||
isNotInOperator: boolean;
|
||||
hasExistingQuery: boolean;
|
||||
visibleItemsCount: number;
|
||||
}
|
||||
|
||||
export interface SectionedItem {
|
||||
value: string;
|
||||
section: SectionType;
|
||||
badge: BadgeConfig | null;
|
||||
checkedState: CheckedState;
|
||||
}
|
||||
|
||||
export interface Section {
|
||||
type: SectionType;
|
||||
items: SectionedItem[];
|
||||
}
|
||||
|
||||
interface SectionedValuesOutput {
|
||||
sections: Section[];
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
const SECTION_ORDER: SectionType[] = [
|
||||
SectionType.SELECTED,
|
||||
SectionType.RELATED,
|
||||
SectionType.ALL_VALUES,
|
||||
];
|
||||
|
||||
function buildSelectedSet(
|
||||
currentFilterState: Record<string, boolean>,
|
||||
isSomeFilterPresentForCurrentAttribute: boolean,
|
||||
isNotInOperator: boolean,
|
||||
): Set<string> {
|
||||
const selectedSet = new Set<string>();
|
||||
if (!isSomeFilterPresentForCurrentAttribute) {
|
||||
return selectedSet;
|
||||
}
|
||||
|
||||
for (const [val, isChecked] of Object.entries(currentFilterState)) {
|
||||
// NOT IN: unchecked = explicitly excluded
|
||||
// IN: checked = explicitly selected
|
||||
const shouldAdd = isNotInOperator ? !isChecked : isChecked;
|
||||
if (shouldAdd) {
|
||||
selectedSet.add(val);
|
||||
}
|
||||
}
|
||||
return selectedSet;
|
||||
}
|
||||
|
||||
export function useSectionedValues({
|
||||
relatedValues,
|
||||
allValues,
|
||||
currentFilterState,
|
||||
isSomeFilterPresentForCurrentAttribute,
|
||||
isNotInOperator,
|
||||
hasExistingQuery,
|
||||
visibleItemsCount,
|
||||
}: SectionedValuesInput): SectionedValuesOutput {
|
||||
const items = useMemo(() => {
|
||||
const selectedSet = buildSelectedSet(
|
||||
currentFilterState,
|
||||
isSomeFilterPresentForCurrentAttribute,
|
||||
isNotInOperator,
|
||||
);
|
||||
|
||||
// Combine all values - API already filters both arrays by searchText
|
||||
const allUniqueValues = Array.from(new Set([...relatedValues, ...allValues]));
|
||||
|
||||
// Include selected values at top - may not be in API response
|
||||
const finalValues = [
|
||||
...new Set([...Array.from(selectedSet), ...allUniqueValues]),
|
||||
];
|
||||
const relatedSet = new Set(relatedValues);
|
||||
|
||||
return deriveItems(finalValues, relatedSet, selectedSet, {
|
||||
isNotInOperator,
|
||||
hasExistingQuery,
|
||||
hasFilterForThisKey: isSomeFilterPresentForCurrentAttribute,
|
||||
});
|
||||
}, [
|
||||
relatedValues,
|
||||
allValues,
|
||||
currentFilterState,
|
||||
isSomeFilterPresentForCurrentAttribute,
|
||||
isNotInOperator,
|
||||
hasExistingQuery,
|
||||
]);
|
||||
|
||||
const sections = useMemo(() => {
|
||||
// Group items by section
|
||||
const sectionMap = new Map<SectionType, SectionedItem[]>();
|
||||
for (const sectionType of SECTION_ORDER) {
|
||||
sectionMap.set(sectionType, []);
|
||||
}
|
||||
|
||||
for (const item of items) {
|
||||
sectionMap.get(item.section)?.push(item);
|
||||
}
|
||||
|
||||
// Sort items within each section alphabetically
|
||||
for (const sectionItems of sectionMap.values()) {
|
||||
sectionItems.sort((a, b) => a.value.localeCompare(b.value));
|
||||
}
|
||||
|
||||
// Apply visibleItemsCount across all sections
|
||||
let remaining = visibleItemsCount;
|
||||
const result: Section[] = [];
|
||||
|
||||
for (const sectionType of SECTION_ORDER) {
|
||||
const sectionItems = sectionMap.get(sectionType) || [];
|
||||
|
||||
if (sectionItems.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const itemsToTake = Math.min(sectionItems.length, remaining);
|
||||
if (itemsToTake === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
result.push({
|
||||
type: sectionType,
|
||||
items: sectionItems.slice(0, itemsToTake),
|
||||
});
|
||||
remaining -= itemsToTake;
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [items, visibleItemsCount]);
|
||||
|
||||
return { sections, totalCount: items.length };
|
||||
}
|
||||
@@ -32,6 +32,7 @@ import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
|
||||
import Checkbox from './FilterRenderers/Checkbox/Checkbox';
|
||||
import CheckboxV2 from './FilterRenderers/Checkbox/v2/CheckboxFilterV2';
|
||||
import Duration from './FilterRenderers/Duration/Duration';
|
||||
import Slider from './FilterRenderers/Slider/Slider';
|
||||
import useFilterConfig from './hooks/useFilterConfig';
|
||||
@@ -51,6 +52,7 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
|
||||
signal,
|
||||
showFilterCollapse = true,
|
||||
showQueryName = true,
|
||||
useFieldApis,
|
||||
} = props;
|
||||
const { user } = useAppContext();
|
||||
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
||||
@@ -297,21 +299,45 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
|
||||
{filterConfig.map((filter) => {
|
||||
switch (filter.type) {
|
||||
case FiltersType.CHECKBOX:
|
||||
return (
|
||||
return useFieldApis ? (
|
||||
<CheckboxV2
|
||||
key={filter.attributeKey.key}
|
||||
source={source}
|
||||
filter={filter}
|
||||
onFilterChange={onFilterChange}
|
||||
useFieldApis={useFieldApis}
|
||||
/>
|
||||
) : (
|
||||
<Checkbox
|
||||
key={filter.attributeKey.key}
|
||||
source={source}
|
||||
filter={filter}
|
||||
onFilterChange={onFilterChange}
|
||||
/>
|
||||
);
|
||||
case FiltersType.DURATION:
|
||||
return <Duration filter={filter} onFilterChange={onFilterChange} />;
|
||||
return (
|
||||
<Duration
|
||||
key={filter.attributeKey.key}
|
||||
filter={filter}
|
||||
onFilterChange={onFilterChange}
|
||||
/>
|
||||
);
|
||||
case FiltersType.SLIDER:
|
||||
return <Slider />;
|
||||
return <Slider key={filter.attributeKey.key} />;
|
||||
// eslint-disable-next-line sonarjs/no-duplicated-branches
|
||||
default:
|
||||
return (
|
||||
return useFieldApis ? (
|
||||
<CheckboxV2
|
||||
key={filter.attributeKey.key}
|
||||
source={source}
|
||||
filter={filter}
|
||||
onFilterChange={onFilterChange}
|
||||
useFieldApis={useFieldApis}
|
||||
/>
|
||||
) : (
|
||||
<Checkbox
|
||||
key={filter.attributeKey.key}
|
||||
source={source}
|
||||
filter={filter}
|
||||
onFilterChange={onFilterChange}
|
||||
@@ -381,4 +407,5 @@ QuickFilters.defaultProps = {
|
||||
config: [],
|
||||
showFilterCollapse: true,
|
||||
showQueryName: true,
|
||||
useFieldApis: undefined,
|
||||
};
|
||||
|
||||
@@ -26,6 +26,11 @@ export enum SignalType {
|
||||
METER_EXPLORER = 'meter',
|
||||
}
|
||||
|
||||
/**
|
||||
* Missing export from signozhq/ui/checkbox, TODO(H4ad): Add and remove this type definition
|
||||
*/
|
||||
export type CheckedState = 'checked' | 'unchecked' | 'indeterminate';
|
||||
|
||||
export interface IQuickFiltersConfig {
|
||||
type: FiltersType;
|
||||
title: string;
|
||||
@@ -46,6 +51,7 @@ export interface IQuickFiltersProps {
|
||||
className?: string;
|
||||
showFilterCollapse?: boolean;
|
||||
showQueryName?: boolean;
|
||||
useFieldApis?: QuickFilterCheckboxUseFieldApis;
|
||||
}
|
||||
|
||||
export enum QuickFiltersSource {
|
||||
@@ -56,3 +62,19 @@ export enum QuickFiltersSource {
|
||||
EXCEPTIONS = 'exceptions',
|
||||
METER_EXPLORER = 'meter',
|
||||
}
|
||||
|
||||
/**
|
||||
* Opt-in: fetch values from the /v1/fields/values API instead of /v3/autocomplete/attribute_values
|
||||
*/
|
||||
export type QuickFilterCheckboxUseFieldApis = {
|
||||
startUnixMilli: number;
|
||||
endUnixMilli: number;
|
||||
/**
|
||||
* If you didn't specify a string, we automatically try to extract this from the currentQuery,
|
||||
* from the filter.expression or filter.items.
|
||||
*
|
||||
* Use null to ignore/disable this behavior.
|
||||
*/
|
||||
existingQuery?: string | null;
|
||||
metricNamespace?: string;
|
||||
};
|
||||
|
||||
@@ -4,11 +4,11 @@ import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
|
||||
import { DatePicker } from 'antd';
|
||||
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import {
|
||||
APIKeyCreatePermission,
|
||||
buildSAAttachPermission,
|
||||
} from 'hooks/useAuthZ/permissions/service-account.permissions';
|
||||
} from 'lib/authz/hooks/useAuthZ/permissions/service-account.permissions';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import { disabledDate } from '../utils';
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { Trash2, X } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
|
||||
import { buildSADeletePermission } from 'hooks/useAuthZ/permissions/service-account.permissions';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import { buildSADeletePermission } from 'lib/authz/hooks/useAuthZ/permissions/service-account.permissions';
|
||||
import { DialogWrapper } from '@signozhq/ui/dialog';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
|
||||
@@ -7,12 +7,12 @@ import { Input } from '@signozhq/ui/input';
|
||||
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
|
||||
import { DatePicker } from 'antd';
|
||||
import type { ServiceaccounttypesGettableFactorAPIKeyDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import {
|
||||
buildAPIKeyDeletePermission,
|
||||
buildAPIKeyUpdatePermission,
|
||||
buildSADetachPermission,
|
||||
} from 'hooks/useAuthZ/permissions/service-account.permissions';
|
||||
} from 'lib/authz/hooks/useAuthZ/permissions/service-account.permissions';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import { disabledDate, formatLastObservedAt } from '../utils';
|
||||
|
||||
@@ -16,8 +16,8 @@ import type {
|
||||
import { AxiosError } from 'axios';
|
||||
import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants';
|
||||
import dayjs from 'dayjs';
|
||||
import { buildAPIKeyUpdatePermission } from 'hooks/useAuthZ/permissions/service-account.permissions';
|
||||
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
|
||||
import { buildAPIKeyUpdatePermission } from 'lib/authz/hooks/useAuthZ/permissions/service-account.permissions';
|
||||
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
import { parseAsString, useQueryState } from 'nuqs';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
|
||||
@@ -4,13 +4,13 @@ import { Button } from '@signozhq/ui/button';
|
||||
import { Skeleton, Table, Tooltip } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table/interface';
|
||||
import type { ServiceaccounttypesGettableFactorAPIKeyDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import {
|
||||
APIKeyCreatePermission,
|
||||
buildAPIKeyDeletePermission,
|
||||
buildSAAttachPermission,
|
||||
buildSADetachPermission,
|
||||
} from 'hooks/useAuthZ/permissions/service-account.permissions';
|
||||
} from 'lib/authz/hooks/useAuthZ/permissions/service-account.permissions';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import dayjs from 'dayjs';
|
||||
import { parseAsBoolean, parseAsString, useQueryState } from 'nuqs';
|
||||
|
||||
@@ -5,11 +5,11 @@ import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import type { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import RolesSelect from 'components/RolesSelect';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { ServiceAccountRow } from 'container/ServiceAccountsSettings/utils';
|
||||
import { buildSAUpdatePermission } from 'hooks/useAuthZ/permissions/service-account.permissions';
|
||||
import { buildSAUpdatePermission } from 'lib/authz/hooks/useAuthZ/permissions/service-account.permissions';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { Trash2, X } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import {
|
||||
buildAPIKeyDeletePermission,
|
||||
buildSADetachPermission,
|
||||
} from 'hooks/useAuthZ/permissions/service-account.permissions';
|
||||
} from 'lib/authz/hooks/useAuthZ/permissions/service-account.permissions';
|
||||
import { DialogWrapper } from '@signozhq/ui/dialog';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
import type { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { AxiosError } from 'axios';
|
||||
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
|
||||
import PermissionDeniedCallout from 'components/PermissionDeniedCallout/PermissionDeniedCallout';
|
||||
import PermissionDeniedCallout from 'lib/authz/components/PermissionDeniedCallout/PermissionDeniedCallout';
|
||||
import { useRoles } from 'components/RolesSelect';
|
||||
import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants';
|
||||
import {
|
||||
@@ -35,8 +35,8 @@ import {
|
||||
buildSADeletePermission,
|
||||
buildSAReadPermission,
|
||||
buildSAUpdatePermission,
|
||||
} from 'hooks/useAuthZ/permissions/service-account.permissions';
|
||||
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
|
||||
} from 'lib/authz/hooks/useAuthZ/permissions/service-account.permissions';
|
||||
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
import {
|
||||
parseAsBoolean,
|
||||
parseAsInteger,
|
||||
@@ -47,7 +47,7 @@ import {
|
||||
import APIError from 'types/api/error';
|
||||
import { toAPIError } from 'utils/errorUtils';
|
||||
|
||||
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import AddKeyModal from './AddKeyModal';
|
||||
import DeleteAccountModal from './DeleteAccountModal';
|
||||
import KeysTab from './KeysTab';
|
||||
|
||||
@@ -6,7 +6,7 @@ import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import EditKeyModal from '../EditKeyModal';
|
||||
|
||||
jest.mock('components/AuthZTooltip/AuthZTooltip', () => ({
|
||||
jest.mock('lib/authz/components/AuthZTooltip/AuthZTooltip', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
children,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import KeysTab from '../KeysTab';
|
||||
|
||||
jest.mock('components/AuthZTooltip/AuthZTooltip', () => ({
|
||||
jest.mock('lib/authz/components/AuthZTooltip/AuthZTooltip', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
children,
|
||||
|
||||
@@ -7,11 +7,11 @@ import {
|
||||
setupAuthzAdmin,
|
||||
setupAuthzDeny,
|
||||
setupAuthzDenyAll,
|
||||
} from 'tests/authz-test-utils';
|
||||
} from 'lib/authz/utils/authz-test-utils';
|
||||
import {
|
||||
APIKeyListPermission,
|
||||
buildSADeletePermission,
|
||||
} from 'hooks/useAuthZ/permissions/service-account.permissions';
|
||||
} from 'lib/authz/hooks/useAuthZ/permissions/service-account.permissions';
|
||||
|
||||
import ServiceAccountDrawer from '../ServiceAccountDrawer';
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { listRolesSuccessResponse } from 'mocks-server/__mockdata__/roles';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import { setupAuthzAdmin } from 'tests/authz-test-utils';
|
||||
import { setupAuthzAdmin } from 'lib/authz/utils/authz-test-utils';
|
||||
|
||||
import ServiceAccountDrawer from '../ServiceAccountDrawer';
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useMemo } from 'react';
|
||||
import { SolidAlertTriangle } from '@signozhq/icons';
|
||||
import { Select, Tooltip } from 'antd';
|
||||
import type { DefaultOptionType } from 'antd/es/select';
|
||||
import classNames from 'classnames';
|
||||
import cx from 'classnames';
|
||||
|
||||
import { UniversalYAxisUnitMappings } from './constants';
|
||||
import { UniversalYAxisUnit, YAxisUnitSelectorProps } from './types';
|
||||
@@ -72,9 +72,7 @@ function YAxisUnitSelector({
|
||||
}, [categoriesOverride, source]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames('y-axis-unit-selector-component', containerClassName)}
|
||||
>
|
||||
<div className={cx('y-axis-unit-selector-component', containerClassName)}>
|
||||
<Select
|
||||
showSearch
|
||||
value={universalUnit}
|
||||
@@ -84,12 +82,17 @@ function YAxisUnitSelector({
|
||||
loading={loading}
|
||||
suffixIcon={
|
||||
incompatibleUnitMessage ? (
|
||||
<Tooltip title={incompatibleUnitMessage}>
|
||||
<SolidAlertTriangle role="img" aria-label="warning" size="md" />
|
||||
<Tooltip
|
||||
title={incompatibleUnitMessage}
|
||||
overlayClassName="y-axis-unit-warning-tooltip"
|
||||
>
|
||||
<span className="y-axis-unit-warning" role="img" aria-label="warning">
|
||||
<SolidAlertTriangle size="md" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : undefined
|
||||
}
|
||||
className={classNames({
|
||||
className={cx({
|
||||
'warning-state': incompatibleUnitMessage,
|
||||
})}
|
||||
data-testid={dataTestId}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { YAxisCategoryNames } from '../constants';
|
||||
import { UniversalYAxisUnit, YAxisSource } from '../types';
|
||||
@@ -6,9 +7,13 @@ import YAxisUnitSelector from '../YAxisUnitSelector';
|
||||
|
||||
describe('YAxisUnitSelector', () => {
|
||||
const mockOnChange = jest.fn();
|
||||
// antd injects its `pointer-events` styles via cssinjs in jsdom, but the SCSS
|
||||
// overrides aren't loaded — skip the pointer-events check so hovers/clicks register.
|
||||
let user: ReturnType<typeof userEvent.setup>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockOnChange.mockClear();
|
||||
user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
});
|
||||
|
||||
it('renders with default placeholder', () => {
|
||||
@@ -34,7 +39,7 @@ describe('YAxisUnitSelector', () => {
|
||||
expect(screen.queryByText('Custom placeholder')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onChange when a value is selected', () => {
|
||||
it('calls onChange when a value is selected', async () => {
|
||||
render(
|
||||
<YAxisUnitSelector
|
||||
value=""
|
||||
@@ -44,9 +49,8 @@ describe('YAxisUnitSelector', () => {
|
||||
);
|
||||
const select = screen.getByRole('combobox');
|
||||
|
||||
fireEvent.mouseDown(select);
|
||||
const option = screen.getByText('Bytes (B)');
|
||||
fireEvent.click(option);
|
||||
await user.click(select);
|
||||
await user.click(screen.getByText('Bytes (B)'));
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith('By', {
|
||||
children: 'Bytes (B)',
|
||||
@@ -55,7 +59,7 @@ describe('YAxisUnitSelector', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('filters options based on search input', () => {
|
||||
it('filters options based on search input', async () => {
|
||||
render(
|
||||
<YAxisUnitSelector
|
||||
value=""
|
||||
@@ -65,14 +69,13 @@ describe('YAxisUnitSelector', () => {
|
||||
);
|
||||
const select = screen.getByRole('combobox');
|
||||
|
||||
fireEvent.mouseDown(select);
|
||||
const input = screen.getByRole('combobox');
|
||||
fireEvent.change(input, { target: { value: 'bytes/sec' } });
|
||||
await user.click(select);
|
||||
await user.type(select, 'bytes/sec');
|
||||
|
||||
expect(screen.getByText('Bytes/sec')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows all categories and their units', () => {
|
||||
it('shows all categories and their units', async () => {
|
||||
render(
|
||||
<YAxisUnitSelector
|
||||
value=""
|
||||
@@ -80,9 +83,8 @@ describe('YAxisUnitSelector', () => {
|
||||
source={YAxisSource.ALERTS}
|
||||
/>,
|
||||
);
|
||||
const select = screen.getByRole('combobox');
|
||||
|
||||
fireEvent.mouseDown(select);
|
||||
await user.click(screen.getByRole('combobox'));
|
||||
|
||||
// Check for category headers
|
||||
expect(screen.getByText('Data')).toBeInTheDocument();
|
||||
@@ -93,7 +95,7 @@ describe('YAxisUnitSelector', () => {
|
||||
expect(screen.getByText('Seconds (s)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows warning message when incompatible unit is selected', () => {
|
||||
it('shows warning message when incompatible unit is selected', async () => {
|
||||
render(
|
||||
<YAxisUnitSelector
|
||||
source={YAxisSource.ALERTS}
|
||||
@@ -104,12 +106,12 @@ describe('YAxisUnitSelector', () => {
|
||||
);
|
||||
const warningIcon = screen.getByLabelText('warning');
|
||||
expect(warningIcon).toBeInTheDocument();
|
||||
fireEvent.mouseOver(warningIcon);
|
||||
return screen
|
||||
.findByText(
|
||||
await user.hover(warningIcon);
|
||||
await expect(
|
||||
screen.findByText(
|
||||
'Unit mismatch. The metric was sent with unit Seconds (s), but Bytes (B) is selected.',
|
||||
)
|
||||
.then((el) => expect(el).toBeInTheDocument());
|
||||
),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show warning message when compatible unit is selected', () => {
|
||||
@@ -125,7 +127,7 @@ describe('YAxisUnitSelector', () => {
|
||||
expect(warningIcon).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('uses categories override to render custom units', () => {
|
||||
it('uses categories override to render custom units', async () => {
|
||||
const customCategories = [
|
||||
{
|
||||
name: YAxisCategoryNames.Data,
|
||||
@@ -147,9 +149,7 @@ describe('YAxisUnitSelector', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
const select = screen.getByRole('combobox');
|
||||
|
||||
fireEvent.mouseDown(select);
|
||||
await user.click(screen.getByRole('combobox'));
|
||||
|
||||
expect(screen.getByText('Custom Bytes (B)')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Bytes (B)')).not.toBeInTheDocument();
|
||||
|
||||
@@ -4,6 +4,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Re-enable hover on the warning icon: its `.ant-select-arrow` parent sets
|
||||
// `pointer-events: none`, which would otherwise suppress the tooltip.
|
||||
.y-axis-unit-warning {
|
||||
display: inline-flex;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.warning-state {
|
||||
.ant-select-selector {
|
||||
border-color: var(--bg-amber-400) !important;
|
||||
@@ -17,3 +24,7 @@
|
||||
right: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
.y-axis-unit-warning-tooltip {
|
||||
max-width: 240px;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export const apDexToolTipText =
|
||||
"Apdex is a way to measure your users' satisfaction with the response time of your web service. It's represented as a score from 0-1.";
|
||||
export const apDexToolTipUrl =
|
||||
'https://signoz.io/docs/userguide/metrics/#apdex?utm_source=product&utm_medium=frontend&utm_campaign=apdex';
|
||||
'https://signoz.io/docs/alerts-management/apdex-alerts/?utm_source=product&utm_medium=frontend&utm_campaign=apdex';
|
||||
export const apDexToolTipUrlText = 'Learn more about Apdex.';
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export const DASHBOARD_CACHE_TIME = 30_000;
|
||||
// keep it low or zero, otherwise, when enabled auto-refresh, this causes OOM due to accumulated queries in cache
|
||||
export const DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED = 0;
|
||||
|
||||
export const FIELD_API_CACHE_TIME = 60_000;
|
||||
|
||||
@@ -68,7 +68,7 @@ function AlertChannels(): JSX.Element {
|
||||
<RightActionContainer>
|
||||
<TextToolTip
|
||||
text={t('tooltip_notification_channels')}
|
||||
url="https://signoz.io/docs/userguide/alerts-management/#setting-notification-channel"
|
||||
url="https://signoz.io/docs/setup-alerts-notification/"
|
||||
/>
|
||||
|
||||
<Tooltip
|
||||
|
||||
@@ -29,8 +29,7 @@ function SelectAlertType({ onSelect }: SelectAlertTypeProps): JSX.Element {
|
||||
let url = '';
|
||||
switch (option) {
|
||||
case AlertTypes.ANOMALY_BASED_ALERT:
|
||||
url =
|
||||
'https://signoz.io/docs/alerts-management/anomaly-based-alerts/?utm_source=product&utm_medium=alert-source-selection-page#examples';
|
||||
url = 'https://signoz.io/docs/alerts-management/anomaly-based-alerts/';
|
||||
break;
|
||||
case AlertTypes.METRICS_BASED_ALERT:
|
||||
url =
|
||||
|
||||
@@ -31,8 +31,7 @@ export const ALERT_TYPE_URL_MAP: Record<
|
||||
'https://signoz.io/docs/alerts-management/exceptions-based-alerts/?utm_source=product&utm_medium=alert-creation-page',
|
||||
},
|
||||
[AlertTypes.ANOMALY_BASED_ALERT]: {
|
||||
selection:
|
||||
'https://signoz.io/docs/alerts-management/anomaly-based-alerts/?utm_source=product&utm_medium=alert-source-selection-page#examples',
|
||||
selection: 'https://signoz.io/docs/alerts-management/anomaly-based-alerts/',
|
||||
creation:
|
||||
'https://signoz.io/docs/alerts-management/anomaly-based-alerts/?utm_source=product&utm_medium=alert-creation-page',
|
||||
},
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Input } from '@signozhq/ui/input';
|
||||
import { Button } from 'antd';
|
||||
import { PrecisionOption, PrecisionOptionsEnum } from 'components/Graph/types';
|
||||
import { ResizeTable } from 'components/ResizeTable';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
import { usePlotContext } from 'lib/uPlotV2/context/PlotContext';
|
||||
import useLegendsSync from 'lib/uPlotV2/hooks/useLegendsSync';
|
||||
@@ -11,6 +10,7 @@ import {
|
||||
selectIsDashboardLocked,
|
||||
useDashboardStore,
|
||||
} from 'providers/Dashboard/store/useDashboardStore';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
|
||||
import { getChartManagerColumns } from './getChartMangerColumns';
|
||||
import { ExtendedChartDataset, getDefaultTableDataSet } from './utils';
|
||||
@@ -44,7 +44,6 @@ export default function ChartManager({
|
||||
decimalPrecision = PrecisionOptionsEnum.TWO,
|
||||
onCancel,
|
||||
}: ChartManagerProps): JSX.Element {
|
||||
const { notifications } = useNotifications();
|
||||
const { legendItemsMap } = useLegendsSync({
|
||||
config,
|
||||
subscribeToFocusChange: false,
|
||||
@@ -136,11 +135,9 @@ export default function ChartManager({
|
||||
|
||||
const handleSave = useCallback((): void => {
|
||||
syncSeriesVisibilityToLocalStorage();
|
||||
notifications.success({
|
||||
message: 'The updated graphs & legends are saved',
|
||||
});
|
||||
toast.success('The updated graphs & legends are saved');
|
||||
onCancel?.();
|
||||
}, [syncSeriesVisibilityToLocalStorage, notifications, onCancel]);
|
||||
}, [syncSeriesVisibilityToLocalStorage, onCancel]);
|
||||
|
||||
return (
|
||||
<div className="chart-manager-container">
|
||||
|
||||
@@ -5,7 +5,7 @@ import { render, screen } from 'tests/test-utils';
|
||||
import ChartManager from '../ChartManager';
|
||||
|
||||
const mockSyncSeriesVisibilityToLocalStorage = jest.fn();
|
||||
const mockNotificationsSuccess = jest.fn();
|
||||
const mockToastSuccess = jest.fn();
|
||||
|
||||
jest.mock('lib/uPlotV2/context/PlotContext', () => ({
|
||||
usePlotContext: (): {
|
||||
@@ -46,12 +46,11 @@ jest.mock('providers/Dashboard/store/useDashboardStore', () => ({
|
||||
}): boolean => s.dashboardData?.locked ?? false,
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useNotifications', () => ({
|
||||
useNotifications: (): { notifications: { success: jest.Mock } } => ({
|
||||
notifications: {
|
||||
success: mockNotificationsSuccess,
|
||||
},
|
||||
}),
|
||||
jest.mock('@signozhq/ui/sonner', () => ({
|
||||
...jest.requireActual('@signozhq/ui/sonner'),
|
||||
toast: {
|
||||
success: (...args: unknown[]): unknown => mockToastSuccess(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('components/ResizeTable', () => {
|
||||
@@ -160,7 +159,7 @@ describe('ChartManager', () => {
|
||||
expect(screen.queryByTestId('row-2')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls syncSeriesVisibilityToLocalStorage, notifications.success, and onCancel when Save is clicked', async () => {
|
||||
it('calls syncSeriesVisibilityToLocalStorage, toast.success, and onCancel when Save is clicked', async () => {
|
||||
render(
|
||||
<ChartManager
|
||||
config={createMockConfig() as UPlotConfigBuilder}
|
||||
@@ -172,9 +171,9 @@ describe('ChartManager', () => {
|
||||
await userEvent.click(screen.getByRole('button', { name: /Save/ }));
|
||||
|
||||
expect(mockSyncSeriesVisibilityToLocalStorage).toHaveBeenCalledTimes(1);
|
||||
expect(mockNotificationsSuccess).toHaveBeenCalledWith({
|
||||
message: 'The updated graphs & legends are saved',
|
||||
});
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith(
|
||||
'The updated graphs & legends are saved',
|
||||
);
|
||||
expect(mockOnCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,14 @@
|
||||
height: 100%;
|
||||
flex-direction: column;
|
||||
|
||||
// Stacked children (the FullView / standalone graph-manager) sit below the chart
|
||||
// in the same container; size the chart region to its content so they aren't
|
||||
// pushed out. Only this case opts out of filling the height — the dashboard grid,
|
||||
// alert preview, and other charts keep 100% so they fill their container.
|
||||
&--with-layout-children {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
&--legend-right {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
@@ -63,6 +63,7 @@ export default function ChartLayout({
|
||||
className={cx('chart-layout', {
|
||||
'chart-layout--legend-right':
|
||||
legendConfig.position === LegendPosition.RIGHT,
|
||||
'chart-layout--with-layout-children': !!layoutChildren,
|
||||
})}
|
||||
>
|
||||
<div className="chart-layout__content">
|
||||
|
||||
@@ -717,13 +717,13 @@ function ExplorerOptions({
|
||||
|
||||
const infoIconLink = useMemo(() => {
|
||||
if (isLogsExplorer) {
|
||||
return 'https://signoz.io/docs/product-features/logs-explorer/?utm_source=product&utm_medium=logs-explorer-toolbar';
|
||||
return 'https://signoz.io/docs/userguide/logs_query_builder/?utm_source=product&utm_medium=logs-explorer-toolbar';
|
||||
}
|
||||
// TODO: Add metrics explorer info icon link
|
||||
if (isMetricsExplorer) {
|
||||
return '';
|
||||
}
|
||||
return 'https://signoz.io/docs/product-features/trace-explorer/?utm_source=product&utm_medium=trace-explorer-toolbar';
|
||||
return 'https://signoz.io/docs/userguide/traces/?utm_source=product&utm_medium=trace-explorer-toolbar';
|
||||
}, [isLogsExplorer, isMetricsExplorer]);
|
||||
|
||||
const getQueryName = (query: Query): string => {
|
||||
|
||||
@@ -201,7 +201,7 @@ export default function SavedViews({
|
||||
});
|
||||
|
||||
window.open(
|
||||
'https://signoz.io/docs/product-features/saved-view/',
|
||||
'https://signoz.io/docs/metrics-management/metrics-explorer/#saved-views-in-metrics-explorer',
|
||||
'_blank',
|
||||
'noopener noreferrer',
|
||||
);
|
||||
|
||||
@@ -29,12 +29,12 @@ export const checkListStepToPreferenceKeyMap = {
|
||||
|
||||
export const DOCS_LINKS = {
|
||||
ADD_DATA_SOURCE: 'https://signoz.io/docs/instrumentation/overview/',
|
||||
SEND_LOGS: 'https://signoz.io/docs/userguide/logs/',
|
||||
SEND_LOGS: 'https://signoz.io/docs/userguide/logs_query_builder/',
|
||||
SEND_TRACES: 'https://signoz.io/docs/userguide/traces/',
|
||||
SEND_METRICS: 'https://signoz.io/docs/metrics-management/metrics-explorer/',
|
||||
SETUP_ALERTS: 'https://signoz.io/docs/userguide/alerts-management/',
|
||||
SETUP_ALERTS: 'https://signoz.io/docs/alerts/',
|
||||
SETUP_SAVED_VIEWS:
|
||||
'https://signoz.io/docs/product-features/saved-view/#step-2-save-your-view',
|
||||
'https://signoz.io/docs/metrics-management/metrics-explorer/#saved-views-in-metrics-explorer',
|
||||
SETUP_DASHBOARDS: 'https://signoz.io/docs/userguide/manage-dashboards/',
|
||||
};
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ export function K8sEmptyState({
|
||||
<span className={styles.message}>
|
||||
Please refer to{' '}
|
||||
<a
|
||||
href="https://signoz.io/docs/userguide/hostmetrics/"
|
||||
href="https://signoz.io/docs/infrastructure-monitoring/hostmetrics/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
|
||||
@@ -611,7 +611,7 @@ describe('K8sBaseList', () => {
|
||||
expect(link).toBeInTheDocument();
|
||||
expect(link).toHaveAttribute(
|
||||
'href',
|
||||
'https://signoz.io/docs/userguide/hostmetrics/',
|
||||
'https://signoz.io/docs/infrastructure-monitoring/hostmetrics/',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,8 +2,9 @@ import { useCallback, useMemo, useRef } from 'react';
|
||||
import { UseQueryResult } from 'react-query';
|
||||
import { Skeleton } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import Uplot from 'components/Uplot';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import TimeSeries from 'container/DashboardContainer/visualization/charts/TimeSeries/TimeSeries';
|
||||
import { LegendPosition } from 'lib/uPlotV2/components/types';
|
||||
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8s/constants';
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
import {
|
||||
@@ -13,13 +14,13 @@ import {
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
import { useMultiIntersectionObserver } from 'hooks/useMultiIntersectionObserver';
|
||||
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
|
||||
import { getUPlotChartOptions } from 'lib/uPlotLib/getUplotChartOptions';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { SuccessResponse } from 'types/api';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import { AlignedData, Options } from 'uplot';
|
||||
|
||||
import { useMultiIntersectionObserver } from 'hooks/useMultiIntersectionObserver';
|
||||
import { buildEntityMetricsChartConfig } from './configBuilder';
|
||||
|
||||
import { useEntityMetrics } from './hooks';
|
||||
import { isKeyNotFoundError } from '../utils';
|
||||
@@ -70,7 +71,7 @@ function EntityMetrics<T>({
|
||||
{ threshold: 0.1 },
|
||||
);
|
||||
|
||||
const { queries, chartData, queryPayloads } = useEntityMetrics({
|
||||
const { queries, chartData, tableData, queryPayloads } = useEntityMetrics({
|
||||
queryKey,
|
||||
timeRange,
|
||||
entity,
|
||||
@@ -80,16 +81,10 @@ function EntityMetrics<T>({
|
||||
});
|
||||
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { timezone } = useTimezone();
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
const dimensions = useResizeObserver(graphRef);
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
const legendScrollPositionRef = useRef<{
|
||||
scrollTop: number;
|
||||
scrollLeft: number;
|
||||
}>({
|
||||
scrollTop: 0,
|
||||
scrollLeft: 0,
|
||||
});
|
||||
|
||||
const onDragSelect = useCallback(
|
||||
(start: number, end: number): void => {
|
||||
@@ -101,43 +96,39 @@ function EntityMetrics<T>({
|
||||
[handleTimeChange],
|
||||
);
|
||||
|
||||
const options = useMemo(
|
||||
const configs = useMemo(
|
||||
() =>
|
||||
queries.map(({ data }, idx) => {
|
||||
const panelType = queryPayloads[idx]?.graphType;
|
||||
if (panelType === PANEL_TYPES.TABLE) {
|
||||
return null;
|
||||
}
|
||||
return getUPlotChartOptions({
|
||||
apiResponse: data?.payload,
|
||||
const widgetTitle = entityWidgetInfo[idx].title
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, '-');
|
||||
return buildEntityMetricsChartConfig({
|
||||
id: `${category}-${widgetTitle}`,
|
||||
isDarkMode,
|
||||
dimensions,
|
||||
currentQuery,
|
||||
onDragSelect,
|
||||
apiResponse: data?.payload,
|
||||
timezone,
|
||||
yAxisUnit: entityWidgetInfo[idx].yAxisUnit,
|
||||
softMax: null,
|
||||
softMin: null,
|
||||
minTimeScale: timeRange.startTime,
|
||||
maxTimeScale: timeRange.endTime,
|
||||
onDragSelect,
|
||||
query: currentQuery,
|
||||
legendScrollPosition: legendScrollPositionRef.current,
|
||||
setLegendScrollPosition: (position: {
|
||||
scrollTop: number;
|
||||
scrollLeft: number;
|
||||
}): void => {
|
||||
legendScrollPositionRef.current = position;
|
||||
},
|
||||
});
|
||||
}),
|
||||
[
|
||||
queries,
|
||||
queryPayloads,
|
||||
category,
|
||||
isDarkMode,
|
||||
dimensions,
|
||||
currentQuery,
|
||||
onDragSelect,
|
||||
timezone,
|
||||
entityWidgetInfo,
|
||||
timeRange.startTime,
|
||||
timeRange.endTime,
|
||||
onDragSelect,
|
||||
currentQuery,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -170,14 +161,22 @@ function EntityMetrics<T>({
|
||||
>
|
||||
{panelType === PANEL_TYPES.TABLE ? (
|
||||
<MetricsTable
|
||||
rows={chartData[idx]?.[0]?.rows ?? []}
|
||||
columns={chartData[idx]?.[0]?.columns ?? []}
|
||||
rows={tableData[idx]?.[0]?.rows ?? []}
|
||||
columns={tableData[idx]?.[0]?.columns ?? []}
|
||||
/>
|
||||
) : (
|
||||
<Uplot
|
||||
options={options[idx] as Options}
|
||||
data={chartData[idx] as AlignedData}
|
||||
/>
|
||||
configs[idx] &&
|
||||
chartData[idx] && (
|
||||
<TimeSeries
|
||||
config={configs[idx]}
|
||||
data={chartData[idx]}
|
||||
legendConfig={{ position: LegendPosition.BOTTOM }}
|
||||
width={dimensions.width}
|
||||
height={dimensions.height}
|
||||
timezone={timezone}
|
||||
yAxisUnit={entityWidgetInfo[idx].yAxisUnit}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { InfraMonitoringEntity } from 'container/InfraMonitoringK8s/constants';
|
||||
import { Time } from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import * as appContextHooks from 'providers/App/App';
|
||||
import { LicenseEvent } from 'types/api/licensesV3/getActive';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import EntityMetrics from '../EntityMetrics';
|
||||
import { useEntityMetrics } from '../hooks';
|
||||
@@ -15,12 +16,15 @@ const mockUseEntityMetrics = useEntityMetrics as jest.MockedFunction<
|
||||
typeof useEntityMetrics
|
||||
>;
|
||||
|
||||
jest.mock('lib/uPlotLib/getUplotChartOptions', () => ({
|
||||
getUPlotChartOptions: jest.fn().mockReturnValue({}),
|
||||
jest.mock('../configBuilder', () => ({
|
||||
buildEntityMetricsChartConfig: jest.fn().mockReturnValue({
|
||||
getId: jest.fn().mockReturnValue('mock-id'),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('lib/uPlotLib/utils/getUplotChartData', () => ({
|
||||
getUPlotChartData: jest.fn().mockReturnValue([]),
|
||||
jest.mock('lib/uPlotV2/utils/dataUtils', () => ({
|
||||
prepareChartData: jest.fn().mockReturnValue([]),
|
||||
hasSingleVisiblePoint: jest.fn().mockReturnValue(false),
|
||||
}));
|
||||
|
||||
jest.mock('container/TopNav/DateTimeSelectionV2', () => ({
|
||||
@@ -30,9 +34,20 @@ jest.mock('container/TopNav/DateTimeSelectionV2', () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('components/Uplot', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div data-testid="uplot-chart">Uplot Chart</div>,
|
||||
jest.mock(
|
||||
'container/DashboardContainer/visualization/charts/TimeSeries/TimeSeries',
|
||||
() => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => (
|
||||
<div data-testid="uplot-chart">TimeSeries Chart</div>
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
jest.mock('providers/Timezone', () => ({
|
||||
useTimezone: (): { timezone: { value: string } } => ({
|
||||
timezone: { value: 'UTC' },
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../MetricsTable', () => ({
|
||||
@@ -294,20 +309,28 @@ const renderEntityMetrics = (overrides = {}): any => {
|
||||
);
|
||||
};
|
||||
|
||||
const mockChartData = [
|
||||
[], // time_series chart data (uplot handles empty array)
|
||||
const mockChartData: (uPlot.AlignedData | null)[] = [
|
||||
[
|
||||
[1705315200, 1705318800],
|
||||
[42.5, 43.2],
|
||||
], // time_series chart data (AlignedData)
|
||||
null, // table uses tableData
|
||||
];
|
||||
|
||||
const mockTableData: (import('../utils').MetricsTableData[] | null)[] = [
|
||||
null, // time_series uses chartData
|
||||
[
|
||||
{
|
||||
rows: [
|
||||
{ data: { timestamp: '2024-01-15T10:00:00Z', value: '1024' } },
|
||||
{ data: { timestamp: '2024-01-15T10:01:00Z', value: '1028' } },
|
||||
{ timestamp: '2024-01-15T10:00:00Z', value: '1024' },
|
||||
{ timestamp: '2024-01-15T10:01:00Z', value: '1028' },
|
||||
],
|
||||
columns: [
|
||||
{ key: 'timestamp', label: 'Timestamp', isValueColumn: false },
|
||||
{ key: 'value', label: 'Value', isValueColumn: true },
|
||||
],
|
||||
},
|
||||
], // table chart data
|
||||
], // table data
|
||||
];
|
||||
|
||||
const mockQueryPayloads = [
|
||||
@@ -321,6 +344,7 @@ describe('EntityMetrics', () => {
|
||||
mockUseEntityMetrics.mockReturnValue({
|
||||
queries: mockQueries as any,
|
||||
chartData: mockChartData,
|
||||
tableData: mockTableData,
|
||||
queryPayloads: mockQueryPayloads as any,
|
||||
});
|
||||
mockUseQuery.mockReturnValue({
|
||||
@@ -351,7 +375,8 @@ describe('EntityMetrics', () => {
|
||||
it('renders loading state when fetching metrics', () => {
|
||||
mockUseEntityMetrics.mockReturnValue({
|
||||
queries: mockLoadingQueries as any,
|
||||
chartData: [[], []],
|
||||
chartData: [null, null],
|
||||
tableData: [null, null],
|
||||
queryPayloads: mockQueryPayloads as any,
|
||||
});
|
||||
renderEntityMetrics();
|
||||
@@ -362,7 +387,8 @@ describe('EntityMetrics', () => {
|
||||
it('renders error state when query fails', () => {
|
||||
mockUseEntityMetrics.mockReturnValue({
|
||||
queries: mockErrorQueries as any,
|
||||
chartData: [[], []],
|
||||
chartData: [null, null],
|
||||
tableData: [null, null],
|
||||
queryPayloads: mockQueryPayloads as any,
|
||||
});
|
||||
renderEntityMetrics();
|
||||
@@ -373,8 +399,9 @@ describe('EntityMetrics', () => {
|
||||
it('renders empty state when no metrics data', () => {
|
||||
mockUseEntityMetrics.mockReturnValue({
|
||||
queries: mockEmptyQueries as any,
|
||||
chartData: [
|
||||
[],
|
||||
chartData: [[[]], null],
|
||||
tableData: [
|
||||
null,
|
||||
[
|
||||
{
|
||||
rows: [],
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { getLegend } from 'lib/dashboard/getQueryResults';
|
||||
import getLabelName from 'lib/getLabelName';
|
||||
import {
|
||||
DrawStyle,
|
||||
FillMode,
|
||||
LineInterpolation,
|
||||
LineStyle,
|
||||
SelectionPreferencesSource,
|
||||
} from 'lib/uPlotV2/config/types';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
import { hasSingleVisiblePoint } from 'lib/uPlotV2/utils/dataUtils';
|
||||
import { get } from 'lodash-es';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
export interface EntityMetricsChartConfigProps {
|
||||
id: string;
|
||||
isDarkMode: boolean;
|
||||
currentQuery: Query;
|
||||
onDragSelect: (startTime: number, endTime: number) => void;
|
||||
apiResponse?: MetricRangePayloadProps;
|
||||
timezone: Timezone;
|
||||
yAxisUnit: string;
|
||||
minTimeScale?: number;
|
||||
maxTimeScale?: number;
|
||||
}
|
||||
|
||||
export function buildEntityMetricsChartConfig({
|
||||
id,
|
||||
isDarkMode,
|
||||
currentQuery,
|
||||
onDragSelect,
|
||||
apiResponse,
|
||||
timezone,
|
||||
yAxisUnit,
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
}: EntityMetricsChartConfigProps): UPlotConfigBuilder {
|
||||
const stepIntervals = get(
|
||||
apiResponse,
|
||||
'data.newResult.meta.stepIntervals',
|
||||
{},
|
||||
) as Record<string, number>;
|
||||
const minStepInterval = Object.keys(stepIntervals).length
|
||||
? Math.min(...Object.values(stepIntervals))
|
||||
: undefined;
|
||||
|
||||
const tzDate = (timestamp: number): Date =>
|
||||
uPlot.tzDate(new Date(timestamp * 1e3), timezone.value);
|
||||
|
||||
const builder = new UPlotConfigBuilder({
|
||||
id,
|
||||
onDragSelect,
|
||||
tzDate,
|
||||
selectionPreferencesSource: SelectionPreferencesSource.IN_MEMORY,
|
||||
stepInterval: minStepInterval,
|
||||
});
|
||||
|
||||
builder.addScale({
|
||||
scaleKey: 'x',
|
||||
time: true,
|
||||
min: minTimeScale,
|
||||
max: maxTimeScale,
|
||||
});
|
||||
|
||||
builder.addScale({
|
||||
scaleKey: 'y',
|
||||
time: false,
|
||||
});
|
||||
|
||||
builder.addAxis({
|
||||
scaleKey: 'x',
|
||||
show: true,
|
||||
side: 2,
|
||||
isDarkMode,
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
});
|
||||
|
||||
builder.addAxis({
|
||||
scaleKey: 'y',
|
||||
show: true,
|
||||
side: 3,
|
||||
isDarkMode,
|
||||
yAxisUnit,
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
});
|
||||
|
||||
if (!apiResponse?.data?.result) {
|
||||
return builder;
|
||||
}
|
||||
|
||||
apiResponse.data.result.forEach((series) => {
|
||||
const hasSingleValidPoint = hasSingleVisiblePoint(series.values);
|
||||
const baseLabelName = getLabelName(
|
||||
series.metric,
|
||||
series.queryName || '',
|
||||
series.legend || '',
|
||||
);
|
||||
|
||||
const label = getLegend(series, currentQuery, baseLabelName);
|
||||
|
||||
builder.addSeries({
|
||||
scaleKey: 'y',
|
||||
drawStyle: hasSingleValidPoint ? DrawStyle.Points : DrawStyle.Line,
|
||||
label,
|
||||
colorMapping: {},
|
||||
spanGaps: true,
|
||||
lineStyle: LineStyle.Solid,
|
||||
lineInterpolation: LineInterpolation.Spline,
|
||||
showPoints: hasSingleValidPoint,
|
||||
pointSize: 5,
|
||||
fillMode: FillMode.None,
|
||||
isDarkMode,
|
||||
metric: series.metric,
|
||||
});
|
||||
});
|
||||
|
||||
return builder;
|
||||
}
|
||||
@@ -8,13 +8,14 @@ import {
|
||||
GetMetricQueryRange,
|
||||
GetQueryResultsProps,
|
||||
} from 'lib/dashboard/getQueryResults';
|
||||
import { getUPlotChartData } from 'lib/uPlotLib/utils/getUplotChartData';
|
||||
import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils';
|
||||
import { SuccessResponse } from 'types/api';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { FeatureKeys } from '../../../../constants/features';
|
||||
import { useAppContext } from '../../../../providers/App/App';
|
||||
import { getMetricsTableData } from './utils';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { getMetricsTableData, MetricsTableData } from './utils';
|
||||
|
||||
export interface UseEntityMetricsParams<T> {
|
||||
queryKey: string;
|
||||
@@ -32,10 +33,8 @@ export interface UseEntityMetricsParams<T> {
|
||||
|
||||
export interface UseEntityMetricsResult {
|
||||
queries: UseQueryResult<SuccessResponse<MetricRangePayloadProps>, unknown>[];
|
||||
chartData: (
|
||||
| ReturnType<typeof getUPlotChartData>
|
||||
| ReturnType<typeof getMetricsTableData>
|
||||
)[];
|
||||
chartData: (uPlot.AlignedData | null)[];
|
||||
tableData: (MetricsTableData[] | null)[];
|
||||
queryPayloads: GetQueryResultsProps[];
|
||||
}
|
||||
|
||||
@@ -93,9 +92,22 @@ export function useEntityMetrics<T>({
|
||||
() =>
|
||||
queries.map(({ data }, index) => {
|
||||
const panelType = queryPayloads[index]?.graphType;
|
||||
return panelType === PANEL_TYPES.TABLE
|
||||
? getMetricsTableData(data)
|
||||
: getUPlotChartData(data?.payload);
|
||||
if (panelType === PANEL_TYPES.TABLE) {
|
||||
return null;
|
||||
}
|
||||
return data?.payload ? prepareChartData(data.payload) : null;
|
||||
}),
|
||||
[queries, queryPayloads],
|
||||
);
|
||||
|
||||
const tableData = useMemo(
|
||||
() =>
|
||||
queries.map(({ data }, index) => {
|
||||
const panelType = queryPayloads[index]?.graphType;
|
||||
if (panelType !== PANEL_TYPES.TABLE) {
|
||||
return null;
|
||||
}
|
||||
return getMetricsTableData(data);
|
||||
}),
|
||||
[queries, queryPayloads],
|
||||
);
|
||||
@@ -103,6 +115,7 @@ export function useEntityMetrics<T>({
|
||||
return {
|
||||
queries,
|
||||
chartData,
|
||||
tableData,
|
||||
queryPayloads,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -66,20 +66,6 @@
|
||||
background: colox-mix(var(--accent-primary), var(--bg-vanilla-100), 20%);
|
||||
}
|
||||
|
||||
:global(.ant-collapse-header) {
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
padding: 12px 8px;
|
||||
}
|
||||
|
||||
:global(.ant-collapse-content-box) {
|
||||
padding: 0 !important;
|
||||
padding-block: 0 !important;
|
||||
|
||||
:global(.quick-filters .checkbox-filter) {
|
||||
padding-left: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
:global(.quick-filters) {
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
@@ -111,22 +97,6 @@
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.quickFiltersCategoryLabel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.quickFiltersCategoryLabelIcon {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.quickFiltersCategoryLabelContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.listContainer {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
@@ -160,3 +130,106 @@
|
||||
.listContainerFiltersVisible {
|
||||
max-width: calc(100% - 280px);
|
||||
}
|
||||
|
||||
.categorySelectorSection {
|
||||
padding: var(--spacing-4);
|
||||
}
|
||||
|
||||
.sectionHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
margin-bottom: var(--spacing-4);
|
||||
|
||||
&[data-type='filter'] {
|
||||
padding: 0 var(--spacing-4);
|
||||
}
|
||||
}
|
||||
|
||||
.sectionLabel {
|
||||
font-size: var(--periscope-font-size-small);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--l3-foreground);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sectionLine {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--l1-border);
|
||||
}
|
||||
|
||||
.categoryCard {
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--spacing-3);
|
||||
padding: var(--spacing-2);
|
||||
}
|
||||
|
||||
.categoryList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-1);
|
||||
}
|
||||
|
||||
.categoryItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-5);
|
||||
padding: var(--spacing-3) var(--spacing-4);
|
||||
border: none;
|
||||
border-radius: var(--spacing-2);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
transition:
|
||||
background-color 0.2s ease,
|
||||
color 0.2s ease;
|
||||
color: var(--l2-foreground);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
--typography-color: var(--l1-foreground);
|
||||
|
||||
&:hover:not(.categoryItemSelected) {
|
||||
background: var(--l2-background-hover);
|
||||
}
|
||||
|
||||
svg {
|
||||
color: var(--l3-foreground);
|
||||
flex-shrink: 0;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
&.categoryItemSelected {
|
||||
background: var(--accent-primary);
|
||||
color: var(--l1-background);
|
||||
font-weight: 600;
|
||||
|
||||
&:hover {
|
||||
background: var(--accent-primary-hover, var(--accent-primary));
|
||||
}
|
||||
|
||||
svg {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.quickFiltersSection {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0.1rem;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--accent-primary);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import * as Sentry from '@sentry/react';
|
||||
import { Button, CollapseProps } from 'antd';
|
||||
import { Collapse, Tooltip } from 'antd';
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
@@ -113,192 +112,74 @@ export default function InfraMonitoringK8s(): JSX.Element {
|
||||
});
|
||||
};
|
||||
|
||||
const renderCategoryLabel = (
|
||||
icon: JSX.Element,
|
||||
label: string,
|
||||
): JSX.Element => (
|
||||
<div className={styles.quickFiltersCategoryLabel}>
|
||||
<div className={styles.quickFiltersCategoryLabelContainer}>
|
||||
{icon}
|
||||
<Typography.Text>{label}</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
const categories = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: K8sCategories.PODS,
|
||||
label: 'Pods',
|
||||
icon: <Container size={14} />,
|
||||
config: GetPodsQuickFiltersConfig(dotMetricsEnabled),
|
||||
},
|
||||
{
|
||||
key: K8sCategories.NODES,
|
||||
label: 'Nodes',
|
||||
icon: <Workflow size={14} />,
|
||||
config: GetNodesQuickFiltersConfig(dotMetricsEnabled),
|
||||
},
|
||||
{
|
||||
key: K8sCategories.NAMESPACES,
|
||||
label: 'Namespaces',
|
||||
icon: <FilePenLine size={14} />,
|
||||
config: GetNamespaceQuickFiltersConfig(dotMetricsEnabled),
|
||||
},
|
||||
{
|
||||
key: K8sCategories.CLUSTERS,
|
||||
label: 'Clusters',
|
||||
icon: <Boxes size={14} />,
|
||||
config: GetClustersQuickFiltersConfig(dotMetricsEnabled),
|
||||
},
|
||||
{
|
||||
key: K8sCategories.DEPLOYMENTS,
|
||||
label: 'Deployments',
|
||||
icon: <Computer size={14} />,
|
||||
config: GetDeploymentsQuickFiltersConfig(dotMetricsEnabled),
|
||||
},
|
||||
{
|
||||
key: K8sCategories.JOBS,
|
||||
label: 'Jobs',
|
||||
icon: <Bolt size={14} />,
|
||||
config: GetJobsQuickFiltersConfig(dotMetricsEnabled),
|
||||
},
|
||||
{
|
||||
key: K8sCategories.DAEMONSETS,
|
||||
label: 'DaemonSets',
|
||||
icon: <Group size={14} />,
|
||||
config: GetDaemonsetsQuickFiltersConfig(dotMetricsEnabled),
|
||||
},
|
||||
{
|
||||
key: K8sCategories.STATEFULSETS,
|
||||
label: 'StatefulSets',
|
||||
icon: <ArrowUpDown size={14} />,
|
||||
config: GetStatefulsetsQuickFiltersConfig(dotMetricsEnabled),
|
||||
},
|
||||
{
|
||||
key: K8sCategories.VOLUMES,
|
||||
label: 'Volumes',
|
||||
icon: <HardDrive size={14} />,
|
||||
config: GetVolumesQuickFiltersConfig(dotMetricsEnabled),
|
||||
},
|
||||
],
|
||||
[dotMetricsEnabled],
|
||||
);
|
||||
|
||||
const items: CollapseProps['items'] = [
|
||||
{
|
||||
label: renderCategoryLabel(
|
||||
<Container size={14} className={styles.quickFiltersCategoryLabelIcon} />,
|
||||
'Pods',
|
||||
),
|
||||
key: K8sCategories.PODS,
|
||||
showArrow: false,
|
||||
children: (
|
||||
<QuickFilters
|
||||
source={QuickFiltersSource.INFRA_MONITORING}
|
||||
config={GetPodsQuickFiltersConfig(dotMetricsEnabled)}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
onFilterChange={handleFilterChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: renderCategoryLabel(
|
||||
<Workflow size={14} className={styles.quickFiltersCategoryLabelIcon} />,
|
||||
'Nodes',
|
||||
),
|
||||
key: K8sCategories.NODES,
|
||||
showArrow: false,
|
||||
children: (
|
||||
<QuickFilters
|
||||
source={QuickFiltersSource.INFRA_MONITORING}
|
||||
config={GetNodesQuickFiltersConfig(dotMetricsEnabled)}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
onFilterChange={handleFilterChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: renderCategoryLabel(
|
||||
<FilePenLine size={14} className={styles.quickFiltersCategoryLabelIcon} />,
|
||||
'Namespaces',
|
||||
),
|
||||
key: K8sCategories.NAMESPACES,
|
||||
showArrow: false,
|
||||
children: (
|
||||
<QuickFilters
|
||||
source={QuickFiltersSource.INFRA_MONITORING}
|
||||
config={GetNamespaceQuickFiltersConfig(dotMetricsEnabled)}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
onFilterChange={handleFilterChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: renderCategoryLabel(
|
||||
<Boxes size={14} className={styles.quickFiltersCategoryLabelIcon} />,
|
||||
'Clusters',
|
||||
),
|
||||
key: K8sCategories.CLUSTERS,
|
||||
showArrow: false,
|
||||
children: (
|
||||
<QuickFilters
|
||||
source={QuickFiltersSource.INFRA_MONITORING}
|
||||
config={GetClustersQuickFiltersConfig(dotMetricsEnabled)}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
onFilterChange={handleFilterChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: renderCategoryLabel(
|
||||
<Computer size={14} className={styles.quickFiltersCategoryLabelIcon} />,
|
||||
'Deployments',
|
||||
),
|
||||
key: K8sCategories.DEPLOYMENTS,
|
||||
showArrow: false,
|
||||
children: (
|
||||
<QuickFilters
|
||||
source={QuickFiltersSource.INFRA_MONITORING}
|
||||
config={GetDeploymentsQuickFiltersConfig(dotMetricsEnabled)}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
onFilterChange={handleFilterChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: renderCategoryLabel(
|
||||
<Bolt size={14} className={styles.quickFiltersCategoryLabelIcon} />,
|
||||
'Jobs',
|
||||
),
|
||||
key: K8sCategories.JOBS,
|
||||
showArrow: false,
|
||||
children: (
|
||||
<QuickFilters
|
||||
source={QuickFiltersSource.INFRA_MONITORING}
|
||||
config={GetJobsQuickFiltersConfig(dotMetricsEnabled)}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
onFilterChange={handleFilterChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: renderCategoryLabel(
|
||||
<Group size={14} className={styles.quickFiltersCategoryLabelIcon} />,
|
||||
'DaemonSets',
|
||||
),
|
||||
key: K8sCategories.DAEMONSETS,
|
||||
showArrow: false,
|
||||
children: (
|
||||
<QuickFilters
|
||||
source={QuickFiltersSource.INFRA_MONITORING}
|
||||
config={GetDaemonsetsQuickFiltersConfig(dotMetricsEnabled)}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
onFilterChange={handleFilterChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: renderCategoryLabel(
|
||||
<ArrowUpDown size={14} className={styles.quickFiltersCategoryLabelIcon} />,
|
||||
'StatefulSets',
|
||||
),
|
||||
key: K8sCategories.STATEFULSETS,
|
||||
showArrow: false,
|
||||
children: (
|
||||
<QuickFilters
|
||||
source={QuickFiltersSource.INFRA_MONITORING}
|
||||
config={GetStatefulsetsQuickFiltersConfig(dotMetricsEnabled)}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
onFilterChange={handleFilterChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: renderCategoryLabel(
|
||||
<HardDrive size={14} className={styles.quickFiltersCategoryLabelIcon} />,
|
||||
'Volumes',
|
||||
),
|
||||
key: K8sCategories.VOLUMES,
|
||||
showArrow: false,
|
||||
children: (
|
||||
<QuickFilters
|
||||
source={QuickFiltersSource.INFRA_MONITORING}
|
||||
config={GetVolumesQuickFiltersConfig(dotMetricsEnabled)}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
onFilterChange={handleFilterChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
// TODO: Enable once we have implemented containers.
|
||||
// {
|
||||
// label: (
|
||||
// <div className="k8s-quick-filters-category-label">
|
||||
// <div className="k8s-quick-filters-category-label-container">
|
||||
// <PackageOpen
|
||||
// size={14}
|
||||
// className="k8s-quick-filters-category-label-icon"
|
||||
// />
|
||||
// <Typography.Text>Containers</Typography.Text>
|
||||
// </div>
|
||||
// </div>
|
||||
// ),
|
||||
// key: K8sCategories.CONTAINERS,
|
||||
// showArrow: false,
|
||||
// children: (
|
||||
// <QuickFilters
|
||||
// source={QuickFiltersSource.INFRA_MONITORING}
|
||||
// config={ContainersQuickFiltersConfig}
|
||||
// handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
// onFilterChange={handleFilterChange}
|
||||
// />
|
||||
// ),
|
||||
// },
|
||||
];
|
||||
const selectedCategoryConfig = useMemo(
|
||||
() => categories.find((cat) => cat.key === selectedCategory)?.config,
|
||||
[categories, selectedCategory],
|
||||
);
|
||||
|
||||
const handleCategoryChange = (key: string | string[]): void => {
|
||||
if (Array.isArray(key) && key.length > 0) {
|
||||
setSelectedCategory(key[0] as string);
|
||||
const handleCategorySelect = (key: string): void => {
|
||||
if (key !== selectedCategory) {
|
||||
setSelectedCategory(key);
|
||||
// Reset filters
|
||||
setUrlFilters(null);
|
||||
setOrderBy(null);
|
||||
@@ -332,26 +213,58 @@ export default function InfraMonitoringK8s(): JSX.Element {
|
||||
<div className={styles.infraContentRow}>
|
||||
{showFilters && (
|
||||
<div className={styles.quickFiltersContainer}>
|
||||
<div className={styles.quickFiltersContainerHeader}>
|
||||
<Typography.Text>Filters</Typography.Text>
|
||||
|
||||
<Tooltip title="Collapse Filters">
|
||||
<ArrowUpToLine
|
||||
style={{ transform: 'rotate(270deg)' }}
|
||||
onClick={handleFilterVisibilityChange}
|
||||
size="md"
|
||||
/>
|
||||
</Tooltip>
|
||||
<div className={styles.categorySelectorSection}>
|
||||
<div className={styles.sectionHeader} data-type="resource">
|
||||
<Typography.Text className={styles.sectionLabel}>
|
||||
Viewing · Resource
|
||||
</Typography.Text>
|
||||
<div className={styles.sectionLine} />
|
||||
<Tooltip title="Collapse Filters">
|
||||
<ArrowUpToLine
|
||||
style={{ transform: 'rotate(270deg)' }}
|
||||
onClick={handleFilterVisibilityChange}
|
||||
size="md"
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className={styles.categoryCard}>
|
||||
<div className={styles.categoryList}>
|
||||
{categories.map((category) => (
|
||||
<button
|
||||
key={category.key}
|
||||
type="button"
|
||||
className={`${styles.categoryItem} ${
|
||||
selectedCategory === category.key
|
||||
? styles.categoryItemSelected
|
||||
: ''
|
||||
}`}
|
||||
onClick={(): void => handleCategorySelect(category.key)}
|
||||
data-testid={`category-${category.key}`}
|
||||
>
|
||||
{category.icon}
|
||||
<Typography.Text>{category.label}</Typography.Text>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.quickFiltersSection}>
|
||||
<div className={styles.sectionHeader} data-type="filter">
|
||||
<Typography.Text className={styles.sectionLabel}>
|
||||
Filter by
|
||||
</Typography.Text>
|
||||
<div className={styles.sectionLine} />
|
||||
</div>
|
||||
{selectedCategoryConfig && (
|
||||
<QuickFilters
|
||||
source={QuickFiltersSource.INFRA_MONITORING}
|
||||
config={selectedCategoryConfig}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
onFilterChange={handleFilterChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Collapse
|
||||
onChange={handleCategoryChange}
|
||||
items={items}
|
||||
defaultActiveKey={[selectedCategory]}
|
||||
activeKey={[selectedCategory]}
|
||||
accordion
|
||||
bordered={false}
|
||||
ghost
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
import { ChangeEvent, useState } from 'react';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Button } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import ApacheIcon from 'assets/CustomIcons/ApacheIcon';
|
||||
import DockerIcon from 'assets/CustomIcons/DockerIcon';
|
||||
import ElasticSearchIcon from 'assets/CustomIcons/ElasticSearchIcon';
|
||||
import HerokuIcon from 'assets/CustomIcons/HerokuIcon';
|
||||
import KubernetesIcon from 'assets/CustomIcons/KubernetesIcon';
|
||||
import MongoDBIcon from 'assets/CustomIcons/MongoDBIcon';
|
||||
import MySQLIcon from 'assets/CustomIcons/MySQLIcon';
|
||||
import NginxIcon from 'assets/CustomIcons/NginxIcon';
|
||||
import PostgreSQLIcon from 'assets/CustomIcons/PostgreSQLIcon';
|
||||
import RedisIcon from 'assets/CustomIcons/RedisIcon';
|
||||
import cx from 'classnames';
|
||||
import {
|
||||
ConciergeBell,
|
||||
DraftingCompass,
|
||||
Drill,
|
||||
Plus,
|
||||
X,
|
||||
} from '@signozhq/icons';
|
||||
import { DashboardTemplate } from 'types/api/dashboard/getAll';
|
||||
|
||||
import blankDashboardTemplatePreviewUrl from '@/assets/Images/blankDashboardTemplatePreview.svg';
|
||||
import redisTemplatePreviewUrl from '@/assets/Images/redisTemplatePreview.svg';
|
||||
|
||||
import { filterTemplates } from '../utils';
|
||||
|
||||
import './DashboardTemplatesModal.styles.scss';
|
||||
|
||||
const templatesList: DashboardTemplate[] = [
|
||||
{
|
||||
name: 'Blank dashboard',
|
||||
icon: <Drill />,
|
||||
id: 'blank',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'Alert Manager',
|
||||
icon: <ConciergeBell />,
|
||||
id: 'alertManager',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'Apache',
|
||||
icon: <ApacheIcon />,
|
||||
id: 'apache',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'Docker',
|
||||
icon: <DockerIcon />,
|
||||
id: 'docker',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'Elasticsearch',
|
||||
icon: <ElasticSearchIcon />,
|
||||
id: 'elasticSearch',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'MongoDB',
|
||||
icon: <MongoDBIcon />,
|
||||
id: 'mongoDB',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'Heroku',
|
||||
icon: <HerokuIcon />,
|
||||
id: 'heroku',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'Nginx',
|
||||
icon: <NginxIcon />,
|
||||
id: 'nginx',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'Kubernetes',
|
||||
icon: <KubernetesIcon />,
|
||||
id: 'kubernetes',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'MySQL',
|
||||
icon: <MySQLIcon />,
|
||||
id: 'mySQL',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'PostgreSQL',
|
||||
icon: <PostgreSQLIcon />,
|
||||
id: 'postgreSQL',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'Redis',
|
||||
icon: <RedisIcon />,
|
||||
id: 'redis',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: redisTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'AWS',
|
||||
icon: <DraftingCompass size={14} />,
|
||||
id: 'aws',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
];
|
||||
|
||||
interface DashboardTemplatesContentProps {
|
||||
onCreateNewDashboard: () => void;
|
||||
/** When provided, renders the modal-style header with a close affordance. Omitted for inline use. */
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
// The template gallery (search + list + preview + create), extracted from the
|
||||
// modal so it can be embedded inline (e.g. the V2 new-dashboard modal's template
|
||||
// tab) as well as inside DashboardTemplatesModal. Styles live under the global
|
||||
// `.new-dashboard-templates-modal` scope, so inline callers wrap it in that class.
|
||||
export default function DashboardTemplatesContent({
|
||||
onCreateNewDashboard,
|
||||
onCancel,
|
||||
}: DashboardTemplatesContentProps): JSX.Element {
|
||||
const [selectedDashboardTemplate, setSelectedDashboardTemplate] = useState(
|
||||
templatesList[0],
|
||||
);
|
||||
|
||||
const [dashboardTemplates, setDashboardTemplates] = useState(templatesList);
|
||||
|
||||
const handleDashboardTemplateSearch = (
|
||||
event: ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
const searchText = event.target.value;
|
||||
const filteredTemplates = filterTemplates(searchText, templatesList);
|
||||
setDashboardTemplates(filteredTemplates);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="new-dashboard-templates-content-container">
|
||||
{onCancel && (
|
||||
<div className="new-dashboard-templates-content-header">
|
||||
<Typography.Text>New Dashboard</Typography.Text>
|
||||
|
||||
<X size={14} className="periscope-btn ghost" onClick={onCancel} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="new-dashboard-templates-content">
|
||||
<div className="new-dashboard-templates-list">
|
||||
<Input
|
||||
className="new-dashboard-templates-search"
|
||||
placeholder="🔍 Search..."
|
||||
onChange={handleDashboardTemplateSearch}
|
||||
/>
|
||||
|
||||
<div className="templates-list">
|
||||
{dashboardTemplates.map((template) => (
|
||||
<div
|
||||
className={cx(
|
||||
'template-list-item',
|
||||
selectedDashboardTemplate.id === template.id ? 'active' : '',
|
||||
)}
|
||||
key={template.name}
|
||||
onClick={() => setSelectedDashboardTemplate(template)}
|
||||
>
|
||||
<div className="template-icon">{template.icon}</div>
|
||||
<div className="template-name">{template.name}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="new-dashboard-template-preview">
|
||||
<div className="template-preview-header">
|
||||
<div className="template-preview-title">
|
||||
<div className="template-preview-icon">
|
||||
{selectedDashboardTemplate.icon}
|
||||
</div>
|
||||
|
||||
<div className="template-info">
|
||||
<div className="template-name">{selectedDashboardTemplate.name}</div>
|
||||
|
||||
<div className="template-description">
|
||||
{selectedDashboardTemplate.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="create-dashboard-btn">
|
||||
<Button
|
||||
type="primary"
|
||||
className="periscope-btn primary"
|
||||
icon={<Plus size={14} />}
|
||||
onClick={onCreateNewDashboard}
|
||||
>
|
||||
New dashboard
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="template-preview-image">
|
||||
<img
|
||||
src={selectedDashboardTemplate.previewImage}
|
||||
alt={`${selectedDashboardTemplate.name}-preview`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,129 +1,9 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
import { ChangeEvent, useState } from 'react';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Button, Modal } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import ApacheIcon from 'assets/CustomIcons/ApacheIcon';
|
||||
import DockerIcon from 'assets/CustomIcons/DockerIcon';
|
||||
import ElasticSearchIcon from 'assets/CustomIcons/ElasticSearchIcon';
|
||||
import HerokuIcon from 'assets/CustomIcons/HerokuIcon';
|
||||
import KubernetesIcon from 'assets/CustomIcons/KubernetesIcon';
|
||||
import MongoDBIcon from 'assets/CustomIcons/MongoDBIcon';
|
||||
import MySQLIcon from 'assets/CustomIcons/MySQLIcon';
|
||||
import NginxIcon from 'assets/CustomIcons/NginxIcon';
|
||||
import PostgreSQLIcon from 'assets/CustomIcons/PostgreSQLIcon';
|
||||
import RedisIcon from 'assets/CustomIcons/RedisIcon';
|
||||
import cx from 'classnames';
|
||||
import {
|
||||
ConciergeBell,
|
||||
DraftingCompass,
|
||||
Drill,
|
||||
Plus,
|
||||
X,
|
||||
} from '@signozhq/icons';
|
||||
import { DashboardTemplate } from 'types/api/dashboard/getAll';
|
||||
import { Modal } from 'antd';
|
||||
|
||||
import blankDashboardTemplatePreviewUrl from '@/assets/Images/blankDashboardTemplatePreview.svg';
|
||||
import redisTemplatePreviewUrl from '@/assets/Images/redisTemplatePreview.svg';
|
||||
|
||||
import { filterTemplates } from '../utils';
|
||||
import DashboardTemplatesContent from './DashboardTemplatesContent';
|
||||
|
||||
import './DashboardTemplatesModal.styles.scss';
|
||||
|
||||
const templatesList: DashboardTemplate[] = [
|
||||
{
|
||||
name: 'Blank dashboard',
|
||||
icon: <Drill />,
|
||||
id: 'blank',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'Alert Manager',
|
||||
icon: <ConciergeBell />,
|
||||
id: 'alertManager',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'Apache',
|
||||
icon: <ApacheIcon />,
|
||||
id: 'apache',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'Docker',
|
||||
icon: <DockerIcon />,
|
||||
id: 'docker',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'Elasticsearch',
|
||||
icon: <ElasticSearchIcon />,
|
||||
id: 'elasticSearch',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'MongoDB',
|
||||
icon: <MongoDBIcon />,
|
||||
id: 'mongoDB',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'Heroku',
|
||||
icon: <HerokuIcon />,
|
||||
id: 'heroku',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'Nginx',
|
||||
icon: <NginxIcon />,
|
||||
id: 'nginx',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'Kubernetes',
|
||||
icon: <KubernetesIcon />,
|
||||
id: 'kubernetes',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'MySQL',
|
||||
icon: <MySQLIcon />,
|
||||
id: 'mySQL',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'PostgreSQL',
|
||||
icon: <PostgreSQLIcon />,
|
||||
id: 'postgreSQL',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'Redis',
|
||||
icon: <RedisIcon />,
|
||||
id: 'redis',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: redisTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'AWS',
|
||||
icon: <DraftingCompass size={14} />,
|
||||
id: 'aws',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
];
|
||||
|
||||
interface DashboardTemplatesModalProps {
|
||||
showNewDashboardTemplatesModal: boolean;
|
||||
onCreateNewDashboard: () => void;
|
||||
@@ -135,20 +15,6 @@ export default function DashboardTemplatesModal({
|
||||
onCreateNewDashboard,
|
||||
onCancel,
|
||||
}: DashboardTemplatesModalProps): JSX.Element {
|
||||
const [selectedDashboardTemplate, setSelectedDashboardTemplate] = useState(
|
||||
templatesList[0],
|
||||
);
|
||||
|
||||
const [dashboardTemplates, setDashboardTemplates] = useState(templatesList);
|
||||
|
||||
const handleDashboardTemplateSearch = (
|
||||
event: ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
const searchText = event.target.value;
|
||||
const filteredTemplates = filterTemplates(searchText, templatesList);
|
||||
setDashboardTemplates(filteredTemplates);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
wrapClassName="new-dashboard-templates-modal"
|
||||
@@ -159,75 +25,10 @@ export default function DashboardTemplatesModal({
|
||||
destroyOnClose
|
||||
width="60vw"
|
||||
>
|
||||
<div className="new-dashboard-templates-content-container">
|
||||
<div className="new-dashboard-templates-content-header">
|
||||
<Typography.Text>New Dashboard</Typography.Text>
|
||||
|
||||
<X size={14} className="periscope-btn ghost" onClick={onCancel} />
|
||||
</div>
|
||||
|
||||
<div className="new-dashboard-templates-content">
|
||||
<div className="new-dashboard-templates-list">
|
||||
<Input
|
||||
className="new-dashboard-templates-search"
|
||||
placeholder="🔍 Search..."
|
||||
onChange={handleDashboardTemplateSearch}
|
||||
/>
|
||||
|
||||
<div className="templates-list">
|
||||
{dashboardTemplates.map((template) => (
|
||||
<div
|
||||
className={cx(
|
||||
'template-list-item',
|
||||
selectedDashboardTemplate.id === template.id ? 'active' : '',
|
||||
)}
|
||||
key={template.name}
|
||||
onClick={() => setSelectedDashboardTemplate(template)}
|
||||
>
|
||||
<div className="template-icon">{template.icon}</div>
|
||||
<div className="template-name">{template.name}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="new-dashboard-template-preview">
|
||||
<div className="template-preview-header">
|
||||
<div className="template-preview-title">
|
||||
<div className="template-preview-icon">
|
||||
{selectedDashboardTemplate.icon}
|
||||
</div>
|
||||
|
||||
<div className="template-info">
|
||||
<div className="template-name">{selectedDashboardTemplate.name}</div>
|
||||
|
||||
<div className="template-description">
|
||||
{selectedDashboardTemplate.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="create-dashboard-btn">
|
||||
<Button
|
||||
type="primary"
|
||||
className="periscope-btn primary"
|
||||
icon={<Plus size={14} />}
|
||||
onClick={onCreateNewDashboard}
|
||||
>
|
||||
New dashboard
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="template-preview-image">
|
||||
<img
|
||||
src={selectedDashboardTemplate.previewImage}
|
||||
alt={`${selectedDashboardTemplate.name}-preview`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DashboardTemplatesContent
|
||||
onCreateNewDashboard={onCreateNewDashboard}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -164,7 +164,7 @@ function BreakDown(): JSX.Element {
|
||||
Meter metrics data is aggregated over 1 hour period. Please select time
|
||||
range accordingly.
|
||||
<a
|
||||
href="https://signoz.io/docs/cost-meter/overview/#accessing-cost-meter"
|
||||
href="https://signoz.io/docs/cost-meter/overview/#get-started"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
style={{ textDecoration: 'underline' }}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
.emptyMeterSearch {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Empty } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import styles from './EmptyMeterSearch.module.scss';
|
||||
|
||||
interface EmptyMeterSearchProps {
|
||||
hasQueryResult?: boolean;
|
||||
}
|
||||
|
||||
export default function EmptyMeterSearch({
|
||||
hasQueryResult,
|
||||
}: EmptyMeterSearchProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.emptyMeterSearch}>
|
||||
<Empty
|
||||
description={
|
||||
<Typography.Title level={5}>
|
||||
{hasQueryResult
|
||||
? 'No data'
|
||||
: 'Select a metric and run a query to see the results'}
|
||||
</Typography.Title>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -73,34 +73,6 @@
|
||||
margin-top: 10px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.empty-meter-search {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.time-series-view-panel {
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
padding: 8px !important;
|
||||
margin: 8px;
|
||||
}
|
||||
|
||||
.time-series-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(
|
||||
auto-fit,
|
||||
minmax(min(100%, calc(50% - 8px)), 1fr)
|
||||
);
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
height: fit-content;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,22 +85,6 @@
|
||||
padding-bottom: 80px;
|
||||
}
|
||||
|
||||
.meter-time-series-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
|
||||
.builder-units-filter {
|
||||
padding: 0 8px;
|
||||
margin-bottom: 0px !important;
|
||||
|
||||
.builder-units-filter-label {
|
||||
margin-bottom: 0px !important;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dashboards-and-alerts-popover-container {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
|
||||
@@ -35,7 +35,6 @@ function Explorer(): JSX.Element {
|
||||
handleRunQuery,
|
||||
stagedQuery,
|
||||
updateAllQueriesOperators,
|
||||
handleSetQueryData,
|
||||
currentQuery,
|
||||
} = useQueryBuilder();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
@@ -67,15 +66,6 @@ function Explorer(): JSX.Element {
|
||||
[updateAllQueriesOperators],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
handleSetQueryData(0, {
|
||||
...initialQueryMeterWithType.builder.queryData[0],
|
||||
source: 'meter',
|
||||
});
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const exportDefaultQuery = useMemo(
|
||||
() =>
|
||||
updateAllQueriesOperators(
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
.loadingMeter {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
height: 240px;
|
||||
padding: var(--spacing-12) 0;
|
||||
}
|
||||
|
||||
.loadingMeterContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.loadingGif {
|
||||
height: 72px;
|
||||
margin-left: calc(-1 * var(--spacing-12));
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import loadingPlaneUrl from '@/assets/Icons/loading-plane.gif';
|
||||
|
||||
import styles from './MeterLoading.module.scss';
|
||||
|
||||
export default function MeterLoading(): JSX.Element {
|
||||
return (
|
||||
<div className={styles.loadingMeter}>
|
||||
<div className={styles.loadingMeterContent}>
|
||||
<img className={styles.loadingGif} src={loadingPlaneUrl} alt="wait-icon" />
|
||||
<Typography>Retrieving your {DataSource.METRICS}</Typography>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
.meterTimeSeriesContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-5);
|
||||
width: 100%;
|
||||
|
||||
:global(.builder-units-filter) {
|
||||
padding: 0 var(--spacing-4);
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
:global(.builder-units-filter-label) {
|
||||
margin-bottom: 0 !important;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.timeSeriesContainer {
|
||||
gap: var(--spacing-8);
|
||||
width: 100%;
|
||||
height: 50vh;
|
||||
max-height: 50vh;
|
||||
padding-right: 16px;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.timeSeriesViewPanel {
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user