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/misc/kowl-table.tsx b/frontend/src/components/misc/kowl-table.tsx deleted file mode 100644 index 93c599d8dd..0000000000 --- a/frontend/src/components/misc/kowl-table.tsx +++ /dev/null @@ -1,108 +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 { Box, Input } from '@redpanda-data/ui'; -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}} - e.stopPropagation()} - onMouseDown={(e) => e.stopPropagation()} - onMouseUp={(e) => e.stopPropagation()} - style={{ - position: 'absolute', - inset: '0px 0px 0px -8px', - display: 'flex', - placeContent: 'center', - placeItems: 'center', - }} - > - { - 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 }); - }} - onKeyDown={this.onKeyDown} - 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/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/components/pages/reassign-partitions/components/active-reassignments.tsx b/frontend/src/components/pages/reassign-partitions/components/active-reassignments.tsx index 6a78b11179..f6519b3fe0 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,21 @@ * by the Apache License, Version 2.0 */ +import { Button } from 'components/redpanda-ui/components/button'; +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, 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'; import { showToast, updateToast } from 'utils/toast.utils'; @@ -111,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} @@ -126,32 +112,34 @@ export class ActiveReassignments extends Component<{ columns={[ { header: 'Topic', - size: 1, cell: ({ row: { original } }) => , }, { header: 'Progress', - size: Number.POSITIVE_INFINITY, - cell: ({ row: { original } }) => , + // The Registry DataTable ignores column sizes; a viewport-wide max-content hands this column the slack. + cell: ({ row: { original } }) => ( +
+ +
+ ), }, { header: 'ETA', - size: 100, cell: ({ row: { original } }) => , }, { header: 'Brokers', - size: 1, cell: ({ row: { original } }) => , }, ]} 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 + // Legacy parity: ten a page, pager only past that. + pagination={currentReassignments.length > 10} sorting={false} /> @@ -170,12 +158,7 @@ export class ActiveReassignments extends Component<{ /> {this.props.throttledTopics.length > 0 && ( - - - - - - - + + + + ); }; 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? - - - - - - + + ); @@ -380,9 +366,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 +376,69 @@ 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} + > + {/* Nearest rung to the old 48rem minimum. */} + + + Reassignment: {state.topicName} + + {modalContent} + - - - + + + ); } @@ -705,12 +704,12 @@ const ProgressBar = (p: { const { percent, state, left, right } = p; return ( <> + {/* The indicator paints bg-primary by default; the tone goes through the slot class. */} diff --git a/frontend/src/components/pages/reassign-partitions/components/bandwidth-slider.tsx b/frontend/src/components/pages/reassign-partitions/components/bandwidth-slider.tsx index 2015a9d0ef..089f547bd3 100644 --- a/frontend/src/components/pages/reassign-partitions/components/bandwidth-slider.tsx +++ b/frontend/src/components/pages/reassign-partitions/components/bandwidth-slider.tsx @@ -14,7 +14,7 @@ import { useState } from 'react'; import type { uiSettings } from '../../../../state/ui'; import { prettyNumber } from '../../../../utils/utils'; import '../../../../utils/number-extensions'; -import { Slider, SliderFilledTrack, SliderMark, SliderThumb, SliderTrack, Tooltip } from '@redpanda-data/ui'; +import { Slider } from 'components/redpanda-ui/components/slider'; // // BandwidthSlider can work with two kinds of inputs @@ -30,12 +30,19 @@ type SettingsCallback = { onSettingsChange: (x: number | null) => void; }; -const labelStyles = { - mt: '1', - mb: '2', - ml: '-2', - fontSize: 'sm', -}; +const SLIDER_MIN = 2; +const SLIDER_MAX = 12; + +/** Marks are positioned by value. */ +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); @@ -56,7 +63,9 @@ export function BandwidthSlider(props: ValueAndChangeCallback | SettingsCallback }; const value = getValue() ?? 0; - const sliderValue = Math.log10(value); + // `maxReplicationTraffic` defaults to 0, so log10 is -Infinity — which would make both the thumb + // position and the bubble's `left: …%` invalid. Clamp once and use the clamped value everywhere. + const sliderValue = Math.min(Math.max(Math.log10(value), SLIDER_MIN), SLIDER_MAX); const tipText = (f: number | null) => { if (f === null) { @@ -65,66 +74,62 @@ export function BandwidthSlider(props: ValueAndChangeCallback | SettingsCallback if (f < 3) { return 'No change'; } - if (f > 12) { - return 'Unlimited'; - } const v = Math.round(10 ** f.clamp(3, 12)); return `${prettyNumber(v).toUpperCase()}B/s`; }; return ( - { - if (n < 2.5) { - setValue(null); - } else { - setValue(Math.round(10 ** n.clamp(3, 12))); - } - }} - onMouseEnter={() => { +
{ setIsDragging(true); }} - onMouseLeave={() => { + onPointerLeave={() => { 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..f30f0e3ff8 --- /dev/null +++ b/frontend/src/components/pages/reassign-partitions/components/wizard-steps.tsx @@ -0,0 +1,47 @@ +/** + * 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 { CheckIcon } from 'components/icons'; +import { cn } from 'components/redpanda-ui/lib/utils'; + +/** + * 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 }) => ( +
    + {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..6f4293c42a 100644 --- a/frontend/src/components/pages/reassign-partitions/reassign-partitions.tsx +++ b/frontend/src/components/pages/reassign-partitions/reassign-partitions.tsx @@ -10,29 +10,23 @@ */ /** 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 { Stat } from 'components/redpanda-ui/components/stat'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from 'components/redpanda-ui/components/tooltip'; 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 { @@ -63,7 +57,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 = { @@ -215,19 +208,21 @@ class ReassignPartitions extends PageComponent { {/* Statistics */}
- - - - - + + + + - +
{/* Active Reassignments */} @@ -242,17 +237,7 @@ class ReassignPartitions extends PageComponent {
{/* Steps */}
- - {steps.map((item) => ( - - - } complete={} incomplete={} /> - - {item.title} - - - ))} - +
{/* Content */} @@ -306,9 +291,9 @@ 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 - - - + {/* Nearest rung to the old 64rem minimum. */} + + + + + + Remove throttle config from topics + + + +
There are {this.state.topicsWithThrottle.length} topics with throttling applied to their replicas.
Kowl implements throttling of reassignments by setting{' '} - - two configuration values - - Kowl sets those two configuration entries when throttling a topic reassignment: -
- leader.replication.throttled.replicas -
- follower.replication.throttled.replicas -
-
-
{' '} + + + + 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 @@ -381,8 +378,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..3467c5047c 100644 --- a/frontend/src/components/pages/reassign-partitions/step1-partitions.tsx +++ b/frontend/src/components/pages/reassign-partitions/step1-partitions.tsx @@ -9,22 +9,33 @@ * by the Apache License, Version 2.0 */ -import { Box, Checkbox, DataTable, Flex, Popover, Text } from '@redpanda-data/ui'; -import { WarningIcon } from 'components/icons'; +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, + type DataTableColumnDef, + 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 type { LegacyRow } from 'utils/legacy-data-table'; 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'; import { BrokerList } from '../../misc/broker-list'; import { renderLogDirSummary, WarningToolip } from '../../misc/common'; -import { SearchTitle } from '../../misc/kowl-table'; + +// 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[]; @@ -36,7 +47,9 @@ export class StepSelectPartitions extends Component<{ onPartitionSelectionChange: (newSelection: PartitionSelection) => void; throttledTopics: string[]; }> { - filterOpen = false; // topic name searchbar + // 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: { selectedTopicPartitions: PartitionSelection; @@ -51,6 +64,110 @@ export class StepSelectPartitions extends Component<{ this.getSelectedPartitions = this.getSelectedPartitions.bind(this); this.getTopicCheckState = this.getTopicCheckState.bind(this); this.getRowKey = this.getRowKey.bind(this); + this.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), + }, + ]; } render() { @@ -58,117 +175,17 @@ export class StepSelectPartitions extends Component<{ return DefaultSkeleton; } - const query = uiSettings.reassignment.quickSearch ?? ''; - const filterActive = query.length > 1; - return (
{/* Current Selection */} - columns={[ - { - id: 'check', - header: '', - cell: ({ row }: { row: LegacyRow }) => { - const { checked, indeterminate } = this.getTopicCheckState(row.original.topicName); - return ( - this.setTopicSelection(row.original, !checked)} - /> - ); - }, - }, - { - id: 'topicName', - header: () => ( - - ), - accessorKey: 'topicName', - cell: ({ row: { original: record } }) => { - const content = filterActive ? ( - - ) : ( - record.topicName - ); - - if (this.props.throttledTopics.includes(record.topicName)) { - return ( - - {content} - - - ); - } - - return ( - - {content} - - ); - }, - size: Number.POSITIVE_INFINITY, - }, - { - header: 'Partitions', - cell: ({ row: { original: topic } }) => { - const errors = topic.partitions.count((p) => p.hasErrors); - if (errors === 0) { - return topic.partitionCount; - } - - return ( - - - - {topic.partitionCount - errors} / {topic.partitionCount} - - - ); - }, - accessorKey: 'partitions', - }, - { - header: 'Replication Factor', - cell: ({ row: { original: r } }) => { - if (r.activeReassignments.length === 0) { - return r.replicationFactor; - } - return ( - - {r.replicationFactor} - - ); - }, - accessorKey: 'replicationFactor', - }, - { - 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), - accessorKey: 'totalSizeBytes', - }, - ]} + columns={this.columns} data={this.topicPartitions} - onRowSelectionChange={(_data) => { - // no op - selection is handled manually - }} - pagination={true} - rowSelection={{ - _internal_connectors_configs: true, - }} - sorting={true} + // 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 } }) => ( this.getSelectedPartitions(topic.topicName)} @@ -178,6 +195,7 @@ export class StepSelectPartitions extends Component<{ topicPartitions={topic.partitions} /> )} + tableOptions={TABLE_OPTIONS} />
); @@ -294,47 +312,57 @@ 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={[ - { - header: 'Check', - cell: ({ row: { original: partition } }: { row: LegacyRow }) => { - const isSelected = this.props.getSelectedPartitions().includes(partition.id); - return ( - { - this.props.setSelection(this.props.topic.topicName, partition.id, !isSelected); - }} - /> - ); - }, - }, - { - header: 'Partition', - accessorKey: 'id', - size: Number.POSITIVE_INFINITY, - }, - { - header: 'Brokers', - cell: ({ row: { original: partition } }: { row: LegacyRow }) => - partition.replicas ? ( - - ) : ( - renderPartitionError(partition) - ), - }, - { - header: 'Size', - cell: ({ row: { original: partition } }) => prettyBytesOrNA(partition.replicaSize), - size: Number.POSITIVE_INFINITY, - }, - ]} + columns={this.columns} data={this.props.topicPartitions} - pagination + pagination={this.props.topicPartitions.length > DEFAULT_TABLE_PAGE_SIZE} sorting + tableOptions={TABLE_OPTIONS} /> ); } @@ -348,42 +376,52 @@ function renderPartitionError(partition: Partition) { const txt = [partition.partitionError, partition.waterMarksError].join('\n\n'); return ( - {txt}} - hideCloseButton - placement="right-start" - size="auto" - title="Partition Error" - > - - - - - + + {/* Hover-to-open, as the Chakra popover was. */} + + + + + + } + /> + {/* PopoverContent is a fixed w-72; the error text needs the room. */} + + Partition Error +
{txt}
+
); } function PartitionErrorsForTopic(_props: { partitionsWithErrors: number }) { return ( - + + {/* Hover-to-open, as the Chakra popover was. */} + + + + + + } + /> + {/* 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.
- } - hideCloseButton - placement="right-start" - size="auto" - title="Partition Error" - > - - - - - +
); } 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 d6257fe863..8a698bd798 100644 --- a/frontend/src/components/pages/reassign-partitions/step2-brokers.tsx +++ b/frontend/src/components/pages/reassign-partitions/step2-brokers.tsx @@ -9,15 +9,27 @@ * 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 DataTableColumnDef, + DataTableColumnHeader, + 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'; 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. 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[]; @@ -25,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[]; @@ -33,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() { @@ -40,8 +112,6 @@ export class StepSelectBrokers extends Component<{ return
Error: no brokers available
; } - const { selectedBrokerIds, onSelectionChange } = this.props; - return ( <>
@@ -55,54 +125,11 @@ 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} - onChange={() => { - if (allIsSelected) { - onSelectionChange([]); - } else { - onSelectionChange(this.brokers.map((b) => b.brokerId)); - } - }} - /> - ); - }, - cell: ({ row: { original: broker } }: { row: LegacyRow }) => { - const checked = selectedBrokerIds.includes(broker.brokerId); - return ( - { - if (checked) { - onSelectionChange(selectedBrokerIds.filter((id) => id !== broker.brokerId)); - } else { - onSelectionChange([...selectedBrokerIds, broker.brokerId]); - } - }} - /> - ); - }, - }, - { header: 'ID', accessorKey: 'brokerId' }, - { header: 'Broker Address', size: Number.POSITIVE_INFINITY, accessorKey: 'address' }, - { header: 'Rack', accessorKey: 'rack' }, - { - header: 'Used Space', - accessorKey: 'logDirSize', - cell: ({ row: { original } }) => prettyBytesOrNA(original.logDirSize), - }, - ]} + columns={this.columns} data={this.brokers} - pagination={true} + pagination={this.brokers.length > DEFAULT_TABLE_PAGE_SIZE} + sorting + tableOptions={TABLE_OPTIONS} /> ); 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 c66608562e..ecab23e9f4 100644 --- a/frontend/src/components/pages/reassign-partitions/step3-review.tsx +++ b/frontend/src/components/pages/reassign-partitions/step3-review.tsx @@ -9,19 +9,33 @@ * by the Apache License, Version 2.0 */ -import { Box, DataTable, Empty } from '@redpanda-data/ui'; -import { Component } from 'react'; +import { ChevronDownIcon, ChevronRightIcon, InboxIcon } from 'components/icons'; +import { Button } from 'components/redpanda-ui/components/button'; +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, 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'; import { BrokerList } from '../../misc/broker-list'; +// 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[]; brokersAfter: number[]; @@ -39,18 +53,104 @@ 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; } if (api.topicPartitions.size === 0) { - return ; + 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 + Partition data has not loaded yet, so there is nothing to review. + + + ); } return ( @@ -64,53 +164,12 @@ export class StepReview extends Component<{
- columns={[ - { - header: 'Topic', - accessorKey: 'topicName', - }, - { - header: 'Brokers Before', - size: 50, - cell: ({ row: { original: topic } }) => { - const brokersBefore = topic.selectedPartitions - .flatMap((x) => x.brokersBefore) - .distinct() - .sort((a, b) => a - b); - return ; - }, - }, - { - accessorKey: 'Brokers After', - size: 50, - cell: ({ row: { original: topic } }) => { - const plannedBrokers = topic.selectedPartitions - .flatMap((x) => x.brokersAfter) - .distinct() - .sort((a, b) => a - b); - return ; - }, - }, - { - id: 'numAddedBrokers', - size: 100, - header: () => ( - - Reassignments - - ), - cell: ({ row: { original: topic } }) => topic.selectedPartitions.sum((p) => p.numAddedBrokers), - }, - { - header: 'Estimated Traffic', - size: 120, - 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 subComponent={({ row: { original: topic } }) => ( - +
{topic.selectedPartitions ? ( +
)} + tableOptions={TABLE_OPTIONS} /> {this.reassignmentOptions()} @@ -133,8 +193,6 @@ export class StepReview extends Component<{ } reassignmentOptions() { - const settings = uiSettings.reassignment; - return (

Bandwidth Throttle

@@ -143,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 }} />
@@ -162,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) => { @@ -209,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) { @@ -256,36 +315,49 @@ export class StepReview extends Component<{ } } -const ReviewPartitionTable = (props: { topic: Topic; topicPartitions: Partition[]; assignments: TopicAssignment }) => ( - - - columns={[ - { - header: 'Partition', - 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} - /> - -); + }, + ], + [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 new file mode 100644 index 0000000000..f9b0d8a6d3 --- /dev/null +++ b/frontend/src/components/pages/reassign-partitions/topic-sorting.test.tsx @@ -0,0 +1,116 @@ +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'); +}); + +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(); +}); 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/src/state/ui.ts b/frontend/src/state/ui.ts index dc794f8862..17b2040645 100644 --- a/frontend/src/state/ui.ts +++ b/frontend/src/state/ui.ts @@ -230,10 +230,6 @@ type UISettings = { pageSize: number; }; - // Select - quickSearch: string; - pageSizeSelect: number; - // Brokers pageSizeBrokers: number; @@ -409,10 +405,6 @@ const defaultUiSettings: UISettings = { pageSize: 5, }, - // Select - quickSearch: '', - pageSizeSelect: 10, - // Brokers pageSizeBrokers: 10, 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(); 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..b6aaa1292a --- /dev/null +++ b/frontend/tests/test-variant-console/reassign-partitions/reassign-partitions.spec.ts @@ -0,0 +1,73 @@ +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. + * + * 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 /; + +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(stats.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(); + }); +}); diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 7c3e09c285..f9acc6f601 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -3571,13 +3571,6 @@ resolved "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz" integrity sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ== -"@types/react-highlight-words@^0.20.0": - version "0.20.0" - resolved "https://registry.npmjs.org/@types/react-highlight-words/-/react-highlight-words-0.20.0.tgz" - integrity sha512-Qm512TiOakvtNzHJ2+TNVHnLn5cJ2wLQV0+LrhuispVth6dRf5b8ydjq3Kc0thpZ7bz4s6RnG6meboAXHWRK+Q== - dependencies: - "@types/react" "*" - "@types/react-syntax-highlighter@^15.5.13": version "15.5.13" resolved "https://registry.npmjs.org/@types/react-syntax-highlighter/-/react-syntax-highlighter-15.5.13.tgz" @@ -6390,11 +6383,6 @@ hermes-parser@^0.25.1: dependencies: hermes-estree "0.25.1" -highlight-words-core@^1.2.0: - version "1.2.3" - resolved "https://registry.npmjs.org/highlight-words-core/-/highlight-words-core-1.2.3.tgz" - integrity sha512-m1O9HW3/GNHxzSIXWw1wCNXXsgLlxrP0OI6+ycGUhiUHkikqW3OrwVHz+lxeNBe5yqLESdIcj8PowHQ2zLvUvQ== - highlight.js@^10.4.1, highlight.js@~10.7.0: version "10.7.3" resolved "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz" @@ -7582,11 +7570,6 @@ mdn-data@2.0.30: resolved "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz" integrity sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA== -memoize-one@^4.0.0: - version "4.1.0" - resolved "https://registry.npmjs.org/memoize-one/-/memoize-one-4.1.0.tgz" - integrity sha512-2GApq0yI/b22J2j9rhbrAlsHb0Qcz+7yWxeLG8h+95sl1XPUgeLimQSOdur4Vw7cUhrBHwaUZxWFZueojqNRzA== - memoize-one@^6.0.0: version "6.0.0" resolved "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz" @@ -9079,7 +9062,7 @@ rc@^1.2.7: minimist "^1.2.0" strip-json-comments "~2.0.1" -react@*, "react@>= 0.14.0", "react@>= 16.8 || 18.0.0", react@>=16, "react@>=16 || ^19.0.0-rc", react@>=16.13, react@>=16.6.0, react@>=16.8, react@>=16.8.6, react@>=16.9.0, react@>=17, react@>=18, "react@>=18.0.0 || >=19.0.0", "react@>=18.2.0 || ^19.0.0-0", "react@^0.14.0 || ^15.0.0 || ^16.0.0-0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0", "react@^15.5.x || ^16.x || ^17.x || ^18.x", "react@^16.13 || ^17 || ^18 || ^19", "react@^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react@^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react@^16.8.0 || ^17 || ^18", "react@^16.8.0 || ^17 || ^18 || ^19", "react@^16.8.0 || ^17.0.0 || ^18.0.0", "react@^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react@^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react@^16.9.0 || ^17 || ^18", "react@^16.9.0 || ^17.0.0 || ^18 || ^19", "react@^17 || ^18", "react@^17 || ^18 || ^19", "react@^18 || ^19", "react@^18 || ^19 || ^19.0.0-rc", "react@^18.0 || ^19", react@^18.0.0, "react@^18.0.0 || ^19.0.0", "react@^18.0.0 || ^19.0.0 || ^19.0.0-rc", react@^19.2.0, react@^19.2.7: +react@*, "react@>= 0.14.0", "react@>= 16.8 || 18.0.0", react@>=16, "react@>=16 || ^19.0.0-rc", react@>=16.13, react@>=16.6.0, react@>=16.8, react@>=16.8.6, react@>=16.9.0, react@>=17, react@>=18, "react@>=18.0.0 || >=19.0.0", "react@>=18.2.0 || ^19.0.0-0", "react@^15.5.x || ^16.x || ^17.x || ^18.x", "react@^16.13 || ^17 || ^18 || ^19", "react@^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react@^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react@^16.8.0 || ^17 || ^18", "react@^16.8.0 || ^17 || ^18 || ^19", "react@^16.8.0 || ^17.0.0 || ^18.0.0", "react@^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react@^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react@^16.9.0 || ^17 || ^18", "react@^16.9.0 || ^17.0.0 || ^18 || ^19", "react@^17 || ^18", "react@^17 || ^18 || ^19", "react@^18 || ^19", "react@^18 || ^19 || ^19.0.0-rc", "react@^18.0 || ^19", react@^18.0.0, "react@^18.0.0 || ^19.0.0", "react@^18.0.0 || ^19.0.0 || ^19.0.0-rc", react@^19.2.0, react@^19.2.7: version "19.2.7" resolved "https://registry.npmjs.org/react/-/react-19.2.7.tgz" integrity sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ== @@ -9172,14 +9155,6 @@ react-focus-lock@^2.9.6: use-callback-ref "^1.3.3" use-sidecar "^1.1.3" -react-highlight-words@^0.21.0: - version "0.21.0" - resolved "https://registry.npmjs.org/react-highlight-words/-/react-highlight-words-0.21.0.tgz" - integrity sha512-SdWEeU9fIINArEPO1rO5OxPyuhdEKZQhHzZZP1ie6UeXQf+CjycT1kWaB+9bwGcVbR0NowuHK3RqgqNg6bgBDQ== - dependencies: - highlight-words-core "^1.2.0" - memoize-one "^4.0.0" - react-hook-form@^7.48.2: version "7.66.0" resolved "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.0.tgz"