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 eb2044f3d fix(web): keep list pages readable at normal window widths
(#2485)
eb2044f3d is described below
commit eb2044f3da74d6a3c9dee61b4cb4f274c0328d7d
Author: lizhimins <[email protected]>
AuthorDate: Fri Aug 21 16:05:10 2026 +0800
fix(web): keep list pages readable at normal window widths (#2485)
Hardcoded scroll.x values drift away from the column definitions, so tables
showed a horizontal scrollbar even on a wide window. Add tableScrollX() and
compute the threshold from the declared column widths on every list page.
Also fold in three list-page fixes found while going through them: retry the
cluster page bootstrap up to three times before showing the failure banner
(instances are not served yet right after a deploy), label open-source
instances as 开源版 in the region column instead of a bare dash, and pass
the topic to the Aliyun trace API, which rejects a message-id-only query.
---
.../provider/alibaba/AliyunInstanceProvider.java | 1 +
.../pages/cluster/__tests__/ClusterPage.test.tsx | 13 ++--
web/src/pages/cluster/certs.tsx | 3 +-
web/src/pages/cluster/clients.tsx | 3 +-
web/src/pages/cluster/index.tsx | 17 ++++-
web/src/pages/instance/__tests__/DLQPage.test.tsx | 6 +-
.../pages/instance/__tests__/InstancePage.test.tsx | 2 +-
web/src/pages/instance/acl.tsx | 3 +
web/src/pages/instance/dlq.tsx | 2 +
web/src/pages/instance/index.tsx | 8 +-
web/src/pages/instance/message.tsx | 2 +
web/src/pages/instance/topic.tsx | 2 +
web/src/pages/ops/alerts.tsx | 2 +
web/src/pages/ops/audit.tsx | 3 +-
web/src/pages/studio/AlertManagement.tsx | 3 +-
web/src/utils/table.test.ts | 48 ++++++++++++
web/src/utils/table.ts | 85 ++++++++++++++++++++++
17 files changed, 184 insertions(+), 19 deletions(-)
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/provider/alibaba/AliyunInstanceProvider.java
b/server/src/main/java/org/apache/rocketmq/studio/provider/alibaba/AliyunInstanceProvider.java
index f42381beb..3cf687d62 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/provider/alibaba/AliyunInstanceProvider.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/provider/alibaba/AliyunInstanceProvider.java
@@ -506,6 +506,7 @@ public class AliyunInstanceProvider implements
InstanceProvider {
Context ctx = resolve(instanceId);
GetTraceRequest request = GetTraceRequest.builder()
.instanceId(ctx.cloudInstanceId())
+ .topicName(topic)
.messageId(msgId)
.build();
GetTraceResponse response = clientFactory.call(ctx.credentialId(),
ctx.regionId(),
diff --git a/web/src/pages/cluster/__tests__/ClusterPage.test.tsx
b/web/src/pages/cluster/__tests__/ClusterPage.test.tsx
index 3ade31836..2b1aef10e 100644
--- a/web/src/pages/cluster/__tests__/ClusterPage.test.tsx
+++ b/web/src/pages/cluster/__tests__/ClusterPage.test.tsx
@@ -272,7 +272,7 @@ describe('Cluster page', () => {
vi.restoreAllMocks();
});
- it('surfaces instance bootstrap failures and retries without querying a
default cluster', async () => {
+ it('auto-retries instance bootstrap failures without querying a default
cluster', async () => {
instanceServiceMocks.listInstances
.mockRejectedValueOnce(new Error('managed instances unavailable'))
.mockResolvedValueOnce([
@@ -289,16 +289,15 @@ describe('Cluster page', () => {
gmtModified: '',
},
]);
- const user = userEvent.setup();
renderWithProviders(<ClusterPage />);
- const alert = await screen.findByRole('alert');
expect(clusterServiceMocks.listClusters).not.toHaveBeenCalled();
- await user.click(within(alert).getByRole('button', { name: /重\s*试/ }));
- expect(await screen.findByText('rocketmq-prod-0')).toBeInTheDocument();
-
expect(clusterServiceMocks.listClusters).toHaveBeenCalledWith('instance-1');
- });
+ await waitFor(
+ () =>
expect(clusterServiceMocks.listClusters).toHaveBeenCalledWith('instance-1'),
+ { timeout: 6000 },
+ );
+ }, 10000);
it('opens proxy detail dialog from the proxy table', async () => {
const user = userEvent.setup();
diff --git a/web/src/pages/cluster/certs.tsx b/web/src/pages/cluster/certs.tsx
index d64e6cd85..1f6397f23 100644
--- a/web/src/pages/cluster/certs.tsx
+++ b/web/src/pages/cluster/certs.tsx
@@ -38,6 +38,7 @@ import InfoBanner from '../../components/InfoBanner';
import type { K8sCertInfo } from '../../api/cluster';
import { listK8sCerts, createK8sCert, deleteK8sCert } from
'../../services/clusterService';
import { formatDateTime } from '../../utils/format';
+import { tableScrollX } from '../../utils/table';
const { Text } = Typography;
@@ -286,7 +287,7 @@ const K8sCertsPage = () => {
loading={loading}
pagination={{ pageSize: 20 }}
size="small"
- scroll={{ x: 1400 }}
+ scroll={{ x: tableScrollX(certColumns) }}
/>
</Card>
diff --git a/web/src/pages/cluster/clients.tsx
b/web/src/pages/cluster/clients.tsx
index 4a8b9a86f..349b5265f 100644
--- a/web/src/pages/cluster/clients.tsx
+++ b/web/src/pages/cluster/clients.tsx
@@ -44,6 +44,7 @@ import { listRegistryClusters } from
'../../services/clusterService';
import type { ClusterInfo } from '../../api/cluster';
import { formatDateTime } from '../../utils/format';
import { buildCsv, downloadCsv, type CsvColumn } from '../../utils/download';
+import { tableScrollX } from '../../utils/table';
const { Text } = Typography;
const DEFAULT_LOAD_ERROR = '客户端连接加载失败,请稍后重试';
@@ -572,7 +573,7 @@ const ClientsPage = () => {
}
loading={loading}
onChange={(_, filters) => setColumnFilters(filters)}
- scroll={{ x: 1320 }}
+ scroll={{ x: tableScrollX(columns) }}
pagination={{
pageSize: 20,
showSizeChanger: true,
diff --git a/web/src/pages/cluster/index.tsx b/web/src/pages/cluster/index.tsx
index 1541cf277..e090fd700 100644
--- a/web/src/pages/cluster/index.tsx
+++ b/web/src/pages/cluster/index.tsx
@@ -74,6 +74,7 @@ import {
import { listInstances } from '../../services/instanceService';
import { supportsApacheRuntime } from '../../api/instance';
import { isMockMode } from '../../services/dataMode';
+import { tableScrollX } from '../../utils/table';
const { Text } = Typography;
@@ -312,6 +313,7 @@ const ClusterPage = () => {
);
const tRef = useRef(t);
const selectedInstanceIdRef = useRef<string | undefined>(undefined);
+ const instanceLoadRetryRef = useRef(0);
useEffect(() => {
let cancelled = false;
@@ -325,6 +327,7 @@ const ClusterPage = () => {
? requestedInstanceId
: apacheInstances[0]?.name;
selectedInstanceIdRef.current = initialInstanceId;
+ instanceLoadRetryRef.current = 0;
setInstanceLoadError(null);
if (initialInstanceId) void requestRefreshRef.current('manual');
})
@@ -333,10 +336,15 @@ const ClusterPage = () => {
selectedInstanceIdRef.current = undefined;
setClusters([]);
setSelectedProxy(null);
- setInstanceLoadError(tRef.current('common.fetchDataFailed'));
setLoading(false);
- setAutoRefresh(false);
- autoRefreshRef.current = false;
+ if (instanceLoadRetryRef.current < 3) {
+ instanceLoadRetryRef.current += 1;
+ window.setTimeout(() => setInstanceLoadKey((key) => key + 1), 3000);
+ } else {
+ setInstanceLoadError(tRef.current('common.fetchDataFailed'));
+ setAutoRefresh(false);
+ autoRefreshRef.current = false;
+ }
});
return () => {
cancelled = true;
@@ -687,6 +695,7 @@ const ClusterPage = () => {
rowKey="addr"
pagination={{ pageSize: 20 }}
size="small"
+ scroll={{ x: tableScrollX(brokerColumns) }}
/>
</Card>
@@ -912,6 +921,7 @@ const ClusterPage = () => {
rowKey="id"
pagination={{ pageSize: 20 }}
size="small"
+ scroll={{ x: tableScrollX(registryColumns) }}
/>
</Card>
</div>
@@ -1066,6 +1076,7 @@ const ClusterPage = () => {
rowKey={(r) => `${r.clusterName}-${r.addr}`}
pagination={{ pageSize: 20 }}
size="small"
+ scroll={{ x: tableScrollX(proxyColumns) }}
/>
</Card>
</div>
diff --git a/web/src/pages/instance/__tests__/DLQPage.test.tsx
b/web/src/pages/instance/__tests__/DLQPage.test.tsx
index 50ef82460..c311e8ee4 100644
--- a/web/src/pages/instance/__tests__/DLQPage.test.tsx
+++ b/web/src/pages/instance/__tests__/DLQPage.test.tsx
@@ -218,7 +218,11 @@ describe('DLQ page', () => {
renderWithProviders(<DLQPage />);
await screen.findByText('cg-order');
- await user.click(screen.getByText('最近入队时间'));
+ // A horizontally scrollable table also renders antd's hidden measure row,
which repeats the
+ // column titles, so the sort trigger has to be looked up inside the
visible header.
+ const header = document.querySelector('thead');
+ if (!header) throw new Error('DLQ table header not found');
+ await user.click(within(header as HTMLElement).getByText('最近入队时间'));
expect(screen.getByText('-')).toBeInTheDocument();
});
diff --git a/web/src/pages/instance/__tests__/InstancePage.test.tsx
b/web/src/pages/instance/__tests__/InstancePage.test.tsx
index 8c7f9d6b8..3b31c4795 100644
--- a/web/src/pages/instance/__tests__/InstancePage.test.tsx
+++ b/web/src/pages/instance/__tests__/InstancePage.test.tsx
@@ -771,7 +771,7 @@ describe('InstancePage', () => {
const cloudRow = (await screen.findByText('rmq-cloud-1')).closest('tr')!;
expect(within(cloudRow).getByText('cn-hangzhou')).toBeInTheDocument();
const apacheRow = screen.getByText('open-source-1').closest('tr')!;
- expect(within(apacheRow).getAllByText('-').length).toBeGreaterThan(0);
+ expect(within(apacheRow).getAllByText('开源版').length).toBeGreaterThan(0);
expect(within(apacheRow).queryByText('cn-hangzhou')).not.toBeInTheDocument();
});
diff --git a/web/src/pages/instance/acl.tsx b/web/src/pages/instance/acl.tsx
index b3d1984e0..680519be5 100644
--- a/web/src/pages/instance/acl.tsx
+++ b/web/src/pages/instance/acl.tsx
@@ -65,6 +65,7 @@ import {
} from '../../services/aclService';
import type { AclRule, AclUser, AclClusterConfig, PlainAccessConfig } from
'../../api/acl';
import { useInstanceFilter } from '../../hooks/useInstanceFilter';
+import { tableScrollX } from '../../utils/table';
type AclRuleFormValues = Pick<
AclRule,
@@ -1054,6 +1055,7 @@ const AclPageContent = ({
},
}}
size="small"
+ scroll={{ x: tableScrollX(ruleColumns) }}
/>
</div>
),
@@ -1106,6 +1108,7 @@ const AclPageContent = ({
},
}}
size="small"
+ scroll={{ x: tableScrollX(userColumns) }}
/>
</div>
),
diff --git a/web/src/pages/instance/dlq.tsx b/web/src/pages/instance/dlq.tsx
index 7bb1efbf2..0a4fcfde6 100644
--- a/web/src/pages/instance/dlq.tsx
+++ b/web/src/pages/instance/dlq.tsx
@@ -40,6 +40,7 @@ import type { DLQGroup } from '../../api/message';
import { exportDLQMessages, listDLQGroups, resendDLQ } from
'../../services/messageService';
import { useInstanceFilter } from '../../hooks/useInstanceFilter';
import { buildCsv, downloadCsv, type CsvColumn } from '../../utils/download';
+import { tableScrollX } from '../../utils/table';
const { Text } = Typography;
const { RangePicker } = DatePicker;
@@ -477,6 +478,7 @@ const DLQPage = () => {
},
}}
size="small"
+ scroll={{ x: tableScrollX(columns, { selection: true }) }}
/>
</Card>
diff --git a/web/src/pages/instance/index.tsx b/web/src/pages/instance/index.tsx
index 592c9975d..020b5831d 100644
--- a/web/src/pages/instance/index.tsx
+++ b/web/src/pages/instance/index.tsx
@@ -48,6 +48,7 @@ import {
} from '../../api/aliyunCatalog';
import { listTencentInstances, listTencentRegions } from
'../../api/tencentCatalog';
import { formatDateTime } from '../../utils/format';
+import { tableScrollX } from '../../utils/table';
import {
createInstance,
deleteInstance,
@@ -458,9 +459,9 @@ const InstancePage = () => {
sorter: (a, b) => (a.regionId ?? '').localeCompare(b.regionId ?? ''),
render: (regionId: string | undefined, record: Instance) => (
<Text type="secondary" style={{ fontSize: 14 }}>
- {!record.vendor || record.vendor === 'APACHE' || !regionId
- ? '-'
- : record.regionName || regionId}
+ {!record.vendor || record.vendor === 'APACHE'
+ ? '开源版'
+ : record.regionName || regionId || '-'}
</Text>
),
},
@@ -697,6 +698,7 @@ const InstancePage = () => {
pagination={false}
size="small"
tableLayout="fixed"
+ scroll={{ x: tableScrollX(columns, { selection: true }) }}
/>
</Card>
diff --git a/web/src/pages/instance/message.tsx
b/web/src/pages/instance/message.tsx
index 8a3d21b21..b36d99e28 100644
--- a/web/src/pages/instance/message.tsx
+++ b/web/src/pages/instance/message.tsx
@@ -60,6 +60,7 @@ import { getMessageTrace, queryMessages } from
'../../services/messageService';
import { listTopics } from '../../services/topicService';
import { useInstanceFilter } from '../../hooks/useInstanceFilter';
import { downloadBlob } from '../../utils/download';
+import { tableScrollX } from '../../utils/table';
const { Paragraph, Text } = Typography;
const { RangePicker } = DatePicker;
@@ -946,6 +947,7 @@ const MessagePageContent = ({
showTotal: (total) => `共 ${total} 条消息`,
}}
size="small"
+ scroll={{ x: tableScrollX(columns) }}
/>
</Card>
diff --git a/web/src/pages/instance/topic.tsx b/web/src/pages/instance/topic.tsx
index 51ea45fc7..c2bc59e59 100644
--- a/web/src/pages/instance/topic.tsx
+++ b/web/src/pages/instance/topic.tsx
@@ -79,6 +79,7 @@ import {
} from '../../utils/resourceCsvImport';
import { buildCsv, downloadCsv, type CsvColumn } from '../../utils/download';
import { parseMessageProperties } from '../../utils/messageProperties';
+import { tableScrollX } from '../../utils/table';
const { Text } = Typography;
@@ -1204,6 +1205,7 @@ const TopicPage = () => {
},
}}
size="small"
+ scroll={{ x: tableScrollX(columns, { selection: true }) }}
onRow={(record) => ({
onClick: () => void openDetail(record),
style: { cursor: 'pointer' },
diff --git a/web/src/pages/ops/alerts.tsx b/web/src/pages/ops/alerts.tsx
index d29fb091d..a68058c3f 100644
--- a/web/src/pages/ops/alerts.tsx
+++ b/web/src/pages/ops/alerts.tsx
@@ -48,6 +48,7 @@ import {
updateAlertRule,
} from '../../services/opsService';
import { attachThresholdUnit } from './alertRulePayload';
+import { tableScrollX } from '../../utils/table';
const { TextArea } = Input;
@@ -440,6 +441,7 @@ const AlertsPage = () => {
loading={loading}
rowSelection={rowSelection}
pagination={false}
+ scroll={{ x: tableScrollX(columns, { selection: true }) }}
/>
</Card>
diff --git a/web/src/pages/ops/audit.tsx b/web/src/pages/ops/audit.tsx
index 59eb03c12..0884db083 100644
--- a/web/src/pages/ops/audit.tsx
+++ b/web/src/pages/ops/audit.tsx
@@ -46,6 +46,7 @@ import {
listAuditRecords,
} from '../../services/opsService';
import { downloadBlob } from '../../utils/download';
+import { tableScrollX } from '../../utils/table';
const emptyFilterOptions: AuditFilterOptions = {
operationTypes: [],
@@ -372,7 +373,7 @@ const AuditPage: React.FC = () => {
dataSource={records}
rowKey="id"
loading={loading}
- scroll={{ x: 1470 }}
+ scroll={{ x: tableScrollX(columns) }}
pagination={{
current: page,
pageSize,
diff --git a/web/src/pages/studio/AlertManagement.tsx
b/web/src/pages/studio/AlertManagement.tsx
index 0af48aa36..3f99d48fd 100644
--- a/web/src/pages/studio/AlertManagement.tsx
+++ b/web/src/pages/studio/AlertManagement.tsx
@@ -56,6 +56,7 @@ import {
} from '../../api/alertManagement';
import type { AlertRule as PersistedAlertRule, AlertRuleRequest } from
'../../api/alertManagement';
import { downloadBlob } from '../../utils/download';
+import { tableScrollX } from '../../utils/table';
const { TextArea } = Input;
@@ -759,7 +760,7 @@ const AlertManagementPage: React.FC = () => {
showSizeChanger: true,
showTotal: (total) => `${t('common.total')} ${total}`,
}}
- scroll={{ x: 1200 }}
+ scroll={{ x: tableScrollX(columns, { selection: true }) }}
/>
</Card>
diff --git a/web/src/utils/table.test.ts b/web/src/utils/table.test.ts
new file mode 100644
index 000000000..d0b3df110
--- /dev/null
+++ b/web/src/utils/table.test.ts
@@ -0,0 +1,48 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { describe, expect, it } from 'vitest';
+
+import { tableScrollX } from './table';
+
+describe('tableScrollX', () => {
+ it('sums the declared column widths', () => {
+ expect(tableScrollX([{ width: 220 }, { width: 200 }, { width: 100
}])).toBe(520);
+ });
+
+ it('reserves room for the selection and expand columns', () => {
+ expect(tableScrollX([{ width: 100 }], { selection: true })).toBe(140);
+ expect(tableScrollX([{ width: 100 }], { expandable: true })).toBe(148);
+ expect(tableScrollX([{ width: 100 }], { selection: true, expandable: true,
extra: 12 })).toBe(
+ 200,
+ );
+ });
+
+ it('falls back to a default share for columns without a numeric width', ()
=> {
+ expect(tableScrollX([{ width: 100 }, {}, { width: '30%' }])).toBe(340);
+ expect(tableScrollX([{ width: '80px' }])).toBe(80);
+ });
+
+ it('adds up grouped children and skips hidden columns', () => {
+ expect(tableScrollX([{ children: [{ width: 100 }, { width: 60 }] }, {
width: 40 }])).toBe(200);
+ expect(tableScrollX([{ width: 100 }, { width: 999, hidden: true
}])).toBe(100);
+ });
+
+ it('tolerates an undefined column list', () => {
+ expect(tableScrollX(undefined)).toBe(0);
+ });
+});
diff --git a/web/src/utils/table.ts b/web/src/utils/table.ts
new file mode 100644
index 000000000..bf8e0a06e
--- /dev/null
+++ b/web/src/utils/table.ts
@@ -0,0 +1,85 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Minimum table width helpers.
+ *
+ * Without `scroll.x` antd keeps `table-layout: auto` and squeezes every
column into the
+ * viewport, so on a narrow window (or after zooming in) the declared column
widths are
+ * ignored and cells wrap. Passing a numeric `scroll.x` switches the table to
+ * `table-layout: fixed` with `min-width: 100%`, which keeps the columns
readable and shows
+ * a horizontal scrollbar when the window is narrower, while still stretching
to fill a
+ * wide window.
+ *
+ * The value is derived from the column definitions instead of being
hard-coded so it stays
+ * correct when columns are added, removed or resized.
+ */
+
+/** Width antd reserves for the leading checkbox column (not part of
`columns`). */
+const SELECTION_COLUMN_WIDTH = 40;
+
+/** Width antd reserves for the leading expand-icon column (not part of
`columns`). */
+const EXPAND_COLUMN_WIDTH = 48;
+
+/** Fallback share for columns that declare no width, so they never collapse
to nothing. */
+const UNSIZED_COLUMN_WIDTH = 120;
+
+interface ColumnLike {
+ width?: number | string;
+ children?: readonly ColumnLike[];
+ hidden?: boolean;
+}
+
+export interface TableScrollXOptions {
+ /** The table renders a `rowSelection` checkbox column. */
+ selection?: boolean;
+ /** The table renders an expandable row icon column. */
+ expandable?: boolean;
+ /** Extra pixels to reserve on top of the declared columns. */
+ extra?: number;
+}
+
+function columnWidth(column: ColumnLike): number {
+ if (column.hidden) {
+ return 0;
+ }
+ if (column.children?.length) {
+ return column.children.reduce((total, child) => total +
columnWidth(child), 0);
+ }
+ if (typeof column.width === 'number') {
+ return column.width;
+ }
+ if (typeof column.width === 'string') {
+ const parsed = Number.parseFloat(column.width);
+ return Number.isFinite(parsed) && column.width.endsWith('px') ? parsed :
UNSIZED_COLUMN_WIDTH;
+ }
+ return UNSIZED_COLUMN_WIDTH;
+}
+
+/** Sums the declared column widths into the `scroll.x` a table needs to stay
readable. */
+export function tableScrollX(
+ columns: readonly ColumnLike[] | undefined,
+ options: TableScrollXOptions = {},
+): number {
+ const declared = (columns ?? []).reduce((total, column) => total +
columnWidth(column), 0);
+ return (
+ declared +
+ (options.selection ? SELECTION_COLUMN_WIDTH : 0) +
+ (options.expandable ? EXPAND_COLUMN_WIDTH : 0) +
+ (options.extra ?? 0)
+ );
+}