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 @@
+
+
+
+
+
+
+
+
+ {{ statusConfig.text }}
+
+
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 @@
+
+
+
+
+
+
+
+
{{ getIcon(toast.type) }}
+
{{ toast.message }}
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+ TTL:
+
+ {{ formattedTime }}
+
+ (expired)
+
+
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 @@
+
+
+
+
+
+
+
+
+
+ ⚠️
+ {{ error }}
+
+
+
+
+
+
+
+
+
+
+
+
+
服务状态
+
+
+ {{ overview.status }}
+
+
+
+ Version {{ overview.version }}
+
+
+
+
+
+
+ 容器实例
+
+ {{ overview.ships.running }}
+ / {{ overview.ships.total }}
+
+
+
+
+ {{ overview.ships.creating }} 创建中
+
+
+
+
+
+
+
+ 活跃会话
+
+ {{ overview.sessions.active }}
+ / {{ overview.sessions.total }}
+
+
+ 实时交互连接
+
+
+
+
+
+
快捷操作
+
+
+ 新建工作区
+
+
+
+
+
+
+
+
+ 容器状态分布
+
+
+
+
{{ overview.ships.running }}
+
运行中
+
+
+
{{ overview.ships.stopped }}
+
已停止
+
+
+
{{ overview.ships.creating }}
+
创建中
+
+
+
{{ overview.sessions.active }}
+
活跃会话
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
Bay Dashboard
+
容器管理控制台
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
🚢
+
404
+
页面未找到
+
+ 您访问的页面不存在或已被移除
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
⚠️
+
+ {{ error }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ session.session_id }}
+
+
+ {{ isActive ? 'Active' : 'Inactive' }}
+
+
+
+
+
+ 创建于 {{ formatDateTime(session.created_at) }}
+
+
+
+ {{ shortId(session.ship_id) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
会话 ID
+
+
{{ session.session_id }}
+
+
+
+
+
容器 ID
+
+
+ {{ session.ship_id }}
+
+
+
+
+
+
会话状态
+
+ {{ isActive ? 'Active' : 'Inactive' }}
+
+
+
+
初始 TTL
+
{{ Math.floor(session.initial_ttl / 60) }} 分钟
+
+
+
创建时间
+
{{ formatDateTime(session.created_at) }}
+
+
+
最后活动
+
{{ formatDateTime(session.last_activity) }}
+
+
+
过期时间
+
{{ formatDateTime(session.expires_at) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
script.py
+
+
+
+
+
+
+
+
+
+
+ Output
+
+
+
+
+
+
{{ item.content }}
+
+
![Output Image]()
+
+
+ {{ item.content }}
+
+
+
+
等待执行...
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 确定要删除会话 {{ shortId(sessionId) }} 吗?
+
+
+ 删除后会话记录将永久移除,无法恢复。这可能会导致正在进行的用户连接中断。
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
会话管理
+
监控和管理所有的用户会话连接
+
+
+
+
+
+
+ 新建工作区
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ option.label }}
+
+
+
+
+
+
+
+
+
+ ⚠️
+ {{ error }}
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+
+ {{ shortId(session.session_id) }}
+
+ |
+
+
+ {{ shortId(session.ship_id) }}
+
+ |
+
+
+
+ {{ session.is_active ? 'Active' : 'Inactive' }}
+
+ |
+
+ {{ formatDateTime(session.created_at) }}
+ |
+
+ {{ getRelativeTime(session.last_activity) }}
+ |
+
+
+ |
+
+
+
+
+
+
+
+
+
+ 💬
+
+
暂无会话记录
+
+ 当前没有活动的会话连接。新建工作区来开始您的工作吧。
+
+
+
+ 新建工作区
+
+
+
+
+
+
+
+
+
+ 确定要删除会话 {{ shortId(deleteConfirmId || '') }} 吗?
+
+
+ 删除后会话记录将永久移除,无法恢复。这可能会导致正在进行的用户连接中断。
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
用于认证 API 请求的安全令牌,清除后将退出登录
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
设置仪表盘数据自动刷新的时间间隔
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
⚠️
+
+ {{ error }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ shipId }}
+
+
+
+
+
+ 创建于 {{ formatDateTime(ship.created_at) }}
+
+
+
+ {{ ship.ip_address }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
容器 ID
+
+
{{ ship.container_id || '-' }}
+
+
+
+
+
IP 地址
+
{{ ship.ip_address || '-' }}
+
+
+
会话数
+
+ {{ ship.current_session_num }}
+ /
+ {{ ship.max_session_num }}
+
+
+
+
TTL 配置
+
{{ Math.floor(ship.ttl / 60) }} 分钟
+
+
+
创建时间
+
{{ formatDateTime(ship.created_at) }}
+
+
+
更新时间
+
{{ formatDateTime(ship.updated_at) }}
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+
+ {{ session.session_id.slice(0, 8) }}
+
+ |
+
+
+
+ {{ session.is_active ? 'Active' : 'Inactive' }}
+
+ |
+ {{ formatDateTime(session.created_at) }} |
+ {{ formatDateTime(session.last_activity) }} |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Live
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 确定要停止容器 {{ shortId(shipId) }} 吗?
+
+
+ 容器停止后将无法继续访问,但数据会保留。您可以随时重新启动或删除它。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 确定要永久删除容器 {{ shortId(shipId) }} 吗?
+
+
+ 此操作将删除容器的数据库记录。
+
+
+
+
+ ⚠️ 以下 {{ sessions.length }} 个关联会话也将被删除:
+
+
+
+ {{ session.session_id.slice(0, 8) }}
+
+
+
+
+ ⚠️ 出于安全考虑,容器的挂载卷数据不会被自动清除。如需清理,请手动处理宿主机上的相关目录。
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ option.label }}
+
+
+
+
+
+
+
+
+
+ ⚠️
+ {{ error }}
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+
+ {{ shortId(ship.id) }}
+
+ |
+
+ {{ ship.ip_address || '-' }}
+ |
+
+
+ |
+
+
+
+ {{ ship.current_session_num }}
+
+ /
+ {{ ship.max_session_num }}
+
+ |
+
+
+ -
+ |
+
+
+ 详情
+
+
+ |
+
+
+
+
+
+
+
+
+
+ 🚢
+
+
暂无容器实例
+
+ 当前没有任何容器。容器会在您新建工作区时自动创建或复用。
+
+
+
+
+
+
+
+
+
+ 确定要停止容器 {{ shortId(deleteConfirmId || '') }} 吗?
+
+
+ 容器停止后将无法继续访问,但数据会保留。您可以随时重新启动或删除它。
+
+
+
+
+
+
+
+
+
+
+
+
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秒)以便快速回收