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 06f2d105 feat: improve K8s certificate visibility and filtering (#713)
06f2d105 is described below
commit 06f2d105e2524511f69043114026e1e469479c52
Author: yx9o <[email protected]>
AuthorDate: Mon Aug 3 11:11:44 2026 +0800
feat: improve K8s certificate visibility and filtering (#713)
---
.../pages/cluster/__tests__/K8sCertsPage.test.tsx | 67 ++++++++++++++++++++--
web/src/pages/cluster/certs.tsx | 50 ++++++++++++++--
2 files changed, 105 insertions(+), 12 deletions(-)
diff --git a/web/src/pages/cluster/__tests__/K8sCertsPage.test.tsx
b/web/src/pages/cluster/__tests__/K8sCertsPage.test.tsx
index 0e7381d6..bd6eccb2 100644
--- a/web/src/pages/cluster/__tests__/K8sCertsPage.test.tsx
+++ b/web/src/pages/cluster/__tests__/K8sCertsPage.test.tsx
@@ -16,7 +16,7 @@
*/
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
-import { render, screen } from '@testing-library/react';
+import { fireEvent, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { App } from 'antd';
import type { K8sCertInfo } from '../../../api/cluster';
@@ -48,7 +48,7 @@ const certs: K8sCertInfo[] = [
{
id: 'cert-staging',
name: 'rocketmq-staging-tls',
- namespace: 'rocketmq',
+ namespace: 'platform',
cluster: 'staging-cluster',
type: 'TLS',
issuer: 'kubernetes-ca',
@@ -56,7 +56,8 @@ const certs: K8sCertInfo[] = [
notAfter: '2027-01-01T00:00:00Z',
status: 'valid',
daysRemaining: 365,
- san: ['broker.staging.example.com'],
+ // The backend returns null when SAN is omitted.
+ san: null as unknown as string[],
},
];
@@ -81,16 +82,70 @@ describe('K8sCertsPage', () => {
vi.mocked(listK8sCerts).mockResolvedValue(certs);
});
- it('trims certificate search text before filtering', async () => {
- const user = userEvent.setup();
+ const renderPage = () =>
render(
<App>
<K8sCertsPage />
</App>,
);
+ it('displays certificate namespace and SAN metadata', async () => {
+ renderPage();
+
+ await screen.findByText('rocketmq-prod-tls');
+
+ expect(screen.getByText('rocketmq')).toBeInTheDocument();
+ expect(screen.getByText('platform')).toBeInTheDocument();
+ expect(screen.getByText('broker.prod.example.com')).toBeInTheDocument();
+ expect(screen.getByText('-')).toBeInTheDocument();
+ });
+
+ it.each([
+ ['platform', 'rocketmq-staging-tls', 'rocketmq-prod-tls'],
+ ['broker.prod.example.com', 'rocketmq-prod-tls', 'rocketmq-staging-tls'],
+ ])('searches certificate metadata by %s', async (query, expected, hidden) =>
{
+ const user = userEvent.setup();
+ renderPage();
+
await screen.findByText('rocketmq-prod-tls');
- await user.type(screen.getByPlaceholderText('搜索证书名称或集群'), ' prod-cluster
{enter}');
+ await user.type(
+ screen.getByPlaceholderText('搜索证书名称、集群、命名空间或 SAN'),
+ `${query}{enter}`,
+ );
+
+ expect(screen.getByText(expected)).toBeInTheDocument();
+ expect(screen.queryByText(hidden)).not.toBeInTheDocument();
+ });
+
+ it('filters certificates by namespace', async () => {
+ const user = userEvent.setup();
+ renderPage();
+
+ await screen.findByText('rocketmq-prod-tls');
+ const namespaceFilter = screen.getByRole('combobox', { name: '按命名空间筛选' });
+ const selector = namespaceFilter
+ .closest('.ant-select')
+ ?.querySelector<HTMLElement>('.ant-select-selector');
+
+ expect(selector).not.toBeNull();
+ fireEvent.mouseDown(selector!);
+ await user.click(
+ await screen.findByText('platform', { selector:
'.ant-select-item-option-content' }),
+ );
+
+ expect(screen.getByText('rocketmq-staging-tls')).toBeInTheDocument();
+ expect(screen.queryByText('rocketmq-prod-tls')).not.toBeInTheDocument();
+ });
+
+ it('trims certificate search text before filtering', async () => {
+ const user = userEvent.setup();
+ renderPage();
+
+ await screen.findByText('rocketmq-prod-tls');
+ await user.type(
+ screen.getByPlaceholderText('搜索证书名称、集群、命名空间或 SAN'),
+ ' prod-cluster {enter}',
+ );
expect(screen.getByText('rocketmq-prod-tls')).toBeInTheDocument();
expect(screen.queryByText('rocketmq-staging-tls')).not.toBeInTheDocument();
diff --git a/web/src/pages/cluster/certs.tsx b/web/src/pages/cluster/certs.tsx
index a9408b13..e0b95bdc 100644
--- a/web/src/pages/cluster/certs.tsx
+++ b/web/src/pages/cluster/certs.tsx
@@ -60,6 +60,7 @@ const K8sCertsPage = () => {
const [renewingId, setRenewingId] = useState<string | null>(null);
const [certSearch, setCertSearch] = useState('');
const [certTypeFilter, setCertTypeFilter] = useState<string>('');
+ const [certNamespaceFilter, setCertNamespaceFilter] = useState<string>('');
const [editModalOpen, setEditModalOpen] = useState(false);
const [editingCert, setEditingCert] = useState<K8sCertInfo | null>(null);
const [editForm] = Form.useForm();
@@ -130,13 +131,18 @@ const K8sCertsPage = () => {
};
const normalizedCertSearch = certSearch.trim().toLowerCase();
+ const namespaceOptions = Array.from(new Set(certs.map((cert) =>
cert.namespace)))
+ .sort((a, b) => a.localeCompare(b))
+ .map((namespace) => ({ value: namespace, label: namespace }));
const filteredCerts = certs.filter((cert) => {
const matchSearch =
!normalizedCertSearch ||
- cert.name.toLowerCase().includes(normalizedCertSearch) ||
- cert.cluster.toLowerCase().includes(normalizedCertSearch);
+ [cert.name, cert.cluster, cert.namespace, ...(cert.san ??
[])].some((value) =>
+ value.toLowerCase().includes(normalizedCertSearch),
+ );
const matchType = !certTypeFilter || cert.type === certTypeFilter;
- return matchSearch && matchType;
+ const matchNamespace = !certNamespaceFilter || cert.namespace ===
certNamespaceFilter;
+ return matchSearch && matchType && matchNamespace;
});
const renewCert = async (cert: K8sCertInfo) => {
@@ -172,6 +178,30 @@ const K8sCertsPage = () => {
<Text style={{ fontFamily: 'monospace', fontSize: 13 }}>{name}</Text>
),
},
+ {
+ title: '命名空间',
+ dataIndex: 'namespace',
+ key: 'namespace',
+ width: 150,
+ sorter: (a, b) => a.namespace.localeCompare(b.namespace),
+ render: (namespace: string) => <Text code>{namespace}</Text>,
+ },
+ {
+ title: 'SAN',
+ dataIndex: 'san',
+ key: 'san',
+ width: 260,
+ render: (san: string[] | null) =>
+ san?.length ? (
+ <Flex wrap gap={4}>
+ {san.map((value) => (
+ <Tag key={value}>{value}</Tag>
+ ))}
+ </Flex>
+ ) : (
+ <Text type="secondary">-</Text>
+ ),
+ },
{
title: '类型',
dataIndex: 'type',
@@ -257,7 +287,7 @@ const K8sCertsPage = () => {
issuer: record.issuer,
namespace: record.namespace,
cluster: record.cluster,
- san: record.san.join(', '),
+ san: record.san?.join(', ') ?? '',
});
setEditModalOpen(true);
}}
@@ -325,11 +355,18 @@ const K8sCertsPage = () => {
<Flex justify="space-between" style={{ marginBottom: 16 }}>
<Space>
<Input.Search
- placeholder="搜索证书名称或集群"
+ placeholder="搜索证书名称、集群、命名空间或 SAN"
allowClear
onSearch={setCertSearch}
onChange={(e) => !e.target.value && setCertSearch('')}
- style={{ width: 240 }}
+ style={{ width: 320 }}
+ />
+ <Select
+ aria-label="按命名空间筛选"
+ value={certNamespaceFilter}
+ onChange={setCertNamespaceFilter}
+ style={{ width: 180 }}
+ options={[{ value: '', label: '全部命名空间' }, ...namespaceOptions]}
/>
<Select
value={certTypeFilter}
@@ -352,6 +389,7 @@ const K8sCertsPage = () => {
loading={loading}
pagination={{ pageSize: 20 }}
size="small"
+ scroll={{ x: 1750 }}
/>
</Card>