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 3bcd14d45 fix(certs): localize the K8s certificate page (#4619)
3bcd14d45 is described below
commit 3bcd14d4552b1fa9f3a9142730f2c34409ed181a
Author: Apulupie <[email protected]>
AuthorDate: Mon Oct 19 19:57:05 2026 +0800
fix(certs): localize the K8s certificate page (#4619)
Verified against `rocketmq-studio` at c702d3010 before merging: the defect
is still present on the
current trunk and the change merges cleanly.
Maintainer follow-ups on top of the contributor commit:
- fix(certs): keep the localized load error without re-fetching on language
change
Each follow-up commit message records the review finding it addresses.
Locally verified: targeted suites green on the integration branch; a full
backend and frontend
build plus test pass runs on the trunk once this batch lands.
---
web/src/i18n/translations.ts | 49 +++++++++--
.../pages/cluster/__tests__/K8sCertsPage.test.tsx | 37 +++++++-
web/src/pages/cluster/certs.tsx | 99 ++++++++++++----------
3 files changed, 131 insertions(+), 54 deletions(-)
diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts
index d4ef5d1d1..bed2185af 100644
--- a/web/src/i18n/translations.ts
+++ b/web/src/i18n/translations.ts
@@ -2736,22 +2736,31 @@ const translations: Record<string, Record<Lang,
string>> = {
'settings.deepseekOfficial': { zh: 'DeepSeek 官方', en: 'DeepSeek official' },
// ─── Certs ───
+ 'cert.pageTitle': { zh: 'K8s 证书配置', en: 'K8s Certificate Config' },
'cert.clusterName': { zh: 'K8s 集群名称', en: 'K8s Cluster Name' },
- 'cert.certName': { zh: '证书名称', en: 'Certificate Name' },
+ 'cert.k8sId': { zh: 'k8s ID', en: 'k8s ID' },
+ 'cert.typeLabel': { zh: '类型', en: 'Type' },
'cert.issuer': { zh: '签发者', en: 'Issuer' },
'cert.expiryTime': { zh: '到期时间', en: 'Expiry Time' },
'cert.daysRemaining': { zh: '剩余天数', en: 'Days Remaining' },
+ 'cert.status': { zh: '状态', en: 'Status' },
+ 'cert.action': { zh: '操作', en: 'Actions' },
'cert.statusValid': { zh: '有效', en: 'Valid' },
'cert.statusExpiring': { zh: '即将过期', en: 'Expiring' },
'cert.statusExpired': { zh: '已过期', en: 'Expired' },
'cert.confirmDelete': { zh: '确认删除', en: 'Confirm Delete' },
'cert.deleteConfirm': {
- zh: '确定要删除证书"{name}"吗?',
- en: 'Are you sure to delete certificate "{name}"?',
- },
- 'cert.deleted': { zh: '证书已删除: {name}', en: 'Certificate deleted: {name}' },
- 'cert.addCert': { zh: '添加证书', en: 'Add Certificate' },
- 'cert.searchPlaceholder': { zh: '搜索证书名称或集群', en: 'Search cert name or
cluster' },
+ zh: '确定要删除证书「{name}」吗?',
+ en: 'Are you sure you want to delete certificate "{name}"?',
+ },
+ 'cert.deleted': { zh: '证书「{name}」已删除', en: 'Certificate "{name}" deleted' },
+ 'cert.certCreated': { zh: '证书「{name}」已添加', en: 'Certificate "{name}" added'
},
+ 'cert.addCert': { zh: '新增证书', en: 'Add Certificate' },
+ 'cert.add': { zh: '添加', en: 'Add' },
+ 'cert.delete': { zh: '删除', en: 'Delete' },
+ 'cert.cancel': { zh: '取消', en: 'Cancel' },
+ 'cert.all': { zh: '全部', en: 'All' },
+ 'cert.searchPlaceholder': { zh: '搜索 k8s ID 或集群', en: 'Search k8s ID or
cluster' },
'cert.editCert': { zh: '编辑证书 — {name}', en: 'Edit Certificate — {name}' },
'cert.certUpdated': { zh: '证书「{name}」已更新', en: 'Certificate "{name}"
updated' },
'cert.namespace': { zh: '命名空间', en: 'Namespace' },
@@ -2759,6 +2768,32 @@ const translations: Record<string, Record<Lang, string>>
= {
'cert.namespacePlaceholder': { zh: '例:kube-system', en: 'e.g. kube-system' },
'cert.featureWip': { zh: '添加证书功能开发中', en: 'Add certificate feature is in
development' },
'cert.totalCount': { zh: '共 {count} 个证书', en: '{count} certificates total' },
+ 'cert.localMetadataTitle': {
+ zh: '当前证书记录仅保存为 Studio 本地配置',
+ en: 'Certificate records are only kept as Studio local configuration',
+ },
+ 'cert.localMetadataDescription': {
+ zh: '这些操作不会连接 Kubernetes 集群或修改集群中的证书资源。请在集群侧管理实际证书。',
+ en: 'These operations do not connect to the Kubernetes cluster or modify
certificate resources in it. Manage the real certificates on the cluster side.',
+ },
+ 'cert.requestFailed': { zh: '请求失败,请稍后重试', en: 'Request failed, please retry
later' },
+ 'cert.k8sIdRequired': { zh: '请输入 k8s ID', en: 'Please enter the k8s ID' },
+ 'cert.k8sIdPlaceholder': { zh: '例如:kubernetes-daily', en: 'e.g.
kubernetes-daily' },
+ 'cert.clusterNameRequired': { zh: '请输入集群名称', en: 'Please enter the cluster
name' },
+ 'cert.clusterNamePlaceholder': {
+ zh: '例如:kubernetes(120.26.99.191:6443)',
+ en: 'e.g. kubernetes (120.26.99.191:6443)',
+ },
+ 'cert.certPemLabel': { zh: '证书内容(PEM)', en: 'Certificate Content (PEM)' },
+ 'cert.certPemHelp': {
+ zh: '粘贴 PEM 格式证书,签发者、有效期与 SAN 将自动解析;留空时有效期按一年占位',
+ en: 'Paste the PEM certificate; issuer, validity and SANs are parsed
automatically. Leave blank to default to a one-year validity.',
+ },
+ 'cert.keyPemLabel': { zh: '私钥内容(PEM)', en: 'Private Key (PEM)' },
+ 'cert.keyPemHelp': {
+ zh: '仅保存,不会在页面展示或返回',
+ en: 'Stored only; never displayed or returned in the UI',
+ },
// ─── Cluster ───
'cluster.brokerCount': { zh: 'Broker 数', en: 'Broker Count' },
diff --git a/web/src/pages/cluster/__tests__/K8sCertsPage.test.tsx
b/web/src/pages/cluster/__tests__/K8sCertsPage.test.tsx
index a8032ca69..d94801d47 100644
--- a/web/src/pages/cluster/__tests__/K8sCertsPage.test.tsx
+++ b/web/src/pages/cluster/__tests__/K8sCertsPage.test.tsx
@@ -22,6 +22,7 @@ import { App } from 'antd';
import type { K8sCertInfo } from '../../../api/cluster';
import { listK8sCerts, createK8sCert, deleteK8sCert } from
'../../../services/clusterService';
import K8sCertsPage from '../certs';
+import { LangProvider } from '../../../i18n/LangContext';
vi.mock('../../../services/clusterService', () => ({
listK8sCerts: vi.fn(),
@@ -29,6 +30,13 @@ vi.mock('../../../services/clusterService', () => ({
deleteK8sCert: vi.fn(),
}));
+const languagePreference = vi.hoisted(() => ({ initial: 'zh' as 'zh' | 'en'
}));
+
+vi.mock('../../../i18n/languagePreference', () => ({
+ getInitialLanguage: () => languagePreference.initial,
+ persistLanguage: vi.fn(),
+}));
+
const certs: K8sCertInfo[] = [
{
id: 1,
@@ -87,6 +95,7 @@ beforeAll(() => {
describe('K8sCertsPage', () => {
beforeEach(() => {
+ languagePreference.initial = 'zh';
vi.clearAllMocks();
vi.mocked(listK8sCerts).mockResolvedValue(certs);
});
@@ -94,7 +103,9 @@ describe('K8sCertsPage', () => {
const renderPage = () =>
render(
<App>
- <K8sCertsPage />
+ <LangProvider>
+ <K8sCertsPage />
+ </LangProvider>
</App>,
);
@@ -269,3 +280,27 @@ describe('K8sCertsPage', () => {
expect(screen.getByText('共 3 个证书')).toBeInTheDocument();
});
});
+
+describe('K8sCertsPage English localization', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ languagePreference.initial = 'en';
+ vi.mocked(listK8sCerts).mockResolvedValue(certs);
+ });
+
+ it('renders English labels inside the language provider', async () => {
+ render(
+ <LangProvider>
+ <K8sCertsPage />
+ </LangProvider>,
+ );
+
+ await screen.findByText('rocketmq-prod-tls');
+ expect(screen.getByRole('heading', { name: 'K8s Certificate Config'
})).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /Add Certificate/
})).toBeInTheDocument();
+ expect(screen.getByRole('columnheader', { name: 'K8s Cluster Name'
})).toBeInTheDocument();
+ expect(screen.getByRole('columnheader', { name: 'Expiry Time'
})).toBeInTheDocument();
+ expect(screen.getByRole('columnheader', { name: 'Days Remaining'
})).toBeInTheDocument();
+ expect(screen.getByRole('columnheader', { name: 'Actions'
})).toBeInTheDocument();
+ });
+});
diff --git a/web/src/pages/cluster/certs.tsx b/web/src/pages/cluster/certs.tsx
index 033fdcda7..14313fca1 100644
--- a/web/src/pages/cluster/certs.tsx
+++ b/web/src/pages/cluster/certs.tsx
@@ -15,7 +15,7 @@
* limitations under the License.
*/
-import { useEffect, useState } from 'react';
+import { useEffect, useRef, useState } from 'react';
import {
Table,
Tag,
@@ -45,8 +45,6 @@ import { useLang } from '../../i18n/LangContext';
const { Text } = Typography;
-const DEFAULT_REQUEST_ERROR = '请求失败,请稍后重试';
-
interface CreateCertFormValues {
k8sId: string;
cluster: string;
@@ -57,6 +55,7 @@ interface CreateCertFormValues {
const K8sCertsPage = () => {
const { t } = useLang();
+ const tRef = useRef(t);
const [certs, setCerts] = useState<K8sCertInfo[]>([]);
const [loading, setLoading] = useState(true);
const [loadFailed, setLoadFailed] = useState(false);
@@ -68,6 +67,14 @@ const K8sCertsPage = () => {
const [deletingId, setDeletingId] = useState<number | null>(null);
const [createForm] = Form.useForm<CreateCertFormValues>();
+ // The load effect below must run once per mount and per explicit reload,
while the translation
+ // function changes identity with the display language. Holding it in a ref
keeps the failure
+ // message localized without re-issuing the certificate list request on
every language switch
+ // (same pattern as pages/cluster/index.tsx).
+ useEffect(() => {
+ tRef.current = t;
+ }, [t]);
+
useEffect(() => {
let active = true;
listK8sCerts()
@@ -81,7 +88,7 @@ const K8sCertsPage = () => {
// Keeping the previous rows and marking the failure: clearing the
list made a failed read
// look like a cluster without certificates, and nothing offered a
retry.
if (active) {
- message.error(describeThrownMessage(error) || DEFAULT_REQUEST_ERROR);
+ message.error(describeThrownMessage(error) ||
tRef.current('cert.requestFailed'));
setLoadFailed(true);
}
})
@@ -121,11 +128,11 @@ const K8sCertsPage = () => {
keyPem: values.keyPem?.trim() || undefined,
});
setCerts((previous) => [...previous, created]);
- message.success(`证书「${created.k8sId}」已添加`);
+ message.success(t('cert.certCreated', { name: created.k8sId }));
setCreateModalOpen(false);
createForm.resetFields();
} catch (error: unknown) {
- message.error(describeThrownMessage(error) || DEFAULT_REQUEST_ERROR);
+ message.error(describeThrownMessage(error) || t('cert.requestFailed'));
} finally {
setCreating(false);
}
@@ -136,9 +143,9 @@ const K8sCertsPage = () => {
try {
await deleteK8sCert(cert.id);
setCerts((previous) => previous.filter((item) => item.id !== cert.id));
- message.success(`证书「${cert.k8sId}」已删除`);
+ message.success(t('cert.deleted', { name: cert.k8sId }));
} catch (error: unknown) {
- message.error(describeThrownMessage(error) || DEFAULT_REQUEST_ERROR);
+ message.error(describeThrownMessage(error) || t('cert.requestFailed'));
} finally {
setDeletingId(null);
}
@@ -146,7 +153,7 @@ const K8sCertsPage = () => {
const certColumns: ColumnsType<K8sCertInfo> = [
{
- title: 'K8s 集群名称',
+ title: t('cert.clusterName'),
dataIndex: 'cluster',
key: 'cluster',
width: 260,
@@ -155,7 +162,7 @@ const K8sCertsPage = () => {
render: (name: string) => <Text strong>{name}</Text>,
},
{
- title: 'k8s ID',
+ title: t('cert.k8sId'),
dataIndex: 'k8sId',
key: 'k8sId',
width: 240,
@@ -165,7 +172,7 @@ const K8sCertsPage = () => {
),
},
{
- title: '类型',
+ title: t('cert.typeLabel'),
dataIndex: 'type',
key: 'type',
width: 110,
@@ -180,7 +187,7 @@ const K8sCertsPage = () => {
},
},
{
- title: '签发者',
+ title: t('cert.issuer'),
dataIndex: 'issuer',
key: 'issuer',
// 唯一可伸展列:余量集中在此,其余列保持声明宽度。选签发者而非集群名,
@@ -191,7 +198,7 @@ const K8sCertsPage = () => {
ellipsis: true,
},
{
- title: '到期时间',
+ title: t('cert.expiryTime'),
dataIndex: 'notAfter',
key: 'notAfter',
width: 156,
@@ -203,7 +210,7 @@ const K8sCertsPage = () => {
),
},
{
- title: '剩余天数',
+ title: t('cert.daysRemaining'),
dataIndex: 'daysRemaining',
key: 'daysRemaining',
width: 100,
@@ -220,32 +227,32 @@ const K8sCertsPage = () => {
),
},
{
- title: '状态',
+ title: t('cert.status'),
dataIndex: 'status',
key: 'status',
width: 100,
sorter: (a, b) => (a.status ?? '').localeCompare(b.status ?? ''),
render: (status: string | null) => {
const map: Record<string, { color: string; label: string }> = {
- valid: { color: 'green', label: '有效' },
- expiring: { color: 'orange', label: '即将过期' },
- expired: { color: 'red', label: '已过期' },
+ valid: { color: 'green', label: t('cert.statusValid') },
+ expiring: { color: 'orange', label: t('cert.statusExpiring') },
+ expired: { color: 'red', label: t('cert.statusExpired') },
};
const cfg = status ? (map[status] ?? { color: 'default', label: status
}) : null;
return cfg ? <Tag color={cfg.color}>{cfg.label}</Tag> : '-';
},
},
{
- title: '操作',
+ title: t('cert.action'),
key: 'action',
width: 90,
fixed: 'right',
render: (_: unknown, cert: K8sCertInfo) => (
<Popconfirm
- title={`确定要删除证书「${cert.k8sId}」吗?`}
+ title={t('cert.deleteConfirm', { name: cert.k8sId })}
onConfirm={() => void handleDelete(cert)}
- okText="删除"
- cancelText="取消"
+ okText={t('cert.delete')}
+ cancelText={t('cert.cancel')}
okButtonProps={{ danger: true }}
>
<Button
@@ -254,7 +261,7 @@ const K8sCertsPage = () => {
style={{ borderColor: '#ff4d4f', color: '#ff4d4f' }}
loading={deletingId === cert.id}
>
- 删除
+ {t('cert.delete')}
</Button>
</Popconfirm>
),
@@ -264,8 +271,12 @@ const K8sCertsPage = () => {
return (
<div style={{ padding: 24 }}>
<PageHeader
- title="K8s 证书配置"
- subtitle={loadFailed ? t('common.unavailable') : `共
${filteredCerts.length} 个证书`}
+ title={t('cert.pageTitle')}
+ subtitle={
+ loadFailed
+ ? t('common.unavailable')
+ : t('cert.totalCount', { count: filteredCerts.length })
+ }
/>
{loadFailed && (
<Alert
@@ -286,13 +297,13 @@ const K8sCertsPage = () => {
)}
<InfoBanner
data-testid="k8s-cert-local-metadata-notice"
- title="当前证书记录仅保存为 Studio 本地配置"
- description="这些操作不会连接 Kubernetes 集群或修改集群中的证书资源。请在集群侧管理实际证书。"
+ title={t('cert.localMetadataTitle')}
+ description={t('cert.localMetadataDescription')}
/>
<Flex justify="space-between" style={{ marginBottom: 16 }}>
<Space>
<Input.Search
- placeholder="搜索 k8s ID 或集群"
+ placeholder={t('cert.searchPlaceholder')}
allowClear
onSearch={setCertSearch}
onChange={(e) => !e.target.value && setCertSearch('')}
@@ -303,7 +314,7 @@ const K8sCertsPage = () => {
onChange={setCertTypeFilter}
style={{ width: 160 }}
options={[
- { value: '', label: '全部' },
+ { value: '', label: t('cert.all') },
{ value: 'TLS', label: 'TLS' },
{ value: 'mTLS', label: 'mTLS' },
{ value: 'ServiceAccount', label: 'ServiceAccount' },
@@ -311,7 +322,7 @@ const K8sCertsPage = () => {
/>
</Space>
<Button type="primary" icon={<PlusOutlined />} onClick={() =>
setCreateModalOpen(true)}>
- 新增证书
+ {t('cert.addCert')}
</Button>
</Flex>
<Card styles={{ body: { padding: 0 } }}>
@@ -328,7 +339,7 @@ const K8sCertsPage = () => {
</Card>
<Modal
- title="新增证书"
+ title={t('cert.addCert')}
open={createModalOpen}
onCancel={() => {
setCreateModalOpen(false);
@@ -336,27 +347,27 @@ const K8sCertsPage = () => {
}}
onOk={() => void handleCreate()}
confirmLoading={creating}
- okText="添加"
- cancelText="取消"
+ okText={t('cert.add')}
+ cancelText={t('cert.cancel')}
width={640}
destroyOnHidden
>
<Form form={createForm} layout="vertical" preserve={false}>
<Form.Item
- label="k8s ID"
+ label={t('cert.k8sId')}
name="k8sId"
- rules={[{ required: true, message: '请输入 k8s ID' }]}
+ rules={[{ required: true, message: t('cert.k8sIdRequired') }]}
>
- <Input placeholder="例如:kubernetes-daily" />
+ <Input placeholder={t('cert.k8sIdPlaceholder')} />
</Form.Item>
<Form.Item
- label="K8s 集群名称"
+ label={t('cert.clusterName')}
name="cluster"
- rules={[{ required: true, message: '请输入集群名称' }]}
+ rules={[{ required: true, message: t('cert.clusterNameRequired')
}]}
>
- <Input placeholder="例如:kubernetes(120.26.99.191:6443)" />
+ <Input placeholder={t('cert.clusterNamePlaceholder')} />
</Form.Item>
- <Form.Item label="类型" name="type" initialValue="TLS">
+ <Form.Item label={t('cert.typeLabel')} name="type"
initialValue="TLS">
<Select
virtual={false}
options={[
@@ -366,18 +377,14 @@ const K8sCertsPage = () => {
]}
/>
</Form.Item>
- <Form.Item
- label="证书内容(PEM)"
- name="certPem"
- extra="粘贴 PEM 格式证书,签发者、有效期与 SAN 将自动解析;留空时有效期按一年占位"
- >
+ <Form.Item label={t('cert.certPemLabel')} name="certPem"
extra={t('cert.certPemHelp')}>
<Input.TextArea
rows={6}
placeholder="-----BEGIN CERTIFICATE-----..."
style={{ fontFamily: 'monospace' }}
/>
</Form.Item>
- <Form.Item label="私钥内容(PEM)" name="keyPem" extra="仅保存,不会在页面展示或返回">
+ <Form.Item label={t('cert.keyPemLabel')} name="keyPem"
extra={t('cert.keyPemHelp')}>
<Input.TextArea
rows={6}
placeholder="-----BEGIN PRIVATE KEY-----..."