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
commit 80d0945915435061ea7df8a051b0518a0263dc70 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 307eab84..b9dc0ecf 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 eac2795c..a75edbbf 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 1b03825f..58250ebb 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 00000000..6db27b12 --- /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 de7dc55b..b656db82 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 00000000..715201c1 --- /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 6bac20e2..48d250eb 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 3feb0cc6..95b919eb 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 +});
