You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@rocketmq.apache.org by li...@apache.org on 2026/07/08 08:06:35 UTC
(rocketmq-dashboard) 03/09: feat: implement frontend pages and API services
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
commit 6670064d2e84a3a0c8a44688f21ef807f3d44a54
Author: lizhimins <70...@qq.com>
AuthorDate: Fri Jul 3 16:52:00 2026 +0800
feat: implement frontend pages and API services
- Add all page components (dashboard, cluster, instance, ops, settings, ai)
- Implement API client layer with axios
- Add mock data for development
- Create service layer for business logic
- Add Zustand state management stores
- Implement i18n support (Chinese/English)
Co-Authored-By: Claude <no...@anthropic.com>
---
web/src/api/acl.ts | 55 ++
web/src/api/ai.ts | 87 +++
web/src/api/auth.ts | 40 +
web/src/api/client.ts | 57 ++
web/src/api/cluster.ts | 171 +++++
web/src/api/connections.ts | 23 +
web/src/api/instance.ts | 66 ++
web/src/api/message.ts | 80 ++
web/src/api/metadata.ts | 172 +++++
web/src/api/metrics.ts | 58 ++
web/src/api/ops.ts | 86 +++
web/src/api/settings.ts | 74 ++
web/src/components/MiniBar.tsx | 56 ++
web/src/components/MiniLine.tsx | 74 ++
web/src/components/PageHeader.tsx | 45 ++
web/src/components/ResultEmpty.tsx | 29 +
web/src/components/StatusBadge.tsx | 40 +
web/src/i18n/LangContext.tsx | 49 ++
web/src/i18n/translations.ts | 343 +++++++++
web/src/mock/acl.ts | 273 +++++++
web/src/mock/alerts.ts | 125 ++++
web/src/mock/audit.ts | 201 +++++
web/src/mock/clients.ts | 255 +++++++
web/src/mock/clusters.ts | 405 ++++++++++
web/src/mock/consumers.ts | 1070 +++++++++++++++++++++++++++
web/src/mock/dashboard.ts | 155 ++++
web/src/mock/dlq.ts | 92 +++
web/src/mock/instances.ts | 76 ++
web/src/mock/messages.ts | 1268 ++++++++++++++++++++++++++++++++
web/src/mock/topics.ts | 434 +++++++++++
web/src/pages/ai/index.tsx | 630 ++++++++++++++++
web/src/pages/cluster/certs.tsx | 298 ++++++++
web/src/pages/cluster/clients.tsx | 252 +++++++
web/src/pages/cluster/index.tsx | 860 ++++++++++++++++++++++
web/src/pages/home/dashboard.tsx | 207 ++++++
web/src/pages/home/index.tsx | 415 +++++++++++
web/src/pages/instance/acl.tsx | 754 +++++++++++++++++++
web/src/pages/instance/consumer.tsx | 1062 ++++++++++++++++++++++++++
web/src/pages/instance/dlq.tsx | 311 ++++++++
web/src/pages/instance/index.tsx | 439 +++++++++++
web/src/pages/instance/message.tsx | 553 ++++++++++++++
web/src/pages/instance/topic.tsx | 914 +++++++++++++++++++++++
web/src/pages/ops/alerts.tsx | 292 ++++++++
web/src/pages/ops/audit.tsx | 237 ++++++
web/src/pages/ops/systemAlerts.tsx | 162 ++++
web/src/pages/settings/index.tsx | 415 +++++++++++
web/src/services/aclService.ts | 72 ++
web/src/services/clusterService.ts | 47 ++
web/src/services/connectionsService.ts | 26 +
web/src/services/consumerService.ts | 47 ++
web/src/services/dashboardService.ts | 14 +
web/src/services/instanceService.ts | 52 ++
web/src/services/messageService.ts | 38 +
web/src/services/opsService.ts | 59 ++
web/src/services/topicService.ts | 94 +++
web/src/stores/authStore.ts | 40 +
web/src/stores/clusterStore.ts | 39 +
web/src/utils/format.ts | 26 +
58 files changed, 14314 insertions(+)
diff --git a/web/src/api/acl.ts b/web/src/api/acl.ts
new file mode 100644
index 0000000..3bd8cd9
--- /dev/null
+++ b/web/src/api/acl.ts
@@ -0,0 +1,55 @@
+import client from './client';
+
+// Matches mock/acl.ts
+export interface AclRule {
+ id: string;
+ principal: string;
+ resource: string;
+ resourceType: string;
+ resourcePattern: string;
+ actions: string[];
+ decision: string;
+ scope: string;
+ aclVersion: string;
+ createdAt: string;
+}
+
+export interface AclUser {
+ id: string;
+ username: string;
+ accessKey: string;
+ secretKey: string;
+ admin: boolean;
+ clusters: string[];
+ createdAt: string;
+}
+
+export async function listAclRules(params?: {
+ keyword?: string;
+ version?: string;
+ decision?: string;
+}) {
+ const res = await client.get<{ data: AclRule[] }>('/acl/rules', { params });
+ return res.data.data;
+}
+
+export async function createAclRule(data: Partial<AclRule>) {
+ await client.post('/acl/rules/create', data);
+}
+
+export async function deleteAclRule(id: string) {
+ await client.post('/acl/rules/delete', { id });
+}
+
+export async function listAclUsers(params?: { keyword?: string }) {
+ const res = await client.get<{ data: AclUser[] }>('/acl/users', { params });
+ return res.data.data;
+}
+
+export async function createAclUser(data: Partial<AclUser>) {
+ await client.post('/acl/users/create', data);
+}
+
+export async function deleteAclUser(id: string) {
+ await client.post('/acl/users/delete', { id });
+}
diff --git a/web/src/api/ai.ts b/web/src/api/ai.ts
new file mode 100644
index 0000000..f38bb02
--- /dev/null
+++ b/web/src/api/ai.ts
@@ -0,0 +1,87 @@
+/*
+ * 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 client from './client';
+
+// ─── Types ──────────────────────────────────────────────────────
+export interface McpTool {
+ name: string;
+ description: string;
+ parameters: Record<string, unknown>;
+}
+
+export interface AiExecuteRequest {
+ message: string;
+ mode: string;
+ model: string;
+ tools?: string[];
+}
+
+// ─── AI ─────────────────────────────────────────────────────────
+export async function chatStream(
+ data: AiExecuteRequest,
+ onChunk: (text: string) => void,
+ signal?: AbortSignal,
+) {
+ const response = await fetch('/api/ai/chat', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${localStorage.getItem('token') || ''}`,
+ },
+ body: JSON.stringify(data),
+ signal,
+ });
+
+ if (!response.ok || !response.body) {
+ throw new Error(`AI chat failed: ${response.statusText}`);
+ }
+
+ const reader = response.body.getReader();
+ const decoder = new TextDecoder();
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ const chunk = decoder.decode(value, { stream: true });
+ // SSE format: "data: ..."
+ const lines = chunk.split('\n').filter((l) => l.startsWith('data: '));
+ for (const line of lines) {
+ const payload = line.slice(6);
+ if (payload === '[DONE]') return;
+ try {
+ const parsed = JSON.parse(payload);
+ if (parsed.content) onChunk(parsed.content);
+ } catch {
+ // raw text chunk
+ onChunk(payload);
+ }
+ }
+ }
+}
+
+export async function executeAiCommand(data: AiExecuteRequest) {
+ const res = await client.post<{ data: { result: string; toolCalls: unknown[] } }>(
+ '/ai/execute',
+ data,
+ );
+ return res.data.data;
+}
+
+export async function listTools() {
+ const res = await client.get<{ data: McpTool[] }>('/ai/tools');
+ return res.data.data;
+}
diff --git a/web/src/api/auth.ts b/web/src/api/auth.ts
new file mode 100644
index 0000000..5d1530d
--- /dev/null
+++ b/web/src/api/auth.ts
@@ -0,0 +1,40 @@
+/*
+ * 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 client from './client';
+
+// ─── Types ──────────────────────────────────────────────────────
+export interface LoginRequest {
+ username: string;
+ password: string;
+}
+
+export interface LoginResponse {
+ token: string;
+ username: string;
+ role: string;
+}
+
+// ─── Auth ───────────────────────────────────────────────────────
+export async function login(username: string, password: string) {
+ const res = await client.post<{ data: LoginResponse }>('/auth/login', { username, password });
+ return res.data.data;
+}
+
+export async function logout() {
+ await client.post('/auth/logout');
+}
diff --git a/web/src/api/client.ts b/web/src/api/client.ts
new file mode 100644
index 0000000..bb341ad
--- /dev/null
+++ b/web/src/api/client.ts
@@ -0,0 +1,57 @@
+/*
+ * 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 axios from 'axios';
+import { message } from 'antd';
+
+const client = axios.create({
+ baseURL: '/api',
+ timeout: 30000,
+});
+
+// Request interceptor: attach Authorization header
+client.interceptors.request.use(
+ (config) => {
+ const token = localStorage.getItem('token');
+ if (token) {
+ config.headers.Authorization = `Bearer ${token}`;
+ }
+ return config;
+ },
+ (error) => Promise.reject(error),
+);
+
+// Response interceptor: check business code and handle 401
+client.interceptors.response.use(
+ (response) => {
+ const data = response.data;
+ if (data.code !== undefined && data.code !== 0) {
+ message.error(data.message || '请求失败');
+ return Promise.reject(new Error(data.message || '请求失败'));
+ }
+ return response;
+ },
+ (error) => {
+ if (error.response?.status === 401) {
+ localStorage.removeItem('token');
+ window.location.href = '/';
+ }
+ return Promise.reject(error);
+ },
+);
+
+export default client;
diff --git a/web/src/api/cluster.ts b/web/src/api/cluster.ts
new file mode 100644
index 0000000..c7d35ef
--- /dev/null
+++ b/web/src/api/cluster.ts
@@ -0,0 +1,171 @@
+/*
+ * 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 client from './client';
+
+// ─── Types ──────────────────────────────────────────────────────
+export interface ClusterInfo {
+ id: string;
+ name: string;
+ type: string;
+ status: string;
+ version: string;
+ brokers: number;
+ proxies: number;
+ topics: number;
+ groups: number;
+ tpsIn: number;
+ tpsOut: number;
+}
+
+export interface ClusterDetail extends ClusterInfo {
+ brokerList: BrokerInfo[];
+ proxyList: ProxyInfo[];
+ nameServerList: NameServerInfo[];
+ config: ClusterConfig;
+}
+
+export interface BrokerInfo {
+ addr: string;
+ brokerName: string;
+ brokerId: number;
+ clusterName: string;
+ status: string;
+ tpsIn: number;
+ tpsOut: number;
+ diskUsage: number;
+ version: string;
+}
+
+export interface ProxyInfo {
+ addr: string;
+ clusterName: string;
+ status: string;
+ connections: number;
+ grpcPort: number;
+ remotingPort: number;
+}
+
+export interface NameServerInfo {
+ addr: string;
+ clusterName: string;
+ status: string;
+ version: string;
+}
+
+export interface ClusterConfig {
+ flushDiskType: string;
+ autoCreateTopicEnable: boolean;
+ autoCreateSubscriptionGroup: boolean;
+ maxMessageSize: number;
+ fileReservedTime: number;
+ writeQueueNums: number;
+ readQueueNums: number;
+ brokerPermission: number;
+}
+
+export interface K8sCertInfo {
+ id: string;
+ name: string;
+ namespace: string;
+ cluster: string;
+ type: string;
+ issuer: string;
+ notBefore: string;
+ notAfter: string;
+ status: string;
+ daysRemaining: number;
+ san: string[];
+}
+
+// ─── Cluster ────────────────────────────────────────────────────
+export async function listClusters() {
+ const res = await client.get<{ data: ClusterInfo[] }>('/clusters');
+ return res.data.data;
+}
+
+export async function getCluster(id: string) {
+ const res = await client.get<{ data: ClusterDetail }>(`/clusters/${id}`);
+ return res.data.data;
+}
+
+export async function updateClusterConfig(data: { id: string } & Partial<ClusterConfig>) {
+ await client.post('/clusters/config/update', data);
+}
+
+export async function restartBroker(clusterId: string, brokerName: string) {
+ const res = await client.post<{ data: { success: boolean; message: string } }>(
+ `/clusters/${clusterId}/brokers/${brokerName}/restart`,
+ );
+ return res.data.data;
+}
+
+// ─── NameServer ─────────────────────────────────────────────────
+export async function restartNameServer(data: { clusterId: string; addr: string }) {
+ await client.post('/nameservers/restart', data);
+}
+
+export async function upgradeNameServer(data: {
+ clusterId: string;
+ addr: string;
+ version: string;
+}) {
+ await client.post('/nameservers/upgrade', data);
+}
+
+export async function deleteNameServer(data: { clusterId: string; addr: string }) {
+ await client.post('/nameservers/delete', data);
+}
+
+export async function createNameServer(data: { clusterId: string; addr: string }) {
+ await client.post('/nameservers/create', data);
+}
+
+export async function updateNameServer(data: {
+ clusterId: string;
+ addr: string;
+ newAddr?: string;
+}) {
+ await client.post('/nameservers/update', data);
+}
+
+// ─── Proxy ──────────────────────────────────────────────────────
+export async function restartProxy(data: { clusterId: string; addr: string }) {
+ await client.post('/proxies/restart', data);
+}
+
+// ─── K8s Certs ──────────────────────────────────────────────────
+export async function listK8sCerts() {
+ const res = await client.get<{ data: K8sCertInfo[] }>('/k8s-certs');
+ return res.data.data;
+}
+
+export async function createK8sCert(data: Partial<K8sCertInfo>) {
+ await client.post('/k8s-certs/create', data);
+}
+
+export async function updateK8sCert(data: Partial<K8sCertInfo>) {
+ await client.post('/k8s-certs/update', data);
+}
+
+export async function renewK8sCert(id: string) {
+ await client.post('/k8s-certs/renew', { id });
+}
+
+export async function deleteK8sCert(id: string) {
+ await client.post('/k8s-certs/delete', { id });
+}
diff --git a/web/src/api/connections.ts b/web/src/api/connections.ts
new file mode 100644
index 0000000..901afad
--- /dev/null
+++ b/web/src/api/connections.ts
@@ -0,0 +1,23 @@
+import client from './client';
+
+// Matches mock/clients.ts
+export interface ClientConnection {
+ clientId: string;
+ type: string;
+ groupOrTopic: string;
+ protocol: string;
+ address: string;
+ language: string;
+ version: string;
+ connectedAt: string;
+ clusterName: string;
+}
+
+export async function listConnections(params?: {
+ keyword?: string;
+ type?: string;
+ language?: string;
+}) {
+ const res = await client.get<{ data: ClientConnection[] }>('/clients', { params });
+ return res.data.data;
+}
diff --git a/web/src/api/instance.ts b/web/src/api/instance.ts
new file mode 100644
index 0000000..30dfe81
--- /dev/null
+++ b/web/src/api/instance.ts
@@ -0,0 +1,66 @@
+/*
+ * 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 client from './client';
+
+// ─── Types ──────────────────────────────────────────────────────
+export interface Instance {
+ id: string;
+ name: string;
+ remark: string;
+ type: 'PROXY' | 'DIRECT';
+ endpoint: string;
+ topicCount: number;
+ consumerGroupCount: number;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface CreateInstanceRequest {
+ name: string;
+ type: 'PROXY' | 'DIRECT';
+ endpoint: string;
+ remark?: string;
+}
+
+export interface UpdateInstanceRequest {
+ id: string;
+ name?: string;
+ type?: 'PROXY' | 'DIRECT';
+ endpoint?: string;
+ remark?: string;
+}
+
+// ─── Instance CRUD ──────────────────────────────────────────────
+export async function listInstances() {
+ const res = await client.get<{ data: Instance[] }>('/instances');
+ return res.data.data;
+}
+
+export async function createInstance(data: CreateInstanceRequest) {
+ const res = await client.post<{ data: Instance }>('/instances/create', data);
+ return res.data.data;
+}
+
+export async function updateInstance(data: UpdateInstanceRequest) {
+ const res = await client.post<{ data: Instance }>('/instances/update', data);
+ return res.data.data;
+}
+
+export async function deleteInstance(id: string) {
+ await client.post('/instances/delete', { id });
+}
diff --git a/web/src/api/message.ts b/web/src/api/message.ts
new file mode 100644
index 0000000..25fdc6a
--- /dev/null
+++ b/web/src/api/message.ts
@@ -0,0 +1,80 @@
+import client from './client';
+
+// Matches mock/messages.ts
+export interface MessageRecord {
+ msgId: string;
+ topic: string;
+ tag: string;
+ key: string;
+ body: string;
+ storeTime: string;
+ bornHost: string;
+ storeHost: string;
+ properties: Record<string, string>;
+ size: number;
+}
+
+export interface TraceNode {
+ title: string;
+ timestamp: string;
+ costTime: number;
+ host: string;
+ status: string;
+ detail?: string;
+}
+
+export interface ConsumerStatus {
+ group: string;
+ clientAddr: string;
+ status: string;
+ consumeTps: number;
+ lastConsumedTime: string;
+}
+
+export interface TraceRecord {
+ nodes: TraceNode[];
+ consumerStatus: ConsumerStatus[];
+}
+
+// Matches mock/dlq.ts
+export interface DLQGroup {
+ groupName: string;
+ dlqTopic: string;
+ messageCount: number;
+ lastEnqueueTime: string;
+ retryCount: number;
+ status: string;
+}
+
+// ─── Messages ───────────────────────────────────────────────────
+export async function queryMessages(params: {
+ mode: string;
+ topic?: string;
+ key?: string;
+ msgId?: string;
+ startTime?: string;
+ endTime?: string;
+}) {
+ const res = await client.get<{ data: MessageRecord[] }>('/messages', { params });
+ return res.data.data;
+}
+
+export async function getMessageTrace(msgId: string) {
+ const res = await client.get<{ data: TraceRecord }>(`/messages/${msgId}/trace`);
+ return res.data.data;
+}
+
+// ─── DLQ ────────────────────────────────────────────────────────
+export async function listDLQGroups() {
+ const res = await client.get<{ data: DLQGroup[] }>('/dlq');
+ return res.data.data;
+}
+
+export async function resendDLQ(data: {
+ groupName: string;
+ startTime: string;
+ endTime: string;
+ targetTopic?: string;
+}) {
+ await client.post('/dlq/resend', data);
+}
diff --git a/web/src/api/metadata.ts b/web/src/api/metadata.ts
new file mode 100644
index 0000000..4c882b1
--- /dev/null
+++ b/web/src/api/metadata.ts
@@ -0,0 +1,172 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.
+ */
+
+import client from './client';
+
+// ─── Topic (matches mock/topics.ts) ────────────────────────────
+export interface Topic {
+ name: string;
+ namespace: string;
+ type: string;
+ clusterId: string;
+ writeQueues: number;
+ readQueues: number;
+ perm: string;
+ messageCount: number;
+ tps: number;
+ consumerGroupCount: number;
+ remark: string;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface BrokerRoute {
+ brokerName: string;
+ brokerAddr: string;
+ writeQueues: number;
+ readQueues: number;
+ perm: string;
+}
+
+export interface ConsumerGroupInfo {
+ group: string;
+ consumeType: string;
+ messageModel: string;
+ consumeTps: number;
+ diffTotal: number;
+}
+
+// ─── Consumer Group (matches mock/consumers.ts) ─────────────────
+export interface ConsumerGroup {
+ name: string;
+ namespace: string;
+ clusterId: string;
+ subscriptionMode: string;
+ consumeType: string;
+ onlineInstances: number;
+ totalLag: number;
+ subscribedTopics: string[];
+ subscriptionDataType: string;
+ deliveryOrderType?: string;
+ retryMaxTimes: number;
+ createdAt: string;
+}
+
+export interface ConsumerInstance {
+ clientId: string;
+ clientAddr: string;
+ language: string;
+ version: string;
+}
+
+export interface QueueProgress {
+ broker: string;
+ queueId: number;
+ brokerOffset: number;
+ consumerOffset: number;
+ diffTotal: number;
+}
+
+export interface SubscriptionEntry {
+ topic: string;
+ expression: string;
+ type: string;
+ filterMode: string;
+ consistency: string;
+}
+
+// ─── Topic API ──────────────────────────────────────────────────
+export async function listTopics(params?: { keyword?: string; type?: string; namespace?: string }) {
+ const res = await client.get<{ data: Topic[] }>('/topics', { params });
+ return res.data.data;
+}
+
+export async function createTopic(data: Partial<Topic>) {
+ const res = await client.post<{ data: Topic }>('/topics/create', data);
+ return res.data.data;
+}
+
+export async function updateTopic(data: Partial<Topic>) {
+ await client.post('/topics/update', data);
+}
+
+export async function deleteTopic(name: string) {
+ await client.post('/topics/delete', { name });
+}
+
+export async function getTopicRoutes(name: string) {
+ const res = await client.get<{ data: BrokerRoute[] }>(`/topics/${name}/routes`);
+ return res.data.data;
+}
+
+export async function getTopicConsumers(name: string) {
+ const res = await client.get<{ data: ConsumerGroupInfo[] }>(`/topics/${name}/consumers`);
+ return res.data.data;
+}
+
+export interface SendTopicMessageRequest {
+ topic: string;
+ tag?: string;
+ key?: string;
+ body: string;
+ properties?: Record<string, string>;
+}
+
+export interface SendTopicMessageResult {
+ msgId: string;
+ sendTime: string;
+ offsetMsgId: string;
+}
+
+export async function sendTopicMessage(data: SendTopicMessageRequest) {
+ const res = await client.post<{ data: SendTopicMessageResult }>('/topics/send', data);
+ return res.data.data;
+}
+
+// ─── Consumer Group API ─────────────────────────────────────────
+export async function listConsumerGroups(params?: { keyword?: string }) {
+ const res = await client.get<{ data: ConsumerGroup[] }>('/groups', { params });
+ return res.data.data;
+}
+
+export async function getConsumerGroup(name: string) {
+ const res = await client.get<{ data: ConsumerGroup & { instances: ConsumerInstance[] } }>(
+ `/groups/${name}`,
+ );
+ return res.data.data;
+}
+
+export async function getConsumerProgress(name: string) {
+ const res = await client.get<{ data: QueueProgress[] }>(`/groups/${name}/progress`);
+ return res.data.data;
+}
+
+export async function getConsumerSubscriptions(name: string) {
+ const res = await client.get<{ data: SubscriptionEntry[] }>(`/groups/${name}/subscriptions`);
+ return res.data.data;
+}
+
+export async function createConsumerGroup(data: Partial<ConsumerGroup>) {
+ await client.post('/groups/create', data);
+}
+
+export async function deleteConsumerGroup(name: string) {
+ await client.post('/groups/delete', { name });
+}
+
+export async function resetConsumerOffset(data: Record<string, unknown>) {
+ await client.post('/groups/reset-offset', data);
+}
+
+export async function importConsumerGroups(data: string) {
+ await client.post('/groups/import', { data });
+}
+
+export async function exportConsumerGroups(names?: string[]) {
+ const res = await client.get<{ data: string }>('/groups/export', {
+ params: { names: names?.join(',') },
+ });
+ return res.data.data;
+}
diff --git a/web/src/api/metrics.ts b/web/src/api/metrics.ts
new file mode 100644
index 0000000..ddc0cc1
--- /dev/null
+++ b/web/src/api/metrics.ts
@@ -0,0 +1,58 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.
+ */
+
+import client from './client';
+
+// ─── Types ──────────────────────────────────────────────────────
+export interface DashboardStats {
+ totalClusters: number;
+ healthyClusters: number;
+ totalBrokers: number;
+ totalProxies: number;
+ totalNameServers: number;
+ totalTopics: number;
+ totalConsumerGroups: number;
+ totalMessagesToday: number;
+ messagesPerSecond: number;
+ tpsIn: number;
+ tpsOut: number;
+}
+
+export interface ClusterOverview {
+ id: string;
+ name: string;
+ type: string;
+ status: string;
+ brokers: number;
+ proxies: number;
+ topics: number;
+ groups: number;
+ tpsIn: number;
+ tpsOut: number;
+ version: string;
+ throughput: number[];
+}
+
+export interface DashboardData {
+ stats: DashboardStats;
+ clusters: ClusterOverview[];
+}
+
+// ─── Dashboard ──────────────────────────────────────────────────
+export async function getDashboard() {
+ const res = await client.get<{ data: DashboardData }>('/dashboard');
+ return res.data.data;
+}
+
+// ─── Metrics ────────────────────────────────────────────────────
+export async function queryMetrics(query: {
+ metric: string;
+ start: number;
+ end: number;
+ step?: string;
+}) {
+ const res = await client.post<{ data: unknown }>('/metrics/query', query);
+ return res.data.data;
+}
diff --git a/web/src/api/ops.ts b/web/src/api/ops.ts
new file mode 100644
index 0000000..8f19996
--- /dev/null
+++ b/web/src/api/ops.ts
@@ -0,0 +1,86 @@
+import client from './client';
+
+// Matches mock/alerts.ts (inferred from data)
+export interface AlertRule {
+ id: string;
+ name: string;
+ metric: string;
+ operator: string;
+ threshold: number;
+ thresholdUnit: string;
+ duration: string;
+ channels: string[];
+ enabled: boolean;
+ lastTriggered: string | null;
+ description: string;
+}
+
+// Matches mock/dashboard.ts systemAlerts
+export interface SystemAlert {
+ id: string;
+ level: string;
+ title: string;
+ description: string;
+ time: string;
+ acknowledged: boolean;
+}
+
+// Matches mock/audit.ts (inferred from data)
+export interface AuditRecord {
+ id: string;
+ timestamp: string;
+ operator: string;
+ operationType: string;
+ target: string;
+ detail: string;
+ ipAddress: string;
+ result: string;
+}
+
+// ─── Alert Rules ────────────────────────────────────────────────
+export async function listAlertRules() {
+ const res = await client.get<{ data: AlertRule[] }>('/alert-rules');
+ return res.data.data;
+}
+
+export async function createAlertRule(data: Partial<AlertRule>) {
+ await client.post('/alert-rules/create', data);
+}
+
+export async function updateAlertRule(data: Partial<AlertRule>) {
+ await client.post('/alert-rules/update', data);
+}
+
+export async function toggleAlertRule(id: string, enabled: boolean) {
+ await client.post('/alert-rules/toggle', { id, enabled });
+}
+
+export async function deleteAlertRule(id: string) {
+ await client.post('/alert-rules/delete', { id });
+}
+
+// ─── System Alerts ──────────────────────────────────────────────
+export async function listSystemAlerts() {
+ const res = await client.get<{ data: SystemAlert[] }>('/system-alerts');
+ return res.data.data;
+}
+
+export async function acknowledgeAlert(id: string) {
+ await client.post('/system-alerts/acknowledge', { id });
+}
+
+export async function clearAcknowledgedAlerts() {
+ await client.post('/system-alerts/clear-acknowledged');
+}
+
+// ─── Audit Logs ─────────────────────────────────────────────────
+export async function listAuditRecords(params?: Record<string, unknown>) {
+ const res = await client.get<{ data: { list: AuditRecord[]; total: number } }>('/audit-logs', {
+ params,
+ });
+ return res.data.data;
+}
+
+export async function cleanupAuditLogs(beforeDays: number) {
+ await client.post('/audit-logs/cleanup', { beforeDays });
+}
diff --git a/web/src/api/settings.ts b/web/src/api/settings.ts
new file mode 100644
index 0000000..8f32b23
--- /dev/null
+++ b/web/src/api/settings.ts
@@ -0,0 +1,74 @@
+/*
+ * 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 client from './client';
+
+// ─── Types ──────────────────────────────────────────────────────
+export interface GeneralSettings {
+ siteName: string;
+ language: string;
+ theme: string;
+ autoRefreshInterval: number;
+ notificationChannels: string[];
+ llmProvider: string;
+ llmModel: string;
+}
+
+export interface DataSource {
+ id: string;
+ name: string;
+ type: string;
+ url: string;
+ isDefault: boolean;
+ status: string;
+}
+
+// ─── General Settings ───────────────────────────────────────────
+export async function getGeneralSettings() {
+ const res = await client.get<{ data: GeneralSettings }>('/settings/general');
+ return res.data.data;
+}
+
+export async function saveGeneralSettings(data: Partial<GeneralSettings>) {
+ await client.post('/settings/general/save', data);
+}
+
+// ─── Data Sources ───────────────────────────────────────────────
+export async function listDataSources() {
+ const res = await client.get<{ data: DataSource[] }>('/settings/datasources');
+ return res.data.data;
+}
+
+export async function createDataSource(data: Partial<DataSource>) {
+ await client.post('/settings/datasources/create', data);
+}
+
+export async function updateDataSource(data: Partial<DataSource>) {
+ await client.post('/settings/datasources/update', data);
+}
+
+export async function deleteDataSource(id: string) {
+ await client.post('/settings/datasources/delete', { id });
+}
+
+export async function testDataSource(data: { type: string; url: string }) {
+ const res = await client.post<{ data: { success: boolean; message: string } }>(
+ '/settings/datasources/test',
+ data,
+ );
+ return res.data.data;
+}
diff --git a/web/src/components/MiniBar.tsx b/web/src/components/MiniBar.tsx
new file mode 100644
index 0000000..abddcf3
--- /dev/null
+++ b/web/src/components/MiniBar.tsx
@@ -0,0 +1,56 @@
+/*
+ * 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.
+ */
+
+interface MiniBarProps {
+ data: number[];
+ color?: string;
+ height?: number;
+ width?: number;
+}
+
+const MiniBar = ({ data, color = '#1677ff', height = 32, width = 120 }: MiniBarProps) => {
+ const max = Math.max(...data, 1);
+ const barWidth = Math.max(2, (width - (data.length - 1) * 2) / data.length);
+
+ return (
+ <div
+ style={{
+ display: 'inline-flex',
+ alignItems: 'flex-end',
+ gap: 2,
+ height,
+ width,
+ }}
+ >
+ {data.map((value, i) => (
+ <div
+ key={i}
+ style={{
+ width: barWidth,
+ height: `${Math.max(4, (value / max) * height)}px`,
+ backgroundColor: color,
+ borderRadius: 2,
+ opacity: 0.3 + (i / data.length) * 0.7,
+ transition: 'height 0.3s ease',
+ }}
+ />
+ ))}
+ </div>
+ );
+};
+
+export default MiniBar;
diff --git a/web/src/components/MiniLine.tsx b/web/src/components/MiniLine.tsx
new file mode 100644
index 0000000..8a483ba
--- /dev/null
+++ b/web/src/components/MiniLine.tsx
@@ -0,0 +1,74 @@
+/*
+ * 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.
+ */
+
+interface MiniLineProps {
+ data: number[];
+ color?: string;
+ height?: number;
+ width?: number;
+ fill?: boolean;
+}
+
+const MiniLine = ({
+ data,
+ color = '#1677ff',
+ height = 32,
+ width = 120,
+ fill = true,
+}: MiniLineProps) => {
+ if (data.length < 2) return null;
+
+ const max = Math.max(...data, 1);
+ const min = Math.min(...data, 0);
+ const range = max - min || 1;
+
+ const padding = 2;
+ const innerW = width - padding * 2;
+ const innerH = height - padding * 2;
+
+ const points = data.map((v, i) => {
+ const x = padding + (i / (data.length - 1)) * innerW;
+ const y = padding + innerH - ((v - min) / range) * innerH;
+ return `${x},${y}`;
+ });
+
+ const linePath = `M${points.join(' L')}`;
+ const areaPath = `${linePath} L${padding + innerW},${height - padding} L${padding},${height - padding} Z`;
+
+ return (
+ <svg width={width} height={height} style={{ display: 'block' }}>
+ {fill && <path d={areaPath} fill={color} opacity={0.1} />}
+ <path
+ d={linePath}
+ fill="none"
+ stroke={color}
+ strokeWidth={1.5}
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ />
+ {/* Last point dot */}
+ <circle
+ cx={padding + innerW}
+ cy={padding + innerH - ((data[data.length - 1] - min) / range) * innerH}
+ r={2.5}
+ fill={color}
+ />
+ </svg>
+ );
+};
+
+export default MiniLine;
diff --git a/web/src/components/PageHeader.tsx b/web/src/components/PageHeader.tsx
new file mode 100644
index 0000000..b34d8bc
--- /dev/null
+++ b/web/src/components/PageHeader.tsx
@@ -0,0 +1,45 @@
+/*
+ * 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 { Flex, Typography } from 'antd';
+import type { ReactNode } from 'react';
+
+const { Title, Text } = Typography;
+
+interface PageHeaderProps {
+ title: string;
+ subtitle?: ReactNode;
+ extra?: ReactNode;
+}
+
+const PageHeader = ({ title, subtitle, extra }: PageHeaderProps) => (
+ <Flex justify="space-between" align="center" style={{ marginBottom: 24 }}>
+ <Flex align="center" gap={12}>
+ <Title level={4} style={{ margin: 0, fontWeight: 600 }}>
+ {title}
+ </Title>
+ {subtitle && (
+ <Text type="secondary" style={{ fontSize: 13 }}>
+ {subtitle}
+ </Text>
+ )}
+ </Flex>
+ {extra && <Flex gap={8}>{extra}</Flex>}
+ </Flex>
+);
+
+export default PageHeader;
diff --git a/web/src/components/ResultEmpty.tsx b/web/src/components/ResultEmpty.tsx
new file mode 100644
index 0000000..4cd7557
--- /dev/null
+++ b/web/src/components/ResultEmpty.tsx
@@ -0,0 +1,29 @@
+/*
+ * 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 { Result } from 'antd';
+
+interface ResultEmptyProps {
+ title?: string;
+ subTitle?: string;
+}
+
+const ResultEmpty = ({ title = '暂无数据', subTitle }: ResultEmptyProps) => (
+ <Result status="info" title={title} subTitle={subTitle} />
+);
+
+export default ResultEmpty;
diff --git a/web/src/components/StatusBadge.tsx b/web/src/components/StatusBadge.tsx
new file mode 100644
index 0000000..83922f3
--- /dev/null
+++ b/web/src/components/StatusBadge.tsx
@@ -0,0 +1,40 @@
+/*
+ * 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 { Badge, Space, Typography } from 'antd';
+import { STATUS_MAP } from '../constants/theme';
+
+const { Text } = Typography;
+
+interface StatusBadgeProps {
+ status: keyof typeof STATUS_MAP;
+ text?: string;
+ showDot?: boolean;
+}
+
+const StatusBadge = ({ status, text, showDot = true }: StatusBadgeProps) => {
+ const config = STATUS_MAP[status] || STATUS_MAP.offline;
+ const label = text || config.label;
+ return (
+ <Space size={4}>
+ {showDot && <Badge color={config.dot} />}
+ <Text style={{ color: config.color, fontSize: 13 }}>{label}</Text>
+ </Space>
+ );
+};
+
+export default StatusBadge;
diff --git a/web/src/i18n/LangContext.tsx b/web/src/i18n/LangContext.tsx
new file mode 100644
index 0000000..7667156
--- /dev/null
+++ b/web/src/i18n/LangContext.tsx
@@ -0,0 +1,49 @@
+/*
+ * 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 { createContext, useContext, useState, type ReactNode } from 'react';
+import translations, { type Lang } from './translations';
+
+interface LangContextType {
+ lang: Lang;
+ setLang: (lang: Lang) => void;
+ t: (key: string, params?: Record<string, string | number>) => string;
+}
+
+const LangContext = createContext<LangContextType>({
+ lang: 'zh',
+ setLang: () => {},
+ t: (key) => key,
+});
+
+export const LangProvider = ({ children }: { children: ReactNode }) => {
+ const [lang, setLang] = useState<Lang>('zh');
+
+ const t = (key: string, params?: Record<string, string | number>): string => {
+ let text = translations[key]?.[lang] ?? key;
+ if (params) {
+ Object.entries(params).forEach(([k, v]) => {
+ text = text.replace(`{${k}}`, String(v));
+ });
+ }
+ return text;
+ };
+
+ return <LangContext.Provider value={{ lang, setLang, t }}>{children}</LangContext.Provider>;
+};
+
+export const useLang = () => useContext(LangContext);
diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts
new file mode 100644
index 0000000..1916eb0
--- /dev/null
+++ b/web/src/i18n/translations.ts
@@ -0,0 +1,343 @@
+/*
+ * 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.
+ */
+
+export type Lang = 'zh' | 'en';
+
+const translations: Record<string, Record<Lang, string>> = {
+ // ─── Navigation ───
+ 'nav.home': { zh: '首页', en: 'Home' },
+ 'nav.dashboard': { zh: '监控面板', en: 'Dashboard' },
+ 'nav.instance': { zh: '实例管理', en: 'Instance' },
+ 'nav.instanceList': { zh: '实例列表', en: 'Instance List' },
+ 'nav.topic': { zh: 'Topic 管理', en: 'Topic Management' },
+ 'nav.group': { zh: 'Group 管理', en: 'Group Management' },
+ 'nav.acl': { zh: 'ACL 管理', en: 'ACL Management' },
+ 'nav.message': { zh: '消息查询', en: 'Message Search' },
+ 'nav.dlq': { zh: '死信队列', en: 'Dead Letter Queue' },
+ 'nav.clusterOps': { zh: '集群运维', en: 'Cluster & Ops' },
+ 'nav.certs': { zh: 'K8s 证书管理', en: 'K8s Certificates' },
+ 'nav.rocketmqCluster': { zh: 'RocketMQ 集群', en: 'RocketMQ Cluster' },
+ 'nav.clients': { zh: '客户端连接', en: 'Client Connections' },
+ 'nav.alertEvents': { zh: '告警事件', en: 'Alert Events' },
+ 'nav.alertRules': { zh: '告警规则', en: 'Alert Rules' },
+ 'nav.audit': { zh: '审计日志', en: 'Audit Log' },
+ 'nav.ai': { zh: 'AI 交互', en: 'AI Chat' },
+ 'nav.settings': { zh: '设置', en: 'Settings' },
+
+ // ─── Common ───
+ 'common.search': { zh: '搜索...', en: 'Search...' },
+ 'common.searchPlaceholder': { zh: '搜索页面、功能...', en: 'Search pages, features...' },
+ 'common.create': { zh: '新建', en: 'Create' },
+ 'common.edit': { zh: '编辑', en: 'Edit' },
+ 'common.delete': { zh: '删除', en: 'Delete' },
+ 'common.confirm': { zh: '确认', en: 'Confirm' },
+ 'common.cancel': { zh: '取消', en: 'Cancel' },
+ 'common.save': { zh: '保存', en: 'Save' },
+ 'common.reset': { zh: '重置', en: 'Reset' },
+ 'common.viewAll': { zh: '查看全部', en: 'View All' },
+ 'common.total': { zh: '共', en: 'Total' },
+ 'common.enabled': { zh: '已启用', en: 'Enabled' },
+ 'common.disabled': { zh: '已禁用', en: 'Disabled' },
+ 'common.status': { zh: '状态', en: 'Status' },
+ 'common.name': { zh: '名称', en: 'Name' },
+ 'common.type': { zh: '类型', en: 'Type' },
+ 'common.version': { zh: '版本', en: 'Version' },
+ 'common.address': { zh: '地址', en: 'Address' },
+ 'common.actions': { zh: '操作', en: 'Actions' },
+ 'common.success': { zh: '成功', en: 'Success' },
+ 'common.failure': { zh: '失败', en: 'Failure' },
+ 'common.all': { zh: '全部', en: 'All' },
+ 'common.import': { zh: '导入', en: 'Import' },
+ 'common.export': { zh: '导出', en: 'Export' },
+ 'common.detail': { zh: '详情', en: 'Detail' },
+ 'common.close': { zh: '关闭', en: 'Close' },
+ 'common.noData': { zh: '暂无数据', en: 'No Data' },
+ 'common.autoRefresh': { zh: '自动刷新', en: 'Auto Refresh' },
+ 'common.liveRefresh': { zh: '实时刷新', en: 'Live Refresh' },
+ 'common.yes': { zh: '是', en: 'Yes' },
+ 'common.no': { zh: '否', en: 'No' },
+
+ // ─── Dashboard ───
+ 'dashboard.title': { zh: '监控面板', en: 'Dashboard' },
+ 'dashboard.subtitle': { zh: 'RocketMQ 集群运行概览', en: 'RocketMQ Cluster Overview' },
+ 'dashboard.clusters': { zh: '集群数', en: 'Clusters' },
+ 'dashboard.topics': { zh: 'Topic 数', en: 'Topics' },
+ 'dashboard.tpsIn': { zh: 'TPS In', en: 'TPS In' },
+ 'dashboard.tpsOut': { zh: 'TPS Out', en: 'TPS Out' },
+ 'dashboard.todayMessages': { zh: '今日消息', en: "Today's Messages" },
+ 'dashboard.clusterHealth': { zh: '集群健康概览', en: 'Cluster Health' },
+ 'dashboard.clusterName': { zh: '集群名称', en: 'Cluster Name' },
+ 'dashboard.broker': { zh: 'Broker', en: 'Broker' },
+ 'dashboard.proxy': { zh: 'Proxy', en: 'Proxy' },
+ 'dashboard.topic': { zh: 'Topic', en: 'Topic' },
+ 'dashboard.group': { zh: 'Group', en: 'Group' },
+ 'dashboard.trend': { zh: '趋势', en: 'Trend' },
+ 'dashboard.millionMessages': { zh: '约 {n}M 条消息', en: '~{n}M messages' },
+
+ // ─── Cluster Page ───
+ 'cluster.title': { zh: 'RocketMQ 集群', en: 'RocketMQ Cluster' },
+ 'cluster.nameserver': { zh: 'NameServer 管理', en: 'NameServer' },
+ 'cluster.broker': { zh: 'Broker 管理', en: 'Broker' },
+ 'cluster.proxy': { zh: 'Proxy 管理', en: 'Proxy' },
+ 'cluster.k8sName': { zh: 'K8s 集群名称', en: 'K8s Cluster' },
+ 'cluster.brokerName': { zh: 'Broker 名称', en: 'Broker Name' },
+ 'cluster.diskUsage': { zh: '磁盘使用', en: 'Disk Usage' },
+ 'cluster.proxyAddr': { zh: 'Proxy 地址', en: 'Proxy Address' },
+ 'cluster.connections': { zh: '连接数', en: 'Connections' },
+ 'cluster.grpcPort': { zh: 'gRPC 端口', en: 'gRPC Port' },
+ 'cluster.remotingPort': { zh: 'Remoting 端口', en: 'Remoting Port' },
+ 'cluster.config': { zh: '配置', en: 'Config' },
+ 'cluster.restart': { zh: '重启', en: 'Restart' },
+ 'cluster.upgrade': { zh: '升级', en: 'Upgrade' },
+ 'cluster.createCluster': { zh: '新建集群', en: 'New Cluster' },
+ 'cluster.createNameServer': { zh: '新建 NameServer', en: 'New NameServer' },
+ 'cluster.editNameServer': { zh: '编辑 NameServer', en: 'Edit NameServer' },
+ 'cluster.nsAddr': { zh: 'NameServer 地址', en: 'NameServer Address' },
+ 'cluster.nsAddrPlaceholder': { zh: '例:10.0.1.1:9876', en: 'e.g. 10.0.1.1:9876' },
+ 'cluster.newAddr': { zh: '新地址', en: 'New Address' },
+ 'cluster.newAddrPlaceholder': { zh: '不填则不修改', en: 'Leave empty to keep unchanged' },
+ 'cluster.selectCluster': { zh: '选择集群', en: 'Select Cluster' },
+ 'cluster.nsCreated': { zh: 'NameServer 已创建', en: 'NameServer created' },
+ 'cluster.nsUpdated': { zh: 'NameServer 已更新', en: 'NameServer updated' },
+ 'cluster.nsClusterName': { zh: 'NameServer 集群名称', en: 'NS Cluster Name' },
+ 'cluster.count': { zh: '数量', en: 'Count' },
+ 'cluster.searchBroker': { zh: '搜索 Broker 名称或地址', en: 'Search Broker name or address' },
+ 'cluster.searchProxy': { zh: '搜索 Proxy 地址', en: 'Search Proxy address' },
+ 'cluster.searchNs': { zh: '搜索地址', en: 'Search address' },
+ 'cluster.running': { zh: '运行中', en: 'Running' },
+ 'cluster.readonly': { zh: '只读', en: 'Read Only' },
+ 'cluster.maintenance': { zh: '维护', en: 'Maintenance' },
+ 'cluster.healthy': { zh: '运行中', en: 'Healthy' },
+ 'cluster.warning': { zh: '告警', en: 'Warning' },
+ 'cluster.error': { zh: '异常', en: 'Error' },
+ 'cluster.offline': { zh: '离线', en: 'Offline' },
+ 'cluster.heartbeat': { zh: '心跳时间', en: 'Heartbeat' },
+ 'cluster.restartNotSupported': {
+ zh: '暂不支持在线重启,请通过运维平台操作',
+ en: 'Online restart not supported, please use ops platform',
+ },
+ 'cluster.confirmRestart': { zh: '确认重启', en: 'Confirm Restart' },
+
+ // ─── Instance / Topic / Group / ACL / Message ───
+ 'instance.title': { zh: '实例列表', en: 'Instance List' },
+ 'instance.subtitle': { zh: '管理 RocketMQ 集群连接', en: 'Manage RocketMQ cluster connections' },
+ 'instance.count': { zh: '共 {n} 个实例', en: '{n} instances' },
+ 'instance.searchPlaceholder': { zh: '搜索实例名称或地址', en: 'Search name or endpoint' },
+ 'instance.allTypes': { zh: '全部架构', en: 'All Types' },
+ 'instance.proxyMode': { zh: 'Proxy 模式', en: 'Proxy Mode' },
+ 'instance.directMode': { zh: 'Direct 模式', en: 'Direct Mode' },
+ 'instance.addInstance': { zh: '添加实例', en: 'Add Instance' },
+ 'instance.instanceName': { zh: '实例名称', en: 'Instance Name' },
+ 'instance.namePlaceholder': { zh: '例:rocketmq-production', en: 'e.g. rocketmq-production' },
+ 'instance.accessType': { zh: '接入方式', en: 'Access Type' },
+ 'instance.selectAccessType': { zh: '选择接入方式', en: 'Select access type' },
+ 'instance.endpoint': { zh: '接入地址', en: 'Endpoint' },
+ 'instance.endpointPlaceholder': {
+ zh: '例:proxy.example.com:8080',
+ en: 'e.g. proxy.example.com:8080',
+ },
+ 'instance.remark': { zh: '备注', en: 'Remark' },
+ 'instance.connect': { zh: '连接', en: 'Connect' },
+ 'instance.cancel': { zh: '取消', en: 'Cancel' },
+ 'instance.edit': { zh: '编辑', en: 'Edit' },
+ 'instance.delete': { zh: '删除', en: 'Delete' },
+ 'instance.confirmDelete': { zh: '确认删除 "{name}"?', en: 'Delete "{name}"?' },
+ 'instance.deleteWarning': { zh: '此操作不可恢复。', en: 'This cannot be undone.' },
+ 'instance.deleted': { zh: '已删除', en: 'Deleted' },
+ 'instance.added': { zh: '实例添加成功', en: 'Instance added' },
+ 'instance.enter': { zh: '进入 {name}', en: 'Enter {name}' },
+ 'instance.createdAt': { zh: '创建时间', en: 'Created' },
+ 'instance.updatedAt': { zh: '修改时间', en: 'Updated' },
+ 'topic.title': { zh: 'Topic 管理', en: 'Topic Management' },
+ 'group.title': { zh: 'Group 管理', en: 'Group Management' },
+ 'acl.title': { zh: 'ACL 管理', en: 'ACL Management' },
+ 'message.title': { zh: '消息查询', en: 'Message Search' },
+
+ // ─── Dead Letter Queue ───
+ 'dlq.title': { zh: '死信队列', en: 'Dead Letter Queue' },
+
+ // ─── Client Connections ───
+ 'clients.title': { zh: '客户端连接', en: 'Client Connections' },
+ 'clients.clientId': { zh: 'Client ID', en: 'Client ID' },
+ 'clients.groupOrTopic': { zh: 'Group/Topic', en: 'Group/Topic' },
+ 'clients.protocol': { zh: '协议', en: 'Protocol' },
+ 'clients.language': { zh: '语言', en: 'Language' },
+ 'clients.cluster': { zh: '所属集群', en: 'Cluster' },
+ 'clients.allClusters': { zh: '全部集群', en: 'All Clusters' },
+ 'clients.searchPlaceholder': { zh: '搜索 Client ID 或地址', en: 'Search Client ID or address' },
+
+ // ─── Alert Rules ───
+ 'alerts.title': { zh: '告警规则管理', en: 'Alert Rules' },
+ 'alerts.subtitle': {
+ zh: '配置集群监控告警规则与通知渠道',
+ en: 'Configure cluster monitoring alert rules and notification channels',
+ },
+ 'alerts.totalRules': { zh: '规则总数', en: 'Total Rules' },
+ 'alerts.enabled': { zh: '已启用', en: 'Enabled' },
+ 'alerts.triggered24h': { zh: '24h 触发', en: 'Triggered (24h)' },
+ 'alerts.newRule': { zh: '新建规则', en: 'New Rule' },
+ 'alerts.ruleName': { zh: '规则名称', en: 'Rule Name' },
+ 'alerts.metric': { zh: '监控指标', en: 'Metric' },
+ 'alerts.threshold': { zh: '阈值条件', en: 'Threshold' },
+ 'alerts.duration': { zh: '持续时间', en: 'Duration' },
+ 'alerts.channels': { zh: '通知渠道', en: 'Channels' },
+ 'alerts.lastTriggered': { zh: '最近触发', en: 'Last Triggered' },
+ 'alerts.neverTriggered': { zh: '从未触发', en: 'Never' },
+ 'alerts.ruleCreated': { zh: '规则创建成功', en: 'Rule created successfully' },
+
+ // ─── System Alerts ───
+ 'sysAlerts.title': { zh: '系统告警', en: 'System Alerts' },
+ 'sysAlerts.clearAcked': { zh: '清除已确认', en: 'Clear Acknowledged' },
+ 'sysAlerts.acknowledge': { zh: '确认', en: 'Acknowledge' },
+ 'sysAlerts.severe': { zh: '严重', en: 'Critical' },
+ 'sysAlerts.warning': { zh: '警告', en: 'Warning' },
+ 'sysAlerts.info': { zh: '信息', en: 'Info' },
+ 'sysAlerts.noAlerts': { zh: '暂无告警', en: 'No Alerts' },
+ 'sysAlerts.acknowledged': { zh: '告警已确认', en: 'Alert Acknowledged' },
+ 'sysAlerts.cleared': { zh: '已清除所有已确认告警', en: 'Cleared all acknowledged alerts' },
+ 'sysAlerts.subtitle': {
+ zh: '集群运行告警监控,当前 {n} 条未确认',
+ en: 'Cluster alert monitoring, {n} unacknowledged',
+ },
+
+ // ─── Audit Log ───
+ 'audit.title': { zh: '运维审计日志', en: 'Audit Log' },
+ 'audit.subtitle': {
+ zh: '记录所有管控操作,支持按操作人、类型和时间筛选',
+ en: 'All control operations, filterable by operator, type and time',
+ },
+ 'audit.time': { zh: '操作时间', en: 'Time' },
+ 'audit.operator': { zh: '操作人', en: 'Operator' },
+ 'audit.opType': { zh: '操作类型', en: 'Operation Type' },
+ 'audit.target': { zh: '操作对象', en: 'Target' },
+ 'audit.detail': { zh: '操作详情', en: 'Detail' },
+ 'audit.ip': { zh: 'IP 地址', en: 'IP Address' },
+ 'audit.result': { zh: '结果', en: 'Result' },
+ 'audit.searchPlaceholder': { zh: '搜索操作人或操作对象', en: 'Search operator or target' },
+ 'audit.cleanup': { zh: '清理日志', en: 'Cleanup' },
+ 'audit.cleanupTitle': { zh: '清理审计日志', en: 'Cleanup Audit Log' },
+ 'audit.cleanupDesc': {
+ zh: '清理指定天数之前的操作日志,此操作不可恢复。',
+ en: 'Clean logs older than specified days. This cannot be undone.',
+ },
+ 'audit.cleanupDays': { zh: '清理 {n} 天之前的日志', en: 'Clean logs older than {n} days' },
+ 'audit.cleanupConfirm': { zh: '确认清理', en: 'Confirm Cleanup' },
+ 'audit.cleanupSuccess': { zh: '已清理 {n} 天之前的日志', en: 'Cleaned logs older than {n} days' },
+
+ // ─── AI Page ───
+ 'ai.title': { zh: 'AI 交互', en: 'AI Chat' },
+ 'ai.commonCommands': { zh: '常用指令', en: 'Common Commands' },
+
+ // ─── Home Page ───
+ 'home.greeting.night': { zh: '夜深了', en: 'Late night' },
+ 'home.greeting.morning': { zh: '上午好', en: 'Good morning' },
+ 'home.greeting.noon': { zh: '中午好', en: 'Good afternoon' },
+ 'home.greeting.afternoon': { zh: '下午好', en: 'Good afternoon' },
+ 'home.greeting.evening': { zh: '晚上好', en: 'Good evening' },
+ 'home.mode.query': { zh: '消息查询', en: 'Message Query' },
+ 'home.mode.diagnose': { zh: '集群诊断', en: 'Cluster Diagnose' },
+ 'home.mode.manage': { zh: '资源管理', en: 'Resource Management' },
+ 'home.mode.chat': { zh: 'AI 对话', en: 'AI Chat' },
+ 'home.recommended': { zh: '推荐', en: 'Recommended' },
+ 'home.welcomeTo': { zh: '来到', en: 'to' },
+ 'home.tagline': {
+ zh: '跨集群 · 跨架构 · 跨云,统一管控你的 RocketMQ 集群',
+ en: 'Cross-cluster · Cross-arch · Cross-cloud, unified RocketMQ management',
+ },
+
+ // ─── ACL ───
+ 'acl.subtitle': {
+ zh: '访问控制规则与用户权限管理,共 {rules} 条规则、{users} 个用户',
+ en: 'Access control rules and user permissions, {rules} rules, {users} users',
+ },
+ 'acl.addRule': { zh: '添加规则', en: 'Add Rule' },
+ 'acl.addUser': { zh: '添加用户', en: 'Add User' },
+ 'acl.ruleTab': { zh: 'ACL 规则', en: 'ACL Rules' },
+ 'acl.userTab': { zh: '用户管理', en: 'Users' },
+ 'acl.principal': { zh: '主体', en: 'Principal' },
+ 'acl.resource': { zh: '资源', en: 'Resource' },
+ 'acl.permissions': { zh: '操作权限', en: 'Permissions' },
+ 'acl.decision': { zh: '决策', en: 'Decision' },
+ 'acl.aclVersion': { zh: 'ACL 版本', en: 'ACL Version' },
+ 'acl.scope': { zh: '范围', en: 'Scope' },
+ 'acl.createdAt': { zh: '创建时间', en: 'Created' },
+ 'acl.allow': { zh: '允许', en: 'Allow' },
+ 'acl.deny': { zh: '拒绝', en: 'Deny' },
+ 'acl.cluster': { zh: '集群', en: 'Cluster' },
+ 'acl.namespace': { zh: '命名空间', en: 'Namespace' },
+ 'acl.admin': { zh: '管理员', en: 'Admin' },
+ 'acl.username': { zh: '用户名', en: 'Username' },
+ 'acl.associatedClusters': { zh: '关联集群', en: 'Associated Clusters' },
+ 'acl.searchPrincipal': { zh: '搜索主体或资源', en: 'Search principal or resource' },
+ 'acl.allVersions': { zh: '全部版本', en: 'All Versions' },
+ 'acl.allDecisions': { zh: '全部决策', en: 'All Decisions' },
+ 'acl.totalRules': { zh: '共 {n} 条规则', en: '{n} rules total' },
+ 'acl.totalUsers': { zh: '共 {n} 个用户', en: '{n} users total' },
+ 'acl.editRule': { zh: '编辑规则', en: 'Edit Rule' },
+ 'acl.save': { zh: '保存', en: 'Save' },
+ 'acl.add': { zh: '添加', en: 'Add' },
+ 'acl.resourceType': { zh: '资源类型', en: 'Resource Type' },
+ 'acl.resourceName': { zh: '资源名称', en: 'Resource Name' },
+ 'acl.matchPattern': { zh: '匹配模式', en: 'Match Pattern' },
+ 'acl.literal': { zh: '精确匹配 (LITERAL)', en: 'Exact (LITERAL)' },
+ 'acl.prefix': { zh: '前缀匹配 (PREFIX)', en: 'Prefix (PREFIX)' },
+ 'acl.pub': { zh: '发布 (PUB)', en: 'Publish (PUB)' },
+ 'acl.sub': { zh: '订阅 (SUB)', en: 'Subscribe (SUB)' },
+ 'acl.all': { zh: '全部 (ALL)', en: 'All (ALL)' },
+ 'acl.allowDesc': { zh: '允许 (ALLOW)', en: 'Allow (ALLOW)' },
+ 'acl.denyDesc': { zh: '拒绝 (DENY)', en: 'Deny (DENY)' },
+ 'acl.effectScope': { zh: '生效范围', en: 'Effect Scope' },
+ 'acl.editUser': { zh: '编辑用户', en: 'Edit User' },
+ 'acl.ruleUpdated': { zh: '规则已更新', en: 'Rule updated' },
+ 'acl.ruleAdded': { zh: '规则已添加', en: 'Rule added' },
+ 'acl.ruleDeleted': { zh: '规则已删除', en: 'Rule deleted' },
+ 'acl.userUpdated': { zh: '用户已更新', en: 'User updated' },
+ 'acl.userAdded': { zh: '用户已添加', en: 'User added' },
+ 'acl.userDeleted': { zh: '用户已删除', en: 'User deleted' },
+ 'acl.adminSet': { zh: '已设为管理员', en: 'Set as admin' },
+ 'acl.adminRemoved': { zh: '已取消管理员', en: 'Admin removed' },
+ 'acl.confirmDeleteRule': { zh: '确认删除此规则?', en: 'Delete this rule?' },
+ 'acl.confirmDeleteUser': { zh: '确认删除此用户?', en: 'Delete this user?' },
+ 'acl.deleteWarning': { zh: '删除后无法恢复,请确认。', en: 'This cannot be undone.' },
+ 'acl.deleteUserWarning': {
+ zh: '删除用户后,关联的 ACL 规则不受影响。',
+ en: 'Associated ACL rules will not be affected.',
+ },
+ 'acl.adminBadge': { zh: '管理员', en: 'Admin' },
+ 'acl.selectPrincipal': { zh: '选择用户', en: 'Select user' },
+ 'acl.selectResourceType': { zh: '选择资源类型', en: 'Select resource type' },
+ 'acl.resourceNamePlaceholder': {
+ zh: '例:order-*、payment-callback、*',
+ en: 'e.g. order-*, payment-callback, *',
+ },
+ 'acl.selectEffectScope': { zh: '选择生效范围', en: 'Select effect scope' },
+ 'acl.clusterScope': { zh: '集群 (cluster)', en: 'Cluster (cluster)' },
+ 'acl.namespaceScope': { zh: '命名空间 (namespace)', en: 'Namespace (namespace)' },
+ 'acl.usernamePlaceholder': { zh: '例:user-order-service', en: 'e.g. user-order-service' },
+ 'acl.autoOrManual': { zh: '自动生成或手动输入', en: 'Auto-generate or enter manually' },
+ 'acl.required': { zh: '请选择{field}', en: 'Please select {field}' },
+ 'acl.inputRequired': { zh: '请输入{field}', en: 'Please enter {field}' },
+
+ // ─── Settings ───
+ 'settings.title': { zh: '设置', en: 'Settings' },
+
+ // ─── User Menu ───
+ 'user.profile': { zh: '个人中心', en: 'Profile' },
+ 'user.logout': { zh: '退出登录', en: 'Logout' },
+};
+
+export default translations;
diff --git a/web/src/mock/acl.ts b/web/src/mock/acl.ts
new file mode 100644
index 0000000..615b75e
--- /dev/null
+++ b/web/src/mock/acl.ts
@@ -0,0 +1,273 @@
+/*
+ * 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.
+ */
+
+export interface AclRule {
+ id: string;
+ principal: string;
+ resource: string;
+ resourceType: 'Topic' | 'Group' | 'Cluster';
+ resourcePattern: 'LITERAL' | 'PREFIX';
+ actions: ('PUB' | 'SUB' | 'ALL')[];
+ decision: 'ALLOW' | 'DENY';
+ scope: 'cluster' | 'namespace';
+ aclVersion: '1.0' | '2.0';
+ createdAt: string;
+}
+
+export interface AclUser {
+ id: string;
+ username: string;
+ accessKey: string;
+ secretKey: string;
+ admin: boolean;
+ clusters: string[];
+ createdAt: string;
+}
+
+export const aclRules: AclRule[] = [
+ {
+ id: 'acl-001',
+ principal: 'user-order-service',
+ resource: 'order-*',
+ resourceType: 'Topic',
+ resourcePattern: 'PREFIX',
+ actions: ['PUB', 'SUB'],
+ decision: 'ALLOW',
+ scope: 'cluster',
+ aclVersion: '2.0',
+ createdAt: '2025-08-12T10:30:00Z',
+ },
+ {
+ id: 'acl-002',
+ principal: 'user-payment-service',
+ resource: 'payment-*',
+ resourceType: 'Topic',
+ resourcePattern: 'PREFIX',
+ actions: ['PUB', 'SUB'],
+ decision: 'ALLOW',
+ scope: 'cluster',
+ aclVersion: '2.0',
+ createdAt: '2025-09-05T14:20:00Z',
+ },
+ {
+ id: 'acl-003',
+ principal: 'user-admin',
+ resource: '*',
+ resourceType: 'Cluster',
+ resourcePattern: 'LITERAL',
+ actions: ['ALL'],
+ decision: 'ALLOW',
+ scope: 'cluster',
+ aclVersion: '2.0',
+ createdAt: '2025-03-01T08:00:00Z',
+ },
+ {
+ id: 'acl-004',
+ principal: 'user-log-collector',
+ resource: 'system-log',
+ resourceType: 'Topic',
+ resourcePattern: 'LITERAL',
+ actions: ['SUB'],
+ decision: 'ALLOW',
+ scope: 'namespace',
+ aclVersion: '1.0',
+ createdAt: '2025-06-20T16:45:00Z',
+ },
+ {
+ id: 'acl-005',
+ principal: 'user-order-service',
+ resource: 'cg-order-*',
+ resourceType: 'Group',
+ resourcePattern: 'PREFIX',
+ actions: ['SUB'],
+ decision: 'ALLOW',
+ scope: 'cluster',
+ aclVersion: '2.0',
+ createdAt: '2025-08-15T11:00:00Z',
+ },
+ {
+ id: 'acl-006',
+ principal: 'user-inventory-service',
+ resource: 'inventory-sync',
+ resourceType: 'Topic',
+ resourcePattern: 'LITERAL',
+ actions: ['PUB', 'SUB'],
+ decision: 'ALLOW',
+ scope: 'cluster',
+ aclVersion: '2.0',
+ createdAt: '2025-10-10T09:30:00Z',
+ },
+ {
+ id: 'acl-007',
+ principal: 'user-guest',
+ resource: 'payment-callback',
+ resourceType: 'Topic',
+ resourcePattern: 'LITERAL',
+ actions: ['PUB', 'SUB'],
+ decision: 'DENY',
+ scope: 'cluster',
+ aclVersion: '1.0',
+ createdAt: '2025-11-02T13:15:00Z',
+ },
+ {
+ id: 'acl-008',
+ principal: 'user-notification-service',
+ resource: 'notification-*',
+ resourceType: 'Topic',
+ resourcePattern: 'PREFIX',
+ actions: ['PUB'],
+ decision: 'ALLOW',
+ scope: 'namespace',
+ aclVersion: '2.0',
+ createdAt: '2025-12-18T10:00:00Z',
+ },
+ {
+ id: 'acl-009',
+ principal: 'user-risk-control',
+ resource: 'order-create',
+ resourceType: 'Topic',
+ resourcePattern: 'LITERAL',
+ actions: ['SUB'],
+ decision: 'ALLOW',
+ scope: 'cluster',
+ aclVersion: '1.0',
+ createdAt: '2026-01-08T15:30:00Z',
+ },
+ {
+ id: 'acl-010',
+ principal: 'user-guest',
+ resource: '*',
+ resourceType: 'Cluster',
+ resourcePattern: 'LITERAL',
+ actions: ['PUB'],
+ decision: 'DENY',
+ scope: 'cluster',
+ aclVersion: '2.0',
+ createdAt: '2026-02-14T08:45:00Z',
+ },
+ {
+ id: 'acl-011',
+ principal: 'user-payment-service',
+ resource: 'cg-payment-*',
+ resourceType: 'Group',
+ resourcePattern: 'PREFIX',
+ actions: ['SUB'],
+ decision: 'ALLOW',
+ scope: 'cluster',
+ aclVersion: '2.0',
+ createdAt: '2026-03-22T11:20:00Z',
+ },
+ {
+ id: 'acl-012',
+ principal: 'user-monitor',
+ resource: 'user-activity-log',
+ resourceType: 'Topic',
+ resourcePattern: 'LITERAL',
+ actions: ['SUB'],
+ decision: 'ALLOW',
+ scope: 'namespace',
+ aclVersion: '1.0',
+ createdAt: '2026-04-15T09:10:00Z',
+ },
+ {
+ id: 'acl-013',
+ principal: 'user-ai-service',
+ resource: 'chat/sess-*',
+ resourceType: 'Topic',
+ resourcePattern: 'PREFIX',
+ actions: ['PUB', 'SUB'],
+ decision: 'ALLOW',
+ scope: 'namespace',
+ aclVersion: '2.0',
+ createdAt: '2026-05-20T14:00:00Z',
+ },
+];
+
+export const aclUsers: AclUser[] = [
+ {
+ id: 'u-001',
+ username: 'user-admin',
+ accessKey: 'LTAI****admin',
+ secretKey: 'HqWz****xK8P',
+ admin: true,
+ clusters: ['rmq-cn-v5-prod-01', 'rmq-cn-v5-prod-02', 'rmq-cn-v4-prod-02'],
+ createdAt: '2025-01-10T08:00:00Z',
+ },
+ {
+ id: 'u-002',
+ username: 'user-order-service',
+ accessKey: 'LTAI****ordr',
+ secretKey: 'MnBv****7dF3',
+ admin: false,
+ clusters: ['rmq-cn-v5-prod-01'],
+ createdAt: '2025-03-22T10:30:00Z',
+ },
+ {
+ id: 'u-003',
+ username: 'user-payment-service',
+ accessKey: 'LTAI****paym',
+ secretKey: 'XcZa****9pQ2',
+ admin: false,
+ clusters: ['rmq-cn-v5-prod-01', 'rmq-cn-v5-prod-02'],
+ createdAt: '2025-04-15T14:20:00Z',
+ },
+ {
+ id: 'u-004',
+ username: 'user-log-collector',
+ accessKey: 'LTAI****logs',
+ secretKey: 'RtYu****3hJ5',
+ admin: false,
+ clusters: ['rmq-cn-v4-prod-02'],
+ createdAt: '2025-06-01T09:00:00Z',
+ },
+ {
+ id: 'u-005',
+ username: 'user-guest',
+ accessKey: 'LTAI****guest',
+ secretKey: 'KpLm****6wN8',
+ admin: false,
+ clusters: ['rmq-cn-v5-prod-01'],
+ createdAt: '2025-07-18T16:00:00Z',
+ },
+ {
+ id: 'u-006',
+ username: 'user-inventory-service',
+ accessKey: 'LTAI****invn',
+ secretKey: 'GhJk****4tY1',
+ admin: false,
+ clusters: ['rmq-cn-v5-prod-01', 'rmq-cn-v5-prod-02'],
+ createdAt: '2025-09-10T11:30:00Z',
+ },
+ {
+ id: 'u-007',
+ username: 'user-notification-service',
+ accessKey: 'LTAI****ntfy',
+ secretKey: 'BvCx****8mZ6',
+ admin: false,
+ clusters: ['rmq-cn-v5-prod-01'],
+ createdAt: '2025-11-05T13:45:00Z',
+ },
+ {
+ id: 'u-008',
+ username: 'user-monitor',
+ accessKey: 'LTAI****monr',
+ secretKey: 'WqEr****2fA4',
+ admin: false,
+ clusters: ['rmq-cn-v5-prod-01', 'rmq-cn-v5-prod-02', 'rmq-cn-v4-prod-02'],
+ createdAt: '2026-01-20T10:00:00Z',
+ },
+];
diff --git a/web/src/mock/alerts.ts b/web/src/mock/alerts.ts
new file mode 100644
index 0000000..294ff46
--- /dev/null
+++ b/web/src/mock/alerts.ts
@@ -0,0 +1,125 @@
+/*
+ * 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.
+ */
+
+export const mockAlertRules = [
+ {
+ id: 'alert-001',
+ name: 'Broker 磁盘使用率过高',
+ metric: '磁盘使用率',
+ operator: '>' as const,
+ threshold: 85,
+ thresholdUnit: '%',
+ duration: '5分钟',
+ channels: ['dingtalk', 'email'] as ('dingtalk' | 'email' | 'sms')[],
+ enabled: true,
+ lastTriggered: '2026-07-02 18:32:11',
+ description: '当 Broker 磁盘使用率超过 85% 时触发告警',
+ },
+ {
+ id: 'alert-002',
+ name: '消费堆积量异常',
+ metric: '消费堆积量',
+ operator: '>' as const,
+ threshold: 10000,
+ thresholdUnit: '条',
+ duration: '15分钟',
+ channels: ['dingtalk', 'sms'] as ('dingtalk' | 'email' | 'sms')[],
+ enabled: true,
+ lastTriggered: '2026-07-02 14:05:23',
+ description: '消费组堆积消息数超过 10000 条时触发',
+ },
+ {
+ id: 'alert-003',
+ name: 'TPS 突降告警',
+ metric: 'TPS 异常',
+ operator: '<' as const,
+ threshold: 500,
+ thresholdUnit: 'TPS',
+ duration: '5分钟',
+ channels: ['dingtalk', 'email', 'sms'] as ('dingtalk' | 'email' | 'sms')[],
+ enabled: true,
+ lastTriggered: '2026-07-01 22:17:45',
+ description: 'TPS 低于 500 时触发,可能表示生产端异常',
+ },
+ {
+ id: 'alert-004',
+ name: 'Broker 离线检测',
+ metric: 'Broker 离线',
+ operator: '>=' as const,
+ threshold: 1,
+ thresholdUnit: '个',
+ duration: '1分钟',
+ channels: ['dingtalk', 'sms'] as ('dingtalk' | 'email' | 'sms')[],
+ enabled: true,
+ lastTriggered: '2026-06-30 09:12:08',
+ description: '检测到任意 Broker 节点离线时立即触发',
+ },
+ {
+ id: 'alert-005',
+ name: 'Proxy 连接数过高',
+ metric: 'Proxy 连接数',
+ operator: '>' as const,
+ threshold: 5000,
+ thresholdUnit: '个',
+ duration: '5分钟',
+ channels: ['dingtalk', 'email'] as ('dingtalk' | 'email' | 'sms')[],
+ enabled: false,
+ lastTriggered: '2026-06-28 11:45:33',
+ description: 'Proxy 连接数超过 5000 时触发告警',
+ },
+ {
+ id: 'alert-006',
+ name: '磁盘使用率预警',
+ metric: '磁盘使用率',
+ operator: '>=' as const,
+ threshold: 70,
+ thresholdUnit: '%',
+ duration: '30分钟',
+ channels: ['email'] as ('dingtalk' | 'email' | 'sms')[],
+ enabled: true,
+ lastTriggered: null,
+ description: '磁盘使用率超过 70% 的预警级别',
+ },
+ {
+ id: 'alert-007',
+ name: '消费堆积轻微告警',
+ metric: '消费堆积量',
+ operator: '>' as const,
+ threshold: 5000,
+ thresholdUnit: '条',
+ duration: '30分钟',
+ channels: ['dingtalk'] as ('dingtalk' | 'email' | 'sms')[],
+ enabled: true,
+ lastTriggered: '2026-07-03 08:21:17',
+ description: '消费堆积量超过 5000 条时的轻微告警',
+ },
+ {
+ id: 'alert-008',
+ name: 'TPS 突增告警',
+ metric: 'TPS 异常',
+ operator: '>' as const,
+ threshold: 50000,
+ thresholdUnit: 'TPS',
+ duration: '1分钟',
+ channels: ['dingtalk', 'email'] as ('dingtalk' | 'email' | 'sms')[],
+ enabled: false,
+ lastTriggered: '2026-06-25 16:08:52',
+ description: 'TPS 超过 50000 时触发,防止流量突增压垮集群',
+ },
+];
+
+export type AlertRule = (typeof mockAlertRules)[number];
diff --git a/web/src/mock/audit.ts b/web/src/mock/audit.ts
new file mode 100644
index 0000000..59f9ab2
--- /dev/null
+++ b/web/src/mock/audit.ts
@@ -0,0 +1,201 @@
+/*
+ * 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.
+ */
+
+export const mockAuditRecords = [
+ {
+ id: 'audit-001',
+ timestamp: '2026-07-03 09:45:12',
+ operator: 'admin',
+ operationType: '创建Topic',
+ target: 'rmq-trade-order-topic',
+ detail: '创建 Topic: rmq-trade-order-topic,队列数 16,权限 6',
+ ipAddress: '10.0.12.45',
+ result: 'success' as const,
+ },
+ {
+ id: 'audit-002',
+ timestamp: '2026-07-03 09:32:08',
+ operator: 'ops-zhang',
+ operationType: '修改配置',
+ target: 'rmq-payment-group',
+ detail: '修改消费组 rmq-payment-group 的消费模式为 CLUSTERING',
+ ipAddress: '10.0.15.22',
+ result: 'success' as const,
+ },
+ {
+ id: 'audit-003',
+ timestamp: '2026-07-03 08:17:55',
+ operator: 'ops-li',
+ operationType: '重置位点',
+ target: 'rmq-log-consumer-group',
+ detail: '重置消费组 rmq-log-consumer-group 的位点到 2026-07-02 00:00:00',
+ ipAddress: '10.0.8.101',
+ result: 'success' as const,
+ },
+ {
+ id: 'audit-004',
+ timestamp: '2026-07-03 07:58:33',
+ operator: 'admin',
+ operationType: '删除Topic',
+ target: 'rmq-test-temp-topic',
+ detail: '删除测试用 Topic: rmq-test-temp-topic',
+ ipAddress: '10.0.12.45',
+ result: 'success' as const,
+ },
+ {
+ id: 'audit-005',
+ timestamp: '2026-07-03 07:22:19',
+ operator: 'ops-wang',
+ operationType: 'ACL变更',
+ target: 'rmq-secure-topic',
+ detail: '为 Topic rmq-secure-topic 添加 ACL 规则,允许 user: readonly 读取',
+ ipAddress: '10.0.20.88',
+ result: 'success' as const,
+ },
+ {
+ id: 'audit-006',
+ timestamp: '2026-07-02 22:45:01',
+ operator: 'system',
+ operationType: '重启Broker',
+ target: 'broker-cn-hangzhou-02',
+ detail: '自动重启异常 Broker 节点 broker-cn-hangzhou-02',
+ ipAddress: '10.0.1.2',
+ result: 'failure' as const,
+ },
+ {
+ id: 'audit-007',
+ timestamp: '2026-07-02 20:11:47',
+ operator: 'ops-zhang',
+ operationType: '修改配置',
+ target: 'broker-cn-shanghai-01',
+ detail: '修改 Broker broker-cn-shanghai-01 的 flushDiskType 为 ASYNC_FLUSH',
+ ipAddress: '10.0.15.22',
+ result: 'success' as const,
+ },
+ {
+ id: 'audit-008',
+ timestamp: '2026-07-02 18:33:28',
+ operator: 'ops-li',
+ operationType: '创建Topic',
+ target: 'rmq-notification-topic',
+ detail: '创建 Topic: rmq-notification-topic,队列数 8,权限 6',
+ ipAddress: '10.0.8.101',
+ result: 'success' as const,
+ },
+ {
+ id: 'audit-009',
+ timestamp: '2026-07-02 16:05:14',
+ operator: 'admin',
+ operationType: '删除消费组',
+ target: 'rmq-legacy-consumer-group',
+ detail: '删除已废弃的消费组 rmq-legacy-consumer-group',
+ ipAddress: '10.0.12.45',
+ result: 'success' as const,
+ },
+ {
+ id: 'audit-010',
+ timestamp: '2026-07-02 14:28:56',
+ operator: 'ops-wang',
+ operationType: 'ACL变更',
+ target: 'rmq-admin-topic',
+ detail: '更新 Topic rmq-admin-topic 的 ACL 规则,移除 user: guest 的访问权限',
+ ipAddress: '10.0.20.88',
+ result: 'success' as const,
+ },
+ {
+ id: 'audit-011',
+ timestamp: '2026-07-02 11:52:09',
+ operator: 'ops-zhang',
+ operationType: '重置位点',
+ target: 'rmq-order-consumer-group',
+ detail: '重置消费组 rmq-order-consumer-group 的位点到 2026-07-01 12:00:00',
+ ipAddress: '10.0.15.22',
+ result: 'failure' as const,
+ },
+ {
+ id: 'audit-012',
+ timestamp: '2026-07-02 10:15:42',
+ operator: 'system',
+ operationType: '重启Broker',
+ target: 'broker-cn-beijing-03',
+ detail: '手动重启 Broker 节点 broker-cn-beijing-03 以应用配置变更',
+ ipAddress: '10.0.1.1',
+ result: 'success' as const,
+ },
+ {
+ id: 'audit-013',
+ timestamp: '2026-07-01 23:41:18',
+ operator: 'ops-li',
+ operationType: '创建Topic',
+ target: 'rmq-metrics-topic',
+ detail: '创建 Topic: rmq-metrics-topic,队列数 32,权限 6',
+ ipAddress: '10.0.8.101',
+ result: 'success' as const,
+ },
+ {
+ id: 'audit-014',
+ timestamp: '2026-07-01 19:08:35',
+ operator: 'admin',
+ operationType: '修改配置',
+ target: 'rmq-cluster-config',
+ detail: '更新集群配置:开启消息轨迹追踪功能',
+ ipAddress: '10.0.12.45',
+ result: 'success' as const,
+ },
+ {
+ id: 'audit-015',
+ timestamp: '2026-07-01 15:27:50',
+ operator: 'ops-wang',
+ operationType: '删除Topic',
+ target: 'rmq-deprecated-topic',
+ detail: '删除已废弃 Topic: rmq-deprecated-topic',
+ ipAddress: '10.0.20.88',
+ result: 'failure' as const,
+ },
+ {
+ id: 'audit-016',
+ timestamp: '2026-07-01 13:55:23',
+ operator: 'ops-zhang',
+ operationType: 'ACL变更',
+ target: 'rmq-billing-topic',
+ detail: '为 Topic rmq-billing-topic 创建新的 ACL 规则,限制仅 admin 组可写入',
+ ipAddress: '10.0.15.22',
+ result: 'success' as const,
+ },
+ {
+ id: 'audit-017',
+ timestamp: '2026-07-01 10:33:07',
+ operator: 'ops-li',
+ operationType: '删除消费组',
+ target: 'rmq-old-consumer-group',
+ detail: '删除已迁移的旧消费组 rmq-old-consumer-group',
+ ipAddress: '10.0.8.101',
+ result: 'success' as const,
+ },
+ {
+ id: 'audit-018',
+ timestamp: '2026-07-01 08:19:44',
+ operator: 'system',
+ operationType: '重启Broker',
+ target: 'broker-cn-shenzhen-01',
+ detail: '自动重启异常 Broker 节点 broker-cn-shenzhen-01,磁盘 IO 超时',
+ ipAddress: '10.0.1.3',
+ result: 'success' as const,
+ },
+] as const;
+
+export type AuditRecord = (typeof mockAuditRecords)[number];
diff --git a/web/src/mock/clients.ts b/web/src/mock/clients.ts
new file mode 100644
index 0000000..6ece01a
--- /dev/null
+++ b/web/src/mock/clients.ts
@@ -0,0 +1,255 @@
+/*
+ * 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.
+ */
+
+/* ─── Types ─── */
+
+export interface ClientConnection {
+ clientId: string;
+ type: 'Producer' | 'Consumer';
+ groupOrTopic: string;
+ protocol: 'gRPC' | 'Remoting';
+ address: string;
+ language: 'Java' | 'Go' | 'Python' | 'Rust';
+ version: string;
+ connectedAt: string;
+ clusterName: string;
+}
+
+/* ─── Mock Data ─── */
+
+export const mockClients: ClientConnection[] = [
+ {
+ clientId: 'order-svc-0@10.0.1.12:49152',
+ type: 'Producer',
+ groupOrTopic: 'order-create',
+ protocol: 'gRPC',
+ address: '10.0.1.12:49152',
+ language: 'Java',
+ version: '5.0.7',
+ connectedAt: '2026-07-01 08:30:00',
+ clusterName: 'ns-prod',
+ },
+ {
+ clientId: 'order-svc-1@10.0.1.13:49200',
+ type: 'Producer',
+ groupOrTopic: 'order-status-change',
+ protocol: 'gRPC',
+ address: '10.0.1.13:49200',
+ language: 'Java',
+ version: '5.0.7',
+ connectedAt: '2026-07-01 08:30:05',
+ clusterName: 'ns-prod',
+ },
+ {
+ clientId: 'payment-svc-0@10.0.2.10:50100',
+ type: 'Producer',
+ groupOrTopic: 'payment-callback',
+ protocol: 'gRPC',
+ address: '10.0.2.10:50100',
+ language: 'Java',
+ version: '5.0.7',
+ connectedAt: '2026-07-01 08:32:10',
+ clusterName: 'ns-prod',
+ },
+ {
+ clientId: 'cg-order-notify-0@10.0.1.12:49160',
+ type: 'Consumer',
+ groupOrTopic: 'cg-order-notify',
+ protocol: 'gRPC',
+ address: '10.0.1.12:49160',
+ language: 'Java',
+ version: '5.0.7',
+ connectedAt: '2026-07-01 08:31:00',
+ clusterName: 'ns-prod',
+ },
+ {
+ clientId: 'cg-order-notify-1@10.0.1.13:49210',
+ type: 'Consumer',
+ groupOrTopic: 'cg-order-notify',
+ protocol: 'gRPC',
+ address: '10.0.1.13:49210',
+ language: 'Java',
+ version: '5.0.7',
+ connectedAt: '2026-07-01 08:31:05',
+ clusterName: 'ns-prod',
+ },
+ {
+ clientId: 'cg-payment-cb-0@10.0.2.10:50110',
+ type: 'Consumer',
+ groupOrTopic: 'cg-payment-callback',
+ protocol: 'gRPC',
+ address: '10.0.2.10:50110',
+ language: 'Java',
+ version: '5.0.7',
+ connectedAt: '2026-07-01 08:33:00',
+ clusterName: 'ns-prod',
+ },
+ {
+ clientId: 'user-svc-0@10.0.3.20:51100',
+ type: 'Producer',
+ groupOrTopic: 'user-activity-log',
+ protocol: 'Remoting',
+ address: '10.0.3.20:51100',
+ language: 'Go',
+ version: '5.0.3',
+ connectedAt: '2026-07-01 09:00:00',
+ clusterName: 'ns-prod',
+ },
+ {
+ clientId: 'cg-user-act-0@10.0.3.20:51110',
+ type: 'Consumer',
+ groupOrTopic: 'cg-user-activity',
+ protocol: 'Remoting',
+ address: '10.0.3.20:51110',
+ language: 'Go',
+ version: '5.0.3',
+ connectedAt: '2026-07-01 09:00:30',
+ clusterName: 'ns-prod',
+ },
+ {
+ clientId: 'log-collector-0@10.0.5.40:53100',
+ type: 'Consumer',
+ groupOrTopic: 'cg-log-collector',
+ protocol: 'gRPC',
+ address: '10.0.5.40:53100',
+ language: 'Rust',
+ version: '5.0.2',
+ connectedAt: '2026-07-01 07:00:00',
+ clusterName: 'ns-prod',
+ },
+ {
+ clientId: 'log-collector-1@10.0.5.41:53200',
+ type: 'Consumer',
+ groupOrTopic: 'cg-log-collector',
+ protocol: 'gRPC',
+ address: '10.0.5.41:53200',
+ language: 'Rust',
+ version: '5.0.2',
+ connectedAt: '2026-07-01 07:00:10',
+ clusterName: 'ns-prod',
+ },
+ {
+ clientId: 'notif-gw-0@10.0.6.50:54100',
+ type: 'Producer',
+ groupOrTopic: 'notification-push',
+ protocol: 'gRPC',
+ address: '10.0.6.50:54100',
+ language: 'Python',
+ version: '5.0.1',
+ connectedAt: '2026-07-01 08:45:00',
+ clusterName: 'ns-prod',
+ },
+ {
+ clientId: 'cg-notif-push-0@10.0.6.50:54110',
+ type: 'Consumer',
+ groupOrTopic: 'cg-notification-push',
+ protocol: 'gRPC',
+ address: '10.0.6.50:54110',
+ language: 'Python',
+ version: '5.0.1',
+ connectedAt: '2026-07-01 08:45:30',
+ clusterName: 'ns-prod',
+ },
+ {
+ clientId: 'ai-infer-0@10.0.7.60:55100',
+ type: 'Consumer',
+ groupOrTopic: 'cg-ai-task-worker',
+ protocol: 'gRPC',
+ address: '10.0.7.60:55100',
+ language: 'Python',
+ version: '5.0.1',
+ connectedAt: '2026-07-01 10:00:00',
+ clusterName: 'ns-prod',
+ },
+ {
+ clientId: 'ai-dispatch-0@10.0.7.70:55200',
+ type: 'Producer',
+ groupOrTopic: 'ai-task-dispatch',
+ protocol: 'gRPC',
+ address: '10.0.7.70:55200',
+ language: 'Java',
+ version: '5.0.7',
+ connectedAt: '2026-07-01 10:00:15',
+ clusterName: 'ns-prod',
+ },
+ {
+ clientId: 'metrics-agent-0@10.0.8.70:56100',
+ type: 'Producer',
+ groupOrTopic: 'metrics-raw',
+ protocol: 'Remoting',
+ address: '10.0.8.70:56100',
+ language: 'Go',
+ version: '5.0.3',
+ connectedAt: '2026-07-01 06:00:00',
+ clusterName: 'ns-pre',
+ },
+ {
+ clientId: 'cg-metrics-agg-0@10.0.8.70:56110',
+ type: 'Consumer',
+ groupOrTopic: 'cg-metrics-aggregator',
+ protocol: 'Remoting',
+ address: '10.0.8.70:56110',
+ language: 'Go',
+ version: '5.0.3',
+ connectedAt: '2026-07-01 06:00:30',
+ clusterName: 'ns-pre',
+ },
+ {
+ clientId: 'risk-engine-0@10.0.9.80:57100',
+ type: 'Consumer',
+ groupOrTopic: 'cg-risk-control',
+ protocol: 'gRPC',
+ address: '10.0.9.80:57100',
+ language: 'Java',
+ version: '5.0.7',
+ connectedAt: '2026-07-01 08:00:00',
+ clusterName: 'ns-prod',
+ },
+ {
+ clientId: 'binlog-bridge-0@10.0.10.90:58100',
+ type: 'Producer',
+ groupOrTopic: 'binlog-event',
+ protocol: 'gRPC',
+ address: '10.0.10.90:58100',
+ language: 'Java',
+ version: '5.0.7',
+ connectedAt: '2026-07-01 06:30:00',
+ clusterName: 'ns-pre',
+ },
+ {
+ clientId: 'cg-data-sync-0@10.0.10.90:58110',
+ type: 'Consumer',
+ groupOrTopic: 'cg-data-sync',
+ protocol: 'gRPC',
+ address: '10.0.10.90:58110',
+ language: 'Java',
+ version: '5.0.7',
+ connectedAt: '2026-07-01 06:30:30',
+ clusterName: 'ns-pre',
+ },
+ {
+ clientId: 'search-idx-0@10.0.11.100:59100',
+ type: 'Consumer',
+ groupOrTopic: 'cg-search-indexer',
+ protocol: 'gRPC',
+ address: '10.0.11.100:59100',
+ language: 'Rust',
+ version: '5.0.2',
+ connectedAt: '2026-07-01 09:15:00',
+ clusterName: 'ns-pre',
+ },
+];
diff --git a/web/src/mock/clusters.ts b/web/src/mock/clusters.ts
new file mode 100644
index 0000000..0e935a9
--- /dev/null
+++ b/web/src/mock/clusters.ts
@@ -0,0 +1,405 @@
+/*
+ * 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.
+ */
+
+export interface BrokerInfo {
+ name: string;
+ addr: string;
+ version: string;
+ status: 'running' | 'readonly' | 'maintenance';
+ diskUsage: number;
+ tpsIn: number;
+ tpsOut: number;
+}
+
+export interface ProxyInfo {
+ addr: string;
+ status: 'healthy' | 'warning' | 'error' | 'offline';
+ connections: number;
+ grpcPort: number;
+ remotingPort: number;
+}
+
+export interface NameServerInfo {
+ addr: string;
+ status: 'healthy' | 'warning' | 'error' | 'offline';
+}
+
+export interface K8sCertInfo {
+ id: string;
+ name: string;
+ namespace: string;
+ cluster: string;
+ type: 'TLS' | 'mTLS' | 'ServiceAccount';
+ issuer: string;
+ notBefore: string;
+ notAfter: string;
+ status: 'valid' | 'expiring' | 'expired';
+ daysRemaining: number;
+ san: string[];
+}
+
+export const mockK8sCerts: K8sCertInfo[] = [
+ {
+ id: 'cert-001',
+ name: 'ca90643d13159433da1dadd826160b2c1',
+ namespace: 'kube-system',
+ cluster: 'rocketmq-prod',
+ type: 'TLS',
+ issuer: 'kubernetes-ca',
+ notBefore: '2025-06-01T00:00:00Z',
+ notAfter: '2026-06-01T00:00:00Z',
+ status: 'valid',
+ daysRemaining: 335,
+ san: ['kubernetes', 'kubernetes.default', 'kubernetes.default.svc'],
+ },
+ {
+ id: 'cert-002',
+ name: 'e7f3a8c291b04d56a1e2f4c6b8d0a9235',
+ namespace: 'kube-system',
+ cluster: 'rocketmq-prod',
+ type: 'mTLS',
+ issuer: 'etcd-ca',
+ notBefore: '2025-06-01T00:00:00Z',
+ notAfter: '2026-06-01T00:00:00Z',
+ status: 'valid',
+ daysRemaining: 335,
+ san: ['etcd-0', 'etcd-1', 'etcd-2'],
+ },
+ {
+ id: 'cert-003',
+ name: 'b2d4f6a8c0e2f4a6b8d0c2e4f6a8b0d2',
+ namespace: 'cert-manager',
+ cluster: 'rocketmq-prod',
+ type: 'TLS',
+ issuer: 'letsencrypt-prod',
+ notBefore: '2025-01-15T00:00:00Z',
+ notAfter: '2025-07-15T00:00:00Z',
+ status: 'expiring',
+ daysRemaining: 12,
+ san: ['*.rocketmq-prod.example.com', 'rocketmq-prod.example.com'],
+ },
+ {
+ id: 'cert-004',
+ name: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6',
+ namespace: 'kube-system',
+ cluster: 'rocketmq-pre',
+ type: 'TLS',
+ issuer: 'kubernetes-ca',
+ notBefore: '2025-09-01T00:00:00Z',
+ notAfter: '2026-09-01T00:00:00Z',
+ status: 'valid',
+ daysRemaining: 427,
+ san: ['kubernetes', 'kubernetes.default', 'kubernetes.default.svc'],
+ },
+ {
+ id: 'cert-005',
+ name: 'f9e8d7c6b5a4f3e2d1c0b9a8f7e6d5c4',
+ namespace: 'istio-system',
+ cluster: 'rocketmq-pre',
+ type: 'mTLS',
+ issuer: 'istio-ca',
+ notBefore: '2025-09-01T00:00:00Z',
+ notAfter: '2026-09-01T00:00:00Z',
+ status: 'valid',
+ daysRemaining: 427,
+ san: ['*.rocketmq-pre.svc.cluster.local', 'istiod.istio-system.svc'],
+ },
+ {
+ id: 'cert-006',
+ name: 'c0ffee1bad2bad3caffe4bad5coffee6bad7',
+ namespace: 'kube-system',
+ cluster: 'rocketmq-prod',
+ type: 'TLS',
+ issuer: 'self-signed',
+ notBefore: '2025-03-01T00:00:00Z',
+ notAfter: '2025-06-20T00:00:00Z',
+ status: 'expired',
+ daysRemaining: -10,
+ san: ['kubernetes', 'localhost'],
+ },
+ {
+ id: 'cert-007',
+ name: 'd3adb33fd3adb33fd3adb33fd3adb33f',
+ namespace: 'kube-system',
+ cluster: 'rocketmq-pre',
+ type: 'ServiceAccount',
+ issuer: 'k8s-sa',
+ notBefore: '2026-01-01T00:00:00Z',
+ notAfter: '2027-01-01T00:00:00Z',
+ status: 'valid',
+ daysRemaining: 549,
+ san: ['system:serviceaccount:rocketmq:broker-sa'],
+ },
+ {
+ id: 'cert-008',
+ name: '5ca1ab1e5ca1ab1e5ca1ab1e5ca1ab1e',
+ namespace: 'kube-system',
+ cluster: 'rocketmq-prod',
+ type: 'mTLS',
+ issuer: 'vault-pki',
+ notBefore: '2025-12-01T00:00:00Z',
+ notAfter: '2026-12-01T00:00:00Z',
+ status: 'valid',
+ daysRemaining: 518,
+ san: ['*.rocketmq-prod.svc.cluster.local'],
+ },
+];
+
+export interface ClusterConfig {
+ writeQueueNums: number;
+ readQueueNums: number;
+ maxMessageSize: number;
+ msgTraceTopicName: string;
+ autoCreateTopicEnable: boolean;
+ autoCreateSubscriptionGroup: boolean;
+ deleteWhen: string;
+ fileReservedTime: number;
+ flushDiskType: 'ASYNC_FLUSH' | 'SYNC_FLUSH';
+ brokerPermission: number;
+}
+
+export interface ClusterInfo {
+ id: string;
+ name: string;
+ nsClusterName: string;
+ type: 'V5_PROXY_CLUSTER';
+ endpoint: string;
+ status: 'healthy' | 'warning' | 'error' | 'offline';
+ version: string;
+ brokers: BrokerInfo[];
+ proxies: ProxyInfo[];
+ nameServers: NameServerInfo[];
+ config: ClusterConfig;
+ topicCount: number;
+ groupCount: number;
+ tpsHistory: number[];
+}
+
+const clusters: ClusterInfo[] = [
+ {
+ id: 'cluster-prod',
+ name: 'rocketmq-prod',
+ nsClusterName: 'ns-prod',
+ type: 'V5_PROXY_CLUSTER',
+ endpoint: '10.101.2.1:9876',
+ status: 'healthy',
+ version: '5.2.0',
+ brokers: [
+ {
+ name: 'rocketmq-prod-0',
+ addr: '10.101.2.11:10911',
+ version: '5.2.0',
+ status: 'running',
+ diskUsage: 62,
+ tpsIn: 12480,
+ tpsOut: 34560,
+ },
+ {
+ name: 'rocketmq-prod-1',
+ addr: '10.101.2.12:10911',
+ version: '5.2.0',
+ status: 'running',
+ diskUsage: 58,
+ tpsIn: 11800,
+ tpsOut: 31200,
+ },
+ {
+ name: 'rocketmq-prod-2',
+ addr: '10.101.2.13:10911',
+ version: '5.2.0',
+ status: 'running',
+ diskUsage: 71,
+ tpsIn: 11200,
+ tpsOut: 28900,
+ },
+ {
+ name: 'rocketmq-prod-3',
+ addr: '10.101.2.14:10911',
+ version: '5.2.0',
+ status: 'running',
+ diskUsage: 55,
+ tpsIn: 10500,
+ tpsOut: 27800,
+ },
+ {
+ name: 'rocketmq-prod-4',
+ addr: '10.101.2.15:10911',
+ version: '5.2.0',
+ status: 'running',
+ diskUsage: 48,
+ tpsIn: 9800,
+ tpsOut: 25600,
+ },
+ {
+ name: 'rocketmq-prod-5',
+ addr: '10.101.2.16:10911',
+ version: '5.2.0',
+ status: 'readonly',
+ diskUsage: 86,
+ tpsIn: 0,
+ tpsOut: 18400,
+ },
+ {
+ name: 'rocketmq-prod-6',
+ addr: '10.101.2.17:10911',
+ version: '5.2.0',
+ status: 'running',
+ diskUsage: 43,
+ tpsIn: 8600,
+ tpsOut: 22400,
+ },
+ {
+ name: 'rocketmq-prod-7',
+ addr: '10.101.2.18:10911',
+ version: '5.1.4',
+ status: 'maintenance',
+ diskUsage: 91,
+ tpsIn: 0,
+ tpsOut: 0,
+ },
+ ],
+ proxies: [
+ {
+ addr: '10.101.2.21:8081',
+ status: 'healthy',
+ connections: 1842,
+ grpcPort: 8081,
+ remotingPort: 8080,
+ },
+ {
+ addr: '10.101.2.22:8081',
+ status: 'healthy',
+ connections: 1653,
+ grpcPort: 8081,
+ remotingPort: 8080,
+ },
+ {
+ addr: '10.101.2.23:8081',
+ status: 'healthy',
+ connections: 1724,
+ grpcPort: 8081,
+ remotingPort: 8080,
+ },
+ ],
+ nameServers: [
+ { addr: '10.101.2.1:9876', status: 'healthy' },
+ { addr: '10.101.2.2:9876', status: 'healthy' },
+ { addr: '10.101.2.3:9876', status: 'healthy' },
+ ],
+ config: {
+ writeQueueNums: 16,
+ readQueueNums: 16,
+ maxMessageSize: 4194304,
+ msgTraceTopicName: 'RMQ_SYS_TRACE_TOPIC4',
+ autoCreateTopicEnable: false,
+ autoCreateSubscriptionGroup: false,
+ deleteWhen: '04',
+ fileReservedTime: 72,
+ flushDiskType: 'SYNC_FLUSH',
+ brokerPermission: 6,
+ },
+ topicCount: 256,
+ groupCount: 128,
+ tpsHistory: [
+ 18200, 19800, 21400, 20100, 23680, 24100, 22500, 25800, 24600, 23200, 22800, 24500,
+ ],
+ },
+ {
+ id: 'cluster-pre',
+ name: 'rocketmq-pre',
+ nsClusterName: 'ns-pre',
+ type: 'V5_PROXY_CLUSTER',
+ endpoint: '10.102.5.1:9876',
+ status: 'healthy',
+ version: '5.2.0',
+ brokers: [
+ {
+ name: 'rocketmq-pre-0',
+ addr: '10.102.5.11:10911',
+ version: '5.2.0',
+ status: 'running',
+ diskUsage: 35,
+ tpsIn: 2400,
+ tpsOut: 6800,
+ },
+ {
+ name: 'rocketmq-pre-1',
+ addr: '10.102.5.12:10911',
+ version: '5.2.0',
+ status: 'running',
+ diskUsage: 28,
+ tpsIn: 2100,
+ tpsOut: 5600,
+ },
+ {
+ name: 'rocketmq-pre-2',
+ addr: '10.102.5.13:10911',
+ version: '5.2.0',
+ status: 'running',
+ diskUsage: 42,
+ tpsIn: 1800,
+ tpsOut: 4800,
+ },
+ {
+ name: 'rocketmq-pre-3',
+ addr: '10.102.5.14:10911',
+ version: '5.2.0',
+ status: 'running',
+ diskUsage: 31,
+ tpsIn: 1500,
+ tpsOut: 4200,
+ },
+ ],
+ proxies: [
+ {
+ addr: '10.102.5.21:8081',
+ status: 'healthy',
+ connections: 424,
+ grpcPort: 8081,
+ remotingPort: 8080,
+ },
+ {
+ addr: '10.102.5.22:8081',
+ status: 'healthy',
+ connections: 382,
+ grpcPort: 8081,
+ remotingPort: 8080,
+ },
+ ],
+ nameServers: [
+ { addr: '10.102.5.1:9876', status: 'healthy' },
+ { addr: '10.102.5.2:9876', status: 'healthy' },
+ ],
+ config: {
+ writeQueueNums: 8,
+ readQueueNums: 8,
+ maxMessageSize: 4194304,
+ msgTraceTopicName: 'RMQ_SYS_TRACE_TOPIC4',
+ autoCreateTopicEnable: true,
+ autoCreateSubscriptionGroup: true,
+ deleteWhen: '04',
+ fileReservedTime: 24,
+ flushDiskType: 'ASYNC_FLUSH',
+ brokerPermission: 6,
+ },
+ topicCount: 48,
+ groupCount: 24,
+ tpsHistory: [5200, 5800, 6400, 6000, 7200, 6800, 6200, 7000, 7400, 6800, 7200, 7600],
+ },
+];
+
+export default clusters;
diff --git a/web/src/mock/consumers.ts b/web/src/mock/consumers.ts
new file mode 100644
index 0000000..0d80508
--- /dev/null
+++ b/web/src/mock/consumers.ts
@@ -0,0 +1,1070 @@
+/*
+ * 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.
+ */
+
+/* ─── Types ─── */
+
+export interface ConsumerInstance {
+ clientId: string;
+ protocol: 'REMOTING' | 'GRPC';
+ address: string;
+ subscribedTopics: string[];
+ lastHeartbeat: string;
+ topicLag: Record<string, number>;
+}
+
+export interface ConsumerGroup {
+ name: string;
+ namespace: string;
+ clusterId: string;
+ subscriptionMode: 'Push' | 'Pop';
+ consumeType: 'CLUSTERING' | 'BROADCASTING';
+ onlineInstances: number;
+ totalLag: number;
+ subscribedTopics: string[];
+ subscriptionDataType: 'NORMAL' | 'FIFO' | 'DELAY' | 'TRANSACTION';
+ deliveryOrderType?: 'PARTITON_ORDER' | 'MESSAGES_ORDER';
+ retryMaxTimes: number;
+ createdAt: string;
+ updatedAt: string;
+ delaySeconds: number;
+ instances: ConsumerInstance[];
+}
+
+export interface QueueProgress {
+ broker: string;
+ queueId: number;
+ brokerOffset: number;
+ consumerOffset: number;
+ diffTotal: number;
+}
+
+export interface SubscriptionEntry {
+ topic: string;
+ expression: string;
+ type: 'NORMAL' | 'FIFO' | 'DELAY' | 'TRANSACTION';
+ filterMode: 'Tag 过滤' | 'SQL92 过滤' | '全量';
+ consistency: '一致' | '不一致';
+}
+
+/* ─── Mock Data ─── */
+
+export const mockConsumerGroups: ConsumerGroup[] = [
+ {
+ name: 'cg-order-notify',
+ namespace: 'trade',
+ clusterId: 'hz-prod',
+ subscriptionMode: 'Push',
+ consumeType: 'CLUSTERING',
+ onlineInstances: 6,
+ totalLag: 2340,
+ subscribedTopics: ['order-create', 'order-status-change', 'payment-callback'],
+ subscriptionDataType: 'NORMAL',
+ retryMaxTimes: 16,
+ createdAt: '2026-03-15 10:30:00',
+ updatedAt: '2026-06-28 15:20:00',
+ delaySeconds: 245,
+ instances: [
+ {
+ clientId: 'order-notify-0@10.0.1.12',
+ protocol: 'GRPC',
+ address: '10.0.1.12:49152',
+ subscribedTopics: ['order-create', 'order-status-change'],
+ lastHeartbeat: '2026-07-01 18:32:05',
+ topicLag: { 'order-create': 180, 'order-status-change': 95 },
+ },
+ {
+ clientId: 'order-notify-1@10.0.1.13',
+ protocol: 'GRPC',
+ address: '10.0.1.13:49200',
+ subscribedTopics: ['order-create', 'payment-callback'],
+ lastHeartbeat: '2026-07-01 18:32:03',
+ topicLag: { 'order-create': 210, 'payment-callback': 88 },
+ },
+ {
+ clientId: 'order-notify-2@10.0.1.14',
+ protocol: 'GRPC',
+ address: '10.0.1.14:49300',
+ subscribedTopics: ['order-status-change', 'payment-callback'],
+ lastHeartbeat: '2026-07-01 18:32:01',
+ topicLag: { 'order-status-change': 156, 'payment-callback': 42 },
+ },
+ {
+ clientId: 'order-notify-3@10.0.1.15',
+ protocol: 'REMOTING',
+ address: '10.0.1.15:49400',
+ subscribedTopics: ['order-create', 'order-status-change', 'payment-callback'],
+ lastHeartbeat: '2026-07-01 18:31:58',
+ topicLag: { 'order-create': 320, 'order-status-change': 201, 'payment-callback': 67 },
+ },
+ {
+ clientId: 'order-notify-4@10.0.1.16',
+ protocol: 'GRPC',
+ address: '10.0.1.16:49500',
+ subscribedTopics: ['order-create'],
+ lastHeartbeat: '2026-07-01 18:32:04',
+ topicLag: { 'order-create': 455 },
+ },
+ {
+ clientId: 'order-notify-5@10.0.1.17',
+ protocol: 'GRPC',
+ address: '10.0.1.17:49600',
+ subscribedTopics: ['order-status-change', 'payment-callback'],
+ lastHeartbeat: '2026-07-01 18:32:02',
+ topicLag: { 'order-status-change': 312, 'payment-callback': 214 },
+ },
+ ],
+ },
+ {
+ name: 'cg-payment-callback',
+ namespace: 'trade',
+ clusterId: 'hz-prod',
+ subscriptionMode: 'Push',
+ consumeType: 'CLUSTERING',
+ onlineInstances: 4,
+ totalLag: 128,
+ subscribedTopics: ['payment-callback', 'refund-event'],
+ subscriptionDataType: 'TRANSACTION',
+ retryMaxTimes: 8,
+ createdAt: '2026-02-20 14:15:00',
+ updatedAt: '2026-06-15 10:45:00',
+ delaySeconds: 50,
+ instances: [
+ {
+ clientId: 'payment-cb-0@10.0.2.10',
+ protocol: 'GRPC',
+ address: '10.0.2.10:50100',
+ subscribedTopics: ['payment-callback', 'refund-event'],
+ lastHeartbeat: '2026-07-01 18:32:00',
+ topicLag: { 'payment-callback': 35, 'refund-event': 12 },
+ },
+ {
+ clientId: 'payment-cb-1@10.0.2.11',
+ protocol: 'GRPC',
+ address: '10.0.2.11:50200',
+ subscribedTopics: ['payment-callback'],
+ lastHeartbeat: '2026-07-01 18:31:58',
+ topicLag: { 'payment-callback': 28 },
+ },
+ {
+ clientId: 'payment-cb-2@10.0.2.12',
+ protocol: 'GRPC',
+ address: '10.0.2.12:50300',
+ subscribedTopics: ['refund-event'],
+ lastHeartbeat: '2026-07-01 18:32:02',
+ topicLag: { 'refund-event': 22 },
+ },
+ {
+ clientId: 'payment-cb-3@10.0.2.13',
+ protocol: 'REMOTING',
+ address: '10.0.2.13:50400',
+ subscribedTopics: ['payment-callback', 'refund-event'],
+ lastHeartbeat: '2026-07-01 18:31:55',
+ topicLag: { 'payment-callback': 18, 'refund-event': 13 },
+ },
+ ],
+ },
+ {
+ name: 'cg-user-activity',
+ namespace: 'user',
+ clusterId: 'hz-prod',
+ subscriptionMode: 'Push',
+ consumeType: 'BROADCASTING',
+ onlineInstances: 3,
+ totalLag: 56700,
+ subscribedTopics: ['user-activity-log', 'user-profile-change'],
+ subscriptionDataType: 'NORMAL',
+ retryMaxTimes: 16,
+ createdAt: '2026-01-10 09:00:00',
+ updatedAt: '2026-05-20 16:30:00',
+ delaySeconds: 3725,
+ instances: [
+ {
+ clientId: 'user-act-0@10.0.3.20',
+ protocol: 'GRPC',
+ address: '10.0.3.20:51100',
+ subscribedTopics: ['user-activity-log', 'user-profile-change'],
+ lastHeartbeat: '2026-07-01 18:31:50',
+ topicLag: { 'user-activity-log': 18500, 'user-profile-change': 420 },
+ },
+ {
+ clientId: 'user-act-1@10.0.3.21',
+ protocol: 'GRPC',
+ address: '10.0.3.21:51200',
+ subscribedTopics: ['user-activity-log'],
+ lastHeartbeat: '2026-07-01 18:31:48',
+ topicLag: { 'user-activity-log': 19200 },
+ },
+ {
+ clientId: 'user-act-2@10.0.3.22',
+ protocol: 'REMOTING',
+ address: '10.0.3.22:51300',
+ subscribedTopics: ['user-activity-log', 'user-profile-change'],
+ lastHeartbeat: '2026-07-01 18:31:52',
+ topicLag: { 'user-activity-log': 17800, 'user-profile-change': 780 },
+ },
+ ],
+ },
+ {
+ name: 'cg-inventory-sync',
+ namespace: 'supply',
+ clusterId: 'sh-prod',
+ subscriptionMode: 'Push',
+ consumeType: 'CLUSTERING',
+ onlineInstances: 2,
+ totalLag: 890,
+ subscribedTopics: ['inventory-sync', 'stock-alert'],
+ subscriptionDataType: 'FIFO',
+ deliveryOrderType: 'PARTITON_ORDER',
+ retryMaxTimes: 3,
+ createdAt: '2026-04-05 11:20:00',
+ updatedAt: '2026-06-30 08:10:00',
+ delaySeconds: 720,
+ instances: [
+ {
+ clientId: 'inv-sync-0@10.0.4.30',
+ protocol: 'GRPC',
+ address: '10.0.4.30:52100',
+ subscribedTopics: ['inventory-sync', 'stock-alert'],
+ lastHeartbeat: '2026-07-01 18:31:45',
+ topicLag: { 'inventory-sync': 520, 'stock-alert': 85 },
+ },
+ {
+ clientId: 'inv-sync-1@10.0.4.31',
+ protocol: 'GRPC',
+ address: '10.0.4.31:52200',
+ subscribedTopics: ['inventory-sync'],
+ lastHeartbeat: '2026-07-01 18:31:42',
+ topicLag: { 'inventory-sync': 285 },
+ },
+ ],
+ },
+ {
+ name: 'cg-log-collector',
+ namespace: 'infra',
+ clusterId: 'hz-prod',
+ subscriptionMode: 'Pop',
+ consumeType: 'CLUSTERING',
+ onlineInstances: 8,
+ totalLag: 12400,
+ subscribedTopics: ['app-log', 'access-log', 'error-log'],
+ subscriptionDataType: 'NORMAL',
+ retryMaxTimes: 5,
+ createdAt: '2026-05-12 16:00:00',
+ updatedAt: '2026-07-01 12:00:00',
+ delaySeconds: 1850,
+ instances: [
+ {
+ clientId: 'log-col-0@10.0.5.40',
+ protocol: 'GRPC',
+ address: '10.0.5.40:53100',
+ subscribedTopics: ['app-log', 'access-log'],
+ lastHeartbeat: '2026-07-01 18:32:08',
+ topicLag: { 'app-log': 1850, 'access-log': 620 },
+ },
+ {
+ clientId: 'log-col-1@10.0.5.41',
+ protocol: 'GRPC',
+ address: '10.0.5.41:53200',
+ subscribedTopics: ['app-log', 'error-log'],
+ lastHeartbeat: '2026-07-01 18:32:06',
+ topicLag: { 'app-log': 1720, 'error-log': 310 },
+ },
+ {
+ clientId: 'log-col-2@10.0.5.42',
+ protocol: 'GRPC',
+ address: '10.0.5.42:53300',
+ subscribedTopics: ['access-log', 'error-log'],
+ lastHeartbeat: '2026-07-01 18:32:04',
+ topicLag: { 'access-log': 1450, 'error-log': 280 },
+ },
+ {
+ clientId: 'log-col-3@10.0.5.43',
+ protocol: 'GRPC',
+ address: '10.0.5.43:53400',
+ subscribedTopics: ['app-log'],
+ lastHeartbeat: '2026-07-01 18:32:02',
+ topicLag: { 'app-log': 1980 },
+ },
+ {
+ clientId: 'log-col-4@10.0.5.44',
+ protocol: 'GRPC',
+ address: '10.0.5.44:53500',
+ subscribedTopics: ['access-log'],
+ lastHeartbeat: '2026-07-01 18:32:00',
+ topicLag: { 'access-log': 1100 },
+ },
+ {
+ clientId: 'log-col-5@10.0.5.45',
+ protocol: 'GRPC',
+ address: '10.0.5.45:53600',
+ subscribedTopics: ['error-log'],
+ lastHeartbeat: '2026-07-01 18:31:58',
+ topicLag: { 'error-log': 420 },
+ },
+ {
+ clientId: 'log-col-6@10.0.5.46',
+ protocol: 'REMOTING',
+ address: '10.0.5.46:53700',
+ subscribedTopics: ['app-log', 'access-log', 'error-log'],
+ lastHeartbeat: '2026-07-01 18:31:55',
+ topicLag: { 'app-log': 950, 'access-log': 580, 'error-log': 190 },
+ },
+ {
+ clientId: 'log-col-7@10.0.5.47',
+ protocol: 'GRPC',
+ address: '10.0.5.47:53800',
+ subscribedTopics: ['app-log', 'error-log'],
+ lastHeartbeat: '2026-07-01 18:31:52',
+ topicLag: { 'app-log': 820, 'error-log': 130 },
+ },
+ ],
+ },
+ {
+ name: 'cg-notification-push',
+ namespace: 'message',
+ clusterId: 'hz-prod',
+ subscriptionMode: 'Push',
+ consumeType: 'CLUSTERING',
+ onlineInstances: 3,
+ totalLag: 45,
+ subscribedTopics: ['notification-push', 'sms-gateway'],
+ subscriptionDataType: 'DELAY',
+ retryMaxTimes: 8,
+ createdAt: '2026-03-28 08:45:00',
+ updatedAt: '2026-06-22 14:30:00',
+ delaySeconds: 8,
+ instances: [
+ {
+ clientId: 'notif-push-0@10.0.6.50',
+ protocol: 'GRPC',
+ address: '10.0.6.50:54100',
+ subscribedTopics: ['notification-push'],
+ lastHeartbeat: '2026-07-01 18:32:10',
+ topicLag: { 'notification-push': 12 },
+ },
+ {
+ clientId: 'notif-push-1@10.0.6.51',
+ protocol: 'GRPC',
+ address: '10.0.6.51:54200',
+ subscribedTopics: ['notification-push', 'sms-gateway'],
+ lastHeartbeat: '2026-07-01 18:32:08',
+ topicLag: { 'notification-push': 8, 'sms-gateway': 15 },
+ },
+ {
+ clientId: 'notif-push-2@10.0.6.52',
+ protocol: 'GRPC',
+ address: '10.0.6.52:54300',
+ subscribedTopics: ['sms-gateway'],
+ lastHeartbeat: '2026-07-01 18:32:06',
+ topicLag: { 'sms-gateway': 10 },
+ },
+ ],
+ },
+ {
+ name: 'cg-ai-task-worker',
+ namespace: 'ai',
+ clusterId: 'hz-prod',
+ subscriptionMode: 'Pop',
+ consumeType: 'CLUSTERING',
+ onlineInstances: 4,
+ totalLag: 3200,
+ subscribedTopics: ['ai-task-dispatch', 'model-inference-request'],
+ subscriptionDataType: 'NORMAL',
+ retryMaxTimes: 3,
+ createdAt: '2026-06-01 13:30:00',
+ updatedAt: '2026-07-01 09:15:00',
+ delaySeconds: 4800,
+ instances: [
+ {
+ clientId: 'ai-worker-0@10.0.7.60',
+ protocol: 'GRPC',
+ address: '10.0.7.60:55100',
+ subscribedTopics: ['ai-task-dispatch', 'model-inference-request'],
+ lastHeartbeat: '2026-07-01 18:32:12',
+ topicLag: { 'ai-task-dispatch': 450, 'model-inference-request': 380 },
+ },
+ {
+ clientId: 'ai-worker-1@10.0.7.61',
+ protocol: 'GRPC',
+ address: '10.0.7.61:55200',
+ subscribedTopics: ['ai-task-dispatch'],
+ lastHeartbeat: '2026-07-01 18:32:10',
+ topicLag: { 'ai-task-dispatch': 620 },
+ },
+ {
+ clientId: 'ai-worker-2@10.0.7.62',
+ protocol: 'GRPC',
+ address: '10.0.7.62:55300',
+ subscribedTopics: ['model-inference-request'],
+ lastHeartbeat: '2026-07-01 18:32:08',
+ topicLag: { 'model-inference-request': 850 },
+ },
+ {
+ clientId: 'ai-worker-3@10.0.7.63',
+ protocol: 'GRPC',
+ address: '10.0.7.63:55400',
+ subscribedTopics: ['ai-task-dispatch', 'model-inference-request'],
+ lastHeartbeat: '2026-07-01 18:32:06',
+ topicLag: { 'ai-task-dispatch': 380, 'model-inference-request': 520 },
+ },
+ ],
+ },
+ {
+ name: 'cg-metrics-aggregator',
+ namespace: 'infra',
+ clusterId: 'sh-prod',
+ subscriptionMode: 'Push',
+ consumeType: 'CLUSTERING',
+ onlineInstances: 2,
+ totalLag: 15800,
+ subscribedTopics: ['metrics-raw', 'trace-span'],
+ subscriptionDataType: 'NORMAL',
+ retryMaxTimes: 16,
+ createdAt: '2026-02-08 07:00:00',
+ updatedAt: '2026-06-10 11:45:00',
+ delaySeconds: 82500,
+ instances: [
+ {
+ clientId: 'metrics-agg-0@10.0.8.70',
+ protocol: 'GRPC',
+ address: '10.0.8.70:56100',
+ subscribedTopics: ['metrics-raw', 'trace-span'],
+ lastHeartbeat: '2026-07-01 18:31:40',
+ topicLag: { 'metrics-raw': 8500, 'trace-span': 3200 },
+ },
+ {
+ clientId: 'metrics-agg-1@10.0.8.71',
+ protocol: 'REMOTING',
+ address: '10.0.8.71:56200',
+ subscribedTopics: ['metrics-raw', 'trace-span'],
+ lastHeartbeat: '2026-07-01 18:31:38',
+ topicLag: { 'metrics-raw': 2800, 'trace-span': 1300 },
+ },
+ ],
+ },
+ {
+ name: 'cg-risk-control',
+ namespace: 'risk',
+ clusterId: 'hz-prod',
+ subscriptionMode: 'Push',
+ consumeType: 'CLUSTERING',
+ onlineInstances: 5,
+ totalLag: 4560,
+ subscribedTopics: ['transaction-event', 'login-event', 'payment-callback'],
+ subscriptionDataType: 'NORMAL',
+ retryMaxTimes: 8,
+ createdAt: '2025-11-20 10:00:00',
+ updatedAt: '2026-06-05 17:20:00',
+ delaySeconds: 1520,
+ instances: [
+ {
+ clientId: 'risk-ctrl-0@10.0.9.80',
+ protocol: 'GRPC',
+ address: '10.0.9.80:57100',
+ subscribedTopics: ['transaction-event', 'login-event'],
+ lastHeartbeat: '2026-07-01 18:32:15',
+ topicLag: { 'transaction-event': 520, 'login-event': 380 },
+ },
+ {
+ clientId: 'risk-ctrl-1@10.0.9.81',
+ protocol: 'GRPC',
+ address: '10.0.9.81:57200',
+ subscribedTopics: ['transaction-event', 'payment-callback'],
+ lastHeartbeat: '2026-07-01 18:32:13',
+ topicLag: { 'transaction-event': 480, 'payment-callback': 290 },
+ },
+ {
+ clientId: 'risk-ctrl-2@10.0.9.82',
+ protocol: 'GRPC',
+ address: '10.0.9.82:57300',
+ subscribedTopics: ['login-event', 'payment-callback'],
+ lastHeartbeat: '2026-07-01 18:32:11',
+ topicLag: { 'login-event': 410, 'payment-callback': 350 },
+ },
+ {
+ clientId: 'risk-ctrl-3@10.0.9.83',
+ protocol: 'GRPC',
+ address: '10.0.9.83:57400',
+ subscribedTopics: ['transaction-event', 'login-event', 'payment-callback'],
+ lastHeartbeat: '2026-07-01 18:32:09',
+ topicLag: { 'transaction-event': 600, 'login-event': 420, 'payment-callback': 310 },
+ },
+ {
+ clientId: 'risk-ctrl-4@10.0.9.84',
+ protocol: 'REMOTING',
+ address: '10.0.9.84:57500',
+ subscribedTopics: ['transaction-event'],
+ lastHeartbeat: '2026-07-01 18:32:07',
+ topicLag: { 'transaction-event': 800 },
+ },
+ ],
+ },
+ {
+ name: 'cg-data-sync',
+ namespace: 'data',
+ clusterId: 'sh-prod',
+ subscriptionMode: 'Push',
+ consumeType: 'CLUSTERING',
+ onlineInstances: 3,
+ totalLag: 18700,
+ subscribedTopics: ['binlog-event', 'schema-change'],
+ subscriptionDataType: 'FIFO',
+ deliveryOrderType: 'PARTITON_ORDER',
+ retryMaxTimes: 5,
+ createdAt: '2025-09-15 08:30:00',
+ updatedAt: '2026-06-28 20:00:00',
+ delaySeconds: 12600,
+ instances: [
+ {
+ clientId: 'data-sync-0@10.0.10.90',
+ protocol: 'GRPC',
+ address: '10.0.10.90:58100',
+ subscribedTopics: ['binlog-event', 'schema-change'],
+ lastHeartbeat: '2026-07-01 18:31:55',
+ topicLag: { 'binlog-event': 8500, 'schema-change': 120 },
+ },
+ {
+ clientId: 'data-sync-1@10.0.10.91',
+ protocol: 'GRPC',
+ address: '10.0.10.91:58200',
+ subscribedTopics: ['binlog-event'],
+ lastHeartbeat: '2026-07-01 18:31:53',
+ topicLag: { 'binlog-event': 9200 },
+ },
+ {
+ clientId: 'data-sync-2@10.0.10.92',
+ protocol: 'GRPC',
+ address: '10.0.10.92:58300',
+ subscribedTopics: ['binlog-event', 'schema-change'],
+ lastHeartbeat: '2026-07-01 18:31:50',
+ topicLag: { 'binlog-event': 7800, 'schema-change': 80 },
+ },
+ ],
+ },
+];
+
+/* ─── Queue Progress (per group) ─── */
+
+export const mockQueueProgress: Record<string, QueueProgress[]> = {
+ 'cg-order-notify': [
+ {
+ broker: 'broker-hz-0',
+ queueId: 0,
+ brokerOffset: 1_580_420,
+ consumerOffset: 1_579_850,
+ diffTotal: 570,
+ },
+ {
+ broker: 'broker-hz-0',
+ queueId: 1,
+ brokerOffset: 1_579_200,
+ consumerOffset: 1_578_900,
+ diffTotal: 300,
+ },
+ {
+ broker: 'broker-hz-0',
+ queueId: 2,
+ brokerOffset: 1_578_800,
+ consumerOffset: 1_578_380,
+ diffTotal: 420,
+ },
+ {
+ broker: 'broker-hz-1',
+ queueId: 0,
+ brokerOffset: 1_420_300,
+ consumerOffset: 1_419_950,
+ diffTotal: 350,
+ },
+ {
+ broker: 'broker-hz-1',
+ queueId: 1,
+ brokerOffset: 1_419_800,
+ consumerOffset: 1_419_450,
+ diffTotal: 350,
+ },
+ {
+ broker: 'broker-hz-1',
+ queueId: 2,
+ brokerOffset: 1_418_500,
+ consumerOffset: 1_418_150,
+ diffTotal: 350,
+ },
+ ],
+ 'cg-payment-callback': [
+ {
+ broker: 'broker-hz-0',
+ queueId: 0,
+ brokerOffset: 892_100,
+ consumerOffset: 892_068,
+ diffTotal: 32,
+ },
+ {
+ broker: 'broker-hz-0',
+ queueId: 1,
+ brokerOffset: 891_500,
+ consumerOffset: 891_468,
+ diffTotal: 32,
+ },
+ {
+ broker: 'broker-hz-1',
+ queueId: 0,
+ brokerOffset: 780_200,
+ consumerOffset: 780_168,
+ diffTotal: 32,
+ },
+ {
+ broker: 'broker-hz-1',
+ queueId: 1,
+ brokerOffset: 779_800,
+ consumerOffset: 779_768,
+ diffTotal: 32,
+ },
+ ],
+ 'cg-user-activity': [
+ {
+ broker: 'broker-hz-0',
+ queueId: 0,
+ brokerOffset: 5_680_000,
+ consumerOffset: 5_661_200,
+ diffTotal: 18800,
+ },
+ {
+ broker: 'broker-hz-0',
+ queueId: 1,
+ brokerOffset: 5_675_000,
+ consumerOffset: 5_655_500,
+ diffTotal: 19500,
+ },
+ {
+ broker: 'broker-hz-1',
+ queueId: 0,
+ brokerOffset: 4_920_000,
+ consumerOffset: 4_910_700,
+ diffTotal: 9300,
+ },
+ {
+ broker: 'broker-hz-1',
+ queueId: 1,
+ brokerOffset: 4_915_000,
+ consumerOffset: 4_905_900,
+ diffTotal: 9100,
+ },
+ ],
+ 'cg-inventory-sync': [
+ {
+ broker: 'broker-sh-0',
+ queueId: 0,
+ brokerOffset: 320_800,
+ consumerOffset: 320_355,
+ diffTotal: 445,
+ },
+ {
+ broker: 'broker-sh-0',
+ queueId: 1,
+ brokerOffset: 320_100,
+ consumerOffset: 319_655,
+ diffTotal: 445,
+ },
+ ],
+ 'cg-log-collector': [
+ {
+ broker: 'broker-hz-0',
+ queueId: 0,
+ brokerOffset: 12_800_000,
+ consumerOffset: 12_798_450,
+ diffTotal: 1550,
+ },
+ {
+ broker: 'broker-hz-0',
+ queueId: 1,
+ brokerOffset: 12_790_000,
+ consumerOffset: 12_788_300,
+ diffTotal: 1700,
+ },
+ {
+ broker: 'broker-hz-0',
+ queueId: 2,
+ brokerOffset: 12_780_000,
+ consumerOffset: 12_778_600,
+ diffTotal: 1400,
+ },
+ {
+ broker: 'broker-hz-0',
+ queueId: 3,
+ brokerOffset: 12_770_000,
+ consumerOffset: 12_768_700,
+ diffTotal: 1300,
+ },
+ {
+ broker: 'broker-hz-1',
+ queueId: 0,
+ brokerOffset: 11_200_000,
+ consumerOffset: 11_198_450,
+ diffTotal: 1550,
+ },
+ {
+ broker: 'broker-hz-1',
+ queueId: 1,
+ brokerOffset: 11_190_000,
+ consumerOffset: 11_188_450,
+ diffTotal: 1550,
+ },
+ {
+ broker: 'broker-hz-2',
+ queueId: 0,
+ brokerOffset: 9_800_000,
+ consumerOffset: 9_798_200,
+ diffTotal: 1800,
+ },
+ {
+ broker: 'broker-hz-2',
+ queueId: 1,
+ brokerOffset: 9_790_000,
+ consumerOffset: 9_788_450,
+ diffTotal: 1550,
+ },
+ ],
+ 'cg-notification-push': [
+ {
+ broker: 'broker-hz-0',
+ queueId: 0,
+ brokerOffset: 450_200,
+ consumerOffset: 450_185,
+ diffTotal: 15,
+ },
+ {
+ broker: 'broker-hz-0',
+ queueId: 1,
+ brokerOffset: 449_800,
+ consumerOffset: 449_785,
+ diffTotal: 15,
+ },
+ {
+ broker: 'broker-hz-1',
+ queueId: 0,
+ brokerOffset: 380_100,
+ consumerOffset: 380_092,
+ diffTotal: 8,
+ },
+ {
+ broker: 'broker-hz-1',
+ queueId: 1,
+ brokerOffset: 379_800,
+ consumerOffset: 379_793,
+ diffTotal: 7,
+ },
+ ],
+ 'cg-ai-task-worker': [
+ {
+ broker: 'broker-hz-0',
+ queueId: 0,
+ brokerOffset: 240_800,
+ consumerOffset: 240_000,
+ diffTotal: 800,
+ },
+ {
+ broker: 'broker-hz-0',
+ queueId: 1,
+ brokerOffset: 240_200,
+ consumerOffset: 239_400,
+ diffTotal: 800,
+ },
+ {
+ broker: 'broker-hz-1',
+ queueId: 0,
+ brokerOffset: 210_600,
+ consumerOffset: 209_800,
+ diffTotal: 800,
+ },
+ {
+ broker: 'broker-hz-1',
+ queueId: 1,
+ brokerOffset: 210_000,
+ consumerOffset: 209_200,
+ diffTotal: 800,
+ },
+ ],
+ 'cg-metrics-aggregator': [
+ {
+ broker: 'broker-sh-0',
+ queueId: 0,
+ brokerOffset: 8_900_000,
+ consumerOffset: 8_892_100,
+ diffTotal: 7900,
+ },
+ {
+ broker: 'broker-sh-0',
+ queueId: 1,
+ brokerOffset: 8_890_000,
+ consumerOffset: 8_882_100,
+ diffTotal: 7900,
+ },
+ ],
+ 'cg-risk-control': [
+ {
+ broker: 'broker-hz-0',
+ queueId: 0,
+ brokerOffset: 3_200_000,
+ consumerOffset: 3_199_100,
+ diffTotal: 900,
+ },
+ {
+ broker: 'broker-hz-0',
+ queueId: 1,
+ brokerOffset: 3_198_000,
+ consumerOffset: 3_197_200,
+ diffTotal: 800,
+ },
+ {
+ broker: 'broker-hz-1',
+ queueId: 0,
+ brokerOffset: 2_800_000,
+ consumerOffset: 2_799_060,
+ diffTotal: 940,
+ },
+ {
+ broker: 'broker-hz-1',
+ queueId: 1,
+ brokerOffset: 2_795_000,
+ consumerOffset: 2_794_080,
+ diffTotal: 920,
+ },
+ {
+ broker: 'broker-hz-2',
+ queueId: 0,
+ brokerOffset: 2_400_000,
+ consumerOffset: 2_399_000,
+ diffTotal: 1000,
+ },
+ ],
+ 'cg-data-sync': [
+ {
+ broker: 'broker-sh-0',
+ queueId: 0,
+ brokerOffset: 6_500_000,
+ consumerOffset: 6_493_500,
+ diffTotal: 6500,
+ },
+ {
+ broker: 'broker-sh-0',
+ queueId: 1,
+ brokerOffset: 6_490_000,
+ consumerOffset: 6_483_800,
+ diffTotal: 6200,
+ },
+ {
+ broker: 'broker-sh-1',
+ queueId: 0,
+ brokerOffset: 5_800_000,
+ consumerOffset: 5_797_000,
+ diffTotal: 3000,
+ },
+ {
+ broker: 'broker-sh-1',
+ queueId: 1,
+ brokerOffset: 5_795_000,
+ consumerOffset: 5_792_000,
+ diffTotal: 3000,
+ },
+ ],
+};
+
+/* ─── Subscription Details (per group) ─── */
+
+export const mockSubscriptions: Record<string, SubscriptionEntry[]> = {
+ 'cg-order-notify': [
+ {
+ topic: 'order-create',
+ expression: '*',
+ type: 'NORMAL',
+ filterMode: '全量',
+ consistency: '一致',
+ },
+ {
+ topic: 'order-status-change',
+ expression: 'STATUS_PAID || STATUS_SHIPPED',
+ type: 'NORMAL',
+ filterMode: 'Tag 过滤',
+ consistency: '一致',
+ },
+ {
+ topic: 'payment-callback',
+ expression: '*',
+ type: 'TRANSACTION',
+ filterMode: '全量',
+ consistency: '不一致',
+ },
+ ],
+ 'cg-payment-callback': [
+ {
+ topic: 'payment-callback',
+ expression: '*',
+ type: 'TRANSACTION',
+ filterMode: '全量',
+ consistency: '一致',
+ },
+ {
+ topic: 'refund-event',
+ expression: 'AMOUNT > 100',
+ type: 'TRANSACTION',
+ filterMode: 'SQL92 过滤',
+ consistency: '一致',
+ },
+ ],
+ 'cg-user-activity': [
+ {
+ topic: 'user-activity-log',
+ expression: '*',
+ type: 'NORMAL',
+ filterMode: '全量',
+ consistency: '一致',
+ },
+ {
+ topic: 'user-profile-change',
+ expression: '*',
+ type: 'NORMAL',
+ filterMode: '全量',
+ consistency: '一致',
+ },
+ ],
+ 'cg-inventory-sync': [
+ {
+ topic: 'inventory-sync',
+ expression: '*',
+ type: 'FIFO',
+ filterMode: '全量',
+ consistency: '一致',
+ },
+ {
+ topic: 'stock-alert',
+ expression: 'LEVEL < 10',
+ type: 'FIFO',
+ filterMode: 'SQL92 过滤',
+ consistency: '一致',
+ },
+ ],
+ 'cg-log-collector': [
+ {
+ topic: 'app-log',
+ expression: 'LEVEL in (ERROR, WARN)',
+ type: 'NORMAL',
+ filterMode: 'SQL92 过滤',
+ consistency: '一致',
+ },
+ {
+ topic: 'access-log',
+ expression: '*',
+ type: 'NORMAL',
+ filterMode: '全量',
+ consistency: '不一致',
+ },
+ {
+ topic: 'error-log',
+ expression: '*',
+ type: 'NORMAL',
+ filterMode: '全量',
+ consistency: '一致',
+ },
+ ],
+ 'cg-notification-push': [
+ {
+ topic: 'notification-push',
+ expression: 'PRIORITY >= 3',
+ type: 'DELAY',
+ filterMode: 'SQL92 过滤',
+ consistency: '一致',
+ },
+ {
+ topic: 'sms-gateway',
+ expression: '*',
+ type: 'DELAY',
+ filterMode: '全量',
+ consistency: '一致',
+ },
+ ],
+ 'cg-ai-task-worker': [
+ {
+ topic: 'ai-task-dispatch',
+ expression: 'MODEL in (GPT, CLAUDE)',
+ type: 'NORMAL',
+ filterMode: 'SQL92 过滤',
+ consistency: '一致',
+ },
+ {
+ topic: 'model-inference-request',
+ expression: '*',
+ type: 'NORMAL',
+ filterMode: '全量',
+ consistency: '一致',
+ },
+ ],
+ 'cg-metrics-aggregator': [
+ {
+ topic: 'metrics-raw',
+ expression: '*',
+ type: 'NORMAL',
+ filterMode: '全量',
+ consistency: '一致',
+ },
+ {
+ topic: 'trace-span',
+ expression: 'DURATION > 500',
+ type: 'NORMAL',
+ filterMode: 'SQL92 过滤',
+ consistency: '不一致',
+ },
+ ],
+ 'cg-risk-control': [
+ {
+ topic: 'transaction-event',
+ expression: 'AMOUNT > 5000',
+ type: 'NORMAL',
+ filterMode: 'SQL92 过滤',
+ consistency: '一致',
+ },
+ {
+ topic: 'login-event',
+ expression: '*',
+ type: 'NORMAL',
+ filterMode: '全量',
+ consistency: '一致',
+ },
+ {
+ topic: 'payment-callback',
+ expression: 'RISK_LEVEL >= 2',
+ type: 'NORMAL',
+ filterMode: 'SQL92 过滤',
+ consistency: '一致',
+ },
+ ],
+ 'cg-data-sync': [
+ {
+ topic: 'binlog-event',
+ expression: '*',
+ type: 'FIFO',
+ filterMode: '全量',
+ consistency: '一致',
+ },
+ {
+ topic: 'schema-change',
+ expression: 'DDL',
+ type: 'FIFO',
+ filterMode: 'Tag 过滤',
+ consistency: '一致',
+ },
+ ],
+};
diff --git a/web/src/mock/dashboard.ts b/web/src/mock/dashboard.ts
new file mode 100644
index 0000000..64ba88a
--- /dev/null
+++ b/web/src/mock/dashboard.ts
@@ -0,0 +1,155 @@
+/*
+ * 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.
+ */
+
+export const dashboardStats = {
+ totalClusters: 2,
+ healthyClusters: 2,
+ totalBrokers: 12,
+ totalProxies: 5,
+ totalNameServers: 5,
+ totalTopics: 304,
+ totalConsumerGroups: 152,
+ totalMessagesToday: 18_420_000,
+ messagesPerSecond: 85_800,
+ tpsIn: 85_800,
+ tpsOut: 222_400,
+};
+
+export const clusterOverview = [
+ {
+ id: 'cluster-prod',
+ name: 'rocketmq-prod',
+ type: 'V5_PROXY_CLUSTER',
+ status: 'healthy' as const,
+ brokers: 8,
+ proxies: 3,
+ topics: 256,
+ groups: 128,
+ tpsIn: 78_000,
+ tpsOut: 203_600,
+ version: '5.2.0',
+ throughput: [
+ 62000, 68000, 74000, 71000, 78000, 82000, 79000, 85000, 81000, 78000, 76000, 80000,
+ ],
+ },
+ {
+ id: 'cluster-pre',
+ name: 'rocketmq-pre',
+ type: 'V5_PROXY_CLUSTER',
+ status: 'healthy' as const,
+ brokers: 4,
+ proxies: 2,
+ topics: 48,
+ groups: 24,
+ tpsIn: 7_800,
+ tpsOut: 21_400,
+ version: '5.2.0',
+ throughput: [5200, 5800, 6400, 6000, 7200, 6800, 6200, 7000, 7400, 6800, 7200, 7600],
+ },
+];
+
+export const recentActivities = [
+ {
+ time: '18:32:05',
+ type: 'topic',
+ message: 'Topic order-payment 创建于 rocketmq-prod',
+ color: 'blue',
+ },
+ {
+ time: '18:28:17',
+ type: 'consumer',
+ message: '消费者组 cg-order-notify 重置消费位点至 1 小时前',
+ color: 'orange',
+ },
+ {
+ time: '18:15:42',
+ type: 'alert',
+ message: 'rocketmq-prod-5 磁盘使用率达到 86%,已切为只读',
+ color: 'red',
+ },
+ {
+ time: '17:58:30',
+ type: 'acl',
+ message: 'ACL 规则更新: user-order-service → Topic order-* [PUB, SUB]',
+ color: 'purple',
+ },
+ {
+ time: '17:42:11',
+ type: 'cluster',
+ message: 'rocketmq-prod-7 进入维护模式,禁止读写',
+ color: 'gold',
+ },
+ {
+ time: '17:30:00',
+ type: 'topic',
+ message: 'LiteTopic chat/sess-a3f8* 自动创建 128 个 (TTL: 24h)',
+ color: 'magenta',
+ },
+ {
+ time: '16:55:23',
+ type: 'consumer',
+ message: '消费者组 cg-payment-callback 新增 2 个在线实例',
+ color: 'cyan',
+ },
+ {
+ time: '16:20:45',
+ type: 'alert',
+ message: 'rocketmq-prod-7 磁盘使用率达到 91%,触发维护',
+ color: 'red',
+ },
+];
+
+export const topTopicsByThroughput = [
+ {
+ name: 'order-create',
+ namespace: 'trade',
+ type: 'TRANSACTION',
+ tps: 12480,
+ messages: 3_680_000,
+ },
+ { name: 'payment-notify', namespace: 'trade', type: 'NORMAL', tps: 11800, messages: 3_480_000 },
+ { name: 'user-login-event', namespace: 'user', type: 'NORMAL', tps: 10500, messages: 3_100_000 },
+ { name: 'inventory-sync', namespace: 'supply', type: 'FIFO', tps: 9800, messages: 2_890_000 },
+ { name: 'promo-push', namespace: 'marketing', type: 'DELAY', tps: 8600, messages: 2_540_000 },
+];
+
+export const systemAlerts = [
+ {
+ id: 'alert-1',
+ level: 'warning' as const,
+ title: 'rocketmq-prod-5 磁盘使用率偏高',
+ description: '磁盘使用率 86%,已自动切为只读模式',
+ time: '18:15',
+ acknowledged: false,
+ },
+ {
+ id: 'alert-2',
+ level: 'error' as const,
+ title: 'rocketmq-prod-7 进入维护模式',
+ description: '磁盘使用率 91%,已禁止读写,需人工介入处理',
+ time: '17:42',
+ acknowledged: false,
+ },
+ {
+ id: 'alert-3',
+ level: 'info' as const,
+ title: 'rocketmq-prod-7 版本落后',
+ description: '当前版本 5.1.4,集群版本 5.2.0,建议升级',
+ time: '16:30',
+ acknowledged: true,
+ },
+];
diff --git a/web/src/mock/dlq.ts b/web/src/mock/dlq.ts
new file mode 100644
index 0000000..c46a750
--- /dev/null
+++ b/web/src/mock/dlq.ts
@@ -0,0 +1,92 @@
+/*
+ * 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.
+ */
+
+export interface DLQGroup {
+ groupName: string;
+ dlqTopic: string;
+ messageCount: number;
+ lastEnqueueTime: string;
+ retryCount: number;
+ status: 'active' | 'empty';
+}
+
+export const mockDLQGroups: DLQGroup[] = [
+ {
+ groupName: 'cg-order-processor',
+ dlqTopic: '%DLQ%cg-order-processor',
+ messageCount: 23,
+ lastEnqueueTime: '2026-07-01T14:32:18.000Z',
+ retryCount: 16,
+ status: 'active',
+ },
+ {
+ groupName: 'cg-payment-handler',
+ dlqTopic: '%DLQ%cg-payment-handler',
+ messageCount: 8,
+ lastEnqueueTime: '2026-07-01T15:10:45.000Z',
+ retryCount: 16,
+ status: 'active',
+ },
+ {
+ groupName: 'cg-notification',
+ dlqTopic: '%DLQ%cg-notification',
+ messageCount: 156,
+ lastEnqueueTime: '2026-07-01T16:05:22.000Z',
+ retryCount: 16,
+ status: 'active',
+ },
+ {
+ groupName: 'cg-inventory-sync',
+ dlqTopic: '%DLQ%cg-inventory-sync',
+ messageCount: 3,
+ lastEnqueueTime: '2026-07-01T12:48:33.000Z',
+ retryCount: 16,
+ status: 'active',
+ },
+ {
+ groupName: 'cg-analytics',
+ dlqTopic: '%DLQ%cg-analytics',
+ messageCount: 42,
+ lastEnqueueTime: '2026-07-01T15:55:10.000Z',
+ retryCount: 16,
+ status: 'active',
+ },
+ {
+ groupName: 'cg-user-tracking',
+ dlqTopic: '%DLQ%cg-user-tracking',
+ messageCount: 0,
+ lastEnqueueTime: '2026-06-28T09:12:00.000Z',
+ retryCount: 16,
+ status: 'empty',
+ },
+ {
+ groupName: 'cg-sms-gateway',
+ dlqTopic: '%DLQ%cg-sms-gateway',
+ messageCount: 67,
+ lastEnqueueTime: '2026-07-01T16:20:55.000Z',
+ retryCount: 16,
+ status: 'active',
+ },
+ {
+ groupName: 'cg-email-service',
+ dlqTopic: '%DLQ%cg-email-service',
+ messageCount: 0,
+ lastEnqueueTime: '2026-06-30T18:05:12.000Z',
+ retryCount: 16,
+ status: 'empty',
+ },
+];
diff --git a/web/src/mock/instances.ts b/web/src/mock/instances.ts
new file mode 100644
index 0000000..bdf45f9
--- /dev/null
+++ b/web/src/mock/instances.ts
@@ -0,0 +1,76 @@
+/*
+ * 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 type { Instance } from '../api/instance';
+
+export const mockInstances: Instance[] = [
+ {
+ id: '1',
+ name: 'rocketmq-trade',
+ remark: '核心交易链路,承载订单、支付等主要业务',
+ type: 'PROXY',
+ endpoint: 'proxy-hz.rocketmq.internal:8080',
+ topicCount: 128,
+ consumerGroupCount: 56,
+ createdAt: '2024-03-15 08:30:00',
+ updatedAt: '2026-06-20 14:15:00',
+ },
+ {
+ id: '2',
+ name: 'rocketmq-dr',
+ remark: '灾备集群,与 trade 集群互为双活',
+ type: 'PROXY',
+ endpoint: 'proxy-sh.rocketmq.internal:8080',
+ topicCount: 96,
+ consumerGroupCount: 42,
+ createdAt: '2024-05-10 10:00:00',
+ updatedAt: '2026-06-18 09:30:00',
+ },
+ {
+ id: '3',
+ name: 'rocketmq-debug',
+ remark: '开发测试环境,仅供内部调试使用',
+ type: 'PROXY',
+ endpoint: 'localhost:8081',
+ topicCount: 15,
+ consumerGroupCount: 8,
+ createdAt: '2025-01-20 14:00:00',
+ updatedAt: '2026-07-01 11:45:00',
+ },
+ {
+ id: '4',
+ name: 'rocketmq-legacy',
+ remark: '旧版集群,计划 Q3 完成迁移后下线',
+ type: 'DIRECT',
+ endpoint: 'namesrv-legacy:9876',
+ topicCount: 64,
+ consumerGroupCount: 30,
+ createdAt: '2022-08-01 09:00:00',
+ updatedAt: '2025-12-10 16:20:00',
+ },
+ {
+ id: '5',
+ name: 'rocketmq-staging',
+ remark: '预发布验证环境,与生产配置一致',
+ type: 'PROXY',
+ endpoint: 'proxy-staging:8080',
+ topicCount: 32,
+ consumerGroupCount: 18,
+ createdAt: '2024-11-05 11:30:00',
+ updatedAt: '2026-06-25 08:00:00',
+ },
+];
diff --git a/web/src/mock/messages.ts b/web/src/mock/messages.ts
new file mode 100644
index 0000000..cd5351c
--- /dev/null
+++ b/web/src/mock/messages.ts
@@ -0,0 +1,1268 @@
+/*
+ * 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.
+ */
+
+export interface MessageRecord {
+ msgId: string;
+ topic: string;
+ tag: string;
+ key: string;
+ body: string;
+ storeTime: string;
+ bornHost: string;
+ storeHost: string;
+ properties: Record<string, string>;
+ size: number;
+}
+
+export interface TraceNode {
+ title: string;
+ timestamp: string;
+ status: 'finish' | 'process' | 'wait';
+ costTime: number;
+ description: string;
+}
+
+export interface ConsumerStatus {
+ group: string;
+ deliveryStatus: 'success' | 'failed' | 'pending';
+ consumeTime: string;
+ retryCount: number;
+}
+
+export interface TraceRecord {
+ nodes: TraceNode[];
+ consumerStatus: ConsumerStatus[];
+}
+
+export const mockMessages: MessageRecord[] = [
+ // ── order-create (5 messages) ──
+ {
+ msgId: 'AC1E0A6400002A9F0000000001A3F2B1',
+ topic: 'order-create',
+ tag: 'create',
+ key: 'order-12345',
+ body: '{"orderId":"20240101001","userId":10001,"items":[{"skuId":"SKU001","quantity":2,"price":99.50}],"totalAmount":199.00}',
+ storeTime: '2026-07-01T10:23:45.123Z',
+ bornHost: '10.0.1.12:54321',
+ storeHost: '10.0.2.3:10911',
+ properties: {
+ KEYS: 'order-12345',
+ TAGS: 'create',
+ UNIQ_KEY: 'AC1E0A6400002A9F0000000001A3F2B1',
+ WAIT: 'true',
+ MSG_REGION: 'DefaultRegion',
+ },
+ size: 3617,
+ },
+ {
+ msgId: 'AC1E0A6400002A9F0000000001A3F7C2',
+ topic: 'order-create',
+ tag: 'create',
+ key: 'order-12346',
+ body: '{"orderId":"20240101002","userId":10042,"items":[{"skuId":"SKU015","quantity":1,"price":59.00}],"totalAmount":59.00}',
+ storeTime: '2026-07-01T10:25:12.456Z',
+ bornHost: '10.0.1.12:54322',
+ storeHost: '10.0.2.3:10911',
+ properties: {
+ KEYS: 'order-12346',
+ TAGS: 'create',
+ UNIQ_KEY: 'AC1E0A6400002A9F0000000001A3F7C2',
+ WAIT: 'true',
+ MSG_REGION: 'DefaultRegion',
+ },
+ size: 486,
+ },
+ {
+ msgId: 'AC1E0A6400002A9F0000000001A401D3',
+ topic: 'order-create',
+ tag: 'create',
+ key: 'order-12347',
+ body: '{"orderId":"20240101003","userId":10099,"items":[{"skuId":"SKU008","quantity":3,"price":33.00},{"skuId":"SKU012","quantity":1,"price":128.00}],"totalAmount":227.00}',
+ storeTime: '2026-07-01T10:30:05.789Z',
+ bornHost: '10.0.1.13:54321',
+ storeHost: '10.0.2.4:10911',
+ properties: {
+ KEYS: 'order-12347',
+ TAGS: 'create',
+ UNIQ_KEY: 'AC1E0A6400002A9F0000000001A401D3',
+ WAIT: 'true',
+ MSG_REGION: 'DefaultRegion',
+ },
+ size: 1331200,
+ },
+ {
+ msgId: 'AC1E0A6400002A9F0000000001A40BE4',
+ topic: 'order-create',
+ tag: 'create',
+ key: 'order-12348',
+ body: '{"orderId":"20240101004","userId":10201,"items":[{"skuId":"SKU003","quantity":1,"price":1299.00}],"totalAmount":1299.00}',
+ storeTime: '2026-07-01T11:15:33.234Z',
+ bornHost: '10.0.1.12:54323',
+ storeHost: '10.0.2.3:10911',
+ properties: {
+ KEYS: 'order-12348',
+ TAGS: 'create',
+ UNIQ_KEY: 'AC1E0A6400002A9F0000000001A40BE4',
+ WAIT: 'true',
+ MSG_REGION: 'DefaultRegion',
+ },
+ size: 456,
+ },
+ {
+ msgId: 'AC1E0A6400002A9F0000000001A415F5',
+ topic: 'order-create',
+ tag: 'create',
+ key: 'order-12349',
+ body: '{"orderId":"20240101005","userId":10077,"items":[{"skuId":"SKU022","quantity":2,"price":45.00}],"totalAmount":90.00}',
+ storeTime: '2026-07-01T12:00:18.567Z',
+ bornHost: '10.0.1.14:54321',
+ storeHost: '10.0.2.5:10911',
+ properties: {
+ KEYS: 'order-12349',
+ TAGS: 'create',
+ UNIQ_KEY: 'AC1E0A6400002A9F0000000001A415F5',
+ WAIT: 'true',
+ MSG_REGION: 'DefaultRegion',
+ },
+ size: 442,
+ },
+
+ // ── payment-callback (5 messages) ──
+ {
+ msgId: 'AC1E0A6400002A9F0000000001A42006',
+ topic: 'payment-callback',
+ tag: 'success',
+ key: 'pay-67890',
+ body: '{"paymentId":"PAY20240101001","orderId":"20240101001","amount":199.00,"channel":"alipay","status":"PAID","paidAt":"2026-07-01T10:24:02.000Z"}',
+ storeTime: '2026-07-01T10:24:05.890Z',
+ bornHost: '10.0.1.20:54400',
+ storeHost: '10.0.2.3:10911',
+ properties: {
+ KEYS: 'pay-67890',
+ TAGS: 'success',
+ UNIQ_KEY: 'AC1E0A6400002A9F0000000001A42006',
+ WAIT: 'true',
+ MSG_REGION: 'DefaultRegion',
+ TRANSACTION_ID: 'TXN20240101001',
+ },
+ size: 8724,
+ },
+ {
+ msgId: 'AC1E0A6400002A9F0000000001A42A17',
+ topic: 'payment-callback',
+ tag: 'success',
+ key: 'pay-67891',
+ body: '{"paymentId":"PAY20240101002","orderId":"20240101002","amount":59.00,"channel":"wechat","status":"PAID","paidAt":"2026-07-01T10:26:15.000Z"}',
+ storeTime: '2026-07-01T10:26:18.123Z',
+ bornHost: '10.0.1.20:54401',
+ storeHost: '10.0.2.4:10911',
+ properties: {
+ KEYS: 'pay-67891',
+ TAGS: 'success',
+ UNIQ_KEY: 'AC1E0A6400002A9F0000000001A42A17',
+ WAIT: 'true',
+ MSG_REGION: 'DefaultRegion',
+ },
+ size: 598,
+ },
+ {
+ msgId: 'AC1E0A6400002A9F0000000001A43428',
+ topic: 'payment-callback',
+ tag: 'failed',
+ key: 'pay-67892',
+ body: '{"paymentId":"PAY20240101003","orderId":"20240101003","amount":227.00,"channel":"alipay","status":"FAILED","reason":"insufficient_balance"}',
+ storeTime: '2026-07-01T10:32:45.456Z',
+ bornHost: '10.0.1.21:54400',
+ storeHost: '10.0.2.3:10911',
+ properties: {
+ KEYS: 'pay-67892',
+ TAGS: 'failed',
+ UNIQ_KEY: 'AC1E0A6400002A9F0000000001A43428',
+ WAIT: 'true',
+ MSG_REGION: 'DefaultRegion',
+ },
+ size: 672,
+ },
+ {
+ msgId: 'AC1E0A6400002A9F0000000001A43E39',
+ topic: 'payment-callback',
+ tag: 'success',
+ key: 'pay-67893',
+ body: '{"paymentId":"PAY20240101004","orderId":"20240101004","amount":1299.00,"channel":"bank_card","status":"PAID","paidAt":"2026-07-01T11:16:05.000Z"}',
+ storeTime: '2026-07-01T11:16:08.789Z',
+ bornHost: '10.0.1.20:54402',
+ storeHost: '10.0.2.5:10911',
+ properties: {
+ KEYS: 'pay-67893',
+ TAGS: 'success',
+ UNIQ_KEY: 'AC1E0A6400002A9F0000000001A43E39',
+ WAIT: 'true',
+ MSG_REGION: 'DefaultRegion',
+ },
+ size: 2457600,
+ },
+ {
+ msgId: 'AC1E0A6400002A9F0000000001A4484A',
+ topic: 'payment-callback',
+ tag: 'refunded',
+ key: 'pay-67894',
+ body: '{"paymentId":"PAY20240101005","orderId":"20240101005","amount":90.00,"channel":"wechat","status":"REFUNDED","refundAmount":90.00,"reason":"user_request"}',
+ storeTime: '2026-07-01T12:05:22.012Z',
+ bornHost: '10.0.1.21:54401',
+ storeHost: '10.0.2.3:10911',
+ properties: {
+ KEYS: 'pay-67894',
+ TAGS: 'refunded',
+ UNIQ_KEY: 'AC1E0A6400002A9F0000000001A4484A',
+ WAIT: 'true',
+ MSG_REGION: 'DefaultRegion',
+ },
+ size: 708,
+ },
+
+ // ── user-activity-log (4 messages) ──
+ {
+ msgId: 'AC1E0A6400002A9F0000000001A4525B',
+ topic: 'user-activity-log',
+ tag: 'login',
+ key: 'user-10001',
+ body: '{"userId":10001,"action":"login","device":"iOS 17.2","ip":"223.104.3.21","timestamp":"2026-07-01T09:15:00.000Z"}',
+ storeTime: '2026-07-01T09:15:01.345Z',
+ bornHost: '10.0.1.30:55000',
+ storeHost: '10.0.2.4:10911',
+ properties: {
+ KEYS: 'user-10001',
+ TAGS: 'login',
+ UNIQ_KEY: 'AC1E0A6400002A9F0000000001A4525B',
+ WAIT: 'true',
+ MSG_REGION: 'DefaultRegion',
+ },
+ size: 5248,
+ },
+ {
+ msgId: 'AC1E0A6400002A9F0000000001A45C6C',
+ topic: 'user-activity-log',
+ tag: 'browse',
+ key: 'user-10042',
+ body: '{"userId":10042,"action":"browse","page":"product_detail","productId":"SKU015","duration":45}',
+ storeTime: '2026-07-01T09:30:12.678Z',
+ bornHost: '10.0.1.31:55001',
+ storeHost: '10.0.2.3:10911',
+ properties: {
+ KEYS: 'user-10042',
+ TAGS: 'browse',
+ UNIQ_KEY: 'AC1E0A6400002A9F0000000001A45C6C',
+ WAIT: 'true',
+ MSG_REGION: 'DefaultRegion',
+ },
+ size: 312,
+ },
+ {
+ msgId: 'AC1E0A6400002A9F0000000001A4667D',
+ topic: 'user-activity-log',
+ tag: 'cart',
+ key: 'user-10099',
+ body: '{"userId":10099,"action":"add_to_cart","skuId":"SKU008","quantity":3,"cartTotal":5}',
+ storeTime: '2026-07-01T10:05:33.901Z',
+ bornHost: '10.0.1.30:55002',
+ storeHost: '10.0.2.5:10911',
+ properties: {
+ KEYS: 'user-10099',
+ TAGS: 'cart',
+ UNIQ_KEY: 'AC1E0A6400002A9F0000000001A4667D',
+ WAIT: 'true',
+ MSG_REGION: 'DefaultRegion',
+ },
+ size: 288,
+ },
+ {
+ msgId: 'AC1E0A6400002A9F0000000001A4708E',
+ topic: 'user-activity-log',
+ tag: 'logout',
+ key: 'user-10001',
+ body: '{"userId":10001,"action":"logout","sessionDuration":3600}',
+ storeTime: '2026-07-01T10:15:45.234Z',
+ bornHost: '10.0.1.30:55000',
+ storeHost: '10.0.2.4:10911',
+ properties: {
+ KEYS: 'user-10001',
+ TAGS: 'logout',
+ UNIQ_KEY: 'AC1E0A6400002A9F0000000001A4708E',
+ WAIT: 'true',
+ MSG_REGION: 'DefaultRegion',
+ },
+ size: 214,
+ },
+
+ // ── notification-push (4 messages) ──
+ {
+ msgId: 'AC1E0A6400002A9F0000000001A47A9F',
+ topic: 'notification-push',
+ tag: 'push',
+ key: 'notif-001',
+ body: '{"notificationId":"N001","userId":10001,"type":"order_status","title":"订单已发货","content":"您的订单 20240101001 已发货,快递单号 SF1234567890","channel":"app"}',
+ storeTime: '2026-07-01T13:00:05.567Z',
+ bornHost: '10.0.1.40:55100',
+ storeHost: '10.0.2.3:10911',
+ properties: {
+ KEYS: 'notif-001',
+ TAGS: 'push',
+ UNIQ_KEY: 'AC1E0A6400002A9F0000000001A47A9F',
+ WAIT: 'true',
+ DELAY: '3',
+ MSG_REGION: 'DefaultRegion',
+ },
+ size: 15872,
+ },
+ {
+ msgId: 'AC1E0A6400002A9F0000000001A484B0',
+ topic: 'notification-push',
+ tag: 'sms',
+ key: 'notif-002',
+ body: '{"notificationId":"N002","userId":10042,"type":"verification","phone":"138****5678","code":"892341","expireMinutes":5}',
+ storeTime: '2026-07-01T13:10:22.890Z',
+ bornHost: '10.0.1.41:55101',
+ storeHost: '10.0.2.4:10911',
+ properties: {
+ KEYS: 'notif-002',
+ TAGS: 'sms',
+ UNIQ_KEY: 'AC1E0A6400002A9F0000000001A484B0',
+ WAIT: 'true',
+ MSG_REGION: 'DefaultRegion',
+ },
+ size: 428,
+ },
+ {
+ msgId: 'AC1E0A6400002A9F0000000001A48EC1',
+ topic: 'notification-push',
+ tag: 'email',
+ key: 'notif-003',
+ body: '{"notificationId":"N003","userId":10099,"type":"weekly_report","email":"user@example.com","subject":"本周消费报告","templateId":"TPL_WEEKLY"}',
+ storeTime: '2026-07-01T14:00:45.123Z',
+ bornHost: '10.0.1.40:55102',
+ storeHost: '10.0.2.5:10911',
+ properties: {
+ KEYS: 'notif-003',
+ TAGS: 'email',
+ UNIQ_KEY: 'AC1E0A6400002A9F0000000001A48EC1',
+ WAIT: 'true',
+ MSG_REGION: 'DefaultRegion',
+ },
+ size: 512,
+ },
+ {
+ msgId: 'AC1E0A6400002A9F0000000001A498D2',
+ topic: 'notification-push',
+ tag: 'push',
+ key: 'notif-004',
+ body: '{"notificationId":"N004","userId":10201,"type":"promotion","title":"限时优惠","content":"您关注的商品 SKU003 降价 200 元,立即查看","channel":"app"}',
+ storeTime: '2026-07-01T15:30:18.456Z',
+ bornHost: '10.0.1.41:55100',
+ storeHost: '10.0.2.3:10911',
+ properties: {
+ KEYS: 'notif-004',
+ TAGS: 'push',
+ UNIQ_KEY: 'AC1E0A6400002A9F0000000001A498D2',
+ WAIT: 'true',
+ MSG_REGION: 'DefaultRegion',
+ },
+ size: 1048576,
+ },
+
+ // ── inventory-sync (4 messages) ──
+ {
+ msgId: 'AC1E0A6400002A9F0000000001A4A2E3',
+ topic: 'inventory-sync',
+ tag: 'sync',
+ key: 'inv-sku001',
+ body: '{"skuId":"SKU001","warehouse":"HZ-01","before":500,"after":498,"change":-2,"reason":"order_deduction","orderId":"20240101001"}',
+ storeTime: '2026-07-01T10:24:10.789Z',
+ bornHost: '10.0.1.50:55200',
+ storeHost: '10.0.2.4:10911',
+ properties: {
+ KEYS: 'inv-sku001',
+ TAGS: 'sync',
+ UNIQ_KEY: 'AC1E0A6400002A9F0000000001A4A2E3',
+ WAIT: 'true',
+ MSG_REGION: 'DefaultRegion',
+ },
+ size: 448,
+ },
+ {
+ msgId: 'AC1E0A6400002A9F0000000001A4ACF4',
+ topic: 'inventory-sync',
+ tag: 'deduct',
+ key: 'inv-sku003',
+ body: '{"skuId":"SKU003","warehouse":"SH-02","before":120,"after":119,"change":-1,"reason":"order_deduction","orderId":"20240101004"}',
+ storeTime: '2026-07-01T11:16:15.012Z',
+ bornHost: '10.0.1.51:55201',
+ storeHost: '10.0.2.3:10911',
+ properties: {
+ KEYS: 'inv-sku003',
+ TAGS: 'deduct',
+ UNIQ_KEY: 'AC1E0A6400002A9F0000000001A4ACF4',
+ WAIT: 'true',
+ MSG_REGION: 'DefaultRegion',
+ },
+ size: 436,
+ },
+ {
+ msgId: 'AC1E0A6400002A9F0000000001A4B705',
+ topic: 'inventory-sync',
+ tag: 'restock',
+ key: 'inv-sku015',
+ body: '{"skuId":"SKU015","warehouse":"HZ-01","before":50,"after":200,"change":150,"reason":"supplier_restock","poId":"PO20240101001"}',
+ storeTime: '2026-07-01T14:30:05.345Z',
+ bornHost: '10.0.1.50:55202',
+ storeHost: '10.0.2.5:10911',
+ properties: {
+ KEYS: 'inv-sku015',
+ TAGS: 'restock',
+ UNIQ_KEY: 'AC1E0A6400002A9F0000000001A4B705',
+ WAIT: 'true',
+ MSG_REGION: 'DefaultRegion',
+ },
+ size: 464,
+ },
+ {
+ msgId: 'AC1E0A6400002A9F0000000001A4C116',
+ topic: 'inventory-sync',
+ tag: 'alert',
+ key: 'inv-sku022',
+ body: '{"skuId":"SKU022","warehouse":"BJ-03","currentStock":3,"threshold":10,"alertType":"low_stock","action":"notify_procurement"}',
+ storeTime: '2026-07-01T16:00:22.678Z',
+ bornHost: '10.0.1.52:55200',
+ storeHost: '10.0.2.3:10911',
+ properties: {
+ KEYS: 'inv-sku022',
+ TAGS: 'alert',
+ UNIQ_KEY: 'AC1E0A6400002A9F0000000001A4C116',
+ WAIT: 'true',
+ MSG_REGION: 'DefaultRegion',
+ },
+ size: 392,
+ },
+];
+
+export const mockMessageTraces: Record<string, TraceRecord> = {
+ // ── order-create traces ──
+ AC1E0A6400002A9F0000000001A3F2B1: {
+ nodes: [
+ {
+ title: 'Producer 发送',
+ timestamp: '2026-07-01T10:23:45.100Z',
+ status: 'finish',
+ costTime: 3,
+ description: 'order-service (10.0.1.12:54321) → broker-hz-01',
+ },
+ {
+ title: 'Broker 存储',
+ timestamp: '2026-07-01T10:23:45.115Z',
+ status: 'finish',
+ costTime: 8,
+ description: 'broker-hz-01 (10.0.2.3:10911) CommitLog 写入成功',
+ },
+ {
+ title: 'Consumer 消费',
+ timestamp: '2026-07-01T10:23:45.230Z',
+ status: 'finish',
+ costTime: 107,
+ description: 'cg-order-processor → 消费成功 (23ms)',
+ },
+ ],
+ consumerStatus: [
+ {
+ group: 'cg-order-processor',
+ deliveryStatus: 'success',
+ consumeTime: '2026-07-01T10:23:45.230Z',
+ retryCount: 0,
+ },
+ {
+ group: 'cg-notification',
+ deliveryStatus: 'success',
+ consumeTime: '2026-07-01T10:23:45.450Z',
+ retryCount: 0,
+ },
+ {
+ group: 'cg-analytics',
+ deliveryStatus: 'success',
+ consumeTime: '2026-07-01T10:23:46.100Z',
+ retryCount: 0,
+ },
+ ],
+ },
+ AC1E0A6400002A9F0000000001A3F7C2: {
+ nodes: [
+ {
+ title: 'Producer 发送',
+ timestamp: '2026-07-01T10:25:12.400Z',
+ status: 'finish',
+ costTime: 2,
+ description: 'order-service (10.0.1.12:54322) → broker-hz-01',
+ },
+ {
+ title: 'Broker 存储',
+ timestamp: '2026-07-01T10:25:12.430Z',
+ status: 'finish',
+ costTime: 6,
+ description: 'broker-hz-01 (10.0.2.3:10911) CommitLog 写入成功',
+ },
+ {
+ title: 'Consumer 消费',
+ timestamp: '2026-07-01T10:25:12.556Z',
+ status: 'finish',
+ costTime: 120,
+ description: 'cg-order-processor → 消费成功 (18ms)',
+ },
+ ],
+ consumerStatus: [
+ {
+ group: 'cg-order-processor',
+ deliveryStatus: 'success',
+ consumeTime: '2026-07-01T10:25:12.556Z',
+ retryCount: 0,
+ },
+ { group: 'cg-notification', deliveryStatus: 'pending', consumeTime: '-', retryCount: 0 },
+ ],
+ },
+ AC1E0A6400002A9F0000000001A401D3: {
+ nodes: [
+ {
+ title: 'Producer 发送',
+ timestamp: '2026-07-01T10:30:05.700Z',
+ status: 'finish',
+ costTime: 4,
+ description: 'order-service (10.0.1.13:54321) → broker-hz-02',
+ },
+ {
+ title: 'Broker 存储',
+ timestamp: '2026-07-01T10:30:05.750Z',
+ status: 'finish',
+ costTime: 12,
+ description: 'broker-hz-02 (10.0.2.4:10911) CommitLog 写入成功',
+ },
+ {
+ title: 'Consumer 消费',
+ timestamp: '2026-07-01T10:30:06.500Z',
+ status: 'finish',
+ costTime: 750,
+ description: 'cg-order-processor → 首次超时,重试成功 (156ms)',
+ },
+ ],
+ consumerStatus: [
+ {
+ group: 'cg-order-processor',
+ deliveryStatus: 'success',
+ consumeTime: '2026-07-01T10:30:06.500Z',
+ retryCount: 1,
+ },
+ {
+ group: 'cg-analytics',
+ deliveryStatus: 'failed',
+ consumeTime: '2026-07-01T10:30:08.000Z',
+ retryCount: 3,
+ },
+ ],
+ },
+ AC1E0A6400002A9F0000000001A40BE4: {
+ nodes: [
+ {
+ title: 'Producer 发送',
+ timestamp: '2026-07-01T11:15:33.200Z',
+ status: 'finish',
+ costTime: 3,
+ description: 'order-service (10.0.1.12:54323) → broker-hz-01',
+ },
+ {
+ title: 'Broker 存储',
+ timestamp: '2026-07-01T11:15:33.220Z',
+ status: 'finish',
+ costTime: 5,
+ description: 'broker-hz-01 (10.0.2.3:10911) CommitLog 写入成功',
+ },
+ {
+ title: 'Consumer 消费',
+ timestamp: '2026-07-01T11:15:33.300Z',
+ status: 'finish',
+ costTime: 78,
+ description: 'cg-order-processor → 消费成功 (15ms)',
+ },
+ ],
+ consumerStatus: [
+ {
+ group: 'cg-order-processor',
+ deliveryStatus: 'success',
+ consumeTime: '2026-07-01T11:15:33.300Z',
+ retryCount: 0,
+ },
+ ],
+ },
+ AC1E0A6400002A9F0000000001A415F5: {
+ nodes: [
+ {
+ title: 'Producer 发送',
+ timestamp: '2026-07-01T12:00:18.500Z',
+ status: 'finish',
+ costTime: 5,
+ description: 'order-service (10.0.1.14:54321) → broker-hz-03',
+ },
+ {
+ title: 'Broker 存储',
+ timestamp: '2026-07-01T12:00:18.540Z',
+ status: 'finish',
+ costTime: 9,
+ description: 'broker-hz-03 (10.0.2.5:10911) CommitLog 写入成功',
+ },
+ {
+ title: 'Consumer 消费',
+ timestamp: '2026-07-01T12:00:18.667Z',
+ status: 'finish',
+ costTime: 122,
+ description: 'cg-order-processor → 消费成功 (32ms)',
+ },
+ ],
+ consumerStatus: [
+ {
+ group: 'cg-order-processor',
+ deliveryStatus: 'success',
+ consumeTime: '2026-07-01T12:00:18.667Z',
+ retryCount: 0,
+ },
+ {
+ group: 'cg-notification',
+ deliveryStatus: 'success',
+ consumeTime: '2026-07-01T12:00:19.100Z',
+ retryCount: 0,
+ },
+ ],
+ },
+
+ // ── payment-callback traces ──
+ AC1E0A6400002A9F0000000001A42006: {
+ nodes: [
+ {
+ title: 'Producer 发送',
+ timestamp: '2026-07-01T10:24:05.800Z',
+ status: 'finish',
+ costTime: 4,
+ description: 'payment-service (10.0.1.20:54400) → broker-hz-01',
+ },
+ {
+ title: 'Broker 存储',
+ timestamp: '2026-07-01T10:24:05.850Z',
+ status: 'finish',
+ costTime: 10,
+ description: 'broker-hz-01 (10.0.2.3:10911) CommitLog 写入成功',
+ },
+ {
+ title: 'Consumer 消费',
+ timestamp: '2026-07-01T10:24:06.000Z',
+ status: 'finish',
+ costTime: 145,
+ description: 'cg-payment-handler → 消费成功 (45ms)',
+ },
+ ],
+ consumerStatus: [
+ {
+ group: 'cg-payment-handler',
+ deliveryStatus: 'success',
+ consumeTime: '2026-07-01T10:24:06.000Z',
+ retryCount: 0,
+ },
+ {
+ group: 'cg-order-processor',
+ deliveryStatus: 'success',
+ consumeTime: '2026-07-01T10:24:06.200Z',
+ retryCount: 0,
+ },
+ { group: 'cg-analytics', deliveryStatus: 'pending', consumeTime: '-', retryCount: 0 },
+ ],
+ },
+ AC1E0A6400002A9F0000000001A42A17: {
+ nodes: [
+ {
+ title: 'Producer 发送',
+ timestamp: '2026-07-01T10:26:18.100Z',
+ status: 'finish',
+ costTime: 3,
+ description: 'payment-service (10.0.1.20:54401) → broker-hz-02',
+ },
+ {
+ title: 'Broker 存储',
+ timestamp: '2026-07-01T10:26:18.110Z',
+ status: 'finish',
+ costTime: 7,
+ description: 'broker-hz-02 (10.0.2.4:10911) CommitLog 写入成功',
+ },
+ {
+ title: 'Consumer 消费',
+ timestamp: '2026-07-01T10:26:18.200Z',
+ status: 'finish',
+ costTime: 88,
+ description: 'cg-payment-handler → 消费成功 (22ms)',
+ },
+ ],
+ consumerStatus: [
+ {
+ group: 'cg-payment-handler',
+ deliveryStatus: 'success',
+ consumeTime: '2026-07-01T10:26:18.200Z',
+ retryCount: 0,
+ },
+ {
+ group: 'cg-order-processor',
+ deliveryStatus: 'success',
+ consumeTime: '2026-07-01T10:26:18.500Z',
+ retryCount: 0,
+ },
+ ],
+ },
+ AC1E0A6400002A9F0000000001A43428: {
+ nodes: [
+ {
+ title: 'Producer 发送',
+ timestamp: '2026-07-01T10:32:45.400Z',
+ status: 'finish',
+ costTime: 5,
+ description: 'payment-service (10.0.1.21:54400) → broker-hz-01',
+ },
+ {
+ title: 'Broker 存储',
+ timestamp: '2026-07-01T10:32:45.440Z',
+ status: 'finish',
+ costTime: 8,
+ description: 'broker-hz-01 (10.0.2.3:10911) CommitLog 写入成功',
+ },
+ {
+ title: 'Consumer 消费',
+ timestamp: '2026-07-01T10:32:46.000Z',
+ status: 'finish',
+ costTime: 560,
+ description: 'cg-payment-handler → 消费失败 (余额不足)',
+ },
+ ],
+ consumerStatus: [
+ {
+ group: 'cg-payment-handler',
+ deliveryStatus: 'failed',
+ consumeTime: '2026-07-01T10:32:46.000Z',
+ retryCount: 3,
+ },
+ { group: 'cg-order-processor', deliveryStatus: 'pending', consumeTime: '-', retryCount: 0 },
+ ],
+ },
+ AC1E0A6400002A9F0000000001A43E39: {
+ nodes: [
+ {
+ title: 'Producer 发送',
+ timestamp: '2026-07-01T11:16:08.700Z',
+ status: 'finish',
+ costTime: 3,
+ description: 'payment-service (10.0.1.20:54402) → broker-hz-03',
+ },
+ {
+ title: 'Broker 存储',
+ timestamp: '2026-07-01T11:16:08.760Z',
+ status: 'finish',
+ costTime: 11,
+ description: 'broker-hz-03 (10.0.2.5:10911) CommitLog 写入成功',
+ },
+ {
+ title: 'Consumer 消费',
+ timestamp: '2026-07-01T11:16:09.000Z',
+ status: 'finish',
+ costTime: 235,
+ description: 'cg-payment-handler → 消费成功 (68ms)',
+ },
+ ],
+ consumerStatus: [
+ {
+ group: 'cg-payment-handler',
+ deliveryStatus: 'success',
+ consumeTime: '2026-07-01T11:16:09.000Z',
+ retryCount: 0,
+ },
+ ],
+ },
+ AC1E0A6400002A9F0000000001A4484A: {
+ nodes: [
+ {
+ title: 'Producer 发送',
+ timestamp: '2026-07-01T12:05:22.000Z',
+ status: 'finish',
+ costTime: 4,
+ description: 'payment-service (10.0.1.21:54401) → broker-hz-01',
+ },
+ {
+ title: 'Broker 存储',
+ timestamp: '2026-07-01T12:05:22.008Z',
+ status: 'finish',
+ costTime: 6,
+ description: 'broker-hz-01 (10.0.2.3:10911) CommitLog 写入成功',
+ },
+ {
+ title: 'Consumer 消费',
+ timestamp: '2026-07-01T12:05:22.120Z',
+ status: 'finish',
+ costTime: 110,
+ description: 'cg-payment-handler → 消费成功 (退款处理)',
+ },
+ ],
+ consumerStatus: [
+ {
+ group: 'cg-payment-handler',
+ deliveryStatus: 'success',
+ consumeTime: '2026-07-01T12:05:22.120Z',
+ retryCount: 0,
+ },
+ {
+ group: 'cg-notification',
+ deliveryStatus: 'success',
+ consumeTime: '2026-07-01T12:05:22.500Z',
+ retryCount: 0,
+ },
+ ],
+ },
+
+ // ── user-activity-log traces ──
+ AC1E0A6400002A9F0000000001A4525B: {
+ nodes: [
+ {
+ title: 'Producer 发送',
+ timestamp: '2026-07-01T09:15:01.300Z',
+ status: 'finish',
+ costTime: 2,
+ description: 'user-service (10.0.1.30:55000) → broker-hz-02',
+ },
+ {
+ title: 'Broker 存储',
+ timestamp: '2026-07-01T09:15:01.330Z',
+ status: 'finish',
+ costTime: 5,
+ description: 'broker-hz-02 (10.0.2.4:10911) CommitLog 写入成功',
+ },
+ {
+ title: 'Consumer 消费',
+ timestamp: '2026-07-01T09:15:01.445Z',
+ status: 'finish',
+ costTime: 112,
+ description: 'cg-user-tracking → 消费成功 (8ms)',
+ },
+ ],
+ consumerStatus: [
+ {
+ group: 'cg-user-tracking',
+ deliveryStatus: 'success',
+ consumeTime: '2026-07-01T09:15:01.445Z',
+ retryCount: 0,
+ },
+ {
+ group: 'cg-risk-control',
+ deliveryStatus: 'success',
+ consumeTime: '2026-07-01T09:15:01.600Z',
+ retryCount: 0,
+ },
+ ],
+ },
+ AC1E0A6400002A9F0000000001A45C6C: {
+ nodes: [
+ {
+ title: 'Producer 发送',
+ timestamp: '2026-07-01T09:30:12.600Z',
+ status: 'finish',
+ costTime: 3,
+ description: 'user-service (10.0.1.31:55001) → broker-hz-01',
+ },
+ {
+ title: 'Broker 存储',
+ timestamp: '2026-07-01T09:30:12.650Z',
+ status: 'finish',
+ costTime: 7,
+ description: 'broker-hz-01 (10.0.2.3:10911) CommitLog 写入成功',
+ },
+ {
+ title: 'Consumer 消费',
+ timestamp: '2026-07-01T09:30:12.778Z',
+ status: 'finish',
+ costTime: 125,
+ description: 'cg-user-tracking → 消费成功 (12ms)',
+ },
+ ],
+ consumerStatus: [
+ {
+ group: 'cg-user-tracking',
+ deliveryStatus: 'success',
+ consumeTime: '2026-07-01T09:30:12.778Z',
+ retryCount: 0,
+ },
+ ],
+ },
+ AC1E0A6400002A9F0000000001A4667D: {
+ nodes: [
+ {
+ title: 'Producer 发送',
+ timestamp: '2026-07-01T10:05:33.800Z',
+ status: 'finish',
+ costTime: 4,
+ description: 'user-service (10.0.1.30:55002) → broker-hz-03',
+ },
+ {
+ title: 'Broker 存储',
+ timestamp: '2026-07-01T10:05:33.870Z',
+ status: 'finish',
+ costTime: 9,
+ description: 'broker-hz-03 (10.0.2.5:10911) CommitLog 写入成功',
+ },
+ {
+ title: 'Consumer 消费',
+ timestamp: '2026-07-01T10:05:34.000Z',
+ status: 'finish',
+ costTime: 125,
+ description: 'cg-recommend → 首次失败,重试成功 (35ms)',
+ },
+ ],
+ consumerStatus: [
+ {
+ group: 'cg-recommend',
+ deliveryStatus: 'success',
+ consumeTime: '2026-07-01T10:05:34.000Z',
+ retryCount: 1,
+ },
+ {
+ group: 'cg-user-tracking',
+ deliveryStatus: 'success',
+ consumeTime: '2026-07-01T10:05:33.980Z',
+ retryCount: 0,
+ },
+ ],
+ },
+ AC1E0A6400002A9F0000000001A4708E: {
+ nodes: [
+ {
+ title: 'Producer 发送',
+ timestamp: '2026-07-01T10:15:45.200Z',
+ status: 'finish',
+ costTime: 2,
+ description: 'user-service (10.0.1.30:55000) → broker-hz-02',
+ },
+ {
+ title: 'Broker 存储',
+ timestamp: '2026-07-01T10:15:45.220Z',
+ status: 'finish',
+ costTime: 4,
+ description: 'broker-hz-02 (10.0.2.4:10911) CommitLog 写入成功',
+ },
+ {
+ title: 'Consumer 消费',
+ timestamp: '2026-07-01T10:15:45.334Z',
+ status: 'finish',
+ costTime: 112,
+ description: 'cg-user-tracking → 消费成功 (6ms)',
+ },
+ ],
+ consumerStatus: [
+ {
+ group: 'cg-user-tracking',
+ deliveryStatus: 'success',
+ consumeTime: '2026-07-01T10:15:45.334Z',
+ retryCount: 0,
+ },
+ ],
+ },
+
+ // ── notification-push traces ──
+ AC1E0A6400002A9F0000000001A47A9F: {
+ nodes: [
+ {
+ title: 'Producer 发送',
+ timestamp: '2026-07-01T13:00:05.500Z',
+ status: 'finish',
+ costTime: 3,
+ description: 'notification-service (10.0.1.40:55100) → broker-hz-01',
+ },
+ {
+ title: 'Broker 存储',
+ timestamp: '2026-07-01T13:00:05.550Z',
+ status: 'finish',
+ costTime: 8,
+ description: 'broker-hz-01 (10.0.2.3:10911) CommitLog 写入成功',
+ },
+ {
+ title: 'Consumer 消费',
+ timestamp: '2026-07-01T13:00:05.800Z',
+ status: 'finish',
+ costTime: 248,
+ description: 'cg-push-service → 推送成功 (56ms)',
+ },
+ ],
+ consumerStatus: [
+ {
+ group: 'cg-push-service',
+ deliveryStatus: 'success',
+ consumeTime: '2026-07-01T13:00:05.800Z',
+ retryCount: 0,
+ },
+ { group: 'cg-sms-gateway', deliveryStatus: 'pending', consumeTime: '-', retryCount: 0 },
+ ],
+ },
+ AC1E0A6400002A9F0000000001A484B0: {
+ nodes: [
+ {
+ title: 'Producer 发送',
+ timestamp: '2026-07-01T13:10:22.800Z',
+ status: 'finish',
+ costTime: 4,
+ description: 'notification-service (10.0.1.41:55101) → broker-hz-02',
+ },
+ {
+ title: 'Broker 存储',
+ timestamp: '2026-07-01T13:10:22.860Z',
+ status: 'finish',
+ costTime: 6,
+ description: 'broker-hz-02 (10.0.2.4:10911) CommitLog 写入成功',
+ },
+ {
+ title: 'Consumer 消费',
+ timestamp: '2026-07-01T13:10:23.500Z',
+ status: 'process',
+ costTime: 640,
+ description: 'cg-sms-gateway → 发送中...',
+ },
+ ],
+ consumerStatus: [
+ { group: 'cg-sms-gateway', deliveryStatus: 'pending', consumeTime: '-', retryCount: 0 },
+ ],
+ },
+ AC1E0A6400002A9F0000000001A48EC1: {
+ nodes: [
+ {
+ title: 'Producer 发送',
+ timestamp: '2026-07-01T14:00:45.100Z',
+ status: 'finish',
+ costTime: 3,
+ description: 'notification-service (10.0.1.40:55102) → broker-hz-03',
+ },
+ {
+ title: 'Broker 存储',
+ timestamp: '2026-07-01T14:00:45.110Z',
+ status: 'finish',
+ costTime: 5,
+ description: 'broker-hz-03 (10.0.2.5:10911) CommitLog 写入成功',
+ },
+ {
+ title: 'Consumer 消费',
+ timestamp: '2026-07-01T14:00:45.500Z',
+ status: 'finish',
+ costTime: 388,
+ description: 'cg-email-service → 发送成功 (120ms)',
+ },
+ ],
+ consumerStatus: [
+ {
+ group: 'cg-email-service',
+ deliveryStatus: 'success',
+ consumeTime: '2026-07-01T14:00:45.500Z',
+ retryCount: 0,
+ },
+ ],
+ },
+ AC1E0A6400002A9F0000000001A498D2: {
+ nodes: [
+ {
+ title: 'Producer 发送',
+ timestamp: '2026-07-01T15:30:18.400Z',
+ status: 'finish',
+ costTime: 5,
+ description: 'notification-service (10.0.1.41:55100) → broker-hz-01',
+ },
+ {
+ title: 'Broker 存储',
+ timestamp: '2026-07-01T15:30:18.440Z',
+ status: 'finish',
+ costTime: 10,
+ description: 'broker-hz-01 (10.0.2.3:10911) CommitLog 写入成功',
+ },
+ {
+ title: 'Consumer 消费',
+ timestamp: '2026-07-01T15:30:19.000Z',
+ status: 'finish',
+ costTime: 558,
+ description: 'cg-push-service → 首次超时,重试成功 (89ms)',
+ },
+ ],
+ consumerStatus: [
+ {
+ group: 'cg-push-service',
+ deliveryStatus: 'success',
+ consumeTime: '2026-07-01T15:30:19.000Z',
+ retryCount: 1,
+ },
+ ],
+ },
+
+ // ── inventory-sync traces ──
+ AC1E0A6400002A9F0000000001A4A2E3: {
+ nodes: [
+ {
+ title: 'Producer 发送',
+ timestamp: '2026-07-01T10:24:10.700Z',
+ status: 'finish',
+ costTime: 3,
+ description: 'inventory-service (10.0.1.50:55200) → broker-hz-02',
+ },
+ {
+ title: 'Broker 存储',
+ timestamp: '2026-07-01T10:24:10.770Z',
+ status: 'finish',
+ costTime: 12,
+ description: 'broker-hz-02 (10.0.2.4:10911) CommitLog 写入成功',
+ },
+ {
+ title: 'Consumer 消费',
+ timestamp: '2026-07-01T10:24:10.889Z',
+ status: 'finish',
+ costTime: 116,
+ description: 'cg-inventory-sync → 同步成功 (15ms)',
+ },
+ ],
+ consumerStatus: [
+ {
+ group: 'cg-inventory-sync',
+ deliveryStatus: 'success',
+ consumeTime: '2026-07-01T10:24:10.889Z',
+ retryCount: 0,
+ },
+ {
+ group: 'cg-warehouse',
+ deliveryStatus: 'success',
+ consumeTime: '2026-07-01T10:24:11.200Z',
+ retryCount: 0,
+ },
+ ],
+ },
+ AC1E0A6400002A9F0000000001A4ACF4: {
+ nodes: [
+ {
+ title: 'Producer 发送',
+ timestamp: '2026-07-01T11:16:15.000Z',
+ status: 'finish',
+ costTime: 4,
+ description: 'inventory-service (10.0.1.51:55201) → broker-hz-01',
+ },
+ {
+ title: 'Broker 存储',
+ timestamp: '2026-07-01T11:16:15.008Z',
+ status: 'finish',
+ costTime: 6,
+ description: 'broker-hz-01 (10.0.2.3:10911) CommitLog 写入成功',
+ },
+ {
+ title: 'Consumer 消费',
+ timestamp: '2026-07-01T11:16:15.200Z',
+ status: 'finish',
+ costTime: 190,
+ description: 'cg-warehouse → 同步成功 (28ms)',
+ },
+ ],
+ consumerStatus: [
+ {
+ group: 'cg-warehouse',
+ deliveryStatus: 'success',
+ consumeTime: '2026-07-01T11:16:15.200Z',
+ retryCount: 0,
+ },
+ ],
+ },
+ AC1E0A6400002A9F0000000001A4B705: {
+ nodes: [
+ {
+ title: 'Producer 发送',
+ timestamp: '2026-07-01T14:30:05.300Z',
+ status: 'finish',
+ costTime: 3,
+ description: 'inventory-service (10.0.1.50:55202) → broker-hz-03',
+ },
+ {
+ title: 'Broker 存储',
+ timestamp: '2026-07-01T14:30:05.330Z',
+ status: 'finish',
+ costTime: 7,
+ description: 'broker-hz-03 (10.0.2.5:10911) CommitLog 写入成功',
+ },
+ {
+ title: 'Consumer 消费',
+ timestamp: '2026-07-01T14:30:05.500Z',
+ status: 'finish',
+ costTime: 168,
+ description: 'cg-procurement → 同步成功 (22ms)',
+ },
+ ],
+ consumerStatus: [
+ {
+ group: 'cg-procurement',
+ deliveryStatus: 'success',
+ consumeTime: '2026-07-01T14:30:05.500Z',
+ retryCount: 0,
+ },
+ {
+ group: 'cg-inventory-sync',
+ deliveryStatus: 'success',
+ consumeTime: '2026-07-01T14:30:05.600Z',
+ retryCount: 0,
+ },
+ ],
+ },
+ AC1E0A6400002A9F0000000001A4C116: {
+ nodes: [
+ {
+ title: 'Producer 发送',
+ timestamp: '2026-07-01T16:00:22.600Z',
+ status: 'finish',
+ costTime: 5,
+ description: 'inventory-service (10.0.1.52:55200) → broker-hz-01',
+ },
+ {
+ title: 'Broker 存储',
+ timestamp: '2026-07-01T16:00:22.660Z',
+ status: 'finish',
+ costTime: 14,
+ description: 'broker-hz-01 (10.0.2.3:10911) CommitLog 写入成功',
+ },
+ {
+ title: 'Consumer 消费',
+ timestamp: '2026-07-01T16:00:23.000Z',
+ status: 'finish',
+ costTime: 336,
+ description: 'cg-alert-service → 告警已触发 (45ms)',
+ },
+ ],
+ consumerStatus: [
+ {
+ group: 'cg-alert-service',
+ deliveryStatus: 'success',
+ consumeTime: '2026-07-01T16:00:23.000Z',
+ retryCount: 0,
+ },
+ {
+ group: 'cg-inventory-sync',
+ deliveryStatus: 'failed',
+ consumeTime: '2026-07-01T16:00:25.000Z',
+ retryCount: 2,
+ },
+ ],
+ },
+};
diff --git a/web/src/mock/topics.ts b/web/src/mock/topics.ts
new file mode 100644
index 0000000..949097c
--- /dev/null
+++ b/web/src/mock/topics.ts
@@ -0,0 +1,434 @@
+/*
+ * 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.
+ */
+
+export interface Topic {
+ name: string;
+ namespace: string;
+ type: 'NORMAL' | 'FIFO' | 'DELAY' | 'TRANSACTION' | 'LITE';
+ clusterId: string;
+ writeQueues: number;
+ readQueues: number;
+ perm: 'RW' | 'RO' | 'WO';
+ messageCount: number;
+ tps: number;
+ consumerGroupCount: number;
+ remark: string;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export const topics: Topic[] = [
+ // NORMAL topics (4)
+ {
+ name: 'order-create',
+ namespace: 'trade',
+ type: 'NORMAL',
+ clusterId: 'rmq-cn-v5-prod-01',
+ writeQueues: 16,
+ readQueues: 16,
+ perm: 'RW',
+ messageCount: 1_842_350,
+ tps: 1280,
+ consumerGroupCount: 8,
+ remark: '订单创建事件通知',
+ createdAt: '2024-03-15T08:30:00Z',
+ updatedAt: '2024-03-15T10:15:00Z',
+ },
+ {
+ name: 'user-activity-log',
+ namespace: 'user',
+ type: 'NORMAL',
+ clusterId: 'rmq-cn-v5-prod-01',
+ writeQueues: 16,
+ readQueues: 16,
+ perm: 'RW',
+ messageCount: 5_623_100,
+ tps: 3450,
+ consumerGroupCount: 5,
+ remark: '用户行为日志同步',
+ createdAt: '2024-01-20T10:00:00Z',
+ updatedAt: '2024-01-20T14:30:00Z',
+ },
+ {
+ name: 'system-log',
+ namespace: 'message',
+ type: 'NORMAL',
+ clusterId: 'rmq-cn-v4-prod-02',
+ writeQueues: 8,
+ readQueues: 8,
+ perm: 'RW',
+ messageCount: 12_480_000,
+ tps: 8620,
+ consumerGroupCount: 3,
+ remark: '系统日志收集与分发',
+ createdAt: '2023-11-05T14:20:00Z',
+ updatedAt: '2023-11-06T09:45:00Z',
+ },
+ {
+ name: 'notification-email',
+ namespace: 'message',
+ type: 'NORMAL',
+ clusterId: 'rmq-cn-v5-prod-01',
+ writeQueues: 4,
+ readQueues: 4,
+ perm: 'RW',
+ messageCount: 328_700,
+ tps: 215,
+ consumerGroupCount: 2,
+ remark: '邮件通知触发',
+ createdAt: '2024-06-10T09:15:00Z',
+ updatedAt: '2024-06-10T11:20:00Z',
+ },
+
+ // FIFO topics (2)
+ {
+ name: 'inventory-sync',
+ namespace: 'supply',
+ type: 'FIFO',
+ clusterId: 'rmq-cn-v5-prod-01',
+ writeQueues: 16,
+ readQueues: 16,
+ perm: 'RW',
+ messageCount: 956_400,
+ tps: 680,
+ consumerGroupCount: 4,
+ remark: '库存同步顺序消息',
+ createdAt: '2024-04-22T11:00:00Z',
+ updatedAt: '2024-04-22T15:30:00Z',
+ },
+ {
+ name: 'payment-sequence',
+ namespace: 'trade',
+ type: 'FIFO',
+ clusterId: 'rmq-cn-v5-prod-01',
+ writeQueues: 8,
+ readQueues: 8,
+ perm: 'RW',
+ messageCount: 412_850,
+ tps: 320,
+ consumerGroupCount: 6,
+ remark: '支付流程顺序处理',
+ createdAt: '2024-05-18T16:30:00Z',
+ updatedAt: '2024-05-19T08:45:00Z',
+ },
+
+ // DELAY topics (2)
+ {
+ name: 'notification-push',
+ namespace: 'message',
+ type: 'DELAY',
+ clusterId: 'rmq-cn-v5-prod-01',
+ writeQueues: 16,
+ readQueues: 16,
+ perm: 'RW',
+ messageCount: 2_105_600,
+ tps: 1540,
+ consumerGroupCount: 3,
+ remark: '延迟推送通知调度',
+ createdAt: '2024-02-14T13:45:00Z',
+ updatedAt: '2024-02-14T16:20:00Z',
+ },
+ {
+ name: 'scheduled-task',
+ namespace: 'supply',
+ type: 'DELAY',
+ clusterId: 'rmq-cn-v4-prod-02',
+ writeQueues: 4,
+ readQueues: 4,
+ perm: 'RW',
+ messageCount: 87_300,
+ tps: 56,
+ consumerGroupCount: 2,
+ remark: '定时任务触发器',
+ createdAt: '2024-07-01T08:00:00Z',
+ updatedAt: '2024-07-01T10:30:00Z',
+ },
+
+ // TRANSACTION topics (2)
+ {
+ name: 'payment-callback',
+ namespace: 'trade',
+ type: 'TRANSACTION',
+ clusterId: 'rmq-cn-v5-prod-01',
+ writeQueues: 16,
+ readQueues: 16,
+ perm: 'RW',
+ messageCount: 634_200,
+ tps: 420,
+ consumerGroupCount: 5,
+ remark: '支付回调事务处理',
+ createdAt: '2024-03-28T10:30:00Z',
+ updatedAt: '2024-03-28T14:15:00Z',
+ },
+ {
+ name: 'order-confirm',
+ namespace: 'trade',
+ type: 'TRANSACTION',
+ clusterId: 'rmq-cn-v5-prod-01',
+ writeQueues: 8,
+ readQueues: 8,
+ perm: 'RW',
+ messageCount: 521_800,
+ tps: 360,
+ consumerGroupCount: 4,
+ remark: '订单确认事务消息',
+ createdAt: '2024-04-05T15:20:00Z',
+ updatedAt: '2024-04-05T18:40:00Z',
+ },
+
+ // LITE topics (2)
+ {
+ name: 'chat-session',
+ namespace: 'ai',
+ type: 'LITE',
+ clusterId: 'rmq-cn-v5-prod-01',
+ writeQueues: 16,
+ readQueues: 16,
+ perm: 'RW',
+ messageCount: 8_920_000,
+ tps: 6200,
+ consumerGroupCount: 1,
+ remark: 'AI 对话会话消息分发',
+ createdAt: '2025-01-10T09:00:00Z',
+ updatedAt: '2025-01-10T11:30:00Z',
+ },
+ {
+ name: 'ai-task-dispatch',
+ namespace: 'ai',
+ type: 'LITE',
+ clusterId: 'rmq-cn-v5-prod-01',
+ writeQueues: 16,
+ readQueues: 16,
+ perm: 'RW',
+ messageCount: 3_450_000,
+ tps: 2380,
+ consumerGroupCount: 2,
+ remark: 'AI 任务调度与状态同步',
+ createdAt: '2025-02-20T14:00:00Z',
+ updatedAt: '2025-02-20T16:45:00Z',
+ },
+];
+
+// Mock route info for drawer
+export interface BrokerRoute {
+ brokerName: string;
+ brokerAddr: string;
+ writeQueues: number;
+ readQueues: number;
+ perm: string;
+}
+
+export const topicRoutes: Record<string, BrokerRoute[]> = {
+ 'order-create': [
+ {
+ brokerName: 'broker-a-0',
+ brokerAddr: '10.0.1.10:10911',
+ writeQueues: 8,
+ readQueues: 8,
+ perm: 'RW',
+ },
+ {
+ brokerName: 'broker-b-0',
+ brokerAddr: '10.0.1.11:10911',
+ writeQueues: 8,
+ readQueues: 8,
+ perm: 'RW',
+ },
+ ],
+ 'user-activity-log': [
+ {
+ brokerName: 'broker-a-0',
+ brokerAddr: '10.0.1.10:10911',
+ writeQueues: 8,
+ readQueues: 8,
+ perm: 'RW',
+ },
+ {
+ brokerName: 'broker-b-0',
+ brokerAddr: '10.0.1.11:10911',
+ writeQueues: 8,
+ readQueues: 8,
+ perm: 'RW',
+ },
+ ],
+ 'system-log': [
+ {
+ brokerName: 'broker-a-0',
+ brokerAddr: '10.0.2.10:10911',
+ writeQueues: 4,
+ readQueues: 4,
+ perm: 'RW',
+ },
+ {
+ brokerName: 'broker-b-0',
+ brokerAddr: '10.0.2.11:10911',
+ writeQueues: 4,
+ readQueues: 4,
+ perm: 'RW',
+ },
+ ],
+ 'inventory-sync': [
+ {
+ brokerName: 'broker-a-0',
+ brokerAddr: '10.0.1.10:10911',
+ writeQueues: 8,
+ readQueues: 8,
+ perm: 'RW',
+ },
+ {
+ brokerName: 'broker-b-0',
+ brokerAddr: '10.0.1.11:10911',
+ writeQueues: 8,
+ readQueues: 8,
+ perm: 'RW',
+ },
+ ],
+ 'payment-callback': [
+ {
+ brokerName: 'broker-a-0',
+ brokerAddr: '10.0.1.10:10911',
+ writeQueues: 8,
+ readQueues: 8,
+ perm: 'RW',
+ },
+ {
+ brokerName: 'broker-b-0',
+ brokerAddr: '10.0.1.11:10911',
+ writeQueues: 8,
+ readQueues: 8,
+ perm: 'RW',
+ },
+ ],
+};
+
+// Default route for topics not explicitly listed
+export const defaultRoute: BrokerRoute[] = [
+ {
+ brokerName: 'broker-a-0',
+ brokerAddr: '10.0.1.10:10911',
+ writeQueues: 8,
+ readQueues: 8,
+ perm: 'RW',
+ },
+ {
+ brokerName: 'broker-b-0',
+ brokerAddr: '10.0.1.11:10911',
+ writeQueues: 8,
+ readQueues: 8,
+ perm: 'RW',
+ },
+];
+
+// Mock consumer groups for topics
+export interface ConsumerGroupInfo {
+ group: string;
+ consumeType: 'CLUSTERING' | 'BROADCASTING';
+ messageModel: string;
+ consumeTps: number;
+ diffTotal: number;
+}
+
+export const topicConsumers: Record<string, ConsumerGroupInfo[]> = {
+ 'order-create': [
+ {
+ group: 'GID_order_service',
+ consumeType: 'CLUSTERING',
+ messageModel: '集群消费',
+ consumeTps: 640,
+ diffTotal: 120,
+ },
+ {
+ group: 'GID_inventory_deduction',
+ consumeType: 'CLUSTERING',
+ messageModel: '集群消费',
+ consumeTps: 320,
+ diffTotal: 0,
+ },
+ {
+ group: 'GID_risk_control',
+ consumeType: 'BROADCASTING',
+ messageModel: '广播消费',
+ consumeTps: 180,
+ diffTotal: 45,
+ },
+ {
+ group: 'GID_notification_trigger',
+ consumeType: 'CLUSTERING',
+ messageModel: '集群消费',
+ consumeTps: 140,
+ diffTotal: 0,
+ },
+ ],
+ 'user-activity-log': [
+ {
+ group: 'GID_user_profile_sync',
+ consumeType: 'CLUSTERING',
+ messageModel: '集群消费',
+ consumeTps: 2100,
+ diffTotal: 350,
+ },
+ {
+ group: 'GID_recommendation_engine',
+ consumeType: 'CLUSTERING',
+ messageModel: '集群消费',
+ consumeTps: 1350,
+ diffTotal: 0,
+ },
+ ],
+ 'payment-callback': [
+ {
+ group: 'GID_payment_service',
+ consumeType: 'CLUSTERING',
+ messageModel: '集群消费',
+ consumeTps: 280,
+ diffTotal: 0,
+ },
+ {
+ group: 'GID_order_status_update',
+ consumeType: 'CLUSTERING',
+ messageModel: '集群消费',
+ consumeTps: 140,
+ diffTotal: 12,
+ },
+ ],
+ 'inventory-sync': [
+ {
+ group: 'GID_warehouse_sync',
+ consumeType: 'CLUSTERING',
+ messageModel: '集群消费',
+ consumeTps: 340,
+ diffTotal: 0,
+ },
+ {
+ group: 'GID_stock_alert',
+ consumeType: 'CLUSTERING',
+ messageModel: '集群消费',
+ consumeTps: 340,
+ diffTotal: 88,
+ },
+ ],
+};
+
+export const defaultConsumers: ConsumerGroupInfo[] = [
+ {
+ group: 'GID_default_consumer',
+ consumeType: 'CLUSTERING',
+ messageModel: '集群消费',
+ consumeTps: 100,
+ diffTotal: 0,
+ },
+];
diff --git a/web/src/pages/ai/index.tsx b/web/src/pages/ai/index.tsx
new file mode 100644
index 0000000..8c8e38a
--- /dev/null
+++ b/web/src/pages/ai/index.tsx
@@ -0,0 +1,630 @@
+/*
+ * 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 { useState, useRef, useEffect, useCallback } from 'react';
+import {
+ Card,
+ Button,
+ Tag,
+ Typography,
+ Table,
+ Statistic,
+ Row,
+ Col,
+ Descriptions,
+ Flex,
+ Divider,
+ Select,
+} from 'antd';
+import { ArrowUp, Sparkle, SlidersHorizontal, CaretDown } from '@phosphor-icons/react';
+import type { ColumnsType } from 'antd/es/table';
+import { useLang } from '../../i18n/LangContext';
+
+const { Text, Paragraph } = Typography;
+
+/* ─── Types ─── */
+
+interface ToolCallTag {
+ name: string;
+ label: string;
+}
+
+interface TopicRow {
+ key: string;
+ name: string;
+ type: string;
+ queues: number;
+}
+
+interface StatItem {
+ title: string;
+ value: string | number;
+ suffix?: string;
+ color: string;
+}
+
+interface DescriptionItem {
+ label: string;
+ value: string;
+}
+
+interface Message {
+ id: string;
+ role: 'user' | 'ai';
+ text?: string;
+ toolCall?: ToolCallTag;
+ tableData?: TopicRow[];
+ tableColumns?: ColumnsType<TopicRow>;
+ stats?: StatItem[];
+ descriptions?: DescriptionItem[];
+ summary?: string;
+ actions?: { label: string; type?: 'primary' | 'default' }[];
+}
+
+/* ─── Model Options ─── */
+
+const modelOptions = [
+ {
+ value: 'qwen3.7-max',
+ label: (
+ <span className="inline-flex items-center gap-1.5">
+ Qwen3.7-Max
+ <span className="px-1 py-0.5 rounded text-[0.625rem] leading-none bg-purple-50 text-purple-600 font-medium">
+ 推荐
+ </span>
+ </span>
+ ),
+ },
+ { value: 'qwen3.7-plus', label: 'Qwen3.7-Plus' },
+ { value: 'claude-opus-4.7', label: 'Claude Opus 4.7' },
+ { value: 'gpt-5.4', label: 'GPT-5.4' },
+];
+
+/* ─── Mock Data ─── */
+
+const topicColumns: ColumnsType<TopicRow> = [
+ { title: 'Topic 名称', dataIndex: 'name', key: 'name' },
+ {
+ title: '类型',
+ dataIndex: 'type',
+ key: 'type',
+ render: (type: string) => {
+ const map: Record<string, { label: string; color: string }> = {
+ NORMAL: { label: '普通', color: 'default' },
+ FIFO: { label: '顺序', color: 'blue' },
+ DELAY: { label: '延迟', color: 'orange' },
+ TRANSACTION: { label: '事务', color: 'purple' },
+ };
+ const cfg = map[type] || { label: type, color: 'default' };
+ return <Tag color={cfg.color}>{cfg.label}</Tag>;
+ },
+ },
+ { title: '队列数', dataIndex: 'queues', key: 'queues', width: 80, align: 'center' },
+];
+
+const mockTopicData: TopicRow[] = [
+ { key: '1', name: 'order-create', type: 'TRANSACTION', queues: 16 },
+ { key: '2', name: 'payment-notify', type: 'NORMAL', queues: 12 },
+ { key: '3', name: 'user-login-event', type: 'NORMAL', queues: 8 },
+ { key: '4', name: 'inventory-sync', type: 'FIFO', queues: 8 },
+ { key: '5', name: 'promo-push', type: 'DELAY', queues: 4 },
+];
+
+const initialMessages: Message[] = [
+ {
+ id: 'm1',
+ role: 'user',
+ text: '查看生产集群-杭州的 Topic 列表',
+ },
+ {
+ id: 'm2',
+ role: 'ai',
+ toolCall: { name: 'list_topics', label: '🔧 list_topics' },
+ tableData: mockTopicData,
+ tableColumns: topicColumns,
+ summary: '生产集群-杭州 共有 128 个 Topic,以上为按吞吐量排序的前 5 个。',
+ },
+ {
+ id: 'm3',
+ role: 'user',
+ text: 'order-create 这个 Topic 最近 1 小时的堆积情况',
+ },
+ {
+ id: 'm4',
+ role: 'ai',
+ toolCall: { name: 'get_topic_lag', label: '🔧 get_topic_lag' },
+ stats: [
+ { title: '当前堆积', value: '2,340', color: '#1677ff' },
+ { title: '消费速率', value: '856', suffix: '/s', color: '#52c41a' },
+ { title: '预计追平', value: '2.7', suffix: 's', color: '#722ed1' },
+ ],
+ summary: 'order-create 消费状态良好,堆积量较低,消费速率稳定。',
+ },
+ {
+ id: 'm5',
+ role: 'user',
+ text: '帮我创建一个事务类型的 Topic,名称 order-refund',
+ },
+ {
+ id: 'm6',
+ role: 'ai',
+ toolCall: { name: 'create_topic', label: '🔧 create_topic (dry-run)' },
+ descriptions: [
+ { label: '名称', value: 'order-refund' },
+ { label: '类型', value: 'TRANSACTION' },
+ { label: '队列数', value: '16' },
+ { label: '集群', value: '生产集群-杭州' },
+ ],
+ summary: '已生成创建预览,确认后将执行创建操作。',
+ actions: [
+ { label: '确认创建', type: 'primary' },
+ { label: '取消', type: 'default' },
+ ],
+ },
+];
+
+/* ─── Quick Actions ─── */
+
+const quickActions = [
+ '查看集群状态',
+ 'Topic 堆积 Top10',
+ '诊断消费延迟',
+ '创建 Topic',
+ '消息轨迹查询',
+ '扩缩容评估',
+];
+
+/* ─── Sub-components ─── */
+
+const UserBubble = ({ text }: { text: string }) => (
+ <Flex justify="flex-end" style={{ marginBottom: 16 }}>
+ <div
+ style={{
+ maxWidth: '70%',
+ padding: '10px 16px',
+ background: '#e6f4ff',
+ borderRadius: 16,
+ borderTopRightRadius: 4,
+ lineHeight: 1.6,
+ fontSize: 14,
+ }}
+ >
+ {text}
+ </div>
+ </Flex>
+);
+
+const AiMessage = ({ msg }: { msg: Message }) => (
+ <Flex gap={12} align="flex-start" style={{ marginBottom: 16 }}>
+ <div
+ style={{
+ width: 36,
+ height: 36,
+ borderRadius: '50%',
+ background: 'linear-gradient(135deg, #1677ff 0%, #722ed1 100%)',
+ flexShrink: 0,
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ boxShadow: '0 2px 8px rgba(22, 119, 255, 0.3)',
+ }}
+ >
+ <svg
+ width="20"
+ height="20"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="white"
+ strokeWidth="2"
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ >
+ <path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z" />
+ <path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z" />
+ <path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0" />
+ <path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5" />
+ </svg>
+ </div>
+ <Card
+ size="small"
+ style={{
+ maxWidth: '75%',
+ boxShadow: '0 1px 4px rgba(0, 0, 0, 0.06)',
+ borderRadius: 12,
+ borderTopLeftRadius: 4,
+ }}
+ styles={{ body: { padding: '12px 16px' } }}
+ >
+ {/* Tool call indicator */}
+ {msg.toolCall && (
+ <Tag
+ color="purple"
+ style={{
+ marginBottom: 12,
+ borderRadius: 6,
+ fontSize: 12,
+ background: '#f9f0ff',
+ borderColor: '#d3adf7',
+ }}
+ >
+ {msg.toolCall.label}
+ </Tag>
+ )}
+
+ {/* Table content */}
+ {msg.tableData && msg.tableColumns && (
+ <Table
+ dataSource={msg.tableData}
+ columns={msg.tableColumns}
+ rowKey="key"
+ size="small"
+ pagination={false}
+ style={{ marginBottom: 12 }}
+ />
+ )}
+
+ {/* Stat cards */}
+ {msg.stats && (
+ <Row gutter={12} style={{ marginBottom: 12 }}>
+ {msg.stats.map((s) => (
+ <Col key={s.title}>
+ <Card
+ size="small"
+ style={{
+ borderRadius: 8,
+ borderTop: `3px solid ${s.color}`,
+ minWidth: 120,
+ }}
+ styles={{ body: { padding: '8px 12px' } }}
+ >
+ <Statistic
+ title={
+ <Text type="secondary" style={{ fontSize: 12 }}>
+ {s.title}
+ </Text>
+ }
+ value={s.value}
+ suffix={s.suffix}
+ valueStyle={{ fontSize: 20, fontWeight: 600, color: s.color }}
+ />
+ </Card>
+ </Col>
+ ))}
+ </Row>
+ )}
+
+ {/* Descriptions */}
+ {msg.descriptions && (
+ <Descriptions bordered size="small" column={2} style={{ marginBottom: 12 }}>
+ {msg.descriptions.map((d) => (
+ <Descriptions.Item key={d.label} label={d.label}>
+ <Text strong>{d.value}</Text>
+ </Descriptions.Item>
+ ))}
+ </Descriptions>
+ )}
+
+ {/* Summary text */}
+ {msg.summary && (
+ <Paragraph style={{ margin: 0, fontSize: 14, color: '#595959' }}>{msg.summary}</Paragraph>
+ )}
+
+ {/* Action buttons */}
+ {msg.actions && (
+ <>
+ <Divider style={{ margin: '12px 0 8px' }} />
+ <Flex gap={8}>
+ {msg.actions.map((a) => (
+ <Button key={a.label} type={a.type || 'default'} size="small">
+ {a.label}
+ </Button>
+ ))}
+ </Flex>
+ </>
+ )}
+ </Card>
+ </Flex>
+);
+
+/* ═══════════════════════════════════════════
+ AiPage
+ ═══════════════════════════════════════════ */
+
+const AiPage = () => {
+ const { t } = useLang();
+ const [messages, setMessages] = useState<Message[]>(initialMessages);
+ const [inputValue, setInputValue] = useState('');
+ const [loading, setLoading] = useState(false);
+ const [selectedModel, setSelectedModel] = useState('qwen3.7-max');
+ const chatEndRef = useRef<HTMLDivElement>(null);
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
+
+ const scrollToBottom = useCallback(() => {
+ chatEndRef.current?.scrollIntoView({ behavior: 'smooth' });
+ }, []);
+
+ useEffect(() => {
+ scrollToBottom();
+ }, [messages, scrollToBottom]);
+
+ /* ─── Auto-resize textarea ─── */
+ useEffect(() => {
+ const ta = textareaRef.current;
+ if (!ta) return;
+ const handler = () => {
+ ta.style.height = 'auto';
+ ta.style.height = `${Math.min(ta.scrollHeight, 300)}px`;
+ };
+ ta.addEventListener('input', handler);
+ return () => ta.removeEventListener('input', handler);
+ }, []);
+
+ const handleSend = useCallback(() => {
+ const text = inputValue.trim();
+ if (!text || loading) return;
+
+ const userMsg: Message = {
+ id: `user-${Date.now()}`,
+ role: 'user',
+ text,
+ };
+
+ setMessages((prev) => [...prev, userMsg]);
+ setInputValue('');
+ if (textareaRef.current) {
+ textareaRef.current.style.height = 'auto';
+ }
+ setLoading(true);
+
+ // Mock AI response after brief delay
+ setTimeout(() => {
+ const aiMsg: Message = {
+ id: `ai-${Date.now()}`,
+ role: 'ai',
+ toolCall: { name: 'process_query', label: '🔧 process_query' },
+ summary: `已收到你的问题:「${text}」。AI 助手正在分析并调用相关 MCP 工具,结果将在此处展示。(当前为演示模式)`,
+ };
+ setMessages((prev) => [...prev, aiMsg]);
+ setLoading(false);
+ }, 1200);
+ }, [inputValue, loading]);
+
+ const handleKeyDown = useCallback(
+ (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault();
+ handleSend();
+ }
+ },
+ [handleSend],
+ );
+
+ const handleQuickAction = useCallback((action: string) => {
+ setInputValue(action);
+ textareaRef.current?.focus();
+ }, []);
+
+ return (
+ <Flex vertical style={{ height: 'calc(100vh - 48px)', padding: 24 }}>
+ {/* Chat Area */}
+ <div
+ className="w-full scrollbar-hide"
+ style={{
+ flex: 1,
+ overflowY: 'auto',
+ padding: '16px 24px',
+ scrollBehavior: 'smooth',
+ }}
+ >
+ {messages.map((msg) =>
+ msg.role === 'user' ? (
+ <UserBubble key={msg.id} text={msg.text!} />
+ ) : (
+ <AiMessage key={msg.id} msg={msg} />
+ ),
+ )}
+ {loading && (
+ <Flex gap={12} align="flex-start" style={{ marginBottom: 16 }}>
+ <div
+ style={{
+ width: 36,
+ height: 36,
+ borderRadius: '50%',
+ background: 'linear-gradient(135deg, #1677ff 0%, #722ed1 100%)',
+ flexShrink: 0,
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ boxShadow: '0 2px 8px rgba(22, 119, 255, 0.3)',
+ }}
+ >
+ <svg
+ width="20"
+ height="20"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="white"
+ strokeWidth="2"
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ >
+ <path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z" />
+ <path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z" />
+ <path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0" />
+ <path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5" />
+ </svg>
+ </div>
+ <Card
+ size="small"
+ style={{
+ boxShadow: '0 1px 4px rgba(0, 0, 0, 0.06)',
+ borderRadius: 12,
+ borderTopLeftRadius: 4,
+ }}
+ styles={{ body: { padding: '12px 16px' } }}
+ >
+ <Flex gap={4} align="center">
+ <span
+ style={{
+ display: 'inline-block',
+ width: 6,
+ height: 6,
+ borderRadius: '50%',
+ background: '#722ed1',
+ animation: 'dotPulse 1.4s infinite ease-in-out',
+ }}
+ />
+ <span
+ style={{
+ display: 'inline-block',
+ width: 6,
+ height: 6,
+ borderRadius: '50%',
+ background: '#722ed1',
+ animation: 'dotPulse 1.4s infinite ease-in-out 0.2s',
+ }}
+ />
+ <span
+ style={{
+ display: 'inline-block',
+ width: 6,
+ height: 6,
+ borderRadius: '50%',
+ background: '#722ed1',
+ animation: 'dotPulse 1.4s infinite ease-in-out 0.4s',
+ }}
+ />
+ </Flex>
+ </Card>
+ </Flex>
+ )}
+ <div ref={chatEndRef} />
+ </div>
+
+ {/* Input Area */}
+ <div className="w-full" style={{ flexShrink: 0, padding: '0 24px' }}>
+ {/* Quick Actions */}
+ <Flex align="center" gap={8} style={{ marginBottom: 12 }} wrap="wrap">
+ <Text type="secondary" style={{ fontSize: 13, flexShrink: 0 }}>
+ {t('ai.commonCommands')}
+ </Text>
+ {quickActions.map((action) => (
+ <Tag
+ key={action}
+ style={{
+ cursor: 'pointer',
+ borderRadius: 6,
+ padding: '2px 10px',
+ fontSize: 13,
+ userSelect: 'none',
+ transition: 'all 0.2s',
+ }}
+ color="blue"
+ onClick={() => handleQuickAction(action)}
+ >
+ {action}
+ </Tag>
+ ))}
+ </Flex>
+
+ {/* Main Input Box */}
+ <div className="relative overflow-visible border-[1.5px] backdrop-blur-xl border-white rounded-2xl bg-white/80 shadow-[0_20px_60px_-20px_rgba(80,90,180,0.18)]">
+ {/* Model Selector */}
+ <div className="flex items-center justify-between gap-3 px-3.5 pt-4">
+ <div className="flex flex-1 min-w-0 items-center gap-2">
+ <Select
+ size="small"
+ value={selectedModel}
+ onChange={(val) => setSelectedModel(val)}
+ options={modelOptions}
+ variant="borderless"
+ popupMatchSelectWidth={false}
+ suffixIcon={<CaretDown size={10} color="#9CA3AF" />}
+ className="model-selector"
+ style={{ fontSize: '0.893rem' }}
+ />
+ </div>
+ </div>
+
+ {/* Textarea */}
+ <div className="relative flex flex-col">
+ <textarea
+ ref={textareaRef}
+ className="chat-input"
+ value={inputValue}
+ onChange={(e) => setInputValue(e.target.value)}
+ onKeyDown={handleKeyDown}
+ placeholder="输入你的问题或指令,例如:查看集群状态、创建 Topic、诊断消费延迟..."
+ />
+ <Sparkle
+ className="text-gray-400"
+ style={{
+ position: 'absolute',
+ top: 18,
+ left: 26,
+ fontSize: 17,
+ }}
+ />
+ </div>
+
+ {/* Bottom Toolbar */}
+ <div className="flex justify-between text-sm items-center px-3.5 py-3 border-t border-gray-100/80">
+ <div className="flex flex-1 gap-1 items-center min-w-0">
+ <div className="flex items-center gap-2 w-full">
+ <div className="flex-1 min-w-0">
+ <div className="flex items-center gap-2 overflow-x-auto scrollbar-hide max-w-full py-2">
+ <button className="tool-btn">
+ <SlidersHorizontal size={17} />
+ <span>工具</span>
+ </button>
+ <button className="tool-btn">
+ <Sparkle size={17} />
+ <span>Prompt 增强</span>
+ </button>
+ </div>
+ </div>
+ <div className="shrink-0 flex items-center gap-1">
+ <button
+ className="flex items-center justify-center w-9 h-9 rounded-full bg-gradient-to-r from-purple-500 to-violet-600 text-white shadow-lg hover:shadow-xl transition-all hover:scale-105"
+ onClick={handleSend}
+ disabled={loading || !inputValue.trim()}
+ style={{
+ opacity: loading || !inputValue.trim() ? 0.5 : 1,
+ cursor: loading || !inputValue.trim() ? 'not-allowed' : 'pointer',
+ }}
+ >
+ <ArrowUp size={19} weight="bold" />
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ {/* Keyframes for loading animation */}
+ <style>{`
+ @keyframes dotPulse {
+ 0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); }
+ 40% { opacity: 1; transform: scale(1.2); }
+ }
+ `}</style>
+ </Flex>
+ );
+};
+
+export default AiPage;
diff --git a/web/src/pages/cluster/certs.tsx b/web/src/pages/cluster/certs.tsx
new file mode 100644
index 0000000..8449357
--- /dev/null
+++ b/web/src/pages/cluster/certs.tsx
@@ -0,0 +1,298 @@
+/*
+ * 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 { useState } from 'react';
+import {
+ Table,
+ Tag,
+ Button,
+ Input,
+ Select,
+ Modal,
+ Form,
+ Flex,
+ Space,
+ Typography,
+ Card,
+ message,
+} from 'antd';
+import type { ColumnsType } from 'antd/es/table';
+import { EditOutlined, DeleteOutlined, PlusOutlined } from '@ant-design/icons';
+import PageHeader from '../../components/PageHeader';
+import { mockK8sCerts, type K8sCertInfo } from '../../mock/clusters';
+
+const { Text } = Typography;
+
+const formatDateTime = (iso: string): string => {
+ const d = new Date(iso);
+ 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 K8sCertsPage = () => {
+ const [certs, setCerts] = useState<K8sCertInfo[]>(mockK8sCerts);
+ const [certSearch, setCertSearch] = useState('');
+ const [certTypeFilter, setCertTypeFilter] = useState<string>('');
+ const [editModalOpen, setEditModalOpen] = useState(false);
+ const [editingCert, setEditingCert] = useState<K8sCertInfo | null>(null);
+ const [editForm] = Form.useForm();
+
+ const filteredCerts = certs.filter((cert) => {
+ const matchSearch =
+ !certSearch ||
+ cert.name.toLowerCase().includes(certSearch.toLowerCase()) ||
+ cert.cluster.toLowerCase().includes(certSearch.toLowerCase());
+ const matchType = !certTypeFilter || cert.type === certTypeFilter;
+ return matchSearch && matchType;
+ });
+
+ const certColumns: ColumnsType<K8sCertInfo> = [
+ {
+ title: 'K8s 集群名称',
+ dataIndex: 'cluster',
+ key: 'cluster',
+ width: 160,
+ sorter: (a, b) => a.cluster.localeCompare(b.cluster),
+ render: (name: string) => <Text strong>{name}</Text>,
+ },
+ {
+ title: '证书名称',
+ dataIndex: 'name',
+ key: 'name',
+ width: 280,
+ sorter: (a, b) => a.name.localeCompare(b.name),
+ render: (name: string) => (
+ <Text style={{ fontFamily: 'monospace', fontSize: 13 }}>{name}</Text>
+ ),
+ },
+ {
+ title: '类型',
+ dataIndex: 'type',
+ key: 'type',
+ width: 130,
+ sorter: (a, b) => a.type.localeCompare(b.type),
+ render: (type: string) => {
+ const colorMap: Record<string, string> = {
+ TLS: 'blue',
+ mTLS: 'purple',
+ ServiceAccount: 'orange',
+ };
+ return <Tag color={colorMap[type] ?? 'default'}>{type}</Tag>;
+ },
+ },
+ {
+ title: '签发者',
+ dataIndex: 'issuer',
+ key: 'issuer',
+ width: 130,
+ sorter: (a, b) => a.issuer.localeCompare(b.issuer),
+ },
+ {
+ title: '到期时间',
+ dataIndex: 'notAfter',
+ key: 'notAfter',
+ width: 170,
+ sorter: (a, b) => new Date(a.notAfter).getTime() - new Date(b.notAfter).getTime(),
+ render: (iso: string) => (
+ <Text type="secondary" style={{ fontSize: 13 }}>
+ {formatDateTime(iso)}
+ </Text>
+ ),
+ },
+ {
+ title: '剩余天数',
+ dataIndex: 'daysRemaining',
+ key: 'daysRemaining',
+ width: 100,
+ sorter: (a, b) => a.daysRemaining - b.daysRemaining,
+ render: (days: number) => (
+ <Text
+ style={{
+ color: days <= 0 ? '#ff4d4f' : days <= 30 ? '#faad14' : '#52c41a',
+ fontWeight: 500,
+ }}
+ >
+ {days}
+ </Text>
+ ),
+ },
+ {
+ title: '状态',
+ dataIndex: 'status',
+ key: 'status',
+ width: 100,
+ sorter: (a, b) => a.status.localeCompare(b.status),
+ render: (status: string) => {
+ const map: Record<string, { color: string; label: string }> = {
+ valid: { color: 'green', label: '有效' },
+ expiring: { color: 'orange', label: '即将过期' },
+ expired: { color: 'red', label: '已过期' },
+ };
+ const cfg = map[status] ?? { color: 'default', label: status };
+ return <Tag color={cfg.color}>{cfg.label}</Tag>;
+ },
+ },
+ {
+ title: '操作',
+ key: 'action',
+ width: 200,
+ render: (_: unknown, record: K8sCertInfo) => (
+ <Flex gap={6}>
+ <Button
+ size="small"
+ icon={<EditOutlined />}
+ style={{ borderColor: '#1677ff', color: '#1677ff' }}
+ onClick={() => {
+ setEditingCert(record);
+ editForm.setFieldsValue({
+ name: record.name,
+ type: record.type,
+ issuer: record.issuer,
+ namespace: record.namespace,
+ });
+ setEditModalOpen(true);
+ }}
+ >
+ 编辑
+ </Button>
+ <Button
+ size="small"
+ icon={<DeleteOutlined />}
+ style={{ borderColor: '#ff4d4f', color: '#ff4d4f' }}
+ onClick={() => {
+ Modal.confirm({
+ title: '确认删除',
+ content: `确定要删除证书 "${record.name}" 吗?`,
+ okText: '确认',
+ cancelText: '取消',
+ okButtonProps: { danger: true },
+ onOk: () => {
+ setCerts((prev) => prev.filter((c) => c.id !== record.id));
+ message.success(`证书已删除: ${record.name}`);
+ },
+ });
+ }}
+ >
+ 删除
+ </Button>
+ </Flex>
+ ),
+ },
+ ];
+
+ return (
+ <div style={{ padding: 24 }}>
+ <PageHeader
+ title="K8s 证书管理"
+ subtitle={`共 ${filteredCerts.length} 个证书`}
+ extra={
+ <Button
+ type="primary"
+ icon={<PlusOutlined />}
+ onClick={() => message.info('添加证书功能开发中')}
+ >
+ 添加证书
+ </Button>
+ }
+ />
+ <Flex justify="space-between" style={{ marginBottom: 16 }}>
+ <Space>
+ <Input.Search
+ placeholder="搜索证书名称或集群"
+ allowClear
+ onSearch={setCertSearch}
+ onChange={(e) => !e.target.value && setCertSearch('')}
+ style={{ width: 240 }}
+ />
+ <Select
+ value={certTypeFilter}
+ onChange={setCertTypeFilter}
+ style={{ width: 160 }}
+ options={[
+ { value: '', label: '全部' },
+ { value: 'TLS', label: 'TLS' },
+ { value: 'mTLS', label: 'mTLS' },
+ { value: 'ServiceAccount', label: 'ServiceAccount' },
+ ]}
+ />
+ </Space>
+ </Flex>
+ <Card bodyStyle={{ padding: 0 }}>
+ <Table
+ columns={certColumns}
+ dataSource={filteredCerts}
+ rowKey="id"
+ pagination={{ pageSize: 20 }}
+ size="small"
+ />
+ </Card>
+
+ {/* Edit Cert Modal */}
+ <Modal
+ title={`编辑证书 — ${editingCert?.name || ''}`}
+ open={editModalOpen}
+ onCancel={() => {
+ setEditModalOpen(false);
+ editForm.resetFields();
+ }}
+ onOk={() => {
+ editForm.validateFields().then((values) => {
+ if (!editingCert) return;
+ setCerts((prev) =>
+ prev.map((c) =>
+ c.id === editingCert.id
+ ? { ...c, issuer: values.issuer, namespace: values.namespace }
+ : c,
+ ),
+ );
+ message.success(`证书「${editingCert.name}」已更新`);
+ setEditModalOpen(false);
+ editForm.resetFields();
+ });
+ }}
+ okText="保存"
+ cancelText="取消"
+ width={520}
+ >
+ <Form form={editForm} layout="vertical" style={{ marginTop: 16 }}>
+ <Form.Item label="证书名称">
+ <Input value={editingCert?.name} disabled />
+ </Form.Item>
+ <Form.Item label="类型">
+ <Select
+ value={editingCert?.type}
+ disabled
+ options={[
+ { value: 'TLS', label: 'TLS' },
+ { value: 'mTLS', label: 'mTLS' },
+ { value: 'ServiceAccount', label: 'ServiceAccount' },
+ ]}
+ />
+ </Form.Item>
+ <Form.Item label="签发者" name="issuer">
+ <Input placeholder="例:kubernetes-ca" />
+ </Form.Item>
+ <Form.Item label="命名空间" name="namespace">
+ <Input placeholder="例:kube-system" />
+ </Form.Item>
+ </Form>
+ </Modal>
+ </div>
+ );
+};
+
+export default K8sCertsPage;
diff --git a/web/src/pages/cluster/clients.tsx b/web/src/pages/cluster/clients.tsx
new file mode 100644
index 0000000..04bba37
--- /dev/null
+++ b/web/src/pages/cluster/clients.tsx
@@ -0,0 +1,252 @@
+/*
+ * 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 { useState, useMemo } from 'react';
+import { Table, Card, Tag, Space, Input, Select, Flex, Typography } from 'antd';
+import { MagnifyingGlass } from '@phosphor-icons/react';
+import type { ColumnsType } from 'antd/es/table';
+
+import PageHeader from '../../components/PageHeader';
+import { mockClients } from '../../mock/clients';
+import type { ClientConnection } from '../../mock/clients';
+import clusters from '../../mock/clusters';
+import { useLang } from '../../i18n/LangContext';
+
+const { Text } = Typography;
+
+/* ─── Helpers ─── */
+
+const typeConfig: Record<string, { color: string; label: string }> = {
+ Producer: { color: 'blue', label: 'Producer' },
+ Consumer: { color: 'green', label: 'Consumer' },
+};
+
+const protocolConfig: Record<string, { color: string; label: string }> = {
+ gRPC: { color: 'green', label: 'gRPC' },
+ Remoting: { color: 'blue', label: 'Remoting' },
+};
+
+const languageConfig: Record<string, { color: string; label: string }> = {
+ Java: { color: 'default', label: 'Java' },
+ Go: { color: 'cyan', label: 'Go' },
+ Python: { color: 'purple', label: 'Python' },
+ Rust: { color: 'orange', label: 'Rust' },
+};
+
+/* ═══════════════════════════════════════════
+ ClientsPage
+ ═══════════════════════════════════════════ */
+const ClientsPage = () => {
+ const { t } = useLang();
+ const [search, setSearch] = useState('');
+ const [clusterFilter, setClusterFilter] = useState<string>('ALL');
+
+ /* ─── Cluster options using nsClusterName ─── */
+ const clusterOptions = useMemo(() => {
+ return [
+ { value: 'ALL', label: t('clients.allClusters') },
+ ...clusters.map((c) => ({ value: c.nsClusterName, label: c.nsClusterName })),
+ ];
+ }, []);
+
+ /* ─── Filtered data (search + cluster only, table handles column filters) ─── */
+ const filtered = useMemo(() => {
+ let data = mockClients.filter(
+ (c) => c.clientId.toLowerCase().includes(search.toLowerCase()) || c.address.includes(search),
+ );
+
+ if (clusterFilter !== 'ALL') {
+ data = data.filter((c) => c.clusterName === clusterFilter);
+ }
+
+ return data;
+ }, [search, clusterFilter]);
+
+ /* ═══════════════════════════════════════════
+ Table Columns (with built-in filters)
+ ═══════════════════════════════════════════ */
+ const columns: ColumnsType<ClientConnection> = [
+ {
+ title: t('clients.cluster'),
+ dataIndex: 'clusterName',
+ key: 'clusterName',
+ width: 130,
+ filters: clusters.map((c) => ({ text: c.nsClusterName, value: c.nsClusterName })),
+ onFilter: (value, record) => record.clusterName === value,
+ render: (name: string) => (
+ <Tag color="blue" style={{ fontSize: 12 }}>
+ {name}
+ </Tag>
+ ),
+ },
+ {
+ title: t('clients.clientId'),
+ dataIndex: 'clientId',
+ key: 'clientId',
+ width: 260,
+ render: (id: string) => (
+ <Text
+ copyable
+ style={{
+ fontSize: 13,
+ fontFamily: 'monospace',
+ whiteSpace: 'nowrap',
+ }}
+ >
+ {id}
+ </Text>
+ ),
+ },
+ {
+ title: t('common.type'),
+ dataIndex: 'type',
+ key: 'type',
+ width: 100,
+ filters: [
+ { text: 'Producer', value: 'Producer' },
+ { text: 'Consumer', value: 'Consumer' },
+ ],
+ onFilter: (value, record) => record.type === value,
+ render: (type: string) => {
+ const cfg = typeConfig[type] ?? { label: type };
+ return <Text style={{ fontSize: 13 }}>{cfg.label}</Text>;
+ },
+ },
+ {
+ title: t('clients.groupOrTopic'),
+ dataIndex: 'groupOrTopic',
+ key: 'groupOrTopic',
+ width: 180,
+ render: (name: string) => (
+ <Text strong style={{ fontSize: 13 }}>
+ {name}
+ </Text>
+ ),
+ },
+ {
+ title: t('clients.protocol'),
+ dataIndex: 'protocol',
+ key: 'protocol',
+ width: 110,
+ filters: [
+ { text: 'gRPC', value: 'gRPC' },
+ { text: 'Remoting', value: 'Remoting' },
+ ],
+ onFilter: (value, record) => record.protocol === value,
+ render: (protocol: string) => {
+ const cfg = protocolConfig[protocol] ?? { color: 'default', label: protocol };
+ return <Tag color={cfg.color}>{cfg.label}</Tag>;
+ },
+ },
+ {
+ title: t('common.address'),
+ dataIndex: 'address',
+ key: 'address',
+ width: 180,
+ render: (addr: string) => (
+ <Text style={{ fontSize: 13, fontFamily: 'monospace' }}>{addr}</Text>
+ ),
+ },
+ {
+ title: t('clients.language'),
+ dataIndex: 'language',
+ key: 'language',
+ width: 100,
+ filters: [
+ { text: 'Java', value: 'Java' },
+ { text: 'Go', value: 'Go' },
+ { text: 'Python', value: 'Python' },
+ { text: 'Rust', value: 'Rust' },
+ ],
+ onFilter: (value, record) => record.language === value,
+ render: (lang: string) => {
+ const cfg = languageConfig[lang] ?? { color: 'default', label: lang };
+ return <Tag color={cfg.color}>{cfg.label}</Tag>;
+ },
+ },
+ {
+ title: t('common.version'),
+ dataIndex: 'version',
+ key: 'version',
+ width: 90,
+ },
+ {
+ title: t('cluster.heartbeat'),
+ dataIndex: 'connectedAt',
+ key: 'connectedAt',
+ width: 170,
+ sorter: (a, b) => a.connectedAt.localeCompare(b.connectedAt),
+ render: (d: string) => (
+ <Text type="secondary" style={{ fontSize: 13 }}>
+ {d}
+ </Text>
+ ),
+ },
+ ];
+
+ /* ═══════════════════════════════════════════
+ Render
+ ═══════════════════════════════════════════ */
+ return (
+ <div style={{ padding: 24 }}>
+ {/* ─── Header ─── */}
+ <PageHeader
+ title={t('clients.title')}
+ subtitle={`${t('clients.title')} — ${filtered.length} connections`}
+ />
+
+ {/* ─── Filter Bar ─── */}
+ <Flex justify="space-between" align="center" style={{ marginBottom: 16 }}>
+ <Space size={12} wrap>
+ <Select
+ value={clusterFilter}
+ onChange={setClusterFilter}
+ style={{ width: 180 }}
+ options={clusterOptions}
+ />
+ <Input.Search
+ placeholder={t('clients.searchPlaceholder')}
+ allowClear
+ value={search}
+ onChange={(e) => setSearch(e.target.value)}
+ onSearch={setSearch}
+ style={{ width: 280 }}
+ prefix={<MagnifyingGlass size={14} color="#9CA3AF" />}
+ />
+ </Space>
+ </Flex>
+
+ {/* ─── Table ─── */}
+ <Card bodyStyle={{ padding: 0 }}>
+ <Table
+ columns={columns}
+ dataSource={filtered}
+ rowKey="clientId"
+ scroll={{ x: 1320 }}
+ pagination={{
+ pageSize: 20,
+ showSizeChanger: true,
+ showTotal: (total) => `${t('common.total')} ${total}`,
+ }}
+ size="small"
+ />
+ </Card>
+ </div>
+ );
+};
+
+export default ClientsPage;
diff --git a/web/src/pages/cluster/index.tsx b/web/src/pages/cluster/index.tsx
new file mode 100644
index 0000000..f22ca74
--- /dev/null
+++ b/web/src/pages/cluster/index.tsx
@@ -0,0 +1,860 @@
+/*
+ * 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 { useState, useEffect } from 'react';
+import {
+ Table,
+ Tabs,
+ Tag,
+ Button,
+ Input,
+ Select,
+ Modal,
+ Form,
+ Radio,
+ Switch,
+ InputNumber,
+ Progress,
+ Flex,
+ Space,
+ Typography,
+ Card,
+ message,
+} from 'antd';
+import type { ColumnsType } from 'antd/es/table';
+import {
+ ReloadOutlined,
+ SettingOutlined,
+ EyeOutlined,
+ EditOutlined,
+ PlusOutlined,
+} from '@ant-design/icons';
+import { Cpu, HardDrives, Globe } from '@phosphor-icons/react';
+import PageHeader from '../../components/PageHeader';
+import { useLang } from '../../i18n/LangContext';
+import clusters, {
+ type BrokerInfo,
+ type ProxyInfo,
+ type NameServerInfo,
+ type ClusterConfig,
+ type ClusterInfo,
+} from '../../mock/clusters';
+
+const { Text } = Typography;
+
+// ─── Page ─────────────────────────────────────────────────────────────────────
+
+const ClusterPage = () => {
+ const { t } = useLang();
+ const [nsSearch, setNsSearch] = useState('');
+ const [brokerSearch, setBrokerSearch] = useState('');
+ const [brokerNsClusterFilter, setBrokerNsClusterFilter] = useState<string>('');
+ const [proxySearch, setProxySearch] = useState('');
+
+ const [configModalOpen, setConfigModalOpen] = useState(false);
+ const [selectedCluster, setSelectedCluster] = useState<ClusterInfo | null>(null);
+ const [nsModalOpen, setNsModalOpen] = useState(false);
+ const [nsModalMode, setNsModalMode] = useState<'create' | 'edit'>('create');
+ const [nsForm] = Form.useForm();
+ const [configForm] = Form.useForm();
+
+ // ─── Auto-refresh TPS / connections every 2s ──────────────────────────────
+ const [autoRefresh, setAutoRefresh] = useState(true);
+ // Initialize from mock base values
+ const initBrokerTps = (): Record<string, { tpsIn: number; tpsOut: number }> => {
+ const m: Record<string, { tpsIn: number; tpsOut: number }> = {};
+ clusters.forEach((c) =>
+ c.brokers.forEach((b) => {
+ m[b.addr] = { tpsIn: b.tpsIn, tpsOut: b.tpsOut };
+ }),
+ );
+ return m;
+ };
+ const initProxyConn = (): Record<string, number> => {
+ const m: Record<string, number> = {};
+ clusters.forEach((c) =>
+ c.proxies.forEach((p) => {
+ m[p.addr] = p.connections;
+ }),
+ );
+ return m;
+ };
+
+ const [brokerTpsMap, setBrokerTpsMap] = useState(initBrokerTps);
+ const [proxyConnMap, setProxyConnMap] = useState(initProxyConn);
+
+ useEffect(() => {
+ if (!autoRefresh) return;
+
+ const timer = setInterval(() => {
+ setBrokerTpsMap((prev) => {
+ const next = { ...prev };
+ clusters.forEach((c) => {
+ c.brokers.forEach((b) => {
+ const cur = next[b.addr] ?? { tpsIn: b.tpsIn, tpsOut: b.tpsOut };
+ const fluctuate = (base: number, v: number) =>
+ base === 0 ? 0 : Math.max(0, Math.round(v + (Math.random() - 0.5) * base * 0.12));
+ next[b.addr] = {
+ tpsIn: fluctuate(b.tpsIn, cur.tpsIn),
+ tpsOut: fluctuate(b.tpsOut, cur.tpsOut),
+ };
+ });
+ });
+ return next;
+ });
+
+ setProxyConnMap((prev) => {
+ const next = { ...prev };
+ clusters.forEach((c) => {
+ c.proxies.forEach((p) => {
+ const cur = prev[p.addr] ?? p.connections;
+ next[p.addr] = Math.max(
+ 0,
+ Math.round(cur + (Math.random() - 0.5) * p.connections * 0.08),
+ );
+ });
+ });
+ return next;
+ });
+ }, 2000);
+
+ return () => clearInterval(timer);
+ }, [autoRefresh]);
+
+ // Broker config handler
+ const handleConfigOpen = (cluster: ClusterInfo) => {
+ const cfg: ClusterConfig = cluster.config;
+ setSelectedCluster(cluster);
+ configForm.setFieldsValue({
+ flushDiskType: cfg.flushDiskType ?? 'ASYNC_FLUSH',
+ autoCreateTopicEnable: cfg.autoCreateTopicEnable ?? false,
+ autoCreateSubscriptionGroup: cfg.autoCreateSubscriptionGroup ?? false,
+ maxMessageSizeMB: Math.round(cfg.maxMessageSize / 1048576),
+ fileReservedTime: cfg.fileReservedTime ?? 72,
+ writeQueueNums: cfg.writeQueueNums ?? 8,
+ readQueueNums: cfg.readQueueNums ?? 8,
+ brokerPermission: cfg.brokerPermission ?? 6,
+ });
+ setConfigModalOpen(true);
+ };
+
+ const tabItems = [
+ {
+ key: 'nameserver',
+ label: (
+ <Flex align="center" gap={4}>
+ <Cpu size={16} />
+ <span>{t('cluster.nameserver')}</span>
+ </Flex>
+ ),
+ children: renderNameServerTab(),
+ },
+ {
+ key: 'broker',
+ label: (
+ <Flex align="center" gap={4}>
+ <HardDrives size={16} />
+ <span>{t('cluster.broker')}</span>
+ </Flex>
+ ),
+ children: renderBrokerTab(),
+ },
+ {
+ key: 'proxy',
+ label: (
+ <Flex align="center" gap={4}>
+ <Globe size={16} />
+ <span>{t('cluster.proxy')}</span>
+ </Flex>
+ ),
+ children: renderProxyTab(),
+ },
+ ];
+
+ // ─── Tab 2: Broker 管理 (flat table) ────────────────────────────────────────
+
+ function renderBrokerTab() {
+ type BrokerWithCluster = BrokerInfo & {
+ clusterName: string;
+ nsClusterName: string;
+ cluster: ClusterInfo;
+ };
+
+ const allBrokers: BrokerWithCluster[] = clusters.flatMap((c) =>
+ c.brokers
+ .filter((b) => {
+ const matchSearch =
+ !brokerSearch ||
+ b.name.toLowerCase().includes(brokerSearch.toLowerCase()) ||
+ b.addr.toLowerCase().includes(brokerSearch.toLowerCase());
+ const matchNsCluster =
+ !brokerNsClusterFilter || c.nsClusterName === brokerNsClusterFilter;
+ return matchSearch && matchNsCluster;
+ })
+ .map((b) => {
+ const tpsOverride = brokerTpsMap[b.addr];
+ return {
+ ...b,
+ tpsIn: tpsOverride?.tpsIn ?? b.tpsIn,
+ tpsOut: tpsOverride?.tpsOut ?? b.tpsOut,
+ clusterName: c.name,
+ nsClusterName: c.nsClusterName,
+ cluster: c,
+ };
+ }),
+ );
+
+ const brokerColumns: ColumnsType<BrokerWithCluster> = [
+ {
+ title: t('cluster.k8sName'),
+ dataIndex: 'clusterName',
+ key: 'clusterName',
+ width: 160,
+ sorter: (a, b) => a.clusterName.localeCompare(b.clusterName),
+ render: (name: string) => (
+ <Text strong style={{ fontSize: 13 }}>
+ {name}
+ </Text>
+ ),
+ },
+ {
+ title: t('cluster.brokerName'),
+ dataIndex: 'name',
+ key: 'name',
+ width: 170,
+ sorter: (a, b) => a.name.localeCompare(b.name),
+ render: (name: string) => (
+ <Text strong style={{ fontSize: 13 }}>
+ {name}
+ </Text>
+ ),
+ },
+ {
+ title: t('common.status'),
+ dataIndex: 'status',
+ key: 'status',
+ width: 90,
+ sorter: (a, b) => a.status.localeCompare(b.status),
+ render: (status: string) => {
+ const map: Record<string, { color: string; label: string }> = {
+ running: { color: 'green', label: t('cluster.running') },
+ readonly: { color: 'gold', label: t('cluster.readonly') },
+ maintenance: { color: 'red', label: t('cluster.maintenance') },
+ };
+ const cfg = map[status] ?? { color: 'default', label: status };
+ return <Tag color={cfg.color}>{cfg.label}</Tag>;
+ },
+ },
+ {
+ title: t('common.version'),
+ dataIndex: 'version',
+ key: 'version',
+ width: 80,
+ align: 'right',
+ sorter: (a, b) => a.version.localeCompare(b.version),
+ render: (v: string) => <span style={{ fontSize: 13 }}>{v}</span>,
+ },
+ {
+ title: t('cluster.diskUsage'),
+ dataIndex: 'diskUsage',
+ key: 'diskUsage',
+ width: 150,
+ sorter: (a, b) => a.diskUsage - b.diskUsage,
+ render: (v: number) => (
+ <Progress
+ percent={v}
+ size="small"
+ strokeColor={v > 85 ? '#ff4d4f' : v > 70 ? '#faad14' : '#1677ff'}
+ />
+ ),
+ },
+ {
+ title: t('common.address'),
+ dataIndex: 'addr',
+ key: 'addr',
+ width: 170,
+ align: 'right',
+ sorter: (a, b) => a.addr.localeCompare(b.addr),
+ render: (addr: string) => <span style={{ fontSize: 13 }}>{addr}</span>,
+ },
+ {
+ title: 'TPS In',
+ dataIndex: 'tpsIn',
+ key: 'tpsIn',
+ width: 90,
+ align: 'right',
+ sorter: (a, b) => a.tpsIn - b.tpsIn,
+ render: (v: number) => v.toLocaleString(),
+ },
+ {
+ title: 'TPS Out',
+ dataIndex: 'tpsOut',
+ key: 'tpsOut',
+ width: 90,
+ align: 'right',
+ sorter: (a, b) => a.tpsOut - b.tpsOut,
+ render: (v: number) => v.toLocaleString(),
+ },
+ {
+ title: t('common.actions'),
+ key: 'action',
+ width: 160,
+ render: (_: unknown, record: BrokerWithCluster) => (
+ <Flex gap={6}>
+ <Button
+ size="small"
+ icon={<SettingOutlined />}
+ style={{ borderColor: '#1677ff', color: '#1677ff' }}
+ onClick={() => handleConfigOpen(record.cluster)}
+ >
+ {t('cluster.config')}
+ </Button>
+ <Button
+ size="small"
+ icon={<ReloadOutlined />}
+ danger
+ style={{ borderColor: '#ff4d4f', color: '#ff4d4f' }}
+ onClick={() => message.warning(t('cluster.restartNotSupported'))}
+ >
+ {t('cluster.restart')}
+ </Button>
+ </Flex>
+ ),
+ },
+ ];
+
+ const nsClusterOptions = [
+ { value: '', label: t('common.all') },
+ ...clusters.map((c) => ({ value: c.nsClusterName, label: c.nsClusterName })),
+ ];
+
+ return (
+ <div>
+ <Flex justify="space-between" style={{ marginBottom: 16 }}>
+ <Space>
+ <Input.Search
+ placeholder={t('cluster.searchBroker')}
+ allowClear
+ onSearch={setBrokerSearch}
+ onChange={(e) => !e.target.value && setBrokerSearch('')}
+ style={{ width: 240 }}
+ />
+ <Select
+ value={brokerNsClusterFilter}
+ onChange={setBrokerNsClusterFilter}
+ style={{ width: 180 }}
+ options={nsClusterOptions}
+ />
+ </Space>
+ <Button
+ type="primary"
+ icon={<PlusOutlined />}
+ onClick={() => message.info('新建集群功能开发中')}
+ >
+ {t('cluster.createCluster')}
+ </Button>
+ </Flex>
+ <Card bodyStyle={{ padding: 0 }}>
+ <Table
+ columns={brokerColumns}
+ dataSource={allBrokers}
+ rowKey="addr"
+ pagination={{ pageSize: 20 }}
+ size="small"
+ />
+ </Card>
+
+ {selectedCluster && (
+ <Modal
+ title={`配置 - ${selectedCluster.name}`}
+ open={configModalOpen}
+ onCancel={() => setConfigModalOpen(false)}
+ onOk={() => {
+ configForm.validateFields().then(() => {
+ message.success('配置已更新');
+ setConfigModalOpen(false);
+ });
+ }}
+ width={560}
+ >
+ <Form form={configForm} layout="vertical">
+ <Form.Item label="刷盘方式" name="flushDiskType">
+ <Radio.Group>
+ <Radio value="SYNC_FLUSH">同步刷盘</Radio>
+ <Radio value="ASYNC_FLUSH">异步刷盘</Radio>
+ </Radio.Group>
+ </Form.Item>
+ <Form.Item
+ label="自动创建 Topic"
+ name="autoCreateTopicEnable"
+ valuePropName="checked"
+ >
+ <Switch />
+ </Form.Item>
+ <Form.Item
+ label="自动创建订阅组"
+ name="autoCreateSubscriptionGroup"
+ valuePropName="checked"
+ >
+ <Switch />
+ </Form.Item>
+ <Form.Item label="最大消息大小 (MB)" name="maxMessageSizeMB">
+ <InputNumber min={1} max={128} style={{ width: '100%' }} />
+ </Form.Item>
+ <Form.Item label="文件保留时长 (小时)" name="fileReservedTime">
+ <InputNumber min={1} max={720} style={{ width: '100%' }} />
+ </Form.Item>
+ <Form.Item label="写队列数" name="writeQueueNums">
+ <InputNumber min={1} max={256} style={{ width: '100%' }} />
+ </Form.Item>
+ <Form.Item label="读队列数" name="readQueueNums">
+ <InputNumber min={1} max={256} style={{ width: '100%' }} />
+ </Form.Item>
+ <Form.Item label="Broker 权限" name="brokerPermission">
+ <InputNumber min={0} max={7} style={{ width: '100%' }} />
+ </Form.Item>
+ </Form>
+ </Modal>
+ )}
+ </div>
+ );
+ }
+
+ // ─── Tab 2: NameServer 管理 (nested by cluster) ────────────────────────────
+
+ function renderNameServerTab() {
+ const filteredClusters = clusters
+ .map((c) => {
+ const nameServers = c.nameServers.filter((ns) => {
+ const matchSearch = !nsSearch || ns.addr.toLowerCase().includes(nsSearch.toLowerCase());
+ return matchSearch;
+ });
+ return { ...c, filteredNameServers: nameServers };
+ })
+ .filter((c) => c.filteredNameServers.length > 0);
+
+ const getNsSubColumns = (clusterId: string): ColumnsType<NameServerInfo> => [
+ {
+ title: t('common.address'),
+ dataIndex: 'addr',
+ key: 'addr',
+ sorter: (a, b) => a.addr.localeCompare(b.addr),
+ render: (addr: string) => (
+ <Text code style={{ fontSize: 12 }}>
+ {addr}
+ </Text>
+ ),
+ },
+ {
+ title: t('common.status'),
+ dataIndex: 'status',
+ key: 'status',
+ width: 90,
+ sorter: (a, b) => a.status.localeCompare(b.status),
+ render: (status: string) => {
+ const map: Record<string, { color: string; label: string }> = {
+ healthy: { color: 'green', label: t('cluster.running') },
+ warning: { color: 'gold', label: '告警' },
+ error: { color: 'red', label: '异常' },
+ offline: { color: 'default', label: '离线' },
+ };
+ const cfg = map[status] ?? { color: 'default', label: status };
+ return <Tag color={cfg.color}>{cfg.label}</Tag>;
+ },
+ },
+ {
+ title: t('common.actions'),
+ key: 'action',
+ width: 100,
+ render: (_: unknown, record: NameServerInfo) => (
+ <Flex gap={6}>
+ <Button
+ size="small"
+ icon={<EditOutlined />}
+ style={{ borderColor: '#722ed1', color: '#722ed1' }}
+ onClick={() => {
+ setNsModalMode('edit');
+ nsForm.setFieldsValue({ clusterId, addr: record.addr, newAddr: '' });
+ setNsModalOpen(true);
+ }}
+ >
+ {t('common.edit')}
+ </Button>
+ </Flex>
+ ),
+ },
+ ];
+
+ const clusterColumns: ColumnsType<ClusterInfo & { filteredNameServers: NameServerInfo[] }> = [
+ {
+ title: t('cluster.k8sName'),
+ dataIndex: 'name',
+ key: 'name',
+ width: 180,
+ render: (name: string) => (
+ <Text strong style={{ fontSize: 14 }}>
+ {name}
+ </Text>
+ ),
+ },
+ {
+ title: t('cluster.nsClusterName'),
+ dataIndex: 'nsClusterName',
+ key: 'nsClusterName',
+ width: 180,
+ render: (name: string) => (
+ <Text strong style={{ fontSize: 13 }}>
+ {name}
+ </Text>
+ ),
+ },
+ {
+ title: t('common.status'),
+ dataIndex: 'status',
+ key: 'status',
+ width: 90,
+ render: (status: string) => {
+ const map: Record<string, { color: string; label: string }> = {
+ healthy: { color: 'green', label: t('cluster.running') },
+ warning: { color: 'gold', label: '告警' },
+ error: { color: 'red', label: '异常' },
+ offline: { color: 'default', label: '离线' },
+ };
+ const cfg = map[status] ?? { color: 'default', label: status };
+ return <Tag color={cfg.color}>{cfg.label}</Tag>;
+ },
+ },
+ {
+ title: t('common.version'),
+ dataIndex: 'version',
+ key: 'version',
+ width: 80,
+ render: (v: string) => <Tag>{v}</Tag>,
+ },
+ {
+ title: t('cluster.count'),
+ key: 'nsCount',
+ width: 80,
+ align: 'center',
+ render: (_: unknown, record: ClusterInfo & { filteredNameServers: NameServerInfo[] }) =>
+ record.filteredNameServers.length,
+ },
+ ];
+
+ return (
+ <div>
+ <Flex justify="space-between" style={{ marginBottom: 16 }}>
+ <Space>
+ <Input.Search
+ placeholder={t('cluster.searchNs')}
+ allowClear
+ onSearch={setNsSearch}
+ onChange={(e) => !e.target.value && setNsSearch('')}
+ style={{ width: 240 }}
+ />
+ </Space>
+ <Button
+ type="primary"
+ icon={<PlusOutlined />}
+ onClick={() => {
+ setNsModalMode('create');
+ nsForm.resetFields();
+ setNsModalOpen(true);
+ }}
+ >
+ {t('cluster.createNameServer')}
+ </Button>
+ </Flex>
+ <Card bodyStyle={{ padding: 0 }}>
+ <Table
+ columns={clusterColumns}
+ dataSource={filteredClusters}
+ rowKey="id"
+ pagination={{ pageSize: 20 }}
+ size="small"
+ expandable={{
+ expandedRowRender: (record) => (
+ <div style={{ padding: '8px 0' }}>
+ <Table
+ columns={getNsSubColumns(record.id)}
+ dataSource={record.filteredNameServers}
+ rowKey="addr"
+ pagination={false}
+ size="small"
+ />
+ </div>
+ ),
+ }}
+ />
+ </Card>
+ </div>
+ );
+ }
+
+ // ─── Tab 3: Proxy 管理 (flat table) ────────────────────────────────────────
+
+ function renderProxyTab() {
+ type ProxyRow = ProxyInfo & { clusterName: string; nsClusterName: string };
+
+ const allProxies: ProxyRow[] = clusters
+ .filter((c) => c.proxies.length > 0)
+ .flatMap((c) =>
+ c.proxies
+ .filter((p) => {
+ const matchSearch =
+ !proxySearch || p.addr.toLowerCase().includes(proxySearch.toLowerCase());
+ return matchSearch;
+ })
+ .map((p) => ({
+ ...p,
+ connections: proxyConnMap[p.addr] ?? p.connections,
+ clusterName: c.name,
+ nsClusterName: c.nsClusterName,
+ })),
+ );
+
+ const proxyColumns: ColumnsType<ProxyRow> = [
+ {
+ title: t('cluster.k8sName'),
+ dataIndex: 'clusterName',
+ key: 'clusterName',
+ width: 160,
+ sorter: (a, b) => a.clusterName.localeCompare(b.clusterName),
+ render: (name: string) => (
+ <Text strong style={{ fontSize: 13 }}>
+ {name}
+ </Text>
+ ),
+ },
+ {
+ title: t('cluster.proxyAddr'),
+ dataIndex: 'addr',
+ key: 'addr',
+ width: 200,
+ sorter: (a, b) => a.addr.localeCompare(b.addr),
+ render: (addr: string) => (
+ <Text code style={{ fontSize: 12 }}>
+ {addr}
+ </Text>
+ ),
+ },
+ {
+ title: t('common.status'),
+ dataIndex: 'status',
+ key: 'status',
+ width: 90,
+ sorter: (a, b) => a.status.localeCompare(b.status),
+ render: (status: string) => {
+ const map: Record<string, { color: string; label: string }> = {
+ healthy: { color: 'green', label: t('cluster.running') },
+ warning: { color: 'gold', label: '告警' },
+ error: { color: 'red', label: '异常' },
+ offline: { color: 'default', label: '离线' },
+ };
+ const cfg = map[status] ?? { color: 'default', label: status };
+ return <Tag color={cfg.color}>{cfg.label}</Tag>;
+ },
+ },
+ {
+ title: t('cluster.connections'),
+ dataIndex: 'connections',
+ key: 'connections',
+ width: 100,
+ align: 'right',
+ sorter: (a, b) => a.connections - b.connections,
+ render: (v: number) => v.toLocaleString(),
+ },
+ {
+ title: t('cluster.grpcPort'),
+ dataIndex: 'grpcPort',
+ key: 'grpcPort',
+ width: 100,
+ align: 'center',
+ sorter: (a, b) => a.grpcPort - b.grpcPort,
+ },
+ {
+ title: t('cluster.remotingPort'),
+ dataIndex: 'remotingPort',
+ key: 'remotingPort',
+ width: 120,
+ align: 'center',
+ sorter: (a, b) => a.remotingPort - b.remotingPort,
+ },
+ {
+ title: t('common.actions'),
+ key: 'action',
+ width: 160,
+ render: (_: unknown, record: ProxyRow) => (
+ <Flex gap={6}>
+ <Button
+ size="small"
+ icon={<EyeOutlined />}
+ style={{ borderColor: '#1677ff', color: '#1677ff' }}
+ onClick={() => message.info(`查看详情: ${record.addr}`)}
+ >
+ {t('common.detail')}
+ </Button>
+ <Button
+ size="small"
+ icon={<ReloadOutlined />}
+ style={{ borderColor: '#faad14', color: '#faad14' }}
+ onClick={() => {
+ Modal.confirm({
+ title: t('cluster.confirmRestart'),
+ content: `确定要重启 Proxy "${record.addr}" 吗?`,
+ okText: '确认',
+ cancelText: '取消',
+ onOk: () => message.success(`Proxy 重启已提交: ${record.addr}`),
+ });
+ }}
+ >
+ {t('cluster.restart')}
+ </Button>
+ </Flex>
+ ),
+ },
+ ];
+
+ return (
+ <div>
+ <Flex justify="space-between" style={{ marginBottom: 16 }}>
+ <Space>
+ <Input.Search
+ placeholder={t('cluster.searchProxy')}
+ allowClear
+ onSearch={setProxySearch}
+ onChange={(e) => !e.target.value && setProxySearch('')}
+ style={{ width: 240 }}
+ />
+ </Space>
+ <Button
+ type="primary"
+ icon={<PlusOutlined />}
+ onClick={() => message.info('新建集群功能开发中')}
+ >
+ {t('cluster.createCluster')}
+ </Button>
+ </Flex>
+ <Card bodyStyle={{ padding: 0 }}>
+ <Table
+ columns={proxyColumns}
+ dataSource={allProxies}
+ rowKey={(r) => `${r.clusterName}-${r.addr}`}
+ pagination={{ pageSize: 20 }}
+ size="small"
+ />
+ </Card>
+ </div>
+ );
+ }
+
+ // ─── Tab 4: K8s 证书管理 ───────────────────────────────────────────────────
+
+ // ─── Render ─────────────────────────────────────────────────────────────────
+
+ const totalBrokers = clusters.reduce((s, c) => s + c.brokers.length, 0);
+ const totalNameServers = clusters.reduce((s, c) => s + c.nameServers.length, 0);
+ const totalProxies = clusters.reduce((s, c) => s + c.proxies.length, 0);
+
+ return (
+ <div style={{ padding: 24 }}>
+ <PageHeader
+ title={t('cluster.title')}
+ subtitle={`${t('common.total')} ${clusters.length} ${t('cluster.title')} · ${totalBrokers} Broker · ${totalNameServers} NameServer · ${totalProxies} Proxy`}
+ extra={
+ <Flex align="center" gap={6}>
+ {autoRefresh && (
+ <span
+ style={{
+ width: 6,
+ height: 6,
+ borderRadius: '50%',
+ background: '#52c41a',
+ display: 'inline-block',
+ animation: 'livePulse 1.5s ease-in-out infinite',
+ }}
+ />
+ )}
+ <Text type="secondary" style={{ fontSize: 12 }}>
+ {autoRefresh ? t('common.liveRefresh') : t('common.autoRefresh')}
+ </Text>
+ <Switch size="small" checked={autoRefresh} onChange={setAutoRefresh} />
+ </Flex>
+ }
+ />
+ <style>{`
+ @keyframes livePulse {
+ 0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(82, 196, 26, 0.4); }
+ 50% { opacity: 0.6; box-shadow: 0 0 0 4px rgba(82, 196, 26, 0); }
+ }
+ `}</style>
+ {/* ─── NameServer Create/Edit Modal ─── */}
+ <Modal
+ title={
+ nsModalMode === 'create' ? t('cluster.createNameServer') : t('cluster.editNameServer')
+ }
+ open={nsModalOpen}
+ onCancel={() => setNsModalOpen(false)}
+ onOk={() => {
+ nsForm.validateFields().then((values: Record<string, string>) => {
+ if (nsModalMode === 'create') {
+ message.success(`${t('cluster.nsCreated')}: ${values.addr}`);
+ } else {
+ message.success(
+ `${t('cluster.nsUpdated')}: ${values.addr}${values.newAddr ? ` → ${values.newAddr}` : ''}`,
+ );
+ }
+ setNsModalOpen(false);
+ });
+ }}
+ okText={t('common.confirm')}
+ cancelText={t('common.cancel')}
+ destroyOnClose
+ >
+ <Form form={nsForm} layout="vertical" style={{ marginTop: 16 }}>
+ {nsModalMode === 'create' && (
+ <Form.Item
+ name="clusterId"
+ label={t('cluster.selectCluster')}
+ rules={[{ required: true, message: t('cluster.selectCluster') }]}
+ >
+ <Select
+ placeholder={t('cluster.selectCluster')}
+ options={clusters.map((c) => ({ label: c.name, value: c.id }))}
+ />
+ </Form.Item>
+ )}
+ <Form.Item
+ name="addr"
+ label={t('cluster.nsAddr')}
+ rules={[{ required: true, message: t('cluster.nsAddr') }]}
+ >
+ <Input placeholder={t('cluster.nsAddrPlaceholder')} disabled={nsModalMode === 'edit'} />
+ </Form.Item>
+ {nsModalMode === 'edit' && (
+ <Form.Item name="newAddr" label={t('cluster.newAddr')}>
+ <Input placeholder={t('cluster.newAddrPlaceholder')} />
+ </Form.Item>
+ )}
+ </Form>
+ </Modal>
+ <Tabs items={tabItems} defaultActiveKey="broker" />
+ </div>
+ );
+};
+
+export default ClusterPage;
diff --git a/web/src/pages/home/dashboard.tsx b/web/src/pages/home/dashboard.tsx
new file mode 100644
index 0000000..b1aa484
--- /dev/null
+++ b/web/src/pages/home/dashboard.tsx
@@ -0,0 +1,207 @@
+import { useState, useEffect } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { Row, Col, Card, Statistic, Table, Tag, Typography } from 'antd';
+import { ClusterOutlined, ThunderboltOutlined } from '@ant-design/icons';
+import { ListDashes, ArrowDown } from '@phosphor-icons/react';
+import PageHeader from '../../components/PageHeader';
+import StatusBadge from '../../components/StatusBadge';
+import MiniBar from '../../components/MiniBar';
+import { CLUSTER_TYPE_MAP } from '../../constants/theme';
+import { getDashboard } from '../../services/dashboardService';
+import type { DashboardData } from '../../api/metrics';
+import { useLang } from '../../i18n/LangContext';
+
+const { Text } = Typography;
+
+const DashboardPage = () => {
+ const navigate = useNavigate();
+ const { t } = useLang();
+ const [dashboard, setDashboard] = useState<DashboardData | null>(null);
+
+ useEffect(() => {
+ getDashboard().then(setDashboard).catch(console.error);
+ }, []);
+
+ if (!dashboard) return null;
+
+ const { stats, clusters } = dashboard;
+
+ const statCards = [
+ {
+ title: t('dashboard.clusters'),
+ value: stats.totalClusters,
+ icon: <ClusterOutlined style={{ fontSize: 22, color: '#52c41a' }} />,
+ color: '#52c41a',
+ suffix: '',
+ detail: `${stats.totalBrokers} Brokers · ${stats.totalProxies} Proxy`,
+ },
+ {
+ title: t('dashboard.topics'),
+ value: stats.totalTopics,
+ icon: <ListDashes size={22} weight="duotone" color="#1677ff" />,
+ color: '#1677ff',
+ suffix: '',
+ detail: `${stats.totalConsumerGroups} Consumer Groups`,
+ },
+ {
+ title: t('dashboard.tpsIn'),
+ value: stats.tpsIn,
+ icon: <ArrowDown size={22} weight="duotone" color="#fa8c16" />,
+ color: '#fa8c16',
+ suffix: '/s',
+ detail: `${t('dashboard.tpsOut')}: ${stats.tpsOut.toLocaleString()}/s`,
+ },
+ {
+ title: t('dashboard.todayMessages'),
+ value: stats.totalMessagesToday,
+ icon: <ThunderboltOutlined style={{ fontSize: 22, color: '#722ed1' }} />,
+ color: '#722ed1',
+ suffix: '',
+ detail: t('dashboard.millionMessages', {
+ n: (stats.totalMessagesToday / 1_000_000).toFixed(1),
+ }),
+ },
+ ];
+
+ const clusterColumns = [
+ {
+ title: t('dashboard.clusterName'),
+ dataIndex: 'name',
+ key: 'name',
+ render: (name: string) => <Text strong>{name}</Text>,
+ },
+ {
+ title: t('common.status'),
+ dataIndex: 'status',
+ key: 'status',
+ width: 100,
+ render: (status: string) => <StatusBadge status={status} />,
+ },
+ {
+ title: t('common.type'),
+ dataIndex: 'type',
+ key: 'type',
+ render: (type: string) => {
+ const info = CLUSTER_TYPE_MAP[type];
+ return info ? <Tag color={info.color}>{info.label}</Tag> : type;
+ },
+ },
+ {
+ title: t('common.version'),
+ dataIndex: 'version',
+ key: 'version',
+ render: (v: string) => <span style={{ fontSize: 13 }}>{v}</span>,
+ },
+ {
+ title: t('dashboard.broker'),
+ dataIndex: 'brokers',
+ key: 'brokers',
+ width: 80,
+ align: 'center' as const,
+ render: (v: number) => Math.max(0, v),
+ },
+ {
+ title: t('dashboard.proxy'),
+ dataIndex: 'proxies',
+ key: 'proxies',
+ width: 80,
+ align: 'center' as const,
+ render: (v: number) => Math.max(0, v),
+ },
+ {
+ title: t('dashboard.topic'),
+ dataIndex: 'topics',
+ key: 'topics',
+ width: 80,
+ align: 'center' as const,
+ },
+ {
+ title: t('dashboard.group'),
+ dataIndex: 'groups',
+ key: 'groups',
+ width: 80,
+ align: 'center' as const,
+ },
+ {
+ title: t('dashboard.tpsIn'),
+ dataIndex: 'tpsIn',
+ key: 'tpsIn',
+ width: 90,
+ align: 'right' as const,
+ render: (v: number) => v.toLocaleString(),
+ },
+ {
+ title: t('dashboard.tpsOut'),
+ dataIndex: 'tpsOut',
+ key: 'tpsOut',
+ width: 90,
+ align: 'right' as const,
+ render: (v: number) => v.toLocaleString(),
+ },
+ {
+ title: t('dashboard.trend'),
+ dataIndex: 'throughput',
+ key: 'throughput',
+ width: 110,
+ render: (data: number[], record: DashboardData['clusters'][0]) => (
+ <MiniBar
+ data={data}
+ color={
+ record.status === 'healthy'
+ ? '#52c41a'
+ : record.status === 'warning'
+ ? '#faad14'
+ : '#d9d9d9'
+ }
+ height={26}
+ width={100}
+ />
+ ),
+ },
+ ];
+
+ return (
+ <div style={{ padding: 24 }}>
+ <PageHeader title={t('dashboard.title')} subtitle={t('dashboard.subtitle')} />
+
+ <Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
+ {statCards.map((card) => (
+ <Col xs={12} sm={12} md={6} key={card.title}>
+ <Card size="small" style={{ borderTop: `3px solid ${card.color}`, borderRadius: 8 }}>
+ <Statistic
+ title={card.title}
+ value={card.value}
+ prefix={card.icon}
+ suffix={card.suffix}
+ valueStyle={{ fontSize: 28, fontWeight: 600 }}
+ />
+ <Text type="secondary" style={{ fontSize: 12, marginTop: 4, display: 'block' }}>
+ {card.detail}
+ </Text>
+ </Card>
+ </Col>
+ ))}
+ </Row>
+
+ <Card
+ title={t('dashboard.clusterHealth')}
+ extra={<a onClick={() => navigate('/cluster')}>{t('common.viewAll')}</a>}
+ styles={{ body: { padding: '0 20px 16px' } }}
+ >
+ <Table
+ dataSource={clusters}
+ columns={clusterColumns}
+ rowKey="id"
+ size="small"
+ pagination={false}
+ onRow={() => ({
+ style: { cursor: 'pointer' },
+ onClick: () => navigate('/cluster'),
+ })}
+ />
+ </Card>
+ </div>
+ );
+};
+
+export default DashboardPage;
diff --git a/web/src/pages/home/index.tsx b/web/src/pages/home/index.tsx
new file mode 100644
index 0000000..dcd9d2a
--- /dev/null
+++ b/web/src/pages/home/index.tsx
@@ -0,0 +1,415 @@
+/*
+ * 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 { useState, useRef, useEffect, useCallback } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { RobotOutlined } from '@ant-design/icons';
+import { Select, ConfigProvider, theme } from 'antd';
+import {
+ Stethoscope,
+ ChatCircleDots,
+ ClockCounterClockwise,
+ SlidersHorizontal,
+ Sparkle,
+ Microphone,
+ ArrowUp,
+ MagnifyingGlass,
+ CaretDown,
+ MegaphoneSimple,
+ Database,
+} from '@phosphor-icons/react';
+import { useLang } from '../../i18n/LangContext';
+
+/* ─── Time-aware greeting key ─── */
+function getGreetingKey(): string {
+ const h = new Date().getHours();
+ if (h < 6) return 'home.greeting.night';
+ if (h < 12) return 'home.greeting.morning';
+ if (h < 14) return 'home.greeting.noon';
+ if (h < 18) return 'home.greeting.afternoon';
+ return 'home.greeting.evening';
+}
+
+/* ─── Mode definitions (keys only, labels resolved via t()) ─── */
+const modes = [
+ { key: 'query', labelKey: 'home.mode.query', icon: MagnifyingGlass },
+ { key: 'diagnose', labelKey: 'home.mode.diagnose', icon: Stethoscope },
+ { key: 'manage', labelKey: 'home.mode.manage', icon: Database },
+ { key: 'chat', labelKey: 'home.mode.chat', icon: ChatCircleDots },
+];
+
+/* ─── Model option values ─── */
+const modelOptions = [
+ { value: 'qwen3.7-max', recommended: true },
+ { value: 'qwen3.7-plus', recommended: false },
+ { value: 'claude-opus-4.7', recommended: false },
+ { value: 'gpt-5.4', recommended: false },
+];
+
+/* ═══════════════════════════════════════════════════════
+ HomePage Component
+ ═══════════════════════════════════════════════════════ */
+const HomePage = () => {
+ const [activeMode, setActiveMode] = useState('query');
+ const [selectedModel, setSelectedModel] = useState('qwen3.7-max');
+ const [indicatorStyle, setIndicatorStyle] = useState({ width: 83, left: 6 });
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
+ const modeBarRef = useRef<HTMLDivElement>(null);
+ const navigate = useNavigate();
+ const { t, lang } = useLang();
+
+ /* ─── Mode switch handler ─── */
+ const handleModeSwitch = useCallback((key: string, btn: HTMLButtonElement) => {
+ setActiveMode(key);
+ const parent = modeBarRef.current;
+ if (parent) {
+ const rect = btn.getBoundingClientRect();
+ const parentRect = parent.getBoundingClientRect();
+ setIndicatorStyle({
+ width: rect.width,
+ left: rect.left - parentRect.left - 6,
+ });
+ }
+ }, []);
+
+ /* ─── Fix indicator on mount and language/mode change ─── */
+ useEffect(() => {
+ const parent = modeBarRef.current;
+ if (!parent) return;
+ const activeBtn = parent.querySelector('.mode-btn.active') as HTMLButtonElement | null;
+ if (activeBtn) {
+ const rect = activeBtn.getBoundingClientRect();
+ const parentRect = parent.getBoundingClientRect();
+ setIndicatorStyle({
+ width: rect.width,
+ left: rect.left - parentRect.left - 6,
+ });
+ }
+ }, [lang, activeMode]);
+
+ /* ─── Auto-resize textarea ─── */
+ useEffect(() => {
+ const ta = textareaRef.current;
+ if (!ta) return;
+ const handler = () => {
+ ta.style.height = 'auto';
+ ta.style.height = `${Math.min(ta.scrollHeight, 300)}px`;
+ };
+ ta.addEventListener('input', handler);
+ return () => ta.removeEventListener('input', handler);
+ }, []);
+
+ /* ─── Keyboard shortcut: Enter to send ─── */
+ const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault();
+ navigate('/ai');
+ }
+ };
+
+ return (
+ <ConfigProvider theme={{ algorithm: theme.defaultAlgorithm }}>
+ <div
+ className="relative w-full overflow-y-auto scrollbar-hide"
+ style={{
+ minHeight: 'calc(100vh - 48px)',
+ display: 'flex',
+ flexDirection: 'column',
+ background: '#fff',
+ }}
+ >
+ {/* ── Animated Orbs Background ── */}
+ <div
+ className="pointer-events-none absolute inset-0 overflow-hidden"
+ style={{ animation: '8s ease-in-out infinite oneday-bg-drift' }}
+ >
+ <div
+ aria-hidden="true"
+ className="absolute"
+ style={{
+ top: '-14%',
+ left: '-7%',
+ width: '42%',
+ height: '42%',
+ background:
+ 'radial-gradient(circle at 30% 30%, rgb(186, 230, 253) 0%, transparent 65%)',
+ opacity: 0.45,
+ filter: 'blur(80px)',
+ animation: '8s ease-in-out infinite oneday-orb-drift-a',
+ willChange: 'transform, opacity',
+ }}
+ />
+ <div
+ aria-hidden="true"
+ className="absolute inset-0"
+ style={{
+ backgroundImage: `url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='160' height='160'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/></filter><rect width='100%' height='100%' filter='url(%23n)' opacity='1'/></svg>")`,
+ opacity: 0.025,
+ mixBlendMode: 'multiply',
+ }}
+ />
+ </div>
+
+ {/* ── Top Banner ── */}
+ <div className="sticky top-0 z-10 w-full">
+ <div
+ className="flex justify-center items-center backdrop-blur-[8px]"
+ style={{
+ background: 'linear-gradient(to bottom, rgba(255,255,255,0.8) 0%, transparent 100%)',
+ }}
+ >
+ <div className="max-w-[920px] w-full mx-auto">
+ <div className="flex justify-center items-center px-4 py-2 min-h-[36px]">
+ <span className="inline-flex items-center gap-2 text-sm text-amber-600 cursor-pointer hover:text-amber-700 transition-colors">
+ <MegaphoneSimple size={16} weight="fill" />
+ <span>RocketMQ Studio — 跨集群 · 跨架构 · 跨云的统一管控平台</span>
+ </span>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ {/* ── Main Content ── */}
+ <div
+ className="relative z-[1] mx-auto flex w-full max-w-[960px] flex-col items-center px-6"
+ style={{ flex: 1, justifyContent: 'center' }}
+ >
+ {/* ── Greeting Section ── */}
+ <div
+ className="pt-4"
+ style={{
+ marginBottom: 32,
+ textAlign: 'center',
+ animation: 'float-in 0.6s ease-out',
+ }}
+ >
+ <div
+ style={{
+ fontSize: '3rem',
+ lineHeight: 1.15,
+ fontWeight: 600,
+ letterSpacing: '-0.025em',
+ whiteSpace: 'nowrap',
+ color: 'var(--bolt-elements-textPrimary)',
+ }}
+ >
+ {t(getGreetingKey())}
+ {lang === 'zh' ? ',欢迎' : ', welcome'}
+ <span
+ style={{
+ display: 'inline-block',
+ transformOrigin: '70% 70%',
+ animation: 'wave 1.6s ease-in-out infinite',
+ margin: '0 0.2em',
+ }}
+ >
+ 🚀
+ </span>
+ {lang === 'zh' ? t('home.welcomeTo') : 'to'}{' '}
+ <span
+ className="bg-clip-text text-transparent bg-gradient-to-r from-violet-600 via-fuchsia-500 to-orange-500"
+ style={{ marginLeft: '0.15em' }}
+ >
+ RocketMQ Studio
+ </span>
+ </div>
+ <div
+ style={{
+ marginTop: 12,
+ fontSize: '1.071rem',
+ lineHeight: 1.5,
+ color: 'var(--bolt-elements-textSecondary)',
+ }}
+ >
+ {t('home.tagline')}
+ </div>
+ </div>
+
+ {/* ── Chat Input Area ── */}
+ <div className="w-full" style={{ animation: 'float-in 0.6s ease-out 0.1s both' }}>
+ <div className="w-full mx-auto relative z-[300] max-w-[60rem]">
+ {/* Mode Toggle Bar */}
+ <div className="flex justify-center mb-5">
+ <div
+ ref={modeBarRef}
+ className="relative rounded-full bg-white/70 shadow-[0_8px_24px_rgba(120,120,180,0.08)] backdrop-blur-md border border-white"
+ style={{
+ display: 'inline-flex',
+ alignItems: 'center',
+ gap: 4,
+ padding: 6,
+ }}
+ >
+ {/* Active indicator */}
+ <div
+ className="pointer-events-none absolute top-1.5 bottom-1.5 rounded-full bg-purple-50 shadow-sm transition-all duration-300"
+ style={{
+ width: indicatorStyle.width,
+ transform: `translateX(${indicatorStyle.left}px)`,
+ }}
+ />
+
+ {modes.map((m) => {
+ const Icon = m.icon;
+ return (
+ <button
+ key={m.key}
+ className={`mode-btn relative ${activeMode === m.key ? 'active' : ''}`}
+ onClick={(e) => handleModeSwitch(m.key, e.currentTarget)}
+ >
+ <Icon size={16} weight={activeMode === m.key ? 'fill' : 'regular'} />
+ <span>{t(m.labelKey)}</span>
+ </button>
+ );
+ })}
+ </div>
+ </div>
+
+ {/* Main Input Box */}
+ <div className="relative overflow-visible border-[1.5px] backdrop-blur-xl border-white mx-auto rounded-2xl bg-white/80 shadow-[0_20px_60px_-20px_rgba(80,90,180,0.18)]">
+ {/* Model Selector & History */}
+ <div className="flex items-center justify-between gap-3 px-3.5 pt-4">
+ <div className="flex flex-1 min-w-0 items-center gap-2">
+ <Select
+ size="small"
+ value={selectedModel}
+ onChange={(val) => setSelectedModel(val)}
+ options={modelOptions.map((m) => ({
+ value: m.value,
+ label: m.recommended ? (
+ <span className="inline-flex items-center gap-1.5">
+ {m.value}
+ <span className="px-1 py-0.5 rounded text-[0.625rem] leading-none bg-purple-50 text-purple-600 font-medium">
+ {lang === 'zh' ? '推荐' : 'Rec.'}
+ </span>
+ </span>
+ ) : (
+ m.value
+ ),
+ }))}
+ variant="borderless"
+ popupMatchSelectWidth={false}
+ suffixIcon={<CaretDown size={10} color="#9CA3AF" />}
+ className="model-selector"
+ style={{ fontSize: '0.893rem' }}
+ />
+ </div>
+ <div className="flex shrink-0 items-center gap-1">
+ <button className="p-1 rounded-md text-gray-400 hover:text-gray-600 hover:bg-gray-50 transition-colors">
+ <ClockCounterClockwise size={20} />
+ </button>
+ </div>
+ </div>
+
+ {/* Textarea */}
+ <div className="relative flex flex-col">
+ <textarea
+ ref={textareaRef}
+ className="chat-input"
+ placeholder="向 RocketMQ Bot 提问,全程加密、安全、可信"
+ onKeyDown={handleKeyDown}
+ />
+ <RobotOutlined
+ className="text-gray-400"
+ style={{
+ position: 'absolute',
+ top: 18,
+ left: 26,
+ fontSize: 17,
+ }}
+ />
+ </div>
+
+ {/* Bottom Toolbar */}
+ <div className="flex justify-between text-sm items-center px-3.5 py-3 border-t border-gray-100/80">
+ <div className="flex flex-1 gap-1 items-center min-w-0">
+ <div className="flex items-center gap-2 w-full">
+ <div className="flex-1 min-w-0">
+ <div className="flex items-center gap-2 overflow-x-auto scrollbar-hide max-w-full py-2">
+ <button className="tool-btn">
+ <SlidersHorizontal size={17} />
+ <span>工具</span>
+ </button>
+ <button className="tool-btn">
+ <Sparkle size={17} />
+ <span>Prompt 增强</span>
+ </button>
+ <button
+ className="tool-btn"
+ style={{ minHeight: 30, minWidth: 32, padding: 6 }}
+ >
+ <Microphone size={17} />
+ </button>
+ </div>
+ </div>
+ <div className="shrink-0 flex items-center gap-1">
+ <button
+ className="flex items-center justify-center w-9 h-9 rounded-full bg-gradient-to-r from-purple-500 to-violet-600 text-white shadow-lg hover:shadow-xl transition-all hover:scale-105"
+ onClick={() => navigate('/ai')}
+ >
+ <ArrowUp size={19} weight="bold" />
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ {/* ── Footer ── */}
+ <footer
+ className="pointer-events-none text-gray-400"
+ style={{
+ position: 'relative',
+ zIndex: 1,
+ paddingBottom: 24,
+ paddingTop: 64,
+ textAlign: 'center',
+ fontSize: '0.857rem',
+ lineHeight: 1.5,
+ background:
+ 'linear-gradient(to top, var(--bolt-elements-bg-depth-1) 0%, color-mix(in srgb, var(--bolt-elements-bg-depth-1) 92%, transparent) 55%, transparent 100%)',
+ }}
+ >
+ <span className="pointer-events-auto">
+ <a
+ href="#"
+ className="transition-colors hover:text-purple-500"
+ style={{ textDecoration: 'none' }}
+ >
+ 文档中心
+ </a>
+ <span style={{ margin: '0 4px' }}>|</span>
+ <a
+ href="#"
+ className="transition-colors hover:text-purple-500"
+ style={{ textDecoration: 'none' }}
+ >
+ RocketMQ 社区
+ </a>
+ <span style={{ margin: '0 4px' }}>|</span>
+ <span>RocketMQ Studio 出品</span>
+ </span>
+ </footer>
+ </div>
+ </ConfigProvider>
+ );
+};
+
+export default HomePage;
diff --git a/web/src/pages/instance/acl.tsx b/web/src/pages/instance/acl.tsx
new file mode 100644
index 0000000..a663ecd
--- /dev/null
+++ b/web/src/pages/instance/acl.tsx
@@ -0,0 +1,754 @@
+/*
+ * 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 { useState } from 'react';
+import {
+ Table,
+ Card,
+ Button,
+ Tag,
+ Space,
+ Input,
+ Select,
+ Tabs,
+ Modal,
+ Form,
+ Switch,
+ Checkbox,
+ Radio,
+ Badge,
+ Typography,
+ Flex,
+ message,
+} from 'antd';
+import { Plus, MagnifyingGlass, ShieldCheck, User, Eye, EyeSlash } from '@phosphor-icons/react';
+import { EditOutlined, DeleteOutlined } from '@ant-design/icons';
+import type { ColumnsType } from 'antd/es/table';
+import PageHeader from '../../components/PageHeader';
+import { useLang } from '../../i18n/LangContext';
+import { aclRules, aclUsers } from '../../mock/acl';
+import type { AclRule, AclUser } from '../../mock/acl';
+
+/* ═══════════════════════════════════════════
+ ACL Management Page
+ ═══════════════════════════════════════════ */
+const AclPage = () => {
+ const { t } = useLang();
+
+ /* ─── State ─── */
+ const [rules, setRules] = useState<AclRule[]>(aclRules);
+ const [users, setUsers] = useState<AclUser[]>(aclUsers);
+ const [activeTab, setActiveTab] = useState('rules');
+
+ // Rule filters
+ const [ruleSearch, setRuleSearch] = useState('');
+ const [ruleVersionFilter, setRuleVersionFilter] = useState<string>('all');
+ const [ruleDecisionFilter, setRuleDecisionFilter] = useState<string>('all');
+
+ // Rule modal
+ const [ruleModalOpen, setRuleModalOpen] = useState(false);
+ const [editingRule, setEditingRule] = useState<AclRule | null>(null);
+ const [ruleForm] = Form.useForm();
+
+ // User modal
+ const [userModalOpen, setUserModalOpen] = useState(false);
+ const [editingUser, setEditingUser] = useState<AclUser | null>(null);
+ const [userForm] = Form.useForm();
+
+ // Secret key reveal
+ const [revealedKeys, setRevealedKeys] = useState<Set<string>>(new Set());
+
+ /* ─── Filtered rules ─── */
+ const filteredRules = rules.filter((r) => {
+ const matchSearch =
+ !ruleSearch ||
+ r.principal.toLowerCase().includes(ruleSearch.toLowerCase()) ||
+ r.resource.toLowerCase().includes(ruleSearch.toLowerCase());
+ const matchVersion = ruleVersionFilter === 'all' || r.aclVersion === ruleVersionFilter;
+ const matchDecision = ruleDecisionFilter === 'all' || r.decision === ruleDecisionFilter;
+ return matchSearch && matchVersion && matchDecision;
+ });
+
+ /* ─── Rule helpers ─── */
+ const isAdmin = (principal: string) =>
+ users.find((u) => u.username === principal)?.admin ?? false;
+
+ const actionTagColor: Record<string, string> = {
+ PUB: 'blue',
+ SUB: 'green',
+ ALL: 'purple',
+ };
+
+ const actionLabel: Record<string, string> = {
+ PUB: t('acl.pub'),
+ SUB: t('acl.sub'),
+ ALL: t('acl.all'),
+ };
+
+ const openAddRuleModal = () => {
+ setEditingRule(null);
+ ruleForm.resetFields();
+ ruleForm.setFieldsValue({
+ resourcePattern: 'PREFIX',
+ actions: ['PUB'],
+ decision: 'ALLOW',
+ scope: 'cluster',
+ });
+ setRuleModalOpen(true);
+ };
+
+ const openEditRuleModal = (rule: AclRule) => {
+ setEditingRule(rule);
+ ruleForm.setFieldsValue({
+ principal: rule.principal,
+ resourceType: rule.resourceType,
+ resource: rule.resource,
+ resourcePattern: rule.resourcePattern,
+ actions: rule.actions,
+ decision: rule.decision,
+ scope: rule.scope,
+ });
+ setRuleModalOpen(true);
+ };
+
+ const handleRuleSubmit = () => {
+ ruleForm.validateFields().then((values) => {
+ if (editingRule) {
+ setRules((prev) => prev.map((r) => (r.id === editingRule.id ? { ...r, ...values } : r)));
+ message.success(t('acl.ruleUpdated'));
+ } else {
+ const newRule: AclRule = {
+ id: `acl-${Date.now()}`,
+ ...values,
+ aclVersion: '2.0',
+ createdAt: new Date().toISOString(),
+ };
+ setRules((prev) => [newRule, ...prev]);
+ message.success(t('acl.ruleAdded'));
+ }
+ setRuleModalOpen(false);
+ });
+ };
+
+ const handleDeleteRule = (id: string) => {
+ setRules((prev) => prev.filter((r) => r.id !== id));
+ message.success(t('acl.ruleDeleted'));
+ };
+
+ /* ─── User helpers ─── */
+ const toggleRevealKey = (userId: string) => {
+ setRevealedKeys((prev) => {
+ const next = new Set(prev);
+ if (next.has(userId)) {
+ next.delete(userId);
+ } else {
+ next.add(userId);
+ }
+ return next;
+ });
+ };
+
+ const openAddUserModal = () => {
+ setEditingUser(null);
+ userForm.resetFields();
+ userForm.setFieldsValue({ admin: false });
+ setUserModalOpen(true);
+ };
+
+ const openEditUserModal = (user: AclUser) => {
+ setEditingUser(user);
+ userForm.setFieldsValue({
+ username: user.username,
+ accessKey: user.accessKey,
+ secretKey: user.secretKey,
+ admin: user.admin,
+ });
+ setUserModalOpen(true);
+ };
+
+ const handleUserSubmit = () => {
+ userForm.validateFields().then((values) => {
+ if (editingUser) {
+ setUsers((prev) => prev.map((u) => (u.id === editingUser.id ? { ...u, ...values } : u)));
+ message.success(t('acl.userUpdated'));
+ } else {
+ const newUser: AclUser = {
+ id: `u-${Date.now()}`,
+ username: values.username,
+ accessKey: values.accessKey || `LTAI****${values.username.slice(-4)}`,
+ secretKey:
+ values.secretKey ||
+ `${Math.random().toString(36).slice(2, 6)}****${Math.random().toString(36).slice(2, 6)}`,
+ admin: values.admin ?? false,
+ clusters: ['rmq-cn-v5-prod-01'],
+ createdAt: new Date().toISOString(),
+ };
+ setUsers((prev) => [newUser, ...prev]);
+ message.success(t('acl.userAdded'));
+ }
+ setUserModalOpen(false);
+ });
+ };
+
+ const handleDeleteUser = (id: string) => {
+ setUsers((prev) => prev.filter((u) => u.id !== id));
+ message.success(t('acl.userDeleted'));
+ };
+
+ const handleToggleAdmin = (userId: string, checked: boolean) => {
+ setUsers((prev) => prev.map((u) => (u.id === userId ? { ...u, admin: checked } : u)));
+ message.success(checked ? t('acl.adminSet') : t('acl.adminRemoved'));
+ };
+
+ const formatDate = (iso: string) => {
+ const d = new Date(iso);
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
+ };
+
+ /* ═══════════════════════════════════════════
+ ACL Rules Table
+ ═══════════════════════════════════════════ */
+ const ruleColumns: ColumnsType<AclRule> = [
+ {
+ title: t('acl.principal'),
+ dataIndex: 'principal',
+ key: 'principal',
+ width: 200,
+ sorter: (a, b) => a.principal.localeCompare(b.principal),
+ render: (text: string) => (
+ <Space size={6}>
+ <User size={14} color="#8c8c8c" weight="fill" />
+ <span style={{ fontWeight: 500 }}>{text}</span>
+ {isAdmin(text) && (
+ <Badge
+ count={t('acl.adminBadge')}
+ style={{ backgroundColor: '#722ed1', fontSize: 11 }}
+ />
+ )}
+ </Space>
+ ),
+ },
+ {
+ title: t('acl.resource'),
+ key: 'resource',
+ width: 240,
+ sorter: (a, b) => a.resource.localeCompare(b.resource),
+ render: (_: unknown, record: AclRule) => (
+ <Space size={6}>
+ <span style={{ fontFamily: 'monospace', fontWeight: 500 }}>{record.resource}</span>
+ <Tag
+ color={record.resourcePattern === 'LITERAL' ? 'blue' : 'green'}
+ style={{ fontSize: 11, lineHeight: '18px' }}
+ >
+ {record.resourcePattern}
+ </Tag>
+ </Space>
+ ),
+ },
+ {
+ title: t('acl.permissions'),
+ dataIndex: 'actions',
+ key: 'actions',
+ width: 180,
+ render: (actions: AclRule['actions']) => (
+ <Space size={4} wrap>
+ {actions.map((action) => (
+ <Tag key={action} color={actionTagColor[action]} style={{ fontSize: 11 }}>
+ {actionLabel[action] ?? action}
+ </Tag>
+ ))}
+ </Space>
+ ),
+ },
+ {
+ title: t('acl.decision'),
+ dataIndex: 'decision',
+ key: 'decision',
+ width: 80,
+ sorter: (a, b) => a.decision.localeCompare(b.decision),
+ render: (decision: string) => (
+ <Tag color={decision === 'ALLOW' ? 'green' : 'red'} style={{ fontWeight: 600 }}>
+ {decision === 'ALLOW' ? t('acl.allow') : t('acl.deny')}
+ </Tag>
+ ),
+ },
+ {
+ title: t('acl.aclVersion'),
+ dataIndex: 'aclVersion',
+ key: 'aclVersion',
+ width: 100,
+ sorter: (a, b) => a.aclVersion.localeCompare(b.aclVersion),
+ render: (version: string) => (
+ <Tag color={version === '2.0' ? 'geekblue' : 'default'}>{version}</Tag>
+ ),
+ },
+ {
+ title: t('acl.scope'),
+ dataIndex: 'scope',
+ key: 'scope',
+ width: 100,
+ sorter: (a, b) => a.scope.localeCompare(b.scope),
+ render: (scope: string) => (
+ <span style={{ fontSize: 13 }}>
+ {scope === 'cluster' ? t('acl.cluster') : t('acl.namespace')}
+ </span>
+ ),
+ },
+ {
+ title: t('acl.createdAt'),
+ dataIndex: 'createdAt',
+ key: 'createdAt',
+ width: 160,
+ sorter: (a, b) => a.createdAt.localeCompare(b.createdAt),
+ render: (iso: string) => (
+ <span style={{ fontSize: 13, color: '#8c8c8c' }}>{formatDate(iso)}</span>
+ ),
+ },
+ {
+ title: t('common.actions'),
+ key: 'ruleActions',
+ width: 160,
+ render: (_: unknown, record: AclRule) => (
+ <Flex gap={6}>
+ <Button
+ size="small"
+ icon={<EditOutlined />}
+ style={{ borderColor: '#1677ff', color: '#1677ff' }}
+ onClick={() => openEditRuleModal(record)}
+ >
+ {t('common.edit')}
+ </Button>
+ <Button
+ size="small"
+ icon={<DeleteOutlined />}
+ style={{ borderColor: '#ff4d4f', color: '#ff4d4f' }}
+ onClick={() =>
+ Modal.confirm({
+ title: t('acl.confirmDeleteRule'),
+ content: t('acl.deleteWarning'),
+ okText: t('common.delete'),
+ okButtonProps: { danger: true },
+ onOk: () => handleDeleteRule(record.id),
+ })
+ }
+ >
+ {t('common.delete')}
+ </Button>
+ </Flex>
+ ),
+ },
+ ];
+
+ /* ═══════════════════════════════════════════
+ Users Table
+ ═══════════════════════════════════════════ */
+ const userColumns: ColumnsType<AclUser> = [
+ {
+ title: t('acl.username'),
+ dataIndex: 'username',
+ key: 'username',
+ width: 200,
+ sorter: (a, b) => a.username.localeCompare(b.username),
+ render: (text: string, record: AclUser) => (
+ <Space size={6}>
+ <User size={14} color="#8c8c8c" weight="fill" />
+ <span style={{ fontWeight: 500 }}>{text}</span>
+ {record.admin && (
+ <Badge
+ count={t('acl.adminBadge')}
+ style={{ backgroundColor: '#722ed1', fontSize: 11 }}
+ />
+ )}
+ </Space>
+ ),
+ },
+ {
+ title: 'Access Key',
+ dataIndex: 'accessKey',
+ key: 'accessKey',
+ width: 220,
+ sorter: (a, b) => a.accessKey.localeCompare(b.accessKey),
+ render: (text: string, record: AclUser) => {
+ const revealed = revealedKeys.has(record.id);
+ return (
+ <Space size={8}>
+ <Typography.Text copyable={{ text }} style={{ fontFamily: 'monospace', fontSize: 13 }}>
+ {revealed ? text : text.replace(/(?<=\*{4}).+/, '••••')}
+ </Typography.Text>
+ <Button
+ type="text"
+ size="small"
+ icon={revealed ? <EyeSlash size={14} /> : <Eye size={14} />}
+ onClick={() => toggleRevealKey(record.id)}
+ />
+ </Space>
+ );
+ },
+ },
+ {
+ title: t('acl.admin'),
+ dataIndex: 'admin',
+ key: 'admin',
+ width: 100,
+ sorter: (a, b) => Number(a.admin) - Number(b.admin),
+ render: (val: boolean, record: AclUser) => (
+ <Switch
+ checked={val}
+ size="small"
+ onChange={(checked) => handleToggleAdmin(record.id, checked)}
+ />
+ ),
+ },
+ {
+ title: t('acl.associatedClusters'),
+ dataIndex: 'clusters',
+ key: 'clusters',
+ width: 280,
+ sorter: (a, b) => a.clusters.length - b.clusters.length,
+ render: (clusters: string[]) => (
+ <Space size={4} wrap>
+ {clusters.map((c) => (
+ <Tag key={c} color="processing" style={{ fontSize: 11 }}>
+ {c}
+ </Tag>
+ ))}
+ </Space>
+ ),
+ },
+ {
+ title: t('acl.createdAt'),
+ dataIndex: 'createdAt',
+ key: 'createdAt',
+ width: 160,
+ sorter: (a, b) => a.createdAt.localeCompare(b.createdAt),
+ render: (iso: string) => (
+ <span style={{ fontSize: 13, color: '#8c8c8c' }}>{formatDate(iso)}</span>
+ ),
+ },
+ {
+ title: t('common.actions'),
+ key: 'userActions',
+ width: 160,
+ render: (_: unknown, record: AclUser) => (
+ <Flex gap={6}>
+ <Button
+ size="small"
+ icon={<EditOutlined />}
+ style={{ borderColor: '#1677ff', color: '#1677ff' }}
+ onClick={() => openEditUserModal(record)}
+ >
+ {t('common.edit')}
+ </Button>
+ <Button
+ size="small"
+ icon={<DeleteOutlined />}
+ style={{ borderColor: '#ff4d4f', color: '#ff4d4f' }}
+ onClick={() =>
+ Modal.confirm({
+ title: t('acl.confirmDeleteUser'),
+ content: t('acl.deleteUserWarning'),
+ okText: t('common.delete'),
+ okButtonProps: { danger: true },
+ onOk: () => handleDeleteUser(record.id),
+ })
+ }
+ >
+ {t('common.delete')}
+ </Button>
+ </Flex>
+ ),
+ },
+ ];
+
+ /* ═══════════════════════════════════════════
+ Render
+ ═══════════════════════════════════════════ */
+ return (
+ <div style={{ padding: 24 }}>
+ <PageHeader
+ title={t('acl.title')}
+ subtitle={t('acl.subtitle', { rules: rules.length, users: users.length })}
+ extra={
+ <Button
+ type="primary"
+ icon={<Plus size={14} weight="bold" />}
+ onClick={activeTab === 'rules' ? openAddRuleModal : openAddUserModal}
+ >
+ {activeTab === 'rules' ? t('acl.addRule') : t('acl.addUser')}
+ </Button>
+ }
+ />
+
+ <Card bordered={false} bodyStyle={{ padding: 0 }}>
+ <Tabs
+ activeKey={activeTab}
+ onChange={setActiveTab}
+ style={{ padding: '0 16px' }}
+ items={[
+ {
+ key: 'rules',
+ label: (
+ <Space size={6}>
+ <ShieldCheck size={15} />
+ <span>{t('acl.ruleTab')}</span>
+ </Space>
+ ),
+ children: (
+ <div>
+ {/* Filter bar */}
+ <div
+ style={{
+ display: 'flex',
+ gap: 12,
+ padding: '16px 0',
+ flexWrap: 'wrap',
+ }}
+ >
+ <Input.Search
+ placeholder={t('acl.searchPrincipal')}
+ prefix={<MagnifyingGlass size={14} color="#9CA3AF" />}
+ value={ruleSearch}
+ onChange={(e) => setRuleSearch(e.target.value)}
+ onSearch={setRuleSearch}
+ allowClear
+ style={{ width: 260 }}
+ />
+ <Select
+ value={ruleVersionFilter}
+ onChange={setRuleVersionFilter}
+ style={{ width: 140 }}
+ options={[
+ { value: 'all', label: t('acl.allVersions') },
+ { value: '1.0', label: 'ACL 1.0' },
+ { value: '2.0', label: 'ACL 2.0' },
+ ]}
+ />
+ <Select
+ value={ruleDecisionFilter}
+ onChange={setRuleDecisionFilter}
+ style={{ width: 140 }}
+ options={[
+ { value: 'all', label: t('acl.allDecisions') },
+ { value: 'ALLOW', label: t('acl.allow') },
+ { value: 'DENY', label: t('acl.deny') },
+ ]}
+ />
+ </div>
+
+ {/* Rules table */}
+ <Table
+ columns={ruleColumns}
+ dataSource={filteredRules}
+ rowKey="id"
+ pagination={{
+ pageSize: 20,
+ showSizeChanger: true,
+ showTotal: (total) => t('acl.totalRules', { n: total }),
+ }}
+ size="small"
+ />
+ </div>
+ ),
+ },
+ {
+ key: 'users',
+ label: (
+ <Space size={6}>
+ <User size={15} />
+ <span>{t('acl.userTab')}</span>
+ </Space>
+ ),
+ children: (
+ <div>
+ <div style={{ padding: '16px 0' }}>
+ <Space>
+ <Button
+ type="primary"
+ icon={<Plus size={14} weight="bold" />}
+ onClick={openAddUserModal}
+ >
+ {t('acl.addUser')}
+ </Button>
+ </Space>
+ </div>
+
+ <Table
+ columns={userColumns}
+ dataSource={users}
+ rowKey="id"
+ pagination={{
+ pageSize: 20,
+ showSizeChanger: true,
+ showTotal: (total) => t('acl.totalUsers', { n: total }),
+ }}
+ size="small"
+ />
+ </div>
+ ),
+ },
+ ]}
+ />
+ </Card>
+
+ {/* ─── Add/Edit Rule Modal ─── */}
+ <Modal
+ title={editingRule ? t('acl.editRule') : t('acl.addRule')}
+ open={ruleModalOpen}
+ onCancel={() => setRuleModalOpen(false)}
+ onOk={handleRuleSubmit}
+ okText={editingRule ? t('acl.save') : t('acl.add')}
+ cancelText={t('common.cancel')}
+ width={560}
+ destroyOnClose
+ >
+ <Form form={ruleForm} layout="vertical" style={{ marginTop: 16 }}>
+ <Form.Item
+ name="principal"
+ label={t('acl.principal')}
+ rules={[{ required: true, message: t('acl.required', { field: t('acl.principal') }) }]}
+ >
+ <Select
+ placeholder={t('acl.selectPrincipal')}
+ showSearch
+ optionFilterProp="label"
+ options={users.map((u) => ({
+ value: u.username,
+ label: u.username,
+ }))}
+ />
+ </Form.Item>
+
+ <Form.Item
+ name="resourceType"
+ label={t('acl.resourceType')}
+ rules={[
+ { required: true, message: t('acl.required', { field: t('acl.resourceType') }) },
+ ]}
+ >
+ <Select
+ placeholder={t('acl.selectResourceType')}
+ options={[
+ { value: 'Topic', label: 'Topic' },
+ { value: 'Group', label: 'Group' },
+ { value: 'Cluster', label: 'Cluster' },
+ ]}
+ />
+ </Form.Item>
+
+ <Form.Item
+ name="resource"
+ label={t('acl.resourceName')}
+ rules={[
+ { required: true, message: t('acl.inputRequired', { field: t('acl.resourceName') }) },
+ ]}
+ >
+ <Input placeholder={t('acl.resourceNamePlaceholder')} />
+ </Form.Item>
+
+ <Form.Item name="resourcePattern" label={t('acl.matchPattern')}>
+ <Radio.Group>
+ <Radio.Button value="LITERAL">{t('acl.literal')}</Radio.Button>
+ <Radio.Button value="PREFIX">{t('acl.prefix')}</Radio.Button>
+ </Radio.Group>
+ </Form.Item>
+
+ <Form.Item
+ name="actions"
+ label={t('acl.permissions')}
+ rules={[
+ { required: true, message: t('acl.required', { field: t('acl.permissions') }) },
+ ]}
+ >
+ <Checkbox.Group>
+ <Space>
+ <Checkbox value="PUB">{t('acl.pub')}</Checkbox>
+ <Checkbox value="SUB">{t('acl.sub')}</Checkbox>
+ <Checkbox value="ALL">{t('acl.all')}</Checkbox>
+ </Space>
+ </Checkbox.Group>
+ </Form.Item>
+
+ <Form.Item name="decision" label={t('acl.decision')}>
+ <Radio.Group>
+ <Radio.Button value="ALLOW">
+ <span style={{ color: '#52c41a' }}>{t('acl.allowDesc')}</span>
+ </Radio.Button>
+ <Radio.Button value="DENY">
+ <span style={{ color: '#ff4d4f' }}>{t('acl.denyDesc')}</span>
+ </Radio.Button>
+ </Radio.Group>
+ </Form.Item>
+
+ <Form.Item name="scope" label={t('acl.effectScope')}>
+ <Select
+ placeholder={t('acl.selectEffectScope')}
+ options={[
+ { value: 'cluster', label: t('acl.clusterScope') },
+ { value: 'namespace', label: t('acl.namespaceScope') },
+ ]}
+ />
+ </Form.Item>
+ </Form>
+ </Modal>
+
+ {/* ─── Add/Edit User Modal ─── */}
+ <Modal
+ title={editingUser ? t('acl.editUser') : t('acl.addUser')}
+ open={userModalOpen}
+ onCancel={() => setUserModalOpen(false)}
+ onOk={handleUserSubmit}
+ okText={editingUser ? t('acl.save') : t('acl.add')}
+ cancelText={t('common.cancel')}
+ width={520}
+ destroyOnClose
+ >
+ <Form form={userForm} layout="vertical" style={{ marginTop: 16 }}>
+ <Form.Item
+ name="username"
+ label={t('acl.username')}
+ rules={[
+ { required: true, message: t('acl.inputRequired', { field: t('acl.username') }) },
+ ]}
+ >
+ <Input
+ placeholder={t('acl.usernamePlaceholder')}
+ disabled={!!editingUser}
+ prefix={<User size={14} color="#9CA3AF" />}
+ />
+ </Form.Item>
+
+ <Form.Item name="accessKey" label="Access Key">
+ <Input placeholder={t('acl.autoOrManual')} style={{ fontFamily: 'monospace' }} />
+ </Form.Item>
+
+ <Form.Item name="secretKey" label="Secret Key">
+ <Input.Password
+ placeholder={t('acl.autoOrManual')}
+ style={{ fontFamily: 'monospace' }}
+ />
+ </Form.Item>
+
+ <Form.Item name="admin" label={t('acl.admin')} valuePropName="checked">
+ <Switch checkedChildren={t('common.yes')} unCheckedChildren={t('common.no')} />
+ </Form.Item>
+ </Form>
+ </Modal>
+ </div>
+ );
+};
+
+export default AclPage;
diff --git a/web/src/pages/instance/consumer.tsx b/web/src/pages/instance/consumer.tsx
new file mode 100644
index 0000000..9e0ec7c
--- /dev/null
+++ b/web/src/pages/instance/consumer.tsx
@@ -0,0 +1,1062 @@
+/*
+ * 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 { useState, useMemo } from 'react';
+import {
+ Table,
+ Card,
+ Button,
+ Tag,
+ Space,
+ Input,
+ Select,
+ Tabs,
+ Modal,
+ Form,
+ Descriptions,
+ Statistic,
+ Radio,
+ InputNumber,
+ Typography,
+ Row,
+ Col,
+ Flex,
+ DatePicker,
+ message,
+} from 'antd';
+import {
+ Plus,
+ MagnifyingGlass,
+ Eye,
+ ArrowsCounterClockwise,
+ Trash,
+ Clock,
+ Cube,
+ Users,
+ ListBullets,
+ Info,
+ ArrowsClockwise,
+} from '@phosphor-icons/react';
+import { ImportOutlined, ExportOutlined, DeleteOutlined } from '@ant-design/icons';
+import type { ColumnsType } from 'antd/es/table';
+import dayjs from 'dayjs';
+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 { mockConsumerGroups, mockQueueProgress, mockSubscriptions } from '../../mock/consumers';
+import type {
+ ConsumerGroup,
+ ConsumerInstance,
+ QueueProgress,
+ SubscriptionEntry,
+} from '../../mock/consumers';
+
+const { Text } = Typography;
+
+/* ─── Helpers ─── */
+
+const lagColor = (lag: number): string => {
+ if (lag >= 10_000) return '#ff4d4f';
+ if (lag >= 1_000) return '#faad14';
+ return '#52c41a';
+};
+
+/**
+ * Format delay seconds into human-readable Chinese time.
+ * Shows at most 3 units: days → hours → minutes → seconds.
+ * e.g. 82500 → "22小时55分钟", 3725 → "1小时2分钟5秒"
+ */
+const formatDelay = (totalSeconds: number): string => {
+ if (totalSeconds <= 0) return '0秒';
+
+ const days = Math.floor(totalSeconds / 86400);
+ let remaining = totalSeconds % 86400;
+ const hours = Math.floor(remaining / 3600);
+ remaining %= 3600;
+ const minutes = Math.floor(remaining / 60);
+ const seconds = remaining % 60;
+
+ const parts: string[] = [];
+ if (days > 0) parts.push(`${days}天`);
+ if (hours > 0) parts.push(`${hours}小时`);
+ if (minutes > 0) parts.push(`${minutes}分钟`);
+ if (seconds > 0 && parts.length < 3) parts.push(`${seconds}秒`);
+
+ 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())}`;
+};
+
+/* ═══════════════════════════════════════════
+ ConsumerPage
+ ═══════════════════════════════════════════ */
+const ConsumerPage = () => {
+ const { t } = useLang();
+ const [groups, setGroups] = useState<ConsumerGroup[]>(mockConsumerGroups);
+ const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
+ const [search, setSearch] = useState('');
+ const [modeFilter, setModeFilter] = useState<string>('ALL');
+ const [sortKey, setSortKey] = useState<string>('name_asc');
+ const [modalOpen, setModalOpen] = useState(false);
+ const [selectedGroup, setSelectedGroup] = useState<ConsumerGroup | null>(null);
+ const [createModalOpen, setCreateModalOpen] = useState(false);
+ const [form] = Form.useForm();
+ const [dataTypeValue, setDataTypeValue] = useState<string | undefined>(undefined);
+ const [resetModalOpen, setResetModalOpen] = useState(false);
+ const [resetGroup, setResetGroup] = useState<ConsumerGroup | null>(null);
+ const [resetTime, setResetTime] = useState<Dayjs>(dayjs().subtract(3, 'hour'));
+
+ /* ─── Filtered & sorted data ─── */
+ const filtered = useMemo(() => {
+ let data = groups.filter(
+ (g) =>
+ g.name.includes(search) ||
+ g.namespace.includes(search) ||
+ g.subscribedTopics.some((t) => t.includes(search)),
+ );
+
+ if (modeFilter !== 'ALL') {
+ data = data.filter((g) => g.subscriptionMode === modeFilter);
+ }
+
+ if (sortKey === 'lag_desc') {
+ data = [...data].sort((a, b) => b.totalLag - a.totalLag);
+ } else if (sortKey === 'name_asc') {
+ data = [...data].sort((a, b) => a.name.localeCompare(b.name));
+ }
+
+ return data;
+ }, [groups, search, modeFilter, sortKey]);
+
+ /* ─── Open detail modal ─── */
+ const openModal = (group: ConsumerGroup) => {
+ setSelectedGroup(group);
+ setModalOpen(true);
+ };
+
+ /* ═══════════════════════════════════════════
+ Main Table Columns
+ ═══════════════════════════════════════════ */
+ const columns: ColumnsType<ConsumerGroup> = [
+ {
+ title: 'Group 名称',
+ dataIndex: 'name',
+ key: 'name',
+ width: 220,
+ sorter: (a, b) => a.name.localeCompare(b.name),
+ render: (name: string) => (
+ <Text strong style={{ fontSize: 14 }}>
+ {name}
+ </Text>
+ ),
+ },
+ {
+ title: '订阅组类型',
+ dataIndex: 'subscriptionDataType',
+ key: 'subscriptionDataType',
+ width: 110,
+ sorter: (a, b) => a.subscriptionDataType.localeCompare(b.subscriptionDataType),
+ render: (type: string) => {
+ const config = TOPIC_TYPE_MAP[type] || { label: type, color: 'default' };
+ return <Tag color={config.color}>{config.label}</Tag>;
+ },
+ },
+ {
+ title: '订阅模式',
+ dataIndex: 'subscriptionMode',
+ key: 'subscriptionMode',
+ width: 90,
+ sorter: (a, b) => a.subscriptionMode.localeCompare(b.subscriptionMode),
+ render: (mode: string) => <Tag color={mode === 'Push' ? 'blue' : 'green'}>{mode}</Tag>,
+ },
+ {
+ title: '在线客户端',
+ dataIndex: 'onlineInstances',
+ key: 'onlineInstances',
+ width: 130,
+ align: 'center',
+ sorter: (a, b) => a.onlineInstances - b.onlineInstances,
+ },
+ {
+ title: '总堆积量',
+ dataIndex: 'totalLag',
+ key: 'totalLag',
+ width: 120,
+ sorter: (a, b) => a.totalLag - b.totalLag,
+ render: (lag: number) => lag.toLocaleString(),
+ },
+ {
+ title: '消费延迟',
+ dataIndex: 'delaySeconds',
+ key: 'delaySeconds',
+ width: 160,
+ sorter: (a, b) => a.delaySeconds - b.delaySeconds,
+ render: (seconds: number) => formatDelay(seconds),
+ },
+ {
+ title: '创建时间',
+ dataIndex: 'createdAt',
+ key: 'createdAt',
+ width: 170,
+ sorter: (a, b) => a.createdAt.localeCompare(b.createdAt),
+ render: (d: string) => (
+ <Text type="secondary" style={{ fontSize: 13 }}>
+ {d}
+ </Text>
+ ),
+ },
+ {
+ title: '修改时间',
+ dataIndex: 'updatedAt',
+ key: 'updatedAt',
+ width: 170,
+ sorter: (a, b) => a.updatedAt.localeCompare(b.updatedAt),
+ render: (d: string) => (
+ <Text type="secondary" style={{ fontSize: 13 }}>
+ {d}
+ </Text>
+ ),
+ },
+ {
+ title: '操作',
+ key: 'actions',
+ width: 240,
+ render: (_: unknown, record: ConsumerGroup) => (
+ <Flex gap={6}>
+ <Button
+ size="small"
+ icon={<Eye size={14} />}
+ style={{ borderColor: '#1677ff', color: '#1677ff' }}
+ onClick={(e) => {
+ e.stopPropagation();
+ openModal(record);
+ }}
+ >
+ 详情
+ </Button>
+ <Button
+ size="small"
+ icon={<ArrowsCounterClockwise size={14} />}
+ style={{ borderColor: '#fa8c16', color: '#fa8c16' }}
+ onClick={(e) => {
+ e.stopPropagation();
+ setResetGroup(record);
+ setResetTime(dayjs().subtract(3, 'hour'));
+ setResetModalOpen(true);
+ }}
+ >
+ 重置位点
+ </Button>
+ <Button
+ size="small"
+ icon={<Trash size={14} />}
+ style={{ borderColor: '#ff4d4f', color: '#ff4d4f' }}
+ onClick={(e) => {
+ e.stopPropagation();
+ Modal.confirm({
+ title: `确认删除消费组 "${record.name}"?`,
+ content: '删除后该消费组的所有配置和消费进度将被清除,此操作不可恢复。',
+ okText: '删除',
+ okButtonProps: { danger: true },
+ cancelText: '取消',
+ onOk: () => message.success(`消费组 ${record.name} 已删除`),
+ });
+ }}
+ >
+ 删除
+ </Button>
+ </Flex>
+ ),
+ },
+ ];
+
+ /* ═══════════════════════════════════════════
+ Expandable Sub-table: Subscription Details
+ ═══════════════════════════════════════════ */
+ const subscriptionSubColumns: ColumnsType<SubscriptionEntry> = [
+ {
+ title: 'Topic 主题',
+ dataIndex: 'topic',
+ key: 'topic',
+ width: 200,
+ render: (name: string) => (
+ <Text strong style={{ fontSize: 13 }}>
+ {name}
+ </Text>
+ ),
+ },
+ {
+ title: '订阅一致性',
+ dataIndex: 'consistency',
+ key: 'consistency',
+ width: 110,
+ render: (v: string) => <Tag color={v === '一致' ? 'green' : 'orange'}>{v}</Tag>,
+ },
+ {
+ title: '订阅模式',
+ dataIndex: 'filterMode',
+ key: 'filterMode',
+ width: 120,
+ render: (mode: string) => {
+ const colorMap: Record<string, string> = {
+ 全量: 'default',
... 4737 lines suppressed ...