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 aa2c567c8 fix(web): consolidate frontend request lifecycle and storage
resilience fixes (#2165)
aa2c567c8 is described below
commit aa2c567c89f1d2ef9f4f0f3f3f1dfabcd2b38742
Author: yyqdbngt <[email protected]>
AuthorDate: Mon Aug 31 19:43:55 2026 +0800
fix(web): consolidate frontend request lifecycle and storage resilience
fixes (#2165)
* fix(web): tolerate unavailable browser storage
Centralize guarded local-storage operations so blocked storage does not
prevent API requests or page rendering.
Reuse the helper across persisted UI state and cover request and Proxy
fallbacks with regression tests.
* fix(web): ignore stale consumer settings responses
* fix(web): refresh deliveries with current filters
* fix(studio): serialize user status mutations
* fix(web): scope instance requests by data mode
---
web/src/i18n/languagePreference.ts | 15 +---
.../pages/instance/__tests__/ConsumerPage.test.tsx | 96 ++++++++++++++++++++++
web/src/pages/instance/consumer.tsx | 16 +++-
.../__tests__/NotificationDeliveriesPage.test.tsx | 74 +++++++++++++++++
web/src/pages/ops/notificationDeliveries.tsx | 11 +--
web/src/pages/studio/Proxy.tsx | 11 +--
web/src/pages/studio/UserManagement.tsx | 9 ++
web/src/pages/studio/__tests__/Proxy.test.tsx | 13 +++
.../pages/studio/__tests__/UserManagement.test.tsx | 23 +++++-
web/src/services/instanceService.test.ts | 21 +++++
web/src/services/instanceService.ts | 9 +-
web/src/stores/authStorage.ts | 46 ++++-------
web/src/theme/themePreference.ts | 28 ++-----
web/src/utils/browserStorage.test.ts | 44 ++++++++++
.../browserStorage.ts} | 25 +++---
15 files changed, 348 insertions(+), 93 deletions(-)
diff --git a/web/src/i18n/languagePreference.ts
b/web/src/i18n/languagePreference.ts
index 7354eb664..fa2a3656a 100644
--- a/web/src/i18n/languagePreference.ts
+++ b/web/src/i18n/languagePreference.ts
@@ -16,22 +16,15 @@
*/
import type { Lang } from './translations';
+import { readLocalStorage, writeLocalStorage } from '../utils/browserStorage';
export const LANGUAGE_STORAGE_KEY = 'rocketmq-studio-language';
export function getInitialLanguage(): Lang {
- try {
- const stored = localStorage.getItem(LANGUAGE_STORAGE_KEY);
- return stored === 'en' || stored === 'zh' ? stored : 'zh';
- } catch {
- return 'zh';
- }
+ const stored = readLocalStorage(LANGUAGE_STORAGE_KEY);
+ return stored === 'en' || stored === 'zh' ? stored : 'zh';
}
export function persistLanguage(lang: Lang): void {
- try {
- localStorage.setItem(LANGUAGE_STORAGE_KEY, lang);
- } catch {
- // Language selection still works when browser storage is unavailable.
- }
+ writeLocalStorage(LANGUAGE_STORAGE_KEY, lang);
}
diff --git a/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
b/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
index 9d760867e..ab1966df9 100644
--- a/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
+++ b/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
@@ -99,6 +99,14 @@ const groupPage = (
...overrides,
});
+const deferred = <T,>() => {
+ let resolve!: (value: T) => void;
+ const promise = new Promise<T>((complete) => {
+ resolve = complete;
+ });
+ return { promise, resolve };
+};
+
const renderWithProviders = (ui: React.ReactElement, initialEntry =
'/instance/consumer') =>
render(
<App>
@@ -931,4 +939,92 @@ describe('Consumer page', () => {
const dialog = await screen.findByRole('dialog', { name: /unknown-lag-cg/
});
expect(within(dialog).getByText('不可用')).toBeInTheDocument();
});
+
+ it('sorts groups with an unknown lag after known backlogs in lag order',
async () => {
+ const user = userEvent.setup();
+ vi.mocked(consumerService.listConsumerGroupPage).mockResolvedValue(
+ groupPage([
+ { ...group, name: 'unknown-lag-cg', totalLag: -1 },
+ { ...group, name: 'known-lag-cg', totalLag: 15000 },
+ ]),
+ );
+ renderWithProviders(<ConsumerPage />);
+ await screen.findByRole('row', { name: /unknown-lag-cg/ });
+
+ const [lagHeader] = screen.getAllByText('总堆积量');
+ await user.click(lagHeader);
+ await waitFor(() => {
+ const rows = Array.from(document.querySelectorAll('tbody tr'));
+ const order = rows
+ .map((row) => row.textContent ?? '')
+ .map((text) =>
+ text.includes('unknown-lag-cg')
+ ? 'unknown'
+ : /\bknown-lag-cg\b/.test(text)
+ ? 'known'
+ : '',
+ )
+ .filter(Boolean);
+ expect(order).toEqual(['known', 'unknown']);
+ });
+ });
+
+ it('ignores settings responses from a previously closed group modal', async
() => {
+ const otherGroup = { ...group, name: 'other-cg' };
+ const firstSettings = deferred<{
+ groupName: string;
+ retryQueueNums: number;
+ retryMaxTimes: number;
+ }>();
+ const secondSettings = deferred<{
+ groupName: string;
+ retryQueueNums: number;
+ retryMaxTimes: number;
+ }>();
+ vi.mocked(consumerService.listConsumerGroupPage).mockResolvedValue(
+ groupPage([group, otherGroup]),
+ );
+ vi.mocked(consumerService.getConsumerGroupSettings)
+ .mockImplementationOnce(() => firstSettings.promise)
+ .mockImplementationOnce(() => secondSettings.promise);
+ const user = userEvent.setup();
+ renderWithProviders(<ConsumerPage />);
+
+ const firstRow = await screen.findByRole('row', { name: /remote-cg/ });
+ await user.click(within(firstRow).getByRole('button', { name: '详情' }));
+ const firstDialog = await screen.findByRole('dialog', { name: /remote-cg/
});
+ await user.click(within(firstDialog).getByRole('tab', { name: '配置' }));
+ await waitFor(() => {
+ expect(consumerService.getConsumerGroupSettings).toHaveBeenCalledWith(
+ 'remote-cg',
+ 'instance-1',
+ );
+ });
+
+ fireEvent.click(firstDialog.querySelector('.ant-modal-close') as
HTMLElement);
+
+ const secondRow = screen.getByRole('row', { name: /other-cg/ });
+ await user.click(within(secondRow).getByRole('button', { name: '详情' }));
+ const secondDialog = await screen.findByRole('dialog', { name: /other-cg/
});
+ await user.click(within(secondDialog).getByRole('tab', { name: '配置' }));
+ await waitFor(() => {
+ expect(consumerService.getConsumerGroupSettings).toHaveBeenCalledWith(
+ 'other-cg',
+ 'instance-1',
+ );
+ });
+
+ await act(async () => {
+ secondSettings.resolve({ groupName: 'other-cg', retryQueueNums: 4,
retryMaxTimes: 12 });
+ });
+ await waitFor(() => {
+ expect(within(secondDialog).getByLabelText('重试队列数')).toHaveValue('4');
+ });
+
+ await act(async () => {
+ firstSettings.resolve({ groupName: 'remote-cg', retryQueueNums: 1,
retryMaxTimes: 16 });
+ });
+ expect(within(secondDialog).getByLabelText('重试队列数')).toHaveValue('4');
+ expect(within(secondDialog).getByLabelText('最大重试次数')).toHaveValue('12');
+ });
});
diff --git a/web/src/pages/instance/consumer.tsx
b/web/src/pages/instance/consumer.tsx
index 69e5264ff..8cc71b3c7 100644
--- a/web/src/pages/instance/consumer.tsx
+++ b/web/src/pages/instance/consumer.tsx
@@ -254,6 +254,7 @@ const ConsumerPageContent = ({
const groupRequestIdRef = useRef(0);
const stackRequestIdRef = useRef(0);
+ const settingsRequestIdRef = useRef(0);
const [autoRefresh, setAutoRefresh] = useState(false);
const silentRefreshRef = useRef(false);
@@ -414,15 +415,22 @@ const ConsumerPageContent = ({
const loadGroupSettings = async (group: ConsumerGroup) => {
if (!selectedInstanceId) return;
+ const requestId = ++settingsRequestIdRef.current;
setSettingsGroup(group);
setSettingsLoading(true);
try {
const settings = await getConsumerGroupSettings(group.name,
selectedInstanceId);
- settingsForm.setFieldsValue(settings);
+ if (requestId === settingsRequestIdRef.current) {
+ settingsForm.setFieldsValue(settings);
+ }
} catch {
- message.error('加载消费组配置失败,请稍后重试');
+ if (requestId === settingsRequestIdRef.current) {
+ message.error('加载消费组配置失败,请稍后重试');
+ }
} finally {
- setSettingsLoading(false);
+ if (requestId === settingsRequestIdRef.current) {
+ setSettingsLoading(false);
+ }
}
};
@@ -1235,10 +1243,12 @@ const ConsumerPageContent = ({
}
open={modalOpen}
onCancel={() => {
+ settingsRequestIdRef.current += 1;
setModalOpen(false);
setSelectedGroup(null);
setShowOnlyInconsistent(false);
setSettingsGroup(null);
+ setSettingsLoading(false);
settingsForm.resetFields();
}}
width={detailTab === 'progress' ? 1080 : 800}
diff --git a/web/src/pages/ops/__tests__/NotificationDeliveriesPage.test.tsx
b/web/src/pages/ops/__tests__/NotificationDeliveriesPage.test.tsx
index f59e1a6a3..3ec320a68 100644
--- a/web/src/pages/ops/__tests__/NotificationDeliveriesPage.test.tsx
+++ b/web/src/pages/ops/__tests__/NotificationDeliveriesPage.test.tsx
@@ -18,9 +18,18 @@ vi.mock('../../../services/instanceService', () => ({
}));
vi.mock('../../../services/opsService', () => ({
listAlertDeliveriesPage: vi.fn(),
+ retryAlertDeliveries: vi.fn(),
retryAlertDelivery: vi.fn(),
}));
+const deferred = <T,>() => {
+ let resolve!: (value: T) => void;
+ const promise = new Promise<T>((complete) => {
+ resolve = complete;
+ });
+ return { promise, resolve };
+};
+
beforeAll(() => {
Object.defineProperty(window, 'matchMedia', {
writable: true,
@@ -73,4 +82,69 @@ describe('NotificationDeliveriesPage', () => {
await waitFor(() => expect(retryAlertDelivery).toHaveBeenCalledWith(7));
await waitFor(() =>
expect(listAlertDeliveriesPage).toHaveBeenCalledTimes(2));
});
+
+ it('refreshes a completed retry with the latest filters', async () => {
+ const retry = deferred<void>();
+ vi.mocked(retryAlertDelivery).mockImplementation(() => retry.promise);
+ vi.mocked(listAlertDeliveriesPage).mockImplementation(async (query) =>
+ query?.status === 'DELIVERED'
+ ? {
+ items: [
+ {
+ id: 8,
+ alertId: 4,
+ alertTitle: 'Delivered notification',
+ channel: 'email',
+ status: 'DELIVERED',
+ attemptCount: 1,
+ createdAt: '2026-08-23T10:00:00',
+ deliveredAt: '2026-08-23T10:01:00',
+ },
+ ],
+ total: 1,
+ page: 1,
+ size: 20,
+ }
+ : {
+ items: [
+ {
+ id: 7,
+ alertId: 3,
+ alertTitle: 'Broker disk usage',
+ channel: 'dingtalk',
+ status: 'FAILED',
+ attemptCount: 5,
+ createdAt: '2026-08-23T10:00:00',
+ lastError: 'Webhook rejected the request',
+ },
+ ],
+ total: 1,
+ page: 1,
+ size: 20,
+ },
+ );
+ const user = userEvent.setup();
+ render(
+ <App>
+ <LangProvider>
+ <NotificationDeliveriesPage />
+ </LangProvider>
+ </App>,
+ );
+
+ await screen.findByText('Broker disk usage');
+ await user.click(screen.getByRole('button', { name: '重新投递' }));
+ await user.click(screen.getAllByRole('combobox')[1]);
+ await user.click(await screen.findByText('DELIVERED'));
+ expect(await screen.findByText('Delivered
notification')).toBeInTheDocument();
+
+ retry.resolve();
+
+ await waitFor(() =>
expect(listAlertDeliveriesPage).toHaveBeenCalledTimes(3));
+ expect(screen.getByText('Delivered notification')).toBeInTheDocument();
+ expect(screen.queryByText('Broker disk usage')).not.toBeInTheDocument();
+ expect(listAlertDeliveriesPage).toHaveBeenLastCalledWith(
+ expect.objectContaining({ status: 'DELIVERED' }),
+ );
+ });
});
diff --git a/web/src/pages/ops/notificationDeliveries.tsx
b/web/src/pages/ops/notificationDeliveries.tsx
index 759e2d885..2cd308206 100644
--- a/web/src/pages/ops/notificationDeliveries.tsx
+++ b/web/src/pages/ops/notificationDeliveries.tsx
@@ -55,16 +55,11 @@ const NotificationDeliveriesPage = () => {
const [selectedDelivery, setSelectedDelivery] =
useState<NotificationDeliveryRecord>();
const [retryingIds, setRetryingIds] = useState<Set<number>>(() => new Set());
const [retryingVisible, setRetryingVisible] = useState(false);
+ const [refreshNonce, setRefreshNonce] = useState(0);
const refresh = () => {
setLoading(true);
- void listAlertDeliveriesPage({ channel, status, instanceId, page, pageSize
})
- .then((result) => {
- setItems(result.items);
- setTotal(result.total);
- })
- .catch(() => message.error(t('deliveries.loadFailed')))
- .finally(() => setLoading(false));
+ setRefreshNonce((current) => current + 1);
};
const retryDelivery = async (record: NotificationDeliveryRecord) => {
@@ -133,7 +128,7 @@ const NotificationDeliveriesPage = () => {
return () => {
cancelled = true;
};
- }, [channel, status, instanceId, page, pageSize, t]);
+ }, [channel, status, instanceId, page, pageSize, refreshNonce, t]);
const resetPage = (change: () => void) => {
setLoading(true);
diff --git a/web/src/pages/studio/Proxy.tsx b/web/src/pages/studio/Proxy.tsx
index db8fb05ad..5014517d8 100644
--- a/web/src/pages/studio/Proxy.tsx
+++ b/web/src/pages/studio/Proxy.tsx
@@ -58,16 +58,13 @@ import {
type ProxyHomePageData,
type ProxyNode,
} from '../../api/proxy';
+import { readLocalStorage, writeLocalStorage } from
'../../utils/browserStorage';
const { Text } = Typography;
const persistProxyAddress = (address?: string) => {
if (!address) return;
- try {
- localStorage.setItem('proxyAddr', address);
- } catch {
- // Proxy discovery remains usable when browser storage is unavailable.
- }
+ writeLocalStorage('proxyAddr', address);
};
const ProxyPage: React.FC = () => {
@@ -83,7 +80,7 @@ const ProxyPage: React.FC = () => {
const [addressMutationLoading, setAddressMutationLoading] = useState(false);
const [removingProxyAddress, setRemovingProxyAddress] = useState<string |
null>(null);
const [clusterId, setClusterId] = useState<string>(
- localStorage.getItem('clusterId') || 'DefaultCluster',
+ readLocalStorage('clusterId') || 'DefaultCluster',
);
const loadRequestId = useRef(0);
@@ -187,7 +184,7 @@ const ProxyPage: React.FC = () => {
const handleClusterIdChange = (value: string) => {
setClusterId(value);
if (value) {
- localStorage.setItem('clusterId', value);
+ writeLocalStorage('clusterId', value);
}
};
diff --git a/web/src/pages/studio/UserManagement.tsx
b/web/src/pages/studio/UserManagement.tsx
index de0de99d3..412d5c9bb 100644
--- a/web/src/pages/studio/UserManagement.tsx
+++ b/web/src/pages/studio/UserManagement.tsx
@@ -89,9 +89,11 @@ const UserManagementPage = () => {
const [createOpen, setCreateOpen] = useState(false);
const [passwordTarget, setPasswordTarget] = useState<StudioUser |
null>(null);
const [userExporting, setUserExporting] = useState(false);
+ const [mutatingUserIds, setMutatingUserIds] = useState<Set<number>>(() =>
new Set());
const [createForm] = Form.useForm<CreateFormValues>();
const [passwordForm] = Form.useForm<PasswordFormValues>();
const requestSeqRef = useRef(0);
+ const mutatingUserIdsRef = useRef(new Set<number>());
useEffect(() => {
const timer = window.setTimeout(() => setDebouncedSearch(search.trim()),
300);
@@ -163,12 +165,18 @@ const UserManagementPage = () => {
};
const setEnabled = async (record: StudioUser, enabled: boolean) => {
+ if (mutatingUserIdsRef.current.has(record.id)) return;
+ mutatingUserIdsRef.current.add(record.id);
+ setMutatingUserIds(new Set(mutatingUserIdsRef.current));
try {
await setStudioUserEnabled(record.id, enabled);
message.success(enabled ? '用户已启用' : '用户已禁用,全部会话已注销');
await loadUsers();
} catch {
message.error('更新用户状态失败');
+ } finally {
+ mutatingUserIdsRef.current.delete(record.id);
+ setMutatingUserIds(new Set(mutatingUserIdsRef.current));
}
};
@@ -237,6 +245,7 @@ const UserManagementPage = () => {
</Button>
<Switch
checked={record.enabled}
+ loading={mutatingUserIds.has(record.id)}
checkedChildren="启用"
unCheckedChildren="禁用"
onChange={(enabled) => void setEnabled(record, enabled)}
diff --git a/web/src/pages/studio/__tests__/Proxy.test.tsx
b/web/src/pages/studio/__tests__/Proxy.test.tsx
index 2fb9551ca..4ac5d3a8a 100644
--- a/web/src/pages/studio/__tests__/Proxy.test.tsx
+++ b/web/src/pages/studio/__tests__/Proxy.test.tsx
@@ -101,6 +101,19 @@ describe('ProxyPage', () => {
storageSpy.mockRestore();
});
+ it('uses the default cluster when stored preferences cannot be read', async
() => {
+ const storageSpy = vi.spyOn(Storage.prototype,
'getItem').mockImplementation(() => {
+ throw new DOMException('storage disabled', 'SecurityError');
+ });
+ try {
+ renderPage();
+ await screen.findAllByText('127.0.0.1:8081');
+ expect(screen.getByDisplayValue('DefaultCluster')).toBeInTheDocument();
+ } finally {
+ storageSpy.mockRestore();
+ }
+ });
+
it('loads Proxy nodes once after the page mounts', async () => {
renderPage();
diff --git a/web/src/pages/studio/__tests__/UserManagement.test.tsx
b/web/src/pages/studio/__tests__/UserManagement.test.tsx
index 07812b13d..136cb79b9 100644
--- a/web/src/pages/studio/__tests__/UserManagement.test.tsx
+++ b/web/src/pages/studio/__tests__/UserManagement.test.tsx
@@ -17,12 +17,14 @@
import { App } from 'antd';
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
-import { render, screen, waitFor } from '@testing-library/react';
+import { act, fireEvent, render, screen, waitFor } from
'@testing-library/react';
import userEvent, { type UserEvent } from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
import {
listAllStudioUsers as downloadStudioUsers,
listStudioUsers,
+ setStudioUserEnabled,
+ type StudioUser,
} from '../../../api/studioUsers';
import { downloadCsv } from '../../../utils/download';
import UserManagementPage from '../UserManagement';
@@ -157,4 +159,23 @@ describe('UserManagementPage', () => {
expect(exportedCsv).toContain('"User"');
expect(exportedCsv).toContain('"Enabled"');
});
+
+ it('does not overlap status updates for the same user', async () => {
+ let resolveUpdate!: () => void;
+ vi.mocked(setStudioUserEnabled).mockImplementationOnce(
+ () =>
+ new Promise<StudioUser>((resolve) => {
+ resolveUpdate = () => resolve({ ...studioUserPage.items[0], enabled:
false });
+ }),
+ );
+ renderPage();
+
+ const toggle = await screen.findByRole('switch');
+ fireEvent.click(toggle);
+ fireEvent.click(toggle);
+
+ expect(setStudioUserEnabled).toHaveBeenCalledTimes(1);
+ expect(setStudioUserEnabled).toHaveBeenCalledWith(7, false);
+ await act(async () => resolveUpdate());
+ });
});
diff --git a/web/src/services/instanceService.test.ts
b/web/src/services/instanceService.test.ts
index c7da95009..c5dd08eb7 100644
--- a/web/src/services/instanceService.test.ts
+++ b/web/src/services/instanceService.test.ts
@@ -165,4 +165,25 @@ describe('instanceService list request dedupe', () => {
await listInstances({});
expect(instanceApiMock.listInstances).toHaveBeenCalledTimes(2);
});
+
+ it('does not share inflight requests across data modes', async () => {
+ dataModeMock.isMockMode.mockReturnValue(false);
+ let resolveRealList!: (value: Instance[]) => void;
+ instanceApiMock.listInstances.mockImplementationOnce(
+ () =>
+ new Promise<Instance[]>((resolve) => {
+ resolveRealList = resolve;
+ }),
+ );
+
+ const realRequest = listInstances({});
+ dataModeMock.isMockMode.mockReturnValue(true);
+ const mockResult = await listInstances({});
+
+ expect(mockResult.length).toBeGreaterThan(0);
+ expect(instanceApiMock.listInstances).toHaveBeenCalledTimes(1);
+
+ resolveRealList([]);
+ await expect(realRequest).resolves.toEqual([]);
+ });
});
diff --git a/web/src/services/instanceService.ts
b/web/src/services/instanceService.ts
index 751f68b8a..af8f54a28 100644
--- a/web/src/services/instanceService.ts
+++ b/web/src/services/instanceService.ts
@@ -41,18 +41,19 @@ const CLOUD_CAPABILITIES:
InstanceCapabilities['capabilities'] = [
const inflightListRequests = new Map<string, Promise<Instance[]>>();
export function listInstances(query: InstanceQuery = {}): Promise<Instance[]> {
- const key = JSON.stringify([query.type ?? null, query.search?.trim() ||
null]);
+ const mockMode = isMockMode();
+ const key = JSON.stringify([mockMode, query.type ?? null,
query.search?.trim() || null]);
const inflight = inflightListRequests.get(key);
if (inflight) {
return inflight.then((items) => items.map(copyInstance));
}
- const request = fetchInstances(query).finally(() =>
inflightListRequests.delete(key));
+ const request = fetchInstances(query, mockMode).finally(() =>
inflightListRequests.delete(key));
inflightListRequests.set(key, request);
return request.then((items) => items.map(copyInstance));
}
-async function fetchInstances(query: InstanceQuery): Promise<Instance[]> {
- if (isMockMode()) {
+async function fetchInstances(query: InstanceQuery, mockMode: boolean):
Promise<Instance[]> {
+ if (mockMode) {
const search = query.search?.trim().toLowerCase();
return mockInstances
.filter((instance) => matchesType(instance, query.type))
diff --git a/web/src/stores/authStorage.ts b/web/src/stores/authStorage.ts
index af69f7d88..8f63329ad 100644
--- a/web/src/stores/authStorage.ts
+++ b/web/src/stores/authStorage.ts
@@ -15,6 +15,8 @@
* limitations under the License.
*/
+import { readLocalStorage, removeLocalStorage, writeLocalStorage } from
'../utils/browserStorage';
+
export const USER_STORAGE_KEY = 'rocketmq-studio-user';
export const USER_ID_STORAGE_KEY = 'rocketmq-studio-user-id';
export const USER_ADMIN_STORAGE_KEY = 'rocketmq-studio-user-admin';
@@ -34,39 +36,27 @@ function parseUserId(raw: string | null): number | null {
}
export function readAuthSession(): AuthSession {
- try {
- const admin = localStorage.getItem(USER_ADMIN_STORAGE_KEY);
- return {
- user: localStorage.getItem(USER_STORAGE_KEY),
- userId: parseUserId(localStorage.getItem(USER_ID_STORAGE_KEY)),
- admin: admin != null ? admin === 'true' : null,
- };
- } catch {
- return { user: null, userId: null, admin: null };
- }
+ const admin = readLocalStorage(USER_ADMIN_STORAGE_KEY);
+ return {
+ user: readLocalStorage(USER_STORAGE_KEY),
+ userId: parseUserId(readLocalStorage(USER_ID_STORAGE_KEY)),
+ admin: admin != null ? admin === 'true' : null,
+ };
}
export function persistAuthSession(user: string, userId: number | null, admin:
boolean): void {
- try {
- localStorage.setItem(USER_STORAGE_KEY, user);
- if (userId != null) {
- localStorage.setItem(USER_ID_STORAGE_KEY, String(userId));
- } else {
- localStorage.removeItem(USER_ID_STORAGE_KEY);
- }
- localStorage.setItem(USER_ADMIN_STORAGE_KEY, String(admin));
- } catch {
- // The in-memory store remains usable when browser storage is unavailable.
+ writeLocalStorage(USER_STORAGE_KEY, user);
+ if (userId != null) {
+ writeLocalStorage(USER_ID_STORAGE_KEY, String(userId));
+ } else {
+ removeLocalStorage(USER_ID_STORAGE_KEY);
}
+ writeLocalStorage(USER_ADMIN_STORAGE_KEY, String(admin));
}
export function clearAuthSession(): void {
- try {
- localStorage.removeItem('token');
- localStorage.removeItem(USER_STORAGE_KEY);
- localStorage.removeItem(USER_ID_STORAGE_KEY);
- localStorage.removeItem(USER_ADMIN_STORAGE_KEY);
- } catch {
- // The caller still clears the in-memory store.
- }
+ removeLocalStorage('token');
+ removeLocalStorage(USER_STORAGE_KEY);
+ removeLocalStorage(USER_ID_STORAGE_KEY);
+ removeLocalStorage(USER_ADMIN_STORAGE_KEY);
}
diff --git a/web/src/theme/themePreference.ts b/web/src/theme/themePreference.ts
index f2c11e741..36b3fbebd 100644
--- a/web/src/theme/themePreference.ts
+++ b/web/src/theme/themePreference.ts
@@ -15,26 +15,20 @@
* limitations under the License.
*/
+import { readLocalStorage, writeLocalStorage } from '../utils/browserStorage';
+
export type ThemeMode = 'light' | 'dark' | 'system';
export const THEME_STORAGE_KEY = 'rocketmq-studio-theme';
export const COMPACT_STORAGE_KEY = 'rocketmq-studio-compact';
export function getStoredThemeMode(): ThemeMode {
- try {
- const stored = localStorage.getItem(THEME_STORAGE_KEY);
- return stored === 'dark' || stored === 'light' || stored === 'system' ?
stored : 'system';
- } catch {
- return 'system';
- }
+ const stored = readLocalStorage(THEME_STORAGE_KEY);
+ return stored === 'dark' || stored === 'light' || stored === 'system' ?
stored : 'system';
}
export function persistThemeMode(mode: ThemeMode): void {
- try {
- localStorage.setItem(THEME_STORAGE_KEY, mode);
- } catch {
- // The active theme still changes when browser storage is unavailable.
- }
+ writeLocalStorage(THEME_STORAGE_KEY, mode);
}
export function getSystemDarkMode(): boolean {
@@ -42,17 +36,9 @@ export function getSystemDarkMode(): boolean {
}
export function getStoredCompact(): boolean {
- try {
- return localStorage.getItem(COMPACT_STORAGE_KEY) === 'true';
- } catch {
- return false;
- }
+ return readLocalStorage(COMPACT_STORAGE_KEY) === 'true';
}
export function persistCompact(compact: boolean): void {
- try {
- localStorage.setItem(COMPACT_STORAGE_KEY, String(compact));
- } catch {
- // Compact mode still toggles when browser storage is unavailable.
- }
+ writeLocalStorage(COMPACT_STORAGE_KEY, String(compact));
}
diff --git a/web/src/utils/browserStorage.test.ts
b/web/src/utils/browserStorage.test.ts
new file mode 100644
index 000000000..a13be2aaf
--- /dev/null
+++ b/web/src/utils/browserStorage.test.ts
@@ -0,0 +1,44 @@
+/*
+ * 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 { describe, expect, it, vi } from 'vitest';
+import { readLocalStorage, removeLocalStorage, writeLocalStorage } from
'./browserStorage';
+
+describe('browserStorage', () => {
+ it('reads, writes and removes available storage', () => {
+ expect(writeLocalStorage('key', 'value')).toBe(true);
+ expect(readLocalStorage('key')).toBe('value');
+ expect(removeLocalStorage('key')).toBe(true);
+ expect(readLocalStorage('key')).toBeNull();
+ });
+
+ it('returns safe fallbacks when storage operations throw', () => {
+ vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
+ throw new DOMException('blocked', 'SecurityError');
+ });
+ vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
+ throw new DOMException('blocked', 'SecurityError');
+ });
+ vi.spyOn(Storage.prototype, 'removeItem').mockImplementation(() => {
+ throw new DOMException('blocked', 'SecurityError');
+ });
+
+ expect(readLocalStorage('key')).toBeNull();
+ expect(writeLocalStorage('key', 'value')).toBe(false);
+ expect(removeLocalStorage('key')).toBe(false);
+ });
+});
diff --git a/web/src/i18n/languagePreference.ts
b/web/src/utils/browserStorage.ts
similarity index 64%
copy from web/src/i18n/languagePreference.ts
copy to web/src/utils/browserStorage.ts
index 7354eb664..e56a53be8 100644
--- a/web/src/i18n/languagePreference.ts
+++ b/web/src/utils/browserStorage.ts
@@ -15,23 +15,28 @@
* limitations under the License.
*/
-import type { Lang } from './translations';
-
-export const LANGUAGE_STORAGE_KEY = 'rocketmq-studio-language';
+export function readLocalStorage(key: string): string | null {
+ try {
+ return localStorage.getItem(key);
+ } catch {
+ return null;
+ }
+}
-export function getInitialLanguage(): Lang {
+export function writeLocalStorage(key: string, value: string): boolean {
try {
- const stored = localStorage.getItem(LANGUAGE_STORAGE_KEY);
- return stored === 'en' || stored === 'zh' ? stored : 'zh';
+ localStorage.setItem(key, value);
+ return true;
} catch {
- return 'zh';
+ return false;
}
}
-export function persistLanguage(lang: Lang): void {
+export function removeLocalStorage(key: string): boolean {
try {
- localStorage.setItem(LANGUAGE_STORAGE_KEY, lang);
+ localStorage.removeItem(key);
+ return true;
} catch {
- // Language selection still works when browser storage is unavailable.
+ return false;
}
}