Compare commits

..

17 Commits

Author SHA1 Message Date
vikrantgupta25
3e58d6dd12 feat(serviceaccount): add integration tests 2026-03-24 03:15:32 +05:30
vikrantgupta25
bd18ee2b75 feat(serviceaccount): add integration tests 2026-03-24 02:48:12 +05:30
Vikrant Gupta
f16f37b4eb Merge branch 'main' into platform-pod/issues/1830-sa 2026-03-24 02:23:06 +05:30
vikrantgupta25
044ed1470d feat(serviceaccount): migration fixes 2026-03-24 02:22:43 +05:30
vikrantgupta25
ea83b622ce feat(serviceaccount): fix some issues 2026-03-24 02:06:52 +05:30
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
vikrantgupta25
5494d97bb1 feat(serviceaccount): fix migration numbers 2026-03-23 23:50:40 +05:30
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
vikrantgupta25
4500080af3 feat(serviceaccount): rebase issues 2026-03-23 23:02:40 +05:30
vikrantgupta25
675e23ee9f feat(serviceaccount): handle migration duplicates 2026-03-23 22:53:06 +05:30
vikrantgupta25
12a5984164 feat(serviceaccount): handle author access for user/dashboard 2026-03-23 22:53:02 +05:30
vikrantgupta25
6ab2dbd2c1 feat(serviceaccount): claims.UserID changes 2026-03-23 22:40:30 +05:30
vikrantgupta25
9e458f4954 feat(serviceaccount): handle self access better 2026-03-23 22:38:09 +05:30
vikrantgupta25
85c396880e feat(serviceaccount): deprecate the api keys 2026-03-23 22:38:04 +05:30
vikrantgupta25
5da4e382a0 feat(serviceaccount): add analytics and cloudintegration migration 2026-03-23 22:35:28 +05:30
vikrantgupta25
5402ceef80 feat(serviceaccount): sqlmigration and identn provider 2026-03-23 22:34:40 +05:30
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
147 changed files with 4600 additions and 3859 deletions

View File

@@ -51,6 +51,7 @@ jobs:
- alerts
- ingestionkeys
- rootuser
- serviceaccount
sqlstore-provider:
- postgres
- sqlite

File diff suppressed because it is too large Load Diff

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,14 +25,31 @@ 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)
}
func (server *Server) CheckWithTupleCreation(ctx context.Context, claims authtypes.Claims, orgID valuer.UUID, relation authtypes.Relation, typeable authtypes.Typeable, selectors []authtypes.Selector, _ []authtypes.Selector) error {
subject, err := authtypes.NewSubject(authtypes.TypeableUser, claims.UserID, orgID, nil)
if err != nil {
return err
subject := ""
switch claims.Principal {
case authtypes.PrincipalUser.StringValue():
user, err := authtypes.NewSubject(authtypes.TypeableUser, claims.UserID, orgID, nil)
if err != nil {
return err
}
subject = user
case authtypes.PrincipalServiceAccount.StringValue():
serviceAccount, err := authtypes.NewSubject(authtypes.TypeableServiceAccount, claims.ServiceAccountID, orgID, nil)
if err != nil {
return err
}
subject = serviceAccount
}
tupleSlice, err := typeable.Tuples(subject, relation, selectors, orgID)

View File

@@ -213,8 +213,8 @@ func (module *module) Update(ctx context.Context, orgID valuer.UUID, id valuer.U
return module.pkgDashboardModule.Update(ctx, orgID, id, updatedBy, data, diff)
}
func (module *module) LockUnlock(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, role types.Role, lock bool) error {
return module.pkgDashboardModule.LockUnlock(ctx, orgID, id, updatedBy, role, lock)
func (module *module) LockUnlock(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, isAdmin bool, lock bool) error {
return module.pkgDashboardModule.LockUnlock(ctx, orgID, id, updatedBy, isAdmin, lock)
}
func (module *module) MustGetTypeables() []authtypes.Typeable {

View File

@@ -14,10 +14,9 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/modules/user"
basemodel "github.com/SigNoz/signoz/pkg/query-service/model"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/serviceaccounttypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/gorilla/mux"
)
@@ -50,7 +49,7 @@ func (ah *APIHandler) CloudIntegrationsGenerateConnectionParams(w http.ResponseW
return
}
apiKey, apiErr := ah.getOrCreateCloudIntegrationPAT(r.Context(), claims.OrgID, cloudProvider)
apiKey, apiErr := ah.getOrCreateCloudIntegrationFactorAPIKey(r.Context(), valuer.MustNewUUID(claims.OrgID), cloudProvider)
if apiErr != nil {
RespondError(w, basemodel.WrapApiError(
apiErr, "couldn't provision PAT for cloud integration:",
@@ -110,84 +109,40 @@ func (ah *APIHandler) CloudIntegrationsGenerateConnectionParams(w http.ResponseW
ah.Respond(w, result)
}
func (ah *APIHandler) getOrCreateCloudIntegrationPAT(ctx context.Context, orgId string, cloudProvider string) (
func (ah *APIHandler) getOrCreateCloudIntegrationFactorAPIKey(ctx context.Context, orgID valuer.UUID, cloudProvider string) (
string, *basemodel.ApiError,
) {
integrationPATName := fmt.Sprintf("%s integration", cloudProvider)
integrationUser, apiErr := ah.getOrCreateCloudIntegrationUser(ctx, orgId, cloudProvider)
serviceAccount, apiErr := ah.getOrCreateCloudIntegrationServiceAccount(ctx, orgID)
if apiErr != nil {
return "", apiErr
}
orgIdUUID, err := valuer.NewUUID(orgId)
if err != nil {
return "", basemodel.InternalError(fmt.Errorf(
"couldn't parse orgId: %w", err,
))
}
allPats, err := ah.Signoz.Modules.UserSetter.ListAPIKeys(ctx, orgIdUUID)
if err != nil {
return "", basemodel.InternalError(fmt.Errorf(
"couldn't list PATs: %w", err,
))
}
for _, p := range allPats {
if p.UserID == integrationUser.ID && p.Name == integrationPATName {
return p.Token, nil
}
}
slog.InfoContext(ctx, "no PAT found for cloud integration, creating a new one",
"cloud_provider", cloudProvider,
)
newPAT, err := types.NewStorableAPIKey(
integrationPATName,
integrationUser.ID,
types.RoleViewer,
0,
)
factorAPIKey, err := serviceAccount.NewFactorAPIKey(integrationPATName, 0)
if err != nil {
return "", basemodel.InternalError(fmt.Errorf(
"couldn't create cloud integration PAT: %w", err,
))
}
err = ah.Signoz.Modules.UserSetter.CreateAPIKey(ctx, newPAT)
factorAPIKey, err = ah.Signoz.Modules.ServiceAccount.GetOrCreateFactorAPIKey(ctx, factorAPIKey)
if err != nil {
return "", basemodel.InternalError(fmt.Errorf(
"couldn't create cloud integration PAT: %w", err,
))
}
return newPAT.Token, nil
return factorAPIKey.Key, nil
}
func (ah *APIHandler) getOrCreateCloudIntegrationUser(
ctx context.Context, orgId string, cloudProvider string,
) (*types.User, *basemodel.ApiError) {
cloudIntegrationUserName := fmt.Sprintf("%s-integration", cloudProvider)
email := valuer.MustNewEmail(fmt.Sprintf("%s@signoz.io", cloudIntegrationUserName))
cloudIntegrationUser, err := types.NewUser(cloudIntegrationUserName, email, valuer.MustNewUUID(orgId), types.UserStatusActive)
func (ah *APIHandler) getOrCreateCloudIntegrationServiceAccount(ctx context.Context, orgId valuer.UUID) (*serviceaccounttypes.ServiceAccount, *basemodel.ApiError) {
prefix := ah.Signoz.Modules.ServiceAccount.Config().Prefix
cloudIntegrationServiceAccount := serviceaccounttypes.NewServiceAccount("integration", prefix, []string{authtypes.SigNozViewerRoleName}, serviceaccounttypes.StatusActive, orgId)
cloudIntegrationServiceAccount, err := ah.Signoz.Modules.ServiceAccount.GetOrCreate(ctx, cloudIntegrationServiceAccount)
if err != nil {
return nil, basemodel.InternalError(fmt.Errorf("couldn't create cloud integration user: %w", err))
return nil, basemodel.InternalError(fmt.Errorf("couldn't create cloud integration service account: %w", err))
}
password := types.MustGenerateFactorPassword(cloudIntegrationUser.ID.StringValue())
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))
}
return cloudIntegrationUser, nil
return cloudIntegrationServiceAccount, nil
}
func (ah *APIHandler) getIngestionUrlAndSigNozAPIUrl(ctx context.Context, licenseKey string) (

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

@@ -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
*/
@@ -2192,10 +2209,6 @@ export interface ServiceaccounttypesPostableFactorAPIKeyDTO {
}
export interface ServiceaccounttypesPostableServiceAccountDTO {
/**
* @type string
*/
email: string;
/**
* @type string
*/
@@ -2212,11 +2225,6 @@ export interface ServiceaccounttypesServiceAccountDTO {
* @format date-time
*/
createdAt?: Date;
/**
* @type string
* @format date-time
*/
deletedAt: Date;
/**
* @type string
*/
@@ -2261,10 +2269,6 @@ export interface ServiceaccounttypesUpdatableFactorAPIKeyDTO {
}
export interface ServiceaccounttypesUpdatableServiceAccountDTO {
/**
* @type string
*/
email: string;
/**
* @type string
*/
@@ -3457,6 +3461,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
@@ -3592,6 +3620,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

@@ -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

@@ -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

@@ -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,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

@@ -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>>;
}

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

@@ -1,216 +0,0 @@
package signozapiserver
import (
"net/http"
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/types"
citypes "github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes"
"github.com/gorilla/mux"
)
func (provider *provider) addCloudIntegrationRoutes(router *mux.Router) error {
if err := router.Handle("/api/v1/cloud_integrations/{cloud_provider}/accounts/connection_artifact", handler.New(
provider.authZ.AdminAccess(provider.cloudIntegrationHandler.GetConnectionArtifact),
handler.OpenAPIDef{
ID: "GetConnectionArtifact",
Tags: []string{"cloudintegration"},
Summary: "Get connection artifact",
Description: "This endpoint returns a connection artifact for the specified cloud provider and creates new cloud integration account",
Request: new(citypes.PostableConnectionArtifact),
RequestContentType: "application/json",
Response: new(citypes.GettableConnectionArtifact),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
},
)).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/cloud_integrations/{cloud_provider}/accounts", handler.New(
provider.authZ.AdminAccess(provider.cloudIntegrationHandler.ListAccounts),
handler.OpenAPIDef{
ID: "ListAccounts",
Tags: []string{"cloudintegration"},
Summary: "List accounts",
Description: "This endpoint lists the accounts for the specified cloud provider",
Request: nil,
RequestContentType: "",
Response: new(citypes.GettableAccounts),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
},
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/cloud_integrations/{cloud_provider}/accounts/{id}", handler.New(
provider.authZ.AdminAccess(provider.cloudIntegrationHandler.GetAccount),
handler.OpenAPIDef{
ID: "GetAccount",
Tags: []string{"cloudintegration"},
Summary: "Get account",
Description: "This endpoint gets an account for the specified cloud provider",
Request: nil,
RequestContentType: "",
Response: new(citypes.GettableAccount),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
},
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/cloud_integrations/{cloud_provider}/accounts/{id}", handler.New(
provider.authZ.AdminAccess(provider.cloudIntegrationHandler.UpdateAccount),
handler.OpenAPIDef{
ID: "UpdateAccount",
Tags: []string{"cloudintegration"},
Summary: "Update account",
Description: "This endpoint updates an account for the specified cloud provider",
Request: new(citypes.UpdatableAccount),
RequestContentType: "application/json",
Response: nil,
ResponseContentType: "",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
},
)).Methods(http.MethodPut).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/cloud_integrations/{cloud_provider}/accounts/{id}", handler.New(
provider.authZ.AdminAccess(provider.cloudIntegrationHandler.DisconnectAccount),
handler.OpenAPIDef{
ID: "DisconnectAccount",
Tags: []string{"cloudintegration"},
Summary: "Disconnect account",
Description: "This endpoint disconnects an account for the specified cloud provider",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
},
)).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/cloud_integrations/{cloud_provider}/services", handler.New(
provider.authZ.AdminAccess(provider.cloudIntegrationHandler.ListServicesMetadata),
handler.OpenAPIDef{
ID: "ListServicesMetadata",
Tags: []string{"cloudintegration"},
Summary: "List services metadata",
Description: "This endpoint lists the services metadata for the specified cloud provider",
Request: nil,
RequestContentType: "",
Response: new(citypes.GettableServicesMetadata),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
},
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/cloud_integrations/{cloud_provider}/services/{service_id}", handler.New(
provider.authZ.AdminAccess(provider.cloudIntegrationHandler.GetService),
handler.OpenAPIDef{
ID: "GetService",
Tags: []string{"cloudintegration"},
Summary: "Get service",
Description: "This endpoint gets a service for the specified cloud provider",
Request: nil,
RequestContentType: "",
Response: new(citypes.GettableService),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
},
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/cloud_integrations/{cloud_provider}/services/{service_id}", handler.New(
provider.authZ.AdminAccess(provider.cloudIntegrationHandler.UpdateService),
handler.OpenAPIDef{
ID: "UpdateService",
Tags: []string{"cloudintegration"},
Summary: "Update service",
Description: "This endpoint updates a service for the specified cloud provider",
Request: new(citypes.UpdatableService),
RequestContentType: "application/json",
Response: nil,
ResponseContentType: "",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
},
)).Methods(http.MethodPut).GetError(); err != nil {
return err
}
// Agent check-in endpoint is kept same as older one to maintain backward compatibility with already deployed agents.
// In the future, this endpoint will be deprecated and a new endpoint will be introduced for consistency with above endpoints.
if err := router.Handle("/api/v1/cloud-integrations/{cloud_provider}/agent-check-in", handler.New(
provider.authZ.ViewAccess(provider.cloudIntegrationHandler.AgentCheckIn),
handler.OpenAPIDef{
ID: "AgentCheckInDeprecated",
Tags: []string{"cloudintegration"},
Summary: "Agent check-in",
Description: "[Deprecated] This endpoint is called by the deployed agent to check in",
Request: new(citypes.PostableAgentCheckInRequest),
RequestContentType: "application/json",
Response: new(citypes.GettableAgentCheckInResponse),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: true, // this endpoint will be deprecated in future
SecuritySchemes: newSecuritySchemes(types.RoleViewer), // agent role is viewer
},
)).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/cloud_integrations/{cloud_provider}/accounts/check_in", handler.New(
provider.authZ.ViewAccess(provider.cloudIntegrationHandler.AgentCheckIn),
handler.OpenAPIDef{
ID: "AgentCheckIn",
Tags: []string{"cloudintegration"},
Summary: "Agent check-in",
Description: "This endpoint is called by the deployed agent to check in",
Request: new(citypes.PostableAgentCheckInRequest),
RequestContentType: "application/json",
Response: new(citypes.GettableAgentCheckInResponse),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer), // agent role is viewer
},
)).Methods(http.MethodPost).GetError(); err != nil {
return err
}
return nil
}

View File

@@ -12,7 +12,6 @@ import (
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/http/middleware"
"github.com/SigNoz/signoz/pkg/modules/authdomain"
"github.com/SigNoz/signoz/pkg/modules/cloudintegration"
"github.com/SigNoz/signoz/pkg/modules/dashboard"
"github.com/SigNoz/signoz/pkg/modules/fields"
"github.com/SigNoz/signoz/pkg/modules/metricsexplorer"
@@ -51,8 +50,7 @@ type provider struct {
zeusHandler zeus.Handler
querierHandler querier.Handler
serviceAccountHandler serviceaccount.Handler
// TODO: wire up later
cloudIntegrationHandler cloudintegration.Handler //nolint:unused
factoryHandler factory.Handler
}
func NewFactory(
@@ -75,7 +73,7 @@ func NewFactory(
zeusHandler zeus.Handler,
querierHandler querier.Handler,
serviceAccountHandler serviceaccount.Handler,
cloudIntegrationHandler cloudintegration.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(
@@ -101,7 +99,7 @@ func NewFactory(
zeusHandler,
querierHandler,
serviceAccountHandler,
cloudIntegrationHandler,
factoryHandler,
)
})
}
@@ -129,33 +127,33 @@ func newProvider(
zeusHandler zeus.Handler,
querierHandler querier.Handler,
serviceAccountHandler serviceaccount.Handler,
cloudIntegrationHandler cloudintegration.Handler,
factoryHandler factory.Handler,
) (apiserver.APIServer, error) {
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/apiserver/signozapiserver")
router := mux.NewRouter().UseEncodedPath()
provider := &provider{
config: config,
settings: settings,
router: router,
orgHandler: orgHandler,
userHandler: userHandler,
sessionHandler: sessionHandler,
authDomainHandler: authDomainHandler,
preferenceHandler: preferenceHandler,
globalHandler: globalHandler,
promoteHandler: promoteHandler,
flaggerHandler: flaggerHandler,
dashboardModule: dashboardModule,
dashboardHandler: dashboardHandler,
metricsExplorerHandler: metricsExplorerHandler,
gatewayHandler: gatewayHandler,
fieldsHandler: fieldsHandler,
authzHandler: authzHandler,
zeusHandler: zeusHandler,
querierHandler: querierHandler,
serviceAccountHandler: serviceAccountHandler,
cloudIntegrationHandler: cloudIntegrationHandler,
config: config,
settings: settings,
router: router,
orgHandler: orgHandler,
userHandler: userHandler,
sessionHandler: sessionHandler,
authDomainHandler: authDomainHandler,
preferenceHandler: preferenceHandler,
globalHandler: globalHandler,
promoteHandler: promoteHandler,
flaggerHandler: flaggerHandler,
dashboardModule: dashboardModule,
dashboardHandler: dashboardHandler,
metricsExplorerHandler: metricsExplorerHandler,
gatewayHandler: gatewayHandler,
fieldsHandler: fieldsHandler,
authzHandler: authzHandler,
zeusHandler: zeusHandler,
querierHandler: querierHandler,
serviceAccountHandler: serviceAccountHandler,
factoryHandler: factoryHandler,
}
provider.authZ = middleware.NewAuthZ(settings.Logger(), orgGetter, authz)
@@ -240,7 +238,7 @@ func (provider *provider) AddToRouter(router *mux.Router) error {
return err
}
if err := provider.addCloudIntegrationRoutes(router); err != nil {
if err := provider.addRegistryRoutes(router); err != nil {
return err
}

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

@@ -43,74 +43,6 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v1/pats", handler.New(provider.authZ.AdminAccess(provider.userHandler.CreateAPIKey), handler.OpenAPIDef{
ID: "CreateAPIKey",
Tags: []string{"users"},
Summary: "Create api key",
Description: "This endpoint creates an api key",
Request: new(types.PostableAPIKey),
RequestContentType: "application/json",
Response: new(types.GettableAPIKey),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/pats", handler.New(provider.authZ.AdminAccess(provider.userHandler.ListAPIKeys), handler.OpenAPIDef{
ID: "ListAPIKeys",
Tags: []string{"users"},
Summary: "List api keys",
Description: "This endpoint lists all api keys",
Request: nil,
RequestContentType: "",
Response: make([]*types.GettableAPIKey, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/pats/{id}", handler.New(provider.authZ.AdminAccess(provider.userHandler.UpdateAPIKey), handler.OpenAPIDef{
ID: "UpdateAPIKey",
Tags: []string{"users"},
Summary: "Update api key",
Description: "This endpoint updates an api key",
Request: new(types.StorableAPIKey),
RequestContentType: "application/json",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodPut).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/pats/{id}", handler.New(provider.authZ.AdminAccess(provider.userHandler.RevokeAPIKey), handler.OpenAPIDef{
ID: "RevokeAPIKey",
Tags: []string{"users"},
Summary: "Revoke api key",
Description: "This endpoint revokes an api key",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/user", handler.New(provider.authZ.AdminAccess(provider.userHandler.ListUsers), handler.OpenAPIDef{
ID: "ListUsers",
Tags: []string{"users"},

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, userRoles, err := a.store.GetActiveUserAndFactorPasswordByEmailAndOrgID(ctx, email, orgID)
user, factorPassword, _, err := a.store.GetActiveUserAndFactorPasswordByEmailAndOrgID(ctx, email, orgID)
if err != nil {
return nil, err
}
@@ -30,11 +30,5 @@ func (a *AuthN) Authenticate(ctx context.Context, email string, password string,
return nil, errors.New(errors.TypeUnauthenticated, types.ErrCodeIncorrectPassword, "invalid email or password")
}
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
return authtypes.NewPrincipalUserIdentity(user.ID, orgID, user.Email, 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

@@ -97,11 +97,7 @@ func (store *store) ListByOrgIDAndNames(ctx context.Context, orgID valuer.UUID,
}
if len(roles) != len(names) {
return nil, store.sqlstore.WrapNotFoundErrf(
nil,
authtypes.ErrCodeRoleNotFound,
"not all roles found for the provided names: %v", names,
)
return nil, errors.Newf(errors.TypeInvalidInput, authtypes.ErrCodeRoleNotFound, "not all roles found for the provided names: %v", names)
}
return roles, nil
@@ -122,11 +118,7 @@ func (store *store) ListByOrgIDAndIDs(ctx context.Context, orgID valuer.UUID, id
}
if len(roles) != len(ids) {
return nil, store.sqlstore.WrapNotFoundErrf(
nil,
authtypes.ErrCodeRoleNotFound,
"not all roles found for the provided ids: %v", ids,
)
return nil, errors.Newf(errors.TypeInvalidInput, authtypes.ErrCodeRoleNotFound, "not all roles found for the provided names: %v", ids)
}
return roles, nil

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)
@@ -128,9 +136,22 @@ func (server *Server) BatchCheck(ctx context.Context, tupleReq map[string]*openf
}
func (server *Server) CheckWithTupleCreation(ctx context.Context, claims authtypes.Claims, orgID valuer.UUID, _ authtypes.Relation, _ authtypes.Typeable, _ []authtypes.Selector, roleSelectors []authtypes.Selector) error {
subject, err := authtypes.NewSubject(authtypes.TypeableUser, claims.UserID, orgID, nil)
if err != nil {
return err
subject := ""
switch claims.Principal {
case authtypes.PrincipalUser.StringValue():
user, err := authtypes.NewSubject(authtypes.TypeableUser, claims.UserID, orgID, nil)
if err != nil {
return err
}
subject = user
case authtypes.PrincipalServiceAccount.StringValue():
serviceAccount, err := authtypes.NewSubject(authtypes.TypeableServiceAccount, claims.ServiceAccountID, orgID, nil)
if err != nil {
return err
}
subject = serviceAccount
}
tupleSlice, err := authtypes.TypeableRole.Tuples(subject, authtypes.RelationAssignee, roleSelectors, orgID)

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

@@ -40,17 +40,6 @@ func (middleware *AuthZ) ViewAccess(next http.HandlerFunc) http.HandlerFunc {
return
}
if claims.IdentNProvider == authtypes.IdentNProviderAPIKey.StringValue() {
if err := claims.IsViewer(); err != nil {
middleware.logger.WarnContext(ctx, authzDeniedMessage, slog.Any("claims", claims))
render.Error(rw, err)
return
}
next(rw, req)
return
}
selectors := []authtypes.Selector{
authtypes.MustNewSelector(authtypes.TypeRole, authtypes.SigNozAdminRoleName),
authtypes.MustNewSelector(authtypes.TypeRole, authtypes.SigNozEditorRoleName),
@@ -90,17 +79,6 @@ func (middleware *AuthZ) EditAccess(next http.HandlerFunc) http.HandlerFunc {
return
}
if claims.IdentNProvider == authtypes.IdentNProviderAPIKey.StringValue() {
if err := claims.IsEditor(); err != nil {
middleware.logger.WarnContext(ctx, authzDeniedMessage, slog.Any("claims", claims))
render.Error(rw, err)
return
}
next(rw, req)
return
}
selectors := []authtypes.Selector{
authtypes.MustNewSelector(authtypes.TypeRole, authtypes.SigNozAdminRoleName),
authtypes.MustNewSelector(authtypes.TypeRole, authtypes.SigNozEditorRoleName),
@@ -139,17 +117,6 @@ func (middleware *AuthZ) AdminAccess(next http.HandlerFunc) http.HandlerFunc {
return
}
if claims.IdentNProvider == authtypes.IdentNProviderAPIKey.StringValue() {
if err := claims.IsAdmin(); err != nil {
middleware.logger.WarnContext(ctx, authzDeniedMessage, slog.Any("claims", claims))
render.Error(rw, err)
return
}
next(rw, req)
return
}
selectors := []authtypes.Selector{
authtypes.MustNewSelector(authtypes.TypeRole, authtypes.SigNozAdminRoleName),
}
@@ -186,13 +153,28 @@ func (middleware *AuthZ) SelfAccess(next http.HandlerFunc) http.HandlerFunc {
return
}
id := mux.Vars(req)["id"]
if err := claims.IsSelfAccess(id); err != nil {
middleware.logger.WarnContext(req.Context(), authzDeniedMessage, slog.Any("claims", claims))
render.Error(rw, err)
return
selectors := []authtypes.Selector{
authtypes.MustNewSelector(authtypes.TypeRole, authtypes.SigNozAdminRoleName),
}
err = middleware.authzService.CheckWithTupleCreation(
req.Context(),
claims,
valuer.MustNewUUID(claims.OrgID),
authtypes.RelationAssignee,
authtypes.TypeableRole,
selectors,
selectors,
)
if err != nil {
id := mux.Vars(req)["id"]
if err := claims.IsSelfAccess(id); err != nil {
middleware.logger.WarnContext(req.Context(), authzDeniedMessage, slog.Any("claims", claims))
render.Error(rw, err)
return
}
}
next(rw, req)
})
}

View File

@@ -63,6 +63,8 @@ func (m *IdentN) Wrap(next http.Handler) http.Handler {
comment := ctxtypes.CommentFromContext(ctx)
comment.Set("identn_provider", claims.IdentNProvider)
comment.Set("user_id", claims.UserID)
comment.Set("service_account_id", claims.ServiceAccountID)
comment.Set("principal", claims.Principal)
comment.Set("org_id", claims.OrgID)
ctx = ctxtypes.NewContextWithComment(ctx, comment)

View File

@@ -10,33 +10,29 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/identn"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
"github.com/SigNoz/signoz/pkg/types/authtypes"
)
// todo: will move this in types layer with service account integration
type apiKeyTokenKey struct{}
type provider struct {
store sqlstore.SQLStore
config identn.Config
settings factory.ScopedProviderSettings
sfGroup *singleflight.Group
serviceAccount serviceaccount.Module
config identn.Config
settings factory.ScopedProviderSettings
sfGroup *singleflight.Group
}
func NewFactory(store sqlstore.SQLStore) factory.ProviderFactory[identn.IdentN, identn.Config] {
func NewFactory(serviceAccount serviceaccount.Module) factory.ProviderFactory[identn.IdentN, identn.Config] {
return factory.NewProviderFactory(factory.MustNewName(authtypes.IdentNProviderAPIKey.StringValue()), func(ctx context.Context, providerSettings factory.ProviderSettings, config identn.Config) (identn.IdentN, error) {
return New(providerSettings, store, config)
return New(serviceAccount, config, providerSettings)
})
}
func New(providerSettings factory.ProviderSettings, store sqlstore.SQLStore, config identn.Config) (identn.IdentN, error) {
func New(serviceAccount serviceaccount.Module, config identn.Config, providerSettings factory.ProviderSettings) (identn.IdentN, error) {
return &provider{
store: store,
config: config,
settings: factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/identn/apikeyidentn"),
sfGroup: &singleflight.Group{},
serviceAccount: serviceAccount,
config: config,
settings: factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/identn/apikeyidentn"),
sfGroup: &singleflight.Group{},
}, nil
}
@@ -54,75 +50,44 @@ func (provider *provider) Test(req *http.Request) bool {
}
func (provider *provider) Pre(req *http.Request) *http.Request {
token := provider.extractToken(req)
if token == "" {
apiKey := provider.extractToken(req)
if apiKey == "" {
return req
}
ctx := context.WithValue(req.Context(), apiKeyTokenKey{}, token)
ctx := authtypes.NewContextWithAPIKey(req.Context(), apiKey)
return req.WithContext(ctx)
}
func (provider *provider) GetIdentity(req *http.Request) (*authtypes.Identity, error) {
ctx := req.Context()
apiKeyToken, ok := ctx.Value(apiKeyTokenKey{}).(string)
if !ok || apiKeyToken == "" {
return nil, errors.New(errors.TypeUnauthenticated, errors.CodeUnauthenticated, "missing api key")
}
var apiKey types.StorableAPIKey
err := provider.
store.
BunDB().
NewSelect().
Model(&apiKey).
Where("token = ?", apiKeyToken).
Scan(ctx)
apiKey, err := authtypes.APIKeyFromContext(ctx)
if err != nil {
return nil, err
}
if apiKey.ExpiresAt.Before(time.Now()) && !apiKey.ExpiresAt.Equal(types.NEVER_EXPIRES) {
return nil, errors.New(errors.TypeUnauthenticated, errors.CodeUnauthenticated, "api key has expired")
}
var user types.User
err = provider.
store.
BunDB().
NewSelect().
Model(&user).
Where("id = ?", apiKey.UserID).
Scan(ctx)
identity, err := provider.serviceAccount.GetIdentity(ctx, apiKey)
if err != nil {
return nil, err
}
identity := authtypes.NewIdentity(user.ID, user.OrgID, user.Email, apiKey.Role, provider.Name())
return identity, nil
}
func (provider *provider) Post(ctx context.Context, _ *http.Request, _ authtypes.Claims) {
apiKeyToken, ok := ctx.Value(apiKeyTokenKey{}).(string)
if !ok || apiKeyToken == "" {
apiKey, err := authtypes.APIKeyFromContext(ctx)
if err != nil {
return
}
_, _, _ = provider.sfGroup.Do(apiKeyToken, func() (any, error) {
_, err := provider.
store.
BunDB().
NewUpdate().
Model(new(types.StorableAPIKey)).
Set("last_used = ?", time.Now()).
Where("token = ?", apiKeyToken).
Where("revoked = false").
Exec(ctx)
if err != nil {
provider.settings.Logger().ErrorContext(ctx, "failed to update last used of api key", errors.Attr(err))
_, _, _ = provider.sfGroup.Do(apiKey, func() (any, error) {
if err := provider.serviceAccount.SetLastObservedAt(ctx, apiKey, time.Now()); err != nil {
provider.settings.Logger().ErrorContext(ctx, "failed to set last observed at", errors.Attr(err))
return false, err
}
return true, nil
})
}
func (provider *provider) extractToken(req *http.Request) string {

View File

@@ -79,22 +79,15 @@ func (provider *provider) GetIdentity(req *http.Request) (*authtypes.Identity, e
return nil, err
}
rootUser, userRoles, err := provider.userGetter.GetRootUserByOrgID(ctx, org.ID)
rootUser, _, 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(
provider.identity = authtypes.NewPrincipalUserIdentity(
rootUser.ID,
rootUser.OrgID,
rootUser.Email,
role,
authtypes.IdentNProviderImpersonation,
)

View File

@@ -53,7 +53,6 @@ type Module interface {
}
type Handler interface {
// GetConnectionArtifact creates a new cloud integration account and returns the connection artifact
GetConnectionArtifact(http.ResponseWriter, *http.Request)
ListAccounts(http.ResponseWriter, *http.Request)
GetAccount(http.ResponseWriter, *http.Request)

View File

@@ -1,58 +0,0 @@
package implcloudintegration
import (
"net/http"
"github.com/SigNoz/signoz/pkg/modules/cloudintegration"
)
type handler struct{}
func NewHandler() cloudintegration.Handler {
return &handler{}
}
func (h handler) GetConnectionArtifact(writer http.ResponseWriter, request *http.Request) {
// TODO implement me
panic("implement me")
}
func (h handler) ListAccounts(writer http.ResponseWriter, request *http.Request) {
// TODO implement me
panic("implement me")
}
func (h handler) GetAccount(writer http.ResponseWriter, request *http.Request) {
// TODO implement me
panic("implement me")
}
func (h handler) UpdateAccount(writer http.ResponseWriter, request *http.Request) {
// TODO implement me
panic("implement me")
}
func (h handler) DisconnectAccount(writer http.ResponseWriter, request *http.Request) {
// TODO implement me
panic("implement me")
}
func (h handler) ListServicesMetadata(writer http.ResponseWriter, request *http.Request) {
// TODO implement me
panic("implement me")
}
func (h handler) GetService(writer http.ResponseWriter, request *http.Request) {
// TODO implement me
panic("implement me")
}
func (h handler) UpdateService(writer http.ResponseWriter, request *http.Request) {
// TODO implement me
panic("implement me")
}
func (h handler) AgentCheckIn(writer http.ResponseWriter, request *http.Request) {
// TODO implement me
panic("implement me")
}

View File

@@ -43,7 +43,7 @@ type Module interface {
Update(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, data dashboardtypes.UpdatableDashboard, diff int) (*dashboardtypes.Dashboard, error)
LockUnlock(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, role types.Role, lock bool) error
LockUnlock(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, isAdmin bool, lock bool) error
Delete(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error

View File

@@ -7,6 +7,7 @@ import (
"strconv"
"time"
"github.com/SigNoz/signoz/pkg/authz"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/http/binding"
@@ -22,11 +23,12 @@ import (
type handler struct {
module dashboard.Module
authz authz.AuthZ
providerSettings factory.ProviderSettings
}
func NewHandler(module dashboard.Module, providerSettings factory.ProviderSettings) dashboard.Handler {
return &handler{module: module, providerSettings: providerSettings}
func NewHandler(module dashboard.Module, providerSettings factory.ProviderSettings, authz authz.AuthZ) dashboard.Handler {
return &handler{module: module, providerSettings: providerSettings, authz: authz}
}
func (handler *handler) Create(rw http.ResponseWriter, r *http.Request) {
@@ -57,7 +59,7 @@ func (handler *handler) Create(rw http.ResponseWriter, r *http.Request) {
dashboardMigrator.Migrate(ctx, req)
}
dashboard, err := handler.module.Create(ctx, orgID, claims.Email, valuer.MustNewUUID(claims.UserID), req)
dashboard, err := handler.module.Create(ctx, orgID, claims.Email, valuer.MustNewUUID(claims.IdentityID()), req)
if err != nil {
render.Error(rw, err)
return
@@ -155,7 +157,24 @@ func (handler *handler) LockUnlock(rw http.ResponseWriter, r *http.Request) {
return
}
err = handler.module.LockUnlock(ctx, orgID, dashboardID, claims.Email, claims.Role, *req.Locked)
isAdmin := false
selectors := []authtypes.Selector{
authtypes.MustNewSelector(authtypes.TypeRole, authtypes.SigNozAdminRoleName),
}
err = handler.authz.CheckWithTupleCreation(
ctx,
claims,
valuer.MustNewUUID(claims.OrgID),
authtypes.RelationAssignee,
authtypes.TypeableRole,
selectors,
selectors,
)
if err == nil {
isAdmin = true
}
err = handler.module.LockUnlock(ctx, orgID, dashboardID, claims.Email, isAdmin, *req.Locked)
if err != nil {
render.Error(rw, err)
return

View File

@@ -100,13 +100,13 @@ func (module *module) Update(ctx context.Context, orgID valuer.UUID, id valuer.U
return dashboard, nil
}
func (module *module) LockUnlock(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, role types.Role, lock bool) error {
func (module *module) LockUnlock(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, isAdmin bool, lock bool) error {
dashboard, err := module.Get(ctx, orgID, id)
if err != nil {
return err
}
err = dashboard.LockUnlock(lock, role, updatedBy)
err = dashboard.LockUnlock(lock, isAdmin, updatedBy)
if err != nil {
return err
}

View File

@@ -0,0 +1,29 @@
package serviceaccount
import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/types/serviceaccounttypes"
)
type Config struct {
Prefix string `mapstructure:"prefix"`
}
func NewConfigFactory() factory.ConfigFactory {
return factory.NewConfigFactory(factory.MustNewName("serviceaccount"), newConfig)
}
func newConfig() factory.Config {
return &Config{
Prefix: "signoz",
}
}
func (c Config) Validate() error {
if c.Prefix == "" {
return errors.New(errors.TypeInvalidInput, serviceaccounttypes.ErrCodeServiceAccountInvalidConfig, "prefix cannot be empty")
}
return nil
}

View File

@@ -35,7 +35,7 @@ func (handler *handler) Create(rw http.ResponseWriter, r *http.Request) {
return
}
serviceAccount := serviceaccounttypes.NewServiceAccount(req.Name, req.Email, req.Roles, serviceaccounttypes.StatusActive, valuer.MustNewUUID(claims.OrgID))
serviceAccount := serviceaccounttypes.NewServiceAccount(req.Name, handler.module.Config().Prefix, req.Roles, serviceaccounttypes.StatusActive, valuer.MustNewUUID(claims.OrgID))
err = handler.module.Create(ctx, valuer.MustNewUUID(claims.OrgID), serviceAccount)
if err != nil {
render.Error(rw, err)
@@ -111,7 +111,7 @@ func (handler *handler) Update(rw http.ResponseWriter, r *http.Request) {
return
}
err = serviceAccount.Update(req.Name, req.Email, req.Roles)
err = serviceAccount.Update(req.Name, req.Roles)
if err != nil {
render.Error(rw, err)
return
@@ -299,7 +299,12 @@ func (handler *handler) UpdateFactorAPIKey(rw http.ResponseWriter, r *http.Reque
return
}
factorAPIKey.Update(req.Name, req.ExpiresAt)
err = factorAPIKey.Update(req.Name, req.ExpiresAt)
if err != nil {
render.Error(rw, err)
return
}
err = handler.module.UpdateFactorAPIKey(ctx, valuer.MustNewUUID(claims.OrgID), serviceAccount.ID, factorAPIKey)
if err != nil {
render.Error(rw, err)

View File

@@ -2,28 +2,36 @@ package implserviceaccount
import (
"context"
"time"
"github.com/SigNoz/signoz/pkg/analytics"
"github.com/SigNoz/signoz/pkg/authz"
"github.com/SigNoz/signoz/pkg/emailing"
"github.com/SigNoz/signoz/pkg/cache"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/emailtypes"
"github.com/SigNoz/signoz/pkg/types/cachetypes"
"github.com/SigNoz/signoz/pkg/types/serviceaccounttypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
var (
emptyOrgID valuer.UUID = valuer.UUID{}
)
type module struct {
store serviceaccounttypes.Store
authz authz.AuthZ
emailing emailing.Emailing
settings factory.ScopedProviderSettings
store serviceaccounttypes.Store
authz authz.AuthZ
cache cache.Cache
analytics analytics.Analytics
settings factory.ScopedProviderSettings
config serviceaccount.Config
}
func NewModule(store serviceaccounttypes.Store, authz authz.AuthZ, emailing emailing.Emailing, providerSettings factory.ProviderSettings) serviceaccount.Module {
func NewModule(store serviceaccounttypes.Store, authz authz.AuthZ, cache cache.Cache, analytics analytics.Analytics, providerSettings factory.ProviderSettings, config serviceaccount.Config) serviceaccount.Module {
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/modules/serviceaccount/implserviceaccount")
return &module{store: store, authz: authz, emailing: emailing, settings: settings}
return &module{store: store, authz: authz, cache: cache, analytics: analytics, settings: settings, config: config}
}
func (module *module) Create(ctx context.Context, orgID valuer.UUID, serviceAccount *serviceaccounttypes.ServiceAccount) error {
@@ -58,6 +66,8 @@ func (module *module) Create(ctx context.Context, orgID valuer.UUID, serviceAcco
return err
}
module.analytics.IdentifyUser(ctx, orgID.String(), serviceAccount.ID.String(), serviceAccount.Traits())
module.analytics.TrackUser(ctx, orgID.String(), serviceAccount.ID.String(), "Service Account Created", serviceAccount.Traits())
return nil
}
@@ -68,7 +78,7 @@ func (module *module) GetOrCreate(ctx context.Context, serviceAccount *serviceac
}
if existingServiceAccount != nil {
return serviceAccount, nil
return serviceaccounttypes.NewServiceAccountFromStorables(existingServiceAccount, serviceAccount.Roles), nil
}
err = module.Create(ctx, serviceAccount.OrgID, serviceAccount)
@@ -76,6 +86,8 @@ func (module *module) GetOrCreate(ctx context.Context, serviceAccount *serviceac
return nil, err
}
module.analytics.IdentifyUser(ctx, serviceAccount.OrgID.String(), serviceAccount.ID.String(), serviceAccount.Traits())
module.analytics.TrackUser(ctx, serviceAccount.OrgID.String(), serviceAccount.ID.String(), "Service Account Created", serviceAccount.Traits())
return serviceAccount, nil
}
@@ -186,6 +198,8 @@ func (module *module) Update(ctx context.Context, orgID valuer.UUID, input *serv
return err
}
module.analytics.IdentifyUser(ctx, orgID.String(), input.ID.String(), input.Traits())
module.analytics.TrackUser(ctx, orgID.String(), input.ID.String(), "Service Account Updated", input.Traits())
return nil
}
@@ -214,6 +228,11 @@ func (module *module) UpdateStatus(ctx context.Context, orgID valuer.UUID, input
return err
}
// delete the cache when updating status for service account
module.cache.Delete(ctx, emptyOrgID, identityCacheKey(input.ID))
module.analytics.IdentifyUser(ctx, orgID.String(), input.ID.String(), input.Traits())
module.analytics.TrackUser(ctx, orgID.String(), input.ID.String(), "Service Account Deleted", map[string]any{})
return nil
}
@@ -251,6 +270,11 @@ func (module *module) Delete(ctx context.Context, orgID valuer.UUID, id valuer.U
return err
}
// delete the cache when deleting service account
module.cache.Delete(ctx, emptyOrgID, identityCacheKey(id))
module.analytics.IdentifyUser(ctx, orgID.String(), serviceAccount.ID.String(), serviceAccount.Traits())
module.analytics.TrackUser(ctx, orgID.String(), id.String(), "Service Account Deleted", map[string]any{})
return nil
}
@@ -263,22 +287,36 @@ func (module *module) CreateFactorAPIKey(ctx context.Context, factorAPIKey *serv
}
serviceAccount, err := module.store.GetByID(ctx, factorAPIKey.ServiceAccountID)
if err != nil {
return err
}
if err := module.emailing.SendHTML(ctx, serviceAccount.Email, "New API Key created for your SigNoz account", emailtypes.TemplateNameAPIKeyEvent, map[string]any{
"Name": serviceAccount.Name,
"KeyName": factorAPIKey.Name,
"KeyID": factorAPIKey.ID.String(),
"KeyCreatedAt": factorAPIKey.CreatedAt.String(),
}); err != nil {
module.settings.Logger().ErrorContext(ctx, "failed to send email", errors.Attr(err))
if err == nil {
module.analytics.TrackUser(ctx, serviceAccount.OrgID, serviceAccount.ID.String(), "API Key created", factorAPIKey.Traits())
}
return nil
}
func (module *module) GetOrCreateFactorAPIKey(ctx context.Context, factorAPIKey *serviceaccounttypes.FactorAPIKey) (*serviceaccounttypes.FactorAPIKey, error) {
existingFactorAPIKey, err := module.store.GetFactorAPIKeyByName(ctx, factorAPIKey.ServiceAccountID, factorAPIKey.Name)
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
return nil, err
}
if existingFactorAPIKey != nil {
return serviceaccounttypes.NewFactorAPIKeyFromStorable(existingFactorAPIKey), nil
}
err = module.CreateFactorAPIKey(ctx, factorAPIKey)
if err != nil {
return nil, err
}
serviceAccount, err := module.store.GetByID(ctx, factorAPIKey.ServiceAccountID)
if err == nil {
module.analytics.TrackUser(ctx, serviceAccount.OrgID, serviceAccount.ID.String(), "API Key created", factorAPIKey.Traits())
}
return factorAPIKey, nil
}
func (module *module) GetFactorAPIKey(ctx context.Context, serviceAccountID valuer.UUID, id valuer.UUID) (*serviceaccounttypes.FactorAPIKey, error) {
storableFactorAPIKey, err := module.store.GetFactorAPIKey(ctx, serviceAccountID, id)
if err != nil {
@@ -297,12 +335,15 @@ func (module *module) ListFactorAPIKey(ctx context.Context, serviceAccountID val
return serviceaccounttypes.NewFactorAPIKeyFromStorables(storables), nil
}
func (module *module) UpdateFactorAPIKey(ctx context.Context, _ valuer.UUID, serviceAccountID valuer.UUID, factorAPIKey *serviceaccounttypes.FactorAPIKey) error {
func (module *module) UpdateFactorAPIKey(ctx context.Context, orgID valuer.UUID, serviceAccountID valuer.UUID, factorAPIKey *serviceaccounttypes.FactorAPIKey) error {
err := module.store.UpdateFactorAPIKey(ctx, serviceAccountID, serviceaccounttypes.NewStorableFactorAPIKey(factorAPIKey))
if err != nil {
return err
}
// delete the cache when updating the factor api key
module.cache.Delete(ctx, emptyOrgID, apiKeyCacheKey(factorAPIKey.Key))
module.analytics.TrackUser(ctx, orgID.String(), serviceAccountID.String(), "API Key updated", factorAPIKey.Traits())
return nil
}
@@ -322,14 +363,108 @@ func (module *module) RevokeFactorAPIKey(ctx context.Context, serviceAccountID v
return err
}
if err := module.emailing.SendHTML(ctx, serviceAccount.Email, "API Key revoked for your SigNoz account", emailtypes.TemplateNameAPIKeyEvent, map[string]any{
"Name": serviceAccount.Name,
"KeyName": factorAPIKey.Name,
"KeyID": factorAPIKey.ID.String(),
"KeyCreatedAt": factorAPIKey.CreatedAt.String(),
}); err != nil {
module.settings.Logger().ErrorContext(ctx, "failed to send email", errors.Attr(err))
}
// delete the cache when revoking the factor api key
module.cache.Delete(ctx, emptyOrgID, apiKeyCacheKey(factorAPIKey.Key))
module.analytics.TrackUser(ctx, serviceAccount.OrgID, serviceAccountID.String(), "API Key revoked", factorAPIKey.Traits())
return nil
}
func (module *module) Config() serviceaccount.Config {
return module.config
}
func (module *module) Collect(ctx context.Context, orgID valuer.UUID) (map[string]any, error) {
stats := make(map[string]any)
count, err := module.store.CountByOrgID(ctx, orgID)
if err == nil {
stats["serviceaccount.count"] = count
}
count, err = module.store.CountFactorAPIKeysByOrgID(ctx, orgID)
if err == nil {
stats["serviceaccount.keys.count"] = count
}
return stats, nil
}
func (module *module) GetIdentity(ctx context.Context, key string) (*authtypes.Identity, error) {
apiKey, err := module.getOrGetSetAPIKey(ctx, key)
if err != nil {
return nil, err
}
if err := apiKey.IsExpired(); err != nil {
return nil, err
}
identity, err := module.getOrGetSetIdentity(ctx, apiKey.ServiceAccountID)
if err != nil {
return nil, err
}
return identity, nil
}
func (module *module) SetLastObservedAt(ctx context.Context, key string, lastObservedAt time.Time) error {
return module.store.UpdateLastObservedAt(ctx, key, lastObservedAt)
}
func (module *module) getOrGetSetAPIKey(ctx context.Context, key string) (*serviceaccounttypes.FactorAPIKey, error) {
factorAPIkey := new(serviceaccounttypes.FactorAPIKey)
err := module.cache.Get(ctx, emptyOrgID, apiKeyCacheKey(key), factorAPIkey)
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
return nil, err
}
if err == nil {
return factorAPIkey, nil
}
storable, err := module.store.GetFactorAPIKeyByKey(ctx, key)
if err != nil {
return nil, err
}
factorAPIkey = serviceaccounttypes.NewFactorAPIKeyFromStorable(storable)
err = module.cache.Set(ctx, emptyOrgID, apiKeyCacheKey(key), factorAPIkey, time.Duration(factorAPIkey.ExpiresAt))
if err != nil {
return nil, err
}
return factorAPIkey, nil
}
func (module *module) getOrGetSetIdentity(ctx context.Context, serviceAccountID valuer.UUID) (*authtypes.Identity, error) {
identity := new(authtypes.Identity)
err := module.cache.Get(ctx, emptyOrgID, identityCacheKey(serviceAccountID), identity)
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
return nil, err
}
if err == nil {
return identity, nil
}
storableServiceAccount, err := module.store.GetByID(ctx, serviceAccountID)
if err != nil {
return nil, err
}
identity = storableServiceAccount.ToIdentity()
err = module.cache.Set(ctx, emptyOrgID, identityCacheKey(serviceAccountID), identity, 0)
if err != nil {
return nil, err
}
return identity, nil
}
func apiKeyCacheKey(apiKey string) string {
return "api_key::" + cachetypes.NewSha1CacheKey(apiKey)
}
func identityCacheKey(serviceAccountID valuer.UUID) string {
return "identity::" + serviceAccountID.String()
}

View File

@@ -2,6 +2,7 @@ package implserviceaccount
import (
"context"
"time"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types/serviceaccounttypes"
@@ -84,6 +85,23 @@ func (store *store) GetByID(ctx context.Context, id valuer.UUID) (*serviceaccoun
return storable, nil
}
func (store *store) CountByOrgID(ctx context.Context, orgID valuer.UUID) (int64, error) {
storable := new(serviceaccounttypes.StorableServiceAccount)
count, err := store.
sqlstore.
BunDB().
NewSelect().
Model(storable).
Where("org_id = ?", orgID).
Count(ctx)
if err != nil {
return 0, err
}
return int64(count), nil
}
func (store *store) List(ctx context.Context, orgID valuer.UUID) ([]*serviceaccounttypes.StorableServiceAccount, error) {
storables := make([]*serviceaccounttypes.StorableServiceAccount, 0)
@@ -231,6 +249,60 @@ func (store *store) GetFactorAPIKey(ctx context.Context, serviceAccountID valuer
return storable, nil
}
func (store *store) GetFactorAPIKeyByKey(ctx context.Context, key string) (*serviceaccounttypes.StorableFactorAPIKey, error) {
storable := new(serviceaccounttypes.StorableFactorAPIKey)
err := store.
sqlstore.
BunDBCtx(ctx).
NewSelect().
Model(storable).
Where("key = ?", key).
Scan(ctx)
if err != nil {
return nil, store.sqlstore.WrapNotFoundErrf(err, serviceaccounttypes.ErrCodeAPIKeytNotFound, "api key with key: %s doesn't exist.", key)
}
return storable, nil
}
func (store *store) GetFactorAPIKeyByName(ctx context.Context, serviceAccountID valuer.UUID, name string) (*serviceaccounttypes.StorableFactorAPIKey, error) {
storable := new(serviceaccounttypes.StorableFactorAPIKey)
err := store.
sqlstore.
BunDBCtx(ctx).
NewSelect().
Model(storable).
Where("service_account_id = ?", serviceAccountID.String()).
Where("name = ?", name).
Scan(ctx)
if err != nil {
return nil, store.sqlstore.WrapNotFoundErrf(err, serviceaccounttypes.ErrCodeAPIKeytNotFound, "api key with name: %s doesn't exist in service account: %s", name, serviceAccountID.String())
}
return storable, nil
}
func (store *store) CountFactorAPIKeysByOrgID(ctx context.Context, orgID valuer.UUID) (int64, error) {
storable := new(serviceaccounttypes.StorableFactorAPIKey)
count, err := store.
sqlstore.
BunDBCtx(ctx).
NewSelect().
Model(storable).
Join("JOIN service_account").
JoinOn("service_account.id = factor_api_key.service_account_id").
Where("service_account.org_id = ?", orgID).
Count(ctx)
if err != nil {
return 0, err
}
return int64(count), nil
}
func (store *store) ListFactorAPIKey(ctx context.Context, serviceAccountID valuer.UUID) ([]*serviceaccounttypes.StorableFactorAPIKey, error) {
storables := make([]*serviceaccounttypes.StorableFactorAPIKey, 0)
@@ -254,6 +326,7 @@ func (store *store) UpdateFactorAPIKey(ctx context.Context, serviceAccountID val
BunDBCtx(ctx).
NewUpdate().
Model(storable).
WherePK().
Where("service_account_id = ?", serviceAccountID).
Exec(ctx)
if err != nil {
@@ -263,6 +336,22 @@ func (store *store) UpdateFactorAPIKey(ctx context.Context, serviceAccountID val
return nil
}
func (store *store) UpdateLastObservedAt(ctx context.Context, key string, lastObservedAt time.Time) error {
_, err := store.
sqlstore.
BunDBCtx(ctx).
NewUpdate().
TableExpr("factor_api_key").
Set("last_observed_at = ?", lastObservedAt).
Where("key = ?", key).
Exec(ctx)
if err != nil {
return err
}
return nil
}
func (store *store) RevokeFactorAPIKey(ctx context.Context, serviceAccountID valuer.UUID, id valuer.UUID) error {
_, err := store.
sqlstore.

View File

@@ -3,7 +3,9 @@ package serviceaccount
import (
"context"
"net/http"
"time"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/serviceaccounttypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
@@ -39,14 +41,24 @@ type Module interface {
// Gets a factor API key by id
GetFactorAPIKey(context.Context, valuer.UUID, valuer.UUID) (*serviceaccounttypes.FactorAPIKey, error)
GetOrCreateFactorAPIKey(context.Context, *serviceaccounttypes.FactorAPIKey) (*serviceaccounttypes.FactorAPIKey, error)
// Lists all the API keys for a service account
ListFactorAPIKey(context.Context, valuer.UUID) ([]*serviceaccounttypes.FactorAPIKey, error)
// Updates an existing API key for a service account
UpdateFactorAPIKey(context.Context, valuer.UUID, valuer.UUID, *serviceaccounttypes.FactorAPIKey) error
// Set the last observed at for an api key.
SetLastObservedAt(context.Context, string, time.Time) error
// Revokes an existing API key for a service account
RevokeFactorAPIKey(context.Context, valuer.UUID, valuer.UUID) error
// Gets the identity for service account based on the factor api key.
GetIdentity(context.Context, string) (*authtypes.Identity, error)
Config() Config
}
type Handler interface {

View File

@@ -160,18 +160,7 @@ func (module *module) CreateCallbackAuthNSession(ctx context.Context, authNProvi
return "", errors.WithAdditionalf(err, "root user can only authenticate via password")
}
userRoles, err := module.userGetter.GetUserRoles(ctx, newUser.ID)
if err != nil {
return "", err
}
if len(userRoles) == 0 {
return "", errors.New(errors.TypeUnexpected, authtypes.ErrCodeUserRolesNotFound, "no user roles entries found")
}
finalRole := authtypes.SigNozManagedRoleToExistingLegacyRole[userRoles[0].Role.Name]
token, err := module.tokenizer.CreateToken(ctx, authtypes.NewIdentity(newUser.ID, newUser.OrgID, newUser.Email, finalRole, authtypes.IdentNProviderTokenizer), map[string]string{})
token, err := module.tokenizer.CreateToken(ctx, authtypes.NewPrincipalUserIdentity(newUser.ID, newUser.OrgID, newUser.Email, authtypes.IdentNProviderTokenizer), map[string]string{})
if err != nil {
return "", err
}

View File

@@ -35,7 +35,7 @@ func (h *handler) GetSpanPercentileDetails(w http.ResponseWriter, r *http.Reques
return
}
result, err := h.module.GetSpanPercentile(r.Context(), valuer.MustNewUUID(claims.OrgID), valuer.MustNewUUID(claims.UserID), spanPercentileRequest)
result, err := h.module.GetSpanPercentile(r.Context(), valuer.MustNewUUID(claims.OrgID), spanPercentileRequest)
if err != nil {
render.Error(w, err)
return

View File

@@ -28,7 +28,7 @@ func NewModule(
}
}
func (m *module) GetSpanPercentile(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, req *spanpercentiletypes.SpanPercentileRequest) (*spanpercentiletypes.SpanPercentileResponse, error) {
func (m *module) GetSpanPercentile(ctx context.Context, orgID valuer.UUID, req *spanpercentiletypes.SpanPercentileRequest) (*spanpercentiletypes.SpanPercentileResponse, error) {
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
instrumentationtypes.CodeNamespace: "spanpercentile",
instrumentationtypes.CodeFunctionName: "GetSpanPercentile",

View File

@@ -9,7 +9,7 @@ import (
)
type Module interface {
GetSpanPercentile(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, req *spanpercentiletypes.SpanPercentileRequest) (*spanpercentiletypes.SpanPercentileResponse, error)
GetSpanPercentile(ctx context.Context, orgID valuer.UUID, req *spanpercentiletypes.SpanPercentileRequest) (*spanpercentiletypes.SpanPercentileResponse, error)
}
type Handler interface {

View File

@@ -4,7 +4,6 @@ import (
"context"
"encoding/json"
"net/http"
"slices"
"time"
"github.com/SigNoz/signoz/pkg/errors"
@@ -13,7 +12,6 @@ import (
root "github.com/SigNoz/signoz/pkg/modules/user"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/integrationtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/gorilla/mux"
)
@@ -43,7 +41,7 @@ func (h *handler) CreateInvite(rw http.ResponseWriter, r *http.Request) {
return
}
invites, err := h.setter.CreateBulkInvite(ctx, valuer.MustNewUUID(claims.OrgID), valuer.MustNewUUID(claims.UserID), &types.PostableBulkInviteRequest{
invites, err := h.setter.CreateBulkInvite(ctx, valuer.MustNewUUID(claims.OrgID), valuer.MustNewUUID(claims.IdentityID()), valuer.MustNewEmail(claims.Email), &types.PostableBulkInviteRequest{
Invites: []types.PostableInvite{req},
})
if err != nil {
@@ -76,7 +74,7 @@ func (h *handler) CreateBulkInvite(rw http.ResponseWriter, r *http.Request) {
return
}
_, err = h.setter.CreateBulkInvite(ctx, valuer.MustNewUUID(claims.OrgID), valuer.MustNewUUID(claims.UserID), &req)
_, err = h.setter.CreateBulkInvite(ctx, valuer.MustNewUUID(claims.OrgID), valuer.MustNewUUID(claims.IdentityID()), valuer.MustNewEmail(claims.Email), &req)
if err != nil {
render.Error(rw, err)
return
@@ -162,7 +160,7 @@ func (h *handler) UpdateUser(w http.ResponseWriter, r *http.Request) {
return
}
updatedUser, err := h.setter.UpdateUser(ctx, valuer.MustNewUUID(claims.OrgID), id, &user, claims.UserID)
updatedUser, err := h.setter.UpdateUser(ctx, valuer.MustNewUUID(claims.OrgID), id, &user)
if err != nil {
render.Error(w, err)
return
@@ -183,7 +181,7 @@ func (h *handler) DeleteUser(w http.ResponseWriter, r *http.Request) {
return
}
if err := h.setter.DeleteUser(ctx, valuer.MustNewUUID(claims.OrgID), id, claims.UserID); err != nil {
if err := h.setter.DeleteUser(ctx, valuer.MustNewUUID(claims.OrgID), id, claims.IdentityID()); err != nil {
render.Error(w, err)
return
}
@@ -274,172 +272,3 @@ func (h *handler) ForgotPassword(w http.ResponseWriter, r *http.Request) {
render.Success(w, http.StatusNoContent, nil)
}
func (h *handler) CreateAPIKey(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(w, err)
return
}
req := new(types.PostableAPIKey)
if err := json.NewDecoder(r.Body).Decode(req); err != nil {
render.Error(w, errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to decode api key"))
return
}
apiKey, err := types.NewStorableAPIKey(
req.Name,
valuer.MustNewUUID(claims.UserID),
req.Role,
req.ExpiresInDays,
)
if err != nil {
render.Error(w, err)
return
}
err = h.setter.CreateAPIKey(ctx, apiKey)
if err != nil {
render.Error(w, err)
return
}
createdApiKey, err := h.setter.GetAPIKey(ctx, valuer.MustNewUUID(claims.OrgID), apiKey.ID)
if err != nil {
render.Error(w, err)
return
}
// just corrected the status code, response is same,
render.Success(w, http.StatusCreated, createdApiKey)
}
func (h *handler) ListAPIKeys(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(w, err)
return
}
apiKeys, err := h.setter.ListAPIKeys(ctx, valuer.MustNewUUID(claims.OrgID))
if err != nil {
render.Error(w, err)
return
}
// for backward compatibility
if len(apiKeys) == 0 {
render.Success(w, http.StatusOK, []types.GettableAPIKey{})
return
}
result := make([]*types.GettableAPIKey, len(apiKeys))
for i, apiKey := range apiKeys {
result[i] = types.NewGettableAPIKeyFromStorableAPIKey(apiKey)
}
render.Success(w, http.StatusOK, result)
}
func (h *handler) UpdateAPIKey(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(w, err)
return
}
req := types.StorableAPIKey{}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
render.Error(w, errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to decode api key"))
return
}
idStr := mux.Vars(r)["id"]
id, err := valuer.NewUUID(idStr)
if err != nil {
render.Error(w, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid-v7"))
return
}
//get the API Key
existingAPIKey, err := h.setter.GetAPIKey(ctx, valuer.MustNewUUID(claims.OrgID), id)
if err != nil {
render.Error(w, err)
return
}
// get the user
createdByUser, err := h.getter.Get(ctx, existingAPIKey.UserID)
if err != nil {
render.Error(w, err)
return
}
if slices.Contains(integrationtypes.AllIntegrationUserEmails, integrationtypes.IntegrationUserEmail(createdByUser.Email.String())) {
render.Error(w, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "API Keys for integration users cannot be revoked"))
return
}
err = h.setter.UpdateAPIKey(ctx, id, &req, valuer.MustNewUUID(claims.UserID))
if err != nil {
render.Error(w, err)
return
}
render.Success(w, http.StatusNoContent, nil)
}
func (h *handler) RevokeAPIKey(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(w, err)
return
}
idStr := mux.Vars(r)["id"]
id, err := valuer.NewUUID(idStr)
if err != nil {
render.Error(w, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid-v7"))
return
}
//get the API Key
existingAPIKey, err := h.setter.GetAPIKey(ctx, valuer.MustNewUUID(claims.OrgID), id)
if err != nil {
render.Error(w, err)
return
}
// get the user
createdByUser, err := h.getter.Get(ctx, existingAPIKey.UserID)
if err != nil {
render.Error(w, err)
return
}
if slices.Contains(integrationtypes.AllIntegrationUserEmails, integrationtypes.IntegrationUserEmail(createdByUser.Email.String())) {
render.Error(w, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "API Keys for integration users cannot be revoked"))
return
}
if err := h.setter.RevokeAPIKey(ctx, id, valuer.MustNewUUID(claims.UserID)); err != nil {
render.Error(w, err)
return
}
render.Success(w, http.StatusNoContent, nil)
}

View File

@@ -23,6 +23,7 @@ type service struct {
authz authz.AuthZ
config user.RootConfig
stopC chan struct{}
healthyC chan struct{}
}
func NewService(
@@ -42,12 +43,14 @@ func NewService(
orgGetter: orgGetter,
authz: authz,
config: config,
stopC: make(chan struct{}),
stopC: make(chan struct{}),
healthyC: make(chan struct{}),
}
}
func (s *service) Start(ctx context.Context) error {
if !s.config.Enabled {
close(s.healthyC)
<-s.stopC
return nil
}
@@ -59,6 +62,7 @@ func (s *service) Start(ctx context.Context) error {
err := s.reconcile(ctx)
if err == nil {
s.settings.Logger().InfoContext(ctx, "root user reconciliation completed successfully")
close(s.healthyC)
<-s.stopC
return nil
}
@@ -74,6 +78,10 @@ func (s *service) Start(ctx context.Context) error {
}
}
func (s *service) Healthy() <-chan struct{} {
return s.healthyC
}
func (s *service) Stop(ctx context.Context) error {
close(s.stopC)
return nil

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