Compare commits

..

95 Commits

Author SHA1 Message Date
Naman Verma
55487dde3a Merge branch 'nv/other-dashboard-v2-update-methods' into nv/delete-v2-dashboard 2026-05-04 19:27:40 +05:30
Naman Verma
fc5717af51 Merge branch 'nv/v2-dashboard-update' into nv/other-dashboard-v2-update-methods 2026-05-04 19:27:26 +05:30
Naman Verma
8bf650192e Merge branch 'nv/v2-dashboard-get' into nv/v2-dashboard-update 2026-05-04 19:27:14 +05:30
Naman Verma
f8fb7e5f8d Merge branch 'nv/v2-dashboard-create' into nv/v2-dashboard-get 2026-05-04 19:27:02 +05:30
Naman Verma
ff578f7d92 Merge branch 'nv/tags-for-dashboard-create' into nv/v2-dashboard-create 2026-05-04 19:26:49 +05:30
Naman Verma
cd630b1152 Merge branch 'nv/dashboardv2' into nv/tags-for-dashboard-create 2026-05-04 19:26:36 +05:30
Naman Verma
bd0842ac17 fix: query-less panels not allowed 2026-05-04 19:25:49 +05:30
Naman Verma
b3e3dd13b4 Merge branch 'nv/other-dashboard-v2-update-methods' into nv/delete-v2-dashboard 2026-05-04 17:48:42 +05:30
Naman Verma
710d5531f3 Merge branch 'nv/v2-dashboard-update' into nv/other-dashboard-v2-update-methods 2026-05-04 17:45:15 +05:30
Naman Verma
e37e427079 fix: merge fixes 2026-05-04 17:40:46 +05:30
Naman Verma
1e99ab4659 Merge branch 'nv/v2-dashboard-get' into nv/v2-dashboard-update 2026-05-04 17:40:26 +05:30
Naman Verma
3353cda021 Merge branch 'nv/v2-dashboard-create' into nv/v2-dashboard-get 2026-05-04 17:35:33 +05:30
Naman Verma
f5a71037bf Merge branch 'nv/v2-dashboard-create' into nv/v2-dashboard-get 2026-05-04 17:33:03 +05:30
Naman Verma
97b85c386a fix: no v2 package and its consequences 2026-05-04 17:27:58 +05:30
Naman Verma
00bdf50c1c fix: no v2 package and its consequences 2026-05-04 17:26:12 +05:30
Naman Verma
5dec4ec580 Merge branch 'nv/tags-for-dashboard-create' into nv/v2-dashboard-create 2026-05-04 17:18:39 +05:30
Naman Verma
325767c240 Merge branch 'nv/dashboardv2' into nv/tags-for-dashboard-create 2026-05-04 17:17:32 +05:30
Naman Verma
5fed2a4585 chore: no v2 subpackage 2026-05-04 17:16:39 +05:30
Naman Verma
664337ae0f Merge branch 'nv/tags-for-dashboard-create' into nv/v2-dashboard-create 2026-05-04 16:19:29 +05:30
Naman Verma
a0ea276681 Merge branch 'nv/dashboardv2' into nv/tags-for-dashboard-create 2026-05-04 16:18:03 +05:30
Naman Verma
2dc8699f08 fix: wrap errors 2026-05-04 14:55:38 +05:30
Naman Verma
ed81ed8ab5 fix: no need for copying textboxvariablespec 2026-05-04 14:44:42 +05:30
Naman Verma
48c9da19df fix: return 500 err if spec is nil for composite kind w/ code comment 2026-05-04 14:34:16 +05:30
Naman Verma
eb9663d518 fix: remove extra (un)marshal cycle 2026-05-04 14:18:37 +05:30
Naman Verma
a56a862338 fix: add allowed values in err messages 2026-05-04 14:16:22 +05:30
Naman Verma
021f33f65e Merge branch 'main' into nv/dashboardv2 2026-05-04 12:52:31 +05:30
Naman Verma
ca96c71146 feat: delete dashboard v2 API and hard delete cron job 2026-05-03 15:01:43 +05:30
Naman Verma
de2909d1d1 feat: lock, unlock, create public, update public v2 dashboard APIs 2026-05-03 14:38:24 +05:30
Naman Verma
f311fcabf7 feat: v2 dashboard update API 2026-04-29 18:39:48 +05:30
Naman Verma
a37c07f881 feat: v2 dashboard GET API 2026-04-29 15:12:47 +05:30
Naman Verma
4d9386f418 fix: merge conflicts 2026-04-29 14:36:39 +05:30
Naman Verma
737473521d Merge branch 'nv/tags-for-dashboard-create' into nv/v2-dashboard-create 2026-04-29 14:33:25 +05:30
Naman Verma
1863db8ba8 Merge branch 'nv/dashboardv2' into nv/tags-for-dashboard-create 2026-04-29 14:32:14 +05:30
Naman Verma
661af09a13 Merge branch 'main' into nv/dashboardv2 2026-04-29 14:31:59 +05:30
Naman Verma
6024fa2b91 fix: remove extra spec from builder query marshalling 2026-04-29 14:31:16 +05:30
Naman Verma
8996a96387 chore: use existing mapper 2026-04-29 14:09:34 +05:30
Naman Verma
d6db5c2aab test: integration test fixes 2026-04-29 12:56:14 +05:30
Naman Verma
709590ea1b test: integration tests for create API 2026-04-29 12:23:12 +05:30
Naman Verma
1add46b4c5 fix: module should also validate postable dashboard 2026-04-28 20:05:38 +05:30
Naman Verma
8401261e20 Merge branch 'nv/tags-for-dashboard-create' into nv/v2-dashboard-create 2026-04-28 20:04:33 +05:30
Naman Verma
0ff34a7274 Merge branch 'nv/dashboardv2' into nv/tags-for-dashboard-create 2026-04-28 20:04:08 +05:30
Naman Verma
44e3bd9608 chore: separate method for validation 2026-04-28 20:03:48 +05:30
Naman Verma
c3944d779e fix: more dashboard request validations 2026-04-28 19:59:11 +05:30
Naman Verma
f5ec783a53 fix: go lint fix 2026-04-28 19:33:28 +05:30
Naman Verma
35b729c425 Merge branch 'nv/tags-for-dashboard-create' into nv/v2-dashboard-create 2026-04-28 19:30:42 +05:30
Naman Verma
4f43c3d803 fix: use existing tag's casing if new tag is a prefix of an existing tag 2026-04-28 19:30:07 +05:30
Naman Verma
5dbde6c64d fix: only return name of a tag in dashboard response 2026-04-28 19:13:03 +05:30
Naman Verma
fb6fdd54ec feat: v2 create dashboard API 2026-04-28 15:05:29 +05:30
Naman Verma
64b8ba62da Merge branch 'nv/dashboardv2' into nv/tags-for-dashboard-create 2026-04-28 15:04:11 +05:30
Naman Verma
7c66df408b Merge branch 'main' into nv/dashboardv2 2026-04-28 15:04:03 +05:30
Naman Verma
54049de391 chore: follow proper unmarshal json method structure 2026-04-28 15:02:49 +05:30
Naman Verma
a82f4237c8 Merge branch 'nv/dashboardv2' into nv/tags-for-dashboard-create 2026-04-28 09:52:23 +05:30
Naman Verma
89606b6238 Merge branch 'main' into nv/dashboardv2 2026-04-28 09:52:13 +05:30
Naman Verma
db5ce958eb Merge branch 'nv/dashboardv2' into nv/tags-for-dashboard-create 2026-04-28 09:49:01 +05:30
Naman Verma
c8d3a9a54b feat: enum for entity type that other modules can register 2026-04-28 09:47:24 +05:30
Naman Verma
637870b1fc feat: define tags module for v2 dashboard creation 2026-04-27 22:14:47 +05:30
Naman Verma
d46a7e24c9 Merge branch 'main' into nv/dashboardv2 2026-04-27 22:12:12 +05:30
Naman Verma
2a451e1c31 test: test for drift detection mechanics 2026-04-27 18:57:41 +05:30
Naman Verma
60b6d1d890 chore: better method name extractKindAndSpec 2026-04-27 18:42:31 +05:30
Naman Verma
36f755b232 chore: cleanup comments 2026-04-27 18:39:41 +05:30
Naman Verma
c1b3e3683a chore: code movement 2026-04-27 18:37:57 +05:30
Naman Verma
4c68544b1a chore: go lint fix (godot) 2026-04-27 18:37:05 +05:30
Naman Verma
90d9ab95f9 chore: code movement 2026-04-27 18:35:18 +05:30
Naman Verma
065e712e0c chore: code movement 2026-04-27 18:33:48 +05:30
Naman Verma
50db309ecd chore: code movement 2026-04-27 18:32:41 +05:30
Naman Verma
261bc552b0 chore: cleanup testing code 2026-04-27 18:24:52 +05:30
Naman Verma
bab720e98b Merge branch 'main' into nv/dashboardv2 2026-04-27 18:21:09 +05:30
Naman Verma
71fef6636b chore: better method name 2026-04-27 18:18:14 +05:30
Naman Verma
fc3cdecbbb chore: cleaner comment 2026-04-27 18:15:21 +05:30
Naman Verma
860fcfa641 chore: cleaner comment 2026-04-27 18:14:27 +05:30
Naman Verma
a090e3a4aa chore: cleaner comment 2026-04-27 18:14:02 +05:30
Naman Verma
6cf73e2ade chore: better comment to explain what restrictKindToLiteral does 2026-04-27 18:13:34 +05:30
Naman Verma
bbcb6a45d6 chore: renames and code rearrangement 2026-04-27 17:53:54 +05:30
Naman Verma
d13934febc fix: remove textbox plugin from openapi spec 2026-04-27 17:29:36 +05:30
Naman Verma
d5a7b7523d fix: strict decode variable spec as well 2026-04-27 17:27:51 +05:30
Naman Verma
5b8984f131 Merge branch 'main' into nv/dashboardv2 2026-04-27 17:18:44 +05:30
Naman Verma
6ddc5f1f12 chore: better error messages 2026-04-27 17:18:11 +05:30
Naman Verma
055968bfad fix: dot at the end of a comment 2026-04-27 17:07:58 +05:30
Naman Verma
1bf0f38ed9 fix: js lint errors 2026-04-27 17:07:38 +05:30
Naman Verma
842125e20a chore: too many comments 2026-04-27 16:50:41 +05:30
Naman Verma
6dab35caf8 chore: better file name 2026-04-27 16:43:42 +05:30
Naman Verma
047e9e2001 chore: better file names 2026-04-27 16:42:31 +05:30
Naman Verma
45eaa7db58 test: add tests for spec wrappers 2026-04-27 16:36:27 +05:30
Naman Verma
8a3d894eba chore: comment cleanup 2026-04-27 16:32:29 +05:30
Naman Verma
5239060b53 chore: move plugin maps to correct file 2026-04-27 16:30:33 +05:30
Naman Verma
42c6f507ac test: more descriptive test file name 2026-04-27 15:42:54 +05:30
Naman Verma
1b695a0b80 chore: separate file for perses replicas 2026-04-27 15:42:21 +05:30
Naman Verma
438cfab155 chore: comment clean up 2026-04-27 15:39:46 +05:30
Naman Verma
69f7617e01 Merge branch 'main' into nv/dashboardv2 2026-04-27 15:36:58 +05:30
Naman Verma
4420a7e1fc test: much bigger json for data column 2026-04-24 22:16:03 +05:30
Naman Verma
b4bc68c5c5 test: data column in perf tests should match real data 2026-04-24 17:17:37 +05:30
Naman Verma
eb9eb317cc test: perf test script for both sql flavours 2026-04-23 17:14:33 +05:30
Naman Verma
0b1eb16a42 test: fixes in dashboard perf testing data generator 2026-04-23 15:42:58 +05:30
Naman Verma
05a4d12183 test: script to generate test dashboard data in a sql db 2026-04-23 14:19:58 +05:30
Naman Verma
bbaf64c4f0 feat: openapi spec generation 2026-04-21 13:41:06 +05:30
160 changed files with 10111 additions and 10088 deletions

View File

@@ -27,6 +27,7 @@ import (
"github.com/SigNoz/signoz/pkg/modules/cloudintegration/implcloudintegration"
"github.com/SigNoz/signoz/pkg/modules/dashboard"
"github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"
"github.com/SigNoz/signoz/pkg/modules/tag"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/rulestatehistory"
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
@@ -100,8 +101,8 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
return openfgaauthz.NewProviderFactory(sqlstore, openfgaschema.NewSchema().Get(ctx), openfgaDataStore), nil
},
func(store sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, _ querier.Querier, _ licensing.Licensing) dashboard.Module {
return impldashboard.NewModule(impldashboard.NewStore(store), settings, analytics, orgGetter, queryParser)
func(store sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, _ querier.Querier, _ licensing.Licensing, tagModule tag.Module) dashboard.Module {
return impldashboard.NewModule(impldashboard.NewStore(store), store, settings, analytics, orgGetter, queryParser, tagModule)
},
func(_ licensing.Licensing) factory.ProviderFactory[gateway.Gateway, gateway.Config] {
return noopgateway.NewProviderFactory()

View File

@@ -42,6 +42,7 @@ import (
pkgcloudintegration "github.com/SigNoz/signoz/pkg/modules/cloudintegration/implcloudintegration"
"github.com/SigNoz/signoz/pkg/modules/dashboard"
pkgimpldashboard "github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"
"github.com/SigNoz/signoz/pkg/modules/tag"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/rulestatehistory"
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
@@ -144,8 +145,8 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
}
return openfgaauthz.NewProviderFactory(sqlstore, openfgaschema.NewSchema().Get(ctx), openfgaDataStore, licensing, onBeforeRoleDelete, dashboardModule), nil
},
func(store sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, querier querier.Querier, licensing licensing.Licensing) dashboard.Module {
return impldashboard.NewModule(pkgimpldashboard.NewStore(store), settings, analytics, orgGetter, queryParser, querier, licensing)
func(store sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, querier querier.Querier, licensing licensing.Licensing, tagModule tag.Module) dashboard.Module {
return impldashboard.NewModule(pkgimpldashboard.NewStore(store), store, settings, analytics, orgGetter, queryParser, querier, licensing, tagModule)
},
func(licensing licensing.Licensing) factory.ProviderFactory[gateway.Gateway, gateway.Config] {
return httpgateway.NewProviderFactory(licensing)

View File

@@ -13,8 +13,6 @@ global:
ingestion_url: <unset>
# the url of the SigNoz MCP server. when unset, the MCP settings page is hidden in the frontend.
# mcp_url: <unset>
# the url of the SigNoz AI Assistant server. when unset, the AI Assistant is hidden in the frontend.
# ai_assistant_url: <unset>
##################### Version #####################
version:

File diff suppressed because it is too large Load Diff

View File

@@ -11,8 +11,10 @@ import (
"github.com/SigNoz/signoz/pkg/modules/dashboard"
pkgimpldashboard "github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/tag"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/queryparser"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
@@ -30,9 +32,9 @@ type module struct {
licensing licensing.Licensing
}
func NewModule(store dashboardtypes.Store, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, querier querier.Querier, licensing licensing.Licensing) dashboard.Module {
func NewModule(store dashboardtypes.Store, sqlstore sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, querier querier.Querier, licensing licensing.Licensing, tagModule tag.Module) dashboard.Module {
scopedProviderSettings := factory.NewScopedProviderSettings(settings, "github.com/SigNoz/signoz/ee/modules/dashboard/impldashboard")
pkgDashboardModule := pkgimpldashboard.NewModule(store, settings, analytics, orgGetter, queryParser)
pkgDashboardModule := pkgimpldashboard.NewModule(store, sqlstore, settings, analytics, orgGetter, queryParser, tagModule)
return &module{
pkgDashboardModule: pkgDashboardModule,
@@ -197,6 +199,68 @@ func (module *module) Create(ctx context.Context, orgID valuer.UUID, createdBy s
return module.pkgDashboardModule.Create(ctx, orgID, createdBy, creator, data)
}
func (module *module) CreateV2(ctx context.Context, orgID valuer.UUID, createdBy string, creator valuer.UUID, postable dashboardtypes.PostableDashboardV2) (*dashboardtypes.DashboardV2, error) {
return module.pkgDashboardModule.CreateV2(ctx, orgID, createdBy, creator, postable)
}
func (module *module) GetV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.DashboardV2, error) {
return module.pkgDashboardModule.GetV2(ctx, orgID, id)
}
func (module *module) UpdateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, updateable dashboardtypes.UpdateableDashboardV2) (*dashboardtypes.DashboardV2, error) {
return module.pkgDashboardModule.UpdateV2(ctx, orgID, id, updatedBy, updateable)
}
func (module *module) LockUnlockV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, isAdmin bool, lock bool) error {
return module.pkgDashboardModule.LockUnlockV2(ctx, orgID, id, updatedBy, isAdmin, lock)
}
func (module *module) CreatePublicV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, postable dashboardtypes.PostablePublicDashboard) (*dashboardtypes.DashboardV2, error) {
if _, err := module.licensing.GetActive(ctx, orgID); err != nil {
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
existing, err := module.pkgDashboardModule.GetV2(ctx, orgID, id)
if err != nil {
return nil, err
}
if existing.PublicConfig != nil {
return nil, errors.Newf(errors.TypeAlreadyExists, dashboardtypes.ErrCodePublicDashboardAlreadyExists, "dashboard with id %s is already public", id)
}
publicDashboard := dashboardtypes.NewPublicDashboard(postable.TimeRangeEnabled, postable.DefaultTimeRange, id)
if err := module.store.CreatePublic(ctx, dashboardtypes.NewStorablePublicDashboardFromPublicDashboard(publicDashboard)); err != nil {
return nil, err
}
existing.PublicConfig = publicDashboard
return existing, nil
}
func (module *module) DeleteV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, deletedBy string) error {
return module.pkgDashboardModule.DeleteV2(ctx, orgID, id, deletedBy)
}
func (module *module) UpdatePublicV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatable dashboardtypes.UpdatablePublicDashboard) (*dashboardtypes.DashboardV2, error) {
if _, err := module.licensing.GetActive(ctx, orgID); err != nil {
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
existing, err := module.pkgDashboardModule.GetV2(ctx, orgID, id)
if err != nil {
return nil, err
}
if existing.PublicConfig == nil {
return nil, errors.Newf(errors.TypeNotFound, dashboardtypes.ErrCodePublicDashboardNotFound, "dashboard with id %s isn't public", id)
}
existing.PublicConfig.Update(updatable.TimeRangeEnabled, updatable.DefaultTimeRange)
if err := module.store.UpdatePublic(ctx, dashboardtypes.NewStorablePublicDashboardFromPublicDashboard(existing.PublicConfig)); err != nil {
return nil, err
}
return existing, nil
}
func (module *module) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.Dashboard, error) {
return module.pkgDashboardModule.Get(ctx, orgID, id)
}

View File

@@ -232,7 +232,6 @@
"ts-jest": "29.4.6",
"ts-node": "^10.2.1",
"typescript-plugin-css-modules": "5.2.0",
"use-sync-external-store": "1.6.0",
"vite-plugin-checker": "0.12.0",
"vite-plugin-compression": "0.5.1",
"vite-plugin-image-optimizer": "2.0.3",

View File

@@ -22,8 +22,6 @@ import type {
AuthtypesUpdateableAuthDomainDTO,
CreateAuthDomain200,
DeleteAuthDomainPathParameters,
GetAuthDomain200,
GetAuthDomainPathParameters,
ListAuthDomains200,
RenderErrorResponseDTO,
UpdateAuthDomainPathParameters,
@@ -279,109 +277,6 @@ export const useDeleteAuthDomain = <
return useMutation(mutationOptions);
};
/**
* This endpoint returns an auth domain by ID
* @summary Get auth domain by ID
*/
export const getAuthDomain = (
{ id }: GetAuthDomainPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetAuthDomain200>({
url: `/api/v1/domains/${id}`,
method: 'GET',
signal,
});
};
export const getGetAuthDomainQueryKey = ({
id,
}: GetAuthDomainPathParameters) => {
return [`/api/v1/domains/${id}`] as const;
};
export const getGetAuthDomainQueryOptions = <
TData = Awaited<ReturnType<typeof getAuthDomain>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ id }: GetAuthDomainPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getAuthDomain>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getGetAuthDomainQueryKey({ id });
const queryFn: QueryFunction<Awaited<ReturnType<typeof getAuthDomain>>> = ({
signal,
}) => getAuthDomain({ id }, signal);
return {
queryKey,
queryFn,
enabled: !!id,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getAuthDomain>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetAuthDomainQueryResult = NonNullable<
Awaited<ReturnType<typeof getAuthDomain>>
>;
export type GetAuthDomainQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get auth domain by ID
*/
export function useGetAuthDomain<
TData = Awaited<ReturnType<typeof getAuthDomain>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ id }: GetAuthDomainPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getAuthDomain>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetAuthDomainQueryOptions({ id }, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
query.queryKey = queryOptions.queryKey;
return query;
}
/**
* @summary Get auth domain by ID
*/
export const invalidateGetAuthDomain = async (
queryClient: QueryClient,
{ id }: GetAuthDomainPathParameters,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetAuthDomainQueryKey({ id }) },
options,
);
return queryClient;
};
/**
* This endpoint updates an auth domain
* @summary Update auth domain

View File

@@ -18,7 +18,6 @@ import type {
} from 'react-query';
import type {
AlertmanagertypesPostableChannelDTO,
ConfigReceiverDTO,
CreateChannel201,
DeleteChannelByIDPathParameters,
@@ -123,14 +122,14 @@ export const invalidateListChannels = async (
* @summary Create notification channel
*/
export const createChannel = (
alertmanagertypesPostableChannelDTO: BodyType<AlertmanagertypesPostableChannelDTO>,
configReceiverDTO: BodyType<ConfigReceiverDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<CreateChannel201>({
url: `/api/v1/channels`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: alertmanagertypesPostableChannelDTO,
data: configReceiverDTO,
signal,
});
};
@@ -142,13 +141,13 @@ export const getCreateChannelMutationOptions = <
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createChannel>>,
TError,
{ data: BodyType<AlertmanagertypesPostableChannelDTO> },
{ data: BodyType<ConfigReceiverDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof createChannel>>,
TError,
{ data: BodyType<AlertmanagertypesPostableChannelDTO> },
{ data: BodyType<ConfigReceiverDTO> },
TContext
> => {
const mutationKey = ['createChannel'];
@@ -162,7 +161,7 @@ export const getCreateChannelMutationOptions = <
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof createChannel>>,
{ data: BodyType<AlertmanagertypesPostableChannelDTO> }
{ data: BodyType<ConfigReceiverDTO> }
> = (props) => {
const { data } = props ?? {};
@@ -175,8 +174,7 @@ export const getCreateChannelMutationOptions = <
export type CreateChannelMutationResult = NonNullable<
Awaited<ReturnType<typeof createChannel>>
>;
export type CreateChannelMutationBody =
BodyType<AlertmanagertypesPostableChannelDTO>;
export type CreateChannelMutationBody = BodyType<ConfigReceiverDTO>;
export type CreateChannelMutationError = ErrorType<RenderErrorResponseDTO>;
/**
@@ -189,13 +187,13 @@ export const useCreateChannel = <
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createChannel>>,
TError,
{ data: BodyType<AlertmanagertypesPostableChannelDTO> },
{ data: BodyType<ConfigReceiverDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof createChannel>>,
TError,
{ data: BodyType<AlertmanagertypesPostableChannelDTO> },
{ data: BodyType<ConfigReceiverDTO> },
TContext
> => {
const mutationOptions = getCreateChannelMutationOptions(options);

View File

@@ -18,8 +18,10 @@ import type {
} from 'react-query';
import type {
CreateDashboardV2201,
CreatePublicDashboard201,
CreatePublicDashboardPathParameters,
DashboardtypesPostableDashboardV2DTO,
DashboardtypesPostablePublicDashboardDTO,
DashboardtypesUpdatablePublicDashboardDTO,
DeletePublicDashboardPathParameters,
@@ -634,3 +636,88 @@ export const invalidateGetPublicDashboardWidgetQueryRange = async (
return queryClient;
};
/**
* This endpoint creates a v2-shape dashboard with structured metadata, a typed data tree, and resolved tags.
* @summary Create dashboard (v2)
*/
export const createDashboardV2 = (
dashboardtypesPostableDashboardV2DTO: BodyType<DashboardtypesPostableDashboardV2DTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<CreateDashboardV2201>({
url: `/api/v2/dashboards`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: dashboardtypesPostableDashboardV2DTO,
signal,
});
};
export const getCreateDashboardV2MutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createDashboardV2>>,
TError,
{ data: BodyType<DashboardtypesPostableDashboardV2DTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof createDashboardV2>>,
TError,
{ data: BodyType<DashboardtypesPostableDashboardV2DTO> },
TContext
> => {
const mutationKey = ['createDashboardV2'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof createDashboardV2>>,
{ data: BodyType<DashboardtypesPostableDashboardV2DTO> }
> = (props) => {
const { data } = props ?? {};
return createDashboardV2(data);
};
return { mutationFn, ...mutationOptions };
};
export type CreateDashboardV2MutationResult = NonNullable<
Awaited<ReturnType<typeof createDashboardV2>>
>;
export type CreateDashboardV2MutationBody =
BodyType<DashboardtypesPostableDashboardV2DTO>;
export type CreateDashboardV2MutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Create dashboard (v2)
*/
export const useCreateDashboardV2 = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createDashboardV2>>,
TError,
{ data: BodyType<DashboardtypesPostableDashboardV2DTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof createDashboardV2>>,
TError,
{ data: BodyType<DashboardtypesPostableDashboardV2DTO> },
TContext
> => {
const mutationOptions = getCreateDashboardV2MutationOptions(options);
return useMutation(mutationOptions);
};

File diff suppressed because it is too large Load Diff

View File

@@ -47,16 +47,10 @@ function TanStackCustomTableRow<TData>({
const isActive = context?.isRowActive?.(rowData) ?? false;
const extraClass = context?.getRowClassName?.(rowData) ?? '';
const rowStyle = context?.getRowStyle?.(rowData);
const enableAlternatingRowColors =
context?.enableAlternatingRowColors ?? false;
const rowClassName = cx(
tableStyles.tableRow,
isActive && tableStyles.tableRowActive,
enableAlternatingRowColors &&
(item.row.index % 2 === 0
? tableStyles.tableRowEven
: tableStyles.tableRowOdd),
extraClass,
);
@@ -111,12 +105,6 @@ function areTableRowPropsEqual<TData>(
if (prev.context?.columnVisibilityKey !== next.context?.columnVisibilityKey) {
return false;
}
if (
prev.context?.enableAlternatingRowColors !==
next.context?.enableAlternatingRowColors
) {
return false;
}
if (prev.context !== next.context) {
const prevActive = prev.context?.isRowActive?.(prevData) ?? false;

View File

@@ -2,10 +2,7 @@
position: sticky;
top: 0;
z-index: 2;
padding: var(--tanstack-cell-padding-top, 0.3rem)
var(--tanstack-cell-padding-right, 0.3rem)
var(--tanstack-cell-padding-bottom, 0.3rem)
var(--tanstack-cell-padding-left, 0.3rem);
padding: 0.3rem;
transform: translate3d(
var(--tanstack-header-translate-x, 0px),
var(--tanstack-header-translate-y, 0px),
@@ -22,17 +19,7 @@
}
border: none !important;
background-color: var(
--tanstack-table-header-cell-bg,
var(--l2-background)
) !important;
&:first-child {
background-color: var(
--tanstack-first-column-header-bg,
var(--tanstack-table-header-cell-bg, var(--l2-background))
) !important;
}
background-color: var(--l2-background) !important;
}
.tanstackHeaderContent {
@@ -74,7 +61,7 @@
width: 12px;
height: 12px;
cursor: grab;
color: var(--tanstack-table-header-cell-color, var(--l2-foreground));
color: var(--l2-foreground);
opacity: 1;
touch-action: none;
}
@@ -87,7 +74,7 @@
height: 20px;
cursor: pointer;
flex-shrink: 0;
color: var(--tanstack-table-header-cell-color, var(--l2-foreground));
color: var(--l2-foreground);
margin-left: auto;
}
@@ -95,9 +82,8 @@
.tanstackColumnActionsContent {
width: 140px;
padding: 0;
background: var(--tanstack-table-header-cell-bg, var(--l2-background));
border: 1px solid
var(--tanstack-table-header-cell-actions-border-color, var(--l2-border));
background: var(--l2-background);
border: 1px solid var(--l2-border);
border-radius: 4px;
box-shadow: none;
}
@@ -151,7 +137,7 @@
}
.tanstackHeaderCell.isResizing .cursorColResize {
background: var(--tanstack-table-resize-active-bg, var(--bg-robin-300));
background: var(--bg-robin-300);
}
.tanstackResizeHandleLine {
@@ -161,7 +147,7 @@
left: 50%;
width: 4px;
transform: translateX(-50%);
background: var(--tanstack-table-resize-handle-bg, var(--l2-background));
background: var(--l2-background);
opacity: 1;
pointer-events: none;
transition:
@@ -169,34 +155,13 @@
width 120ms ease;
}
.tanstackHeaderCell:first-child .tanstackResizeHandleLine {
background: var(
--tanstack-first-column-header-bg,
var(--tanstack-table-resize-handle-bg, var(--l2-background))
);
}
.cursorColResize:hover .tanstackResizeHandleLine {
background: var(--tanstack-table-resize-handle-hover-bg, var(--l2-border));
}
.tanstackHeaderCell:first-child
.cursorColResize:hover
.tanstackResizeHandleLine {
background: color-mix(
in srgb,
var(
--tanstack-first-column-header-bg,
var(--tanstack-table-resize-handle-bg, var(--l2-background))
)
60%,
black
);
background: var(--l2-border);
}
.tanstackHeaderCell.isResizing .tanstackResizeHandleLine {
width: 2px;
background: var(--tanstack-table-resize-handle-active-bg, var(--bg-robin-500));
background: var(--bg-robin-500);
transition: none;
}
@@ -248,12 +213,7 @@
flex-shrink: 0;
width: 14px;
height: 14px;
color: var(--l3-foreground);
&[data-sort-direction='asc'],
&[data-sort-direction='desc'] {
color: var(--primary);
}
color: var(--l2-foreground);
}
.isSortable {

View File

@@ -9,7 +9,7 @@ import { useSortable } from '@dnd-kit/sortable';
import { Popover, PopoverContent, PopoverTrigger } from '@signozhq/ui';
import { flexRender, Header as TanStackHeader } from '@tanstack/react-table';
import cx from 'classnames';
import { ArrowDown, ArrowUp, ArrowUpDown, GripVertical } from 'lucide-react';
import { ChevronDown, ChevronUp, GripVertical } from 'lucide-react';
import { SortState, TableColumnDef } from './types';
@@ -177,17 +177,12 @@ function TanStackHeaderRow<TData>({
? column.header()
: String(column.header || '').replace(/^\w/, (c) => c.toUpperCase())}
</span>
<span
className={headerStyles.tanstackSortIndicator}
data-sort-direction={currentSortDirection || 'none'}
>
<span className={headerStyles.tanstackSortIndicator}>
{currentSortDirection === 'asc' ? (
<ArrowUp size={SORT_ICON_SIZE} />
<ChevronUp size={SORT_ICON_SIZE} />
) : currentSortDirection === 'desc' ? (
<ArrowDown size={SORT_ICON_SIZE} />
) : (
<ArrowUpDown size={SORT_ICON_SIZE} />
)}
<ChevronDown size={SORT_ICON_SIZE} />
) : null}
</span>
</button>
) : (

View File

@@ -1,22 +1,4 @@
.tanStackTable {
--tanstack-cell-padding: var(--tanstack-cell-padding-override, 0.3rem);
--tanstack-cell-padding-left: var(
--tanstack-cell-padding-left-override,
var(--tanstack-cell-padding)
);
--tanstack-cell-padding-right: var(
--tanstack-cell-padding-right-override,
var(--tanstack-cell-padding)
);
--tanstack-cell-padding-top: var(
--tanstack-cell-padding-top-override,
var(--tanstack-cell-padding)
);
--tanstack-cell-padding-bottom: var(
--tanstack-cell-padding-bottom-override,
var(--tanstack-cell-padding)
);
width: 100%;
border-collapse: separate;
border-spacing: 0;
@@ -44,7 +26,7 @@
line-clamp: var(--tanstack-plain-body-line-clamp, 1);
font-size: var(--tanstack-plain-cell-font-size, 14px);
line-height: var(--tanstack-plain-cell-line-height, 18px);
color: var(--tanstack-table-cell-color, var(--l2-foreground));
color: var(--l2-foreground);
max-width: 100%;
word-break: break-all;
}
@@ -60,35 +42,13 @@
}
.tableCell {
padding: var(--tanstack-cell-padding-top) var(--tanstack-cell-padding-right)
var(--tanstack-cell-padding-bottom) var(--tanstack-cell-padding-left);
height: var(--tanstack-table-row-height, auto);
padding: 0.3rem;
font-style: normal;
font-weight: 400;
letter-spacing: -0.07px;
font-size: var(--tanstack-plain-cell-font-size, 14px);
line-height: var(--tanstack-plain-cell-line-height, 18px);
color: var(--tanstack-table-cell-color, var(--l2-foreground));
background-color: var(--tanstack-table-cell-bg, transparent);
&:first-child {
padding: var(
--tanstack-cell-padding-top-first-column,
var(--tanstack-cell-padding-top)
)
var(
--tanstack-cell-padding-right-first-column,
var(--tanstack-cell-padding-right)
)
var(
--tanstack-cell-padding-bottom-first-column,
var(--tanstack-cell-padding-bottom)
)
var(
--tanstack-cell-padding-left-first-column,
var(--tanstack-cell-padding-left)
);
}
color: var(--l2-foreground);
}
.tableRow {
@@ -98,69 +58,19 @@
&:hover {
.tableCell {
background-color: var(
--tanstack-table-row-hover-bg,
var(--row-hover-bg)
) !important;
background-color: var(--row-hover-bg) !important;
}
}
&.tableRowActive {
.tableCell {
background-color: var(
--tanstack-table-row-active-bg,
var(--row-active-bg)
) !important;
background-color: var(--row-active-bg) !important;
}
}
&.tableRowOdd {
.tableCell {
background-color: var(--tanstack-table-row-odd-bg, transparent);
}
}
&.tableRowEven {
.tableCell {
background-color: var(--tanstack-table-row-even-bg, transparent);
}
}
.tableCell:first-child {
background-color: var(
--tanstack-first-column-bg,
var(--tanstack-table-cell-bg, transparent)
);
color: var(
--tanstack-first-column-color,
var(--tanstack-table-cell-color, var(--l2-foreground))
);
}
&.tableRowOdd .tableCell:first-child {
background-color: var(
--tanstack-first-column-odd-bg,
var(
--tanstack-first-column-bg,
var(--tanstack-table-row-odd-bg, transparent)
)
);
}
&.tableRowEven .tableCell:first-child {
background-color: var(
--tanstack-first-column-even-bg,
var(
--tanstack-first-column-bg,
var(--tanstack-table-row-even-bg, transparent)
)
);
}
}
.tableHeaderCell {
padding: var(--tanstack-cell-padding-top) var(--tanstack-cell-padding-right)
var(--tanstack-cell-padding-bottom) var(--tanstack-cell-padding-left);
padding: 0.3rem;
height: 36px;
text-align: left;
font-size: 14px;
@@ -168,40 +78,20 @@
font-weight: 400;
line-height: 18px;
letter-spacing: -0.07px;
color: var(--tanstack-table-header-cell-color, var(--l1-foreground));
background: var(--tanstack-table-header-cell-bg, var(--l1-background));
border-bottom: 1px solid var(--tanstack-table-header-border-color, transparent);
color: var(--l1-foreground);
// TODO: Remove this once background color (l1) is matching the actual background color of the page
&[data-dark-mode='true'] {
background: #0b0c0d;
}
&[data-dark-mode='false'] {
background: #fdfdfd;
}
}
.tableRowExpansion {
display: table-row;
.tableCellExpansion {
background-color: var(--tanstack-table-header-cell-bg, var(--l1-background));
color: var(--tanstack-table-header-cell-color, var(--l1-foreground));
}
& > td {
padding: 0;
}
& thead th:first-child {
padding-left: var(
--tanstack-expansion-first-col-padding-left,
calc(var(--spacing-3) - 1px)
);
}
& tbody td:first-child {
padding-left: var(
--tanstack-expansion-first-col-padding-left,
calc(var(--spacing-20) + var(--spacing-4))
);
}
:global(thead) {
position: unset !important;
}
}
.tableCellExpansion {

View File

@@ -90,7 +90,6 @@ function TanStackTableInner<TData>(
skeletonRowCount = 10,
enableQueryParams,
pagination,
paginationClassname,
onEndReached,
getRowKey,
getItemKey,
@@ -113,17 +112,9 @@ function TanStackTableInner<TData>(
testId,
prefixPaginationContent,
suffixPaginationContent,
enableAlternatingRowColors,
disableVirtualScroll,
}: TanStackTableProps<TData>,
forwardedRef: React.ForwardedRef<TanStackTableHandle>,
): JSX.Element {
if (disableVirtualScroll && onEndReached) {
throw new Error(
'TanStackTable: Cannot use onEndReached with disableVirtualScroll. Infinite scroll requires virtualization.',
);
}
const virtuosoRef = useRef<TableVirtuosoHandle | null>(null);
const isDarkMode = useIsDarkMode();
@@ -230,15 +221,6 @@ function TanStackTableInner<TData>(
[getRowCanExpand],
);
const isExpandEnabled = Boolean(renderExpandedRow);
useEffect(() => {
const hasExpanded =
typeof expanded === 'boolean' ? expanded : Object.keys(expanded).length > 0;
if (!isExpandEnabled && hasExpanded) {
setExpanded({});
}
}, [isExpandEnabled, expanded, setExpanded]);
const table = useReactTable({
data: effectiveData,
columns: tanstackColumns,
@@ -247,7 +229,7 @@ function TanStackTableInner<TData>(
columnResizeMode: 'onChange',
getCoreRowModel: getCoreRowModel(),
getRowId,
enableExpanding: isExpandEnabled,
enableExpanding: Boolean(renderExpandedRow),
getRowCanExpand: renderExpandedRow ? tableGetRowCanExpand : undefined,
onColumnSizingChange: handleColumnSizingChange,
onColumnVisibilityChange: noopColumnVisibility,
@@ -351,7 +333,6 @@ function TanStackTableInner<TData>(
hasSingleColumn,
columnOrderKey,
columnVisibilityKey,
enableAlternatingRowColors,
}),
[
getRowStyle,
@@ -369,7 +350,6 @@ function TanStackTableInner<TData>(
hasSingleColumn,
columnOrderKey,
columnVisibilityKey,
enableAlternatingRowColors,
],
);
@@ -520,10 +500,7 @@ function TanStackTableInner<TData>(
);
return (
<div
className={cx(viewStyles.tanstackTableViewWrapper, className)}
data-has-group-by={(groupBy?.length || 0) > 0}
>
<div className={cx(viewStyles.tanstackTableViewWrapper, className)}>
<TanStackTableStateProvider>
<TableLoadingSync
isLoading={isLoading}
@@ -531,53 +508,23 @@ function TanStackTableInner<TData>(
/>
<ColumnVisibilitySync visibility={effectiveVisibility} />
<TooltipProvider>
{disableVirtualScroll ? (
<div
className={virtuosoClassName}
{...restTableScrollerProps}
data-testid={testId}
>
<table className={tableStyles.tanStackTable} style={virtuosoTableStyle}>
<VirtuosoTableColGroup columns={effectiveColumns} table={table} />
<thead>{tableHeader()}</thead>
<tbody>
{(isLoading && data.length === 0
? flatItems.slice(0, skeletonRowCount)
: flatItems
).map((item, index) => (
<TanStackCustomTableRow
key={
item.kind === 'expansion' ? `${item.row.id}-expansion` : item.row.id
}
item={item}
context={virtuosoContext}
data-index={index}
data-item-index={index}
data-known-size={0}
/>
))}
</tbody>
</table>
</div>
) : (
<TableVirtuoso<FlatItem<TData>, TableRowContext<TData>>
className={virtuosoClassName}
ref={virtuosoRef}
{...restTableScrollerProps}
data={flatItems}
totalCount={flatItems.length}
context={virtuosoContext}
increaseViewportBy={INCREASE_VIEWPORT_BY}
initialTopMostItemIndex={
flatIndexForActiveRow >= 0 ? flatIndexForActiveRow : 0
}
fixedHeaderContent={tableHeader}
style={virtuosoTableStyle}
components={virtuosoComponents}
endReached={onEndReached ? handleEndReached : undefined}
data-testid={testId}
/>
)}
<TableVirtuoso<FlatItem<TData>, TableRowContext<TData>>
className={virtuosoClassName}
ref={virtuosoRef}
{...restTableScrollerProps}
data={flatItems}
totalCount={flatItems.length}
context={virtuosoContext}
increaseViewportBy={INCREASE_VIEWPORT_BY}
initialTopMostItemIndex={
flatIndexForActiveRow >= 0 ? flatIndexForActiveRow : 0
}
fixedHeaderContent={tableHeader}
style={virtuosoTableStyle}
components={virtuosoComponents}
endReached={onEndReached ? handleEndReached : undefined}
data-testid={testId}
/>
{showInfiniteScrollLoader && (
<div
className={viewStyles.tanstackLoadingOverlay}
@@ -587,7 +534,7 @@ function TanStackTableInner<TData>(
</div>
)}
{showPagination && pagination && (
<div className={cx(viewStyles.paginationContainer, paginationClassname)}>
<div className={viewStyles.paginationContainer}>
{prefixPaginationContent}
<Pagination
current={page}

View File

@@ -49,8 +49,7 @@
width: 100%;
overflow-x: auto;
scrollbar-width: thin;
scrollbar-color: var(--tanstack-table-scrollbar-color, var(--bg-slate-300))
transparent;
scrollbar-color: var(--bg-slate-300) transparent;
&::-webkit-scrollbar {
width: 4px;
@@ -66,12 +65,12 @@
}
&::-webkit-scrollbar-thumb {
background: var(--tanstack-table-scrollbar-color, var(--bg-slate-300));
background: var(--bg-slate-300);
border-radius: 9999px;
}
&::-webkit-scrollbar-thumb:hover {
background: var(--tanstack-table-scrollbar-hover-color, var(--bg-slate-200));
background: var(--bg-slate-200);
}
&.cellTypographySmall {
@@ -136,25 +135,18 @@
z-index: 3;
border-radius: 8px;
padding: 8px 16px;
background: var(--tanstack-table-loading-overlay-bg, var(--l1-background));
box-shadow: var(
--tanstack-table-loading-overlay-shadow,
0 2px 8px rgba(0, 0, 0, 0.15)
);
background: var(--l1-background);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
:global(.lightMode) .tanstackTableVirtuosoScroll {
scrollbar-color: var(--tanstack-table-scrollbar-color, var(--bg-vanilla-300))
transparent;
scrollbar-color: var(--bg-vanilla-300) transparent;
&::-webkit-scrollbar-thumb {
background: var(--tanstack-table-scrollbar-color, var(--bg-vanilla-300));
background: var(--bg-vanilla-300);
}
&::-webkit-scrollbar-thumb:hover {
background: var(
--tanstack-table-scrollbar-hover-color,
var(--bg-vanilla-100)
);
background: var(--bg-vanilla-100);
}
}

View File

@@ -389,101 +389,6 @@ describe('TanStackTableView Integration', () => {
// by checking the table renders without errors
expect(screen.getByRole('table')).toBeInTheDocument();
});
it('renders without errors when expanded state exists but expansion is disabled', async () => {
// This tests that the table handles the case where URL has expanded state
// but renderExpandedRow is undefined (expansion disabled).
// The table's useEffect should reset expanded state automatically.
renderTanStackTable({
props: {
enableQueryParams: true,
// renderExpandedRow is undefined - expansion disabled
},
queryParams: { expanded: '["1"]' },
});
await waitFor(() => {
expect(screen.getByText('Item 1')).toBeInTheDocument();
});
// Table should render without any expanded rows
expect(screen.queryByTestId('expanded-content')).not.toBeInTheDocument();
});
it('renders expanded rows with unique keys in non-virtualized mode', async () => {
// This tests that row and expansion items have unique keys to avoid
// React's "duplicate key" warning when disableVirtualScroll is true
renderTanStackTable({
props: {
disableVirtualScroll: true,
enableQueryParams: true,
renderExpandedRow: (row) => (
<div data-testid={`expanded-${row.id}`}>Expanded: {row.name}</div>
),
getRowCanExpand: () => true,
getRowKey: (row) => row.id,
},
queryParams: { expanded: '["1"]' },
});
await waitFor(() => {
expect(screen.getByText('Item 1')).toBeInTheDocument();
});
// Both the row and its expansion content should be rendered
expect(screen.getByTestId('expanded-1')).toBeInTheDocument();
expect(screen.getByText('Expanded: Item 1')).toBeInTheDocument();
// Verify all 3 data rows plus 1 expansion row = 4 tr elements in tbody
const tbody = screen.getByRole('table').querySelector('tbody');
expect(tbody?.querySelectorAll('tr')).toHaveLength(4);
});
});
describe('disableVirtualScroll', () => {
it('throws error when used with onEndReached', () => {
expect(() => {
renderTanStackTable({
props: {
disableVirtualScroll: true,
onEndReached: jest.fn(),
},
});
}).toThrow(
'TanStackTable: Cannot use onEndReached with disableVirtualScroll. Infinite scroll requires virtualization.',
);
});
it('renders all rows without virtualization', async () => {
renderTanStackTable({
props: {
disableVirtualScroll: true,
},
});
await waitFor(() => {
expect(screen.getByText('Item 1')).toBeInTheDocument();
expect(screen.getByText('Item 2')).toBeInTheDocument();
expect(screen.getByText('Item 3')).toBeInTheDocument();
});
// Verify table structure exists
expect(screen.getByRole('table')).toBeInTheDocument();
});
it('renders column headers without virtualization', async () => {
renderTanStackTable({
props: {
disableVirtualScroll: true,
},
});
await waitFor(() => {
expect(screen.getByText('ID')).toBeInTheDocument();
expect(screen.getByText('Name')).toBeInTheDocument();
expect(screen.getByText('Value')).toBeInTheDocument();
});
});
});
describe('infinite scroll', () => {

View File

@@ -138,10 +138,7 @@ describe('useTableParams (URL mode — enableQueryParams set)', () => {
it('reads initial page from URL params', () => {
const wrapper = createNuqsWrapper({ page: '3' });
// Pass matching default to prevent reset on mount (page resets when orderBy changes)
const { result } = renderHook(() => useTableParams(true, { page: 3 }), {
wrapper,
});
const { result } = renderHook(() => useTableParams(true), { wrapper });
expect(result.current.page).toBe(3);
});
@@ -252,294 +249,3 @@ describe('useTableParams (URL mode — enableQueryParams set)', () => {
expect(result.current.orderBy).toBeNull();
});
});
describe('useTableParams (selective URL mode — partial config object)', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it('syncs only page to URL when only page is configured', () => {
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
const wrapper = createNuqsWrapper({}, onUrlUpdate);
const { result } = renderHook(() => useTableParams({ page: 'myPage' }), {
wrapper,
});
// Update page - should sync to URL
act(() => {
result.current.setPage(5);
jest.runAllTimers();
});
expect(result.current.page).toBe(5);
const lastPage = onUrlUpdate.mock.calls
.map((call) => call[0].searchParams.get('myPage'))
.filter(Boolean)
.pop();
expect(lastPage).toBe('5');
// Update limit - should stay local (not in URL)
act(() => {
result.current.setLimit(100);
jest.runAllTimers();
});
expect(result.current.limit).toBe(100);
const limitInUrl = onUrlUpdate.mock.calls.some(
(call) => call[0].searchParams.get('limit') !== null,
);
expect(limitInUrl).toBe(false);
// Update orderBy - should stay local (not in URL)
act(() => {
result.current.setOrderBy({ columnName: 'test', order: 'asc' });
jest.runAllTimers();
});
expect(result.current.orderBy).toStrictEqual({
columnName: 'test',
order: 'asc',
});
const orderByInUrl = onUrlUpdate.mock.calls.some(
(call) => call[0].searchParams.get('order_by') !== null,
);
expect(orderByInUrl).toBe(false);
});
it('syncs only orderBy to URL when only orderBy is configured', () => {
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
const wrapper = createNuqsWrapper({}, onUrlUpdate);
const { result } = renderHook(() => useTableParams({ orderBy: 'mySort' }), {
wrapper,
});
// Update orderBy - should sync to URL
act(() => {
result.current.setOrderBy({ columnName: 'cpu', order: 'desc' });
jest.runAllTimers();
});
expect(result.current.orderBy).toStrictEqual({
columnName: 'cpu',
order: 'desc',
});
const lastOrderBy = onUrlUpdate.mock.calls
.map((call) => call[0].searchParams.get('mySort'))
.filter(Boolean)
.pop();
expect(lastOrderBy).toBeDefined();
expect(JSON.parse(lastOrderBy!)).toStrictEqual({
columnName: 'cpu',
order: 'desc',
});
// Update page - should stay local
act(() => {
result.current.setPage(3);
jest.runAllTimers();
});
expect(result.current.page).toBe(3);
const pageInUrl = onUrlUpdate.mock.calls.some(
(call) => call[0].searchParams.get('page') !== null,
);
expect(pageInUrl).toBe(false);
});
it('syncs only limit to URL when only limit is configured', () => {
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
const wrapper = createNuqsWrapper({}, onUrlUpdate);
const { result } = renderHook(() => useTableParams({ limit: 'myLimit' }), {
wrapper,
});
// Update limit - should sync to URL
act(() => {
result.current.setLimit(25);
jest.runAllTimers();
});
expect(result.current.limit).toBe(25);
const lastLimit = onUrlUpdate.mock.calls
.map((call) => call[0].searchParams.get('myLimit'))
.filter(Boolean)
.pop();
expect(lastLimit).toBe('25');
// Update page - should stay local
act(() => {
result.current.setPage(2);
jest.runAllTimers();
});
expect(result.current.page).toBe(2);
const pageInUrl = onUrlUpdate.mock.calls.some(
(call) => call[0].searchParams.get('page') !== null,
);
expect(pageInUrl).toBe(false);
});
it('syncs only expanded to URL when only expanded is configured', () => {
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
const wrapper = createNuqsWrapper({}, onUrlUpdate);
const { result } = renderHook(
() => useTableParams({ expanded: 'myExpanded' }),
{ wrapper },
);
// Update expanded - should sync to URL
act(() => {
result.current.setExpanded({ 'row-1': true, 'row-2': true });
jest.runAllTimers();
});
expect(result.current.expanded).toStrictEqual({
'row-1': true,
'row-2': true,
});
const lastExpanded = onUrlUpdate.mock.calls
.map((call) => call[0].searchParams.get('myExpanded'))
.filter(Boolean)
.pop();
expect(lastExpanded).toBeDefined();
expect(JSON.parse(lastExpanded!)).toEqual(
expect.arrayContaining(['row-1', 'row-2']),
);
// Update page - should stay local
act(() => {
result.current.setPage(4);
jest.runAllTimers();
});
expect(result.current.page).toBe(4);
const pageInUrl = onUrlUpdate.mock.calls.some(
(call) => call[0].searchParams.get('page') !== null,
);
expect(pageInUrl).toBe(false);
});
it('syncs page and orderBy to URL but keeps limit and expanded local', () => {
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
const wrapper = createNuqsWrapper({}, onUrlUpdate);
const { result } = renderHook(
() => useTableParams({ page: 'p', orderBy: 'sort' }),
{ wrapper },
);
// Update limit and expanded first (should stay local)
act(() => {
result.current.setLimit(75);
result.current.setExpanded({ 'row-5': true });
jest.runAllTimers();
});
expect(result.current.limit).toBe(75);
expect(result.current.expanded).toStrictEqual({ 'row-5': true });
// Update page (should sync to URL)
act(() => {
result.current.setPage(2);
jest.runAllTimers();
});
expect(result.current.page).toBe(2);
const lastPage = onUrlUpdate.mock.calls
.map((call) => call[0].searchParams.get('p'))
.filter(Boolean)
.pop();
expect(lastPage).toBe('2');
// Update orderBy (should sync to URL, and resets page to default)
act(() => {
result.current.setOrderBy({ columnName: 'name', order: 'asc' });
jest.runAllTimers();
});
expect(result.current.orderBy).toStrictEqual({
columnName: 'name',
order: 'asc',
});
const lastOrderBy = onUrlUpdate.mock.calls
.map((call) => call[0].searchParams.get('sort'))
.filter(Boolean)
.pop();
expect(lastOrderBy).toBeDefined();
// limit should NOT be in URL
const limitInUrl = onUrlUpdate.mock.calls.some(
(call) =>
call[0].searchParams.get('limit') !== null ||
call[0].searchParams.get('myLimit') !== null,
);
expect(limitInUrl).toBe(false);
// expanded should NOT be in URL
const expandedInUrl = onUrlUpdate.mock.calls.some(
(call) =>
call[0].searchParams.get('expanded') !== null ||
call[0].searchParams.get('myExpanded') !== null,
);
expect(expandedInUrl).toBe(false);
});
it('reads initial values from URL for configured params only', () => {
const wrapper = createNuqsWrapper({
customPage: '7',
limit: '999', // This should be ignored since limit is not configured
});
const { result } = renderHook(
// Pass page default matching URL to prevent reset on mount
() => useTableParams({ page: 'customPage' }, { page: 7 }),
{ wrapper },
);
// Page should come from URL
expect(result.current.page).toBe(7);
// Limit should be default (not from URL since it's not configured)
expect(result.current.limit).toBe(50);
});
it('supports updater function for expanded state', () => {
const wrapper = createNuqsWrapper();
const { result } = renderHook(() => useTableParams({ expanded: 'exp' }), {
wrapper,
});
// Set initial expanded state
act(() => {
result.current.setExpanded({ 'row-1': true });
});
expect(result.current.expanded).toStrictEqual({ 'row-1': true });
// Use updater function to add another row
act(() => {
result.current.setExpanded((prev) => ({
...(typeof prev === 'boolean' ? {} : prev),
'row-2': true,
}));
});
expect(result.current.expanded).toStrictEqual({
'row-1': true,
'row-2': true,
});
});
it('supports updater function for local expanded state', () => {
const wrapper = createNuqsWrapper();
const { result } = renderHook(() => useTableParams(), { wrapper });
// Set initial expanded state
act(() => {
result.current.setExpanded({ 'row-a': true });
});
expect(result.current.expanded).toStrictEqual({ 'row-a': true });
// Use updater function
act(() => {
result.current.setExpanded((prev) => ({
...(typeof prev === 'boolean' ? {} : prev),
'row-b': true,
}));
});
expect(result.current.expanded).toStrictEqual({
'row-a': true,
'row-b': true,
});
});
});

View File

@@ -171,27 +171,6 @@ export * from './useTableParams';
* tableScrollerProps={{ className: 'my-table-scroll', 'data-testid': 'logs-scroller' }}
* />
* ```
*
* @example Disable virtual scroll — useful for nested tables inside expanded rows.
* Virtual scroll requires a fixed height container, which is problematic for nested tables
* that need dynamic height. Use `disableVirtualScroll` when rendering tables inside
* `renderExpandedRow` to allow the nested table to grow based on content.
* Note: Cannot be combined with `onEndReached` (infinite scroll requires virtualization).
* ```tsx
* // Parent table with expandable rows
* <TanStackTable
* data={parentData}
* columns={parentColumns}
* renderExpandedRow={(row) => (
* // Nested table without virtualization — height adapts to content
* <TanStackTable
* data={row.children}
* columns={childColumns}
* disableVirtualScroll
* />
* )}
* />
* ```
*/
const TanStackTable = Object.assign(TanStackTableBase, {
Text: TanStackTableText,

View File

@@ -107,8 +107,6 @@ export type TableRowContext<TData> = {
columnOrderKey: string;
/** Column visibility key for memo invalidation on visibility change */
columnVisibilityKey: string;
/** Enable alternating row background colors (zebra striping) */
enableAlternatingRowColors?: boolean;
};
export type PaginationProps = {
@@ -118,10 +116,10 @@ export type PaginationProps = {
};
export type TanstackTableQueryParamsConfig = {
page?: string;
limit?: string;
orderBy?: string;
expanded?: string;
page: string;
limit: string;
orderBy: string;
expanded: string;
};
export type TanStackTableProps<TData> = {
@@ -139,7 +137,6 @@ export type TanStackTableProps<TData> = {
skeletonRowCount?: number;
enableQueryParams?: boolean | string | TanstackTableQueryParamsConfig;
pagination?: PaginationProps;
paginationClassname?: string;
onEndReached?: (index: number) => void;
/** Function to get the unique key for a row (before duplicate handling).
* When set, enables automatic duplicate key detection and group-aware key composition. */
@@ -179,10 +176,6 @@ export type TanStackTableProps<TData> = {
prefixPaginationContent?: ReactNode;
/** Content rendered after the pagination controls */
suffixPaginationContent?: ReactNode;
/** Enable alternating row background colors (zebra striping) */
enableAlternatingRowColors?: boolean;
/** Disable virtual scrolling and render all rows at once. Cannot be used with onEndReached. */
disableVirtualScroll?: boolean;
};
export type TanStackTableHandle = TableVirtuosoHandle & {

View File

@@ -8,12 +8,6 @@ import { SortState, TanstackTableQueryParamsConfig } from './types';
const NUQS_OPTIONS = { history: 'push' as const };
const DEFAULT_PAGE = 1;
const DEFAULT_LIMIT = 50;
const URL_KEYS_DEFAULT = {
page: 'page',
limit: 'limit',
orderBy: 'order_by',
expanded: 'expanded',
} as const;
type Defaults = {
page?: number;
@@ -55,49 +49,30 @@ export function useTableParams(
enableQueryParams?: boolean | string | TanstackTableQueryParamsConfig,
defaults?: Defaults,
): TableParamsResult {
// Determine which params should sync to URL vs use local state
const isObjectConfig = typeof enableQueryParams === 'object';
const useUrlForPage =
enableQueryParams === true ||
typeof enableQueryParams === 'string' ||
(isObjectConfig && enableQueryParams.page !== undefined);
const useUrlForLimit =
enableQueryParams === true ||
typeof enableQueryParams === 'string' ||
(isObjectConfig && enableQueryParams.limit !== undefined);
const useUrlForOrderBy =
enableQueryParams === true ||
typeof enableQueryParams === 'string' ||
(isObjectConfig && enableQueryParams.orderBy !== undefined);
const useUrlForExpanded =
enableQueryParams === true ||
typeof enableQueryParams === 'string' ||
(isObjectConfig && enableQueryParams.expanded !== undefined);
const pageQueryParam =
typeof enableQueryParams === 'string'
? `${enableQueryParams}_${URL_KEYS_DEFAULT.page}`
: isObjectConfig
? (enableQueryParams.page ?? URL_KEYS_DEFAULT.page)
: URL_KEYS_DEFAULT.page;
? `${enableQueryParams}_page`
: typeof enableQueryParams === 'object'
? enableQueryParams.page
: 'page';
const limitQueryParam =
typeof enableQueryParams === 'string'
? `${enableQueryParams}_${URL_KEYS_DEFAULT.limit}`
: isObjectConfig
? (enableQueryParams.limit ?? URL_KEYS_DEFAULT.limit)
: URL_KEYS_DEFAULT.limit;
? `${enableQueryParams}_limit`
: typeof enableQueryParams === 'object'
? enableQueryParams.limit
: 'limit';
const orderByQueryParam =
typeof enableQueryParams === 'string'
? `${enableQueryParams}_${URL_KEYS_DEFAULT.orderBy}`
: isObjectConfig
? (enableQueryParams.orderBy ?? URL_KEYS_DEFAULT.orderBy)
: URL_KEYS_DEFAULT.orderBy;
? `${enableQueryParams}_order_by`
: typeof enableQueryParams === 'object'
? enableQueryParams.orderBy
: 'order_by';
const expandedQueryParam =
typeof enableQueryParams === 'string'
? `${enableQueryParams}_${URL_KEYS_DEFAULT.expanded}`
: isObjectConfig
? (enableQueryParams.expanded ?? URL_KEYS_DEFAULT.expanded)
: URL_KEYS_DEFAULT.expanded;
? `${enableQueryParams}_expanded`
: typeof enableQueryParams === 'object'
? enableQueryParams.expanded
: 'expanded';
const pageDefault = defaults?.page ?? DEFAULT_PAGE;
const limitDefault = defaults?.limit ?? DEFAULT_LIMIT;
const orderByDefault = defaults?.orderBy ?? null;
@@ -174,29 +149,45 @@ export function useTableParams(
const orderByDefaultMemoKey = `${orderByDefault?.columnName}${orderByDefault?.order}`;
const orderByUrlMemoKey = `${urlOrderBy?.columnName}${urlOrderBy?.order}`;
const isEnabledQueryParams =
typeof enableQueryParams === 'string' ||
typeof enableQueryParams === 'object';
useEffect(() => {
if (useUrlForPage) {
if (isEnabledQueryParams) {
setUrlPage(pageDefault);
} else {
setLocalPage(pageDefault);
}
}, [
useUrlForPage,
isEnabledQueryParams,
orderByDefaultMemoKey,
orderByUrlMemoKey,
pageDefault,
setUrlPage,
]);
if (enableQueryParams) {
return {
page: urlPage,
limit: urlLimit,
orderBy: urlOrderBy as SortState | null,
expanded: urlExpanded,
setPage: setUrlPage,
setLimit: setUrlLimit,
setOrderBy: setUrlOrderBy,
setExpanded: setUrlExpanded,
};
}
return {
page: useUrlForPage ? urlPage : localPage,
limit: useUrlForLimit ? urlLimit : localLimit,
orderBy: (useUrlForOrderBy ? urlOrderBy : localOrderBy) as SortState | null,
expanded: useUrlForExpanded ? urlExpanded : localExpanded,
setPage: useUrlForPage ? setUrlPage : setLocalPage,
setLimit: useUrlForLimit ? setUrlLimit : setLocalLimit,
setOrderBy: useUrlForOrderBy ? setUrlOrderBy : setLocalOrderBy,
setExpanded: useUrlForExpanded ? setUrlExpanded : handleSetLocalExpanded,
page: localPage,
limit: localLimit,
orderBy: localOrderBy,
expanded: localExpanded,
setPage: setLocalPage,
setLimit: setLocalLimit,
setOrderBy: setLocalOrderBy,
setExpanded: handleSetLocalExpanded,
};
}

View File

@@ -35,10 +35,7 @@ export const getColumnWidthStyle = <TData>(
): CSSProperties => {
// Last column always fills remaining space
if (isLastColumn) {
return {
width: '100%',
minWidth: persistedWidth ?? column?.width?.min,
};
return { width: '100%' };
}
const { width } = column;
@@ -62,19 +59,10 @@ export const getColumnWidthStyle = <TData>(
};
};
const isSkeletonRow = (row: unknown): boolean => {
const r = row as Record<string, unknown>;
return typeof r?.id === 'string' && r.id.startsWith('skeleton-');
};
const buildAccessorFn = <TData>(
colDef: TableColumnDef<TData>,
): ((row: TData) => unknown) => {
return (row: TData): unknown => {
// Skip accessor for skeleton rows to avoid errors with missing properties
if (isSkeletonRow(row)) {
return undefined;
}
if (colDef.accessorFn) {
return colDef.accessorFn(row);
}

View File

@@ -11,8 +11,8 @@ import { K8sBaseList } from 'container/InfraMonitoringK8s/Base/K8sBaseList';
import { K8sBaseFilters } from 'container/InfraMonitoringK8s/Base/types';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8s/constants';
import {
useInfraMonitoringFiltersK8s,
useInfraMonitoringPageListing,
useInfraMonitoringCurrentPage,
useInfraMonitoringFilters,
} from 'container/InfraMonitoringK8s/hooks';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
@@ -32,9 +32,9 @@ import {
hostWidgetInfo,
} from './constants';
import {
getHostItemKey,
getHostRowKey,
hostColumns,
hostColumnsConfig,
hostRenderRowData,
} from './table.config';
import { getHostsQuickFiltersConfig } from './utils';
@@ -42,8 +42,8 @@ import styles from './InfraMonitoringHosts.module.scss';
function Hosts(): JSX.Element {
const [showFilters, setShowFilters] = useState(true);
const [, setCurrentPage] = useInfraMonitoringPageListing();
const [urlFilters, setUrlFilters] = useInfraMonitoringFiltersK8s();
const [, setCurrentPage] = useInfraMonitoringCurrentPage();
const [urlFilters, setUrlFilters] = useInfraMonitoringFilters();
const { featureFlags } = useAppContext();
const dotMetricsEnabled =
@@ -170,10 +170,10 @@ function Hosts(): JSX.Element {
<K8sBaseList
controlListPrefix={controlListPrefix}
entity={InfraMonitoringEntity.HOSTS}
tableColumnsDefinitions={hostColumns}
tableColumns={hostColumnsConfig}
fetchListData={fetchListData}
getRowKey={getHostRowKey}
getItemKey={getHostItemKey}
renderRowData={hostRenderRowData}
eventCategory={InfraMonitoringEvents.HostEntity}
/>
</div>

View File

@@ -0,0 +1,190 @@
import { QueryClient, QueryClientProvider } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
import { render, waitFor } from '@testing-library/react';
import * as getHostListsApi from 'api/infraMonitoring/getHostLists';
import { initialQueriesMap } from 'constants/queryBuilder';
import * as useQueryBuilderHooks from 'hooks/queryBuilder/useQueryBuilder';
import * as useQueryBuilderOperations from 'hooks/queryBuilder/useQueryBuilderOperations';
import { withNuqsTestingAdapter } from 'nuqs/adapters/testing';
import * as appContextHooks from 'providers/App/App';
import * as timezoneHooks from 'providers/Timezone';
import store from 'store';
import { LicenseEvent } from 'types/api/licensesV3/getActive';
import Hosts from '../Hosts';
jest.mock('lib/getMinMax', () => ({
__esModule: true,
default: jest.fn().mockImplementation(() => ({
minTime: 1713734400000,
maxTime: 1713738000000,
isValidShortHandDateTimeFormat: jest.fn().mockReturnValue(true),
})),
getMinMaxForSelectedTime: jest.fn().mockReturnValue({
minTime: 1713734400000000000,
maxTime: 1713738000000000000,
}),
}));
jest.mock('container/TopNav/DateTimeSelectionV2', () => ({
__esModule: true,
default: (): JSX.Element => (
<div data-testid="date-time-selection">Date Time</div>
),
}));
jest.mock('components/CustomTimePicker/CustomTimePicker', () => ({
__esModule: true,
default: ({ onSelect, selectedTime, selectedValue }: any): JSX.Element => (
<div data-testid="custom-time-picker">
<button onClick={(): void => onSelect('custom')}>
{selectedTime} - {selectedValue}
</button>
</div>
),
}));
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
jest.mock('react-router-dom', () => {
const ROUTES = jest.requireActual('constants/routes').default;
return {
...jest.requireActual('react-router-dom'),
useLocation: jest.fn().mockReturnValue({
pathname: ROUTES.INFRASTRUCTURE_MONITORING_HOSTS,
}),
};
});
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): any => ({
safeNavigate: jest.fn(),
}),
}));
jest.spyOn(timezoneHooks, 'useTimezone').mockReturnValue({
timezone: {
offset: 0,
},
browserTimezone: {
offset: 0,
},
} as any);
jest.spyOn(getHostListsApi, 'getHostLists').mockResolvedValue({
statusCode: 200,
error: null,
message: 'Success',
payload: {
status: 'success',
data: {
type: 'list',
records: [
{
hostName: 'test-host',
active: true,
os: 'linux',
cpu: 0.75,
cpuTimeSeries: { labels: {}, labelsArray: [], values: [] },
memory: 0.65,
memoryTimeSeries: { labels: {}, labelsArray: [], values: [] },
wait: 0.03,
waitTimeSeries: { labels: {}, labelsArray: [], values: [] },
load15: 0.5,
load15TimeSeries: { labels: {}, labelsArray: [], values: [] },
},
],
groups: null,
total: 1,
sentAnyHostMetricsData: true,
isSendingK8SAgentMetrics: false,
endTimeBeforeRetention: false,
},
},
params: {} as any,
});
jest.spyOn(appContextHooks, 'useAppContext').mockReturnValue({
user: {
role: 'admin',
},
featureFlags: [],
activeLicenseV3: {
event_queue: {
created_at: '0',
event: LicenseEvent.NO_EVENT,
scheduled_at: '0',
status: '',
updated_at: '0',
},
license: {
license_key: 'test-license-key',
license_type: 'trial',
org_id: 'test-org-id',
plan_id: 'test-plan-id',
plan_name: 'test-plan-name',
plan_type: 'trial',
plan_version: 'test-plan-version',
},
},
} as any);
jest.spyOn(useQueryBuilderHooks, 'useQueryBuilder').mockReturnValue({
currentQuery: initialQueriesMap.metrics,
setSupersetQuery: jest.fn(),
setLastUsedQuery: jest.fn(),
handleSetConfig: jest.fn(),
resetQuery: jest.fn(),
updateAllQueriesOperators: jest.fn(),
} as any);
jest.spyOn(useQueryBuilderOperations, 'useQueryOperations').mockReturnValue({
handleChangeQueryData: jest.fn(),
} as any);
const Wrapper = withNuqsTestingAdapter({ searchParams: {} });
describe('Hosts', () => {
beforeEach(() => {
queryClient.clear();
});
it('renders hosts list table', async () => {
const { container } = render(
<Wrapper>
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<Provider store={store}>
<Hosts />
</Provider>
</MemoryRouter>
</QueryClientProvider>
</Wrapper>,
);
await waitFor(() => {
expect(container.querySelector('.ant-table')).toBeInTheDocument();
});
});
it('renders filters', async () => {
const { container } = render(
<Wrapper>
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<Provider store={store}>
<Hosts />
</Provider>
</MemoryRouter>
</QueryClientProvider>
</Wrapper>,
);
await waitFor(() => {
expect(container.querySelector('.filters')).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,143 @@
import { render, screen } from '@testing-library/react';
import { HostData, TimeSeries } from 'api/infraMonitoring/getHostLists';
import { hostRenderRowData } from '../table.config';
import { getHostsQuickFiltersConfig, HostnameCell } from '../utils';
const emptyTimeSeries: TimeSeries = {
labels: {},
labelsArray: [],
values: [],
};
describe('InfraMonitoringHosts utils', () => {
describe('hostRenderRowData', () => {
it('should format host data correctly', () => {
const host: HostData = {
hostName: 'test-host',
active: true,
cpu: 0.95,
memory: 0.85,
wait: 0.05,
load15: 2.5,
os: 'linux',
cpuTimeSeries: emptyTimeSeries,
memoryTimeSeries: emptyTimeSeries,
waitTimeSeries: emptyTimeSeries,
load15TimeSeries: emptyTimeSeries,
};
const result = hostRenderRowData(host, []);
expect(result.wait).toBe('5%');
expect(result.load15).toBe(2.5);
expect(result.itemKey).toBe('test-host');
expect(result.hostName).toBe('test-host');
const activeTag = render(result.active as JSX.Element);
expect(activeTag.container.textContent).toBe('ACTIVE');
expect(activeTag.getByText('ACTIVE')).toBeTruthy();
const cpuProgress = render(result.cpu as JSX.Element);
expect(cpuProgress.container.querySelector('.ant-progress')).toBeTruthy();
const memoryProgress = render(result.memory as JSX.Element);
expect(memoryProgress.container.querySelector('.ant-progress')).toBeTruthy();
});
it('should handle inactive hosts', () => {
const host: HostData = {
hostName: 'test-host',
active: false,
cpu: 0.3,
memory: 0.4,
wait: 0.02,
load15: 1.2,
os: 'linux',
cpuTimeSeries: emptyTimeSeries,
memoryTimeSeries: emptyTimeSeries,
waitTimeSeries: emptyTimeSeries,
load15TimeSeries: emptyTimeSeries,
};
const result = hostRenderRowData(host, []);
const inactiveTag = render(result.active as JSX.Element);
expect(inactiveTag.container.textContent).toBe('INACTIVE');
expect(inactiveTag.getByText('INACTIVE')).toBeTruthy();
});
it('should use empty itemKey when host has no hostname', () => {
const host: HostData = {
hostName: '',
active: true,
cpu: 0.5,
memory: 0.4,
wait: 0.01,
load15: 1.0,
os: 'linux',
cpuTimeSeries: emptyTimeSeries,
memoryTimeSeries: emptyTimeSeries,
waitTimeSeries: emptyTimeSeries,
load15TimeSeries: emptyTimeSeries,
};
const result = hostRenderRowData(host, []);
expect(result.itemKey).toBe('');
});
});
describe('HostnameCell', () => {
it('should render hostname when present (case A: no icon)', () => {
const { container } = render(<HostnameCell hostName="gke-prod-1" />);
expect(container.querySelector('.hostname-column-value')).toBeTruthy();
expect(container.textContent).toBe('gke-prod-1');
expect(container.querySelector('.hostname-cell-missing')).toBeFalsy();
expect(container.querySelector('.hostname-cell-warning-icon')).toBeFalsy();
});
it('should render placeholder and icon when hostName is empty (case B)', () => {
const { container } = render(<HostnameCell hostName="" />);
expect(screen.getByText('-')).toBeTruthy();
expect(container.querySelector('.hostname-cell-missing')).toBeTruthy();
const iconWrapper = container.querySelector('.hostname-cell-warning-icon');
expect(iconWrapper).toBeTruthy();
expect(iconWrapper?.getAttribute('aria-label')).toBe(
'Missing host.name metadata',
);
expect(iconWrapper?.getAttribute('tabindex')).toBe('0');
});
it('should render placeholder and icon when hostName is whitespace only (case C)', () => {
const { container } = render(<HostnameCell hostName=" " />);
expect(screen.getByText('-')).toBeTruthy();
expect(container.querySelector('.hostname-cell-missing')).toBeTruthy();
expect(container.querySelector('.hostname-cell-warning-icon')).toBeTruthy();
});
it('should render placeholder and icon when hostName is undefined (case D)', () => {
const { container } = render(<HostnameCell hostName={undefined} />);
expect(screen.getByText('-')).toBeTruthy();
expect(container.querySelector('.hostname-cell-missing')).toBeTruthy();
expect(container.querySelector('.hostname-cell-warning-icon')).toBeTruthy();
});
});
describe('getHostsQuickFiltersConfig', () => {
it('should return correct config when dotMetricsEnabled is true', () => {
const result = getHostsQuickFiltersConfig(true);
expect(result[0].attributeKey.key).toBe('host.name');
expect(result[1].attributeKey.key).toBe('os.type');
expect(result[0].aggregateAttribute).toBe('system.cpu.load_average.15m');
});
it('should return correct config when dotMetricsEnabled is false', () => {
const result = getHostsQuickFiltersConfig(false);
expect(result[0].attributeKey.key).toBe('host_name');
expect(result[1].attributeKey.key).toBe('os_type');
expect(result[0].aggregateAttribute).toBe('system_cpu_load_average_15m');
});
});
});

View File

@@ -1,22 +1,160 @@
import React from 'react';
import { InfoCircleOutlined } from '@ant-design/icons';
import { Tag, Tooltip } from 'antd';
import { Progress, TableColumnType as ColumnType, Tag, Tooltip } from 'antd';
import { HostData } from 'api/infraMonitoring/getHostLists';
import TanStackTable, { TableColumnDef } from 'components/TanStackTableView';
import { getGroupByEl } from 'container/InfraMonitoringK8s/Base/utils';
import { K8sRenderedRowData } from 'container/InfraMonitoringK8s/Base/types';
import { IEntityColumn } from 'container/InfraMonitoringK8s/Base/useInfraMonitoringTableColumnsStore';
import {
EntityProgressBar,
ExpandButtonWrapper,
ValidateColumnValueWrapper,
} from 'container/InfraMonitoringK8s/components';
getGroupByEl,
getGroupedByMeta,
getRowKey,
} from 'container/InfraMonitoringK8s/Base/utils';
import { ValidateColumnValueWrapper } from 'container/InfraMonitoringK8s/commonUtils';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8s/constants';
import { useInfraMonitoringGroupBy } from 'container/InfraMonitoringK8s/hooks';
import EntityGroupHeader from 'container/InfraMonitoringK8s/Base/EntityGroupHeader';
import { Group } from 'lucide-react';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { getMemoryProgressColor, getProgressColor } from './constants';
import { HostnameCell } from './utils';
import styles from './table.module.scss';
import { Container } from 'lucide-react';
export const hostColumns: IEntityColumn[] = [
{
label: 'Host group',
value: 'hostGroup',
id: 'hostGroup',
canBeHidden: false,
defaultVisibility: true,
behavior: 'hidden-on-collapse',
},
{
label: 'Hostname',
value: 'hostName',
id: 'hostName',
canBeHidden: false,
defaultVisibility: true,
behavior: 'hidden-on-expand',
},
{
label: 'Status',
value: 'active',
id: 'active',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'CPU Usage',
value: 'cpu',
id: 'cpu',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Memory Usage',
value: 'memory',
id: 'memory',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'IOWait',
value: 'wait',
id: 'wait',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Load Avg',
value: 'load15',
id: 'load15',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
];
export const hostColumnsConfig: ColumnType<K8sRenderedRowData>[] = [
{
title: (
<div className={styles.entityGroupHeader}>
<Group size={14} /> HOST GROUP
</div>
),
dataIndex: 'hostGroup',
key: 'hostGroup',
ellipsis: true,
width: 180,
sorter: false,
},
{
title: <div className={styles.hostnameColumnHeader}>Hostname</div>,
dataIndex: 'hostName',
key: 'hostName',
width: 250,
render: (_value, record): React.ReactNode => (
<HostnameCell
hostName={typeof record.hostName === 'string' ? record.hostName : ''}
/>
),
},
{
title: (
<div className={styles.statusHeader}>
Status
<Tooltip title="Sent system metrics in last 10 mins">
<InfoCircleOutlined />
</Tooltip>
</div>
),
dataIndex: 'active',
key: 'active',
width: 100,
},
{
title: <div className={styles.columnHeaderRight}>CPU Usage</div>,
dataIndex: 'cpu',
key: 'cpu',
width: 100,
sorter: true,
align: 'right',
},
{
title: (
<div className={`${styles.columnHeaderRight} ${styles.memoryUsageHeader}`}>
Memory Usage
<Tooltip title="Excluding cache memory">
<InfoCircleOutlined />
</Tooltip>
</div>
),
dataIndex: 'memory',
key: 'memory',
width: 100,
sorter: true,
align: 'right',
},
{
title: <div className={styles.columnHeaderRight}>IOWait</div>,
dataIndex: 'wait',
key: 'wait',
width: 100,
sorter: true,
align: 'right',
},
{
title: <div className={styles.columnHeaderRight}>Load Avg</div>,
dataIndex: 'load15',
key: 'load15',
width: 100,
sorter: true,
align: 'right',
},
];
function hostRowSource(host: HostData): { meta: Record<string, string> } {
return {
@@ -30,154 +168,67 @@ function hostRowSource(host: HostData): { meta: Record<string, string> } {
};
}
export function getHostRowKey(host: HostData): string {
return host.hostName || 'unknown';
}
export const hostRenderRowData = (
host: HostData,
groupBy: BaseAutocompleteData[],
): K8sRenderedRowData => {
const synthetic = hostRowSource(host);
const rowKey = getRowKey(synthetic, () => host.hostName || 'unknown', groupBy);
const groupedByMeta = getGroupedByMeta(synthetic, groupBy);
const cpuPercent = Number((host.cpu * 100).toFixed(1));
const memoryPercent = Number((host.memory * 100).toFixed(1));
export function getHostItemKey(host: HostData): string {
return host.hostName ?? '';
}
function HostGroupCell({ row }: { row: HostData }): JSX.Element {
const [groupBy] = useInfraMonitoringGroupBy();
const synthetic = hostRowSource(row);
return getGroupByEl(synthetic, groupBy) as JSX.Element;
}
export const hostColumnsConfig: TableColumnDef<HostData>[] = [
{
id: 'hostGroup',
header: (): React.ReactNode => <EntityGroupHeader title="HOST GROUP" />,
accessorFn: (row): string => row.hostName ?? '',
width: { min: 300 },
enableSort: false,
enableRemove: false,
enableMove: false,
pin: 'left',
visibilityBehavior: 'hidden-on-collapse',
cell: ({ row, isExpanded, toggleExpanded }): React.ReactNode => (
<ExpandButtonWrapper isExpanded={isExpanded} toggleExpanded={toggleExpanded}>
<HostGroupCell row={row} />
</ExpandButtonWrapper>
return {
key: rowKey,
itemKey: host.hostName ?? '',
groupedByMeta,
meta: synthetic.meta,
hostGroup: getGroupByEl(synthetic, groupBy),
...synthetic.meta,
hostName: host.hostName ?? '',
active: (
<Tag
bordered
className={`${styles.statusTag} ${
host.active ? styles.statusTagActive : styles.statusTagInactive
}`}
>
{host.active ? 'ACTIVE' : 'INACTIVE'}
</Tag>
),
},
{
id: 'hostName',
header: (): React.ReactNode => (
<EntityGroupHeader title="Hostname" icon={<Container size={14} />} />
),
accessorFn: (row): string => row.hostName ?? '',
width: { min: 290 },
enableSort: false,
enableRemove: false,
enableMove: false,
pin: 'left',
visibilityBehavior: 'hidden-on-expand',
cell: ({ value }): React.ReactNode => (
<HostnameCell hostName={value as string} />
),
},
{
id: 'active',
header: (): React.ReactNode => (
<div className={styles.statusHeader}>
Status
<Tooltip title="Sent system metrics in last 10 mins">
<InfoCircleOutlined />
</Tooltip>
</div>
),
accessorFn: (row): boolean => row.active,
width: { min: 150, default: 150 },
enableSort: false,
cell: ({ value }): React.ReactNode => {
const active = value as boolean;
return (
<Tag
bordered
className={`${styles.statusTag} ${
active ? styles.statusTagActive : styles.statusTagInactive
}`}
cpu: (
<div className={styles.progressContainer}>
<ValidateColumnValueWrapper
value={host.cpu}
entity={InfraMonitoringEntity.HOSTS}
>
{active ? 'ACTIVE' : 'INACTIVE'}
</Tag>
);
},
},
{
id: 'cpu',
header: (): React.ReactNode => (
<div className={styles.columnHeaderRight}>CPU Usage</div>
),
accessorFn: (row): number => row.cpu,
width: { min: 220 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const cpu = value as number;
return (
<div className={styles.progressContainer}>
<ValidateColumnValueWrapper
value={cpu}
entity={InfraMonitoringEntity.HOSTS}
>
<EntityProgressBar value={cpu} type="cpu" />
</ValidateColumnValueWrapper>
</div>
);
},
},
{
id: 'memory',
header: (): React.ReactNode => (
<div className={`${styles.columnHeaderRight} ${styles.memoryUsageHeader}`}>
Memory Usage
<Tooltip title="Excluding cache memory">
<InfoCircleOutlined />
</Tooltip>
<Progress
percent={cpuPercent}
strokeLinecap="butt"
size="small"
strokeColor={getProgressColor(cpuPercent)}
className={styles.progressBar}
/>
</ValidateColumnValueWrapper>
</div>
),
accessorFn: (row): number => row.memory,
width: { min: 220 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const memory = value as number;
return (
<div className={styles.progressContainer}>
<ValidateColumnValueWrapper
value={memory}
entity={InfraMonitoringEntity.HOSTS}
>
<EntityProgressBar value={memory} type="memory" />
</ValidateColumnValueWrapper>
</div>
);
},
},
{
id: 'wait',
header: (): React.ReactNode => (
<div className={styles.columnHeaderRight}>IOWait</div>
memory: (
<div className={styles.progressContainer}>
<ValidateColumnValueWrapper
value={host.memory}
entity={InfraMonitoringEntity.HOSTS}
>
<Progress
percent={memoryPercent}
strokeLinecap="butt"
size="small"
strokeColor={getMemoryProgressColor(memoryPercent)}
className={styles.progressBar}
/>
</ValidateColumnValueWrapper>
</div>
),
accessorFn: (row): number => row.wait,
width: { min: 100, default: 100 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const wait = value as number;
return (
<TanStackTable.Text>{`${Number((wait * 100).toFixed(1))}%`}</TanStackTable.Text>
);
},
},
{
id: 'load15',
header: (): React.ReactNode => (
<div className={styles.columnHeaderRight}>Load Avg</div>
),
accessorFn: (row): number => row.load15,
width: { min: 100, default: 100 },
enableSort: true,
cell: ({ value }): React.ReactNode => (
<TanStackTable.Text>{value as number}</TanStackTable.Text>
),
},
];
wait: `${Number((host.wait * 100).toFixed(1))}%`,
load15: host.load15,
};
};

View File

@@ -1,21 +0,0 @@
import { Group } from 'lucide-react';
import styles from './EntityGroupHeader.module.scss';
interface EntityGroupHeaderProps {
title: string;
icon?: React.ReactNode;
}
function EntityGroupHeader({
title,
icon,
}: EntityGroupHeaderProps): JSX.Element {
return (
<div className={styles.entityGroupHeader}>
{icon || <Group size={14} data-hide-expanded="true" />} {title}
</div>
);
}
export default EntityGroupHeader;

View File

@@ -37,7 +37,10 @@ import {
X,
} from 'lucide-react';
import { isCustomTimeRange, useGlobalTimeStore } from 'store/globalTime';
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime/utils';
import {
getAutoRefreshQueryKey,
NANO_SECOND_MULTIPLIER,
} from 'store/globalTime/utils';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import {
IBuilderQuery,
@@ -187,19 +190,16 @@ function K8sBaseDetails<T>({
);
const selectedTime = useGlobalTimeStore((s) => s.selectedTime);
const lastComputedMinMax = useGlobalTimeStore((s) => s.lastComputedMinMax);
const getMinMaxTime = useGlobalTimeStore((s) => s.getMinMaxTime);
const getAutoRefreshQueryKey = useGlobalTimeStore(
(s) => s.getAutoRefreshQueryKey,
);
const { startMs, endMs } = useMemo(
() => ({
startMs: Math.floor(lastComputedMinMax.minTime / NANO_SECOND_MULTIPLIER),
endMs: Math.floor(lastComputedMinMax.maxTime / NANO_SECOND_MULTIPLIER),
}),
[lastComputedMinMax],
);
const { startMs, endMs } = useMemo(() => {
const { minTime: startNs, maxTime: endNs } = getMinMaxTime(selectedTime);
return {
startMs: Math.floor(startNs / NANO_SECOND_MULTIPLIER),
endMs: Math.floor(endNs / NANO_SECOND_MULTIPLIER),
};
}, [getMinMaxTime, selectedTime]);
const [modalTimeRange, setModalTimeRange] = useState(() => ({
startTime: startMs,
@@ -246,7 +246,7 @@ function K8sBaseDetails<T>({
`${queryKeyPrefix}EntityDetails`,
selectedItem,
),
[getAutoRefreshQueryKey, queryKeyPrefix, selectedItem, selectedTime],
[queryKeyPrefix, selectedItem, selectedTime],
);
const {

View File

@@ -1,40 +1,175 @@
.emptyStateContainer {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
min-height: 400px;
.clickableRow {
cursor: pointer;
}
.k8SListTable {
padding-left: var(--spacing-2);
--tanstack-table-header-cell-bg: var(--l2-background);
--tanstack-table-header-cell-color: var(--l2-foreground);
--tanstack-table-cell-bg: var(--l2-background);
--tanstack-table-cell-color: var(--l2-foreground);
--tanstack-table-row-hover-bg: var(--l2-background-hover);
--tanstack-table-row-active-bg: var(--l2-background-active);
--tanstack-table-resize-handle-bg: var(--l2-background);
--tanstack-table-resize-handle-hover-bg: var(--l2-border);
--tanstack-table-row-height: 42px;
--k8s-base-list-pagination-offset: 64px;
--tanstack-cell-padding-top-override: 5px;
--tanstack-cell-padding-bottom-override: 5px;
--tanstack-cell-padding-left-override: 5px;
--tanstack-cell-padding-right-override: 5px;
display: flex;
flex-direction: column;
--tanstack-expansion-first-col-padding-left: 30px;
:global(.ant-spin-nested-loading) {
flex: 1;
display: flex;
flex-direction: column;
}
&[data-has-group-by='false'] {
--tanstack-cell-padding-left-first-column: 28px;
:global(.ant-spin-container) {
position: relative;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
padding-bottom: var(--k8s-base-list-pagination-offset);
}
:global(.ant-table-container) {
flex: 1;
display: flex;
flex-direction: column;
}
:global(.ant-table-header) {
flex-shrink: 0;
}
:global(.ant-table-body) {
flex: 1;
min-height: 0;
overflow: auto !important;
}
:global(.ant-table) {
flex: 1;
background: var(--l1-background) !important;
:global(.ant-table-thead > tr > th) {
padding: 12px;
font-weight: 500;
font-size: 12px;
line-height: 18px;
border-bottom: none;
color: var(--l2-foreground);
font-family: Inter;
font-size: 11px;
font-style: normal;
font-weight: 600;
line-height: 18px;
letter-spacing: 0.44px;
text-transform: uppercase;
background: var(--l1-background) !important;
&::before {
background: transparent !important;
}
}
:global(.ant-table-cell) {
padding: 12px;
font-size: 13px;
line-height: 20px;
color: var(--l1-foreground);
background: var(--l1-background);
border-bottom: none;
&:before,
&:after {
display: none;
}
}
:global(.progress-container) {
:global(.ant-progress-bg) {
height: 8px !important;
border-radius: 4px;
}
}
:global(.ant-table-tbody > tr:hover > td) {
background: var(--l1-background-hover);
}
:global(.ant-table-tbody > tr:not(.ant-table-expanded-row):hover > td) {
background: var(--l1-background-hover) !important;
}
:global(.ant-table-tbody > tr.ant-table-expanded-row:hover > td) {
background: var(--l1-background);
}
:global(.ant-table-tbody > tr > td) {
border-bottom: none;
}
:global(.ant-empty-normal) {
visibility: hidden;
}
:global(.ant-table-cell) {
min-width: 180px !important;
max-width: 180px !important;
}
:global(.ant-table-cell:first-of-type):not(
:global(.ant-table-row-expand-icon-cell)
) {
min-width: 250px !important;
max-width: 250px !important;
}
:global(.ant-table-row-expand-icon-cell) {
min-width: 40px !important;
max-width: 40px !important;
}
:global(.ant-table-content) {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
&::-webkit-scrollbar {
width: 4px;
height: 4px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--accent-primary);
}
&::-webkit-scrollbar-thumb:hover {
background: color-mix(var(--accent-primary), transparent 40%);
}
}
}
}
.paginationContainer {
padding-bottom: var(--spacing-8);
padding-right: var(--spacing-8);
.paginationDock {
position: fixed;
bottom: 0;
right: 0;
width: calc(100% - 340px) !important;
margin: 0 !important;
padding: 16px;
background-color: var(--l1-background);
z-index: 1;
ul {
margin: 0;
:global(.ant-pagination-item) {
border-radius: 4px;
}
:global(.ant-pagination-item-active) {
background: var(--l2-background);
border-color: var(--l2-border);
a {
color: var(--l1-foreground) !important;
}
}
}

View File

@@ -1,36 +1,48 @@
import { useCallback, useEffect, useMemo } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useQuery } from 'react-query';
import { Typography } from 'antd';
// eslint-disable-next-line no-restricted-imports
import { LoadingOutlined } from '@ant-design/icons';
import {
Button,
Spin,
Table,
TableColumnType as ColumnType,
TablePaginationConfig,
TableProps,
} from 'antd';
import type { SorterResult } from 'antd/es/table/interface';
import logEvent from 'api/common/logEvent';
import TanStackTable, {
TableColumnDef,
useHiddenColumnIds,
} from 'components/TanStackTableView';
import { InfraMonitoringEvents } from 'constants/events';
import { ChevronDown, ChevronRight } from 'lucide-react';
import { parseAsString, useQueryState } from 'nuqs';
import { useGlobalTimeStore } from 'store/globalTime';
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime/utils';
import {
getAutoRefreshQueryKey,
NANO_SECOND_MULTIPLIER,
} from 'store/globalTime/utils';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { buildAbsolutePath, isModifierKeyPressed } from 'utils/app';
import { openInNewTab } from 'utils/navigation';
import { InfraMonitoringEntity } from '../constants';
import {
INFRA_MONITORING_K8S_PARAMS_KEYS,
InfraMonitoringEntity,
} from '../constants';
import {
useInfraMonitoringFiltersK8s,
useInfraMonitoringCurrentPage,
useInfraMonitoringFilters,
useInfraMonitoringGroupBy,
useInfraMonitoringOrderBy,
useInfraMonitoringPageListing,
useInfraMonitoringPageSizeListing,
} from '../hooks';
import { usePageSize } from '../utils';
import { K8sEmptyState } from './K8sEmptyState';
import { K8sExpandedRow } from './K8sExpandedRow';
import K8sHeader from './K8sHeader';
import { K8sBaseFilters } from './types';
import { getGroupedByMeta } from './utils';
import { K8sBaseFilters, K8sRenderedRowData } from './types';
import {
IEntityColumn,
useInfraMonitoringTableColumnsForPage,
useInfraMonitoringTableColumnsStore,
} from './useInfraMonitoringTableColumnsStore';
import styles from './K8sBaseList.module.scss';
import cx from 'classnames';
export type K8sBaseListEmptyStateContext = {
isError: boolean;
@@ -41,13 +53,11 @@ export type K8sBaseListEmptyStateContext = {
rawData?: unknown;
};
/** Base type constraint for K8s entity data */
export type K8sEntityData = { meta?: Record<string, string> };
export type K8sBaseListProps<T extends K8sEntityData> = {
export type K8sBaseListProps<T = unknown> = {
controlListPrefix?: React.ReactNode;
entity: InfraMonitoringEntity;
tableColumns: TableColumnDef<T>[];
tableColumnsDefinitions: IEntityColumn[];
tableColumns: ColumnType<K8sRenderedRowData>[];
fetchListData: (
filters: K8sBaseFilters,
signal?: AbortSignal,
@@ -57,63 +67,69 @@ export type K8sBaseListProps<T extends K8sEntityData> = {
error?: string | null;
rawData?: unknown;
}>;
/** Function to get the unique key for a row. */
getRowKey?: (record: T) => string;
/** Function to get the item key used for selection. Defaults to getRowKey if not provided. */
getItemKey?: (record: T) => string;
renderRowData: (
record: T,
groupBy: BaseAutocompleteData[],
) => K8sRenderedRowData;
eventCategory: InfraMonitoringEvents;
renderEmptyState?: (
context: K8sBaseListEmptyStateContext,
) => React.ReactNode | null;
};
export function K8sBaseList<T extends K8sEntityData>({
export function K8sBaseList<T>({
controlListPrefix,
entity,
tableColumnsDefinitions,
tableColumns,
fetchListData,
getRowKey,
getItemKey,
renderRowData,
eventCategory,
renderEmptyState,
}: K8sBaseListProps<T>): JSX.Element {
const [queryFilters] = useInfraMonitoringFiltersK8s();
const [currentPage] = useInfraMonitoringPageListing();
const [currentPageSize] = useInfraMonitoringPageSizeListing();
const [queryFilters] = useInfraMonitoringFilters();
const [currentPage, setCurrentPage] = useInfraMonitoringCurrentPage();
const [groupBy] = useInfraMonitoringGroupBy();
const [orderBy] = useInfraMonitoringOrderBy();
const [orderBy, setOrderBy] = useInfraMonitoringOrderBy();
const [initialOrderBy] = useState(orderBy);
const [selectedItem, setSelectedItem] = useQueryState(
'selectedItem',
parseAsString,
);
const columnStorageKey = `k8s-${entity}-columns`;
const hiddenColumnIds = useHiddenColumnIds(columnStorageKey);
const [expandedRowKeys, setExpandedRowKeys] = useState<string[]>([]);
useEffect(() => {
setExpandedRowKeys([]);
}, [groupBy, currentPage]);
const { pageSize, setPageSize } = usePageSize(entity);
const initializeTableColumns = useInfraMonitoringTableColumnsStore(
(state) => state.initializePageColumns,
);
useEffect(() => {
initializeTableColumns(entity, tableColumnsDefinitions);
}, [initializeTableColumns, entity, tableColumnsDefinitions]);
const selectedTime = useGlobalTimeStore((s) => s.selectedTime);
const refreshInterval = useGlobalTimeStore((s) => s.refreshInterval);
const isRefreshEnabled = useGlobalTimeStore((s) => s.isRefreshEnabled);
const getMinMaxTime = useGlobalTimeStore((s) => s.getMinMaxTime);
const getAutoRefreshQueryKey = useGlobalTimeStore(
(s) => s.getAutoRefreshQueryKey,
);
const queryKey = useMemo(() => {
return getAutoRefreshQueryKey(
selectedTime,
'k8sBaseList',
entity,
String(currentPageSize),
String(pageSize),
String(currentPage),
JSON.stringify(queryFilters),
JSON.stringify(orderBy),
JSON.stringify(groupBy),
);
}, [
getAutoRefreshQueryKey,
selectedTime,
entity,
currentPageSize,
pageSize,
currentPage,
queryFilters,
orderBy,
@@ -127,8 +143,8 @@ export function K8sBaseList<T extends K8sEntityData>({
return fetchListData(
{
limit: currentPageSize,
offset: (currentPage - 1) * currentPageSize,
limit: pageSize,
offset: (currentPage - 1) * pageSize,
filters: queryFilters || { items: [], op: 'AND' },
start: Math.floor(minTime / NANO_SECOND_MULTIPLIER),
end: Math.floor(maxTime / NANO_SECOND_MULTIPLIER),
@@ -141,14 +157,62 @@ export function K8sBaseList<T extends K8sEntityData>({
refetchInterval: isRefreshEnabled ? refreshInterval : false,
});
const pageData = data?.data ?? [];
const pageData = data?.data;
const totalCount = data?.total || 0;
const hasFilters = (queryFilters?.items?.length ?? 0) > 0;
const getGroupKeyFn = useCallback(
(item: T) => getGroupedByMeta(item, groupBy),
[groupBy],
);
const formattedItemsData = useMemo(() => {
if (!pageData) {
return undefined;
}
const rows = pageData.map((item) => renderRowData(item, groupBy));
// Without handling duplicated keys, the table became unpredictable/unstable
const keyCount = new Map<string, number>();
return rows.map(
// eslint-disable-next-line sonarjs/no-identical-functions
(row): K8sRenderedRowData => {
const count = keyCount.get(row.key) || 0;
keyCount.set(row.key, count + 1);
if (count > 0) {
return { ...row, key: `${row.key}-${count}` };
}
return row;
},
);
}, [pageData, renderRowData, groupBy]);
const handleTableChange: TableProps<K8sRenderedRowData>['onChange'] =
useCallback(
(
pagination: TablePaginationConfig,
_filters: Record<string, (string | number | boolean)[] | null>,
sorter:
| SorterResult<K8sRenderedRowData>
| SorterResult<K8sRenderedRowData>[],
): void => {
if (pagination.current) {
setCurrentPage(pagination.current);
logEvent(InfraMonitoringEvents.PageNumberChanged, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.ListPage,
category: eventCategory,
});
}
if ('field' in sorter && sorter.order) {
setOrderBy({
columnName: sorter.field as string,
order: (sorter.order === 'ascend' ? 'asc' : 'desc') as 'asc' | 'desc',
});
} else {
setOrderBy(null);
}
},
[eventCategory, setCurrentPage, setOrderBy],
);
useEffect(() => {
logEvent(InfraMonitoringEvents.PageVisited, {
@@ -159,137 +223,214 @@ export function K8sBaseList<T extends K8sEntityData>({
});
}, [eventCategory, totalCount]);
const handleRowClick = useCallback(
(_record: T, itemKey: string): void => {
if (groupBy.length === 0) {
setSelectedItem(itemKey);
}
const handleGroupByRowClick = (record: K8sRenderedRowData): void => {
if (expandedRowKeys.includes(record.key)) {
setExpandedRowKeys(expandedRowKeys.filter((key) => key !== record.key));
} else {
setExpandedRowKeys([record.key]);
}
};
logEvent(InfraMonitoringEvents.ItemClicked, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.ListPage,
category: eventCategory,
});
},
[eventCategory, groupBy.length, setSelectedItem],
const openItemInNewTab = (record: K8sRenderedRowData): void => {
const newParams = new URLSearchParams(document.location.search);
newParams.set('selectedItem', record.itemKey);
openInNewTab(
buildAbsolutePath({
relativePath: '',
urlQueryString: newParams.toString(),
}),
);
};
const handleRowClick = (
record: K8sRenderedRowData,
event: React.MouseEvent,
): void => {
if (event && isModifierKeyPressed(event)) {
openItemInNewTab(record);
return;
}
if (groupBy.length === 0) {
setSelectedItem(record.itemKey);
} else {
handleGroupByRowClick(record);
}
logEvent(InfraMonitoringEvents.ItemClicked, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.ListPage,
category: eventCategory,
});
};
const [columnsDefinitions, columnsHidden] =
useInfraMonitoringTableColumnsForPage(entity);
const hiddenColumnIdsOnList = useMemo(
() =>
columnsDefinitions
.filter(
(col) =>
(groupBy?.length > 0 && col.behavior === 'hidden-on-expand') ||
(!groupBy?.length && col.behavior === 'hidden-on-collapse'),
)
.map((col) => col.id),
[columnsDefinitions, groupBy?.length],
);
const handleRowClickNewTab = useCallback(
(_record: T, itemKey: string): void => {
if (groupBy.length > 0) {
return;
const mapDefaultSort = useCallback(
(
tableColumn: ColumnType<K8sRenderedRowData>,
): ColumnType<K8sRenderedRowData> => {
if (tableColumn.key === initialOrderBy?.columnName) {
return {
...tableColumn,
defaultSortOrder: initialOrderBy?.order === 'asc' ? 'ascend' : 'descend',
};
}
// Build URL with selectedItem param
const url = new URL(window.location.href);
url.searchParams.set('selectedItem', itemKey);
openInNewTab(url.pathname + url.search);
logEvent(InfraMonitoringEvents.ItemClicked, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.ListPage,
category: eventCategory,
});
return tableColumn;
},
[eventCategory, groupBy.length],
[initialOrderBy?.columnName, initialOrderBy?.order],
);
const columns = useMemo(
() =>
tableColumns
.filter(
(c) =>
!hiddenColumnIdsOnList.includes(c.key?.toString() || '') &&
!columnsHidden.includes(c.key?.toString() || ''),
)
.map(mapDefaultSort),
[columnsHidden, hiddenColumnIdsOnList, mapDefaultSort, tableColumns],
);
const isGroupedByAttribute = groupBy.length > 0;
// Filter columns for expanded row based on parent's hidden columns
const expandedRowColumns = useMemo(
() => tableColumns.filter((col) => !hiddenColumnIds.includes(col.id)),
[tableColumns, hiddenColumnIds],
const expandedRowRender = (record: K8sRenderedRowData): JSX.Element => (
<K8sExpandedRow<T>
record={record}
entity={entity}
tableColumns={tableColumns}
fetchListData={fetchListData}
renderRowData={renderRowData}
/>
);
const renderExpandedRow = useCallback(
(
_record: T,
rowKey: string,
groupMeta?: Record<string, string>,
): JSX.Element => (
<K8sExpandedRow<T>
rowKey={rowKey}
groupMeta={groupMeta}
entity={entity}
tableColumns={expandedRowColumns}
fetchListData={fetchListData}
getRowKey={getRowKey}
getItemKey={getItemKey}
/>
),
[entity, fetchListData, getRowKey, getItemKey, expandedRowColumns],
);
const expandRowIconRenderer = ({
expanded,
onExpand,
record,
}: {
expanded: boolean;
onExpand: (
record: K8sRenderedRowData,
e: React.MouseEvent<HTMLButtonElement>,
) => void;
record: K8sRenderedRowData;
}): JSX.Element | null => {
if (!isGroupedByAttribute) {
return null;
}
const getRowCanExpand = useCallback(
(): boolean => isGroupedByAttribute,
[isGroupedByAttribute],
);
return expanded ? (
<Button
className="periscope-btn ghost"
onClick={(e: React.MouseEvent<HTMLButtonElement>): void =>
onExpand(record, e)
}
role="button"
>
<ChevronDown size={14} />
</Button>
) : (
<Button
className="periscope-btn ghost"
onClick={(e: React.MouseEvent<HTMLButtonElement>): void =>
onExpand(record, e)
}
role="button"
>
<ChevronRight size={14} />
</Button>
);
};
const onPaginationChange = (page: number, pageSize: number): void => {
setCurrentPage(page);
setPageSize(pageSize);
logEvent(InfraMonitoringEvents.PageNumberChanged, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.ListPage,
category: eventCategory,
});
};
const showTableLoadingState = isLoading;
const emptyTableMessage: React.ReactNode = renderEmptyState?.({
isError,
const emptyStateContext: K8sBaseListEmptyStateContext = {
isError: isError || !!data?.error,
error: data?.error,
totalCount,
hasFilters,
isLoading: showTableLoadingState,
rawData: data?.rawData,
}) || (
};
const emptyTableMessage: React.ReactNode = renderEmptyState?.(
emptyStateContext,
) || (
<K8sEmptyState
isError={isError}
error={data?.error}
isLoading={showTableLoadingState}
rawData={data?.rawData}
isError={emptyStateContext.isError}
error={emptyStateContext.error}
isLoading={emptyStateContext.isLoading}
rawData={emptyStateContext.rawData}
/>
);
const showEmptyState = !showTableLoadingState && pageData.length === 0;
return (
<>
<K8sHeader
controlListPrefix={controlListPrefix}
entity={entity}
showAutoRefresh={!selectedItem}
columns={tableColumns}
columnStorageKey={columnStorageKey}
/>
{isError && (
<Typography>{data?.error?.toString() || 'Something went wrong'}</Typography>
)}
{showEmptyState ? (
<div className={styles.emptyStateContainer}>{emptyTableMessage}</div>
) : (
<TanStackTable<T>
data={pageData}
columns={tableColumns}
columnStorageKey={columnStorageKey}
isLoading={showTableLoadingState}
getRowKey={getRowKey}
getItemKey={getItemKey}
groupBy={groupBy}
getGroupKey={getGroupKeyFn}
onRowClick={handleRowClick}
onRowClickNewTab={handleRowClickNewTab}
renderExpandedRow={isGroupedByAttribute ? renderExpandedRow : undefined}
getRowCanExpand={isGroupedByAttribute ? getRowCanExpand : undefined}
className={cx(styles.k8SListTable, expandedRowColumns)}
enableQueryParams={{
page: INFRA_MONITORING_K8S_PARAMS_KEYS.PAGE,
limit: INFRA_MONITORING_K8S_PARAMS_KEYS.PAGE_SIZE,
orderBy: INFRA_MONITORING_K8S_PARAMS_KEYS.ORDER_BY,
expanded: INFRA_MONITORING_K8S_PARAMS_KEYS.EXPANDED,
}}
pagination={{
total: totalCount,
defaultLimit: 10,
defaultPage: 1,
}}
paginationClassname={styles.paginationContainer}
/>
)}
<Table
className={styles.k8SListTable}
dataSource={showTableLoadingState ? [] : formattedItemsData}
columns={columns}
pagination={{
current: currentPage,
pageSize,
total: totalCount,
showSizeChanger: true,
hideOnSinglePage: false,
onChange: onPaginationChange,
className: styles.paginationDock,
}}
loading={{
spinning: showTableLoadingState,
indicator: <Spin indicator={<LoadingOutlined size={14} spin />} />,
}}
locale={{
emptyText: showTableLoadingState ? null : emptyTableMessage,
}}
scroll={{ x: true }}
tableLayout="fixed"
onChange={handleTableChange}
onRow={(
record,
): { onClick: (event: React.MouseEvent) => void; className: string } => ({
onClick: (event: React.MouseEvent): void => handleRowClick(record, event),
className: styles.clickableRow,
})}
expandable={{
expandedRowRender: isGroupedByAttribute ? expandedRowRender : undefined,
expandIcon: expandRowIconRenderer,
expandedRowKeys,
}}
/>
</>
);
}

View File

@@ -21,7 +21,6 @@
.title {
font-weight: 500;
margin: 0;
font-size: var(--periscope-font-size-medium);
}
.message {

View File

@@ -7,7 +7,7 @@ import { AlertTriangle, LifeBuoy } from 'lucide-react';
import emptyStateUrl from '@/assets/Icons/emptyState.svg';
import eyesEmojiUrl from '@/assets/Images/eyesEmoji.svg';
import type { K8sBaseListEmptyStateContext } from './K8sBaseList';
import { K8sBaseListEmptyStateContext } from './K8sBaseList';
import styles from './K8sEmptyState.module.scss';

View File

@@ -1,35 +1,28 @@
.expandedTableContainer {
overflow-x: auto;
.expandedClickableRow {
cursor: pointer;
}
.expandedTable {
--tanstack-table-header-cell-bg: var(--l1-background);
--tanstack-table-header-cell-color: var(--l1-foreground);
--tanstack-table-cell-bg: var(--l1-background);
--tanstack-table-cell-color: var(--l1-foreground);
--tanstack-table-row-hover-bg: var(--l1-background-hover);
--tanstack-table-row-active-bg: var(--l1-background-active);
--tanstack-table-resize-handle-bg: var(--l1-background);
--tanstack-table-resize-handle-hover-bg: var(--l1-border);
--tanstack-table-row-height: 36px;
.expandedTableContainer {
border: 1px solid var(--l1-border);
overflow-x: auto;
padding-left: 48px;
--tanstack-cell-padding-left-override: 15px;
--tanstack-cell-padding-right-override: 15px;
& [data-hide-expanded='true'] {
display: none;
:global(.ant-table-tbody > tr:hover > td) {
background: none !important;
}
}
.expandedTableFooter {
display: flex;
justify-content: flex-start;
gap: var(--spacing-4);
background-color: var(--l1-background);
gap: 8px;
padding: 8px;
padding-left: 42px;
margin-top: 8px;
}
.viewAllButton {
display: flex;
align-items: center;
margin: var(--spacing-4);
gap: var(--spacing-2);
}

View File

@@ -1,40 +1,43 @@
import { useCallback, useEffect, useMemo } from 'react';
import React, { useCallback, useMemo } from 'react';
import { useQuery } from 'react-query';
import { Button } from '@signozhq/ui';
import { Typography } from 'antd';
import TanStackTable, {
SortState,
TableColumnDef,
TanStackTableStateProvider,
} from 'components/TanStackTableView';
// eslint-disable-next-line no-restricted-imports
import { LoadingOutlined } from '@ant-design/icons';
import {
Button,
Spin,
Table,
TableColumnType as ColumnType,
Typography,
} from 'antd';
import { CornerDownRight } from 'lucide-react';
import { useQueryState } from 'nuqs';
import { useGlobalTimeStore } from 'store/globalTime';
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime/utils';
import {
getAutoRefreshQueryKey,
NANO_SECOND_MULTIPLIER,
} from 'store/globalTime/utils';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { parseAsJsonNoValidate } from 'utils/nuqsParsers';
import { buildAbsolutePath, isModifierKeyPressed } from 'utils/app';
import { openInNewTab } from 'utils/navigation';
import { InfraMonitoringEntity } from '../constants';
import {
useInfraMonitoringFiltersK8s,
useInfraMonitoringCurrentPage,
useInfraMonitoringFilters,
useInfraMonitoringGroupBy,
useInfraMonitoringOrderBy,
useInfraMonitoringPageListing,
useInfraMonitoringSelectedItem,
} from '../hooks';
import { K8sBaseFilters } from './types';
import LoadingContainer from '../LoadingContainer';
import { K8sBaseFilters, K8sRenderedRowData } from './types';
import { useInfraMonitoringTableColumnsForPage } from './useInfraMonitoringTableColumnsStore';
import styles from './K8sExpandedRow.module.scss';
const EXPANDED_ROW_LIMIT = 10;
export type K8sExpandedRowProps<T> = {
/** Pre-computed row key from parent table (includes group prefix + duplicate handling) */
rowKey: string;
/** Group metadata for building filters */
groupMeta?: Record<string, string>;
record: K8sRenderedRowData;
entity: InfraMonitoringEntity;
tableColumns: TableColumnDef<T>[];
tableColumns: ColumnType<K8sRenderedRowData>[];
fetchListData: (
filters: K8sBaseFilters,
signal?: AbortSignal,
@@ -44,46 +47,47 @@ export type K8sExpandedRowProps<T> = {
error?: string | null;
rawData?: unknown;
}>;
/** Function to get the unique key for a row. */
getRowKey?: (record: T) => string;
/** Function to get the item key used for selection. Defaults to getRowKey if not provided. */
getItemKey?: (record: T) => string;
renderRowData: (
record: T,
groupBy: BaseAutocompleteData[],
) => K8sRenderedRowData;
};
export const MAX_ITEMS_TO_FETCH_WHEN_GROUP_BY = 10;
export function K8sExpandedRow<T>({
rowKey,
groupMeta,
record,
entity,
tableColumns,
fetchListData,
getRowKey,
getItemKey,
renderRowData,
}: K8sExpandedRowProps<T>): JSX.Element {
const [, setGroupBy] = useInfraMonitoringGroupBy();
const [, setCurrentPage] = useInfraMonitoringPageListing();
const [queryFilters, setFilters] = useInfraMonitoringFiltersK8s();
const [groupBy, setGroupBy] = useInfraMonitoringGroupBy();
const [orderBy, setOrderBy] = useInfraMonitoringOrderBy();
const [, setCurrentPage] = useInfraMonitoringCurrentPage();
const [queryFilters, setFilters] = useInfraMonitoringFilters();
const [, setSelectedItem] = useInfraMonitoringSelectedItem();
const [, setMainOrderBy] = useInfraMonitoringOrderBy();
const orderByParamKey = useMemo(
() => `orderBy_${rowKey.replace(/[^a-zA-Z0-9]/g, '_')}`,
[rowKey],
);
const [orderBy, setOrderBy] = useQueryState(
orderByParamKey,
parseAsJsonNoValidate<SortState | null>()
.withDefault(null as never)
.withOptions({
history: 'push',
}),
);
useEffect(() => {
return (): void => {
void setOrderBy(null);
};
}, [setOrderBy]);
const [columnsDefinitions, columnsHidden] =
useInfraMonitoringTableColumnsForPage(entity);
const storageKey = `k8s-${entity}-columns-expanded`;
const hiddenColumnIdsForNested = useMemo(
() =>
columnsDefinitions
.filter((col) => col.behavior === 'hidden-on-collapse')
.map((col) => col.id),
[columnsDefinitions],
);
const nestedColumns = useMemo(
() =>
tableColumns.filter(
(c) =>
!columnsHidden.includes(c.key?.toString() || '') &&
!hiddenColumnIdsForNested.includes(c.key?.toString() || ''),
),
[tableColumns, columnsHidden, hiddenColumnIdsForNested],
);
const createFiltersForRecord = useCallback((): NonNullable<
IBuilderQuery['filters']
@@ -93,64 +97,45 @@ export function K8sExpandedRow<T>({
op: 'and',
};
const metaKeys = groupMeta ?? {};
const { groupedByMeta } = record;
for (const key of Object.keys(metaKeys)) {
const value = metaKeys[key];
// Skip empty values to avoid creating invalid filters
if (value === '' || value === undefined || value === null) {
continue;
}
for (const key of Object.keys(groupedByMeta)) {
baseFilters.items.push({
key: {
key,
type: 'resource',
type: null,
},
op: '=',
value,
value: groupedByMeta[key],
id: key,
});
}
return baseFilters;
}, [queryFilters?.items, groupMeta]);
}, [queryFilters?.items, record]);
const selectedTime = useGlobalTimeStore((s) => s.selectedTime);
const refreshInterval = useGlobalTimeStore((s) => s.refreshInterval);
const isRefreshEnabled = useGlobalTimeStore((s) => s.isRefreshEnabled);
const getMinMaxTime = useGlobalTimeStore((s) => s.getMinMaxTime);
const getAutoRefreshQueryKey = useGlobalTimeStore(
(s) => s.getAutoRefreshQueryKey,
);
const queryKey = useMemo(() => {
return getAutoRefreshQueryKey(
selectedTime,
entity,
return getAutoRefreshQueryKey(selectedTime, [
'k8sExpandedRow',
JSON.stringify(groupMeta),
rowKey,
record.key,
JSON.stringify(queryFilters),
JSON.stringify(orderBy),
);
}, [
getAutoRefreshQueryKey,
selectedTime,
entity,
groupMeta,
rowKey,
queryFilters,
orderBy,
]);
]);
}, [selectedTime, record.key, queryFilters, orderBy]);
const { data, isLoading, isError } = useQuery({
const { data, isFetching, isLoading, isError } = useQuery({
queryKey,
queryFn: async ({ signal }) => {
queryFn: ({ signal }) => {
const { minTime, maxTime } = getMinMaxTime();
return await fetchListData(
return fetchListData(
{
limit: EXPANDED_ROW_LIMIT,
limit: MAX_ITEMS_TO_FETCH_WHEN_GROUP_BY,
offset: 0,
filters: createFiltersForRecord(),
start: Math.floor(minTime / NANO_SECOND_MULTIPLIER),
@@ -161,45 +146,48 @@ export function K8sExpandedRow<T>({
signal,
);
},
staleTime: 1000 * 60 * 30,
refetchInterval: isRefreshEnabled ? refreshInterval : false,
});
const expandedData = data?.data ?? [];
const formattedData = useMemo(() => {
if (!data?.data) {
return undefined;
}
const handleRowClick = useCallback(
(_row: T, itemKey: string): void => {
setSelectedItem(itemKey);
},
[setSelectedItem],
);
const rows = data.data.map((item) => renderRowData(item, groupBy));
// Without handling duplicated keys, the table became unpredictable/unstable
const keyCount = new Map<string, number>();
return rows.map((row): K8sRenderedRowData => {
const count = keyCount.get(row.key) || 0;
keyCount.set(row.key, count + 1);
if (count > 0) {
return { ...row, key: `${row.key}-${count}` };
}
return row;
});
}, [data?.data, renderRowData, groupBy]);
const openRecordInNewTab = (rowRecord: K8sRenderedRowData): void => {
const newParams = new URLSearchParams(document.location.search);
newParams.set('selectedItem', rowRecord.itemKey);
openInNewTab(
buildAbsolutePath({
relativePath: '',
urlQueryString: newParams.toString(),
}),
);
};
const handleViewAllClick = (): void => {
const filters = createFiltersForRecord();
setGroupBy([]);
setCurrentPage(1);
setFilters(filters);
if (orderBy) {
setMainOrderBy(orderBy);
}
setCurrentPage(1);
setGroupBy([]);
setOrderBy(null);
};
const total = data?.total ?? 0;
const hasMoreItems = total > EXPANDED_ROW_LIMIT;
const footerContent = hasMoreItems ? (
<Button
type="button"
color="secondary"
variant="outlined"
className={styles.viewAllButton}
onClick={handleViewAllClick}
prefix={<CornerDownRight size={14} />}
>
View All
</Button>
) : null;
return (
<div
className={styles.expandedTableContainer}
@@ -209,30 +197,50 @@ export function K8sExpandedRow<T>({
<Typography>{data?.error?.toString() || 'Something went wrong'}</Typography>
)}
<div data-testid="expanded-table">
<TanStackTableStateProvider>
<TanStackTable<T>
data={expandedData}
columns={tableColumns}
columnStorageKey={storageKey}
isLoading={isLoading}
getRowKey={getRowKey}
getItemKey={getItemKey}
onRowClick={handleRowClick}
enableQueryParams={{
orderBy: orderByParamKey,
{isFetching || isLoading ? (
<LoadingContainer />
) : (
<div data-testid="expanded-table">
<Table
columns={nestedColumns}
dataSource={formattedData}
pagination={false}
scroll={{ x: true }}
tableLayout="fixed"
showHeader={false}
loading={{
spinning: isFetching || isLoading,
indicator: <Spin indicator={<LoadingOutlined size={14} spin />} />,
}}
tableScrollerProps={{
className: styles.expandedTable,
}}
disableVirtualScroll
cellTypographySize="medium"
onRow={(
rowRecord: K8sRenderedRowData,
): { onClick: (event: React.MouseEvent) => void; className: string } => ({
onClick: (event: React.MouseEvent): void => {
if (isModifierKeyPressed(event)) {
openRecordInNewTab(rowRecord);
return;
}
setSelectedItem(rowRecord.itemKey);
},
className: styles.expandedClickableRow,
})}
/>
</TanStackTableStateProvider>
{!isLoading && expandedData.length > 0 && (
<div className={styles.expandedTableFooter}>{footerContent}</div>
)}
</div>
{data?.total && data?.total > MAX_ITEMS_TO_FETCH_WHEN_GROUP_BY && (
<div className={styles.expandedTableFooter}>
<Button
type="default"
size="small"
className={styles.viewAllButton}
onClick={handleViewAllClick}
>
<CornerDownRight size={14} />
View All
</Button>
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -1,98 +1,57 @@
import { useMemo } from 'react';
import { Button, DrawerWrapper } from '@signozhq/ui';
import { InfraMonitoringEntity } from '../constants';
import {
hideColumn,
showColumn,
TableColumnDef,
useHiddenColumnIds,
} from 'components/TanStackTableView';
useInfraMonitoringTableColumnsForPage,
useInfraMonitoringTableColumnsStore,
} from './useInfraMonitoringTableColumnsStore';
import styles from './K8sFiltersSidePanel.module.scss';
type ColumnPickerItem = {
id: string;
label: string;
canBeHidden: boolean;
visibilityBehavior:
| 'hidden-on-expand'
| 'hidden-on-collapse'
| 'always-visible';
};
/**
* Converts TableColumnDef to column picker item format
*/
function toColumnPickerItems<T>(
columns: TableColumnDef<T>[],
): ColumnPickerItem[] {
return columns.map((col) => ({
id: col.id,
label: typeof col.header === 'string' ? col.header : col.id,
canBeHidden: col.canBeHidden !== false && col.enableRemove !== false,
visibilityBehavior: col.visibilityBehavior ?? 'always-visible',
}));
}
function K8sFiltersSidePanel<TData>({
function K8sFiltersSidePanel({
open,
onClose,
columns,
storageKey,
entity,
}: {
open: boolean;
onClose: () => void;
columns: TableColumnDef<TData>[];
storageKey: string;
entity: InfraMonitoringEntity;
}): JSX.Element {
const columnPickerItems = useMemo(
() => toColumnPickerItems(columns),
[columns],
const addColumn = useInfraMonitoringTableColumnsStore(
(state) => state.addColumn,
);
const hiddenColumnIds = useHiddenColumnIds(storageKey);
const addedColumns = useMemo(
() =>
columnPickerItems.filter(
(column) =>
!hiddenColumnIds.includes(column.id) &&
column.visibilityBehavior !== 'hidden-on-collapse',
),
[columnPickerItems, hiddenColumnIds],
const removeColumn = useInfraMonitoringTableColumnsStore(
(state) => state.removeColumn,
);
const hiddenColumns = useMemo(
() =>
columnPickerItems.filter((column) => hiddenColumnIds.includes(column.id)),
[columnPickerItems, hiddenColumnIds],
);
const handleRemoveColumn = (columnId: string): void => {
hideColumn(storageKey, columnId);
};
const handleAddColumn = (columnId: string): void => {
showColumn(storageKey, columnId);
};
const [columns, columnsHidden] = useInfraMonitoringTableColumnsForPage(entity);
const drawerContent = (
<>
<div className={styles.columnsTitle}>Added Columns (Click to remove)</div>
<div className={styles.columnsList}>
{addedColumns.map((column) => (
<div className={styles.columnItem} key={column.id}>
<Button
variant="ghost"
color="none"
className={styles.columnItem}
disabled={!column.canBeHidden}
data-testid={`remove-column-${column.id}`}
onClick={(): void => handleRemoveColumn(column.id)}
>
{column.label}
</Button>
</div>
))}
{columns
.filter(
(column) =>
!columnsHidden.includes(column.id) &&
column.behavior !== 'hidden-on-collapse',
)
.map((column) => (
<div className={styles.columnItem} key={column.value}>
{/*<GripVertical size={16} /> TODO: Add support back when update the table component */}
<Button
variant="ghost"
color="none"
className={styles.columnItem}
disabled={!column.canBeHidden}
data-testid={`remove-column-${column.id}`}
onClick={(): void => removeColumn(entity, column.id)}
>
{column.label}
</Button>
</div>
))}
</div>
<div className={styles.horizontalDivider} />
@@ -100,21 +59,23 @@ function K8sFiltersSidePanel<TData>({
<div className={styles.columnsTitle}>Other Columns (Click to add)</div>
<div className={styles.columnsList}>
{hiddenColumns.map((column) => (
<div className={styles.columnItem} key={column.id}>
<Button
variant="ghost"
color="none"
className={styles.columnItem}
data-can-be-added="true"
data-testid={`add-column-${column.id}`}
onClick={(): void => handleAddColumn(column.id)}
tabIndex={0}
>
{column.label}
</Button>
</div>
))}
{columns
.filter((column) => columnsHidden.includes(column.id))
.map((column) => (
<div className={styles.columnItem} key={column.value}>
<Button
variant="ghost"
color="none"
className={styles.columnItem}
data-can-be-added="true"
data-testid={`add-column-${column.id}`}
onClick={(): void => addColumn(entity, column.id)}
tabIndex={0}
>
{column.label}
</Button>
</div>
))}
</div>
</>
);

View File

@@ -1,17 +0,0 @@
import { getGroupByEl } from './utils';
import { useInfraMonitoringGroupBy } from '../hooks';
interface K8sEntityWithMeta {
meta?: Record<string, string>;
}
function K8sGroupCell<T extends K8sEntityWithMeta>({
row,
}: {
row: T;
}): JSX.Element {
const [groupBy] = useInfraMonitoringGroupBy();
return getGroupByEl(row, groupBy) as JSX.Element;
}
export default K8sGroupCell;

View File

@@ -2,7 +2,6 @@ import React, { useCallback, useMemo, useState } from 'react';
import { Button } from '@signozhq/ui';
import { Select } from 'antd';
import logEvent from 'api/common/logEvent';
import { TableColumnDef } from 'components/TanStackTableView';
import { InfraMonitoringEvents } from 'constants/events';
import { FeatureKeys } from 'constants/features';
import { initialQueriesMap } from 'constants/queryBuilder';
@@ -20,31 +19,27 @@ import {
InfraMonitoringEntity,
} from '../constants';
import {
useInfraMonitoringFiltersK8s,
useInfraMonitoringCurrentPage,
useInfraMonitoringFilters,
useInfraMonitoringGroupBy,
useInfraMonitoringPageListing,
} from '../hooks';
import K8sFiltersSidePanel from './K8sFiltersSidePanel';
import styles from './K8sHeader.module.scss';
interface K8sHeaderProps<TData> {
interface K8sHeaderProps {
controlListPrefix?: React.ReactNode;
entity: InfraMonitoringEntity;
showAutoRefresh: boolean;
columns: TableColumnDef<TData>[];
columnStorageKey: string;
}
function K8sHeader<TData>({
function K8sHeader({
controlListPrefix,
entity,
showAutoRefresh,
columns,
columnStorageKey,
}: K8sHeaderProps<TData>): JSX.Element {
}: K8sHeaderProps): JSX.Element {
const [isFiltersSidePanelOpen, setIsFiltersSidePanelOpen] = useState(false);
const [urlFilters, setUrlFilters] = useInfraMonitoringFiltersK8s();
const [urlFilters, setUrlFilters] = useInfraMonitoringFilters();
const currentQuery = initialQueriesMap[DataSource.METRICS];
@@ -82,7 +77,7 @@ function K8sHeader<TData>({
entityVersion: '',
});
const [, setCurrentPage] = useInfraMonitoringPageListing();
const [, setCurrentPage] = useInfraMonitoringCurrentPage();
const handleChangeTagFilters = useCallback(
(value: IBuilderQuery['filters']) => {
setUrlFilters(value || null);
@@ -212,6 +207,7 @@ function K8sHeader<TData>({
variant="ghost"
size="icon"
color="none"
disabled={groupBy?.length > 0}
data-testid="k8s-list-filters-button"
onClick={(): void => setIsFiltersSidePanelOpen(true)}
>
@@ -221,8 +217,7 @@ function K8sHeader<TData>({
<K8sFiltersSidePanel
open={isFiltersSidePanelOpen}
columns={columns}
storageKey={columnStorageKey}
entity={entity}
onClose={onClickOutside}
/>
</div>

View File

@@ -13,14 +13,15 @@ export type K8sBaseFilters = {
orderBy?: OrderBySchemaType;
};
/**
* Type for table row data with required key fields.
* Used when rendering raw data in the table.
*/
export type K8sTableRowData<T> = T & {
export type K8sRenderedRowData = {
/**
* The unique ID for the row
*/
key: string;
id: string;
/**
* The ID to the selectedItem
*/
itemKey: string;
/** Metadata about which attributes were used for grouping */
groupedByMeta?: Record<string, string>;
groupedByMeta: Record<string, string>;
[key: string]: unknown;
};

View File

@@ -22,32 +22,30 @@ const dotToUnder: Record<string, string> = {
'k8s.persistentvolumeclaim.name': 'k8s_persistentvolumeclaim_name',
};
export function getGroupedByMeta<T extends { meta?: Record<string, string> }>(
export function getGroupedByMeta<T extends { meta: Record<string, string> }>(
itemData: T,
groupBy: BaseAutocompleteData[],
): Record<string, string> {
const result: Record<string, string> = {};
const meta = itemData.meta ?? {};
groupBy.forEach((group) => {
const rawKey = group.key as string;
const metaKey = (dotToUnder[rawKey] ?? rawKey) as keyof typeof meta;
result[rawKey] = (meta[metaKey] || meta[rawKey]) ?? '';
const metaKey = (dotToUnder[rawKey] ?? rawKey) as keyof typeof itemData.meta;
result[rawKey] = (itemData.meta[metaKey] || itemData.meta[rawKey]) ?? '';
});
return result;
}
export function getRowKey<T extends { meta?: Record<string, string> }>(
export function getRowKey<T extends { meta: Record<string, string> }>(
itemData: T,
getItemIdentifier: () => string,
groupBy: BaseAutocompleteData[],
): string {
const nodeIdentifier = getItemIdentifier();
const meta = itemData.meta ?? {};
if (groupBy.length === 0) {
return nodeIdentifier || JSON.stringify(meta);
return nodeIdentifier || JSON.stringify(itemData.meta);
}
const groupedMeta = getGroupedByMeta(itemData, groupBy);
@@ -63,32 +61,30 @@ export function getRowKey<T extends { meta?: Record<string, string> }>(
return nodeIdentifier;
}
return JSON.stringify(meta);
return JSON.stringify(itemData.meta);
}
export function getGroupByEl<T extends { meta?: Record<string, string> }>(
export function getGroupByEl<T extends { meta: Record<string, string> }>(
itemData: T,
groupBy: IBuilderQuery['groupBy'],
): React.ReactNode {
const groupByValues: string[] = [];
const meta = itemData.meta ?? {};
groupBy.forEach((group) => {
const rawKey = group.key as string;
// Choose mapped key if present, otherwise use rawKey
const metaKey = (dotToUnder[rawKey] ?? rawKey) as keyof typeof meta;
const value = meta[metaKey] || meta[rawKey] || '<no-value>';
const metaKey = (dotToUnder[rawKey] ?? rawKey) as keyof typeof itemData.meta;
const value = itemData.meta[metaKey] || itemData.meta[rawKey] || '<no-value>';
groupByValues.push(value);
});
return (
<div className={styles.itemDataGroup}>
{groupByValues.map((value, index) => (
{groupByValues.map((value) => (
<Badge
// oxlint-disable-next-line react/no-array-index-key
key={`${index}-${value}`}
key={value}
color="secondary"
className={styles.itemDataGroupTagItem}
>

View File

@@ -19,9 +19,9 @@ import {
k8sClusterInitialLogTracesFilter,
} from './constants';
import {
getK8sClusterItemKey,
getK8sClusterRowKey,
k8sClustersColumns,
k8sClustersColumnsConfig,
k8sClustersRenderRowData,
} from './table.config';
function K8sClustersList({
@@ -91,10 +91,10 @@ function K8sClustersList({
<K8sBaseList<K8sClusterData>
controlListPrefix={controlListPrefix}
entity={InfraMonitoringEntity.CLUSTERS}
tableColumnsDefinitions={k8sClustersColumns}
tableColumns={k8sClustersColumnsConfig}
fetchListData={fetchListData}
getRowKey={getK8sClusterRowKey}
getItemKey={getK8sClusterItemKey}
renderRowData={k8sClustersRenderRowData}
eventCategory={InfraMonitoringEvents.Cluster}
/>

View File

@@ -1,26 +1,77 @@
import { Tooltip } from 'antd';
import { TableColumnDef } from 'components/TanStackTableView';
import TanStackTable from 'components/TanStackTableView';
import { ExpandButtonWrapper } from 'container/InfraMonitoringK8s/components';
import { TableColumnType as ColumnType, Tooltip } from 'antd';
import { Group } from 'lucide-react';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import EntityGroupHeader from '../Base/EntityGroupHeader';
import K8sGroupCell from '../Base/K8sGroupCell';
import { formatBytes } from '../commonUtils';
import { ValidateColumnValueWrapper } from '../components';
import { K8sRenderedRowData } from '../Base/types';
import { IEntityColumn } from '../Base/useInfraMonitoringTableColumnsStore';
import { getGroupByEl, getGroupedByMeta, getRowKey } from '../Base/utils';
import { formatBytes, ValidateColumnValueWrapper } from '../commonUtils';
import { K8sClusterData, K8sClustersListPayload } from './api';
import { Boxes } from 'lucide-react';
export function getK8sClusterRowKey(cluster: K8sClusterData): string {
return (
cluster.clusterUID ||
cluster.meta.k8s_cluster_uid ||
cluster.meta.k8s_cluster_name
);
import styles from './table.module.scss';
export interface K8sClustersRowData {
key: string;
itemKey: string;
clusterUID: string;
clusterName: React.ReactNode;
cpu: React.ReactNode;
cpu_allocatable: React.ReactNode;
memory: React.ReactNode;
memory_allocatable: React.ReactNode;
groupedByMeta?: Record<string, string>;
}
export function getK8sClusterItemKey(cluster: K8sClusterData): string {
return cluster.meta.k8s_cluster_name;
}
export const k8sClustersColumns: IEntityColumn[] = [
{
label: 'Cluster Group',
value: 'clusterGroup',
id: 'clusterGroup',
canBeHidden: false,
defaultVisibility: true,
behavior: 'hidden-on-collapse',
},
{
label: 'Cluster Name',
value: 'clusterName',
id: 'clusterName',
canBeHidden: false,
defaultVisibility: true,
behavior: 'hidden-on-expand',
},
{
label: 'CPU Usage (cores)',
value: 'cpu',
id: 'cpu',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'CPU Alloc (cores)',
value: 'cpu_allocatable',
id: 'cpu_allocatable',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Memory Usage (WSS)',
value: 'memory',
id: 'memory',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Memory Alloc (bytes)',
value: 'memory_allocatable',
id: 'memory_allocatable',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
];
export const getK8sClustersListQuery = (): K8sClustersListPayload => ({
filters: {
@@ -30,110 +81,103 @@ export const getK8sClustersListQuery = (): K8sClustersListPayload => ({
orderBy: { columnName: 'cpu', order: 'desc' },
});
export const k8sClustersColumnsConfig: TableColumnDef<K8sClusterData>[] = [
export const k8sClustersColumnsConfig: ColumnType<K8sRenderedRowData>[] = [
{
id: 'clusterGroup',
header: (): React.ReactNode => <EntityGroupHeader title="CLUSTER GROUP" />,
accessorFn: (row): string => row.meta.k8s_cluster_name || '',
width: { min: 300 },
enableSort: false,
enableRemove: false,
enableMove: false,
pin: 'left',
visibilityBehavior: 'hidden-on-collapse',
cell: ({ isExpanded, toggleExpanded, row }): JSX.Element | null => {
return (
<ExpandButtonWrapper
isExpanded={isExpanded}
toggleExpanded={toggleExpanded}
>
<K8sGroupCell row={row} />
</ExpandButtonWrapper>
);
},
},
{
id: 'clusterName',
header: (): React.ReactNode => (
<EntityGroupHeader
title="Cluster Name"
icon={<Boxes data-hide-expanded="true" size={14} />}
/>
title: (
<div className={styles.entityGroupHeader}>
<Group size={14} /> CLUSTER GROUP
</div>
),
accessorFn: (row): string => row.meta.k8s_cluster_name || '',
width: { min: 290 },
enableSort: false,
enableRemove: false,
enableMove: false,
pin: 'left',
visibilityBehavior: 'hidden-on-expand',
cell: ({ value }): React.ReactNode => {
const clusterName = value as string;
return (
<Tooltip title={clusterName}>
<TanStackTable.Text>{clusterName}</TanStackTable.Text>
</Tooltip>
);
},
dataIndex: 'clusterGroup',
key: 'clusterGroup',
ellipsis: true,
width: 150,
align: 'left',
sorter: false,
},
{
id: 'cpu',
header: 'CPU Usage (cores)',
accessorFn: (row): number => row.cpuUsage,
width: { min: 220 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const cpu = value as number;
return (
<ValidateColumnValueWrapper value={cpu}>
<TanStackTable.Text>{cpu}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
title: <div>Cluster Name</div>,
dataIndex: 'clusterName',
key: 'clusterName',
ellipsis: true,
width: 150,
sorter: false,
align: 'left',
},
{
id: 'cpu_allocatable',
header: 'CPU Alloc (cores)',
accessorFn: (row): number => row.cpuAllocatable,
width: { min: 220 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const cpuAllocatable = value as number;
return (
<ValidateColumnValueWrapper value={cpuAllocatable}>
<TanStackTable.Text>{cpuAllocatable}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
title: <div>CPU Usage (cores)</div>,
dataIndex: 'cpu',
key: 'cpu',
width: 80,
sorter: true,
align: 'left',
},
{
id: 'memory',
header: 'Memory Usage (WSS)',
accessorFn: (row): number => row.memoryUsage,
width: { min: 220 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const memory = value as number;
return (
<ValidateColumnValueWrapper value={memory}>
<TanStackTable.Text>{formatBytes(memory)}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
title: <div>CPU Alloc (cores)</div>,
dataIndex: 'cpu_allocatable',
key: 'cpu_allocatable',
width: 80,
sorter: true,
align: 'left',
},
{
id: 'memory_allocatable',
header: 'Memory Allocatable',
accessorFn: (row): number => row.memoryAllocatable,
width: { min: 220 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const memoryAllocatable = value as number;
return (
<ValidateColumnValueWrapper value={memoryAllocatable}>
<TanStackTable.Text>{formatBytes(memoryAllocatable)}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
title: <div>Memory Usage (WSS)</div>,
dataIndex: 'memory',
key: 'memory',
width: 80,
sorter: true,
align: 'left',
},
{
title: <div>Memory Allocatable</div>,
dataIndex: 'memory_allocatable',
key: 'memory_allocatable',
width: 80,
sorter: true,
align: 'left',
},
];
export const k8sClustersRenderRowData = (
cluster: K8sClusterData,
groupBy: BaseAutocompleteData[],
): K8sRenderedRowData => ({
key: getRowKey(
cluster,
() =>
cluster.clusterUID ||
cluster.meta.k8s_cluster_uid ||
cluster.meta.k8s_cluster_name,
groupBy,
),
itemKey: cluster.meta.k8s_cluster_name,
clusterUID: cluster.clusterUID || cluster.meta.k8s_cluster_uid,
clusterName: (
<Tooltip title={cluster.meta.k8s_cluster_name}>
{cluster.meta.k8s_cluster_name || ''}
</Tooltip>
),
cpu: (
<ValidateColumnValueWrapper value={cluster.cpuUsage}>
{cluster.cpuUsage}
</ValidateColumnValueWrapper>
),
memory: (
<ValidateColumnValueWrapper value={cluster.memoryUsage}>
{formatBytes(cluster.memoryUsage)}
</ValidateColumnValueWrapper>
),
cpu_allocatable: (
<ValidateColumnValueWrapper value={cluster.cpuAllocatable}>
{cluster.cpuAllocatable}
</ValidateColumnValueWrapper>
),
memory_allocatable: (
<ValidateColumnValueWrapper value={cluster.memoryAllocatable}>
{formatBytes(cluster.memoryAllocatable)}
</ValidateColumnValueWrapper>
),
clusterGroup: getGroupByEl(cluster, groupBy),
...cluster.meta,
groupedByMeta: getGroupedByMeta(cluster, groupBy),
});

View File

@@ -1,5 +1,6 @@
.entityGroupHeader {
display: flex;
align-items: center;
padding-left: var(--spacing-5);
gap: var(--spacing-5);
}

View File

@@ -19,9 +19,9 @@ import {
k8sDaemonSetInitialLogTracesFilter,
} from './constants';
import {
getK8sDaemonSetItemKey,
getK8sDaemonSetRowKey,
k8sDaemonSetsColumns,
k8sDaemonSetsColumnsConfig,
k8sDaemonSetsRenderRowData,
} from './table.config';
function K8sDaemonSetsList({
@@ -91,10 +91,10 @@ function K8sDaemonSetsList({
<K8sBaseList<K8sDaemonSetsData>
controlListPrefix={controlListPrefix}
entity={InfraMonitoringEntity.DAEMONSETS}
tableColumnsDefinitions={k8sDaemonSetsColumns}
tableColumns={k8sDaemonSetsColumnsConfig}
fetchListData={fetchListData}
getRowKey={getK8sDaemonSetRowKey}
getItemKey={getK8sDaemonSetItemKey}
renderRowData={k8sDaemonSetsRenderRowData}
eventCategory={InfraMonitoringEvents.DaemonSet}
/>

View File

@@ -1,225 +1,297 @@
import { Tooltip } from 'antd';
import { TableColumnDef } from 'components/TanStackTableView';
import TanStackTable from 'components/TanStackTableView';
import { ExpandButtonWrapper } from 'container/InfraMonitoringK8s/components';
import { TableColumnType as ColumnType, Tooltip } from 'antd';
import { Group } from 'lucide-react';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import EntityGroupHeader from '../Base/EntityGroupHeader';
import K8sGroupCell from '../Base/K8sGroupCell';
import { formatBytes } from '../commonUtils';
import { EntityProgressBar, ValidateColumnValueWrapper } from '../components';
import { K8sRenderedRowData } from '../Base/types';
import { IEntityColumn } from '../Base/useInfraMonitoringTableColumnsStore';
import { getGroupByEl, getGroupedByMeta, getRowKey } from '../Base/utils';
import {
EntityProgressBar,
formatBytes,
ValidateColumnValueWrapper,
} from '../commonUtils';
import { InfraMonitoringEntity } from '../constants';
import { K8sDaemonSetsData } from './api';
import { Group } from 'lucide-react';
export function getK8sDaemonSetRowKey(daemonSet: K8sDaemonSetsData): string {
return (
daemonSet.daemonSetName ||
daemonSet.meta.k8s_daemonset_name ||
`${daemonSet.meta.k8s_namespace_name}-${daemonSet.meta.k8s_daemonset_name}`
);
}
import styles from './table.module.scss';
export function getK8sDaemonSetItemKey(daemonSet: K8sDaemonSetsData): string {
return daemonSet.meta.k8s_daemonset_name;
}
export const k8sDaemonSetsColumnsConfig: TableColumnDef<K8sDaemonSetsData>[] = [
export const k8sDaemonSetsColumns: IEntityColumn[] = [
{
label: 'DaemonSet Group',
value: 'daemonSetGroup',
id: 'daemonSetGroup',
header: (): React.ReactNode => <EntityGroupHeader title="DAEMONSET GROUP" />,
accessorFn: (row): string => row.meta.k8s_daemonset_name || '',
width: { min: 300 },
enableSort: false,
enableRemove: false,
enableMove: false,
pin: 'left',
visibilityBehavior: 'hidden-on-collapse',
cell: ({ isExpanded, toggleExpanded, row }): JSX.Element | null => {
return (
<ExpandButtonWrapper
isExpanded={isExpanded}
toggleExpanded={toggleExpanded}
>
<K8sGroupCell row={row} />
</ExpandButtonWrapper>
);
},
canBeHidden: false,
defaultVisibility: true,
behavior: 'hidden-on-collapse',
},
{
label: 'DaemonSet Name',
value: 'daemonsetName',
id: 'daemonsetName',
header: (): React.ReactNode => (
<EntityGroupHeader
title="DaemonSet Name"
icon={<Group data-hide-expanded="true" size={14} />}
/>
),
accessorFn: (row): string => row.meta.k8s_daemonset_name || '',
width: { min: 290 },
enableSort: false,
enableRemove: false,
enableMove: false,
pin: 'left',
visibilityBehavior: 'hidden-on-expand',
cell: ({ value }): React.ReactNode => {
const daemonsetName = value as string;
return (
<Tooltip title={daemonsetName}>
<TanStackTable.Text>{daemonsetName}</TanStackTable.Text>
</Tooltip>
);
},
canBeHidden: false,
defaultVisibility: true,
behavior: 'hidden-on-expand',
},
{
label: 'Namespace Name',
value: 'namespaceName',
id: 'namespaceName',
header: 'Namespace Name',
accessorFn: (row): string => row.meta.k8s_namespace_name || '',
width: { default: 100 },
enableSort: false,
cell: ({ value }): React.ReactNode => {
const namespaceName = value as string;
return (
<Tooltip title={namespaceName}>
<TanStackTable.Text>{namespaceName}</TanStackTable.Text>
</Tooltip>
);
},
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Available',
value: 'available_nodes',
id: 'available_nodes',
header: 'Available',
accessorFn: (row): number => row.availableNodes,
width: { min: 140 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const availableNodes = value as number;
return (
<ValidateColumnValueWrapper value={availableNodes}>
<TanStackTable.Text>{availableNodes}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Desired',
value: 'desired_nodes',
id: 'desired_nodes',
header: 'Desired',
accessorFn: (row): number => row.desiredNodes,
width: { min: 140 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const desiredNodes = value as number;
return (
<ValidateColumnValueWrapper value={desiredNodes}>
<TanStackTable.Text>{desiredNodes}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'CPU Req Usage (%)',
value: 'cpu_request',
id: 'cpu_request',
header: 'CPU Req Usage (%)',
accessorFn: (row): number => row.cpuRequest,
width: { min: 200, default: 200 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const cpuRequest = value as number;
return (
<ValidateColumnValueWrapper
value={cpuRequest}
entity={InfraMonitoringEntity.DAEMONSETS}
attribute="CPU Request"
>
<EntityProgressBar value={cpuRequest} type="request" />
</ValidateColumnValueWrapper>
);
},
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'CPU Limit Usage (%)',
value: 'cpu_limit',
id: 'cpu_limit',
header: 'CPU Limit Usage (%)',
accessorFn: (row): number => row.cpuLimit,
width: { min: 200, default: 200 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const cpuLimit = value as number;
return (
<ValidateColumnValueWrapper
value={cpuLimit}
entity={InfraMonitoringEntity.DAEMONSETS}
attribute="CPU Limit"
>
<EntityProgressBar value={cpuLimit} type="limit" />
</ValidateColumnValueWrapper>
);
},
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'CPU Usage (cores)',
value: 'cpu',
id: 'cpu',
header: 'CPU Usage (cores)',
accessorFn: (row): number => row.cpuUsage,
width: { min: 190 },
enableSort: true,
defaultVisibility: false,
cell: ({ value }): React.ReactNode => {
const cpu = value as number;
return (
<ValidateColumnValueWrapper value={cpu}>
<TanStackTable.Text>{cpu}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Mem Req Usage (%)',
value: 'memory_request',
id: 'memory_request',
header: 'Mem Req Usage (%)',
accessorFn: (row): number => row.memoryRequest,
width: { min: 190 },
enableSort: true,
defaultVisibility: false,
cell: ({ value }): React.ReactNode => {
const memoryRequest = value as number;
return (
<ValidateColumnValueWrapper
value={memoryRequest}
entity={InfraMonitoringEntity.DAEMONSETS}
attribute="Memory Request"
>
<EntityProgressBar value={memoryRequest} type="request" />
</ValidateColumnValueWrapper>
);
},
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Mem Limit Usage (%)',
value: 'memory_limit',
id: 'memory_limit',
header: 'Mem Limit Usage (%)',
accessorFn: (row): number => row.memoryLimit,
width: { min: 180 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const memoryLimit = value as number;
return (
<ValidateColumnValueWrapper
value={memoryLimit}
entity={InfraMonitoringEntity.DAEMONSETS}
attribute="Memory Limit"
>
<EntityProgressBar value={memoryLimit} type="limit" />
</ValidateColumnValueWrapper>
);
},
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Mem Usage (WSS)',
value: 'memory',
id: 'memory',
header: 'Mem Usage (WSS)',
accessorFn: (row): number => row.memoryUsage,
width: { min: 160 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const memory = value as number;
return (
<ValidateColumnValueWrapper value={memory}>
<TanStackTable.Text>{formatBytes(memory)}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
];
export const k8sDaemonSetsColumnsConfig: ColumnType<K8sRenderedRowData>[] = [
{
title: (
<div className={styles.entityGroupHeader}>
<Group size={14} /> DAEMONSET GROUP
</div>
),
dataIndex: 'daemonSetGroup',
key: 'daemonSetGroup',
ellipsis: true,
width: 150,
align: 'left',
sorter: false,
},
{
title: <div>DaemonSet Name</div>,
dataIndex: 'daemonsetName',
key: 'daemonsetName',
ellipsis: true,
width: 150,
sorter: false,
align: 'left',
},
{
title: <div>Namespace Name</div>,
dataIndex: 'namespaceName',
key: 'namespaceName',
ellipsis: true,
width: 80,
sorter: false,
align: 'left',
},
{
title: <div>Available</div>,
dataIndex: 'available_nodes',
key: 'available_nodes',
width: 50,
ellipsis: true,
sorter: true,
align: 'left',
},
{
title: <div>Desired</div>,
dataIndex: 'desired_nodes',
key: 'desired_nodes',
width: 50,
sorter: true,
align: 'left',
},
{
title: <div>CPU Req Usage (%)</div>,
dataIndex: 'cpu_request',
key: 'cpu_request',
width: 180,
ellipsis: true,
sorter: true,
align: 'left',
},
{
title: <div>CPU Limit Usage (%)</div>,
dataIndex: 'cpu_limit',
key: 'cpu_limit',
width: 120,
sorter: true,
align: 'left',
},
{
title: <div>CPU Usage (cores)</div>,
dataIndex: 'cpu',
key: 'cpu',
width: 80,
sorter: true,
align: 'left',
},
{
title: <div>Mem Req Usage (%)</div>,
dataIndex: 'memory_request',
key: 'memory_request',
width: 170,
sorter: true,
align: 'left',
},
{
title: <div>Mem Limit Usage (%)</div>,
dataIndex: 'memory_limit',
key: 'memory_limit',
width: 120,
sorter: true,
align: 'left',
},
{
title: <div>Mem Usage (WSS)</div>,
dataIndex: 'memory',
key: 'memory',
width: 120,
ellipsis: true,
sorter: true,
align: 'left',
},
];
export const k8sDaemonSetsRenderRowData = (
entity: K8sDaemonSetsData,
groupBy: BaseAutocompleteData[],
): K8sRenderedRowData => ({
key: getRowKey(
entity,
() => entity.daemonSetName || entity.meta.k8s_daemonset_name || '',
groupBy,
),
itemKey: entity.meta.k8s_daemonset_name,
daemonsetName: (
<Tooltip title={entity.meta.k8s_daemonset_name}>
{entity.meta.k8s_daemonset_name || ''}
</Tooltip>
),
namespaceName: (
<Tooltip title={entity.meta.k8s_namespace_name}>
{entity.meta.k8s_namespace_name || ''}
</Tooltip>
),
cpu_request: (
<ValidateColumnValueWrapper
value={entity.cpuRequest}
entity={InfraMonitoringEntity.DAEMONSETS}
attribute="CPU Request"
>
<div className={styles.progressBar}>
<EntityProgressBar value={entity.cpuRequest} type="request" />
</div>
</ValidateColumnValueWrapper>
),
cpu_limit: (
<ValidateColumnValueWrapper
value={entity.cpuLimit}
entity={InfraMonitoringEntity.DAEMONSETS}
attribute="CPU Limit"
>
<div className={styles.progressBar}>
<EntityProgressBar value={entity.cpuLimit} type="limit" />
</div>
</ValidateColumnValueWrapper>
),
cpu: (
<ValidateColumnValueWrapper value={entity.cpuUsage}>
{entity.cpuUsage}
</ValidateColumnValueWrapper>
),
memory_request: (
<ValidateColumnValueWrapper
value={entity.memoryRequest}
entity={InfraMonitoringEntity.DAEMONSETS}
attribute="Memory Request"
>
<div className={styles.progressBar}>
<EntityProgressBar value={entity.memoryRequest} type="request" />
</div>
</ValidateColumnValueWrapper>
),
memory_limit: (
<ValidateColumnValueWrapper
value={entity.memoryLimit}
entity={InfraMonitoringEntity.DAEMONSETS}
attribute="Memory Limit"
>
<div className={styles.progressBar}>
<EntityProgressBar value={entity.memoryLimit} type="limit" />
</div>
</ValidateColumnValueWrapper>
),
memory: (
<ValidateColumnValueWrapper value={entity.memoryUsage}>
{formatBytes(entity.memoryUsage)}
</ValidateColumnValueWrapper>
),
available_nodes: (
<ValidateColumnValueWrapper value={entity.availableNodes}>
{entity.availableNodes}
</ValidateColumnValueWrapper>
),
desired_nodes: (
<ValidateColumnValueWrapper value={entity.desiredNodes}>
{entity.desiredNodes}
</ValidateColumnValueWrapper>
),
daemonSetGroup: getGroupByEl(entity, groupBy),
...entity.meta,
groupedByMeta: getGroupedByMeta(entity, groupBy),
});

View File

@@ -0,0 +1,11 @@
.entityGroupHeader {
padding-left: var(--spacing-5);
gap: var(--spacing-5);
display: flex;
align-items: center;
}
.progressBar {
display: flex;
justify-content: start;
}

View File

@@ -19,9 +19,9 @@ import {
k8sDeploymentInitialLogTracesFilter,
} from './constants';
import {
getK8sDeploymentItemKey,
getK8sDeploymentRowKey,
k8sDeploymentsColumns,
k8sDeploymentsColumnsConfig,
k8sDeploymentsRenderRowData,
} from './table.config';
function K8sDeploymentsList({
@@ -91,10 +91,10 @@ function K8sDeploymentsList({
<K8sBaseList<K8sDeploymentsData>
controlListPrefix={controlListPrefix}
entity={InfraMonitoringEntity.DEPLOYMENTS}
tableColumnsDefinitions={k8sDeploymentsColumns}
tableColumns={k8sDeploymentsColumnsConfig}
fetchListData={fetchListData}
getRowKey={getK8sDeploymentRowKey}
getItemKey={getK8sDeploymentItemKey}
renderRowData={k8sDeploymentsRenderRowData}
eventCategory={InfraMonitoringEvents.Deployment}
/>

View File

@@ -1,230 +1,269 @@
import { Tooltip } from 'antd';
import { TableColumnDef } from 'components/TanStackTableView';
import TanStackTable from 'components/TanStackTableView';
import { ExpandButtonWrapper } from 'container/InfraMonitoringK8s/components';
import { TableColumnType as ColumnType, Tooltip } from 'antd';
import { Group } from 'lucide-react';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import EntityGroupHeader from '../Base/EntityGroupHeader';
import K8sGroupCell from '../Base/K8sGroupCell';
import { formatBytes } from '../commonUtils';
import { EntityProgressBar, ValidateColumnValueWrapper } from '../components';
import { InfraMonitoringEntity } from '../constants';
import { K8sRenderedRowData } from '../Base/types';
import { IEntityColumn } from '../Base/useInfraMonitoringTableColumnsStore';
import { getGroupByEl, getGroupedByMeta, getRowKey } from '../Base/utils';
import {
EntityProgressBar,
formatBytes,
ValidateColumnValueWrapper,
} from '../commonUtils';
import { K8sDeploymentsData } from './api';
import { Computer } from 'lucide-react';
export function getK8sDeploymentRowKey(deployment: K8sDeploymentsData): string {
return deployment.meta.k8s_deployment_name || deployment.deploymentName;
}
import styles from './table.module.scss';
export function getK8sDeploymentItemKey(
export const k8sDeploymentsColumns: IEntityColumn[] = [
{
label: 'Deployment Group',
value: 'deploymentGroup',
id: 'deploymentGroup',
canBeHidden: false,
defaultVisibility: true,
behavior: 'hidden-on-collapse',
},
{
label: 'Deployment Name',
value: 'deploymentName',
id: 'deploymentName',
canBeHidden: false,
defaultVisibility: true,
behavior: 'hidden-on-expand',
},
{
label: 'Namespace Name',
value: 'namespaceName',
id: 'namespaceName',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Available',
value: 'available_pods',
id: 'available_pods',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Desired',
value: 'desired_pods',
id: 'desired_pods',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'CPU Req Usage (%)',
value: 'cpu_request',
id: 'cpu_request',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'CPU Limit Usage (%)',
value: 'cpu_limit',
id: 'cpu_limit',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'CPU Usage (cores)',
value: 'cpu',
id: 'cpu',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Mem Req Usage (%)',
value: 'memory_request',
id: 'memory_request',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Mem Limit Usage (%)',
value: 'memory_limit',
id: 'memory_limit',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Mem Usage (WSS)',
value: 'memory',
id: 'memory',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
];
export const k8sDeploymentsColumnsConfig: ColumnType<K8sRenderedRowData>[] = [
{
title: (
<div className={styles.entityGroupHeader}>
<Group size={14} /> DEPLOYMENT GROUP
</div>
),
dataIndex: 'deploymentGroup',
key: 'deploymentGroup',
ellipsis: true,
width: 150,
align: 'left',
sorter: false,
},
{
title: <div>Deployment Name</div>,
dataIndex: 'deploymentName',
key: 'deploymentName',
ellipsis: true,
width: 150,
sorter: false,
align: 'left',
},
{
title: <div>Namespace Name</div>,
dataIndex: 'namespaceName',
key: 'namespaceName',
ellipsis: true,
width: 150,
sorter: false,
align: 'left',
},
{
title: <div>Available</div>,
dataIndex: 'available_pods',
key: 'available_pods',
width: 100,
sorter: false,
align: 'left',
},
{
title: <div>Desired</div>,
dataIndex: 'desired_pods',
key: 'desired_pods',
width: 80,
sorter: false,
align: 'left',
},
{
title: <div>CPU Req Usage (%)</div>,
dataIndex: 'cpu_request',
key: 'cpu_request',
width: 170,
sorter: true,
align: 'left',
},
{
title: <div>CPU Limit Usage (%)</div>,
dataIndex: 'cpu_limit',
key: 'cpu_limit',
width: 170,
sorter: true,
align: 'left',
},
{
title: <div>CPU Usage (cores)</div>,
dataIndex: 'cpu',
key: 'cpu',
width: 80,
sorter: true,
align: 'left',
},
{
title: <div>Mem Req Usage (%)</div>,
dataIndex: 'memory_request',
key: 'memory_request',
width: 170,
sorter: true,
align: 'left',
},
{
title: <div>Mem Limit Usage (%)</div>,
dataIndex: 'memory_limit',
key: 'memory_limit',
width: 170,
sorter: true,
align: 'left',
},
{
title: <div>Mem Usage (WSS)</div>,
dataIndex: 'memory',
key: 'memory',
width: 120,
sorter: true,
align: 'left',
},
];
export const k8sDeploymentsRenderRowData = (
deployment: K8sDeploymentsData,
): string {
return deployment.meta.k8s_deployment_name;
}
export const k8sDeploymentsColumnsConfig: TableColumnDef<K8sDeploymentsData>[] =
[
{
id: 'deploymentGroup',
header: (): React.ReactNode => (
<EntityGroupHeader title="DEPLOYMENT GROUP" />
),
accessorFn: (row): string => row.meta.k8s_deployment_name || '',
width: { min: 220 },
enableSort: false,
enableRemove: false,
enableMove: false,
pin: 'left',
visibilityBehavior: 'hidden-on-collapse',
cell: ({ isExpanded, toggleExpanded, row }): JSX.Element | null => {
return (
<ExpandButtonWrapper
isExpanded={isExpanded}
toggleExpanded={toggleExpanded}
>
<K8sGroupCell row={row} />
</ExpandButtonWrapper>
);
},
},
{
id: 'deploymentName',
header: (): React.ReactNode => (
<EntityGroupHeader
title="Deployment Name"
icon={<Computer data-hide-expanded="true" size={14} />}
/>
),
accessorFn: (row): string => row.meta.k8s_deployment_name || '',
width: { min: 210 },
enableSort: false,
enableRemove: false,
enableMove: false,
pin: 'left',
visibilityBehavior: 'hidden-on-expand',
cell: ({ value }): React.ReactNode => {
const deploymentName = value as string;
return (
<Tooltip title={deploymentName}>
<TanStackTable.Text>{deploymentName}</TanStackTable.Text>
</Tooltip>
);
},
},
{
id: 'namespaceName',
header: 'Namespace Name',
accessorFn: (row): string => row.meta.k8s_namespace_name || '',
width: { default: 220 },
enableSort: false,
enableResize: true,
cell: ({ value }): React.ReactNode => (
<TanStackTable.Text>{value as string}</TanStackTable.Text>
),
},
{
id: 'available_pods',
header: 'Available',
accessorFn: (row): number => row.availablePods,
width: { min: 100 },
enableSort: false,
enableResize: true,
defaultVisibility: false,
cell: ({ value }): React.ReactNode => {
const availablePods = value as number;
return (
<ValidateColumnValueWrapper value={availablePods}>
<TanStackTable.Text>{availablePods}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
},
{
id: 'desired_pods',
header: 'Desired',
accessorFn: (row): number => row.desiredPods,
width: { min: 80 },
enableSort: false,
enableResize: true,
defaultVisibility: false,
cell: ({ value }): React.ReactNode => {
const desiredPods = value as number;
return (
<ValidateColumnValueWrapper value={desiredPods}>
<TanStackTable.Text>{desiredPods}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
},
{
id: 'cpu_request',
header: 'CPU Req Usage (%)',
accessorFn: (row): number => row.cpuRequest,
width: { min: 210 },
enableSort: true,
enableResize: true,
cell: ({ value }): React.ReactNode => {
const cpuRequest = value as number;
return (
<ValidateColumnValueWrapper
value={cpuRequest}
entity={InfraMonitoringEntity.DEPLOYMENTS}
attribute="CPU Request"
>
<EntityProgressBar value={cpuRequest} type="request" />
</ValidateColumnValueWrapper>
);
},
},
{
id: 'cpu_limit',
header: 'CPU Limit Usage (%)',
accessorFn: (row): number => row.cpuLimit,
width: { min: 210 },
enableSort: true,
enableResize: true,
cell: ({ value }): React.ReactNode => {
const cpuLimit = value as number;
return (
<ValidateColumnValueWrapper
value={cpuLimit}
entity={InfraMonitoringEntity.DEPLOYMENTS}
attribute="CPU Limit"
>
<EntityProgressBar value={cpuLimit} type="limit" />
</ValidateColumnValueWrapper>
);
},
},
{
id: 'cpu',
header: 'CPU Usage (cores)',
accessorFn: (row): number => row.cpuUsage,
width: { min: 210 },
enableSort: true,
enableResize: true,
cell: ({ value }): React.ReactNode => {
const cpu = value as number;
return (
<ValidateColumnValueWrapper value={cpu}>
<TanStackTable.Text>{cpu}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
},
{
id: 'memory_request',
header: 'Mem Req Usage (%)',
accessorFn: (row): number => row.memoryRequest,
width: { min: 210 },
enableSort: true,
enableResize: true,
cell: ({ value }): React.ReactNode => {
const memoryRequest = value as number;
return (
<ValidateColumnValueWrapper
value={memoryRequest}
entity={InfraMonitoringEntity.DEPLOYMENTS}
attribute="Memory Request"
>
<EntityProgressBar value={memoryRequest} type="request" />
</ValidateColumnValueWrapper>
);
},
},
{
id: 'memory_limit',
header: 'Mem Limit Usage (%)',
accessorFn: (row): number => row.memoryLimit,
width: { min: 210 },
enableSort: true,
enableResize: true,
cell: ({ value }): React.ReactNode => {
const memoryLimit = value as number;
return (
<ValidateColumnValueWrapper
value={memoryLimit}
entity={InfraMonitoringEntity.DEPLOYMENTS}
attribute="Memory Limit"
>
<EntityProgressBar value={memoryLimit} type="limit" />
</ValidateColumnValueWrapper>
);
},
},
{
id: 'memory',
header: 'Mem Usage (WSS)',
accessorFn: (row): number => row.memoryUsage,
width: { min: 140 },
enableSort: true,
enableResize: true,
cell: ({ value }): React.ReactNode => {
const memory = value as number;
return (
<ValidateColumnValueWrapper value={memory}>
<TanStackTable.Text>{formatBytes(memory)}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
},
];
groupBy: BaseAutocompleteData[],
): K8sRenderedRowData => ({
key: getRowKey(deployment, () => deployment.meta.k8s_deployment_name, groupBy),
itemKey: deployment.meta.k8s_deployment_name,
deploymentName: (
<Tooltip title={deployment.meta.k8s_deployment_name}>
{deployment.meta.k8s_deployment_name || ''}
</Tooltip>
),
namespaceName: deployment.meta.k8s_namespace_name,
available_pods: (
<ValidateColumnValueWrapper value={deployment.availablePods}>
{deployment.availablePods}
</ValidateColumnValueWrapper>
),
desired_pods: (
<ValidateColumnValueWrapper value={deployment.desiredPods}>
{deployment.desiredPods}
</ValidateColumnValueWrapper>
),
cpu: (
<ValidateColumnValueWrapper value={deployment.cpuUsage}>
{deployment.cpuUsage}
</ValidateColumnValueWrapper>
),
cpu_request: (
<ValidateColumnValueWrapper value={deployment.cpuRequest}>
<div className={styles.progressBar}>
<EntityProgressBar value={deployment.cpuRequest} type="request" />
</div>
</ValidateColumnValueWrapper>
),
cpu_limit: (
<ValidateColumnValueWrapper value={deployment.cpuLimit}>
<div className={styles.progressBar}>
<EntityProgressBar value={deployment.cpuLimit} type="limit" />
</div>
</ValidateColumnValueWrapper>
),
memory: (
<ValidateColumnValueWrapper value={deployment.memoryUsage}>
{formatBytes(deployment.memoryUsage)}
</ValidateColumnValueWrapper>
),
memory_request: (
<ValidateColumnValueWrapper value={deployment.memoryRequest}>
<div className={styles.progressBar}>
<EntityProgressBar value={deployment.memoryRequest} type="request" />
</div>
</ValidateColumnValueWrapper>
),
memory_limit: (
<ValidateColumnValueWrapper value={deployment.memoryLimit}>
<div className={styles.progressBar}>
<EntityProgressBar value={deployment.memoryLimit} type="limit" />
</div>
</ValidateColumnValueWrapper>
),
deploymentGroup: getGroupByEl(deployment, groupBy),
...deployment.meta,
groupedByMeta: getGroupedByMeta(deployment, groupBy),
});

View File

@@ -0,0 +1,11 @@
.entityGroupHeader {
padding-left: var(--spacing-5);
gap: var(--spacing-5);
display: flex;
align-items: center;
}
.progressBar {
display: flex;
justify-content: start;
}

View File

@@ -43,7 +43,7 @@ import K8sDaemonSetsList from './DaemonSets/K8sDaemonSetsList';
import K8sDeploymentsList from './Deployments/K8sDeploymentsList';
import {
useInfraMonitoringCategory,
useInfraMonitoringFiltersK8s,
useInfraMonitoringFilters,
useInfraMonitoringGroupBy,
useInfraMonitoringOrderBy,
} from './hooks';
@@ -60,7 +60,7 @@ export default function InfraMonitoringK8s(): JSX.Element {
const [showFilters, setShowFilters] = useState(true);
const [selectedCategory, setSelectedCategory] = useInfraMonitoringCategory();
const [urlFilters, setUrlFilters] = useInfraMonitoringFiltersK8s();
const [urlFilters, setUrlFilters] = useInfraMonitoringFilters();
const [, setGroupBy] = useInfraMonitoringGroupBy();
const [, setOrderBy] = useInfraMonitoringOrderBy();

View File

@@ -19,9 +19,9 @@ import {
k8sJobInitialLogTracesFilter,
} from './constants';
import {
getK8sJobItemKey,
getK8sJobRowKey,
k8sJobsColumns,
k8sJobsColumnsConfig,
k8sJobsRenderRowData,
} from './table.config';
function K8sJobsList({
@@ -91,10 +91,10 @@ function K8sJobsList({
<K8sBaseList<K8sJobsData>
controlListPrefix={controlListPrefix}
entity={InfraMonitoringEntity.JOBS}
tableColumnsDefinitions={k8sJobsColumns}
tableColumns={k8sJobsColumnsConfig}
fetchListData={fetchListData}
getRowKey={getK8sJobRowKey}
getItemKey={getK8sJobItemKey}
renderRowData={k8sJobsRenderRowData}
eventCategory={InfraMonitoringEvents.Job}
/>

View File

@@ -1,249 +1,330 @@
import { Tooltip } from 'antd';
import { TableColumnDef } from 'components/TanStackTableView';
import TanStackTable from 'components/TanStackTableView';
import { ExpandButtonWrapper } from 'container/InfraMonitoringK8s/components';
import { TableColumnType as ColumnType, Tooltip } from 'antd';
import { Group } from 'lucide-react';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import EntityGroupHeader from '../Base/EntityGroupHeader';
import K8sGroupCell from '../Base/K8sGroupCell';
import { formatBytes } from '../commonUtils';
import { EntityProgressBar, ValidateColumnValueWrapper } from '../components';
import { K8sRenderedRowData } from '../Base/types';
import { IEntityColumn } from '../Base/useInfraMonitoringTableColumnsStore';
import { getGroupByEl, getGroupedByMeta, getRowKey } from '../Base/utils';
import {
EntityProgressBar,
formatBytes,
ValidateColumnValueWrapper,
} from '../commonUtils';
import { InfraMonitoringEntity } from '../constants';
import { K8sJobsData } from './api';
import { Bolt } from 'lucide-react';
export function getK8sJobRowKey(job: K8sJobsData): string {
return job.jobName || job.meta.k8s_job_name || '';
}
import styles from './table.module.scss';
export function getK8sJobItemKey(job: K8sJobsData): string {
return job.meta.k8s_job_name;
}
export const k8sJobsColumnsConfig: TableColumnDef<K8sJobsData>[] = [
export const k8sJobsColumns: IEntityColumn[] = [
{
label: 'Job Group',
value: 'jobGroup',
id: 'jobGroup',
header: (): React.ReactNode => <EntityGroupHeader title="JOB GROUP" />,
accessorFn: (row): string => row.meta.k8s_job_name || '',
width: { min: 270 },
enableSort: false,
enableRemove: false,
enableMove: false,
pin: 'left',
visibilityBehavior: 'hidden-on-collapse',
cell: ({ isExpanded, toggleExpanded, row }): JSX.Element | null => {
return (
<ExpandButtonWrapper
isExpanded={isExpanded}
toggleExpanded={toggleExpanded}
>
<K8sGroupCell row={row} />
</ExpandButtonWrapper>
);
},
canBeHidden: false,
defaultVisibility: true,
behavior: 'hidden-on-collapse',
},
{
label: 'Job Name',
value: 'jobName',
id: 'jobName',
header: (): React.ReactNode => (
<EntityGroupHeader
title="Job Name"
icon={<Bolt data-hide-expanded="true" size={14} />}
/>
),
accessorFn: (row): string => row.meta.k8s_job_name || '',
width: { min: 260 },
enableSort: false,
enableRemove: false,
enableMove: false,
pin: 'left',
visibilityBehavior: 'hidden-on-expand',
cell: ({ value }): React.ReactNode => {
const jobName = value as string;
return (
<Tooltip title={jobName}>
<TanStackTable.Text>{jobName}</TanStackTable.Text>
</Tooltip>
);
},
canBeHidden: false,
defaultVisibility: true,
behavior: 'hidden-on-expand',
},
{
label: 'Namespace Name',
value: 'namespaceName',
id: 'namespaceName',
header: 'Namespace Name',
accessorFn: (row): string => row.meta.k8s_namespace_name || '',
width: { default: 150 },
enableSort: false,
cell: ({ value }): React.ReactNode => {
const namespaceName = value as string;
return (
<Tooltip title={namespaceName}>
<TanStackTable.Text>{namespaceName}</TanStackTable.Text>
</Tooltip>
);
},
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Successful',
value: 'successful_pods',
id: 'successful_pods',
header: 'Successful',
accessorFn: (row): number => row.successfulPods,
width: { min: 120 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const successfulPods = value as number;
return (
<ValidateColumnValueWrapper value={successfulPods}>
<TanStackTable.Text>{successfulPods}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Failed',
value: 'failed_pods',
id: 'failed_pods',
header: 'Failed',
accessorFn: (row): number => row.failedPods,
width: { min: 100 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const failedPods = value as number;
return (
<ValidateColumnValueWrapper value={failedPods}>
<TanStackTable.Text>{failedPods}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Desired Successful',
value: 'desired_successful_pods',
id: 'desired_successful_pods',
header: 'Desired Successful',
accessorFn: (row): number => row.desiredSuccessfulPods,
width: { min: 160 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const desiredSuccessfulPods = value as number;
return (
<ValidateColumnValueWrapper value={desiredSuccessfulPods}>
<TanStackTable.Text>{desiredSuccessfulPods}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Active',
value: 'active_pods',
id: 'active_pods',
header: 'Active',
accessorFn: (row): number => row.activePods,
width: { min: 100 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const activePods = value as number;
return (
<ValidateColumnValueWrapper value={activePods}>
<TanStackTable.Text>{activePods}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'CPU Req Usage (%)',
value: 'cpu_request',
id: 'cpu_request',
header: 'CPU Req Usage (%)',
accessorFn: (row): number => row.cpuRequest,
width: { min: 200, default: 200 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const cpuRequest = value as number;
return (
<ValidateColumnValueWrapper
value={cpuRequest}
entity={InfraMonitoringEntity.JOBS}
attribute="CPU Request"
>
<EntityProgressBar value={cpuRequest} type="request" />
</ValidateColumnValueWrapper>
);
},
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'CPU Limit Usage (%)',
value: 'cpu_limit',
id: 'cpu_limit',
header: 'CPU Limit Usage (%)',
accessorFn: (row): number => row.cpuLimit,
width: { min: 200, default: 200 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const cpuLimit = value as number;
return (
<ValidateColumnValueWrapper
value={cpuLimit}
entity={InfraMonitoringEntity.JOBS}
attribute="CPU Limit"
>
<EntityProgressBar value={cpuLimit} type="limit" />
</ValidateColumnValueWrapper>
);
},
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'CPU Usage (cores)',
value: 'cpu',
id: 'cpu',
header: 'CPU Usage (cores)',
accessorFn: (row): number => row.cpuUsage,
width: { min: 190 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const cpu = value as number;
return (
<ValidateColumnValueWrapper value={cpu}>
<TanStackTable.Text>{cpu}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Mem Req Usage (%)',
value: 'memory_request',
id: 'memory_request',
header: 'Mem Req Usage (%)',
accessorFn: (row): number => row.memoryRequest,
width: { min: 190 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const memoryRequest = value as number;
return (
<ValidateColumnValueWrapper
value={memoryRequest}
entity={InfraMonitoringEntity.JOBS}
attribute="Memory Request"
>
<EntityProgressBar value={memoryRequest} type="request" />
</ValidateColumnValueWrapper>
);
},
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Mem Limit Usage (%)',
value: 'memory_limit',
id: 'memory_limit',
header: 'Mem Limit Usage (%)',
accessorFn: (row): number => row.memoryLimit,
width: { min: 180 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const memoryLimit = value as number;
return (
<ValidateColumnValueWrapper
value={memoryLimit}
entity={InfraMonitoringEntity.JOBS}
attribute="Memory Limit"
>
<EntityProgressBar value={memoryLimit} type="limit" />
</ValidateColumnValueWrapper>
);
},
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Mem Usage (WSS)',
value: 'memory',
id: 'memory',
header: 'Mem Usage (WSS)',
accessorFn: (row): number => row.memoryUsage,
width: { min: 160 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const memory = value as number;
return (
<ValidateColumnValueWrapper value={memory}>
<TanStackTable.Text>{formatBytes(memory)}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
];
export const k8sJobsColumnsConfig: ColumnType<K8sRenderedRowData>[] = [
{
title: (
<div className={styles.entityGroupHeader}>
<Group size={14} /> JOB GROUP
</div>
),
dataIndex: 'jobGroup',
key: 'jobGroup',
ellipsis: true,
width: 150,
align: 'left',
sorter: false,
},
{
title: <div>Job Name</div>,
dataIndex: 'jobName',
key: 'jobName',
ellipsis: true,
width: 80,
sorter: false,
align: 'left',
},
{
title: <div>Namespace Name</div>,
dataIndex: 'namespaceName',
key: 'namespaceName',
ellipsis: true,
width: 80,
sorter: false,
align: 'left',
},
{
title: <div>Successful</div>,
dataIndex: 'successful_pods',
key: 'successful_pods',
ellipsis: true,
sorter: true,
align: 'left',
},
{
title: <div>Failed</div>,
dataIndex: 'failed_pods',
key: 'failed_pods',
sorter: true,
align: 'left',
},
{
title: <div>Desired Successful</div>,
dataIndex: 'desired_successful_pods',
key: 'desired_successful_pods',
ellipsis: true,
sorter: true,
align: 'left',
},
{
title: <div>Active</div>,
dataIndex: 'active_pods',
key: 'active_pods',
sorter: true,
align: 'left',
},
{
title: <div>CPU Req Usage (%)</div>,
dataIndex: 'cpu_request',
key: 'cpu_request',
width: 180,
ellipsis: true,
sorter: true,
align: 'left',
},
{
title: <div>CPU Limit Usage (%)</div>,
dataIndex: 'cpu_limit',
key: 'cpu_limit',
width: 120,
sorter: true,
align: 'left',
},
{
title: <div>CPU Usage (cores)</div>,
dataIndex: 'cpu',
key: 'cpu',
width: 80,
sorter: true,
align: 'left',
},
{
title: <div>Mem Req Usage (%)</div>,
dataIndex: 'memory_request',
key: 'memory_request',
width: 120,
sorter: true,
align: 'left',
},
{
title: <div>Mem Limit Usage (%)</div>,
dataIndex: 'memory_limit',
key: 'memory_limit',
width: 120,
sorter: true,
align: 'left',
},
{
title: <div>Mem Usage (WSS)</div>,
dataIndex: 'memory',
key: 'memory',
width: 120,
ellipsis: true,
sorter: true,
align: 'left',
},
];
export const k8sJobsRenderRowData = (
job: K8sJobsData,
groupBy: BaseAutocompleteData[],
): K8sRenderedRowData => ({
key: getRowKey(job, () => job.jobName || job.meta.k8s_job_name || '', groupBy),
itemKey: job.meta.k8s_job_name,
jobName: (
<Tooltip title={job.meta.k8s_job_name}>{job.meta.k8s_job_name || ''}</Tooltip>
),
namespaceName: (
<Tooltip title={job.meta.k8s_namespace_name}>
{job.meta.k8s_namespace_name || ''}
</Tooltip>
),
cpu_request: (
<ValidateColumnValueWrapper
value={job.cpuRequest}
entity={InfraMonitoringEntity.JOBS}
attribute="CPU Request"
>
<div className={styles.progressBar}>
<EntityProgressBar value={job.cpuRequest} type="request" />
</div>
</ValidateColumnValueWrapper>
),
cpu_limit: (
<ValidateColumnValueWrapper
value={job.cpuLimit}
entity={InfraMonitoringEntity.JOBS}
attribute="CPU Limit"
>
<div className={styles.progressBar}>
<EntityProgressBar value={job.cpuLimit} type="limit" />
</div>
</ValidateColumnValueWrapper>
),
cpu: (
<ValidateColumnValueWrapper value={job.cpuUsage}>
{job.cpuUsage}
</ValidateColumnValueWrapper>
),
memory_request: (
<ValidateColumnValueWrapper
value={job.memoryRequest}
entity={InfraMonitoringEntity.JOBS}
attribute="Memory Request"
>
<div className={styles.progressBar}>
<EntityProgressBar value={job.memoryRequest} type="request" />
</div>
</ValidateColumnValueWrapper>
),
memory_limit: (
<ValidateColumnValueWrapper
value={job.memoryLimit}
entity={InfraMonitoringEntity.JOBS}
attribute="Memory Limit"
>
<div className={styles.progressBar}>
<EntityProgressBar value={job.memoryLimit} type="limit" />
</div>
</ValidateColumnValueWrapper>
),
memory: (
<ValidateColumnValueWrapper value={job.memoryUsage}>
{formatBytes(job.memoryUsage)}
</ValidateColumnValueWrapper>
),
successful_pods: (
<ValidateColumnValueWrapper value={job.successfulPods}>
{job.successfulPods}
</ValidateColumnValueWrapper>
),
desired_successful_pods: (
<ValidateColumnValueWrapper value={job.desiredSuccessfulPods}>
{job.desiredSuccessfulPods}
</ValidateColumnValueWrapper>
),
failed_pods: (
<ValidateColumnValueWrapper value={job.failedPods}>
{job.failedPods}
</ValidateColumnValueWrapper>
),
active_pods: (
<ValidateColumnValueWrapper value={job.activePods}>
{job.activePods}
</ValidateColumnValueWrapper>
),
jobGroup: getGroupByEl(job, groupBy),
...job.meta,
groupedByMeta: getGroupedByMeta(job, groupBy),
});

View File

@@ -0,0 +1,11 @@
.entityGroupHeader {
padding-left: var(--spacing-5);
gap: var(--spacing-5);
display: flex;
align-items: center;
}
.progressBar {
display: flex;
justify-content: start;
}

View File

@@ -19,9 +19,9 @@ import {
namespaceWidgetInfo,
} from './constants';
import {
getK8sNamespaceItemKey,
getK8sNamespaceRowKey,
k8sNamespacesColumns,
k8sNamespacesColumnsConfig,
k8sNamespacesRenderRowData,
} from './table.config';
function K8sNamespacesList({
@@ -91,10 +91,10 @@ function K8sNamespacesList({
<K8sBaseList<K8sNamespacesData>
controlListPrefix={controlListPrefix}
entity={InfraMonitoringEntity.NAMESPACES}
tableColumnsDefinitions={k8sNamespacesColumns}
tableColumns={k8sNamespacesColumnsConfig}
fetchListData={fetchListData}
getRowKey={getK8sNamespaceRowKey}
getItemKey={getK8sNamespaceItemKey}
renderRowData={k8sNamespacesRenderRowData}
eventCategory={InfraMonitoringEvents.Namespace}
/>

View File

@@ -1,21 +1,68 @@
import { Tooltip } from 'antd';
import TanStackTable, { TableColumnDef } from 'components/TanStackTableView';
import { ExpandButtonWrapper } from 'container/InfraMonitoringK8s/components';
import { TableColumnType as ColumnType, Tooltip } from 'antd';
import { Group } from 'lucide-react';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import EntityGroupHeader from '../Base/EntityGroupHeader';
import K8sGroupCell from '../Base/K8sGroupCell';
import { formatBytes } from '../commonUtils';
import { ValidateColumnValueWrapper } from '../components';
import { K8sRenderedRowData } from '../Base/types';
import { IEntityColumn } from '../Base/useInfraMonitoringTableColumnsStore';
import { getGroupByEl, getGroupedByMeta, getRowKey } from '../Base/utils';
import { formatBytes, ValidateColumnValueWrapper } from '../commonUtils';
import { K8sNamespacesData, K8sNamespacesListPayload } from './api';
import { FilePenLine } from 'lucide-react';
export function getK8sNamespaceRowKey(namespace: K8sNamespacesData): string {
return namespace.namespaceName || namespace.meta.k8s_namespace_name;
import styles from './table.module.scss';
export interface K8sNamespacesRowData {
key: string;
itemKey: string;
namespaceUID: string;
namespaceName: React.ReactNode;
clusterName: string;
cpu: React.ReactNode;
memory: React.ReactNode;
groupedByMeta?: Record<string, string>;
}
export function getK8sNamespaceItemKey(namespace: K8sNamespacesData): string {
return namespace.meta.k8s_namespace_name;
}
export const k8sNamespacesColumns: IEntityColumn[] = [
{
label: 'Namespace Group',
value: 'namespaceGroup',
id: 'namespaceGroup',
canBeHidden: false,
defaultVisibility: true,
behavior: 'hidden-on-collapse',
},
{
label: 'Namespace Name',
value: 'namespaceName',
id: 'namespaceName',
canBeHidden: false,
defaultVisibility: true,
behavior: 'hidden-on-expand',
},
{
label: 'Cluster Name',
value: 'clusterName',
id: 'clusterName',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'CPU Usage (cores)',
value: 'cpu',
id: 'cpu',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Memory Usage (WSS)',
value: 'memory',
id: 'memory',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
];
export const getK8sNamespacesListQuery = (): K8sNamespacesListPayload => ({
filters: {
@@ -25,90 +72,84 @@ export const getK8sNamespacesListQuery = (): K8sNamespacesListPayload => ({
orderBy: { columnName: 'cpu', order: 'desc' },
});
export const k8sNamespacesColumnsConfig: TableColumnDef<K8sNamespacesData>[] = [
export const k8sNamespacesColumnsConfig: ColumnType<K8sRenderedRowData>[] = [
{
id: 'namespaceGroup',
header: (): React.ReactNode => <EntityGroupHeader title="NAMESPACE GROUP" />,
accessorFn: (row): string => row.meta.k8s_namespace_name || '',
width: { min: 300 },
enableSort: false,
enableRemove: false,
enableMove: false,
pin: 'left',
visibilityBehavior: 'hidden-on-collapse',
cell: ({ isExpanded, toggleExpanded, row }): JSX.Element | null => {
return (
<ExpandButtonWrapper
isExpanded={isExpanded}
toggleExpanded={toggleExpanded}
>
<K8sGroupCell row={row} />
</ExpandButtonWrapper>
);
},
},
{
id: 'namespaceName',
header: (): React.ReactNode => (
<EntityGroupHeader
title="Namespace Name"
icon={<FilePenLine data-hide-expanded="true" size={14} />}
/>
title: (
<div className={styles.entityGroupHeader}>
<Group size={14} /> NAMESPACE GROUP
</div>
),
accessorFn: (row): string => row.namespaceName || '',
width: { min: 290 },
enableSort: false,
enableRemove: false,
enableMove: false,
pin: 'left',
visibilityBehavior: 'hidden-on-expand',
cell: ({ value }): React.ReactNode => {
const namespaceName = value as string;
return (
<Tooltip title={namespaceName}>
<TanStackTable.Text>{namespaceName}</TanStackTable.Text>
</Tooltip>
);
},
dataIndex: 'namespaceGroup',
key: 'namespaceGroup',
ellipsis: true,
width: 150,
align: 'left',
sorter: false,
},
{
id: 'clusterName',
header: 'Cluster Name',
accessorFn: (row): string => row.meta.k8s_cluster_name || '',
width: { default: 150 },
enableSort: false,
cell: ({ value }): React.ReactNode => (
<TanStackTable.Text>{value as string}</TanStackTable.Text>
),
title: <div>Namespace Name</div>,
dataIndex: 'namespaceName',
key: 'namespaceName',
ellipsis: true,
width: 120,
sorter: false,
align: 'left',
},
{
id: 'cpu',
header: 'CPU Usage (cores)',
accessorFn: (row): number => row.cpuUsage,
width: { min: 220 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const cpu = value as number;
return (
<ValidateColumnValueWrapper value={cpu}>
<TanStackTable.Text>{cpu}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
title: <div>Cluster Name</div>,
dataIndex: 'clusterName',
key: 'clusterName',
ellipsis: true,
width: 120,
sorter: false,
align: 'left',
},
{
id: 'memory',
header: 'Mem Usage (WSS)',
accessorFn: (row): number => row.memoryUsage,
width: { min: 220 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const memory = value as number;
return (
<ValidateColumnValueWrapper value={memory}>
<TanStackTable.Text>{formatBytes(memory)}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
title: <div>CPU Usage (cores)</div>,
dataIndex: 'cpu',
key: 'cpu',
width: 100,
sorter: true,
align: 'left',
},
{
title: <div>Mem Usage (WSS)</div>,
dataIndex: 'memory',
key: 'memory',
width: 120,
sorter: true,
align: 'left',
},
];
export const k8sNamespacesRenderRowData = (
namespace: K8sNamespacesData,
groupBy: BaseAutocompleteData[],
): K8sRenderedRowData => ({
key: getRowKey(
namespace,
() => namespace.namespaceName || namespace.meta.k8s_namespace_name,
groupBy,
),
itemKey: namespace.meta.k8s_namespace_name,
namespaceUID: namespace.namespaceName,
namespaceName: (
<Tooltip title={namespace.namespaceName}>
{namespace.namespaceName || ''}
</Tooltip>
),
clusterName: namespace.meta.k8s_cluster_name,
cpu: (
<ValidateColumnValueWrapper value={namespace.cpuUsage}>
{namespace.cpuUsage}
</ValidateColumnValueWrapper>
),
memory: (
<ValidateColumnValueWrapper value={namespace.memoryUsage}>
{formatBytes(namespace.memoryUsage)}
</ValidateColumnValueWrapper>
),
namespaceGroup: getGroupByEl(namespace, groupBy),
...namespace.meta,
groupedByMeta: getGroupedByMeta(namespace, groupBy),
});

View File

@@ -0,0 +1,6 @@
.entityGroupHeader {
padding-left: var(--spacing-5);
gap: var(--spacing-5);
display: flex;
align-items: center;
}

View File

@@ -19,9 +19,9 @@ import {
nodeWidgetInfo,
} from './constants';
import {
getK8sNodeItemKey,
getK8sNodeRowKey,
k8sNodesColumns,
k8sNodesColumnsConfig,
k8sNodesRenderRowData,
} from './table.config';
function K8sNodesList({
@@ -91,10 +91,10 @@ function K8sNodesList({
<K8sBaseList<K8sNodeData>
controlListPrefix={controlListPrefix}
entity={InfraMonitoringEntity.NODES}
tableColumnsDefinitions={k8sNodesColumns}
tableColumns={k8sNodesColumnsConfig}
fetchListData={fetchListData}
getRowKey={getK8sNodeRowKey}
getItemKey={getK8sNodeItemKey}
renderRowData={k8sNodesRenderRowData}
eventCategory={InfraMonitoringEvents.Node}
/>

View File

@@ -1,22 +1,86 @@
import { Tooltip } from 'antd';
import { TableColumnDef } from 'components/TanStackTableView';
import TanStackTable from 'components/TanStackTableView';
import { ExpandButtonWrapper } from 'container/InfraMonitoringK8s/components';
import { TableColumnType as ColumnType, Tooltip } from 'antd';
import { Group } from 'lucide-react';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import EntityGroupHeader from '../Base/EntityGroupHeader';
import K8sGroupCell from '../Base/K8sGroupCell';
import { formatBytes } from '../commonUtils';
import { ValidateColumnValueWrapper } from '../components';
import { K8sRenderedRowData } from '../Base/types';
import { IEntityColumn } from '../Base/useInfraMonitoringTableColumnsStore';
import { getGroupByEl, getGroupedByMeta, getRowKey } from '../Base/utils';
import { formatBytes, ValidateColumnValueWrapper } from '../commonUtils';
import { K8sNodeData, K8sNodesListPayload } from './api';
import { Workflow } from 'lucide-react';
export function getK8sNodeRowKey(node: K8sNodeData): string {
return node.nodeUID || node.meta.k8s_node_uid || node.meta.k8s_node_name;
import styles from './table.module.scss';
export interface K8sNodesRowData {
key: string;
itemKey: string;
nodeUID: string;
nodeName: React.ReactNode;
clusterName: string;
cpu: React.ReactNode;
cpu_allocatable: React.ReactNode;
memory: React.ReactNode;
memory_allocatable: React.ReactNode;
groupedByMeta?: any;
}
export function getK8sNodeItemKey(node: K8sNodeData): string {
return node.meta.k8s_node_name;
}
export const k8sNodesColumns: IEntityColumn[] = [
{
label: 'Node Group',
value: 'nodeGroup',
id: 'nodeGroup',
canBeHidden: false,
defaultVisibility: true,
behavior: 'hidden-on-collapse',
},
{
label: 'Node Name',
value: 'nodeName',
id: 'nodeName',
canBeHidden: false,
defaultVisibility: true,
behavior: 'hidden-on-expand',
},
{
label: 'Cluster Name',
value: 'clusterName',
id: 'clusterName',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'CPU Usage (cores)',
value: 'cpu',
id: 'cpu',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'CPU Alloc (cores)',
value: 'cpu_allocatable',
id: 'cpu_allocatable',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Memory Usage (WSS)',
value: 'memory',
id: 'memory',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Memory Alloc (bytes)',
value: 'memory_allocatable',
id: 'memory_allocatable',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
];
export const getK8sNodesListQuery = (): K8sNodesListPayload => ({
filters: {
@@ -26,120 +90,110 @@ export const getK8sNodesListQuery = (): K8sNodesListPayload => ({
orderBy: { columnName: 'cpu', order: 'desc' },
});
export const k8sNodesColumnsConfig: TableColumnDef<K8sNodeData>[] = [
export const k8sNodesColumnsConfig: ColumnType<K8sRenderedRowData>[] = [
{
id: 'nodeGroup',
header: (): React.ReactNode => <EntityGroupHeader title="NODE GROUP" />,
accessorFn: (row): string => row.meta.k8s_node_name || '',
width: { min: 300 },
enableSort: false,
enableRemove: false,
enableMove: false,
pin: 'left',
visibilityBehavior: 'hidden-on-collapse',
cell: ({ isExpanded, toggleExpanded, row }): JSX.Element | null => {
return (
<ExpandButtonWrapper
isExpanded={isExpanded}
toggleExpanded={toggleExpanded}
>
<K8sGroupCell row={row} />
</ExpandButtonWrapper>
);
},
},
{
id: 'nodeName',
header: (): React.ReactNode => (
<EntityGroupHeader
title="Node Name"
icon={<Workflow data-hide-expanded="true" size={14} />}
/>
title: (
<div className={styles.entityGroupHeader}>
<Group size={14} /> NODE GROUP
</div>
),
accessorFn: (row): string => row.meta.k8s_node_name || '',
width: { min: 290 },
enableSort: false,
enableRemove: false,
enableMove: false,
pin: 'left',
visibilityBehavior: 'hidden-on-expand',
cell: ({ value }): React.ReactNode => {
const nodeName = value as string;
return (
<Tooltip title={nodeName}>
<TanStackTable.Text>{nodeName}</TanStackTable.Text>
</Tooltip>
);
},
dataIndex: 'nodeGroup',
key: 'nodeGroup',
ellipsis: true,
width: 150,
align: 'left',
sorter: false,
},
{
id: 'clusterName',
header: 'Cluster Name',
accessorFn: (row): string => row.meta.k8s_cluster_name || '',
width: { min: 150, default: 150 },
enableSort: false,
cell: ({ value }): React.ReactNode => (
<TanStackTable.Text>{value as string}</TanStackTable.Text>
),
title: <div>Node Name</div>,
dataIndex: 'nodeName',
key: 'nodeName',
ellipsis: true,
width: 80,
sorter: false,
align: 'left',
},
{
id: 'cpu',
header: 'CPU Usage (cores)',
accessorFn: (row): number => row.nodeCPUUsage,
width: { min: 200, default: 200 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const cpu = value as number;
return (
<ValidateColumnValueWrapper value={cpu}>
<TanStackTable.Text>{cpu}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
title: <div>Cluster Name</div>,
dataIndex: 'clusterName',
key: 'clusterName',
ellipsis: true,
width: 80,
sorter: false,
align: 'left',
},
{
id: 'cpu_allocatable',
header: 'CPU Alloc (cores)',
accessorFn: (row): number => row.nodeCPUAllocatable,
width: { min: 200, default: 200 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const cpuAllocatable = value as number;
return (
<ValidateColumnValueWrapper value={cpuAllocatable}>
<TanStackTable.Text>{cpuAllocatable}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
title: <div>CPU Usage (cores)</div>,
dataIndex: 'cpu',
key: 'cpu',
width: 80,
sorter: true,
align: 'left',
},
{
id: 'memory',
header: 'Memory Usage (WSS)',
accessorFn: (row): number => row.nodeMemoryUsage,
width: { min: 240, default: 240 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const memory = value as number;
return (
<ValidateColumnValueWrapper value={memory}>
<TanStackTable.Text>{formatBytes(memory)}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
title: <div>CPU Alloc (cores)</div>,
dataIndex: 'cpu_allocatable',
key: 'cpu_allocatable',
width: 80,
sorter: true,
align: 'left',
},
{
id: 'memory_allocatable',
header: 'Memory Allocatable',
accessorFn: (row): number => row.nodeMemoryAllocatable,
width: { min: 240, default: 240 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const memoryAllocatable = value as number;
return (
<ValidateColumnValueWrapper value={memoryAllocatable}>
<TanStackTable.Text>{formatBytes(memoryAllocatable)}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
title: <div>Memory Usage (WSS)</div>,
dataIndex: 'memory',
key: 'memory',
width: 80,
sorter: true,
align: 'left',
},
{
title: <div>Memory Allocatable</div>,
dataIndex: 'memory_allocatable',
key: 'memory_allocatable',
width: 80,
sorter: true,
align: 'left',
},
];
export const k8sNodesRenderRowData = (
node: K8sNodeData,
groupBy: BaseAutocompleteData[],
): K8sRenderedRowData => ({
key: getRowKey(
node,
() => node.nodeUID || node.meta.k8s_node_uid || node.meta.k8s_node_name,
groupBy,
),
itemKey: node.meta.k8s_node_name,
nodeUID: node.nodeUID || node.meta.k8s_node_uid,
nodeName: (
<Tooltip title={node.meta.k8s_node_name}>
{node.meta.k8s_node_name || ''}
</Tooltip>
),
clusterName: node.meta.k8s_cluster_name,
cpu: (
<ValidateColumnValueWrapper value={node.nodeCPUUsage}>
{node.nodeCPUUsage}
</ValidateColumnValueWrapper>
),
memory: (
<ValidateColumnValueWrapper value={node.nodeMemoryUsage}>
{formatBytes(node.nodeMemoryUsage)}
</ValidateColumnValueWrapper>
),
cpu_allocatable: (
<ValidateColumnValueWrapper value={node.nodeCPUAllocatable}>
{node.nodeCPUAllocatable}
</ValidateColumnValueWrapper>
),
memory_allocatable: (
<ValidateColumnValueWrapper value={node.nodeMemoryAllocatable}>
{formatBytes(node.nodeMemoryAllocatable)}
</ValidateColumnValueWrapper>
),
nodeGroup: getGroupByEl(node, groupBy),
...node.meta,
groupedByMeta: getGroupedByMeta(node, groupBy),
});

View File

@@ -0,0 +1,6 @@
.entityGroupHeader {
display: flex;
align-items: center;
padding-left: var(--spacing-5);
gap: var(--spacing-5);
}

View File

@@ -19,9 +19,9 @@ import {
podWidgetInfo,
} from './constants';
import {
getK8sPodItemKey,
getK8sPodRowKey,
k8sPodColumns,
k8sPodColumnsConfig,
k8sPodRenderRowData,
} from './table.config';
function K8sPodsList({
@@ -91,10 +91,10 @@ function K8sPodsList({
<K8sBaseList<K8sPodsData>
controlListPrefix={controlListPrefix}
entity={InfraMonitoringEntity.PODS}
tableColumnsDefinitions={k8sPodColumns}
tableColumns={k8sPodColumnsConfig}
fetchListData={fetchListData}
getRowKey={getK8sPodRowKey}
getItemKey={getK8sPodItemKey}
renderRowData={k8sPodRenderRowData}
eventCategory={InfraMonitoringEvents.Pod}
/>

View File

@@ -1,207 +1,328 @@
import { Tooltip } from 'antd';
import { TableColumnDef } from 'components/TanStackTableView';
import TanStackTable from 'components/TanStackTableView';
import { ExpandButtonWrapper } from 'container/InfraMonitoringK8s/components';
import React from 'react';
import { TableColumnType as ColumnType, Tooltip } from 'antd';
import { Group } from 'lucide-react';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import EntityGroupHeader from '../Base/EntityGroupHeader';
import K8sGroupCell from '../Base/K8sGroupCell';
import { formatBytes } from '../commonUtils';
import { EntityProgressBar, ValidateColumnValueWrapper } from '../components';
import { K8sRenderedRowData } from '../Base/types';
import { IEntityColumn } from '../Base/useInfraMonitoringTableColumnsStore';
import { getGroupByEl, getGroupedByMeta, getRowKey } from '../Base/utils';
import {
EntityProgressBar,
formatBytes,
ValidateColumnValueWrapper,
} from '../commonUtils';
import { InfraMonitoringEntity } from '../constants';
import { K8sPodsData } from './api';
import { Container } from 'lucide-react';
export function getK8sPodRowKey(pod: K8sPodsData): string {
return pod.podUID || pod.meta.k8s_pod_uid || pod.meta.k8s_pod_name;
import styles from './table.module.scss';
export interface K8sPodsRowData {
key: string;
podName: React.ReactNode;
podUID: string;
cpu_request: React.ReactNode;
cpu_limit: React.ReactNode;
cpu: React.ReactNode;
memory_request: React.ReactNode;
memory_limit: React.ReactNode;
memory: React.ReactNode;
restarts: React.ReactNode;
groupedByMeta?: any;
}
export function getK8sPodItemKey(pod: K8sPodsData): string {
return pod.podUID;
}
export const k8sPodColumnsConfig: TableColumnDef<K8sPodsData>[] = [
export const k8sPodColumns: IEntityColumn[] = [
{
label: 'Pod Group',
value: 'podGroup',
id: 'podGroup',
header: (): React.ReactNode => <EntityGroupHeader title="POD GROUP" />,
accessorFn: (row): string => row.meta.k8s_pod_name || '',
width: { min: 300 },
enableSort: false,
enableRemove: false,
enableMove: false,
pin: 'left',
visibilityBehavior: 'hidden-on-collapse',
cell: ({ isExpanded, toggleExpanded, row }): JSX.Element | null => {
return (
<ExpandButtonWrapper
isExpanded={isExpanded}
toggleExpanded={toggleExpanded}
>
<K8sGroupCell row={row} />
</ExpandButtonWrapper>
);
},
canBeHidden: false,
defaultVisibility: true,
behavior: 'hidden-on-collapse',
},
{
label: 'Pod name',
value: 'podName',
id: 'podName',
header: (): React.ReactNode => (
<EntityGroupHeader
title="Pod Name"
icon={<Container data-hide-expanded="true" size={14} />}
/>
),
accessorFn: (row): string => row.meta.k8s_pod_name || '',
width: { min: 290 },
enableSort: false,
enableRemove: false,
enableMove: false,
pin: 'left',
visibilityBehavior: 'hidden-on-expand',
cell: ({ value }): React.ReactNode => {
const podName = value as string;
return (
<Tooltip title={podName}>
<TanStackTable.Text>{podName}</TanStackTable.Text>
</Tooltip>
);
},
canBeHidden: false,
defaultVisibility: true,
behavior: 'hidden-on-expand',
},
{
label: 'CPU Req Usage (%)',
value: 'cpu_request',
id: 'cpu_request',
header: 'CPU Req Usage (%)',
accessorFn: (row): number => row.podCPURequest,
width: { min: 210 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const cpuRequest = value as number;
return (
<ValidateColumnValueWrapper
value={cpuRequest}
entity={InfraMonitoringEntity.PODS}
attribute="CPU Request"
>
<EntityProgressBar value={cpuRequest} type="request" />
</ValidateColumnValueWrapper>
);
},
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'CPU Limit Usage (%)',
value: 'cpu_limit',
id: 'cpu_limit',
header: 'CPU Limit Usage (%)',
accessorFn: (row): number => row.podCPULimit,
width: { min: 210 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const cpuLimit = value as number;
return (
<ValidateColumnValueWrapper
value={cpuLimit}
entity={InfraMonitoringEntity.PODS}
attribute="CPU Limit"
>
<EntityProgressBar value={cpuLimit} type="limit" />
</ValidateColumnValueWrapper>
);
},
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'CPU Usage (cores)',
value: 'cpu',
id: 'cpu',
header: 'CPU Usage (cores)',
accessorFn: (row): number => row.podCPU,
width: { min: 210 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const cpu = value as number;
return (
<ValidateColumnValueWrapper value={cpu}>
<TanStackTable.Text>{cpu}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Mem Req Usage (%)',
value: 'memory_request',
id: 'memory_request',
header: 'Mem Req Usage (%)',
accessorFn: (row): number => row.podMemoryRequest,
width: { min: 210 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const memoryRequest = value as number;
return (
<ValidateColumnValueWrapper
value={memoryRequest}
entity={InfraMonitoringEntity.PODS}
attribute="Memory Request"
>
<EntityProgressBar value={memoryRequest} type="request" />
</ValidateColumnValueWrapper>
);
},
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Mem Limit Usage (%)',
value: 'memory_limit',
id: 'memory_limit',
header: 'Mem Limit Usage (%)',
accessorFn: (row): number => row.podMemoryLimit,
width: { min: 210 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const memoryLimit = value as number;
return (
<ValidateColumnValueWrapper
value={memoryLimit}
entity={InfraMonitoringEntity.PODS}
attribute="Memory Limit"
>
<EntityProgressBar value={memoryLimit} type="limit" />
</ValidateColumnValueWrapper>
);
},
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Mem Usage (WSS)',
value: 'memory',
id: 'memory',
header: 'Mem Usage (WSS)',
accessorFn: (row): number => row.podMemory,
width: { min: 210, default: '100%' },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const memory = value as number;
return (
<ValidateColumnValueWrapper value={memory}>
<TanStackTable.Text>{formatBytes(memory)}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Namespace name',
value: 'namespace',
id: 'namespace',
header: 'Namespace',
accessorFn: (row): string => row.meta.k8s_namespace_name || '',
width: { default: 100 },
enableSort: false,
canBeHidden: true,
defaultVisibility: false,
cell: ({ value }): React.ReactNode => (
<TanStackTable.Text>{value as string}</TanStackTable.Text>
),
behavior: 'always-visible',
},
{
label: 'Node name',
value: 'node',
id: 'node',
header: 'Node',
accessorFn: (row): string => row.meta.k8s_node_name || '',
width: { default: 100 },
enableSort: false,
canBeHidden: true,
defaultVisibility: false,
cell: ({ value }): React.ReactNode => (
<TanStackTable.Text>{value as string}</TanStackTable.Text>
),
behavior: 'always-visible',
},
{
label: 'Cluster name',
value: 'cluster',
id: 'cluster',
header: 'Cluster',
accessorFn: (row): string => row.meta.k8s_cluster_name || '',
width: { default: 100 },
enableSort: false,
canBeHidden: true,
defaultVisibility: false,
cell: ({ value }): React.ReactNode => (
<TanStackTable.Text>{value as string}</TanStackTable.Text>
),
behavior: 'always-visible',
},
// TODO - Re-enable the column once backend issue is fixed
// {
// label: 'Restarts',
// value: 'restarts',
// id: 'restarts',
// canRemove: false,
// },
];
export const k8sPodColumnsConfig: ColumnType<K8sRenderedRowData>[] = [
{
title: (
<div className={styles.entityGroupHeader}>
<Group size={14} /> POD GROUP
</div>
),
dataIndex: 'podGroup',
key: 'podGroup',
ellipsis: true,
width: 180,
sorter: false,
},
{
title: <div>Pod Name</div>,
dataIndex: 'podName',
key: 'podName',
width: 180,
ellipsis: true,
sorter: false,
},
{
title: <div>CPU Req Usage (%)</div>,
dataIndex: 'cpu_request',
key: 'cpu_request',
width: 180,
ellipsis: true,
sorter: true,
align: 'left',
},
{
title: <div>CPU Limit Usage (%)</div>,
dataIndex: 'cpu_limit',
key: 'cpu_limit',
width: 120,
sorter: true,
align: 'left',
},
{
title: <div>CPU Usage (cores)</div>,
dataIndex: 'cpu',
key: 'cpu',
width: 80,
sorter: true,
align: 'left',
},
{
title: <div>Mem Req Usage (%)</div>,
dataIndex: 'memory_request',
key: 'memory_request',
width: 120,
sorter: true,
align: 'left',
},
{
title: <div>Mem Limit Usage (%)</div>,
dataIndex: 'memory_limit',
key: 'memory_limit',
width: 120,
sorter: true,
align: 'left',
},
{
title: <div>Mem Usage (WSS)</div>,
dataIndex: 'memory',
key: 'memory',
width: 120,
ellipsis: true,
sorter: true,
align: 'left',
},
{
title: <div>Namespace</div>,
dataIndex: 'namespace',
key: 'namespace',
width: 100,
sorter: false,
ellipsis: true,
align: 'left',
},
{
title: <div>Node</div>,
dataIndex: 'node',
key: 'node',
width: 100,
sorter: false,
ellipsis: true,
align: 'left',
},
{
title: <div>Cluster</div>,
dataIndex: 'cluster',
key: 'cluster',
width: 100,
sorter: false,
ellipsis: true,
align: 'left',
},
// TODO - Re-enable the column once backend issue is fixed
// {
// title: (
// <div className="column-header">
// <Tooltip title="Container Restarts">Restarts</Tooltip>
// </div>
// ),
// dataIndex: 'restarts',
// key: 'restarts',
// width: 40,
// ellipsis: true,
// sorter: true,
// align: 'left',
// className: `column ${columnProgressBarClassName}`,
// },
];
export const k8sPodRenderRowData = (
pod: K8sPodsData,
groupBy: BaseAutocompleteData[],
): K8sRenderedRowData => ({
key: getRowKey(
pod,
() => pod.podUID || pod.meta.k8s_pod_uid || pod.meta.k8s_pod_name,
groupBy,
),
itemKey: pod.podUID,
podName: (
<Tooltip title={pod.meta.k8s_pod_name || ''}>
{pod.meta.k8s_pod_name || ''}
</Tooltip>
),
podUID: pod.podUID || '',
cpu_request: (
<ValidateColumnValueWrapper
value={pod.podCPURequest}
entity={InfraMonitoringEntity.PODS}
attribute="CPU Request"
>
<div className={styles.progressBar}>
<EntityProgressBar value={pod.podCPURequest} type="request" />
</div>
</ValidateColumnValueWrapper>
),
cpu_limit: (
<ValidateColumnValueWrapper
value={pod.podCPULimit}
entity={InfraMonitoringEntity.PODS}
attribute="CPU Limit"
>
<div className={styles.progressBar}>
<EntityProgressBar value={pod.podCPULimit} type="limit" />
</div>
</ValidateColumnValueWrapper>
),
cpu: (
<ValidateColumnValueWrapper value={pod.podCPU}>
{pod.podCPU}
</ValidateColumnValueWrapper>
),
memory_request: (
<ValidateColumnValueWrapper
value={pod.podMemoryRequest}
entity={InfraMonitoringEntity.PODS}
attribute="Memory Request"
>
<div className={styles.progressBar}>
<EntityProgressBar value={pod.podMemoryRequest} type="request" />
</div>
</ValidateColumnValueWrapper>
),
memory_limit: (
<ValidateColumnValueWrapper
value={pod.podMemoryLimit}
entity={InfraMonitoringEntity.PODS}
attribute="Memory Limit"
>
<div className={styles.progressBar}>
<EntityProgressBar value={pod.podMemoryLimit} type="limit" />
</div>
</ValidateColumnValueWrapper>
),
memory: (
<ValidateColumnValueWrapper value={pod.podMemory}>
{formatBytes(pod.podMemory)}
</ValidateColumnValueWrapper>
),
restarts: (
<ValidateColumnValueWrapper value={pod.restartCount}>
{pod.restartCount}
</ValidateColumnValueWrapper>
),
namespace: pod.meta.k8s_namespace_name,
node: pod.meta.k8s_node_name,
cluster: pod.meta.k8s_cluster_name,
meta: pod.meta,
podGroup: getGroupByEl(pod, groupBy),
...pod.meta,
groupedByMeta: getGroupedByMeta(pod, groupBy),
});

View File

@@ -0,0 +1,11 @@
.entityGroupHeader {
padding-left: var(--spacing-5);
gap: var(--spacing-5);
display: flex;
align-items: center;
}
.progressBar {
display: flex;
justify-content: start;
}

View File

@@ -19,9 +19,9 @@ import {
statefulSetWidgetInfo,
} from './constants';
import {
getK8sStatefulSetItemKey,
getK8sStatefulSetRowKey,
k8sStatefulSetsColumns,
k8sStatefulSetsColumnsConfig,
k8sStatefulSetsRenderRowData,
} from './table.config';
function K8sStatefulSetsList({
@@ -91,10 +91,10 @@ function K8sStatefulSetsList({
<K8sBaseList<K8sStatefulSetsData>
controlListPrefix={controlListPrefix}
entity={InfraMonitoringEntity.STATEFULSETS}
tableColumnsDefinitions={k8sStatefulSetsColumns}
tableColumns={k8sStatefulSetsColumnsConfig}
fetchListData={fetchListData}
getRowKey={getK8sStatefulSetRowKey}
getItemKey={getK8sStatefulSetItemKey}
renderRowData={k8sStatefulSetsRenderRowData}
eventCategory={InfraMonitoringEvents.StatefulSet}
/>

View File

@@ -1,239 +1,295 @@
import { Tooltip } from 'antd';
import { TableColumnDef } from 'components/TanStackTableView';
import TanStackTable from 'components/TanStackTableView';
import { ExpandButtonWrapper } from 'container/InfraMonitoringK8s/components';
import { TableColumnType as ColumnType, Tooltip } from 'antd';
import { Group } from 'lucide-react';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import EntityGroupHeader from '../Base/EntityGroupHeader';
import K8sGroupCell from '../Base/K8sGroupCell';
import { formatBytes } from '../commonUtils';
import { EntityProgressBar, ValidateColumnValueWrapper } from '../components';
import { K8sRenderedRowData } from '../Base/types';
import { IEntityColumn } from '../Base/useInfraMonitoringTableColumnsStore';
import { getGroupByEl, getGroupedByMeta, getRowKey } from '../Base/utils';
import {
EntityProgressBar,
formatBytes,
ValidateColumnValueWrapper,
} from '../commonUtils';
import { InfraMonitoringEntity } from '../constants';
import { K8sStatefulSetsData } from './api';
import { ArrowUpDown } from 'lucide-react';
export function getK8sStatefulSetRowKey(
import styles from './table.module.scss';
export const k8sStatefulSetsColumns: IEntityColumn[] = [
{
label: 'StatefulSet Group',
value: 'statefulSetGroup',
id: 'statefulSetGroup',
canBeHidden: false,
defaultVisibility: true,
behavior: 'hidden-on-collapse',
},
{
label: 'StatefulSet Name',
value: 'statefulsetName',
id: 'statefulsetName',
canBeHidden: false,
defaultVisibility: true,
behavior: 'hidden-on-expand',
},
{
label: 'Namespace Name',
value: 'namespaceName',
id: 'namespaceName',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Available',
value: 'available_pods',
id: 'available_pods',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Desired',
value: 'desired_pods',
id: 'desired_pods',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'CPU Req Usage (%)',
value: 'cpu_request',
id: 'cpu_request',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'CPU Limit Usage (%)',
value: 'cpu_limit',
id: 'cpu_limit',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'CPU Usage (cores)',
value: 'cpu',
id: 'cpu',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Mem Req Usage (%)',
value: 'memory_request',
id: 'memory_request',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Mem Limit Usage (%)',
value: 'memory_limit',
id: 'memory_limit',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Mem Usage (WSS)',
value: 'memory',
id: 'memory',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
];
export const k8sStatefulSetsColumnsConfig: ColumnType<K8sRenderedRowData>[] = [
{
title: (
<div className={styles.entityGroupHeader}>
<Group size={14} /> STATEFULSET GROUP
</div>
),
dataIndex: 'statefulSetGroup',
key: 'statefulSetGroup',
ellipsis: true,
width: 150,
align: 'left',
sorter: false,
},
{
title: <div>StatefulSet Name</div>,
dataIndex: 'statefulsetName',
key: 'statefulsetName',
ellipsis: true,
width: 80,
sorter: false,
align: 'left',
},
{
title: <div>Namespace Name</div>,
dataIndex: 'namespaceName',
key: 'namespaceName',
ellipsis: true,
width: 80,
sorter: false,
align: 'left',
},
{
title: <div>Available</div>,
dataIndex: 'available_pods',
key: 'available_pods',
width: 80,
sorter: true,
align: 'left',
},
{
title: <div>Desired</div>,
dataIndex: 'desired_pods',
key: 'desired_pods',
width: 80,
sorter: true,
align: 'left',
},
{
title: <div>CPU Req Usage (%)</div>,
dataIndex: 'cpu_request',
key: 'cpu_request',
width: 120,
sorter: true,
align: 'left',
},
{
title: <div>CPU Limit Usage (%)</div>,
dataIndex: 'cpu_limit',
key: 'cpu_limit',
width: 120,
sorter: true,
align: 'left',
},
{
title: <div>CPU Usage (cores)</div>,
dataIndex: 'cpu',
key: 'cpu',
width: 80,
sorter: true,
align: 'left',
},
{
title: <div>Mem Req Usage (%)</div>,
dataIndex: 'memory_request',
key: 'memory_request',
width: 120,
sorter: true,
align: 'left',
},
{
title: <div>Mem Limit Usage (%)</div>,
dataIndex: 'memory_limit',
key: 'memory_limit',
width: 120,
sorter: true,
align: 'left',
},
{
title: <div>Mem Usage (WSS)</div>,
dataIndex: 'memory',
key: 'memory',
width: 80,
sorter: true,
align: 'left',
},
];
export const k8sStatefulSetsRenderRowData = (
statefulSet: K8sStatefulSetsData,
): string {
return (
statefulSet.statefulSetName || statefulSet.meta.k8s_statefulset_name || ''
);
}
export function getK8sStatefulSetItemKey(
statefulSet: K8sStatefulSetsData,
): string {
return statefulSet.meta.k8s_statefulset_name;
}
export const k8sStatefulSetsColumnsConfig: TableColumnDef<K8sStatefulSetsData>[] =
[
{
id: 'statefulSetGroup',
header: (): React.ReactNode => (
<EntityGroupHeader title="STATEFULSET GROUP" />
),
accessorFn: (row): string => row.meta.k8s_statefulset_name || '',
width: { min: 210 },
enableSort: false,
enableRemove: false,
enableMove: false,
pin: 'left',
visibilityBehavior: 'hidden-on-collapse',
cell: ({ isExpanded, toggleExpanded, row }): JSX.Element | null => {
return (
<ExpandButtonWrapper
isExpanded={isExpanded}
toggleExpanded={toggleExpanded}
>
<K8sGroupCell row={row} />
</ExpandButtonWrapper>
);
},
},
{
id: 'statefulsetName',
header: (): React.ReactNode => (
<EntityGroupHeader
title="StatefulSet Name"
icon={<ArrowUpDown data-hide-expanded="true" size={14} />}
/>
),
accessorFn: (row): string => row.meta.k8s_statefulset_name || '',
width: { min: 200 },
enableSort: false,
enableRemove: false,
enableMove: false,
pin: 'left',
visibilityBehavior: 'hidden-on-expand',
cell: ({ value }): React.ReactNode => {
const statefulsetName = value as string;
return (
<Tooltip title={statefulsetName}>
<TanStackTable.Text>{statefulsetName}</TanStackTable.Text>
</Tooltip>
);
},
},
{
id: 'namespaceName',
header: 'Namespace Name',
accessorFn: (row): string => row.meta.k8s_namespace_name || '',
width: { default: 150 },
enableSort: false,
enableResize: true,
cell: ({ value }): React.ReactNode => {
const namespaceName = value as string;
return (
<Tooltip title={namespaceName}>
<TanStackTable.Text>{namespaceName}</TanStackTable.Text>
</Tooltip>
);
},
},
{
id: 'available_pods',
header: 'Available',
accessorFn: (row): number => row.availablePods,
width: { min: 100, default: 140 },
enableSort: true,
enableResize: true,
cell: ({ value }): React.ReactNode => {
const availablePods = value as number;
return (
<ValidateColumnValueWrapper value={availablePods}>
<TanStackTable.Text>{availablePods}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
},
{
id: 'desired_pods',
header: 'Desired',
accessorFn: (row): number => row.desiredPods,
width: { min: 100, default: 140 },
enableSort: true,
enableResize: true,
cell: ({ value }): React.ReactNode => {
const desiredPods = value as number;
return (
<ValidateColumnValueWrapper value={desiredPods}>
<TanStackTable.Text>{desiredPods}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
},
{
id: 'cpu_request',
header: 'CPU Req Usage (%)',
accessorFn: (row): number => row.cpuRequest,
width: { min: 200, default: 200 },
enableSort: true,
enableResize: true,
cell: ({ value }): React.ReactNode => {
const cpuRequest = value as number;
return (
<ValidateColumnValueWrapper
value={cpuRequest}
entity={InfraMonitoringEntity.STATEFULSETS}
attribute="CPU Request"
>
<EntityProgressBar value={cpuRequest} type="request" />
</ValidateColumnValueWrapper>
);
},
},
{
id: 'cpu_limit',
header: 'CPU Limit Usage (%)',
accessorFn: (row): number => row.cpuLimit,
width: { min: 200, default: 200 },
enableSort: true,
enableResize: true,
cell: ({ value }): React.ReactNode => {
const cpuLimit = value as number;
return (
<ValidateColumnValueWrapper
value={cpuLimit}
entity={InfraMonitoringEntity.STATEFULSETS}
attribute="CPU Limit"
>
<EntityProgressBar value={cpuLimit} type="limit" />
</ValidateColumnValueWrapper>
);
},
},
{
id: 'cpu',
header: 'CPU Usage (cores)',
accessorFn: (row): number => row.cpuUsage,
width: { min: 190 },
enableSort: true,
enableResize: true,
defaultVisibility: false,
cell: ({ value }): React.ReactNode => {
const cpu = value as number;
return (
<ValidateColumnValueWrapper value={cpu}>
<TanStackTable.Text>{cpu}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
},
{
id: 'memory_request',
header: 'Mem Req Usage (%)',
accessorFn: (row): number => row.memoryRequest,
width: { min: 190 },
enableSort: true,
enableResize: true,
cell: ({ value }): React.ReactNode => {
const memoryRequest = value as number;
return (
<ValidateColumnValueWrapper
value={memoryRequest}
entity={InfraMonitoringEntity.STATEFULSETS}
attribute="Memory Request"
>
<EntityProgressBar value={memoryRequest} type="request" />
</ValidateColumnValueWrapper>
);
},
},
{
id: 'memory_limit',
header: 'Mem Limit Usage (%)',
accessorFn: (row): number => row.memoryLimit,
width: { min: 180 },
enableSort: true,
enableResize: true,
cell: ({ value }): React.ReactNode => {
const memoryLimit = value as number;
return (
<ValidateColumnValueWrapper
value={memoryLimit}
entity={InfraMonitoringEntity.STATEFULSETS}
attribute="Memory Limit"
>
<EntityProgressBar value={memoryLimit} type="limit" />
</ValidateColumnValueWrapper>
);
},
},
{
id: 'memory',
header: 'Mem Usage (WSS)',
accessorFn: (row): number => row.memoryUsage,
width: { min: 160 },
enableSort: true,
enableResize: true,
defaultVisibility: false,
cell: ({ value }): React.ReactNode => {
const memory = value as number;
return (
<ValidateColumnValueWrapper value={memory}>
<TanStackTable.Text>{formatBytes(memory)}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
},
];
groupBy: BaseAutocompleteData[],
): K8sRenderedRowData => ({
key: getRowKey(
statefulSet,
() =>
statefulSet.statefulSetName || statefulSet.meta.k8s_statefulset_name || '',
groupBy,
),
itemKey: statefulSet.meta.k8s_statefulset_name,
statefulsetName: (
<Tooltip title={statefulSet.meta.k8s_statefulset_name}>
{statefulSet.meta.k8s_statefulset_name || ''}
</Tooltip>
),
namespaceName: (
<Tooltip title={statefulSet.meta.k8s_namespace_name}>
{statefulSet.meta.k8s_namespace_name || ''}
</Tooltip>
),
cpu_request: (
<ValidateColumnValueWrapper
value={statefulSet.cpuRequest}
entity={InfraMonitoringEntity.STATEFULSETS}
attribute="CPU Request"
>
<div className={styles.progressBar}>
<EntityProgressBar value={statefulSet.cpuRequest} type="request" />
</div>
</ValidateColumnValueWrapper>
),
cpu_limit: (
<ValidateColumnValueWrapper
value={statefulSet.cpuLimit}
entity={InfraMonitoringEntity.STATEFULSETS}
attribute="CPU Limit"
>
<div className={styles.progressBar}>
<EntityProgressBar value={statefulSet.cpuLimit} type="limit" />
</div>
</ValidateColumnValueWrapper>
),
cpu: (
<ValidateColumnValueWrapper value={statefulSet.cpuUsage}>
{statefulSet.cpuUsage}
</ValidateColumnValueWrapper>
),
memory_request: (
<ValidateColumnValueWrapper
value={statefulSet.memoryRequest}
entity={InfraMonitoringEntity.STATEFULSETS}
attribute="Memory Request"
>
<div className={styles.progressBar}>
<EntityProgressBar value={statefulSet.memoryRequest} type="request" />
</div>
</ValidateColumnValueWrapper>
),
memory_limit: (
<ValidateColumnValueWrapper
value={statefulSet.memoryLimit}
entity={InfraMonitoringEntity.STATEFULSETS}
attribute="Memory Limit"
>
<div className={styles.progressBar}>
<EntityProgressBar value={statefulSet.memoryLimit} type="limit" />
</div>
</ValidateColumnValueWrapper>
),
memory: (
<ValidateColumnValueWrapper value={statefulSet.memoryUsage}>
{formatBytes(statefulSet.memoryUsage)}
</ValidateColumnValueWrapper>
),
available_pods: (
<ValidateColumnValueWrapper value={statefulSet.availablePods}>
{statefulSet.availablePods}
</ValidateColumnValueWrapper>
),
desired_pods: (
<ValidateColumnValueWrapper value={statefulSet.desiredPods}>
{statefulSet.desiredPods}
</ValidateColumnValueWrapper>
),
statefulSetGroup: getGroupByEl(statefulSet, groupBy),
...statefulSet.meta,
groupedByMeta: getGroupedByMeta(statefulSet, groupBy),
});

View File

@@ -0,0 +1,11 @@
.entityGroupHeader {
display: flex;
align-items: center;
padding-left: var(--spacing-5);
gap: var(--spacing-5);
}
.progressBar {
display: flex;
justify-content: start;
}

View File

@@ -19,9 +19,9 @@ import {
volumeWidgetInfo,
} from './constants';
import {
getK8sVolumeItemKey,
getK8sVolumeRowKey,
k8sVolumesColumns,
k8sVolumesColumnsConfig,
k8sVolumesRenderRowData,
} from './table.config';
function K8sVolumesList({
@@ -91,10 +91,10 @@ function K8sVolumesList({
<K8sBaseList<K8sVolumesData>
controlListPrefix={controlListPrefix}
entity={InfraMonitoringEntity.VOLUMES}
tableColumnsDefinitions={k8sVolumesColumns}
tableColumns={k8sVolumesColumnsConfig}
fetchListData={fetchListData}
getRowKey={getK8sVolumeRowKey}
getItemKey={getK8sVolumeItemKey}
renderRowData={k8sVolumesRenderRowData}
eventCategory={InfraMonitoringEvents.Volumes}
/>

View File

@@ -1,131 +1,164 @@
import { Tooltip } from 'antd';
import { TableColumnDef } from 'components/TanStackTableView';
import TanStackTable from 'components/TanStackTableView';
import { ExpandButtonWrapper } from 'container/InfraMonitoringK8s/components';
import { TableColumnType as ColumnType, Tooltip } from 'antd';
import { Group } from 'lucide-react';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import EntityGroupHeader from '../Base/EntityGroupHeader';
import K8sGroupCell from '../Base/K8sGroupCell';
import { formatBytes } from '../commonUtils';
import { ValidateColumnValueWrapper } from '../components';
import { K8sRenderedRowData } from '../Base/types';
import { IEntityColumn } from '../Base/useInfraMonitoringTableColumnsStore';
import { getGroupByEl, getGroupedByMeta, getRowKey } from '../Base/utils';
import { formatBytes, ValidateColumnValueWrapper } from '../commonUtils';
import { K8sVolumesData } from './api';
import { HardDrive } from 'lucide-react';
export function getK8sVolumeRowKey(volume: K8sVolumesData): string {
return (
volume.persistentVolumeClaimName ||
volume.meta.k8s_persistentvolumeclaim_name ||
''
);
}
import styles from './table.module.scss';
export function getK8sVolumeItemKey(volume: K8sVolumesData): string {
return volume.persistentVolumeClaimName;
}
export const k8sVolumesColumnsConfig: TableColumnDef<K8sVolumesData>[] = [
export const k8sVolumesColumns: IEntityColumn[] = [
{
label: 'Volume Group',
value: 'volumeGroup',
id: 'volumeGroup',
header: (): React.ReactNode => <EntityGroupHeader title="VOLUME GROUP" />,
accessorFn: (row): string => row.persistentVolumeClaimName || '',
width: { min: 300 },
enableSort: false,
enableRemove: false,
enableMove: false,
pin: 'left',
visibilityBehavior: 'hidden-on-collapse',
cell: ({ isExpanded, toggleExpanded, row }): JSX.Element | null => {
return (
<ExpandButtonWrapper
isExpanded={isExpanded}
toggleExpanded={toggleExpanded}
>
<K8sGroupCell row={row} />
</ExpandButtonWrapper>
);
},
canBeHidden: false,
defaultVisibility: true,
behavior: 'hidden-on-collapse',
},
{
label: 'PVC Name',
value: 'pvcName',
id: 'pvcName',
header: (): React.ReactNode => (
<EntityGroupHeader
title="PVC Name"
icon={<HardDrive data-hide-expanded="true" size={14} />}
/>
),
accessorFn: (row): string => row.persistentVolumeClaimName || '',
width: { min: 290 },
enableSort: false,
enableRemove: false,
enableMove: false,
pin: 'left',
visibilityBehavior: 'hidden-on-expand',
cell: ({ value }): React.ReactNode => {
const pvcName = value as string;
return (
<Tooltip title={pvcName}>
<TanStackTable.Text>{pvcName}</TanStackTable.Text>
</Tooltip>
);
},
canBeHidden: false,
defaultVisibility: true,
behavior: 'hidden-on-expand',
},
{
label: 'Namespace Name',
value: 'namespaceName',
id: 'namespaceName',
header: 'Namespace Name',
accessorFn: (row): string => row.meta.k8s_namespace_name || '',
width: { min: 220 },
enableSort: false,
cell: ({ value }): React.ReactNode => {
const namespaceName = value as string;
return (
<Tooltip title={namespaceName}>
<TanStackTable.Text>{namespaceName}</TanStackTable.Text>
</Tooltip>
);
},
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Volume Capacity',
value: 'capacity',
id: 'capacity',
header: 'Volume Capacity',
accessorFn: (row): number => row.volumeCapacity,
width: { min: 220 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const capacity = value as number;
return (
<ValidateColumnValueWrapper value={capacity}>
<TanStackTable.Text>{formatBytes(capacity)}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Volume Utilization',
value: 'usage',
id: 'usage',
header: 'Volume Utilization',
accessorFn: (row): number => row.volumeUsage,
width: { min: 220 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const usage = value as number;
return (
<ValidateColumnValueWrapper value={usage}>
<TanStackTable.Text>{formatBytes(usage)}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Volume Available',
value: 'available',
id: 'available',
header: 'Volume Available',
accessorFn: (row): number => row.volumeAvailable,
width: { min: 220 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const available = value as number;
return (
<ValidateColumnValueWrapper value={available}>
<TanStackTable.Text>{formatBytes(available)}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
];
export const k8sVolumesColumnsConfig: ColumnType<K8sRenderedRowData>[] = [
{
title: (
<div className={styles.entityGroupHeader}>
<Group size={14} /> VOLUME GROUP
</div>
),
dataIndex: 'volumeGroup',
key: 'volumeGroup',
ellipsis: true,
width: 150,
align: 'left',
sorter: false,
},
{
title: <div>PVC Name</div>,
dataIndex: 'pvcName',
key: 'pvcName',
ellipsis: true,
width: 120,
sorter: false,
align: 'left',
},
{
title: <div>Namespace Name</div>,
dataIndex: 'namespaceName',
key: 'namespaceName',
ellipsis: true,
width: 120,
sorter: false,
align: 'left',
},
{
title: <div>Volume Capacity</div>,
dataIndex: 'capacity',
key: 'capacity',
ellipsis: true,
width: 120,
sorter: true,
align: 'left',
},
{
title: <div>Volume Utilization</div>,
dataIndex: 'usage',
key: 'usage',
width: 100,
sorter: true,
align: 'left',
},
{
title: <div>Volume Available</div>,
dataIndex: 'available',
key: 'available',
width: 80,
sorter: true,
align: 'left',
},
];
export const k8sVolumesRenderRowData = (
volume: K8sVolumesData,
groupBy: BaseAutocompleteData[],
): K8sRenderedRowData => ({
key: getRowKey(
volume,
() =>
volume.persistentVolumeClaimName ||
volume.meta.k8s_persistentvolumeclaim_name ||
'',
groupBy,
),
itemKey: volume.persistentVolumeClaimName,
pvcName: (
<Tooltip title={volume.persistentVolumeClaimName}>
{volume.persistentVolumeClaimName || ''}
</Tooltip>
),
namespaceName: (
<Tooltip title={volume.meta.k8s_namespace_name}>
{volume.meta.k8s_namespace_name || ''}
</Tooltip>
),
available: (
<ValidateColumnValueWrapper value={volume.volumeAvailable}>
{formatBytes(volume.volumeAvailable)}
</ValidateColumnValueWrapper>
),
capacity: (
<ValidateColumnValueWrapper value={volume.volumeCapacity}>
{formatBytes(volume.volumeCapacity)}
</ValidateColumnValueWrapper>
),
usage: (
<ValidateColumnValueWrapper value={volume.volumeUsage}>
{formatBytes(volume.volumeUsage)}
</ValidateColumnValueWrapper>
),
volumeGroup: getGroupByEl(volume, groupBy),
...volume.meta,
groupedByMeta: getGroupedByMeta(volume, groupBy),
});

View File

@@ -0,0 +1,6 @@
.entityGroupHeader {
padding-left: var(--spacing-5);
gap: var(--spacing-5);
display: flex;
align-items: center;
}

View File

@@ -1,11 +1,21 @@
import { render, screen } from '@testing-library/react';
import { EntityProgressBar } from '../components';
import { EventContents } from '../commonUtils';
import { EntityProgressBar, EventContents } from '../commonUtils';
jest.mock('../commonUtils.module.scss', () => ({
__esModule: true,
default: {
entityProgressBar: 'entity-progress-bar-module',
progressBar: 'progress-bar-module',
eventContentContainer: 'event-content-container-module',
},
}));
jest.mock('components/ResizeTable', () => ({
ResizeTable: ({ dataSource }: { dataSource: unknown }): JSX.Element => (
<div data-testid="resize-table">{JSON.stringify(dataSource)}</div>
ResizeTable: ({ className, dataSource }: any): JSX.Element => (
<div data-testid="resize-table" className={className}>
{JSON.stringify(dataSource)}
</div>
),
}));
@@ -15,22 +25,24 @@ jest.mock('container/LogDetailedView/FieldRenderer', () => ({
}));
describe('commonUtils', () => {
it('renders EntityProgressBar with percentage value', () => {
render(<EntityProgressBar value={0.5} type="request" />);
it('renders EntityProgressBar with module classes', () => {
const { container } = render(
<EntityProgressBar value={0.5} type="request" />,
);
expect(container.firstChild).toHaveClass('entity-progress-bar-module');
expect(container.querySelector('.progress-bar-module')).toBeInTheDocument();
expect(screen.getByText('50%')).toBeInTheDocument();
});
it('renders EntityProgressBar with dash for NaN value', () => {
render(<EntityProgressBar value={NaN} type="limit" />);
expect(screen.getByText('-')).toBeInTheDocument();
});
it('renders EventContents with data fields', () => {
it('renders EventContents with the module-scoped table class', () => {
render(
<EventContents data={{ namespace: 'default', cluster: 'prod-cluster' }} />,
);
const resizeTable = screen.getByTestId('resize-table');
expect(resizeTable).toHaveClass('event-content-container-module');
expect(resizeTable).toHaveTextContent('namespace');
expect(resizeTable).toHaveTextContent('prod-cluster');
});

View File

@@ -1,3 +1,15 @@
.entityProgressBar {
display: flex;
align-items: center;
}
.progressBar {
flex: 1;
margin-right: 8px;
margin-bottom: 0;
min-width: 100px;
}
.eventContentContainer {
:global(.ant-table) {
background: var(--l1-background);

View File

@@ -2,12 +2,17 @@
import { useMemo } from 'react';
import { Color } from '@signozhq/design-tokens';
import { Table, Tooltip } from 'antd';
import { Progress, Table, Tooltip, Typography } from 'antd';
import type { ColumnsType } from 'antd/lib/table';
import { ResizeTable } from 'components/ResizeTable';
import FieldRenderer from 'container/LogDetailedView/FieldRenderer';
import { DataType } from 'container/LogDetailedView/TableView';
import { TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
import {
IBuilderQuery,
TagFilterItem,
} from 'types/api/queryBuilder/queryBuilderData';
import { getInvalidValueTooltipText, InfraMonitoringEntity } from './constants';
import styles from './commonUtils.module.scss';
@@ -15,10 +20,6 @@ import styles from './commonUtils.module.scss';
* Converts size in bytes to a human-readable string with appropriate units
*/
export function formatBytes(bytes: number, decimals = 2): string {
if (Number.isNaN(bytes) || !Number.isFinite(bytes)) {
return '-';
}
if (bytes === 0) {
return '0 Bytes';
}
@@ -30,6 +31,36 @@ export function formatBytes(bytes: number, decimals = 2): string {
return `${parseFloat((bytes / k ** i).toFixed(decimals))} ${sizes[i]}`;
}
/**
* Wrapper component that renders its children for valid values or renders '-' for invalid values (-1)
*/
export function ValidateColumnValueWrapper({
children,
value,
entity,
attribute,
}: {
children: React.ReactNode;
value: number;
entity?: InfraMonitoringEntity;
attribute?: string;
}): JSX.Element {
if (value === -1) {
let element = <div>-</div>;
if (entity && attribute) {
element = (
<Tooltip title={getInvalidValueTooltipText(entity, attribute)}>
{element}
</Tooltip>
);
}
return element;
}
return <div>{children}</div>;
}
/**
* Returns stroke color for request utilization parameters according to current value
*/
@@ -72,6 +103,35 @@ export function getStrokeColorForLimitUtilization(value: number): string {
return Color.BG_SAKURA_500;
}
export function EntityProgressBar({
value,
type,
}: {
value: number;
type: 'request' | 'limit';
}): JSX.Element {
const percentage = Number((value * 100).toFixed(1));
return (
<div className={styles.entityProgressBar}>
<Progress
percent={percentage}
strokeLinecap="butt"
size="small"
status="normal"
strokeColor={
type === 'limit'
? getStrokeColorForLimitUtilization(value)
: getStrokeColorForRequestUtilization(value)
}
className={styles.progressBar}
showInfo={false}
/>
<Typography.Text style={{ fontSize: '10px' }}>{percentage}%</Typography.Text>
</div>
);
}
export function EventContents({
data,
}: {
@@ -188,3 +248,19 @@ export const filterDuplicateFilters = (
return uniqueFilters;
};
export const getFiltersFromParams = (
searchParams: URLSearchParams,
queryKey: string,
): IBuilderQuery['filters'] | null => {
const filtersFromParams = searchParams.get(queryKey);
if (filtersFromParams) {
try {
const parsed = JSON.parse(filtersFromParams);
return parsed as IBuilderQuery['filters'];
} catch {
return null;
}
}
return null;
};

View File

@@ -1,11 +0,0 @@
.entityProgressBar {
display: flex;
align-items: center;
}
.progressBar {
flex: 1;
margin-right: 8px;
margin-bottom: 0;
min-width: 100px;
}

View File

@@ -1,65 +0,0 @@
import { Progress } from 'antd';
import TanStackTable from 'components/TanStackTableView';
import {
getMemoryProgressColor,
getProgressColor,
} from 'container/InfraMonitoringHosts/constants';
import {
getStrokeColorForLimitUtilization,
getStrokeColorForRequestUtilization,
} from '../commonUtils';
import styles from './EntityProgressBar.module.scss';
type EntityProgressBarType = 'request' | 'limit' | 'cpu' | 'memory';
function getStrokeColor(type: EntityProgressBarType, value: number): string {
switch (type) {
case 'limit':
return getStrokeColorForLimitUtilization(value);
case 'request':
return getStrokeColorForRequestUtilization(value);
case 'cpu':
return getProgressColor(Number((value * 100).toFixed(1)));
case 'memory':
return getMemoryProgressColor(Number((value * 100).toFixed(1)));
default:
return getStrokeColorForRequestUtilization(value);
}
}
export function EntityProgressBar({
value,
type,
}: {
value: number;
type: EntityProgressBarType;
}): JSX.Element {
const percentage = Number.isNaN(+value)
? null
: Number((value * 100).toFixed(1));
if (percentage === null) {
return (
<div className={styles.entityProgressBar}>
<TanStackTable.Text>-</TanStackTable.Text>
</div>
);
}
return (
<div className={styles.entityProgressBar}>
<Progress
percent={percentage}
strokeLinecap="butt"
size="small"
status="normal"
strokeColor={getStrokeColor(type, value)}
className={styles.progressBar}
showInfo={false}
/>
<TanStackTable.Text>{percentage}%</TanStackTable.Text>
</div>
);
}

View File

@@ -1,39 +0,0 @@
import { Button } from '@signozhq/ui';
import { ChevronDown, ChevronRight } from 'lucide-react';
import styles from './ExpandedButtonWrapper.module.scss';
import { useEffect, useState } from 'react';
export function ExpandButtonWrapper({
toggleExpanded,
isExpanded,
children,
}: {
toggleExpanded: () => void;
isExpanded: boolean;
children?: React.ReactNode;
}): JSX.Element {
// the state is duplicated because it takes a few ms to propagate using isExpanded
// so this local is used to avoid this delay
const [localIsExpanded, setLocalIsExpanded] = useState(isExpanded);
useEffect(() => {
setLocalIsExpanded(isExpanded);
}, [isExpanded]);
return (
<div className={styles.expandButtonContainer}>
<Button
variant="ghost"
color="secondary"
onClick={(e): void => {
e.stopPropagation();
setLocalIsExpanded((v) => !v);
toggleExpanded();
}}
size="icon"
prefix={localIsExpanded ? <ChevronDown /> : <ChevronRight />}
/>
{children}
</div>
);
}

View File

@@ -1,7 +0,0 @@
.expandButtonContainer {
display: flex;
& > button {
margin-right: var(--spacing-4);
}
}

View File

@@ -1,34 +0,0 @@
import { Tooltip } from 'antd';
import TanStackTable from 'components/TanStackTableView';
import {
getInvalidValueTooltipText,
InfraMonitoringEntity,
} from '../constants';
export function ValidateColumnValueWrapper({
children,
value,
entity,
attribute,
}: {
children: React.ReactNode;
value: number;
entity?: InfraMonitoringEntity;
attribute?: string;
}): JSX.Element {
if (value === -1) {
let element = <TanStackTable.Text>-</TanStackTable.Text>;
if (entity && attribute) {
element = (
<Tooltip title={getInvalidValueTooltipText(entity, attribute)}>
{element}
</Tooltip>
);
}
return element;
}
return <>{children}</>;
}

View File

@@ -1,3 +0,0 @@
export { EntityProgressBar } from './EntityProgressBar';
export { ValidateColumnValueWrapper } from './ValidateColumnValueWrapper';
export { ExpandButtonWrapper } from './ExpandButtonWrapper';

View File

@@ -800,8 +800,5 @@ export const INFRA_MONITORING_K8S_PARAMS_KEYS = {
EVENTS_FILTERS: 'eventsFilters',
HOSTS_FILTERS: 'hostsFilters',
CURRENT_PAGE: 'currentPage',
PAGE: 'page',
PAGE_SIZE: 'pageSize',
EXPANDED: 'expanded',
SELECTED_ITEM: 'selectedItem',
};

View File

@@ -13,11 +13,8 @@ import {
} from 'types/api/queryBuilder/queryBuilderData';
import { parseAsJsonNoValidate } from 'utils/nuqsParsers';
import {
INFRA_MONITORING_K8S_PARAMS_KEYS,
K8sCategories,
VIEWS,
} from './constants';
import { VIEWS } from './constants';
import { INFRA_MONITORING_K8S_PARAMS_KEYS, K8sCategories } from './constants';
import { orderBySchema, OrderBySchemaType } from './schemas';
const defaultNuqsOptions: Options = {
@@ -33,24 +30,6 @@ export const useInfraMonitoringCurrentPage = (): UseQueryStateReturn<
parseAsInteger.withDefault(1).withOptions(defaultNuqsOptions),
);
export const useInfraMonitoringPageListing = (): UseQueryStateReturn<
number,
number
> =>
useQueryState(
INFRA_MONITORING_K8S_PARAMS_KEYS.PAGE,
parseAsInteger.withDefault(1).withOptions(defaultNuqsOptions),
);
export const useInfraMonitoringPageSizeListing = (): UseQueryStateReturn<
number,
number
> =>
useQueryState(
INFRA_MONITORING_K8S_PARAMS_KEYS.PAGE_SIZE,
parseAsInteger.withDefault(10).withOptions(defaultNuqsOptions),
);
export const useInfraMonitoringOrderBy = (): UseQueryStateReturn<
OrderBySchemaType,
OrderBySchemaType
@@ -119,7 +98,7 @@ export const useInfraMonitoringCategory = (): UseQueryStateReturn<
parseAsString.withDefault(K8sCategories.PODS).withOptions(defaultNuqsOptions),
);
export const useInfraMonitoringFiltersK8s = (): UseQueryStateReturn<
export const useInfraMonitoringFilters = (): UseQueryStateReturn<
TagFilter,
undefined
> =>

View File

@@ -1,11 +1,10 @@
import { GoogleSquareFilled, KeyOutlined } from '@ant-design/icons';
import { Button, Typography } from 'antd';
import { AuthtypesAuthNProviderDTO } from 'api/generated/services/sigNoz.schemas';
import './CreateEdit.styles.scss';
interface AuthNProvider {
key: AuthtypesAuthNProviderDTO;
key: string;
title: string;
description: string;
icon: JSX.Element;
@@ -15,14 +14,14 @@ interface AuthNProvider {
function getAuthNProviders(samlEnabled: boolean): AuthNProvider[] {
return [
{
key: AuthtypesAuthNProviderDTO.google_auth,
key: 'google_auth',
title: 'Google Apps Authentication',
description: 'Let members sign-in with a Google workspace account',
icon: <GoogleSquareFilled style={{ fontSize: '37px' }} />,
enabled: true,
},
{
key: AuthtypesAuthNProviderDTO.saml,
key: 'saml',
title: 'SAML Authentication',
description:
'Azure, Active Directory, Okta or your custom SAML 2.0 solution',
@@ -31,7 +30,7 @@ function getAuthNProviders(samlEnabled: boolean): AuthNProvider[] {
},
{
key: AuthtypesAuthNProviderDTO.oidc,
key: 'oidc',
title: 'OIDC Authentication',
description:
'Authenticate using OpenID Connect providers like Azure, Active Directory, Okta, or other OIDC compliant solutions',
@@ -45,9 +44,7 @@ function AuthnProviderSelector({
setAuthnProvider,
samlEnabled,
}: {
setAuthnProvider: React.Dispatch<
React.SetStateAction<AuthtypesAuthNProviderDTO | ''>
>;
setAuthnProvider: React.Dispatch<React.SetStateAction<string>>;
samlEnabled: boolean;
}): JSX.Element {
const authnProviders = getAuthNProviders(samlEnabled);

View File

@@ -7,7 +7,6 @@ import {
useUpdateAuthDomain,
} from 'api/generated/services/authdomains';
import {
AuthtypesAuthNProviderDTO,
AuthtypesGettableAuthDomainDTO,
AuthtypesGoogleConfigDTO,
AuthtypesRoleMappingDTO,
@@ -58,9 +57,9 @@ interface CreateOrEditProps {
function CreateOrEdit(props: CreateOrEditProps): JSX.Element {
const { isCreate, record, onClose } = props;
const [form] = Form.useForm<FormValues>();
const [authnProvider, setAuthnProvider] = useState<
AuthtypesAuthNProviderDTO | ''
>(record?.ssoType || '');
const [authnProvider, setAuthnProvider] = useState<string>(
record?.ssoType || '',
);
const { showErrorModal } = useErrorModal();
const { featureFlags } = useAppContext();
@@ -139,10 +138,6 @@ function CreateOrEdit(props: CreateOrEditProps): JSX.Element {
return;
}
if (authnProvider === '') {
return;
}
const name = form.getFieldValue('name');
const googleAuthConfig = getGoogleAuthConfig();
const samlConfig = form.getFieldValue('samlConfig');

View File

@@ -1,7 +1,4 @@
import {
AuthtypesAuthNProviderDTO,
AuthtypesGettableAuthDomainDTO,
} from 'api/generated/services/sigNoz.schemas';
import { AuthtypesGettableAuthDomainDTO } from 'api/generated/services/sigNoz.schemas';
// API Endpoints
export const AUTH_DOMAINS_LIST_ENDPOINT = '*/api/v1/domains';
@@ -14,7 +11,7 @@ export const mockGoogleAuthDomain: AuthtypesGettableAuthDomainDTO = {
id: 'domain-1',
name: 'signoz.io',
ssoEnabled: true,
ssoType: AuthtypesAuthNProviderDTO.google_auth,
ssoType: 'google_auth',
googleAuthConfig: {
clientId: 'test-client-id',
clientSecret: 'test-client-secret',
@@ -29,7 +26,7 @@ export const mockSamlAuthDomain: AuthtypesGettableAuthDomainDTO = {
id: 'domain-2',
name: 'example.com',
ssoEnabled: false,
ssoType: AuthtypesAuthNProviderDTO.saml,
ssoType: 'saml',
samlConfig: {
samlIdp: 'https://idp.example.com/sso',
samlEntity: 'urn:example:idp',
@@ -45,7 +42,7 @@ export const mockOidcAuthDomain: AuthtypesGettableAuthDomainDTO = {
id: 'domain-3',
name: 'corp.io',
ssoEnabled: true,
ssoType: AuthtypesAuthNProviderDTO.oidc,
ssoType: 'oidc',
oidcConfig: {
issuer: 'https://oidc.corp.io',
clientId: 'oidc-client-id',
@@ -61,7 +58,7 @@ export const mockDomainWithRoleMapping: AuthtypesGettableAuthDomainDTO = {
id: 'domain-4',
name: 'enterprise.com',
ssoEnabled: true,
ssoType: AuthtypesAuthNProviderDTO.saml,
ssoType: 'saml',
samlConfig: {
samlIdp: 'https://idp.enterprise.com/sso',
samlEntity: 'urn:enterprise:idp',
@@ -87,7 +84,7 @@ export const mockDomainWithDirectRoleAttribute: AuthtypesGettableAuthDomainDTO =
id: 'domain-5',
name: 'direct-role.com',
ssoEnabled: true,
ssoType: AuthtypesAuthNProviderDTO.oidc,
ssoType: 'oidc',
oidcConfig: {
issuer: 'https://oidc.direct-role.com',
clientId: 'direct-role-client-id',
@@ -107,7 +104,7 @@ export const mockOidcWithClaimMapping: AuthtypesGettableAuthDomainDTO = {
id: 'domain-6',
name: 'oidc-claims.com',
ssoEnabled: true,
ssoType: AuthtypesAuthNProviderDTO.oidc,
ssoType: 'oidc',
oidcConfig: {
issuer: 'https://oidc.claims.com',
issuerAlias: 'https://alias.claims.com',
@@ -132,7 +129,7 @@ export const mockSamlWithAttributeMapping: AuthtypesGettableAuthDomainDTO = {
id: 'domain-7',
name: 'saml-attrs.com',
ssoEnabled: true,
ssoType: AuthtypesAuthNProviderDTO.saml,
ssoType: 'saml',
samlConfig: {
samlIdp: 'https://idp.saml-attrs.com/sso',
samlEntity: 'urn:saml-attrs:idp',
@@ -155,7 +152,7 @@ export const mockGoogleAuthWithWorkspaceGroups: AuthtypesGettableAuthDomainDTO =
id: 'domain-8',
name: 'google-groups.com',
ssoEnabled: true,
ssoType: AuthtypesAuthNProviderDTO.google_auth,
ssoType: 'google_auth',
googleAuthConfig: {
clientId: 'google-groups-client-id',
clientSecret: 'google-groups-client-secret',

View File

@@ -17,7 +17,7 @@ import dayjs, { Dayjs } from 'dayjs';
import {
useGlobalTimeQueryInvalidate,
useIsGlobalTimeQueryRefreshing,
} from 'store/globalTime';
} from 'hooks/globalTime';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
@@ -101,14 +101,14 @@ function DateTimeSelection({
if (modalInitialStartTime !== undefined) {
initialModalStartTime = modalInitialStartTime;
} else if (searchStartTime) {
initialModalStartTime = Number.parseInt(searchStartTime, 10);
initialModalStartTime = parseInt(searchStartTime, 10);
}
let initialModalEndTime = 0;
if (modalInitialEndTime !== undefined) {
initialModalEndTime = modalInitialEndTime;
} else if (searchEndTime) {
initialModalEndTime = Number.parseInt(searchEndTime, 10);
initialModalEndTime = parseInt(searchEndTime, 10);
}
const [modalStartTime, setModalStartTime] = useState<number>(
@@ -159,11 +159,9 @@ function DateTimeSelection({
const getTime = useCallback((): [number, number] | undefined => {
if (searchEndTime && searchStartTime) {
const startDate = dayjs(
new Date(Number.parseInt(getTimeString(searchStartTime), 10)),
);
const endDate = dayjs(
new Date(Number.parseInt(getTimeString(searchEndTime), 10)),
new Date(parseInt(getTimeString(searchStartTime), 10)),
);
const endDate = dayjs(new Date(parseInt(getTimeString(searchEndTime), 10)));
return [startDate.toDate().getTime() || 0, endDate.toDate().getTime() || 0];
}

View File

@@ -0,0 +1,2 @@
export { useGlobalTimeQueryInvalidate } from './useGlobalTimeQueryInvalidate';
export { useIsGlobalTimeQueryRefreshing } from './useIsGlobalTimeQueryRefreshing';

View File

@@ -0,0 +1,16 @@
import { useCallback } from 'react';
import { useQueryClient } from 'react-query';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
/**
* Use when you want to invalida any query tracked by {@link REACT_QUERY_KEY.AUTO_REFRESH_QUERY}
*/
export function useGlobalTimeQueryInvalidate(): () => Promise<void> {
const queryClient = useQueryClient();
return useCallback(async () => {
return await queryClient.invalidateQueries({
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY],
});
}, [queryClient]);
}

View File

@@ -0,0 +1,13 @@
import { useIsFetching } from 'react-query';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
/**
* Use when you want to know if any query tracked by {@link REACT_QUERY_KEY.AUTO_REFRESH_QUERY} is refreshing
*/
export function useIsGlobalTimeQueryRefreshing(): boolean {
return (
useIsFetching({
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY],
}) > 0
);
}

View File

@@ -1,71 +0,0 @@
import {
// oxlint-disable-next-line no-restricted-imports
createContext,
ReactNode,
// oxlint-disable-next-line no-restricted-imports
useContext,
useState,
} from 'react';
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
import get from 'api/browser/localstorage/get';
import {
createGlobalTimeStore,
defaultGlobalTimeStore,
GlobalTimeStoreApi,
} from './globalTimeStore';
import { GlobalTimeProviderOptions, GlobalTimeSelectedTime } from './types';
import { usePersistence } from './usePersistence';
import { useQueryCacheSync } from './useQueryCacheSync';
import { useUrlSync } from './useUrlSync';
import { useComputedMinMaxSync } from 'store/globalTime/useComputedMinMaxSync';
export const GlobalTimeContext = createContext<GlobalTimeStoreApi | null>(null);
export function GlobalTimeProvider({
children,
name,
inheritGlobalTime = false,
initialTime,
enableUrlParams = false,
removeQueryParamsOnUnmount = false,
localStoragePersistKey,
refreshInterval: initialRefreshInterval,
}: GlobalTimeProviderOptions & { children: ReactNode }): JSX.Element {
const parentStore = useContext(GlobalTimeContext);
const globalStore = parentStore ?? defaultGlobalTimeStore;
const resolveInitialTime = (): GlobalTimeSelectedTime => {
if (inheritGlobalTime) {
return globalStore.getState().selectedTime;
}
if (localStoragePersistKey) {
const stored = get(localStoragePersistKey);
if (stored) {
return stored as GlobalTimeSelectedTime;
}
}
return initialTime ?? DEFAULT_TIME_RANGE;
};
// Create isolated store (stable reference)
const [store] = useState(() =>
createGlobalTimeStore({
name,
selectedTime: resolveInitialTime(),
refreshInterval: initialRefreshInterval ?? 0,
}),
);
useComputedMinMaxSync(store);
useQueryCacheSync(store);
useUrlSync(store, enableUrlParams, removeQueryParamsOnUnmount);
usePersistence(store, localStoragePersistKey);
return (
<GlobalTimeContext.Provider value={store}>
{children}
</GlobalTimeContext.Provider>
);
}

View File

@@ -1,693 +0,0 @@
import { act, renderHook, waitFor } from '@testing-library/react';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import { ReactNode } from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
import set from 'api/browser/localstorage/set';
import { GlobalTimeProvider } from '../GlobalTimeContext';
import { useGlobalTime } from '../hooks';
import { GlobalTimeProviderOptions } from '../types';
import { createCustomTimeRange, NANO_SECOND_MULTIPLIER } from '../utils';
jest.mock('api/browser/localstorage/set');
const createTestQueryClient = (): QueryClient =>
new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
const createWrapper = (
providerProps: GlobalTimeProviderOptions,
nuqsProps?: { searchParams?: string },
) => {
const queryClient = createTestQueryClient();
return function Wrapper({ children }: { children: ReactNode }): JSX.Element {
return (
<QueryClientProvider client={queryClient}>
<NuqsTestingAdapter searchParams={nuqsProps?.searchParams}>
<GlobalTimeProvider {...providerProps}>{children}</GlobalTimeProvider>
</NuqsTestingAdapter>
</QueryClientProvider>
);
};
};
describe('GlobalTimeProvider', () => {
describe('name prop', () => {
it('should pass name to store when provided', () => {
const wrapper = createWrapper({ name: 'test-drawer' });
const { result } = renderHook(() => useGlobalTime((s) => s.name), {
wrapper,
});
expect(result.current).toBe('test-drawer');
});
it('should have undefined name when not provided', () => {
const wrapper = createWrapper({});
const { result } = renderHook(() => useGlobalTime((s) => s.name), {
wrapper,
});
expect(result.current).toBeUndefined();
});
});
describe('store isolation', () => {
it('should create isolated store for each provider', () => {
const wrapper1 = createWrapper({ initialTime: '1h' });
const wrapper2 = createWrapper({ initialTime: '15m' });
const { result: result1 } = renderHook(
() => useGlobalTime((s) => s.selectedTime),
{ wrapper: wrapper1 },
);
const { result: result2 } = renderHook(
() => useGlobalTime((s) => s.selectedTime),
{ wrapper: wrapper2 },
);
expect(result1.current).toBe('1h');
expect(result2.current).toBe('15m');
});
});
describe('inheritGlobalTime', () => {
it('should inherit time from parent store when inheritGlobalTime is true', () => {
const queryClient = createTestQueryClient();
const NestedWrapper = ({
children,
}: {
children: React.ReactNode;
}): JSX.Element => (
<QueryClientProvider client={queryClient}>
<NuqsTestingAdapter>
<GlobalTimeProvider initialTime="6h">
<GlobalTimeProvider inheritGlobalTime>{children}</GlobalTimeProvider>
</GlobalTimeProvider>
</NuqsTestingAdapter>
</QueryClientProvider>
);
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
wrapper: NestedWrapper,
});
// Should inherit '6h' from parent provider
expect(result.current).toBe('6h');
});
it('should use initialTime when inheritGlobalTime is false', () => {
const queryClient = createTestQueryClient();
const NestedWrapper = ({
children,
}: {
children: React.ReactNode;
}): JSX.Element => (
<QueryClientProvider client={queryClient}>
<NuqsTestingAdapter>
<GlobalTimeProvider initialTime="6h">
<GlobalTimeProvider inheritGlobalTime={false} initialTime="15m">
{children}
</GlobalTimeProvider>
</GlobalTimeProvider>
</NuqsTestingAdapter>
</QueryClientProvider>
);
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
wrapper: NestedWrapper,
});
// Should use its own initialTime, not parent's
expect(result.current).toBe('15m');
});
it('should prefer URL params over inheritGlobalTime when both are present', async () => {
const queryClient = createTestQueryClient();
const NestedWrapper = ({
children,
}: {
children: React.ReactNode;
}): JSX.Element => (
<QueryClientProvider client={queryClient}>
<NuqsTestingAdapter searchParams="?relativeTime=1h">
<GlobalTimeProvider initialTime="6h">
<GlobalTimeProvider inheritGlobalTime enableUrlParams>
{children}
</GlobalTimeProvider>
</GlobalTimeProvider>
</NuqsTestingAdapter>
</QueryClientProvider>
);
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
wrapper: NestedWrapper,
});
// inheritGlobalTime sets initial value to '6h', but URL sync updates it to '1h'
await waitFor(() => {
expect(result.current).toBe('1h');
});
});
it('should use inherited time when URL params are empty', async () => {
const queryClient = createTestQueryClient();
const NestedWrapper = ({
children,
}: {
children: React.ReactNode;
}): JSX.Element => (
<QueryClientProvider client={queryClient}>
<NuqsTestingAdapter searchParams="">
<GlobalTimeProvider initialTime="6h">
<GlobalTimeProvider inheritGlobalTime enableUrlParams>
{children}
</GlobalTimeProvider>
</GlobalTimeProvider>
</NuqsTestingAdapter>
</QueryClientProvider>
);
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
wrapper: NestedWrapper,
});
// No URL params, should keep inherited value
expect(result.current).toBe('6h');
});
it('should prefer custom time URL params over inheritGlobalTime', async () => {
const queryClient = createTestQueryClient();
const startTime = 1700000000000;
const endTime = 1700003600000;
const NestedWrapper = ({
children,
}: {
children: React.ReactNode;
}): JSX.Element => (
<QueryClientProvider client={queryClient}>
<NuqsTestingAdapter
searchParams={`?startTime=${startTime}&endTime=${endTime}`}
>
<GlobalTimeProvider initialTime="6h">
<GlobalTimeProvider inheritGlobalTime enableUrlParams>
{children}
</GlobalTimeProvider>
</GlobalTimeProvider>
</NuqsTestingAdapter>
</QueryClientProvider>
);
const { result } = renderHook(() => useGlobalTime(), {
wrapper: NestedWrapper,
});
// URL custom time params should override inherited time
await waitFor(() => {
const { minTime, maxTime } = result.current.getMinMaxTime();
expect(minTime).toBe(startTime * NANO_SECOND_MULTIPLIER);
expect(maxTime).toBe(endTime * NANO_SECOND_MULTIPLIER);
});
});
});
describe('URL sync', () => {
it('should read relativeTime from URL on mount', async () => {
const wrapper = createWrapper(
{ enableUrlParams: true },
{ searchParams: '?relativeTime=1h' },
);
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
wrapper,
});
await waitFor(() => {
expect(result.current).toBe('1h');
});
});
it('should read custom time from URL on mount', async () => {
const startTime = 1700000000000;
const endTime = 1700003600000;
const wrapper = createWrapper(
{ enableUrlParams: true },
{ searchParams: `?startTime=${startTime}&endTime=${endTime}` },
);
const { result } = renderHook(() => useGlobalTime(), { wrapper });
await waitFor(() => {
const { minTime, maxTime } = result.current.getMinMaxTime();
expect(minTime).toBe(startTime * NANO_SECOND_MULTIPLIER);
expect(maxTime).toBe(endTime * NANO_SECOND_MULTIPLIER);
});
});
it('should use custom URL keys when provided', async () => {
const wrapper = createWrapper(
{
enableUrlParams: {
relativeTimeKey: 'modalTime',
},
},
{ searchParams: '?modalTime=3h' },
);
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
wrapper,
});
await waitFor(() => {
expect(result.current).toBe('3h');
});
});
it('should use custom startTimeKey and endTimeKey when provided', async () => {
const startTime = 1700000000000;
const endTime = 1700003600000;
const wrapper = createWrapper(
{
enableUrlParams: {
startTimeKey: 'customStart',
endTimeKey: 'customEnd',
},
},
{ searchParams: `?customStart=${startTime}&customEnd=${endTime}` },
);
const { result } = renderHook(() => useGlobalTime(), { wrapper });
await waitFor(() => {
const { minTime, maxTime } = result.current.getMinMaxTime();
expect(minTime).toBe(startTime * NANO_SECOND_MULTIPLIER);
expect(maxTime).toBe(endTime * NANO_SECOND_MULTIPLIER);
});
});
it('should NOT read from URL when enableUrlParams is false', async () => {
const wrapper = createWrapper(
{ enableUrlParams: false, initialTime: '15m' },
{ searchParams: '?relativeTime=1h' },
);
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
wrapper,
});
// Should use initialTime, not URL value
expect(result.current).toBe('15m');
});
it('should prefer startTime/endTime over relativeTime when both present in URL', async () => {
const startTime = 1700000000000;
const endTime = 1700003600000;
const wrapper = createWrapper(
{ enableUrlParams: true },
{
searchParams: `?relativeTime=15m&startTime=${startTime}&endTime=${endTime}`,
},
);
const { result } = renderHook(() => useGlobalTime(), { wrapper });
await waitFor(() => {
const { minTime, maxTime } = result.current.getMinMaxTime();
// Should use startTime/endTime, not relativeTime
expect(minTime).toBe(startTime * NANO_SECOND_MULTIPLIER);
expect(maxTime).toBe(endTime * NANO_SECOND_MULTIPLIER);
});
});
it('should use initialTime when URL has invalid time values', async () => {
const wrapper = createWrapper(
{ enableUrlParams: true, initialTime: '15m' },
{ searchParams: '?startTime=invalid&endTime=also-invalid' },
);
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
wrapper,
});
// parseAsInteger returns null for invalid values, so should fallback to initialTime
expect(result.current).toBe('15m');
});
it('should update store when custom time is set from URL with only startTime and endTime', async () => {
const startTime = 1700000000000;
const endTime = 1700003600000;
const wrapper = createWrapper(
{ enableUrlParams: true },
{ searchParams: `?startTime=${startTime}&endTime=${endTime}` },
);
const { result } = renderHook(() => useGlobalTime(), { wrapper });
await waitFor(() => {
// Verify selectedTime is a custom time range string
expect(result.current.selectedTime).toContain('||_||');
});
});
describe('removeQueryParamsOnUnmount', () => {
const createUnmountTestWrapper = (
getQueryString: () => string,
setQueryString: (qs: string) => void,
) => {
return function TestWrapper({
children,
}: {
children: React.ReactNode;
}): JSX.Element {
const queryClient = createTestQueryClient();
return (
<QueryClientProvider client={queryClient}>
<NuqsTestingAdapter
searchParams={getQueryString()}
onUrlUpdate={(event): void => {
setQueryString(event.queryString);
}}
>
{children}
</NuqsTestingAdapter>
</QueryClientProvider>
);
};
};
it('should remove URL params when provider unmounts with removeQueryParamsOnUnmount=true', async () => {
let currentQueryString = 'relativeTime=1h';
const TestWrapper = createUnmountTestWrapper(
() => currentQueryString,
(qs) => {
currentQueryString = qs;
},
);
const { unmount } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
wrapper: ({ children }) => (
<TestWrapper>
<GlobalTimeProvider enableUrlParams removeQueryParamsOnUnmount>
{children}
</GlobalTimeProvider>
</TestWrapper>
),
});
// Verify initial URL params are present
expect(currentQueryString).toContain('relativeTime=1h');
// Unmount the provider
unmount();
// URL params should be removed
await waitFor(() => {
expect(currentQueryString).not.toContain('relativeTime');
expect(currentQueryString).not.toContain('startTime');
expect(currentQueryString).not.toContain('endTime');
});
});
it('should NOT remove URL params when provider unmounts with removeQueryParamsOnUnmount=false', async () => {
let currentQueryString = 'relativeTime=1h';
const TestWrapper = createUnmountTestWrapper(
() => currentQueryString,
(qs) => {
currentQueryString = qs;
},
);
const { unmount } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
wrapper: ({ children }) => (
<TestWrapper>
<GlobalTimeProvider enableUrlParams removeQueryParamsOnUnmount={false}>
{children}
</GlobalTimeProvider>
</TestWrapper>
),
});
// Verify initial URL params are present
expect(currentQueryString).toContain('relativeTime=1h');
// Unmount the provider
unmount();
// Wait a tick to ensure cleanup effects would have run
await waitFor(() => {
// URL params should still be present
expect(currentQueryString).toContain('relativeTime=1h');
});
});
it('should remove custom time URL params on unmount', async () => {
const startTime = 1700000000000;
const endTime = 1700003600000;
let currentQueryString = `startTime=${startTime}&endTime=${endTime}`;
const TestWrapper = createUnmountTestWrapper(
() => currentQueryString,
(qs) => {
currentQueryString = qs;
},
);
const { unmount } = renderHook(() => useGlobalTime(), {
wrapper: ({ children }) => (
<TestWrapper>
<GlobalTimeProvider enableUrlParams removeQueryParamsOnUnmount>
{children}
</GlobalTimeProvider>
</TestWrapper>
),
});
// Verify initial URL params are present
expect(currentQueryString).toContain('startTime');
expect(currentQueryString).toContain('endTime');
// Unmount the provider
unmount();
// URL params should be removed
await waitFor(() => {
expect(currentQueryString).not.toContain('startTime');
expect(currentQueryString).not.toContain('endTime');
});
});
it('should remove custom URL key params on unmount', async () => {
let currentQueryString = 'modalTime=3h';
const TestWrapper = createUnmountTestWrapper(
() => currentQueryString,
(qs) => {
currentQueryString = qs;
},
);
const { unmount } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
wrapper: ({ children }) => (
<TestWrapper>
<GlobalTimeProvider
enableUrlParams={{
relativeTimeKey: 'modalTime',
}}
removeQueryParamsOnUnmount
>
{children}
</GlobalTimeProvider>
</TestWrapper>
),
});
// Verify initial URL params are present
expect(currentQueryString).toContain('modalTime=3h');
// Unmount the provider
unmount();
// URL params should be removed
await waitFor(() => {
expect(currentQueryString).not.toContain('modalTime');
});
});
it('should NOT remove URL params when enableUrlParams is false', async () => {
let currentQueryString = 'relativeTime=1h';
const TestWrapper = createUnmountTestWrapper(
() => currentQueryString,
(qs) => {
currentQueryString = qs;
},
);
const { unmount } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
wrapper: ({ children }) => (
<TestWrapper>
<GlobalTimeProvider enableUrlParams={false} removeQueryParamsOnUnmount>
{children}
</GlobalTimeProvider>
</TestWrapper>
),
});
// Verify initial URL params are present
expect(currentQueryString).toContain('relativeTime=1h');
// Unmount the provider
unmount();
// Wait a tick
await waitFor(() => {
// URL params should still be present (enableUrlParams is false)
expect(currentQueryString).toContain('relativeTime=1h');
});
});
});
});
describe('localStorage persistence', () => {
const mockSet = set as jest.MockedFunction<typeof set>;
beforeEach(() => {
localStorage.clear();
mockSet.mockClear();
mockSet.mockReturnValue(true);
});
it('should read from localStorage on mount', () => {
localStorage.setItem('test-time-key', '6h');
const wrapper = createWrapper({ localStoragePersistKey: 'test-time-key' });
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
wrapper,
});
expect(result.current).toBe('6h');
});
it('should write to localStorage on selectedTime change', async () => {
const wrapper = createWrapper({
localStoragePersistKey: 'test-persist-key',
});
const { result } = renderHook(() => useGlobalTime(), { wrapper });
mockSet.mockClear();
act(() => {
result.current.setSelectedTime('12h');
});
await waitFor(() => {
expect(mockSet).toHaveBeenCalledWith('test-persist-key', '12h');
});
});
it('should NOT write to localStorage when persistKey is undefined', async () => {
const wrapper = createWrapper({ initialTime: '15m' });
const { result } = renderHook(() => useGlobalTime(), { wrapper });
mockSet.mockClear();
act(() => {
result.current.setSelectedTime('1h');
});
// Wait a tick to ensure any async operations complete
await waitFor(() => {
expect(result.current.selectedTime).toBe('1h');
});
expect(mockSet).not.toHaveBeenCalled();
});
it('should only write to localStorage when selectedTime changes, not other state', async () => {
const wrapper = createWrapper({
localStoragePersistKey: 'test-key',
initialTime: '15m',
});
const { result } = renderHook(() => useGlobalTime(), { wrapper });
mockSet.mockClear();
// Change refreshInterval (not selectedTime)
act(() => {
result.current.setRefreshInterval(5000);
});
// Wait to ensure subscription handler had a chance to run
await waitFor(() => {
expect(result.current.refreshInterval).toBe(5000);
});
// Should NOT have written to localStorage for refreshInterval change
expect(mockSet).not.toHaveBeenCalled();
// Now change selectedTime
act(() => {
result.current.setSelectedTime('1h');
});
await waitFor(() => {
expect(mockSet).toHaveBeenCalledWith('test-key', '1h');
});
});
it('should fallback to initialTime when localStorage contains empty string', () => {
localStorage.setItem('test-key', '');
const wrapper = createWrapper({
localStoragePersistKey: 'test-key',
initialTime: '15m',
});
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
wrapper,
});
// Empty string is falsy, should use initialTime
expect(result.current).toBe('15m');
});
it('should write custom time range to localStorage', async () => {
const wrapper = createWrapper({
localStoragePersistKey: 'test-custom-key',
initialTime: '15m',
});
const { result } = renderHook(() => useGlobalTime(), { wrapper });
mockSet.mockClear();
const customTime = createCustomTimeRange(1000000000, 2000000000);
act(() => {
result.current.setSelectedTime(customTime);
});
await waitFor(() => {
expect(mockSet).toHaveBeenCalledWith('test-custom-key', customTime);
});
});
});
describe('refreshInterval', () => {
it('should initialize with provided refreshInterval', () => {
const wrapper = createWrapper({ refreshInterval: 5000 });
const { result } = renderHook(() => useGlobalTime(), { wrapper });
expect(result.current.refreshInterval).toBe(5000);
expect(result.current.isRefreshEnabled).toBe(true);
});
});
});

View File

@@ -1,148 +0,0 @@
import { act } from '@testing-library/react';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
import {
createGlobalTimeStore,
defaultGlobalTimeStore,
} from '../globalTimeStore';
import { createCustomTimeRange } from '../utils';
describe('createGlobalTimeStore', () => {
describe('factory function', () => {
it('should create independent store instances', () => {
const store1 = createGlobalTimeStore();
const store2 = createGlobalTimeStore();
store1.getState().setSelectedTime('1h');
expect(store1.getState().selectedTime).toBe('1h');
expect(store2.getState().selectedTime).toBe(DEFAULT_TIME_RANGE);
});
it('should accept initial state', () => {
const store = createGlobalTimeStore({
selectedTime: '15m',
refreshInterval: 5000,
});
expect(store.getState().selectedTime).toBe('15m');
expect(store.getState().refreshInterval).toBe(5000);
expect(store.getState().isRefreshEnabled).toBe(true);
});
it('should compute isRefreshEnabled correctly for custom time', () => {
const customTime = createCustomTimeRange(1000000000, 2000000000);
const store = createGlobalTimeStore({
selectedTime: customTime,
refreshInterval: 5000,
});
expect(store.getState().isRefreshEnabled).toBe(false);
});
});
describe('defaultGlobalTimeStore', () => {
it('should be a singleton', () => {
expect(defaultGlobalTimeStore).toBeDefined();
expect(defaultGlobalTimeStore.getState().selectedTime).toBeDefined();
});
});
describe('setRefreshInterval', () => {
it('should update refresh interval and enable refresh', () => {
const store = createGlobalTimeStore();
act(() => {
store.getState().setRefreshInterval(10000);
});
expect(store.getState().refreshInterval).toBe(10000);
expect(store.getState().isRefreshEnabled).toBe(true);
});
it('should disable refresh when interval is 0', () => {
const store = createGlobalTimeStore({ refreshInterval: 5000 });
act(() => {
store.getState().setRefreshInterval(0);
});
expect(store.getState().refreshInterval).toBe(0);
expect(store.getState().isRefreshEnabled).toBe(false);
});
it('should not enable refresh for custom time range', () => {
const customTime = createCustomTimeRange(1000000000, 2000000000);
const store = createGlobalTimeStore({ selectedTime: customTime });
act(() => {
store.getState().setRefreshInterval(10000);
});
expect(store.getState().refreshInterval).toBe(10000);
expect(store.getState().isRefreshEnabled).toBe(false);
});
});
describe('store name', () => {
it('should store name when provided', () => {
const store = createGlobalTimeStore({ name: 'drawer' });
expect(store.getState().name).toBe('drawer');
});
it('should have undefined name when not provided', () => {
const store = createGlobalTimeStore();
expect(store.getState().name).toBeUndefined();
});
});
describe('getAutoRefreshQueryKey', () => {
it('should generate key without name for unnamed store', () => {
const store = createGlobalTimeStore();
const key = store
.getState()
.getAutoRefreshQueryKey('15m', 'MY_QUERY', 'param1');
expect(key).toStrictEqual([
REACT_QUERY_KEY.AUTO_REFRESH_QUERY,
'MY_QUERY',
'param1',
'15m',
]);
});
it('should generate key with name for named store', () => {
const store = createGlobalTimeStore({ name: 'drawer' });
const key = store
.getState()
.getAutoRefreshQueryKey('15m', 'MY_QUERY', 'param1');
expect(key).toStrictEqual([
REACT_QUERY_KEY.AUTO_REFRESH_QUERY,
'drawer',
'MY_QUERY',
'param1',
'15m',
]);
});
it('should handle no query parts for named store', () => {
const store = createGlobalTimeStore({ name: 'test' });
const key = store.getState().getAutoRefreshQueryKey('1h');
expect(key).toStrictEqual([
REACT_QUERY_KEY.AUTO_REFRESH_QUERY,
'test',
'1h',
]);
});
it('should handle no query parts for unnamed store', () => {
const store = createGlobalTimeStore();
const key = store.getState().getAutoRefreshQueryKey('1h');
expect(key).toStrictEqual([REACT_QUERY_KEY.AUTO_REFRESH_QUERY, '1h']);
});
});
});

View File

@@ -0,0 +1,202 @@
import { act, renderHook } from '@testing-library/react';
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
import { useGlobalTimeStore } from '../globalTimeStore';
import { GlobalTimeSelectedTime } from '../types';
import { createCustomTimeRange, NANO_SECOND_MULTIPLIER } from '../utils';
describe('globalTimeStore', () => {
beforeEach(() => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime(DEFAULT_TIME_RANGE, 0);
});
});
describe('initial state', () => {
it(`should have default selectedTime of ${DEFAULT_TIME_RANGE}`, () => {
const { result } = renderHook(() => useGlobalTimeStore());
expect(result.current.selectedTime).toBe(DEFAULT_TIME_RANGE);
});
it('should have isRefreshEnabled as false by default', () => {
const { result } = renderHook(() => useGlobalTimeStore());
expect(result.current.isRefreshEnabled).toBe(false);
});
it('should have refreshInterval as 0 by default', () => {
const { result } = renderHook(() => useGlobalTimeStore());
expect(result.current.refreshInterval).toBe(0);
});
});
describe('setSelectedTime', () => {
it('should update selectedTime', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m');
});
expect(result.current.selectedTime).toBe('15m');
});
it('should update refreshInterval when provided', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m', 5000);
});
expect(result.current.refreshInterval).toBe(5000);
});
it('should keep existing refreshInterval when not provided', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m', 5000);
});
act(() => {
result.current.setSelectedTime('1h');
});
expect(result.current.refreshInterval).toBe(5000);
});
it('should enable refresh for relative time with refreshInterval > 0', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m', 5000);
});
expect(result.current.isRefreshEnabled).toBe(true);
});
it('should disable refresh for relative time with refreshInterval = 0', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m', 0);
});
expect(result.current.isRefreshEnabled).toBe(false);
});
it('should disable refresh for custom time range even with refreshInterval > 0', () => {
const { result } = renderHook(() => useGlobalTimeStore());
const customTime = createCustomTimeRange(1000000000, 2000000000);
act(() => {
result.current.setSelectedTime(customTime, 5000);
});
expect(result.current.isRefreshEnabled).toBe(false);
expect(result.current.refreshInterval).toBe(5000);
});
it('should handle various relative time formats', () => {
const { result } = renderHook(() => useGlobalTimeStore());
const timeFormats: GlobalTimeSelectedTime[] = [
'1m',
'5m',
'15m',
'30m',
'1h',
'3h',
'6h',
'1d',
'1w',
];
timeFormats.forEach((time) => {
act(() => {
result.current.setSelectedTime(time, 10000);
});
expect(result.current.selectedTime).toBe(time);
expect(result.current.isRefreshEnabled).toBe(true);
});
});
});
describe('getMinMaxTime', () => {
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2024-01-15T12:00:00.000Z'));
});
afterEach(() => {
jest.useRealTimers();
});
it('should return min/max time for custom time range', () => {
const { result } = renderHook(() => useGlobalTimeStore());
const minTime = 1000000000;
const maxTime = 2000000000;
const customTime = createCustomTimeRange(minTime, maxTime);
act(() => {
result.current.setSelectedTime(customTime);
});
const { minTime: resultMin, maxTime: resultMax } =
result.current.getMinMaxTime();
expect(resultMin).toBe(minTime);
expect(resultMax).toBe(maxTime);
});
it('should compute fresh min/max time for relative time', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m');
});
const { minTime, maxTime } = result.current.getMinMaxTime();
const now = Date.now() * NANO_SECOND_MULTIPLIER;
const fifteenMinutesNs = 15 * 60 * 1000 * NANO_SECOND_MULTIPLIER;
expect(maxTime).toBe(now);
expect(minTime).toBe(now - fifteenMinutesNs);
});
it('should return different values on subsequent calls for relative time', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m');
});
const first = result.current.getMinMaxTime();
// Advance time by 1 second
act(() => {
jest.advanceTimersByTime(1000);
});
const second = result.current.getMinMaxTime();
// maxTime should be different (1 second later)
expect(second.maxTime).toBe(first.maxTime + 1000 * NANO_SECOND_MULTIPLIER);
expect(second.minTime).toBe(first.minTime + 1000 * NANO_SECOND_MULTIPLIER);
});
});
describe('store isolation', () => {
it('should share state between multiple hook instances', () => {
const { result: result1 } = renderHook(() => useGlobalTimeStore());
const { result: result2 } = renderHook(() => useGlobalTimeStore());
act(() => {
result1.current.setSelectedTime('1h', 10000);
});
expect(result2.current.selectedTime).toBe('1h');
expect(result2.current.refreshInterval).toBe(10000);
expect(result2.current.isRefreshEnabled).toBe(true);
});
});
});

View File

@@ -1,868 +0,0 @@
import { act, renderHook } from '@testing-library/react';
import { ReactNode } from 'react';
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
import { createGlobalTimeStore, useGlobalTimeStore } from '../globalTimeStore';
import { GlobalTimeContext } from '../GlobalTimeContext';
import { useGlobalTime } from '../hooks';
import { GlobalTimeSelectedTime, GlobalTimeState } from '../types';
import { createCustomTimeRange, NANO_SECOND_MULTIPLIER } from '../utils';
/**
* Creates an isolated store wrapper for testing.
* Each test gets its own store instance, avoiding test pollution.
*/
function createIsolatedWrapper(
initialState?: Partial<GlobalTimeState>,
): ({ children }: { children: ReactNode }) => JSX.Element {
const store = createGlobalTimeStore(initialState);
return function Wrapper({ children }: { children: ReactNode }): JSX.Element {
return (
<GlobalTimeContext.Provider value={store}>
{children}
</GlobalTimeContext.Provider>
);
};
}
describe('globalTimeStore', () => {
beforeEach(() => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime(DEFAULT_TIME_RANGE, 0);
});
});
describe('initial state', () => {
it(`should have default selectedTime of ${DEFAULT_TIME_RANGE}`, () => {
const { result } = renderHook(() => useGlobalTimeStore());
expect(result.current.selectedTime).toBe(DEFAULT_TIME_RANGE);
});
it('should have isRefreshEnabled as false by default', () => {
const { result } = renderHook(() => useGlobalTimeStore());
expect(result.current.isRefreshEnabled).toBe(false);
});
it('should have refreshInterval as 0 by default', () => {
const { result } = renderHook(() => useGlobalTimeStore());
expect(result.current.refreshInterval).toBe(0);
});
it('should have lastRefreshTimestamp as 0 by default', () => {
const { result } = renderHook(() => useGlobalTimeStore());
expect(result.current.lastRefreshTimestamp).toBe(0);
});
it('should have lastComputedMinMax with default values', () => {
const { result } = renderHook(() => useGlobalTimeStore());
expect(result.current.lastComputedMinMax).toStrictEqual({
minTime: 0,
maxTime: 0,
});
});
});
describe('setSelectedTime', () => {
it('should update selectedTime', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m');
});
expect(result.current.selectedTime).toBe('15m');
});
it('should update refreshInterval when provided', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m', 5000);
});
expect(result.current.refreshInterval).toBe(5000);
});
it('should keep existing refreshInterval when not provided', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m', 5000);
});
act(() => {
result.current.setSelectedTime('1h');
});
expect(result.current.refreshInterval).toBe(5000);
});
it('should enable refresh for relative time with refreshInterval > 0', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m', 5000);
});
expect(result.current.isRefreshEnabled).toBe(true);
});
it('should disable refresh for relative time with refreshInterval = 0', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m', 0);
});
expect(result.current.isRefreshEnabled).toBe(false);
});
it('should disable refresh for custom time range even with refreshInterval > 0', () => {
const { result } = renderHook(() => useGlobalTimeStore());
const customTime = createCustomTimeRange(1000000000, 2000000000);
act(() => {
result.current.setSelectedTime(customTime, 5000);
});
expect(result.current.isRefreshEnabled).toBe(false);
expect(result.current.refreshInterval).toBe(5000);
});
it('should handle various relative time formats', () => {
const { result } = renderHook(() => useGlobalTimeStore());
const timeFormats: GlobalTimeSelectedTime[] = [
'1m',
'5m',
'15m',
'30m',
'1h',
'3h',
'6h',
'1d',
'1w',
];
timeFormats.forEach((time) => {
act(() => {
result.current.setSelectedTime(time, 10000);
});
expect(result.current.selectedTime).toBe(time);
expect(result.current.isRefreshEnabled).toBe(true);
});
});
it('should compute and store lastComputedMinMax when selectedTime changes', () => {
const wrapper = createIsolatedWrapper({
selectedTime: '15m',
refreshInterval: 5000,
});
const { result } = renderHook(() => useGlobalTime(), { wrapper });
// setSelectedTime computes values on init (createIsolatedWrapper uses createGlobalTimeStore)
// But initial store state has minTime/maxTime as 0 until first setSelectedTime is called
const initialMinMax = { ...result.current.lastComputedMinMax };
// Now switch to a custom time range
const customTime = createCustomTimeRange(1000000000, 2000000000);
act(() => {
result.current.setSelectedTime(customTime);
});
// lastComputedMinMax should be updated to the custom range values
expect(result.current.lastComputedMinMax).toStrictEqual({
minTime: 1000000000,
maxTime: 2000000000,
});
expect(result.current.lastComputedMinMax).not.toStrictEqual(initialMinMax);
});
it('should return fresh custom time values after switching from relative time', () => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2024-01-15T12:00:00.000Z'));
const wrapper = createIsolatedWrapper({
selectedTime: '15m',
refreshInterval: 5000,
});
const { result } = renderHook(() => useGlobalTime(), { wrapper });
// Compute and cache values for relative time
act(() => {
result.current.computeAndStoreMinMax();
});
const relativeMinMax = { ...result.current.lastComputedMinMax };
// Switch to custom time range
const customMinTime = 5000000000;
const customMaxTime = 6000000000;
const customTime = createCustomTimeRange(customMinTime, customMaxTime);
act(() => {
result.current.setSelectedTime(customTime);
});
// getMinMaxTime should return the custom time values, not cached relative values
const returned = result.current.getMinMaxTime();
expect(returned.minTime).toBe(customMinTime);
expect(returned.maxTime).toBe(customMaxTime);
expect(returned).not.toStrictEqual(relativeMinMax);
jest.useRealTimers();
});
});
describe('getMinMaxTime', () => {
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2024-01-15T12:00:00.000Z'));
});
afterEach(() => {
jest.useRealTimers();
});
it('should return min/max time for custom time range', () => {
const { result } = renderHook(() => useGlobalTimeStore());
const minTime = 1000000000;
const maxTime = 2000000000;
const customTime = createCustomTimeRange(minTime, maxTime);
act(() => {
result.current.setSelectedTime(customTime);
});
const { minTime: resultMin, maxTime: resultMax } =
result.current.getMinMaxTime();
expect(resultMin).toBe(minTime);
expect(resultMax).toBe(maxTime);
});
it('should NOT round custom time range values to minute boundaries', () => {
const { result } = renderHook(() => useGlobalTimeStore());
// Use timestamps that are NOT on minute boundaries (12:30:45.123)
// If rounding occurred, these would change to 12:30:00.000
const minTimeWithSeconds =
new Date('2024-01-15T12:15:45.123Z').getTime() * NANO_SECOND_MULTIPLIER;
const maxTimeWithSeconds =
new Date('2024-01-15T12:30:45.123Z').getTime() * NANO_SECOND_MULTIPLIER;
// What the values would be if rounded down to minute boundary
const minTimeRounded =
new Date('2024-01-15T12:15:00.000Z').getTime() * NANO_SECOND_MULTIPLIER;
const maxTimeRounded =
new Date('2024-01-15T12:30:00.000Z').getTime() * NANO_SECOND_MULTIPLIER;
const customTime = createCustomTimeRange(
minTimeWithSeconds,
maxTimeWithSeconds,
);
act(() => {
result.current.setSelectedTime(customTime);
});
const { minTime, maxTime } = result.current.getMinMaxTime();
// Should return exact values, NOT rounded values
expect(minTime).toBe(minTimeWithSeconds);
expect(maxTime).toBe(maxTimeWithSeconds);
expect(minTime).not.toBe(minTimeRounded);
expect(maxTime).not.toBe(maxTimeRounded);
});
it('should compute fresh min/max time for relative time', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m');
});
const { minTime, maxTime } = result.current.getMinMaxTime();
const now = Date.now() * NANO_SECOND_MULTIPLIER;
const fifteenMinutesNs = 15 * 60 * 1000 * NANO_SECOND_MULTIPLIER;
expect(maxTime).toBe(now);
expect(minTime).toBe(now - fifteenMinutesNs);
});
it('should return same values on subsequent calls when refresh disabled (under minute boundary)', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m', 0); // refresh disabled
});
const first = result.current.getMinMaxTime();
act(() => {
jest.advanceTimersByTime(59000);
});
const second = result.current.getMinMaxTime();
// With refresh disabled, should return cached lastComputedMinMax
expect(second.maxTime).toBe(first.maxTime);
expect(second.minTime).toBe(first.minTime);
});
it('should return different values on subsequent calls when refresh disabled after minute boundary', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m', 0); // refresh disabled
});
const first = result.current.getMinMaxTime();
act(() => {
jest.advanceTimersByTime(60000);
});
// Without refresh enabled, getMinMaxTime returns cached values
// Need to call computeAndStoreMinMax to get new values
const second = result.current.getMinMaxTime();
expect(second.maxTime).toBe(first.maxTime);
// After computing, values should update
act(() => {
result.current.computeAndStoreMinMax();
});
const third = result.current.getMinMaxTime();
expect(third.maxTime).toBe(first.maxTime + 60000 * NANO_SECOND_MULTIPLIER);
expect(third.minTime).toBe(first.minTime + 60000 * NANO_SECOND_MULTIPLIER);
});
it('should return stored lastComputedMinMax when available', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m');
result.current.computeAndStoreMinMax();
});
const stored = { ...result.current.lastComputedMinMax };
// Advance time by 5 seconds
act(() => {
jest.advanceTimersByTime(5000);
});
// getMinMaxTime should return stored values, not fresh computation
const returned = result.current.getMinMaxTime();
expect(returned).toStrictEqual(stored);
});
describe('with isRefreshEnabled (isolated store)', () => {
it('should compute fresh values when isRefreshEnabled is true (5s rounding)', () => {
jest.setSystemTime(new Date('2024-01-15T12:00:00.000Z')); // Start at 5s boundary
const wrapper = createIsolatedWrapper({
selectedTime: '15m',
refreshInterval: 5000,
});
const { result } = renderHook(() => useGlobalTime(), { wrapper });
// getMinMaxTime computes 5s-rounded values when refresh enabled
const initialMinMax = result.current.getMinMaxTime();
// Advance time by 5 seconds to cross 5s boundary
act(() => {
jest.advanceTimersByTime(5000);
});
// getMinMaxTime should return fresh values, not cached
const freshValues = result.current.getMinMaxTime();
expect(freshValues.maxTime).toBe(
initialMinMax.maxTime + 5000 * NANO_SECOND_MULTIPLIER,
);
expect(freshValues.minTime).toBe(
initialMinMax.minTime + 5000 * NANO_SECOND_MULTIPLIER,
);
});
it('should update lastComputedMinMax when values change (5s rounding)', () => {
jest.setSystemTime(new Date('2024-01-15T12:00:00.000Z')); // Start at 5s boundary
const wrapper = createIsolatedWrapper({
selectedTime: '15m',
refreshInterval: 5000,
});
const { result } = renderHook(() => useGlobalTime(), { wrapper });
// Get initial values (uses 5s rounding when refresh enabled)
const initialMinMax = result.current.getMinMaxTime();
// Advance time by 5 seconds to cross 5s boundary
act(() => {
jest.advanceTimersByTime(5000);
});
// Call getMinMaxTime - should update lastComputedMinMax
act(() => {
result.current.getMinMaxTime();
});
expect(result.current.lastComputedMinMax.maxTime).toBe(
initialMinMax.maxTime + 5000 * NANO_SECOND_MULTIPLIER,
);
expect(result.current.lastComputedMinMax.minTime).toBe(
initialMinMax.minTime + 5000 * NANO_SECOND_MULTIPLIER,
);
});
it('should update lastRefreshTimestamp when values change', () => {
const wrapper = createIsolatedWrapper({
selectedTime: '15m',
refreshInterval: 5000,
});
const { result } = renderHook(() => useGlobalTime(), { wrapper });
act(() => {
result.current.computeAndStoreMinMax();
});
const initialTimestamp = result.current.lastRefreshTimestamp;
// Advance time past minute boundary
act(() => {
jest.advanceTimersByTime(60000);
});
// Call getMinMaxTime - should update timestamp
act(() => {
result.current.getMinMaxTime();
});
expect(result.current.lastRefreshTimestamp).toBeGreaterThan(
initialTimestamp,
);
});
it('should NOT update lastComputedMinMax when values have not changed (same 5s window)', () => {
jest.setSystemTime(new Date('2024-01-15T12:00:00.000Z')); // Start at 5s boundary
const wrapper = createIsolatedWrapper({
selectedTime: '15m',
refreshInterval: 5000,
});
const { result } = renderHook(() => useGlobalTime(), { wrapper });
// Get initial values (triggers computation for 5s-rounded values)
result.current.getMinMaxTime();
const initialMinMax = { ...result.current.lastComputedMinMax };
const initialTimestamp = result.current.lastRefreshTimestamp;
// Advance time but stay within same 5-second window
act(() => {
jest.advanceTimersByTime(4000);
});
// Call getMinMaxTime - should NOT update store (same 5s boundary)
act(() => {
result.current.getMinMaxTime();
});
// Values should be unchanged (no unnecessary re-renders)
expect(result.current.lastComputedMinMax).toStrictEqual(initialMinMax);
expect(result.current.lastRefreshTimestamp).toBe(initialTimestamp);
});
it('should return cached values when isRefreshEnabled is false', () => {
const wrapper = createIsolatedWrapper({
selectedTime: '15m',
refreshInterval: 0, // Refresh disabled
});
const { result } = renderHook(() => useGlobalTime(), { wrapper });
act(() => {
result.current.computeAndStoreMinMax();
});
const storedMinMax = { ...result.current.lastComputedMinMax };
// Advance time past minute boundary
act(() => {
jest.advanceTimersByTime(60000);
});
// getMinMaxTime should return cached values since refresh is disabled
const returned = result.current.getMinMaxTime();
expect(returned).toStrictEqual(storedMinMax);
expect(result.current.lastComputedMinMax).toStrictEqual(storedMinMax);
});
it('should return same values for custom time range regardless of time passing', () => {
const minTime = 1000000000;
const maxTime = 2000000000;
const customTime = createCustomTimeRange(minTime, maxTime);
const wrapper = createIsolatedWrapper({
selectedTime: customTime,
refreshInterval: 5000,
});
const { result } = renderHook(() => useGlobalTime(), { wrapper });
// isRefreshEnabled should be false for custom time ranges
expect(result.current.isRefreshEnabled).toBe(false);
// Custom time ranges always return the fixed values, not relative to "now"
const first = result.current.getMinMaxTime();
expect(first.minTime).toBe(minTime);
expect(first.maxTime).toBe(maxTime);
// Advance time past minute boundary
act(() => {
jest.advanceTimersByTime(60000);
});
// Should still return the same fixed values (custom range doesn't drift)
const second = result.current.getMinMaxTime();
expect(second.minTime).toBe(minTime);
expect(second.maxTime).toBe(maxTime);
});
it('should handle multiple consecutive refetch intervals correctly (5s rounding)', () => {
jest.setSystemTime(new Date('2024-01-15T12:00:00.000Z')); // Start at 5s boundary
const wrapper = createIsolatedWrapper({
selectedTime: '15m',
refreshInterval: 5000,
});
const { result } = renderHook(() => useGlobalTime(), { wrapper });
// Get initial values
const initialMinMax = result.current.getMinMaxTime();
// Simulate 3 refetch intervals crossing 5-second boundaries
for (let i = 1; i <= 3; i++) {
act(() => {
jest.advanceTimersByTime(5000);
});
act(() => {
result.current.getMinMaxTime();
});
expect(result.current.lastComputedMinMax.maxTime).toBe(
initialMinMax.maxTime + i * 5000 * NANO_SECOND_MULTIPLIER,
);
}
});
});
});
describe('computeAndStoreMinMax', () => {
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2024-01-15T12:30:45.123Z'));
});
afterEach(() => {
jest.useRealTimers();
});
it('should compute and store min/max values', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m');
});
act(() => {
result.current.computeAndStoreMinMax();
});
// maxTime should be the current time (no rounding when refresh disabled)
const expectedMaxTime =
new Date('2024-01-15T12:30:45.123Z').getTime() * NANO_SECOND_MULTIPLIER;
const fifteenMinutesNs = 15 * 60 * 1000 * NANO_SECOND_MULTIPLIER;
expect(result.current.lastComputedMinMax.maxTime).toBe(expectedMaxTime);
expect(result.current.lastComputedMinMax.minTime).toBe(
expectedMaxTime - fifteenMinutesNs,
);
});
it('should update lastRefreshTimestamp', () => {
const { result } = renderHook(() => useGlobalTimeStore());
const beforeTimestamp = Date.now();
act(() => {
result.current.computeAndStoreMinMax();
});
expect(result.current.lastRefreshTimestamp).toBeGreaterThanOrEqual(
beforeTimestamp,
);
});
it('should return the computed values', () => {
const { result } = renderHook(() => useGlobalTimeStore());
let returnedValue: { minTime: number; maxTime: number } | undefined;
act(() => {
returnedValue = result.current.computeAndStoreMinMax();
});
expect(returnedValue).toStrictEqual(result.current.lastComputedMinMax);
});
it('should NOT round custom time range values to minute boundaries', () => {
const { result } = renderHook(() => useGlobalTimeStore());
// Use timestamps that are NOT on minute boundaries (12:30:45.123)
// If rounding occurred, these would change to 12:30:00.000
const minTimeWithSeconds =
new Date('2024-01-15T12:15:45.123Z').getTime() * NANO_SECOND_MULTIPLIER;
const maxTimeWithSeconds =
new Date('2024-01-15T12:30:45.123Z').getTime() * NANO_SECOND_MULTIPLIER;
// What the values would be if rounded down to minute boundary
const minTimeRounded =
new Date('2024-01-15T12:15:00.000Z').getTime() * NANO_SECOND_MULTIPLIER;
const maxTimeRounded =
new Date('2024-01-15T12:30:00.000Z').getTime() * NANO_SECOND_MULTIPLIER;
const customTime = createCustomTimeRange(
minTimeWithSeconds,
maxTimeWithSeconds,
);
act(() => {
result.current.setSelectedTime(customTime);
});
let returnedValue: { minTime: number; maxTime: number } | undefined;
act(() => {
returnedValue = result.current.computeAndStoreMinMax();
});
// Should return exact values, NOT rounded values
expect(returnedValue?.minTime).toBe(minTimeWithSeconds);
expect(returnedValue?.maxTime).toBe(maxTimeWithSeconds);
expect(returnedValue?.minTime).not.toBe(minTimeRounded);
expect(returnedValue?.maxTime).not.toBe(maxTimeRounded);
// lastComputedMinMax should also have exact values
expect(result.current.lastComputedMinMax.minTime).toBe(minTimeWithSeconds);
expect(result.current.lastComputedMinMax.maxTime).toBe(maxTimeWithSeconds);
});
});
describe('updateRefreshTimestamp', () => {
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2024-01-15T12:30:45.123Z'));
});
afterEach(() => {
jest.useRealTimers();
});
it('should update lastRefreshTimestamp to current time', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.updateRefreshTimestamp();
});
expect(result.current.lastRefreshTimestamp).toBe(Date.now());
});
it('should not modify lastComputedMinMax', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.computeAndStoreMinMax();
});
const beforeMinMax = { ...result.current.lastComputedMinMax };
act(() => {
jest.advanceTimersByTime(5000);
result.current.updateRefreshTimestamp();
});
expect(result.current.lastComputedMinMax).toStrictEqual(beforeMinMax);
});
});
describe('store isolation', () => {
it('should share state between multiple hook instances', () => {
const { result: result1 } = renderHook(() => useGlobalTimeStore());
const { result: result2 } = renderHook(() => useGlobalTimeStore());
act(() => {
result1.current.setSelectedTime('1h', 10000);
});
expect(result2.current.selectedTime).toBe('1h');
expect(result2.current.refreshInterval).toBe(10000);
expect(result2.current.isRefreshEnabled).toBe(true);
});
});
describe('setSelectedTime (min/max computation)', () => {
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2024-01-15T12:00:00.000Z'));
});
afterEach(() => {
jest.useRealTimers();
});
it('should compute and store min/max for relative time on setSelectedTime', () => {
const wrapper = createIsolatedWrapper();
const { result } = renderHook(() => useGlobalTime(), { wrapper });
// Initial state has 0 values
expect(result.current.lastComputedMinMax.maxTime).toBe(0);
act(() => {
result.current.setSelectedTime('15m');
});
// Should have computed values immediately
const expectedMaxTime =
new Date('2024-01-15T12:00:00.000Z').getTime() * NANO_SECOND_MULTIPLIER;
const fifteenMinutesNs = 15 * 60 * 1000 * NANO_SECOND_MULTIPLIER;
expect(result.current.lastComputedMinMax.maxTime).toBe(expectedMaxTime);
expect(result.current.lastComputedMinMax.minTime).toBe(
expectedMaxTime - fifteenMinutesNs,
);
});
it('should compute and store min/max for custom time on setSelectedTime', () => {
const wrapper = createIsolatedWrapper();
const { result } = renderHook(() => useGlobalTime(), { wrapper });
const minTime = 1000000000;
const maxTime = 2000000000;
const customTime = createCustomTimeRange(minTime, maxTime);
act(() => {
result.current.setSelectedTime(customTime);
});
expect(result.current.lastComputedMinMax.minTime).toBe(minTime);
expect(result.current.lastComputedMinMax.maxTime).toBe(maxTime);
});
it('should update lastRefreshTimestamp on setSelectedTime', () => {
const wrapper = createIsolatedWrapper();
const { result } = renderHook(() => useGlobalTime(), { wrapper });
expect(result.current.lastRefreshTimestamp).toBe(0);
act(() => {
result.current.setSelectedTime('15m');
});
expect(result.current.lastRefreshTimestamp).toBe(Date.now());
});
it('should skip update when same selectedTime and refreshInterval', () => {
const wrapper = createIsolatedWrapper({
selectedTime: '15m',
refreshInterval: 5000,
});
const { result } = renderHook(() => useGlobalTime(), { wrapper });
// Set initial values
act(() => {
result.current.setSelectedTime('15m', 5000);
});
const initialTimestamp = result.current.lastRefreshTimestamp;
// Advance time
act(() => {
jest.advanceTimersByTime(1000);
});
// Try to set same values again
act(() => {
result.current.setSelectedTime('15m', 5000);
});
// Should not have updated timestamp (no state change)
expect(result.current.lastRefreshTimestamp).toBe(initialTimestamp);
});
});
describe('computeAndStoreMinMax (refresh behavior)', () => {
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2024-01-15T12:00:00.000Z'));
});
afterEach(() => {
jest.useRealTimers();
});
it('should skip computation and return lastComputedMinMax when refresh is enabled', () => {
const wrapper = createIsolatedWrapper({
selectedTime: '15m',
refreshInterval: 5000,
});
const { result } = renderHook(() => useGlobalTime(), { wrapper });
// Get initial values via getMinMaxTime (which computes for refresh enabled)
const initialMinMax = result.current.getMinMaxTime();
// Advance time
act(() => {
jest.advanceTimersByTime(60000);
});
// computeAndStoreMinMax should skip computation when refresh is enabled
let returnedValue: { minTime: number; maxTime: number } | undefined;
act(() => {
returnedValue = result.current.computeAndStoreMinMax();
});
// Should return the current lastComputedMinMax, not fresh computation
expect(returnedValue).toStrictEqual(initialMinMax);
});
it('should compute fresh values when refresh is disabled', () => {
const wrapper = createIsolatedWrapper({
selectedTime: '15m',
refreshInterval: 0, // Disabled
});
const { result } = renderHook(() => useGlobalTime(), { wrapper });
// Get initial values
act(() => {
result.current.computeAndStoreMinMax();
});
const initialMinMax = { ...result.current.lastComputedMinMax };
// Advance time past minute boundary
act(() => {
jest.advanceTimersByTime(60000);
});
// computeAndStoreMinMax should compute fresh values
let returnedValue: { minTime: number; maxTime: number } | undefined;
act(() => {
returnedValue = result.current.computeAndStoreMinMax();
});
// Should return new values
expect(returnedValue?.maxTime).toBe(
initialMinMax.maxTime + 60000 * NANO_SECOND_MULTIPLIER,
);
});
});
});

View File

@@ -1,190 +0,0 @@
import { act, renderHook } from '@testing-library/react';
import { ReactNode } from 'react';
import { createGlobalTimeStore } from '../globalTimeStore';
import { GlobalTimeContext } from '../GlobalTimeContext';
import {
useGlobalTime,
useGlobalTimeStoreApi,
useIsCustomTimeRange,
useLastComputedMinMax,
} from '../hooks';
import { useComputedMinMaxSync } from '../useComputedMinMaxSync';
import { createCustomTimeRange } from '../utils';
describe('useGlobalTime', () => {
it('should return full store state without selector', () => {
const { result } = renderHook(() => useGlobalTime());
expect(result.current.selectedTime).toBeDefined();
expect(result.current.setSelectedTime).toBeInstanceOf(Function);
});
it('should return selected value with selector', () => {
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime));
expect(typeof result.current).toBe('string');
});
it('should use context store when provided', () => {
const contextStore = createGlobalTimeStore({ selectedTime: '1h' });
const wrapper = ({ children }: { children: ReactNode }): JSX.Element => (
<GlobalTimeContext.Provider value={contextStore}>
{children}
</GlobalTimeContext.Provider>
);
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
wrapper,
});
expect(result.current).toBe('1h');
});
});
describe('useIsCustomTimeRange', () => {
it('should return false for relative time', () => {
const { result } = renderHook(() => useIsCustomTimeRange());
expect(result.current).toBe(false);
});
it('should return true for custom time range', () => {
const customTime = createCustomTimeRange(1000000000, 2000000000);
const contextStore = createGlobalTimeStore({ selectedTime: customTime });
const { result } = renderHook(() => useIsCustomTimeRange(), {
wrapper: ({ children }: { children: ReactNode }): JSX.Element => (
<GlobalTimeContext.Provider value={contextStore}>
{children}
</GlobalTimeContext.Provider>
),
});
expect(result.current).toBe(true);
});
});
describe('useGlobalTimeStoreApi', () => {
it('should return store API', () => {
const { result } = renderHook(() => useGlobalTimeStoreApi());
expect(result.current.getState).toBeInstanceOf(Function);
expect(result.current.subscribe).toBeInstanceOf(Function);
});
});
describe('useLastComputedMinMax', () => {
it('should return lastComputedMinMax from store', () => {
const contextStore = createGlobalTimeStore({ selectedTime: '15m' });
// Compute the min/max first
contextStore.getState().computeAndStoreMinMax();
const { result } = renderHook(() => useLastComputedMinMax(), {
wrapper: ({ children }: { children: ReactNode }): JSX.Element => (
<GlobalTimeContext.Provider value={contextStore}>
{children}
</GlobalTimeContext.Provider>
),
});
expect(result.current).toStrictEqual(
contextStore.getState().lastComputedMinMax,
);
});
it('should update when store changes', () => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2024-01-15T12:30:45.123Z'));
const contextStore = createGlobalTimeStore({ selectedTime: '15m' });
contextStore.getState().computeAndStoreMinMax();
const { result } = renderHook(() => useLastComputedMinMax(), {
wrapper: ({ children }: { children: ReactNode }): JSX.Element => (
<GlobalTimeContext.Provider value={contextStore}>
{children}
</GlobalTimeContext.Provider>
),
});
const firstValue = { ...result.current };
// Change time and recompute
act(() => {
jest.advanceTimersByTime(60000); // Advance 1 minute
contextStore.getState().computeAndStoreMinMax();
});
expect(result.current).not.toStrictEqual(firstValue);
jest.useRealTimers();
});
});
describe('useComputedMinMaxSync', () => {
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2024-01-15T12:30:45.123Z'));
});
afterEach(() => {
jest.useRealTimers();
});
it('should compute min/max on mount when store has zero values', () => {
const contextStore = createGlobalTimeStore({ selectedTime: '15m' });
expect(contextStore.getState().lastComputedMinMax).toStrictEqual({
minTime: 0,
maxTime: 0,
});
renderHook(() => useComputedMinMaxSync(contextStore));
// Should have computed values now
expect(contextStore.getState().lastComputedMinMax.maxTime).toBeGreaterThan(0);
expect(contextStore.getState().lastComputedMinMax.minTime).toBeGreaterThan(0);
});
it('should NOT recompute when store already has values', () => {
const contextStore = createGlobalTimeStore({ selectedTime: '15m' });
contextStore.getState().computeAndStoreMinMax();
const initialMinMax = { ...contextStore.getState().lastComputedMinMax };
const initialTimestamp = contextStore.getState().lastRefreshTimestamp;
jest.advanceTimersByTime(60000);
renderHook(() => useComputedMinMaxSync(contextStore));
// Should NOT have recomputed - values should be unchanged
expect(contextStore.getState().lastComputedMinMax).toStrictEqual(
initialMinMax,
);
expect(contextStore.getState().lastRefreshTimestamp).toBe(initialTimestamp);
});
it('should only compute on mount, not on re-renders', () => {
const contextStore = createGlobalTimeStore({ selectedTime: '15m' });
const { rerender } = renderHook(() => useComputedMinMaxSync(contextStore));
const afterMountMinMax = { ...contextStore.getState().lastComputedMinMax };
const afterMountTimestamp = contextStore.getState().lastRefreshTimestamp;
jest.advanceTimersByTime(60000);
rerender();
// Should NOT have recomputed on re-render
expect(contextStore.getState().lastComputedMinMax).toStrictEqual(
afterMountMinMax,
);
expect(contextStore.getState().lastRefreshTimestamp).toBe(
afterMountTimestamp,
);
});
});

View File

@@ -1,381 +0,0 @@
import { act, renderHook, waitFor } from '@testing-library/react';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import { QueryClient, QueryClientProvider, useQuery } from 'react-query';
import { ReactNode } from 'react';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { GlobalTimeProvider } from '../GlobalTimeContext';
import { useGlobalTime } from '../hooks';
import { GlobalTimeProviderOptions } from '../types';
import { useGlobalTimeQueryInvalidate } from '../useGlobalTimeQueryInvalidate';
import { createCustomTimeRange, NANO_SECOND_MULTIPLIER } from '../utils';
const createTestQueryClient = (): QueryClient =>
new QueryClient({
defaultOptions: {
queries: {
retry: false,
cacheTime: Infinity,
},
},
});
const createWrapper = (
providerProps: GlobalTimeProviderOptions,
queryClient: QueryClient,
) => {
return function Wrapper({ children }: { children: ReactNode }): JSX.Element {
return (
<QueryClientProvider client={queryClient}>
<NuqsTestingAdapter>
<GlobalTimeProvider {...providerProps}>{children}</GlobalTimeProvider>
</NuqsTestingAdapter>
</QueryClientProvider>
);
};
};
describe('useGlobalTimeQueryInvalidate', () => {
let queryClient: QueryClient;
beforeEach(() => {
queryClient = createTestQueryClient();
jest.useFakeTimers();
jest.setSystemTime(new Date('2024-01-15T12:30:45.123Z'));
});
afterEach(() => {
jest.useRealTimers();
queryClient.clear();
});
it('should return a function', () => {
const wrapper = createWrapper({ initialTime: '15m' }, queryClient);
const { result } = renderHook(() => useGlobalTimeQueryInvalidate(), {
wrapper,
});
expect(typeof result.current).toBe('function');
});
it('should call computeAndStoreMinMax before invalidating queries (refresh disabled)', async () => {
const wrapper = createWrapper(
{ initialTime: '15m', refreshInterval: 0 }, // refresh disabled so computeAndStoreMinMax computes fresh values
queryClient,
);
const { result } = renderHook(
() => ({
invalidate: useGlobalTimeQueryInvalidate(),
globalTime: useGlobalTime(),
}),
{ wrapper },
);
// Initial computation - need to call computeAndStoreMinMax first
act(() => {
result.current.globalTime.computeAndStoreMinMax();
});
const initialMinMax = { ...result.current.globalTime.lastComputedMinMax };
// Advance time past minute boundary
act(() => {
jest.advanceTimersByTime(60000);
});
// Call invalidate - should compute fresh values when refresh is disabled
await act(async () => {
await result.current.invalidate();
});
// lastComputedMinMax should have been updated
expect(result.current.globalTime.lastComputedMinMax.maxTime).toBe(
initialMinMax.maxTime + 60000 * NANO_SECOND_MULTIPLIER,
);
});
it('should invalidate queries with AUTO_REFRESH_QUERY key', async () => {
const mockQueryFn = jest.fn().mockResolvedValue({ data: 'test' });
const wrapper = createWrapper({ initialTime: '15m' }, queryClient);
// Set up a query with AUTO_REFRESH_QUERY key
const { result: queryResult } = renderHook(
() =>
useQuery({
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'test-query'],
queryFn: mockQueryFn,
}),
{ wrapper },
);
// Wait for initial query to complete
await waitFor(() => {
expect(queryResult.current.isSuccess).toBe(true);
});
expect(mockQueryFn).toHaveBeenCalledTimes(1);
// Now render the invalidate hook and call it
const { result: invalidateResult } = renderHook(
() => useGlobalTimeQueryInvalidate(),
{ wrapper },
);
await act(async () => {
await invalidateResult.current();
});
// Query should have been refetched
await waitFor(() => {
expect(mockQueryFn).toHaveBeenCalledTimes(2);
});
});
it('should NOT invalidate queries without AUTO_REFRESH_QUERY key', async () => {
const autoRefreshQueryFn = jest.fn().mockResolvedValue({ data: 'auto' });
const regularQueryFn = jest.fn().mockResolvedValue({ data: 'regular' });
const wrapper = createWrapper({ initialTime: '15m' }, queryClient);
// Set up both types of queries
const { result: autoRefreshQuery } = renderHook(
() =>
useQuery({
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'auto-query'],
queryFn: autoRefreshQueryFn,
}),
{ wrapper },
);
const { result: regularQuery } = renderHook(
() =>
useQuery({
queryKey: ['regular-query'],
queryFn: regularQueryFn,
}),
{ wrapper },
);
// Wait for initial queries to complete
await waitFor(() => {
expect(autoRefreshQuery.current.isSuccess).toBe(true);
expect(regularQuery.current.isSuccess).toBe(true);
});
expect(autoRefreshQueryFn).toHaveBeenCalledTimes(1);
expect(regularQueryFn).toHaveBeenCalledTimes(1);
// Call invalidate
const { result: invalidateResult } = renderHook(
() => useGlobalTimeQueryInvalidate(),
{ wrapper },
);
await act(async () => {
await invalidateResult.current();
});
// Only auto-refresh query should be refetched
await waitFor(() => {
expect(autoRefreshQueryFn).toHaveBeenCalledTimes(2);
});
// Regular query should NOT be refetched
expect(regularQueryFn).toHaveBeenCalledTimes(1);
});
it('should use exact custom time values (not rounded) when invalidating', async () => {
// Use timestamps that are NOT on minute boundaries
const minTimeWithSeconds =
new Date('2024-01-15T12:15:45.123Z').getTime() * NANO_SECOND_MULTIPLIER;
const maxTimeWithSeconds =
new Date('2024-01-15T12:30:45.123Z').getTime() * NANO_SECOND_MULTIPLIER;
const customTime = createCustomTimeRange(
minTimeWithSeconds,
maxTimeWithSeconds,
);
const wrapper = createWrapper({ initialTime: customTime }, queryClient);
const { result } = renderHook(
() => ({
invalidate: useGlobalTimeQueryInvalidate(),
globalTime: useGlobalTime(),
}),
{ wrapper },
);
// Call invalidate
await act(async () => {
await result.current.invalidate();
});
// Verify custom time values are NOT rounded
expect(result.current.globalTime.lastComputedMinMax.minTime).toBe(
minTimeWithSeconds,
);
expect(result.current.globalTime.lastComputedMinMax.maxTime).toBe(
maxTimeWithSeconds,
);
});
it('should invalidate multiple AUTO_REFRESH_QUERY queries at once', async () => {
const queryFn1 = jest.fn().mockResolvedValue({ data: 'query1' });
const queryFn2 = jest.fn().mockResolvedValue({ data: 'query2' });
const queryFn3 = jest.fn().mockResolvedValue({ data: 'query3' });
const wrapper = createWrapper({ initialTime: '15m' }, queryClient);
// Set up multiple auto-refresh queries
renderHook(
() =>
useQuery({
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'query1'],
queryFn: queryFn1,
}),
{ wrapper },
);
renderHook(
() =>
useQuery({
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'query2'],
queryFn: queryFn2,
}),
{ wrapper },
);
renderHook(
() =>
useQuery({
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'query3'],
queryFn: queryFn3,
}),
{ wrapper },
);
// Wait for initial queries
await waitFor(() => {
expect(queryFn1).toHaveBeenCalledTimes(1);
expect(queryFn2).toHaveBeenCalledTimes(1);
expect(queryFn3).toHaveBeenCalledTimes(1);
});
// Call invalidate
const { result } = renderHook(() => useGlobalTimeQueryInvalidate(), {
wrapper,
});
await act(async () => {
await result.current();
});
// All queries should be refetched
await waitFor(() => {
expect(queryFn1).toHaveBeenCalledTimes(2);
expect(queryFn2).toHaveBeenCalledTimes(2);
expect(queryFn3).toHaveBeenCalledTimes(2);
});
});
describe('scoped invalidation with store name', () => {
it('should only invalidate queries matching store name', async () => {
const namedQueryFn = jest.fn().mockResolvedValue({ data: 'named' });
const unnamedQueryFn = jest.fn().mockResolvedValue({ data: 'unnamed' });
const wrapper = createWrapper(
{ name: 'drawer', initialTime: '15m' },
queryClient,
);
// Query with matching name
renderHook(
() =>
useQuery({
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'drawer', 'named-query'],
queryFn: namedQueryFn,
}),
{ wrapper },
);
// Query without name (different store)
renderHook(
() =>
useQuery({
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'unnamed-query'],
queryFn: unnamedQueryFn,
}),
{ wrapper },
);
await waitFor(() => {
expect(namedQueryFn).toHaveBeenCalledTimes(1);
expect(unnamedQueryFn).toHaveBeenCalledTimes(1);
});
// Call invalidate
const { result } = renderHook(() => useGlobalTimeQueryInvalidate(), {
wrapper,
});
await act(async () => {
await result.current();
});
// Only named query should be refetched
await waitFor(() => {
expect(namedQueryFn).toHaveBeenCalledTimes(2);
});
// Unnamed query should NOT be refetched
expect(unnamedQueryFn).toHaveBeenCalledTimes(1);
});
it('should invalidate all queries for unnamed store (backward compatible)', async () => {
const queryFn1 = jest.fn().mockResolvedValue({ data: 'query1' });
const queryFn2 = jest.fn().mockResolvedValue({ data: 'query2' });
// Unnamed store (no name prop)
const wrapper = createWrapper({ initialTime: '15m' }, queryClient);
renderHook(
() =>
useQuery({
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'query1'],
queryFn: queryFn1,
}),
{ wrapper },
);
renderHook(
() =>
useQuery({
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'query2'],
queryFn: queryFn2,
}),
{ wrapper },
);
await waitFor(() => {
expect(queryFn1).toHaveBeenCalledTimes(1);
expect(queryFn2).toHaveBeenCalledTimes(1);
});
const { result } = renderHook(() => useGlobalTimeQueryInvalidate(), {
wrapper,
});
await act(async () => {
await result.current();
});
// Both should be refetched
await waitFor(() => {
expect(queryFn1).toHaveBeenCalledTimes(2);
expect(queryFn2).toHaveBeenCalledTimes(2);
});
});
});
});

View File

@@ -1,323 +0,0 @@
import { act, renderHook, waitFor } from '@testing-library/react';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import { QueryClient, QueryClientProvider, useQuery } from 'react-query';
import { ReactNode } from 'react';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { GlobalTimeProvider } from '../GlobalTimeContext';
import { GlobalTimeProviderOptions } from '../types';
import { useIsGlobalTimeQueryRefreshing } from '../useIsGlobalTimeQueryRefreshing';
const createTestQueryClient = (): QueryClient =>
new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
const createWrapper = (
queryClient: QueryClient,
): (({ children }: { children: ReactNode }) => JSX.Element) => {
return function Wrapper({ children }: { children: ReactNode }): JSX.Element {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
};
const createProviderWrapper = (
providerProps: GlobalTimeProviderOptions,
queryClient: QueryClient,
): (({ children }: { children: ReactNode }) => JSX.Element) => {
return function Wrapper({ children }: { children: ReactNode }): JSX.Element {
return (
<QueryClientProvider client={queryClient}>
<NuqsTestingAdapter>
<GlobalTimeProvider {...providerProps}>{children}</GlobalTimeProvider>
</NuqsTestingAdapter>
</QueryClientProvider>
);
};
};
describe('useIsGlobalTimeQueryRefreshing', () => {
let queryClient: QueryClient;
beforeEach(() => {
queryClient = createTestQueryClient();
});
afterEach(() => {
queryClient.clear();
});
it('should return false when no queries are fetching', () => {
const wrapper = createWrapper(queryClient);
const { result } = renderHook(() => useIsGlobalTimeQueryRefreshing(), {
wrapper,
});
expect(result.current).toBe(false);
});
it('should return true when AUTO_REFRESH_QUERY is fetching', async () => {
let resolveQuery: (value: unknown) => void;
const queryPromise = new Promise((resolve) => {
resolveQuery = resolve;
});
const wrapper = createWrapper(queryClient);
// Start the auto-refresh query
renderHook(
() =>
useQuery({
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'test'],
queryFn: () => queryPromise,
}),
{ wrapper },
);
// Check if refreshing hook detects it
const { result } = renderHook(() => useIsGlobalTimeQueryRefreshing(), {
wrapper,
});
// Should be true while fetching
expect(result.current).toBe(true);
// Resolve the query
act(() => {
resolveQuery({ data: 'done' });
});
// Should be false after fetching completes
await waitFor(() => {
expect(result.current).toBe(false);
});
});
it('should return false when non-AUTO_REFRESH_QUERY is fetching', async () => {
let resolveQuery: (value: unknown) => void;
const queryPromise = new Promise((resolve) => {
resolveQuery = resolve;
});
const wrapper = createWrapper(queryClient);
// Start a regular query (not auto-refresh)
renderHook(
() =>
useQuery({
queryKey: ['regular-query'],
queryFn: () => queryPromise,
}),
{ wrapper },
);
// Check if refreshing hook detects it
const { result } = renderHook(() => useIsGlobalTimeQueryRefreshing(), {
wrapper,
});
// Should be false - not an auto-refresh query
expect(result.current).toBe(false);
// Cleanup
act(() => {
resolveQuery({ data: 'done' });
});
});
it('should return true when multiple AUTO_REFRESH_QUERY queries are fetching', async () => {
let resolveQuery1: (value: unknown) => void;
let resolveQuery2: (value: unknown) => void;
const queryPromise1 = new Promise((resolve) => {
resolveQuery1 = resolve;
});
const queryPromise2 = new Promise((resolve) => {
resolveQuery2 = resolve;
});
const wrapper = createWrapper(queryClient);
// Start multiple auto-refresh queries
renderHook(
() =>
useQuery({
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'query1'],
queryFn: () => queryPromise1,
}),
{ wrapper },
);
renderHook(
() =>
useQuery({
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'query2'],
queryFn: () => queryPromise2,
}),
{ wrapper },
);
const { result } = renderHook(() => useIsGlobalTimeQueryRefreshing(), {
wrapper,
});
// Should be true while fetching
expect(result.current).toBe(true);
// Resolve first query
act(() => {
resolveQuery1({ data: 'done1' });
});
// Should still be true (second query still fetching)
await waitFor(() => {
expect(result.current).toBe(true);
});
// Resolve second query
act(() => {
resolveQuery2({ data: 'done2' });
});
// Should be false after all complete
await waitFor(() => {
expect(result.current).toBe(false);
});
});
it('should only track AUTO_REFRESH_QUERY, not other queries', async () => {
let resolveAutoRefresh: (value: unknown) => void;
let resolveRegular: (value: unknown) => void;
const autoRefreshPromise = new Promise((resolve) => {
resolveAutoRefresh = resolve;
});
const regularPromise = new Promise((resolve) => {
resolveRegular = resolve;
});
const wrapper = createWrapper(queryClient);
// Start both types of queries
renderHook(
() =>
useQuery({
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'auto'],
queryFn: () => autoRefreshPromise,
}),
{ wrapper },
);
renderHook(
() =>
useQuery({
queryKey: ['regular'],
queryFn: () => regularPromise,
}),
{ wrapper },
);
const { result } = renderHook(() => useIsGlobalTimeQueryRefreshing(), {
wrapper,
});
// Should be true (auto-refresh is fetching)
expect(result.current).toBe(true);
// Resolve auto-refresh query
act(() => {
resolveAutoRefresh({ data: 'done' });
});
// Should be false even though regular query is still fetching
await waitFor(() => {
expect(result.current).toBe(false);
});
// Cleanup
act(() => {
resolveRegular({ data: 'done' });
});
});
describe('scoped refreshing check with store name', () => {
it('should return true only for queries matching store name', async () => {
let resolveNamedQuery: (value: unknown) => void;
const namedQueryPromise = new Promise((resolve) => {
resolveNamedQuery = resolve;
});
const wrapper = createProviderWrapper(
{ name: 'drawer', initialTime: '15m' },
queryClient,
);
// Start query with matching name
renderHook(
() =>
useQuery({
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'drawer', 'test'],
queryFn: () => namedQueryPromise,
}),
{ wrapper },
);
// Check refreshing status
const { result } = renderHook(() => useIsGlobalTimeQueryRefreshing(), {
wrapper,
});
// Should be true - named query is fetching
expect(result.current).toBe(true);
// Resolve the query
act(() => {
resolveNamedQuery({ data: 'done' });
});
await waitFor(() => {
expect(result.current).toBe(false);
});
});
it('should return false when only different store queries are fetching', async () => {
let resolveOtherQuery: (value: unknown) => void;
const otherQueryPromise = new Promise((resolve) => {
resolveOtherQuery = resolve;
});
const wrapper = createProviderWrapper(
{ name: 'drawer', initialTime: '15m' },
queryClient,
);
// Start query with different name (belongs to different store)
renderHook(
() =>
useQuery({
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'other-store', 'test'],
queryFn: () => otherQueryPromise,
}),
{ wrapper },
);
// Check refreshing status for 'drawer' store
const { result } = renderHook(() => useIsGlobalTimeQueryRefreshing(), {
wrapper,
});
// Should be false - the fetching query belongs to 'other-store', not 'drawer'
expect(result.current).toBe(false);
// Cleanup
act(() => {
resolveOtherQuery({ data: 'done' });
});
});
});
});

View File

@@ -1,190 +0,0 @@
import { act, renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from 'react-query';
import { ReactNode } from 'react';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { createGlobalTimeStore, GlobalTimeStoreApi } from '../globalTimeStore';
import { useQueryCacheSync } from '../useQueryCacheSync';
function createTestQueryClient(): QueryClient {
return new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
}
function createWrapper(
queryClient: QueryClient,
): ({ children }: { children: ReactNode }) => JSX.Element {
return function Wrapper({ children }: { children: ReactNode }): JSX.Element {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
}
describe('useQueryCacheSync', () => {
let store: GlobalTimeStoreApi;
let queryClient: QueryClient;
beforeEach(() => {
store = createGlobalTimeStore();
queryClient = createTestQueryClient();
jest.useFakeTimers();
jest.setSystemTime(new Date('2024-01-15T12:30:45.123Z'));
});
afterEach(() => {
jest.useRealTimers();
queryClient.clear();
});
it('should update lastRefreshTimestamp when auto-refresh query succeeds', async () => {
// Initialize store
act(() => {
store.getState().computeAndStoreMinMax();
});
const initialTimestamp = store.getState().lastRefreshTimestamp;
// Advance time
act(() => {
jest.advanceTimersByTime(5000);
});
// Render the hook
renderHook(() => useQueryCacheSync(store), {
wrapper: createWrapper(queryClient),
});
// Simulate a successful auto-refresh query
await act(async () => {
await queryClient.fetchQuery({
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'test'],
queryFn: () => Promise.resolve({ data: 'test' }),
});
});
await waitFor(() => {
expect(store.getState().lastRefreshTimestamp).toBeGreaterThan(
initialTimestamp,
);
});
});
it('should not update timestamp for non-auto-refresh queries', async () => {
act(() => {
store.getState().computeAndStoreMinMax();
});
const initialTimestamp = store.getState().lastRefreshTimestamp;
renderHook(() => useQueryCacheSync(store), {
wrapper: createWrapper(queryClient),
});
// Simulate a regular query (not auto-refresh)
await act(async () => {
await queryClient.fetchQuery({
queryKey: ['some-other-query'],
queryFn: () => Promise.resolve({ data: 'test' }),
});
});
expect(store.getState().lastRefreshTimestamp).toBe(initialTimestamp);
});
describe('store name filtering', () => {
it('should update timestamp for named store when matching query succeeds', async () => {
const store = createGlobalTimeStore({ name: 'drawer' });
act(() => {
store.getState().computeAndStoreMinMax();
});
const initialTimestamp = store.getState().lastRefreshTimestamp;
act(() => {
jest.advanceTimersByTime(5000);
});
renderHook(() => useQueryCacheSync(store), {
wrapper: createWrapper(queryClient),
});
// Query with matching name
await act(async () => {
await queryClient.fetchQuery({
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'drawer', 'test'],
queryFn: () => Promise.resolve({ data: 'test' }),
});
});
await waitFor(() => {
expect(store.getState().lastRefreshTimestamp).toBeGreaterThan(
initialTimestamp,
);
});
});
it('should NOT update timestamp for named store when different name query succeeds', async () => {
const store = createGlobalTimeStore({ name: 'drawer' });
act(() => {
store.getState().computeAndStoreMinMax();
});
const initialTimestamp = store.getState().lastRefreshTimestamp;
act(() => {
jest.advanceTimersByTime(5000);
});
renderHook(() => useQueryCacheSync(store), {
wrapper: createWrapper(queryClient),
});
// Query with different name
await act(async () => {
await queryClient.fetchQuery({
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'other-store', 'test'],
queryFn: () => Promise.resolve({ data: 'test' }),
});
});
expect(store.getState().lastRefreshTimestamp).toBe(initialTimestamp);
});
it('should NOT update timestamp for named store when unnamed query succeeds', async () => {
const store = createGlobalTimeStore({ name: 'drawer' });
act(() => {
store.getState().computeAndStoreMinMax();
});
const initialTimestamp = store.getState().lastRefreshTimestamp;
act(() => {
jest.advanceTimersByTime(5000);
});
renderHook(() => useQueryCacheSync(store), {
wrapper: createWrapper(queryClient),
});
// Query without name (unnamed store format)
await act(async () => {
await queryClient.fetchQuery({
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'test-query'],
queryFn: () => Promise.resolve({ data: 'test' }),
});
});
expect(store.getState().lastRefreshTimestamp).toBe(initialTimestamp);
});
});
});

View File

@@ -1,15 +1,10 @@
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import {
computeRounded5sMinMax,
createCustomTimeRange,
CUSTOM_TIME_SEPARATOR,
getAutoRefreshQueryKey,
isCustomTimeRange,
NANO_SECOND_MULTIPLIER,
parseCustomTimeRange,
parseSelectedTime,
roundDownTo5Seconds,
} from '../utils';
describe('globalTime/utils', () => {
@@ -141,184 +136,4 @@ describe('globalTime/utils', () => {
expect(result.minTime).toBe(now - oneDayNs);
});
});
describe('roundDownTo5Seconds', () => {
it('should round down timestamp to 5-second boundary', () => {
// 12:30:47.123Z -> 12:30:45.000Z
const inputNano = 1705321847123 * NANO_SECOND_MULTIPLIER;
const expectedNano = 1705321845000 * NANO_SECOND_MULTIPLIER;
expect(roundDownTo5Seconds(inputNano)).toBe(expectedNano);
});
it('should not change timestamp already at 5-second boundary', () => {
const inputNano = 1705321845000 * NANO_SECOND_MULTIPLIER; // 12:30:45.000
expect(roundDownTo5Seconds(inputNano)).toBe(inputNano);
});
it('should round 12:30:04.999 down to 12:30:00.000', () => {
const inputNano = 1705321804999 * NANO_SECOND_MULTIPLIER;
const expectedNano = 1705321800000 * NANO_SECOND_MULTIPLIER;
expect(roundDownTo5Seconds(inputNano)).toBe(expectedNano);
});
it('should round 12:30:09.999 down to 12:30:05.000', () => {
const inputNano = 1705321809999 * NANO_SECOND_MULTIPLIER;
const expectedNano = 1705321805000 * NANO_SECOND_MULTIPLIER;
expect(roundDownTo5Seconds(inputNano)).toBe(expectedNano);
});
it('should handle timestamp at exact 5-second intervals', () => {
// Test 5, 10, 15, 20, 25... second marks
const base = 1705321800000; // 12:30:00
for (let sec = 0; sec < 60; sec += 5) {
const inputNano = (base + sec * 1000) * NANO_SECOND_MULTIPLIER;
expect(roundDownTo5Seconds(inputNano)).toBe(inputNano);
}
});
});
describe('computeRounded5sMinMax', () => {
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2024-01-15T12:30:47.123Z'));
});
afterEach(() => {
jest.useRealTimers();
});
it('should return maxTime rounded to 5-second boundary for relative time', () => {
const result = computeRounded5sMinMax('15m');
// maxTime should be rounded down to 12:30:45.000
const expectedMaxTime =
new Date('2024-01-15T12:30:45.000Z').getTime() * NANO_SECOND_MULTIPLIER;
expect(result.maxTime).toBe(expectedMaxTime);
});
it('should compute minTime based on 5s-rounded maxTime', () => {
const result = computeRounded5sMinMax('15m');
const expectedMaxTime =
new Date('2024-01-15T12:30:45.000Z').getTime() * NANO_SECOND_MULTIPLIER;
const fifteenMinutesNs = 15 * 60 * 1000 * NANO_SECOND_MULTIPLIER;
expect(result.minTime).toBe(expectedMaxTime - fifteenMinutesNs);
});
it('should return unchanged values for custom time range', () => {
const minTime = 1000000000;
const maxTime = 2000000000;
const customTime = createCustomTimeRange(minTime, maxTime);
const result = computeRounded5sMinMax(customTime);
expect(result.minTime).toBe(minTime);
expect(result.maxTime).toBe(maxTime);
});
it('should preserve duration for 1h relative time', () => {
const result = computeRounded5sMinMax('1h');
const oneHourNs = 60 * 60 * 1000 * NANO_SECOND_MULTIPLIER;
const duration = result.maxTime - result.minTime;
expect(duration).toBe(oneHourNs);
});
});
describe('getAutoRefreshQueryKey', () => {
it('should prefix with AUTO_REFRESH_QUERY constant', () => {
const result = getAutoRefreshQueryKey('15m', 'MY_QUERY');
expect(result[0]).toBe(REACT_QUERY_KEY.AUTO_REFRESH_QUERY);
});
it('should append selectedTime at end', () => {
const result = getAutoRefreshQueryKey('15m', 'MY_QUERY', 'param1');
expect(result).toStrictEqual([
REACT_QUERY_KEY.AUTO_REFRESH_QUERY,
'MY_QUERY',
'param1',
'15m',
]);
});
it('should handle no additional query parts', () => {
const result = getAutoRefreshQueryKey('1h');
expect(result).toStrictEqual([REACT_QUERY_KEY.AUTO_REFRESH_QUERY, '1h']);
});
it('should handle custom time range as selectedTime', () => {
const customTime = createCustomTimeRange(1000000000, 2000000000);
const result = getAutoRefreshQueryKey(customTime, 'METRICS');
expect(result).toStrictEqual([
REACT_QUERY_KEY.AUTO_REFRESH_QUERY,
'METRICS',
customTime,
]);
});
it('should handle object query parts', () => {
const params = { entityId: '123', filter: 'active' };
const result = getAutoRefreshQueryKey('15m', 'ENTITY', params);
expect(result).toStrictEqual([
REACT_QUERY_KEY.AUTO_REFRESH_QUERY,
'ENTITY',
params,
'15m',
]);
});
});
describe('getAutoRefreshQueryKey deprecation', () => {
const originalEnv = process.env.NODE_ENV;
const originalWarn = console.warn;
beforeEach(() => {
console.warn = jest.fn();
});
afterEach(() => {
process.env.NODE_ENV = originalEnv;
console.warn = originalWarn;
});
it('should log deprecation warning in development', () => {
process.env.NODE_ENV = 'development';
getAutoRefreshQueryKey('15m', 'TEST');
expect(console.warn).toHaveBeenCalledWith(
expect.stringContaining('deprecated'),
);
});
it('should NOT log deprecation warning in production', () => {
process.env.NODE_ENV = 'production';
getAutoRefreshQueryKey('15m', 'TEST');
expect(console.warn).not.toHaveBeenCalled();
});
it('should still return correct query key format', () => {
const result = getAutoRefreshQueryKey('15m', 'MY_QUERY', 'param1');
expect(result).toStrictEqual([
REACT_QUERY_KEY.AUTO_REFRESH_QUERY,
'MY_QUERY',
'param1',
'15m',
]);
});
});
});

View File

@@ -1,144 +1,32 @@
import { createStore, StoreApi, useStore } from 'zustand';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
import { create } from 'zustand';
import {
GlobalTimeSelectedTime,
GlobalTimeState,
GlobalTimeStore,
IGlobalTimeStoreActions,
IGlobalTimeStoreState,
ParsedTimeRange,
} from './types';
import {
computeRounded5sMinMax,
isCustomTimeRange,
parseSelectedTime,
} from './utils';
import { isCustomTimeRange, parseSelectedTime } from './utils';
export type GlobalTimeStoreApi = StoreApi<GlobalTimeStore>;
export type IGlobalTimeStore = GlobalTimeStore;
export type IGlobalTimeStore = IGlobalTimeStoreState & IGlobalTimeStoreActions;
function computeIsRefreshEnabled(
selectedTime: GlobalTimeSelectedTime,
refreshInterval: number,
): boolean {
if (isCustomTimeRange(selectedTime)) {
return false;
}
return refreshInterval > 0;
}
export const useGlobalTimeStore = create<IGlobalTimeStore>((set, get) => ({
selectedTime: DEFAULT_TIME_RANGE,
isRefreshEnabled: false,
refreshInterval: 0,
setSelectedTime: (selectedTime, refreshInterval): void => {
set((state) => {
const newRefreshInterval = refreshInterval ?? state.refreshInterval;
const isCustom = isCustomTimeRange(selectedTime);
export function createGlobalTimeStore(
initialState?: Partial<GlobalTimeState>,
): GlobalTimeStoreApi {
const selectedTime = initialState?.selectedTime ?? DEFAULT_TIME_RANGE;
const refreshInterval = initialState?.refreshInterval ?? 0;
const name = initialState?.name;
return createStore<GlobalTimeStore>((set, get) => ({
name,
selectedTime,
refreshInterval,
isRefreshEnabled: computeIsRefreshEnabled(selectedTime, refreshInterval),
lastRefreshTimestamp: 0,
lastComputedMinMax: { minTime: 0, maxTime: 0 },
setSelectedTime: (
time: GlobalTimeSelectedTime,
newRefreshInterval?: number,
): void => {
const state = get();
const interval = newRefreshInterval ?? state.refreshInterval;
if (time === state.selectedTime && interval === state.refreshInterval) {
return;
}
const computedMinMax = parseSelectedTime(time);
set({
selectedTime: time,
refreshInterval: interval,
isRefreshEnabled: computeIsRefreshEnabled(time, interval),
lastComputedMinMax: computedMinMax,
lastRefreshTimestamp: Date.now(),
});
},
setRefreshInterval: (interval: number): void => {
set((state) => ({
refreshInterval: interval,
isRefreshEnabled: computeIsRefreshEnabled(state.selectedTime, interval),
}));
},
getMinMaxTime: (): ParsedTimeRange => {
const state = get();
if (isCustomTimeRange(state.selectedTime)) {
return parseSelectedTime(state.selectedTime);
}
if (state.isRefreshEnabled) {
const freshMinMax = computeRounded5sMinMax(state.selectedTime);
if (
freshMinMax.minTime !== state.lastComputedMinMax.minTime ||
freshMinMax.maxTime !== state.lastComputedMinMax.maxTime
) {
set({ lastComputedMinMax: freshMinMax, lastRefreshTimestamp: Date.now() });
}
return freshMinMax;
}
return state.lastComputedMinMax;
},
computeAndStoreMinMax: (): ParsedTimeRange => {
const state = get();
if (state.isRefreshEnabled) {
return state.lastComputedMinMax;
}
const computedMinMax = parseSelectedTime(state.selectedTime);
set({
lastComputedMinMax: computedMinMax,
lastRefreshTimestamp: Date.now(),
});
return computedMinMax;
},
updateRefreshTimestamp: (): void => {
set({ lastRefreshTimestamp: Date.now() });
},
getAutoRefreshQueryKey: (
selectedTime: GlobalTimeSelectedTime,
...queryParts: unknown[]
): unknown[] => {
const storeName = get().name;
if (storeName) {
return [
REACT_QUERY_KEY.AUTO_REFRESH_QUERY,
storeName,
...queryParts,
selectedTime,
];
}
return [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, ...queryParts, selectedTime];
},
}));
}
export const defaultGlobalTimeStore = createGlobalTimeStore();
export const useGlobalTimeStore = <T = GlobalTimeStore>(
selector?: (state: GlobalTimeStore) => T,
): T => {
return useStore(
defaultGlobalTimeStore,
selector ?? ((state) => state as unknown as T),
);
};
return {
selectedTime,
refreshInterval: newRefreshInterval,
isRefreshEnabled: !isCustom && newRefreshInterval > 0,
};
});
},
getMinMaxTime: (selectedTime): ParsedTimeRange => {
return parseSelectedTime(selectedTime || get().selectedTime);
},
}));

View File

@@ -1,57 +0,0 @@
// oxlint-disable-next-line no-restricted-imports
import { useContext } from 'react';
import { useStoreWithEqualityFn } from 'zustand/traditional';
import { GlobalTimeContext } from './GlobalTimeContext';
import { defaultGlobalTimeStore, GlobalTimeStoreApi } from './globalTimeStore';
import { GlobalTimeStore, ParsedTimeRange } from './types';
import { isCustomTimeRange } from './utils';
/**
* Access global time state with optional selector for performance.
*
* @example
* // Full state (re-renders on any change)
* const { selectedTime, setSelectedTime } = useGlobalTime();
*
* @example
* // With selector (re-renders only when selectedTime changes)
* const selectedTime = useGlobalTime(state => state.selectedTime);
*/
export function useGlobalTime<T = GlobalTimeStore>(
selector?: (state: GlobalTimeStore) => T,
equalityFn?: (a: T, b: T) => boolean,
): T {
const contextStore = useContext(GlobalTimeContext);
const store = contextStore ?? defaultGlobalTimeStore;
return useStoreWithEqualityFn(
store,
selector ?? ((state) => state as unknown as T),
equalityFn,
);
}
/**
* Check if currently using a custom time range.
*/
export function useIsCustomTimeRange(): boolean {
const selectedTime = useGlobalTime((state) => state.selectedTime);
return isCustomTimeRange(selectedTime);
}
/**
* Get the store API directly (for subscriptions or non-React contexts).
*/
export function useGlobalTimeStoreApi(): GlobalTimeStoreApi {
const contextStore = useContext(GlobalTimeContext);
return contextStore ?? defaultGlobalTimeStore;
}
/**
* Get the last computed min/max time values.
* Use this for display purposes to ensure consistency with query data.
*/
export function useLastComputedMinMax(): ParsedTimeRange {
return useGlobalTime((state) => state.lastComputedMinMax);
}

View File

@@ -1,558 +1,9 @@
/**
* # Global Time Store
*
* Centralized time management for the application with auto-refresh support.
*
* ## Quick Start
*
* ```tsx
* import { useGlobalTime, NANO_SECOND_MULTIPLIER } from 'store/globalTime';
*
* function MyComponent() {
* const selectedTime = useGlobalTime((s) => s.selectedTime);
* const getMinMaxTime = useGlobalTime((s) => s.getMinMaxTime);
* const getAutoRefreshQueryKey = useGlobalTime((s) => s.getAutoRefreshQueryKey);
* const isRefreshEnabled = useGlobalTime((s) => s.isRefreshEnabled);
* const refreshInterval = useGlobalTime((s) => s.refreshInterval);
*
* const { data } = useQuery({
* queryKey: getAutoRefreshQueryKey(selectedTime, 'MY_QUERY', params),
* queryFn: () => {
* const { minTime, maxTime } = getMinMaxTime();
* const start = Math.floor(minTime / NANO_SECOND_MULTIPLIER / 1000);
* const end = Math.floor(maxTime / NANO_SECOND_MULTIPLIER / 1000);
* return fetchData({ start, end });
* },
* refetchInterval: isRefreshEnabled ? refreshInterval : false,
* });
* }
* ```
*
* ## Core Concepts
*
* ### Time Formats
*
* | Format | Example | Description |
* |--------|---------|-------------|
* | Relative | `'15m'`, `'1h'`, `'1d'` | Duration from now, supports auto-refresh |
* | Custom | `'1234567890||_||1234567899'` | Fixed range in nanoseconds, no auto-refresh |
*
* ### Time Units
*
* - Store values are in **nanoseconds**
* - Most APIs expect **seconds**
* - Convert to have seconds: `Math.floor(nanoTime / NANO_SECOND_MULTIPLIER / 1000)`
* - Convert to have ms: `Math.floor(nanoTime / NANO_SECOND_MULTIPLIER)`
*
* ## Integration Guide
*
* ### Step 1: Get Store State
*
* Use selectors for optimal re-render performance:
*
* ```tsx
* // Good - only re-renders when selectedTime changes
* const selectedTime = useGlobalTime((s) => s.selectedTime);
* const getMinMaxTime = useGlobalTime((s) => s.getMinMaxTime);
*
* // Avoid - re-renders on ANY store change
* const store = useGlobalTime();
* ```
*
* ### Step 2: Build Query Key
*
* Use the store's `getAutoRefreshQueryKey` to enable auto-refresh:
*
* ```tsx
* const getAutoRefreshQueryKey = useGlobalTime((s) => s.getAutoRefreshQueryKey);
*
* const queryKey = useMemo(
* () => getAutoRefreshQueryKey(
* selectedTime, // Required - triggers invalidation
* 'UNIQUE_KEY', // Your query identifier
* ...otherParams // Additional cache-busting params
* ),
* [getAutoRefreshQueryKey, selectedTime, ...deps]
* );
* ```
*
* **Note:** For named providers (with `name` prop), query keys are automatically
* scoped to that store, enabling isolated invalidation and refresh tracking.
*
* ### Step 3: Fetch Data
*
* **IMPORTANT**: Call `getMinMaxTime()` INSIDE `queryFn`:
*
* ```tsx
* const { data } = useQuery({
* queryKey,
* queryFn: () => {
* // Fresh time values computed here during auto-refresh
* const { minTime, maxTime } = getMinMaxTime();
* const start = Math.floor(minTime / NANO_SECOND_MULTIPLIER / 1000);
* const end = Math.floor(maxTime / NANO_SECOND_MULTIPLIER / 1000);
* return api.fetch({ start, end });
* },
* refetchInterval: isRefreshEnabled ? refreshInterval : false,
* });
* ```
*
* ### Step 4: Add Refresh Button (Optional)
*
* ```tsx
* import {
* useGlobalTimeQueryInvalidate,
* useIsGlobalTimeQueryRefreshing,
* } from 'store/globalTime';
*
* function RefreshButton() {
* const invalidate = useGlobalTimeQueryInvalidate();
* const isRefreshing = useIsGlobalTimeQueryRefreshing();
*
* return (
* <button onClick={invalidate} disabled={isRefreshing}>
* {isRefreshing ? 'Refreshing...' : 'Refresh'}
* </button>
* );
* }
* ```
*
* ## Avoiding Stale Data
*
* ### Problem: Time Drift During Refresh
*
* If multiple queries compute time independently, they may use different values:
*
* ```tsx
* // BAD - each query gets different time
* queryFn: () => {
* const now = Date.now();
* return fetchData({ end: now, start: now - duration });
* }
* ```
*
* ### Solution: Use getMinMaxTime()
*
* `getMinMaxTime()` ensures all queries use consistent timestamps:
* - When auto-refresh is **disabled**: returns cached values from `computeAndStoreMinMax()`
* - When auto-refresh is **enabled**: computes fresh values (rounded to 5-second boundaries)
*
* Since values are rounded to 5-second boundaries, all queries calling `getMinMaxTime()`
* within the same 5-second window get identical timestamps.
*
* ```tsx
* // GOOD - all queries get same time
* queryFn: () => {
* const { minTime, maxTime } = getMinMaxTime();
* return fetchData({ start: minTime, end: maxTime });
* }
* ```
*
* ### How It Works
*
* **Manual refresh:**
* 1. User clicks refresh
* 2. `useGlobalTimeQueryInvalidate` calls `computeAndStoreMinMax()`
* 3. Fresh min/max stored in `lastComputedMinMax`
* 4. All queries re-run and call `getMinMaxTime()`
* 5. All get the SAME cached values
*
* **Auto-refresh (when `isRefreshEnabled = true`):**
* 1. React-query's `refetchInterval` triggers query re-execution
* 2. `getMinMaxTime()` computes fresh values (rounded to 5 seconds)
* 3. If values changed, updates `lastComputedMinMax` cache
* 4. All queries within same 5-second window get consistent values
*
* ## Auto-Refresh Setup
*
* Auto-refresh is enabled when:
* - `selectedTime` is a relative duration (e.g., `'15m'`)
* - `refreshInterval > 0`
*
* ```tsx
* // Auto-refresh configuration
* const selectedTime = useGlobalTime((s) => s.selectedTime);
* const getAutoRefreshQueryKey = useGlobalTime((s) => s.getAutoRefreshQueryKey);
* const isRefreshEnabled = useGlobalTime((s) => s.isRefreshEnabled);
* const refreshInterval = useGlobalTime((s) => s.refreshInterval);
*
* useQuery({
* queryKey: getAutoRefreshQueryKey(selectedTime, 'MY_QUERY'),
* queryFn: () => { ... },
* // Enable periodic refetch
* refetchInterval: isRefreshEnabled ? refreshInterval : false,
* });
* ```
*
* ## API Reference
*
* ### Hooks
*
* | Hook | Returns | Description |
* |------|---------|-------------|
* | `useGlobalTime(selector?)` | `T` | Access store state with optional selector |
* | `useGlobalTimeQueryInvalidate()` | `() => Promise<void>` | Invalidate all auto-refresh queries |
* | `useIsGlobalTimeQueryRefreshing()` | `boolean` | Check if any query is refreshing |
* | `useIsCustomTimeRange()` | `boolean` | Check if using fixed time range |
* | `useLastComputedMinMax()` | `ParsedTimeRange` | Get cached min/max values |
* | `useGlobalTimeStoreApi()` | `GlobalTimeStoreApi` | Get raw store API |
*
* ### Store Actions
*
* | Action | Description |
* |--------|-------------|
* | `setSelectedTime(time, interval?)` | Set time range and optional refresh interval (resets cache) |
* | `setRefreshInterval(ms)` | Set auto-refresh interval |
* | `getMinMaxTime(time?)` | Get min/max (fresh if auto-refresh enabled, cached otherwise) |
* | `computeAndStoreMinMax()` | Compute fresh values and cache them |
* | `getAutoRefreshQueryKey(time, ...parts)` | Build scoped query key for this store instance |
*
* ### Utilities
*
* | Function | Description |
* |----------|-------------|
* | `getAutoRefreshQueryKey(time, ...parts)` | **@deprecated** Use store action instead |
* | `parseSelectedTime(time)` | Parse time string to min/max (fresh computation) |
* | `isCustomTimeRange(time)` | Check if time is custom range format |
* | `createCustomTimeRange(min, max)` | Create custom range string |
*
* ### Constants
*
* | Constant | Value | Description |
* |----------|-------|-------------|
* | `NANO_SECOND_MULTIPLIER` | `1000000` | Convert ms to ns |
* | `CUSTOM_TIME_SEPARATOR` | `'||_||'` | Separator in custom range strings |
*
* ## Context & Composition
*
* ### Why Use Context?
*
* By default, `useGlobalTime()` uses a shared global store. Use `GlobalTimeProvider`
* to create isolated time state for specific UI sections (modals, drawers, etc.).
*
* ### Provider Options
*
* | Option | Type | Description |
* |--------|------|-------------|
* | `name` | `string` | Scope query keys to this store (enables isolated invalidation) |
* | `inheritGlobalTime` | `boolean` | Initialize with parent/global time value |
* | `initialTime` | `string` | Initial time if not inheriting |
* | `enableUrlParams` | `boolean \| object` | Sync time to URL query params |
* | `removeQueryParamsOnUnmount` | `boolean` | Clean URL params on unmount |
* | `localStoragePersistKey` | `string` | Persist time to localStorage |
* | `refreshInterval` | `number` | Initial auto-refresh interval (ms) |
*
* ### Example 1: Isolated Time in Modal
*
* A modal with its own time picker that doesn't affect the main page:
*
* ```tsx
* import { GlobalTimeProvider, useGlobalTime } from 'store/globalTime';
*
* function EntityDetailsModal({ entity, onClose }) {
* return (
* <Modal open onClose={onClose}>
* // Isolated time context - changes here don't affect parent
* <GlobalTimeProvider
* inheritGlobalTime // Start with parent's current time
* refreshInterval={0} // No auto-refresh in modal
* >
* <ModalContent entity={entity} />
* </GlobalTimeProvider>
* </Modal>
* );
* }
*
* function ModalContent({ entity }) {
* // This useGlobalTime reads from the modal's isolated store
* const selectedTime = useGlobalTime((s) => s.selectedTime);
* const setSelectedTime = useGlobalTime((s) => s.setSelectedTime);
*
* return (
* <>
* <DateTimePicker
* value={selectedTime}
* onChange={(time) => setSelectedTime(time)}
* />
* <EntityMetrics entity={entity} />
* <EntityLogs entity={entity} />
* </>
* );
* }
* ```
*
* ### Example 2: List Page with Detail Drawer
*
* Main list uses global time, drawer has independent time:
*
* ```tsx
* // Main list page - uses global time (no provider needed)
* function K8sPodsList() {
* const selectedTime = useGlobalTime((s) => s.selectedTime);
* const [selectedPod, setSelectedPod] = useState(null);
*
* return (
* <>
* <PageHeader>
* <DateTimeSelectionV3 /> // Controls global time
* </PageHeader>
*
* <PodsTable
* timeRange={selectedTime}
* onRowClick={setSelectedPod}
* />
*
* {selectedPod && (
* <PodDetailsDrawer
* pod={selectedPod}
* onClose={() => setSelectedPod(null)}
* />
* )}
* </>
* );
* }
*
* // Drawer with its own time context
* function PodDetailsDrawer({ pod, onClose }) {
* return (
* <Drawer open onClose={onClose}>
* <GlobalTimeProvider
* name="pod-drawer" // Scopes queries - only this drawer's queries are invalidated
* inheritGlobalTime // Start with list's time
* removeQueryParamsOnUnmount // Clean up URL when drawer closes
* enableUrlParams={{
* relativeTimeKey: 'drawerTime',
* startTimeKey: 'drawerStart',
* endTimeKey: 'drawerEnd',
* }}
* >
* <DrawerHeader>
* <DateTimeSelectionV3 /> // Controls drawer's time only
* </DrawerHeader>
*
* <Tabs>
* <Tab label="Metrics"><PodMetrics pod={pod} /></Tab>
* <Tab label="Logs"><PodLogs pod={pod} /></Tab>
* <Tab label="Events"><PodEvents pod={pod} /></Tab>
* </Tabs>
* </GlobalTimeProvider>
* </Drawer>
* );
* }
* ```
*
* ### Example 3: Nested Contexts
*
* Contexts can be nested - each level creates isolation:
*
* ```tsx
* // App level - global time
* function App() {
* return (
* <QueryClientProvider>
* // No provider here = uses defaultGlobalTimeStore
* <Dashboard />
* </QueryClientProvider>
* );
* }
*
* // Dashboard with comparison panel
* function Dashboard() {
* return (
* <div className="dashboard">
* // Main dashboard uses global time
* <MainCharts />
*
* // Comparison panel has its own time
* <GlobalTimeProvider initialTime="1h">
* <ComparisonPanel />
* </GlobalTimeProvider>
* </div>
* );
* }
*
* function ComparisonPanel() {
* // This reads from ComparisonPanel's isolated store (1h)
* // Not affected by global time changes
* const selectedTime = useGlobalTime((s) => s.selectedTime);
* return <ComparisonCharts timeRange={selectedTime} />;
* }
* ```
*
* ### Example 4: URL Sync for Shareable Links
*
* Persist time selection to URL for shareable links:
*
* ```tsx
* function TracesExplorer() {
* return (
* <GlobalTimeProvider
* enableUrlParams={{
* relativeTimeKey: 'time', // ?time=15m
* startTimeKey: 'startTime', // ?startTime=1234567890
* endTimeKey: 'endTime', // ?endTime=1234567899
* }}
* initialTime="15m" // Fallback if URL has no time params
* >
* <TracesContent />
* </GlobalTimeProvider>
* );
* }
* ```
*
* ### Example 5: localStorage Persistence
*
* Remember user's last selected time across sessions:
*
* ```tsx
* function MetricsExplorer() {
* return (
* <GlobalTimeProvider
* localStoragePersistKey="metrics-explorer-time"
* initialTime="1h" // Fallback for first visit
* >
* <MetricsContent />
* </GlobalTimeProvider>
* );
* }
* ```
*
* ### Context Resolution Order
*
* When `useGlobalTime()` is called, it resolves the store in this order:
*
* 1. Nearest `GlobalTimeProvider` ancestor (if any)
* 2. `defaultGlobalTimeStore` (global singleton)
*
* ```
* App (no provider -> uses defaultGlobalTimeStore)
* |-- Dashboard
* |-- MainCharts (uses defaultGlobalTimeStore)
* |-- GlobalTimeProvider (isolated store A)
* |-- ComparisonPanel (uses store A)
* |-- GlobalTimeProvider (isolated store B)
* |-- NestedChart (uses store B)
* ```
*
* ### Scoped Query Keys with `name`
*
* The `name` prop enables isolated query invalidation. When a provider has a name,
* its queries are prefixed with that name, so invalidation only affects that store:
*
* ```tsx
* // Main page - unnamed store
* // Query keys: ['AUTO_REFRESH_QUERY', 'METRICS', ...]
* function MainDashboard() {
* const getAutoRefreshQueryKey = useGlobalTime((s) => s.getAutoRefreshQueryKey);
* // ...
* }
*
* // Drawer - named store
* // Query keys: ['AUTO_REFRESH_QUERY', 'drawer', 'METRICS', ...]
* function DetailDrawer() {
* return (
* <GlobalTimeProvider name="drawer" inheritGlobalTime>
* <DrawerContent />
* </GlobalTimeProvider>
* );
* }
*
* function DrawerContent() {
* const getAutoRefreshQueryKey = useGlobalTime((s) => s.getAutoRefreshQueryKey);
* const invalidate = useGlobalTimeQueryInvalidate();
* // invalidate() only refreshes queries with 'drawer' prefix
* }
* ```
*
* ## Complete Example
*
* ```tsx
* import { useMemo } from 'react';
* import { useQuery } from 'react-query';
* import { useGlobalTime, NANO_SECOND_MULTIPLIER } from 'store/globalTime';
*
* function MetricsPanel({ entityId }: { entityId: string }) {
* // 1. Get store state with selectors
* const selectedTime = useGlobalTime((s) => s.selectedTime);
* const getMinMaxTime = useGlobalTime((s) => s.getMinMaxTime);
* const getAutoRefreshQueryKey = useGlobalTime((s) => s.getAutoRefreshQueryKey);
* const isRefreshEnabled = useGlobalTime((s) => s.isRefreshEnabled);
* const refreshInterval = useGlobalTime((s) => s.refreshInterval);
*
* // 2. Build query key (memoized) - automatically scoped if using named provider
* const queryKey = useMemo(
* () => getAutoRefreshQueryKey(selectedTime, 'METRICS', entityId),
* [getAutoRefreshQueryKey, selectedTime, entityId]
* );
*
* // 3. Query with auto-refresh
* const { data, isLoading } = useQuery({
* queryKey,
* queryFn: () => {
* // Get fresh time inside queryFn
* const { minTime, maxTime } = getMinMaxTime();
* const start = Math.floor(minTime / NANO_SECOND_MULTIPLIER / 1000);
* const end = Math.floor(maxTime / NANO_SECOND_MULTIPLIER / 1000);
*
* return fetchMetrics({ entityId, start, end });
* },
* refetchInterval: isRefreshEnabled ? refreshInterval : false,
* });
*
* return <Chart data={data} loading={isLoading} />;
* }
* ```
*
* @module store/globalTime
*/
// Store
export {
createGlobalTimeStore,
defaultGlobalTimeStore,
useGlobalTimeStore,
} from './globalTimeStore';
export type { GlobalTimeStoreApi } from './globalTimeStore';
// Context & Provider
export { GlobalTimeContext, GlobalTimeProvider } from './GlobalTimeContext';
// Hooks
export {
useGlobalTime,
useGlobalTimeStoreApi,
useIsCustomTimeRange,
useLastComputedMinMax,
} from './hooks';
// Query hooks for auto-refresh
export { useGlobalTimeQueryInvalidate } from './useGlobalTimeQueryInvalidate';
export { useIsGlobalTimeQueryRefreshing } from './useIsGlobalTimeQueryRefreshing';
// Types
export type {
CustomTimeRange,
CustomTimeRangeSeparator,
GlobalTimeActions,
GlobalTimeProviderOptions,
GlobalTimeSelectedTime,
GlobalTimeState,
GlobalTimeStore,
IGlobalTimeStoreActions,
IGlobalTimeStoreState,
ParsedTimeRange,
} from './types';
// Utilities
export { useGlobalTimeStore } from './globalTimeStore';
export type { IGlobalTimeStoreState, ParsedTimeRange } from './types';
export {
createCustomTimeRange,
CUSTOM_TIME_SEPARATOR,
getAutoRefreshQueryKey,
isCustomTimeRange,
NANO_SECOND_MULTIPLIER,
parseCustomTimeRange,
parseSelectedTime,
} from './utils';
// Internal hooks (for advanced use cases)
export { useQueryCacheSync } from './useQueryCacheSync';

View File

@@ -44,80 +44,9 @@ export interface IGlobalTimeStoreActions {
) => void;
/**
* Get the current min/max time values.
* - Custom time ranges: returns exact parsed values
* - isRefreshEnabled true: computes 5s-rounded values and updates store
* - isRefreshEnabled false: returns lastComputedMinMax
* Get the current min/max time values parsed from selectedTime.
* For durations, computes fresh values based on Date.now().
* For custom ranges, extracts the stored values.
*/
getMinMaxTime: () => ParsedTimeRange;
getMinMaxTime: (selectedItem?: GlobalTimeSelectedTime) => ParsedTimeRange;
}
export interface GlobalTimeProviderOptions {
/**
* Optional name for the store instance.
* Used to scope query keys - only queries with this store's prefix
* will be tracked/invalidated by this store's hooks.
*/
name?: string;
/** Initialize from parent/global time */
inheritGlobalTime?: boolean;
/** Initial time if not inheriting */
initialTime?: GlobalTimeSelectedTime;
/** URL sync configuration. When false/omitted, no URL sync. */
enableUrlParams?:
| boolean
| {
relativeTimeKey?: string;
startTimeKey?: string;
endTimeKey?: string;
};
removeQueryParamsOnUnmount?: boolean;
localStoragePersistKey?: string;
refreshInterval?: number;
}
export interface GlobalTimeState {
/**
* Optional name for the store instance.
* Used to scope query keys for auto-refresh queries.
* Unnamed stores use the default prefix without a name.
*/
name?: string;
selectedTime: GlobalTimeSelectedTime;
refreshInterval: number;
isRefreshEnabled: boolean;
lastRefreshTimestamp: number;
lastComputedMinMax: ParsedTimeRange;
}
export interface GlobalTimeActions {
setSelectedTime: (
time: GlobalTimeSelectedTime,
refreshInterval?: number,
) => void;
setRefreshInterval: (interval: number) => void;
getMinMaxTime: () => ParsedTimeRange;
/**
* Compute fresh rounded min/max values, store them, and update refresh timestamp.
* Call this before invalidating queries to ensure all queries use the same time values.
*
* @returns The newly computed ParsedTimeRange
*/
computeAndStoreMinMax: () => ParsedTimeRange;
/**
* Update the refresh timestamp to current time.
* Called by QueryCache listener when auto-refresh queries complete.
*/
updateRefreshTimestamp: () => void;
/**
* Build query key for auto-refresh queries scoped to this store.
* Named stores: ['AUTO_REFRESH_QUERY', name, ...parts, selectedTime]
* Unnamed stores: ['AUTO_REFRESH_QUERY', ...parts, selectedTime]
*/
getAutoRefreshQueryKey: (
selectedTime: GlobalTimeSelectedTime,
...queryParts: unknown[]
) => unknown[];
}
export type GlobalTimeStore = GlobalTimeState & GlobalTimeActions;

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