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 0ac39f28492d38ea8c10ccbfca58d2ff94f65bf4 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 00000000..49a35e52 --- /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 c7d35ef0..007498b2 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 84493579..e5155467 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 0e6615a8..a3270e2f 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);
