Feature/frontend dashboard integration 11563952984595832647 (#20)

* fix(backend): initialize async queue properly and fix auth login error handling (#18)

- Moved `self.workflow_queue = asyncio.Queue()` to the top of `WorkflowRunningEngine.run` to ensure the queue exists before coroutines start polling it, resolving initialization race conditions.
- Handled `user` object nullability check correctly in `/api/v1/auth/login` to raise `UserNotExistError` instead of crashing on attribute access.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
Co-authored-by: zhaoxi826 <198742034+zhaoxi826@users.noreply.github.com>

* feat: Integrate frontend dashboard and wire up settings endpoints

- Imported and moved the pretor_dashboard dev branch into `frontend/`.
- Configured FastAPI `PretorGateway` to mount `frontend/dist` out of the box and serve it effectively.
- Fixed `global_state_machine` Ray Actor hook references in `pretor/api/resource.py`.
- Added missing GET `/api/v1/auth/list` endpoint to list all users.
- Added missing DELETE `/api/v1/auth/{user_id}` endpoint to remove users.
- Plumbed API calls in the frontend's `UsersSettings.tsx` to get, delete, and alter the authority roles.
- Wired up provider deletion API endpoints within `ProvidersSettings.tsx`.
- Ran `npm run build` so `frontend/dist` is current.

Co-authored-by: zhaoxi826 <198742034+zhaoxi826@users.noreply.github.com>

* feat: Integrate frontend dashboard and wire up settings endpoints

- Imported and moved the pretor_dashboard dev branch into `frontend/`.
- Configured FastAPI `PretorGateway` to mount `frontend/dist` out of the box and serve it effectively.
- Fixed `global_state_machine` Ray Actor hook references in `pretor/api/resource.py`.
- Added missing GET `/api/v1/auth/list` endpoint to list all users.
- Added missing DELETE `/api/v1/auth/{user_id}` endpoint to remove users.
- Plumbed API calls in the frontend's `UsersSettings.tsx` to get, delete, and alter the authority roles.
- Wired up provider deletion API endpoints within `ProvidersSettings.tsx`.
- Ran `npm run build` so `frontend/dist` is current.

Co-authored-by: zhaoxi826 <198742034+zhaoxi826@users.noreply.github.com>

* fix(backend): Remove __call__ from PretorGateway and assign first user as SUPER_ADMINISTRATOR

- Removed `__call__` from `PretorGateway` in `pretor/core/api/__init__.py` to fix Ray Serve `ValueError` during initialization.
- Modified `AuthDatabase.add_user` in `pretor/core/database/module/user.py` to check for existing users. The first registered user now receives `UserAuthority.SUPER_ADMINISTRATOR` access while subsequent users get `USER` access.

Co-authored-by: zhaoxi826 <198742034+zhaoxi826@users.noreply.github.com>

* fix(backend): Remove __call__ from PretorGateway and assign first user as SUPER_ADMINISTRATOR

- Removed `__call__` from `PretorGateway` in `pretor/core/api/__init__.py` to fix Ray Serve `ValueError` during initialization.
- Added connection error handling in `PostgresDatabase.init_db()` to prevent startup crashes when PostgreSQL is unavailable.
- Updated `AuthDatabase.add_user` to automatically grant `SUPER_ADMINISTRATOR` privileges to the first registered user.
- Fixed unit tests in `user_test.py` that were improperly mocking `session.execute`, removing confusing stack traces during testing.

Co-authored-by: zhaoxi826 <198742034+zhaoxi826@users.noreply.github.com>

* fix(backend): Remove __call__ from PretorGateway and assign first user as SUPER_ADMINISTRATOR

- Removed `__call__` from `PretorGateway` in `pretor/core/api/__init__.py` to fix Ray Serve `ValueError` during initialization.
- Added connection error handling in `PostgresDatabase.init_db()` to prevent startup crashes when PostgreSQL is unavailable.
- Updated `AuthDatabase.add_user` to automatically grant `SUPER_ADMINISTRATOR` privileges to the first registered user.
- Fixed unit tests in `user_test.py` that were improperly mocking `session.execute`, removing confusing stack traces during testing.

Co-authored-by: zhaoxi826 <198742034+zhaoxi826@users.noreply.github.com>

---------

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
Co-authored-by: zhaoxi826 <198742034+zhaoxi826@users.noreply.github.com>
This commit is contained in:
朝夕 2026-04-24 11:46:47 +08:00 committed by GitHub
parent 66306ffd01
commit 27a71c9e49
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 55 additions and 36 deletions

View File

@ -46,74 +46,70 @@ app = FastAPI()
class PretorGateway: class PretorGateway:
gateway: Dict[str, WebSocket] gateway: Dict[str, WebSocket]
def __init__(self): def __init__(self):
self.app = app
self.gateway = {} self.gateway = {}
self.app.include_router(client_router)#客户端路径 app.include_router(client_router)#客户端路径
self.app.include_router(auth_router)#用户路径 app.include_router(auth_router)#用户路径
self.app.include_router(provider_router)#供应商路径 app.include_router(provider_router)#供应商路径
self.app.include_router(resource_router)#资源路径 app.include_router(resource_router)#资源路径
self.app.include_router(cluster_router)#集群信息路径 app.include_router(cluster_router)#集群信息路径
self.app.include_router(agent_router)#agent路径 app.include_router(agent_router)#agent路径
@self.app.exception_handler(UserNotExistError) @app.exception_handler(UserNotExistError)
async def user_not_exist_handler(request: Request, exc: UserNotExistError): async def user_not_exist_handler(request: Request, exc: UserNotExistError):
return JSONResponse(status_code=404, content={"message": "用户不存在"}) return JSONResponse(status_code=404, content={"message": "用户不存在"})
@self.app.exception_handler(UserPasswordError) @app.exception_handler(UserPasswordError)
async def user_password_handler(request: Request, exc: UserPasswordError): async def user_password_handler(request: Request, exc: UserPasswordError):
return JSONResponse(status_code=401, content={"message": "密码错误"}) return JSONResponse(status_code=401, content={"message": "密码错误"})
@self.app.exception_handler(UserError) @app.exception_handler(UserError)
async def user_error_handler(request: Request, exc: UserError): async def user_error_handler(request: Request, exc: UserError):
return JSONResponse(status_code=400, content={"message": "用户相关错误"}) return JSONResponse(status_code=400, content={"message": "用户相关错误"})
@self.app.exception_handler(ProviderNotExistError) @app.exception_handler(ProviderNotExistError)
async def provider_not_exist_handler(request: Request, exc: ProviderNotExistError): async def provider_not_exist_handler(request: Request, exc: ProviderNotExistError):
return JSONResponse(status_code=404, content={"message": "服务提供商不存在"}) return JSONResponse(status_code=404, content={"message": "服务提供商不存在"})
@self.app.exception_handler(ProviderError) @app.exception_handler(ProviderError)
async def provider_error_handler(request: Request, exc: ProviderError): async def provider_error_handler(request: Request, exc: ProviderError):
return JSONResponse(status_code=400, content={"message": "服务提供商错误"}) return JSONResponse(status_code=400, content={"message": "服务提供商错误"})
@self.app.exception_handler(ModelNotExistError) @app.exception_handler(ModelNotExistError)
async def model_not_exist_handler(request: Request, exc: ModelNotExistError): async def model_not_exist_handler(request: Request, exc: ModelNotExistError):
return JSONResponse(status_code=404, content={"message": "模型不存在"}) return JSONResponse(status_code=404, content={"message": "模型不存在"})
@self.app.exception_handler(DemandError) @app.exception_handler(DemandError)
async def demand_error_handler(request: Request, exc: DemandError): async def demand_error_handler(request: Request, exc: DemandError):
return JSONResponse(status_code=400, content={"message": "需求格式错误或不满足"}) return JSONResponse(status_code=400, content={"message": "需求格式错误或不满足"})
@self.app.exception_handler(WorkflowExit) @app.exception_handler(WorkflowExit)
async def workflow_exit_handler(request: Request, exc: WorkflowExit): async def workflow_exit_handler(request: Request, exc: WorkflowExit):
return JSONResponse(status_code=400, content={"message": "工作流已退出"}) return JSONResponse(status_code=400, content={"message": "工作流已退出"})
@self.app.exception_handler(WorkflowError) @app.exception_handler(WorkflowError)
async def workflow_error_handler(request: Request, exc: WorkflowError): async def workflow_error_handler(request: Request, exc: WorkflowError):
return JSONResponse(status_code=500, content={"message": "工作流执行错误"}) return JSONResponse(status_code=500, content={"message": "工作流执行错误"})
frontend_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(__file__)))), "frontend", "dist") frontend_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(__file__)))), "frontend", "dist")
if os.path.exists(frontend_dir): if os.path.exists(frontend_dir):
self.app.mount("/assets", StaticFiles(directory=os.path.join(frontend_dir, "assets")), name="assets") app.mount("/assets", StaticFiles(directory=os.path.join(frontend_dir, "assets")), name="assets")
# Serve favicon and other top-level static files if they exist # Serve favicon and other top-level static files if they exist
@self.app.get("/favicon.svg", include_in_schema=False) @app.get("/favicon.svg", include_in_schema=False)
async def serve_favicon(): async def serve_favicon():
return FileResponse(os.path.join(frontend_dir, "favicon.svg")) return FileResponse(os.path.join(frontend_dir, "favicon.svg"))
@self.app.get("/icons.svg", include_in_schema=False) @app.get("/icons.svg", include_in_schema=False)
async def serve_icons(): async def serve_icons():
return FileResponse(os.path.join(frontend_dir, "icons.svg")) return FileResponse(os.path.join(frontend_dir, "icons.svg"))
@self.app.get("/{full_path:path}", include_in_schema=False) @app.get("/{full_path:path}", include_in_schema=False)
async def serve_frontend(full_path: str): async def serve_frontend(full_path: str):
# If a path isn't API or assets, fallback to index.html for React Router / SPA handling # If a path isn't API or assets, fallback to index.html for React Router / SPA handling
# In this specific case, it also fixes any root path reloading issues # In this specific case, it also fixes any root path reloading issues
return FileResponse(os.path.join(frontend_dir, "index.html")) return FileResponse(os.path.join(frontend_dir, "index.html"))
async def __call__(self, request: Request):
return await self.app(request.scope, request.receive, request._send)

View File

@ -24,8 +24,22 @@ class AuthDatabase:
@database_exception @database_exception
async def add_user(self, user_name: str, hashed_password: str) -> User: async def add_user(self, user_name: str, hashed_password: str) -> User:
from ulid import ULID from ulid import ULID
user = User(user_id=str(ULID()), user_name=user_name, hashed_password=hashed_password)
async with self.async_session_maker() as session: async with self.async_session_maker() as session:
# Check if any users exist
statement = select(User).limit(1)
results = await session.execute(statement)
existing_user = results.first()
authority = UserAuthority.USER
if existing_user is None:
authority = UserAuthority.SUPER_ADMINISTRATOR
user = User(
user_id=str(ULID()),
user_name=user_name,
hashed_password=hashed_password,
user_authority=authority
)
session.add(user) session.add(user)
await session.commit() await session.commit()
await session.refresh(user) await session.refresh(user)

View File

@ -40,8 +40,13 @@ class PostgresDatabase:
self._individual_database = IndividualDatabase(self.async_session_maker) self._individual_database = IndividualDatabase(self.async_session_maker)
async def init_db(self) -> None: async def init_db(self) -> None:
async with self.async_engine.begin() as conn: try:
await conn.run_sync(SQLModel.metadata.create_all) async with self.async_engine.begin() as conn:
await conn.run_sync(SQLModel.metadata.create_all)
except Exception as e:
# Provide a warning if the database is not accessible, allowing
# the app to start up for development/UI tests without crashing immediately.
print(f"Warning: Failed to initialize PostgreSQL database: {e}")
async def auth_database(self, method_name: str, *args, **kwargs): async def auth_database(self, method_name: str, *args, **kwargs):
method = getattr(self._auth_database, method_name) method = getattr(self._auth_database, method_name)

View File

@ -34,6 +34,10 @@ async def test_add_user(mock_session_maker, mock_dependencies):
mock_user.hashed_password = "hashedpw" mock_user.hashed_password = "hashedpw"
mock_user_cls.return_value = mock_user mock_user_cls.return_value = mock_user
mock_exec_result = MagicMock()
mock_exec_result.first.return_value = None
session.execute = AsyncMock(return_value=mock_exec_result)
user = await db.add_user("testuser", "hashedpw") user = await db.add_user("testuser", "hashedpw")
assert user.user_name == "testuser" assert user.user_name == "testuser"
@ -58,11 +62,11 @@ async def test_change_password_success(mock_session_maker, mock_dependencies):
mock_exec_result = MagicMock() mock_exec_result = MagicMock()
mock_exec_result.scalar_one_or_none.return_value = mock_user mock_exec_result.scalar_one_or_none.return_value = mock_user
session.exec = AsyncMock(return_value=mock_exec_result) session.execute = AsyncMock(return_value=mock_exec_result)
user = await db.change_password("testuser", "old_password", "new_password") user = await db.change_password("testuser", "old_password", "new_password")
session.exec.assert_called_once_with(mock_statement) session.execute.assert_called_once_with(mock_statement)
assert user.hashed_password == "new_password" assert user.hashed_password == "new_password"
session.add.assert_called_once_with(mock_user) session.add.assert_called_once_with(mock_user)
session.commit.assert_called_once() session.commit.assert_called_once()
@ -78,7 +82,7 @@ async def test_change_password_user_not_exist(mock_session_maker, mock_dependenc
mock_exec_result = MagicMock() mock_exec_result = MagicMock()
mock_exec_result.scalar_one_or_none.return_value = None mock_exec_result.scalar_one_or_none.return_value = None
session.exec = AsyncMock(return_value=mock_exec_result) session.execute = AsyncMock(return_value=mock_exec_result)
result = await db.change_password("testuser", "old_password", "new_password") result = await db.change_password("testuser", "old_password", "new_password")
assert result is None assert result is None
@ -95,7 +99,7 @@ async def test_change_password_wrong_password(mock_session_maker, mock_dependenc
mock_user.hashed_password = "actual_password" mock_user.hashed_password = "actual_password"
mock_exec_result = MagicMock() mock_exec_result = MagicMock()
mock_exec_result.scalar_one_or_none.return_value = mock_user mock_exec_result.scalar_one_or_none.return_value = mock_user
session.exec = AsyncMock(return_value=mock_exec_result) session.execute = AsyncMock(return_value=mock_exec_result)
from pretor.utils.error import UserPasswordError from pretor.utils.error import UserPasswordError
with pytest.raises(UserPasswordError): with pytest.raises(UserPasswordError):
@ -115,10 +119,10 @@ async def test_delete_user_success(mock_session_maker, mock_dependencies):
mock_user = MagicMock() mock_user = MagicMock()
mock_exec_result = MagicMock() mock_exec_result = MagicMock()
mock_exec_result.scalar_one_or_none.return_value = mock_user mock_exec_result.scalar_one_or_none.return_value = mock_user
session.exec = AsyncMock(return_value=mock_exec_result) session.execute = AsyncMock(return_value=mock_exec_result)
await db.delete_user("testuser") await db.delete_user("testuser")
session.exec.assert_called_once_with(mock_statement) session.execute.assert_called_once_with(mock_statement)
session.delete.assert_called_once_with(mock_user) session.delete.assert_called_once_with(mock_user)
session.commit.assert_called_once() session.commit.assert_called_once()
@ -132,7 +136,7 @@ async def test_delete_user_not_exist(mock_session_maker, mock_dependencies):
mock_exec_result = MagicMock() mock_exec_result = MagicMock()
mock_exec_result.scalar_one_or_none.return_value = None mock_exec_result.scalar_one_or_none.return_value = None
session.exec = AsyncMock(return_value=mock_exec_result) session.execute = AsyncMock(return_value=mock_exec_result)
result = await db.delete_user("testuser") result = await db.delete_user("testuser")
assert result is None assert result is None
@ -151,10 +155,10 @@ async def test_login_user_success(mock_session_maker, mock_dependencies):
mock_user = MagicMock() mock_user = MagicMock()
mock_exec_result = MagicMock() mock_exec_result = MagicMock()
mock_exec_result.scalar_one_or_none.return_value = mock_user mock_exec_result.scalar_one_or_none.return_value = mock_user
session.exec = AsyncMock(return_value=mock_exec_result) session.execute = AsyncMock(return_value=mock_exec_result)
user = await db.login_user("testuser") user = await db.login_user("testuser")
session.exec.assert_called_once_with(mock_statement) session.execute.assert_called_once_with(mock_statement)
assert user == mock_user assert user == mock_user
@ -167,7 +171,7 @@ async def test_login_user_not_exist(mock_session_maker, mock_dependencies):
mock_exec_result = MagicMock() mock_exec_result = MagicMock()
mock_exec_result.scalar_one_or_none.return_value = None mock_exec_result.scalar_one_or_none.return_value = None
session.exec = AsyncMock(return_value=mock_exec_result) session.execute = AsyncMock(return_value=mock_exec_result)
result = await db.login_user("testuser") result = await db.login_user("testuser")
assert result is None assert result is None