diff --git a/.gitignore b/.gitignore index 1e6b70c..df1de68 100644 --- a/.gitignore +++ b/.gitignore @@ -185,3 +185,4 @@ logs/ pkgs/bay/ship_data/ pkgs/bay/scripts/ +pkgs/bay/tests/k8s/k8s-deploy-local.yaml diff --git a/pkgs/bay/Dockerfile b/pkgs/bay/Dockerfile index 7fa7b0e..be7bbe6 100644 --- a/pkgs/bay/Dockerfile +++ b/pkgs/bay/Dockerfile @@ -1,27 +1,84 @@ +# ============================================ +# Stage 1: Build frontend (Vue.js Dashboard) +# ============================================ +FROM node:22-alpine AS frontend-builder + +WORKDIR /app/dashboard + +# Copy package files first for better caching +COPY dashboard/package.json dashboard/package-lock.json ./ + +# Install dependencies +RUN npm ci + +# Copy source files +COPY dashboard/ ./ + +# Build for production +RUN npm run build + +# ============================================ +# Stage 2: Build Python dependencies +# ============================================ +FROM python:3.11-slim AS python-builder + +WORKDIR /app + +# Install build dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc \ + libc6-dev \ + && rm -rf /var/lib/apt/lists/* + +# Copy requirements and install dependencies +COPY requirements.txt ./ +RUN pip wheel --no-cache-dir --wheel-dir /app/wheels -r requirements.txt + +# ============================================ +# Stage 3: Final image with Nginx + Python +# ============================================ FROM python:3.11-slim -# Install system dependencies -# RUN apt-get update && apt-get install -y \ -# gcc \ -# libc6-dev \ -# libffi-dev \ -# bash \ -# && rm -rf /var/lib/apt/lists/* +# Install Nginx and curl (for health checks) +RUN apt-get update && apt-get install -y --no-install-recommends \ + nginx \ + curl \ + && rm -rf /var/lib/apt/lists/* -# Set working directory WORKDIR /app -# Copy all project files -COPY pyproject.toml requirements.txt alembic.ini run.py ./ +# Copy Python wheels and install +COPY --from=python-builder /app/wheels /app/wheels +COPY requirements.txt ./ +RUN pip install --no-cache-dir --no-index --find-links=/app/wheels -r requirements.txt \ + && rm -rf /app/wheels + +# Copy Python application files +COPY pyproject.toml alembic.ini run.py ./ COPY app/ ./app/ COPY alembic/ ./alembic/ -# Install dependencies and create data directory in one layer -RUN pip install -r requirements.txt --no-cache-dir && \ - mkdir -p /app/data +# Create data directory +RUN mkdir -p /app/data + +# Copy built frontend to Nginx html directory +COPY --from=frontend-builder /app/dashboard/dist /usr/share/nginx/html + +# Copy Nginx configuration +COPY nginx.conf /etc/nginx/nginx.conf + +# Copy and prepare entrypoint script +COPY docker-entrypoint.sh /docker-entrypoint.sh +RUN chmod +x /docker-entrypoint.sh + +# Expose ports: +# - 8156: API (Python backend, can be exposed publicly) +# - 8157: Dashboard (Nginx, can be hidden behind NAT) +EXPOSE 8156 8157 -# Expose port -EXPOSE 8156 +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \ + CMD curl -f http://localhost:8157/nginx-health && curl -f http://localhost:8156/health || exit 1 -# Start the application -CMD ["python", "run.py"] +# Start both services +ENTRYPOINT ["/docker-entrypoint.sh"] diff --git a/pkgs/bay/app/database.py b/pkgs/bay/app/database.py index e780460..2a153ad 100644 --- a/pkgs/bay/app/database.py +++ b/pkgs/bay/app/database.py @@ -3,7 +3,7 @@ from sqlalchemy.pool import StaticPool from typing import Optional, List from app.config import settings -from app.models import Ship, SessionShip +from app.models import Ship, SessionShip, ShipStatus from datetime import datetime, timezone @@ -63,10 +63,12 @@ async def update_ship(self, ship: Ship) -> Ship: ship.updated_at = datetime.now(timezone.utc) session = self.get_session() try: - session.add(ship) + # Use merge() instead of add() to handle detached objects + # merge() copies the state of the given instance into a persistent instance + merged_ship = await session.merge(ship) await session.commit() - await session.refresh(ship) - return ship + await session.refresh(merged_ship) + return merged_ship finally: await session.close() @@ -87,10 +89,23 @@ async def delete_ship(self, ship_id: str) -> bool: await session.close() async def list_active_ships(self) -> List[Ship]: - """List all active ships""" + """List all active ships (running and creating)""" + session = self.get_session() + try: + # Include both RUNNING and CREATING status ships + statement = select(Ship).where( + (Ship.status == ShipStatus.RUNNING) | (Ship.status == ShipStatus.CREATING) + ) + result = await session.execute(statement) + return list(result.scalars().all()) + finally: + await session.close() + + async def list_all_ships(self) -> List[Ship]: + """List all ships (including stopped)""" session = self.get_session() try: - statement = select(Ship).where(Ship.status == 1) + statement = select(Ship).order_by(Ship.created_at.desc()) result = await session.execute(statement) return list(result.scalars().all()) finally: @@ -163,10 +178,11 @@ async def update_session_ship(self, session_ship: SessionShip) -> SessionShip: """Update session-ship relationship""" session = self.get_session() try: - session.add(session_ship) + # Use merge() instead of add() to handle detached objects + merged_session_ship = await session.merge(session_ship) await session.commit() - await session.refresh(session_ship) - return session_ship + await session.refresh(merged_session_ship) + return merged_session_ship finally: await session.close() @@ -174,9 +190,9 @@ async def find_available_ship(self, session_id: str) -> Optional[Ship]: """Find an available ship that can accept a new session""" session = self.get_session() try: - # Find ships that have available session slots + # Find ships that have available session slots (only RUNNING ships) statement = select(Ship).where( - Ship.status == 1, Ship.current_session_num < Ship.max_session_num + Ship.status == ShipStatus.RUNNING, Ship.current_session_num < Ship.max_session_num ) result = await session.execute(statement) ships = list(result.scalars().all()) @@ -193,38 +209,50 @@ async def find_available_ship(self, session_id: str) -> Optional[Ship]: await session.close() async def find_active_ship_for_session(self, session_id: str) -> Optional[Ship]: - """Find an active running ship that this session has access to""" + """Find an active running ship that this session has access to. + + If the session has access to multiple running ships, returns the most recently updated one. + """ session = self.get_session() try: - # Find active ships that this session has access to + # Find RUNNING ships that this session has access to + # Order by updated_at desc to get the most recently used one statement = ( select(Ship) .join(SessionShip, Ship.id == SessionShip.ship_id) .where( SessionShip.session_id == session_id, - Ship.status == 1, + Ship.status == ShipStatus.RUNNING, ) + .order_by(Ship.updated_at.desc()) ) result = await session.execute(statement) - return result.scalar_one_or_none() + # Use scalars().first() instead of scalar_one_or_none() to handle multiple results + return result.scalars().first() finally: await session.close() async def find_stopped_ship_for_session(self, session_id: str) -> Optional[Ship]: - """Find a stopped ship that belongs to this session""" + """Find a stopped ship that belongs to this session. + + If the session has access to multiple stopped ships, returns the most recently updated one. + """ session = self.get_session() try: - # Find stopped ships that this session has access to + # Find STOPPED ships that this session has access to + # Order by updated_at desc to get the most recently stopped one statement = ( select(Ship) .join(SessionShip, Ship.id == SessionShip.ship_id) .where( SessionShip.session_id == session_id, - Ship.status == 0, + Ship.status == ShipStatus.STOPPED, ) + .order_by(Ship.updated_at.desc()) ) result = await session.execute(statement) - return result.scalar_one_or_none() + # Use scalars().first() instead of scalar_one_or_none() to handle multiple results + return result.scalars().first() finally: await session.close() @@ -266,5 +294,88 @@ async def decrement_ship_session_count(self, ship_id: str) -> Optional[Ship]: finally: await session.close() + async def delete_sessions_for_ship(self, ship_id: str) -> List[str]: + """Delete all session-ship relationships for a ship and return deleted session IDs""" + session = self.get_session() + try: + # First, get all session IDs for this ship + statement = select(SessionShip).where(SessionShip.ship_id == ship_id) + result = await session.execute(statement) + session_ships = list(result.scalars().all()) + + deleted_session_ids = [ss.session_id for ss in session_ships] + + # Delete all session-ship relationships + for ss in session_ships: + await session.delete(ss) + + await session.commit() + return deleted_session_ids + finally: + await session.close() + + async def extend_session_ttl( + self, session_id: str, ttl: int + ) -> Optional[SessionShip]: + """Extend the TTL for a session by updating expires_at""" + from datetime import timedelta + + session = self.get_session() + try: + statement = select(SessionShip).where(SessionShip.session_id == session_id) + result = await session.execute(statement) + session_ship = result.scalar_one_or_none() + + if session_ship: + now = datetime.now(timezone.utc) + session_ship.expires_at = now + timedelta(seconds=ttl) + session_ship.last_activity = now + session.add(session_ship) + await session.commit() + await session.refresh(session_ship) + + return session_ship + finally: + await session.close() + + async def expire_sessions_for_ship(self, ship_id: str) -> int: + """Mark all sessions for a ship as expired by setting expires_at to current time. + + This is called when a ship is stopped to ensure session status + reflects the actual container state. + + Args: + ship_id: The ship ID + + Returns: + Number of sessions updated + """ + session = self.get_session() + try: + statement = select(SessionShip).where(SessionShip.ship_id == ship_id) + result = await session.execute(statement) + session_ships = list(result.scalars().all()) + + now = datetime.now(timezone.utc) + updated_count = 0 + + for ss in session_ships: + # Only update if session is still active (expires_at > now) + expires_at = ss.expires_at + if expires_at is not None: + if expires_at.tzinfo is None: + expires_at = expires_at.replace(tzinfo=timezone.utc) + if expires_at > now: + ss.expires_at = now + session.add(ss) + updated_count += 1 + + if updated_count > 0: + await session.commit() + + return updated_count + finally: + await session.close() + db_service = DatabaseService() diff --git a/pkgs/bay/app/main.py b/pkgs/bay/app/main.py index 07f2bfd..ee805c6 100644 --- a/pkgs/bay/app/main.py +++ b/pkgs/bay/app/main.py @@ -6,7 +6,7 @@ from app.database import db_service from app.drivers import initialize_driver, close_driver from app.services.status import status_checker -from app.routes import health, ships, stat +from app.routes import health, ships, stat, sessions # Configure logging logging.basicConfig( @@ -85,6 +85,7 @@ def create_app() -> FastAPI: app.include_router(health.router, tags=["health"]) app.include_router(ships.router, tags=["ships"]) app.include_router(stat.router, tags=["stat"]) + app.include_router(sessions.router, tags=["sessions"]) return app diff --git a/pkgs/bay/app/models.py b/pkgs/bay/app/models.py index 1e9efa3..339c44d 100644 --- a/pkgs/bay/app/models.py +++ b/pkgs/bay/app/models.py @@ -5,10 +5,18 @@ import uuid +# Ship status constants +class ShipStatus: + """Ship status constants""" + STOPPED = 0 # Ship is stopped, container not running + RUNNING = 1 # Ship is running, container active + CREATING = 2 # Ship is being created, container not yet ready + + # Database Models class ShipBase(SQLModel): id: str = Field(default_factory=lambda: str(uuid.uuid4()), primary_key=True) - status: int = Field(default=1, description="1: running, 0: stopped") + status: int = Field(default=ShipStatus.CREATING, description="0: stopped, 1: running, 2: creating") created_at: datetime = Field( default_factory=lambda: datetime.now(timezone.utc), sa_column=Column(DateTime(timezone=True)), @@ -87,6 +95,10 @@ class CreateShipRequest(BaseModel): max_session_num: int = Field( default=1, gt=0, description="Maximum number of sessions that can use this ship" ) + force_create: bool = Field( + default=False, + description="If True, skip all reuse logic and always create a new container" + ) class ShipResponse(BaseModel): @@ -127,6 +139,12 @@ class ExtendTTLRequest(BaseModel): ttl: int = Field(..., gt=0, description="New TTL in seconds") +class StartShipRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + ttl: int = Field(default=3600, gt=0, description="TTL in seconds for the started ship") + + class ErrorResponse(BaseModel): detail: str diff --git a/pkgs/bay/app/routes/sessions.py b/pkgs/bay/app/routes/sessions.py new file mode 100644 index 0000000..ec1696e --- /dev/null +++ b/pkgs/bay/app/routes/sessions.py @@ -0,0 +1,226 @@ +"""Session management endpoints for dashboard""" + +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel +from typing import List, Optional +from datetime import datetime, timezone +from app.database import db_service +from app.auth import verify_token + +router = APIRouter() + + +def is_session_active(expires_at: datetime, now: datetime) -> bool: + """Check if session is active, handling timezone-naive datetimes""" + if expires_at is None: + return False + # If expires_at is naive, make it aware (assume UTC) + if expires_at.tzinfo is None: + expires_at = expires_at.replace(tzinfo=timezone.utc) + return expires_at > now + + +class SessionResponse(BaseModel): + id: str + session_id: str + ship_id: str + created_at: datetime + last_activity: datetime + expires_at: datetime + initial_ttl: int + is_active: bool + + +class SessionListResponse(BaseModel): + sessions: List[SessionResponse] + total: int + + +class ShipSessionsResponse(BaseModel): + ship_id: str + sessions: List[SessionResponse] + total: int + + +@router.get("/sessions", response_model=SessionListResponse) +async def list_sessions(token: str = Depends(verify_token)): + """List all sessions""" + from sqlmodel import select + from app.models import SessionShip + + session = db_service.get_session() + try: + result = await session.execute(select(SessionShip)) + all_sessions = list(result.scalars().all()) + + now = datetime.now(timezone.utc) + sessions = [ + SessionResponse( + id=s.id, + session_id=s.session_id, + ship_id=s.ship_id, + created_at=s.created_at, + last_activity=s.last_activity, + expires_at=s.expires_at, + initial_ttl=s.initial_ttl, + is_active=is_session_active(s.expires_at, now) + ) + for s in all_sessions + ] + + return SessionListResponse( + sessions=sessions, + total=len(sessions) + ) + finally: + await session.close() + + +@router.get("/sessions/{session_id}", response_model=SessionResponse) +async def get_session_detail(session_id: str, token: str = Depends(verify_token)): + """Get session details by session_id""" + from sqlmodel import select + from app.models import SessionShip + + session = db_service.get_session() + try: + statement = select(SessionShip).where(SessionShip.session_id == session_id) + result = await session.execute(statement) + session_ship = result.scalar_one_or_none() + + if not session_ship: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Session not found" + ) + + now = datetime.now(timezone.utc) + return SessionResponse( + id=session_ship.id, + session_id=session_ship.session_id, + ship_id=session_ship.ship_id, + created_at=session_ship.created_at, + last_activity=session_ship.last_activity, + expires_at=session_ship.expires_at, + initial_ttl=session_ship.initial_ttl, + is_active=is_session_active(session_ship.expires_at, now) + ) + finally: + await session.close() + + +@router.get("/ship/{ship_id}/sessions", response_model=ShipSessionsResponse) +async def get_ship_sessions(ship_id: str, token: str = Depends(verify_token)): + """Get all sessions for a specific ship""" + from sqlmodel import select + from app.models import SessionShip, Ship + + session = db_service.get_session() + try: + # Verify ship exists + ship_result = await session.execute(select(Ship).where(Ship.id == ship_id)) + ship = ship_result.scalar_one_or_none() + + if not ship: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Ship not found" + ) + + # Get sessions for this ship + statement = select(SessionShip).where(SessionShip.ship_id == ship_id) + result = await session.execute(statement) + ship_sessions = list(result.scalars().all()) + + now = datetime.now(timezone.utc) + sessions = [ + SessionResponse( + id=s.id, + session_id=s.session_id, + ship_id=s.ship_id, + created_at=s.created_at, + last_activity=s.last_activity, + expires_at=s.expires_at, + initial_ttl=s.initial_ttl, + is_active=is_session_active(s.expires_at, now) + ) + for s in ship_sessions + ] + + return ShipSessionsResponse( + ship_id=ship_id, + sessions=sessions, + total=len(sessions) + ) + finally: + await session.close() + + +class ExtendSessionTTLRequest(BaseModel): + ttl: int # TTL in seconds + + +@router.post("/sessions/{session_id}/extend-ttl", response_model=SessionResponse) +async def extend_session_ttl( + session_id: str, + request: ExtendSessionTTLRequest, + token: str = Depends(verify_token) +): + """Extend the TTL for a session""" + if request.ttl <= 0: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="TTL must be greater than 0" + ) + + session_ship = await db_service.extend_session_ttl(session_id, request.ttl) + + if not session_ship: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Session not found" + ) + + now = datetime.now(timezone.utc) + return SessionResponse( + id=session_ship.id, + session_id=session_ship.session_id, + ship_id=session_ship.ship_id, + created_at=session_ship.created_at, + last_activity=session_ship.last_activity, + expires_at=session_ship.expires_at, + initial_ttl=session_ship.initial_ttl, + is_active=is_session_active(session_ship.expires_at, now) + ) + + +@router.delete("/sessions/{session_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_session(session_id: str, token: str = Depends(verify_token)): + """Force terminate a session""" + from sqlmodel import select + from app.models import SessionShip + + session = db_service.get_session() + try: + statement = select(SessionShip).where(SessionShip.session_id == session_id) + result = await session.execute(statement) + session_ship = result.scalar_one_or_none() + + if not session_ship: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Session not found" + ) + + # Try to decrement the ship's session count (may fail if ship already deleted) + try: + await db_service.decrement_ship_session_count(session_ship.ship_id) + except Exception: + # Ship may have been deleted, ignore the error + pass + + # Delete the session + await session.delete(session_ship) + await session.commit() + finally: + await session.close() diff --git a/pkgs/bay/app/routes/ships.py b/pkgs/bay/app/routes/ships.py index 68f0b65..6a7b951 100644 --- a/pkgs/bay/app/routes/ships.py +++ b/pkgs/bay/app/routes/ships.py @@ -1,25 +1,33 @@ -from fastapi import APIRouter, Depends, HTTPException, status, Header, UploadFile, Form +import asyncio +import logging +from fastapi import APIRouter, Depends, HTTPException, status, Header, UploadFile, Form, WebSocket, Query from fastapi.responses import Response +import aiohttp from app.models import ( CreateShipRequest, ShipResponse, ExecRequest, ExecResponse, ExtendTTLRequest, + StartShipRequest, LogsResponse, UploadFileResponse, + ShipStatus, ) from app.services.ship import ship_service from app.auth import verify_token +from app.database import db_service from app.config import settings +logger = logging.getLogger(__name__) + router = APIRouter() @router.get("/ships", response_model=list[ShipResponse]) async def list_ships(token: str = Depends(verify_token)): - """Get all running ships""" - ships = await ship_service.list_active_ships() + """Get all ships (including stopped)""" + ships = await ship_service.list_all_ships() return [ShipResponse.model_validate(ship) for ship in ships] @@ -59,7 +67,7 @@ async def get_ship(ship_id: str, token: str = Depends(verify_token)): @router.delete("/ship/{ship_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_ship(ship_id: str, token: str = Depends(verify_token)): - """Delete ship environment""" + """Delete ship environment (soft delete - stops container but preserves data)""" success = await ship_service.delete_ship(ship_id) if not success: raise HTTPException( @@ -67,6 +75,16 @@ async def delete_ship(ship_id: str, token: str = Depends(verify_token)): ) +@router.delete("/ship/{ship_id}/permanent", status_code=status.HTTP_204_NO_CONTENT) +async def delete_ship_permanent(ship_id: str, token: str = Depends(verify_token)): + """Permanently delete ship environment (removes container, data, and database record)""" + success = await ship_service.delete_ship(ship_id, permanent=True) + if not success: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Ship not found" + ) + + @router.post("/ship/{ship_id}/exec", response_model=ExecResponse) async def execute_operation( ship_id: str, @@ -105,6 +123,46 @@ async def extend_ship_ttl( return ShipResponse.model_validate(ship) +@router.post("/ship/{ship_id}/start", response_model=ShipResponse) +async def start_ship( + ship_id: str, + request: StartShipRequest, + token: str = Depends(verify_token), + x_session_id: str = Header(..., alias="X-SESSION-ID"), +): + """ + Start a stopped ship container. + + This endpoint starts a stopped ship by recreating its container. + The ship data is preserved and will be mounted to the new container. + """ + try: + ship = await ship_service.start_ship(ship_id, x_session_id, request.ttl) + if not ship: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Ship not found or is currently being created" + ) + return ShipResponse.model_validate(ship) + except HTTPException: + # Re-raise HTTP exceptions as-is + raise + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail=str(e) + ) + except RuntimeError as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e) + ) + except Exception as e: + logger.error(f"Failed to start ship {ship_id}: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to start ship: {str(e)}" + ) + + @router.post("/ship/{ship_id}/upload", response_model=UploadFileResponse) async def upload_file( ship_id: str, @@ -206,3 +264,119 @@ async def download_file( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"File download failed: {str(e)}", ) + + +@router.websocket("/ship/{ship_id}/term") +async def websocket_terminal_proxy( + websocket: WebSocket, + ship_id: str, + token: str = Query(...), + session_id: str = Query(...), + cols: int = Query(80), + rows: int = Query(24), +): + """ + WebSocket proxy for interactive terminal. + + Query parameters: + - token: Authentication token + - session_id: The session ID for user isolation + - cols: Terminal columns (default 80) + - rows: Terminal rows (default 24) + """ + # Verify token (simple comparison for WebSocket, as we can't use FastAPI Depends) + if token != settings.access_token: + await websocket.close(code=4001, reason="Unauthorized") + return + + # Get ship info + ship = await db_service.get_ship(ship_id) + if not ship or ship.status != ShipStatus.RUNNING: + await websocket.close(code=4004, reason="Ship not found or not running") + return + + if not ship.ip_address: + await websocket.close(code=4004, reason="Ship IP address not available") + return + + # Verify session has access to this ship + session_ship = await db_service.get_session_ship(session_id, ship_id) + if not session_ship: + await websocket.close(code=4003, reason="Session does not have access to this ship") + return + + await websocket.accept() + + # Build WebSocket URL to Ship container + # Handle both docker mode (IP only) and docker-host mode (IP:port) + if ":" in ship.ip_address: + # docker-host mode: address already has port (e.g., "127.0.0.1:39314") + ship_ws_url = f"ws://{ship.ip_address}/term/ws?session_id={session_id}&cols={cols}&rows={rows}" + else: + # docker mode: need to add the default port + ship_ws_url = f"ws://{ship.ip_address}:{settings.ship_container_port}/term/ws?session_id={session_id}&cols={cols}&rows={rows}" + + logger.info(f"Proxying terminal WebSocket for ship {ship_id} to {ship_ws_url}") + + ship_ws = None + + try: + # Connect to Ship's WebSocket + async with aiohttp.ClientSession() as http_session: + async with http_session.ws_connect(ship_ws_url) as ship_ws: + # Create tasks for bidirectional forwarding + async def forward_to_ship(): + """Forward messages from frontend to Ship""" + try: + while True: + message = await websocket.receive() + if message["type"] == "websocket.disconnect": + break + if "text" in message: + await ship_ws.send_str(message["text"]) + elif "bytes" in message: + await ship_ws.send_bytes(message["bytes"]) + except Exception as e: + logger.debug(f"Forward to ship ended: {e}") + + async def forward_to_frontend(): + """Forward messages from Ship to frontend""" + try: + async for msg in ship_ws: + if msg.type == aiohttp.WSMsgType.TEXT: + await websocket.send_text(msg.data) + elif msg.type == aiohttp.WSMsgType.BINARY: + await websocket.send_bytes(msg.data) + elif msg.type == aiohttp.WSMsgType.CLOSED: + break + elif msg.type == aiohttp.WSMsgType.ERROR: + logger.error(f"Ship WebSocket error: {ship_ws.exception()}") + break + except Exception as e: + logger.debug(f"Forward to frontend ended: {e}") + + # Run both tasks concurrently + await asyncio.gather( + forward_to_ship(), + forward_to_frontend(), + return_exceptions=True, + ) + + except aiohttp.ClientError as e: + logger.error(f"Failed to connect to Ship WebSocket: {e}") + try: + await websocket.close(code=1011, reason=f"Failed to connect to Ship: {str(e)}") + except Exception: + pass + except Exception as e: + logger.error(f"Terminal proxy error: {e}") + try: + await websocket.close(code=1011, reason=str(e)) + except Exception: + pass + finally: + # Update session activity + try: + await db_service.update_session_activity(session_id, ship_id) + except Exception: + pass diff --git a/pkgs/bay/app/routes/stat.py b/pkgs/bay/app/routes/stat.py index e9ccb55..5a34e80 100644 --- a/pkgs/bay/app/routes/stat.py +++ b/pkgs/bay/app/routes/stat.py @@ -1,8 +1,12 @@ """Statistics and version information endpoints""" -from fastapi import APIRouter +from fastapi import APIRouter, Depends +from pydantic import BaseModel +from typing import Optional import tomli from pathlib import Path +from app.database import db_service +from app.auth import verify_token router = APIRouter() @@ -18,6 +22,26 @@ def get_version() -> str: return "unknown" +class ShipStats(BaseModel): + total: int + running: int + stopped: int + creating: int + + +class SessionStats(BaseModel): + total: int + active: int + + +class OverviewResponse(BaseModel): + service: str + version: str + status: str + ships: ShipStats + sessions: SessionStats + + @router.get("/stat") async def get_stat(): """Get service statistics and version information""" @@ -27,3 +51,56 @@ async def get_stat(): "status": "running", "author": "AstrBot Team", } + + +@router.get("/stat/overview", response_model=OverviewResponse) +async def get_overview(token: str = Depends(verify_token)): + """Get system overview statistics for dashboard""" + from sqlmodel import select + from app.models import Ship, SessionShip, ShipStatus + from datetime import datetime, timezone + + session = db_service.get_session() + try: + # Get ship statistics + all_ships_result = await session.execute(select(Ship)) + all_ships = list(all_ships_result.scalars().all()) + + running_ships = [s for s in all_ships if s.status == ShipStatus.RUNNING] + stopped_ships = [s for s in all_ships if s.status == ShipStatus.STOPPED] + creating_ships = [s for s in all_ships if s.status == ShipStatus.CREATING] + + # Get session statistics + all_sessions_result = await session.execute(select(SessionShip)) + all_sessions = list(all_sessions_result.scalars().all()) + + now = datetime.now(timezone.utc) + # Handle both timezone-aware and timezone-naive datetimes + def is_session_active(s) -> bool: + expires_at = s.expires_at + if expires_at is None: + return False + # If expires_at is naive, make it aware (assume UTC) + if expires_at.tzinfo is None: + expires_at = expires_at.replace(tzinfo=timezone.utc) + return expires_at > now + + active_sessions = [s for s in all_sessions if is_session_active(s)] + + return OverviewResponse( + service="bay", + version=get_version(), + status="running", + ships=ShipStats( + total=len(all_ships), + running=len(running_ships), + stopped=len(stopped_ships), + creating=len(creating_ships) + ), + sessions=SessionStats( + total=len(all_sessions), + active=len(active_sessions) + ) + ) + finally: + await session.close() diff --git a/pkgs/bay/app/services/ship/service.py b/pkgs/bay/app/services/ship/service.py index ab69407..e33c2c8 100644 --- a/pkgs/bay/app/services/ship/service.py +++ b/pkgs/bay/app/services/ship/service.py @@ -13,6 +13,7 @@ from app.config import settings from app.models import ( Ship, + ShipStatus, CreateShipRequest, ExecRequest, ExecResponse, @@ -39,92 +40,119 @@ def __init__(self): self._cleanup_tasks: Dict[str, asyncio.Task] = {} async def create_ship(self, request: CreateShipRequest, session_id: str) -> Ship: - """Create a new ship or reuse an existing one for the session.""" - # First, check if this session already has an active running ship - active_ship = await db_service.find_active_ship_for_session(session_id) - if active_ship: - # Verify that the container actually exists and is running - if active_ship.container_id and await get_driver().is_container_running( - active_ship.container_id - ): - # Update last activity and return the existing active ship - await db_service.update_session_activity(session_id, active_ship.id) + """Create a new ship or reuse an existing one for the session. + + If request.force_create is True, skip all reuse logic and always create a new container. + """ + # If force_create is True, skip all reuse logic + if not request.force_create: + # First, check if this session already has an active running ship + active_ship = await db_service.find_active_ship_for_session(session_id) + if active_ship: + # Verify that the container actually exists and is running + if active_ship.container_id and await get_driver().is_container_running( + active_ship.container_id + ): + # Update last activity and return the existing active ship + await db_service.update_session_activity(session_id, active_ship.id) + logger.info( + f"Session {session_id} already has active ship {active_ship.id}, returning it" + ) + return active_ship + else: + # Container doesn't exist or isn't running, mark ship as stopped and restore it + logger.warning( + f"Ship {active_ship.id} is marked active but container is not running, restoring..." + ) + active_ship.status = ShipStatus.STOPPED + await db_service.update_ship(active_ship) + # Restore the ship + return await self._restore_ship(active_ship, request, session_id) + + # Second, check if this session has a stopped ship with existing data + stopped_ship = await db_service.find_stopped_ship_for_session(session_id) + if stopped_ship and get_driver().ship_data_exists(stopped_ship.id): + # Restore the stopped ship logger.info( - f"Session {session_id} already has active ship {active_ship.id}, returning it" - ) - return active_ship - else: - # Container doesn't exist or isn't running, mark ship as stopped and restore it - logger.warning( - f"Ship {active_ship.id} is marked active but container is not running, restoring..." - ) - active_ship.status = 0 - await db_service.update_ship(active_ship) - # Restore the ship - return await self._restore_ship(active_ship, request, session_id) - - # Second, check if this session has a stopped ship with existing data - stopped_ship = await db_service.find_stopped_ship_for_session(session_id) - if stopped_ship and get_driver().ship_data_exists(stopped_ship.id): - # Restore the stopped ship - logger.info( - f"Restoring stopped ship {stopped_ship.id} for session {session_id}" - ) - return await self._restore_ship(stopped_ship, request, session_id) - - # Third, try to find an available ship that can accept this session - available_ship = await db_service.find_available_ship(session_id) - - if available_ship: - # Verify that the container actually exists and is running - if ( - not available_ship.container_id - or not await get_driver().is_container_running( - available_ship.container_id - ) - ): - # Container doesn't exist or isn't running, mark ship as stopped - logger.warning( - f"Ship {available_ship.id} is marked active but container is not running, marking as stopped" - ) - available_ship.status = 0 - await db_service.update_ship(available_ship) - # Don't use this ship, continue to create a new one - available_ship = None - - if available_ship: - # Check if this session already has access to this ship - existing_session = await db_service.get_session_ship( - session_id, available_ship.id - ) - - if existing_session: - # Update last activity and return existing ship - await db_service.update_session_activity(session_id, available_ship.id) - return available_ship - else: - # Calculate expiration time for this session - expires_at = datetime.now(timezone.utc) + timedelta(seconds=request.ttl) - - # Add this session to the ship - session_ship = SessionShip( - session_id=session_id, - ship_id=available_ship.id, - expires_at=expires_at, - initial_ttl=request.ttl, + f"Restoring stopped ship {stopped_ship.id} for session {session_id}" ) - await db_service.create_session_ship(session_ship) - available_ship = await db_service.increment_ship_session_count(available_ship.id) - - # Recalculate ship's TTL based on all sessions' expiration times - await self._recalculate_and_schedule_cleanup(available_ship.id) - - logger.info( - f"Session {session_id} joined ship {available_ship.id}, expires at {expires_at}" + return await self._restore_ship(stopped_ship, request, session_id) + + # Third, try to find an available ship that can accept this session + # NOTE: This only applies to NEW sessions that don't have any ship yet. + # If a session already has a ship (active or stopped), it should NOT join another ship. + # The checks in steps 1 and 2 above ensure we only reach here for truly new sessions. + logger.debug(f"Looking for available ship for new session {session_id}") + available_ship = await db_service.find_available_ship(session_id) + logger.debug(f"find_available_ship returned: {available_ship}") + + if available_ship: + # Verify that the container actually exists and is running + logger.debug(f"Checking container status for ship {available_ship.id}, container_id: {available_ship.container_id}") + if ( + not available_ship.container_id + or not await get_driver().is_container_running( + available_ship.container_id + ) + ): + # Container doesn't exist or isn't running, mark ship as stopped + logger.warning( + f"Ship {available_ship.id} is marked active but container is not running, marking as stopped" + ) + available_ship.status = ShipStatus.STOPPED + await db_service.update_ship(available_ship) + # Don't use this ship, continue to create a new one + available_ship = None + + if available_ship: + # Check if this session already has access to this ship + logger.debug(f"Checking if session {session_id} already has access to ship {available_ship.id}") + existing_session = await db_service.get_session_ship( + session_id, available_ship.id ) - return available_ship + logger.debug(f"Existing session: {existing_session}") + + if existing_session: + # Update last activity and return existing ship + logger.info(f"Session {session_id} already has access to ship {available_ship.id}, updating activity") + await db_service.update_session_activity(session_id, available_ship.id) + return available_ship + else: + # Calculate expiration time for this session + expires_at = datetime.now(timezone.utc) + timedelta(seconds=request.ttl) + + # Add this session to the ship + logger.info(f"Adding session {session_id} to ship {available_ship.id}") + session_ship = SessionShip( + session_id=session_id, + ship_id=available_ship.id, + expires_at=expires_at, + initial_ttl=request.ttl, + ) + await db_service.create_session_ship(session_ship) + logger.debug(f"Created session_ship record: {session_ship.id}") + + updated_ship = await db_service.increment_ship_session_count(available_ship.id) + logger.debug(f"increment_ship_session_count returned: {updated_ship}") + + if updated_ship is None: + logger.error(f"Failed to increment session count for ship {available_ship.id}") + raise ValueError(f"Failed to update ship {available_ship.id} session count") + + available_ship = updated_ship + + # Recalculate ship's TTL based on all sessions' expiration times + logger.debug(f"Recalculating cleanup for ship {available_ship.id}") + await self._recalculate_and_schedule_cleanup(available_ship.id) + + logger.info( + f"Session {session_id} joined ship {available_ship.id}, expires at {expires_at}" + ) + return available_ship + else: + logger.info(f"force_create=True, skipping reuse logic for session {session_id}") - # Fourth, no available ship found, create a new one + # Fourth (or First if force_create), no available ship found, create a new one # Check ship limits if settings.behavior_after_max_ship == "reject": active_count = await db_service.count_active_ships() @@ -134,8 +162,9 @@ async def create_ship(self, request: CreateShipRequest, session_id: str) -> Ship # Wait for available slot await self._wait_for_available_slot() - # Create ship record - ship = Ship(ttl=request.ttl, max_session_num=request.max_session_num) + # Create ship record with CREATING status (status=2) + # This prevents status_checker from marking it as stopped during creation + ship = Ship(ttl=request.ttl, max_session_num=request.max_session_num, status=ShipStatus.CREATING) ship = await db_service.create_ship(ship) try: @@ -179,6 +208,10 @@ async def create_ship(self, request: CreateShipRequest, session_id: str) -> Ship await db_service.create_session_ship(session_ship) ship = await db_service.increment_ship_session_count(ship.id) + # Mark ship as RUNNING now that it's fully ready + ship.status = ShipStatus.RUNNING + ship = await db_service.update_ship(ship) + # Schedule TTL cleanup await self._schedule_cleanup(ship.id, ship.ttl) @@ -222,7 +255,7 @@ async def delete_ship(self, ship_id: str, permanent: bool = False) -> bool: # For soft delete, return False if ship is already stopped # (cannot "stop" an already stopped ship) - if not permanent and ship.status == 0: + if not permanent and ship.status == ShipStatus.STOPPED: return False # Cancel cleanup task if exists @@ -240,28 +273,61 @@ async def delete_ship(self, ship_id: str, permanent: bool = False) -> bool: logger.error(f"Failed to stop container for ship {ship_id}: {e}") if permanent: - # Permanent delete: remove from database + # Permanent delete: first delete all session-ship relationships, then remove ship from database + deleted_session_ids = await db_service.delete_sessions_for_ship(ship_id) + if deleted_session_ids: + logger.info(f"Deleted {len(deleted_session_ids)} session(s) for ship {ship_id}: {deleted_session_ids}") logger.info(f"Permanently deleting ship {ship_id} from database") return await db_service.delete_ship(ship_id) else: # Soft delete: mark as stopped, keep database record for restore - ship.status = 0 + ship.status = ShipStatus.STOPPED ship.container_id = None # Clear container ID since it's stopped await db_service.update_ship(ship) + + # Expire all sessions for this ship so they show as inactive + expired_count = await db_service.expire_sessions_for_ship(ship_id) + if expired_count > 0: + logger.info(f"Expired {expired_count} session(s) for stopped ship {ship_id}") + logger.info(f"Ship {ship_id} stopped (soft delete), data preserved for restore") return True - async def extend_ttl(self, ship_id: str, new_ttl: int) -> Optional[Ship]: - """Extend ship TTL.""" + async def extend_ttl(self, ship_id: str, additional_ttl: int) -> Optional[Ship]: + """Extend ship TTL by adding additional time to all sessions. + + Args: + ship_id: The ship ID + additional_ttl: Additional time in seconds to add + + Returns: + Updated ship or None if not found + """ ship = await db_service.get_ship(ship_id) - if not ship or ship.status == 0: + if not ship or ship.status == ShipStatus.STOPPED: return None - ship.ttl = new_ttl + # Get all sessions for this ship and extend their expiration times + all_sessions = await db_service.get_sessions_for_ship(ship_id) + + for session in all_sessions: + # Add additional time to the expiration + if session.expires_at.tzinfo is None: + session.expires_at = session.expires_at.replace(tzinfo=timezone.utc) + session.expires_at = session.expires_at + timedelta(seconds=additional_ttl) + # Also update initial_ttl to reflect the new base TTL + session.initial_ttl = session.initial_ttl + additional_ttl + await db_service.update_session_ship(session) + + # Update ship's ttl configuration + ship.ttl = ship.ttl + additional_ttl ship = await db_service.update_ship(ship) - # Reschedule cleanup - await self._schedule_cleanup(ship_id, new_ttl) + # Recalculate and reschedule cleanup based on new session expiration times + await self._recalculate_and_schedule_cleanup(ship_id) + + # Set expires_at for the response + await self._set_ship_expires_at(ship) return ship @@ -270,7 +336,7 @@ async def execute_operation( ) -> ExecResponse: """Execute operation on ship.""" ship = await db_service.get_ship(ship_id) - if not ship or ship.status == 0: + if not ship or ship.status != ShipStatus.RUNNING: return ExecResponse(success=False, error="Ship not found or not running") if not ship.ip_address: @@ -311,6 +377,14 @@ async def list_active_ships(self) -> List[Ship]: await self._set_ship_expires_at(ship) return ships + async def list_all_ships(self) -> List[Ship]: + """List all ships including stopped ones.""" + ships = await db_service.list_all_ships() + # Calculate and set the actual expiration time for each ship + for ship in ships: + await self._set_ship_expires_at(ship) + return ships + async def upload_file( self, ship_id: str, file_content: bytes, file_path: str, session_id: str ) -> UploadFileResponse: @@ -324,7 +398,7 @@ async def upload_file( ) ship = await db_service.get_ship(ship_id) - if not ship or ship.status == 0: + if not ship or ship.status != ShipStatus.RUNNING: return UploadFileResponse( success=False, error="Ship not found or not running", @@ -370,7 +444,7 @@ async def download_file( tuple: (success, file_content, error_message) """ ship = await db_service.get_ship(ship_id) - if not ship or ship.status == 0: + if not ship or ship.status != ShipStatus.RUNNING: return (False, b"", "Ship not found or not running") if not ship.ip_address: @@ -456,10 +530,15 @@ async def _recalculate_and_schedule_cleanup(self, ship_id: str): async def _set_ship_expires_at(self, ship: Ship): """Calculate and set ship's expiration time based on all sessions.""" - if ship.status == 0: + if ship.status == ShipStatus.STOPPED: # Stopped ships don't have an expiration time ship.expires_at = None return + + if ship.status == ShipStatus.CREATING: + # Creating ships don't have an expiration time yet + ship.expires_at = None + return # Get all sessions for this ship all_sessions = await db_service.get_sessions_for_ship(ship.id) @@ -514,9 +593,9 @@ async def _cleanup_ship_after_delay(self, ship_id: str, ttl: int): await asyncio.sleep(ttl) ship = await db_service.get_ship(ship_id) - if ship and ship.status == 1: + if ship and ship.status == ShipStatus.RUNNING: # Mark as stopped - ship.status = 0 + ship.status = ShipStatus.STOPPED await db_service.update_ship(ship) # Stop container (but keep ship_data directory) @@ -534,6 +613,48 @@ async def _cleanup_ship_after_delay(self, ship_id: str, ttl: int): if ship_id in self._cleanup_tasks: del self._cleanup_tasks[ship_id] + async def start_ship( + self, ship_id: str, session_id: str, ttl: int = 3600 + ) -> Optional[Ship]: + """ + Start a stopped ship container. + + This is a public API that allows manually starting a stopped ship. + Unlike _restore_ship which is called during create_ship flow, + this method can be called directly to start any stopped ship. + + Args: + ship_id: The ID of the ship to start + session_id: The session ID requesting the start + ttl: TTL for the ship (default 1 hour) + + Returns: + The started Ship, or None if ship not found or already running + """ + ship = await db_service.get_ship(ship_id) + if not ship: + return None + + if ship.status == ShipStatus.RUNNING: + # Already running, just return it + await self._set_ship_expires_at(ship) + return ship + + if ship.status == ShipStatus.CREATING: + # Ship is being created, cannot start + return None + + # Ship is stopped, restore it + try: + # Create a minimal CreateShipRequest for restoration + from app.models import CreateShipRequest, ShipSpec + request = CreateShipRequest(ttl=ttl) + + return await self._restore_ship(ship, request, session_id) + except Exception as e: + logger.error(f"Failed to start ship {ship_id}: {e}") + raise + async def _restore_ship( self, ship: Ship, request: CreateShipRequest, session_id: str ) -> Ship: @@ -547,7 +668,7 @@ async def _restore_ship( # Update ship with new container info ship.container_id = container_info.container_id ship.ip_address = container_info.ip_address - ship.status = 1 # Mark as running + ship.status = ShipStatus.RUNNING # Mark as running ship.ttl = request.ttl # Update TTL ship = await db_service.update_ship(ship) @@ -564,7 +685,7 @@ async def _restore_ship( logger.error(f"Restored ship {ship.id} failed health check") if ship.container_id: await get_driver().stop_ship_container(ship.container_id) - ship.status = 0 + ship.status = ShipStatus.STOPPED await db_service.update_ship(ship) raise RuntimeError( f"Ship failed to become ready within {settings.ship_health_check_timeout} seconds" @@ -594,7 +715,7 @@ async def _restore_ship( except Exception as e: # Mark ship as stopped on failure - ship.status = 0 + ship.status = ShipStatus.STOPPED await db_service.update_ship(ship) logger.error(f"Failed to restore ship {ship.id}: {e}") raise diff --git a/pkgs/bay/app/services/status/status_checker.py b/pkgs/bay/app/services/status/status_checker.py index 8f1984c..5464936 100644 --- a/pkgs/bay/app/services/status/status_checker.py +++ b/pkgs/bay/app/services/status/status_checker.py @@ -4,6 +4,7 @@ from app.database import db_service from app.drivers import get_driver +from app.models import ShipStatus logger = logging.getLogger(__name__) @@ -65,17 +66,22 @@ async def _run(self): async def _check_all_ships(self): """Check status of all active ships and update database if needed""" try: - # Get all ships from database (both active and stopped) + # Get all ships from database (running and creating only for status sync) ships = await db_service.list_active_ships() - if not ships: + if ships: + logger.info(f"Checking status of {len(ships)} active ships") + else: logger.debug("No active ships to check") - return - - logger.info(f"Checking status of {len(ships)} active ships") updated_count = 0 for ship in ships: + # Skip ships that are still being created + # These haven't finished initialization yet and shouldn't be checked + if ship.status == ShipStatus.CREATING: + logger.debug(f"Skipping ship {ship.id} - still in CREATING status") + continue + # Check if container is actually running if ship.container_id: is_running = await get_driver().is_container_running( @@ -83,40 +89,102 @@ async def _check_all_ships(self): ) # If ship is marked as running but container is not, update status - if ship.status == 1 and not is_running: + if ship.status == ShipStatus.RUNNING and not is_running: logger.warning( f"Ship {ship.id} is marked as running but container {ship.container_id} is not running. Updating status to stopped." ) - ship.status = 0 + ship.status = ShipStatus.STOPPED await db_service.update_ship(ship) + + # Expire all sessions for this ship so they show as inactive + expired_count = await db_service.expire_sessions_for_ship(ship.id) + if expired_count > 0: + logger.info(f"Expired {expired_count} session(s) for stopped ship {ship.id}") + updated_count += 1 # If ship is marked as stopped but container is running, update status - elif ship.status == 0 and is_running: + elif ship.status == ShipStatus.STOPPED and is_running: logger.info( f"Ship {ship.id} is marked as stopped but container {ship.container_id} is running. Updating status to running." ) - ship.status = 1 + ship.status = ShipStatus.RUNNING await db_service.update_ship(ship) updated_count += 1 else: # Ship has no container_id but is marked as running - if ship.status == 1: + if ship.status == ShipStatus.RUNNING: logger.warning( f"Ship {ship.id} is marked as running but has no container_id. Updating status to stopped." ) - ship.status = 0 + ship.status = ShipStatus.STOPPED await db_service.update_ship(ship) + + # Expire all sessions for this ship so they show as inactive + expired_count = await db_service.expire_sessions_for_ship(ship.id) + if expired_count > 0: + logger.info(f"Expired {expired_count} session(s) for stopped ship {ship.id}") + updated_count += 1 if updated_count > 0: logger.info(f"Updated status for {updated_count} ships") else: logger.debug("All ships are in sync with actual container status") + + # Also check for stopped ships with active sessions (data inconsistency fix) + await self._fix_stopped_ships_with_active_sessions() except Exception as e: logger.error(f"Failed to check ship status: {e}", exc_info=True) + async def _fix_stopped_ships_with_active_sessions(self): + """Fix data inconsistency: expire sessions for stopped ships that still have active sessions. + + This handles the case where a ship was stopped before the expire_sessions_for_ship + logic was added, or if there was any other data inconsistency. + """ + from datetime import datetime, timezone + + try: + # Get all stopped ships + all_ships = await db_service.list_all_ships() + stopped_ships = [s for s in all_ships if s.status == ShipStatus.STOPPED] + + if not stopped_ships: + return + + now = datetime.now(timezone.utc) + fixed_count = 0 + + for ship in stopped_ships: + # Check if this ship has any active sessions + sessions = await db_service.get_sessions_for_ship(ship.id) + active_sessions = [] + + for s in sessions: + expires_at = s.expires_at + if expires_at is not None: + if expires_at.tzinfo is None: + expires_at = expires_at.replace(tzinfo=timezone.utc) + if expires_at > now: + active_sessions.append(s) + + if active_sessions: + # Expire these sessions + expired_count = await db_service.expire_sessions_for_ship(ship.id) + if expired_count > 0: + logger.info( + f"Fixed {expired_count} orphaned active session(s) for stopped ship {ship.id}" + ) + fixed_count += expired_count + + if fixed_count > 0: + logger.info(f"Fixed {fixed_count} total orphaned active sessions") + + except Exception as e: + logger.error(f"Failed to fix stopped ships with active sessions: {e}", exc_info=True) + # Global status checker instance status_checker = StatusChecker(check_interval=60) diff --git a/pkgs/bay/dashboard/.gitignore b/pkgs/bay/dashboard/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/pkgs/bay/dashboard/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/pkgs/bay/dashboard/README.md b/pkgs/bay/dashboard/README.md new file mode 100644 index 0000000..3347489 --- /dev/null +++ b/pkgs/bay/dashboard/README.md @@ -0,0 +1,88 @@ +# Bay Dashboard + +Bay 容器管理控制台前端项目,基于 Vue 3 + TypeScript + Vite 构建。 + +## 功能特性 + +- 🚢 **容器管理**: 查看、创建、删除容器,延长 TTL +- 💬 **会话管理**: 管理容器会话连接 +- 📊 **仪表盘**: 系统状态概览和资源统计 +- 📁 **文件操作**: 上传/下载容器文件 +- 🖥️ **调试控制台**: Shell 和 Python 命令执行 +- 📜 **日志查看**: 实时查看容器日志 + +## 开发 + +### 前置要求 + +- Node.js >= 18 +- npm >= 9 + +### 安装依赖 + +```bash +npm install +``` + +### 启动开发服务器 + +```bash +npm run dev +``` + +开发服务器默认运行在 `http://localhost:3000`,API 请求会自动代理到 `http://localhost:8156`。 + +### 启动 Bay 后端 + +在另一个终端中,使用 docker-host 模式启动 Bay: + +```bash +cd ../ +# 参考 tests/scripts/test_docker_host.sh +export CONTAINER_DRIVER=docker-host +python run.py +``` + +### 构建生产版本 + +```bash +npm run build +``` + +构建产物输出到 `dist/` 目录。 + +### 类型检查 + +```bash +npm run type-check +``` + +## 项目结构 + +``` +src/ +├── api/ # API 客户端和服务 +│ ├── client.ts # Axios 封装 +│ └── index.ts # API 接口定义 +├── layouts/ # 布局组件 +├── router/ # Vue Router 配置 +├── stores/ # Pinia 状态管理 +├── types/ # TypeScript 类型定义 +└── views/ # 页面视图组件 +``` + +## 配置说明 + +### 开发环境 + +开发模式下,Vite 会将 `/api` 开头的请求代理到 Bay 后端服务(默认 `localhost:8156`)。 + +### 生产环境 + +生产部署时,需要配置反向代理将 API 请求转发到 Bay 服务,或者在登录时配置正确的 API 地址。 + +## 认证 + +Dashboard 使用 Bearer Token 认证。登录时输入 Bay 服务配置的 `access_token`(默认为 `secret-token`,仅供开发测试)。 + +Token 会保存在浏览器的 LocalStorage 中。 diff --git a/pkgs/bay/dashboard/index.html b/pkgs/bay/dashboard/index.html new file mode 100644 index 0000000..57f8692 --- /dev/null +++ b/pkgs/bay/dashboard/index.html @@ -0,0 +1,13 @@ + + + + + + + Bay Dashboard + + +
+ + + diff --git a/pkgs/bay/dashboard/package-lock.json b/pkgs/bay/dashboard/package-lock.json new file mode 100644 index 0000000..1f5f22e --- /dev/null +++ b/pkgs/bay/dashboard/package-lock.json @@ -0,0 +1,2882 @@ +{ + "name": "dashboard", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "dashboard", + "version": "0.0.0", + "dependencies": { + "@guolao/vue-monaco-editor": "^1.6.0", + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", + "axios": "^1.13.3", + "monaco-editor": "^0.55.1", + "pinia": "^3.0.4", + "vue": "^3.5.24", + "vue-router": "^4.6.4" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4.1.18", + "@tailwindcss/vite": "^4.1.18", + "@types/node": "^24.10.9", + "@vitejs/plugin-vue": "^6.0.1", + "@vue/tsconfig": "^0.8.1", + "autoprefixer": "^10.4.23", + "postcss": "^8.5.6", + "tailwindcss": "^4.1.18", + "typescript": "~5.9.3", + "vite": "^7.2.4", + "vue-tsc": "^3.1.4" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz", + "integrity": "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.6" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz", + "integrity": "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@guolao/vue-monaco-editor": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@guolao/vue-monaco-editor/-/vue-monaco-editor-1.6.0.tgz", + "integrity": "sha512-w2IiJ6eJGGeuIgCK6EKZOAfhHTTUB5aZwslzwGbZ5e89Hb4avx6++GkLTW8p84Sng/arFMjLPPxSBI56cFudyQ==", + "license": "MIT", + "dependencies": { + "@monaco-editor/loader": "^1.6.1", + "vue-demi": "latest" + }, + "peerDependencies": { + "@vue/composition-api": "^1.7.2", + "monaco-editor": ">=0.43.0", + "vue": "^2.6.14 || >=3.0.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/@guolao/vue-monaco-editor/node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@monaco-editor/loader": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz", + "integrity": "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==", + "license": "MIT", + "dependencies": { + "state-local": "^1.0.6" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.53", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz", + "integrity": "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.56.0.tgz", + "integrity": "sha512-LNKIPA5k8PF1+jAFomGe3qN3bbIgJe/IlpDBwuVjrDKrJhVWywgnJvflMt/zkbVNLFtF1+94SljYQS6e99klnw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.56.0.tgz", + "integrity": "sha512-lfbVUbelYqXlYiU/HApNMJzT1E87UPGvzveGg2h0ktUNlOCxKlWuJ9jtfvs1sKHdwU4fzY7Pl8sAl49/XaEk6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.56.0.tgz", + "integrity": "sha512-EgxD1ocWfhoD6xSOeEEwyE7tDvwTgZc8Bss7wCWe+uc7wO8G34HHCUH+Q6cHqJubxIAnQzAsyUsClt0yFLu06w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.56.0.tgz", + "integrity": "sha512-1vXe1vcMOssb/hOF8iv52A7feWW2xnu+c8BV4t1F//m9QVLTfNVpEdja5ia762j/UEJe2Z1jAmEqZAK42tVW3g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.56.0.tgz", + "integrity": "sha512-bof7fbIlvqsyv/DtaXSck4VYQ9lPtoWNFCB/JY4snlFuJREXfZnm+Ej6yaCHfQvofJDXLDMTVxWscVSuQvVWUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.56.0.tgz", + "integrity": "sha512-KNa6lYHloW+7lTEkYGa37fpvPq+NKG/EHKM8+G/g9WDU7ls4sMqbVRV78J6LdNuVaeeK5WB9/9VAFbKxcbXKYg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.56.0.tgz", + "integrity": "sha512-E8jKK87uOvLrrLN28jnAAAChNq5LeCd2mGgZF+fGF5D507WlG/Noct3lP/QzQ6MrqJ5BCKNwI9ipADB6jyiq2A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.56.0.tgz", + "integrity": "sha512-jQosa5FMYF5Z6prEpTCCmzCXz6eKr/tCBssSmQGEeozA9tkRUty/5Vx06ibaOP9RCrW1Pvb8yp3gvZhHwTDsJw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.56.0.tgz", + "integrity": "sha512-uQVoKkrC1KGEV6udrdVahASIsaF8h7iLG0U0W+Xn14ucFwi6uS539PsAr24IEF9/FoDtzMeeJXJIBo5RkbNWvQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.56.0.tgz", + "integrity": "sha512-vLZ1yJKLxhQLFKTs42RwTwa6zkGln+bnXc8ueFGMYmBTLfNu58sl5/eXyxRa2RarTkJbXl8TKPgfS6V5ijNqEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.56.0.tgz", + "integrity": "sha512-FWfHOCub564kSE3xJQLLIC/hbKqHSVxy8vY75/YHHzWvbJL7aYJkdgwD/xGfUlL5UV2SB7otapLrcCj2xnF1dg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.56.0.tgz", + "integrity": "sha512-z1EkujxIh7nbrKL1lmIpqFTc/sr0u8Uk0zK/qIEFldbt6EDKWFk/pxFq3gYj4Bjn3aa9eEhYRlL3H8ZbPT1xvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.56.0.tgz", + "integrity": "sha512-iNFTluqgdoQC7AIE8Q34R3AuPrJGJirj5wMUErxj22deOcY7XwZRaqYmB6ZKFHoVGqRcRd0mqO+845jAibKCkw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.56.0.tgz", + "integrity": "sha512-MtMeFVlD2LIKjp2sE2xM2slq3Zxf9zwVuw0jemsxvh1QOpHSsSzfNOTH9uYW9i1MXFxUSMmLpeVeUzoNOKBaWg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.56.0.tgz", + "integrity": "sha512-in+v6wiHdzzVhYKXIk5U74dEZHdKN9KH0Q4ANHOTvyXPG41bajYRsy7a8TPKbYPl34hU7PP7hMVHRvv/5aCSew==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.56.0.tgz", + "integrity": "sha512-yni2raKHB8m9NQpI9fPVwN754mn6dHQSbDTwxdr9SE0ks38DTjLMMBjrwvB5+mXrX+C0npX0CVeCUcvvvD8CNQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.56.0.tgz", + "integrity": "sha512-zhLLJx9nQPu7wezbxt2ut+CI4YlXi68ndEve16tPc/iwoylWS9B3FxpLS2PkmfYgDQtosah07Mj9E0khc3Y+vQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.56.0.tgz", + "integrity": "sha512-MVC6UDp16ZSH7x4rtuJPAEoE1RwS8N4oK9DLHy3FTEdFoUTCFVzMfJl/BVJ330C+hx8FfprA5Wqx4FhZXkj2Kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.56.0.tgz", + "integrity": "sha512-ZhGH1eA4Qv0lxaV00azCIS1ChedK0V32952Md3FtnxSqZTBTd6tgil4nZT5cU8B+SIw3PFYkvyR4FKo2oyZIHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.56.0.tgz", + "integrity": "sha512-O16XcmyDeFI9879pEcmtWvD/2nyxR9mF7Gs44lf1vGGx8Vg2DRNx11aVXBEqOQhWb92WN4z7fW/q4+2NYzCbBA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.56.0.tgz", + "integrity": "sha512-LhN/Reh+7F3RCgQIRbgw8ZMwUwyqJM+8pXNT6IIJAqm2IdKkzpCh/V9EdgOMBKuebIrzswqy4ATlrDgiOwbRcQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.56.0.tgz", + "integrity": "sha512-kbFsOObXp3LBULg1d3JIUQMa9Kv4UitDmpS+k0tinPBz3watcUiV2/LUDMMucA6pZO3WGE27P7DsfaN54l9ing==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.56.0.tgz", + "integrity": "sha512-vSSgny54D6P4vf2izbtFm/TcWYedw7f8eBrOiGGecyHyQB9q4Kqentjaj8hToe+995nob/Wv48pDqL5a62EWtg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.56.0.tgz", + "integrity": "sha512-FeCnkPCTHQJFbiGG49KjV5YGW/8b9rrXAM2Mz2kiIoktq2qsJxRD5giEMEOD2lPdgs72upzefaUvS+nc8E3UzQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.56.0.tgz", + "integrity": "sha512-H8AE9Ur/t0+1VXujj90w0HrSOuv0Nq9r1vSZF2t5km20NTfosQsGGUXDaKdQZzwuLts7IyL1fYT4hM95TI9c4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tailwindcss/node": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz", + "integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "enhanced-resolve": "^5.18.3", + "jiti": "^2.6.1", + "lightningcss": "1.30.2", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.1.18" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz", + "integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-x64": "4.1.18", + "@tailwindcss/oxide-freebsd-x64": "4.1.18", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-x64-musl": "4.1.18", + "@tailwindcss/oxide-wasm32-wasi": "4.1.18", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz", + "integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz", + "integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz", + "integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz", + "integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz", + "integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz", + "integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz", + "integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz", + "integrity": "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz", + "integrity": "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz", + "integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.0", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz", + "integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz", + "integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.18.tgz", + "integrity": "sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.1.18", + "@tailwindcss/oxide": "4.1.18", + "postcss": "^8.4.41", + "tailwindcss": "4.1.18" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.18.tgz", + "integrity": "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.1.18", + "@tailwindcss/oxide": "4.1.18", + "tailwindcss": "4.1.18" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.10.9", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.9.tgz", + "integrity": "sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, + "node_modules/@vitejs/plugin-vue": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.3.tgz", + "integrity": "sha512-TlGPkLFLVOY3T7fZrwdvKpjprR3s4fxRln0ORDo1VQ7HHyxJwTlrjKU3kpVWTlaAjIEuCTokmjkZnr8Tpc925w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-beta.53" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.27", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.27.tgz", + "integrity": "sha512-DjmjBWZ4tJKxfNC1F6HyYERNHPYS7L7OPFyCrestykNdUZMFYzI9WTyvwPcaNaHlrEUwESHYsfEw3isInncZxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.27" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.27", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.27.tgz", + "integrity": "sha512-ynlcBReMgOZj2i6po+qVswtDUeeBRCTgDurjMGShbm8WYZgJ0PA4RmtebBJ0BCYol1qPv3GQF6jK7C9qoVc7lg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.27", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.27.tgz", + "integrity": "sha512-eWaYCcl/uAPInSK2Lze6IqVWaBu/itVqR5InXcHXFyles4zO++Mglt3oxdgj75BDcv1Knr9Y93nowS8U3wqhxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.27", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.27.tgz", + "integrity": "sha512-gnSBQjZA+//qDZen+6a2EdHqJ68Z7uybrMf3SPjEGgG4dicklwDVmMC1AeIHxtLVPT7sn6sH1KOO+tS6gwOUeQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@vue/shared": "3.5.27", + "entities": "^7.0.0", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.27.tgz", + "integrity": "sha512-oAFea8dZgCtVVVTEC7fv3T5CbZW9BxpFzGGxC79xakTr6ooeEqmRuvQydIiDAkglZEAd09LgVf1RoDnL54fu5w==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.27", + "@vue/shared": "3.5.27" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.27.tgz", + "integrity": "sha512-sHZu9QyDPeDmN/MRoshhggVOWE5WlGFStKFwu8G52swATgSny27hJRWteKDSUUzUH+wp+bmeNbhJnEAel/auUQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@vue/compiler-core": "3.5.27", + "@vue/compiler-dom": "3.5.27", + "@vue/compiler-ssr": "3.5.27", + "@vue/shared": "3.5.27", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.6", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.27.tgz", + "integrity": "sha512-Sj7h+JHt512fV1cTxKlYhg7qxBvack+BGncSpH+8vnN+KN95iPIcqB5rsbblX40XorP+ilO7VIKlkuu3Xq2vjw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.27", + "@vue/shared": "3.5.27" + } + }, + "node_modules/@vue/devtools-api": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.9.tgz", + "integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==", + "license": "MIT", + "dependencies": { + "@vue/devtools-kit": "^7.7.9" + } + }, + "node_modules/@vue/devtools-kit": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz", + "integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==", + "license": "MIT", + "dependencies": { + "@vue/devtools-shared": "^7.7.9", + "birpc": "^2.3.0", + "hookable": "^5.5.3", + "mitt": "^3.0.1", + "perfect-debounce": "^1.0.0", + "speakingurl": "^14.0.1", + "superjson": "^2.2.2" + } + }, + "node_modules/@vue/devtools-shared": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz", + "integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==", + "license": "MIT", + "dependencies": { + "rfdc": "^1.4.1" + } + }, + "node_modules/@vue/language-core": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.2.3.tgz", + "integrity": "sha512-VpN/GnYDzGLh44AI6i1OB/WsLXo6vwnl0EWHBelGc4TyC0yEq6azwNaed/+Tgr8anFlSdWYnMEkyHJDPe7ii7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.27", + "@vue/compiler-dom": "^3.5.0", + "@vue/shared": "^3.5.0", + "alien-signals": "^3.0.0", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1", + "picomatch": "^4.0.2" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.27.tgz", + "integrity": "sha512-vvorxn2KXfJ0nBEnj4GYshSgsyMNFnIQah/wczXlsNXt+ijhugmW+PpJ2cNPe4V6jpnBcs0MhCODKllWG+nvoQ==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.27" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.27.tgz", + "integrity": "sha512-fxVuX/fzgzeMPn/CLQecWeDIFNt3gQVhxM0rW02Tvp/YmZfXQgcTXlakq7IMutuZ/+Ogbn+K0oct9J3JZfyk3A==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.27", + "@vue/shared": "3.5.27" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.27.tgz", + "integrity": "sha512-/QnLslQgYqSJ5aUmb5F0z0caZPGHRB8LEAQ1s81vHFM5CBfnun63rxhvE/scVb/j3TbBuoZwkJyiLCkBluMpeg==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.27", + "@vue/runtime-core": "3.5.27", + "@vue/shared": "3.5.27", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.27.tgz", + "integrity": "sha512-qOz/5thjeP1vAFc4+BY3Nr6wxyLhpeQgAE/8dDtKo6a6xdk+L4W46HDZgNmLOBUDEkFXV3G7pRiUqxjX0/2zWA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.27", + "@vue/shared": "3.5.27" + }, + "peerDependencies": { + "vue": "3.5.27" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.27.tgz", + "integrity": "sha512-dXr/3CgqXsJkZ0n9F3I4elY8wM9jMJpP3pvRG52r6m0tu/MsAFIe6JpXVGeNMd/D9F4hQynWT8Rfuj0bdm9kFQ==", + "license": "MIT" + }, + "node_modules/@vue/tsconfig": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@vue/tsconfig/-/tsconfig-0.8.1.tgz", + "integrity": "sha512-aK7feIWPXFSUhsCP9PFqPyFOcz4ENkb8hZ2pneL6m2UjCkccvaOhC/5KCKluuBufvp2KzkbdA2W2pk20vLzu3g==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "typescript": "5.x", + "vue": "^3.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, + "node_modules/@xterm/addon-fit": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", + "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==", + "license": "MIT" + }, + "node_modules/@xterm/xterm": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", + "integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==", + "license": "MIT", + "workspaces": [ + "addons/*" + ] + }, + "node_modules/alien-signals": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.1.2.tgz", + "integrity": "sha512-d9dYqZTS90WLiU0I5c6DHj/HcKkF8ZyGN3G5x8wSbslulz70KOxaqCT0hQCo9KOyhVqzqGojvNdJXoTumZOtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.4.23", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.23.tgz", + "integrity": "sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1", + "caniuse-lite": "^1.0.30001760", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/axios": { + "version": "1.13.3", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.3.tgz", + "integrity": "sha512-ERT8kdX7DZjtUm7IitEyV7InTHAF42iJuMArIiDIV5YtPanJkgw4hw5Dyg9fh0mihdWNn1GKaeIWErfe56UQ1g==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.18", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.18.tgz", + "integrity": "sha512-e23vBV1ZLfjb9apvfPk4rHVu2ry6RIr2Wfs+O324okSidrX7pTAnEJPCh/O5BtRlr7QtZI7ktOP3vsqr7Z5XoA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/birpc": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", + "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001766", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001766.tgz", + "integrity": "sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/copy-anything": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz", + "integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==", + "license": "MIT", + "dependencies": { + "is-what": "^5.2.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dompurify": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz", + "integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.278", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.278.tgz", + "integrity": "sha512-dQ0tM1svDRQOwxnXxm+twlGTjr9Upvt8UFWAgmLsxEzFQxhbti4VwxmMjsDxVC51Zo84swW7FVCXEV+VAkhuPw==", + "dev": true, + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.18.4", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz", + "integrity": "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hookable": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", + "license": "MIT" + }, + "node_modules/is-what": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz", + "integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/lightningcss": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", + "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.30.2", + "lightningcss-darwin-arm64": "1.30.2", + "lightningcss-darwin-x64": "1.30.2", + "lightningcss-freebsd-x64": "1.30.2", + "lightningcss-linux-arm-gnueabihf": "1.30.2", + "lightningcss-linux-arm64-gnu": "1.30.2", + "lightningcss-linux-arm64-musl": "1.30.2", + "lightningcss-linux-x64-gnu": "1.30.2", + "lightningcss-linux-x64-musl": "1.30.2", + "lightningcss-win32-arm64-msvc": "1.30.2", + "lightningcss-win32-x64-msvc": "1.30.2" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", + "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", + "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", + "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", + "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", + "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", + "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", + "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", + "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", + "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", + "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", + "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/marked": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", + "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "license": "MIT" + }, + "node_modules/monaco-editor": { + "version": "0.55.1", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz", + "integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==", + "license": "MIT", + "dependencies": { + "dompurify": "3.2.7", + "marked": "14.0.0" + } + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pinia": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz", + "integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^7.7.7" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.5.0", + "vue": "^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.56.0.tgz", + "integrity": "sha512-9FwVqlgUHzbXtDg9RCMgodF3Ua4Na6Gau+Sdt9vyCN4RhHfVKX2DCHy3BjMLTDd47ITDhYAnTwGulWTblJSDLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.56.0", + "@rollup/rollup-android-arm64": "4.56.0", + "@rollup/rollup-darwin-arm64": "4.56.0", + "@rollup/rollup-darwin-x64": "4.56.0", + "@rollup/rollup-freebsd-arm64": "4.56.0", + "@rollup/rollup-freebsd-x64": "4.56.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.56.0", + "@rollup/rollup-linux-arm-musleabihf": "4.56.0", + "@rollup/rollup-linux-arm64-gnu": "4.56.0", + "@rollup/rollup-linux-arm64-musl": "4.56.0", + "@rollup/rollup-linux-loong64-gnu": "4.56.0", + "@rollup/rollup-linux-loong64-musl": "4.56.0", + "@rollup/rollup-linux-ppc64-gnu": "4.56.0", + "@rollup/rollup-linux-ppc64-musl": "4.56.0", + "@rollup/rollup-linux-riscv64-gnu": "4.56.0", + "@rollup/rollup-linux-riscv64-musl": "4.56.0", + "@rollup/rollup-linux-s390x-gnu": "4.56.0", + "@rollup/rollup-linux-x64-gnu": "4.56.0", + "@rollup/rollup-linux-x64-musl": "4.56.0", + "@rollup/rollup-openbsd-x64": "4.56.0", + "@rollup/rollup-openharmony-arm64": "4.56.0", + "@rollup/rollup-win32-arm64-msvc": "4.56.0", + "@rollup/rollup-win32-ia32-msvc": "4.56.0", + "@rollup/rollup-win32-x64-gnu": "4.56.0", + "@rollup/rollup-win32-x64-msvc": "4.56.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/speakingurl": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz", + "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/state-local": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz", + "integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==", + "license": "MIT" + }, + "node_modules/superjson": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz", + "integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==", + "license": "MIT", + "dependencies": { + "copy-anything": "^4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tailwindcss": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", + "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.27.tgz", + "integrity": "sha512-aJ/UtoEyFySPBGarREmN4z6qNKpbEguYHMmXSiOGk69czc+zhs0NF6tEFrY8TZKAl8N/LYAkd4JHVd5E/AsSmw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.27", + "@vue/compiler-sfc": "3.5.27", + "@vue/runtime-dom": "3.5.27", + "@vue/server-renderer": "3.5.27", + "@vue/shared": "3.5.27" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/vue-router/node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/vue-tsc": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.2.3.tgz", + "integrity": "sha512-1RdRB7rQXGFMdpo0aXf9spVzWEPGAk7PEb/ejHQwVrcuQA/HsGiixIc3uBQeqY2YjeEEgvr2ShQewBgcN4c1Cw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.27", + "@vue/language-core": "3.2.3" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + } + } +} diff --git a/pkgs/bay/dashboard/package.json b/pkgs/bay/dashboard/package.json new file mode 100644 index 0000000..3c72a86 --- /dev/null +++ b/pkgs/bay/dashboard/package.json @@ -0,0 +1,34 @@ +{ + "name": "dashboard", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@guolao/vue-monaco-editor": "^1.6.0", + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", + "axios": "^1.13.3", + "monaco-editor": "^0.55.1", + "pinia": "^3.0.4", + "vue": "^3.5.24", + "vue-router": "^4.6.4" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4.1.18", + "@tailwindcss/vite": "^4.1.18", + "@types/node": "^24.10.9", + "@vitejs/plugin-vue": "^6.0.1", + "@vue/tsconfig": "^0.8.1", + "autoprefixer": "^10.4.23", + "postcss": "^8.5.6", + "tailwindcss": "^4.1.18", + "typescript": "~5.9.3", + "vite": "^7.2.4", + "vue-tsc": "^3.1.4" + } +} diff --git a/pkgs/bay/dashboard/public/vite.svg b/pkgs/bay/dashboard/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/pkgs/bay/dashboard/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/pkgs/bay/dashboard/src/App.vue b/pkgs/bay/dashboard/src/App.vue new file mode 100644 index 0000000..7180e9e --- /dev/null +++ b/pkgs/bay/dashboard/src/App.vue @@ -0,0 +1,20 @@ + + + diff --git a/pkgs/bay/dashboard/src/api/client.ts b/pkgs/bay/dashboard/src/api/client.ts new file mode 100644 index 0000000..06a8cbe --- /dev/null +++ b/pkgs/bay/dashboard/src/api/client.ts @@ -0,0 +1,93 @@ +import axios, { type AxiosInstance, type AxiosError, type InternalAxiosRequestConfig } from 'axios' +import type { ApiError } from '@/types/api' +import { useSettingsStore } from '@/stores/settings' +import { useSessionStore } from '@/stores/session' +import { toast } from '@/composables/useToast' +import router from '@/router' + +// 创建 axios 实例 +const createApiClient = (): AxiosInstance => { + const instance = axios.create({ + timeout: 30000, + headers: { + 'Content-Type': 'application/json', + }, + }) + + // 请求拦截器 - 添加认证 token 和动态 baseURL + instance.interceptors.request.use( + (config: InternalAxiosRequestConfig) => { + const settingsStore = useSettingsStore() + const sessionStore = useSessionStore() + + // 动态设置 baseURL + config.baseURL = settingsStore.apiBaseUrl + + // 添加 Authorization header + if (settingsStore.token) { + config.headers.Authorization = `Bearer ${settingsStore.token}` + } + + // 添加 X-SESSION-ID header + if (!config.headers['X-SESSION-ID']) { + config.headers['X-SESSION-ID'] = sessionStore.sessionId + } + + return config + }, + (error) => { + return Promise.reject(error) + } + ) + + // 响应拦截器 - 统一错误处理 + instance.interceptors.response.use( + (response) => response, + (error: AxiosError) => { + const status = error.response?.status + const message = error.response?.data?.detail || error.response?.data?.message || error.message + + switch (status) { + case 401: + // 未认证,清除 token 并跳转到登录页 + const settingsStore = useSettingsStore() + settingsStore.resetSettings() + toast.error('登录已过期,请重新登录') + router.push('/login') + break + case 403: + toast.error('无权执行此操作') + break + case 404: + toast.error('请求的资源不存在') + break + case 408: + toast.error('请求超时,请稍后重试') + break + case 413: + toast.error('文件过大,请选择更小的文件') + break + case 500: + case 502: + case 503: + toast.error('服务器错误,请稍后重试') + break + default: + if (error.code === 'ERR_NETWORK') { + toast.error('网络连接失败,请检查 API 地址') + } else if (message) { + toast.error(message) + } + break + } + + return Promise.reject(error) + } + ) + + return instance +} + +export const apiClient = createApiClient() + +export default apiClient diff --git a/pkgs/bay/dashboard/src/api/index.ts b/pkgs/bay/dashboard/src/api/index.ts new file mode 100644 index 0000000..cf7baf2 --- /dev/null +++ b/pkgs/bay/dashboard/src/api/index.ts @@ -0,0 +1,98 @@ +import apiClient from './client' +import type { + OverviewResponse, + Ship, + CreateShipRequest, + ExtendTTLRequest, + StartShipRequest, + ExtendSessionTTLRequest, + ShipLogsResponse, + Session, + SessionListResponse, + ShipSessionsResponse, + ExecRequest, + ExecResponse, + UploadFileResponse, +} from '@/types/api' + +// 统计接口 +export const statApi = { + // 获取基本信息(不需要认证) + getInfo: () => apiClient.get<{ service: string; version: string; status: string }>('/stat'), + // 获取详细概览(需要认证) + getOverview: () => apiClient.get('/stat/overview'), +} + +// Ship 接口 +export const shipApi = { + // 获取所有 Ships + getList: () => apiClient.get('/ships'), + + // 获取单个 Ship 详情 + getById: (id: string) => apiClient.get(`/ship/${id}`), + + // 创建 Ship + create: (data: CreateShipRequest) => apiClient.post('/ship', data), + + // 删除 Ship(软删除 - 停止容器但保留数据) + delete: (id: string) => apiClient.delete(`/ship/${id}`), + + // 永久删除 Ship(硬删除 - 删除容器、数据和数据库记录) + deletePermanent: (id: string) => apiClient.delete(`/ship/${id}/permanent`), + + // 延长 TTL + extendTTL: (id: string, data: ExtendTTLRequest) => + apiClient.post(`/ship/${id}/extend-ttl`, data), + + // 启动已停止的容器 + start: (id: string, data?: StartShipRequest) => + apiClient.post(`/ship/${id}/start`, data || {}), + + // 获取日志 + getLogs: (id: string) => apiClient.get(`/ship/logs/${id}`), + + // 获取关联的 Sessions + getSessions: (id: string) => apiClient.get(`/ship/${id}/sessions`), + + // 执行命令 + exec: (id: string, data: ExecRequest) => + apiClient.post(`/ship/${id}/exec`, data), + + // 上传文件 + uploadFile: (id: string, file: File, filePath: string) => { + const formData = new FormData() + formData.append('file', file) + formData.append('file_path', filePath) + return apiClient.post(`/ship/${id}/upload`, formData, { + headers: { 'Content-Type': 'multipart/form-data' } + }) + }, + + // 下载文件 + downloadFile: (id: string, filePath: string) => + apiClient.get(`/ship/${id}/download`, { + params: { file_path: filePath }, + responseType: 'blob' + }), +} + +// Session 接口 +export const sessionApi = { + // 获取所有 Sessions + getList: () => apiClient.get('/sessions'), + + // 获取单个 Session + getById: (id: string) => apiClient.get(`/sessions/${id}`), + + // 延长 Session TTL + extendTTL: (id: string, data: ExtendSessionTTLRequest) => + apiClient.post(`/sessions/${id}/extend-ttl`, data), + + // 删除 Session + delete: (id: string) => apiClient.delete(`/sessions/${id}`), +} + +// 健康检查接口 +export const healthApi = { + check: () => apiClient.get<{ status: string; message: string }>('/health'), +} diff --git a/pkgs/bay/dashboard/src/assets/vue.svg b/pkgs/bay/dashboard/src/assets/vue.svg new file mode 100644 index 0000000..770e9d3 --- /dev/null +++ b/pkgs/bay/dashboard/src/assets/vue.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/pkgs/bay/dashboard/src/components/index.ts b/pkgs/bay/dashboard/src/components/index.ts new file mode 100644 index 0000000..2097986 --- /dev/null +++ b/pkgs/bay/dashboard/src/components/index.ts @@ -0,0 +1,5 @@ +export { default as StatusBadge } from './status-badge/index.vue' +export { default as TTLCountdown } from './ttl-countdown/index.vue' +export { default as Modal } from './modal/index.vue' +export { default as ToastContainer } from './toast-container/index.vue' +export { default as XtermTerminal } from './xterm-terminal/index.vue' diff --git a/pkgs/bay/dashboard/src/components/modal/index.vue b/pkgs/bay/dashboard/src/components/modal/index.vue new file mode 100644 index 0000000..7fc1b7a --- /dev/null +++ b/pkgs/bay/dashboard/src/components/modal/index.vue @@ -0,0 +1,94 @@ + + + + + diff --git a/pkgs/bay/dashboard/src/components/modal/useModal.ts b/pkgs/bay/dashboard/src/components/modal/useModal.ts new file mode 100644 index 0000000..df86c5e --- /dev/null +++ b/pkgs/bay/dashboard/src/components/modal/useModal.ts @@ -0,0 +1,53 @@ +import { ref, watch } from 'vue' + +export interface ModalProps { + modelValue: boolean + title?: string + size?: 'sm' | 'md' | 'lg' | 'xl' + closeOnOverlay?: boolean +} + +export function useModal(props: ModalProps, emit: (event: 'update:modelValue', value: boolean) => void) { + const isVisible = ref(props.modelValue) + + watch(() => props.modelValue, (val) => { + isVisible.value = val + }) + + const close = () => { + emit('update:modelValue', false) + } + + const handleOverlayClick = () => { + if (props.closeOnOverlay !== false) { + close() + } + } + + const handleEscape = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + close() + } + } + + const sizeClass = (): string => { + switch (props.size) { + case 'sm': + return 'max-w-sm' + case 'lg': + return 'max-w-2xl' + case 'xl': + return 'max-w-4xl' + default: + return 'max-w-md' + } + } + + return { + isVisible, + close, + handleOverlayClick, + handleEscape, + sizeClass, + } +} diff --git a/pkgs/bay/dashboard/src/components/status-badge/index.vue b/pkgs/bay/dashboard/src/components/status-badge/index.vue new file mode 100644 index 0000000..0396b37 --- /dev/null +++ b/pkgs/bay/dashboard/src/components/status-badge/index.vue @@ -0,0 +1,26 @@ + + + diff --git a/pkgs/bay/dashboard/src/components/status-badge/useStatusBadge.ts b/pkgs/bay/dashboard/src/components/status-badge/useStatusBadge.ts new file mode 100644 index 0000000..b025671 --- /dev/null +++ b/pkgs/bay/dashboard/src/components/status-badge/useStatusBadge.ts @@ -0,0 +1,58 @@ +import { computed } from 'vue' +import { ShipStatus } from '@/types/api' + +export interface StatusBadgeProps { + status: number + size?: 'sm' | 'md' | 'lg' +} + +export function useStatusBadge(props: StatusBadgeProps) { + const statusConfig = computed(() => { + switch (props.status) { + case ShipStatus.RUNNING: + return { + text: 'Running', + bgClass: 'bg-green-100', + textClass: 'text-green-800', + dotClass: 'bg-green-500', + } + case ShipStatus.STOPPED: + return { + text: 'Stopped', + bgClass: 'bg-gray-100', + textClass: 'text-gray-600', + dotClass: 'bg-gray-400', + } + case ShipStatus.CREATING: + return { + text: 'Creating', + bgClass: 'bg-yellow-100', + textClass: 'text-yellow-800', + dotClass: 'bg-yellow-500', + } + default: + return { + text: 'Unknown', + bgClass: 'bg-gray-100', + textClass: 'text-gray-600', + dotClass: 'bg-gray-400', + } + } + }) + + const sizeClass = computed(() => { + switch (props.size || 'md') { + case 'sm': + return 'px-2 py-0.5 text-xs' + case 'lg': + return 'px-4 py-2 text-base' + default: + return 'px-2.5 py-1 text-sm' + } + }) + + return { + statusConfig, + sizeClass, + } +} diff --git a/pkgs/bay/dashboard/src/components/toast-container/index.vue b/pkgs/bay/dashboard/src/components/toast-container/index.vue new file mode 100644 index 0000000..7016a57 --- /dev/null +++ b/pkgs/bay/dashboard/src/components/toast-container/index.vue @@ -0,0 +1,78 @@ + + + + + diff --git a/pkgs/bay/dashboard/src/components/ttl-countdown/index.vue b/pkgs/bay/dashboard/src/components/ttl-countdown/index.vue new file mode 100644 index 0000000..f0e4783 --- /dev/null +++ b/pkgs/bay/dashboard/src/components/ttl-countdown/index.vue @@ -0,0 +1,26 @@ + + + diff --git a/pkgs/bay/dashboard/src/components/ttl-countdown/useTTLCountdown.ts b/pkgs/bay/dashboard/src/components/ttl-countdown/useTTLCountdown.ts new file mode 100644 index 0000000..e85ced6 --- /dev/null +++ b/pkgs/bay/dashboard/src/components/ttl-countdown/useTTLCountdown.ts @@ -0,0 +1,61 @@ +import { ref, computed, onMounted, onUnmounted, toValue, type MaybeRefOrGetter } from 'vue' +import { parseServerDate, formatRemainingTime } from '@/utils/time' + +export interface TTLCountdownProps { + expiresAt: MaybeRefOrGetter + showLabel?: boolean +} + +export function useTTLCountdown(props: TTLCountdownProps) { + const now = ref(Date.now()) + let intervalId: ReturnType | null = null + + const remainingSeconds = computed(() => { + // 使用 toValue 来支持响应式和非响应式的 expiresAt + const expiresAt = toValue(props.expiresAt) + const date = parseServerDate(expiresAt) + if (!date) return 0 + const remaining = Math.max(0, Math.floor((date.getTime() - now.value) / 1000)) + return remaining + }) + + const isExpired = computed(() => remainingSeconds.value <= 0) + + const formattedTime = computed(() => { + return formatRemainingTime(remainingSeconds.value) + }) + + const progressPercent = computed(() => { + // 假设最大 TTL 为 24 小时 + const maxSeconds = 86400 + return Math.min(100, (remainingSeconds.value / maxSeconds) * 100) + }) + + const colorClass = computed(() => { + const seconds = remainingSeconds.value + if (seconds <= 0) return 'text-gray-400' + if (seconds <= 300) return 'text-red-600' // < 5 min + if (seconds <= 1800) return 'text-yellow-600' // < 30 min + return 'text-green-600' + }) + + onMounted(() => { + intervalId = setInterval(() => { + now.value = Date.now() + }, 1000) + }) + + onUnmounted(() => { + if (intervalId) { + clearInterval(intervalId) + } + }) + + return { + remainingSeconds, + isExpired, + formattedTime, + progressPercent, + colorClass, + } +} diff --git a/pkgs/bay/dashboard/src/components/xterm-terminal/index.vue b/pkgs/bay/dashboard/src/components/xterm-terminal/index.vue new file mode 100644 index 0000000..9ed0920 --- /dev/null +++ b/pkgs/bay/dashboard/src/components/xterm-terminal/index.vue @@ -0,0 +1,319 @@ + + + + + diff --git a/pkgs/bay/dashboard/src/composables/index.ts b/pkgs/bay/dashboard/src/composables/index.ts new file mode 100644 index 0000000..fd372a4 --- /dev/null +++ b/pkgs/bay/dashboard/src/composables/index.ts @@ -0,0 +1,3 @@ +export { useAutoRefresh } from './useAutoRefresh' +export { useToast, toast, type Toast, type ToastType } from './useToast' +export { useConfirm, confirmDialog, confirmDelete } from './useConfirm' diff --git a/pkgs/bay/dashboard/src/composables/useAutoRefresh.ts b/pkgs/bay/dashboard/src/composables/useAutoRefresh.ts new file mode 100644 index 0000000..0f2feb0 --- /dev/null +++ b/pkgs/bay/dashboard/src/composables/useAutoRefresh.ts @@ -0,0 +1,64 @@ +import { ref, onMounted, onUnmounted, watch } from 'vue' +import { useSettingsStore } from '@/stores/settings' + +/** + * 自动刷新数据的 composable + * @param fetchFn 获取数据的函数 + * @param immediate 是否立即执行一次 + */ +export function useAutoRefresh(fetchFn: () => Promise, immediate = true) { + const settingsStore = useSettingsStore() + const loading = ref(false) + const error = ref(null) + let intervalId: ReturnType | null = null + + const execute = async () => { + loading.value = true + error.value = null + try { + await fetchFn() + } catch (e: unknown) { + error.value = e instanceof Error ? e.message : '请求失败' + } finally { + loading.value = false + } + } + + const startInterval = () => { + stopInterval() + if (settingsStore.refreshInterval > 0) { + intervalId = setInterval(execute, settingsStore.refreshInterval) + } + } + + const stopInterval = () => { + if (intervalId) { + clearInterval(intervalId) + intervalId = null + } + } + + // 监听刷新间隔变化 + watch(() => settingsStore.refreshInterval, () => { + startInterval() + }) + + onMounted(() => { + if (immediate) { + execute() + } + startInterval() + }) + + onUnmounted(() => { + stopInterval() + }) + + return { + loading, + error, + refresh: execute, + pause: stopInterval, + resume: startInterval, + } +} diff --git a/pkgs/bay/dashboard/src/composables/useConfirm.ts b/pkgs/bay/dashboard/src/composables/useConfirm.ts new file mode 100644 index 0000000..391d8e8 --- /dev/null +++ b/pkgs/bay/dashboard/src/composables/useConfirm.ts @@ -0,0 +1,71 @@ +import { ref } from 'vue' + +export interface ConfirmOptions { + title: string + message: string + confirmText?: string + cancelText?: string + type?: 'danger' | 'warning' | 'info' +} + +const isOpen = ref(false) +const options = ref(null) +let resolvePromise: ((value: boolean) => void) | null = null + +/** + * 确认弹窗 composable + */ +export function useConfirm() { + const confirm = (opts: ConfirmOptions): Promise => { + options.value = { + confirmText: '确认', + cancelText: '取消', + type: 'info', + ...opts, + } + isOpen.value = true + + return new Promise((resolve) => { + resolvePromise = resolve + }) + } + + const handleConfirm = () => { + isOpen.value = false + if (resolvePromise) { + resolvePromise(true) + resolvePromise = null + } + } + + const handleCancel = () => { + isOpen.value = false + if (resolvePromise) { + resolvePromise(false) + resolvePromise = null + } + } + + return { + isOpen, + options, + confirm, + handleConfirm, + handleCancel, + } +} + +// 便捷方法 +export const confirmDialog = (opts: ConfirmOptions) => { + const { confirm } = useConfirm() + return confirm(opts) +} + +export const confirmDelete = (itemName: string) => { + return confirmDialog({ + title: '确认删除', + message: `确定要删除 ${itemName} 吗?此操作不可撤销。`, + confirmText: '删除', + type: 'danger', + }) +} diff --git a/pkgs/bay/dashboard/src/composables/useToast.ts b/pkgs/bay/dashboard/src/composables/useToast.ts new file mode 100644 index 0000000..a8bfedf --- /dev/null +++ b/pkgs/bay/dashboard/src/composables/useToast.ts @@ -0,0 +1,67 @@ +import { ref, readonly } from 'vue' + +export type ToastType = 'success' | 'error' | 'warning' | 'info' + +export interface Toast { + id: number + type: ToastType + message: string + duration: number +} + +// 全局共享的 toasts 状态(单例模式) +const toasts = ref([]) +let toastId = 0 + +const add = (type: ToastType, message: string, duration = 3000) => { + const id = ++toastId + toasts.value.push({ id, type, message, duration }) + + if (duration > 0) { + setTimeout(() => { + remove(id) + }, duration) + } + + return id +} + +const remove = (id: number) => { + const index = toasts.value.findIndex(t => t.id === id) + if (index > -1) { + toasts.value.splice(index, 1) + } +} + +const clear = () => { + toasts.value = [] +} + +/** + * Toast 通知 composable + */ +export function useToast() { + const success = (message: string, duration?: number) => add('success', message, duration) + const error = (message: string, duration?: number) => add('error', message, duration) + const warning = (message: string, duration?: number) => add('warning', message, duration) + const info = (message: string, duration?: number) => add('info', message, duration) + + return { + toasts: readonly(toasts), + add, + remove, + success, + error, + warning, + info, + clear, + } +} + +// 全局 toast 实例(便捷方法) +export const toast = { + success: (message: string, duration?: number) => add('success', message, duration), + error: (message: string, duration?: number) => add('error', message, duration), + warning: (message: string, duration?: number) => add('warning', message, duration), + info: (message: string, duration?: number) => add('info', message, duration), +} diff --git a/pkgs/bay/dashboard/src/layouts/MainLayout.vue b/pkgs/bay/dashboard/src/layouts/MainLayout.vue new file mode 100644 index 0000000..60b5ff5 --- /dev/null +++ b/pkgs/bay/dashboard/src/layouts/MainLayout.vue @@ -0,0 +1,156 @@ + + + diff --git a/pkgs/bay/dashboard/src/main.ts b/pkgs/bay/dashboard/src/main.ts new file mode 100644 index 0000000..4a4f2bf --- /dev/null +++ b/pkgs/bay/dashboard/src/main.ts @@ -0,0 +1,12 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import router from './router' +import App from './App.vue' +import './style.css' + +const app = createApp(App) + +app.use(createPinia()) +app.use(router) + +app.mount('#app') diff --git a/pkgs/bay/dashboard/src/router/index.ts b/pkgs/bay/dashboard/src/router/index.ts new file mode 100644 index 0000000..9403a8d --- /dev/null +++ b/pkgs/bay/dashboard/src/router/index.ts @@ -0,0 +1,93 @@ +import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router' +import { useSettingsStore } from '@/stores/settings' + +const routes: RouteRecordRaw[] = [ + { + path: '/login', + name: 'Login', + component: () => import('@/views/login/index.vue'), + meta: { title: '登录', requiresAuth: false, hideLayout: true } + }, + { + path: '/', + name: 'Dashboard', + component: () => import('@/views/dashboard/index.vue'), + meta: { title: '仪表盘', requiresAuth: true } + }, + { + path: '/ships', + name: 'Ships', + component: () => import('@/views/ships/index.vue'), + meta: { title: '容器管理', requiresAuth: true } + }, + { + path: '/ships/create', + name: 'CreateShip', + component: () => import('@/views/ship-create/index.vue'), + meta: { title: '创建容器', requiresAuth: true } + }, + { + path: '/ships/:id', + name: 'ShipDetail', + component: () => import('@/views/ship-detail/index.vue'), + meta: { title: '容器详情', requiresAuth: true } + }, + { + path: '/sessions', + name: 'Sessions', + component: () => import('@/views/sessions/index.vue'), + meta: { title: '会话管理', requiresAuth: true } + }, + { + path: '/sessions/:id', + name: 'SessionDetail', + component: () => import('@/views/session-detail/index.vue'), + meta: { title: '会话详情', requiresAuth: true } + }, + { + path: '/settings', + name: 'Settings', + component: () => import('@/views/settings/index.vue'), + meta: { title: '系统设置', requiresAuth: true } + }, + { + path: '/:pathMatch(.*)*', + name: 'NotFound', + component: () => import('@/views/not-found/index.vue'), + meta: { title: '页面未找到' } + } +] + +const router = createRouter({ + history: createWebHistory(), + routes +}) + +// 导航守卫 +router.beforeEach((to, _from, next) => { + // 更新页面标题 + document.title = `${to.meta.title || 'Bay Dashboard'} - Bay Dashboard` + + // 检查是否需要认证 + if (to.meta.requiresAuth) { + const settingsStore = useSettingsStore() + if (!settingsStore.isAuthenticated) { + // 未认证,跳转到登录页 + next({ name: 'Login', query: { redirect: to.fullPath } }) + return + } + } + + // 如果已经登录并尝试访问登录页,重定向到首页 + if (to.name === 'Login') { + const settingsStore = useSettingsStore() + if (settingsStore.isAuthenticated) { + next({ name: 'Dashboard' }) + return + } + } + + next() +}) + +export default router diff --git a/pkgs/bay/dashboard/src/stores/session.ts b/pkgs/bay/dashboard/src/stores/session.ts new file mode 100644 index 0000000..02ba2cd --- /dev/null +++ b/pkgs/bay/dashboard/src/stores/session.ts @@ -0,0 +1,105 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' + +const SESSION_STORAGE_KEY = 'bay-session-id' + +/** + * 生成 UUID v4 + * 兼容非安全上下文(HTTP 环境) + */ +const generateUUID = (): string => { + // 优先使用 crypto.randomUUID()(需要安全上下文) + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + try { + return crypto.randomUUID() + } catch { + // 在非安全上下文中可能会失败 + } + } + + // 回退方案:使用 crypto.getRandomValues() + if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') { + const bytes = new Uint8Array(16) + crypto.getRandomValues(bytes) + // 设置版本为 4 和变体 + const byte6 = bytes[6] + const byte8 = bytes[8] + if (byte6 !== undefined && byte8 !== undefined) { + bytes[6] = (byte6 & 0x0f) | 0x40 + bytes[8] = (byte8 & 0x3f) | 0x80 + } + + const hex = Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('') + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}` + } + + // 最后的回退方案:使用 Math.random() + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => { + const r = (Math.random() * 16) | 0 + const v = c === 'x' ? r : (r & 0x3) | 0x8 + return v.toString(16) + }) +} + +/** + * Session Store + * 管理当前浏览器会话的上下文,用于 X-SESSION-ID 请求头 + */ +export const useSessionStore = defineStore('session', () => { + // 从 sessionStorage 加载或生成新的 session ID + const loadOrCreateSessionId = (): string => { + try { + const stored = sessionStorage.getItem(SESSION_STORAGE_KEY) + if (stored) { + return stored + } + } catch { + // sessionStorage 可能不可用 + } + + const newId = generateUUID() + + try { + sessionStorage.setItem(SESSION_STORAGE_KEY, newId) + } catch { + // sessionStorage 可能不可用 + } + + return newId + } + + const sessionId = ref(loadOrCreateSessionId()) + const currentShipId = ref(null) + + // Computed + const shortSessionId = computed(() => sessionId.value.slice(0, 8)) + + // Actions + const regenerateSessionId = () => { + const newId = generateUUID() + try { + sessionStorage.setItem(SESSION_STORAGE_KEY, newId) + } catch { + // sessionStorage 可能不可用 + } + sessionId.value = newId + } + + const setCurrentShipId = (shipId: string | null) => { + currentShipId.value = shipId + } + + const clearSession = () => { + sessionStorage.removeItem(SESSION_STORAGE_KEY) + currentShipId.value = null + } + + return { + sessionId, + currentShipId, + shortSessionId, + regenerateSessionId, + setCurrentShipId, + clearSession, + } +}) diff --git a/pkgs/bay/dashboard/src/stores/settings.ts b/pkgs/bay/dashboard/src/stores/settings.ts new file mode 100644 index 0000000..e2c47cc --- /dev/null +++ b/pkgs/bay/dashboard/src/stores/settings.ts @@ -0,0 +1,77 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import type { AppSettings } from '@/types/api' + +const STORAGE_KEY = 'bay-dashboard-settings' + +const defaultSettings: AppSettings = { + apiBaseUrl: '/api', // 使用代理模式 + token: '', + refreshInterval: 30000, // 30 秒 +} + +export const useSettingsStore = defineStore('settings', () => { + // 从 localStorage 加载设置 + const loadSettings = (): AppSettings => { + const stored = localStorage.getItem(STORAGE_KEY) + if (stored) { + try { + const parsed = JSON.parse(stored) + // 强制使用固定的 apiBaseUrl,忽略存储的值 + return { + ...defaultSettings, + ...parsed, + apiBaseUrl: '/api' // 始终使用代理模式 + } + } catch { + return defaultSettings + } + } + return defaultSettings + } + + const settings = ref(loadSettings()) + + // Computed + const apiBaseUrl = computed(() => settings.value.apiBaseUrl) + const token = computed(() => settings.value.token) + const refreshInterval = computed(() => settings.value.refreshInterval) + const isAuthenticated = computed(() => !!settings.value.token) + + // Actions + const saveSettings = () => { + localStorage.setItem(STORAGE_KEY, JSON.stringify(settings.value)) + } + + const updateApiBaseUrl = (url: string) => { + settings.value.apiBaseUrl = url + saveSettings() + } + + const updateToken = (newToken: string) => { + settings.value.token = newToken + saveSettings() + } + + const updateRefreshInterval = (interval: number) => { + settings.value.refreshInterval = interval + saveSettings() + } + + const resetSettings = () => { + settings.value = { ...defaultSettings } + saveSettings() + } + + return { + settings, + apiBaseUrl, + token, + refreshInterval, + isAuthenticated, + updateApiBaseUrl, + updateToken, + updateRefreshInterval, + resetSettings, + } +}) diff --git a/pkgs/bay/dashboard/src/style.css b/pkgs/bay/dashboard/src/style.css new file mode 100644 index 0000000..b229aa0 --- /dev/null +++ b/pkgs/bay/dashboard/src/style.css @@ -0,0 +1,39 @@ +@import "tailwindcss"; + +/* 自定义样式 */ +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + @apply bg-slate-50 text-slate-800; +} + +/* Ocean Breeze Theme Utilities */ +.btn-primary { + @apply bg-gradient-to-r from-[#0F4C75] to-[#3282B8] text-white rounded-lg hover:shadow-lg hover:shadow-blue-200 transition-all duration-300 hover:-translate-y-0.5 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:translate-y-0 disabled:hover:shadow-none; +} + +.btn-secondary { + @apply bg-white border border-gray-200 text-gray-600 rounded-lg hover:bg-gray-50 hover:text-gray-900 hover:border-gray-300 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed; +} + +.btn-danger { + @apply bg-red-50 text-red-600 border border-red-100 rounded-lg hover:bg-red-100 hover:text-red-700 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed; +} + +.card { + @apply bg-white rounded-xl shadow-lg shadow-blue-100/50 border border-blue-50; +} + +.input-field { + @apply w-full px-4 py-2 border-2 border-blue-50 rounded-lg focus:border-[#3282B8] focus:ring-2 focus:ring-blue-100 transition-all duration-200 outline-none; +} + +.table-header { + @apply bg-gradient-to-r from-slate-50 to-blue-50 text-xs font-semibold text-slate-500 uppercase tracking-wider; +} diff --git a/pkgs/bay/dashboard/src/types/api.ts b/pkgs/bay/dashboard/src/types/api.ts new file mode 100644 index 0000000..63c1649 --- /dev/null +++ b/pkgs/bay/dashboard/src/types/api.ts @@ -0,0 +1,155 @@ +// API 响应的基础类型 + +// 系统概览 +export interface OverviewResponse { + service: string + version: string + status: 'running' | 'stopped' + ships: { + total: number + running: number + stopped: number + creating: number + } + sessions: { + total: number + active: number + } +} + +// Ship 状态常量 (与后端一致) +export const ShipStatus = { + STOPPED: 0, + RUNNING: 1, + CREATING: 2, +} as const + +export type ShipStatusType = typeof ShipStatus[keyof typeof ShipStatus] + +// Ship 相关类型 (与后端 ShipResponse 一致) +export interface Ship { + id: string + status: number // 0: stopped, 1: running, 2: creating + created_at: string + updated_at: string + container_id: string | null + ip_address: string | null + ttl: number + max_session_num: number + current_session_num: number + expires_at: string | null +} + +// Ship 规格 +export interface ShipSpec { + cpus?: number + memory?: string // e.g., '512m', '1g' + disk?: string // e.g., '1Gi', '10G' +} + +// 创建 Ship 请求 (与后端 CreateShipRequest 一致) +export interface CreateShipRequest { + ttl: number + spec?: ShipSpec + max_session_num?: number + force_create?: boolean // If true, skip reuse logic and always create new container +} + +export interface ExtendTTLRequest { + ttl: number +} + +export interface StartShipRequest { + ttl?: number // 默认 3600 秒 +} + +export interface ExtendSessionTTLRequest { + ttl: number // TTL in seconds +} + +export interface ShipLogsResponse { + logs: string +} + +// Session 相关类型 (与后端 SessionResponse 一致) +export interface Session { + id: string + session_id: string + ship_id: string + created_at: string + last_activity: string + expires_at: string + initial_ttl: number + is_active: boolean +} + +// Session 列表响应 +export interface SessionListResponse { + sessions: Session[] + total: number +} + +// Ship Sessions 响应 +export interface ShipSessionsResponse { + ship_id: string + sessions: Session[] + total: number +} + +// 执行命令相关类型 (与后端 ExecRequest 一致) +export interface ExecRequest { + type: string // e.g., 'shell/exec', 'ipython/exec' + payload?: Record +} + +export interface ExecResponse { + success: boolean + data?: Record + error?: string +} + +// 文件操作相关类型 +export interface UploadFileResponse { + success: boolean + message: string + file_path?: string + error?: string +} + +// API 通用响应 +export interface ApiResponse { + data: T + message?: string +} + +export interface ApiError { + status: number + message: string + detail?: string +} + +// 设置相关 +export interface AppSettings { + apiBaseUrl: string + token: string + refreshInterval: number +} + +// 辅助函数 +export function getShipStatusText(status: number): string { + switch (status) { + case ShipStatus.RUNNING: return 'running' + case ShipStatus.STOPPED: return 'stopped' + case ShipStatus.CREATING: return 'creating' + default: return 'unknown' + } +} + +export function getShipStatusClass(status: number): string { + switch (status) { + case ShipStatus.RUNNING: return 'bg-green-100 text-green-800' + case ShipStatus.STOPPED: return 'bg-gray-100 text-gray-800' + case ShipStatus.CREATING: return 'bg-yellow-100 text-yellow-800' + default: return 'bg-gray-100 text-gray-800' + } +} diff --git a/pkgs/bay/dashboard/src/types/router.d.ts b/pkgs/bay/dashboard/src/types/router.d.ts new file mode 100644 index 0000000..5e016e8 --- /dev/null +++ b/pkgs/bay/dashboard/src/types/router.d.ts @@ -0,0 +1,9 @@ +import 'vue-router' + +declare module 'vue-router' { + interface RouteMeta { + title?: string + requiresAuth?: boolean + hideLayout?: boolean + } +} diff --git a/pkgs/bay/dashboard/src/utils/time.ts b/pkgs/bay/dashboard/src/utils/time.ts new file mode 100644 index 0000000..78c66e2 --- /dev/null +++ b/pkgs/bay/dashboard/src/utils/time.ts @@ -0,0 +1,148 @@ +/** + * 时间处理工具函数 + * 处理后端 UTC 时间与本地时间的转换 + * + * 后端使用 datetime.now(timezone.utc) 存储时间, + * 但 FastAPI/Pydantic 序列化时可能不带时区后缀: + * - 2024-01-26T07:08:45.123456 (没有时区信息,但实际是 UTC) + * - 2024-01-26T07:08:45+00:00 + * - 2024-01-26T07:08:45Z + */ + +/** + * 将后端返回的时间字符串解析为 Date 对象 + * 后端时间都是 UTC,如果没有时区信息则添加 Z 后缀 + */ +export function parseServerDate(dateStr: string | null | undefined): Date | null { + if (!dateStr) return null + + let normalized = dateStr.trim() + + // 如果没有时区信息,添加 Z 后缀表示 UTC + // 检查是否以 Z 结尾,或包含 + 或 - 时区偏移(但排除日期中的 -) + const hasTimezone = normalized.endsWith('Z') || + /[+-]\d{2}:\d{2}$/.test(normalized) || + /[+-]\d{4}$/.test(normalized) + + if (!hasTimezone) { + normalized = normalized + 'Z' + } + + const date = new Date(normalized) + return isNaN(date.getTime()) ? null : date +} + +/** + * 格式化日期时间为本地格式 + */ +export function formatDateTime(dateStr: string | null | undefined): string { + const date = parseServerDate(dateStr) + if (!date) return '-' + + return date.toLocaleString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }) +} + +/** + * 格式化日期为本地短格式 + */ +export function formatDate(dateStr: string | null | undefined): string { + const date = parseServerDate(dateStr) + if (!date) return '-' + + return date.toLocaleDateString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + }) +} + +/** + * 格式化时间为本地短格式 + */ +export function formatTime(dateStr: string | null | undefined): string { + const date = parseServerDate(dateStr) + if (!date) return '-' + + return date.toLocaleTimeString('zh-CN', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }) +} + +/** + * 计算相对时间(X 分钟前、X 小时前等) + */ +export function getRelativeTime(dateStr: string | null | undefined): string { + const date = parseServerDate(dateStr) + if (!date) return '-' + + const now = new Date() + const diffMs = now.getTime() - date.getTime() + + // 处理未来时间(可能由于时间同步误差) + if (diffMs < -60000) { // 允许 1 分钟的误差 + return '刚刚' + } + + const diffSecs = Math.abs(Math.floor(diffMs / 1000)) + const diffMins = Math.floor(diffSecs / 60) + const diffHours = Math.floor(diffMins / 60) + const diffDays = Math.floor(diffHours / 24) + const diffWeeks = Math.floor(diffDays / 7) + const diffMonths = Math.floor(diffDays / 30) + + if (diffSecs < 60) return '刚刚' + if (diffMins < 60) return `${diffMins} 分钟前` + if (diffHours < 24) return `${diffHours} 小时前` + if (diffDays < 7) return `${diffDays} 天前` + if (diffWeeks < 4) return `${diffWeeks} 周前` + if (diffMonths < 12) return `${diffMonths} 月前` + return `${Math.floor(diffMonths / 12)} 年前` +} + +/** + * 计算距离过期的剩余秒数 + */ +export function getRemainingSeconds(expiresAt: string | null | undefined): number { + const date = parseServerDate(expiresAt) + if (!date) return 0 + + const now = new Date() + const remaining = Math.floor((date.getTime() - now.getTime()) / 1000) + return Math.max(0, remaining) +} + +/** + * 格式化剩余时间为可读格式 + */ +export function formatRemainingTime(seconds: number): string { + if (seconds <= 0) return '已过期' + + const days = Math.floor(seconds / 86400) + const hours = Math.floor((seconds % 86400) / 3600) + const minutes = Math.floor((seconds % 3600) / 60) + const secs = seconds % 60 + + const parts: string[] = [] + if (days > 0) parts.push(`${days}d`) + if (hours > 0 || days > 0) parts.push(`${hours}h`) + if (minutes > 0 || hours > 0 || days > 0) parts.push(`${minutes}m`) + parts.push(`${secs}s`) + + return parts.join(' ') +} + +/** + * 判断时间是否已过期 + */ +export function isExpired(expiresAt: string | null | undefined): boolean { + return getRemainingSeconds(expiresAt) <= 0 +} diff --git a/pkgs/bay/dashboard/src/views/dashboard/index.vue b/pkgs/bay/dashboard/src/views/dashboard/index.vue new file mode 100644 index 0000000..da4f325 --- /dev/null +++ b/pkgs/bay/dashboard/src/views/dashboard/index.vue @@ -0,0 +1,151 @@ + + + diff --git a/pkgs/bay/dashboard/src/views/dashboard/useDashboard.ts b/pkgs/bay/dashboard/src/views/dashboard/useDashboard.ts new file mode 100644 index 0000000..41070f4 --- /dev/null +++ b/pkgs/bay/dashboard/src/views/dashboard/useDashboard.ts @@ -0,0 +1,22 @@ +import { ref } from 'vue' +import { statApi } from '@/api' +import type { OverviewResponse } from '@/types/api' +import { useAutoRefresh } from '@/composables/useAutoRefresh' + +export function useDashboard() { + const overview = ref(null) + + const fetchOverview = async () => { + const response = await statApi.getOverview() + overview.value = response.data + } + + const { loading, error, refresh } = useAutoRefresh(fetchOverview) + + return { + overview, + loading, + error, + refresh, + } +} diff --git a/pkgs/bay/dashboard/src/views/login/index.vue b/pkgs/bay/dashboard/src/views/login/index.vue new file mode 100644 index 0000000..0f93a97 --- /dev/null +++ b/pkgs/bay/dashboard/src/views/login/index.vue @@ -0,0 +1,60 @@ + + + diff --git a/pkgs/bay/dashboard/src/views/login/useLogin.ts b/pkgs/bay/dashboard/src/views/login/useLogin.ts new file mode 100644 index 0000000..0d87177 --- /dev/null +++ b/pkgs/bay/dashboard/src/views/login/useLogin.ts @@ -0,0 +1,58 @@ +import { ref } from 'vue' +import { useRouter } from 'vue-router' +import { useSettingsStore } from '@/stores/settings' +import { healthApi } from '@/api' +import { toast } from '@/composables/useToast' + +export function useLogin() { + const router = useRouter() + const settingsStore = useSettingsStore() + + const form = ref({ + token: '', + }) + + const loading = ref(false) + const showPassword = ref(false) + + const validateForm = (): boolean => { + if (!form.value.token.trim()) { + toast.error('请输入 Access Token') + return false + } + return true + } + + const handleLogin = async () => { + if (!validateForm()) return + + loading.value = true + + try { + // 设置 token(API 地址固定为 /api,通过 Nginx 代理) + settingsStore.updateToken(form.value.token) + + // 测试连接 + await healthApi.check() + + toast.success('登录成功') + + // 跳转到首页 + const redirect = router.currentRoute.value.query.redirect as string + router.push(redirect || '/') + } catch (error: unknown) { + // 登录失败,清除 token + settingsStore.updateToken('') + // 错误已经在 api/client.ts 中处理 + } finally { + loading.value = false + } + } + + return { + form, + loading, + showPassword, + handleLogin, + } +} diff --git a/pkgs/bay/dashboard/src/views/not-found/index.vue b/pkgs/bay/dashboard/src/views/not-found/index.vue new file mode 100644 index 0000000..02178c4 --- /dev/null +++ b/pkgs/bay/dashboard/src/views/not-found/index.vue @@ -0,0 +1,32 @@ + + + diff --git a/pkgs/bay/dashboard/src/views/not-found/useNotFound.ts b/pkgs/bay/dashboard/src/views/not-found/useNotFound.ts new file mode 100644 index 0000000..6ef8e13 --- /dev/null +++ b/pkgs/bay/dashboard/src/views/not-found/useNotFound.ts @@ -0,0 +1,18 @@ +import { useRouter } from 'vue-router' + +export function useNotFound() { + const router = useRouter() + + const goHome = () => { + router.push('/') + } + + const goBack = () => { + router.back() + } + + return { + goHome, + goBack, + } +} diff --git a/pkgs/bay/dashboard/src/views/session-detail/index.vue b/pkgs/bay/dashboard/src/views/session-detail/index.vue new file mode 100644 index 0000000..0836d93 --- /dev/null +++ b/pkgs/bay/dashboard/src/views/session-detail/index.vue @@ -0,0 +1,423 @@ + + + diff --git a/pkgs/bay/dashboard/src/views/session-detail/useSessionDetail.ts b/pkgs/bay/dashboard/src/views/session-detail/useSessionDetail.ts new file mode 100644 index 0000000..5cf4d35 --- /dev/null +++ b/pkgs/bay/dashboard/src/views/session-detail/useSessionDetail.ts @@ -0,0 +1,273 @@ +import { ref, computed } from 'vue' +import { useRoute, useRouter } from 'vue-router' +import { sessionApi, shipApi } from '@/api' +import type { Session } from '@/types/api' +import { useAutoRefresh } from '@/composables/useAutoRefresh' +import { toast } from '@/composables/useToast' +import { apiClient } from '@/api/client' + +export function useSessionDetail() { + const route = useRoute() + const router = useRouter() + + const sessionId = computed(() => route.params.id as string) + const session = ref(null) + const deleteConfirmVisible = ref(false) + const deleteLoading = ref(false) + const startLoading = ref(false) + + const fetchSessionDetail = async () => { + const response = await sessionApi.getById(sessionId.value) + session.value = response.data + } + + const { loading, error, refresh } = useAutoRefresh(fetchSessionDetail) + + const isActive = computed(() => session.value?.is_active ?? false) + + const handleDelete = async () => { + if (!session.value) return + + deleteLoading.value = true + try { + await sessionApi.delete(sessionId.value) + toast.success('会话已删除') + deleteConfirmVisible.value = false + router.push('/sessions') + } catch { + // 错误已在 client.ts 中处理 + } finally { + deleteLoading.value = false + } + } + + const handleStart = async () => { + if (!session.value) return + + startLoading.value = true + const defaultTTL = 3600 // 默认 1 小时 + try { + // 启动容器 + await shipApi.start(session.value.ship_id, { ttl: defaultTTL }) + + // 同时延长会话的 TTL + await sessionApi.extendTTL(session.value.session_id, { ttl: defaultTTL }) + + toast.success('容器已启动,会话 TTL 已更新') + // 刷新会话信息 + await fetchSessionDetail() + } catch { + // 错误已在 client.ts 中处理 + } finally { + startLoading.value = false + } + } + + const shortId = (id: string) => id.slice(0, 8) + + const copyToClipboard = async (text: string) => { + try { + await navigator.clipboard.writeText(text) + toast.success('已复制到剪贴板') + } catch { + toast.error('复制失败') + } + } + + const activeTab = ref<'info' | 'terminal' | 'python'>('info') + + const tabs = [ + { name: 'info', label: '基本信息', icon: 'info' }, + { name: 'terminal', label: '终端', icon: 'terminal' }, + { name: 'python', label: 'Python 执行', icon: 'code' }, + ] as const + + const setActiveTab = (tab: typeof activeTab.value) => { + activeTab.value = tab + } + + const terminalInput = ref('') + const terminalHistory = ref([]) + const terminalLoading = ref(false) + + + const clearTerminal = () => { + terminalHistory.value = [] + } + + const refreshShipSessions = async () => { + if (!session.value) return + const response = await shipApi.getSessions(session.value.ship_id) + const target = response.data.sessions.find((item) => item.session_id === session.value?.session_id) + if (target) { + session.value = target + } + } + + // 终端命令历史回溯 + const commandHistoryIndex = ref(-1) + const localCommandHistory = ref([]) + + const getPreviousCommand = () => { + if (localCommandHistory.value.length === 0) return '' + if (commandHistoryIndex.value < localCommandHistory.value.length - 1) { + commandHistoryIndex.value++ + return localCommandHistory.value[localCommandHistory.value.length - 1 - commandHistoryIndex.value] + } + return localCommandHistory.value[0] + } + + const getNextCommand = () => { + if (commandHistoryIndex.value > 0) { + commandHistoryIndex.value-- + return localCommandHistory.value[localCommandHistory.value.length - 1 - commandHistoryIndex.value] + } + commandHistoryIndex.value = -1 + return '' + } + + // 修改 sendCommand 以支持历史记录 + const sendCommand = async () => { + if (!session.value || !terminalInput.value.trim() || terminalLoading.value) return + const command = terminalInput.value.trim() + + // 添加到本地历史用于回溯 + localCommandHistory.value.push(command) + commandHistoryIndex.value = -1 + + // 添加到显示历史 + terminalHistory.value.push(`$ ${command}`) + terminalInput.value = '' + terminalLoading.value = true + + try { + const response = await apiClient.post( + `/ship/${session.value.ship_id}/exec`, + { + type: 'shell/exec', + payload: { + command, + timeout: 30, + shell: true, + background: false, + }, + }, + { + headers: { + 'X-SESSION-ID': session.value.session_id, + }, + } + ) + const data = response.data as any + if (data?.data?.stdout) { + terminalHistory.value.push(data.data.stdout.trimEnd()) + } + if (data?.data?.stderr) { + terminalHistory.value.push(data.data.stderr.trimEnd()) + } + if (!data?.success && data?.error) { + terminalHistory.value.push(`Error: ${data.error}`) + } + if (!data?.data?.stdout && !data?.data?.stderr && data?.success) { + // 如果没有输出且成功,不显示任何内容(像真实终端一样) + } + } catch { + // 错误已在 client.ts 中处理 + } finally { + terminalLoading.value = false + } + } + + // Python 执行相关 + const pythonCode = ref('# 在这里编写 Python 代码\nprint("Hello, World!")\n') + const pythonOutput = ref>([]) + const pythonLoading = ref(false) + + const runPython = async () => { + if (!session.value || pythonLoading.value) return + pythonLoading.value = true + pythonOutput.value = [] + try { + const response = await apiClient.post( + `/ship/${session.value.ship_id}/exec`, + { + type: 'ipython/exec', + payload: { + code: pythonCode.value, + timeout: 60, + }, + }, + { + headers: { + 'X-SESSION-ID': session.value.session_id, + }, + } + ) + const resp = response.data as any + // Bay 代理返回格式: { success, data: { success, output: { text, images }, error, ... } } + // Ship 返回的数据在 resp.data 里 + const shipData = resp?.data || {} + if (resp?.success && shipData?.success !== false) { + const output = shipData?.output || {} + // 处理文本输出 + if (output.text) { + pythonOutput.value.push({ type: 'text', content: output.text }) + } + // 处理图片输出 (images 是数组,每个元素为 {"image/png": "base64..."}) + if (output.images && Array.isArray(output.images)) { + for (const img of output.images) { + if (img['image/png']) { + pythonOutput.value.push({ type: 'image', content: img['image/png'] }) + } + } + } + // 如果没有任何输出,显示执行完成 + if (pythonOutput.value.length === 0) { + pythonOutput.value.push({ type: 'text', content: '执行完成' }) + } + } else if (shipData?.error || resp?.error) { + pythonOutput.value.push({ type: 'error', content: shipData?.error || resp?.error }) + } + } catch { + // 错误已在 client.ts 中处理 + } finally { + pythonLoading.value = false + } + } + + const clearPythonOutput = () => { + pythonOutput.value = [] + } + + return { + sessionId, + session, + deleteConfirmVisible, + deleteLoading, + loading, + error, + refresh, + isActive, + handleDelete, + handleStart, + startLoading, + shortId, + copyToClipboard, + activeTab, + tabs, + setActiveTab, + terminalInput, + terminalHistory, + terminalLoading, + sendCommand, + clearTerminal, + refreshShipSessions, + pythonCode, + pythonOutput, + pythonLoading, + runPython, + clearPythonOutput, + getPreviousCommand, + getNextCommand, + } +} diff --git a/pkgs/bay/dashboard/src/views/sessions/index.vue b/pkgs/bay/dashboard/src/views/sessions/index.vue new file mode 100644 index 0000000..43f5b9d --- /dev/null +++ b/pkgs/bay/dashboard/src/views/sessions/index.vue @@ -0,0 +1,296 @@ + + + diff --git a/pkgs/bay/dashboard/src/views/sessions/useSessions.ts b/pkgs/bay/dashboard/src/views/sessions/useSessions.ts new file mode 100644 index 0000000..b2f3e60 --- /dev/null +++ b/pkgs/bay/dashboard/src/views/sessions/useSessions.ts @@ -0,0 +1,75 @@ +import { ref, computed } from 'vue' +import { sessionApi } from '@/api' +import type { Session } from '@/types/api' +import { useAutoRefresh } from '@/composables/useAutoRefresh' +import { toast } from '@/composables/useToast' +import { formatDateTime, getRelativeTime } from '@/utils/time' + +export function useSessions() { + const sessions = ref([]) + const searchQuery = ref('') + const activeFilter = ref(null) + const deleteConfirmId = ref(null) + const deleteLoading = ref(false) + + const fetchSessions = async () => { + const response = await sessionApi.getList() + sessions.value = response.data.sessions + } + + const { loading, error, refresh } = useAutoRefresh(fetchSessions) + + const filteredSessions = computed(() => { + let result = sessions.value + + // 按活跃状态筛选 + if (activeFilter.value !== null) { + result = result.filter(s => s.is_active === activeFilter.value) + } + + // 按关键字搜索 + if (searchQuery.value.trim()) { + const query = searchQuery.value.toLowerCase() + result = result.filter(s => + s.session_id.toLowerCase().includes(query) || + s.ship_id.toLowerCase().includes(query) + ) + } + + return result + }) + + const handleDelete = async () => { + if (!deleteConfirmId.value) return + + deleteLoading.value = true + try { + await sessionApi.delete(deleteConfirmId.value) + toast.success('会话已删除') + deleteConfirmId.value = null + await refresh() + } catch { + // 错误已在 client.ts 中处理 + } finally { + deleteLoading.value = false + } + } + + const shortId = (id: string) => id.slice(0, 8) + + return { + sessions, + filteredSessions, + searchQuery, + activeFilter, + deleteConfirmId, + deleteLoading, + loading, + error, + refresh, + handleDelete, + shortId, + formatDateTime, + getRelativeTime, + } +} diff --git a/pkgs/bay/dashboard/src/views/settings/index.vue b/pkgs/bay/dashboard/src/views/settings/index.vue new file mode 100644 index 0000000..0f473b3 --- /dev/null +++ b/pkgs/bay/dashboard/src/views/settings/index.vue @@ -0,0 +1,146 @@ + + + diff --git a/pkgs/bay/dashboard/src/views/settings/useSettings.ts b/pkgs/bay/dashboard/src/views/settings/useSettings.ts new file mode 100644 index 0000000..fdaf3c5 --- /dev/null +++ b/pkgs/bay/dashboard/src/views/settings/useSettings.ts @@ -0,0 +1,89 @@ +import { ref, computed, onMounted } from 'vue' +import { useRouter } from 'vue-router' +import { useSettingsStore } from '@/stores/settings' +import { toast } from '@/composables/useToast' + +export function useSettings() { + const settingsStore = useSettingsStore() + const router = useRouter() + + // 本地表单状态 (避免直接修改 store) + const token = ref('') + const refreshInterval = ref(5000) + const showToken = ref(false) + + // 预设的刷新间隔 + const refreshIntervalPresets = [ + { label: '5 秒', value: 5000 }, + { label: '10 秒', value: 10000 }, + { label: '30 秒', value: 30000 }, + { label: '1 分钟', value: 60000 }, + { label: '禁用', value: 0 }, + ] + + // 初始化表单 + onMounted(() => { + token.value = settingsStore.token + refreshInterval.value = settingsStore.refreshInterval + }) + + // 检查是否有更改 + const hasChanges = computed(() => { + return ( + token.value !== settingsStore.token || + refreshInterval.value !== settingsStore.refreshInterval + ) + }) + + // 表单验证(现在只验证 token) + const isValid = computed(() => true) + + const handleSave = () => { + if (!isValid.value) return + + settingsStore.updateToken(token.value.trim()) + settingsStore.updateRefreshInterval(refreshInterval.value) + toast.success('设置已保存') + } + + const handleReset = () => { + token.value = settingsStore.token + refreshInterval.value = settingsStore.refreshInterval + toast.info('已恢复到当前设置') + } + + const handleClearToken = () => { + token.value = '' + settingsStore.updateToken('') + toast.success('已退出登录') + // 跳转到登录页 + router.push('/login') + } + + const handleResetAll = () => { + settingsStore.resetSettings() + token.value = settingsStore.token + refreshInterval.value = settingsStore.refreshInterval + toast.success('已恢复默认设置') + // 清除 token 后需要重新登录 + router.push('/login') + } + + const toggleShowToken = () => { + showToken.value = !showToken.value + } + + return { + token, + refreshInterval, + showToken, + refreshIntervalPresets, + hasChanges, + isValid, + handleSave, + handleReset, + handleClearToken, + handleResetAll, + toggleShowToken, + } +} diff --git a/pkgs/bay/dashboard/src/views/ship-create/index.vue b/pkgs/bay/dashboard/src/views/ship-create/index.vue new file mode 100644 index 0000000..7800d82 --- /dev/null +++ b/pkgs/bay/dashboard/src/views/ship-create/index.vue @@ -0,0 +1,285 @@ + + + diff --git a/pkgs/bay/dashboard/src/views/ship-create/useCreateShip.ts b/pkgs/bay/dashboard/src/views/ship-create/useCreateShip.ts new file mode 100644 index 0000000..73e649a --- /dev/null +++ b/pkgs/bay/dashboard/src/views/ship-create/useCreateShip.ts @@ -0,0 +1,116 @@ +import { ref, computed } from 'vue' +import { useRouter } from 'vue-router' +import { shipApi } from '@/api' +import type { CreateShipRequest, ShipSpec } from '@/types/api' +import { toast } from '@/composables/useToast' + +// 创建模式类型 +export type CreateMode = 'quick' | 'custom' + +export function useCreateShip() { + const router = useRouter() + + // 创建模式:quick(快速模式)或 custom(自定义模式) + const createMode = ref('quick') + + // 表单数据 + const ttlMinutes = ref(60) // 默认60分钟 + const maxSessionNum = ref(1) // 默认1个会话 + const cpus = ref(undefined) + const memory = ref('') + const disk = ref('') + const submitting = ref(false) + + // 预设的 TTL 选项 + const ttlPresets = [ + { label: '30 分钟', value: 30 }, + { label: '1 小时', value: 60 }, + { label: '2 小时', value: 120 }, + { label: '4 小时', value: 240 }, + { label: '8 小时', value: 480 }, + { label: '24 小时', value: 1440 }, + ] + + // 表单验证 + const errors = computed(() => { + const errs: Record = {} + if (ttlMinutes.value < 1) { + errs.ttl = 'TTL 必须大于 0' + } + if (ttlMinutes.value > 1440 * 7) { + errs.ttl = 'TTL 最大为 7 天' + } + if (maxSessionNum.value < 1) { + errs.maxSessionNum = '最大会话数必须大于 0' + } + if (memory.value && !/^\d+(m|g|M|G|Mi|Gi)?$/.test(memory.value)) { + errs.memory = '内存格式无效,例如:512m, 1g' + } + if (disk.value && !/^\d+(g|G|Gi)?$/.test(disk.value)) { + errs.disk = '磁盘格式无效,例如:1Gi, 10G' + } + return errs + }) + + const isValid = computed(() => Object.keys(errors.value).length === 0) + + const handleSubmit = async () => { + if (!isValid.value || submitting.value) return + + submitting.value = true + try { + const spec: ShipSpec = {} + // 只在自定义模式下收集资源配置 + if (createMode.value === 'custom') { + if (cpus.value !== undefined && cpus.value > 0) { + spec.cpus = cpus.value + } + if (memory.value.trim()) { + spec.memory = memory.value.trim() + } + if (disk.value.trim()) { + spec.disk = disk.value.trim() + } + } + + const request: CreateShipRequest = { + ttl: ttlMinutes.value * 60, // 转换为秒 + max_session_num: createMode.value === 'custom' ? maxSessionNum.value : 1, + // 自定义模式下强制创建新容器,确保配置生效 + force_create: createMode.value === 'custom', + } + + // 只有在设置了 spec 时才添加 + if (Object.keys(spec).length > 0) { + request.spec = spec + } + + const response = await shipApi.create(request) + toast.success('工作区创建成功') + router.push(`/ships/${response.data.id}`) + } catch { + // 错误已在 client.ts 中处理 + } finally { + submitting.value = false + } + } + + const handleCancel = () => { + router.push('/sessions') + } + + return { + createMode, + ttlMinutes, + maxSessionNum, + cpus, + memory, + disk, + submitting, + ttlPresets, + errors, + isValid, + handleSubmit, + handleCancel, + } +} diff --git a/pkgs/bay/dashboard/src/views/ship-detail/index.vue b/pkgs/bay/dashboard/src/views/ship-detail/index.vue new file mode 100644 index 0000000..8ed263c --- /dev/null +++ b/pkgs/bay/dashboard/src/views/ship-detail/index.vue @@ -0,0 +1,433 @@ + + + diff --git a/pkgs/bay/dashboard/src/views/ship-detail/useShipDetail.ts b/pkgs/bay/dashboard/src/views/ship-detail/useShipDetail.ts new file mode 100644 index 0000000..bb278ea --- /dev/null +++ b/pkgs/bay/dashboard/src/views/ship-detail/useShipDetail.ts @@ -0,0 +1,172 @@ +import { ref, computed, watch } from 'vue' +import { useRoute, useRouter } from 'vue-router' +import { shipApi } from '@/api' +import type { Ship, Session, ExtendTTLRequest } from '@/types/api' +import { useAutoRefresh } from '@/composables/useAutoRefresh' +import { toast } from '@/composables/useToast' +import { ShipStatus } from '@/types/api' + +type TabName = 'info' | 'sessions' | 'logs' + +export function useShipDetail() { + const route = useRoute() + const router = useRouter() + + const shipId = computed(() => route.params.id as string) + const ship = ref(null) + const sessions = ref([]) + const logs = ref('') + const activeTab = ref('info') + const deleteConfirmVisible = ref(false) + const deleteLoading = ref(false) + const extendLoading = ref(false) + const recycleConfirmVisible = ref(false) + const recycleLoading = ref(false) + + const fetchShipDetail = async () => { + const response = await shipApi.getById(shipId.value) + ship.value = response.data + } + + const fetchSessions = async () => { + try { + const response = await shipApi.getSessions(shipId.value) + sessions.value = response.data.sessions + } catch { + // 容器已停止时可能无法获取会话,忽略错误 + sessions.value = [] + } + } + + const fetchLogs = async () => { + try { + const response = await shipApi.getLogs(shipId.value) + logs.value = response.data.logs + } catch { + logs.value = '' + } + } + + const { loading, error, refresh } = useAutoRefresh( + async () => { + await fetchShipDetail() + if (activeTab.value === 'sessions') { + await fetchSessions() + } else if (activeTab.value === 'logs') { + await fetchLogs() + } + } + ) + + // 监听 tab 变化以加载对应数据 + watch(activeTab, async (tab) => { + if (tab === 'sessions') { + await fetchSessions() + } else if (tab === 'logs') { + await fetchLogs() + } + }) + + const isRunning = computed(() => ship.value?.status === ShipStatus.RUNNING) + + const handleDelete = async () => { + if (!ship.value) return + + deleteLoading.value = true + try { + await shipApi.delete(shipId.value) + toast.success('容器已停止') + deleteConfirmVisible.value = false + await refresh() + } catch { + // 错误已在 client.ts 中处理 + } finally { + deleteLoading.value = false + } + } + + const handleExtend = async (minutes: number) => { + if (!ship.value) return + + extendLoading.value = true + try { + const data: ExtendTTLRequest = { ttl: minutes * 60 } + await shipApi.extendTTL(shipId.value, data) + toast.success(`已延长 ${minutes} 分钟`) + await refresh() + } catch { + // 错误已在 client.ts 中处理 + } finally { + extendLoading.value = false + } + } + + const openRecycleConfirm = async () => { + // 在显示确认弹窗前先获取关联的会话 + await fetchSessions() + recycleConfirmVisible.value = true + } + + const handleRecycle = async () => { + if (!ship.value) return + + recycleLoading.value = true + try { + await shipApi.deletePermanent(shipId.value) + toast.success('容器记录已删除') + recycleConfirmVisible.value = false + router.push('/ships') + } catch { + // 错误已在 client.ts 中处理 + } finally { + recycleLoading.value = false + } + } + + const setActiveTab = (tab: TabName) => { + activeTab.value = tab + } + + const tabs = [ + { name: 'info' as TabName, label: '信息', icon: 'info' }, + { name: 'sessions' as TabName, label: '会话', icon: 'users' }, + { name: 'logs' as TabName, label: '日志', icon: 'terminal' }, + ] + + const shortId = (id: string) => id.slice(0, 8) + + const copyToClipboard = async (text: string) => { + try { + await navigator.clipboard.writeText(text) + toast.success('已复制到剪贴板') + } catch { + toast.error('复制失败') + } + } + + return { + shipId, + ship, + sessions, + logs, + activeTab, + tabs, + deleteConfirmVisible, + deleteLoading, + extendLoading, + recycleConfirmVisible, + recycleLoading, + loading, + error, + isRunning, + refresh, + handleDelete, + handleExtend, + openRecycleConfirm, + handleRecycle, + setActiveTab, + shortId, + copyToClipboard, + ShipStatus, + } +} diff --git a/pkgs/bay/dashboard/src/views/ships/index.vue b/pkgs/bay/dashboard/src/views/ships/index.vue new file mode 100644 index 0000000..308f59b --- /dev/null +++ b/pkgs/bay/dashboard/src/views/ships/index.vue @@ -0,0 +1,279 @@ + + + diff --git a/pkgs/bay/dashboard/src/views/ships/useShips.ts b/pkgs/bay/dashboard/src/views/ships/useShips.ts new file mode 100644 index 0000000..730fe7d --- /dev/null +++ b/pkgs/bay/dashboard/src/views/ships/useShips.ts @@ -0,0 +1,84 @@ +import { ref, computed } from 'vue' +import { shipApi } from '@/api' +import type { Ship } from '@/types/api' +import { useAutoRefresh } from '@/composables/useAutoRefresh' +import { toast } from '@/composables/useToast' +import { ShipStatus } from '@/types/api' + +export function useShips() { + const ships = ref([]) + const searchQuery = ref('') + const statusFilter = ref(null) + const deleteConfirmId = ref(null) + const deleteLoading = ref(false) + + const fetchShips = async () => { + const response = await shipApi.getList() + ships.value = response.data + } + + const { loading, error, refresh } = useAutoRefresh(fetchShips) + + const filteredShips = computed(() => { + let result = ships.value + + // 按状态筛选 + if (statusFilter.value !== null) { + result = result.filter(s => s.status === statusFilter.value) + } + + // 按关键字搜索 + if (searchQuery.value.trim()) { + const query = searchQuery.value.toLowerCase() + result = result.filter(s => + s.id.toLowerCase().includes(query) || + s.ip_address?.toLowerCase().includes(query) + ) + } + + return result + }) + + const handleDelete = async () => { + if (!deleteConfirmId.value) return + + deleteLoading.value = true + try { + await shipApi.delete(deleteConfirmId.value) + toast.success('容器已停止') + deleteConfirmId.value = null + await refresh() + } catch { + // 错误已在 client.ts 中处理 + } finally { + deleteLoading.value = false + } + } + + const shortId = (id: string) => id.slice(0, 8) + + const getStatusText = (status: number) => { + switch (status) { + case ShipStatus.RUNNING: return 'Running' + case ShipStatus.STOPPED: return 'Stopped' + case ShipStatus.CREATING: return 'Creating' + default: return 'Unknown' + } + } + + return { + ships, + filteredShips, + searchQuery, + statusFilter, + deleteConfirmId, + deleteLoading, + loading, + error, + refresh, + handleDelete, + shortId, + getStatusText, + ShipStatus, + } +} diff --git a/pkgs/bay/dashboard/tsconfig.app.json b/pkgs/bay/dashboard/tsconfig.app.json new file mode 100644 index 0000000..6826f7f --- /dev/null +++ b/pkgs/bay/dashboard/tsconfig.app.json @@ -0,0 +1,20 @@ +{ + "extends": "@vue/tsconfig/tsconfig.dom.json", + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "types": ["vite/client", "node"], + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + }, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"] +} diff --git a/pkgs/bay/dashboard/tsconfig.json b/pkgs/bay/dashboard/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/pkgs/bay/dashboard/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/pkgs/bay/dashboard/tsconfig.node.json b/pkgs/bay/dashboard/tsconfig.node.json new file mode 100644 index 0000000..8a67f62 --- /dev/null +++ b/pkgs/bay/dashboard/tsconfig.node.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/pkgs/bay/dashboard/vite.config.ts b/pkgs/bay/dashboard/vite.config.ts new file mode 100644 index 0000000..45dc58c --- /dev/null +++ b/pkgs/bay/dashboard/vite.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import tailwindcss from '@tailwindcss/vite' +import { fileURLToPath, URL } from 'node:url' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [vue(), tailwindcss()], + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)) + } + }, + server: { + port: 3000, + proxy: { + '/api': { + target: 'http://localhost:8156', + changeOrigin: true, + rewrite: (path) => path.replace(/^\/api/, ''), + ws: true // 启用 WebSocket 代理 + } + } + } +}) diff --git a/pkgs/bay/docker-compose.yml b/pkgs/bay/docker-compose.yml index b7a818e..0f53723 100644 --- a/pkgs/bay/docker-compose.yml +++ b/pkgs/bay/docker-compose.yml @@ -4,7 +4,10 @@ services: shipyard: image: soulter/shipyard-bay:latest ports: + # API port (public, for agent access) - "8156:8156" + # Dashboard port (optional, can be hidden behind NAT) + - "8157:8157" environment: - PORT=8156 - DATABASE_URL=sqlite+aiosqlite:///./data/bay.db diff --git a/pkgs/bay/docker-entrypoint.sh b/pkgs/bay/docker-entrypoint.sh new file mode 100644 index 0000000..fe64daf --- /dev/null +++ b/pkgs/bay/docker-entrypoint.sh @@ -0,0 +1,25 @@ +#!/bin/sh +set -e + +# Start the Python backend in the background +echo "Starting Bay API backend..." +python run.py & +BACKEND_PID=$! + +# Wait for backend to be ready +echo "Waiting for backend to start..." +for i in $(seq 1 30); do + if curl -s http://127.0.0.1:8156/health > /dev/null 2>&1; then + echo "Backend is ready!" + break + fi + if [ $i -eq 30 ]; then + echo "Backend failed to start within 30 seconds" + exit 1 + fi + sleep 1 +done + +# Start Nginx in the foreground +echo "Starting Nginx..." +exec nginx -g "daemon off;" diff --git a/pkgs/bay/nginx.conf b/pkgs/bay/nginx.conf new file mode 100644 index 0000000..76541d2 --- /dev/null +++ b/pkgs/bay/nginx.conf @@ -0,0 +1,101 @@ +# Note: Running as root in container, no user directive needed +# user nginx; +worker_processes auto; + +error_log /var/log/nginx/error.log notice; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + + sendfile on; + keepalive_timeout 65; + + # Gzip compression + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_proxied any; + gzip_types text/plain text/css text/xml text/javascript application/javascript application/x-javascript application/xml application/json; + + # Upstream for the Python backend (internal port 8156) + upstream backend { + server 127.0.0.1:8156; + keepalive 32; + } + + # Dashboard server on port 8157 (can be hidden behind NAT) + server { + listen 8157; + server_name _; + + # Max upload size (for file uploads) + client_max_body_size 100M; + + # API proxy - all /api/* requests go to backend + location /api/ { + # Remove /api prefix when forwarding to backend + rewrite ^/api/(.*)$ /$1 break; + + proxy_pass http://backend; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # WebSocket support + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + # Timeout settings + proxy_connect_timeout 60s; + proxy_send_timeout 300s; + proxy_read_timeout 300s; + } + + # Swagger/OpenAPI docs access via dashboard + location ~ ^/(docs|redoc|openapi\.json)$ { + proxy_pass http://backend; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # Static files (dashboard) + location / { + root /usr/share/nginx/html; + index index.html; + + # Vue Router HTML5 history mode support + # Try to serve the file directly, fallback to index.html for SPA routes + try_files $uri $uri/ /index.html; + + # Cache static assets + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + } + + # Health check endpoint for Nginx + location /nginx-health { + access_log off; + return 200 "healthy\n"; + add_header Content-Type text/plain; + } + } +} diff --git a/pkgs/bay/pyproject.toml b/pkgs/bay/pyproject.toml index 0cba5e9..5f17257 100644 --- a/pkgs/bay/pyproject.toml +++ b/pkgs/bay/pyproject.toml @@ -20,6 +20,7 @@ dependencies = [ "python-multipart>=0.0.20", "tomli>=2.0.0", "kubernetes-asyncio>=34.3.3", + "websocket-client>=1.9.0", ] [project.optional-dependencies] diff --git a/pkgs/bay/tests/conftest.py b/pkgs/bay/tests/conftest.py index 5eeb2f9..470ae07 100644 --- a/pkgs/bay/tests/conftest.py +++ b/pkgs/bay/tests/conftest.py @@ -5,10 +5,14 @@ """ import os +import sys import time import uuid from pathlib import Path +# 添加 app 路径以便测试导入 +sys.path.insert(0, str(Path(__file__).parent.parent)) + import docker import pytest import requests diff --git a/pkgs/bay/tests/e2e/test_bay_api.py b/pkgs/bay/tests/e2e/test_bay_api.py index 63bdb76..00eb140 100644 --- a/pkgs/bay/tests/e2e/test_bay_api.py +++ b/pkgs/bay/tests/e2e/test_bay_api.py @@ -126,6 +126,61 @@ def test_stat(self, bay_url): resp = requests.get(f"{bay_url}/stat", timeout=5) assert resp.status_code == 200, f"统计信息失败: {resp.text}" + def test_stat_overview_without_auth(self, bay_url): + """/stat/overview 需要认证""" + resp = requests.get(f"{bay_url}/stat/overview", timeout=5) + assert resp.status_code in [401, 403], f"未授权访问未被拒绝: {resp.status_code}" + + def test_stat_overview_with_auth(self, bay_url, auth_headers): + """/stat/overview 获取系统概览(需要认证)""" + resp = requests.get(f"{bay_url}/stat/overview", headers=auth_headers, timeout=5) + assert resp.status_code == 200, f"获取系统概览失败: {resp.text}" + data = resp.json() + assert "service" in data, "响应应包含 service 字段" + assert "version" in data, "响应应包含 version 字段" + assert "ships" in data, "响应应包含 ships 字段" + assert "sessions" in data, "响应应包含 sessions 字段" + assert "total" in data["ships"], "ships 应包含 total 字段" + assert "running" in data["ships"], "ships 应包含 running 字段" + assert "stopped" in data["ships"], "ships 应包含 stopped 字段" + + +@pytest.mark.e2e +class TestSessionsEndpoints: + """阶段 2.5: Sessions 端点基础测试(认证和 404)""" + + def test_sessions_without_auth(self, bay_url): + """/sessions 需要认证""" + resp = requests.get(f"{bay_url}/sessions", timeout=5) + assert resp.status_code in [401, 403], f"未授权访问未被拒绝: {resp.status_code}" + + def test_list_sessions_with_auth(self, bay_url, auth_headers): + """/sessions 列出所有会话(需要认证)""" + resp = requests.get(f"{bay_url}/sessions", headers=auth_headers, timeout=5) + assert resp.status_code == 200, f"列出会话失败: {resp.text}" + data = resp.json() + assert "sessions" in data, "响应应包含 sessions 字段" + assert "total" in data, "响应应包含 total 字段" + assert isinstance(data["sessions"], list), "sessions 应该是列表" + + def test_get_session_not_found(self, bay_url, auth_headers): + """/sessions/{session_id} 获取不存在的会话""" + resp = requests.get( + f"{bay_url}/sessions/not-exists-session", + headers=auth_headers, + timeout=5, + ) + assert resp.status_code == 404, f"不存在会话未返回 404: {resp.status_code}" + + def test_ship_sessions_not_found(self, bay_url, auth_headers): + """/ship/{ship_id}/sessions 获取不存在 ship 的会话""" + resp = requests.get( + f"{bay_url}/ship/not-exists-ship/sessions", + headers=auth_headers, + timeout=5, + ) + assert resp.status_code == 404, f"不存在 ship 未返回 404: {resp.status_code}" + @pytest.mark.e2e class TestAuthentication: @@ -224,8 +279,9 @@ def test_get_ship_not_found(self, bay_url, auth_headers): assert resp.status_code == 404, f"不存在 ship 未返回 404: {resp.status_code}" def test_exec_shell(self, bay_url): - """执行 Shell 命令""" + """执行 Shell 命令并验证会话状态""" with fresh_ship() as (ship_id, headers): + # 执行 Shell 命令 payload = {"type": "shell/exec", "payload": {"command": "echo Bay"}} resp = requests.post( f"{bay_url}/ship/{ship_id}/exec", @@ -235,6 +291,17 @@ def test_exec_shell(self, bay_url): ) assert resp.status_code == 200, f"Shell 命令执行失败: {resp.text}" + # 同时验证会话状态 - 真实场景下 Dashboard 会并行查询 + sessions_resp = requests.get( + f"{bay_url}/ship/{ship_id}/sessions", + headers=headers, + timeout=5, + ) + assert sessions_resp.status_code == 200, f"获取会话失败: {sessions_resp.text}" + sessions_data = sessions_resp.json() + assert sessions_data["total"] >= 1, "执行操作后应该有活跃会话" + assert sessions_data["sessions"][0]["is_active"] is True, "会话应该是活跃状态" + def test_exec_invalid_type(self, bay_url): """非法操作类型""" with fresh_ship() as (ship_id, headers): @@ -377,10 +444,20 @@ def test_filesystem_operations(self, bay_url): assert result.get("success") is True, f"删除文件操作失败: {result}" def test_ipython_operations(self, bay_url): - """IPython 操作测试""" + """IPython 操作测试,同时验证会话和系统概览""" with fresh_ship() as (ship_id, headers): headers_with_content_type = {**headers, "Content-Type": "application/json"} + # 获取执行前的系统概览 + overview_before = requests.get( + f"{bay_url}/stat/overview", + headers=headers, + timeout=5, + ) + assert overview_before.status_code == 200, f"获取概览失败: {overview_before.text}" + before_data = overview_before.json() + running_before = before_data["ships"]["running"] + # 1. 执行简单 Python 代码 ipython_data = { "type": "ipython/exec", @@ -397,6 +474,18 @@ def test_ipython_operations(self, bay_url): assert result.get("success") is True, f"IPython 操作失败: {result}" assert "Result: 8" in result["data"]["output"]["text"], f"IPython 输出不匹配: {result}" + # 执行期间验证会话活跃状态 + sessions_resp = requests.get( + f"{bay_url}/ship/{ship_id}/sessions", + headers=headers, + timeout=5, + ) + assert sessions_resp.status_code == 200, f"获取会话失败: {sessions_resp.text}" + sessions_data = sessions_resp.json() + assert sessions_data["total"] >= 1, "应该有活跃会话" + # 验证 last_activity 被更新(会话应该是活跃的) + assert sessions_data["sessions"][0]["is_active"] is True, "IPython 执行后会话应该活跃" + # 2. 执行带 import 的代码 import_data = { "type": "ipython/exec", @@ -416,6 +505,17 @@ def test_ipython_operations(self, bay_url): assert result.get("success") is True, f"IPython import 操作失败: {result}" assert "Square root of 16 is 4.0" in result["data"]["output"]["text"], f"IPython import 输出不匹配: {result}" + # 获取执行后的系统概览,验证统计数据一致性 + overview_after = requests.get( + f"{bay_url}/stat/overview", + headers=headers, + timeout=5, + ) + assert overview_after.status_code == 200, f"获取概览失败: {overview_after.text}" + after_data = overview_after.json() + # 运行中的 ships 数量应该至少保持不变(可能有其他测试创建的) + assert after_data["ships"]["running"] >= 1, "应该至少有一个运行中的 ship" + @pytest.mark.e2e class TestShipDeletion: @@ -671,3 +771,938 @@ def test_data_persistence(self, bay_url): ) except Exception: pass + + +# ============================================================================= +# Ships 路由补充测试 +# ============================================================================= + + +@pytest.mark.e2e +class TestShipsRouteExtended: + """Ships 路由扩展测试:补充 ships.py 相关端点的测试""" + + def test_create_ship_missing_session_id(self, bay_url): + """/ship 创建 Ship 需要 X-SESSION-ID 头""" + payload = {"ttl": 60, "max_session_num": 1} + headers = {"Authorization": f"Bearer {ACCESS_TOKEN}", "Content-Type": "application/json"} + # 不包含 X-SESSION-ID + resp = requests.post( + f"{bay_url}/ship", + headers=headers, + json=payload, + timeout=10, + ) + assert resp.status_code == 422, f"缺少 X-SESSION-ID 未被拒绝: {resp.status_code}" + + def test_create_ship_response_fields(self, bay_url): + """验证创建 Ship 响应包含所有必需字段""" + with fresh_ship() as (ship_id, headers): + resp = requests.get(f"{bay_url}/ship/{ship_id}", headers=headers, timeout=5) + assert resp.status_code == 200 + data = resp.json() + + # 验证必需字段 + assert "id" in data, "响应应包含 id 字段" + assert "status" in data, "响应应包含 status 字段" + assert "created_at" in data, "响应应包含 created_at 字段" + assert "updated_at" in data, "响应应包含 updated_at 字段" + assert "ttl" in data, "响应应包含 ttl 字段" + assert "max_session_num" in data, "响应应包含 max_session_num 字段" + assert "current_session_num" in data, "响应应包含 current_session_num 字段" + + def test_list_ships_returns_created_ship(self, bay_url): + """验证创建的 Ship 出现在列表中""" + with fresh_ship() as (ship_id, headers): + resp = requests.get(f"{bay_url}/ships", headers=headers, timeout=5) + assert resp.status_code == 200, f"列出 ships 失败: {resp.text}" + data = resp.json() + ship_ids = [ship["id"] for ship in data] + assert ship_id in ship_ids, f"创建的 Ship {ship_id} 应出现在列表中" + + +@pytest.mark.e2e +class TestShipPermanentDeletion: + """永久删除 Ship 端点测试""" + + def test_delete_permanent_without_auth(self, bay_url): + """/ship/{ship_id}/permanent 删除需要认证""" + resp = requests.delete(f"{bay_url}/ship/some-id/permanent", timeout=5) + assert resp.status_code in [401, 403], f"未授权访问未被拒绝: {resp.status_code}" + + def test_delete_permanent_not_found(self, bay_url, auth_headers): + """/ship/{ship_id}/permanent 删除不存在的 Ship""" + resp = requests.delete( + f"{bay_url}/ship/non-existent-ship-id/permanent", + headers=auth_headers, + timeout=10, + ) + assert resp.status_code == 404, f"不存在 Ship 未返回 404: {resp.status_code}" + + def test_delete_permanent_success(self, bay_url): + """成功永久删除 Ship""" + # 创建 Ship + session_id = f"perm-delete-{uuid.uuid4().hex[:8]}" + headers = get_auth_headers(session_id) + + payload = {"ttl": 60, "max_session_num": 1, "spec": {"cpus": 0.5, "memory": "256m"}} + resp = requests.post( + f"{bay_url}/ship", + headers={**headers, "Content-Type": "application/json"}, + json=payload, + timeout=120, + ) + assert resp.status_code == 201, f"创建 Ship 失败: {resp.text}" + ship_id = resp.json()["id"] + + time.sleep(2) + + # 永久删除 Ship + resp = requests.delete( + f"{bay_url}/ship/{ship_id}/permanent", + headers=headers, + timeout=30, + ) + assert resp.status_code == 204, f"永久删除 Ship 失败: {resp.status_code}" + + # 验证 Ship 已被永久删除(无法获取) + resp = requests.get(f"{bay_url}/ship/{ship_id}", headers=headers, timeout=5) + assert resp.status_code == 404, f"永久删除后 Ship 应返回 404: {resp.status_code}" + + +@pytest.mark.e2e +class TestShipFileOperationsExtended: + """Ship 文件操作扩展测试""" + + def test_upload_without_auth(self, bay_url): + """/ship/{ship_id}/upload 需要认证""" + files = {"file": ("test.txt", io.BytesIO(b"hello"), "text/plain")} + data = {"file_path": "test.txt"} + resp = requests.post( + f"{bay_url}/ship/some-id/upload", + files=files, + data=data, + timeout=10, + ) + assert resp.status_code in [401, 403], f"未授权访问未被拒绝: {resp.status_code}" + + def test_download_without_auth(self, bay_url): + """/ship/{ship_id}/download 需要认证""" + resp = requests.get( + f"{bay_url}/ship/some-id/download", + params={"file_path": "test.txt"}, + timeout=10, + ) + assert resp.status_code in [401, 403], f"未授权访问未被拒绝: {resp.status_code}" + + def test_download_not_found(self, bay_url): + """下载不存在的文件""" + with fresh_ship() as (ship_id, headers): + resp = requests.get( + f"{bay_url}/ship/{ship_id}/download", + headers=headers, + params={"file_path": "non_existent_file_12345.txt"}, + timeout=30, + ) + assert resp.status_code == 404, f"不存在文件未返回 404: {resp.status_code}" + + def test_upload_to_subdirectory(self, bay_url): + """上传文件到子目录""" + with fresh_ship() as (ship_id, headers): + content = b"content in subdirectory" + files = {"file": ("nested.txt", io.BytesIO(content), "text/plain")} + data = {"file_path": "subdir/nested.txt"} + + resp = requests.post( + f"{bay_url}/ship/{ship_id}/upload", + headers=headers, + files=files, + data=data, + timeout=30, + ) + assert resp.status_code == 200, f"上传到子目录失败: {resp.text}" + + # 验证可以下载 + download_resp = requests.get( + f"{bay_url}/ship/{ship_id}/download", + headers=headers, + params={"file_path": "subdir/nested.txt"}, + timeout=30, + ) + assert download_resp.status_code == 200, f"从子目录下载失败: {download_resp.status_code}" + assert download_resp.content == content, "下载内容不匹配" + + +@pytest.mark.e2e +class TestShipExecExtended: + """Ship 执行操作扩展测试""" + + def test_exec_without_auth(self, bay_url): + """/ship/{ship_id}/exec 需要认证""" + payload = {"type": "shell/exec", "payload": {"command": "echo hello"}} + resp = requests.post( + f"{bay_url}/ship/some-id/exec", + json=payload, + headers={"Content-Type": "application/json"}, + timeout=10, + ) + assert resp.status_code in [401, 403], f"未授权访问未被拒绝: {resp.status_code}" + + def test_exec_missing_session_id(self, bay_url): + """/ship/{ship_id}/exec 需要 X-SESSION-ID 头""" + with fresh_ship() as (ship_id, _): + payload = {"type": "shell/exec", "payload": {"command": "echo hello"}} + headers = {"Authorization": f"Bearer {ACCESS_TOKEN}", "Content-Type": "application/json"} + # 不包含 X-SESSION-ID + resp = requests.post( + f"{bay_url}/ship/{ship_id}/exec", + json=payload, + headers=headers, + timeout=10, + ) + assert resp.status_code == 422, f"缺少 X-SESSION-ID 未被拒绝: {resp.status_code}" + + def test_exec_fs_delete_file(self, bay_url): + """执行文件系统删除文件操作""" + with fresh_ship() as (ship_id, headers): + headers_with_content_type = {**headers, "Content-Type": "application/json"} + + # 先创建文件 + create_payload = { + "type": "fs/create_file", + "payload": {"path": "to_delete.txt", "content": "will be deleted"}, + } + resp = requests.post( + f"{bay_url}/ship/{ship_id}/exec", + headers=headers_with_content_type, + json=create_payload, + timeout=30, + ) + assert resp.status_code == 200, f"创建文件失败: {resp.text}" + + # 删除文件 + delete_payload = {"type": "fs/delete_file", "payload": {"path": "to_delete.txt"}} + resp = requests.post( + f"{bay_url}/ship/{ship_id}/exec", + headers=headers_with_content_type, + json=delete_payload, + timeout=30, + ) + assert resp.status_code == 200, f"删除文件失败: {resp.text}" + data = resp.json() + assert data.get("success") is True + + # 验证文件已删除(读取应该失败) + read_payload = {"type": "fs/read_file", "payload": {"path": "to_delete.txt"}} + resp = requests.post( + f"{bay_url}/ship/{ship_id}/exec", + headers=headers_with_content_type, + json=read_payload, + timeout=30, + ) + # 文件不存在时应该返回错误 + data = resp.json() + assert data.get("success") is False or resp.status_code != 200 + + +@pytest.mark.e2e +class TestExtendTTLExtended: + """扩展 TTL 端点扩展测试""" + + def test_extend_ttl_without_auth(self, bay_url): + """/ship/{ship_id}/extend-ttl 需要认证""" + payload = {"ttl": 600} + resp = requests.post( + f"{bay_url}/ship/some-id/extend-ttl", + json=payload, + headers={"Content-Type": "application/json"}, + timeout=10, + ) + assert resp.status_code in [401, 403], f"未授权访问未被拒绝: {resp.status_code}" + + def test_extend_ttl_not_found(self, bay_url, auth_headers): + """/ship/{ship_id}/extend-ttl 对不存在的 Ship""" + payload = {"ttl": 600} + resp = requests.post( + f"{bay_url}/ship/non-existent-ship-id/extend-ttl", + headers={**auth_headers, "Content-Type": "application/json"}, + json=payload, + timeout=10, + ) + assert resp.status_code == 404, f"不存在 Ship 未返回 404: {resp.status_code}" + + def test_extend_ttl_invalid_value(self, bay_url): + """扩展 TTL(无效值)""" + with fresh_ship() as (ship_id, headers): + payload = {"ttl": 0} + resp = requests.post( + f"{bay_url}/ship/{ship_id}/extend-ttl", + headers={**headers, "Content-Type": "application/json"}, + json=payload, + timeout=10, + ) + assert resp.status_code == 422, f"无效 TTL 未被拒绝: {resp.status_code}" + + def test_extend_ttl_negative_value(self, bay_url): + """扩展 TTL(负值)""" + with fresh_ship() as (ship_id, headers): + payload = {"ttl": -100} + resp = requests.post( + f"{bay_url}/ship/{ship_id}/extend-ttl", + headers={**headers, "Content-Type": "application/json"}, + json=payload, + timeout=10, + ) + assert resp.status_code == 422, f"负数 TTL 未被拒绝: {resp.status_code}" + + +@pytest.mark.e2e +class TestStartShip: + """启动已停止的 Ship 端点测试""" + + def test_start_ship_without_auth(self, bay_url): + """/ship/{ship_id}/start 需要认证""" + payload = {"ttl": 3600} + resp = requests.post( + f"{bay_url}/ship/some-id/start", + json=payload, + headers={"Content-Type": "application/json"}, + timeout=10, + ) + assert resp.status_code in [401, 403], f"未授权访问未被拒绝: {resp.status_code}" + + def test_start_ship_not_found(self, bay_url, auth_headers): + """/ship/{ship_id}/start 对不存在的 Ship""" + payload = {"ttl": 3600} + resp = requests.post( + f"{bay_url}/ship/non-existent-ship-id/start", + headers={**auth_headers, "Content-Type": "application/json"}, + json=payload, + timeout=10, + ) + assert resp.status_code == 404, f"不存在 Ship 未返回 404: {resp.status_code}" + + def test_start_ship_invalid_ttl(self, bay_url): + """启动 Ship(无效 TTL 值)""" + with fresh_ship() as (ship_id, headers): + # 先停止 Ship + requests.delete( + f"{bay_url}/ship/{ship_id}", + headers=headers, + timeout=30, + ) + time.sleep(2) + + # 尝试用无效的 TTL 启动 + payload = {"ttl": 0} + resp = requests.post( + f"{bay_url}/ship/{ship_id}/start", + headers={**headers, "Content-Type": "application/json"}, + json=payload, + timeout=10, + ) + assert resp.status_code == 422, f"无效 TTL 未被拒绝: {resp.status_code}" + + def test_start_stopped_ship(self, bay_url): + """成功启动已停止的 Ship""" + # 创建 Ship + session_id = f"start-test-{uuid.uuid4().hex[:8]}" + headers = get_auth_headers(session_id) + + payload = {"ttl": 120, "max_session_num": 1, "spec": {"cpus": 0.5, "memory": "256m"}} + resp = requests.post( + f"{bay_url}/ship", + headers={**headers, "Content-Type": "application/json"}, + json=payload, + timeout=120, + ) + assert resp.status_code == 201, f"创建 Ship 失败: {resp.text}" + ship_id = resp.json()["id"] + + try: + time.sleep(2) + + # 停止 Ship + resp = requests.delete( + f"{bay_url}/ship/{ship_id}", + headers=headers, + timeout=30, + ) + assert resp.status_code == 204, f"停止 Ship 失败: {resp.status_code}" + + time.sleep(2) + + # 启动 Ship + start_payload = {"ttl": 3600} + resp = requests.post( + f"{bay_url}/ship/{ship_id}/start", + headers={**headers, "Content-Type": "application/json"}, + json=start_payload, + timeout=120, + ) + assert resp.status_code == 200, f"启动 Ship 失败: {resp.status_code} - {resp.text}" + + # 验证 Ship 已启动 + data = resp.json() + assert data["status"] == 1, f"Ship 状态应为 RUNNING (1): {data['status']}" + assert data["id"] == ship_id, "Ship ID 应匹配" + + finally: + # 清理 + try: + requests.delete( + f"{bay_url}/ship/{ship_id}", + headers=headers, + timeout=30, + ) + # 永久删除以清理资源 + requests.delete( + f"{bay_url}/ship/{ship_id}/permanent", + headers=headers, + timeout=30, + ) + except Exception: + pass + + +@pytest.mark.e2e +class TestShipLogsExtended: + """Ship 日志端点扩展测试""" + + def test_logs_without_auth(self, bay_url): + """/ship/logs/{ship_id} 需要认证""" + resp = requests.get(f"{bay_url}/ship/logs/some-id", timeout=5) + assert resp.status_code in [401, 403], f"未授权访问未被拒绝: {resp.status_code}" + + def test_logs_response_format(self, bay_url): + """验证日志响应格式""" + with fresh_ship() as (ship_id, headers): + resp = requests.get( + f"{bay_url}/ship/logs/{ship_id}", headers=headers, timeout=10 + ) + assert resp.status_code == 200, f"获取日志失败: {resp.text}" + data = resp.json() + assert "logs" in data, "响应应包含 logs 字段" + assert isinstance(data["logs"], str), "logs 应该是字符串" + + +@pytest.mark.e2e +class TestShipSessionsExtended: + """Ship 会话端点扩展测试""" + + def test_ship_sessions_without_auth(self, bay_url): + """/ship/{ship_id}/sessions 需要认证""" + resp = requests.get(f"{bay_url}/ship/some-id/sessions", timeout=5) + assert resp.status_code in [401, 403], f"未授权访问未被拒绝: {resp.status_code}" + + def test_ship_sessions_response_format(self, bay_url): + """验证 Ship 会话响应格式""" + with fresh_ship() as (ship_id, headers): + resp = requests.get( + f"{bay_url}/ship/{ship_id}/sessions", headers=headers, timeout=5 + ) + assert resp.status_code == 200, f"获取会话失败: {resp.text}" + data = resp.json() + assert "sessions" in data, "响应应包含 sessions 字段" + assert "total" in data, "响应应包含 total 字段" + assert isinstance(data["sessions"], list), "sessions 应该是列表" + assert data["total"] >= 1, "应该至少有一个会话" + + # 验证会话字段 + if data["sessions"]: + session = data["sessions"][0] + assert "session_id" in session, "会话应包含 session_id" + assert "ship_id" in session, "会话应包含 ship_id" + assert "is_active" in session, "会话应包含 is_active" + + +@pytest.mark.e2e +class TestSessionStateOnShipStop: + """测试 Ship 停止时会话状态变化""" + + def test_session_becomes_inactive_when_ship_stopped(self, bay_url): + """当 Ship 停止时,关联的会话应该变为 inactive""" + # 创建 Ship + session_id = f"stop-session-test-{uuid.uuid4().hex[:8]}" + headers = get_auth_headers(session_id) + + payload = { + "ttl": 600, + "max_session_num": 1, + "spec": {"cpus": 0.5, "memory": "256m"}, + } + + resp = requests.post( + f"{bay_url}/ship", + headers={**headers, "Content-Type": "application/json"}, + json=payload, + timeout=120, + ) + assert resp.status_code == 201, f"创建 Ship 失败: {resp.status_code}" + ship_id = resp.json()["id"] + + try: + time.sleep(2) + + # 验证会话初始是活跃的 + sessions_resp = requests.get( + f"{bay_url}/ship/{ship_id}/sessions", + headers=headers, + timeout=5, + ) + assert sessions_resp.status_code == 200 + sessions_data = sessions_resp.json() + assert sessions_data["total"] >= 1, "应该有会话" + assert sessions_data["sessions"][0]["is_active"] is True, "会话应该是活跃的" + + # 停止 Ship (soft delete) + resp = requests.delete( + f"{bay_url}/ship/{ship_id}", + headers=headers, + timeout=30, + ) + assert resp.status_code == 204, f"停止 Ship 失败: {resp.status_code}" + + time.sleep(1) + + # 验证会话现在是非活跃的 + sessions_resp = requests.get( + f"{bay_url}/sessions/{session_id}", + headers=headers, + timeout=5, + ) + assert sessions_resp.status_code == 200, f"获取会话失败: {sessions_resp.text}" + session_data = sessions_resp.json() + assert session_data["is_active"] is False, "Ship 停止后会话应该变为 inactive" + + # 验证重新启动 Ship 后会话可以恢复 + start_payload = {"ttl": 600} + resp = requests.post( + f"{bay_url}/ship/{ship_id}/start", + headers={**headers, "Content-Type": "application/json"}, + json=start_payload, + timeout=120, + ) + assert resp.status_code == 200, f"启动 Ship 失败: {resp.status_code}" + + # 验证会话现在是活跃的 + sessions_resp = requests.get( + f"{bay_url}/sessions/{session_id}", + headers=headers, + timeout=5, + ) + assert sessions_resp.status_code == 200 + session_data = sessions_resp.json() + assert session_data["is_active"] is True, "Ship 启动后会话应该恢复为 active" + + finally: + # 清理 + try: + requests.delete( + f"{bay_url}/ship/{ship_id}", + headers=headers, + timeout=30, + ) + requests.delete( + f"{bay_url}/ship/{ship_id}/permanent", + headers=headers, + timeout=30, + ) + except Exception: + pass + + def test_multiple_sessions_become_inactive_when_ship_stopped(self, bay_url): + """当 Ship 停止时,所有关联的会话都应该变为 inactive""" + # 创建第一个 session + session_id_1 = f"multi-stop-test-{uuid.uuid4().hex[:8]}" + headers_1 = get_auth_headers(session_id_1) + + # 创建 Ship with max_session_num = 2 + payload = { + "ttl": 600, + "max_session_num": 2, + "spec": {"cpus": 0.5, "memory": "256m"}, + } + + resp = requests.post( + f"{bay_url}/ship", + headers={**headers_1, "Content-Type": "application/json"}, + json=payload, + timeout=120, + ) + assert resp.status_code == 201, f"创建 Ship 失败: {resp.status_code}" + ship_id = resp.json()["id"] + + try: + time.sleep(3) + + # 用第二个 session ID 加入 + session_id_2 = f"multi-stop-test-{uuid.uuid4().hex[:8]}" + headers_2 = get_auth_headers(session_id_2) + + resp = requests.post( + f"{bay_url}/ship", + headers={**headers_2, "Content-Type": "application/json"}, + json=payload, + timeout=120, + ) + assert resp.status_code == 201, f"第二个会话请求失败: {resp.status_code}" + + # 验证两个会话都是活跃的 + sessions_resp = requests.get( + f"{bay_url}/ship/{ship_id}/sessions", + headers=headers_1, + timeout=5, + ) + assert sessions_resp.status_code == 200 + sessions_data = sessions_resp.json() + active_count = sum(1 for s in sessions_data["sessions"] if s["is_active"]) + assert active_count >= 1, "至少应该有一个活跃会话" + + # 停止 Ship + resp = requests.delete( + f"{bay_url}/ship/{ship_id}", + headers=headers_1, + timeout=30, + ) + assert resp.status_code == 204, f"停止 Ship 失败: {resp.status_code}" + + time.sleep(1) + + # 验证所有会话现在都是非活跃的 + for sid, hdrs in [(session_id_1, headers_1), (session_id_2, headers_2)]: + sessions_resp = requests.get( + f"{bay_url}/sessions/{sid}", + headers=hdrs, + timeout=5, + ) + if sessions_resp.status_code == 200: + session_data = sessions_resp.json() + assert session_data["is_active"] is False, f"会话 {sid} 应该变为 inactive" + + finally: + # 清理 + try: + requests.delete( + f"{bay_url}/ship/{ship_id}/permanent", + headers=headers_1, + timeout=30, + ) + except Exception: + pass + + +# ============================================================================= +# WebSocket Terminal 端点测试 +# ============================================================================= + +try: + import websocket + WEBSOCKET_AVAILABLE = True +except ImportError: + WEBSOCKET_AVAILABLE = False + + +@pytest.mark.e2e +@pytest.mark.skipif(not WEBSOCKET_AVAILABLE, reason="websocket-client 未安装") +class TestWebSocketTerminal: + """WebSocket Terminal 端点测试 (/ship/{ship_id}/term) + + 测试 WebSocket 代理终端功能,包括: + - 认证验证 + - 会话权限验证 + - 基本连接和消息传递 + """ + + def _get_ws_url(self, ship_id: str, token: str, session_id: str, cols: int = 80, rows: int = 24) -> str: + """构建 WebSocket URL""" + # 将 http:// 替换为 ws:// + ws_base = BAY_URL.replace("http://", "ws://").replace("https://", "wss://") + return f"{ws_base}/ship/{ship_id}/term?token={token}&session_id={session_id}&cols={cols}&rows={rows}" + + def test_websocket_unauthorized(self, bay_url): + """WebSocket 连接需要有效的 token""" + # 使用无效 token 尝试连接 + ws_url = self._get_ws_url("some-ship-id", "invalid-token", "test-session") + + ws = websocket.WebSocket() + try: + ws.connect(ws_url, timeout=5) + # 如果连接成功,应该很快被关闭 + # 尝试接收消息看是否被关闭 + try: + ws.recv() + pytest.fail("应该被拒绝连接") + except websocket.WebSocketConnectionClosedException: + pass # 预期行为 - 连接被关闭 + except websocket.WebSocketBadStatusException as e: + # 也可能在握手时就被拒绝 + assert e.status_code in [401, 403, 4001], f"未授权访问应被拒绝: {e.status_code}" + except Exception as e: + # 连接失败也是可接受的 + pass + finally: + try: + ws.close() + except Exception: + pass + + def test_websocket_ship_not_found(self, bay_url): + """WebSocket 连接到不存在的 Ship""" + ws_url = self._get_ws_url("non-existent-ship-id", ACCESS_TOKEN, "test-session") + + ws = websocket.WebSocket() + try: + ws.connect(ws_url, timeout=5) + # 如果连接成功,应该很快被关闭 + try: + ws.recv() + pytest.fail("应该被关闭连接") + except websocket.WebSocketConnectionClosedException: + pass # 预期行为 + except websocket.WebSocketBadStatusException as e: + # 可能在握手时就被拒绝 + # 403 表示会话无权限访问(可能先检查权限),404 表示 Ship 不存在 + assert e.status_code in [403, 404, 4003, 4004], f"不存在 Ship 应返回相应错误: {e.status_code}" + except Exception: + pass # 连接失败也是可接受的 + finally: + try: + ws.close() + except Exception: + pass + + def test_websocket_session_no_access(self, bay_url): + """WebSocket 连接 - 会话无权访问 Ship""" + with fresh_ship() as (ship_id, headers): + # 使用不同的 session_id 尝试连接 + different_session_id = f"different-{uuid.uuid4().hex[:8]}" + ws_url = self._get_ws_url(ship_id, ACCESS_TOKEN, different_session_id) + + ws = websocket.WebSocket() + try: + ws.connect(ws_url, timeout=10) + # 如果连接成功,应该很快被关闭 + try: + ws.recv() + pytest.fail("无权访问的会话应被拒绝") + except websocket.WebSocketConnectionClosedException: + pass # 预期行为 + except websocket.WebSocketBadStatusException as e: + # 可能在握手时就被拒绝 + assert e.status_code in [403, 4003], f"无权访问应返回相应错误: {e.status_code}" + except Exception: + pass # 连接失败也是可接受的 + finally: + try: + ws.close() + except Exception: + pass + + def test_websocket_connect_success(self, bay_url): + """WebSocket 成功连接并发送/接收消息""" + # 创建 Ship 并获取 session_id + test_session_id = f"ws-test-{uuid.uuid4().hex[:8]}" + + with fresh_ship(session_id=test_session_id) as (ship_id, headers): + # 等待 Ship 完全就绪 + time.sleep(3) + + ws_url = self._get_ws_url(ship_id, ACCESS_TOKEN, test_session_id) + + ws = websocket.WebSocket() + try: + ws.connect(ws_url, timeout=10) + + # 发送一个简单的命令 + ws.send("echo 'WebSocket Test'\n") + + # 等待并接收响应 + response_received = False + for _ in range(10): # 最多等待 10 次 + try: + ws.settimeout(2) + data = ws.recv() + if data: + response_received = True + break + except websocket.WebSocketTimeoutException: + continue + except Exception: + break + + # 验证收到了响应 + assert response_received, "应该收到 WebSocket 响应" + + except websocket.WebSocketBadStatusException as e: + pytest.fail(f"WebSocket 连接失败: {e.status_code}") + except Exception as e: + pytest.fail(f"WebSocket 测试失败: {e}") + finally: + try: + ws.close() + except Exception: + pass + + def test_websocket_custom_terminal_size(self, bay_url): + """WebSocket 连接使用自定义终端大小""" + test_session_id = f"ws-size-{uuid.uuid4().hex[:8]}" + + with fresh_ship(session_id=test_session_id) as (ship_id, headers): + time.sleep(3) + + # 使用自定义终端大小 + ws_url = self._get_ws_url(ship_id, ACCESS_TOKEN, test_session_id, cols=120, rows=40) + + ws = websocket.WebSocket() + try: + ws.connect(ws_url, timeout=10) + + # 发送命令获取终端大小 + ws.send("stty size\n") + + # 等待响应 + for _ in range(10): + try: + ws.settimeout(2) + data = ws.recv() + if data and ("40" in data or "120" in data): + # 终端大小设置成功 + break + except websocket.WebSocketTimeoutException: + continue + except Exception: + break + + except Exception: + pass # 自定义大小测试可能在某些环境下失败 + finally: + try: + ws.close() + except Exception: + pass + + +# 使用 asyncio 进行更高级的 WebSocket 测试 +try: + import asyncio + import aiohttp + AIOHTTP_AVAILABLE = True +except ImportError: + AIOHTTP_AVAILABLE = False + + +@pytest.mark.e2e +@pytest.mark.skipif(not AIOHTTP_AVAILABLE, reason="aiohttp 未安装") +class TestWebSocketTerminalAsync: + """使用 aiohttp 进行异步 WebSocket 测试""" + + def _get_ws_url(self, ship_id: str, token: str, session_id: str, cols: int = 80, rows: int = 24) -> str: + """构建 WebSocket URL""" + ws_base = BAY_URL.replace("http://", "ws://").replace("https://", "wss://") + return f"{ws_base}/ship/{ship_id}/term?token={token}&session_id={session_id}&cols={cols}&rows={rows}" + + def test_websocket_bidirectional_communication(self, bay_url): + """测试 WebSocket 双向通信""" + async def run_test(): + test_session_id = f"ws-async-{uuid.uuid4().hex[:8]}" + + # 需要同步创建 Ship + headers = get_auth_headers(test_session_id) + payload = {"ttl": 120, "max_session_num": 1, "spec": {"cpus": 0.5, "memory": "256m"}} + + resp = requests.post( + f"{BAY_URL}/ship", + headers={**headers, "Content-Type": "application/json"}, + json=payload, + timeout=120, + ) + if resp.status_code != 201: + pytest.skip(f"创建 Ship 失败: {resp.status_code}") + + ship_id = resp.json()["id"] + + try: + # 等待 Ship 就绪 + await asyncio.sleep(3) + + ws_url = self._get_ws_url(ship_id, ACCESS_TOKEN, test_session_id) + + async with aiohttp.ClientSession() as session: + try: + async with session.ws_connect(ws_url, timeout=10) as ws: + # 发送命令 + await ws.send_str("echo 'Async Test'\n") + + # 接收响应 + received_data = [] + try: + async for msg in ws: + if msg.type == aiohttp.WSMsgType.TEXT: + received_data.append(msg.data) + if "Async Test" in msg.data or len(received_data) > 5: + break + elif msg.type == aiohttp.WSMsgType.ERROR: + break + except asyncio.TimeoutError: + pass + + assert len(received_data) > 0, "应该收到 WebSocket 响应" + + except aiohttp.ClientError as e: + pytest.fail(f"WebSocket 连接失败: {e}") + + finally: + # 清理 + requests.delete( + f"{BAY_URL}/ship/{ship_id}", + headers=headers, + timeout=30, + ) + + asyncio.get_event_loop().run_until_complete(run_test()) + + def test_websocket_connection_close(self, bay_url): + """测试 WebSocket 连接关闭""" + async def run_test(): + test_session_id = f"ws-close-{uuid.uuid4().hex[:8]}" + + headers = get_auth_headers(test_session_id) + payload = {"ttl": 120, "max_session_num": 1, "spec": {"cpus": 0.5, "memory": "256m"}} + + resp = requests.post( + f"{BAY_URL}/ship", + headers={**headers, "Content-Type": "application/json"}, + json=payload, + timeout=120, + ) + if resp.status_code != 201: + pytest.skip(f"创建 Ship 失败: {resp.status_code}") + + ship_id = resp.json()["id"] + + try: + await asyncio.sleep(3) + + ws_url = self._get_ws_url(ship_id, ACCESS_TOKEN, test_session_id) + + async with aiohttp.ClientSession() as session: + ws = await session.ws_connect(ws_url, timeout=10) + + # 验证连接成功 + assert not ws.closed, "WebSocket 应该已连接" + + # 正常关闭连接 + await ws.close() + + # 验证连接已关闭 + assert ws.closed, "WebSocket 应该已关闭" + + finally: + requests.delete( + f"{BAY_URL}/ship/{ship_id}", + headers=headers, + timeout=30, + ) + + asyncio.get_event_loop().run_until_complete(run_test()) diff --git a/pkgs/bay/tests/k8s/k8s-deploy-local.yaml b/pkgs/bay/tests/k8s/k8s-deploy-local.yaml deleted file mode 100644 index ec3cb96..0000000 --- a/pkgs/bay/tests/k8s/k8s-deploy-local.yaml +++ /dev/null @@ -1,163 +0,0 @@ ---- -# Shipyard Kubernetes Deployment (测试用) -# -# 用于在本地 K8s 集群(如 kind, minikube, k3s)中测试 Kubernetes 驱动 -# -# 使用方法: -# kubectl apply -f k8s-deploy.yaml -# kubectl port-forward svc/bay 8156:8156 -n shipyard -# ---- -apiVersion: v1 -kind: Namespace -metadata: - name: shipyard ---- -# Bay 需要访问 K8s API 来管理 Ship Pods 和 PVCs -apiVersion: v1 -kind: ServiceAccount -metadata: - name: bay - namespace: shipyard ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: Role -metadata: - name: bay-ship-manager - namespace: shipyard -rules: - # Pod 管理 - - apiGroups: [""] - resources: ["pods"] - verbs: ["get", "list", "watch", "create", "delete"] - - apiGroups: [""] - resources: ["pods/log"] - verbs: ["get"] - # PVC 管理 - - apiGroups: [""] - resources: ["persistentvolumeclaims"] - verbs: ["get", "list", "watch", "create", "delete"] ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: RoleBinding -metadata: - name: bay-ship-manager - namespace: shipyard -subjects: - - kind: ServiceAccount - name: bay - namespace: shipyard -roleRef: - kind: Role - name: bay-ship-manager - apiGroup: rbac.authorization.k8s.io ---- -# 用于允许 Bay 列出 namespaces(初始化时的健康检查) -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRole -metadata: - name: shipyard-bay-namespace-reader -rules: - - apiGroups: [""] - resources: ["namespaces"] - verbs: ["list"] ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRoleBinding -metadata: - name: shipyard-bay-namespace-reader -subjects: - - kind: ServiceAccount - name: bay - namespace: shipyard -roleRef: - kind: ClusterRole - name: shipyard-bay-namespace-reader - apiGroup: rbac.authorization.k8s.io ---- -# Bay 配置 -apiVersion: v1 -kind: ConfigMap -metadata: - name: bay-config - namespace: shipyard -data: - CONTAINER_DRIVER: "kubernetes" - KUBE_NAMESPACE: "shipyard" - KUBE_PVC_SIZE: "1Gi" - KUBE_IMAGE_PULL_POLICY: "IfNotPresent" - # Use StorageClass with Retain reclaim policy for data persistence - KUBE_STORAGE_CLASS: "ship-hostpath-retain" - DOCKER_IMAGE: "ship:latest" - ACCESS_TOKEN: "test-token" - MAX_SHIP_NUM: "10" - DATABASE_URL: "sqlite+aiosqlite:///./data/bay.db" ---- -# Bay Deployment -apiVersion: apps/v1 -kind: Deployment -metadata: - name: bay - namespace: shipyard - labels: - app: bay -spec: - replicas: 1 - selector: - matchLabels: - app: bay - template: - metadata: - labels: - app: bay - spec: - serviceAccountName: bay - containers: - - name: bay - image: bay:latest - imagePullPolicy: Never - ports: - - containerPort: 8156 - envFrom: - - configMapRef: - name: bay-config - volumeMounts: - - name: data - mountPath: /app/data - resources: - requests: - cpu: "100m" - memory: "256Mi" - limits: - cpu: "500m" - memory: "512Mi" - livenessProbe: - httpGet: - path: /health - port: 8156 - initialDelaySeconds: 10 - periodSeconds: 30 - readinessProbe: - httpGet: - path: /health - port: 8156 - initialDelaySeconds: 5 - periodSeconds: 10 - volumes: - - name: data - emptyDir: {} ---- -# Bay Service -apiVersion: v1 -kind: Service -metadata: - name: bay - namespace: shipyard -spec: - selector: - app: bay - ports: - - protocol: TCP - port: 8156 - targetPort: 8156 - type: ClusterIP diff --git a/pkgs/bay/tests/scripts/dev_server.sh b/pkgs/bay/tests/scripts/dev_server.sh new file mode 100755 index 0000000..bef6a8a --- /dev/null +++ b/pkgs/bay/tests/scripts/dev_server.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# 本地开发服务器脚本 +# 用于前后端联调时启动 Bay 后端服务 +# +# 使用方法: +# cd pkgs/bay +# ./tests/scripts/dev_server.sh +# +# 说明: +# - 使用 docker-host 模式运行 +# - Bay 后端运行在 http://localhost:8156 +# - Dashboard 前端运行在 http://localhost:3000 (需另开终端: cd dashboard && npm run dev) +# - 默认 Token: secret-token +# +set -euo pipefail + +SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +PROJECT_ROOT=$(cd "${SCRIPT_DIR}/../.." && pwd) + +cd "${PROJECT_ROOT}" + +echo "============================================" +echo " Bay 本地开发服务器" +echo "============================================" +echo "" + +# 检查 Docker 服务 +echo "===> 检查 Docker 服务..." +if ! docker info >/dev/null 2>&1; then + echo "❌ Docker 服务未运行,请先启动 Docker" + exit 1 +fi +echo "✅ Docker 服务正常" + +# 构建 Ship 镜像 +echo "" +echo "===> 构建 Ship 镜像 (ship:latest)..." +if ! docker build -t ship:latest "${PROJECT_ROOT}/../ship"; then + echo "❌ Ship 镜像构建失败" + exit 1 +fi +echo "✅ Ship 镜像构建成功" + +# 创建网络 +echo "" +echo "===> 创建 Docker 网络..." +docker network inspect shipyard_network >/dev/null 2>&1 || docker network create shipyard_network +echo "✅ 网络 shipyard_network 就绪" + +# 创建数据目录 +mkdir -p "${PROJECT_ROOT}/data" + +# 设置环境变量 +export ACCESS_TOKEN="${ACCESS_TOKEN:-secret-token}" +export DATABASE_URL="${DATABASE_URL:-sqlite+aiosqlite:///./data/bay_dev.db}" +export CONTAINER_DRIVER="docker-host" +export DOCKER_IMAGE="ship:latest" +export DOCKER_NETWORK="shipyard_network" +export SHIP_DATA_DIR="${PROJECT_ROOT}/data/shipyard/ship_mnt_data" +# 使用字符串导入方式以支持热重载 +export DEBUG="${DEBUG:-false}" + +echo "" +echo "============================================" +echo " 配置信息" +echo "============================================" +echo " Bay API: http://localhost:8156" +echo " Dashboard: http://localhost:3000 (需另开终端启动)" +echo " Access Token: ${ACCESS_TOKEN}" +echo " Driver: ${CONTAINER_DRIVER}" +echo " Database: ${DATABASE_URL}" +echo "============================================" +echo "" +echo "提示: 在另一个终端中启动前端开发服务器:" +echo " cd ${PROJECT_ROOT}/dashboard && npm run dev" +echo "" +echo "按 Ctrl+C 停止服务器" +echo "" + +# 使用 uvicorn 命令行直接启动(支持热重载) +echo "===> 启动 Bay 服务..." +uvicorn app.main:app --host 0.0.0.0 --port 8156 --reload diff --git a/pkgs/bay/tests/scripts/test_docker_container.sh b/pkgs/bay/tests/scripts/test_docker_container.sh index c092251..f2eded4 100755 --- a/pkgs/bay/tests/scripts/test_docker_container.sh +++ b/pkgs/bay/tests/scripts/test_docker_container.sh @@ -39,6 +39,9 @@ for i in {1..30}; do fi done +echo "==> [Docker Container] 运行单元测试" +uv run python -m pytest tests/unit/ -v + echo "==> [Docker Container] 运行 E2E 测试" uv run python -m pytest tests/e2e/ -v diff --git a/pkgs/bay/tests/scripts/test_docker_host.sh b/pkgs/bay/tests/scripts/test_docker_host.sh index 3a4b2a3..ac5fde6 100755 --- a/pkgs/bay/tests/scripts/test_docker_host.sh +++ b/pkgs/bay/tests/scripts/test_docker_host.sh @@ -50,5 +50,8 @@ for i in {1..30}; do fi done +echo "==> [Docker Host] 运行单元测试" +python -m pytest tests/unit/ -v + echo "==> [Docker Host] 运行 E2E 测试" python -m pytest tests/e2e/ -v diff --git a/pkgs/bay/tests/scripts/test_kubernetes.sh b/pkgs/bay/tests/scripts/test_kubernetes.sh index 8af8c38..d42da88 100755 --- a/pkgs/bay/tests/scripts/test_kubernetes.sh +++ b/pkgs/bay/tests/scripts/test_kubernetes.sh @@ -214,8 +214,12 @@ run_tests() { # 运行测试脚本 echo "" - echo " 运行 pytest tests/e2e/..." + echo " 运行 pytest tests/unit/..." cd "$PROJECT_ROOT" + python -m pytest tests/unit/ -v || true + + echo "" + echo " 运行 pytest tests/e2e/..." python -m pytest tests/e2e/ -v || true # 清理端口转发 @@ -301,6 +305,7 @@ main() { wait_for_bay show_status run_tests + cleanup ;; build) check_prerequisites diff --git a/pkgs/bay/tests/scripts/test_podman_container.sh b/pkgs/bay/tests/scripts/test_podman_container.sh index c2156da..17b4b81 100755 --- a/pkgs/bay/tests/scripts/test_podman_container.sh +++ b/pkgs/bay/tests/scripts/test_podman_container.sh @@ -69,5 +69,8 @@ for i in {1..30}; do fi done +echo "==> [Podman Container] 运行单元测试" +python -m pytest tests/unit/ -v + echo "==> [Podman Container] 运行 E2E 测试" python -m pytest tests/e2e/ -v diff --git a/pkgs/bay/tests/scripts/test_podman_host.sh b/pkgs/bay/tests/scripts/test_podman_host.sh index f8dd6b9..5ac603d 100755 --- a/pkgs/bay/tests/scripts/test_podman_host.sh +++ b/pkgs/bay/tests/scripts/test_podman_host.sh @@ -54,5 +54,8 @@ for i in {1..30}; do fi done +echo "==> [Podman Host] 运行单元测试" +python -m pytest tests/unit/ -v + echo "==> [Podman Host] 运行 E2E 测试" python -m pytest tests/e2e/ -v diff --git a/pkgs/bay/tests/unit/test_sessions.py b/pkgs/bay/tests/unit/test_sessions.py new file mode 100644 index 0000000..292ef6d --- /dev/null +++ b/pkgs/bay/tests/unit/test_sessions.py @@ -0,0 +1,288 @@ +""" +单元测试:sessions 路由测试 + +测试 /sessions 相关端点的响应模型。 +""" + +import pytest +from datetime import datetime, timezone, timedelta + + +class TestSessionsRoutes: + """Sessions 路由单元测试""" + + def test_session_response_model(self): + """测试 SessionResponse 模型""" + from app.routes.sessions import SessionResponse + + now = datetime.now(timezone.utc) + expires_at = now + timedelta(hours=1) + + session = SessionResponse( + id="test-id", + session_id="session-123", + ship_id="ship-456", + created_at=now, + last_activity=now, + expires_at=expires_at, + initial_ttl=3600, + is_active=True + ) + + assert session.id == "test-id" + assert session.session_id == "session-123" + assert session.ship_id == "ship-456" + assert session.initial_ttl == 3600 + assert session.is_active is True + + def test_session_list_response_model(self): + """测试 SessionListResponse 模型""" + from app.routes.sessions import SessionResponse, SessionListResponse + + now = datetime.now(timezone.utc) + expires_at = now + timedelta(hours=1) + + session = SessionResponse( + id="test-id", + session_id="session-123", + ship_id="ship-456", + created_at=now, + last_activity=now, + expires_at=expires_at, + initial_ttl=3600, + is_active=True + ) + + session_list = SessionListResponse( + sessions=[session], + total=1 + ) + + assert session_list.total == 1 + assert len(session_list.sessions) == 1 + assert session_list.sessions[0].session_id == "session-123" + + def test_ship_sessions_response_model(self): + """测试 ShipSessionsResponse 模型""" + from app.routes.sessions import SessionResponse, ShipSessionsResponse + + now = datetime.now(timezone.utc) + expires_at = now + timedelta(hours=1) + + session = SessionResponse( + id="test-id", + session_id="session-123", + ship_id="ship-456", + created_at=now, + last_activity=now, + expires_at=expires_at, + initial_ttl=3600, + is_active=True + ) + + ship_sessions = ShipSessionsResponse( + ship_id="ship-456", + sessions=[session], + total=1 + ) + + assert ship_sessions.ship_id == "ship-456" + assert ship_sessions.total == 1 + assert len(ship_sessions.sessions) == 1 + + def test_session_is_active_calculation(self): + """测试会话活跃状态计算逻辑""" + from app.routes.sessions import SessionResponse + + now = datetime.now(timezone.utc) + + # 未过期的会话应该是活跃的 + future_expires = now + timedelta(hours=1) + active_session = SessionResponse( + id="test-id", + session_id="session-123", + ship_id="ship-456", + created_at=now, + last_activity=now, + expires_at=future_expires, + initial_ttl=3600, + is_active=True + ) + assert active_session.is_active is True + + # 已过期的会话应该是非活跃的 + past_expires = now - timedelta(hours=1) + inactive_session = SessionResponse( + id="test-id", + session_id="session-123", + ship_id="ship-456", + created_at=now, + last_activity=now, + expires_at=past_expires, + initial_ttl=3600, + is_active=False + ) + assert inactive_session.is_active is False + + def test_is_session_active_function(self): + """测试 is_session_active 辅助函数""" + from app.routes.sessions import is_session_active + + now = datetime.now(timezone.utc) + + # 未过期的会话应该是活跃的 + future_expires = now + timedelta(hours=1) + assert is_session_active(future_expires, now) is True + + # 已过期的会话应该是非活跃的 + past_expires = now - timedelta(hours=1) + assert is_session_active(past_expires, now) is False + + # expires_at 为 None 时应该是非活跃的 + assert is_session_active(None, now) is False + + # 处理 timezone-naive datetime + naive_future = datetime.utcnow() + timedelta(hours=1) + assert is_session_active(naive_future, now) is True + + naive_past = datetime.utcnow() - timedelta(hours=1) + assert is_session_active(naive_past, now) is False + + +class TestExpireSessionsForShip: + """测试 expire_sessions_for_ship 数据库方法""" + + @pytest.mark.asyncio + async def test_expire_sessions_for_ship_marks_active_sessions_as_expired(self): + """测试当 ship 停止时,活跃会话会被标记为已过期""" + from app.database import db_service + from app.models import Ship, SessionShip, ShipStatus + + # 初始化数据库 + await db_service.initialize() + await db_service.create_tables() + + # 创建测试 ship + ship = Ship( + id="test-ship-expire-sessions", + ttl=3600, + max_session_num=2, + status=ShipStatus.RUNNING + ) + ship = await db_service.create_ship(ship) + + try: + now = datetime.now(timezone.utc) + future_expires = now + timedelta(hours=1) + + # 创建活跃会话 + session1 = SessionShip( + id="session1-expire-test", + session_id="user-session-1", + ship_id=ship.id, + expires_at=future_expires, + initial_ttl=3600 + ) + await db_service.create_session_ship(session1) + + session2 = SessionShip( + id="session2-expire-test", + session_id="user-session-2", + ship_id=ship.id, + expires_at=future_expires, + initial_ttl=3600 + ) + await db_service.create_session_ship(session2) + + # 执行 expire_sessions_for_ship + expired_count = await db_service.expire_sessions_for_ship(ship.id) + + # 验证两个会话都被更新 + assert expired_count == 2 + + # 验证会话现在是非活跃的 + sessions = await db_service.get_sessions_for_ship(ship.id) + for s in sessions: + # expires_at 应该是当前时间或更早 + if s.expires_at.tzinfo is None: + s.expires_at = s.expires_at.replace(tzinfo=timezone.utc) + assert s.expires_at <= datetime.now(timezone.utc) + timedelta(seconds=5) + + finally: + # 清理测试数据 + await db_service.delete_sessions_for_ship(ship.id) + await db_service.delete_ship(ship.id) + + @pytest.mark.asyncio + async def test_expire_sessions_for_ship_skips_already_expired_sessions(self): + """测试已过期的会话不会被重复更新""" + from app.database import db_service + from app.models import Ship, SessionShip, ShipStatus + + # 初始化数据库 + await db_service.initialize() + await db_service.create_tables() + + # 创建测试 ship + ship = Ship( + id="test-ship-skip-expired", + ttl=3600, + max_session_num=2, + status=ShipStatus.RUNNING + ) + ship = await db_service.create_ship(ship) + + try: + now = datetime.now(timezone.utc) + past_expires = now - timedelta(hours=1) + + # 创建已过期的会话 + session = SessionShip( + id="session-already-expired", + session_id="user-session-expired", + ship_id=ship.id, + expires_at=past_expires, + initial_ttl=3600 + ) + await db_service.create_session_ship(session) + + # 执行 expire_sessions_for_ship + expired_count = await db_service.expire_sessions_for_ship(ship.id) + + # 已过期的会话不应该被计入更新数 + assert expired_count == 0 + + finally: + # 清理测试数据 + await db_service.delete_sessions_for_ship(ship.id) + await db_service.delete_ship(ship.id) + + @pytest.mark.asyncio + async def test_expire_sessions_for_ship_with_no_sessions(self): + """测试没有会话的 ship 不会出错""" + from app.database import db_service + from app.models import Ship, ShipStatus + + # 初始化数据库 + await db_service.initialize() + await db_service.create_tables() + + # 创建测试 ship + ship = Ship( + id="test-ship-no-sessions", + ttl=3600, + max_session_num=1, + status=ShipStatus.RUNNING + ) + ship = await db_service.create_ship(ship) + + try: + # 执行 expire_sessions_for_ship(没有会话) + expired_count = await db_service.expire_sessions_for_ship(ship.id) + + # 应该返回 0 + assert expired_count == 0 + + finally: + # 清理测试数据 + await db_service.delete_ship(ship.id) diff --git a/pkgs/bay/tests/unit/test_ships.py b/pkgs/bay/tests/unit/test_ships.py new file mode 100644 index 0000000..c16665a --- /dev/null +++ b/pkgs/bay/tests/unit/test_ships.py @@ -0,0 +1,502 @@ +""" +单元测试:ships 路由测试 + +测试 /ships, /ship 相关端点的模型和基础逻辑。 +""" + +import pytest +from datetime import datetime, timezone, timedelta +from unittest.mock import AsyncMock, MagicMock, patch +from io import BytesIO + + +class TestShipModels: + """Ships 路由相关模型单元测试""" + + def test_ship_status_constants(self): + """测试 ShipStatus 常量""" + from app.models import ShipStatus + + assert ShipStatus.STOPPED == 0 + assert ShipStatus.RUNNING == 1 + assert ShipStatus.CREATING == 2 + + def test_create_ship_request_model(self): + """测试 CreateShipRequest 模型""" + from app.models import CreateShipRequest, ShipSpec + + # 基本创建请求 + request = CreateShipRequest(ttl=3600, max_session_num=1) + assert request.ttl == 3600 + assert request.max_session_num == 1 + assert request.spec is None + + # 带规格的创建请求 + spec = ShipSpec(cpus=0.5, memory="256m", disk="1Gi") + request_with_spec = CreateShipRequest(ttl=3600, spec=spec) + assert request_with_spec.spec.cpus == 0.5 + assert request_with_spec.spec.memory == "256m" + assert request_with_spec.spec.disk == "1Gi" + + def test_create_ship_request_validation(self): + """测试 CreateShipRequest 验证""" + from app.models import CreateShipRequest + from pydantic import ValidationError + + # ttl 必须大于 0 + with pytest.raises(ValidationError): + CreateShipRequest(ttl=0, max_session_num=1) + + with pytest.raises(ValidationError): + CreateShipRequest(ttl=-1, max_session_num=1) + + # max_session_num 必须大于 0 + with pytest.raises(ValidationError): + CreateShipRequest(ttl=3600, max_session_num=0) + + def test_ship_spec_model(self): + """测试 ShipSpec 模型""" + from app.models import ShipSpec + + # 空规格 + empty_spec = ShipSpec() + assert empty_spec.cpus is None + assert empty_spec.memory is None + assert empty_spec.disk is None + + # 完整规格 + full_spec = ShipSpec(cpus=1.0, memory="512m", disk="2Gi") + assert full_spec.cpus == 1.0 + assert full_spec.memory == "512m" + assert full_spec.disk == "2Gi" + + def test_ship_spec_validation(self): + """测试 ShipSpec 验证""" + from app.models import ShipSpec + from pydantic import ValidationError + + # cpus 必须大于 0 + with pytest.raises(ValidationError): + ShipSpec(cpus=0) + + with pytest.raises(ValidationError): + ShipSpec(cpus=-0.5) + + def test_ship_response_model(self): + """测试 ShipResponse 模型""" + from app.models import ShipResponse, ShipStatus + + now = datetime.now(timezone.utc) + expires_at = now + timedelta(hours=1) + + response = ShipResponse( + id="ship-123", + status=ShipStatus.RUNNING, + created_at=now, + updated_at=now, + container_id="container-abc", + ip_address="172.17.0.2", + ttl=3600, + max_session_num=2, + current_session_num=1, + expires_at=expires_at, + ) + + assert response.id == "ship-123" + assert response.status == ShipStatus.RUNNING + assert response.container_id == "container-abc" + assert response.ip_address == "172.17.0.2" + assert response.ttl == 3600 + assert response.max_session_num == 2 + assert response.current_session_num == 1 + + def test_ship_response_optional_fields(self): + """测试 ShipResponse 可选字段""" + from app.models import ShipResponse, ShipStatus + + now = datetime.now(timezone.utc) + + response = ShipResponse( + id="ship-123", + status=ShipStatus.CREATING, + created_at=now, + updated_at=now, + container_id=None, + ip_address=None, + ttl=3600, + max_session_num=1, + current_session_num=0, + expires_at=None, + ) + + assert response.container_id is None + assert response.ip_address is None + assert response.expires_at is None + + def test_exec_request_model(self): + """测试 ExecRequest 模型""" + from app.models import ExecRequest + + # Shell 执行请求 + shell_request = ExecRequest( + type="shell/exec", payload={"command": "echo hello"} + ) + assert shell_request.type == "shell/exec" + assert shell_request.payload["command"] == "echo hello" + + # IPython 执行请求 + ipython_request = ExecRequest( + type="ipython/exec", payload={"code": "print('hello')", "timeout": 10} + ) + assert ipython_request.type == "ipython/exec" + assert ipython_request.payload["code"] == "print('hello')" + + # 文件系统操作请求 + fs_request = ExecRequest( + type="fs/create_file", payload={"path": "test.txt", "content": "hello"} + ) + assert fs_request.type == "fs/create_file" + + def test_exec_response_model(self): + """测试 ExecResponse 模型""" + from app.models import ExecResponse + + # 成功响应 + success_response = ExecResponse( + success=True, data={"output": "hello world"}, error=None + ) + assert success_response.success is True + assert success_response.data["output"] == "hello world" + assert success_response.error is None + + # 失败响应 + error_response = ExecResponse( + success=False, data=None, error="Command failed" + ) + assert error_response.success is False + assert error_response.data is None + assert error_response.error == "Command failed" + + def test_extend_ttl_request_model(self): + """测试 ExtendTTLRequest 模型""" + from app.models import ExtendTTLRequest + from pydantic import ValidationError + + # 有效请求 + request = ExtendTTLRequest(ttl=7200) + assert request.ttl == 7200 + + # ttl 必须大于 0 + with pytest.raises(ValidationError): + ExtendTTLRequest(ttl=0) + + with pytest.raises(ValidationError): + ExtendTTLRequest(ttl=-100) + + def test_start_ship_request_model(self): + """测试 StartShipRequest 模型""" + from app.models import StartShipRequest + from pydantic import ValidationError + + # 默认值 + request = StartShipRequest() + assert request.ttl == 3600 + + # 自定义 TTL + request_custom = StartShipRequest(ttl=7200) + assert request_custom.ttl == 7200 + + # ttl 必须大于 0 + with pytest.raises(ValidationError): + StartShipRequest(ttl=0) + + with pytest.raises(ValidationError): + StartShipRequest(ttl=-100) + + def test_logs_response_model(self): + """测试 LogsResponse 模型""" + from app.models import LogsResponse + + response = LogsResponse(logs="Container started\nService ready") + assert "Container started" in response.logs + assert "Service ready" in response.logs + + def test_upload_file_response_model(self): + """测试 UploadFileResponse 模型""" + from app.models import UploadFileResponse + + # 成功上传 + success_response = UploadFileResponse( + success=True, + message="File uploaded successfully", + file_path="/workspace/test.txt", + error=None, + ) + assert success_response.success is True + assert success_response.file_path == "/workspace/test.txt" + + # 上传失败 + error_response = UploadFileResponse( + success=False, message="Upload failed", file_path=None, error="File too large" + ) + assert error_response.success is False + assert error_response.error == "File too large" + + +class TestShipsRouteLogic: + """Ships 路由逻辑单元测试""" + + def test_ship_ip_address_format_docker_mode(self): + """测试 docker 模式 IP 地址格式(不带端口)""" + # 在 docker 模式下,IP 地址不包含端口 + ip_address = "172.17.0.2" + assert ":" not in ip_address + + # 构建 WebSocket URL 需要添加默认端口 + ship_container_port = 8000 + ws_url = f"ws://{ip_address}:{ship_container_port}/term/ws" + assert ws_url == "ws://172.17.0.2:8000/term/ws" + + def test_ship_ip_address_format_docker_host_mode(self): + """测试 docker-host 模式 IP 地址格式(带端口)""" + # 在 docker-host 模式下,IP 地址包含端口 + ip_address = "127.0.0.1:39314" + assert ":" in ip_address + + # 构建 WebSocket URL 直接使用地址 + ws_url = f"ws://{ip_address}/term/ws" + assert ws_url == "ws://127.0.0.1:39314/term/ws" + + def test_filename_extraction_from_path(self): + """测试从文件路径提取文件名""" + # 包含路径分隔符 + file_path = "/workspace/subdir/test_file.txt" + filename = file_path.split("/")[-1] if "/" in file_path else file_path + assert filename == "test_file.txt" + + # 不包含路径分隔符 + file_path_simple = "test_file.txt" + filename_simple = ( + file_path_simple.split("/")[-1] + if "/" in file_path_simple + else file_path_simple + ) + assert filename_simple == "test_file.txt" + + def test_error_message_categorization(self): + """测试错误消息分类逻辑""" + error_messages = [ + ("file size exceeds limit", "size"), + ("resource not found", "not found"), + ("access denied", "access"), + ("unknown error", None), + ] + + for error_msg, expected_keyword in error_messages: + if expected_keyword: + assert expected_keyword in error_msg.lower() + else: + # 一般错误,不匹配特定关键字 + assert "size" not in error_msg.lower() + assert "not found" not in error_msg.lower() + assert "access" not in error_msg.lower() + + +class TestShipsRouteHTTPStatus: + """Ships 路由 HTTP 状态码测试""" + + def test_expected_status_codes(self): + """测试预期的 HTTP 状态码""" + from fastapi import status + + # 成功创建 Ship + assert status.HTTP_201_CREATED == 201 + + # 成功删除 Ship + assert status.HTTP_204_NO_CONTENT == 204 + + # Ship 未找到 + assert status.HTTP_404_NOT_FOUND == 404 + + # 无效请求 + assert status.HTTP_400_BAD_REQUEST == 400 + + # 请求超时 + assert status.HTTP_408_REQUEST_TIMEOUT == 408 + + # 文件过大 + assert status.HTTP_413_REQUEST_ENTITY_TOO_LARGE == 413 + + # 禁止访问 + assert status.HTTP_403_FORBIDDEN == 403 + + # 服务器内部错误 + assert status.HTTP_500_INTERNAL_SERVER_ERROR == 500 + + +class TestShipBase: + """ShipBase 模型测试""" + + def test_ship_base_defaults(self): + """测试 ShipBase 默认值""" + from app.models import Ship, ShipStatus + + ship = Ship(ttl=3600) + assert ship.status == ShipStatus.CREATING + assert ship.max_session_num == 1 + assert ship.current_session_num == 0 + assert ship.container_id is None + assert ship.ip_address is None + assert ship.id is not None # 自动生成 + + def test_ship_with_all_fields(self): + """测试 Ship 所有字段""" + from app.models import Ship, ShipStatus + + now = datetime.now(timezone.utc) + ship = Ship( + id="custom-id", + status=ShipStatus.RUNNING, + created_at=now, + updated_at=now, + container_id="container-123", + ip_address="10.0.0.1", + ttl=7200, + max_session_num=5, + current_session_num=3, + ) + + assert ship.id == "custom-id" + assert ship.status == ShipStatus.RUNNING + assert ship.container_id == "container-123" + assert ship.ip_address == "10.0.0.1" + assert ship.ttl == 7200 + assert ship.max_session_num == 5 + assert ship.current_session_num == 3 + + +class TestWebSocketTerminalLogic: + """WebSocket Terminal 相关逻辑单元测试""" + + def test_websocket_url_construction_docker_mode(self): + """测试 docker 模式 WebSocket URL 构建""" + # docker 模式:IP 地址不包含端口 + ip_address = "172.17.0.2" + ship_container_port = 8000 + session_id = "test-session" + cols = 80 + rows = 24 + + # 构建 WebSocket URL + ws_url = f"ws://{ip_address}:{ship_container_port}/term/ws?session_id={session_id}&cols={cols}&rows={rows}" + + assert ws_url == "ws://172.17.0.2:8000/term/ws?session_id=test-session&cols=80&rows=24" + + def test_websocket_url_construction_docker_host_mode(self): + """测试 docker-host 模式 WebSocket URL 构建""" + # docker-host 模式:IP 地址包含端口 + ip_address = "127.0.0.1:39314" + session_id = "test-session" + cols = 120 + rows = 40 + + # 构建 WebSocket URL + ws_url = f"ws://{ip_address}/term/ws?session_id={session_id}&cols={cols}&rows={rows}" + + assert ws_url == "ws://127.0.0.1:39314/term/ws?session_id=test-session&cols=120&rows=40" + + def test_ip_address_format_detection(self): + """测试 IP 地址格式检测(是否包含端口)""" + # docker 模式 + docker_ip = "172.17.0.2" + assert ":" not in docker_ip + + # docker-host 模式 + docker_host_ip = "127.0.0.1:39314" + assert ":" in docker_host_ip + + def test_websocket_close_codes(self): + """测试 WebSocket 关闭代码""" + # 自定义关闭代码 + UNAUTHORIZED = 4001 + SESSION_NO_ACCESS = 4003 + SHIP_NOT_FOUND = 4004 + + assert UNAUTHORIZED == 4001 + assert SESSION_NO_ACCESS == 4003 + assert SHIP_NOT_FOUND == 4004 + + # 标准关闭代码 + INTERNAL_ERROR = 1011 + assert INTERNAL_ERROR == 1011 + + def test_terminal_default_size(self): + """测试终端默认大小""" + default_cols = 80 + default_rows = 24 + + assert default_cols == 80 + assert default_rows == 24 + + def test_terminal_size_validation(self): + """测试终端大小验证""" + # 有效的终端大小 + valid_sizes = [ + (80, 24), # 标准 + (120, 40), # 大屏 + (40, 10), # 小屏 + ] + + for cols, rows in valid_sizes: + assert cols > 0 + assert rows > 0 + + def test_websocket_message_types(self): + """测试 WebSocket 消息类型""" + # 模拟 aiohttp 消息类型 + class MockWSMsgType: + TEXT = 1 + BINARY = 2 + CLOSED = 258 + ERROR = 256 + + assert MockWSMsgType.TEXT == 1 + assert MockWSMsgType.BINARY == 2 + assert MockWSMsgType.CLOSED == 258 + assert MockWSMsgType.ERROR == 256 + + +class TestShipStatusValidation: + """Ship 状态验证单元测试""" + + def test_ship_running_status_required_for_websocket(self): + """测试 WebSocket 连接需要 Ship 处于运行状态""" + from app.models import ShipStatus + + # 只有 RUNNING 状态的 Ship 可以连接 WebSocket + valid_status = ShipStatus.RUNNING + invalid_statuses = [ShipStatus.STOPPED, ShipStatus.CREATING] + + assert valid_status == 1 + for status in invalid_statuses: + assert status != ShipStatus.RUNNING + + def test_ship_ip_address_required_for_websocket(self): + """测试 WebSocket 连接需要 Ship 有 IP 地址""" + from app.models import Ship, ShipStatus + + # Ship 有 IP 地址 + ship_with_ip = Ship( + ttl=3600, + status=ShipStatus.RUNNING, + ip_address="172.17.0.2" + ) + assert ship_with_ip.ip_address is not None + + # Ship 没有 IP 地址 + ship_without_ip = Ship( + ttl=3600, + status=ShipStatus.RUNNING, + ip_address=None + ) + assert ship_without_ip.ip_address is None diff --git a/pkgs/bay/tests/unit/test_stat.py b/pkgs/bay/tests/unit/test_stat.py new file mode 100644 index 0000000..bfb19a1 --- /dev/null +++ b/pkgs/bay/tests/unit/test_stat.py @@ -0,0 +1,52 @@ +""" +单元测试:stat 路由测试 + +测试 /stat 和 /stat/overview 端点。 +""" + +import pytest +from unittest.mock import AsyncMock, patch, MagicMock +from datetime import datetime, timezone, timedelta + + +class TestStatRoutes: + """Stat 路由单元测试""" + + def test_get_version(self): + """测试 get_version 函数""" + from app.routes.stat import get_version + + version = get_version() + # 版本应该是一个非空字符串 + assert isinstance(version, str) + assert len(version) > 0 + + def test_stat_response_models(self): + """测试 stat 响应模型""" + from app.routes.stat import ShipStats, SessionStats, OverviewResponse + + # 测试 ShipStats + ship_stats = ShipStats(total=10, running=8, stopped=2, creating=0) + assert ship_stats.total == 10 + assert ship_stats.running == 8 + assert ship_stats.stopped == 2 + assert ship_stats.creating == 0 + + # 测试 SessionStats + session_stats = SessionStats(total=15, active=12) + assert session_stats.total == 15 + assert session_stats.active == 12 + + # 测试 OverviewResponse + overview = OverviewResponse( + service="bay", + version="1.0.0", + status="running", + ships=ship_stats, + sessions=session_stats + ) + assert overview.service == "bay" + assert overview.version == "1.0.0" + assert overview.status == "running" + assert overview.ships.total == 10 + assert overview.sessions.active == 12 diff --git a/pkgs/bay/uv.lock b/pkgs/bay/uv.lock index 3501f2f..8163f78 100644 --- a/pkgs/bay/uv.lock +++ b/pkgs/bay/uv.lock @@ -182,6 +182,7 @@ dependencies = [ { name = "sqlmodel" }, { name = "tomli" }, { name = "uvicorn", extra = ["standard"] }, + { name = "websocket-client" }, ] [package.optional-dependencies] @@ -211,6 +212,7 @@ requires-dist = [ { name = "sqlmodel" }, { name = "tomli", specifier = ">=2.0.0" }, { name = "uvicorn", extras = ["standard"] }, + { name = "websocket-client", specifier = ">=1.9.0" }, ] provides-extras = ["test"] @@ -1346,6 +1348,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bd/d3/254cea30f918f489db09d6a8435a7de7047f8cb68584477a515f160541d6/watchfiles-1.1.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:923fec6e5461c42bd7e3fd5ec37492c6f3468be0499bc0707b4bbbc16ac21792", size = 454009, upload-time = "2025-06-15T19:06:52.896Z" }, ] +[[package]] +name = "websocket-client" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576, upload-time = "2025-10-07T21:16:36.495Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" }, +] + [[package]] name = "websockets" version = "15.0.1" diff --git a/pkgs/ship/Dockerfile b/pkgs/ship/Dockerfile index cb20d9f..67fe55a 100644 --- a/pkgs/ship/Dockerfile +++ b/pkgs/ship/Dockerfile @@ -48,6 +48,12 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ curl \ gnupg \ git \ + # 常用文本编辑器和工具(方便终端直连调试) + vim-tiny \ + nano \ + less \ + procps \ + htop \ && rm -rf /var/lib/apt/lists/* # 从builder阶段复制Python包 diff --git a/pkgs/ship/app/components/ipython.py b/pkgs/ship/app/components/ipython.py index ef29cfe..94c6e94 100644 --- a/pkgs/ship/app/components/ipython.py +++ b/pkgs/ship/app/components/ipython.py @@ -61,16 +61,23 @@ async def ensure_kernel_running(km: AsyncKernelManager): import matplotlib.pyplot as plt import matplotlib.font_manager as fm import shutil, os +import warnings +warnings.filterwarnings('ignore', category=UserWarning, module='matplotlib') +# 清除字体缓存以确保字体更新生效 cache_dir = os.path.expanduser("~/.cache/matplotlib") if os.path.exists(cache_dir): shutil.rmtree(cache_dir) -font_path = "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc" -font_prop = fm.FontProperties(fname=font_path, index=2) +# 重建字体列表 +fm._load_fontmanager(try_read_cache=False) -plt.rcParams['font.family'] = font_prop.get_name() -plt.rcParams['axes.unicode_minus'] = False +# 配置中文字体 +font_path = "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc" +if os.path.exists(font_path): + # 使用 sans-serif 字体族并设置回退 + plt.rcParams['font.sans-serif'] = ['Noto Sans CJK SC', 'Noto Sans CJK JP', 'Noto Sans CJK TC', 'DejaVu Sans'] + plt.rcParams['axes.unicode_minus'] = False """ diff --git a/pkgs/ship/app/components/term.py b/pkgs/ship/app/components/term.py new file mode 100644 index 0000000..f1756a1 --- /dev/null +++ b/pkgs/ship/app/components/term.py @@ -0,0 +1,185 @@ +""" +WebSocket terminal component for interactive shell sessions. + +This module provides a WebSocket endpoint for xterm.js integration, +supporting PTY-based interactive shell sessions with terminal resize. +""" + +import asyncio +import os +import struct +import fcntl +import termios +import logging +import json +from typing import Optional, Dict +from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Header, Query + +from .user_manager import UserManager, get_or_create_session_user + +logger = logging.getLogger(__name__) + +router = APIRouter() + +# 活跃的终端会话: session_id -> TerminalSession +_active_terminals: Dict[str, "TerminalSession"] = {} + + +class TerminalSession: + """Represents an active terminal session""" + + def __init__(self, session_id: str, master_fd: int, pid: int): + self.session_id = session_id + self.master_fd = master_fd + self.pid = pid + self.websocket: Optional[WebSocket] = None + self._read_task: Optional[asyncio.Task] = None + self._closed = False + + async def start_reader(self, websocket: WebSocket): + """Start reading from PTY and sending to WebSocket""" + self.websocket = websocket + loop = asyncio.get_event_loop() + + def read_pty(): + """Blocking read from PTY""" + try: + return os.read(self.master_fd, 4096) + except OSError: + return b"" + + while not self._closed: + try: + # Read from PTY in a thread to avoid blocking + data = await loop.run_in_executor(None, read_pty) + if not data: + logger.info(f"PTY closed for session {self.session_id}") + break + # Send to WebSocket as text (xterm expects text) + await websocket.send_text(data.decode("utf-8", errors="replace")) + except WebSocketDisconnect: + logger.info(f"WebSocket disconnected for session {self.session_id}") + break + except Exception as e: + logger.error(f"Error reading from PTY: {e}") + break + + async def write(self, data: str): + """Write data to PTY""" + try: + os.write(self.master_fd, data.encode("utf-8")) + except OSError as e: + logger.error(f"Error writing to PTY: {e}") + + def resize(self, cols: int, rows: int): + """Resize the terminal""" + try: + winsize = struct.pack("HHHH", rows, cols, 0, 0) + fcntl.ioctl(self.master_fd, termios.TIOCSWINSZ, winsize) + logger.debug(f"Resized terminal to {cols}x{rows}") + except Exception as e: + logger.error(f"Error resizing terminal: {e}") + + def close(self): + """Close the terminal session""" + if self._closed: + return + self._closed = True + + try: + os.close(self.master_fd) + except OSError: + pass + + # Try to terminate the child process + try: + os.kill(self.pid, 9) # SIGKILL + os.waitpid(self.pid, os.WNOHANG) + except OSError: + pass + + logger.info(f"Closed terminal session {self.session_id}") + + +@router.websocket("/ws") +async def websocket_terminal( + websocket: WebSocket, + session_id: str = Query(..., alias="session_id"), + cols: int = Query(80), + rows: int = Query(24), +): + """ + WebSocket endpoint for interactive terminal. + + Query parameters: + - session_id: The session ID for user isolation + - cols: Terminal columns (default 80) + - rows: Terminal rows (default 24) + + Messages from client: + - Text: Input data to send to PTY + - JSON: {"type": "resize", "cols": , "rows": } + """ + await websocket.accept() + + terminal: Optional[TerminalSession] = None + + try: + # Start interactive shell + master_fd, pid = await UserManager.start_interactive_shell( + session_id, cols=cols, rows=rows + ) + + terminal = TerminalSession(session_id, master_fd, pid) + _active_terminals[session_id] = terminal + + logger.info(f"Terminal session started for {session_id}") + + # Start PTY reader in background + read_task = asyncio.create_task(terminal.start_reader(websocket)) + + # Handle incoming WebSocket messages + while True: + try: + message = await websocket.receive() + + if message["type"] == "websocket.disconnect": + break + + if "text" in message: + text = message["text"] + # Check if it's a control message + if text.startswith("{"): + try: + data = json.loads(text) + if data.get("type") == "resize": + terminal.resize(data.get("cols", 80), data.get("rows", 24)) + continue + except json.JSONDecodeError: + pass + # Regular input data + await terminal.write(text) + + elif "bytes" in message: + # Binary data (also valid input) + await terminal.write(message["bytes"].decode("utf-8", errors="replace")) + + except WebSocketDisconnect: + break + except Exception as e: + logger.error(f"Error handling WebSocket message: {e}") + break + + except Exception as e: + logger.error(f"Error in terminal WebSocket: {e}") + try: + await websocket.close(code=1011, reason=str(e)) + except Exception: + pass + + finally: + # Cleanup + if terminal: + terminal.close() + if session_id in _active_terminals: + del _active_terminals[session_id] diff --git a/pkgs/ship/app/components/user_manager.py b/pkgs/ship/app/components/user_manager.py index 121bbf2..4fc6b3b 100644 --- a/pkgs/ship/app/components/user_manager.py +++ b/pkgs/ship/app/components/user_manager.py @@ -16,7 +16,7 @@ import uuid from dataclasses import dataclass from pathlib import Path -from typing import Dict, Optional, List +from typing import Dict, Optional, List, Tuple from fastapi import HTTPException logger = logging.getLogger(__name__) @@ -437,6 +437,81 @@ async def restore_all_users() -> int: logger.error(f"Failed to restore users: {e}") return 0 + @staticmethod + async def start_interactive_shell( + session_id: str, + cols: int = 80, + rows: int = 24, + env: Optional[Dict[str, str]] = None, + ) -> Tuple[int, int]: + """ + 启动交互式 shell (PTY) + + Returns: + (master_fd, pid) + """ + try: + import pty + import tty + + username = await get_or_create_session_user(session_id) + user_info = await UserManager.get_user_info(username) + user_home = user_info["home_dir"] + working_dir = Path(user_home) / "workspace" + + # 准备环境变量 + process_env = { + "HOME": user_home, + "USER": username, + "LOGNAME": username, + "PATH": "/usr/local/bin:/usr/bin:/bin", + "SHELL": "/bin/bash", + "TERM": "xterm-256color", + "LANG": "en_US.UTF-8", + } + if env: + process_env.update(env) + + pid, master_fd = pty.fork() + + if pid == 0: # Child process + try: + # 设置工作目录 + os.chdir(str(working_dir)) + + # 准备 sudo 命令参数 + sudo_cmd = "/usr/bin/sudo" + sudo_args = [ + sudo_cmd, + "-u", + username, + "-H", + "bash", # 显式运行 bash + "-l", # login shell + ] + + os.execvpe(sudo_cmd, sudo_args, process_env) + + except Exception as e: + print(f"Error starting shell: {e}") + os._exit(1) + + # Parent process + # 设置窗口大小 + import termios + import struct + import fcntl + + winsize = struct.pack("HHHH", rows, cols, 0, 0) + fcntl.ioctl(master_fd, termios.TIOCSWINSZ, winsize) + + logger.info(f"Started interactive shell for {username} (PID {pid})") + return master_fd, pid + + except Exception as e: + logger.error(f"Failed to start interactive shell for {session_id}: {e}") + raise + @staticmethod async def cleanup_session_user(session_id: str) -> bool: """清理session用户""" diff --git a/pkgs/ship/app/main.py b/pkgs/ship/app/main.py index 2ff14f8..15eed84 100644 --- a/pkgs/ship/app/main.py +++ b/pkgs/ship/app/main.py @@ -4,6 +4,7 @@ from .components.ipython import router as ipython_router from .components.shell import router as shell_router from .components.upload import router as upload_router +from .components.term import router as term_router from .components.user_manager import UserManager import logging import tomli @@ -34,6 +35,7 @@ async def lifespan(app: FastAPI): app.include_router(ipython_router, prefix="/ipython", tags=["ipython"]) app.include_router(shell_router, prefix="/shell", tags=["shell"]) app.include_router(upload_router, tags=["upload"]) +app.include_router(term_router, prefix="/term", tags=["terminal"]) @app.get("/") diff --git a/plans/test-container-isolation.md b/plans/test-container-isolation.md deleted file mode 100644 index 339d8e7..0000000 --- a/plans/test-container-isolation.md +++ /dev/null @@ -1,271 +0,0 @@ -# 测试容器隔离改造方案 - -## 目标 - -将 `pkgs/bay/test_bay_api.py` 中的测试改造为:**每个需要容器的测试使用独立的容器**,测试结束后自动清理。 - -## 当前问题 - -当前代码在 `main()` 函数中创建一个共享的 Ship 容器,然后多个测试复用这个容器: -- `test_get_ship(ship_id)` -- `test_exec_shell(ship_id)` -- `test_exec_invalid_type(ship_id)` -- `test_extend_ttl(ship_id)` -- `test_get_logs(ship_id)` -- `test_upload_download(ship_id)` -- `test_delete_ship(ship_id)` - -这导致测试之间存在隐式依赖,一个测试的副作用可能影响后续测试。 - -## 改造方案 - -### 1. 添加辅助基础设施 - -创建一个上下文管理器 `fresh_ship()` 用于自动创建和清理容器: - -```python -from contextlib import contextmanager -from typing import Generator - -@contextmanager -def fresh_ship( - session_id: str | None = None, - ttl: int = 60, - cpus: float = 0.5, - memory: str = "256m", - disk: str | None = None, -) -> Generator[tuple[str, dict], None, None]: - """ - 创建独立的 Ship 容器用于单个测试,测试结束后自动清理。 - - Yields: - tuple[ship_id, headers]: Ship ID 和请求头 - """ - # 生成唯一的 session ID - test_session_id = session_id or f"test-{uuid.uuid4().hex[:12]}" - headers = { - "Authorization": f"Bearer {ACCESS_TOKEN}", - "X-SESSION-ID": test_session_id, - } - - ship_id = None - try: - # 创建 Ship - spec = {"cpus": cpus, "memory": memory} - if disk: - spec["disk"] = disk - payload = {"ttl": ttl, "max_session_num": 1, "spec": spec} - - resp = requests.post( - f"{BAY_URL}/ship", - headers={**headers, "Content-Type": "application/json"}, - json=payload, - timeout=120, - ) - if resp.status_code != 201: - raise RuntimeError(f"创建 Ship 失败: {resp.status_code} - {resp.text}") - - data = resp.json() - ship_id = data.get("id") - - # 等待容器就绪 - time.sleep(2) - - yield ship_id, headers - - finally: - # 清理容器 - if ship_id: - try: - requests.delete( - f"{BAY_URL}/ship/{ship_id}", - headers=headers, - timeout=30, - ) - except Exception: - pass # 清理失败不影响测试结果 -``` - -### 2. 测试分类与改造 - -#### 第一类:不需要容器的测试(无需修改) - -| 测试函数 | 说明 | -|---------|------| -| `test_memory_utils()` | 本地单元测试 | -| `test_disk_utils()` | 本地单元测试 | -| `test_health()` | API 测试,无需容器 | -| `test_root()` | API 测试,无需容器 | -| `test_stat()` | API 测试,无需容器 | -| `test_auth_required()` | API 测试,无需容器 | -| `test_list_ships()` | API 测试,无需容器 | -| `test_create_ship_invalid_payload()` | API 测试,无需容器 | -| `test_get_ship_not_found()` | API 测试,无需容器 | - -#### 第二类:已经使用独立容器的测试(无需修改) - -| 测试函数 | 说明 | -|---------|------| -| `test_create_ship_with_small_memory()` | 已自包含 ✅ | -| `test_create_ship_with_disk()` | 已自包含 ✅ | -| `test_data_persistence()` | 已自包含 ✅ | - -#### 第三类:需要改造为独立容器的测试 - -| 测试函数 | 改造方式 | -|---------|---------| -| `test_create_ship()` | 改为 `test_create_and_get_ship()` - 创建、验证、删除 | -| `test_get_ship()` | 合并到 `test_create_and_get_ship()` | -| `test_exec_shell()` | 改为自包含,使用 `fresh_ship()` | -| `test_exec_invalid_type()` | 改为自包含,使用 `fresh_ship()` | -| `test_extend_ttl()` | 改为自包含,使用 `fresh_ship()` | -| `test_get_logs()` | 改为自包含,使用 `fresh_ship()` | -| `test_upload_download()` | 改为自包含,使用 `fresh_ship()` | -| `test_delete_ship()` | 合并到每个测试的清理逻辑中 | -| `test_delete_ship_not_found()` | 改为独立测试,创建后删除两次 | - -### 3. 改造后的测试函数示例 - -#### test_exec_shell 改造 - -```python -def test_exec_shell() -> bool: - print_section("测试: 执行 Shell 命令") - try: - with fresh_ship() as (ship_id, headers): - print(f"使用独立 Ship: {ship_id}") - payload = {"type": "shell/exec", "payload": {"command": "echo Bay"}} - resp = requests.post( - f"{BAY_URL}/ship/{ship_id}/exec", - headers={**headers, "Content-Type": "application/json"}, - json=payload, - timeout=30, - ) - return check_status(resp, 200, "Shell 命令执行成功", "Shell 命令执行失败") - except Exception as exc: - print(f"❌ 请求失败: {exc}") - return False -``` - -#### test_delete_ship_not_found 改造 - -```python -def test_delete_ship_not_found() -> bool: - print_section("测试: 删除不存在的 Ship") - try: - with fresh_ship() as (ship_id, headers): - # 先删除一次 - resp = requests.delete( - f"{BAY_URL}/ship/{ship_id}", - headers=headers, - timeout=30, - ) - if resp.status_code != 204: - print(f"❌ 第一次删除失败: {resp.status_code}") - return False - - # 再删除一次,应该返回 404 - resp = requests.delete( - f"{BAY_URL}/ship/{ship_id}", - headers=headers, - timeout=10, - ) - return check_status(resp, 404, "重复删除返回 404", "重复删除未返回 404") - except Exception as exc: - print(f"❌ 请求失败: {exc}") - return False -``` - -### 4. 改造后的 main() 函数 - -```python -def main() -> None: - print("Bay API 功能测试(容器隔离版)") - print("=" * 70) - print(f"服务地址: {BAY_URL}") - print() - - # ===== 第一阶段:本地单元测试 ===== - print("\n" + "=" * 70) - print("阶段 1: 本地单元测试") - print("=" * 70) - - if not test_memory_utils(): - print("\n内存单元测试失败,退出测试") - sys.exit(1) - if not test_disk_utils(): - print("\n磁盘单元测试失败,退出测试") - sys.exit(1) - - # ===== 第二阶段:无容器 API 测试 ===== - print("\n" + "=" * 70) - print("阶段 2: 无容器 API 测试") - print("=" * 70) - - if not test_health(): - print("\n服务未运行,退出测试") - sys.exit(1) - test_root() - test_stat() - test_auth_required() - test_list_ships() - test_create_ship_invalid_payload() - test_get_ship_not_found() - - # ===== 第三阶段:独立容器测试 ===== - print("\n" + "=" * 70) - print("阶段 3: 独立容器测试(每个测试使用独立容器)") - print("=" * 70) - - test_create_ship_with_small_memory() - test_create_ship_with_disk() - test_create_and_get_ship() - test_exec_shell() - test_exec_invalid_type() - test_extend_ttl() - test_get_logs() - test_upload_download() - test_delete_ship_not_found() - - # ===== 第四阶段:特殊生命周期测试 ===== - print("\n" + "=" * 70) - print("阶段 4: 特殊生命周期测试") - print("=" * 70) - - test_data_persistence() - - print("\n" + "=" * 70) - print("测试完成!") - print("=" * 70) -``` - -## 实施计划 - -- [ ] 添加 `fresh_ship()` 上下文管理器 -- [ ] 创建 `test_create_and_get_ship()` 合并创建和获取测试 -- [ ] 改造 `test_exec_shell()` 使用独立容器 -- [ ] 改造 `test_exec_invalid_type()` 使用独立容器 -- [ ] 改造 `test_extend_ttl()` 使用独立容器 -- [ ] 改造 `test_get_logs()` 使用独立容器 -- [ ] 改造 `test_upload_download()` 使用独立容器 -- [ ] 改造 `test_delete_ship_not_found()` 使用独立容器 -- [ ] 更新 `main()` 函数移除共享容器逻辑 -- [ ] 删除不再需要的 `test_create_ship()`, `test_get_ship()`, `test_delete_ship()` 函数 - -## 优势 - -1. **完全隔离** - 每个测试使用独立容器,不会相互影响 -2. **更健壮** - 单个测试失败不会导致后续测试全部失败 -3. **可并行** - 理论上可以并行运行所有独立容器测试 -4. **易于调试** - 每个测试的容器状态是确定的 - -## 潜在问题 - -1. **运行时间增加** - 每个测试都需要创建和销毁容器,总时间会增加 -2. **资源消耗** - 同时运行多个测试时可能消耗更多资源 - -## 缓解措施 - -- 可以添加 `--share-container` 参数用于快速开发测试场景 -- 容器创建可以使用较小的资源配置(0.5 CPU, 256m 内存) -- TTL 设置较短(60秒)以便快速回收