Compare commits

..

11 Commits

Author SHA1 Message Date
Piyush Singariya
9bc8a02f32 fix: return error in enrich function 2026-03-24 13:14:55 +05:30
Piyush Singariya
8084efc162 fix: enrich unspecified fields 2026-03-24 13:01:37 +05:30
Tushar Vats
c95523c747 feat: export traces (#9991)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* feat: added trace export

feat: added types for export

feat: added support for complex queries

fix: added correct open api spec

fix: updated unit tests

fix: type handling logic

fix: improve order by

feat: added integration tests

fix: address comments

* fix: address comments

* fix: removed nits

* fix: go fmt

* fix: rebased main and ran generate cmd

* fix: renamed method

* fix: address comments

* fix: lint error

* fix: lint error

* fix: ran yarn generate:api

* fix: address comments

* fix: address comments

* fix: typo

* fix: better names for functions

* fix: added unit tests, renamed file, added validation

* fix: update integration test

* fix: removed get method for export

* fix: yarn generate:api

* chore: yarn generate:api

* fix: rename file

---------

Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
2026-03-23 21:46:56 +00:00
Pandey
63cb54c5b0 feat(factory): add service state tracking, AwaitHealthy, depends_on, and /healthz (#10671)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* feat(factory): add service state tracking, AwaitHealthy, depends_on, and /healthz endpoint

Add explicit lifecycle state tracking to factory.Registry services
(starting/running/failed) modeled after Guava's ServiceManager. Services
can declare dependencies via NewNamedService(..., dependsOn) which are
validated for unknown refs and cycles at registry creation. AwaitHealthy
blocks until all services reach running state. A /healthz endpoint is
wired through signozapiserver returning 200/503 with per-service state.

* feat(apiserver): move health endpoints to /api/v2/ and register readyz, livez

* refactor(factory): use gonum for cycle detection, return error on cycles, fix test assertions

Replace custom DFS cycle detection with gonum's topo.Sort + TarjanSCC.
Dependency cycles now return an error from NewRegistry instead of being
silently dropped. Use assert for final test assertions and require only
for intermediate setup errors.

* chore: go mod tidy

* refactor(factory): decouple Handler from Registry, wire through Handlers struct

Move Handler implementation to a private handler struct with NewHandler
constructor instead of methods on *Registry. Route handler through the
existing Handlers struct as RegistryHandler. Rename healthz.go to
registry.go in signozapiserver. Fix handler_test.go for new param.

* feat(factory): add ServiceWithHealthy interface, add Healthy to authz, user depends on authz

Add ServiceWithHealthy interface embedding Service + Healthy. NamedService
now delegates Healthy() to the underlying service, eliminating unwrapService.
AuthZ interface requires Healthy(), implemented in both pkg and ee providers.
User service declares dependency on authz via dependsOn.

* test(integration): use /api/v2/healthz for readiness check, log 503 response body

* fix(factory): replace fmt.Errorf with errors.Newf in tests to satisfy linter

* feat: generate openapi spec

* fix(integration): log errors at error level in healthz readiness check

* test(integration): log and assert healthz response in test_setup

* feat(user): implement ServiceWithHealthy for user service

User service signals healthy after successful root user reconciliation
or immediately when disabled. User Service interface now embeds
factory.ServiceWithHealthy.

* fix(factory): reflect service names as strings

* fix(apiserver): document health 503 responses

* feat: generate openapi spec
2026-03-23 18:46:15 +00:00
Vishal Sharma
19e8196472 feat: add onboarding configurations and new datasource (#10680)
Mistral AI, OpenClaw, Claude Agent SDK, and Render, update icon fetching documentation
2026-03-23 18:07:42 +00:00
Ashwin Bhatkal
c360e4498d refactor: move dashboard provider from redux to zustand (#10628)
* chore: move dashboard provider from redux to zustand

* chore: replace useDashboard with useDashboardStore (#10629)

* chore: derive dashboard locked state from global state (#10645)

* chore: remove usage of updatedTimeRef in dashboard provider (#10551)

* chore: removed updatedTimeRef from global store

* chore: removed updatedTimeRef from global store

* chore: remove dashboardQueryRangeCalled from global dashboard state (#10650)

* chore: remove dashboardQueryRangeCalled from global dashboard state

* chore: cleanup dashboard page setup (#10652)

* chore: update tests from dashboard provider migration (#10653)

* chore: update tests from dashboard provider migration

* chore: cleaner local storage variable update (#10656)
2026-03-23 15:14:01 +00:00
Tushar Vats
79b4c2e4b0 fix: added download button in trace page (#10613)
* fix: added download button in trace page

* fix: update unit tests

* fix: revert prepareQueryRangePayloadV5.ts

* fix: addressed comments

* fix: address commnets from aditya

* fix: update tests
2026-03-23 14:49:18 +00:00
swapnil-signoz
866e541e29 refactor: cloud integration store implementation (#10469)
* feat: adding cloud integration type for refactor

* refactor: store interfaces to use local types and error

* feat: adding sql store implementation

* refactor: removing interface check

* feat: adding updated types for cloud integration

* refactor: using struct for map

* refactor: update cloud integration types and module interface

* fix: correct GetService signature and remove shadowed Data field

* feat: implement cloud integration store

* refactor: adding comments and removed wrong code

* refactor: streamlining types

* refactor: add comments for backward compatibility in PostableAgentCheckInRequest

* refactor: update Dashboard struct comments and remove unused fields

* refactor: split upsert store method

* feat: adding integration test

* refactor: clean up types

* refactor: renaming service type to service id

* refactor: using serviceID type

* feat: adding method for service id creation

* refactor: updating store methods

* refactor: clean up

* refactor: clean up

* refactor: review comments

* refactor: clean up

* fix: update error code for service not found

* refactor: returning error only for create methods

* refactor: method chaining formatting
2026-03-23 14:25:32 +00:00
Vinicius Lourenço
b1efb66197 chore(cursor): add rules & skills to help migration (#10405) 2026-03-23 14:03:35 +00:00
Karan Balani
b0eec8132b feat: introduce user_role table (#10664)
* feat: introduce user_role table

* fix: golint and register migrations

* fix: user types and order of update user

* feat: add migration to drop role column from users table

* fix: raw queries pointing to role column in users table

* chore: remove storable user struct and minor other changes

* chore: remove refs of calling vars as storable users

* chore: user 0th role instead of highest

* chore: address pr comments

* chore: rename userrolestore to user_role_store

* chore: return userroles with user in getter where possible

* chore: move user module as user setter

* chore: arrange getter and setter methods

* fix: nil pointer for update user in integration test due to half payload being passed

* chore: update openapi specs

* fix: nil errors without making frontend changes

* fix: empty array check everywhere for user roles array and minor other changes

* fix: imports

* fix: rebase changes

* chore: renaming functions

* chore: simplified getorcreateuser user setter method and call sites

* fix: golint

* fix: remove redundant authz migration, remove fk enforcement for drop migration

* fix: add new event for user activation
2026-03-23 13:36:20 +00:00
Piyush Singariya
efbeca23cf chore: prepend normalize pipeline (#10627)
* fix: prepend normalize pipeline

* fix: don't save normalize pipeline in config
2026-03-23 12:50:27 +00:00
197 changed files with 8063 additions and 4883 deletions

View File

@@ -17,5 +17,7 @@
},
"[html]": {
"editor.defaultFormatter": "vscode.html-language-features"
}
},
"python-envs.defaultEnvManager": "ms-python.python:system",
"python-envs.pythonProjects": []
}

View File

@@ -417,6 +417,18 @@ components:
message:
type: string
type: object
FactoryResponse:
properties:
healthy:
type: boolean
services:
additionalProperties:
items:
type: string
type: array
nullable: true
type: object
type: object
FeaturetypesGettableFeature:
properties:
defaultVariant:
@@ -2026,6 +2038,31 @@ components:
userId:
type: string
type: object
TypesDeprecatedUser:
properties:
createdAt:
format: date-time
type: string
displayName:
type: string
email:
type: string
id:
type: string
isRoot:
type: boolean
orgId:
type: string
role:
type: string
status:
type: string
updatedAt:
format: date-time
type: string
required:
- id
type: object
TypesGettableAPIKey:
properties:
createdAt:
@@ -2222,8 +2259,6 @@ components:
type: boolean
orgId:
type: string
role:
type: string
status:
type: string
updatedAt:
@@ -3016,6 +3051,68 @@ paths:
summary: Update auth domain
tags:
- authdomains
/api/v1/export_raw_data:
post:
deprecated: false
description: This endpoints allows complex query exporting raw data for traces
and logs
operationId: HandleExportRawDataPOST
parameters:
- description: The output format for the export.
in: query
name: format
schema:
default: csv
description: The output format for the export.
enum:
- csv
- jsonl
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/Querybuildertypesv5QueryRangeRequest'
responses:
"200":
content:
application/json:
schema:
type: string
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: Export raw data
tags:
- logs
- traces
/api/v1/fields/keys:
get:
deprecated: false
@@ -5077,7 +5174,7 @@ paths:
properties:
data:
items:
$ref: '#/components/schemas/TypesUser'
$ref: '#/components/schemas/TypesDeprecatedUser'
type: array
status:
type: string
@@ -5175,7 +5272,7 @@ paths:
schema:
properties:
data:
$ref: '#/components/schemas/TypesUser'
$ref: '#/components/schemas/TypesDeprecatedUser'
status:
type: string
required:
@@ -5229,7 +5326,7 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/TypesUser'
$ref: '#/components/schemas/TypesDeprecatedUser'
responses:
"200":
content:
@@ -5237,7 +5334,7 @@ paths:
schema:
properties:
data:
$ref: '#/components/schemas/TypesUser'
$ref: '#/components/schemas/TypesDeprecatedUser'
status:
type: string
required:
@@ -5295,7 +5392,7 @@ paths:
schema:
properties:
data:
$ref: '#/components/schemas/TypesUser'
$ref: '#/components/schemas/TypesDeprecatedUser'
status:
type: string
required:
@@ -5961,6 +6058,70 @@ paths:
summary: Search ingestion keys for workspace
tags:
- gateway
/api/v2/healthz:
get:
operationId: Healthz
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/FactoryResponse'
status:
type: string
required:
- status
- data
type: object
description: OK
"503":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/FactoryResponse'
status:
type: string
required:
- status
- data
type: object
description: Service Unavailable
summary: Health check
tags:
- health
/api/v2/livez:
get:
deprecated: false
description: ""
operationId: Livez
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/FactoryResponse'
status:
type: string
required:
- status
- data
type: object
description: OK
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
summary: Liveness check
tags:
- health
/api/v2/metrics:
get:
deprecated: false
@@ -6637,6 +6798,41 @@ paths:
summary: Update my organization
tags:
- orgs
/api/v2/readyz:
get:
operationId: Readyz
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/FactoryResponse'
status:
type: string
required:
- status
- data
type: object
description: OK
"503":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/FactoryResponse'
status:
type: string
required:
- status
- data
type: object
description: Service Unavailable
summary: Readiness check
tags:
- health
/api/v2/sessions:
delete:
deprecated: false

View File

@@ -123,6 +123,7 @@ if err := router.Handle("/api/v1/things", handler.New(
Description: "This endpoint creates a thing",
Request: new(types.PostableThing),
RequestContentType: "application/json",
RequestQuery: new(types.QueryableThing),
Response: new(types.GettableThing),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
@@ -155,6 +156,8 @@ The `handler.New` function ties the HTTP handler to OpenAPI metadata via `OpenAP
- **Request / RequestContentType**:
- `Request` is a Go type that describes the request body or form.
- `RequestContentType` is usually `"application/json"` or `"application/x-www-form-urlencoded"` (for callbacks like SAML).
- **RequestQuery**:
- `RequestQuery` is a Go type that descirbes query url params.
- **RequestExamples**: An array of `handler.OpenAPIExample` that provide concrete request payloads in the generated spec. See [Adding request examples](#adding-request-examples) below.
- **Response / ResponseContentType**:
- `Response` is the Go type for the successful response payload.

View File

@@ -273,6 +273,7 @@ Options can be simple (direct link) or nested (with another question):
- Place logo files in `public/Logos/`
- Use SVG format
- Reference as `"/Logos/your-logo.svg"`
- **Fetching Icons**: New icons can be easily fetched from [OpenBrand](https://openbrand.sh/). Use the pattern `https://openbrand.sh/?url=<TARGET_URL>`, where `<TARGET_URL>` is the URL-encoded link to the service's website. For example, to get Render's logo, use [https://openbrand.sh/?url=https%3A%2F%2Frender.com](https://openbrand.sh/?url=https%3A%2F%2Frender.com).
- **Optimize new SVGs**: Run any newly downloaded SVGs through an optimizer like [SVGOMG (svgo)](https://svgomg.net/) or use `npx svgo public/Logos/your-logo.svg` to minimise their size before committing.
### 4. Links

View File

@@ -57,6 +57,10 @@ func (provider *provider) Start(ctx context.Context) error {
return provider.openfgaServer.Start(ctx)
}
func (provider *provider) Healthy() <-chan struct{} {
return provider.openfgaServer.Healthy()
}
func (provider *provider) Stop(ctx context.Context) error {
return provider.openfgaServer.Stop(ctx)
}

View File

@@ -16,7 +16,6 @@ type Server struct {
}
func NewOpenfgaServer(ctx context.Context, pkgAuthzService authz.AuthZ) (*Server, error) {
return &Server{
pkgAuthzService: pkgAuthzService,
}, nil
@@ -26,6 +25,10 @@ func (server *Server) Start(ctx context.Context) error {
return server.pkgAuthzService.Start(ctx)
}
func (server *Server) Healthy() <-chan struct{} {
return server.pkgAuthzService.Healthy()
}
func (server *Server) Stop(ctx context.Context) error {
return server.pkgAuthzService.Stop(ctx)
}

View File

@@ -10,6 +10,8 @@ import (
"strings"
"time"
"log/slog"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/modules/user"
@@ -18,7 +20,6 @@ import (
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/gorilla/mux"
"log/slog"
)
type CloudIntegrationConnectionParamsResponse struct {
@@ -126,7 +127,7 @@ func (ah *APIHandler) getOrCreateCloudIntegrationPAT(ctx context.Context, orgId
))
}
allPats, err := ah.Signoz.Modules.User.ListAPIKeys(ctx, orgIdUUID)
allPats, err := ah.Signoz.Modules.UserSetter.ListAPIKeys(ctx, orgIdUUID)
if err != nil {
return "", basemodel.InternalError(fmt.Errorf(
"couldn't list PATs: %w", err,
@@ -154,7 +155,7 @@ func (ah *APIHandler) getOrCreateCloudIntegrationPAT(ctx context.Context, orgId
))
}
err = ah.Signoz.Modules.User.CreateAPIKey(ctx, newPAT)
err = ah.Signoz.Modules.UserSetter.CreateAPIKey(ctx, newPAT)
if err != nil {
return "", basemodel.InternalError(fmt.Errorf(
"couldn't create cloud integration PAT: %w", err,
@@ -169,14 +170,19 @@ func (ah *APIHandler) getOrCreateCloudIntegrationUser(
cloudIntegrationUserName := fmt.Sprintf("%s-integration", cloudProvider)
email := valuer.MustNewEmail(fmt.Sprintf("%s@signoz.io", cloudIntegrationUserName))
cloudIntegrationUser, err := types.NewUser(cloudIntegrationUserName, email, types.RoleViewer, valuer.MustNewUUID(orgId), types.UserStatusActive)
cloudIntegrationUser, err := types.NewUser(cloudIntegrationUserName, email, valuer.MustNewUUID(orgId), types.UserStatusActive)
if err != nil {
return nil, basemodel.InternalError(fmt.Errorf("couldn't create cloud integration user: %w", err))
}
password := types.MustGenerateFactorPassword(cloudIntegrationUser.ID.StringValue())
cloudIntegrationUser, err = ah.Signoz.Modules.User.GetOrCreateUser(ctx, cloudIntegrationUser, user.WithFactorPassword(password))
cloudIntegrationUser, err = ah.Signoz.Modules.UserSetter.GetOrCreateUser(
ctx,
cloudIntegrationUser,
user.WithFactorPassword(password),
user.WithRoleNames([]string{authtypes.SigNozViewerRoleName}),
)
if err != nil {
return nil, basemodel.InternalError(fmt.Errorf("couldn't look for integration user: %w", err))
}

View File

@@ -136,6 +136,7 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
logParsingPipelineController, err := logparsingpipeline.NewLogParsingPipelinesController(
signoz.SQLStore,
integrationsController.GetPipelinesForInstalledIntegrations,
reader,
)
if err != nil {
return nil, err

View File

@@ -0,0 +1,97 @@
---
globs: **/*.store.ts
alwaysApply: false
---
# State Management: React Query, nuqs, Zustand
Use the following stack. Do **not** introduce or recommend Redux or React Context for shared/global state.
## Server state → React Query
- **Use for:** API responses, time-series data, caching, background refetch, retries, stale/refresh.
- **Do not use Redux/Context** to store or mirror data that comes from React Query (e.g. do not dispatch API results into Redux).
- Prefer generated React Query hooks from `frontend/src/api/generated` when available.
- Keep server state in React Query; expose it via hooks that return the query result (and optionally memoized derived values). Do not duplicate it in Redux or Context.
```tsx
// ✅ GOOD: single source of truth from React Query
export function useAppStateHook() {
const { data, isError } = useQuery(...)
const memoizedConfigs = useMemo(() => ({ ... }), [data?.configs])
return { configs: memoizedConfigs, isError, ... }
}
// ❌ BAD: copying React Query result into Redux
dispatch({ type: UPDATE_LATEST_VERSION, payload: queryResponse.data })
```
## URL state → nuqs
- **Use for:** shareable state, filters, time range, selected values, pagination, view state that belongs in the URL.
- **Do not use Redux/Context** for state that should be shareable or reflected in the URL.
- Use [nuqs](https://nuqs.dev/docs/basic-usage) for typed, type-safe URL search params. Avoid ad-hoc `useSearchParams` encoding/decoding.
- Keep URL payload small; respect browser URL length limits (e.g. Chrome ~2k chars). Do not put large datasets or sensitive data in query params.
```tsx
// ✅ GOOD: nuqs for filters / time range / selection
const [timeRange, setTimeRange] = useQueryState('timeRange', parseAsString.withDefault('1h'))
const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1))
// ❌ BAD: Redux/Context for shareable or URL-synced state
const { timeRange } = useContext(SomeContext)
```
## Client state → Zustand
- **Use for:** global/client state, cross-component state, feature flags, complex or large client objects (e.g. dashboard state, query builder state).
- **Do not use Redux or React Context** for global or feature-level client state.
- Prefer small, domain-scoped stores (e.g. DashboardStore, QueryBuilderStore).
### Zustand best practices (align with eslint-plugin-zustand-rules)
- **One store per module.** Do not define multiple `create()` calls in the same file; use one store per module (or compose slices into one store).
- **Always use selectors.** Call the store hook with a selector so only the used slice triggers re-renders. Never use `useStore()` with no selector.
```tsx
// ✅ GOOD: selector — re-renders only when isDashboardLocked changes
const isLocked = useDashboardStore(state => state.isDashboardLocked)
// ❌ BAD: no selector — re-renders on any store change
const state = useDashboardStore()
```
- **Never mutate state directly.** Update only via `set` or `setState` (or `getState()` + `set` for reads). No `state.foo = x` or `state.bears += 1` inside actions.
```tsx
// ✅ GOOD: use set
increment: () => set(state => ({ bears: state.bears + 1 }))
// ❌ BAD: direct mutation
increment: () => { state.bears += 1 }
```
- **State properties before actions.** In the store object, list all state fields first, then action functions.
- **Split into slices when state is large.** If a store has many top-level properties (e.g. more than 510), split into slice factories and combine with one `create()`.
```tsx
// ✅ GOOD: slices for large state
const createBearSlice = set => ({ bears: 0, addBear: () => set(s => ({ bears: s.bears + 1 })) })
const createFishSlice = set => ({ fish: 0, addFish: () => set(s => ({ fish: s.fish + 1 })) })
const useStore = create(set => ({ ...createBearSlice(set), ...createFishSlice(set) }))
```
- **In projects using Zustand:** add `eslint-plugin-zustand-rules` and extend `plugin:zustand-rules/recommended` to enforce these rules automatically.
## Local state → React state only
- **Use useState/useReducer** for: component-local UI state, form inputs, toggles, hover state, data that never leaves the component.
- Do not use Zustand, Redux, or Context for state that is purely local to one component or a small subtree.
## Summary
| State type | Use | Avoid |
|-------------------|------------------|--------------------|
| Server / API | React Query | Redux, Context |
| URL / shareable | nuqs | Redux, Context |
| Global client | Zustand | Redux, Context |
| Local UI | useState/useReducer | Zustand, Redux, Context |

View File

@@ -0,0 +1,150 @@
---
name: migrate-state-management
description: Migrate Redux or React Context to the correct state option (React Query for server state, nuqs for URL/shareable state, Zustand for global client state). Use when refactoring away from Redux/Context, moving state to the right store, or when the user asks to migrate state management.
---
# Migrate State: Redux/Context → React Query, nuqs, Zustand
Do **not** introduce or recommend Redux or React Context. Migrate existing usage to the stack below.
## 1. Classify the state
Before changing code, classify what the state represents:
| If the state is… | Migrate to | Do not use |
|------------------|------------|------------|
| From API / server (versions, configs, fetched lists, time-series) | **React Query** | Redux, Context |
| Shareable via URL (filters, time range, page, selected ids) | **nuqs** | Redux, Context |
| Global/client UI (dashboard lock, query builder, feature flags, large client objects) | **Zustand** | Redux, Context |
| Local to one component (inputs, toggles, hover) | **useState / useReducer** | Zustand, Redux, Context |
If one slice mixes concerns (e.g. Redux has both API data and pagination), split: API → React Query, pagination → nuqs, rest → Zustand or local state.
## 2. Migrate to React Query (server state)
**When:** State comes from or mirrors an API response (e.g. `currentVersion`, `latestVersion`, `configs`, lists).
**Steps:**
1. Find where the data is fetched (existing `useQuery`/API call) and where it is dispatched or set in Context/Redux.
2. Remove the dispatch/set that writes API results into Redux/Context.
3. Expose a single hook that uses the query and returns the same shape consumers expect (use `useMemo` for derived objects like `configs` to avoid unnecessary re-renders).
4. Replace Redux/Context consumption with the new hook. Prefer generated React Query hooks from `frontend/src/api/generated` when available.
5. Configure cache/refetch (e.g. `refetchOnMount: false`, `staleTime`) so behavior matches previous “single source” expectations.
**Before (Redux mirroring React Query):**
```tsx
if (getUserLatestVersionResponse.isFetched && getUserLatestVersionResponse.isSuccess && getUserLatestVersionResponse.data?.payload) {
dispatch({ type: UPDATE_LATEST_VERSION, payload: { latestVersion: getUserLatestVersionResponse.data.payload.tag_name } })
}
```
**After (single source in React Query):**
```tsx
export function useAppStateHook() {
const { data, isError } = useQuery(...)
const memoizedConfigs = useMemo(() => ({ ... }), [data?.configs])
return {
latestVersion: data?.payload?.tag_name,
configs: memoizedConfigs,
isError,
}
}
```
Consumers use `useAppStateHook()` instead of `useSelector` or Context. Do not copy React Query result into Redux or Context.
## 3. Migrate to nuqs (URL / shareable state)
**When:** State should be in the URL: filters, time range, pagination, selected values, view state. Keep payload small (e.g. Chrome ~2k chars); no large datasets or sensitive data.
**Steps:**
1. Identify which Redux/Context fields are shareable or already reflected in the URL (e.g. `currentPage`, `timeRange`, `selectedFilter`).
2. Add nuqs (or use existing): `useQueryState('param', parseAsString.withDefault('…'))` (or `parseAsInteger`, etc.).
3. Replace reads/writes of those fields with nuqs hooks. Use typed parsers; avoid ad-hoc `useSearchParams` encoding/decoding.
4. Remove the same fields from Redux/Context and their reducers/providers.
**Before (Context/Redux):**
```tsx
const { timeRange } = useContext(SomeContext)
const [page, setPage] = useDispatch(...)
```
**After (nuqs):**
```tsx
const [timeRange, setTimeRange] = useQueryState('timeRange', parseAsString.withDefault('1h'))
const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1))
```
## 4. Migrate to Zustand (global client state)
**When:** State is global or cross-component client state: feature flags, dashboard state, query builder state, complex/large client objects (e.g. up to ~1.52MB). Not for server cache or local-only UI.
**Steps:**
1. Create one store per domain (e.g. `DashboardStore`, `QueryBuilderStore`). One `create()` per module; for large state use slice factories and combine.
2. Put state properties first, then actions. Use `set` (or `setState` / `getState()` + `set`) for updates; never mutate state directly.
3. Replace Context/Redux consumption with the store hook **and a selector** so only the used slice triggers re-renders.
4. Remove the old Context provider / Redux slice and related dispatches.
**Selector (required):**
```tsx
const isLocked = useDashboardStore(state => state.isDashboardLocked)
```
Never use `useStore()` with no selector. Never do `state.foo = x` inside actions; use `set(state => ({ ... }))`.
**Before (Context/Redux):**
```tsx
const { isDashboardLocked, setLocked } = useContext(DashboardContext)
```
**After (Zustand):**
```tsx
const isLocked = useDashboardStore(state => state.isDashboardLocked)
const setLocked = useDashboardStore(state => state.setLocked)
```
For large stores (many top-level fields), split into slices and combine:
```tsx
const createBearSlice = set => ({ bears: 0, addBear: () => set(s => ({ bears: s.bears + 1 })) })
const useStore = create(set => ({ ...createBearSlice(set), ...createFishSlice(set) }))
```
Add `eslint-plugin-zustand-rules` with `plugin:zustand-rules/recommended` to enforce selectors and no direct mutation.
## 5. Migrate to local state (useState / useReducer)
**When:** State is used only inside one component or a small subtree (form inputs, toggles, hover, panel selection). No URL sync, no cross-feature sharing.
**Steps:**
1. Move the state into the component that owns it (or the smallest common parent).
2. Use `useState` or `useReducer` (useReducer when multiple related fields change together).
3. Remove from Redux/Context and any provider/slice.
Do not use Zustand, Redux, or Context for purely local UI state.
## 6. Migration checklist
- [ ] Classify each piece of state (server / URL / global client / local).
- [ ] Server state: move to React Query; expose via hook; remove Redux/Context mirroring.
- [ ] URL state: move to nuqs; remove from Redux/Context; keep URL payload small.
- [ ] Global client state: move to Zustand with selectors and immutable updates; one store per domain.
- [ ] Local state: move to useState/useReducer in the owning component.
- [ ] Remove old Redux slices / Context providers and all dispatches/consumers for migrated state.
- [ ] Do not duplicate the same data in multiple places (e.g. React Query + Redux).
## Additional resources
- Project rule: [.cursor/rules/state-management.mdc](../../rules/state-management.mdc)
- Detailed patterns and rationale: [reference.md](reference.md)

View File

@@ -0,0 +1,50 @@
# State migration reference
## Why migrate
- **Context:** Re-renders all consumers on any change; no granular subscriptions; becomes brittle at scale.
- **Redux:** Heavy boilerplate (actions, reducers, selectors, Provider); slower onboarding; often used to mirror React Query or URL state.
- **Goal:** Fewer mechanisms, domain isolation, granular subscriptions, single source of truth per state type.
## React Query migration (server state)
Typical anti-pattern: API is called via React Query, then result is dispatched to Redux. Flow becomes: Component → useQueries → API → dispatch → Reducer → Redux state → useSelector.
Correct flow: Component → useQuery (or custom hook wrapping it) → same component reads from hook. No Redux/Context in between.
- Prefer generated hooks from `frontend/src/api/generated`.
- For “app state” that is just API data (versions, configs), one hook that returns `{ ...data, configs: useMemo(...) }` is enough. No selectors needed for plain data; useMemo only where the value is used as dependency (e.g. in useState).
- Set `staleTime` / `refetchOnMount` etc. so refetch behavior matches previous expectations.
## nuqs migration (URL state)
Redux/Context often hold pagination, filters, time range, selected values that are shareable. Those belong in the URL.
- Use [nuqs](https://nuqs.dev/docs/basic-usage) for typed search params. Avoid ad-hoc `useSearchParams` + manual encoding.
- Browser limits: Chrome ~2k chars practical; keep payload small; no large datasets or secrets in query params.
- If the app uses TanStack Router, search params can be handled there; otherwise nuqs is the standard.
## Zustand migration (client state)
- One store per domain (e.g. DashboardStore, QueryBuilderStore). Multiple `create()` in one file is disallowed; use one store or composed slices.
- Always use a selector: `useStore(s => s.field)` so only that field drives re-renders.
- Never mutate: update only via `set(state => ({ ... }))` or `setState` / `getState()` + `set`.
- State properties first, then actions. For 510+ top-level fields, split into slice factories and combine with one `create()`.
- Large client objects: Zustand is for “large” in the ~1.52MB range; above that, optimize at API/store design.
- Testing: no Provider; stores are plain functions; easy to reset and mock.
## What not to use
- **Redux / Context** for new or migrated shared/global state.
- **Redux / Context** to store or mirror React Query results.
- **Redux / Context** for state that should live in the URL (use nuqs).
- **Zustand / Redux / Context** for component-local UI (use useState/useReducer).
## Summary table
| State type | Use | Avoid |
|-------------|--------------------|-----------------|
| Server/API | React Query | Redux, Context |
| URL/shareable | nuqs | Redux, Context |
| Global client | Zustand | Redux, Context |
| Local UI | useState/useReducer | Zustand, Redux, Context |

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="#fa520f" viewBox="0 0 24 24"><title>Mistral AI</title><path d="M17.143 3.429v3.428h-3.429v3.429h-3.428V6.857H6.857V3.43H3.43v13.714H0v3.428h10.286v-3.428H6.857v-3.429h3.429v3.429h3.429v-3.429h3.428v3.429h-3.428v3.428H24v-3.428h-3.43V3.429z"/></svg>

After

Width:  |  Height:  |  Size: 294 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 120 120"><defs><linearGradient id="a" x1="0%" x2="100%" y1="0%" y2="100%"><stop offset="0%" stop-color="#ff4d4d"/><stop offset="100%" stop-color="#991b1b"/></linearGradient></defs><path fill="url(#a)" d="M60 10c-30 0-45 25-45 45s15 40 30 45v10h10v-10s5 2 10 0v10h10v-10c15-5 30-25 30-45S90 10 60 10"/><path fill="url(#a)" d="M20 45C5 40 0 50 5 60s15 5 20-5c3-7 0-10-5-10"/><path fill="url(#a)" d="M100 45c15-5 20 5 15 15s-15 5-20-5c-3-7 0-10 5-10"/><path stroke="#ff4d4d" stroke-linecap="round" stroke-width="3" d="M45 15Q35 5 30 8M75 15Q85 5 90 8"/><circle cx="45" cy="35" r="6" fill="#050810"/><circle cx="75" cy="35" r="6" fill="#050810"/><circle cx="46" cy="34" r="2.5" fill="#00e5cc"/><circle cx="76" cy="34" r="2.5" fill="#00e5cc"/></svg>

After

Width:  |  Height:  |  Size: 809 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>Render</title><path d="M18.263.007c-3.121-.147-5.744 2.109-6.192 5.082-.018.138-.045.272-.067.405-.696 3.703-3.936 6.507-7.827 6.507a7.9 7.9 0 0 1-3.825-.979.202.202 0 0 0-.302.178V24H12v-8.999c0-1.656 1.338-3 2.987-3h2.988c3.382 0 6.103-2.817 5.97-6.244-.12-3.084-2.61-5.603-5.682-5.75"/></svg>

After

Width:  |  Height:  |  Size: 362 B

View File

@@ -0,0 +1,250 @@
/**
* ! Do not edit manually
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'yarn generate:api'
* SigNoz
*/
import type {
InvalidateOptions,
QueryClient,
QueryFunction,
QueryKey,
UseQueryOptions,
UseQueryResult,
} from 'react-query';
import { useQuery } from 'react-query';
import type { ErrorType } from '../../../generatedAPIInstance';
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type {
Healthz200,
Healthz503,
Livez200,
Readyz200,
Readyz503,
RenderErrorResponseDTO,
} from '../sigNoz.schemas';
/**
* @summary Health check
*/
export const healthz = (signal?: AbortSignal) => {
return GeneratedAPIInstance<Healthz200>({
url: `/api/v2/healthz`,
method: 'GET',
signal,
});
};
export const getHealthzQueryKey = () => {
return [`/api/v2/healthz`] as const;
};
export const getHealthzQueryOptions = <
TData = Awaited<ReturnType<typeof healthz>>,
TError = ErrorType<Healthz503>
>(options?: {
query?: UseQueryOptions<Awaited<ReturnType<typeof healthz>>, TError, TData>;
}) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getHealthzQueryKey();
const queryFn: QueryFunction<Awaited<ReturnType<typeof healthz>>> = ({
signal,
}) => healthz(signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof healthz>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type HealthzQueryResult = NonNullable<
Awaited<ReturnType<typeof healthz>>
>;
export type HealthzQueryError = ErrorType<Healthz503>;
/**
* @summary Health check
*/
export function useHealthz<
TData = Awaited<ReturnType<typeof healthz>>,
TError = ErrorType<Healthz503>
>(options?: {
query?: UseQueryOptions<Awaited<ReturnType<typeof healthz>>, TError, TData>;
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getHealthzQueryOptions(options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
query.queryKey = queryOptions.queryKey;
return query;
}
/**
* @summary Health check
*/
export const invalidateHealthz = async (
queryClient: QueryClient,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getHealthzQueryKey() },
options,
);
return queryClient;
};
/**
* @summary Liveness check
*/
export const livez = (signal?: AbortSignal) => {
return GeneratedAPIInstance<Livez200>({
url: `/api/v2/livez`,
method: 'GET',
signal,
});
};
export const getLivezQueryKey = () => {
return [`/api/v2/livez`] as const;
};
export const getLivezQueryOptions = <
TData = Awaited<ReturnType<typeof livez>>,
TError = ErrorType<RenderErrorResponseDTO>
>(options?: {
query?: UseQueryOptions<Awaited<ReturnType<typeof livez>>, TError, TData>;
}) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getLivezQueryKey();
const queryFn: QueryFunction<Awaited<ReturnType<typeof livez>>> = ({
signal,
}) => livez(signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof livez>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type LivezQueryResult = NonNullable<Awaited<ReturnType<typeof livez>>>;
export type LivezQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Liveness check
*/
export function useLivez<
TData = Awaited<ReturnType<typeof livez>>,
TError = ErrorType<RenderErrorResponseDTO>
>(options?: {
query?: UseQueryOptions<Awaited<ReturnType<typeof livez>>, TError, TData>;
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getLivezQueryOptions(options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
query.queryKey = queryOptions.queryKey;
return query;
}
/**
* @summary Liveness check
*/
export const invalidateLivez = async (
queryClient: QueryClient,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries({ queryKey: getLivezQueryKey() }, options);
return queryClient;
};
/**
* @summary Readiness check
*/
export const readyz = (signal?: AbortSignal) => {
return GeneratedAPIInstance<Readyz200>({
url: `/api/v2/readyz`,
method: 'GET',
signal,
});
};
export const getReadyzQueryKey = () => {
return [`/api/v2/readyz`] as const;
};
export const getReadyzQueryOptions = <
TData = Awaited<ReturnType<typeof readyz>>,
TError = ErrorType<Readyz503>
>(options?: {
query?: UseQueryOptions<Awaited<ReturnType<typeof readyz>>, TError, TData>;
}) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getReadyzQueryKey();
const queryFn: QueryFunction<Awaited<ReturnType<typeof readyz>>> = ({
signal,
}) => readyz(signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof readyz>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type ReadyzQueryResult = NonNullable<Awaited<ReturnType<typeof readyz>>>;
export type ReadyzQueryError = ErrorType<Readyz503>;
/**
* @summary Readiness check
*/
export function useReadyz<
TData = Awaited<ReturnType<typeof readyz>>,
TError = ErrorType<Readyz503>
>(options?: {
query?: UseQueryOptions<Awaited<ReturnType<typeof readyz>>, TError, TData>;
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getReadyzQueryOptions(options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
query.queryKey = queryOptions.queryKey;
return query;
}
/**
* @summary Readiness check
*/
export const invalidateReadyz = async (
queryClient: QueryClient,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getReadyzQueryKey() },
options,
);
return queryClient;
};

View File

@@ -20,11 +20,113 @@ import { useMutation, useQuery } from 'react-query';
import type { BodyType, ErrorType } from '../../../generatedAPIInstance';
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type {
HandleExportRawDataPOSTParams,
ListPromotedAndIndexedPaths200,
PromotetypesPromotePathDTO,
Querybuildertypesv5QueryRangeRequestDTO,
RenderErrorResponseDTO,
} from '../sigNoz.schemas';
/**
* This endpoints allows complex query exporting raw data for traces and logs
* @summary Export raw data
*/
export const handleExportRawDataPOST = (
querybuildertypesv5QueryRangeRequestDTO: BodyType<Querybuildertypesv5QueryRangeRequestDTO>,
params?: HandleExportRawDataPOSTParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<string>({
url: `/api/v1/export_raw_data`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: querybuildertypesv5QueryRangeRequestDTO,
params,
signal,
});
};
export const getHandleExportRawDataPOSTMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof handleExportRawDataPOST>>,
TError,
{
data: BodyType<Querybuildertypesv5QueryRangeRequestDTO>;
params?: HandleExportRawDataPOSTParams;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof handleExportRawDataPOST>>,
TError,
{
data: BodyType<Querybuildertypesv5QueryRangeRequestDTO>;
params?: HandleExportRawDataPOSTParams;
},
TContext
> => {
const mutationKey = ['handleExportRawDataPOST'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof handleExportRawDataPOST>>,
{
data: BodyType<Querybuildertypesv5QueryRangeRequestDTO>;
params?: HandleExportRawDataPOSTParams;
}
> = (props) => {
const { data, params } = props ?? {};
return handleExportRawDataPOST(data, params);
};
return { mutationFn, ...mutationOptions };
};
export type HandleExportRawDataPOSTMutationResult = NonNullable<
Awaited<ReturnType<typeof handleExportRawDataPOST>>
>;
export type HandleExportRawDataPOSTMutationBody = BodyType<Querybuildertypesv5QueryRangeRequestDTO>;
export type HandleExportRawDataPOSTMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Export raw data
*/
export const useHandleExportRawDataPOST = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof handleExportRawDataPOST>>,
TError,
{
data: BodyType<Querybuildertypesv5QueryRangeRequestDTO>;
params?: HandleExportRawDataPOSTParams;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof handleExportRawDataPOST>>,
TError,
{
data: BodyType<Querybuildertypesv5QueryRangeRequestDTO>;
params?: HandleExportRawDataPOSTParams;
},
TContext
> => {
const mutationOptions = getHandleExportRawDataPOSTMutationOptions(options);
return useMutation(mutationOptions);
};
/**
* This endpoints promotes and indexes paths
* @summary Promote and index paths

View File

@@ -543,6 +543,23 @@ export interface ErrorsResponseerroradditionalDTO {
message?: string;
}
/**
* @nullable
*/
export type FactoryResponseDTOServices = { [key: string]: string[] } | null;
export interface FactoryResponseDTO {
/**
* @type boolean
*/
healthy?: boolean;
/**
* @type object
* @nullable true
*/
services?: FactoryResponseDTOServices;
}
/**
* @nullable
*/
@@ -2384,6 +2401,47 @@ export interface TypesChangePasswordRequestDTO {
userId?: string;
}
export interface TypesDeprecatedUserDTO {
/**
* @type string
* @format date-time
*/
createdAt?: Date;
/**
* @type string
*/
displayName?: string;
/**
* @type string
*/
email?: string;
/**
* @type string
*/
id: string;
/**
* @type boolean
*/
isRoot?: boolean;
/**
* @type string
*/
orgId?: string;
/**
* @type string
*/
role?: string;
/**
* @type string
*/
status?: string;
/**
* @type string
* @format date-time
*/
updatedAt?: Date;
}
export interface TypesGettableAPIKeyDTO {
/**
* @type string
@@ -2682,10 +2740,6 @@ export interface TypesUserDTO {
* @type string
*/
orgId?: string;
/**
* @type string
*/
role?: string;
/**
* @type string
*/
@@ -2905,6 +2959,19 @@ export type DeleteAuthDomainPathParameters = {
export type UpdateAuthDomainPathParameters = {
id: string;
};
export type HandleExportRawDataPOSTParams = {
/**
* @enum csv,jsonl
* @type string
* @description The output format for the export.
*/
format?: HandleExportRawDataPOSTFormat;
};
export enum HandleExportRawDataPOSTFormat {
csv = 'csv',
jsonl = 'jsonl',
}
export type GetFieldsKeysParams = {
/**
* @description undefined
@@ -3266,7 +3333,7 @@ export type ListUsers200 = {
/**
* @type array
*/
data: TypesUserDTO[];
data: TypesDeprecatedUserDTO[];
/**
* @type string
*/
@@ -3280,7 +3347,7 @@ export type GetUserPathParameters = {
id: string;
};
export type GetUser200 = {
data: TypesUserDTO;
data: TypesDeprecatedUserDTO;
/**
* @type string
*/
@@ -3291,7 +3358,7 @@ export type UpdateUserPathParameters = {
id: string;
};
export type UpdateUser200 = {
data: TypesUserDTO;
data: TypesDeprecatedUserDTO;
/**
* @type string
*/
@@ -3299,7 +3366,7 @@ export type UpdateUser200 = {
};
export type GetMyUser200 = {
data: TypesUserDTO;
data: TypesDeprecatedUserDTO;
/**
* @type string
*/
@@ -3420,6 +3487,30 @@ export type SearchIngestionKeys200 = {
status: string;
};
export type Healthz200 = {
data: FactoryResponseDTO;
/**
* @type string
*/
status: string;
};
export type Healthz503 = {
data: FactoryResponseDTO;
/**
* @type string
*/
status: string;
};
export type Livez200 = {
data: FactoryResponseDTO;
/**
* @type string
*/
status: string;
};
export type ListMetricsParams = {
/**
* @type integer
@@ -3555,6 +3646,22 @@ export type GetMyOrganization200 = {
status: string;
};
export type Readyz200 = {
data: FactoryResponseDTO;
/**
* @type string
*/
status: string;
};
export type Readyz503 = {
data: FactoryResponseDTO;
/**
* @type string
*/
status: string;
};
export type GetSessionContext200 = {
data: AuthtypesSessionContextDTO;
/**

View File

@@ -34,13 +34,13 @@ import type {
RenderErrorResponseDTO,
RevokeAPIKeyPathParameters,
TypesChangePasswordRequestDTO,
TypesDeprecatedUserDTO,
TypesPostableAPIKeyDTO,
TypesPostableBulkInviteRequestDTO,
TypesPostableForgotPasswordDTO,
TypesPostableInviteDTO,
TypesPostableResetPasswordDTO,
TypesStorableAPIKeyDTO,
TypesUserDTO,
UpdateAPIKeyPathParameters,
UpdateUser200,
UpdateUserPathParameters,
@@ -1093,13 +1093,13 @@ export const invalidateGetUser = async (
*/
export const updateUser = (
{ id }: UpdateUserPathParameters,
typesUserDTO: BodyType<TypesUserDTO>,
typesDeprecatedUserDTO: BodyType<TypesDeprecatedUserDTO>,
) => {
return GeneratedAPIInstance<UpdateUser200>({
url: `/api/v1/user/${id}`,
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
data: typesUserDTO,
data: typesDeprecatedUserDTO,
});
};
@@ -1110,13 +1110,19 @@ export const getUpdateUserMutationOptions = <
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateUser>>,
TError,
{ pathParams: UpdateUserPathParameters; data: BodyType<TypesUserDTO> },
{
pathParams: UpdateUserPathParameters;
data: BodyType<TypesDeprecatedUserDTO>;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof updateUser>>,
TError,
{ pathParams: UpdateUserPathParameters; data: BodyType<TypesUserDTO> },
{
pathParams: UpdateUserPathParameters;
data: BodyType<TypesDeprecatedUserDTO>;
},
TContext
> => {
const mutationKey = ['updateUser'];
@@ -1130,7 +1136,10 @@ export const getUpdateUserMutationOptions = <
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof updateUser>>,
{ pathParams: UpdateUserPathParameters; data: BodyType<TypesUserDTO> }
{
pathParams: UpdateUserPathParameters;
data: BodyType<TypesDeprecatedUserDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
@@ -1143,7 +1152,7 @@ export const getUpdateUserMutationOptions = <
export type UpdateUserMutationResult = NonNullable<
Awaited<ReturnType<typeof updateUser>>
>;
export type UpdateUserMutationBody = BodyType<TypesUserDTO>;
export type UpdateUserMutationBody = BodyType<TypesDeprecatedUserDTO>;
export type UpdateUserMutationError = ErrorType<RenderErrorResponseDTO>;
/**
@@ -1156,13 +1165,19 @@ export const useUpdateUser = <
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateUser>>,
TError,
{ pathParams: UpdateUserPathParameters; data: BodyType<TypesUserDTO> },
{
pathParams: UpdateUserPathParameters;
data: BodyType<TypesDeprecatedUserDTO>;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof updateUser>>,
TError,
{ pathParams: UpdateUserPathParameters; data: BodyType<TypesUserDTO> },
{
pathParams: UpdateUserPathParameters;
data: BodyType<TypesDeprecatedUserDTO>;
},
TContext
> => {
const mutationOptions = getUpdateUserMutationOptions(options);

View File

@@ -8,42 +8,32 @@ export const downloadExportData = async (
props: ExportRawDataProps,
): Promise<void> => {
try {
const queryParams = new URLSearchParams();
queryParams.append('start', String(props.start));
queryParams.append('end', String(props.end));
queryParams.append('filter', props.filter);
props.columns.forEach((col) => {
queryParams.append('columns', col);
});
queryParams.append('order_by', props.orderBy);
queryParams.append('limit', String(props.limit));
queryParams.append('format', props.format);
const response = await axios.get<Blob>(`export_raw_data?${queryParams}`, {
responseType: 'blob', // Important: tell axios to handle response as blob
decompress: true, // Enable automatic decompression
headers: {
Accept: 'application/octet-stream', // Tell server we expect binary data
const response = await axios.post<Blob>(
`export_raw_data?format=${encodeURIComponent(props.format)}`,
props.body,
{
responseType: 'blob',
decompress: true,
headers: {
Accept: 'application/octet-stream',
'Content-Type': 'application/json',
},
timeout: 0,
},
timeout: 0,
});
);
// Only proceed if the response status is 200
if (response.status !== 200) {
throw new Error(
`Failed to download data: server returned status ${response.status}`,
);
}
// Create blob URL from response data
const blob = new Blob([response.data], { type: 'application/octet-stream' });
const url = window.URL.createObjectURL(blob);
// Create and configure download link
const link = document.createElement('a');
link.href = url;
// Get filename from Content-Disposition header or generate timestamped default
const filename =
response.headers['content-disposition']
?.split('filename=')[1]
@@ -51,7 +41,6 @@ export const downloadExportData = async (
link.setAttribute('download', filename);
// Trigger download
document.body.appendChild(link);
link.click();
link.remove();

View File

@@ -7,7 +7,7 @@ import ROUTES from 'constants/routes';
import useUpdatedQuery from 'container/GridCardLayout/useResolveQuery';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useNotifications } from 'hooks/useNotifications';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import { AppState } from 'store/reducers';
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource, MetricAggregateOperator } from 'types/common/queryBuilder';
@@ -79,7 +79,7 @@ export function useNavigateToExplorer(): (
);
const { getUpdatedQuery } = useUpdatedQuery();
const { selectedDashboard } = useDashboard();
const { selectedDashboard } = useDashboardStore();
const { notifications } = useNotifications();
return useCallback(

View File

@@ -1,11 +1,11 @@
.logs-download-popover {
.download-popover {
.ant-popover-inner {
border-radius: 4px;
border: 1px solid var(--bg-slate-400);
border: 1px solid var(--l3-border);
background: linear-gradient(
139deg,
var(--bg-ink-400) 0%,
var(--bg-ink-500) 98.68%
var(--l2-background) 0%,
var(--l3-background) 98.68%
);
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(20px);
@@ -19,7 +19,7 @@
.title {
display: flex;
color: var(--bg-slate-50);
color: var(--l3-foreground);
font-family: Inter;
font-size: 11px;
font-style: normal;
@@ -38,7 +38,7 @@
flex-direction: column;
:global(.ant-radio-wrapper) {
color: var(--bg-vanilla-400);
color: var(--foreground);
font-family: Inter;
font-size: 13px;
}
@@ -46,7 +46,7 @@
.horizontal-line {
height: 1px;
background: var(--bg-slate-400);
background: var(--l3-border);
}
.export-button {
@@ -59,27 +59,27 @@
}
.lightMode {
.logs-download-popover {
.download-popover {
.ant-popover-inner {
border: 1px solid var(--bg-vanilla-300);
border: 1px solid var(--l2-border);
background: linear-gradient(
139deg,
var(--bg-vanilla-100) 0%,
var(--bg-vanilla-300) 98.68%
var(--background) 0%,
var(--l1-background) 98.68%
);
box-shadow: 4px 10px 16px 2px rgba(255, 255, 255, 0.2);
}
.export-options-container {
.title {
color: var(--bg-ink-200);
color: var(--l2-foreground);
}
:global(.ant-radio-wrapper) {
color: var(--bg-ink-400);
color: var(--foreground);
}
.horizontal-line {
background: var(--bg-vanilla-300);
background: var(--l2-border);
}
}
}

View File

@@ -0,0 +1,331 @@
// eslint-disable-next-line no-restricted-imports
import { Provider } from 'react-redux';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { message } from 'antd';
import configureStore from 'redux-mock-store';
import store from 'store';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { DataSource, StringOperators } from 'types/common/queryBuilder';
import '@testing-library/jest-dom';
import { DownloadFormats, DownloadRowCounts } from './constants';
import DownloadOptionsMenu from './DownloadOptionsMenu';
const mockDownloadExportData = jest.fn().mockResolvedValue(undefined);
jest.mock('api/v1/download/downloadExportData', () => ({
downloadExportData: (...args: any[]): any => mockDownloadExportData(...args),
default: (...args: any[]): any => mockDownloadExportData(...args),
}));
jest.mock('antd', () => {
const actual = jest.requireActual('antd');
return {
...actual,
message: {
success: jest.fn(),
error: jest.fn(),
},
};
});
const mockUseQueryBuilder = jest.fn();
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: (): any => mockUseQueryBuilder(),
}));
const mockStore = configureStore([]);
const createMockReduxStore = (): any =>
mockStore({
...store.getState(),
});
const createMockStagedQuery = (dataSource: DataSource): Query => ({
id: 'test-query-id',
queryType: EQueryType.QUERY_BUILDER,
builder: {
queryData: [
{
queryName: 'A',
dataSource,
aggregateOperator: StringOperators.NOOP,
aggregateAttribute: {
id: '',
dataType: '' as any,
key: '',
type: '',
},
aggregations: [{ expression: 'count()' }],
functions: [],
filter: { expression: 'status = 200' },
filters: { items: [], op: 'AND' },
groupBy: [],
expression: 'A',
disabled: false,
having: { expression: '' } as any,
limit: null,
stepInterval: null,
orderBy: [{ columnName: 'timestamp', order: 'desc' }],
legend: '',
selectColumns: [],
},
],
queryFormulas: [],
queryTraceOperator: [],
},
promql: [],
clickhouse_sql: [],
});
const renderWithStore = (dataSource: DataSource): void => {
const mockReduxStore = createMockReduxStore();
render(
<Provider store={mockReduxStore}>
<DownloadOptionsMenu dataSource={dataSource} />
</Provider>,
);
};
describe.each([
[DataSource.LOGS, 'logs'],
[DataSource.TRACES, 'traces'],
])('DownloadOptionsMenu for %s', (dataSource, signal) => {
const testId = `periscope-btn-download-${dataSource}`;
beforeEach(() => {
mockDownloadExportData.mockReset().mockResolvedValue(undefined);
(message.success as jest.Mock).mockReset();
(message.error as jest.Mock).mockReset();
mockUseQueryBuilder.mockReturnValue({
stagedQuery: createMockStagedQuery(dataSource),
});
});
it('renders download button', () => {
renderWithStore(dataSource);
const button = screen.getByTestId(testId);
expect(button).toBeInTheDocument();
expect(button).toHaveClass('periscope-btn', 'ghost');
});
it('shows popover with export options when download button is clicked', () => {
renderWithStore(dataSource);
fireEvent.click(screen.getByTestId(testId));
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(screen.getByText('FORMAT')).toBeInTheDocument();
expect(screen.getByText('Number of Rows')).toBeInTheDocument();
expect(screen.getByText('Columns')).toBeInTheDocument();
});
it('allows changing export format', () => {
renderWithStore(dataSource);
fireEvent.click(screen.getByTestId(testId));
const csvRadio = screen.getByRole('radio', { name: 'csv' });
const jsonlRadio = screen.getByRole('radio', { name: 'jsonl' });
expect(csvRadio).toBeChecked();
fireEvent.click(jsonlRadio);
expect(jsonlRadio).toBeChecked();
expect(csvRadio).not.toBeChecked();
});
it('allows changing row limit', () => {
renderWithStore(dataSource);
fireEvent.click(screen.getByTestId(testId));
const tenKRadio = screen.getByRole('radio', { name: '10k' });
const fiftyKRadio = screen.getByRole('radio', { name: '50k' });
expect(tenKRadio).toBeChecked();
fireEvent.click(fiftyKRadio);
expect(fiftyKRadio).toBeChecked();
expect(tenKRadio).not.toBeChecked();
});
it('allows changing columns scope', () => {
renderWithStore(dataSource);
fireEvent.click(screen.getByTestId(testId));
const allColumnsRadio = screen.getByRole('radio', { name: 'All' });
const selectedColumnsRadio = screen.getByRole('radio', { name: 'Selected' });
expect(allColumnsRadio).toBeChecked();
fireEvent.click(selectedColumnsRadio);
expect(selectedColumnsRadio).toBeChecked();
expect(allColumnsRadio).not.toBeChecked();
});
it('calls downloadExportData with correct format and POST body', async () => {
renderWithStore(dataSource);
fireEvent.click(screen.getByTestId(testId));
fireEvent.click(screen.getByText('Export'));
await waitFor(() => {
expect(mockDownloadExportData).toHaveBeenCalledTimes(1);
const callArgs = mockDownloadExportData.mock.calls[0][0];
expect(callArgs.format).toBe(DownloadFormats.CSV);
expect(callArgs.body).toBeDefined();
expect(callArgs.body.requestType).toBe('raw');
expect(callArgs.body.compositeQuery.queries).toHaveLength(1);
const query = callArgs.body.compositeQuery.queries[0];
expect(query.type).toBe('builder_query');
expect(query.spec.signal).toBe(signal);
expect(query.spec.limit).toBe(DownloadRowCounts.TEN_K);
});
});
it('clears groupBy and having in the export payload', async () => {
const mockQuery = createMockStagedQuery(dataSource);
mockQuery.builder.queryData[0].groupBy = [
{ key: 'service', dataType: 'string' as any, type: '' },
];
mockQuery.builder.queryData[0].having = {
expression: 'count() > 10',
} as any;
mockUseQueryBuilder.mockReturnValue({ stagedQuery: mockQuery });
renderWithStore(dataSource);
fireEvent.click(screen.getByTestId(testId));
fireEvent.click(screen.getByText('Export'));
await waitFor(() => {
expect(mockDownloadExportData).toHaveBeenCalledTimes(1);
const callArgs = mockDownloadExportData.mock.calls[0][0];
const query = callArgs.body.compositeQuery.queries[0];
expect(query.spec.groupBy).toBeUndefined();
expect(query.spec.having).toEqual({ expression: '' });
});
});
it('keeps selectColumns when column scope is Selected', async () => {
const mockQuery = createMockStagedQuery(dataSource);
mockQuery.builder.queryData[0].selectColumns = [
{ name: 'http.status', fieldDataType: 'int64', fieldContext: 'attribute' },
] as any;
mockUseQueryBuilder.mockReturnValue({ stagedQuery: mockQuery });
renderWithStore(dataSource);
fireEvent.click(screen.getByTestId(testId));
fireEvent.click(screen.getByRole('radio', { name: 'Selected' }));
fireEvent.click(screen.getByText('Export'));
await waitFor(() => {
expect(mockDownloadExportData).toHaveBeenCalledTimes(1);
const callArgs = mockDownloadExportData.mock.calls[0][0];
const query = callArgs.body.compositeQuery.queries[0];
expect(query.spec.selectFields).toEqual([
expect.objectContaining({
name: 'http.status',
fieldDataType: 'int64',
}),
]);
});
});
it('sends no selectFields when column scope is All', async () => {
renderWithStore(dataSource);
fireEvent.click(screen.getByTestId(testId));
fireEvent.click(screen.getByRole('radio', { name: 'All' }));
fireEvent.click(screen.getByText('Export'));
await waitFor(() => {
expect(mockDownloadExportData).toHaveBeenCalledTimes(1);
const callArgs = mockDownloadExportData.mock.calls[0][0];
const query = callArgs.body.compositeQuery.queries[0];
expect(query.spec.selectFields).toBeUndefined();
});
});
it('handles successful export with success message', async () => {
renderWithStore(dataSource);
fireEvent.click(screen.getByTestId(testId));
fireEvent.click(screen.getByText('Export'));
await waitFor(() => {
expect(message.success).toHaveBeenCalledWith(
'Export completed successfully',
);
});
});
it('handles export failure with error message', async () => {
mockDownloadExportData.mockRejectedValueOnce(new Error('Server error'));
renderWithStore(dataSource);
fireEvent.click(screen.getByTestId(testId));
fireEvent.click(screen.getByText('Export'));
await waitFor(() => {
expect(message.error).toHaveBeenCalledWith(
`Failed to export ${dataSource}. Please try again.`,
);
});
});
it('handles UI state correctly during export process', async () => {
let resolveDownload: () => void;
mockDownloadExportData.mockImplementationOnce(
() =>
new Promise<void>((resolve) => {
resolveDownload = resolve;
}),
);
renderWithStore(dataSource);
fireEvent.click(screen.getByTestId(testId));
expect(screen.getByRole('dialog')).toBeInTheDocument();
fireEvent.click(screen.getByText('Export'));
expect(screen.getByTestId(testId)).toBeDisabled();
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
resolveDownload!();
await waitFor(() => {
expect(screen.getByTestId(testId)).not.toBeDisabled();
});
});
});
describe('DownloadOptionsMenu for traces with queryTraceOperator', () => {
const dataSource = DataSource.TRACES;
const testId = `periscope-btn-download-${dataSource}`;
beforeEach(() => {
mockDownloadExportData.mockReset().mockResolvedValue(undefined);
(message.success as jest.Mock).mockReset();
});
it('applies limit and clears groupBy on queryTraceOperator entries', async () => {
const query = createMockStagedQuery(dataSource);
query.builder.queryTraceOperator = [
{
...query.builder.queryData[0],
queryName: 'TraceOp1',
expression: 'TraceOp1',
groupBy: [{ key: 'service', dataType: 'string' as any, type: '' }],
},
];
mockUseQueryBuilder.mockReturnValue({ stagedQuery: query });
renderWithStore(dataSource);
fireEvent.click(screen.getByTestId(testId));
fireEvent.click(screen.getByRole('radio', { name: '50k' }));
fireEvent.click(screen.getByText('Export'));
await waitFor(() => {
expect(mockDownloadExportData).toHaveBeenCalledTimes(1);
const callArgs = mockDownloadExportData.mock.calls[0][0];
const queries = callArgs.body.compositeQuery.queries;
const traceOpQuery = queries.find((q: any) => q.spec.name === 'TraceOp1');
if (traceOpQuery) {
expect(traceOpQuery.spec.limit).toBe(DownloadRowCounts.FIFTY_K);
expect(traceOpQuery.spec.groupBy).toBeUndefined();
}
});
});
});

View File

@@ -1,8 +1,8 @@
import { useCallback, useMemo, useState } from 'react';
import { Button, message, Popover, Radio, Tooltip, Typography } from 'antd';
import { downloadExportData } from 'api/v1/download/downloadExportData';
import { Button, Popover, Radio, Tooltip, Typography } from 'antd';
import { useExportRawData } from 'hooks/useDownloadOptionsMenu/useDownloadOptionsMenu';
import { Download, DownloadIcon, Loader2 } from 'lucide-react';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { DataSource } from 'types/common/queryBuilder';
import {
DownloadColumnsScopes,
@@ -10,75 +10,34 @@ import {
DownloadRowCounts,
} from './constants';
import './LogsDownloadOptionsMenu.styles.scss';
import './DownloadOptionsMenu.styles.scss';
function convertTelemetryFieldKeyToText(key: TelemetryFieldKey): string {
const prefix = key.fieldContext ? `${key.fieldContext}.` : '';
const suffix = key.fieldDataType ? `:${key.fieldDataType}` : '';
return `${prefix}${key.name}${suffix}`;
interface DownloadOptionsMenuProps {
dataSource: DataSource;
}
interface LogsDownloadOptionsMenuProps {
startTime: number;
endTime: number;
filter: string;
columns: TelemetryFieldKey[];
orderBy: string;
}
export default function LogsDownloadOptionsMenu({
startTime,
endTime,
filter,
columns,
orderBy,
}: LogsDownloadOptionsMenuProps): JSX.Element {
export default function DownloadOptionsMenu({
dataSource,
}: DownloadOptionsMenuProps): JSX.Element {
const [exportFormat, setExportFormat] = useState<string>(DownloadFormats.CSV);
const [rowLimit, setRowLimit] = useState<number>(DownloadRowCounts.TEN_K);
const [columnsScope, setColumnsScope] = useState<string>(
DownloadColumnsScopes.ALL,
);
const [isDownloading, setIsDownloading] = useState<boolean>(false);
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
const handleExportRawData = useCallback(async (): Promise<void> => {
setIsPopoverOpen(false);
try {
setIsDownloading(true);
const downloadOptions = {
source: 'logs',
start: startTime,
end: endTime,
columns:
columnsScope === DownloadColumnsScopes.SELECTED
? columns.map((col) => convertTelemetryFieldKeyToText(col))
: [],
filter,
orderBy,
format: exportFormat,
limit: rowLimit,
};
const { isDownloading, handleExportRawData } = useExportRawData({
dataSource,
});
await downloadExportData(downloadOptions);
message.success('Export completed successfully');
} catch (error) {
console.error('Error exporting logs:', error);
message.error('Failed to export logs. Please try again.');
} finally {
setIsDownloading(false);
}
}, [
startTime,
endTime,
columnsScope,
columns,
filter,
orderBy,
exportFormat,
rowLimit,
setIsDownloading,
setIsPopoverOpen,
]);
const handleExport = useCallback(async (): Promise<void> => {
setIsPopoverOpen(false);
await handleExportRawData({
format: exportFormat,
rowLimit,
clearSelectColumns: columnsScope === DownloadColumnsScopes.ALL,
});
}, [exportFormat, rowLimit, columnsScope, handleExportRawData]);
const popoverContent = useMemo(
() => (
@@ -129,7 +88,7 @@ export default function LogsDownloadOptionsMenu({
<Button
type="primary"
icon={<Download size={16} />}
onClick={handleExportRawData}
onClick={handleExport}
className="export-button"
disabled={isDownloading}
loading={isDownloading}
@@ -138,7 +97,7 @@ export default function LogsDownloadOptionsMenu({
</Button>
</div>
),
[exportFormat, rowLimit, columnsScope, isDownloading, handleExportRawData],
[exportFormat, rowLimit, columnsScope, isDownloading, handleExport],
);
return (
@@ -149,19 +108,19 @@ export default function LogsDownloadOptionsMenu({
arrow={false}
open={isPopoverOpen}
onOpenChange={setIsPopoverOpen}
rootClassName="logs-download-popover"
rootClassName="download-popover"
>
<Tooltip title="Download" placement="top">
<Button
className="periscope-btn ghost"
icon={
isDownloading ? (
<Loader2 size={18} className="animate-spin" />
<Loader2 size={14} className="animate-spin" />
) : (
<DownloadIcon size={15} />
<DownloadIcon size={14} />
)
}
data-testid="periscope-btn-download-options"
data-testid={`periscope-btn-download-${dataSource}`}
disabled={isDownloading}
/>
</Tooltip>

View File

@@ -1,341 +0,0 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { message } from 'antd';
import { ENVIRONMENT } from 'constants/env';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import '@testing-library/jest-dom';
import { DownloadFormats, DownloadRowCounts } from './constants';
import LogsDownloadOptionsMenu from './LogsDownloadOptionsMenu';
// Mock antd message
jest.mock('antd', () => {
const actual = jest.requireActual('antd');
return {
...actual,
message: {
success: jest.fn(),
error: jest.fn(),
},
};
});
const TEST_IDS = {
DOWNLOAD_BUTTON: 'periscope-btn-download-options',
} as const;
interface TestProps {
startTime: number;
endTime: number;
filter: string;
columns: TelemetryFieldKey[];
orderBy: string;
}
const createTestProps = (): TestProps => ({
startTime: 1631234567890,
endTime: 1631234567999,
filter: 'status = 200',
columns: [
{
name: 'http.status',
fieldContext: 'attribute',
fieldDataType: 'int64',
} as TelemetryFieldKey,
],
orderBy: 'timestamp:desc',
});
const testRenderContent = (props: TestProps): void => {
render(
<LogsDownloadOptionsMenu
startTime={props.startTime}
endTime={props.endTime}
filter={props.filter}
columns={props.columns}
orderBy={props.orderBy}
/>,
);
};
const testSuccessResponse = (res: any, ctx: any): any =>
res(
ctx.status(200),
ctx.set('Content-Type', 'application/octet-stream'),
ctx.set('Content-Disposition', 'attachment; filename="export.csv"'),
ctx.body('id,value\n1,2\n'),
);
describe('LogsDownloadOptionsMenu', () => {
const BASE_URL = ENVIRONMENT.baseURL;
const EXPORT_URL = `${BASE_URL}/api/v1/export_raw_data`;
let requestSpy: jest.Mock<any, any>;
const setupDefaultServer = (): void => {
server.use(
rest.get(EXPORT_URL, (req, res, ctx) => {
const params = req.url.searchParams;
const payload = {
start: Number(params.get('start')),
end: Number(params.get('end')),
filter: params.get('filter'),
columns: params.getAll('columns'),
order_by: params.get('order_by'),
limit: Number(params.get('limit')),
format: params.get('format'),
};
requestSpy(payload);
return testSuccessResponse(res, ctx);
}),
);
};
// Mock URL.createObjectURL used by download logic
const originalCreateObjectURL = URL.createObjectURL;
const originalRevokeObjectURL = URL.revokeObjectURL;
beforeEach(() => {
requestSpy = jest.fn();
setupDefaultServer();
(message.success as jest.Mock).mockReset();
(message.error as jest.Mock).mockReset();
// jsdom doesn't implement it by default
((URL as unknown) as {
createObjectURL: (b: Blob) => string;
}).createObjectURL = jest.fn(() => 'blob:mock');
((URL as unknown) as {
revokeObjectURL: (u: string) => void;
}).revokeObjectURL = jest.fn();
});
beforeAll(() => {
server.listen();
});
afterEach(() => {
server.resetHandlers();
});
afterAll(() => {
server.close();
// restore
URL.createObjectURL = originalCreateObjectURL;
URL.revokeObjectURL = originalRevokeObjectURL;
});
it('renders download button', () => {
const props = createTestProps();
testRenderContent(props);
const button = screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON);
expect(button).toBeInTheDocument();
expect(button).toHaveClass('periscope-btn', 'ghost');
});
it('shows popover with export options when download button is clicked', () => {
const props = createTestProps();
render(
<LogsDownloadOptionsMenu
startTime={props.startTime}
endTime={props.endTime}
filter={props.filter}
columns={props.columns}
orderBy={props.orderBy}
/>,
);
fireEvent.click(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON));
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(screen.getByText('FORMAT')).toBeInTheDocument();
expect(screen.getByText('Number of Rows')).toBeInTheDocument();
expect(screen.getByText('Columns')).toBeInTheDocument();
});
it('allows changing export format', () => {
const props = createTestProps();
testRenderContent(props);
fireEvent.click(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON));
const csvRadio = screen.getByRole('radio', { name: 'csv' });
const jsonlRadio = screen.getByRole('radio', { name: 'jsonl' });
expect(csvRadio).toBeChecked();
fireEvent.click(jsonlRadio);
expect(jsonlRadio).toBeChecked();
expect(csvRadio).not.toBeChecked();
});
it('allows changing row limit', () => {
const props = createTestProps();
testRenderContent(props);
fireEvent.click(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON));
const tenKRadio = screen.getByRole('radio', { name: '10k' });
const fiftyKRadio = screen.getByRole('radio', { name: '50k' });
expect(tenKRadio).toBeChecked();
fireEvent.click(fiftyKRadio);
expect(fiftyKRadio).toBeChecked();
expect(tenKRadio).not.toBeChecked();
});
it('allows changing columns scope', () => {
const props = createTestProps();
testRenderContent(props);
fireEvent.click(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON));
const allColumnsRadio = screen.getByRole('radio', { name: 'All' });
const selectedColumnsRadio = screen.getByRole('radio', { name: 'Selected' });
expect(allColumnsRadio).toBeChecked();
fireEvent.click(selectedColumnsRadio);
expect(selectedColumnsRadio).toBeChecked();
expect(allColumnsRadio).not.toBeChecked();
});
it('calls downloadExportData with correct parameters when export button is clicked (Selected columns)', async () => {
const props = createTestProps();
testRenderContent(props);
fireEvent.click(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON));
fireEvent.click(screen.getByRole('radio', { name: 'Selected' }));
fireEvent.click(screen.getByText('Export'));
await waitFor(() => {
expect(requestSpy).toHaveBeenCalledWith(
expect.objectContaining({
start: props.startTime,
end: props.endTime,
columns: ['attribute.http.status:int64'],
filter: props.filter,
order_by: props.orderBy,
format: DownloadFormats.CSV,
limit: DownloadRowCounts.TEN_K,
}),
);
});
});
it('calls downloadExportData with correct parameters when export button is clicked', async () => {
const props = createTestProps();
testRenderContent(props);
fireEvent.click(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON));
fireEvent.click(screen.getByRole('radio', { name: 'All' }));
fireEvent.click(screen.getByText('Export'));
await waitFor(() => {
expect(requestSpy).toHaveBeenCalledWith(
expect.objectContaining({
start: props.startTime,
end: props.endTime,
columns: [],
filter: props.filter,
order_by: props.orderBy,
format: DownloadFormats.CSV,
limit: DownloadRowCounts.TEN_K,
}),
);
});
});
it('handles successful export with success message', async () => {
const props = createTestProps();
testRenderContent(props);
fireEvent.click(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON));
fireEvent.click(screen.getByText('Export'));
await waitFor(() => {
expect(message.success).toHaveBeenCalledWith(
'Export completed successfully',
);
});
});
it('handles export failure with error message', async () => {
// Override handler to return 500 for this test
server.use(rest.get(EXPORT_URL, (_req, res, ctx) => res(ctx.status(500))));
const props = createTestProps();
testRenderContent(props);
fireEvent.click(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON));
fireEvent.click(screen.getByText('Export'));
await waitFor(() => {
expect(message.error).toHaveBeenCalledWith(
'Failed to export logs. Please try again.',
);
});
});
it('handles UI state correctly during export process', async () => {
server.use(
rest.get(EXPORT_URL, (_req, res, ctx) => testSuccessResponse(res, ctx)),
);
const props = createTestProps();
testRenderContent(props);
// Open popover
fireEvent.click(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON));
expect(screen.getByRole('dialog')).toBeInTheDocument();
// Start export
fireEvent.click(screen.getByText('Export'));
// Check button is disabled during export
expect(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON)).toBeDisabled();
// Check popover is closed immediately after export starts
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
// Wait for export to complete and verify button is enabled again
await waitFor(() => {
expect(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON)).not.toBeDisabled();
});
});
it('uses filename from Content-Disposition and triggers download click', async () => {
server.use(
rest.get(EXPORT_URL, (_req, res, ctx) =>
res(
ctx.status(200),
ctx.set('Content-Type', 'application/octet-stream'),
ctx.set('Content-Disposition', 'attachment; filename="report.jsonl"'),
ctx.body('row\n'),
),
),
);
const originalCreateElement = document.createElement.bind(document);
const anchorEl = originalCreateElement('a') as HTMLAnchorElement;
const setAttrSpy = jest.spyOn(anchorEl, 'setAttribute');
const clickSpy = jest.spyOn(anchorEl, 'click');
const removeSpy = jest.spyOn(anchorEl, 'remove');
const createElSpy = jest
.spyOn(document, 'createElement')
.mockImplementation((tagName: any): any =>
tagName === 'a' ? anchorEl : originalCreateElement(tagName),
);
const appendSpy = jest.spyOn(document.body, 'appendChild');
const props = createTestProps();
testRenderContent(props);
fireEvent.click(screen.getByTestId(TEST_IDS.DOWNLOAD_BUTTON));
fireEvent.click(screen.getByText('Export'));
await waitFor(() => {
expect(appendSpy).toHaveBeenCalledWith(anchorEl);
expect(setAttrSpy).toHaveBeenCalledWith('download', 'report.jsonl');
expect(clickSpy).toHaveBeenCalled();
expect(removeSpy).toHaveBeenCalled();
});
expect(anchorEl.getAttribute('download')).toBe('report.jsonl');
createElSpy.mockRestore();
appendSpy.mockRestore();
});
});

View File

@@ -86,8 +86,8 @@ jest.mock('hooks/useDarkMode', () => ({
useIsDarkMode: (): boolean => false,
}));
jest.mock('providers/Dashboard/Dashboard', () => ({
useDashboard: (): { selectedDashboard: undefined } => ({
jest.mock('providers/Dashboard/store/useDashboardStore', () => ({
useDashboardStore: (): { selectedDashboard: undefined } => ({
selectedDashboard: undefined,
}),
}));

View File

@@ -1,4 +1,6 @@
import { ReactNode } from 'react';
import { MemoryRouter, useLocation } from 'react-router-dom';
import { useDashboardBootstrap } from 'hooks/dashboard/useDashboardBootstrap';
import {
getDashboardById,
getNonIntegrationDashboardById,
@@ -6,10 +8,9 @@ import {
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import {
DashboardContext,
DashboardProvider,
} from 'providers/Dashboard/Dashboard';
import { IDashboardContext } from 'providers/Dashboard/types';
resetDashboard,
useDashboardStore,
} from 'providers/Dashboard/store/useDashboardStore';
import {
fireEvent,
render,
@@ -21,6 +22,18 @@ import { Dashboard } from 'types/api/dashboard/getAll';
import DashboardDescription from '..';
function DashboardBootstrapWrapper({
dashboardId,
children,
}: {
dashboardId: string;
children: ReactNode;
}): JSX.Element {
useDashboardBootstrap(dashboardId);
// eslint-disable-next-line react/jsx-no-useless-fragment
return <>{children}</>;
}
interface MockSafeNavigateReturn {
safeNavigate: jest.MockedFunction<(url: string) => void>;
}
@@ -54,6 +67,7 @@ describe('Dashboard landing page actions header tests', () => {
beforeEach(() => {
mockSafeNavigate.mockClear();
sessionStorage.clear();
resetDashboard();
});
it('unlock dashboard should be disabled for integrations created dashboards', async () => {
@@ -64,7 +78,7 @@ describe('Dashboard landing page actions header tests', () => {
(useLocation as jest.Mock).mockReturnValue(mockLocation);
const { getByTestId } = render(
<MemoryRouter initialEntries={[DASHBOARD_PATH]}>
<DashboardProvider dashboardId="4">
<DashboardBootstrapWrapper dashboardId="4">
<DashboardDescription
handle={{
active: false,
@@ -73,7 +87,7 @@ describe('Dashboard landing page actions header tests', () => {
node: { current: null },
}}
/>
</DashboardProvider>
</DashboardBootstrapWrapper>
</MemoryRouter>,
);
@@ -105,7 +119,7 @@ describe('Dashboard landing page actions header tests', () => {
);
const { getByTestId } = render(
<MemoryRouter initialEntries={[DASHBOARD_PATH]}>
<DashboardProvider dashboardId="4">
<DashboardBootstrapWrapper dashboardId="4">
<DashboardDescription
handle={{
active: false,
@@ -114,7 +128,7 @@ describe('Dashboard landing page actions header tests', () => {
node: { current: null },
}}
/>
</DashboardProvider>
</DashboardBootstrapWrapper>
</MemoryRouter>,
);
@@ -144,7 +158,7 @@ describe('Dashboard landing page actions header tests', () => {
const { getByText } = render(
<MemoryRouter initialEntries={[DASHBOARD_PATH]}>
<DashboardProvider dashboardId="4">
<DashboardBootstrapWrapper dashboardId="4">
<DashboardDescription
handle={{
active: false,
@@ -153,7 +167,7 @@ describe('Dashboard landing page actions header tests', () => {
node: { current: null },
}}
/>
</DashboardProvider>
</DashboardBootstrapWrapper>
</MemoryRouter>,
);
@@ -181,37 +195,26 @@ describe('Dashboard landing page actions header tests', () => {
(useLocation as jest.Mock).mockReturnValue(mockLocation);
const mockContextValue: IDashboardContext = {
isDashboardLocked: false,
handleDashboardLockToggle: jest.fn(),
dashboardResponse: {} as IDashboardContext['dashboardResponse'],
useDashboardStore.setState({
selectedDashboard: (getDashboardById.data as unknown) as Dashboard,
layouts: [],
panelMap: {},
setPanelMap: jest.fn(),
setLayouts: jest.fn(),
setSelectedDashboard: jest.fn(),
updatedTimeRef: { current: null },
updateLocalStorageDashboardVariables: jest.fn(),
dashboardQueryRangeCalled: false,
setDashboardQueryRangeCalled: jest.fn(),
isDashboardFetching: false,
columnWidths: {},
setColumnWidths: jest.fn(),
};
});
const { getByText } = render(
<MemoryRouter initialEntries={[DASHBOARD_PATH]}>
<DashboardContext.Provider value={mockContextValue}>
<DashboardDescription
handle={{
active: false,
enter: (): Promise<void> => Promise.resolve(),
exit: (): Promise<void> => Promise.resolve(),
node: { current: null },
}}
/>
</DashboardContext.Provider>
<DashboardDescription
handle={{
active: false,
enter: (): Promise<void> => Promise.resolve(),
exit: (): Promise<void> => Promise.resolve(),
node: { current: null },
}}
/>
</MemoryRouter>,
);

View File

@@ -21,6 +21,7 @@ import { DeleteButton } from 'container/ListOfDashboard/TableComponents/DeleteBu
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import { useDashboardVariables } from 'hooks/dashboard/useDashboardVariables';
import { useGetPublicDashboardMeta } from 'hooks/dashboard/useGetPublicDashboardMeta';
import { useLockDashboard } from 'hooks/dashboard/useLockDashboard';
import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
import useComponentPermission from 'hooks/useComponentPermission';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
@@ -39,8 +40,11 @@ import {
X,
} from 'lucide-react';
import { useAppContext } from 'providers/App/App';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { usePanelTypeSelectionModalStore } from 'providers/Dashboard/helpers/panelTypeSelectionModalHelper';
import {
selectIsDashboardLocked,
useDashboardStore,
} from 'providers/Dashboard/store/useDashboardStore';
import { sortLayout } from 'providers/Dashboard/util';
import { DashboardData } from 'types/api/dashboard/getAll';
import { Props } from 'types/api/dashboard/update';
@@ -79,10 +83,11 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
setPanelMap,
layouts,
setLayouts,
isDashboardLocked,
setSelectedDashboard,
handleDashboardLockToggle,
} = useDashboard();
} = useDashboardStore();
const isDashboardLocked = useDashboardStore(selectIsDashboardLocked);
const handleDashboardLockToggle = useLockDashboard();
const variablesSettingsTabHandle = useRef<VariablesSettingsTab>(null);
const [isSettingsDrawerOpen, setIsSettingsDrawerOpen] = useState<boolean>(

View File

@@ -30,7 +30,7 @@ import {
Pyramid,
X,
} from 'lucide-react';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import { AppState } from 'store/reducers';
import {
IDashboardVariable,
@@ -239,7 +239,7 @@ function VariableItem({
const [selectedWidgets, setSelectedWidgets] = useState<string[]>([]);
const { selectedDashboard } = useDashboard();
const { selectedDashboard } = useDashboardStore();
const widgetsByDynamicVariableId = useWidgetsByDynamicVariableId();
useEffect(() => {

View File

@@ -2,7 +2,7 @@ import React from 'react';
import { CustomMultiSelect } from 'components/NewSelect';
import { PANEL_GROUP_TYPES } from 'constants/queryBuilder';
import { generateGridTitle } from 'container/GridPanelSwitch/utils';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import { WidgetRow, Widgets } from 'types/api/dashboard/getAll';
export function WidgetSelector({
@@ -12,7 +12,7 @@ export function WidgetSelector({
selectedWidgets: string[];
setSelectedWidgets: (widgets: string[]) => void;
}): JSX.Element {
const { selectedDashboard } = useDashboard();
const { selectedDashboard } = useDashboardStore();
// Get layout IDs for cross-referencing
const layoutIds = new Set(

View File

@@ -19,8 +19,8 @@ import { useDashboardVariables } from 'hooks/dashboard/useDashboardVariables';
import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
import { useNotifications } from 'hooks/useNotifications';
import { PenLine, Trash2 } from 'lucide-react';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { IDashboardVariables } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreTypes';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import { TVariableMode } from './types';
@@ -87,7 +87,7 @@ function VariablesSettings({
const { t } = useTranslation(['dashboard']);
const { selectedDashboard, setSelectedDashboard } = useDashboard();
const { selectedDashboard, setSelectedDashboard } = useDashboardStore();
const { dashboardVariables } = useDashboardVariables();
const { notifications } = useNotifications();

View File

@@ -5,7 +5,7 @@ import AddTags from 'container/DashboardContainer/DashboardSettings/General/AddT
import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
import { isEqual } from 'lodash-es';
import { Check, X } from 'lucide-react';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import { Button } from './styles';
import { Base64Icons } from './utils';
@@ -15,7 +15,7 @@ import './GeneralSettings.styles.scss';
const { Option } = Select;
function GeneralDashboardSettings(): JSX.Element {
const { selectedDashboard, setSelectedDashboard } = useDashboard();
const { selectedDashboard, setSelectedDashboard } = useDashboardStore();
const updateDashboardMutation = useUpdateDashboard();

View File

@@ -7,14 +7,14 @@ import {
unpublishedPublicDashboardMeta,
} from 'mocks-server/__mockdata__/publicDashboard';
import { rest, server } from 'mocks-server/server';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { USER_ROLES } from 'types/roles';
import PublicDashboardSetting from '../index';
// Mock dependencies
jest.mock('providers/Dashboard/Dashboard');
jest.mock('providers/Dashboard/store/useDashboardStore');
jest.mock('react-use', () => ({
...jest.requireActual('react-use'),
useCopyToClipboard: jest.fn(),
@@ -26,7 +26,7 @@ jest.mock('@signozhq/sonner', () => ({
},
}));
const mockUseDashboard = jest.mocked(useDashboard);
const mockUseDashboard = jest.mocked(useDashboardStore);
const mockUseCopyToClipboard = jest.mocked(useCopyToClipboard);
const mockToast = jest.mocked(toast);
@@ -67,10 +67,10 @@ beforeEach(() => {
// Mock window.open
window.open = jest.fn();
// Mock useDashboard
// Mock useDashboardStore
mockUseDashboard.mockReturnValue(({
selectedDashboard: mockSelectedDashboard,
} as unknown) as ReturnType<typeof useDashboard>);
} as unknown) as ReturnType<typeof useDashboardStore>);
// Mock useCopyToClipboard
mockUseCopyToClipboard.mockReturnValue(([

View File

@@ -11,7 +11,7 @@ import { useGetPublicDashboardMeta } from 'hooks/dashboard/useGetPublicDashboard
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { Copy, ExternalLink, Globe, Info, Loader2, Trash } from 'lucide-react';
import { useAppContext } from 'providers/App/App';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import { PublicDashboardMetaProps } from 'types/api/dashboard/public/getMeta';
import APIError from 'types/api/error';
import { USER_ROLES } from 'types/roles';
@@ -59,7 +59,7 @@ function PublicDashboardSetting(): JSX.Element {
const [defaultTimeRange, setDefaultTimeRange] = useState('30m');
const [, setCopyPublicDashboardURL] = useCopyToClipboard();
const { selectedDashboard } = useDashboard();
const { selectedDashboard } = useDashboardStore();
const { isCloudUser, isEnterpriseSelfHostedUser } = useGetTenantLicense();

View File

@@ -3,13 +3,14 @@ import { memo, useCallback, useEffect, useMemo } from 'react';
import { useSelector } from 'react-redux';
import { Row } from 'antd';
import { ALL_SELECTED_VALUE } from 'components/NewSelect/utils';
import { updateLocalStorageDashboardVariable } from 'hooks/dashboard/useDashboardFromLocalStorage';
import {
useDashboardVariables,
useDashboardVariablesSelector,
} from 'hooks/dashboard/useDashboardVariables';
import useVariablesFromUrl from 'hooks/dashboard/useVariablesFromUrl';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { updateDashboardVariablesStore } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStore';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import {
enqueueDescendantsOfVariable,
enqueueFetchOfAllVariables,
@@ -18,23 +19,23 @@ import {
import { AppState } from 'store/reducers';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import { GlobalReducer } from 'types/reducer/globalTime';
import { useShallow } from 'zustand/react/shallow';
import VariableItem from './VariableItem';
import './DashboardVariableSelection.styles.scss';
function DashboardVariableSelection(): JSX.Element | null {
const {
setSelectedDashboard,
updateLocalStorageDashboardVariables,
} = useDashboard();
const { dashboardId, setSelectedDashboard } = useDashboardStore(
useShallow((s) => ({
dashboardId: s.selectedDashboard?.id ?? '',
setSelectedDashboard: s.setSelectedDashboard,
})),
);
const { updateUrlVariable } = useVariablesFromUrl();
const { dashboardVariables } = useDashboardVariables();
const dashboardId = useDashboardVariablesSelector(
(state) => state.dashboardId,
);
const sortedVariablesArray = useDashboardVariablesSelector(
(state) => state.sortedVariablesArray,
);
@@ -82,7 +83,13 @@ function DashboardVariableSelection(): JSX.Element | null {
// This makes localStorage much lighter by avoiding storing all individual values
const variable = dashboardVariables[id] || dashboardVariables[name];
const isDynamic = variable.type === 'DYNAMIC';
updateLocalStorageDashboardVariables(name, value, allSelected, isDynamic);
updateLocalStorageDashboardVariable(
dashboardId,
name,
value,
allSelected,
isDynamic,
);
if (allSelected) {
updateUrlVariable(name || id, ALL_SELECTED_VALUE);
@@ -150,13 +157,7 @@ function DashboardVariableSelection(): JSX.Element | null {
// Safe to call synchronously now that the store already has the updated value.
enqueueDescendantsOfVariable(name);
},
[
dashboardId,
dashboardVariables,
updateLocalStorageDashboardVariables,
updateUrlVariable,
setSelectedDashboard,
],
[dashboardId, dashboardVariables, updateUrlVariable, setSelectedDashboard],
);
return (

View File

@@ -32,11 +32,22 @@ const mockVariableItemCallbacks: {
// Mock providers/Dashboard/Dashboard
const mockSetSelectedDashboard = jest.fn();
const mockUpdateLocalStorageDashboardVariables = jest.fn();
jest.mock('providers/Dashboard/Dashboard', () => ({
useDashboard: (): Record<string, unknown> => ({
setSelectedDashboard: mockSetSelectedDashboard,
updateLocalStorageDashboardVariables: mockUpdateLocalStorageDashboardVariables,
}),
interface MockDashboardStoreState {
selectedDashboard?: { id: string };
setSelectedDashboard: typeof mockSetSelectedDashboard;
updateLocalStorageDashboardVariables: typeof mockUpdateLocalStorageDashboardVariables;
}
jest.mock('providers/Dashboard/store/useDashboardStore', () => ({
useDashboardStore: (
selector?: (s: Record<string, unknown>) => MockDashboardStoreState,
): MockDashboardStoreState => {
const state = {
selectedDashboard: { id: 'dash-1' },
setSelectedDashboard: mockSetSelectedDashboard,
updateLocalStorageDashboardVariables: mockUpdateLocalStorageDashboardVariables,
};
return selector ? selector(state) : state;
},
}));
// Mock hooks/dashboard/useVariablesFromUrl

View File

@@ -1,11 +1,13 @@
/* eslint-disable sonarjs/cognitive-complexity */
import { useCallback } from 'react';
import { useAddDynamicVariableToPanels } from 'hooks/dashboard/useAddDynamicVariableToPanels';
import { updateLocalStorageDashboardVariable } from 'hooks/dashboard/useDashboardFromLocalStorage';
import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { IDashboardVariables } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreTypes';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import { v4 as uuidv4 } from 'uuid';
import { useShallow } from 'zustand/react/shallow';
import { convertVariablesToDbFormat } from './util';
@@ -37,11 +39,16 @@ interface UseDashboardVariableUpdateReturn {
export const useDashboardVariableUpdate = (): UseDashboardVariableUpdateReturn => {
const {
dashboardId,
selectedDashboard,
setSelectedDashboard,
updateLocalStorageDashboardVariables,
} = useDashboard();
} = useDashboardStore(
useShallow((s) => ({
dashboardId: s.selectedDashboard?.id ?? '',
selectedDashboard: s.selectedDashboard,
setSelectedDashboard: s.setSelectedDashboard,
})),
);
const addDynamicVariableToPanels = useAddDynamicVariableToPanels();
const updateMutation = useUpdateDashboard();
@@ -59,7 +66,13 @@ export const useDashboardVariableUpdate = (): UseDashboardVariableUpdateReturn =
// This makes localStorage much lighter and more efficient.
// currently all the variables are dynamic
const isDynamic = true;
updateLocalStorageDashboardVariables(name, value, allSelected, isDynamic);
updateLocalStorageDashboardVariable(
dashboardId,
name,
value,
allSelected,
isDynamic,
);
if (selectedDashboard) {
setSelectedDashboard((prev) => {
@@ -97,11 +110,7 @@ export const useDashboardVariableUpdate = (): UseDashboardVariableUpdateReturn =
}
}
},
[
selectedDashboard,
setSelectedDashboard,
updateLocalStorageDashboardVariables,
],
[dashboardId, selectedDashboard, setSelectedDashboard],
);
const updateVariables = useCallback(

View File

@@ -49,8 +49,8 @@ const mockDashboard = {
// Mock the dashboard provider with stable functions to prevent infinite loops
const mockSetSelectedDashboard = jest.fn();
const mockUpdateLocalStorageDashboardVariables = jest.fn();
jest.mock('providers/Dashboard/Dashboard', () => ({
useDashboard: (): any => ({
jest.mock('providers/Dashboard/store/useDashboardStore', () => ({
useDashboardStore: (): any => ({
selectedDashboard: mockDashboard,
setSelectedDashboard: mockSetSelectedDashboard,
updateLocalStorageDashboardVariables: mockUpdateLocalStorageDashboardVariables,

View File

@@ -56,8 +56,8 @@ const mockDashboard = {
},
};
// Mock dependencies
jest.mock('providers/Dashboard/Dashboard', () => ({
useDashboard: (): any => ({
jest.mock('providers/Dashboard/store/useDashboardStore', () => ({
useDashboardStore: (): any => ({
selectedDashboard: mockDashboard,
}),
}));
@@ -152,8 +152,8 @@ describe('Panel Management Tests', () => {
};
// Temporarily mock the dashboard
jest.doMock('providers/Dashboard/Dashboard', () => ({
useDashboard: (): any => ({
jest.doMock('providers/Dashboard/store/useDashboardStore', () => ({
useDashboardStore: (): any => ({
selectedDashboard: modifiedDashboard,
}),
}));

View File

@@ -4,7 +4,7 @@ import ROUTES from 'constants/routes';
import { DASHBOARDS_LIST_QUERY_PARAMS_STORAGE_KEY } from 'hooks/dashboard/useDashboardsListQueryParams';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { LayoutGrid } from 'lucide-react';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import { DashboardData } from 'types/api/dashboard/getAll';
import { Base64Icons } from '../../DashboardSettings/General/utils';
@@ -13,7 +13,7 @@ import './DashboardBreadcrumbs.styles.scss';
function DashboardBreadcrumbs(): JSX.Element {
const { safeNavigate } = useSafeNavigate();
const { selectedDashboard } = useDashboard();
const { selectedDashboard } = useDashboardStore();
const updatedAtRef = useRef(selectedDashboard?.updatedAt);
const selectedData = selectedDashboard

View File

@@ -6,7 +6,10 @@ 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';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import {
selectIsDashboardLocked,
useDashboardStore,
} from 'providers/Dashboard/store/useDashboardStore';
import { getChartManagerColumns } from './getChartMangerColumns';
import { ExtendedChartDataset, getDefaultTableDataSet } from './utils';
@@ -50,7 +53,7 @@ export default function ChartManager({
onToggleSeriesVisibility,
syncSeriesVisibilityToLocalStorage,
} = usePlotContext();
const { isDashboardLocked } = useDashboard();
const isDashboardLocked = useDashboardStore(selectIsDashboardLocked);
const [tableDataSet, setTableDataSet] = useState<ExtendedChartDataset[]>(() =>
getDefaultTableDataSet(

View File

@@ -32,10 +32,18 @@ jest.mock('lib/uPlotV2/hooks/useLegendsSync', () => ({
}),
}));
jest.mock('providers/Dashboard/Dashboard', () => ({
useDashboard: (): { isDashboardLocked: boolean } => ({
isDashboardLocked: false,
}),
jest.mock('providers/Dashboard/store/useDashboardStore', () => ({
useDashboardStore: (
selector?: (s: {
selectedDashboard: { locked: boolean } | undefined;
}) => { selectedDashboard: { locked: boolean } },
): { selectedDashboard: { locked: boolean } } => {
const mockState = { selectedDashboard: { locked: false } };
return selector ? selector(mockState) : mockState;
},
selectIsDashboardLocked: (s: {
selectedDashboard: { locked: boolean } | undefined;
}): boolean => s.selectedDashboard?.locked ?? false,
}));
jest.mock('hooks/useNotifications', () => ({

View File

@@ -8,8 +8,11 @@ import { VariablesSettingsTab } from 'container/DashboardContainer/DashboardDesc
import DashboardSettings from 'container/DashboardContainer/DashboardSettings';
import useComponentPermission from 'hooks/useComponentPermission';
import { useAppContext } from 'providers/App/App';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { usePanelTypeSelectionModalStore } from 'providers/Dashboard/helpers/panelTypeSelectionModalHelper';
import {
selectIsDashboardLocked,
useDashboardStore,
} from 'providers/Dashboard/store/useDashboardStore';
import { ROLES, USER_ROLES } from 'types/roles';
import { ComponentTypes } from 'utils/permission';
@@ -20,7 +23,8 @@ export default function DashboardEmptyState(): JSX.Element {
(s) => s.setIsPanelTypeSelectionModalOpen,
);
const { selectedDashboard, isDashboardLocked } = useDashboard();
const { selectedDashboard } = useDashboardStore();
const isDashboardLocked = useDashboardStore(selectIsDashboardLocked);
const variablesSettingsTabHandle = useRef<VariablesSettingsTab>(null);
const [isSettingsDrawerOpen, setIsSettingsDrawerOpen] = useState<boolean>(

View File

@@ -3,7 +3,10 @@ import { Button, Input } from 'antd';
import type { CheckboxChangeEvent } from 'antd/es/checkbox';
import { ResizeTable } from 'components/ResizeTable';
import { useNotifications } from 'hooks/useNotifications';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import {
selectIsDashboardLocked,
useDashboardStore,
} from 'providers/Dashboard/store/useDashboardStore';
import { getGraphManagerTableColumns } from './TableRender/GraphManagerColumns';
import { ExtendedChartDataset, GraphManagerProps } from './types';
@@ -34,7 +37,7 @@ function GraphManager({
}, [data, options]);
const { notifications } = useNotifications();
const { isDashboardLocked } = useDashboard();
const isDashboardLocked = useDashboardStore(selectIsDashboardLocked);
const checkBoxOnChangeHandler = useCallback(
(e: CheckboxChangeEvent, index: number): void => {

View File

@@ -39,7 +39,10 @@ import { getDashboardVariables } from 'lib/dashboardVariables/getDashboardVariab
import GetMinMax from 'lib/getMinMax';
import { isEmpty } from 'lodash-es';
import { useAppContext } from 'providers/App/App';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import {
selectIsDashboardLocked,
useDashboardStore,
} from 'providers/Dashboard/store/useDashboardStore';
import { AppState } from 'store/reducers';
import { Warning } from 'types/api';
import { GlobalReducer } from 'types/reducer/globalTime';
@@ -81,11 +84,8 @@ function FullView({
setCurrentGraphRef(fullViewRef);
}, [setCurrentGraphRef]);
const {
selectedDashboard,
isDashboardLocked,
setColumnWidths,
} = useDashboard();
const { selectedDashboard, setColumnWidths } = useDashboardStore();
const isDashboardLocked = useDashboardStore(selectIsDashboardLocked);
const onColumnWidthsChange = useCallback(
(widths: Record<string, number>) => {

View File

@@ -161,8 +161,8 @@ const mockProps: WidgetGraphComponentProps = {
};
// Mock useDashabord hook
jest.mock('providers/Dashboard/Dashboard', () => ({
useDashboard: (): any => ({
jest.mock('providers/Dashboard/store/useDashboardStore', () => ({
useDashboardStore: (): any => ({
selectedDashboard: {
data: {
variables: [],

View File

@@ -28,7 +28,7 @@ import {
getCustomTimeRangeWindowSweepInMS,
getStartAndEndTimesInMilliseconds,
} from 'pages/MessagingQueues/MessagingQueuesUtils';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import { Widgets } from 'types/api/dashboard/getAll';
import { Props } from 'types/api/dashboard/update';
import { EQueryType } from 'types/common/dashboard';
@@ -106,7 +106,7 @@ function WidgetGraphComponent({
selectedDashboard,
setSelectedDashboard,
setColumnWidths,
} = useDashboard();
} = useDashboardStore();
const onColumnWidthsChange = useCallback(
(widths: Record<string, number>) => {

View File

@@ -1,6 +1,7 @@
import { memo, useEffect, useMemo, useRef, useState } from 'react';
// eslint-disable-next-line no-restricted-imports
import { useDispatch, useSelector } from 'react-redux';
import * as Sentry from '@sentry/react';
import logEvent from 'api/common/logEvent';
import { DEFAULT_ENTITY_VERSION, ENTITY_VERSION_V5 } from 'constants/app';
import { QueryParams } from 'constants/query';
@@ -17,7 +18,6 @@ import { getVariableReferencesInQuery } from 'lib/dashboardVariables/variableRef
import getTimeString from 'lib/getTimeString';
import { isEqual } from 'lodash-es';
import isEmpty from 'lodash-es/isEmpty';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { UpdateTimeInterval } from 'store/actions';
import { AppState } from 'store/reducers';
import APIError from 'types/api/error';
@@ -68,7 +68,19 @@ function GridCardGraph({
const [isInternalServerError, setIsInternalServerError] = useState<boolean>(
false,
);
const { setDashboardQueryRangeCalled } = useDashboard();
const queryRangeCalledRef = useRef(false);
useEffect(() => {
const timeoutId = setTimeout(() => {
if (!queryRangeCalledRef.current) {
Sentry.captureEvent({
message: `Dashboard query range not called within expected timeframe for widget ${widget?.id}`,
level: 'warning',
});
}
}, 120000);
return (): void => clearTimeout(timeoutId);
}, [widget?.id]);
const {
minTime,
@@ -260,14 +272,14 @@ function GridCardGraph({
});
}
}
setDashboardQueryRangeCalled(true);
queryRangeCalledRef.current = true;
},
onSettled: (data) => {
dataAvailable?.(
isDataAvailableByPanelType(data?.payload?.data, widget?.panelTypes),
);
getGraphData?.(data?.payload?.data);
setDashboardQueryRangeCalled(true);
queryRangeCalledRef.current = true;
},
},
);

View File

@@ -1,10 +1,10 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { FullScreen, FullScreenHandle } from 'react-full-screen';
import { ItemCallback, Layout } from 'react-grid-layout';
import { useIsFetching } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { useDispatch } from 'react-redux';
import { useLocation } from 'react-router-dom';
import * as Sentry from '@sentry/react';
import { Color } from '@signozhq/design-tokens';
import { Button, Form, Input, Modal, Typography } from 'antd';
import logEvent from 'api/common/logEvent';
@@ -12,6 +12,7 @@ import cx from 'classnames';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { QueryParams } from 'constants/query';
import { PANEL_GROUP_TYPES, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { themeColors } from 'constants/theme';
import { DEFAULT_ROW_NAME } from 'container/DashboardContainer/DashboardDescription/utils';
import { useDashboardVariables } from 'hooks/dashboard/useDashboardVariables';
@@ -31,7 +32,10 @@ import {
X,
} from 'lucide-react';
import { useAppContext } from 'providers/App/App';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import {
selectIsDashboardLocked,
useDashboardStore,
} from 'providers/Dashboard/store/useDashboardStore';
import { sortLayout } from 'providers/Dashboard/util';
import { UpdateTimeInterval } from 'store/actions';
import { Widgets } from 'types/api/dashboard/getAll';
@@ -61,6 +65,9 @@ interface GraphLayoutProps {
function GraphLayout(props: GraphLayoutProps): JSX.Element {
const { handle, enableDrillDown = false } = props;
const { safeNavigate } = useSafeNavigate();
const isDashboardFetching =
useIsFetching([REACT_QUERY_KEY.DASHBOARD_BY_ID]) > 0;
const {
selectedDashboard,
layouts,
@@ -68,12 +75,9 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
panelMap,
setPanelMap,
setSelectedDashboard,
isDashboardLocked,
dashboardQueryRangeCalled,
setDashboardQueryRangeCalled,
isDashboardFetching,
columnWidths,
} = useDashboard();
} = useDashboardStore();
const isDashboardLocked = useDashboardStore(selectIsDashboardLocked);
const { data } = selectedDashboard || {};
const { pathname } = useLocation();
const dispatch = useDispatch();
@@ -137,25 +141,6 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
setDashboardLayout(sortLayout(layouts));
}, [layouts]);
useEffect(() => {
setDashboardQueryRangeCalled(false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
const timeoutId = setTimeout(() => {
// Send Sentry event if query_range is not called within expected timeframe (2 mins) when there are widgets
if (!dashboardQueryRangeCalled && data?.widgets?.length) {
Sentry.captureEvent({
message: `Dashboard query range not called within expected timeframe even when there are ${data?.widgets?.length} widgets`,
level: 'warning',
});
}
}, 120000);
return (): void => clearTimeout(timeoutId);
}, [dashboardQueryRangeCalled, data?.widgets?.length]);
const logEventCalledRef = useRef(false);
useEffect(() => {
if (!logEventCalledRef.current && !isUndefined(data)) {

View File

@@ -4,9 +4,12 @@ import { Button, Popover } from 'antd';
import useComponentPermission from 'hooks/useComponentPermission';
import { EllipsisIcon, PenLine, Plus, X } from 'lucide-react';
import { useAppContext } from 'providers/App/App';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { usePanelTypeSelectionModalStore } from 'providers/Dashboard/helpers/panelTypeSelectionModalHelper';
import { setSelectedRowWidgetId } from 'providers/Dashboard/helpers/selectedRowWidgetIdHelper';
import {
selectIsDashboardLocked,
useDashboardStore,
} from 'providers/Dashboard/store/useDashboardStore';
import { ROLES, USER_ROLES } from 'types/roles';
import { ComponentTypes } from 'utils/permission';
@@ -39,7 +42,8 @@ export function WidgetRowHeader(props: WidgetRowHeaderProps): JSX.Element {
(s) => s.setIsPanelTypeSelectionModalOpen,
);
const { selectedDashboard, isDashboardLocked } = useDashboard();
const { selectedDashboard } = useDashboardStore();
const isDashboardLocked = useDashboardStore(selectIsDashboardLocked);
const permissions: ComponentTypes[] = ['add_panel'];
const { user } = useAppContext();

View File

@@ -1,6 +1,6 @@
import { useCallback } from 'react';
import { useNotifications } from 'hooks/useNotifications';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import { Widgets } from 'types/api/dashboard/getAll';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import {
@@ -121,7 +121,7 @@ function useNavigateToExplorerPages(): (
) => Promise<{
[queryName: string]: { filters: TagFilterItem[]; dataSource?: string };
}> {
const { selectedDashboard } = useDashboard();
const { selectedDashboard } = useDashboardStore();
const { notifications } = useNotifications();
return useCallback(

View File

@@ -92,8 +92,8 @@ jest.mock('hooks/useDarkMode', () => ({
useIsDarkMode: (): boolean => false,
}));
jest.mock('providers/Dashboard/Dashboard', () => ({
useDashboard: (): { selectedDashboard: undefined } => ({
jest.mock('providers/Dashboard/store/useDashboardStore', () => ({
useDashboardStore: (): { selectedDashboard: undefined } => ({
selectedDashboard: undefined,
}),
}));

View File

@@ -1,5 +1,5 @@
import { Switch, Typography } from 'antd';
import LogsDownloadOptionsMenu from 'components/LogsDownloadOptionsMenu/LogsDownloadOptionsMenu';
import DownloadOptionsMenu from 'components/DownloadOptionsMenu/DownloadOptionsMenu';
import LogsFormatOptionsMenu from 'components/LogsFormatOptionsMenu/LogsFormatOptionsMenu';
import ListViewOrderBy from 'components/OrderBy/ListViewOrderBy';
import { LOCALSTORAGE } from 'constants/localStorage';
@@ -21,8 +21,6 @@ function LogsActionsContainer({
isLoading,
isError,
isSuccess,
minTime,
maxTime,
}: {
listQuery: any;
selectedPanelType: PANEL_TYPES;
@@ -34,8 +32,6 @@ function LogsActionsContainer({
isLoading: boolean;
isError: boolean;
isSuccess: boolean;
minTime: number;
maxTime: number;
}): JSX.Element {
const { options, config } = useOptionsMenu({
storageKey: LOCALSTORAGE.LOGS_LIST_OPTIONS,
@@ -96,13 +92,7 @@ function LogsActionsContainer({
/>
</div>
<div className="download-options-container">
<LogsDownloadOptionsMenu
startTime={minTime}
endTime={maxTime}
filter={listQuery?.filter?.expression || ''}
columns={config.addColumn?.value || []}
orderBy={orderBy}
/>
<DownloadOptionsMenu dataSource={DataSource.LOGS} />
</div>
<div className="format-options-container">
<LogsFormatOptionsMenu

View File

@@ -444,8 +444,6 @@ function LogsExplorerViewsContainer({
isLoading={isLoading}
isError={isError}
isSuccess={isSuccess}
minTime={minTime}
maxTime={maxTime}
/>
)}

View File

@@ -168,7 +168,7 @@ describe('LogsExplorerViews -', () => {
lodsQueryServerRequest();
const { queryByTestId } = renderer();
const periscopeDownloadButtonTestId = 'periscope-btn-download-options';
const periscopeDownloadButtonTestId = 'periscope-btn-download-logs';
const periscopeFormatButtonTestId = 'periscope-btn-format-options';
// Test that the periscope button is present

View File

@@ -6,11 +6,24 @@
// - Handling multiple rows correctly
// - Handling widgets with different heights
import { ReactNode } from 'react';
import { I18nextProvider } from 'react-i18next';
import { useSearchParams } from 'react-router-dom-v5-compat';
import { screen } from '@testing-library/react';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { DashboardProvider } from 'providers/Dashboard/Dashboard';
import { useDashboardBootstrap } from 'hooks/dashboard/useDashboardBootstrap';
function DashboardBootstrapWrapper({
dashboardId,
children,
}: {
dashboardId: string;
children: ReactNode;
}): JSX.Element {
useDashboardBootstrap(dashboardId);
// eslint-disable-next-line react/jsx-no-useless-fragment
return <>{children}</>;
}
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
import i18n from 'ReactI18';
import {
@@ -309,7 +322,7 @@ describe('Stacking bar in new panel', () => {
const { container, getByText } = render(
<I18nextProvider i18n={i18n}>
<DashboardProvider dashboardId="">
<DashboardBootstrapWrapper dashboardId="">
<PreferenceContextProvider>
<NewWidget
dashboardId=""
@@ -317,7 +330,7 @@ describe('Stacking bar in new panel', () => {
selectedGraph={PANEL_TYPES.BAR}
/>
</PreferenceContextProvider>
</DashboardProvider>
</DashboardBootstrapWrapper>
</I18nextProvider>,
);
@@ -362,13 +375,13 @@ describe('when switching to BAR panel type', () => {
});
const { getByTestId, getByText, container } = render(
<DashboardProvider dashboardId="">
<DashboardBootstrapWrapper dashboardId="">
<NewWidget
dashboardId=""
selectedDashboard={undefined}
selectedGraph={PANEL_TYPES.BAR}
/>
</DashboardProvider>,
</DashboardBootstrapWrapper>,
);
expect(getByTestId('panel-change-select')).toHaveAttribute(

View File

@@ -2,16 +2,15 @@ import { Dispatch, SetStateAction } from 'react';
import { UseQueryResult } from 'react-query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import { IDashboardContext } from 'providers/Dashboard/types';
import { SuccessResponse, Warning } from 'types/api';
import { Widgets } from 'types/api/dashboard/getAll';
import { Dashboard, Widgets } from 'types/api/dashboard/getAll';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { timePreferance } from './RightContainer/timeItems';
export interface NewWidgetProps {
dashboardId: string;
selectedDashboard: IDashboardContext['selectedDashboard'];
selectedDashboard: Dashboard | undefined;
selectedGraph: PANEL_TYPES;
enableDrillDown?: boolean;
}
@@ -35,7 +34,7 @@ export interface WidgetGraphProps {
>
>;
enableDrillDown?: boolean;
selectedDashboard: IDashboardContext['selectedDashboard'];
selectedDashboard: Dashboard | undefined;
isNewPanel?: boolean;
}

View File

@@ -6122,5 +6122,95 @@
],
"id": "huggingface-observability",
"link": "/docs/huggingface-observability/"
},
{
"dataSource": "mistral-observability",
"label": "Mistral AI",
"imgUrl": "/Logos/mistral.svg",
"tags": [
"LLM Monitoring"
],
"module": "apm",
"relatedSearchKeywords": [
"llm",
"llm monitoring",
"mistral",
"mistral ai",
"monitoring",
"observability",
"otel mistral",
"traces",
"tracing"
],
"id": "mistral-observability",
"link": "/docs/mistral-observability/"
},
{
"dataSource": "openclaw-observability",
"label": "OpenClaw",
"imgUrl": "/Logos/openclaw.svg",
"tags": [
"LLM Monitoring"
],
"module": "apm",
"relatedSearchKeywords": [
"llm",
"llm monitoring",
"monitoring",
"observability",
"openclaw",
"otel openclaw",
"traces",
"tracing"
],
"id": "openclaw-observability",
"link": "/docs/openclaw-monitoring/"
},
{
"dataSource": "claude-agent-monitoring",
"label": "Claude Agent SDK",
"imgUrl": "/Logos/claude-code.svg",
"tags": [
"LLM Monitoring"
],
"module": "apm",
"relatedSearchKeywords": [
"anthropic",
"claude",
"claude agent",
"claude agent sdk",
"claude sdk",
"llm",
"llm monitoring",
"monitoring",
"observability",
"otel claude",
"traces",
"tracing"
],
"id": "claude-agent-monitoring",
"link": "/docs/claude-agent-monitoring/"
},
{
"dataSource": "render-metrics",
"label": "Render",
"imgUrl": "/Logos/render.svg",
"tags": [
"infrastructure monitoring",
"metrics"
],
"module": "metrics",
"relatedSearchKeywords": [
"infrastructure",
"metrics",
"monitoring",
"observability",
"paas",
"render",
"render metrics",
"render monitoring"
],
"id": "render-metrics",
"link": "/docs/metrics-management/render-metrics/"
}
]

View File

@@ -11,7 +11,7 @@ import useContextVariables from 'hooks/dashboard/useContextVariables';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import createQueryParams from 'lib/createQueryParams';
import ContextMenu from 'periscope/components/ContextMenu';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import { ContextLinksData } from 'types/api/dashboard/getAll';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
@@ -66,7 +66,7 @@ const useBaseAggregateOptions = ({
getUpdatedQuery,
isLoading: isResolveQueryLoading,
} = useUpdatedQuery();
const { selectedDashboard } = useDashboard();
const { selectedDashboard } = useDashboardStore();
useEffect(() => {
if (!aggregateData) {

View File

@@ -12,8 +12,8 @@ jest.mock('react-router-dom', () => ({
}));
// Mock useDashabord hook
jest.mock('providers/Dashboard/Dashboard', () => ({
useDashboard: (): any => ({
jest.mock('providers/Dashboard/store/useDashboardStore', () => ({
useDashboardStore: (): any => ({
selectedDashboard: {
data: {
variables: [],

View File

@@ -1,6 +1,7 @@
.trace-explorer-controls {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 8px;
.order-by-container {

View File

@@ -11,6 +11,7 @@ import {
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import logEvent from 'api/common/logEvent';
import DownloadOptionsMenu from 'components/DownloadOptionsMenu/DownloadOptionsMenu';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import ListViewOrderBy from 'components/OrderBy/ListViewOrderBy';
import { ResizeTable } from 'components/ResizeTable';
@@ -238,6 +239,8 @@ function ListView({
/>
</div>
<DownloadOptionsMenu dataSource={DataSource.TRACES} />
<TraceExplorerControls
isLoading={isFetching}
totalCount={totalCount}

View File

@@ -1,5 +1,5 @@
import { renderHook } from '@testing-library/react';
import { useDashboardVariablesFromLocalStorage } from 'hooks/dashboard/useDashboardFromLocalStorage';
import { getLocalStorageDashboardVariables } from 'hooks/dashboard/useDashboardFromLocalStorage';
import { useTransformDashboardVariables } from 'hooks/dashboard/useTransformDashboardVariables';
import useVariablesFromUrl from 'hooks/dashboard/useVariablesFromUrl';
import { Dashboard, IDashboardVariable } from 'types/api/dashboard/getAll';
@@ -7,8 +7,8 @@ import { Dashboard, IDashboardVariable } from 'types/api/dashboard/getAll';
jest.mock('hooks/dashboard/useDashboardFromLocalStorage');
jest.mock('hooks/dashboard/useVariablesFromUrl');
const mockUseDashboardVariablesFromLocalStorage = useDashboardVariablesFromLocalStorage as jest.MockedFunction<
typeof useDashboardVariablesFromLocalStorage
const mockGetLocalStorageDashboardVariables = getLocalStorageDashboardVariables as jest.MockedFunction<
typeof getLocalStorageDashboardVariables
>;
const mockUseVariablesFromUrl = useVariablesFromUrl as jest.MockedFunction<
typeof useVariablesFromUrl
@@ -46,10 +46,7 @@ const setupHook = (
currentDashboard: Record<string, any> = {},
urlVariables: Record<string, any> = {},
): ReturnType<typeof useTransformDashboardVariables> => {
mockUseDashboardVariablesFromLocalStorage.mockReturnValue({
currentDashboard,
updateLocalStorageDashboardVariables: jest.fn(),
});
mockGetLocalStorageDashboardVariables.mockReturnValue(currentDashboard as any);
mockUseVariablesFromUrl.mockReturnValue({
getUrlVariables: () => urlVariables,
setUrlVariables: jest.fn(),

View File

@@ -0,0 +1,164 @@
import { useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
// eslint-disable-next-line no-restricted-imports
import { useDispatch, useSelector } from 'react-redux';
import { Modal } from 'antd';
import dayjs from 'dayjs';
import { useTransformDashboardVariables } from 'hooks/dashboard/useTransformDashboardVariables';
import useTabVisibility from 'hooks/useTabFocus';
import { getUpdatedLayout } from 'lib/dashboard/getUpdatedLayout';
import { getMinMaxForSelectedTime } from 'lib/getMinMax';
import { defaultTo } from 'lodash-es';
import { initializeDefaultVariables } from 'providers/Dashboard/initializeDefaultVariables';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import { sortLayout } from 'providers/Dashboard/util';
// eslint-disable-next-line no-restricted-imports
import { Dispatch } from 'redux';
import { AppState } from 'store/reducers';
import AppActions from 'types/actions';
import { UPDATE_TIME_INTERVAL } from 'types/actions/globalTime';
import { Dashboard } from 'types/api/dashboard/getAll';
import { GlobalReducer } from 'types/reducer/globalTime';
import { useDashboardQuery } from './useDashboardQuery';
import { useDashboardVariablesSync } from './useDashboardVariablesSync';
interface UseDashboardBootstrapOptions {
/** Pass `onModal.confirm` from `Modal.useModal()` to get theme-aware modals. Falls back to static `Modal.confirm`. */
confirm?: typeof Modal.confirm;
}
export interface UseDashboardBootstrapReturn {
isLoading: boolean;
isError: boolean;
isFetching: boolean;
error: unknown;
}
export function useDashboardBootstrap(
dashboardId: string,
options: UseDashboardBootstrapOptions = {},
): UseDashboardBootstrapReturn {
const confirm = options.confirm ?? Modal.confirm;
const { t } = useTranslation(['dashboard']);
const dispatch = useDispatch<Dispatch<AppActions>>();
const globalTime = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const {
setSelectedDashboard,
setLayouts,
setPanelMap,
resetDashboardStore,
} = useDashboardStore();
const dashboardRef = useRef<Dashboard>();
const modalRef = useRef<ReturnType<typeof Modal.confirm>>();
const isVisible = useTabVisibility();
const {
getUrlVariables,
updateUrlVariable,
transformDashboardVariables,
} = useTransformDashboardVariables(dashboardId);
// Keep the external variables store in sync with selectedDashboard
useDashboardVariablesSync(dashboardId);
const dashboardQuery = useDashboardQuery(dashboardId);
// Handle new dashboard data: initialize on first load, detect changes on subsequent fetches.
// React Query's structural sharing means this effect only fires when data actually changes.
useEffect(() => {
if (!dashboardQuery.data?.data) {
return;
}
const updatedDashboardData = transformDashboardVariables(
dashboardQuery.data.data,
);
const updatedDate = dayjs(updatedDashboardData?.updatedAt);
// First load: initialize store and URL variables, then return
if (!dashboardRef.current) {
const variables = updatedDashboardData?.data?.variables;
if (variables) {
initializeDefaultVariables(variables, getUrlVariables, updateUrlVariable);
}
setSelectedDashboard(updatedDashboardData);
dashboardRef.current = updatedDashboardData;
setLayouts(sortLayout(getUpdatedLayout(updatedDashboardData?.data.layout)));
setPanelMap(defaultTo(updatedDashboardData?.data?.panelMap, {}));
return;
}
// Subsequent fetches: skip if updatedAt hasn't advanced
if (!updatedDate.isAfter(dayjs(dashboardRef.current.updatedAt))) {
return;
}
// Data has changed: prompt user if tab is visible
if (isVisible && dashboardRef.current.id === updatedDashboardData?.id) {
const modal = confirm({
centered: true,
title: t('dashboard_has_been_updated'),
content: t('do_you_want_to_refresh_the_dashboard'),
onOk() {
setSelectedDashboard(updatedDashboardData);
const { maxTime, minTime } = getMinMaxForSelectedTime(
globalTime.selectedTime,
globalTime.minTime,
globalTime.maxTime,
);
dispatch({
type: UPDATE_TIME_INTERVAL,
payload: { maxTime, minTime, selectedTime: globalTime.selectedTime },
});
dashboardRef.current = updatedDashboardData;
setLayouts(
sortLayout(getUpdatedLayout(updatedDashboardData?.data.layout)),
);
setPanelMap(defaultTo(updatedDashboardData?.data.panelMap, {}));
},
});
modalRef.current = modal;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dashboardQuery.data]);
// Refetch when tab becomes visible (after initial load)
useEffect(() => {
if (isVisible && dashboardRef.current && !!dashboardId) {
dashboardQuery.refetch();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isVisible]);
// Dismiss stale modal when tab is hidden
useEffect(() => {
if (!isVisible && modalRef.current) {
modalRef.current.destroy();
}
}, [isVisible]);
// Reset store on unmount so stale state doesn't bleed across dashboards
useEffect(
() => (): void => {
resetDashboardStore();
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
return {
isLoading: dashboardQuery.isLoading,
isError: dashboardQuery.isError,
isFetching: dashboardQuery.isFetching,
error: dashboardQuery.error,
};
}

View File

@@ -0,0 +1,68 @@
import getLocalStorageKey from 'api/browser/localstorage/get';
import setLocalStorageKey from 'api/browser/localstorage/set';
import { LOCALSTORAGE } from 'constants/localStorage';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
export interface LocalStoreDashboardVariables {
[id: string]: {
selectedValue: IDashboardVariable['selectedValue'];
allSelected: boolean;
};
}
interface DashboardLocalStorageVariables {
[id: string]: LocalStoreDashboardVariables;
}
function readAll(): DashboardLocalStorageVariables {
const raw = getLocalStorageKey(LOCALSTORAGE.DASHBOARD_VARIABLES);
if (!raw) {
return {};
}
try {
return JSON.parse(raw);
} catch {
console.error('Failed to parse dashboard variables from local storage');
return {};
}
}
function writeAll(data: DashboardLocalStorageVariables): void {
try {
setLocalStorageKey(LOCALSTORAGE.DASHBOARD_VARIABLES, JSON.stringify(data));
} catch {
console.error('Failed to set dashboard variables in local storage');
}
}
/** Read the saved variable selections for a dashboard from localStorage. */
export function getLocalStorageDashboardVariables(
dashboardId: string,
): LocalStoreDashboardVariables {
return readAll()[dashboardId] ?? {};
}
/**
* Write one variable's selection for a dashboard to localStorage.
* All call sites write to the same store with no React state coordination.
*/
export function updateLocalStorageDashboardVariable(
dashboardId: string,
id: string,
selectedValue: IDashboardVariable['selectedValue'],
allSelected: boolean,
isDynamic?: boolean,
): void {
const all = readAll();
all[dashboardId] = {
...(all[dashboardId] ?? {}),
[id]:
isDynamic && allSelected
? {
selectedValue: (undefined as unknown) as IDashboardVariable['selectedValue'],
allSelected: true,
}
: { selectedValue, allSelected },
};
writeAll(all);
}

View File

@@ -1,110 +0,0 @@
import { useEffect, useState } from 'react';
import getLocalStorageKey from 'api/browser/localstorage/get';
import setLocalStorageKey from 'api/browser/localstorage/set';
import { LOCALSTORAGE } from 'constants/localStorage';
import { defaultTo } from 'lodash-es';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
interface LocalStoreDashboardVariables {
[id: string]: {
selectedValue: IDashboardVariable['selectedValue'];
allSelected: boolean;
};
}
interface DashboardLocalStorageVariables {
[id: string]: LocalStoreDashboardVariables;
}
export interface UseDashboardVariablesFromLocalStorageReturn {
currentDashboard: LocalStoreDashboardVariables;
updateLocalStorageDashboardVariables: (
id: string,
selectedValue: IDashboardVariable['selectedValue'],
allSelected: boolean,
isDynamic?: boolean,
) => void;
}
export const useDashboardVariablesFromLocalStorage = (
dashboardId: string,
): UseDashboardVariablesFromLocalStorageReturn => {
const [
allDashboards,
setAllDashboards,
] = useState<DashboardLocalStorageVariables>({});
const [
currentDashboard,
setCurrentDashboard,
] = useState<LocalStoreDashboardVariables>({});
useEffect(() => {
const localStoreDashboardVariablesString = getLocalStorageKey(
LOCALSTORAGE.DASHBOARD_VARIABLES,
);
let localStoreDashboardVariables: DashboardLocalStorageVariables = {};
if (localStoreDashboardVariablesString === null) {
try {
const serialzedData = JSON.stringify({
[dashboardId]: {},
});
setLocalStorageKey(LOCALSTORAGE.DASHBOARD_VARIABLES, serialzedData);
} catch {
console.error('Failed to seralise the data');
}
} else {
try {
localStoreDashboardVariables = JSON.parse(
localStoreDashboardVariablesString,
);
} catch {
console.error('Failed to parse dashboards from local storage');
localStoreDashboardVariables = {};
} finally {
setAllDashboards(localStoreDashboardVariables);
}
}
setCurrentDashboard(defaultTo(localStoreDashboardVariables[dashboardId], {}));
}, [dashboardId]);
useEffect(() => {
try {
const serializedData = JSON.stringify(allDashboards);
setLocalStorageKey(LOCALSTORAGE.DASHBOARD_VARIABLES, serializedData);
} catch {
console.error('Failed to set dashboards in local storage');
}
}, [allDashboards]);
useEffect(() => {
setAllDashboards((prev) => ({
...prev,
[dashboardId]: { ...currentDashboard },
}));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentDashboard]);
const updateLocalStorageDashboardVariables = (
id: string,
selectedValue: IDashboardVariable['selectedValue'],
allSelected: boolean,
isDynamic?: boolean,
): void => {
setCurrentDashboard((prev) => ({
...prev,
[id]:
isDynamic && allSelected
? {
selectedValue: (undefined as unknown) as IDashboardVariable['selectedValue'],
allSelected: true,
}
: { selectedValue, allSelected },
}));
};
return {
currentDashboard,
updateLocalStorageDashboardVariables,
};
};

View File

@@ -0,0 +1,49 @@
import { useQuery, UseQueryResult } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import getDashboard from 'api/v1/dashboards/id/get';
import {
DASHBOARD_CACHE_TIME,
DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
} from 'constants/queryCacheTime';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useAppContext } from 'providers/App/App';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { AppState } from 'store/reducers';
import { SuccessResponseV2 } from 'types/api';
import { Dashboard } from 'types/api/dashboard/getAll';
import APIError from 'types/api/error';
import { GlobalReducer } from 'types/reducer/globalTime';
/**
* Fetches a dashboard by ID. Handles auth gating, cache time based on
* auto-refresh setting, and surfaces API errors via the error modal.
*/
export function useDashboardQuery(
dashboardId: string,
): UseQueryResult<SuccessResponseV2<Dashboard>> {
const { isLoggedIn } = useAppContext();
const { showErrorModal } = useErrorModal();
const globalTime = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
return useQuery(
[
REACT_QUERY_KEY.DASHBOARD_BY_ID,
dashboardId,
globalTime.isAutoRefreshDisabled,
],
{
enabled: !!dashboardId && isLoggedIn,
queryFn: () => getDashboard({ id: dashboardId }),
refetchOnWindowFocus: false,
cacheTime: globalTime.isAutoRefreshDisabled
? DASHBOARD_CACHE_TIME
: DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
onError: (error) => {
showErrorModal(error as APIError);
},
},
);
}

View File

@@ -0,0 +1,33 @@
import { useEffect } from 'react';
import isEqual from 'lodash-es/isEqual';
import {
setDashboardVariablesStore,
updateDashboardVariablesStore,
} from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStore';
import {
DashboardStore,
useDashboardStore,
} from 'providers/Dashboard/store/useDashboardStore';
import { useDashboardVariablesSelector } from './useDashboardVariables';
/**
* Keeps the external variables store in sync with the zustand dashboard store.
* When selectedDashboard changes, propagates variable updates to the variables store.
*/
export function useDashboardVariablesSync(dashboardId: string): void {
const dashboardVariables = useDashboardVariablesSelector((s) => s.variables);
const savedDashboardId = useDashboardVariablesSelector((s) => s.dashboardId);
const selectedDashboard = useDashboardStore(
(s: DashboardStore) => s.selectedDashboard,
);
useEffect(() => {
const updatedVariables = selectedDashboard?.data.variables || {};
if (savedDashboardId !== dashboardId) {
setDashboardVariablesStore({ dashboardId, variables: updatedVariables });
} else if (!isEqual(dashboardVariables, updatedVariables)) {
updateDashboardVariablesStore({ dashboardId, variables: updatedVariables });
}
}, [selectedDashboard]); // eslint-disable-line react-hooks/exhaustive-deps
}

View File

@@ -0,0 +1,42 @@
import { useMutation } from 'react-query';
import locked from 'api/v1/dashboards/id/lock';
import {
getSelectedDashboard,
useDashboardStore,
} from 'providers/Dashboard/store/useDashboardStore';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
/**
* Hook for toggling dashboard locked state.
* Calls the lock API and syncs the result into the Zustand store.
*/
export function useLockDashboard(): (value: boolean) => Promise<void> {
const { showErrorModal } = useErrorModal();
const { setSelectedDashboard } = useDashboardStore();
const { mutate: lockDashboard } = useMutation(locked, {
onSuccess: (_, props) => {
setSelectedDashboard((prev) =>
prev ? { ...prev, locked: props.lock } : prev,
);
},
onError: (error) => {
showErrorModal(error as APIError);
},
});
return async (value: boolean): Promise<void> => {
const selectedDashboard = getSelectedDashboard();
if (selectedDashboard) {
try {
await lockDashboard({
id: selectedDashboard.id,
lock: value,
});
} catch (error) {
showErrorModal(error as APIError);
}
}
};
}

View File

@@ -1,8 +1,5 @@
import { ALL_SELECTED_VALUE } from 'components/NewSelect/utils';
import {
useDashboardVariablesFromLocalStorage,
UseDashboardVariablesFromLocalStorageReturn,
} from 'hooks/dashboard/useDashboardFromLocalStorage';
import { getLocalStorageDashboardVariables } from 'hooks/dashboard/useDashboardFromLocalStorage';
import useVariablesFromUrl, {
UseVariablesFromUrlReturn,
} from 'hooks/dashboard/useVariablesFromUrl';
@@ -13,14 +10,10 @@ import { v4 as generateUUID } from 'uuid';
export function useTransformDashboardVariables(
dashboardId: string,
): Pick<UseVariablesFromUrlReturn, 'getUrlVariables' | 'updateUrlVariable'> &
UseDashboardVariablesFromLocalStorageReturn & {
transformDashboardVariables: (data: Dashboard) => Dashboard;
} {
const {
currentDashboard,
updateLocalStorageDashboardVariables,
} = useDashboardVariablesFromLocalStorage(dashboardId);
): Pick<UseVariablesFromUrlReturn, 'getUrlVariables' | 'updateUrlVariable'> & {
transformDashboardVariables: (data: Dashboard) => Dashboard;
currentDashboard: ReturnType<typeof getLocalStorageDashboardVariables>;
} {
const { getUrlVariables, updateUrlVariable } = useVariablesFromUrl();
const mergeDBWithLocalStorage = (
@@ -80,7 +73,7 @@ export function useTransformDashboardVariables(
if (data && data.data && data.data.variables) {
const clonedDashboardData = mergeDBWithLocalStorage(
JSON.parse(JSON.stringify(data)),
currentDashboard,
getLocalStorageDashboardVariables(dashboardId),
);
const { variables } = clonedDashboardData.data;
const existingOrders: Set<number> = new Set();
@@ -122,7 +115,6 @@ export function useTransformDashboardVariables(
transformDashboardVariables,
getUrlVariables,
updateUrlVariable,
currentDashboard,
updateLocalStorageDashboardVariables,
currentDashboard: getLocalStorageDashboardVariables(dashboardId),
};
}

View File

@@ -1,7 +1,5 @@
import { useMutation, UseMutationResult } from 'react-query';
import update from 'api/v1/dashboards/id/update';
import dayjs from 'dayjs';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { SuccessResponseV2 } from 'types/api';
import { Dashboard } from 'types/api/dashboard/getAll';
@@ -9,14 +7,8 @@ import { Props } from 'types/api/dashboard/update';
import APIError from 'types/api/error';
export const useUpdateDashboard = (): UseUpdateDashboard => {
const { updatedTimeRef } = useDashboard();
const { showErrorModal } = useErrorModal();
return useMutation(update, {
onSuccess: (data) => {
if (data.data) {
updatedTimeRef.current = dayjs(data.data.updatedAt);
}
},
onError: (error) => {
showErrorModal(error);
},

View File

@@ -1,7 +1,7 @@
import { useMemo } from 'react';
import { PANEL_GROUP_TYPES } from 'constants/queryBuilder';
import { createDynamicVariableToWidgetsMap } from 'hooks/dashboard/utils';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import { Widgets } from 'types/api/dashboard/getAll';
import { useDashboardVariablesByType } from './useDashboardVariablesByType';
@@ -12,7 +12,7 @@ import { useDashboardVariablesByType } from './useDashboardVariablesByType';
*/
export function useWidgetsByDynamicVariableId(): Record<string, string[]> {
const dynamicVariables = useDashboardVariablesByType('DYNAMIC', 'values');
const { selectedDashboard } = useDashboard();
const { selectedDashboard } = useDashboardStore();
return useMemo(() => {
const widgets =

View File

@@ -17,7 +17,7 @@ import { useNotifications } from 'hooks/useNotifications';
import { getDashboardVariables } from 'lib/dashboardVariables/getDashboardVariables';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import { isEmpty } from 'lodash-es';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import { AppState } from 'store/reducers';
import { Widgets } from 'types/api/dashboard/getAll';
import { GlobalReducer } from 'types/reducer/globalTime';
@@ -33,7 +33,7 @@ const useCreateAlerts = (widget?: Widgets, caller?: string): VoidFunction => {
const { notifications } = useNotifications();
const { selectedDashboard } = useDashboard();
const { selectedDashboard } = useDashboardStore();
const { dashboardVariables } = useDashboardVariables();
const dashboardDynamicVariables = useDashboardVariablesByType(

View File

@@ -0,0 +1,95 @@
import { useCallback, useState } from 'react';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { message } from 'antd';
import { downloadExportData } from 'api/v1/download/downloadExportData';
import { prepareQueryRangePayloadV5 } from 'api/v5/v5';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { AppState } from 'store/reducers';
import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
interface ExportOptions {
format: string;
rowLimit: number;
clearSelectColumns: boolean;
}
interface UseExportRawDataProps {
dataSource: DataSource;
}
interface UseExportRawDataReturn {
isDownloading: boolean;
handleExportRawData: (options: ExportOptions) => Promise<void>;
}
export function useExportRawData({
dataSource,
}: UseExportRawDataProps): UseExportRawDataReturn {
const [isDownloading, setIsDownloading] = useState<boolean>(false);
const { stagedQuery } = useQueryBuilder();
const { selectedTime: globalSelectedInterval } = useSelector<
AppState,
GlobalReducer
>((state) => state.globalTime);
const handleExportRawData = useCallback(
async ({
format,
rowLimit,
clearSelectColumns,
}: ExportOptions): Promise<void> => {
if (!stagedQuery) {
return;
}
try {
setIsDownloading(true);
const exportQuery = {
...stagedQuery,
builder: {
...stagedQuery.builder,
queryData: stagedQuery.builder.queryData.map((qd) => ({
...qd,
groupBy: [],
having: { expression: '' },
limit: rowLimit,
...(clearSelectColumns && { selectColumns: [] }),
})),
queryTraceOperator: (stagedQuery.builder.queryTraceOperator || []).map(
(traceOp) => ({
...traceOp,
groupBy: [],
having: { expression: '' },
limit: rowLimit,
...(clearSelectColumns && { selectColumns: [] }),
}),
),
},
};
const { queryPayload } = prepareQueryRangePayloadV5({
query: exportQuery,
graphType: PANEL_TYPES.LIST,
selectedTime: 'GLOBAL_TIME',
globalSelectedInterval,
});
await downloadExportData({ format, body: queryPayload });
message.success('Export completed successfully');
} catch (error) {
message.error(`Failed to export ${dataSource}. Please try again.`);
} finally {
setIsDownloading(false);
}
},
[stagedQuery, globalSelectedInterval, dataSource],
);
return { isDownloading, handleExportRawData };
}

View File

@@ -1,40 +0,0 @@
import { useEffect } from 'react';
import { Typography } from 'antd';
import { AxiosError } from 'axios';
import NotFound from 'components/NotFound';
import Spinner from 'components/Spinner';
import DashboardContainer from 'container/DashboardContainer';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { ErrorType } from 'types/common';
function DashboardPage(): JSX.Element {
const { dashboardResponse } = useDashboard();
const { isFetching, isError, isLoading } = dashboardResponse;
const errorMessage = isError
? (dashboardResponse?.error as AxiosError<{ errorType: string }>)?.response
?.data?.errorType
: 'Something went wrong';
useEffect(() => {
const dashboardTitle = dashboardResponse.data?.data.data.title;
document.title = dashboardTitle || document.title;
}, [dashboardResponse.data?.data.data.title, isFetching]);
if (isError && !isFetching && errorMessage === ErrorType.NotFound) {
return <NotFound />;
}
if (isError && errorMessage) {
return <Typography>{errorMessage}</Typography>;
}
if (isLoading) {
return <Spinner tip="Loading.." />;
}
return <DashboardContainer />;
}
export default DashboardPage;

View File

@@ -1,16 +1,56 @@
import { useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { DashboardProvider } from 'providers/Dashboard/Dashboard';
import { Modal, Typography } from 'antd';
import { AxiosError } from 'axios';
import NotFound from 'components/NotFound';
import Spinner from 'components/Spinner';
import DashboardContainer from 'container/DashboardContainer';
import { useDashboardBootstrap } from 'hooks/dashboard/useDashboardBootstrap';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import { ErrorType } from 'types/common';
import DashboardPage from './DashboardPage';
function DashboardPageWithProvider(): JSX.Element {
function DashboardPage(): JSX.Element {
const { dashboardId } = useParams<{ dashboardId: string }>();
const [onModal, Content] = Modal.useModal();
const {
isLoading,
isError,
isFetching,
error,
} = useDashboardBootstrap(dashboardId, { confirm: onModal.confirm });
const dashboardTitle = useDashboardStore(
(s) => s.selectedDashboard?.data.title,
);
useEffect(() => {
document.title = dashboardTitle || document.title;
}, [dashboardTitle]);
const errorMessage = isError
? (error as AxiosError<{ errorType: string }>)?.response?.data?.errorType
: 'Something went wrong';
if (isError && !isFetching && errorMessage === ErrorType.NotFound) {
return <NotFound />;
}
if (isError && errorMessage) {
return <Typography>{errorMessage}</Typography>;
}
if (isLoading) {
return <Spinner tip="Loading.." />;
}
return (
<DashboardProvider dashboardId={dashboardId}>
<DashboardPage />
</DashboardProvider>
<>
{Content}
<DashboardContainer />
</>
);
}
export default DashboardPageWithProvider;
export default DashboardPage;

View File

@@ -1,365 +0,0 @@
import {
// eslint-disable-next-line no-restricted-imports
createContext,
PropsWithChildren,
// eslint-disable-next-line no-restricted-imports
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { Layout } from 'react-grid-layout';
import { useTranslation } from 'react-i18next';
import { useMutation, useQuery, UseQueryResult } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { useDispatch, useSelector } from 'react-redux';
import { Modal } from 'antd';
import getDashboard from 'api/v1/dashboards/id/get';
import locked from 'api/v1/dashboards/id/lock';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import dayjs, { Dayjs } from 'dayjs';
import { useTransformDashboardVariables } from 'hooks/dashboard/useTransformDashboardVariables';
import useTabVisibility from 'hooks/useTabFocus';
import { getUpdatedLayout } from 'lib/dashboard/getUpdatedLayout';
import { getMinMaxForSelectedTime } from 'lib/getMinMax';
import { defaultTo } from 'lodash-es';
import isEqual from 'lodash-es/isEqual';
import isUndefined from 'lodash-es/isUndefined';
import omitBy from 'lodash-es/omitBy';
import { useAppContext } from 'providers/App/App';
import { initializeDefaultVariables } from 'providers/Dashboard/initializeDefaultVariables';
import { useErrorModal } from 'providers/ErrorModalProvider';
// eslint-disable-next-line no-restricted-imports
import { Dispatch } from 'redux';
import { AppState } from 'store/reducers';
import AppActions from 'types/actions';
import { UPDATE_TIME_INTERVAL } from 'types/actions/globalTime';
import { SuccessResponseV2 } from 'types/api';
import { Dashboard } from 'types/api/dashboard/getAll';
import APIError from 'types/api/error';
import { GlobalReducer } from 'types/reducer/globalTime';
import {
DASHBOARD_CACHE_TIME,
DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
} from '../../constants/queryCacheTime';
import { useDashboardVariablesSelector } from '../../hooks/dashboard/useDashboardVariables';
import {
setDashboardVariablesStore,
updateDashboardVariablesStore,
} from './store/dashboardVariables/dashboardVariablesStore';
import { IDashboardContext, WidgetColumnWidths } from './types';
import { sortLayout } from './util';
export const DashboardContext = createContext<IDashboardContext>({
isDashboardLocked: false,
handleDashboardLockToggle: () => {},
dashboardResponse: {} as UseQueryResult<
SuccessResponseV2<Dashboard>,
APIError
>,
selectedDashboard: {} as Dashboard,
layouts: [],
panelMap: {},
setPanelMap: () => {},
setLayouts: () => {},
setSelectedDashboard: () => {},
updatedTimeRef: {} as React.MutableRefObject<Dayjs | null>,
updateLocalStorageDashboardVariables: () => {},
dashboardQueryRangeCalled: false,
setDashboardQueryRangeCalled: () => {},
isDashboardFetching: false,
columnWidths: {},
setColumnWidths: () => {},
});
// eslint-disable-next-line sonarjs/cognitive-complexity
export function DashboardProvider({
children,
dashboardId,
}: PropsWithChildren<{ dashboardId: string }>): JSX.Element {
const [isDashboardLocked, setIsDashboardLocked] = useState<boolean>(false);
const [
dashboardQueryRangeCalled,
setDashboardQueryRangeCalled,
] = useState<boolean>(false);
const { showErrorModal } = useErrorModal();
const dispatch = useDispatch<Dispatch<AppActions>>();
const globalTime = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const [onModal, Content] = Modal.useModal();
const [layouts, setLayouts] = useState<Layout[]>([]);
const [panelMap, setPanelMap] = useState<
Record<string, { widgets: Layout[]; collapsed: boolean }>
>({});
const { isLoggedIn } = useAppContext();
const [selectedDashboard, setSelectedDashboard] = useState<Dashboard>();
const dashboardVariables = useDashboardVariablesSelector((s) => s.variables);
const savedDashboardId = useDashboardVariablesSelector((s) => s.dashboardId);
useEffect(() => {
const existingVariables = dashboardVariables;
const updatedVariables = selectedDashboard?.data.variables || {};
if (savedDashboardId !== dashboardId) {
setDashboardVariablesStore({
dashboardId,
variables: updatedVariables,
});
} else if (!isEqual(existingVariables, updatedVariables)) {
updateDashboardVariablesStore({
dashboardId,
variables: updatedVariables,
});
}
}, [selectedDashboard]);
const {
currentDashboard,
updateLocalStorageDashboardVariables,
getUrlVariables,
updateUrlVariable,
transformDashboardVariables,
} = useTransformDashboardVariables(dashboardId);
const updatedTimeRef = useRef<Dayjs | null>(null); // Using ref to store the updated time
const modalRef = useRef<any>(null);
const isVisible = useTabVisibility();
const { t } = useTranslation(['dashboard']);
const dashboardRef = useRef<Dashboard>();
const [isDashboardFetching, setIsDashboardFetching] = useState<boolean>(false);
const dashboardResponse = useQuery(
[
REACT_QUERY_KEY.DASHBOARD_BY_ID,
dashboardId,
globalTime.isAutoRefreshDisabled,
],
{
enabled: !!dashboardId && isLoggedIn,
queryFn: async () => {
setIsDashboardFetching(true);
try {
return await getDashboard({
id: dashboardId,
});
} catch (error) {
showErrorModal(error as APIError);
return;
} finally {
setIsDashboardFetching(false);
}
},
refetchOnWindowFocus: false,
cacheTime: globalTime.isAutoRefreshDisabled
? DASHBOARD_CACHE_TIME
: DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
onError: (error) => {
showErrorModal(error as APIError);
},
onSuccess: (data: SuccessResponseV2<Dashboard>) => {
const updatedDashboardData = transformDashboardVariables(data?.data);
// initialize URL variables after dashboard state is set to avoid race conditions
const variables = updatedDashboardData?.data?.variables;
if (variables) {
initializeDefaultVariables(variables, getUrlVariables, updateUrlVariable);
}
const updatedDate = dayjs(updatedDashboardData?.updatedAt);
setIsDashboardLocked(updatedDashboardData?.locked || false);
// on first render
if (updatedTimeRef.current === null) {
setSelectedDashboard(updatedDashboardData);
updatedTimeRef.current = updatedDate;
dashboardRef.current = updatedDashboardData;
setLayouts(
sortLayout(getUpdatedLayout(updatedDashboardData?.data.layout)),
);
setPanelMap(defaultTo(updatedDashboardData?.data?.panelMap, {}));
}
if (
updatedTimeRef.current !== null &&
updatedDate.isAfter(updatedTimeRef.current) &&
isVisible &&
dashboardRef.current?.id === updatedDashboardData?.id
) {
// show modal when state is out of sync
const modal = onModal.confirm({
centered: true,
title: t('dashboard_has_been_updated'),
content: t('do_you_want_to_refresh_the_dashboard'),
onOk() {
setSelectedDashboard(updatedDashboardData);
const { maxTime, minTime } = getMinMaxForSelectedTime(
globalTime.selectedTime,
globalTime.minTime,
globalTime.maxTime,
);
dispatch({
type: UPDATE_TIME_INTERVAL,
payload: {
maxTime,
minTime,
selectedTime: globalTime.selectedTime,
},
});
dashboardRef.current = updatedDashboardData;
updatedTimeRef.current = dayjs(updatedDashboardData?.updatedAt);
setLayouts(
sortLayout(getUpdatedLayout(updatedDashboardData?.data.layout)),
);
setPanelMap(defaultTo(updatedDashboardData?.data.panelMap, {}));
},
});
modalRef.current = modal;
} else {
// normal flow
updatedTimeRef.current = dayjs(updatedDashboardData?.updatedAt);
dashboardRef.current = updatedDashboardData;
if (!isEqual(selectedDashboard, updatedDashboardData)) {
setSelectedDashboard(updatedDashboardData);
}
if (
!isEqual(
[omitBy(layouts, (value): boolean => isUndefined(value))[0]],
updatedDashboardData?.data.layout,
)
) {
setLayouts(
sortLayout(getUpdatedLayout(updatedDashboardData?.data.layout)),
);
setPanelMap(defaultTo(updatedDashboardData?.data.panelMap, {}));
}
}
},
},
);
useEffect(() => {
// make the call on tab visibility only if the user is on dashboard / widget page
if (isVisible && updatedTimeRef.current && !!dashboardId) {
dashboardResponse.refetch();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isVisible]);
useEffect(() => {
if (!isVisible && modalRef.current) {
modalRef.current.destroy();
}
}, [isVisible]);
const { mutate: lockDashboard } = useMutation(locked, {
onSuccess: (_, props) => {
setIsDashboardLocked(props.lock);
},
onError: (error) => {
showErrorModal(error as APIError);
},
});
const handleDashboardLockToggle = async (value: boolean): Promise<void> => {
if (selectedDashboard) {
try {
await lockDashboard({
id: selectedDashboard.id,
lock: value,
});
} catch (error) {
showErrorModal(error as APIError);
}
}
};
const [columnWidths, setColumnWidths] = useState<WidgetColumnWidths>({});
const value: IDashboardContext = useMemo(
() => ({
isDashboardLocked,
handleDashboardLockToggle,
dashboardResponse,
selectedDashboard,
dashboardId,
layouts,
panelMap,
setLayouts,
setPanelMap,
setSelectedDashboard,
updatedTimeRef,
updateLocalStorageDashboardVariables,
dashboardQueryRangeCalled,
setDashboardQueryRangeCalled,
isDashboardFetching,
columnWidths,
setColumnWidths,
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[
isDashboardLocked,
dashboardResponse,
selectedDashboard,
dashboardId,
layouts,
panelMap,
updateLocalStorageDashboardVariables,
currentDashboard,
dashboardQueryRangeCalled,
setDashboardQueryRangeCalled,
isDashboardFetching,
columnWidths,
setColumnWidths,
],
);
return (
<DashboardContext.Provider value={value}>
{Content}
{children}
</DashboardContext.Provider>
);
}
export const useDashboard = (): IDashboardContext => {
const context = useContext(DashboardContext);
if (!context) {
throw new Error('Should be used inside the context');
}
return context;
};

View File

@@ -1,3 +1,4 @@
import { ReactNode } from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
@@ -6,7 +7,20 @@ import { render, RenderResult, screen, waitFor } from '@testing-library/react';
import getDashboard from 'api/v1/dashboards/id/get';
import { DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED } from 'constants/queryCacheTime';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { DashboardProvider, useDashboard } from 'providers/Dashboard/Dashboard';
import { useDashboardBootstrap } from 'hooks/dashboard/useDashboardBootstrap';
function DashboardBootstrapWrapper({
dashboardId,
children,
}: {
dashboardId: string;
children: ReactNode;
}): JSX.Element {
useDashboardBootstrap(dashboardId);
// eslint-disable-next-line react/jsx-no-useless-fragment
return <>{children}</>;
}
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import { useDashboardVariables } from '../../../hooks/dashboard/useDashboardVariables';
@@ -55,17 +69,12 @@ jest.mock('react-redux', () => ({
jest.mock('uuid', () => ({ v4: jest.fn(() => 'mock-uuid') }));
function TestComponent(): JSX.Element {
const { dashboardResponse, selectedDashboard } = useDashboard();
const { selectedDashboard } = useDashboardStore();
const { dashboardVariables } = useDashboardVariables();
return (
<div>
<div data-testid="dashboard-id">{selectedDashboard?.id}</div>
<div data-testid="query-status">{dashboardResponse.status}</div>
<div data-testid="is-loading">{dashboardResponse.isLoading.toString()}</div>
<div data-testid="is-fetching">
{dashboardResponse.isFetching.toString()}
</div>
<div data-testid="dashboard-variables">
{dashboardVariables ? JSON.stringify(dashboardVariables) : 'null'}
</div>
@@ -89,7 +98,7 @@ function createTestQueryClient(): QueryClient {
}
// Helper to render with dashboard provider
function renderWithDashboardProvider(
function renderWithDashboardBootstrap(
dashboardId = 'test-dashboard-id',
): RenderResult {
const queryClient = createTestQueryClient();
@@ -98,9 +107,9 @@ function renderWithDashboardProvider(
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[initialRoute]}>
<DashboardProvider dashboardId={dashboardId}>
<DashboardBootstrapWrapper dashboardId={dashboardId}>
<TestComponent />
</DashboardProvider>
</DashboardBootstrapWrapper>
</MemoryRouter>
</QueryClientProvider>,
);
@@ -172,7 +181,7 @@ describe('Dashboard Provider - Query Key with Route Params', () => {
describe('Query Key Behavior', () => {
it('should include route params in query key when on dashboard page', async () => {
const dashboardId = 'test-dashboard-id';
renderWithDashboardProvider(dashboardId);
renderWithDashboardBootstrap(dashboardId);
await waitFor(() => {
expect(mockGetDashboard).toHaveBeenCalledWith({ id: dashboardId });
@@ -187,7 +196,7 @@ describe('Dashboard Provider - Query Key with Route Params', () => {
const newDashboardId = 'new-dashboard-id';
// First render with initial dashboard ID
const { rerender } = renderWithDashboardProvider(initialDashboardId);
const { rerender } = renderWithDashboardBootstrap(initialDashboardId);
await waitFor(() => {
expect(mockGetDashboard).toHaveBeenCalledWith({ id: initialDashboardId });
@@ -197,9 +206,9 @@ describe('Dashboard Provider - Query Key with Route Params', () => {
rerender(
<QueryClientProvider client={createTestQueryClient()}>
<MemoryRouter initialEntries={[`/dashboard/${newDashboardId}`]}>
<DashboardProvider dashboardId={newDashboardId}>
<DashboardBootstrapWrapper dashboardId={newDashboardId}>
<TestComponent />
</DashboardProvider>
</DashboardBootstrapWrapper>
</MemoryRouter>
</QueryClientProvider>,
);
@@ -213,7 +222,7 @@ describe('Dashboard Provider - Query Key with Route Params', () => {
});
it('should not fetch when no dashboardId is provided', () => {
renderWithDashboardProvider('');
renderWithDashboardBootstrap('');
// Should not call the API
expect(mockGetDashboard).not.toHaveBeenCalled();
@@ -229,9 +238,9 @@ describe('Dashboard Provider - Query Key with Route Params', () => {
const { rerender } = render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[`/dashboard/${dashboardId1}`]}>
<DashboardProvider dashboardId={dashboardId1}>
<DashboardBootstrapWrapper dashboardId={dashboardId1}>
<TestComponent />
</DashboardProvider>
</DashboardBootstrapWrapper>
</MemoryRouter>
</QueryClientProvider>,
);
@@ -243,9 +252,9 @@ describe('Dashboard Provider - Query Key with Route Params', () => {
rerender(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[`/dashboard/${dashboardId2}`]}>
<DashboardProvider dashboardId={dashboardId2}>
<DashboardBootstrapWrapper dashboardId={dashboardId2}>
<TestComponent />
</DashboardProvider>
</DashboardBootstrapWrapper>
</MemoryRouter>
</QueryClientProvider>,
);
@@ -286,9 +295,9 @@ describe('Dashboard Provider - Query Key with Route Params', () => {
render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[`/dashboard/${dashboardId}`]}>
<DashboardProvider dashboardId={dashboardId}>
<DashboardBootstrapWrapper dashboardId={dashboardId}>
<TestComponent />
</DashboardProvider>
</DashboardBootstrapWrapper>
</MemoryRouter>
</QueryClientProvider>,
);
@@ -365,7 +374,7 @@ describe('Dashboard Provider - URL Variables Integration', () => {
// Empty URL variables - tests initialization flow
mockGetUrlVariables.mockReturnValue({});
renderWithDashboardProvider(DASHBOARD_ID);
renderWithDashboardBootstrap(DASHBOARD_ID);
await waitFor(() => {
expect(mockGetDashboard).toHaveBeenCalledWith({ id: DASHBOARD_ID });
@@ -421,7 +430,7 @@ describe('Dashboard Provider - URL Variables Integration', () => {
.mockReturnValueOnce('development')
.mockReturnValueOnce(['db', 'cache']);
renderWithDashboardProvider(DASHBOARD_ID);
renderWithDashboardBootstrap(DASHBOARD_ID);
await waitFor(() => {
expect(mockGetDashboard).toHaveBeenCalledWith({ id: DASHBOARD_ID });
@@ -481,7 +490,7 @@ describe('Dashboard Provider - URL Variables Integration', () => {
mockGetUrlVariables.mockReturnValue(urlVariables);
renderWithDashboardProvider(DASHBOARD_ID);
renderWithDashboardBootstrap(DASHBOARD_ID);
await waitFor(() => {
expect(mockGetDashboard).toHaveBeenCalledWith({ id: DASHBOARD_ID });
@@ -517,7 +526,7 @@ describe('Dashboard Provider - URL Variables Integration', () => {
.mockReturnValueOnce('development')
.mockReturnValueOnce(['api']);
renderWithDashboardProvider(DASHBOARD_ID);
renderWithDashboardBootstrap(DASHBOARD_ID);
await waitFor(() => {
// Verify normalization was called with the specific values and variable configs
@@ -584,7 +593,7 @@ describe('Dashboard Provider - Textbox Variable Backward Compatibility', () => {
} as any);
/* eslint-enable @typescript-eslint/no-explicit-any */
renderWithDashboardProvider(DASHBOARD_ID);
renderWithDashboardBootstrap(DASHBOARD_ID);
await waitFor(() => {
expect(mockGetDashboard).toHaveBeenCalledWith({ id: DASHBOARD_ID });
@@ -626,7 +635,7 @@ describe('Dashboard Provider - Textbox Variable Backward Compatibility', () => {
} as any);
/* eslint-enable @typescript-eslint/no-explicit-any */
renderWithDashboardProvider(DASHBOARD_ID);
renderWithDashboardBootstrap(DASHBOARD_ID);
await waitFor(() => {
expect(mockGetDashboard).toHaveBeenCalledWith({ id: DASHBOARD_ID });
@@ -669,7 +678,7 @@ describe('Dashboard Provider - Textbox Variable Backward Compatibility', () => {
} as any);
/* eslint-enable @typescript-eslint/no-explicit-any */
renderWithDashboardProvider(DASHBOARD_ID);
renderWithDashboardBootstrap(DASHBOARD_ID);
await waitFor(() => {
expect(mockGetDashboard).toHaveBeenCalledWith({ id: DASHBOARD_ID });
@@ -711,7 +720,7 @@ describe('Dashboard Provider - Textbox Variable Backward Compatibility', () => {
} as any);
/* eslint-enable @typescript-eslint/no-explicit-any */
renderWithDashboardProvider(DASHBOARD_ID);
renderWithDashboardBootstrap(DASHBOARD_ID);
await waitFor(() => {
expect(mockGetDashboard).toHaveBeenCalledWith({ id: DASHBOARD_ID });

View File

@@ -0,0 +1,51 @@
import type { Layout } from 'react-grid-layout';
import type { StateCreator } from 'zustand';
import type { DashboardStore } from '../useDashboardStore';
export interface DashboardLayoutSlice {
//
layouts: Layout[];
setLayouts: (updater: Layout[] | ((prev: Layout[]) => Layout[])) => void;
//
panelMap: Record<string, { widgets: Layout[]; collapsed: boolean }>;
setPanelMap: (
updater:
| Record<string, { widgets: Layout[]; collapsed: boolean }>
| ((
prev: Record<string, { widgets: Layout[]; collapsed: boolean }>,
) => Record<string, { widgets: Layout[]; collapsed: boolean }>),
) => void;
// resetDashboardLayout: () => void;
}
export const initialDashboardLayoutState = {
layouts: [] as Layout[],
panelMap: {} as Record<string, { widgets: Layout[]; collapsed: boolean }>,
};
export const createDashboardLayoutSlice: StateCreator<
DashboardStore,
[['zustand/immer', never]],
[],
DashboardLayoutSlice
> = (set) => ({
...initialDashboardLayoutState,
setLayouts: (updater): void =>
set((state) => {
state.layouts =
typeof updater === 'function' ? updater(state.layouts) : updater;
}),
setPanelMap: (updater): void =>
set((state) => {
state.panelMap =
typeof updater === 'function' ? updater(state.panelMap) : updater;
}),
// resetDashboardLayout: () =>
// set((state) => {
// Object.assign(state, initialDashboardLayoutState);
// }),
});

View File

@@ -0,0 +1,57 @@
import type { Dashboard } from 'types/api/dashboard/getAll';
import type { StateCreator } from 'zustand';
import type { DashboardStore } from '../useDashboardStore';
export type WidgetColumnWidths = {
[widgetId: string]: Record<string, number>;
};
export interface DashboardUISlice {
//
selectedDashboard: Dashboard | undefined;
setSelectedDashboard: (
updater:
| Dashboard
| undefined
| ((prev: Dashboard | undefined) => Dashboard | undefined),
) => void;
//
columnWidths: WidgetColumnWidths;
setColumnWidths: (
updater:
| WidgetColumnWidths
| ((prev: WidgetColumnWidths) => WidgetColumnWidths),
) => void;
}
export const initialDashboardUIState = {
selectedDashboard: undefined as Dashboard | undefined,
columnWidths: {} as WidgetColumnWidths,
};
export const createDashboardUISlice: StateCreator<
DashboardStore,
[['zustand/immer', never]],
[],
DashboardUISlice
> = (set) => ({
...initialDashboardUIState,
setSelectedDashboard: (updater): void =>
set((state: DashboardUISlice): void => {
state.selectedDashboard =
typeof updater === 'function' ? updater(state.selectedDashboard) : updater;
}),
setColumnWidths: (updater): void =>
set((state: DashboardUISlice): void => {
state.columnWidths =
typeof updater === 'function' ? updater(state.columnWidths) : updater;
}),
resetDashboardUI: (): void =>
set((state: DashboardUISlice): void => {
Object.assign(state, initialDashboardUIState);
}),
});

View File

@@ -0,0 +1,50 @@
import type { Layout } from 'react-grid-layout';
import type { Dashboard } from 'types/api/dashboard/getAll';
import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';
import {
createDashboardLayoutSlice,
DashboardLayoutSlice,
initialDashboardLayoutState,
} from './slices/dashboardLayoutSlice';
import {
createDashboardUISlice,
DashboardUISlice,
initialDashboardUIState,
} from './slices/dashboardUISlice';
export type DashboardStore = DashboardUISlice &
DashboardLayoutSlice & {
resetDashboardStore: () => void;
};
/**
* 'select*' is a redux naming convention that can be carried over to zustand.
* It is used to select a piece of state from the store.
* In this case, we are selecting the locked state of the selected dashboard.
* */
export const selectIsDashboardLocked = (s: DashboardStore): boolean =>
s.selectedDashboard?.locked ?? false;
export const useDashboardStore = create<DashboardStore>()(
immer((set, get, api) => ({
...createDashboardUISlice(set, get, api),
...createDashboardLayoutSlice(set, get, api),
resetDashboardStore: (): void =>
set((state: DashboardStore) => {
Object.assign(state, initialDashboardUIState, initialDashboardLayoutState);
}),
})),
);
// Standalone imperative accessors — use these instead of calling useDashboardStore.getState() at call sites.
export const getSelectedDashboard = (): Dashboard | undefined =>
useDashboardStore.getState().selectedDashboard;
export const getDashboardLayouts = (): Layout[] =>
useDashboardStore.getState().layouts;
export const resetDashboard = (): void =>
useDashboardStore.getState().resetDashboardStore();

View File

@@ -1,41 +0,0 @@
import { Layout } from 'react-grid-layout';
import { UseQueryResult } from 'react-query';
import dayjs from 'dayjs';
import { SuccessResponseV2 } from 'types/api';
import { Dashboard } from 'types/api/dashboard/getAll';
export type WidgetColumnWidths = {
[widgetId: string]: Record<string, number>;
};
export interface IDashboardContext {
isDashboardLocked: boolean;
handleDashboardLockToggle: (value: boolean) => void;
dashboardResponse: UseQueryResult<SuccessResponseV2<Dashboard>, unknown>;
selectedDashboard: Dashboard | undefined;
layouts: Layout[];
panelMap: Record<string, { widgets: Layout[]; collapsed: boolean }>;
setPanelMap: React.Dispatch<React.SetStateAction<Record<string, any>>>;
setLayouts: React.Dispatch<React.SetStateAction<Layout[]>>;
setSelectedDashboard: React.Dispatch<
React.SetStateAction<Dashboard | undefined>
>;
updatedTimeRef: React.MutableRefObject<dayjs.Dayjs | null>;
updateLocalStorageDashboardVariables: (
id: string,
selectedValue:
| string
| number
| boolean
| (string | number | boolean)[]
| null
| undefined,
allSelected: boolean,
isDynamic?: boolean,
) => void;
dashboardQueryRangeCalled: boolean;
setDashboardQueryRangeCalled: (value: boolean) => void;
isDashboardFetching: boolean;
columnWidths: WidgetColumnWidths;
setColumnWidths: React.Dispatch<React.SetStateAction<WidgetColumnWidths>>;
}

View File

@@ -1,10 +1,6 @@
import { QueryRangePayloadV5 } from 'types/api/v5/queryRange';
export interface ExportRawDataProps {
source: string;
format: string;
start: number;
end: number;
columns: string[];
filter: string;
orderBy: string;
limit: number;
body: QueryRangePayloadV5;
}

4
go.mod
View File

@@ -81,6 +81,8 @@ require (
golang.org/x/oauth2 v0.34.0
golang.org/x/sync v0.19.0
golang.org/x/text v0.33.0
gonum.org/v1/gonum v0.17.0
google.golang.org/api v0.265.0
google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v2 v2.4.0
gopkg.in/yaml.v3 v3.0.1
@@ -377,8 +379,6 @@ require (
golang.org/x/sys v0.40.0 // indirect
golang.org/x/time v0.14.0 // indirect
golang.org/x/tools v0.41.0 // indirect
gonum.org/v1/gonum v0.17.0 // indirect
google.golang.org/api v0.265.0
google.golang.org/genproto/googleapis/api v0.0.0-20260203192932-546029d2fa20 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect
google.golang.org/grpc v1.78.0 // indirect

View File

@@ -18,6 +18,7 @@ import (
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/preference"
"github.com/SigNoz/signoz/pkg/modules/promote"
"github.com/SigNoz/signoz/pkg/modules/rawdataexport"
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
"github.com/SigNoz/signoz/pkg/modules/session"
"github.com/SigNoz/signoz/pkg/modules/user"
@@ -47,9 +48,11 @@ type provider struct {
gatewayHandler gateway.Handler
fieldsHandler fields.Handler
authzHandler authz.Handler
rawDataExportHandler rawdataexport.Handler
zeusHandler zeus.Handler
querierHandler querier.Handler
serviceAccountHandler serviceaccount.Handler
factoryHandler factory.Handler
}
func NewFactory(
@@ -69,9 +72,11 @@ func NewFactory(
gatewayHandler gateway.Handler,
fieldsHandler fields.Handler,
authzHandler authz.Handler,
rawDataExportHandler rawdataexport.Handler,
zeusHandler zeus.Handler,
querierHandler querier.Handler,
serviceAccountHandler serviceaccount.Handler,
factoryHandler factory.Handler,
) factory.ProviderFactory[apiserver.APIServer, apiserver.Config] {
return factory.NewProviderFactory(factory.MustNewName("signoz"), func(ctx context.Context, providerSettings factory.ProviderSettings, config apiserver.Config) (apiserver.APIServer, error) {
return newProvider(
@@ -94,9 +99,11 @@ func NewFactory(
gatewayHandler,
fieldsHandler,
authzHandler,
rawDataExportHandler,
zeusHandler,
querierHandler,
serviceAccountHandler,
factoryHandler,
)
})
}
@@ -121,9 +128,11 @@ func newProvider(
gatewayHandler gateway.Handler,
fieldsHandler fields.Handler,
authzHandler authz.Handler,
rawDataExportHandler rawdataexport.Handler,
zeusHandler zeus.Handler,
querierHandler querier.Handler,
serviceAccountHandler serviceaccount.Handler,
factoryHandler factory.Handler,
) (apiserver.APIServer, error) {
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/apiserver/signozapiserver")
router := mux.NewRouter().UseEncodedPath()
@@ -146,9 +155,11 @@ func newProvider(
gatewayHandler: gatewayHandler,
fieldsHandler: fieldsHandler,
authzHandler: authzHandler,
rawDataExportHandler: rawDataExportHandler,
zeusHandler: zeusHandler,
querierHandler: querierHandler,
serviceAccountHandler: serviceAccountHandler,
factoryHandler: factoryHandler,
}
provider.authZ = middleware.NewAuthZ(settings.Logger(), orgGetter, authz)
@@ -221,6 +232,10 @@ func (provider *provider) AddToRouter(router *mux.Router) error {
return err
}
if err := provider.addRawDataExportRoutes(router); err != nil {
return err
}
if err := provider.addZeusRoutes(router); err != nil {
return err
}
@@ -233,6 +248,10 @@ func (provider *provider) AddToRouter(router *mux.Router) error {
return err
}
if err := provider.addRegistryRoutes(router); err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,33 @@
package signozapiserver
import (
"net/http"
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/exporttypes"
v5 "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/gorilla/mux"
)
func (provider *provider) addRawDataExportRoutes(router *mux.Router) error {
if err := router.Handle("/api/v1/export_raw_data", handler.New(provider.authZ.ViewAccess(provider.rawDataExportHandler.ExportRawData), handler.OpenAPIDef{
ID: "HandleExportRawDataPOST",
Tags: []string{"logs", "traces"},
Summary: "Export raw data",
Description: "This endpoints allows complex query exporting raw data for traces and logs",
Request: new(v5.QueryRangeRequest),
RequestQuery: new(exporttypes.ExportRawDataFormatQueryParam),
RequestContentType: "application/json",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest},
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,84 @@
package signozapiserver
import (
"net/http"
"github.com/SigNoz/signoz/pkg/factory"
pkghandler "github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/gorilla/mux"
openapi "github.com/swaggest/openapi-go"
)
type healthOpenAPIHandler struct {
handlerFunc http.HandlerFunc
id string
summary string
}
func newHealthOpenAPIHandler(handlerFunc http.HandlerFunc, id, summary string) pkghandler.Handler {
return &healthOpenAPIHandler{
handlerFunc: handlerFunc,
id: id,
summary: summary,
}
}
func (handler *healthOpenAPIHandler) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
handler.handlerFunc.ServeHTTP(rw, req)
}
func (handler *healthOpenAPIHandler) ServeOpenAPI(opCtx openapi.OperationContext) {
opCtx.SetID(handler.id)
opCtx.SetTags("health")
opCtx.SetSummary(handler.summary)
response := render.SuccessResponse{
Status: render.StatusSuccess.String(),
Data: new(factory.Response),
}
opCtx.AddRespStructure(
response,
openapi.WithContentType("application/json"),
openapi.WithHTTPStatus(http.StatusOK),
)
opCtx.AddRespStructure(
response,
openapi.WithContentType("application/json"),
openapi.WithHTTPStatus(http.StatusServiceUnavailable),
)
}
func (provider *provider) addRegistryRoutes(router *mux.Router) error {
if err := router.Handle("/api/v2/healthz", newHealthOpenAPIHandler(
provider.authZ.OpenAccess(provider.factoryHandler.Healthz),
"Healthz",
"Health check",
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/readyz", newHealthOpenAPIHandler(
provider.authZ.OpenAccess(provider.factoryHandler.Readyz),
"Readyz",
"Readiness check",
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/livez", pkghandler.New(provider.authZ.OpenAccess(provider.factoryHandler.Livez),
pkghandler.OpenAPIDef{
ID: "Livez",
Tags: []string{"health"},
Summary: "Liveness check",
Response: new(factory.Response),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
},
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
return nil
}

View File

@@ -118,7 +118,7 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
Description: "This endpoint lists all users",
Request: nil,
RequestContentType: "",
Response: make([]*types.GettableUser, 0),
Response: make([]*types.DeprecatedUser, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
@@ -135,7 +135,7 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
Description: "This endpoint returns the user I belong to",
Request: nil,
RequestContentType: "",
Response: new(types.GettableUser),
Response: new(types.DeprecatedUser),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
@@ -152,7 +152,7 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
Description: "This endpoint returns the user by id",
Request: nil,
RequestContentType: "",
Response: new(types.GettableUser),
Response: new(types.DeprecatedUser),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
@@ -167,9 +167,9 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
Tags: []string{"users"},
Summary: "Update user",
Description: "This endpoint updates the user by id",
Request: new(types.User),
Request: new(types.DeprecatedUser),
RequestContentType: "application/json",
Response: new(types.GettableUser),
Response: new(types.DeprecatedUser),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},

View File

@@ -3,6 +3,7 @@ package sqlauthnstore
import (
"context"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
@@ -17,7 +18,7 @@ func NewStore(sqlstore sqlstore.SQLStore) authtypes.AuthNStore {
return &store{sqlstore: sqlstore}
}
func (store *store) GetActiveUserAndFactorPasswordByEmailAndOrgID(ctx context.Context, email string, orgID valuer.UUID) (*types.User, *types.FactorPassword, error) {
func (store *store) GetActiveUserAndFactorPasswordByEmailAndOrgID(ctx context.Context, email string, orgID valuer.UUID) (*types.User, *types.FactorPassword, []*authtypes.UserRole, error) {
user := new(types.User)
factorPassword := new(types.FactorPassword)
@@ -31,7 +32,7 @@ func (store *store) GetActiveUserAndFactorPasswordByEmailAndOrgID(ctx context.Co
Where("status = ?", types.UserStatusActive.StringValue()).
Scan(ctx)
if err != nil {
return nil, nil, store.sqlstore.WrapNotFoundErrf(err, types.ErrCodeUserNotFound, "user with email %s in org %s not found", email, orgID)
return nil, nil, nil, store.sqlstore.WrapNotFoundErrf(err, types.ErrCodeUserNotFound, "user with email %s in org %s not found", email, orgID)
}
err = store.
@@ -42,10 +43,22 @@ func (store *store) GetActiveUserAndFactorPasswordByEmailAndOrgID(ctx context.Co
Where("user_id = ?", user.ID).
Scan(ctx)
if err != nil {
return nil, nil, store.sqlstore.WrapNotFoundErrf(err, types.ErrCodePasswordNotFound, "user with email %s in org %s does not have password", email, orgID)
return nil, nil, nil, store.sqlstore.WrapNotFoundErrf(err, types.ErrCodePasswordNotFound, "user with email %s in org %s does not have password", email, orgID)
}
return user, factorPassword, nil
userRoles := make([]*authtypes.UserRole, 0)
err = store.sqlstore.
BunDBCtx(ctx).
NewSelect().
Model(&userRoles).
Where("user_id = ?", user.ID).
Relation("Role").
Scan(ctx)
if err != nil {
return nil, nil, nil, errors.Newf(errors.TypeInternal, errors.CodeInternal, "failed to get user roles for user %s in org %s", email, orgID)
}
return user, factorPassword, userRoles, nil
}
func (store *store) GetAuthDomainFromID(ctx context.Context, domainID valuer.UUID) (*authtypes.AuthDomain, error) {

View File

@@ -21,7 +21,7 @@ func New(store authtypes.AuthNStore) *AuthN {
}
func (a *AuthN) Authenticate(ctx context.Context, email string, password string, orgID valuer.UUID) (*authtypes.Identity, error) {
user, factorPassword, err := a.store.GetActiveUserAndFactorPasswordByEmailAndOrgID(ctx, email, orgID)
user, factorPassword, userRoles, err := a.store.GetActiveUserAndFactorPasswordByEmailAndOrgID(ctx, email, orgID)
if err != nil {
return nil, err
}
@@ -30,5 +30,11 @@ func (a *AuthN) Authenticate(ctx context.Context, email string, password string,
return nil, errors.New(errors.TypeUnauthenticated, types.ErrCodeIncorrectPassword, "invalid email or password")
}
return authtypes.NewIdentity(user.ID, orgID, user.Email, user.Role, authtypes.IdentNProviderTokenizer), nil
if len(userRoles) == 0 {
return nil, errors.New(errors.TypeUnexpected, authtypes.ErrCodeUserRolesNotFound, "no user roles entries found")
}
role := authtypes.SigNozManagedRoleToExistingLegacyRole[userRoles[0].Role.Name]
return authtypes.NewIdentity(user.ID, orgID, user.Email, role, authtypes.IdentNProviderTokenizer), nil
}

View File

@@ -11,7 +11,7 @@ import (
)
type AuthZ interface {
factory.Service
factory.ServiceWithHealthy
// CheckWithTupleCreation takes upon the responsibility for generating the tuples alongside everything Check does.
CheckWithTupleCreation(context.Context, authtypes.Claims, valuer.UUID, authtypes.Relation, authtypes.Typeable, []authtypes.Selector, []authtypes.Selector) error

View File

@@ -43,6 +43,10 @@ func (provider *provider) Start(ctx context.Context) error {
return provider.server.Start(ctx)
}
func (provider *provider) Healthy() <-chan struct{} {
return provider.server.Healthy()
}
func (provider *provider) Stop(ctx context.Context) error {
return provider.server.Stop(ctx)
}

View File

@@ -31,6 +31,7 @@ type Server struct {
modelID string
mtx sync.RWMutex
stopChan chan struct{}
healthyC chan struct{}
}
func NewOpenfgaServer(ctx context.Context, settings factory.ProviderSettings, config authz.Config, sqlstore sqlstore.SQLStore, openfgaSchema []openfgapkgtransformer.ModuleFile) (*Server, error) {
@@ -61,6 +62,7 @@ func NewOpenfgaServer(ctx context.Context, settings factory.ProviderSettings, co
openfgaSchema: openfgaSchema,
mtx: sync.RWMutex{},
stopChan: make(chan struct{}),
healthyC: make(chan struct{}),
}, nil
}
@@ -80,10 +82,16 @@ func (server *Server) Start(ctx context.Context) error {
server.storeID = storeID
server.mtx.Unlock()
close(server.healthyC)
<-server.stopChan
return nil
}
func (server *Server) Healthy() <-chan struct{} {
return server.healthyC
}
func (server *Server) Stop(ctx context.Context) error {
server.openfgaServer.Close()
close(server.stopChan)

67
pkg/factory/handler.go Normal file
View File

@@ -0,0 +1,67 @@
package factory
import (
"net/http"
"github.com/SigNoz/signoz/pkg/http/render"
)
// Handler provides HTTP handler functions for service health checks.
type Handler interface {
// Readyz reports whether services are ready.
Readyz(http.ResponseWriter, *http.Request)
// Livez reports whether services are alive.
Livez(http.ResponseWriter, *http.Request)
// Healthz reports overall service health.
Healthz(http.ResponseWriter, *http.Request)
}
type handler struct {
registry *Registry
}
func NewHandler(registry *Registry) Handler {
return &handler{
registry: registry,
}
}
type Response struct {
Healthy bool `json:"healthy"`
Services map[State][]Name `json:"services"`
}
func (handler *handler) Healthz(rw http.ResponseWriter, req *http.Request) {
byState := handler.registry.ServicesByState()
healthy := handler.registry.IsHealthy()
statusCode := http.StatusOK
if !healthy {
statusCode = http.StatusServiceUnavailable
}
render.Success(rw, statusCode, Response{
Healthy: healthy,
Services: byState,
})
}
func (handler *handler) Readyz(rw http.ResponseWriter, req *http.Request) {
healthy := handler.registry.IsHealthy()
statusCode := http.StatusOK
if !healthy {
statusCode = http.StatusServiceUnavailable
}
render.Success(rw, statusCode, Response{
Healthy: healthy,
Services: handler.registry.ServicesByState(),
})
}
func (handler *handler) Livez(rw http.ResponseWriter, req *http.Request) {
render.Success(rw, http.StatusOK, nil)
}

View File

@@ -5,9 +5,11 @@ import (
"regexp"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/swaggest/jsonschema-go"
)
var _ slog.LogValuer = (Name{})
var _ jsonschema.Exposer = (Name{})
var (
// nameRegex is a regex that matches a valid name.
@@ -27,6 +29,21 @@ func (n Name) String() string {
return n.name
}
// MarshalText implements encoding.TextMarshaler for JSON serialization.
func (n Name) MarshalText() ([]byte, error) {
return []byte(n.name), nil
}
// MarshalJSON implements json.Marshaler so Name serializes as a JSON string.
func (n Name) MarshalJSON() ([]byte, error) {
return []byte(`"` + n.name + `"`), nil
}
// JSONSchema implements jsonschema.Exposer so OpenAPI reflects Name as a string.
func (n Name) JSONSchema() (jsonschema.Schema, error) {
return *new(jsonschema.Schema).WithType(jsonschema.String.Type()), nil
}
// NewName creates a new name.
func NewName(name string) (Name, error) {
if !nameRegex.MatchString(name) {

View File

@@ -8,21 +8,26 @@ import (
"syscall"
"github.com/SigNoz/signoz/pkg/errors"
"gonum.org/v1/gonum/graph/simple"
"gonum.org/v1/gonum/graph/topo"
)
var (
ErrCodeInvalidRegistry = errors.MustNewCode("invalid_registry")
ErrCodeInvalidRegistry = errors.MustNewCode("invalid_registry")
ErrCodeDependencyFailed = errors.MustNewCode("dependency_failed")
ErrCodeServiceFailed = errors.MustNewCode("service_failed")
)
type Registry struct {
services NamedMap[NamedService]
logger *slog.Logger
startCh chan error
stopCh chan error
services []*serviceWithState
servicesByName map[Name]*serviceWithState
logger *slog.Logger
startC chan error
stopC chan error
}
// New creates a new registry of services. It needs at least one service in the input.
func NewRegistry(logger *slog.Logger, services ...NamedService) (*Registry, error) {
func NewRegistry(ctx context.Context, logger *slog.Logger, services ...NamedService) (*Registry, error) {
if logger == nil {
return nil, errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidRegistry, "cannot build registry, logger is required")
}
@@ -31,59 +36,131 @@ func NewRegistry(logger *slog.Logger, services ...NamedService) (*Registry, erro
return nil, errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidRegistry, "cannot build registry, at least one service is required")
}
m, err := NewNamedMap(services...)
if err != nil {
servicesWithState := make([]*serviceWithState, len(services))
servicesByName := make(map[Name]*serviceWithState, len(services))
for i, s := range services {
if _, ok := servicesByName[s.Name()]; ok {
return nil, errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidRegistry, "cannot build registry, duplicate service name %q", s.Name())
}
ss := newServiceWithState(s)
servicesWithState[i] = ss
servicesByName[s.Name()] = ss
}
registryLogger := logger.With(slog.String("pkg", "github.com/SigNoz/signoz/pkg/factory"))
for _, ss := range servicesWithState {
for _, dep := range ss.service.DependsOn() {
if dep == ss.service.Name() {
registryLogger.ErrorContext(ctx, "ignoring self-dependency", slog.Any("service", ss.service.Name()))
continue
}
if _, ok := servicesByName[dep]; !ok {
registryLogger.ErrorContext(ctx, "ignoring unknown dependency", slog.Any("service", ss.service.Name()), slog.Any("dependency", dep))
continue
}
ss.dependsOn = append(ss.dependsOn, dep)
}
}
if err := detectCyclicDeps(servicesWithState); err != nil {
return nil, err
}
return &Registry{
logger: logger.With(slog.String("pkg", "go.signoz.io/pkg/factory")),
services: m,
startCh: make(chan error, 1),
stopCh: make(chan error, len(services)),
logger: registryLogger,
services: servicesWithState,
servicesByName: servicesByName,
startC: make(chan error, 1),
stopC: make(chan error, len(services)),
}, nil
}
func (r *Registry) Start(ctx context.Context) {
for _, s := range r.services.GetInOrder() {
go func(s NamedService) {
r.logger.InfoContext(ctx, "starting service", slog.Any("service", s.Name()))
err := s.Start(ctx)
r.startCh <- err
}(s)
}
func (registry *Registry) Start(ctx context.Context) {
for _, ss := range registry.services {
go func(ss *serviceWithState) {
// Wait for all dependencies to be healthy before starting.
for _, dep := range ss.dependsOn {
depState := registry.servicesByName[dep]
registry.logger.InfoContext(ctx, "service waiting for dependency", slog.Any("service", ss.service.Name()), slog.Any("dependency", dep))
select {
case <-ctx.Done():
ss.mu.Lock()
ss.state = StateFailed
ss.startErr = ctx.Err()
ss.mu.Unlock()
close(ss.startReturnedC)
registry.startC <- ctx.Err()
return
case <-depState.healthyC:
// Dependency is healthy, continue.
case <-depState.startReturnedC:
// Dependency failed before becoming healthy.
err := errors.Newf(errors.TypeInternal, ErrCodeDependencyFailed, "dependency %q of service %q failed", dep, ss.service.Name())
ss.mu.Lock()
ss.state = StateFailed
ss.startErr = err
ss.mu.Unlock()
close(ss.startReturnedC)
registry.startC <- err
return
}
}
registry.logger.InfoContext(ctx, "starting service", slog.Any("service", ss.service.Name()))
go func() {
select {
case <-ss.service.Healthy():
ss.setState(StateRunning)
case <-ss.startReturnedC:
}
}()
err := ss.service.Start(ctx)
if err != nil {
ss.mu.Lock()
ss.state = StateFailed
ss.startErr = err
ss.mu.Unlock()
}
close(ss.startReturnedC)
registry.startC <- err
}(ss)
}
}
func (r *Registry) Wait(ctx context.Context) error {
func (registry *Registry) Wait(ctx context.Context) error {
interrupt := make(chan os.Signal, 1)
signal.Notify(interrupt, syscall.SIGINT, syscall.SIGTERM)
select {
case <-ctx.Done():
r.logger.InfoContext(ctx, "caught context error, exiting", errors.Attr(ctx.Err()))
registry.logger.InfoContext(ctx, "caught context error, exiting", errors.Attr(ctx.Err()))
case s := <-interrupt:
r.logger.InfoContext(ctx, "caught interrupt signal, exiting", slog.Any("signal", s))
case err := <-r.startCh:
r.logger.ErrorContext(ctx, "caught service error, exiting", errors.Attr(err))
registry.logger.InfoContext(ctx, "caught interrupt signal, exiting", slog.Any("signal", s))
case err := <-registry.startC:
registry.logger.ErrorContext(ctx, "caught service error, exiting", errors.Attr(err))
return err
}
return nil
}
func (r *Registry) Stop(ctx context.Context) error {
for _, s := range r.services.GetInOrder() {
go func(s NamedService) {
r.logger.InfoContext(ctx, "stopping service", slog.Any("service", s.Name()))
err := s.Stop(ctx)
r.stopCh <- err
}(s)
func (registry *Registry) Stop(ctx context.Context) error {
for _, ss := range registry.services {
go func(ss *serviceWithState) {
registry.logger.InfoContext(ctx, "stopping service", slog.Any("service", ss.service.Name()))
err := ss.service.Stop(ctx)
registry.stopC <- err
}(ss)
}
errs := make([]error, len(r.services.GetInOrder()))
for i := 0; i < len(r.services.GetInOrder()); i++ {
err := <-r.stopCh
errs := make([]error, len(registry.services))
for i := 0; i < len(registry.services); i++ {
err := <-registry.stopC
if err != nil {
errs = append(errs, err)
}
@@ -91,3 +168,83 @@ func (r *Registry) Stop(ctx context.Context) error {
return errors.Join(errs...)
}
// AwaitHealthy blocks until all services reach the RUNNING state or any service fails.
func (registry *Registry) AwaitHealthy(ctx context.Context) error {
for _, ss := range registry.services {
select {
case <-ctx.Done():
return ctx.Err()
case <-ss.healthyC:
case <-ss.startReturnedC:
ss.mu.RLock()
err := ss.startErr
ss.mu.RUnlock()
if err != nil {
return errors.Wrapf(err, errors.TypeInternal, ErrCodeServiceFailed, "service %q failed before becoming healthy", ss.service.Name())
}
return errors.Newf(errors.TypeInternal, ErrCodeServiceFailed, "service %q terminated before becoming healthy", ss.service.Name())
}
}
return nil
}
// ServicesByState returns a snapshot of the current state of all services.
func (registry *Registry) ServicesByState() map[State][]Name {
result := make(map[State][]Name)
for _, ss := range registry.services {
state := ss.getState()
result[state] = append(result[state], ss.service.Name())
}
return result
}
// IsHealthy returns true if all services are in the RUNNING state.
func (registry *Registry) IsHealthy() bool {
for _, ss := range registry.services {
if ss.getState() != StateRunning {
return false
}
}
return true
}
// detectCyclicDeps returns an error listing all dependency cycles found using
// gonum's Tarjan SCC algorithm.
func detectCyclicDeps(services []*serviceWithState) error {
nameToID := make(map[Name]int64, len(services))
idToName := make(map[int64]Name, len(services))
for i, ss := range services {
id := int64(i)
nameToID[ss.service.Name()] = id
idToName[id] = ss.service.Name()
}
g := simple.NewDirectedGraph()
for _, ss := range services {
g.AddNode(simple.Node(nameToID[ss.service.Name()]))
}
for _, ss := range services {
fromID := nameToID[ss.service.Name()]
for _, dep := range ss.dependsOn {
g.SetEdge(simple.Edge{F: simple.Node(fromID), T: simple.Node(nameToID[dep])})
}
}
if _, err := topo.Sort(g); err == nil {
return nil
}
var cycles [][]Name
for _, scc := range topo.TarjanSCC(g) {
if len(scc) > 1 {
cycle := make([]Name, len(scc))
for i, n := range scc {
cycle[i] = idToName[n.ID()]
}
cycles = append(cycles, cycle)
}
}
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidRegistry, "dependency cycles detected: %v", cycles)
}

View File

@@ -5,7 +5,10 @@ import (
"log/slog"
"sync"
"testing"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -28,11 +31,46 @@ func (s *tservice) Stop(_ context.Context) error {
return nil
}
type healthyTestService struct {
tservice
healthyC chan struct{}
}
func newHealthyTestService(t *testing.T) *healthyTestService {
t.Helper()
return &healthyTestService{
tservice: tservice{c: make(chan struct{})},
healthyC: make(chan struct{}),
}
}
func (s *healthyTestService) Healthy() <-chan struct{} {
return s.healthyC
}
// failingHealthyService implements Healthy but fails before signaling healthy.
type failingHealthyService struct {
healthyC chan struct{}
err error
}
func (s *failingHealthyService) Start(_ context.Context) error {
return s.err
}
func (s *failingHealthyService) Stop(_ context.Context) error {
return nil
}
func (s *failingHealthyService) Healthy() <-chan struct{} {
return s.healthyC
}
func TestRegistryWith2Services(t *testing.T) {
s1 := newTestService(t)
s2 := newTestService(t)
registry, err := NewRegistry(slog.New(slog.DiscardHandler), NewNamedService(MustNewName("s1"), s1), NewNamedService(MustNewName("s2"), s2))
registry, err := NewRegistry(context.Background(), slog.New(slog.DiscardHandler), NewNamedService(MustNewName("s1"), s1), NewNamedService(MustNewName("s2"), s2))
require.NoError(t, err)
ctx, cancel := context.WithCancel(context.Background())
@@ -41,8 +79,8 @@ func TestRegistryWith2Services(t *testing.T) {
go func() {
defer wg.Done()
registry.Start(ctx)
require.NoError(t, registry.Wait(ctx))
require.NoError(t, registry.Stop(ctx))
assert.NoError(t, registry.Wait(ctx))
assert.NoError(t, registry.Stop(ctx))
}()
cancel()
@@ -53,7 +91,7 @@ func TestRegistryWith2ServicesWithoutWait(t *testing.T) {
s1 := newTestService(t)
s2 := newTestService(t)
registry, err := NewRegistry(slog.New(slog.DiscardHandler), NewNamedService(MustNewName("s1"), s1), NewNamedService(MustNewName("s2"), s2))
registry, err := NewRegistry(context.Background(), slog.New(slog.DiscardHandler), NewNamedService(MustNewName("s1"), s1), NewNamedService(MustNewName("s2"), s2))
require.NoError(t, err)
ctx := context.Background()
@@ -62,8 +100,245 @@ func TestRegistryWith2ServicesWithoutWait(t *testing.T) {
go func() {
defer wg.Done()
registry.Start(ctx)
require.NoError(t, registry.Stop(ctx))
assert.NoError(t, registry.Stop(ctx))
}()
wg.Wait()
}
func TestServiceStateTransitions(t *testing.T) {
s1 := newTestService(t)
registry, err := NewRegistry(context.Background(), slog.New(slog.DiscardHandler), NewNamedService(MustNewName("s1"), s1))
require.NoError(t, err)
ctx := context.Background()
registry.Start(ctx)
require.NoError(t, registry.AwaitHealthy(ctx))
byState := registry.ServicesByState()
assert.Len(t, byState[StateRunning], 1)
assert.True(t, registry.IsHealthy())
assert.NoError(t, registry.Stop(ctx))
}
func TestServiceStateWithHealthy(t *testing.T) {
s1 := newHealthyTestService(t)
registry, err := NewRegistry(context.Background(), slog.New(slog.DiscardHandler), NewNamedService(MustNewName("s1"), s1))
require.NoError(t, err)
ctx := context.Background()
registry.Start(ctx)
// Poll until STARTING state is observed
require.Eventually(t, func() bool {
byState := registry.ServicesByState()
return len(byState[StateStarting]) == 1
}, time.Second, time.Millisecond)
assert.False(t, registry.IsHealthy())
// Signal healthy
close(s1.healthyC)
require.NoError(t, registry.AwaitHealthy(ctx))
assert.True(t, registry.IsHealthy())
byState := registry.ServicesByState()
assert.Len(t, byState[StateRunning], 1)
assert.NoError(t, registry.Stop(ctx))
}
func TestAwaitHealthy(t *testing.T) {
s1 := newTestService(t)
s2 := newTestService(t)
registry, err := NewRegistry(context.Background(), slog.New(slog.DiscardHandler), NewNamedService(MustNewName("s1"), s1), NewNamedService(MustNewName("s2"), s2))
require.NoError(t, err)
ctx := context.Background()
registry.Start(ctx)
assert.NoError(t, registry.AwaitHealthy(ctx))
assert.True(t, registry.IsHealthy())
assert.NoError(t, registry.Stop(ctx))
}
func TestAwaitHealthyWithFailure(t *testing.T) {
s1 := &failingHealthyService{
healthyC: make(chan struct{}),
err: errors.Newf(errors.TypeInternal, errors.CodeInternal,"startup failed"),
}
registry, err := NewRegistry(context.Background(), slog.New(slog.DiscardHandler), NewNamedService(MustNewName("s1"), s1))
require.NoError(t, err)
ctx := context.Background()
registry.Start(ctx)
err = registry.AwaitHealthy(ctx)
assert.Error(t, err)
assert.Contains(t, err.Error(), "startup failed")
}
func TestServicesByState(t *testing.T) {
s1 := newTestService(t)
s2 := newHealthyTestService(t)
registry, err := NewRegistry(context.Background(), slog.New(slog.DiscardHandler), NewNamedService(MustNewName("s1"), s1), NewNamedService(MustNewName("s2"), s2))
require.NoError(t, err)
ctx := context.Background()
registry.Start(ctx)
// Wait for s1 to be running (no Healthy interface) and s2 to be starting
require.Eventually(t, func() bool {
byState := registry.ServicesByState()
return len(byState[StateRunning]) == 1 && len(byState[StateStarting]) == 1
}, time.Second, time.Millisecond)
// Make s2 healthy
close(s2.healthyC)
require.NoError(t, registry.AwaitHealthy(ctx))
byState := registry.ServicesByState()
assert.Len(t, byState[StateRunning], 2)
assert.NoError(t, registry.Stop(ctx))
}
func TestDependsOnStartsAfterDependency(t *testing.T) {
s1 := newHealthyTestService(t)
s2 := newTestService(t)
// s2 depends on s1
registry, err := NewRegistry(
context.Background(),
slog.New(slog.DiscardHandler),
NewNamedService(MustNewName("s1"), s1),
NewNamedService(MustNewName("s2"), s2, MustNewName("s1")),
)
require.NoError(t, err)
ctx := context.Background()
registry.Start(ctx)
// s2 should still be STARTING because s1 hasn't become healthy yet
require.Eventually(t, func() bool {
byState := registry.ServicesByState()
return len(byState[StateStarting]) == 2
}, time.Second, time.Millisecond)
// Make s1 healthy — s2 should then start and become RUNNING
close(s1.healthyC)
assert.NoError(t, registry.AwaitHealthy(ctx))
assert.True(t, registry.IsHealthy())
assert.NoError(t, registry.Stop(ctx))
}
func TestDependsOnFailsWhenDependencyFails(t *testing.T) {
s1 := &failingHealthyService{
healthyC: make(chan struct{}),
err: errors.Newf(errors.TypeInternal, errors.CodeInternal,"s1 crashed"),
}
s2 := newTestService(t)
// s2 depends on s1
registry, err := NewRegistry(
context.Background(),
slog.New(slog.DiscardHandler),
NewNamedService(MustNewName("s1"), s1),
NewNamedService(MustNewName("s2"), s2, MustNewName("s1")),
)
require.NoError(t, err)
ctx := context.Background()
registry.Start(ctx)
// Both should eventually fail
assert.Eventually(t, func() bool {
byState := registry.ServicesByState()
return len(byState[StateFailed]) == 2
}, time.Second, time.Millisecond)
}
func TestDependsOnUnknownServiceIsIgnored(t *testing.T) {
s1 := newTestService(t)
// Unknown dependency is logged and ignored, not an error.
registry, err := NewRegistry(
context.Background(),
slog.New(slog.DiscardHandler),
NewNamedService(MustNewName("s1"), s1, MustNewName("unknown")),
)
require.NoError(t, err)
ctx := context.Background()
registry.Start(ctx)
assert.NoError(t, registry.AwaitHealthy(ctx))
assert.True(t, registry.IsHealthy())
assert.NoError(t, registry.Stop(ctx))
}
func TestServiceStateFailed(t *testing.T) {
s1 := &failingHealthyService{
healthyC: make(chan struct{}),
err: errors.Newf(errors.TypeInternal, errors.CodeInternal,"fatal error"),
}
registry, err := NewRegistry(context.Background(), slog.New(slog.DiscardHandler), NewNamedService(MustNewName("s1"), s1))
require.NoError(t, err)
ctx := context.Background()
registry.Start(ctx)
// Wait for the service to fail
assert.Eventually(t, func() bool {
byState := registry.ServicesByState()
return len(byState[StateFailed]) == 1
}, time.Second, time.Millisecond)
assert.False(t, registry.IsHealthy())
}
func TestDependsOnSelfDependencyIsIgnored(t *testing.T) {
s1 := newTestService(t)
// Self-dependency is logged and ignored.
registry, err := NewRegistry(
context.Background(),
slog.New(slog.DiscardHandler),
NewNamedService(MustNewName("s1"), s1, MustNewName("s1")),
)
require.NoError(t, err)
ctx := context.Background()
registry.Start(ctx)
assert.NoError(t, registry.AwaitHealthy(ctx))
assert.True(t, registry.IsHealthy())
assert.NoError(t, registry.Stop(ctx))
}
func TestDependsOnCycleReturnsError(t *testing.T) {
s1 := newTestService(t)
s2 := newTestService(t)
// A -> B and B -> A is a cycle.
_, err := NewRegistry(
context.Background(),
slog.New(slog.DiscardHandler),
NewNamedService(MustNewName("s1"), s1, MustNewName("s2")),
NewNamedService(MustNewName("s2"), s2, MustNewName("s1")),
)
assert.Error(t, err)
assert.Contains(t, err.Error(), "dependency cycles detected")
}

View File

@@ -2,30 +2,81 @@ package factory
import "context"
// Service is the core lifecycle interface for long-running services.
type Service interface {
// Starts a service. It should block and should not return until the service is stopped or it fails.
Start(context.Context) error
// Stops a service.
Stop(context.Context) error
}
type NamedService interface {
Named
Service
// Healthy is an optional interface that services can implement to signal
// when they have completed startup and are ready to serve.
// Services that do not implement this interface are considered healthy
// immediately after Start() is called.
type Healthy interface {
// Healthy returns a channel that is closed when the service is healthy.
Healthy() <-chan struct{}
}
type namedService struct {
name Name
// ServiceWithHealthy is a Service that explicitly signals when it is healthy.
type ServiceWithHealthy interface {
Service
Healthy
}
// NamedService is a Service with a Name and optional dependencies.
type NamedService interface {
Named
ServiceWithHealthy
// DependsOn returns the names of services that must be healthy before this service starts.
DependsOn() []Name
}
// closedC is a pre-closed channel returned for services that don't implement Healthy.
var closedC = func() chan struct{} {
c := make(chan struct{})
close(c)
return c
}()
type namedService struct {
name Name
dependsOn []Name
service Service
}
// NewNamedService wraps a Service with a Name and optional dependency names.
func NewNamedService(name Name, service Service, dependsOn ...Name) NamedService {
return &namedService{
name: name,
dependsOn: dependsOn,
service: service,
}
}
func (s *namedService) Name() Name {
return s.name
}
func NewNamedService(name Name, service Service) NamedService {
return &namedService{
name: name,
Service: service,
}
func (s *namedService) DependsOn() []Name {
return s.dependsOn
}
func (s *namedService) Start(ctx context.Context) error {
return s.service.Start(ctx)
}
func (s *namedService) Stop(ctx context.Context) error {
return s.service.Stop(ctx)
}
// Healthy delegates to the underlying service if it implements Healthy,
// otherwise returns an already-closed channel (immediately healthy).
func (s *namedService) Healthy() <-chan struct{} {
if h, ok := s.service.(Healthy); ok {
return h.Healthy()
}
return closedC
}

75
pkg/factory/state.go Normal file
View File

@@ -0,0 +1,75 @@
package factory
import "sync"
// State represents the lifecycle state of a service.
type State struct {
s string
}
func (s State) String() string {
return s.s
}
// MarshalText implements encoding.TextMarshaler so State can be used as a JSON map key.
func (s State) MarshalText() ([]byte, error) {
return []byte(s.s), nil
}
var (
StateStarting = State{"starting"}
StateRunning = State{"running"}
StateFailed = State{"failed"}
)
// serviceWithState wraps a NamedService with thread-safe state tracking.
type serviceWithState struct {
// service is the underlying named service.
service NamedService
// dependsOn is the validated subset of declared dependencies that exist in the registry.
dependsOn []Name
// mu protects state and startErr from concurrent access.
mu sync.RWMutex
// state is the current lifecycle state of the service.
state State
// healthyC is closed when the service transitions to StateRunning.
healthyC chan struct{}
// startReturnedC is closed when Start() returns, whether with nil or an error.
startReturnedC chan struct{}
// startErr is the error returned by Start(), or nil if it returned successfully.
startErr error
}
func newServiceWithState(service NamedService) *serviceWithState {
return &serviceWithState{
service: service,
state: StateStarting,
healthyC: make(chan struct{}),
startReturnedC: make(chan struct{}),
}
}
func (ss *serviceWithState) setState(state State) {
ss.mu.Lock()
defer ss.mu.Unlock()
ss.state = state
if state == StateRunning {
select {
case <-ss.healthyC:
default:
close(ss.healthyC)
}
}
}
func (ss *serviceWithState) getState() State {
ss.mu.RLock()
defer ss.mu.RUnlock()
return ss.state
}

View File

@@ -79,16 +79,22 @@ func (provider *provider) GetIdentity(req *http.Request) (*authtypes.Identity, e
return nil, err
}
rootUser, err := provider.userGetter.GetRootUserByOrgID(ctx, org.ID)
rootUser, userRoles, err := provider.userGetter.GetRootUserByOrgID(ctx, org.ID)
if err != nil {
return nil, err
}
if len(userRoles) == 0 {
return nil, errors.New(errors.TypeUnexpected, authtypes.ErrCodeUserRolesNotFound, "no user roles entries found")
}
role := authtypes.SigNozManagedRoleToExistingLegacyRole[userRoles[0].Role.Name]
provider.identity = authtypes.NewIdentity(
rootUser.ID,
rootUser.OrgID,
rootUser.Email,
rootUser.Role,
role,
authtypes.IdentNProviderImpersonation,
)

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