Develop #16
+8
-1
@@ -17,6 +17,12 @@ DATABASE_URL=mysql+asyncmy://root:change-me@127.0.0.1:3306/model_platform?charse
|
|||||||
DEMO_AUTH_ENABLED=true
|
DEMO_AUTH_ENABLED=true
|
||||||
JWT_SECRET=change-this-development-secret
|
JWT_SECRET=change-this-development-secret
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# CRITICAL: must set BEFORE first run. The initial admin user is seeded by
|
||||||
|
# the deployment bootstrap. Never keep the development default in production.
|
||||||
|
# ============================================================================
|
||||||
|
INITIAL_ADMIN_PASSWORD=admin12345
|
||||||
|
|
||||||
# Object storage (S3-compatible, RustFS).
|
# Object storage (S3-compatible, RustFS).
|
||||||
# RUSTFS_ENDPOINT is the single upstream URL consumed by all 4 services:
|
# RUSTFS_ENDPOINT is the single upstream URL consumed by all 4 services:
|
||||||
# - nginx (via scripts/nginx-entrypoint.sh, which parses host + port)
|
# - nginx (via scripts/nginx-entrypoint.sh, which parses host + port)
|
||||||
@@ -36,4 +42,5 @@ RUSTFS_SECRET_KEY=change-me
|
|||||||
RUSTFS_WORKSPACE_BUCKET=workspaces
|
RUSTFS_WORKSPACE_BUCKET=workspaces
|
||||||
RUSTFS_VERSION_BUCKET=versions
|
RUSTFS_VERSION_BUCKET=versions
|
||||||
RUSTFS_RUN_LOG_BUCKET=run-logs
|
RUSTFS_RUN_LOG_BUCKET=run-logs
|
||||||
|
RUSTFS_TRASH_BUCKET=trash
|
||||||
|
RUSTFS_TRASH_RETENTION_DAYS=30
|
||||||
|
|||||||
+4
-4
@@ -8,10 +8,10 @@ WORKDIR /app
|
|||||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv
|
COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv
|
||||||
|
|
||||||
RUN ( \
|
RUN ( \
|
||||||
sed -i 's/deb.debian.org/mirrors.cloud.aliyuncs.com/g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \
|
sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \
|
||||||
sed -i 's/deb.debian.org/mirrors.cloud.aliyuncs.com/g' /etc/apt/sources.list 2>/dev/null || \
|
sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list 2>/dev/null || \
|
||||||
sed -i 's/archive.ubuntu.com/mirrors.cloud.aliyuncs.com/g' /etc/apt/sources.list 2>/dev/null || \
|
sed -i 's/archive.ubuntu.com/mirrors.aliyun.com/g' /etc/apt/sources.list 2>/dev/null || \
|
||||||
sed -i 's/security.ubuntu.com/mirrors.cloud.aliyuncs.com/g' /etc/apt/sources.list 2>/dev/null \
|
sed -i 's/security.ubuntu.com/mirrors.aliyun.com/g' /etc/apt/sources.list 2>/dev/null \
|
||||||
) || ( \
|
) || ( \
|
||||||
sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \
|
sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \
|
||||||
sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list 2>/dev/null || \
|
sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list 2>/dev/null || \
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ dependencies = [
|
|||||||
"alembic==1.18.5",
|
"alembic==1.18.5",
|
||||||
"cryptography==49.0.0",
|
"cryptography==49.0.0",
|
||||||
"gunicorn>=26.0.0",
|
"gunicorn>=26.0.0",
|
||||||
|
"passlib==1.7.4",
|
||||||
|
"bcrypt>=4.0,<4.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.uv.sources]
|
[tool.uv.sources]
|
||||||
|
|||||||
@@ -35,18 +35,18 @@ router = APIRouter(tags=["auth"])
|
|||||||
# (future extension).
|
# (future extension).
|
||||||
COOKIE_NAME = "access_token"
|
COOKIE_NAME = "access_token"
|
||||||
COOKIE_TTL_SECONDS = 24 * 60 * 60
|
COOKIE_TTL_SECONDS = 24 * 60 * 60
|
||||||
COOKIE_SECURE = True
|
|
||||||
COOKIE_SAMESITE = "lax"
|
COOKIE_SAMESITE = "lax"
|
||||||
|
|
||||||
|
|
||||||
def _set_session_cookie(response: Response, token: str) -> None:
|
def _set_session_cookie(request: Request, response: Response, token: str) -> None:
|
||||||
|
forwarded_scheme = request.headers.get("x-forwarded-proto", request.url.scheme)
|
||||||
response.set_cookie(
|
response.set_cookie(
|
||||||
key=COOKIE_NAME,
|
key=COOKIE_NAME,
|
||||||
value=token,
|
value=token,
|
||||||
max_age=COOKIE_TTL_SECONDS,
|
max_age=COOKIE_TTL_SECONDS,
|
||||||
path="/",
|
path="/",
|
||||||
httponly=True,
|
httponly=True,
|
||||||
secure=COOKIE_SECURE,
|
secure=forwarded_scheme == "https",
|
||||||
samesite=COOKIE_SAMESITE,
|
samesite=COOKIE_SAMESITE,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -159,7 +159,7 @@ async def login(
|
|||||||
user_role_code = rows[0][1].role_code
|
user_role_code = rows[0][1].role_code
|
||||||
|
|
||||||
token = issue_jwt(user.user_id, ttl_seconds=COOKIE_TTL_SECONDS)
|
token = issue_jwt(user.user_id, ttl_seconds=COOKIE_TTL_SECONDS)
|
||||||
_set_session_cookie(response, token)
|
_set_session_cookie(request, response, token)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"request_id": new_ulid(),
|
"request_id": new_ulid(),
|
||||||
|
|||||||
@@ -1,22 +1,48 @@
|
|||||||
|
"""FastAPI dependencies for the public API.
|
||||||
|
|
||||||
|
Two distinct concerns live here:
|
||||||
|
|
||||||
|
* :func:`database_session` — yields a transactional ``AsyncSession``
|
||||||
|
bound to the request's app-state factory. The session commits on
|
||||||
|
success and rolls back on exception via ``common.db.session_scope``.
|
||||||
|
|
||||||
|
* :func:`request_context` — verifies the ``access_token`` cookie
|
||||||
|
(HS256-signed JWT), then loads the user's active membership for the
|
||||||
|
``workspace_id`` query parameter. Returns a :class:`RequestContext`
|
||||||
|
dataclass that downstream handlers read for identity, role, and the
|
||||||
|
request id.
|
||||||
|
|
||||||
|
The 56 ``Depends(request_context)`` call sites elsewhere in the
|
||||||
|
backend expect this exact dataclass shape; the workspace and role are
|
||||||
|
always present, so handlers do not need to handle the "no workspace
|
||||||
|
selected" case.
|
||||||
|
|
||||||
|
Routes that need a workspace context must declare the query parameter
|
||||||
|
explicitly, even when the path also contains a resource id (e.g.
|
||||||
|
``/schedules/{schedule_id}``). This keeps authorization decisions
|
||||||
|
local to the request and prevents the "implicit workspace" footgun
|
||||||
|
where a path-resource lookup quietly overrides what the user asked
|
||||||
|
for.
|
||||||
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import AsyncIterator
|
from typing import AsyncIterator
|
||||||
|
|
||||||
from fastapi import Depends, Header, HTTPException, Request, status
|
from fastapi import Depends, HTTPException, Query, Request, status
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from common.auth.jwt import JwtError, verify_jwt_token
|
||||||
|
from common.auth.membership import MembershipError, load_active_membership
|
||||||
from common.db import session_scope
|
from common.db import session_scope
|
||||||
from common.db.models import (
|
from common.db.models import Roles, Users, Workspaces
|
||||||
Roles,
|
|
||||||
Users,
|
|
||||||
WorkspaceMembers,
|
|
||||||
Workspaces,
|
|
||||||
)
|
|
||||||
from common.ids import new_ulid
|
from common.ids import new_ulid
|
||||||
|
|
||||||
|
|
||||||
|
ACCESS_TOKEN_COOKIE = "access_token"
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class RequestContext:
|
class RequestContext:
|
||||||
request_id: str
|
request_id: str
|
||||||
@@ -34,41 +60,65 @@ async def database_session(request: Request) -> AsyncIterator[AsyncSession]:
|
|||||||
yield session
|
yield session
|
||||||
|
|
||||||
|
|
||||||
|
async def current_user(
|
||||||
|
request: Request,
|
||||||
|
session: AsyncSession = Depends(database_session),
|
||||||
|
) -> Users:
|
||||||
|
"""Resolve the authenticated user from the ``access_token`` cookie.
|
||||||
|
|
||||||
|
Returns the active ``Users`` row or raises 401. Does NOT load a
|
||||||
|
workspace — use :func:`request_context` for handlers that need
|
||||||
|
a workspace-scoped context, or use ``Depends(current_user)`` for
|
||||||
|
workspace-agnostic endpoints (e.g. ``/api/v1/auth/me``).
|
||||||
|
"""
|
||||||
|
token = request.cookies.get(ACCESS_TOKEN_COOKIE)
|
||||||
|
if not token:
|
||||||
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "not authenticated")
|
||||||
|
try:
|
||||||
|
payload = verify_jwt_token(token)
|
||||||
|
except JwtError as exc:
|
||||||
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, str(exc)) from exc
|
||||||
|
user_id = payload.get("sub")
|
||||||
|
if not user_id:
|
||||||
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "invalid token")
|
||||||
|
user = await session.get(Users, user_id)
|
||||||
|
if user is None or user.status != "active":
|
||||||
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "user not found")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
async def request_context(
|
async def request_context(
|
||||||
request: Request,
|
request: Request,
|
||||||
x_user_id: str = Header(alias="X-User-ID"),
|
workspace_id: str = Query(
|
||||||
x_workspace_id: str = Header(alias="X-Workspace-ID"),
|
...,
|
||||||
x_request_id: str | None = Header(default=None, alias="X-Request-ID"),
|
min_length=26,
|
||||||
|
max_length=26,
|
||||||
|
description="Workspace context for this request (CHAR(26) ULID).",
|
||||||
|
),
|
||||||
|
session: AsyncSession = Depends(database_session),
|
||||||
) -> RequestContext:
|
) -> RequestContext:
|
||||||
async with request.app.state.session_factory() as session:
|
"""Verify JWT and load the user's active membership for ``workspace_id``.
|
||||||
statement = (
|
|
||||||
select(Users, Workspaces, Roles)
|
The ``request_id`` comes from the ``X-Request-ID`` header if
|
||||||
.join(
|
present, else from a freshly minted ULID. Handlers receive the
|
||||||
WorkspaceMembers,
|
same :class:`RequestContext` shape they had under the old
|
||||||
WorkspaceMembers.user_id == Users.user_id,
|
header-based implementation, so the 56 ``Depends(request_context)``
|
||||||
|
call sites in this repo stay working without changes — they just
|
||||||
|
now pass ``?workspace_id=`` instead of the old headers.
|
||||||
|
"""
|
||||||
|
user = await current_user(request, session)
|
||||||
|
try:
|
||||||
|
_user, workspace, role = await load_active_membership(
|
||||||
|
session, user.user_id, workspace_id,
|
||||||
)
|
)
|
||||||
.join(
|
except MembershipError as exc:
|
||||||
Workspaces,
|
|
||||||
Workspaces.workspace_id == WorkspaceMembers.workspace_id,
|
|
||||||
)
|
|
||||||
.join(Roles, Roles.role_id == WorkspaceMembers.role_id)
|
|
||||||
.where(
|
|
||||||
Users.user_id == x_user_id,
|
|
||||||
Users.status == "active",
|
|
||||||
WorkspaceMembers.workspace_id == x_workspace_id,
|
|
||||||
WorkspaceMembers.member_status == "active",
|
|
||||||
Workspaces.status == "active",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
row = (await session.execute(statement)).one_or_none()
|
|
||||||
if row is None:
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status.HTTP_403_FORBIDDEN,
|
status.HTTP_403_FORBIDDEN,
|
||||||
"active workspace membership is required",
|
"active workspace membership is required",
|
||||||
)
|
) from exc
|
||||||
user, workspace, role = row
|
request_id = request.headers.get("X-Request-ID") or new_ulid()
|
||||||
return RequestContext(
|
return RequestContext(
|
||||||
request_id=x_request_id or new_ulid(),
|
request_id=request_id,
|
||||||
user=user,
|
user=user,
|
||||||
workspace=workspace,
|
workspace=workspace,
|
||||||
role=role,
|
role=role,
|
||||||
|
|||||||
+24
-175
@@ -1,129 +1,24 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import base64
|
|
||||||
import hashlib
|
|
||||||
import hmac
|
|
||||||
import json
|
|
||||||
import re
|
import re
|
||||||
import time
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from backend.dependencies import (
|
|
||||||
RequestContext,
|
|
||||||
database_session,
|
|
||||||
request_context,
|
|
||||||
)
|
|
||||||
from backend.runtime_client import RuntimeClientError
|
|
||||||
from common.config import settings
|
|
||||||
from common.db.models import Scripts, Users, WorkspaceMembers, Workspaces
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from backend.dependencies import database_session
|
||||||
|
from backend.runtime_client import RuntimeClientError
|
||||||
|
from common.auth.jwt import JwtError, verify_jwt_token
|
||||||
|
from common.auth.membership import MembershipError, load_active_membership
|
||||||
|
from common.db.models import Scripts
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(tags=["jupyter"])
|
router = APIRouter(tags=["jupyter"])
|
||||||
security = HTTPBearer(auto_error=False)
|
security = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
|
|
||||||
JWT_SECRET = settings.jwt_secret
|
|
||||||
JWT_ALGORITHM = "HS256"
|
|
||||||
|
|
||||||
|
|
||||||
def _b64decode(value: str) -> bytes:
|
|
||||||
padding = "=" * (-len(value) % 4)
|
|
||||||
return base64.urlsafe_b64decode(value + padding)
|
|
||||||
|
|
||||||
|
|
||||||
def _b64encode(value: bytes) -> str:
|
|
||||||
return base64.urlsafe_b64encode(value).rstrip(b"=").decode()
|
|
||||||
|
|
||||||
|
|
||||||
def create_jwt_token(user_id: str, *, expires_seconds: int = 3600) -> str:
|
|
||||||
header = _b64encode(
|
|
||||||
json.dumps(
|
|
||||||
{"alg": JWT_ALGORITHM, "typ": "JWT"},
|
|
||||||
separators=(",", ":"),
|
|
||||||
).encode()
|
|
||||||
)
|
|
||||||
expires_at = int(time.time()) + expires_seconds
|
|
||||||
payload = _b64encode(
|
|
||||||
json.dumps(
|
|
||||||
{"sub": user_id, "exp": expires_at},
|
|
||||||
separators=(",", ":"),
|
|
||||||
).encode()
|
|
||||||
)
|
|
||||||
signing_input = f"{header}.{payload}".encode()
|
|
||||||
signature = _b64encode(
|
|
||||||
hmac.new(
|
|
||||||
JWT_SECRET.encode(),
|
|
||||||
signing_input,
|
|
||||||
hashlib.sha256,
|
|
||||||
).digest()
|
|
||||||
)
|
|
||||||
return f"{header}.{payload}.{signature}"
|
|
||||||
|
|
||||||
|
|
||||||
def verify_jwt_token(token: str) -> dict:
|
|
||||||
"""Parse a signed JWT from the access_token cookie.
|
|
||||||
|
|
||||||
Returns a payload dict containing at least ``sub`` (user_id) and
|
|
||||||
``exp``; raises 401 on missing, malformed, or expired tokens. The
|
|
||||||
current implementation is intentionally minimal — replace with a
|
|
||||||
real verification once a public-key/HSM source is wired in.
|
|
||||||
"""
|
|
||||||
if not token:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=401,
|
|
||||||
detail="Missing Authentication Token",
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
header_b64, payload_b64, signature_b64 = token.split(".", 2)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=401,
|
|
||||||
detail="Invalid Authentication Token",
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
signing_input = f"{header_b64}.{payload_b64}".encode()
|
|
||||||
expected = hmac.new(
|
|
||||||
JWT_SECRET.encode(),
|
|
||||||
signing_input,
|
|
||||||
hashlib.sha256,
|
|
||||||
).digest()
|
|
||||||
try:
|
|
||||||
signature = _b64decode(signature_b64)
|
|
||||||
except Exception as exc: # pragma: no cover - malformed b64
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=401,
|
|
||||||
detail="Invalid Authentication Token",
|
|
||||||
) from exc
|
|
||||||
if not hmac.compare_digest(expected, signature):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=401,
|
|
||||||
detail="Invalid Authentication Token",
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
payload = json.loads(_b64decode(payload_b64))
|
|
||||||
except Exception as exc:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=401,
|
|
||||||
detail="Invalid Authentication Token",
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
exp = payload.get("exp")
|
|
||||||
if not isinstance(exp, (int, float)) or exp < time.time():
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=401,
|
|
||||||
detail="Token expired",
|
|
||||||
)
|
|
||||||
|
|
||||||
return payload
|
|
||||||
|
|
||||||
|
|
||||||
def extract_notebook_path(
|
def extract_notebook_path(
|
||||||
uri: str,
|
uri: str,
|
||||||
workspace_id: str,
|
workspace_id: str,
|
||||||
@@ -168,75 +63,23 @@ async def check_notebook_is_locked(
|
|||||||
return bool(is_locked)
|
return bool(is_locked)
|
||||||
|
|
||||||
|
|
||||||
async def load_active_membership(
|
async def load_active_membership_or_403(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
user_id: str,
|
user_id: str,
|
||||||
workspace_id: str,
|
workspace_id: str,
|
||||||
) -> tuple[Users, Workspaces]:
|
):
|
||||||
"""Resolve the user's active membership in the workspace."""
|
"""Resolve the user's active membership, raising 403 if missing.
|
||||||
statement = (
|
|
||||||
select(Users, Workspaces)
|
Thin wrapper around :func:`common.auth.membership.load_active_membership`
|
||||||
.join(
|
that maps the library's ``MembershipError`` to a FastAPI 403.
|
||||||
WorkspaceMembers,
|
"""
|
||||||
WorkspaceMembers.user_id == Users.user_id,
|
try:
|
||||||
)
|
return await load_active_membership(session, user_id, workspace_id)
|
||||||
.join(
|
except MembershipError as exc:
|
||||||
Workspaces,
|
|
||||||
Workspaces.workspace_id == WorkspaceMembers.workspace_id,
|
|
||||||
)
|
|
||||||
.where(
|
|
||||||
Users.user_id == user_id,
|
|
||||||
Users.status == "active",
|
|
||||||
WorkspaceMembers.workspace_id == workspace_id,
|
|
||||||
WorkspaceMembers.member_status == "active",
|
|
||||||
Workspaces.status == "active",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
row = (await session.execute(statement)).one_or_none()
|
|
||||||
if row is None:
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=403,
|
status_code=403,
|
||||||
detail="active workspace membership is required",
|
detail="active workspace membership is required",
|
||||||
)
|
) from exc
|
||||||
return row
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/v1/auth/demo-session")
|
|
||||||
async def create_demo_session(
|
|
||||||
request: Request,
|
|
||||||
response: Response,
|
|
||||||
context: RequestContext = Depends(request_context),
|
|
||||||
) -> dict:
|
|
||||||
"""Issue the short-lived HttpOnly cookie used by the self-hosted UI.
|
|
||||||
|
|
||||||
The normal request-context check still requires an active user/workspace
|
|
||||||
membership. This endpoint is disabled by default and must be explicitly
|
|
||||||
enabled by the deployment configuration.
|
|
||||||
"""
|
|
||||||
if not settings.demo_auth_enabled:
|
|
||||||
raise HTTPException(status_code=404, detail="Not Found")
|
|
||||||
expires_seconds = 3600
|
|
||||||
response.set_cookie(
|
|
||||||
"access_token",
|
|
||||||
create_jwt_token(
|
|
||||||
context.user.user_id,
|
|
||||||
expires_seconds=expires_seconds,
|
|
||||||
),
|
|
||||||
max_age=expires_seconds,
|
|
||||||
httponly=True,
|
|
||||||
secure=request.url.scheme == "https",
|
|
||||||
samesite="lax",
|
|
||||||
path="/",
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"request_id": context.request_id,
|
|
||||||
"data": {
|
|
||||||
"user_id": context.user.user_id,
|
|
||||||
"workspace_id": context.workspace.workspace_id,
|
|
||||||
"expires_at": int(time.time()) + expires_seconds,
|
|
||||||
},
|
|
||||||
"meta": {},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/v1/auth/jupyter")
|
@router.get("/api/v1/auth/jupyter")
|
||||||
@@ -266,7 +109,13 @@ async def verify_jupyter_access(
|
|||||||
cookie_token = request.cookies.get("access_token")
|
cookie_token = request.cookies.get("access_token")
|
||||||
bearer_token = auth.credentials if auth else None
|
bearer_token = auth.credentials if auth else None
|
||||||
token = cookie_token or bearer_token
|
token = cookie_token or bearer_token
|
||||||
|
try:
|
||||||
payload = verify_jwt_token(token)
|
payload = verify_jwt_token(token)
|
||||||
|
except JwtError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=401,
|
||||||
|
detail=str(exc),
|
||||||
|
) from exc
|
||||||
user_id = payload.get("sub")
|
user_id = payload.get("sub")
|
||||||
if not user_id:
|
if not user_id:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -274,7 +123,7 @@ async def verify_jupyter_access(
|
|||||||
detail="Invalid Authentication Token",
|
detail="Invalid Authentication Token",
|
||||||
)
|
)
|
||||||
|
|
||||||
await load_active_membership(session, user_id, workspace_id)
|
await load_active_membership_or_403(session, user_id, workspace_id)
|
||||||
|
|
||||||
notebook_path = extract_notebook_path(original_uri, workspace_id)
|
notebook_path = extract_notebook_path(original_uri, workspace_id)
|
||||||
if notebook_path and await check_notebook_is_locked(
|
if notebook_path and await check_notebook_is_locked(
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from common.db import create_database_engine, create_session_factory
|
|||||||
from common.service_app import create_service_app
|
from common.service_app import create_service_app
|
||||||
from common.storage import RustFSObjectStore
|
from common.storage import RustFSObjectStore
|
||||||
from backend.admin import router as admin_router
|
from backend.admin import router as admin_router
|
||||||
|
from backend.auth import router as auth_router
|
||||||
from backend.jupyter import router as jupyter_router
|
from backend.jupyter import router as jupyter_router
|
||||||
from backend.resources import router as resources_router
|
from backend.resources import router as resources_router
|
||||||
from backend.runtime_client import RuntimeClient
|
from backend.runtime_client import RuntimeClient
|
||||||
@@ -34,12 +35,13 @@ async def lifespan(app: Any) -> AsyncIterator[None]:
|
|||||||
access_key=settings.rustfs_access_key,
|
access_key=settings.rustfs_access_key,
|
||||||
secret_key=settings.rustfs_secret_key,
|
secret_key=settings.rustfs_secret_key,
|
||||||
)
|
)
|
||||||
# Ensure all three purpose-named buckets exist; the storage edge picks
|
# Ensure all four purpose-named buckets exist; the storage edge picks
|
||||||
# the right one per upload (see resolve_bucket in storage_api.py).
|
# the right one per upload (see resolve_bucket in storage_api.py).
|
||||||
for bucket in (
|
for bucket in (
|
||||||
settings.rustfs_workspace_bucket,
|
settings.rustfs_workspace_bucket,
|
||||||
settings.rustfs_version_bucket,
|
settings.rustfs_version_bucket,
|
||||||
settings.rustfs_run_log_bucket,
|
settings.rustfs_run_log_bucket,
|
||||||
|
settings.rustfs_trash_bucket,
|
||||||
):
|
):
|
||||||
await asyncio.to_thread(
|
await asyncio.to_thread(
|
||||||
app.state.object_store.ensure_bucket,
|
app.state.object_store.ensure_bucket,
|
||||||
@@ -69,6 +71,7 @@ app = create_service_app(
|
|||||||
settings.service_name,
|
settings.service_name,
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
|
app.include_router(auth_router)
|
||||||
app.include_router(jupyter_router)
|
app.include_router(jupyter_router)
|
||||||
app.include_router(resources_router)
|
app.include_router(resources_router)
|
||||||
app.include_router(schedule_runs_router)
|
app.include_router(schedule_runs_router)
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import hashlib
|
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
|
|
||||||
@@ -13,19 +12,22 @@ from common.db.models import (
|
|||||||
ScheduleNodeRuns,
|
ScheduleNodeRuns,
|
||||||
ScheduleRuns,
|
ScheduleRuns,
|
||||||
)
|
)
|
||||||
from common.eventing import add_outbox_event, utcnow
|
|
||||||
from common.ids import new_ulid
|
|
||||||
from backend.dependencies import (
|
from backend.dependencies import (
|
||||||
RequestContext,
|
RequestContext,
|
||||||
database_session,
|
database_session,
|
||||||
request_context,
|
request_context,
|
||||||
)
|
)
|
||||||
from common.schemas import StrictModel
|
from common.schemas import StrictModel
|
||||||
from backend.schedules import (
|
from common.scheduler import (
|
||||||
graph_rows,
|
DagTooLarge,
|
||||||
schedule_row,
|
InvalidDag,
|
||||||
validate_dag,
|
InvalidNodeArguments,
|
||||||
|
ScheduleNotFound,
|
||||||
|
TriggerError,
|
||||||
|
create_scheduled_run,
|
||||||
|
normalize_idempotency_key,
|
||||||
)
|
)
|
||||||
|
from common.ids import new_ulid
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(tags=["schedule-runs"])
|
router = APIRouter(tags=["schedule-runs"])
|
||||||
@@ -51,46 +53,23 @@ def _iso(value: datetime | None) -> str | None:
|
|||||||
return value.astimezone(UTC).isoformat()
|
return value.astimezone(UTC).isoformat()
|
||||||
|
|
||||||
|
|
||||||
def _normalized_idempotency_key(
|
def _http_error_from_trigger(exc: TriggerError) -> HTTPException:
|
||||||
workspace_id: str,
|
if isinstance(exc, ScheduleNotFound):
|
||||||
schedule_id: str,
|
return HTTPException(status.HTTP_404_NOT_FOUND, str(exc))
|
||||||
value: str,
|
if isinstance(exc, InvalidDag):
|
||||||
) -> str:
|
return HTTPException(
|
||||||
normalized = value.strip()
|
status.HTTP_409_CONFLICT,
|
||||||
if len(normalized) < 8:
|
detail={
|
||||||
raise HTTPException(
|
"code": "SCHEDULE_DAG_INVALID",
|
||||||
status.HTTP_422_UNPROCESSABLE_ENTITY,
|
"message": str(exc),
|
||||||
"Idempotency-Key must contain at least 8 characters",
|
"errors": exc.errors,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
digest = hashlib.sha256(
|
if isinstance(exc, DagTooLarge):
|
||||||
f"{workspace_id}:{schedule_id}:{normalized}".encode("utf-8")
|
return HTTPException(status.HTTP_409_CONFLICT, str(exc))
|
||||||
).hexdigest()
|
if isinstance(exc, InvalidNodeArguments):
|
||||||
return f"run:v1:{digest}"
|
return HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, exc.message)
|
||||||
|
return HTTPException(status.HTTP_409_CONFLICT, str(exc))
|
||||||
|
|
||||||
def _arguments(value: dict[str, Any] | None) -> list[str]:
|
|
||||||
payload = value or {}
|
|
||||||
raw = payload.get("_args")
|
|
||||||
result = [str(item) for item in raw] if isinstance(raw, list) else []
|
|
||||||
for key, item in payload.items():
|
|
||||||
if key == "_args":
|
|
||||||
continue
|
|
||||||
option = f"--{key.replace('_', '-')}"
|
|
||||||
if item is True:
|
|
||||||
result.append(option)
|
|
||||||
elif item is False or item is None:
|
|
||||||
continue
|
|
||||||
elif isinstance(item, list):
|
|
||||||
for list_item in item:
|
|
||||||
result.extend((option, str(list_item)))
|
|
||||||
elif isinstance(item, (str, int, float)):
|
|
||||||
result.extend((option, str(item)))
|
|
||||||
else:
|
|
||||||
raise HTTPException(
|
|
||||||
status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
||||||
f"node argument {key!r} must be a scalar or list",
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def run_summary(item: ScheduleRuns) -> dict[str, Any]:
|
def run_summary(item: ScheduleRuns) -> dict[str, Any]:
|
||||||
@@ -183,125 +162,40 @@ async def run_schedule_now(
|
|||||||
session: AsyncSession = Depends(database_session),
|
session: AsyncSession = Depends(database_session),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
reason = payload.reason if payload is not None else "manual_run"
|
reason = payload.reason if payload is not None else "manual_run"
|
||||||
key = _normalized_idempotency_key(
|
trigger_type: Literal["manual", "cron"] = "cron" if reason == "cron" else "manual"
|
||||||
|
try:
|
||||||
|
key = normalize_idempotency_key(
|
||||||
context.workspace.workspace_id,
|
context.workspace.workspace_id,
|
||||||
schedule_id,
|
schedule_id,
|
||||||
idempotency_key,
|
idempotency_key,
|
||||||
)
|
)
|
||||||
existing = await session.scalar(
|
except TriggerError as exc:
|
||||||
select(ScheduleRuns).where(ScheduleRuns.idempotency_key == key)
|
|
||||||
)
|
|
||||||
if existing is not None:
|
|
||||||
if (
|
|
||||||
existing.workspace_id != context.workspace.workspace_id
|
|
||||||
or existing.schedule_id != schedule_id
|
|
||||||
):
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status.HTTP_409_CONFLICT,
|
status.HTTP_422_UNPROCESSABLE_ENTITY, str(exc),
|
||||||
"Idempotency-Key belongs to another schedule run",
|
) from exc
|
||||||
)
|
|
||||||
return {
|
|
||||||
"request_id": context.request_id,
|
|
||||||
"data": await run_detail(existing, session),
|
|
||||||
"meta": {"reused": True},
|
|
||||||
}
|
|
||||||
|
|
||||||
schedule = await schedule_row(
|
try:
|
||||||
schedule_id,
|
run, is_new = await create_scheduled_run(
|
||||||
context,
|
|
||||||
session,
|
session,
|
||||||
for_update=True,
|
schedule_id=schedule_id,
|
||||||
)
|
workspace_id=context.workspace.workspace_id,
|
||||||
node_rows, edges = await graph_rows(schedule_id, session)
|
triggered_by_user_id=context.user.user_id,
|
||||||
nodes = [row[0] for row in node_rows]
|
trigger_type=trigger_type,
|
||||||
validation = validate_dag(nodes, edges)
|
|
||||||
if not validation["valid"] or not nodes:
|
|
||||||
raise HTTPException(
|
|
||||||
status.HTTP_409_CONFLICT,
|
|
||||||
detail={
|
|
||||||
"code": "SCHEDULE_DAG_INVALID",
|
|
||||||
"message": "schedule must contain a valid non-empty DAG",
|
|
||||||
"errors": validation["errors"],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if len(nodes) > 100 or len(edges) > 500:
|
|
||||||
raise HTTPException(
|
|
||||||
status.HTTP_409_CONFLICT,
|
|
||||||
"schedule exceeds the v1 execution size limit",
|
|
||||||
)
|
|
||||||
|
|
||||||
snapshot = {
|
|
||||||
"schedule_name": schedule.schedule_name,
|
|
||||||
"workflow_version": schedule.workflow_version,
|
|
||||||
"max_concurrency": schedule.max_concurrency,
|
|
||||||
"failure_policy": schedule.failure_policy,
|
|
||||||
"nodes": [
|
|
||||||
{
|
|
||||||
"node_id": node.node_id,
|
|
||||||
"node_key": node.node_key,
|
|
||||||
"versions_id": version.versions_id,
|
|
||||||
"script_type": script.script_type,
|
|
||||||
"artifact_object_id": version.artifact_object_id,
|
|
||||||
"artifact_path": version.artifact_path,
|
|
||||||
"timeout_seconds": node.timeout_seconds,
|
|
||||||
"retry_count": node.retry_count,
|
|
||||||
"retry_interval_sec": node.retry_interval_sec,
|
|
||||||
"arguments": _arguments(node.arguments_json),
|
|
||||||
}
|
|
||||||
for node, version, script in node_rows
|
|
||||||
],
|
|
||||||
"edges": [
|
|
||||||
{
|
|
||||||
"source_node_id": edge.source_node_id,
|
|
||||||
"target_node_id": edge.target_node_id,
|
|
||||||
}
|
|
||||||
for edge in edges
|
|
||||||
],
|
|
||||||
}
|
|
||||||
now = utcnow()
|
|
||||||
run = ScheduleRuns(
|
|
||||||
run_id=new_ulid(),
|
|
||||||
schedule_id=schedule.schedule_id,
|
|
||||||
workspace_id=schedule.workspace_id,
|
|
||||||
workflow_version=schedule.workflow_version,
|
|
||||||
trigger_type="cron" if reason == "cron" else "manual",
|
|
||||||
idempotency_key=key,
|
idempotency_key=key,
|
||||||
run_status="queued",
|
|
||||||
state_version=0,
|
|
||||||
schedule_snapshot=snapshot,
|
|
||||||
queued_at=now,
|
|
||||||
triggered_by=context.user.user_id,
|
|
||||||
)
|
|
||||||
session.add(run)
|
|
||||||
schedule.last_run_at = now
|
|
||||||
await add_outbox_event(
|
|
||||||
session,
|
|
||||||
event_type="schedule.run.requested",
|
|
||||||
producer="platform-api",
|
|
||||||
trace_id=context.request_id,
|
trace_id=context.request_id,
|
||||||
aggregate_type="schedule_run",
|
|
||||||
aggregate_id=run.run_id,
|
|
||||||
idempotency_key=key,
|
|
||||||
payload={
|
|
||||||
"workspace_id": run.workspace_id,
|
|
||||||
"schedule_id": run.schedule_id,
|
|
||||||
"run_id": run.run_id,
|
|
||||||
"workflow_version": run.workflow_version,
|
|
||||||
"trigger_type": run.trigger_type,
|
|
||||||
"triggered_by": run.triggered_by,
|
|
||||||
"schedule_snapshot": snapshot,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
await session.flush()
|
except TriggerError as exc:
|
||||||
# Commit before yielding so the Outbox row is visible to the executor's
|
raise _http_error_from_trigger(exc) from exc
|
||||||
# next MySQL poll — the executor's _database_event_loop picks it up.
|
|
||||||
# We intentionally do NOT HTTP-push; see backend/schedule_client.py.
|
# Commit before responding so the Outbox row is visible to the
|
||||||
|
# executor's next MySQL poll — the executor's _database_event_loop
|
||||||
|
# picks it up. We intentionally do NOT HTTP-push.
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(run)
|
await session.refresh(run)
|
||||||
return {
|
return {
|
||||||
"request_id": context.request_id,
|
"request_id": context.request_id,
|
||||||
"data": await run_detail(run, session),
|
"data": await run_detail(run, session),
|
||||||
"meta": {"reused": False},
|
"meta": {"reused": not is_new},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -124,9 +124,20 @@ async def lifespan(app: Any) -> AsyncIterator[None]:
|
|||||||
secret_key=settings.rustfs_secret_key,
|
secret_key=settings.rustfs_secret_key,
|
||||||
)
|
)
|
||||||
app.state.default_bucket = settings.rustfs_workspace_bucket
|
app.state.default_bucket = settings.rustfs_workspace_bucket
|
||||||
|
# Ensure every purpose-named bucket exists up front, including the
|
||||||
|
# trash bucket. The trash bucket is shared across all workspaces
|
||||||
|
# and usage_types; the source key is preserved as a prefix so a
|
||||||
|
# restore is a same-key move back to the source bucket.
|
||||||
|
for bucket in (
|
||||||
|
settings.rustfs_workspace_bucket,
|
||||||
|
settings.rustfs_version_bucket,
|
||||||
|
settings.rustfs_run_log_bucket,
|
||||||
|
settings.rustfs_trash_bucket,
|
||||||
|
):
|
||||||
await asyncio.to_thread(
|
await asyncio.to_thread(
|
||||||
app.state.object_store.ensure_bucket,
|
app.state.object_store.ensure_bucket,
|
||||||
app.state.default_bucket)
|
bucket,
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
@@ -227,12 +238,18 @@ async def create_upload_record(
|
|||||||
storage_object = await session.get(
|
storage_object = await session.get(
|
||||||
StorageObjects,
|
StorageObjects,
|
||||||
upload.storage_object_id)
|
upload.storage_object_id)
|
||||||
|
if storage_object is None or storage_object.object_status != "available":
|
||||||
|
# The previously-completed object was deleted (or never
|
||||||
|
# materialized). Treat the idempotency hit as a tombstone
|
||||||
|
# and fall through to a fresh upload: clear the pointer so
|
||||||
|
# complete_upload_record re-validates the bucket + key.
|
||||||
|
upload.storage_object_id = None
|
||||||
|
upload.upload_status = "created"
|
||||||
|
else:
|
||||||
return {
|
return {
|
||||||
"upload_id": upload.upload_id,
|
"upload_id": upload.upload_id,
|
||||||
"status": upload.upload_status,
|
"status": upload.upload_status,
|
||||||
"storage_object": (
|
"storage_object": storage_payload(storage_object),
|
||||||
storage_payload(storage_object) if storage_object else None
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
if upload.upload_status not in {"created", "uploading"}:
|
if upload.upload_status not in {"created", "uploading"}:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -295,10 +312,13 @@ async def complete_upload_record(
|
|||||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "upload not found")
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "upload not found")
|
||||||
if upload.upload_status == "completed" and upload.storage_object_id:
|
if upload.upload_status == "completed" and upload.storage_object_id:
|
||||||
item = await session.get(StorageObjects, upload.storage_object_id)
|
item = await session.get(StorageObjects, upload.storage_object_id)
|
||||||
if item is None:
|
if item is None or item.object_status != "available":
|
||||||
raise HTTPException(
|
# The linked storage object was deleted. Reset the upload so
|
||||||
status.HTTP_409_CONFLICT,
|
# the caller can re-upload the same bytes and create a
|
||||||
"completed upload has no storage object")
|
# fresh, available object.
|
||||||
|
upload.storage_object_id = None
|
||||||
|
upload.upload_status = "created"
|
||||||
|
else:
|
||||||
return item
|
return item
|
||||||
if upload.upload_status not in {"created", "uploading"}:
|
if upload.upload_status not in {"created", "uploading"}:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -552,6 +572,20 @@ async def delete_object(
|
|||||||
storage_object_id: str,
|
storage_object_id: str,
|
||||||
request: Request,
|
request: Request,
|
||||||
session: AsyncSession = Depends(database_session)) -> dict[str, Any]:
|
session: AsyncSession = Depends(database_session)) -> dict[str, Any]:
|
||||||
|
"""Soft-delete a storage object.
|
||||||
|
|
||||||
|
The bytes are copied to ``rustfs_trash_bucket`` under the same key
|
||||||
|
(preserved as ``{source_bucket}/{object_key}`` so a restore is a
|
||||||
|
same-name move), the source key is then deleted from its origin
|
||||||
|
bucket, and the row's ``object_status`` flips to ``"deleted"`` with
|
||||||
|
``deleted_at`` stamped for the reaper.
|
||||||
|
|
||||||
|
Immutable artifacts (version snapshots, run logs) are not
|
||||||
|
trashed — the policy is enforced by ``is_immutable`` and an
|
||||||
|
explicit 409. The reaper will physically delete trashed objects
|
||||||
|
older than ``rustfs_trash_retention_days`` (out of scope for this
|
||||||
|
endpoint; the field is the contract).
|
||||||
|
"""
|
||||||
item = await session.scalar(
|
item = await session.scalar(
|
||||||
select(StorageObjects)
|
select(StorageObjects)
|
||||||
.where(StorageObjects.storage_object_id == storage_object_id)
|
.where(StorageObjects.storage_object_id == storage_object_id)
|
||||||
@@ -563,22 +597,145 @@ async def delete_object(
|
|||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status.HTTP_409_CONFLICT,
|
status.HTTP_409_CONFLICT,
|
||||||
"immutable object cannot be deleted")
|
"immutable object cannot be deleted")
|
||||||
if item.object_status != "deleted":
|
if item.object_status == "deleted":
|
||||||
|
return {
|
||||||
|
"data": {
|
||||||
|
"storage_object_id": storage_object_id,
|
||||||
|
"object_status": item.object_status,
|
||||||
|
"trash_key": item.trash_key,
|
||||||
|
}
|
||||||
|
}
|
||||||
if item.storage_backend == "rustfs" and item.bucket_name and item.object_key:
|
if item.storage_backend == "rustfs" and item.bucket_name and item.object_key:
|
||||||
|
trash_key = f"{item.bucket_name}/{item.object_key}"
|
||||||
|
try:
|
||||||
await asyncio.to_thread(
|
await asyncio.to_thread(
|
||||||
request.app.state.object_store.delete,
|
request.app.state.object_store.move_to_trash,
|
||||||
bucket_name=item.bucket_name,
|
source_bucket=item.bucket_name,
|
||||||
object_key=item.object_key)
|
source_key=item.object_key,
|
||||||
|
trash_bucket=settings.rustfs_trash_bucket,
|
||||||
|
trash_key=trash_key,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
# If the move fails, leave the source intact and surface the
|
||||||
|
# error. We do NOT mark the row as deleted in that case —
|
||||||
|
# otherwise we'd have a row pointing to non-existent bytes.
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_502_BAD_GATEWAY,
|
||||||
|
f"failed to move object to trash: {exc}",
|
||||||
|
) from exc
|
||||||
|
item.trash_key = trash_key
|
||||||
item.object_status = "deleted"
|
item.object_status = "deleted"
|
||||||
item.deleted_at = utcnow()
|
item.deleted_at = utcnow()
|
||||||
return {
|
return {
|
||||||
"data": {
|
"data": {
|
||||||
"storage_object_id": storage_object_id,
|
"storage_object_id": storage_object_id,
|
||||||
"object_status": item.object_status,
|
"object_status": item.object_status,
|
||||||
|
"trash_key": item.trash_key,
|
||||||
|
"trash_bucket": settings.rustfs_trash_bucket,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post(
|
||||||
|
"/internal/v1/objects/{storage_object_id}/restore")
|
||||||
|
async def restore_object(
|
||||||
|
storage_object_id: str,
|
||||||
|
request: Request,
|
||||||
|
session: AsyncSession = Depends(database_session)) -> dict[str, Any]:
|
||||||
|
"""Restore a soft-deleted object from the trash bucket.
|
||||||
|
|
||||||
|
Copies the bytes back to the source bucket + key and flips the
|
||||||
|
row back to ``object_status='available'``. If the source bucket
|
||||||
|
is missing the object (e.g. the trash copy was the only one), the
|
||||||
|
restore still works because we copy *from* trash rather than
|
||||||
|
renaming in place. The trash copy is left in place — the reaper
|
||||||
|
will collect it on the next sweep; this is intentional so a
|
||||||
|
failed restore does not destroy the only copy.
|
||||||
|
"""
|
||||||
|
item = await session.scalar(
|
||||||
|
select(StorageObjects)
|
||||||
|
.where(StorageObjects.storage_object_id == storage_object_id)
|
||||||
|
.with_for_update()
|
||||||
|
)
|
||||||
|
if item is None:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "object not found")
|
||||||
|
if item.object_status != "deleted":
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_409_CONFLICT,
|
||||||
|
"object is not in trash")
|
||||||
|
if not item.trash_key or not item.bucket_name or not item.object_key:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_409_CONFLICT,
|
||||||
|
"object has no trash pointer; cannot restore")
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(
|
||||||
|
request.app.state.object_store.copy,
|
||||||
|
source_bucket=settings.rustfs_trash_bucket,
|
||||||
|
source_key=item.trash_key,
|
||||||
|
dest_bucket=item.bucket_name,
|
||||||
|
dest_key=item.object_key,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_502_BAD_GATEWAY,
|
||||||
|
f"failed to restore from trash: {exc}",
|
||||||
|
) from exc
|
||||||
|
item.object_status = "available"
|
||||||
|
item.deleted_at = None
|
||||||
|
# Keep trash_key so the reaper can clean up the duplicate on its
|
||||||
|
# next pass; we don't try to delete it here because a partial
|
||||||
|
# failure would leave the user with no data.
|
||||||
|
return {
|
||||||
|
"data": storage_payload(item),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post(
|
||||||
|
"/internal/v1/admin/trash/purge")
|
||||||
|
async def purge_trash_object(
|
||||||
|
payload: dict[str, Any],
|
||||||
|
request: Request,
|
||||||
|
session: AsyncSession = Depends(database_session)) -> dict[str, Any]:
|
||||||
|
"""Physically delete a trashed object.
|
||||||
|
|
||||||
|
Admin / reaper endpoint — given a ``storage_object_id``, deletes
|
||||||
|
the bytes from the trash bucket and hard-deletes the DB row.
|
||||||
|
The route is split from ``delete_object`` because the regular
|
||||||
|
delete path is the one users hit, and reaper runs need a way to
|
||||||
|
finalize the lifecycle without re-entering the soft-delete branch.
|
||||||
|
"""
|
||||||
|
storage_object_id = (payload or {}).get("storage_object_id", "").strip()
|
||||||
|
if not storage_object_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_400_BAD_REQUEST,
|
||||||
|
"storage_object_id is required")
|
||||||
|
item = await session.scalar(
|
||||||
|
select(StorageObjects)
|
||||||
|
.where(StorageObjects.storage_object_id == storage_object_id)
|
||||||
|
.with_for_update()
|
||||||
|
)
|
||||||
|
if item is None:
|
||||||
|
return {"data": {"storage_object_id": storage_object_id, "purged": False}}
|
||||||
|
if item.object_status != "deleted":
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_409_CONFLICT,
|
||||||
|
"object is not in trash; refuse to hard-delete live data")
|
||||||
|
if item.trash_key:
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(
|
||||||
|
request.app.state.object_store.delete,
|
||||||
|
bucket_name=settings.rustfs_trash_bucket,
|
||||||
|
object_key=item.trash_key,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_502_BAD_GATEWAY,
|
||||||
|
f"failed to delete from trash: {exc}",
|
||||||
|
) from exc
|
||||||
|
await session.delete(item)
|
||||||
|
return {"data": {"storage_object_id": storage_object_id, "purged": True}}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/internal/health/storage")
|
@app.get("/internal/health/storage")
|
||||||
async def internal_health() -> dict[str, str]:
|
async def internal_health() -> dict[str, str]:
|
||||||
return {"status": "ready", "service": "storage-api"}
|
return {"status": "ready", "service": "storage-api"}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ dependencies = [
|
|||||||
"boto3>=1.34,<2",
|
"boto3>=1.34,<2",
|
||||||
"fastapi==0.116.1",
|
"fastapi==0.116.1",
|
||||||
"pydantic-settings>=2.14.2",
|
"pydantic-settings>=2.14.2",
|
||||||
|
"passlib==1.7.4",
|
||||||
|
"bcrypt>=4.0,<4.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
|
|||||||
@@ -83,6 +83,22 @@ class Settings(BaseSettings):
|
|||||||
default="run-logs",
|
default="run-logs",
|
||||||
description="Bucket for schedule run logs.",
|
description="Bucket for schedule run logs.",
|
||||||
)
|
)
|
||||||
|
rustfs_trash_bucket: str = Field(
|
||||||
|
default="trash",
|
||||||
|
description=(
|
||||||
|
"Bucket for soft-deleted objects. The source bucket key is "
|
||||||
|
"preserved as a prefix so a restore is a same-key move. "
|
||||||
|
"Trash is reaped on a schedule out of band."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
rustfs_trash_retention_days: int = Field(
|
||||||
|
default=30,
|
||||||
|
description=(
|
||||||
|
"How long a trashed object is retained before reaping. "
|
||||||
|
"Tracked in the database (StorageObjects.deleted_at) so the "
|
||||||
|
"reaper can run as a single SQL sweep."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
# ── local FS roots ────────────────────────────────────────────
|
# ── local FS roots ────────────────────────────────────────────
|
||||||
workspace_root: str = Field(
|
workspace_root: str = Field(
|
||||||
|
|||||||
@@ -103,6 +103,15 @@ class StorageObjects(Base):
|
|||||||
TINYINT(1), nullable=False, server_default=text("0")
|
TINYINT(1), nullable=False, server_default=text("0")
|
||||||
)
|
)
|
||||||
deleted_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
|
deleted_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
|
||||||
|
trash_key: Mapped[Optional[str]] = mapped_column(
|
||||||
|
String(1100),
|
||||||
|
comment=(
|
||||||
|
"Path inside the trash bucket where the soft-deleted bytes "
|
||||||
|
"live. Format: '{source_bucket}/{object_key}' so a restore "
|
||||||
|
"is a same-key copy back to the source bucket. NULL while "
|
||||||
|
"the row is still available."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class DataResources(Base):
|
class DataResources(Base):
|
||||||
|
|||||||
@@ -16,6 +16,18 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from common.scheduler.trigger import (
|
||||||
|
SYSTEM_CRON_USER_ID,
|
||||||
|
DagTooLarge,
|
||||||
|
InvalidDag,
|
||||||
|
InvalidNodeArguments,
|
||||||
|
ScheduleNotFound,
|
||||||
|
TriggerError,
|
||||||
|
create_scheduled_run,
|
||||||
|
normalize_idempotency_key,
|
||||||
|
parse_node_arguments,
|
||||||
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING: # pragma: no cover - typing only
|
if TYPE_CHECKING: # pragma: no cover - typing only
|
||||||
from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore
|
from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore
|
||||||
|
|
||||||
@@ -53,7 +65,16 @@ def build_sqlalchemy_jobstore(
|
|||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"DagTooLarge",
|
||||||
|
"InvalidDag",
|
||||||
|
"InvalidNodeArguments",
|
||||||
"JOBSTORE_TABLE",
|
"JOBSTORE_TABLE",
|
||||||
|
"SYSTEM_CRON_USER_ID",
|
||||||
|
"ScheduleNotFound",
|
||||||
|
"TriggerError",
|
||||||
"build_sqlalchemy_jobstore",
|
"build_sqlalchemy_jobstore",
|
||||||
|
"create_scheduled_run",
|
||||||
|
"normalize_idempotency_key",
|
||||||
|
"parse_node_arguments",
|
||||||
"to_sync_database_url",
|
"to_sync_database_url",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -156,3 +156,50 @@ class RustFSObjectStore:
|
|||||||
|
|
||||||
def delete(self, *, bucket_name: str, object_key: str) -> None:
|
def delete(self, *, bucket_name: str, object_key: str) -> None:
|
||||||
self.internal.delete_object(Bucket=bucket_name, Key=object_key)
|
self.internal.delete_object(Bucket=bucket_name, Key=object_key)
|
||||||
|
|
||||||
|
def copy(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
source_bucket: str,
|
||||||
|
source_key: str,
|
||||||
|
dest_bucket: str,
|
||||||
|
dest_key: str,
|
||||||
|
) -> None:
|
||||||
|
"""Server-side copy ``source_bucket/source_key`` → ``dest_bucket/dest_key``.
|
||||||
|
|
||||||
|
``CopySource`` is a single header string of the form
|
||||||
|
``/{bucket}/{key}`` — must NOT be URL-encoded or quoted.
|
||||||
|
"""
|
||||||
|
self.internal.copy_object(
|
||||||
|
Bucket=dest_bucket,
|
||||||
|
Key=dest_key,
|
||||||
|
CopySource={"Bucket": source_bucket, "Key": source_key},
|
||||||
|
)
|
||||||
|
|
||||||
|
def move_to_trash(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
source_bucket: str,
|
||||||
|
source_key: str,
|
||||||
|
trash_bucket: str,
|
||||||
|
trash_key: str,
|
||||||
|
) -> None:
|
||||||
|
"""Copy an object into the trash bucket and delete the source.
|
||||||
|
|
||||||
|
The copy is a server-side operation in RustFS (no data flows
|
||||||
|
through the client). The source delete is best-effort: if it
|
||||||
|
fails after the copy succeeds the trash holds the only copy of
|
||||||
|
the bytes, which is exactly the point — the caller can retry.
|
||||||
|
"""
|
||||||
|
self.copy(
|
||||||
|
source_bucket=source_bucket,
|
||||||
|
source_key=source_key,
|
||||||
|
dest_bucket=trash_bucket,
|
||||||
|
dest_key=trash_key,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
self.delete(bucket_name=source_bucket, object_key=source_key)
|
||||||
|
except ClientError:
|
||||||
|
# Source was already gone, or transient delete failure —
|
||||||
|
# the trash copy is what matters; caller logs and moves on.
|
||||||
|
pass
|
||||||
|
|||||||
@@ -43,6 +43,18 @@ server {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
# Explicitly forward the session cookie set by
|
||||||
|
# POST /api/v1/auth/login. nginx forwards it by default, but
|
||||||
|
# spelling it out keeps the auth contract visible.
|
||||||
|
proxy_set_header Cookie $http_cookie;
|
||||||
|
# Defense in depth: blank out the legacy identity headers so a
|
||||||
|
# malicious client cannot bypass the cookie-based auth flow
|
||||||
|
# by stuffing X-User-ID / X-Workspace-ID into the request.
|
||||||
|
# The backend's RequestContext no longer reads them (it
|
||||||
|
# derives identity from the access_token cookie), so this is
|
||||||
|
# belt-and-suspenders against a future regression.
|
||||||
|
proxy_set_header X-User-ID "";
|
||||||
|
proxy_set_header X-Workspace-ID "";
|
||||||
}
|
}
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
@@ -104,6 +116,15 @@ server {
|
|||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
# Defense in depth: do not let the browser-supplied identity
|
||||||
|
# headers leak past the auth subrequest. The auth subrequest
|
||||||
|
# only forwards the Cookie + Authorization it cares about;
|
||||||
|
# the actual Jupyter upstream is fully trusted (the address
|
||||||
|
# comes from the backend's runtime registry), so a leaked
|
||||||
|
# X-User-ID here would not matter for the proxy target but
|
||||||
|
# could pollute audit logs.
|
||||||
|
proxy_set_header X-User-ID "";
|
||||||
|
proxy_set_header X-Workspace-ID "";
|
||||||
}
|
}
|
||||||
|
|
||||||
# 2. 内部 Auth 子请求 location
|
# 2. 内部 Auth 子请求 location
|
||||||
@@ -123,6 +144,13 @@ server {
|
|||||||
|
|
||||||
proxy_set_header Cookie $http_cookie;
|
proxy_set_header Cookie $http_cookie;
|
||||||
proxy_set_header Authorization $http_authorization;
|
proxy_set_header Authorization $http_authorization;
|
||||||
|
# Defense in depth: the auth subrequest reads the session
|
||||||
|
# cookie / Authorization header, not the legacy identity
|
||||||
|
# headers. Blank them out so a poisoned client header cannot
|
||||||
|
# be confused for an authenticated identity if the backend
|
||||||
|
# code is ever refactored to read them again.
|
||||||
|
proxy_set_header X-User-ID "";
|
||||||
|
proxy_set_header X-Workspace-ID "";
|
||||||
}
|
}
|
||||||
|
|
||||||
# 拒绝其余非法路径
|
# 拒绝其余非法路径
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ services:
|
|||||||
- head
|
- head
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: ${DATABASE_URL:?DATABASE_URL is required}
|
DATABASE_URL: ${DATABASE_URL:?DATABASE_URL is required}
|
||||||
|
INITIAL_ADMIN_PASSWORD: ${INITIAL_ADMIN_PASSWORD:-admin12345}
|
||||||
|
|
||||||
web:
|
web:
|
||||||
build:
|
build:
|
||||||
@@ -55,6 +56,7 @@ services:
|
|||||||
SERVICE_NAME: model-platform-backend
|
SERVICE_NAME: model-platform-backend
|
||||||
JWT_SECRET: ${JWT_SECRET:?JWT_SECRET is required}
|
JWT_SECRET: ${JWT_SECRET:?JWT_SECRET is required}
|
||||||
DEMO_AUTH_ENABLED: ${DEMO_AUTH_ENABLED:-false}
|
DEMO_AUTH_ENABLED: ${DEMO_AUTH_ENABLED:-false}
|
||||||
|
INITIAL_ADMIN_PASSWORD: ${INITIAL_ADMIN_PASSWORD:-admin12345}
|
||||||
RUNTIME_API_URL: http://runtime:8000
|
RUNTIME_API_URL: http://runtime:8000
|
||||||
RUSTFS_ENDPOINT: ${RUSTFS_ENDPOINT:?RUSTFS_ENDPOINT is required}
|
RUSTFS_ENDPOINT: ${RUSTFS_ENDPOINT:?RUSTFS_ENDPOINT is required}
|
||||||
RUSTFS_ACCESS_KEY: ${RUSTFS_ACCESS_KEY:?RUSTFS_ACCESS_KEY is required}
|
RUSTFS_ACCESS_KEY: ${RUSTFS_ACCESS_KEY:?RUSTFS_ACCESS_KEY is required}
|
||||||
@@ -62,6 +64,8 @@ services:
|
|||||||
RUSTFS_WORKSPACE_BUCKET: ${RUSTFS_WORKSPACE_BUCKET:-workspaces}
|
RUSTFS_WORKSPACE_BUCKET: ${RUSTFS_WORKSPACE_BUCKET:-workspaces}
|
||||||
RUSTFS_VERSION_BUCKET: ${RUSTFS_VERSION_BUCKET:-versions}
|
RUSTFS_VERSION_BUCKET: ${RUSTFS_VERSION_BUCKET:-versions}
|
||||||
RUSTFS_RUN_LOG_BUCKET: ${RUSTFS_RUN_LOG_BUCKET:-run-logs}
|
RUSTFS_RUN_LOG_BUCKET: ${RUSTFS_RUN_LOG_BUCKET:-run-logs}
|
||||||
|
RUSTFS_TRASH_BUCKET: ${RUSTFS_TRASH_BUCKET:-trash}
|
||||||
|
RUSTFS_TRASH_RETENTION_DAYS: ${RUSTFS_TRASH_RETENTION_DAYS:-30}
|
||||||
READINESS_TARGETS: ${MYSQL_HOST:?MYSQL_HOST is required}:${MYSQL_PORT:-3306},${RUSTFS_HOST:?RUSTFS_HOST is required}:${RUSTFS_PORT:-9000},runtime:8000
|
READINESS_TARGETS: ${MYSQL_HOST:?MYSQL_HOST is required}:${MYSQL_PORT:-3306},${RUSTFS_HOST:?RUSTFS_HOST is required}:${RUSTFS_PORT:-9000},runtime:8000
|
||||||
depends_on:
|
depends_on:
|
||||||
migrate:
|
migrate:
|
||||||
|
|||||||
@@ -25,3 +25,86 @@
|
|||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
html, body { margin: 0; min-width: 1024px; min-height: 100%; }
|
html, body { margin: 0; min-width: 1024px; min-height: 100%; }
|
||||||
button, input, select, textarea { font: inherit; }
|
button, input, select, textarea { font: inherit; }
|
||||||
|
|
||||||
|
.auth-loading,
|
||||||
|
.login-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-loading {
|
||||||
|
gap: 12px;
|
||||||
|
color: #61758a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-loading > span {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border: 2px solid #bfd5e9;
|
||||||
|
border-top-color: #1677ff;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: auth-spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes auth-spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
.login-page {
|
||||||
|
padding: 48px;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 20% 15%, rgba(22, 119, 255, 0.12), transparent 34%),
|
||||||
|
linear-gradient(145deg, #eef5fb, #f8fafc 55%, #edf3f8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
width: 420px;
|
||||||
|
padding: 42px;
|
||||||
|
border: 1px solid #dbe6ef;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: rgba(255, 255, 255, 0.96);
|
||||||
|
box-shadow: 0 22px 60px rgba(37, 63, 88, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-brand {
|
||||||
|
width: 46px;
|
||||||
|
height: 46px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 13px;
|
||||||
|
color: white;
|
||||||
|
background: linear-gradient(145deg, #1177e8, #25a1f2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-kicker {
|
||||||
|
margin: 26px 0 8px;
|
||||||
|
color: #1677ff;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 750;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card h1 { margin: 0; font-size: 27px; }
|
||||||
|
.login-description { margin: 10px 0 28px; color: #728398; }
|
||||||
|
.login-card form { display: grid; gap: 18px; }
|
||||||
|
.login-card label { display: grid; gap: 8px; color: #465b70; font-size: 13px; }
|
||||||
|
.login-card input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid #cad8e5;
|
||||||
|
border-radius: 9px;
|
||||||
|
outline: none;
|
||||||
|
color: #18334f;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
.login-card input:focus { border-color: #1677ff; box-shadow: 0 0 0 3px rgba(22, 119, 255, 0.12); }
|
||||||
|
.login-card button {
|
||||||
|
min-height: 44px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 9px;
|
||||||
|
color: white;
|
||||||
|
background: #1677ff;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.login-card button:disabled { opacity: 0.65; cursor: wait; }
|
||||||
|
.login-error { margin: -4px 0 0; color: #d4380d; font-size: 13px; }
|
||||||
|
|||||||
@@ -0,0 +1,256 @@
|
|||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
type ReactNode,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { useLocation, useNavigate } from "react-router";
|
||||||
|
|
||||||
|
import * as rawApi from "../services/api";
|
||||||
|
import type { WorkspaceBoundApi } from "../services/api";
|
||||||
|
|
||||||
|
export type AuthUser = {
|
||||||
|
user_id: string;
|
||||||
|
username: string;
|
||||||
|
display_name: string;
|
||||||
|
email: string | null;
|
||||||
|
status: string;
|
||||||
|
role_code: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AuthWorkspace = {
|
||||||
|
workspace_id: string;
|
||||||
|
workspace_code: string;
|
||||||
|
workspace_name: string;
|
||||||
|
role_code: string;
|
||||||
|
role_name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AuthSession = {
|
||||||
|
user: AuthUser;
|
||||||
|
workspaces: AuthWorkspace[];
|
||||||
|
default_workspace_id: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ApiEnvelope<T> = {
|
||||||
|
data: T;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AuthContextValue = {
|
||||||
|
user: AuthUser | null;
|
||||||
|
workspaces: AuthWorkspace[];
|
||||||
|
currentWorkspace: AuthWorkspace | null;
|
||||||
|
loading: boolean;
|
||||||
|
login: (username: string, password: string) => Promise<void>;
|
||||||
|
logout: () => Promise<void>;
|
||||||
|
setCurrentWorkspace: (workspaceId: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||||
|
const workspaceStorageKey = "model-platform-current-workspace";
|
||||||
|
|
||||||
|
async function authRequest<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||||
|
const response = await fetch(path, {
|
||||||
|
...init,
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: {
|
||||||
|
...(init.body ? { "Content-Type": "application/json" } : {}),
|
||||||
|
...init.headers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const payload = await response.json().catch(() => ({})) as
|
||||||
|
| ApiEnvelope<T>
|
||||||
|
| { detail?: string };
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
"detail" in payload && typeof payload.detail === "string"
|
||||||
|
? payload.detail
|
||||||
|
: `请求失败(HTTP ${response.status})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (payload as ApiEnvelope<T>).data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectWorkspace(
|
||||||
|
session: AuthSession,
|
||||||
|
preferredId?: string | null,
|
||||||
|
): AuthWorkspace | null {
|
||||||
|
return session.workspaces.find((item) => item.workspace_id === preferredId)
|
||||||
|
?? session.workspaces.find(
|
||||||
|
(item) => item.workspace_id === session.default_workspace_id,
|
||||||
|
)
|
||||||
|
?? session.workspaces[0]
|
||||||
|
?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [user, setUser] = useState<AuthUser | null>(null);
|
||||||
|
const [workspaces, setWorkspaces] = useState<AuthWorkspace[]>([]);
|
||||||
|
const [currentWorkspace, setWorkspace] = useState<AuthWorkspace | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const applySession = useCallback((session: AuthSession) => {
|
||||||
|
const storedId = typeof window === "undefined"
|
||||||
|
? null
|
||||||
|
: window.localStorage.getItem(workspaceStorageKey);
|
||||||
|
const workspace = selectWorkspace(session, storedId);
|
||||||
|
setUser(session.user);
|
||||||
|
setWorkspaces(session.workspaces);
|
||||||
|
setWorkspace(workspace);
|
||||||
|
if (workspace && typeof window !== "undefined") {
|
||||||
|
window.localStorage.setItem(workspaceStorageKey, workspace.workspace_id);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
void authRequest<AuthSession>("/api/v1/auth/me")
|
||||||
|
.then((session) => {
|
||||||
|
if (!cancelled) applySession(session);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setUser(null);
|
||||||
|
setWorkspaces([]);
|
||||||
|
setWorkspace(null);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [applySession]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (loading) return;
|
||||||
|
if (!user && location.pathname !== "/login") {
|
||||||
|
navigate("/login", { replace: true });
|
||||||
|
} else if (user && location.pathname === "/login") {
|
||||||
|
navigate("/workbench", { replace: true });
|
||||||
|
}
|
||||||
|
}, [loading, location.pathname, navigate, user]);
|
||||||
|
|
||||||
|
const login = useCallback(async (username: string, password: string) => {
|
||||||
|
const session = await authRequest<AuthSession>("/api/v1/auth/login", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
applySession(session);
|
||||||
|
navigate("/workbench", { replace: true });
|
||||||
|
}, [applySession, navigate]);
|
||||||
|
|
||||||
|
const logout = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await authRequest<{ logged_out: boolean }>("/api/v1/auth/logout", {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setUser(null);
|
||||||
|
setWorkspaces([]);
|
||||||
|
setWorkspace(null);
|
||||||
|
navigate("/login", { replace: true });
|
||||||
|
}
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
const setCurrentWorkspace = useCallback((workspaceId: string) => {
|
||||||
|
const workspace = workspaces.find((item) => item.workspace_id === workspaceId);
|
||||||
|
if (!workspace) return;
|
||||||
|
setWorkspace(workspace);
|
||||||
|
window.localStorage.setItem(workspaceStorageKey, workspace.workspace_id);
|
||||||
|
}, [workspaces]);
|
||||||
|
|
||||||
|
const value = useMemo<AuthContextValue>(() => ({
|
||||||
|
user,
|
||||||
|
workspaces,
|
||||||
|
currentWorkspace,
|
||||||
|
loading,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
setCurrentWorkspace,
|
||||||
|
}), [currentWorkspace, loading, login, logout, setCurrentWorkspace, user, workspaces]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="auth-loading" role="status">
|
||||||
|
<span />
|
||||||
|
正在验证登录状态…
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth(): AuthContextValue {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useAuth 必须在 AuthProvider 内使用");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useApi(): WorkspaceBoundApi {
|
||||||
|
const { currentWorkspace } = useAuth();
|
||||||
|
const workspaceId = currentWorkspace?.workspace_id ?? "";
|
||||||
|
|
||||||
|
return useMemo<WorkspaceBoundApi>(() => ({
|
||||||
|
listScripts: () => rawApi.listScripts(workspaceId),
|
||||||
|
createScript: (input) => rawApi.createScript(workspaceId, input),
|
||||||
|
uploadScript: (file, parentPath, visibility) =>
|
||||||
|
rawApi.uploadScript(workspaceId, file, parentPath, visibility),
|
||||||
|
updateScript: (scriptId, input) =>
|
||||||
|
rawApi.updateScript(workspaceId, scriptId, input),
|
||||||
|
deleteScript: (scriptId) => rawApi.deleteScript(workspaceId, scriptId),
|
||||||
|
listWorkspaceDirectories: () => rawApi.listWorkspaceDirectories(workspaceId),
|
||||||
|
createWorkspaceDirectory: (directoryName, parentPath) =>
|
||||||
|
rawApi.createWorkspaceDirectory(workspaceId, directoryName, parentPath),
|
||||||
|
deleteWorkspaceDirectory: (path) =>
|
||||||
|
rawApi.deleteWorkspaceDirectory(workspaceId, path),
|
||||||
|
acquireFileLock: (script) => rawApi.acquireFileLock(workspaceId, script),
|
||||||
|
heartbeatFileLock: (session) => rawApi.heartbeatFileLock(workspaceId, session),
|
||||||
|
releaseFileLock: (session) => rawApi.releaseFileLock(workspaceId, session),
|
||||||
|
releaseFileLockOnUnload: (session) =>
|
||||||
|
rawApi.releaseFileLockOnUnload(workspaceId, session),
|
||||||
|
createJupyterAccessTicket: (session) =>
|
||||||
|
rawApi.createJupyterAccessTicket(workspaceId, session),
|
||||||
|
listScriptVersions: (scriptId) => rawApi.listScriptVersions(workspaceId, scriptId),
|
||||||
|
publishScriptVersion: (input) => rawApi.publishScriptVersion(workspaceId, input),
|
||||||
|
listSchedules: () => rawApi.listSchedules(workspaceId),
|
||||||
|
getSchedule: (scheduleId) => rawApi.getSchedule(workspaceId, scheduleId),
|
||||||
|
createSchedule: (input) => rawApi.createSchedule(workspaceId, input),
|
||||||
|
updateSchedule: (scheduleId, input) =>
|
||||||
|
rawApi.updateSchedule(workspaceId, scheduleId, input),
|
||||||
|
deleteSchedule: (scheduleId, workflowVersion) =>
|
||||||
|
rawApi.deleteSchedule(workspaceId, scheduleId, workflowVersion),
|
||||||
|
listScheduleArtifacts: () => rawApi.listScheduleArtifacts(workspaceId),
|
||||||
|
hideScheduleArtifact: (versionsId) =>
|
||||||
|
rawApi.hideScheduleArtifact(workspaceId, versionsId),
|
||||||
|
listEmployees: () => rawApi.listEmployees(workspaceId),
|
||||||
|
createEmployee: (input) => rawApi.createEmployee(workspaceId, input),
|
||||||
|
updateEmployee: (userId, input) =>
|
||||||
|
rawApi.updateEmployee(workspaceId, userId, input),
|
||||||
|
deleteEmployee: (userId) => rawApi.deleteEmployee(workspaceId, userId),
|
||||||
|
createScheduleNode: (scheduleId, input) =>
|
||||||
|
rawApi.createScheduleNode(workspaceId, scheduleId, input),
|
||||||
|
updateScheduleNode: (scheduleId, nodeId, input) =>
|
||||||
|
rawApi.updateScheduleNode(workspaceId, scheduleId, nodeId, input),
|
||||||
|
deleteScheduleNode: (scheduleId, nodeId, workflowVersion) =>
|
||||||
|
rawApi.deleteScheduleNode(workspaceId, scheduleId, nodeId, workflowVersion),
|
||||||
|
createScheduleEdge: (scheduleId, input) =>
|
||||||
|
rawApi.createScheduleEdge(workspaceId, scheduleId, input),
|
||||||
|
deleteScheduleEdge: (scheduleId, edgeId, workflowVersion) =>
|
||||||
|
rawApi.deleteScheduleEdge(workspaceId, scheduleId, edgeId, workflowVersion),
|
||||||
|
validateSchedule: (scheduleId) => rawApi.validateSchedule(workspaceId, scheduleId),
|
||||||
|
previewCron: (input) => rawApi.previewCron(workspaceId, input),
|
||||||
|
runScheduleNow: (scheduleId) => rawApi.runScheduleNow(workspaceId, scheduleId),
|
||||||
|
listScheduleRuns: (input) => rawApi.listScheduleRuns(workspaceId, input),
|
||||||
|
getScheduleRun: (runId) => rawApi.getScheduleRun(workspaceId, runId),
|
||||||
|
}), [workspaceId]);
|
||||||
|
}
|
||||||
@@ -2,13 +2,9 @@ import { type FormEvent, useEffect, useState } from "react";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
ApiRequestError,
|
ApiRequestError,
|
||||||
createEmployee,
|
|
||||||
deleteEmployee,
|
|
||||||
demoContext,
|
|
||||||
listEmployees,
|
|
||||||
updateEmployee,
|
|
||||||
type Employee,
|
type Employee,
|
||||||
} from "../../services/api";
|
} from "../../services/api";
|
||||||
|
import { useApi, useAuth } from "../../context/AuthContext";
|
||||||
import Icon from "../../components/Icon";
|
import Icon from "../../components/Icon";
|
||||||
import "../../styles/admin.css";
|
import "../../styles/admin.css";
|
||||||
import "../../styles/dashboard.css";
|
import "../../styles/dashboard.css";
|
||||||
@@ -28,13 +24,17 @@ export function DashboardPage({
|
|||||||
online: boolean;
|
online: boolean;
|
||||||
onNavigate: (page: "scripts" | "schedules" | "system") => void;
|
onNavigate: (page: "scripts" | "schedules" | "system") => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { user, currentWorkspace } = useAuth();
|
||||||
return (
|
return (
|
||||||
<section className="dashboard-page">
|
<section className="dashboard-page">
|
||||||
<div className="dashboard-hero">
|
<div className="dashboard-hero">
|
||||||
<div>
|
<div>
|
||||||
<span>MODEL DEVELOPMENT PLATFORM</span>
|
<span>MODEL DEVELOPMENT PLATFORM</span>
|
||||||
<h2>下午好,{demoContext.userName}</h2>
|
<h2>下午好,{user?.display_name ?? "用户"}</h2>
|
||||||
<p>当前位于 {demoContext.workspaceName},可以继续构建脚本或配置调度。</p>
|
<p>
|
||||||
|
当前位于 {currentWorkspace?.workspace_name ?? "(未选择 Workspace)"}
|
||||||
|
,可以继续构建脚本或配置调度。
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="dashboard-hero__badge">{online ? "服务正常" : "服务连接中"}</span>
|
<span className="dashboard-hero__badge">{online ? "服务正常" : "服务连接中"}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -112,18 +112,20 @@ export function SystemAdminPage({
|
|||||||
onNotify: (notice: Notice) => void;
|
onNotify: (notice: Notice) => void;
|
||||||
onConnectionChange: (online: boolean) => void;
|
onConnectionChange: (online: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const api = useApi();
|
||||||
|
const { user, currentWorkspace } = useAuth();
|
||||||
const [employees, setEmployees] = useState<Employee[]>([]);
|
const [employees, setEmployees] = useState<Employee[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [editing, setEditing] = useState<Employee | null>(null);
|
const [editing, setEditing] = useState<Employee | null>(null);
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
const [form, setForm] = useState(EMPTY_FORM);
|
const [form, setForm] = useState(EMPTY_FORM);
|
||||||
const canManage = demoContext.roleCode === "admin";
|
const canManage = user?.role_code === "admin";
|
||||||
|
|
||||||
const load = async (): Promise<void> => {
|
const load = async (): Promise<void> => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
setEmployees(await listEmployees());
|
setEmployees(await api.listEmployees());
|
||||||
onConnectionChange(true);
|
onConnectionChange(true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
onConnectionChange(false);
|
onConnectionChange(false);
|
||||||
@@ -164,7 +166,7 @@ export function SystemAdminPage({
|
|||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
if (editing) {
|
if (editing) {
|
||||||
const updated = await updateEmployee(editing.user_id, {
|
const updated = await api.updateEmployee(editing.user_id, {
|
||||||
display_name: form.display_name.trim(),
|
display_name: form.display_name.trim(),
|
||||||
email: form.email.trim() || null,
|
email: form.email.trim() || null,
|
||||||
role_code: form.role_code,
|
role_code: form.role_code,
|
||||||
@@ -174,7 +176,7 @@ export function SystemAdminPage({
|
|||||||
(item) => item.user_id === updated.user_id ? updated : item,
|
(item) => item.user_id === updated.user_id ? updated : item,
|
||||||
));
|
));
|
||||||
} else {
|
} else {
|
||||||
const created = await createEmployee({
|
const created = await api.createEmployee({
|
||||||
username: form.username.trim(),
|
username: form.username.trim(),
|
||||||
display_name: form.display_name.trim(),
|
display_name: form.display_name.trim(),
|
||||||
email: form.email.trim() || null,
|
email: form.email.trim() || null,
|
||||||
@@ -197,7 +199,7 @@ export function SystemAdminPage({
|
|||||||
const remove = async (employee: Employee): Promise<void> => {
|
const remove = async (employee: Employee): Promise<void> => {
|
||||||
if (!window.confirm(`确定从当前 Workspace 删除员工“${employee.display_name}”吗?`)) return;
|
if (!window.confirm(`确定从当前 Workspace 删除员工“${employee.display_name}”吗?`)) return;
|
||||||
try {
|
try {
|
||||||
await deleteEmployee(employee.user_id);
|
await api.deleteEmployee(employee.user_id);
|
||||||
setEmployees((current) => current.filter((item) => item.user_id !== employee.user_id));
|
setEmployees((current) => current.filter((item) => item.user_id !== employee.user_id));
|
||||||
onNotify({ tone: "success", message: "员工已删除" });
|
onNotify({ tone: "success", message: "员工已删除" });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -211,7 +213,11 @@ export function SystemAdminPage({
|
|||||||
return (
|
return (
|
||||||
<section className="admin-page">
|
<section className="admin-page">
|
||||||
<header className="admin-page__header">
|
<header className="admin-page__header">
|
||||||
<div><span>系统管理</span><h2>员工管理</h2><p>{demoContext.workspaceName} · {employees.length} 名员工</p></div>
|
<div>
|
||||||
|
<span>系统管理</span>
|
||||||
|
<h2>员工管理</h2>
|
||||||
|
<p>{currentWorkspace?.workspace_name ?? "(未选择 Workspace)"} · {employees.length} 名员工</p>
|
||||||
|
</div>
|
||||||
<button className="primary-button" type="button" disabled={!canManage} onClick={openCreate}>
|
<button className="primary-button" type="button" disabled={!canManage} onClick={openCreate}>
|
||||||
<Icon name="plus" size={15} />添加员工
|
<Icon name="plus" size={15} />添加员工
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -10,24 +10,6 @@ import {
|
|||||||
import { useLocation, useNavigate } from "react-router";
|
import { useLocation, useNavigate } from "react-router";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
acquireFileLock,
|
|
||||||
createWorkspaceDirectory,
|
|
||||||
createScript,
|
|
||||||
createJupyterAccessTicket,
|
|
||||||
deleteScript,
|
|
||||||
deleteWorkspaceDirectory,
|
|
||||||
demoContext,
|
|
||||||
demoUsers,
|
|
||||||
demoWorkspaces,
|
|
||||||
heartbeatFileLock,
|
|
||||||
listScripts,
|
|
||||||
listScriptVersions,
|
|
||||||
listWorkspaceDirectories,
|
|
||||||
publishScriptVersion,
|
|
||||||
releaseFileLock,
|
|
||||||
releaseFileLockOnUnload,
|
|
||||||
setDemoContext,
|
|
||||||
uploadScript,
|
|
||||||
type ActiveEditSession,
|
type ActiveEditSession,
|
||||||
type ScriptItem,
|
type ScriptItem,
|
||||||
type ScriptType,
|
type ScriptType,
|
||||||
@@ -35,6 +17,7 @@ import {
|
|||||||
type Visibility,
|
type Visibility,
|
||||||
type WorkspaceDirectory,
|
type WorkspaceDirectory,
|
||||||
} from "../../services/api";
|
} from "../../services/api";
|
||||||
|
import { useApi, useAuth } from "~/context/AuthContext";
|
||||||
import Icon from "../../components/Icon";
|
import Icon from "../../components/Icon";
|
||||||
import SchedulePage from "../schedules/SchedulePage";
|
import SchedulePage from "../schedules/SchedulePage";
|
||||||
import { DashboardPage, SystemAdminPage } from "../admin/AdminPages";
|
import { DashboardPage, SystemAdminPage } from "../admin/AdminPages";
|
||||||
@@ -123,9 +106,27 @@ function mergeDirectories(
|
|||||||
).values()];
|
).values()];
|
||||||
}
|
}
|
||||||
export default function ModelPlatformApp() {
|
export default function ModelPlatformApp() {
|
||||||
|
const { currentWorkspace } = useAuth();
|
||||||
|
if (!currentWorkspace) {
|
||||||
|
return (
|
||||||
|
<div className="app-shell" style={{ display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||||||
|
<div style={{ textAlign: "center" }}>
|
||||||
|
<span style={{ fontSize: 18 }}>加载中…</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <AuthenticatedModelPlatformApp />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AuthenticatedModelPlatformApp() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const activePage = pageFromPath(location.pathname);
|
const activePage = pageFromPath(location.pathname);
|
||||||
|
const auth = useAuth();
|
||||||
|
const { user, workspaces, setCurrentWorkspace, logout } = auth;
|
||||||
|
const currentWorkspace = auth.currentWorkspace!;
|
||||||
|
const api = useApi();
|
||||||
const [scripts, setScripts] = useState<ScriptItem[]>([]);
|
const [scripts, setScripts] = useState<ScriptItem[]>([]);
|
||||||
const [directories, setDirectories] = useState<WorkspaceDirectory[]>([]);
|
const [directories, setDirectories] = useState<WorkspaceDirectory[]>([]);
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
@@ -144,7 +145,6 @@ export default function ModelPlatformApp() {
|
|||||||
}>({ open: false, parentPath: "", name: "", busy: false });
|
}>({ open: false, parentPath: "", name: "", busy: false });
|
||||||
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
|
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
|
||||||
const [workspaceMenuOpen, setWorkspaceMenuOpen] = useState(false);
|
const [workspaceMenuOpen, setWorkspaceMenuOpen] = useState(false);
|
||||||
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
|
||||||
const [uploadParentPath, setUploadParentPath] = useState("");
|
const [uploadParentPath, setUploadParentPath] = useState("");
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const uploadInputRef = useRef<HTMLInputElement | null>(null);
|
const uploadInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
@@ -176,8 +176,8 @@ export default function ModelPlatformApp() {
|
|||||||
setRefreshing(silent);
|
setRefreshing(silent);
|
||||||
try {
|
try {
|
||||||
const [items, folderItems] = await Promise.all([
|
const [items, folderItems] = await Promise.all([
|
||||||
listScripts(),
|
api.listScripts(),
|
||||||
listWorkspaceDirectories(),
|
api.listWorkspaceDirectories(),
|
||||||
]);
|
]);
|
||||||
setScripts(items);
|
setScripts(items);
|
||||||
setDirectories(folderItems);
|
setDirectories(folderItems);
|
||||||
@@ -248,7 +248,7 @@ export default function ModelPlatformApp() {
|
|||||||
}
|
}
|
||||||
let ignore = false;
|
let ignore = false;
|
||||||
setVersionsLoading(true);
|
setVersionsLoading(true);
|
||||||
void listScriptVersions(selectedId)
|
void api.listScriptVersions(selectedId)
|
||||||
.then((items) => {
|
.then((items) => {
|
||||||
if (!ignore) setVersions(items);
|
if (!ignore) setVersions(items);
|
||||||
})
|
})
|
||||||
@@ -282,7 +282,7 @@ export default function ModelPlatformApp() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
heartbeatRunning = true;
|
heartbeatRunning = true;
|
||||||
void heartbeatFileLock(current)
|
void api.heartbeatFileLock(current)
|
||||||
.then((updated) => {
|
.then((updated) => {
|
||||||
setEditSession((active) => active
|
setEditSession((active) => active
|
||||||
&& active.edit_session_id === updated.edit_session_id
|
&& active.edit_session_id === updated.edit_session_id
|
||||||
@@ -319,7 +319,7 @@ export default function ModelPlatformApp() {
|
|||||||
if (!current || current.edit_session_id !== editSession.edit_session_id) {
|
if (!current || current.edit_session_id !== editSession.edit_session_id) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void createJupyterAccessTicket(current)
|
void api.createJupyterAccessTicket(current)
|
||||||
.then((ticket) => {
|
.then((ticket) => {
|
||||||
setEditSession((active) => active
|
setEditSession((active) => active
|
||||||
&& active.edit_session_id === ticket.edit_session_id
|
&& active.edit_session_id === ticket.edit_session_id
|
||||||
@@ -342,7 +342,7 @@ export default function ModelPlatformApp() {
|
|||||||
if (!editSession) return;
|
if (!editSession) return;
|
||||||
const handleUnload = () => {
|
const handleUnload = () => {
|
||||||
const current = editSessionRef.current;
|
const current = editSessionRef.current;
|
||||||
if (current) releaseFileLockOnUnload(current);
|
if (current) api.releaseFileLockOnUnload(current);
|
||||||
};
|
};
|
||||||
window.addEventListener("beforeunload", handleUnload);
|
window.addEventListener("beforeunload", handleUnload);
|
||||||
return () => window.removeEventListener("beforeunload", handleUnload);
|
return () => window.removeEventListener("beforeunload", handleUnload);
|
||||||
@@ -356,24 +356,17 @@ export default function ModelPlatformApp() {
|
|||||||
);
|
);
|
||||||
}, [keyword, scripts]);
|
}, [keyword, scripts]);
|
||||||
|
|
||||||
const memberScriptGroups = [...demoUsers]
|
const memberScriptGroups = (() => {
|
||||||
.sort((left, right) => (
|
const currentUserScripts = filteredScripts.filter(
|
||||||
Number(right.userId === demoContext.userId)
|
(item) => item.owner_user_id === user?.user_id,
|
||||||
- Number(left.userId === demoContext.userId)
|
|
||||||
))
|
|
||||||
.map((user) => {
|
|
||||||
const memberScripts = filteredScripts.filter(
|
|
||||||
(item) => item.owner_user_id === user.userId,
|
|
||||||
);
|
);
|
||||||
const inferred = inferredDirectories(memberScripts);
|
const inferred = inferredDirectories(currentUserScripts);
|
||||||
return {
|
return [{
|
||||||
user,
|
user: user,
|
||||||
scripts: memberScripts,
|
scripts: currentUserScripts,
|
||||||
directories: user.userId === demoContext.userId
|
directories: mergeDirectories(directories, inferred),
|
||||||
? mergeDirectories(directories, inferred)
|
}];
|
||||||
: inferred,
|
})();
|
||||||
};
|
|
||||||
});
|
|
||||||
const selected = scripts.find((item) => item.script_id === selectedId) ?? null;
|
const selected = scripts.find((item) => item.script_id === selectedId) ?? null;
|
||||||
|
|
||||||
const selectScript = (scriptId: string | null) => {
|
const selectScript = (scriptId: string | null) => {
|
||||||
@@ -413,7 +406,7 @@ export default function ModelPlatformApp() {
|
|||||||
let newlyAcquired = false;
|
let newlyAcquired = false;
|
||||||
try {
|
try {
|
||||||
if (active && active.script_id !== script.script_id) {
|
if (active && active.script_id !== script.script_id) {
|
||||||
await releaseFileLock(active);
|
await api.releaseFileLock(active);
|
||||||
setEmbeddedJupyterUrl(null);
|
setEmbeddedJupyterUrl(null);
|
||||||
setEditSession(null);
|
setEditSession(null);
|
||||||
editSessionRef.current = null;
|
editSessionRef.current = null;
|
||||||
@@ -422,20 +415,20 @@ export default function ModelPlatformApp() {
|
|||||||
if (!requestIsCurrent()) return;
|
if (!requestIsCurrent()) return;
|
||||||
|
|
||||||
if (!active) {
|
if (!active) {
|
||||||
active = await acquireFileLock(script);
|
active = await api.acquireFileLock(script);
|
||||||
newlyAcquired = true;
|
newlyAcquired = true;
|
||||||
}
|
}
|
||||||
if (!requestIsCurrent()) {
|
if (!requestIsCurrent()) {
|
||||||
if (active) {
|
if (active) {
|
||||||
await releaseFileLock(active);
|
await api.releaseFileLock(active);
|
||||||
clearSessionIfActive(active);
|
clearSessionIfActive(active);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ticket = await createJupyterAccessTicket(active);
|
const ticket = await api.createJupyterAccessTicket(active);
|
||||||
if (!requestIsCurrent()) {
|
if (!requestIsCurrent()) {
|
||||||
await releaseFileLock(active);
|
await api.releaseFileLock(active);
|
||||||
clearSessionIfActive(active);
|
clearSessionIfActive(active);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -456,7 +449,7 @@ export default function ModelPlatformApp() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (newlyAcquired && active) {
|
if (newlyAcquired && active) {
|
||||||
try {
|
try {
|
||||||
await releaseFileLock(active);
|
await api.releaseFileLock(active);
|
||||||
} catch {
|
} catch {
|
||||||
// The database lease is the final safety net if compensation cannot reach Runtime.
|
// The database lease is the final safety net if compensation cannot reach Runtime.
|
||||||
}
|
}
|
||||||
@@ -513,7 +506,7 @@ export default function ModelPlatformApp() {
|
|||||||
}
|
}
|
||||||
setEditBusy(true);
|
setEditBusy(true);
|
||||||
try {
|
try {
|
||||||
await releaseFileLock(active);
|
await api.releaseFileLock(active);
|
||||||
setEmbeddedJupyterUrl(null);
|
setEmbeddedJupyterUrl(null);
|
||||||
setEditSession(null);
|
setEditSession(null);
|
||||||
editSessionRef.current = null;
|
editSessionRef.current = null;
|
||||||
@@ -572,7 +565,7 @@ export default function ModelPlatformApp() {
|
|||||||
if (!publishTarget) return;
|
if (!publishTarget) return;
|
||||||
setPublishing(true);
|
setPublishing(true);
|
||||||
try {
|
try {
|
||||||
const version = await publishScriptVersion({
|
const version = await api.publishScriptVersion({
|
||||||
script: publishTarget,
|
script: publishTarget,
|
||||||
releaseNote,
|
releaseNote,
|
||||||
visibility: publishVisibility,
|
visibility: publishVisibility,
|
||||||
@@ -602,7 +595,7 @@ export default function ModelPlatformApp() {
|
|||||||
if (!form.name.trim()) return;
|
if (!form.name.trim()) return;
|
||||||
setCreating(true);
|
setCreating(true);
|
||||||
try {
|
try {
|
||||||
const created = await createScript(form);
|
const created = await api.createScript(form);
|
||||||
setScripts((items) => [created, ...items]);
|
setScripts((items) => [created, ...items]);
|
||||||
selectScript(created.script_id);
|
selectScript(created.script_id);
|
||||||
setCreateOpen(false);
|
setCreateOpen(false);
|
||||||
@@ -658,7 +651,7 @@ export default function ModelPlatformApp() {
|
|||||||
let lastCreated: ScriptItem | null = null;
|
let lastCreated: ScriptItem | null = null;
|
||||||
try {
|
try {
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
lastCreated = await uploadScript(file, uploadParentPath);
|
lastCreated = await api.uploadScript(file, uploadParentPath);
|
||||||
}
|
}
|
||||||
await load(true);
|
await load(true);
|
||||||
if (lastCreated) selectScript(lastCreated.script_id);
|
if (lastCreated) selectScript(lastCreated.script_id);
|
||||||
@@ -684,7 +677,7 @@ export default function ModelPlatformApp() {
|
|||||||
if (!folderDialog.name.trim()) return;
|
if (!folderDialog.name.trim()) return;
|
||||||
setFolderDialog((current) => ({ ...current, busy: true }));
|
setFolderDialog((current) => ({ ...current, busy: true }));
|
||||||
try {
|
try {
|
||||||
await createWorkspaceDirectory(
|
await api.createWorkspaceDirectory(
|
||||||
folderDialog.name.trim(),
|
folderDialog.name.trim(),
|
||||||
folderDialog.parentPath,
|
folderDialog.parentPath,
|
||||||
);
|
);
|
||||||
@@ -718,7 +711,7 @@ export default function ModelPlatformApp() {
|
|||||||
if (editSessionRef.current?.script_id === script.script_id) return;
|
if (editSessionRef.current?.script_id === script.script_id) return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await deleteScript(script.script_id);
|
await api.deleteScript(script.script_id);
|
||||||
if (selectedIdRef.current === script.script_id) selectScript(null);
|
if (selectedIdRef.current === script.script_id) selectScript(null);
|
||||||
await load(true);
|
await load(true);
|
||||||
setToast({
|
setToast({
|
||||||
@@ -752,7 +745,7 @@ export default function ModelPlatformApp() {
|
|||||||
if (editSessionRef.current?.script_id === activeScript.script_id) return;
|
if (editSessionRef.current?.script_id === activeScript.script_id) return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const result = await deleteWorkspaceDirectory(path);
|
const result = await api.deleteWorkspaceDirectory(path);
|
||||||
const selectedScript = scripts.find(
|
const selectedScript = scripts.find(
|
||||||
(item) => item.script_id === selectedIdRef.current,
|
(item) => item.script_id === selectedIdRef.current,
|
||||||
);
|
);
|
||||||
@@ -851,36 +844,29 @@ export default function ModelPlatformApp() {
|
|||||||
{apiOnline ? "服务已连接" : "服务未连接"}
|
{apiOnline ? "服务已连接" : "服务未连接"}
|
||||||
</div>
|
</div>
|
||||||
<div className="topbar-menu-wrap">
|
<div className="topbar-menu-wrap">
|
||||||
<button className="workspace-switcher" type="button" onClick={() => { setWorkspaceMenuOpen((value) => !value); setUserMenuOpen(false); }}>
|
<button className="workspace-switcher" type="button" onClick={() => { setWorkspaceMenuOpen((value) => !value); }}>
|
||||||
<span className="workspace-switcher__icon"><Icon name="workspace" size={18} /></span>
|
<span className="workspace-switcher__icon"><Icon name="workspace" size={18} /></span>
|
||||||
<span><small>当前 Workspace</small><strong>{demoContext.workspaceName}</strong></span>
|
<span><small>当前 Workspace</small><strong>{currentWorkspace.workspace_name}</strong></span>
|
||||||
<Icon name="chevron" size={15} />
|
<Icon name="chevron" size={15} />
|
||||||
</button>
|
</button>
|
||||||
{workspaceMenuOpen && (
|
{workspaceMenuOpen && (
|
||||||
<div className="topbar-dropdown">
|
<div className="topbar-dropdown">
|
||||||
{demoWorkspaces.map((workspace) => (
|
{workspaces.map((workspace) => (
|
||||||
<button className={workspace.workspaceId === demoContext.workspaceId ? "is-selected" : ""} type="button" key={workspace.workspaceId} onClick={() => { setDemoContext({ workspace }); window.location.reload(); }}>
|
<button className={workspace.workspace_id === currentWorkspace.workspace_id ? "is-selected" : ""} type="button" key={workspace.workspace_id} onClick={() => { setCurrentWorkspace(workspace.workspace_id); setWorkspaceMenuOpen(false); }}>
|
||||||
<Icon name="workspace" size={15} /><span><strong>{workspace.workspaceName}</strong><small>{workspace.workspaceId === demoContext.workspaceId ? "当前使用" : "点击切换"}</small></span>
|
<Icon name="workspace" size={15} /><span><strong>{workspace.workspace_name}</strong><small>{workspace.workspace_id === currentWorkspace.workspace_id ? "当前使用" : "点击切换"}</small></span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="topbar-menu-wrap">
|
<div className="topbar-menu-wrap">
|
||||||
<button className="user-menu" type="button" onClick={() => { setUserMenuOpen((value) => !value); setWorkspaceMenuOpen(false); }}>
|
<button className="user-menu" type="button">
|
||||||
<span className="avatar">{demoContext.userName.slice(0, 1)}</span>
|
<span className="avatar">{user?.display_name?.slice(0, 1) ?? "?"}</span>
|
||||||
<span className="user-menu__copy"><strong>{demoContext.userName}</strong><small>{demoContext.roleName}</small></span>
|
<span className="user-menu__copy"><strong>{user?.display_name ?? "未知用户"}</strong><small>{user?.role_code === "admin" ? "管理员" : "开发人员"}</small></span>
|
||||||
<Icon name="chevron" size={15} />
|
|
||||||
</button>
|
</button>
|
||||||
{userMenuOpen && (
|
<button className="text-button" type="button" onClick={() => { logout(); window.location.assign("/login"); }}>
|
||||||
<div className="topbar-dropdown topbar-dropdown--users">
|
登出
|
||||||
{demoUsers.map((user) => (
|
|
||||||
<button className={user.userId === demoContext.userId ? "is-selected" : ""} type="button" key={user.userId} onClick={() => { setDemoContext({ user }); window.location.reload(); }}>
|
|
||||||
<span className="avatar">{user.userName.slice(0, 1)}</span><span><strong>{user.userName}</strong><small>{user.roleName} · {user.username}</small></span>
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -949,18 +935,18 @@ export default function ModelPlatformApp() {
|
|||||||
<>
|
<>
|
||||||
{memberScriptGroups.map((group) => (
|
{memberScriptGroups.map((group) => (
|
||||||
<WorkspaceTreeGroup
|
<WorkspaceTreeGroup
|
||||||
key={group.user.userId}
|
key={group.user?.user_id ?? "anon"}
|
||||||
title={`${group.user.userName}的文件`}
|
title={`${group.user?.display_name}的文件`}
|
||||||
scripts={group.scripts}
|
scripts={group.scripts}
|
||||||
directories={group.directories}
|
directories={group.directories}
|
||||||
selectedId={selectedId}
|
selectedId={selectedId}
|
||||||
onSelect={selectScript}
|
onSelect={selectScript}
|
||||||
onContextMenu={
|
onContextMenu={
|
||||||
group.user.userId === demoContext.userId
|
group.user?.user_id === user?.user_id
|
||||||
? showContextMenu
|
? showContextMenu
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
readOnly={group.user.userId !== demoContext.userId}
|
readOnly={group.user?.user_id !== user?.user_id}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{filteredScripts.length === 0 && (
|
{filteredScripts.length === 0 && (
|
||||||
@@ -1045,13 +1031,13 @@ export default function ModelPlatformApp() {
|
|||||||
</section>
|
</section>
|
||||||
) : activePage === "schedules" ? (
|
) : activePage === "schedules" ? (
|
||||||
<SchedulePage
|
<SchedulePage
|
||||||
key={`${demoContext.userId}-${demoContext.workspaceId}`}
|
key={`${user?.user_id ?? "anon"}-${currentWorkspace?.workspace_id ?? "none"}`}
|
||||||
onNotify={setToast}
|
onNotify={setToast}
|
||||||
onConnectionChange={setApiOnline}
|
onConnectionChange={setApiOnline}
|
||||||
/>
|
/>
|
||||||
) : activePage === "system" ? (
|
) : activePage === "system" ? (
|
||||||
<SystemAdminPage
|
<SystemAdminPage
|
||||||
key={`${demoContext.userId}-${demoContext.workspaceId}`}
|
key={`${user?.user_id ?? "anon"}-${currentWorkspace?.workspace_id ?? "none"}`}
|
||||||
onNotify={setToast}
|
onNotify={setToast}
|
||||||
onConnectionChange={setApiOnline}
|
onConnectionChange={setApiOnline}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,327 @@
|
|||||||
|
import Icon from "../../components/Icon";
|
||||||
|
import type {
|
||||||
|
ActiveEditSession,
|
||||||
|
ScriptItem,
|
||||||
|
StableVersion,
|
||||||
|
} from "../../services/api";
|
||||||
|
import { scriptIcon } from "./WorkspaceTree";
|
||||||
|
|
||||||
|
type ToastState = {
|
||||||
|
tone: "success" | "error" | "info";
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ScriptWorkspaceProps = {
|
||||||
|
script: ScriptItem;
|
||||||
|
editSession: ActiveEditSession | null;
|
||||||
|
jupyterUrl: string | null;
|
||||||
|
editBusy: boolean;
|
||||||
|
openError: string | null;
|
||||||
|
latestVersion: StableVersion | null;
|
||||||
|
versionsLoading: boolean;
|
||||||
|
onOpenEditor: () => void;
|
||||||
|
onEndEditing: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
onPublish: () => void;
|
||||||
|
onInfo: (toast: ToastState) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatTime(value: string) {
|
||||||
|
return new Intl.DateTimeFormat("zh-CN", {
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
hour12: false,
|
||||||
|
}).format(new Date(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(value: number) {
|
||||||
|
if (value < 1024) return `${value} B`;
|
||||||
|
return `${(value / 1024).toFixed(1)} KB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortHash(value: string) {
|
||||||
|
return value ? `${value.slice(0, 8)}…${value.slice(-6)}` : "—";
|
||||||
|
}
|
||||||
|
|
||||||
|
function confineJupyterFrame(frame: HTMLIFrameElement): void {
|
||||||
|
try {
|
||||||
|
const document = frame.contentDocument;
|
||||||
|
if (!document?.documentElement) return;
|
||||||
|
const keepInside = (): void => {
|
||||||
|
document.querySelectorAll<HTMLElement>("button,[role='button']").forEach(
|
||||||
|
(element) => {
|
||||||
|
const label = `${element.getAttribute("aria-label") ?? ""} ${
|
||||||
|
element.getAttribute("title") ?? ""
|
||||||
|
} ${element.textContent ?? ""}`.trim();
|
||||||
|
if (/^open in\b/i.test(label) || /\bopen in\.\.\./i.test(label)) {
|
||||||
|
element.style.setProperty("display", "none", "important");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
document.querySelectorAll<HTMLAnchorElement>("a[target]").forEach((link) => {
|
||||||
|
if (["_blank", "_top", "_parent"].includes(link.target)) {
|
||||||
|
link.target = "_self";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
keepInside();
|
||||||
|
new MutationObserver(keepInside).observe(document.documentElement, {
|
||||||
|
childList: true,
|
||||||
|
subtree: true,
|
||||||
|
});
|
||||||
|
document.addEventListener("click", (event) => {
|
||||||
|
const target = event.target as HTMLElement | null;
|
||||||
|
const link = target?.closest?.("a") as HTMLAnchorElement | null;
|
||||||
|
if (link && ["_blank", "_top", "_parent"].includes(link.target)) {
|
||||||
|
link.target = "_self";
|
||||||
|
}
|
||||||
|
}, true);
|
||||||
|
} catch {
|
||||||
|
// The iframe remains sandboxed even if its document is not yet accessible.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScriptWorkspace({
|
||||||
|
script,
|
||||||
|
editSession,
|
||||||
|
jupyterUrl,
|
||||||
|
editBusy,
|
||||||
|
openError,
|
||||||
|
latestVersion,
|
||||||
|
versionsLoading,
|
||||||
|
onOpenEditor,
|
||||||
|
onEndEditing,
|
||||||
|
onClose,
|
||||||
|
onPublish,
|
||||||
|
onInfo,
|
||||||
|
}: ScriptWorkspaceProps) {
|
||||||
|
const isNotebook = script.script_type === "notebook";
|
||||||
|
const isEditing = editSession?.session_status === "active";
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="tabbar">
|
||||||
|
<div className="editor-tab editor-tab--active">
|
||||||
|
<span className={`file-icon file-icon--${script.script_type}`}>
|
||||||
|
<Icon name={scriptIcon(script)} size={16} />
|
||||||
|
</span>
|
||||||
|
<span>{script.script_name}</span>
|
||||||
|
<button type="button" aria-label="关闭标签" onClick={onClose}>
|
||||||
|
<Icon name="close" size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="new-tab"
|
||||||
|
type="button"
|
||||||
|
onClick={() => onInfo({ tone: "info", message: "请从左侧选择或新建脚本" })}
|
||||||
|
>
|
||||||
|
<Icon name="plus" size={17} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="editor-toolbar">
|
||||||
|
<div className="editor-toolbar__path">
|
||||||
|
<span className={`file-icon file-icon--${script.script_type}`}>
|
||||||
|
<Icon name={scriptIcon(script)} size={17} />
|
||||||
|
</span>
|
||||||
|
<span>工作副本</span>
|
||||||
|
<Icon name="chevron" size={13} />
|
||||||
|
<strong>{script.script_name}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="editor-toolbar__actions">
|
||||||
|
{isEditing ? (
|
||||||
|
<button
|
||||||
|
className="end-edit-button"
|
||||||
|
type="button"
|
||||||
|
disabled={editBusy}
|
||||||
|
onClick={onEndEditing}
|
||||||
|
>
|
||||||
|
{editBusy ? "正在释放…" : "结束编辑"}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onInfo({
|
||||||
|
tone: "info",
|
||||||
|
message: "Jupyter 中保存后会直接写入 Workspace 工作副本",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
保存说明
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="release-button"
|
||||||
|
type="button"
|
||||||
|
onClick={onPublish}
|
||||||
|
>
|
||||||
|
发布稳定版
|
||||||
|
</button>
|
||||||
|
<span className={`stage-badge${isEditing ? " is-editing" : ""}`}>
|
||||||
|
<span />
|
||||||
|
{isEditing
|
||||||
|
? isNotebook
|
||||||
|
? "Demo 无锁模式 · Kernel 已连接"
|
||||||
|
: "Demo 无锁模式 · 编辑中"
|
||||||
|
: latestVersion
|
||||||
|
? `最新 ${latestVersion.version_label}`
|
||||||
|
: "工作副本已就绪"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`editor-canvas${isEditing && jupyterUrl ? " is-embedded" : ""}`}>
|
||||||
|
{isEditing && jupyterUrl ? (
|
||||||
|
<section className="embedded-jupyter">
|
||||||
|
<div className="embedded-jupyter__status">
|
||||||
|
<span>
|
||||||
|
<i />
|
||||||
|
Workspace Jupyter Server
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{isNotebook ? "Notebook Session · Python 3 Kernel" : "Jupyter 文本编辑器"}
|
||||||
|
</span>
|
||||||
|
<code title={editSession.runtime_id}>
|
||||||
|
Runtime {editSession.runtime_id.slice(-8)}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
<iframe
|
||||||
|
key={`${editSession.edit_session_id}:${jupyterUrl}`}
|
||||||
|
src={jupyterUrl}
|
||||||
|
title={`${script.script_name} Jupyter 编辑器`}
|
||||||
|
allow="clipboard-read; clipboard-write"
|
||||||
|
sandbox="allow-same-origin allow-scripts allow-forms allow-downloads allow-modals"
|
||||||
|
onLoad={(event) => confineJupyterFrame(event.currentTarget)}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
) : isNotebook ? (
|
||||||
|
<section
|
||||||
|
className={`editor-opening-state${openError ? " has-error" : ""}`}
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<div className="editor-opening-state__icon">
|
||||||
|
{openError
|
||||||
|
? <Icon name="info" size={28} />
|
||||||
|
: <span className="button-spinner button-spinner--blue" />}
|
||||||
|
</div>
|
||||||
|
<strong>
|
||||||
|
{openError ? "Notebook 打开失败" : "正在打开 Notebook"}
|
||||||
|
</strong>
|
||||||
|
<p>
|
||||||
|
{openError
|
||||||
|
? openError
|
||||||
|
: "正在获取编辑锁并连接 Workspace Jupyter Server…"}
|
||||||
|
</p>
|
||||||
|
{openError && (
|
||||||
|
<button
|
||||||
|
className="open-editor-button"
|
||||||
|
type="button"
|
||||||
|
disabled={editBusy}
|
||||||
|
onClick={onOpenEditor}
|
||||||
|
>
|
||||||
|
{editBusy
|
||||||
|
? <span className="button-spinner button-spinner--blue" />
|
||||||
|
: <Icon name="refresh" size={16} />}
|
||||||
|
{editBusy ? "正在重试…" : "重试打开"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
) : (
|
||||||
|
<section className="script-overview">
|
||||||
|
<div className="script-overview__header">
|
||||||
|
<div>
|
||||||
|
<span className="section-kicker">PYTHON SCRIPT</span>
|
||||||
|
<h2>{script.script_name}</h2>
|
||||||
|
<p>{script.relative_path}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className={`open-editor-button${isEditing ? " is-editing" : ""}`}
|
||||||
|
type="button"
|
||||||
|
disabled={editBusy}
|
||||||
|
onClick={onOpenEditor}
|
||||||
|
>
|
||||||
|
{editBusy
|
||||||
|
? <span className="button-spinner button-spinner--blue" />
|
||||||
|
: <Icon name="external" size={17} />}
|
||||||
|
{editBusy
|
||||||
|
? "正在准备编辑器…"
|
||||||
|
: isEditing
|
||||||
|
? "继续编辑"
|
||||||
|
: "打开编辑器"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="metadata-grid">
|
||||||
|
<div>
|
||||||
|
<span>脚本类型</span>
|
||||||
|
<strong>Python</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>可见范围</span>
|
||||||
|
<strong>
|
||||||
|
{script.visibility === "workspace"
|
||||||
|
? "Workspace"
|
||||||
|
: script.visibility === "public" ? "公开" : "私有"}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>文件大小</span>
|
||||||
|
<strong>{formatBytes(script.size_bytes)}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>最近更新</span>
|
||||||
|
<strong>{formatTime(script.updated_at)}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="preview-card">
|
||||||
|
<div className="preview-card__bar">
|
||||||
|
<div>
|
||||||
|
<span className="window-dot window-dot--red" />
|
||||||
|
<span className="window-dot window-dot--yellow" />
|
||||||
|
<span className="window-dot window-dot--green" />
|
||||||
|
</div>
|
||||||
|
<span>Python 预览</span>
|
||||||
|
<em>{isEditing ? "Jupyter 中可编辑" : "平台只读预览"}</em>
|
||||||
|
</div>
|
||||||
|
<PythonPreview />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="integrity-row">
|
||||||
|
<span>
|
||||||
|
<Icon name="check" size={15} />
|
||||||
|
{isEditing ? "Demo 无锁编辑会话已连接" : "工作副本已同步"}
|
||||||
|
</span>
|
||||||
|
<span>SHA-256 {shortHash(script.content_hash)}</span>
|
||||||
|
<span>
|
||||||
|
稳定版本
|
||||||
|
{versionsLoading
|
||||||
|
? "加载中"
|
||||||
|
: latestVersion
|
||||||
|
? `${latestVersion.version_label} · ${latestVersion.versions_id}`
|
||||||
|
: "尚未发布"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PythonPreview() {
|
||||||
|
return (
|
||||||
|
<div className="python-preview">
|
||||||
|
<div className="line-numbers">
|
||||||
|
1<br />2<br />3<br />4<br />5<br />6<br />7<br />8<br />9
|
||||||
|
</div>
|
||||||
|
<pre>
|
||||||
|
<span className="code-comment">"""模型实验开发平台构建脚本。"""</span>
|
||||||
|
{"\n\n"}<b>def</b> <span className="code-function">main</span>() -> <b>None</b>:
|
||||||
|
{"\n"} print(<i>"Hello, Model Platform!"</i>)
|
||||||
|
{"\n\n\n"}<b>if</b> __name__ == <i>"__main__"</i>:
|
||||||
|
{"\n"} main()
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
import { type MouseEvent as ReactMouseEvent, useState } from "react";
|
||||||
|
|
||||||
|
import Icon from "../../components/Icon";
|
||||||
|
import type {
|
||||||
|
ScriptItem,
|
||||||
|
WorkspaceDirectory,
|
||||||
|
} from "../../services/api";
|
||||||
|
|
||||||
|
export type WorkspaceTreeTarget = {
|
||||||
|
kind: "root" | "directory" | "file";
|
||||||
|
path: string;
|
||||||
|
script?: ScriptItem;
|
||||||
|
};
|
||||||
|
|
||||||
|
type WorkspaceTreeProps = {
|
||||||
|
title: string;
|
||||||
|
scripts: ScriptItem[];
|
||||||
|
directories: WorkspaceDirectory[];
|
||||||
|
selectedId: string | null;
|
||||||
|
onSelect: (id: string) => void;
|
||||||
|
onContextMenu?: (
|
||||||
|
event: ReactMouseEvent,
|
||||||
|
target: WorkspaceTreeTarget,
|
||||||
|
) => void;
|
||||||
|
readOnly?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type WorkspaceTreeItemsProps = Omit<WorkspaceTreeProps, "title" | "readOnly"> & {
|
||||||
|
path: string;
|
||||||
|
depth: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatTime(value: string) {
|
||||||
|
return new Intl.DateTimeFormat("zh-CN", {
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
hour12: false,
|
||||||
|
}).format(new Date(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scriptIcon(item: ScriptItem) {
|
||||||
|
return item.script_type === "notebook" ? "notebook" : "python";
|
||||||
|
}
|
||||||
|
|
||||||
|
function ownedScriptPath(item: ScriptItem) {
|
||||||
|
return item.relative_path.replaceAll("\\", "/").split("/").slice(2).join("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
function parentOf(path: string) {
|
||||||
|
const parts = path.split("/");
|
||||||
|
parts.pop();
|
||||||
|
return parts.join("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WorkspaceTreeGroup({
|
||||||
|
title,
|
||||||
|
scripts,
|
||||||
|
directories,
|
||||||
|
selectedId,
|
||||||
|
onSelect,
|
||||||
|
onContextMenu,
|
||||||
|
readOnly = false,
|
||||||
|
}: WorkspaceTreeProps) {
|
||||||
|
const [open, setOpen] = useState(true);
|
||||||
|
return (
|
||||||
|
<div className="tree-group">
|
||||||
|
<button
|
||||||
|
className={`tree-group__title${open ? " is-open" : ""}`}
|
||||||
|
type="button"
|
||||||
|
aria-expanded={open}
|
||||||
|
onClick={() => setOpen((current) => !current)}
|
||||||
|
onContextMenu={onContextMenu
|
||||||
|
? (event) => onContextMenu(event, { kind: "root", path: "" })
|
||||||
|
: undefined}
|
||||||
|
>
|
||||||
|
<Icon name="chevron" size={14} />
|
||||||
|
<Icon name="folder" size={17} />
|
||||||
|
<span>{title}</span>
|
||||||
|
<em>{scripts.length}</em>
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="tree-group__items">
|
||||||
|
<WorkspaceTreeItems
|
||||||
|
path=""
|
||||||
|
depth={0}
|
||||||
|
scripts={scripts}
|
||||||
|
directories={directories}
|
||||||
|
selectedId={selectedId}
|
||||||
|
onSelect={onSelect}
|
||||||
|
onContextMenu={onContextMenu}
|
||||||
|
/>
|
||||||
|
{scripts.length === 0 && directories.length === 0 && (
|
||||||
|
<p className="tree-group__empty">
|
||||||
|
{readOnly ? "暂无文件" : "右键此目录即可新建或上传"}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WorkspaceTreeItems({
|
||||||
|
path,
|
||||||
|
depth,
|
||||||
|
scripts,
|
||||||
|
directories,
|
||||||
|
selectedId,
|
||||||
|
onSelect,
|
||||||
|
onContextMenu,
|
||||||
|
}: WorkspaceTreeItemsProps) {
|
||||||
|
const childDirectories = directories.filter(
|
||||||
|
(item) => item.parent_path === path,
|
||||||
|
);
|
||||||
|
const childScripts = scripts.filter(
|
||||||
|
(item) => parentOf(ownedScriptPath(item)) === path,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{childDirectories.map((directory) => (
|
||||||
|
<DirectoryBranch
|
||||||
|
key={directory.path}
|
||||||
|
directory={directory}
|
||||||
|
depth={depth}
|
||||||
|
scripts={scripts}
|
||||||
|
directories={directories}
|
||||||
|
selectedId={selectedId}
|
||||||
|
onSelect={onSelect}
|
||||||
|
onContextMenu={onContextMenu}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{childScripts.map((item) => (
|
||||||
|
<button
|
||||||
|
className={`script-row${
|
||||||
|
selectedId === item.script_id ? " script-row--active" : ""
|
||||||
|
}`}
|
||||||
|
style={{ paddingLeft: 20 + depth * 16 }}
|
||||||
|
key={item.script_id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelect(item.script_id)}
|
||||||
|
onContextMenu={onContextMenu
|
||||||
|
? (event) => onContextMenu(event, {
|
||||||
|
kind: "file",
|
||||||
|
path: ownedScriptPath(item),
|
||||||
|
script: item,
|
||||||
|
})
|
||||||
|
: undefined}
|
||||||
|
>
|
||||||
|
<span className={`file-icon file-icon--${item.script_type}`}>
|
||||||
|
<Icon name={scriptIcon(item)} size={17} />
|
||||||
|
</span>
|
||||||
|
<span className="script-row__copy">
|
||||||
|
<strong title={item.script_name}>{item.script_name}</strong>
|
||||||
|
<small>{formatTime(item.updated_at)}</small>
|
||||||
|
</span>
|
||||||
|
{item.visibility !== "private" && (
|
||||||
|
<span className="visibility-dot" title="Workspace 可见" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DirectoryBranch({
|
||||||
|
directory,
|
||||||
|
depth,
|
||||||
|
scripts,
|
||||||
|
directories,
|
||||||
|
selectedId,
|
||||||
|
onSelect,
|
||||||
|
onContextMenu,
|
||||||
|
}: Omit<WorkspaceTreeItemsProps, "path"> & {
|
||||||
|
directory: WorkspaceDirectory;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(true);
|
||||||
|
return (
|
||||||
|
<div className="directory-branch">
|
||||||
|
<button
|
||||||
|
className="directory-row"
|
||||||
|
style={{ paddingLeft: 10 + depth * 16 }}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((current) => !current)}
|
||||||
|
onContextMenu={onContextMenu
|
||||||
|
? (event) => onContextMenu(event, {
|
||||||
|
kind: "directory",
|
||||||
|
path: directory.path,
|
||||||
|
})
|
||||||
|
: undefined}
|
||||||
|
>
|
||||||
|
<span className={`directory-row__chevron${open ? " is-open" : ""}`}>
|
||||||
|
<Icon name="chevron" size={13} />
|
||||||
|
</span>
|
||||||
|
<Icon name="folder" size={17} />
|
||||||
|
<strong title={directory.path}>{directory.name}</strong>
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<WorkspaceTreeItems
|
||||||
|
path={directory.path}
|
||||||
|
depth={depth + 1}
|
||||||
|
scripts={scripts}
|
||||||
|
directories={directories}
|
||||||
|
selectedId={selectedId}
|
||||||
|
onSelect={onSelect}
|
||||||
|
onContextMenu={onContextMenu}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,22 +11,6 @@ import {
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
ApiRequestError,
|
ApiRequestError,
|
||||||
createSchedule,
|
|
||||||
createScheduleEdge,
|
|
||||||
createScheduleNode,
|
|
||||||
deleteSchedule,
|
|
||||||
deleteScheduleEdge,
|
|
||||||
deleteScheduleNode,
|
|
||||||
hideScheduleArtifact,
|
|
||||||
getSchedule,
|
|
||||||
listScheduleArtifacts,
|
|
||||||
listScheduleRuns,
|
|
||||||
listSchedules,
|
|
||||||
previewCron,
|
|
||||||
runScheduleNow,
|
|
||||||
updateSchedule,
|
|
||||||
updateScheduleNode,
|
|
||||||
validateSchedule,
|
|
||||||
type CronPreview,
|
type CronPreview,
|
||||||
type Schedule,
|
type Schedule,
|
||||||
type ScheduleArtifact,
|
type ScheduleArtifact,
|
||||||
@@ -34,6 +18,8 @@ import {
|
|||||||
type ScheduleNode,
|
type ScheduleNode,
|
||||||
type ScheduleRunSummary,
|
type ScheduleRunSummary,
|
||||||
} from "../../services/api";
|
} from "../../services/api";
|
||||||
|
|
||||||
|
import { useApi } from "~/context/AuthContext";
|
||||||
import Icon from "../../components/Icon";
|
import Icon from "../../components/Icon";
|
||||||
import "../../styles/schedule.css";
|
import "../../styles/schedule.css";
|
||||||
|
|
||||||
@@ -250,6 +236,7 @@ export default function SchedulePage({
|
|||||||
const dragRef = useRef<DragState | null>(null);
|
const dragRef = useRef<DragState | null>(null);
|
||||||
const positionDraftsRef = useRef<Record<string, NodePositionDraft>>({});
|
const positionDraftsRef = useRef<Record<string, NodePositionDraft>>({});
|
||||||
const [positionDraftCount, setPositionDraftCount] = useState(0);
|
const [positionDraftCount, setPositionDraftCount] = useState(0);
|
||||||
|
const api = useApi();
|
||||||
|
|
||||||
const selectedNode = schedule?.nodes.find(
|
const selectedNode = schedule?.nodes.find(
|
||||||
(item) => item.node_id === selectedNodeId,
|
(item) => item.node_id === selectedNodeId,
|
||||||
@@ -328,7 +315,7 @@ export default function SchedulePage({
|
|||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
if (showLoading) setRunsLoading(true);
|
if (showLoading) setRunsLoading(true);
|
||||||
try {
|
try {
|
||||||
const items = await listScheduleRuns({
|
const items = await api.listScheduleRuns({
|
||||||
scheduleId,
|
scheduleId,
|
||||||
limit: 20,
|
limit: 20,
|
||||||
});
|
});
|
||||||
@@ -349,8 +336,8 @@ export default function SchedulePage({
|
|||||||
preferredScheduleId?: string | null,
|
preferredScheduleId?: string | null,
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
const [scheduleItems, artifactItems] = await Promise.all([
|
const [scheduleItems, artifactItems] = await Promise.all([
|
||||||
listSchedules(),
|
api.listSchedules(),
|
||||||
listScheduleArtifacts(),
|
api.listScheduleArtifacts(),
|
||||||
]);
|
]);
|
||||||
setSchedules(scheduleItems);
|
setSchedules(scheduleItems);
|
||||||
setArtifacts(artifactItems);
|
setArtifacts(artifactItems);
|
||||||
@@ -362,20 +349,20 @@ export default function SchedulePage({
|
|||||||
setSchedule(null);
|
setSchedule(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const detail = await getSchedule(targetId);
|
const detail = await api.getSchedule(targetId);
|
||||||
setSchedule(applyPositionDrafts(detail));
|
setSchedule(applyPositionDrafts(detail));
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
Promise.all([listSchedules(), listScheduleArtifacts()])
|
Promise.all([api.listSchedules(), api.listScheduleArtifacts()])
|
||||||
.then(async ([scheduleItems, artifactItems]) => {
|
.then(async ([scheduleItems, artifactItems]) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setSchedules(scheduleItems);
|
setSchedules(scheduleItems);
|
||||||
setArtifacts(artifactItems);
|
setArtifacts(artifactItems);
|
||||||
if (scheduleItems[0]) {
|
if (scheduleItems[0]) {
|
||||||
const detail = await getSchedule(scheduleItems[0].schedule_id);
|
const detail = await api.getSchedule(scheduleItems[0].schedule_id);
|
||||||
if (!cancelled) setSchedule(applyPositionDrafts(detail));
|
if (!cancelled) setSchedule(applyPositionDrafts(detail));
|
||||||
}
|
}
|
||||||
onConnectionChange(true);
|
onConnectionChange(true);
|
||||||
@@ -404,7 +391,7 @@ export default function SchedulePage({
|
|||||||
}
|
}
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setRunsLoading(true);
|
setRunsLoading(true);
|
||||||
listScheduleRuns({ scheduleId, limit: 20 })
|
api.listScheduleRuns({ scheduleId, limit: 20 })
|
||||||
.then((items) => {
|
.then((items) => {
|
||||||
if (!cancelled) setRuns(items);
|
if (!cancelled) setRuns(items);
|
||||||
})
|
})
|
||||||
@@ -507,7 +494,7 @@ export default function SchedulePage({
|
|||||||
setLinkSourceId(null);
|
setLinkSourceId(null);
|
||||||
setCronResult(null);
|
setCronResult(null);
|
||||||
try {
|
try {
|
||||||
setSchedule(await getSchedule(scheduleId));
|
setSchedule(await api.getSchedule(scheduleId));
|
||||||
onConnectionChange(true);
|
onConnectionChange(true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await handleError(error, "调度详情加载失败");
|
await handleError(error, "调度详情加载失败");
|
||||||
@@ -529,7 +516,7 @@ export default function SchedulePage({
|
|||||||
if (busy || !scheduleName) return;
|
if (busy || !scheduleName) return;
|
||||||
setBusy("create-schedule");
|
setBusy("create-schedule");
|
||||||
try {
|
try {
|
||||||
const created = await createSchedule({
|
const created = await api.createSchedule({
|
||||||
schedule_name: scheduleName,
|
schedule_name: scheduleName,
|
||||||
description: "在画布中拖入稳定版本并配置执行顺序",
|
description: "在画布中拖入稳定版本并配置执行顺序",
|
||||||
trigger_type: "manual",
|
trigger_type: "manual",
|
||||||
@@ -557,7 +544,7 @@ export default function SchedulePage({
|
|||||||
if (!window.confirm(`确定删除调度“${selectedSchedule.schedule_name}”吗?`)) return;
|
if (!window.confirm(`确定删除调度“${selectedSchedule.schedule_name}”吗?`)) return;
|
||||||
setBusy("delete-schedule");
|
setBusy("delete-schedule");
|
||||||
try {
|
try {
|
||||||
await deleteSchedule(
|
await api.deleteSchedule(
|
||||||
selectedSchedule.schedule_id,
|
selectedSchedule.schedule_id,
|
||||||
selectedSchedule.workflow_version,
|
selectedSchedule.workflow_version,
|
||||||
);
|
);
|
||||||
@@ -571,7 +558,7 @@ export default function SchedulePage({
|
|||||||
setSelectedNodeId(null);
|
setSelectedNodeId(null);
|
||||||
setSelectedEdgeId(null);
|
setSelectedEdgeId(null);
|
||||||
if (remaining[0]) {
|
if (remaining[0]) {
|
||||||
setSchedule(await getSchedule(remaining[0].schedule_id));
|
setSchedule(await api.getSchedule(remaining[0].schedule_id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
onNotify({ tone: "success", message: "调度方案已删除" });
|
onNotify({ tone: "success", message: "调度方案已删除" });
|
||||||
@@ -589,7 +576,7 @@ export default function SchedulePage({
|
|||||||
if (!scheduleName || scheduleName === target.schedule_name) return;
|
if (!scheduleName || scheduleName === target.schedule_name) return;
|
||||||
setBusy("rename-schedule");
|
setBusy("rename-schedule");
|
||||||
try {
|
try {
|
||||||
const updated = await updateSchedule(target.schedule_id, {
|
const updated = await api.updateSchedule(target.schedule_id, {
|
||||||
workflow_version: target.workflow_version,
|
workflow_version: target.workflow_version,
|
||||||
schedule_name: scheduleName,
|
schedule_name: scheduleName,
|
||||||
});
|
});
|
||||||
@@ -620,7 +607,7 @@ export default function SchedulePage({
|
|||||||
) return;
|
) return;
|
||||||
setBusy("delete-artifact");
|
setBusy("delete-artifact");
|
||||||
try {
|
try {
|
||||||
await hideScheduleArtifact(artifact.versions_id);
|
await api.hideScheduleArtifact(artifact.versions_id);
|
||||||
setArtifacts((current) => current.filter(
|
setArtifacts((current) => current.filter(
|
||||||
(item) => item.versions_id !== artifact.versions_id,
|
(item) => item.versions_id !== artifact.versions_id,
|
||||||
));
|
));
|
||||||
@@ -652,13 +639,13 @@ export default function SchedulePage({
|
|||||||
for (const [nodeId, position] of Object.entries(
|
for (const [nodeId, position] of Object.entries(
|
||||||
positionDraftsRef.current,
|
positionDraftsRef.current,
|
||||||
)) {
|
)) {
|
||||||
updated = await updateScheduleNode(updated.schedule_id, nodeId, {
|
updated = await api.updateScheduleNode(updated.schedule_id, nodeId, {
|
||||||
workflow_version: updated.workflow_version,
|
workflow_version: updated.workflow_version,
|
||||||
position_x: position.position_x,
|
position_x: position.position_x,
|
||||||
position_y: position.position_y,
|
position_y: position.position_y,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
updated = await updateSchedule(updated.schedule_id, {
|
updated = await api.updateSchedule(updated.schedule_id, {
|
||||||
workflow_version: updated.workflow_version,
|
workflow_version: updated.workflow_version,
|
||||||
schedule_name: scheduleForm.scheduleName.trim(),
|
schedule_name: scheduleForm.scheduleName.trim(),
|
||||||
description: scheduleForm.description.trim() || null,
|
description: scheduleForm.description.trim() || null,
|
||||||
@@ -694,7 +681,7 @@ export default function SchedulePage({
|
|||||||
if (busy) return;
|
if (busy) return;
|
||||||
setBusy("cron-preview");
|
setBusy("cron-preview");
|
||||||
try {
|
try {
|
||||||
const result = await previewCron({
|
const result = await api.previewCron({
|
||||||
cron_expression: scheduleForm.cronExpression.trim(),
|
cron_expression: scheduleForm.cronExpression.trim(),
|
||||||
timezone: scheduleForm.timezone.trim(),
|
timezone: scheduleForm.timezone.trim(),
|
||||||
count: 5,
|
count: 5,
|
||||||
@@ -727,7 +714,7 @@ export default function SchedulePage({
|
|||||||
}
|
}
|
||||||
setBusy("run-now");
|
setBusy("run-now");
|
||||||
try {
|
try {
|
||||||
const created = await runScheduleNow(schedule.schedule_id);
|
const created = await api.runScheduleNow(schedule.schedule_id);
|
||||||
setRuns((current) => [
|
setRuns((current) => [
|
||||||
created,
|
created,
|
||||||
...current.filter((item) => item.run_id !== created.run_id),
|
...current.filter((item) => item.run_id !== created.run_id),
|
||||||
@@ -763,7 +750,7 @@ export default function SchedulePage({
|
|||||||
const nodeKey = artifactNodeKey(artifact, schedule);
|
const nodeKey = artifactNodeKey(artifact, schedule);
|
||||||
const updated = await withMutation(
|
const updated = await withMutation(
|
||||||
"add-node",
|
"add-node",
|
||||||
() => createScheduleNode(schedule.schedule_id, {
|
() => api.createScheduleNode(schedule.schedule_id, {
|
||||||
workflow_version: schedule.workflow_version,
|
workflow_version: schedule.workflow_version,
|
||||||
node_key: nodeKey,
|
node_key: nodeKey,
|
||||||
node_name: artifact.script_name,
|
node_name: artifact.script_name,
|
||||||
@@ -898,7 +885,7 @@ export default function SchedulePage({
|
|||||||
setLinkSourceId(null);
|
setLinkSourceId(null);
|
||||||
await withMutation(
|
await withMutation(
|
||||||
"create-edge",
|
"create-edge",
|
||||||
() => createScheduleEdge(schedule.schedule_id, {
|
() => api.createScheduleEdge(schedule.schedule_id, {
|
||||||
workflow_version: schedule.workflow_version,
|
workflow_version: schedule.workflow_version,
|
||||||
source_node_id: sourceId,
|
source_node_id: sourceId,
|
||||||
target_node_id: targetNodeId,
|
target_node_id: targetNodeId,
|
||||||
@@ -935,7 +922,7 @@ export default function SchedulePage({
|
|||||||
);
|
);
|
||||||
await withMutation(
|
await withMutation(
|
||||||
"save-node",
|
"save-node",
|
||||||
() => updateScheduleNode(schedule.schedule_id, selectedNode.node_id, {
|
() => api.updateScheduleNode(schedule.schedule_id, selectedNode.node_id, {
|
||||||
workflow_version: schedule.workflow_version,
|
workflow_version: schedule.workflow_version,
|
||||||
node_name: nodeForm.nodeName.trim(),
|
node_name: nodeForm.nodeName.trim(),
|
||||||
timeout_seconds: timeoutSeconds,
|
timeout_seconds: timeoutSeconds,
|
||||||
@@ -958,7 +945,7 @@ export default function SchedulePage({
|
|||||||
if (!window.confirm(`确定删除节点“${node.node_name}”吗?`)) return;
|
if (!window.confirm(`确定删除节点“${node.node_name}”吗?`)) return;
|
||||||
const updated = await withMutation(
|
const updated = await withMutation(
|
||||||
"delete-node",
|
"delete-node",
|
||||||
() => deleteScheduleNode(
|
() => api.deleteScheduleNode(
|
||||||
schedule.schedule_id,
|
schedule.schedule_id,
|
||||||
node.node_id,
|
node.node_id,
|
||||||
schedule.workflow_version,
|
schedule.workflow_version,
|
||||||
@@ -974,7 +961,7 @@ export default function SchedulePage({
|
|||||||
setContextMenu(null);
|
setContextMenu(null);
|
||||||
const updated = await withMutation(
|
const updated = await withMutation(
|
||||||
"delete-edge",
|
"delete-edge",
|
||||||
() => deleteScheduleEdge(
|
() => api.deleteScheduleEdge(
|
||||||
schedule.schedule_id,
|
schedule.schedule_id,
|
||||||
edge.edge_id,
|
edge.edge_id,
|
||||||
schedule.workflow_version,
|
schedule.workflow_version,
|
||||||
@@ -988,7 +975,7 @@ export default function SchedulePage({
|
|||||||
if (!schedule || busy) return;
|
if (!schedule || busy) return;
|
||||||
setBusy("validate");
|
setBusy("validate");
|
||||||
try {
|
try {
|
||||||
const result = await validateSchedule(schedule.schedule_id);
|
const result = await api.validateSchedule(schedule.schedule_id);
|
||||||
setSchedule((current) => current
|
setSchedule((current) => current
|
||||||
? { ...current, dag_validation: result }
|
? { ...current, dag_validation: result }
|
||||||
: current);
|
: current);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
} from "react-router";
|
} from "react-router";
|
||||||
|
|
||||||
import type { Route } from "./+types/root";
|
import type { Route } from "./+types/root";
|
||||||
|
import { AuthProvider } from "~/context/AuthContext";
|
||||||
import "./app.css";
|
import "./app.css";
|
||||||
|
|
||||||
export const links: Route.LinksFunction = () => [];
|
export const links: Route.LinksFunction = () => [];
|
||||||
@@ -31,7 +32,11 @@ export function Layout({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return <Outlet />;
|
return (
|
||||||
|
<AuthProvider>
|
||||||
|
<Outlet />
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
import { index, type RouteConfig, route } from "@react-router/dev/routes";
|
import { type RouteConfig, route } from "@react-router/dev/routes";
|
||||||
|
|
||||||
export default [
|
export default [
|
||||||
route("login", "routes/login.tsx"),
|
route("login", "routes/login.tsx"),
|
||||||
index("routes/home.tsx"),
|
|
||||||
route("workbench", "routes/workbench.tsx"),
|
|
||||||
route("scripts", "routes/scripts.tsx"),
|
|
||||||
route("schedules", "routes/schedules.tsx"),
|
|
||||||
route("system", "routes/system.tsx"),
|
|
||||||
route("*", "routes/platform.tsx"),
|
route("*", "routes/platform.tsx"),
|
||||||
] satisfies RouteConfig;
|
] satisfies RouteConfig;
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import type { Route } from "./+types/home";
|
|
||||||
import { Welcome } from "../welcome/welcome";
|
import { Welcome } from "../welcome/welcome";
|
||||||
|
|
||||||
export function meta({}: Route.MetaArgs) {
|
export function meta() {
|
||||||
return [
|
return [
|
||||||
{ title: "New React Router App" },
|
{ title: "New React Router App" },
|
||||||
{ name: "description", content: "Welcome to React Router!" },
|
{ name: "description", content: "Welcome to React Router!" },
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { type FormEvent, useState } from "react";
|
||||||
|
|
||||||
|
import type { Route } from "./+types/login";
|
||||||
|
import { useAuth } from "../context/AuthContext";
|
||||||
|
|
||||||
|
export function meta({}: Route.MetaArgs) {
|
||||||
|
return [
|
||||||
|
{ title: "登录 · 模型实验开发平台" },
|
||||||
|
{ name: "description", content: "登录模型实验开发平台" },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LoginRoute() {
|
||||||
|
const { login } = useAuth();
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const submit = async (event: FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!username.trim() || !password) {
|
||||||
|
setError("请输入用户名和密码");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await login(username.trim(), password);
|
||||||
|
} catch (cause) {
|
||||||
|
setError(cause instanceof Error ? cause.message : "登录失败,请稍后重试");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="login-page">
|
||||||
|
<section className="login-card" aria-labelledby="login-title">
|
||||||
|
<div className="login-brand" aria-hidden="true">◆</div>
|
||||||
|
<p className="login-kicker">MODEL EXPERIMENT PLATFORM</p>
|
||||||
|
<h1 id="login-title">模型实验开发平台</h1>
|
||||||
|
<p className="login-description">使用平台账号登录并选择你的 Workspace。</p>
|
||||||
|
<form onSubmit={(event) => void submit(event)}>
|
||||||
|
<label>
|
||||||
|
<span>用户名</span>
|
||||||
|
<input
|
||||||
|
autoComplete="username"
|
||||||
|
autoFocus
|
||||||
|
value={username}
|
||||||
|
onChange={(event) => setUsername(event.target.value)}
|
||||||
|
placeholder="请输入用户名"
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>密码</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
value={password}
|
||||||
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
|
placeholder="请输入密码"
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{error && <p className="login-error" role="alert">{error}</p>}
|
||||||
|
<button type="submit" disabled={busy}>
|
||||||
|
{busy ? "正在登录…" : "登录"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
+405
-128
@@ -92,6 +92,19 @@ export function setDemoContext(input: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// API client for the platform backend.
|
||||||
|
//
|
||||||
|
// All endpoints that take a workspace context require the caller to
|
||||||
|
// pass `workspaceId` explicitly. Components read the active workspace
|
||||||
|
// from `useAuth().currentWorkspace` and thread it through; the cookie
|
||||||
|
// set by `/api/v1/auth/login` is sent automatically thanks to
|
||||||
|
// `credentials: "same-origin"`, and the backend reads it via the
|
||||||
|
// shared `request_context` dependency.
|
||||||
|
//
|
||||||
|
// 401 from any endpoint means the session has expired or was never
|
||||||
|
// established; the global `apiRequest` helper bounces the user to
|
||||||
|
// `/login` so the platform never tries to render with a stale identity.
|
||||||
|
|
||||||
export type ScriptType = "python" | "notebook";
|
export type ScriptType = "python" | "notebook";
|
||||||
export type Visibility = "private" | "workspace" | "public";
|
export type Visibility = "private" | "workspace" | "public";
|
||||||
|
|
||||||
@@ -162,22 +175,40 @@ export class ApiRequestError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function appendWorkspaceId(path: string, workspaceId: string): string {
|
||||||
|
// `path` may already contain a query string. Use URLSearchParams to
|
||||||
|
// merge cleanly either way.
|
||||||
|
const separator = path.includes("?") ? "&" : "?";
|
||||||
|
return `${path}${separator}workspace_id=${encodeURIComponent(workspaceId)}`;
|
||||||
|
}
|
||||||
|
|
||||||
async function apiRequest<T>(
|
async function apiRequest<T>(
|
||||||
path: string,
|
path: string,
|
||||||
init: RequestInit = {},
|
init: RequestInit = {},
|
||||||
|
workspaceId?: string,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const response = await fetch(path, {
|
const finalPath = workspaceId ? appendWorkspaceId(path, workspaceId) : path;
|
||||||
|
const response = await fetch(finalPath, {
|
||||||
...init,
|
...init,
|
||||||
credentials: "same-origin",
|
credentials: "same-origin",
|
||||||
headers: {
|
headers: {
|
||||||
"X-User-ID": demoContext.userId,
|
|
||||||
"X-Workspace-ID": demoContext.workspaceId,
|
|
||||||
"X-Request-ID": createUuid().replaceAll("-", ""),
|
"X-Request-ID": createUuid().replaceAll("-", ""),
|
||||||
...(init.body ? { "Content-Type": "application/json" } : {}),
|
...(init.body ? { "Content-Type": "application/json" } : {}),
|
||||||
...init.headers,
|
...init.headers,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Session expired / never authenticated — bounce to login. The
|
||||||
|
// /login route itself is the only path that must remain reachable
|
||||||
|
// while anonymous, so the redirect there is safe.
|
||||||
|
if (response.status === 401 && typeof window !== "undefined") {
|
||||||
|
const here = window.location.pathname;
|
||||||
|
if (here !== "/login") {
|
||||||
|
window.location.assign("/login");
|
||||||
|
}
|
||||||
|
throw new ApiRequestError("未登录或登录已过期", 401);
|
||||||
|
}
|
||||||
|
|
||||||
const payload = (await response.json().catch(() => ({}))) as
|
const payload = (await response.json().catch(() => ({}))) as
|
||||||
| ApiEnvelope<T>
|
| ApiEnvelope<T>
|
||||||
| ApiErrorEnvelope;
|
| ApiErrorEnvelope;
|
||||||
@@ -201,8 +232,8 @@ async function apiRequest<T>(
|
|||||||
return (payload as ApiEnvelope<T>).data;
|
return (payload as ApiEnvelope<T>).data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listScripts(): Promise<ScriptItem[]> {
|
export async function listScripts(workspaceId: string): Promise<ScriptItem[]> {
|
||||||
return apiRequest<ScriptItem[]>("/api/v1/scripts");
|
return apiRequest<ScriptItem[]>("/api/v1/scripts", {}, workspaceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function initialContent(scriptType: ScriptType): string {
|
function initialContent(scriptType: ScriptType): string {
|
||||||
@@ -258,13 +289,18 @@ function initialContent(scriptType: ScriptType): string {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createScript(input: {
|
export async function createScript(
|
||||||
|
workspaceId: string,
|
||||||
|
input: {
|
||||||
name: string;
|
name: string;
|
||||||
scriptType: ScriptType;
|
scriptType: ScriptType;
|
||||||
visibility: Visibility;
|
visibility: Visibility;
|
||||||
parentPath?: string | null;
|
parentPath?: string | null;
|
||||||
}): Promise<ScriptItem> {
|
},
|
||||||
return apiRequest<ScriptItem>("/api/v1/scripts", {
|
): Promise<ScriptItem> {
|
||||||
|
return apiRequest<ScriptItem>(
|
||||||
|
"/api/v1/scripts",
|
||||||
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
script_name: input.name.trim(),
|
script_name: input.name.trim(),
|
||||||
@@ -273,10 +309,13 @@ export async function createScript(input: {
|
|||||||
content: initialContent(input.scriptType),
|
content: initialContent(input.scriptType),
|
||||||
parent_path: input.parentPath,
|
parent_path: input.parentPath,
|
||||||
}),
|
}),
|
||||||
});
|
},
|
||||||
|
workspaceId,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function uploadScript(
|
export async function uploadScript(
|
||||||
|
workspaceId: string,
|
||||||
file: File,
|
file: File,
|
||||||
parentPath = "",
|
parentPath = "",
|
||||||
visibility: Visibility = "workspace",
|
visibility: Visibility = "workspace",
|
||||||
@@ -293,38 +332,64 @@ export async function uploadScript(
|
|||||||
headers: { "Content-Type": "application/octet-stream" },
|
headers: { "Content-Type": "application/octet-stream" },
|
||||||
body: file,
|
body: file,
|
||||||
},
|
},
|
||||||
|
workspaceId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateScript(
|
||||||
|
workspaceId: string,
|
||||||
|
scriptId: string,
|
||||||
|
input: { content: string },
|
||||||
|
): Promise<ScriptItem> {
|
||||||
|
return apiRequest<ScriptItem>(
|
||||||
|
`/api/v1/scripts/${scriptId}`,
|
||||||
|
{ method: "PUT", body: JSON.stringify(input) },
|
||||||
|
workspaceId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteScript(
|
export async function deleteScript(
|
||||||
|
workspaceId: string,
|
||||||
scriptId: string,
|
scriptId: string,
|
||||||
): Promise<{ script_id: string; status: string; versions_preserved: boolean }> {
|
): Promise<{ script_id: string; status: string; versions_preserved: boolean }> {
|
||||||
return apiRequest(`/api/v1/scripts/${scriptId}`, { method: "DELETE" });
|
return apiRequest(
|
||||||
|
`/api/v1/scripts/${scriptId}`,
|
||||||
|
{ method: "DELETE" },
|
||||||
|
workspaceId,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listWorkspaceDirectories(): Promise<
|
export async function listWorkspaceDirectories(
|
||||||
WorkspaceDirectory[]
|
workspaceId: string,
|
||||||
> {
|
): Promise<WorkspaceDirectory[]> {
|
||||||
const data = await apiRequest<{ directories: WorkspaceDirectory[] }>(
|
const data = await apiRequest<{ directories: WorkspaceDirectory[] }>(
|
||||||
"/api/v1/workspace-tree",
|
"/api/v1/workspace-tree",
|
||||||
|
{},
|
||||||
|
workspaceId,
|
||||||
);
|
);
|
||||||
return data.directories;
|
return data.directories;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createWorkspaceDirectory(
|
export async function createWorkspaceDirectory(
|
||||||
|
workspaceId: string,
|
||||||
directoryName: string,
|
directoryName: string,
|
||||||
parentPath = "",
|
parentPath = "",
|
||||||
): Promise<WorkspaceDirectory> {
|
): Promise<WorkspaceDirectory> {
|
||||||
return apiRequest<WorkspaceDirectory>("/api/v1/workspace-directories", {
|
return apiRequest<WorkspaceDirectory>(
|
||||||
|
"/api/v1/workspace-directories",
|
||||||
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
directory_name: directoryName,
|
directory_name: directoryName,
|
||||||
parent_path: parentPath,
|
parent_path: parentPath,
|
||||||
}),
|
}),
|
||||||
});
|
},
|
||||||
|
workspaceId,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteWorkspaceDirectory(
|
export async function deleteWorkspaceDirectory(
|
||||||
|
workspaceId: string,
|
||||||
path: string,
|
path: string,
|
||||||
): Promise<{
|
): Promise<{
|
||||||
path: string;
|
path: string;
|
||||||
@@ -333,9 +398,11 @@ export async function deleteWorkspaceDirectory(
|
|||||||
versions_preserved: boolean;
|
versions_preserved: boolean;
|
||||||
}> {
|
}> {
|
||||||
const parameters = new URLSearchParams({ path });
|
const parameters = new URLSearchParams({ path });
|
||||||
return apiRequest(`/api/v1/workspace-directories?${parameters.toString()}`, {
|
return apiRequest(
|
||||||
method: "DELETE",
|
`/api/v1/workspace-directories?${parameters.toString()}`,
|
||||||
});
|
{ method: "DELETE" },
|
||||||
|
workspaceId,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FileLockSession = {
|
export type FileLockSession = {
|
||||||
@@ -386,83 +453,115 @@ export type StableVersion = {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Note: the file-lock and jupyter-ticket endpoints are not yet
|
||||||
|
// implemented in the backend (see the cookie+JWT auth refactor plan).
|
||||||
|
// They are retained here so the editor UI keeps its existing call
|
||||||
|
// sites, but they will return 404 until the backend ships the
|
||||||
|
// corresponding routes.
|
||||||
|
|
||||||
export async function acquireFileLock(
|
export async function acquireFileLock(
|
||||||
|
workspaceId: string,
|
||||||
script: ScriptItem,
|
script: ScriptItem,
|
||||||
): Promise<ActiveEditSession> {
|
): Promise<ActiveEditSession> {
|
||||||
const now = Date.now();
|
const session = await apiRequest<FileLockSession>(
|
||||||
|
`/api/v1/files/${script.current_object_id}/lock`,
|
||||||
|
{ method: "POST" },
|
||||||
|
workspaceId,
|
||||||
|
);
|
||||||
|
if (!session.lock_token) {
|
||||||
|
throw new Error("加锁成功响应缺少 lock_token");
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
edit_session_id: createUuid().replaceAll("-", ""),
|
...session,
|
||||||
workspace_id: script.workspace_id,
|
|
||||||
storage_object_id: script.current_object_id,
|
|
||||||
user_id: demoContext.userId,
|
|
||||||
session_status: "active",
|
|
||||||
lease_seconds: 3600,
|
|
||||||
heartbeat_interval_seconds: 300,
|
|
||||||
expires_at: new Date(now + 3600_000).toISOString(),
|
|
||||||
runtime_id: script.workspace_id,
|
|
||||||
jupyter_session_id: "demo-session",
|
|
||||||
relative_path: script.relative_path,
|
|
||||||
lock_token: "demo-unlocked-session",
|
|
||||||
script_id: script.script_id,
|
script_id: script.script_id,
|
||||||
script_name: script.script_name,
|
script_name: script.script_name,
|
||||||
jupyter_path: script.jupyter_path,
|
jupyter_path: session.relative_path ?? script.jupyter_path,
|
||||||
|
lock_token: session.lock_token,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function heartbeatFileLock(
|
export async function heartbeatFileLock(
|
||||||
|
workspaceId: string,
|
||||||
session: ActiveEditSession,
|
session: ActiveEditSession,
|
||||||
): Promise<FileLockSession> {
|
): Promise<FileLockSession> {
|
||||||
return {
|
return apiRequest<FileLockSession>(
|
||||||
...session,
|
`/api/v1/file-locks/${session.edit_session_id}/heartbeat`,
|
||||||
expires_at: new Date(Date.now() + 3600_000).toISOString(),
|
{
|
||||||
};
|
method: "POST",
|
||||||
|
body: JSON.stringify({ lock_token: session.lock_token }),
|
||||||
|
},
|
||||||
|
workspaceId,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function releaseFileLock(
|
export async function releaseFileLock(
|
||||||
|
workspaceId: string,
|
||||||
session: ActiveEditSession,
|
session: ActiveEditSession,
|
||||||
): Promise<FileLockSession> {
|
): Promise<FileLockSession> {
|
||||||
return { ...session, session_status: "closed" };
|
return apiRequest<FileLockSession>(
|
||||||
|
`/api/v1/file-locks/${session.edit_session_id}`,
|
||||||
|
{
|
||||||
|
method: "DELETE",
|
||||||
|
body: JSON.stringify({ lock_token: session.lock_token }),
|
||||||
|
},
|
||||||
|
workspaceId,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function releaseFileLockOnUnload(_session: ActiveEditSession): void {
|
export function releaseFileLockOnUnload(
|
||||||
// The current Backend deliberately has no persisted file-lock API.
|
workspaceId: string,
|
||||||
|
session: ActiveEditSession,
|
||||||
|
): void {
|
||||||
|
void fetch(
|
||||||
|
`/api/v1/file-locks/${session.edit_session_id}?workspace_id=${
|
||||||
|
encodeURIComponent(workspaceId)
|
||||||
|
}`,
|
||||||
|
{
|
||||||
|
method: "DELETE",
|
||||||
|
credentials: "same-origin",
|
||||||
|
keepalive: true,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ lock_token: session.lock_token }),
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createJupyterAccessTicket(
|
export async function createJupyterAccessTicket(
|
||||||
|
workspaceId: string,
|
||||||
session: ActiveEditSession,
|
session: ActiveEditSession,
|
||||||
): Promise<JupyterAccessTicket> {
|
): Promise<JupyterAccessTicket> {
|
||||||
const result = await apiRequest<{ expires_at: number }>(
|
return apiRequest<JupyterAccessTicket>(
|
||||||
"/api/v1/auth/demo-session",
|
"/api/v1/jupyter/access-tickets",
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
},
|
body: JSON.stringify({
|
||||||
);
|
|
||||||
const editorRoute = session.script_name.toLowerCase().endsWith(".ipynb")
|
|
||||||
? "notebooks"
|
|
||||||
: "edit";
|
|
||||||
const encodedPath = session.jupyter_path
|
|
||||||
.split("/")
|
|
||||||
.filter(Boolean)
|
|
||||||
.map(encodeURIComponent)
|
|
||||||
.join("/");
|
|
||||||
return {
|
|
||||||
edit_session_id: session.edit_session_id,
|
edit_session_id: session.edit_session_id,
|
||||||
jupyter_url: `/jupyter/${encodeURIComponent(session.workspace_id)}/${editorRoute}/${encodedPath}`,
|
lock_token: session.lock_token,
|
||||||
expires_at: new Date(result.expires_at * 1000).toISOString(),
|
}),
|
||||||
};
|
},
|
||||||
|
workspaceId,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listScriptVersions(
|
export async function listScriptVersions(
|
||||||
|
workspaceId: string,
|
||||||
scriptId: string,
|
scriptId: string,
|
||||||
): Promise<StableVersion[]> {
|
): Promise<StableVersion[]> {
|
||||||
return apiRequest<StableVersion[]>(`/api/v1/scripts/${scriptId}/versions`);
|
return apiRequest<StableVersion[]>(
|
||||||
|
`/api/v1/scripts/${scriptId}/versions`,
|
||||||
|
{},
|
||||||
|
workspaceId,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function publishScriptVersion(input: {
|
export async function publishScriptVersion(
|
||||||
|
workspaceId: string,
|
||||||
|
input: {
|
||||||
script: ScriptItem;
|
script: ScriptItem;
|
||||||
releaseNote: string;
|
releaseNote: string;
|
||||||
visibility: Visibility;
|
visibility: Visibility;
|
||||||
}): Promise<StableVersion> {
|
},
|
||||||
|
): Promise<StableVersion> {
|
||||||
return apiRequest<StableVersion>(
|
return apiRequest<StableVersion>(
|
||||||
`/api/v1/scripts/${input.script.script_id}/versions`,
|
`/api/v1/scripts/${input.script.script_id}/versions`,
|
||||||
{
|
{
|
||||||
@@ -473,6 +572,7 @@ export async function publishScriptVersion(input: {
|
|||||||
visibility: input.visibility,
|
visibility: input.visibility,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
workspaceId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -625,15 +725,24 @@ export type ScheduleRunDetail = ScheduleRunSummary & {
|
|||||||
node_runs: ScheduleNodeRun[];
|
node_runs: ScheduleNodeRun[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function listSchedules(): Promise<Schedule[]> {
|
export async function listSchedules(workspaceId: string): Promise<Schedule[]> {
|
||||||
return apiRequest<Schedule[]>("/api/v1/schedules");
|
return apiRequest<Schedule[]>("/api/v1/schedules", {}, workspaceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getSchedule(scheduleId: string): Promise<Schedule> {
|
export async function getSchedule(
|
||||||
return apiRequest<Schedule>(`/api/v1/schedules/${scheduleId}`);
|
workspaceId: string,
|
||||||
|
scheduleId: string,
|
||||||
|
): Promise<Schedule> {
|
||||||
|
return apiRequest<Schedule>(
|
||||||
|
`/api/v1/schedules/${scheduleId}`,
|
||||||
|
{},
|
||||||
|
workspaceId,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createSchedule(input: {
|
export async function createSchedule(
|
||||||
|
workspaceId: string,
|
||||||
|
input: {
|
||||||
schedule_name: string;
|
schedule_name: string;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
trigger_type?: "manual" | "cron" | "api";
|
trigger_type?: "manual" | "cron" | "api";
|
||||||
@@ -642,14 +751,17 @@ export async function createSchedule(input: {
|
|||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
max_concurrency?: number;
|
max_concurrency?: number;
|
||||||
failure_policy?: "stop" | "continue";
|
failure_policy?: "stop" | "continue";
|
||||||
}): Promise<Schedule> {
|
},
|
||||||
return apiRequest<Schedule>("/api/v1/schedules", {
|
): Promise<Schedule> {
|
||||||
method: "POST",
|
return apiRequest<Schedule>(
|
||||||
body: JSON.stringify(input),
|
"/api/v1/schedules",
|
||||||
});
|
{ method: "POST", body: JSON.stringify(input) },
|
||||||
|
workspaceId,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateSchedule(
|
export async function updateSchedule(
|
||||||
|
workspaceId: string,
|
||||||
scheduleId: string,
|
scheduleId: string,
|
||||||
input: {
|
input: {
|
||||||
workflow_version: number;
|
workflow_version: number;
|
||||||
@@ -663,55 +775,72 @@ export async function updateSchedule(
|
|||||||
failure_policy?: "stop" | "continue";
|
failure_policy?: "stop" | "continue";
|
||||||
},
|
},
|
||||||
): Promise<Schedule> {
|
): Promise<Schedule> {
|
||||||
return apiRequest<Schedule>(`/api/v1/schedules/${scheduleId}`, {
|
return apiRequest<Schedule>(
|
||||||
method: "PATCH",
|
`/api/v1/schedules/${scheduleId}`,
|
||||||
body: JSON.stringify(input),
|
{ method: "PATCH", body: JSON.stringify(input) },
|
||||||
});
|
workspaceId,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteSchedule(
|
export async function deleteSchedule(
|
||||||
|
workspaceId: string,
|
||||||
scheduleId: string,
|
scheduleId: string,
|
||||||
workflowVersion: number,
|
workflowVersion: number,
|
||||||
): Promise<{ schedule_id: string; deleted: boolean; workflow_version: number }> {
|
): Promise<{ schedule_id: string; deleted: boolean; workflow_version: number }> {
|
||||||
return apiRequest(`/api/v1/schedules/${scheduleId}`, {
|
return apiRequest(
|
||||||
method: "DELETE",
|
`/api/v1/schedules/${scheduleId}`,
|
||||||
body: JSON.stringify({ workflow_version: workflowVersion }),
|
{ method: "DELETE", body: JSON.stringify({ workflow_version: workflowVersion }) },
|
||||||
});
|
workspaceId,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listScheduleArtifacts(): Promise<ScheduleArtifact[]> {
|
export async function listScheduleArtifacts(
|
||||||
return apiRequest<ScheduleArtifact[]>("/api/v1/schedule-artifacts");
|
workspaceId: string,
|
||||||
|
): Promise<ScheduleArtifact[]> {
|
||||||
|
return apiRequest<ScheduleArtifact[]>(
|
||||||
|
"/api/v1/schedule-artifacts",
|
||||||
|
{},
|
||||||
|
workspaceId,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function hideScheduleArtifact(
|
export async function hideScheduleArtifact(
|
||||||
|
workspaceId: string,
|
||||||
versionsId: string,
|
versionsId: string,
|
||||||
): Promise<{
|
): Promise<{
|
||||||
versions_id: string;
|
versions_id: string;
|
||||||
deleted: boolean;
|
deleted: boolean;
|
||||||
artifact_preserved: boolean;
|
artifact_preserved: boolean;
|
||||||
}> {
|
}> {
|
||||||
return apiRequest(`/api/v1/versions/${versionsId}`, {
|
return apiRequest(
|
||||||
method: "DELETE",
|
`/api/v1/versions/${versionsId}`,
|
||||||
});
|
{ method: "DELETE" },
|
||||||
|
workspaceId,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listEmployees(): Promise<Employee[]> {
|
export async function listEmployees(workspaceId: string): Promise<Employee[]> {
|
||||||
return apiRequest<Employee[]>("/api/v1/admin/employees");
|
return apiRequest<Employee[]>("/api/v1/admin/employees", {}, workspaceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createEmployee(input: {
|
export async function createEmployee(
|
||||||
|
workspaceId: string,
|
||||||
|
input: {
|
||||||
username: string;
|
username: string;
|
||||||
display_name: string;
|
display_name: string;
|
||||||
email?: string | null;
|
email?: string | null;
|
||||||
role_code: "admin" | "developer";
|
role_code: "admin" | "developer";
|
||||||
}): Promise<Employee> {
|
},
|
||||||
return apiRequest<Employee>("/api/v1/admin/employees", {
|
): Promise<Employee> {
|
||||||
method: "POST",
|
return apiRequest<Employee>(
|
||||||
body: JSON.stringify(input),
|
"/api/v1/admin/employees",
|
||||||
});
|
{ method: "POST", body: JSON.stringify(input) },
|
||||||
|
workspaceId,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateEmployee(
|
export async function updateEmployee(
|
||||||
|
workspaceId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
input: {
|
input: {
|
||||||
display_name?: string;
|
display_name?: string;
|
||||||
@@ -720,21 +849,26 @@ export async function updateEmployee(
|
|||||||
status?: "active" | "disabled" | "locked";
|
status?: "active" | "disabled" | "locked";
|
||||||
},
|
},
|
||||||
): Promise<Employee> {
|
): Promise<Employee> {
|
||||||
return apiRequest<Employee>(`/api/v1/admin/employees/${userId}`, {
|
return apiRequest<Employee>(
|
||||||
method: "PATCH",
|
`/api/v1/admin/employees/${userId}`,
|
||||||
body: JSON.stringify(input),
|
{ method: "PATCH", body: JSON.stringify(input) },
|
||||||
});
|
workspaceId,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteEmployee(
|
export async function deleteEmployee(
|
||||||
|
workspaceId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
): Promise<{ user_id: string; deleted: boolean }> {
|
): Promise<{ user_id: string; deleted: boolean }> {
|
||||||
return apiRequest(`/api/v1/admin/employees/${userId}`, {
|
return apiRequest(
|
||||||
method: "DELETE",
|
`/api/v1/admin/employees/${userId}`,
|
||||||
});
|
{ method: "DELETE" },
|
||||||
|
workspaceId,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createScheduleNode(
|
export async function createScheduleNode(
|
||||||
|
workspaceId: string,
|
||||||
scheduleId: string,
|
scheduleId: string,
|
||||||
input: {
|
input: {
|
||||||
workflow_version: number;
|
workflow_version: number;
|
||||||
@@ -750,13 +884,15 @@ export async function createScheduleNode(
|
|||||||
env_refs_json?: Record<string, string>;
|
env_refs_json?: Record<string, string>;
|
||||||
},
|
},
|
||||||
): Promise<Schedule> {
|
): Promise<Schedule> {
|
||||||
return apiRequest<Schedule>(`/api/v1/schedules/${scheduleId}/nodes`, {
|
return apiRequest<Schedule>(
|
||||||
method: "POST",
|
`/api/v1/schedules/${scheduleId}/nodes`,
|
||||||
body: JSON.stringify(input),
|
{ method: "POST", body: JSON.stringify(input) },
|
||||||
});
|
workspaceId,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateScheduleNode(
|
export async function updateScheduleNode(
|
||||||
|
workspaceId: string,
|
||||||
scheduleId: string,
|
scheduleId: string,
|
||||||
nodeId: string,
|
nodeId: string,
|
||||||
input: {
|
input: {
|
||||||
@@ -774,28 +910,26 @@ export async function updateScheduleNode(
|
|||||||
): Promise<Schedule> {
|
): Promise<Schedule> {
|
||||||
return apiRequest<Schedule>(
|
return apiRequest<Schedule>(
|
||||||
`/api/v1/schedules/${scheduleId}/nodes/${nodeId}`,
|
`/api/v1/schedules/${scheduleId}/nodes/${nodeId}`,
|
||||||
{
|
{ method: "PUT", body: JSON.stringify(input) },
|
||||||
method: "PUT",
|
workspaceId,
|
||||||
body: JSON.stringify(input),
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteScheduleNode(
|
export async function deleteScheduleNode(
|
||||||
|
workspaceId: string,
|
||||||
scheduleId: string,
|
scheduleId: string,
|
||||||
nodeId: string,
|
nodeId: string,
|
||||||
workflowVersion: number,
|
workflowVersion: number,
|
||||||
): Promise<Schedule> {
|
): Promise<Schedule> {
|
||||||
return apiRequest<Schedule>(
|
return apiRequest<Schedule>(
|
||||||
`/api/v1/schedules/${scheduleId}/nodes/${nodeId}`,
|
`/api/v1/schedules/${scheduleId}/nodes/${nodeId}`,
|
||||||
{
|
{ method: "DELETE", body: JSON.stringify({ workflow_version: workflowVersion }) },
|
||||||
method: "DELETE",
|
workspaceId,
|
||||||
body: JSON.stringify({ workflow_version: workflowVersion }),
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createScheduleEdge(
|
export async function createScheduleEdge(
|
||||||
|
workspaceId: string,
|
||||||
scheduleId: string,
|
scheduleId: string,
|
||||||
input: {
|
input: {
|
||||||
workflow_version: number;
|
workflow_version: number;
|
||||||
@@ -804,50 +938,58 @@ export async function createScheduleEdge(
|
|||||||
condition_expr?: string | null;
|
condition_expr?: string | null;
|
||||||
},
|
},
|
||||||
): Promise<Schedule> {
|
): Promise<Schedule> {
|
||||||
return apiRequest<Schedule>(`/api/v1/schedules/${scheduleId}/edges`, {
|
return apiRequest<Schedule>(
|
||||||
method: "POST",
|
`/api/v1/schedules/${scheduleId}/edges`,
|
||||||
body: JSON.stringify(input),
|
{ method: "POST", body: JSON.stringify(input) },
|
||||||
});
|
workspaceId,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteScheduleEdge(
|
export async function deleteScheduleEdge(
|
||||||
|
workspaceId: string,
|
||||||
scheduleId: string,
|
scheduleId: string,
|
||||||
edgeId: string,
|
edgeId: string,
|
||||||
workflowVersion: number,
|
workflowVersion: number,
|
||||||
): Promise<Schedule> {
|
): Promise<Schedule> {
|
||||||
return apiRequest<Schedule>(
|
return apiRequest<Schedule>(
|
||||||
`/api/v1/schedules/${scheduleId}/edges/${edgeId}`,
|
`/api/v1/schedules/${scheduleId}/edges/${edgeId}`,
|
||||||
{
|
{ method: "DELETE", body: JSON.stringify({ workflow_version: workflowVersion }) },
|
||||||
method: "DELETE",
|
workspaceId,
|
||||||
body: JSON.stringify({ workflow_version: workflowVersion }),
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function validateSchedule(
|
export async function validateSchedule(
|
||||||
|
workspaceId: string,
|
||||||
scheduleId: string,
|
scheduleId: string,
|
||||||
): Promise<DagValidation & {
|
): Promise<DagValidation & {
|
||||||
schedule_id: string;
|
schedule_id: string;
|
||||||
workflow_version: number;
|
workflow_version: number;
|
||||||
}> {
|
}> {
|
||||||
return apiRequest(`/api/v1/schedules/${scheduleId}/validate`, {
|
return apiRequest(
|
||||||
method: "POST",
|
`/api/v1/schedules/${scheduleId}/validate`,
|
||||||
});
|
{ method: "POST" },
|
||||||
|
workspaceId,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function previewCron(input: {
|
export async function previewCron(
|
||||||
|
workspaceId: string,
|
||||||
|
input: {
|
||||||
cron_expression: string;
|
cron_expression: string;
|
||||||
timezone: string;
|
timezone: string;
|
||||||
count?: number;
|
count?: number;
|
||||||
base_time?: string;
|
base_time?: string;
|
||||||
}): Promise<CronPreview> {
|
},
|
||||||
return apiRequest<CronPreview>("/api/v1/cron/preview", {
|
): Promise<CronPreview> {
|
||||||
method: "POST",
|
return apiRequest<CronPreview>(
|
||||||
body: JSON.stringify(input),
|
"/api/v1/cron/preview",
|
||||||
});
|
{ method: "POST", body: JSON.stringify(input) },
|
||||||
|
workspaceId,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runScheduleNow(
|
export async function runScheduleNow(
|
||||||
|
workspaceId: string,
|
||||||
scheduleId: string,
|
scheduleId: string,
|
||||||
): Promise<ScheduleRunDetail> {
|
): Promise<ScheduleRunDetail> {
|
||||||
return apiRequest<ScheduleRunDetail>(
|
return apiRequest<ScheduleRunDetail>(
|
||||||
@@ -859,25 +1001,160 @@ export async function runScheduleNow(
|
|||||||
},
|
},
|
||||||
body: JSON.stringify({ reason: "manual_run" }),
|
body: JSON.stringify({ reason: "manual_run" }),
|
||||||
},
|
},
|
||||||
|
workspaceId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listScheduleRuns(input: {
|
export async function listScheduleRuns(
|
||||||
|
workspaceId: string,
|
||||||
|
input: {
|
||||||
scheduleId?: string;
|
scheduleId?: string;
|
||||||
status?: ScheduleRunStatus;
|
status?: ScheduleRunStatus;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
} = {}): Promise<ScheduleRunSummary[]> {
|
} = {},
|
||||||
|
): Promise<ScheduleRunSummary[]> {
|
||||||
const query = new URLSearchParams();
|
const query = new URLSearchParams();
|
||||||
if (input.scheduleId) query.set("schedule_id", input.scheduleId);
|
if (input.scheduleId) query.set("schedule_id", input.scheduleId);
|
||||||
if (input.status) query.set("status", input.status);
|
if (input.status) query.set("status", input.status);
|
||||||
query.set("limit", String(input.limit ?? 20));
|
query.set("limit", String(input.limit ?? 20));
|
||||||
return apiRequest<ScheduleRunSummary[]>(
|
return apiRequest<ScheduleRunSummary[]>(
|
||||||
`/api/v1/schedule-runs?${query.toString()}`,
|
`/api/v1/schedule-runs?${query.toString()}`,
|
||||||
|
{},
|
||||||
|
workspaceId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getScheduleRun(
|
export async function getScheduleRun(
|
||||||
|
workspaceId: string,
|
||||||
runId: string,
|
runId: string,
|
||||||
): Promise<ScheduleRunDetail> {
|
): Promise<ScheduleRunDetail> {
|
||||||
return apiRequest<ScheduleRunDetail>(`/api/v1/schedule-runs/${runId}`);
|
return apiRequest<ScheduleRunDetail>(
|
||||||
|
`/api/v1/schedule-runs/${runId}`,
|
||||||
|
{},
|
||||||
|
workspaceId,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Workspace-bound API surface.
|
||||||
|
//
|
||||||
|
// `useApi()` in ~/context/AuthContext returns an object where every
|
||||||
|
// function has had its first `workspaceId` argument pre-filled. The
|
||||||
|
// type below lets consumers import the bound type without depending
|
||||||
|
// on the raw functions. Keep this last in the file so the type
|
||||||
|
// references all the exports above.
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
export type WorkspaceBoundApi = {
|
||||||
|
listScripts: () => Promise<ScriptItem[]>;
|
||||||
|
createScript: (
|
||||||
|
input: Parameters<typeof createScript>[1],
|
||||||
|
) => Promise<ScriptItem>;
|
||||||
|
uploadScript: (
|
||||||
|
file: File,
|
||||||
|
parentPath?: string,
|
||||||
|
visibility?: Visibility,
|
||||||
|
) => Promise<ScriptItem>;
|
||||||
|
updateScript: (
|
||||||
|
scriptId: string,
|
||||||
|
input: Parameters<typeof updateScript>[2],
|
||||||
|
) => Promise<ScriptItem>;
|
||||||
|
deleteScript: (
|
||||||
|
scriptId: string,
|
||||||
|
) => Promise<{ script_id: string; status: string; versions_preserved: boolean }>;
|
||||||
|
listWorkspaceDirectories: () => Promise<WorkspaceDirectory[]>;
|
||||||
|
createWorkspaceDirectory: (
|
||||||
|
directoryName: string,
|
||||||
|
parentPath?: string,
|
||||||
|
) => Promise<WorkspaceDirectory>;
|
||||||
|
deleteWorkspaceDirectory: (
|
||||||
|
path: string,
|
||||||
|
) => Promise<{
|
||||||
|
path: string;
|
||||||
|
status: string;
|
||||||
|
deleted_scripts: number;
|
||||||
|
versions_preserved: boolean;
|
||||||
|
}>;
|
||||||
|
acquireFileLock: (
|
||||||
|
script: ScriptItem,
|
||||||
|
) => Promise<ActiveEditSession>;
|
||||||
|
heartbeatFileLock: (
|
||||||
|
session: ActiveEditSession,
|
||||||
|
) => Promise<FileLockSession>;
|
||||||
|
releaseFileLock: (
|
||||||
|
session: ActiveEditSession,
|
||||||
|
) => Promise<FileLockSession>;
|
||||||
|
releaseFileLockOnUnload: (session: ActiveEditSession) => void;
|
||||||
|
createJupyterAccessTicket: (
|
||||||
|
session: ActiveEditSession,
|
||||||
|
) => Promise<JupyterAccessTicket>;
|
||||||
|
listScriptVersions: (scriptId: string) => Promise<StableVersion[]>;
|
||||||
|
publishScriptVersion: (
|
||||||
|
input: Parameters<typeof publishScriptVersion>[1],
|
||||||
|
) => Promise<StableVersion>;
|
||||||
|
listSchedules: () => Promise<Schedule[]>;
|
||||||
|
getSchedule: (scheduleId: string) => Promise<Schedule>;
|
||||||
|
createSchedule: (
|
||||||
|
input: Parameters<typeof createSchedule>[1],
|
||||||
|
) => Promise<Schedule>;
|
||||||
|
updateSchedule: (
|
||||||
|
scheduleId: string,
|
||||||
|
input: Parameters<typeof updateSchedule>[2],
|
||||||
|
) => Promise<Schedule>;
|
||||||
|
deleteSchedule: (
|
||||||
|
scheduleId: string,
|
||||||
|
workflowVersion: number,
|
||||||
|
) => Promise<{ schedule_id: string; deleted: boolean; workflow_version: number }>;
|
||||||
|
listScheduleArtifacts: () => Promise<ScheduleArtifact[]>;
|
||||||
|
hideScheduleArtifact: (
|
||||||
|
versionsId: string,
|
||||||
|
) => Promise<{
|
||||||
|
versions_id: string;
|
||||||
|
deleted: boolean;
|
||||||
|
artifact_preserved: boolean;
|
||||||
|
}>;
|
||||||
|
listEmployees: () => Promise<Employee[]>;
|
||||||
|
createEmployee: (
|
||||||
|
input: Parameters<typeof createEmployee>[1],
|
||||||
|
) => Promise<Employee>;
|
||||||
|
updateEmployee: (
|
||||||
|
userId: string,
|
||||||
|
input: Parameters<typeof updateEmployee>[2],
|
||||||
|
) => Promise<Employee>;
|
||||||
|
deleteEmployee: (
|
||||||
|
userId: string,
|
||||||
|
) => Promise<{ user_id: string; deleted: boolean }>;
|
||||||
|
createScheduleNode: (
|
||||||
|
scheduleId: string,
|
||||||
|
input: Parameters<typeof createScheduleNode>[2],
|
||||||
|
) => Promise<Schedule>;
|
||||||
|
updateScheduleNode: (
|
||||||
|
scheduleId: string,
|
||||||
|
nodeId: string,
|
||||||
|
input: Parameters<typeof updateScheduleNode>[3],
|
||||||
|
) => Promise<Schedule>;
|
||||||
|
deleteScheduleNode: (
|
||||||
|
scheduleId: string,
|
||||||
|
nodeId: string,
|
||||||
|
workflowVersion: number,
|
||||||
|
) => Promise<Schedule>;
|
||||||
|
createScheduleEdge: (
|
||||||
|
scheduleId: string,
|
||||||
|
input: Parameters<typeof createScheduleEdge>[2],
|
||||||
|
) => Promise<Schedule>;
|
||||||
|
deleteScheduleEdge: (
|
||||||
|
scheduleId: string,
|
||||||
|
edgeId: string,
|
||||||
|
workflowVersion: number,
|
||||||
|
) => Promise<Schedule>;
|
||||||
|
validateSchedule: (
|
||||||
|
scheduleId: string,
|
||||||
|
) => Promise<DagValidation & { schedule_id: string; workflow_version: number }>;
|
||||||
|
previewCron: (
|
||||||
|
input: Parameters<typeof previewCron>[1],
|
||||||
|
) => Promise<CronPreview>;
|
||||||
|
runScheduleNow: (scheduleId: string) => Promise<ScheduleRunDetail>;
|
||||||
|
listScheduleRuns: (
|
||||||
|
input?: Parameters<typeof listScheduleRuns>[1],
|
||||||
|
) => Promise<ScheduleRunSummary[]>;
|
||||||
|
getScheduleRun: (runId: string) => Promise<ScheduleRunDetail>;
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"""enable password login for the seeded development users
|
||||||
|
|
||||||
|
Revision ID: 9a1b2c3d4e5f
|
||||||
|
Revises: b71c4f2a9d10
|
||||||
|
Create Date: 2026-08-03 16:00:00
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
import os
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from common.auth.passwords import hash_password
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "9a1b2c3d4e5f"
|
||||||
|
down_revision: str | Sequence[str] | None = "b71c4f2a9d10"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
SEEDED_USER_IDS = (
|
||||||
|
"0000000000RF6FG1SDBXG59S13",
|
||||||
|
"0000000000H2QYCGPCWQM1JSGS",
|
||||||
|
"0000000000RWG40ESZPGJT629J",
|
||||||
|
"00000000004CQV7WASJA6N6FW4",
|
||||||
|
)
|
||||||
|
DISABLED_PASSWORD = "demo-login-disabled"
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
password = os.environ.get("INITIAL_ADMIN_PASSWORD", "admin12345")
|
||||||
|
users = sa.table(
|
||||||
|
"users",
|
||||||
|
sa.column("user_id", sa.String),
|
||||||
|
sa.column("password_hash", sa.String),
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
users.update()
|
||||||
|
.where(users.c.user_id.in_(SEEDED_USER_IDS))
|
||||||
|
.where(users.c.password_hash == DISABLED_PASSWORD)
|
||||||
|
.values(password_hash=hash_password(password))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
users = sa.table(
|
||||||
|
"users",
|
||||||
|
sa.column("user_id", sa.String),
|
||||||
|
sa.column("password_hash", sa.String),
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
users.update()
|
||||||
|
.where(users.c.user_id.in_(SEEDED_USER_IDS))
|
||||||
|
.values(password_hash=DISABLED_PASSWORD)
|
||||||
|
)
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""add the RustFS trash object key
|
||||||
|
|
||||||
|
Revision ID: a2b3c4d5e6f7
|
||||||
|
Revises: 9a1b2c3d4e5f
|
||||||
|
Create Date: 2026-08-03 16:30:00
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "a2b3c4d5e6f7"
|
||||||
|
down_revision: str | Sequence[str] | None = "9a1b2c3d4e5f"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"storage_objects",
|
||||||
|
sa.Column(
|
||||||
|
"trash_key",
|
||||||
|
sa.String(length=1100),
|
||||||
|
nullable=True,
|
||||||
|
comment="Path inside the trash bucket where soft-deleted bytes are stored",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("storage_objects", "trash_key")
|
||||||
+4
-4
@@ -10,10 +10,10 @@ COPY --from=rclone/rclone:latest /usr/local/bin/rclone /usr/local/bin/rclone
|
|||||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv
|
COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv
|
||||||
|
|
||||||
RUN ( \
|
RUN ( \
|
||||||
sed -i 's/deb.debian.org/mirrors.cloud.aliyuncs.com/g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \
|
sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \
|
||||||
sed -i 's/deb.debian.org/mirrors.cloud.aliyuncs.com/g' /etc/apt/sources.list 2>/dev/null || \
|
sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list 2>/dev/null || \
|
||||||
sed -i 's/archive.ubuntu.com/mirrors.cloud.aliyuncs.com/g' /etc/apt/sources.list 2>/dev/null || \
|
sed -i 's/archive.ubuntu.com/mirrors.aliyun.com/g' /etc/apt/sources.list 2>/dev/null || \
|
||||||
sed -i 's/security.ubuntu.com/mirrors.cloud.aliyuncs.com/g' /etc/apt/sources.list 2>/dev/null \
|
sed -i 's/security.ubuntu.com/mirrors.aliyun.com/g' /etc/apt/sources.list 2>/dev/null \
|
||||||
) || ( \
|
) || ( \
|
||||||
sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \
|
sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \
|
||||||
sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list 2>/dev/null || \
|
sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list 2>/dev/null || \
|
||||||
|
|||||||
@@ -19,8 +19,11 @@ from runtime.process import (
|
|||||||
JUPYTER_PROCESSES,
|
JUPYTER_PROCESSES,
|
||||||
get_workspace,
|
get_workspace,
|
||||||
list_workspaces,
|
list_workspaces,
|
||||||
|
reconcile_processes,
|
||||||
scan_workspaces,
|
scan_workspaces,
|
||||||
|
start_reaper,
|
||||||
start_workspace,
|
start_workspace,
|
||||||
|
stop_reaper,
|
||||||
stop_workspace,
|
stop_workspace,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -37,12 +40,26 @@ async def lifespan(app: FastAPI):
|
|||||||
logger.info("Starting up Runtime Service...")
|
logger.info("Starting up Runtime Service...")
|
||||||
start_rclone_mount()
|
start_rclone_mount()
|
||||||
logger.info("Scanning workspaces")
|
logger.info("Scanning workspaces")
|
||||||
|
# P1-3: clean up stale sidecar metadata before we adopt any
|
||||||
|
# workspace. Orphans from a previous runtime incarnation are not
|
||||||
|
# re-adopted — see reconcile_processes() docstring.
|
||||||
|
counters = reconcile_processes()
|
||||||
|
logger.info(
|
||||||
|
f"Reconcile: scanned={counters['scanned']} "
|
||||||
|
f"removed_meta={counters['removed_meta']} "
|
||||||
|
f"live_in_registry={counters['live_in_registry']}"
|
||||||
|
)
|
||||||
await scan_workspaces()
|
await scan_workspaces()
|
||||||
|
# P1-3: spawn background idle reaper. Stopped in the lifespan
|
||||||
|
# finally block; awaits the cancellation to avoid a leaked task.
|
||||||
|
start_reaper()
|
||||||
logger.info("Runtime Service started")
|
logger.info("Runtime Service started")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
|
logger.info("Service is shutting down. Stopping reaper...")
|
||||||
|
await stop_reaper()
|
||||||
logger.info(
|
logger.info(
|
||||||
"Service is shutting down. Terminating all active Jupyter sub-processes..."
|
"Service is shutting down. Terminating all active Jupyter sub-processes..."
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,11 +4,37 @@ Owns the in-memory ``JUPYTER_PROCESSES`` dict, the ``STATE_LOCK`` that
|
|||||||
serializes mutations to it, and the per-workspace start/stop/list/get
|
serializes mutations to it, and the per-workspace start/stop/list/get
|
||||||
operations. Workspace discovery (startup scan) lives here because it is
|
operations. Workspace discovery (startup scan) lives here because it is
|
||||||
a thin wrapper over ``start_workspace``.
|
a thin wrapper over ``start_workspace``.
|
||||||
|
|
||||||
|
Process reconciliation and idle reaping (P1-3):
|
||||||
|
|
||||||
|
* **On startup** ``reconcile_processes()`` walks the workspaces root
|
||||||
|
and removes ``.runtime-meta.json`` files whose owning Jupyter
|
||||||
|
process is no longer tracked. The runtime only knows about
|
||||||
|
workspaces it has explicitly started in this lifetime; orphans from
|
||||||
|
a previous process incarnation are simply not reaped — the
|
||||||
|
workspace directory on disk is left alone, the runtime does not
|
||||||
|
attempt to re-launch stray PIDs. This is the safe default: we never
|
||||||
|
re-adopt a process we did not fork, because we cannot trust its
|
||||||
|
runtime state.
|
||||||
|
|
||||||
|
* **Idle reaping** runs in a background task started by
|
||||||
|
``runtime.main.lifespan``. Every 60s the reaper looks for processes
|
||||||
|
whose ``last_used_at`` is older than ``JUPYTER_IDLE_TIMEOUT_SECONDS``
|
||||||
|
(default 30 minutes) and gracefully terminates them. ``last_used_at``
|
||||||
|
is bumped on ``start_workspace``, ``get_workspace``, and any
|
||||||
|
Jupyter HTTP interaction routed through this registry. Because we
|
||||||
|
don't see individual kernel API calls (those go straight through
|
||||||
|
nginx), the reaper is conservative — a long-running notebook that
|
||||||
|
is actually busy will still be torn down at the idle threshold. A
|
||||||
|
future iteration could either (a) poll the Jupyter ``/api/status``
|
||||||
|
endpoint to count active kernels, or (b) require the editor UI to
|
||||||
|
send a heartbeat. Both are out of scope here.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -25,6 +51,27 @@ from runtime.mount import WORKSPACES_ROOT
|
|||||||
|
|
||||||
PUBLIC_BASE_URL = settings.public_base_url
|
PUBLIC_BASE_URL = settings.public_base_url
|
||||||
|
|
||||||
|
# P1-3: idle reaping tuning knobs. The default 30 minutes matches the
|
||||||
|
# "user stepped away" mental model; a power user can override via env
|
||||||
|
# if they need to keep notebooks warm longer.
|
||||||
|
JUPYTER_IDLE_TIMEOUT_SECONDS = int(
|
||||||
|
os.environ.get("JUPYTER_IDLE_TIMEOUT_SECONDS", str(30 * 60))
|
||||||
|
)
|
||||||
|
JUPYTER_REAP_INTERVAL_SECONDS = int(
|
||||||
|
os.environ.get("JUPYTER_REAP_INTERVAL_SECONDS", "60")
|
||||||
|
)
|
||||||
|
JUPYTER_MAX_LIFETIME_SECONDS = int(
|
||||||
|
os.environ.get("JUPYTER_MAX_LIFETIME_SECONDS", str(24 * 3600))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Sidecar metadata file written next to the workspace directory. Holds
|
||||||
|
# the runtime state we need to reconstruct the in-memory map after a
|
||||||
|
# crash. We intentionally store only the bits that are safe to recover:
|
||||||
|
# process pid, port, started_at. ``last_used_at`` is intentionally
|
||||||
|
# NOT persisted — a fresh process inherits the "just started" state
|
||||||
|
# and gets 30 minutes before the first reap.
|
||||||
|
RUNTIME_META_FILENAME = ".runtime-meta.json"
|
||||||
|
|
||||||
|
|
||||||
class JupyterProcessRecord(TypedDict):
|
class JupyterProcessRecord(TypedDict):
|
||||||
process: subprocess.Popen
|
process: subprocess.Popen
|
||||||
@@ -32,11 +79,14 @@ class JupyterProcessRecord(TypedDict):
|
|||||||
token: str
|
token: str
|
||||||
base_url: str
|
base_url: str
|
||||||
started_at: float
|
started_at: float
|
||||||
|
last_used_at: float
|
||||||
|
meta_path: str
|
||||||
|
|
||||||
|
|
||||||
JUPYTER_PROCESSES: dict[str, JupyterProcessRecord] = {}
|
JUPYTER_PROCESSES: dict[str, JupyterProcessRecord] = {}
|
||||||
WORKSPACE_LOCKS: dict[str, asyncio.Lock] = {}
|
WORKSPACE_LOCKS: dict[str, asyncio.Lock] = {}
|
||||||
_LOCKS_REGISTRY = asyncio.Lock()
|
_LOCKS_REGISTRY = asyncio.Lock()
|
||||||
|
_REAPER_TASK: asyncio.Task[None] | None = None
|
||||||
|
|
||||||
|
|
||||||
def get_workspace_lock(ws_id: str) -> asyncio.Lock:
|
def get_workspace_lock(ws_id: str) -> asyncio.Lock:
|
||||||
@@ -69,18 +119,60 @@ def _drop_workspace_lock(ws_id: str) -> None:
|
|||||||
WORKSPACE_LOCKS.pop(ws_id, None)
|
WORKSPACE_LOCKS.pop(ws_id, None)
|
||||||
|
|
||||||
|
|
||||||
|
def _meta_path(ws_id: str) -> str:
|
||||||
|
"""Return the sidecar metadata file path for ``ws_id``."""
|
||||||
|
return str(WORKSPACES_ROOT / ws_id / RUNTIME_META_FILENAME)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_meta(ws_id: str, record: JupyterProcessRecord) -> None:
|
||||||
|
"""Persist the bits of the record we can safely recover.
|
||||||
|
|
||||||
|
Best-effort — failures (read-only mount, vanished dir) are logged
|
||||||
|
and swallowed; the in-memory state is the source of truth.
|
||||||
|
"""
|
||||||
|
payload = {
|
||||||
|
"workspace_id": ws_id,
|
||||||
|
"pid": record["process"].pid,
|
||||||
|
"port": record["port"],
|
||||||
|
"started_at": record["started_at"],
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
with open(record["meta_path"], "w", encoding="utf-8") as fp:
|
||||||
|
json.dump(payload, fp)
|
||||||
|
except Exception as exc: # pragma: no cover - defensive
|
||||||
|
logger.warning(f"failed to write runtime meta for {ws_id}: {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
def _delete_meta(ws_id: str) -> None:
|
||||||
|
"""Best-effort remove of the sidecar metadata file."""
|
||||||
|
try:
|
||||||
|
os.unlink(_meta_path(ws_id))
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
except Exception as exc: # pragma: no cover - defensive
|
||||||
|
logger.warning(f"failed to delete runtime meta for {ws_id}: {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
def _bump_last_used(record: JupyterProcessRecord) -> None:
|
||||||
|
"""Touch ``last_used_at`` so the idle reaper does not eat active ws."""
|
||||||
|
record["last_used_at"] = time.time()
|
||||||
|
|
||||||
|
|
||||||
async def start_workspace(ws_id: str) -> dict:
|
async def start_workspace(ws_id: str) -> dict:
|
||||||
async with get_workspace_lock(ws_id):
|
async with get_workspace_lock(ws_id):
|
||||||
workspace_path = WORKSPACES_ROOT / ws_id
|
workspace_path = WORKSPACES_ROOT / ws_id
|
||||||
|
workspace_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
if ws_id in JUPYTER_PROCESSES:
|
if ws_id in JUPYTER_PROCESSES:
|
||||||
p_info = JUPYTER_PROCESSES[ws_id]
|
p_info = JUPYTER_PROCESSES[ws_id]
|
||||||
if p_info["process"].poll() is None:
|
if p_info["process"].poll() is None:
|
||||||
if time.time() - p_info["started_at"] > 24 * 3600:
|
if time.time() - p_info["started_at"] > JUPYTER_MAX_LIFETIME_SECONDS:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Reusing Jupyter for {ws_id} older than 24h "
|
f"Reusing Jupyter for {ws_id} older than "
|
||||||
|
f"{JUPYTER_MAX_LIFETIME_SECONDS}s "
|
||||||
f"(started_at={p_info['started_at']})"
|
f"(started_at={p_info['started_at']})"
|
||||||
)
|
)
|
||||||
|
_bump_last_used(p_info)
|
||||||
return {
|
return {
|
||||||
"status": "running",
|
"status": "running",
|
||||||
"workspace_id": ws_id,
|
"workspace_id": ws_id,
|
||||||
@@ -93,6 +185,9 @@ async def start_workspace(ws_id: str) -> dict:
|
|||||||
f"?token={p_info['token']}"
|
f"?token={p_info['token']}"
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
# Process died but we still hold a record — drop it and
|
||||||
|
# start a fresh one.
|
||||||
|
_delete_meta(ws_id)
|
||||||
del JUPYTER_PROCESSES[ws_id]
|
del JUPYTER_PROCESSES[ws_id]
|
||||||
|
|
||||||
port = get_free_port()
|
port = get_free_port()
|
||||||
@@ -125,14 +220,18 @@ async def start_workspace(ws_id: str) -> dict:
|
|||||||
)
|
)
|
||||||
|
|
||||||
full_url = f"{PUBLIC_BASE_URL}:{port}{base_path}?token={token}"
|
full_url = f"{PUBLIC_BASE_URL}:{port}{base_path}?token={token}"
|
||||||
|
meta_path = _meta_path(ws_id)
|
||||||
|
now = time.time()
|
||||||
JUPYTER_PROCESSES[ws_id] = {
|
JUPYTER_PROCESSES[ws_id] = {
|
||||||
"process": process,
|
"process": process,
|
||||||
"base_url": PUBLIC_BASE_URL,
|
"base_url": PUBLIC_BASE_URL,
|
||||||
"port": port,
|
"port": port,
|
||||||
"token": token,
|
"token": token,
|
||||||
"started_at": time.time(),
|
"started_at": now,
|
||||||
|
"last_used_at": now,
|
||||||
|
"meta_path": meta_path,
|
||||||
}
|
}
|
||||||
|
_write_meta(ws_id, JUPYTER_PROCESSES[ws_id])
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Started Jupyter for workspace {ws_id} "
|
f"Started Jupyter for workspace {ws_id} "
|
||||||
@@ -176,6 +275,7 @@ async def stop_workspace(ws_id: str) -> dict:
|
|||||||
logger.error(f"Failed to kill Jupyter process for {ws_id}: {err}")
|
logger.error(f"Failed to kill Jupyter process for {ws_id}: {err}")
|
||||||
|
|
||||||
del JUPYTER_PROCESSES[ws_id]
|
del JUPYTER_PROCESSES[ws_id]
|
||||||
|
_delete_meta(ws_id)
|
||||||
|
|
||||||
_drop_workspace_lock(ws_id)
|
_drop_workspace_lock(ws_id)
|
||||||
return {
|
return {
|
||||||
@@ -198,6 +298,8 @@ async def list_workspaces() -> dict:
|
|||||||
f"?token={info['token']}"
|
f"?token={info['token']}"
|
||||||
),
|
),
|
||||||
"is_alive": info["process"].poll() is None,
|
"is_alive": info["process"].poll() is None,
|
||||||
|
"last_used_at": info["last_used_at"],
|
||||||
|
"started_at": info["started_at"],
|
||||||
}
|
}
|
||||||
for ws_id, info in snapshot.items()
|
for ws_id, info in snapshot.items()
|
||||||
},
|
},
|
||||||
@@ -217,7 +319,9 @@ async def get_workspace(ws_id: str) -> dict:
|
|||||||
|
|
||||||
if not is_alive:
|
if not is_alive:
|
||||||
del JUPYTER_PROCESSES[ws_id]
|
del JUPYTER_PROCESSES[ws_id]
|
||||||
|
_delete_meta(ws_id)
|
||||||
else:
|
else:
|
||||||
|
_bump_last_used(p_info)
|
||||||
return {
|
return {
|
||||||
"status": "running",
|
"status": "running",
|
||||||
"pid": p_info["process"].pid,
|
"pid": p_info["process"].pid,
|
||||||
@@ -230,6 +334,7 @@ async def get_workspace(ws_id: str) -> dict:
|
|||||||
f"?token={p_info['token']}"
|
f"?token={p_info['token']}"
|
||||||
),
|
),
|
||||||
"started_at": p_info["started_at"],
|
"started_at": p_info["started_at"],
|
||||||
|
"last_used_at": p_info["last_used_at"],
|
||||||
}
|
}
|
||||||
|
|
||||||
_drop_workspace_lock(ws_id)
|
_drop_workspace_lock(ws_id)
|
||||||
@@ -242,6 +347,152 @@ async def get_workspace(ws_id: str) -> dict:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def reconcile_processes() -> dict[str, int]:
|
||||||
|
"""P1-3 startup sweep.
|
||||||
|
|
||||||
|
Walks the workspaces root and inspects ``.runtime-meta.json``
|
||||||
|
sidecars. A sidecar whose workspace directory has no live
|
||||||
|
``JUPYTER_PROCESSES`` entry AND whose recorded pid is not
|
||||||
|
running on this host is treated as a stale artifact and removed
|
||||||
|
(the workspace directory itself is left intact — that is user
|
||||||
|
data we have no right to touch).
|
||||||
|
|
||||||
|
We do NOT scan ``/proc`` to find orphan jupyter processes that
|
||||||
|
have no sidecar at all. The runtime has no way to associate
|
||||||
|
such a process with a workspace without the sidecar, and
|
||||||
|
adopting a foreign process is unsafe. Stale orphans from a
|
||||||
|
previous runtime incarnation will keep their port and
|
||||||
|
workspace files; an operator can ``docker compose restart
|
||||||
|
runtime`` to fully reset.
|
||||||
|
|
||||||
|
Returns a small counter dict so the caller can log it.
|
||||||
|
"""
|
||||||
|
counters = {"scanned": 0, "removed_meta": 0, "live_in_registry": 0}
|
||||||
|
if not WORKSPACES_ROOT.exists():
|
||||||
|
return counters
|
||||||
|
try:
|
||||||
|
entries = os.listdir(WORKSPACES_ROOT)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(f"reconcile_processes: cannot list workspaces: {exc}")
|
||||||
|
return counters
|
||||||
|
for entry in entries:
|
||||||
|
ws_path = WORKSPACES_ROOT / entry
|
||||||
|
if not ws_path.is_dir():
|
||||||
|
continue
|
||||||
|
meta_file = ws_path / RUNTIME_META_FILENAME
|
||||||
|
if not meta_file.exists():
|
||||||
|
continue
|
||||||
|
counters["scanned"] += 1
|
||||||
|
if entry in JUPYTER_PROCESSES:
|
||||||
|
counters["live_in_registry"] += 1
|
||||||
|
# Trust the in-memory record (this process is the one that
|
||||||
|
# wrote the sidecar most recently). Refresh it.
|
||||||
|
_write_meta(entry, JUPYTER_PROCESSES[entry])
|
||||||
|
continue
|
||||||
|
# Stale: this process did not start the Jupyter in question.
|
||||||
|
# Verify the pid is dead; if it is, drop the sidecar so the
|
||||||
|
# next scan is clean. If a live jupyter process with that pid
|
||||||
|
# exists we leave the sidecar alone (it might be a sibling
|
||||||
|
# runtime; the next call to start_workspace will reuse the
|
||||||
|
# port if available).
|
||||||
|
try:
|
||||||
|
with open(meta_file, encoding="utf-8") as fp:
|
||||||
|
meta = json.load(fp)
|
||||||
|
pid = int(meta.get("pid", 0))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.info(f"reconcile: removing malformed meta for {entry}: {exc}")
|
||||||
|
_delete_meta(entry)
|
||||||
|
counters["removed_meta"] += 1
|
||||||
|
continue
|
||||||
|
if pid <= 0:
|
||||||
|
_delete_meta(entry)
|
||||||
|
counters["removed_meta"] += 1
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
os.kill(pid, 0)
|
||||||
|
alive = True
|
||||||
|
except ProcessLookupError:
|
||||||
|
alive = False
|
||||||
|
except PermissionError:
|
||||||
|
alive = True # someone else's process, leave alone
|
||||||
|
if not alive:
|
||||||
|
logger.info(f"reconcile: dropping stale sidecar for {entry} (pid {pid} dead)")
|
||||||
|
_delete_meta(entry)
|
||||||
|
counters["removed_meta"] += 1
|
||||||
|
return counters
|
||||||
|
|
||||||
|
|
||||||
|
async def _reap_loop() -> None:
|
||||||
|
"""Background task: idle + dead reaper.
|
||||||
|
|
||||||
|
Runs every ``JUPYTER_REAP_INTERVAL_SECONDS``; stops on
|
||||||
|
:class:`asyncio.CancelledError`. Decisions:
|
||||||
|
* process is dead (poll() != None) → drop the record + meta
|
||||||
|
* process is alive but ``last_used_at`` is older than the
|
||||||
|
idle timeout → graceful stop
|
||||||
|
* process is alive but older than the max lifetime → graceful
|
||||||
|
stop (defence in depth: prevents a workspace from holding a
|
||||||
|
port forever)
|
||||||
|
"""
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(JUPYTER_REAP_INTERVAL_SECONDS)
|
||||||
|
now = time.time()
|
||||||
|
victims: list[str] = []
|
||||||
|
for ws_id, info in list(JUPYTER_PROCESSES.items()):
|
||||||
|
if info["process"].poll() is not None:
|
||||||
|
logger.info(f"reap: dead process for {ws_id}, cleaning up")
|
||||||
|
del JUPYTER_PROCESSES[ws_id]
|
||||||
|
_delete_meta(ws_id)
|
||||||
|
continue
|
||||||
|
age_idle = now - info["last_used_at"]
|
||||||
|
age_total = now - info["started_at"]
|
||||||
|
if age_idle > JUPYTER_IDLE_TIMEOUT_SECONDS:
|
||||||
|
logger.info(
|
||||||
|
f"reap: idle Jupyter for {ws_id} "
|
||||||
|
f"(idle={age_idle:.0f}s > {JUPYTER_IDLE_TIMEOUT_SECONDS}s)"
|
||||||
|
)
|
||||||
|
victims.append(ws_id)
|
||||||
|
elif age_total > JUPYTER_MAX_LIFETIME_SECONDS:
|
||||||
|
logger.info(
|
||||||
|
f"reap: max-lifetime Jupyter for {ws_id} "
|
||||||
|
f"(age={age_total:.0f}s > {JUPYTER_MAX_LIFETIME_SECONDS}s)"
|
||||||
|
)
|
||||||
|
victims.append(ws_id)
|
||||||
|
for ws_id in victims:
|
||||||
|
try:
|
||||||
|
await stop_workspace(ws_id)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(f"reap: failed to stop {ws_id}: {exc}")
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception(f"reap loop failed: {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
def start_reaper() -> asyncio.Task[None]:
|
||||||
|
"""Spawn the background idle reaper. Idempotent."""
|
||||||
|
global _REAPER_TASK
|
||||||
|
if _REAPER_TASK is not None and not _REAPER_TASK.done():
|
||||||
|
return _REAPER_TASK
|
||||||
|
_REAPER_TASK = asyncio.create_task(_reap_loop(), name="jupyter-idle-reaper")
|
||||||
|
return _REAPER_TASK
|
||||||
|
|
||||||
|
|
||||||
|
async def stop_reaper() -> None:
|
||||||
|
"""Cancel the reaper and wait for it to finish (called from lifespan)."""
|
||||||
|
global _REAPER_TASK
|
||||||
|
task = _REAPER_TASK
|
||||||
|
_REAPER_TASK = None
|
||||||
|
if task is None or task.done():
|
||||||
|
return
|
||||||
|
task.cancel()
|
||||||
|
try:
|
||||||
|
await task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
async def scan_workspaces() -> None:
|
async def scan_workspaces() -> None:
|
||||||
if not WORKSPACES_ROOT.exists():
|
if not WORKSPACES_ROOT.exists():
|
||||||
return
|
return
|
||||||
@@ -257,6 +508,10 @@ async def scan_workspaces() -> None:
|
|||||||
path = WORKSPACES_ROOT / entry
|
path = WORKSPACES_ROOT / entry
|
||||||
if not path.is_dir():
|
if not path.is_dir():
|
||||||
return
|
return
|
||||||
|
# Skip workspace dirs that already have a live Jupyter tracked
|
||||||
|
# in this process — saves a port allocation and a noop start.
|
||||||
|
if entry in JUPYTER_PROCESSES:
|
||||||
|
return
|
||||||
logger.info(f"Found workspace: {entry}")
|
logger.info(f"Found workspace: {entry}")
|
||||||
logger.info(f"Auto-starting Jupyter for workspace ID: '{entry}'")
|
logger.info(f"Auto-starting Jupyter for workspace ID: '{entry}'")
|
||||||
async with sem:
|
async with sem:
|
||||||
|
|||||||
+4
-4
@@ -7,10 +7,10 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN ( \
|
RUN ( \
|
||||||
sed -i 's/deb.debian.org/mirrors.cloud.aliyuncs.com/g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \
|
sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \
|
||||||
sed -i 's/deb.debian.org/mirrors.cloud.aliyuncs.com/g' /etc/apt/sources.list 2>/dev/null || \
|
sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list 2>/dev/null || \
|
||||||
sed -i 's/archive.ubuntu.com/mirrors.cloud.aliyuncs.com/g' /etc/apt/sources.list 2>/dev/null || \
|
sed -i 's/archive.ubuntu.com/mirrors.aliyun.com/g' /etc/apt/sources.list 2>/dev/null || \
|
||||||
sed -i 's/security.ubuntu.com/mirrors.cloud.aliyuncs.com/g' /etc/apt/sources.list 2>/dev/null \
|
sed -i 's/security.ubuntu.com/mirrors.aliyun.com/g' /etc/apt/sources.list 2>/dev/null \
|
||||||
) || ( \
|
) || ( \
|
||||||
sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \
|
sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \
|
||||||
sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list 2>/dev/null || \
|
sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list 2>/dev/null || \
|
||||||
|
|||||||
@@ -18,12 +18,12 @@ from schedule.storage_client import SchedulerStorageClient
|
|||||||
async def lifespan(app: Any) -> AsyncIterator[None]:
|
async def lifespan(app: Any) -> AsyncIterator[None]:
|
||||||
engine = create_database_engine(settings.database_url)
|
engine = create_database_engine(settings.database_url)
|
||||||
session_factory = create_session_factory(engine)
|
session_factory = create_session_factory(engine)
|
||||||
backend_http_client = build_storage_http_client()
|
storage_http_client = build_storage_http_client()
|
||||||
service = SchedulerService(
|
service = SchedulerService(
|
||||||
session_factory=session_factory,
|
session_factory=session_factory,
|
||||||
backend_http_client=backend_http_client,
|
storage_http_client=storage_http_client,
|
||||||
object_store=build_object_store(),
|
object_store=build_object_store(),
|
||||||
storage_client=SchedulerStorageClient(backend_http_client),
|
storage_client=SchedulerStorageClient(storage_http_client),
|
||||||
database_url=settings.database_url,
|
database_url=settings.database_url,
|
||||||
)
|
)
|
||||||
app.state.scheduler_service = service
|
app.state.scheduler_service = service
|
||||||
@@ -32,7 +32,7 @@ async def lifespan(app: Any) -> AsyncIterator[None]:
|
|||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
await service.close()
|
await service.close()
|
||||||
await backend_http_client.aclose()
|
await storage_http_client.aclose()
|
||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -47,20 +47,30 @@ class DispatchOrchestrator:
|
|||||||
- ``_database_event_loop`` drains ``schedule.run.requested`` and
|
- ``_database_event_loop`` drains ``schedule.run.requested`` and
|
||||||
``job.node.finished`` events under ``dispatch_lock``. These are
|
``job.node.finished`` events under ``dispatch_lock``. These are
|
||||||
short, in-line DB transactions.
|
short, in-line DB transactions.
|
||||||
- ``_execution_loop`` claims ``job.node.execute`` events, sets a
|
- ``_execution_loop`` claims ``job.node.execute`` events whose
|
||||||
far-future ``available_at`` as a lease, then dispatches each as
|
``available_at <= utcnow()`` and dispatches each as
|
||||||
``asyncio.create_task`` so the polling path is never blocked by
|
``asyncio.create_task`` so the polling path is never blocked by
|
||||||
notebook execution. A semaphore caps concurrent notebooks.
|
notebook execution. A semaphore caps concurrent notebooks.
|
||||||
|
|
||||||
Holds a ``dispatch_lock`` to keep two concurrent drain loops from
|
Holds a ``dispatch_lock`` to keep two concurrent drain loops from
|
||||||
fighting over the same batch.
|
fighting over the same batch.
|
||||||
|
|
||||||
|
Lease semantics live on the outbox row itself, not in the claim
|
||||||
|
step. The dispatcher sets ``available_at = utcnow() + node_timeout
|
||||||
|
+ LEASE_SLACK`` when it writes the ``job.node.execute`` event, so a
|
||||||
|
process crash mid-execution lets the row re-eligible automatically
|
||||||
|
once the lease expires. A hard-coded 30-minute lease was the
|
||||||
|
original P0-2 bug: a node with ``timeout_seconds = 86_400`` would
|
||||||
|
be re-claimed at 30 minutes and run twice; a node with
|
||||||
|
``timeout_seconds = 60`` would have its lease expire 29 minutes
|
||||||
|
too early. Tieing the lease to the actual node timeout closes both
|
||||||
|
cases.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Lease window for a claimed ``job.node.execute`` event. If the
|
# Margin added on top of ``timeout_seconds`` when writing the lease
|
||||||
# process dies mid-execution, the row re-eligible after this many
|
# ``available_at``. Gives the worker time to update the row to a
|
||||||
# minutes. The handler is idempotent (it short-circuits on terminal
|
# terminal state before the poll re-picks it.
|
||||||
# node states), so safe re-execution.
|
LEASE_SLACK = timedelta(seconds=30)
|
||||||
EXECUTION_LEASE = timedelta(minutes=30)
|
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -140,11 +150,13 @@ class DispatchOrchestrator:
|
|||||||
) -> int:
|
) -> int:
|
||||||
"""Claim ``job.node.execute`` rows and dispatch them as tasks.
|
"""Claim ``job.node.execute`` rows and dispatch them as tasks.
|
||||||
|
|
||||||
The claim step bumps ``available_at`` to a far-future lease so
|
Lease is owned by the row itself (the dispatcher sets
|
||||||
the polling loop does not re-pick the same row while the
|
``available_at = utcnow() + node.timeout_seconds + LEASE_SLACK``
|
||||||
dispatched task is still running. Status stays ``pending``;
|
when the event is enqueued), so this method is a pure
|
||||||
the dispatched task flips it to ``published`` or ``failed``
|
read-and-dispatch — no DB writes in the claim step. If the
|
||||||
when execution completes.
|
process dies before ``_run_node_execute`` finishes, the row
|
||||||
|
re-eligible once ``available_at`` falls back to now; the worker
|
||||||
|
handler is idempotent (short-circuits on terminal node state).
|
||||||
"""
|
"""
|
||||||
async with session_scope(self.session_factory) as session:
|
async with session_scope(self.session_factory) as session:
|
||||||
statement = (
|
statement = (
|
||||||
@@ -158,17 +170,18 @@ class DispatchOrchestrator:
|
|||||||
.limit(limit)
|
.limit(limit)
|
||||||
)
|
)
|
||||||
events = list((await session.scalars(statement)).all())
|
events = list((await session.scalars(statement)).all())
|
||||||
claimed: list[tuple[dict[str, Any], str]] = []
|
claimed: list[tuple[dict[str, Any], str]] = [
|
||||||
lease_until = utcnow() + self.EXECUTION_LEASE
|
(
|
||||||
for item in events:
|
{
|
||||||
item.available_at = lease_until
|
|
||||||
envelope = {
|
|
||||||
"event_type": item.event_type,
|
"event_type": item.event_type,
|
||||||
"event_id": item.event_id,
|
"event_id": item.event_id,
|
||||||
"trace_id": item.trace_id,
|
"trace_id": item.trace_id,
|
||||||
"payload": item.payload_json,
|
"payload": item.payload_json,
|
||||||
}
|
},
|
||||||
claimed.append((envelope, f"mysql:{item.event_id}"))
|
f"mysql:{item.event_id}",
|
||||||
|
)
|
||||||
|
for item in events
|
||||||
|
]
|
||||||
for envelope, message_id in claimed:
|
for envelope, message_id in claimed:
|
||||||
task = asyncio.create_task(
|
task = asyncio.create_task(
|
||||||
self._run_node_execute(envelope, message_id),
|
self._run_node_execute(envelope, message_id),
|
||||||
@@ -379,6 +392,20 @@ class DispatchOrchestrator:
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
session.add(node_run)
|
session.add(node_run)
|
||||||
|
# Lease is owned by the outbox row, not the claim step. Pick
|
||||||
|
# the later of (now, scheduled retry) and the timeout + slack,
|
||||||
|
# so a slow node isn't re-dispatched while it's still running
|
||||||
|
# but a crashed node does become eligible again after its
|
||||||
|
# timeout expires. See P0-2 in the auth refactor plan.
|
||||||
|
retry_at = (
|
||||||
|
utcnow() + timedelta(seconds=delay_seconds)
|
||||||
|
if delay_seconds
|
||||||
|
else utcnow()
|
||||||
|
)
|
||||||
|
lease_at = utcnow() + timedelta(
|
||||||
|
seconds=int(node["timeout_seconds"])
|
||||||
|
) + self.LEASE_SLACK
|
||||||
|
available_at = max(retry_at, lease_at)
|
||||||
await add_outbox_event(
|
await add_outbox_event(
|
||||||
session,
|
session,
|
||||||
event_type="job.node.execute",
|
event_type="job.node.execute",
|
||||||
@@ -387,11 +414,7 @@ class DispatchOrchestrator:
|
|||||||
aggregate_type="schedule_node_run",
|
aggregate_type="schedule_node_run",
|
||||||
aggregate_id=node_run.node_run_id,
|
aggregate_id=node_run.node_run_id,
|
||||||
idempotency_key=f"{node_run.node_run_id}:{attempt_no}",
|
idempotency_key=f"{node_run.node_run_id}:{attempt_no}",
|
||||||
available_at=(
|
available_at=available_at,
|
||||||
utcnow() + timedelta(seconds=delay_seconds)
|
|
||||||
if delay_seconds
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
payload={
|
payload={
|
||||||
"workspace_id": run.workspace_id,
|
"workspace_id": run.workspace_id,
|
||||||
"run_id": run.run_id,
|
"run_id": run.run_id,
|
||||||
@@ -480,13 +503,29 @@ class DispatchOrchestrator:
|
|||||||
if node_id in latest:
|
if node_id in latest:
|
||||||
continue
|
continue
|
||||||
parent_runs = [latest.get(parent) for parent in parents[node_id]]
|
parent_runs = [latest.get(parent) for parent in parents[node_id]]
|
||||||
parent_failed = any(
|
# Decide whether this node should be skipped. A node
|
||||||
|
# is only skipped when we know it can never run:
|
||||||
|
# * ``stop_all`` — the whole run was aborted on the
|
||||||
|
# first failure, so any not-yet-dispatched node is
|
||||||
|
# dropped;
|
||||||
|
# * all parents are terminal AND at least one
|
||||||
|
# failed — there is no remaining success path.
|
||||||
|
# If even one parent is still ``queued`` or
|
||||||
|
# ``running`` we keep waiting: under ``failure_policy
|
||||||
|
# == 'continue'`` a sibling might still succeed and
|
||||||
|
# the failed parent does not block that.
|
||||||
|
parents_terminal = all(
|
||||||
item is not None
|
item is not None
|
||||||
and item.node_status in TERMINAL_NODE_STATES
|
and item.node_status in TERMINAL_NODE_STATES
|
||||||
and item.node_status != "succeeded"
|
|
||||||
for item in parent_runs
|
for item in parent_runs
|
||||||
)
|
)
|
||||||
if stop_all or parent_failed:
|
any_parent_failed = any(
|
||||||
|
item is not None
|
||||||
|
and item.node_status in FAILED_NODE_STATES
|
||||||
|
for item in parent_runs
|
||||||
|
)
|
||||||
|
parents_blocked = parents_terminal and any_parent_failed
|
||||||
|
if stop_all or parents_blocked:
|
||||||
skipped = ScheduleNodeRuns(
|
skipped = ScheduleNodeRuns(
|
||||||
node_run_id=new_ulid(),
|
node_run_id=new_ulid(),
|
||||||
run_id=run.run_id,
|
run_id=run.run_id,
|
||||||
@@ -500,15 +539,24 @@ class DispatchOrchestrator:
|
|||||||
message=(
|
message=(
|
||||||
"调度失败策略为 stop,未再启动"
|
"调度失败策略为 stop,未再启动"
|
||||||
if stop_all
|
if stop_all
|
||||||
else "上游节点未成功,已跳过"
|
else "上游节点全部终止且至少一个失败,已跳过"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
session.add(skipped)
|
session.add(skipped)
|
||||||
latest[node_id] = skipped
|
latest[node_id] = skipped
|
||||||
changed = True
|
changed = True
|
||||||
elif (
|
elif (
|
||||||
all(
|
# Dispatch only when every parent has actually
|
||||||
item is not None and item.node_status == "succeeded"
|
# run to completion successfully. A None parent
|
||||||
|
# means the parent has not even been dispatched
|
||||||
|
# yet (e.g. upstream is still queued); the existing
|
||||||
|
# parents_blocked branch above handles the case
|
||||||
|
# where every parent is terminal but at least one
|
||||||
|
# failed.
|
||||||
|
len(parent_runs) > 0
|
||||||
|
and all(
|
||||||
|
item is not None
|
||||||
|
and item.node_status == "succeeded"
|
||||||
for item in parent_runs
|
for item in parent_runs
|
||||||
)
|
)
|
||||||
and active_count < max_concurrency
|
and active_count < max_concurrency
|
||||||
@@ -531,13 +579,40 @@ class DispatchOrchestrator:
|
|||||||
for item in latest.values()
|
for item in latest.values()
|
||||||
):
|
):
|
||||||
now = utcnow()
|
now = utcnow()
|
||||||
|
# Final run status depends on the schedule's
|
||||||
|
# ``failure_policy``. ``stop`` keeps the legacy rule — any
|
||||||
|
# non-success node fails the whole run. ``continue`` is
|
||||||
|
# more lenient: the run is a success when at least one
|
||||||
|
# root-level node succeeded and there is no remaining
|
||||||
|
# ``failed`` / ``cancelled`` / ``timed_out`` node that
|
||||||
|
# would have produced real artifacts had it run. Nodes
|
||||||
|
# marked ``skipped`` count as "decided to not run" and do
|
||||||
|
# not by themselves fail the run.
|
||||||
|
failure_policy = snapshot.get("failure_policy", "stop")
|
||||||
|
statuses = [item.node_status for item in latest.values()]
|
||||||
|
any_real_failure = any(
|
||||||
|
status in FAILED_NODE_STATES for status in statuses
|
||||||
|
)
|
||||||
|
any_success = any(
|
||||||
|
status == "succeeded" for status in statuses
|
||||||
|
)
|
||||||
|
if failure_policy == "continue":
|
||||||
|
# A run with mixed success/failure/skip outcomes is
|
||||||
|
# only "succeeded" when at least one node actually ran
|
||||||
|
# to completion and nothing hit a hard failure. A
|
||||||
|
# run where every node was skipped or failed is
|
||||||
|
# itself a failure.
|
||||||
|
succeeded = any_success and not any_real_failure
|
||||||
|
else:
|
||||||
succeeded = all(
|
succeeded = all(
|
||||||
item.node_status == "succeeded" for item in latest.values()
|
status == "succeeded" for status in statuses
|
||||||
)
|
)
|
||||||
run.run_status = "succeeded" if succeeded else "failed"
|
run.run_status = "succeeded" if succeeded else "failed"
|
||||||
run.error_code = None if succeeded else "SCHEDULE_NODE_FAILED"
|
run.error_code = None if succeeded else "SCHEDULE_NODE_FAILED"
|
||||||
run.error_message = (
|
run.error_message = (
|
||||||
None if succeeded else "one or more schedule nodes did not succeed"
|
None
|
||||||
|
if succeeded
|
||||||
|
else "one or more schedule nodes did not succeed"
|
||||||
)
|
)
|
||||||
run.finished_at = now
|
run.finished_at = now
|
||||||
if run.started_at:
|
if run.started_at:
|
||||||
|
|||||||
@@ -6,13 +6,16 @@ Composes three single-purpose components into one bootable service:
|
|||||||
- :class:`schedule.orchestrator.DispatchOrchestrator` — Outbox polling + DAG
|
- :class:`schedule.orchestrator.DispatchOrchestrator` — Outbox polling + DAG
|
||||||
- :class:`schedule.worker.NodeExecutor` — node-level execution
|
- :class:`schedule.worker.NodeExecutor` — node-level execution
|
||||||
|
|
||||||
This module also exposes the two factory functions (``build_object_store`` /
|
This module also exposes the factory function ``build_object_store``
|
||||||
``build_storage_http_client``) consumed by ``schedule.main`` to construct
|
consumed by ``schedule.main`` to construct the RustFS S3 client.
|
||||||
the backing resources that flow into the facade.
|
|
||||||
|
|
||||||
The ``SchedulerService`` itself stays small: it wires the three components
|
The :class:`SchedulerService` itself stays small: it wires the three
|
||||||
together and implements ``trigger_schedule``, the cron post-back to
|
components together and implements :meth:`SchedulerService.trigger_schedule`,
|
||||||
Backend that ``CronScheduler`` calls at every cron tick.
|
the cron tick handler. The trigger writes the new ``ScheduleRuns`` row
|
||||||
|
and ``schedule.run.requested`` outbox event in a single transaction
|
||||||
|
via :func:`common.scheduler.create_scheduled_run` — no HTTP call to
|
||||||
|
the backend, so no service-to-service auth is needed (the schedule
|
||||||
|
service shares the same MySQL via the Docker network).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -25,6 +28,11 @@ import httpx
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||||
|
|
||||||
from common.config import settings
|
from common.config import settings
|
||||||
|
from common.scheduler import (
|
||||||
|
SYSTEM_CRON_USER_ID,
|
||||||
|
TriggerError,
|
||||||
|
create_scheduled_run,
|
||||||
|
)
|
||||||
from common.ids import new_ulid
|
from common.ids import new_ulid
|
||||||
|
|
||||||
from schedule.orchestrator import DispatchOrchestrator
|
from schedule.orchestrator import DispatchOrchestrator
|
||||||
@@ -49,13 +57,13 @@ class SchedulerService:
|
|||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
session_factory: async_sessionmaker[AsyncSession],
|
session_factory: async_sessionmaker[AsyncSession],
|
||||||
backend_http_client: httpx.AsyncClient,
|
storage_http_client: httpx.AsyncClient,
|
||||||
object_store: Any,
|
object_store: Any,
|
||||||
storage_client: Any,
|
storage_client: Any,
|
||||||
database_url: str,
|
database_url: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.session_factory = session_factory
|
self.session_factory = session_factory
|
||||||
self.backend_http_client = backend_http_client
|
self.storage_http_client = storage_http_client
|
||||||
self.object_store = object_store
|
self.object_store = object_store
|
||||||
self.storage_client = storage_client
|
self.storage_client = storage_client
|
||||||
self.database_url = database_url
|
self.database_url = database_url
|
||||||
@@ -99,11 +107,17 @@ class SchedulerService:
|
|||||||
await self.cron.close()
|
await self.cron.close()
|
||||||
|
|
||||||
async def trigger_schedule(self, schedule_id: str) -> None:
|
async def trigger_schedule(self, schedule_id: str) -> None:
|
||||||
"""Cron tick callback: post back to Backend to register a new run.
|
"""Cron tick callback: write a new ``ScheduleRuns`` row + outbox event.
|
||||||
|
|
||||||
Backend writes the ``schedule.run.requested`` Outbox row in the same
|
Runs in its own session. The cron ``triggered_by`` is the
|
||||||
transaction as the ``ScheduleRuns`` insert; the orchestrator's polling
|
stable :data:`common.scheduler.SYSTEM_CRON_USER_ID` (the
|
||||||
loop will pick it up and start advancing the DAG.
|
bootstrap migration seeds the matching ``Users`` row) — we no
|
||||||
|
longer impersonate the schedule's human creator as the previous
|
||||||
|
header-based implementation did.
|
||||||
|
|
||||||
|
Idempotency key is the cron minute bucket, so re-entering the
|
||||||
|
same tick (e.g. after a brief outage) reuses the existing run
|
||||||
|
via the unique constraint on ``schedule_runs.idempotency_key``.
|
||||||
"""
|
"""
|
||||||
async with self.session_factory() as session:
|
async with self.session_factory() as session:
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
@@ -118,26 +132,29 @@ class SchedulerService:
|
|||||||
or item.trigger_type != "cron"
|
or item.trigger_type != "cron"
|
||||||
):
|
):
|
||||||
return
|
return
|
||||||
user_id = item.created_by
|
|
||||||
workspace_id = item.workspace_id
|
workspace_id = item.workspace_id
|
||||||
|
|
||||||
now = datetime.now(UTC)
|
now = datetime.now(UTC)
|
||||||
idempotency_key = (
|
idempotency_key = (
|
||||||
f"cron:{schedule_id}:{now.strftime('%Y%m%d%H%M')}"
|
f"cron:{schedule_id}:{now.strftime('%Y%m%d%H%M')}"
|
||||||
)
|
)
|
||||||
response = await self.backend_http_client.post(
|
try:
|
||||||
f"/api/v1/schedules/{schedule_id}/run",
|
async with self.session_factory() as session:
|
||||||
headers={
|
await create_scheduled_run(
|
||||||
"X-User-ID": user_id,
|
session,
|
||||||
"X-Workspace-ID": workspace_id,
|
schedule_id=schedule_id,
|
||||||
"X-Request-ID": new_ulid(),
|
workspace_id=workspace_id,
|
||||||
"Idempotency-Key": idempotency_key,
|
triggered_by_user_id=SYSTEM_CRON_USER_ID,
|
||||||
},
|
trigger_type="cron",
|
||||||
json={"reason": "cron"},
|
idempotency_key=idempotency_key,
|
||||||
|
trace_id=new_ulid(),
|
||||||
)
|
)
|
||||||
if response.is_error:
|
await session.commit()
|
||||||
raise RuntimeError(
|
except TriggerError as exc:
|
||||||
f"backend rejected cron run: {response.status_code} "
|
# Most likely: idempotency_key collision from a previous
|
||||||
f"{response.text[:500]}"
|
# tick in the same minute — silently no-op.
|
||||||
|
LOGGER.info(
|
||||||
|
"cron trigger no-op for schedule %s: %s", schedule_id, exc,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def process_pending_events(
|
async def process_pending_events(
|
||||||
@@ -175,7 +192,14 @@ def build_object_store() -> Any:
|
|||||||
|
|
||||||
|
|
||||||
def build_storage_http_client() -> httpx.AsyncClient:
|
def build_storage_http_client() -> httpx.AsyncClient:
|
||||||
"""Construct the httpx client that talks to Backend's HTTP API."""
|
"""Construct the httpx client that talks to Backend's storage API.
|
||||||
|
|
||||||
|
The schedule service no longer needs to call any user-facing
|
||||||
|
endpoint (cron trigger writes directly to the DB now), but the
|
||||||
|
storage endpoints at ``/internal/v1/...`` still live on the
|
||||||
|
backend process and are reached via this client. Auth is not
|
||||||
|
required — the client is bound to the shared Docker network.
|
||||||
|
"""
|
||||||
return httpx.AsyncClient(
|
return httpx.AsyncClient(
|
||||||
base_url=settings.backend_api_url,
|
base_url=settings.backend_api_url,
|
||||||
timeout=httpx.Timeout(60.0),
|
timeout=httpx.Timeout(60.0),
|
||||||
|
|||||||
@@ -194,27 +194,50 @@ version = "0.2.0"
|
|||||||
source = { editable = "backend" }
|
source = { editable = "backend" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "alembic" },
|
{ name = "alembic" },
|
||||||
|
{ name = "bcrypt" },
|
||||||
{ name = "common" },
|
{ name = "common" },
|
||||||
{ name = "croniter" },
|
{ name = "croniter" },
|
||||||
{ name = "cryptography" },
|
{ name = "cryptography" },
|
||||||
{ name = "fastapi" },
|
{ name = "fastapi" },
|
||||||
{ name = "gunicorn" },
|
{ name = "gunicorn" },
|
||||||
{ name = "httpx" },
|
{ name = "httpx" },
|
||||||
|
{ name = "passlib" },
|
||||||
{ name = "uvicorn", extra = ["standard"] },
|
{ name = "uvicorn", extra = ["standard"] },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "alembic", specifier = "==1.18.5" },
|
{ name = "alembic", specifier = "==1.18.5" },
|
||||||
|
{ name = "bcrypt", specifier = ">=4.0,<4.1" },
|
||||||
{ name = "common", editable = "common" },
|
{ name = "common", editable = "common" },
|
||||||
{ name = "croniter", specifier = "==6.2.4" },
|
{ name = "croniter", specifier = "==6.2.4" },
|
||||||
{ name = "cryptography", specifier = "==49.0.0" },
|
{ name = "cryptography", specifier = "==49.0.0" },
|
||||||
{ name = "fastapi", specifier = "==0.116.1" },
|
{ name = "fastapi", specifier = "==0.116.1" },
|
||||||
{ name = "gunicorn", specifier = ">=26.0.0" },
|
{ name = "gunicorn", specifier = ">=26.0.0" },
|
||||||
{ name = "httpx", specifier = "==0.28.1" },
|
{ name = "httpx", specifier = "==0.28.1" },
|
||||||
|
{ name = "passlib", specifier = "==1.7.4" },
|
||||||
{ name = "uvicorn", extras = ["standard"], specifier = "==0.35.0" },
|
{ name = "uvicorn", extras = ["standard"], specifier = "==0.35.0" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bcrypt"
|
||||||
|
version = "4.0.1"
|
||||||
|
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple/" }
|
||||||
|
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8c/ae/3af7d006aacf513975fd1948a6b4d6f8b4a307f8a244e1a3d3774b297aad/bcrypt-4.0.1.tar.gz", hash = "sha256:27d375903ac8261cfe4047f6709d16f7d18d39b1ec92aaf72af989552a650ebd", size = 25498, upload-time = "2022-10-09T15:36:49.775Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/78/d4/3b2657bd58ef02b23a07729b0df26f21af97169dbd0b5797afa9e97ebb49/bcrypt-4.0.1-cp36-abi3-macosx_10_10_universal2.whl", hash = "sha256:b1023030aec778185a6c16cf70f359cbb6e0c289fd564a7cfa29e727a1c38f8f", size = 473446, upload-time = "2022-10-09T15:36:25.481Z" },
|
||||||
|
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/ec/0a/1582790232fef6c2aa201f345577306b8bfe465c2c665dec04c86a016879/bcrypt-4.0.1-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:08d2947c490093a11416df18043c27abe3921558d2c03e2076ccb28a116cb6d0", size = 583044, upload-time = "2022-10-09T15:37:09.447Z" },
|
||||||
|
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/41/16/49ff5146fb815742ad58cafb5034907aa7f166b1344d0ddd7fd1c818bd17/bcrypt-4.0.1-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0eaa47d4661c326bfc9d08d16debbc4edf78778e6aaba29c1bc7ce67214d4410", size = 583189, upload-time = "2022-10-09T15:37:10.69Z" },
|
||||||
|
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/aa/48/fd2b197a9741fa790ba0b88a9b10b5e88e62ff5cf3e1bc96d8354d7ce613/bcrypt-4.0.1-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ae88eca3024bb34bb3430f964beab71226e761f51b912de5133470b649d82344", size = 593473, upload-time = "2022-10-09T15:36:27.195Z" },
|
||||||
|
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/7d/50/e683d8418974a602ba40899c8a5c38b3decaf5a4d36c32fc65dce454d8a8/bcrypt-4.0.1-cp36-abi3-manylinux_2_24_x86_64.whl", hash = "sha256:a522427293d77e1c29e303fc282e2d71864579527a04ddcfda6d4f8396c6c36a", size = 593249, upload-time = "2022-10-09T15:36:28.481Z" },
|
||||||
|
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/fb/a7/ee4561fd9b78ca23c8e5591c150cc58626a5dfb169345ab18e1c2c664ee0/bcrypt-4.0.1-cp36-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:fbdaec13c5105f0c4e5c52614d04f0bca5f5af007910daa8b6b12095edaa67b3", size = 583586, upload-time = "2022-10-09T15:37:11.962Z" },
|
||||||
|
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/64/fe/da28a5916128d541da0993328dc5cf4b43dfbf6655f2c7a2abe26ca2dc88/bcrypt-4.0.1-cp36-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:ca3204d00d3cb2dfed07f2d74a25f12fc12f73e606fcaa6975d1f7ae69cacbb2", size = 593659, upload-time = "2022-10-09T15:36:30.049Z" },
|
||||||
|
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/dd/4f/3632a69ce344c1551f7c9803196b191a8181c6a1ad2362c225581ef0d383/bcrypt-4.0.1-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:089098effa1bc35dc055366740a067a2fc76987e8ec75349eb9484061c54f535", size = 613116, upload-time = "2022-10-09T15:37:14.107Z" },
|
||||||
|
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/87/69/edacb37481d360d06fc947dab5734aaf511acb7d1a1f9e2849454376c0f8/bcrypt-4.0.1-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:e9a51bbfe7e9802b5f3508687758b564069ba937748ad7b9e890086290d2f79e", size = 624290, upload-time = "2022-10-09T15:36:31.251Z" },
|
||||||
|
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/aa/ca/6a534669890725cbb8c1fb4622019be31813c8edaa7b6d5b62fc9360a17e/bcrypt-4.0.1-cp36-abi3-win32.whl", hash = "sha256:2caffdae059e06ac23fce178d31b4a702f2a3264c20bfb5ff541b338194d8fab", size = 159428, upload-time = "2022-10-09T15:36:32.893Z" },
|
||||||
|
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/46/81/d8c22cd7e5e1c6a7d48e41a1d1d46c92f17dae70a54d9814f746e6027dec/bcrypt-4.0.1-cp36-abi3-win_amd64.whl", hash = "sha256:8a68f4341daf7522fe8d73874de8906f3a339048ba406be6ddc1b3ccb16fc0d9", size = 152930, upload-time = "2022-10-09T15:36:34.635Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "beautifulsoup4"
|
name = "beautifulsoup4"
|
||||||
version = "4.15.0"
|
version = "4.15.0"
|
||||||
@@ -465,9 +488,11 @@ source = { editable = "common" }
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "apscheduler" },
|
{ name = "apscheduler" },
|
||||||
{ name = "asyncmy" },
|
{ name = "asyncmy" },
|
||||||
|
{ name = "bcrypt" },
|
||||||
{ name = "boto3" },
|
{ name = "boto3" },
|
||||||
{ name = "fastapi" },
|
{ name = "fastapi" },
|
||||||
{ name = "greenlet" },
|
{ name = "greenlet" },
|
||||||
|
{ name = "passlib" },
|
||||||
{ name = "pydantic-settings" },
|
{ name = "pydantic-settings" },
|
||||||
{ name = "sqlalchemy" },
|
{ name = "sqlalchemy" },
|
||||||
]
|
]
|
||||||
@@ -476,9 +501,11 @@ dependencies = [
|
|||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "apscheduler", specifier = ">=3.11.3" },
|
{ name = "apscheduler", specifier = ">=3.11.3" },
|
||||||
{ name = "asyncmy", specifier = "==0.2.11" },
|
{ name = "asyncmy", specifier = "==0.2.11" },
|
||||||
|
{ name = "bcrypt", specifier = ">=4.0,<4.1" },
|
||||||
{ name = "boto3", specifier = ">=1.34,<2" },
|
{ name = "boto3", specifier = ">=1.34,<2" },
|
||||||
{ name = "fastapi", specifier = "==0.116.1" },
|
{ name = "fastapi", specifier = "==0.116.1" },
|
||||||
{ name = "greenlet", specifier = ">=3.0.0" },
|
{ name = "greenlet", specifier = ">=3.0.0" },
|
||||||
|
{ name = "passlib", specifier = "==1.7.4" },
|
||||||
{ name = "pydantic-settings", specifier = ">=2.14.2" },
|
{ name = "pydantic-settings", specifier = ">=2.14.2" },
|
||||||
{ name = "sqlalchemy", specifier = "==2.0.51" },
|
{ name = "sqlalchemy", specifier = "==2.0.51" },
|
||||||
]
|
]
|
||||||
@@ -1357,6 +1384,15 @@ wheels = [
|
|||||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/99/5d/8268b644392ee874ee82a635cd0df1773de230bde356c38de28e298392cc/parso-0.8.7-py2.py3-none-any.whl", hash = "sha256:a8926eb2a1b915486941fdbd31e86a4baf88fe8c210f25f2f35ecec5b574ca1c", size = 107025, upload-time = "2026-05-01T23:12:58.867Z" },
|
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/99/5d/8268b644392ee874ee82a635cd0df1773de230bde356c38de28e298392cc/parso-0.8.7-py2.py3-none-any.whl", hash = "sha256:a8926eb2a1b915486941fdbd31e86a4baf88fe8c210f25f2f35ecec5b574ca1c", size = 107025, upload-time = "2026-05-01T23:12:58.867Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "passlib"
|
||||||
|
version = "1.7.4"
|
||||||
|
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple/" }
|
||||||
|
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b6/06/9da9ee59a67fae7761aab3ccc84fa4f3f33f125b370f1ccdb915bf967c11/passlib-1.7.4.tar.gz", hash = "sha256:defd50f72b65c5402ab2c573830a6978e5f202ad0d984793c8dde2c4152ebe04", size = 689844, upload-time = "2020-10-08T19:00:52.121Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/3b/a4/ab6b7589382ca3df236e03faa71deac88cae040af60c071a78d254a62172/passlib-1.7.4-py2.py3-none-any.whl", hash = "sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1", size = 525554, upload-time = "2020-10-08T19:00:49.856Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pexpect"
|
name = "pexpect"
|
||||||
version = "4.9.0"
|
version = "4.9.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user