Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions frontend/src/components/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export const FEATURE_FLAGS = {
enablePipelineDiagrams: false,
enableConnectSlashMenu: false,
enableNewSecurityPage: true,
enableNewTopicMessagesPage: false,
};

// Cloud-managed tag keys for service account integration
Expand Down
28 changes: 1 addition & 27 deletions frontend/src/components/pages/topics/Tab.Messages/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<PayloadEncoding, string>
);
import { PAYLOAD_ENCODING_LABELS } from '../messages/constants';

type TopicMessageViewProps = {
topic: Topic;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -122,7 +122,12 @@ function preparePayloadData(payload: Payload): PayloadRenderData {
}
}

export const PayloadComponent = (p: { payload: Payload; loadLargeMessage: () => Promise<void> }) => {
export const PayloadComponent = (p: {
payload: Payload;
loadLargeMessage: () => Promise<void>;
/** 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]);
Expand Down Expand Up @@ -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 <KowlJsonView escapeLatin1={payload.encoding === 'avro'} srcObj={renderData.content} />;
return (
<KowlJsonView escapeLatin1={payload.encoding === 'avro'} srcObj={renderData.content} style={p.viewerStyle} />
);
}
return <span style={{ color: 'red' }}>Error in RenderExpandedMessage: {renderData.content}</span>;
};
Original file line number Diff line number Diff line change
Expand Up @@ -339,7 +339,7 @@ export function getPreviewTags(
const displayName = tag.customName && tag.customName.length > 0 ? tag.customName : r.fullPath;

ar.push(
<span className="previewTag">
<span className="previewTag" key={`${tag.id}-${r.fullPath}-${ar.length}`}>
<span className="path">{displayName}</span>
<span>{toSafeString(r.prop.value)}</span>
</span>
Expand Down
56 changes: 56 additions & 0 deletions frontend/src/components/pages/topics/messages/constants.ts
Original file line number Diff line number Diff line change
@@ -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<DataColumnKey, string> = {
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<PayloadEncoding, string>
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
/**
* 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;
}) => (
<Collapsible
className={cn('shrink-0 border-b px-3 py-2.5 last:border-b-0', fill && open && 'flex min-h-0 flex-1 flex-col')}
onOpenChange={onOpenChange}
open={open}
testId={testId}
>
<div className="flex items-center justify-between gap-2">
<CollapsibleTrigger className="flex min-w-0 flex-1 cursor-pointer select-none items-center gap-1.5">
<ChevronRightIcon
className={`size-3 shrink-0 text-muted-foreground transition-transform ${open ? 'rotate-90' : ''}`}
/>
<span className="font-semibold text-caption text-muted-foreground uppercase tracking-wider">{label}</span>
</CollapsibleTrigger>
<div className="flex items-center gap-2">
{meta && <span className="whitespace-nowrap font-mono text-caption text-muted-foreground">{meta}</span>}
{copyContent !== undefined && <CopyButton className="size-6" content={copyContent} size="sm" variant="ghost" />}
</div>
</div>
<CollapsibleContent className={fill ? 'min-h-0 flex-1' : undefined}>
<div className={cn('pt-1.5', fill && 'flex h-full flex-col')}>{children}</div>
</CollapsibleContent>
</Collapsible>
);

const MetaRow = ({ label, children }: { label: string; children: ReactNode }) => (
<div className="grid grid-cols-[minmax(110px,42%)_1fr] border-b last:border-b-0">
<div className="px-2.5 py-1.5 font-semibold text-caption text-muted-foreground uppercase tracking-wide">
{label}
</div>
<div className="min-w-0 break-all border-l px-2.5 py-1.5 font-mono text-body-sm">{children}</div>
</div>
);

/** 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) => (
<DetailSection label="Metadata" onOpenChange={onOpenChange} open={open} testId="detail-metadata-section">
<div className="overflow-hidden rounded-md border bg-card">
<MetaRow label="Timestamp">
<TimestampDisplay format="default" unixEpochMillisecond={msg.timestamp} />
</MetaRow>
<MetaRow label="Partition">{msg.partitionID}</MetaRow>
<MetaRow label="Offset">{msg.offset.toLocaleString()}</MetaRow>
<MetaRow label="Headers">{msg.headers.length}</MetaRow>
<MetaRow label="Compression">
<Badge className="font-mono" size="sm" tone="neutral" variant="subtle">
{msg.compression}
</Badge>
</MetaRow>
<MetaRow label="Transactional">{msg.isTransactional ? 'true' : 'false'}</MetaRow>
<MetaRow label="Key size">{prettyBytes(msg.key.size)}</MetaRow>
<MetaRow label="Value size">{prettyBytes(msg.value.size)}</MetaRow>
</div>
</DetailSection>
);

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 (
<div className="mt-1.5 rounded-md border border-destructive/40 bg-destructive/5 px-2.5 py-2">
<div className="mb-1 flex items-center gap-1.5 font-semibold text-body-sm text-destructive">
<AlertTriangleIcon className="size-3.5 shrink-0" />
Errors were encountered when deserializing this payload
</div>
<div className="flex flex-col gap-1">
{report.map((entry) => (
<div className="break-words font-mono text-body-sm leading-relaxed" key={entry.serdeName}>
<span className="font-semibold capitalize">{entry.serdeName}:</span> {entry.message}
</div>
))}
</div>
</div>
);
};

const payloadCopyText = (payload: Payload, json: string) => {
if (payload.isPayloadNull) {
return 'null';
}
return json;
};

export const KeySection = ({ msg, open, onOpenChange }: SectionProps) => (
<DetailSection
copyContent={payloadCopyText(msg.key, msg.keyJson)}
label="Key"
meta={payloadMeta(msg.key)}
onOpenChange={onOpenChange}
open={open}
testId="detail-key-section"
>
<div className="break-all rounded-md border bg-muted px-2.5 py-1.5 font-mono text-body-sm">
{msg.key.isPayloadNull ? <span className="text-muted-foreground italic">null</span> : msg.keyJson}
</div>
<TroubleshootNote payload={msg.key} />
</DetailSection>
);

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'] }) => (
<div className="overflow-hidden rounded-md border bg-card">
<div className="grid grid-cols-[minmax(110px,42%)_1fr] border-b">
<div className="px-2.5 py-1 font-semibold text-caption text-muted-foreground uppercase tracking-wide">Key</div>
<div className="border-l px-2.5 py-1 font-semibold text-caption text-muted-foreground uppercase tracking-wide">
Value
</div>
</div>
{headers.map((header, i) => {
const text = headerValueText(header.value);
return (
<div
className="grid grid-cols-[minmax(110px,42%)_1fr] border-b font-mono text-body-sm last:border-b-0"
// biome-ignore lint/suspicious/noArrayIndexKey: header keys can repeat; order is stable
key={`${header.key}-${i}`}
>
<div className="min-w-0 break-all px-2.5 py-1.5 text-primary">{header.key}</div>
<div className="min-w-0 break-all border-l px-2.5 py-1.5">
{text === null ? <span className="text-muted-foreground italic">null</span> : text}
</div>
</div>
);
})}
</div>
);

export const HeadersSection = ({ msg, open, onOpenChange }: SectionProps) => (
<DetailSection
copyContent={msg.headers.length > 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 ? (
<HeaderGrid headers={msg.headers} />
) : (
<div className="py-2 text-body-sm text-muted-foreground">This record carries no headers.</div>
)}
</DetailSection>
);

export const ValueSection = ({
msg,
loadLargeMessage,
fill,
open,
onOpenChange,
}: SectionProps & {
loadLargeMessage: () => Promise<void>;
/** Stretch the value viewer to the remaining panel height (expanded sheet). */
fill?: boolean;
}) => (
<DetailSection
copyContent={payloadCopyText(msg.value, msg.valueJson)}
fill={fill}
label="Value"
meta={payloadMeta(msg.value)}
onOpenChange={onOpenChange}
open={open}
testId="detail-value-section"
>
<div className={cn('font-mono text-body-sm leading-relaxed', fill && 'min-h-0 flex-1')}>
<PayloadComponent
loadLargeMessage={loadLargeMessage}
payload={msg.value}
viewerStyle={fill ? { height: '100%', maxHeight: 'none' } : undefined}
/>
</div>
<TroubleshootNote payload={msg.value} />
</DetailSection>
);
Loading
Loading