Skip to content

Commit 8d23c60

Browse files
authored
Merge pull request #5 from RC-CHN/bay-dashboard
Bay dashboard
2 parents 1e5c73d + 1593768 commit 8d23c60

87 files changed

Lines changed: 11477 additions & 598 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: 130 additions & 19 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

@@ -63,10 +63,12 @@ async def update_ship(self, ship: Ship) -> Ship:
6363
ship.updated_at = datetime.now(timezone.utc)
6464
session = self.get_session()
6565
try:
66-
session.add(ship)
66+
# Use merge() instead of add() to handle detached objects
67+
# merge() copies the state of the given instance into a persistent instance
68+
merged_ship = await session.merge(ship)
6769
await session.commit()
68-
await session.refresh(ship)
69-
return ship
70+
await session.refresh(merged_ship)
71+
return merged_ship
7072
finally:
7173
await session.close()
7274

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

8991
async def list_active_ships(self) -> List[Ship]:
90-
"""List all active ships"""
92+
"""List all active ships (running and creating)"""
93+
session = self.get_session()
94+
try:
95+
# Include both RUNNING and CREATING status ships
96+
statement = select(Ship).where(
97+
(Ship.status == ShipStatus.RUNNING) | (Ship.status == ShipStatus.CREATING)
98+
)
99+
result = await session.execute(statement)
100+
return list(result.scalars().all())
101+
finally:
102+
await session.close()
103+
104+
async def list_all_ships(self) -> List[Ship]:
105+
"""List all ships (including stopped)"""
91106
session = self.get_session()
92107
try:
93-
statement = select(Ship).where(Ship.status == 1)
108+
statement = select(Ship).order_by(Ship.created_at.desc())
94109
result = await session.execute(statement)
95110
return list(result.scalars().all())
96111
finally:
@@ -163,20 +178,21 @@ async def update_session_ship(self, session_ship: SessionShip) -> SessionShip:
163178
"""Update session-ship relationship"""
164179
session = self.get_session()
165180
try:
166-
session.add(session_ship)
181+
# Use merge() instead of add() to handle detached objects
182+
merged_session_ship = await session.merge(session_ship)
167183
await session.commit()
168-
await session.refresh(session_ship)
169-
return session_ship
184+
await session.refresh(merged_session_ship)
185+
return merged_session_ship
170186
finally:
171187
await session.close()
172188

173189
async def find_available_ship(self, session_id: str) -> Optional[Ship]:
174190
"""Find an available ship that can accept a new session"""
175191
session = self.get_session()
176192
try:
177-
# Find ships that have available session slots
193+
# Find ships that have available session slots (only RUNNING ships)
178194
statement = select(Ship).where(
179-
Ship.status == 1, Ship.current_session_num < Ship.max_session_num
195+
Ship.status == ShipStatus.RUNNING, Ship.current_session_num < Ship.max_session_num
180196
)
181197
result = await session.execute(statement)
182198
ships = list(result.scalars().all())
@@ -193,38 +209,50 @@ async def find_available_ship(self, session_id: str) -> Optional[Ship]:
193209
await session.close()
194210

195211
async def find_active_ship_for_session(self, session_id: str) -> Optional[Ship]:
196-
"""Find an active running ship that this session has access to"""
212+
"""Find an active running ship that this session has access to.
213+
214+
If the session has access to multiple running ships, returns the most recently updated one.
215+
"""
197216
session = self.get_session()
198217
try:
199-
# Find active ships that this session has access to
218+
# Find RUNNING ships that this session has access to
219+
# Order by updated_at desc to get the most recently used one
200220
statement = (
201221
select(Ship)
202222
.join(SessionShip, Ship.id == SessionShip.ship_id)
203223
.where(
204224
SessionShip.session_id == session_id,
205-
Ship.status == 1,
225+
Ship.status == ShipStatus.RUNNING,
206226
)
227+
.order_by(Ship.updated_at.desc())
207228
)
208229
result = await session.execute(statement)
209-
return result.scalar_one_or_none()
230+
# Use scalars().first() instead of scalar_one_or_none() to handle multiple results
231+
return result.scalars().first()
210232
finally:
211233
await session.close()
212234

213235
async def find_stopped_ship_for_session(self, session_id: str) -> Optional[Ship]:
214-
"""Find a stopped ship that belongs to this session"""
236+
"""Find a stopped ship that belongs to this session.
237+
238+
If the session has access to multiple stopped ships, returns the most recently updated one.
239+
"""
215240
session = self.get_session()
216241
try:
217-
# Find stopped ships that this session has access to
242+
# Find STOPPED ships that this session has access to
243+
# Order by updated_at desc to get the most recently stopped one
218244
statement = (
219245
select(Ship)
220246
.join(SessionShip, Ship.id == SessionShip.ship_id)
221247
.where(
222248
SessionShip.session_id == session_id,
223-
Ship.status == 0,
249+
Ship.status == ShipStatus.STOPPED,
224250
)
251+
.order_by(Ship.updated_at.desc())
225252
)
226253
result = await session.execute(statement)
227-
return result.scalar_one_or_none()
254+
# Use scalars().first() instead of scalar_one_or_none() to handle multiple results
255+
return result.scalars().first()
228256
finally:
229257
await session.close()
230258

@@ -266,5 +294,88 @@ async def decrement_ship_session_count(self, ship_id: str) -> Optional[Ship]:
266294
finally:
267295
await session.close()
268296

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

270381
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: 19 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)),
@@ -87,6 +95,10 @@ class CreateShipRequest(BaseModel):
8795
max_session_num: int = Field(
8896
default=1, gt=0, description="Maximum number of sessions that can use this ship"
8997
)
98+
force_create: bool = Field(
99+
default=False,
100+
description="If True, skip all reuse logic and always create a new container"
101+
)
90102

91103

92104
class ShipResponse(BaseModel):
@@ -127,6 +139,12 @@ class ExtendTTLRequest(BaseModel):
127139
ttl: int = Field(..., gt=0, description="New TTL in seconds")
128140

129141

142+
class StartShipRequest(BaseModel):
143+
model_config = ConfigDict(extra="forbid")
144+
145+
ttl: int = Field(default=3600, gt=0, description="TTL in seconds for the started ship")
146+
147+
130148
class ErrorResponse(BaseModel):
131149
detail: str
132150

0 commit comments

Comments
 (0)