diff --git a/frontend/src/components/pages/topics/Tab.Messages/index.tsx b/frontend/src/components/pages/topics/Tab.Messages/index.tsx index fa9997601..62601860a 100644 --- a/frontend/src/components/pages/topics/Tab.Messages/index.tsx +++ b/frontend/src/components/pages/topics/Tab.Messages/index.tsx @@ -51,6 +51,7 @@ import { isServerless } from '../../../../config'; import { Link as ReactRouterLink } from 'react-router-dom'; import { PublishMessagePayloadOptions, PublishMessageRequest } from '../../../../protogen/redpanda/api/console/v1alpha1/publish_messages_pb'; import { CompressionType, KafkaRecordHeader, PayloadEncoding } from '../../../../protogen/redpanda/api/console/v1alpha1/common_pb'; +import { appGlobal } from '../../../../state/appGlobal'; import { WarningIcon } from '@chakra-ui/icons'; interface TopicMessageViewProps { @@ -311,7 +312,9 @@ export class TopicMessageView extends Component { this.showPublishRecordsModal({ topicName: this.props.topic.topicName })} + onClick={() => { + appGlobal.history.push(`/topics/${encodeURIComponent(topic.topicName)}/publish`); + }} > Publish Message diff --git a/frontend/src/components/pages/topics/Topic.Publish.tsx b/frontend/src/components/pages/topics/Topic.Publish.tsx new file mode 100644 index 000000000..9ba56336b --- /dev/null +++ b/frontend/src/components/pages/topics/Topic.Publish.tsx @@ -0,0 +1,518 @@ +import { Alert, Box, Button, Flex, FormControl, Grid, GridItem, Heading, HStack, IconButton, Input, Link, Text, useToast } from '@redpanda-data/ui'; +import { PageComponent, PageInitHelper } from '../Page'; +import { autorun, computed } from 'mobx'; +import { api } from '../../../state/backendApi'; +import { observer } from 'mobx-react'; +import { Controller, SubmitHandler, useFieldArray, useForm } from 'react-hook-form' +import { FC, useEffect } from 'react'; +import { SingleSelect } from '../../misc/Select'; +import { Label } from '../../../utils/tsxUtils'; +import { proto3 } from '@bufbuild/protobuf'; +import { CompressionType, KafkaRecordHeader, PayloadEncoding } from '../../../protogen/redpanda/api/console/v1alpha1/common_pb'; +import { HiOutlineTrash } from 'react-icons/hi'; +import KowlEditor, { IStandaloneCodeEditor, Monaco } from '../../misc/KowlEditor'; +import { Link as ReactRouterLink } from 'react-router-dom' +import { PublishMessagePayloadOptions, PublishMessageRequest } from '../../../protogen/redpanda/api/console/v1alpha1/publish_messages_pb'; +import { uiSettings } from '../../../state/ui'; +import { appGlobal } from '../../../state/appGlobal'; + +type EncodingOption = { + value: PayloadEncoding | 'base64', + label: string, + tooltip: string, // React.ReactNode | (() => React.ReactNode), +}; +const encodingOptions: EncodingOption[] = [ + {value: PayloadEncoding.NONE, label: 'None (Tombstone)', tooltip: 'Message value will be null'}, + {value: PayloadEncoding.TEXT, label: 'Text', tooltip: 'Text in the editor will be encoded to UTF-8 bytes'}, + {value: PayloadEncoding.JSON, label: 'JSON', tooltip: 'Syntax higlighting for JSON, otherwise the same as text'}, + + {value: PayloadEncoding.AVRO, label: 'Avro', tooltip: 'The given JSON will be serialized using the selected schema'}, + {value: PayloadEncoding.PROTOBUF, label: 'Protobuf', tooltip: 'The given JSON will be serialized using the selected schema'}, + + {value: 'base64', label: 'Binary (Base64)', tooltip: 'Message value is binary, represented as a base64 string in the editor'}, +]; + +const protoBufInfoElement = + Protobuf schemas can define multiple types. Specify which type you want to use for this + message. Learn more here. + + +function encodingToLanguage(encoding: PayloadEncoding | 'base64') { + if (encoding == PayloadEncoding.AVRO) return 'json'; + if (encoding == PayloadEncoding.JSON) return 'json'; + if (encoding == PayloadEncoding.PROTOBUF) return 'protobuf'; + return undefined; +} + +type PayloadOptions = { + encoding: PayloadEncoding | 'base64'; + data: string; + + // Schema name + schemaName?: string; + schemaVersion?: number; + schemaId?: number; + + protobufIndex?: number; // if encoding is protobuf, we also need an index +} + +type Inputs = { + partition: number; + compressionType: CompressionType; + headers: { key: string; value: string }[]; + key: PayloadOptions; + value: PayloadOptions; +} + +const PublishTopicForm: FC<{ topicName: string }> = observer(({topicName}) => { + const toast = useToast() + + const { + control, + register, + setValue, + handleSubmit, + setError, + formState: { + isSubmitting, + errors + }, + watch + } = useForm({ + defaultValues: { + partition: -1, + compressionType: CompressionType.UNCOMPRESSED, + headers: [{key: '', value: ''}], + key: { + data: '', + encoding: PayloadEncoding.TEXT, + }, + value: { + data: '', + encoding: PayloadEncoding.TEXT, + }, + } + }) + + const {fields, append, remove} = useFieldArray({ + control, + name: 'headers', + }); + + + const keyPayloadOptions = watch('key') + const valuePayloadOptions = watch('value') + + const showKeySchemaSelection = keyPayloadOptions.encoding === PayloadEncoding.AVRO || keyPayloadOptions.encoding === PayloadEncoding.PROTOBUF + const showValueSchemaSelection = valuePayloadOptions.encoding === PayloadEncoding.AVRO || valuePayloadOptions.encoding === PayloadEncoding.PROTOBUF + + const compressionTypes = proto3.getEnumType(CompressionType).values + // .filter(x => x.no != CompressionType.UNSPECIFIED) + .map(x => ({label: x.localName, value: x.no as CompressionType})) + + const availablePartitions = computed(() => { + const partitions: { label: string, value: number; }[] = [ + {label: 'Auto (CRC32)', value: -1}, + ]; + + const count = api.topics?.first(t => t.topicName == topicName)?.partitionCount; + if (count == undefined) { + // topic not found + return partitions; + } + + if (count == 1) { + // only one partition to select + return partitions; + } + + for (let i = 0; i < count; i++) { + partitions.push({label: `Partition ${i}`, value: i}); + } + + return partitions; + }) + + useEffect(() => { + return autorun(() => { + api.schemaSubjects + ?.filter(x => uiSettings.schemaList.showSoftDeleted || (!uiSettings.schemaList.showSoftDeleted && !x.isSoftDeleted)) + ?.filter(x => x.name.toLowerCase().includes(uiSettings.schemaList.quickSearch.toLowerCase())) + .forEach(x => { + void api.refreshSchemaDetails(x.name); + }) + }) + }, []); + + const availableValues = Array.from(api.schemaDetails.values()) + + const keySchemaName = watch('key.schemaName') + const valueSchemaName = watch('value.schemaName') + + const onSubmit: SubmitHandler = async (data) => { + const req = new PublishMessageRequest(); + req.topic = topicName + req.partitionId = data.partition + req.compression = data.compressionType + + // Headers + for (const h of data.headers) { + if (!h.value && !h.value) { + continue; + } + const kafkaHeader = new KafkaRecordHeader(); + kafkaHeader.key = h.key; + kafkaHeader.value = new TextEncoder().encode(h.value); + req.headers.push(kafkaHeader); + } + + // Value + if (data.value.encoding != PayloadEncoding.NONE) { + req.value = new PublishMessagePayloadOptions(); + req.value.data = new TextEncoder().encode(data.value.data); + req.value.encoding = data.value.encoding as PayloadEncoding; + req.value.schemaId = data.value.schemaId; + req.value.index = data.value.protobufIndex; + } + + const result = await api.publishMessage(req).catch(err => { + setError('root.serverError', { + message: err.rawMessage, + }) + }) + + if (result) { + toast({ + status: 'success', + description: <>Record published on partition {result.partitionId} with offset {Number(result.offset)}, + duration: 3000 + }) + appGlobal.history.push(`/topics/${encodeURIComponent(topicName)}`) + } + } + + return ( +
+ + + + + + + + + + + + + + + + + + {showKeySchemaSelection && + + } + + + {showKeySchemaSelection && } + + + + {keyPayloadOptions.encoding === PayloadEncoding.PROTOBUF && } + + + + + + + + + + {showValueSchemaSelection && } + + + {showValueSchemaSelection && } + + + + {valuePayloadOptions.encoding === PayloadEncoding.PROTOBUF && } + + + + + {!!errors?.root?.serverError && + + {errors.root.serverError.message} + } + + + + Go Back + + +
+ ) +}) + + +@observer +export class TopicPublishPage extends PageComponent<{ topicName: string }> { + initPage(p: PageInitHelper): void { + const topicName = this.props.topicName; + p.title = 'Publish' + p.addBreadcrumb('Topics', '/topics'); + p.addBreadcrumb(topicName, '/topics/' + topicName); + p.addBreadcrumb('Publish', '/publish') + this.refreshData(true); + appGlobal.onRefresh = () => this.refreshData(true); + } + + refreshData(force?: boolean) { + api.refreshSchemaSubjects(force); + } + + render() { + return ( + + Produce message + This will produce a single message to the {this.props.topicName} topic. + + + + + + ) + } +} + + +function setTheme(editor: IStandaloneCodeEditor, monaco: Monaco) { + monaco.editor.defineTheme('kowl', { + base: 'vs', + inherit: true, + colors: { + 'editor.background': '#fcfcfc', + 'editor.foreground': '#ff0000', + + 'editorGutter.background': '#00000018', + + 'editor.lineHighlightBackground': '#aaaaaa20', + 'editor.lineHighlightBorder': '#00000000', + 'editorLineNumber.foreground': '#8c98a8', + + 'scrollbarSlider.background': '#ff0000', + // "editorOverviewRuler.border": "#0000", + 'editorOverviewRuler.background': '#606060', + 'editorOverviewRuler.currentContentForeground': '#ff0000' + // background: #0001; + // border-left: 1px solid #0002; + }, + rules: [] + }) + monaco.editor.setTheme('kowl'); +} diff --git a/frontend/src/components/routes.tsx b/frontend/src/components/routes.tsx index 6a0e88759..de859bca5 100644 --- a/frontend/src/components/routes.tsx +++ b/frontend/src/components/routes.tsx @@ -42,6 +42,7 @@ import Overview from './pages/overview/Overview'; import { BrokerDetails } from './pages/overview/Broker.Details'; import EditSchemaCompatibilityPage from './pages/schemas/EditCompatibility'; import { SchemaCreatePage, SchemaAddVersionPage } from './pages/schemas/Schema.Create'; +import { TopicPublishPage } from './pages/topics/Topic.Publish'; // // Route Types @@ -273,6 +274,7 @@ export const APP_ROUTES: IRouteEntry[] = [ MakeRoute<{}>('/topics', TopicList, 'Topics', CollectionIcon), MakeRoute<{ topicName: string }>('/topics/:topicName', TopicDetails, 'Topics'), + MakeRoute<{ topicName: string }>('/topics/:topicName/publish', TopicPublishPage, 'Publish Message'), MakeRoute<{}>('/schema-registry', SchemaList, 'Schema Registry', CubeTransparentIcon), MakeRoute<{}>('/schema-registry/create', SchemaCreatePage, 'Create schema'),