diff --git a/frontend/src/components/pages/topics/DeleteRecordsModal/DeleteRecordsModal.module.scss b/frontend/src/components/pages/topics/DeleteRecordsModal/DeleteRecordsModal.module.scss deleted file mode 100644 index 823bf691f6..0000000000 --- a/frontend/src/components/pages/topics/DeleteRecordsModal/DeleteRecordsModal.module.scss +++ /dev/null @@ -1,25 +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 - */ - -.slider { - flex-grow: 1; -} - -.partitionSelect { - margin-top: 1em; -} - -.twoCol { - display: grid; - grid-template-columns: 66px 1fr; - gap: 30px; - margin-bottom: 25px; -} diff --git a/frontend/src/components/pages/topics/DeleteRecordsModal/delete-records-modal.tsx b/frontend/src/components/pages/topics/DeleteRecordsModal/delete-records-modal.tsx index 1326c65408..6ae3bd9534 100644 --- a/frontend/src/components/pages/topics/DeleteRecordsModal/delete-records-modal.tsx +++ b/frontend/src/components/pages/topics/DeleteRecordsModal/delete-records-modal.tsx @@ -9,39 +9,44 @@ * by the Apache License, Version 2.0 */ +import { Alert, AlertDescription } from 'components/redpanda-ui/components/alert'; +import { Button } from 'components/redpanda-ui/components/button'; import { - Alert, - AlertIcon, - Button, - Flex, - Input, - List, - ListItem, - Modal, - ModalBody, - ModalContent, - ModalFooter, - ModalHeader, - ModalOverlay, - Slider, - SliderFilledTrack, - SliderMark, - SliderThumb, - SliderTrack, - Spinner, - Text, - useToast, -} from '@redpanda-data/ui'; + Choicebox, + ChoiceboxItem, + ChoiceboxItemContent, + ChoiceboxItemDescription, + ChoiceboxItemHeader, + ChoiceboxItemIndicator, + ChoiceboxItemTitle, +} from 'components/redpanda-ui/components/choicebox'; +import { + Dialog, + DialogBody, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from 'components/redpanda-ui/components/dialog'; +import { Input } from 'components/redpanda-ui/components/input'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from 'components/redpanda-ui/components/select'; +import { Slider } from 'components/redpanda-ui/components/slider'; +import { Spinner } from 'components/redpanda-ui/components/spinner'; +import { List, ListItem, Text } from 'components/redpanda-ui/components/typography'; import { useEffect, useState } from 'react'; +import { toast } from 'sonner'; -import styles from './DeleteRecordsModal.module.scss'; import { api, useApiStoreHook } from '../../../../state/backend-api'; import type { DeleteRecordsResponseData, Partition, Topic } from '../../../../state/rest-interfaces'; -import { RadioOptionGroup } from '../../../../utils/tsx-utils'; import { prettyNumber } from '../../../../utils/utils'; import { range } from '../../../misc/common'; import { KowlTimePicker } from '../../../misc/kowl-time-picker'; -import { SingleSelect } from '../../../misc/select'; type AllPartitions = 'allPartitions'; type SpecificPartition = 'specificPartition'; @@ -51,7 +56,7 @@ const DIGITS_ONLY_REGEX = /^\d*$/; function TrashIcon() { return ( - + Trash + + {children} + + ); +} + +function DeleteOption({ + value, + title, + subTitle, + isSelected, + children, +}: { + value: string; + title: string; + subTitle: string; + isSelected: boolean; + children?: React.ReactNode; +}) { + return ( +
+ + + {title} + {subTitle} + + + + + + {isSelected && children ?
{children}
: null} +
+ ); +} + function SelectPartitionStep({ selectedPartitionOption, onPartitionOptionSelected, @@ -86,65 +129,53 @@ function SelectPartitionStep({ partitions: number[]; }): JSX.Element { return ( - <> -
- -

- You are about to delete records in your topic. Choose on what partitions you want to delete records. In the - next step you can choose the new low water mark for your selected partitions. -

-
- - onChange={(v) => { - if (v === 'allPartitions') { +
+ + You are about to delete records in your topic. Choose on what partitions you want to delete records. In the next + step you can choose the new low water mark for your selected partitions. + + { + const option = v as PartitionOption; + if (option === 'allPartitions') { onSpecificPartitionSelected(null); } - onPartitionOptionSelected(v); + onPartitionOptionSelected(option); }} - options={[ - { - value: 'allPartitions', - title: 'All Partitions', - subTitle: 'Delete records until specified offset across all available partitions in this topic.', - }, - { - value: 'specificPartition', - title: 'Specific Partition', - subTitle: 'Delete records within a specific partition in this topic only.', - content: ( - // Workaround for Ant Design Issue: https://github.com/ant-design/ant-design/issues/25959 - // fixes immediately self closing Select drop down after an option has already been selected - // biome-ignore lint/a11y/noStaticElementInteractions: event handlers needed for dropdown workaround -
{ - e.preventDefault(); - e.stopPropagation(); - }} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - e.stopPropagation(); - } - }} - role="presentation" - > - - onChange={onSpecificPartitionSelected as (v: number | undefined) => void} - options={partitions.map((i) => ({ - label: `Partition ${i}`, - value: i, - }))} - placeholder="Choose Partition…" - value={specificPartition ?? undefined} - /> -
- ), - }, - ]} - showContent="onlyWhenSelected" - value={selectedPartitionOption} - /> - + value={selectedPartitionOption ?? ''} + > + + + + +
+
); } @@ -168,69 +199,46 @@ const SelectOffsetStep = ({ timestamp: number | null; onTimestampChanged: (v: number) => void; }) => { - const upperOption = - partitionInfo === 'allPartitions' - ? { - value: 'highWatermark' as OffsetOption, - title: 'High Watermark', - subTitle: 'Delete records until high watermark across all partitions in this topic.', - } - : { - value: 'manualOffset' as OffsetOption, - title: 'Manual Offset', - subTitle: `Delete records until specified offset across all selected partitions (ID: ${partitionInfo[1]}) in this topic.`, - content: ( + const isAllPartitions = partitionInfo === 'allPartitions'; + + return ( +
+ + Choose the new low offset for your selected partitions. Take note that this is a soft delete and that the actual + data may still be on the hard drive but not visible for any clients, even if they request the data. + + selectValue(v as OffsetOption)} value={selectedValue ?? ''}> + {isAllPartitions ? ( + + ) : ( + - ), - }; - - return ( - <> -
- -

- Choose the new low offset for your selected partitions. Take note that this is a soft delete and that the - actual data may still be on the hard drive but not visible for any clients, even if they request the data. -

-
- - onChange={selectValue} - options={[ - upperOption, - { - value: 'timestamp', - title: 'Timestamp', - subTitle: 'Delete all records prior to the selected timestamp.', - content: ( - // Workaround for Ant Design Issue: https://github.com/ant-design/ant-design/issues/25959 - // fixes immediately self closing Select drop down after an option has already been selected - // biome-ignore lint/a11y/noStaticElementInteractions: event handlers needed for dropdown workaround -
{ - e.preventDefault(); - e.stopPropagation(); - }} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - e.stopPropagation(); - } - }} - role="presentation" - > - -
- ), - }, - ]} - showContent="onlyWhenSelected" - value={selectedValue} - /> - +
+ )} + + + +
+
); }; @@ -260,32 +268,28 @@ const ManualOffsetContent = ({ const waterMarksErrors = topicWatermarksErrors?.map(({ waterMarksError }) => (
  • {waterMarksError}
  • )); - const message = ( - <> - {partitionErrors && partitionErrors.length > 0 ? ( - <> - Partition Errors: - - - ) : null} - {waterMarksErrors && waterMarksErrors.length > 0 ? ( - <> - Watermarks Errors: - - - ) : null} - - ); return ( - - - {message} + + + {partitionErrors && partitionErrors.length > 0 ? ( + <> + Partition Errors: +
      {partitionErrors}
    + + ) : null} + {waterMarksErrors && waterMarksErrors.length > 0 ? ( + <> + Watermarks Errors: +
      {waterMarksErrors}
    + + ) : null} +
    ); } if (!partitions) { - return ; + return ; } const [, partitionId] = partitionInfo; @@ -293,31 +297,29 @@ const ManualOffsetContent = ({ if (!partition) { return ( - - - {`Partition of topic ${topicName} with ID ${partitionId} not found!`} + + {`Partition of topic ${topicName} with ID ${partitionId} not found!`} ); } const { marks, min, max } = getMarks(partition); return ( - - - {marks - ? Object.entries(marks).map(([value, label]) => ( - +
    +
    + updateOffsetFromSlider(v)} value={sliderValue} /> + {marks ? ( +
    + {Object.values(marks).map((label) => ( + {label} - - )) - : null} - - - - - + + ))} +
    + ) : null} +
    { if (sliderValue < min) { updateOffsetFromSlider(min); @@ -336,7 +338,7 @@ const ManualOffsetContent = ({ }} value={sliderValue} /> - +
    ); }; @@ -393,8 +395,7 @@ type DeleteRecordsModalProps = { }; export default function DeleteRecordsModal(props: DeleteRecordsModalProps): JSX.Element | null { - const { visible, topic, onCancel, onFinish, afterClose } = props; - const toast = useToast(); + const { visible, topic, onCancel, onFinish } = props; useEffect(() => { if (topic?.topicName) { @@ -441,10 +442,7 @@ export default function DeleteRecordsModal(props: DeleteRecordsModalProps): JSX. setOkButtonLoading(false); } else { onFinish(); - toast({ - description: 'Records deleted', - status: 'success', - }); + toast.success('Records deleted'); } }; @@ -523,26 +521,45 @@ export default function DeleteRecordsModal(props: DeleteRecordsModalProps): JSX. return 'allPartitions'; }; + const okButtonLabel = (() => { + if (hasErrors) { + return 'Ok'; + } + if (step === 1) { + return 'Choose End Offset'; + } + return 'Delete Records'; + })(); + return ( - - - - Delete records in topic - - {Boolean(hasErrors) && ( - - - - Errors occurred while processing your request. Contact your Kafka administrator. - - {errors.map((e) => ( - {e} - ))} - - + { + if (!open) { + onCancel(); + } + }} + open={visible} + > + + + Delete records in topic + + + {hasErrors ? ( + + +
    + Errors occurred while processing your request. Contact your Kafka administrator. + + {errors.map((e) => ( + {e} + ))} + +
    +
    - )} - {!hasErrors && step === 1 && ( + ) : null} + {!hasErrors && step === 1 ? ( - )} - {!hasErrors && step === 2 && partitionOption !== null && ( + ) : null} + {!hasErrors && step === 2 && partitionOption !== null ? ( - )} -
    - + ) : null} + + - -
    -
    + + + ); } diff --git a/frontend/src/components/pages/topics/topic-produce.tsx b/frontend/src/components/pages/topics/topic-produce.tsx index 8340ea1947..3e58bad238 100644 --- a/frontend/src/components/pages/topics/topic-produce.tsx +++ b/frontend/src/components/pages/topics/topic-produce.tsx @@ -1,25 +1,22 @@ +/** + * 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 { create } from '@bufbuild/protobuf'; -import { - Alert, - Box, - Button, - Divider, - Flex, - FormControl, - Grid, - GridItem, - Heading, - HStack, - IconButton, - Input, - SectionHeading, - Text, - useToast, -} from '@redpanda-data/ui'; +import { zodResolver } from '@hookform/resolvers/zod'; import { Link } from '@tanstack/react-router'; -import { TrashIcon } from 'components/icons'; +import type React from 'react'; import { type FC, useEffect, useState } from 'react'; -import { Controller, type SubmitHandler, useFieldArray, useForm, useWatch } from 'react-hook-form'; +import { Controller, type SubmitHandler, useForm, useWatch } from 'react-hook-form'; +import { toast } from 'sonner'; +import { z } from 'zod'; import { setMonacoTheme } from '../../../config'; import { @@ -34,12 +31,23 @@ import { } from '../../../protogen/redpanda/api/console/v1alpha1/publish_messages_pb'; import { appGlobal } from '../../../state/app-global'; import { api, useApiStoreHook } from '../../../state/backend-api'; -import { uiState } from '../../../state/ui-state'; -import { Label } from '../../../utils/tsx-utils'; +import { setPageHeader, uiState } from '../../../state/ui-state'; import { base64ToUInt8Array, isValidBase64, substringWithEllipsis } from '../../../utils/utils'; import KowlEditor from '../../misc/kowl-editor'; -import { SingleSelect } from '../../misc/select'; -import { PageComponent, type PageInitHelper } from '../page'; +import { Alert, AlertDescription } from '../../redpanda-ui/components/alert'; +import { Button } from '../../redpanda-ui/components/button'; +import { + Field, + FieldError, + FieldLabel, + FieldLegend, + FieldSeparator, + FieldSet, +} from '../../redpanda-ui/components/field'; +import { Input } from '../../redpanda-ui/components/input'; +import { KeyValueField } from '../../redpanda-ui/components/key-value-field'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../redpanda-ui/components/select'; +import { Heading, Text } from '../../redpanda-ui/components/typography'; type EncodingOption = { value: PayloadEncoding | 'base64'; @@ -80,7 +88,7 @@ const encodingOptions: EncodingOption[] = [ ]; const protoBufInfoElement = ( - + Protobuf schemas can define multiple types. Specify which type you want to use for this message.{' '} Learn more here. @@ -104,25 +112,66 @@ function encodingToLanguage(encoding: PayloadEncoding) { return; } -type PayloadOptions = { - encoding: PayloadEncoding; - data: string; - - // Schema name - schemaName?: string; - schemaVersion?: number; - schemaId?: number; +// Numeric-valued select wrapper bridging the registry Select (string values) with +// react-hook-form fields that hold numeric enums (partition, compression, encoding, version). +function NumberSelect({ + value, + onChange, + options, + testId, + placeholder, +}: { + value: number | undefined; + onChange: (value: number) => void; + options: { label: React.ReactNode; value: number }[]; + testId?: string; + placeholder?: string; +}) { + return ( + + ); +} - protobufIndex?: number; // if encoding is protobuf, we also need an index -}; +const payloadOptionsSchema = z.object({ + encoding: z.nativeEnum(PayloadEncoding), + data: z.string(), + schemaName: z.string().optional(), + schemaVersion: z.number().optional(), + schemaId: z.number().optional(), + protobufIndex: z.number().optional(), +}); + +const produceRecordSchema = z + .object({ + partition: z.number(), + compressionType: z.nativeEnum(CompressionType), + headers: z.array(z.object({ key: z.string(), value: z.string() })), + key: payloadOptionsSchema, + value: payloadOptionsSchema, + }) + .superRefine((data, ctx) => { + if (data.key.encoding === PayloadEncoding.BINARY && !isValidBase64(data.key.data)) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Invalid Base64 format', path: ['key', 'data'] }); + } + if (data.value.encoding === PayloadEncoding.BINARY && !isValidBase64(data.value.data)) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Invalid Base64 format', path: ['value', 'data'] }); + } + }); -type Inputs = { - partition: number; - compressionType: CompressionType; - headers: { key: string; value: string }[]; - key: PayloadOptions; - value: PayloadOptions; -}; +type Inputs = z.infer; const persistCompressionType = (compressionType: CompressionType) => { uiState.topicSettings.produceRecordCompression = compressionType; @@ -130,8 +179,6 @@ const persistCompressionType = (compressionType: CompressionType) => { // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: complex business logic const PublishTopicForm: FC<{ topicName: string }> = ({ topicName }) => { - const toast = useToast(); - const { control, register, @@ -139,8 +186,9 @@ const PublishTopicForm: FC<{ topicName: string }> = ({ topicName }) => { handleSubmit, setError, formState: { isSubmitting, errors }, - clearErrors, } = useForm({ + resolver: zodResolver(produceRecordSchema), + mode: 'onChange', defaultValues: { partition: -1, compressionType: uiState.topicSettings.produceRecordCompression, @@ -156,36 +204,10 @@ const PublishTopicForm: FC<{ topicName: string }> = ({ topicName }) => { }, }); - const { fields, append, remove } = useFieldArray({ - control, - name: 'headers', - }); - const keyPayloadOptions = useWatch({ control, name: 'key' }); const valuePayloadOptions = useWatch({ control, name: 'value' }); const [isKeyExpanded, setKeyExpanded] = useState(false); - useEffect(() => { - if (keyPayloadOptions.encoding === PayloadEncoding.BINARY && !isValidBase64(keyPayloadOptions.data)) { - setError('key.data', { - type: 'manual', - message: 'Invalid Base64 format', - }); - } else { - clearErrors('key.data'); - } - }, [keyPayloadOptions.encoding, keyPayloadOptions.data, setError, clearErrors]); - - useEffect(() => { - if (valuePayloadOptions.encoding === PayloadEncoding.BINARY && !isValidBase64(valuePayloadOptions.data)) { - setError('value.data', { - type: 'manual', - message: 'Invalid Base64 format', - }); - } else { - clearErrors('value.data'); - } - }, [valuePayloadOptions.encoding, valuePayloadOptions.data, setError, clearErrors]); const showKeySchemaSelection = keyPayloadOptions.encoding === PayloadEncoding.AVRO || keyPayloadOptions.encoding === PayloadEncoding.PROTOBUF; @@ -199,10 +221,11 @@ const PublishTopicForm: FC<{ topicName: string }> = ({ topicName }) => { value: value.number as CompressionType, })); + const topics = useApiStoreHook((s) => s.topics); const availablePartitions = (() => { const partitions: { label: string; value: number }[] = [{ label: 'Auto (Murmur2)', value: -1 }]; - const count = api.topics?.first((t) => t.topicName === topicName)?.partitionCount; + const count = topics?.first((t) => t.topicName === topicName)?.partitionCount; if (count === undefined) { // topic not found return partitions; @@ -220,14 +243,8 @@ const PublishTopicForm: FC<{ topicName: string }> = ({ topicName }) => { return partitions; })(); - useEffect(() => { - // Fetch schema subjects list if not already loaded - if (!api.schemaSubjects) { - api.refreshSchemaSubjects(); - } - }, []); - - const availableValues = api.schemaSubjects?.filter((x) => !x.isSoftDeleted) ?? []; + const schemaSubjects = useApiStoreHook((s) => s.schemaSubjects); + const availableValues = schemaSubjects?.filter((x) => !x.isSoftDeleted) ?? []; const keySchemaName = useWatch({ control, name: 'key.schemaName' }); const valueSchemaName = useWatch({ control, name: 'value.schemaName' }); @@ -298,7 +315,9 @@ const PublishTopicForm: FC<{ topicName: string }> = ({ topicName }) => { } } - req.key.index = data.key.protobufIndex; + if (data.key.protobufIndex !== undefined) { + req.key.index = data.key.protobufIndex; + } } // Value @@ -331,7 +350,9 @@ const PublishTopicForm: FC<{ topicName: string }> = ({ topicName }) => { } } - req.value.index = data.value.protobufIndex; + if (data.value.protobufIndex !== undefined) { + req.value.index = data.value.protobufIndex; + } } const result = await api.publishMessage(req).catch((err) => { @@ -341,364 +362,340 @@ const PublishTopicForm: FC<{ topicName: string }> = ({ topicName }) => { }); if (result) { - toast({ - status: 'success', - description: ( - <> - Record published on partition {result.partitionId} with offset{' '} - {Number(result.offset)} - - ), - duration: 3000, - }); + toast.success(`Record published on partition ${result.partitionId} with offset ${Number(result.offset)}`); appGlobal.historyPush(`/topics/${encodeURIComponent(topicName)}`); } }; - const filteredEncodingOptions = encodingOptions.filter((x) => x.value !== PayloadEncoding.AVRO); + const filteredEncodingOptions = encodingOptions + .filter((x) => x.value !== PayloadEncoding.AVRO) + .map((x) => ({ label: x.label, value: x.value as number })); + + const schemaNameOptions = availableValues.map((schema) => ({ label: schema.name, value: schema.name })); + + const sortedKeyVersions = + keySchemaDetail?.versions + .slice() + .sort(({ version: version1 }, { version: version2 }) => version2 - version1) + .map(({ version }) => ({ label: version, value: version })) ?? []; + + const sortedValueVersions = + valueSchemaDetail?.versions + .slice() + .sort(({ version: version1 }, { version: version2 }) => version2 - version1) + .map(({ version }) => ({ label: version, value: version })) ?? []; return ( -
    - - - -