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 a002477 fix: connect K8s certificate page to APIs (#440)
a002477 is described below
commit a002477b5264423250668a25d054fa8aaaa75c7e
Author: btlqql <[email protected]>
AuthorDate: Fri Jul 17 16:18:57 2026 +0800
fix: connect K8s certificate page to APIs (#440)
---
web/src/api/cluster.test.ts | 83 +++++++++++++++++++
web/src/api/cluster.ts | 9 +-
web/src/pages/cluster/certs.tsx | 165 ++++++++++++++++++++++++++++---------
web/src/services/clusterService.ts | 53 +++++++++++-
4 files changed, 267 insertions(+), 43 deletions(-)
diff --git a/web/src/api/cluster.test.ts b/web/src/api/cluster.test.ts
new file mode 100644
index 0000000..49a35e5
--- /dev/null
+++ b/web/src/api/cluster.test.ts
@@ -0,0 +1,83 @@
+/*
+ * 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 { createK8sCert, deleteK8sCert, listK8sCerts, updateK8sCert } from
'./cluster';
+import type { K8sCertInfo } from './cluster';
+
+const mock = new MockAdapter(client);
+
+const cert: K8sCertInfo = {
+ id: 'cert-1',
+ name: 'rocketmq-tls',
+ namespace: 'rocketmq',
+ cluster: 'prod-cluster',
+ type: 'TLS',
+ issuer: 'kubernetes-ca',
+ notBefore: '2026-01-01T00:00:00Z',
+ notAfter: '2027-01-01T00:00:00Z',
+ status: 'valid',
+ daysRemaining: 365,
+ san: ['broker.example.com'],
+};
+
+describe('K8s certificate API', () => {
+ beforeEach(() => {
+ mock.reset();
+ vi.stubGlobal('localStorage', { getItem: vi.fn().mockReturnValue(null) });
+ });
+ afterEach(() => {
+ mock.reset();
+ vi.unstubAllGlobals();
+ });
+
+ it('loads and unwraps certificate records', async () => {
+ mock.onGet('/k8s-certs').reply(200, { code: 200, message: 'success', data:
[cert] });
+
+ await expect(listK8sCerts()).resolves.toEqual([cert]);
+ });
+
+ it('returns the certificate created by the backend', async () => {
+ mock.onPost('/k8s-certs/create').reply((config) => {
+ expect(JSON.parse(config.data)).toMatchObject({ name: cert.name,
cluster: cert.cluster });
+ return [200, { code: 200, message: 'success', data: cert }];
+ });
+
+ await expect(createK8sCert({ name: cert.name, cluster: cert.cluster
})).resolves.toEqual(cert);
+ });
+
+ it('returns the updated certificate and sends its id', async () => {
+ const updated = { ...cert, issuer: 'vault' };
+ mock.onPost('/k8s-certs/update').reply((config) => {
+ expect(JSON.parse(config.data)).toMatchObject({ id: cert.id, issuer:
'vault' });
+ return [200, { code: 200, message: 'success', data: updated }];
+ });
+
+ await expect(updateK8sCert({ id: cert.id, issuer: 'vault'
})).resolves.toEqual(updated);
+ });
+
+ it('sends the certificate id when deleting', async () => {
+ mock.onPost('/k8s-certs/delete').reply((config) => {
+ expect(JSON.parse(config.data)).toEqual({ id: cert.id });
+ return [200, { code: 200, message: 'success', data: null }];
+ });
+
+ await expect(deleteK8sCert(cert.id)).resolves.toBeUndefined();
+ });
+});
diff --git a/web/src/api/cluster.ts b/web/src/api/cluster.ts
index c7d35ef..007498b 100644
--- a/web/src/api/cluster.ts
+++ b/web/src/api/cluster.ts
@@ -155,15 +155,18 @@ export async function listK8sCerts() {
}
export async function createK8sCert(data: Partial<K8sCertInfo>) {
- await client.post('/k8s-certs/create', data);
+ const res = await client.post<{ data: K8sCertInfo }>('/k8s-certs/create',
data);
+ return res.data.data;
}
export async function updateK8sCert(data: Partial<K8sCertInfo>) {
- await client.post('/k8s-certs/update', data);
+ const res = await client.post<{ data: K8sCertInfo }>('/k8s-certs/update',
data);
+ return res.data.data;
}
export async function renewK8sCert(id: string) {
- await client.post('/k8s-certs/renew', { id });
+ const res = await client.post<{ data: K8sCertInfo }>('/k8s-certs/renew', {
id });
+ return res.data.data;
}
export async function deleteK8sCert(id: string) {
diff --git a/web/src/pages/cluster/certs.tsx b/web/src/pages/cluster/certs.tsx
index 8449357..e515546 100644
--- a/web/src/pages/cluster/certs.tsx
+++ b/web/src/pages/cluster/certs.tsx
@@ -15,7 +15,7 @@
* limitations under the License.
*/
-import { useState } from 'react';
+import { useEffect, useState } from 'react';
import {
Table,
Tag,
@@ -33,7 +33,13 @@ import {
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';
+import type { K8sCertInfo } from '../../api/cluster';
+import {
+ createK8sCert,
+ deleteK8sCert,
+ listK8sCerts,
+ updateK8sCert,
+} from '../../services/clusterService';
const { Text } = Typography;
@@ -43,14 +49,84 @@ const formatDateTime = (iso: string): string => {
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}
${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
};
+const getErrorMessage = (error: unknown): string =>
+ error instanceof Error && error.message ? error.message : '请求失败,请稍后重试';
+
const K8sCertsPage = () => {
- const [certs, setCerts] = useState<K8sCertInfo[]>(mockK8sCerts);
+ const [certs, setCerts] = useState<K8sCertInfo[]>([]);
+ const [loading, setLoading] = useState(true);
+ const [submitting, setSubmitting] = useState(false);
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();
+ useEffect(() => {
+ let active = true;
+ listK8sCerts()
+ .then((data) => {
+ if (active) setCerts(data);
+ })
+ .catch((error: unknown) => {
+ if (active) message.error(getErrorMessage(error));
+ })
+ .finally(() => {
+ if (active) setLoading(false);
+ });
+ return () => {
+ active = false;
+ };
+ }, []);
+
+ const openCreateModal = () => {
+ setEditingCert(null);
+ editForm.resetFields();
+ editForm.setFieldsValue({ type: 'TLS', namespace: 'default' });
+ setEditModalOpen(true);
+ };
+
+ const closeEditModal = () => {
+ setEditModalOpen(false);
+ setEditingCert(null);
+ editForm.resetFields();
+ };
+
+ const saveCert = async () => {
+ const values = await editForm.validateFields();
+ const data = {
+ name: values.name,
+ namespace: values.namespace,
+ cluster: values.cluster,
+ type: values.type,
+ issuer: values.issuer,
+ san: values.san
+ ? String(values.san)
+ .split(',')
+ .map((value) => value.trim())
+ .filter(Boolean)
+ : [],
+ };
+
+ setSubmitting(true);
+ try {
+ if (editingCert) {
+ const updated = await updateK8sCert({ id: editingCert.id, ...data });
+ setCerts((prev) => prev.map((cert) => (cert.id === updated.id ?
updated : cert)));
+ message.success(`证书「${updated.name}」已更新`);
+ } else {
+ const created = await createK8sCert(data);
+ setCerts((prev) => [...prev, created]);
+ message.success(`证书「${created.name}」已创建`);
+ }
+ closeEditModal();
+ } catch (error) {
+ message.error(getErrorMessage(error));
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
const filteredCerts = certs.filter((cert) => {
const matchSearch =
!certSearch ||
@@ -163,6 +239,8 @@ const K8sCertsPage = () => {
type: record.type,
issuer: record.issuer,
namespace: record.namespace,
+ cluster: record.cluster,
+ san: record.san.join(', '),
});
setEditModalOpen(true);
}}
@@ -180,9 +258,15 @@ const K8sCertsPage = () => {
okText: '确认',
cancelText: '取消',
okButtonProps: { danger: true },
- onOk: () => {
- setCerts((prev) => prev.filter((c) => c.id !== record.id));
- message.success(`证书已删除: ${record.name}`);
+ onOk: async () => {
+ try {
+ await deleteK8sCert(record.id);
+ setCerts((prev) => prev.filter((c) => c.id !== record.id));
+ message.success(`证书已删除: ${record.name}`);
+ } catch (error) {
+ message.error(getErrorMessage(error));
+ throw error;
+ }
},
});
}}
@@ -200,11 +284,7 @@ const K8sCertsPage = () => {
title="K8s 证书管理"
subtitle={`共 ${filteredCerts.length} 个证书`}
extra={
- <Button
- type="primary"
- icon={<PlusOutlined />}
- onClick={() => message.info('添加证书功能开发中')}
- >
+ <Button type="primary" icon={<PlusOutlined />}
onClick={openCreateModal}>
添加证书
</Button>
}
@@ -236,6 +316,7 @@ const K8sCertsPage = () => {
columns={certColumns}
dataSource={filteredCerts}
rowKey="id"
+ loading={loading}
pagination={{ pageSize: 20 }}
size="small"
/>
@@ -243,39 +324,36 @@ const K8sCertsPage = () => {
{/* Edit Cert Modal */}
<Modal
- title={`编辑证书 — ${editingCert?.name || ''}`}
+ title={editingCert ? `编辑证书 — ${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();
- });
- }}
+ onCancel={closeEditModal}
+ onOk={saveCert}
+ confirmLoading={submitting}
okText="保存"
cancelText="取消"
width={520}
>
<Form form={editForm} layout="vertical" style={{ marginTop: 16 }}>
- <Form.Item label="证书名称">
- <Input value={editingCert?.name} disabled />
+ <Form.Item
+ label="证书名称"
+ name="name"
+ rules={[{ required: true, message: '请输入证书名称' }]}
+ >
+ <Input placeholder="例:rocketmq-tls"
disabled={Boolean(editingCert)} />
+ </Form.Item>
+ <Form.Item
+ label="K8s 集群名称"
+ name="cluster"
+ rules={[{ required: true, message: '请输入集群名称' }]}
+ >
+ <Input placeholder="例:prod-cluster" />
</Form.Item>
- <Form.Item label="类型">
+ <Form.Item
+ label="类型"
+ name="type"
+ rules={[{ required: true, message: '请选择证书类型' }]}
+ >
<Select
- value={editingCert?.type}
- disabled
options={[
{ value: 'TLS', label: 'TLS' },
{ value: 'mTLS', label: 'mTLS' },
@@ -283,12 +361,23 @@ const K8sCertsPage = () => {
]}
/>
</Form.Item>
- <Form.Item label="签发者" name="issuer">
+ <Form.Item
+ label="签发者"
+ name="issuer"
+ rules={[{ required: true, message: '请输入签发者' }]}
+ >
<Input placeholder="例:kubernetes-ca" />
</Form.Item>
- <Form.Item label="命名空间" name="namespace">
+ <Form.Item
+ label="命名空间"
+ name="namespace"
+ rules={[{ required: true, message: '请输入命名空间' }]}
+ >
<Input placeholder="例:kube-system" />
</Form.Item>
+ <Form.Item label="SAN" name="san" tooltip="多个域名或 IP 使用英文逗号分隔">
+ <Input placeholder="例:broker.example.com, *.rocketmq.example.com"
/>
+ </Form.Item>
</Form>
</Modal>
</div>
diff --git a/web/src/services/clusterService.ts
b/web/src/services/clusterService.ts
index 0e6615a..a3270e2 100644
--- a/web/src/services/clusterService.ts
+++ b/web/src/services/clusterService.ts
@@ -1,7 +1,12 @@
import { USE_MOCK } from '../config';
import * as clusterApi from '../api/cluster';
import type { ClusterInfo, K8sCertInfo } from '../api/cluster';
-import clusters from '../mock/clusters';
+import clusters, { mockK8sCerts } from '../mock/clusters';
+
+const mockCertStore: K8sCertInfo[] = mockK8sCerts.map((cert) => ({
+ ...cert,
+ san: [...cert.san],
+}));
export async function listClusters(): Promise<ClusterInfo[]> {
if (USE_MOCK) {
@@ -32,10 +37,54 @@ export async function getCluster(id: string) {
}
export async function listK8sCerts(): Promise<K8sCertInfo[]> {
- if (USE_MOCK) return []; // certs mock not yet defined
+ if (USE_MOCK) return mockCertStore.map((cert) => ({ ...cert, san:
[...cert.san] }));
return clusterApi.listK8sCerts();
}
+export async function createK8sCert(data: Partial<K8sCertInfo>):
Promise<K8sCertInfo> {
+ if (USE_MOCK) {
+ const now = new Date();
+ const notAfter = new Date(now);
+ notAfter.setFullYear(notAfter.getFullYear() + 1);
+ const cert: K8sCertInfo = {
+ id: `cert-${Date.now()}`,
+ name: data.name ?? '',
+ namespace: data.namespace ?? '',
+ cluster: data.cluster ?? '',
+ type: data.type ?? 'TLS',
+ issuer: data.issuer ?? '',
+ notBefore: now.toISOString(),
+ notAfter: notAfter.toISOString(),
+ status: 'valid',
+ daysRemaining: 365,
+ san: data.san ?? [],
+ };
+ mockCertStore.push(cert);
+ return { ...cert, san: [...cert.san] };
+ }
+ return clusterApi.createK8sCert(data);
+}
+
+export async function updateK8sCert(data: Partial<K8sCertInfo>):
Promise<K8sCertInfo> {
+ if (USE_MOCK) {
+ const existing = mockCertStore.find((cert) => cert.id === data.id);
+ if (!existing) throw new Error(`Certificate not found: ${data.id}`);
+ Object.assign(existing, data, { san: data.san ?? existing.san });
+ return { ...existing, san: [...existing.san] };
+ }
+ return clusterApi.updateK8sCert(data);
+}
+
+export async function deleteK8sCert(id: string): Promise<void> {
+ if (USE_MOCK) {
+ const index = mockCertStore.findIndex((cert) => cert.id === id);
+ if (index < 0) throw new Error(`Certificate not found: ${id}`);
+ mockCertStore.splice(index, 1);
+ return;
+ }
+ return clusterApi.deleteK8sCert(id);
+}
+
export async function updateClusterConfig(data: { id: string } &
Record<string, unknown>) {
if (USE_MOCK) return;
return clusterApi.updateClusterConfig(data);