Skip to content

Commit 1176450

Browse files
committed
feat(bay): add Vue 3 dashboard with session management and Python execution
Dashboard Features: - Vue 3 + TypeScript + Vite frontend with Ocean Breeze UI theme - Ships management: list, create, delete, view details, logs - Sessions management: list, view details, TTL extension, delete - Monaco Editor integration for Python code execution - WebSocket terminal (xterm.js) for interactive shell - Real-time status tracking and auto-refresh - Copy to clipboard functionality Backend Enhancements: - Session management API endpoints - Ship start/stop with session TTL handling - Multi-stage Docker build with Nginx proxy - Comprehensive unit and e2e tests - matplotlib Chinese font configuration fix
1 parent 1e5c73d commit 1176450

87 files changed

Lines changed: 11253 additions & 508 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,3 +185,4 @@ logs/
185185

186186
pkgs/bay/ship_data/
187187
pkgs/bay/scripts/
188+
pkgs/bay/tests/k8s/k8s-deploy-local.yaml

pkgs/bay/Dockerfile

Lines changed: 74 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,84 @@
1+
# ============================================
2+
# Stage 1: Build frontend (Vue.js Dashboard)
3+
# ============================================
4+
FROM node:22-alpine AS frontend-builder
5+
6+
WORKDIR /app/dashboard
7+
8+
# Copy package files first for better caching
9+
COPY dashboard/package.json dashboard/package-lock.json ./
10+
11+
# Install dependencies
12+
RUN npm ci
13+
14+
# Copy source files
15+
COPY dashboard/ ./
16+
17+
# Build for production
18+
RUN npm run build
19+
20+
# ============================================
21+
# Stage 2: Build Python dependencies
22+
# ============================================
23+
FROM python:3.11-slim AS python-builder
24+
25+
WORKDIR /app
26+
27+
# Install build dependencies
28+
RUN apt-get update && apt-get install -y --no-install-recommends \
29+
gcc \
30+
libc6-dev \
31+
&& rm -rf /var/lib/apt/lists/*
32+
33+
# Copy requirements and install dependencies
34+
COPY requirements.txt ./
35+
RUN pip wheel --no-cache-dir --wheel-dir /app/wheels -r requirements.txt
36+
37+
# ============================================
38+
# Stage 3: Final image with Nginx + Python
39+
# ============================================
140
FROM python:3.11-slim
241

3-
# Install system dependencies
4-
# RUN apt-get update && apt-get install -y \
5-
# gcc \
6-
# libc6-dev \
7-
# libffi-dev \
8-
# bash \
9-
# && rm -rf /var/lib/apt/lists/*
42+
# Install Nginx and curl (for health checks)
43+
RUN apt-get update && apt-get install -y --no-install-recommends \
44+
nginx \
45+
curl \
46+
&& rm -rf /var/lib/apt/lists/*
1047

11-
# Set working directory
1248
WORKDIR /app
1349

14-
# Copy all project files
15-
COPY pyproject.toml requirements.txt alembic.ini run.py ./
50+
# Copy Python wheels and install
51+
COPY --from=python-builder /app/wheels /app/wheels
52+
COPY requirements.txt ./
53+
RUN pip install --no-cache-dir --no-index --find-links=/app/wheels -r requirements.txt \
54+
&& rm -rf /app/wheels
55+
56+
# Copy Python application files
57+
COPY pyproject.toml alembic.ini run.py ./
1658
COPY app/ ./app/
1759
COPY alembic/ ./alembic/
1860

19-
# Install dependencies and create data directory in one layer
20-
RUN pip install -r requirements.txt --no-cache-dir && \
21-
mkdir -p /app/data
61+
# Create data directory
62+
RUN mkdir -p /app/data
63+
64+
# Copy built frontend to Nginx html directory
65+
COPY --from=frontend-builder /app/dashboard/dist /usr/share/nginx/html
66+
67+
# Copy Nginx configuration
68+
COPY nginx.conf /etc/nginx/nginx.conf
69+
70+
# Copy and prepare entrypoint script
71+
COPY docker-entrypoint.sh /docker-entrypoint.sh
72+
RUN chmod +x /docker-entrypoint.sh
73+
74+
# Expose ports:
75+
# - 8156: API (Python backend, can be exposed publicly)
76+
# - 8157: Dashboard (Nginx, can be hidden behind NAT)
77+
EXPOSE 8156 8157
2278

23-
# Expose port
24-
EXPOSE 8156
79+
# Health check
80+
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
81+
CMD curl -f http://localhost:8157/nginx-health && curl -f http://localhost:8156/health || exit 1
2582

26-
# Start the application
27-
CMD ["python", "run.py"]
83+
# Start both services
84+
ENTRYPOINT ["/docker-entrypoint.sh"]

pkgs/bay/app/database.py

Lines changed: 105 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from sqlalchemy.pool import StaticPool
44
from typing import Optional, List
55
from app.config import settings
6-
from app.models import Ship, SessionShip
6+
from app.models import Ship, SessionShip, ShipStatus
77
from datetime import datetime, timezone
88

99

@@ -87,10 +87,23 @@ async def delete_ship(self, ship_id: str) -> bool:
8787
await session.close()
8888

8989
async def list_active_ships(self) -> List[Ship]:
90-
"""List all active ships"""
90+
"""List all active ships (running and creating)"""
9191
session = self.get_session()
9292
try:
93-
statement = select(Ship).where(Ship.status == 1)
93+
# Include both RUNNING and CREATING status ships
94+
statement = select(Ship).where(
95+
(Ship.status == ShipStatus.RUNNING) | (Ship.status == ShipStatus.CREATING)
96+
)
97+
result = await session.execute(statement)
98+
return list(result.scalars().all())
99+
finally:
100+
await session.close()
101+
102+
async def list_all_ships(self) -> List[Ship]:
103+
"""List all ships (including stopped)"""
104+
session = self.get_session()
105+
try:
106+
statement = select(Ship).order_by(Ship.created_at.desc())
94107
result = await session.execute(statement)
95108
return list(result.scalars().all())
96109
finally:
@@ -174,9 +187,9 @@ async def find_available_ship(self, session_id: str) -> Optional[Ship]:
174187
"""Find an available ship that can accept a new session"""
175188
session = self.get_session()
176189
try:
177-
# Find ships that have available session slots
190+
# Find ships that have available session slots (only RUNNING ships)
178191
statement = select(Ship).where(
179-
Ship.status == 1, Ship.current_session_num < Ship.max_session_num
192+
Ship.status == ShipStatus.RUNNING, Ship.current_session_num < Ship.max_session_num
180193
)
181194
result = await session.execute(statement)
182195
ships = list(result.scalars().all())
@@ -196,13 +209,13 @@ async def find_active_ship_for_session(self, session_id: str) -> Optional[Ship]:
196209
"""Find an active running ship that this session has access to"""
197210
session = self.get_session()
198211
try:
199-
# Find active ships that this session has access to
212+
# Find RUNNING ships that this session has access to
200213
statement = (
201214
select(Ship)
202215
.join(SessionShip, Ship.id == SessionShip.ship_id)
203216
.where(
204217
SessionShip.session_id == session_id,
205-
Ship.status == 1,
218+
Ship.status == ShipStatus.RUNNING,
206219
)
207220
)
208221
result = await session.execute(statement)
@@ -214,13 +227,13 @@ async def find_stopped_ship_for_session(self, session_id: str) -> Optional[Ship]
214227
"""Find a stopped ship that belongs to this session"""
215228
session = self.get_session()
216229
try:
217-
# Find stopped ships that this session has access to
230+
# Find STOPPED ships that this session has access to
218231
statement = (
219232
select(Ship)
220233
.join(SessionShip, Ship.id == SessionShip.ship_id)
221234
.where(
222235
SessionShip.session_id == session_id,
223-
Ship.status == 0,
236+
Ship.status == ShipStatus.STOPPED,
224237
)
225238
)
226239
result = await session.execute(statement)
@@ -266,5 +279,88 @@ async def decrement_ship_session_count(self, ship_id: str) -> Optional[Ship]:
266279
finally:
267280
await session.close()
268281

282+
async def delete_sessions_for_ship(self, ship_id: str) -> List[str]:
283+
"""Delete all session-ship relationships for a ship and return deleted session IDs"""
284+
session = self.get_session()
285+
try:
286+
# First, get all session IDs for this ship
287+
statement = select(SessionShip).where(SessionShip.ship_id == ship_id)
288+
result = await session.execute(statement)
289+
session_ships = list(result.scalars().all())
290+
291+
deleted_session_ids = [ss.session_id for ss in session_ships]
292+
293+
# Delete all session-ship relationships
294+
for ss in session_ships:
295+
await session.delete(ss)
296+
297+
await session.commit()
298+
return deleted_session_ids
299+
finally:
300+
await session.close()
301+
302+
async def extend_session_ttl(
303+
self, session_id: str, ttl: int
304+
) -> Optional[SessionShip]:
305+
"""Extend the TTL for a session by updating expires_at"""
306+
from datetime import timedelta
307+
308+
session = self.get_session()
309+
try:
310+
statement = select(SessionShip).where(SessionShip.session_id == session_id)
311+
result = await session.execute(statement)
312+
session_ship = result.scalar_one_or_none()
313+
314+
if session_ship:
315+
now = datetime.now(timezone.utc)
316+
session_ship.expires_at = now + timedelta(seconds=ttl)
317+
session_ship.last_activity = now
318+
session.add(session_ship)
319+
await session.commit()
320+
await session.refresh(session_ship)
321+
322+
return session_ship
323+
finally:
324+
await session.close()
325+
326+
async def expire_sessions_for_ship(self, ship_id: str) -> int:
327+
"""Mark all sessions for a ship as expired by setting expires_at to current time.
328+
329+
This is called when a ship is stopped to ensure session status
330+
reflects the actual container state.
331+
332+
Args:
333+
ship_id: The ship ID
334+
335+
Returns:
336+
Number of sessions updated
337+
"""
338+
session = self.get_session()
339+
try:
340+
statement = select(SessionShip).where(SessionShip.ship_id == ship_id)
341+
result = await session.execute(statement)
342+
session_ships = list(result.scalars().all())
343+
344+
now = datetime.now(timezone.utc)
345+
updated_count = 0
346+
347+
for ss in session_ships:
348+
# Only update if session is still active (expires_at > now)
349+
expires_at = ss.expires_at
350+
if expires_at is not None:
351+
if expires_at.tzinfo is None:
352+
expires_at = expires_at.replace(tzinfo=timezone.utc)
353+
if expires_at > now:
354+
ss.expires_at = now
355+
session.add(ss)
356+
updated_count += 1
357+
358+
if updated_count > 0:
359+
await session.commit()
360+
361+
return updated_count
362+
finally:
363+
await session.close()
364+
269365

270366
db_service = DatabaseService()

pkgs/bay/app/main.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from app.database import db_service
77
from app.drivers import initialize_driver, close_driver
88
from app.services.status import status_checker
9-
from app.routes import health, ships, stat
9+
from app.routes import health, ships, stat, sessions
1010

1111
# Configure logging
1212
logging.basicConfig(
@@ -85,6 +85,7 @@ def create_app() -> FastAPI:
8585
app.include_router(health.router, tags=["health"])
8686
app.include_router(ships.router, tags=["ships"])
8787
app.include_router(stat.router, tags=["stat"])
88+
app.include_router(sessions.router, tags=["sessions"])
8889

8990
return app
9091

pkgs/bay/app/models.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,18 @@
55
import uuid
66

77

8+
# Ship status constants
9+
class ShipStatus:
10+
"""Ship status constants"""
11+
STOPPED = 0 # Ship is stopped, container not running
12+
RUNNING = 1 # Ship is running, container active
13+
CREATING = 2 # Ship is being created, container not yet ready
14+
15+
816
# Database Models
917
class ShipBase(SQLModel):
1018
id: str = Field(default_factory=lambda: str(uuid.uuid4()), primary_key=True)
11-
status: int = Field(default=1, description="1: running, 0: stopped")
19+
status: int = Field(default=ShipStatus.CREATING, description="0: stopped, 1: running, 2: creating")
1220
created_at: datetime = Field(
1321
default_factory=lambda: datetime.now(timezone.utc),
1422
sa_column=Column(DateTime(timezone=True)),
@@ -127,6 +135,12 @@ class ExtendTTLRequest(BaseModel):
127135
ttl: int = Field(..., gt=0, description="New TTL in seconds")
128136

129137

138+
class StartShipRequest(BaseModel):
139+
model_config = ConfigDict(extra="forbid")
140+
141+
ttl: int = Field(default=3600, gt=0, description="TTL in seconds for the started ship")
142+
143+
130144
class ErrorResponse(BaseModel):
131145
detail: str
132146

0 commit comments

Comments
 (0)