diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index d62d5e92d..645a8d3ae 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -166,7 +166,7 @@ jobs: # See: https://quarkus.io/guides/tests-with-coverage#coverage-for-integration-tests # mvn verify -B --no-transfer-progress -DskipSTs \ - -Dquarkus.package.write-transformed-bytecode-to-build-output=true + -Dquarkus.package.write-transformed-bytecode-to-build-output=false - name: Archive Failed Tests Results uses: actions/upload-artifact@v7 diff --git a/.gitignore b/.gitignore index 5b4cf75cd..cf63cfcdb 100644 --- a/.gitignore +++ b/.gitignore @@ -36,4 +36,5 @@ release.properties # Systemtests systemtests/screenshots/ systemtests/config.yaml -systemtests/tracing/ \ No newline at end of file +systemtests/tracing/ +/.playwright-mcp/ diff --git a/api/pom.xml b/api/pom.xml index 2779561b2..fa5673f20 100644 --- a/api/pom.xml +++ b/api/pom.xml @@ -306,6 +306,7 @@ build + generate-code @@ -319,6 +320,30 @@ org.apache.maven.plugins maven-dependency-plugin + + unpack + initialize + + unpack + + + + + + com.linkedin.cruisecontrol + cruise-control + 2.5.146 + jar + true + ${project.build.directory}/schema/cruise-control + yaml/** + + + + analyze diff --git a/api/src/main/java/com/github/streamshub/console/api/KafkaRebalancesResource.java b/api/src/main/java/com/github/streamshub/console/api/KafkaRebalancesResource.java index 981f61283..ed8798889 100644 --- a/api/src/main/java/com/github/streamshub/console/api/KafkaRebalancesResource.java +++ b/api/src/main/java/com/github/streamshub/console/api/KafkaRebalancesResource.java @@ -142,7 +142,7 @@ public Response listRebalances( listParams, KafkaRebalance::fromCursor); - var rebalanceList = rebalanceService.listRebalances(listSupport); + var rebalanceList = rebalanceService.listRebalances(fields, listSupport); var responseEntity = new KafkaRebalance.RebalanceDataList(rebalanceList, listSupport); return Response.ok(responseEntity).build(); @@ -156,7 +156,7 @@ public Response listRebalances( @APIResponse(responseCode = "504", ref = "ServerTimeout") @Authorized @ResourcePrivilege(Privilege.GET) - public Response getRebalance( + public Response describeRebalance( @Parameter(description = "Cluster identifier") @PathParam("clusterId") String clusterId, @@ -176,6 +176,7 @@ public Response getRebalance( KafkaRebalance.Fields.STATUS, KafkaRebalance.Fields.MODE, KafkaRebalance.Fields.BROKERS, + KafkaRebalance.Fields.BROKER_CAPACITY, KafkaRebalance.Fields.GOALS, KafkaRebalance.Fields.SKIP_HARD_GOAL_CHECK, KafkaRebalance.Fields.REBALANCE_DISK, @@ -187,6 +188,7 @@ public Response getRebalance( KafkaRebalance.Fields.REPLICA_MOVEMENT_STRATEGIES, KafkaRebalance.Fields.SESSION_ID, KafkaRebalance.Fields.OPTIMIZATION_RESULT, + KafkaRebalance.Fields.OPTIMIZATION_PROPOSAL, KafkaRebalance.Fields.CONDITIONS, }, payload = ErrorCategory.InvalidQueryParameter.class) @@ -203,6 +205,7 @@ public Response getRebalance( KafkaRebalance.Fields.STATUS, KafkaRebalance.Fields.MODE, KafkaRebalance.Fields.BROKERS, + KafkaRebalance.Fields.BROKER_CAPACITY, KafkaRebalance.Fields.GOALS, KafkaRebalance.Fields.SKIP_HARD_GOAL_CHECK, KafkaRebalance.Fields.REBALANCE_DISK, @@ -214,13 +217,14 @@ public Response getRebalance( KafkaRebalance.Fields.REPLICA_MOVEMENT_STRATEGIES, KafkaRebalance.Fields.SESSION_ID, KafkaRebalance.Fields.OPTIMIZATION_RESULT, + KafkaRebalance.Fields.OPTIMIZATION_PROPOSAL, KafkaRebalance.Fields.CONDITIONS, })) List fields) { requestedFields.accept(fields); - var result = rebalanceService.getRebalance(rebalanceId); + var result = rebalanceService.getRebalance(rebalanceId, fields); var responseEntity = new KafkaRebalance.RebalanceData(result); return Response.ok(responseEntity).build(); diff --git a/api/src/main/java/com/github/streamshub/console/api/model/KafkaRebalance.java b/api/src/main/java/com/github/streamshub/console/api/model/KafkaRebalance.java index 588ee595e..a9edaedcd 100644 --- a/api/src/main/java/com/github/streamshub/console/api/model/KafkaRebalance.java +++ b/api/src/main/java/com/github/streamshub/console/api/model/KafkaRebalance.java @@ -1,5 +1,6 @@ package com.github.streamshub.console.api.model; +import java.math.BigDecimal; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; @@ -64,6 +65,7 @@ public static class Fields { public static final String STATUS = "status"; public static final String MODE = "mode"; public static final String BROKERS = "brokers"; + public static final String BROKER_CAPACITY = "brokerCapacity"; public static final String GOALS = "goals"; public static final String SKIP_HARD_GOAL_CHECK = "skipHardGoalCheck"; public static final String REBALANCE_DISK = "rebalanceDisk"; @@ -75,6 +77,7 @@ public static class Fields { public static final String REPLICA_MOVEMENT_STRATEGIES = "replicaMovementStrategies"; public static final String SESSION_ID = "sessionId"; public static final String OPTIMIZATION_RESULT = "optimizationResult"; + public static final String OPTIMIZATION_PROPOSAL = "optimizationProposal"; public static final String CONDITIONS = "conditions"; static final Comparator ID_COMPARATOR = @@ -196,6 +199,46 @@ static final class Meta extends JsonApiMeta { String action; } + public static final record BrokerCapacityOverride( + @JsonProperty + List brokers, + @JsonProperty + String cpu, + @JsonProperty + String inboundNetwork, + @JsonProperty + String outboundNetwork + ) { + } + + public static final record BrokerCapacity( + @JsonProperty + String cpu, + @JsonProperty + String inboundNetwork, + @JsonProperty + String outboundNetwork, + @JsonProperty + List overrides + ) { + } + + public static final record BrokerLoadImpact( + @JsonProperty + BigDecimal before, + @JsonProperty + BigDecimal after, + @JsonProperty + BigDecimal diff + ) { + } + + public static final record OptimizationProposal( + @JsonProperty + Map> brokerImpact + ) { + } + @JsonFilter("fieldFilter") @Schema(name = "KafkaRebalanceAttributes") static class Attributes extends KubeAttributes { @@ -211,6 +254,9 @@ static class Attributes extends KubeAttributes { @Schema(readOnly = true, nullable = true) List brokers; + @JsonProperty + BrokerCapacity brokerCapacity; + @JsonProperty @Schema(readOnly = true, nullable = true) List goals; @@ -253,7 +299,11 @@ static class Attributes extends KubeAttributes { @JsonProperty @Schema(readOnly = true) - Map optimizationResult = new HashMap<>(0); + Map optimizationResult = HashMap.newHashMap(0); + + @JsonProperty + @Schema(readOnly = true) + OptimizationProposal optimizationProposal; @JsonProperty @Schema(readOnly = true) @@ -317,6 +367,10 @@ public void brokers(List brokers) { attributes.brokers = brokers; } + public void brokerCapacity(BrokerCapacity brokerCapacity) { + attributes.brokerCapacity = brokerCapacity; + } + public void goals(List goals) { attributes.goals = goals; } @@ -361,6 +415,10 @@ public Map optimizationResult() { return attributes.optimizationResult; } + public void optimizationProposal(OptimizationProposal optimizationProposal) { + attributes.optimizationProposal = optimizationProposal; + } + public void conditions(List conditions) { attributes.conditions = conditions; } diff --git a/api/src/main/java/com/github/streamshub/console/api/service/KafkaRebalanceService.java b/api/src/main/java/com/github/streamshub/console/api/service/KafkaRebalanceService.java index c274579bd..a0cc6797e 100644 --- a/api/src/main/java/com/github/streamshub/console/api/service/KafkaRebalanceService.java +++ b/api/src/main/java/com/github/streamshub/console/api/service/KafkaRebalanceService.java @@ -3,6 +3,7 @@ import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.Base64; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -17,8 +18,11 @@ import org.jboss.logging.Logger; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; import com.github.streamshub.console.api.model.Condition; import com.github.streamshub.console.api.model.KafkaRebalance; +import com.github.streamshub.console.api.model.KafkaRebalance.BrokerLoadImpact; import com.github.streamshub.console.api.security.PermissionService; import com.github.streamshub.console.api.support.KafkaContext; import com.github.streamshub.console.api.support.ListRequestContext; @@ -31,6 +35,8 @@ import io.strimzi.api.ResourceAnnotations; import io.strimzi.api.ResourceLabels; import io.strimzi.api.kafka.model.kafka.Kafka; +import io.strimzi.api.kafka.model.kafka.KafkaSpec; +import io.strimzi.api.kafka.model.kafka.cruisecontrol.CruiseControlSpec; import io.strimzi.api.kafka.model.rebalance.KafkaRebalanceMode; import io.strimzi.api.kafka.model.rebalance.KafkaRebalanceSpec; import io.strimzi.api.kafka.model.rebalance.KafkaRebalanceState; @@ -45,6 +51,9 @@ public class KafkaRebalanceService { @Inject KubernetesClient client; + @Inject + ObjectMapper mapper; + @Inject ConsoleConfig consoleConfig; @@ -54,7 +63,7 @@ public class KafkaRebalanceService { @Inject PermissionService permissionService; - public List listRebalances(ListRequestContext listSupport) { + public List listRebalances(List fields, ListRequestContext listSupport) { final Map statuses = new HashMap<>(); listSupport.meta().put("summary", Map.of("statuses", statuses)); @@ -65,7 +74,7 @@ public List listRebalances(ListRequestContext li ResourceTypes.Kafka.REBALANCES, Privilege.LIST, r -> r.getMetadata().getName())) - .map(this::toKafkaRebalance) + .map(r -> toKafkaRebalance(r, fields)) .map(rebalance -> tallyStatus(statuses, rebalance)) .filter(listSupport.filter(KafkaRebalance.class)) .map(listSupport::tally) @@ -77,9 +86,9 @@ public List listRebalances(ListRequestContext li .toList(); } - public KafkaRebalance getRebalance(String id) { + public KafkaRebalance getRebalance(String id, List fields) { return findRebalance(id) - .map(this::toKafkaRebalance) + .map(r -> toKafkaRebalance(r, fields)) .map(permissionService.addPrivileges(ResourceTypes.Kafka.REBALANCES, KafkaRebalance::name)) .orElseThrow(() -> new NotFoundException("No such Kafka rebalance resource")); } @@ -98,11 +107,11 @@ public KafkaRebalance patchRebalance(String id, KafkaRebalance rebalance) { return client.resource(resource).patch(); }) - .map(this::toKafkaRebalance) + .map(r -> toKafkaRebalance(r, Collections.emptyList())) .orElseThrow(() -> new NotFoundException("No such Kafka rebalance resource")); } - KafkaRebalance toKafkaRebalance(io.strimzi.api.kafka.model.rebalance.KafkaRebalance resource) { + KafkaRebalance toKafkaRebalance(io.strimzi.api.kafka.model.rebalance.KafkaRebalance resource, List fields) { KafkaRebalanceSpec rebalanceSpec = resource.getSpec(); Optional rebalanceStatus = Optional.ofNullable(resource.getStatus()); Optional state = rebalanceStatus @@ -115,13 +124,32 @@ KafkaRebalance toKafkaRebalance(io.strimzi.api.kafka.model.rebalance.KafkaRebala .findFirst(); String id = Base64.getUrlEncoder().encodeToString(Cache.metaNamespaceKeyFunc(resource).getBytes(StandardCharsets.UTF_8)); + String namespace = resource.getMetadata().getNamespace(); KafkaRebalance rebalance = new KafkaRebalance(id); rebalance.name(resource.getMetadata().getName()); - rebalance.namespace(resource.getMetadata().getNamespace()); + rebalance.namespace(namespace); rebalance.creationTimestamp(resource.getMetadata().getCreationTimestamp()); rebalance.status(state.map(Enum::name).orElse(null)); rebalance.mode(Optional.ofNullable(rebalanceSpec.getMode()).map(KafkaRebalanceMode::toValue).orElse(null)); rebalance.brokers(rebalanceSpec.getBrokers()); + rebalance.brokerCapacity(Optional.ofNullable(kafkaContext.resource()) + .map(Kafka::getSpec) + .map(KafkaSpec::getCruiseControl) + .map(CruiseControlSpec::getBrokerCapacity) + .map(capacity -> new KafkaRebalance.BrokerCapacity( + capacity.getCpu(), + capacity.getInboundNetwork(), + capacity.getOutboundNetwork(), + Optional.ofNullable(capacity.getOverrides()) + .orElseGet(Collections::emptyList) + .stream() + .map(override -> new KafkaRebalance.BrokerCapacityOverride( + override.getBrokers(), + override.getCpu(), + override.getInboundNetwork(), + override.getOutboundNetwork())) + .toList())) + .orElse(null)); rebalance.goals(rebalanceSpec.getGoals()); rebalance.skipHardGoalCheck(rebalanceSpec.isSkipHardGoalCheck()); rebalance.rebalanceDisk(rebalanceSpec.isRebalanceDisk()); @@ -148,9 +176,43 @@ KafkaRebalance toKafkaRebalance(io.strimzi.api.kafka.model.rebalance.KafkaRebala .map(allowed -> allowed.stream().map(Enum::name).toList()) .ifPresent(rebalance.allowedActions()::addAll); + if (fields.contains(KafkaRebalance.Fields.OPTIMIZATION_PROPOSAL)) { + rebalance.optimizationProposal(getOptimizationProposal(namespace, rebalanceStatus)); + } + return rebalance; } + private KafkaRebalance.OptimizationProposal getOptimizationProposal(String namespace, Optional rebalanceStatus) { + return rebalanceStatus + .map(KafkaRebalanceStatus::getOptimizationResult) + .map(result -> result.get("afterBeforeLoadConfigMap")) + .filter(String.class::isInstance) + .map(String.class::cast) + .map(configMapName -> client.configMaps().inNamespace(namespace).withName(configMapName).get()) + .map(configMap -> { + var data = configMap.getData(); + var qname = "%s/%s".formatted(configMap.getMetadata().getNamespace(), configMap.getMetadata().getName()); + var brokerLoadImpact = Optional.ofNullable(data.get("brokerLoad.json")) + .map(json -> { + try { + return mapper.readValue(json, new TypeReference>>() { + // No implementation + }); + } catch (Exception e) { + logger.warnf(""" + Error reading 'brokerLoad.json' from rebalance \ + afterBeforeLoadConfigMap ConfigMap[%s]: %s""", qname, e.getMessage()); + return null; + } + }) + .orElse(null); + + return new KafkaRebalance.OptimizationProposal(brokerLoadImpact); + }) + .orElse(null); + } + KafkaRebalance tallyStatus(Map statuses, KafkaRebalance rebalance) { String status = rebalance.status(); if (status != null) { @@ -214,3 +276,4 @@ private boolean isTemplate(io.strimzi.api.kafka.model.rebalance.KafkaRebalance r .orElse(false); } } + diff --git a/api/src/main/resources/application.properties b/api/src/main/resources/application.properties index 52d60befc..e77d8948a 100644 --- a/api/src/main/resources/application.properties +++ b/api/src/main/resources/application.properties @@ -62,6 +62,13 @@ quarkus.arc.unremovable-types=com.github.streamshub.console.api.** quarkus.arc.exclude-types=io.apicurio.registry.rest.JacksonDateTimeCustomizer quarkus.arc.ignored-split-packages=io.apicurio.registry.content.*,io.apicurio.registry.rules.*, +# These properties are used to generate Java classes for the Cruise Control +# rebalance status information published by Strimzi for KafkaRebalance resources. +quarkus.openapi-generator.codegen.input-base-dir=target/schema/cruise-control/yaml +quarkus.openapi-generator.codegen.include=base.yaml +quarkus.openapi-generator.codegen.spec.base_yaml.base-package=io.streamshub.console.api.model.rebalance.cc +quarkus.openapi-generator.codegen.spec.base_yaml.generate-apis=false + quarkus.index-dependency.kafka-clients.group-id=org.apache.kafka quarkus.index-dependency.kafka-clients.artifact-id=kafka-clients quarkus.index-dependency.strimzi-api.group-id=io.strimzi diff --git a/api/src/main/webui/src/api/hooks/useNodes.ts b/api/src/main/webui/src/api/hooks/useNodes.ts index be4509ff3..c1d053c0e 100644 --- a/api/src/main/webui/src/api/hooks/useNodes.ts +++ b/api/src/main/webui/src/api/hooks/useNodes.ts @@ -5,96 +5,33 @@ import { useQuery } from '@tanstack/react-query'; import { apiClient } from '../client'; import { - NodesResponse, NodeConfigResponse, - BrokerStatus, - ControllerStatus, - NodeRoles, + Node, + NodeListMeta, } from '../types'; +import { ResourceListParams, useResourceList } from './useResourceList'; /** - * Fetch all nodes for a Kafka cluster + * Fetch all nodes for a Kafka cluster. + * + * Filter keys (pass via params.filters): + * nodePool – string array, matched with 'in' + * roles – string array, matched with 'in' + * broker.status – string array, matched with 'in' + * controller.status – string array, matched with 'in' */ export function useNodes( kafkaId: string | undefined, - params?: { - pageSize?: number; - pageCursor?: string; - sort?: string; - sortDir?: 'asc' | 'desc'; - nodePool?: string[]; - roles?: NodeRoles[]; - brokerStatus?: BrokerStatus[]; - controllerStatus?: ControllerStatus[]; - fields?: string[]; - } + params?: ResourceListParams, ) { - return useQuery({ - queryKey: [ - 'nodes', - kafkaId, - params?.pageSize, - params?.pageCursor, - params?.sort, - params?.sortDir, - params?.nodePool, - params?.roles, - params?.brokerStatus, - params?.controllerStatus, - params?.fields, - ], - queryFn: async () => { - if (!kafkaId) { - throw new Error('Kafka ID is required'); - } - - const searchParams = new URLSearchParams(); - - if (params?.pageSize) { - searchParams.set('page[size]', params.pageSize.toString()); - } - - // Handle cursor-based pagination - if (params?.pageCursor) { - if (params.pageCursor.startsWith('after:')) { - searchParams.set('page[after]', params.pageCursor.slice(6)); - } else if (params.pageCursor.startsWith('before:')) { - searchParams.set('page[before]', params.pageCursor.slice(7)); - } - } - - if (params?.sort) { - const sortPrefix = params.sortDir === 'desc' ? '-' : ''; - searchParams.set('sort', `${sortPrefix}${params.sort}`); - } - - if (params?.nodePool && params.nodePool.length > 0) { - searchParams.set('filter[nodePool]', `in,${params.nodePool.join(',')}`); - } - - if (params?.roles && params.roles.length > 0) { - searchParams.set('filter[roles]', `in,${params.roles.join(',')}`); - } - - if (params?.brokerStatus && params.brokerStatus.length > 0) { - searchParams.set('filter[broker.status]', `in,${params.brokerStatus.join(',')}`); - } - - if (params?.controllerStatus && params.controllerStatus.length > 0) { - searchParams.set('filter[controller.status]', `in,${params.controllerStatus.join(',')}`); - } - - if (params?.fields) { - searchParams.set('fields[nodes]', params.fields.join(',')); - } - - const queryString = searchParams.toString(); - const path = `/api/kafkas/${kafkaId}/nodes${queryString ? `?${queryString}` : ''}`; - - return apiClient.get(path); + return useResourceList( + 'nodes', + `/api/kafkas/${kafkaId}/nodes`, + { + ...params, + enabled: !!kafkaId && (params?.enabled ?? true), }, - enabled: !!kafkaId, - }); + ); } /** diff --git a/api/src/main/webui/src/api/hooks/useRebalances.ts b/api/src/main/webui/src/api/hooks/useRebalances.ts index 5bcf424c6..0148918b0 100644 --- a/api/src/main/webui/src/api/hooks/useRebalances.ts +++ b/api/src/main/webui/src/api/hooks/useRebalances.ts @@ -3,88 +3,37 @@ */ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import escape from '../utils/escape'; import { apiClient } from '../client'; import { - RebalancesResponse, RebalanceResponse, - RebalanceStatus, - RebalanceMode, + Rebalance, } from '../types'; +import { ResourceListParams, useResourceList } from './useResourceList'; + +const REBALANCE_FIELDS = 'name,namespace,creationTimestamp,status,mode,brokers,optimizationResult,conditions'; +const REBALANCE_DETAIL_FIELDS = `${REBALANCE_FIELDS},brokerCapacity,goals,optimizationProposal,sessionId`; /** - * Fetch all rebalances for a Kafka cluster + * Fetch all rebalances for a Kafka cluster. + * + * Filter keys (pass via params.filters): + * name – string, matched with 'like' + * status – string array, matched with 'in' + * mode – string array, matched with 'in' */ export function useRebalances( kafkaId: string | undefined, - params?: { - pageSize?: number; - pageCursor?: string; - sort?: string; - sortDir?: 'asc' | 'desc'; - name?: string; - status?: RebalanceStatus[]; - mode?: RebalanceMode[]; - } + params?: ResourceListParams, ) { - return useQuery({ - queryKey: [ - 'rebalances', - kafkaId, - params?.pageSize, - params?.pageCursor, - params?.sort, - params?.sortDir, - params?.name, - params?.status, - params?.mode, - ], - queryFn: async () => { - if (!kafkaId) { - throw new Error('Kafka ID is required'); - } - - const searchParams = new URLSearchParams(); - - // Always include these fields - searchParams.set( - 'fields[kafkaRebalances]', - 'name,namespace,creationTimestamp,status,mode,brokers,optimizationResult,conditions' - ); - - if (params?.pageSize) { - searchParams.set('page[size]', params.pageSize.toString()); - } - - // Handle cursor-based pagination - if (params?.pageCursor) { - searchParams.set('page[after]', params.pageCursor); - } - - if (params?.sort) { - const sortPrefix = params.sortDir === 'desc' ? '-' : ''; - searchParams.set('sort', `${sortPrefix}${params.sort}`); - } - - if (params?.name) { - searchParams.set('filter[name]', `like,*${escape(params.name)}*`); - } - - if (params?.status && params.status.length > 0) { - searchParams.set('filter[status]', `in,${params.status.join(',')}`); - } - - if (params?.mode && params.mode.length > 0) { - searchParams.set('filter[mode]', `in,${params.mode.join(',')}`); - } - - const queryString = searchParams.toString(); - const path = `/api/kafkas/${kafkaId}/rebalances${queryString ? `?${queryString}` : ''}`; - - return apiClient.get(path); + return useResourceList( + 'kafkaRebalances', + `/api/kafkas/${kafkaId}/rebalances`, + { + fields: REBALANCE_FIELDS, + ...params, + enabled: !!kafkaId && (params?.enabled ?? true), }, - enabled: !!kafkaId, - }); + ); } /** @@ -101,7 +50,7 @@ export function useRebalance( throw new Error('Kafka ID and Rebalance ID are required'); } - const path = `/api/kafkas/${kafkaId}/rebalances/${rebalanceId}`; + const path = `/api/kafkas/${kafkaId}/rebalances/${rebalanceId}?fields[kafkaRebalances]=${REBALANCE_DETAIL_FIELDS}`; return apiClient.get(path); }, diff --git a/api/src/main/webui/src/api/hooks/useResourceList.ts b/api/src/main/webui/src/api/hooks/useResourceList.ts index d9c1dee40..78981a08a 100644 --- a/api/src/main/webui/src/api/hooks/useResourceList.ts +++ b/api/src/main/webui/src/api/hooks/useResourceList.ts @@ -5,7 +5,7 @@ import { useQuery } from '@tanstack/react-query'; import escape from '../utils/escape'; import { apiClient } from '../client'; -import { ListResponse, Resource } from '../types'; +import { AbstractMeta, ListResponse, Resource } from '../types'; export interface ResourceListPageParams { size?: number | null; @@ -73,7 +73,7 @@ function updatePageParams(page: ResourceListPageParams, searchParams: URLSearchP } } -export function useResourceList( +export function useResourceList( resourceType: string, path: string, params?: ResourceListParams, @@ -115,7 +115,7 @@ export function useResourceList( const queryString = searchParams.toString(); const url = path + (queryString ? `?${queryString}` : ''); - return apiClient.get>(url); + return apiClient.get>(url); }, enabled: params?.enabled, refetchInterval: params?.refreshInterval, diff --git a/api/src/main/webui/src/api/types.ts b/api/src/main/webui/src/api/types.ts index 739617fdf..36f497e42 100644 --- a/api/src/main/webui/src/api/types.ts +++ b/api/src/main/webui/src/api/types.ts @@ -16,8 +16,8 @@ export type PaginationMeta = { rangeTruncated: boolean; }; -export interface ListResponse { - meta?: AbstractMeta & { +export interface ListResponse { + meta?: M & { page: PaginationMeta; }; links?: { @@ -100,6 +100,7 @@ export interface KafkaCluster extends Resource { status?: string; kafkaVersion?: string; creationTimestamp?: string; + cruiseControlEnabled?: boolean; listeners?: KafkaClusterListener[]; conditions?: KafkaClusterCondition[]; }; @@ -298,12 +299,20 @@ export interface NodePoolMeta { export type NodePools = Record; -export type Statuses = Record< +export type NodeStatuses = Record< 'brokers' | 'controllers' | 'combined', Record >; -export interface Node { +export interface NodeListMeta extends MetaWithPrivileges { + summary: { + nodePools: NodePools; + statuses: NodeStatuses; + leaderId?: string; + }; +} + +export interface Node extends Resource { id: string; type: 'nodes'; meta?: MetaWithPrivileges; @@ -332,27 +341,6 @@ export interface Node { }; } -export interface NodesResponse { - data: Node[]; - meta: { - summary: { - nodePools: NodePools; - statuses: Statuses; - leaderId?: string; - }; - page: { - total: number; - pageNumber?: number; - }; - }; - links: { - first: string | null; - prev: string | null; - next: string | null; - last: string | null; - }; -} - export interface NodeConfigResponse { data: { id?: string; @@ -486,6 +474,28 @@ export interface TopicMetricsResponse { // Time duration options for metrics (in seconds) export type MetricsDuration = 300 | 900 | 3600 | 21600 | 43200 | 86400; // 5min, 15min, 1hr, 6hr, 12hr, 1d +export interface BrokerCapacity { + cpu: string | null; + inboundNetwork: string | null; + outboundNetwork: string | null; + overrides: [{ + brokers: number[] | null; + cpu: string | null; + inboundNetwork: string | null; + outboundNetwork: string | null; + }]; +} + +export interface BrokerLoadImpact { + before?: number; + after?: number; + diff?: number; +} + +export interface OptimizationProposal { + brokerImpact?: Record> | null; +} + export interface OptimizationResult { numIntraBrokerReplicaMovements?: number; numReplicaMovements?: number; @@ -530,8 +540,11 @@ export interface Rebalance { status: RebalanceStatus | null; mode: RebalanceMode; brokers: number[] | null; + brokerCapacity?: BrokerCapacity; sessionId?: string | null; optimizationResult?: OptimizationResult; + goals?: string[] | null; + optimizationProposal?: OptimizationProposal | null; conditions?: RebalanceCondition[] | null; }; } diff --git a/api/src/main/webui/src/components/StatusLabel/configs/rebalanceStatusConfig.ts b/api/src/main/webui/src/components/StatusLabel/configs/rebalanceStatusConfig.ts index d53048db8..b7111ee90 100644 --- a/api/src/main/webui/src/components/StatusLabel/configs/rebalanceStatusConfig.ts +++ b/api/src/main/webui/src/components/StatusLabel/configs/rebalanceStatusConfig.ts @@ -30,41 +30,49 @@ export function createRebalanceStatusConfig( return { New: { icon: ExclamationCircleIcon, + iconStatus: 'info', label: t('rebalancing.statuses.new.label'), tooltip: t('rebalancing.statuses.new.tooltip'), }, PendingProposal: { icon: PendingIcon, + iconStatus: 'info', label: t('rebalancing.statuses.pendingProposal.label'), tooltip: t('rebalancing.statuses.pendingProposal.tooltip'), }, ProposalReady: { icon: CheckIcon, + iconStatus: 'info', label: t('rebalancing.statuses.proposalReady.label'), tooltip: t('rebalancing.statuses.proposalReady.tooltip'), }, Stopped: { icon: PauseCircleIcon, // Note: Component may replace with custom stop icon + iconStatus: 'warning', label: t('rebalancing.statuses.stopped.label'), tooltip: t('rebalancing.statuses.stopped.tooltip'), }, Rebalancing: { icon: PendingIcon, + iconStatus: 'info', label: t('rebalancing.statuses.rebalancing.label'), tooltip: t('rebalancing.statuses.rebalancing.tooltip'), }, NotReady: { icon: OutlinedClockIcon, + iconStatus: 'danger', label: t('rebalancing.statuses.notReady.label'), tooltip: t('rebalancing.statuses.notReady.tooltip'), }, Ready: { icon: CheckIcon, + iconStatus: 'success', label: t('rebalancing.statuses.ready.label'), tooltip: t('rebalancing.statuses.ready.tooltip'), }, ReconciliationPaused: { icon: PauseCircleIcon, + iconStatus: 'warning', label: t('rebalancing.statuses.reconciliationPaused.label'), tooltip: t('rebalancing.statuses.reconciliationPaused.tooltip'), }, diff --git a/api/src/main/webui/src/components/common/ResourceListDataView.tsx b/api/src/main/webui/src/components/common/ResourceListDataView.tsx index 36c582782..756cd798d 100644 --- a/api/src/main/webui/src/components/common/ResourceListDataView.tsx +++ b/api/src/main/webui/src/components/common/ResourceListDataView.tsx @@ -14,6 +14,7 @@ import { DataViewState, useDataViewSort, DataViewTextFilterProps, + ExpandableContent, } from '@patternfly/react-data-view'; /* * The following import is a work-around for @@ -183,8 +184,17 @@ export interface ResourceListDataViewColumnMapper { ): DataViewTh[]; } +export interface ResourceListDataViewRowResult { + row: DataViewTr; + expandedRows?: ExpandableContent[]; +} + +function isRowResult(v: DataViewTr | ResourceListDataViewRowResult): v is ResourceListDataViewRowResult { + return v !== null && typeof v === 'object' && !Array.isArray(v) && 'expandedRows' in v; +} + export interface ResourceListDataViewRowMapper { - (entity: T): DataViewTr; + (entity: T): DataViewTr | ResourceListDataViewRowResult; } export interface ResourceListDataViewProps { @@ -434,17 +444,17 @@ export function ResourceListDataView({ return columnProvider.callback(sortBy, direction, onSort); }, [sortBy, direction, onSort, columnProvider]); - // Determine the active state, errors, and table rows for DataView - const [ activeState, errors, rows ] = useMemo(() => { + // Determine the active state, errors, table rows, and expanded rows for DataView + const [ activeState, errors, rows, expandedRows ] = useMemo(() => { if (resourceResult.isLoading) { - return [ DataViewState.loading, undefined, [] ]; + return [ DataViewState.loading, undefined, [], [] ]; } if (resourceResult?.error) { const e = resourceResult.error; if (e instanceof ApiError) { - return [ DataViewState.error, e.errors, [] ]; + return [ DataViewState.error, e.errors, [], [] ]; } const errObjects = [{ @@ -452,18 +462,18 @@ export function ResourceListDataView({ detail: e.toString(), }]; - return [ DataViewState.error, errObjects, [] ]; + return [ DataViewState.error, errObjects, [], [] ]; } if (listResponse?.data && listResponse?.data.length === 0) { - return [ DataViewState.empty, [], [] ]; + return [ DataViewState.empty, [], [], [] ]; } - return [ - undefined, - [], - listResponse?.data?.map(entry => rowProvider.callback(entry)) ?? [] - ]; + const results = listResponse?.data?.map(entry => rowProvider.callback(entry)) ?? []; + const tableRows = results.map(r => isRowResult(r) ? r.row : r); + const allExpandedRows = results.flatMap(r => isRowResult(r) ? (r.expandedRows ?? []) : []); + + return [ undefined, [], tableRows, allExpandedRows ]; }, [ resourceResult.isLoading, resourceResult.error, listResponse, rowProvider ]); useEffect(() => { @@ -630,6 +640,8 @@ export function ResourceListDataView({ ouiaId={`${ouiaIdPrefix}-table`} columns={columns} rows={rows} + isExpandable={expandedRows.length > 0} + expandedRows={expandedRows} headStates={{ [DataViewState.loading]: headLoading }} diff --git a/api/src/main/webui/src/components/kafka/nodes/BrokerImpactTable.tsx b/api/src/main/webui/src/components/kafka/nodes/BrokerImpactTable.tsx new file mode 100644 index 000000000..6af9438f8 --- /dev/null +++ b/api/src/main/webui/src/components/kafka/nodes/BrokerImpactTable.tsx @@ -0,0 +1,481 @@ +import { useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Button, + EmptyState, + EmptyStateBody, + Pagination, + SearchInput, + Select, + SelectList, + SelectOption, + MenuToggle, + Switch, + Toolbar, + ToolbarContent, + ToolbarFilter, + ToolbarGroup, + ToolbarItem, +} from '@patternfly/react-core'; +import { Table, Tbody, Td, Th, Thead, Tr, ThProps, InnerScrollContainer } from '@patternfly/react-table'; +import { BrokerCapacity, BrokerLoadImpact } from '@/api/types'; + +interface BrokerImpactTableProps { + brokerCapacity?: BrokerCapacity; + brokerImpact: Record> | null | undefined; +} + +interface BrokerRow { + brokerId: string; + metrics: Record; +} + +/** + * Column groups shown in the table, in display order. + * pctKey drives the bar fill and delta colouring. + * absKey (optional) is shown alongside the percentage inside the bar label. + */ +const COLUMN_GROUPS: Array<{ + label: string; + pctKey?: string; + absKey?: string; + absUnit?: string; +}> = [ + { label: 'Storage', pctKey: 'diskUsedPercentage', absKey: 'diskUsedMB', absUnit: 'MB' }, + { label: 'CPU', pctKey: 'cpuPercentage' }, + { label: 'Leaders', absKey: 'leaders' }, + { label: 'Followers', absKey: 'replicas' }, + { label: 'Network In', absKey: 'leaderNetworkInRateKB', absUnit: "KB/s" }, + { label: 'Network Out', absKey: 'networkOutRateKB', absUnit: "KB/s" }, +]; + +// Sortable column identifiers +type SortKey = + | 'brokerId' + | `${string}-before` + | `${string}-after` + | `${string}-delta`; + +/** Bar with the value label centred inside it. */ +function BarCell({ + pct, + label, +}: { + pct: number | null | undefined; + label: string; +}) { + if (pct == null) return ; + + const barColor = 'var(--pf-t--global--color--brand--default)'; + const fill = Math.min(100, Math.max(0, pct)); + + return ( + + + + {label} + + + ); +} + +function formatDiff(diff: number): string { + const sign = diff > 0 ? '+' : ''; + return `${sign}${diff % 1 === 0 ? String(diff) : diff.toFixed(2)}`; +} + +function formatFixed(positions: number, val?: number): string { + if (val) { + if (Number.isInteger(val)) { + return val.toString(); + } else { + return val.toFixed(positions); + } + } + return ''; +} + +function DeltaCell({ + pctDiff, + absDiff, + absUnit, +}: { + pctDiff?: number; + absDiff?: number; + absUnit?: string; +}) { + if (absDiff === 0 && pctDiff === 0) { + return -; + } + + return ( + + {absDiff !== undefined ? <>{formatDiff(absDiff)} {absUnit} : <>} + {absDiff !== undefined && pctDiff !== undefined ? <> /  : <>} + {pctDiff !== undefined ? <>{formatDiff(pctDiff ?? '-')}% : <>} + + ); +} + +/** Label shown inside the bar: "123 MB – 45%" or just "45%". */ +function buildBarLabel(pct: number, abs: number | null | undefined): string { + const pctStr = `${pct.toFixed(1)}%`; + if (abs == null) return pctStr; + const absMB = abs % 1 === 0 ? String(abs) : abs.toFixed(1); + return `${absMB} MB – ${pctStr}`; +} + +function buildBrokerCapacity(id: string, brokerCapacity?: BrokerCapacity): string { + if (brokerCapacity) { + const brokerId = parseInt(id); + + const capacity: { + cpu: string | null; + inboundNetwork: string | null; + outboundNetwork: string | null; + } = brokerCapacity.overrides?.find(o => o.brokers?.includes(brokerId)) + ?? brokerCapacity; + + const elements = [ + (capacity.cpu ? 'CPU: ' + capacity.cpu : null), + (capacity.inboundNetwork ? capacity.inboundNetwork + ' in' : null), + (capacity.outboundNetwork ? capacity.outboundNetwork + ' out' : null) + ]; + + return elements.filter(s => s != null).join(", "); + } + + return '-'; +} + +const DEFAULT_PAGE_SIZE = 20; + +export function BrokerImpactTable({ + brokerCapacity, + brokerImpact +}: BrokerImpactTableProps) { + const { t } = useTranslation(); + + const [nameFilter, setNameFilter] = useState(''); + const [selectedBrokers, setSelectedBrokers] = useState([]); + const [isBrokerSelectOpen, setIsBrokerSelectOpen] = useState(false); + const [onlyDeltas, setOnlyDeltas] = useState(false); + + // Sort state + const [sortKey, setSortKey] = useState('brokerId'); + const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc'); + + // Pagination state + const [page, setPage] = useState(1); + const [perPage, setPerPage] = useState(DEFAULT_PAGE_SIZE); + + // Only include groups whose pctKey is actually present in the data + const activeGroups = useMemo(() => { + if (!brokerImpact) return []; + return COLUMN_GROUPS.filter((g) => + Object.values(brokerImpact).some((m) => (g.absKey ?? '' in m) || (g.pctKey ?? '' in m)), + ); + }, [brokerImpact]); + + // Flat rows sorted by current sort state + const allRows = useMemo((): BrokerRow[] => { + if (!brokerImpact) return []; + return Object.entries(brokerImpact) + .map(([brokerId, metrics]) => ({ brokerId, metrics })) + .sort((a, b) => { + // eslint-disable-next-line no-useless-assignment + let result = 0; + + if (sortKey === 'brokerId') { + const aNum = parseInt(a.brokerId, 10); + const bNum = parseInt(b.brokerId, 10); + result = !isNaN(aNum) && !isNaN(bNum) ? aNum - bNum : a.brokerId.localeCompare(b.brokerId); + } else { + // sortKey is "-before", "-after", or "-delta" + const lastDash = sortKey.lastIndexOf('-'); + const metricKey = sortKey.slice(0, lastDash) as string; + const slot = sortKey.slice(lastDash + 1) as 'before' | 'after' | 'delta'; + const aVal = slot === 'delta' ? (a.metrics[metricKey]?.diff ?? 0) : (a.metrics[metricKey]?.[slot === 'before' ? 'before' : 'after'] ?? 0); + const bVal = slot === 'delta' ? (b.metrics[metricKey]?.diff ?? 0) : (b.metrics[metricKey]?.[slot === 'before' ? 'before' : 'after'] ?? 0); + result = (aVal as number) - (bVal as number); + } + + return sortDirection === 'asc' ? result : -result; + }); + }, [brokerImpact, sortKey, sortDirection]); + + const brokerIds = useMemo(() => allRows.map((r) => r.brokerId), [allRows]); + + const filteredRows = useMemo(() => { + return allRows.filter((row) => { + if (nameFilter && !row.brokerId.toLowerCase().includes(nameFilter.toLowerCase())) { + return false; + } + if (selectedBrokers.length > 0 && !selectedBrokers.includes(row.brokerId)) { + return false; + } + if (onlyDeltas) { + const hasAnyDelta = activeGroups.some((g) => { + const impact = row.metrics[g.absKey ?? ''] ?? row.metrics[g.pctKey ?? '']; + return impact?.diff != null && impact.diff !== 0; + }); + if (!hasAnyDelta) return false; + } + return true; + }); + }, [allRows, nameFilter, selectedBrokers, onlyDeltas, activeGroups]); + + const pagedRows = useMemo(() => { + const start = (page - 1) * perPage; + return filteredRows.slice(start, start + perPage); + }, [filteredRows, page, perPage]); + + const toggleBroker = (brokerId: string) => { + setSelectedBrokers((prev) => + prev.includes(brokerId) ? prev.filter((b) => b !== brokerId) : [...prev, brokerId], + ); + }; + + const handleSort = (key: SortKey) => { + if (sortKey === key) { + setSortDirection((d) => (d === 'asc' ? 'desc' : 'asc')); + } else { + setSortKey(key); + setSortDirection('asc'); + } + setPage(1); + }; + + const getSortParams = (key: SortKey): ThProps['sort'] => ({ + sortBy: { + index: 0, + direction: sortKey === key ? sortDirection : undefined, + }, + onSort: () => handleSort(key), + columnIndex: 0, + }); + + if (!brokerImpact) { + return ( + + {t('rebalancing.brokerImpact.noData')} + + ); + } + + const brokerFilterLabels = selectedBrokers.map((b) => t('rebalancing.broker', { b })); + + const colCount = 1 + activeGroups.length * (onlyDeltas ? 1 : 3); + + return ( + <> + { setNameFilter(''); setSelectedBrokers([]); setPage(1); }}> + + + + { setNameFilter(val); setPage(1); }} + onClear={() => { setNameFilter(''); setPage(1); }} + /> + + { + const brokerId = brokerIds.find( + (b) => t('rebalancing.broker', { b }) === chip, + ); + if (brokerId) toggleBroker(brokerId); + }} + deleteLabelGroup={() => { setSelectedBrokers([]); setPage(1); }} + categoryName={t('rebalancing.brokerImpact.brokers')} + > + + + + + { setOnlyDeltas(checked); setPage(1); }} + /> + + {(nameFilter || selectedBrokers.length > 0) && ( + + + + )} + + setPage(newPage)} + onPerPageSelect={(_, newPerPage) => { setPerPage(newPerPage); setPage(1); }} + variant="top" + /> + + + + + + + + + + {activeGroups.map((g) => ( + <> + {!onlyDeltas && ( + + )} + {!onlyDeltas && ( + + )} + + + ))} + + + + + {pagedRows.length === 0 ? ( + + + + ) : ( + pagedRows.map((row) => ( + + + {activeGroups.map((g) => { + const pctImpact = g.pctKey ? row.metrics[g.pctKey] : undefined; + const absImpact = g.absKey ? row.metrics[g.absKey] : undefined; + return ( + <> + {!onlyDeltas && ( + + )} + {!onlyDeltas && ( + + )} + + + ); + })} + + + )) + )} + +
+ {t('rebalancing.brokerImpact.broker')} + + {g.label} {t('rebalancing.brokerImpact.before')} + + {g.label} {t('rebalancing.brokerImpact.after')} + + {g.label} Δ + + {t('rebalancing.brokerImpact.brokerCapacity')} +
+ + {t('rebalancing.brokerImpact.noResults')} + +
+ {t('rebalancing.broker', { b: row.brokerId })} + + {pctImpact + ? + : <>{formatFixed(2, absImpact?.before)} {g?.absUnit}} + + {pctImpact + ? + : <>{formatFixed(2, absImpact?.after)} {g?.absUnit}} + + + + {buildBrokerCapacity(row.brokerId, brokerCapacity)} +
+
+ + setPage(newPage)} + onPerPageSelect={(_, newPerPage) => { setPerPage(newPerPage); setPage(1); }} + variant="bottom" + /> + + ); +} diff --git a/api/src/main/webui/src/components/kafka/nodes/NodeChartsCard.tsx b/api/src/main/webui/src/components/kafka/nodes/NodeChartsCard.tsx new file mode 100644 index 000000000..1a76cb2e9 --- /dev/null +++ b/api/src/main/webui/src/components/kafka/nodes/NodeChartsCard.tsx @@ -0,0 +1,90 @@ +import { useTranslation } from 'react-i18next'; +import { + Card, + CardBody, + CardHeader, + CardTitle, + Divider, + Stack, + StackItem, + Tooltip, +} from '@patternfly/react-core'; +import { ChartLineIcon, HelpIcon } from '@patternfly/react-icons'; +import { UseQueryResult } from '@tanstack/react-query'; +import { ListResponse, Node, NodeListMeta } from '@/api/types'; +import { ChartSkeletonLoader } from '@/components/kafka/overview/ChartSkeletonLoader'; +import { ChartNodeStorageUsage } from './charts/ChartNodeStorageUsage'; +import { ChartPartitionDistribution } from './charts/ChartPartitionDistribution'; + +export interface NodeChartsCardProps { + nodeResult: UseQueryResult, Error>; +} + +export function NodeChartsCard({ nodeResult }: NodeChartsCardProps) { + const { t } = useTranslation(); + const nodes = nodeResult.data?.data ?? []; + + return ( + + + + + {t('nodes.charts.title')} + + + + + {nodeResult.isLoading ? ( + <> + + + + + + + + + + + ) : ( + <> + +
+ {t('nodes.charts.storageUsage')} +
+
+ {t('nodes.charts.storageUsageSubtitle')}{' '} + + + +
+
+ + + + + + + + + +
+ {t('nodes.charts.partitionDistribution')} +
+
+ {t('nodes.charts.partitionDistributionSubtitle')}{' '} + + + +
+
+ + + + + )} +
+
+
+ ); +} diff --git a/api/src/main/webui/src/components/kafka/nodes/NodeStatusLabel.tsx b/api/src/main/webui/src/components/kafka/nodes/NodeStatusLabel.tsx index 01124e949..26129c0de 100644 --- a/api/src/main/webui/src/components/kafka/nodes/NodeStatusLabel.tsx +++ b/api/src/main/webui/src/components/kafka/nodes/NodeStatusLabel.tsx @@ -18,7 +18,7 @@ import { InProgressIcon, PendingIcon, } from '@patternfly/react-icons'; -import type { BrokerStatus, ControllerStatus, NodeRoles, Statuses } from '@/api/types'; +import type { BrokerStatus, ControllerStatus, NodeRoles, NodeStatuses } from '@/api/types'; // Icon component for new process (recovery status) const NewProcessIcon = () => ( @@ -39,7 +39,7 @@ const NewProcessIcon = () => ( * Role labels with counts */ export const useRoleLabels = ( - statuses?: Statuses + statuses?: NodeStatuses ): Record => { const { t } = useTranslation(); @@ -301,4 +301,4 @@ export const useControllerStatusLabelsWithCount = ( ): Record => { const labels = useControllerStatusLabels(); return generateStatusLabelsWithCount(labels, statuses); -}; \ No newline at end of file +}; diff --git a/api/src/main/webui/src/components/kafka/nodes/NodesDataView.tsx b/api/src/main/webui/src/components/kafka/nodes/NodesDataView.tsx new file mode 100644 index 000000000..f90e3b108 --- /dev/null +++ b/api/src/main/webui/src/components/kafka/nodes/NodesDataView.tsx @@ -0,0 +1,267 @@ +import { useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Link } from 'react-router'; +import { ThProps } from '@patternfly/react-table'; +import { UseQueryResult } from '@tanstack/react-query'; +import { + Flex, + FlexItem, + Label, + Tooltip, +} from '@patternfly/react-core'; +import { HelpIcon } from '@patternfly/react-icons'; +import { Node, ListResponse, BrokerStatus, ControllerStatus, NodeListMeta } from '@/api/types'; +import { ResourceListParams } from '@/api/hooks/useResourceList'; +import { + ResourceListDataView, + ResourceListDataViewColumnMapper, + ResourceListDataViewRowMapper, + ResourceListDataViewRowResult, +} from '@/components/common/ResourceListDataView'; +import { + useRoleLabels, + useBrokerStatusLabels, + useControllerStatusLabels, +} from './NodeStatusLabel'; +import { formatNumber } from '@/utils/format'; + +const columnNames = ['id', 'roles', 'status', 'replicas', 'rack', 'nodePool'] as const; + +interface NodesDataViewProps { + kafkaId: string; + nodeResult: UseQueryResult, Error>; + onDataViewChange: (params: ResourceListParams) => void; +} + +export function NodesDataView({ + kafkaId, + nodeResult, + onDataViewChange, +}: NodesDataViewProps) { + const { t } = useTranslation(); + const roleLabels = useRoleLabels(); + const brokerStatusLabels = useBrokerStatusLabels(); + const controllerStatusLabels = useControllerStatusLabels(); + + const nodePoolFilterOptions = useMemo(() => { + const nodePools = nodeResult.data?.meta?.summary?.nodePools; + if (!nodePools) return []; + return Object.entries(nodePools).map(([name, meta]) => ({ + value: name, + label: ( + <> + + {name} + + {meta.count} + + +
+ {t('nodes.filter.nodePoolRoles', { roles: meta.roles.join(', ') })} +
+ + ), + })); + }, [nodeResult.data?.meta, t]); + + const handleSort = useCallback(( + onSort: ((event: React.MouseEvent, sortBy: string, direction: 'asc' | 'desc') => void) | undefined, + event: React.MouseEvent, + columnIndex: number, + direction: 'asc' | 'desc', + ) => { + onSort?.(event, columnNames[columnIndex], direction); + }, []); + + const colMapper: ResourceListDataViewColumnMapper = useCallback( + (sortBy, direction, onSort) => [ + { + cell: t('nodes.nodeId'), + props: { + sort: { + sortBy: { + index: sortBy ? columnNames.indexOf(sortBy as typeof columnNames[number]) : undefined, + direction, + }, + columnIndex: 0, + onSort: (event, columnIndex, dir) => handleSort(onSort, event, columnIndex, dir), + } as ThProps['sort'], + }, + }, + { cell: t('nodes.roles') }, + { cell: t('nodes.status') }, + { cell: t('nodes.kafkaVersion') }, + { + cell: ( + <> + {t('nodes.replicas')}{' '} + + + + + ), + props: { modifier: 'fitContent', style: { textAlign: 'right' } }, + }, + { + cell: ( + <> + {t('nodes.leaders')}{' '} + + + + + ), + props: { modifier: 'fitContent', style: { textAlign: 'right' } }, + }, + { + cell: ( + <> + {t('nodes.rack')}{' '} + + + + + ), + }, + { cell: t('nodes.nodePool') }, + ], + [t, handleSort], + ); + + const colProvider = useMemo(() => ({ + dependencies: [t, handleSort], + callback: colMapper, + }), [colMapper, t, handleSort]); + + const rowMapper: ResourceListDataViewRowMapper = useCallback( + (node): ResourceListDataViewRowResult => { + return { + row: [ + { + cell: ( + <> + {node.meta?.privileges?.includes('GET') === true ? ( + + {node.id} + + ) : ( + node.id + )} + {node.attributes.metadataState?.status === 'leader' && ( + + )} + + ), + props: { dataLabel: t('nodes.nodeId'), modifier: 'nowrap' }, + }, + { + cell: ( + <>{node.attributes.roles?.map((role) => ( +
{roleLabels[role].label}
+ ))} + ), + props: { dataLabel: t('nodes.roles'), modifier: 'nowrap' }, + }, + { + cell: ( + <> +
+ {node.attributes.broker && brokerStatusLabels[node.attributes.broker.status]} +
+
+ {node.attributes.controller && controllerStatusLabels[node.attributes.controller.status]} +
+ + ), + props: { dataLabel: t('nodes.status'), modifier: 'nowrap' }, + }, + { + cell: node.attributes.kafkaVersion, + props: { dataLabel: t('nodes.kafkaVersion'), modifier: 'nowrap' }, + }, + { + cell: typeof node.attributes.broker?.leaderCount === 'number' && + typeof node.attributes.broker?.replicaCount === 'number' + ? formatNumber(node.attributes.broker.leaderCount + node.attributes.broker.replicaCount) + : '-', + props: { dataLabel: t('nodes.replicas'), modifier: 'fitContent', style: { textAlign: 'right' } }, + }, + { + cell: typeof node.attributes.broker?.leaderCount === 'number' + ? formatNumber(node.attributes.broker.leaderCount) + : '-', + props: { dataLabel: t('nodes.leaders'), modifier: 'fitContent', style: { textAlign: 'right' } }, + }, + { + cell: node.attributes.rack || 'n/a', + props: { dataLabel: t('nodes.rack'), modifier: 'nowrap' }, + }, + { + cell: node.attributes.nodePool || 'n/a', + props: { dataLabel: t('nodes.nodePool'), modifier: 'nowrap' }, + }, + ], + }; + }, + [kafkaId, t, roleLabels, brokerStatusLabels, controllerStatusLabels], + ); + + const rowProvider = useMemo(() => ({ + dependencies: [kafkaId, t, roleLabels, brokerStatusLabels, controllerStatusLabels], + callback: rowMapper, + }), [rowMapper, kafkaId, t, roleLabels, brokerStatusLabels, controllerStatusLabels]); + + + return ( + ({ + value: role, + label: roleLabels[role].label, + })), + }, + 'broker.status': { + type: 'checkbox', + title: t('nodes.filter.brokerStatus'), + placeholder: t('nodes.filter.statusPlaceholder'), + options: (Object.keys(brokerStatusLabels) as BrokerStatus[]).map((status) => ({ + value: status, + label: brokerStatusLabels[status], + })), + }, + 'controller.status': { + type: 'checkbox', + title: t('nodes.filter.controllerStatus'), + placeholder: t('nodes.filter.statusPlaceholder'), + options: (Object.keys(controllerStatusLabels) as ControllerStatus[]).map((status) => ({ + value: status, + label: controllerStatusLabels[status], + })), + }, + }} + columnProvider={colProvider} + rowProvider={rowProvider} + /> + ); +} diff --git a/api/src/main/webui/src/components/kafka/nodes/ProposalDetailCard.tsx b/api/src/main/webui/src/components/kafka/nodes/ProposalDetailCard.tsx new file mode 100644 index 000000000..db5ccb917 --- /dev/null +++ b/api/src/main/webui/src/components/kafka/nodes/ProposalDetailCard.tsx @@ -0,0 +1,252 @@ +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Card, + CardBody, + DescriptionList, + DescriptionListDescription, + DescriptionListGroup, + DescriptionListTerm, + ExpandableSection, + Flex, + FlexItem, + Tooltip, +} from '@patternfly/react-core'; +import { HelpIcon } from '@patternfly/react-icons'; +import { Rebalance } from '@/api/types'; + +interface ProposalDetailCardProps { + rebalance: Rebalance; +} + +interface StatTileProps { + value: string | number; + label: string | React.ReactNode; +} + +function StatTile({ value, label }: StatTileProps) { + return ( + + +

+ {value} +

+ + {label} + +
+
+ ); +} + +export function ProposalDetailCard({ rebalance }: ProposalDetailCardProps) { + const { t } = useTranslation(); + const [isExpanded, setIsExpanded] = useState(false); + + const opt = rebalance.attributes.optimizationResult; + const sessionId = rebalance.attributes.sessionId; + + return ( + + + setIsExpanded(expanded)} + > + {/* Summary stat tiles */} + + + + {t('rebalancing.optimizationProposal.numReplicaMovements')}{' '} + + + + + } + /> + + + + {t('rebalancing.optimizationProposal.numLeaderMovements')}{' '} + + + + + } + /> + + + + {t('rebalancing.optimizationProposal.dataToMove')}{' '} + + + + + } + /> + + + + {/* Detailed description list */} + {opt ? ( + + + + {t('rebalancing.optimizationProposal.sessionId')}{' '} + + + + + + {sessionId ?? '–'} + + + + + + {t('rebalancing.optimizationProposal.recentWindows')}{' '} + + + + + + {opt.recentWindows ?? '-'} + + + + + + {t('rebalancing.optimizationProposal.onDemandBalancednessScoreBefore')}{' '} + + + + + + {opt.onDemandBalancednessScoreBefore ?? '-'} + + + + + + {t('rebalancing.optimizationProposal.onDemandBalancednessScoreAfter')}{' '} + + + + + + {opt.onDemandBalancednessScoreAfter ?? '-'} + + + + + + {t('rebalancing.optimizationProposal.numIntraBrokerReplicaMovements')}{' '} + + + + + + {opt.numIntraBrokerReplicaMovements ?? '-'} + + + + + + {t('rebalancing.optimizationProposal.intraBrokerDataToMove')}{' '} + + + + + + {opt.intraBrokerDataToMoveMB ? opt.intraBrokerDataToMoveMB + ' MB' : '-'} + + + + + + {t('rebalancing.optimizationProposal.excludedBrokersForReplicaMove')}{' '} + + + + + + {opt.excludedBrokersForReplicaMove?.length + ? opt.excludedBrokersForReplicaMove.join(', ') + : '–'} + + + + + + {t('rebalancing.optimizationProposal.excludedBrokersForLeadership')}{' '} + + + + + + {opt.excludedBrokersForLeadership?.length + ? opt.excludedBrokersForLeadership.join(', ') + : '–'} + + + + + + {t('rebalancing.optimizationProposal.excludedTopics')}{' '} + + + + + + {opt.excludedTopics?.length ? opt.excludedTopics.join(', ') : '–'} + + + + + + {t('rebalancing.optimizationProposal.monitoredPartitionsPercentage')}{' '} + + + + + + {opt.monitoredPartitionsPercentage ?? '-'} + + + + + ) : ( +

+ {t('rebalancing.proposalDetail.noProposalData')} +

+ )} +
+
+
+ ); +} diff --git a/api/src/main/webui/src/components/kafka/nodes/RebalanceModal.tsx b/api/src/main/webui/src/components/kafka/nodes/RebalanceModal.tsx deleted file mode 100644 index f04150364..000000000 --- a/api/src/main/webui/src/components/kafka/nodes/RebalanceModal.tsx +++ /dev/null @@ -1,226 +0,0 @@ -/** - * Rebalance Modal Component - * Displays optimization proposal details for a Kafka rebalance - */ - -import { useTranslation } from 'react-i18next'; -import { - Modal, - ModalVariant, - Button, - DescriptionList, - DescriptionListGroup, - DescriptionListTerm, - DescriptionListDescription, - Tooltip, -} from '@patternfly/react-core'; -import { HelpIcon } from '@patternfly/react-icons'; -import { Rebalance } from '@/api/types'; - -interface RebalanceModalProps { - rebalance: Rebalance | null; - isOpen: boolean; - onClose: () => void; -} - -export function RebalanceModal({ rebalance, isOpen, onClose }: RebalanceModalProps) { - const { t } = useTranslation(); - - if (!rebalance) { - return null; - } - - const optimizationResult = rebalance.attributes.optimizationResult; - const sessionId = rebalance.attributes.sessionId; - - return ( - -
-

{t('rebalancing.optimizationProposal.description')}

- - - - {t('rebalancing.optimizationProposal.dataToMove')}{' '} - - - - - - {optimizationResult?.dataToMoveMB || 0} MB - - - - - - {t('rebalancing.optimizationProposal.excludedBrokersForLeadership')}{' '} - - - - - - {optimizationResult?.excludedBrokersForLeadership && - optimizationResult.excludedBrokersForLeadership.length > 0 - ? optimizationResult.excludedBrokersForLeadership.join(', ') - : '-'} - - - - - - {t('rebalancing.optimizationProposal.excludedBrokersForReplicaMove')}{' '} - - - - - - {optimizationResult?.excludedBrokersForReplicaMove && - optimizationResult.excludedBrokersForReplicaMove.length > 0 - ? optimizationResult.excludedBrokersForReplicaMove.join(', ') - : '-'} - - - - - - {t('rebalancing.optimizationProposal.excludedTopics')}{' '} - - - - - - {optimizationResult?.excludedTopics && optimizationResult.excludedTopics.length > 0 - ? optimizationResult.excludedTopics.join(', ') - : '-'} - - - - - - {t('rebalancing.optimizationProposal.intraBrokerDataToMove')}{' '} - - - - - - {optimizationResult?.intraBrokerDataToMoveMB || 0} - - - - - - {t('rebalancing.optimizationProposal.monitoredPartitionsPercentage')}{' '} - - - - - - {optimizationResult?.monitoredPartitionsPercentage || 0} - - - - - - {t('rebalancing.optimizationProposal.numIntraBrokerReplicaMovements')}{' '} - - - - - - {optimizationResult?.numIntraBrokerReplicaMovements || 0} - - - - - - {t('rebalancing.optimizationProposal.numLeaderMovements')}{' '} - - - - - - {optimizationResult?.numLeaderMovements || 0} - - - - - - {t('rebalancing.optimizationProposal.numReplicaMovements')}{' '} - - - - - - {optimizationResult?.numReplicaMovements || 0} - - - - - - {t('rebalancing.optimizationProposal.onDemandBalancednessScoreAfter')}{' '} - - - - - - {optimizationResult?.onDemandBalancednessScoreAfter || 0} - - - - - - {t('rebalancing.optimizationProposal.onDemandBalancednessScoreBefore')}{' '} - - - - - - {optimizationResult?.onDemandBalancednessScoreBefore || 0} - - - - - - {t('rebalancing.optimizationProposal.recentWindows')}{' '} - - - - - - {optimizationResult?.recentWindows || 0} - - - - - - {t('rebalancing.optimizationProposal.sessionId')}{' '} - - - - - {sessionId ?? '-'} - - -
-
- -
-
- ); -} \ No newline at end of file diff --git a/api/src/main/webui/src/components/kafka/nodes/RebalancesDataView.tsx b/api/src/main/webui/src/components/kafka/nodes/RebalancesDataView.tsx new file mode 100644 index 000000000..5f00fb847 --- /dev/null +++ b/api/src/main/webui/src/components/kafka/nodes/RebalancesDataView.tsx @@ -0,0 +1,335 @@ +import { useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Link } from 'react-router'; +import { DataViewTd } from '@patternfly/react-data-view'; +import { ThProps, ActionsColumn } from '@patternfly/react-table'; +import { UseQueryResult } from '@tanstack/react-query'; +import { + DescriptionList, + DescriptionListDescription, + DescriptionListGroup, + DescriptionListTerm, + Flex, + FlexItem, + List, + ListItem, + Popover, +} from '@patternfly/react-core'; +import { AngleRightIcon, HelpIcon } from '@patternfly/react-icons'; +import { Rebalance, ListResponse } from '@/api/types'; +import { ResourceListParams } from '@/api/hooks/useResourceList'; +import { + ResourceListDataView, + ResourceListDataViewColumnMapper, + ResourceListDataViewRowMapper, + ResourceListDataViewRowResult, +} from '@/components/common/ResourceListDataView'; +import { StatusLabel } from '@/components/StatusLabel'; +import { createRebalanceStatusConfig } from '@/components/StatusLabel/configs'; +import { hasPrivilege } from '@/utils/privileges'; +import { formatDateTime } from '@/utils/dateTime'; + +const columnNames = ['name', 'status', 'lastUpdated', 'dataToMove', 'partitionsToMove', 'leadershipUpdates', 'estTimeToComplete'] as const; + +function getLastUpdated(rebalance: Rebalance): string { + const statusCondition = rebalance.attributes.conditions?.find( + (c) => c.type === rebalance.attributes.status, + ); + return statusCondition?.lastTransitionTime || rebalance.attributes.creationTimestamp || ''; +} + +interface RebalancesDataViewProps { + kafkaId: string; + rebalanceResult: UseQueryResult, Error>; + onDataViewChange: (params: ResourceListParams) => void; + onApprove: (rebalance: Rebalance) => void; + onStop: (rebalance: Rebalance) => void; + onRefresh: (rebalance: Rebalance) => void; +} + +export function RebalancesDataView({ + kafkaId, + rebalanceResult, + onDataViewChange, + onApprove, + onStop, + onRefresh, +}: RebalancesDataViewProps) { + const { t } = useTranslation(); + const statusConfig = useMemo(() => createRebalanceStatusConfig(t), [t]); + + const handleSort = useCallback(( + onSort: ((event: React.MouseEvent, sortBy: string, direction: 'asc' | 'desc') => void) | undefined, + event: React.MouseEvent, + columnIndex: number, + direction: 'asc' | 'desc', + ) => { + onSort?.(event, columnNames[columnIndex], direction); + }, []); + + const colMapper: ResourceListDataViewColumnMapper = useCallback( + (sortBy, direction, onSort) => [ + { + // expander column, + cell: '' + }, + { + cell: t('rebalancing.rebalanceName'), + props: { + width: 30, + sort: { + sortBy: { + index: sortBy ? columnNames.indexOf(sortBy as typeof columnNames[number]) : undefined, + direction, + }, + columnIndex: 0, + onSort: (event, columnIndex, dir) => handleSort(onSort, event, columnIndex, dir), + } as ThProps['sort'], + }, + }, + { + cell: t('rebalancing.status'), + props: { + sort: { + sortBy: { + index: sortBy ? columnNames.indexOf(sortBy as typeof columnNames[number]) : undefined, + direction, + }, + columnIndex: 1, + onSort: (event, columnIndex, dir) => handleSort(onSort, event, columnIndex, dir), + } as ThProps['sort'], + }, + }, + { cell: t('rebalancing.dataToMove'), props: { modifier: 'nowrap' } }, + { cell: t('rebalancing.partitionsToMove'), props: { modifier: 'nowrap' } }, + { cell: t('rebalancing.leadershipUpdates'), props: { modifier: 'nowrap' } }, + /* { cell: t('rebalancing.estTimeToComplete'), props: { modifier: 'nowrap' } }, */ + { + cell: t('rebalancing.lastUpdated'), + props: { + sort: { + sortBy: { + index: sortBy ? columnNames.indexOf(sortBy as typeof columnNames[number]) : undefined, + direction, + }, + columnIndex: 2, + onSort: (event, columnIndex, dir) => handleSort(onSort, event, columnIndex, dir), + } as ThProps['sort'], + }, + }, + { cell: '' }, // actions column + ], + [t, handleSort], + ); + + const colProvider = useMemo(() => ({ + dependencies: [t, handleSort], + callback: colMapper, + }), [colMapper, t, handleSort]); + + const rowMapper: ResourceListDataViewRowMapper = useCallback( + (rebalance): ResourceListDataViewRowResult => { + const canUpdate = hasPrivilege('UPDATE', rebalance); + const lastUpdated = getLastUpdated(rebalance); + + return { + row: { + id: rebalance.id, + row: [ + { + id: rebalance.id, + cell: ( + + ), + } as DataViewTd, + { + cell: ( + + {rebalance.attributes.name} + + ), + props: { dataLabel: t('rebalancing.rebalanceName') }, + }, + { + cell: ( + + ), + props: { dataLabel: t('rebalancing.status') }, + }, + { + cell: rebalance.attributes.optimizationResult?.dataToMoveMB != null + ? `${rebalance.attributes.optimizationResult.dataToMoveMB} MB` + : '–', + props: { dataLabel: t('rebalancing.dataToMove') }, + }, + { + cell: rebalance.attributes.optimizationResult?.numReplicaMovements ?? '–', + props: { dataLabel: t('rebalancing.partitionsToMove') }, + }, + { + cell: rebalance.attributes.optimizationResult?.numLeaderMovements ?? '–', + props: { dataLabel: t('rebalancing.leadershipUpdates') }, + }, + /* { + cell: '–', + props: { dataLabel: t('rebalancing.estTimeToComplete') }, + }, */ + { + cell: formatDateTime({ value: lastUpdated }), + props: { dataLabel: t('rebalancing.lastUpdated') }, + }, + { + cell: ( + onApprove(rebalance), + isDisabled: !canUpdate || !rebalance.meta?.allowedActions?.includes('approve'), + }, + { + title: t('rebalancing.refresh'), + onClick: () => onRefresh(rebalance), + isDisabled: !canUpdate || !rebalance.meta?.allowedActions?.includes('refresh'), + }, + { + title: t('rebalancing.stop'), + onClick: () => onStop(rebalance), + isDisabled: !canUpdate || !rebalance.meta?.allowedActions?.includes('stop'), + }, + ]} + /> + ), + props: { isActionCell: true }, + }, + ], + }, + expandedRows: [{ + rowId: rebalance.id as unknown as number, + columnId: 0, + content: ( + + + + + {t('rebalancing.autoApprovalEnabled')} + + {rebalance.meta?.autoApproval === true ? 'true' : 'false'} + + + + + + + {t('rebalancing.mode')}{' '} + {t('rebalancing.rebalanceMode')}} + bodyContent={ +
+ + + {t('rebalancing.fullMode')}{' '} + {t('rebalancing.fullModeDescription')} + + + {t('rebalancing.addBrokersMode')}{' '} + {t('rebalancing.addBrokersModeDescription')} + + + {t('rebalancing.removeBrokersMode')}{' '} + {t('rebalancing.removeBrokersModeDescription')} + + +
+ } + > + +
+
+ + {rebalance.attributes.mode === 'full' ? ( + t('rebalancing.fullMode') + ) : ( + <> + {rebalance.attributes.mode === 'add-brokers' + ? t('rebalancing.addBrokersMode') + : t('rebalancing.removeBrokersMode')}{' '} + {rebalance.attributes.brokers?.length + ? rebalance.attributes.brokers.map((b, index) => ( + + + {t('rebalancing.broker', { b })} + + {index < (rebalance.attributes.brokers?.length || 0) - 1 && ', '} + + )) + : ''} + + )} + +
+
+
+
+ ), + }], + }; + }, + [kafkaId, t, statusConfig, onApprove, onStop, onRefresh], + ); + + const rowProvider = useMemo(() => ({ + dependencies: [kafkaId, t, statusConfig, onApprove, onStop, onRefresh], + callback: rowMapper, + }), [rowMapper, kafkaId, t, statusConfig, onApprove, onStop, onRefresh]); + + return ( + ({ + value: s, + label: , + })), + }, + mode: { + type: 'checkbox', + title: t('rebalancing.mode'), + placeholder: t('rebalancing.filter.modePlaceholder'), + options: [ + { value: 'full', label: t('rebalancing.fullMode') }, + { value: 'add-brokers', label: t('rebalancing.addBrokersMode') }, + { value: 'remove-brokers', label: t('rebalancing.removeBrokersMode') }, + ], + }, + }} + columnProvider={colProvider} + rowProvider={rowProvider} + /> + ); +} diff --git a/api/src/main/webui/src/components/kafka/nodes/RebalancesTable.tsx b/api/src/main/webui/src/components/kafka/nodes/RebalancesTable.tsx deleted file mode 100644 index eb276c3c4..000000000 --- a/api/src/main/webui/src/components/kafka/nodes/RebalancesTable.tsx +++ /dev/null @@ -1,269 +0,0 @@ -/** - * Rebalances Table Component - * Displays Kafka rebalances with actions (approve, stop, refresh) - */ - -import { useState, useMemo } from 'react'; -import { useTranslation } from 'react-i18next'; -import { Link } from 'react-router'; -import { formatDateTime } from '@/utils/dateTime'; -import { - Table, - Thead, - Tr, - Th, - Tbody, - Td, - ActionsColumn, - ExpandableRowContent, - ThProps, -} from '@patternfly/react-table'; -import { - Button, - Flex, - FlexItem, - DescriptionList, - DescriptionListGroup, - DescriptionListTerm, - DescriptionListDescription, - Badge, - Popover, - List, - ListItem, -} from '@patternfly/react-core'; -import { - HelpIcon, -} from '@patternfly/react-icons'; -import { Rebalance } from '@/api/types'; -import { RebalanceModal } from './RebalanceModal'; -import { hasPrivilege } from '@/utils/privileges'; -import { StatusLabel } from '@/components/StatusLabel'; -import { createRebalanceStatusConfig } from '@/components/StatusLabel/configs'; - -interface RebalancesTableProps { - rebalances: Rebalance[] | undefined; - sortBy: string; - sortDirection: 'asc' | 'desc'; - onSort: (column: string) => void; - onApprove: (rebalance: Rebalance) => void; - onStop: (rebalance: Rebalance) => void; - onRefresh: (rebalance: Rebalance) => void; - kafkaId: string; -} - -export function RebalancesTable({ - rebalances, - sortBy, - sortDirection, - onSort, - onApprove, - onStop, - onRefresh, - kafkaId, -}: RebalancesTableProps) { - const { t } = useTranslation(); - - // Create status config with i18n translations - const statusConfig = useMemo(() => createRebalanceStatusConfig(t), [t]); - - const [expandedRows, setExpandedRows] = useState>(new Set()); - const [selectedRebalance, setSelectedRebalance] = useState(null); - const [isModalOpen, setIsModalOpen] = useState(false); - - const handleRebalanceClick = (rebalance: Rebalance) => { - setSelectedRebalance(rebalance); - setIsModalOpen(true); - }; - - const handleModalClose = () => { - setIsModalOpen(false); - setSelectedRebalance(null); - }; - - const toggleRowExpanded = (id: string) => { - setExpandedRows((prev) => { - const newSet = new Set(prev); - if (newSet.has(id)) { - newSet.delete(id); - } else { - newSet.add(id); - } - return newSet; - }); - }; - - const getSortParams = (columnName: string): ThProps['sort'] => ({ - sortBy: { - index: sortBy === columnName ? 0 : undefined, - direction: sortDirection, - }, - onSort: () => onSort(columnName), - columnIndex: 0, - }); - - // Get last updated timestamp - const getLastUpdated = (rebalance: Rebalance): string => { - const statusCondition = rebalance.attributes.conditions?.find( - (c) => c.type === rebalance.attributes.status - ); - return statusCondition?.lastTransitionTime || rebalance.attributes.creationTimestamp || ''; - }; - - return ( - <> - - - - - - - - - - {rebalances?.map((rebalance) => { - const isExpanded = expandedRows.has(rebalance.id); - const lastUpdated = getLastUpdated(rebalance); - - const canUpdate = hasPrivilege('UPDATE', rebalance); - - return ( - <> - - - - - - - {isExpanded && ( - - - - )} - - ); - })} - -
- - {t('rebalancing.rebalanceName')} - {t('rebalancing.status')}{t('rebalancing.lastUpdated')} -
toggleRowExpanded(rebalance.id), - }} - /> - - - - - - {formatDateTime({ value: lastUpdated })} - - onApprove(rebalance), - isDisabled: !canUpdate || !rebalance.meta?.allowedActions?.includes('approve'), - }, - { - title: t('rebalancing.refresh'), - onClick: () => onRefresh(rebalance), - isDisabled: !canUpdate || !rebalance.meta?.allowedActions?.includes('refresh'), - }, - { - title: t('rebalancing.stop'), - onClick: () => onStop(rebalance), - isDisabled: !canUpdate || !rebalance.meta?.allowedActions?.includes('stop'), - }, - ]} - /> -
- - - - - - {t('rebalancing.autoApprovalEnabled')} - - {rebalance.meta?.autoApproval === true ? 'true' : 'false'} - - - - - - - {t('rebalancing.mode')}{' '} - {t('rebalancing.rebalanceMode')}} - bodyContent={ -
- - - {t('rebalancing.fullMode')}{' '} - {t('rebalancing.fullModeDescription')} - - - {t('rebalancing.addBrokersMode')}{' '} - {t('rebalancing.addBrokersModeDescription')} - - - {t('rebalancing.removeBrokersMode')}{' '} - {t('rebalancing.removeBrokersModeDescription')} - - -
- } - > - -
-
- - {rebalance.attributes.mode === 'full' ? ( - t('rebalancing.fullMode') - ) : ( - <> - {rebalance.attributes.mode === 'add-brokers' - ? t('rebalancing.addBrokersMode') - : t('rebalancing.removeBrokersMode')}{' '} - {rebalance.attributes.brokers?.length - ? rebalance.attributes.brokers.map((b, index) => ( - - - {t('rebalancing.broker', { b })} - - {index < (rebalance.attributes.brokers?.length || 0) - 1 && ', '} - - )) - : ''} - - )} - -
-
-
-
-
-
- - - - ); -} \ No newline at end of file diff --git a/api/src/main/webui/src/components/kafka/nodes/charts/ChartNodeStorageUsage.tsx b/api/src/main/webui/src/components/kafka/nodes/charts/ChartNodeStorageUsage.tsx new file mode 100644 index 000000000..ce2bddf27 --- /dev/null +++ b/api/src/main/webui/src/components/kafka/nodes/charts/ChartNodeStorageUsage.tsx @@ -0,0 +1,131 @@ +import { useTranslation } from 'react-i18next'; +import { + Chart, + ChartAxis, + ChartBar, + ChartLegend, + ChartStack, + ChartThemeColor, + ChartTooltip, +} from '@patternfly/react-charts/victory'; +import { Alert } from '@patternfly/react-core'; +import { Node } from '@/api/types'; +import { formatBytes } from '@/utils/format'; +import { useChartWidth } from '@/components/kafka/overview/utils/useChartWidth'; +import { getPadding } from '@/components/kafka/overview/utils/chartConsts'; +import { useMemo } from 'react'; + +interface ChartNodeStorageUsageProps { + nodes: Node[]; +} + +export function ChartNodeStorageUsage({ nodes }: ChartNodeStorageUsageProps) { + const { t } = useTranslation(); + const [containerRef, width] = useChartWidth(); + + const storageNodes = useMemo(() => nodes + .filter((n) => n.attributes.storageUsed != null && n.attributes.storageCapacity != null) + .sort((n1, n2) => parseInt(n2.id) - parseInt(n1.id)), + [nodes]); + + const usedData = useMemo(() => storageNodes.map((n) => ({ + name: t('nodes.charts.storageUsageSeriesUsed'), + x: `Node ${n.id}`, + y: n.attributes.storageUsed as number, + //label: `${t('nodes.charts.storageUsageSeriesUsed')}: ${formatBytes(n.attributes.storageUsed as number)}`, + })), [t, storageNodes]); + + const availableData = useMemo(() => storageNodes.map((n) => { + const available = (n.attributes.storageCapacity as number) - (n.attributes.storageUsed as number); + return { + name: t('nodes.charts.storageUsageSeriesAvailable'), + x: `Node ${n.id}`, + y: available, + //label: `${t('nodes.charts.storageUsageSeriesAvailable')}: ${formatBytes(available)}`, + }; + }), [t, storageNodes]); + + const labels = useMemo(() => storageNodes.map((n) => { + const capacity = n.attributes.storageCapacity as number; + const used = n.attributes.storageUsed as number; + const available = capacity - used; + return `Used ${formatBytes(used)} (${((used / capacity) * 100).toFixed(2)}%) of ${formatBytes(capacity)}\nAvailable ${formatBytes(available)} (${((available / capacity) * 100).toFixed(2)}%)`; + }), [storageNodes]); + + const legendData = [ + { name: t('nodes.charts.storageUsageSeriesUsed') }, + { name: t('nodes.charts.storageUsageSeriesAvailable') }, + ]; + + // Compute 5 evenly-spaced, round tick values from 0 to maxCapacity. + // Victory's tickCount hint does not produce round values for byte ranges, + // so we derive explicit tickValues instead. + const maxCapacity = Math.max(...storageNodes.map((n) => n.attributes.storageCapacity as number)); + const tickStep = maxCapacity / 4; + + // Round step up to a power-of-1024 boundary so labels stay in one unit. + const unitBoundary = Math.pow(1024, Math.floor(Math.log(tickStep) / Math.log(1024))); + const roundedStep = Math.ceil(tickStep / unitBoundary) * unitBoundary; + const tickValues = [0, 1, 2, 3, 4].map((i) => i * roundedStep); + + // Configure custom spacing dimensions + const barWidth = 20; // Thickness of each individual bar + const innerPadding = 16; // Distance between bars in pixels + + // Dynamically calculate the SVG canvas size based on data density + const calculatedChartHeight = usedData.length * (barWidth + innerPadding) + 100; + const legendRows = 1; + const padding = { ...getPadding(legendRows), left: 70 }; + + if (storageNodes.length === 0) { + return ( + + ); + } + + return ( +
+ + } + padding={padding} + domainPadding={{ x: [30, 25] }} + themeColor={ChartThemeColor.multiOrdered} + width={width} + height={calculatedChartHeight} + legendAllowWrap={true} + > + formatBytes(d)} + horizontal + /> + + } + > + + + + +
+ ); +} diff --git a/api/src/main/webui/src/components/kafka/nodes/charts/ChartPartitionDistribution.tsx b/api/src/main/webui/src/components/kafka/nodes/charts/ChartPartitionDistribution.tsx new file mode 100644 index 000000000..dd27cfbe6 --- /dev/null +++ b/api/src/main/webui/src/components/kafka/nodes/charts/ChartPartitionDistribution.tsx @@ -0,0 +1,115 @@ +import { useTranslation } from 'react-i18next'; +import { + Chart, + ChartAxis, + ChartBar, + ChartLegend, + ChartStack, + ChartThemeColor, + ChartTooltip, +} from '@patternfly/react-charts/victory'; +import { Alert } from '@patternfly/react-core'; +import { Node } from '@/api/types'; +import { formatNumber } from '@/utils/format'; +import { useChartWidth } from '@/components/kafka/overview/utils/useChartWidth'; +import { getPadding } from '@/components/kafka/overview/utils/chartConsts'; +import { useMemo } from 'react'; + +interface ChartPartitionDistributionProps { + nodes: Node[]; +} + +export function ChartPartitionDistribution({ nodes }: ChartPartitionDistributionProps) { + const { t } = useTranslation(); + const [containerRef, width] = useChartWidth(); + + const brokerNodes = useMemo(() => nodes + .filter((n) => n.attributes.broker != null) + .sort((n1, n2) => parseInt(n2.id) - parseInt(n1.id)), + [nodes]); + + // Bottom segment: leader partitions + const leadersData = useMemo(() => brokerNodes.map((n) => { + const broker = n.attributes.broker!; + return { + name: t('nodes.charts.partitionDistributionSeriesLeaders'), + x: `Node ${n.id}`, + y: broker.leaderCount, + label: `${t('nodes.charts.partitionDistributionSeriesLeaders')}: ${formatNumber(broker.leaderCount)}`, + }; + }), [t, brokerNodes]); + + // Top segment: follower replicas only (excludes leaders) + const replicasData = useMemo(() => brokerNodes.map((n) => { + const broker = n.attributes.broker!; + return { + name: t('nodes.charts.partitionDistributionSeriesReplicas'), + x: `Node ${n.id}`, + y: broker.replicaCount, + label: `${t('nodes.charts.partitionDistributionSeriesReplicas')}: ${formatNumber(broker.replicaCount)}`, + }; + }), [t, brokerNodes]); + + const legendData = [ + { name: t('nodes.charts.partitionDistributionSeriesReplicas') }, + { name: t('nodes.charts.partitionDistributionSeriesLeaders') }, + ]; + + // Configure custom spacing dimensions + const barWidth = 20; // Thickness of each individual bar + const innerPadding = 16; // Distance between bars in pixels + + // Dynamically calculate the SVG canvas size based on data density + const calculatedChartHeight = leadersData.length * (barWidth + innerPadding) + 100; + const legendRows = 1; + const padding = { ...getPadding(legendRows), left: 70 }; + + if (brokerNodes.length === 0) { + return ( + + ); + } + + return ( +
+ + } + padding={padding} + domainPadding={{ x: [30, 25] }} + themeColor={ChartThemeColor.multiOrdered} + width={width} + height={calculatedChartHeight} + legendAllowWrap={true} + > + + + + } + /> + } + /> + + +
+ ); +} diff --git a/api/src/main/webui/src/components/kafka/topics/AdvancedSearch.tsx b/api/src/main/webui/src/components/kafka/topics/AdvancedSearch.tsx index a2a63b909..49314c797 100644 --- a/api/src/main/webui/src/components/kafka/topics/AdvancedSearch.tsx +++ b/api/src/main/webui/src/components/kafka/topics/AdvancedSearch.tsx @@ -232,7 +232,7 @@ export function AdvancedSearch({
- + diff --git a/api/src/main/webui/src/i18n/messages/en.json b/api/src/main/webui/src/i18n/messages/en.json index 12121b758..4785cda0b 100644 --- a/api/src/main/webui/src/i18n/messages/en.json +++ b/api/src/main/webui/src/i18n/messages/en.json @@ -11,7 +11,6 @@ "create": "Create", "search": "Search", "filterByName": "Filter by name", - "filter": "Filter", "clear": "Clear", "apply": "Apply", "close": "Close", @@ -32,7 +31,12 @@ "noResultsFound": "No results found", "noResultsFoundDescription": "No results match the filter criteria. Clear all filters and try again.", "clearAllFilters": "Clear all filters", - "refreshDataTooltip": "Refresh data
Last update: {{lastRefresh}}" + "refreshDataTooltip": "Refresh data
Last update: {{lastRefresh}}", + "filter": { + "label": "Filter", + "namePlaceholder": "Filter by name", + "statusPlaceholder": "Filter by status" + } }, "about": { "buttonLabel": "About", @@ -430,10 +434,10 @@ "nodePool": "Node Pool", "allNodePoolsPlaceholder": "All node pools", "roles": "Roles", - "status": "Status", - "statusPlaceholder": "All statuses", "replicas": "Total Replicas", "replicasTooltip": "The overall count of partition replicas hosted by the broker. Replicas provide fault tolerance and data availability.", + "leaders": "Leader partitions", + "leadersTooltip": "The number of partition replicas for which this broker is currently the elected leader. Leaders handle all reads and writes for their partitions.", "diskUsage": "Disk usage", "kafkaVersion": "Kafka version", "leadController": "Lead controller", @@ -443,6 +447,17 @@ "broker": "Broker", "controller": "Controller" }, + "status": "Status", + "filter": { + "nodePool": "Node pool", + "nodePoolPlaceholder": "Filter by node pool", + "nodePoolRoles": "Roles: {{roles}}", + "role": "Role", + "rolePlaceholder": "Filter by role", + "statusPlaceholder": "Filter by status", + "brokerStatus": "Broker status", + "controllerStatus": "Controller status" + }, "brokerStatus": { "running": { "label": "Running", @@ -474,67 +489,103 @@ } }, "controllerStatus": { - "quorumLeader": "QuorumLeader", + "quorumLeader": "Quorum Leader", "quorumLeaderPopoverText": "The leader of the metadata quorum (also known as the Active Controller). It handles all metadata requests from Kafka brokers.", - "quorumFollower": "QuorumFollower", + "quorumFollower": "Quorum Follower", "quorumFollowerPopoverText": "Follower controllers replicate metadata written by the quorum leader (Active Controller) and serve as hot standbys in case of leader failure.", - "quorumFollowerLagged": "QuorumFollowerLagged", + "quorumFollowerLagged": "Quorum Follower Lagged", "quorumFollowerLaggedPopoverText": "Follower controllers replicate metadata written by the quorum leader (Active Controller). This follower is lagging behind the leader. Quorum followers must stay up-to-date to prevent data loss if the leader fails.", "unknown": "Unknown", "unknownPopoverText": "The controller's state is unknown" }, "tabs": { "overview": "Overview", - "rebalances": "Rebalances" + "rebalances": "Rebalance" }, "distribution": { - "title": "Partition distribution", "totalNodes": "Total nodes", "totalNodesTooltip": "Total number of Kafka nodes across all roles (broker, controller, or combined).", "controllerRole": "Controller role", "brokerRole": "Broker role", "leadController": "Lead controller", "leadControllerTooltip": "The Lead Controller (also known as the Active Controller) is the primary metadata manager in a KRaft-based Kafka cluster. It maintains a global view of the cluster, processes metadata updates, and distributes them to all other nodes.", - "leadControllerValue": "Node {{leadController}}", - "partitionsDistributionOfTotal": "Node Partition Distribution", - "partitionsDistributionOfTotalTooltip": "The percentage distribution of partitions across brokers in the cluster. Consider rebalancing if the distribution is uneven to ensure efficient resource utilization.", - "distributionToggles": "Distribution filter toggles", - "allLabel": "All ({{count}})", - "leadersLabel": "Leaders ({{count}})", - "followersLabel": "Followers ({{count}})", - "distributionChartDescription": "Bar chart showing partition distribution across brokers", - "distributionChartTitle": "Partition distribution", - "brokerNodeVoronoiFollowers": "Broker {{name}}: {{value}} followers", - "brokerNodeVoronoiLeaders": "Broker {{name}}: {{value}} leaders", - "brokerNodeVoronoiAll": "Broker {{name}}: {{value}} replicas", - "brokerNodeCount": "Broker {{node}}: {{count}} ({{percentage}}%)", - "brokerNodeCountMissing": "Broker {{node}}: N/A", - "metricsUnavailable": "Metrics are not available for this cluster" + "leadControllerValue": "Node {{leadController}}" }, "statusLabels": { "healthyTooltip": "Number of healthy nodes", "unhealthyTooltip": "Number of unhealthy nodes" + }, + "charts": { + "title": "Node charts", + "storageUsage": "Node storage usage", + "storageUsageSubtitle": "Total storage used per node", + "storageUsageTooltip": "Used and available storage per node.", + "storageUsageNoData": "No storage usage data available", + "storageUsageAriaTitle": "Node storage usage chart", + "storageUsageSeriesUsed": "Used", + "storageUsageSeriesAvailable": "Available", + "partitionDistribution": "Partition distribution", + "partitionDistributionSubtitle": "Balance of partition leaders and replicas across brokers", + "partitionDistributionTooltip": "Total replicas and leader partitions per broker node.", + "partitionDistributionNoData": "No partition data available", + "partitionDistributionAriaTitle": "Partition distribution chart", + "partitionDistributionSeriesLeaders": "Leaders", + "partitionDistributionSeriesReplicas": "Replicas" } }, "rebalancing": { - "title": "Rebalances", - "rebalanceName": "Rebalance", + "title": "Rebalance", + "rebalanceName": "Name", "status": "Status", "lastUpdated": "Last updated", + "dataToMove": "Data to move", + "partitionsToMove": "Partitions to move", + "leadershipUpdates": "Leadership updates", + "estTimeToComplete": "Est. time to complete", "cruiseControlEnabled": "Cruise Control is enabled", "learnMoreAboutCruiseControl": "Learn more about Cruise Control enablement", "cruiseControlLink": "https://strimzi.io/docs/operators/latest/deploying#cruise-control-concepts-str", + "cruiseControlNotEnabled": "Cruise Control is not enabled", + "cruiseControlNotEnabledDescription": "Cruise Control is not enabled for this cluster. Add Cruise Control to your Kafka custom resource to get started with rebalancing.", + "cruiseControlGetStarted": "Get started", "totalRebalances": "Total Rebalances", "proposalReady": "Proposal Ready", "rebalancing": "Rebalancing", "ready": "Ready", "stopped": "Stopped", + "namespace": "Namespace", + "created": "Created", + "rebalanceNotFound": "Rebalance not found.", "mode": "Mode", "rebalanceMode": "Rebalance mode", + "goals": "Goals", "autoApprovalEnabled": "Auto-approval enabled", "approve": "Approve", "refresh": "Refresh", + "refreshProposal": "Refresh proposal", "stop": "Stop", + "proposalReadyAlert": { + "title": "Proposal ready for review", + "description": "Cruise Control has generated a rebalance proposal. Review the before/after state and approve if the changes meet your needs." + }, + "brokerImpact": { + "title": "Broker impact", + "tableLabel": "Broker impact table", + "broker": "Broker", + "brokerCapacity": "Cruise Control Broker Capacity", + "before": "Before", + "after": "After", + "findBroker": "Find broker", + "brokers": "Brokers", + "selectBrokers": "Select brokers", + "onlyShowDeltas": "Only show deltas", + "noData": "No broker impact data available. A proposal must be generated first.", + "noResults": "No brokers match the current filters." + }, + "proposalDetail": { + "title": "Proposal detail", + "noProposalData": "No proposal data available." + }, "confirm": "Confirm", "confirmApproveTitle": "Approve Rebalance Proposal?", "confirmApproveDescription": "This will apply the optimization changes. Are you sure you want to proceed?", @@ -542,7 +593,6 @@ "confirmStopDescription": "Stopping will halt the current rebalancing process. You can start a new rebalance later. Are you sure you want to proceed?", "confirmRefreshTitle": "Refresh Rebalance", "confirmRefreshDescription": "Refresh the optimization proposal to the latest cluster metrics. Are you sure you want to proceed?", - "crBadge": "CR", "broker": "Broker {{b}}", "fullMode": "Full", "fullModeDescription": "Moves replicas across all brokers. This is the default mode", @@ -556,6 +606,9 @@ "noRebalances": "No Kafka cluster rebalances found", "noRebalancesDescription": "Configure a KafkaRebalance resource to generate optimization proposals and initiate rebalances for your Kafka cluster.", "noRebalancesAction": "Learn more about Kafka rebalancing", + "filter": { + "modePlaceholder": "Filter by mode" + }, "statuses": { "new": { "label": "New", @@ -597,7 +650,7 @@ "optimizationProposal": { "title": "Optimization proposal for KafkaRebalance", "description": "A summary of proposed changes based on defined optimization goals, assessed in a specific order of priority.", - "dataToMove": "Data To Move MB", + "dataToMove": "Data To Move", "dataToMoveTooltip": "Total amount of data (in MB) moved across brokers as part of the optimization.", "excludedBrokersForLeadership": "Excluded Brokers For Leadership", "excludedBrokersForLeadershipTooltip": "Brokers excluded from becoming leaders for any partition.", @@ -605,15 +658,15 @@ "excludedBrokersForReplicaMoveTooltip": "Brokers excluded from losing existing replicas or receiving new replicas", "excludedTopics": "Excluded Topics", "excludedTopicsTooltip": "Topics excluded from partition movements.", - "intraBrokerDataToMove": "Intra Broker Data to Move MB", + "intraBrokerDataToMove": "Intrabroker Data to Move", "intraBrokerDataToMoveTooltip": "Total amount of data (in MB) moved within individual brokers. For example, log directory changes.", "monitoredPartitionsPercentage": "Monitored Partitions Percentage", "monitoredPartitionsPercentageTooltip": "Percentage of partitions being monitored for optimization. If this percentage is low, it may be necessary to investigate why certain partitions are not being monitored.", - "numIntraBrokerReplicaMovements": "Num Intra Broker Replica Movements", + "numIntraBrokerReplicaMovements": "Intrabroker Replica Movements", "numIntraBrokerReplicaMovementsTooltip": "Number of replica movements within the same broker.", - "numLeaderMovements": "Num Leader Movements", + "numLeaderMovements": "Leader Movements", "numLeaderMovementsTooltip": "Number of leadership changes for partitions.", - "numReplicaMovements": "Number Replica Movements", + "numReplicaMovements": "Replica Movements", "numReplicaMovementsTooltip": "Total number of replica movements across brokers.", "onDemandBalancednessScoreAfter": "On Demand Balancedness Score After", "onDemandBalancednessScoreAfterTooltip": "Balancedness score of the Kafka cluster after optimization. If all goals are satisfied, the score is 100.", @@ -621,7 +674,7 @@ "onDemandBalancednessScoreBeforeTooltip": "Balancedness score of the Kafka cluster before optimization. Scores range from 0 to 100, where lower values indicate greater imbalance.", "recentWindows": "Recent Windows", "recentWindowsTooltip": "Number of recent monitoring windows used to assess the Kafka cluster's performance for this optimization.", - "sessionId": "Session Id", + "sessionId": "Session ID", "sessionIdTooltip": "Unique identifier for the optimization." } }, @@ -797,4 +850,4 @@ "logout": "Logout", "anonymous": "Anonymous" } -} \ No newline at end of file +} diff --git a/api/src/main/webui/src/index.css b/api/src/main/webui/src/index.css index bbe875d1a..fca6f3909 100644 --- a/api/src/main/webui/src/index.css +++ b/api/src/main/webui/src/index.css @@ -32,4 +32,30 @@ code { .pf-v6-c-nav__link a.pf-m-current { color: var(--pf-v6-c-nav__link--m-current--Color); background-color: var(--pf-v6-c-nav__link--m-current--BackgroundColor); -} \ No newline at end of file +} + +/* Expandable rows: rotate the expand icon when the row is expanded */ +.pf-v6-c-table__compound-expansion-toggle button[aria-expanded="true"] .expand-icon { + transform: rotate(90deg); +} + +/* Expandable rows: make the expand button fill the entire cell */ +.pf-v6-c-table__compound-expansion-toggle .pf-v6-c-table__button { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; +} + +/* Expandable rows: centre the icon inside the TableText wrapper */ +.pf-v6-c-table__compound-expansion-toggle .pf-v6-c-table__text { + display: flex; + align-items: center; + justify-content: center; +} + +/* Center align table cell contents */ +.pf-v6-c-table__td { + vertical-align: middle; +} diff --git a/api/src/main/webui/src/pages/kafka/KafkaLayout.tsx b/api/src/main/webui/src/pages/kafka/KafkaLayout.tsx index 2e995a4f8..996202c17 100644 --- a/api/src/main/webui/src/pages/kafka/KafkaLayout.tsx +++ b/api/src/main/webui/src/pages/kafka/KafkaLayout.tsx @@ -20,6 +20,7 @@ import { useKafkaCluster, useKafkaClusters } from '@/api/hooks/useKafkaClusters' import { useConnector, useConnectCluster } from '@/api/hooks/useConnect'; import { useTopic } from '@/api/hooks/useTopics'; import { useUser } from '@/api/hooks/useUsers'; +import { useRebalance } from '@/api/hooks/useRebalances'; import { KafkaClusterSidebar } from '@/components/kafka/KafkaClusterSidebar'; import { AppMasthead } from '@/components/app/AppMasthead'; import { ReconciliationControls } from '@/components/kafka/overview/ReconciliationControls'; @@ -35,7 +36,8 @@ export function KafkaLayout() { connectorId, connectClusterId, userId, - nodeId + nodeId, + rebalanceId, } = useParams<{ kafkaId: string; topicId?: string; @@ -44,6 +46,7 @@ export function KafkaLayout() { connectClusterId?: string; userId?: string; nodeId?: string; + rebalanceId?: string; }>(); // Must be called unconditionally before any early returns. @@ -90,6 +93,12 @@ export function KafkaLayout() { { fields: ['username'] } ); + // Fetch rebalance name if we're on a rebalance detail page + const { data: rebalanceData } = useRebalance( + rebalanceId ? kafkaId : undefined, + rebalanceId, + ); + if (isLoading) { return ( }> @@ -131,8 +140,12 @@ export function KafkaLayout() { const isTopicDetailPage = !!topicId; const topicName = topicData?.data?.attributes?.name || topicId || ''; + // Check if we're on a rebalance detail page + const isRebalanceDetailPage = !!rebalanceId; + const rebalanceName = rebalanceData?.data?.attributes?.name || rebalanceId || ''; + // Check if we're on a nodes page (overview or rebalances tab) - const isNodesPage = pathSegments.includes('nodes') && !nodeId; + const isNodesPage = pathSegments.includes('nodes') && !nodeId && !isRebalanceDetailPage; const nodesTab = isNodesPage ? currentPage : null; // Check if we're on a node detail page @@ -229,15 +242,32 @@ export function KafkaLayout() { )} {isNodesPage && ( + + {t('kafka.nodes')} + + )} + {isNodesPage && nodesTab && nodesTab !== 'nodes' && ( + + {getNodesTabTitle(nodesTab)} + + )} + {isRebalanceDetailPage && ( {t('kafka.nodes')} )} - {isNodesPage && nodesTab && nodesTab !== 'nodes' && ( + {isRebalanceDetailPage && ( + + + {t('nodes.tabs.rebalances')} + + + )} + {isRebalanceDetailPage && ( - {getNodesTabTitle(nodesTab)} + {rebalanceName} )} {isNodeDetailPage && ( @@ -322,7 +352,7 @@ export function KafkaLayout() { {username} )} - {!isTopicDetailPage && !isNodesPage && !isNodeDetailPage && !isConnectPage && !isConnectorDetailPage && !isConnectClusterDetailPage && !isGroupDetailPage && !isUserDetailPage && currentPage !== kafkaId && ( + {!isTopicDetailPage && !isNodesPage && !isNodeDetailPage && !isRebalanceDetailPage && !isConnectPage && !isConnectorDetailPage && !isConnectClusterDetailPage && !isGroupDetailPage && !isUserDetailPage && currentPage !== kafkaId && ( {getPageTitle(currentPage)} diff --git a/api/src/main/webui/src/pages/kafka/nodes/NodesOverviewTab.tsx b/api/src/main/webui/src/pages/kafka/nodes/NodesOverviewTab.tsx index 016c69ce1..ead8070b3 100644 --- a/api/src/main/webui/src/pages/kafka/nodes/NodesOverviewTab.tsx +++ b/api/src/main/webui/src/pages/kafka/nodes/NodesOverviewTab.tsx @@ -1,310 +1,84 @@ /** - * Nodes Overview Tab - Shows node distribution chart and nodes table + * Nodes Overview Tab - Shows cluster node summary and nodes table */ +import { useState, useCallback } from 'react'; import { useParams } from 'react-router'; import { useTranslation } from 'react-i18next'; import { - PageSection, - Grid, - GridItem, Card, CardBody, DescriptionList, + DescriptionListDescription, DescriptionListGroup, DescriptionListTerm, - DescriptionListDescription, - Tooltip, + Grid, + GridItem, Icon, - EmptyState, - EmptyStateBody, - Spinner, - Title, - CardHeader, - CardTitle, - ToggleGroup, - ToggleGroupItem, - Toolbar, - ToolbarContent, - ToolbarItem, - ToolbarGroup, - Pagination, - PaginationVariant, - Select, - SelectOption, - SelectList, - MenuToggle, - MenuToggleElement, - Flex, - FlexItem, - Label, - Button, + PageSection, + Tooltip, } from '@patternfly/react-core'; import { CheckCircleIcon, ExclamationTriangleIcon, HelpIcon, - FilterIcon, } from '@patternfly/react-icons'; import { useNodes } from '@/api/hooks/useNodes'; -import { useState, useRef, useEffect, useMemo } from 'react'; -import { - Chart, - ChartAxis, - ChartBar, - ChartStack, - ChartVoronoiContainer, - ChartThemeColor, -} from '@patternfly/react-charts/victory'; +import { ResourceListParams } from '@/api/hooks/useResourceList'; import { formatNumber } from '@/utils/format'; -import { NodesTable } from '@/components/kafka/nodes/NodesTable'; -import { useTableState } from '@/hooks'; -import type { BrokerStatus, ControllerStatus, NodeRoles } from '@/api/types'; -import { - useBrokerStatusLabels, - useControllerStatusLabels, - useRoleLabels, -} from '@/components/kafka/nodes/NodeStatusLabel'; - -type DistributionFilter = 'all' | 'leaders' | 'followers'; +import { NodesDataView } from '@/components/kafka/nodes/NodesDataView'; +import { NodeChartsCard } from '@/components/kafka/nodes/NodeChartsCard'; export function NodesOverviewTab() { const { t } = useTranslation(); const { kafkaId } = useParams<{ kafkaId: string }>(); - - // Fetch nodes for distribution chart (all nodes) - const { data: chartData, isLoading: chartLoading, error: chartError } = useNodes(kafkaId, { - pageSize: 100, - }); - - // Table state (pagination + sorting) - const table = useTableState({ - initialSortColumn: 'id', - initialSortDirection: 'asc', - }); - - // Filter state - const [filterNodePools, setFilterNodePools] = useState([]); - const [filterRoles, setFilterRoles] = useState([]); - const [filterBrokerStatuses, setFilterBrokerStatuses] = useState([]); - const [filterControllerStatuses, setFilterControllerStatuses] = useState([]); - - // Filter menu states - const [nodePoolFilterOpen, setNodePoolFilterOpen] = useState(false); - const [roleFilterOpen, setRoleFilterOpen] = useState(false); - const [statusFilterOpen, setStatusFilterOpen] = useState(false); - - // Fetch nodes for table with pagination and filters - const { data: tableData, isLoading: tableLoading } = useNodes(kafkaId, { - pageSize: table.pageSize, - pageCursor: table.pageCursor, - sort: table.sortBy, - sortDir: table.sortDirection, - nodePool: filterNodePools.length > 0 ? filterNodePools : undefined, - roles: filterRoles.length > 0 ? filterRoles : undefined, - brokerStatus: filterBrokerStatuses.length > 0 ? filterBrokerStatuses : undefined, - controllerStatus: filterControllerStatuses.length > 0 ? filterControllerStatuses : undefined, - }); - - // Update table state when data changes - useEffect(() => { - table.setData(tableData); - }, [tableData, table]); - const [filter, setFilter] = useState('all'); - const [chartWidth, setChartWidth] = useState(600); - const chartContainerRef = useRef(null); + // Table data driven by NodesDataView + const [tableParams, setTableParams] = useState({}); + const nodeResult = useNodes(kafkaId, tableParams); - // Get labels - const roleLabels = useRoleLabels(chartData?.meta?.summary?.statuses); - const brokerStatusLabels = useBrokerStatusLabels(); - const controllerStatusLabels = useControllerStatusLabels(); - - // Update chart width on resize - useEffect(() => { - const updateWidth = () => { - if (chartContainerRef.current) { - setChartWidth(chartContainerRef.current.offsetWidth); - } - }; - - updateWidth(); - window.addEventListener('resize', updateWidth); - return () => window.removeEventListener('resize', updateWidth); + const handleDataViewChange = useCallback((params: ResourceListParams) => { + setTableParams(params); }, []); - const nodes = chartData?.data || []; - const summary = chartData?.meta?.summary; - const leadControllerId = summary?.leaderId || ''; - const totalItems = tableData?.meta?.page?.total || 0; - const currentPage = tableData?.meta?.page?.pageNumber || 1; + const summary = nodeResult.data?.meta?.summary; - // Calculate node counts - const totalNodes = Object.values(summary?.statuses?.combined || {}).reduce( - (sum, count) => sum + Number(count), - 0 - ); + const leadControllerId = summary?.leaderId ?? ''; - const brokersTotal = Object.values(summary?.statuses?.brokers || {}).reduce( + const totalNodes = Object.values(summary?.statuses?.combined ?? {}).reduce( (sum, count) => sum + Number(count), - 0 + 0, ); - const brokersWarning = Object.keys(summary?.statuses?.brokers || {}).some( - (key) => key !== 'Running' - ); - - const controllersTotal = Object.values(summary?.statuses?.controllers || {}).reduce( + const brokersTotal = Object.values(summary?.statuses?.brokers ?? {}).reduce( (sum, count) => sum + Number(count), - 0 + 0, ); - - const controllersWarning = Object.keys(summary?.statuses?.controllers || {}).some( - (key) => key !== 'QuorumLeader' && key !== 'QuorumFollower' + const brokersWarning = Object.keys(summary?.statuses?.brokers ?? {}).some( + (key) => key !== 'Running', ); - // Build distribution data from broker nodes - const distributionData: Record = {}; - nodes - .filter((n: typeof nodes[number]) => n.attributes.roles?.includes('broker')) - .forEach((node: typeof nodes[number]) => { - distributionData[node.id] = { - leaders: node.attributes.broker?.leaderCount || 0, - followers: node.attributes.broker?.replicaCount || 0, - }; - }); - - const allCount = Object.values(distributionData).reduce( - (acc, v) => v.followers + v.leaders + acc, - 0 - ); - const leadersCount = Object.values(distributionData).reduce( - (acc, v) => v.leaders + acc, - 0 + const controllersTotal = Object.values(summary?.statuses?.controllers ?? {}).reduce( + (sum, count) => sum + Number(count), + 0, ); - const followersCount = Object.values(distributionData).reduce( - (acc, v) => v.followers + acc, - 0 + const controllersWarning = Object.keys(summary?.statuses?.controllers ?? {}).some( + (key) => key !== 'QuorumLeader' && key !== 'QuorumFollower', ); - const getCount = (nodeData: { leaders: number; followers: number }) => { - switch (filter) { - case 'leaders': - return nodeData.leaders; - case 'followers': - return nodeData.followers; - default: - return nodeData.leaders + nodeData.followers; - } - }; - - const getPercentage = (count: number) => { - const total = filter === 'leaders' ? leadersCount : filter === 'followers' ? followersCount : allCount; - return total > 0 ? ((count / total) * 100).toFixed(2) : '0.00'; - }; - - // Node pool options for filter - const nodePoolOptions = useMemo(() => { - if (!summary?.nodePools) return []; - return Object.entries(summary.nodePools).map(([poolName, poolMeta]) => { - const typedPoolMeta = poolMeta as { roles: string[]; count: number }; - return { - value: poolName, - label: poolName, - count: typedPoolMeta.count, - description: `Roles: ${typedPoolMeta.roles.join(', ')}`, - }; - }); - }, [summary]); - - // Role options for filter - const roleOptions: { value: NodeRoles; label: string; count: number }[] = [ - { - value: 'broker', - label: t('nodes.nodeRoles.broker'), - count: summary?.statuses?.brokers - ? Object.values(summary.statuses.brokers).reduce((sum, count) => sum + Number(count), 0) - : 0, - }, - { - value: 'controller', - label: t('nodes.nodeRoles.controller'), - count: summary?.statuses?.controllers - ? Object.values(summary.statuses.controllers).reduce((sum, count) => sum + Number(count), 0) - : 0, - }, - ]; - - // Status options for filter (grouped) - const brokerStatusOptions = useMemo(() => { - if (!summary?.statuses?.brokers) return []; - return Object.keys(brokerStatusLabels).map((status) => ({ - value: status as BrokerStatus, - label: status, - count: summary.statuses.brokers[status as BrokerStatus] || 0, - })); - }, [summary, brokerStatusLabels]); - - const controllerStatusOptions = useMemo(() => { - if (!summary?.statuses?.controllers) return []; - return Object.keys(controllerStatusLabels).map((status) => ({ - value: status as ControllerStatus, - label: status, - count: summary.statuses.controllers[status as ControllerStatus] || 0, - })); - }, [summary, controllerStatusLabels]); - - // Clear all filters - const clearAllFilters = () => { - setFilterNodePools([]); - setFilterRoles([]); - setFilterBrokerStatuses([]); - setFilterControllerStatuses([]); - table.resetPagination(); - }; - - // Check if any filters are active - const hasActiveFilters = - filterNodePools.length > 0 || - filterRoles.length > 0 || - filterBrokerStatuses.length > 0 || - filterControllerStatuses.length > 0; - - if (chartLoading) { - return ( - - - - - {t('common.loading')} - - - - ); - } - - if (chartError) { - return ( - - - - {t('common.error')} - - {chartError.message} - - - ); - } - return ( - - + + - + - + {t('nodes.distribution.totalNodes')}{' '} @@ -314,487 +88,57 @@ export function NodesOverviewTab() { {formatNumber(totalNodes)} + - - {t('nodes.distribution.controllerRole')} - + {t('nodes.distribution.controllerRole')} {controllersWarning ? ( - - - - ) : ( - - - - )} -   {formatNumber(controllersTotal)} - - - - - {t('nodes.distribution.brokerRole')} - - - {brokersWarning ? ( - - - + ) : ( - - - + )} -   {formatNumber(brokersTotal)} + {' '}{formatNumber(controllersTotal)} + - + {t('nodes.distribution.leadController')}{' '} - {t('nodes.distribution.leadControllerValue', { - leadController: leadControllerId, - })} + {t('nodes.distribution.leadControllerValue', { leadController: leadControllerId })} + + + + + {t('nodes.distribution.brokerRole')} + + {brokersWarning ? ( + + ) : ( + + )} + {' '}{formatNumber(brokersTotal)} - - - - - {t('nodes.distribution.partitionsDistributionOfTotal')}{' '} - - - - - - {allCount > 0 ? ( - - - setFilter('all')} - /> - setFilter('leaders')} - /> - setFilter('followers')} - /> - -
- { - switch (filter) { - case 'followers': - return t('nodes.distribution.brokerNodeVoronoiFollowers', { - name: datum.name, - value: datum.y, - }); - case 'leaders': - return t('nodes.distribution.brokerNodeVoronoiLeaders', { - name: datum.name, - value: datum.y, - }); - default: - return t('nodes.distribution.brokerNodeVoronoiAll', { - name: datum.name, - value: datum.y, - }); - } - }} - constrainToVisibleArea - /> - } - legendOrientation="horizontal" - legendPosition="bottom" - legendData={Object.keys(distributionData).map((node) => { - const count = getCount(distributionData[node]); - const percentage = getPercentage(count); - return { - name: t('nodes.distribution.brokerNodeCount', { - node, - count, - percentage, - }), - }; - })} - height={100} - padding={{ - bottom: 70, - left: 0, - right: 0, - top: 30, - }} - width={chartWidth} - > - - - {Object.entries(distributionData).map(([node, data], idx) => ( - - ))} - - -
-
- ) : ( - -
{t('nodes.distribution.metricsUnavailable')}
-
- )} -
-
- - - - {t('nodes.title')} - - - - - - {/* Node Pool Filter */} - - - - - {/* Role Filter */} - - - - {/* Status Filter (Grouped) */} - - - - - - - {}} - onPerPageSelect={table.handlePerPageChange} - onNextClick={table.handleNextPage} - onPreviousClick={table.handlePrevPage} - variant={PaginationVariant.top} - isCompact - /> - - - - {/* Filter chips */} - {hasActiveFilters && ( - - - {filterNodePools.length > 0 && ( - - {t('nodes.nodePool')}:{' '} - {filterNodePools.map((pool) => ( - - ))} - - )} - {filterRoles.length > 0 && ( - - {t('nodes.roles')}:{' '} - {filterRoles.map((role) => ( - - ))} - - )} - {filterBrokerStatuses.length > 0 && ( - - Broker Status:{' '} - {filterBrokerStatuses.map((status) => ( - - ))} - - )} - {filterControllerStatuses.length > 0 && ( - - Controller Status:{' '} - {filterControllerStatuses.map((status) => ( - - ))} - - )} - - - - - - )} - + + + - - {(tableData?.data || []).length > 0 && ( - - - - {}} - onPerPageSelect={table.handlePerPageChange} - onNextClick={table.handleNextPage} - onPreviousClick={table.handlePrevPage} - variant={PaginationVariant.bottom} - isCompact - /> - - - - )} - - + +
diff --git a/api/src/main/webui/src/pages/kafka/nodes/NodesPage.tsx b/api/src/main/webui/src/pages/kafka/nodes/NodesPage.tsx index dcb4045b1..3543ad67a 100644 --- a/api/src/main/webui/src/pages/kafka/nodes/NodesPage.tsx +++ b/api/src/main/webui/src/pages/kafka/nodes/NodesPage.tsx @@ -29,7 +29,7 @@ export function NodesPage() { const location = useLocation(); // Fetch nodes data for status labels - const { data, isLoading } = useNodes(kafkaId, { pageSize: 1 }); + const { data, isLoading } = useNodes(kafkaId, { page: { size: 1 } }); const summary = data?.meta?.summary; const totalNodes = data?.meta?.page?.total || 0; diff --git a/api/src/main/webui/src/pages/kafka/nodes/NodesRebalancesTab.tsx b/api/src/main/webui/src/pages/kafka/nodes/NodesRebalancesTab.tsx index 30a360ba0..fa3706288 100644 --- a/api/src/main/webui/src/pages/kafka/nodes/NodesRebalancesTab.tsx +++ b/api/src/main/webui/src/pages/kafka/nodes/NodesRebalancesTab.tsx @@ -1,135 +1,63 @@ -/** - * Nodes Rebalances Tab - Shows Kafka rebalances - */ - -import { useState, useEffect } from 'react'; +import { useState, useCallback } from 'react'; import { useParams } from 'react-router'; import { useTranslation } from 'react-i18next'; import { PageSection, - Alert, - AlertActionCloseButton, - AlertActionLink, - Grid, - GridItem, - Toolbar, - ToolbarContent, - ToolbarItem, - ToolbarGroup, - SearchInput, - Select, - SelectOption, - SelectList, - MenuToggle, - MenuToggleElement, - Pagination, Button, EmptyState, + EmptyStateActions, EmptyStateBody, - Title, - PaginationVariant, + EmptyStateFooter, } from '@patternfly/react-core'; -import { FilterIcon } from '@patternfly/react-icons'; +import { BalanceScaleIcon } from '@patternfly/react-icons'; import { useRebalances, usePatchRebalance } from '@/api/hooks/useRebalances'; -import { RebalancesTable } from '@/components/kafka/nodes/RebalancesTable'; -import { RebalancesCountCard } from '@/components/kafka/nodes/RebalancesCountCard'; +import { useKafkaCluster } from '@/api/hooks/useKafkaClusters'; +import { ResourceListParams } from '@/api/hooks/useResourceList'; +import { RebalancesDataView } from '@/components/kafka/nodes/RebalancesDataView'; import { RebalanceConfirmationModal } from '@/components/kafka/nodes/RebalanceConfirmationModal'; -import { Rebalance, RebalanceStatus, RebalanceMode } from '@/api/types'; -import { useTableState } from '@/hooks'; -import { useShowLearning } from '@/hooks/useShowLearning'; +import { Rebalance } from '@/api/types'; export function NodesRebalancesTab() { const { t } = useTranslation(); const { kafkaId } = useParams<{ kafkaId: string }>(); - const showLearning = useShowLearning(); - - // Alert state - const [isAlertVisible, setIsAlertVisible] = useState(true); + const { data: clusterData } = useKafkaCluster(kafkaId, { fields: 'cruiseControlEnabled' }); + const cruiseControlEnabled = clusterData?.data?.attributes?.cruiseControlEnabled ?? true; - // Table state (pagination + sorting) - const table = useTableState({ - initialSortColumn: 'name', - initialSortDirection: 'asc', - }); + const [dataParams, setDataParams] = useState({}); + const rebalanceResult = useRebalances(kafkaId, dataParams); - // Filter state - const [filterName, setFilterName] = useState(''); - const [filterStatuses, setFilterStatuses] = useState([]); - const [filterModes, setFilterModes] = useState([]); - const [searchValue, setSearchValue] = useState(''); - const [isStatusSelectOpen, setIsStatusSelectOpen] = useState(false); - const [isModeSelectOpen, setIsModeSelectOpen] = useState(false); + const handleDataViewChange = useCallback((params: ResourceListParams) => { + setDataParams(params); + }, []); // Confirmation modal state const [isConfirmModalOpen, setIsConfirmModalOpen] = useState(false); const [pendingAction, setPendingAction] = useState<'approve' | 'stop' | 'refresh'>('approve'); const [pendingRebalance, setPendingRebalance] = useState(null); - // Fetch rebalances with filters - const { data, isLoading } = useRebalances(kafkaId!, { - pageSize: table.pageSize, - pageCursor: table.pageCursor, - sort: table.sortBy, - sortDir: table.sortDirection, - name: filterName || undefined, - status: filterStatuses.length > 0 ? filterStatuses : undefined, - mode: filterModes.length > 0 ? filterModes : undefined, - }); - - // Update table state when data changes - useEffect(() => { - table.setData(data); - }, [data, table]); - - // Mutation for rebalance actions const { mutate: patchRebalance } = usePatchRebalance(kafkaId!); - const handleFilterNameChange = (name: string) => { - setFilterName(name); - table.resetPagination(); - }; - - const handleFilterStatusChange = (statuses: RebalanceStatus[]) => { - setFilterStatuses(statuses); - table.resetPagination(); - }; - - const handleFilterModeChange = (modes: RebalanceMode[]) => { - setFilterModes(modes); - table.resetPagination(); - }; - - const handleClearAllFilters = () => { - setFilterName(''); - setFilterStatuses([]); - setFilterModes([]); - table.resetPagination(); - }; - - const handleApprove = (rebalance: Rebalance) => { + const handleApprove = useCallback((rebalance: Rebalance) => { setPendingRebalance(rebalance); setPendingAction('approve'); setIsConfirmModalOpen(true); - }; + }, []); - const handleStop = (rebalance: Rebalance) => { + const handleStop = useCallback((rebalance: Rebalance) => { setPendingRebalance(rebalance); setPendingAction('stop'); setIsConfirmModalOpen(true); - }; + }, []); - const handleRefresh = (rebalance: Rebalance) => { + const handleRefresh = useCallback((rebalance: Rebalance) => { setPendingRebalance(rebalance); setPendingAction('refresh'); setIsConfirmModalOpen(true); - }; + }, []); const handleConfirmAction = () => { if (pendingRebalance) { - patchRebalance({ - rebalanceId: pendingRebalance.id, - action: pendingAction, - }); + patchRebalance({ rebalanceId: pendingRebalance.id, action: pendingAction }); } setIsConfirmModalOpen(false); setPendingRebalance(null); @@ -140,303 +68,39 @@ export function NodesRebalancesTab() { setPendingRebalance(null); }; - // Calculate status counts - const statusCounts = data?.data?.reduce( - (acc, rebalance) => { - const status = rebalance.attributes.status; - if (status === 'ProposalReady') acc.proposalReady += 1; - if (status === 'Rebalancing') acc.rebalancing += 1; - if (status === 'Ready') acc.ready += 1; - if (status === 'Stopped') acc.stopped += 1; - return acc; - }, - { proposalReady: 0, rebalancing: 0, ready: 0, stopped: 0 } - ) || { proposalReady: 0, rebalancing: 0, ready: 0, stopped: 0 }; - - const totalCount = data?.meta?.page?.total || 0; - const page = data?.meta?.page?.pageNumber || 1; - - const allStatuses: RebalanceStatus[] = [ - 'New', - 'PendingProposal', - 'ProposalReady', - 'Stopped', - 'Rebalancing', - 'NotReady', - 'Ready', - 'ReconciliationPaused', - ]; - - const allModes: RebalanceMode[] = ['full', 'add-brokers', 'remove-brokers']; - - // Empty state when no rebalances exist - if (!isLoading && totalCount === 0 && !filterName && filterStatuses.length === 0 && filterModes.length === 0) { - return ( - - - {showLearning && isAlertVisible && ( - - setIsAlertVisible(false)} />} - actionLinks={ - - {t('rebalancing.learnMoreAboutCruiseControl')} - - } - /> - - )} - - - - <FilterIcon /> {t('rebalancing.noRebalances')} - - {t('rebalancing.noRebalancesDescription')} - - - - - - ); - } - - // Empty state when filters don't match - if (!isLoading && data?.data?.length === 0 && (filterName || filterStatuses.length > 0 || filterModes.length > 0)) { + if (!cruiseControlEnabled) { return ( - - {isAlertVisible && ( - - setIsAlertVisible(false)} />} - actionLinks={ - - {t('rebalancing.learnMoreAboutCruiseControl')} - - } - /> - - )} - - - - <FilterIcon /> {t('common.noResultsFound')} - - {t('common.noResultsFoundDescription')} - - - - + + + ); } return ( - - {isAlertVisible && ( - - setIsAlertVisible(false)} />} - actionLinks={ - - {t('rebalancing.learnMoreAboutCruiseControl')} - - } - /> - - )} - - - - - - - - - setSearchValue(value)} - onSearch={(_, value) => { - handleFilterNameChange(value); - setSearchValue(value); - }} - onClear={() => { - handleFilterNameChange(''); - setSearchValue(''); - }} - aria-label={t('rebalancing.rebalanceName')} - /> - - - - - - - - - - - {(filterName || filterStatuses.length > 0 || filterModes.length > 0) && ( - - - - )} - - - - {}} - onPerPageSelect={table.handlePerPageChange} - onNextClick={table.handleNextPage} - onPreviousClick={table.handlePrevPage} - variant={PaginationVariant.top} - isCompact - /> - - - - - - - - - - {}} - onPerPageSelect={table.handlePerPageChange} - onNextClick={table.handleNextPage} - onPreviousClick={table.handlePrevPage} - variant={PaginationVariant.bottom} - isCompact - /> - - - - - + ); -} \ No newline at end of file +} diff --git a/api/src/main/webui/src/pages/kafka/nodes/detail/RebalanceDetailPage.tsx b/api/src/main/webui/src/pages/kafka/nodes/detail/RebalanceDetailPage.tsx new file mode 100644 index 000000000..6f346c8e0 --- /dev/null +++ b/api/src/main/webui/src/pages/kafka/nodes/detail/RebalanceDetailPage.tsx @@ -0,0 +1,287 @@ +import { useCallback, useMemo, useState } from 'react'; +import { useParams } from 'react-router'; +import { useTranslation } from 'react-i18next'; +import { + Alert, + AlertActionCloseButton, + Button, + CodeBlock, + DescriptionList, + DescriptionListDescription, + DescriptionListGroup, + DescriptionListTerm, + Divider, + EmptyState, + EmptyStateBody, + Flex, + FlexItem, + Label, + LabelGroup, + PageSection, + Spinner, + Title, +} from '@patternfly/react-core'; +import { SyncAltIcon } from '@patternfly/react-icons'; +import { useRebalance, usePatchRebalance } from '@/api/hooks/useRebalances'; +import { usePageTitle } from '@/hooks'; +import { StatusLabel } from '@/components/StatusLabel'; +import { createRebalanceStatusConfig } from '@/components/StatusLabel/configs'; +import { RebalanceConfirmationModal } from '@/components/kafka/nodes/RebalanceConfirmationModal'; +import { BrokerImpactTable } from '@/components/kafka/nodes/BrokerImpactTable'; +import { ProposalDetailCard } from '@/components/kafka/nodes/ProposalDetailCard'; +import { hasPrivilege } from '@/utils/privileges'; +import { formatDateTime } from '@/utils/dateTime'; +import { Rebalance } from '@/api/types'; + +function getLastUpdated(rebalance: Rebalance): string { + const statusCondition = rebalance.attributes.conditions?.find( + (c) => c.type === rebalance.attributes.status, + ); + return statusCondition?.lastTransitionTime || rebalance.attributes.creationTimestamp || ''; +} + +export function RebalanceDetailPage() { + const { t } = useTranslation(); + const { kafkaId, rebalanceId } = useParams<{ kafkaId: string; rebalanceId: string }>(); + + const { data, isLoading, error, refetch } = useRebalance(kafkaId, rebalanceId); + const rebalance = data?.data; + + const statusConfig = useMemo(() => createRebalanceStatusConfig(t), [t]); + + usePageTitle(rebalance?.attributes.name); + + // Action confirmation state + const [isConfirmModalOpen, setIsConfirmModalOpen] = useState(false); + const [pendingAction, setPendingAction] = useState<'approve' | 'stop' | 'refresh'>('approve'); + + // Alert dismiss state + const [isAlertDismissed, setIsAlertDismissed] = useState(false); + + const { mutate: patchRebalance } = usePatchRebalance(kafkaId!); + + const handleAction = useCallback((action: 'approve' | 'stop' | 'refresh') => { + setPendingAction(action); + setIsConfirmModalOpen(true); + }, []); + + const handleConfirmAction = useCallback(() => { + if (rebalance) { + patchRebalance( + { rebalanceId: rebalance.id, action: pendingAction }, + { onSuccess: () => { void refetch(); } }, + ); + } + setIsConfirmModalOpen(false); + }, [rebalance, patchRebalance, pendingAction, refetch]); + + const handleCancelAction = useCallback(() => { + setIsConfirmModalOpen(false); + }, []); + + if (isLoading) { + return ( + + + + + {t('common.loading')} + + + + ); + } + + if (error || !rebalance) { + return ( + + + + {t('common.error')} + + {error?.message ?? t('rebalancing.rebalanceNotFound')} + + + ); + } + + const canUpdate = hasPrivilege('UPDATE', rebalance); + const allowedActions = rebalance.meta?.allowedActions ?? []; + const status = rebalance.attributes.status; + const lastUpdated = getLastUpdated(rebalance); + + const modeLabel = + rebalance.attributes.mode === 'full' + ? t('rebalancing.fullMode') + : rebalance.attributes.mode === 'add-brokers' + ? t('rebalancing.addBrokersMode') + : t('rebalancing.removeBrokersMode'); + + return ( + <> + {/* Header */} + + + + + {rebalance.attributes.name} + + + + + + {/* Proposal-ready alert */} + {status === 'ProposalReady' && !isAlertDismissed && ( + + setIsAlertDismissed(true)} />} + > + {t('rebalancing.proposalReadyAlert.description')} + + + )} + + {/* Action buttons */} + + + + + + + + + + + + + + + {/* Metadata */} + + + + {t('rebalancing.rebalanceName')} + {rebalance.attributes.name} + + + + {t('rebalancing.namespace')} + {rebalance.attributes.namespace ?? '–'} + + + + {t('rebalancing.created')} + + {formatDateTime({ value: rebalance.attributes.creationTimestamp })} + + + + + {t('rebalancing.lastUpdated')} + + {formatDateTime({ value: lastUpdated })} + + + + + {t('rebalancing.mode')} + {modeLabel} + + + + {t('rebalancing.autoApprovalEnabled')} + + {String(rebalance.meta?.autoApproval === true)} + + + + {rebalance.attributes.goals && rebalance.attributes.goals.length > 0 && ( + + {t('rebalancing.goals')} + + + {rebalance.attributes.goals.map((goal) => ( + + ))} + + + + )} + + + {t('rebalancing.status')} + + {status ? + <> + + {rebalance.attributes.conditions + ?.filter(c => c.type === status) + .filter(c => c.message?.length ?? 0 > 0) + .map(c => { + return <> + + {c.message} + ; + }) + } + + : '–'} + + + + + + + + + + {/* Broker impact table */} + + + {t('rebalancing.brokerImpact.title')} + + + + + {/* Proposal detail expandable card */} + + + + + + + ); +} diff --git a/api/src/main/webui/src/pages/kafka/overview/KafkaOverview.tsx b/api/src/main/webui/src/pages/kafka/overview/KafkaOverview.tsx index 371e7540d..7f7696324 100644 --- a/api/src/main/webui/src/pages/kafka/overview/KafkaOverview.tsx +++ b/api/src/main/webui/src/pages/kafka/overview/KafkaOverview.tsx @@ -80,8 +80,8 @@ function KafkaOverviewContent() { // Fetch nodes to get broker counts and for charts const { data: nodesData } = useNodes(kafkaId, { - fields: ['roles', 'broker'], - pageSize: 100, + fields: 'roles,broker', + page: { size: 100 }, }); // Calculate broker counts diff --git a/api/src/main/webui/src/routes/index.tsx b/api/src/main/webui/src/routes/index.tsx index f83f559c9..e5fd05012 100644 --- a/api/src/main/webui/src/routes/index.tsx +++ b/api/src/main/webui/src/routes/index.tsx @@ -28,6 +28,7 @@ import { NodesOverviewTab } from '@/pages/kafka/nodes/NodesOverviewTab'; import { NodesRebalancesTab } from '@/pages/kafka/nodes/NodesRebalancesTab'; import { NodeDetailPage } from '@/pages/kafka/nodes/detail/NodeDetailPage'; import { NodeConfigurationTab } from '@/pages/kafka/nodes/detail/NodeConfigurationTab'; +import { RebalanceDetailPage } from '@/pages/kafka/nodes/detail/RebalanceDetailPage'; // Groups pages import { GroupsPage } from '@/pages/kafka/groups/GroupsPage'; @@ -127,6 +128,11 @@ export const router = createBrowserRouter([ }, ], }, + { + path: 'nodes/rebalances/:rebalanceId', + // Title is dynamic (rebalance name) — set by RebalanceDetailPage via usePageTitle + element: , + }, { path: 'nodes/:nodeId', // Title is dynamic (broker ID) — set by NodeDetailPage via usePageTitle diff --git a/api/src/test/java/com/github/streamshub/console/api/KafkaRebalancesResourceIT.java b/api/src/test/java/com/github/streamshub/console/api/KafkaRebalancesResourceIT.java index 6206f39fa..2b82f1f97 100644 --- a/api/src/test/java/com/github/streamshub/console/api/KafkaRebalancesResourceIT.java +++ b/api/src/test/java/com/github/streamshub/console/api/KafkaRebalancesResourceIT.java @@ -24,6 +24,8 @@ import com.github.streamshub.console.kafka.systemtest.TestPlainProfile; import com.github.streamshub.console.test.TestHelper; +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.ConfigMapBuilder; import io.fabric8.kubernetes.client.KubernetesClient; import io.quarkus.test.common.http.TestHTTPEndpoint; import io.quarkus.test.junit.QuarkusTest; @@ -47,6 +49,7 @@ import static org.hamcrest.Matchers.hasKey; import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.nullValue; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; @@ -79,6 +82,7 @@ static KafkaRebalance buildRebalance(int sequence, String clusterName, KafkaReba .endMetadata() .withNewSpec() .withMode(mode) + .withGoals("Goal1", "Goal2", "Goal3") .endSpec(); if (clusterName != null) { @@ -102,6 +106,8 @@ static KafkaRebalance buildRebalance(int sequence, String clusterName, KafkaReba .withLastTransitionTime(Instant.now().toString()) .endCondition() .addToOptimizationResult("intraBrokerDataToMoveMB", "0") + // May not be created always - only for some tests + .addToOptimizationResult("afterBeforeLoadConfigMap", "rebalance-" + sequence) .endStatus(); } @@ -119,13 +125,30 @@ void setup() { utils = new TestHelper(bootstrapServers, config); utils.resetSecurity(consoleConfig, false); + client.resources(ConfigMap.class).inAnyNamespace().delete(); client.resources(Kafka.class).inAnyNamespace().delete(); client.resources(KafkaRebalance.class).inAnyNamespace().delete(); utils.apply(client, new KafkaBuilder(utils.buildKafkaResource("test-kafka1", utils.getClusterId(), bootstrapServers)) .editSpec() .withNewCruiseControl() - // empty + .withNewBrokerCapacity() + .withCpu("100m") + .withInboundNetwork("100KiB/s") + .withOutboundNetwork("100KiB/s") + .addNewOverride() + .withBrokers(2) + .withCpu("200m") + .withInboundNetwork("200KiB/s") + .withOutboundNetwork("200KiB/s") + .endOverride() + .addNewOverride() + .withBrokers(3) + .withCpu("300m") + .withInboundNetwork("300KiB/s") + .withOutboundNetwork("300KiB/s") + .endOverride() + .endBrokerCapacity() .endCruiseControl() .endSpec() .build()); @@ -215,6 +238,97 @@ void testListRebalancesFullySorted(String sortField) { assertEquals(sortedValues, values); } + @Test + void testDescribeRebalanceWithBrokerImpact() { + var response = whenRequesting(req -> req + .param("filter[mode]", KafkaRebalanceMode.FULL.toValue()) + .param("filter[status]", KafkaRebalanceState.ProposalReady.name()) + .param("filter[name]", "like,rebalance-*") + .get("", clusterId1)) + .assertThat() + .statusCode(is(Status.OK.getStatusCode())) + .body("data.size()", equalTo(1)) + .extract(); + + String rebalanceId = response.jsonPath().getString("data[0].id"); + String rebalanceName = response.jsonPath().getString("data[0].attributes.name"); + + client.resource(new ConfigMapBuilder() + .withNewMetadata() + .withNamespace("default") + .withName(rebalanceName) + .endMetadata() + .addToData( + "brokerLoad.json", + """ + { + "0": { + "leaders": { "before": 1, "after": 2, "diff": 1 }, + "replicas": { "before": 2, "after": 1, "diff": -1 } + }, + "1": { + "leaders": { "before": 1, "after": 2, "diff": 1 }, + "replicas": { "before": 2, "after": 1, "diff": -1 } + }, + "2": { + "leaders": { "before": 1, "after": 2, "diff": 1 }, + "replicas": { "before": 2, "after": 1, "diff": -1 } + } + } + """ + ) + .build()) + .create(); + + whenRequesting(req -> req + .param( + "fields[" + com.github.streamshub.console.api.model.KafkaRebalance.API_TYPE + "]", + "brokerCapacity,optimizationProposal" + ) + .get("{rebalanceId}", clusterId1, rebalanceId)) + .assertThat() + .statusCode(is(Status.OK.getStatusCode())) + .body("data.attributes.optimizationProposal.brokerImpact", allOf(hasKey("0"), hasKey("1"), hasKey("2"))); + } + + @Test + void testDescribeRebalanceWithInvalidBrokerImpact() { + var response = whenRequesting(req -> req + .param("filter[mode]", KafkaRebalanceMode.FULL.toValue()) + .param("filter[status]", KafkaRebalanceState.ProposalReady.name()) + .param("filter[name]", "like,rebalance-*") + .get("", clusterId1)) + .assertThat() + .statusCode(is(Status.OK.getStatusCode())) + .body("data.size()", equalTo(1)) + .extract(); + + String rebalanceId = response.jsonPath().getString("data[0].id"); + String rebalanceName = response.jsonPath().getString("data[0].attributes.name"); + + client.resource(new ConfigMapBuilder() + .withNewMetadata() + .withNamespace("default") + .withName(rebalanceName) + .endMetadata() + .addToData( + "brokerLoad.json", + "{ INVALID JSON }" + ) + .build()) + .create(); + + whenRequesting(req -> req + .param( + "fields[" + com.github.streamshub.console.api.model.KafkaRebalance.API_TYPE + "]", + "brokerCapacity,optimizationProposal" + ) + .get("{rebalanceId}", clusterId1, rebalanceId)) + .assertThat() + .statusCode(is(Status.OK.getStatusCode())) + .body("data.attributes.optimizationProposal.brokerImpact", nullValue(String.class)); + } + @Test void testPatchRebalanceWithStatusProposalReady() { String rebalanceId = whenRequesting(req -> req diff --git a/operator/src/main/resources/com/github/streamshub/console/dependents/console.clusterrole.yaml b/operator/src/main/resources/com/github/streamshub/console/dependents/console.clusterrole.yaml index 7a4647d0c..6a07370c0 100644 --- a/operator/src/main/resources/com/github/streamshub/console/dependents/console.clusterrole.yaml +++ b/operator/src/main/resources/com/github/streamshub/console/dependents/console.clusterrole.yaml @@ -33,6 +33,13 @@ rules: - "" resources: - pods + # API may read the ConfigMap associated with a KafkaRebalance + - apiGroups: + - "" + resources: + - configmaps + verbs: + - get # API may read the ClusterVersion to fetch the OpenShift version for display - verbs: - get diff --git a/pom.xml b/pom.xml index 9d789ecc9..f8119e6ba 100644 --- a/pom.xml +++ b/pom.xml @@ -87,6 +87,17 @@ systemtests + + + linkedin-artifactory + LinkedIn JFrog Artifactory + https://linkedin.jfrog.io/artifactory/release + + false + + + + diff --git a/ui/tests/playwright/NodePropertyPage.test.tsx b/ui/tests/playwright/NodePropertyPage.test.tsx index a6a48576a..522c653e5 100644 --- a/ui/tests/playwright/NodePropertyPage.test.tsx +++ b/ui/tests/playwright/NodePropertyPage.test.tsx @@ -8,7 +8,7 @@ test("Node property page", async ({ page, authenticatedPage }) => { await test.step("Navigate to node property page", async () => { await page.click('text="Kafka Nodes"'); await expect(page.getByRole('columnheader', { name: 'Node ID' })).toBeVisible(); - await authenticatedPage.clickFirstLinkInTheTable("nodes-listing"); + await authenticatedPage.clickFirstLinkInTheTable("nodes-table"); await expect(page.getByRole('columnheader', { name: 'Property' })).toBeVisible(); }); await test.step("Node page should display properties", async () => { diff --git a/ui/tests/playwright/NodesPage.test.tsx b/ui/tests/playwright/NodesPage.test.tsx index d3549abe7..76df797aa 100644 --- a/ui/tests/playwright/NodesPage.test.tsx +++ b/ui/tests/playwright/NodesPage.test.tsx @@ -11,27 +11,31 @@ test("Nodes page", async ({ page, authenticatedPage }) => { }); await test.step("Nodes page should display table", async () => { await expect(page.locator('h1').getByText('Nodes')).toBeVisible(); - await expect(page.getByText('Node Partition Distribution')).toBeVisible(); const headerRows = await page - .locator('table[data-ouia-component-id="nodes-listing"] thead tr') + .locator('table[data-ouia-component-id="nodes-table"] thead tr') .all(); const headerRow = headerRows[0]; - expect(await headerRow.locator("th").nth(1).innerText()).toBe("Node ID"); - expect(await headerRow.locator("th").nth(2).innerText()).toBe("Roles"); - expect(await headerRow.locator("th").nth(3).innerText()).toBe("Status"); - expect(await headerRow.locator("th").nth(4).innerText()).toContain( + let col = 0; + expect(await headerRow.locator("th").nth(col++).innerText()).toBe("Node ID"); + expect(await headerRow.locator("th").nth(col++).innerText()).toBe("Roles"); + expect(await headerRow.locator("th").nth(col++).innerText()).toBe("Status"); + expect(await headerRow.locator("th").nth(col++).innerText()).toBe("Kafka version"); + expect(await headerRow.locator("th").nth(col++).innerText()).toContain( "Total Replicas ", ); - expect(await headerRow.locator("th").nth(5).innerText()).toContain("Rack "); - expect(await headerRow.locator("th").nth(6).innerText()).toBe("Node Pool"); + expect(await headerRow.locator("th").nth(col++).innerText()).toContain( + "Leader partitions ", + ); + expect(await headerRow.locator("th").nth(col++).innerText()).toContain("Rack "); + expect(await headerRow.locator("th").nth(col++).innerText()).toBe("Node Pool"); const dataRows = await page - .locator('table[data-ouia-component-id="nodes-listing"] tbody tr') + .locator('table[data-ouia-component-id="nodes-table"] tbody tr') .count(); expect(dataRows).toBeGreaterThan(0); const dataCells = await page - .locator('table[data-ouia-component-id="nodes-listing"] tbody tr td') + .locator('table[data-ouia-component-id="nodes-table"] tbody tr td') .evaluateAll((tds) => tds.map((td) => td.innerHTML?.trim() ?? "")); expect(dataCells.length).toBeGreaterThan(0);