diff --git a/API.md b/API.md index fd93dc4..e6e0ae1 100644 --- a/API.md +++ b/API.md @@ -73,9 +73,12 @@ > 是它在对象存储里的"工作副本",`Versions` 是 immutable 的稳定版本。 > 写操作受 **is_locked 门禁 + owner 校验** 保护(架构 V3.1 §4)。 -### 3.1 `GET /api/v1/workspace-tree` +### 3.1 `GET /api/v1/workspace-tree` (**legacy / 全量视图**) -列出当前用户在 workspace 内的**目录树**。 +> **Deprecated**: 新代码请走 §3.3.1 (按 `parent_path` 单层)。该接口仍保留供调试 / 兼容使用, +> 谓词已统一为 `object_status='available' AND is_deleted=0`,与 §3.3.1 保持一致。 + +列出当前用户在 workspace 内的**整个目录树**(扁平数组)。 - **来源**: 显式 `StorageObjects` 行 (`object_type='directory'`,见 §3.2) **并入** 从 `Scripts.relative_path` 派生的祖先目录,**去重**。空目录(只有显式行、没有文件)也会出现。 - **鉴权**: workspace 成员 @@ -145,6 +148,41 @@ } ``` +### 3.3.1 `GET /api/v1/workspace-directories?parent_path=...` + +列出指定父目录下的**直接子目录**(单层),用于前端懒加载。**新代码请走本接口**;§3.1 仅作为 legacy / 全量保留。 + +- **行为**: `parent_path` 为空字符串或缺省 → 用户根目录;非空 → 该父目录的直接子目录(workspace-relative)。仅返回 `object_status='available' AND is_deleted=0` 的 `StorageObjects` 行。 +- **鉴权**: workspace 成员 +- **查询参数**: + | 名 | 类型 | 必填 | 说明 | + |---|---|---|---| + | `parent_path` | string | 否 | 父目录相对路径,空字符串或缺省表示用户根目录 | +- **谓词(SQL 等价)**: `relative_path LIKE '/%' AND relative_path NOT LIKE '/%/%'`,其中 `prefix = scoped_prefix/{parent_path}`,索引走 `idx_storage_workspace_relative_path(workspace_id, relative_path(255))`。 +- **响应**: + ```json + { + "request_id": "...", + "data": { + "directories": [ + {"path": "scripts", "name": "scripts", "parent_path": "", "has_children": true}, + {"path": "scripts/etl", "name": "etl", "parent_path": "scripts", "has_children": false}, + {"path": "scripts/etl/daily", "name": "daily", "parent_path": "scripts/etl", "has_children": false} + ] + }, + "meta": {"directory_count": 3} + } + ``` + - 字段表(继承 §3.1): + | 字段 | 类型 | 说明 | + |---|---|---| + | `path` | string | workspace 内相对路径 | + | `name` | string | `path` 的最后一段 | + | `parent_path` | string | 父目录相对路径,根目录用空串 | + | `has_children` | bool | 该目录下是否还有直接子目录(后端额外 `EXISTS` 查询,可为空目录为 `false`) | +- **空结果**: 不返回 404,空目录列表即 `directories: []`。 +- **错误**: 401(未登录)/ 403(非 workspace 成员)同其他接口。 + ### 3.4 `GET /api/v1/scripts` 列出当前 workspace 内**全部 active 脚本**。不受 is_locked 影响(读路径不锁)。 @@ -213,7 +251,7 @@ multipart/binary 形式上传大文件(走 server-proxied PUT,详见 §九)。 | `visibility` | enum | | | `status` | `active` \| `deleted` | | | `is_locked` | boolean | 是否锁定。`PUT /scripts/{id}`(§3.8) 受此字段门禁;可通过 §3.16 切换 | -| `relative_path` | string \| null | 例如 `users/alice/scripts/etl/train.py` | +| `relative_path` | string \| null | 例如 `workspace/01HXX.../scripts/etl/train.py`(以 `user_id` 为作用域) | | `content_hash` | string \| null | SHA-256 十六进制 | | `size_bytes` | int | | | `created_at` / `updated_at` | ISO-8601 | | diff --git a/backend/src/backend/scripts.py b/backend/src/backend/scripts.py index 1064caf..1f51065 100644 --- a/backend/src/backend/scripts.py +++ b/backend/src/backend/scripts.py @@ -11,10 +11,12 @@ from common.config import settings from common.db.models import ( Scripts, StorageObjects, + Users, Versions, ) from common.ids import new_ulid from common.storage.schemas import ServerObjectRequest +from common.storage import build_storage_uri from fastapi import ( APIRouter, BackgroundTasks, @@ -88,8 +90,17 @@ def safe_directory_name(value: str) -> str: return name +# Version/run artifacts are not part of the user's workspace directory tree. +TREE_EXCLUDED_USAGE_TYPES = ( + "version_artifact", + "snapshot", + "run_log", + "run_result", +) + + def user_relative_path(context: RequestContext, child_path: str = "") -> str: - base = f"users/{context.user.username}" + base = f"workspace/{context.user.user_id}" normalized = normalize_user_path(child_path) return f"{base}/{normalized}" if normalized else base @@ -123,11 +134,36 @@ def _jupyter_path(script_type: str, script_id: str) -> str: (``/jupyter//notebooks/.ipynb``) is jupyter's URL route for the editor view, not a filesystem path — jupyter routes that URL to the file at the workspace root. + + NOTE: This function returns the flat basename only (``{script_id}.{ext}``). + It must NOT be used directly as a Jupyter path for scripts that live + inside sub-directories. Callers must combine it with the parent + directory ULID (for newly-created files) or derive the real path from + ``StorageObjects.object_key`` (for existing files). """ ext = ".ipynb" if script_type == "notebook" else ".py" return f"{script_id}{ext}" +def _derive_jupyter_path( + storage_object: StorageObjects | None, + workspace_id: str, + script_type: str, + script_id: str, +) -> str: + """Return the real Jupyter path for an existing script. + + For normal workspace files the path is taken from + ``StorageObjects.object_key`` with the workspace prefix removed. + For jupyter-only scripts that have no StorageObjects row, fall back + to the flat ``_jupyter_path()`` basename so existing behavior is + preserved. + """ + if storage_object is None or not storage_object.object_key: + return _jupyter_path(script_type, script_id) + return storage_object.object_key.removeprefix(f"{workspace_id}/") + + def validate_script_content(content: str, script_type: str) -> bytes: encoded = content.encode("utf-8") if len(encoded) > 10 * 1024 * 1024: @@ -156,9 +192,16 @@ def validate_script_content(content: str, script_type: str) -> bytes: def script_payload( script: Scripts, - storage_object: StorageObjects | dict[str, Any], + storage_object: StorageObjects | dict[str, Any] | None, + *, + owner_display_name: str | None = None, ) -> dict[str, Any]: - if isinstance(storage_object, dict): + if storage_object is None: + relative_path = None + object_key = None + content_hash = None + size_bytes = 0 + elif isinstance(storage_object, dict): relative_path = storage_object.get("relative_path") object_key = storage_object.get("object_key") content_hash = storage_object.get("content_hash") @@ -169,16 +212,20 @@ def script_payload( content_hash = storage_object.content_hash size_bytes = storage_object.size_bytes workspace_prefix = f"{script.workspace_id}/" - jupyter_path = ( - object_key[len(workspace_prefix) :] - if object_key and object_key.startswith(workspace_prefix) - else object_key - ) + if object_key: + jupyter_path = ( + object_key[len(workspace_prefix) :] + if object_key.startswith(workspace_prefix) + else object_key + ) + else: + jupyter_path = _jupyter_path(script.script_type, script.script_id) return { "script_id": script.script_id, "workspace_id": script.workspace_id, "current_object_id": script.current_object_id, "owner_user_id": script.owner_user_id, + "owner_display_name": owner_display_name, "script_name": script.script_name, "script_type": script.script_type, "visibility": script.visibility, @@ -219,7 +266,8 @@ async def get_script_row( session: AsyncSession, *, for_update: bool = False, -) -> tuple[Scripts, StorageObjects]: + allow_missing_storage_object: bool = False, +) -> tuple[Scripts, StorageObjects | None]: if for_update: script = await session.scalar( select(Scripts) @@ -239,25 +287,39 @@ async def get_script_row( StorageObjects, script.current_object_id, ) - if storage_object is None: + if storage_object is None and not allow_missing_storage_object: raise HTTPException( status.HTTP_409_CONFLICT, "script working-copy metadata is missing", ) row = (script, storage_object) else: - statement = ( - select(Scripts, StorageObjects) - .join( - StorageObjects, - StorageObjects.storage_object_id == Scripts.current_object_id, + if allow_missing_storage_object: + statement = ( + select(Scripts, StorageObjects) + .outerjoin( + StorageObjects, + StorageObjects.storage_object_id == Scripts.current_object_id, + ) + .where( + Scripts.script_id == script_id, + Scripts.workspace_id == context.workspace.workspace_id, + Scripts.status == "active", + ) ) - .where( - Scripts.script_id == script_id, - Scripts.workspace_id == context.workspace.workspace_id, - Scripts.status == "active", + else: + statement = ( + select(Scripts, StorageObjects) + .join( + StorageObjects, + StorageObjects.storage_object_id == Scripts.current_object_id, + ) + .where( + Scripts.script_id == script_id, + Scripts.workspace_id == context.workspace.workspace_id, + Scripts.status == "active", + ) ) - ) row = (await session.execute(statement)).one_or_none() if row is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "script not found") @@ -412,7 +474,7 @@ async def create_script_record( bucket_name=bucket_name, object_key=object_key, object_key_hash=hashlib.sha256(object_key.encode("utf-8")).digest(), - storage_uri=f"s3://{bucket_name}/{object_key}", + storage_uri=build_storage_uri(bucket_name, object_key), file_name=name, file_extension=PurePosixPath(jupyter_name).suffix.lower() or None, mime_type=mime_type, @@ -571,7 +633,9 @@ async def get_workspace_tree( select(StorageObjects.relative_path, StorageObjects.object_type).where( StorageObjects.workspace_id == context.workspace.workspace_id, StorageObjects.object_status == "available", + StorageObjects.is_deleted == 0, StorageObjects.relative_path.like(like_prefix), + StorageObjects.usage_type.notin_(TREE_EXCLUDED_USAGE_TYPES), ) ) ).all() @@ -612,6 +676,78 @@ async def get_workspace_tree( }, ) sorted_dirs = sorted(directories.values(), key=lambda item: item["path"]) + return { + "request_id": context.request_id, + "data": {"directories": sorted_dirs}, + "meta": {"directory_count": len(sorted_dirs)}, + } + + +@router.get("/api/v1/workspace-directories") +async def list_workspace_directories( + parent_path: str = Query(default=""), + context: RequestContext = Depends(request_context), + session: AsyncSession = Depends(database_session), +) -> dict[str, Any]: + """List direct child directories of a workspace path. + + Empty ``parent_path`` returns the directories immediately under the + user's scoped root. Only available, non-deleted StorageObjects are + considered. + """ + scoped_prefix = user_relative_path(context) + parent = normalize_user_path(parent_path) + target_prefix = f"{scoped_prefix}/{parent}" if parent else scoped_prefix + descendant_prefix = f"{target_prefix}/" + + rows = ( + await session.execute( + select(StorageObjects.relative_path).where( + StorageObjects.workspace_id == context.workspace.workspace_id, + StorageObjects.object_status == "available", + StorageObjects.is_deleted == 0, + StorageObjects.relative_path.like(f"{descendant_prefix}%"), + ~StorageObjects.relative_path.like(f"{descendant_prefix}%/%"), + StorageObjects.object_type == "directory", + StorageObjects.usage_type.notin_(TREE_EXCLUDED_USAGE_TYPES), + ) + ) + ).all() + + directories: dict[str, dict[str, Any]] = {} + for (relative,) in rows: + if not relative or not relative.startswith(descendant_prefix): + continue + suffix = relative[len(descendant_prefix) :] + if "/" in suffix: + continue + child_path = f"{parent}/{suffix}" if parent else suffix + directories.setdefault( + child_path, + { + "path": child_path, + "name": suffix, + "parent_path": parent, + "has_children": False, + }, + ) + + for directory in directories.values(): + # directory['path'] is already workspace-relative and includes the parent segment. + child_prefix = f"{scoped_prefix}/{directory['path']}/" + has_children = await session.scalar( + select(StorageObjects.storage_object_id).where( + StorageObjects.workspace_id == context.workspace.workspace_id, + StorageObjects.object_status == "available", + StorageObjects.is_deleted == 0, + StorageObjects.relative_path.like(f"{child_prefix}%"), + ~StorageObjects.relative_path.like(f"{child_prefix}%/%"), + StorageObjects.usage_type.notin_(TREE_EXCLUDED_USAGE_TYPES), + ).limit(1) + ) + directory["has_children"] = has_children is not None + + sorted_dirs = sorted(directories.values(), key=lambda item: item["path"]) return { "request_id": context.request_id, "data": {"directories": sorted_dirs}, @@ -873,11 +1009,12 @@ async def list_scripts( session: AsyncSession = Depends(database_session), ) -> dict[str, Any]: statement = ( - select(Scripts, StorageObjects) + select(Scripts, StorageObjects, Users.display_name) .join( StorageObjects, StorageObjects.storage_object_id == Scripts.current_object_id, ) + .outerjoin(Users, Users.user_id == Scripts.owner_user_id) .where( Scripts.workspace_id == context.workspace.workspace_id, Scripts.status == "active", @@ -888,7 +1025,8 @@ async def list_scripts( return { "request_id": context.request_id, "data": [ - script_payload(script, storage_object) for script, storage_object in rows + script_payload(script, storage_object, owner_display_name=owner_display_name) + for script, storage_object, owner_display_name in rows ], "meta": {"count": len(rows)}, } @@ -971,24 +1109,16 @@ async def update_script( context: RequestContext = Depends(request_context), session: AsyncSession = Depends(database_session), ) -> dict[str, Any]: - # Look up the Scripts row on its own: jupyter-only scripts do not - # have a StorageObjects row to JOIN against, and update is an - # in-place overwrite of the same jupyter path, so we do not need - # any object-store metadata. - script = await session.scalar( - select(Scripts) - .where( - Scripts.script_id == script_id, - Scripts.workspace_id == context.workspace.workspace_id, - Scripts.status == "active", - ) - .with_for_update() + # Load the working-copy StorageObject as well as the Scripts row. + # Jupyter-only scripts may not have a StorageObjects row; allow that + # case and fall back to the flat _jupyter_path() name. + script, storage_object = await get_script_row( + script_id, + context, + session, + for_update=True, + allow_missing_storage_object=True, ) - if script is None: - raise HTTPException( - status.HTTP_404_NOT_FOUND, - "script not found", - ) require_script_modify_access( script, user_id=context.user.user_id, @@ -997,21 +1127,23 @@ async def update_script( content = validate_script_content(payload.content, script.script_type) runtime_client = request.app.state.runtime_client workspace_id = context.workspace.workspace_id - jupyter_name = _jupyter_path(script.script_type, script.script_id) + jupyter_path = _derive_jupyter_path( + storage_object, workspace_id, script.script_type, script.script_id + ) try: if script.script_type == "notebook": notebook = json.loads(content.decode("utf-8")) jupyter_resp = await runtime_client.create_notebook( workspace_id, - name=jupyter_name, + name=jupyter_path, cells=notebook.get("cells"), ) else: jupyter_resp = await runtime_client.upload_file( workspace_id, - name=jupyter_name, + name=jupyter_path, content=content.decode("utf-8"), - content_type=(mimetypes.guess_type(jupyter_name)[0] or "text/plain"), + content_type=(mimetypes.guess_type(jupyter_path)[0] or "text/plain"), ) except RuntimeClientError as exc: raise HTTPException( @@ -1022,8 +1154,8 @@ async def update_script( script.updated_at = datetime.now(UTC).replace(tzinfo=None) storage_data = { "storage_object_id": script.current_object_id, - "relative_path": jupyter_name, - "object_key": f"{workspace_id}/{jupyter_name}", + "relative_path": jupyter_path, + "object_key": f"{workspace_id}/{jupyter_path}", "content_hash": hashlib.sha256(content).hexdigest(), "size_bytes": len(content), } @@ -1083,23 +1215,16 @@ async def delete_script( context: RequestContext = Depends(request_context), session: AsyncSession = Depends(database_session), ) -> dict[str, Any]: - # Scripts created via the jupyter path do not have a corresponding - # StorageObjects row, so we look up the Scripts row on its own and - # forward the delete to the workspace's live Jupyter instance. - script = await session.scalar( - select(Scripts) - .where( - Scripts.script_id == script_id, - Scripts.workspace_id == context.workspace.workspace_id, - Scripts.status == "active", - ) - .with_for_update() + # Load the working-copy StorageObject if it exists. Jupyter-only + # scripts may not have a StorageObjects row; fall back to the flat + # _jupyter_path() name in that case. + script, storage_object = await get_script_row( + script_id, + context, + session, + for_update=True, + allow_missing_storage_object=True, ) - if script is None: - raise HTTPException( - status.HTTP_404_NOT_FOUND, - "script not found", - ) require_script_modify_access( script, user_id=context.user.user_id, @@ -1107,17 +1232,23 @@ async def delete_script( ) runtime_client = request.app.state.runtime_client + workspace_id = context.workspace.workspace_id + jupyter_path = _derive_jupyter_path( + storage_object, workspace_id, script.script_type, script.script_id + ) try: - await runtime_client.delete_file( - context.workspace.workspace_id, - name=_jupyter_path(script.script_type, script.script_id), - ) + await runtime_client.delete_file(workspace_id, name=jupyter_path) except RuntimeClientError as exc: raise HTTPException( status_code=exc.status_code, detail=exc.detail, ) from exc + if storage_object: + storage_object.object_status = "deleted" + storage_object.is_deleted = 1 + storage_object.deleted_at = datetime.now(UTC).replace(tzinfo=None) + script.status = "deleted" script.deleted_at = datetime.now(UTC).replace(tzinfo=None) return { @@ -1142,25 +1273,16 @@ async def publish_version( context: RequestContext = Depends(request_context), session: AsyncSession = Depends(database_session), ) -> dict[str, Any]: - # Jupyter-only scripts do not have a StorageObjects row, so the - # legacy get_script_row helper raises 409 before we even get here. - # Look up the Scripts row on its own — version publication is a - # metadata operation, we do not need the working-copy object - # metadata. - script = await session.scalar( - select(Scripts) - .where( - Scripts.script_id == script_id, - Scripts.workspace_id == context.workspace.workspace_id, - Scripts.status == "active", - ) - .with_for_update() + # Load the working-copy StorageObject if it exists. Jupyter-only + # scripts may not have a StorageObjects row; fall back to the flat + # _jupyter_path() name when reading from Jupyter. + script, storage_object = await get_script_row( + script_id, + context, + session, + for_update=True, + allow_missing_storage_object=True, ) - if script is None: - raise HTTPException( - status.HTTP_404_NOT_FOUND, - "script not found", - ) require_script_modify_access( script, user_id=context.user.user_id, @@ -1180,9 +1302,11 @@ async def publish_version( # files come back as a UTF-8 string. runtime_client = request.app.state.runtime_client workspace_id = context.workspace.workspace_id - jupyter_name = _jupyter_path(script.script_type, script.script_id) + jupyter_path = _derive_jupyter_path( + storage_object, workspace_id, script.script_type, script.script_id + ) try: - contents = await runtime_client.get_file(workspace_id, name=jupyter_name) + contents = await runtime_client.get_file(workspace_id, name=jupyter_path) except RuntimeClientError as exc: raise HTTPException( status_code=exc.status_code, @@ -1241,7 +1365,7 @@ async def publish_version( artifact_object_id=artifact_data["storage_object_id"], version_no=version_no, version_label=f"v{version_no}.0", - source_path=jupyter_name, + source_path=jupyter_path, artifact_path=artifact_data["storage_uri"], content_hash=content_hash, file_size_bytes=len(content), diff --git a/backend/src/backend/services/storage.py b/backend/src/backend/services/storage.py index 0cf14d5..4690832 100644 --- a/backend/src/backend/services/storage.py +++ b/backend/src/backend/services/storage.py @@ -47,6 +47,7 @@ from common.storage.schemas import ( DownloadUrlRequest, ServerObjectRequest, ) +from common.storage import build_storage_uri # ── shared low-level helpers (module-private) ──────────────────────────── @@ -90,7 +91,7 @@ def _build_storage_object( bucket_name=upload.bucket_name, object_key=upload.object_key, object_key_hash=upload.object_key_hash, - storage_uri=f"s3://{upload.bucket_name}/{upload.object_key}", + storage_uri=build_storage_uri(upload.bucket_name, upload.object_key), file_name=safe_name, file_extension=PurePosixPath(safe_name).suffix.lower() or None, mime_type=content_type, @@ -519,4 +520,4 @@ async def soft_delete_object( "trash_key": item.trash_key, "trash_bucket": settings.s3_trash_bucket, } - } \ No newline at end of file + } diff --git a/backend/src/backend/storage_api.py b/backend/src/backend/storage_api.py index 593469b..2e35434 100644 --- a/backend/src/backend/storage_api.py +++ b/backend/src/backend/storage_api.py @@ -19,7 +19,7 @@ from common.db.models import ( Workspaces, ) from common.ids import new_ulid -from common.storage import actual_bucket_name +from common.storage import actual_bucket_name, build_storage_uri from common.storage.schemas import ( CreateUploadRequest, DownloadUrlRequest, @@ -350,7 +350,7 @@ async def upload_bytes_to_session( bucket_name=upload.bucket_name, object_key=upload.object_key, object_key_hash=upload.object_key_hash, - storage_uri=f"s3://{upload.bucket_name}/{upload.object_key}", + storage_uri=build_storage_uri(upload.bucket_name, upload.object_key), file_name=file_name, file_extension=PurePosixPath(file_name).suffix.lower() or None, mime_type=upload.content_type, diff --git a/common/src/common/storage/__init__.py b/common/src/common/storage/__init__.py index bc82ba0..3a49301 100644 --- a/common/src/common/storage/__init__.py +++ b/common/src/common/storage/__init__.py @@ -23,6 +23,7 @@ from .factory import ( PURPOSE_BUCKETS, RCLONE_REMOTE_NAME, actual_bucket_name, + build_storage_uri, build_storage_config, create_storage, rclone_remote_spec, @@ -34,6 +35,7 @@ __all__ = [ "create_storage", "build_storage_config", "actual_bucket_name", + "build_storage_uri", "workspaces_root", "rclone_remote_spec", "RCLONE_REMOTE_NAME", diff --git a/common/src/common/storage/factory.py b/common/src/common/storage/factory.py index 6d41344..334340b 100644 --- a/common/src/common/storage/factory.py +++ b/common/src/common/storage/factory.py @@ -78,6 +78,24 @@ def actual_bucket_name(purpose: str) -> str: return getattr(settings, f"s3_{purpose}_bucket") +def build_storage_uri(bucket_name: str, object_key: str) -> str: + """根据 ``settings.storage_backend`` 构造对象的 storage_uri。 + + - s3 模式:``s3://{bucket_name}/{object_key}`` + - local 模式:``file://{absolute_bucket_path}/{object_key}`` + + local 模式下 ``bucket_name`` 是文件系统路径(见 ``actual_bucket_name``), + 不能直接用 ``s3://`` 前缀,否则会产生 ``s3:///data/version/...`` 这种 + 非法 URI。因此返回 ``file://`` URI,并把相对路径先转成绝对路径。 + """ + from common.config import settings # 延迟 import 避免循环 + + if settings.storage_backend == "local": + absolute_path = Path(bucket_name).absolute().as_posix() + return f"file://{absolute_path}/{object_key}" + return f"s3://{bucket_name}/{object_key}" + + def build_storage_config(bucket_name: str) -> Dict[str, Any]: """根据 ``settings.storage_backend`` 构造 ``create_storage()`` 的入参。 diff --git a/docker-compose.yml b/docker-compose.yml index 36d6f47..8d15eac 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,6 +3,11 @@ services: build: context: . dockerfile: backend/Dockerfile + logging: + driver: json-file + options: + max-size: "200m" + max-file: "10" restart: "no" command: - uv @@ -21,6 +26,11 @@ services: build: context: . dockerfile: frontend/Dockerfile + logging: + driver: json-file + options: + max-size: "200m" + max-file: "10" restart: unless-stopped # Architecture §2.2: this is the only service exposed to the host. The # default.conf file is mounted as a template; scripts/nginx-entrypoint.sh @@ -50,6 +60,11 @@ services: build: context: . dockerfile: backend/Dockerfile + logging: + driver: json-file + options: + max-size: "200m" + max-file: "10" restart: unless-stopped # No host port: architecture §2.2 — only Nginx is externally reachable. # No local-FS volume: backend stores everything in S3 (S3_*). @@ -73,7 +88,7 @@ services: S3_RUN_LOG_BUCKET: ${S3_RUN_LOG_BUCKET:-run-log} S3_TRASH_BUCKET: ${S3_TRASH_BUCKET:-trash} S3_TRASH_RETENTION_DAYS: ${S3_TRASH_RETENTION_DAYS:-30} - READINESS_TARGETS: ${MYSQL_HOST:?MYSQL_HOST is required}:${MYSQL_PORT:-3306},${S3_HOST:-s3}:${S3_PORT:-9000},runtime:8000 + READINESS_TARGETS: ${MYSQL_HOST:?MYSQL_HOST is required}:${MYSQL_PORT:-3306},runtime:8000 depends_on: migrate: condition: service_completed_successfully @@ -96,6 +111,11 @@ services: build: context: . dockerfile: runtime/Dockerfile + logging: + driver: json-file + options: + max-size: "200m" + max-file: "10" restart: unless-stopped ports: - 8892:8000 @@ -150,6 +170,11 @@ services: build: context: . dockerfile: schedule/Dockerfile + logging: + driver: json-file + options: + max-size: "200m" + max-file: "10" restart: unless-stopped # No host port: architecture §2.2 — only Nginx is externally reachable. # No local-FS volume: schedule executes nodes via tempfile.TemporaryDirectory diff --git a/frontend/app/components/platform/ScriptExplorer.tsx b/frontend/app/components/platform/ScriptExplorer.tsx index 211b848..399f4d0 100644 --- a/frontend/app/components/platform/ScriptExplorer.tsx +++ b/frontend/app/components/platform/ScriptExplorer.tsx @@ -1,8 +1,9 @@ -import { type ChangeEvent, type MouseEvent as ReactMouseEvent, type RefObject, useEffect, useMemo, useState } from "react"; +import { type ChangeEvent, type MouseEvent as ReactMouseEvent, type RefObject, useMemo } from "react"; import Icon from "../common/Icon"; import { WorkspaceTreeGroup } from "~/features/platform/WorkspaceTree"; -import type { ScriptItem, WorkspaceDirectory, WorkspaceMember } from "~/services/api"; -import { useApi, useAuth, type AuthUser } from "~/context/AuthContext"; +import { useScriptWorkspaceStore } from "~/features/platform/state/scriptWorkspaceStore"; +import type { ScriptItem, WorkspaceDirectory } from "~/services/api"; +import { type AuthUser } from "~/context/AuthContext"; type ScriptExplorerProps = { scripts: ScriptItem[]; @@ -50,36 +51,11 @@ export function ScriptExplorer({ uploadInputRef, onHandleUpload, }: ScriptExplorerProps) { - const api = useApi(); - const { currentWorkspace } = useAuth(); - const [members, setMembers] = useState([]); - - useEffect(() => { - if (!currentWorkspace) { - setMembers([]); - return; - } - let cancelled = false; - api - .listWorkspaceMembers(currentWorkspace.workspace_id) - .then((list) => { - if (!cancelled) setMembers(list); - }) - .catch(() => { - if (!cancelled) setMembers([]); - }); - return () => { - cancelled = true; - }; - }, [api, currentWorkspace]); - - const displayNameByUserId = useMemo(() => { - const map = new Map(); - for (const m of members) { - map.set(m.user_id, m.display_name || m.username || m.user_id); - } - return map; - }, [members]); + const expandedPaths = useScriptWorkspaceStore((s) => s.expandedPaths); + const loadingChildrenPaths = useScriptWorkspaceStore( + (s) => s.loadingChildrenPaths, + ); + const onToggle = useScriptWorkspaceStore((s) => s.toggleExpanded); const memberScriptGroups = useMemo(() => { const visibleScripts = @@ -93,6 +69,10 @@ export function ScriptExplorer({ ); const byOwner = new Map(); + // 当前用户的目录树即使没有脚本也要渲染,所以预置空组。 + if (user?.user_id) { + byOwner.set(user.user_id, []); + } for (const item of visibleScripts) { const list = byOwner.get(item.owner_user_id) ?? []; list.push(item); @@ -106,7 +86,7 @@ export function ScriptExplorer({ }[] = []; for (const [ownerUserId, groupScripts] of byOwner.entries()) { const displayName = - displayNameByUserId.get(ownerUserId) ?? + groupScripts[0]?.owner_display_name ?? (ownerUserId === user?.user_id ? user?.display_name : null) ?? `${ownerUserId.slice(-6)}…`; const groupUser = @@ -139,7 +119,7 @@ export function ScriptExplorer({ }); return groups; - }, [filteredScripts, user, displayNameByUserId]); + }, [filteredScripts, directories, user]); return (