diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e336878 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +.git +.gitignore +**/__pycache__ +**/*.py[cod] +**/.pytest_cache +**/.mypy_cache +**/.ruff_cache +**/.venv +frontend/node_modules +frontend/build +frontend/.react-router +deploy/data +*.zip diff --git a/.env b/.env new file mode 100644 index 0000000..a238f3e --- /dev/null +++ b/.env @@ -0,0 +1,28 @@ +COMPOSE_PROJECT_NAME=model-develop-new + +# Local development ports +GATEWAY_PORT=28081 +MYSQL_PORT=23318 +RUSTFS_API_PORT=29010 +RUSTFS_CONSOLE_PORT=29011 +BACKEND_PORT=28010 +RUNTIME_PORT=28012 +SCHEDULE_PORT=28013 + +# Jupyter +JUPYTER_IMAGE=quay.io/jupyter/base-notebook:2025-12-31 +JUPYTER_TOKEN=ModelDevelop_Jupyter_Isolated_2026_A7K9 + +# MySQL +MYSQL_DATABASE=model_develop_isolated +MYSQL_USER=model_develop_app +MYSQL_PASSWORD=ModelDevelop_MySQL_App_2026_B8M4 +MYSQL_ROOT_PASSWORD=ModelDevelop_MySQL_Root_2026_C6R2 + +# RustFS +RUSTFS_IMAGE=rustfs/rustfs:latest +RUSTFS_ACCESS_KEY=modeldevelopisolated +RUSTFS_SECRET_KEY=ModelDevelop_RustFS_2026_D9P5 + +# Internal service authentication +INTERNAL_SERVICE_TOKEN=ModelDevelop_Internal_Service_2026_E4T8 \ No newline at end of file diff --git a/.env.example b/.env.example index aae7d7f..7439f6c 100644 --- a/.env.example +++ b/.env.example @@ -1,17 +1,15 @@ COMPOSE_PROJECT_NAME=model-platform-refactored # Local development ports -NGINX_PORT=8080 GATEWAY_PORT=8081 MYSQL_PORT=3308 -REDIS_PORT=6380 RUSTFS_API_PORT=9010 RUSTFS_CONSOLE_PORT=9011 BACKEND_PORT=8010 RUNTIME_PORT=8012 SCHEDULE_PORT=8013 -# Jupyter runs on the internal Compose network only in step 14. +# Jupyter only exposes port 8888 inside the Compose network. JUPYTER_IMAGE=quay.io/jupyter/base-notebook:2025-12-31 JUPYTER_TOKEN=ChangeMe_Jupyter_Internal_2026 @@ -21,9 +19,6 @@ MYSQL_USER=model_platform MYSQL_PASSWORD=ChangeMe_MySQL_App_2026 MYSQL_ROOT_PASSWORD=ChangeMe_MySQL_Root_2026 -# Redis local development credential -REDIS_PASSWORD=ChangeMe_Redis_2026 - # RustFS local development image and credentials RUSTFS_IMAGE=rustfs/rustfs:latest RUSTFS_ACCESS_KEY=modelplatform diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..633687e --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,39 @@ +# 简化系统架构 + +```text +Browser + | + v +Nginx Gateway (静态 React Router SPA + /api + /jupyter 代理) + |-----------------------------| + | /api/v1 | /jupyter/ + v v +FastAPI Backend Shared Jupyter Server + | ^ + | Runtime HTTP | Runtime 管理会话/票据 + v | +Runtime Manager ---------------| + | + +------ MySQL(编辑租约、运行实例) + +FastAPI Backend + | 1. 写 schedule_runs + outbox_events + | 2. 尝试 HTTP 立即推送 + v +Schedule Executor(APScheduler) + |-- MySQL APSchedulerJobStore + |-- MySQL Outbox 轮询兜底 + |-- DAG 节点执行与重试 + |-- RustFS 日志/结果 + +-- Backend 内部 Storage API +``` + +## 关键简化 + +1. 删除 Redis 服务、Redis Streams 和 Redis 文件锁。 +2. 调度定义、运行记录、Outbox、Inbox、Cron JobStore 都由 MySQL 保存。 +3. 立即运行采用 Backend -> Schedule Executor 内部 HTTP 推送;推送失败由 MySQL Outbox 轮询兜底。 +4. Schedule Executor 自带 APScheduler,负责 Cron 触发和 DAG 执行。 +5. 文件编辑锁改为 MySQL 租约,Runtime 单副本运行。 +6. Jupyter 使用一个共享容器,工作区目录通过 Volume 挂载同步。 +7. 前端改为 React Router SPA,并按 feature / route / service / component 分层。 diff --git a/CLAUDE.md b/CLAUDE.md index 12a227d..32cb110 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,78 +1,64 @@ -# CLAUDE.md +# Repository Guide -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +## Current architecture -## Repository overview +- `frontend`: React Router SPA. Production files are built in `nginx/Dockerfile`. +- `backend`: public FastAPI API and internal RustFS storage API in one process. +- `runtime`: shared Jupyter lifecycle, MySQL edit leases and short-lived in-memory access tickets. +- `schedule`: APScheduler, MySQL JobStore, MySQL Outbox polling and DAG execution. +- `common`: SQLAlchemy models, database/session helpers, IDs and object-store helpers. +- `migrations`: Alembic schema and seed migrations. +- `nginx`: static frontend, `/api/` proxy and authenticated `/jupyter/` proxy. -This is a Python 3.12 `uv` workspace for a small Jupyter workspace platform. The root `pyproject.toml` includes four workspace packages: +Redis and the former separate Storage API container are intentionally removed. -- `backend`: FastAPI service used by Nginx `auth_request`. It reads the original workspace/URI headers, performs the current authentication and notebook-lock checks, asks Runtime to find or start a workspace, and returns the selected Jupyter upstream and internal token in response headers for Nginx. -- `runtime`: FastAPI process manager. At startup it mounts `REMOTE_BUCKET` with `rclone`, scans `WORKSPACES_ROOT`, and auto-starts a Jupyter Notebook process for each workspace directory. It keeps process metadata (PID, port, token, URL, start time) in the in-memory `JUPYTER_PROCESSES` dictionary and exposes health and start/stop/list/get actions. -- `common`: Shared package scaffold for SQLAlchemy/Alembic database code and object-storage helpers. `common/src/common/migrations/env.py` uses `common.db.base.Base.metadata`; the model/session/storage layers are currently mostly placeholders. -- `schedule`: Package scaffold only; `main.py` and `executor.py` are currently empty apart from module headers. +## Commands -The request path in the deployed stack is **client → Nginx (`web`) → backend auth subrequest → runtime → per-workspace Jupyter**. `default.conf` also proxies `/storage/` to RustFS and configures WebSocket forwarding for Jupyter. The frontend is not implemented in this repository; `frontend/README.md` only identifies it as the frontend. - -## Common commands - -Run these from the repository root. Dependencies are managed by the committed `uv.lock` file. +From the repository root: ```bash -# Install/synchronize all workspace dependencies -uv sync +# Local Python workspace +uv sync --all-packages -# Run the services locally -make runtime # Runtime on 0.0.0.0:8001 -make runtime-dev # Runtime with Uvicorn reload -make backend # Backend on 0.0.0.0:8000 -make backend-dev # Backend with Uvicorn reload +# Static Python check +python -m compileall common/src backend/src runtime/src schedule/src -# Equivalent direct commands -uv run --package runtime uvicorn runtime.main:app --host 0.0.0.0 --port 8001 -uv run --package backend uvicorn backend.main:app --host 0.0.0.0 --port 8000 +# Database migration +uv run --package backend alembic upgrade head -# Run the containerized stack (Nginx is exposed on localhost:8888) -docker compose up --build -docker compose down +# Full Docker stack +cp .env.example .env +docker compose config +docker compose up -d --build ``` -`Makefile` variables can be overridden on the command line, for example: +Frontend development: ```bash -make runtime-dev WORKSPACES_ROOT=./test/workspaces RUNTIME_PORT=8001 -make backend-dev RUNTIME_BASE_URL=http://127.0.0.1:8001 +cd frontend +pnpm install +pnpm dev +pnpm typecheck +pnpm build ``` -The local Runtime defaults to `WORKSPACES_ROOT=/app/workspaces` in code, while the Makefile overrides it to `./test/workspaces`; make sure the directory exists and contains workspace directories before expecting auto-start behavior. Runtime also needs `rclone`, FUSE support, and a valid remote configuration when exercising the mount path. The Docker image provides `rclone` and `fuse3`; a local process does not. +## Service rules -### Tests, linting, and formatting +- Browser traffic enters through Gateway only. +- Frontend API calls use same-origin `/api/v1/...` paths. +- Backend writes `schedule_runs` and `outbox_events`, then performs best-effort HTTP dispatch to Schedule Executor. +- Schedule Executor always polls pending MySQL Outbox rows, so HTTP dispatch failure does not lose a task. +- Cron jobs are persisted by APScheduler in MySQL table `apscheduler_jobs`. +- Runtime must stay single-replica while file leases and Jupyter tickets use the simplified implementation. +- Never expose the internal Jupyter token to the browser. +- Never delete Docker volumes when preserving MySQL or RustFS data is required. -There is currently no test suite, test configuration, or lint/format command in the repository. Do not assume `make test`, `pytest`, Ruff, or Black is configured. If tests are added, run a single test with the project’s chosen runner (the conventional pytest form is `uv run pytest path/to/test_file.py::test_name`), then add the corresponding dependency and documented command rather than silently relying on a globally installed tool. +## Main entrypoints -A lightweight current smoke check is to compile the Python sources: - -```bash -python -m compileall backend/src common/src runtime/src schedule/src +```text +frontend/app/routes/platform.tsx +backend/src/backend/main.py +runtime/src/runtime/main.py +schedule/src/schedule/main.py +nginx/default.conf.template ``` - -### Database migrations - -Alembic is configured in `common/alembic.ini`, with scripts under `common/src/common/migrations`. The checked-in `sqlalchemy.url` is the generated placeholder `driver://user:pass@localhost/dbname`, so replace/configure it for a real database before running migrations. From the root, the usual commands are: - -```bash -uv run alembic -c common/alembic.ini current -uv run alembic -c common/alembic.ini upgrade head -uv run alembic -c common/alembic.ini revision --autogenerate -m "describe change" -``` - -## Important implementation details - -- `backend/src/backend/main.py` is the ASGI entrypoint (`backend.main:app`). Nginx sends `/internal-auth` to `GET /api/v1/auth/jupyter`; the backend expects `X-Original-Workspace-Id` and `X-Original-URI`, chooses a bearer token or `access_token` cookie, and returns `x-upstream-addr` plus `x-jupyter-internal-token` headers. The JWT validation and database lock lookup are currently mocked/commented, so treat this as demo behavior rather than complete production authentication. -- `runtime/src/runtime/main.py` is the ASGI entrypoint (`runtime.main:app`). Startup/shutdown is implemented through FastAPI lifespan: mount/scan/start on startup, stop Jupyter processes and unmount on shutdown. The API is `GET /api/v1/health` and `POST /api/v1/jupyter` with `action` values `start`, `stop`, `list`, or `get` (workspace ID is required for all except `list`). -- Runtime workspace IDs are directory names below `WORKSPACES_ROOT`. A started Jupyter server receives a dynamic port, a generated token, and base URL `/jupyter//`. Because process state is only in memory, restarting Runtime loses the registry and causes startup scanning to rediscover workspaces. -- `common` is a uv workspace dependency declared by backend/runtime/schedule, but the current service code does not yet contain substantial shared model or storage integration. Keep shared DB/storage behavior in `common` rather than duplicating it in services. -- `docker-compose.yml` builds backend and runtime from the root workspace, exposes backend on `8000`, runtime on `8001`, and Nginx on `8888`. Runtime requires Linux FUSE capabilities (`SYS_ADMIN`, `/dev/fuse`, and unconfined AppArmor). Its RustFS/rclone settings are supplied as compose environment variables; use deployment secrets or environment overrides instead of committing credentials. - -## Change boundaries - -Keep service entrypoints and the Nginx header contract compatible when changing backend/runtime behavior. If an API action or response field changes, update both the caller in `backend` and the proxy rules in `default.conf` together. For runtime process changes, preserve cleanup in the lifespan shutdown path and consider failures from missing mounts, dead subprocesses, dynamic port allocation, and remote storage separately. diff --git a/README.md b/README.md new file mode 100644 index 0000000..85dbcc9 --- /dev/null +++ b/README.md @@ -0,0 +1,69 @@ +# Model Platform Refactored + +本项目是简化后的模型实验开发平台:React Router 前端、FastAPI Backend、 +Runtime、Schedule Executor、MySQL、RustFS、共享 Jupyter 与 Nginx Gateway。 +Redis 已移除。 + +## 目录 + +```text +frontend/app/ + components/ 通用组件 + features/platform/ 平台主框架与脚本工作台 + features/schedules/ 调度画布 + features/admin/ 工作台与系统管理 + routes/ React Router 路由 + services/ API 调用与 DTO +backend/ 核心业务 API +runtime/ Jupyter 与文件编辑租约 +schedule/ APScheduler + DAG Executor +common/ 数据库模型和公共能力 +nginx/ 前端静态站点与统一反向代理 +``` + +## 简化后的调用链 + +```text +Browser -> Nginx Gateway -> FastAPI Backend -> MySQL / RustFS + -> Runtime -> shared Jupyter +Backend --HTTP push-----> Schedule Executor +Schedule Executor --poll MySQL Outbox / APScheduler--> execute DAG +``` + +## 启动 + +Windows PowerShell: + +```powershell +Copy-Item .env.example .env +docker compose config +docker compose up -d --build +docker compose ps +``` + +Git Bash/Linux: + +```bash +cp .env.example .env +docker compose config +docker compose up -d --build +docker compose ps +``` + +访问:`http://127.0.0.1:8081`。 + +查看日志: + +```bash +docker compose logs -f backend runtime schedule gateway +``` + +数据库迁移由 `migrate` 一次性容器在 Backend 启动前自动执行。 + +## Windows 快速脚本 + +```powershell +.\scripts\start.ps1 +.\scripts\logs.ps1 +.\scripts\stop.ps1 +``` diff --git a/REFACTOR_NOTES.md b/REFACTOR_NOTES.md new file mode 100644 index 0000000..4b17b93 --- /dev/null +++ b/REFACTOR_NOTES.md @@ -0,0 +1,56 @@ +# 本次重构说明 + +## 已完成 + +### 前端 + +- 将原 `frontend/src` 页面迁入 React Router SPA 结构; +- 主入口改为 `app/routes/platform.tsx`; +- 拆分为 `components / features / routes / services / styles`; +- 保留脚本管理、目录管理、Jupyter 编辑、版本发布、调度画布、运行记录和系统管理功能; +- 将原 Hash 页面切换改为 React Router 路径:`/`、`/scripts`、`/schedules`、`/system`; +- Nginx 使用 SPA fallback,刷新子路径不会 404。 + +### 后端 + +- 合并 Platform API 和 Storage API 到同一个 `backend` 进程; +- 删除 Redis 容器、依赖、Streams 消费和锁实现; +- 文件锁改为 MySQL `edit_sessions` 租约; +- Jupyter 短期票据由单 Runtime 进程内存保存; +- Schedule Executor 内置 APScheduler,并使用 MySQL `apscheduler_jobs`; +- Backend 创建运行和 Outbox 后,通过内部 HTTP 尝试立即推送; +- Schedule Executor 轮询 MySQL Outbox 作为失败兜底; +- 增加迁移 `20260730_0004`,兼容旧数据库的 `redis_lock_key -> lock_key`。 + +### Docker + +最终容器: + +```text +mysql +rustfs +jupyter +migrate(一次性) +backend +runtime +schedule +gateway +``` + +## 已执行检查 + +- Python 全项目 `compileall` 通过; +- SQLAlchemy 模型导入通过,共加载 26 张表; +- Alembic `upgrade head --sql` 离线生成通过; +- `docker-compose.yml` YAML 解析通过; +- 前端 feature、route 和 root 文件 TypeScript 静态检查通过。 + +## 未在当前沙箱执行 + +当前执行环境没有 Docker 命令,并且无法连接 npm/PyPI,因此没有在沙箱内完成: + +- `docker compose up --build`; +- 正式 `pnpm install && pnpm build`; +- Python 依赖在线安装后的集成测试。 + +请在安装了 Docker Desktop且网络可访问依赖仓库的 Windows 电脑上执行根目录 README 中的启动命令。 diff --git a/backend/Dockerfile b/backend/Dockerfile index 5a3c28e..a76c832 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,14 +1,13 @@ FROM python:3.12-slim-bookworm -ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 +ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PYTHONPATH=/app WORKDIR /app COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv -COPY pyproject.toml uv.lock ./ COPY common ./common COPY backend ./backend COPY alembic.ini ./ COPY migrations ./migrations -RUN uv sync --frozen --no-dev --no-editable --package backend +RUN uv pip install --system ./common ./backend EXPOSE 8000 -CMD ["uv", "run", "--frozen", "--package", "backend", "uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000"] +CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 818e80d..f8a6b83 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -13,7 +13,7 @@ dependencies = [ ] [tool.uv.sources] -common = { workspace = true } +common = { path = "../common" } [build-system] requires = ["hatchling"] diff --git a/backend/src/backend/main.py b/backend/src/backend/main.py index 5991bac..976b952 100644 --- a/backend/src/backend/main.py +++ b/backend/src/backend/main.py @@ -18,6 +18,7 @@ from backend.jupyter import router as jupyter_router from backend.resources import router as resources_router from backend.runtime_client import RuntimeClient from backend.schedule_runs import router as schedule_runs_router +from backend.schedule_client import ScheduleExecutorClient from backend.schedules import router as schedules_router from backend.scripts import router as scripts_router from backend.storage_api import app as storage_app @@ -72,10 +73,19 @@ async def lifespan(app: Any) -> AsyncIterator[None]: runtime_http_client, os.environ["INTERNAL_SERVICE_TOKEN"], ) + schedule_http_client = httpx.AsyncClient( + base_url=os.getenv("SCHEDULE_API_URL", "http://schedule:8000"), + timeout=httpx.Timeout(10.0), + ) + app.state.schedule_client = ScheduleExecutorClient( + schedule_http_client, + os.environ["INTERNAL_SERVICE_TOKEN"], + ) try: yield finally: await runtime_http_client.aclose() + await schedule_http_client.aclose() await storage_http_client.aclose() await engine.dispose() diff --git a/backend/src/backend/schedule_client.py b/backend/src/backend/schedule_client.py new file mode 100644 index 0000000..ba804e5 --- /dev/null +++ b/backend/src/backend/schedule_client.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +import logging + +import httpx + + +LOGGER = logging.getLogger(__name__) + + +class ScheduleExecutorClient: + """Best-effort HTTP notification for immediate run dispatch. + + MySQL remains the source of truth. If this notification fails, the + executor's database polling loop will still pick up the pending Outbox row. + """ + + def __init__(self, client: httpx.AsyncClient, service_token: str) -> None: + self.client = client + self.headers = {"X-Service-Token": service_token} + + async def dispatch_run(self, run_id: str) -> bool: + try: + response = await self.client.post( + f"/internal/v1/runs/{run_id}/dispatch", + headers=self.headers, + ) + except httpx.RequestError: + LOGGER.warning( + "schedule executor notification failed for run %s", + run_id, + exc_info=True, + ) + return False + if response.is_error: + LOGGER.warning( + "schedule executor rejected run %s: %s %s", + run_id, + response.status_code, + response.text[:500], + ) + return False + return True diff --git a/backend/src/backend/schedule_runs.py b/backend/src/backend/schedule_runs.py index 3ac862b..d75de5d 100644 --- a/backend/src/backend/schedule_runs.py +++ b/backend/src/backend/schedule_runs.py @@ -4,7 +4,7 @@ import hashlib from datetime import UTC, datetime from typing import Any, Literal -from fastapi import APIRouter, Depends, Header, HTTPException, Query, status +from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, status from pydantic import Field from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -176,12 +176,13 @@ async def _visible_run( ) async def run_schedule_now( schedule_id: str, + request: Request, payload: RunScheduleRequest | None = None, idempotency_key: str = Header(alias="Idempotency-Key"), context: RequestContext = Depends(request_context), session: AsyncSession = Depends(database_session), ) -> dict[str, Any]: - del payload + reason = payload.reason if payload is not None else "manual_run" key = _normalized_idempotency_key( context.workspace.workspace_id, schedule_id, @@ -263,7 +264,7 @@ async def run_schedule_now( schedule_id=schedule.schedule_id, workspace_id=schedule.workspace_id, workflow_version=schedule.workflow_version, - trigger_type="manual", + trigger_type="cron" if reason == "cron" else "manual", idempotency_key=key, run_status="queued", state_version=0, @@ -292,6 +293,10 @@ async def run_schedule_now( }, ) await session.flush() + # Commit before the HTTP push so the executor can read the Outbox row. + # The executor also polls MySQL, so a failed push does not lose the run. + await session.commit() + await request.app.state.schedule_client.dispatch_run(run.run_id) await session.refresh(run) return { "request_id": context.request_id, diff --git a/common/src/common/db/models.py b/common/src/common/db/models.py index e097c12..f3aa8af 100644 --- a/common/src/common/db/models.py +++ b/common/src/common/db/models.py @@ -14,14 +14,14 @@ class ConsumerInbox(Base): __tablename__ = 'consumer_inbox' __table_args__ = ( Index('idx_consumer_inbox_status', 'consumer_name', 'process_status', 'created_at'), - {'comment': '消费者幂等 Inbox,防止 Stream 重投导致重复执行'} + {'comment': '消费者幂等 Inbox,防止数据库事件重复处理'} ) consumer_name: Mapped[str] = mapped_column(String(128), primary_key=True) event_id: Mapped[str] = mapped_column(CHAR(26), primary_key=True) process_status: Mapped[str] = mapped_column(String(16), nullable=False, server_default=text("'processing'"), comment='processing/succeeded/failed') created_at: Mapped[datetime.datetime] = mapped_column(DATETIME(fsp=3), nullable=False, server_default=text('CURRENT_TIMESTAMP(3)')) - message_id: Mapped[Optional[str]] = mapped_column(String(128), comment='Redis Stream message ID') + message_id: Mapped[Optional[str]] = mapped_column(String(128), comment='数据库事件处理批次标识') processed_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3)) error_message: Mapped[Optional[str]] = mapped_column(String(2000)) @@ -32,7 +32,7 @@ class OutboxEvents(Base): Index('idx_outbox_aggregate', 'aggregate_type', 'aggregate_id', 'created_at'), Index('idx_outbox_idempotency', 'idempotency_key'), Index('idx_outbox_pending', 'event_status', 'available_at', 'created_at'), - {'comment': '事务 Outbox;提交后发布到 Redis Streams'} + {'comment': '事务 Outbox;由 Schedule Executor 直接轮询处理'} ) event_id: Mapped[str] = mapped_column(CHAR(26), primary_key=True) @@ -432,14 +432,14 @@ class EditSessions(Base): Index('idx_edit_sessions_object', 'storage_object_id', 'session_status', 'expires_at'), Index('idx_edit_sessions_runtime', 'runtime_id', 'session_status'), Index('idx_edit_sessions_user', 'user_id', 'session_status'), - {'comment': '编辑会话审计;实时锁状态以 Redis 为准'} + {'comment': '编辑会话与数据库租约;MySQL 为锁状态权威'} ) edit_session_id: Mapped[str] = mapped_column(CHAR(26), primary_key=True) workspace_id: Mapped[str] = mapped_column(CHAR(26), nullable=False) storage_object_id: Mapped[str] = mapped_column(CHAR(26), nullable=False) user_id: Mapped[str] = mapped_column(CHAR(26), nullable=False) - redis_lock_key: Mapped[str] = mapped_column(String(512), nullable=False) + lock_key: Mapped[str] = mapped_column(String(512), nullable=False) lock_token_hash: Mapped[bytes] = mapped_column(BINARY(32), nullable=False, comment='不保存原始 token') session_status: Mapped[str] = mapped_column(String(16), nullable=False, server_default=text("'active'"), comment='active/closed/expired/failed') started_at: Mapped[datetime.datetime] = mapped_column(DATETIME(fsp=3), nullable=False, server_default=text('CURRENT_TIMESTAMP(3)')) diff --git a/common/src/common/eventing.py b/common/src/common/eventing.py index 02f6b6d..7035679 100644 --- a/common/src/common/eventing.py +++ b/common/src/common/eventing.py @@ -9,10 +9,10 @@ from common.db.models import OutboxEvents from common.ids import new_ulid -STREAM_BY_EVENT_TYPE = { - "schedule.run.requested": "stream:scheduler:commands", - "job.node.execute": "stream:jobs:execute", - "job.node.finished": "stream:jobs:results", +SUPPORTED_EVENT_TYPES = { + "schedule.run.requested", + "job.node.execute", + "job.node.finished", } @@ -39,7 +39,7 @@ async def add_outbox_event( payload: dict[str, Any], available_at: datetime | None = None, ) -> OutboxEvents: - if event_type not in STREAM_BY_EVENT_TYPE: + if event_type not in SUPPORTED_EVENT_TYPES: raise ValueError(f"unsupported event type: {event_type}") event_id = new_ulid() envelope = { diff --git a/contracts/README.md b/contracts/README.md index 5c1a927..002f4b4 100644 --- a/contracts/README.md +++ b/contracts/README.md @@ -4,7 +4,7 @@ ```text openapi/ HTTP OpenAPI 3 契约 -events/ Redis Streams JSON Schema +events/ MySQL Outbox 内部事件 JSON Schema runtime/ Runtime Adapter 契约 locks/ Notebook 编辑锁契约 ``` diff --git a/contracts/demo-core-v1.md b/contracts/demo-core-v1.md index b30696b..da788ff 100644 --- a/contracts/demo-core-v1.md +++ b/contracts/demo-core-v1.md @@ -30,17 +30,16 @@ Demo 公共 HTTP API 由两部分共同组成: - 失败策略:`stop / continue` - 触发类型:`manual / cron / api / retry` -## 4. Redis Streams 路由 +## 4. MySQL Outbox 与 HTTP 推送 -| Stream | Consumer Group | 事件 | 生产者 | 消费者 | -|---|---|---|---|---| -| `stream:scheduler:commands` | `schedule-orchestrator` | `schedule.run.requested` | Platform API / Cron Dispatcher | Schedule Orchestrator | -| `stream:jobs:execute` | `job-workers` | `job.node.execute` | Schedule Orchestrator | Job Worker | -| `stream:jobs:results` | `schedule-results` | `job.node.finished` | Job Worker | Schedule Orchestrator | +| 事件 | 写入方 | 处理方 | +|---|---|---| +| `schedule.run.requested` | Backend / Cron Dispatcher | Schedule Executor | +| `job.node.execute` | Schedule Executor | Schedule Executor Worker | +| `job.node.finished` | Schedule Executor Worker | Schedule Executor | -交付语义为至少一次。业务事务先写 `outbox_events`,发布成功后更新 -Outbox;消费者处理前以 `consumer_inbox` 去重,业务更新与 Inbox 写入同一 -MySQL 事务。只有业务事务提交成功后才确认 Redis 消息。 +业务事务先写 `outbox_events`。Backend 对“立即运行”执行一次内部 HTTP 推送, +Executor 同时轮询 MySQL 作为兜底;`consumer_inbox` 防止同一事件重复执行。 ## 5. 模块所有权 diff --git a/contracts/events/README.md b/contracts/events/README.md index 38b8415..4e0482a 100644 --- a/contracts/events/README.md +++ b/contracts/events/README.md @@ -1,11 +1,11 @@ -# Event Schemas +# Database Event Schemas -Redis Streams 事件以 JSON Schema Draft 2020-12 定义: +内部事件以 JSON Schema Draft 2020-12 定义: -- `event-envelope-v1.json`:公共事件信封。 -- `schedule-run-requested-v1.json`:请求启动一次调度运行。 -- `job-node-execute-v1.json`:请求 Worker 执行一个稳定版本节点。 -- `job-node-finished-v1.json`:Worker 报告节点终态。 +- `event-envelope-v1.json`:公共事件信封; +- `schedule-run-requested-v1.json`:请求启动一次调度运行; +- `job-node-execute-v1.json`:请求执行一个稳定版本节点; +- `job-node-finished-v1.json`:节点终态。 -所有事件使用至少一次投递、Transactional Outbox 与 Consumer Inbox。 -字段和状态值不得在生产者或消费者中另行定义。 +事件先随业务事务写入 MySQL `outbox_events`,Schedule Executor 直接轮询处理; +`consumer_inbox` 提供幂等保护。字段和状态值不得在生产者或消费者中另行定义。 diff --git a/contracts/events/event-envelope-v1.json b/contracts/events/event-envelope-v1.json index feb5136..7484242 100644 --- a/contracts/events/event-envelope-v1.json +++ b/contracts/events/event-envelope-v1.json @@ -2,7 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "event-envelope-v1.json", "title": "Model Platform Event Envelope V1", - "description": "Redis Streams 中所有业务事件共用的不可变信封。", + "description": "MySQL Outbox 中所有内部业务事件共用的不可变信封。", "type": "object", "additionalProperties": false, "required": [ diff --git a/contracts/locks/README.md b/contracts/locks/README.md index c8a8f12..3578db1 100644 --- a/contracts/locks/README.md +++ b/contracts/locks/README.md @@ -1,4 +1,3 @@ -# 编辑锁契约 +# File Edit Lock -当前版本见 [file-edit-lock-v1.md](file-edit-lock-v1.md)。Redis 是实时锁唯一 -权威,MySQL `edit_sessions` 仅用于审计。 +当前版本见 [file-edit-lock-v1.md](file-edit-lock-v1.md)。MySQL 租约是实时锁权威。 diff --git a/contracts/locks/file-edit-lock-v1.md b/contracts/locks/file-edit-lock-v1.md index af49c36..f955c61 100644 --- a/contracts/locks/file-edit-lock-v1.md +++ b/contracts/locks/file-edit-lock-v1.md @@ -8,56 +8,31 @@ POST /api/v1/file-locks/{edit_session_id}/heartbeat DELETE /api/v1/file-locks/{edit_session_id} ``` -三个接口均要求用户身份和 Workspace 身份。当前开发基线使用 -`X-User-ID`、`X-Workspace-ID`,后续接入 JWT 时保持路径和业务 DTO 不变。 - -加锁成功返回一次性原始 `lock_token`。心跳和释放请求体均为: +三个接口均要求 `X-User-ID` 和 `X-Workspace-ID`。加锁成功返回一次性原始 +`lock_token`;心跳和释放请求体均为: ```json {"lock_token": "raw-token-returned-by-acquire"} ``` -原始 token 只由客户端持有,禁止写入 MySQL 和日志。 +原始 token 只由客户端持有,数据库只保存 SHA-256 摘要。 -## Redis 数据 +## MySQL 租约 -```text -Key: lock:file:{workspace_id}:{storage_object_id} -TTL: 45000 ms -``` +`edit_sessions` 是实时锁权威: -Value: +- `session_status=active` 且 `expires_at > now()` 表示锁有效; +- 同一 `storage_object_id` 同时只能存在一个有效编辑会话; +- 心跳更新 `last_heartbeat_at` 与 `expires_at`; +- 主动释放将状态改为 `closed`; +- 后台清理将超时租约改为 `expired`。 -```json -{ - "edit_session_id": "01J...", - "user_id": "01J...", - "display_name": "张三", - "token_hash": "sha256-hex", - "acquired_at": "UTC timestamp" -} -``` - -加锁必须使用: - -```text -SET key value NX PX 45000 -``` - -心跳和释放必须执行 Lua 原子操作,并同时比较 -`edit_session_id + token_hash`: - -```text -heartbeat: compare owner -> PEXPIRE 45000 -release: compare owner -> DEL -``` - -禁止使用 `GET` 后单独 `DEL`,也禁止在 MySQL 文件表增加 `is_locked`。 +加锁、心跳和释放都在数据库事务中校验 `edit_session_id + lock_token_hash`。 +部署时 Runtime 保持单副本;若扩展到多副本,应为加锁查询增加数据库行锁或唯一 +租约表约束。 ## 状态与错误 -- Redis 是实时锁唯一权威。 -- `edit_sessions` 记录 `active/closed/expired`,仅用于审计。 -- 冲突返回 HTTP 409、错误码 `FILE_LOCK_CONFLICT`,并包含当前编辑者及租约到期时间。 -- 错误 token 返回 HTTP 403,且不得续期或释放现有锁。 -- 浏览器建议每 15 秒心跳;关闭、断线或心跳超时后由主动释放或 TTL 释放。 +- 冲突返回 HTTP 409、错误码 `FILE_LOCK_CONFLICT`,并包含当前编辑者和租约到期时间; +- 错误 token 返回 HTTP 403,且不得续期或释放现有锁; +- 浏览器建议每 15 秒心跳,默认租约为 45 秒。 diff --git a/contracts/schedules/schedule-definition-api-v1.md b/contracts/schedules/schedule-definition-api-v1.md index af6c88a..4393816 100644 --- a/contracts/schedules/schedule-definition-api-v1.md +++ b/contracts/schedules/schedule-definition-api-v1.md @@ -13,7 +13,7 @@ - 五段 Cron 校验与未来时间预览; - DAG 完整性和有向无环校验。 -立即运行、Cron 自动触发、Redis Streams 事件和 Worker 执行属于后续小步。 +立即运行、Cron 自动触发和 Executor 执行已经由独立 Schedule 服务承接。 ## 2. 请求上下文 diff --git a/deploy/data/workspaces/model-dev/users/admin-zhang/.ipynb_checkpoints/111test1-checkpoint.ipynb b/deploy/data/workspaces/model-dev/users/admin-zhang/.ipynb_checkpoints/111test1-checkpoint.ipynb new file mode 100644 index 0000000..06b3181 --- /dev/null +++ b/deploy/data/workspaces/model-dev/users/admin-zhang/.ipynb_checkpoints/111test1-checkpoint.ipynb @@ -0,0 +1,67 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "5f4293c4", + "metadata": {}, + "source": [ + "# 新建模型实验\\n在这里开始数据探索与模型构建。" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "694d9330", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Hello, Model Platform!\n" + ] + } + ], + "source": [ + "print('Hello, Model Platform!')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6c70739e-0bd2-413d-bb0e-68ed8951d4bb", + "metadata": {}, + "outputs": [], + "source": [] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a1574172-4d3c-40cd-860e-f0f0b4822c49", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.13.11" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/deploy/data/workspaces/model-dev/users/admin-zhang/.ipynb_checkpoints/222test2-checkpoint.py b/deploy/data/workspaces/model-dev/users/admin-zhang/.ipynb_checkpoints/222test2-checkpoint.py new file mode 100644 index 0000000..2dc3e1b --- /dev/null +++ b/deploy/data/workspaces/model-dev/users/admin-zhang/.ipynb_checkpoints/222test2-checkpoint.py @@ -0,0 +1,9 @@ +"""模型实验开发平台构建脚本。""" + + +def main() -> None: + print("Hello, Model Platform!") + + +if __name__ == "__main__": + main() diff --git a/deploy/data/workspaces/model-dev/users/admin-zhang/111test1.ipynb b/deploy/data/workspaces/model-dev/users/admin-zhang/111test1.ipynb new file mode 100644 index 0000000..08007ca --- /dev/null +++ b/deploy/data/workspaces/model-dev/users/admin-zhang/111test1.ipynb @@ -0,0 +1,89 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "5f4293c4", + "metadata": {}, + "source": [ + "# 新建模型实验\\n在这里开始数据探索与模型构建。" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "694d9330", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Hello, Model Platform!\n" + ] + } + ], + "source": [ + "print('Hello, Model Platform!')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6c70739e-0bd2-413d-bb0e-68ed8951d4bb", + "metadata": {}, + "outputs": [], + "source": [] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "a1574172-4d3c-40cd-860e-f0f0b4822c49", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "'2026-07-30 10:38:49.218085'" + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "from datetime import datetime\n", + "str(datetime.now())" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2d521122-0ee9-4f39-b0a2-6d3a3b004916", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.13.11" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/deploy/data/workspaces/model-dev/users/admin-zhang/222test2.py b/deploy/data/workspaces/model-dev/users/admin-zhang/222test2.py new file mode 100644 index 0000000..2dc3e1b --- /dev/null +++ b/deploy/data/workspaces/model-dev/users/admin-zhang/222test2.py @@ -0,0 +1,9 @@ +"""模型实验开发平台构建脚本。""" + + +def main() -> None: + print("Hello, Model Platform!") + + +if __name__ == "__main__": + main() diff --git a/docker-compose.yml b/docker-compose.yml index 16fdec1..f5a6d4b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,4 @@ -name: ${COMPOSE_PROJECT_NAME:-model-platform-refactored} +name: ${COMPOSE_PROJECT_NAME:-model-platform-new} x-app-environment: &app-environment DATABASE_URL: mysql+asyncmy://${MYSQL_USER:-model_platform}:${MYSQL_PASSWORD:-model_platform}@mysql:3306/${MYSQL_DATABASE:-model_platform}?charset=utf8mb4 @@ -18,6 +18,7 @@ services: command: - --character-set-server=utf8mb4 - --collation-server=utf8mb4_0900_ai_ci + - --default-time-zone=+08:00 ports: - "${MYSQL_PORT:-3308}:3306" volumes: @@ -27,20 +28,7 @@ services: interval: 10s timeout: 5s retries: 15 - - redis: - image: redis:7.2.5-alpine - restart: unless-stopped - command: ["redis-server", "--appendonly", "yes", "--requirepass", "${REDIS_PASSWORD:-model_platform_redis}"] - ports: - - "${REDIS_PORT:-6380}:6379" - volumes: - - redis_data:/data - healthcheck: - test: ["CMD-SHELL", "redis-cli -a '${REDIS_PASSWORD:-model_platform_redis}' ping | grep PONG"] - interval: 10s - timeout: 5s - retries: 10 + start_period: 20s rustfs: image: ${RUSTFS_IMAGE:-rustfs/rustfs:latest} @@ -75,6 +63,16 @@ services: - ./deploy/data/workspaces:/home/jovyan/work expose: - "8888" + healthcheck: + test: + - CMD + - python + - -c + - "import os, urllib.request; r=urllib.request.Request('http://127.0.0.1:8888/jupyter/api/status', headers={'Authorization':'Bearer '+os.environ['JUPYTER_TOKEN']}); urllib.request.urlopen(r, timeout=3)" + interval: 10s + timeout: 5s + retries: 15 + start_period: 30s migrate: build: @@ -82,15 +80,7 @@ services: dockerfile: backend/Dockerfile environment: <<: *app-environment - command: - - uv - - run - - --frozen - - --package - - backend - - alembic - - upgrade - - head + command: ["alembic", "upgrade", "head"] depends_on: mysql: condition: service_healthy @@ -104,13 +94,24 @@ services: environment: <<: *app-environment SERVICE_NAME: backend - READINESS_TARGETS: mysql:3306,redis:6379,rustfs:9000 + READINESS_TARGETS: mysql:3306,rustfs:9000 RUNTIME_API_URL: http://runtime:8000 + SCHEDULE_API_URL: http://schedule:8000 RUSTFS_INTERNAL_ENDPOINT: http://rustfs:9000 RUSTFS_PUBLIC_ENDPOINT: http://localhost:${RUSTFS_API_PORT:-9010} RUSTFS_ACCESS_KEY: ${RUSTFS_ACCESS_KEY:-modelplatform} RUSTFS_SECRET_KEY: ${RUSTFS_SECRET_KEY:-modelplatformsecret} RUSTFS_DEFAULT_BUCKET: model-platform + healthcheck: + test: + - CMD + - python + - -c + - "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health/ready', timeout=3)" + interval: 10s + timeout: 5s + retries: 10 + start_period: 10s volumes: - ./deploy/data/workspaces:/workspace/workspaces ports: @@ -120,8 +121,6 @@ services: condition: service_completed_successfully mysql: condition: service_healthy - redis: - condition: service_healthy rustfs: condition: service_started @@ -133,23 +132,32 @@ services: environment: <<: *app-environment SERVICE_NAME: runtime-manager - READINESS_TARGETS: mysql:3306,redis:6379,jupyter:8888 - REDIS_HOST: redis - REDIS_PORT: "6379" - REDIS_PASSWORD: ${REDIS_PASSWORD:-model_platform_redis} + READINESS_TARGETS: mysql:3306,jupyter:8888 FILE_LOCK_ENABLED: "false" + FILE_LOCK_TTL_MS: "45000" JUPYTER_INTERNAL_URL: http://jupyter:8888/jupyter/ JUPYTER_PROXY_BASE_PATH: /jupyter/ JUPYTER_TICKET_TTL_SECONDS: "300" + RUNTIME_LEASE_SECONDS: "1800" + healthcheck: + test: + - CMD + - python + - -c + - "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health/ready', timeout=3)" + interval: 10s + timeout: 5s + retries: 10 + start_period: 10s ports: - "${RUNTIME_PORT:-8012}:8000" depends_on: + migrate: + condition: service_completed_successfully mysql: condition: service_healthy - redis: - condition: service_healthy jupyter: - condition: service_started + condition: service_healthy schedule: build: @@ -159,14 +167,21 @@ services: environment: <<: *app-environment SERVICE_NAME: schedule-executor - READINESS_TARGETS: mysql:3306,redis:6379,rustfs:9000,backend:8000 - REDIS_HOST: redis - REDIS_PORT: "6379" - REDIS_PASSWORD: ${REDIS_PASSWORD:-model_platform_redis} + READINESS_TARGETS: mysql:3306,rustfs:9000,backend:8000 RUSTFS_INTERNAL_ENDPOINT: http://rustfs:9000 RUSTFS_ACCESS_KEY: ${RUSTFS_ACCESS_KEY:-modelplatform} RUSTFS_SECRET_KEY: ${RUSTFS_SECRET_KEY:-modelplatformsecret} - STORAGE_API_URL: http://backend:8000 + BACKEND_API_URL: http://backend:8000 + healthcheck: + test: + - CMD + - python + - -c + - "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health/ready', timeout=3)" + interval: 10s + timeout: 5s + retries: 10 + start_period: 10s volumes: - ./deploy/data/workspaces:/workspace/workspaces ports: @@ -174,29 +189,36 @@ services: depends_on: mysql: condition: service_healthy - redis: - condition: service_healthy - backend: + rustfs: condition: service_started + backend: + condition: service_healthy gateway: - image: nginx:1.27-alpine + build: + context: . + dockerfile: nginx/Dockerfile restart: unless-stopped environment: INTERNAL_SERVICE_TOKEN: ${INTERNAL_SERVICE_TOKEN:-local-internal-token} - volumes: - - ./nginx/default.conf.template:/etc/nginx/templates/default.conf.template:ro ports: - "${GATEWAY_PORT:-8081}:80" depends_on: backend: - condition: service_started + condition: service_healthy runtime: - condition: service_started + condition: service_healthy + schedule: + condition: service_healthy jupyter: - condition: service_started + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1/health >/dev/null 2>&1"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 5s volumes: mysql_data: - redis_data: rustfs_data: diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..9b8d514 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,4 @@ +.react-router +build +node_modules +README.md \ No newline at end of file diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..039ee62 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,7 @@ +.DS_Store +.env +/node_modules/ + +# React Router +/.react-router/ +/build/ diff --git a/frontend/README.md b/frontend/README.md index f5cde7f..a3844c1 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1 +1,22 @@ -前端 \ No newline at end of file +# Frontend + +React Router SPA,已将原前端按功能拆分到 `app/`: + +```text +app/components/ 通用图标组件 +app/features/platform/ 主框架、脚本工作区和 Jupyter 编辑 +app/features/schedules/ DAG 调度页面 +app/features/admin/ 工作台与系统管理 +app/routes/ React Router 路由入口 +app/services/ API 请求、DTO 和演示上下文 +app/styles/ 分功能样式 +``` + +本地开发: + +```bash +pnpm install +pnpm dev +``` + +生产构建由根目录 `nginx/Dockerfile` 完成,构建结果复制到 Nginx 静态目录。 diff --git a/frontend/app/app.css b/frontend/app/app.css new file mode 100644 index 0000000..928fa83 --- /dev/null +++ b/frontend/app/app.css @@ -0,0 +1,11 @@ +:root { + font-family: Inter, "PingFang SC", "Microsoft YaHei", system-ui, sans-serif; + color: #1f354b; + background: #f3f6f9; + font-synthesis: none; + text-rendering: optimizeLegibility; +} + +* { box-sizing: border-box; } +html, body { margin: 0; min-width: 1024px; min-height: 100%; } +button, input, select, textarea { font: inherit; } diff --git a/frontend/app/components/Icon.tsx b/frontend/app/components/Icon.tsx new file mode 100644 index 0000000..ec50beb --- /dev/null +++ b/frontend/app/components/Icon.tsx @@ -0,0 +1,183 @@ +type IconName = + | "brand" + | "home" + | "script" + | "schedule" + | "experiment" + | "database" + | "settings" + | "chevron" + | "search" + | "plus" + | "upload" + | "folder" + | "notebook" + | "python" + | "refresh" + | "close" + | "check" + | "info" + | "workspace" + | "menu" + | "external" + | "release" + | "play"; + +type IconProps = { + name: IconName; + size?: number; + strokeWidth?: number; +}; + +export default function Icon({ + name, + size = 18, + strokeWidth = 1.8, +}: IconProps) { + const common = { + width: size, + height: size, + viewBox: "0 0 24 24", + fill: "none", + xmlns: "http://www.w3.org/2000/svg", + "aria-hidden": true, + }; + + if (name === "brand") { + return ( + + + + + + ); + } + + const paths: Record, React.ReactNode> = { + home: ( + <> + + + + ), + script: ( + <> + + + + ), + schedule: ( + <> + + + + ), + experiment: ( + <> + + + + ), + database: ( + <> + + + + + ), + settings: ( + <> + + + + ), + chevron: , + search: ( + <> + + + + ), + plus: , + upload: ( + <> + + + + ), + folder: ( + + ), + notebook: ( + <> + + + + ), + python: ( + <> + + + + + + ), + refresh: ( + <> + + + + ), + close: , + check: , + info: ( + <> + + + + ), + workspace: ( + <> + + + + ), + menu: ( + <> + + + ), + external: ( + <> + + + + ), + release: ( + <> + + + + ), + play: , + }; + + return ( + + {paths[name]} + + ); +} diff --git a/frontend/app/features/admin/AdminPages.tsx b/frontend/app/features/admin/AdminPages.tsx new file mode 100644 index 0000000..1f7ad1d --- /dev/null +++ b/frontend/app/features/admin/AdminPages.tsx @@ -0,0 +1,256 @@ +import { FormEvent, useEffect, useState } from "react"; + +import { + ApiRequestError, + createEmployee, + deleteEmployee, + demoContext, + listEmployees, + updateEmployee, + type Employee, +} from "../../services/api"; +import Icon from "../../components/Icon"; +import "../../styles/admin.css"; +import "../../styles/dashboard.css"; + + +type Notice = { + tone: "success" | "error" | "info"; + message: string; +}; + +export function DashboardPage({ + scriptCount, + online, + onNavigate, +}: { + scriptCount: number; + online: boolean; + onNavigate: (page: "scripts" | "schedules" | "system") => void; +}) { + return ( +
+
+
+ MODEL DEVELOPMENT PLATFORM +

下午好,{demoContext.userName}

+

当前位于 {demoContext.workspaceName},可以继续构建脚本或配置调度。

+
+ {online ? "服务正常" : "服务连接中"} +
+
+
{scriptCount}工作副本
+
2Workspace
+
4平台员工
+
{online ? "正常" : "检查中"}平台状态
+
+
+ + + +
+
+
+
运行趋势

近 7 天调度执行

成功率 92.6%
+
+ {[38, 55, 44, 73, 61, 86, 78].map((value, index) => ( +
+ {Math.round(value / 7)} + + {["周一", "周二", "周三", "周四", "周五", "周六", "今天"][index]} +
+ ))} +
+
成功 75失败 6
+
+
+
脚本资产

类型分布

+
+
{scriptCount}全部脚本
+
+ Notebook{Math.max(1, Math.round(scriptCount * .67))} 个 · 67% + Python{Math.max(0, scriptCount - Math.round(scriptCount * .67))} 个 · 33% + 稳定版本3 个已发布 +
+
+
+
+
ACTIVITY

最近平台活动

+
+
操作内容执行人状态时间
+ {[ + ["数据探索.ipynb 发布稳定版本 v3.0", "张三", "成功", "16:42"], + ["每日模型训练流程完成调度运行", "Scheduler", "成功", "15:25"], + ["批量预测.py 更新工作副本", "王五", "已同步", "14:18"], + ["风险验证流程完成 DAG 校验", "李四", "成功", "11:06"], + ].map((row) => ( +
+ {row[0]} + {row[1]}{row[2]}{row[3]} +
+ ))} +
+
+
+
+ ); +} + + +const EMPTY_FORM = { + username: "", + display_name: "", + email: "", + role_code: "developer" as "admin" | "developer", + status: "active" as "active" | "disabled" | "locked", +}; + +export function SystemAdminPage({ + onNotify, + onConnectionChange, +}: { + onNotify: (notice: Notice) => void; + onConnectionChange: (online: boolean) => void; +}) { + const [employees, setEmployees] = useState([]); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [editing, setEditing] = useState(null); + const [dialogOpen, setDialogOpen] = useState(false); + const [form, setForm] = useState(EMPTY_FORM); + const canManage = demoContext.roleCode === "admin"; + + const load = async (): Promise => { + setLoading(true); + try { + setEmployees(await listEmployees()); + onConnectionChange(true); + } catch (error) { + onConnectionChange(false); + onNotify({ + tone: "error", + message: error instanceof Error ? error.message : "员工列表加载失败", + }); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + void load(); + }, []); + + const openCreate = (): void => { + setEditing(null); + setForm(EMPTY_FORM); + setDialogOpen(true); + }; + + const openEdit = (employee: Employee): void => { + setEditing(employee); + setForm({ + username: employee.username, + display_name: employee.display_name, + email: employee.email ?? "", + role_code: employee.role_code, + status: employee.status, + }); + setDialogOpen(true); + }; + + const submit = async (event: FormEvent): Promise => { + event.preventDefault(); + if (!form.display_name.trim() || (!editing && !form.username.trim())) return; + setSaving(true); + try { + if (editing) { + const updated = await updateEmployee(editing.user_id, { + display_name: form.display_name.trim(), + email: form.email.trim() || null, + role_code: form.role_code, + status: form.status, + }); + setEmployees((current) => current.map( + (item) => item.user_id === updated.user_id ? updated : item, + )); + } else { + const created = await createEmployee({ + username: form.username.trim(), + display_name: form.display_name.trim(), + email: form.email.trim() || null, + role_code: form.role_code, + }); + setEmployees((current) => [...current, created]); + } + setDialogOpen(false); + onNotify({ tone: "success", message: editing ? "员工信息已更新" : "员工已添加" }); + } catch (error) { + onNotify({ + tone: "error", + message: error instanceof ApiRequestError ? error.message : "保存员工失败", + }); + } finally { + setSaving(false); + } + }; + + const remove = async (employee: Employee): Promise => { + if (!window.confirm(`确定从当前 Workspace 删除员工“${employee.display_name}”吗?`)) return; + try { + await deleteEmployee(employee.user_id); + setEmployees((current) => current.filter((item) => item.user_id !== employee.user_id)); + onNotify({ tone: "success", message: "员工已删除" }); + } catch (error) { + onNotify({ + tone: "error", + message: error instanceof Error ? error.message : "删除员工失败", + }); + } + }; + + return ( +
+
+
系统管理

员工管理

{demoContext.workspaceName} · {employees.length} 名员工

+ +
+ {!canManage &&
当前为开发人员,只能查看员工列表。
} +
+
员工账号角色状态操作
+ {loading ?

正在加载员工…

: employees.map((employee) => { + const isProtectedAdmin = employee.role_code === "admin"; + return ( +
+ {employee.display_name.slice(0, 1)}{employee.display_name}{employee.email ?? "未设置邮箱"} + {employee.username} + {employee.role_name} + {employee.status === "active" ? "正常" : employee.status === "disabled" ? "已停用" : "已锁定"} + + + + +
+ ); + })} +
+ + {dialogOpen && ( +
+
+
EMPLOYEE

{editing ? "编辑员工" : "添加员工"}

+
void submit(event)}> + + + + + {editing && } +
+
+
+
+ )} +
+ ); +} diff --git a/frontend/app/features/platform/ModelPlatformApp.tsx b/frontend/app/features/platform/ModelPlatformApp.tsx new file mode 100644 index 0000000..73a3b9c --- /dev/null +++ b/frontend/app/features/platform/ModelPlatformApp.tsx @@ -0,0 +1,1965 @@ +import { + type ChangeEvent, + FormEvent, + type MouseEvent as ReactMouseEvent, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { useLocation, useNavigate } from "react-router"; + +import { + acquireFileLock, + createWorkspaceDirectory, + createScript, + createJupyterAccessTicket, + deleteScript, + deleteWorkspaceDirectory, + demoContext, + demoUsers, + demoWorkspaces, + heartbeatFileLock, + listScripts, + listScriptVersions, + listWorkspaceDirectories, + publishScriptVersion, + releaseFileLock, + releaseFileLockOnUnload, + setDemoContext, + uploadScript, + type ActiveEditSession, + type ScriptItem, + type ScriptType, + type StableVersion, + type Visibility, + type WorkspaceDirectory, +} from "../../services/api"; +import Icon from "../../components/Icon"; +import SchedulePage from "../schedules/SchedulePage"; +import { DashboardPage, SystemAdminPage } from "../admin/AdminPages"; +import "../../styles/platform.css"; + + +type NewScriptForm = { + name: string; + scriptType: ScriptType; + visibility: Visibility; + parentPath: string; +}; + +type ContextMenuState = { + x: number; + y: number; + kind: "root" | "directory" | "file"; + path: string; + script?: ScriptItem; +}; + +type ToastState = { + tone: "success" | "error" | "info"; + message: string; +}; + +const navigation = [ + { label: "工作台", icon: "home" as const, page: "home" as const }, + { label: "构建脚本", icon: "script" as const, page: "scripts" as const }, + { label: "调度配置", icon: "schedule" as const, page: "schedules" as const }, + { label: "系统管理", icon: "settings" as const, page: "system" as const }, +]; + +type ActivePage = "home" | "scripts" | "schedules" | "system"; + +function pageFromPath(pathname: string): ActivePage { + const page = pathname.replace(/^\/+|\/+$/g, ""); + return ["scripts", "schedules", "system"].includes(page) + ? page as ActivePage + : "home"; +} + +function pathForPage(page: ActivePage): string { + return page === "home" ? "/workbench" : `/${page}`; +} + +const initialForm: NewScriptForm = { + name: "", + scriptType: "notebook", + visibility: "workspace", + parentPath: "", +}; + +function formatTime(value: string) { + return new Intl.DateTimeFormat("zh-CN", { + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hour12: false, + }).format(new Date(value)); +} + +function formatBytes(value: number) { + if (value < 1024) return `${value} B`; + return `${(value / 1024).toFixed(1)} KB`; +} + +function shortHash(value: string) { + return value ? `${value.slice(0, 8)}…${value.slice(-6)}` : "—"; +} + +function scriptIcon(item: ScriptItem) { + return item.script_type === "notebook" ? "notebook" : "python"; +} + +function ownedScriptPath(item: ScriptItem) { + return item.relative_path.replaceAll("\\", "/").split("/").slice(2).join("/"); +} + +function parentOf(path: string) { + const parts = path.split("/"); + parts.pop(); + return parts.join("/"); +} + +function inferredDirectories(items: ScriptItem[]): WorkspaceDirectory[] { + const result = new Map(); + for (const item of items) { + const parts = parentOf(ownedScriptPath(item)).split("/").filter(Boolean); + let parentPath = ""; + for (const name of parts) { + const path = parentPath ? `${parentPath}/${name}` : name; + result.set(path, { path, name, parent_path: parentPath }); + parentPath = path; + } + } + return [...result.values()]; +} + +function mergeDirectories( + left: WorkspaceDirectory[], + right: WorkspaceDirectory[], +): WorkspaceDirectory[] { + return [...new Map( + [...left, ...right].map((item) => [item.path, item]), + ).values()]; +} + +function confineJupyterFrame(frame: HTMLIFrameElement): void { + try { + const document = frame.contentDocument; + if (!document?.documentElement) return; + const keepInside = (): void => { + document.querySelectorAll("button,[role='button']").forEach( + (element) => { + const label = `${element.getAttribute("aria-label") ?? ""} ${ + element.getAttribute("title") ?? "" + } ${element.textContent ?? ""}`.trim(); + if (/^open in\b/i.test(label) || /\bopen in\.\.\./i.test(label)) { + element.style.setProperty("display", "none", "important"); + } + }, + ); + document.querySelectorAll("a[target]").forEach((link) => { + if (["_blank", "_top", "_parent"].includes(link.target)) { + link.target = "_self"; + } + }); + }; + keepInside(); + new MutationObserver(keepInside).observe(document.documentElement, { + childList: true, + subtree: true, + }); + document.addEventListener("click", (event) => { + const target = event.target as HTMLElement | null; + const link = target?.closest?.("a") as HTMLAnchorElement | null; + if (link && ["_blank", "_top", "_parent"].includes(link.target)) { + link.target = "_self"; + } + }, true); + } catch { + // The iframe remains sandboxed even if its document is not yet accessible. + } +} + +export default function ModelPlatformApp() { + const location = useLocation(); + const navigate = useNavigate(); + const activePage = pageFromPath(location.pathname); + const [scripts, setScripts] = useState([]); + const [directories, setDirectories] = useState([]); + const [selectedId, setSelectedId] = useState(null); + const [keyword, setKeyword] = useState(""); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [apiOnline, setApiOnline] = useState(false); + const [createOpen, setCreateOpen] = useState(false); + const [creating, setCreating] = useState(false); + const [form, setForm] = useState(initialForm); + const [folderDialog, setFolderDialog] = useState<{ + open: boolean; + parentPath: string; + name: string; + busy: boolean; + }>({ open: false, parentPath: "", name: "", busy: false }); + const [contextMenu, setContextMenu] = useState(null); + const [workspaceMenuOpen, setWorkspaceMenuOpen] = useState(false); + const [userMenuOpen, setUserMenuOpen] = useState(false); + const [uploadParentPath, setUploadParentPath] = useState(""); + const [uploading, setUploading] = useState(false); + const uploadInputRef = useRef(null); + const [toast, setToast] = useState(null); + const [editSession, setEditSession] = useState(null); + const editSessionRef = useRef(null); + const selectedIdRef = useRef(null); + const editorOpenRequestRef = useRef(0); + const editorOpeningRef = useRef(false); + const [embeddedJupyterUrl, setEmbeddedJupyterUrl] = + useState(null); + const [editBusy, setEditBusy] = useState(false); + const [editorOpenError, setEditorOpenError] = useState<{ + scriptId: string; + message: string; + } | null>(null); + const [versions, setVersions] = useState([]); + const [versionsLoading, setVersionsLoading] = useState(false); + const [publishTarget, setPublishTarget] = useState(null); + const [releaseNote, setReleaseNote] = useState(""); + const [publishVisibility, setPublishVisibility] = + useState("workspace"); + const [publishing, setPublishing] = useState(false); + const [publishedVersion, setPublishedVersion] = + useState(null); + + const load = async (silent = false) => { + if (!silent) setLoading(true); + setRefreshing(silent); + try { + const [items, folderItems] = await Promise.all([ + listScripts(), + listWorkspaceDirectories(), + ]); + setScripts(items); + setDirectories(folderItems); + setApiOnline(true); + setSelectedId((current) => { + if (current && items.some((item) => item.script_id === current)) { + selectedIdRef.current = current; + return current; + } + const nextSelectedId = ( + items.find((item) => item.script_type === "notebook")?.script_id + ?? items[0]?.script_id + ?? null + ); + selectedIdRef.current = nextSelectedId; + return nextSelectedId; + }); + } catch (error) { + setApiOnline(false); + setToast({ + tone: "error", + message: error instanceof Error ? error.message : "脚本列表加载失败", + }); + } finally { + setLoading(false); + setRefreshing(false); + } + }; + + useEffect(() => { + void load(); + }, []); + + useEffect(() => { + if (!toast) return; + const timer = window.setTimeout(() => setToast(null), 3200); + return () => window.clearTimeout(timer); + }, [toast]); + + useEffect(() => { + if (!contextMenu) return; + const close = () => setContextMenu(null); + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key === "Escape") close(); + }; + window.addEventListener("pointerdown", close); + window.addEventListener("blur", close); + window.addEventListener("resize", close); + window.addEventListener("scroll", close, true); + window.addEventListener("keydown", closeOnEscape); + return () => { + window.removeEventListener("pointerdown", close); + window.removeEventListener("blur", close); + window.removeEventListener("resize", close); + window.removeEventListener("scroll", close, true); + window.removeEventListener("keydown", closeOnEscape); + }; + }, [contextMenu]); + + useEffect(() => { + editSessionRef.current = editSession; + }, [editSession]); + + useEffect(() => { + if (!selectedId) { + setVersions([]); + return; + } + let ignore = false; + setVersionsLoading(true); + void listScriptVersions(selectedId) + .then((items) => { + if (!ignore) setVersions(items); + }) + .catch((error) => { + if (!ignore) { + setToast({ + tone: "error", + message: error instanceof Error ? error.message : "版本列表加载失败", + }); + } + }) + .finally(() => { + if (!ignore) setVersionsLoading(false); + }); + return () => { + ignore = true; + }; + }, [selectedId]); + + useEffect(() => { + if (!editSession) return; + const intervalSeconds = Math.max( + 5, + editSession.heartbeat_interval_seconds || 15, + ); + let heartbeatRunning = false; + const timer = window.setInterval(() => { + if (heartbeatRunning) return; + const current = editSessionRef.current; + if (!current || current.edit_session_id !== editSession.edit_session_id) { + return; + } + heartbeatRunning = true; + void heartbeatFileLock(current) + .then((updated) => { + setEditSession((active) => active + && active.edit_session_id === updated.edit_session_id + ? { + ...active, + session_status: updated.session_status, + expires_at: updated.expires_at, + } + : active); + }) + .catch((error) => { + setEditSession(null); + setEmbeddedJupyterUrl(null); + setToast({ + tone: "error", + message: `编辑锁心跳已中断:${ + error instanceof Error ? error.message : "请重新打开文件" + }`, + }); + }) + .finally(() => { + heartbeatRunning = false; + }); + }, intervalSeconds * 1000); + return () => window.clearInterval(timer); + }, [editSession?.edit_session_id, editSession?.heartbeat_interval_seconds]); + + useEffect(() => { + if (!editSession?.ticket_expires_at) return; + const expiresAt = new Date(editSession.ticket_expires_at).getTime(); + const renewAfter = Math.max(15_000, expiresAt - Date.now() - 60_000); + const timer = window.setTimeout(() => { + const current = editSessionRef.current; + if (!current || current.edit_session_id !== editSession.edit_session_id) { + return; + } + void createJupyterAccessTicket(current) + .then((ticket) => { + setEditSession((active) => active + && active.edit_session_id === ticket.edit_session_id + ? { ...active, ticket_expires_at: ticket.expires_at } + : active); + }) + .catch((error) => { + setToast({ + tone: "error", + message: `Jupyter 访问票据续签失败:${ + error instanceof Error ? error.message : "请重新打开文件" + }`, + }); + }); + }, renewAfter); + return () => window.clearTimeout(timer); + }, [editSession?.edit_session_id, editSession?.ticket_expires_at]); + + useEffect(() => { + if (!editSession) return; + const handleUnload = () => { + const current = editSessionRef.current; + if (current) releaseFileLockOnUnload(current); + }; + window.addEventListener("beforeunload", handleUnload); + return () => window.removeEventListener("beforeunload", handleUnload); + }, [editSession?.edit_session_id]); + + const filteredScripts = useMemo(() => { + const normalized = keyword.trim().toLocaleLowerCase(); + if (!normalized) return scripts; + return scripts.filter((item) => + item.script_name.toLocaleLowerCase().includes(normalized), + ); + }, [keyword, scripts]); + + const memberScriptGroups = [...demoUsers] + .sort((left, right) => ( + Number(right.userId === demoContext.userId) + - Number(left.userId === demoContext.userId) + )) + .map((user) => { + const memberScripts = filteredScripts.filter( + (item) => item.owner_user_id === user.userId, + ); + const inferred = inferredDirectories(memberScripts); + return { + user, + scripts: memberScripts, + directories: user.userId === demoContext.userId + ? mergeDirectories(directories, inferred) + : inferred, + }; + }); + const selected = scripts.find((item) => item.script_id === selectedId) ?? null; + + const selectScript = (scriptId: string | null) => { + if (selectedIdRef.current !== scriptId) { + editorOpenRequestRef.current += 1; + setEditorOpenError(null); + } + selectedIdRef.current = scriptId; + setSelectedId(scriptId); + }; + + const openScriptEditor = async ( + script: ScriptItem, + showToast = true, + ) => { + if (editorOpeningRef.current) return; + editorOpeningRef.current = true; + const requestId = editorOpenRequestRef.current + 1; + editorOpenRequestRef.current = requestId; + const requestIsCurrent = () => + editorOpenRequestRef.current === requestId + && selectedIdRef.current === script.script_id; + const clearSessionIfActive = (session: ActiveEditSession) => { + if ( + editSessionRef.current?.edit_session_id === session.edit_session_id + ) { + setEmbeddedJupyterUrl(null); + setEditSession(null); + editSessionRef.current = null; + } + }; + + setEditBusy(true); + setEditorOpenError((current) => + current?.scriptId === script.script_id ? null : current); + let active = editSessionRef.current; + let newlyAcquired = false; + try { + if (active && active.script_id !== script.script_id) { + await releaseFileLock(active); + setEmbeddedJupyterUrl(null); + setEditSession(null); + editSessionRef.current = null; + active = null; + } + if (!requestIsCurrent()) return; + + if (!active) { + active = await acquireFileLock(script); + newlyAcquired = true; + } + if (!requestIsCurrent()) { + if (active) { + await releaseFileLock(active); + clearSessionIfActive(active); + } + return; + } + + const ticket = await createJupyterAccessTicket(active); + if (!requestIsCurrent()) { + await releaseFileLock(active); + clearSessionIfActive(active); + return; + } + + const readySession = { + ...active, + ticket_expires_at: ticket.expires_at, + }; + setEditSession(readySession); + editSessionRef.current = readySession; + setEmbeddedJupyterUrl(ticket.jupyter_url); + if (showToast) { + setToast({ + tone: "success", + message: `${script.script_name} 已打开,Jupyter 已嵌入当前工作区`, + }); + } + } catch (error) { + if (newlyAcquired && active) { + try { + await releaseFileLock(active); + } catch { + // The database lease is the final safety net if compensation cannot reach Runtime. + } + setEditSession(null); + editSessionRef.current = null; + } + setEmbeddedJupyterUrl(null); + if (requestIsCurrent()) { + const message = error instanceof Error ? error.message : "打开编辑器失败"; + setEditorOpenError({ scriptId: script.script_id, message }); + if (showToast) { + setToast({ tone: "error", message }); + } + } + } finally { + editorOpeningRef.current = false; + setEditBusy(false); + } + }; + + useEffect(() => { + if ( + activePage !== "scripts" + || !selected + || selected.script_type !== "notebook" + || editBusy + || editorOpenError?.scriptId === selected.script_id + || ( + editSession?.script_id === selected.script_id + && embeddedJupyterUrl + ) + ) { + return; + } + + void openScriptEditor(selected, false); + }, [ + activePage, + editBusy, + editSession?.script_id, + editorOpenError?.scriptId, + embeddedJupyterUrl, + selected?.script_id, + selected?.script_type, + ]); + + const endEditing = async (closeTab = false, showToast = true) => { + editorOpenRequestRef.current += 1; + setEditorOpenError(null); + const active = editSessionRef.current; + if (!active) { + if (closeTab) selectScript(null); + return; + } + setEditBusy(true); + try { + await releaseFileLock(active); + setEmbeddedJupyterUrl(null); + setEditSession(null); + editSessionRef.current = null; + if (closeTab) selectScript(null); + if (showToast) { + setToast({ + tone: "success", + message: `${active.script_name} 的编辑锁已释放`, + }); + } + } catch (error) { + setToast({ + tone: "error", + message: error instanceof Error ? error.message : "释放编辑锁失败", + }); + } finally { + setEditBusy(false); + } + }; + + useEffect(() => { + const active = editSessionRef.current; + if ( + !active + || !selected + || selected.script_type === "notebook" + || selected.script_id === active.script_id + || editBusy + ) { + return; + } + void endEditing(false, false); + }, [editBusy, selected?.script_id, selected?.script_type]); + + useEffect(() => { + if ( + activePage === "scripts" + || editBusy + || (!editSessionRef.current && !editorOpeningRef.current) + ) { + return; + } + void endEditing(true, false); + }, [activePage, editBusy]); + + const openPublishDialog = (script: ScriptItem) => { + setPublishTarget(script); + setReleaseNote(""); + setPublishVisibility( + script.visibility === "private" ? "private" : "workspace", + ); + }; + + const submitPublish = async (event: FormEvent) => { + event.preventDefault(); + if (!publishTarget) return; + setPublishing(true); + try { + const version = await publishScriptVersion({ + script: publishTarget, + releaseNote, + visibility: publishVisibility, + }); + setVersions((items) => [ + version, + ...items.filter((item) => item.versions_id !== version.versions_id), + ]); + setPublishTarget(null); + setPublishedVersion(version); + setToast({ + tone: "success", + message: `${version.version_label} 稳定版本发布成功`, + }); + } catch (error) { + setToast({ + tone: "error", + message: error instanceof Error ? error.message : "稳定版本发布失败", + }); + } finally { + setPublishing(false); + } + }; + + const submitCreate = async (event: FormEvent) => { + event.preventDefault(); + if (!form.name.trim()) return; + setCreating(true); + try { + const created = await createScript(form); + setScripts((items) => [created, ...items]); + selectScript(created.script_id); + setCreateOpen(false); + setForm(initialForm); + setToast({ + tone: "success", + message: `${created.script_name} 已创建`, + }); + } catch (error) { + setToast({ + tone: "error", + message: error instanceof Error ? error.message : "创建失败", + }); + } finally { + setCreating(false); + } + }; + + const openCreateDialog = ( + parentPath = "", + scriptType: ScriptType = "notebook", + ) => { + setContextMenu(null); + setForm({ + ...initialForm, + parentPath, + scriptType, + }); + setCreateOpen(true); + }; + + const openFolderDialog = (parentPath = "") => { + setContextMenu(null); + setFolderDialog({ + open: true, + parentPath, + name: "", + busy: false, + }); + }; + + const chooseUpload = (parentPath = "") => { + setContextMenu(null); + setUploadParentPath(parentPath); + uploadInputRef.current?.click(); + }; + + const handleUpload = async (event: ChangeEvent) => { + const files = Array.from(event.target.files ?? []); + event.target.value = ""; + if (files.length === 0) return; + setUploading(true); + let lastCreated: ScriptItem | null = null; + try { + for (const file of files) { + lastCreated = await uploadScript(file, uploadParentPath); + } + await load(true); + if (lastCreated) selectScript(lastCreated.script_id); + setToast({ + tone: "success", + message: `${files.length} 个文件已上传到${ + uploadParentPath ? ` ${uploadParentPath}` : "当前目录" + }`, + }); + } catch (error) { + await load(true); + setToast({ + tone: "error", + message: error instanceof Error ? error.message : "文件上传失败", + }); + } finally { + setUploading(false); + } + }; + + const submitFolder = async (event: FormEvent) => { + event.preventDefault(); + if (!folderDialog.name.trim()) return; + setFolderDialog((current) => ({ ...current, busy: true })); + try { + await createWorkspaceDirectory( + folderDialog.name.trim(), + folderDialog.parentPath, + ); + await load(true); + setFolderDialog({ + open: false, + parentPath: "", + name: "", + busy: false, + }); + setToast({ + tone: "success", + message: `${folderDialog.name.trim()} 文件夹已创建`, + }); + } catch (error) { + setFolderDialog((current) => ({ ...current, busy: false })); + setToast({ + tone: "error", + message: error instanceof Error ? error.message : "文件夹创建失败", + }); + } + }; + + const removeScript = async (script: ScriptItem) => { + setContextMenu(null); + if (!window.confirm(`确定删除文件“${script.script_name}”吗?稳定版本会保留。`)) { + return; + } + if (editSessionRef.current?.script_id === script.script_id) { + await endEditing(false, false); + if (editSessionRef.current?.script_id === script.script_id) return; + } + try { + await deleteScript(script.script_id); + if (selectedIdRef.current === script.script_id) selectScript(null); + await load(true); + setToast({ + tone: "success", + message: `${script.script_name} 已删除`, + }); + } catch (error) { + setToast({ + tone: "error", + message: error instanceof Error ? error.message : "文件删除失败", + }); + } + }; + + const removeDirectory = async (path: string) => { + setContextMenu(null); + if (!window.confirm(`确定递归删除文件夹“${path}”及其内容吗?稳定版本会保留。`)) { + return; + } + const activeScript = scripts.find( + (item) => item.script_id === editSessionRef.current?.script_id, + ); + if ( + activeScript + && ( + ownedScriptPath(activeScript) === path + || ownedScriptPath(activeScript).startsWith(`${path}/`) + ) + ) { + await endEditing(false, false); + if (editSessionRef.current?.script_id === activeScript.script_id) return; + } + try { + const result = await deleteWorkspaceDirectory(path); + const selectedScript = scripts.find( + (item) => item.script_id === selectedIdRef.current, + ); + if ( + selectedScript + && ownedScriptPath(selectedScript).startsWith(`${path}/`) + ) { + selectScript(null); + } + await load(true); + setToast({ + tone: "success", + message: `${path} 已删除(含 ${result.deleted_scripts} 个脚本)`, + }); + } catch (error) { + setToast({ + tone: "error", + message: error instanceof Error ? error.message : "文件夹删除失败", + }); + } + }; + + const showContextMenu = ( + event: ReactMouseEvent, + target: Omit, + ) => { + event.preventDefault(); + event.stopPropagation(); + const width = 188; + const height = target.kind === "file" ? 92 : 190; + setContextMenu({ + ...target, + x: Math.min(event.clientX, window.innerWidth - width - 8), + y: Math.min(event.clientY, window.innerHeight - height - 8), + }); + }; + + return ( +
+ + +
+
+
+ +
+ 开发工作区 +

{{ + home: "工作台", + scripts: "构建脚本", + schedules: "调度配置", + system: "系统管理", + }[activePage]}

+
+
+
+
+ + {apiOnline ? "服务已连接" : "服务未连接"} +
+
+ + {workspaceMenuOpen && ( +
+ {demoWorkspaces.map((workspace) => ( + + ))} +
+ )} +
+
+ + {userMenuOpen && ( +
+ {demoUsers.map((user) => ( + + ))} +
+ )} +
+
+
+ + {activePage === "scripts" ? ( +
+ + +
+ {selected ? ( + void openScriptEditor(selected)} + onEndEditing={() => void endEditing()} + onClose={() => { + if ( + editSessionRef.current?.script_id === selected.script_id + ) { + void endEditing(true); + } else { + selectScript(null); + } + }} + onPublish={() => openPublishDialog(selected)} + onInfo={setToast} + /> + ) : ( +
+
+ +
+ 构建脚本工作台 +

创建你的第一个模型脚本

+

+ 通过 Notebook 完成数据探索,或使用 Python + 脚本构建可调度的处理任务。 +

+ +
+ )} +
+
+ ) : activePage === "schedules" ? ( + + ) : activePage === "system" ? ( + + ) : ( + { + navigate(pathForPage(page)); + }} + /> + )} +
+ + {createOpen && ( +
+
+
+
+ 工作副本 +

新建构建脚本

+
+ +
+
+
+ + 保存到:{form.parentPath || "个人根目录"} +
+ + +
+ 脚本类型 + + +
+ + + +
+ + +
+
+
+
+ )} + + {folderDialog.open && ( +
+
+
+
+ WORKSPACE +

新建文件夹

+
+ +
+
+
+ + 创建到:{folderDialog.parentPath || "个人根目录"} +
+ +
+ + +
+
+
+
+ )} + + {contextMenu && ( +
event.stopPropagation()} + > + {contextMenu.kind === "file" && contextMenu.script ? ( + <> + + + + ) : ( + <> + + + + + {contextMenu.kind === "directory" && ( + <> + + + + )} + + )} +
+ )} + + {publishTarget && ( +
+
+
+
+ 不可变制品 +

发布稳定版本

+
+ +
+
+
+ + + + + {publishTarget.script_name} + 当前 Workspace 工作副本 + +
+