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 d12b7df3c6df9f15777f6f9ba4977f82c412ea8a 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 c23820a0..5d327947 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 00000000..352555ca --- /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 00000000..f080ef21 --- /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 6823e5d4..1a5c95f9 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 00000000..57fa5c64 --- /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;
