Merge branch 'refactor/remove-redis' into develop
# Conflicts: # .env.example # CLAUDE.md # backend/Dockerfile # backend/pyproject.toml # backend/src/backend/main.py # backend/src/backend/schedule_runs.py # common/src/common/db/models.py # common/src/common/eventing.py # contracts/README.md # contracts/demo-core-v1.md # contracts/events/README.md # contracts/events/event-envelope-v1.json # contracts/locks/README.md # contracts/locks/file-edit-lock-v1.md # contracts/schedules/schedule-definition-api-v1.md # docker-compose.yml # frontend/README.md # migrations/README.md # migrations/versions/20260724_0001_v1_schema_baseline.py # runtime/Dockerfile # runtime/README.md # runtime/pyproject.toml # runtime/src/runtime/main.py # schedule/Dockerfile # schedule/README.md # schedule/pyproject.toml # schedule/src/schedule/main.py # schedule/src/schedule/service.py
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
+1
-6
@@ -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
|
||||
|
||||
@@ -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 分层。
|
||||
@@ -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/<workspace_id>/`. 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.
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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 中的启动命令。
|
||||
+3
-4
@@ -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"]
|
||||
|
||||
@@ -13,7 +13,7 @@ dependencies = [
|
||||
]
|
||||
|
||||
[tool.uv.sources]
|
||||
common = { workspace = true }
|
||||
common = { path = "../common" }
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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)'))
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
|
||||
```text
|
||||
openapi/ HTTP OpenAPI 3 契约
|
||||
events/ Redis Streams JSON Schema
|
||||
events/ MySQL Outbox 内部事件 JSON Schema
|
||||
runtime/ Runtime Adapter 契约
|
||||
locks/ Notebook 编辑锁契约
|
||||
```
|
||||
|
||||
@@ -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. 模块所有权
|
||||
|
||||
|
||||
@@ -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` 提供幂等保护。字段和状态值不得在生产者或消费者中另行定义。
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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 租约是实时锁权威。
|
||||
|
||||
@@ -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 秒。
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
- 五段 Cron 校验与未来时间预览;
|
||||
- DAG 完整性和有向无环校验。
|
||||
|
||||
立即运行、Cron 自动触发、Redis Streams 事件和 Worker 执行属于后续小步。
|
||||
立即运行、Cron 自动触发和 Executor 执行已经由独立 Schedule 服务承接。
|
||||
|
||||
## 2. 请求上下文
|
||||
|
||||
|
||||
+67
@@ -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
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
"""模型实验开发平台构建脚本。"""
|
||||
|
||||
|
||||
def main() -> None:
|
||||
print("Hello, Model Platform!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
"""模型实验开发平台构建脚本。"""
|
||||
|
||||
|
||||
def main() -> None:
|
||||
print("Hello, Model Platform!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+71
-49
@@ -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:
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
.react-router
|
||||
build
|
||||
node_modules
|
||||
README.md
|
||||
@@ -0,0 +1,7 @@
|
||||
.DS_Store
|
||||
.env
|
||||
/node_modules/
|
||||
|
||||
# React Router
|
||||
/.react-router/
|
||||
/build/
|
||||
+22
-1
@@ -1 +1,22 @@
|
||||
前端
|
||||
# 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 静态目录。
|
||||
|
||||
@@ -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; }
|
||||
@@ -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 (
|
||||
<svg {...common} viewBox="0 0 32 32">
|
||||
<path
|
||||
d="M16 3.5 27 9.8v12.4L16 28.5 5 22.2V9.8L16 3.5Z"
|
||||
fill="currentColor"
|
||||
opacity=".2"
|
||||
/>
|
||||
<path
|
||||
d="M16 7.7 23.4 12v8L16 24.3 8.6 20v-8L16 7.7Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<circle cx="16" cy="16" r="3.3" fill="white" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const paths: Record<Exclude<IconName, "brand">, React.ReactNode> = {
|
||||
home: (
|
||||
<>
|
||||
<path d="m3.5 11 8.5-7 8.5 7" />
|
||||
<path d="M5.5 10v10h13V10M9.5 20v-6h5v6" />
|
||||
</>
|
||||
),
|
||||
script: (
|
||||
<>
|
||||
<rect x="5" y="3.5" width="14" height="17" rx="2" />
|
||||
<path d="M8.5 8h7M8.5 12h7M8.5 16h4.5" />
|
||||
</>
|
||||
),
|
||||
schedule: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="8.5" />
|
||||
<path d="M12 7.5V12l3.2 2" />
|
||||
</>
|
||||
),
|
||||
experiment: (
|
||||
<>
|
||||
<path d="M9 3.5h6M10 3.5v5l-5.2 9a2 2 0 0 0 1.7 3h11a2 2 0 0 0 1.7-3L14 8.5v-5" />
|
||||
<path d="M7.3 15h9.4" />
|
||||
</>
|
||||
),
|
||||
database: (
|
||||
<>
|
||||
<ellipse cx="12" cy="5.5" rx="7.5" ry="3" />
|
||||
<path d="M4.5 5.5v6c0 1.7 3.4 3 7.5 3s7.5-1.3 7.5-3v-6" />
|
||||
<path d="M4.5 11.5v6c0 1.7 3.4 3 7.5 3s7.5-1.3 7.5-3v-6" />
|
||||
</>
|
||||
),
|
||||
settings: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M19.4 15a1.7 1.7 0 0 0 .3 1.9l.1.1-2.8 2.8-.1-.1a1.7 1.7 0 0 0-1.9-.3 1.7 1.7 0 0 0-1 1.6v.2h-4V21a1.7 1.7 0 0 0-1-1.6 1.7 1.7 0 0 0-1.9.3l-.1.1L4.2 17l.1-.1a1.7 1.7 0 0 0 .3-1.9A1.7 1.7 0 0 0 3 14H2.8v-4H3a1.7 1.7 0 0 0 1.6-1 1.7 1.7 0 0 0-.3-1.9L4.2 7 7 4.2l.1.1a1.7 1.7 0 0 0 1.9.3A1.7 1.7 0 0 0 10 3v-.2h4V3a1.7 1.7 0 0 0 1 1.6 1.7 1.7 0 0 0 1.9-.3l.1-.1L19.8 7l-.1.1a1.7 1.7 0 0 0-.3 1.9 1.7 1.7 0 0 0 1.6 1h.2v4H21a1.7 1.7 0 0 0-1.6 1Z" />
|
||||
</>
|
||||
),
|
||||
chevron: <path d="m9 7 5 5-5 5" />,
|
||||
search: (
|
||||
<>
|
||||
<circle cx="10.5" cy="10.5" r="6.5" />
|
||||
<path d="m15.5 15.5 4 4" />
|
||||
</>
|
||||
),
|
||||
plus: <path d="M12 5v14M5 12h14" />,
|
||||
upload: (
|
||||
<>
|
||||
<path d="M12 16V4m0 0L7.5 8.5M12 4l4.5 4.5" />
|
||||
<path d="M4 15.5V20h16v-4.5" />
|
||||
</>
|
||||
),
|
||||
folder: (
|
||||
<path d="M3.5 6.5h6l2 2H20.5v10.5a1.5 1.5 0 0 1-1.5 1.5H5a1.5 1.5 0 0 1-1.5-1.5V6.5Z" />
|
||||
),
|
||||
notebook: (
|
||||
<>
|
||||
<rect x="5" y="3.5" width="14" height="17" rx="2" />
|
||||
<path d="M8.5 3.5v17M11.5 8h4M11.5 12h4M11.5 16h2.5" />
|
||||
</>
|
||||
),
|
||||
python: (
|
||||
<>
|
||||
<path d="M8 4.5h5.5a2.5 2.5 0 0 1 2.5 2.5v3H8a3 3 0 0 0-3 3v2" />
|
||||
<path d="M16 19.5h-5.5A2.5 2.5 0 0 1 8 17v-3h8a3 3 0 0 0 3-3V9" />
|
||||
<circle cx="11" cy="7" r=".8" fill="currentColor" stroke="none" />
|
||||
<circle cx="13" cy="17" r=".8" fill="currentColor" stroke="none" />
|
||||
</>
|
||||
),
|
||||
refresh: (
|
||||
<>
|
||||
<path d="M19 8V4l-1.7 1.7A8 8 0 1 0 20 12" />
|
||||
<path d="M19 4h-4" />
|
||||
</>
|
||||
),
|
||||
close: <path d="m6 6 12 12M18 6 6 18" />,
|
||||
check: <path d="m5 12.5 4 4L19 7" />,
|
||||
info: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M12 11v5M12 8h.01" />
|
||||
</>
|
||||
),
|
||||
workspace: (
|
||||
<>
|
||||
<rect x="3.5" y="5" width="17" height="14" rx="2" />
|
||||
<path d="M8 5V3.5h8V5M8 11h8M12 8v6" />
|
||||
</>
|
||||
),
|
||||
menu: (
|
||||
<>
|
||||
<path d="M4 6h16M4 12h16M4 18h16" />
|
||||
</>
|
||||
),
|
||||
external: (
|
||||
<>
|
||||
<path d="M14 5h5v5M19 5l-8 8" />
|
||||
<path d="M18 13v6H5V6h6" />
|
||||
</>
|
||||
),
|
||||
release: (
|
||||
<>
|
||||
<path d="M12 3.5v11M7.5 8 12 3.5 16.5 8" />
|
||||
<path d="M5 12.5v7h14v-7" />
|
||||
</>
|
||||
),
|
||||
play: <path d="m8 5 11 7-11 7V5Z" />,
|
||||
};
|
||||
|
||||
return (
|
||||
<svg
|
||||
{...common}
|
||||
stroke="currentColor"
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
{paths[name]}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<section className="dashboard-page">
|
||||
<div className="dashboard-hero">
|
||||
<div>
|
||||
<span>MODEL DEVELOPMENT PLATFORM</span>
|
||||
<h2>下午好,{demoContext.userName}</h2>
|
||||
<p>当前位于 {demoContext.workspaceName},可以继续构建脚本或配置调度。</p>
|
||||
</div>
|
||||
<span className="dashboard-hero__badge">{online ? "服务正常" : "服务连接中"}</span>
|
||||
</div>
|
||||
<div className="dashboard-metrics">
|
||||
<article><Icon name="script" /><span><b>{scriptCount}</b><small>工作副本</small></span></article>
|
||||
<article><Icon name="workspace" /><span><b>2</b><small>Workspace</small></span></article>
|
||||
<article><Icon name="settings" /><span><b>4</b><small>平台员工</small></span></article>
|
||||
<article><Icon name="check" /><span><b>{online ? "正常" : "检查中"}</b><small>平台状态</small></span></article>
|
||||
</div>
|
||||
<div className="dashboard-actions">
|
||||
<button type="button" onClick={() => onNavigate("scripts")}><Icon name="script" />构建脚本</button>
|
||||
<button type="button" onClick={() => onNavigate("schedules")}><Icon name="schedule" />调度配置</button>
|
||||
<button type="button" onClick={() => onNavigate("system")}><Icon name="settings" />员工管理</button>
|
||||
</div>
|
||||
<div className="dashboard-grid">
|
||||
<section className="dashboard-panel dashboard-panel--trend">
|
||||
<header><div><span>运行趋势</span><h3>近 7 天调度执行</h3></div><b>成功率 92.6%</b></header>
|
||||
<div className="trend-chart">
|
||||
{[38, 55, 44, 73, 61, 86, 78].map((value, index) => (
|
||||
<div className="trend-column" key={index}>
|
||||
<span className="trend-column__value">{Math.round(value / 7)}</span>
|
||||
<i style={{ height: `${value}%` }} />
|
||||
<small>{["周一", "周二", "周三", "周四", "周五", "周六", "今天"][index]}</small>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<footer><span><i className="legend-dot is-success" />成功 75</span><span><i className="legend-dot is-failed" />失败 6</span></footer>
|
||||
</section>
|
||||
<section className="dashboard-panel dashboard-panel--donut">
|
||||
<header><div><span>脚本资产</span><h3>类型分布</h3></div></header>
|
||||
<div className="donut-layout">
|
||||
<div className="donut-chart"><span><b>{scriptCount}</b><small>全部脚本</small></span></div>
|
||||
<div className="donut-legend">
|
||||
<span><i className="legend-dot is-notebook" /><b>Notebook</b><small>{Math.max(1, Math.round(scriptCount * .67))} 个 · 67%</small></span>
|
||||
<span><i className="legend-dot is-python" /><b>Python</b><small>{Math.max(0, scriptCount - Math.round(scriptCount * .67))} 个 · 33%</small></span>
|
||||
<span><i className="legend-dot is-version" /><b>稳定版本</b><small>3 个已发布</small></span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section className="dashboard-panel dashboard-panel--activity">
|
||||
<header><div><span>ACTIVITY</span><h3>最近平台活动</h3></div><button type="button">查看全部</button></header>
|
||||
<div className="activity-table">
|
||||
<div className="activity-table__head"><span>操作内容</span><span>执行人</span><span>状态</span><span>时间</span></div>
|
||||
{[
|
||||
["数据探索.ipynb 发布稳定版本 v3.0", "张三", "成功", "16:42"],
|
||||
["每日模型训练流程完成调度运行", "Scheduler", "成功", "15:25"],
|
||||
["批量预测.py 更新工作副本", "王五", "已同步", "14:18"],
|
||||
["风险验证流程完成 DAG 校验", "李四", "成功", "11:06"],
|
||||
].map((row) => (
|
||||
<div className="activity-row" key={row[0]}>
|
||||
<span><i className="activity-icon"><Icon name="check" size={13} /></i>{row[0]}</span>
|
||||
<span>{row[1]}</span><span><b>{row[2]}</b></span><span>{row[3]}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
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<Employee[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [editing, setEditing] = useState<Employee | null>(null);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [form, setForm] = useState(EMPTY_FORM);
|
||||
const canManage = demoContext.roleCode === "admin";
|
||||
|
||||
const load = async (): Promise<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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 (
|
||||
<section className="admin-page">
|
||||
<header className="admin-page__header">
|
||||
<div><span>系统管理</span><h2>员工管理</h2><p>{demoContext.workspaceName} · {employees.length} 名员工</p></div>
|
||||
<button className="primary-button" type="button" disabled={!canManage} onClick={openCreate}>
|
||||
<Icon name="plus" size={15} />添加员工
|
||||
</button>
|
||||
</header>
|
||||
{!canManage && <div className="admin-readonly">当前为开发人员,只能查看员工列表。</div>}
|
||||
<div className="employee-table">
|
||||
<div className="employee-table__head"><span>员工</span><span>账号</span><span>角色</span><span>状态</span><span>操作</span></div>
|
||||
{loading ? <p className="admin-empty">正在加载员工…</p> : employees.map((employee) => {
|
||||
const isProtectedAdmin = employee.role_code === "admin";
|
||||
return (
|
||||
<div className="employee-row" key={employee.user_id}>
|
||||
<span className="employee-name"><b className="avatar">{employee.display_name.slice(0, 1)}</b><span><strong>{employee.display_name}</strong><small>{employee.email ?? "未设置邮箱"}</small></span></span>
|
||||
<code>{employee.username}</code>
|
||||
<span className={`role-pill is-${employee.role_code}`}>{employee.role_name}</span>
|
||||
<span className={`status-pill is-${employee.status}`}>{employee.status === "active" ? "正常" : employee.status === "disabled" ? "已停用" : "已锁定"}</span>
|
||||
<span className="employee-actions">
|
||||
<button type="button" disabled={!canManage} onClick={() => openEdit(employee)}>编辑</button>
|
||||
<button type="button" className="is-danger" disabled={!canManage || isProtectedAdmin} title={isProtectedAdmin ? "管理员账号不能删除" : "删除"} onClick={() => void remove(employee)}>删除</button>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{dialogOpen && (
|
||||
<div className="modal-backdrop">
|
||||
<section className="modal modal--compact" role="dialog" aria-modal="true">
|
||||
<div className="modal__header"><div><span className="modal__eyebrow">EMPLOYEE</span><h2>{editing ? "编辑员工" : "添加员工"}</h2></div><button className="icon-button" type="button" onClick={() => setDialogOpen(false)}><Icon name="close" /></button></div>
|
||||
<form onSubmit={(event) => void submit(event)}>
|
||||
<label className="form-field"><span>姓名</span><input autoFocus value={form.display_name} onChange={(event) => setForm({ ...form, display_name: event.target.value })} /></label>
|
||||
<label className="form-field"><span>登录账号</span><input disabled={Boolean(editing)} value={form.username} onChange={(event) => setForm({ ...form, username: event.target.value })} /></label>
|
||||
<label className="form-field"><span>邮箱</span><input type="email" value={form.email} onChange={(event) => setForm({ ...form, email: event.target.value })} /></label>
|
||||
<label className="form-field"><span>角色</span><select value={form.role_code} onChange={(event) => setForm({ ...form, role_code: event.target.value as "admin" | "developer" })}><option value="developer">开发人员</option><option value="admin">管理员</option></select></label>
|
||||
{editing && <label className="form-field"><span>状态</span><select value={form.status} onChange={(event) => setForm({ ...form, status: event.target.value as typeof form.status })}><option value="active">正常</option><option value="disabled">停用</option><option value="locked">锁定</option></select></label>}
|
||||
<div className="modal__footer"><button className="secondary-button" type="button" onClick={() => setDialogOpen(false)}>取消</button><button className="primary-button" type="submit" disabled={saving}>{saving ? "保存中…" : "保存"}</button></div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,75 @@
|
||||
import {
|
||||
isRouteErrorResponse,
|
||||
Links,
|
||||
Meta,
|
||||
Outlet,
|
||||
Scripts,
|
||||
ScrollRestoration,
|
||||
} from "react-router";
|
||||
|
||||
import type { Route } from "./+types/root";
|
||||
import "./app.css";
|
||||
|
||||
export const links: Route.LinksFunction = () => [
|
||||
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
|
||||
{
|
||||
rel: "preconnect",
|
||||
href: "https://fonts.gstatic.com",
|
||||
crossOrigin: "anonymous",
|
||||
},
|
||||
{
|
||||
rel: "stylesheet",
|
||||
href: "https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap",
|
||||
},
|
||||
];
|
||||
|
||||
export function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<Meta />
|
||||
<Links />
|
||||
</head>
|
||||
<body>
|
||||
{children}
|
||||
<ScrollRestoration />
|
||||
<Scripts />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
||||
let message = "页面加载失败";
|
||||
let details = "系统发生了未预期的错误。";
|
||||
let stack: string | undefined;
|
||||
|
||||
if (isRouteErrorResponse(error)) {
|
||||
message = error.status === 404 ? "404" : "请求失败";
|
||||
details =
|
||||
error.status === 404
|
||||
? "没有找到请求的页面。"
|
||||
: error.statusText || details;
|
||||
} else if (import.meta.env.DEV && error && error instanceof Error) {
|
||||
details = error.message;
|
||||
stack = error.stack;
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="pt-16 p-4 container mx-auto">
|
||||
<h1>{message}</h1>
|
||||
<p>{details}</p>
|
||||
{stack && (
|
||||
<pre className="w-full p-4 overflow-x-auto">
|
||||
<code>{stack}</code>
|
||||
</pre>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { type RouteConfig, route } from "@react-router/dev/routes";
|
||||
|
||||
export default [route("*", "routes/platform.tsx")] satisfies RouteConfig;
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { Route } from "./+types/platform";
|
||||
import ModelPlatformApp from "../features/platform/ModelPlatformApp";
|
||||
|
||||
export function meta({}: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: "模型实验开发平台" },
|
||||
{ name: "description", content: "模型实验、脚本版本与 DAG 调度平台" },
|
||||
];
|
||||
}
|
||||
|
||||
export default function PlatformRoute() {
|
||||
return <ModelPlatformApp />;
|
||||
}
|
||||
@@ -0,0 +1,853 @@
|
||||
export type DemoUser = {
|
||||
userId: string;
|
||||
userName: string;
|
||||
username: string;
|
||||
roleCode: "admin" | "developer";
|
||||
roleName: string;
|
||||
};
|
||||
|
||||
export type DemoWorkspace = {
|
||||
workspaceId: string;
|
||||
workspaceName: string;
|
||||
};
|
||||
|
||||
export const demoUsers: DemoUser[] = [
|
||||
{ userId: "0000000000RF6FG1SDBXG59S13", userName: "张三", username: "admin-zhang", roleCode: "admin", roleName: "管理员" },
|
||||
{ userId: "0000000000H2QYCGPCWQM1JSGS", userName: "李四", username: "admin-li", roleCode: "admin", roleName: "管理员" },
|
||||
{ userId: "0000000000RWG40ESZPGJT629J", userName: "王五", username: "dev-wang", roleCode: "developer", roleName: "开发人员" },
|
||||
{ userId: "00000000004CQV7WASJA6N6FW4", userName: "赵六", username: "dev-zhao", roleCode: "developer", roleName: "开发人员" },
|
||||
];
|
||||
|
||||
export const demoWorkspaces: DemoWorkspace[] = [
|
||||
{ workspaceId: "00000000000BM630VT9ARVFZPC", workspaceName: "模型开发 Workspace" },
|
||||
{ workspaceId: "0000000000AE0NC0V5T424KK86", workspaceName: "风险验证 Workspace" },
|
||||
];
|
||||
|
||||
function readStoredContext(): Partial<{
|
||||
userId: string;
|
||||
workspaceId: string;
|
||||
}> {
|
||||
if (typeof window === "undefined") return {};
|
||||
try {
|
||||
return JSON.parse(
|
||||
window.localStorage.getItem("model-platform-demo-context") ?? "{}",
|
||||
) as Partial<{ userId: string; workspaceId: string }>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
const storedContext = readStoredContext();
|
||||
const initialUser = demoUsers.find((item) => item.userId === storedContext.userId)
|
||||
?? demoUsers[0];
|
||||
const initialWorkspace = demoWorkspaces.find(
|
||||
(item) => item.workspaceId === storedContext.workspaceId,
|
||||
) ?? demoWorkspaces[0];
|
||||
|
||||
export const demoContext = {
|
||||
...initialUser,
|
||||
...initialWorkspace,
|
||||
};
|
||||
|
||||
export function setDemoContext(input: {
|
||||
user?: DemoUser;
|
||||
workspace?: DemoWorkspace;
|
||||
}): void {
|
||||
if (input.user) Object.assign(demoContext, input.user);
|
||||
if (input.workspace) Object.assign(demoContext, input.workspace);
|
||||
if (typeof window !== "undefined") {
|
||||
window.localStorage.setItem("model-platform-demo-context", JSON.stringify({
|
||||
userId: demoContext.userId,
|
||||
workspaceId: demoContext.workspaceId,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
export type ScriptType = "python" | "notebook";
|
||||
export type Visibility = "private" | "workspace" | "public";
|
||||
|
||||
export type Employee = {
|
||||
user_id: string;
|
||||
username: string;
|
||||
display_name: string;
|
||||
email: string | null;
|
||||
status: "active" | "disabled" | "locked";
|
||||
role_code: "admin" | "developer";
|
||||
role_name: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type ScriptItem = {
|
||||
script_id: string;
|
||||
workspace_id: string;
|
||||
current_object_id: string;
|
||||
owner_user_id: string;
|
||||
script_name: string;
|
||||
script_type: ScriptType;
|
||||
visibility: Visibility;
|
||||
status: string;
|
||||
relative_path: string;
|
||||
content_hash: string;
|
||||
size_bytes: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type WorkspaceDirectory = {
|
||||
path: string;
|
||||
name: string;
|
||||
parent_path: string;
|
||||
};
|
||||
|
||||
type ApiEnvelope<T> = {
|
||||
request_id: string;
|
||||
data: T;
|
||||
meta: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type ApiErrorEnvelope = {
|
||||
detail?: string | {
|
||||
code?: string;
|
||||
message?: string;
|
||||
};
|
||||
error?: {
|
||||
code?: string;
|
||||
message?: string;
|
||||
details?: {
|
||||
editor_name?: string;
|
||||
lease_expires_at?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export class ApiRequestError extends Error {
|
||||
readonly status: number;
|
||||
readonly code?: string;
|
||||
|
||||
constructor(message: string, status: number, code?: string) {
|
||||
super(message);
|
||||
this.name = "ApiRequestError";
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
async function apiRequest<T>(
|
||||
path: string,
|
||||
init: RequestInit = {},
|
||||
): Promise<T> {
|
||||
const response = await fetch(path, {
|
||||
...init,
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
"X-User-ID": demoContext.userId,
|
||||
"X-Workspace-ID": demoContext.workspaceId,
|
||||
"X-Request-ID": crypto.randomUUID().replaceAll("-", ""),
|
||||
...(init.body ? { "Content-Type": "application/json" } : {}),
|
||||
...init.headers,
|
||||
},
|
||||
});
|
||||
|
||||
const payload = (await response.json().catch(() => ({}))) as
|
||||
| ApiEnvelope<T>
|
||||
| ApiErrorEnvelope;
|
||||
if (!response.ok) {
|
||||
const error = payload as ApiErrorEnvelope;
|
||||
const detailMessage = typeof error.detail === "string"
|
||||
? error.detail
|
||||
: error.detail?.message;
|
||||
const editor = error.error?.details?.editor_name;
|
||||
throw new ApiRequestError(
|
||||
(editor ? `${error.error?.message ?? "文件正在编辑"}(${editor})` : undefined)
|
||||
?? error.error?.message
|
||||
?? detailMessage
|
||||
?? `请求失败(HTTP ${response.status})`,
|
||||
response.status,
|
||||
typeof error.detail === "object"
|
||||
? error.detail?.code
|
||||
: error.error?.code,
|
||||
);
|
||||
}
|
||||
return (payload as ApiEnvelope<T>).data;
|
||||
}
|
||||
|
||||
export async function listScripts(): Promise<ScriptItem[]> {
|
||||
return apiRequest<ScriptItem[]>("/api/v1/scripts");
|
||||
}
|
||||
|
||||
function initialContent(scriptType: ScriptType): string {
|
||||
if (scriptType === "python") {
|
||||
return [
|
||||
'"""模型实验开发平台构建脚本。"""',
|
||||
"",
|
||||
"",
|
||||
"def main() -> None:",
|
||||
' print("Hello, Model Platform!")',
|
||||
"",
|
||||
"",
|
||||
'if __name__ == "__main__":',
|
||||
" main()",
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
return JSON.stringify(
|
||||
{
|
||||
cells: [
|
||||
{
|
||||
cell_type: "markdown",
|
||||
metadata: {},
|
||||
source: ["# 新建模型实验\\n", "在这里开始数据探索与模型构建。"],
|
||||
},
|
||||
{
|
||||
cell_type: "code",
|
||||
execution_count: null,
|
||||
metadata: {},
|
||||
outputs: [],
|
||||
source: ["print('Hello, Model Platform!')\\n"],
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
kernelspec: {
|
||||
display_name: "Python 3",
|
||||
language: "python",
|
||||
name: "python3",
|
||||
},
|
||||
language_info: {
|
||||
name: "python",
|
||||
version: "3.12",
|
||||
},
|
||||
},
|
||||
nbformat: 4,
|
||||
nbformat_minor: 5,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
}
|
||||
|
||||
export async function createScript(input: {
|
||||
name: string;
|
||||
scriptType: ScriptType;
|
||||
visibility: Visibility;
|
||||
parentPath?: string | null;
|
||||
}): Promise<ScriptItem> {
|
||||
return apiRequest<ScriptItem>("/api/v1/scripts", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
script_name: input.name.trim(),
|
||||
script_type: input.scriptType,
|
||||
visibility: input.visibility,
|
||||
content: initialContent(input.scriptType),
|
||||
parent_path: input.parentPath,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function uploadScript(
|
||||
file: File,
|
||||
parentPath = "",
|
||||
visibility: Visibility = "workspace",
|
||||
): Promise<ScriptItem> {
|
||||
const parameters = new URLSearchParams({
|
||||
file_name: file.name,
|
||||
parent_path: parentPath,
|
||||
visibility,
|
||||
});
|
||||
return apiRequest<ScriptItem>(
|
||||
`/api/v1/scripts/upload?${parameters.toString()}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/octet-stream" },
|
||||
body: file,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteScript(
|
||||
scriptId: string,
|
||||
): Promise<{ script_id: string; status: string; versions_preserved: boolean }> {
|
||||
return apiRequest(`/api/v1/scripts/${scriptId}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export async function listWorkspaceDirectories(): Promise<
|
||||
WorkspaceDirectory[]
|
||||
> {
|
||||
const data = await apiRequest<{ directories: WorkspaceDirectory[] }>(
|
||||
"/api/v1/workspace-tree",
|
||||
);
|
||||
return data.directories;
|
||||
}
|
||||
|
||||
export async function createWorkspaceDirectory(
|
||||
directoryName: string,
|
||||
parentPath = "",
|
||||
): Promise<WorkspaceDirectory> {
|
||||
return apiRequest<WorkspaceDirectory>("/api/v1/workspace-directories", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
directory_name: directoryName,
|
||||
parent_path: parentPath,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteWorkspaceDirectory(
|
||||
path: string,
|
||||
): Promise<{
|
||||
path: string;
|
||||
status: string;
|
||||
deleted_scripts: number;
|
||||
versions_preserved: boolean;
|
||||
}> {
|
||||
const parameters = new URLSearchParams({ path });
|
||||
return apiRequest(`/api/v1/workspace-directories?${parameters.toString()}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
export type FileLockSession = {
|
||||
edit_session_id: string;
|
||||
workspace_id: string;
|
||||
storage_object_id: string;
|
||||
user_id: string;
|
||||
session_status: "active" | "closed" | "expired";
|
||||
lease_seconds: number;
|
||||
heartbeat_interval_seconds: number;
|
||||
expires_at: string;
|
||||
runtime_id: string;
|
||||
jupyter_session_id: string;
|
||||
jupyter_url?: string;
|
||||
relative_path?: string;
|
||||
lock_token?: string;
|
||||
};
|
||||
|
||||
export type ActiveEditSession = FileLockSession & {
|
||||
script_id: string;
|
||||
script_name: string;
|
||||
lock_token: string;
|
||||
ticket_expires_at?: string;
|
||||
};
|
||||
|
||||
export type JupyterAccessTicket = {
|
||||
edit_session_id: string;
|
||||
jupyter_url: string;
|
||||
expires_at: string;
|
||||
};
|
||||
|
||||
export type StableVersion = {
|
||||
versions_id: string;
|
||||
workspace_id: string;
|
||||
script_id: string;
|
||||
source_object_id: string;
|
||||
artifact_object_id: string;
|
||||
version_no: number;
|
||||
version_label: string;
|
||||
source_path: string;
|
||||
artifact_path: string;
|
||||
content_hash: string;
|
||||
file_size_bytes: number;
|
||||
visibility: Visibility;
|
||||
release_note: string | null;
|
||||
created_by: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export async function acquireFileLock(
|
||||
script: ScriptItem,
|
||||
): Promise<ActiveEditSession> {
|
||||
const session = await apiRequest<FileLockSession>(
|
||||
`/api/v1/files/${script.current_object_id}/lock`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
if (!session.lock_token) {
|
||||
throw new Error("加锁成功响应缺少 lock_token");
|
||||
}
|
||||
return {
|
||||
...session,
|
||||
script_id: script.script_id,
|
||||
script_name: script.script_name,
|
||||
lock_token: session.lock_token,
|
||||
};
|
||||
}
|
||||
|
||||
export async function heartbeatFileLock(
|
||||
session: ActiveEditSession,
|
||||
): Promise<FileLockSession> {
|
||||
return apiRequest<FileLockSession>(
|
||||
`/api/v1/file-locks/${session.edit_session_id}/heartbeat`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ lock_token: session.lock_token }),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function releaseFileLock(
|
||||
session: ActiveEditSession,
|
||||
): Promise<FileLockSession> {
|
||||
return apiRequest<FileLockSession>(
|
||||
`/api/v1/file-locks/${session.edit_session_id}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
body: JSON.stringify({ lock_token: session.lock_token }),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function releaseFileLockOnUnload(session: ActiveEditSession): void {
|
||||
void fetch(`/api/v1/file-locks/${session.edit_session_id}`, {
|
||||
method: "DELETE",
|
||||
credentials: "same-origin",
|
||||
keepalive: true,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-User-ID": demoContext.userId,
|
||||
"X-Workspace-ID": demoContext.workspaceId,
|
||||
"X-Request-ID": crypto.randomUUID().replaceAll("-", ""),
|
||||
},
|
||||
body: JSON.stringify({ lock_token: session.lock_token }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function createJupyterAccessTicket(
|
||||
session: ActiveEditSession,
|
||||
): Promise<JupyterAccessTicket> {
|
||||
return apiRequest<JupyterAccessTicket>("/api/v1/jupyter/access-tickets", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
edit_session_id: session.edit_session_id,
|
||||
lock_token: session.lock_token,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function listScriptVersions(
|
||||
scriptId: string,
|
||||
): Promise<StableVersion[]> {
|
||||
return apiRequest<StableVersion[]>(`/api/v1/scripts/${scriptId}/versions`);
|
||||
}
|
||||
|
||||
export async function publishScriptVersion(input: {
|
||||
script: ScriptItem;
|
||||
releaseNote: string;
|
||||
visibility: Visibility;
|
||||
}): Promise<StableVersion> {
|
||||
return apiRequest<StableVersion>(
|
||||
`/api/v1/scripts/${input.script.script_id}/versions`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
source_object_id: input.script.current_object_id,
|
||||
release_note: input.releaseNote.trim() || null,
|
||||
visibility: input.visibility,
|
||||
}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export type ScheduleArtifact = {
|
||||
versions_id: string;
|
||||
version_label: string;
|
||||
script_id: string;
|
||||
script_name: string;
|
||||
script_type: ScriptType;
|
||||
content_hash: string;
|
||||
file_size_bytes: number;
|
||||
visibility: Visibility;
|
||||
created_by: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type ScheduleNode = {
|
||||
node_id: string;
|
||||
schedule_id: string;
|
||||
node_key: string;
|
||||
node_name: string;
|
||||
versions_id: string;
|
||||
timeout_seconds: number;
|
||||
retry_count: number;
|
||||
retry_interval_sec: number;
|
||||
position_x: number;
|
||||
position_y: number;
|
||||
arguments_json: Record<string, unknown>;
|
||||
env_refs_json: Record<string, string>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
version: {
|
||||
versions_id: string;
|
||||
version_label: string;
|
||||
script_id: string;
|
||||
script_name: string;
|
||||
script_type: ScriptType;
|
||||
content_hash: string;
|
||||
created_at: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type ScheduleEdge = {
|
||||
edge_id: string;
|
||||
schedule_id: string;
|
||||
source_node_id: string;
|
||||
target_node_id: string;
|
||||
condition_expr: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type DagValidation = {
|
||||
valid: boolean;
|
||||
node_count: number;
|
||||
edge_count: number;
|
||||
root_node_ids: string[];
|
||||
leaf_node_ids: string[];
|
||||
topological_order: string[];
|
||||
errors: Array<{
|
||||
code: string;
|
||||
message: string;
|
||||
edge_id?: string;
|
||||
node_ids?: string[];
|
||||
}>;
|
||||
};
|
||||
|
||||
export type Schedule = {
|
||||
schedule_id: string;
|
||||
workspace_id: string;
|
||||
schedule_name: string;
|
||||
description: string | null;
|
||||
trigger_type: "manual" | "cron" | "api";
|
||||
cron_expression: string | null;
|
||||
timezone: string;
|
||||
enabled: boolean;
|
||||
workflow_version: number;
|
||||
max_concurrency: number;
|
||||
failure_policy: "stop" | "continue";
|
||||
last_run_at: string | null;
|
||||
next_run_at: string | null;
|
||||
created_by: string;
|
||||
updated_by: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
node_count: number;
|
||||
edge_count: number;
|
||||
nodes: ScheduleNode[];
|
||||
edges: ScheduleEdge[];
|
||||
dag_validation: DagValidation;
|
||||
};
|
||||
|
||||
export type CronPreview = {
|
||||
cron_expression: string;
|
||||
timezone: string;
|
||||
base_time: string;
|
||||
occurrences: Array<{
|
||||
local_time: string;
|
||||
utc_time: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type ScheduleRunStatus =
|
||||
| "queued"
|
||||
| "running"
|
||||
| "succeeded"
|
||||
| "failed"
|
||||
| "cancelled"
|
||||
| "timed_out";
|
||||
|
||||
export type ScheduleNodeRunStatus =
|
||||
| ScheduleRunStatus
|
||||
| "skipped";
|
||||
|
||||
export type ScheduleRunSummary = {
|
||||
run_id: string;
|
||||
schedule_id: string;
|
||||
workspace_id: string;
|
||||
workflow_version: number;
|
||||
trigger_type: "manual" | "cron" | "api" | "retry";
|
||||
run_status: ScheduleRunStatus;
|
||||
state_version: number;
|
||||
queued_at: string;
|
||||
started_at: string | null;
|
||||
finished_at: string | null;
|
||||
duration_ms: number | null;
|
||||
error_code: string | null;
|
||||
error_message: string | null;
|
||||
logs_object_id: string | null;
|
||||
result_object_id: string | null;
|
||||
};
|
||||
|
||||
export type ScheduleNodeRun = {
|
||||
node_run_id: string;
|
||||
run_id: string;
|
||||
node_id: string;
|
||||
versions_id: string;
|
||||
attempt_no: number;
|
||||
node_status: ScheduleNodeRunStatus;
|
||||
state_version: number;
|
||||
started_at: string | null;
|
||||
finished_at: string | null;
|
||||
duration_ms: number | null;
|
||||
exit_code: number | null;
|
||||
message: string | null;
|
||||
logs_object_id: string | null;
|
||||
result_object_id: string | null;
|
||||
};
|
||||
|
||||
export type ScheduleRunDetail = ScheduleRunSummary & {
|
||||
node_runs: ScheduleNodeRun[];
|
||||
};
|
||||
|
||||
export async function listSchedules(): Promise<Schedule[]> {
|
||||
return apiRequest<Schedule[]>("/api/v1/schedules");
|
||||
}
|
||||
|
||||
export async function getSchedule(scheduleId: string): Promise<Schedule> {
|
||||
return apiRequest<Schedule>(`/api/v1/schedules/${scheduleId}`);
|
||||
}
|
||||
|
||||
export async function createSchedule(input: {
|
||||
schedule_name: string;
|
||||
description?: string | null;
|
||||
trigger_type?: "manual" | "cron" | "api";
|
||||
cron_expression?: string | null;
|
||||
timezone?: string;
|
||||
enabled?: boolean;
|
||||
max_concurrency?: number;
|
||||
failure_policy?: "stop" | "continue";
|
||||
}): Promise<Schedule> {
|
||||
return apiRequest<Schedule>("/api/v1/schedules", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateSchedule(
|
||||
scheduleId: string,
|
||||
input: {
|
||||
workflow_version: number;
|
||||
schedule_name?: string;
|
||||
description?: string | null;
|
||||
trigger_type?: "manual" | "cron" | "api";
|
||||
cron_expression?: string | null;
|
||||
timezone?: string;
|
||||
enabled?: boolean;
|
||||
max_concurrency?: number;
|
||||
failure_policy?: "stop" | "continue";
|
||||
},
|
||||
): Promise<Schedule> {
|
||||
return apiRequest<Schedule>(`/api/v1/schedules/${scheduleId}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteSchedule(
|
||||
scheduleId: string,
|
||||
workflowVersion: number,
|
||||
): Promise<{ schedule_id: string; deleted: boolean; workflow_version: number }> {
|
||||
return apiRequest(`/api/v1/schedules/${scheduleId}`, {
|
||||
method: "DELETE",
|
||||
body: JSON.stringify({ workflow_version: workflowVersion }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function listScheduleArtifacts(): Promise<ScheduleArtifact[]> {
|
||||
return apiRequest<ScheduleArtifact[]>("/api/v1/schedule-artifacts");
|
||||
}
|
||||
|
||||
export async function hideScheduleArtifact(
|
||||
versionsId: string,
|
||||
): Promise<{
|
||||
versions_id: string;
|
||||
deleted: boolean;
|
||||
artifact_preserved: boolean;
|
||||
}> {
|
||||
return apiRequest(`/api/v1/versions/${versionsId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
export async function listEmployees(): Promise<Employee[]> {
|
||||
return apiRequest<Employee[]>("/api/v1/admin/employees");
|
||||
}
|
||||
|
||||
export async function createEmployee(input: {
|
||||
username: string;
|
||||
display_name: string;
|
||||
email?: string | null;
|
||||
role_code: "admin" | "developer";
|
||||
}): Promise<Employee> {
|
||||
return apiRequest<Employee>("/api/v1/admin/employees", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateEmployee(
|
||||
userId: string,
|
||||
input: {
|
||||
display_name?: string;
|
||||
email?: string | null;
|
||||
role_code?: "admin" | "developer";
|
||||
status?: "active" | "disabled" | "locked";
|
||||
},
|
||||
): Promise<Employee> {
|
||||
return apiRequest<Employee>(`/api/v1/admin/employees/${userId}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteEmployee(
|
||||
userId: string,
|
||||
): Promise<{ user_id: string; deleted: boolean }> {
|
||||
return apiRequest(`/api/v1/admin/employees/${userId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
export async function createScheduleNode(
|
||||
scheduleId: string,
|
||||
input: {
|
||||
workflow_version: number;
|
||||
node_key: string;
|
||||
node_name: string;
|
||||
versions_id: string;
|
||||
timeout_seconds?: number;
|
||||
retry_count?: number;
|
||||
retry_interval_sec?: number;
|
||||
position_x?: number;
|
||||
position_y?: number;
|
||||
arguments_json?: Record<string, unknown>;
|
||||
env_refs_json?: Record<string, string>;
|
||||
},
|
||||
): Promise<Schedule> {
|
||||
return apiRequest<Schedule>(`/api/v1/schedules/${scheduleId}/nodes`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateScheduleNode(
|
||||
scheduleId: string,
|
||||
nodeId: string,
|
||||
input: {
|
||||
workflow_version: number;
|
||||
node_name?: string;
|
||||
versions_id?: string;
|
||||
timeout_seconds?: number;
|
||||
retry_count?: number;
|
||||
retry_interval_sec?: number;
|
||||
position_x?: number;
|
||||
position_y?: number;
|
||||
arguments_json?: Record<string, unknown>;
|
||||
env_refs_json?: Record<string, string>;
|
||||
},
|
||||
): Promise<Schedule> {
|
||||
return apiRequest<Schedule>(
|
||||
`/api/v1/schedules/${scheduleId}/nodes/${nodeId}`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify(input),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteScheduleNode(
|
||||
scheduleId: string,
|
||||
nodeId: string,
|
||||
workflowVersion: number,
|
||||
): Promise<Schedule> {
|
||||
return apiRequest<Schedule>(
|
||||
`/api/v1/schedules/${scheduleId}/nodes/${nodeId}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
body: JSON.stringify({ workflow_version: workflowVersion }),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function createScheduleEdge(
|
||||
scheduleId: string,
|
||||
input: {
|
||||
workflow_version: number;
|
||||
source_node_id: string;
|
||||
target_node_id: string;
|
||||
condition_expr?: string | null;
|
||||
},
|
||||
): Promise<Schedule> {
|
||||
return apiRequest<Schedule>(`/api/v1/schedules/${scheduleId}/edges`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteScheduleEdge(
|
||||
scheduleId: string,
|
||||
edgeId: string,
|
||||
workflowVersion: number,
|
||||
): Promise<Schedule> {
|
||||
return apiRequest<Schedule>(
|
||||
`/api/v1/schedules/${scheduleId}/edges/${edgeId}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
body: JSON.stringify({ workflow_version: workflowVersion }),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function validateSchedule(
|
||||
scheduleId: string,
|
||||
): Promise<DagValidation & {
|
||||
schedule_id: string;
|
||||
workflow_version: number;
|
||||
}> {
|
||||
return apiRequest(`/api/v1/schedules/${scheduleId}/validate`, {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
export async function previewCron(input: {
|
||||
cron_expression: string;
|
||||
timezone: string;
|
||||
count?: number;
|
||||
base_time?: string;
|
||||
}): Promise<CronPreview> {
|
||||
return apiRequest<CronPreview>("/api/v1/cron/preview", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export async function runScheduleNow(
|
||||
scheduleId: string,
|
||||
): Promise<ScheduleRunDetail> {
|
||||
return apiRequest<ScheduleRunDetail>(
|
||||
`/api/v1/schedules/${scheduleId}/run`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Idempotency-Key": crypto.randomUUID(),
|
||||
},
|
||||
body: JSON.stringify({ reason: "manual_run" }),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function listScheduleRuns(input: {
|
||||
scheduleId?: string;
|
||||
status?: ScheduleRunStatus;
|
||||
limit?: number;
|
||||
} = {}): Promise<ScheduleRunSummary[]> {
|
||||
const query = new URLSearchParams();
|
||||
if (input.scheduleId) query.set("schedule_id", input.scheduleId);
|
||||
if (input.status) query.set("status", input.status);
|
||||
query.set("limit", String(input.limit ?? 20));
|
||||
return apiRequest<ScheduleRunSummary[]>(
|
||||
`/api/v1/schedule-runs?${query.toString()}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function getScheduleRun(
|
||||
runId: string,
|
||||
): Promise<ScheduleRunDetail> {
|
||||
return apiRequest<ScheduleRunDetail>(`/api/v1/schedule-runs/${runId}`);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
.dashboard-page,.admin-page{height:100%;padding:24px;overflow:auto;background:#f3f6f9}.dashboard-hero{display:flex;align-items:center;justify-content:space-between;padding:34px;border-radius:12px;color:#fff;background:linear-gradient(125deg,#0b3d69,#1978d4);box-shadow:0 12px 30px rgb(19 80 137/18%)}.dashboard-hero span{font-size:10px;letter-spacing:.12em;opacity:.82}.dashboard-hero h2{margin:8px 0 5px;font-size:25px}.dashboard-hero p{margin:0;font-size:12px;opacity:.88}.dashboard-hero__badge{padding:8px 12px;border-radius:20px;background:rgb(255 255 255/16%)}.dashboard-metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-top:18px}.dashboard-metrics article{display:flex;align-items:center;gap:14px;padding:20px;border:1px solid #e0e8ef;border-radius:9px;background:#fff}.dashboard-metrics svg{color:#1978d4}.dashboard-metrics span{display:flex;flex-direction:column}.dashboard-metrics b{color:#20364c;font-size:21px}.dashboard-metrics small{color:#8796a6}.dashboard-actions{display:flex;gap:12px;margin-top:18px}.dashboard-actions button{display:flex;align-items:center;gap:8px;padding:13px 18px;border:1px solid #d8e4ef;border-radius:7px;color:#346587;background:#fff;cursor:pointer}.admin-page__header{display:flex;align-items:center;justify-content:space-between;margin-bottom:15px;padding:20px 22px;border:1px solid #dee7ef;border-radius:9px;background:#fff}.admin-page__header span{color:#1978d4;font-size:10px;font-weight:800}.admin-page__header h2{margin:4px 0;color:#20364c}.admin-page__header p{margin:0;color:#8a99a8;font-size:11px}.admin-readonly{margin-bottom:12px;padding:10px 13px;border:1px solid #f1d79c;border-radius:6px;color:#8a6416;background:#fff8e8;font-size:11px}.employee-table{overflow:hidden;border:1px solid #dfe7ee;border-radius:9px;background:#fff}.employee-table__head,.employee-row{display:grid;grid-template-columns:1.5fr 1fr .7fr .65fr .8fr;align-items:center;gap:12px;padding:12px 17px}.employee-table__head{color:#748598;background:#f5f8fb;font-size:10px;font-weight:700}.employee-row{min-height:64px;border-top:1px solid #edf1f5;color:#44576a;font-size:11px}.employee-name{display:flex;align-items:center;gap:10px}.employee-name .avatar{display:grid;width:32px;height:32px;place-items:center}.employee-name>span{display:flex;min-width:0;flex-direction:column}.employee-name strong{color:#23384e}.employee-name small{margin-top:3px;color:#93a0ae}.employee-row code{color:#60758b}.role-pill,.status-pill{width:max-content;padding:4px 8px;border-radius:12px}.role-pill{color:#1d6ab3;background:#eaf4ff}.role-pill.is-admin{color:#87580f;background:#fff2d6}.status-pill{color:#138657;background:#e8f8f1}.status-pill.is-disabled,.status-pill.is-locked{color:#a64b4b;background:#ffeded}.employee-actions{display:flex;gap:6px}.employee-actions button{padding:5px 9px;border:1px solid #d9e3ec;border-radius:4px;color:#4c6c88;background:#fff;cursor:pointer}.employee-actions button.is-danger{color:#c14a4a}.employee-actions button:disabled{cursor:not-allowed;opacity:.45}.admin-empty{padding:25px;text-align:center;color:#8796a6}
|
||||
@@ -0,0 +1,185 @@
|
||||
.dashboard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.6fr) minmax(310px, .9fr);
|
||||
gap: 14px;
|
||||
margin-top: 18px;
|
||||
padding-bottom: 25px;
|
||||
}
|
||||
.dashboard-panel {
|
||||
border: 1px solid #dfe7ee;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
box-shadow: 0 5px 16px rgb(33 65 97 / 5%);
|
||||
}
|
||||
.dashboard-panel > header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 17px 20px;
|
||||
border-bottom: 1px solid #edf1f5;
|
||||
}
|
||||
.dashboard-panel header span {
|
||||
color: #7890a6;
|
||||
font-size: 9px;
|
||||
font-weight: 800;
|
||||
letter-spacing: .09em;
|
||||
}
|
||||
.dashboard-panel h3 {
|
||||
margin: 4px 0 0;
|
||||
color: #263b50;
|
||||
font-size: 14px;
|
||||
}
|
||||
.dashboard-panel--trend > header > b {
|
||||
padding: 5px 9px;
|
||||
border-radius: 13px;
|
||||
color: #16855a;
|
||||
background: #e8f8f1;
|
||||
font-size: 10px;
|
||||
}
|
||||
.trend-chart {
|
||||
display: flex;
|
||||
height: 190px;
|
||||
align-items: flex-end;
|
||||
gap: 18px;
|
||||
padding: 26px 28px 17px;
|
||||
background: repeating-linear-gradient(to bottom, #fff 0, #fff 44px, #eef3f7 45px);
|
||||
}
|
||||
.trend-column {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
.trend-column i {
|
||||
display: block;
|
||||
width: min(36px, 72%);
|
||||
min-height: 12px;
|
||||
border-radius: 5px 5px 2px 2px;
|
||||
background: linear-gradient(#49a0eb, #207bd1);
|
||||
box-shadow: 0 4px 9px rgb(32 123 209 / 18%);
|
||||
}
|
||||
.trend-column__value, .trend-column small {
|
||||
color: #7890a3;
|
||||
font-size: 9px;
|
||||
}
|
||||
.dashboard-panel--trend footer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 22px;
|
||||
padding: 10px;
|
||||
color: #708397;
|
||||
font-size: 9px;
|
||||
}
|
||||
.dashboard-panel footer span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
.legend-dot {
|
||||
display: inline-block;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.legend-dot.is-success { background: #27b87c; }
|
||||
.legend-dot.is-failed { background: #ed7777; }
|
||||
.legend-dot.is-notebook { background: #318de0; }
|
||||
.legend-dot.is-python { background: #72c9b0; }
|
||||
.legend-dot.is-version { background: #f3b75c; }
|
||||
.donut-layout {
|
||||
display: flex;
|
||||
min-height: 226px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 28px;
|
||||
padding: 20px;
|
||||
}
|
||||
.donut-chart {
|
||||
position: relative;
|
||||
display: grid;
|
||||
width: 128px;
|
||||
height: 128px;
|
||||
flex: 0 0 auto;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
background: conic-gradient(#318de0 0 67%, #72c9b0 67% 86%, #f3b75c 86% 100%);
|
||||
}
|
||||
.donut-chart::after {
|
||||
position: absolute;
|
||||
width: 78px;
|
||||
height: 78px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
content: "";
|
||||
}
|
||||
.donut-chart span {
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
.donut-chart b { color: #273d53; font-size: 20px; }
|
||||
.donut-chart small { color: #8a9aaa; font-size: 8px; }
|
||||
.donut-legend { display: flex; flex-direction: column; gap: 15px; }
|
||||
.donut-legend > span {
|
||||
display: grid;
|
||||
grid-template-columns: 8px 1fr;
|
||||
align-items: center;
|
||||
column-gap: 7px;
|
||||
}
|
||||
.donut-legend b { color: #40566c; font-size: 10px; }
|
||||
.donut-legend small { grid-column: 2; color: #8a9baa; font-size: 8px; }
|
||||
.dashboard-panel--activity { grid-column: 1 / -1; }
|
||||
.dashboard-panel--activity > header > button {
|
||||
border: 0;
|
||||
color: #257bcb;
|
||||
background: transparent;
|
||||
font-size: 10px;
|
||||
}
|
||||
.activity-table__head, .activity-row {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr .7fr .55fr .5fr;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
padding: 11px 20px;
|
||||
}
|
||||
.activity-table__head {
|
||||
color: #8595a5;
|
||||
background: #f7f9fb;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.activity-row {
|
||||
border-top: 1px solid #edf1f5;
|
||||
color: #607488;
|
||||
font-size: 10px;
|
||||
}
|
||||
.activity-row > span:first-child {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
color: #344c63;
|
||||
}
|
||||
.activity-icon {
|
||||
display: grid;
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
place-items: center;
|
||||
border-radius: 6px;
|
||||
color: #277fcc;
|
||||
background: #eaf4ff;
|
||||
}
|
||||
.activity-row b {
|
||||
padding: 3px 7px;
|
||||
border-radius: 10px;
|
||||
color: #16855a;
|
||||
background: #e9f8f2;
|
||||
font-size: 9px;
|
||||
}
|
||||
@media (max-width: 1200px) {
|
||||
.dashboard-grid { grid-template-columns: 1fr; }
|
||||
.dashboard-panel--activity { grid-column: auto; }
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "react-router build",
|
||||
"dev": "react-router dev",
|
||||
"start": "react-router-serve ./build/server/index.js",
|
||||
"typecheck": "react-router typegen && tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-router/node": "^8",
|
||||
"@react-router/serve": "^8",
|
||||
"isbot": "^5.1.36",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-router": "^8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@react-router/dev": "^8",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@types/node": "^22",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^8.0.3"
|
||||
},
|
||||
"packageManager": "pnpm@10.15.1"
|
||||
}
|
||||
Generated
+2327
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,7 @@
|
||||
import type { Config } from "@react-router/dev/config";
|
||||
|
||||
export default {
|
||||
// Config options...
|
||||
// Server-side render by default, to enable SPA mode set this to `false`
|
||||
ssr: false,
|
||||
} satisfies Config;
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"include": [
|
||||
"**/*",
|
||||
"**/.server/**/*",
|
||||
"**/.client/**/*",
|
||||
".react-router/types/**/*"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2022"],
|
||||
"types": ["node", "vite/client"],
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"rootDirs": [".", "./.react-router/types"],
|
||||
"paths": {
|
||||
"~/*": ["./app/*"]
|
||||
},
|
||||
"esModuleInterop": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { reactRouter } from "@react-router/dev/vite";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [reactRouter()],
|
||||
server: {
|
||||
host: "0.0.0.0",
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: process.env.VITE_GATEWAY_URL || "http://127.0.0.1:8081",
|
||||
changeOrigin: true,
|
||||
},
|
||||
"/jupyter": {
|
||||
target: process.env.VITE_GATEWAY_URL || "http://127.0.0.1:8081",
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
+18
-61
@@ -1,71 +1,28 @@
|
||||
# Migrations
|
||||
|
||||
MySQL 8 的 Alembic 迁移目录。
|
||||
MySQL 8 Alembic 迁移目录。
|
||||
|
||||
当前数据库设计基线位于 `4/model_platform_schema.sql`。
|
||||
当前链路:
|
||||
|
||||
## V1 基线
|
||||
|
||||
- Alembic:`1.18.5`
|
||||
- 基线版本:`20260724_0001`
|
||||
- 迁移文件:`versions/20260724_0001_v1_schema_baseline.py`
|
||||
- 业务表:26 张
|
||||
- 外键:71 个
|
||||
- 索引:94 个
|
||||
- `upgrade()`:可以从空 MySQL 8 数据库建立完整 V1 Schema。
|
||||
- `downgrade()`:按反向依赖顺序删除 26 张业务表。
|
||||
|
||||
现有 `model_platform` 数据库原本由 V1 DDL 建立,结构校验一致后已使用
|
||||
`alembic stamp head` 接管,没有重复执行建表。
|
||||
|
||||
## 常用命令
|
||||
|
||||
所有命令必须通过环境变量传入连接串,仓库中不保存数据库密码:
|
||||
|
||||
```powershell
|
||||
$env:DATABASE_URL = "mysql+asyncmy://<user>:<password>@<host>:3306/<database>?charset=utf8mb4"
|
||||
alembic -c alembic.ini current
|
||||
alembic -c alembic.ini check
|
||||
alembic -c alembic.ini upgrade head
|
||||
```text
|
||||
20260724_0001 完整业务 Schema 基线
|
||||
20260728_0002 Demo 用户、角色、Workspace 数据
|
||||
20260728_0003 调度稳定版本可见性字段
|
||||
20260730_0004 编辑租约字段 redis_lock_key -> lock_key 兼容迁移
|
||||
```
|
||||
|
||||
迁移发布后不得直接修改旧版本;表结构变化应新增 revision,并评审自动生成的
|
||||
数据类型、约束、索引、默认值和回滚顺序。
|
||||
Docker Compose 中的 `migrate` 一次性容器会在 Backend 和 Runtime 启动前执行:
|
||||
|
||||
## 第 8 小步验收记录
|
||||
```bash
|
||||
alembic upgrade head
|
||||
```
|
||||
|
||||
- 执行日期:2026-07-24
|
||||
- 目标服务:Docker Compose `mysql`
|
||||
- 数据库:`model_platform`
|
||||
- MySQL:8.0.36
|
||||
- 空库升级:通过
|
||||
- 模型与数据库结构校验:26 张表、71 个外键、差异 0
|
||||
- `alembic check`:`No new upgrade operations detected`
|
||||
- 回滚到 base:通过,剩余业务表 0
|
||||
- 临时测试数据库:验收后已删除
|
||||
- 现有开发库版本:`20260724_0001 (head)`
|
||||
本地执行:
|
||||
|
||||
## 第 9 小步数据迁移
|
||||
```bash
|
||||
export DATABASE_URL='mysql+asyncmy://user:password@127.0.0.1:3308/model_platform?charset=utf8mb4'
|
||||
uv run --package backend alembic current
|
||||
uv run --package backend alembic upgrade head
|
||||
```
|
||||
|
||||
旧版 `文件1/platform_data/system.json` 已通过
|
||||
`data/migrate_system_json.py` 事务化迁移到 MySQL:
|
||||
|
||||
- `roles`:2 行;
|
||||
- `permissions`:13 行;
|
||||
- `role_permissions`:21 行;
|
||||
- `users`:4 行;
|
||||
- `audit_logs`:26 行。
|
||||
|
||||
迁移工具默认 dry-run,显式 `--apply` 才写库;重复执行不会重复写入。
|
||||
|
||||
## 第 10 小步 Workspace 迁移
|
||||
|
||||
旧版 `文件1/server.py` 中的 `WORKSPACE_DEFINITIONS` 已通过
|
||||
`data/migrate_legacy_workspaces.py` 事务化迁移到 MySQL:
|
||||
|
||||
- `workspaces`:2 行;
|
||||
- `workspace_members`:4 行;
|
||||
- 已有 `audit_logs.workspace_id`:按用户唯一 Workspace 关系补齐 26 行。
|
||||
|
||||
迁移工具使用 AST 读取静态常量,不执行旧版服务代码;默认 dry-run,显式
|
||||
`--apply` 才写库。重复执行与后端镜像内隔离 dry-run 的变更数均为 0。
|
||||
已发布的旧 revision 不应再修改;后续表结构调整必须新增 revision。
|
||||
|
||||
@@ -26,11 +26,11 @@ def upgrade() -> None:
|
||||
sa.Column('event_id', sa.CHAR(length=26), nullable=False),
|
||||
sa.Column('process_status', sa.String(length=16), server_default=sa.text("'processing'"), nullable=False, comment='processing/succeeded/failed'),
|
||||
sa.Column('created_at', mysql.DATETIME(fsp=3), server_default=sa.text('CURRENT_TIMESTAMP(3)'), nullable=False),
|
||||
sa.Column('message_id', sa.String(length=128), nullable=True, comment='Redis Stream message ID'),
|
||||
sa.Column('message_id', sa.String(length=128), nullable=True, comment='数据库事件处理批次标识'),
|
||||
sa.Column('processed_at', mysql.DATETIME(fsp=3), nullable=True),
|
||||
sa.Column('error_message', sa.String(length=2000), nullable=True),
|
||||
sa.PrimaryKeyConstraint('consumer_name', 'event_id'),
|
||||
comment='消费者幂等 Inbox,防止 Stream 重投导致重复执行'
|
||||
comment='消费者幂等 Inbox,防止数据库事件重复处理'
|
||||
)
|
||||
op.create_index('idx_consumer_inbox_status', 'consumer_inbox', ['consumer_name', 'process_status', 'created_at'], unique=False)
|
||||
op.create_table('outbox_events',
|
||||
@@ -49,7 +49,7 @@ def upgrade() -> None:
|
||||
sa.Column('published_at', mysql.DATETIME(fsp=3), nullable=True),
|
||||
sa.Column('last_error', sa.String(length=2000), nullable=True),
|
||||
sa.PrimaryKeyConstraint('event_id'),
|
||||
comment='事务 Outbox;提交后发布到 Redis Streams'
|
||||
comment='事务 Outbox;由 Schedule Executor 直接轮询处理'
|
||||
)
|
||||
op.create_index('idx_outbox_aggregate', 'outbox_events', ['aggregate_type', 'aggregate_id', 'created_at'], unique=False)
|
||||
op.create_index('idx_outbox_idempotency', 'outbox_events', ['idempotency_key'], unique=False)
|
||||
@@ -295,7 +295,7 @@ def upgrade() -> None:
|
||||
sa.Column('workspace_id', sa.CHAR(length=26), nullable=False),
|
||||
sa.Column('storage_object_id', sa.CHAR(length=26), nullable=False),
|
||||
sa.Column('user_id', sa.CHAR(length=26), nullable=False),
|
||||
sa.Column('redis_lock_key', sa.String(length=512), nullable=False),
|
||||
sa.Column('lock_key', sa.String(length=512), nullable=False),
|
||||
sa.Column('lock_token_hash', sa.BINARY(length=32), nullable=False, comment='不保存原始 token'),
|
||||
sa.Column('session_status', sa.String(length=16), server_default=sa.text("'active'"), nullable=False, comment='active/closed/expired/failed'),
|
||||
sa.Column('started_at', mysql.DATETIME(fsp=3), server_default=sa.text('CURRENT_TIMESTAMP(3)'), nullable=False),
|
||||
@@ -310,7 +310,7 @@ def upgrade() -> None:
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.user_id'], name='fk_edit_sessions_user', ondelete='RESTRICT'),
|
||||
sa.ForeignKeyConstraint(['workspace_id'], ['workspaces.workspace_id'], name='fk_edit_sessions_workspace', ondelete='RESTRICT'),
|
||||
sa.PrimaryKeyConstraint('edit_session_id'),
|
||||
comment='编辑会话审计;实时锁状态以 Redis 为准'
|
||||
comment='编辑会话与数据库租约;MySQL 为锁状态权威'
|
||||
)
|
||||
op.create_index('fk_edit_sessions_workspace', 'edit_sessions', ['workspace_id'], unique=False)
|
||||
op.create_index('idx_edit_sessions_object', 'edit_sessions', ['storage_object_id', 'session_status', 'expires_at'], unique=False)
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
"""remove Redis-specific lock column naming
|
||||
|
||||
Revision ID: 20260730_0004
|
||||
Revises: 20260728_0003
|
||||
Create Date: 2026-07-30
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import context, op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = "20260730_0004"
|
||||
down_revision: str | None = "20260728_0003"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def _column_names() -> set[str]:
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
return {item["name"] for item in inspector.get_columns("edit_sessions")}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if context.is_offline_mode():
|
||||
return
|
||||
columns = _column_names()
|
||||
if "redis_lock_key" in columns and "lock_key" not in columns:
|
||||
op.alter_column(
|
||||
"edit_sessions",
|
||||
"redis_lock_key",
|
||||
new_column_name="lock_key",
|
||||
existing_type=sa.String(length=512),
|
||||
existing_nullable=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if context.is_offline_mode():
|
||||
return
|
||||
columns = _column_names()
|
||||
if "lock_key" in columns and "redis_lock_key" not in columns:
|
||||
op.alter_column(
|
||||
"edit_sessions",
|
||||
"lock_key",
|
||||
new_column_name="redis_lock_key",
|
||||
existing_type=sa.String(length=512),
|
||||
existing_nullable=False,
|
||||
)
|
||||
@@ -0,0 +1,12 @@
|
||||
FROM node:22-alpine AS frontend-build
|
||||
WORKDIR /app
|
||||
RUN corepack enable && corepack prepare pnpm@10.15.1 --activate
|
||||
COPY frontend/package.json frontend/pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
COPY frontend/ ./
|
||||
RUN pnpm build
|
||||
|
||||
FROM nginx:1.27-alpine
|
||||
COPY nginx/default.conf.template /etc/nginx/templates/default.conf.template
|
||||
COPY --from=frontend-build /app/build/client /usr/share/nginx/html
|
||||
EXPOSE 80
|
||||
@@ -0,0 +1,83 @@
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
|
||||
upstream backend_upstream {
|
||||
server backend:8000;
|
||||
}
|
||||
|
||||
upstream runtime_upstream {
|
||||
server runtime:8000;
|
||||
}
|
||||
|
||||
upstream jupyter_upstream {
|
||||
server jupyter:8888;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
client_max_body_size 100m;
|
||||
|
||||
location = /health {
|
||||
default_type application/json;
|
||||
return 200 '{"status":"ok","service":"gateway"}';
|
||||
}
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://backend_upstream;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Request-ID $request_id;
|
||||
}
|
||||
|
||||
location = /_jupyter_auth {
|
||||
internal;
|
||||
proxy_pass http://runtime_upstream/internal/v1/jupyter/authorize;
|
||||
proxy_pass_request_body off;
|
||||
proxy_set_header Content-Length "";
|
||||
proxy_set_header Cookie $http_cookie;
|
||||
proxy_set_header X-Original-URI $request_uri;
|
||||
proxy_set_header X-Request-ID $request_id;
|
||||
proxy_set_header X-Service-Token "${INTERNAL_SERVICE_TOKEN}";
|
||||
}
|
||||
|
||||
location = /jupyter {
|
||||
return 308 /jupyter/;
|
||||
}
|
||||
|
||||
location ^~ /jupyter/ {
|
||||
auth_request /_jupyter_auth;
|
||||
auth_request_set $jupyter_authorization
|
||||
$upstream_http_x_jupyter_authorization;
|
||||
|
||||
proxy_pass http://jupyter_upstream;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Authorization $jupyter_authorization;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header Origin $http_origin;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Prefix /jupyter;
|
||||
proxy_set_header X-Request-ID $request_id;
|
||||
proxy_buffering off;
|
||||
proxy_request_buffering off;
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
proxy_redirect off;
|
||||
}
|
||||
}
|
||||
+2
-3
@@ -3,11 +3,10 @@ FROM python:3.12-slim-bookworm
|
||||
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 contracts ./contracts
|
||||
COPY runtime ./runtime
|
||||
RUN uv sync --frozen --no-dev --no-editable --package runtime
|
||||
RUN uv pip install --system ./common ./runtime
|
||||
|
||||
EXPOSE 8000
|
||||
CMD ["uv", "run", "--frozen", "--package", "runtime", "uvicorn", "runtime.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
CMD ["uvicorn", "runtime.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
|
||||
+9
-2
@@ -1,4 +1,11 @@
|
||||
# Runtime
|
||||
|
||||
独立 Runtime/Jupyter 管理服务。负责 Workspace Runtime、Notebook Session、
|
||||
Jupyter 访问票据及可选编辑锁。Demo 默认关闭互斥锁。
|
||||
独立 Runtime/Jupyter 管理服务:
|
||||
|
||||
- 使用共享 Jupyter Server;
|
||||
- 在 MySQL 中维护 Runtime 实例和编辑会话租约;
|
||||
- 创建、心跳和释放文件编辑锁;
|
||||
- 创建短期 Jupyter 访问票据;
|
||||
- 为 Nginx `auth_request` 校验票据并注入内部 Jupyter Token。
|
||||
|
||||
当前简化部署要求 Runtime 单副本运行。
|
||||
|
||||
@@ -7,11 +7,10 @@ dependencies = [
|
||||
"fastapi==0.116.1",
|
||||
"uvicorn[standard]==0.35.0",
|
||||
"httpx==0.28.1",
|
||||
"redis==5.2.1",
|
||||
]
|
||||
|
||||
[tool.uv.sources]
|
||||
common = { workspace = true }
|
||||
common = { path = "../common" }
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
|
||||
+63
-183
@@ -2,7 +2,6 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
@@ -23,8 +22,7 @@ from fastapi import (
|
||||
Response,
|
||||
status,
|
||||
)
|
||||
from redis.asyncio import Redis
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from common.db import create_database_engine, create_session_factory
|
||||
@@ -39,13 +37,6 @@ from common.db.models import (
|
||||
)
|
||||
from common.ids import new_ulid
|
||||
from common.service_app import create_service_app
|
||||
from runtime.redis_lock import (
|
||||
acquire as redis_acquire,
|
||||
current as redis_current,
|
||||
heartbeat as redis_heartbeat,
|
||||
lock_key,
|
||||
release as redis_release,
|
||||
)
|
||||
from runtime.providers.shared_jupyter import (
|
||||
RuntimeProviderError,
|
||||
SharedJupyterAdapter,
|
||||
@@ -140,7 +131,7 @@ async def lifespan(app: Any) -> AsyncIterator[None]:
|
||||
os.getenv("FILE_LOCK_TTL_MS", "45000")
|
||||
)
|
||||
app.state.file_lock_enabled = (
|
||||
os.getenv("FILE_LOCK_ENABLED", "true").strip().lower()
|
||||
os.getenv("FILE_LOCK_ENABLED", "false").strip().lower()
|
||||
not in {"0", "false", "no", "off"}
|
||||
)
|
||||
if not 5_000 <= app.state.file_lock_ttl_ms <= 300_000:
|
||||
@@ -152,13 +143,8 @@ async def lifespan(app: Any) -> AsyncIterator[None]:
|
||||
raise RuntimeError(
|
||||
"JUPYTER_TICKET_TTL_SECONDS must be between 30 and 300"
|
||||
)
|
||||
app.state.redis_client = Redis(
|
||||
host=os.getenv("REDIS_HOST", "redis"),
|
||||
port=int(os.getenv("REDIS_PORT", "6379")),
|
||||
password=os.getenv("REDIS_PASSWORD"),
|
||||
decode_responses=True,
|
||||
)
|
||||
await app.state.redis_client.ping()
|
||||
app.state.jupyter_tickets: dict[str, dict[str, Any]] = {}
|
||||
app.state.ticket_lock = asyncio.Lock()
|
||||
jupyter_internal_url = os.getenv(
|
||||
"JUPYTER_INTERNAL_URL",
|
||||
"http://jupyter:8888/jupyter/",
|
||||
@@ -195,7 +181,6 @@ async def lifespan(app: Any) -> AsyncIterator[None]:
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
await app.state.jupyter_http_client.aclose()
|
||||
await app.state.redis_client.aclose()
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@@ -351,7 +336,7 @@ async def acquire_file_lock(
|
||||
edit_session_id = new_ulid()
|
||||
raw_token = secrets.token_urlsafe(32)
|
||||
digest = token_digest(raw_token)
|
||||
key = lock_key(payload.workspace_id, payload.storage_object_id)
|
||||
key = f"db-lock:{payload.workspace_id}:{payload.storage_object_id}"
|
||||
if not request.app.state.file_lock_enabled:
|
||||
key = f"{key}:session:{edit_session_id}"
|
||||
|
||||
@@ -362,40 +347,36 @@ async def acquire_file_lock(
|
||||
user_id=payload.user_id,
|
||||
)
|
||||
storage_object = await editable_object(session, payload, role)
|
||||
acquired = await redis_acquire(
|
||||
request.app.state.redis_client,
|
||||
key=key,
|
||||
value={
|
||||
"edit_session_id": edit_session_id,
|
||||
"user_id": payload.user_id,
|
||||
"display_name": user.display_name,
|
||||
"token_hash": digest.hex(),
|
||||
"acquired_at": utc_iso(now),
|
||||
},
|
||||
ttl_ms=ttl_ms,
|
||||
|
||||
if request.app.state.file_lock_enabled:
|
||||
current = await session.scalar(
|
||||
select(EditSessions)
|
||||
.where(
|
||||
EditSessions.workspace_id == payload.workspace_id,
|
||||
EditSessions.storage_object_id == payload.storage_object_id,
|
||||
EditSessions.session_status == "active",
|
||||
)
|
||||
if not acquired:
|
||||
current, remaining_ms = await redis_current(
|
||||
request.app.state.redis_client,
|
||||
key,
|
||||
)
|
||||
lease_expires_at = utcnow() + timedelta(
|
||||
milliseconds=max(remaining_ms, 0)
|
||||
.order_by(EditSessions.started_at.desc())
|
||||
.with_for_update()
|
||||
)
|
||||
if current is not None and current.expires_at > now:
|
||||
editor = await session.get(Users, current.user_id)
|
||||
raise lock_error(
|
||||
status.HTTP_409_CONFLICT,
|
||||
"FILE_LOCK_CONFLICT",
|
||||
"文件正在被其他用户编辑",
|
||||
retryable=True,
|
||||
details={
|
||||
"edit_session_id": (
|
||||
current or {}
|
||||
).get("edit_session_id"),
|
||||
"editor_user_id": (current or {}).get("user_id"),
|
||||
"editor_name": (current or {}).get("display_name"),
|
||||
"lease_expires_at": utc_iso(lease_expires_at),
|
||||
"edit_session_id": current.edit_session_id,
|
||||
"editor_user_id": current.user_id,
|
||||
"editor_name": editor.display_name if editor else current.user_id,
|
||||
"lease_expires_at": utc_iso(current.expires_at),
|
||||
},
|
||||
)
|
||||
if current is not None:
|
||||
current.session_status = "expired"
|
||||
current.ended_at = now
|
||||
current.end_reason = "database_lease_expired"
|
||||
|
||||
runtime_session = None
|
||||
try:
|
||||
@@ -426,28 +407,13 @@ async def acquire_file_lock(
|
||||
user_id=payload.user_id,
|
||||
runtime_id=runtime_item.runtime_id,
|
||||
jupyter_session_id=runtime_session.session_id,
|
||||
redis_lock_key=key,
|
||||
lock_key=key,
|
||||
lock_token_hash=digest,
|
||||
session_status="active",
|
||||
started_at=now,
|
||||
last_heartbeat_at=now,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
if request.app.state.file_lock_enabled:
|
||||
await session.execute(
|
||||
update(EditSessions)
|
||||
.where(
|
||||
EditSessions.workspace_id == payload.workspace_id,
|
||||
EditSessions.storage_object_id
|
||||
== payload.storage_object_id,
|
||||
EditSessions.session_status == "active",
|
||||
)
|
||||
.values(
|
||||
session_status="expired",
|
||||
ended_at=now,
|
||||
end_reason="redis_lease_expired",
|
||||
)
|
||||
)
|
||||
session.add(item)
|
||||
await session.commit()
|
||||
except Exception as exc:
|
||||
@@ -463,12 +429,6 @@ async def acquire_file_lock(
|
||||
"failed to compensate Jupyter session creation",
|
||||
exc_info=True,
|
||||
)
|
||||
await redis_release(
|
||||
request.app.state.redis_client,
|
||||
key=key,
|
||||
edit_session_id=edit_session_id,
|
||||
token_hash=digest.hex(),
|
||||
)
|
||||
if isinstance(exc, RuntimeProviderError):
|
||||
raise lock_error(
|
||||
status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
@@ -508,7 +468,7 @@ async def heartbeat_file_lock(
|
||||
workspace_id=payload.workspace_id,
|
||||
user_id=payload.user_id,
|
||||
)
|
||||
token_hash = verify_token(item, payload.lock_token)
|
||||
verify_token(item, payload.lock_token)
|
||||
if item.session_status != "active":
|
||||
raise lock_error(
|
||||
status.HTTP_409_CONFLICT,
|
||||
@@ -516,19 +476,10 @@ async def heartbeat_file_lock(
|
||||
"编辑锁已结束",
|
||||
details={"session_status": item.session_status},
|
||||
)
|
||||
result = await redis_heartbeat(
|
||||
request.app.state.redis_client,
|
||||
key=item.redis_lock_key,
|
||||
edit_session_id=item.edit_session_id,
|
||||
token_hash=token_hash,
|
||||
ttl_ms=ttl_ms,
|
||||
)
|
||||
if result != 1:
|
||||
if item.expires_at <= now:
|
||||
item.session_status = "expired"
|
||||
item.ended_at = now
|
||||
item.end_reason = (
|
||||
"redis_lease_expired" if result == 0 else "lock_replaced"
|
||||
)
|
||||
item.end_reason = "database_lease_expired"
|
||||
try:
|
||||
await request.app.state.runtime_lifecycle.terminate_session(
|
||||
item.runtime_id,
|
||||
@@ -549,10 +500,7 @@ async def heartbeat_file_lock(
|
||||
item.last_heartbeat_at = now
|
||||
item.expires_at = now + timedelta(milliseconds=ttl_ms)
|
||||
if item.runtime_id:
|
||||
runtime_item = await session.get(
|
||||
RuntimeInstances,
|
||||
item.runtime_id,
|
||||
)
|
||||
runtime_item = await session.get(RuntimeInstances, item.runtime_id)
|
||||
if runtime_item is not None:
|
||||
request.app.state.runtime_lifecycle.touch(runtime_item)
|
||||
await session.commit()
|
||||
@@ -576,24 +524,12 @@ async def release_file_lock(
|
||||
workspace_id=payload.workspace_id,
|
||||
user_id=payload.user_id,
|
||||
)
|
||||
token_hash = verify_token(item, payload.lock_token)
|
||||
verify_token(item, payload.lock_token)
|
||||
if item.session_status != "active":
|
||||
return {"data": session_payload(item), "meta": {"reused": True}}
|
||||
result = await redis_release(
|
||||
request.app.state.redis_client,
|
||||
key=item.redis_lock_key,
|
||||
edit_session_id=item.edit_session_id,
|
||||
token_hash=token_hash,
|
||||
)
|
||||
item.ended_at = now
|
||||
if result == 1:
|
||||
item.session_status = "closed"
|
||||
item.end_reason = "client_release"
|
||||
else:
|
||||
item.session_status = "expired"
|
||||
item.end_reason = (
|
||||
"redis_lease_expired" if result == 0 else "lock_replaced"
|
||||
)
|
||||
try:
|
||||
await request.app.state.runtime_lifecycle.terminate_session(
|
||||
item.runtime_id,
|
||||
@@ -628,25 +564,7 @@ async def create_jupyter_access_ticket(
|
||||
user_id=payload.user_id,
|
||||
)
|
||||
token_hash = verify_token(item, payload.lock_token)
|
||||
if item.session_status != "active":
|
||||
raise lock_error(
|
||||
status.HTTP_409_CONFLICT,
|
||||
"FILE_LOCK_NOT_ACTIVE",
|
||||
"编辑锁已结束",
|
||||
details={"session_status": item.session_status},
|
||||
)
|
||||
|
||||
current, remaining_ms = await redis_current(
|
||||
request.app.state.redis_client,
|
||||
item.redis_lock_key,
|
||||
)
|
||||
if (
|
||||
not current
|
||||
or remaining_ms <= 0
|
||||
or current.get("edit_session_id") != item.edit_session_id
|
||||
or current.get("user_id") != item.user_id
|
||||
or current.get("token_hash") != token_hash
|
||||
):
|
||||
if item.session_status != "active" or item.expires_at <= now:
|
||||
raise lock_error(
|
||||
status.HTTP_409_CONFLICT,
|
||||
"FILE_LOCK_EXPIRED",
|
||||
@@ -655,10 +573,7 @@ async def create_jupyter_access_ticket(
|
||||
)
|
||||
|
||||
runtime_item = await session.get(RuntimeInstances, item.runtime_id)
|
||||
if (
|
||||
runtime_item is None
|
||||
or runtime_item.actual_state != "running"
|
||||
):
|
||||
if runtime_item is None or runtime_item.actual_state != "running":
|
||||
raise lock_error(
|
||||
status.HTTP_409_CONFLICT,
|
||||
"RUNTIME_NOT_RUNNING",
|
||||
@@ -689,23 +604,13 @@ async def create_jupyter_access_ticket(
|
||||
"edit_session_id": item.edit_session_id,
|
||||
"runtime_id": item.runtime_id,
|
||||
"jupyter_session_id": item.jupyter_session_id,
|
||||
"redis_lock_key": item.redis_lock_key,
|
||||
"lock_token_hash": token_hash,
|
||||
"expires_at": utc_iso(expires_at),
|
||||
"expires_at": expires_at,
|
||||
}
|
||||
stored = await request.app.state.redis_client.set(
|
||||
jupyter_ticket_key(raw_ticket),
|
||||
json.dumps(ticket_data, separators=(",", ":")),
|
||||
ex=ttl_seconds,
|
||||
nx=True,
|
||||
)
|
||||
if not stored:
|
||||
raise lock_error(
|
||||
status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
"JUPYTER_TICKET_COLLISION",
|
||||
"访问票据生成失败,请重试",
|
||||
retryable=True,
|
||||
)
|
||||
async with request.app.state.ticket_lock:
|
||||
request.app.state.jupyter_tickets[
|
||||
jupyter_ticket_key(raw_ticket)
|
||||
] = ticket_data
|
||||
|
||||
return {
|
||||
"data": {
|
||||
@@ -751,43 +656,30 @@ async def authorize_jupyter_proxy(
|
||||
"缺少 Jupyter 访问票据",
|
||||
)
|
||||
|
||||
raw_data = await request.app.state.redis_client.get(
|
||||
jupyter_ticket_key(jupyter_access)
|
||||
)
|
||||
if not raw_data:
|
||||
ticket_key = jupyter_ticket_key(jupyter_access)
|
||||
async with request.app.state.ticket_lock:
|
||||
ticket_data = request.app.state.jupyter_tickets.get(ticket_key)
|
||||
if ticket_data and ticket_data["expires_at"] <= utcnow():
|
||||
request.app.state.jupyter_tickets.pop(ticket_key, None)
|
||||
ticket_data = None
|
||||
if not ticket_data:
|
||||
raise lock_error(
|
||||
status.HTTP_401_UNAUTHORIZED,
|
||||
"JUPYTER_TICKET_EXPIRED",
|
||||
"Jupyter 访问票据无效或已过期",
|
||||
)
|
||||
try:
|
||||
ticket_data = json.loads(raw_data)
|
||||
except (TypeError, ValueError):
|
||||
await request.app.state.redis_client.delete(
|
||||
jupyter_ticket_key(jupyter_access)
|
||||
)
|
||||
raise lock_error(
|
||||
status.HTTP_401_UNAUTHORIZED,
|
||||
"JUPYTER_TICKET_INVALID",
|
||||
"Jupyter 访问票据无效",
|
||||
)
|
||||
|
||||
current, remaining_ms = await redis_current(
|
||||
request.app.state.redis_client,
|
||||
ticket_data["redis_lock_key"],
|
||||
)
|
||||
async with request.app.state.session_factory() as session:
|
||||
item = await session.get(EditSessions, ticket_data["edit_session_id"])
|
||||
if (
|
||||
not current
|
||||
or remaining_ms <= 0
|
||||
or current.get("edit_session_id")
|
||||
!= ticket_data["edit_session_id"]
|
||||
or current.get("user_id") != ticket_data["user_id"]
|
||||
or current.get("token_hash")
|
||||
!= ticket_data["lock_token_hash"]
|
||||
item is None
|
||||
or item.session_status != "active"
|
||||
or item.expires_at <= utcnow()
|
||||
or item.user_id != ticket_data["user_id"]
|
||||
or item.lock_token_hash.hex() != ticket_data["lock_token_hash"]
|
||||
):
|
||||
await request.app.state.redis_client.delete(
|
||||
jupyter_ticket_key(jupyter_access)
|
||||
)
|
||||
async with request.app.state.ticket_lock:
|
||||
request.app.state.jupyter_tickets.pop(ticket_key, None)
|
||||
raise lock_error(
|
||||
status.HTTP_403_FORBIDDEN,
|
||||
"JUPYTER_EDIT_SESSION_INACTIVE",
|
||||
@@ -823,20 +715,6 @@ async def reconcile_expired_edit_sessions(app_state: Any) -> None:
|
||||
)
|
||||
).all()
|
||||
for item in items:
|
||||
current, remaining_ms = await redis_current(
|
||||
app_state.state.redis_client,
|
||||
item.redis_lock_key,
|
||||
)
|
||||
if (
|
||||
current
|
||||
and current.get("edit_session_id")
|
||||
== item.edit_session_id
|
||||
and remaining_ms > 0
|
||||
):
|
||||
item.expires_at = now + timedelta(
|
||||
milliseconds=remaining_ms
|
||||
)
|
||||
continue
|
||||
try:
|
||||
await (
|
||||
app_state.state.runtime_lifecycle
|
||||
@@ -852,8 +730,16 @@ async def reconcile_expired_edit_sessions(app_state: Any) -> None:
|
||||
)
|
||||
item.session_status = "expired"
|
||||
item.ended_at = now
|
||||
item.end_reason = "redis_lease_expired"
|
||||
item.end_reason = "database_lease_expired"
|
||||
await session.commit()
|
||||
async with app_state.state.ticket_lock:
|
||||
expired_keys = [
|
||||
key
|
||||
for key, value in app_state.state.jupyter_tickets.items()
|
||||
if value["expires_at"] <= now
|
||||
]
|
||||
for key in expired_keys:
|
||||
app_state.state.jupyter_tickets.pop(key, None)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
@@ -1011,12 +897,6 @@ async def stop_runtime(
|
||||
)
|
||||
).all()
|
||||
for edit_session in active_sessions:
|
||||
await redis_release(
|
||||
request.app.state.redis_client,
|
||||
key=edit_session.redis_lock_key,
|
||||
edit_session_id=edit_session.edit_session_id,
|
||||
token_hash=edit_session.lock_token_hash.hex(),
|
||||
)
|
||||
try:
|
||||
await request.app.state.runtime_lifecycle.terminate_session(
|
||||
runtime_id,
|
||||
|
||||
+3
-4
@@ -1,12 +1,11 @@
|
||||
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 schedule ./schedule
|
||||
RUN uv sync --frozen --no-dev --no-editable --package schedule
|
||||
RUN uv pip install --system ./common ./schedule
|
||||
|
||||
EXPOSE 8000
|
||||
CMD ["uv", "run", "--frozen", "--package", "schedule", "uvicorn", "schedule.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
CMD ["uvicorn", "schedule.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
|
||||
+8
-3
@@ -1,4 +1,9 @@
|
||||
# Schedule
|
||||
# Schedule Executor
|
||||
|
||||
独立调度执行服务。负责 Outbox/Redis Streams 轮询、DAG 节点派发、稳定版本
|
||||
执行、重试、状态推进以及日志和结果回写。
|
||||
独立调度执行服务,内置 APScheduler。
|
||||
|
||||
- Cron 任务持久化到 MySQL 的 `apscheduler_jobs` 表;
|
||||
- FastAPI Backend 创建运行记录和 Outbox 事件后,通过 HTTP 尝试立即推送;
|
||||
- HTTP 推送失败时,Executor 继续轮询 MySQL `outbox_events`,保证任务不会丢失;
|
||||
- Executor 负责 DAG 节点派发、稳定版本执行、重试、状态推进和结果回写;
|
||||
- 不依赖 Redis,MySQL 是调度状态与幂等状态的唯一权威。
|
||||
|
||||
@@ -7,14 +7,15 @@ dependencies = [
|
||||
"fastapi==0.116.1",
|
||||
"uvicorn[standard]==0.35.0",
|
||||
"httpx==0.28.1",
|
||||
"redis==5.2.1",
|
||||
"apscheduler==3.11.3",
|
||||
"pymysql==1.2.0",
|
||||
"nbclient==0.10.2",
|
||||
"nbformat==5.10.4",
|
||||
"ipykernel==6.29.5",
|
||||
]
|
||||
|
||||
[tool.uv.sources]
|
||||
common = { workspace = true }
|
||||
common = { path = "../common" }
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
|
||||
@@ -1,38 +1,52 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import secrets
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from typing import Any, AsyncIterator
|
||||
|
||||
from fastapi import Depends, Header, HTTPException, Request, status
|
||||
|
||||
from common.db import create_database_engine, create_session_factory
|
||||
from common.service_app import create_service_app
|
||||
from schedule.service import (
|
||||
SchedulerService,
|
||||
build_object_store,
|
||||
build_redis_client,
|
||||
build_storage_http_client,
|
||||
)
|
||||
from schedule.storage_client import SchedulerStorageClient
|
||||
|
||||
|
||||
def verify_internal_service(
|
||||
x_service_token: str = Header(alias="X-Service-Token"),
|
||||
) -> None:
|
||||
expected = os.environ.get("INTERNAL_SERVICE_TOKEN", "")
|
||||
if not expected or not secrets.compare_digest(expected, x_service_token):
|
||||
raise HTTPException(
|
||||
status.HTTP_401_UNAUTHORIZED,
|
||||
"invalid internal service identity",
|
||||
)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: Any) -> AsyncIterator[None]:
|
||||
engine = create_database_engine(os.environ["DATABASE_URL"])
|
||||
database_url = os.environ["DATABASE_URL"]
|
||||
engine = create_database_engine(database_url)
|
||||
session_factory = create_session_factory(engine)
|
||||
redis = build_redis_client()
|
||||
storage_http_client = build_storage_http_client()
|
||||
backend_http_client = build_storage_http_client()
|
||||
service = SchedulerService(
|
||||
session_factory=session_factory,
|
||||
redis=redis,
|
||||
object_store=build_object_store(),
|
||||
storage_client=SchedulerStorageClient(
|
||||
storage_http_client,
|
||||
backend_http_client,
|
||||
os.environ["INTERNAL_SERVICE_TOKEN"],
|
||||
),
|
||||
backend_http_client=backend_http_client,
|
||||
workspace_root=Path(
|
||||
os.getenv("WORKSPACE_ROOT", "/workspace/workspaces")
|
||||
),
|
||||
database_url=database_url,
|
||||
)
|
||||
app.state.scheduler_service = service
|
||||
await service.start()
|
||||
@@ -40,12 +54,24 @@ async def lifespan(app: Any) -> AsyncIterator[None]:
|
||||
yield
|
||||
finally:
|
||||
await service.close()
|
||||
await storage_http_client.aclose()
|
||||
await redis.aclose()
|
||||
await backend_http_client.aclose()
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
app = create_service_app(
|
||||
os.getenv("SERVICE_NAME", "scheduler-worker"),
|
||||
os.getenv("SERVICE_NAME", "schedule-executor"),
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
|
||||
@app.post(
|
||||
"/internal/v1/runs/{run_id}/dispatch",
|
||||
dependencies=[Depends(verify_internal_service)],
|
||||
)
|
||||
async def dispatch_run(run_id: str, request: Request) -> dict[str, Any]:
|
||||
processed = await request.app.state.scheduler_service.dispatch_run(run_id)
|
||||
return {
|
||||
"status": "accepted",
|
||||
"run_id": run_id,
|
||||
"processed_events": processed,
|
||||
}
|
||||
|
||||
+174
-155
@@ -5,18 +5,18 @@ import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
import traceback
|
||||
from collections.abc import Awaitable, Callable
|
||||
from contextlib import suppress
|
||||
from datetime import timedelta
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import boto3
|
||||
import httpx
|
||||
from redis.asyncio import Redis
|
||||
from redis.exceptions import ResponseError
|
||||
from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
@@ -30,12 +30,7 @@ from common.db.models import (
|
||||
Versions,
|
||||
Workspaces,
|
||||
)
|
||||
from common.eventing import (
|
||||
STREAM_BY_EVENT_TYPE,
|
||||
add_outbox_event,
|
||||
event_time,
|
||||
utcnow,
|
||||
)
|
||||
from common.eventing import add_outbox_event, event_time, utcnow
|
||||
from common.ids import new_ulid
|
||||
from common.db.session import session_scope
|
||||
from schedule.execution import ExecutionResult, execute_artifact
|
||||
@@ -58,203 +53,236 @@ TERMINAL_RUN_STATES = {
|
||||
"timed_out",
|
||||
}
|
||||
|
||||
_ACTIVE_SERVICE: "SchedulerService | None" = None
|
||||
|
||||
|
||||
def _sync_database_url(value: str) -> str:
|
||||
return value.replace("mysql+asyncmy://", "mysql+pymysql://", 1)
|
||||
|
||||
|
||||
def _naive_utc(value: datetime | None) -> datetime | None:
|
||||
if value is None:
|
||||
return None
|
||||
if value.tzinfo is None:
|
||||
value = value.replace(tzinfo=UTC)
|
||||
return value.astimezone(UTC).replace(tzinfo=None)
|
||||
|
||||
|
||||
async def run_scheduled_job(schedule_id: str) -> None:
|
||||
service = _ACTIVE_SERVICE
|
||||
if service is None:
|
||||
LOGGER.warning("scheduler job skipped because service is not ready")
|
||||
return
|
||||
await service.trigger_schedule(schedule_id)
|
||||
|
||||
|
||||
class SchedulerService:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
session_factory: async_sessionmaker[AsyncSession],
|
||||
redis: Redis,
|
||||
object_store: Any,
|
||||
storage_client: SchedulerStorageClient,
|
||||
backend_http_client: httpx.AsyncClient,
|
||||
workspace_root: Path,
|
||||
database_url: str,
|
||||
) -> None:
|
||||
self.session_factory = session_factory
|
||||
self.redis = redis
|
||||
self.object_store = object_store
|
||||
self.storage_client = storage_client
|
||||
self.backend_http_client = backend_http_client
|
||||
self.workspace_root = workspace_root
|
||||
self.consumer_name = (
|
||||
os.getenv("SCHEDULER_CONSUMER_NAME")
|
||||
or f"{socket.gethostname()}-{os.getpid()}"
|
||||
)
|
||||
self.tasks: list[asyncio.Task[Any]] = []
|
||||
self.dispatch_lock = asyncio.Lock()
|
||||
self.scheduler = AsyncIOScheduler(
|
||||
jobstores={
|
||||
"default": SQLAlchemyJobStore(
|
||||
url=_sync_database_url(database_url),
|
||||
tablename="apscheduler_jobs",
|
||||
)
|
||||
},
|
||||
timezone=UTC,
|
||||
)
|
||||
|
||||
async def start(self) -> None:
|
||||
await self._ensure_group(
|
||||
"stream:scheduler:commands",
|
||||
"schedule-orchestrator",
|
||||
)
|
||||
await self._ensure_group("stream:jobs:execute", "job-workers")
|
||||
await self._ensure_group("stream:jobs:results", "schedule-results")
|
||||
global _ACTIVE_SERVICE
|
||||
_ACTIVE_SERVICE = self
|
||||
self.scheduler.start()
|
||||
await self._sync_cron_jobs()
|
||||
self.tasks = [
|
||||
asyncio.create_task(
|
||||
self._outbox_loop(),
|
||||
name="scheduler-outbox-publisher",
|
||||
self._database_event_loop(),
|
||||
name="scheduler-database-events",
|
||||
),
|
||||
asyncio.create_task(
|
||||
self._consumer_loop(
|
||||
"stream:scheduler:commands",
|
||||
"schedule-orchestrator",
|
||||
self._handle_run_requested,
|
||||
),
|
||||
name="schedule-orchestrator",
|
||||
),
|
||||
asyncio.create_task(
|
||||
self._consumer_loop(
|
||||
"stream:jobs:execute",
|
||||
"job-workers",
|
||||
self._handle_node_execute,
|
||||
),
|
||||
name="job-worker",
|
||||
),
|
||||
asyncio.create_task(
|
||||
self._consumer_loop(
|
||||
"stream:jobs:results",
|
||||
"schedule-results",
|
||||
self._handle_node_finished,
|
||||
),
|
||||
name="schedule-results",
|
||||
self._schedule_sync_loop(),
|
||||
name="scheduler-cron-sync",
|
||||
),
|
||||
]
|
||||
|
||||
async def close(self) -> None:
|
||||
global _ACTIVE_SERVICE
|
||||
for task in self.tasks:
|
||||
task.cancel()
|
||||
for task in self.tasks:
|
||||
with suppress(asyncio.CancelledError):
|
||||
await task
|
||||
self.tasks.clear()
|
||||
if self.scheduler.running:
|
||||
self.scheduler.shutdown(wait=False)
|
||||
_ACTIVE_SERVICE = None
|
||||
|
||||
async def _ensure_group(self, stream: str, group: str) -> None:
|
||||
try:
|
||||
await self.redis.xgroup_create(
|
||||
stream,
|
||||
group,
|
||||
id="0-0",
|
||||
mkstream=True,
|
||||
)
|
||||
except ResponseError as exc:
|
||||
if "BUSYGROUP" not in str(exc):
|
||||
raise
|
||||
|
||||
async def _outbox_loop(self) -> None:
|
||||
async def _database_event_loop(self) -> None:
|
||||
while True:
|
||||
try:
|
||||
published = await self._publish_outbox_batch()
|
||||
if not published:
|
||||
await asyncio.sleep(0.35)
|
||||
processed = await self.process_pending_events(limit=20)
|
||||
if not processed:
|
||||
await asyncio.sleep(0.25)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
LOGGER.exception("outbox publisher iteration failed")
|
||||
LOGGER.exception("database event loop failed")
|
||||
await asyncio.sleep(1)
|
||||
|
||||
async def _publish_outbox_batch(self) -> int:
|
||||
now = utcnow()
|
||||
async def process_pending_events(
|
||||
self,
|
||||
*,
|
||||
limit: int = 20,
|
||||
aggregate_id: str | None = None,
|
||||
) -> int:
|
||||
async with self.dispatch_lock:
|
||||
async with session_scope(self.session_factory) as session:
|
||||
events = list(
|
||||
(
|
||||
await session.scalars(
|
||||
statement = (
|
||||
select(OutboxEvents)
|
||||
.where(
|
||||
OutboxEvents.event_status == "pending",
|
||||
OutboxEvents.available_at <= now,
|
||||
OutboxEvents.available_at <= utcnow(),
|
||||
)
|
||||
.order_by(OutboxEvents.created_at)
|
||||
.limit(20)
|
||||
.with_for_update(skip_locked=True)
|
||||
.limit(limit)
|
||||
)
|
||||
).all()
|
||||
if aggregate_id:
|
||||
statement = statement.where(
|
||||
OutboxEvents.aggregate_id == aggregate_id
|
||||
)
|
||||
events = list((await session.scalars(statement)).all())
|
||||
for item in events:
|
||||
stream = STREAM_BY_EVENT_TYPE.get(item.event_type)
|
||||
if stream is None:
|
||||
item.event_status = "failed"
|
||||
item.last_error = f"unsupported event type: {item.event_type}"
|
||||
continue
|
||||
try:
|
||||
await self.redis.xadd(
|
||||
stream,
|
||||
{
|
||||
"event": json.dumps(
|
||||
item.payload_json,
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
},
|
||||
)
|
||||
await self._process_outbox_event(item)
|
||||
item.event_status = "published"
|
||||
item.published_at = utcnow()
|
||||
item.last_error = None
|
||||
except Exception as exc:
|
||||
item.retry_count += 1
|
||||
item.last_error = str(exc)[:2000]
|
||||
raise
|
||||
if item.retry_count >= 5:
|
||||
item.event_status = "failed"
|
||||
else:
|
||||
item.available_at = utcnow() + timedelta(
|
||||
seconds=min(30, 2 ** item.retry_count)
|
||||
)
|
||||
LOGGER.exception(
|
||||
"failed to process database event %s",
|
||||
item.event_id,
|
||||
)
|
||||
return len(events)
|
||||
|
||||
async def _consumer_loop(
|
||||
self,
|
||||
stream: str,
|
||||
group: str,
|
||||
handler: Callable[[dict[str, Any], str], Awaitable[None]],
|
||||
) -> None:
|
||||
async def _process_outbox_event(self, item: OutboxEvents) -> None:
|
||||
handlers = {
|
||||
"schedule.run.requested": self._handle_run_requested,
|
||||
"job.node.execute": self._handle_node_execute,
|
||||
"job.node.finished": self._handle_node_finished,
|
||||
}
|
||||
handler = handlers.get(item.event_type)
|
||||
if handler is None:
|
||||
raise ValueError(f"unsupported event type: {item.event_type}")
|
||||
await handler(item.payload_json, f"mysql:{item.event_id}")
|
||||
|
||||
async def dispatch_run(self, run_id: str) -> int:
|
||||
return await self.process_pending_events(
|
||||
limit=50,
|
||||
aggregate_id=run_id,
|
||||
)
|
||||
|
||||
async def _schedule_sync_loop(self) -> None:
|
||||
while True:
|
||||
try:
|
||||
messages = await self.redis.xreadgroup(
|
||||
group,
|
||||
self.consumer_name,
|
||||
{stream: ">"},
|
||||
count=5,
|
||||
block=1000,
|
||||
)
|
||||
entries: list[tuple[str, dict[str, str]]] = []
|
||||
for _, stream_messages in messages:
|
||||
entries.extend(stream_messages)
|
||||
if not entries:
|
||||
claimed = await self.redis.xautoclaim(
|
||||
stream,
|
||||
group,
|
||||
self.consumer_name,
|
||||
min_idle_time=10_000,
|
||||
start_id="0-0",
|
||||
count=5,
|
||||
)
|
||||
if len(claimed) >= 2:
|
||||
entries.extend(claimed[1])
|
||||
for message_id, fields in entries:
|
||||
try:
|
||||
raw = fields.get("event")
|
||||
if not raw:
|
||||
raise ValueError("stream message has no event field")
|
||||
event = json.loads(raw)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except (ValueError, TypeError, json.JSONDecodeError):
|
||||
LOGGER.exception(
|
||||
"discarding malformed message %s from %s",
|
||||
message_id,
|
||||
stream,
|
||||
)
|
||||
await self.redis.xack(stream, group, message_id)
|
||||
continue
|
||||
try:
|
||||
await handler(event, message_id)
|
||||
await self._sync_cron_jobs()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
LOGGER.exception(
|
||||
"consumer %s failed for message %s",
|
||||
group,
|
||||
message_id,
|
||||
LOGGER.exception("cron job synchronization failed")
|
||||
await asyncio.sleep(5)
|
||||
|
||||
async def _sync_cron_jobs(self) -> None:
|
||||
async with session_scope(self.session_factory) as session:
|
||||
schedules = list(
|
||||
(
|
||||
await session.scalars(
|
||||
select(Schedules).where(
|
||||
Schedules.deleted_at.is_(None),
|
||||
Schedules.enabled == 1,
|
||||
Schedules.trigger_type == "cron",
|
||||
Schedules.cron_expression.is_not(None),
|
||||
)
|
||||
)
|
||||
).all()
|
||||
)
|
||||
active_job_ids: set[str] = set()
|
||||
for item in schedules:
|
||||
job_id = f"schedule:{item.schedule_id}"
|
||||
active_job_ids.add(job_id)
|
||||
expression = (item.cron_expression or "").strip()
|
||||
trigger = CronTrigger.from_crontab(
|
||||
expression,
|
||||
timezone=ZoneInfo(item.timezone),
|
||||
)
|
||||
job = self.scheduler.add_job(
|
||||
run_scheduled_job,
|
||||
trigger=trigger,
|
||||
args=[item.schedule_id],
|
||||
id=job_id,
|
||||
replace_existing=True,
|
||||
coalesce=True,
|
||||
max_instances=max(1, item.max_concurrency),
|
||||
misfire_grace_time=60,
|
||||
)
|
||||
item.next_run_at = _naive_utc(job.next_run_time)
|
||||
for job in self.scheduler.get_jobs():
|
||||
if job.id.startswith("schedule:") and job.id not in active_job_ids:
|
||||
self.scheduler.remove_job(job.id)
|
||||
|
||||
async def trigger_schedule(self, schedule_id: str) -> None:
|
||||
async with self.session_factory() as session:
|
||||
item = await session.get(Schedules, schedule_id)
|
||||
if (
|
||||
item is None
|
||||
or item.deleted_at is not None
|
||||
or not item.enabled
|
||||
or item.trigger_type != "cron"
|
||||
):
|
||||
return
|
||||
user_id = item.created_by
|
||||
workspace_id = item.workspace_id
|
||||
now = datetime.now(UTC)
|
||||
idempotency_key = (
|
||||
f"cron:{schedule_id}:{now.strftime('%Y%m%d%H%M')}"
|
||||
)
|
||||
response = await self.backend_http_client.post(
|
||||
f"/api/v1/schedules/{schedule_id}/run",
|
||||
headers={
|
||||
"X-User-ID": user_id,
|
||||
"X-Workspace-ID": workspace_id,
|
||||
"X-Request-ID": new_ulid(),
|
||||
"Idempotency-Key": idempotency_key,
|
||||
},
|
||||
json={"reason": "cron"},
|
||||
)
|
||||
if response.is_error:
|
||||
raise RuntimeError(
|
||||
f"backend rejected cron run: {response.status_code} "
|
||||
f"{response.text[:500]}"
|
||||
)
|
||||
continue
|
||||
await self.redis.xack(stream, group, message_id)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
LOGGER.exception("consumer loop %s failed", group)
|
||||
await asyncio.sleep(1)
|
||||
|
||||
async def _start_inbox(
|
||||
self,
|
||||
@@ -874,15 +902,6 @@ class SchedulerService:
|
||||
self._finish_inbox(inbox)
|
||||
|
||||
|
||||
def build_redis_client() -> Redis:
|
||||
return Redis(
|
||||
host=os.getenv("REDIS_HOST", "redis"),
|
||||
port=int(os.getenv("REDIS_PORT", "6379")),
|
||||
password=os.getenv("REDIS_PASSWORD") or None,
|
||||
decode_responses=True,
|
||||
)
|
||||
|
||||
|
||||
def build_object_store() -> Any:
|
||||
return boto3.client(
|
||||
"s3",
|
||||
@@ -898,6 +917,6 @@ def build_object_store() -> Any:
|
||||
|
||||
def build_storage_http_client() -> httpx.AsyncClient:
|
||||
return httpx.AsyncClient(
|
||||
base_url=os.getenv("STORAGE_API_URL", "http://storage_api:8000"),
|
||||
base_url=os.getenv("BACKEND_API_URL", "http://backend:8000"),
|
||||
timeout=httpx.Timeout(60.0),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
Set-Location (Split-Path $PSScriptRoot -Parent)
|
||||
docker compose logs -f backend runtime schedule gateway
|
||||
@@ -0,0 +1,12 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
Set-Location (Split-Path $PSScriptRoot -Parent)
|
||||
|
||||
if (-not (Test-Path ".env")) {
|
||||
Copy-Item ".env.example" ".env"
|
||||
Write-Host "已从 .env.example 创建 .env,请按需修改密码。"
|
||||
}
|
||||
|
||||
docker compose config | Out-Null
|
||||
docker compose up -d --build
|
||||
docker compose ps
|
||||
Write-Host "访问地址:http://127.0.0.1:8081"
|
||||
@@ -0,0 +1,3 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
Set-Location (Split-Path $PSScriptRoot -Parent)
|
||||
docker compose down
|
||||
Reference in New Issue
Block a user