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 6c0e983dd fix(web): scope LiteTopic TTL to its instance and drop a
fabricated column (#5600)
6c0e983dd is described below
commit 6c0e983dd7ca033b38a5579d6c4c313d2d018e21
Author: Zhao Jianing <[email protected]>
AuthorDate: Fri Oct 9 18:46:31 2026 +0800
fix(web): scope LiteTopic TTL to its instance and drop a fabricated column
(#5600)
Two defects on the cluster pages.
- `extendLiteTopicTTL` was called without the instance, so a TTL extension
could be applied through
whichever instance the request happened to land on; the API now takes
`instanceId` first and the
page forwards the instance that owns the row. (#5600)
- The NameServer tab's "Connections" column was fabricated: `mapClusters`
wrote `connections: 0` for
every nameserver, so the column always read zero. The field is removed
along with the column
instead of being populated with a guess. (#5543)
`liteTopic` + `LiteTopic` + `BrokerCluster` tests: 40 green; prettier
clean; the two eslint warnings
are pre-existing on trunk.
Maintainer edits on top of the contribution: restored the trailing newline
in all five changed files
(the branch had dropped them, which fails prettier), reused the existing
`supportsApacheRuntime`
helper instead of an inline copy of its condition, dropped a duplicated
comment, and switched the
test's `vi.mock('../../../api/instance')` to the `importActual` + spread
pattern already used in that
file so the real helper stays in play. Note that the helper reuse is a
de-duplication only: it differs
from the inline `!vendor` form just for a `null`/empty vendor, which the
API does not produce, so no
fixture can pin it.
Folded in #5543.
---
web/src/api/liteTopic.test.ts | 6 ++-
web/src/api/liteTopic.ts | 10 +++-
web/src/i18n/translations.ts | 6 +++
web/src/pages/studio/BrokerCluster.tsx | 9 ----
web/src/pages/studio/LiteTopic.tsx | 55 ++++++++++++++++++++--
.../pages/studio/__tests__/BrokerCluster.test.tsx | 14 ++++++
web/src/pages/studio/__tests__/LiteTopic.test.tsx | 42 +++++++++++++++++
7 files changed, 126 insertions(+), 16 deletions(-)
diff --git a/web/src/api/liteTopic.test.ts b/web/src/api/liteTopic.test.ts
index a571470f2..9fac99c31 100644
--- a/web/src/api/liteTopic.test.ts
+++ b/web/src/api/liteTopic.test.ts
@@ -112,15 +112,17 @@ describe('LiteTopic API', () => {
expect(result.popProgress).toBe(96);
});
- it('extends lite topic TTL', async () => {
+ it('extends lite topic TTL with the owning instance id', async () => {
mock.onPost('/liteTopic/extendTTL').reply((config) => {
const body = JSON.parse(config.data);
+ // LiteTopicTTLUpdateDTO requires a non-blank instanceId (@NotBlank).
+ expect(body.instanceId).toBe('apache-main');
expect(body.topicPattern).toBe('order-*');
expect(body.newTTL).toBe(7200);
return [200, { code: 200 }];
});
- await extendLiteTopicTTL('order-*', 7200);
+ await extendLiteTopicTTL('apache-main', 'order-*', 7200);
});
it('queries lite topic quota without namespace', async () => {
diff --git a/web/src/api/liteTopic.ts b/web/src/api/liteTopic.ts
index 1dfeff3bd..1a01e3a7f 100644
--- a/web/src/api/liteTopic.ts
+++ b/web/src/api/liteTopic.ts
@@ -89,8 +89,14 @@ export async function queryLiteTopicSession(sessionId:
string): Promise<LiteTopi
return res.data.data;
}
-export async function extendLiteTopicTTL(topicPattern: string, newTTL:
number): Promise<void> {
- await client.post('/liteTopic/extendTTL', { topicPattern, newTTL });
+export async function extendLiteTopicTTL(
+ instanceId: string,
+ topicPattern: string,
+ newTTL: number,
+): Promise<void> {
+ // The backend's LiteTopicTTLUpdateDTO requires a non-blank instanceId
+ // (the write is ownership-checked against the owning instance).
+ await client.post('/liteTopic/extendTTL', { instanceId, topicPattern, newTTL
});
}
export async function queryLiteTopicQuota(namespace?: string):
Promise<LiteTopicQuota> {
diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts
index 09e9dbba9..26588b377 100644
--- a/web/src/i18n/translations.ts
+++ b/web/src/i18n/translations.ts
@@ -3580,6 +3580,12 @@ const translations: Record<string, Record<Lang, string>>
= {
en: 'Enter new TTL value (ms)',
},
'liteTopic.extendTtlModalTitle': { zh: '延长 TTL', en: 'Extend TTL' },
+ 'liteTopic.instance': { zh: '所属实例', en: 'Owning Instance' },
+ 'liteTopic.instancePlaceholder': { zh: '选择所属实例', en: 'Select the owning
instance' },
+ 'liteTopic.instanceRequired': {
+ zh: '请选择 Topic 所属的实例',
+ en: 'Select the instance that owns the topic',
+ },
'liteTopic.popProgress': { zh: 'Pop 进度', en: 'Pop Progress' },
'liteTopic.sessionStatus': { zh: '会话状态', en: 'Session Status' },
'liteTopic.creationCount': { zh: '创建数量', en: 'Creation Count' },
diff --git a/web/src/pages/studio/BrokerCluster.tsx
b/web/src/pages/studio/BrokerCluster.tsx
index 0db75bc92..dedeeae46 100644
--- a/web/src/pages/studio/BrokerCluster.tsx
+++ b/web/src/pages/studio/BrokerCluster.tsx
@@ -59,7 +59,6 @@ interface NameServerRecord {
status: NodeStatus;
version: string;
address: string;
- connections: number;
}
interface ProxyRecord {
@@ -90,7 +89,6 @@ const NAMESERVER_EXPORT_COLUMNS:
CsvColumn<NameServerRecord>[] = [
{ header: 'Status', value: (nameServer) => nameServer.status },
{ header: 'Version', value: (nameServer) => nameServer.version },
{ header: 'Address', value: (nameServer) => nameServer.address },
- { header: 'Connections', value: (nameServer) => nameServer.connections },
];
const PROXY_EXPORT_COLUMNS: CsvColumn<ProxyRecord>[] = [
@@ -158,7 +156,6 @@ function mapClusters(clusters: ClusterInfo[]): {
status: normalizeStatus(nameServer.status),
version: cluster.version,
address: nameServer.addr,
- connections: 0,
});
});
@@ -429,12 +426,6 @@ const BrokerClusterPage = () => {
</code>
),
},
- {
- title: t('brokerCluster.connections'),
- dataIndex: 'connections',
- key: 'connections',
- render: (text: number) => <span style={{ fontWeight: 500
}}>{text.toLocaleString()}</span>,
- },
];
const proxyColumns = [
diff --git a/web/src/pages/studio/LiteTopic.tsx
b/web/src/pages/studio/LiteTopic.tsx
index 8061af752..b1b1547c3 100644
--- a/web/src/pages/studio/LiteTopic.tsx
+++ b/web/src/pages/studio/LiteTopic.tsx
@@ -59,6 +59,7 @@ import {
type LiteTopicItem,
type LiteTopicSession,
} from '../../api/liteTopic';
+import { listInstances, supportsApacheRuntime, type Instance } from
'../../api/instance';
import { buildCsv, downloadCsv, type CsvColumn } from '../../utils/download';
const formatDuration = (ms: number | undefined | null): string => {
@@ -147,10 +148,15 @@ const LiteTopicPage: React.FC = () => {
// Extend TTL modal
const [extendTTLModalOpen, setExtendTTLModalOpen] = useState(false);
const [extendTTLForm, setExtendTTLForm] = useState<{
+ instanceId: string;
topicPattern: string;
newTTL: number | null;
- }>({ topicPattern: '', newTTL: null });
+ }>({ instanceId: '', topicPattern: '', newTTL: null });
const [extendTTLLoading, setExtendTTLLoading] = useState(false);
+ // Instances that may own a Lite topic write (the backend's ownership guard
+ // only supports Apache-vendor instances for TTL updates).
+ const [ttlInstanceOptions, setTtlInstanceOptions] = useState<Instance[]>([]);
+ const [ttlInstancesLoading, setTtlInstancesLoading] = useState(false);
const mountedRef = useRef(false);
const bootstrapRequestId = useRef(0);
@@ -304,17 +310,43 @@ const LiteTopicPage: React.FC = () => {
const handleOpenExtendTTL = (record: LiteTopicItem) => {
setExtendTTLForm({
+ instanceId: '',
topicPattern: record.topicPattern || '',
newTTL: null,
});
setExtendTTLModalOpen(true);
+ // The TTL write is ownership-checked against the owning instance, so the
+ // modal needs the Apache-vendor instance list; auto-select when only one
+ // candidate exists.
+ setTtlInstancesLoading(true);
+ listInstances()
+ .then((instances) => {
+ const candidates = instances.filter(supportsApacheRuntime);
+ setTtlInstanceOptions(candidates);
+ setExtendTTLForm((form) =>
+ form.instanceId === '' && candidates.length === 1
+ ? { ...form, instanceId: String(candidates[0].name) }
+ : form,
+ );
+ })
+ .catch(() => {
+ setTtlInstanceOptions([]);
+ })
+ .finally(() => {
+ setTtlInstancesLoading(false);
+ });
};
const handleExtendTTL = async () => {
- if (!extendTTLForm.topicPattern || extendTTLForm.newTTL == null) return;
+ if (!extendTTLForm.instanceId || !extendTTLForm.topicPattern ||
extendTTLForm.newTTL == null)
+ return;
setExtendTTLLoading(true);
try {
- await extendLiteTopicTTL(extendTTLForm.topicPattern,
extendTTLForm.newTTL);
+ await extendLiteTopicTTL(
+ extendTTLForm.instanceId,
+ extendTTLForm.topicPattern,
+ extendTTLForm.newTTL,
+ );
message.success(t('liteTopic.extendTtlSuccess'));
setExtendTTLModalOpen(false);
void fetchData(patternFilter || undefined, namespaceFilter || undefined);
@@ -912,6 +944,23 @@ const LiteTopicPage: React.FC = () => {
destroyOnHidden
>
<Form layout="vertical" style={{ marginTop: 16 }}>
+ <Form.Item
+ label={t('liteTopic.instance')}
+ required
+ validateStatus={extendTTLForm.instanceId ? '' : 'error'}
+ help={extendTTLForm.instanceId ? undefined :
t('liteTopic.instanceRequired')}
+ >
+ <Select
+ value={extendTTLForm.instanceId || undefined}
+ onChange={(val) => setExtendTTLForm({ ...extendTTLForm,
instanceId: val })}
+ loading={ttlInstancesLoading}
+ placeholder={t('liteTopic.instancePlaceholder')}
+ options={ttlInstanceOptions.map((instance) => ({
+ value: String(instance.name),
+ label: instance.name,
+ }))}
+ />
+ </Form.Item>
<Form.Item label={t('liteTopic.pattern')}>
<Input
value={extendTTLForm.topicPattern}
diff --git a/web/src/pages/studio/__tests__/BrokerCluster.test.tsx
b/web/src/pages/studio/__tests__/BrokerCluster.test.tsx
index 21f19ba64..c9aa13fdc 100644
--- a/web/src/pages/studio/__tests__/BrokerCluster.test.tsx
+++ b/web/src/pages/studio/__tests__/BrokerCluster.test.tsx
@@ -402,4 +402,18 @@ describe('BrokerCluster Page', () => {
expect(screen.getByText('N/A')).toBeInTheDocument();
expect(screen.queryByText('运行中')).not.toBeInTheDocument();
});
+
+ it('does not render a Connections column for nameservers', async () => {
+ // The backend NameServerVO has no connection count; the column used to
+ // fabricate a hardcoded 0 for every nameserver (and export it to CSV).
+ const user = userEvent.setup();
+ renderWithProviders(<BrokerCluster />);
+ await screen.findByText('broker-api-a');
+ await user.click(screen.getByRole('tab', { name: /NameServer|Nameserver/i
}));
+ await screen.findAllByRole('columnheader');
+
+ expect(
+ screen.queryByRole('columnheader', { name: /连接数|[Cc]onnections/ }),
+ ).not.toBeInTheDocument();
+ });
});
diff --git a/web/src/pages/studio/__tests__/LiteTopic.test.tsx
b/web/src/pages/studio/__tests__/LiteTopic.test.tsx
index 8f160e368..413acb04e 100644
--- a/web/src/pages/studio/__tests__/LiteTopic.test.tsx
+++ b/web/src/pages/studio/__tests__/LiteTopic.test.tsx
@@ -21,6 +21,7 @@ import userEvent from '@testing-library/user-event';
import { App } from 'antd';
import { LangProvider } from '../../../i18n/LangContext';
import type { LiteTopicItem, LiteTopicQuota } from '../../../api/liteTopic';
+import type { Instance } from '../../../api/instance';
import { downloadCsv } from '../../../utils/download';
import LiteTopic, { formatTime } from '../LiteTopic';
@@ -34,6 +35,17 @@ const apiMocks = vi.hoisted(() => ({
vi.mock('../../../api/liteTopic', () => apiMocks);
+const instanceApiMocks = vi.hoisted(() => ({ listInstances: vi.fn() }));
+
+vi.mock('../../../api/instance', async () => {
+ const instanceModule =
+ await vi.importActual<typeof
import('../../../api/instance')>('../../../api/instance');
+ return {
+ ...instanceModule,
+ listInstances: instanceApiMocks.listInstances,
+ };
+});
+
vi.mock('../../../utils/download', async () => {
const downloadModule =
await vi.importActual<typeof
import('../../../utils/download')>('../../../utils/download');
@@ -119,6 +131,36 @@ describe('LiteTopic Page', () => {
consumedMessages: 0,
popProgress: 96,
});
+ instanceApiMocks.listInstances.mockResolvedValue([]);
+ });
+
+ it('sends the owning instance id when extending TTL', async () => {
+ // LiteTopicTTLUpdateDTO requires a non-blank instanceId; only the
+ // Apache-vendor instance is a candidate for the ownership-checked write.
+ instanceApiMocks.listInstances.mockResolvedValue([
+ { id: 1, name: 'apache-main', vendor: 'APACHE' },
+ { id: 2, name: 'cloud-1', vendor: 'ALIYUN' },
+ ] as unknown as Instance[]);
+ const user = userEvent.setup();
+ renderPage();
+
+ await screen.findByText('order-*');
+ await user.click(await screen.findByText('延长 TTL'));
+
+ // exactly one Apache-vendor instance -> auto-selected in the modal
+ await waitFor(() => {
+ expect(apiMocks.extendLiteTopicTTL).not.toHaveBeenCalled();
+ const selected = document.querySelector('.ant-modal
.ant-select-selection-item');
+ expect(selected?.textContent).toBe('apache-main');
+ });
+
+ await user.type(screen.getByRole('spinbutton'), '7200');
+ // antd inserts a space between the two CJK chars of the confirm button
+ await user.click(await screen.findByRole('button', { name: /确\s*认/ }));
+
+ await waitFor(() => {
+ expect(apiMocks.extendLiteTopicTTL).toHaveBeenCalledWith('apache-main',
'order-*', 7200);
+ });
});
it('returns to the first page when the local TTL filter changes', async ()
=> {