Compare commits

..

12 Commits

Author SHA1 Message Date
Ashwin Bhatkal
b60dbb9ba2 chore: update docker ignore 2026-05-05 18:56:09 +05:30
Ashwin Bhatkal
02cf588461 test: dashboards list spec with new e2e framework 2026-05-05 18:16:14 +05:30
Abhi kumar
18d5e92ae2 fix: added fix for panel sync mode in non-view panels (#11187) 2026-05-05 10:06:28 +00:00
Vikrant Gupta
5eaca31759 chore(service-account): remove api keys deprecation banner (#11188) 2026-05-05 09:53:26 +00:00
Abhi kumar
8b0ccc8ddc feat: user dashboard preference (#11159)
Some checks failed
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* feat: user dashboard preference

* chore: moved to module css

* chore: pr review changes

* chore: minor fixes

* feat: added changes for synced tooltip modes (#11175)

* feat: added changes for synced tooltip modes

* chore: pr review changes

* chore: minor fix

* chore: added changes for deleting dashboard preferences on dashboard delete
2026-05-05 08:07:33 +00:00
SagarRajput-7
1118136b69 feat: updated the cancel subscription banner styles and message (#11181)
* feat: updated the cancel subscription banner styles and message

* feat: cancel button update

* feat: updated the confirmation dialog styles and added 'cancel' input

* feat: added test cases

* feat: added test cases

* feat: updated messages

* feat: updated test cases

* feat: updated styles as per feedback
2026-05-05 07:41:59 +00:00
Abhi kumar
ae3f5114c4 chore: minor ui fixes in tooltip (#11099)
* chore: minor ui fixes in tooltip

* chore: preetify

* chore: exposed tooltip + added panelid in events

* chore: fixed and updated tooltip test

* chore: added tooltip footer tests

* chore: updated pr review changes and added support for multi query

* chore: minor fix
2026-05-05 06:45:59 +00:00
Pandey
8409a9798d fix(authdomain): nest config response, rename Updateable→Updatable, return Identifiable on create (#11176)
Some checks failed
build-staging / staging (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* fix(authdomain): nest config response, rename Updateable→Updatable, return Identifiable on create

Three small API-shape corrections on auth_domain:

- GettableAuthDomain previously embedded AuthDomainConfig, which
  flattened sso_enabled / saml_config / oidc_config / google_auth_config /
  role_mapping at the response root and made the response shape
  diverge from the request shape (PostableAuthDomain has them under
  `config`). Move it under a named `Config` field with a `config`
  json tag so request and response carry the same nested object.
- UpdateableAuthDomain → UpdatableAuthDomain (typo fix; aligns with
  UpdatableUser already in the codebase).
- CreateAuthDomain previously returned the full GettableAuthDomain;
  the only field clients actually need from the create response is
  the new ID. Switch to Identifiable so the contract states what the
  endpoint guarantees and clients re-Read for the full domain when
  needed.

Frontend schema and OpenAPI spec regenerated.

* fix(authdomain-frontend): adapt to nested config + Identifiable create response

Regenerate the orval client (`yarn generate:api`) and update the
auth-domain UI for the API shape changes from the previous commit:

- `record.ssoType`, `.ssoEnabled`, `.googleAuthConfig`, `.oidcConfig`,
  `.samlConfig`, `.roleMapping` are now nested under `record.config.*`
  in `AuthtypesGettableAuthDomainDTO` — update SSOEnforcementToggle,
  CreateEdit form initial-values, the list page's Configure button,
  and the auth-domain test mocks.
- `mockCreateSuccessResponse` now returns `{ id }` (Identifiable)
  instead of the full domain.

`yarn generate:api` ran clean: lint OK, tsgo OK.

* fix(authdomain): align CreateAuthDomain success code with handler + adjust integration test

The Create handler returns http.StatusCreated but the OpenAPI
annotation said StatusOK. Sync the annotation to 201, regenerate the
spec + frontend client.

The callbackauthn integration test (01_domain.py) still read
`domain["ssoType"]` off the GET response — now nested under
`domain["config"]["ssoType"]` after the previous shape change. Update
the assertion.
2026-05-04 20:44:41 +00:00
Vinicius Lourenço
de6e4890ae feat(query-search-v2): add initial expression support & store to manage (#11062)
* feat(query-search-v2): add initial expression support & store to manage

* fix(qbv2): format issue
2026-05-04 16:22:52 +00:00
Vinicius Lourenço
20dd264ac1 feat(infra-monitoring): use new table component (#11122)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* feat(infra-monitoring): use new table component

* test(k8s-base-list): try fix issue with flaky test

* fix(table): tweaks in the layout

* fix(pr-comments): usage of const and move disable lint to line

* fix(css): format of css file

* test(k8s-base-list): flaky test

* test(k8s-base-list): second try to fix flaky test

* fix(table): have different ids for expanded table

* fix(k8s-base-list): third attempt to de-flaky test

* refactor(table): tiny adjustments on table

* fix(k8s-empty-state): better title size
2026-05-04 13:16:23 +00:00
Vishal Sharma
8a7793794d feat(global): add ai assistant url to global config (#11171) 2026-05-04 13:06:33 +00:00
Pandey
680bcd08c3 fix(types): correct OpenAPI schema for AuthDomainConfig and PostableChannel (#11164)
* fix(authtypes): embed values and expose AuthDomainConfig oneOf

GettableAuthDomain now embeds StorableAuthDomain and AuthDomainConfig
by value so the response flattens correctly. AuthDomainConfig also
implements jsonschema.OneOfExposer over the SAML/Google/OIDC variants.

* fix(alertmanagertypes): expose PostableChannel JSONSchema

PostableChannel now implements jsonschema.Exposer, requiring name
and a oneOf branch per *_configs field so the OpenAPI request body
for POST /channels matches the runtime contract enforced in
NewChannelFromReceiver. Switched the route's Request type from
Receiver to PostableChannel and regenerated the OpenAPI spec.

* fix(alertmanagertypes): use components/schemas prefix in PostableChannel refs

The standalone reflector inside JSONSchema defaulted to #/definitions/
prefix, producing dangling refs to ConfigDiscordConfig etc. that broke
the generated frontend client. Pass DefinitionsPrefix("#/components/schemas/")
so refs point to existing OpenAPI components, and regenerate the frontend
Orval client.

* feat(authdomain): add GET /api/v1/domains/{id} endpoint

Returns a single GettableAuthDomain scoped to the caller's organization,
backed by the existing module.GetByOrgIDAndID. Adds Get to the Handler
interface, wires the route under AdminAccess, and regenerates the
OpenAPI spec and frontend Orval client.

* feat(authtypes): expose AuthNProvider enum in OpenAPI schema

AuthNProvider now implements jsonschema.Enum, narrowing the generated
TypeScript type from string to a typed enum. Updated callers in the
auth-domain settings UI and mocks to use AuthtypesAuthNProviderDTO,
and added an early-return guard in the create/edit submit handler so
TS can narrow the union before passing it as ssoType.

* chore(types): document oneOf/discriminator mismatch on PostableChannel and AuthDomainConfig

Both types emit a oneOf in the OpenAPI spec but neither shape supports an
OpenAPI discriminator: PostableChannel implies the variant by which *_configs
field is present, and AuthDomainConfig keeps the variant payload in a
sibling field instead of being the payload itself. Leave a TODO pointing at
ruletypes.RuleThresholdData as the envelope pattern to migrate to.

* fix(ruletypes): handle string driver values in Schedule.Scan and Recurrence.Scan

The Scan methods only handled []byte and silently no-op'd on anything
else. SQLite's TEXT columns come back as string from the driver, so
every GET of a planned_maintenance returned a zero-valued Schedule
(empty timezone, 0001-01-01 startTime/endTime, no recurrence) — even
though Create + Update wrote the values correctly.

Switch on src type, accept []byte, string, and nil; error on anything
else. Aligns Schedule with the existing pattern; in Recurrence fixes
the receiver — Unmarshal was being passed src (the interface{} arg)
rather than r.
2026-05-04 18:00:43 +05:30
286 changed files with 10214 additions and 21312 deletions

View File

@@ -7,4 +7,9 @@ deploy
sample-apps
# frontend
node_modules
**/node_modules
frontend/build
# local env files (tracked example.env templates are unaffected)
**/.env
**/.env.*

View File

@@ -13,6 +13,8 @@ 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:

View File

@@ -96,6 +96,122 @@ components:
- createdAt
- updatedAt
type: object
AlertmanagertypesPostableChannel:
oneOf:
- required:
- discord_configs
- required:
- email_configs
- required:
- incidentio_configs
- required:
- pagerduty_configs
- required:
- slack_configs
- required:
- webhook_configs
- required:
- opsgenie_configs
- required:
- wechat_configs
- required:
- pushover_configs
- required:
- victorops_configs
- required:
- sns_configs
- required:
- telegram_configs
- required:
- webex_configs
- required:
- msteams_configs
- required:
- msteamsv2_configs
- required:
- jira_configs
- required:
- rocketchat_configs
- required:
- mattermost_configs
properties:
discord_configs:
items:
$ref: '#/components/schemas/ConfigDiscordConfig'
type: array
email_configs:
items:
$ref: '#/components/schemas/ConfigEmailConfig'
type: array
incidentio_configs:
items:
$ref: '#/components/schemas/ConfigIncidentioConfig'
type: array
jira_configs:
items:
$ref: '#/components/schemas/ConfigJiraConfig'
type: array
mattermost_configs:
items:
$ref: '#/components/schemas/ConfigMattermostConfig'
type: array
msteams_configs:
items:
$ref: '#/components/schemas/ConfigMSTeamsConfig'
type: array
msteamsv2_configs:
items:
$ref: '#/components/schemas/ConfigMSTeamsV2Config'
type: array
name:
type: string
opsgenie_configs:
items:
$ref: '#/components/schemas/ConfigOpsGenieConfig'
type: array
pagerduty_configs:
items:
$ref: '#/components/schemas/ConfigPagerdutyConfig'
type: array
pushover_configs:
items:
$ref: '#/components/schemas/ConfigPushoverConfig'
type: array
rocketchat_configs:
items:
$ref: '#/components/schemas/ConfigRocketchatConfig'
type: array
slack_configs:
items:
$ref: '#/components/schemas/ConfigSlackConfig'
type: array
sns_configs:
items:
$ref: '#/components/schemas/ConfigSNSConfig'
type: array
telegram_configs:
items:
$ref: '#/components/schemas/ConfigTelegramConfig'
type: array
victorops_configs:
items:
$ref: '#/components/schemas/ConfigVictorOpsConfig'
type: array
webex_configs:
items:
$ref: '#/components/schemas/ConfigWebexConfig'
type: array
webhook_configs:
items:
$ref: '#/components/schemas/ConfigWebhookConfig'
type: array
wechat_configs:
items:
$ref: '#/components/schemas/ConfigWechatConfig'
type: array
required:
- name
type: object
AlertmanagertypesPostableRoutePolicy:
properties:
channels:
@@ -133,6 +249,10 @@ components:
type: string
type: object
AuthtypesAuthDomainConfig:
oneOf:
- $ref: '#/components/schemas/AuthtypesSamlConfig'
- $ref: '#/components/schemas/AuthtypesGoogleConfig'
- $ref: '#/components/schemas/AuthtypesOIDCConfig'
properties:
googleAuthConfig:
$ref: '#/components/schemas/AuthtypesGoogleConfig'
@@ -145,8 +265,15 @@ components:
ssoEnabled:
type: boolean
ssoType:
type: string
$ref: '#/components/schemas/AuthtypesAuthNProvider'
type: object
AuthtypesAuthNProvider:
enum:
- google_auth
- saml
- email_password
- oidc
type: string
AuthtypesAuthNProviderInfo:
properties:
relayStatePath:
@@ -169,7 +296,7 @@ components:
AuthtypesCallbackAuthNSupport:
properties:
provider:
type: string
$ref: '#/components/schemas/AuthtypesAuthNProvider'
url:
type: string
type: object
@@ -177,27 +304,17 @@ components:
properties:
authNProviderInfo:
$ref: '#/components/schemas/AuthtypesAuthNProviderInfo'
config:
$ref: '#/components/schemas/AuthtypesAuthDomainConfig'
createdAt:
format: date-time
type: string
googleAuthConfig:
$ref: '#/components/schemas/AuthtypesGoogleConfig'
id:
type: string
name:
type: string
oidcConfig:
$ref: '#/components/schemas/AuthtypesOIDCConfig'
orgId:
type: string
roleMapping:
$ref: '#/components/schemas/AuthtypesRoleMapping'
samlConfig:
$ref: '#/components/schemas/AuthtypesSamlConfig'
ssoEnabled:
type: boolean
ssoType:
type: string
updatedAt:
format: date-time
type: string
@@ -323,7 +440,7 @@ components:
AuthtypesPasswordAuthNSupport:
properties:
provider:
type: string
$ref: '#/components/schemas/AuthtypesAuthNProvider'
type: object
AuthtypesPatchableObjects:
properties:
@@ -458,7 +575,7 @@ components:
- relation
- object
type: object
AuthtypesUpdateableAuthDomain:
AuthtypesUpdatableAuthDomain:
properties:
config:
$ref: '#/components/schemas/AuthtypesAuthDomainConfig'
@@ -2363,6 +2480,9 @@ components:
type: object
GlobaltypesConfig:
properties:
ai_assistant_url:
nullable: true
type: string
external_url:
type: string
identN:
@@ -2376,6 +2496,7 @@ components:
- external_url
- ingestion_url
- mcp_url
- ai_assistant_url
type: object
GlobaltypesIdentNConfig:
properties:
@@ -5665,7 +5786,7 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/ConfigReceiver'
$ref: '#/components/schemas/AlertmanagertypesPostableChannel'
responses:
"201":
content:
@@ -6944,20 +7065,20 @@ paths:
schema:
$ref: '#/components/schemas/AuthtypesPostableAuthDomain'
responses:
"200":
"201":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/AuthtypesGettableAuthDomain'
$ref: '#/components/schemas/TypesIdentifiable'
status:
type: string
required:
- status
- data
type: object
description: OK
description: Created
"400":
content:
application/json:
@@ -7042,6 +7163,63 @@ paths:
summary: Delete auth domain
tags:
- authdomains
get:
deprecated: false
description: This endpoint returns an auth domain by ID
operationId: GetAuthDomain
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/AuthtypesGettableAuthDomain'
status:
type: string
required:
- status
- data
type: object
description: OK
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- ADMIN
- tokenizer:
- ADMIN
summary: Get auth domain by ID
tags:
- authdomains
put:
deprecated: false
description: This endpoint updates an auth domain
@@ -7056,7 +7234,7 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/AuthtypesUpdateableAuthDomain'
$ref: '#/components/schemas/AuthtypesUpdatableAuthDomain'
responses:
"204":
description: No Content

View File

@@ -51,10 +51,6 @@
"@signozhq/design-tokens": "2.1.4",
"@signozhq/icons": "0.1.0",
"@signozhq/resizable": "0.0.2",
"@signozhq/tooltip": "0.0.2",
"@signozhq/tabs": "0.0.11",
"@signozhq/table": "0.3.8",
"@signozhq/toggle-group": "0.0.3",
"@signozhq/ui": "0.0.12",
"@tanstack/react-table": "8.21.3",
"@tanstack/react-virtual": "3.13.22",
@@ -125,12 +121,10 @@
"react-helmet-async": "1.3.0",
"react-hook-form": "7.71.2",
"react-i18next": "^11.16.1",
"react-json-tree": "^0.20.0",
"react-lottie": "1.2.10",
"react-markdown": "8.0.7",
"react-query": "3.39.3",
"react-redux": "^7.2.2",
"react-rnd": "^10.5.3",
"react-router-dom": "^5.2.0",
"react-router-dom-v5-compat": "6.27.0",
"react-syntax-highlighter": "15.5.0",

View File

@@ -65,13 +65,6 @@ export const TraceDetail = Loadable(
),
);
export const TraceDetailV3 = Loadable(
() =>
import(
/* webpackChunkName: "TraceDetailV3 Page" */ 'pages/TraceDetailV3Page/index'
),
);
export const UsageExplorerPage = Loadable(
() => import(/* webpackChunkName: "UsageExplorerPage" */ 'modules/Usage'),
);

View File

@@ -48,7 +48,6 @@ import {
StatusPage,
SupportPage,
TraceDetail,
TraceDetailV3,
TraceFilter,
TracesExplorer,
TracesFunnelDetails,
@@ -142,16 +141,9 @@ const routes: AppRoutes[] = [
{
path: ROUTES.TRACE_DETAIL,
exact: true,
component: TraceDetailV3,
isPrivate: true,
key: 'TRACE_DETAIL',
},
{
path: ROUTES.TRACE_DETAIL_OLD,
exact: true,
component: TraceDetail,
isPrivate: true,
key: 'TRACE_DETAIL_OLD',
key: 'TRACE_DETAIL',
},
{
path: ROUTES.SETTINGS,

View File

@@ -19,9 +19,11 @@ import type {
import type {
AuthtypesPostableAuthDomainDTO,
AuthtypesUpdateableAuthDomainDTO,
CreateAuthDomain200,
AuthtypesUpdatableAuthDomainDTO,
CreateAuthDomain201,
DeleteAuthDomainPathParameters,
GetAuthDomain200,
GetAuthDomainPathParameters,
ListAuthDomains200,
RenderErrorResponseDTO,
UpdateAuthDomainPathParameters,
@@ -124,7 +126,7 @@ export const createAuthDomain = (
authtypesPostableAuthDomainDTO: BodyType<AuthtypesPostableAuthDomainDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<CreateAuthDomain200>({
return GeneratedAPIInstance<CreateAuthDomain201>({
url: `/api/v1/domains`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -277,19 +279,122 @@ 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
*/
export const updateAuthDomain = (
{ id }: UpdateAuthDomainPathParameters,
authtypesUpdateableAuthDomainDTO: BodyType<AuthtypesUpdateableAuthDomainDTO>,
authtypesUpdatableAuthDomainDTO: BodyType<AuthtypesUpdatableAuthDomainDTO>,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v1/domains/${id}`,
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
data: authtypesUpdateableAuthDomainDTO,
data: authtypesUpdatableAuthDomainDTO,
});
};
@@ -302,7 +407,7 @@ export const getUpdateAuthDomainMutationOptions = <
TError,
{
pathParams: UpdateAuthDomainPathParameters;
data: BodyType<AuthtypesUpdateableAuthDomainDTO>;
data: BodyType<AuthtypesUpdatableAuthDomainDTO>;
},
TContext
>;
@@ -311,7 +416,7 @@ export const getUpdateAuthDomainMutationOptions = <
TError,
{
pathParams: UpdateAuthDomainPathParameters;
data: BodyType<AuthtypesUpdateableAuthDomainDTO>;
data: BodyType<AuthtypesUpdatableAuthDomainDTO>;
},
TContext
> => {
@@ -328,7 +433,7 @@ export const getUpdateAuthDomainMutationOptions = <
Awaited<ReturnType<typeof updateAuthDomain>>,
{
pathParams: UpdateAuthDomainPathParameters;
data: BodyType<AuthtypesUpdateableAuthDomainDTO>;
data: BodyType<AuthtypesUpdatableAuthDomainDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
@@ -343,7 +448,7 @@ export type UpdateAuthDomainMutationResult = NonNullable<
Awaited<ReturnType<typeof updateAuthDomain>>
>;
export type UpdateAuthDomainMutationBody =
BodyType<AuthtypesUpdateableAuthDomainDTO>;
BodyType<AuthtypesUpdatableAuthDomainDTO>;
export type UpdateAuthDomainMutationError = ErrorType<RenderErrorResponseDTO>;
/**
@@ -358,7 +463,7 @@ export const useUpdateAuthDomain = <
TError,
{
pathParams: UpdateAuthDomainPathParameters;
data: BodyType<AuthtypesUpdateableAuthDomainDTO>;
data: BodyType<AuthtypesUpdatableAuthDomainDTO>;
},
TContext
>;
@@ -367,7 +472,7 @@ export const useUpdateAuthDomain = <
TError,
{
pathParams: UpdateAuthDomainPathParameters;
data: BodyType<AuthtypesUpdateableAuthDomainDTO>;
data: BodyType<AuthtypesUpdatableAuthDomainDTO>;
},
TContext
> => {

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,72 +0,0 @@
import { ApiV3Instance as axios } from 'api';
import { omit } from 'lodash-es';
import { ErrorResponse, SuccessResponse } from 'types/api';
import {
GetTraceV3PayloadProps,
GetTraceV3SuccessResponse,
SpanV3,
} from 'types/api/trace/getTraceV3';
const getTraceV3 = async (
props: GetTraceV3PayloadProps,
): Promise<SuccessResponse<GetTraceV3SuccessResponse> | ErrorResponse> => {
let uncollapsedSpans = [...props.uncollapsedSpans];
if (!props.isSelectedSpanIDUnCollapsed) {
uncollapsedSpans = uncollapsedSpans.filter(
(node) => node !== props.selectedSpanId,
);
} else if (
props.selectedSpanId &&
!uncollapsedSpans.includes(props.selectedSpanId)
) {
// V3 backend only uses uncollapsedSpans list (unlike V2 which also interprets
// isSelectedSpanIDUnCollapsed server-side), so explicitly add the selected span
uncollapsedSpans.push(props.selectedSpanId);
}
const postData: GetTraceV3PayloadProps = {
...props,
uncollapsedSpans,
limit: 10000,
};
const response = await axios.post<GetTraceV3SuccessResponse>(
`/traces/${props.traceId}/waterfall`,
omit(postData, 'traceId'),
);
// V3 API wraps response in { status, data }
const rawPayload = (response.data as any).data || response.data;
// Derive 'service.name' from resource for convenience — only derived field
const spans: SpanV3[] = (rawPayload.spans || []).map((span: any) => ({
...span,
'service.name': span.resource?.['service.name'] || '',
}));
// V3 API returns startTimestampMillis/endTimestampMillis as relative durations (ms from epoch offset),
// not absolute unix millis like V2. The span timestamps are absolute unix millis.
// Convert by using the first span's timestamp as the base if there's a mismatch.
let { startTimestampMillis, endTimestampMillis } = rawPayload;
if (
spans.length > 0 &&
spans[0].timestamp > 0 &&
startTimestampMillis < spans[0].timestamp / 10
) {
const durationMillis = endTimestampMillis - startTimestampMillis;
startTimestampMillis = spans[0].timestamp;
endTimestampMillis = startTimestampMillis + durationMillis;
}
return {
statusCode: 200,
error: null,
message: 'Success',
payload: {
...rawPayload,
spans,
startTimestampMillis,
endTimestampMillis,
},
};
};
export default getTraceV3;

View File

@@ -13,8 +13,4 @@
import '@signozhq/design-tokens';
import '@signozhq/icons';
import '@signozhq/resizable';
import '@signozhq/tooltip';
import '@signozhq/tabs';
import '@signozhq/table';
import '@signozhq/toggle-group';
import '@signozhq/ui';

View File

@@ -1,41 +0,0 @@
.details-header {
// ghost + secondary missing hover bg token in @signozhq/button
--button-ghost-hover-background: var(--l3-background);
box-sizing: border-box;
display: flex;
align-items: center;
gap: 8px;
padding: 0 12px;
border-bottom: 1px solid var(--l1-border);
height: 36px;
background: var(--l2-background);
&__icon-btn {
flex-shrink: 0;
}
&__title {
font-size: 13px;
font-weight: 500;
color: var(--l1-foreground);
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__actions {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
&__nav {
display: flex;
align-items: center;
gap: 2px;
}
}

View File

@@ -1,59 +0,0 @@
import { ReactNode } from 'react';
import { Button } from '@signozhq/ui';
import { X } from '@signozhq/icons';
import './DetailsHeader.styles.scss';
export interface HeaderAction {
key: string;
component: ReactNode; // check later if we can use direct btn itself or not.
}
export interface DetailsHeaderProps {
title: string;
onClose: () => void;
actions?: HeaderAction[];
closePosition?: 'left' | 'right';
className?: string;
}
function DetailsHeader({
title,
onClose,
actions,
closePosition = 'right',
className,
}: DetailsHeaderProps): JSX.Element {
const closeButton = (
<Button
variant="ghost"
size="icon"
color="secondary"
onClick={onClose}
aria-label="Close"
className="details-header__icon-btn"
>
<X size={14} />
</Button>
);
return (
<div className={`details-header ${className || ''}`}>
{closePosition === 'left' && closeButton}
<span className="details-header__title">{title}</span>
{actions && (
<div className="details-header__actions">
{actions.map((action) => (
<div key={action.key}>{action.component}</div>
))}
</div>
)}
{closePosition === 'right' && closeButton}
</div>
);
}
export default DetailsHeader;

View File

@@ -1,7 +0,0 @@
.details-panel-drawer {
&__body {
display: flex;
flex-direction: column;
height: 100%;
}
}

View File

@@ -1,35 +0,0 @@
import { DrawerWrapper } from '@signozhq/ui';
import './DetailsPanelDrawer.styles.scss';
interface DetailsPanelDrawerProps {
isOpen: boolean;
onClose: () => void;
children: React.ReactNode;
className?: string;
}
function DetailsPanelDrawer({
isOpen,
onClose,
children,
className,
}: DetailsPanelDrawerProps): JSX.Element {
return (
<DrawerWrapper
open={isOpen}
onOpenChange={(open): void => {
if (!open) {
onClose();
}
}}
direction="right"
showOverlay={false}
className={`details-panel-drawer ${className || ''}`}
>
<div className="details-panel-drawer__body">{children}</div>
</DrawerWrapper>
);
}
export default DetailsPanelDrawer;

View File

@@ -1,8 +0,0 @@
export type {
DetailsHeaderProps,
HeaderAction,
} from './DetailsHeader/DetailsHeader';
export { default as DetailsHeader } from './DetailsHeader/DetailsHeader';
export { default as DetailsPanelDrawer } from './DetailsPanelDrawer';
export type { DetailsPanelState, UseDetailsPanelOptions } from './types';
export { default as useDetailsPanel } from './useDetailsPanel';

View File

@@ -1,10 +0,0 @@
export interface DetailsPanelState {
isOpen: boolean;
open: () => void;
close: () => void;
}
export interface UseDetailsPanelOptions {
entityId: string | undefined;
onClose?: () => void;
}

View File

@@ -1,29 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { DetailsPanelState, UseDetailsPanelOptions } from './types';
function useDetailsPanel({
entityId,
onClose,
}: UseDetailsPanelOptions): DetailsPanelState {
const [isOpen, setIsOpen] = useState<boolean>(false);
const prevEntityIdRef = useRef<string>('');
useEffect(() => {
const currentId = entityId || '';
if (currentId && currentId !== prevEntityIdRef.current) {
setIsOpen(true);
}
prevEntityIdRef.current = currentId;
}, [entityId]);
const open = useCallback(() => setIsOpen(true), []);
const close = useCallback(() => {
setIsOpen(false);
onClose?.();
}, [onClose]);
return { isOpen, open, close };
}
export default useDetailsPanel;

View File

@@ -17,6 +17,7 @@ import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import ContextView from 'container/LogDetailedView/ContextView/ContextView';
import InfraMetrics from 'container/LogDetailedView/InfraMetrics/InfraMetrics';
import JSONView from 'container/LogDetailedView/JsonView';
import Overview from 'container/LogDetailedView/Overview';
import {
aggregateAttributesResourcesToString,
@@ -46,7 +47,6 @@ import {
TextSelect,
X,
} from 'lucide-react';
import { JsonView } from 'periscope/components/JsonView';
import { useAppContext } from 'providers/App/App';
import { AppState } from 'store/reducers';
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
@@ -562,9 +562,7 @@ function LogDetailInner({
handleChangeSelectedView={handleChangeSelectedView}
/>
)}
{selectedView === VIEW_TYPES.JSON && (
<JsonView data={LogJsonData} height="68vh" />
)}
{selectedView === VIEW_TYPES.JSON && <JSONView logData={log} />}
{selectedView === VIEW_TYPES.CONTEXT && (
<ContextView

View File

@@ -0,0 +1,126 @@
import { ReactNode, useCallback, useEffect, useMemo, useRef } from 'react';
import { parseAsString, useQueryState } from 'nuqs';
import { useStore } from 'zustand';
import {
combineInitialAndUserExpression,
getUserExpressionFromCombined,
} from '../utils';
import { QuerySearchV2Context } from './context';
import type { QuerySearchV2ContextValue } from './QuerySearchV2.store';
import { createExpressionStore } from './QuerySearchV2.store';
export interface QuerySearchV2ProviderProps {
queryParamKey: string;
initialExpression?: string;
/**
* @default false
*/
persistOnUnmount?: boolean;
children: ReactNode;
}
/**
* Provider component that creates a scoped zustand store and exposes
* expression state to children via context.
*/
export function QuerySearchV2Provider({
initialExpression = '',
persistOnUnmount = false,
queryParamKey,
children,
}: QuerySearchV2ProviderProps): JSX.Element {
const storeRef = useRef(createExpressionStore());
const store = storeRef.current;
const [urlExpression, setUrlExpression] = useQueryState(
queryParamKey,
parseAsString,
);
const committedExpression = useStore(store, (s) => s.committedExpression);
const setInputExpression = useStore(store, (s) => s.setInputExpression);
const commitExpression = useStore(store, (s) => s.commitExpression);
const initializeFromUrl = useStore(store, (s) => s.initializeFromUrl);
const resetExpression = useStore(store, (s) => s.resetExpression);
const isInitialized = useRef(false);
useEffect(() => {
if (!isInitialized.current && urlExpression) {
const cleanedExpression = getUserExpressionFromCombined(
initialExpression,
urlExpression,
);
initializeFromUrl(cleanedExpression);
isInitialized.current = true;
}
}, [urlExpression, initialExpression, initializeFromUrl]);
useEffect(() => {
if (isInitialized.current || !urlExpression) {
setUrlExpression(committedExpression || null);
}
}, [committedExpression, setUrlExpression, urlExpression]);
useEffect(() => {
return (): void => {
if (!persistOnUnmount) {
setUrlExpression(null);
resetExpression();
}
};
}, [persistOnUnmount, setUrlExpression, resetExpression]);
const handleChange = useCallback(
(expression: string): void => {
const userOnly = getUserExpressionFromCombined(
initialExpression,
expression,
);
setInputExpression(userOnly);
},
[initialExpression, setInputExpression],
);
const handleRun = useCallback(
(expression: string): void => {
const userOnly = getUserExpressionFromCombined(
initialExpression,
expression,
);
commitExpression(userOnly);
},
[initialExpression, commitExpression],
);
const combinedExpression = useMemo(
() => combineInitialAndUserExpression(initialExpression, committedExpression),
[initialExpression, committedExpression],
);
const contextValue = useMemo<QuerySearchV2ContextValue>(
() => ({
expression: combinedExpression,
userExpression: committedExpression,
initialExpression,
querySearchProps: {
initialExpression: initialExpression.trim() ? initialExpression : undefined,
onChange: handleChange,
onRun: handleRun,
},
}),
[
combinedExpression,
committedExpression,
initialExpression,
handleChange,
handleRun,
],
);
return (
<QuerySearchV2Context.Provider value={contextValue}>
{children}
</QuerySearchV2Context.Provider>
);
}

View File

@@ -0,0 +1,60 @@
import { createStore, StoreApi } from 'zustand';
export type QuerySearchV2Store = {
/**
* User-typed expression (local state, updates on typing)
*/
inputExpression: string;
/**
* Committed expression (synced to URL, updates on submit)
*/
committedExpression: string;
setInputExpression: (expression: string) => void;
commitExpression: (expression: string) => void;
resetExpression: () => void;
initializeFromUrl: (urlExpression: string) => void;
};
export interface QuerySearchProps {
initialExpression: string | undefined;
onChange: (expression: string) => void;
onRun: (expression: string) => void;
}
export interface QuerySearchV2ContextValue {
/**
* Combined expression: "initialExpression AND (userExpression)"
*/
expression: string;
userExpression: string;
initialExpression: string;
querySearchProps: QuerySearchProps;
}
export function createExpressionStore(): StoreApi<QuerySearchV2Store> {
return createStore<QuerySearchV2Store>((set) => ({
inputExpression: '',
committedExpression: '',
setInputExpression: (expression: string): void => {
set({ inputExpression: expression });
},
commitExpression: (expression: string): void => {
set({
inputExpression: expression,
committedExpression: expression,
});
},
resetExpression: (): void => {
set({
inputExpression: '',
committedExpression: '',
});
},
initializeFromUrl: (urlExpression: string): void => {
set({
inputExpression: urlExpression,
committedExpression: urlExpression,
});
},
}));
}

View File

@@ -0,0 +1,95 @@
import { ReactNode } from 'react';
import { act, renderHook } from '@testing-library/react';
import { useQuerySearchV2Context } from '../context';
import {
QuerySearchV2Provider,
QuerySearchV2ProviderProps,
} from '../QuerySearchV2.provider';
const mockSetQueryState = jest.fn();
let mockUrlValue: string | null = null;
jest.mock('nuqs', () => ({
parseAsString: {},
useQueryState: jest.fn(() => [mockUrlValue, mockSetQueryState]),
}));
function createWrapper(
props: Partial<QuerySearchV2ProviderProps> = {},
): ({ children }: { children: ReactNode }) => JSX.Element {
return function Wrapper({ children }: { children: ReactNode }): JSX.Element {
return (
<QuerySearchV2Provider queryParamKey="testExpression" {...props}>
{children}
</QuerySearchV2Provider>
);
};
}
describe('QuerySearchExpressionProvider', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUrlValue = null;
});
it('should provide initial context values', () => {
const { result } = renderHook(() => useQuerySearchV2Context(), {
wrapper: createWrapper(),
});
expect(result.current.expression).toBe('');
expect(result.current.userExpression).toBe('');
expect(result.current.initialExpression).toBe('');
});
it('should combine initialExpression with userExpression', () => {
const { result } = renderHook(() => useQuerySearchV2Context(), {
wrapper: createWrapper({ initialExpression: 'k8s.pod.name = "my-pod"' }),
});
expect(result.current.expression).toBe('k8s.pod.name = "my-pod"');
expect(result.current.initialExpression).toBe('k8s.pod.name = "my-pod"');
act(() => {
result.current.querySearchProps.onChange('service = "api"');
});
act(() => {
result.current.querySearchProps.onRun('service = "api"');
});
expect(result.current.expression).toBe(
'k8s.pod.name = "my-pod" AND (service = "api")',
);
expect(result.current.userExpression).toBe('service = "api"');
});
it('should provide querySearchProps with correct callbacks', () => {
const { result } = renderHook(() => useQuerySearchV2Context(), {
wrapper: createWrapper({ initialExpression: 'initial' }),
});
expect(result.current.querySearchProps.initialExpression).toBe('initial');
expect(typeof result.current.querySearchProps.onChange).toBe('function');
expect(typeof result.current.querySearchProps.onRun).toBe('function');
});
it('should initialize from URL value on mount', () => {
mockUrlValue = 'status = 500';
const { result } = renderHook(() => useQuerySearchV2Context(), {
wrapper: createWrapper(),
});
expect(result.current.userExpression).toBe('status = 500');
expect(result.current.expression).toBe('status = 500');
});
it('should throw error when used outside provider', () => {
expect(() => {
renderHook(() => useQuerySearchV2Context());
}).toThrow(
'useQuerySearchV2Context must be used within a QuerySearchV2Provider',
);
});
});

View File

@@ -0,0 +1,61 @@
import { createExpressionStore } from '../QuerySearchV2.store';
describe('createExpressionStore', () => {
it('should create a store with initial state', () => {
const store = createExpressionStore();
const state = store.getState();
expect(state.inputExpression).toBe('');
expect(state.committedExpression).toBe('');
});
it('should update inputExpression via setInputExpression', () => {
const store = createExpressionStore();
store.getState().setInputExpression('service.name = "api"');
expect(store.getState().inputExpression).toBe('service.name = "api"');
expect(store.getState().committedExpression).toBe('');
});
it('should update both expressions via commitExpression', () => {
const store = createExpressionStore();
store.getState().setInputExpression('service.name = "api"');
store.getState().commitExpression('service.name = "api"');
expect(store.getState().inputExpression).toBe('service.name = "api"');
expect(store.getState().committedExpression).toBe('service.name = "api"');
});
it('should reset all state via resetExpression', () => {
const store = createExpressionStore();
store.getState().setInputExpression('service.name = "api"');
store.getState().commitExpression('service.name = "api"');
store.getState().resetExpression();
expect(store.getState().inputExpression).toBe('');
expect(store.getState().committedExpression).toBe('');
});
it('should initialize from URL value', () => {
const store = createExpressionStore();
store.getState().initializeFromUrl('status = 500');
expect(store.getState().inputExpression).toBe('status = 500');
expect(store.getState().committedExpression).toBe('status = 500');
});
it('should create isolated store instances', () => {
const store1 = createExpressionStore();
const store2 = createExpressionStore();
store1.getState().setInputExpression('expr1');
store2.getState().setInputExpression('expr2');
expect(store1.getState().inputExpression).toBe('expr1');
expect(store2.getState().inputExpression).toBe('expr2');
});
});

View File

@@ -0,0 +1,17 @@
// eslint-disable-next-line no-restricted-imports -- React Context required for scoped store pattern
import { createContext, useContext } from 'react';
import type { QuerySearchV2ContextValue } from './QuerySearchV2.store';
export const QuerySearchV2Context =
createContext<QuerySearchV2ContextValue | null>(null);
export function useQuerySearchV2Context(): QuerySearchV2ContextValue {
const context = useContext(QuerySearchV2Context);
if (!context) {
throw new Error(
'useQuerySearchV2Context must be used within a QuerySearchV2Provider',
);
}
return context;
}

View File

@@ -0,0 +1,8 @@
export { useQuerySearchV2Context } from './context';
export type { QuerySearchV2ProviderProps } from './QuerySearchV2.provider';
export { QuerySearchV2Provider } from './QuerySearchV2.provider';
export type {
QuerySearchProps,
QuerySearchV2ContextValue,
QuerySearchV2Store,
} from './QuerySearchV2.store';

View File

@@ -19,6 +19,13 @@
display: flex;
flex-direction: row;
.query-search-initial-scope-label {
position: absolute;
left: 8px;
top: 10px;
z-index: 10;
}
.query-where-clause-editor {
flex: 1;
min-width: 400px;
@@ -53,6 +60,10 @@
}
}
}
&.hasInitialExpression .cm-editor .cm-content {
padding-left: 22px !important;
}
}
.cm-editor {
@@ -68,7 +79,6 @@
border-radius: 2px;
border: 1px solid var(--l1-border);
padding: 0px !important;
background-color: var(--l1-background) !important;
&:focus-within {
border-color: var(--l1-border);

View File

@@ -30,7 +30,7 @@ import { useDashboardVariablesByType } from 'hooks/dashboard/useDashboardVariabl
import { useIsDarkMode } from 'hooks/useDarkMode';
import useDebounce from 'hooks/useDebounce';
import { debounce, isNull } from 'lodash-es';
import { Info, TriangleAlert } from 'lucide-react';
import { Filter, Info, TriangleAlert } from 'lucide-react';
import {
IDetailedError,
IQueryContext,
@@ -47,6 +47,7 @@ import { validateQuery } from 'utils/queryValidationUtils';
import { unquote } from 'utils/stringUtils';
import { queryExamples } from './constants';
import { combineInitialAndUserExpression } from './utils';
import './QuerySearch.styles.scss';
@@ -85,6 +86,8 @@ interface QuerySearchProps {
hardcodedAttributeKeys?: QueryKeyDataSuggestionsProps[];
onRun?: (query: string) => void;
showFilterSuggestionsWithoutMetric?: boolean;
/** When set, the editor shows only the user expression; API/filter uses `initial AND (user)`. */
initialExpression?: string;
}
function QuerySearch({
@@ -96,6 +99,7 @@ function QuerySearch({
signalSource,
hardcodedAttributeKeys,
showFilterSuggestionsWithoutMetric,
initialExpression,
}: QuerySearchProps): JSX.Element {
const isDarkMode = useIsDarkMode();
const [valueSuggestions, setValueSuggestions] = useState<any[]>([]);
@@ -112,18 +116,26 @@ function QuerySearch({
const [isFocused, setIsFocused] = useState(false);
const editorRef = useRef<EditorView | null>(null);
const handleQueryValidation = useCallback((newExpression: string): void => {
try {
const validationResponse = validateQuery(newExpression);
setValidation(validationResponse);
} catch (error) {
setValidation({
isValid: false,
message: 'Failed to process query',
errors: [error as IDetailedError],
});
}
}, []);
const isScopedFilter = initialExpression !== undefined;
const validateExpressionForEditor = useCallback(
(editorDoc: string): void => {
const toValidate = isScopedFilter
? combineInitialAndUserExpression(initialExpression ?? '', editorDoc)
: editorDoc;
try {
const validationResponse = validateQuery(toValidate);
setValidation(validationResponse);
} catch (error) {
setValidation({
isValid: false,
message: 'Failed to process query',
errors: [error as IDetailedError],
});
}
},
[initialExpression, isScopedFilter],
);
const getCurrentExpression = useCallback(
(): string => editorRef.current?.state.doc.toString() || '',
@@ -165,6 +177,8 @@ function QuerySearch({
setIsEditorReady(true);
}, []);
const prevQueryDataExpressionRef = useRef<string | undefined>();
useEffect(
() => {
if (!isEditorReady) {
@@ -173,13 +187,22 @@ function QuerySearch({
const newExpression = queryData.filter?.expression || '';
const currentExpression = getCurrentExpression();
const prevExpression = prevQueryDataExpressionRef.current;
// Do not update codemirror editor if the expression is the same
if (newExpression !== currentExpression && !isFocused) {
// Only sync editor when queryData.filter?.expression actually changed from external source
// Not when focus changed (which would reset uncommitted user input)
const queryDataExpressionChanged = prevExpression !== newExpression;
prevQueryDataExpressionRef.current = newExpression;
if (
queryDataExpressionChanged &&
newExpression !== currentExpression &&
!isFocused
) {
updateEditorValue(newExpression, { skipOnChange: true });
if (newExpression) {
handleQueryValidation(newExpression);
}
}
if (!isFocused) {
validateExpressionForEditor(currentExpression);
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -284,7 +307,7 @@ function QuerySearch({
}
});
}
setKeySuggestions(Array.from(merged.values()));
setKeySuggestions([...merged.values()]);
// Force reopen the completion if editor is available and focused
if (editorRef.current) {
@@ -337,7 +360,7 @@ function QuerySearch({
// If value contains single quotes, escape them and wrap in single quotes
if (value.includes("'")) {
// Replace single quotes with escaped single quotes
const escapedValue = value.replace(/'/g, "\\'");
const escapedValue = value.replaceAll(/'/g, "\\'");
return `'${escapedValue}'`;
}
@@ -614,7 +637,7 @@ function QuerySearch({
const handleBlur = (): void => {
const currentExpression = getCurrentExpression();
handleQueryValidation(currentExpression);
validateExpressionForEditor(currentExpression);
setIsFocused(false);
};
@@ -632,7 +655,6 @@ function QuerySearch({
);
const handleExampleClick = (exampleQuery: string): void => {
// If there's an existing query, append the example with AND
const currentExpression = getCurrentExpression();
const newExpression = currentExpression
? `${currentExpression} AND ${exampleQuery}`
@@ -897,12 +919,12 @@ function QuerySearch({
// If we have previous pairs, we can prioritize keys that haven't been used yet
if (queryContext.queryPairs && queryContext.queryPairs.length > 0) {
const usedKeys = queryContext.queryPairs.map((pair) => pair.key);
const usedKeys = new Set(queryContext.queryPairs.map((pair) => pair.key));
// Add boost to unused keys to prioritize them
options = options.map((option) => ({
...option,
boost: usedKeys.includes(option.label) ? -10 : 10,
boost: usedKeys.has(option.label) ? -10 : 10,
}));
}
@@ -1317,6 +1339,19 @@ function QuerySearch({
)}
<div className="query-where-clause-editor-container">
{isScopedFilter ? (
<Tooltip title={initialExpression || ''} placement="left">
<div className="query-search-initial-scope-label">
<Filter
size={14}
style={{
opacity: 0.9,
color: isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_500,
}}
/>
</div>
</Tooltip>
) : null}
<Tooltip
title={<div data-log-detail-ignore="true">{getTooltipContent()}</div>}
placement="left"
@@ -1356,6 +1391,7 @@ function QuerySearch({
className={cx('query-where-clause-editor', {
isValid: validation.isValid === true,
hasErrors: validation.errors.length > 0,
hasInitialExpression: isScopedFilter,
})}
extensions={[
autocompletion({
@@ -1390,7 +1426,12 @@ function QuerySearch({
// Mod-Enter is usually Ctrl-Enter or Cmd-Enter based on OS
run: (): boolean => {
if (onRun && typeof onRun === 'function') {
onRun(getCurrentExpression());
const user = getCurrentExpression();
onRun(
isScopedFilter
? combineInitialAndUserExpression(initialExpression ?? '', user)
: user,
);
}
return true;
},
@@ -1555,6 +1596,7 @@ QuerySearch.defaultProps = {
placeholder:
"Enter your filter query (e.g., http.status_code >= 500 AND service.name = 'frontend')",
showFilterSuggestionsWithoutMetric: false,
initialExpression: undefined,
};
export default QuerySearch;

View File

@@ -0,0 +1,58 @@
import {
combineInitialAndUserExpression,
getUserExpressionFromCombined,
} from '../utils';
describe('entityLogsExpression', () => {
describe('combineInitialAndUserExpression', () => {
it('returns user when initial is empty', () => {
expect(combineInitialAndUserExpression('', 'body contains error')).toBe(
'body contains error',
);
});
it('returns initial when user is empty', () => {
expect(combineInitialAndUserExpression('k8s.pod.name = "x"', '')).toBe(
'k8s.pod.name = "x"',
);
});
it('wraps user in parentheses with AND', () => {
expect(
combineInitialAndUserExpression('k8s.pod.name = "x"', 'body = "a"'),
).toBe('k8s.pod.name = "x" AND (body = "a")');
});
});
describe('getUserExpressionFromCombined', () => {
it('returns empty when combined equals initial', () => {
expect(
getUserExpressionFromCombined('k8s.pod.name = "x"', 'k8s.pod.name = "x"'),
).toBe('');
});
it('extracts user from wrapped form', () => {
expect(
getUserExpressionFromCombined(
'k8s.pod.name = "x"',
'k8s.pod.name = "x" AND (body = "a")',
),
).toBe('body = "a"');
});
it('extracts user from legacy AND without parens', () => {
expect(
getUserExpressionFromCombined(
'k8s.pod.name = "x"',
'k8s.pod.name = "x" AND body = "a"',
),
).toBe('body = "a"');
});
it('returns full combined when initial is empty', () => {
expect(getUserExpressionFromCombined('', 'service.name = "a"')).toBe(
'service.name = "a"',
);
});
});
});

View File

@@ -0,0 +1,40 @@
export function combineInitialAndUserExpression(
initial: string,
user: string,
): string {
const i = initial.trim();
const u = user.trim();
if (!i) {
return u;
}
if (!u) {
return i;
}
return `${i} AND (${u})`;
}
export function getUserExpressionFromCombined(
initial: string,
combined: string | null | undefined,
): string {
const i = initial.trim();
const c = (combined ?? '').trim();
if (!c) {
return '';
}
if (!i) {
return c;
}
if (c === i) {
return '';
}
const wrappedPrefix = `${i} AND (`;
if (c.startsWith(wrappedPrefix) && c.endsWith(')')) {
return c.slice(wrappedPrefix.length, -1);
}
const plainPrefix = `${i} AND `;
if (c.startsWith(plainPrefix)) {
return c.slice(plainPrefix.length);
}
return c;
}

View File

@@ -0,0 +1,14 @@
export type {
QuerySearchProps,
QuerySearchV2ContextValue,
QuerySearchV2ProviderProps,
} from './QueryV2/QuerySearch/Provider';
export {
QuerySearchV2Provider,
useQuerySearchV2Context,
} from './QueryV2/QuerySearch/Provider';
export { QueryBuilderV2 } from './QueryBuilderV2';
export {
QueryBuilderV2Provider,
useQueryBuilderV2Context,
} from './QueryBuilderV2Context';

View File

@@ -47,10 +47,16 @@ 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,
);
@@ -105,6 +111,12 @@ 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,7 +2,10 @@
position: sticky;
top: 0;
z-index: 2;
padding: 0.3rem;
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);
transform: translate3d(
var(--tanstack-header-translate-x, 0px),
var(--tanstack-header-translate-y, 0px),
@@ -19,7 +22,17 @@
}
border: none !important;
background-color: var(--l2-background) !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;
}
}
.tanstackHeaderContent {
@@ -61,7 +74,7 @@
width: 12px;
height: 12px;
cursor: grab;
color: var(--l2-foreground);
color: var(--tanstack-table-header-cell-color, var(--l2-foreground));
opacity: 1;
touch-action: none;
}
@@ -74,7 +87,7 @@
height: 20px;
cursor: pointer;
flex-shrink: 0;
color: var(--l2-foreground);
color: var(--tanstack-table-header-cell-color, var(--l2-foreground));
margin-left: auto;
}
@@ -82,8 +95,9 @@
.tanstackColumnActionsContent {
width: 140px;
padding: 0;
background: var(--l2-background);
border: 1px solid var(--l2-border);
background: var(--tanstack-table-header-cell-bg, var(--l2-background));
border: 1px solid
var(--tanstack-table-header-cell-actions-border-color, var(--l2-border));
border-radius: 4px;
box-shadow: none;
}
@@ -137,7 +151,7 @@
}
.tanstackHeaderCell.isResizing .cursorColResize {
background: var(--bg-robin-300);
background: var(--tanstack-table-resize-active-bg, var(--bg-robin-300));
}
.tanstackResizeHandleLine {
@@ -147,7 +161,7 @@
left: 50%;
width: 4px;
transform: translateX(-50%);
background: var(--l2-background);
background: var(--tanstack-table-resize-handle-bg, var(--l2-background));
opacity: 1;
pointer-events: none;
transition:
@@ -155,13 +169,34 @@
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(--l2-border);
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
);
}
.tanstackHeaderCell.isResizing .tanstackResizeHandleLine {
width: 2px;
background: var(--bg-robin-500);
background: var(--tanstack-table-resize-handle-active-bg, var(--bg-robin-500));
transition: none;
}
@@ -213,7 +248,12 @@
flex-shrink: 0;
width: 14px;
height: 14px;
color: var(--l2-foreground);
color: var(--l3-foreground);
&[data-sort-direction='asc'],
&[data-sort-direction='desc'] {
color: var(--primary);
}
}
.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 { ChevronDown, ChevronUp, GripVertical } from 'lucide-react';
import { ArrowDown, ArrowUp, ArrowUpDown, GripVertical } from 'lucide-react';
import { SortState, TableColumnDef } from './types';
@@ -177,12 +177,17 @@ function TanStackHeaderRow<TData>({
? column.header()
: String(column.header || '').replace(/^\w/, (c) => c.toUpperCase())}
</span>
<span className={headerStyles.tanstackSortIndicator}>
<span
className={headerStyles.tanstackSortIndicator}
data-sort-direction={currentSortDirection || 'none'}
>
{currentSortDirection === 'asc' ? (
<ChevronUp size={SORT_ICON_SIZE} />
<ArrowUp size={SORT_ICON_SIZE} />
) : currentSortDirection === 'desc' ? (
<ChevronDown size={SORT_ICON_SIZE} />
) : null}
<ArrowDown size={SORT_ICON_SIZE} />
) : (
<ArrowUpDown size={SORT_ICON_SIZE} />
)}
</span>
</button>
) : (

View File

@@ -1,4 +1,22 @@
.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;
@@ -26,7 +44,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(--l2-foreground);
color: var(--tanstack-table-cell-color, var(--l2-foreground));
max-width: 100%;
word-break: break-all;
}
@@ -42,13 +60,35 @@
}
.tableCell {
padding: 0.3rem;
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);
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(--l2-foreground);
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)
);
}
}
.tableRow {
@@ -58,19 +98,69 @@
&:hover {
.tableCell {
background-color: var(--row-hover-bg) !important;
background-color: var(
--tanstack-table-row-hover-bg,
var(--row-hover-bg)
) !important;
}
}
&.tableRowActive {
.tableCell {
background-color: var(--row-active-bg) !important;
background-color: var(
--tanstack-table-row-active-bg,
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: 0.3rem;
padding: var(--tanstack-cell-padding-top) var(--tanstack-cell-padding-right)
var(--tanstack-cell-padding-bottom) var(--tanstack-cell-padding-left);
height: 36px;
text-align: left;
font-size: 14px;
@@ -78,20 +168,40 @@
font-weight: 400;
line-height: 18px;
letter-spacing: -0.07px;
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;
}
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);
}
.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,6 +90,7 @@ function TanStackTableInner<TData>(
skeletonRowCount = 10,
enableQueryParams,
pagination,
paginationClassname,
onEndReached,
getRowKey,
getItemKey,
@@ -112,9 +113,17 @@ 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();
@@ -221,6 +230,15 @@ 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,
@@ -229,7 +247,7 @@ function TanStackTableInner<TData>(
columnResizeMode: 'onChange',
getCoreRowModel: getCoreRowModel(),
getRowId,
enableExpanding: Boolean(renderExpandedRow),
enableExpanding: isExpandEnabled,
getRowCanExpand: renderExpandedRow ? tableGetRowCanExpand : undefined,
onColumnSizingChange: handleColumnSizingChange,
onColumnVisibilityChange: noopColumnVisibility,
@@ -333,6 +351,7 @@ function TanStackTableInner<TData>(
hasSingleColumn,
columnOrderKey,
columnVisibilityKey,
enableAlternatingRowColors,
}),
[
getRowStyle,
@@ -350,6 +369,7 @@ function TanStackTableInner<TData>(
hasSingleColumn,
columnOrderKey,
columnVisibilityKey,
enableAlternatingRowColors,
],
);
@@ -500,7 +520,10 @@ function TanStackTableInner<TData>(
);
return (
<div className={cx(viewStyles.tanstackTableViewWrapper, className)}>
<div
className={cx(viewStyles.tanstackTableViewWrapper, className)}
data-has-group-by={(groupBy?.length || 0) > 0}
>
<TanStackTableStateProvider>
<TableLoadingSync
isLoading={isLoading}
@@ -508,23 +531,53 @@ function TanStackTableInner<TData>(
/>
<ColumnVisibilitySync visibility={effectiveVisibility} />
<TooltipProvider>
<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}
/>
{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}
/>
)}
{showInfiniteScrollLoader && (
<div
className={viewStyles.tanstackLoadingOverlay}
@@ -534,7 +587,7 @@ function TanStackTableInner<TData>(
</div>
)}
{showPagination && pagination && (
<div className={viewStyles.paginationContainer}>
<div className={cx(viewStyles.paginationContainer, paginationClassname)}>
{prefixPaginationContent}
<Pagination
current={page}

View File

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

View File

@@ -389,6 +389,101 @@ 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,7 +138,10 @@ describe('useTableParams (URL mode — enableQueryParams set)', () => {
it('reads initial page from URL params', () => {
const wrapper = createNuqsWrapper({ page: '3' });
const { result } = renderHook(() => useTableParams(true), { wrapper });
// Pass matching default to prevent reset on mount (page resets when orderBy changes)
const { result } = renderHook(() => useTableParams(true, { page: 3 }), {
wrapper,
});
expect(result.current.page).toBe(3);
});
@@ -249,3 +252,294 @@ 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,6 +171,27 @@ 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,6 +107,8 @@ 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 = {
@@ -116,10 +118,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> = {
@@ -137,6 +139,7 @@ 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. */
@@ -176,6 +179,10 @@ 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,6 +8,12 @@ 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;
@@ -49,30 +55,49 @@ 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}_page`
: typeof enableQueryParams === 'object'
? enableQueryParams.page
: 'page';
? `${enableQueryParams}_${URL_KEYS_DEFAULT.page}`
: isObjectConfig
? (enableQueryParams.page ?? URL_KEYS_DEFAULT.page)
: URL_KEYS_DEFAULT.page;
const limitQueryParam =
typeof enableQueryParams === 'string'
? `${enableQueryParams}_limit`
: typeof enableQueryParams === 'object'
? enableQueryParams.limit
: 'limit';
? `${enableQueryParams}_${URL_KEYS_DEFAULT.limit}`
: isObjectConfig
? (enableQueryParams.limit ?? URL_KEYS_DEFAULT.limit)
: URL_KEYS_DEFAULT.limit;
const orderByQueryParam =
typeof enableQueryParams === 'string'
? `${enableQueryParams}_order_by`
: typeof enableQueryParams === 'object'
? enableQueryParams.orderBy
: 'order_by';
? `${enableQueryParams}_${URL_KEYS_DEFAULT.orderBy}`
: isObjectConfig
? (enableQueryParams.orderBy ?? URL_KEYS_DEFAULT.orderBy)
: URL_KEYS_DEFAULT.orderBy;
const expandedQueryParam =
typeof enableQueryParams === 'string'
? `${enableQueryParams}_expanded`
: typeof enableQueryParams === 'object'
? enableQueryParams.expanded
: 'expanded';
? `${enableQueryParams}_${URL_KEYS_DEFAULT.expanded}`
: isObjectConfig
? (enableQueryParams.expanded ?? URL_KEYS_DEFAULT.expanded)
: URL_KEYS_DEFAULT.expanded;
const pageDefault = defaults?.page ?? DEFAULT_PAGE;
const limitDefault = defaults?.limit ?? DEFAULT_LIMIT;
const orderByDefault = defaults?.orderBy ?? null;
@@ -149,45 +174,29 @@ 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 (isEnabledQueryParams) {
if (useUrlForPage) {
setUrlPage(pageDefault);
} else {
setLocalPage(pageDefault);
}
}, [
isEnabledQueryParams,
useUrlForPage,
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: localPage,
limit: localLimit,
orderBy: localOrderBy,
expanded: localExpanded,
setPage: setLocalPage,
setLimit: setLocalLimit,
setOrderBy: setLocalOrderBy,
setExpanded: handleSetLocalExpanded,
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,
};
}

View File

@@ -35,7 +35,10 @@ export const getColumnWidthStyle = <TData>(
): CSSProperties => {
// Last column always fills remaining space
if (isLastColumn) {
return { width: '100%' };
return {
width: '100%',
minWidth: persistedWidth ?? column?.width?.min,
};
}
const { width } = column;
@@ -59,10 +62,19 @@ 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

@@ -1,20 +0,0 @@
.timeline-v3-container {
overflow: visible;
position: relative;
}
.timeline-v3-cursor-badge {
position: absolute;
top: 0;
transform: translateX(-50%);
background: var(--l3-background);
border: 1px solid var(--l2-border);
border-radius: 4px;
padding: 2px 8px;
font-size: 11px;
font-weight: 500;
color: var(--l1-foreground);
white-space: nowrap;
pointer-events: none;
z-index: 1;
}

View File

@@ -1,119 +0,0 @@
import { useEffect, useMemo, useState } from 'react';
import { useMeasure } from 'react-use';
import { resolveTimeFromInterval } from 'components/TimelineV2/utils';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { toFixed } from 'utils/toFixed';
import {
getIntervals,
getIntervalUnit,
getMinimumIntervalsBasedOnWidth,
Interval,
} from './utils';
import './TimelineV3.styles.scss';
interface ITimelineV3Props {
startTimestamp: number;
endTimestamp: number;
timelineHeight: number;
offsetTimestamp: number;
/** Cursor X as a fraction of the timeline width (01). null = no cursor. */
cursorXPercent?: number | null;
}
function TimelineV3(props: ITimelineV3Props): JSX.Element {
const {
startTimestamp,
endTimestamp,
timelineHeight,
offsetTimestamp,
cursorXPercent,
} = props;
const [intervals, setIntervals] = useState<Interval[]>([]);
const [ref, { width }] = useMeasure<HTMLDivElement>();
const isDarkMode = useIsDarkMode();
const spread = endTimestamp - startTimestamp;
useEffect(() => {
if (spread < 0) {
return;
}
const minIntervals = getMinimumIntervalsBasedOnWidth(width);
const intervalisedSpread = (spread / minIntervals) * 1.0;
const newIntervals = getIntervals(
intervalisedSpread,
spread,
offsetTimestamp,
);
setIntervals(newIntervals);
}, [startTimestamp, endTimestamp, width, offsetTimestamp, spread]);
// Compute cursor time label using the same unit as timeline ticks
const cursorLabel = useMemo(() => {
if (cursorXPercent == null || spread <= 0) {
return null;
}
const timeAtCursor = offsetTimestamp + cursorXPercent * spread;
const unit = getIntervalUnit(spread, offsetTimestamp);
const formatted = toFixed(resolveTimeFromInterval(timeAtCursor, unit), 2);
return `${formatted}${unit.name}`;
}, [cursorXPercent, spread, offsetTimestamp]);
if (endTimestamp < startTimestamp) {
console.error(
'endTimestamp cannot be less than startTimestamp',
startTimestamp,
endTimestamp,
);
return <div />;
}
const strokeColor = isDarkMode ? ' rgb(192,193,195,0.8)' : 'black';
const svgHeight = timelineHeight * 2.5;
const cursorX = cursorXPercent != null ? cursorXPercent * width : null;
return (
<div ref={ref as never} className="timeline-v3-container">
<svg
width={width}
height={svgHeight}
xmlns="http://www.w3.org/2000/svg"
overflow="visible"
>
{intervals &&
intervals.length > 0 &&
intervals.map((interval, index) => (
<g
transform={`translate(${(interval.percentage * width) / 100},0)`}
key={`${interval.percentage + interval.label + index}`}
textAnchor="middle"
fontSize="0.6rem"
>
<text
x={index === intervals.length - 1 ? -10 : 0}
y={timelineHeight * 2}
fill={strokeColor}
>
{interval.label}
</text>
<line y1={0} y2={timelineHeight} stroke={strokeColor} strokeWidth="1" />
</g>
))}
</svg>
{/* Cursor time badge — DOM element for easy CSS styling */}
{cursorX !== null && cursorLabel && (
<div className="timeline-v3-cursor-badge" style={{ left: cursorX }}>
{cursorLabel}
</div>
)}
</div>
);
}
export default TimelineV3;

View File

@@ -1,109 +0,0 @@
import {
IIntervalUnit,
Interval,
INTERVAL_UNITS,
resolveTimeFromInterval,
} from 'components/TimelineV2/utils';
import { toFixed } from 'utils/toFixed';
export type { Interval };
/**
* Select the interval unit matching the timeline's logic.
* Exported so crosshair labels use the same unit as timeline ticks.
*/
export function getIntervalUnit(
spread: number,
offsetTimestamp: number,
): IIntervalUnit {
const minIntervals = 6;
const intervalSpread = spread / minIntervals;
const valueForUnitSelection = Math.max(offsetTimestamp, intervalSpread);
let unit: IIntervalUnit = INTERVAL_UNITS[0];
for (let idx = INTERVAL_UNITS.length - 1; idx >= 0; idx -= 1) {
if (valueForUnitSelection * INTERVAL_UNITS[idx].multiplier >= 1) {
unit = INTERVAL_UNITS[idx];
break;
}
}
return unit;
}
/** Fewer intervals than TimelineV2 for a cleaner flamegraph ruler. */
export function getMinimumIntervalsBasedOnWidth(width: number): number {
if (width < 640) {
return 3;
}
if (width < 768) {
return 4;
}
if (width < 1024) {
return 5;
}
return 6;
}
/**
* Computes timeline intervals with offset-aware labels.
* Labels reflect absolute time from trace start (offsetTimestamp + elapsed),
* so when zoomed into a window, the first tick shows e.g. "50ms" not "0ms".
*/
export function getIntervals(
intervalSpread: number,
baseSpread: number,
offsetTimestamp: number,
): Interval[] {
const integerPartString = intervalSpread.toString().split('.')[0];
const integerPartLength = integerPartString.length;
const intervalSpreadNormalized =
intervalSpread < 1.0
? intervalSpread
: Math.floor(Number(integerPartString) / 10 ** (integerPartLength - 1)) *
10 ** (integerPartLength - 1);
// Unit must suit both: (1) tick granularity (intervalSpread) and (2) label magnitude
// (offsetTimestamp). When zoomed deep into a trace, labels show offsetTimestamp + elapsed,
// so we must pick a unit where that value is readable (e.g. "500.00s" not "500000.00ms").
const valueForUnitSelection = Math.max(offsetTimestamp, intervalSpread);
let intervalUnit: IIntervalUnit = INTERVAL_UNITS[0];
for (let idx = INTERVAL_UNITS.length - 1; idx >= 0; idx -= 1) {
const standardInterval = INTERVAL_UNITS[idx];
if (valueForUnitSelection * standardInterval.multiplier >= 1) {
intervalUnit = INTERVAL_UNITS[idx];
break;
}
}
const intervals: Interval[] = [
{
label: `${toFixed(
resolveTimeFromInterval(offsetTimestamp, intervalUnit),
2,
)}${intervalUnit.name}`,
percentage: 0,
},
];
// Only show even-interval ticks — skip the trailing partial tick at the edge.
// The last even tick sits before the full width, so it doesn't conflict with
// span duration labels that may have sub-millisecond precision.
let elapsedIntervals = 0;
while (
elapsedIntervals + intervalSpreadNormalized <= baseSpread &&
intervals.length < 20
) {
elapsedIntervals += intervalSpreadNormalized;
const labelTime = offsetTimestamp + elapsedIntervals;
intervals.push({
label: `${toFixed(resolveTimeFromInterval(labelTime, intervalUnit), 2)}${
intervalUnit.name
}`,
percentage: (elapsedIntervals / baseSpread) * 100,
});
}
return intervals;
}

View File

@@ -6,6 +6,7 @@ export enum Events {
TOOLTIP_PINNED = 'TOOLTIP_PINNED',
TOOLTIP_UNPINNED = 'TOOLTIP_UNPINNED',
TOOLTIP_CONTENT_SCROLLED = 'TOOLTIP_CONTENT_SCROLLED',
TOOLTIP_SYNC_MODE_CHANGED = 'TOOLTIP_SYNC_MODE_CHANGED',
}
export enum InfraMonitoringEvents {

View File

@@ -37,6 +37,6 @@ export enum LOCALSTORAGE {
SHOW_FREQUENCY_CHART = 'SHOW_FREQUENCY_CHART',
DISSMISSED_COST_METER_INFO = 'DISMISSED_COST_METER_INFO',
DISMISSED_API_KEYS_DEPRECATION_BANNER = 'DISMISSED_API_KEYS_DEPRECATION_BANNER',
TRACE_DETAILS_SPAN_DETAILS_POSITION = 'TRACE_DETAILS_SPAN_DETAILS_POSITION',
LICENSE_KEY_CALLOUT_DISMISSED = 'LICENSE_KEY_CALLOUT_DISMISSED',
DASHBOARD_PREFERENCES = 'DASHBOARD_PREFERENCES',
}

View File

@@ -33,7 +33,6 @@ export const REACT_QUERY_KEY = {
UPDATE_ALERT_RULE: 'UPDATE_ALERT_RULE',
GET_ACTIVE_LICENSE_V3: 'GET_ACTIVE_LICENSE_V3',
GET_TRACE_V2_WATERFALL: 'GET_TRACE_V2_WATERFALL',
GET_TRACE_V3_WATERFALL: 'GET_TRACE_V3_WATERFALL',
GET_TRACE_V2_FLAMEGRAPH: 'GET_TRACE_V2_FLAMEGRAPH',
GET_POD_LIST: 'GET_POD_LIST',
GET_NODE_LIST: 'GET_NODE_LIST',

View File

@@ -8,7 +8,6 @@ const ROUTES = {
SERVICE_MAP: '/service-map',
TRACE: '/trace',
TRACE_DETAIL: '/trace/:id',
TRACE_DETAIL_OLD: '/trace-old/:id',
TRACES_EXPLORER: '/traces-explorer',
ONBOARDING: '/onboarding',
GET_STARTED: '/get-started',

View File

@@ -33,102 +33,6 @@ const themeColors = {
purple: '#800080',
cyan: '#00FFFF',
},
traceDetailColorsV3: {
// Blues
blue1: '#2F80ED',
blue2: '#3366E6',
blue3: '#4682B4',
blue4: '#1F63E0',
blue5: '#3A7AED',
blue6: '#5A9DF5',
blue7: '#2874A6',
blue8: '#2E86C1',
blue9: '#3498DB',
blue10: '#1E90FF',
blue11: '#4169E1',
// Cyans / Teals
cyan1: '#00CEC9',
cyan2: '#22A6F2',
cyan3: '#00B0AA',
cyan4: '#33D6C2',
cyan5: '#66E9DA',
cyan6: '#48DBFB',
cyan7: '#00BFFF',
cyan8: '#63B8FF',
teal1: '#009688',
teal2: '#1ABC9C',
teal3: '#48C9B0',
teal4: '#76D7C4',
teal5: '#20B2AA',
// Greens
green1: '#27AE60',
green2: '#3CB371',
green3: '#1E8449',
green4: '#2ECC71',
green5: '#58D68D',
green6: '#229954',
green7: '#52BE80',
green8: '#82E0AA',
green9: '#73C6B6',
// Limes
lime1: '#A3E635',
lime2: '#B9F18D',
lime3: '#84CC16',
lime4: '#65A30D',
// Yellows
yellow1: '#F1C40F',
yellow2: '#F7DC6F',
yellow3: '#F9E79F',
yellow4: '#F4D03F',
yellow5: '#D4AC0D',
// Golds / Ambers
gold1: '#F2C94C',
gold2: '#FFD93D',
gold3: '#FFCA28',
gold4: '#B7950B',
gold5: '#D4A017',
// Oranges (non-red)
orange1: '#F39C12',
orange2: '#E67E22',
orange3: '#F5B041',
orange4: '#D35400',
orange5: '#EB984E',
orange6: '#FAD7A0',
// Purples / Violets
purple1: '#BB6BD9',
purple2: '#9B51E0',
purple3: '#DA77F2',
purple4: '#C77DFF',
purple5: '#6C5CE7',
purple6: '#8E44AD',
purple7: '#9B59B6',
purple8: '#BB8FCE',
purple9: '#7D3C98',
purple10: '#A569BD',
// Lavenders
lavender1: '#AF7AC5',
lavender2: '#C39BD3',
lavender3: '#D2B4DE',
// Pinks / Magentas
pink1: '#E91E8C',
pink2: '#FF6FD8',
pink3: '#F06292',
pink4: '#CE93D8',
// Salmons / Corals (distinct from error red)
salmon1: '#FF8A65',
salmon2: '#FFAB91',
salmon3: '#E0876A',
},
chartcolors: {
// Blues (3)
dodgerBlue: '#2F80ED',

View File

@@ -107,7 +107,7 @@ describe('BillingContainer', () => {
).resolves.toBeInTheDocument();
await expect(
screen.findByText('Cancel Subscription', { selector: 'span' }),
screen.findByText('Cancel your subscription', { selector: 'span' }),
).resolves.toBeInTheDocument();
});
@@ -150,7 +150,7 @@ describe('BillingContainer', () => {
expect(dayRemainingInBillingPeriod).toBeInTheDocument();
await expect(
screen.findByText('Cancel Subscription', { selector: 'span' }),
screen.findByText('Cancel your subscription', { selector: 'span' }),
).resolves.toBeInTheDocument();
});
});
@@ -162,7 +162,7 @@ describe('BillingContainer', () => {
it('should render when license is ACTIVATED and platform is CLOUD', async () => {
render(<BillingContainer />);
await expect(
screen.findByText('Cancel Subscription', { selector: 'span' }),
screen.findByText('Cancel your subscription', { selector: 'span' }),
).resolves.toBeInTheDocument();
});
@@ -186,7 +186,7 @@ describe('BillingContainer', () => {
);
await screen.findByText('billing');
expect(
screen.queryByText('Cancel Subscription', { selector: 'span' }),
screen.queryByText('Cancel your subscription', { selector: 'span' }),
).not.toBeInTheDocument();
});
@@ -225,7 +225,7 @@ describe('BillingContainer', () => {
render(<BillingContainer />, {}, { appContextOverrides: overrides });
await screen.findByText('billing');
expect(
screen.queryByText('Cancel Subscription', { selector: 'span' }),
screen.queryByText('Cancel your subscription', { selector: 'span' }),
).not.toBeInTheDocument();
});
});

View File

@@ -1,11 +1,11 @@
.banner {
display: flex;
justify-content: space-between;
align-items: center;
align-items: flex-start;
padding: var(--padding-4);
border-radius: 4px;
border: 1px solid var(--callout-error-border);
background-color: var(--callout-error-background);
border: 1px solid var(--l1-border);
background-color: var(--l2-background);
margin: var(--spacing-4) 0 var(--spacing-12);
}
@@ -15,21 +15,55 @@
gap: var(--spacing-2);
}
.titleRow {
display: flex;
align-items: center;
gap: var(--spacing-3);
}
.title {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
color: var(--callout-error-title);
font-size: var(--paragraph-base-500-font-size);
font-weight: var(--paragraph-base-500-font-weight);
line-height: var(--paragraph-base-500-line-height);
color: var(--l1-foreground);
}
.subtitle {
font-size: var(--paragraph-base-400-font-size);
font-weight: var(--paragraph-base-400-font-weight);
line-height: var(--paragraph-base-400-line-height);
color: var(--callout-error-icon);
color: var(--l2-foreground);
padding-left: 20px;
}
.dialogBody {
font-size: var(--font-size-sm);
line-height: var(--line-height-relaxed);
color: var(--l2-foreground);
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.dialogDescription {
font-size: var(--paragraph-base-400-font-size);
font-weight: var(--paragraph-base-400-font-weight);
line-height: var(--paragraph-base-400-line-height);
color: var(--l2-foreground);
margin: 0;
}
.dialogConfirmLabel {
font-size: var(--paragraph-base-400-font-size);
font-weight: var(--paragraph-base-400-font-weight);
line-height: var(--paragraph-base-400-line-height);
color: var(--l2-foreground);
margin: 0;
code {
font-family: var(--font-mono);
color: var(--l1-foreground);
}
}
.cancelButton {
background: var(--secondary-background);
border: 1px solid var(--l1-border);
}

View File

@@ -1,4 +1,4 @@
import { render, screen, userEvent } from 'tests/test-utils';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import CancelSubscriptionBanner from './CancelSubscriptionBanner';
@@ -13,14 +13,16 @@ describe('CancelSubscriptionBanner', () => {
it('renders banner with title and subtitle', () => {
render(<CancelSubscriptionBanner />);
expect(
screen.getByText('Cancel Subscription', { selector: 'span' }),
screen.getByText('Cancel your subscription', { selector: 'span' }),
).toBeInTheDocument();
expect(
screen.getByText('Cancel your SigNoz subscription.'),
screen.getByText(
/When you cancel your SigNoz subscription, all your data will be deleted/i,
),
).toBeInTheDocument();
});
it('opens dialog with correct content when Cancel Subscription is clicked', async () => {
it('opens dialog with content when Cancel Subscription is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<CancelSubscriptionBanner />);
@@ -30,17 +32,62 @@ describe('CancelSubscriptionBanner', () => {
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(
screen.getByText(/reach out to our support team/i),
screen.getByText(/Cancelling your subscription would stop your data/i),
).toBeInTheDocument();
expect(screen.getByText(/Type/i)).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /keep subscription/i }),
screen.getByPlaceholderText(/Enter the word cancel/i),
).toBeInTheDocument();
expect(screen.getByRole('button', { name: /go back/i })).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /contact support/i }),
screen.getByRole('button', { name: /cancel subscription/i }),
).toBeInTheDocument();
});
it('sends mailto to cloud-support with correct subject on Contact Support', async () => {
it('keeps Cancel subscription button disabled until "cancel" is typed', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<CancelSubscriptionBanner />);
await user.click(
screen.getByRole('button', { name: /cancel subscription/i }),
);
const confirmButton = screen.getByRole('button', {
name: /cancel subscription/i,
});
expect(confirmButton).toBeDisabled();
const input = screen.getByPlaceholderText(/Enter the word cancel/i);
await user.type(input, 'canc');
expect(confirmButton).toBeDisabled();
await user.type(input, 'el');
expect(confirmButton).toBeEnabled();
});
it('closes dialog and resets input when Go back is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<CancelSubscriptionBanner />);
await user.click(
screen.getByRole('button', { name: /cancel subscription/i }),
);
const input = screen.getByPlaceholderText(/Enter the word cancel/i);
await user.type(input, 'cancel');
await user.click(screen.getByRole('button', { name: /go back/i }));
await waitFor(() =>
expect(screen.queryByRole('dialog')).not.toBeInTheDocument(),
);
await user.click(
screen.getByRole('button', { name: /cancel subscription/i }),
);
expect(screen.getByPlaceholderText(/Enter the word cancel/i)).toHaveValue('');
});
it('sends mailto to cloud-support with correct subject after typing "cancel"', async () => {
const realCreateElement = document.createElement.bind(document);
const mockClick = jest.fn();
const mockAnchor = { href: '', click: mockClick };
@@ -57,7 +104,13 @@ describe('CancelSubscriptionBanner', () => {
await user.click(
screen.getByRole('button', { name: /cancel subscription/i }),
);
await user.click(screen.getByRole('button', { name: /contact support/i }));
const input = screen.getByPlaceholderText(/Enter the word cancel/i);
await user.type(input, 'cancel');
await user.click(
screen.getByRole('button', { name: /cancel subscription/i }),
);
expect(mockAnchor.href).toContain('mailto:cloud-support@signoz.io');
expect(mockAnchor.href).toContain('Cancel%20My%20SigNoz%20Subscription');

View File

@@ -1,15 +1,17 @@
import { useState } from 'react';
import { X } from '@signozhq/icons';
import { Button, DialogWrapper } from '@signozhq/ui';
import { SolidInfoCircle, Undo2, X } from '@signozhq/icons';
import { Button, DialogWrapper, Input } from '@signozhq/ui';
import logEvent from 'api/common/logEvent';
import { pick } from 'lodash-es';
import { useAppContext } from 'providers/App/App';
import { getBaseUrl } from 'utils/basePath';
import styles from './CancelSubscriptionBanner.module.scss';
import { Color } from '@signozhq/design-tokens';
function CancelSubscriptionBanner(): JSX.Element {
const [open, setOpen] = useState(false);
const [confirmText, setConfirmText] = useState('');
const { user, org } = useAppContext();
const handleOpenCancelDialog = (): void => {
@@ -53,6 +55,12 @@ function CancelSubscriptionBanner(): JSX.Element {
link.href = `mailto:cloud-support@signoz.io?subject=${subject}&body=${body}`;
link.click();
setOpen(false);
setConfirmText('');
};
const handleClose = (): void => {
setOpen(false);
setConfirmText('');
};
const footer = (
@@ -60,12 +68,19 @@ function CancelSubscriptionBanner(): JSX.Element {
<Button
variant="solid"
color="secondary"
onClick={(): void => setOpen(false)}
prefix={<Undo2 size={14} />}
onClick={handleClose}
>
Keep Subscription
Go back
</Button>
<Button variant="solid" color="destructive" onClick={handleContactSupport}>
Contact Support
<Button
variant="solid"
color="destructive"
prefix={<X size={14} />}
disabled={confirmText !== 'cancel'}
onClick={handleContactSupport}
>
Cancel subscription
</Button>
</>
);
@@ -74,30 +89,47 @@ function CancelSubscriptionBanner(): JSX.Element {
<>
<div className={styles.banner}>
<div className={styles.info}>
<span className={styles.title}>Cancel Subscription</span>
<span className={styles.subtitle}>Cancel your SigNoz subscription.</span>
<div className={styles.titleRow}>
<SolidInfoCircle color={Color.BG_SAKURA_500} size={12} />
<span className={styles.title}>Cancel your subscription</span>
</div>
<span className={styles.subtitle}>
When you cancel your SigNoz subscription, all your data will be deleted
immediately and removed from our servers.
</span>
</div>
<Button
variant="solid"
color="destructive"
color="secondary"
prefix={<X size={12} />}
onClick={handleOpenCancelDialog}
className={styles.cancelButton}
>
Cancel Subscription
</Button>
</div>
<DialogWrapper
open={open}
onOpenChange={setOpen}
title="Cancel your subscription"
onOpenChange={handleClose}
title="Cancel your subscription?"
width="narrow"
showCloseButton={false}
footer={footer}
>
<p className={styles.dialogBody}>
To cancel your SigNoz subscription, please reach out to our support team.
We&apos;ll be happy to assist you.
</p>
<div className={styles.dialogBody}>
<p className={styles.dialogDescription}>
Cancelling your subscription would stop your data from being ingested to
SigNoz. All the data that has been already sent will also be deleted.
</p>
<p className={styles.dialogConfirmLabel}>
Type <code>cancel</code> to confirm the cancellation.
</p>
<Input
placeholder="Enter the word cancel..."
value={confirmText}
onChange={(e): void => setConfirmText(e.target.value)}
/>
</div>
</DialogWrapper>
</>
);

View File

@@ -0,0 +1,190 @@
.overviewContent {
display: flex;
flex-direction: column;
gap: 16px;
}
.overviewSettings {
border-radius: 3px;
border: 1px solid var(--l1-border);
padding: 16px !important;
}
.crossPanelSyncGroup {
display: flex;
flex-direction: column;
gap: 16px;
}
.crossPanelSyncSectionTitle {
color: var(--l1-foreground);
font-family: Inter;
font-size: 14px;
font-weight: 500;
line-height: 20px;
}
.crossPanelSyncRow {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
gap: 16px;
& + & {
padding-top: 16px;
border-top: 1px solid var(--l1-border);
}
}
.crossPanelSyncInfo {
display: flex;
flex-direction: column;
gap: 4px;
}
.crossPanelSyncTitle {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-weight: 400;
line-height: 20px;
}
.crossPanelSyncDescription {
color: var(--l3-foreground);
font-family: Inter;
font-size: 13px;
font-weight: 400;
line-height: 20px;
}
.nameIconInput {
display: flex;
}
.dashboardImageInput {
:global(.ant-select-selector) {
display: flex;
width: 32px;
height: 32px;
padding: 6px;
justify-content: center;
align-items: center;
border-radius: 2px 0px 0px 2px;
border: 1px solid var(--l1-border) !important;
background: var(--l3-background) !important;
:global(.ant-select-selection-item) {
display: flex;
align-items: center;
}
}
&:global(.ant-select-dropdown) {
padding: 0px !important;
}
:global(.ant-select-item) {
padding: 0px;
align-items: center;
justify-content: center;
:global(.ant-select-item-option-content) {
display: flex;
align-items: center;
justify-content: center;
}
}
}
.listItemImage {
height: 16px;
width: 16px;
}
.dashboardNameInput {
border-radius: 0px 2px 2px 0px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
}
.dashboardName {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px;
margin-bottom: 0.5rem;
}
.descriptionTextArea {
padding: 6px 6px 6px 8px;
border-radius: 2px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
}
.overviewSettingsFooter {
display: flex;
justify-content: space-between;
align-items: center;
width: -webkit-fill-available;
padding: 12px 16px 12px 0px;
position: fixed;
bottom: 0;
height: 32px;
border-top: 1px solid var(--l1-border);
background: var(--l2-background);
}
.unsaved {
display: flex;
align-items: center;
gap: 8px;
}
.unsavedDot {
width: 6px;
height: 6px;
border-radius: 50px;
background: var(--primary-background);
box-shadow: 0px 0px 6px 0px
color-mix(in srgb, var(--primary-background) 40%, transparent);
}
.unsavedChanges {
color: var(--bg-robin-400);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 24px;
letter-spacing: -0.07px;
}
.footerActionBtns {
display: flex;
gap: 8px;
}
.discardBtn {
margin: '16px 0';
color: var(--l1-foreground);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 24px;
}
.saveBtn {
margin: 0px !important;
color: var(--l1-foreground);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 24px;
}

View File

@@ -1,143 +0,0 @@
.overview-content {
display: flex;
flex-direction: column;
.overview-settings {
border-radius: 3px;
border: 1px solid var(--l1-border);
padding: 16px !important;
.name-icon-input {
display: flex;
.dashboard-image-input {
.ant-select-selector {
display: flex;
width: 32px;
height: 32px;
padding: 6px;
justify-content: center;
align-items: center;
border-radius: 2px 0px 0px 2px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
.ant-select-selection-item {
display: flex;
align-items: center;
.list-item-image {
height: 16px;
width: 16px;
}
}
}
}
.dashboard-name-input {
border-radius: 0px 2px 2px 0px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
}
}
.dashboard-name {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
}
.description-text-area {
padding: 6px 6px 6px 8px;
border-radius: 2px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
}
}
.overview-settings-footer {
display: flex;
justify-content: space-between;
align-items: center;
width: -webkit-fill-available;
padding: 12px 16px 12px 0px;
position: fixed;
bottom: 0;
height: 32px;
border-top: 1px solid var(--l1-border);
background: var(--l2-background);
.unsaved {
display: flex;
align-items: center;
gap: 8px;
.unsaved-dot {
width: 6px;
height: 6px;
border-radius: 50px;
background: var(--primary-background);
box-shadow: 0px 0px 6px 0px
color-mix(in srgb, var(--primary-background) 40%, transparent);
}
.unsaved-changes {
color: var(--bg-robin-400);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 24px; /* 171.429% */
letter-spacing: -0.07px;
}
}
.footer-action-btns {
display: flex;
gap: 8px;
.discard-btn {
margin: '16px 0';
color: var(--l1-foreground);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 24px;
}
.save-btn {
margin: 0px !important;
color: var(--l1-foreground);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 24px;
}
}
}
}
.dashboard-image-input {
&.ant-select-dropdown {
padding: 0px !important;
}
.ant-select-item {
padding: 0px;
align-items: center;
justify-content: center;
.ant-select-item-option-content {
display: flex;
align-items: center;
justify-content: center;
.list-item-image {
height: 16px;
width: 16px;
}
}
}
}

View File

@@ -1,16 +1,24 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Col, Input, Select, Space, Typography } from 'antd';
import { Col, Input, Radio, Select, Space, Typography } from 'antd';
import AddTags from 'container/DashboardContainer/DashboardSettings/General/AddTags';
import { useDashboardCursorSyncMode } from 'hooks/dashboard/useDashboardCursorSyncMode';
import { useSyncTooltipFilterMode } from 'hooks/dashboard/useSyncTooltipFilterMode';
import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
import {
DashboardCursorSync,
SyncTooltipFilterMode,
} from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import { isEqual } from 'lodash-es';
import { Check, X } from 'lucide-react';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import styles from './GeneralSettings.module.scss';
import { Button } from './styles';
import { Base64Icons } from './utils';
import './GeneralSettings.styles.scss';
import logEvent from 'api/common/logEvent';
import { Events } from 'constants/events';
import { getAbsoluteUrl } from 'utils/basePath';
const { Option } = Select;
@@ -19,6 +27,13 @@ function GeneralDashboardSettings(): JSX.Element {
const updateDashboardMutation = useUpdateDashboard();
const [cursorSyncMode, setCursorSyncMode] = useDashboardCursorSyncMode(
dashboardData?.id,
);
const [syncTooltipFilterMode, setSyncTooltipFilterMode] =
useSyncTooltipFilterMode(dashboardData?.id);
const selectedData = dashboardData?.data;
const {
@@ -100,8 +115,8 @@ function GeneralDashboardSettings(): JSX.Element {
};
return (
<div className="overview-content">
<Col className="overview-settings">
<div className={styles.overviewContent}>
<Col className={styles.overviewSettings}>
<Space
direction="vertical"
style={{
@@ -112,27 +127,29 @@ function GeneralDashboardSettings(): JSX.Element {
}}
>
<div>
<Typography style={{ marginBottom: '0.5rem' }} className="dashboard-name">
Dashboard Name
</Typography>
<section className="name-icon-input">
<Typography className={styles.dashboardName}>Dashboard Name</Typography>
<section className={styles.nameIconInput}>
<Select
defaultActiveFirstOption
data-testid="dashboard-image"
suffixIcon={null}
rootClassName="dashboard-image-input"
rootClassName={styles.dashboardImageInput}
value={updatedImage}
onChange={(value: string): void => setUpdatedImage(value)}
>
{Base64Icons.map((icon) => (
<Option value={icon} key={icon}>
<img src={icon} alt="dashboard-icon" className="list-item-image" />
<img
src={icon}
alt="dashboard-icon"
className={styles.listItemImage}
/>
</Option>
))}
</Select>
<Input
data-testid="dashboard-name"
className="dashboard-name-input"
className={styles.dashboardNameInput}
value={updatedTitle}
onChange={(e): void => setUpdatedTitle(e.target.value)}
/>
@@ -140,41 +157,92 @@ function GeneralDashboardSettings(): JSX.Element {
</div>
<div>
<Typography style={{ marginBottom: '0.5rem' }} className="dashboard-name">
Description
</Typography>
<Typography className={styles.dashboardName}>Description</Typography>
<Input.TextArea
data-testid="dashboard-desc"
rows={6}
value={updatedDescription}
className="description-text-area"
className={styles.descriptionTextArea}
onChange={(e): void => setUpdatedDescription(e.target.value)}
/>
</div>
<div>
<Typography style={{ marginBottom: '0.5rem' }} className="dashboard-name">
Tags
</Typography>
<Typography className={styles.dashboardName}>Tags</Typography>
<AddTags tags={updatedTags} setTags={setUpdatedTags} />
</div>
</Space>
</Col>
<Col className={`${styles.overviewSettings} ${styles.crossPanelSyncGroup}`}>
<Typography.Text className={styles.crossPanelSyncSectionTitle}>
Cross-Panel Sync
</Typography.Text>
<div className={styles.crossPanelSyncRow}>
<div className={styles.crossPanelSyncInfo}>
<Typography.Text className={styles.crossPanelSyncTitle}>
Sync Mode
</Typography.Text>
<Typography.Text className={styles.crossPanelSyncDescription}>
Sync crosshair and tooltip across all the dashboard panels
</Typography.Text>
</div>
<Radio.Group
value={cursorSyncMode}
onChange={(e): void => {
setCursorSyncMode(e.target.value as DashboardCursorSync);
}}
>
<Radio.Button value={DashboardCursorSync.None}>No Sync</Radio.Button>
<Radio.Button value={DashboardCursorSync.Crosshair}>
Crosshair
</Radio.Button>
<Radio.Button value={DashboardCursorSync.Tooltip}>Tooltip</Radio.Button>
</Radio.Group>
</div>
{cursorSyncMode === DashboardCursorSync.Tooltip && (
<div className={styles.crossPanelSyncRow}>
<div className={styles.crossPanelSyncInfo}>
<Typography.Text className={styles.crossPanelSyncTitle}>
Synced Tooltip Series
</Typography.Text>
<Typography.Text className={styles.crossPanelSyncDescription}>
Show only series that intersect on group-by, or every series with the
matching ones highlighted
</Typography.Text>
</div>
<Radio.Group
value={syncTooltipFilterMode}
onChange={(e): void => {
logEvent(Events.TOOLTIP_SYNC_MODE_CHANGED, {
path: getAbsoluteUrl(window.location.pathname),
mode: e.target.value,
});
setSyncTooltipFilterMode(e.target.value as SyncTooltipFilterMode);
}}
>
<Radio.Button value={SyncTooltipFilterMode.All}>All</Radio.Button>
<Radio.Button value={SyncTooltipFilterMode.Filtered}>
Filtered
</Radio.Button>
</Radio.Group>
</div>
)}
</Col>
{numberOfUnsavedChanges > 0 && (
<div className="overview-settings-footer">
<div className="unsaved">
<div className="unsaved-dot" />
<Typography.Text className="unsaved-changes">
<div className={styles.overviewSettingsFooter}>
<div className={styles.unsaved}>
<div className={styles.unsavedDot} />
<Typography.Text className={styles.unsavedChanges}>
{numberOfUnsavedChanges} unsaved change
{numberOfUnsavedChanges > 1 && 's'}
</Typography.Text>
</div>
<div className="footer-action-btns">
<div className={styles.footerActionBtns}>
<Button
disabled={updateDashboardMutation.isLoading}
icon={<X size={14} />}
onClick={discardHandler}
type="text"
className="discard-btn"
className={styles.discardBtn}
>
Discard
</Button>
@@ -188,7 +256,7 @@ function GeneralDashboardSettings(): JSX.Element {
data-testid="save-dashboard-config"
onClick={onSaveHandler}
type="primary"
className="save-btn"
className={styles.saveBtn}
>
{t('save')}
</Button>

View File

@@ -33,11 +33,13 @@ export default function BarChart(props: BarChartProps): JSX.Element {
}
const tooltipProps: BarTooltipProps = {
...props,
id: config.getId(),
timezone: rest.timezone,
yAxisUnit: rest.yAxisUnit,
decimalPrecision: rest.decimalPrecision,
isStackedBarChart: isStackedBarChart,
canPinTooltip: rest.canPinTooltip,
renderTooltipFooter: rest.renderTooltipFooter,
};
return <BarChartTooltip {...tooltipProps} />;
},
@@ -48,6 +50,7 @@ export default function BarChart(props: BarChartProps): JSX.Element {
rest.decimalPrecision,
isStackedBarChart,
rest.canPinTooltip,
rest.renderTooltipFooter,
],
);

View File

@@ -29,11 +29,12 @@ export default function ChartWrapper({
onClick,
syncMode,
syncKey,
syncFilterMode,
onDestroy = noop,
children,
layoutChildren,
yAxisUnit,
groupBy,
groupByPerQuery,
customTooltip,
pinnedTooltipElement,
'data-testid': testId,
@@ -69,9 +70,10 @@ export default function ChartWrapper({
const syncMetadata = useMemo(
() => ({
yAxisUnit,
groupBy,
groupByPerQuery,
filterMode: syncFilterMode,
}),
[yAxisUnit, groupBy],
[yAxisUnit, groupByPerQuery, syncFilterMode],
);
return (

View File

@@ -24,13 +24,21 @@ export default function Histogram(props: HistogramChartProps): JSX.Element {
}
const tooltipProps: HistogramTooltipProps = {
...props,
id: rest.config.getId(),
yAxisUnit: rest.yAxisUnit,
decimalPrecision: rest.decimalPrecision,
canPinTooltip: rest.canPinTooltip,
renderTooltipFooter: rest.renderTooltipFooter,
};
return <HistogramTooltip {...tooltipProps} />;
},
[customTooltip, rest.yAxisUnit, rest.decimalPrecision, rest.canPinTooltip],
[
customTooltip,
rest.yAxisUnit,
rest.decimalPrecision,
rest.canPinTooltip,
rest.renderTooltipFooter,
],
);
return (

View File

@@ -9,7 +9,7 @@ import {
import { TimeSeriesChartProps } from '../types';
export default function TimeSeries(props: TimeSeriesChartProps): JSX.Element {
const { children, customTooltip, pinnedTooltipElement, ...rest } = props;
const { children, customTooltip, ...rest } = props;
const renderTooltip = useCallback(
(props: TooltipRenderArgs): React.ReactNode => {
@@ -18,10 +18,12 @@ export default function TimeSeries(props: TimeSeriesChartProps): JSX.Element {
}
const tooltipProps: TimeSeriesTooltipProps = {
...props,
id: rest.config.getId(),
timezone: rest.timezone,
yAxisUnit: rest.yAxisUnit,
decimalPrecision: rest.decimalPrecision,
canPinTooltip: rest.canPinTooltip,
renderTooltipFooter: rest.renderTooltipFooter,
};
return <TimeSeriesTooltip {...tooltipProps} />;
},
@@ -31,15 +33,12 @@ export default function TimeSeries(props: TimeSeriesChartProps): JSX.Element {
rest.yAxisUnit,
rest.decimalPrecision,
rest.canPinTooltip,
rest.renderTooltipFooter,
],
);
return (
<ChartWrapper
{...rest}
customTooltip={renderTooltip}
pinnedTooltipElement={pinnedTooltipElement}
>
<ChartWrapper {...rest} customTooltip={renderTooltip}>
{children}
</ChartWrapper>
);

View File

@@ -1,9 +1,14 @@
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PrecisionOption } from 'components/Graph/types';
import { LegendConfig, TooltipRenderArgs } from 'lib/uPlotV2/components/types';
import {
IRenderTooltipFooterArgs,
LegendConfig,
TooltipRenderArgs,
} from 'lib/uPlotV2/components/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import {
DashboardCursorSync,
SyncTooltipFilterMode,
TooltipClickData,
} from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
@@ -21,6 +26,7 @@ interface BaseChartProps {
yAxisUnit?: string;
decimalPrecision?: PrecisionOption;
pinnedTooltipElement?: (clickData: TooltipClickData) => React.ReactNode;
renderTooltipFooter?: (args: IRenderTooltipFooterArgs) => React.ReactNode;
customTooltip?: (props: TooltipRenderArgs) => React.ReactNode;
'data-testid'?: string;
}
@@ -30,6 +36,7 @@ interface UPlotBasedChartProps {
legendConfig: LegendConfig;
syncMode?: DashboardCursorSync;
syncKey?: string;
syncFilterMode?: SyncTooltipFilterMode;
plotRef?: (plot: uPlot | null) => void;
onDestroy?: (plot: uPlot) => void;
children?: React.ReactNode;
@@ -39,7 +46,7 @@ interface UPlotBasedChartProps {
interface UPlotChartDataProps {
yAxisUnit?: string;
decimalPrecision?: PrecisionOption;
groupBy?: BaseAutocompleteData[];
groupByPerQuery?: Record<string, BaseAutocompleteData[]>;
}
export interface TimeSeriesChartProps

View File

@@ -1,9 +1,15 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { PanelWrapperProps } from 'container/PanelWrapper/panelWrapper.types';
import { useDashboardCursorSyncMode } from 'hooks/dashboard/useDashboardCursorSyncMode';
import { useSyncTooltipFilterMode } from 'hooks/dashboard/useSyncTooltipFilterMode';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import { LegendPosition } from 'lib/uPlotV2/components/types';
import {
IRenderTooltipFooterArgs,
LegendPosition,
} from 'lib/uPlotV2/components/types';
import ContextMenu from 'periscope/components/ContextMenu';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import { useTimezone } from 'providers/Timezone';
import uPlot from 'uplot';
import { getTimeRange } from 'utils/getTimeRange';
@@ -14,7 +20,7 @@ import { usePanelContextMenu } from '../../hooks/usePanelContextMenu';
import { prepareBarPanelConfig, prepareBarPanelData } from './utils';
import '../Panel.styles.scss';
import get from 'lodash/get';
import TooltipFooter from '../components/TooltipFooter';
function BarPanel(props: PanelWrapperProps): JSX.Element {
const {
@@ -24,6 +30,7 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
onDragSelect,
isFullViewMode,
onToggleModelHandler,
groupByPerQuery,
} = props;
const uPlotRef = useRef<uPlot | null>(null);
const graphRef = useRef<HTMLDivElement>(null);
@@ -34,6 +41,10 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
const isDarkMode = useIsDarkMode();
const { timezone } = useTimezone();
const dashboardId = useDashboardStore((s) => s.dashboardData?.id);
const [syncMode] = useDashboardCursorSyncMode(dashboardId, panelMode);
const [syncFilterMode] = useSyncTooltipFilterMode(dashboardId);
useEffect((): void => {
const { startTime, endTime } = getTimeRange(queryResponse);
@@ -75,6 +86,11 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
maxTimeScale,
timezone,
panelMode,
// `config` gets mutated by TooltipPlugin (config.setCursor for cursor sync).
// Rebuild it on syncMode changes so the new chart instance starts from a
// clean config — otherwise switching to "No Sync" would inherit stale sync
// settings from the previous mode.
syncMode,
]);
const chartData = useMemo(() => {
@@ -114,14 +130,20 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
uPlotRef.current = plot;
}, []);
const groupBy = useMemo(() => {
return get(widget, 'query.builder.queryData[0].groupBy', []);
}, [widget.query]);
const renderTooltipFooter = useCallback(
({ isPinned, dismiss }: IRenderTooltipFooterArgs) => {
return (
<TooltipFooter id={widget.id} isPinned={isPinned} dismiss={dismiss} />
);
},
[],
);
return (
<div className="panel-container" ref={graphRef}>
{containerDimensions.width > 0 && containerDimensions.height > 0 && (
<BarChart
key={`${syncMode}-${syncFilterMode}`}
config={config}
legendConfig={{
position: widget?.legendPosition ?? LegendPosition.BOTTOM,
@@ -133,11 +155,14 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
width={containerDimensions.width}
height={containerDimensions.height}
layoutChildren={layoutChildren}
groupBy={groupBy}
groupByPerQuery={groupByPerQuery}
isStackedBarChart={widget.stackedBarChart ?? false}
yAxisUnit={widget.yAxisUnit}
decimalPrecision={widget.decimalPrecision}
timezone={timezone}
syncMode={syncMode}
syncFilterMode={syncFilterMode}
renderTooltipFooter={renderTooltipFooter}
>
<ContextMenu
coordinates={coordinates}

View File

@@ -1,8 +1,11 @@
import { useMemo, useRef } from 'react';
import { useCallback, useMemo, useRef } from 'react';
import { PanelWrapperProps } from 'container/PanelWrapper/panelWrapper.types';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import { LegendPosition } from 'lib/uPlotV2/components/types';
import {
IRenderTooltipFooterArgs,
LegendPosition,
} from 'lib/uPlotV2/components/types';
import uPlot from 'uplot';
import Histogram from '../../charts/Histogram/Histogram';
@@ -13,6 +16,7 @@ import {
} from './utils';
import '../Panel.styles.scss';
import TooltipFooter from '../components/TooltipFooter';
function HistogramPanel(props: PanelWrapperProps): JSX.Element {
const {
@@ -75,6 +79,20 @@ function HistogramPanel(props: PanelWrapperProps): JSX.Element {
widget.mergeAllActiveQueries,
]);
const renderTooltipFooter = useCallback(
({ isPinned, dismiss }: IRenderTooltipFooterArgs) => {
return (
<TooltipFooter
id={widget.id}
isPinned={isPinned}
dismiss={dismiss}
canDrilldown={false}
/>
);
},
[],
);
return (
<div className="panel-container" ref={graphRef}>
{containerDimensions.width > 0 && containerDimensions.height > 0 && (
@@ -97,6 +115,7 @@ function HistogramPanel(props: PanelWrapperProps): JSX.Element {
width={containerDimensions.width}
height={containerDimensions.height}
layoutChildren={layoutChildren}
renderTooltipFooter={renderTooltipFooter}
/>
)}
</div>

View File

@@ -1,20 +1,26 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import TimeSeries from 'container/DashboardContainer/visualization/charts/TimeSeries/TimeSeries';
import ChartManager from 'container/DashboardContainer/visualization/components/ChartManager/ChartManager';
import { usePanelContextMenu } from 'container/DashboardContainer/visualization/hooks/usePanelContextMenu';
import { PanelWrapperProps } from 'container/PanelWrapper/panelWrapper.types';
import { useDashboardCursorSyncMode } from 'hooks/dashboard/useDashboardCursorSyncMode';
import { useSyncTooltipFilterMode } from 'hooks/dashboard/useSyncTooltipFilterMode';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import { LegendPosition } from 'lib/uPlotV2/components/types';
import {
IRenderTooltipFooterArgs,
LegendPosition,
} from 'lib/uPlotV2/components/types';
import { ContextMenu } from 'periscope/components/ContextMenu';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import { useTimezone } from 'providers/Timezone';
import uPlot from 'uplot';
import { getTimeRange } from 'utils/getTimeRange';
import get from 'lodash/get';
import { prepareChartData, prepareUPlotConfig } from '../TimeSeriesPanel/utils';
import '../Panel.styles.scss';
import TooltipFooter from '../components/TooltipFooter';
function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
const {
@@ -24,6 +30,7 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
onDragSelect,
isFullViewMode,
onToggleModelHandler,
groupByPerQuery,
} = props;
const graphRef = useRef<HTMLDivElement>(null);
const [minTimeScale, setMinTimeScale] = useState<number>();
@@ -33,6 +40,10 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
const isDarkMode = useIsDarkMode();
const { timezone } = useTimezone();
const dashboardId = useDashboardStore((s) => s.dashboardData?.id);
const [syncMode] = useDashboardCursorSyncMode(dashboardId, panelMode);
const [syncFilterMode] = useSyncTooltipFilterMode(dashboardId);
useEffect((): void => {
const { startTime, endTime } = getTimeRange(queryResponse);
@@ -81,6 +92,11 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
minTimeScale,
maxTimeScale,
timezone,
// `config` gets mutated by TooltipPlugin (config.setCursor for cursor sync).
// Rebuild it on syncMode changes so the new chart instance starts from a
// clean config — otherwise switching to "No Sync" would inherit stale sync
// settings from the previous mode.
syncMode,
]);
const layoutChildren = useMemo(() => {
@@ -105,14 +121,20 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
widget.decimalPrecision,
]);
const groupBy = useMemo(() => {
return get(widget, 'query.builder.queryData[0].groupBy', []);
}, [widget.query]);
const renderTooltipFooter = useCallback(
({ isPinned, dismiss }: IRenderTooltipFooterArgs) => {
return (
<TooltipFooter id={widget.id} isPinned={isPinned} dismiss={dismiss} />
);
},
[],
);
return (
<div className="panel-container" ref={graphRef}>
{containerDimensions.width > 0 && containerDimensions.height > 0 && (
<TimeSeries
key={`${syncMode}-${syncFilterMode}`}
config={config}
legendConfig={{
position: widget?.legendPosition ?? LegendPosition.BOTTOM,
@@ -122,10 +144,13 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
yAxisUnit={widget.yAxisUnit}
decimalPrecision={widget.decimalPrecision}
data={chartData as uPlot.AlignedData}
groupBy={groupBy}
groupByPerQuery={groupByPerQuery}
width={containerDimensions.width}
height={containerDimensions.height}
syncMode={syncMode}
syncFilterMode={syncFilterMode}
layoutChildren={layoutChildren}
renderTooltipFooter={renderTooltipFooter}
>
<ContextMenu
coordinates={coordinates}

View File

@@ -7,22 +7,25 @@ import Styles from './TooltipFooter.module.scss';
import { MousePointerClick } from '@signozhq/icons';
import logEvent from 'api/common/logEvent';
import { Events } from 'constants/events';
import { getAbsoluteUrl } from 'utils/basePath';
interface TooltipFooterProps {
id: string;
pinKey?: string;
isPinned: boolean;
canDrilldown?: boolean;
dismiss: () => void;
}
export default function TooltipFooter({
id,
pinKey = DEFAULT_PIN_TOOLTIP_KEY,
isPinned,
canDrilldown = true,
dismiss,
}: TooltipFooterProps): JSX.Element {
const handleUnpinClick = (): void => {
logEvent(Events.TOOLTIP_UNPINNED, {
path: getAbsoluteUrl(window.location.pathname),
id: id,
});
dismiss();
};
@@ -43,12 +46,14 @@ export default function TooltipFooter({
</div>
) : (
<div className={Styles.hintList}>
<div className={Styles.hint} data-active="false">
<Kbd>
<MousePointerClick size={12} />
</Kbd>
<span>Click to drilldown</span>
</div>
{canDrilldown && (
<div className={Styles.hint} data-active="false">
<Kbd>
<MousePointerClick size={12} />
</Kbd>
<span>Click to drilldown</span>
</div>
)}
<div className={Styles.hint} data-active="false">
<span>Press</span>
<Kbd>{pinKey.toUpperCase()}</Kbd>

View File

@@ -0,0 +1,93 @@
import { Events } from 'constants/events';
import { DEFAULT_PIN_TOOLTIP_KEY } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import { render, screen, userEvent } from 'tests/test-utils';
import TooltipFooter from '../TooltipFooter';
const mockLogEvent = jest.fn();
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: (...args: unknown[]): unknown => mockLogEvent(...args),
}));
describe('TooltipFooter', () => {
const defaultProps = {
id: 'panel-123',
isPinned: false,
dismiss: jest.fn(),
};
describe('when not pinned', () => {
it('renders the drilldown and pin hints by default', () => {
render(<TooltipFooter {...defaultProps} />);
expect(screen.getByText('Click to drilldown')).toBeInTheDocument();
expect(screen.getByText('to pin the tooltip')).toBeInTheDocument();
expect(
screen.getByText(DEFAULT_PIN_TOOLTIP_KEY.toUpperCase()),
).toBeInTheDocument();
});
it('hides the drilldown hint when canDrilldown is false', () => {
render(<TooltipFooter {...defaultProps} canDrilldown={false} />);
expect(screen.queryByText('Click to drilldown')).not.toBeInTheDocument();
expect(screen.getByText('to pin the tooltip')).toBeInTheDocument();
});
it('renders a custom pin key in uppercase', () => {
render(<TooltipFooter {...defaultProps} pinKey="x" />);
expect(screen.getByText('X')).toBeInTheDocument();
});
it('does not render the unpin button', () => {
render(<TooltipFooter {...defaultProps} />);
expect(screen.queryByTestId('uplot-tooltip-unpin')).not.toBeInTheDocument();
});
});
describe('when pinned', () => {
it('renders the unpin hint with pin key and Esc', () => {
render(<TooltipFooter {...defaultProps} isPinned />);
expect(screen.getByText('to unpin')).toBeInTheDocument();
expect(
screen.getByText(DEFAULT_PIN_TOOLTIP_KEY.toUpperCase()),
).toBeInTheDocument();
expect(screen.getByText('Esc')).toBeInTheDocument();
});
it('renders the unpin button', () => {
render(<TooltipFooter {...defaultProps} isPinned />);
expect(screen.getByTestId('uplot-tooltip-unpin')).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /unpin tooltip/i }),
).toBeInTheDocument();
});
it('hides the drilldown and pin-instruction hints', () => {
render(<TooltipFooter {...defaultProps} isPinned />);
expect(screen.queryByText('Click to drilldown')).not.toBeInTheDocument();
expect(screen.queryByText('to pin the tooltip')).not.toBeInTheDocument();
});
it('calls dismiss and logs the unpin event when the unpin button is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const dismiss = jest.fn();
render(<TooltipFooter {...defaultProps} dismiss={dismiss} isPinned />);
await user.click(screen.getByTestId('uplot-tooltip-unpin'));
expect(mockLogEvent).toHaveBeenCalledWith(Events.TOOLTIP_UNPINNED, {
id: 'panel-123',
});
expect(dismiss).toHaveBeenCalledTimes(1);
});
});
});

View File

@@ -123,7 +123,6 @@
&__row {
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: flex-end;
max-width: 825px;
gap: 25px;

View File

@@ -3,7 +3,7 @@ import React, { useCallback, useEffect, useState } from 'react';
import { useMutation, useQuery } from 'react-query';
import { Color } from '@signozhq/design-tokens';
import { Compass, Dot, House, Plus, Wrench } from '@signozhq/icons';
import { Button, PersistedAnnouncementBanner } from '@signozhq/ui';
import { Button } from '@signozhq/ui';
import { Popover } from 'antd';
import logEvent from 'api/common/logEvent';
import { useGetMetricsOnboardingStatus } from 'api/generated/services/metrics';
@@ -11,7 +11,6 @@ import listUserPreferences from 'api/v1/user/preferences/list';
import updateUserPreferenceAPI from 'api/v1/user/preferences/name/update';
import Header from 'components/Header/Header';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { LOCALSTORAGE } from 'constants/localStorage';
import { ORG_PREFERENCES } from 'constants/orgPreferences';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
@@ -271,23 +270,6 @@ export default function Home(): JSX.Element {
return (
<div className="home-container">
{user?.role === USER_ROLES.ADMIN && (
<PersistedAnnouncementBanner
type="info"
storageKey={LOCALSTORAGE.DISMISSED_API_KEYS_DEPRECATION_BANNER}
action={{
label: 'Go to Service Accounts',
onClick: (): void => history.push(ROUTES.SERVICE_ACCOUNTS_SETTINGS),
}}
>
<>
<strong>API keys</strong> have been deprecated in favour of{' '}
<strong>Service accounts</strong>. The existing API Keys have been
migrated to service accounts.
</>
</PersistedAnnouncementBanner>
)}
<div className="sticky-header">
<Header
leftComponent={

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 {
useInfraMonitoringCurrentPage,
useInfraMonitoringFilters,
useInfraMonitoringFiltersK8s,
useInfraMonitoringPageListing,
} 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 {
hostColumns,
getHostItemKey,
getHostRowKey,
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] = useInfraMonitoringCurrentPage();
const [urlFilters, setUrlFilters] = useInfraMonitoringFilters();
const [, setCurrentPage] = useInfraMonitoringPageListing();
const [urlFilters, setUrlFilters] = useInfraMonitoringFiltersK8s();
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}
renderRowData={hostRenderRowData}
getRowKey={getHostRowKey}
getItemKey={getHostItemKey}
eventCategory={InfraMonitoringEvents.HostEntity}
/>
</div>

View File

@@ -1,190 +0,0 @@
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

@@ -1,143 +0,0 @@
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,160 +1,22 @@
import React from 'react';
import { InfoCircleOutlined } from '@ant-design/icons';
import { Progress, TableColumnType as ColumnType, Tag, Tooltip } from 'antd';
import { Tag, Tooltip } from 'antd';
import { HostData } from 'api/infraMonitoring/getHostLists';
import { K8sRenderedRowData } from 'container/InfraMonitoringK8s/Base/types';
import { IEntityColumn } from 'container/InfraMonitoringK8s/Base/useInfraMonitoringTableColumnsStore';
import TanStackTable, { TableColumnDef } from 'components/TanStackTableView';
import { getGroupByEl } from 'container/InfraMonitoringK8s/Base/utils';
import {
getGroupByEl,
getGroupedByMeta,
getRowKey,
} from 'container/InfraMonitoringK8s/Base/utils';
import { ValidateColumnValueWrapper } from 'container/InfraMonitoringK8s/commonUtils';
EntityProgressBar,
ExpandButtonWrapper,
ValidateColumnValueWrapper,
} from 'container/InfraMonitoringK8s/components';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8s/constants';
import { Group } from 'lucide-react';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { useInfraMonitoringGroupBy } from 'container/InfraMonitoringK8s/hooks';
import EntityGroupHeader from 'container/InfraMonitoringK8s/Base/EntityGroupHeader';
import { getMemoryProgressColor, getProgressColor } from './constants';
import { HostnameCell } from './utils';
import styles from './table.module.scss';
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',
},
];
import { Container } from 'lucide-react';
function hostRowSource(host: HostData): { meta: Record<string, string> } {
return {
@@ -168,67 +30,154 @@ function hostRowSource(host: HostData): { meta: Record<string, string> } {
};
}
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 getHostRowKey(host: HostData): string {
return host.hostName || 'unknown';
}
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>
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>
),
cpu: (
<div className={styles.progressContainer}>
<ValidateColumnValueWrapper
value={host.cpu}
entity={InfraMonitoringEntity.HOSTS}
>
<Progress
percent={cpuPercent}
strokeLinecap="butt"
size="small"
strokeColor={getProgressColor(cpuPercent)}
className={styles.progressBar}
/>
</ValidateColumnValueWrapper>
},
{
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>
),
memory: (
<div className={styles.progressContainer}>
<ValidateColumnValueWrapper
value={host.memory}
entity={InfraMonitoringEntity.HOSTS}
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
}`}
>
<Progress
percent={memoryPercent}
strokeLinecap="butt"
size="small"
strokeColor={getMemoryProgressColor(memoryPercent)}
className={styles.progressBar}
/>
</ValidateColumnValueWrapper>
{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>
</div>
),
wait: `${Number((host.wait * 100).toFixed(1))}%`,
load15: host.load15,
};
};
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>
),
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>
),
},
];

View File

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

View File

@@ -0,0 +1,21 @@
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

@@ -1,175 +1,40 @@
.clickableRow {
cursor: pointer;
.emptyStateContainer {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
min-height: 400px;
}
.k8SListTable {
--k8s-base-list-pagination-offset: 64px;
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;
display: flex;
flex-direction: column;
--tanstack-cell-padding-top-override: 5px;
--tanstack-cell-padding-bottom-override: 5px;
--tanstack-cell-padding-left-override: 5px;
--tanstack-cell-padding-right-override: 5px;
:global(.ant-spin-nested-loading) {
flex: 1;
display: flex;
flex-direction: column;
}
--tanstack-expansion-first-col-padding-left: 30px;
: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%);
}
}
&[data-has-group-by='false'] {
--tanstack-cell-padding-left-first-column: 28px;
}
}
.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;
.paginationContainer {
padding-bottom: var(--spacing-8);
padding-right: var(--spacing-8);
: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;
}
ul {
margin: 0;
}
}

View File

@@ -1,45 +1,36 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo } from 'react';
import { useQuery } from 'react-query';
// 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 { Typography } from 'antd';
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 { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { buildAbsolutePath, isModifierKeyPressed } from 'utils/app';
import { openInNewTab } from 'utils/navigation';
import { InfraMonitoringEntity } from '../constants';
import {
useInfraMonitoringCurrentPage,
useInfraMonitoringFilters,
INFRA_MONITORING_K8S_PARAMS_KEYS,
InfraMonitoringEntity,
} from '../constants';
import {
useInfraMonitoringFiltersK8s,
useInfraMonitoringGroupBy,
useInfraMonitoringOrderBy,
useInfraMonitoringPageListing,
useInfraMonitoringPageSizeListing,
} from '../hooks';
import { usePageSize } from '../utils';
import { K8sEmptyState } from './K8sEmptyState';
import { K8sExpandedRow } from './K8sExpandedRow';
import K8sHeader from './K8sHeader';
import { K8sBaseFilters, K8sRenderedRowData } from './types';
import {
IEntityColumn,
useInfraMonitoringTableColumnsForPage,
useInfraMonitoringTableColumnsStore,
} from './useInfraMonitoringTableColumnsStore';
import { K8sBaseFilters } from './types';
import { getGroupedByMeta } from './utils';
import styles from './K8sBaseList.module.scss';
import cx from 'classnames';
export type K8sBaseListEmptyStateContext = {
isError: boolean;
@@ -50,11 +41,13 @@ export type K8sBaseListEmptyStateContext = {
rawData?: unknown;
};
export type K8sBaseListProps<T = unknown> = {
/** Base type constraint for K8s entity data */
export type K8sEntityData = { meta?: Record<string, string> };
export type K8sBaseListProps<T extends K8sEntityData> = {
controlListPrefix?: React.ReactNode;
entity: InfraMonitoringEntity;
tableColumnsDefinitions: IEntityColumn[];
tableColumns: ColumnType<K8sRenderedRowData>[];
tableColumns: TableColumnDef<T>[];
fetchListData: (
filters: K8sBaseFilters,
signal?: AbortSignal,
@@ -64,48 +57,38 @@ export type K8sBaseListProps<T = unknown> = {
error?: string | null;
rawData?: unknown;
}>;
renderRowData: (
record: T,
groupBy: BaseAutocompleteData[],
) => K8sRenderedRowData;
/** 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;
eventCategory: InfraMonitoringEvents;
renderEmptyState?: (
context: K8sBaseListEmptyStateContext,
) => React.ReactNode | null;
};
export function K8sBaseList<T>({
export function K8sBaseList<T extends K8sEntityData>({
controlListPrefix,
entity,
tableColumnsDefinitions,
tableColumns,
fetchListData,
renderRowData,
getRowKey,
getItemKey,
eventCategory,
renderEmptyState,
}: K8sBaseListProps<T>): JSX.Element {
const [queryFilters] = useInfraMonitoringFilters();
const [currentPage, setCurrentPage] = useInfraMonitoringCurrentPage();
const [queryFilters] = useInfraMonitoringFiltersK8s();
const [currentPage] = useInfraMonitoringPageListing();
const [currentPageSize] = useInfraMonitoringPageSizeListing();
const [groupBy] = useInfraMonitoringGroupBy();
const [orderBy, setOrderBy] = useInfraMonitoringOrderBy();
const [initialOrderBy] = useState(orderBy);
const [orderBy] = useInfraMonitoringOrderBy();
const [selectedItem, setSelectedItem] = useQueryState(
'selectedItem',
parseAsString,
);
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 columnStorageKey = `k8s-${entity}-columns`;
const hiddenColumnIds = useHiddenColumnIds(columnStorageKey);
const selectedTime = useGlobalTimeStore((s) => s.selectedTime);
const refreshInterval = useGlobalTimeStore((s) => s.refreshInterval);
@@ -120,7 +103,7 @@ export function K8sBaseList<T>({
selectedTime,
'k8sBaseList',
entity,
String(pageSize),
String(currentPageSize),
String(currentPage),
JSON.stringify(queryFilters),
JSON.stringify(orderBy),
@@ -130,7 +113,7 @@ export function K8sBaseList<T>({
getAutoRefreshQueryKey,
selectedTime,
entity,
pageSize,
currentPageSize,
currentPage,
queryFilters,
orderBy,
@@ -144,8 +127,8 @@ export function K8sBaseList<T>({
return fetchListData(
{
limit: pageSize,
offset: (currentPage - 1) * pageSize,
limit: currentPageSize,
offset: (currentPage - 1) * currentPageSize,
filters: queryFilters || { items: [], op: 'AND' },
start: Math.floor(minTime / NANO_SECOND_MULTIPLIER),
end: Math.floor(maxTime / NANO_SECOND_MULTIPLIER),
@@ -158,62 +141,14 @@ export function K8sBaseList<T>({
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 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],
);
const getGroupKeyFn = useCallback(
(item: T) => getGroupedByMeta(item, groupBy),
[groupBy],
);
useEffect(() => {
logEvent(InfraMonitoringEvents.PageVisited, {
@@ -224,214 +159,137 @@ export function K8sBaseList<T>({
});
}, [eventCategory, totalCount]);
const handleGroupByRowClick = (record: K8sRenderedRowData): void => {
if (expandedRowKeys.includes(record.key)) {
setExpandedRowKeys(expandedRowKeys.filter((key) => key !== record.key));
} else {
setExpandedRowKeys([record.key]);
}
};
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 mapDefaultSort = useCallback(
(
tableColumn: ColumnType<K8sRenderedRowData>,
): ColumnType<K8sRenderedRowData> => {
if (tableColumn.key === initialOrderBy?.columnName) {
return {
...tableColumn,
defaultSortOrder: initialOrderBy?.order === 'asc' ? 'ascend' : 'descend',
};
const handleRowClick = useCallback(
(_record: T, itemKey: string): void => {
if (groupBy.length === 0) {
setSelectedItem(itemKey);
}
return tableColumn;
logEvent(InfraMonitoringEvents.ItemClicked, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.ListPage,
category: eventCategory,
});
},
[initialOrderBy?.columnName, initialOrderBy?.order],
[eventCategory, groupBy.length, setSelectedItem],
);
const columns = useMemo(
() =>
tableColumns
.filter(
(c) =>
!hiddenColumnIdsOnList.includes(c.key?.toString() || '') &&
!columnsHidden.includes(c.key?.toString() || ''),
)
.map(mapDefaultSort),
[columnsHidden, hiddenColumnIdsOnList, mapDefaultSort, tableColumns],
const handleRowClickNewTab = useCallback(
(_record: T, itemKey: string): void => {
if (groupBy.length > 0) {
return;
}
// 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,
});
},
[eventCategory, groupBy.length],
);
const isGroupedByAttribute = groupBy.length > 0;
const expandedRowRender = (record: K8sRenderedRowData): JSX.Element => (
<K8sExpandedRow<T>
record={record}
entity={entity}
tableColumns={tableColumns}
fetchListData={fetchListData}
renderRowData={renderRowData}
/>
// Filter columns for expanded row based on parent's hidden columns
const expandedRowColumns = useMemo(
() => tableColumns.filter((col) => !hiddenColumnIds.includes(col.id)),
[tableColumns, hiddenColumnIds],
);
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 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],
);
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 getRowCanExpand = useCallback(
(): boolean => isGroupedByAttribute,
[isGroupedByAttribute],
);
const showTableLoadingState = isLoading;
const emptyStateContext: K8sBaseListEmptyStateContext = {
isError: isError || !!data?.error,
const emptyTableMessage: React.ReactNode = renderEmptyState?.({
isError,
error: data?.error,
totalCount,
hasFilters,
isLoading: showTableLoadingState,
rawData: data?.rawData,
};
const emptyTableMessage: React.ReactNode = renderEmptyState?.(
emptyStateContext,
) || (
}) || (
<K8sEmptyState
isError={emptyStateContext.isError}
error={emptyStateContext.error}
isLoading={emptyStateContext.isLoading}
rawData={emptyStateContext.rawData}
isError={isError}
error={data?.error}
isLoading={showTableLoadingState}
rawData={data?.rawData}
/>
);
const showEmptyState = !showTableLoadingState && pageData.length === 0;
return (
<>
<K8sHeader
controlListPrefix={controlListPrefix}
entity={entity}
showAutoRefresh={!selectedItem}
columns={tableColumns}
columnStorageKey={columnStorageKey}
/>
<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,
}}
/>
{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}
/>
)}
</>
);
}

View File

@@ -21,6 +21,7 @@
.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 { K8sBaseListEmptyStateContext } from './K8sBaseList';
import type { K8sBaseListEmptyStateContext } from './K8sBaseList';
import styles from './K8sEmptyState.module.scss';

View File

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

View File

@@ -1,40 +1,40 @@
import React, { useCallback, useMemo } from 'react';
import { useCallback, useEffect, useMemo } from 'react';
import { useQuery } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { LoadingOutlined } from '@ant-design/icons';
import {
Button,
Spin,
Table,
TableColumnType as ColumnType,
Typography,
} from 'antd';
import { Button } from '@signozhq/ui';
import { Typography } from 'antd';
import TanStackTable, {
SortState,
TableColumnDef,
TanStackTableStateProvider,
} from 'components/TanStackTableView';
import { CornerDownRight } from 'lucide-react';
import { useQueryState } from 'nuqs';
import { useGlobalTimeStore } from 'store/globalTime';
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime/utils';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { buildAbsolutePath, isModifierKeyPressed } from 'utils/app';
import { openInNewTab } from 'utils/navigation';
import { parseAsJsonNoValidate } from 'utils/nuqsParsers';
import { InfraMonitoringEntity } from '../constants';
import {
useInfraMonitoringCurrentPage,
useInfraMonitoringFilters,
useInfraMonitoringFiltersK8s,
useInfraMonitoringGroupBy,
useInfraMonitoringOrderBy,
useInfraMonitoringPageListing,
useInfraMonitoringSelectedItem,
} from '../hooks';
import LoadingContainer from '../LoadingContainer';
import { K8sBaseFilters, K8sRenderedRowData } from './types';
import { useInfraMonitoringTableColumnsForPage } from './useInfraMonitoringTableColumnsStore';
import { K8sBaseFilters } from './types';
import styles from './K8sExpandedRow.module.scss';
const EXPANDED_ROW_LIMIT = 10;
export type K8sExpandedRowProps<T> = {
record: K8sRenderedRowData;
/** Pre-computed row key from parent table (includes group prefix + duplicate handling) */
rowKey: string;
/** Group metadata for building filters */
groupMeta?: Record<string, string>;
entity: InfraMonitoringEntity;
tableColumns: ColumnType<K8sRenderedRowData>[];
tableColumns: TableColumnDef<T>[];
fetchListData: (
filters: K8sBaseFilters,
signal?: AbortSignal,
@@ -44,47 +44,46 @@ export type K8sExpandedRowProps<T> = {
error?: string | null;
rawData?: unknown;
}>;
renderRowData: (
record: T,
groupBy: BaseAutocompleteData[],
) => K8sRenderedRowData;
/** 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;
};
export const MAX_ITEMS_TO_FETCH_WHEN_GROUP_BY = 10;
export function K8sExpandedRow<T>({
record,
rowKey,
groupMeta,
entity,
tableColumns,
fetchListData,
renderRowData,
getRowKey,
getItemKey,
}: K8sExpandedRowProps<T>): JSX.Element {
const [groupBy, setGroupBy] = useInfraMonitoringGroupBy();
const [orderBy, setOrderBy] = useInfraMonitoringOrderBy();
const [, setCurrentPage] = useInfraMonitoringCurrentPage();
const [queryFilters, setFilters] = useInfraMonitoringFilters();
const [, setGroupBy] = useInfraMonitoringGroupBy();
const [, setCurrentPage] = useInfraMonitoringPageListing();
const [queryFilters, setFilters] = useInfraMonitoringFiltersK8s();
const [, setSelectedItem] = useInfraMonitoringSelectedItem();
const [, setMainOrderBy] = useInfraMonitoringOrderBy();
const [columnsDefinitions, columnsHidden] =
useInfraMonitoringTableColumnsForPage(entity);
const hiddenColumnIdsForNested = useMemo(
() =>
columnsDefinitions
.filter((col) => col.behavior === 'hidden-on-collapse')
.map((col) => col.id),
[columnsDefinitions],
const orderByParamKey = useMemo(
() => `orderBy_${rowKey.replace(/[^a-zA-Z0-9]/g, '_')}`,
[rowKey],
);
const nestedColumns = useMemo(
() =>
tableColumns.filter(
(c) =>
!columnsHidden.includes(c.key?.toString() || '') &&
!hiddenColumnIdsForNested.includes(c.key?.toString() || ''),
),
[tableColumns, columnsHidden, hiddenColumnIdsForNested],
const [orderBy, setOrderBy] = useQueryState(
orderByParamKey,
parseAsJsonNoValidate<SortState | null>()
.withDefault(null as never)
.withOptions({
history: 'push',
}),
);
useEffect(() => {
return (): void => {
void setOrderBy(null);
};
}, [setOrderBy]);
const storageKey = `k8s-${entity}-columns-expanded`;
const createFiltersForRecord = useCallback((): NonNullable<
IBuilderQuery['filters']
@@ -94,22 +93,27 @@ export function K8sExpandedRow<T>({
op: 'and',
};
const { groupedByMeta } = record;
const metaKeys = groupMeta ?? {};
for (const key of Object.keys(groupedByMeta)) {
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;
}
baseFilters.items.push({
key: {
key,
type: null,
type: 'resource',
},
op: '=',
value: groupedByMeta[key],
value,
id: key,
});
}
return baseFilters;
}, [queryFilters?.items, record]);
}, [queryFilters?.items, groupMeta]);
const selectedTime = useGlobalTimeStore((s) => s.selectedTime);
const refreshInterval = useGlobalTimeStore((s) => s.refreshInterval);
@@ -120,22 +124,33 @@ export function K8sExpandedRow<T>({
);
const queryKey = useMemo(() => {
return getAutoRefreshQueryKey(selectedTime, [
return getAutoRefreshQueryKey(
selectedTime,
entity,
'k8sExpandedRow',
record.key,
JSON.stringify(groupMeta),
rowKey,
JSON.stringify(queryFilters),
JSON.stringify(orderBy),
]);
}, [getAutoRefreshQueryKey, selectedTime, record.key, queryFilters, orderBy]);
);
}, [
getAutoRefreshQueryKey,
selectedTime,
entity,
groupMeta,
rowKey,
queryFilters,
orderBy,
]);
const { data, isFetching, isLoading, isError } = useQuery({
const { data, isLoading, isError } = useQuery({
queryKey,
queryFn: ({ signal }) => {
queryFn: async ({ signal }) => {
const { minTime, maxTime } = getMinMaxTime();
return fetchListData(
return await fetchListData(
{
limit: MAX_ITEMS_TO_FETCH_WHEN_GROUP_BY,
limit: EXPANDED_ROW_LIMIT,
offset: 0,
filters: createFiltersForRecord(),
start: Math.floor(minTime / NANO_SECOND_MULTIPLIER),
@@ -146,48 +161,45 @@ export function K8sExpandedRow<T>({
signal,
);
},
staleTime: 1000 * 60 * 30,
refetchInterval: isRefreshEnabled ? refreshInterval : false,
});
const formattedData = useMemo(() => {
if (!data?.data) {
return undefined;
}
const expandedData = data?.data ?? [];
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 handleRowClick = useCallback(
(_row: T, itemKey: string): void => {
setSelectedItem(itemKey);
},
[setSelectedItem],
);
const handleViewAllClick = (): void => {
const filters = createFiltersForRecord();
setFilters(filters);
setCurrentPage(1);
setGroupBy([]);
setOrderBy(null);
setCurrentPage(1);
setFilters(filters);
if (orderBy) {
setMainOrderBy(orderBy);
}
};
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}
@@ -197,50 +209,30 @@ export function K8sExpandedRow<T>({
<Typography>{data?.error?.toString() || 'Something went wrong'}</Typography>
)}
{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 />} />,
<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,
}}
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,
})}
tableScrollerProps={{
className: styles.expandedTable,
}}
disableVirtualScroll
cellTypographySize="medium"
/>
{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>
)}
</TanStackTableStateProvider>
{!isLoading && expandedData.length > 0 && (
<div className={styles.expandedTableFooter}>{footerContent}</div>
)}
</div>
</div>
);
}

View File

@@ -1,57 +1,98 @@
import { useMemo } from 'react';
import { Button, DrawerWrapper } from '@signozhq/ui';
import { InfraMonitoringEntity } from '../constants';
import {
useInfraMonitoringTableColumnsForPage,
useInfraMonitoringTableColumnsStore,
} from './useInfraMonitoringTableColumnsStore';
hideColumn,
showColumn,
TableColumnDef,
useHiddenColumnIds,
} from 'components/TanStackTableView';
import styles from './K8sFiltersSidePanel.module.scss';
function K8sFiltersSidePanel({
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>({
open,
onClose,
entity,
columns,
storageKey,
}: {
open: boolean;
onClose: () => void;
entity: InfraMonitoringEntity;
columns: TableColumnDef<TData>[];
storageKey: string;
}): JSX.Element {
const addColumn = useInfraMonitoringTableColumnsStore(
(state) => state.addColumn,
const columnPickerItems = useMemo(
() => toColumnPickerItems(columns),
[columns],
);
const removeColumn = useInfraMonitoringTableColumnsStore(
(state) => state.removeColumn,
const hiddenColumnIds = useHiddenColumnIds(storageKey);
const addedColumns = useMemo(
() =>
columnPickerItems.filter(
(column) =>
!hiddenColumnIds.includes(column.id) &&
column.visibilityBehavior !== 'hidden-on-collapse',
),
[columnPickerItems, hiddenColumnIds],
);
const [columns, columnsHidden] = useInfraMonitoringTableColumnsForPage(entity);
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 drawerContent = (
<>
<div className={styles.columnsTitle}>Added Columns (Click to remove)</div>
<div className={styles.columnsList}>
{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>
))}
{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>
))}
</div>
<div className={styles.horizontalDivider} />
@@ -59,23 +100,21 @@ function K8sFiltersSidePanel({
<div className={styles.columnsTitle}>Other Columns (Click to add)</div>
<div className={styles.columnsList}>
{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>
))}
{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>
))}
</div>
</>
);

View File

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

View File

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

View File

@@ -22,30 +22,32 @@ 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 itemData.meta;
result[rawKey] = (itemData.meta[metaKey] || itemData.meta[rawKey]) ?? '';
const metaKey = (dotToUnder[rawKey] ?? rawKey) as keyof typeof meta;
result[rawKey] = (meta[metaKey] || 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(itemData.meta);
return nodeIdentifier || JSON.stringify(meta);
}
const groupedMeta = getGroupedByMeta(itemData, groupBy);
@@ -61,30 +63,32 @@ export function getRowKey<T extends { meta: Record<string, string> }>(
return nodeIdentifier;
}
return JSON.stringify(itemData.meta);
return JSON.stringify(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 itemData.meta;
const value = itemData.meta[metaKey] || itemData.meta[rawKey] || '<no-value>';
const metaKey = (dotToUnder[rawKey] ?? rawKey) as keyof typeof meta;
const value = meta[metaKey] || meta[rawKey] || '<no-value>';
groupByValues.push(value);
});
return (
<div className={styles.itemDataGroup}>
{groupByValues.map((value) => (
{groupByValues.map((value, index) => (
<Badge
key={value}
// oxlint-disable-next-line react/no-array-index-key
key={`${index}-${value}`}
color="secondary"
className={styles.itemDataGroupTagItem}
>

View File

@@ -19,9 +19,9 @@ import {
k8sClusterInitialLogTracesFilter,
} from './constants';
import {
k8sClustersColumns,
getK8sClusterItemKey,
getK8sClusterRowKey,
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}
renderRowData={k8sClustersRenderRowData}
getRowKey={getK8sClusterRowKey}
getItemKey={getK8sClusterItemKey}
eventCategory={InfraMonitoringEvents.Cluster}
/>

View File

@@ -1,77 +1,26 @@
import { TableColumnType as ColumnType, Tooltip } from 'antd';
import { Group } from 'lucide-react';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Tooltip } from 'antd';
import { TableColumnDef } from 'components/TanStackTableView';
import TanStackTable from 'components/TanStackTableView';
import { ExpandButtonWrapper } from 'container/InfraMonitoringK8s/components';
import { K8sRenderedRowData } from '../Base/types';
import { IEntityColumn } from '../Base/useInfraMonitoringTableColumnsStore';
import { getGroupByEl, getGroupedByMeta, getRowKey } from '../Base/utils';
import { formatBytes, ValidateColumnValueWrapper } from '../commonUtils';
import EntityGroupHeader from '../Base/EntityGroupHeader';
import K8sGroupCell from '../Base/K8sGroupCell';
import { formatBytes } from '../commonUtils';
import { ValidateColumnValueWrapper } from '../components';
import { K8sClusterData, K8sClustersListPayload } from './api';
import { Boxes } from 'lucide-react';
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 getK8sClusterRowKey(cluster: K8sClusterData): string {
return (
cluster.clusterUID ||
cluster.meta.k8s_cluster_uid ||
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 function getK8sClusterItemKey(cluster: K8sClusterData): string {
return cluster.meta.k8s_cluster_name;
}
export const getK8sClustersListQuery = (): K8sClustersListPayload => ({
filters: {
@@ -81,103 +30,110 @@ export const getK8sClustersListQuery = (): K8sClustersListPayload => ({
orderBy: { columnName: 'cpu', order: 'desc' },
});
export const k8sClustersColumnsConfig: ColumnType<K8sRenderedRowData>[] = [
export const k8sClustersColumnsConfig: TableColumnDef<K8sClusterData>[] = [
{
title: (
<div className={styles.entityGroupHeader}>
<Group size={14} /> CLUSTER GROUP
</div>
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} />}
/>
),
dataIndex: 'clusterGroup',
key: 'clusterGroup',
ellipsis: true,
width: 150,
align: 'left',
sorter: false,
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>
);
},
},
{
title: <div>Cluster Name</div>,
dataIndex: 'clusterName',
key: 'clusterName',
ellipsis: true,
width: 150,
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>CPU Usage (cores)</div>,
dataIndex: 'cpu',
key: 'cpu',
width: 80,
sorter: true,
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 Alloc (cores)</div>,
dataIndex: 'cpu_allocatable',
key: 'cpu_allocatable',
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>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',
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>
);
},
},
];
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

@@ -19,9 +19,9 @@ import {
k8sDaemonSetInitialLogTracesFilter,
} from './constants';
import {
k8sDaemonSetsColumns,
getK8sDaemonSetItemKey,
getK8sDaemonSetRowKey,
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}
renderRowData={k8sDaemonSetsRenderRowData}
getRowKey={getK8sDaemonSetRowKey}
getItemKey={getK8sDaemonSetItemKey}
eventCategory={InfraMonitoringEvents.DaemonSet}
/>

View File

@@ -1,297 +1,225 @@
import { TableColumnType as ColumnType, Tooltip } from 'antd';
import { Group } from 'lucide-react';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Tooltip } from 'antd';
import { TableColumnDef } from 'components/TanStackTableView';
import TanStackTable from 'components/TanStackTableView';
import { ExpandButtonWrapper } from 'container/InfraMonitoringK8s/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 EntityGroupHeader from '../Base/EntityGroupHeader';
import K8sGroupCell from '../Base/K8sGroupCell';
import { formatBytes } from '../commonUtils';
import { EntityProgressBar, ValidateColumnValueWrapper } from '../components';
import { InfraMonitoringEntity } from '../constants';
import { K8sDaemonSetsData } from './api';
import { Group } from 'lucide-react';
import styles from './table.module.scss';
export function getK8sDaemonSetRowKey(daemonSet: K8sDaemonSetsData): string {
return (
daemonSet.daemonSetName ||
daemonSet.meta.k8s_daemonset_name ||
`${daemonSet.meta.k8s_namespace_name}-${daemonSet.meta.k8s_daemonset_name}`
);
}
export const k8sDaemonSetsColumns: IEntityColumn[] = [
export function getK8sDaemonSetItemKey(daemonSet: K8sDaemonSetsData): string {
return daemonSet.meta.k8s_daemonset_name;
}
export const k8sDaemonSetsColumnsConfig: TableColumnDef<K8sDaemonSetsData>[] = [
{
label: 'DaemonSet Group',
value: 'daemonSetGroup',
id: 'daemonSetGroup',
canBeHidden: false,
defaultVisibility: true,
behavior: 'hidden-on-collapse',
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>
);
},
},
{
label: 'DaemonSet Name',
value: 'daemonsetName',
id: 'daemonsetName',
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_nodes',
id: 'available_nodes',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Desired',
value: 'desired_nodes',
id: 'desired_nodes',
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 k8sDaemonSetsColumnsConfig: ColumnType<K8sRenderedRowData>[] = [
{
title: (
<div className={styles.entityGroupHeader}>
<Group size={14} /> DAEMONSET GROUP
</div>
header: (): React.ReactNode => (
<EntityGroupHeader
title="DaemonSet Name"
icon={<Group data-hide-expanded="true" size={14} />}
/>
),
dataIndex: 'daemonSetGroup',
key: 'daemonSetGroup',
ellipsis: true,
width: 150,
align: 'left',
sorter: false,
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>
);
},
},
{
title: <div>DaemonSet Name</div>,
dataIndex: 'daemonsetName',
key: 'daemonsetName',
ellipsis: true,
width: 150,
sorter: false,
align: 'left',
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>
);
},
},
{
title: <div>Namespace Name</div>,
dataIndex: 'namespaceName',
key: 'namespaceName',
ellipsis: true,
width: 80,
sorter: false,
align: 'left',
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>
);
},
},
{
title: <div>Available</div>,
dataIndex: 'available_nodes',
key: 'available_nodes',
width: 50,
ellipsis: true,
sorter: true,
align: 'left',
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>
);
},
},
{
title: <div>Desired</div>,
dataIndex: 'desired_nodes',
key: 'desired_nodes',
width: 50,
sorter: true,
align: 'left',
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>
);
},
},
{
title: <div>CPU Req Usage (%)</div>,
dataIndex: 'cpu_request',
key: 'cpu_request',
width: 180,
ellipsis: true,
sorter: true,
align: 'left',
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>
);
},
},
{
title: <div>CPU Limit Usage (%)</div>,
dataIndex: 'cpu_limit',
key: 'cpu_limit',
width: 120,
sorter: true,
align: 'left',
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>
);
},
},
{
title: <div>CPU Usage (cores)</div>,
dataIndex: 'cpu',
key: 'cpu',
width: 80,
sorter: true,
align: 'left',
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>
);
},
},
{
title: <div>Mem Req Usage (%)</div>,
dataIndex: 'memory_request',
key: 'memory_request',
width: 170,
sorter: true,
align: 'left',
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>
);
},
},
{
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',
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>
);
},
},
];
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

@@ -1,11 +0,0 @@
.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 {
k8sDeploymentsColumns,
getK8sDeploymentItemKey,
getK8sDeploymentRowKey,
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}
renderRowData={k8sDeploymentsRenderRowData}
getRowKey={getK8sDeploymentRowKey}
getItemKey={getK8sDeploymentItemKey}
eventCategory={InfraMonitoringEvents.Deployment}
/>

View File

@@ -1,269 +1,230 @@
import { TableColumnType as ColumnType, Tooltip } from 'antd';
import { Group } from 'lucide-react';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Tooltip } from 'antd';
import { TableColumnDef } from 'components/TanStackTableView';
import TanStackTable from 'components/TanStackTableView';
import { ExpandButtonWrapper } from 'container/InfraMonitoringK8s/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 EntityGroupHeader from '../Base/EntityGroupHeader';
import K8sGroupCell from '../Base/K8sGroupCell';
import { formatBytes } from '../commonUtils';
import { EntityProgressBar, ValidateColumnValueWrapper } from '../components';
import { InfraMonitoringEntity } from '../constants';
import { K8sDeploymentsData } from './api';
import { Computer } from 'lucide-react';
import styles from './table.module.scss';
export function getK8sDeploymentRowKey(deployment: K8sDeploymentsData): string {
return deployment.meta.k8s_deployment_name || deployment.deploymentName;
}
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 = (
export function getK8sDeploymentItemKey(
deployment: K8sDeploymentsData,
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),
});
): 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>
);
},
},
];

View File

@@ -1,11 +0,0 @@
.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,
useInfraMonitoringFilters,
useInfraMonitoringFiltersK8s,
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] = useInfraMonitoringFilters();
const [urlFilters, setUrlFilters] = useInfraMonitoringFiltersK8s();
const [, setGroupBy] = useInfraMonitoringGroupBy();
const [, setOrderBy] = useInfraMonitoringOrderBy();

View File

@@ -19,9 +19,9 @@ import {
k8sJobInitialLogTracesFilter,
} from './constants';
import {
k8sJobsColumns,
getK8sJobItemKey,
getK8sJobRowKey,
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}
renderRowData={k8sJobsRenderRowData}
getRowKey={getK8sJobRowKey}
getItemKey={getK8sJobItemKey}
eventCategory={InfraMonitoringEvents.Job}
/>

View File

@@ -1,330 +1,249 @@
import { TableColumnType as ColumnType, Tooltip } from 'antd';
import { Group } from 'lucide-react';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Tooltip } from 'antd';
import { TableColumnDef } from 'components/TanStackTableView';
import TanStackTable from 'components/TanStackTableView';
import { ExpandButtonWrapper } from 'container/InfraMonitoringK8s/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 EntityGroupHeader from '../Base/EntityGroupHeader';
import K8sGroupCell from '../Base/K8sGroupCell';
import { formatBytes } from '../commonUtils';
import { EntityProgressBar, ValidateColumnValueWrapper } from '../components';
import { InfraMonitoringEntity } from '../constants';
import { K8sJobsData } from './api';
import { Bolt } from 'lucide-react';
import styles from './table.module.scss';
export function getK8sJobRowKey(job: K8sJobsData): string {
return job.jobName || job.meta.k8s_job_name || '';
}
export const k8sJobsColumns: IEntityColumn[] = [
export function getK8sJobItemKey(job: K8sJobsData): string {
return job.meta.k8s_job_name;
}
export const k8sJobsColumnsConfig: TableColumnDef<K8sJobsData>[] = [
{
label: 'Job Group',
value: 'jobGroup',
id: 'jobGroup',
canBeHidden: false,
defaultVisibility: true,
behavior: 'hidden-on-collapse',
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>
);
},
},
{
label: 'Job Name',
value: 'jobName',
id: 'jobName',
canBeHidden: false,
defaultVisibility: true,
behavior: 'hidden-on-expand',
},
{
label: 'Namespace Name',
value: 'namespaceName',
id: 'namespaceName',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Successful',
value: 'successful_pods',
id: 'successful_pods',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Failed',
value: 'failed_pods',
id: 'failed_pods',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Desired Successful',
value: 'desired_successful_pods',
id: 'desired_successful_pods',
canBeHidden: false,
defaultVisibility: true,
behavior: 'always-visible',
},
{
label: 'Active',
value: 'active_pods',
id: 'active_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 k8sJobsColumnsConfig: ColumnType<K8sRenderedRowData>[] = [
{
title: (
<div className={styles.entityGroupHeader}>
<Group size={14} /> JOB GROUP
</div>
header: (): React.ReactNode => (
<EntityGroupHeader
title="Job Name"
icon={<Bolt data-hide-expanded="true" size={14} />}
/>
),
dataIndex: 'jobGroup',
key: 'jobGroup',
ellipsis: true,
width: 150,
align: 'left',
sorter: false,
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>
);
},
},
{
title: <div>Job Name</div>,
dataIndex: 'jobName',
key: 'jobName',
ellipsis: true,
width: 80,
sorter: false,
align: 'left',
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>
);
},
},
{
title: <div>Namespace Name</div>,
dataIndex: 'namespaceName',
key: 'namespaceName',
ellipsis: true,
width: 80,
sorter: false,
align: 'left',
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>
);
},
},
{
title: <div>Successful</div>,
dataIndex: 'successful_pods',
key: 'successful_pods',
ellipsis: true,
sorter: true,
align: 'left',
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>
);
},
},
{
title: <div>Failed</div>,
dataIndex: 'failed_pods',
key: 'failed_pods',
sorter: true,
align: 'left',
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>
);
},
},
{
title: <div>Desired Successful</div>,
dataIndex: 'desired_successful_pods',
key: 'desired_successful_pods',
ellipsis: true,
sorter: true,
align: 'left',
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>
);
},
},
{
title: <div>Active</div>,
dataIndex: 'active_pods',
key: 'active_pods',
sorter: true,
align: 'left',
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>
);
},
},
{
title: <div>CPU Req Usage (%)</div>,
dataIndex: 'cpu_request',
key: 'cpu_request',
width: 180,
ellipsis: true,
sorter: true,
align: 'left',
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>
);
},
},
{
title: <div>CPU Limit Usage (%)</div>,
dataIndex: 'cpu_limit',
key: 'cpu_limit',
width: 120,
sorter: true,
align: 'left',
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>
);
},
},
{
title: <div>CPU Usage (cores)</div>,
dataIndex: 'cpu',
key: 'cpu',
width: 80,
sorter: true,
align: 'left',
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>
);
},
},
{
title: <div>Mem Req Usage (%)</div>,
dataIndex: 'memory_request',
key: 'memory_request',
width: 120,
sorter: true,
align: 'left',
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>
);
},
},
{
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',
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>
);
},
},
];
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

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

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