feat(cloud-api): bake cloud-console SPA into the image and serve at /console

Multi-stage Dockerfile: stage 1 (node:20-bookworm-slim) builds cloud-console
with vite base "/console/"; stage 2 (uv) copies dist/ to /app/console-static.
Cloud API mounts the SPA at /console via SpaStaticFiles (StaticFiles subclass
that falls back to index.html for deep-link refreshes) when the new
CLOUD_CONSOLE_STATIC_DIR env is set, and 307-redirects / to /console/. Static
files bypass bearer auth (the SPA shell is public; tokens are still required
for /v1/*). Compose enables the mount by default; local dev still uses
npm run dev + CLOUD_CONSOLE_CORS_ORIGINS.

Jenkinsfile passes mirror overrides (NODE_IMAGE, NPM_REGISTRY, UV_IMAGE,
APT_MIRROR, UV_INDEX_URL) as --build-arg, defaulting to CN mirrors
(registry.jerryyan.net, registry.npmmirror.com, registry-ghcr.jerryyan.top,
mirrors.aliyun.com) so CN builds don't time out; Dockerfile ARGs default to
official upstreams so `docker build .` still works anywhere.

Backend suite: 443 passed (-m "not integration"); cloud-console typecheck
and production build succeed with the new base path.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-13 14:40:52 +08:00
co-authored by Claude Opus 4.6
parent d673e77171
commit 2ccbc63d95
10 changed files with 257 additions and 4 deletions
+43 -1
View File
@@ -5,11 +5,15 @@ import logging
from collections.abc import Callable
from contextlib import asynccontextmanager
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from fastapi import FastAPI, status
from fastapi import Request
from fastapi.responses import JSONResponse
from fastapi.responses import JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from starlette.exceptions import HTTPException as StarletteHTTPException
from starlette.types import Scope
from cloud.auth import (
ChainedAuthProvider,
@@ -19,6 +23,7 @@ from cloud.auth import (
)
from cloud.config import CloudConfig
from cloud.control_config import (
CloudConfigurationError,
CloudControlConfig,
load_control_config,
validate_control_config,
@@ -69,6 +74,25 @@ class CloudApplicationServices:
auth_provider: Any
class SpaStaticFiles(StaticFiles):
"""``StaticFiles`` variant that falls back to ``index.html`` for SPA routes.
``StaticFiles(html=True)`` only serves ``index.html`` for the mount root and
for directory roots; an unknown path like ``/console/tasks/abc`` raises a
plain 404, which breaks deep-link refreshes in a browser-running SPA. This
subclass intercepts 404s for non-asset paths and re-serves ``index.html``
so the SPA router can take over.
"""
async def get_response(self, path: str, scope: Scope) -> Any:
try:
return await super().get_response(path, scope)
except StarletteHTTPException as exc:
if exc.status_code == 404 and path != "index.html":
return await super().get_response("index.html", scope)
raise
def create_app(
*,
config: CloudControlConfig | None = None,
@@ -237,6 +261,24 @@ def create_app(
lease_duration_seconds=control_config.lease_duration_seconds,
)
)
if control_config.console_static_dir:
dist_dir = Path(control_config.console_static_dir)
if not dist_dir.is_dir():
raise CloudConfigurationError(
f"CLOUD_CONSOLE_STATIC_DIR is not a directory: {dist_dir}"
)
@app.get("/", include_in_schema=False)
async def _redirect_to_console() -> RedirectResponse:
return RedirectResponse(url="/console/")
app.mount(
"/console",
SpaStaticFiles(directory=str(dist_dir), html=True),
name="cloud-console",
)
return app