From a961052ffb9cde882b8f0c6a9ef4e449077b225e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A1n=20Vor=C4=8D=C3=A1k?= Date: Mon, 23 Mar 2026 13:43:53 +0100 Subject: [PATCH 1/4] Remove topics from backend-api' --- frontend/bun.lock | 12 +- .../pages/admin/admin-debug-bundle.tsx | 5 +- .../pages/connect/connector-details.tsx | 19 +- .../connect/dynamic-ui/forms/topic-input.tsx | 10 +- .../src/components/pages/consumers/modals.tsx | 19 +- .../pages/mcp-servers/create/tool-card.tsx | 2 +- .../pages/mcp-servers/create/tools-step.tsx | 2 +- .../details/remote-mcp-configuration-tab.tsx | 2 +- .../components/active-reassignments.tsx | 21 +- .../components/statistics-bar.tsx | 6 +- .../logic/reassignment-tracker.ts | 8 +- .../reassign-partitions.tsx | 62 ++- .../reassign-partitions/step1-partitions.tsx | 19 +- .../reassign-partitions/step3-review.tsx | 16 +- .../components/pages/rp-connect/errors.tsx | 3 +- .../pages/rp-connect/pipeline/index.tsx | 2 +- .../pages/rp-connect/pipelines-details.tsx | 5 +- .../pages/schemas/schema-create.tsx | 8 +- .../delete-records-modal.tsx | 60 +-- .../pages/topics/Tab.Messages/index.tsx | 8 +- .../components/pages/topics/quick-info.tsx | 12 +- .../components/pages/topics/tab-config.tsx | 12 +- .../components/pages/topics/tab-consumers.tsx | 7 +- .../src/components/pages/topics/tab-docu.tsx | 127 +++--- .../pages/topics/tab-partitions.tsx | 10 +- .../pages/topics/topic-configuration.tsx | 23 +- .../components/pages/topics/topic-details.tsx | 68 +-- .../components/pages/topics/topic-list.tsx | 7 +- .../components/pages/topics/topic-produce.tsx | 4 +- .../pages/transforms/transform-details.tsx | 5 +- frontend/src/react-query/api/ai-gateway.tsx | 12 +- .../api/ai-gateway/model-providers.tsx | 12 +- .../src/react-query/api/ai-gateway/models.tsx | 12 +- frontend/src/react-query/api/topic.tsx | 389 +++++++++++++++++- frontend/src/state/backend-api.ts | 386 +---------------- 35 files changed, 709 insertions(+), 666 deletions(-) diff --git a/frontend/bun.lock b/frontend/bun.lock index 20bfbd15bb..59b1806912 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -151,17 +151,17 @@ }, }, "overrides": { + "lodash": "^4.17.23", "@hono/node-server": "^1.19.10", - "ajv": "^8.18.0", - "body-parser": "^2.2.1", "diff": "^5.2.2", "dompurify": "^3.3.2", - "elliptic": "^6.6.1", - "lodash": "^4.17.23", + "qs": "^6.14.2", "lodash-es": "^4.17.23", - "mdast-util-to-hast": "^13.2.1", "prismjs": "^1.30.0", - "qs": "^6.14.2", + "elliptic": "^6.6.1", + "body-parser": "^2.2.1", + "ajv": "^8.18.0", + "mdast-util-to-hast": "^13.2.1", }, "packages": { "@a2a-js/sdk": ["@a2a-js/sdk@0.3.10", "", { "dependencies": { "uuid": "^11.1.0" }, "peerDependencies": { "@bufbuild/protobuf": "^2.10.2", "@grpc/grpc-js": "^1.11.0", "express": "^4.21.2 || ^5.1.0" }, "optionalPeers": ["@bufbuild/protobuf", "@grpc/grpc-js", "express"] }, "sha512-t6w5ctnwJkSOMRl6M9rn95C1FTHCPqixxMR0yWXtzhZXEnF6mF1NAK0CfKlG3cz+tcwTxkmn287QZC3t9XPgrA=="], diff --git a/frontend/src/components/pages/admin/admin-debug-bundle.tsx b/frontend/src/components/pages/admin/admin-debug-bundle.tsx index e8625f8882..47ceabf056 100644 --- a/frontend/src/components/pages/admin/admin-debug-bundle.tsx +++ b/frontend/src/components/pages/admin/admin-debug-bundle.tsx @@ -44,6 +44,7 @@ import { type SCRAMAuth, SCRAMAuth_Mechanism, } from '../../../protogen/redpanda/api/console/v1alpha1/debug_bundle_pb'; +import queryClient from '../../../query-client'; import { appGlobal } from '../../../state/app-global'; import { api, useApiStoreHook } from '../../../state/backend-api'; import type { BrokerWithConfigAndStorage } from '../../../state/rest-interfaces'; @@ -216,9 +217,7 @@ const NewDebugBundleForm: FC<{ useEffect(() => { api.refreshBrokers(true); - api.refreshPartitions('all', true).catch(() => { - // Error handling managed by API layer - }); + queryClient.invalidateQueries({ queryKey: ['topicPartitionsAll'] }); }, []); const fieldViolationsMap = error?.details diff --git a/frontend/src/components/pages/connect/connector-details.tsx b/frontend/src/components/pages/connect/connector-details.tsx index 934e01f97c..c12478ed8e 100644 --- a/frontend/src/components/pages/connect/connector-details.tsx +++ b/frontend/src/components/pages/connect/connector-details.tsx @@ -14,6 +14,7 @@ import React, { useEffect, useRef, useState } from 'react'; import { ConfigPage } from './dynamic-ui/components'; +import { useTopicsQuery } from '../../../react-query/api/topic'; import { appGlobal } from '../../../state/app-global'; import { api, createMessageSearch, type MessageSearch, type MessageSearchRequest } from '../../../state/backend-api'; import { ConnectClusterStore } from '../../../state/connect/state'; @@ -89,7 +90,8 @@ const KafkaConnectorMain = ({ }) => { const [connectClusterStore] = useState(() => ConnectClusterStore.getInstance(clusterName)); - const logsTopic = api.topics?.first((x) => x.topicName === LOGS_TOPIC_NAME); + const { data: topicsData } = useTopicsQuery(); + const logsTopic = topicsData?.topics?.first((x) => x.topicName === LOGS_TOPIC_NAME); useEffect(() => { const init = async () => { @@ -494,7 +496,8 @@ const ConnectorErrorModal = (p: { error: ConnectorError }) => { const errorType = p.error.type === 'ERROR' ? 'error' : 'warning'; - const hasConnectorLogs = api.topics?.any((x) => x.topicName === LOGS_TOPIC_NAME); + const { data: connectorTopicsData } = useTopicsQuery(); + const hasConnectorLogs = connectorTopicsData?.topics?.any((x) => x.topicName === LOGS_TOPIC_NAME); return ( <> @@ -552,13 +555,11 @@ class KafkaConnectorDetails extends PageComponent<{ clusterName: string; connect appGlobal.onRefresh = () => this.refreshData(true).catch(console.error); } - async refreshData(force: boolean): Promise { + async refreshData(_force: boolean): Promise { ConnectClusterStore.connectClusters.clear(); await api.refreshConnectClusters(); - // refresh topics so we know whether or not we can show the "go to error logs topic" button in the connector details error popup - // and show the logs tab - api.refreshTopics(force); + // React Query handles topics fetching via useTopicsQuery hooks in child components } render() { @@ -569,9 +570,6 @@ class KafkaConnectorDetails extends PageComponent<{ clusterName: string; connect return ; } - // Touch observables so PageComponent's Reaction tracks them for re-renders. - void api.topics; - return ( @@ -674,7 +672,8 @@ const LogsTab = (p: { const { connector } = p; const connectorName = connector.name; const topicName = LOGS_TOPIC_NAME; - const topic = api.topics?.first((x) => x.topicName === topicName); + const { data: logsTopicsData } = useTopicsQuery(); + const topic = logsTopicsData?.topics?.first((x) => x.topicName === topicName); const [logState, setLogState] = useState<{ messages: TopicMessage[]; isComplete: boolean }>({ messages: [], diff --git a/frontend/src/components/pages/connect/dynamic-ui/forms/topic-input.tsx b/frontend/src/components/pages/connect/dynamic-ui/forms/topic-input.tsx index 67773cafcd..e9f26d2dfb 100644 --- a/frontend/src/components/pages/connect/dynamic-ui/forms/topic-input.tsx +++ b/frontend/src/components/pages/connect/dynamic-ui/forms/topic-input.tsx @@ -19,9 +19,9 @@ import { isMultiValue, Select, } from '@redpanda-data/ui'; -import { useEffect, useMemo, useState } from 'react'; +import { useMemo, useState } from 'react'; -import { api } from '../../../../../state/backend-api'; +import { useTopicsQuery } from '../../../../../react-query/api/topic'; import type { Property } from '../../../../../state/connect/state'; import { ExpandableText } from '../../../../misc/expandable-text'; @@ -40,9 +40,7 @@ export const TopicInput = (p: { properties: Property[]; connectorType: 'sink' | const [selected, setSelected] = useState(initialSelection); - useEffect(() => { - api.refreshTopics(); - }, []); + const { data: topicsData } = useTopicsQuery(); const property = propsMap.get(selected); const isRegex = selected === 'topics.regex'; @@ -97,7 +95,7 @@ export const TopicInput = (p: { properties: Property[]; connectorType: 'sink' | setPropertyValue(property, v.map(({ value }) => value)?.join(',') ?? []); } }} - options={api.topics?.map((x) => ({ value: x.topicName, label: x.topicName })) ?? []} + options={topicsData?.topics?.map((x) => ({ value: x.topicName, label: x.topicName })) ?? []} value={ property.value ? property.value diff --git a/frontend/src/components/pages/consumers/modals.tsx b/frontend/src/components/pages/consumers/modals.tsx index dc33cccd9c..dc65a892e6 100644 --- a/frontend/src/components/pages/consumers/modals.tsx +++ b/frontend/src/components/pages/consumers/modals.tsx @@ -37,6 +37,8 @@ import { import { ChevronLeftIcon, ChevronRightIcon, SkipIcon, TrashIcon, WarningIcon } from 'components/icons'; import { Component } from 'react'; +import queryClient from '../../../query-client'; +import { getTopicOffsetsByTimestamp } from '../../../react-query/api/topic'; import { appGlobal } from '../../../state/app-global'; import { api } from '../../../state/backend-api'; import type { @@ -496,7 +498,7 @@ export class EditOffsetsModal extends Component<{ let offsetsForTimestamp: TopicOffset[]; try { - offsetsForTimestamp = await api.getTopicOffsetsByTimestamp(requiredTopics, this.state.timestampUtcMs); + offsetsForTimestamp = await getTopicOffsetsByTimestamp(requiredTopics, this.state.timestampUtcMs); toast.update(toastRef, { status: 'success', duration: 2000, @@ -658,10 +660,9 @@ export class EditOffsetsModal extends Component<{ // need all groups for "other groups" dropdown api.refreshConsumerGroups(); - // need watermarks for all topics the group consumes - // in order to know earliest/latest offsets + // React Query handles partition data; invalidate to force refetch const topics = this.props.group.topicOffsets.map((x) => x.topic).distinct(); - api.refreshPartitions(topics, true); + queryClient.invalidateQueries({ queryKey: ['topicPartitionsAll', ...topics.slice().sort()] }); // reset settings this.setState({ page: 0, selectedOption: 'startOffset' }); @@ -785,7 +786,10 @@ class ColAfter extends Component<{ // not found - no message after given timestamp // use 'latest' - const partition = api.topicPartitions.get(record.topicName)?.first((p) => p.id === record.partitionId); + const partitionsAllData = queryClient.getQueryData< + Map + >(['topicPartitionsAll']); + const partition = partitionsAllData?.get(record.topicName)?.first((p) => p.id === record.partitionId); return (
p.id === record.partitionId); + const partitionsAllData2 = queryClient.getQueryData< + Map + >(['topicPartitionsAll']); + const partition = partitionsAllData2?.get(record.topicName)?.first((p) => p.id === record.partitionId); const content = val === -2 diff --git a/frontend/src/components/pages/mcp-servers/create/tool-card.tsx b/frontend/src/components/pages/mcp-servers/create/tool-card.tsx index cee70fafcb..2c5d30406f 100644 --- a/frontend/src/components/pages/mcp-servers/create/tool-card.tsx +++ b/frontend/src/components/pages/mcp-servers/create/tool-card.tsx @@ -8,6 +8,7 @@ * by the Apache License, Version 2.0 */ +import type { LintHint } from '@buf/redpandadata_common.bufbuild_es/redpanda/api/common/v1/linthint_pb'; import { Button } from 'components/redpanda-ui/components/button'; import { Card, CardContent } from 'components/redpanda-ui/components/card'; import { Field, FieldDescription, FieldError, FieldLabel } from 'components/redpanda-ui/components/field'; @@ -24,7 +25,6 @@ import { RedpandaConnectComponentTypeBadge } from 'components/ui/connect/redpand import { LintHintList } from 'components/ui/lint-hint/lint-hint-list'; import { YamlEditorCard } from 'components/ui/yaml/yaml-editor-card'; import { Trash2 } from 'lucide-react'; -import type { LintHint } from '@buf/redpandadata_common.bufbuild_es/redpanda/api/common/v1/linthint_pb'; import { MCPServer_Tool_ComponentType } from 'protogen/redpanda/api/dataplane/v1alpha3/mcp_pb'; import { Controller, type UseFormReturn, useWatch } from 'react-hook-form'; diff --git a/frontend/src/components/pages/mcp-servers/create/tools-step.tsx b/frontend/src/components/pages/mcp-servers/create/tools-step.tsx index 92c2facff3..27a61c4f35 100644 --- a/frontend/src/components/pages/mcp-servers/create/tools-step.tsx +++ b/frontend/src/components/pages/mcp-servers/create/tools-step.tsx @@ -8,12 +8,12 @@ * by the Apache License, Version 2.0 */ +import type { LintHint } from '@buf/redpandadata_common.bufbuild_es/redpanda/api/common/v1/linthint_pb'; import { Button } from 'components/redpanda-ui/components/button'; import { Field, FieldError } from 'components/redpanda-ui/components/field'; import { Heading, Text } from 'components/redpanda-ui/components/typography'; import { QuickAddSecrets } from 'components/ui/secret/quick-add-secrets'; import { Plus } from 'lucide-react'; -import type { LintHint } from '@buf/redpandadata_common.bufbuild_es/redpanda/api/common/v1/linthint_pb'; import { Scope } from 'protogen/redpanda/api/dataplane/v1/secret_pb'; import { MCPServer_Tool_ComponentType } from 'protogen/redpanda/api/dataplane/v1alpha3/mcp_pb'; import type { UseFieldArrayReturn, UseFormReturn } from 'react-hook-form'; diff --git a/frontend/src/components/pages/mcp-servers/details/remote-mcp-configuration-tab.tsx b/frontend/src/components/pages/mcp-servers/details/remote-mcp-configuration-tab.tsx index 8eac2fa989..cbf00ae48a 100644 --- a/frontend/src/components/pages/mcp-servers/details/remote-mcp-configuration-tab.tsx +++ b/frontend/src/components/pages/mcp-servers/details/remote-mcp-configuration-tab.tsx @@ -20,6 +20,7 @@ import { getRouteApi } from '@tanstack/react-router'; const routeApi = getRouteApi('/mcp-servers/$id'); +import type { LintHint } from '@buf/redpandadata_common.bufbuild_es/redpanda/api/common/v1/linthint_pb'; import { CLOUD_MANAGED_TAG_KEYS, isCloudManagedTagKey } from 'components/constants'; import { Button } from 'components/redpanda-ui/components/button'; import { Card, CardContent, CardHeader, CardTitle } from 'components/redpanda-ui/components/card'; @@ -44,7 +45,6 @@ import { ServiceAccountSection } from 'components/ui/service-account/service-acc import { ExpandedYamlDialog } from 'components/ui/yaml/expanded-yaml-dialog'; import { YamlEditorCard } from 'components/ui/yaml/yaml-editor-card'; import { Edit, FileText, Hammer, Plus, Save, Settings, ShieldCheck, Trash2 } from 'lucide-react'; -import type { LintHint } from '@buf/redpandadata_common.bufbuild_es/redpanda/api/common/v1/linthint_pb'; import { Scope } from 'protogen/redpanda/api/dataplane/v1/secret_pb'; import React, { useCallback, useState } from 'react'; import { 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 b7a7a683f2..4d9b17673d 100644 --- a/frontend/src/components/pages/reassign-partitions/components/active-reassignments.tsx +++ b/frontend/src/components/pages/reassign-partitions/components/active-reassignments.tsx @@ -47,6 +47,7 @@ import { import React, { Component, type FC, useRef, useState } from 'react'; import { BandwidthSlider } from './bandwidth-slider'; +import queryClient from '../../../../query-client'; import { api } from '../../../../state/backend-api'; import type { ConfigEntry } from '../../../../state/rest-interfaces'; import { QuickTable } from '../../../../utils/tsx-utils'; @@ -376,19 +377,17 @@ export class ReassignmentDetailsDialog extends Component<{ state: ReassignmentSt // became visible or invisible // force update of topic config, so isThrottle has up to date information setTimeout(async () => { - api.topicConfig.delete(state.topicName); - await api.refreshTopicConfig(state.topicName, true); + await queryClient.invalidateQueries({ queryKey: ['topicConfig', state.topicName] }); this.setState({ shouldThrottle: this.isThrottled() }); }); } this.wasVisible = visible; - const topicConfig = api.topicConfig.get(state.topicName); - if (!topicConfig) { - setTimeout(() => { - api.refreshTopicConfig(state.topicName); - }); - } + const topicConfig = + queryClient.getQueryData([ + 'topicConfig', + state.topicName, + ]) ?? null; const replicas = state.partitions.flatMap((p) => p.replicas).distinct(); const addingReplicas = state.partitions.flatMap((p) => p.addingReplicas).distinct(); @@ -463,7 +462,11 @@ export class ReassignmentDetailsDialog extends Component<{ state: ReassignmentSt if (!this.lastState) { return false; } - const config = api.topicConfig.get(this.lastState.topicName); + const config = + queryClient.getQueryData([ + 'topicConfig', + this.lastState.topicName, + ]) ?? null; if (!config) { return false; } diff --git a/frontend/src/components/pages/reassign-partitions/components/statistics-bar.tsx b/frontend/src/components/pages/reassign-partitions/components/statistics-bar.tsx index 3639f7a7f2..fe9c31d059 100644 --- a/frontend/src/components/pages/reassign-partitions/components/statistics-bar.tsx +++ b/frontend/src/components/pages/reassign-partitions/components/statistics-bar.tsx @@ -9,18 +9,20 @@ * by the Apache License, Version 2.0 */ +import queryClient from '../../../../query-client'; import { api } from '../../../../state/backend-api'; import type { Broker, Partition } from '../../../../state/rest-interfaces'; import { prettyBytesOrNA } from '../../../../utils/utils'; import type { PartitionSelection } from '../reassign-partitions'; export function SelectionInfoBar(props: { partitionSelection: PartitionSelection; margin?: string }) { - if (api.topicPartitions === null) { + const topicPartitionsAll = queryClient.getQueryData>(['topicPartitionsAll']); + if (topicPartitionsAll === null) { return null; } const selectedPartitions: { topic: string; partitions: Partition[] }[] = []; - for (const [topic, partitions] of api.topicPartitions) { + for (const [topic, partitions] of topicPartitionsAll ?? new Map()) { if (partitions === null) { continue; } diff --git a/frontend/src/components/pages/reassign-partitions/logic/reassignment-tracker.ts b/frontend/src/components/pages/reassign-partitions/logic/reassignment-tracker.ts index ac87c17297..a3ad760230 100644 --- a/frontend/src/components/pages/reassign-partitions/logic/reassignment-tracker.ts +++ b/frontend/src/components/pages/reassign-partitions/logic/reassignment-tracker.ts @@ -13,6 +13,7 @@ // - manages timers for refreshing current reassignments // - tracks progress history for each reassignment to estimate speed and ETA +import queryClient from '../../../../query-client'; import { api } from '../../../../state/backend-api'; import type { PartitionReassignments } from '../../../../state/rest-interfaces'; import { IsDev } from '../../../../utils/env'; @@ -114,7 +115,7 @@ export class ReassignmentTracker { // Update relevant topic-partitions const topics = liveReassignments.map((r) => r.topicName); if (topics.length > 0) { - await api.refreshPartitions(topics, true); + queryClient.invalidateQueries({ queryKey: ['topicPartitionsAll'] }); } // Add new reassignments @@ -189,7 +190,10 @@ export class ReassignmentTracker { // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: legacy code updateReassignmentState(state: ReassignmentState) { // partition stats - const topicPartitions = api.topicPartitions.get(state.topicName); + const topicPartitionsAllData = queryClient.getQueryData< + Map + >(['topicPartitionsAll']); + const topicPartitions = topicPartitionsAllData?.get(state.topicName); for (const p of state.partitions) { const logDirs = topicPartitions?.first((e) => e.id === p.partitionId)?.partitionLogDirs.filter((l) => !l.error); if (!logDirs || logDirs.length === 0) { diff --git a/frontend/src/components/pages/reassign-partitions/reassign-partitions.tsx b/frontend/src/components/pages/reassign-partitions/reassign-partitions.tsx index 691841ddaf..4f46565e2f 100644 --- a/frontend/src/components/pages/reassign-partitions/reassign-partitions.tsx +++ b/frontend/src/components/pages/reassign-partitions/reassign-partitions.tsx @@ -45,6 +45,7 @@ import { import { StepSelectPartitions } from './step1-partitions'; import { StepSelectBrokers } from './step2-brokers'; import { StepReview, type TopicWithMoves } from './step3-review'; +import queryClient from '../../../query-client'; import { appGlobal } from '../../../state/app-global'; import { api, partialTopicConfigs } from '../../../state/backend-api'; import type { @@ -185,8 +186,8 @@ class ReassignPartitions extends PageComponent { refreshData(force: boolean) { api.refreshCluster(force); // need to know brokers for reassignment calculation, will also refresh config - api.refreshTopics(force); - api.refreshPartitions('all', force); + queryClient.invalidateQueries({ queryKey: ['topics'] }); + queryClient.invalidateQueries({ queryKey: ['topicPartitionsAll'] }); api.refreshPartitionReassignments(force); } @@ -200,16 +201,15 @@ class ReassignPartitions extends PageComponent { if (!api.clusterInfo) { return DefaultSkeleton; } - if (!api.topics) { - return DefaultSkeleton; - } - if (api.partitionReassignments === undefined) { return DefaultSkeleton; } - const partitionCountLeaders = api.topics?.sum((t) => t.partitionCount); - const partitionCountOnlyReplicated = api.topics?.sum((t) => t.partitionCount * (t.replicationFactor - 1)); + const cachedTopics = queryClient.getQueryData([ + 'topics', + ])?.topics; + const partitionCountLeaders = cachedTopics?.sum((t) => t.partitionCount); + const partitionCountOnlyReplicated = cachedTopics?.sum((t) => t.partitionCount * (t.replicationFactor - 1)); const { currentStep, requestInProgress, partitionSelection, selectedBrokerIds, reassignmentRequest } = this.state; @@ -236,7 +236,10 @@ class ReassignPartitions extends PageComponent { (); - for (const [topicName, partitions] of api.topicPartitions) { + const cachedTopicPartitions = queryClient.getQueryData< + Map + >(['topicPartitionsAll']); + for (const [topicName, partitions] of cachedTopicPartitions ?? + new Map()) { if (!partitions) { continue; } @@ -505,10 +512,12 @@ class ReassignPartitions extends PageComponent { apiTopicPartitions.set(topicName, validOnly); } + const cachedTopicsData = + queryClient.getQueryData(['topics'])?.topics ?? []; // error checking will happen inside computeReassignments const apiData: ApiData = { brokers: api.clusterInfo?.brokers ?? [], - topics: api.topics as Topic[], + topics: cachedTopicsData as Topic[], topicPartitions: apiTopicPartitions, }; @@ -634,7 +643,10 @@ class ReassignPartitions extends PageComponent { const followerReplicas: { partitionId: number; brokerId: number }[] = []; for (const p of t.partitions) { const partitionId = p.partitionId; - const brokersOld = api.topicPartitions + const cachedTpForTraffic = queryClient.getQueryData< + Map + >(['topicPartitionsAll']); + const brokersOld = cachedTpForTraffic ?.get(t.topicName) ?.first((partition) => partition.id === partitionId)?.replicas; const brokersNew = p.replicas; @@ -773,8 +785,12 @@ class ReassignPartitions extends PageComponent { } get selectedTopicPartitions(): TopicPartitions[] | undefined { - const apiTopics = api.topics; - const apiPartitions = api.topicPartitions; + const apiTopics = queryClient.getQueryData([ + 'topics', + ])?.topics; + const apiPartitions = queryClient.getQueryData< + Map + >(['topicPartitionsAll']); if (!(apiTopics && apiPartitions)) { // biome-ignore lint/suspicious/useGetterReturn: early return for undefined case @@ -788,7 +804,10 @@ class ReassignPartitions extends PageComponent { let maxRf = 0; for (const topicName in this.state.partitionSelection) { if (Object.hasOwn(this.state.partitionSelection, topicName)) { - const topic = api.topics?.first((x) => x.topicName === topicName); + const cachedTopicsForRf = queryClient.getQueryData([ + 'topics', + ])?.topics; + const topic = cachedTopicsForRf?.first((x) => x.topicName === topicName); if (topic && topic.replicationFactor > maxRf) { maxRf = topic.replicationFactor; } @@ -801,14 +820,21 @@ class ReassignPartitions extends PageComponent { if (this.state.reassignmentRequest === null) { return []; } - if (api.topics === null) { + const cachedTopicsForMoves = queryClient.getQueryData([ + 'topics', + ])?.topics; + if (!cachedTopicsForMoves) { return []; } + const cachedTpForMoves = + queryClient.getQueryData>([ + 'topicPartitionsAll', + ]) ?? new Map(); return computeMovedReplicas( this.state.partitionSelection, this.state.reassignmentRequest, - api.topics, - api.topicPartitions + cachedTopicsForMoves, + cachedTpForMoves ); } diff --git a/frontend/src/components/pages/reassign-partitions/step1-partitions.tsx b/frontend/src/components/pages/reassign-partitions/step1-partitions.tsx index 8f0576b4f2..84640bff0c 100644 --- a/frontend/src/components/pages/reassign-partitions/step1-partitions.tsx +++ b/frontend/src/components/pages/reassign-partitions/step1-partitions.tsx @@ -17,8 +17,14 @@ import Highlighter from 'react-highlight-words'; import { SelectionInfoBar } from './components/statistics-bar'; import type { PartitionSelection } from './reassign-partitions'; +import queryClient from '../../../query-client'; import { api } from '../../../state/backend-api'; -import type { Partition, PartitionReassignmentsPartition, Topic } from '../../../state/rest-interfaces'; +import type { + GetTopicsResponse, + 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'; @@ -54,7 +60,8 @@ export class StepSelectPartitions extends Component<{ } render() { - if (!api.topics) { + const topics = queryClient.getQueryData(['topics']); + if (!topics) { return DefaultSkeleton; } @@ -262,11 +269,13 @@ export class StepSelectPartitions extends Component<{ } get topicPartitions(): TopicWithPartitions[] { - if (api.topics === null) { + const topicsData = queryClient.getQueryData(['topics']); + const topicPartitionsAll = queryClient.getQueryData>(['topicPartitionsAll']); + if (topicsData === null || topicsData === undefined) { return []; } - return api.topics.flatMap((topic) => { - const partitions = api.topicPartitions.get(topic.topicName); + return (topicsData.topics ?? []).flatMap((topic) => { + const partitions = topicPartitionsAll?.get(topic.topicName); if (!partitions) { return []; // skip topics whose partitions haven't loaded yet (e.g. newly created) } diff --git a/frontend/src/components/pages/reassign-partitions/step3-review.tsx b/frontend/src/components/pages/reassign-partitions/step3-review.tsx index 70083f1219..32600db58f 100644 --- a/frontend/src/components/pages/reassign-partitions/step3-review.tsx +++ b/frontend/src/components/pages/reassign-partitions/step3-review.tsx @@ -15,8 +15,14 @@ import { Component } 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 queryClient from '../../../query-client'; +import type { + GetTopicsResponse, + Partition, + PartitionReassignmentRequest, + Topic, + TopicAssignment, +} from '../../../state/rest-interfaces'; import { uiSettings } from '../../../state/ui'; import { DefaultSkeleton, InfoText } from '../../../utils/tsx-utils'; import { prettyBytesOrNA, prettyMilliseconds } from '../../../utils/utils'; @@ -46,10 +52,12 @@ export class StepReview extends Component<{ reassignPartitions: ReassignPartitions; // since api is still changing, we pass parent down so we can call functions on it directly }> { render() { - if (!api.topics) { + const topics = queryClient.getQueryData(['topics']); + const topicPartitionsAll = queryClient.getQueryData>(['topicPartitionsAll']); + if (!topics) { return DefaultSkeleton; } - if (api.topicPartitions.size === 0) { + if ((topicPartitionsAll?.size ?? 0) === 0) { return ; } diff --git a/frontend/src/components/pages/rp-connect/errors.tsx b/frontend/src/components/pages/rp-connect/errors.tsx index d8f21e0b2a..584e9db1e9 100644 --- a/frontend/src/components/pages/rp-connect/errors.tsx +++ b/frontend/src/components/pages/rp-connect/errors.tsx @@ -1,9 +1,8 @@ +import { type LintHint, LintHintSchema } from '@buf/redpandadata_common.bufbuild_es/redpanda/api/common/v1/linthint_pb'; import { create } from '@bufbuild/protobuf'; import { ConnectError } from '@connectrpc/connect'; import { Text } from 'components/redpanda-ui/components/typography'; -import { type LintHint, LintHintSchema } from '@buf/redpandadata_common.bufbuild_es/redpanda/api/common/v1/linthint_pb'; - /** * Extracts lint hints from a ConnectError for display in LintResults component. * Converts all errors (validation errors, field violations, and generic errors) to lint hints. diff --git a/frontend/src/components/pages/rp-connect/pipeline/index.tsx b/frontend/src/components/pages/rp-connect/pipeline/index.tsx index 4c06024eb4..b0fe10d084 100644 --- a/frontend/src/components/pages/rp-connect/pipeline/index.tsx +++ b/frontend/src/components/pages/rp-connect/pipeline/index.tsx @@ -11,6 +11,7 @@ 'use no memo'; +import type { LintHint } from '@buf/redpandadata_common.bufbuild_es/redpanda/api/common/v1/linthint_pb'; import { create } from '@bufbuild/protobuf'; import { zodResolver } from '@hookform/resolvers/zod'; import { useNavigate, useRouter, useSearch } from '@tanstack/react-router'; @@ -31,7 +32,6 @@ import { useDebounce } from 'hooks/use-debounce'; import { useDebouncedValue } from 'hooks/use-debounced-value'; import type { editor } from 'monaco-editor'; import type { JSONSchema } from 'monaco-yaml'; -import type { LintHint } from '@buf/redpandadata_common.bufbuild_es/redpanda/api/common/v1/linthint_pb'; import { CreatePipelineRequestSchema, UpdatePipelineRequestSchema, diff --git a/frontend/src/components/pages/rp-connect/pipelines-details.tsx b/frontend/src/components/pages/rp-connect/pipelines-details.tsx index 047e27c417..328cf8d136 100644 --- a/frontend/src/components/pages/rp-connect/pipelines-details.tsx +++ b/frontend/src/components/pages/rp-connect/pipelines-details.tsx @@ -31,9 +31,9 @@ import { type Pipeline_Resources, Pipeline_State, } from '../../../protogen/redpanda/api/dataplane/v1/pipeline_pb'; +import { useTopicsQuery } from '../../../react-query/api/topic'; import { appGlobal } from '../../../state/app-global'; import { - api, createMessageSearch, type MessageSearch, type MessageSearchRequest, @@ -264,7 +264,8 @@ const PipelineEditor = (p: { pipeline: Pipeline }) => { export const LogsTab = ({ pipeline, variant = 'card' }: { pipeline: Pipeline; variant?: 'ghost' | 'card' }) => { const topicName = '__redpanda.connect.logs'; - const topic = api.topics?.first((x) => x.topicName === topicName); + const { data: topicsData } = useTopicsQuery(); + const topic = topicsData?.topics?.first((x) => x.topicName === topicName); const [logState, setLogState] = useState<{ messages: TopicMessage[]; isComplete: boolean }>({ messages: [], diff --git a/frontend/src/components/pages/schemas/schema-create.tsx b/frontend/src/components/pages/schemas/schema-create.tsx index 8797b18bea..573fe5a2b4 100644 --- a/frontend/src/components/pages/schemas/schema-create.tsx +++ b/frontend/src/components/pages/schemas/schema-create.tsx @@ -35,6 +35,7 @@ import { type Dispatch, type SetStateAction, useEffect, useState } from 'react'; import { openSwitchSchemaFormatModal, openValidationErrorsModal } from './modals'; import { useSchemaTypesQuery } from '../../../react-query/api/schema-registry'; +import { useTopicsQuery } from '../../../react-query/api/topic'; import { appGlobal } from '../../../state/app-global'; import { api } from '../../../state/backend-api'; import { @@ -153,7 +154,7 @@ export class SchemaCreatePage extends PageComponent { refreshData(force?: boolean) { api.refreshSchemaSubjects(force); // for references editor -> subject selector - api.refreshTopics(force); // for the topics selector + // topics are fetched via useTopicsQuery hook } render() { @@ -467,6 +468,7 @@ const SchemaEditor = (p: { onStateChange: SetSchemaState; }) => { const { data: schemaTypes } = useSchemaTypesQuery(); + const { data: topicsData } = useTopicsQuery(); useEffect(() => { api.refreshSchemaTypes(true); @@ -527,7 +529,9 @@ const SchemaEditor = (p: { p.onStateChange((prev) => ({ ...prev, userInput: e })); }} options={ - api.topics?.filter((x) => !x.topicName.startsWith('_')).map((x) => ({ value: x.topicName })) ?? [] + topicsData?.topics + ?.filter((x) => !x.topicName.startsWith('_')) + .map((x) => ({ value: x.topicName })) ?? [] } value={state.userInput} /> 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 02edb9afbd..c8cda27b5b 100644 --- a/frontend/src/components/pages/topics/DeleteRecordsModal/delete-records-modal.tsx +++ b/frontend/src/components/pages/topics/DeleteRecordsModal/delete-records-modal.tsx @@ -34,10 +34,14 @@ import { Text, useToast, } from '@redpanda-data/ui'; -import { useEffect, useState } from 'react'; +import { useState } from 'react'; import styles from './DeleteRecordsModal.module.scss'; -import { api } from '../../../../state/backend-api'; +import { + getTopicOffsetsByTimestamp, + useDeleteTopicRecordsMutation, + useTopicPartitionsQuery, +} from '../../../../react-query/api/topic'; import type { DeleteRecordsResponseData, Partition, Topic } from '../../../../state/rest-interfaces'; import { RadioOptionGroup } from '../../../../utils/tsx-utils'; import { prettyNumber } from '../../../../utils/utils'; @@ -252,25 +256,29 @@ const ManualOffsetContent = ({ onOffsetSpecified(v); }; - if (api.topicPartitionErrors?.get(topicName) || api.topicWatermarksErrors?.get(topicName)) { - const partitionErrors = api.topicPartitionErrors - .get(topicName) - ?.map(({ partitionError }) =>
  • {partitionError}
  • ); - const waterMarksErrors = api.topicWatermarksErrors - .get(topicName) - ?.map(({ waterMarksError }) =>
  • {waterMarksError}
  • ); + const { data: topicPartitionsResult } = useTopicPartitionsQuery(topicName); + const partitionErrors = topicPartitionsResult?.partitionErrors ?? []; + const waterMarkErrors = topicPartitionsResult?.waterMarkErrors ?? []; + + if (partitionErrors.length > 0 || waterMarkErrors.length > 0) { + const partitionErrorItems = partitionErrors.map(({ partitionError }) => ( +
  • {partitionError}
  • + )); + const waterMarkErrorItems = waterMarkErrors.map(({ waterMarksError }) => ( +
  • {waterMarksError}
  • + )); const message = ( <> - {partitionErrors && partitionErrors.length > 0 ? ( + {partitionErrorItems.length > 0 ? ( <> Partition Errors: -
      {partitionErrors}
    +
      {partitionErrorItems}
    ) : null} - {waterMarksErrors && waterMarksErrors.length > 0 ? ( + {waterMarkErrorItems.length > 0 ? ( <> Watermarks Errors: -
      {waterMarksErrors}
    +
      {waterMarkErrorItems}
    ) : null} @@ -283,7 +291,7 @@ const ManualOffsetContent = ({ ); } - const partitions = api.topicPartitions?.get(topicName); + const partitions = topicPartitionsResult?.partitions; if (!partitions) { return ; @@ -396,12 +404,8 @@ type DeleteRecordsModalProps = { export default function DeleteRecordsModal(props: DeleteRecordsModalProps): JSX.Element | null { const { visible, topic, onCancel, onFinish, afterClose } = props; const toast = useToast(); - - useEffect(() => { - if (topic?.topicName) { - api.refreshPartitionsForTopic(topic.topicName, true); - } - }, [topic?.topicName]); + const deleteTopicRecords = useDeleteTopicRecordsMutation(); + const { data: topicPartitionsData } = useTopicPartitionsQuery(topic?.topicName ?? '', !!topic?.topicName); const [wizardState, setWizardState] = useState(() => ({ partitionOption: null as PartitionOption, @@ -489,24 +493,30 @@ export default function DeleteRecordsModal(props: DeleteRecordsModalProps): JSX. setOkButtonLoading(true); if (isAllPartitions && isHighWatermark) { - api.deleteTopicRecordsFromAllPartitionsHighWatermark(topicName)?.then(handleFinish); + const partitions = topicPartitionsData?.partitions ?? []; + const pairs = partitions.map(({ waterMarkHigh, id }) => ({ partitionId: id, offset: waterMarkHigh })); + deleteTopicRecords.mutateAsync({ topicName, pairs }).then(handleFinish); } else if (isSpecficPartition && isManualOffset) { // biome-ignore lint/style/noNonNullAssertion: not touching MobX observables - api.deleteTopicRecords(topicName, specifiedOffset, specifiedPartition!)?.then(handleFinish); + deleteTopicRecords + .mutateAsync({ topicName, pairs: [{ partitionId: specifiedPartition!, offset: specifiedOffset }] }) + .then(handleFinish); } else if (isTimestamp && timestamp !== null) { - api.getTopicOffsetsByTimestamp([topicName], timestamp).then((topicOffsets) => { + getTopicOffsetsByTimestamp([topicName], timestamp).then((topicOffsets) => { if (isAllPartitions) { const pairs = topicOffsets[0].partitions.map(({ partitionId, offset }) => ({ partitionId, offset, })); - api.deleteTopicRecordsFromMultiplePartitionOffsetPairs(topicName, pairs)?.then(handleFinish); + deleteTopicRecords.mutateAsync({ topicName, pairs }).then(handleFinish); } else if (isSpecficPartition) { const partitionOffset = topicOffsets[0].partitions.find((p) => specifiedPartition === p.partitionId)?.offset; if (partitionOffset !== null && partitionOffset !== undefined) { // biome-ignore lint/style/noNonNullAssertion: not touching MobX observables - api.deleteTopicRecords(topicName, partitionOffset, specifiedPartition!)?.then(handleFinish); + deleteTopicRecords + .mutateAsync({ topicName, pairs: [{ partitionId: specifiedPartition!, offset: partitionOffset }] }) + .then(handleFinish); } else { setErrors([ 'No partition offset was specified, this should not happen. Please contact your administrator.', diff --git a/frontend/src/components/pages/topics/Tab.Messages/index.tsx b/frontend/src/components/pages/topics/Tab.Messages/index.tsx index 556162cd27..17de65bd2f 100644 --- a/frontend/src/components/pages/topics/Tab.Messages/index.tsx +++ b/frontend/src/components/pages/topics/Tab.Messages/index.tsx @@ -13,7 +13,7 @@ import React, { type FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { api, createMessageSearch, type MessageSearchRequest } from '../../../../state/backend-api'; +import { createMessageSearch, type MessageSearchRequest } from '../../../../state/backend-api'; import type { Topic, TopicMessage } from '../../../../state/rest-interfaces'; import { createFilterEntry, @@ -662,8 +662,7 @@ export const TopicMessageView: FC = (props) => { const executeMessageSearch = useCallback( // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: complex business logic async (abortSignal?: AbortSignal): Promise => { - const canUseFilters = - (api.topicPermissions.get(props.topic.topicName)?.canUseSearchFilters ?? true) && !isServerless(); + const canUseFilters = !isServerless(); let filterCode = ''; if (canUseFilters) { @@ -1286,8 +1285,7 @@ export const TopicMessageView: FC = (props) => { }); // Search controls derived state - const canUseFilters = - (api.topicPermissions.get(props.topic.topicName)?.canUseSearchFilters ?? true) && !isServerless(); + const canUseFilters = !isServerless(); const customStartOffsetValid = !Number.isNaN(Number(customStartOffsetValue)); const startOffsetOptions = [ diff --git a/frontend/src/components/pages/topics/quick-info.tsx b/frontend/src/components/pages/topics/quick-info.tsx index 5abe2230ca..ecdf5fa72b 100644 --- a/frontend/src/components/pages/topics/quick-info.tsx +++ b/frontend/src/components/pages/topics/quick-info.tsx @@ -9,7 +9,6 @@ * by the Apache License, Version 2.0 */ -import { api } from '../../../state/backend-api'; import type { ConfigEntry, Topic } from '../../../state/rest-interfaces'; import '../../../utils/array-extensions'; import { Box, Divider, Flex, Text, Tooltip } from '@redpanda-data/ui'; @@ -17,6 +16,7 @@ import { InfoIcon } from 'components/icons'; import type { ReactNode } from 'react'; import type { CleanupPolicyType } from './types'; +import { useTopicConfigQuery, useTopicPartitionsQuery } from '../../../react-query/api/topic'; import { formatConfigValue } from '../../../utils/formatters/config-value-formatter'; import { numberToThousandsString } from '../../../utils/tsx-utils'; import { prettyBytesOrNA } from '../../../utils/utils'; @@ -27,21 +27,23 @@ export const TopicQuickInfoStatistic = (p: { topic: Topic }) => { const topic = p.topic; // Messages - const partitions = api.topicPartitions.get(topic.topicName); + const { data: partitionsResult, isLoading: partitionsLoading } = useTopicPartitionsQuery(topic.topicName); let messageSum: ReactNode; - if (partitions === undefined) { + if (partitionsLoading) { messageSum = '...'; // no response yet - } else if (partitions === null) { + } else if (partitionsResult?.partitions === null) { messageSum = 'N/A'; // explicit null -> not allowed } else { + const partitions = partitionsResult?.partitions ?? []; const totalMessages = partitions.sum((partition) => partition.waterMarkHigh - partition.waterMarkLow); messageSum = numberToThousandsString(totalMessages); } // Config Entries / Separator - const configEntries = api.topicConfig.get(topic.topicName)?.configEntries; + const { data: topicConfigData } = useTopicConfigQuery(topic.topicName); + const configEntries = topicConfigData?.configEntries; const filteredConfigEntries = filterTopicConfig(configEntries); const cleanupPolicy = configEntries?.find((x) => x.name === 'cleanup.policy')?.value; diff --git a/frontend/src/components/pages/topics/tab-config.tsx b/frontend/src/components/pages/topics/tab-config.tsx index 43670d7d97..b68a19e92e 100644 --- a/frontend/src/components/pages/topics/tab-config.tsx +++ b/frontend/src/components/pages/topics/tab-config.tsx @@ -10,10 +10,11 @@ */ import { Box, Button, Code, CodeBlock, Empty, Flex, Result } from '@redpanda-data/ui'; +import { useQueryClient } from '@tanstack/react-query'; import TopicConfigurationEditor from './topic-configuration'; +import { useTopicConfigQuery } from '../../../react-query/api/topic'; import { appGlobal } from '../../../state/app-global'; -import { api } from '../../../state/backend-api'; import type { KafkaError, Topic } from '../../../state/rest-interfaces'; import { toJson } from '../../../utils/json-utils'; import { DefaultSkeleton } from '../../../utils/tsx-utils'; @@ -24,15 +25,16 @@ import '../../../utils/array-extensions'; // Full topic configuration export function TopicConfiguration(props: { topic: Topic }) { - const config = api.topicConfig.get(props.topic.topicName); + const queryClient = useQueryClient(); + const { data: config, isLoading } = useTopicConfigQuery(props.topic.topicName); - if (config === undefined) { + if (isLoading) { return DefaultSkeleton; } if (config?.error) { return renderKafkaError(props.topic.topicName, config.error); } - if (config === null || config.configEntries.length === 0) { + if (config === null || config === undefined || config.configEntries.length === 0) { return ; } @@ -42,7 +44,7 @@ export function TopicConfiguration(props: { topic: Topic }) { { - api.refreshTopicConfig(props.topic.topicName, true); + queryClient.invalidateQueries({ queryKey: ['topicConfig', props.topic.topicName] }); }} targetTopic={props.topic.topicName} /> diff --git a/frontend/src/components/pages/topics/tab-consumers.tsx b/frontend/src/components/pages/topics/tab-consumers.tsx index 585b3c7bbb..8da8b3a230 100644 --- a/frontend/src/components/pages/topics/tab-consumers.tsx +++ b/frontend/src/components/pages/topics/tab-consumers.tsx @@ -18,8 +18,8 @@ import '../../../utils/array-extensions'; import { DataTable } from '@redpanda-data/ui'; import usePaginationParams from '../../../hooks/use-pagination-params'; +import { useTopicConsumersQuery } from '../../../react-query/api/topic'; import { appGlobal } from '../../../state/app-global'; -import { api } from '../../../state/backend-api'; import { uiState } from '../../../state/ui-state'; import { onPaginationChange } from '../../../utils/pagination'; import { editQuery } from '../../../utils/query-helper'; @@ -28,13 +28,12 @@ import { DefaultSkeleton } from '../../../utils/tsx-utils'; type TopicConsumersProps = { topic: Topic }; export const TopicConsumers: FC = ({ topic }) => { - const rawConsumers = api.topicConsumers.get(topic.topicName); - const isLoading = rawConsumers === null; + const { data: rawConsumers, isLoading } = useTopicConsumersQuery(topic.topicName); const consumers = rawConsumers ?? []; const paginationParams = usePaginationParams(consumers.length, uiState.topicSettings.consumerPageSize); - if (isLoading) { + if (isLoading || rawConsumers === undefined) { return DefaultSkeleton; } diff --git a/frontend/src/components/pages/topics/tab-docu.tsx b/frontend/src/components/pages/topics/tab-docu.tsx index 58b79151ff..4582ae6319 100644 --- a/frontend/src/components/pages/topics/tab-docu.tsx +++ b/frontend/src/components/pages/topics/tab-docu.tsx @@ -9,7 +9,7 @@ * by the Apache License, Version 2.0 */ -import { Component } from 'react'; +import type { FC } from 'react'; import type { Topic } from '../../../state/rest-interfaces'; import '../../../utils/array-extensions'; @@ -21,7 +21,7 @@ import { vs } from 'react-syntax-highlighter/dist/esm/styles/prism'; import remarkEmoji from 'remark-emoji'; import remarkGfm from 'remark-gfm'; -import { api } from '../../../state/backend-api'; +import { useTopicDocumentationQuery } from '../../../react-query/api/topic'; import { animProps } from '../../../utils/animation-props'; import { DefaultSkeleton } from '../../../utils/tsx-utils'; @@ -31,13 +31,6 @@ const CODE_LANGUAGE_REGEX = /language-(\w+)/; // Regex for removing trailing newlines const TRAILING_NEWLINE_REGEX = /\n$/; -// Test for link sanitizer -/* - -My nonsuspious link - -*/ - const allowedProtocols = ['http://', 'https://', 'mailto://']; function sanitizeUrl(uri: string): string { @@ -59,66 +52,68 @@ function sanitizeUrl(uri: string): string { return ''; // didn't match any allowed protocol, remove the link } -export class TopicDocumentation extends Component<{ topic: Topic }> { - private readonly components = { - // biome-ignore lint/suspicious/noExplicitAny: react-markdown component props are complex and dynamic - code({ inline, className, children, ...props }: any) { - const match = CODE_LANGUAGE_REGEX.exec(className || ''); - return !inline && match ? ( - - {String(children).replace(TRAILING_NEWLINE_REGEX, '')} - - ) : ( - - {children} - - ); - }, - }; - - render() { - const docu = api.topicDocumentation.get(this.props.topic.topicName); - if (docu === undefined) { - return DefaultSkeleton; // not yet loaded - } - if (!docu.isEnabled) { - return errorNotConfigured; - } +// biome-ignore lint/suspicious/noExplicitAny: react-markdown component props are complex and dynamic +const CodeBlock = ({ inline, className, children, ...props }: any) => { + const match = CODE_LANGUAGE_REGEX.exec(className || ''); + return !inline && match ? ( + + {String(children).replace(TRAILING_NEWLINE_REGEX, '')} + + ) : ( + + {children} + + ); +}; - const markdown = docu?.text; - if (markdown === null || markdown === undefined) { - return errorNotFound; - } +const markdownComponents = { code: CodeBlock }; - if (markdown === '') { - return errorEmpty; - } +export const TopicDocumentation: FC<{ topic: Topic }> = ({ topic }) => { + const { data: docu, isLoading } = useTopicDocumentationQuery(topic.topicName); - return ( -
    - - {markdown} - -
    - ); + if (isLoading) { + return DefaultSkeleton; } -} + if (!docu) { + return DefaultSkeleton; + } + if (!docu.isEnabled) { + return errorNotConfigured; + } + + const markdown = docu?.text; + if (markdown === null || markdown === undefined) { + return errorNotFound; + } + + if (markdown === '') { + return errorEmpty; + } + + return ( +
    + + {markdown} + +
    + ); +}; const errorNotConfigured = renderDocuError( 'Not Configured', @@ -155,8 +150,6 @@ const errorEmpty = renderDocuError( ); -// todo: use common renderError function everywhere -// todo: use for them function renderDocuError(title: string, body: JSX.Element) { return ( diff --git a/frontend/src/components/pages/topics/tab-partitions.tsx b/frontend/src/components/pages/topics/tab-partitions.tsx index c1f8e454c5..39c9a65261 100644 --- a/frontend/src/components/pages/topics/tab-partitions.tsx +++ b/frontend/src/components/pages/topics/tab-partitions.tsx @@ -11,14 +11,15 @@ import type { FC } from 'react'; -import { api } from '../../../state/backend-api'; import type { Partition, Topic } from '../../../state/rest-interfaces'; import '../../../utils/array-extensions'; import { Alert, AlertIcon, Box, DataTable, Flex, Popover, Text } from '@redpanda-data/ui'; import { WarningIcon } from 'components/icons'; import { Badge } from 'components/redpanda-ui/components/badge'; +import { useTopicPartitionsQuery } from 'react-query/api/topic'; import usePaginationParams from '../../../hooks/use-pagination-params'; +import { api } from '../../../state/backend-api'; import { uiState } from '../../../state/ui-state'; import { onPaginationChange } from '../../../utils/pagination'; import { editQuery } from '../../../utils/query-helper'; @@ -34,10 +35,11 @@ const persistPartitionPageSize = (pageSize: number) => { }; export const TopicPartitions: FC = ({ topic }) => { - const partitions = api.topicPartitions.get(topic.topicName); + const { data: partitionsResult, isLoading } = useTopicPartitionsQuery(topic.topicName); + const partitions = partitionsResult?.partitions; const paginationParams = usePaginationParams(partitions?.length ?? 0, uiState.topicSettings.partitionPageSize); - if (partitions === undefined) { + if (isLoading || partitionsResult === undefined) { return DefaultSkeleton; } if (partitions === null) { @@ -124,7 +126,7 @@ export const TopicPartitions: FC = ({ topic }) => { cell: ({ row: { original: partition } }) => , }, ]} - data={partitions} + data={partitions!} // @ts-expect-error - we need to get rid of this enum in DataTable defaultPageSize={uiState.topicSettings.partitionPageSize} onPaginationChange={onPaginationChange(paginationParams, ({ pageSize, pageIndex }) => { diff --git a/frontend/src/components/pages/topics/topic-configuration.tsx b/frontend/src/components/pages/topics/topic-configuration.tsx index f2b84ec91a..234e6627e9 100644 --- a/frontend/src/components/pages/topics/topic-configuration.tsx +++ b/frontend/src/components/pages/topics/topic-configuration.tsx @@ -38,7 +38,7 @@ import { import './TopicConfiguration.scss'; import { isServerless } from '../../../config'; -import { api } from '../../../state/backend-api'; +import { useUpdateTopicConfigMutation } from '../../../react-query/api/topic'; import { SingleSelect } from '../../misc/select'; type ConfigurationEditorProps = { @@ -59,6 +59,7 @@ const ConfigEditorForm: FC<{ targetTopic: string; }> = ({ editedEntry, onClose, targetTopic, onSuccess }) => { const toast = useToast(); + const updateTopicConfig = useUpdateTopicConfigMutation(); const [globalError, setGlobalError] = useState(null); const defaultValueType = (() => { @@ -113,13 +114,16 @@ const ConfigEditorForm: FC<{ const configValue = operation === 'SET' ? String(value) : undefined; try { - await api.changeTopicConfig(targetTopic, [ - { - key: editedEntry.name, - op: operation, - value: configValue, - }, - ]); + await updateTopicConfig.mutateAsync({ + topicName: targetTopic, + configs: [ + { + key: editedEntry.name, + op: operation, + value: configValue, + }, + ], + }); toast({ status: 'success', description: ( @@ -230,8 +234,7 @@ const ConfigurationEditor: FC = (props) => { setEditedEntry(configEntry); }; - const topic = props.targetTopic; - const hasEditPermissions = topic ? (api.topicPermissions.get(topic)?.canEditTopicConfig ?? true) : true; + const hasEditPermissions = true; let entries = props.entries; if (filter) { diff --git a/frontend/src/components/pages/topics/topic-details.tsx b/frontend/src/components/pages/topics/topic-details.tsx index aefc55dbef..4ec6f66b92 100644 --- a/frontend/src/components/pages/topics/topic-details.tsx +++ b/frontend/src/components/pages/topics/topic-details.tsx @@ -11,6 +11,7 @@ import React, { useState, useSyncExternalStore } from 'react'; +import { useTopicAclsQuery, useTopicConfigQuery, useTopicsQuery } from '../../../react-query/api/topic'; import { appGlobal } from '../../../state/app-global'; import { api, useApiStore } from '../../../state/backend-api'; import type { ConfigEntry, Topic, TopicAction } from '../../../state/rest-interfaces'; @@ -139,36 +140,29 @@ const warnIcon = ( ); -function refreshTopicData(topicName: string, force: boolean) { +import queryClient from '../../../query-client'; + +function refreshTopicData(topicName: string, _force: boolean) { // must know what distribution we're working with; redpanda has some differences api.refreshClusterOverview(); - // there is no single endpoint to refresh a single topic - api.refreshTopics(force); - - // consumers are lazy loaded because they're (relatively) expensive - if (uiSettings.topicDetailsActiveTabKey === 'consumers') { - api.refreshTopicConsumers(topicName, force); - } - - // partitions are required for the Partitions tab - api.refreshPartitionsForTopic(topicName, force); - - // configuration is always required for the statistics bar - api.refreshTopicConfig(topicName, force); + // React Query handles topics, consumers, partitions, config, documentation, acls via hooks + queryClient.invalidateQueries({ queryKey: ['topics'] }); + queryClient.invalidateQueries({ queryKey: ['topicPartitions', topicName] }); + queryClient.invalidateQueries({ queryKey: ['topicConfig', topicName] }); api.refreshClusterHealth().catch(() => { // Error handling managed by API layer }); - // documentation can be lazy loaded + if (uiSettings.topicDetailsActiveTabKey === 'consumers') { + queryClient.invalidateQueries({ queryKey: ['topicConsumers', topicName] }); + } if (uiSettings.topicDetailsActiveTabKey === 'documentation') { - api.refreshTopicDocumentation(topicName, force); + queryClient.invalidateQueries({ queryKey: ['topicDocumentation', topicName] }); } - - // ACL can be lazy loaded if (uiSettings.topicDetailsActiveTabKey === 'topicacl') { - api.refreshTopicAcls(topicName, force); + queryClient.invalidateQueries({ queryKey: ['topicAcls', topicName] }); } } @@ -190,28 +184,36 @@ class TopicDetails extends PageComponent<{ topicName: string }> { render() { const { topicName } = this.props; - // Read api.topics in the class render so PageComponent's forceUpdate() re-evaluates - // the loading state when the Zustand store delivers topics data. - if (!api.topics) { - return DefaultSkeleton; - } - const topic = api.topics.find((e) => e.topicName === topicName); - if (!topic) { - return topicNotFound(topicName); - } - return ; + return ; } } +const TopicAclsTabWrapper = ({ topicName }: { topicName: string }) => { + const { data: aclData } = useTopicAclsQuery(topicName); + return ; +}; + +const TopicDetailsWrapper = ({ topicName }: { topicName: string }) => { + const { data: topicsData, isLoading } = useTopicsQuery(); + if (isLoading) return DefaultSkeleton; + const topic = topicsData?.topics?.find((e) => e.topicName === topicName); + if (!topic) return topicNotFound(topicName); + return ; +}; + const TopicDetailsContent = ({ topic, topicName }: { topic: Topic; topicName: string }) => { useSyncExternalStore(useApiStore.subscribe, useApiStore.getState); const [deleteRecordsModalAlive, setDeleteRecordsModalAlive] = useState(false); - // Derived: topicConfig - const config = api.topicConfig.get(topicName); + // Derived: topicConfig via React Query + const { data: topicConfigData } = useTopicConfigQuery(topicName); const topicConfig: ConfigEntry[] | null | undefined = - config === undefined ? undefined : config === null || config.error !== null ? null : config.configEntries; + topicConfigData === undefined + ? undefined + : topicConfigData === null || topicConfigData.error !== null + ? null + : topicConfigData.configEntries; setTimeout(() => topicConfig && addBaseFavs(topicConfig)); @@ -280,7 +282,7 @@ const TopicDetailsContent = ({ topic, topicName }: { topic: Topic; topicName: st 'topicacl', 'seeTopic', 'ACL', - (t) => , + (t) => , [ () => { if ( diff --git a/frontend/src/components/pages/topics/topic-list.tsx b/frontend/src/components/pages/topics/topic-list.tsx index 7a4461b700..1ccd36cf65 100644 --- a/frontend/src/components/pages/topics/topic-list.tsx +++ b/frontend/src/components/pages/topics/topic-list.tsx @@ -36,7 +36,7 @@ import { AnimatePresence, motion } from 'framer-motion'; import { useQueryStateWithCallback } from 'hooks/use-query-state-with-callback'; import { parseAsBoolean, parseAsString, useQueryState } from 'nuqs'; import React, { type FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { useLegacyListTopicsQuery } from 'react-query/api/topic'; +import { useDeleteTopicMutation, useLegacyListTopicsQuery } from 'react-query/api/topic'; import { CreateTopicModal } from './CreateTopicModal/create-topic-modal'; import colors from '../../../colors'; @@ -412,6 +412,7 @@ function ConfirmDeletionModal({ const [error, setError] = useState(null); const toast = useToast(); const cancelRef = useRef(null); + const deleteTopic = useDeleteTopicMutation(); const cleanup = () => { setDeletionPending(false); @@ -475,8 +476,8 @@ function ConfirmDeletionModal({ onClick={() => { if (topicToDelete?.topicName) { setDeletionPending(true); - api - .deleteTopic(topicToDelete?.topicName) + deleteTopic + .mutateAsync(topicToDelete?.topicName) .then(finish) .catch((err) => { toast({ diff --git a/frontend/src/components/pages/topics/topic-produce.tsx b/frontend/src/components/pages/topics/topic-produce.tsx index 4dde0d02b4..781717b7d3 100644 --- a/frontend/src/components/pages/topics/topic-produce.tsx +++ b/frontend/src/components/pages/topics/topic-produce.tsx @@ -32,6 +32,7 @@ import { PublishMessagePayloadOptionsSchema, PublishMessageRequestSchema, } from '../../../protogen/redpanda/api/console/v1alpha1/publish_messages_pb'; +import { useTopicsQuery } from '../../../react-query/api/topic'; import { appGlobal } from '../../../state/app-global'; import { api } from '../../../state/backend-api'; import { uiState } from '../../../state/ui-state'; @@ -131,6 +132,7 @@ const persistCompressionType = (compressionType: CompressionType) => { // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: complex business logic const PublishTopicForm: FC<{ topicName: string }> = ({ topicName }) => { const toast = useToast(); + const { data: topicsData } = useTopicsQuery(); const { control, @@ -202,7 +204,7 @@ const PublishTopicForm: FC<{ topicName: string }> = ({ topicName }) => { 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 = topicsData?.topics?.first((t) => t.topicName === topicName)?.partitionCount; if (count === undefined) { // topic not found return partitions; diff --git a/frontend/src/components/pages/transforms/transform-details.tsx b/frontend/src/components/pages/transforms/transform-details.tsx index 993b86a99e..4422547e9a 100644 --- a/frontend/src/components/pages/transforms/transform-details.tsx +++ b/frontend/src/components/pages/transforms/transform-details.tsx @@ -24,9 +24,9 @@ import { PartitionTransformStatus_PartitionStatus, type TransformMetadata, } from '../../../protogen/redpanda/api/dataplane/v1/transform_pb'; +import { useTopicsQuery } from '../../../react-query/api/topic'; import { appGlobal } from '../../../state/app-global'; import { - api, createMessageSearch, type MessageSearch, type MessageSearchRequest, @@ -189,7 +189,8 @@ const OverviewTab = (p: { transform: TransformMetadata }) => { const LogsTab = (p: { transform: TransformMetadata }) => { const topicName = '_redpanda.transform_logs'; - const topic = api.topics?.first((x) => x.topicName === topicName); + const { data: topicsData } = useTopicsQuery(); + const topic = topicsData?.topics?.first((x) => x.topicName === topicName); const [logState, setLogState] = useState<{ messages: TopicMessage[]; isComplete: boolean }>({ messages: [], diff --git a/frontend/src/react-query/api/ai-gateway.tsx b/frontend/src/react-query/api/ai-gateway.tsx index e2bc15da05..e3ec8983f6 100644 --- a/frontend/src/react-query/api/ai-gateway.tsx +++ b/frontend/src/react-query/api/ai-gateway.tsx @@ -9,18 +9,18 @@ * - Prod: /.redpanda/api/redpanda.api.aigateway.v1.* handled by backend proxy */ -import { create } from '@bufbuild/protobuf'; -import type { GenMessage } from '@bufbuild/protobuf/codegenv1'; -import type { ConnectError } from '@connectrpc/connect'; -import { useQuery } from '@connectrpc/connect-query'; -import type { UseQueryResult } from '@tanstack/react-query'; -import { useAIGatewayTransport } from 'hooks/use-ai-gateway-transport'; import { type ListGatewaysRequest, ListGatewaysRequestSchema, type ListGatewaysResponse, } from '@buf/redpandadata_ai-gateway.bufbuild_es/redpanda/api/aigateway/v1/gateway_pb'; import { listGateways } from '@buf/redpandadata_ai-gateway.connectrpc_query-es/redpanda/api/aigateway/v1/gateway-GatewayService_connectquery'; +import { create } from '@bufbuild/protobuf'; +import type { GenMessage } from '@bufbuild/protobuf/codegenv1'; +import type { ConnectError } from '@connectrpc/connect'; +import { useQuery } from '@connectrpc/connect-query'; +import type { UseQueryResult } from '@tanstack/react-query'; +import { useAIGatewayTransport } from 'hooks/use-ai-gateway-transport'; import type { MessageInit, QueryOptions } from 'react-query/react-query.utils'; const AI_GATEWAY_DEFAULT_PAGE_SIZE = 50; diff --git a/frontend/src/react-query/api/ai-gateway/model-providers.tsx b/frontend/src/react-query/api/ai-gateway/model-providers.tsx index 0121b4837f..dbafa2744a 100644 --- a/frontend/src/react-query/api/ai-gateway/model-providers.tsx +++ b/frontend/src/react-query/api/ai-gateway/model-providers.tsx @@ -5,18 +5,18 @@ * Use `useAIGatewayTransport()` hook to create the transport that points to /.redpanda/api/ */ -import { create } from '@bufbuild/protobuf'; -import type { GenMessage } from '@bufbuild/protobuf/codegenv1'; -import type { ConnectError } from '@connectrpc/connect'; -import { useQuery } from '@connectrpc/connect-query'; -import type { UseQueryResult } from '@tanstack/react-query'; -import { useAIGatewayTransport } from 'hooks/use-ai-gateway-transport'; import { type ListModelProvidersRequest, ListModelProvidersRequestSchema, type ListModelProvidersResponse, } from '@buf/redpandadata_ai-gateway.bufbuild_es/redpanda/api/aigateway/v1/model_providers_pb'; import { listModelProviders } from '@buf/redpandadata_ai-gateway.connectrpc_query-es/redpanda/api/aigateway/v1/model_providers-ModelProvidersService_connectquery'; +import { create } from '@bufbuild/protobuf'; +import type { GenMessage } from '@bufbuild/protobuf/codegenv1'; +import type { ConnectError } from '@connectrpc/connect'; +import { useQuery } from '@connectrpc/connect-query'; +import type { UseQueryResult } from '@tanstack/react-query'; +import { useAIGatewayTransport } from 'hooks/use-ai-gateway-transport'; import type { MessageInit, QueryOptions } from 'react-query/react-query.utils'; const AI_GATEWAY_DEFAULT_PAGE_SIZE = 50; diff --git a/frontend/src/react-query/api/ai-gateway/models.tsx b/frontend/src/react-query/api/ai-gateway/models.tsx index fc864fef08..5e4dce9f5b 100644 --- a/frontend/src/react-query/api/ai-gateway/models.tsx +++ b/frontend/src/react-query/api/ai-gateway/models.tsx @@ -5,18 +5,18 @@ * Use `useAIGatewayTransport()` hook to create the transport that points to /.redpanda/api/ */ -import { create } from '@bufbuild/protobuf'; -import type { GenMessage } from '@bufbuild/protobuf/codegenv1'; -import type { ConnectError } from '@connectrpc/connect'; -import { useQuery } from '@connectrpc/connect-query'; -import type { UseQueryResult } from '@tanstack/react-query'; -import { useAIGatewayTransport } from 'hooks/use-ai-gateway-transport'; import { type ListModelsRequest, ListModelsRequestSchema, type ListModelsResponse, } from '@buf/redpandadata_ai-gateway.bufbuild_es/redpanda/api/aigateway/v1/models_pb'; import { listModels } from '@buf/redpandadata_ai-gateway.connectrpc_query-es/redpanda/api/aigateway/v1/models-ModelsService_connectquery'; +import { create } from '@bufbuild/protobuf'; +import type { GenMessage } from '@bufbuild/protobuf/codegenv1'; +import type { ConnectError } from '@connectrpc/connect'; +import { useQuery } from '@connectrpc/connect-query'; +import type { UseQueryResult } from '@tanstack/react-query'; +import { useAIGatewayTransport } from 'hooks/use-ai-gateway-transport'; import type { MessageInit, QueryOptions } from 'react-query/react-query.utils'; const AI_GATEWAY_DEFAULT_PAGE_SIZE = 50; diff --git a/frontend/src/react-query/api/topic.tsx b/frontend/src/react-query/api/topic.tsx index 1574521573..a335bdab3d 100644 --- a/frontend/src/react-query/api/topic.tsx +++ b/frontend/src/react-query/api/topic.tsx @@ -15,11 +15,26 @@ import { } from 'protogen/redpanda/api/dataplane/v1/topic_pb'; import { createTopic, listTopics } from 'protogen/redpanda/api/dataplane/v1/topic-TopicService_connectquery'; import { MAX_PAGE_SIZE, type MessageInit, type QueryOptions } from 'react-query/react-query.utils'; -import type { GetTopicsResponse, TopicDescription } from 'state/rest-interfaces'; +import { aclRequestToQuery } from 'state/backend-api'; +import { + AclRequestDefault, + type DeleteRecordsResponseData, + type GetAclOverviewResponse, + type GetAllPartitionsResponse, + type GetPartitionsResponse, + type GetTopicConsumersResponse, + type GetTopicOffsetsByTimestampResponse, + type GetTopicsResponse, + type Partition, + type TopicConfigResponse, + type TopicConsumer, + type TopicDescription, + type TopicDocumentation, + type TopicDocumentationResponse, + type TopicOffset, +} from 'state/rest-interfaces'; import { formatToastErrorMessageGRPC } from 'utils/toast.utils'; -import { api } from '../../state/backend-api'; - type ListTopicsExtraOptions = { hideInternalTopics?: boolean; }; @@ -52,7 +67,6 @@ export const useLegacyListTopicsQuery = ( const legacyListTopicsResult = useTanstackQuery({ queryKey: infiniteQueryKey, queryFn: async () => { - // Add JWT Bearer token if available (same as REST and gRPC calls) const headers: HeadersInit = {}; if (config.jwt) { headers.Authorization = `Bearer ${config.jwt}`; @@ -78,6 +92,28 @@ export const useLegacyListTopicsQuery = ( return { ...legacyListTopicsResult, data: { topics } }; }; +/** + * Hook to fetch the full list of topics. + * Replaces api.refreshTopics + api.topics. + */ +export const useTopicsQuery = (options?: { staleTime?: number; refetchOnWindowFocus?: boolean }) => + useTanstackQuery({ + queryKey: ['topics'], + queryFn: async () => { + const headers: HeadersInit = {}; + if (config.jwt) { + headers.Authorization = `Bearer ${config.jwt}`; + } + const response = await config.fetch(`${config.restBasePath}/topics`, { + method: 'GET', + headers, + }); + return response.json(); + }, + staleTime: options?.staleTime ?? 20 * 1000, + refetchOnWindowFocus: options?.refetchOnWindowFocus, + }); + /** * WARNING: Only use once Console v3 is released. */ @@ -116,7 +152,7 @@ export const useCreateTopicMutation = () => { return useMutation(createTopic, { onSuccess: async () => { await Promise.all([ - api.refreshTopics(true), + queryClient.invalidateQueries({ queryKey: ['topics'] }), queryClient.invalidateQueries({ queryKey: createConnectQueryKey({ schema: TopicService.method.listTopics, @@ -135,30 +171,49 @@ export const useCreateTopicMutation = () => { }); }; +function prepareSynonymsLocal(configEntries: { synonyms?: Array<{ type?: string }>; type?: string }[]) { + if (!Array.isArray(configEntries)) return; + for (const e of configEntries) { + if (e.synonyms === undefined) continue; + for (const s of e.synonyms) { + s.type = e.type; + } + } +} + /** - * React Query hook to fetch topic configuration + * React Query hook to fetch topic configuration. + * Replaces api.refreshTopicConfig + api.topicConfig. */ -export const useTopicConfigQuery = (topicName: string, enabled = true) => { - return useTanstackQuery({ +export const useTopicConfigQuery = (topicName: string, enabled = true) => + useTanstackQuery({ queryKey: ['topicConfig', topicName], queryFn: async () => { - await api.refreshTopicConfig(topicName, true); - return api.topicConfig.get(topicName) || null; + const headers: HeadersInit = {}; + if (config.jwt) { + headers.Authorization = `Bearer ${config.jwt}`; + } + const response = await config.fetch( + `${config.restBasePath}/topics/${encodeURIComponent(topicName)}/configuration`, + { method: 'GET', headers } + ); + const v: TopicConfigResponse | null = await response.json(); + if (!v) return null; + if (v.topicDescription.error) return v.topicDescription; + prepareSynonymsLocal(v.topicDescription.configEntries as Parameters[0]); + return v.topicDescription; }, enabled: enabled && !!topicName, - staleTime: 30 * 1000, // 30 seconds + staleTime: 30 * 1000, retry: (failureCount, error) => { - // Don't retry on authorization errors - if (error && typeof error === 'object' && 'statusText' in error) { - return false; - } + if (error && typeof error === 'object' && 'statusText' in error) return false; return failureCount < 2; }, }); -}; /** - * Hook to manage topic configuration updates with React Query + * Hook to manage topic configuration updates with React Query. + * Replaces api.changeTopicConfig. */ export const useUpdateTopicConfigMutation = () => { const queryClient = useQueryClient(); @@ -171,14 +226,306 @@ export const useUpdateTopicConfigMutation = () => { topicName: string; configs: Array<{ key: string; op: 'SET' | 'DELETE'; value?: string }>; }) => { - await api.changeTopicConfig(topicName, configs); + const headers: HeadersInit = { 'Content-Type': 'application/json' }; + if (config.jwt) { + headers.Authorization = `Bearer ${config.jwt}`; + } + const response = await config.fetch( + `${config.restBasePath}/topics/${encodeURIComponent(topicName)}/configuration`, + { + method: 'PATCH', + headers, + body: JSON.stringify({ configs }), + } + ); + if (!response.ok) { + throw new Error(`Failed to update config: ${response.statusText}`); + } return { topicName, configs }; }, onSuccess: (data) => { - // Invalidate the specific topic config to refetch fresh data - queryClient.invalidateQueries({ - queryKey: ['topicConfig', data.topicName], + queryClient.invalidateQueries({ queryKey: ['topicConfig', data.topicName] }); + }, + }); +}; + +/** + * Hook to fetch topic documentation. + * Replaces api.refreshTopicDocumentation + api.topicDocumentation. + */ +export const useTopicDocumentationQuery = (topicName: string, enabled = true) => + useTanstackQuery({ + queryKey: ['topicDocumentation', topicName], + queryFn: async () => { + const headers: HeadersInit = {}; + if (config.jwt) { + headers.Authorization = `Bearer ${config.jwt}`; + } + const response = await config.fetch( + `${config.restBasePath}/topics/${encodeURIComponent(topicName)}/documentation`, + { method: 'GET', headers } + ); + const v: TopicDocumentationResponse = await response.json(); + const markdown = v.documentation.markdown === null ? null : decodeBase64(v.documentation.markdown); + return { ...v.documentation, text: markdown }; + }, + enabled: enabled && !!topicName, + staleTime: 60 * 1000, + }); + +function decodeBase64(base64: string): string { + return decodeURIComponent( + Array.from(atob(base64), (c) => `%${c.charCodeAt(0).toString(16).padStart(2, '0')}`).join('') + ); +} + +/** + * Hook to fetch topic consumers. + * Replaces api.refreshTopicConsumers + api.topicConsumers. + */ +export const useTopicConsumersQuery = (topicName: string, enabled = true) => + useTanstackQuery({ + queryKey: ['topicConsumers', topicName], + queryFn: async () => { + const headers: HeadersInit = {}; + if (config.jwt) { + headers.Authorization = `Bearer ${config.jwt}`; + } + const response = await config.fetch(`${config.restBasePath}/topics/${encodeURIComponent(topicName)}/consumers`, { + method: 'GET', + headers, + }); + const v: GetTopicConsumersResponse = await response.json(); + return v.topicConsumers; + }, + enabled: enabled && !!topicName, + staleTime: 20 * 1000, + }); + +function normalizeAclsLocal(acls: GetAclOverviewResponse['aclResources']) { + function upperFirst(str: string) { + if (!str) return str; + return str[0].toUpperCase() + str.slice(1).toLowerCase(); + } + const specialCaseMap: Record = { TRANSACTIONAL_ID: 'TransactionalID' }; + function normalizeEnum(str: T): T { + if (!str) return str; + if (specialCaseMap[str]) return specialCaseMap[str] as T; + return str.split('_').map(upperFirst).join('') as T; + } + for (const e of acls) { + e.resourceType = normalizeEnum(e.resourceType); + e.resourcePatternType = normalizeEnum(e.resourcePatternType); + for (const acl of e.acls) { + acl.operation = normalizeEnum(acl.operation); + acl.permissionType = normalizeEnum(acl.permissionType); + } + } +} + +/** + * Hook to fetch ACLs for a topic. + * Replaces api.refreshTopicAcls + api.topicAcls. + */ +export const useTopicAclsQuery = (topicName: string, enabled = true) => + useTanstackQuery({ + queryKey: ['topicAcls', topicName], + queryFn: async () => { + const query = aclRequestToQuery({ + ...AclRequestDefault, + resourcePatternTypeFilter: 'Match', + resourceType: 'Topic', + resourceName: topicName, + }); + const headers: HeadersInit = {}; + if (config.jwt) { + headers.Authorization = `Bearer ${config.jwt}`; + } + const response = await config.fetch(`${config.restBasePath}/acls?${query}`, { + method: 'GET', + headers, + }); + const v: GetAclOverviewResponse | null = await response.json(); + if (v) normalizeAclsLocal(v.aclResources); + return v; + }, + enabled: enabled && !!topicName, + staleTime: 20 * 1000, + }); + +export type TopicPartitionsResult = { + partitions: Partition[] | null; + partitionErrors: Array<{ id: number; partitionError: string }>; + waterMarkErrors: Array<{ id: number; waterMarksError: string }>; +}; + +function processPartitions(partitions: Partition[], topicName: string): TopicPartitionsResult { + const partitionErrors: Array<{ id: number; partitionError: string }> = []; + const waterMarkErrors: Array<{ id: number; waterMarksError: string }> = []; + + for (const p of partitions) { + p.topicName = topicName; + if (p.partitionError) partitionErrors.push({ id: p.id, partitionError: p.partitionError }); + if (p.waterMarksError) waterMarkErrors.push({ id: p.id, waterMarksError: p.waterMarksError }); + } + + for (const p of partitions) { + if (p.partitionError || p.waterMarksError) { + p.hasErrors = true; + } else { + const validLogDirs = p.partitionLogDirs.filter((e) => (e.error === null || e.error === '') && e.size >= 0); + const replicaSize = validLogDirs.length > 0 ? validLogDirs.max((e) => e.size) : 0; + p.replicaSize = replicaSize >= 0 ? replicaSize : 0; + } + } + + return { partitions, partitionErrors, waterMarkErrors }; +} + +/** + * Hook to fetch partitions for a single topic. + * Replaces api.refreshPartitionsForTopic + api.topicPartitions. + */ +export const useTopicPartitionsQuery = (topicName: string, enabled = true) => + useTanstackQuery({ + queryKey: ['topicPartitions', topicName], + queryFn: async () => { + const headers: HeadersInit = {}; + if (config.jwt) { + headers.Authorization = `Bearer ${config.jwt}`; + } + const response = await config.fetch(`${config.restBasePath}/topics/${encodeURIComponent(topicName)}/partitions`, { + method: 'GET', + headers, + }); + const data: GetPartitionsResponse | null = await response.json(); + if (!data?.partitions) { + return { partitions: null, partitionErrors: [], waterMarkErrors: [] }; + } + return processPartitions(data.partitions, topicName); + }, + enabled: enabled && !!topicName, + staleTime: 20 * 1000, + }); + +/** + * Hook to fetch partitions for all topics (or a specific set). + * Replaces api.refreshPartitions + api.topicPartitions (full map). + */ +export const useAllTopicPartitionsQuery = ( + topics: 'all' | string[] = 'all', + options?: { enabled?: boolean; staleTime?: number } +) => { + const queryKey = topics === 'all' ? ['topicPartitionsAll'] : ['topicPartitionsAll', ...topics.slice().sort()]; + + return useTanstackQuery>({ + queryKey, + queryFn: async () => { + const processedTopics = Array.isArray(topics) + ? topics + .slice() + .sort() + .map((t) => encodeURIComponent(t)) + : topics; + const url = + processedTopics === 'all' + ? `${config.restBasePath}/operations/topic-details` + : `${config.restBasePath}/operations/topic-details?topicNames=${processedTopics.join(',')}`; + + const headers: HeadersInit = {}; + if (config.jwt) { + headers.Authorization = `Bearer ${config.jwt}`; + } + const response = await config.fetch(url, { method: 'GET', headers }); + const data: GetAllPartitionsResponse | null = await response.json(); + + const result = new Map(); + if (!data?.topics) return result; + + for (const t of data.topics) { + if (t.error !== null && t.error !== undefined) continue; + result.set(t.topicName, processPartitions(t.partitions, t.topicName).partitions); + } + return result; + }, + enabled: options?.enabled !== false, + staleTime: options?.staleTime ?? 20 * 1000, + }); +}; + +/** + * Hook to delete a topic. + * Replaces api.deleteTopic. + */ +export const useDeleteTopicMutation = () => { + const queryClient = useQueryClient(); + + return useTanstackMutation({ + mutationFn: async (topicName: string) => { + const headers: HeadersInit = {}; + if (config.jwt) { + headers.Authorization = `Bearer ${config.jwt}`; + } + const response = await config.fetch(`${config.restBasePath}/topics/${encodeURIComponent(topicName)}`, { + method: 'DELETE', + headers, }); + if (!response.ok) { + throw new Error(`Failed to delete topic: ${response.statusText}`); + } + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['topics'] }); }, }); }; + +/** + * Hook to delete records from topic partitions. + * Replaces api.deleteTopicRecordsFromMultiplePartitionOffsetPairs. + */ +export const useDeleteTopicRecordsMutation = () => + useTanstackMutation({ + mutationFn: async ({ + topicName, + pairs, + }: { + topicName: string; + pairs: Array<{ partitionId: number; offset: number }>; + }) => { + const headers: HeadersInit = { 'Content-Type': 'application/json' }; + if (config.jwt) { + headers.Authorization = `Bearer ${config.jwt}`; + } + const response = await config.fetch(`${config.restBasePath}/topics/${encodeURIComponent(topicName)}/records`, { + method: 'DELETE', + headers, + body: JSON.stringify({ partitions: pairs }), + }); + if (!response.ok) { + throw new Error(`Failed to delete records: ${response.statusText}`); + } + return response.json() as Promise; + }, + }); + +/** + * Standalone function to get topic offsets by timestamp. + * Replaces api.getTopicOffsetsByTimestamp. + */ +export async function getTopicOffsetsByTimestamp( + topicNames: string[], + timestampUnixMs: number +): Promise { + const query = `topicNames=${encodeURIComponent(topicNames.join(','))}×tamp=${timestampUnixMs}`; + const headers: HeadersInit = {}; + if (config.jwt) { + headers.Authorization = `Bearer ${config.jwt}`; + } + const response = await config.fetch(`${config.restBasePath}/topics-offsets?${query}`, { + method: 'GET', + headers, + }); + const r: GetTopicOffsetsByTimestampResponse = await response.json(); + return r.topicOffsets; +} diff --git a/frontend/src/state/backend-api.ts b/frontend/src/state/backend-api.ts index 88e5a98fc8..0d3ad637ba 100644 --- a/frontend/src/state/backend-api.ts +++ b/frontend/src/state/backend-api.ts @@ -57,7 +57,6 @@ import { type DeleteConsumerGroupOffsetsResponse, type DeleteConsumerGroupOffsetsResponseTopic, type DeleteConsumerGroupOffsetsTopic, - type DeleteRecordsResponseData, type EditConsumerGroupOffsetsRequest, type EditConsumerGroupOffsetsResponse, type EditConsumerGroupOffsetsResponseTopic, @@ -66,25 +65,18 @@ import { type EndpointCompatibilityResponse, type GetAclOverviewResponse, type GetAclsRequest, - type GetAllPartitionsResponse, type GetConsumerGroupResponse, type GetConsumerGroupsResponse, - type GetPartitionsResponse, - type GetTopicConsumersResponse, - type GetTopicOffsetsByTimestampResponse, - type GetTopicsResponse, type GetUsersResponse, type GroupDescription, isApiError, type KafkaConnectors, type PartialTopicConfigsResponse, - type Partition, type PartitionReassignmentRequest, type PartitionReassignments, type PartitionReassignmentsResponse, type PatchConfigsRequest, type PatchConfigsResponse, - type PatchTopicConfigsRequest, type ProduceRecordsResponse, type PublishRecordsRequest, type QuotaResponse, @@ -103,15 +95,7 @@ import { type SchemaRegistrySubjectDetails, type SchemaRegistryValidateSchemaResponse, type SchemaVersion, - type Topic, - type TopicConfigResponse, - type TopicConsumer, - type TopicDescription, - type TopicDocumentation, - type TopicDocumentationResponse, type TopicMessage, - type TopicOffset, - type TopicPermissions, type UserData, WrappedApiError, } from './rest-interfaces'; @@ -169,13 +153,14 @@ import type { KnowledgeBaseCreate, KnowledgeBaseUpdate, } from '../protogen/redpanda/api/dataplane/v1alpha3/knowledge_base_pb'; +import queryClient from '../query-client'; import { getBuildDate } from '../utils/env'; import fetchWithTimeout from '../utils/fetch-with-timeout'; import { toJson } from '../utils/json-utils'; import { LazyMap } from '../utils/lazy-map'; import { convertListMessageData } from '../utils/message-converters'; import { ObjToKv } from '../utils/tsx-utils'; -import { decodeBase64, getOidcSubject, TimeSince } from '../utils/utils'; +import { getOidcSubject, TimeSince } from '../utils/utils'; const REST_TIMEOUT_SEC = 25; export const REST_CACHE_DURATION_SEC = 20; @@ -440,16 +425,6 @@ const _apiCreator = (set: any, get: any) => ({ schemaReferencedBy: new Map>(), // subjectName => version => details schemaUsagesById: new Map(), - topics: null as Topic[] | null, - topicConfig: new Map(), // null = not allowed to view config of this topic - topicDocumentation: new Map(), - topicPermissions: new Map(), - topicPartitions: new Map(), // null = not allowed to view partitions of this config - topicPartitionErrors: new Map>(), - topicWatermarksErrors: new Map>(), - topicConsumers: new Map(), - topicAcls: new Map(), - serviceAccounts: undefined as GetUsersResponse | undefined | null, serviceAccountsLoading: false, serviceAccountsError: null as WrappedApiError | null, @@ -656,345 +631,6 @@ const _apiCreator = (set: any, get: any) => ({ _msgSearchVersion: 0, - refreshTopics(force?: boolean): Promise { - return cachedApiRequest(`${appConfig.restBasePath}/topics`, force).then((v) => { - if (v?.topics !== null && v?.topics !== undefined) { - for (const t of v.topics) { - if (!t.allowedActions) { - // no op - allowedActions may not be set - } - - // DEBUG: randomly remove some allowedActions - /* - const numToRemove = Math.round(Math.random() * t.allowedActions.length); - for (let i = 0; i < numToRemove; i++) { - const randomIndex = Math.round(Math.random() * (t.allowedActions.length - 1)); - t.allowedActions.splice(randomIndex, 1); - } - */ - } - } - set({ topics: v?.topics }); - }, addError); - }, - - refreshTopicConfig(topicName: string, force?: boolean): Promise { - const promise = cachedApiRequest( - `${appConfig.restBasePath}/topics/${encodeURIComponent(topicName)}/configuration`, - force - ).then((v) => { - if (!v) { - set((s: any) => { - const m = new Map(s.topicConfig); - m.delete(topicName); - return { topicConfig: m }; - }); - return; - } - - if (v.topicDescription.error) { - set((s: any) => ({ topicConfig: new Map(s.topicConfig).set(topicName, v.topicDescription) })); - return; - } - - // add 'type' to each synonym - // in the raw data, only the root entries have 'type', but the nested synonyms do not - // we need 'type' on synonyms as well for filtering - const topicDescription = v.topicDescription; - prepareSynonyms(topicDescription.configEntries); - set((s: any) => ({ topicConfig: new Map(s.topicConfig).set(topicName, topicDescription) })); - }, addError); // 403 -> null - return promise as Promise; - }, - - async getTopicOffsetsByTimestamp(topicNames: string[], timestampUnixMs: number): Promise { - const query = `topicNames=${encodeURIComponent(topicNames.join(','))}×tamp=${timestampUnixMs}`; - const response = await appConfig.fetch(`${appConfig.restBasePath}/topics-offsets?${query}`, { - method: 'GET', - headers: [['Content-Type', 'application/json']], - }); - - const r = await parseOrUnwrap(response, null); - return r.topicOffsets; - }, - - refreshTopicDocumentation(topicName: string, force?: boolean) { - cachedApiRequest( - `${appConfig.restBasePath}/topics/${encodeURIComponent(topicName)}/documentation`, - force - ).then((v) => { - const text = v.documentation.markdown === null ? null : decodeBase64(v.documentation.markdown); - v.documentation.text = text; - set((s: any) => ({ topicDocumentation: new Map(s.topicDocumentation).set(topicName, v.documentation) })); - }, addError); - }, - - async deleteTopic(topicName: string) { - const response = await appConfig.fetch(`${appConfig.restBasePath}/topics/${encodeURIComponent(topicName)}`, { - method: 'DELETE', - }); - return parseOrUnwrap(response, null); - }, - - deleteTopicRecords(topicName: string, offset: number, partitionId?: number) { - const partitions = - partitionId !== undefined - ? [{ partitionId, offset }] - : get() - .topicPartitions?.get(topicName) - ?.map((partition: Partition) => ({ partitionId: partition.id, offset })); - - if (!partitions || partitions.length === 0) { - addError(new Error(`Topic ${topicName} doesn't have partitions.`)); - return; - } - - return get().deleteTopicRecordsFromMultiplePartitionOffsetPairs(topicName, partitions); - }, - - deleteTopicRecordsFromAllPartitionsHighWatermark(topicName: string) { - const partitions = get() - .topicPartitions?.get(topicName) - ?.map(({ waterMarkHigh, id }: Partition) => ({ - partitionId: id, - offset: waterMarkHigh, - })); - - if (!partitions || partitions.length === 0) { - addError(new Error(`Topic ${topicName} doesn't have partitions.`)); - return; - } - - return get().deleteTopicRecordsFromMultiplePartitionOffsetPairs(topicName, partitions); - }, - - deleteTopicRecordsFromMultiplePartitionOffsetPairs( - topicName: string, - pairs: Array<{ partitionId: number; offset: number }> - ) { - return rest( - `${appConfig.restBasePath}/topics/${encodeURIComponent(topicName)}/records`, - { - method: 'DELETE', - headers: [['Content-Type', 'application/json']], - body: JSON.stringify({ partitions: pairs }), - } - ).catch(addError); - }, - - refreshPartitions(topics: 'all' | string[] = 'all', force?: boolean): Promise { - const processedTopics = Array.isArray(topics) ? topics.sort().map((t) => encodeURIComponent(t)) : topics; - - const url = - processedTopics === 'all' - ? `${appConfig.restBasePath}/operations/topic-details` - : `${appConfig.restBasePath}/operations/topic-details?topicNames=${processedTopics.joinStr(',')}`; - - return cachedApiRequest(url, force).then((response) => { - if (!response?.topics) { - return; - } - // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: complexity 42, refactor later - { - const errors: { - topicName: string; - partitionErrors: { partitionId: number; error: string }[]; - waterMarkErrors: { partitionId: number; error: string }[]; - }[] = []; - - const newTopicPartitions = new Map(get().topicPartitions); - - for (const t of response.topics) { - if (t.error !== null && t.error !== undefined) { - // biome-ignore lint/suspicious/noConsole: intentional console usage - console.error(`refreshAllTopicPartitions: error for topic ${t.topicName}: ${t.error}`); - continue; - } - - // If any partition has any errors, don't set the result for that topic - const partitionErrors: Array<{ partitionId: number; error: string }> = []; - const waterMarkErrors: Array<{ partitionId: number; error: string }> = []; - for (const p of t.partitions) { - // topicName - p.topicName = t.topicName; - - let partitionHasError = false; - if (p.partitionError) { - partitionErrors.push({ - partitionId: p.id, - error: p.partitionError, - }); - partitionHasError = true; - } - if (p.waterMarksError) { - waterMarkErrors.push({ - partitionId: p.id, - error: p.waterMarksError, - }); - partitionHasError = true; - } - if (partitionHasError) { - p.hasErrors = true; - continue; - } - - // Add some local/cached properties to make working with the data easier - const validLogDirs = p.partitionLogDirs.filter((e) => !e.error && e.size >= 0); - const replicaSize = validLogDirs.length > 0 ? validLogDirs.max((e) => e.size) : 0; - p.replicaSize = replicaSize >= 0 ? replicaSize : 0; - } - - // Set partition - newTopicPartitions.set(t.topicName, t.partitions); - - if (partitionErrors.length === 0 && waterMarkErrors.length === 0) { - // no op - no errors to track - } else { - errors.push({ - topicName: t.topicName, - partitionErrors, - waterMarkErrors, - }); - } - } - - set({ topicPartitions: newTopicPartitions }); - } - }, addError); - }, - - refreshPartitionsForTopic(topicName: string, force?: boolean) { - cachedApiRequest( - `${appConfig.restBasePath}/topics/${encodeURIComponent(topicName)}/partitions`, - force - ) - // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: complexity 46, refactor later - .then((response) => { - if (response?.partitions) { - const partitionErrors: Array<{ id: number; partitionError: string }> = []; - const waterMarksErrors: Array<{ id: number; waterMarksError: string }> = []; - - // Add some local/cached properties to make working with the data easier - for (const p of response.partitions) { - // topicName - p.topicName = topicName; - - if (p.partitionError) { - partitionErrors.push({ - id: p.id, - partitionError: p.partitionError, - }); - } - if (p.waterMarksError) { - waterMarksErrors.push({ - id: p.id, - waterMarksError: p.waterMarksError, - }); - } - if (partitionErrors.length || waterMarksErrors.length) { - continue; - } - - // replicaSize - const validLogDirs = p.partitionLogDirs.filter((e) => (e.error === null || e.error === '') && e.size >= 0); - const replicaSize = validLogDirs.length > 0 ? validLogDirs.max((e) => e.size) : 0; - p.replicaSize = replicaSize >= 0 ? replicaSize : 0; - } - - if (partitionErrors.length === 0 && waterMarksErrors.length === 0) { - // Set partitions - set((s: any) => { - const tpe = new Map(s.topicPartitionErrors); - tpe.delete(topicName); - const twe = new Map(s.topicWatermarksErrors); - twe.delete(topicName); - return { - topicPartitionErrors: tpe, - topicWatermarksErrors: twe, - topicPartitions: new Map(s.topicPartitions).set(topicName, response.partitions), - }; - }); - } else { - set((s: any) => ({ - topicPartitionErrors: new Map(s.topicPartitionErrors).set(topicName, partitionErrors), - topicWatermarksErrors: new Map(s.topicWatermarksErrors).set(topicName, waterMarksErrors), - })); - // biome-ignore lint/suspicious/noConsole: intentional console usage - console.error( - `refreshPartitionsForTopic: response has partition errors (t=${topicName} p=${partitionErrors.length}, w=${waterMarksErrors.length})` - ); - } - } else { - // Set null to indicate that we're not allowed to see the partitions - set((s: any) => ({ topicPartitions: new Map(s.topicPartitions).set(topicName, null) })); - return; - } - - let partitionErrors = 0; - let waterMarkErrors = 0; - - // Add some local/cached properties to make working with the data easier - for (const p of response.partitions) { - // topicName - p.topicName = topicName; - - if (p.partitionError) { - partitionErrors += 1; - } - if (p.waterMarksError) { - waterMarkErrors += 1; - } - if (partitionErrors || waterMarkErrors) { - p.hasErrors = true; - continue; - } - - // replicaSize - const validLogDirs = p.partitionLogDirs.filter((e) => (e.error === null || e.error === '') && e.size >= 0); - const replicaSize = validLogDirs.length > 0 ? validLogDirs.max((e) => e.size) : 0; - p.replicaSize = replicaSize >= 0 ? replicaSize : 0; - } - - // Set partitions - set((s: any) => ({ topicPartitions: new Map(s.topicPartitions).set(topicName, response.partitions) })); - - if (partitionErrors > 0 || waterMarkErrors > 0) { - // biome-ignore lint/suspicious/noConsole: intentional console usage - console.warn( - `refreshPartitionsForTopic: response has partition errors (topic=${topicName} partitionErrors=${partitionErrors}, waterMarkErrors=${waterMarkErrors})` - ); - } - }, addError); - }, - - refreshTopicAcls(topicName: string, force?: boolean) { - const query = aclRequestToQuery({ - ...AclRequestDefault, - resourcePatternTypeFilter: 'Match', - resourceType: 'Topic', - resourceName: topicName, - }); - cachedApiRequest(`${appConfig.restBasePath}/acls?${query}`, force) - .then((v) => { - if (v) { - normalizeAcls(v.aclResources); - } - set((s: any) => ({ topicAcls: new Map(s.topicAcls).set(topicName, v) })); - }) - // biome-ignore lint/suspicious/noConsole: intentional console usage - .catch(console.error); - }, - - refreshTopicConsumers(topicName: string, force?: boolean) { - cachedApiRequest( - `${appConfig.restBasePath}/topics/${encodeURIComponent(topicName)}/consumers`, - force - ).then( - (v) => set((s: any) => ({ topicConsumers: new Map(s.topicConsumers).set(topicName, v.topicConsumers) })), - addError - ); - }, - async refreshAcls(request: GetAclsRequest, force?: boolean): Promise { const query = aclRequestToQuery(request); await cachedApiRequest(`${appConfig.restBasePath}/acls?${query}`, force).then( @@ -1737,21 +1373,6 @@ const _apiCreator = (set: any, get: any) => ({ ); }, - // PATCH /topics/{topicName}/configuration // - // PATCH /topics/configuration // default config - async changeTopicConfig(topicName: string | null, configs: PatchTopicConfigsRequest['configs']): Promise { - const url = topicName - ? `${appConfig.restBasePath}/topics/${encodeURIComponent(topicName)}/configuration` - : `${appConfig.restBasePath}/topics/configuration`; - - const response = await appConfig.fetch(url, { - method: 'PATCH', - headers: [['Content-Type', 'application/json']], - body: toJson({ configs }), - }); - await parseOrUnwrap(response, null); - }, - // AdditionalInfo = list of plugins refreshClusterAdditionalInfo(clusterName: string, force?: boolean): Promise { return cachedApiRequest( @@ -3151,7 +2772,8 @@ export const api = new Proxy({} as apiStoreType, { return s.clusterOverview?.redpanda !== null; case 'getTopicPartitionArray': { const result: string[] = []; - s.topicPartitions.forEach((partitions: any, topicName: string) => { + const topicPartitionsAll = queryClient.getQueryData>(['topicPartitionsAll']); + topicPartitionsAll?.forEach((partitions, topicName) => { if (partitions !== null) { for (const partition of partitions) { result.push(`${topicName}/${partition.id}`); From a48084a9831d0f0fbcb4e337ef341e3fb94f3228 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A1n=20Vor=C4=8D=C3=A1k?= Date: Mon, 23 Mar 2026 14:23:48 +0100 Subject: [PATCH 2/4] Fixes integration test --- .../delete-records-modal.test.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/pages/topics/DeleteRecordsModal/delete-records-modal.test.tsx b/frontend/src/components/pages/topics/DeleteRecordsModal/delete-records-modal.test.tsx index ae40da5c79..d87a36cf18 100644 --- a/frontend/src/components/pages/topics/DeleteRecordsModal/delete-records-modal.test.tsx +++ b/frontend/src/components/pages/topics/DeleteRecordsModal/delete-records-modal.test.tsx @@ -9,11 +9,14 @@ * by the Apache License, Version 2.0 */ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { render, screen } from '@testing-library/react'; import DeleteRecordsModal from './delete-records-modal'; import type { Topic } from '../../../../state/rest-interfaces'; +const queryClient = new QueryClient(); + const testTopic: Topic = { allowedActions: ['all'], cleanupPolicy: 'compact', @@ -32,7 +35,15 @@ const testTopic: Topic = { describe('DeleteRecordsModal', () => { test('renders all expected elements in step 1', () => { render( - + + + ); expect(screen.getByText('Delete records in topic')).toBeInTheDocument(); From dfb55a3bb092ec4202e7ccdb117be9f9e4072108 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A1n=20Vor=C4=8D=C3=A1k?= Date: Tue, 24 Mar 2026 15:39:00 +0100 Subject: [PATCH 3/4] remove quotas from backend-api --- frontend/src/state/backend-api.ts | 9 -------- frontend/src/state/rest-interfaces.ts | 33 --------------------------- 2 files changed, 42 deletions(-) diff --git a/frontend/src/state/backend-api.ts b/frontend/src/state/backend-api.ts index eb71d415ba..d07d8d07b6 100644 --- a/frontend/src/state/backend-api.ts +++ b/frontend/src/state/backend-api.ts @@ -79,7 +79,6 @@ import { type PatchConfigsResponse, type ProduceRecordsResponse, type PublishRecordsRequest, - type QuotaResponse, type ResourceConfig, type SchemaReferencedByEntry, type SchemaRegistryCompatibilityMode, @@ -432,8 +431,6 @@ const _apiCreator = (set: any, get: any) => ({ ACLs: undefined as GetAclOverviewResponse | undefined | null, - Quotas: undefined as QuotaResponse | undefined | null, - consumerGroups: new Map(), consumerGroupAcls: new Map(), @@ -647,12 +644,6 @@ const _apiCreator = (set: any, get: any) => ({ ); }, - refreshQuotas(force?: boolean) { - cachedApiRequest(`${appConfig.restBasePath}/quotas`, force).then((v) => { - set({ Quotas: v ?? null }); - }, addError); - }, - async refreshSupportedEndpoints(): Promise { try { const r = await rest(`${appConfig.restBasePath}/console/endpoints`); diff --git a/frontend/src/state/rest-interfaces.ts b/frontend/src/state/rest-interfaces.ts index 610c2d40b9..4ee96308b4 100644 --- a/frontend/src/state/rest-interfaces.ts +++ b/frontend/src/state/rest-interfaces.ts @@ -830,39 +830,6 @@ export type DeleteACLsRequest = { permissionType: AclStrPermission; }; -export type QuotaResponse = { - error?: string; - items: QuotaResponseItem[]; -}; - -export type QuotaResponseItem = { - entityType: 'client-id' | 'user' | 'ip'; - entityName?: string; - settings: QuotaResponseSetting[]; -}; - -export const QuotaType = { - // A rate representing the upper bound (bytes/sec) for producer traffic - PRODUCER_BYTE_RATE: 'producer_byte_rate', - // A rate representing the upper bound (bytes/sec) for consumer traffic. - CONSUMER_BYTE_RATE: 'consumer_byte_rate', - // A percentage representing the upper bound of time spent for processing requests. - REQUEST_PERCENTAGE: 'request_percentage', - // The rate at which mutations are accepted for the create "topics request, - // the create partitions request and the delete topics request. The rate is accumulated by - // the number of partitions created or deleted. - CONTROLLER_MUTATION_RATE: 'controller_mutation_rate', - // An int representing the upper bound of connections accepted for the specified IP. - CONNECTION_CREATION_RATE: 'connection_creation_rate', -} as const; - -export type QuotaTypeType = (typeof QuotaType)[keyof typeof QuotaType]; - -export type QuotaResponseSetting = { - key: QuotaTypeType; - value: number; -}; - export const SchemaType = { AVRO: 'AVRO', JSON: 'JSON', From 4ee14d2bebf78c120cdbf7472f235f8150cfddaa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A1n=20Vor=C4=8D=C3=A1k?= Date: Fri, 27 Mar 2026 17:17:13 +0100 Subject: [PATCH 4/4] Fixes permission check in topic configuration --- frontend/src/components/pages/topics/tab-config.tsx | 1 + .../src/components/pages/topics/topic-configuration.tsx | 7 ++++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/pages/topics/tab-config.tsx b/frontend/src/components/pages/topics/tab-config.tsx index b68a19e92e..e81043fe1e 100644 --- a/frontend/src/components/pages/topics/tab-config.tsx +++ b/frontend/src/components/pages/topics/tab-config.tsx @@ -42,6 +42,7 @@ export function TopicConfiguration(props: { topic: Topic }) { return ( { queryClient.invalidateQueries({ queryKey: ['topicConfig', props.topic.topicName] }); diff --git a/frontend/src/components/pages/topics/topic-configuration.tsx b/frontend/src/components/pages/topics/topic-configuration.tsx index 234e6627e9..44290d518c 100644 --- a/frontend/src/components/pages/topics/topic-configuration.tsx +++ b/frontend/src/components/pages/topics/topic-configuration.tsx @@ -39,12 +39,14 @@ import './TopicConfiguration.scss'; import { isServerless } from '../../../config'; import { useUpdateTopicConfigMutation } from '../../../react-query/api/topic'; +import type { TopicAction } from '../../../state/rest-interfaces'; import { SingleSelect } from '../../misc/select'; type ConfigurationEditorProps = { targetTopic: string; // topic name, or null if default configs entries: ConfigEntryExtended[]; onForceRefresh: () => void; + allowedActions?: TopicAction[]; // undefined means all actions allowed }; type Inputs = { @@ -234,7 +236,10 @@ const ConfigurationEditor: FC = (props) => { setEditedEntry(configEntry); }; - const hasEditPermissions = true; + const hasEditPermissions = + props.allowedActions === undefined || + props.allowedActions.includes('editConfig') || + props.allowedActions.includes('all'); let entries = props.entries; if (filter) {