From 32c337a3b26c52943312ca4811c1bc9332e9bba8 Mon Sep 17 00:00:00 2001 From: Peter Zenger Date: Fri, 4 Sep 2026 13:55:24 -0700 Subject: [PATCH 01/14] frontend: smoke spec for the reassign-partitions wizard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 45 distinct Chakra symbols, three steps of state, and no Playwright coverage until now. The spec walks step 1 -> step 2 and back, because the step state is the thing a re-skin can break invisibly: the wizard owns `currentStep` and every guard reads it. It stops before "Start Reassignment" — this suite runs against a live cluster. Co-Authored-By: Claude Opus 5 (1M context) --- .../reassign-partitions.spec.ts | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 frontend/tests/test-variant-console/reassign-partitions/reassign-partitions.spec.ts diff --git a/frontend/tests/test-variant-console/reassign-partitions/reassign-partitions.spec.ts b/frontend/tests/test-variant-console/reassign-partitions/reassign-partitions.spec.ts new file mode 100644 index 0000000000..3cea857b48 --- /dev/null +++ b/frontend/tests/test-variant-console/reassign-partitions/reassign-partitions.spec.ts @@ -0,0 +1,69 @@ +import { expect, test } from '@playwright/test'; + +/** + * Smoke coverage for the reassign-partitions wizard — 45 distinct Chakra symbols and + * three steps of state, with no Playwright spec until now. + * + * It walks step 1 → step 2 and back, because the step state is the thing a re-skin + * can break invisibly: the wizard owns `currentStep` and every guard reads it, and + * the Next button is enabled only once the step's own precondition holds. + * + * Deliberately stops before "Start Reassignment" — this suite runs against a live + * cluster and starting a reassignment is not a smoke test. + */ +const SELECT_TOPIC_LABEL = /^Select topic /; +const SELECT_PARTITION_LABEL = /^Select partition /; + +test.describe('Reassign partitions', () => { + test('renders the cluster statistics and the step indicator', async ({ page }) => { + await page.goto('/reassign-partitions'); + + for (const label of ['Broker Count', 'Leader Partitions', 'Replica Partitions', 'Total Partitions']) { + await expect(page.getByText(label, { exact: true })).toBeVisible(); + } + + const steps = page.getByRole('list', { name: 'Reassignment steps' }); + await expect(steps).toBeVisible(); + for (const step of ['Select Partitions', 'Assign to Brokers', 'Review and Confirm']) { + await expect(steps.getByText(step, { exact: true })).toBeVisible(); + } + + await expect(page.getByText('Current Reassignments')).toBeVisible(); + }); + + test('gates step 1 on a partition selection, then steps forward and back', async ({ page }) => { + await page.goto('/reassign-partitions'); + + const nextButton = page.getByRole('button', { name: 'Select Target Brokers' }); + + // Nothing selected yet, so the wizard will not advance. + await expect(nextButton).toBeDisabled(); + + // Select the first topic. Its checkbox has an accessible name of "Select topic ". + const firstTopicCheckbox = page.getByRole('checkbox', { name: SELECT_TOPIC_LABEL }).first(); + await firstTopicCheckbox.waitFor({ state: 'visible', timeout: 30_000 }); + await firstTopicCheckbox.click(); + + await expect(nextButton).toBeEnabled(); + await nextButton.click(); + + // Step 2 renders the broker table. + await expect(page.getByRole('heading', { name: 'Target Brokers' })).toBeVisible(); + await expect(page.getByRole('checkbox', { name: 'Select all brokers' })).toBeVisible(); + + // Back returns to step 1 with the selection intact. + await page.getByRole('button', { name: 'Select Partitions' }).click(); + await expect(page.getByRole('button', { name: 'Select Target Brokers' })).toBeEnabled(); + }); + + test('expands a topic to reveal its partitions', async ({ page }) => { + await page.goto('/reassign-partitions'); + + const expander = page.getByRole('button', { name: 'Expand row' }).first(); + await expander.waitFor({ state: 'visible', timeout: 30_000 }); + await expander.click(); + + // The nested partition table appears, with its own per-partition checkboxes. + await expect(page.getByRole('checkbox', { name: SELECT_PARTITION_LABEL }).first()).toBeVisible(); + }); +}); From 63ed3f377350763c4a506764d641ad41adbb761a Mon Sep 17 00:00:00 2001 From: Peter Zenger Date: Fri, 4 Sep 2026 13:55:24 -0700 Subject: [PATCH 02/14] frontend: the reassign-partitions wizard on Registry components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seven files. The shell and the three steps share `currentStep` and the selection maps, so they ship together. No API and no behaviour change: every guard (`isEnabled`, `computeWarning`, `maximumSelectedReplicationFactor`) reads the same state it did before. The hunks that carry judgement: - `components/wizard-steps.tsx` replaces Chakra's ``. The Registry ships `defineStepper`, but that owns navigation through its own `methods` — this wizard keeps `currentStep` in its own state, so the indicator is a pure function of that index and nothing in it can move the wizard. - `bandwidth-slider` — the Registry Slider renders its own track and thumb and has no mark or thumb-tooltip slot, so the five marks and the value bubble are positioned against the same value scale (`percentOf`). The bubble appears on hover, as Chakra's `isOpen={isDragging}` tooltip did. - Three tables gain the expander column Chakra's DataTable injected for `subComponent` (step 1's topics, step 3's review, and the nested tables get explicit `pagination={false} sorting={false}` — false in Chakra, true in the Registry). - step 1's table passed a no-op `onRowSelectionChange` and a placeholder `rowSelection={{ _internal_connectors_configs: true }}`; selection is done by the `check` column, so both simply go. - Every checkbox gains an accessible name. They had none: Chakra's took its label from children, and these render bare in table cells. That is also what makes the spec able to find them. - `CancelReassignmentButton` — a Chakra Popover with header/body/footer slots becomes a Registry Popover with plain markup; `closeOnBlur={false}` and `returnFocusOnClose={false}` have no Base UI equivalent and were fighting the default focus behaviour rather than relying on it. - `Progress colorScheme` becomes an indicator class: the Registry indicator paints `bg-primary`, so only the success state needs an override. - The active-reassignments table is row-clickable; it now declares `getRowAriaLabel` so the row's purpose is announced. `misc/kowl-table`'s SearchTitle rides along — it is the column-header search step 1's topic table uses, and nothing else imports it. Co-Authored-By: Claude Opus 5 (1M context) --- frontend/src/components/misc/kowl-table.tsx | 15 +- .../components/active-reassignments.tsx | 208 +++++++++--------- .../components/bandwidth-slider.tsx | 106 ++++----- .../components/wizard-steps.tsx | 50 +++++ .../reassign-partitions.tsx | 91 ++++---- .../reassign-partitions/step1-partitions.tsx | 123 ++++++----- .../reassign-partitions/step2-brokers.tsx | 20 +- .../reassign-partitions/step3-review.tsx | 42 +++- 8 files changed, 367 insertions(+), 288 deletions(-) create mode 100644 frontend/src/components/pages/reassign-partitions/components/wizard-steps.tsx diff --git a/frontend/src/components/misc/kowl-table.tsx b/frontend/src/components/misc/kowl-table.tsx index 93c599d8dd..fded322fe7 100644 --- a/frontend/src/components/misc/kowl-table.tsx +++ b/frontend/src/components/misc/kowl-table.tsx @@ -9,7 +9,7 @@ * by the Apache License, Version 2.0 */ -import { Box, Input } from '@redpanda-data/ui'; +import { Input } from 'components/redpanda-ui/components/input'; import React, { Component } from 'react'; export class SearchTitle extends Component<{ @@ -50,17 +50,12 @@ export class SearchTitle extends Component<{ return ( {!this.state.filterOpen && {this.props.title}} - e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()} onMouseUp={(e) => e.stopPropagation()} - style={{ - position: 'absolute', - inset: '0px 0px 0px -8px', - display: 'flex', - placeContent: 'center', - placeItems: 'center', - }} > { @@ -86,7 +81,7 @@ export class SearchTitle extends Component<{ spellCheck={false} value={this.state.quickSearch} /> - + ); } diff --git a/frontend/src/components/pages/reassign-partitions/components/active-reassignments.tsx b/frontend/src/components/pages/reassign-partitions/components/active-reassignments.tsx index 6a78b11179..7094c15ca4 100644 --- a/frontend/src/components/pages/reassign-partitions/components/active-reassignments.tsx +++ b/frontend/src/components/pages/reassign-partitions/components/active-reassignments.tsx @@ -9,34 +9,22 @@ * by the Apache License, Version 2.0 */ +import { Button } from 'components/redpanda-ui/components/button'; +import { ButtonGroup } from 'components/redpanda-ui/components/button-group'; +import { Checkbox } from 'components/redpanda-ui/components/checkbox'; +import { DataTable } from 'components/redpanda-ui/components/data-table'; import { - Box, - Button, - ButtonGroup, - Checkbox, - DataTable, - Flex, - ListItem, - Modal, - ModalBody, - ModalContent, - ModalFooter, - ModalHeader, - ModalOverlay, - Popover, - PopoverArrow, - PopoverBody, - PopoverCloseButton, - PopoverContent, - PopoverFooter, - PopoverHeader, - PopoverTrigger, - Progress, - Skeleton, - Text, - UnorderedList, - useDisclosure, -} from '@redpanda-data/ui'; + Dialog, + DialogBody, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from 'components/redpanda-ui/components/dialog'; +import { Label } from 'components/redpanda-ui/components/label'; +import { Popover, PopoverContent, PopoverTrigger } from 'components/redpanda-ui/components/popover'; +import { Progress } from 'components/redpanda-ui/components/progress'; +import { SkeletonText } from 'components/redpanda-ui/components/skeleton'; import React, { Component, type FC, type JSX, useRef, useState } from 'react'; import { showToast, updateToast } from 'utils/toast.utils'; @@ -146,13 +134,14 @@ export class ActiveReassignments extends Component<{ }, ]} data={currentReassignments} - defaultPageSize={10} emptyText="No reassignments currently in progress" + getRowAriaLabel={(row) => `Show reassignment details for ${row.original.topicName}`} onRow={(row) => { this.setState({ reassignmentDetails: row.original }); }} pagination sorting={false} + tableOptions={{ initialState: { pagination: { pageIndex: 0, pageSize: 10 } } }} /> - - - Throttle Settings - - - - Using throttling you can limit the network traffic for reassignments. - - Throttling applies to all replication traffic, not just to active reassignments. - + { + if (!open) { + onClose(); + } + }} + open={visible} + > + + + Throttle Settings + + +
+
+
Using throttling you can limit the network traffic for reassignments.
+
    +
  • Throttling applies to all replication traffic, not just to active reassignments.
  • +
  • Once the reassignment completes you'll have to remove the throttling configuration.
    Console will show a warning below the "Current Reassignments" table when there are throttled topics that are no longer being reassigned. - - - +
  • +
+
{ setNewThrottleValue(x); }} value={throttleValue} /> - - - +
+
+ - - - - -
- + + + + ); }; const CancelReassignmentButton: FC<{ onConfirm: () => void }> = ({ onConfirm }) => { - const { isOpen, onToggle, onClose } = useDisclosure(); + const [isOpen, setIsOpen] = useState(false); return ( - - - - + + Cancel Reassignment} /> - Confirmation - - - Are you sure you want to stop the reassignment? - - - - - - +
+
Confirmation
+
Are you sure you want to stop the reassignment?
+
+ + + + +
+
); @@ -380,9 +372,9 @@ export class ReassignmentDetailsDialog extends Component<{ state: ReassignmentSt const removingReplicas = state.partitions.flatMap((p) => p.removingReplicas).distinct(); const modalContent = topicConfig ? ( - +
{/* Info */} - +
{QuickTable([ ['Replicas', replicas], @@ -390,56 +382,66 @@ export class ReassignmentDetailsDialog extends Component<{ state: ReassignmentSt ['Removing', removingReplicas], ])}
- +
{/* Throttle */} - +
{ - this.setState({ shouldThrottle: e.target.checked }); + checked={this.state.shouldThrottle} + id="throttle-reassignment" + onCheckedChange={(checked) => { + this.setState({ shouldThrottle: checked === true }); }} - > + /> + - + +
{/* Cancel */} this.cancelReassignment()} /> -
+
) : ( - + ); return ( - - - - Reassignment: {state.topicName} - {modalContent} - + { + if (!open) { + this.props.onClose(); + } + }} + open={visible} + > + + + Reassignment: {state.topicName} + + {modalContent} + - - - + + + ); } @@ -705,13 +707,9 @@ const ProgressBar = (p: { const { percent, state, left, right } = p; return ( <> + {/* Chakra's colorScheme becomes an indicator class: the Registry indicator paints `bg-primary`. */}
void; }; -const labelStyles = { - mt: '1', - mb: '2', - ml: '-2', - fontSize: 'sm', -}; +const SLIDER_MIN = 2; +const SLIDER_MAX = 12.1; + +/** Marks are positioned by value, as Chakra's `SliderMark` did. */ +const MARKS: { value: number; label: string }[] = [ + { value: 2, label: '-' }, + { value: 3, label: '1kB' }, + { value: 6, label: '1MB' }, + { value: 9, label: '1GB' }, + { value: 12, label: '1TB' }, +]; + +const percentOf = (value: number) => ((value - SLIDER_MIN) / (SLIDER_MAX - SLIDER_MIN)) * 100; export function BandwidthSlider(props: ValueAndChangeCallback | SettingsCallback) { const [isDragging, setIsDragging] = useState(false); @@ -73,58 +80,55 @@ export function BandwidthSlider(props: ValueAndChangeCallback | SettingsCallback }; return ( - { - if (n < 2.5) { - setValue(null); - } else { - setValue(Math.round(10 ** n.clamp(3, 12))); - } - }} + // biome-ignore lint/a11y/noStaticElementInteractions: hover only reveals the value bubble; the Slider itself is the control +
{ setIsDragging(true); }} onMouseLeave={() => { setIsDragging(false); }} - step={0.1} - value={sliderValue} > - - - - - - 1kB - - - 1MB - - - 1GB - - - 1TB - + {/* + The Registry Slider renders its own track and thumb and has no mark or thumb-tooltip slot, + so the marks and the value bubble are positioned against the same value scale here. The + bubble follows the thumb and appears on hover, as Chakra's `isOpen={isDragging}` tooltip did. + */} + {isDragging && tipText(sliderValue) ? ( +
+ {tipText(sliderValue)} +
+ ) : null} - - - + { + if (n < 2.5) { + setValue(null); + } else { + setValue(Math.round(10 ** n.clamp(3, 12))); + } + }} + step={0.1} + value={sliderValue} + /> - - - - +
+ {MARKS.map((mark) => ( + + {mark.label} + + ))} +
+
); } diff --git a/frontend/src/components/pages/reassign-partitions/components/wizard-steps.tsx b/frontend/src/components/pages/reassign-partitions/components/wizard-steps.tsx new file mode 100644 index 0000000000..247b87637a --- /dev/null +++ b/frontend/src/components/pages/reassign-partitions/components/wizard-steps.tsx @@ -0,0 +1,50 @@ +/** + * Copyright 2026 Redpanda Data, Inc. + * + * Use of this software is governed by the Business Source License + * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md + * + * As of the Change Date specified in that file, in accordance with + * the Business Source License, use of this software will be governed + * by the Apache License, Version 2.0 + */ + +import { cn } from 'components/redpanda-ui/lib/utils'; +import { Check } from 'lucide-react'; + +/** + * Presentational step indicator, replacing Chakra's ``. + * + * The Registry ships `defineStepper`, but that owns navigation through its own `methods`. This + * wizard keeps `currentStep` in `ReassignPartitions`'s own state and every guard reads it, so the + * indicator stays a pure function of that index — nothing here can move the wizard. + */ +export const WizardSteps = ({ steps, currentStep }: { steps: { title: string }[]; currentStep: number }) => ( +
    + {steps.map((step, index) => { + const isComplete = index < currentStep; + const isActive = index === currentStep; + + return ( +
  1. + + {isComplete ? : index + 1} + + {step.title} + {index < steps.length - 1 && } +
  2. + ); + })} +
+); diff --git a/frontend/src/components/pages/reassign-partitions/reassign-partitions.tsx b/frontend/src/components/pages/reassign-partitions/reassign-partitions.tsx index 6b4e31d1fa..3d90638abd 100644 --- a/frontend/src/components/pages/reassign-partitions/reassign-partitions.tsx +++ b/frontend/src/components/pages/reassign-partitions/reassign-partitions.tsx @@ -10,29 +10,21 @@ */ /** biome-ignore-all lint/correctness/useUniqueElementIds: legacy, needs refactor */ -import { - Box, - Button, - Flex, - Modal, - ModalBody, - ModalContent, - ModalFooter, - ModalHeader, - ModalOverlay, - Step, - StepIcon, - StepIndicator, - StepNumber, - Stepper, - StepSeparator, - StepStatus, -} from '@redpanda-data/ui'; import { AlertIcon, ChevronLeftIcon, ChevronRightIcon } from 'components/icons'; +import { Button } from 'components/redpanda-ui/components/button'; +import { + Dialog, + DialogBody, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from 'components/redpanda-ui/components/dialog'; import { motion } from 'motion/react'; import { closeToast, showToast, updateToast } from 'utils/toast.utils'; import { ActiveReassignments } from './components/active-reassignments'; +import { WizardSteps } from './components/wizard-steps'; import { type ApiData, computeReassignments, type TopicPartitions } from './logic/reassign-logic'; import { ReassignmentTracker } from './logic/reassignment-tracker'; import { @@ -215,7 +207,7 @@ class ReassignPartitions extends PageComponent { {/* Statistics */}
- +
@@ -227,7 +219,7 @@ class ReassignPartitions extends PageComponent { : '...' } /> - +
{/* Active Reassignments */} @@ -242,17 +234,7 @@ class ReassignPartitions extends PageComponent {
{/* Steps */}
- - {steps.map((item) => ( - - - } complete={} incomplete={} /> - - {item.title} - - - ))} - +
{/* Content */} @@ -306,7 +288,7 @@ class ReassignPartitions extends PageComponent { {/* Back */} {Boolean(step.backButton) && (
- { - this.setState({ removeThrottleFromTopicsContent: null }); + { + if (!open) { + this.setState({ removeThrottleFromTopicsContent: null }); + } }} + open={this.state.removeThrottleFromTopicsContent !== null} > - - - - - - Remove throttle config from topics - - - + + + + + + Remove throttle config from topics + + + +
There are {this.state.topicsWithThrottle.length} topics with throttling applied to their replicas. @@ -381,8 +366,8 @@ class ReassignPartitions extends PageComponent {
Do you want to remove the throttle config from those topics?
-
- + + - -
-
+ + + ); } diff --git a/frontend/src/components/pages/reassign-partitions/step1-partitions.tsx b/frontend/src/components/pages/reassign-partitions/step1-partitions.tsx index 15bea3d375..7c1c7fe1cc 100644 --- a/frontend/src/components/pages/reassign-partitions/step1-partitions.tsx +++ b/frontend/src/components/pages/reassign-partitions/step1-partitions.tsx @@ -9,11 +9,14 @@ * by the Apache License, Version 2.0 */ -import { Box, Checkbox, DataTable, Flex, Popover, Text } from '@redpanda-data/ui'; import { WarningIcon } from 'components/icons'; +import { Button } from 'components/redpanda-ui/components/button'; +import { Checkbox } from 'components/redpanda-ui/components/checkbox'; +import { DataTable, type DataTableRow } from 'components/redpanda-ui/components/data-table'; +import { Popover, PopoverContent, PopoverTrigger } from 'components/redpanda-ui/components/popover'; +import { ChevronDown, ChevronRight } from 'lucide-react'; import { Component } from 'react'; import Highlighter from 'react-highlight-words'; -import type { LegacyRow } from 'utils/legacy-data-table'; import { SelectionInfoBar } from './components/statistics-bar'; import type { PartitionSelection } from './reassign-partitions'; @@ -68,16 +71,33 @@ export class StepSelectPartitions extends Component<{ columns={[ + // Chakra's DataTable injected this column whenever `subComponent` was set; the Registry one does not. + { + id: 'expander', + size: 40, + enableSorting: false, + cell: ({ row }) => + row.getCanExpand() ? ( + + ) : null, + }, { id: 'check', header: '', - cell: ({ row }: { row: LegacyRow }) => { + cell: ({ row }: { row: DataTableRow }) => { const { checked, indeterminate } = this.getTopicCheckState(row.original.topicName); return ( this.setTopicSelection(row.original, !checked)} + aria-label={`Select topic ${row.original.topicName}`} + checked={indeterminate ? 'indeterminate' : checked} + onCheckedChange={() => this.setTopicSelection(row.original, !checked)} /> ); }, @@ -97,18 +117,14 @@ export class StepSelectPartitions extends Component<{ if (this.props.throttledTopics.includes(record.topicName)) { return ( - +
{content} - +
); } - return ( - - {content} - - ); + return
{content}
; }, size: Number.POSITIVE_INFINITY, }, @@ -121,12 +137,12 @@ export class StepSelectPartitions extends Component<{ } return ( - +
- +
{topic.partitionCount - errors} / {topic.partitionCount} - - +
+
); }, accessorKey: 'partitions', @@ -161,14 +177,10 @@ export class StepSelectPartitions extends Component<{ }, ]} data={this.topicPartitions} - onRowSelectionChange={(_data) => { - // no op - selection is handled manually - }} - pagination={true} - rowSelection={{ - _internal_connectors_configs: true, - }} - sorting={true} + // Chakra took a no-op `onRowSelectionChange` plus a placeholder `rowSelection`; selection + // is done by the `check` column above, so the Registry table simply leaves it off. + pagination + sorting subComponent={({ row: { original: topic } }) => ( this.getSelectedPartitions(topic.topicName)} @@ -300,12 +312,13 @@ export class SelectPartitionTable extends Component<{ columns={[ { header: 'Check', - cell: ({ row: { original: partition } }: { row: LegacyRow }) => { + cell: ({ row: { original: partition } }: { row: DataTableRow }) => { const isSelected = this.props.getSelectedPartitions().includes(partition.id); return ( { + aria-label={`Select partition ${partition.id}`} + checked={isSelected} + onCheckedChange={() => { this.props.setSelection(this.props.topic.topicName, partition.id, !isSelected); }} /> @@ -319,7 +332,7 @@ export class SelectPartitionTable extends Component<{ }, { header: 'Brokers', - cell: ({ row: { original: partition } }: { row: LegacyRow }) => + cell: ({ row: { original: partition } }: { row: DataTableRow }) => partition.replicas ? ( ) : ( @@ -348,42 +361,44 @@ function renderPartitionError(partition: Partition) { const txt = [partition.partitionError, partition.waterMarksError].join('\n\n'); return ( - {txt}} - hideCloseButton - placement="right-start" - size="auto" - title="Partition Error" - > - - - - - + + + + + + + } + /> + +
Partition Error
+
{txt}
+
); } function PartitionErrorsForTopic(_props: { partitionsWithErrors: number }) { return ( - + + + + + + } + /> + +
Partition Error
Some partitions could not be retreived.
Expand the topic to see which partitions are affected.
- } - hideCloseButton - placement="right-start" - size="auto" - title="Partition Error" - > - - - - - +
); } diff --git a/frontend/src/components/pages/reassign-partitions/step2-brokers.tsx b/frontend/src/components/pages/reassign-partitions/step2-brokers.tsx index d6257fe863..25a9e223e1 100644 --- a/frontend/src/components/pages/reassign-partitions/step2-brokers.tsx +++ b/frontend/src/components/pages/reassign-partitions/step2-brokers.tsx @@ -9,9 +9,9 @@ * by the Apache License, Version 2.0 */ -import { Checkbox, DataTable } from '@redpanda-data/ui'; +import { Checkbox } from 'components/redpanda-ui/components/checkbox'; +import { DataTable, type DataTableRow } from 'components/redpanda-ui/components/data-table'; import { Component } from 'react'; -import type { LegacyRow } from 'utils/legacy-data-table'; import { SelectionInfoBar } from './components/statistics-bar'; import type { PartitionSelection } from './reassign-partitions'; @@ -64,9 +64,9 @@ export class StepSelectBrokers extends Component<{ const allIsSelected = eqSet(selectedSet, allIdsSet); return ( 0} - onChange={() => { + aria-label="Select all brokers" + checked={allIsSelected ? true : selectedSet.size > 0 ? 'indeterminate' : false} + onCheckedChange={() => { if (allIsSelected) { onSelectionChange([]); } else { @@ -76,12 +76,13 @@ export class StepSelectBrokers extends Component<{ /> ); }, - cell: ({ row: { original: broker } }: { row: LegacyRow }) => { + cell: ({ row: { original: broker } }: { row: DataTableRow }) => { const checked = selectedBrokerIds.includes(broker.brokerId); return ( { + aria-label={`Select broker ${broker.brokerId}`} + checked={checked} + onCheckedChange={() => { if (checked) { onSelectionChange(selectedBrokerIds.filter((id) => id !== broker.brokerId)); } else { @@ -102,7 +103,8 @@ export class StepSelectBrokers extends Component<{ }, ]} data={this.brokers} - pagination={true} + pagination + sorting={false} /> ); diff --git a/frontend/src/components/pages/reassign-partitions/step3-review.tsx b/frontend/src/components/pages/reassign-partitions/step3-review.tsx index c66608562e..17b4d3bd03 100644 --- a/frontend/src/components/pages/reassign-partitions/step3-review.tsx +++ b/frontend/src/components/pages/reassign-partitions/step3-review.tsx @@ -9,7 +9,10 @@ * by the Apache License, Version 2.0 */ -import { Box, DataTable, Empty } from '@redpanda-data/ui'; +import { Button } from 'components/redpanda-ui/components/button'; +import { DataTable } from 'components/redpanda-ui/components/data-table'; +import { Empty, EmptyDescription, EmptyHeader } from 'components/redpanda-ui/components/empty'; +import { ChevronDown, ChevronRight } from 'lucide-react'; import { Component } from 'react'; import { BandwidthSlider } from './components/bandwidth-slider'; @@ -50,7 +53,13 @@ export class StepReview extends Component<{ return DefaultSkeleton; } if (api.topicPartitions.size === 0) { - return ; + return ( + + + No partitions + + + ); } return ( @@ -65,6 +74,23 @@ export class StepReview extends Component<{ columns={[ + // Chakra's DataTable injected this column whenever `subComponent` was set; the Registry one does not. + { + id: 'expander', + size: 40, + enableSorting: false, + cell: ({ row }) => + row.getCanExpand() ? ( + + ) : null, + }, { header: 'Topic', accessorKey: 'topicName', @@ -109,8 +135,10 @@ export class StepReview extends Component<{ }, ]} data={this.props.topicsWithMoves} + pagination={false} + sorting={false} subComponent={({ row: { original: topic } }) => ( - +
{topic.selectedPartitions ? ( +
)} /> @@ -257,7 +285,7 @@ export class StepReview extends Component<{ } const ReviewPartitionTable = (props: { topic: Topic; topicPartitions: Partition[]; assignments: TopicAssignment }) => ( - +
columns={[ { @@ -286,6 +314,8 @@ const ReviewPartitionTable = (props: { topic: Topic; topicPartitions: Partition[ }, ]} data={props.topicPartitions} + pagination={false} + sorting={false} /> - +
); From 1e193598ecb8cf4f525729b139d2cfe33f198ae5 Mon Sep 17 00:00:00 2001 From: Peter Zenger Date: Fri, 4 Sep 2026 17:54:12 -0700 Subject: [PATCH 03/14] frontend: review fixes for the reassign-partitions wizard migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by a review pass over the branch: - **The bandwidth slider rendered a phantom second thumb that could write NaN.** The Registry Slider counts thumbs from an array; a scalar `value` fell through to `[min, max]`, so a second thumb rendered with no value — invisible but focusable, and one arrow key from `maxReplicationTraffic = NaN` and a `setReplicationThrottleRate(brokers, NaN)` call. It takes `[sliderValue]` now. - **The value bubble was positioned at `left: -Infinity%`** in the default state: `maxReplicationTraffic` defaults to 0, so `log10` is `-Infinity`. The slider value is clamped once and used for the thumb, the bubble and the label. - **Four net-new lint errors were shipping.** `bun run lint` is `ultracite fix`, which exits 0 with errors still present — `lint:check` is the real gate. The two `biome-ignore` comments named a rule that does not fire on those nodes, so they suppressed nothing: the search overlay's handlers move onto the `Input` (the actual interactive element), the slider wrapper uses pointer events, and the select-all checkbox uses `checked` + `indeterminate` instead of a nested ternary. Added to the gate recipe. - **The throttle dialog's footer collapsed to the right.** `className="justify-between"` loses to the variant's own `sm:justify-end` (tailwind-merge drops only the unprefixed class), so "Remove throttle" no longer sat opposite Close/Apply. It uses `justify="between"` now. - **Dialog widths had jumped.** `minW="5xl"` (64rem) had become `full` (90vw, ~1728px on the Playwright viewport) and `minW="3xl"` (48rem) had become `xl` (56rem); they are `xl` and `lg` respectively. - **step 1 lost click-to-sort on four columns.** Chakra painted the affordance itself; restored with `DataTableColumnHeader`. The topic-name column is explicitly `enableSorting: false` because its header *is* the search control, so the model no longer claims sortability there is no UI for. - **Two columns shared `accessorKey: 'partitions'`**, so both resolved to the same TanStack column id — duplicate React keys and column lookups hitting the wrong def. Pre-existing, but the array is rewritten here. - The "no partitions" state was unframed text: the `Empty` root sets only `border-dashed` and Preflight leaves the width at 0. It has a border, an icon and a title now. - Popover content is `w-auto max-w-[500px]` — the Registry's fixed `w-72` was halving the width Chakra's `size="auto"` gave partition errors; `aria-expanded` on both expanders; `size-4` on the chevrons was overridden by the button variant's own `[&_svg]` rule; icons from `components/icons`; the active-reassignments `tableOptions` restated the DataTable's own default. Not fixed, pre-existing and unrelated to the migration: "Remove throttle" reads `newThrottleValue` from the same render it just set, so it re-applies the current throttle instead of clearing it. Identical on the base commit. Co-Authored-By: Claude Opus 5 (1M context) --- frontend/src/components/misc/kowl-table.tsx | 17 +++--- .../components/active-reassignments.tsx | 11 ++-- .../components/bandwidth-slider.tsx | 14 +++-- .../reassign-partitions.tsx | 3 +- .../reassign-partitions/step1-partitions.tsx | 53 +++++++++++++------ .../reassign-partitions/step2-brokers.tsx | 3 +- .../reassign-partitions/step3-review.tsx | 18 +++++-- 7 files changed, 80 insertions(+), 39 deletions(-) diff --git a/frontend/src/components/misc/kowl-table.tsx b/frontend/src/components/misc/kowl-table.tsx index fded322fe7..ce4ce78e57 100644 --- a/frontend/src/components/misc/kowl-table.tsx +++ b/frontend/src/components/misc/kowl-table.tsx @@ -50,14 +50,14 @@ export class SearchTitle extends Component<{ return ( {!this.state.filterOpen && {this.props.title}} - {/* biome-ignore lint/a11y/noStaticElementInteractions: the handlers only stop propagation to the sortable column header */} -
e.stopPropagation()} - onMouseDown={(e) => e.stopPropagation()} - onMouseUp={(e) => e.stopPropagation()} - > + {/* NOTE: this overlay is currently unreachable — `filterOpen` is initialised false and + nothing sets it true (see `hideSearchBar`, which only clears it). Kept as-is by the + migration; wiring or removing it is a separate change. The absolute positioning also + assumed Chakra's `Th` was `position: relative`, which the Registry TableHead is not. */} +
{ const inputWrapper = e.target.parentElement; const focusInside = inputWrapper?.contains(e.relatedTarget as HTMLElement); @@ -75,7 +75,10 @@ export class SearchTitle extends Component<{ props.observableSettings.quickSearch = e.target.value; this.setState({ quickSearch: e.target.value }); }} + onClick={(e) => e.stopPropagation()} onKeyDown={this.onKeyDown} + onMouseDown={(e) => e.stopPropagation()} + onMouseUp={(e) => e.stopPropagation()} placeholder="Enter search term/regex" ref={this.inputRef} spellCheck={false} diff --git a/frontend/src/components/pages/reassign-partitions/components/active-reassignments.tsx b/frontend/src/components/pages/reassign-partitions/components/active-reassignments.tsx index 7094c15ca4..6bf17d33a1 100644 --- a/frontend/src/components/pages/reassign-partitions/components/active-reassignments.tsx +++ b/frontend/src/components/pages/reassign-partitions/components/active-reassignments.tsx @@ -141,7 +141,6 @@ export class ActiveReassignments extends Component<{ }} pagination sorting={false} - tableOptions={{ initialState: { pagination: { pageIndex: 0, pageSize: 10 } } }} /> - + {/* Chakra's `minW="3xl"` was 48rem; `lg` (42rem) is the nearest rung. */} + Throttle Settings @@ -267,7 +267,7 @@ export const ThrottleDialog: FC<{ />
- +
- ) : null, }, @@ -108,6 +108,10 @@ export class StepSelectPartitions extends Component<{ ), accessorKey: 'topicName', + // This column's header *is* the search control (SearchTitle), so there is nowhere to + // put a sort affordance — the model says so rather than enabling sorting with no UI. + enableSorting: false, + enableHiding: false, cell: ({ row: { original: record } }) => { const content = filterActive ? ( @@ -129,7 +133,10 @@ export class StepSelectPartitions extends Component<{ size: Number.POSITIVE_INFINITY, }, { - header: 'Partitions', + id: 'partitionCount', + enableHiding: false, + header: ({ column }) => , + accessorKey: 'partitionCount', cell: ({ row: { original: topic } }) => { const errors = topic.partitions.count((p) => p.hasErrors); if (errors === 0) { @@ -145,10 +152,12 @@ export class StepSelectPartitions extends Component<{
); }, - accessorKey: 'partitions', }, { - header: 'Replication Factor', + id: 'replicationFactor', + enableHiding: false, + header: ({ column }) => , + accessorKey: 'replicationFactor', cell: ({ row: { original: r } }) => { if (r.activeReassignments.length === 0) { return r.replicationFactor; @@ -162,18 +171,23 @@ export class StepSelectPartitions extends Component<{ ); }, - accessorKey: 'replicationFactor', }, { + // Distinct id: the "Partitions" column above also keyed off `partitions`, so both + // resolved to the same TanStack column id. + id: 'brokers', + enableSorting: false, + enableHiding: false, header: 'Brokers', - accessorKey: 'partitions', cell: ({ row: { original: record } }) => record.partitions?.map((p) => p.leader).distinct().length ?? 'N/A', }, { - header: 'Size', - cell: ({ row: { original: r } }) => renderLogDirSummary(r.logDirSummary), + id: 'totalSizeBytes', + enableHiding: false, + header: ({ column }) => , accessorKey: 'totalSizeBytes', + cell: ({ row: { original: r } }) => renderLogDirSummary(r.logDirSummary), }, ]} data={this.topicPartitions} @@ -311,6 +325,9 @@ export class SelectPartitionTable extends Component<{ columns={[ { + id: 'check', + enableSorting: false, + enableHiding: false, header: 'Check', cell: ({ row: { original: partition } }: { row: DataTableRow }) => { const isSelected = this.props.getSelectedPartitions().includes(partition.id); @@ -326,11 +343,15 @@ export class SelectPartitionTable extends Component<{ }, }, { - header: 'Partition', + id: 'id', + enableHiding: false, + header: ({ column }) => , accessorKey: 'id', - size: Number.POSITIVE_INFINITY, }, { + id: 'replicas', + enableSorting: false, + enableHiding: false, header: 'Brokers', cell: ({ row: { original: partition } }: { row: DataTableRow }) => partition.replicas ? ( @@ -340,9 +361,11 @@ export class SelectPartitionTable extends Component<{ ), }, { - header: 'Size', + id: 'replicaSize', + enableHiding: false, + header: ({ column }) => , + accessorKey: 'replicaSize', cell: ({ row: { original: partition } }) => prettyBytesOrNA(partition.replicaSize), - size: Number.POSITIVE_INFINITY, }, ]} data={this.props.topicPartitions} diff --git a/frontend/src/components/pages/reassign-partitions/step2-brokers.tsx b/frontend/src/components/pages/reassign-partitions/step2-brokers.tsx index 25a9e223e1..31f215705b 100644 --- a/frontend/src/components/pages/reassign-partitions/step2-brokers.tsx +++ b/frontend/src/components/pages/reassign-partitions/step2-brokers.tsx @@ -65,7 +65,8 @@ export class StepSelectBrokers extends Component<{ return ( 0 ? 'indeterminate' : false} + checked={allIsSelected} + indeterminate={!allIsSelected && selectedSet.size > 0} onCheckedChange={() => { if (allIsSelected) { onSelectionChange([]); diff --git a/frontend/src/components/pages/reassign-partitions/step3-review.tsx b/frontend/src/components/pages/reassign-partitions/step3-review.tsx index 17b4d3bd03..d25de519ea 100644 --- a/frontend/src/components/pages/reassign-partitions/step3-review.tsx +++ b/frontend/src/components/pages/reassign-partitions/step3-review.tsx @@ -9,10 +9,11 @@ * by the Apache License, Version 2.0 */ +import { ChevronDownIcon, ChevronRightIcon } from 'components/icons'; import { Button } from 'components/redpanda-ui/components/button'; import { DataTable } from 'components/redpanda-ui/components/data-table'; -import { Empty, EmptyDescription, EmptyHeader } from 'components/redpanda-ui/components/empty'; -import { ChevronDown, ChevronRight } from 'lucide-react'; +import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from 'components/redpanda-ui/components/empty'; +import { InboxIcon } from 'lucide-react'; import { Component } from 'react'; import { BandwidthSlider } from './components/bandwidth-slider'; @@ -54,9 +55,15 @@ export class StepReview extends Component<{ } if (api.topicPartitions.size === 0) { return ( - + // `border` explicitly: the Empty root sets only `border-dashed`, and Preflight leaves + // border-width at 0, so without it the panel is unframed text. + - No partitions + + + + No partitions + Partition data has not loaded yet, so there is nothing to review. ); @@ -82,12 +89,13 @@ export class StepReview extends Component<{ cell: ({ row }) => row.getCanExpand() ? ( ) : null, }, From 2985bb136f74c13ab64ece1742f11413674bee4f Mon Sep 17 00:00:00 2001 From: Peter Zenger Date: Fri, 4 Sep 2026 18:47:06 -0700 Subject: [PATCH 04/14] frontend: scope the reassign-partitions stat assertions to the header row Step 1's SelectionInfoBar repeats "Leader Partitions" and "Total Partitions", so the page-wide exact match in the new smoke spec tripped Playwright strict mode on every run. Co-Authored-By: Claude Opus 5 (1M context) --- .../pages/reassign-partitions/reassign-partitions.tsx | 3 ++- .../reassign-partitions/reassign-partitions.spec.ts | 6 +++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/pages/reassign-partitions/reassign-partitions.tsx b/frontend/src/components/pages/reassign-partitions/reassign-partitions.tsx index 0c2b15a9ed..459eabdde9 100644 --- a/frontend/src/components/pages/reassign-partitions/reassign-partitions.tsx +++ b/frontend/src/components/pages/reassign-partitions/reassign-partitions.tsx @@ -207,7 +207,8 @@ class ReassignPartitions extends PageComponent { {/* Statistics */}
-
+ {/* Scoped: step 1's SelectionInfoBar repeats these labels. */} +
diff --git a/frontend/tests/test-variant-console/reassign-partitions/reassign-partitions.spec.ts b/frontend/tests/test-variant-console/reassign-partitions/reassign-partitions.spec.ts index 3cea857b48..b6aaa1292a 100644 --- a/frontend/tests/test-variant-console/reassign-partitions/reassign-partitions.spec.ts +++ b/frontend/tests/test-variant-console/reassign-partitions/reassign-partitions.spec.ts @@ -10,6 +10,9 @@ import { expect, test } from '@playwright/test'; * * Deliberately stops before "Start Reassignment" — this suite runs against a live * cluster and starting a reassignment is not a smoke test. + * + * The header stats are scoped: step 1's SelectionInfoBar repeats "Leader Partitions" + * and "Total Partitions", so a page-wide exact match trips strict mode. */ const SELECT_TOPIC_LABEL = /^Select topic /; const SELECT_PARTITION_LABEL = /^Select partition /; @@ -18,8 +21,9 @@ test.describe('Reassign partitions', () => { test('renders the cluster statistics and the step indicator', async ({ page }) => { await page.goto('/reassign-partitions'); + const stats = page.getByTestId('cluster-statistics'); for (const label of ['Broker Count', 'Leader Partitions', 'Replica Partitions', 'Total Partitions']) { - await expect(page.getByText(label, { exact: true })).toBeVisible(); + await expect(stats.getByText(label, { exact: true })).toBeVisible(); } const steps = page.getByRole('list', { name: 'Reassignment steps' }); From c1bea1fa341a4424f340a2f15d8515ff52186ce2 Mon Sep 17 00:00:00 2001 From: Beniamin Malinski Date: Mon, 7 Sep 2026 23:18:40 +0800 Subject: [PATCH 05/14] fix(review): restore reassignment table sorting --- .../reassign-partitions/step1-partitions.tsx | 8 +- .../step2-brokers.test.tsx | 33 ++++++ .../reassign-partitions/step2-brokers.tsx | 26 ++++- .../reassign-partitions/step3-review.tsx | 12 +- .../topic-sorting.test.tsx | 104 ++++++++++++++++++ 5 files changed, 165 insertions(+), 18 deletions(-) create mode 100644 frontend/src/components/pages/reassign-partitions/step2-brokers.test.tsx create mode 100644 frontend/src/components/pages/reassign-partitions/topic-sorting.test.tsx diff --git a/frontend/src/components/pages/reassign-partitions/step1-partitions.tsx b/frontend/src/components/pages/reassign-partitions/step1-partitions.tsx index a756032cec..18c002922f 100644 --- a/frontend/src/components/pages/reassign-partitions/step1-partitions.tsx +++ b/frontend/src/components/pages/reassign-partitions/step1-partitions.tsx @@ -26,7 +26,6 @@ import { DefaultSkeleton, InfoText, ZeroSizeWrapper } from '../../../utils/tsx-u import { prettyBytesOrNA } from '../../../utils/utils'; import { BrokerList } from '../../misc/broker-list'; import { renderLogDirSummary, WarningToolip } from '../../misc/common'; -import { SearchTitle } from '../../misc/kowl-table'; export type TopicWithPartitions = Topic & { partitions: Partition[]; @@ -104,13 +103,8 @@ export class StepSelectPartitions extends Component<{ }, { id: 'topicName', - header: () => ( - - ), + header: ({ column }) => , accessorKey: 'topicName', - // This column's header *is* the search control (SearchTitle), so there is nowhere to - // put a sort affordance — the model says so rather than enabling sorting with no UI. - enableSorting: false, enableHiding: false, cell: ({ row: { original: record } }) => { const content = filterActive ? ( diff --git a/frontend/src/components/pages/reassign-partitions/step2-brokers.test.tsx b/frontend/src/components/pages/reassign-partitions/step2-brokers.test.tsx new file mode 100644 index 0000000000..b00df03b65 --- /dev/null +++ b/frontend/src/components/pages/reassign-partitions/step2-brokers.test.tsx @@ -0,0 +1,33 @@ +import { afterEach, expect, rs, test } from '@rstest/core'; +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { StepSelectBrokers } from './step2-brokers'; +import { useApiStore } from '../../../state/backend-api'; + +const initialState = useApiStore.getState(); +afterEach(() => useApiStore.setState(initialState, true)); + +test('sorts target brokers by space without changing selection identity', async () => { + useApiStore.setState({ + clusterInfo: { + controllerId: 1, + kafkaVersion: '4.0', + brokers: [ + { brokerId: 1, address: 'one', rack: 'a', logDirSize: 1000, config: { configs: undefined, error: undefined } }, + { brokerId: 2, address: 'two', rack: 'b', logDirSize: 100, config: { configs: undefined, error: undefined } }, + ], + }, + }); + const user = userEvent.setup(); + const onSelectionChange = rs.fn(); + render(); + await user.click(screen.getByRole('button', { name: 'Used Space' })); + await user.click(screen.getByRole('menuitem', { name: 'Asc' })); + expect(within(screen.getAllByRole('row')[1]).getByRole('checkbox', { name: 'Select broker 2' })).toBeVisible(); + await user.click(screen.getByRole('checkbox', { name: 'Select broker 2' })); + expect(onSelectionChange).toHaveBeenLastCalledWith([2]); + await user.click(screen.getByRole('button', { name: 'Used Space' })); + await user.click(screen.getByRole('menuitem', { name: 'Desc' })); + expect(within(screen.getAllByRole('row')[1]).getByRole('checkbox', { name: 'Select broker 1' })).toBeVisible(); +}); diff --git a/frontend/src/components/pages/reassign-partitions/step2-brokers.tsx b/frontend/src/components/pages/reassign-partitions/step2-brokers.tsx index 31f215705b..8b169da2c9 100644 --- a/frontend/src/components/pages/reassign-partitions/step2-brokers.tsx +++ b/frontend/src/components/pages/reassign-partitions/step2-brokers.tsx @@ -10,7 +10,7 @@ */ import { Checkbox } from 'components/redpanda-ui/components/checkbox'; -import { DataTable, type DataTableRow } from 'components/redpanda-ui/components/data-table'; +import { DataTable, DataTableColumnHeader, type DataTableRow } from 'components/redpanda-ui/components/data-table'; import { Component } from 'react'; import { SelectionInfoBar } from './components/statistics-bar'; @@ -94,18 +94,32 @@ export class StepSelectBrokers extends Component<{ ); }, }, - { header: 'ID', accessorKey: 'brokerId' }, - { header: 'Broker Address', size: Number.POSITIVE_INFINITY, accessorKey: 'address' }, - { header: 'Rack', accessorKey: 'rack' }, { - header: 'Used Space', + header: ({ column }) => , + enableHiding: false, + accessorKey: 'brokerId', + }, + { + header: ({ column }) => , + enableHiding: false, + size: Number.POSITIVE_INFINITY, + accessorKey: 'address', + }, + { + header: ({ column }) => , + enableHiding: false, + accessorKey: 'rack', + }, + { + header: ({ column }) => , + enableHiding: false, accessorKey: 'logDirSize', cell: ({ row: { original } }) => prettyBytesOrNA(original.logDirSize), }, ]} data={this.brokers} pagination - sorting={false} + sorting /> ); diff --git a/frontend/src/components/pages/reassign-partitions/step3-review.tsx b/frontend/src/components/pages/reassign-partitions/step3-review.tsx index d25de519ea..3f213a8cb4 100644 --- a/frontend/src/components/pages/reassign-partitions/step3-review.tsx +++ b/frontend/src/components/pages/reassign-partitions/step3-review.tsx @@ -11,7 +11,7 @@ import { ChevronDownIcon, ChevronRightIcon } from 'components/icons'; import { Button } from 'components/redpanda-ui/components/button'; -import { DataTable } from 'components/redpanda-ui/components/data-table'; +import { DataTable, DataTableColumnHeader } from 'components/redpanda-ui/components/data-table'; import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from 'components/redpanda-ui/components/empty'; import { InboxIcon } from 'lucide-react'; import { Component } from 'react'; @@ -100,7 +100,8 @@ export class StepReview extends Component<{ ) : null, }, { - header: 'Topic', + header: ({ column }) => , + enableHiding: false, accessorKey: 'topicName', }, { @@ -144,7 +145,7 @@ export class StepReview extends Component<{ ]} data={this.props.topicsWithMoves} pagination={false} - sorting={false} + sorting subComponent={({ row: { original: topic } }) => (
{topic.selectedPartitions ? ( @@ -297,7 +298,8 @@ const ReviewPartitionTable = (props: { topic: Topic; topicPartitions: Partition[ columns={[ { - header: 'Partition', + header: ({ column }) => , + enableHiding: false, accessorKey: 'id', }, { @@ -323,7 +325,7 @@ const ReviewPartitionTable = (props: { topic: Topic; topicPartitions: Partition[ ]} data={props.topicPartitions} pagination={false} - sorting={false} + sorting />
); diff --git a/frontend/src/components/pages/reassign-partitions/topic-sorting.test.tsx b/frontend/src/components/pages/reassign-partitions/topic-sorting.test.tsx new file mode 100644 index 0000000000..314e900eb0 --- /dev/null +++ b/frontend/src/components/pages/reassign-partitions/topic-sorting.test.tsx @@ -0,0 +1,104 @@ +import { afterEach, expect, rs, test } from '@rstest/core'; +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import ReassignPartitions from './reassign-partitions'; +import { StepSelectPartitions } from './step1-partitions'; +import { StepReview } from './step3-review'; +import { appGlobal } from '../../../state/app-global'; +import { useApiStore } from '../../../state/backend-api'; +import type { Partition, Topic } from '../../../state/rest-interfaces'; + +const initialState = useApiStore.getState(); +const initialRefresh = appGlobal.onRefresh; +afterEach(() => { + useApiStore.setState(initialState, true); + appGlobal.onRefresh = initialRefresh; + rs.restoreAllMocks(); +}); +const topics: Topic[] = ['zebra', 'alpha'].map((topicName) => ({ + topicName, + isInternal: false, + partitionCount: 2, + replicationFactor: 1, + cleanupPolicy: 'delete', + documentation: 'UNKNOWN', + logDirSummary: { totalSizeBytes: 0, replicaErrors: null, hint: null }, + allowedActions: undefined, +})); +const partitions = (topicName: string): Partition[] => + [2, 1].map((id) => ({ + id, + topicName, + partitionError: null, + replicas: [1], + offlineReplicas: [], + inSyncReplicas: [1], + leader: 1, + partitionLogDirs: [], + waterMarksError: null, + waterMarkLow: 0, + waterMarkHigh: 0, + replicaSize: 0, + hasErrors: false, + })); +const seed = () => + useApiStore.setState({ + topics, + topicPartitions: new Map(topics.map((topic) => [topic.topicName, partitions(topic.topicName)])), + }); + +test('sorts selectable topics by name', async () => { + seed(); + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole('button', { name: 'Topic' })); + await user.click(screen.getByRole('menuitem', { name: 'Asc' })); + expect(within(screen.getAllByRole('row')[1]).getByRole('checkbox', { name: 'Select topic alpha' })).toBeVisible(); +}); + +test('sorts review topics and expanded partitions', async () => { + seed(); + const user = userEvent.setup(); + rs.spyOn(ReassignPartitions.prototype, 'refreshData').mockImplementation(() => undefined); + const parent = new ReassignPartitions({ matchedPath: '/reassign-partitions' }); + const topicsWithMoves = topics.map((topic) => ({ + topicName: topic.topicName, + topic, + allPartitions: partitions(topic.topicName), + selectedPartitions: partitions(topic.topicName).map((partition) => ({ + ...partition, + brokersBefore: [1], + brokersAfter: [2], + numAddedBrokers: 1, + numRemovedBrokers: 1, + changedLeader: true, + anyChanges: true, + })), + })); + render( + ({ + topicName: topic.topicName, + partitions: partitions(topic.topicName).map((partition) => ({ partitionId: partition.id, replicas: [2] })), + })), + }} + partitionSelection={{}} + reassignPartitions={parent} + topicsWithMoves={topicsWithMoves} + /> + ); + await user.click(screen.getByRole('button', { name: 'Topic' })); + await user.click(screen.getByRole('menuitem', { name: 'Asc' })); + const firstRow = screen.getAllByRole('row')[1]; + expect(within(firstRow).getByText('alpha')).toBeVisible(); + await user.click(within(firstRow).getByRole('button', { name: 'Expand row' })); + await user.click(screen.getByRole('button', { name: 'Partition' })); + await user.click(screen.getByRole('menuitem', { name: 'Asc' })); + const table = screen.getByRole('columnheader', { name: 'Partition' }).closest('table'); + if (!table) { + throw new Error('Missing partition table'); + } + expect(within(within(table).getAllByRole('row')[1]).getAllByRole('cell')[0]).toHaveTextContent('1'); +}); From f4d08b9d5ce49d732d05d0fd8edefe825dc7f629 Mon Sep 17 00:00:00 2001 From: Peter Zenger Date: Tue, 8 Sep 2026 07:19:32 -0700 Subject: [PATCH 06/14] =?UTF-8?q?frontend:=20reassign=20partitions=20?= =?UTF-8?q?=E2=80=94=20drop=20the=20orphaned=20SearchTitle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sorting fix replaced the Topic column's SearchTitle header with DataTableColumnHeader; that overlay was already unreachable (nothing ever set filterOpen), and kowl-table.tsx had no other importer. Delete it and the filterOpen flag it wrote to. Co-Authored-By: Claude Fable 5.1 --- frontend/src/components/misc/kowl-table.tsx | 106 ------------------ .../reassign-partitions/step1-partitions.tsx | 2 - 2 files changed, 108 deletions(-) delete mode 100644 frontend/src/components/misc/kowl-table.tsx diff --git a/frontend/src/components/misc/kowl-table.tsx b/frontend/src/components/misc/kowl-table.tsx deleted file mode 100644 index ce4ce78e57..0000000000 --- a/frontend/src/components/misc/kowl-table.tsx +++ /dev/null @@ -1,106 +0,0 @@ -/** - * Copyright 2022 Redpanda Data, Inc. - * - * Use of this software is governed by the Business Source License - * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md - * - * As of the Change Date specified in that file, in accordance with - * the Business Source License, use of this software will be governed - * by the Apache License, Version 2.0 - */ - -import { Input } from 'components/redpanda-ui/components/input'; -import React, { Component } from 'react'; - -export class SearchTitle extends Component<{ - title: string; - observableFilterOpen: { filterOpen: boolean }; - observableSettings: { quickSearch: string }; -}> { - inputRef = React.createRef(); // reference to input, used to focus it - - state = { - filterOpen: false, - quickSearch: '', - }; - - constructor(p: { - title: string; - observableFilterOpen: { filterOpen: boolean }; - observableSettings: { quickSearch: string }; - }) { - super(p); - this.hideSearchBar = this.hideSearchBar.bind(this); - this.focusInput = this.focusInput.bind(this); - this.onKeyDown = this.onKeyDown.bind(this); - } - - render() { - const props = this.props; - - if (!this.state.filterOpen) { - return this.props.title; - } - - // Render the actual search bar - - // inputRef won't be set yet, so we delay by one frame - setTimeout(this.focusInput); - - return ( - - {!this.state.filterOpen && {this.props.title}} - {/* NOTE: this overlay is currently unreachable — `filterOpen` is initialised false and - nothing sets it true (see `hideSearchBar`, which only clears it). Kept as-is by the - migration; wiring or removing it is a separate change. The absolute positioning also - assumed Chakra's `Th` was `position: relative`, which the Registry TableHead is not. */} -
- { - const inputWrapper = e.target.parentElement; - const focusInside = inputWrapper?.contains(e.relatedTarget as HTMLElement); - - if (focusInside) { - // Most likely a click on the "clear" button - props.observableSettings.quickSearch = ''; - this.setState({ quickSearch: '' }); - this.hideSearchBar(); - } else { - setTimeout(this.hideSearchBar); - } - }} - onChange={(e) => { - props.observableSettings.quickSearch = e.target.value; - this.setState({ quickSearch: e.target.value }); - }} - onClick={(e) => e.stopPropagation()} - onKeyDown={this.onKeyDown} - onMouseDown={(e) => e.stopPropagation()} - onMouseUp={(e) => e.stopPropagation()} - placeholder="Enter search term/regex" - ref={this.inputRef} - spellCheck={false} - value={this.state.quickSearch} - /> -
-
- ); - } - - focusInput() { - this.inputRef.current?.focus(); - } - - hideSearchBar() { - this.props.observableFilterOpen.filterOpen = false; - this.setState({ filterOpen: false }); - } - - onKeyDown(e: React.KeyboardEvent) { - if (e.key === 'Enter' || e.key === 'Escape') { - this.hideSearchBar(); - } - } -} diff --git a/frontend/src/components/pages/reassign-partitions/step1-partitions.tsx b/frontend/src/components/pages/reassign-partitions/step1-partitions.tsx index 18c002922f..6dbd17dd45 100644 --- a/frontend/src/components/pages/reassign-partitions/step1-partitions.tsx +++ b/frontend/src/components/pages/reassign-partitions/step1-partitions.tsx @@ -37,8 +37,6 @@ export class StepSelectPartitions extends Component<{ onPartitionSelectionChange: (newSelection: PartitionSelection) => void; throttledTopics: string[]; }> { - filterOpen = false; // topic name searchbar - constructor(props: { selectedTopicPartitions: PartitionSelection; partitionSelection: PartitionSelection; From 1672fb10bbb2a9c3d9d142eddc15781719d76397 Mon Sep 17 00:00:00 2001 From: Peter Zenger Date: Tue, 8 Sep 2026 09:12:21 -0700 Subject: [PATCH 07/14] =?UTF-8?q?frontend:=20reassign=20partitions=20?= =?UTF-8?q?=E2=80=94=20legacy=20table=20parity=20and=20Stat?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The wizard tables paginate like the legacy DataTable again: 50 a page with the pager only past that (10 for the active-reassignments list, which had defaultPageSize 10), instead of 10 a page with a permanent footer or, on the review step, no paging at all. - Column size hints the Registry never reads are gone from all four tables. - Statistic wrapper replaced by the Registry Stat on the header row. Co-Authored-By: Claude Fable 5.1 --- .../components/active-reassignments.tsx | 7 ++----- .../reassign-partitions/reassign-partitions.tsx | 13 +++++++------ .../reassign-partitions/step1-partitions.tsx | 12 ++++++++---- .../pages/reassign-partitions/step2-brokers.tsx | 8 ++++++-- .../pages/reassign-partitions/step3-review.tsx | 15 ++++++++------- 5 files changed, 31 insertions(+), 24 deletions(-) diff --git a/frontend/src/components/pages/reassign-partitions/components/active-reassignments.tsx b/frontend/src/components/pages/reassign-partitions/components/active-reassignments.tsx index 6bf17d33a1..0b6ecfb695 100644 --- a/frontend/src/components/pages/reassign-partitions/components/active-reassignments.tsx +++ b/frontend/src/components/pages/reassign-partitions/components/active-reassignments.tsx @@ -114,22 +114,18 @@ export class ActiveReassignments extends Component<{ columns={[ { header: 'Topic', - size: 1, cell: ({ row: { original } }) => , }, { header: 'Progress', - size: Number.POSITIVE_INFINITY, cell: ({ row: { original } }) => , }, { header: 'ETA', - size: 100, cell: ({ row: { original } }) => , }, { header: 'Brokers', - size: 1, cell: ({ row: { original } }) => , }, ]} @@ -139,7 +135,8 @@ export class ActiveReassignments extends Component<{ onRow={(row) => { this.setState({ reassignmentDetails: row.original }); }} - pagination + // Legacy parity: ten a page, pager only past that. + pagination={currentReassignments.length > 10} sorting={false} /> diff --git a/frontend/src/components/pages/reassign-partitions/reassign-partitions.tsx b/frontend/src/components/pages/reassign-partitions/reassign-partitions.tsx index 459eabdde9..98884f1d23 100644 --- a/frontend/src/components/pages/reassign-partitions/reassign-partitions.tsx +++ b/frontend/src/components/pages/reassign-partitions/reassign-partitions.tsx @@ -20,6 +20,7 @@ import { DialogHeader, DialogTitle, } from 'components/redpanda-ui/components/dialog'; +import { Stat } from 'components/redpanda-ui/components/stat'; import { motion } from 'motion/react'; import { closeToast, showToast, updateToast } from 'utils/toast.utils'; @@ -55,7 +56,6 @@ import { showErrorModal } from '../../misc/error-modal'; import { NullFallbackBoundary } from '../../misc/null-fallback-boundary'; import PageContent from '../../misc/page-content'; import Section from '../../misc/section'; -import { Statistic } from '../../misc/statistic'; import { PageComponent, type PageInitHelper } from '../page'; export type PartitionSelection = { @@ -209,11 +209,12 @@ class ReassignPartitions extends PageComponent {
{/* Scoped: step 1's SelectionInfoBar repeats these labels. */}
- - - - + + + row.getCanExpand() ? ( @@ -122,7 +125,6 @@ export class StepSelectPartitions extends Component<{ return
{content}
; }, - size: Number.POSITIVE_INFINITY, }, { id: 'partitionCount', @@ -185,7 +187,7 @@ export class StepSelectPartitions extends Component<{ data={this.topicPartitions} // Chakra took a no-op `onRowSelectionChange` plus a placeholder `rowSelection`; selection // is done by the `check` column above, so the Registry table simply leaves it off. - pagination + pagination={this.topicPartitions.length > DEFAULT_TABLE_PAGE_SIZE} sorting subComponent={({ row: { original: topic } }) => ( )} + tableOptions={TABLE_OPTIONS} />
); @@ -361,8 +364,9 @@ export class SelectPartitionTable extends Component<{ }, ]} data={this.props.topicPartitions} - pagination + pagination={this.props.topicPartitions.length > DEFAULT_TABLE_PAGE_SIZE} sorting + tableOptions={TABLE_OPTIONS} /> ); } diff --git a/frontend/src/components/pages/reassign-partitions/step2-brokers.tsx b/frontend/src/components/pages/reassign-partitions/step2-brokers.tsx index 8b169da2c9..b8a3a415aa 100644 --- a/frontend/src/components/pages/reassign-partitions/step2-brokers.tsx +++ b/frontend/src/components/pages/reassign-partitions/step2-brokers.tsx @@ -18,6 +18,10 @@ import type { PartitionSelection } from './reassign-partitions'; import { api } from '../../../state/backend-api'; import type { Broker } from '../../../state/rest-interfaces'; import { eqSet, prettyBytesOrNA } from '../../../utils/utils'; +import { DEFAULT_TABLE_PAGE_SIZE } from '../../constants'; + +// Legacy table parity: 50 rows a page, pager only past that. +const TABLE_OPTIONS = { initialState: { pagination: { pageIndex: 0, pageSize: DEFAULT_TABLE_PAGE_SIZE } } }; export class StepSelectBrokers extends Component<{ selectedBrokerIds: number[]; @@ -102,7 +106,6 @@ export class StepSelectBrokers extends Component<{ { header: ({ column }) => , enableHiding: false, - size: Number.POSITIVE_INFINITY, accessorKey: 'address', }, { @@ -118,8 +121,9 @@ export class StepSelectBrokers extends Component<{ }, ]} data={this.brokers} - pagination + pagination={this.brokers.length > DEFAULT_TABLE_PAGE_SIZE} sorting + tableOptions={TABLE_OPTIONS} /> ); diff --git a/frontend/src/components/pages/reassign-partitions/step3-review.tsx b/frontend/src/components/pages/reassign-partitions/step3-review.tsx index 3f213a8cb4..1cb0666f28 100644 --- a/frontend/src/components/pages/reassign-partitions/step3-review.tsx +++ b/frontend/src/components/pages/reassign-partitions/step3-review.tsx @@ -24,8 +24,12 @@ import type { Partition, PartitionReassignmentRequest, Topic, TopicAssignment } import { uiSettings } from '../../../state/ui'; import { DefaultSkeleton, InfoText } from '../../../utils/tsx-utils'; import { prettyBytesOrNA, prettyMilliseconds } from '../../../utils/utils'; +import { DEFAULT_TABLE_PAGE_SIZE } from '../../constants'; import { BrokerList } from '../../misc/broker-list'; +// Legacy table parity: 50 rows a page, pager only past that. +const TABLE_OPTIONS = { initialState: { pagination: { pageIndex: 0, pageSize: DEFAULT_TABLE_PAGE_SIZE } } }; + export type PartitionWithMoves = Partition & { brokersBefore: number[]; brokersAfter: number[]; @@ -84,7 +88,6 @@ export class StepReview extends Component<{ // Chakra's DataTable injected this column whenever `subComponent` was set; the Registry one does not. { id: 'expander', - size: 40, enableSorting: false, cell: ({ row }) => row.getCanExpand() ? ( @@ -106,7 +109,6 @@ export class StepReview extends Component<{ }, { header: 'Brokers Before', - size: 50, cell: ({ row: { original: topic } }) => { const brokersBefore = topic.selectedPartitions .flatMap((x) => x.brokersBefore) @@ -117,7 +119,6 @@ export class StepReview extends Component<{ }, { accessorKey: 'Brokers After', - size: 50, cell: ({ row: { original: topic } }) => { const plannedBrokers = topic.selectedPartitions .flatMap((x) => x.brokersAfter) @@ -128,7 +129,6 @@ export class StepReview extends Component<{ }, { id: 'numAddedBrokers', - size: 100, header: () => ( Reassignments @@ -138,13 +138,12 @@ export class StepReview extends Component<{ }, { header: 'Estimated Traffic', - size: 120, cell: ({ row: { original: topic } }) => prettyBytesOrNA(topic.selectedPartitions.sum((p) => p.numAddedBrokers * p.replicaSize)), }, ]} data={this.props.topicsWithMoves} - pagination={false} + pagination={this.props.topicsWithMoves.length > DEFAULT_TABLE_PAGE_SIZE} sorting subComponent={({ row: { original: topic } }) => (
@@ -160,6 +159,7 @@ export class StepReview extends Component<{ )}
)} + tableOptions={TABLE_OPTIONS} /> {this.reassignmentOptions()} @@ -324,8 +324,9 @@ const ReviewPartitionTable = (props: { topic: Topic; topicPartitions: Partition[ }, ]} data={props.topicPartitions} - pagination={false} + pagination={props.topicPartitions.length > DEFAULT_TABLE_PAGE_SIZE} sorting + tableOptions={TABLE_OPTIONS} />
); From 0c5a84f260071f3ede04691879994ad00d38a7bf Mon Sep 17 00:00:00 2001 From: Peter Zenger Date: Tue, 8 Sep 2026 09:36:26 -0700 Subject: [PATCH 08/14] =?UTF-8?q?frontend:=20reassign=20partitions=20?= =?UTF-8?q?=E2=80=94=20verified=20review=20findings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 'Remove throttle' re-applied the existing throttle: the handler read the stale value from the render closure. It now takes the value explicitly, and the topic-throttle dialog closes after a successful reset. - Partition-error popovers open on hover again, are titled (the dialog was unnamed), and take the width their text needs; the cancel-confirm popover is titled and its buttons are a spaced pair, not an attached group. - The Size column sorted on a field that does not exist; it reads logDirSummary.totalSizeBytes. The Progress column takes the slack again. - Progress bars paint brand for active state, not the Registry default; the slider bubble was background-on-background; the slider's dead 'Unlimited' end is gone. Co-Authored-By: Claude Fable 5.1 --- .../components/active-reassignments.tsx | 45 +++++++++++-------- .../components/bandwidth-slider.tsx | 7 +-- .../reassign-partitions.tsx | 1 + .../reassign-partitions/step1-partitions.tsx | 24 ++++++---- 4 files changed, 45 insertions(+), 32 deletions(-) diff --git a/frontend/src/components/pages/reassign-partitions/components/active-reassignments.tsx b/frontend/src/components/pages/reassign-partitions/components/active-reassignments.tsx index 0b6ecfb695..48475779d6 100644 --- a/frontend/src/components/pages/reassign-partitions/components/active-reassignments.tsx +++ b/frontend/src/components/pages/reassign-partitions/components/active-reassignments.tsx @@ -10,7 +10,6 @@ */ import { Button } from 'components/redpanda-ui/components/button'; -import { ButtonGroup } from 'components/redpanda-ui/components/button-group'; import { Checkbox } from 'components/redpanda-ui/components/checkbox'; import { DataTable } from 'components/redpanda-ui/components/data-table'; import { @@ -22,7 +21,7 @@ import { DialogTitle, } from 'components/redpanda-ui/components/dialog'; import { Label } from 'components/redpanda-ui/components/label'; -import { Popover, PopoverContent, PopoverTrigger } from 'components/redpanda-ui/components/popover'; +import { Popover, PopoverContent, PopoverTitle, PopoverTrigger } from 'components/redpanda-ui/components/popover'; import { Progress } from 'components/redpanda-ui/components/progress'; import { SkeletonText } from 'components/redpanda-ui/components/skeleton'; import React, { Component, type FC, type JSX, useRef, useState } from 'react'; @@ -118,7 +117,12 @@ export class ActiveReassignments extends Component<{ }, { header: 'Progress', - cell: ({ row: { original } }) => , + // The Registry DataTable ignores column sizes; a viewport-wide max-content hands this column the slack. + cell: ({ row: { original } }) => ( +
+ +
+ ), }, { header: 'ETA', @@ -183,7 +187,8 @@ export const ThrottleDialog: FC<{ const throttleValue = newThrottleValue ?? 0; const noChange = newThrottleValue === lastKnownMinThrottle || newThrottleValue === null; - const applyBandwidthThrottle = async () => { + // Takes the value explicitly: 'Remove throttle' clears state and applies in the same tick. + const applyBandwidthThrottle = async (value: number | null) => { toastRef.current = showToast({ status: 'loading', description: 'Setting throttle rate...', @@ -199,10 +204,10 @@ export const ThrottleDialog: FC<{ return; } - const shouldSet = newThrottleValue !== null && newThrottleValue > 0; + const shouldSet = value !== null && value > 0; try { if (shouldSet) { - await api.setReplicationThrottleRate(allBrokers, newThrottleValue as number); + await api.setReplicationThrottleRate(allBrokers, value); } else { await api.resetReplicationThrottleRate(allBrokers); } @@ -268,7 +273,7 @@ export const ThrottleDialog: FC<{ } />
-
Confirmation
+ Confirmation
Are you sure you want to stop the reassignment?
-
- - - - +
+ +
@@ -707,7 +710,11 @@ const ProgressBar = (p: { <> {/* Chakra's colorScheme becomes an indicator class: the Registry indicator paints `bg-primary`. */}
12) { - return 'Unlimited'; - } const v = Math.round(10 ** f.clamp(3, 12)); return `${prettyNumber(v).toUpperCase()}B/s`; }; @@ -101,7 +98,7 @@ export function BandwidthSlider(props: ValueAndChangeCallback | SettingsCallback */} {isDragging && tipText(sliderValue) ? (
{tipText(sliderValue)} diff --git a/frontend/src/components/pages/reassign-partitions/reassign-partitions.tsx b/frontend/src/components/pages/reassign-partitions/reassign-partitions.tsx index 98884f1d23..ce5ea43f60 100644 --- a/frontend/src/components/pages/reassign-partitions/reassign-partitions.tsx +++ b/frontend/src/components/pages/reassign-partitions/reassign-partitions.tsx @@ -409,6 +409,7 @@ class ReassignPartitions extends PageComponent { } await this.refreshTopicConfigs(); + this.setState({ removeThrottleFromTopicsContent: null }); }} variant="destructive" > diff --git a/frontend/src/components/pages/reassign-partitions/step1-partitions.tsx b/frontend/src/components/pages/reassign-partitions/step1-partitions.tsx index dae13abfd2..9345a346b6 100644 --- a/frontend/src/components/pages/reassign-partitions/step1-partitions.tsx +++ b/frontend/src/components/pages/reassign-partitions/step1-partitions.tsx @@ -13,7 +13,7 @@ import { ChevronDownIcon, ChevronRightIcon, WarningIcon } from 'components/icons import { Button } from 'components/redpanda-ui/components/button'; import { Checkbox } from 'components/redpanda-ui/components/checkbox'; import { DataTable, DataTableColumnHeader, type DataTableRow } from 'components/redpanda-ui/components/data-table'; -import { Popover, PopoverContent, PopoverTrigger } from 'components/redpanda-ui/components/popover'; +import { Popover, PopoverContent, PopoverTitle, PopoverTrigger } from 'components/redpanda-ui/components/popover'; import { Component } from 'react'; import Highlighter from 'react-highlight-words'; @@ -180,7 +180,7 @@ export class StepSelectPartitions extends Component<{ id: 'totalSizeBytes', enableHiding: false, header: ({ column }) => , - accessorKey: 'totalSizeBytes', + accessorFn: (topic) => topic.logDirSummary?.totalSizeBytes ?? 0, cell: ({ row: { original: r } }) => renderLogDirSummary(r.logDirSummary), }, ]} @@ -381,7 +381,10 @@ function renderPartitionError(partition: Partition) { return ( + {/* Hover-to-open, as the Chakra popover was. */} @@ -390,9 +393,10 @@ function renderPartitionError(partition: Partition) { } /> - -
Partition Error
-
{txt}
+ {/* PopoverContent is a fixed w-72; the error text needs the room. */} + + Partition Error +
{txt}
); @@ -401,7 +405,10 @@ function renderPartitionError(partition: Partition) { function PartitionErrorsForTopic(_props: { partitionsWithErrors: number }) { return ( + {/* Hover-to-open, as the Chakra popover was. */} @@ -410,9 +417,10 @@ function PartitionErrorsForTopic(_props: { partitionsWithErrors: number }) { } /> - -
Partition Error
-
+ {/* PopoverContent is a fixed w-72; the error text needs the room. */} + + Partition Error +
Some partitions could not be retreived.
Expand the topic to see which partitions are affected. From ef970ef66b4a53c33e1a3acdcfc878d32cf4621a Mon Sep 17 00:00:00 2001 From: Peter Zenger Date: Tue, 8 Sep 2026 10:57:38 -0700 Subject: [PATCH 09/14] =?UTF-8?q?frontend:=20reassign=20partitions=20?= =?UTF-8?q?=E2=80=94=20final=20review?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The remove-throttle dialog's hover note is a Registry Tooltip; the hand-rolled bubble was clipped by the Dialog's overflow. - The dead topic-search path (Highlighter and the quickSearch read) goes with the deleted SearchTitle. - Comments trimmed to the constraints; the wizard-steps note no longer misstates the Registry Stepper. Co-Authored-By: Claude Fable 5.1 --- .../components/active-reassignments.tsx | 6 +++--- .../components/bandwidth-slider.tsx | 2 +- .../components/wizard-steps.tsx | 7 ++----- .../reassign-partitions/reassign-partitions.tsx | 17 ++++++++++------- .../reassign-partitions/step1-partitions.tsx | 14 ++------------ 5 files changed, 18 insertions(+), 28 deletions(-) diff --git a/frontend/src/components/pages/reassign-partitions/components/active-reassignments.tsx b/frontend/src/components/pages/reassign-partitions/components/active-reassignments.tsx index 48475779d6..68a12de42c 100644 --- a/frontend/src/components/pages/reassign-partitions/components/active-reassignments.tsx +++ b/frontend/src/components/pages/reassign-partitions/components/active-reassignments.tsx @@ -243,7 +243,7 @@ export const ThrottleDialog: FC<{ }} open={visible} > - {/* Chakra's `minW="3xl"` was 48rem; `lg` (42rem) is the nearest rung. */} + {/* Nearest rung to the old 48rem minimum. */} Throttle Settings @@ -420,7 +420,7 @@ export class ReassignmentDetailsDialog extends Component<{ state: ReassignmentSt }} open={visible} > - {/* Chakra's `minW="3xl"` was 48rem; `lg` (42rem) is the nearest rung. */} + {/* Nearest rung to the old 48rem minimum. */} Reassignment: {state.topicName} @@ -708,7 +708,7 @@ const ProgressBar = (p: { const { percent, state, left, right } = p; return ( <> - {/* Chakra's colorScheme becomes an indicator class: the Registry indicator paints `bg-primary`. */} + {/* The indicator paints bg-primary by default; the tone goes through the slot class. */} `. - * - * The Registry ships `defineStepper`, but that owns navigation through its own `methods`. This - * wizard keeps `currentStep` in `ReassignPartitions`'s own state and every guard reads it, so the - * indicator stays a pure function of that index — nothing here can move the wizard. + * Presentational step indicator. The wizard owns `currentStep`; the Registry `Stepper` would need a + * re-keyed Provider to follow external state, so this stays a pure function of the index. */ export const WizardSteps = ({ steps, currentStep }: { steps: { title: string }[]; currentStep: number }) => (
    diff --git a/frontend/src/components/pages/reassign-partitions/reassign-partitions.tsx b/frontend/src/components/pages/reassign-partitions/reassign-partitions.tsx index ce5ea43f60..3c0a4194d5 100644 --- a/frontend/src/components/pages/reassign-partitions/reassign-partitions.tsx +++ b/frontend/src/components/pages/reassign-partitions/reassign-partitions.tsx @@ -21,6 +21,7 @@ import { DialogTitle, } from 'components/redpanda-ui/components/dialog'; import { Stat } from 'components/redpanda-ui/components/stat'; +import { Tooltip, TooltipContent, TooltipTrigger } from 'components/redpanda-ui/components/tooltip'; import { motion } from 'motion/react'; import { closeToast, showToast, updateToast } from 'utils/toast.utils'; @@ -327,7 +328,7 @@ class ReassignPartitions extends PageComponent { }} open={this.state.removeThrottleFromTopicsContent !== null} > - {/* Chakra's `minW="5xl"` was 64rem; `xl` (56rem) is the nearest rung — `full` is 90vw. */} + {/* Nearest rung to the old 64rem minimum. */} @@ -343,17 +344,19 @@ class ReassignPartitions extends PageComponent { There are {this.state.topicsWithThrottle.length} topics with throttling applied to their replicas.
    Kowl implements throttling of reassignments by setting{' '} - - two configuration values - + + two configuration values} + /> + Kowl sets those two configuration entries when throttling a topic reassignment: -
    +
    leader.replication.throttled.replicas
    follower.replication.throttled.replicas
    - - {' '} + + {' '} in a topics configuration.
    So if you previously used Kowl to reassign any of the partitions of the following topics, the diff --git a/frontend/src/components/pages/reassign-partitions/step1-partitions.tsx b/frontend/src/components/pages/reassign-partitions/step1-partitions.tsx index 9345a346b6..60f05a94f1 100644 --- a/frontend/src/components/pages/reassign-partitions/step1-partitions.tsx +++ b/frontend/src/components/pages/reassign-partitions/step1-partitions.tsx @@ -15,13 +15,11 @@ import { Checkbox } from 'components/redpanda-ui/components/checkbox'; import { DataTable, DataTableColumnHeader, type DataTableRow } from 'components/redpanda-ui/components/data-table'; import { Popover, PopoverContent, PopoverTitle, PopoverTrigger } from 'components/redpanda-ui/components/popover'; import { Component } from 'react'; -import Highlighter from 'react-highlight-words'; import { SelectionInfoBar } from './components/statistics-bar'; import type { PartitionSelection } from './reassign-partitions'; import { api } from '../../../state/backend-api'; import type { Partition, PartitionReassignmentsPartition, Topic } from '../../../state/rest-interfaces'; -import { uiSettings } from '../../../state/ui'; import { DefaultSkeleton, InfoText, ZeroSizeWrapper } from '../../../utils/tsx-utils'; import { prettyBytesOrNA } from '../../../utils/utils'; import { DEFAULT_TABLE_PAGE_SIZE } from '../../constants'; @@ -61,9 +59,6 @@ export class StepSelectPartitions extends Component<{ return DefaultSkeleton; } - const query = uiSettings.reassignment.quickSearch ?? ''; - const filterActive = query.length > 1; - return (
    {/* Current Selection */} @@ -108,11 +103,7 @@ export class StepSelectPartitions extends Component<{ accessorKey: 'topicName', enableHiding: false, cell: ({ row: { original: record } }) => { - const content = filterActive ? ( - - ) : ( - record.topicName - ); + const content = record.topicName; if (this.props.throttledTopics.includes(record.topicName)) { return ( @@ -185,8 +176,7 @@ export class StepSelectPartitions extends Component<{ }, ]} data={this.topicPartitions} - // Chakra took a no-op `onRowSelectionChange` plus a placeholder `rowSelection`; selection - // is done by the `check` column above, so the Registry table simply leaves it off. + // Selection is done by the `check` column; no table-level row selection. pagination={this.topicPartitions.length > DEFAULT_TABLE_PAGE_SIZE} sorting subComponent={({ row: { original: topic } }) => ( From df6f82387a3a44fcf1db5455dad56734c88baed3 Mon Sep 17 00:00:00 2001 From: Peter Zenger Date: Tue, 8 Sep 2026 12:42:41 -0700 Subject: [PATCH 10/14] =?UTF-8?q?frontend:=20reassign=20partitions=20?= =?UTF-8?q?=E2=80=94=20hiding=20off=20at=20table=20level?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit enableHiding: false moves from every column into each step's shared table options, so a column added later cannot bring the header menu's "Hide" item back. Co-Authored-By: Claude Fable 5.1 --- .../reassign-partitions/step1-partitions.tsx | 16 +++++----------- .../pages/reassign-partitions/step2-brokers.tsx | 11 +++++------ .../pages/reassign-partitions/step3-review.tsx | 9 +++++---- 3 files changed, 15 insertions(+), 21 deletions(-) diff --git a/frontend/src/components/pages/reassign-partitions/step1-partitions.tsx b/frontend/src/components/pages/reassign-partitions/step1-partitions.tsx index 60f05a94f1..6c6a1c08b4 100644 --- a/frontend/src/components/pages/reassign-partitions/step1-partitions.tsx +++ b/frontend/src/components/pages/reassign-partitions/step1-partitions.tsx @@ -26,8 +26,11 @@ import { DEFAULT_TABLE_PAGE_SIZE } from '../../constants'; import { BrokerList } from '../../misc/broker-list'; import { renderLogDirSummary, WarningToolip } from '../../misc/common'; -// Legacy table parity: 50 rows a page, pager only past that. -const TABLE_OPTIONS = { initialState: { pagination: { pageIndex: 0, pageSize: DEFAULT_TABLE_PAGE_SIZE } } }; +// Legacy table parity: 50 rows a page, pager only past that. No column-visibility UI, so hiding is off. +const TABLE_OPTIONS = { + enableHiding: false, + initialState: { pagination: { pageIndex: 0, pageSize: DEFAULT_TABLE_PAGE_SIZE } }, +}; export type TopicWithPartitions = Topic & { partitions: Partition[]; @@ -101,7 +104,6 @@ export class StepSelectPartitions extends Component<{ id: 'topicName', header: ({ column }) => , accessorKey: 'topicName', - enableHiding: false, cell: ({ row: { original: record } }) => { const content = record.topicName; @@ -119,7 +121,6 @@ export class StepSelectPartitions extends Component<{ }, { id: 'partitionCount', - enableHiding: false, header: ({ column }) => , accessorKey: 'partitionCount', cell: ({ row: { original: topic } }) => { @@ -140,7 +141,6 @@ export class StepSelectPartitions extends Component<{ }, { id: 'replicationFactor', - enableHiding: false, header: ({ column }) => , accessorKey: 'replicationFactor', cell: ({ row: { original: r } }) => { @@ -162,14 +162,12 @@ export class StepSelectPartitions extends Component<{ // resolved to the same TanStack column id. id: 'brokers', enableSorting: false, - enableHiding: false, header: 'Brokers', cell: ({ row: { original: record } }) => record.partitions?.map((p) => p.leader).distinct().length ?? 'N/A', }, { id: 'totalSizeBytes', - enableHiding: false, header: ({ column }) => , accessorFn: (topic) => topic.logDirSummary?.totalSizeBytes ?? 0, cell: ({ row: { original: r } }) => renderLogDirSummary(r.logDirSummary), @@ -312,7 +310,6 @@ export class SelectPartitionTable extends Component<{ { id: 'check', enableSorting: false, - enableHiding: false, header: 'Check', cell: ({ row: { original: partition } }: { row: DataTableRow }) => { const isSelected = this.props.getSelectedPartitions().includes(partition.id); @@ -329,14 +326,12 @@ export class SelectPartitionTable extends Component<{ }, { id: 'id', - enableHiding: false, header: ({ column }) => , accessorKey: 'id', }, { id: 'replicas', enableSorting: false, - enableHiding: false, header: 'Brokers', cell: ({ row: { original: partition } }: { row: DataTableRow }) => partition.replicas ? ( @@ -347,7 +342,6 @@ export class SelectPartitionTable extends Component<{ }, { id: 'replicaSize', - enableHiding: false, header: ({ column }) => , accessorKey: 'replicaSize', cell: ({ row: { original: partition } }) => prettyBytesOrNA(partition.replicaSize), diff --git a/frontend/src/components/pages/reassign-partitions/step2-brokers.tsx b/frontend/src/components/pages/reassign-partitions/step2-brokers.tsx index b8a3a415aa..dc0f449d67 100644 --- a/frontend/src/components/pages/reassign-partitions/step2-brokers.tsx +++ b/frontend/src/components/pages/reassign-partitions/step2-brokers.tsx @@ -20,8 +20,11 @@ import type { Broker } from '../../../state/rest-interfaces'; import { eqSet, prettyBytesOrNA } from '../../../utils/utils'; import { DEFAULT_TABLE_PAGE_SIZE } from '../../constants'; -// Legacy table parity: 50 rows a page, pager only past that. -const TABLE_OPTIONS = { initialState: { pagination: { pageIndex: 0, pageSize: DEFAULT_TABLE_PAGE_SIZE } } }; +// Legacy table parity: 50 rows a page, pager only past that. No column-visibility UI, so hiding is off. +const TABLE_OPTIONS = { + enableHiding: false, + initialState: { pagination: { pageIndex: 0, pageSize: DEFAULT_TABLE_PAGE_SIZE } }, +}; export class StepSelectBrokers extends Component<{ selectedBrokerIds: number[]; @@ -100,22 +103,18 @@ export class StepSelectBrokers extends Component<{ }, { header: ({ column }) => , - enableHiding: false, accessorKey: 'brokerId', }, { header: ({ column }) => , - enableHiding: false, accessorKey: 'address', }, { header: ({ column }) => , - enableHiding: false, accessorKey: 'rack', }, { header: ({ column }) => , - enableHiding: false, accessorKey: 'logDirSize', cell: ({ row: { original } }) => prettyBytesOrNA(original.logDirSize), }, diff --git a/frontend/src/components/pages/reassign-partitions/step3-review.tsx b/frontend/src/components/pages/reassign-partitions/step3-review.tsx index 1cb0666f28..f6d33a497b 100644 --- a/frontend/src/components/pages/reassign-partitions/step3-review.tsx +++ b/frontend/src/components/pages/reassign-partitions/step3-review.tsx @@ -27,8 +27,11 @@ import { prettyBytesOrNA, prettyMilliseconds } from '../../../utils/utils'; import { DEFAULT_TABLE_PAGE_SIZE } from '../../constants'; import { BrokerList } from '../../misc/broker-list'; -// Legacy table parity: 50 rows a page, pager only past that. -const TABLE_OPTIONS = { initialState: { pagination: { pageIndex: 0, pageSize: DEFAULT_TABLE_PAGE_SIZE } } }; +// Legacy table parity: 50 rows a page, pager only past that. No column-visibility UI, so hiding is off. +const TABLE_OPTIONS = { + enableHiding: false, + initialState: { pagination: { pageIndex: 0, pageSize: DEFAULT_TABLE_PAGE_SIZE } }, +}; export type PartitionWithMoves = Partition & { brokersBefore: number[]; @@ -104,7 +107,6 @@ export class StepReview extends Component<{ }, { header: ({ column }) => , - enableHiding: false, accessorKey: 'topicName', }, { @@ -299,7 +301,6 @@ const ReviewPartitionTable = (props: { topic: Topic; topicPartitions: Partition[ columns={[ { header: ({ column }) => , - enableHiding: false, accessorKey: 'id', }, { From a17f8cfd42f0d53b23f42e578638e54f671f5999 Mon Sep 17 00:00:00 2001 From: Peter Zenger Date: Tue, 8 Sep 2026 16:47:27 -0700 Subject: [PATCH 11/14] =?UTF-8?q?frontend:=20reassign=20partitions=20?= =?UTF-8?q?=E2=80=94=20fourth=20review=20round?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `react-highlight-words` and its types leave `package.json` and both lockfiles: `SearchTitle` was the last importer. - Wizard icons come from `components/icons` (adds `InboxIcon`). - The throttle-dialog tooltip gets a `TooltipProvider` (150 ms, not Base UI's 600) and a focusable trigger. - Nav buttons use `min-w-56`/`ml-auto` classes and the link buttons `size="xs"` instead of inline styles; the loading skeleton's margin moves onto a wrapper. - `uiSettings.reassignment.quickSearch`/`pageSizeSelect` are dropped: step 1 no longer reads them. Co-Authored-By: Claude Fable 5.1 --- frontend/bun.lock | 10 ----- frontend/package.json | 2 - frontend/src/components/icons/index.tsx | 1 + .../components/active-reassignments.tsx | 14 +++---- .../components/wizard-steps.tsx | 4 +- .../reassign-partitions.tsx | 40 +++++++++++-------- .../reassign-partitions/step3-review.tsx | 3 +- frontend/src/state/ui.ts | 8 ---- frontend/yarn.lock | 27 +------------ 9 files changed, 33 insertions(+), 76 deletions(-) diff --git a/frontend/bun.lock b/frontend/bun.lock index e62ba6f5cb..c6b11cf4e7 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -74,7 +74,6 @@ "react-day-picker": "^9.14.0", "react-dom": "^19.2.0", "react-dropzone": "^15.0.0", - "react-highlight-words": "^0.21.0", "react-hook-form": "^7.76.1", "react-markdown": "^10.1.0", "react-resizable-panels": "^4.11.2", @@ -120,7 +119,6 @@ "@types/node": "^22.19.1", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.2", - "@types/react-highlight-words": "^0.20.0", "@types/react-syntax-highlighter": "^15.5.13", "@typescript/native-preview": "^7.0.0-dev.20260108.1", "baseline-browser-mapping": "2.10.33", @@ -973,8 +971,6 @@ "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], - "@types/react-highlight-words": ["@types/react-highlight-words@0.20.0", "", { "dependencies": { "@types/react": "*" } }, "sha512-Qm512TiOakvtNzHJ2+TNVHnLn5cJ2wLQV0+LrhuispVth6dRf5b8ydjq3Kc0thpZ7bz4s6RnG6meboAXHWRK+Q=="], - "@types/react-syntax-highlighter": ["@types/react-syntax-highlighter@15.5.13", "", { "dependencies": { "@types/react": "*" } }, "sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA=="], "@types/react-transition-group": ["@types/react-transition-group@4.4.12", "", { "peerDependencies": { "@types/react": "*" } }, "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w=="], @@ -1693,8 +1689,6 @@ "hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="], - "highlight-words-core": ["highlight-words-core@1.2.3", "", {}, "sha512-m1O9HW3/GNHxzSIXWw1wCNXXsgLlxrP0OI6+ycGUhiUHkikqW3OrwVHz+lxeNBe5yqLESdIcj8PowHQ2zLvUvQ=="], - "highlight.js": ["highlight.js@10.7.3", "", {}, "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A=="], "highlightjs-vue": ["highlightjs-vue@1.0.0", "", {}, "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA=="], @@ -2295,8 +2289,6 @@ "react-focus-lock": ["react-focus-lock@2.13.6", "", { "dependencies": { "@babel/runtime": "^7.0.0", "focus-lock": "^1.3.6", "prop-types": "^15.6.2", "react-clientside-effect": "^1.2.7", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ehylFFWyYtBKXjAO9+3v8d0i+cnc1trGS0vlTGhzFW1vbFXVUTmR8s2tt/ZQG8x5hElg6rhENlLG1H3EZK0Llg=="], - "react-highlight-words": ["react-highlight-words@0.21.0", "", { "dependencies": { "highlight-words-core": "^1.2.0", "memoize-one": "^4.0.0" }, "peerDependencies": { "react": "^0.14.0 || ^15.0.0 || ^16.0.0-0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-SdWEeU9fIINArEPO1rO5OxPyuhdEKZQhHzZZP1ie6UeXQf+CjycT1kWaB+9bwGcVbR0NowuHK3RqgqNg6bgBDQ=="], - "react-hook-form": ["react-hook-form@7.78.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-EEZqc+N23moyzTlz61Pj+JvcXo76ICkpfOZo8JZw+sM4+wLQGh6nI2Ms+PdMOYNluFu0ghlM7B8mCzhRYtJCnA=="], "react-icons": ["react-icons@4.12.0", "", { "peerDependencies": { "react": "*" } }, "sha512-IBaDuHiShdZqmfc/TwHu6+d6k2ltNCf3AszxNmjJc1KUfXdEeRJOKyNvLmAHaarhzGmTSVygNdyu8/opXv2gaw=="], @@ -3141,8 +3133,6 @@ "react-day-picker/date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="], - "react-highlight-words/memoize-one": ["memoize-one@4.1.0", "", {}, "sha512-2GApq0yI/b22J2j9rhbrAlsHb0Qcz+7yWxeLG8h+95sl1XPUgeLimQSOdur4Vw7cUhrBHwaUZxWFZueojqNRzA=="], - "react-select/@floating-ui/dom": ["@floating-ui/dom@1.7.4", "", { "dependencies": { "@floating-ui/core": "^1.7.3", "@floating-ui/utils": "^0.2.10" } }, "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA=="], "readable-stream/buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], diff --git a/frontend/package.json b/frontend/package.json index 56437631ba..7967a2638c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -117,7 +117,6 @@ "react-day-picker": "^9.14.0", "react-dom": "^19.2.0", "react-dropzone": "^15.0.0", - "react-highlight-words": "^0.21.0", "react-hook-form": "^7.76.1", "react-markdown": "^10.1.0", "react-resizable-panels": "^4.11.2", @@ -163,7 +162,6 @@ "@types/node": "^22.19.1", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.2", - "@types/react-highlight-words": "^0.20.0", "@types/react-syntax-highlighter": "^15.5.13", "@typescript/native-preview": "^7.0.0-dev.20260108.1", "baseline-browser-mapping": "2.10.33", diff --git a/frontend/src/components/icons/index.tsx b/frontend/src/components/icons/index.tsx index 3c39a34cb3..97c40a7a94 100644 --- a/frontend/src/components/icons/index.tsx +++ b/frontend/src/components/icons/index.tsx @@ -68,6 +68,7 @@ export { HelpCircle as HelpIcon, // MdHelpOutline, MdOutlineQuestionMark Home as HomeIcon, // HomeIcon (Heroicons) Hourglass as HourglassIcon, // MdHourglassFull + Inbox as InboxIcon, Info as InfoIcon, // MdInfoOutline, InfoIcon (Chakra/Octicons) Key as KeyIcon, // MdKey Layers as LayersIcon, // MdOutlineLayers diff --git a/frontend/src/components/pages/reassign-partitions/components/active-reassignments.tsx b/frontend/src/components/pages/reassign-partitions/components/active-reassignments.tsx index 68a12de42c..f6519b3fe0 100644 --- a/frontend/src/components/pages/reassign-partitions/components/active-reassignments.tsx +++ b/frontend/src/components/pages/reassign-partitions/components/active-reassignments.tsx @@ -98,8 +98,7 @@ export class ActiveReassignments extends Component<{ onClick={() => { this.setState({ showThrottleDialog: true }); }} - size="sm" - style={{ fontSize: 'smaller', padding: '0px 8px' }} + size="xs" variant="link" > {throttleText} @@ -159,12 +158,7 @@ export class ActiveReassignments extends Component<{ /> {this.props.throttledTopics.length > 0 && ( -
    ) : ( - +
    + +
    ); return ( diff --git a/frontend/src/components/pages/reassign-partitions/components/wizard-steps.tsx b/frontend/src/components/pages/reassign-partitions/components/wizard-steps.tsx index 9208d0458d..f30f0e3ff8 100644 --- a/frontend/src/components/pages/reassign-partitions/components/wizard-steps.tsx +++ b/frontend/src/components/pages/reassign-partitions/components/wizard-steps.tsx @@ -9,8 +9,8 @@ * by the Apache License, Version 2.0 */ +import { CheckIcon } from 'components/icons'; import { cn } from 'components/redpanda-ui/lib/utils'; -import { Check } from 'lucide-react'; /** * Presentational step indicator. The wizard owns `currentStep`; the Registry `Stepper` would need a @@ -36,7 +36,7 @@ export const WizardSteps = ({ steps, currentStep }: { steps: { title: string }[] !(isComplete || isActive) && 'text-subtle' )} > - {isComplete ? : index + 1} + {isComplete ? : index + 1} {step.title} {index < steps.length - 1 && } diff --git a/frontend/src/components/pages/reassign-partitions/reassign-partitions.tsx b/frontend/src/components/pages/reassign-partitions/reassign-partitions.tsx index 3c0a4194d5..6f4293c42a 100644 --- a/frontend/src/components/pages/reassign-partitions/reassign-partitions.tsx +++ b/frontend/src/components/pages/reassign-partitions/reassign-partitions.tsx @@ -21,7 +21,7 @@ import { DialogTitle, } from 'components/redpanda-ui/components/dialog'; import { Stat } from 'components/redpanda-ui/components/stat'; -import { Tooltip, TooltipContent, TooltipTrigger } from 'components/redpanda-ui/components/tooltip'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from 'components/redpanda-ui/components/tooltip'; import { motion } from 'motion/react'; import { closeToast, showToast, updateToast } from 'utils/toast.utils'; @@ -291,9 +291,9 @@ class ReassignPartitions extends PageComponent { {/* Back */} {Boolean(step.backButton) && ( + ) : null, + }, + { + id: 'check', + header: '', + cell: ({ row }: { row: DataTableRow }) => { + const { checked, indeterminate } = this.getTopicCheckState(row.original.topicName); + return ( + this.setTopicSelection(row.original, !checked)} + /> + ); + }, + }, + { + id: 'topicName', + header: ({ column }) => , + accessorKey: 'topicName', + cell: ({ row: { original: record } }) => { + const content = record.topicName; + + if (this.props.throttledTopics.includes(record.topicName)) { + return ( +
    + {content} + +
    + ); + } + + return
    {content}
    ; + }, + }, + { + id: 'partitionCount', + header: ({ column }) => , + accessorKey: 'partitionCount', + cell: ({ row: { original: topic } }) => { + const errors = topic.partitions.count((p) => p.hasErrors); + if (errors === 0) { + return topic.partitionCount; + } + + return ( +
    + +
    + {topic.partitionCount - errors} / {topic.partitionCount} +
    +
    + ); + }, + }, + { + id: 'replicationFactor', + header: ({ column }) => , + accessorKey: 'replicationFactor', + cell: ({ row: { original: r } }) => { + if (r.activeReassignments.length === 0) { + return r.replicationFactor; + } + return ( + + {r.replicationFactor} + + ); + }, + }, + { + // Distinct id: the "Partitions" column above also keyed off `partitions`, so both + // resolved to the same TanStack column id. + id: 'brokers', + enableSorting: false, + header: 'Brokers', + cell: ({ row: { original: record } }) => record.partitions?.map((p) => p.leader).distinct().length ?? 'N/A', + }, + { + id: 'totalSizeBytes', + header: ({ column }) => , + accessorFn: (topic) => topic.logDirSummary?.totalSizeBytes ?? 0, + cell: ({ row: { original: r } }) => renderLogDirSummary(r.logDirSummary), + }, + ]; } render() { @@ -68,111 +181,7 @@ export class StepSelectPartitions extends Component<{ - columns={[ - // Chakra's DataTable injected this column whenever `subComponent` was set; the Registry one does not. - { - id: 'expander', - enableSorting: false, - cell: ({ row }) => - row.getCanExpand() ? ( - - ) : null, - }, - { - id: 'check', - header: '', - cell: ({ row }: { row: DataTableRow }) => { - const { checked, indeterminate } = this.getTopicCheckState(row.original.topicName); - return ( - this.setTopicSelection(row.original, !checked)} - /> - ); - }, - }, - { - id: 'topicName', - header: ({ column }) => , - accessorKey: 'topicName', - cell: ({ row: { original: record } }) => { - const content = record.topicName; - - if (this.props.throttledTopics.includes(record.topicName)) { - return ( -
    - {content} - -
    - ); - } - - return
    {content}
    ; - }, - }, - { - id: 'partitionCount', - header: ({ column }) => , - accessorKey: 'partitionCount', - cell: ({ row: { original: topic } }) => { - const errors = topic.partitions.count((p) => p.hasErrors); - if (errors === 0) { - return topic.partitionCount; - } - - return ( -
    - -
    - {topic.partitionCount - errors} / {topic.partitionCount} -
    -
    - ); - }, - }, - { - id: 'replicationFactor', - header: ({ column }) => , - accessorKey: 'replicationFactor', - cell: ({ row: { original: r } }) => { - if (r.activeReassignments.length === 0) { - return r.replicationFactor; - } - return ( - - {r.replicationFactor} - - ); - }, - }, - { - // Distinct id: the "Partitions" column above also keyed off `partitions`, so both - // resolved to the same TanStack column id. - id: 'brokers', - enableSorting: false, - header: 'Brokers', - cell: ({ row: { original: record } }) => - record.partitions?.map((p) => p.leader).distinct().length ?? 'N/A', - }, - { - id: 'totalSizeBytes', - header: ({ column }) => , - accessorFn: (topic) => topic.logDirSummary?.totalSizeBytes ?? 0, - cell: ({ row: { original: r } }) => renderLogDirSummary(r.logDirSummary), - }, - ]} + columns={this.columns} data={this.topicPartitions} // Selection is done by the `check` column; no table-level row selection. pagination={this.topicPartitions.length > DEFAULT_TABLE_PAGE_SIZE} @@ -303,50 +312,53 @@ export class SelectPartitionTable extends Component<{ isSelected: (topic: string, partition: number) => boolean; getSelectedPartitions: () => number[]; }> { + // Built once, as in StepSelectPartitions above. + private readonly columns: DataTableColumnDef[] = [ + { + id: 'check', + enableSorting: false, + header: 'Check', + cell: ({ row: { original: partition } }: { row: DataTableRow }) => { + const isSelected = this.props.getSelectedPartitions().includes(partition.id); + return ( + { + this.props.setSelection(this.props.topic.topicName, partition.id, !isSelected); + }} + /> + ); + }, + }, + { + id: 'id', + header: ({ column }) => , + accessorKey: 'id', + }, + { + id: 'replicas', + enableSorting: false, + header: 'Brokers', + cell: ({ row: { original: partition } }: { row: DataTableRow }) => + partition.replicas ? ( + + ) : ( + renderPartitionError(partition) + ), + }, + { + id: 'replicaSize', + header: ({ column }) => , + accessorKey: 'replicaSize', + cell: ({ row: { original: partition } }) => prettyBytesOrNA(partition.replicaSize), + }, + ]; + render() { return ( - columns={[ - { - id: 'check', - enableSorting: false, - header: 'Check', - cell: ({ row: { original: partition } }: { row: DataTableRow }) => { - const isSelected = this.props.getSelectedPartitions().includes(partition.id); - return ( - { - this.props.setSelection(this.props.topic.topicName, partition.id, !isSelected); - }} - /> - ); - }, - }, - { - id: 'id', - header: ({ column }) => , - accessorKey: 'id', - }, - { - id: 'replicas', - enableSorting: false, - header: 'Brokers', - cell: ({ row: { original: partition } }: { row: DataTableRow }) => - partition.replicas ? ( - - ) : ( - renderPartitionError(partition) - ), - }, - { - id: 'replicaSize', - header: ({ column }) => , - accessorKey: 'replicaSize', - cell: ({ row: { original: partition } }) => prettyBytesOrNA(partition.replicaSize), - }, - ]} + columns={this.columns} data={this.props.topicPartitions} pagination={this.props.topicPartitions.length > DEFAULT_TABLE_PAGE_SIZE} sorting diff --git a/frontend/src/components/pages/reassign-partitions/step2-brokers.tsx b/frontend/src/components/pages/reassign-partitions/step2-brokers.tsx index dc0f449d67..8a698bd798 100644 --- a/frontend/src/components/pages/reassign-partitions/step2-brokers.tsx +++ b/frontend/src/components/pages/reassign-partitions/step2-brokers.tsx @@ -10,7 +10,12 @@ */ import { Checkbox } from 'components/redpanda-ui/components/checkbox'; -import { DataTable, DataTableColumnHeader, type DataTableRow } from 'components/redpanda-ui/components/data-table'; +import { + DataTable, + type DataTableColumnDef, + DataTableColumnHeader, + type DataTableRow, +} from 'components/redpanda-ui/components/data-table'; import { Component } from 'react'; import { SelectionInfoBar } from './components/statistics-bar'; @@ -32,6 +37,9 @@ export class StepSelectBrokers extends Component<{ partitionSelection: PartitionSelection; }> { brokers: Broker[]; + // Built once: the page force-updates on every poll, and a fresh `header`/`cell` identity + // remounts the header's sort menu out from under the pointer. + private readonly columns: DataTableColumnDef[]; constructor(props: { selectedBrokerIds: number[]; @@ -40,6 +48,63 @@ export class StepSelectBrokers extends Component<{ }) { super(props); this.brokers = api.clusterInfo?.brokers ?? []; + this.columns = [ + { + id: 'check', + header: () => { + const selectedSet = new Set(this.props.selectedBrokerIds); + const allIdsSet = new Set(this.brokers.map(({ brokerId }) => brokerId)); + const allIsSelected = eqSet(selectedSet, allIdsSet); + return ( + 0} + onCheckedChange={() => { + if (allIsSelected) { + this.props.onSelectionChange([]); + } else { + this.props.onSelectionChange(this.brokers.map((b) => b.brokerId)); + } + }} + /> + ); + }, + cell: ({ row: { original: broker } }: { row: DataTableRow }) => { + const checked = this.props.selectedBrokerIds.includes(broker.brokerId); + return ( + { + if (checked) { + this.props.onSelectionChange(this.props.selectedBrokerIds.filter((id) => id !== broker.brokerId)); + } else { + this.props.onSelectionChange([...this.props.selectedBrokerIds, broker.brokerId]); + } + }} + /> + ); + }, + }, + { + header: ({ column }) => , + accessorKey: 'brokerId', + }, + { + header: ({ column }) => , + accessorKey: 'address', + }, + { + header: ({ column }) => , + accessorKey: 'rack', + }, + { + header: ({ column }) => , + accessorKey: 'logDirSize', + cell: ({ row: { original } }) => prettyBytesOrNA(original.logDirSize), + }, + ]; } render() { @@ -47,8 +112,6 @@ export class StepSelectBrokers extends Component<{ return
    Error: no brokers available
    ; } - const { selectedBrokerIds, onSelectionChange } = this.props; - return ( <>
    @@ -62,63 +125,7 @@ export class StepSelectBrokers extends Component<{ - columns={[ - { - id: 'check', - header: () => { - const selectedSet = new Set(selectedBrokerIds); - const allIdsSet = new Set(this.brokers.map(({ brokerId }) => brokerId)); - const allIsSelected = eqSet(selectedSet, allIdsSet); - return ( - 0} - onCheckedChange={() => { - if (allIsSelected) { - onSelectionChange([]); - } else { - onSelectionChange(this.brokers.map((b) => b.brokerId)); - } - }} - /> - ); - }, - cell: ({ row: { original: broker } }: { row: DataTableRow }) => { - const checked = selectedBrokerIds.includes(broker.brokerId); - return ( - { - if (checked) { - onSelectionChange(selectedBrokerIds.filter((id) => id !== broker.brokerId)); - } else { - onSelectionChange([...selectedBrokerIds, broker.brokerId]); - } - }} - /> - ); - }, - }, - { - header: ({ column }) => , - accessorKey: 'brokerId', - }, - { - header: ({ column }) => , - accessorKey: 'address', - }, - { - header: ({ column }) => , - accessorKey: 'rack', - }, - { - header: ({ column }) => , - accessorKey: 'logDirSize', - cell: ({ row: { original } }) => prettyBytesOrNA(original.logDirSize), - }, - ]} + columns={this.columns} data={this.brokers} pagination={this.brokers.length > DEFAULT_TABLE_PAGE_SIZE} sorting diff --git a/frontend/src/components/pages/reassign-partitions/step3-review.test.tsx b/frontend/src/components/pages/reassign-partitions/step3-review.test.tsx new file mode 100644 index 0000000000..6dd2988871 --- /dev/null +++ b/frontend/src/components/pages/reassign-partitions/step3-review.test.tsx @@ -0,0 +1,83 @@ +import { afterEach, expect, rs, test } from '@rstest/core'; +import { act, render, screen } from '@testing-library/react'; + +import ReassignPartitions from './reassign-partitions'; +import { StepReview } from './step3-review'; +import { useApiStore } from '../../../state/backend-api'; +import type { Partition, Topic } from '../../../state/rest-interfaces'; +import { uiSettings } from '../../../state/ui'; + +const initialApiState = useApiStore.getState(); +const initialThrottle = uiSettings.reassignment.maxReplicationTraffic; +afterEach(() => { + useApiStore.setState(initialApiState, true); + uiSettings.reassignment = { ...uiSettings.reassignment, maxReplicationTraffic: initialThrottle }; + rs.restoreAllMocks(); +}); + +const topic: Topic = { + topicName: 'alpha', + isInternal: false, + partitionCount: 1, + replicationFactor: 1, + cleanupPolicy: 'delete', + documentation: 'UNKNOWN', + logDirSummary: { totalSizeBytes: 0, replicaErrors: null, hint: null }, + allowedActions: undefined, +}; +const partition: Partition = { + id: 0, + topicName: 'alpha', + partitionError: null, + replicas: [1], + offlineReplicas: [], + inSyncReplicas: [1], + leader: 1, + partitionLogDirs: [], + waterMarksError: null, + waterMarkLow: 0, + waterMarkHigh: 0, + replicaSize: 1024, + hasErrors: false, +}; + +const renderStepReview = () => { + useApiStore.setState({ topics: [topic], topicPartitions: new Map([[topic.topicName, [partition]]]) }); + rs.spyOn(ReassignPartitions.prototype, 'refreshData').mockImplementation(() => undefined); + render( + + ); +}; + +test('follows the throttle setting without waiting for a re-render', () => { + renderStepReview(); + const throttleValue = () => screen.getByText('Traffic Throttle').nextElementSibling; + expect(throttleValue()).toHaveTextContent('disabled'); + // The write the slider handler makes: a nested assignment would not notify the settings store. + act(() => { + uiSettings.reassignment = { ...uiSettings.reassignment, maxReplicationTraffic: 1024 }; + }); + expect(throttleValue()).toHaveTextContent('1 kiB/s'); +}); diff --git a/frontend/src/components/pages/reassign-partitions/step3-review.tsx b/frontend/src/components/pages/reassign-partitions/step3-review.tsx index 688569e0e1..ecab23e9f4 100644 --- a/frontend/src/components/pages/reassign-partitions/step3-review.tsx +++ b/frontend/src/components/pages/reassign-partitions/step3-review.tsx @@ -11,16 +11,20 @@ import { ChevronDownIcon, ChevronRightIcon, InboxIcon } from 'components/icons'; import { Button } from 'components/redpanda-ui/components/button'; -import { DataTable, DataTableColumnHeader } from 'components/redpanda-ui/components/data-table'; +import { + DataTable, + type DataTableColumnDef, + DataTableColumnHeader, +} from 'components/redpanda-ui/components/data-table'; import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from 'components/redpanda-ui/components/empty'; -import { Component } from 'react'; +import { Component, useMemo } from 'react'; import { BandwidthSlider } from './components/bandwidth-slider'; import type ReassignPartitions from './reassign-partitions'; import type { PartitionSelection } from './reassign-partitions'; import { api } from '../../../state/backend-api'; import type { Partition, PartitionReassignmentRequest, Topic, TopicAssignment } from '../../../state/rest-interfaces'; -import { uiSettings } from '../../../state/ui'; +import { uiSettings, useUISettingsStore } from '../../../state/ui'; import { DefaultSkeleton, InfoText } from '../../../utils/tsx-utils'; import { prettyBytesOrNA, prettyMilliseconds } from '../../../utils/utils'; import { DEFAULT_TABLE_PAGE_SIZE } from '../../constants'; @@ -49,12 +53,86 @@ export type TopicWithMoves = { selectedPartitions: PartitionWithMoves[]; }; -export class StepReview extends Component<{ +type StepReviewProps = { partitionSelection: PartitionSelection; topicsWithMoves: TopicWithMoves[]; assignments: PartitionReassignmentRequest; reassignPartitions: ReassignPartitions; // since api is still changing, we pass parent down so we can call functions on it directly -}> { +}; + +export function StepReview(props: StepReviewProps) { + // Subscribed here, not read through `uiSettings`: proxy reads do not notify, so the slider and + // the summary would only catch up on the next poll. + const maxReplicationTraffic = useUISettingsStore((state) => state.reassignment.maxReplicationTraffic); + + return ; +} + +class StepReviewContent extends Component { + // Built once: the page force-updates on every poll, and a fresh `header`/`cell` identity + // remounts the header's sort menu out from under the pointer. + private readonly columns: DataTableColumnDef[] = [ + // Chakra's DataTable injected this column whenever `subComponent` was set; the Registry one does not. + { + id: 'expander', + enableSorting: false, + cell: ({ row }) => + row.getCanExpand() ? ( + + ) : null, + }, + { + header: ({ column }) => , + accessorKey: 'topicName', + }, + { + header: 'Brokers Before', + cell: ({ row: { original: topic } }) => { + const brokersBefore = topic.selectedPartitions + .flatMap((x) => x.brokersBefore) + .distinct() + .sort((a, b) => a - b); + return ; + }, + }, + { + // Derived from the plan, so there is nothing to sort on — and table-level `sorting` + // would otherwise mark it sortable with no header affordance to trigger it. + enableSorting: false, + header: 'Brokers After', + id: 'brokersAfter', + cell: ({ row: { original: topic } }) => { + const plannedBrokers = topic.selectedPartitions + .flatMap((x) => x.brokersAfter) + .distinct() + .sort((a, b) => a - b); + return ; + }, + }, + { + id: 'numAddedBrokers', + header: () => ( + + Reassignments + + ), + cell: ({ row: { original: topic } }) => topic.selectedPartitions.sum((p) => p.numAddedBrokers), + }, + { + header: 'Estimated Traffic', + cell: ({ row: { original: topic } }) => + prettyBytesOrNA(topic.selectedPartitions.sum((p) => p.numAddedBrokers * p.replicaSize)), + }, + ]; + render() { if (!api.topics) { return DefaultSkeleton; @@ -86,67 +164,7 @@ export class StepReview extends Component<{
    - columns={[ - // Chakra's DataTable injected this column whenever `subComponent` was set; the Registry one does not. - { - id: 'expander', - enableSorting: false, - cell: ({ row }) => - row.getCanExpand() ? ( - - ) : null, - }, - { - header: ({ column }) => , - accessorKey: 'topicName', - }, - { - header: 'Brokers Before', - cell: ({ row: { original: topic } }) => { - const brokersBefore = topic.selectedPartitions - .flatMap((x) => x.brokersBefore) - .distinct() - .sort((a, b) => a - b); - return ; - }, - }, - { - // Derived from the plan, so there is nothing to sort on — and table-level `sorting` - // would otherwise mark it sortable with no header affordance to trigger it. - enableSorting: false, - header: 'Brokers After', - id: 'brokersAfter', - cell: ({ row: { original: topic } }) => { - const plannedBrokers = topic.selectedPartitions - .flatMap((x) => x.brokersAfter) - .distinct() - .sort((a, b) => a - b); - return ; - }, - }, - { - id: 'numAddedBrokers', - header: () => ( - - Reassignments - - ), - cell: ({ row: { original: topic } }) => topic.selectedPartitions.sum((p) => p.numAddedBrokers), - }, - { - header: 'Estimated Traffic', - cell: ({ row: { original: topic } }) => - prettyBytesOrNA(topic.selectedPartitions.sum((p) => p.numAddedBrokers * p.replicaSize)), - }, - ]} + columns={this.columns} data={this.props.topicsWithMoves} pagination={this.props.topicsWithMoves.length > DEFAULT_TABLE_PAGE_SIZE} sorting @@ -175,8 +193,6 @@ export class StepReview extends Component<{ } reassignmentOptions() { - const settings = uiSettings.reassignment; - return (

    Bandwidth Throttle

    @@ -185,9 +201,11 @@ export class StepReview extends Component<{
    { - settings.maxReplicationTraffic = x; + // Whole-section assignment: only a top-level set goes through the `uiSettings` + // proxy's trap, so a nested write would never notify the store. + uiSettings.reassignment = { ...uiSettings.reassignment, maxReplicationTraffic: x }; }} - settings={settings} + settings={{ maxReplicationTraffic: this.props.maxReplicationTraffic }} />
    @@ -204,8 +222,7 @@ export class StepReview extends Component<{ } summary() { - const settings = uiSettings.reassignment; - const maxReplicationTraffic = settings.maxReplicationTraffic ?? 0; + const maxReplicationTraffic = this.props.maxReplicationTraffic ?? 0; const trafficStats = this.props.topicsWithMoves.map((t) => { const partitionStats = t.selectedPartitions.map((p) => { @@ -251,8 +268,8 @@ export class StepReview extends Component<{ const totalTraffic = trafficStats.sum((t) => t.partitionStats.sum((p) => p.totalTraffic)); - const isThrottled = settings.maxReplicationTraffic !== null && settings.maxReplicationTraffic > 0; - const trafficThrottle = isThrottled ? `${prettyBytesOrNA(settings.maxReplicationTraffic ?? 0)}/s` : 'disabled'; + const isThrottled = this.props.maxReplicationTraffic !== null && this.props.maxReplicationTraffic > 0; + const trafficThrottle = isThrottled ? `${prettyBytesOrNA(maxReplicationTraffic)}/s` : 'disabled'; const estimatedTime = (() => { if (!isThrottled) { @@ -298,39 +315,49 @@ export class StepReview extends Component<{ } } -const ReviewPartitionTable = (props: { topic: Topic; topicPartitions: Partition[]; assignments: TopicAssignment }) => ( -
    - - columns={[ - { - header: ({ column }) => , - accessorKey: 'id', - }, - { - header: 'Brokers Before', - cell: ({ row: { original: partition } }) => ( - - ), - }, - { - header: 'Brokers After', - cell: ({ row: { original: partition } }) => { - const partitionAssignments = props.assignments.partitions.first((p) => p.partitionId === partition.id); - if ( - partitionAssignments === null || - partitionAssignments === undefined || - partitionAssignments.replicas === null - ) { - return '??'; - } - return ; - }, +const ReviewPartitionTable = (props: { topic: Topic; topicPartitions: Partition[]; assignments: TopicAssignment }) => { + // Memoized: the page force-updates on every poll, and a fresh `header`/`cell` identity + // remounts the header's sort menu out from under the pointer. The plan itself only changes + // when the wizard recomputes it. + const columns = useMemo[]>( + () => [ + { + header: ({ column }) => , + accessorKey: 'id', + }, + { + header: 'Brokers Before', + cell: ({ row: { original: partition } }) => ( + + ), + }, + { + header: 'Brokers After', + cell: ({ row: { original: partition } }) => { + const partitionAssignments = props.assignments.partitions.first((p) => p.partitionId === partition.id); + if ( + partitionAssignments === null || + partitionAssignments === undefined || + partitionAssignments.replicas === null + ) { + return '??'; + } + return ; }, - ]} - data={props.topicPartitions} - pagination={props.topicPartitions.length > DEFAULT_TABLE_PAGE_SIZE} - sorting - tableOptions={TABLE_OPTIONS} - /> -
    -); + }, + ], + [props.assignments] + ); + + return ( +
    + + columns={columns} + data={props.topicPartitions} + pagination={props.topicPartitions.length > DEFAULT_TABLE_PAGE_SIZE} + sorting + tableOptions={TABLE_OPTIONS} + /> +
    + ); +}; diff --git a/frontend/src/components/pages/reassign-partitions/topic-sorting.test.tsx b/frontend/src/components/pages/reassign-partitions/topic-sorting.test.tsx index 314e900eb0..f9b0d8a6d3 100644 --- a/frontend/src/components/pages/reassign-partitions/topic-sorting.test.tsx +++ b/frontend/src/components/pages/reassign-partitions/topic-sorting.test.tsx @@ -102,3 +102,15 @@ test('sorts review topics and expanded partitions', async () => { } expect(within(within(table).getAllByRole('row')[1]).getAllByRole('cell')[0]).toHaveTextContent('1'); }); + +test('keeps the sort menu open when the page re-renders', async () => { + seed(); + const user = userEvent.setup(); + const props = { onPartitionSelectionChange: rs.fn(), partitionSelection: {}, throttledTopics: [] }; + const { rerender } = render(); + await user.click(screen.getByRole('button', { name: 'Size' })); + expect(screen.getByRole('menuitem', { name: 'Asc' })).toBeVisible(); + // PageComponent forceUpdates the whole page on every store poll — three seconds here. + rerender(); + expect(screen.getByRole('menuitem', { name: 'Asc' })).toBeVisible(); +}); From 6a86165709092fd0de1d650838c1ae14b4ef6a2f Mon Sep 17 00:00:00 2001 From: Peter Zenger Date: Wed, 9 Sep 2026 16:15:15 -0700 Subject: [PATCH 14/14] frontend: default the quotas and consumer-group lists to ten rows a page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both were on `DEFAULT_TABLE_PAGE_SIZE` (50), the legacy-parity size the Chakra DataTable used. Ten is the Registry pager's own default and the first of its "Rows per page" options, and both pages keep the choice in the URL, so a wider page is one select away and survives a reload. The quotas route's `pageSize` fallback moves with it: `.catch(50)` only fires for a URL value outside the schema's range, and landing on 50 when the page now defaults to 10 would be its own surprise. `quota-pagination.spec.ts` hardcoded 50 as the app default for its "no pager below one page" check, so it moves too. That assertion is vacuous today for an unrelated reason — it locates `[aria-label="pagination"]` while the Registry pager labels its group `"Pagination"` — and the pager renders unconditionally on that page, so the spec cannot see either state. Left as found; noted so it can be fixed as a spec change rather than inside this one. Co-Authored-By: Claude Opus 5 (1M context) --- frontend/src/components/pages/consumers/group-list.tsx | 5 +++-- frontend/src/components/pages/quotas/quotas-list.tsx | 2 +- frontend/src/routes/quotas.tsx | 2 +- .../test-variant-console/quotas/quota-pagination.spec.ts | 4 ++-- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/pages/consumers/group-list.tsx b/frontend/src/components/pages/consumers/group-list.tsx index 6185e65d3c..431f8c613a 100644 --- a/frontend/src/components/pages/consumers/group-list.tsx +++ b/frontend/src/components/pages/consumers/group-list.tsx @@ -23,7 +23,6 @@ import { columnMeta, readColumnMeta } from 'utils/data-table-column-meta'; import { appGlobal } from '../../../state/app-global'; import type { GroupDescription } from '../../../state/rest-interfaces'; import { setPageHeader } from '../../../state/ui-state'; -import { DEFAULT_TABLE_PAGE_SIZE } from '../../constants'; import { BrokerList } from '../../misc/broker-list'; import { ShortNum } from '../../misc/short-num'; import { Alert, AlertDescription, AlertTitle } from '../../redpanda-ui/components/alert'; @@ -60,6 +59,8 @@ const groupIdFilterFn = (row: DataTableRow, _columnId: string, } }; +const DEFAULT_PAGE_SIZE = 10; + const stateFilterFn = (row: DataTableRow, columnId: string, filterValues: string[]) => { if (!filterValues?.length) { return true; @@ -84,7 +85,7 @@ const GroupList: FC = () => { const [searchValue, setSearchValue] = useQueryState('q', parseAsString.withDefault('')); const [stateFilter, setStateFilter] = useQueryState('state', parseAsArrayOf(parseAsString).withDefault([])); const [pageIndex, setPageIndex] = useQueryState('page', parseAsInteger.withDefault(0)); - const [pageSize, setPageSize] = useQueryState('pageSize', parseAsInteger.withDefault(DEFAULT_TABLE_PAGE_SIZE)); + const [pageSize, setPageSize] = useQueryState('pageSize', parseAsInteger.withDefault(DEFAULT_PAGE_SIZE)); const [sortId, setSortId] = useQueryState('sortId', parseAsString.withDefault('')); const [sortDesc, setSortDesc] = useQueryState('sortDesc', parseAsString.withDefault('')); diff --git a/frontend/src/components/pages/quotas/quotas-list.tsx b/frontend/src/components/pages/quotas/quotas-list.tsx index 19bbae97b0..68063ee487 100644 --- a/frontend/src/components/pages/quotas/quotas-list.tsx +++ b/frontend/src/components/pages/quotas/quotas-list.tsx @@ -48,7 +48,7 @@ import { Quota_EntityType, Quota_ValueType } from '../../../protogen/redpanda/ap import { prettyBytes, prettyNumber } from '../../../utils/utils'; import PageContent from '../../misc/page-content'; -const DEFAULT_PAGE_SIZE = 50; +const DEFAULT_PAGE_SIZE = 10; type QuotaRow = { entityType: QuotaEntityDisplay; diff --git a/frontend/src/routes/quotas.tsx b/frontend/src/routes/quotas.tsx index 743763576a..39d2041922 100644 --- a/frontend/src/routes/quotas.tsx +++ b/frontend/src/routes/quotas.tsx @@ -19,7 +19,7 @@ import { uiState } from '../state/ui-state'; const quotasSearchSchema = z.object({ page: z.number().int().min(0).optional().catch(0), - pageSize: z.number().int().min(10).max(100).optional().catch(50), + pageSize: z.number().int().min(10).max(100).optional().catch(10), sortField: z .enum(['entityType', 'entityName', 'producerRate', 'consumerRate', 'controllerMutationRate']) .optional() diff --git a/frontend/tests/test-variant-console/quotas/quota-pagination.spec.ts b/frontend/tests/test-variant-console/quotas/quota-pagination.spec.ts index 79b636894e..df02f60e0d 100644 --- a/frontend/tests/test-variant-console/quotas/quota-pagination.spec.ts +++ b/frontend/tests/test-variant-console/quotas/quota-pagination.spec.ts @@ -3,7 +3,7 @@ import { expect, test } from '@playwright/test'; import { createClientIdQuota, deleteClientIdQuota } from '../../shared/quota.utils'; import { QuotaPage } from '../utils/quota-page'; -const DEFAULT_PAGE_SIZE = 50; +const DEFAULT_PAGE_SIZE = 10; // Regex patterns for pagination tests const ENTITY_TYPE_REGEX = /client-id|user|ip/; @@ -20,7 +20,7 @@ test.describe('Quotas - Pagination', () => { // Check if table has rows but pagination is not present const rowCount = await page.locator('tr').filter({ hasText: ENTITY_TYPE_REGEX }).count(); - // If there are less than 50 items, pagination should not be visible + // If there are fewer than a page of items, pagination should not be visible if (rowCount < DEFAULT_PAGE_SIZE) { const pagination = page.locator('[aria-label="pagination"]'); await expect(pagination).not.toBeVisible();