From 79996f9a5af07b5de4daf91e452e10a17f79c07f Mon Sep 17 00:00:00 2001 From: Sam Calder-Mason Date: Fri, 20 Feb 2026 17:07:18 +1000 Subject: [PATCH 1/2] feat(entities): add validator status charts and redesign detail page Add daily validator count by status data with two new charts: - Active Validators (line chart with area fill) - Validator Count by Status (stacked area chart) Redesign entity detail page header into a unified card with health status badge, 4 stat panels with health-based color coding, and staggered fade-in animations. Default time range changed to "all". --- src/api/@tanstack/react-query.gen.ts | 68 ++++ src/api/index.ts | 15 + src/api/sdk.gen.ts | 48 +++ src/api/types.gen.ts | 304 ++++++++++++++++++ src/api/zod.gen.ts | 235 ++++++++++++++ src/pages/ethereum/entities/DetailPage.tsx | 93 ++++-- .../ActiveValidatorsChart.tsx | 92 ++++++ .../components/ActiveValidatorsChart/index.ts | 1 + .../EntityBasicInfoCard.tsx | 123 ++++--- .../EntityBasicInfoCard.types.ts | 4 + .../ValidatorStatusChart.tsx | 95 ++++++ .../ValidatorStatusChart.types.ts | 7 + .../components/ValidatorStatusChart/index.ts | 2 + .../ethereum/entities/components/index.ts | 5 + src/pages/ethereum/entities/constants.ts | 24 ++ src/pages/ethereum/entities/hooks/index.ts | 1 + .../hooks/useEntityValidatorStatusData.ts | 87 +++++ src/routes/ethereum/entities/$entity.tsx | 8 +- 18 files changed, 1138 insertions(+), 74 deletions(-) create mode 100644 src/pages/ethereum/entities/components/ActiveValidatorsChart/ActiveValidatorsChart.tsx create mode 100644 src/pages/ethereum/entities/components/ActiveValidatorsChart/index.ts create mode 100644 src/pages/ethereum/entities/components/ValidatorStatusChart/ValidatorStatusChart.tsx create mode 100644 src/pages/ethereum/entities/components/ValidatorStatusChart/ValidatorStatusChart.types.ts create mode 100644 src/pages/ethereum/entities/components/ValidatorStatusChart/index.ts create mode 100644 src/pages/ethereum/entities/constants.ts create mode 100644 src/pages/ethereum/entities/hooks/useEntityValidatorStatusData.ts diff --git a/src/api/@tanstack/react-query.gen.ts b/src/api/@tanstack/react-query.gen.ts index 1e531c0ff..9edbefa57 100644 --- a/src/api/@tanstack/react-query.gen.ts +++ b/src/api/@tanstack/react-query.gen.ts @@ -242,6 +242,8 @@ import { fctValidatorBalanceHourlyServiceList, fctValidatorBalanceServiceGet, fctValidatorBalanceServiceList, + fctValidatorCountByEntityByStatusDailyServiceGet, + fctValidatorCountByEntityByStatusDailyServiceList, intAddressFirstAccessServiceGet, intAddressFirstAccessServiceList, intAddressLastAccessServiceGet, @@ -1089,6 +1091,12 @@ import type { FctValidatorBalanceServiceListData, FctValidatorBalanceServiceListError, FctValidatorBalanceServiceListResponse, + FctValidatorCountByEntityByStatusDailyServiceGetData, + FctValidatorCountByEntityByStatusDailyServiceGetError, + FctValidatorCountByEntityByStatusDailyServiceGetResponse, + FctValidatorCountByEntityByStatusDailyServiceListData, + FctValidatorCountByEntityByStatusDailyServiceListError, + FctValidatorCountByEntityByStatusDailyServiceListResponse, IntAddressFirstAccessServiceGetData, IntAddressFirstAccessServiceGetError, IntAddressFirstAccessServiceGetResponse, @@ -8408,6 +8416,66 @@ export const fctValidatorBalanceHourlyServiceGetOptions = (options: Options +) => createQueryKey('fctValidatorCountByEntityByStatusDailyServiceList', options); + +/** + * List records + * + * Retrieve paginated results with optional filtering + */ +export const fctValidatorCountByEntityByStatusDailyServiceListOptions = ( + options?: Options +) => + queryOptions< + FctValidatorCountByEntityByStatusDailyServiceListResponse, + FctValidatorCountByEntityByStatusDailyServiceListError, + FctValidatorCountByEntityByStatusDailyServiceListResponse, + ReturnType + >({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await fctValidatorCountByEntityByStatusDailyServiceList({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: fctValidatorCountByEntityByStatusDailyServiceListQueryKey(options), + }); + +export const fctValidatorCountByEntityByStatusDailyServiceGetQueryKey = ( + options: Options +) => createQueryKey('fctValidatorCountByEntityByStatusDailyServiceGet', options); + +/** + * Get record + * + * Retrieve a single record by day_start_date + */ +export const fctValidatorCountByEntityByStatusDailyServiceGetOptions = ( + options: Options +) => + queryOptions< + FctValidatorCountByEntityByStatusDailyServiceGetResponse, + FctValidatorCountByEntityByStatusDailyServiceGetError, + FctValidatorCountByEntityByStatusDailyServiceGetResponse, + ReturnType + >({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await fctValidatorCountByEntityByStatusDailyServiceGet({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: fctValidatorCountByEntityByStatusDailyServiceGetQueryKey(options), + }); + export const intAddressFirstAccessServiceListQueryKey = (options?: Options) => createQueryKey('intAddressFirstAccessServiceList', options); diff --git a/src/api/index.ts b/src/api/index.ts index ab13d7f6d..b9ca6eead 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -239,6 +239,8 @@ export { fctValidatorBalanceHourlyServiceList, fctValidatorBalanceServiceGet, fctValidatorBalanceServiceList, + fctValidatorCountByEntityByStatusDailyServiceGet, + fctValidatorCountByEntityByStatusDailyServiceList, intAddressFirstAccessServiceGet, intAddressFirstAccessServiceList, intAddressLastAccessServiceGet, @@ -1682,6 +1684,17 @@ export type { FctValidatorBalanceServiceListErrors, FctValidatorBalanceServiceListResponse, FctValidatorBalanceServiceListResponses, + FctValidatorCountByEntityByStatusDaily, + FctValidatorCountByEntityByStatusDailyServiceGetData, + FctValidatorCountByEntityByStatusDailyServiceGetError, + FctValidatorCountByEntityByStatusDailyServiceGetErrors, + FctValidatorCountByEntityByStatusDailyServiceGetResponse, + FctValidatorCountByEntityByStatusDailyServiceGetResponses, + FctValidatorCountByEntityByStatusDailyServiceListData, + FctValidatorCountByEntityByStatusDailyServiceListError, + FctValidatorCountByEntityByStatusDailyServiceListErrors, + FctValidatorCountByEntityByStatusDailyServiceListResponse, + FctValidatorCountByEntityByStatusDailyServiceListResponses, GetAdminCbtIncrementalResponse, GetAdminCbtScheduledResponse, GetDimBlockBlobSubmitterResponse, @@ -1801,6 +1814,7 @@ export type { GetFctValidatorBalanceDailyResponse, GetFctValidatorBalanceHourlyResponse, GetFctValidatorBalanceResponse, + GetFctValidatorCountByEntityByStatusDailyResponse, GetIntAddressFirstAccessResponse, GetIntAddressLastAccessResponse, GetIntAddressStorageSlotFirstAccessResponse, @@ -2701,6 +2715,7 @@ export type { ListFctValidatorBalanceDailyResponse, ListFctValidatorBalanceHourlyResponse, ListFctValidatorBalanceResponse, + ListFctValidatorCountByEntityByStatusDailyResponse, ListIntAddressFirstAccessResponse, ListIntAddressLastAccessResponse, ListIntAddressStorageSlotFirstAccessResponse, diff --git a/src/api/sdk.gen.ts b/src/api/sdk.gen.ts index 1800f097b..bb8898229 100644 --- a/src/api/sdk.gen.ts +++ b/src/api/sdk.gen.ts @@ -717,6 +717,12 @@ import type { FctValidatorBalanceServiceListData, FctValidatorBalanceServiceListErrors, FctValidatorBalanceServiceListResponses, + FctValidatorCountByEntityByStatusDailyServiceGetData, + FctValidatorCountByEntityByStatusDailyServiceGetErrors, + FctValidatorCountByEntityByStatusDailyServiceGetResponses, + FctValidatorCountByEntityByStatusDailyServiceListData, + FctValidatorCountByEntityByStatusDailyServiceListErrors, + FctValidatorCountByEntityByStatusDailyServiceListResponses, IntAddressFirstAccessServiceGetData, IntAddressFirstAccessServiceGetErrors, IntAddressFirstAccessServiceGetResponses, @@ -1585,6 +1591,10 @@ import { zFctValidatorBalanceServiceGetResponse, zFctValidatorBalanceServiceListData, zFctValidatorBalanceServiceListResponse, + zFctValidatorCountByEntityByStatusDailyServiceGetData, + zFctValidatorCountByEntityByStatusDailyServiceGetResponse, + zFctValidatorCountByEntityByStatusDailyServiceListData, + zFctValidatorCountByEntityByStatusDailyServiceListResponse, zIntAddressFirstAccessServiceGetData, zIntAddressFirstAccessServiceGetResponse, zIntAddressFirstAccessServiceListData, @@ -6358,6 +6368,44 @@ export const fctValidatorBalanceHourlyServiceGet = ( + options?: Options +) => + (options?.client ?? client).get< + FctValidatorCountByEntityByStatusDailyServiceListResponses, + FctValidatorCountByEntityByStatusDailyServiceListErrors, + ThrowOnError + >({ + requestValidator: async data => await zFctValidatorCountByEntityByStatusDailyServiceListData.parseAsync(data), + responseValidator: async data => await zFctValidatorCountByEntityByStatusDailyServiceListResponse.parseAsync(data), + url: '/api/v1/fct_validator_count_by_entity_by_status_daily', + ...options, + }); + +/** + * Get record + * + * Retrieve a single record by day_start_date + */ +export const fctValidatorCountByEntityByStatusDailyServiceGet = ( + options: Options +) => + (options.client ?? client).get< + FctValidatorCountByEntityByStatusDailyServiceGetResponses, + FctValidatorCountByEntityByStatusDailyServiceGetErrors, + ThrowOnError + >({ + requestValidator: async data => await zFctValidatorCountByEntityByStatusDailyServiceGetData.parseAsync(data), + responseValidator: async data => await zFctValidatorCountByEntityByStatusDailyServiceGetResponse.parseAsync(data), + url: '/api/v1/fct_validator_count_by_entity_by_status_daily/{day_start_date}', + ...options, + }); + /** * List records * diff --git a/src/api/types.gen.ts b/src/api/types.gen.ts index 6a1703be5..667f4a5ac 100644 --- a/src/api/types.gen.ts +++ b/src/api/types.gen.ts @@ -5735,6 +5735,29 @@ export type FctValidatorBalanceHourly = { validator_index?: number; }; +export type FctValidatorCountByEntityByStatusDaily = { + /** + * Start of the day period + */ + day_start_date?: string; + /** + * Entity name from dim_node mapping + */ + entity?: string; + /** + * Validator status (active_ongoing, pending_queued, etc) + */ + status?: string; + /** + * Timestamp when the record was last updated + */ + updated_date_time?: number; + /** + * Number of validators with this status for this entity on this day + */ + validator_count?: number; +}; + /** * Response for getting a single admin_cbt_incremental record */ @@ -6568,6 +6591,13 @@ export type GetFctValidatorBalanceResponse = { item?: FctValidatorBalance; }; +/** + * Response for getting a single fct_validator_count_by_entity_by_status_daily record + */ +export type GetFctValidatorCountByEntityByStatusDailyResponse = { + item?: FctValidatorCountByEntityByStatusDaily; +}; + /** * Response for getting a single int_address_first_access record */ @@ -11503,6 +11533,20 @@ export type ListFctValidatorBalanceResponse = { next_page_token?: string; }; +/** + * Response for listing fct_validator_count_by_entity_by_status_daily records + */ +export type ListFctValidatorCountByEntityByStatusDailyResponse = { + /** + * The list of fct_validator_count_by_entity_by_status_daily. + */ + fct_validator_count_by_entity_by_status_daily?: Array; + /** + * A token, which can be sent as `page_token` to retrieve the next page. If this field is omitted, there are no subsequent pages. + */ + next_page_token?: string; +}; + /** * Response for listing int_address_first_access records */ @@ -66231,6 +66275,266 @@ export type FctValidatorBalanceHourlyServiceGetResponses = { export type FctValidatorBalanceHourlyServiceGetResponse = FctValidatorBalanceHourlyServiceGetResponses[keyof FctValidatorBalanceHourlyServiceGetResponses]; +export type FctValidatorCountByEntityByStatusDailyServiceListData = { + body?: never; + path?: never; + query?: { + /** + * Start of the day period (filter: eq) + */ + day_start_date_eq?: string; + /** + * Start of the day period (filter: ne) + */ + day_start_date_ne?: string; + /** + * Start of the day period (filter: contains) + */ + day_start_date_contains?: string; + /** + * Start of the day period (filter: starts_with) + */ + day_start_date_starts_with?: string; + /** + * Start of the day period (filter: ends_with) + */ + day_start_date_ends_with?: string; + /** + * Start of the day period (filter: like) + */ + day_start_date_like?: string; + /** + * Start of the day period (filter: not_like) + */ + day_start_date_not_like?: string; + /** + * Start of the day period (filter: in_values) (comma-separated list) + */ + day_start_date_in_values?: string; + /** + * Start of the day period (filter: not_in_values) (comma-separated list) + */ + day_start_date_not_in_values?: string; + /** + * Entity name from dim_node mapping (filter: eq) + */ + entity_eq?: string; + /** + * Entity name from dim_node mapping (filter: ne) + */ + entity_ne?: string; + /** + * Entity name from dim_node mapping (filter: contains) + */ + entity_contains?: string; + /** + * Entity name from dim_node mapping (filter: starts_with) + */ + entity_starts_with?: string; + /** + * Entity name from dim_node mapping (filter: ends_with) + */ + entity_ends_with?: string; + /** + * Entity name from dim_node mapping (filter: like) + */ + entity_like?: string; + /** + * Entity name from dim_node mapping (filter: not_like) + */ + entity_not_like?: string; + /** + * Entity name from dim_node mapping (filter: in_values) (comma-separated list) + */ + entity_in_values?: string; + /** + * Entity name from dim_node mapping (filter: not_in_values) (comma-separated list) + */ + entity_not_in_values?: string; + /** + * Validator status (active_ongoing, pending_queued, etc) (filter: eq) + */ + status_eq?: string; + /** + * Validator status (active_ongoing, pending_queued, etc) (filter: ne) + */ + status_ne?: string; + /** + * Validator status (active_ongoing, pending_queued, etc) (filter: contains) + */ + status_contains?: string; + /** + * Validator status (active_ongoing, pending_queued, etc) (filter: starts_with) + */ + status_starts_with?: string; + /** + * Validator status (active_ongoing, pending_queued, etc) (filter: ends_with) + */ + status_ends_with?: string; + /** + * Validator status (active_ongoing, pending_queued, etc) (filter: like) + */ + status_like?: string; + /** + * Validator status (active_ongoing, pending_queued, etc) (filter: not_like) + */ + status_not_like?: string; + /** + * Validator status (active_ongoing, pending_queued, etc) (filter: in_values) (comma-separated list) + */ + status_in_values?: string; + /** + * Validator status (active_ongoing, pending_queued, etc) (filter: not_in_values) (comma-separated list) + */ + status_not_in_values?: string; + /** + * Timestamp when the record was last updated (filter: eq) + */ + updated_date_time_eq?: number; + /** + * Timestamp when the record was last updated (filter: ne) + */ + updated_date_time_ne?: number; + /** + * Timestamp when the record was last updated (filter: lt) + */ + updated_date_time_lt?: number; + /** + * Timestamp when the record was last updated (filter: lte) + */ + updated_date_time_lte?: number; + /** + * Timestamp when the record was last updated (filter: gt) + */ + updated_date_time_gt?: number; + /** + * Timestamp when the record was last updated (filter: gte) + */ + updated_date_time_gte?: number; + /** + * Timestamp when the record was last updated (filter: between_min) + */ + updated_date_time_between_min?: number; + /** + * Timestamp when the record was last updated (filter: between_max_value) + */ + updated_date_time_between_max_value?: number; + /** + * Timestamp when the record was last updated (filter: in_values) (comma-separated list) + */ + updated_date_time_in_values?: string; + /** + * Timestamp when the record was last updated (filter: not_in_values) (comma-separated list) + */ + updated_date_time_not_in_values?: string; + /** + * Number of validators with this status for this entity on this day (filter: eq) + */ + validator_count_eq?: number; + /** + * Number of validators with this status for this entity on this day (filter: ne) + */ + validator_count_ne?: number; + /** + * Number of validators with this status for this entity on this day (filter: lt) + */ + validator_count_lt?: number; + /** + * Number of validators with this status for this entity on this day (filter: lte) + */ + validator_count_lte?: number; + /** + * Number of validators with this status for this entity on this day (filter: gt) + */ + validator_count_gt?: number; + /** + * Number of validators with this status for this entity on this day (filter: gte) + */ + validator_count_gte?: number; + /** + * Number of validators with this status for this entity on this day (filter: between_min) + */ + validator_count_between_min?: number; + /** + * Number of validators with this status for this entity on this day (filter: between_max_value) + */ + validator_count_between_max_value?: number; + /** + * Number of validators with this status for this entity on this day (filter: in_values) (comma-separated list) + */ + validator_count_in_values?: string; + /** + * Number of validators with this status for this entity on this day (filter: not_in_values) (comma-separated list) + */ + validator_count_not_in_values?: string; + /** + * The maximum number of fct_validator_count_by_entity_by_status_daily to return. If unspecified, at most 100 items will be returned. The maximum value is 10000; values above 10000 will be coerced to 10000. + */ + page_size?: number; + /** + * A page token, received from a previous `ListFctValidatorCountByEntityByStatusDaily` call. Provide this to retrieve the subsequent page. + */ + page_token?: string; + /** + * The order of results. Format: comma-separated list of fields. Example: "foo,bar" or "foo desc,bar" for descending order on foo. If unspecified, results will be returned in the default order. + */ + order_by?: string; + }; + url: '/api/v1/fct_validator_count_by_entity_by_status_daily'; +}; + +export type FctValidatorCountByEntityByStatusDailyServiceListErrors = { + /** + * Default error response + */ + default: Status; +}; + +export type FctValidatorCountByEntityByStatusDailyServiceListError = + FctValidatorCountByEntityByStatusDailyServiceListErrors[keyof FctValidatorCountByEntityByStatusDailyServiceListErrors]; + +export type FctValidatorCountByEntityByStatusDailyServiceListResponses = { + /** + * OK + */ + 200: ListFctValidatorCountByEntityByStatusDailyResponse; +}; + +export type FctValidatorCountByEntityByStatusDailyServiceListResponse = + FctValidatorCountByEntityByStatusDailyServiceListResponses[keyof FctValidatorCountByEntityByStatusDailyServiceListResponses]; + +export type FctValidatorCountByEntityByStatusDailyServiceGetData = { + body?: never; + path: { + /** + * Start of the day period + */ + day_start_date: string; + }; + query?: never; + url: '/api/v1/fct_validator_count_by_entity_by_status_daily/{day_start_date}'; +}; + +export type FctValidatorCountByEntityByStatusDailyServiceGetErrors = { + /** + * Default error response + */ + default: Status; +}; + +export type FctValidatorCountByEntityByStatusDailyServiceGetError = + FctValidatorCountByEntityByStatusDailyServiceGetErrors[keyof FctValidatorCountByEntityByStatusDailyServiceGetErrors]; + +export type FctValidatorCountByEntityByStatusDailyServiceGetResponses = { + /** + * OK + */ + 200: GetFctValidatorCountByEntityByStatusDailyResponse; +}; + +export type FctValidatorCountByEntityByStatusDailyServiceGetResponse = + FctValidatorCountByEntityByStatusDailyServiceGetResponses[keyof FctValidatorCountByEntityByStatusDailyServiceGetResponses]; + export type IntAddressFirstAccessServiceListData = { body?: never; path?: never; diff --git a/src/api/zod.gen.ts b/src/api/zod.gen.ts index 6cbf05ecf..593805c99 100644 --- a/src/api/zod.gen.ts +++ b/src/api/zod.gen.ts @@ -8244,6 +8244,28 @@ export const zFctValidatorBalanceHourly = z.object({ ), }); +export const zFctValidatorCountByEntityByStatusDaily = z.object({ + day_start_date: z.optional(z.string()), + entity: z.optional(z.string()), + status: z.optional(z.string()), + updated_date_time: z.optional( + z + .int() + .check( + z.minimum(0, { error: 'Invalid value: Expected uint32 to be >= 0' }), + z.maximum(4294967295, { error: 'Invalid value: Expected uint32 to be <= 4294967295' }) + ) + ), + validator_count: z.optional( + z + .int() + .check( + z.minimum(0, { error: 'Invalid value: Expected uint32 to be >= 0' }), + z.maximum(4294967295, { error: 'Invalid value: Expected uint32 to be <= 4294967295' }) + ) + ), +}); + /** * Response for getting a single admin_cbt_incremental record */ @@ -9077,6 +9099,13 @@ export const zGetFctValidatorBalanceResponse = z.object({ item: z.optional(zFctValidatorBalance), }); +/** + * Response for getting a single fct_validator_count_by_entity_by_status_daily record + */ +export const zGetFctValidatorCountByEntityByStatusDailyResponse = z.object({ + item: z.optional(zFctValidatorCountByEntityByStatusDaily), +}); + /** * Contains an arbitrary serialized message along with a @type that describes the type of the serialized message. */ @@ -14716,6 +14745,14 @@ export const zListFctValidatorBalanceResponse = z.object({ next_page_token: z.optional(z.string()), }); +/** + * Response for listing fct_validator_count_by_entity_by_status_daily records + */ +export const zListFctValidatorCountByEntityByStatusDailyResponse = z.object({ + fct_validator_count_by_entity_by_status_daily: z.optional(z.array(zFctValidatorCountByEntityByStatusDaily)), + next_page_token: z.optional(z.string()), +}); + /** * Response for listing int_address_first_access records */ @@ -82738,6 +82775,204 @@ export const zFctValidatorBalanceHourlyServiceGetData = z.object({ */ export const zFctValidatorBalanceHourlyServiceGetResponse = zGetFctValidatorBalanceHourlyResponse; +export const zFctValidatorCountByEntityByStatusDailyServiceListData = z.object({ + body: z.optional(z.never()), + path: z.optional(z.never()), + query: z.optional( + z.object({ + day_start_date_eq: z.optional(z.string()), + day_start_date_ne: z.optional(z.string()), + day_start_date_contains: z.optional(z.string()), + day_start_date_starts_with: z.optional(z.string()), + day_start_date_ends_with: z.optional(z.string()), + day_start_date_like: z.optional(z.string()), + day_start_date_not_like: z.optional(z.string()), + day_start_date_in_values: z.optional(z.string().check(z.regex(/^[^,]+(,[^,]+)*$/))), + day_start_date_not_in_values: z.optional(z.string().check(z.regex(/^[^,]+(,[^,]+)*$/))), + entity_eq: z.optional(z.string()), + entity_ne: z.optional(z.string()), + entity_contains: z.optional(z.string()), + entity_starts_with: z.optional(z.string()), + entity_ends_with: z.optional(z.string()), + entity_like: z.optional(z.string()), + entity_not_like: z.optional(z.string()), + entity_in_values: z.optional(z.string().check(z.regex(/^[^,]+(,[^,]+)*$/))), + entity_not_in_values: z.optional(z.string().check(z.regex(/^[^,]+(,[^,]+)*$/))), + status_eq: z.optional(z.string()), + status_ne: z.optional(z.string()), + status_contains: z.optional(z.string()), + status_starts_with: z.optional(z.string()), + status_ends_with: z.optional(z.string()), + status_like: z.optional(z.string()), + status_not_like: z.optional(z.string()), + status_in_values: z.optional(z.string().check(z.regex(/^[^,]+(,[^,]+)*$/))), + status_not_in_values: z.optional(z.string().check(z.regex(/^[^,]+(,[^,]+)*$/))), + updated_date_time_eq: z.optional( + z + .int() + .check( + z.minimum(0, { error: 'Invalid value: Expected uint32 to be >= 0' }), + z.maximum(4294967295, { error: 'Invalid value: Expected uint32 to be <= 4294967295' }) + ) + ), + updated_date_time_ne: z.optional( + z + .int() + .check( + z.minimum(0, { error: 'Invalid value: Expected uint32 to be >= 0' }), + z.maximum(4294967295, { error: 'Invalid value: Expected uint32 to be <= 4294967295' }) + ) + ), + updated_date_time_lt: z.optional( + z + .int() + .check( + z.minimum(0, { error: 'Invalid value: Expected uint32 to be >= 0' }), + z.maximum(4294967295, { error: 'Invalid value: Expected uint32 to be <= 4294967295' }) + ) + ), + updated_date_time_lte: z.optional( + z + .int() + .check( + z.minimum(0, { error: 'Invalid value: Expected uint32 to be >= 0' }), + z.maximum(4294967295, { error: 'Invalid value: Expected uint32 to be <= 4294967295' }) + ) + ), + updated_date_time_gt: z.optional( + z + .int() + .check( + z.minimum(0, { error: 'Invalid value: Expected uint32 to be >= 0' }), + z.maximum(4294967295, { error: 'Invalid value: Expected uint32 to be <= 4294967295' }) + ) + ), + updated_date_time_gte: z.optional( + z + .int() + .check( + z.minimum(0, { error: 'Invalid value: Expected uint32 to be >= 0' }), + z.maximum(4294967295, { error: 'Invalid value: Expected uint32 to be <= 4294967295' }) + ) + ), + updated_date_time_between_min: z.optional( + z + .int() + .check( + z.minimum(0, { error: 'Invalid value: Expected uint32 to be >= 0' }), + z.maximum(4294967295, { error: 'Invalid value: Expected uint32 to be <= 4294967295' }) + ) + ), + updated_date_time_between_max_value: z.optional( + z + .int() + .check( + z.minimum(0, { error: 'Invalid value: Expected uint32 to be >= 0' }), + z.maximum(4294967295, { error: 'Invalid value: Expected uint32 to be <= 4294967295' }) + ) + ), + updated_date_time_in_values: z.optional(z.string().check(z.regex(/^\d+(,\d+)*$/))), + updated_date_time_not_in_values: z.optional(z.string().check(z.regex(/^\d+(,\d+)*$/))), + validator_count_eq: z.optional( + z + .int() + .check( + z.minimum(0, { error: 'Invalid value: Expected uint32 to be >= 0' }), + z.maximum(4294967295, { error: 'Invalid value: Expected uint32 to be <= 4294967295' }) + ) + ), + validator_count_ne: z.optional( + z + .int() + .check( + z.minimum(0, { error: 'Invalid value: Expected uint32 to be >= 0' }), + z.maximum(4294967295, { error: 'Invalid value: Expected uint32 to be <= 4294967295' }) + ) + ), + validator_count_lt: z.optional( + z + .int() + .check( + z.minimum(0, { error: 'Invalid value: Expected uint32 to be >= 0' }), + z.maximum(4294967295, { error: 'Invalid value: Expected uint32 to be <= 4294967295' }) + ) + ), + validator_count_lte: z.optional( + z + .int() + .check( + z.minimum(0, { error: 'Invalid value: Expected uint32 to be >= 0' }), + z.maximum(4294967295, { error: 'Invalid value: Expected uint32 to be <= 4294967295' }) + ) + ), + validator_count_gt: z.optional( + z + .int() + .check( + z.minimum(0, { error: 'Invalid value: Expected uint32 to be >= 0' }), + z.maximum(4294967295, { error: 'Invalid value: Expected uint32 to be <= 4294967295' }) + ) + ), + validator_count_gte: z.optional( + z + .int() + .check( + z.minimum(0, { error: 'Invalid value: Expected uint32 to be >= 0' }), + z.maximum(4294967295, { error: 'Invalid value: Expected uint32 to be <= 4294967295' }) + ) + ), + validator_count_between_min: z.optional( + z + .int() + .check( + z.minimum(0, { error: 'Invalid value: Expected uint32 to be >= 0' }), + z.maximum(4294967295, { error: 'Invalid value: Expected uint32 to be <= 4294967295' }) + ) + ), + validator_count_between_max_value: z.optional( + z + .int() + .check( + z.minimum(0, { error: 'Invalid value: Expected uint32 to be >= 0' }), + z.maximum(4294967295, { error: 'Invalid value: Expected uint32 to be <= 4294967295' }) + ) + ), + validator_count_in_values: z.optional(z.string().check(z.regex(/^\d+(,\d+)*$/))), + validator_count_not_in_values: z.optional(z.string().check(z.regex(/^\d+(,\d+)*$/))), + page_size: z.optional( + z + .int() + .check( + z.minimum(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' }), + z.maximum(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }) + ) + ), + page_token: z.optional(z.string()), + order_by: z.optional(z.string()), + }) + ), +}); + +/** + * OK + */ +export const zFctValidatorCountByEntityByStatusDailyServiceListResponse = + zListFctValidatorCountByEntityByStatusDailyResponse; + +export const zFctValidatorCountByEntityByStatusDailyServiceGetData = z.object({ + body: z.optional(z.never()), + path: z.object({ + day_start_date: z.string(), + }), + query: z.optional(z.never()), +}); + +/** + * OK + */ +export const zFctValidatorCountByEntityByStatusDailyServiceGetResponse = + zGetFctValidatorCountByEntityByStatusDailyResponse; + export const zIntAddressFirstAccessServiceListData = z.object({ body: z.optional(z.never()), path: z.optional(z.never()), diff --git a/src/pages/ethereum/entities/DetailPage.tsx b/src/pages/ethereum/entities/DetailPage.tsx index 2c56926fd..560e64fd2 100644 --- a/src/pages/ethereum/entities/DetailPage.tsx +++ b/src/pages/ethereum/entities/DetailPage.tsx @@ -1,6 +1,7 @@ -import { useParams } from '@tanstack/react-router'; +import { useParams, useNavigate } from '@tanstack/react-router'; import { useMemo } from 'react'; import { TabGroup, TabPanel, TabPanels } from '@headlessui/react'; +import clsx from 'clsx'; import { Alert } from '@/components/Feedback/Alert'; import { Container } from '@/components/Layout/Container'; @@ -12,30 +13,26 @@ import { useNetworkChangeRedirect } from '@/hooks/useNetworkChangeRedirect'; import { useTabState } from '@/hooks/useTabState'; import { Route } from '@/routes/ethereum/entities/$entity'; -import { AttestationRateChart, AttestationVolumeChart, EntityBasicInfoCard, RecentActivityTable } from './components'; +import { + ActiveValidatorsChart, + AttestationRateChart, + AttestationVolumeChart, + EntityBasicInfoCard, + RecentActivityTable, + ValidatorStatusChart, +} from './components'; import { EpochSlotsTable } from '../epochs/components'; -import { useEntityDetailData } from './hooks'; - -/** - * Entity detail page - comprehensive analysis of a single validator entity - * - * Shows: - * - Basic entity statistics - * - Attestation rate over time - * - Attestation volume (attested vs missed) - * - Block proposal history - * - Recent activity table - * - * Validates entity parameter and handles errors - */ +import { useEntityDetailData, useEntityValidatorStatusData } from './hooks'; +import { TIME_RANGE_CONFIG, TIME_PERIOD_OPTIONS, type TimePeriod } from './constants'; + export function DetailPage(): React.JSX.Element { const params = useParams({ from: '/ethereum/entities/$entity' }); + const { t } = Route.useSearch(); + const navigate = useNavigate({ from: '/ethereum/entities/$entity' }); const context = Route.useRouteContext(); - // Redirect to entities index when network changes useNetworkChangeRedirect(context.redirectOnNetworkChange); - // Decode entity name from URL parameter const entityName = useMemo(() => { try { return decodeURIComponent(params.entity); @@ -44,17 +41,19 @@ export function DetailPage(): React.JSX.Element { } }, [params.entity]); - // Fetch data for this entity + const timePeriod: TimePeriod = t ?? 'all'; + const config = TIME_RANGE_CONFIG[timePeriod]; + const { data, isLoading, error } = useEntityDetailData(entityName ?? ''); + const validatorStatus = useEntityValidatorStatusData(entityName ?? '', config.days); - // Tab state management with URL search params const { selectedIndex, onChange } = useTabState([ + { id: 'validators', anchors: ['active-validators-chart', 'validator-status-chart'] }, { id: 'recent', anchors: ['recent-activity'] }, { id: 'attestations', anchors: ['attestation-rate-chart', 'attestation-volume-chart'] }, { id: 'blocks', anchors: ['block-proposals'] }, ]); - // Handle invalid entity if (entityName === null) { return ( @@ -64,7 +63,6 @@ export function DetailPage(): React.JSX.Element { ); } - // Loading state if (isLoading) { return ( @@ -74,7 +72,6 @@ export function DetailPage(): React.JSX.Element { ); } - // Error state if (error) { return ( @@ -84,7 +81,6 @@ export function DetailPage(): React.JSX.Element { ); } - // No data state if (!data) { return ( @@ -100,24 +96,57 @@ export function DetailPage(): React.JSX.Element { return ( - {/* Header */} -
- - {/* Overview - Always visible */} -
- + {/* Unified entity header with stats */} +
+
- {/* Tabs */} -
+ {/* Tabbed content */} +
+ Validators Recent Attestations Blocks + {/* Validators Tab */} + +
+ {TIME_PERIOD_OPTIONS.map(({ value, label }) => ( + + ))} +
+ {validatorStatus.isLoading ? ( + + ) : validatorStatus.error ? ( + + ) : ( +
+ + +
+ )} +
+ {/* Recent Tab */} diff --git a/src/pages/ethereum/entities/components/ActiveValidatorsChart/ActiveValidatorsChart.tsx b/src/pages/ethereum/entities/components/ActiveValidatorsChart/ActiveValidatorsChart.tsx new file mode 100644 index 000000000..3be320bb1 --- /dev/null +++ b/src/pages/ethereum/entities/components/ActiveValidatorsChart/ActiveValidatorsChart.tsx @@ -0,0 +1,92 @@ +import { useMemo } from 'react'; + +import { MultiLineChart } from '@/components/Charts/MultiLine'; +import { Alert } from '@/components/Feedback/Alert'; +import { PopoutCard } from '@/components/Layout/PopoutCard'; + +import type { EntityValidatorStatusData } from '../../hooks/useEntityValidatorStatusData'; +import type { TimePeriod } from '../../constants'; + +const ACTIVE_STATUSES = new Set(['active_ongoing', 'active_exiting']); + +const SUBTITLE_MAP: Record = { + '7d': 'Last 7 days', + '30d': 'Last 30 days', + '90d': 'Last 90 days', + '180d': 'Last 180 days', + all: 'All time', +}; + +interface ActiveValidatorsChartProps { + data: EntityValidatorStatusData | null; + timePeriod: TimePeriod; +} + +export function ActiveValidatorsChart({ data, timePeriod }: ActiveValidatorsChartProps): React.JSX.Element { + const { series, yMin } = useMemo(() => { + if (!data) return { series: [], yMin: 0 }; + + let minVal = Infinity; + const chartData: Array<[string, number]> = data.days.map(day => { + let total = 0; + for (const status of ACTIVE_STATUSES) { + total += data.byStatus.get(status)?.get(day) ?? 0; + } + if (total < minVal) minVal = total; + return [day, total]; + }); + + // Round down to a nice boundary (nearest 1000, 5000, or 10000 depending on magnitude) + const step = minVal > 100000 ? 10000 : minVal > 10000 ? 5000 : 1000; + const roundedMin = Math.floor(minVal / step) * step; + + return { + series: [ + { + name: 'Active Validators', + data: chartData, + color: '#22c55e', + showArea: true, + areaOpacity: 0.15, + lineWidth: 2, + showSymbol: false, + }, + ], + yMin: roundedMin, + }; + }, [data]); + + const subtitle = SUBTITLE_MAP[timePeriod] ?? 'Last 30 days'; + + if (!data || series.length === 0) { + return ( + + {() => ( + + )} + + ); + } + + return ( + + {({ inModal }) => ( + + )} + + ); +} diff --git a/src/pages/ethereum/entities/components/ActiveValidatorsChart/index.ts b/src/pages/ethereum/entities/components/ActiveValidatorsChart/index.ts new file mode 100644 index 000000000..2b961affb --- /dev/null +++ b/src/pages/ethereum/entities/components/ActiveValidatorsChart/index.ts @@ -0,0 +1 @@ +export { ActiveValidatorsChart } from './ActiveValidatorsChart'; diff --git a/src/pages/ethereum/entities/components/EntityBasicInfoCard/EntityBasicInfoCard.tsx b/src/pages/ethereum/entities/components/EntityBasicInfoCard/EntityBasicInfoCard.tsx index cc24178a3..b52d2e2ad 100644 --- a/src/pages/ethereum/entities/components/EntityBasicInfoCard/EntityBasicInfoCard.tsx +++ b/src/pages/ethereum/entities/components/EntityBasicInfoCard/EntityBasicInfoCard.tsx @@ -1,54 +1,105 @@ -import { Card } from '@/components/Layout/Card'; +import clsx from 'clsx'; import type { EntityBasicInfoCardProps } from './EntityBasicInfoCard.types'; /** - * Display basic information about an entity + * Unified entity header card * - * Shows: - * - Entity name - * - 12h attestation rate - * - Validator count - * - Total blocks proposed + * Combines the entity identity with key health metrics in a single card. + * Header section shows entity name + health status badge. + * Stat panels below show detailed metrics with health-based color coding. */ -export function EntityBasicInfoCard({ stats }: EntityBasicInfoCardProps): React.JSX.Element { +export function EntityBasicInfoCard({ + stats, + activeValidatorCount, + totalValidatorCount, +}: EntityBasicInfoCardProps): React.JSX.Element { const rate24h = (stats.rate24h * 100).toFixed(2); + const validatorCount = activeValidatorCount ?? stats.validatorCount; return ( - -
- {/* Entity name header */} -
-

{stats.entity}

-
- - {/* Stats grid */} -
- {/* 12h Online Rate */} +
+ {/* Header section */} +
+
-
12h Online Rate
-
= 0.99 ? 'text-success' : stats.rate24h < 0.95 ? 'text-warning' : '' - }`} - > - {rate24h}% -
+

{stats.entity}

+

Validator entity overview

- {/* Validator Count */} -
-
Validator Count
-
{stats.validatorCount.toLocaleString()}
+ {/* Health status badge */} +
= 0.99 + ? 'bg-success/10 text-success ring-1 ring-success/20' + : stats.rate24h >= 0.95 + ? 'bg-warning/10 text-warning ring-1 ring-warning/20' + : 'bg-danger/10 text-danger ring-1 ring-danger/20' + )} + > + = 0.99 ? 'bg-success' : stats.rate24h >= 0.95 ? 'bg-warning' : 'bg-danger' + )} + /> + {rate24h}% online
+
+
- {/* Proposals (12h) */} -
-
Proposals (12h)
-
{stats.blocksProposed24h.toLocaleString()}
-
+ {/* Stat panels */} +
+ {/* Active Validators */} +
+
Active Validators
+
+ {validatorCount.toLocaleString()} +
+ {totalValidatorCount !== undefined && totalValidatorCount > validatorCount && ( +
of {totalValidatorCount.toLocaleString()} total
+ )} +
+ + {/* 12h Online Rate */} +
+
12h Online Rate
+
= 0.99 ? 'text-success' : stats.rate24h < 0.95 ? 'text-warning' : 'text-foreground' + )} + > + {rate24h}% +
+
+ + {/* Proposals (12h) */} +
+
Proposals (12h)
+
+ {stats.blocksProposed24h.toLocaleString()} +
+
+ + {/* Missed Attestations (12h) */} +
+
Missed Attestations (12h)
+
1000 + ? 'text-warning' + : 'text-foreground' + )} + > + {stats.missedAttestations24h.toLocaleString()} +
- +
); } diff --git a/src/pages/ethereum/entities/components/EntityBasicInfoCard/EntityBasicInfoCard.types.ts b/src/pages/ethereum/entities/components/EntityBasicInfoCard/EntityBasicInfoCard.types.ts index 016468158..83a5e17c0 100644 --- a/src/pages/ethereum/entities/components/EntityBasicInfoCard/EntityBasicInfoCard.types.ts +++ b/src/pages/ethereum/entities/components/EntityBasicInfoCard/EntityBasicInfoCard.types.ts @@ -6,4 +6,8 @@ import type { EntityStats } from '../../hooks'; export interface EntityBasicInfoCardProps { /** Entity statistics */ stats: EntityStats; + /** Actual active validator count from daily table (overrides estimated count) */ + activeValidatorCount?: number; + /** Total validator count across all statuses */ + totalValidatorCount?: number; } diff --git a/src/pages/ethereum/entities/components/ValidatorStatusChart/ValidatorStatusChart.tsx b/src/pages/ethereum/entities/components/ValidatorStatusChart/ValidatorStatusChart.tsx new file mode 100644 index 000000000..13508eb8f --- /dev/null +++ b/src/pages/ethereum/entities/components/ValidatorStatusChart/ValidatorStatusChart.tsx @@ -0,0 +1,95 @@ +import { useMemo } from 'react'; + +import { MultiLineChart } from '@/components/Charts/MultiLine'; +import { Alert } from '@/components/Feedback/Alert'; +import { PopoutCard } from '@/components/Layout/PopoutCard'; + +import type { ValidatorStatusChartProps } from './ValidatorStatusChart.types'; + +const HIDDEN_STATUSES = new Set(['withdrawal_done']); + +const STATUS_COLORS: Record = { + active_ongoing: '#22c55e', + active_exiting: '#eab308', + active_slashed: '#ef4444', + pending_initialized: '#60a5fa', + pending_queued: '#3b82f6', + exited_unslashed: '#6b7280', + exited_slashed: '#dc2626', + withdrawal_possible: '#9ca3af', +}; + +function getStatusColor(status: string): string { + return STATUS_COLORS[status] ?? '#8b5cf6'; +} + +const SUBTITLE_MAP: Record = { + '7d': 'Last 7 days', + '30d': 'Last 30 days', + '90d': 'Last 90 days', + '180d': 'Last 180 days', + all: 'All time', +}; + +export function ValidatorStatusChart({ data, timePeriod }: ValidatorStatusChartProps): React.JSX.Element { + const series = useMemo(() => { + if (!data) return []; + + return data.statuses + .filter(status => !HIDDEN_STATUSES.has(status)) + .map(status => { + const dayMap = data.byStatus.get(status)!; + const chartData: Array<[string, number]> = data.days.map(day => [day, dayMap.get(day) ?? 0]); + + return { + name: status.replace(/_/g, ' '), + data: chartData, + color: getStatusColor(status), + stack: 'validator-status', + showArea: true, + areaOpacity: 0.85, + lineWidth: 0, + showSymbol: false, + }; + }); + }, [data]); + + const subtitle = SUBTITLE_MAP[timePeriod] ?? 'Last 30 days'; + + if (!data || series.length === 0) { + return ( + + {() => ( + + )} + + ); + } + + return ( + + {({ inModal }) => ( + + )} + + ); +} diff --git a/src/pages/ethereum/entities/components/ValidatorStatusChart/ValidatorStatusChart.types.ts b/src/pages/ethereum/entities/components/ValidatorStatusChart/ValidatorStatusChart.types.ts new file mode 100644 index 000000000..b7da58cc2 --- /dev/null +++ b/src/pages/ethereum/entities/components/ValidatorStatusChart/ValidatorStatusChart.types.ts @@ -0,0 +1,7 @@ +import type { EntityValidatorStatusData } from '../../hooks/useEntityValidatorStatusData'; +import type { TimePeriod } from '../../constants'; + +export interface ValidatorStatusChartProps { + data: EntityValidatorStatusData | null; + timePeriod: TimePeriod; +} diff --git a/src/pages/ethereum/entities/components/ValidatorStatusChart/index.ts b/src/pages/ethereum/entities/components/ValidatorStatusChart/index.ts new file mode 100644 index 000000000..52d558b19 --- /dev/null +++ b/src/pages/ethereum/entities/components/ValidatorStatusChart/index.ts @@ -0,0 +1,2 @@ +export { ValidatorStatusChart } from './ValidatorStatusChart'; +export type { ValidatorStatusChartProps } from './ValidatorStatusChart.types'; diff --git a/src/pages/ethereum/entities/components/index.ts b/src/pages/ethereum/entities/components/index.ts index 15c26dd24..5e7987780 100644 --- a/src/pages/ethereum/entities/components/index.ts +++ b/src/pages/ethereum/entities/components/index.ts @@ -15,3 +15,8 @@ export type { RecentActivityTableProps } from './RecentActivityTable'; export { BlockProposalsTable } from './BlockProposalsTable'; export type { BlockProposalsTableProps } from './BlockProposalsTable'; + +export { ValidatorStatusChart } from './ValidatorStatusChart'; +export type { ValidatorStatusChartProps } from './ValidatorStatusChart'; + +export { ActiveValidatorsChart } from './ActiveValidatorsChart'; diff --git a/src/pages/ethereum/entities/constants.ts b/src/pages/ethereum/entities/constants.ts new file mode 100644 index 000000000..7c920c159 --- /dev/null +++ b/src/pages/ethereum/entities/constants.ts @@ -0,0 +1,24 @@ +import { z } from 'zod'; + +export type TimePeriod = '7d' | '30d' | '90d' | '180d' | 'all'; + +export const entityDetailSearchSchema = z.object({ + tab: z.enum(['validators', 'recent', 'attestations', 'blocks']).default('validators'), + t: z.enum(['7d', '30d', '90d', '180d', 'all']).optional(), +}); + +export const TIME_RANGE_CONFIG = { + '7d': { days: 7 }, + '30d': { days: 30 }, + '90d': { days: 90 }, + '180d': { days: 180 }, + all: { days: null }, +} as const; + +export const TIME_PERIOD_OPTIONS = [ + { value: '7d', label: '7d' }, + { value: '30d', label: '30d' }, + { value: '90d', label: '90d' }, + { value: '180d', label: '180d' }, + { value: 'all', label: 'All' }, +] as const; diff --git a/src/pages/ethereum/entities/hooks/index.ts b/src/pages/ethereum/entities/hooks/index.ts index 08bd0a2be..a46b509f9 100644 --- a/src/pages/ethereum/entities/hooks/index.ts +++ b/src/pages/ethereum/entities/hooks/index.ts @@ -2,3 +2,4 @@ export * from './useEntitiesData'; export * from './useEntitiesData.types'; export * from './useEntityDetailData'; export * from './useEntityDetailData.types'; +export * from './useEntityValidatorStatusData'; diff --git a/src/pages/ethereum/entities/hooks/useEntityValidatorStatusData.ts b/src/pages/ethereum/entities/hooks/useEntityValidatorStatusData.ts new file mode 100644 index 000000000..e0a08969f --- /dev/null +++ b/src/pages/ethereum/entities/hooks/useEntityValidatorStatusData.ts @@ -0,0 +1,87 @@ +import { useMemo } from 'react'; +import { useQuery } from '@tanstack/react-query'; + +import { fctValidatorCountByEntityByStatusDailyServiceListOptions } from '@/api/@tanstack/react-query.gen'; +import type { FctValidatorCountByEntityByStatusDaily } from '@/api/types.gen'; + +export interface EntityValidatorStatusData { + days: string[]; + statuses: string[]; + byStatus: Map>; + latestTotalCount: number; + latestActiveCount: number; +} + +/** + * Hook to fetch validator count by status data for an entity. + * Fetches all available data and filters client-side by days parameter. + */ +export function useEntityValidatorStatusData(entityName: string, days: number | null) { + const query = useQuery({ + ...fctValidatorCountByEntityByStatusDailyServiceListOptions({ + query: { + entity_eq: entityName, + day_start_date_starts_with: '20', + order_by: 'day_start_date asc', + page_size: 10000, + }, + }), + enabled: entityName.length > 0, + }); + + const data = useMemo((): EntityValidatorStatusData | null => { + const rows = query.data?.fct_validator_count_by_entity_by_status_daily; + if (!rows || rows.length === 0) return null; + + let filtered: FctValidatorCountByEntityByStatusDaily[] = rows; + + if (days !== null) { + const cutoff = new Date(); + cutoff.setDate(cutoff.getDate() - days); + const cutoffStr = cutoff.toISOString().split('T')[0]; + filtered = rows.filter((r: FctValidatorCountByEntityByStatusDaily) => (r.day_start_date ?? '') >= cutoffStr); + } + + if (filtered.length === 0) return null; + + const daysSet = new Set(); + const statusesSet = new Set(); + const byStatus = new Map>(); + + for (const row of filtered) { + const day = row.day_start_date ?? ''; + const status = row.status ?? 'unknown'; + const count = row.validator_count ?? 0; + + daysSet.add(day); + statusesSet.add(status); + + if (!byStatus.has(status)) { + byStatus.set(status, new Map()); + } + byStatus.get(status)!.set(day, count); + } + + const sortedDays = [...daysSet].sort(); + const statuses = [...statusesSet].sort(); + + const latestDay = sortedDays[sortedDays.length - 1]; + let latestTotalCount = 0; + let latestActiveCount = 0; + for (const [status, dayMap] of byStatus) { + const count = dayMap.get(latestDay) ?? 0; + latestTotalCount += count; + if (status === 'active_ongoing' || status === 'active_exiting') { + latestActiveCount += count; + } + } + + return { days: sortedDays, statuses, byStatus, latestTotalCount, latestActiveCount }; + }, [query.data, days]); + + return { + data, + isLoading: query.isLoading, + error: query.error, + }; +} diff --git a/src/routes/ethereum/entities/$entity.tsx b/src/routes/ethereum/entities/$entity.tsx index 7c61a5cbc..0bd7ac785 100644 --- a/src/routes/ethereum/entities/$entity.tsx +++ b/src/routes/ethereum/entities/$entity.tsx @@ -1,14 +1,10 @@ import { createFileRoute } from '@tanstack/react-router'; -import { z } from 'zod'; import { DetailPage } from '@/pages/ethereum/entities'; - -const entitySearchSchema = z.object({ - tab: z.enum(['recent', 'attestations', 'blocks']).default('recent'), -}); +import { entityDetailSearchSchema } from '@/pages/ethereum/entities/constants'; export const Route = createFileRoute('/ethereum/entities/$entity')({ component: DetailPage, - validateSearch: entitySearchSchema, + validateSearch: entityDetailSearchSchema, beforeLoad: ({ params }) => ({ getBreadcrumb: () => ({ label: params.entity }), redirectOnNetworkChange: '/ethereum/entities', From c5f8bb621217ea48ec8e3cf6ca616fb802c87c83 Mon Sep 17 00:00:00 2001 From: Sam Calder-Mason Date: Fri, 20 Feb 2026 20:15:01 +1000 Subject: [PATCH 2/2] fix: use number[] for category chart data to match SeriesData type Category x-axis charts should use plain number[] (y-values indexed by category position) instead of [string, number][] tuples. --- .../ActiveValidatorsChart/ActiveValidatorsChart.tsx | 4 ++-- .../components/ValidatorStatusChart/ValidatorStatusChart.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/pages/ethereum/entities/components/ActiveValidatorsChart/ActiveValidatorsChart.tsx b/src/pages/ethereum/entities/components/ActiveValidatorsChart/ActiveValidatorsChart.tsx index 3be320bb1..dcb745e6d 100644 --- a/src/pages/ethereum/entities/components/ActiveValidatorsChart/ActiveValidatorsChart.tsx +++ b/src/pages/ethereum/entities/components/ActiveValidatorsChart/ActiveValidatorsChart.tsx @@ -27,13 +27,13 @@ export function ActiveValidatorsChart({ data, timePeriod }: ActiveValidatorsChar if (!data) return { series: [], yMin: 0 }; let minVal = Infinity; - const chartData: Array<[string, number]> = data.days.map(day => { + const chartData: number[] = data.days.map(day => { let total = 0; for (const status of ACTIVE_STATUSES) { total += data.byStatus.get(status)?.get(day) ?? 0; } if (total < minVal) minVal = total; - return [day, total]; + return total; }); // Round down to a nice boundary (nearest 1000, 5000, or 10000 depending on magnitude) diff --git a/src/pages/ethereum/entities/components/ValidatorStatusChart/ValidatorStatusChart.tsx b/src/pages/ethereum/entities/components/ValidatorStatusChart/ValidatorStatusChart.tsx index 13508eb8f..c5d28d75c 100644 --- a/src/pages/ethereum/entities/components/ValidatorStatusChart/ValidatorStatusChart.tsx +++ b/src/pages/ethereum/entities/components/ValidatorStatusChart/ValidatorStatusChart.tsx @@ -39,7 +39,7 @@ export function ValidatorStatusChart({ data, timePeriod }: ValidatorStatusChartP .filter(status => !HIDDEN_STATUSES.has(status)) .map(status => { const dayMap = data.byStatus.get(status)!; - const chartData: Array<[string, number]> = data.days.map(day => [day, dayMap.get(day) ?? 0]); + const chartData: number[] = data.days.map(day => dayMap.get(day) ?? 0); return { name: status.replace(/_/g, ' '),