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);

Reply via email to