This is an automated email from the ASF dual-hosted git repository.
lizhimins pushed a commit to branch rocketmq-studio
in repository https://gitbox.apache.org/repos/asf/rocketmq-dashboard.git
The following commit(s) were added to refs/heads/rocketmq-studio by this push:
new 9a0b9df feat: add lite topic management page (#481)
9a0b9df is described below
commit 9a0b9dfd1c6e23622b0b3dc7b96cc592fbb33e0e
Author: zhaohai <[email protected]>
AuthorDate: Wed Jul 22 19:23:58 2026 +0800
feat: add lite topic management page (#481)
Add lite topic management page.
---
web/src/App.tsx | 2 +
web/src/api/liteTopic.test.ts | 156 ++++++++
web/src/api/liteTopic.ts | 105 ++++++
web/src/i18n/translations.ts | 14 +
web/src/pages/studio/LiteTopic.tsx | 743 +++++++++++++++++++++++++++++++++++++
5 files changed, 1020 insertions(+)
diff --git a/web/src/App.tsx b/web/src/App.tsx
index c23820a..5d32794 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -33,6 +33,7 @@ import SystemAlertsPage from './pages/ops/systemAlerts';
import AuditPage from './pages/ops/audit';
import AiPage from './pages/ai';
import SettingsPage from './pages/settings';
+import LiteTopicPage from './pages/studio/LiteTopic';
import GroupManagementPage from './pages/studio/GroupManagement';
import BrokerClusterPage from './pages/studio/BrokerCluster';
import SslSettingsPage from './pages/studio/SslSettings';
@@ -62,6 +63,7 @@ function App() {
<Route path="ops/audit" element={<AuditPage />} />
<Route path="ai" element={<AiPage />} />
<Route path="settings" element={<SettingsPage />} />
+ <Route path="studio/lite-topic" element={<LiteTopicPage />} />
<Route path="studio/group-management" element={<GroupManagementPage
/>} />
<Route path="studio/broker-cluster" element={<BrokerClusterPage />} />
<Route path="studio/ssl-settings" element={<SslSettingsPage />} />
diff --git a/web/src/api/liteTopic.test.ts b/web/src/api/liteTopic.test.ts
new file mode 100644
index 0000000..352555c
--- /dev/null
+++ b/web/src/api/liteTopic.test.ts
@@ -0,0 +1,156 @@
+/*
+ * 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 MockAdapter from 'axios-mock-adapter';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import client from './client';
+import {
+ queryLiteTopicList,
+ queryLiteTopicSession,
+ extendLiteTopicTTL,
+ queryLiteTopicQuota,
+ queryLiteTopicCapability,
+ type LiteTopicItem,
+ type LiteTopicQuota,
+ type LiteTopicSession,
+} from './liteTopic';
+
+const mock = new MockAdapter(client);
+
+const sampleItem: LiteTopicItem = {
+ topicPattern: 'order-*',
+ topicCount: 15,
+ consumerCount: 3,
+ totalBacklog: 1200,
+ averageTTL: 3600,
+ ttlStatus: 'active',
+ lastActiveTime: Date.now(),
+ sessionIds: ['sess-1', 'sess-2'],
+};
+
+const sampleQuota: LiteTopicQuota = {
+ currentTopicCount: 50,
+ maxTopicCount: 200,
+ currentSessionCount: 10,
+ maxSessionCount: 100,
+ currentCreationRate: 5,
+ maxCreationRate: 50,
+ defaultTTL: 3600,
+ maxTTL: 86400,
+ remainingQuota: 150,
+ consumerDensity: 0.3,
+};
+
+const sampleSession: LiteTopicSession = {
+ sessionId: 'sess-1',
+ clientId: 'client-001',
+ clientAddress: '192.168.1.10',
+ parentTopic: 'order-events',
+ consumerGroup: 'order-processor',
+ createTime: Date.now() - 3600000,
+ lastActiveTime: Date.now(),
+ ttl: 3600,
+ ttlRemaining: 1800,
+ status: 'active',
+ totalMessages: 5000,
+ consumedMessages: 4800,
+ pendingMessages: 200,
+ popProgress: 96,
+};
+
+describe('LiteTopic API', () => {
+ beforeEach(() => {
+ mock.reset();
+ vi.stubGlobal('localStorage', { getItem: vi.fn().mockReturnValue(null) });
+ });
+
+ afterEach(() => {
+ mock.reset();
+ vi.unstubAllGlobals();
+ });
+
+ it('queries lite topic list without filters', async () => {
+ mock.onGet(/\/liteTopic\/list/).reply(200, { code: 200, data: [sampleItem]
});
+
+ const result = await queryLiteTopicList();
+ expect(result).toHaveLength(1);
+ expect(result[0].topicPattern).toBe('order-*');
+ });
+
+ it('queries lite topic list with pattern and namespace', async () => {
+ mock.onGet(/\/liteTopic\/list/).reply((config) => {
+ expect(config.url).toContain('pattern=order');
+ expect(config.url).toContain('namespace=ns1');
+ return [200, { code: 200, data: [sampleItem] }];
+ });
+
+ const result = await queryLiteTopicList('order', 'ns1');
+ expect(result).toHaveLength(1);
+ });
+
+ it('queries lite topic session by id', async () => {
+ mock.onGet('/liteTopic/session/sess-1').reply(200, { code: 200, data:
sampleSession });
+
+ const result = await queryLiteTopicSession('sess-1');
+ expect(result.sessionId).toBe('sess-1');
+ expect(result.popProgress).toBe(96);
+ });
+
+ it('extends lite topic TTL', async () => {
+ mock.onPost('/liteTopic/extendTTL').reply((config) => {
+ const body = JSON.parse(config.data);
+ expect(body.topicPattern).toBe('order-*');
+ expect(body.newTTL).toBe(7200);
+ return [200, { code: 200 }];
+ });
+
+ await extendLiteTopicTTL('order-*', 7200);
+ });
+
+ it('queries lite topic quota without namespace', async () => {
+ mock.onGet(/\/liteTopic\/quota/).reply(200, { code: 200, data: sampleQuota
});
+
+ const result = await queryLiteTopicQuota();
+ expect(result.currentTopicCount).toBe(50);
+ expect(result.maxTopicCount).toBe(200);
+ expect(result.defaultTTL).toBe(3600);
+ });
+
+ it('queries lite topic quota with namespace', async () => {
+ mock.onGet(/\/liteTopic\/quota/).reply((config) => {
+ expect(config.url).toContain('namespace=ns1');
+ return [200, { code: 200, data: sampleQuota }];
+ });
+
+ const result = await queryLiteTopicQuota('ns1');
+ expect(result.currentTopicCount).toBe(50);
+ });
+
+ it('queries lite topic capability as supported', async () => {
+ mock.onGet('/liteTopic/capability').reply(200, { code: 200, data: {
supported: true } });
+
+ const result = await queryLiteTopicCapability();
+ expect(result.supported).toBe(true);
+ });
+
+ it('queries lite topic capability as unsupported', async () => {
+ mock.onGet('/liteTopic/capability').reply(200, { code: 200, data: {
supported: false } });
+
+ const result = await queryLiteTopicCapability();
+ expect(result.supported).toBe(false);
+ });
+});
diff --git a/web/src/api/liteTopic.ts b/web/src/api/liteTopic.ts
new file mode 100644
index 0000000..f080ef2
--- /dev/null
+++ b/web/src/api/liteTopic.ts
@@ -0,0 +1,105 @@
+/*
+ * 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';
+
+// ─── Interfaces ──────────────────────────────────────────────────
+
+export interface LiteTopicQuota {
+ currentTopicCount: number;
+ maxTopicCount: number;
+ currentSessionCount: number;
+ maxSessionCount: number;
+ currentCreationRate: number;
+ maxCreationRate: number;
+ usageRate?: number;
+ sessionUsageRate?: number;
+ defaultTTL?: number;
+ maxTTL?: number;
+ remainingQuota?: number;
+ consumerDensity?: number;
+}
+
+export interface LiteTopicItem {
+ topicPattern: string;
+ topicCount?: number;
+ consumerCount?: number;
+ totalBacklog?: number;
+ averageTTL?: number;
+ ttlStatus?: string;
+ lastActiveTime?: number;
+ sessionIds?: string[];
+}
+
+export interface LiteTopicSession {
+ sessionId: string;
+ clientId?: string;
+ clientAddress?: string;
+ parentTopic?: string;
+ consumerGroup?: string;
+ createTime?: number;
+ lastActiveTime?: number;
+ ttl?: number;
+ ttlRemaining?: number;
+ status?: string;
+ totalMessages?: number;
+ consumedMessages?: number;
+ pendingMessages?: number;
+ popProgress?: number;
+ liteTopicCreationCount?: number;
+ liteTopics?: { topicName: string; status: string; ttlRemaining?: number }[];
+}
+
+export interface LiteTopicCapability {
+ supported: boolean;
+}
+
+// ─── API Functions ───────────────────────────────────────────────
+
+export async function queryLiteTopicList(
+ pattern?: string,
+ namespace?: string,
+): Promise<LiteTopicItem[]> {
+ const params = new URLSearchParams();
+ if (pattern) params.append('pattern', pattern);
+ if (namespace) params.append('namespace', namespace);
+ const res = await client.get<{ data: LiteTopicItem[]
}>(`/liteTopic/list?${params.toString()}`);
+ return res.data.data;
+}
+
+export async function queryLiteTopicSession(sessionId: string):
Promise<LiteTopicSession> {
+ const res = await client.get<{ data: LiteTopicSession }>(
+ `/liteTopic/session/${encodeURIComponent(sessionId)}`,
+ );
+ return res.data.data;
+}
+
+export async function extendLiteTopicTTL(topicPattern: string, newTTL:
number): Promise<void> {
+ await client.post('/liteTopic/extendTTL', { topicPattern, newTTL });
+}
+
+export async function queryLiteTopicQuota(namespace?: string):
Promise<LiteTopicQuota> {
+ const params = new URLSearchParams();
+ if (namespace) params.append('namespace', namespace);
+ const res = await client.get<{ data: LiteTopicQuota
}>(`/liteTopic/quota?${params.toString()}`);
+ return res.data.data;
+}
+
+export async function queryLiteTopicCapability(): Promise<LiteTopicCapability>
{
+ const res = await client.get<{ data: LiteTopicCapability
}>('/liteTopic/capability');
+ return res.data.data;
+}
diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts
index 6823e5d..1a5c95f 100644
--- a/web/src/i18n/translations.ts
+++ b/web/src/i18n/translations.ts
@@ -874,6 +874,20 @@ const translations: Record<string, Record<Lang, string>> =
{
'liteTopic.extendTtlSuccess': { zh: 'TTL 延长成功', en: 'TTL extended
successfully' },
'liteTopic.extendTtlFailed': { zh: 'TTL 延长失败', en: 'Failed to extend TTL' },
'liteTopic.total': { zh: '共 {total} 条记录', en: 'Total {total} records' },
+ 'liteTopic.defaultTtl': { zh: '默认 TTL', en: 'Default TTL' },
+ 'liteTopic.maxTtl': { zh: '最大 TTL', en: 'Max TTL' },
+ 'liteTopic.remainingQuota': { zh: '剩余配额', en: 'Remaining Quota' },
+ 'liteTopic.consumerDensity': { zh: '消费者密度', en: 'Consumer Density' },
+ 'liteTopic.newTtl': { zh: '新 TTL', en: 'New TTL' },
+ 'liteTopic.newTtlPlaceholder': {
+ zh: '请输入新的 TTL 值(毫秒)',
+ en: 'Enter new TTL value (ms)',
+ },
+ 'liteTopic.extendTtlModalTitle': { zh: '延长 TTL', en: 'Extend TTL' },
+ 'liteTopic.popProgress': { zh: 'Pop 进度', en: 'Pop Progress' },
+ 'liteTopic.sessionStatus': { zh: '会话状态', en: 'Session Status' },
+ 'liteTopic.creationCount': { zh: '创建数量', en: 'Creation Count' },
+ 'liteTopic.liteTopics': { zh: 'LiteTopic 列表', en: 'LiteTopics' },
// ─── Common (additional) ───
'common.loading': { zh: '加载中', en: 'Loading' },
diff --git a/web/src/pages/studio/LiteTopic.tsx
b/web/src/pages/studio/LiteTopic.tsx
new file mode 100644
index 0000000..57fa5c6
--- /dev/null
+++ b/web/src/pages/studio/LiteTopic.tsx
@@ -0,0 +1,743 @@
+/*
+ * 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 } from 'react';
+import {
+ Table,
+ Button,
+ Input,
+ Select,
+ Tag,
+ Modal,
+ Drawer,
+ Card,
+ Row,
+ Col,
+ Progress,
+ Space,
+ Statistic,
+ Descriptions,
+ Form,
+ InputNumber,
+ Alert,
+ App,
+} from 'antd';
+import type { ColumnsType } from 'antd/es/table';
+import {
+ MagnifyingGlass,
+ ArrowClockwise,
+ ClockCounterClockwise,
+ Eye,
+ PencilSimple,
+ Gauge,
+ Info,
+} from '@phosphor-icons/react';
+import PageHeader from '../../components/PageHeader';
+import { useLang } from '../../i18n/LangContext';
+import {
+ queryLiteTopicList,
+ queryLiteTopicQuota,
+ queryLiteTopicSession,
+ extendLiteTopicTTL,
+ queryLiteTopicCapability,
+ type LiteTopicQuota,
+ type LiteTopicItem,
+ type LiteTopicSession,
+} from '../../api/liteTopic';
+
+const formatDuration = (ms: number | undefined | null): string => {
+ if (ms == null) return '-';
+ if (ms < 1000) return `${ms}ms`;
+ if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
+ if (ms < 3600000) return `${(ms / 60000).toFixed(1)}min`;
+ return `${(ms / 3600000).toFixed(1)}h`;
+};
+
+const formatTime = (timestamp: number | undefined | null): string => {
+ if (!timestamp) return '-';
+ return new Date(timestamp).toLocaleString();
+};
+
+const getProgressStatus = (percent: number): 'exception' | 'active' | 'normal'
=> {
+ if (percent >= 90) return 'exception';
+ if (percent >= 70) return 'active';
+ return 'normal';
+};
+
+const LiteTopicPage: React.FC = () => {
+ const { t } = useLang();
+ const { message } = App.useApp();
+
+ const [loading, setLoading] = useState(false);
+ const [topicList, setTopicList] = useState<LiteTopicItem[]>([]);
+ const [quota, setQuota] = useState<LiteTopicQuota | null>(null);
+ const [capabilitySupported, setCapabilitySupported] = useState(true);
+ const [patternFilter, setPatternFilter] = useState('');
+ const [namespaceFilter, setNamespaceFilter] = useState('');
+
+ // Session drawer
+ const [sessionDrawerOpen, setSessionDrawerOpen] = useState(false);
+ const [sessionData, setSessionData] = useState<LiteTopicSession |
null>(null);
+ const [sessionLoading, setSessionLoading] = useState(false);
+
+ // Extend TTL modal
+ const [extendTTLModalOpen, setExtendTTLModalOpen] = useState(false);
+ const [extendTTLForm, setExtendTTLForm] = useState<{
+ topicPattern: string;
+ newTTL: number | null;
+ }>({ topicPattern: '', newTTL: null });
+ const [extendTTLLoading, setExtendTTLLoading] = useState(false);
+
+ const initialized = useRef<boolean | null>(null);
+ if (initialized.current == null) {
+ initialized.current = true;
+ const init = async () => {
+ try {
+ const cap = await queryLiteTopicCapability();
+ if (cap && cap.supported === false) {
+ setCapabilitySupported(false);
+ return;
+ }
+ } catch {
+ // Assume supported on error
+ }
+ try {
+ const q = await queryLiteTopicQuota();
+ setQuota(q);
+ } catch {
+ // ignore
+ }
+ setLoading(true);
+ try {
+ const list = await queryLiteTopicList();
+ setTopicList(list);
+ } catch {
+ message.error(t('liteTopic.fetchListFailed'));
+ } finally {
+ setLoading(false);
+ }
+ };
+ init();
+ }
+
+ const fetchQuota = async () => {
+ try {
+ const q = await queryLiteTopicQuota(namespaceFilter || undefined);
+ setQuota(q);
+ } catch {
+ // ignore
+ }
+ };
+
+ const fetchTopicList = async () => {
+ setLoading(true);
+ try {
+ const list = await queryLiteTopicList(
+ patternFilter || undefined,
+ namespaceFilter || undefined,
+ );
+ setTopicList(list);
+ } catch {
+ message.error(t('liteTopic.fetchListFailed'));
+ setTopicList([]);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleSearch = () => {
+ fetchTopicList();
+ };
+
+ const handleRefresh = () => {
+ fetchQuota();
+ fetchTopicList();
+ };
+
+ const handleNamespaceChange = (val: string | undefined) => {
+ setNamespaceFilter(val || '');
+ // Re-fetch with new namespace
+ const doFetch = async () => {
+ setLoading(true);
+ try {
+ const ns = val || undefined;
+ const [q, list] = await Promise.all([
+ queryLiteTopicQuota(ns),
+ queryLiteTopicList(patternFilter || undefined, ns),
+ ]);
+ setQuota(q);
+ setTopicList(list);
+ } catch {
+ message.error(t('liteTopic.fetchListFailed'));
+ } finally {
+ setLoading(false);
+ }
+ };
+ doFetch();
+ };
+
+ const handleViewSessions = async (sessionId: string) => {
+ setSessionDrawerOpen(true);
+ setSessionLoading(true);
+ try {
+ const data = await queryLiteTopicSession(sessionId);
+ setSessionData(data);
+ } catch {
+ message.error(t('liteTopic.fetchSessionFailed'));
+ setSessionData(null);
+ } finally {
+ setSessionLoading(false);
+ }
+ };
+
+ const handleOpenExtendTTL = (record: LiteTopicItem) => {
+ setExtendTTLForm({
+ topicPattern: record.topicPattern || '',
+ newTTL: null,
+ });
+ setExtendTTLModalOpen(true);
+ };
+
+ const handleExtendTTL = async () => {
+ if (!extendTTLForm.topicPattern || extendTTLForm.newTTL == null) return;
+ setExtendTTLLoading(true);
+ try {
+ await extendLiteTopicTTL(extendTTLForm.topicPattern,
extendTTLForm.newTTL);
+ message.success(t('liteTopic.extendTtlSuccess'));
+ setExtendTTLModalOpen(false);
+ fetchTopicList();
+ fetchQuota();
+ } catch {
+ message.error(t('liteTopic.extendTtlFailed'));
+ } finally {
+ setExtendTTLLoading(false);
+ }
+ };
+
+ const getTTLStatusTag = (status: string | undefined) => {
+ const map: Record<string, { color: string; label: string }> = {
+ ACTIVE: { color: 'success', label: t('liteTopic.active') },
+ EXPIRING_SOON: { color: 'warning', label: t('liteTopic.expiringSoon') },
+ EXPIRED: { color: 'error', label: t('liteTopic.expired') },
+ };
+ const cfg = map[status || ''] || { color: 'default', label:
t('liteTopic.unknown') };
+ return <Tag color={cfg.color}>{cfg.label}</Tag>;
+ };
+
+ // ─── Columns ─────────────────────────────────────────────────
+
+ const columns: ColumnsType<LiteTopicItem> = [
+ {
+ title: t('liteTopic.pattern'),
+ dataIndex: 'topicPattern',
+ key: 'topicPattern',
+ render: (text: string) => <span style={{ fontWeight: 500, color:
'#1677ff' }}>{text}</span>,
+ ellipsis: true,
+ },
+ {
+ title: t('liteTopic.topicCount'),
+ dataIndex: 'topicCount',
+ key: 'topicCount',
+ render: (val: number) => <span style={{ fontWeight: 500 }}>{val ??
'-'}</span>,
+ sorter: (a, b) => (a.topicCount || 0) - (b.topicCount || 0),
+ },
+ {
+ title: t('liteTopic.consumers'),
+ dataIndex: 'consumerCount',
+ key: 'consumerCount',
+ render: (val: number) => <span style={{ fontWeight: 500 }}>{val ??
'-'}</span>,
+ sorter: (a, b) => (a.consumerCount || 0) - (b.consumerCount || 0),
+ },
+ {
+ title: t('liteTopic.backlog'),
+ dataIndex: 'totalBacklog',
+ key: 'totalBacklog',
+ render: (val: number) => {
+ const num = val ?? 0;
+ const color = num > 10000 ? '#ff4d4f' : num > 0 ? '#fa8c16' :
'#52c41a';
+ return <span style={{ color, fontWeight: 500
}}>{num.toLocaleString()}</span>;
+ },
+ sorter: (a, b) => (a.totalBacklog || 0) - (b.totalBacklog || 0),
+ },
+ {
+ title: t('liteTopic.avgTtl'),
+ dataIndex: 'averageTTL',
+ key: 'averageTTL',
+ render: (val: number) => formatDuration(val),
+ sorter: (a, b) => (a.averageTTL || 0) - (b.averageTTL || 0),
+ },
+ {
+ title: t('liteTopic.status'),
+ dataIndex: 'ttlStatus',
+ key: 'ttlStatus',
+ render: (status: string) => getTTLStatusTag(status),
+ },
+ {
+ title: t('liteTopic.lastActive'),
+ dataIndex: 'lastActiveTime',
+ key: 'lastActiveTime',
+ render: (val: number) => formatTime(val),
+ sorter: (a, b) => (a.lastActiveTime || 0) - (b.lastActiveTime || 0),
+ },
+ {
+ title: t('liteTopic.actions'),
+ key: 'action',
+ render: (_: unknown, record: LiteTopicItem) => (
+ <Space size="small">
+ <Button
+ type="link"
+ size="small"
+ icon={<PencilSimple size={14} />}
+ onClick={(e) => {
+ e.stopPropagation();
+ handleOpenExtendTTL(record);
+ }}
+ >
+ {t('liteTopic.extendTtl')}
+ </Button>
+ {record.sessionIds && record.sessionIds.length > 0 && (
+ <Button
+ type="link"
+ size="small"
+ icon={<Eye size={14} />}
+ onClick={(e) => {
+ e.stopPropagation();
+ handleViewSessions(record.sessionIds![0]);
+ }}
+ >
+ {t('liteTopic.viewSessions')}
+ </Button>
+ )}
+ </Space>
+ ),
+ },
+ ];
+
+ // ─── Quota Panel ─────────────────────────────────────────────
+
+ const renderQuotaPanel = () => {
+ if (!quota) return null;
+
+ const topicUsagePercent =
+ quota.usageRate != null
+ ? Math.round(quota.usageRate * 100)
+ : quota.maxTopicCount > 0
+ ? Math.round((quota.currentTopicCount / quota.maxTopicCount) * 100)
+ : 0;
+ const sessionUsagePercent =
+ quota.sessionUsageRate != null
+ ? Math.round(quota.sessionUsageRate * 100)
+ : quota.maxSessionCount > 0
+ ? Math.round((quota.currentSessionCount / quota.maxSessionCount) *
100)
+ : 0;
+ const creationRatePercent =
+ quota.maxCreationRate > 0
+ ? Math.round((quota.currentCreationRate / quota.maxCreationRate) * 100)
+ : 0;
+
+ return (
+ <Card bordered={false} style={{ marginBottom: 16, borderRadius: 8 }}>
+ <div style={{ display: 'flex', alignItems: 'center', marginBottom: 16
}}>
+ <Gauge size={20} weight="bold" style={{ marginRight: 8, color:
'#1677ff' }} />
+ <h3 style={{ margin: 0, fontSize: 15, fontWeight: 600 }}>
+ {t('liteTopic.quotaOverview')}
+ </h3>
+ </div>
+ <Row gutter={24}>
+ <Col span={8}>
+ <div style={{ textAlign: 'center' }}>
+ <div style={{ fontSize: 13, color: '#595959', marginBottom: 8,
fontWeight: 500 }}>
+ {t('liteTopic.topicUsage')}
+ </div>
+ <Progress
+ percent={topicUsagePercent}
+ status={getProgressStatus(topicUsagePercent)}
+ strokeColor={
+ topicUsagePercent >= 90
+ ? '#ff4d4f'
+ : topicUsagePercent >= 70
+ ? '#fa8c16'
+ : '#1677ff'
+ }
+ />
+ <div style={{ fontSize: 12, color: '#8c8c8c', marginTop: 4 }}>
+ {quota.currentTopicCount} / {quota.maxTopicCount}
+ </div>
+ </div>
+ </Col>
+ <Col span={8}>
+ <div style={{ textAlign: 'center' }}>
+ <div style={{ fontSize: 13, color: '#595959', marginBottom: 8,
fontWeight: 500 }}>
+ {t('liteTopic.sessionUsage')}
+ </div>
+ <Progress
+ percent={sessionUsagePercent}
+ status={getProgressStatus(sessionUsagePercent)}
+ strokeColor={
+ sessionUsagePercent >= 90
+ ? '#ff4d4f'
+ : sessionUsagePercent >= 70
+ ? '#fa8c16'
+ : '#1677ff'
+ }
+ />
+ <div style={{ fontSize: 12, color: '#8c8c8c', marginTop: 4 }}>
+ {quota.currentSessionCount} / {quota.maxSessionCount}
+ </div>
+ </div>
+ </Col>
+ <Col span={8}>
+ <div style={{ textAlign: 'center' }}>
+ <div style={{ fontSize: 13, color: '#595959', marginBottom: 8,
fontWeight: 500 }}>
+ {t('liteTopic.creationRate')}
+ </div>
+ <Progress
+ percent={creationRatePercent}
+ status={getProgressStatus(creationRatePercent)}
+ strokeColor={
+ creationRatePercent >= 90
+ ? '#ff4d4f'
+ : creationRatePercent >= 70
+ ? '#fa8c16'
+ : '#1677ff'
+ }
+ />
+ <div style={{ fontSize: 12, color: '#8c8c8c', marginTop: 4 }}>
+ {quota.currentCreationRate} / {quota.maxCreationRate}
+ </div>
+ </div>
+ </Col>
+ </Row>
+ <Row gutter={16} style={{ marginTop: 16 }}>
+ <Col span={6}>
+ <Statistic title={t('liteTopic.defaultTtl')}
value={formatDuration(quota.defaultTTL)} />
+ </Col>
+ <Col span={6}>
+ <Statistic title={t('liteTopic.maxTtl')}
value={formatDuration(quota.maxTTL)} />
+ </Col>
+ <Col span={6}>
+ <Statistic title={t('liteTopic.remainingQuota')}
value={quota.remainingQuota ?? '-'} />
+ </Col>
+ <Col span={6}>
+ <Statistic
+ title={t('liteTopic.consumerDensity')}
+ value={quota.consumerDensity ?? '-'}
+ />
+ </Col>
+ </Row>
+ </Card>
+ );
+ };
+
+ // ─── Session Drawer Content ──────────────────────────────────
+
+ const renderSessionContent = () => {
+ if (sessionLoading) {
+ return <div style={{ textAlign: 'center', padding: 40
}}>{t('common.loading')}...</div>;
+ }
+ if (!sessionData) {
+ return <div style={{ textAlign: 'center', padding: 40
}}>{t('common.noData')}</div>;
+ }
+
+ const consumptionPercent =
+ sessionData.totalMessages != null && sessionData.totalMessages > 0
+ ? Math.round(((sessionData.consumedMessages ?? 0) /
sessionData.totalMessages) * 100)
+ : 0;
+
+ return (
+ <div>
+ <Descriptions column={2} bordered size="small">
+ <Descriptions.Item label={t('liteTopic.sessionId')} span={2}>
+ <code style={{ fontSize: 12 }}>{sessionData.sessionId}</code>
+ </Descriptions.Item>
+ <Descriptions.Item label={t('liteTopic.clientId')}>
+ {sessionData.clientId || '-'}
+ </Descriptions.Item>
+ <Descriptions.Item label={t('liteTopic.clientAddress')}>
+ <code style={{ fontSize: 12 }}>{sessionData.clientAddress ||
'-'}</code>
+ </Descriptions.Item>
+ <Descriptions.Item label={t('liteTopic.parentTopic')}>
+ {sessionData.parentTopic || '-'}
+ </Descriptions.Item>
+ <Descriptions.Item label={t('liteTopic.consumerGroup')}>
+ {sessionData.consumerGroup || '-'}
+ </Descriptions.Item>
+ <Descriptions.Item label={t('liteTopic.createTime')}>
+ {formatTime(sessionData.createTime)}
+ </Descriptions.Item>
+ <Descriptions.Item label={t('liteTopic.lastActive')}>
+ {formatTime(sessionData.lastActiveTime)}
+ </Descriptions.Item>
+ <Descriptions.Item label={t('liteTopic.ttl')}>
+ {formatDuration(sessionData.ttl)}
+ </Descriptions.Item>
+ <Descriptions.Item label={t('liteTopic.ttlRemaining')}>
+ {formatDuration(sessionData.ttlRemaining)}
+ </Descriptions.Item>
+ <Descriptions.Item label={t('liteTopic.sessionStatus')}>
+ <Tag
+ color={
+ sessionData.status === 'ACTIVE'
+ ? 'success'
+ : sessionData.status === 'EXPIRED'
+ ? 'error'
+ : 'default'
+ }
+ >
+ {sessionData.status || '-'}
+ </Tag>
+ </Descriptions.Item>
+ <Descriptions.Item label={t('liteTopic.creationCount')}>
+ {sessionData.liteTopicCreationCount ?? '-'}
+ </Descriptions.Item>
+ </Descriptions>
+
+ <h4 style={{ marginTop: 20, marginBottom: 12, fontSize: 14,
fontWeight: 600 }}>
+ {t('liteTopic.consumptionRate')}
+ </h4>
+ <Row gutter={16}>
+ <Col span={8}>
+ <Card
+ bordered={false}
+ style={{ background: '#f6ffed', borderRadius: 8, textAlign:
'center', padding: 12 }}
+ >
+ <div style={{ fontSize: 12, color: '#8c8c8c', marginBottom: 4 }}>
+ {t('liteTopic.totalMessages')}
+ </div>
+ <div style={{ fontSize: 24, fontWeight: 700
}}>{sessionData.totalMessages ?? 0}</div>
+ </Card>
+ </Col>
+ <Col span={8}>
+ <Card
+ bordered={false}
+ style={{ background: '#f6ffed', borderRadius: 8, textAlign:
'center', padding: 12 }}
+ >
+ <div style={{ fontSize: 12, color: '#8c8c8c', marginBottom: 4 }}>
+ {t('liteTopic.consumedMessages')}
+ </div>
+ <div style={{ fontSize: 24, fontWeight: 700 }}>
+ {sessionData.consumedMessages ?? 0}
+ </div>
+ </Card>
+ </Col>
+ <Col span={8}>
+ <Card
+ bordered={false}
+ style={{ background: '#fffbe6', borderRadius: 8, textAlign:
'center', padding: 12 }}
+ >
+ <div style={{ fontSize: 12, color: '#8c8c8c', marginBottom: 4 }}>
+ {t('liteTopic.pendingMessages')}
+ </div>
+ <div style={{ fontSize: 24, fontWeight: 700, color: '#fa8c16' }}>
+ {sessionData.pendingMessages ?? 0}
+ </div>
+ </Card>
+ </Col>
+ </Row>
+
+ <div style={{ marginTop: 16 }}>
+ <div style={{ marginBottom: 8, fontWeight: 500
}}>{t('liteTopic.consumptionRate')}</div>
+ <Progress
+ percent={consumptionPercent}
+ status={consumptionPercent >= 100 ? 'success' : 'active'}
+ strokeColor={consumptionPercent >= 100 ? '#52c41a' : '#1677ff'}
+ />
+ </div>
+
+ {sessionData.popProgress != null && (
+ <div style={{ marginTop: 16 }}>
+ <div style={{ marginBottom: 8, fontWeight: 500
}}>{t('liteTopic.popProgress')}</div>
+ <Progress
+ percent={Math.round(sessionData.popProgress * 100)}
+ status="active"
+ strokeColor="#722ed1"
+ />
+ </div>
+ )}
+
+ {sessionData.liteTopics && sessionData.liteTopics.length > 0 && (
+ <>
+ <h4 style={{ marginTop: 20, marginBottom: 12, fontSize: 14,
fontWeight: 600 }}>
+ {t('liteTopic.liteTopics')}
+ </h4>
+ <Table
+ dataSource={sessionData.liteTopics.map((lt, idx) => ({
+ key: idx,
+ ...lt,
+ }))}
+ columns={[
+ {
+ title: t('topic.name'),
+ dataIndex: 'topicName',
+ key: 'topicName',
+ render: (text: string) => <span style={{ fontWeight: 500
}}>{text}</span>,
+ },
+ {
+ title: t('liteTopic.status'),
+ dataIndex: 'status',
+ key: 'status',
+ render: (s: string) => (
+ <Tag color={s === 'ACTIVE' ? 'success' :
'default'}>{s}</Tag>
+ ),
+ },
+ {
+ title: t('liteTopic.ttlRemaining'),
+ dataIndex: 'ttlRemaining',
+ key: 'ttlRemaining',
+ render: (v: number) => formatDuration(v),
+ },
+ ]}
+ pagination={false}
+ size="small"
+ />
+ </>
+ )}
+ </div>
+ );
+ };
+
+ // ─── Graceful Degradation ────────────────────────────────────
+
+ if (!capabilitySupported) {
+ return (
+ <div style={{ padding: 0 }}>
+ <PageHeader title={t('liteTopic.title')} />
+ <Alert
+ message={t('liteTopic.notSupported')}
+ type="info"
+ showIcon
+ icon={<Info size={16} />}
+ style={{ marginTop: 16 }}
+ />
+ </div>
+ );
+ }
+
+ // ─── Main Render ─────────────────────────────────────────────
+
+ return (
+ <div style={{ padding: 0 }}>
+ <PageHeader
+ title={t('liteTopic.title')}
+ extra={
+ <Button icon={<ArrowClockwise size={14} />} size="small"
onClick={handleRefresh}>
+ {t('common.refresh')}
+ </Button>
+ }
+ />
+
+ {/* Quota Panel */}
+ {renderQuotaPanel()}
+
+ {/* Search / Filter Bar */}
+ <Card bordered={false} style={{ marginBottom: 16, borderRadius: 8 }}>
+ <Space size="middle" wrap>
+ <Input
+ placeholder={t('liteTopic.searchPlaceholder')}
+ prefix={<MagnifyingGlass size={14} />}
+ value={patternFilter}
+ onChange={(e) => setPatternFilter(e.target.value)}
+ onPressEnter={handleSearch}
+ style={{ width: 260 }}
+ allowClear
+ />
+ <Select
+ placeholder={t('liteTopic.namespacePlaceholder')}
+ value={namespaceFilter || undefined}
+ onChange={handleNamespaceChange}
+ style={{ width: 180 }}
+ allowClear
+ >
+ <Select.Option
value="">{t('liteTopic.allNamespaces')}</Select.Option>
+ </Select>
+ <Button type="primary" icon={<MagnifyingGlass size={14} />}
onClick={handleSearch}>
+ {t('common.search')}
+ </Button>
+ </Space>
+ </Card>
+
+ {/* Main Table */}
+ <Card bordered={false} style={{ borderRadius: 8 }}>
+ <Table
+ columns={columns}
+ dataSource={topicList}
+ rowKey={(record) => record.topicPattern || Math.random().toString()}
+ loading={loading}
+ pagination={{
+ pageSize: 10,
+ showTotal: (total) => t('liteTopic.total').replace('{total}',
String(total)),
+ showSizeChanger: true,
+ }}
+ size="middle"
+ />
+ </Card>
+
+ {/* Session Detail Drawer */}
+ <Drawer
+ title={
+ <span>
+ <ClockCounterClockwise size={16} style={{ marginRight: 8, color:
'#1677ff' }} />
+ {t('liteTopic.sessionDetail')}
+ </span>
+ }
+ placement="right"
+ width={680}
+ open={sessionDrawerOpen}
+ onClose={() => {
+ setSessionDrawerOpen(false);
+ setSessionData(null);
+ }}
+ destroyOnClose
+ >
+ {renderSessionContent()}
+ </Drawer>
+
+ {/* Extend TTL Modal */}
+ <Modal
+ title={t('liteTopic.extendTtlModalTitle')}
+ open={extendTTLModalOpen}
+ onOk={handleExtendTTL}
+ onCancel={() => setExtendTTLModalOpen(false)}
+ confirmLoading={extendTTLLoading}
+ okText={t('common.confirm')}
+ cancelText={t('common.cancel')}
+ destroyOnClose
+ >
+ <Form layout="vertical" style={{ marginTop: 16 }}>
+ <Form.Item label={t('liteTopic.pattern')}>
+ <Input
+ value={extendTTLForm.topicPattern}
+ onChange={(e) => setExtendTTLForm({ ...extendTTLForm,
topicPattern: e.target.value })}
+ disabled
+ />
+ </Form.Item>
+ <Form.Item label={t('liteTopic.newTtl')} required>
+ <InputNumber
+ style={{ width: '100%' }}
+ placeholder={t('liteTopic.newTtlPlaceholder')}
+ value={extendTTLForm.newTTL}
+ onChange={(val) => setExtendTTLForm({ ...extendTTLForm, newTTL:
val })}
+ min={1}
+ />
+ </Form.Item>
+ </Form>
+ </Modal>
+ </div>
+ );
+};
+
+export default LiteTopicPage;