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 fc468941 feat(web): switch data source at runtime and consume real
APIs (#799)
fc468941 is described below
commit fc46894108a7c12bb06d41d634965da9022671f0
Author: lizhimins <[email protected]>
AuthorDate: Mon Aug 3 14:33:07 2026 +0800
feat(web): switch data source at runtime and consume real APIs (#799)
---
web/src/App.test.tsx | 1 +
web/src/App.tsx | 6 +-
web/src/config.ts | 9 +-
web/src/index.css | 9 +
web/src/layouts/MainLayout.tsx | 43 +++
web/src/pages/cluster/clients.tsx | 3 +-
web/src/pages/instance/consumer.tsx | 11 +-
web/src/pages/instance/index.tsx | 5 +-
web/src/pages/instance/topic.tsx | 54 ++-
web/src/pages/studio/BrokerCluster.tsx | 380 ++++++++++++++++-----
web/src/pages/studio/GroupManagement.tsx | 378 +++++++++++++++++---
.../pages/studio/__tests__/BrokerCluster.test.tsx | 122 +++++--
.../studio/__tests__/GroupManagement.test.tsx | 66 +++-
web/src/services/aclService.test.ts | 2 +-
web/src/services/aclService.ts | 18 +-
web/src/services/clusterService.test.ts | 2 +-
web/src/services/clusterService.ts | 34 +-
web/src/services/connectionsService.test.ts | 2 +-
web/src/services/connectionsService.ts | 4 +-
web/src/services/consumerService.test.ts | 2 +-
web/src/services/consumerService.ts | 24 +-
web/src/services/dashboardService.test.ts | 2 +-
web/src/services/dashboardService.ts | 4 +-
web/src/services/dataMode.ts | 27 ++
web/src/services/instanceService.test.ts | 2 +-
web/src/services/instanceService.ts | 11 +-
web/src/services/messageService.test.ts | 2 +-
web/src/services/messageService.ts | 10 +-
web/src/services/opsService.test.ts | 2 +-
web/src/services/opsService.ts | 24 +-
web/src/services/topicService.test.ts | 2 +-
web/src/services/topicService.ts | 16 +-
web/src/stores/dataModeStore.ts | 36 ++
33 files changed, 1060 insertions(+), 253 deletions(-)
diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx
index 13c152de..3a5d69ec 100644
--- a/web/src/App.test.tsx
+++ b/web/src/App.test.tsx
@@ -29,6 +29,7 @@ vi.mock('./api/auth', async (importOriginal) => {
});
vi.mock('./config', () => ({ API_BASE_URL: '/api', USE_MOCK: false }));
+vi.mock('./services/dataMode', () => ({ isMockMode: () => false }));
const mockedGetAuthStatus = vi.mocked(getAuthStatus);
diff --git a/web/src/App.tsx b/web/src/App.tsx
index fa54c406..4e98d064 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -19,7 +19,7 @@ import { lazy, Suspense, useCallback, useEffect, useState }
from 'react';
import { Button, Result, Spin } from 'antd';
import { Routes, Route, Navigate, Outlet } from 'react-router-dom';
import { getAuthStatus } from './api/auth';
-import { USE_MOCK } from './config';
+import { isMockMode } from './services/dataMode';
import { useLang } from './i18n/LangContext';
import useAuthStore from './stores/authStore';
import MainLayout from './layouts/MainLayout';
@@ -56,11 +56,11 @@ type AuthGateState = 'checking' | 'allowed' | 'denied' |
'error';
export function AuthGate() {
const { t } = useLang();
const clearAuth = useAuthStore((state) => state.logout);
- const [gateState, setGateState] = useState<AuthGateState>(USE_MOCK ?
'allowed' : 'checking');
+ const [gateState, setGateState] = useState<AuthGateState>(isMockMode() ?
'allowed' : 'checking');
const [attempt, setAttempt] = useState(0);
useEffect(() => {
- if (USE_MOCK) return;
+ if (isMockMode()) return;
let cancelled = false;
void getAuthStatus()
diff --git a/web/src/config.ts b/web/src/config.ts
index 8d58a72c..4068a3f5 100644
--- a/web/src/config.ts
+++ b/web/src/config.ts
@@ -1,10 +1,13 @@
/*
* Compile-time configuration.
- * VITE_USE_MOCK controls whether the app uses mock data or real API calls.
- * Set via .env file or build-time environment variable.
*/
-export const USE_MOCK = import.meta.env.VITE_USE_MOCK === 'true';
+/**
+ * @deprecated Use `isMockMode()` from `./services/dataMode` instead.
+ * The data mode is now a runtime toggle persisted in localStorage via Zustand.
+ * This export is kept temporarily for backward compatibility and will be
removed.
+ */
+export const USE_MOCK = false;
/** API prefix for browser requests. Defaults to the reverse-proxy friendly
`/api`. */
export const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL ||
'/api').replace(/\/$/, '');
diff --git a/web/src/index.css b/web/src/index.css
index d9074ce1..1c6f07b7 100644
--- a/web/src/index.css
+++ b/web/src/index.css
@@ -284,3 +284,12 @@ body {
color: #262626;
font-weight: 600;
}
+
+/* Tables keep every cell on one line: wrapped cells make rows uneven and hard
to scan.
+ Long values are truncated with an ellipsis, and the table scrolls
horizontally instead. */
+.ant-table-cell {
+ white-space: nowrap;
+}
+.ant-table-cell .ant-typography {
+ margin-bottom: 0;
+}
diff --git a/web/src/layouts/MainLayout.tsx b/web/src/layouts/MainLayout.tsx
index 71cbac71..cb8e0f2d 100644
--- a/web/src/layouts/MainLayout.tsx
+++ b/web/src/layouts/MainLayout.tsx
@@ -47,6 +47,7 @@ import {
isNavigationSearchShortcut,
type NavigationSearchEntry,
} from './navigationSearch';
+import { useDataModeStore } from '../stores/dataModeStore';
const { Sider, Content } = Layout;
@@ -60,6 +61,14 @@ const MainLayout = () => {
const [searchText, setSearchText] = useState('');
const { lang, setLang, t } = useLang();
const clearAuth = useAuthStore((state) => state.logout);
+ const useMock = useDataModeStore((state) => state.useMock);
+ const toggleDataMode = useDataModeStore((state) => state.toggle);
+
+ // Pages fetch on mount, so reload to re-request everything from the new
data source.
+ const handleDataModeToggle = () => {
+ toggleDataMode();
+ window.location.reload();
+ };
const handleUserMenuClick = async ({ key }: { key: string }) => {
if (key === 'profile') {
@@ -302,6 +311,40 @@ const MainLayout = () => {
</span>
</div>
+ {/* Data mode toggle */}
+ <div
+ onClick={handleDataModeToggle}
+ style={{
+ cursor: 'pointer',
+ display: 'flex',
+ alignItems: 'center',
+ gap: 6,
+ padding: '4px 10px',
+ borderRadius: 6,
+ border: `1px solid ${borderColor}`,
+ fontSize: 12,
+ fontWeight: 500,
+ color: useMock ? '#d48806' : '#389e0d',
+ transition: 'all 0.2s',
+ }}
+ title={
+ useMock
+ ? 'Data Mode: Mock (click to switch to Real)'
+ : 'Data Mode: Real (click to switch to Mock)'
+ }
+ >
+ <span
+ style={{
+ width: 8,
+ height: 8,
+ borderRadius: '50%',
+ background: useMock ? '#faad14' : '#52c41a',
+ display: 'inline-block',
+ }}
+ />
+ {useMock ? 'Mock' : 'Real'}
+ </div>
+
{/* Language toggle */}
<div
onClick={() => setLang(lang === 'zh' ? 'en' : 'zh')}
diff --git a/web/src/pages/cluster/clients.tsx
b/web/src/pages/cluster/clients.tsx
index 351b6634..baa8d789 100644
--- a/web/src/pages/cluster/clients.tsx
+++ b/web/src/pages/cluster/clients.tsx
@@ -39,6 +39,7 @@ import PageHeader from '../../components/PageHeader';
import { useLang } from '../../i18n/LangContext';
import type { ClientConnection } from '../../api/connections';
import { listConnections } from '../../services/connectionsService';
+import { formatDateTime } from '../../utils/format';
const { Text } = Typography;
@@ -264,7 +265,7 @@ const ClientsPage = () => {
sorter: (a, b) => a.connectedAt.localeCompare(b.connectedAt),
render: (d: string) => (
<Text type="secondary" style={{ fontSize: 13 }}>
- {d}
+ {formatDateTime(d)}
</Text>
),
},
diff --git a/web/src/pages/instance/consumer.tsx
b/web/src/pages/instance/consumer.tsx
index f21abb00..9b6491c1 100644
--- a/web/src/pages/instance/consumer.tsx
+++ b/web/src/pages/instance/consumer.tsx
@@ -61,6 +61,7 @@ import type { Dayjs } from 'dayjs';
import PageHeader from '../../components/PageHeader';
import { useLang } from '../../i18n/LangContext';
import { TOPIC_TYPE_MAP, PROTOCOL_MAP } from '../../constants/theme';
+import { formatDateTime } from '../../utils/format';
import type {
ConsumerGroup,
ConsumerInstance,
@@ -111,12 +112,6 @@ const formatDelay = (totalSeconds: number): string => {
return parts.length > 0 ? parts.join('') : '0秒';
};
-const formatDateTime = (dateStr: string): string => {
- const d = new Date(dateStr);
- const pad = (n: number) => String(n).padStart(2, '0');
- return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}
${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
-};
-
const normalizedConsistency = (value?: string | null): string =>
value?.trim().toLowerCase() ?? '';
const isConsistentValue = (value?: string | null): boolean =>
@@ -326,7 +321,7 @@ const ConsumerPage = () => {
sorter: (a, b) => a.createdAt.localeCompare(b.createdAt),
render: (d: string) => (
<Text type="secondary" style={{ fontSize: 13 }}>
- {d}
+ {formatDateTime(d)}
</Text>
),
},
@@ -338,7 +333,7 @@ const ConsumerPage = () => {
sorter: (a, b) => a.updatedAt.localeCompare(b.updatedAt),
render: (d: string) => (
<Text type="secondary" style={{ fontSize: 13 }}>
- {d}
+ {formatDateTime(d)}
</Text>
),
},
diff --git a/web/src/pages/instance/index.tsx b/web/src/pages/instance/index.tsx
index 225b806d..5ef21347 100644
--- a/web/src/pages/instance/index.tsx
+++ b/web/src/pages/instance/index.tsx
@@ -35,6 +35,7 @@ import { Plus, MagnifyingGlass } from '@phosphor-icons/react';
import { EditOutlined, DeleteOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import type { Instance, InstanceQuery } from '../../api/instance';
+import { formatDateTime } from '../../utils/format';
import {
createInstance,
deleteInstance,
@@ -213,7 +214,7 @@ const InstancePage = () => {
sorter: (a, b) => a.createdAt.localeCompare(b.createdAt),
render: (d: string) => (
<Text type="secondary" style={{ fontSize: 13 }}>
- {d}
+ {formatDateTime(d)}
</Text>
),
},
@@ -225,7 +226,7 @@ const InstancePage = () => {
sorter: (a, b) => a.updatedAt.localeCompare(b.updatedAt),
render: (d: string) => (
<Text type="secondary" style={{ fontSize: 13 }}>
- {d}
+ {formatDateTime(d)}
</Text>
),
},
diff --git a/web/src/pages/instance/topic.tsx b/web/src/pages/instance/topic.tsx
index 9c0967a3..b8ae793a 100644
--- a/web/src/pages/instance/topic.tsx
+++ b/web/src/pages/instance/topic.tsx
@@ -17,6 +17,7 @@
import { useEffect, useState, useMemo } from 'react';
import {
+ Alert,
Table,
Card,
Tag,
@@ -227,6 +228,8 @@ const TopicPage = () => {
const [tablePageSize, setTablePageSize] = useState(20);
const [viewMode, setViewMode] = useState<string>('列表');
const [detailModalOpen, setDetailModalOpen] = useState(false);
+ const [detailLoading, setDetailLoading] = useState(false);
+ const [rebuilding, setRebuilding] = useState(false);
const [selectedTopic, setSelectedTopic] = useState<Topic | null>(null);
const [modalOpen, setModalOpen] = useState(false);
const [form] = Form.useForm();
@@ -279,6 +282,7 @@ const TopicPage = () => {
const openDetail = async (topic: Topic) => {
setSelectedTopic(topic);
setDetailModalOpen(true);
+ setDetailLoading(true);
try {
const [routes, consumers] = await Promise.all([
getTopicRoutes(topic.name),
@@ -288,6 +292,28 @@ const TopicPage = () => {
setConsumersByTopic((previous) => ({ ...previous, [topic.name]:
consumers }));
} catch {
message.error('Topic 详情加载失败,请稍后重试');
+ } finally {
+ setDetailLoading(false);
+ }
+ };
+
+ // Metadata lives in the database, so a record can exist without a broker
route.
+ const rebuildTopic = async (topic: Topic) => {
+ setRebuilding(true);
+ try {
+ await createTopic({
+ name: topic.name,
+ type: topic.type,
+ writeQueues: topic.writeQueues,
+ readQueues: topic.readQueues,
+ });
+ const routes = await getTopicRoutes(topic.name);
+ setRoutesByTopic((previous) => ({ ...previous, [topic.name]: routes }));
+ message.success(`Topic「${topic.name}」已在 Broker 上重建`);
+ } catch {
+ message.error('重建 Topic 失败,请检查 Broker 状态后重试');
+ } finally {
+ setRebuilding(false);
}
};
@@ -333,7 +359,7 @@ const TopicPage = () => {
width: 220,
sorter: (a, b) => a.name.localeCompare(b.name),
render: (name: string) => (
- <Text strong style={{ fontSize: 14 }}>
+ <Text strong style={{ fontSize: 14, display: 'block' }} ellipsis={{
tooltip: name }}>
{name}
</Text>
),
@@ -345,7 +371,11 @@ const TopicPage = () => {
width: 200,
sorter: (a, b) => a.remark.localeCompare(b.remark),
render: (remark: string) => (
- <Text type="secondary" style={{ fontSize: 13 }}>
+ <Text
+ type="secondary"
+ style={{ fontSize: 13, display: 'block' }}
+ ellipsis={{ tooltip: remark }}
+ >
{remark}
</Text>
),
@@ -784,12 +814,32 @@ const TopicPage = () => {
<Text strong style={{ fontSize: 14, display: 'block',
marginBottom: 12 }}>
路由信息
</Text>
+ {!detailLoading && getRoutes(selectedTopic.name).length === 0 && (
+ <Alert
+ type="warning"
+ showIcon
+ style={{ marginBottom: 12 }}
+ message="Broker 上没有该 Topic 的路由"
+ description="元数据库中存在这条记录,但 Broker 未返回路由信息,可能尚未在 Broker
上创建或已被删除。可按库中记录的队列数重建。"
+ action={
+ <Button
+ size="small"
+ type="primary"
+ loading={rebuilding}
+ onClick={() => void rebuildTopic(selectedTopic)}
+ >
+ 在 Broker 上重建
+ </Button>
+ }
+ />
+ )}
<Table<BrokerRoute>
columns={routeColumns}
dataSource={getRoutes(selectedTopic.name)}
rowKey="brokerName"
pagination={false}
size="small"
+ loading={detailLoading}
/>
<Divider style={{ margin: '20px 0 16px' }} />
diff --git a/web/src/pages/studio/BrokerCluster.tsx
b/web/src/pages/studio/BrokerCluster.tsx
index f584d3e5..9e589c0d 100644
--- a/web/src/pages/studio/BrokerCluster.tsx
+++ b/web/src/pages/studio/BrokerCluster.tsx
@@ -15,8 +15,8 @@
* limitations under the License.
*/
-import { useState } from 'react';
-import { Table, Button, Tag, Tabs, Card, Space, Switch, Progress, Tooltip }
from 'antd';
+import { useCallback, useRef, useState } from 'react';
+import { Table, Button, Tag, Tabs, Card, Space, Switch, Progress, Tooltip,
Spin, App } from 'antd';
import {
Plus,
ArrowClockwise,
@@ -27,25 +27,29 @@ import {
PlugsConnected,
} from '@phosphor-icons/react';
import { useLang } from '../../i18n/LangContext';
+import { listClusters } from '../../services/clusterService';
+import type { ClusterInfo } from '../../api/cluster';
// ─── Types ──────────────────────────────────────────────────────
+type NodeStatus = 'running' | 'readonly' | 'maintenance';
+
interface BrokerRecord {
key: string;
k8sCluster: string;
brokerName: string;
- status: 'running' | 'readonly' | 'maintenance';
+ status: NodeStatus;
version: string;
diskUsage: number;
address: string;
- tpsIn: string;
- tpsOut: string;
+ tpsIn: number;
+ tpsOut: number;
}
interface NameServerRecord {
key: string;
k8sCluster: string;
name: string;
- status: 'running' | 'readonly' | 'maintenance';
+ status: NodeStatus;
version: string;
address: string;
connections: number;
@@ -55,22 +59,233 @@ interface ProxyRecord {
key: string;
k8sCluster: string;
name: string;
- status: 'running' | 'readonly' | 'maintenance';
+ status: NodeStatus;
version: string;
address: string;
grpcPort: string;
connections: number;
}
-const brokerData: BrokerRecord[] = [];
-const nameServerData: NameServerRecord[] = [];
-const proxyData: ProxyRecord[] = [];
+// ─── Mock Data (fallback when the API is unavailable) ───────────
+const mockBrokerData: BrokerRecord[] = [
+ {
+ key: '1',
+ k8sCluster: 'prod-cn-east-1',
+ brokerName: 'broker-a',
+ status: 'running',
+ version: '5.3.0',
+ diskUsage: 62,
+ address: '10.0.1.10:10911',
+ tpsIn: 12580,
+ tpsOut: 8340,
+ },
+ {
+ key: '2',
+ k8sCluster: 'prod-cn-east-1',
+ brokerName: 'broker-b',
+ status: 'readonly',
+ version: '5.3.0',
+ diskUsage: 89,
+ address: '10.0.1.11:10911',
+ tpsIn: 0,
+ tpsOut: 3120,
+ },
+ {
+ key: '3',
+ k8sCluster: 'prod-cn-east-1',
+ brokerName: 'broker-c',
+ status: 'running',
+ version: '5.2.0',
+ diskUsage: 45,
+ address: '10.0.1.12:10911',
+ tpsIn: 9750,
+ tpsOut: 6280,
+ },
+ {
+ key: '4',
+ k8sCluster: 'prod-cn-south-1',
+ brokerName: 'broker-d',
+ status: 'maintenance',
+ version: '5.3.0',
+ diskUsage: 33,
+ address: '10.0.2.10:10911',
+ tpsIn: 0,
+ tpsOut: 0,
+ },
+ {
+ key: '5',
+ k8sCluster: 'prod-cn-south-1',
+ brokerName: 'broker-e',
+ status: 'running',
+ version: '5.3.0',
+ diskUsage: 51,
+ address: '10.0.2.11:10911',
+ tpsIn: 7890,
+ tpsOut: 5430,
+ },
+ {
+ key: '6',
+ k8sCluster: 'staging-cn-east-1',
+ brokerName: 'broker-staging-a',
+ status: 'running',
+ version: '5.3.1',
+ diskUsage: 28,
+ address: '10.0.10.10:10911',
+ tpsIn: 1230,
+ tpsOut: 980,
+ },
+];
+
+const mockNameServerData: NameServerRecord[] = [
+ {
+ key: '1',
+ k8sCluster: 'prod-cn-east-1',
+ name: 'nameserver-a',
+ status: 'running',
+ version: '5.3.0',
+ address: '10.0.1.20:9876',
+ connections: 156,
+ },
+ {
+ key: '2',
+ k8sCluster: 'prod-cn-east-1',
+ name: 'nameserver-b',
+ status: 'running',
+ version: '5.3.0',
+ address: '10.0.1.21:9876',
+ connections: 148,
+ },
+ {
+ key: '3',
+ k8sCluster: 'prod-cn-south-1',
+ name: 'nameserver-c',
+ status: 'running',
+ version: '5.3.0',
+ address: '10.0.2.20:9876',
+ connections: 92,
+ },
+];
+
+const mockProxyData: ProxyRecord[] = [
+ {
+ key: '1',
+ k8sCluster: 'prod-cn-east-1',
+ name: 'proxy-a',
+ status: 'running',
+ version: '5.3.0',
+ address: '10.0.1.30:8080',
+ grpcPort: '10.0.1.30:8081',
+ connections: 2340,
+ },
+ {
+ key: '2',
+ k8sCluster: 'prod-cn-south-1',
+ name: 'proxy-b',
+ status: 'running',
+ version: '5.3.0',
+ address: '10.0.2.30:8080',
+ grpcPort: '10.0.2.30:8081',
+ connections: 1560,
+ },
+];
+
+// ─── Helpers ────────────────────────────────────────────────────
+const normalizeStatus = (status: string): NodeStatus => {
+ const value = (status || '').toLowerCase();
+ if (value === 'readonly' || value === 'warning') return 'readonly';
+ if (value === 'maintenance' || value === 'error' || value === 'offline')
return 'maintenance';
+ return 'running';
+};
+
+const hostOf = (addr: string): string => addr.split(':')[0] ?? addr;
+
+function mapClusters(clusters: ClusterInfo[]): {
+ brokers: BrokerRecord[];
+ nameServers: NameServerRecord[];
+ proxies: ProxyRecord[];
+} {
+ const brokers: BrokerRecord[] = [];
+ const nameServers: NameServerRecord[] = [];
+ const proxies: ProxyRecord[] = [];
+
+ clusters.forEach((cluster) => {
+ const clusterLabel = cluster.nsClusterName || cluster.name || cluster.id;
+
+ cluster.brokers.forEach((broker, index) => {
+ brokers.push({
+ key: `${cluster.id}-broker-${broker.addr || index}`,
+ k8sCluster: clusterLabel,
+ brokerName: broker.name || broker.addr,
+ status: normalizeStatus(broker.status),
+ version: broker.version || cluster.version,
+ diskUsage: broker.diskUsage ?? 0,
+ address: broker.addr,
+ tpsIn: broker.tpsIn ?? 0,
+ tpsOut: broker.tpsOut ?? 0,
+ });
+ });
+
+ cluster.nameServers.forEach((nameServer, index) => {
+ nameServers.push({
+ key: `${cluster.id}-ns-${nameServer.addr || index}`,
+ k8sCluster: clusterLabel,
+ name: nameServer.addr,
+ status: normalizeStatus(nameServer.status),
+ version: cluster.version,
+ address: nameServer.addr,
+ connections: 0,
+ });
+ });
+
+ cluster.proxies.forEach((proxy, index) => {
+ const host = hostOf(proxy.addr);
+ proxies.push({
+ key: `${cluster.id}-proxy-${proxy.addr || index}`,
+ k8sCluster: clusterLabel,
+ name: proxy.addr,
+ status: normalizeStatus(proxy.status),
+ version: cluster.version,
+ address: proxy.addr,
+ grpcPort: proxy.grpcPort ? `${host}:${proxy.grpcPort}` : '-',
+ connections: proxy.connections ?? 0,
+ });
+ });
+ });
+
+ return { brokers, nameServers, proxies };
+}
// ─── Component ──────────────────────────────────────────────────
const BrokerClusterPage = () => {
const [autoRefresh, setAutoRefresh] = useState(false);
const [activeTab, setActiveTab] = useState('broker');
+ const [loading, setLoading] = useState(false);
+ const [brokerData, setBrokerData] = useState<BrokerRecord[]>(mockBrokerData);
+ const [nameServerData, setNameServerData] =
useState<NameServerRecord[]>(mockNameServerData);
+ const [proxyData, setProxyData] = useState<ProxyRecord[]>(mockProxyData);
const { t } = useLang();
+ const { message } = App.useApp();
+
+ const loadData = useCallback(async () => {
+ setLoading(true);
+ try {
+ const clusters = await listClusters();
+ const mapped = mapClusters(clusters);
+ setBrokerData(mapped.brokers);
+ setNameServerData(mapped.nameServers);
+ setProxyData(mapped.proxies);
+ } catch {
+ message.error(t('common.refreshFailed'));
+ } finally {
+ setLoading(false);
+ }
+ }, [message, t]);
+
+ const initialized = useRef<boolean | null>(null);
+ if (initialized.current == null) {
+ initialized.current = true;
+ void loadData();
+ }
const renderStatus = (status: string) => {
const config: Record<string, { color: string; label: string }> = {
@@ -157,17 +372,15 @@ const BrokerClusterPage = () => {
title: t('brokerCluster.tpsIn'),
dataIndex: 'tpsIn',
key: 'tpsIn',
- render: (text: string) => <span style={{ fontWeight: 500
}}>{text}</span>,
- sorter: (a: BrokerRecord, b: BrokerRecord) =>
- parseFloat(a.tpsIn.replace(/,/g, '')) -
parseFloat(b.tpsIn.replace(/,/g, '')),
+ render: (value: number) => <span style={{ fontWeight: 500
}}>{value.toLocaleString()}</span>,
+ sorter: (a: BrokerRecord, b: BrokerRecord) => a.tpsIn - b.tpsIn,
},
{
title: t('brokerCluster.tpsOut'),
dataIndex: 'tpsOut',
key: 'tpsOut',
- render: (text: string) => <span style={{ fontWeight: 500
}}>{text}</span>,
- sorter: (a: BrokerRecord, b: BrokerRecord) =>
- parseFloat(a.tpsOut.replace(/,/g, '')) -
parseFloat(b.tpsOut.replace(/,/g, '')),
+ render: (value: number) => <span style={{ fontWeight: 500
}}>{value.toLocaleString()}</span>,
+ sorter: (a: BrokerRecord, b: BrokerRecord) => a.tpsOut - b.tpsOut,
},
{
title: t('common.actions'),
@@ -230,7 +443,7 @@ const BrokerClusterPage = () => {
title: t('brokerCluster.connections'),
dataIndex: 'connections',
key: 'connections',
- render: (text: number) => <span style={{ fontWeight: 500
}}>{text}</span>,
+ render: (text: number) => <span style={{ fontWeight: 500
}}>{text.toLocaleString()}</span>,
},
{
title: t('common.actions'),
@@ -306,7 +519,7 @@ const BrokerClusterPage = () => {
title: t('brokerCluster.connections'),
dataIndex: 'connections',
key: 'connections',
- render: (text: number) => <span style={{ fontWeight: 500
}}>{text}</span>,
+ render: (text: number) => <span style={{ fontWeight: 500
}}>{text.toLocaleString()}</span>,
},
{
title: t('common.actions'),
@@ -354,7 +567,7 @@ const BrokerClusterPage = () => {
unCheckedChildren={t('brokerCluster.manual')}
size="small"
/>
- <Button icon={<ArrowClockwise size={14} />} size="small">
+ <Button icon={<ArrowClockwise size={14} />} size="small" onClick={()
=> void loadData()}>
{t('common.reset')}
</Button>
<Button type="primary" icon={<Plus size={14} />}>
@@ -363,71 +576,70 @@ const BrokerClusterPage = () => {
</Space>
</div>
- <Card bordered={false} style={{ borderRadius: 8, boxShadow: '0 1px 6px
rgba(0,0,0,0.04)' }}>
- <Tabs
- activeKey={activeTab}
- onChange={setActiveTab}
- items={[
- {
- key: 'nameserver',
- label: (
- <span>
- <ChartBar size={16} style={{ marginRight: 4, verticalAlign:
'middle' }} />
- {t('brokerCluster.nsManagement')}
- </span>
- ),
- children: (
- <Table
- columns={nsColumns}
- dataSource={nameServerData}
- locale={{ emptyText: t('brokerCluster.providerUnavailable')
}}
- pagination={false}
- size="middle"
- />
- ),
- },
- {
- key: 'broker',
- label: (
- <span>
- <Cloud size={16} style={{ marginRight: 4, verticalAlign:
'middle' }} />
- {t('brokerCluster.brokerManagement')}
- </span>
- ),
- children: (
- <Table
- columns={brokerColumns}
- dataSource={brokerData}
- locale={{ emptyText: t('brokerCluster.providerUnavailable')
}}
- pagination={{
- pageSize: 10,
- showTotal: (total) => `${t('common.total')} ${total}
Broker`,
- }}
- size="middle"
- />
- ),
- },
- {
- key: 'proxy',
- label: (
- <span>
- <PlugsConnected size={16} style={{ marginRight: 4,
verticalAlign: 'middle' }} />
- {t('brokerCluster.proxyManagement')}
- </span>
- ),
- children: (
- <Table
- columns={proxyColumns}
- dataSource={proxyData}
- locale={{ emptyText: t('brokerCluster.providerUnavailable')
}}
- pagination={false}
- size="middle"
- />
- ),
- },
- ]}
- />
- </Card>
+ <Spin spinning={loading} tip={t('common.loading')}>
+ <Card bordered={false} style={{ borderRadius: 8, boxShadow: '0 1px 6px
rgba(0,0,0,0.04)' }}>
+ <Tabs
+ activeKey={activeTab}
+ onChange={setActiveTab}
+ items={[
+ {
+ key: 'nameserver',
+ label: (
+ <span>
+ <ChartBar size={16} style={{ marginRight: 4,
verticalAlign: 'middle' }} />
+ {t('brokerCluster.nsManagement')}
+ </span>
+ ),
+ children: (
+ <Table
+ columns={nsColumns}
+ dataSource={nameServerData}
+ pagination={false}
+ size="middle"
+ />
+ ),
+ },
+ {
+ key: 'broker',
+ label: (
+ <span>
+ <Cloud size={16} style={{ marginRight: 4, verticalAlign:
'middle' }} />
+ {t('brokerCluster.brokerManagement')}
+ </span>
+ ),
+ children: (
+ <Table
+ columns={brokerColumns}
+ dataSource={brokerData}
+ pagination={{
+ pageSize: 10,
+ showTotal: (total) => `${t('common.total')} ${total}
Broker`,
+ }}
+ size="middle"
+ />
+ ),
+ },
+ {
+ key: 'proxy',
+ label: (
+ <span>
+ <PlugsConnected size={16} style={{ marginRight: 4,
verticalAlign: 'middle' }} />
+ {t('brokerCluster.proxyManagement')}
+ </span>
+ ),
+ children: (
+ <Table
+ columns={proxyColumns}
+ dataSource={proxyData}
+ pagination={false}
+ size="middle"
+ />
+ ),
+ },
+ ]}
+ />
+ </Card>
+ </Spin>
</div>
);
};
diff --git a/web/src/pages/studio/GroupManagement.tsx
b/web/src/pages/studio/GroupManagement.tsx
index 0a648b04..7727b231 100644
--- a/web/src/pages/studio/GroupManagement.tsx
+++ b/web/src/pages/studio/GroupManagement.tsx
@@ -15,68 +15,149 @@
* limitations under the License.
*/
-import { useState } from 'react';
+import { useCallback, useEffect, useMemo, useState } from 'react';
import {
Table,
Button,
Input,
Tag,
+ Modal,
+ Tabs,
Card,
+ Row,
+ Col,
+ Descriptions,
Space,
Switch,
+ message,
} from 'antd';
-import { MagnifyingGlass, Plus, ArrowClockwise, Users } from
'@phosphor-icons/react';
+import { MagnifyingGlass, Plus, ArrowClockwise, Users, Eye } from
'@phosphor-icons/react';
import { useLang } from '../../i18n/LangContext';
+import type { ConsumerGroup, QueueProgress, SubscriptionEntry } from
'../../api/metadata';
+import {
+ getConsumerProgress,
+ getConsumerSubscriptions,
+ listConsumerGroups,
+} from '../../services/consumerService';
+
+// ─── Helpers ────────────────────────────────────────────────────
+type GroupStatus = 'running' | 'warning' | 'stopped';
+
+const BACKLOG_WARNING_THRESHOLD = 10000;
+
+const deriveStatus = (group: ConsumerGroup): GroupStatus => {
+ if (group.onlineInstances <= 0) return 'stopped';
+ if (group.totalLag > BACKLOG_WARNING_THRESHOLD) return 'warning';
+ return 'running';
+};
-// ─── Types ──────────────────────────────────────────────────────
-interface GroupRecord {
- key: string;
- group: string;
- namespace: string;
- cluster: string;
- count: number;
- consumeType: string;
- messageModel: 'CLUSTERING' | 'BROADCASTING';
- diff: number;
- status: 'running' | 'warning' | 'stopped';
-}
-
-const groupData: GroupRecord[] = [];
+const isConsistent = (consistency: string): boolean =>
+ consistency === 'consistent' || consistency === '一致';
// ─── Component ──────────────────────────────────────────────────
const GroupManagementPage = () => {
const [searchText, setSearchText] = useState('');
+ const [modalVisible, setModalVisible] = useState(false);
+ const [selectedGroup, setSelectedGroup] = useState<ConsumerGroup |
null>(null);
const [autoRefresh, setAutoRefresh] = useState(false);
+ const [groups, setGroups] = useState<ConsumerGroup[]>([]);
+ const [loading, setLoading] = useState(true);
+ const [subscriptions, setSubscriptions] = useState<SubscriptionEntry[]>([]);
+ const [progress, setProgress] = useState<QueueProgress[]>([]);
+ const [detailLoading, setDetailLoading] = useState(false);
const { t } = useLang();
+ useEffect(() => {
+ let cancelled = false;
+
+ const fetchGroups = async () => {
+ try {
+ const data = await listConsumerGroups();
+ if (!cancelled) setGroups(data);
+ } catch {
+ if (!cancelled) message.error(t('consumer.fetchListFailed'));
+ } finally {
+ if (!cancelled) setLoading(false);
+ }
+ };
+
+ void fetchGroups();
+ return () => {
+ cancelled = true;
+ };
+ }, [t]);
+
+ const handleRefresh = useCallback(async () => {
+ setLoading(true);
+ try {
+ const data = await listConsumerGroups();
+ setGroups(data);
+ } catch {
+ message.error(t('consumer.fetchListFailed'));
+ } finally {
+ setLoading(false);
+ }
+ }, [t]);
+
+ const handleViewDetail = useCallback(
+ async (group: ConsumerGroup) => {
+ setSelectedGroup(group);
+ setModalVisible(true);
+ setSubscriptions([]);
+ setProgress([]);
+ setDetailLoading(true);
+ try {
+ const [subs, prog] = await Promise.all([
+ getConsumerSubscriptions(group.name),
+ getConsumerProgress(group.name),
+ ]);
+ setSubscriptions(subs);
+ setProgress(prog);
+ } catch {
+ message.error(t('consumer.fetchProgressFailed', { name: group.name }));
+ } finally {
+ setDetailLoading(false);
+ }
+ },
+ [t],
+ );
+
const normalizedSearchText = searchText.trim().toLowerCase();
- const filteredGroupData = groupData.filter(
- (record) => !normalizedSearchText ||
record.group.toLowerCase().includes(normalizedSearchText),
+ const filteredGroupData = useMemo(
+ () =>
+ groups.filter(
+ (record) =>
+ !normalizedSearchText ||
record.name.toLowerCase().includes(normalizedSearchText),
+ ),
+ [groups, normalizedSearchText],
);
const columns = [
{
title: t('groupMgmt.groupName'),
- dataIndex: 'group',
- key: 'group',
- render: (text: string) => (
- <span style={{ color: '#1677ff', fontWeight: 500 }}>
+ dataIndex: 'name',
+ key: 'name',
+ render: (text: string, record: ConsumerGroup) => (
+ <a
+ onClick={() => void handleViewDetail(record)}
+ style={{ color: '#1677ff', fontWeight: 500, whiteSpace: 'nowrap' }}
+ >
{text}
- </span>
+ </a>
),
},
{ title: t('groupMgmt.namespace'), dataIndex: 'namespace', key:
'namespace' },
- { title: t('groupMgmt.cluster'), dataIndex: 'cluster', key: 'cluster' },
+ { title: t('groupMgmt.cluster'), dataIndex: 'clusterId', key: 'clusterId'
},
{
title: t('groupMgmt.onlineInstances'),
- dataIndex: 'count',
- key: 'count',
+ dataIndex: 'onlineInstances',
+ key: 'onlineInstances',
render: (count: number) => <span style={{ fontWeight: 500
}}>{count}</span>,
},
{
title: t('groupMgmt.consumeMode'),
- dataIndex: 'messageModel',
- key: 'messageModel',
+ dataIndex: 'consumeType',
+ key: 'consumeType',
render: (mode: string) => (
<Tag color={mode === 'CLUSTERING' ? 'blue' : 'orange'}>
{mode === 'CLUSTERING' ? t('groupMgmt.clustering') :
t('groupMgmt.broadcasting')}
@@ -85,8 +166,8 @@ const GroupManagementPage = () => {
},
{
title: t('groupMgmt.diff'),
- dataIndex: 'diff',
- key: 'diff',
+ dataIndex: 'totalLag',
+ key: 'totalLag',
render: (diff: number) => (
<span
style={{
@@ -100,27 +181,27 @@ const GroupManagementPage = () => {
},
{
title: t('brokerCluster.status'),
- dataIndex: 'status',
key: 'status',
- render: (status: string) => {
- const config: Record<string, { color: string; label: string }> = {
+ render: (_: unknown, record: ConsumerGroup) => {
+ const status = deriveStatus(record);
+ const config: Record<GroupStatus, { color: string; label: string }> = {
running: { color: 'success', label: t('brokerCluster.statusRunning')
},
warning: { color: 'warning', label: t('groupMgmt.backlogAlert') },
stopped: { color: 'error', label: t('groupMgmt.stopped') },
};
- const { color, label } = config[status] || config.running;
+ const { color, label } = config[status];
return <Tag color={color}>{label}</Tag>;
},
},
{
title: t('common.actions'),
key: 'action',
- render: () => (
+ render: (_: unknown, record: ConsumerGroup) => (
<Space size="small">
- <Button type="link" size="small" disabled>
+ <Button type="link" size="small" onClick={() => void
handleViewDetail(record)}>
{t('common.detail')}
</Button>
- <Button type="link" size="small" disabled>
+ <Button type="link" size="small">
{t('brokerCluster.config')}
</Button>
</Space>
@@ -128,6 +209,45 @@ const GroupManagementPage = () => {
},
];
+ const subscriptionColumns = [
+ {
+ title: t('groupMgmt.topic'),
+ dataIndex: 'topic',
+ key: 'topic',
+ render: (text: string) => <span style={{ fontWeight: 500
}}>{text}</span>,
+ },
+ {
+ title: t('groupMgmt.consistency'),
+ dataIndex: 'consistency',
+ key: 'consistency',
+ render: (consistency: string) => (
+ <Tag color={isConsistent(consistency) ? 'success' : 'warning'}>
+ {isConsistent(consistency) ? t('groupMgmt.consistent') :
t('groupMgmt.inconsistent')}
+ </Tag>
+ ),
+ },
+ { title: t('groupMgmt.subMode'), dataIndex: 'filterMode', key:
'filterMode' },
+ {
+ title: t('groupMgmt.expression'),
+ dataIndex: 'expression',
+ key: 'expression',
+ render: (text: string) => (
+ <code style={{ background: '#f5f5f5', padding: '2px 6px',
borderRadius: 4, fontSize: 12 }}>
+ {text}
+ </code>
+ ),
+ },
+ {
+ title: t('common.actions'),
+ key: 'action',
+ render: () => (
+ <Button type="link" size="small" icon={<Eye size={14} />}>
+ {t('groupMgmt.viewDistribution')}
+ </Button>
+ ),
+ },
+ ];
+
return (
<div style={{ padding: 0 }}>
<div
@@ -169,7 +289,11 @@ const GroupManagementPage = () => {
unCheckedChildren={t('groupMgmt.manual')}
size="small"
/>
- <Button icon={<ArrowClockwise size={14} />} size="small">
+ <Button
+ icon={<ArrowClockwise size={14} />}
+ size="small"
+ onClick={() => void handleRefresh()}
+ >
{t('common.reset')}
</Button>
</Space>
@@ -179,7 +303,8 @@ const GroupManagementPage = () => {
<Table
columns={columns}
dataSource={filteredGroupData}
- locale={{ emptyText: t('groupMgmt.providerUnavailable') }}
+ rowKey="name"
+ loading={loading}
pagination={{
pageSize: 10,
showTotal: (total) => `${t('common.total')} ${total} Group`,
@@ -188,6 +313,181 @@ const GroupManagementPage = () => {
size="middle"
/>
</Card>
+
+ <Modal
+ title={null}
+ open={modalVisible}
+ onCancel={() => setModalVisible(false)}
+ footer={null}
+ width={720}
+ destroyOnClose
+ >
+ <div style={{ marginBottom: 16 }}>
+ <h3 style={{ margin: 0, display: 'flex', alignItems: 'center' }}>
+ <Users size={18} style={{ marginRight: 8, color: '#1677ff' }} />
+ {selectedGroup?.name}
+ </h3>
+ </div>
+ {selectedGroup && (
+ <Tabs
+ defaultActiveKey="overview"
+ items={[
+ {
+ key: 'overview',
+ label: t('groupMgmt.overview'),
+ children: (
+ <div>
+ <Row gutter={16} style={{ marginBottom: 20 }}>
+ <Col span={8}>
+ <Card bordered={false} style={{ background: '#f6ffed'
}}>
+ <div style={{ color: '#666', fontSize: 12 }}>
+ {t('groupMgmt.onlineInstances')}
+ </div>
+ <div style={{ fontSize: 24, fontWeight: 600 }}>
+ {selectedGroup.onlineInstances}{' '}
+ <Tag color="success" style={{ marginLeft: 8 }}>
+ {t('groupMgmt.online')}
+ </Tag>
+ </div>
+ </Card>
+ </Col>
+ <Col span={8}>
+ <Card bordered={false} style={{ background: '#fff2f0'
}}>
+ <div style={{ color: '#666', fontSize: 12 }}>
+ {t('groupMgmt.totalDiff')}
+ </div>
+ <div style={{ fontSize: 24, fontWeight: 600, color:
'#ff4d4f' }}>
+ {selectedGroup.totalLag.toLocaleString()}
+ </div>
+ </Card>
+ </Col>
+ <Col span={8}>
+ <Card bordered={false} style={{ background: '#f0f5ff'
}}>
+ <div style={{ color: '#666', fontSize: 12 }}>
+ {t('groupMgmt.subscribedTopics')}
+ </div>
+ <div style={{ fontSize: 24, fontWeight: 600 }}>
+ {selectedGroup.subscribedTopics.length}
+ </div>
+ </Card>
+ </Col>
+ </Row>
+ <Descriptions column={2} bordered size="small">
+ <Descriptions.Item label={t('groupMgmt.groupName')}>
+ {selectedGroup.name}
+ </Descriptions.Item>
+ <Descriptions.Item label={t('groupMgmt.namespace')}>
+ {selectedGroup.namespace}
+ </Descriptions.Item>
+ <Descriptions.Item label={t('groupMgmt.cluster')}>
+ {selectedGroup.clusterId}
+ </Descriptions.Item>
+ <Descriptions.Item label={t('groupMgmt.consumeMode')}>
+ <Tag color={selectedGroup.consumeType === 'CLUSTERING'
? 'blue' : 'orange'}>
+ {selectedGroup.consumeType === 'CLUSTERING'
+ ? t('groupMgmt.clustering')
+ : t('groupMgmt.broadcasting')}
+ </Tag>
+ </Descriptions.Item>
+ <Descriptions.Item label={t('groupMgmt.consumeType')}>
+ {selectedGroup.subscriptionMode}
+ </Descriptions.Item>
+ <Descriptions.Item label={t('groupMgmt.consumeDelay')}>
+ {selectedGroup.delaySeconds.toLocaleString()}s
+ </Descriptions.Item>
+ <Descriptions.Item label={t('groupMgmt.maxRetry')}>
+ {selectedGroup.retryMaxTimes}
+ </Descriptions.Item>
+ <Descriptions.Item label={t('groupMgmt.createdAt')}>
+ {selectedGroup.createdAt}
+ </Descriptions.Item>
+ <Descriptions.Item
label={t('groupMgmt.subscribedTopics')} span={2}>
+ {selectedGroup.subscribedTopics.join(', ')}
+ </Descriptions.Item>
+ </Descriptions>
+ <h4 style={{ marginTop: 20, marginBottom: 12 }}>
+ {t('groupMgmt.subscription')}
+ </h4>
+ <Table
+ columns={subscriptionColumns}
+ dataSource={subscriptions}
+ rowKey="topic"
+ loading={detailLoading}
+ pagination={false}
+ size="small"
+ />
+ </div>
+ ),
+ },
+ {
+ key: 'instances',
+ label: t('groupMgmt.onlineInstances'),
+ children: (
+ <Table
+ columns={[
+ {
+ title: t('groupMgmt.instanceId'),
+ dataIndex: 'clientId',
+ key: 'clientId',
+ },
+ { title: t('common.address'), dataIndex: 'address', key:
'address' },
+ { title: t('brokerCluster.version'), dataIndex:
'protocol', key: 'protocol' },
+ {
+ title: t('brokerCluster.status'),
+ key: 'status',
+ render: () => <Tag
color="success">{t('groupMgmt.online')}</Tag>,
+ },
+ ]}
+ dataSource={selectedGroup.instances}
+ rowKey="clientId"
+ pagination={false}
+ size="small"
+ />
+ ),
+ },
+ {
+ 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"
+ />
+ ),
+ },
+ ]}
+ />
+ )}
+ </Modal>
</div>
);
};
diff --git a/web/src/pages/studio/__tests__/BrokerCluster.test.tsx
b/web/src/pages/studio/__tests__/BrokerCluster.test.tsx
index 9d580df3..03a5ccd4 100644
--- a/web/src/pages/studio/__tests__/BrokerCluster.test.tsx
+++ b/web/src/pages/studio/__tests__/BrokerCluster.test.tsx
@@ -15,13 +15,19 @@
* limitations under the License.
*/
-import { describe, it, expect, vi, beforeAll } from 'vitest';
-import { render, screen } from '@testing-library/react';
+import { describe, it, expect, vi, beforeAll, beforeEach } from 'vitest';
+import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { App } from 'antd';
import { LangProvider } from '../../../i18n/LangContext';
+import { listClusters } from '../../../services/clusterService';
+import type { ClusterInfo } from '../../../api/cluster';
import BrokerCluster from '../BrokerCluster';
+vi.mock('../../../services/clusterService', () => ({
+ listClusters: vi.fn(),
+}));
+
// Mock matchMedia for antd responsive components
beforeAll(() => {
Object.defineProperty(window, 'matchMedia', {
@@ -45,6 +51,63 @@ vi.mock('react-router-dom', () => ({
useParams: () => ({}),
}));
+const clusterFixture: ClusterInfo[] = [
+ {
+ id: 'cluster-1',
+ name: 'prod-cn-east-1',
+ nsClusterName: 'prod-cn-east-1',
+ type: 'V5_PROXY_CLUSTER',
+ endpoint: '10.0.1.20:9876',
+ status: 'healthy',
+ version: '5.3.0',
+ brokers: [
+ {
+ name: 'broker-a',
+ addr: '10.0.1.10:10911',
+ version: '5.3.0',
+ status: 'running',
+ diskUsage: 62,
+ tpsIn: 12580,
+ tpsOut: 8340,
+ },
+ {
+ name: 'broker-b',
+ addr: '10.0.1.11:10911',
+ version: '5.3.0',
+ status: 'readonly',
+ diskUsage: 89,
+ tpsIn: 0,
+ tpsOut: 3120,
+ },
+ ],
+ proxies: [
+ {
+ addr: '10.0.1.30:8080',
+ status: 'healthy',
+ connections: 2340,
+ grpcPort: 8081,
+ remotingPort: 8080,
+ },
+ ],
+ nameServers: [{ addr: 'nameserver-a', status: 'healthy' }],
+ config: {
+ flushDiskType: 'SYNC_FLUSH',
+ autoCreateTopicEnable: false,
+ autoCreateSubscriptionGroup: false,
+ maxMessageSize: 4194304,
+ msgTraceTopicName: 'RMQ_SYS_TRACE_TOPIC4',
+ fileReservedTime: 72,
+ writeQueueNums: 16,
+ readQueueNums: 16,
+ brokerPermission: 6,
+ deleteWhen: '04',
+ },
+ topicCount: 10,
+ groupCount: 5,
+ tpsHistory: [],
+ },
+];
+
const renderWithProviders = (ui: React.ReactElement) => {
return render(
<App>
@@ -54,6 +117,11 @@ const renderWithProviders = (ui: React.ReactElement) => {
};
describe('BrokerCluster Page', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(listClusters).mockResolvedValue(clusterFixture);
+ });
+
it('should render the page title', () => {
renderWithProviders(<BrokerCluster />);
expect(screen.getByText('Broker 集群')).toBeInTheDocument();
@@ -69,44 +137,58 @@ describe('BrokerCluster Page', () => {
expect(screen.getByText('重置')).toBeInTheDocument();
});
- it('should show an explicit unavailable state instead of mock broker data',
() => {
+ it('should display broker tab with data from the API', async () => {
renderWithProviders(<BrokerCluster />);
- expect(screen.getByText('当前版本尚未接入真实集群拓扑接口,已停止展示模拟 Broker / NameServer /
Proxy 数据。')).toBeInTheDocument();
- expect(screen.queryByText('broker-a')).not.toBeInTheDocument();
- expect(screen.queryByText('broker-b')).not.toBeInTheDocument();
+ // Default tab is broker - data is loaded asynchronously from the service
+ const brokerA = await screen.findAllByText('broker-a');
+ expect(brokerA.length).toBeGreaterThan(0);
+ expect(screen.getAllByText('broker-b').length).toBeGreaterThan(0);
});
- it('should not render row status tags without real broker data', () => {
+ it('should display broker status tags', async () => {
renderWithProviders(<BrokerCluster />);
- expect(screen.queryByText('运行中')).not.toBeInTheDocument();
- expect(screen.queryByText('只读')).not.toBeInTheDocument();
+ await screen.findAllByText('broker-a');
+ const runningTags = screen.getAllByText('运行中');
+ expect(runningTags.length).toBeGreaterThan(0);
+ const readonlyTags = screen.getAllByText('只读');
+ expect(readonlyTags.length).toBeGreaterThan(0);
});
it('should switch to NameServer tab on click', async () => {
const user = userEvent.setup();
renderWithProviders(<BrokerCluster />);
+ await screen.findByText('broker-a');
const nsTab = screen.getByText('NameServer 管理');
await user.click(nsTab);
- expect(
- screen.getAllByText('当前版本尚未接入真实集群拓扑接口,已停止展示模拟 Broker / NameServer /
Proxy 数据。').length,
- ).toBeGreaterThan(0);
- expect(screen.queryByText('nameserver-a')).not.toBeInTheDocument();
+ // After clicking, NameServer data should be visible (name equals address,
so it appears twice)
+ expect(screen.getAllByText('nameserver-a').length).toBeGreaterThan(0);
});
it('should switch to Proxy tab on click', async () => {
const user = userEvent.setup();
renderWithProviders(<BrokerCluster />);
+ await screen.findByText('broker-a');
const proxyTab = screen.getByText('Proxy 管理');
await user.click(proxyTab);
- expect(
- screen.getAllByText('当前版本尚未接入真实集群拓扑接口,已停止展示模拟 Broker / NameServer /
Proxy 数据。').length,
- ).toBeGreaterThan(0);
- expect(screen.queryByText('proxy-a')).not.toBeInTheDocument();
+ // After clicking, Proxy data should be visible (proxy name equals its
address, so it appears twice)
+ expect(screen.getAllByText('10.0.1.30:8080').length).toBeGreaterThan(0);
+ });
+
+ it('should render config and restart action buttons', async () => {
+ renderWithProviders(<BrokerCluster />);
+ await screen.findByText('broker-a');
+ const configButtons = screen.getAllByText('配置');
+ expect(configButtons.length).toBeGreaterThan(0);
+ const restartButtons = screen.getAllByText('重启');
+ expect(restartButtons.length).toBeGreaterThan(0);
});
- it('should not render row action buttons without real infrastructure data',
() => {
+ it('should fall back to mock data when the API fails', async () => {
+ vi.mocked(listClusters).mockRejectedValueOnce(new Error('network error'));
renderWithProviders(<BrokerCluster />);
- expect(screen.queryByText('配置')).not.toBeInTheDocument();
- expect(screen.queryByText('重启')).not.toBeInTheDocument();
+ // Initial state holds the mock fallback rows
+ await waitFor(() => {
+ expect(screen.getByText('broker-a')).toBeInTheDocument();
+ });
});
});
diff --git a/web/src/pages/studio/__tests__/GroupManagement.test.tsx
b/web/src/pages/studio/__tests__/GroupManagement.test.tsx
index 05d48695..8db04717 100644
--- a/web/src/pages/studio/__tests__/GroupManagement.test.tsx
+++ b/web/src/pages/studio/__tests__/GroupManagement.test.tsx
@@ -15,13 +15,21 @@
* limitations under the License.
*/
-import { describe, it, expect, vi, beforeAll } from 'vitest';
-import { render, screen } from '@testing-library/react';
+import { describe, it, expect, vi, beforeAll, beforeEach } from 'vitest';
+import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { App } from 'antd';
import { LangProvider } from '../../../i18n/LangContext';
+import type { ConsumerGroup } from '../../../api/metadata';
+import * as consumerService from '../../../services/consumerService';
import GroupManagement from '../GroupManagement';
+vi.mock('../../../services/consumerService', () => ({
+ listConsumerGroups: vi.fn(),
+ getConsumerProgress: vi.fn(),
+ getConsumerSubscriptions: vi.fn(),
+}));
+
// Mock matchMedia for antd responsive components
beforeAll(() => {
Object.defineProperty(window, 'matchMedia', {
@@ -45,6 +53,29 @@ vi.mock('react-router-dom', () => ({
useParams: () => ({}),
}));
+const makeGroup = (overrides: Partial<ConsumerGroup>): ConsumerGroup => ({
+ name: 'order-consumer-group',
+ namespace: 'default',
+ clusterId: 'cluster-production',
+ subscriptionMode: 'Push',
+ consumeType: 'CLUSTERING',
+ onlineInstances: 4,
+ totalLag: 1280,
+ subscribedTopics: ['ORDER_TOPIC'],
+ subscriptionDataType: 'NORMAL',
+ retryMaxTimes: 16,
+ createdAt: '2025-03-15 10:30:00',
+ updatedAt: '2025-03-15 10:30:00',
+ delaySeconds: 12,
+ instances: [],
+ ...overrides,
+});
+
+const groups: ConsumerGroup[] = [
+ makeGroup({ name: 'order-consumer-group' }),
+ makeGroup({ name: 'payment-consumer-group', totalLag: 0, onlineInstances: 2
}),
+];
+
const renderWithProviders = (ui: React.ReactElement) => {
return render(
<App>
@@ -54,6 +85,12 @@ const renderWithProviders = (ui: React.ReactElement) => {
};
describe('GroupManagement Page', () => {
+ beforeEach(() => {
+ vi.mocked(consumerService.listConsumerGroups).mockResolvedValue(groups);
+ vi.mocked(consumerService.getConsumerProgress).mockResolvedValue([]);
+ vi.mocked(consumerService.getConsumerSubscriptions).mockResolvedValue([]);
+ });
+
it('should render the page title', () => {
renderWithProviders(<GroupManagement />);
expect(screen.getByText('消费组管理')).toBeInTheDocument();
@@ -74,25 +111,32 @@ describe('GroupManagement Page', () => {
expect(screen.getByText('重置')).toBeInTheDocument();
});
- it('should show an explicit unavailable state instead of mock consumer
groups', () => {
+ it('should display consumer group data from the service in table', async ()
=> {
renderWithProviders(<GroupManagement />);
-
expect(screen.getByText('当前版本尚未接入真实消费组管理接口,已停止展示模拟消费组数据。')).toBeInTheDocument();
- expect(screen.queryByText('order-consumer-group')).not.toBeInTheDocument();
-
expect(screen.queryByText('payment-consumer-group')).not.toBeInTheDocument();
+ await waitFor(() => {
+ expect(screen.getByText('order-consumer-group')).toBeInTheDocument();
+ });
+ expect(screen.getByText('payment-consumer-group')).toBeInTheDocument();
});
- it('should not render row actions without real consumer group data', () => {
+ it('should render detail action buttons for each row', async () => {
renderWithProviders(<GroupManagement />);
- expect(screen.queryByText('详情')).not.toBeInTheDocument();
+ await waitFor(() => {
+ expect(screen.getByText('order-consumer-group')).toBeInTheDocument();
+ });
+ const detailButtons = screen.getAllByText('详情');
+ expect(detailButtons.length).toBeGreaterThan(0);
});
- it('should keep mock groups hidden when filtering by search text', async ()
=> {
+ it('should filter groups by search text', async () => {
const user = userEvent.setup();
renderWithProviders(<GroupManagement />);
+ await waitFor(() => {
+ expect(screen.getByText('order-consumer-group')).toBeInTheDocument();
+ });
const searchInput = screen.getByPlaceholderText('搜索消费组');
await user.type(searchInput, 'ORDER');
-
expect(screen.getByText('当前版本尚未接入真实消费组管理接口,已停止展示模拟消费组数据。')).toBeInTheDocument();
- expect(screen.queryByText('order-consumer-group')).not.toBeInTheDocument();
+ expect(screen.getByText('order-consumer-group')).toBeInTheDocument();
expect(screen.queryByText('payment-consumer-group')).not.toBeInTheDocument();
});
});
diff --git a/web/src/services/aclService.test.ts
b/web/src/services/aclService.test.ts
index dea835f5..feaee9e3 100644
--- a/web/src/services/aclService.test.ts
+++ b/web/src/services/aclService.test.ts
@@ -25,9 +25,9 @@ import {
updateAclUser,
} from './aclService';
+vi.mock('./dataMode', () => ({ isMockMode: () => true }));
vi.mock('../config', () => ({
API_BASE_URL: '/api',
- USE_MOCK: true,
}));
describe('ACL service mock data', () => {
diff --git a/web/src/services/aclService.ts b/web/src/services/aclService.ts
index df7361a7..29d56782 100644
--- a/web/src/services/aclService.ts
+++ b/web/src/services/aclService.ts
@@ -1,4 +1,4 @@
-import { USE_MOCK } from '../config';
+import { isMockMode } from './dataMode';
import * as aclApi from '../api/acl';
import type { AclRule, AclRuleQuery, AclUser } from '../api/acl';
import { aclRules as mockRules, aclUsers as mockUsers } from '../mock/acl';
@@ -21,7 +21,7 @@ function copyAclUser(user: AclUser): AclUser {
}
export async function listAclRules(params?: AclRuleQuery): Promise<AclRule[]> {
- if (USE_MOCK) {
+ if (isMockMode()) {
let result = [...aclRulesState];
if (params?.principal) {
const principal = params.principal.toLowerCase();
@@ -33,7 +33,7 @@ export async function listAclRules(params?: AclRuleQuery):
Promise<AclRule[]> {
}
export async function listAclUsers(params?: { keyword?: string }):
Promise<AclUser[]> {
- if (USE_MOCK) {
+ if (isMockMode()) {
let result = [...aclUsersState];
if (params?.keyword) {
const kw = params.keyword.toLowerCase();
@@ -45,7 +45,7 @@ export async function listAclUsers(params?: { keyword?:
string }): Promise<AclUs
}
export async function createAclRule(data: Partial<AclRule>): Promise<AclRule> {
- if (USE_MOCK) {
+ if (isMockMode()) {
const rule: AclRule = {
id: `acl-${Date.now()}`,
principal: '',
@@ -66,7 +66,7 @@ export async function createAclRule(data: Partial<AclRule>):
Promise<AclRule> {
}
export async function updateAclRule(data: Partial<AclRule>): Promise<AclRule> {
- if (USE_MOCK) {
+ if (isMockMode()) {
const idx = aclRulesState.findIndex((rule) => rule.id === data.id);
if (idx < 0) throw new Error(`ACL rule not found: ${data.id}`);
aclRulesState[idx] = {
@@ -80,7 +80,7 @@ export async function updateAclRule(data: Partial<AclRule>):
Promise<AclRule> {
}
export async function deleteAclRule(id: string): Promise<void> {
- if (USE_MOCK) {
+ if (isMockMode()) {
const idx = aclRulesState.findIndex((rule) => rule.id === id);
if (idx >= 0) aclRulesState.splice(idx, 1);
return;
@@ -89,7 +89,7 @@ export async function deleteAclRule(id: string):
Promise<void> {
}
export async function createAclUser(data: Partial<AclUser>): Promise<AclUser> {
- if (USE_MOCK) {
+ if (isMockMode()) {
const user: AclUser = {
id: `user-${Date.now()}`,
username: '',
@@ -107,7 +107,7 @@ export async function createAclUser(data:
Partial<AclUser>): Promise<AclUser> {
}
export async function updateAclUser(data: Partial<AclUser>): Promise<AclUser> {
- if (USE_MOCK) {
+ if (isMockMode()) {
const idx = aclUsersState.findIndex((user) => user.id === data.id);
if (idx < 0) throw new Error(`ACL user not found: ${data.id}`);
aclUsersState[idx] = {
@@ -121,7 +121,7 @@ export async function updateAclUser(data:
Partial<AclUser>): Promise<AclUser> {
}
export async function deleteAclUser(id: string): Promise<void> {
- if (USE_MOCK) {
+ if (isMockMode()) {
const idx = aclUsersState.findIndex((user) => user.id === id);
if (idx >= 0) aclUsersState.splice(idx, 1);
return;
diff --git a/web/src/services/clusterService.test.ts
b/web/src/services/clusterService.test.ts
index 3e153313..555c45f3 100644
--- a/web/src/services/clusterService.test.ts
+++ b/web/src/services/clusterService.test.ts
@@ -17,8 +17,8 @@
import { describe, expect, it, vi } from 'vitest';
+vi.mock('./dataMode', () => ({ isMockMode: () => true }));
vi.mock('../config', () => ({
- USE_MOCK: true,
API_BASE_URL: '/api',
}));
diff --git a/web/src/services/clusterService.ts
b/web/src/services/clusterService.ts
index a0958d7b..de6677dd 100644
--- a/web/src/services/clusterService.ts
+++ b/web/src/services/clusterService.ts
@@ -1,4 +1,4 @@
-import { USE_MOCK } from '../config';
+import { isMockMode } from './dataMode';
import * as clusterApi from '../api/cluster';
import type { ClusterConfig, ClusterInfo, ClusterProbeResult, K8sCertInfo }
from '../api/cluster';
import clusters, { mockK8sCerts } from '../mock/clusters';
@@ -28,14 +28,14 @@ function copyCluster(cluster: ClusterInfo): ClusterInfo {
}
export async function listClusters(): Promise<ClusterInfo[]> {
- if (USE_MOCK) {
+ if (isMockMode()) {
return clusters.map(copyCluster);
}
return clusterApi.listClusters();
}
export async function testClusterConnection(namesrvAddr: string):
Promise<ClusterProbeResult> {
- if (USE_MOCK) {
+ if (isMockMode()) {
const trimmed = namesrvAddr.trim();
const cluster = clusters[0];
const brokerNames = cluster ? cluster.brokers.map((broker) => broker.name)
: [];
@@ -53,7 +53,7 @@ export async function testClusterConnection(namesrvAddr:
string): Promise<Cluste
}
export async function getCluster(id: string): Promise<ClusterInfo> {
- if (USE_MOCK) {
+ if (isMockMode()) {
const cluster = clusters.find((item) => item.id === id);
if (!cluster) throw new Error('Cluster not found');
return copyCluster(cluster);
@@ -62,12 +62,12 @@ export async function getCluster(id: string):
Promise<ClusterInfo> {
}
export async function listK8sCerts(): Promise<K8sCertInfo[]> {
- if (USE_MOCK) return mockCertStore.map((cert) => ({ ...cert, san:
[...cert.san] }));
+ if (isMockMode()) return mockCertStore.map((cert) => ({ ...cert, san:
[...cert.san] }));
return clusterApi.listK8sCerts();
}
export async function createK8sCert(data: Partial<K8sCertInfo>):
Promise<K8sCertInfo> {
- if (USE_MOCK) {
+ if (isMockMode()) {
const now = new Date();
const notAfter = new Date(now);
notAfter.setFullYear(notAfter.getFullYear() + 1);
@@ -91,7 +91,7 @@ export async function createK8sCert(data:
Partial<K8sCertInfo>): Promise<K8sCert
}
export async function updateK8sCert(data: Partial<K8sCertInfo>):
Promise<K8sCertInfo> {
- if (USE_MOCK) {
+ if (isMockMode()) {
const existing = mockCertStore.find((cert) => cert.id === data.id);
if (!existing) throw new Error(`Certificate not found: ${data.id}`);
Object.assign(existing, data, { san: data.san ?? existing.san });
@@ -101,7 +101,7 @@ export async function updateK8sCert(data:
Partial<K8sCertInfo>): Promise<K8sCert
}
export async function renewK8sCert(id: string): Promise<K8sCertInfo> {
- if (USE_MOCK) {
+ if (isMockMode()) {
const existing = mockCertStore.find((cert) => cert.id === id);
if (!existing) throw new Error(`Certificate not found: ${id}`);
const now = new Date();
@@ -119,7 +119,7 @@ export async function renewK8sCert(id: string):
Promise<K8sCertInfo> {
}
export async function deleteK8sCert(id: string): Promise<void> {
- if (USE_MOCK) {
+ if (isMockMode()) {
const index = mockCertStore.findIndex((cert) => cert.id === id);
if (index < 0) throw new Error(`Certificate not found: ${id}`);
mockCertStore.splice(index, 1);
@@ -129,7 +129,7 @@ export async function deleteK8sCert(id: string):
Promise<void> {
}
export async function updateClusterConfig(data: { id: string } &
Partial<ClusterConfig>) {
- if (USE_MOCK) {
+ if (isMockMode()) {
const { id, ...config } = data;
Object.assign(getMockCluster(id).config, config);
return;
@@ -138,7 +138,7 @@ export async function updateClusterConfig(data: { id:
string } & Partial<Cluster
}
export async function restartBroker(clusterId: string, brokerName: string) {
- if (USE_MOCK) return { success: true, message: `Broker ${brokerName}
restarted (mock)` };
+ if (isMockMode()) return { success: true, message: `Broker ${brokerName}
restarted (mock)` };
return clusterApi.restartBroker(clusterId, brokerName);
}
@@ -149,7 +149,7 @@ function getMockCluster(clusterId: string) {
}
export async function restartNameServer(data: { clusterId: string; addr:
string }): Promise<void> {
- if (USE_MOCK) {
+ if (isMockMode()) {
const nameServer = getMockCluster(data.clusterId).nameServers.find(
(item) => item.addr === data.addr,
);
@@ -165,7 +165,7 @@ export async function upgradeNameServer(data: {
addr: string;
version: string;
}): Promise<void> {
- if (USE_MOCK) {
+ if (isMockMode()) {
const exists = getMockCluster(data.clusterId).nameServers.some(
(item) => item.addr === data.addr,
);
@@ -176,7 +176,7 @@ export async function upgradeNameServer(data: {
}
export async function deleteNameServer(data: { clusterId: string; addr: string
}): Promise<void> {
- if (USE_MOCK) {
+ if (isMockMode()) {
const nameServers = getMockCluster(data.clusterId).nameServers;
const index = nameServers.findIndex((item) => item.addr === data.addr);
if (index < 0) throw new Error(`NameServer not found: ${data.addr}`);
@@ -187,7 +187,7 @@ export async function deleteNameServer(data: { clusterId:
string; addr: string }
}
export async function createNameServer(data: { clusterId: string; addr: string
}): Promise<void> {
- if (USE_MOCK) {
+ if (isMockMode()) {
const nameServers = getMockCluster(data.clusterId).nameServers;
if (nameServers.some((item) => item.addr === data.addr)) {
throw new Error(`NameServer already exists: ${data.addr}`);
@@ -203,7 +203,7 @@ export async function updateNameServer(data: {
addr: string;
newAddr?: string;
}): Promise<void> {
- if (USE_MOCK) {
+ if (isMockMode()) {
const nameServer = getMockCluster(data.clusterId).nameServers.find(
(item) => item.addr === data.addr,
);
@@ -215,7 +215,7 @@ export async function updateNameServer(data: {
}
export async function restartProxy(data: { clusterId: string; addr: string }):
Promise<void> {
- if (USE_MOCK) {
+ if (isMockMode()) {
const proxy = getMockCluster(data.clusterId).proxies.find((item) =>
item.addr === data.addr);
if (!proxy) throw new Error(`Proxy not found: ${data.addr}`);
proxy.status = 'healthy';
diff --git a/web/src/services/connectionsService.test.ts
b/web/src/services/connectionsService.test.ts
index 5461a0a0..6c461108 100644
--- a/web/src/services/connectionsService.test.ts
+++ b/web/src/services/connectionsService.test.ts
@@ -17,8 +17,8 @@
import { describe, expect, it, vi } from 'vitest';
+vi.mock('./dataMode', () => ({ isMockMode: () => true }));
vi.mock('../config', () => ({
- USE_MOCK: true,
API_BASE_URL: '/api',
}));
diff --git a/web/src/services/connectionsService.ts
b/web/src/services/connectionsService.ts
index d29b58a0..d4adc728 100644
--- a/web/src/services/connectionsService.ts
+++ b/web/src/services/connectionsService.ts
@@ -1,4 +1,4 @@
-import { USE_MOCK } from '../config';
+import { isMockMode } from './dataMode';
import * as connApi from '../api/connections';
import type { ClientConnection, ClientConnectionQuery } from
'../api/connections';
import { mockClients } from '../mock/clients';
@@ -8,7 +8,7 @@ function copyConnection(connection: ClientConnection):
ClientConnection {
}
export async function listConnections(params?: ClientConnectionQuery):
Promise<ClientConnection[]> {
- if (USE_MOCK) {
+ if (isMockMode()) {
let result = [...mockClients];
if (params?.clusterId)
result = result.filter((connection) => connection.clusterName ===
params.clusterId);
diff --git a/web/src/services/consumerService.test.ts
b/web/src/services/consumerService.test.ts
index 603861d1..d5d637cf 100644
--- a/web/src/services/consumerService.test.ts
+++ b/web/src/services/consumerService.test.ts
@@ -24,9 +24,9 @@ import {
listConsumerGroups,
} from './consumerService';
+vi.mock('./dataMode', () => ({ isMockMode: () => true }));
vi.mock('../config', () => ({
API_BASE_URL: '/api',
- USE_MOCK: true,
}));
describe('consumer service mock data', () => {
diff --git a/web/src/services/consumerService.ts
b/web/src/services/consumerService.ts
index c1859dce..6cfc0678 100644
--- a/web/src/services/consumerService.ts
+++ b/web/src/services/consumerService.ts
@@ -1,4 +1,4 @@
-import { USE_MOCK } from '../config';
+import { isMockMode } from './dataMode';
import * as metadataApi from '../api/metadata';
import type {
ConsumerGroup,
@@ -12,7 +12,9 @@ import { mockConsumerGroups, mockQueueProgress,
mockSubscriptions } from '../moc
const consumerGroupsState = mockConsumerGroups as unknown as ConsumerGroup[];
-function copyConsumerInstance(instance: ConsumerGroup['instances'][number]):
ConsumerGroup['instances'][number] {
+function copyConsumerInstance(
+ instance: ConsumerGroup['instances'][number],
+): ConsumerGroup['instances'][number] {
return {
...instance,
subscribedTopics: [...instance.subscribedTopics],
@@ -37,7 +39,7 @@ function copySubscription(subscription: SubscriptionEntry):
SubscriptionEntry {
}
export async function listConsumerGroups(params?: ConsumerGroupQuery):
Promise<ConsumerGroup[]> {
- if (USE_MOCK) {
+ if (isMockMode()) {
let result = [...consumerGroupsState];
if (params?.clusterId) result = result.filter((group) => group.clusterId
=== params.clusterId);
if (params?.search) {
@@ -50,14 +52,14 @@ export async function listConsumerGroups(params?:
ConsumerGroupQuery): Promise<C
}
export async function getConsumerProgress(name: string):
Promise<QueueProgress[]> {
- if (USE_MOCK) {
+ if (isMockMode()) {
return ((mockQueueProgress[name] as unknown as QueueProgress[]) ??
[]).map(copyQueueProgress);
}
return metadataApi.getConsumerProgress(name);
}
export async function getConsumerGroup(name: string):
Promise<ConsumerGroupDetail> {
- if (USE_MOCK) {
+ if (isMockMode()) {
const group = mockConsumerGroups.find((item) => item.name === name);
if (!group) throw new Error(`Consumer group not found: ${name}`);
return copyConsumerGroup(group as unknown as ConsumerGroupDetail) as
ConsumerGroupDetail;
@@ -66,14 +68,16 @@ export async function getConsumerGroup(name: string):
Promise<ConsumerGroupDetai
}
export async function getConsumerSubscriptions(name: string):
Promise<SubscriptionEntry[]> {
- if (USE_MOCK) {
- return ((mockSubscriptions[name] as unknown as SubscriptionEntry[]) ??
[]).map(copySubscription);
+ if (isMockMode()) {
+ return ((mockSubscriptions[name] as unknown as SubscriptionEntry[]) ??
[]).map(
+ copySubscription,
+ );
}
return metadataApi.getConsumerSubscriptions(name);
}
export async function createConsumerGroup(data: Partial<ConsumerGroup>):
Promise<ConsumerGroup> {
- if (USE_MOCK) {
+ if (isMockMode()) {
const now = new Date().toISOString();
const group = {
name: data.name ?? '',
@@ -98,7 +102,7 @@ export async function createConsumerGroup(data:
Partial<ConsumerGroup>): Promise
}
export async function deleteConsumerGroup(name: string): Promise<void> {
- if (USE_MOCK) {
+ if (isMockMode()) {
const idx = consumerGroupsState.findIndex((group) => group.name === name);
if (idx >= 0) consumerGroupsState.splice(idx, 1);
return;
@@ -107,7 +111,7 @@ export async function deleteConsumerGroup(name: string):
Promise<void> {
}
export async function resetConsumerOffset(data: ResetConsumerOffsetRequest):
Promise<void> {
- if (USE_MOCK) return;
+ if (isMockMode()) return;
return metadataApi.resetConsumerOffset(data);
}
diff --git a/web/src/services/dashboardService.test.ts
b/web/src/services/dashboardService.test.ts
index f2bfc39b..2098a9ba 100644
--- a/web/src/services/dashboardService.test.ts
+++ b/web/src/services/dashboardService.test.ts
@@ -17,8 +17,8 @@
import { describe, expect, it, vi } from 'vitest';
+vi.mock('./dataMode', () => ({ isMockMode: () => true }));
vi.mock('../config', () => ({
- USE_MOCK: true,
API_BASE_URL: '/api',
}));
diff --git a/web/src/services/dashboardService.ts
b/web/src/services/dashboardService.ts
index 0d396eff..2cd749d3 100644
--- a/web/src/services/dashboardService.ts
+++ b/web/src/services/dashboardService.ts
@@ -1,4 +1,4 @@
-import { USE_MOCK } from '../config';
+import { isMockMode } from './dataMode';
import * as metricsApi from '../api/metrics';
import { dashboardStats, clusterOverview } from '../mock/dashboard';
import type { DashboardData } from '../api/metrics';
@@ -11,7 +11,7 @@ function copyClusterOverview(cluster:
DashboardData['clusters'][number]) {
}
export async function getDashboard(): Promise<DashboardData> {
- if (USE_MOCK) {
+ if (isMockMode()) {
return {
stats: { ...dashboardStats },
clusters: (clusterOverview as
DashboardData['clusters']).map(copyClusterOverview),
diff --git a/web/src/services/dataMode.ts b/web/src/services/dataMode.ts
new file mode 100644
index 00000000..0c00d887
--- /dev/null
+++ b/web/src/services/dataMode.ts
@@ -0,0 +1,27 @@
+/*
+ * 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 { useDataModeStore } from '../stores/dataModeStore';
+
+/**
+ * Returns whether the app is currently in mock-data mode.
+ * Reads from the Zustand store (persisted to localStorage) so the value
+ * can be toggled at runtime without a rebuild.
+ */
+export function isMockMode(): boolean {
+ return useDataModeStore.getState().useMock;
+}
diff --git a/web/src/services/instanceService.test.ts
b/web/src/services/instanceService.test.ts
index a8f93118..b003f31d 100644
--- a/web/src/services/instanceService.test.ts
+++ b/web/src/services/instanceService.test.ts
@@ -17,8 +17,8 @@
import { describe, expect, it, vi } from 'vitest';
+vi.mock('./dataMode', () => ({ isMockMode: () => true }));
vi.mock('../config', () => ({
- USE_MOCK: true,
API_BASE_URL: '/api',
}));
diff --git a/web/src/services/instanceService.ts
b/web/src/services/instanceService.ts
index 0b6564c4..d162c160 100644
--- a/web/src/services/instanceService.ts
+++ b/web/src/services/instanceService.ts
@@ -1,4 +1,4 @@
-import { USE_MOCK } from '../config';
+import { isMockMode } from './dataMode';
import * as instanceApi from '../api/instance';
import type {
Instance,
@@ -9,14 +9,13 @@ import type {
import { mockInstances } from '../mock/instances';
// Compile-time switch: mock or real API
-// Vite replaces USE_MOCK with a literal at build time → tree-shaking removes
unused branch
function copyInstance(instance: Instance): Instance {
return { ...instance };
}
export async function listInstances(query: InstanceQuery = {}):
Promise<Instance[]> {
- if (USE_MOCK) {
+ if (isMockMode()) {
const search = query.search?.trim().toLowerCase();
return mockInstances
.filter((instance) => !query.type || instance.type === query.type)
@@ -33,7 +32,7 @@ export async function listInstances(query: InstanceQuery =
{}): Promise<Instance
}
export async function createInstance(data: CreateInstanceRequest):
Promise<Instance> {
- if (USE_MOCK) {
+ if (isMockMode()) {
const instance: Instance = {
id: String(Date.now()),
...data,
@@ -50,7 +49,7 @@ export async function createInstance(data:
CreateInstanceRequest): Promise<Insta
}
export async function updateInstance(data: UpdateInstanceRequest):
Promise<Instance> {
- if (USE_MOCK) {
+ if (isMockMode()) {
const idx = mockInstances.findIndex((i) => i.id === data.id);
if (idx >= 0) {
Object.assign(mockInstances[idx], data, {
@@ -64,7 +63,7 @@ export async function updateInstance(data:
UpdateInstanceRequest): Promise<Insta
}
export async function deleteInstance(id: string): Promise<void> {
- if (USE_MOCK) {
+ if (isMockMode()) {
const idx = mockInstances.findIndex((i) => i.id === id);
if (idx >= 0) mockInstances.splice(idx, 1);
return;
diff --git a/web/src/services/messageService.test.ts
b/web/src/services/messageService.test.ts
index 42b0c926..3e010f01 100644
--- a/web/src/services/messageService.test.ts
+++ b/web/src/services/messageService.test.ts
@@ -18,9 +18,9 @@
import { describe, expect, it, vi } from 'vitest';
import { getMessageTrace, listDLQGroups, queryMessages } from
'./messageService';
+vi.mock('./dataMode', () => ({ isMockMode: () => true }));
vi.mock('../config', () => ({
API_BASE_URL: '/api',
- USE_MOCK: true,
}));
describe('message service mock data', () => {
diff --git a/web/src/services/messageService.ts
b/web/src/services/messageService.ts
index 750afc21..44d74c27 100644
--- a/web/src/services/messageService.ts
+++ b/web/src/services/messageService.ts
@@ -1,4 +1,4 @@
-import { USE_MOCK } from '../config';
+import { isMockMode } from './dataMode';
import * as messageApi from '../api/message';
import { sortMessagesByStoreTimeDesc } from '../api/message';
import type { MessageQuery, MessageRecord, TraceRecord, DLQGroup } from
'../api/message';
@@ -18,7 +18,7 @@ const cloneTrace = (trace: TraceRecord): TraceRecord => ({
const cloneDLQGroup = (group: DLQGroup): DLQGroup => ({ ...group });
export async function queryMessages(params: MessageQuery):
Promise<MessageRecord[]> {
- if (USE_MOCK) {
+ if (isMockMode()) {
let result = [...mockMessages];
if (params.topic) result = result.filter((m) => m.topic === params.topic);
if (params.tag) result = result.filter((m) => m.tag === params.tag);
@@ -30,7 +30,7 @@ export async function queryMessages(params: MessageQuery):
Promise<MessageRecord
}
export async function getMessageTrace(msgId: string): Promise<TraceRecord |
null> {
- if (USE_MOCK) {
+ if (isMockMode()) {
const trace = mockMessageTraces[msgId] as unknown as TraceRecord |
undefined;
return trace ? cloneTrace(trace) : null;
}
@@ -38,7 +38,7 @@ export async function getMessageTrace(msgId: string):
Promise<TraceRecord | null
}
export async function listDLQGroups(): Promise<DLQGroup[]> {
- if (USE_MOCK) return (mockDLQGroups as unknown as
DLQGroup[]).map(cloneDLQGroup);
+ if (isMockMode()) return (mockDLQGroups as unknown as
DLQGroup[]).map(cloneDLQGroup);
return messageApi.listDLQGroups();
}
@@ -48,6 +48,6 @@ export async function resendDLQ(data: {
endTime: number;
targetTopic?: string;
}): Promise<void> {
- if (USE_MOCK) return;
+ if (isMockMode()) return;
return messageApi.resendDLQ(data);
}
diff --git a/web/src/services/opsService.test.ts
b/web/src/services/opsService.test.ts
index 2571a12b..ae02a2dc 100644
--- a/web/src/services/opsService.test.ts
+++ b/web/src/services/opsService.test.ts
@@ -28,9 +28,9 @@ import {
updateAlertRule,
} from './opsService';
+vi.mock('./dataMode', () => ({ isMockMode: () => true }));
vi.mock('../config', () => ({
API_BASE_URL: '/api',
- USE_MOCK: true,
}));
describe('ops service mock data', () => {
diff --git a/web/src/services/opsService.ts b/web/src/services/opsService.ts
index 9750fc1b..b27819ad 100644
--- a/web/src/services/opsService.ts
+++ b/web/src/services/opsService.ts
@@ -1,6 +1,6 @@
-import { USE_MOCK } from '../config';
import { exportAuditLogs as exportAuditLogsApi } from '../api/audit';
import type { AuditFilter } from '../api/audit';
+import { isMockMode } from './dataMode';
import * as opsApi from '../api/ops';
import type { AlertRule, SystemAlert, AuditQuery, AuditRecord, PageResult }
from '../api/ops';
import { mockAlertRules } from '../mock/alerts';
@@ -72,12 +72,12 @@ function formatAuditCsv(records: AuditRecord[]): string {
}
export async function listAlertRules(): Promise<AlertRule[]> {
- if (USE_MOCK) return alertRulesState.map(copyAlertRule);
+ if (isMockMode()) return alertRulesState.map(copyAlertRule);
return opsApi.listAlertRules();
}
export async function createAlertRule(data: Partial<AlertRule>):
Promise<AlertRule> {
- if (USE_MOCK) {
+ if (isMockMode()) {
const rule: AlertRule = {
id: `alert-${Date.now()}`,
name: '',
@@ -99,7 +99,7 @@ export async function createAlertRule(data:
Partial<AlertRule>): Promise<AlertRu
}
export async function updateAlertRule(data: AlertRule): Promise<AlertRule> {
- if (USE_MOCK) {
+ if (isMockMode()) {
const index = alertRulesState.findIndex((rule) => rule.id === data.id);
const rule = copyAlertRule(data);
if (index >= 0) alertRulesState[index] = rule;
@@ -109,7 +109,7 @@ export async function updateAlertRule(data: AlertRule):
Promise<AlertRule> {
}
export async function toggleAlertRule(id: string, enabled: boolean):
Promise<AlertRule> {
- if (USE_MOCK) {
+ if (isMockMode()) {
const rule = alertRulesState.find((item) => item.id === id);
if (!rule) throw new Error(`Alert rule not found: ${id}`);
rule.enabled = enabled;
@@ -119,7 +119,7 @@ export async function toggleAlertRule(id: string, enabled:
boolean): Promise<Ale
}
export async function deleteAlertRule(id: string): Promise<void> {
- if (USE_MOCK) {
+ if (isMockMode()) {
const idx = alertRulesState.findIndex((rule) => rule.id === id);
if (idx >= 0) alertRulesState.splice(idx, 1);
return;
@@ -128,12 +128,12 @@ export async function deleteAlertRule(id: string):
Promise<void> {
}
export async function listSystemAlerts(): Promise<SystemAlert[]> {
- if (USE_MOCK) return (mockSystemAlerts as unknown as
SystemAlert[]).map(copySystemAlert);
+ if (isMockMode()) return (mockSystemAlerts as unknown as
SystemAlert[]).map(copySystemAlert);
return opsApi.listSystemAlerts();
}
export async function acknowledgeAlert(id: string): Promise<void> {
- if (USE_MOCK) {
+ if (isMockMode()) {
const a = mockSystemAlerts.find((a: Record<string, unknown>) => a.id ===
id);
if (a) (a as Record<string, unknown>).acknowledged = true;
return;
@@ -142,7 +142,7 @@ export async function acknowledgeAlert(id: string):
Promise<void> {
}
export async function clearAcknowledgedAlerts(): Promise<number> {
- if (USE_MOCK) {
+ if (isMockMode()) {
const acknowledged = mockSystemAlerts.filter((alert) =>
alert.acknowledged).length;
const remaining = mockSystemAlerts.filter((alert) => !alert.acknowledged);
mockSystemAlerts.splice(0, mockSystemAlerts.length, ...remaining);
@@ -153,7 +153,7 @@ export async function clearAcknowledgedAlerts():
Promise<number> {
}
export async function listAuditRecords(params: AuditQuery = {}):
Promise<PageResult<AuditRecord>> {
- if (!USE_MOCK) return opsApi.listAuditRecords(params);
+ if (!isMockMode()) return opsApi.listAuditRecords(params);
const page = params.page ?? 1;
const pageSize = params.pageSize ?? 20;
@@ -168,12 +168,12 @@ export async function listAuditRecords(params: AuditQuery
= {}): Promise<PageRes
}
export async function exportAuditLogs(params: AuditFilter = {}):
Promise<string> {
- if (!USE_MOCK) return exportAuditLogsApi(params);
+ if (!isMockMode()) return exportAuditLogsApi(params);
return formatAuditCsv(filterAuditRecords(params));
}
export async function cleanupAuditLogs(beforeDays: number): Promise<number> {
- if (USE_MOCK) {
+ if (isMockMode()) {
const cutoff = new Date(Date.now() - beforeDays * 24 * 60 * 60 * 1000);
const remaining = auditRecordsState.filter((record) => new
Date(record.timestamp) >= cutoff);
const deleted = auditRecordsState.length - remaining.length;
diff --git a/web/src/services/topicService.test.ts
b/web/src/services/topicService.test.ts
index ff832977..04580f07 100644
--- a/web/src/services/topicService.test.ts
+++ b/web/src/services/topicService.test.ts
@@ -17,9 +17,9 @@
import { getTopicConsumers, getTopicRoutes, listTopics } from './topicService';
+vi.mock('./dataMode', () => ({ isMockMode: () => true }));
vi.mock('../config', () => ({
API_BASE_URL: '/api',
- USE_MOCK: true,
}));
describe('topic service mock data', () => {
diff --git a/web/src/services/topicService.ts b/web/src/services/topicService.ts
index 5db5ac48..ca1e945a 100644
--- a/web/src/services/topicService.ts
+++ b/web/src/services/topicService.ts
@@ -1,4 +1,4 @@
-import { USE_MOCK } from '../config';
+import { isMockMode } from './dataMode';
import * as metadataApi from '../api/metadata';
import type {
Topic,
@@ -16,7 +16,7 @@ const cloneConsumers = (consumers: ConsumerGroupInfo[]):
ConsumerGroupInfo[] =>
consumers.map((consumer) => ({ ...consumer }));
export async function listTopics(params?: TopicQuery): Promise<Topic[]> {
- if (USE_MOCK) {
+ if (isMockMode()) {
let result = [...mockTopics];
if (params?.search) {
const keyword = params.search.trim().toLowerCase();
@@ -30,7 +30,7 @@ export async function listTopics(params?: TopicQuery):
Promise<Topic[]> {
}
export async function createTopic(data: Partial<Topic>): Promise<Topic> {
- if (USE_MOCK) {
+ if (isMockMode()) {
const topic = {
...data,
createdAt: new Date().toISOString(),
@@ -46,7 +46,7 @@ export async function createTopic(data: Partial<Topic>):
Promise<Topic> {
}
export async function updateTopic(data: Partial<Topic>): Promise<Topic> {
- if (USE_MOCK) {
+ if (isMockMode()) {
const idx = mockTopics.findIndex((t) => t.name === data.name);
if (idx < 0) throw new Error(`Topic not found: ${data.name}`);
Object.assign(mockTopics[idx], data, { updatedAt: new Date().toISOString()
});
@@ -56,7 +56,7 @@ export async function updateTopic(data: Partial<Topic>):
Promise<Topic> {
}
export async function deleteTopic(name: string): Promise<void> {
- if (USE_MOCK) {
+ if (isMockMode()) {
const idx = mockTopics.findIndex((t) => t.name === name);
if (idx >= 0) mockTopics.splice(idx, 1);
return;
@@ -84,12 +84,12 @@ export async function batchDeleteTopics(names: string[]):
Promise<BatchDeleteTop
}
export async function getTopicRoutes(name: string): Promise<BrokerRoute[]> {
- if (USE_MOCK) return cloneRoutes((topicRoutes[name] as unknown as
BrokerRoute[]) ?? []);
+ if (isMockMode()) return cloneRoutes((topicRoutes[name] as unknown as
BrokerRoute[]) ?? []);
return metadataApi.getTopicRoutes(name);
}
export async function getTopicConsumers(name: string):
Promise<ConsumerGroupInfo[]> {
- if (USE_MOCK)
+ if (isMockMode())
return cloneConsumers((topicConsumers[name] as unknown as
ConsumerGroupInfo[]) ?? []);
return metadataApi.getTopicConsumers(name);
}
@@ -97,7 +97,7 @@ export async function getTopicConsumers(name: string):
Promise<ConsumerGroupInfo
export async function sendTopicMessage(
data: SendTopicMessageRequest,
): Promise<SendTopicMessageResult> {
- if (USE_MOCK) {
+ if (isMockMode()) {
// Simulate a short delay
await new Promise((r) => setTimeout(r, 400));
return {
diff --git a/web/src/stores/dataModeStore.ts b/web/src/stores/dataModeStore.ts
new file mode 100644
index 00000000..195c40be
--- /dev/null
+++ b/web/src/stores/dataModeStore.ts
@@ -0,0 +1,36 @@
+/*
+ * 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 { create } from 'zustand';
+import { persist } from 'zustand/middleware';
+
+interface DataModeState {
+ useMock: boolean;
+ toggle: () => void;
+}
+
+export const useDataModeStore = create<DataModeState>()(
+ persist(
+ (set) => ({
+ useMock: false,
+ toggle: () => set((state) => ({ useMock: !state.useMock })),
+ }),
+ {
+ name: 'rocketmq-studio-data-mode',
+ },
+ ),
+);