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 76cea71e4 fix(web): localize dashboard copy and guard the template
save (#5873)
76cea71e4 is described below
commit 76cea71e4003701ed5e9e802cfb48d22ff91ce0a
Author: btlqql <[email protected]>
AuthorDate: Mon Oct 12 16:12:48 2026 +0800
fix(web): localize dashboard copy and guard the template save (#5873)
Three fixes from the same author for user-visible copy and messaging on the
home and AI
surfaces — two locale gaps and one button that invites a spurious error
toast — folded into
a single commit.
- #5873 `web/src/pages/home/dashboard.tsx:168`, `:169` and `:172` hard-code
the load-failure
Alert's message, description and retry button in Chinese, the only
untranslated copy left on a
page where everything else goes through `t()`, so an en-locale user whose
`getDashboard` call
fails (`:161-177`) reads a Chinese error block; switches the three
strings to
`t('dashboard.loadFailed')`, `t('dashboard.loadFailedDescription')` and
the already-existing
`t('common.retry')` (`web/src/i18n/translations.ts:77`), adding the two
new keys to the
Dashboard block with zh and en values side by side.
- #5874 `web/src/pages/home/DashboardTrafficInsights.tsx:159` interpolates
a full-width colon and
joins the findings with `、` regardless of locale, rendering
`Traffic signals to review:prod carries 88.6%…、canary …` under en — the
only hard-coded CJK
punctuation in the file; reads `lang` from the existing `useLang()` call
(`:67`) and derives a
list separator (`、` for zh, `; ` otherwise) and a label separator (`:`
for zh, `: ` otherwise),
the convention trunk already follows in `pages/instance/index.tsx:408` and
`components/MiniBar.tsx:50`, so no new i18n key is needed —
`dashboardTraffic.findings` already
exists in both locales.
- #5875 `web/src/pages/ai/components/PromptTemplateModal.tsx:370` gates the
"save current input"
button on `!inputValue.trim()` alone, but a successful save clears the
title (`:162`) while the
composer draft stays put, so a second click still reaches
`handleSaveCurrentPromptTemplate`
(`:143`), `saveCustomPromptTemplate` returns `reason: 'empty_title'`, and
`ai.promptTemplates.titleRequired` (`:153-154`) pops an error toast right
next to the success
one; adds `|| !customTemplateTitle.trim()` to the same `disabled`
expression, leaving the
`empty_title` branch in place as defence in depth.
Three new cases, one per member, all mutation-sensitive: reverting the
dashboard copy leaves
`findByText('Failed to load dashboard')` and the `Retry` button unmatched
and reverting either new
translation key makes `t()` fall back to the key itself, reverting the
separator derivation fails
both `not.toContain('、')` and `not.toContain(':')` against an en render
whose fixture produces
at least two findings, and reverting the save guard makes the second click
surface
`Enter a template title` while the accompanying
`loadPromptTemplateCatalog()` check still pins
that only one custom template was written.
Folded: #5874, #5875 were closed as folded into this commit (same author).
---
web/src/i18n/translations.ts | 5 ++
.../ai/__tests__/PromptTemplateModal.test.tsx | 87 ++++++++++++++++++++++
.../pages/ai/components/PromptTemplateModal.tsx | 2 +-
web/src/pages/home/DashboardTrafficInsights.tsx | 8 +-
.../pages/home/__tests__/DashboardPage.test.tsx | 21 +++++-
.../__tests__/DashboardTrafficInsights.test.tsx | 24 +++++-
web/src/pages/home/dashboard.tsx | 6 +-
7 files changed, 145 insertions(+), 8 deletions(-)
diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts
index b9a607189..7e33dc2bf 100644
--- a/web/src/i18n/translations.ts
+++ b/web/src/i18n/translations.ts
@@ -105,6 +105,11 @@ const translations: Record<string, Record<Lang, string>> =
{
'dashboard.clusterHealth': { zh: '集群健康概览', en: 'Cluster Health' },
'dashboard.clusterName': { zh: '集群名称', en: 'Cluster Name' },
'dashboard.allInstances': { zh: '全部已配置实例', en: 'All configured instances' },
+ 'dashboard.loadFailed': { zh: '仪表盘加载失败', en: 'Failed to load dashboard' },
+ 'dashboard.loadFailedDescription': {
+ zh: '无法获取集群概览,请检查网络连接后重试。',
+ en: 'Could not load the cluster overview. Check the connection and retry.',
+ },
'dashboard.instanceFilter': { zh: '实例筛选', en: 'Instance filter' },
'dashboard.broker': { zh: 'Broker', en: 'Broker' },
'dashboard.proxy': { zh: 'Proxy', en: 'Proxy' },
diff --git a/web/src/pages/ai/__tests__/PromptTemplateModal.test.tsx
b/web/src/pages/ai/__tests__/PromptTemplateModal.test.tsx
new file mode 100644
index 000000000..f21e2fc72
--- /dev/null
+++ b/web/src/pages/ai/__tests__/PromptTemplateModal.test.tsx
@@ -0,0 +1,87 @@
+/*
+ * 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/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 { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { App } from 'antd';
+import { LangProvider } from '../../../i18n/LangContext';
+import PromptTemplateModal from '../components/PromptTemplateModal';
+import { loadPromptTemplateCatalog } from '../promptTemplates';
+
+const before = () => {
+ localStorage.setItem('rocketmq-studio-language', 'en');
+ render(
+ <App>
+ <LangProvider>
+ <PromptTemplateModal
+ open
+ inputValue="diagnose consumer lag on orders topic"
+ mode="diagnose"
+ enhance={false}
+ onClose={vi.fn()}
+ onApply={vi.fn()}
+ />
+ </LangProvider>
+ </App>,
+ );
+};
+
+describe('PromptTemplateModal', () => {
+ beforeAll(() => {
+ Object.defineProperty(window, 'matchMedia', {
+ writable: true,
+ value: vi.fn().mockImplementation((query: string) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ })),
+ });
+ });
+
+ beforeEach(() => {
+ localStorage.clear();
+ });
+
+ it('a second click on Save after a successful save neither saves nor
errors', async () => {
+ const user = userEvent.setup();
+ before();
+
+ await user.type(screen.getByLabelText('Template title'), 'lag runbook');
+ await user.click(screen.getByRole('button', { name: 'Save' }));
+
+ const saved = loadPromptTemplateCatalog().templates.filter(
+ (template) => template.scope === 'custom',
+ );
+ expect(saved).toHaveLength(1);
+ expect(saved[0]?.title).toBe('lag runbook');
+
+ // Double-click / repeated Enter lands on the Save button again right
after the
+ // first save cleared the title: that must be a no-op, not a "title
required"
+ // error toast next to a success toast.
+ await user.click(screen.getByRole('button', { name: 'Save' }));
+
+ expect(screen.queryByText('Enter a template
title')).not.toBeInTheDocument();
+ expect(
+ loadPromptTemplateCatalog().templates.filter((template) =>
template.scope === 'custom'),
+ ).toHaveLength(1);
+ });
+});
diff --git a/web/src/pages/ai/components/PromptTemplateModal.tsx
b/web/src/pages/ai/components/PromptTemplateModal.tsx
index ae28c17e8..fe236cec0 100644
--- a/web/src/pages/ai/components/PromptTemplateModal.tsx
+++ b/web/src/pages/ai/components/PromptTemplateModal.tsx
@@ -367,7 +367,7 @@ const PromptTemplateModal = ({
/>
<Button
icon={<Plus size={16} />}
- disabled={!inputValue.trim()}
+ disabled={!inputValue.trim() || !customTemplateTitle.trim()}
onClick={handleSaveCurrentPromptTemplate}
>
{t('common.save')}
diff --git a/web/src/pages/home/DashboardTrafficInsights.tsx
b/web/src/pages/home/DashboardTrafficInsights.tsx
index b2d36ba20..7307a358e 100644
--- a/web/src/pages/home/DashboardTrafficInsights.tsx
+++ b/web/src/pages/home/DashboardTrafficInsights.tsx
@@ -64,8 +64,12 @@ const issueTextKey = (issue: DashboardTrafficIssue) => {
};
const DashboardTrafficInsights = ({ insights }: Props) => {
- const { t } = useLang();
+ const { t, lang } = useLang();
const visibleIssues = insights.issues.slice(0, 4);
+ // Locale-aware separators: the findings line is prose, and CJK punctuation
in an
+ // English sentence reads as noise. Same convention as instance batch-delete
toasts.
+ const listSeparator = lang === 'zh' ? '、' : '; ';
+ const labelSeparator = lang === 'zh' ? ':' : ': ';
const issueText = (issue: DashboardTrafficIssue) =>
t(issueTextKey(issue), {
@@ -156,7 +160,7 @@ const DashboardTrafficInsights = ({ insights }: Props) => {
? 'warning'
: 'info'
}
-
message={`${t('dashboardTraffic.findings')}:${visibleIssues.map(issueText).join('、')}`}
+
message={`${t('dashboardTraffic.findings')}${labelSeparator}${visibleIssues.map(issueText).join(listSeparator)}`}
style={{ marginBottom: 16 }}
/>
)}
diff --git a/web/src/pages/home/__tests__/DashboardPage.test.tsx
b/web/src/pages/home/__tests__/DashboardPage.test.tsx
index 1533a4f5b..2b533e233 100644
--- a/web/src/pages/home/__tests__/DashboardPage.test.tsx
+++ b/web/src/pages/home/__tests__/DashboardPage.test.tsx
@@ -10,9 +10,10 @@ import { render, screen, waitFor, within } from
'@testing-library/react';
import userEvent from '@testing-library/user-event';
import type React from 'react';
import { MemoryRouter, useLocation } from 'react-router-dom';
-import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
+import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from
'vitest';
import type { DashboardData } from '../../../api/metrics';
import { LangProvider } from '../../../i18n/LangContext';
+import { LANGUAGE_STORAGE_KEY } from '../../../i18n/languagePreference';
import * as dashboardService from '../../../services/dashboardService';
import * as instanceService from '../../../services/instanceService';
import DashboardPage from '../dashboard';
@@ -160,6 +161,12 @@ beforeEach(() => {
]);
});
+afterEach(() => {
+ // The English-locale case below writes the language preference; without
this the rest of the file
+ // (and anything appended later) silently depends on test order.
+ localStorage.removeItem(LANGUAGE_STORAGE_KEY);
+});
+
describe('DashboardPage', () => {
it('renders unavailable Proxy topology counts as N/A instead of zero', async
() => {
vi.mocked(dashboardService.getDashboard).mockResolvedValue(unavailableTopologyDashboard());
@@ -323,4 +330,16 @@ describe('DashboardPage', () => {
screen.queryByText('cloud-instance', { selector:
'.ant-select-item-option-content' }),
).not.toBeInTheDocument();
});
+
+ it('localizes the load-error alert for English users', async () => {
+ localStorage.setItem(LANGUAGE_STORAGE_KEY, 'en');
+ vi.mocked(dashboardService.getDashboard).mockRejectedValue(new
Error('boom'));
+ vi.mocked(instanceService.listInstances).mockResolvedValue([]);
+
+ renderWithProviders(<DashboardPage />);
+
+ expect(await screen.findByText('Failed to load
dashboard')).toBeInTheDocument();
+ expect(screen.queryByText('仪表盘加载失败')).not.toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument();
+ });
});
diff --git a/web/src/pages/home/__tests__/DashboardTrafficInsights.test.tsx
b/web/src/pages/home/__tests__/DashboardTrafficInsights.test.tsx
index d5d0198a8..2c2fc1e8f 100644
--- a/web/src/pages/home/__tests__/DashboardTrafficInsights.test.tsx
+++ b/web/src/pages/home/__tests__/DashboardTrafficInsights.test.tsx
@@ -17,9 +17,10 @@
import { App } from 'antd';
import { render, screen } from '@testing-library/react';
-import { beforeAll, describe, expect, it, vi } from 'vitest';
+import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import type { DashboardData } from '../../../api/metrics';
import { LangProvider } from '../../../i18n/LangContext';
+import { LANGUAGE_STORAGE_KEY } from '../../../i18n/languagePreference';
import { buildDashboardTrafficInsights } from
'../../../utils/dashboardTrafficInsights';
import DashboardTrafficInsights from '../DashboardTrafficInsights';
@@ -108,6 +109,10 @@ beforeAll(() => {
});
});
+afterEach(() => {
+ localStorage.removeItem(LANGUAGE_STORAGE_KEY);
+});
+
describe('DashboardTrafficInsights', () => {
it('renders traffic findings and summary cards without duplicating cluster
rows', () => {
renderPanel();
@@ -153,4 +158,21 @@ describe('DashboardTrafficInsights', () => {
// Findings render as one joined line, so match on a substring rather than
the exact node text.
expect(screen.getByText(/未检测到活跃流量/u)).toBeInTheDocument();
});
+
+ /**
+ * Both branches of the separator switch, not just the one being added:
asserting English alone
+ * leaves `lang === 'zh' ? '、' : '; '` free to collapse to a constant
without anything going red.
+ */
+ it.each([
+ { lang: 'zh', heading: '需要关注的流量信号', label: ':', list: '、', other: '; ' },
+ { lang: 'en', heading: 'Traffic signals to review', label: ': ', list: ';
', other: '、' },
+ ])('formats findings with the $lang separators', ({ lang, heading, label,
list, other }) => {
+ localStorage.setItem(LANGUAGE_STORAGE_KEY, lang);
+ renderPanel();
+
+ const text = screen.getByText(new RegExp(heading, 'u')).textContent ?? '';
+ expect(text).toContain(`${heading}${label}`);
+ expect(text).toContain(list);
+ expect(text).not.toContain(other);
+ });
});
diff --git a/web/src/pages/home/dashboard.tsx b/web/src/pages/home/dashboard.tsx
index 396909dde..87561e9ae 100644
--- a/web/src/pages/home/dashboard.tsx
+++ b/web/src/pages/home/dashboard.tsx
@@ -165,11 +165,11 @@ const DashboardPage = () => {
<Alert
type="error"
showIcon
- message="仪表盘加载失败"
- description="无法获取集群概览,请检查网络连接后重试。"
+ message={t('dashboard.loadFailed')}
+ description={t('dashboard.loadFailedDescription')}
action={
<Button size="small" onClick={() => void loadDashboard()}
loading={loading}>
- 重试
+ {t('common.retry')}
</Button>
}
/>