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 cc61356d2 fix(web): retain partial diagnostics and harden page
rendering (#2221)
cc61356d2 is described below
commit cc61356d217365c8dfecd65badde832c53fd986e
Author: btlqql <[email protected]>
AuthorDate: Wed Aug 19 13:56:52 2026 +0800
fix(web): retain partial diagnostics and harden page rendering (#2221)
* fix(web): retain partial consumer group diagnostics
* fix(clients): export only visible filtered rows
* fix(web): harden nullable collection rendering
---
.../pages/cluster/__tests__/ClientsPage.test.tsx | 36 +++++
web/src/pages/cluster/clients.tsx | 23 +++-
web/src/pages/instance/consumer.tsx | 6 +-
web/src/pages/ops/alerts.tsx | 2 +-
web/src/pages/studio/GroupManagement.tsx | 146 +++++++++++++--------
.../studio/__tests__/GroupManagement.test.tsx | 44 +++++++
6 files changed, 200 insertions(+), 57 deletions(-)
diff --git a/web/src/pages/cluster/__tests__/ClientsPage.test.tsx
b/web/src/pages/cluster/__tests__/ClientsPage.test.tsx
index 9e5f8b03b..b6b06a274 100644
--- a/web/src/pages/cluster/__tests__/ClientsPage.test.tsx
+++ b/web/src/pages/cluster/__tests__/ClientsPage.test.tsx
@@ -264,6 +264,42 @@ describe('Clients page', () => {
expect(revokeObjectURL).toHaveBeenCalledWith('blob:client-connections');
});
+ it('applies table column filters to the exported CSV', async () => {
+ const createObjectURL = vi.fn((blob: Blob | MediaSource) => {
+ expect(blob).toBeInstanceOf(Blob);
+ return 'blob:filtered-client-connections';
+ });
+ Object.defineProperty(URL, 'createObjectURL', {
+ writable: true,
+ value: createObjectURL,
+ });
+ Object.defineProperty(URL, 'revokeObjectURL', {
+ writable: true,
+ value: vi.fn(),
+ });
+ vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() =>
{});
+
vi.mocked(connectionsService.listConnections).mockResolvedValue(connections);
+ const user = userEvent.setup();
+ renderWithProviders(<ClientsPage />);
+
+ await screen.findByText('[email protected]:49152');
+ const filterTriggers =
document.querySelectorAll<HTMLElement>('.ant-table-filter-trigger');
+ await user.click(filterTriggers[1]);
+ const filterDropdown =
document.querySelector<HTMLElement>('.ant-table-filter-dropdown');
+ expect(filterDropdown).not.toBeNull();
+ await user.click(within(filterDropdown!).getByText('Consumer'));
+ await user.click(within(filterDropdown!).getByRole('button', { name: 'OK'
}));
+
+
expect(screen.queryByText('[email protected]:49152')).not.toBeInTheDocument();
+ await user.click(screen.getByRole('button', { name: '导出' }));
+
+ const blob = createObjectURL.mock.calls[0][0] as Blob;
+ const csv = await blob.text();
+ expect(csv).not.toContain('[email protected]:49152');
+ expect(csv).toContain('[email protected]:49153');
+ expect(csv).toContain('[email protected]:49154');
+ });
+
it('renders empty distributions when no connections are available', async ()
=> {
vi.mocked(connectionsService.listConnections).mockResolvedValue([]);
renderWithProviders(<ClientsPage />);
diff --git a/web/src/pages/cluster/clients.tsx
b/web/src/pages/cluster/clients.tsx
index 278bef5cb..4a8b9a86f 100644
--- a/web/src/pages/cluster/clients.tsx
+++ b/web/src/pages/cluster/clients.tsx
@@ -34,6 +34,7 @@ import {
} from 'antd';
import { DownloadSimple, Eye, MagnifyingGlass } from '@phosphor-icons/react';
import type { ColumnsType } from 'antd/es/table';
+import type { TableProps } from 'antd';
import PageHeader from '../../components/PageHeader';
import { useLang } from '../../i18n/LangContext';
@@ -83,6 +84,8 @@ const CLIENT_CONNECTION_EXPORT_COLUMNS:
CsvColumn<ClientConnection>[] = [
{ header: 'Partial', value: (connection) => (connection.partial ? 'true' :
'false') },
];
+type ClientTableFilters =
Parameters<NonNullable<TableProps<ClientConnection>['onChange']>>[1];
+
const countBy = (values: string[]) =>
[
...values.reduce(
@@ -130,6 +133,7 @@ const ClientsPage = () => {
const [loadError, setLoadError] = useState<string | null>(null);
const [registryLoadKey, setRegistryLoadKey] = useState(0);
const [connectionLoadKey, setConnectionLoadKey] = useState(0);
+ const [columnFilters, setColumnFilters] = useState<ClientTableFilters>({});
const selectedCluster = registryClusters.find((cluster) => cluster.endpoint
=== selectedEndpoint);
@@ -263,9 +267,23 @@ const ClientsPage = () => {
);
}, [clusterConnections, search]);
+ const exportConnections = useMemo(() => {
+ const matches = (key: string, value: string) => {
+ const selected = columnFilters[key];
+ return !selected?.length || selected.some((filterValue) =>
String(filterValue) === value);
+ };
+ return filtered.filter(
+ (connection) =>
+ matches('clusterName', connection.clusterName) &&
+ matches('type', connection.type) &&
+ matches('protocol', connection.protocol) &&
+ matches('language', connection.language),
+ );
+ }, [columnFilters, filtered]);
+
const handleExport = () => {
const filename = `rocketmq-client-connections-${new
Date().toISOString().slice(0, 10)}.csv`;
- const csv = buildCsv(CLIENT_CONNECTION_EXPORT_COLUMNS, filtered);
+ const csv = buildCsv(CLIENT_CONNECTION_EXPORT_COLUMNS, exportConnections);
downloadCsv(filename, csv);
};
@@ -476,7 +494,7 @@ const ClientsPage = () => {
</Space>
<Button
icon={<DownloadSimple size={16} />}
- disabled={filtered.length === 0}
+ disabled={exportConnections.length === 0}
onClick={handleExport}
>
{t('common.export')}
@@ -553,6 +571,7 @@ const ClientsPage = () => {
`${connection.type}:${connection.clientId}:${connection.groupOrTopic}`
}
loading={loading}
+ onChange={(_, filters) => setColumnFilters(filters)}
scroll={{ x: 1320 }}
pagination={{
pageSize: 20,
diff --git a/web/src/pages/instance/consumer.tsx
b/web/src/pages/instance/consumer.tsx
index 62e57c173..ef7c038e6 100644
--- a/web/src/pages/instance/consumer.tsx
+++ b/web/src/pages/instance/consumer.tsx
@@ -949,7 +949,7 @@ const ConsumerPageContent = ({
dataSource={
subscriptionsByGroup[diagnosticCacheKey(selectedInstanceId, record.name)] ?? []
}
- rowKey="topic"
+ rowKey={(record) =>
`${record.topic}-${record.filterMode}-${record.expression}`}
loading={
subscriptionLoadingByGroup[diagnosticCacheKey(selectedInstanceId, record.name)]
}
@@ -1177,7 +1177,9 @@ const ConsumerPageContent = ({
<Table
columns={subscriptionSubColumns}
dataSource={visibleSubscriptions}
- rowKey="topic"
+ rowKey={(record) =>
+
`${record.topic}-${record.filterMode}-${record.expression}`
+ }
loading={subscriptionLoadingByGroup[selectedDiagnosticKey]}
pagination={false}
size="small"
diff --git a/web/src/pages/ops/alerts.tsx b/web/src/pages/ops/alerts.tsx
index b4b5cbb60..d29fb091d 100644
--- a/web/src/pages/ops/alerts.tsx
+++ b/web/src/pages/ops/alerts.tsx
@@ -260,7 +260,7 @@ const AlertsPage = () => {
title: t('alerts.channels'),
render: (_, record) => (
<Flex gap={4} wrap="wrap">
- {record.channels.map((ch) => (
+ {(record.channels ?? []).map((ch) => (
<Tag key={ch} color={channelColors[ch]}>
{channelLabels[ch]}
</Tag>
diff --git a/web/src/pages/studio/GroupManagement.tsx
b/web/src/pages/studio/GroupManagement.tsx
index c297b21ec..29038d6c4 100644
--- a/web/src/pages/studio/GroupManagement.tsx
+++ b/web/src/pages/studio/GroupManagement.tsx
@@ -30,6 +30,7 @@ import {
Space,
Switch,
message,
+ Alert,
} from 'antd';
import { MagnifyingGlass, ArrowClockwise, Users } from '@phosphor-icons/react';
import { useLang } from '../../i18n/LangContext';
@@ -66,7 +67,10 @@ const GroupManagementPage = () => {
const [loading, setLoading] = useState(true);
const [subscriptions, setSubscriptions] = useState<SubscriptionEntry[]>([]);
const [progress, setProgress] = useState<QueueProgress[]>([]);
- const [detailLoading, setDetailLoading] = useState(false);
+ const [subscriptionLoading, setSubscriptionLoading] = useState(false);
+ const [progressLoading, setProgressLoading] = useState(false);
+ const [subscriptionError, setSubscriptionError] = useState<string |
null>(null);
+ const [progressError, setProgressError] = useState<string | null>(null);
const listRequestId = useRef(0);
const listInFlight = useRef<Promise<void> | null>(null);
const listRefreshQueued = useRef(false);
@@ -135,23 +139,41 @@ const GroupManagementPage = () => {
setModalVisible(true);
setSubscriptions([]);
setProgress([]);
- setDetailLoading(true);
- try {
- const [subs, prog] = await Promise.all([
- getConsumerSubscriptions(group.name, group.instanceId),
- getConsumerProgress(group.name, group.instanceId),
- ]);
- if (requestId !== detailRequestId.current) return;
- setSubscriptions(subs);
- setProgress(prog);
- } catch {
- if (requestId !== detailRequestId.current) return;
- message.error(t('consumer.fetchProgressFailed', { name: group.name }));
- } finally {
- if (requestId === detailRequestId.current) {
- setDetailLoading(false);
- }
- }
+ setSubscriptionError(null);
+ setProgressError(null);
+ setSubscriptionLoading(true);
+ setProgressLoading(true);
+
+ const subscriptionRequest = getConsumerSubscriptions(group.name,
group.instanceId)
+ .then(
+ (result) => {
+ if (requestId === detailRequestId.current)
setSubscriptions(result);
+ },
+ () => {
+ if (requestId === detailRequestId.current) {
+ setSubscriptionError(t('consumer.fetchSubscriptionsFailed', {
name: group.name }));
+ }
+ },
+ )
+ .finally(() => {
+ if (requestId === detailRequestId.current)
setSubscriptionLoading(false);
+ });
+ const progressRequest = getConsumerProgress(group.name, group.instanceId)
+ .then(
+ (result) => {
+ if (requestId === detailRequestId.current) setProgress(result);
+ },
+ () => {
+ if (requestId === detailRequestId.current) {
+ setProgressError(t('consumer.fetchProgressFailed', { name:
group.name }));
+ }
+ },
+ )
+ .finally(() => {
+ if (requestId === detailRequestId.current) setProgressLoading(false);
+ });
+
+ await Promise.all([subscriptionRequest, progressRequest]);
},
[t],
);
@@ -434,11 +456,19 @@ const GroupManagementPage = () => {
<h4 style={{ marginTop: 20, marginBottom: 12 }}>
{t('groupMgmt.subscription')}
</h4>
+ {subscriptionError && (
+ <Alert
+ type="error"
+ showIcon
+ message={subscriptionError}
+ style={{ marginBottom: 12 }}
+ />
+ )}
<Table
columns={subscriptionColumns}
dataSource={subscriptions}
rowKey="topic"
- loading={detailLoading}
+ loading={subscriptionLoading}
pagination={false}
size="small"
/>
@@ -475,39 +505,51 @@ const GroupManagementPage = () => {
key: 'progress',
label: t('groupMgmt.consumeProgress'),
children: (
- <Table
- columns={[
- { title: 'Broker', dataIndex: 'broker', key: 'broker' },
- { title: 'QueueId', dataIndex: 'queueId', key: 'queueId'
},
- {
- title: 'Broker Offset',
- dataIndex: 'brokerOffset',
- key: 'brokerOffset',
- render: (v: number) => v.toLocaleString(),
- },
- {
- title: 'Consumer Offset',
- dataIndex: 'consumerOffset',
- key: 'consumerOffset',
- render: (v: number) => v.toLocaleString(),
- },
- {
- title: 'Diff',
- dataIndex: 'diffTotal',
- key: 'diffTotal',
- render: (v: number) => (
- <span style={{ color: v > 100 ? '#ff4d4f' :
'#52c41a', fontWeight: 500 }}>
- {v.toLocaleString()}
- </span>
- ),
- },
- ]}
- dataSource={progress}
- rowKey={(record) => `${record.broker}-${record.queueId}`}
- loading={detailLoading}
- pagination={false}
- size="small"
- />
+ <>
+ {progressError && (
+ <Alert
+ type="error"
+ showIcon
+ message={progressError}
+ style={{ marginBottom: 12 }}
+ />
+ )}
+ <Table
+ columns={[
+ { title: 'Broker', dataIndex: 'broker', key: 'broker'
},
+ { title: 'QueueId', dataIndex: 'queueId', key:
'queueId' },
+ {
+ title: 'Broker Offset',
+ dataIndex: 'brokerOffset',
+ key: 'brokerOffset',
+ render: (v: number) => v.toLocaleString(),
+ },
+ {
+ title: 'Consumer Offset',
+ dataIndex: 'consumerOffset',
+ key: 'consumerOffset',
+ render: (v: number) => v.toLocaleString(),
+ },
+ {
+ title: 'Diff',
+ dataIndex: 'diffTotal',
+ key: 'diffTotal',
+ render: (v: number) => (
+ <span
+ style={{ color: v > 100 ? '#ff4d4f' : '#52c41a',
fontWeight: 500 }}
+ >
+ {v.toLocaleString()}
+ </span>
+ ),
+ },
+ ]}
+ dataSource={progress}
+ rowKey={(record) => `${record.broker}-${record.queueId}`}
+ loading={progressLoading}
+ pagination={false}
+ size="small"
+ />
+ </>
),
},
]}
diff --git a/web/src/pages/studio/__tests__/GroupManagement.test.tsx
b/web/src/pages/studio/__tests__/GroupManagement.test.tsx
index 2701533b5..2e2d1d9c3 100644
--- a/web/src/pages/studio/__tests__/GroupManagement.test.tsx
+++ b/web/src/pages/studio/__tests__/GroupManagement.test.tsx
@@ -199,6 +199,50 @@ describe('GroupManagement Page', () => {
expect(screen.queryByText('FIRST_GROUP_TOPIC')).not.toBeInTheDocument();
});
+ it('keeps subscriptions when progress loading fails', async () => {
+ vi.mocked(consumerService.getConsumerSubscriptions).mockResolvedValue([
+ {
+ topic: 'AVAILABLE_SUBSCRIPTION',
+ expression: '*',
+ type: 'TAG',
+ filterMode: 'TAG',
+ consistency: 'consistent',
+ },
+ ]);
+ vi.mocked(consumerService.getConsumerProgress).mockRejectedValue(new
Error('unavailable'));
+
+ const user = userEvent.setup();
+ renderWithProviders(<GroupManagement />);
+ await user.click(await screen.findByText('order-consumer-group'));
+
+ expect(await
screen.findByText('AVAILABLE_SUBSCRIPTION')).toBeInTheDocument();
+ const dialog = await screen.findByRole('dialog');
+ await user.click(within(dialog).getAllByRole('tab')[2]);
+ expect(document.querySelector('.ant-alert-error')).toBeInTheDocument();
+ });
+
+ it('keeps progress when subscription loading fails', async () => {
+ vi.mocked(consumerService.getConsumerSubscriptions).mockRejectedValue(new
Error('unavailable'));
+ vi.mocked(consumerService.getConsumerProgress).mockResolvedValue([
+ {
+ broker: 'broker-a',
+ queueId: 0,
+ brokerOffset: 20,
+ consumerOffset: 10,
+ diffTotal: 10,
+ },
+ ]);
+
+ const user = userEvent.setup();
+ renderWithProviders(<GroupManagement />);
+ await user.click(await screen.findByText('order-consumer-group'));
+ const dialog = await screen.findByRole('dialog');
+ await user.click(within(dialog).getAllByRole('tab')[2]);
+
+ expect(await screen.findByText('broker-a')).toBeInTheDocument();
+ expect(document.querySelector('.ant-alert-error')).toBeInTheDocument();
+ });
+
it('queues one refresh instead of overlapping an active group request',
async () => {
const initialGroups = createDeferred<ConsumerGroup[]>();
const refreshedGroups = createDeferred<ConsumerGroup[]>();