This is an automated email from the ASF dual-hosted git repository.
lizhimins pushed a commit to branch rocketmq-studio
in repository https://gitbox.apache.org/repos/asf/rocketmq-dashboard.git
The following commit(s) were added to refs/heads/rocketmq-studio by this push:
new 4c66ebfc3 fix(queue-browser): localize the queue browser (#6482)
4c66ebfc3 is described below
commit 4c66ebfc367361722c102fec4d1fc42ae8876b73
Author: 0 <[email protected]>
AuthorDate: Thu Oct 15 20:21:35 2026 +0800
fix(queue-browser): localize the queue browser (#6482)
* fix(web): take the Queue Browser strings through the translation table
Every user-facing string in the queue browser panel was hardcoded
Chinese: the topic placeholder, the load button, both empty-state hints,
the column titles, the view action, the queue summary, the detail labels,
the no-message placeholder and both error toasts. A viewer who switched
the display language to English kept getting Chinese on this panel.
The panel now resolves through t(): it reuses messagePage.topicPlaceholder,
messagePage.storeTime, messagePage.size, common.actions and common.view,
and adds a queueBrowser.* group for the panel-specific strings, with a
test that renders the panel under the English locale.
Fixes #5220
* test(queue-browser): cover the six queueBrowser keys that had no assertion
Only queueBrowser.loadQueues and queueBrowser.emptyHint were asserted, so
dropping loadQueuesFailed, pullFailed, offsetRange, queueSummary,
detailEmptyHint or noMessageAtOffset from the table left every test green.
Add four cases that render the panel under the en locale: the queue table
headers plus the summary with both counters interpolated to real numbers,
both error toasts through a non-Error rejection so the dictionary fallback
is what fires, and the pulled-message detail covering the empty-offset
placeholder and the formatBytes size cell.
Also re-run Prettier over QueueBrowser.tsx: the upstream merge left the
MessageProperties element misindented, which fails the format check.
---------
Co-authored-by: lizhimins <[email protected]>
---
web/src/components/QueueBrowser.tsx | 456 +++++++++++----------
web/src/components/__tests__/QueueBrowser.test.tsx | 160 +++++++-
web/src/i18n/translations.ts | 22 +
3 files changed, 414 insertions(+), 224 deletions(-)
diff --git a/web/src/components/QueueBrowser.tsx
b/web/src/components/QueueBrowser.tsx
index 9de3120b5..df00a390e 100644
--- a/web/src/components/QueueBrowser.tsx
+++ b/web/src/components/QueueBrowser.tsx
@@ -34,8 +34,9 @@ import {
import { CloseOutlined, SearchOutlined } from '@ant-design/icons';
import type { MessageRecord, QueueOffset } from '../api/message';
import { getQueueOffsets, pullMessageAtOffset } from '../api/message';
+import { useLang } from '../i18n/LangContext';
import MessageProperties from './MessageProperties';
-import { formatTimeMs } from '../utils/format';
+import { formatBytes, formatTimeMs } from '../utils/format';
const { Text, Paragraph } = Typography;
@@ -51,6 +52,7 @@ export interface PulledEntry {
}
export const useQueueBrowser = (instanceId?: string) => {
+ const { t } = useLang();
const [topic, setTopic] = useState<string | undefined>();
const [queues, setQueues] = useState<QueueOffset[]>([]);
const [loading, setLoading] = useState(false);
@@ -103,7 +105,7 @@ export const useQueueBrowser = (instanceId?: string) => {
setOffsets(initial);
} catch (err) {
if (requestId === requestSeqRef.current) {
- message.error(err instanceof Error ? err.message : '加载队列信息失败');
+ message.error(err instanceof Error ? err.message :
t('queueBrowser.loadQueuesFailed'));
}
} finally {
if (requestId === requestSeqRef.current) {
@@ -111,7 +113,7 @@ export const useQueueBrowser = (instanceId?: string) => {
setLoading(false);
}
}
- }, [instanceId, topic]);
+ }, [instanceId, topic, t]);
const handlePull = async (queue: QueueOffset) => {
if (!instanceId || !topic) return;
@@ -139,7 +141,7 @@ export const useQueueBrowser = (instanceId?: string) => {
]);
} catch (err) {
if (ownsPull()) {
- message.error(err instanceof Error ? err.message : '拉取消息失败');
+ message.error(err instanceof Error ? err.message :
t('queueBrowser.pullFailed'));
}
} finally {
if (ownsPull()) {
@@ -184,228 +186,236 @@ export const QueueBrowserControls = ({
state,
topicOptions,
topicLoading,
-}: ControlsProps) => (
- <Flex gap={12} align="center">
- <Select
- showSearch
- allowClear
- placeholder="选择 Topic"
- value={state.topic}
- onChange={state.setTopic}
- options={topicOptions}
- loading={topicLoading}
- style={{ width: 280 }}
- />
- <Button
- type="primary"
- icon={<SearchOutlined />}
- disabled={!instanceId || !state.topic}
- loading={state.loading}
- onClick={() => void state.loadQueues()}
- >
- 加载队列
- </Button>
- </Flex>
-);
-
-export const QueueBrowserResults = ({ state }: { state: QueueBrowserState })
=> (
- <Card>
- {state.loading ? (
- <Flex justify="center" style={{ padding: 32 }}>
- <Spin />
- </Flex>
- ) : state.queues.length === 0 ? (
- <Empty
- image={Empty.PRESENTED_IMAGE_SIMPLE}
- description="选择 Topic 并点击「加载队列」,按队列浏览消息"
- style={{ padding: '32px 0' }}
+}: ControlsProps) => {
+ const { t } = useLang();
+ return (
+ <Flex gap={12} align="center">
+ <Select
+ showSearch
+ allowClear
+ placeholder={t('messagePage.topicPlaceholder')}
+ value={state.topic}
+ onChange={state.setTopic}
+ options={topicOptions}
+ loading={topicLoading}
+ style={{ width: 280 }}
/>
- ) : (
- <Flex gap={16} align="flex-start">
- {/* 左侧:队列表格 */}
- <div style={{ width: '50%', flexShrink: 0 }}>
- <Table<QueueOffset>
- rowKey={(r) => `${r.brokerName}-${r.queueId}`}
- dataSource={state.queues}
- size="small"
- pagination={false}
- columns={[
- {
- title: 'Broker',
- dataIndex: 'brokerName',
- width: 180,
- ellipsis: { showTitle: false },
- render: (v: string) => (
- <Tooltip title={v}>
- <Text strong style={{ fontSize: 14 }}>
- {v}
- </Text>
- </Tooltip>
- ),
- },
- {
- title: 'Queue',
- dataIndex: 'queueId',
- width: 50,
- align: 'center',
- render: (v: number) => <Text style={{ fontSize: 14
}}>{v}</Text>,
- },
- {
- title: 'Offset 范围',
- key: 'offset',
- width: 170,
- render: (_: unknown, record: QueueOffset) => {
- const key = `${record.brokerName}-${record.queueId}`;
- const currentOffset = state.offsets[key] ?? record.minOffset;
- return (
- <Flex align="center" gap={8}>
- <Text type="secondary" style={{ fontSize: 14,
flexShrink: 0 }}>
- {record.minOffset}
- </Text>
- <Slider
- style={{ flex: 1, margin: 0 }}
- min={record.minOffset}
- max={
- record.maxOffset > record.minOffset
- ? record.maxOffset - 1
- : record.minOffset
- }
- value={currentOffset}
- onChange={(value) =>
- state.setOffsets((prev) => ({ ...prev, [key]: value
}))
- }
- tooltip={{ formatter: (v) => `offset: ${v}` }}
- />
- <Text code style={{ fontSize: 14, flexShrink: 0 }}>
- {currentOffset}
+ <Button
+ type="primary"
+ icon={<SearchOutlined />}
+ disabled={!instanceId || !state.topic}
+ loading={state.loading}
+ onClick={() => void state.loadQueues()}
+ >
+ {t('queueBrowser.loadQueues')}
+ </Button>
+ </Flex>
+ );
+};
+
+export const QueueBrowserResults = ({ state }: { state: QueueBrowserState })
=> {
+ const { t } = useLang();
+ return (
+ <Card>
+ {state.loading ? (
+ <Flex justify="center" style={{ padding: 32 }}>
+ <Spin />
+ </Flex>
+ ) : state.queues.length === 0 ? (
+ <Empty
+ image={Empty.PRESENTED_IMAGE_SIMPLE}
+ description={t('queueBrowser.emptyHint')}
+ style={{ padding: '32px 0' }}
+ />
+ ) : (
+ <Flex gap={16} align="flex-start">
+ {/* 左侧:队列表格 */}
+ <div style={{ width: '50%', flexShrink: 0 }}>
+ <Table<QueueOffset>
+ rowKey={(r) => `${r.brokerName}-${r.queueId}`}
+ dataSource={state.queues}
+ size="small"
+ pagination={false}
+ columns={[
+ {
+ title: 'Broker',
+ dataIndex: 'brokerName',
+ width: 180,
+ ellipsis: { showTitle: false },
+ render: (v: string) => (
+ <Tooltip title={v}>
+ <Text strong style={{ fontSize: 14 }}>
+ {v}
</Text>
- </Flex>
- );
+ </Tooltip>
+ ),
},
- },
- {
- title: '操作',
- key: 'action',
- width: 70,
- align: 'center',
- render: (_: unknown, record: QueueOffset) => {
- const key = `${record.brokerName}-${record.queueId}`;
- return (
- <Button
- size="small"
- type="primary"
- loading={state.pulling.has(key)}
- onClick={() => void state.handlePull(record)}
- >
- 查看
- </Button>
- );
+ {
+ title: 'Queue',
+ dataIndex: 'queueId',
+ width: 50,
+ align: 'center',
+ render: (v: number) => <Text style={{ fontSize: 14
}}>{v}</Text>,
},
- },
- ]}
- />
- <Text type="secondary" style={{ display: 'block', marginTop: 8,
fontSize: 14 }}>
- 共 {state.queues.length} 个队列,总消息量{' '}
- {state.queues.reduce((sum, q) => sum + (q.maxOffset -
q.minOffset), 0)} 条
- </Text>
- </div>
-
- {/* 右侧:消息详情(2 列,可多条并存) */}
- <div style={{ flex: 1, minWidth: 0 }}>
- {state.entries.length === 0 ? (
- <Empty
- image={Empty.PRESENTED_IMAGE_SIMPLE}
- description="点击左侧「查看」,消息详情将显示在这里"
- style={{ padding: '32px 0' }}
+ {
+ title: t('queueBrowser.offsetRange'),
+ key: 'offset',
+ width: 170,
+ render: (_: unknown, record: QueueOffset) => {
+ const key = `${record.brokerName}-${record.queueId}`;
+ const currentOffset = state.offsets[key] ??
record.minOffset;
+ return (
+ <Flex align="center" gap={8}>
+ <Text type="secondary" style={{ fontSize: 14,
flexShrink: 0 }}>
+ {record.minOffset}
+ </Text>
+ <Slider
+ style={{ flex: 1, margin: 0 }}
+ min={record.minOffset}
+ max={
+ record.maxOffset > record.minOffset
+ ? record.maxOffset - 1
+ : record.minOffset
+ }
+ value={currentOffset}
+ onChange={(value) =>
+ state.setOffsets((prev) => ({ ...prev, [key]:
value }))
+ }
+ tooltip={{ formatter: (v) => `offset: ${v}` }}
+ />
+ <Text code style={{ fontSize: 14, flexShrink: 0 }}>
+ {currentOffset}
+ </Text>
+ </Flex>
+ );
+ },
+ },
+ {
+ title: t('common.actions'),
+ key: 'action',
+ width: 70,
+ align: 'center',
+ render: (_: unknown, record: QueueOffset) => {
+ const key = `${record.brokerName}-${record.queueId}`;
+ return (
+ <Button
+ size="small"
+ type="primary"
+ loading={state.pulling.has(key)}
+ onClick={() => void state.handlePull(record)}
+ >
+ {t('common.view')}
+ </Button>
+ );
+ },
+ },
+ ]}
/>
- ) : (
- <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr',
gap: 12 }}>
- {state.entries.map((entry) => (
- <Card
- key={entry.key}
- size="small"
- style={{ borderRadius: 8 }}
- title={
- <Space size={4}>
- <Tag color="blue" style={{ marginInlineEnd: 0 }}>
- {entry.key}
- </Tag>
- <Text type="secondary" style={{ fontSize: 14 }}>
- @ {entry.offset}
- </Text>
- </Space>
- }
- extra={
- <Button
- type="text"
- size="small"
- icon={<CloseOutlined />}
- onClick={() => state.closeEntry(entry.key)}
- />
- }
- >
- {entry.message ? (
- <>
- <Descriptions column={1} size="small">
- <Descriptions.Item label="Message ID">
- <Paragraph
- copyable
- style={{ marginBottom: 0, fontFamily: 'monospace',
fontSize: 14 }}
- >
- {entry.message.msgId}
- </Paragraph>
- </Descriptions.Item>
- <Descriptions.Item label="Tag">
- <Tag>{entry.message.tag || '-'}</Tag>
- </Descriptions.Item>
- <Descriptions.Item label="Key">
- <span style={{ fontFamily: 'monospace', fontSize: 14
}}>
- {entry.message.key || '-'}
- </span>
- </Descriptions.Item>
- <Descriptions.Item label="存储时间">
- <span style={{ fontFamily: 'monospace', fontSize: 14
}}>
- {formatTimeMs(entry.message.storeTime)}
- </span>
- </Descriptions.Item>
- <Descriptions.Item label="大小">
- {entry.message.size} bytes
- </Descriptions.Item>
- <Descriptions.Item label="Born Host">
- {entry.message.bornHost || '-'}
- </Descriptions.Item>
- </Descriptions>
- {entry.message.body && (
- <Card size="small" title="Body" style={{ marginTop: 12
}}>
- <pre
- style={{
- maxHeight: 160,
- overflow: 'auto',
- fontSize: 14,
- fontFamily: 'monospace',
- whiteSpace: 'pre-wrap',
- wordBreak: 'break-all',
- margin: 0,
- }}
- >
- {entry.message.body}
- </pre>
- </Card>
- )}
- <MessageProperties
- properties={entry.message.properties}
- propertiesTruncated={entry.message.propertiesTruncated}
+ <Text type="secondary" style={{ display: 'block', marginTop: 8,
fontSize: 14 }}>
+ {t('queueBrowser.queueSummary', {
+ queues: state.queues.length,
+ messages: state.queues.reduce((sum, q) => sum + (q.maxOffset -
q.minOffset), 0),
+ })}
+ </Text>
+ </div>
+
+ {/* 右侧:消息详情(2 列,可多条并存) */}
+ <div style={{ flex: 1, minWidth: 0 }}>
+ {state.entries.length === 0 ? (
+ <Empty
+ image={Empty.PRESENTED_IMAGE_SIMPLE}
+ description={t('queueBrowser.detailEmptyHint')}
+ style={{ padding: '32px 0' }}
+ />
+ ) : (
+ <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr',
gap: 12 }}>
+ {state.entries.map((entry) => (
+ <Card
+ key={entry.key}
+ size="small"
+ style={{ borderRadius: 8 }}
+ title={
+ <Space size={4}>
+ <Tag color="blue" style={{ marginInlineEnd: 0 }}>
+ {entry.key}
+ </Tag>
+ <Text type="secondary" style={{ fontSize: 14 }}>
+ @ {entry.offset}
+ </Text>
+ </Space>
+ }
+ extra={
+ <Button
+ type="text"
+ size="small"
+ icon={<CloseOutlined />}
+ onClick={() => state.closeEntry(entry.key)}
/>
- </>
- ) : (
- <Text type="secondary">该 offset 处无消息</Text>
- )}
- </Card>
- ))}
- </div>
- )}
- </div>
- </Flex>
- )}
- </Card>
-);
+ }
+ >
+ {entry.message ? (
+ <>
+ <Descriptions column={1} size="small">
+ <Descriptions.Item label="Message ID">
+ <Paragraph
+ copyable
+ style={{ marginBottom: 0, fontFamily:
'monospace', fontSize: 14 }}
+ >
+ {entry.message.msgId}
+ </Paragraph>
+ </Descriptions.Item>
+ <Descriptions.Item label="Tag">
+ <Tag>{entry.message.tag || '-'}</Tag>
+ </Descriptions.Item>
+ <Descriptions.Item label="Key">
+ <span style={{ fontFamily: 'monospace', fontSize:
14 }}>
+ {entry.message.key || '-'}
+ </span>
+ </Descriptions.Item>
+ <Descriptions.Item
label={t('messagePage.storeTime')}>
+ <span style={{ fontFamily: 'monospace', fontSize:
14 }}>
+ {formatTimeMs(entry.message.storeTime)}
+ </span>
+ </Descriptions.Item>
+ <Descriptions.Item label={t('messagePage.size')}>
+ {formatBytes(entry.message.size)}
+ </Descriptions.Item>
+ <Descriptions.Item label="Born Host">
+ {entry.message.bornHost || '-'}
+ </Descriptions.Item>
+ </Descriptions>
+ {entry.message.body && (
+ <Card size="small" title="Body" style={{ marginTop:
12 }}>
+ <pre
+ style={{
+ maxHeight: 160,
+ overflow: 'auto',
+ fontSize: 14,
+ fontFamily: 'monospace',
+ whiteSpace: 'pre-wrap',
+ wordBreak: 'break-all',
+ margin: 0,
+ }}
+ >
+ {entry.message.body}
+ </pre>
+ </Card>
+ )}
+ <MessageProperties
+ properties={entry.message.properties}
+
propertiesTruncated={entry.message.propertiesTruncated}
+ />
+ </>
+ ) : (
+ <Text
type="secondary">{t('queueBrowser.noMessageAtOffset')}</Text>
+ )}
+ </Card>
+ ))}
+ </div>
+ )}
+ </div>
+ </Flex>
+ )}
+ </Card>
+ );
+};
diff --git a/web/src/components/__tests__/QueueBrowser.test.tsx
b/web/src/components/__tests__/QueueBrowser.test.tsx
index e59a440b3..7cd126d81 100644
--- a/web/src/components/__tests__/QueueBrowser.test.tsx
+++ b/web/src/components/__tests__/QueueBrowser.test.tsx
@@ -30,7 +30,8 @@ import { afterEach, beforeAll, beforeEach, describe, expect,
it, vi } from 'vite
import { StrictMode } from 'react';
import type { MessageRecord, QueueOffset } from '../../api/message';
import { getQueueOffsets, pullMessageAtOffset } from '../../api/message';
-import { QueueBrowserResults, useQueueBrowser } from '../QueueBrowser';
+import { QueueBrowserControls, QueueBrowserResults, useQueueBrowser } from
'../QueueBrowser';
+import { LangProvider } from '../../i18n/LangContext';
vi.mock('../../api/message', () => ({
getQueueOffsets: vi.fn(),
@@ -503,3 +504,160 @@ describe('QueueBrowser request ownership', () => {
).toBeInTheDocument();
});
});
+
+describe('QueueBrowser localization', () => {
+ // The queue browser hardcoded Chinese strings regardless of the display
language (#4941 fixed
+ // the composer font size; this panel regressed the other way). The strings
now go through the
+ // translation table like every other surface.
+ it('renders the controls and empty states in the display language', () => {
+ window.localStorage.setItem('rocketmq-studio-language', 'en');
+
+ // The hook itself consumes the language context, so it must render inside
the provider.
+ const Inner = () => {
+ const state = useQueueBrowser(undefined);
+ return (
+ <>
+ <QueueBrowserControls
+ instanceId={undefined}
+ state={state}
+ topicOptions={[]}
+ topicLoading={false}
+ />
+ <QueueBrowserResults state={state} />
+ </>
+ );
+ };
+ render(
+ <LangProvider>
+ <Inner />
+ </LangProvider>,
+ );
+
+ // The search icon contributes to the accessible name, so match on the
label text.
+ expect(screen.getByRole('button', { name: /Load Queues/
})).toBeInTheDocument();
+ expect(
+ screen.getByText('Select a topic and click "Load Queues" to browse
messages by queue'),
+ ).toBeInTheDocument();
+ expect(screen.getByText('Select a topic')).toBeInTheDocument();
+ // The empty queue list renders before the detail pane, so only its hint
is visible here;
+ // the point is that no hardcoded Chinese remains on the rendered surface.
+ expect(screen.queryByText('加载队列')).not.toBeInTheDocument();
+ expect(
+ screen.queryByText('选择 Topic 并点击「加载队列」,按队列浏览消息'),
+ ).not.toBeInTheDocument();
+ });
+
+ // The remaining queueBrowser.* keys only surface once a topic has actually
been loaded, so they
+ // need a probe that drives the hook (and therefore the language context)
under the en locale.
+ const renderEnglishResults = () => {
+ window.localStorage.setItem('rocketmq-studio-language', 'en');
+ const Inner = () => {
+ const state = useQueueBrowser('instance-a');
+ const firstQueue = state.queues[0];
+ return (
+ <div>
+ <button type="button" onClick={() => state.setTopic('topic-a')}>
+ topic-a
+ </button>
+ <button type="button" onClick={() => void state.loadQueues()}>
+ load
+ </button>
+ <button
+ type="button"
+ disabled={!firstQueue}
+ onClick={() => firstQueue && void state.handlePull(firstQueue)}
+ >
+ pull
+ </button>
+ <QueueBrowserResults state={state} />
+ </div>
+ );
+ };
+ render(
+ <LangProvider>
+ <Inner />
+ </LangProvider>,
+ );
+ const user = userEvent.setup();
+ const loadTopic = async () => {
+ await user.click(screen.getByRole('button', { name: 'topic-a' }));
+ await user.click(screen.getByRole('button', { name: 'load' }));
+ await screen.findByText('broker-a');
+ };
+ return { user, loadTopic };
+ };
+
+ it('renders the loaded queue table and its summary in the display language',
async () => {
+ vi.mocked(getQueueOffsets).mockResolvedValue([
+ { brokerName: 'broker-a', queueId: 0, minOffset: 0, maxOffset: 3 },
+ { brokerName: 'broker-b', queueId: 1, minOffset: 5, maxOffset: 12 },
+ ]);
+ const { loadTopic } = renderEnglishResults();
+
+ await loadTopic();
+
+ expect(screen.getByText('Offset Range')).toBeInTheDocument();
+ // The summary interpolates both counters, so assert the substituted
numbers: a key missing
+ // from the dictionary would render the raw `{queues} queues, {messages}
messages in total`.
+ expect(screen.getByText('2 queues, 10 messages in
total')).toBeInTheDocument();
+ expect(
+ screen.getByText('Click "View" on the left; message details appear
here'),
+ ).toBeInTheDocument();
+ expect(screen.queryByText('Offset 范围')).not.toBeInTheDocument();
+ expect(screen.queryByText(/共 \d+ 个队列/)).not.toBeInTheDocument();
+ expect(screen.queryByText('点击左侧「查看」,消息详情将显示在这里')).not.toBeInTheDocument();
+ });
+
+ it('announces a queue load failure in the display language', async () => {
+ const errorToast = vi.spyOn(message, 'error').mockImplementation(vi.fn());
+ // The toast only falls back to the dictionary when the rejection carries
no message of its
+ // own, which is what a non-Error provider failure looks like.
+ vi.mocked(getQueueOffsets).mockRejectedValue('provider rejected the
request');
+ const { user } = renderEnglishResults();
+
+ await user.click(screen.getByRole('button', { name: 'topic-a' }));
+ await user.click(screen.getByRole('button', { name: 'load' }));
+
+ await waitFor(() =>
+ expect(errorToast).toHaveBeenCalledWith('Failed to load queue
information'),
+ );
+ expect(errorToast).not.toHaveBeenCalledWith('加载队列信息失败');
+ errorToast.mockRestore();
+ });
+
+ it('announces a pull failure in the display language', async () => {
+ const errorToast = vi.spyOn(message, 'error').mockImplementation(vi.fn());
+ vi.mocked(getQueueOffsets).mockResolvedValue([queue('broker-a')]);
+ vi.mocked(pullMessageAtOffset).mockRejectedValue('provider rejected the
pull');
+ const { loadTopic, user } = renderEnglishResults();
+
+ await loadTopic();
+ await user.click(screen.getByRole('button', { name: 'pull' }));
+
+ await waitFor(() => expect(errorToast).toHaveBeenCalledWith('Failed to
pull the message'));
+ expect(errorToast).not.toHaveBeenCalledWith('拉取消息失败');
+ errorToast.mockRestore();
+ });
+
+ it('renders the pulled message detail in the display language', async () => {
+ vi.mocked(getQueueOffsets).mockResolvedValue([queue('broker-a')]);
+ vi.mocked(pullMessageAtOffset)
+ .mockResolvedValueOnce(null)
+ .mockResolvedValueOnce({ ...messageRecord('sized-message'), size: 1024
});
+ const { loadTopic, user } = renderEnglishResults();
+
+ await loadTopic();
+
+ await user.click(screen.getByRole('button', { name: 'pull' }));
+ // An offset with nothing behind it renders the placeholder instead of an
empty detail card.
+ await screen.findByText('No message at this offset');
+ expect(screen.queryByText('该 offset 处无消息')).not.toBeInTheDocument();
+
+ await user.click(screen.getByRole('button', { name: 'pull' }));
+ await screen.findByText('sized-message');
+ // The size cell goes through the shared byte formatter, so it reads as a
scaled unit rather
+ // than a raw byte count.
+ expect(screen.getByText('1.0 KB')).toBeInTheDocument();
+ expect(screen.queryByText('1024 bytes')).not.toBeInTheDocument();
+ });
+});
diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts
index d101d2f76..2f0087186 100644
--- a/web/src/i18n/translations.ts
+++ b/web/src/i18n/translations.ts
@@ -855,6 +855,28 @@ const translations: Record<string, Record<Lang, string>> =
{
},
'messagePage.downloadSuccess': { zh: '消息下载成功', en: 'Message downloaded' },
+ // ─── Queue Browser ───
+ 'queueBrowser.loadQueues': { zh: '加载队列', en: 'Load Queues' },
+ 'queueBrowser.loadQueuesFailed': {
+ zh: '加载队列信息失败',
+ en: 'Failed to load queue information',
+ },
+ 'queueBrowser.pullFailed': { zh: '拉取消息失败', en: 'Failed to pull the message'
},
+ 'queueBrowser.emptyHint': {
+ zh: '选择 Topic 并点击「加载队列」,按队列浏览消息',
+ en: 'Select a topic and click "Load Queues" to browse messages by queue',
+ },
+ 'queueBrowser.offsetRange': { zh: 'Offset 范围', en: 'Offset Range' },
+ 'queueBrowser.queueSummary': {
+ zh: '共 {queues} 个队列,总消息量 {messages} 条',
+ en: '{queues} queues, {messages} messages in total',
+ },
+ 'queueBrowser.detailEmptyHint': {
+ zh: '点击左侧「查看」,消息详情将显示在这里',
+ en: 'Click "View" on the left; message details appear here',
+ },
+ 'queueBrowser.noMessageAtOffset': { zh: '该 offset 处无消息', en: 'No message at
this offset' },
+
// ─── Message Query History ───
'messageHistory.title': { zh: '服务端查询历史', en: 'Server Query History' },
'messageHistory.messageQueries': { zh: '消息查询', en: 'Message Queries' },