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 6c853c4 feat: implement login page, auth & AI modules, simplify theme
management (#473)
6c853c4 is described below
commit 6c853c4c731cf4a5449b206bf283790bc08e3afb
Author: zhaohai <[email protected]>
AuthorDate: Wed Jul 22 19:09:38 2026 +0800
feat: implement login page, auth & AI modules, simplify theme management
(#473)
Add login page with full auth workflow, Auth/AI API modules, Zustand state,
and simplified theme management.
---
web/package.json | 2 +-
web/src/App.tsx | 2 +
web/src/api/ai.test.ts | 191 ++++++++++++++++++--------
web/src/api/auth.test.ts | 74 ++++++++++
web/src/layouts/MainLayout.tsx | 10 +-
web/src/pages/login/index.tsx | 102 ++++++++++++++
web/src/theme/ThemeContext.tsx | 2 +-
web/src/theme/__tests__/ThemeContext.test.tsx | 2 +-
8 files changed, 316 insertions(+), 69 deletions(-)
diff --git a/web/package.json b/web/package.json
index 307eab8..b9dc0ec 100644
--- a/web/package.json
+++ b/web/package.json
@@ -60,4 +60,4 @@
"vite": "^6.0.0",
"vitest": "^4.1.10"
}
-}
\ No newline at end of file
+}
diff --git a/web/src/App.tsx b/web/src/App.tsx
index eac2795..a75edbb 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -33,10 +33,12 @@ import SystemAlertsPage from './pages/ops/systemAlerts';
import AuditPage from './pages/ops/audit';
import AiPage from './pages/ai';
import SettingsPage from './pages/settings';
+import LoginPage from './pages/login';
function App() {
return (
<Routes>
+ <Route path="/login" element={<LoginPage />} />
<Route path="/" element={<MainLayout />}>
<Route index element={<HomePage />} />
<Route path="instance" element={<InstancePage />} />
diff --git a/web/src/api/ai.test.ts b/web/src/api/ai.test.ts
index 1b03825..58250eb 100644
--- a/web/src/api/ai.test.ts
+++ b/web/src/api/ai.test.ts
@@ -15,9 +15,12 @@
* limitations under the License.
*/
-import { afterEach, describe, expect, it, vi } from 'vitest';
-import { chatStream } from './ai';
+import MockAdapter from 'axios-mock-adapter';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import client from './client';
+import { chatStream, executeAiCommand, listTools, type AiExecuteRequest, type
McpTool } from './ai';
+const mock = new MockAdapter(client);
const encoder = new TextEncoder();
function streamResponse(chunks: string[]): Response {
@@ -30,68 +33,142 @@ function streamResponse(chunks: string[]): Response {
return new Response(body, { status: 200 });
}
-describe('AI chat SSE stream', () => {
+describe('AI API', () => {
+ beforeEach(() => {
+ mock.reset();
+ vi.stubGlobal('localStorage', {
+ getItem: vi.fn().mockReturnValue('test-token'),
+ setItem: vi.fn(),
+ removeItem: vi.fn(),
+ });
+ });
+
afterEach(() => {
+ mock.reset();
vi.unstubAllGlobals();
});
- it('reassembles an event split across network chunks', async () => {
- vi.stubGlobal(
- 'fetch',
- vi
- .fn()
- .mockResolvedValue(
- streamResponse([
- 'event: message\r\ndata: {"text":"hel',
- 'lo"}\r\n\r\nevent: done\r\ndata: [DONE]\r\n\r\n',
- ]),
- ),
- );
- vi.stubGlobal('localStorage', { getItem: vi.fn().mockReturnValue('token')
});
- const chunks: string[] = [];
-
- await chatStream({ message: 'hello', mode: 'chat', model: 'stub' }, (text)
=>
- chunks.push(text),
- );
-
- expect(chunks).toEqual(['hello']);
+ describe('chatStream (SSE)', () => {
+ it('reassembles an event split across network chunks', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi
+ .fn()
+ .mockResolvedValue(
+ streamResponse([
+ 'event: message\r\ndata: {"text":"hel',
+ 'lo"}\r\n\r\nevent: done\r\ndata: [DONE]\r\n\r\n',
+ ]),
+ ),
+ );
+ const chunks: string[] = [];
+
+ await chatStream({ message: 'hello', mode: 'chat', model: 'stub' },
(text) =>
+ chunks.push(text),
+ );
+
+ expect(chunks).toEqual(['hello']);
+ });
+
+ it('dispatches multiple events delivered in one network chunk', async ()
=> {
+ vi.stubGlobal(
+ 'fetch',
+ vi
+ .fn()
+ .mockResolvedValue(
+ streamResponse([
+ 'data: {"content":"first"}\n\ndata:
{"content":"second"}\n\ndata: [DONE]\n\n',
+ ]),
+ ),
+ );
+ const chunks: string[] = [];
+
+ await chatStream({ message: 'hello', mode: 'chat', model: 'stub' },
(text) =>
+ chunks.push(text),
+ );
+
+ expect(chunks).toEqual(['first', 'second']);
+ });
+
+ it('supports multiline and raw SSE data at end of stream', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi
+ .fn()
+ .mockResolvedValue(
+ streamResponse(['data: {"content":\ndata: "hello"}\n\ndata: raw
text']),
+ ),
+ );
+ const chunks: string[] = [];
+
+ await chatStream({ message: 'hello', mode: 'chat', model: 'stub' },
(text) =>
+ chunks.push(text),
+ );
+
+ expect(chunks).toEqual(['hello', 'raw text']);
+ });
});
- it('dispatches multiple events delivered in one network chunk', async () => {
- vi.stubGlobal(
- 'fetch',
- vi
- .fn()
- .mockResolvedValue(
- streamResponse([
- 'data: {"content":"first"}\n\ndata: {"content":"second"}\n\ndata:
[DONE]\n\n',
- ]),
- ),
- );
- vi.stubGlobal('localStorage', { getItem: vi.fn().mockReturnValue(null) });
- const chunks: string[] = [];
-
- await chatStream({ message: 'hello', mode: 'chat', model: 'stub' }, (text)
=>
- chunks.push(text),
- );
-
- expect(chunks).toEqual(['first', 'second']);
+ describe('executeAiCommand', () => {
+ it('should post AI command and return result with tool calls', async () =>
{
+ const request: AiExecuteRequest = {
+ message: 'list topics',
+ mode: 'agent',
+ model: 'gpt-4',
+ };
+ const mockResult = { result: 'Found 5 topics', toolCalls: [{ name:
'listTopics' }] };
+ mock.onPost('/ai/execute', request).reply(200, { data: mockResult });
+
+ const result = await executeAiCommand(request);
+ expect(result.result).toBe('Found 5 topics');
+ expect(result.toolCalls).toHaveLength(1);
+ expect((result.toolCalls[0] as { name: string
}).name).toBe('listTopics');
+ });
+
+ it('should handle empty tool calls', async () => {
+ mock.onPost('/ai/execute').reply(200, { data: { result: 'Hi!',
toolCalls: [] } });
+
+ const result = await executeAiCommand({
+ message: 'hello',
+ mode: 'chat',
+ model: 'gpt-4',
+ });
+ expect(result.result).toBe('Hi!');
+ expect(result.toolCalls).toEqual([]);
+ });
+
+ it('should handle server error', async () => {
+ mock.onPost('/ai/execute').reply(500);
+ await expect(
+ executeAiCommand({ message: 'test', mode: 'chat', model: 'gpt-4' }),
+ ).rejects.toThrow();
+ });
});
- it('supports multiline and raw SSE data at end of stream', async () => {
- vi.stubGlobal(
- 'fetch',
- vi
- .fn()
- .mockResolvedValue(streamResponse(['data: {"content":\ndata:
"hello"}\n\ndata: raw text'])),
- );
- vi.stubGlobal('localStorage', { getItem: vi.fn().mockReturnValue(null) });
- const chunks: string[] = [];
-
- await chatStream({ message: 'hello', mode: 'chat', model: 'stub' }, (text)
=>
- chunks.push(text),
- );
-
- expect(chunks).toEqual(['hello', 'raw text']);
+ describe('listTools', () => {
+ it('should return list of MCP tools', async () => {
+ const mockTools: McpTool[] = [
+ { name: 'listTopics', description: 'List all topics', parameters: {} },
+ { name: 'createTopic', description: 'Create a topic', parameters: {
type: 'object' } },
+ ];
+ mock.onGet('/ai/tools').reply(200, { data: mockTools });
+
+ const result = await listTools();
+ expect(result).toHaveLength(2);
+ expect(result[0].name).toBe('listTopics');
+ expect(result[1].name).toBe('createTopic');
+ });
+
+ it('should return empty list when no tools available', async () => {
+ mock.onGet('/ai/tools').reply(200, { data: [] });
+
+ const result = await listTools();
+ expect(result).toEqual([]);
+ });
+
+ it('should handle server error', async () => {
+ mock.onGet('/ai/tools').reply(500);
+ await expect(listTools()).rejects.toThrow();
+ });
});
});
diff --git a/web/src/api/auth.test.ts b/web/src/api/auth.test.ts
new file mode 100644
index 0000000..6db27b1
--- /dev/null
+++ b/web/src/api/auth.test.ts
@@ -0,0 +1,74 @@
+/*
+ * 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 { login, logout } from './auth';
+
+const mock = new MockAdapter(client);
+
+describe('Auth API', () => {
+ beforeEach(() => {
+ mock.reset();
+ vi.stubGlobal('localStorage', {
+ getItem: vi.fn().mockReturnValue(null),
+ setItem: vi.fn(),
+ removeItem: vi.fn(),
+ });
+ });
+
+ afterEach(() => {
+ mock.reset();
+ vi.unstubAllGlobals();
+ });
+
+ it('login should post credentials and return token data', async () => {
+ const mockResponse = {
+ token: 'jwt-token-123',
+ username: 'admin',
+ role: 'ADMIN',
+ };
+ mock.onPost('/auth/login', { username: 'admin', password: 'secret'
}).reply(200, {
+ data: mockResponse,
+ });
+
+ const result = await login('admin', 'secret');
+ expect(result).toEqual(mockResponse);
+ expect(result.token).toBe('jwt-token-123');
+ expect(result.username).toBe('admin');
+ expect(result.role).toBe('ADMIN');
+ });
+
+ it('login should handle error response', async () => {
+ mock.onPost('/auth/login').reply(401, { message: 'Invalid credentials' });
+
+ await expect(login('wrong', 'creds')).rejects.toThrow();
+ });
+
+ it('logout should post to logout endpoint', async () => {
+ mock.onPost('/auth/logout').reply(200);
+
+ await expect(logout()).resolves.toBeUndefined();
+ });
+
+ it('logout should handle server error', async () => {
+ mock.onPost('/auth/logout').reply(500);
+
+ await expect(logout()).rejects.toThrow();
+ });
+});
diff --git a/web/src/layouts/MainLayout.tsx b/web/src/layouts/MainLayout.tsx
index de7dc55..b656db8 100644
--- a/web/src/layouts/MainLayout.tsx
+++ b/web/src/layouts/MainLayout.tsx
@@ -16,15 +16,7 @@
*/
import { useState } from 'react';
-import {
- Layout,
- Menu,
- Breadcrumb,
- Avatar,
- Dropdown,
- Input,
- Modal,
-} from 'antd';
+import { Layout, Menu, Breadcrumb, Avatar, Dropdown, Input, Modal } from
'antd';
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
import {
House,
diff --git a/web/src/pages/login/index.tsx b/web/src/pages/login/index.tsx
new file mode 100644
index 0000000..715201c
--- /dev/null
+++ b/web/src/pages/login/index.tsx
@@ -0,0 +1,102 @@
+/*
+ * 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 { useState } from 'react';
+import { Button, Form, Input, Typography, App } from 'antd';
+import { useNavigate } from 'react-router-dom';
+import { useLang } from '../../i18n/LangContext';
+import useAuthStore from '../../stores/authStore';
+import { login as loginApi } from '../../api/auth';
+
+const { Title } = Typography;
+
+interface LoginFormValues {
+ username: string;
+ password: string;
+}
+
+const LoginPage = () => {
+ const [loading, setLoading] = useState(false);
+ const [form] = Form.useForm<LoginFormValues>();
+ const { t } = useLang();
+ const { message } = App.useApp();
+ const navigate = useNavigate();
+ const authLogin = useAuthStore((s) => s.login);
+
+ const onFinish = async (values: LoginFormValues) => {
+ setLoading(true);
+ try {
+ const data = await loginApi(values.username, values.password);
+ authLogin(data.token, data.username);
+ localStorage.setItem('userrole', data.role);
+ message.success(t('login.success'));
+ navigate('/', { replace: true });
+ } catch (err: unknown) {
+ const errorMsg = err instanceof Error ? err.message : t('login.failed');
+ message.error(errorMsg);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+ <div
+ style={{
+ maxWidth: 400,
+ margin: '100px auto',
+ padding: 24,
+ boxShadow: '0 2px 8px #f0f1f2',
+ borderRadius: 8,
+ }}
+ >
+ <Title level={3} style={{ textAlign: 'center', marginBottom: 24 }}>
+ {t('login.welcome')}
+ </Title>
+ <Form
+ form={form}
+ name="login_form"
+ layout="vertical"
+ onFinish={onFinish}
+ initialValues={{ username: '', password: '' }}
+ >
+ <Form.Item
+ label={t('login.username')}
+ name="username"
+ rules={[{ required: true, message: t('login.usernameRequired') }]}
+ >
+ <Input placeholder={t('login.usernamePlaceholder')} />
+ </Form.Item>
+
+ <Form.Item
+ label={t('login.password')}
+ name="password"
+ rules={[{ required: true, message: t('login.passwordRequired') }]}
+ >
+ <Input.Password placeholder={t('login.passwordPlaceholder')} />
+ </Form.Item>
+
+ <Form.Item>
+ <Button type="primary" htmlType="submit" block loading={loading}>
+ {t('login.title')}
+ </Button>
+ </Form.Item>
+ </Form>
+ </div>
+ );
+};
+
+export default LoginPage;
diff --git a/web/src/theme/ThemeContext.tsx b/web/src/theme/ThemeContext.tsx
index 6bac20e..48d250e 100644
--- a/web/src/theme/ThemeContext.tsx
+++ b/web/src/theme/ThemeContext.tsx
@@ -65,4 +65,4 @@ export const ThemeProvider = ({ children }: { children:
ReactNode }) => {
export const useTheme = () => useContext(ThemeContext);
-export default ThemeContext;
\ No newline at end of file
+export default ThemeContext;
diff --git a/web/src/theme/__tests__/ThemeContext.test.tsx
b/web/src/theme/__tests__/ThemeContext.test.tsx
index 3feb0cc..95b919e 100644
--- a/web/src/theme/__tests__/ThemeContext.test.tsx
+++ b/web/src/theme/__tests__/ThemeContext.test.tsx
@@ -153,4 +153,4 @@ describe('ThemeContext', () => {
await user.click(screen.getByText('set-light'));
expect(screen.getByTestId('mode')).toHaveTextContent('light');
});
-});
\ No newline at end of file
+});