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 00ed28751 fix(web): stop charting an unmapped client language as null
(#4850)
00ed28751 is described below
commit 00ed28751880ba65aafcc64cc9e1b0c766e1a23d
Author: Wang1rrr <[email protected]>
AuthorDate: Thu Sep 24 10:47:17 2026 +0800
fix(web): stop charting an unmapped client language as null (#4850)
`ClientConnectionVO.language` is nullable on the server (`mapLanguage`
returns null for every `LanguageCode` with no `ClientLanguage` member, and the
VO is serialised without NON_NULL) while `api/connections.ts` declared it as a
required string, so `strictNullChecks` could not flag any consumer and three of
them rendered the gap: the language distribution interpolated null into its
bucket label, and both the Language column and the detail drawer looked the
value up in `languageConfig` [...]
---
web/src/api/connections.ts | 2 +-
web/src/i18n/translations.ts | 1 +
.../pages/cluster/__tests__/ClientsPage.test.tsx | 21 +++++++++++++
web/src/pages/cluster/clients.tsx | 36 ++++++++++++++++------
4 files changed, 49 insertions(+), 11 deletions(-)
diff --git a/web/src/api/connections.ts b/web/src/api/connections.ts
index a47d2de8a..38e163604 100644
--- a/web/src/api/connections.ts
+++ b/web/src/api/connections.ts
@@ -7,7 +7,7 @@ export interface ClientConnection {
groupOrTopic: string;
protocol: string;
address?: string | null;
- language: string;
+ language?: string | null;
version: string;
connectedAt?: string | null;
partial?: boolean;
diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts
index b57616769..f907b0977 100644
--- a/web/src/i18n/translations.ts
+++ b/web/src/i18n/translations.ts
@@ -75,6 +75,7 @@ const translations: Record<string, Record<Lang, string>> = {
'common.no': { zh: '否', en: 'No' },
'common.retry': { zh: '重试', en: 'Retry' },
'common.unavailable': { zh: '不可用', en: 'Unavailable' },
+ 'common.unknown': { zh: '未知', en: 'Unknown' },
// ─── Global layout controls ───
'layout.skipToMain': { zh: '跳到主要内容', en: 'Skip to main content' },
diff --git a/web/src/pages/cluster/__tests__/ClientsPage.test.tsx
b/web/src/pages/cluster/__tests__/ClientsPage.test.tsx
index 004de88c7..ecae50abd 100644
--- a/web/src/pages/cluster/__tests__/ClientsPage.test.tsx
+++ b/web/src/pages/cluster/__tests__/ClientsPage.test.tsx
@@ -390,6 +390,27 @@ describe('Clients page', () => {
expect(within(dialog).getAllByText('-')).toHaveLength(2);
});
+ it('labels a connection whose language the backend could not map', async ()
=> {
+ vi.mocked(connectionsService.listConnections).mockResolvedValue([
+ {
+ ...connection,
+ clientId: '[email protected]:49152',
+ groupOrTopic: 'ruby-topic',
+ language: null,
+ },
+ ]);
+ renderWithProviders(<ClientsPage />);
+
+ const rows = await screen.findAllByRole('row', { name: /ruby-topic/ });
+ const row = rows.find((candidate) =>
within(candidate).queryByRole('button', { name: /详情/ }));
+ expect(row).toBeDefined();
+ expect(within(row!).getByText('未知')).toBeInTheDocument();
+
+ const distribution = await
screen.findByTestId('language-version-distribution');
+ expect(within(distribution).getByText('未知 5.0.7: 1')).toBeInTheDocument();
+ expect(within(distribution).queryByText(/null/)).not.toBeInTheDocument();
+ });
+
it('exports the currently filtered client connections as CSV', async () => {
const createObjectURL = vi.fn((blob: Blob | MediaSource) => {
expect(blob).toBeInstanceOf(Blob);
diff --git a/web/src/pages/cluster/clients.tsx
b/web/src/pages/cluster/clients.tsx
index 01c86ca37..6fc768aa3 100644
--- a/web/src/pages/cluster/clients.tsx
+++ b/web/src/pages/cluster/clients.tsx
@@ -165,6 +165,12 @@ function getLoadErrorMessage(error: unknown): string {
const displayMetadata = (value: string | null | undefined) => value || '-';
+/**
+ * Bucket key for a connection whose broker-reported `LanguageCode` has no
`ClientLanguage`
+ * counterpart, so the API sends `language: null`. The visible label is
localized on render.
+ */
+const UNKNOWN_LANGUAGE = 'unknown';
+
/* ═══════════════════════════════════════════
ClientsPage
═══════════════════════════════════════════ */
@@ -314,7 +320,9 @@ const ClientsPage = () => {
consumers: instances.filter((connection) => connection.type ===
'Consumer').length,
protocols: countBy(instances.map((connection) => connection.protocol)),
languageVersions: countBy(
- instances.map((connection) => `${connection.language}
${connection.version}`),
+ instances.map(
+ (connection) => `${connection.language ?? UNKNOWN_LANGUAGE}
${connection.version}`,
+ ),
),
};
}, [clusterConnections]);
@@ -377,7 +385,7 @@ const ClientsPage = () => {
matches('clusterName', connection.clusterName) &&
matches('type', connection.type) &&
matches('protocol', connection.protocol) &&
- matches('language', connection.language),
+ matches('language', connection.language ?? ''),
);
}, [columnFilters, filtered]);
@@ -393,6 +401,15 @@ const ClientsPage = () => {
/* ═══════════════════════════════════════════
Table Columns (with built-in filters)
═══════════════════════════════════════════ */
+ const renderLanguageTag = (language?: string | null) => {
+ const config = languageConfig[language ?? ''];
+ return (
+ <Tag color={config?.color ?? 'default'}>
+ {config?.label ?? (language || t('common.unknown'))}
+ </Tag>
+ );
+ };
+
const columns: ColumnsType<ClientConnection> = [
{
title: t('clients.cluster'),
@@ -490,10 +507,7 @@ const ClientsPage = () => {
})),
filteredValue: columnFilters.language ?? null,
onFilter: (value, record) => record.language === value,
- render: (lang: string) => {
- const cfg = languageConfig[lang] ?? { color: 'default', label: lang };
- return <Tag color={cfg.color}>{cfg.label}</Tag>;
- },
+ render: (lang?: string | null) => renderLanguageTag(lang),
},
{
title: t('common.version'),
@@ -839,7 +853,11 @@ const ClientsPage = () => {
connectionStats.languageVersions.map(({ label, count }) => {
const [language, ...versionParts] = label.split(' ');
const version = versionParts.join(' ');
- const config = languageConfig[language] ?? { color: 'default',
label: language };
+ const config =
+ languageConfig[language] ??
+ (language === UNKNOWN_LANGUAGE
+ ? { color: 'default', label: t('common.unknown') }
+ : { color: 'default', label: language });
return (
<Tag key={label} color={config.color}>
{config.label} {version}: {count}
@@ -1007,9 +1025,7 @@ const ClientsPage = () => {
</Text>
</Descriptions.Item>
<Descriptions.Item label={t('clients.language')}>
- <Tag color={languageConfig[selectedConnection.language]?.color
?? 'default'}>
- {languageConfig[selectedConnection.language]?.label ??
selectedConnection.language}
- </Tag>
+ {renderLanguageTag(selectedConnection.language)}
</Descriptions.Item>
<Descriptions.Item label={t('common.version')}>
{selectedConnection.version}