From 711ea33275d7e7e2180fcf98e7a17d9d0059fe8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A1n=20Vor=C4=8D=C3=A1k?= Date: Fri, 31 Jul 2026 13:24:31 +0200 Subject: [PATCH 1/6] feat: new topic messages page behind enableNewTopicMessagesPage flag Redesigned messages UX (from the Console Messages UX prototype): - Read scope popover (newest/oldest/offset/timestamp) with live tail in the menu, continuous pagination for newest/oldest only, and an illustrated "how reading starts" doc sheet - Filter bar with typed tokens (partition:1, offset>5, key:abc), autocomplete with value suggestions and ghost completion, keyboard chip editing (ArrowLeft unwraps a badge into editable text, Enter recommits in place), and JS predicate filters with live preview - Field filters, partition, quick search and selected message persist in the URL; JS filters stay in sessionStorage - Message detail as docked resizable panel or expanded sheet, with persisted view state (mode, widths, section expansion) and a metadata table, headers grid and troubleshoot reports - View settings sidebar: row density, drag-reorderable columns with per-column config (timestamp format, deserializers, preview fields) - Quick info stats rebuilt on the registry Stat component Fixes surfaced while building it: - assignDeep now replaces arrays wholesale; the index-wise merge mutated shared elements through the uiSettings sync and corrupted reordered arrays (duplicate/lost message columns after drag'n'drop); getMessageColumns heals already-corrupted persisted entries - SidebarInset gets min-w-0 so wide content (messages table) scrolls inside its container instead of widening the page past the viewport --- frontend/bun.lock | 32 +- frontend/src/app.tsx | 11 +- frontend/src/components/constants.ts | 1 + .../pages/topics/Tab.Messages/index.tsx | 28 +- .../message-display/payload-component.tsx | 13 +- .../topics/Tab.Messages/preview-settings.tsx | 2 +- .../pages/topics/messages/constants.ts | 56 ++ .../messages/detail/detail-sections.tsx | 233 ++++++++ .../messages/detail/detail-view-state.ts | 61 ++ .../messages/detail/message-detail-panel.tsx | 230 ++++++++ .../messages/dialogs/js-filter-dialog.tsx | 256 +++++++++ .../messages/hooks/use-client-filters.ts | 58 ++ .../topics/messages/hooks/use-keyboard-nav.ts | 73 +++ .../hooks/use-message-search.test.tsx | 198 +++++++ .../messages/hooks/use-message-search.ts | 366 ++++++++++++ .../messages/hooks/use-messages-url-state.ts | 231 ++++++++ .../pages/topics/messages/index.tsx | 34 ++ .../topics/messages/table/message-cells.tsx | 162 ++++++ .../topics/messages/table/messages-footer.tsx | 166 ++++++ .../topics/messages/table/messages-table.tsx | 279 +++++++++ .../messages/toolbar/filter-bar.test.tsx | 219 +++++++ .../topics/messages/toolbar/filter-bar.tsx | 478 ++++++++++++++++ .../toolbar/filter-suggestions.test.ts | 138 +++++ .../messages/toolbar/filter-suggestions.ts | 270 +++++++++ .../messages/toolbar/messages-toolbar.tsx | 54 ++ .../messages/toolbar/read-scope-doc-sheet.tsx | 224 ++++++++ .../toolbar/read-scope-popover.test.tsx | 101 ++++ .../messages/toolbar/read-scope-popover.tsx | 322 +++++++++++ .../topics/messages/topic-messages-view.tsx | 539 ++++++++++++++++++ .../components/pages/topics/messages/types.ts | 29 + .../messages/utils/client-match.test.ts | 106 ++++ .../topics/messages/utils/client-match.ts | 136 +++++ .../messages/utils/filter-token.test.ts | 135 +++++ .../topics/messages/utils/filter-token.ts | 125 ++++ .../topics/messages/utils/live-window.test.ts | 35 ++ .../topics/messages/utils/live-window.ts | 24 + .../messages/view-settings/column-list.tsx | 111 ++++ .../view-settings/preview-fields-editor.tsx | 187 ++++++ .../view-settings-panel.test.tsx | 76 +++ .../view-settings/view-settings-panel.tsx | 257 +++++++++ .../components/pages/topics/quick-info.tsx | 147 ++--- .../components/pages/topics/topic-details.tsx | 4 +- .../redpanda-ui/components/stat/index.tsx | 170 ++++++ frontend/src/globals.css | 14 + frontend/src/routes/__root.tsx | 5 +- .../src/stores/topic-settings-store.test.tsx | 117 +++- frontend/src/stores/topic-settings-store.ts | 90 +++ frontend/src/utils/utils.test.ts | 29 +- frontend/src/utils/utils.ts | 7 +- frontend/yarn.lock | 8 +- 50 files changed, 6498 insertions(+), 149 deletions(-) create mode 100644 frontend/src/components/pages/topics/messages/constants.ts create mode 100644 frontend/src/components/pages/topics/messages/detail/detail-sections.tsx create mode 100644 frontend/src/components/pages/topics/messages/detail/detail-view-state.ts create mode 100644 frontend/src/components/pages/topics/messages/detail/message-detail-panel.tsx create mode 100644 frontend/src/components/pages/topics/messages/dialogs/js-filter-dialog.tsx create mode 100644 frontend/src/components/pages/topics/messages/hooks/use-client-filters.ts create mode 100644 frontend/src/components/pages/topics/messages/hooks/use-keyboard-nav.ts create mode 100644 frontend/src/components/pages/topics/messages/hooks/use-message-search.test.tsx create mode 100644 frontend/src/components/pages/topics/messages/hooks/use-message-search.ts create mode 100644 frontend/src/components/pages/topics/messages/hooks/use-messages-url-state.ts create mode 100644 frontend/src/components/pages/topics/messages/index.tsx create mode 100644 frontend/src/components/pages/topics/messages/table/message-cells.tsx create mode 100644 frontend/src/components/pages/topics/messages/table/messages-footer.tsx create mode 100644 frontend/src/components/pages/topics/messages/table/messages-table.tsx create mode 100644 frontend/src/components/pages/topics/messages/toolbar/filter-bar.test.tsx create mode 100644 frontend/src/components/pages/topics/messages/toolbar/filter-bar.tsx create mode 100644 frontend/src/components/pages/topics/messages/toolbar/filter-suggestions.test.ts create mode 100644 frontend/src/components/pages/topics/messages/toolbar/filter-suggestions.ts create mode 100644 frontend/src/components/pages/topics/messages/toolbar/messages-toolbar.tsx create mode 100644 frontend/src/components/pages/topics/messages/toolbar/read-scope-doc-sheet.tsx create mode 100644 frontend/src/components/pages/topics/messages/toolbar/read-scope-popover.test.tsx create mode 100644 frontend/src/components/pages/topics/messages/toolbar/read-scope-popover.tsx create mode 100644 frontend/src/components/pages/topics/messages/topic-messages-view.tsx create mode 100644 frontend/src/components/pages/topics/messages/types.ts create mode 100644 frontend/src/components/pages/topics/messages/utils/client-match.test.ts create mode 100644 frontend/src/components/pages/topics/messages/utils/client-match.ts create mode 100644 frontend/src/components/pages/topics/messages/utils/filter-token.test.ts create mode 100644 frontend/src/components/pages/topics/messages/utils/filter-token.ts create mode 100644 frontend/src/components/pages/topics/messages/utils/live-window.test.ts create mode 100644 frontend/src/components/pages/topics/messages/utils/live-window.ts create mode 100644 frontend/src/components/pages/topics/messages/view-settings/column-list.tsx create mode 100644 frontend/src/components/pages/topics/messages/view-settings/preview-fields-editor.tsx create mode 100644 frontend/src/components/pages/topics/messages/view-settings/view-settings-panel.test.tsx create mode 100644 frontend/src/components/pages/topics/messages/view-settings/view-settings-panel.tsx create mode 100644 frontend/src/components/redpanda-ui/components/stat/index.tsx diff --git a/frontend/bun.lock b/frontend/bun.lock index 9f2956b4b0..e91318fa02 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "dependencies": { @@ -159,30 +158,29 @@ }, }, "overrides": { - "@babel/core": "^7.29.7", - "@isaacs/brace-expansion": "^5.0.1", - "@types/react": "^19.2.17", - "@types/react-dom": "^19.2.3", + "hono": "^4.12.25", "adm-zip": "^0.6.0", - "baseline-browser-mapping": "2.10.33", - "bn.js": "^5.2.3", "brace-expansion": "^2.0.3", - "dompurify": "^3.4.11", - "esbuild": "^0.28.1", - "hono": "^4.12.25", - "ip-address": "^10.2.0", - "js-yaml": "^4.3.0", + "@types/react-dom": "^19.2.3", "launch-editor": "^2.14.1", + "baseline-browser-mapping": "2.10.33", "memoize-one": "^6.0.0", - "minimatch": "^9.0.7", - "prismjs": "^1.30.0", - "rollup": "^4.59.0", "socket.io-parser": "^4.2.6", - "svgo": "^3.3.3", + "js-yaml": "^4.3.0", + "@types/react": "^19.2.17", + "ws": "^8.21.0", "tmp": "^0.2.7", "undici": "^7.28.0", + "dompurify": "^3.4.11", + "esbuild": "^0.28.1", + "bn.js": "^5.2.3", + "prismjs": "^1.30.0", + "@babel/core": "^7.29.7", + "svgo": "^3.3.3", + "rollup": "^4.59.0", + "minimatch": "^9.0.7", "vite": "^7.3.6", - "ws": "^8.21.0", + "ip-address": "^10.2.0", }, "packages": { "@a2a-js/sdk": ["@a2a-js/sdk@0.3.13", "", { "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-BZr0f9JVNQs3GKOM9xINWCh6OKIJWZFPyqqVqTym5mxO2Eemc6I/0zL7zWnljHzGdaf5aZQyQN5xa6PSH62q+A=="], diff --git a/frontend/src/app.tsx b/frontend/src/app.tsx index 2bcbd140a0..2e4e464af6 100644 --- a/frontend/src/app.tsx +++ b/frontend/src/app.tsx @@ -49,7 +49,10 @@ import { useEffect } from 'react'; import { getBasePath } from 'utils/env'; import { patchedRedpandaTheme as redpandaTheme } from 'utils/redpanda-theme'; -import { applyOverrides as applyDebugFeatureFlagOverrides } from './components/debug-helper/feature-flag-overrides'; +import { + applyOverrides as applyDebugFeatureFlagOverrides, + getEffectiveFlags, +} from './components/debug-helper/feature-flag-overrides'; import { NotFoundPage } from './components/misc/not-found-page'; import { RoutePendingFallback } from './components/misc/route-pending-fallback'; import { addBearerTokenInterceptor, checkExpiredLicenseInterceptor, getGrpcBasePath, setup } from './config'; @@ -130,9 +133,11 @@ const App = () => { }; }, []); - // Need to use CustomFeatureFlagProvider for completeness with EmbeddedApp + // Need to use CustomFeatureFlagProvider for completeness with EmbeddedApp. + // Standalone dev seeds from the effective flags (constants.ts defaults overlaid + // with debug-dialog localStorage overrides); E2E globals keep the last word. return ( - + diff --git a/frontend/src/components/constants.ts b/frontend/src/components/constants.ts index cdc79fc872..060456bc5c 100644 --- a/frontend/src/components/constants.ts +++ b/frontend/src/components/constants.ts @@ -20,6 +20,7 @@ export const FEATURE_FLAGS = { enableConnectSlashMenu: false, enableNewSecurityPage: true, enableTeamsBridge: false, + enableNewTopicMessagesPage: false, }; // Cloud-managed tag keys for service account integration diff --git a/frontend/src/components/pages/topics/Tab.Messages/index.tsx b/frontend/src/components/pages/topics/Tab.Messages/index.tsx index 01207e1de7..fa7ada5756 100644 --- a/frontend/src/components/pages/topics/Tab.Messages/index.tsx +++ b/frontend/src/components/pages/topics/Tab.Messages/index.tsx @@ -105,33 +105,7 @@ import { import { encodeBase64, prettyBytes, prettyMilliseconds } from '../../../../utils/utils'; import { range } from '../../../misc/common'; import RemovableFilter from '../../../misc/removable-filter'; - -const payloadEncodingPairs = [ - { value: PayloadEncoding.UNSPECIFIED, label: 'Automatic' }, - { value: PayloadEncoding.NULL, label: 'None (Null)' }, - { value: PayloadEncoding.AVRO, label: 'AVRO' }, - { value: PayloadEncoding.PROTOBUF, label: 'Protobuf' }, - { value: PayloadEncoding.PROTOBUF_SCHEMA, label: 'Protobuf Schema' }, - { value: PayloadEncoding.JSON, label: 'JSON' }, - { value: PayloadEncoding.JSON_SCHEMA, label: 'JSON Schema' }, - { value: PayloadEncoding.XML, label: 'XML' }, - { value: PayloadEncoding.TEXT, label: 'Plain Text' }, - { value: PayloadEncoding.UTF8, label: 'UTF-8' }, - { value: PayloadEncoding.MESSAGE_PACK, label: 'Message Pack' }, - { value: PayloadEncoding.SMILE, label: 'Smile' }, - { value: PayloadEncoding.BINARY, label: 'Binary' }, - { value: PayloadEncoding.UINT, label: 'Unsigned Int' }, - { value: PayloadEncoding.CONSUMER_OFFSETS, label: 'Consumer Offsets' }, - { value: PayloadEncoding.CBOR, label: 'CBOR' }, -]; - -const PAYLOAD_ENCODING_LABELS = payloadEncodingPairs.reduce( - (acc, pair) => { - acc[pair.value] = pair.label; - return acc; - }, - {} as Record -); +import { PAYLOAD_ENCODING_LABELS } from '../messages/constants'; type TopicMessageViewProps = { topic: Topic; diff --git a/frontend/src/components/pages/topics/Tab.Messages/message-display/payload-component.tsx b/frontend/src/components/pages/topics/Tab.Messages/message-display/payload-component.tsx index d5bcb46ee7..f942dbbfc3 100644 --- a/frontend/src/components/pages/topics/Tab.Messages/message-display/payload-component.tsx +++ b/frontend/src/components/pages/topics/Tab.Messages/message-display/payload-component.tsx @@ -9,7 +9,7 @@ * by the Apache License, Version 2.0 */ -import type { ReactNode } from 'react'; +import type { CSSProperties, ReactNode } from 'react'; import { useMemo, useState } from 'react'; import { toast } from 'sonner'; @@ -122,7 +122,12 @@ function preparePayloadData(payload: Payload): PayloadRenderData { } } -export const PayloadComponent = (p: { payload: Payload; loadLargeMessage: () => Promise }) => { +export const PayloadComponent = (p: { + payload: Payload; + loadLargeMessage: () => Promise; + /** Style overrides for the JSON viewer (e.g. lift the default max-height in full-height layouts). */ + viewerStyle?: CSSProperties; +}) => { const { payload, loadLargeMessage } = p; const [isLoadingLargeMessage, setLoadingLargeMessage] = useState(false); const renderData = useMemo(() => preparePayloadData(payload), [payload]); @@ -179,7 +184,9 @@ export const PayloadComponent = (p: { payload: Payload; loadLargeMessage: () => // Avro JSON encodes bytes fields as \u00XX escape sequences. Re-escape // Latin-1 code points in the viewer so copy-paste yields the original // bytes rather than their UTF-8 encoding. - return ; + return ( + + ); } return Error in RenderExpandedMessage: {renderData.content}; }; diff --git a/frontend/src/components/pages/topics/Tab.Messages/preview-settings.tsx b/frontend/src/components/pages/topics/Tab.Messages/preview-settings.tsx index 1f45301fdf..d75935428c 100644 --- a/frontend/src/components/pages/topics/Tab.Messages/preview-settings.tsx +++ b/frontend/src/components/pages/topics/Tab.Messages/preview-settings.tsx @@ -339,7 +339,7 @@ export function getPreviewTags( const displayName = tag.customName && tag.customName.length > 0 ? tag.customName : r.fullPath; ar.push( - + {displayName} {toSafeString(r.prop.value)} diff --git a/frontend/src/components/pages/topics/messages/constants.ts b/frontend/src/components/pages/topics/messages/constants.ts new file mode 100644 index 0000000000..bbcc78a290 --- /dev/null +++ b/frontend/src/components/pages/topics/messages/constants.ts @@ -0,0 +1,56 @@ +/** + * Copyright 2025 Redpanda Data, Inc. + * + * Use of this software is governed by the Business Source License + * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md + * + * As of the Change Date specified in that file, in accordance with + * the Business Source License, use of this software will be governed + * by the Apache License, Version 2.0 + */ + +import { PayloadEncoding } from '../../../../protogen/redpanda/api/console/v1alpha1/common_pb'; +import type { DataColumnKey } from '../../../../state/ui'; + +export const COLUMN_LABELS: Record = { + offset: 'Offset', + partitionID: 'Partition', + timestamp: 'Timestamp', + key: 'Key', + value: 'Value', + keySize: 'Key size', + valueSize: 'Value size', +}; + +/** Segmented max-results / page-size options in the read-scope popover. */ +export const LIMIT_OPTIONS = [10, 20, 50, 100]; + +/** Max rows kept in the table during live tail / continuous mode; older rows are trimmed. */ +export const DISPLAY_WINDOW_CAP = 150; + +export const PAYLOAD_ENCODING_PAIRS = [ + { value: PayloadEncoding.UNSPECIFIED, label: 'Automatic' }, + { value: PayloadEncoding.NULL, label: 'None (Null)' }, + { value: PayloadEncoding.AVRO, label: 'AVRO' }, + { value: PayloadEncoding.PROTOBUF, label: 'Protobuf' }, + { value: PayloadEncoding.PROTOBUF_SCHEMA, label: 'Protobuf Schema' }, + { value: PayloadEncoding.JSON, label: 'JSON' }, + { value: PayloadEncoding.JSON_SCHEMA, label: 'JSON Schema' }, + { value: PayloadEncoding.XML, label: 'XML' }, + { value: PayloadEncoding.TEXT, label: 'Plain Text' }, + { value: PayloadEncoding.UTF8, label: 'UTF-8' }, + { value: PayloadEncoding.MESSAGE_PACK, label: 'Message Pack' }, + { value: PayloadEncoding.SMILE, label: 'Smile' }, + { value: PayloadEncoding.BINARY, label: 'Binary' }, + { value: PayloadEncoding.UINT, label: 'Unsigned Int' }, + { value: PayloadEncoding.CONSUMER_OFFSETS, label: 'Consumer Offsets' }, + { value: PayloadEncoding.CBOR, label: 'CBOR' }, +]; + +export const PAYLOAD_ENCODING_LABELS = PAYLOAD_ENCODING_PAIRS.reduce( + (acc, pair) => { + acc[pair.value] = pair.label; + return acc; + }, + {} as Record +); diff --git a/frontend/src/components/pages/topics/messages/detail/detail-sections.tsx b/frontend/src/components/pages/topics/messages/detail/detail-sections.tsx new file mode 100644 index 0000000000..49c60658bc --- /dev/null +++ b/frontend/src/components/pages/topics/messages/detail/detail-sections.tsx @@ -0,0 +1,233 @@ +/** + * Copyright 2025 Redpanda Data, Inc. + * + * Use of this software is governed by the Business Source License + * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md + * + * As of the Change Date specified in that file, in accordance with + * the Business Source License, use of this software will be governed + * by the Apache License, Version 2.0 + */ + +import { Badge } from 'components/redpanda-ui/components/badge'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from 'components/redpanda-ui/components/collapsible'; +import { CopyButton } from 'components/redpanda-ui/components/copy-button'; +import { cn } from 'components/redpanda-ui/lib/utils'; +import { AlertTriangleIcon, ChevronRightIcon } from 'lucide-react'; +import type { ReactNode } from 'react'; + +import type { Payload, TopicMessage } from '../../../../../state/rest-interfaces'; +import { TimestampDisplay } from '../../../../../utils/tsx-utils'; +import { prettyBytes } from '../../../../../utils/utils'; +import { PayloadComponent } from '../../Tab.Messages/message-display/payload-component'; + +/** Collapsible section with an uppercase label, optional right-side meta and copy action. + * Open state is controlled — it lives in the persisted detail view state. */ +const DetailSection = ({ + label, + open, + onOpenChange, + meta, + copyContent, + children, + testId, + fill, +}: { + label: string; + open: boolean; + onOpenChange: (open: boolean) => void; + meta?: string; + copyContent?: string; + children: ReactNode; + testId: string; + /** Stretch this section to fill the remaining panel height while open. */ + fill?: boolean; +}) => ( + +
+ + + {label} + +
+ {meta && {meta}} + {copyContent !== undefined && } +
+
+ +
{children}
+
+
+); + +const MetaRow = ({ label, children }: { label: string; children: ReactNode }) => ( +
+
{label}
+
{children}
+
+); + +/** Controlled open state, persisted in the detail view state object. */ +type SectionProps = { + msg: TopicMessage; + open: boolean; + onOpenChange: (open: boolean) => void; +}; + +export const MetadataSection = ({ msg, open, onOpenChange }: SectionProps) => ( + +
+ + + + {msg.partitionID} + {msg.offset.toLocaleString()} + {msg.headers.length} + + + {msg.compression} + + + {msg.isTransactional ? 'true' : 'false'} + {prettyBytes(msg.key.size)} + {prettyBytes(msg.value.size)} +
+
+); + +const payloadMeta = (payload: Payload) => `${String(payload.encoding).toUpperCase()} – ${prettyBytes(payload.size)}`; + +/** Compact deserialization-failure report (e.g. a forced Protobuf decoder on text payloads). */ +const TroubleshootNote = ({ payload }: { payload: Payload }) => { + const report = payload.troubleshootReport; + if (!report || report.length === 0) { + return null; + } + return ( +
+
+ + Errors were encountered when deserializing this payload +
+
+ {report.map((entry) => ( +
+ {entry.serdeName}: {entry.message} +
+ ))} +
+
+ ); +}; + +const payloadCopyText = (payload: Payload, json: string) => { + if (payload.isPayloadNull) { + return 'null'; + } + return json; +}; + +export const KeySection = ({ msg, open, onOpenChange }: SectionProps) => ( + +
+ {msg.key.isPayloadNull ? null : msg.keyJson} +
+ +
+); + +const headerValueText = (value: Payload) => { + if (value.isPayloadNull) { + return null; + } + return typeof value.payload === 'object' ? JSON.stringify(value.payload) : String(value.payload); +}; + +/** Compact key/value grid (the design mock's header list — no table chrome or pagination). */ +const HeaderGrid = ({ headers }: { headers: TopicMessage['headers'] }) => ( +
+
+
Key
+
+ Value +
+
+ {headers.map((header, i) => { + const text = headerValueText(header.value); + return ( +
+
{header.key}
+
+ {text === null ? null : text} +
+
+ ); + })} +
+); + +export const HeadersSection = ({ msg, open, onOpenChange }: SectionProps) => ( + 0 ? JSON.stringify(msg.headers, null, 2) : undefined} + label="Headers" + meta={msg.headers.length === 1 ? '1 header' : `${msg.headers.length} headers`} + onOpenChange={onOpenChange} + open={open} + testId="detail-headers-section" + > + {msg.headers.length > 0 ? ( + + ) : ( +
This record carries no headers.
+ )} +
+); + +export const ValueSection = ({ + msg, + loadLargeMessage, + fill, + open, + onOpenChange, +}: SectionProps & { + loadLargeMessage: () => Promise; + /** Stretch the value viewer to the remaining panel height (expanded sheet). */ + fill?: boolean; +}) => ( + +
+ +
+ +
+); diff --git a/frontend/src/components/pages/topics/messages/detail/detail-view-state.ts b/frontend/src/components/pages/topics/messages/detail/detail-view-state.ts new file mode 100644 index 0000000000..97263feea7 --- /dev/null +++ b/frontend/src/components/pages/topics/messages/detail/detail-view-state.ts @@ -0,0 +1,61 @@ +/** + * Copyright 2025 Redpanda Data, Inc. + * + * Use of this software is governed by the Business Source License + * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md + * + * As of the Change Date specified in that file, in accordance with + * the Business Source License, use of this software will be governed + * by the Apache License, Version 2.0 + */ + +export type DetailSectionKey = 'metadata' | 'key' | 'headers' | 'value'; + +/** Every persisted preference of the message detail, stored as one object. */ +export type DetailViewState = { + /** Presentation: full-height sheet (true) or docked panel (false). */ + expanded: boolean; + /** Expanded sheet width in pixels. */ + sheetWidth: number; + /** Docked panel width as a percentage of the resizable group. */ + panelSizePct: number; + /** Which sections are open — shared across messages, so a collapsed + * Metadata stays collapsed while flipping through records. */ + sections: Record; +}; + +export const DEFAULT_DETAIL_VIEW_STATE: DetailViewState = { + expanded: false, + sheetWidth: 720, + panelSizePct: 32, + sections: { metadata: false, key: false, headers: false, value: true }, +}; + +const STORAGE_KEY = 'messages.detailView'; + +export const readDetailViewState = (): DetailViewState => { + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (!raw) { + return DEFAULT_DETAIL_VIEW_STATE; + } + const parsed = JSON.parse(raw) as Partial; + return { + ...DEFAULT_DETAIL_VIEW_STATE, + ...parsed, + sections: { ...DEFAULT_DETAIL_VIEW_STATE.sections, ...parsed.sections }, + }; + } catch { + return DEFAULT_DETAIL_VIEW_STATE; + } +}; + +/** Read-modify-write so independent writers (mode, widths, sections) don't clobber each other. */ +export const patchDetailViewState = (patch: Partial): void => { + try { + const next = { ...readDetailViewState(), ...patch }; + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); + } catch { + // storage unavailable (private mode) — preferences just won't persist + } +}; diff --git a/frontend/src/components/pages/topics/messages/detail/message-detail-panel.tsx b/frontend/src/components/pages/topics/messages/detail/message-detail-panel.tsx new file mode 100644 index 0000000000..c8fe4f2103 --- /dev/null +++ b/frontend/src/components/pages/topics/messages/detail/message-detail-panel.tsx @@ -0,0 +1,230 @@ +/** + * Copyright 2025 Redpanda Data, Inc. + * + * Use of this software is governed by the Business Source License + * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md + * + * As of the Change Date specified in that file, in accordance with + * the Business Source License, use of this software will be governed + * by the Apache License, Version 2.0 + */ + +import { Button } from 'components/redpanda-ui/components/button'; +import { Sheet, SheetContent, SheetHeader, SheetTitle } from 'components/redpanda-ui/components/sheet'; +import { cn } from 'components/redpanda-ui/lib/utils'; +import { DownloadIcon, Maximize2Icon, Minimize2Icon, XIcon } from 'lucide-react'; +import { useEffect, useState } from 'react'; + +import { HeadersSection, KeySection, MetadataSection, ValueSection } from './detail-sections'; +import { type DetailSectionKey, patchDetailViewState, readDetailViewState } from './detail-view-state'; +import type { TopicMessage } from '../../../../../state/rest-interfaces'; +import { toJson } from '../../../../../utils/json-utils'; + +export type MessageDetailPanelProps = { + msg: TopicMessage; + onClose: () => void; + loadLargeMessage: () => Promise; + /** Controlled: whether the full-height sheet presentation is shown. */ + expanded: boolean; + onExpandedChange: (expanded: boolean) => void; +}; + +/** Builds the record download exactly like the design mock: record-p{partition}-o{offset}.json */ +export const downloadRecord = (msg: TopicMessage) => { + const record = { + partition: msg.partitionID, + offset: msg.offset, + timestamp: msg.timestamp, + key: msg.key.isPayloadNull ? null : msg.key.payload, + value: msg.value.isPayloadNull ? null : msg.value.payload, + headers: msg.headers.map((h) => ({ key: h.key, value: h.value.payload })), + compression: msg.compression, + transactional: msg.isTransactional, + }; + const blob = new Blob([toJson(record, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `record-p${msg.partitionID}-o${msg.offset}.json`; + link.click(); + URL.revokeObjectURL(url); +}; + +const DetailBody = ({ + msg, + loadLargeMessage, + fillValue, + sections, + onSectionOpenChange, +}: { + msg: TopicMessage; + loadLargeMessage: () => Promise; + /** Expanded sheet: the value section stretches to use the full remaining height. */ + fillValue?: boolean; + sections: Record; + onSectionOpenChange: (section: DetailSectionKey, open: boolean) => void; +}) => ( + <> +
+ onSectionOpenChange('metadata', open)} + open={sections.metadata} + /> + onSectionOpenChange('key', open)} open={sections.key} /> + onSectionOpenChange('headers', open)} open={sections.headers} /> + onSectionOpenChange('value', open)} + open={sections.value} + /> +
+
+ +
+ +); + +/** + * Message inspector: docked next to the table, or (controlled via `expanded`) + * a full-height sheet. Only one presentation renders at a time — the page + * unmounts the docked resizable slot while the sheet is open. + */ +export const MessageDetailPanel = ({ + msg, + onClose, + loadLargeMessage, + expanded, + onExpandedChange, +}: MessageDetailPanelProps) => { + const [sheetWidth, setSheetWidth] = useState(() => + Math.min(Math.max(480, readDetailViewState().sheetWidth), window.innerWidth - 80) + ); + + // Section expansion is shared across messages and both presentations; every + // toggle persists into the consolidated detail view state. + const [sections, setSections] = useState(() => readDetailViewState().sections); + const handleSectionOpenChange = (section: DetailSectionKey, open: boolean) => { + setSections((prev) => { + const next = { ...prev, [section]: open }; + patchDetailViewState({ sections: next }); + return next; + }); + }; + + const startSheetResize = (e: React.PointerEvent) => { + e.preventDefault(); + const startX = e.clientX; + const startWidth = sheetWidth; + let latestWidth = startWidth; + const onMove = (event: PointerEvent) => { + latestWidth = Math.min(Math.max(480, startWidth + (startX - event.clientX)), window.innerWidth - 80); + setSheetWidth(latestWidth); + }; + const onUp = () => { + window.removeEventListener('pointermove', onMove); + window.removeEventListener('pointerup', onUp); + document.body.style.userSelect = ''; + document.body.style.cursor = ''; + patchDetailViewState({ sheetWidth: latestWidth }); + }; + window.addEventListener('pointermove', onMove); + window.addEventListener('pointerup', onUp); + document.body.style.userSelect = 'none'; + document.body.style.cursor = 'col-resize'; + }; + + // Esc closes the panel (unless typing in an input; the sheet handles its own Esc) + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + const target = e.target as HTMLElement; + if (e.key === 'Escape' && !expanded && !/^(input|textarea|select)$/i.test(target.tagName)) { + onClose(); + } + }; + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [expanded, onClose]); + + if (expanded) { + return ( + // Non-modal + no pointer dismissal: the table stays interactive while + // expanded, so clicking another row swaps the record shown in place. + + +
+ + Message + + + + + + + ); + } + + return ( +
+
+ Message + + +
+ +
+ ); +}; diff --git a/frontend/src/components/pages/topics/messages/dialogs/js-filter-dialog.tsx b/frontend/src/components/pages/topics/messages/dialogs/js-filter-dialog.tsx new file mode 100644 index 0000000000..9f7f17e0ac --- /dev/null +++ b/frontend/src/components/pages/topics/messages/dialogs/js-filter-dialog.tsx @@ -0,0 +1,256 @@ +/** + * Copyright 2025 Redpanda Data, Inc. + * + * Use of this software is governed by the Business Source License + * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md + * + * As of the Change Date specified in that file, in accordance with + * the Business Source License, use of this software will be governed + * by the Apache License, Version 2.0 + */ + +import { Button } from 'components/redpanda-ui/components/button'; +import { + Dialog, + DialogBody, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from 'components/redpanda-ui/components/dialog'; +import { Input } from 'components/redpanda-ui/components/input'; +import { Kbd } from 'components/redpanda-ui/components/kbd'; +import { Label } from 'components/redpanda-ui/components/label'; +import { InlineCode } from 'components/redpanda-ui/components/typography'; +import { useMemo, useState } from 'react'; + +import type { TopicMessage } from '../../../../../state/rest-interfaces'; +import { createFilterEntry, type FilterEntry } from '../../../../../state/ui'; +import { wrapFilterFragment } from '../../../../../utils/filter-helper'; +import FilterEditor from '../../Tab.Messages/editor'; + +const EXAMPLES: { code: string; note: string }[] = [ + { code: 'value != null', note: 'skips records without value' }, + { code: "if (key == 'example') return true", note: "only messages whose key equals 'example' (after decoding)" }, + { code: 'value.version === 0', note: 'matches records whose value version is 0' }, + { code: 'offset % 2 === 0', note: 'keeps only even offsets' }, +]; + +const PREVIEW_LIMIT = 4; + +type PreviewResult = + | { state: 'empty-code' } + | { state: 'error'; message: string } + | { state: 'ok'; hits: TopicMessage[]; hitCount: number; total: number }; + +/** + * Run the transpiled predicate over the loaded rows. Same trust model as the + * backend execution: it's the user's own code running on their own data. + */ +const runPreview = (transpiledCode: string, messages: TopicMessage[]): PreviewResult => { + if (!transpiledCode.trim()) { + return { state: 'empty-code' }; + } + try { + // biome-ignore lint/security/noGlobalEval: predicate authored by the user, mirrors backend filter execution + const fn = new Function( + 'offset', + 'partitionID', + 'key', + 'value', + 'headers', + 'keySchemaID', + 'valueSchemaID', + wrapFilterFragment(transpiledCode) + ); + const hits: TopicMessage[] = []; + let hitCount = 0; + for (const msg of messages) { + const headers = Object.fromEntries(msg.headers.map((h) => [h.key, h.value.payload])); + const matched = fn( + msg.offset, + msg.partitionID, + msg.key.isPayloadNull ? null : msg.key.payload, + msg.value.isPayloadNull ? null : msg.value.payload, + headers, + msg.key.schemaId, + msg.value.schemaId + ); + if (matched) { + hitCount += 1; + if (hits.length < PREVIEW_LIMIT) { + hits.push(msg); + } + } + } + return { state: 'ok', hits, hitCount, total: messages.length }; + } catch (err) { + return { state: 'error', message: err instanceof Error ? err.message : String(err) }; + } +}; + +export type JsFilterDialogProps = { + /** Filter being edited, or null to create a new one. */ + filter: FilterEntry | null; + /** Code seeded from the filter bar (`js:` input), for new filters. */ + seedCode?: string; + messages: TopicMessage[]; + onClose: () => void; + onSave: (filter: FilterEntry) => void; +}; + +export const JsFilterDialog = ({ filter, seedCode, messages, onClose, onSave }: JsFilterDialogProps) => { + const [draft, setDraft] = useState( + () => filter ?? createFilterEntry({ code: seedCode ?? 'return true', transpiledCode: seedCode ?? 'return true' }) + ); + + const preview = useMemo(() => runPreview(draft.transpiledCode, messages), [draft.transpiledCode, messages]); + + const apply = () => { + if (preview.state === 'error') { + return; + } + onSave({ ...draft, isActive: true }); + onClose(); + }; + + return ( + { + if (!open) { + onClose(); + } + }} + open + > + { + if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + apply(); + } + }} + size="xl" + > + + JavaScript filtering + + +
+ return true allows messages, return false discards them. + Available params: offset, partitionID,{' '} + key, value, headers,{' '} + keySchemaID, valueSchemaID. Multiple active filters are + combined with and. +
+ +
+ + setDraft((prev) => ({ ...prev, name: e.target.value }))} + placeholder="e.g. Invoices missing zip" + testId="js-filter-name" + value={draft.name} + /> +
+ +
+ +
+ + setDraft((prev) => ({ ...prev, code, transpiledCode: transpiled })) + } + value={draft.code} + /> +
+ {preview.state === 'error' && ( +
+ ⚠ {preview.message} +
+ )} +
+ +
+ +
+ {EXAMPLES.map((example) => ( + + ))} +
+
+ +
+
+ + + {preview.state === 'ok' && + (preview.total === 0 + ? 'no rows loaded to preview' + : `${preview.hitCount} of ${preview.total} loaded match`)} + {preview.state === 'empty-code' && 'offset, partitionID, key, value, headers in scope'} + {preview.state === 'error' && '⚠ fix the code to preview'} + +
+ {preview.state === 'ok' && preview.hits.length > 0 && ( +
+ {preview.hits.map((msg) => ( +
+ {msg.offset} + + {msg.keyJson.length > 12 ? `${msg.keyJson.slice(0, 12)}…` : msg.keyJson} + + {msg.valueJson} +
+ ))} + {preview.hitCount > preview.hits.length && ( +
+ + {preview.hitCount - preview.hits.length} more +
+ )} +
+ )} + {preview.state === 'ok' && preview.total === 0 && ( +
+ Nothing loaded to preview against — the preview runs over the rows currently in the table. The filter + still applies on the broker when you apply it. +
+ )} + {preview.state === 'ok' && preview.total > 0 && preview.hitCount === 0 && ( +
+ No loaded records match this predicate. +
+ )} +
+
+ + + ⌘⏎ apply + + + + +
+
+ ); +}; diff --git a/frontend/src/components/pages/topics/messages/hooks/use-client-filters.ts b/frontend/src/components/pages/topics/messages/hooks/use-client-filters.ts new file mode 100644 index 0000000000..2f0ae61285 --- /dev/null +++ b/frontend/src/components/pages/topics/messages/hooks/use-client-filters.ts @@ -0,0 +1,58 @@ +/** + * Copyright 2025 Redpanda Data, Inc. + * + * Use of this software is governed by the Business Source License + * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md + * + * As of the Change Date specified in that file, in accordance with + * the Business Source License, use of this software will be governed + * by the Apache License, Version 2.0 + */ + +import { useMemo } from 'react'; + +import type { TopicMessage } from '../../../../../state/rest-interfaces'; +import type { FilterToken } from '../types'; +import { matchesFieldFilter } from '../utils/client-match'; +import { parseFilterInput } from '../utils/filter-token'; + +/** + * Live full-text filtering over the loaded rows (offset, key and value JSON), + * mirroring the legacy quick search. When the typed text parses as a + * `field op value` token it is applied as a field filter instead. + */ +export function matchesQuickSearch(msg: TopicMessage, query: string): boolean { + const parsed = parseFilterInput(query); + if (parsed) { + return matchesFieldFilter(msg, parsed.field, parsed.op, parsed.value); + } + const needle = query.toLowerCase(); + return ( + String(msg.offset).includes(needle) || + msg.keyJson?.toLowerCase().includes(needle) || + msg.valueJson?.toLowerCase().includes(needle) + ); +} + +/** + * Client-side filtering: every committed field token must match (AND), then the + * live typed text. JS filters are pushed down to the backend and don't run here. + */ +export function useClientFilters( + messages: TopicMessage[], + quickSearch: string, + fieldTokens: FilterToken[] = [] +): TopicMessage[] { + return useMemo(() => { + const query = quickSearch.trim(); + const tokens = fieldTokens.filter((t) => t.kind === 'field'); + if (!(query || tokens.length > 0)) { + return messages; + } + return messages.filter( + (msg) => + tokens.every((t) => matchesFieldFilter(msg, t.field, t.op, t.value)) && + (!query || matchesQuickSearch(msg, query)) + ); + }, [messages, quickSearch, fieldTokens]); +} diff --git a/frontend/src/components/pages/topics/messages/hooks/use-keyboard-nav.ts b/frontend/src/components/pages/topics/messages/hooks/use-keyboard-nav.ts new file mode 100644 index 0000000000..b4f1531bb4 --- /dev/null +++ b/frontend/src/components/pages/topics/messages/hooks/use-keyboard-nav.ts @@ -0,0 +1,73 @@ +/** + * Copyright 2025 Redpanda Data, Inc. + * + * Use of this software is governed by the Business Source License + * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md + * + * As of the Change Date specified in that file, in accordance with + * the Business Source License, use of this software will be governed + * by the Apache License, Version 2.0 + */ + +import { useEffect } from 'react'; +import { toast } from 'sonner'; + +const isTypingTarget = (target: EventTarget | null): boolean => { + const el = target as HTMLElement | null; + return el !== null && (/^(input|textarea|select)$/i.test(el.tagName) || el.isContentEditable); +}; + +export type KeyboardNavOptions = { + /** Row keys in on-screen (sorted, paginated) order. */ + visibleKeys: string[]; + selectedKey: string | null; + onSelect: (key: string | null) => void; + /** Returns the copyable value JSON for a row key. */ + getCopyText: (key: string) => string | undefined; + enabled: boolean; +}; + +/** + * Table keyboard navigation from the design mock: j/k or ↑/↓ move the selected + * row, `c` copies the value, `/` focuses the filter input. Escape is handled by + * the detail panel itself. + */ +export function useKeyboardNav({ visibleKeys, selectedKey, onSelect, getCopyText, enabled }: KeyboardNavOptions) { + useEffect(() => { + if (!enabled) { + return; + } + const onKeyDown = (e: KeyboardEvent) => { + if (isTypingTarget(e.target)) { + return; + } + if (e.key === '/') { + e.preventDefault(); + document.querySelector('[data-testid="messages-filter-input"]')?.focus(); + return; + } + if (e.key === 'j' || e.key === 'ArrowDown' || e.key === 'k' || e.key === 'ArrowUp') { + if (visibleKeys.length === 0) { + return; + } + e.preventDefault(); + const delta = e.key === 'j' || e.key === 'ArrowDown' ? 1 : -1; + const currentIndex = selectedKey ? visibleKeys.indexOf(selectedKey) : -1; + const nextIndex = Math.min(Math.max(currentIndex + delta, 0), visibleKeys.length - 1); + onSelect(visibleKeys[nextIndex]); + return; + } + if (e.key === 'c' && selectedKey) { + const text = getCopyText(selectedKey); + if (text !== undefined) { + navigator.clipboard + .writeText(text) + .then(() => toast.success('Value copied to clipboard')) + .catch(() => toast.error('Could not copy to clipboard')); + } + } + }; + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [enabled, visibleKeys, selectedKey, onSelect, getCopyText]); +} diff --git a/frontend/src/components/pages/topics/messages/hooks/use-message-search.test.tsx b/frontend/src/components/pages/topics/messages/hooks/use-message-search.test.tsx new file mode 100644 index 0000000000..7396f57881 --- /dev/null +++ b/frontend/src/components/pages/topics/messages/hooks/use-message-search.test.tsx @@ -0,0 +1,198 @@ +/** + * Copyright 2025 Redpanda Data, Inc. + * + * Use of this software is governed by the Business Source License + * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md + * + * As of the Change Date specified in that file, in accordance with + * the Business Source License, use of this software will be governed + * by the Apache License, Version 2.0 + */ + +import { act, renderHook, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import { type MessageSearchParams, messageKey, useMessageSearch } from './use-message-search'; + +// The hook feeds raw data frames through convertListMessageData; identity-mock it so +// tests can use plain {partitionID, offset} stubs without building full proto payloads. +vi.mock('../../../../../utils/message-converters', () => ({ + convertListMessageData: (value: unknown) => value, +})); + +vi.mock('sonner', () => ({ + toast: { error: vi.fn() }, +})); + +type Frame = + | { case: 'phase'; value: { phase: string } } + | { case: 'progress'; value: { bytesConsumed: bigint; messagesConsumed: bigint } } + | { case: 'done'; value: { bytesConsumed: bigint; elapsedMs: bigint; nextPageToken: string; isCancelled: boolean } } + | { case: 'error'; value: { message: string } } + | { case: 'data'; value: { partitionID: number; offset: number } }; + +const listMessagesMock = vi.fn(); + +vi.mock('../../../../../config', () => ({ + config: { + get consoleClient() { + return { listMessages: listMessagesMock }; + }, + }, +})); + +const dataFrame = (partitionID: number, offset: number): Frame => ({ + case: 'data', + value: { partitionID, offset }, +}); + +const doneFrame = (nextPageToken = ''): Frame => ({ + case: 'done', + value: { bytesConsumed: 1024n, elapsedMs: 42n, nextPageToken, isCancelled: false }, +}); + +/** Builds a listMessages implementation yielding the given frames, capturing call args. */ +function scriptStream(...frames: Frame[]) { + listMessagesMock.mockImplementation((_req: unknown, _opts: { signal?: AbortSignal; timeoutMs: number }) => + (async function* () { + for (const frame of frames) { + await Promise.resolve(); + yield { controlMessage: frame }; + } + })() + ); +} + +const baseParams: MessageSearchParams = { + startOffset: -1, + startTimestamp: -1, + partitionId: -1, + maxResults: 50, + filterInterpreterCode: '', +}; + +describe('useMessageSearch', () => { + beforeEach(() => { + listMessagesMock.mockReset(); + }); + + test('collects data frames and finishes with done stats', async () => { + scriptStream( + { case: 'phase', value: { phase: 'Consuming messages' } }, + { case: 'progress', value: { bytesConsumed: 512n, messagesConsumed: 2n } }, + dataFrame(0, 1), + dataFrame(0, 2), + dataFrame(1, 7), + doneFrame('token-1') + ); + + const { result } = renderHook(() => useMessageSearch('test-topic')); + await act(() => result.current.start(baseParams)); + + expect(result.current.messages.map(messageKey)).toEqual(['0-1', '0-2', '1-7']); + expect(result.current.phase).toBe('done'); + expect(result.current.bytesConsumed).toBe(1024); + expect(result.current.elapsedMs).toBe(42); + expect(result.current.nextPageToken).toBe('token-1'); + expect(result.current.error).toBeNull(); + }); + + test('loadMore appends using the next page token', async () => { + scriptStream(dataFrame(0, 1), doneFrame('page-2')); + const { result } = renderHook(() => useMessageSearch('test-topic')); + await act(() => result.current.start(baseParams)); + expect(result.current.nextPageToken).toBe('page-2'); + + scriptStream(dataFrame(0, 2), doneFrame('')); + await act(() => result.current.loadMore(25)); + + expect(listMessagesMock).toHaveBeenCalledTimes(2); + const secondReq = listMessagesMock.mock.calls[1][0]; + expect(secondReq.pageToken).toBe('page-2'); + expect(secondReq.pageSize).toBe(25); + expect(result.current.messages.map(messageKey)).toEqual(['0-1', '0-2']); + expect(result.current.nextPageToken).toBeNull(); + }); + + test('a new start clears previous results', async () => { + scriptStream(dataFrame(0, 1), doneFrame()); + const { result } = renderHook(() => useMessageSearch('test-topic')); + await act(() => result.current.start(baseParams)); + expect(result.current.messages).toHaveLength(1); + + scriptStream(dataFrame(2, 9), doneFrame()); + await act(() => result.current.start(baseParams)); + expect(result.current.messages.map(messageKey)).toEqual(['2-9']); + }); + + test('stream failure surfaces as error state', async () => { + listMessagesMock.mockImplementation(() => + (async function* () { + await Promise.resolve(); + yield { controlMessage: dataFrame(0, 1) }; + throw new Error('connection lost'); + })() + ); + + const { result } = renderHook(() => useMessageSearch('test-topic')); + await act(() => result.current.start(baseParams)); + + await waitFor(() => expect(result.current.error?.message).toBe('connection lost')); + // Data received before the failure is kept + expect(result.current.messages).toHaveLength(1); + }); + + test('live mode marks arriving rows as new', async () => { + scriptStream(dataFrame(0, 5), dataFrame(0, 6), doneFrame()); + const { result } = renderHook(() => useMessageSearch('test-topic')); + await act(() => result.current.start({ ...baseParams, startOffset: -3 }, { live: true })); + + expect(result.current.newKeys.has('0-5')).toBe(true); + expect(result.current.newKeys.has('0-6')).toBe(true); + }); + + test('non-live paged searches do not flash rows', async () => { + scriptStream(dataFrame(0, 5), doneFrame()); + const { result } = renderHook(() => useMessageSearch('test-topic')); + await act(() => result.current.start(baseParams)); + expect(result.current.newKeys.size).toBe(0); + }); + + test('uses the long timeout for live and filtered streams, short otherwise', async () => { + const { result } = renderHook(() => useMessageSearch('test-topic')); + + scriptStream(doneFrame()); + await act(() => result.current.start(baseParams)); + expect(listMessagesMock.mock.calls[0][1].timeoutMs).toBe(30 * 1000); + + scriptStream(doneFrame()); + await act(() => result.current.start({ ...baseParams, filterInterpreterCode: 'cmV0dXJuIHRydWU=' })); + expect(listMessagesMock.mock.calls[1][1].timeoutMs).toBe(30 * 60 * 1000); + + scriptStream(doneFrame()); + await act(() => result.current.start(baseParams, { live: true })); + expect(listMessagesMock.mock.calls[2][1].timeoutMs).toBe(30 * 60 * 1000); + }); + + test('loadLargeMessage replaces the matching row in place', async () => { + scriptStream(dataFrame(0, 1), dataFrame(0, 2), doneFrame()); + const { result } = renderHook(() => useMessageSearch('test-topic')); + await act(() => result.current.start(baseParams)); + + listMessagesMock.mockImplementation(() => + (async function* () { + await Promise.resolve(); + yield { controlMessage: { case: 'data', value: { partitionID: 0, offset: 2, reloaded: true } } }; + })() + ); + await act(() => result.current.loadLargeMessage(0, 2)); + + const replaced = result.current.messages[1] as { reloaded?: boolean }; + expect(replaced.reloaded).toBe(true); + // The one-off fetch must lift the size limit and request the raw payload + const req = listMessagesMock.mock.calls.at(-1)?.[0]; + expect(req.ignoreMaxSizeLimit).toBe(true); + expect(req.includeOriginalRawPayload).toBe(true); + expect(req.maxResults).toBe(1); + }); +}); diff --git a/frontend/src/components/pages/topics/messages/hooks/use-message-search.ts b/frontend/src/components/pages/topics/messages/hooks/use-message-search.ts new file mode 100644 index 0000000000..2ae5d447c4 --- /dev/null +++ b/frontend/src/components/pages/topics/messages/hooks/use-message-search.ts @@ -0,0 +1,366 @@ +/** + * Copyright 2025 Redpanda Data, Inc. + * + * Use of this software is governed by the Business Source License + * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md + * + * As of the Change Date specified in that file, in accordance with + * the Business Source License, use of this software will be governed + * by the Apache License, Version 2.0 + */ + +import { create } from '@bufbuild/protobuf'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { toast } from 'sonner'; + +import { config as appConfig } from '../../../../../config'; +import type { PayloadEncoding } from '../../../../../protogen/redpanda/api/console/v1alpha1/common_pb'; +import { ListMessagesRequestSchema } from '../../../../../protogen/redpanda/api/console/v1alpha1/list_messages_pb'; +import type { TopicMessage } from '../../../../../state/rest-interfaces'; +import { PartitionOffsetOrigin } from '../../../../../state/ui'; +import { appendWithSlackCap } from '../../../../../utils/bounded-array'; +import { convertListMessageData } from '../../../../../utils/message-converters'; + +/** Memory bound for live-tail / filtered streams (mirrors the legacy engine's cap). */ +const LIVE_BUFFER_MAX = 50_000; +const LIVE_BUFFER_SLACK = 1024; + +/** How often buffered stream data is flushed into React state. */ +const FLUSH_INTERVAL_MS = 200; + +/** How long a freshly arrived row keeps its "new" marker (drives the flash animation). */ +const NEW_KEY_TTL_MS = 3000; + +const DEFAULT_TIMEOUT_MS = 30 * 1000; +const LIVE_TIMEOUT_MS = 30 * 60 * 1000; + +export type MessageSearchParams = { + startOffset: number; + startTimestamp: number; + partitionId: number; + maxResults: number; + pageSize?: number; + /** Combined JS predicate, base64 encoded. Empty string when no JS filters are active. */ + filterInterpreterCode: string; + keyDeserializer?: PayloadEncoding; + valueDeserializer?: PayloadEncoding; + includeRawPayload?: boolean; + ignoreSizeLimit?: boolean; +}; + +export type MessageSearchPhase = 'idle' | 'connecting' | 'searching' | 'streaming' | 'done'; + +export type MessageSearchResult = { + messages: TopicMessage[]; + phase: MessageSearchPhase; + /** Raw phase string as reported by the backend (e.g. "Consuming messages"). */ + backendPhase: string | null; + error: Error | null; + bytesConsumed: number; + totalMessagesConsumed: number; + elapsedMs: number | null; + nextPageToken: string | null; + isLoadingMore: boolean; + /** `partition-offset` ids of rows that arrived within the last few seconds (drives flash). */ + newKeys: ReadonlySet; + start: (params: MessageSearchParams, options?: { live?: boolean }) => Promise; + stop: () => void; + loadMore: (pageSize?: number) => Promise; + loadLargeMessage: (partitionId: number, offset: number) => Promise; +}; + +export const messageKey = (m: Pick) => `${m.partitionID}-${m.offset}`; + +const buildListMessagesRequest = (topicName: string, params: MessageSearchParams) => { + const req = create(ListMessagesRequestSchema); + req.topic = topicName; + req.startOffset = BigInt(params.startOffset); + req.startTimestamp = BigInt(params.startTimestamp); + req.partitionId = params.partitionId; + req.maxResults = params.maxResults; + req.pageToken = ''; + req.pageSize = params.pageSize ?? 0; + req.filterInterpreterCode = params.filterInterpreterCode; + req.includeOriginalRawPayload = params.includeRawPayload ?? false; + req.ignoreMaxSizeLimit = params.ignoreSizeLimit ?? false; + req.keyDeserializer = params.keyDeserializer; + req.valueDeserializer = params.valueDeserializer; + return req; +}; + +type StreamStats = { + bytesConsumed: number; + totalMessagesConsumed: number; + elapsedMs: number | null; + nextPageToken: string | null; +}; + +/** + * Streaming message search over `ConsoleService.listMessages`. + * + * Unlike the legacy `createMessageSearch` engine this surfaces every data frame + * incrementally (throttled to ~5 flushes/s), which the live-tail UX needs to + * insert rows and drive the flash animation while the stream is open. + */ +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: stream lifecycle is inherently stateful; split further only if it grows +export function useMessageSearch(topicName: string): MessageSearchResult { + const [messages, setMessages] = useState([]); + const [phase, setPhase] = useState('idle'); + const [backendPhase, setBackendPhase] = useState(null); + const [error, setError] = useState(null); + const [stats, setStats] = useState({ + bytesConsumed: 0, + totalMessagesConsumed: 0, + elapsedMs: null, + nextPageToken: null, + }); + const [isLoadingMore, setIsLoadingMore] = useState(false); + const [newKeys, setNewKeys] = useState>(new Set()); + + const bufferRef = useRef([]); + const pendingNewKeysRef = useRef>(new Set()); + const flushTimerRef = useRef | null>(null); + const newKeysClearTimerRef = useRef | null>(null); + const abortControllerRef = useRef(null); + const lastParamsRef = useRef(null); + const trackNewRef = useRef(false); + + const flush = useCallback(() => { + flushTimerRef.current = null; + setMessages([...bufferRef.current]); + + if (trackNewRef.current && pendingNewKeysRef.current.size > 0) { + const fresh = pendingNewKeysRef.current; + pendingNewKeysRef.current = new Set(); + setNewKeys((prev) => new Set([...prev, ...fresh])); + if (newKeysClearTimerRef.current) { + clearTimeout(newKeysClearTimerRef.current); + } + newKeysClearTimerRef.current = setTimeout(() => { + newKeysClearTimerRef.current = null; + setNewKeys(new Set()); + }, NEW_KEY_TTL_MS); + } + }, []); + + const scheduleFlush = useCallback(() => { + if (flushTimerRef.current === null) { + flushTimerRef.current = setTimeout(flush, FLUSH_INTERVAL_MS); + } + }, [flush]); + + const stop = useCallback(() => { + if (abortControllerRef.current) { + abortControllerRef.current.abort('stopped by user'); + abortControllerRef.current = null; + } + }, []); + + const runStream = useCallback( + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: one switch per control-message frame kind + async (params: MessageSearchParams, options: { live?: boolean; append?: boolean; pageToken?: string }) => { + const client = appConfig.consoleClient; + if (!client) { + throw new Error('No console client configured'); + } + + // Abort any in-flight stream before starting a new one + stop(); + const abortController = new AbortController(); + abortControllerRef.current = abortController; + const { signal } = abortController; + + lastParamsRef.current = params; + // Live tail flashes every arriving row; loadMore appends without flashing. + trackNewRef.current = options.live === true; + + if (!options.append) { + bufferRef.current = []; + pendingNewKeysRef.current = new Set(); + setMessages([]); + setNewKeys(new Set()); + } + setError(null); + setIsLoadingMore(options.append === true); + setPhase('connecting'); + setBackendPhase(null); + setStats((prev) => ({ + bytesConsumed: 0, + totalMessagesConsumed: 0, + elapsedMs: null, + nextPageToken: options.append ? prev.nextPageToken : null, + })); + + const req = buildListMessagesRequest(topicName, params); + if (options.pageToken) { + req.pageToken = options.pageToken; + } + + // Live tail and push-down filters keep the stream open (backend semantics), + // so those runs get the long timeout — same rule as the legacy engine. + const isLongLived = + options.live === true || + params.startOffset === PartitionOffsetOrigin.End || + params.filterInterpreterCode !== ''; + const timeoutMs = isLongLived ? LIVE_TIMEOUT_MS : DEFAULT_TIMEOUT_MS; + const startTime = Date.now(); + + try { + for await (const res of client.listMessages(req, { signal, timeoutMs })) { + if (signal.aborted) { + break; + } + const controlMessage = res.controlMessage; + switch (controlMessage.case) { + case 'phase': + setBackendPhase(controlMessage.value.phase); + setPhase('searching'); + break; + case 'progress': { + const bytesConsumed = Number(controlMessage.value.bytesConsumed); + const totalMessagesConsumed = Number(controlMessage.value.messagesConsumed); + setStats((prev) => ({ ...prev, bytesConsumed, totalMessagesConsumed })); + break; + } + case 'done': { + const done = controlMessage.value; + const bytesConsumed = Number(done.bytesConsumed); + const elapsedMs = Number(done.elapsedMs) || Date.now() - startTime; + const nextPageToken = done.nextPageToken || null; + setStats((prev) => ({ ...prev, bytesConsumed, elapsedMs, nextPageToken })); + break; + } + case 'error': + toast.error('Backend error', { description: controlMessage.value.message }); + break; + case 'data': { + const message = convertListMessageData(controlMessage.value); + if (isLongLived) { + appendWithSlackCap(bufferRef.current, message, LIVE_BUFFER_MAX, LIVE_BUFFER_SLACK); + } else { + bufferRef.current.push(message); + } + if (trackNewRef.current) { + pendingNewKeysRef.current.add(messageKey(message)); + } + setPhase('streaming'); + scheduleFlush(); + break; + } + default: + break; + } + } + } catch (err) { + if (!signal.aborted) { + setError(err instanceof Error ? err : new Error(String(err))); + } + } finally { + if (abortControllerRef.current === abortController) { + abortControllerRef.current = null; + } + if (flushTimerRef.current) { + clearTimeout(flushTimerRef.current); + } + flush(); + setIsLoadingMore(false); + setPhase('done'); + setBackendPhase(null); + } + }, + [topicName, stop, flush, scheduleFlush] + ); + + const start = useCallback( + (params: MessageSearchParams, options?: { live?: boolean }) => runStream(params, { live: options?.live }), + [runStream] + ); + + const loadMore = useCallback( + async (pageSize?: number) => { + const params = lastParamsRef.current; + const pageToken = stats.nextPageToken; + if (!(params && pageToken)) { + return; + } + await runStream({ ...params, pageSize: pageSize ?? params.pageSize }, { append: true, pageToken }); + }, + [runStream, stats.nextPageToken] + ); + + /** + * Re-fetch a single "payload too large" message with the size limit lifted and + * swap it into the current result set in place. + */ + const loadLargeMessage = useCallback( + async (partitionId: number, offset: number) => { + const client = appConfig.consoleClient; + if (!client) { + throw new Error('No console client configured'); + } + const params = lastParamsRef.current; + const req = buildListMessagesRequest(topicName, { + startOffset: offset, + startTimestamp: 0, + partitionId, + maxResults: 1, + filterInterpreterCode: '', + includeRawPayload: true, + ignoreSizeLimit: true, + keyDeserializer: params?.keyDeserializer, + valueDeserializer: params?.valueDeserializer, + }); + let loaded: TopicMessage | null = null; + for await (const res of client.listMessages(req, { timeoutMs: DEFAULT_TIMEOUT_MS })) { + if (res.controlMessage.case === 'data') { + loaded = convertListMessageData(res.controlMessage.value); + } + } + if (!loaded) { + throw new Error("Couldn't load the message content, the response was empty"); + } + + const index = bufferRef.current.findIndex((m) => m.partitionID === partitionId && m.offset === offset); + if (index === -1) { + throw new Error('Cannot find the message to replace — results changed since the load started'); + } + bufferRef.current[index] = loaded; + setMessages([...bufferRef.current]); + }, + [topicName] + ); + + // Abort the stream and cancel timers on unmount + useEffect( + () => () => { + if (abortControllerRef.current) { + abortControllerRef.current.abort('component unmounted'); + abortControllerRef.current = null; + } + if (flushTimerRef.current) { + clearTimeout(flushTimerRef.current); + } + if (newKeysClearTimerRef.current) { + clearTimeout(newKeysClearTimerRef.current); + } + }, + [] + ); + + return { + messages, + phase, + backendPhase, + error, + bytesConsumed: stats.bytesConsumed, + totalMessagesConsumed: stats.totalMessagesConsumed, + elapsedMs: stats.elapsedMs, + nextPageToken: stats.nextPageToken, + isLoadingMore, + newKeys, + start, + stop, + loadMore, + loadLargeMessage, + }; +} diff --git a/frontend/src/components/pages/topics/messages/hooks/use-messages-url-state.ts b/frontend/src/components/pages/topics/messages/hooks/use-messages-url-state.ts new file mode 100644 index 0000000000..723510e933 --- /dev/null +++ b/frontend/src/components/pages/topics/messages/hooks/use-messages-url-state.ts @@ -0,0 +1,231 @@ +/** + * Copyright 2025 Redpanda Data, Inc. + * + * Use of this software is governed by the Business Source License + * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md + * + * As of the Change Date specified in that file, in accordance with + * the Business Source License, use of this software will be governed + * by the Apache License, Version 2.0 + */ + +import type { SortingState } from '@tanstack/react-table'; +import { createParser, parseAsBoolean, parseAsInteger, parseAsString, useQueryState } from 'nuqs'; +import { useCallback, useEffect } from 'react'; + +import { useQueryStateWithCallback } from '../../../../../hooks/use-query-state-with-callback'; +import { PayloadEncoding } from '../../../../../protogen/redpanda/api/console/v1alpha1/common_pb'; +import { PartitionOffsetOrigin } from '../../../../../state/ui'; +import { DEFAULT_SORTING, useTopicSettingsStore } from '../../../../../stores/topic-settings-store'; +import { sortingParser } from '../../../../../utils/sorting-parser'; +import type { FieldFilterToken, ReadScopeMode } from '../types'; +import { parseFilterInput, tokenQueryText } from '../utils/filter-token'; + +const DEFAULT_MAX_RESULTS = 50; + +// Comma-separated `field op value` texts (`key:abc,offset>5`). Commas inside +// values are escaped so splitting stays unambiguous. JS filters are +// intentionally NOT persisted in the URL — code doesn't belong in share links. +const escapeToken = (text: string) => text.replace(/%/g, '%25').replace(/,/g, '%2C'); +const unescapeToken = (text: string) => text.replace(/%2C/g, ',').replace(/%25/g, '%'); + +export const fieldTokensParser = createParser({ + parse: (value) => { + const tokens: FieldFilterToken[] = []; + for (const part of value.split(',')) { + const parsed = parseFilterInput(unescapeToken(part)); + if (parsed) { + tokens.push({ kind: 'field', ...parsed }); + } + } + return tokens; + }, + serialize: (tokens) => tokens.map((t) => escapeToken(tokenQueryText(t))).join(','), + eq: (a, b) => a.length === b.length && a.every((t, i) => tokenQueryText(t) === tokenQueryText(b[i])), +}); + +/** Maps the persisted/URL start offset sentinel to the read-scope mode shown in the toolbar. */ +export function readScopeModeFromOffset(startOffset: number): ReadScopeMode { + switch (startOffset) { + case PartitionOffsetOrigin.Start: + return 'oldest'; + case PartitionOffsetOrigin.Timestamp: + return 'timestamp'; + case PartitionOffsetOrigin.EndMinusResults: + return 'newest'; + default: + return startOffset >= 0 ? 'offset' : 'newest'; + } +} + +export function offsetForReadScopeMode(mode: ReadScopeMode, customOffset: number): number { + switch (mode) { + case 'oldest': + return PartitionOffsetOrigin.Start; + case 'timestamp': + return PartitionOffsetOrigin.Timestamp; + case 'offset': + return Math.max(0, customOffset); + default: + return PartitionOffsetOrigin.EndMinusResults; + } +} + +/** + * URL-backed search state for the messages page (same query keys as the legacy + * viewer so shared links keep working), mirrored into the per-topic Zustand store. + * + * `live` is new: live tail is orthogonal to the read scope. Legacy URLs with + * `o=-3` (PartitionOffsetOrigin.End) are migrated to `live=true` + newest scope. + */ +export function useMessagesUrlState(topicName: string) { + const { setSearchParams, getSearchParams, setSorting, getSorting } = useTopicSettingsStore(); + + const [partitionId, setPartitionId] = useQueryStateWithCallback( + { + onUpdate: (val) => setSearchParams(topicName, { partitionID: val }), + getDefaultValue: () => getSearchParams(topicName)?.partitionID ?? -1, + }, + 'p', + parseAsInteger.withDefault(-1) + ); + + const [maxResults, setMaxResults] = useQueryStateWithCallback( + { + onUpdate: (val) => setSearchParams(topicName, { maxResults: val }), + getDefaultValue: () => getSearchParams(topicName)?.maxResults ?? DEFAULT_MAX_RESULTS, + }, + 's', + parseAsInteger.withDefault(DEFAULT_MAX_RESULTS) + ); + + const [startOffset, setStartOffset] = useQueryStateWithCallback( + { + onUpdate: (val) => setSearchParams(topicName, { startOffset: val }), + getDefaultValue: () => { + const stored = getSearchParams(topicName)?.startOffset ?? PartitionOffsetOrigin.EndMinusResults; + // Live tail is no longer a start-offset mode; stored End means "was live" + return stored === PartitionOffsetOrigin.End ? PartitionOffsetOrigin.EndMinusResults : stored; + }, + }, + 'o', + parseAsInteger.withDefault(PartitionOffsetOrigin.EndMinusResults) + ); + + const [startTimestamp, setStartTimestamp] = useQueryStateWithCallback( + { + onUpdate: (val) => setSearchParams(topicName, { startTimestamp: val, startTimestampWasSetByUser: true }), + getDefaultValue: () => getSearchParams(topicName)?.startTimestamp ?? -1, + }, + 't', + parseAsInteger.withDefault(-1) + ); + + const [quickSearch, setQuickSearch] = useQueryState('q', parseAsString.withDefault('')); + + // Committed filter chips (`key:abc`, `offset>5`, …). JS filter chips are excluded. + const [fieldTokens, setFieldTokens] = useQueryState('f', fieldTokensParser.withDefault([])); + + const [keyDeserializer, setKeyDeserializer] = useQueryStateWithCallback( + { + onUpdate: (val) => setSearchParams(topicName, { keyDeserializer: val }), + getDefaultValue: () => getSearchParams(topicName)?.keyDeserializer ?? PayloadEncoding.UNSPECIFIED, + }, + 'kd', + parseAsInteger.withDefault(PayloadEncoding.UNSPECIFIED) + ); + + const [valueDeserializer, setValueDeserializer] = useQueryStateWithCallback( + { + onUpdate: (val) => setSearchParams(topicName, { valueDeserializer: val }), + getDefaultValue: () => getSearchParams(topicName)?.valueDeserializer ?? PayloadEncoding.UNSPECIFIED, + }, + 'vd', + parseAsInteger.withDefault(PayloadEncoding.UNSPECIFIED) + ); + + const [pageIndex, setPageIndex] = useQueryState('page', parseAsInteger.withDefault(0)); + + const [pageSize, setPageSize] = useQueryStateWithCallback( + { + onUpdate: (val) => setSearchParams(topicName, { pageSize: val }), + getDefaultValue: () => getSearchParams(topicName)?.pageSize ?? DEFAULT_MAX_RESULTS, + }, + 'pageSize', + parseAsInteger.withDefault(DEFAULT_MAX_RESULTS) + ); + + const [sorting, setSortingState] = useQueryStateWithCallback( + { + onUpdate: (val) => setSorting(topicName, val), + getDefaultValue: () => getSorting(topicName), + }, + 'sort', + sortingParser.withDefault(DEFAULT_SORTING) + ); + + const [continuousMode, setContinuousMode] = useQueryStateWithCallback( + { + onUpdate: (val) => setSearchParams(topicName, { continuousPaginationEnabled: val }), + getDefaultValue: () => getSearchParams(topicName)?.continuousPaginationEnabled ?? false, + }, + 'inf', + parseAsBoolean.withDefault(false) + ); + + const [liveTail, setLiveTail] = useQueryState('live', parseAsBoolean.withDefault(false)); + + // Selected message (`partition-offset`), so a reload or shared link reopens the detail + const [selectedKey, setSelectedKey] = useQueryState('selected', parseAsString); + + // Migrate legacy live-tail URLs (`o=-3`) to the orthogonal `live` param + useEffect(() => { + if (startOffset === PartitionOffsetOrigin.End) { + setStartOffset(PartitionOffsetOrigin.EndMinusResults); + setLiveTail(true); + } + }, [startOffset, setStartOffset, setLiveTail]); + + const readScopeMode = readScopeModeFromOffset(startOffset); + + const setReadScopeMode = useCallback( + (mode: ReadScopeMode, customOffset = 0) => { + setStartOffset(offsetForReadScopeMode(mode, customOffset)); + setPageIndex(0); + }, + [setStartOffset, setPageIndex] + ); + + return { + partitionId, + setPartitionId, + maxResults, + setMaxResults, + startOffset, + setStartOffset, + startTimestamp, + setStartTimestamp, + quickSearch, + setQuickSearch, + fieldTokens, + setFieldTokens, + keyDeserializer, + setKeyDeserializer, + valueDeserializer, + setValueDeserializer, + pageIndex, + setPageIndex, + pageSize, + setPageSize, + sorting, + setSortingState, + continuousMode, + setContinuousMode, + liveTail, + setLiveTail, + selectedKey, + setSelectedKey, + readScopeMode, + setReadScopeMode, + }; +} diff --git a/frontend/src/components/pages/topics/messages/index.tsx b/frontend/src/components/pages/topics/messages/index.tsx new file mode 100644 index 0000000000..81a3f70b81 --- /dev/null +++ b/frontend/src/components/pages/topics/messages/index.tsx @@ -0,0 +1,34 @@ +/** + * Copyright 2025 Redpanda Data, Inc. + * + * Use of this software is governed by the Business Source License + * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md + * + * As of the Change Date specified in that file, in accordance with + * the Business Source License, use of this software will be governed + * by the Apache License, Version 2.0 + */ + +import { TopicMessagesView } from './topic-messages-view'; +import { useBooleanFlagValue } from '../../../../custom-feature-flag-provider'; +import type { Topic } from '../../../../state/rest-interfaces'; +import { TopicMessageView } from '../Tab.Messages'; + +export type TopicMessagesTabProps = { + topic: Topic; + refreshTopicData: (force: boolean) => void; +}; + +/** + * Messages tab entry point: renders the redesigned viewer when + * `enableNewTopicMessagesPage` is on, otherwise the legacy `TopicMessageView`. + */ +export const TopicMessagesTab = ({ topic, refreshTopicData }: TopicMessagesTabProps) => { + const useNewMessagesPage = useBooleanFlagValue('enableNewTopicMessagesPage'); + + if (useNewMessagesPage) { + return ; + } + + return ; +}; diff --git a/frontend/src/components/pages/topics/messages/table/message-cells.tsx b/frontend/src/components/pages/topics/messages/table/message-cells.tsx new file mode 100644 index 0000000000..d7bf534009 --- /dev/null +++ b/frontend/src/components/pages/topics/messages/table/message-cells.tsx @@ -0,0 +1,162 @@ +/** + * Copyright 2025 Redpanda Data, Inc. + * + * Use of this software is governed by the Business Source License + * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md + * + * As of the Change Date specified in that file, in accordance with + * the Business Source License, use of this software will be governed + * by the Apache License, Version 2.0 + */ + +import { Badge } from 'components/redpanda-ui/components/badge'; + +import type { Payload, TopicMessage } from '../../../../../state/rest-interfaces'; +import type { PreviewTagV2, TimestampDisplayFormat } from '../../../../../state/ui'; +import { TimestampDisplay } from '../../../../../utils/tsx-utils'; +import { prettyBytes } from '../../../../../utils/utils'; +import { getPreviewTags } from '../../Tab.Messages/preview-settings'; +import type { RowDensity } from '../types'; + +export type ValuePreviewConfig = { + tags: PreviewTagV2[]; + caseSensitive: boolean; + multiResultMode: 'showOnlyFirst' | 'showAll'; + displayMode: 'single' | 'wrap' | 'rows'; +}; + +/** Max characters of the one-line JSON preview shown in key/value cells (mirrors the mock's ~92ch). */ +const PREVIEW_MAX_CHARS = 92; + +const truncate = (text: string, max = PREVIEW_MAX_CHARS) => (text.length > max ? `${text.slice(0, max)}…` : text); + +export const OffsetCell = ({ offset }: { offset: number }) => + offset < 0 ? ( + Loading… + ) : ( + {offset.toLocaleString()} + ); + +export const TimestampCell = ({ timestamp, format }: { timestamp: number; format: TimestampDisplayFormat }) => ( + + + +); + +export const SizeCell = ({ size }: { size: number }) => ( + {prettyBytes(size)} +); + +const payloadText = ( + payload: Payload, + json: string, + hexPreview: string +): { text: string; muted: boolean; error?: boolean } => { + if (payload.isPayloadNull) { + return { text: 'null', muted: true }; + } + if (payload.isPayloadTooLarge) { + return { text: 'Payload too large to display — open the message to load it', muted: true }; + } + if (json) { + return { text: json, muted: false }; + } + if (hexPreview) { + return { text: hexPreview, muted: false }; + } + if (payload.troubleshootReport && payload.troubleshootReport.length > 0) { + return { text: '⚠ failed to deserialize — open the message for details', muted: false, error: true }; + } + return { text: '', muted: true }; +}; + +/** + * Meta row shown under key/value content in `detailed` density: + * the decoder badge and payload byte size, per the design mock. + */ +const PayloadMeta = ({ payload }: { payload: Payload }) => ( + + + {payload.encoding} + + {prettyBytes(payload.size)} + +); + +export const PayloadCell = ({ + payload, + json, + hexPreview, + density, + className, +}: { + payload: Payload; + json: string; + hexPreview: string; + density: RowDensity; + className?: string; +}) => { + const { text, muted, error } = payloadText(payload, json, hexPreview); + return ( + + + {truncate(text)} + + {density === 'detailed' && !payload.isPayloadNull && } + + ); +}; + +export const KeyCell = ({ msg, density }: { msg: TopicMessage; density: RowDensity }) => ( + +); + +export const ValueCell = ({ + msg, + density, + preview, +}: { + msg: TopicMessage; + density: RowDensity; + preview?: ValuePreviewConfig; +}) => { + const activeTags = preview?.tags.filter((t) => t.isActive && t.pattern.trim().length > 0 && t.searchInMessageValue); + const payloadIsObject = + !msg.value.isPayloadNull && typeof msg.value.payload === 'object' && msg.value.payload !== null; + + if (preview && activeTags && activeTags.length > 0 && payloadIsObject) { + let chips = getPreviewTags(msg.value.payload as Record, activeTags, preview.caseSensitive); + if (preview.multiResultMode === 'showOnlyFirst') { + chips = chips.slice(0, 1); + } + return ( + + + {chips.length > 0 ? chips : no preview match} + + {density === 'detailed' && } + + ); + } + + return ( + + ); +}; diff --git a/frontend/src/components/pages/topics/messages/table/messages-footer.tsx b/frontend/src/components/pages/topics/messages/table/messages-footer.tsx new file mode 100644 index 0000000000..93758479c3 --- /dev/null +++ b/frontend/src/components/pages/topics/messages/table/messages-footer.tsx @@ -0,0 +1,166 @@ +/** + * Copyright 2025 Redpanda Data, Inc. + * + * Use of this software is governed by the Business Source License + * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md + * + * As of the Change Date specified in that file, in accordance with + * the Business Source License, use of this software will be governed + * by the Apache License, Version 2.0 + */ + +import { Button } from 'components/redpanda-ui/components/button'; +import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon, ClockIcon, DownloadIcon, Trash2Icon } from 'lucide-react'; + +import { prettyBytes } from '../../../../../utils/utils'; + +export type MessagesFooterProps = { + totalLoaded: number; + pageIndex: number; + pageSize: number; + onPageChange: (pageIndex: number) => void; + continuousMode: boolean; + /** Continuous mode: rows currently shown in the display window. */ + windowSize: number; + windowCap: number; + trimmedCount: number; + canLoadMore: boolean; + isLoadingMore: boolean; + loadMoreCount: number; + onLoadMore: () => void; + /** Stats line (hidden while live tailing / refreshing). */ + showStats: boolean; + bytesConsumed: number; + elapsedMs: number | null; +}; + +const RangeLabel = ({ + continuousMode, + windowSize, + totalLoaded, + pageIndex, + pageSize, + trimmedCount, +}: Pick< + MessagesFooterProps, + 'continuousMode' | 'windowSize' | 'totalLoaded' | 'pageIndex' | 'pageSize' | 'trimmedCount' +>) => { + if (continuousMode) { + return ( + + Showing {windowSize} in window · {totalLoaded} loaded + {trimmedCount > 0 && ( + + + {trimmedCount} older trimmed + + )} + + ); + } + if (totalLoaded === 0) { + return No messages loaded; + } + const start = pageIndex * pageSize + 1; + const end = Math.min((pageIndex + 1) * pageSize, totalLoaded); + return ( + + {start}–{end} of {totalLoaded} loaded + + ); +}; + +export const MessagesFooter = ({ + totalLoaded, + pageIndex, + pageSize, + onPageChange, + continuousMode, + windowSize, + windowCap, + trimmedCount, + canLoadMore, + isLoadingMore, + loadMoreCount, + onLoadMore, + showStats, + bytesConsumed, + elapsedMs, +}: MessagesFooterProps) => { + const pageCount = Math.max(1, Math.ceil(totalLoaded / pageSize)); + + return ( +
+
+ + {continuousMode ? ( +
+ + + {windowSize} in buffer · window {windowCap} + + {canLoadMore && ( + + )} +
+ ) : ( + + )} +
+ {showStats && (bytesConsumed > 0 || elapsedMs !== null) && ( +
+ + + {prettyBytes(bytesConsumed)} + + {elapsedMs !== null && ( + + + {Math.round(elapsedMs)}ms + + )} +
+ )} +
+ ); +}; diff --git a/frontend/src/components/pages/topics/messages/table/messages-table.tsx b/frontend/src/components/pages/topics/messages/table/messages-table.tsx new file mode 100644 index 0000000000..78afc165fe --- /dev/null +++ b/frontend/src/components/pages/topics/messages/table/messages-table.tsx @@ -0,0 +1,279 @@ +/** + * Copyright 2025 Redpanda Data, Inc. + * + * Use of this software is governed by the Business Source License + * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md + * + * As of the Change Date specified in that file, in accordance with + * the Business Source License, use of this software will be governed + * by the Apache License, Version 2.0 + */ + +import { + type ColumnDef, + flexRender, + getCoreRowModel, + getPaginationRowModel, + getSortedRowModel, + type OnChangeFn, + type PaginationState, + type SortingState, + useReactTable, +} from '@tanstack/react-table'; +import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from 'components/redpanda-ui/components/empty'; +import { Skeleton } from 'components/redpanda-ui/components/skeleton'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from 'components/redpanda-ui/components/table'; +import { cn } from 'components/redpanda-ui/lib/utils'; +import { ArrowDownIcon, ArrowUpIcon } from 'lucide-react'; +import { Fragment, useMemo } from 'react'; + +import { KeyCell, OffsetCell, SizeCell, TimestampCell, ValueCell, type ValuePreviewConfig } from './message-cells'; +import type { TopicMessage } from '../../../../../state/rest-interfaces'; +import type { TimestampDisplayFormat } from '../../../../../state/ui'; +import { COLUMN_LABELS } from '../constants'; +import { messageKey } from '../hooks/use-message-search'; +import type { MessageColumnConfig, RowDensity } from '../types'; + +export type MessagesTableProps = { + messages: TopicMessage[]; + columnConfig: MessageColumnConfig[]; + density: RowDensity; + timestampFormat: TimestampDisplayFormat; + sorting: SortingState; + onSortingChange: OnChangeFn; + /** Sorting is unavailable in continuous mode — server order must be preserved for paging. */ + sortingDisabled: boolean; + pagination: PaginationState; + onPaginationChange: OnChangeFn; + isLoading: boolean; + /** True while live tail is streaming with no rows yet. */ + isLiveWaiting: boolean; + hasActiveFilter: boolean; + selectedKey: string | null; + onRowClick: (msg: TopicMessage) => void; + /** Rows that arrived in the last few seconds; they get the flash animation. */ + newKeys: ReadonlySet; + /** Rendered before the first row that existed before live tail started. */ + liveSeparatorKey?: string | null; + /** Preview-fields rendering for the value column (from view settings). */ + valuePreview?: ValuePreviewConfig; +}; + +const buildColumn = ( + config: MessageColumnConfig, + density: RowDensity, + timestampFormat: TimestampDisplayFormat, + sortingDisabled: boolean, + valuePreview?: ValuePreviewConfig +): ColumnDef => { + const base: ColumnDef = { + id: config.id, + header: COLUMN_LABELS[config.id], + enableSorting: false, + }; + switch (config.id) { + case 'offset': + return { ...base, accessorKey: 'offset', cell: ({ row }) => }; + case 'partitionID': + return { + ...base, + accessorKey: 'partitionID', + cell: ({ row }) => {row.original.partitionID}, + }; + case 'timestamp': + return { + ...base, + accessorKey: 'timestamp', + enableSorting: !sortingDisabled, + cell: ({ row }) => , + }; + case 'key': + return { ...base, accessorKey: 'keyJson', cell: ({ row }) => }; + case 'value': + return { + ...base, + accessorKey: 'valueJson', + cell: ({ row }) => , + }; + case 'keySize': + return { ...base, accessorKey: 'key.size', cell: ({ row }) => }; + case 'valueSize': + return { ...base, accessorKey: 'value.size', cell: ({ row }) => }; + default: + return base; + } +}; + +const LoadingRows = ({ columnCount }: { columnCount: number }) => ( + <> + {Array.from({ length: 5 }, (_, rowIdx) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: static skeleton rows + + {Array.from({ length: columnCount }, (_, colIdx) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: static skeleton cells + + + + ))} + + ))} + +); + +const LiveWaitingState = () => ( + + + Waiting for messages… + Streaming live from the topic. New records will appear here as they arrive. + +
+ + + +
+
+); + +const LiveSeparatorRow = ({ columnCount }: { columnCount: number }) => ( + + +
+ + + Live + + New messages appear above as they arrive +
+
+
+); + +export const MessagesTable = ({ + messages, + columnConfig, + density, + timestampFormat, + sorting, + onSortingChange, + sortingDisabled, + pagination, + onPaginationChange, + isLoading, + isLiveWaiting, + hasActiveFilter, + selectedKey, + onRowClick, + newKeys, + liveSeparatorKey, + valuePreview, +}: MessagesTableProps) => { + // All configured columns are registered (so sorting can reference hidden ones, + // e.g. the offset tiebreaker); visibility is controlled through table state. + const columns = useMemo( + () => columnConfig.map((config) => buildColumn(config, density, timestampFormat, sortingDisabled, valuePreview)), + [columnConfig, density, timestampFormat, sortingDisabled, valuePreview] + ); + + const columnVisibility = useMemo( + () => Object.fromEntries(columnConfig.map((c) => [c.id, c.visible])), + [columnConfig] + ); + + const table = useReactTable({ + data: messages, + columns, + state: { sorting, pagination, columnVisibility }, + onSortingChange, + onPaginationChange, + // Pagination is controlled through URL state; auto-reset would fire + // onPaginationChange on every data change and loop with the URL updates. + autoResetPageIndex: false, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getRowId: (msg) => messageKey(msg), + }); + + const rows = table.getRowModel().rows; + const visibleColumnCount = table.getVisibleLeafColumns().length; + const showEmpty = !isLoading && rows.length === 0; + + return ( +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const canSort = header.column.getCanSort(); + const sortDir = header.column.getIsSorted(); + return ( + + + {flexRender(header.column.columnDef.header, header.getContext())} + {sortDir === 'asc' && } + {sortDir === 'desc' && } + + + ); + })} + + ))} + + + {isLoading && rows.length === 0 && } + {rows.map((row) => { + const key = messageKey(row.original); + return ( + + {liveSeparatorKey === key && } + onRowClick(row.original)} + > + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + + ); + })} + +
+ {showEmpty && isLiveWaiting && } + {showEmpty && !isLiveWaiting && ( + + + {hasActiveFilter ? 'No messages match your filter.' : 'No messages'} + + {hasActiveFilter + ? 'Clear the filter or widen the offset range to see records.' + : 'This topic returned no records for the current read scope.'} + + + + )} +
+ ); +}; diff --git a/frontend/src/components/pages/topics/messages/toolbar/filter-bar.test.tsx b/frontend/src/components/pages/topics/messages/toolbar/filter-bar.test.tsx new file mode 100644 index 0000000000..85418489fd --- /dev/null +++ b/frontend/src/components/pages/topics/messages/toolbar/filter-bar.test.tsx @@ -0,0 +1,219 @@ +/** + * Copyright 2025 Redpanda Data, Inc. + * + * Use of this software is governed by the Business Source License + * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md + * + * As of the Change Date specified in that file, in accordance with + * the Business Source License, use of this software will be governed + * by the Apache License, Version 2.0 + */ + +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useState } from 'react'; +import { describe, expect, test, vi } from 'vitest'; + +import { FilterBar, type FilterBarProps } from './filter-bar'; +import type { TopicMessage } from '../../../../../state/rest-interfaces'; +import type { FieldFilterToken } from '../types'; + +const makeMsg = (partitionID: number, offset: number, value: unknown): TopicMessage => + ({ + partitionID, + offset, + timestamp: 0, + compression: 'uncompressed', + isTransactional: false, + headers: [], + key: { payload: `key-${offset}`, isPayloadNull: false, size: 1 }, + value: { payload: value, isPayloadNull: false, size: 1 }, + keyJson: `"key-${offset}"`, + valueJson: JSON.stringify(value), + keyBinHexPreview: '', + valueBinHexPreview: '', + }) as TopicMessage; + +const messages = [makeMsg(0, 1, { type: 'INVOICE' }), makeMsg(1, 2, { type: 'ORDER' })]; + +/** Stateful wrapper: the bar's inputs are controlled, so tests need live state. */ +const StatefulBar = (props: Partial & { spies: FilterBarProps }) => { + const [quickSearch, setQuickSearch] = useState(props.quickSearch ?? ''); + const [fieldTokens, setFieldTokens] = useState(props.fieldTokens ?? []); + const [partitionId, setPartitionId] = useState(props.partitionId ?? -1); + const { spies } = props; + return ( + { + setFieldTokens(tokens); + spies.onFieldTokensChange(tokens); + }} + onPartitionIdChange={(id) => { + setPartitionId(id); + spies.onPartitionIdChange(id); + }} + onQuickSearchChange={(q) => { + setQuickSearch(q); + spies.onQuickSearchChange(q); + }} + partitionId={partitionId} + quickSearch={quickSearch} + /> + ); +}; + +const renderBar = (overrides: Partial = {}) => { + const spies: FilterBarProps = { + messages, + quickSearch: '', + onQuickSearchChange: vi.fn(), + fieldTokens: [], + onFieldTokensChange: vi.fn(), + partitionId: -1, + onPartitionIdChange: vi.fn(), + jsFilters: [], + onEditJsFilter: vi.fn(), + onRemoveJsFilter: vi.fn(), + canUseJsFilters: true, + }; + render(); + return spies; +}; + +const input = () => screen.getByTestId('messages-filter-input'); + +describe('FilterBar', () => { + test('focusing opens grouped suggestions', async () => { + renderBar(); + await userEvent.click(input()); + expect(screen.getByText('partition:')).toBeInTheDocument(); + expect(screen.getByText('js:')).toBeInTheDocument(); + expect(screen.getByText('value:')).toBeInTheDocument(); + }); + + test('partition flow: pick field, pick value, commits partition id', async () => { + const props = renderBar(); + await userEvent.click(input()); + await userEvent.click(screen.getByText('partition:')); + // Pending mode lists distinct partition values with counts + await userEvent.click(screen.getByText('1')); + expect(props.onPartitionIdChange).toHaveBeenCalledWith(1); + // focus returns to the input, right after the freshly committed badge + expect(input()).toHaveFocus(); + }); + + test('typed offset token commits a field token on Enter', async () => { + const props = renderBar(); + await userEvent.click(input()); + await userEvent.keyboard('offset>1'); + await userEvent.keyboard('{ArrowDown}{Enter}'); + expect(props.onFieldTokensChange).toHaveBeenCalledWith([{ kind: 'field', field: 'offset', op: 'gt', value: '1' }]); + }); + + test('Tab accepts the ghost completion into pending mode', async () => { + const props = renderBar(); + await userEvent.click(input()); + await userEvent.keyboard('par'); + await userEvent.keyboard('{Tab}'); + // pending pill for partition appears, nothing committed yet + expect(screen.getByText('partition:')).toBeInTheDocument(); + expect(props.onPartitionIdChange).not.toHaveBeenCalled(); + }); + + test('typing partition: shows the possible values; picking one commits', async () => { + const props = renderBar(); + await userEvent.click(input()); + await userEvent.keyboard('partition:'); + // distinct partition values appear with counts, like pending mode + expect(screen.getAllByText('1 msg')).toHaveLength(2); + await userEvent.click(screen.getByText('1')); + expect(props.onPartitionIdChange).toHaveBeenCalledWith(1); + }); + + test('typed partition:1 commits the partition badge', async () => { + const props = renderBar(); + await userEvent.click(input()); + await userEvent.keyboard('partition:1'); + await userEvent.keyboard('{ArrowDown}{Enter}'); + expect(props.onPartitionIdChange).toHaveBeenCalledWith(1); + expect(screen.getByTitle('Click to edit the partition filter')).toHaveTextContent('partition:1'); + }); + + test('ArrowLeft unwraps the last chip into editable text', async () => { + const props = renderBar({ partitionId: 1 }); + await userEvent.click(input()); + await userEvent.keyboard('{ArrowLeft}'); + // the badge turns back into text: partition cleared, its token in the input + expect(props.onPartitionIdChange).toHaveBeenCalledWith(-1); + expect(input()).toHaveValue('partition:1'); + }); + + test('unwrapped chip can be edited and recommitted (partition:1 -> partition:2)', async () => { + const props = renderBar({ partitionId: 1 }); + await userEvent.click(input()); + await userEvent.keyboard('{ArrowLeft}'); + expect(input()).toHaveValue('partition:1'); + await userEvent.clear(input()); + await userEvent.type(input(), 'partition:2'); + await userEvent.keyboard('{Enter}'); + expect(props.onPartitionIdChange).toHaveBeenLastCalledWith(2); + expect(screen.getByTitle('Click to edit the partition filter')).toHaveTextContent('partition:2'); + }); + + test('editing a middle chip keeps its position on recommit', async () => { + const props = renderBar({ + fieldTokens: [ + { kind: 'field', field: 'key', op: 'contains', value: 'abc' }, + { kind: 'field', field: 'value', op: 'contains', value: 'x' }, + ], + }); + // click the first chip to unwrap it into the input + await userEvent.click(screen.getByText('key:abc')); + expect(input()).toHaveValue('key:abc'); + await userEvent.clear(input()); + await userEvent.type(input(), 'key:zzz'); + await userEvent.keyboard('{Enter}'); + // the edited filter stays first — it must not jump to the end + expect(props.onFieldTokensChange).toHaveBeenLastCalledWith([ + { kind: 'field', field: 'key', op: 'contains', value: 'zzz' }, + { kind: 'field', field: 'value', op: 'contains', value: 'x' }, + ]); + }); + + test('Backspace on empty input removes the last chip', async () => { + const tokens: FieldFilterToken[] = [{ kind: 'field', field: 'key', op: 'contains', value: 'abc' }]; + const props = renderBar({ fieldTokens: tokens }); + await userEvent.click(input()); + await userEvent.keyboard('{Backspace}'); + expect(props.onFieldTokensChange).toHaveBeenCalledWith([]); + }); + + test('chips render and clear-all removes everything', async () => { + const props = renderBar({ + fieldTokens: [{ kind: 'field', field: 'key', op: 'contains', value: 'abc' }], + partitionId: 2, + }); + expect(screen.getByText('key:abc')).toBeInTheDocument(); + expect(screen.getByText('partition:2')).toBeInTheDocument(); + await userEvent.click(screen.getByTitle('Clear all filters')); + expect(props.onFieldTokensChange).toHaveBeenCalledWith([]); + expect(props.onPartitionIdChange).toHaveBeenCalledWith(-1); + expect(props.onQuickSearchChange).toHaveBeenCalledWith(''); + }); + + test('js suggestion opens the editor; hidden without permission', async () => { + const props = renderBar(); + await userEvent.click(input()); + await userEvent.click(screen.getByText('js:')); + expect(props.onEditJsFilter).toHaveBeenCalledWith(null, undefined); + }); + + test('js: is not offered when JS filters are unavailable', async () => { + renderBar({ canUseJsFilters: false }); + await userEvent.click(input()); + expect(screen.queryByText('js:')).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/pages/topics/messages/toolbar/filter-bar.tsx b/frontend/src/components/pages/topics/messages/toolbar/filter-bar.tsx new file mode 100644 index 0000000000..3e4f2ad6d1 --- /dev/null +++ b/frontend/src/components/pages/topics/messages/toolbar/filter-bar.tsx @@ -0,0 +1,478 @@ +/** + * Copyright 2025 Redpanda Data, Inc. + * + * Use of this software is governed by the Business Source License + * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md + * + * As of the Change Date specified in that file, in accordance with + * the Business Source License, use of this software will be governed + * by the Apache License, Version 2.0 + */ + +import { Kbd } from 'components/redpanda-ui/components/kbd'; +import { cn } from 'components/redpanda-ui/lib/utils'; +import { SearchIcon, XIcon } from 'lucide-react'; +import { type KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { + buildSuggestions, + computeGhost, + type RecentSearch, + type SuggestionAction, + type SuggestionItem, +} from './filter-suggestions'; +import type { TopicMessage } from '../../../../../state/rest-interfaces'; +import type { FilterEntry } from '../../../../../state/ui'; +import type { FieldFilterToken } from '../types'; +import { formatTokenText, tokenEditText } from '../utils/filter-token'; + +const MAX_RECENTS = 4; + +export type FilterBarProps = { + messages: TopicMessage[]; + quickSearch: string; + onQuickSearchChange: (query: string) => void; + fieldTokens: FieldFilterToken[]; + onFieldTokensChange: (tokens: FieldFilterToken[]) => void; + /** Partition selection lives in the URL (`p`); shown here as a removable chip when set. */ + partitionId: number; + onPartitionIdChange: (partitionId: number) => void; + jsFilters: FilterEntry[]; + onEditJsFilter: (filter: FilterEntry | null, seedCode?: string) => void; + onRemoveJsFilter: (id: string) => void; + canUseJsFilters: boolean; +}; + +const Chip = ({ + text, + title, + onEdit, + onRemove, +}: { + text: string; + title: string; + onEdit?: () => void; + onRemove: () => void; +}) => ( + + + + +); + +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: single interactive control combining chips, ghost completion and keyboard navigation +export const FilterBar = ({ + messages, + quickSearch, + onQuickSearchChange, + fieldTokens, + onFieldTokensChange, + partitionId, + onPartitionIdChange, + jsFilters, + onEditJsFilter, + onRemoveJsFilter, + canUseJsFilters, +}: FilterBarProps) => { + const [open, setOpen] = useState(false); + const [pendingField, setPendingField] = useState(null); + const [pendingValue, setPendingValue] = useState(''); + const [activeIdx, setActiveIdx] = useState(0); + const [recents, setRecents] = useState([]); + const containerRef = useRef(null); + const inputRef = useRef(null); + // While a chip is unwrapped for textual editing, remembers its position in + // fieldTokens so recommitting puts it back in place instead of appending. + const editSlotRef = useRef(null); + + const query = pendingField ? pendingValue : quickSearch; + + const suggestionsInput = useMemo( + () => ({ query, pendingField, messages, recents, canUseJsFilters }), + [query, pendingField, messages, recents, canUseJsFilters] + ); + const { heading, items } = useMemo(() => buildSuggestions(suggestionsInput), [suggestionsInput]); + const ghost = useMemo(() => computeGhost(suggestionsInput), [suggestionsInput]); + const actionableItems = useMemo( + () => items.filter((i): i is SuggestionItem & { kind: 'item' } => i.kind === 'item'), + [items] + ); + + // Close on outside click + useEffect(() => { + const onMouseDown = (e: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setOpen(false); + setPendingField(null); + setPendingValue(''); + editSlotRef.current = null; + } + }; + document.addEventListener('mousedown', onMouseDown); + return () => document.removeEventListener('mousedown', onMouseDown); + }, []); + + const recordRecent = useCallback((recent: RecentSearch) => { + setRecents((prev) => [recent, ...prev.filter((r) => r.label !== recent.label)].slice(0, MAX_RECENTS)); + }, []); + + // Unwrap a committed badge back into plain text in the input, caret at the + // end, so it can be edited textually and recommitted with Enter. + const editChipAsText = useCallback( + (text: string) => { + onQuickSearchChange(text); + setOpen(true); + const el = inputRef.current; + el?.focus(); + // caret placement must wait for the controlled value to render + setTimeout(() => el?.setSelectionRange(text.length, text.length), 0); + }, + [onQuickSearchChange] + ); + + const applyAction = useCallback( + (action: SuggestionAction) => { + switch (action.type) { + case 'set-pending': + setPendingField(action.field); + setPendingValue(''); + setActiveIdx(0); + inputRef.current?.focus(); + break; + case 'commit-field': { + if (action.field === 'partition') { + onPartitionIdChange(Number(action.value)); + } else { + const token = { kind: 'field', field: action.field, op: action.op, value: action.value } as const; + const next = [...fieldTokens]; + const slot = editSlotRef.current; + next.splice(slot !== null && slot >= 0 && slot <= next.length ? slot : next.length, 0, token); + onFieldTokensChange(next); + } + editSlotRef.current = null; + recordRecent({ label: formatTokenText({ kind: 'field', ...action }), action }); + setPendingField(null); + setPendingValue(''); + onQuickSearchChange(''); + setActiveIdx(0); + // keep typing where the badge just landed — the input sits right after it + inputRef.current?.focus(); + break; + } + case 'fill-text': + setPendingField(null); + setPendingValue(''); + editChipAsText(action.text); + break; + case 'open-js': + setOpen(false); + setPendingField(null); + setPendingValue(''); + onQuickSearchChange(''); + onEditJsFilter(null, action.code); + break; + default: + break; + } + }, + [ + fieldTokens, + onFieldTokensChange, + onPartitionIdChange, + onQuickSearchChange, + onEditJsFilter, + recordRecent, + editChipAsText, + ] + ); + + // One ordered model for every badge — rendering and keyboard editing share it. + const chipDescriptors = useMemo(() => { + const chips: { key: string; text: string; title: string; edit?: () => void; remove: () => void }[] = []; + if (partitionId >= 0) { + chips.push({ + key: 'partition', + text: `partition:${partitionId}`, + title: 'Click to edit the partition filter', + edit: () => { + editSlotRef.current = null; + onPartitionIdChange(-1); + editChipAsText(`partition:${partitionId}`); + }, + remove: () => onPartitionIdChange(-1), + }); + } + fieldTokens.forEach((token, i) => { + chips.push({ + key: `field-${formatTokenText(token)}`, + text: formatTokenText(token), + title: 'Click to edit this filter', + edit: () => { + editSlotRef.current = i; + onFieldTokensChange(fieldTokens.filter((_, idx) => idx !== i)); + editChipAsText(tokenEditText(token)); + }, + remove: () => onFieldTokensChange(fieldTokens.filter((_, idx) => idx !== i)), + }); + }); + for (const filter of jsFilters) { + chips.push({ + key: `js-${filter.id}`, + text: `ƒ ${filter.name || filter.code}`, + title: filter.name ? `${filter.name}: ${filter.code}` : 'Edit JavaScript filter', + edit: () => onEditJsFilter(filter), + remove: () => onRemoveJsFilter(filter.id), + }); + } + return chips; + }, [ + partitionId, + onPartitionIdChange, + editChipAsText, + fieldTokens, + onFieldTokensChange, + jsFilters, + onEditJsFilter, + onRemoveJsFilter, + ]); + + const removeLastChip = useCallback(() => { + if (fieldTokens.length > 0) { + onFieldTokensChange(fieldTokens.slice(0, -1)); + return; + } + const lastJs = jsFilters.at(-1); + if (lastJs) { + onRemoveJsFilter(lastJs.id); + return; + } + if (partitionId >= 0) { + onPartitionIdChange(-1); + } + }, [fieldTokens, onFieldTokensChange, jsFilters, onRemoveJsFilter, partitionId, onPartitionIdChange]); + + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: one keyboard state machine for suggestions + chip cursor + const onKeyDown = useCallback( + (e: KeyboardEvent) => { + const caretAtStart = e.currentTarget.selectionStart === 0 && e.currentTarget.selectionEnd === 0; + + switch (e.key) { + case 'ArrowLeft': { + // ArrowLeft on an empty input walks back into the last badge: it + // unwraps into editable text so the value can be changed in place. + const last = chipDescriptors.at(-1); + if (caretAtStart && !pendingField && query.length === 0 && last?.edit) { + e.preventDefault(); + last.edit(); + } + break; + } + case 'ArrowDown': + e.preventDefault(); + setOpen(true); + setActiveIdx((i) => Math.min(i + 1, actionableItems.length - 1)); + break; + case 'ArrowUp': + e.preventDefault(); + setActiveIdx((i) => Math.max(i - 1, 0)); + break; + case 'Enter': { + const active = actionableItems[activeIdx]; + if (open && active) { + e.preventDefault(); + applyAction(active.action); + } else { + setOpen(false); + } + break; + } + case 'Tab': + if (ghost) { + e.preventDefault(); + applyAction(ghost.action); + } + break; + case 'Backspace': + if (query.length === 0) { + e.preventDefault(); + if (pendingField) { + setPendingField(null); + } else { + removeLastChip(); + } + } + break; + case 'Escape': + setOpen(false); + setPendingField(null); + setPendingValue(''); + editSlotRef.current = null; + inputRef.current?.blur(); + break; + default: + break; + } + }, + [actionableItems, activeIdx, open, applyAction, ghost, query, pendingField, removeLastChip, chipDescriptors] + ); + + const hasChips = fieldTokens.length > 0 || jsFilters.length > 0 || partitionId >= 0; + + const clearAll = useCallback(() => { + onFieldTokensChange([]); + for (const filter of jsFilters) { + onRemoveJsFilter(filter.id); + } + onPartitionIdChange(-1); + onQuickSearchChange(''); + }, [onFieldTokensChange, jsFilters, onRemoveJsFilter, onPartitionIdChange, onQuickSearchChange]); + + const placeholder = pendingField + ? `Select or type ${pendingField}…` + : hasChips + ? 'Add another filter…' + : 'Filter — type or pick a field…'; + + let itemIdx = -1; + + return ( +
+
inputRef.current?.focus()} + onKeyDown={() => { + // clicks focus the input; keyboard handling lives on the input itself + }} + > + + {chipDescriptors.map((chip) => ( + + ))} + {pendingField && ( + + {pendingField}: + + + )} + + {ghost && ( + + {query} + {ghost.rest} + + )} + { + if (pendingField) { + setPendingValue(e.target.value); + } else { + onQuickSearchChange(e.target.value); + } + setActiveIdx(0); + setOpen(true); + }} + onFocus={() => setOpen(true)} + onKeyDown={onKeyDown} + placeholder={placeholder} + ref={inputRef} + value={query} + /> + + {hasChips && ( + + )} +
+ + {open && items.length > 0 && ( +
+
+ {heading} +
+ {items.map((item, i) => { + if (item.kind === 'header') { + return ( +
+ {item.label} +
+ ); + } + itemIdx += 1; + const isActive = itemIdx === activeIdx; + return ( + + ); + })} +
+ + ↑↓ navigate + + + select + + + edit last + + + remove last + + + esc close + +
+
+ )} +
+ ); +}; diff --git a/frontend/src/components/pages/topics/messages/toolbar/filter-suggestions.test.ts b/frontend/src/components/pages/topics/messages/toolbar/filter-suggestions.test.ts new file mode 100644 index 0000000000..620ddeca5b --- /dev/null +++ b/frontend/src/components/pages/topics/messages/toolbar/filter-suggestions.test.ts @@ -0,0 +1,138 @@ +/** + * Copyright 2025 Redpanda Data, Inc. + * + * Use of this software is governed by the Business Source License + * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md + * + * As of the Change Date specified in that file, in accordance with + * the Business Source License, use of this software will be governed + * by the Apache License, Version 2.0 + */ + +import { describe, expect, test } from 'vitest'; + +import { buildSuggestions, computeGhost, type SuggestionsInput } from './filter-suggestions'; +import type { TopicMessage } from '../../../../../state/rest-interfaces'; + +const makeMsg = (partitionID: number, offset: number, value: unknown): TopicMessage => + ({ + partitionID, + offset, + timestamp: 0, + compression: 'uncompressed', + isTransactional: false, + headers: [], + key: { payload: `key-${offset}`, isPayloadNull: false, size: 1 }, + value: { payload: value, isPayloadNull: false, size: 1 }, + keyJson: `"key-${offset}"`, + valueJson: JSON.stringify(value), + keyBinHexPreview: '', + valueBinHexPreview: '', + }) as TopicMessage; + +const messages = [ + makeMsg(0, 1, { type: 'INVOICE', address: { city: 'Berlin' } }), + makeMsg(0, 2, { type: 'ORDER', address: { city: 'Paris' } }), + makeMsg(1, 3, { type: 'INVOICE', address: { city: 'Berlin' } }), +]; + +const input = (overrides: Partial): SuggestionsInput => ({ + query: '', + pendingField: null, + messages, + recents: [], + canUseJsFilters: true, + ...overrides, +}); + +const itemLabels = (result: ReturnType) => + result.items.filter((i) => i.kind === 'item').map((i) => i.label); + +describe('buildSuggestions', () => { + test('default view groups filters and fields', () => { + const result = buildSuggestions(input({})); + expect(itemLabels(result)).toEqual(['partition:', 'js:', 'value:', 'key:', 'offset:']); + }); + + test('recents appear first when present', () => { + const result = buildSuggestions( + input({ + recents: [{ label: 'partition:1', action: { type: 'commit-field', field: 'partition', op: 'eq', value: '1' } }], + }) + ); + expect(itemLabels(result)[0]).toBe('partition:1'); + }); + + test('js suggestions are hidden without the permission', () => { + const result = buildSuggestions(input({ canUseJsFilters: false })); + expect(itemLabels(result)).not.toContain('js:'); + }); + + test('pending partition lists distinct values with counts', () => { + const result = buildSuggestions(input({ pendingField: 'partition' })); + expect(result.heading).toBe('Value for Partition'); + const items = result.items.filter((i) => i.kind === 'item'); + expect(items[0]).toMatchObject({ label: '0', sub: '2 msgs' }); + expect(items[1]).toMatchObject({ label: '1', sub: '1 msg' }); + }); + + test('pending offset with typed value offers comparisons', () => { + const result = buildSuggestions(input({ pendingField: 'offset', query: '2' })); + expect(itemLabels(result)).toEqual(['offset > 2', 'offset < 2', 'offset = 2']); + }); + + test('value path traversal suggests nested fields', () => { + const result = buildSuggestions(input({ query: 'value.add' })); + expect(itemLabels(result)).toContain('value.address'); + expect(itemLabels(result)).toContain('value.address.city'); + }); + + test('js: prefix routes to the editor', () => { + const result = buildSuggestions(input({ query: 'js: value.type === "ORDER"' })); + expect(result.heading).toBe('JavaScript'); + const item = result.items.find((i) => i.kind === 'item'); + expect(item?.action).toEqual({ type: 'open-js', code: 'value.type === "ORDER"' }); + }); + + test('typing a bare field: lists its possible values like pending mode', () => { + const result = buildSuggestions(input({ query: 'partition:' })); + expect(result.heading).toBe('Value for Partition'); + const items = result.items.filter((i) => i.kind === 'item'); + expect(items[0]).toMatchObject({ label: '0', sub: '2 msgs' }); + expect(items[1]).toMatchObject({ label: '1', sub: '1 msg' }); + + const keyResult = buildSuggestions(input({ query: 'key:' })); + expect(keyResult.heading).toBe('Value for key'); + expect(itemLabels(keyResult).length).toBeGreaterThan(0); + }); + + test('typed field token shows the parsed filter with match count', () => { + const result = buildSuggestions(input({ query: 'partition:1' })); + const item = result.items.find((i) => i.kind === 'item'); + expect(item).toMatchObject({ label: 'partition:1', sub: '1 msg' }); + expect(item?.kind === 'item' && item.action).toEqual({ + type: 'commit-field', + field: 'partition', + op: 'eq', + value: '1', + }); + }); +}); + +describe('computeGhost', () => { + test('completes field names with a trailing colon', () => { + const ghost = computeGhost(input({ query: 'par' })); + expect(ghost?.rest).toBe('tition:'); + expect(ghost?.action).toEqual({ type: 'set-pending', field: 'partition' }); + }); + + test('completes pending values toward the most common match', () => { + const ghost = computeGhost(input({ pendingField: 'value.type', query: 'INV' })); + expect(ghost?.rest).toBe('OICE'); + }); + + test('returns null when nothing completes', () => { + expect(computeGhost(input({ query: 'zzz' }))).toBeNull(); + expect(computeGhost(input({ query: '' }))).toBeNull(); + }); +}); diff --git a/frontend/src/components/pages/topics/messages/toolbar/filter-suggestions.ts b/frontend/src/components/pages/topics/messages/toolbar/filter-suggestions.ts new file mode 100644 index 0000000000..01a4728444 --- /dev/null +++ b/frontend/src/components/pages/topics/messages/toolbar/filter-suggestions.ts @@ -0,0 +1,270 @@ +/** + * Copyright 2025 Redpanda Data, Inc. + * + * Use of this software is governed by the Business Source License + * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md + * + * As of the Change Date specified in that file, in accordance with + * the Business Source License, use of this software will be governed + * by the Apache License, Version 2.0 + */ + +import type { TopicMessage } from '../../../../../state/rest-interfaces'; +import type { FilterOp } from '../types'; +import { distinctFieldValues, matchesFieldFilter, valuePaths } from '../utils/client-match'; +import { looksLikeJs, parseFilterInput, stripJsPrefix } from '../utils/filter-token'; + +/** What selecting a suggestion does; interpreted by the filter bar. */ +export type SuggestionAction = + | { type: 'set-pending'; field: string } + | { type: 'commit-field'; field: string; op: FilterOp; value: string } + | { type: 'fill-text'; text: string } + | { type: 'open-js'; code?: string }; + +export type SuggestionItem = + | { kind: 'header'; label: string } + | { kind: 'item'; label: string; sub?: string; action: SuggestionAction }; + +export type RecentSearch = { label: string; action: SuggestionAction }; + +export type SuggestionsInput = { + query: string; + pendingField: string | null; + messages: TopicMessage[]; + recents: RecentSearch[]; + canUseJsFilters: boolean; +}; + +const msgs = (n: number) => (n === 1 ? '1 msg' : `${n} msgs`); + +const countMatches = (messages: TopicMessage[], field: string, op: FilterOp, value: string) => + messages.filter((m) => matchesFieldFilter(m, field, op, value)).length; + +const pendingValueItems = ({ query, pendingField, messages }: SuggestionsInput): SuggestionItem[] => { + const field = pendingField as string; + const items: SuggestionItem[] = []; + + if (field === 'offset' && query) { + for (const op of ['gt', 'lt', 'eq'] as const) { + const symbol = { gt: '>', lt: '<', eq: '=' }[op]; + items.push({ + kind: 'item', + label: `offset ${symbol} ${query}`, + sub: msgs(countMatches(messages, 'offset', op, query)), + action: { type: 'commit-field', field, op, value: query }, + }); + } + return items; + } + + for (const { value, count } of distinctFieldValues(messages, field, query)) { + items.push({ + kind: 'item', + label: value, + sub: msgs(count), + action: { type: 'commit-field', field, op: field === 'partition' ? 'eq' : 'contains', value }, + }); + } + if (query && field !== 'partition') { + items.push({ + kind: 'item', + label: `${field} contains "${query}"`, + sub: msgs(countMatches(messages, field, 'contains', query)), + action: { type: 'commit-field', field, op: 'contains', value: query }, + }); + } + return items; +}; + +/** `value:` / `value.` traversal: suggest nested paths, then values at a leaf. */ +const nestedValueItems = (input: SuggestionsInput): SuggestionItem[] | null => { + const match = /^value[.:](?[\w.-]*)$/.exec(input.query.trim()); + if (!match?.groups) { + return null; + } + const typedPath = match.groups.path; + const paths = valuePaths(input.messages, typedPath); + if (paths.length === 0) { + return null; + } + const items: SuggestionItem[] = [{ kind: 'header', label: 'Fields under value' }]; + for (const path of paths.slice(0, 6)) { + items.push({ + kind: 'item', + label: `value.${path}`, + sub: 'field', + action: { type: 'set-pending', field: `value.${path}` }, + }); + } + return items; +}; + +const typedTokenItems = (input: SuggestionsInput): SuggestionItem[] | null => { + const parsed = parseFilterInput(input.query); + if (!parsed) { + return null; + } + return [ + { kind: 'header', label: 'Field filter' }, + { + kind: 'item', + label: input.query.trim(), + sub: msgs(countMatches(input.messages, parsed.field, parsed.op, parsed.value)), + action: { type: 'commit-field', ...parsed }, + }, + ]; +}; + +const defaultGroupItems = ({ query, recents, canUseJsFilters, messages }: SuggestionsInput): SuggestionItem[] => { + const items: SuggestionItem[] = []; + + if (!query && recents.length > 0) { + items.push({ kind: 'header', label: 'Recent searches' }); + for (const recent of recents.slice(0, 3)) { + items.push({ kind: 'item', label: recent.label, action: recent.action }); + } + } + + items.push({ kind: 'header', label: 'Filters' }); + items.push({ + kind: 'item', + label: 'partition:', + sub: 'filter by partition', + action: { type: 'set-pending', field: 'partition' }, + }); + if (canUseJsFilters) { + items.push({ + kind: 'item', + label: 'js:', + sub: 'JavaScript predicate', + action: { type: 'open-js' }, + }); + } + + items.push({ kind: 'header', label: 'Fields' }); + items.push({ + kind: 'item', + label: 'value:', + sub: 'search entire value', + action: { type: 'fill-text', text: 'value:' }, + }); + items.push({ kind: 'item', label: 'key:', sub: 'search keys', action: { type: 'set-pending', field: 'key' } }); + items.push({ + kind: 'item', + label: 'offset:', + sub: 'compare offsets', + action: { type: 'set-pending', field: 'offset' }, + }); + + if (query) { + const fullTextCount = messages.filter( + (m) => + String(m.offset).includes(query.toLowerCase()) || + m.keyJson?.toLowerCase().includes(query.toLowerCase()) || + m.valueJson?.toLowerCase().includes(query.toLowerCase()) + ).length; + items.unshift( + { kind: 'header', label: 'Full text' }, + { + kind: 'item', + label: `Search "${query}"`, + sub: msgs(fullTextCount), + action: { type: 'fill-text', text: query }, + } + ); + if (canUseJsFilters && looksLikeJs(query)) { + items.unshift( + { kind: 'header', label: 'JavaScript' }, + { + kind: 'item', + label: `ƒ ${stripJsPrefix(query)}`, + sub: 'open editor', + action: { type: 'open-js', code: stripJsPrefix(query) }, + } + ); + } + } + + return items; +}; + +/** Heading + item list for the autocomplete dropdown, given the current input state. */ +export function buildSuggestions(input: SuggestionsInput): { heading: string; items: SuggestionItem[] } { + if (input.pendingField) { + const label = input.pendingField === 'partition' ? 'Partition' : input.pendingField; + return { heading: `Value for ${label}`, items: pendingValueItems(input) }; + } + + const nested = nestedValueItems(input); + if (nested) { + return { heading: 'Suggestions', items: nested }; + } + + // A bare `field:` with no value yet behaves like picking the field from the + // list: show the possible values right away (`value:` is handled above). + const emptyValueField = /^(partition|key|offset):$/.exec(input.query.trim()); + if (emptyValueField) { + const field = emptyValueField[1]; + return { + heading: `Value for ${field === 'partition' ? 'Partition' : field}`, + items: pendingValueItems({ ...input, pendingField: field, query: '' }), + }; + } + + if (input.canUseJsFilters && (input.query.startsWith('js:') || input.query.startsWith('javascript:'))) { + return { + heading: 'JavaScript', + items: [ + { + kind: 'item', + label: `ƒ ${stripJsPrefix(input.query) || 'new filter'}`, + sub: 'open editor', + action: { type: 'open-js', code: stripJsPrefix(input.query) }, + }, + ], + }; + } + + const typed = typedTokenItems(input); + if (typed) { + return { heading: 'Suggestions', items: typed }; + } + + return { heading: 'Suggestions', items: defaultGroupItems(input) }; +} + +/** + * Inline ghost completion: when the typed text is a prefix of a suggestable + * field name or pending value, returns the remainder plus the action Tab accepts. + */ +export function computeGhost(input: SuggestionsInput): { rest: string; action: SuggestionAction } | null { + const query = input.query; + if (!query) { + return null; + } + + if (input.pendingField) { + const [first] = distinctFieldValues(input.messages, input.pendingField, '', 100).filter(({ value }) => + value.toLowerCase().startsWith(query.toLowerCase()) + ); + if (first && first.value.length > query.length) { + return { + rest: first.value.slice(query.length), + action: { + type: 'commit-field', + field: input.pendingField, + op: input.pendingField === 'partition' ? 'eq' : 'contains', + value: first.value, + }, + }; + } + return null; + } + + for (const field of ['partition', 'offset', 'key', 'value']) { + if (field.startsWith(query.toLowerCase()) && field !== query.toLowerCase()) { + return { rest: `${field.slice(query.length)}:`, action: { type: 'set-pending', field } }; + } + } + return null; +} diff --git a/frontend/src/components/pages/topics/messages/toolbar/messages-toolbar.tsx b/frontend/src/components/pages/topics/messages/toolbar/messages-toolbar.tsx new file mode 100644 index 0000000000..4b01f78c16 --- /dev/null +++ b/frontend/src/components/pages/topics/messages/toolbar/messages-toolbar.tsx @@ -0,0 +1,54 @@ +/** + * Copyright 2025 Redpanda Data, Inc. + * + * Use of this software is governed by the Business Source License + * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md + * + * As of the Change Date specified in that file, in accordance with + * the Business Source License, use of this software will be governed + * by the Apache License, Version 2.0 + */ + +import { Button } from 'components/redpanda-ui/components/button'; +import { cn } from 'components/redpanda-ui/lib/utils'; +import { RefreshCwIcon } from 'lucide-react'; +import type { ReactNode } from 'react'; + +import { ReadScopePopover, type ReadScopePopoverProps } from './read-scope-popover'; + +export type MessagesToolbarProps = { + scopeProps: ReadScopePopoverProps; + /** The filter bar (or interim quick-search input) rendered between scope and actions. */ + filterSlot: ReactNode; + /** Extra toolbar actions rendered before refresh. */ + actionsSlot?: ReactNode; + isRefreshing: boolean; + /** Live tail keeps the stream open — refresh is a no-op and just keeps spinning. */ + isLive: boolean; + onRefresh: () => void; +}; + +export const MessagesToolbar = ({ + scopeProps, + filterSlot, + actionsSlot, + isRefreshing, + isLive, + onRefresh, +}: MessagesToolbarProps) => ( +
+ +
{filterSlot}
+ {actionsSlot} + +
+); diff --git a/frontend/src/components/pages/topics/messages/toolbar/read-scope-doc-sheet.tsx b/frontend/src/components/pages/topics/messages/toolbar/read-scope-doc-sheet.tsx new file mode 100644 index 0000000000..c73f59b8a5 --- /dev/null +++ b/frontend/src/components/pages/topics/messages/toolbar/read-scope-doc-sheet.tsx @@ -0,0 +1,224 @@ +/** + * Copyright 2025 Redpanda Data, Inc. + * + * Use of this software is governed by the Business Source License + * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md + * + * As of the Change Date specified in that file, in accordance with + * the Business Source License, use of this software will be governed + * by the Apache License, Version 2.0 + */ + +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from 'components/redpanda-ui/components/sheet'; +import { cn } from 'components/redpanda-ui/lib/utils'; +import { RadioIcon } from 'lucide-react'; +import { useEffect, useState } from 'react'; + +import { READ_SCOPE_META } from './read-scope-popover'; +import type { ReadScopeMode } from '../types'; + +type AxisMode = ReadScopeMode | 'live'; + +/** The illustrated partition: cells 0–4 already aged out, 5–14 retained, 15–17 future. */ +const CELL_COUNT = 18; +const AGED_END = 5; +const RETAINED_END = 14; + +const AXIS_CONFIGS: Record< + AxisMode, + { + window: [number, number]; + anchor: number; + direction: 'backward' | 'forward' | 'live'; + ghost?: number; + anchorLabel: string; + caption: string; + } +> = { + newest: { + window: [11, 14], + anchor: 14, + direction: 'backward', + anchorLabel: 'newest offset', + caption: 'Latest results, reading backward from the newest offset.', + }, + oldest: { + window: [5, 8], + anchor: 5, + direction: 'forward', + anchorLabel: 'low watermark', + caption: 'From the earliest retained offset (low watermark), reading forward.', + }, + offset: { + window: [9, 12], + anchor: 9, + direction: 'forward', + ghost: 2, + anchorLabel: 'your offset', + caption: 'Jumps to one exact offset. A hardcoded offset may have already aged out of retention.', + }, + timestamp: { + window: [8, 11], + anchor: 8, + direction: 'forward', + anchorLabel: 'resolved offset', + caption: 'Resolves a point in time to an offset, then reads forward.', + }, + live: { + window: [12, 14], + anchor: 15, + direction: 'live', + anchorLabel: 'live edge', + caption: 'Tails past the newest offset — new messages stream in as producers write them.', + }, +}; + +const MODE_DESCRIPTIONS: { mode: AxisMode; title: string; description: string }[] = [ + { mode: 'newest', title: 'Newest', description: 'The most recent messages' }, + { mode: 'oldest', title: 'Oldest', description: 'From the beginning of the topic' }, + { mode: 'offset', title: 'Offset', description: 'Start from a specific offset' }, + { mode: 'timestamp', title: 'Timestamp', description: 'Start from a point in time' }, + { mode: 'live', title: 'Live tail', description: 'Stream new messages as they arrive' }, +]; + +const AGED_STRIPES = 'repeating-linear-gradient(45deg, var(--color-muted) 0 3px, transparent 3px 6px)'; + +const DIRECTION_ARROWS: Record<'backward' | 'forward' | 'live', string> = { + backward: '←', + forward: '→', + live: '≫', +}; + +/** Illustrated partition axis: where the selected mode drops the reading needle. */ +const MiniAxis = ({ mode }: { mode: AxisMode }) => { + const cfg = AXIS_CONFIGS[mode]; + return ( +
+
+ {Array.from({ length: CELL_COUNT }, (_, i) => { + const aged = i < AGED_END; + const future = i > RETAINED_END; + const inWindow = i >= cfg.window[0] && i <= cfg.window[1] && !future; + const isAnchor = i === cfg.anchor && !future; + const isGhost = cfg.ghost === i; + const liveInflow = mode === 'live' && future; + return ( +
+ ); + })} +
+
+ + {DIRECTION_ARROWS[cfg.direction]} {cfg.anchorLabel} + + + + + aged + + + + retained + + + + future + + +
+

{cfg.caption}

+
+ ); +}; + +export const ReadScopeDocSheet = ({ + open, + onOpenChange, + mode = 'newest', + liveTail = false, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + /** The currently selected read scope — previewed when the sheet opens. */ + mode?: ReadScopeMode; + liveTail?: boolean; +}) => { + const [previewMode, setPreviewMode] = useState(liveTail ? 'live' : mode); + + // Every open starts on the mode the user is actually reading with + useEffect(() => { + if (open) { + setPreviewMode(liveTail ? 'live' : mode); + } + }, [open, mode, liveTail]); + + const previewTitle = MODE_DESCRIPTIONS.find((m) => m.mode === previewMode)?.title ?? ''; + + return ( + + + + How reading starts + + A partition is an append-only log. Each mode is a rule for where to drop the needle. + + +
+
+ Where {previewTitle} lands +
+
+ +
+ +
+ Every mode — tap to preview +
+ {MODE_DESCRIPTIONS.map(({ mode: m, title, description }) => { + const Icon = m === 'live' ? RadioIcon : READ_SCOPE_META[m].icon; + return ( + + ); + })} +
+
+
+ ); +}; diff --git a/frontend/src/components/pages/topics/messages/toolbar/read-scope-popover.test.tsx b/frontend/src/components/pages/topics/messages/toolbar/read-scope-popover.test.tsx new file mode 100644 index 0000000000..549ec3799b --- /dev/null +++ b/frontend/src/components/pages/topics/messages/toolbar/read-scope-popover.test.tsx @@ -0,0 +1,101 @@ +/** + * Copyright 2025 Redpanda Data, Inc. + * + * Use of this software is governed by the Business Source License + * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md + * + * As of the Change Date specified in that file, in accordance with + * the Business Source License, use of this software will be governed + * by the Apache License, Version 2.0 + */ + +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, test, vi } from 'vitest'; + +import { ReadScopePopover, type ReadScopePopoverProps } from './read-scope-popover'; + +const renderPopover = (overrides: Partial = {}) => { + const props: ReadScopePopoverProps = { + topicName: 'test-topic', + mode: 'newest', + onModeChange: vi.fn(), + customOffset: -1, + onCustomOffsetChange: vi.fn(), + startTimestamp: -1, + onStartTimestampChange: vi.fn(), + maxResults: 50, + onMaxResultsChange: vi.fn(), + continuousMode: false, + onContinuousModeChange: vi.fn(), + partitionId: -1, + onPartitionIdChange: vi.fn(), + partitionCount: 3, + liveTail: false, + onLiveTailChange: vi.fn(), + onOpenDocs: vi.fn(), + ...overrides, + }; + render(); + return props; +}; + +describe('ReadScopePopover', () => { + test('shows the current mode and limit summary on the trigger', () => { + renderPopover({ mode: 'oldest', maxResults: 20 }); + expect(screen.getByTestId('read-scope-button')).toHaveTextContent('Oldest'); + expect(screen.getByTestId('read-scope-button')).toHaveTextContent('· 20'); + }); + + test('summary reflects continuous paging', () => { + renderPopover({ continuousMode: true, maxResults: 20 }); + expect(screen.getByTestId('read-scope-button')).toHaveTextContent('· 20/page · continuous'); + }); + + test('selecting a mode applies immediately', async () => { + const props = renderPopover(); + await userEvent.click(screen.getByTestId('read-scope-button')); + await userEvent.click(screen.getByTestId('read-scope-mode-oldest')); + expect(props.onModeChange).toHaveBeenCalledWith('oldest'); + }); + + test('offset mode exposes the start-offset input', async () => { + const props = renderPopover({ mode: 'offset', customOffset: 48_210 }); + await userEvent.click(screen.getByTestId('read-scope-button')); + const offsetInput = screen.getByTestId('read-scope-offset-input'); + expect(offsetInput).toHaveValue('48210'); + await userEvent.type(offsetInput, '7'); + // The input is controlled by the parent; each keystroke reports the parsed offset + expect(props.onCustomOffsetChange).toHaveBeenCalledWith(482_107); + }); + + test('continuous switch only exists for newest/oldest modes', async () => { + renderPopover({ mode: 'timestamp' }); + await userEvent.click(screen.getByTestId('read-scope-button')); + expect(screen.queryByTestId('read-scope-continuous-switch')).not.toBeInTheDocument(); + }); + + test('limit segmented control changes max results', async () => { + const props = renderPopover(); + await userEvent.click(screen.getByTestId('read-scope-button')); + await userEvent.click(screen.getByTestId('read-scope-limit-100')); + expect(props.onMaxResultsChange).toHaveBeenCalledWith(100); + }); + + test('live tail is a menu entry and reflects on the trigger', async () => { + const props = renderPopover({ liveTail: true }); + expect(screen.getByTestId('read-scope-button')).toHaveTextContent('Live tail'); + expect(screen.getByTestId('read-scope-button')).toHaveTextContent('· streaming'); + await userEvent.click(screen.getByTestId('read-scope-button')); + await userEvent.click(screen.getByTestId('read-scope-mode-live')); + expect(props.onLiveTailChange).toHaveBeenCalledWith(false); + }); + + test('picking a start mode stops live tail first', async () => { + const props = renderPopover({ liveTail: true }); + await userEvent.click(screen.getByTestId('read-scope-button')); + await userEvent.click(screen.getByTestId('read-scope-mode-oldest')); + expect(props.onLiveTailChange).toHaveBeenCalledWith(false); + expect(props.onModeChange).toHaveBeenCalledWith('oldest'); + }); +}); diff --git a/frontend/src/components/pages/topics/messages/toolbar/read-scope-popover.tsx b/frontend/src/components/pages/topics/messages/toolbar/read-scope-popover.tsx new file mode 100644 index 0000000000..e6bd8a25ca --- /dev/null +++ b/frontend/src/components/pages/topics/messages/toolbar/read-scope-popover.tsx @@ -0,0 +1,322 @@ +/** + * Copyright 2025 Redpanda Data, Inc. + * + * Use of this software is governed by the Business Source License + * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md + * + * As of the Change Date specified in that file, in accordance with + * the Business Source License, use of this software will be governed + * by the Apache License, Version 2.0 + */ + +import { Button } from 'components/redpanda-ui/components/button'; +import { Input } from 'components/redpanda-ui/components/input'; +import { Label } from 'components/redpanda-ui/components/label'; +import { Popover, PopoverContent, PopoverTrigger } from 'components/redpanda-ui/components/popover'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from 'components/redpanda-ui/components/select'; +import { Switch } from 'components/redpanda-ui/components/switch'; +import { ToggleGroup, ToggleGroupItem } from 'components/redpanda-ui/components/toggle-group'; +import { cn } from 'components/redpanda-ui/lib/utils'; +import { + CalendarIcon, + CheckIcon, + ChevronDownIcon, + HashIcon, + HistoryIcon, + InfoIcon, + RadioIcon, + SkipBackIcon, +} from 'lucide-react'; +import { useState } from 'react'; + +import { StartOffsetDateTimePicker } from '../../Tab.Messages/forms/start-offset-date-time-picker'; +import { LIMIT_OPTIONS } from '../constants'; +import type { ReadScopeMode } from '../types'; + +export const READ_SCOPE_META: Record = + { + newest: { label: 'Newest', description: 'The most recent messages', icon: HistoryIcon }, + oldest: { label: 'Oldest', description: 'From the beginning of the topic', icon: SkipBackIcon }, + offset: { label: 'Offset', description: 'Start from a specific offset', icon: HashIcon }, + timestamp: { label: 'Timestamp', description: 'Start from a point in time', icon: CalendarIcon }, + }; + +const SCOPE_MODES: ReadScopeMode[] = ['newest', 'oldest', 'offset', 'timestamp']; + +/** Modes that support continuous (load-as-you-scroll) pagination. */ +const supportsContinuous = (mode: ReadScopeMode) => mode === 'newest' || mode === 'oldest'; + +export type ReadScopePopoverProps = { + topicName: string; + mode: ReadScopeMode; + onModeChange: (mode: ReadScopeMode) => void; + customOffset: number; + onCustomOffsetChange: (offset: number) => void; + startTimestamp: number; + onStartTimestampChange: (timestamp: number) => void; + maxResults: number; + onMaxResultsChange: (maxResults: number) => void; + continuousMode: boolean; + onContinuousModeChange: (enabled: boolean) => void; + partitionId: number; + onPartitionIdChange: (partitionId: number) => void; + partitionCount: number; + /** Live tail is a menu entry here ("or stream"); picking a start mode stops it. */ + liveTail: boolean; + onLiveTailChange: (enabled: boolean) => void; + onOpenDocs: () => void; +}; + +const ModeRow = ({ mode, selected, onSelect }: { mode: ReadScopeMode; selected: boolean; onSelect: () => void }) => { + const meta = READ_SCOPE_META[mode]; + const Icon = meta.icon; + return ( + + ); +}; + +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: popover composes several independent controls +export const ReadScopePopover = ({ + topicName, + mode, + onModeChange, + customOffset, + onCustomOffsetChange, + startTimestamp, + onStartTimestampChange, + maxResults, + onMaxResultsChange, + continuousMode, + onContinuousModeChange, + partitionId, + onPartitionIdChange, + partitionCount, + liveTail, + onLiveTailChange, + onOpenDocs, +}: ReadScopePopoverProps) => { + const [open, setOpen] = useState(false); + const meta = READ_SCOPE_META[mode]; + const Icon = liveTail ? RadioIcon : meta.icon; + const continuousAvailable = supportsContinuous(mode) && !liveTail; + const summary = (() => { + if (liveTail) { + return '· streaming'; + } + return continuousMode && continuousAvailable ? `· ${maxResults}/page · continuous` : `· ${maxResults}`; + })(); + + const limitValues = LIMIT_OPTIONS.includes(maxResults) + ? LIMIT_OPTIONS + : [...LIMIT_OPTIONS, maxResults].sort((a, b) => a - b); + + return ( + + + + {liveTail ? 'Live tail' : meta.label} + {summary} + + + } + /> + +
+
+
+ + Start from + + +
+ {SCOPE_MODES.map((m) => ( + { + if (liveTail) { + onLiveTailChange(false); + } + onModeChange(m); + }} + selected={!liveTail && mode === m} + /> + ))} +
+
+ + or stream + +
+
+ +
+ +
+ {mode === 'offset' && ( +
+ + { + const parsed = Number.parseInt(e.target.value, 10); + if (!Number.isNaN(parsed) && parsed >= 0) { + onCustomOffsetChange(parsed); + } + }} + placeholder="e.g. 48210" + testId="read-scope-offset-input" + value={customOffset >= 0 ? String(customOffset) : ''} + /> + First message at or after this offset +
+ )} + {mode === 'timestamp' && ( +
+ + + First message at or after this time +
+ )} + + {/* Continuous pagination only exists for the newest/oldest scopes */} + {continuousAvailable && ( +
+
+
Load continuously
+
+ On: pages load as you scroll. Turn off to sort the table. +
+
+ +
+ )} + +
+
+
+
+ {continuousMode && continuousAvailable ? 'Page size' : 'Max results'} +
+
+ {continuousMode && continuousAvailable ? 'Rows fetched per scroll' : 'Rows fetched in one request'} +
+
+ { + if (value.length > 0) { + onMaxResultsChange(Number(value[0])); + } + }} + size="sm" + value={[String(maxResults)]} + > + {limitValues.map((limit) => ( + + {limit} + + ))} + +
+ +
+
Partition
+ +
+
+
+
+ + + ); +}; diff --git a/frontend/src/components/pages/topics/messages/topic-messages-view.tsx b/frontend/src/components/pages/topics/messages/topic-messages-view.tsx new file mode 100644 index 0000000000..6c7c10aa2e --- /dev/null +++ b/frontend/src/components/pages/topics/messages/topic-messages-view.tsx @@ -0,0 +1,539 @@ +/** + * Copyright 2025 Redpanda Data, Inc. + * + * Use of this software is governed by the Business Source License + * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md + * + * As of the Change Date specified in that file, in accordance with + * the Business Source License, use of this software will be governed + * by the Apache License, Version 2.0 + */ + +import type { PaginationState, SortingState, Updater } from '@tanstack/react-table'; +import { Button } from 'components/redpanda-ui/components/button'; +import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from 'components/redpanda-ui/components/resizable'; +import { DownloadIcon, SettingsIcon } from 'lucide-react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { PanelSize } from 'react-resizable-panels'; + +import { DISPLAY_WINDOW_CAP } from './constants'; +import { patchDetailViewState, readDetailViewState } from './detail/detail-view-state'; +import { MessageDetailPanel } from './detail/message-detail-panel'; +import { JsFilterDialog } from './dialogs/js-filter-dialog'; +import { useClientFilters } from './hooks/use-client-filters'; +import { useKeyboardNav } from './hooks/use-keyboard-nav'; +import { type MessageSearchParams, messageKey, useMessageSearch } from './hooks/use-message-search'; +import { useMessagesUrlState } from './hooks/use-messages-url-state'; +import type { ValuePreviewConfig } from './table/message-cells'; +import { MessagesFooter } from './table/messages-footer'; +import { MessagesTable } from './table/messages-table'; +import { FilterBar } from './toolbar/filter-bar'; +import { MessagesToolbar } from './toolbar/messages-toolbar'; +import { ReadScopeDocSheet } from './toolbar/read-scope-doc-sheet'; +import { valuePaths } from './utils/client-match'; +import { applyDisplayWindow } from './utils/live-window'; +import { ViewSettingsPanel } from './view-settings/view-settings-panel'; +import { isServerless } from '../../../../config'; +import { PayloadEncoding } from '../../../../protogen/redpanda/api/console/v1alpha1/common_pb'; +import { appGlobal } from '../../../../state/app-global'; +import { useApiStoreHook } from '../../../../state/backend-api'; +import type { Topic, TopicMessage } from '../../../../state/rest-interfaces'; +import { type FilterEntry, PartitionOffsetOrigin } from '../../../../state/ui'; +import { useTopicSettingsStore } from '../../../../stores/topic-settings-store'; +import { sanitizeString, wrapFilterFragment } from '../../../../utils/filter-helper'; +import { getTopicFilters, setTopicFilters } from '../../../../utils/topic-filters-session'; +import { encodeBase64 } from '../../../../utils/utils'; +import { SaveMessagesDialog } from '../Tab.Messages/dialogs/save-messages-dialog'; + +export type TopicMessagesViewProps = { + topic: Topic; +}; + +/** + * Redesigned topic messages viewer ("Console Messages UX"). + * Rendered behind the `enableNewTopicMessagesPage` feature flag. + */ +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: page container wires url state, streaming and table state together +export const TopicMessagesView = ({ topic }: TopicMessagesViewProps) => { + const topicName = topic.topicName; + const urlState = useMessagesUrlState(topicName); + const search = useMessageSearch(topicName); + const { getRowDensity, getMessageColumns, getTopicSettings } = useTopicSettingsStore(); + + const [docSheetOpen, setDocSheetOpen] = useState(false); + const [saveDialogOpen, setSaveDialogOpen] = useState(false); + const [viewSettingsOpen, setViewSettingsOpen] = useState(false); + const [refreshCounter, setRefreshCounter] = useState(0); + + // Selection lives in the URL (`selected=partition-offset`) so reloads and + // shared links reopen the detail once the message is loaded. + const { selectedKey, setSelectedKey } = urlState; + + // Detail presentation is a persisted preference: whoever prefers the expanded + // sheet gets it again for the next message and after reloads. + const [detailExpanded, setDetailExpandedState] = useState(() => readDetailViewState().expanded); + const setDetailExpanded = useCallback((expanded: boolean) => { + setDetailExpandedState(expanded); + patchDetailViewState({ expanded }); + }, []); + + const handleDetailClose = useCallback(() => { + setSelectedKey(null); + }, [setSelectedKey]); + + // Docked panel width is a persisted preference too. A ref (not state) so + // resize drags don't re-render the whole page; the panel reads it on mount. + const detailPanelSizeRef = useRef(readDetailViewState().panelSizePct); + const handleDetailPanelResize = useCallback((size: PanelSize) => { + detailPanelSizeRef.current = size.asPercentage; + patchDetailViewState({ panelSizePct: size.asPercentage }); + }, []); + + // Committed field-token filters live in the URL (`f`) so reloads and shared + // links keep them; JS filters are deliberately excluded from the URL. + const { fieldTokens, setFieldTokens } = urlState; + // JavaScript push-down filters, persisted in sessionStorage per topic + const [jsFilters, setJsFilters] = useState(() => getTopicFilters(topicName)); + const [jsDialog, setJsDialog] = useState<{ filter: FilterEntry | null; seedCode?: string } | null>(null); + + useEffect(() => { + setTopicFilters(topicName, jsFilters); + }, [topicName, jsFilters]); + + // The toggle state persists across mode switches, but continuous pagination + // only applies to the newest/oldest scopes (never to offset/timestamp or live). + const continuousActive = + urlState.continuousMode && + (urlState.readScopeMode === 'newest' || urlState.readScopeMode === 'oldest') && + !urlState.liveTail; + + const topicPermissions = useApiStoreHook((s) => s.topicPermissions.get(topicName)); + const canUseJsFilters = (topicPermissions?.canUseSearchFilters ?? true) && !isServerless() && !continuousActive; + + const density = getRowDensity(topicName); + const columnConfig = getMessageColumns(topicName); + const topicSettings = getTopicSettings(topicName); + const timestampFormat = topicSettings?.previewTimestamps ?? 'default'; + + const valuePreview: ValuePreviewConfig = useMemo( + () => ({ + tags: topicSettings?.previewTags ?? [], + caseSensitive: topicSettings?.previewTagsCaseSensitive === 'caseSensitive', + multiResultMode: topicSettings?.previewMultiResultMode ?? 'showAll', + displayMode: topicSettings?.previewDisplayMode ?? 'single', + }), + [ + topicSettings?.previewTags, + topicSettings?.previewTagsCaseSensitive, + topicSettings?.previewMultiResultMode, + topicSettings?.previewDisplayMode, + ] + ); + + const valuePathHints = useMemo(() => valuePaths(search.messages), [search.messages]); + + // Combine active JS filters into the base64 backend predicate (AND semantics) + const filterInterpreterCode = useMemo(() => { + if (!canUseJsFilters) { + return ''; + } + const active = jsFilters.filter((f) => f.isActive && f.code && f.transpiledCode); + if (active.length === 0) { + return ''; + } + const functions = active.map((f, i) => `function filter${i + 1}() {\n${wrapFilterFragment(f.transpiledCode)}\n}`); + const code = `${functions.join('\n\n')}\n\nreturn ${active.map((_, i) => `filter${i + 1}()`).join(' && ')}`; + return encodeBase64(sanitizeString(code)); + }, [jsFilters, canUseJsFilters]); + + const searchParams: MessageSearchParams = useMemo( + () => ({ + startOffset: urlState.startOffset, + startTimestamp: urlState.readScopeMode === 'timestamp' ? urlState.startTimestamp : -1, + partitionId: urlState.partitionId, + maxResults: urlState.maxResults, + pageSize: continuousActive ? urlState.maxResults : undefined, + filterInterpreterCode, + keyDeserializer: urlState.keyDeserializer, + valueDeserializer: urlState.valueDeserializer, + includeRawPayload: true, + }), + [ + urlState.startOffset, + urlState.startTimestamp, + urlState.readScopeMode, + urlState.partitionId, + urlState.maxResults, + continuousActive, + urlState.keyDeserializer, + urlState.valueDeserializer, + filterInterpreterCode, + ] + ); + + // Auto-search on parameter change: 100ms debounce with a signature guard so + // unrelated re-renders don't restart an identical stream (ported from legacy). + const lastSignatureRef = useRef(null); + useEffect(() => { + if (urlState.liveTail) { + return; // live tail owns the stream (P5) + } + const signature = `${JSON.stringify(searchParams)}|${refreshCounter}`; + if (signature === lastSignatureRef.current) { + return; + } + const timer = setTimeout(() => { + lastSignatureRef.current = signature; + search.start(searchParams).catch(() => { + // errors are surfaced through search.error + }); + }, 100); + return () => clearTimeout(timer); + }, [searchParams, refreshCounter, urlState.liveTail, search.start]); + + // Live tail: stream from the log's end; stopping restores the paged backlog + // (clearing the signature lets the paged auto-search above re-run). + useEffect(() => { + if (!urlState.liveTail) { + return; + } + lastSignatureRef.current = null; + search + .start({ ...searchParams, startOffset: PartitionOffsetOrigin.End, pageSize: undefined }, { live: true }) + .catch(() => { + // errors are surfaced through search.error + }); + return () => search.stop(); + // searchParams is intentionally not a dependency: scope edits are disabled while live, + // and deserializer changes take effect on the next (re)start. + // biome-ignore lint/correctness/useExhaustiveDependencies: see above + }, [urlState.liveTail, search.start, search.stop]); + + const filteredMessages = useClientFilters(search.messages, urlState.quickSearch, fieldTokens); + + // In continuous or live mode only the newest DISPLAY_WINDOW_CAP rows stay rendered + const { rows: windowedMessages, trimmed } = useMemo( + () => + continuousActive || urlState.liveTail + ? applyDisplayWindow(filteredMessages, DISPLAY_WINDOW_CAP) + : { rows: filteredMessages, trimmed: 0 }, + [filteredMessages, continuousActive, urlState.liveTail] + ); + + // Stable mutable copy for consumers typed as TopicMessage[] — a fresh array on + // every render would defeat react-table's data memoization. + const tableData = useMemo(() => [...windowedMessages], [windowedMessages]); + + const isSearching = search.phase === 'connecting' || search.phase === 'searching'; + + const pagination: PaginationState = useMemo( + () => ({ + pageIndex: urlState.pageIndex, + pageSize: continuousActive ? Math.max(windowedMessages.length, 1) : urlState.pageSize, + }), + [urlState.pageIndex, urlState.pageSize, continuousActive, windowedMessages.length] + ); + + const handlePaginationChange = useCallback( + (updater: Updater) => { + const next = typeof updater === 'function' ? updater(pagination) : updater; + urlState.setPageIndex(next.pageIndex); + if (!continuousActive) { + urlState.setPageSize(next.pageSize); + } + }, + [pagination, urlState.setPageIndex, urlState.setPageSize, continuousActive] + ); + + const handleSortingChange = useCallback( + (updater: Updater) => { + const next = typeof updater === 'function' ? updater(urlState.sorting) : updater; + urlState.setSortingState(next); + }, + [urlState.sorting, urlState.setSortingState] + ); + + // Row clicks always select (mock behavior) — switching rows swaps the open + // detail (docked or expanded) in place; closing is the panel's own X. + const handleRowClick = useCallback( + (msg: TopicMessage) => { + // Detail and view settings are distinct right-side surfaces — one at a time + setViewSettingsOpen(false); + setSelectedKey(messageKey(msg)); + }, + [setSelectedKey] + ); + + const selectedMessage = useMemo( + () => (selectedKey ? (search.messages.find((m) => messageKey(m) === selectedKey) ?? null) : null), + [selectedKey, search.messages] + ); + + const handleLoadLargeMessage = useCallback(() => { + if (!selectedMessage) { + return Promise.resolve(); + } + return search.loadLargeMessage(selectedMessage.partitionID, selectedMessage.offset); + }, [selectedMessage, search.loadLargeMessage]); + + const handleRefresh = useCallback(() => { + if (urlState.liveTail) { + return; + } + setRefreshCounter((c) => c + 1); + }, [urlState.liveTail]); + + // DeleteRecordsModal (and other page-level actions) re-trigger the search through this global + useEffect(() => { + appGlobal.searchMessagesFunc = () => setRefreshCounter((c) => c + 1); + return () => { + appGlobal.searchMessagesFunc = undefined; + }; + }, []); + + // Keyboard nav follows the on-screen order: current sort, then current page + const visibleKeys = useMemo(() => { + const sorted = [...windowedMessages].sort((a, b) => { + for (const sort of urlState.sorting) { + const direction = sort.desc ? -1 : 1; + const left = sort.id === 'timestamp' ? a.timestamp : a.offset; + const right = sort.id === 'timestamp' ? b.timestamp : b.offset; + if (left !== right) { + return left < right ? -direction : direction; + } + } + return 0; + }); + const start = pagination.pageIndex * pagination.pageSize; + return sorted.slice(start, start + pagination.pageSize).map(messageKey); + }, [windowedMessages, urlState.sorting, pagination]); + + const getCopyText = useCallback( + (key: string) => search.messages.find((m) => messageKey(m) === key)?.valueJson, + [search.messages] + ); + + useKeyboardNav({ + visibleKeys, + selectedKey, + onSelect: setSelectedKey, + getCopyText, + enabled: !(saveDialogOpen || jsDialog), + }); + + return ( +
+ f.isActive)} + messages={search.messages} + onEditJsFilter={(filter, seedCode) => setJsDialog({ filter, seedCode })} + onFieldTokensChange={setFieldTokens} + onPartitionIdChange={(partitionId) => { + urlState.setPartitionId(partitionId); + urlState.setPageIndex(0); + }} + onQuickSearchChange={urlState.setQuickSearch} + onRemoveJsFilter={(id) => setJsFilters((prev) => prev.filter((f) => f.id !== id))} + partitionId={urlState.partitionId} + quickSearch={urlState.quickSearch} + /> + } + isLive={urlState.liveTail} + isRefreshing={isSearching} + onRefresh={handleRefresh} + scopeProps={{ + topicName, + mode: urlState.readScopeMode, + onModeChange: urlState.setReadScopeMode, + customOffset: urlState.startOffset >= 0 ? urlState.startOffset : -1, + onCustomOffsetChange: (offset) => { + urlState.setStartOffset(offset); + urlState.setPageIndex(0); + }, + startTimestamp: urlState.startTimestamp, + onStartTimestampChange: (timestamp) => { + urlState.setStartTimestamp(timestamp); + urlState.setStartOffset(PartitionOffsetOrigin.Timestamp); + urlState.setPageIndex(0); + }, + maxResults: urlState.maxResults, + onMaxResultsChange: (maxResults) => { + urlState.setMaxResults(maxResults); + urlState.setPageIndex(0); + }, + continuousMode: urlState.continuousMode, + onContinuousModeChange: urlState.setContinuousMode, + partitionId: urlState.partitionId, + onPartitionIdChange: (partitionId) => { + urlState.setPartitionId(partitionId); + urlState.setPageIndex(0); + }, + partitionCount: topic.partitionCount, + liveTail: urlState.liveTail, + onLiveTailChange: (enabled) => { + setSelectedKey(null); + if (enabled) { + urlState.setReadScopeMode('newest'); + urlState.setPageIndex(0); + } + urlState.setLiveTail(enabled); + }, + onOpenDocs: () => setDocSheetOpen(true), + }} + /> + + + +
+
+ + +
+ 0 || fieldTokens.length > 0 || filterInterpreterCode !== '' + } + isLiveWaiting={urlState.liveTail && search.messages.length === 0} + isLoading={isSearching && !urlState.liveTail} + messages={tableData} + newKeys={search.newKeys} + onPaginationChange={handlePaginationChange} + onRowClick={handleRowClick} + onSortingChange={handleSortingChange} + pagination={pagination} + selectedKey={selectedKey} + sorting={urlState.sorting} + sortingDisabled={continuousActive} + timestampFormat={timestampFormat} + valuePreview={valuePreview} + /> +
+
+ {selectedMessage && !detailExpanded && ( + <> + + + + + + )} +
+ + {/* Expanded presentation replaces the docked slot entirely (sheet portals to body) */} + {selectedMessage && detailExpanded && ( + + )} + + search.loadMore(urlState.maxResults)} + onPageChange={urlState.setPageIndex} + pageIndex={urlState.pageIndex} + pageSize={urlState.pageSize} + showStats={!(urlState.liveTail || isSearching)} + totalLoaded={filteredMessages.length} + trimmedCount={trimmed} + windowCap={DISPLAY_WINDOW_CAP} + windowSize={windowedMessages.length} + /> + + {viewSettingsOpen && ( + + )} + + + {jsDialog && ( + setJsDialog(null)} + onSave={(saved) => + setJsFilters((prev) => + prev.some((f) => f.id === saved.id) ? prev.map((f) => (f.id === saved.id ? saved : f)) : [...prev, saved] + ) + } + seedCode={jsDialog.seedCode} + /> + )} + {saveDialogOpen && ( + setSaveDialogOpen(false)} + onRequireRawPayload={() => Promise.resolve(tableData)} + /> + )} +
+ ); +}; diff --git a/frontend/src/components/pages/topics/messages/types.ts b/frontend/src/components/pages/topics/messages/types.ts new file mode 100644 index 0000000000..47af45156f --- /dev/null +++ b/frontend/src/components/pages/topics/messages/types.ts @@ -0,0 +1,29 @@ +/** + * Copyright 2025 Redpanda Data, Inc. + * + * Use of this software is governed by the Business Source License + * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md + * + * As of the Change Date specified in that file, in accordance with + * the Business Source License, use of this software will be governed + * by the Apache License, Version 2.0 + */ + +export type { MessageColumnConfig, RowDensity } from '../../../../stores/topic-settings-store'; + +/** Where a message search starts reading. Live tail is orthogonal — see the `live` URL param. */ +export type ReadScopeMode = 'newest' | 'oldest' | 'offset' | 'timestamp'; + +export type FilterOp = 'contains' | 'eq' | 'neq' | 'gt' | 'lt'; + +/** + * A committed filter chip. Structured chips carry `field`/`op`/`value` + * (field is `key`, `value`, `partition`, `offset`, or a `value.` accessor); + * JavaScript chips carry the predicate code and an optional display name. + */ +export type FilterToken = + | { kind: 'field'; field: string; op: FilterOp; value: string } + | { kind: 'js'; code: string; name?: string }; + +/** The structured (non-JS) filter chips — the only kind persisted in the URL. */ +export type FieldFilterToken = Extract; diff --git a/frontend/src/components/pages/topics/messages/utils/client-match.test.ts b/frontend/src/components/pages/topics/messages/utils/client-match.test.ts new file mode 100644 index 0000000000..0ef033a751 --- /dev/null +++ b/frontend/src/components/pages/topics/messages/utils/client-match.test.ts @@ -0,0 +1,106 @@ +/** + * Copyright 2025 Redpanda Data, Inc. + * + * Use of this software is governed by the Business Source License + * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md + * + * As of the Change Date specified in that file, in accordance with + * the Business Source License, use of this software will be governed + * by the Apache License, Version 2.0 + */ + +import { describe, expect, test } from 'vitest'; + +import { distinctFieldValues, matchesFieldFilter, resolveField, valuePaths } from './client-match'; +import type { TopicMessage } from '../../../../../state/rest-interfaces'; + +const makeMsg = (overrides: { offset?: number; partitionID?: number; key?: unknown; value?: unknown }): TopicMessage => + ({ + partitionID: overrides.partitionID ?? 0, + offset: overrides.offset ?? 1, + timestamp: 0, + compression: 'uncompressed', + isTransactional: false, + headers: [], + key: { payload: overrides.key ?? 'k', isPayloadNull: overrides.key === null, size: 1 }, + value: { payload: overrides.value ?? {}, isPayloadNull: overrides.value === null, size: 1 }, + keyJson: JSON.stringify(overrides.key ?? 'k'), + valueJson: JSON.stringify(overrides.value ?? {}), + keyBinHexPreview: '', + valueBinHexPreview: '', + }) as TopicMessage; + +describe('resolveField', () => { + const msg = makeMsg({ offset: 42, partitionID: 2, key: 'abc', value: { address: { city: 'Berlin' }, version: 3 } }); + + test('resolves scalar fields', () => { + expect(resolveField(msg, 'offset')).toBe(42); + expect(resolveField(msg, 'partition')).toBe(2); + expect(resolveField(msg, 'key')).toBe('abc'); + }); + + test('resolves nested value paths', () => { + expect(resolveField(msg, 'value.address.city')).toBe('Berlin'); + expect(resolveField(msg, 'value.version')).toBe(3); + expect(resolveField(msg, 'value.missing.path')).toBeUndefined(); + }); +}); + +describe('matchesFieldFilter', () => { + const msg = makeMsg({ offset: 100, key: 'user-7', value: { type: 'INVOICE', version: 0 } }); + + test('contains is case-insensitive', () => { + expect(matchesFieldFilter(msg, 'key', 'contains', 'USER')).toBe(true); + expect(matchesFieldFilter(msg, 'value.type', 'contains', 'invoice')).toBe(true); + expect(matchesFieldFilter(msg, 'key', 'contains', 'nope')).toBe(false); + }); + + test('eq/neq compare stringified values', () => { + expect(matchesFieldFilter(msg, 'value.version', 'eq', '0')).toBe(true); + expect(matchesFieldFilter(msg, 'value.version', 'neq', '1')).toBe(true); + expect(matchesFieldFilter(msg, 'partition', 'eq', '0')).toBe(true); + }); + + test('gt/lt compare numerically', () => { + expect(matchesFieldFilter(msg, 'offset', 'gt', '99')).toBe(true); + expect(matchesFieldFilter(msg, 'offset', 'lt', '99')).toBe(false); + expect(matchesFieldFilter(msg, 'key', 'gt', '5')).toBe(false); + }); +}); + +describe('distinctFieldValues', () => { + const messages = [ + makeMsg({ partitionID: 0 }), + makeMsg({ partitionID: 0 }), + makeMsg({ partitionID: 1 }), + makeMsg({ partitionID: 2 }), + ]; + + test('returns count-sorted distinct values', () => { + expect(distinctFieldValues(messages, 'partition', '')).toEqual([ + { value: '0', count: 2 }, + { value: '1', count: 1 }, + { value: '2', count: 1 }, + ]); + }); + + test('filters by typed query', () => { + expect(distinctFieldValues(messages, 'partition', '2')).toEqual([{ value: '2', count: 1 }]); + }); +}); + +describe('valuePaths', () => { + const messages = [ + makeMsg({ value: { address: { city: 'Berlin', zip: '10115' }, name: 'a' } }), + makeMsg({ value: { version: 1 } }), + ]; + + test('collects nested dotted paths across rows', () => { + expect(valuePaths(messages)).toEqual(['address', 'address.city', 'address.zip', 'name', 'version']); + }); + + test('prefix matches sort before substring matches', () => { + expect(valuePaths(messages, 'address')).toEqual(['address', 'address.city', 'address.zip']); + expect(valuePaths(messages, 'city')[0]).toBe('address.city'); + }); +}); diff --git a/frontend/src/components/pages/topics/messages/utils/client-match.ts b/frontend/src/components/pages/topics/messages/utils/client-match.ts new file mode 100644 index 0000000000..da601b2de5 --- /dev/null +++ b/frontend/src/components/pages/topics/messages/utils/client-match.ts @@ -0,0 +1,136 @@ +/** + * Copyright 2025 Redpanda Data, Inc. + * + * Use of this software is governed by the Business Source License + * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md + * + * As of the Change Date specified in that file, in accordance with + * the Business Source License, use of this software will be governed + * by the Apache License, Version 2.0 + */ + +import type { TopicMessage } from '../../../../../state/rest-interfaces'; +import type { FilterOp } from '../types'; + +/** + * Resolve a filter field against a loaded message: `key`, `partition`, `offset`, + * `value`, or a `value.` accessor into the decoded value payload. + * Returns undefined when the path doesn't exist. + */ +export function resolveField(msg: TopicMessage, field: string): unknown { + switch (field) { + case 'key': + return msg.key.isPayloadNull ? undefined : (msg.key.payload ?? msg.keyJson); + case 'partition': + return msg.partitionID; + case 'offset': + return msg.offset; + case 'value': + return msg.valueJson; + default: + break; + } + if (!field.startsWith('value.')) { + return; + } + let current: unknown = msg.value.isPayloadNull ? undefined : msg.value.payload; + for (const segment of field.slice('value.'.length).split('.')) { + if (current === null || typeof current !== 'object') { + return; + } + current = (current as Record)[segment]; + } + return current; +} + +const compareNumeric = (resolved: unknown, value: string, op: 'gt' | 'lt'): boolean => { + const left = Number(resolved); + const right = Number(value); + if (Number.isNaN(left) || Number.isNaN(right)) { + return false; + } + return op === 'gt' ? left > right : left < right; +}; + +const asComparableString = (resolved: unknown): string => + typeof resolved === 'string' ? resolved : (JSON.stringify(resolved) ?? ''); + +/** Apply one structured `field op value` filter to a loaded message. */ +export function matchesFieldFilter(msg: TopicMessage, field: string, op: FilterOp, value: string): boolean { + const resolved = resolveField(msg, field); + if (resolved === undefined) { + return false; + } + switch (op) { + case 'contains': + return asComparableString(resolved).toLowerCase().includes(value.toLowerCase()); + case 'eq': + return asComparableString(resolved).toLowerCase() === value.toLowerCase(); + case 'neq': + return asComparableString(resolved).toLowerCase() !== value.toLowerCase(); + case 'gt': + case 'lt': + return compareNumeric(resolved, value, op); + default: + return false; + } +} + +/** + * Distinct values of a field across the loaded rows, filtered by an optional + * typed prefix/substring, sorted by frequency. Powers the suggestion dropdown. + */ +export function distinctFieldValues( + messages: TopicMessage[], + field: string, + query: string, + limit = 6 +): { value: string; count: number }[] { + const counts = new Map(); + for (const msg of messages) { + const resolved = resolveField(msg, field); + if (resolved === undefined || resolved === null || typeof resolved === 'object') { + continue; + } + const text = String(resolved); + counts.set(text, (counts.get(text) ?? 0) + 1); + } + const needle = query.toLowerCase(); + return [...counts.entries()] + .filter(([value]) => !needle || value.toLowerCase().includes(needle)) + .sort((a, b) => b[1] - a[1]) + .slice(0, limit) + .map(([value, count]) => ({ value, count })); +} + +const MAX_PATH_DEPTH = 4; + +const collectPaths = (obj: unknown, prefix: string, depth: number, out: Set) => { + if (depth > MAX_PATH_DEPTH || obj === null || typeof obj !== 'object' || Array.isArray(obj)) { + return; + } + for (const [key, child] of Object.entries(obj as Record)) { + const path = prefix ? `${prefix}.${key}` : key; + out.add(path); + collectPaths(child, path, depth + 1, out); + } +}; + +/** Dotted paths that exist under the decoded value payloads of the loaded rows. */ +export function valuePaths(messages: TopicMessage[], filter = ''): string[] { + const paths = new Set(); + for (const msg of messages.slice(0, 50)) { + if (!msg.value.isPayloadNull) { + collectPaths(msg.value.payload, '', 0, paths); + } + } + const needle = filter.toLowerCase(); + const all = [...paths].sort(); + if (!needle) { + return all; + } + // Prefix matches first, then substring matches + const prefixed = all.filter((p) => p.toLowerCase().startsWith(needle)); + const contained = all.filter((p) => !p.toLowerCase().startsWith(needle) && p.toLowerCase().includes(needle)); + return [...prefixed, ...contained]; +} diff --git a/frontend/src/components/pages/topics/messages/utils/filter-token.test.ts b/frontend/src/components/pages/topics/messages/utils/filter-token.test.ts new file mode 100644 index 0000000000..9867255002 --- /dev/null +++ b/frontend/src/components/pages/topics/messages/utils/filter-token.test.ts @@ -0,0 +1,135 @@ +/** + * Copyright 2025 Redpanda Data, Inc. + * + * Use of this software is governed by the Business Source License + * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md + * + * As of the Change Date specified in that file, in accordance with + * the Business Source License, use of this software will be governed + * by the Apache License, Version 2.0 + */ + +import { describe, expect, test } from 'vitest'; + +import { + formatTokenText, + looksLikeJs, + parseFilterInput, + stripJsPrefix, + tokenEditText, + tokenQueryText, +} from './filter-token'; +import { fieldTokensParser } from '../hooks/use-messages-url-state'; +import type { FieldFilterToken } from '../types'; + +describe('parseFilterInput', () => { + test('parses partition equality', () => { + expect(parseFilterInput('partition:2')).toEqual({ field: 'partition', op: 'eq', value: '2' }); + }); + + test('parses offset comparisons', () => { + expect(parseFilterInput('offset>48210')).toEqual({ field: 'offset', op: 'gt', value: '48210' }); + expect(parseFilterInput('offset<100')).toEqual({ field: 'offset', op: 'lt', value: '100' }); + }); + + test('parses key/value contains and not-equals', () => { + expect(parseFilterInput('key:abc')).toEqual({ field: 'key', op: 'contains', value: 'abc' }); + expect(parseFilterInput('key!=abc')).toEqual({ field: 'key', op: 'neq', value: 'abc' }); + }); + + test('parses nested value paths', () => { + expect(parseFilterInput('value.address.city:Berlin')).toEqual({ + field: 'value.address.city', + op: 'contains', + value: 'Berlin', + }); + }); + + test('rejects plain text, unknown fields, and empty values', () => { + expect(parseFilterInput('hello world')).toBeNull(); + expect(parseFilterInput('timestamp:5')).toBeNull(); + expect(parseFilterInput('partition:')).toBeNull(); + expect(parseFilterInput('')).toBeNull(); + }); +}); + +describe('formatTokenText / tokenEditText', () => { + test('formats field tokens per operator', () => { + expect(formatTokenText({ kind: 'field', field: 'partition', op: 'eq', value: '2' })).toBe('partition:2'); + expect(formatTokenText({ kind: 'field', field: 'offset', op: 'gt', value: '10' })).toBe('offset>10'); + expect(formatTokenText({ kind: 'field', field: 'offset', op: 'lt', value: '10' })).toBe('offset<10'); + expect(formatTokenText({ kind: 'field', field: 'key', op: 'neq', value: 'x' })).toBe('key!=x'); + }); + + test('formats js tokens with name fallback to code', () => { + expect(formatTokenText({ kind: 'js', code: 'value.version === 0', name: 'v0 only' })).toBe('ƒ v0 only'); + expect(formatTokenText({ kind: 'js', code: 'value.version === 0' })).toBe('ƒ value.version === 0'); + }); + + test('tokenEditText round-trips through parseFilterInput', () => { + const token = { kind: 'field' as const, field: 'offset', op: 'gt' as const, value: '48210' }; + expect(parseFilterInput(tokenEditText(token))).toEqual({ field: 'offset', op: 'gt', value: '48210' }); + }); + + test('tokenEditText returns raw code for js tokens', () => { + expect(tokenEditText({ kind: 'js', code: 'return true', name: 'all' })).toBe('return true'); + }); +}); + +describe('tokenQueryText / fieldTokensParser (URL persistence)', () => { + test('tokenQueryText keeps eq distinct from contains', () => { + expect(tokenQueryText({ kind: 'field', field: 'offset', op: 'eq', value: '5' })).toBe('offset=5'); + expect(tokenQueryText({ kind: 'field', field: 'key', op: 'contains', value: 'abc' })).toBe('key:abc'); + }); + + test('parseFilterInput reads = as equality for any field', () => { + expect(parseFilterInput('offset=5')).toEqual({ field: 'offset', op: 'eq', value: '5' }); + }); + + test('serialize/parse round-trips every operator', () => { + const tokens: FieldFilterToken[] = [ + { kind: 'field', field: 'key', op: 'contains', value: 'abc' }, + { kind: 'field', field: 'offset', op: 'eq', value: '5' }, + { kind: 'field', field: 'offset', op: 'gt', value: '10' }, + { kind: 'field', field: 'value.address.city', op: 'neq', value: 'Berlin' }, + ]; + const serialized = fieldTokensParser.serialize(tokens); + expect(fieldTokensParser.parse(serialized)).toEqual(tokens); + }); + + test('round-trips values containing commas and percent signs', () => { + const tokens: FieldFilterToken[] = [ + { kind: 'field', field: 'value', op: 'contains', value: 'hello, world' }, + { kind: 'field', field: 'key', op: 'contains', value: '100%' }, + ]; + expect(fieldTokensParser.parse(fieldTokensParser.serialize(tokens))).toEqual(tokens); + }); + + test('drops unparseable fragments instead of failing', () => { + expect(fieldTokensParser.parse('key:abc,garbage,offset>1')).toEqual([ + { kind: 'field', field: 'key', op: 'contains', value: 'abc' }, + { kind: 'field', field: 'offset', op: 'gt', value: '1' }, + ]); + }); +}); + +describe('looksLikeJs / stripJsPrefix', () => { + test('detects js: prefixes and code-like expressions', () => { + expect(looksLikeJs('js: value.version === 0')).toBe(true); + expect(looksLikeJs('javascript:return true')).toBe(true); + expect(looksLikeJs('value.version === 0')).toBe(true); + expect(looksLikeJs('offset % 2 === 0')).toBe(true); + }); + + test('does not flag plain text or parseable field tokens', () => { + expect(looksLikeJs('hello world')).toBe(false); + expect(looksLikeJs('partition:2')).toBe(false); + expect(looksLikeJs('offset>10')).toBe(false); + }); + + test('stripJsPrefix removes both prefixes and leaves bare code alone', () => { + expect(stripJsPrefix('js: return true')).toBe('return true'); + expect(stripJsPrefix('javascript: return true')).toBe('return true'); + expect(stripJsPrefix('return true')).toBe('return true'); + }); +}); diff --git a/frontend/src/components/pages/topics/messages/utils/filter-token.ts b/frontend/src/components/pages/topics/messages/utils/filter-token.ts new file mode 100644 index 0000000000..a3dd73b380 --- /dev/null +++ b/frontend/src/components/pages/topics/messages/utils/filter-token.ts @@ -0,0 +1,125 @@ +/** + * Copyright 2025 Redpanda Data, Inc. + * + * Use of this software is governed by the Business Source License + * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md + * + * As of the Change Date specified in that file, in accordance with + * the Business Source License, use of this software will be governed + * by the Apache License, Version 2.0 + */ + +import type { FieldFilterToken, FilterOp, FilterToken } from '../types'; + +export const OP_LABELS: Record = { + contains: 'contains', + eq: '=', + neq: '≠', + gt: '>', + lt: '<', +}; + +/** Fields that can appear in a typed `field:value` token. `value.` accessors are also valid fields. */ +export const FILTER_FIELDS = ['key', 'value', 'partition', 'offset'] as const; + +const FIELD_PATTERN = /^(key|value(?:\.[\w.*-]+)?|partition|offset)/; + +/** Compact chip text, mirroring the design mock: `partition:2`, `offset>48210`, `key!=abc`, `ƒ name`. */ +export function formatTokenText(token: FilterToken): string { + if (token.kind === 'js') { + return `ƒ ${token.name || token.code}`; + } + switch (token.op) { + case 'gt': + return `${token.field}>${token.value}`; + case 'lt': + return `${token.field}<${token.value}`; + case 'neq': + return `${token.field}!=${token.value}`; + default: + return `${token.field}:${token.value}`; + } +} + +/** Text placed back into the filter input when a chip is clicked for editing. */ +export function tokenEditText(token: FilterToken): string { + if (token.kind === 'js') { + return token.code; + } + return formatTokenText(token); +} + +/** + * Lossless text form for URL persistence: unlike the display text (which + * collapses `eq` to `:`), `eq` serializes as `=` so `parseFilterInput` + * round-trips the operator exactly. + */ +export function tokenQueryText(token: FieldFilterToken): string { + return token.op === 'eq' ? `${token.field}=${token.value}` : formatTokenText(token); +} + +/** + * Parse a typed token like `partition:2`, `offset>48210`, `value.address.city:Berlin` + * or `key!=abc`. Returns null when the text is not a recognized `field op value` form + * (plain full-text stays live in the bar instead of becoming a token). + */ +export function parseFilterInput(text: string): { field: string; op: FilterOp; value: string } | null { + const trimmed = text.trim(); + const fieldMatch = FIELD_PATTERN.exec(trimmed); + if (!fieldMatch) { + return null; + } + + const field = fieldMatch[1]; + const rest = trimmed.slice(field.length); + + let op: FilterOp; + let value: string; + if (rest.startsWith('!=')) { + op = 'neq'; + value = rest.slice(2); + } else if (rest.startsWith('>')) { + op = 'gt'; + value = rest.slice(1); + } else if (rest.startsWith('<')) { + op = 'lt'; + value = rest.slice(1); + } else if (rest.startsWith('=')) { + op = 'eq'; + value = rest.slice(1); + } else if (rest.startsWith(':')) { + // `:` means equality for enumerable fields (partition) and contains for text fields + op = field === 'partition' ? 'eq' : 'contains'; + value = rest.slice(1); + } else { + return null; + } + + value = value.trim(); + if (value.length === 0) { + return null; + } + + return { field, op, value }; +} + +/** True when the text looks like a JavaScript predicate rather than a field token or plain text. */ +export function looksLikeJs(text: string): boolean { + const trimmed = text.trim(); + if (trimmed.startsWith('js:') || trimmed.startsWith('javascript:')) { + return true; + } + return /return |=>|[=!<>]==?|&&|\|\||[();{}]/.test(trimmed) && !parseFilterInput(trimmed); +} + +/** Strip a `js:` / `javascript:` prefix from typed input, returning the code. */ +export function stripJsPrefix(text: string): string { + const trimmed = text.trim(); + if (trimmed.startsWith('js:')) { + return trimmed.slice(3).trim(); + } + if (trimmed.startsWith('javascript:')) { + return trimmed.slice('javascript:'.length).trim(); + } + return trimmed; +} diff --git a/frontend/src/components/pages/topics/messages/utils/live-window.test.ts b/frontend/src/components/pages/topics/messages/utils/live-window.test.ts new file mode 100644 index 0000000000..17e59a0d4f --- /dev/null +++ b/frontend/src/components/pages/topics/messages/utils/live-window.test.ts @@ -0,0 +1,35 @@ +/** + * Copyright 2025 Redpanda Data, Inc. + * + * Use of this software is governed by the Business Source License + * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md + * + * As of the Change Date specified in that file, in accordance with + * the Business Source License, use of this software will be governed + * by the Apache License, Version 2.0 + */ + +import { describe, expect, test } from 'vitest'; + +import { applyDisplayWindow } from './live-window'; + +describe('applyDisplayWindow', () => { + test('returns the same array reference when within the cap', () => { + const rows = [1, 2, 3]; + const result = applyDisplayWindow(rows, 5); + expect(result.rows).toBe(rows); + expect(result.trimmed).toBe(0); + }); + + test('trims the oldest rows (front) and reports the trimmed count', () => { + const rows = Array.from({ length: 10 }, (_, i) => i); + const result = applyDisplayWindow(rows, 4); + expect(result.rows).toEqual([6, 7, 8, 9]); + expect(result.trimmed).toBe(6); + }); + + test('handles cap equal to length', () => { + const rows = [1, 2]; + expect(applyDisplayWindow(rows, 2)).toEqual({ rows, trimmed: 0 }); + }); +}); diff --git a/frontend/src/components/pages/topics/messages/utils/live-window.ts b/frontend/src/components/pages/topics/messages/utils/live-window.ts new file mode 100644 index 0000000000..66727cb334 --- /dev/null +++ b/frontend/src/components/pages/topics/messages/utils/live-window.ts @@ -0,0 +1,24 @@ +/** + * Copyright 2025 Redpanda Data, Inc. + * + * Use of this software is governed by the Business Source License + * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md + * + * As of the Change Date specified in that file, in accordance with + * the Business Source License, use of this software will be governed + * by the Apache License, Version 2.0 + */ + +/** + * Bound the rows shown during live tail / continuous mode to the newest `cap` + * entries (rows arrive oldest→newest, so the front is trimmed). Returns the + * input array unchanged when it already fits, so referential equality holds + * for memoized consumers. + */ +export function applyDisplayWindow(rows: readonly T[], cap: number): { rows: readonly T[]; trimmed: number } { + if (rows.length <= cap) { + return { rows, trimmed: 0 }; + } + const trimmed = rows.length - cap; + return { rows: rows.slice(trimmed), trimmed }; +} diff --git a/frontend/src/components/pages/topics/messages/view-settings/column-list.tsx b/frontend/src/components/pages/topics/messages/view-settings/column-list.tsx new file mode 100644 index 0000000000..55d414a997 --- /dev/null +++ b/frontend/src/components/pages/topics/messages/view-settings/column-list.tsx @@ -0,0 +1,111 @@ +/** + * Copyright 2025 Redpanda Data, Inc. + * + * Use of this software is governed by the Business Source License + * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md + * + * As of the Change Date specified in that file, in accordance with + * the Business Source License, use of this software will be governed + * by the Apache License, Version 2.0 + */ + +import { DragDropContext, Draggable, Droppable, type DropResult } from '@hello-pangea/dnd'; +import { Checkbox } from 'components/redpanda-ui/components/checkbox'; +import { cn } from 'components/redpanda-ui/lib/utils'; +import { ChevronDownIcon, GripVerticalIcon, Settings2Icon } from 'lucide-react'; +import { type ReactNode, useState } from 'react'; + +import { COLUMN_LABELS } from '../constants'; +import type { MessageColumnConfig } from '../types'; + +/** Columns that expose extra configuration (decoder / display format / preview fields). */ +const CONFIGURABLE = new Set(['timestamp', 'key', 'value']); + +export type ColumnListProps = { + columns: MessageColumnConfig[]; + onColumnsChange: (columns: MessageColumnConfig[]) => void; + /** Per-column config content, rendered inside the expander of configurable columns. */ + renderConfig: (columnId: MessageColumnConfig['id']) => ReactNode; + /** Short summary shown next to the config toggle (e.g. current format/decoder). */ + configSummary: (columnId: MessageColumnConfig['id']) => string; +}; + +export const ColumnList = ({ columns, onColumnsChange, renderConfig, configSummary }: ColumnListProps) => { + const [expanded, setExpanded] = useState(null); + + const onDragEnd = (result: DropResult) => { + if (!result.destination || result.destination.index === result.source.index) { + return; + } + const next = [...columns]; + const [moved] = next.splice(result.source.index, 1); + next.splice(result.destination.index, 0, moved); + onColumnsChange(next); + }; + + const toggleVisible = (id: MessageColumnConfig['id']) => { + onColumnsChange(columns.map((c) => (c.id === id ? { ...c, visible: !c.visible } : c))); + }; + + return ( + + + {(dropProvided) => ( +
+ {columns.map((column, index) => ( + + {(dragProvided, snapshot) => ( +
+
+ + + + toggleVisible(column.id)} + testId={`column-toggle-${column.id}`} + /> + {COLUMN_LABELS[column.id]} + {CONFIGURABLE.has(column.id) && ( + + )} +
+ {expanded === column.id &&
{renderConfig(column.id)}
} +
+ )} +
+ ))} + {dropProvided.placeholder} +
+ )} +
+
+ ); +}; diff --git a/frontend/src/components/pages/topics/messages/view-settings/preview-fields-editor.tsx b/frontend/src/components/pages/topics/messages/view-settings/preview-fields-editor.tsx new file mode 100644 index 0000000000..6737c0d20d --- /dev/null +++ b/frontend/src/components/pages/topics/messages/view-settings/preview-fields-editor.tsx @@ -0,0 +1,187 @@ +/** + * Copyright 2025 Redpanda Data, Inc. + * + * Use of this software is governed by the Business Source License + * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md + * + * As of the Change Date specified in that file, in accordance with + * the Business Source License, use of this software will be governed + * by the Apache License, Version 2.0 + */ + +import { Button } from 'components/redpanda-ui/components/button'; +import { Checkbox } from 'components/redpanda-ui/components/checkbox'; +import { Combobox } from 'components/redpanda-ui/components/combobox'; +import { ToggleGroup, ToggleGroupItem } from 'components/redpanda-ui/components/toggle-group'; +import { XIcon } from 'lucide-react'; +import { useMemo } from 'react'; + +import type { PreviewTagV2 } from '../../../../../state/ui'; +import { useTopicSettingsStore } from '../../../../../stores/topic-settings-store'; +import { randomId } from '../../../../../utils/utils'; + +const OptionRow = ({ + label, + value, + options, + onChange, +}: { + label: string; + value: string; + options: { value: string; label: string }[]; + onChange: (value: string) => void; +}) => ( +
+
{label}
+ { + if (next.length > 0) { + onChange(next[0]); + } + }} + size="sm" + value={[value]} + > + {options.map((option) => ( + + {option.label} + + ))} + +
+); + +/** + * Preview fields for the Value column: glob patterns picked out of the decoded + * value and shown as `path=value` chips instead of the raw JSON line. + * Persists straight into the per-topic settings store (`previewTags` et al.). + */ +export const PreviewFieldsEditor = ({ topicName, valuePathHints }: { topicName: string; valuePathHints: string[] }) => { + const { + getPreviewTags, + setPreviewTags, + getPreviewTagsCaseSensitive, + setPreviewTagsCaseSensitive, + getPreviewMultiResultMode, + setPreviewMultiResultMode, + getPreviewDisplayMode, + setPreviewDisplayMode, + } = useTopicSettingsStore(); + + const tags = getPreviewTags(topicName); + const pathOptions = useMemo(() => valuePathHints.map((path) => ({ value: path, label: path })), [valuePathHints]); + + const updateTag = (id: string, patch: Partial) => { + setPreviewTags( + topicName, + tags.map((t) => (t.id === id ? { ...t, ...patch } : t)) + ); + }; + + return ( +
+
+
+ Preview fields +
+

+ Show only chosen fields from the value. Add glob patterns like{' '} + address.*. +

+
+ {tags.map((tag) => ( +
+ updateTag(tag.id, { isActive: checked === true })} + testId={`preview-tag-toggle-${tag.id}`} + /> + updateTag(tag.id, { pattern: value })} + onCreateOption={(value) => updateTag(tag.id, { pattern: value })} + options={pathOptions} + placeholder="field or glob" + start={null} + value={tag.pattern} + /> + +
+ ))} +
+ +
+ + setPreviewTagsCaseSensitive(topicName, v as 'caseSensitive' | 'ignoreCase')} + options={[ + { value: 'ignoreCase', label: 'Ignore case' }, + { value: 'caseSensitive', label: 'Case sensitive' }, + ]} + value={getPreviewTagsCaseSensitive(topicName)} + /> + setPreviewMultiResultMode(topicName, v as 'showOnlyFirst' | 'showAll')} + options={[ + { value: 'showOnlyFirst', label: 'First result' }, + { value: 'showAll', label: 'Show all' }, + ]} + value={getPreviewMultiResultMode(topicName)} + /> + setPreviewDisplayMode(topicName, v as 'single' | 'wrap' | 'rows')} + options={[ + { value: 'single', label: 'Single' }, + { value: 'wrap', label: 'Wrap' }, + { value: 'rows', label: 'Rows' }, + ]} + value={getPreviewDisplayMode(topicName)} + /> +
+ ); +}; diff --git a/frontend/src/components/pages/topics/messages/view-settings/view-settings-panel.test.tsx b/frontend/src/components/pages/topics/messages/view-settings/view-settings-panel.test.tsx new file mode 100644 index 0000000000..dfbbf02e3a --- /dev/null +++ b/frontend/src/components/pages/topics/messages/view-settings/view-settings-panel.test.tsx @@ -0,0 +1,76 @@ +/** + * Copyright 2025 Redpanda Data, Inc. + * + * Use of this software is governed by the Business Source License + * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md + * + * As of the Change Date specified in that file, in accordance with + * the Business Source License, use of this software will be governed + * by the Apache License, Version 2.0 + */ + +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import { ViewSettingsPanel, type ViewSettingsPanelProps } from './view-settings-panel'; +import { PayloadEncoding } from '../../../../../protogen/redpanda/api/console/v1alpha1/common_pb'; +import { DEFAULT_ROW_DENSITY, useTopicSettingsStore } from '../../../../../stores/topic-settings-store'; + +const TOPIC = 'view-settings-test-topic'; + +const renderPanel = (overrides: Partial = {}) => { + const props: ViewSettingsPanelProps = { + topicName: TOPIC, + onClose: vi.fn(), + keyDeserializer: PayloadEncoding.UNSPECIFIED, + onKeyDeserializerChange: vi.fn(), + valueDeserializer: PayloadEncoding.UNSPECIFIED, + onValueDeserializerChange: vi.fn(), + onResetDeserializers: vi.fn(), + valuePathHints: ['address', 'address.city'], + ...overrides, + }; + render(); + return props; +}; + +describe('ViewSettingsPanel', () => { + beforeEach(() => { + useTopicSettingsStore.setState({ perTopicSettings: [] }); + }); + + test('density toggle writes to the store instantly', async () => { + renderPanel(); + await userEvent.click(screen.getByTestId('view-settings-density-compact')); + expect(useTopicSettingsStore.getState().getRowDensity(TOPIC)).toBe('compact'); + }); + + test('column visibility toggle updates the store', async () => { + renderPanel(); + await userEvent.click(screen.getByTestId('column-toggle-offset')); + const columns = useTopicSettingsStore.getState().getMessageColumns(TOPIC); + expect(columns.find((c) => c.id === 'offset')?.visible).toBe(true); + }); + + test('column count summary reflects visibility', () => { + renderPanel(); + // Defaults: timestamp, key, value visible out of 7 + expect(screen.getByText('3 of 7')).toBeInTheDocument(); + }); + + test('preview field editor adds a pattern row', async () => { + renderPanel(); + await userEvent.click(screen.getByTestId('column-config-value')); + await userEvent.click(screen.getByTestId('preview-tag-add')); + expect(useTopicSettingsStore.getState().getPreviewTags(TOPIC)).toHaveLength(1); + }); + + test('reset restores defaults and resets deserializers', async () => { + const props = renderPanel(); + useTopicSettingsStore.getState().setRowDensity(TOPIC, 'compact'); + await userEvent.click(screen.getByTestId('view-settings-reset')); + expect(useTopicSettingsStore.getState().getRowDensity(TOPIC)).toBe(DEFAULT_ROW_DENSITY); + expect(props.onResetDeserializers).toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/components/pages/topics/messages/view-settings/view-settings-panel.tsx b/frontend/src/components/pages/topics/messages/view-settings/view-settings-panel.tsx new file mode 100644 index 0000000000..7b622fd2bd --- /dev/null +++ b/frontend/src/components/pages/topics/messages/view-settings/view-settings-panel.tsx @@ -0,0 +1,257 @@ +/** + * Copyright 2025 Redpanda Data, Inc. + * + * Use of this software is governed by the Business Source License + * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md + * + * As of the Change Date specified in that file, in accordance with + * the Business Source License, use of this software will be governed + * by the Apache License, Version 2.0 + */ + +import { Button } from 'components/redpanda-ui/components/button'; +import { Label } from 'components/redpanda-ui/components/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from 'components/redpanda-ui/components/select'; +import { ToggleGroup, ToggleGroupItem } from 'components/redpanda-ui/components/toggle-group'; +import { XIcon } from 'lucide-react'; + +import { ColumnList } from './column-list'; +import { PreviewFieldsEditor } from './preview-fields-editor'; +import type { PayloadEncoding } from '../../../../../protogen/redpanda/api/console/v1alpha1/common_pb'; +import type { TimestampDisplayFormat } from '../../../../../state/ui'; +import { useTopicSettingsStore } from '../../../../../stores/topic-settings-store'; +import { PAYLOAD_ENCODING_LABELS, PAYLOAD_ENCODING_PAIRS } from '../constants'; +import type { MessageColumnConfig } from '../types'; + +const TS_FORMATS: { value: TimestampDisplayFormat; label: string }[] = [ + { value: 'default', label: 'Local DateTime' }, + { value: 'unixTimestamp', label: 'Unix Seconds' }, + { value: 'relative', label: 'Relative' }, + { value: 'onlyDate', label: 'Local Date' }, + { value: 'onlyTime', label: 'Local Time' }, + { value: 'unixMillis', label: 'Unix Millis' }, +]; + +const DeserializerSelect = ({ + id, + value, + onChange, +}: { + id: string; + value: PayloadEncoding; + onChange: (encoding: PayloadEncoding) => void; +}) => ( + +); + +export type ViewSettingsPanelProps = { + topicName: string; + onClose: () => void; + keyDeserializer: PayloadEncoding; + onKeyDeserializerChange: (encoding: PayloadEncoding) => void; + valueDeserializer: PayloadEncoding; + onValueDeserializerChange: (encoding: PayloadEncoding) => void; + onResetDeserializers: () => void; + /** Dotted paths seen in loaded values — autocomplete hints for preview patterns. */ + valuePathHints: string[]; +}; + +/** + * Docked "View settings" panel (shares the right dock slot with the message + * detail panel). Every change applies to the table instantly. + */ +export const ViewSettingsPanel = ({ + topicName, + onClose, + keyDeserializer, + onKeyDeserializerChange, + valueDeserializer, + onValueDeserializerChange, + onResetDeserializers, + valuePathHints, +}: ViewSettingsPanelProps) => { + const { + getRowDensity, + setRowDensity, + getMessageColumns, + setMessageColumns, + getTopicSettings, + setTopicSettings, + resetViewSettings, + } = useTopicSettingsStore(); + + const density = getRowDensity(topicName); + const columns = getMessageColumns(topicName); + const tsFormat = getTopicSettings(topicName)?.previewTimestamps ?? 'default'; + + const configSummary = (columnId: MessageColumnConfig['id']): string => { + switch (columnId) { + case 'timestamp': + return TS_FORMATS.find((f) => f.value === tsFormat)?.label ?? 'Local DateTime'; + case 'key': + return PAYLOAD_ENCODING_LABELS[keyDeserializer]; + case 'value': + return PAYLOAD_ENCODING_LABELS[valueDeserializer]; + default: + return ''; + } + }; + + const renderConfig = (columnId: MessageColumnConfig['id']) => { + switch (columnId) { + case 'timestamp': + return ( +
+ + +
+ ); + case 'key': + return ( +
+ + +

+ How key bytes are decoded for display. Automatic detects the format. +

+
+ ); + case 'value': + return ( +
+
+ + +
+ +
+ ); + default: + return null; + } + }; + + return ( +
+
+
+
View settings
+
Changes apply to the table instantly
+
+ +
+ +
+
+
Row density
+ { + if (next.length > 0) { + setRowDensity(topicName, next[0] as 'compact' | 'detailed'); + } + }} + size="sm" + value={[density]} + > + + Compact + + + Detailed + + +

+ Detailed shows the decoder badge and byte size inline. Compact hides them for a denser table. +

+
+ +
+
+
Columns
+ + {columns.filter((c) => c.visible).length} of {columns.length} + +
+

+ Drag to reorder · toggle to show or hide. Configurable columns expose their decoder and display options. +

+ setMessageColumns(topicName, next)} + renderConfig={renderConfig} + /> +
+
+ +
+ +
+
+ ); +}; diff --git a/frontend/src/components/pages/topics/quick-info.tsx b/frontend/src/components/pages/topics/quick-info.tsx index 896ec8e45c..0dcbeafcaa 100644 --- a/frontend/src/components/pages/topics/quick-info.tsx +++ b/frontend/src/components/pages/topics/quick-info.tsx @@ -12,8 +12,9 @@ import { useApiStoreHook } 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'; -import { InfoIcon } from 'components/icons'; +import { Stat } from 'components/redpanda-ui/components/stat'; +import { Tooltip, TooltipContent, TooltipTrigger } from 'components/redpanda-ui/components/tooltip'; +import { InfoIcon } from 'lucide-react'; import type { ReactNode } from 'react'; import type { CleanupPolicyType } from './types'; @@ -21,8 +22,16 @@ import { formatConfigValue } from '../../../utils/formatters/config-value-format import { numberToThousandsString } from '../../../utils/tsx-utils'; import { prettyBytesOrNA } from '../../../utils/utils'; +const CLEANUP_POLICY_LABELS: Record = { + compact: 'Compact', + 'compact,delete': 'Compact & Delete', + delete: 'Delete', +}; + +const ESTIMATE_HINT = + 'The number of messages shown is an estimate. This is calculated by summing the differences between the highest and lowest offsets in each partition. The actual number of messages may vary due to factors such as message deletions, log compaction, and uncommitted or transactional messages.'; + // todo: rename QuickInfo -// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: complex business logic export const TopicQuickInfoStatistic = (p: { topic: Topic }) => { const topic = p.topic; @@ -56,97 +65,59 @@ export const TopicQuickInfoStatistic = (p: { topic: Topic }) => { } return ( - - - - Size: - - {topic ? prettyBytesOrNA(topic.logDirSummary.totalSizeBytes) : '...'} - - - - - - - - - - - - Estimated messages: - - {messageSum} - - - - - {Boolean(cleanupPolicy) && ( - - - Cleanup Policy: - - - {( - { - compact: 'Compact', - 'compact,delete': 'Compact & Delete', - delete: 'Delete', - } as Record - )[cleanupPolicy as CleanupPolicyType] ?? ''} - - +
+ + + {messageSum} + + } + /> + {ESTIMATE_HINT} + + + } + /> + + {cleanupPolicy === 'compact' && segmentMs && segmentBytes && ( + + ~{formatConfigValue(segmentMs.name, segmentMs.value, 'friendly')} or{' '} + {formatConfigValue(segmentBytes.name, segmentBytes.value, 'friendly')} + {Number.isFinite(Number(segmentBytes.value)) && Number(segmentBytes.value) !== -1 && ' / partition'} + + } + /> )} - - - - - {cleanupPolicy === 'compact' && ( - <> - - Segment: - - {segmentMs && segmentBytes ? ( - - ~{formatConfigValue(segmentMs.name, segmentMs.value, 'friendly')} or{' '} - {formatConfigValue(segmentBytes.name, segmentBytes.value, 'friendly')} - {Number.isFinite(Number(segmentBytes.value)) && Number(segmentBytes.value) !== -1 && ' / partition'} - - ) : null} - - )} - - {cleanupPolicy === 'delete' && retentionMs && retentionBytes && ( - <> - - - Retention Time: - - + {cleanupPolicy === 'delete' && retentionMs && retentionBytes && ( + <> + {retentionMs.value !== '-1' && '~'} {formatConfigValue(retentionMs.name, retentionMs.value, 'friendly')} - - - - - - - - Retention Size: - - + + } + /> + {retentionBytes.value !== '-1' && '~'} {formatConfigValue(retentionBytes.name, retentionBytes.value, 'friendly')} {Number.isFinite(Number(retentionBytes.value)) && Number(retentionBytes.value) !== -1 && ' / partition'} - - - - )} - - + + } + /> + + )} +
); }; diff --git a/frontend/src/components/pages/topics/topic-details.tsx b/frontend/src/components/pages/topics/topic-details.tsx index 5f628c5343..f9b7b3996e 100644 --- a/frontend/src/components/pages/topics/topic-details.tsx +++ b/frontend/src/components/pages/topics/topic-details.tsx @@ -32,9 +32,9 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from 'components/redpanda-ui import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from 'components/redpanda-ui/components/tooltip'; import DeleteRecordsModal from './DeleteRecordsModal/delete-records-modal'; +import { TopicMessagesTab } from './messages'; import { TopicQuickInfoStatistic } from './quick-info'; import AclList from './Tab.Acl/acl-list'; -import { TopicMessageView } from './Tab.Messages'; import { DeleteRecordsMenuItem } from './Tab.Messages/common/delete-records-menu-item'; import { TopicConfiguration } from './tab-config'; import { TopicConsumers } from './tab-consumers'; @@ -272,7 +272,7 @@ const TopicDetailsContent = ({ topic, topicName }: { topic: Topic; topicName: st <> {(t) => ( - refreshTopicData(topicName, force)} topic={t} /> + refreshTopicData(topicName, force)} topic={t} /> )} diff --git a/frontend/src/components/redpanda-ui/components/stat/index.tsx b/frontend/src/components/redpanda-ui/components/stat/index.tsx new file mode 100644 index 0000000000..ba40b8de3d --- /dev/null +++ b/frontend/src/components/redpanda-ui/components/stat/index.tsx @@ -0,0 +1,170 @@ +import { cva, type VariantProps } from 'class-variance-authority'; +import { ArrowDown, ArrowUp, ArrowUpRight, Minus } from 'lucide-react'; +import React from 'react'; + +import { cn, type SharedProps } from '../../lib/utils'; + +export const statValueVariants = cva('leading-none', { + variants: { + size: { + sm: 'text-sm', + md: 'text-base', + lg: 'font-bold text-2xl tracking-tighter', + }, + tone: { + default: 'text-foreground', + muted: 'text-muted-foreground', + success: 'text-success', + warning: 'text-warning', + destructive: 'text-destructive', + }, + mono: { + true: 'font-mono tabular-nums', + false: '', + }, + }, + defaultVariants: { + size: 'md', + tone: 'default', + mono: false, + }, +}); + +export type StatDeltaDirection = 'up' | 'down' | 'neutral'; + +export interface StatDelta { + /** Formatted change text, e.g. "+12%" or "-3.2k". */ + value: string; + /** Drives the icon and default color. */ + direction: StatDeltaDirection; + /** Override the semantic tone of the delta. Defaults to direction-based color. */ + tone?: 'default' | 'muted' | 'success' | 'warning' | 'destructive'; +} + +const deltaIcons: Record> = { + up: ArrowUp, + down: ArrowDown, + neutral: Minus, +}; + +const deltaToneByDirection: Record> = { + up: 'success', + down: 'destructive', + neutral: 'muted', +}; + +const deltaToneClasses: Record, string> = { + default: 'text-foreground', + muted: 'text-muted-foreground', + success: 'text-success', + warning: 'text-warning', + destructive: 'text-destructive', +}; + +export interface StatProps + extends Omit, 'children'>, + VariantProps, + SharedProps { + /** Caption rendered above the value, uppercased and muted. */ + label: string; + value: React.ReactNode; + /** Secondary line rendered below the value (e.g. "6 partitions"), muted and smaller. */ + sublabel?: React.ReactNode; + delta?: StatDelta; + /** Turns the label into a link. Pass a single link element with no children — label text and trailing arrow are injected and link styling merged in. */ + labelLink?: React.ReactElement<{ className?: string }>; +} + +// Mirrors `labelStrongXSmall` typography so a linked label is visually identical to a plain one. +const LABEL_LINK_CLASSNAME = + 'inline-flex items-center gap-1 font-semibold text-body-sm text-muted-foreground uppercase transition-colors hover:text-foreground'; + +export const Stat = React.forwardRef( + ({ className, label, value, sublabel, size, tone, mono, delta, labelLink, testId, ...props }, ref) => { + const deltaTone = delta ? (delta.tone ?? deltaToneByDirection[delta.direction]) : undefined; + const DeltaIcon = delta ? deltaIcons[delta.direction] : undefined; + + const labelNode = labelLink ? ( + React.cloneElement( + labelLink, + { className: cn(LABEL_LINK_CLASSNAME, labelLink.props.className) }, + <> + {label} +
@@ -69,8 +69,10 @@ const DetailSection = ({ const MetaRow = ({ label, children }: { label: string; children: ReactNode }) => (
-
{label}
-
{children}
+
+ {label} +
+
{children}
); @@ -112,13 +114,13 @@ const TroubleshootNote = ({ payload }: { payload: Payload }) => { } return (
-
+
Errors were encountered when deserializing this payload
{report.map((entry) => ( -
+
{entry.serdeName}: {entry.message}
))} @@ -143,7 +145,7 @@ export const KeySection = ({ msg, open, onOpenChange }: SectionProps) => ( open={open} testId="detail-key-section" > -
+
{msg.key.isPayloadNull ? null : msg.keyJson}
@@ -161,8 +163,8 @@ const headerValueText = (value: Payload) => { const HeaderGrid = ({ headers }: { headers: TopicMessage['headers'] }) => (
-
Key
-
+
Key
+
Value
@@ -170,7 +172,7 @@ const HeaderGrid = ({ headers }: { headers: TopicMessage['headers'] }) => ( const text = headerValueText(header.value); return (
@@ -196,7 +198,7 @@ export const HeadersSection = ({ msg, open, onOpenChange }: SectionProps) => ( {msg.headers.length > 0 ? ( ) : ( -
This record carries no headers.
+
This record carries no headers.
)} ); @@ -221,7 +223,7 @@ export const ValueSection = ({ open={open} testId="detail-value-section" > -
+
- Message + Message - diff --git a/frontend/src/components/pages/topics/messages/topic-messages-view.tsx b/frontend/src/components/pages/topics/messages/topic-messages-view.tsx index 6c7c10aa2e..6a1e6ec96a 100644 --- a/frontend/src/components/pages/topics/messages/topic-messages-view.tsx +++ b/frontend/src/components/pages/topics/messages/topic-messages-view.tsx @@ -517,7 +517,6 @@ export const TopicMessagesView = ({ topic }: TopicMessagesViewProps) => { {jsDialog && ( setJsDialog(null)} onSave={(saved) => setJsFilters((prev) =>