[fix/feat]:[FL-81][ATP模型管理改造为资产发布制]
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -2,6 +2,7 @@ from fastapi import APIRouter
|
||||
|
||||
from .v1.admin import router as admin_router
|
||||
from .v1.admin_files import router as admin_files_router
|
||||
from .v1.atp_assets import router as atp_assets_router
|
||||
from .v1.atp_models import router as atp_models_router
|
||||
from .v1.auth import router as auth_router
|
||||
from .v1.elevation import router as elevation_router
|
||||
@@ -24,6 +25,7 @@ v1_router.include_router(auth_router)
|
||||
v1_router.include_router(users_router)
|
||||
v1_router.include_router(admin_router)
|
||||
v1_router.include_router(admin_files_router)
|
||||
v1_router.include_router(atp_assets_router)
|
||||
v1_router.include_router(atp_models_router)
|
||||
v1_router.include_router(task_monitor_router)
|
||||
v1_router.include_router(scheduled_tasks_router)
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ...core.database import get_db
|
||||
from ...core.dependencies import CurrentUser, require_any_permission, require_permission
|
||||
from ...schemas.atp_asset import (
|
||||
AtpAssetCreateRequest,
|
||||
AtpAssetDetail,
|
||||
AtpAssetFileListResponse,
|
||||
AtpAssetListResponse,
|
||||
AtpAssetReleaseCreateRequest,
|
||||
AtpAssetReleaseDetail,
|
||||
AtpAssetReleaseListResponse,
|
||||
AtpAssetRunDetail,
|
||||
AtpAssetRunListResponse,
|
||||
AtpAssetRunRequest,
|
||||
AtpAssetUpdateRequest,
|
||||
)
|
||||
from ...schemas.atp_model import AtpEngineStatusResponse
|
||||
from ...services.atp_asset_service import (
|
||||
activate_release,
|
||||
create_asset,
|
||||
create_release,
|
||||
delete_asset,
|
||||
get_asset_by_id,
|
||||
get_release_by_id,
|
||||
get_run_detail,
|
||||
list_assets,
|
||||
list_release_files,
|
||||
list_releases,
|
||||
list_runs,
|
||||
run_release,
|
||||
serialize_asset,
|
||||
serialize_release_detail,
|
||||
update_asset,
|
||||
update_release,
|
||||
)
|
||||
from ...services.atp_model_service import get_engine_status
|
||||
|
||||
router = APIRouter(prefix="/atp", tags=["atp-assets"])
|
||||
|
||||
|
||||
@router.get("/engine/status", response_model=AtpEngineStatusResponse)
|
||||
def get_atp_engine_status_endpoint(
|
||||
_: CurrentUser = Depends(require_any_permission("atp.read", "atp.run", "atp.manage")),
|
||||
) -> AtpEngineStatusResponse:
|
||||
return get_engine_status()
|
||||
|
||||
|
||||
@router.get("/assets", response_model=AtpAssetListResponse)
|
||||
def get_atp_asset_list(
|
||||
keyword: str | None = Query(default=None),
|
||||
status_filter: str | None = Query(default=None, alias="status"),
|
||||
voltage_level: str | None = Query(default=None),
|
||||
tower_type: str | None = Query(default=None),
|
||||
scene_type: str | None = Query(default=None),
|
||||
_: CurrentUser = Depends(require_any_permission("atp.read", "atp.run", "atp.manage")),
|
||||
db: Session = Depends(get_db),
|
||||
) -> AtpAssetListResponse:
|
||||
return list_assets(
|
||||
db,
|
||||
keyword=keyword,
|
||||
status_filter=status_filter,
|
||||
voltage_level=voltage_level,
|
||||
tower_type=tower_type,
|
||||
scene_type=scene_type,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/assets", response_model=AtpAssetDetail)
|
||||
def create_atp_asset_endpoint(
|
||||
payload: AtpAssetCreateRequest,
|
||||
current_user: CurrentUser = Depends(require_permission("atp.manage")),
|
||||
db: Session = Depends(get_db),
|
||||
) -> AtpAssetDetail:
|
||||
created = create_asset(db, payload, actor_user_id=current_user.user.id)
|
||||
if not created:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Asset code already exists")
|
||||
return AtpAssetDetail(**created.model_dump())
|
||||
|
||||
|
||||
@router.get("/assets/{asset_id}", response_model=AtpAssetDetail)
|
||||
def get_atp_asset_detail(
|
||||
asset_id: str,
|
||||
_: CurrentUser = Depends(require_any_permission("atp.read", "atp.run", "atp.manage")),
|
||||
db: Session = Depends(get_db),
|
||||
) -> AtpAssetDetail:
|
||||
item = get_asset_by_id(db, asset_id)
|
||||
if not item:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Asset not found")
|
||||
active_release = next((release for release in item.releases if release.is_active), None)
|
||||
detail = serialize_asset(
|
||||
item,
|
||||
release_count=len(item.releases),
|
||||
run_count=len(item.runs),
|
||||
last_run_status=item.runs[0].status if item.runs else None,
|
||||
last_run_date=item.runs[0].create_date if item.runs else None,
|
||||
active_release=active_release,
|
||||
)
|
||||
return AtpAssetDetail(**detail.model_dump())
|
||||
|
||||
|
||||
@router.patch("/assets/{asset_id}", response_model=AtpAssetDetail)
|
||||
def update_atp_asset_endpoint(
|
||||
asset_id: str,
|
||||
payload: AtpAssetUpdateRequest,
|
||||
current_user: CurrentUser = Depends(require_permission("atp.manage")),
|
||||
db: Session = Depends(get_db),
|
||||
) -> AtpAssetDetail:
|
||||
updated = update_asset(db, asset_id, payload, actor_user_id=current_user.user.id)
|
||||
if not updated:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Asset not found")
|
||||
return AtpAssetDetail(**updated.model_dump())
|
||||
|
||||
|
||||
@router.delete("/assets/{asset_id}")
|
||||
def delete_atp_asset_endpoint(
|
||||
asset_id: str,
|
||||
_: CurrentUser = Depends(require_permission("atp.manage")),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict[str, bool]:
|
||||
deleted = delete_asset(db, asset_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Asset not found")
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@router.get("/assets/{asset_id}/releases", response_model=AtpAssetReleaseListResponse)
|
||||
def get_atp_asset_releases(
|
||||
asset_id: str,
|
||||
limit: int = Query(default=200, ge=1, le=500),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
_: CurrentUser = Depends(require_any_permission("atp.read", "atp.run", "atp.manage")),
|
||||
db: Session = Depends(get_db),
|
||||
) -> AtpAssetReleaseListResponse:
|
||||
if not get_asset_by_id(db, asset_id):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Asset not found")
|
||||
return list_releases(db, asset_id=asset_id, limit=limit, offset=offset)
|
||||
|
||||
|
||||
@router.post("/assets/{asset_id}/releases", response_model=AtpAssetReleaseDetail)
|
||||
def create_atp_asset_release_endpoint(
|
||||
asset_id: str,
|
||||
payload: AtpAssetReleaseCreateRequest,
|
||||
current_user: CurrentUser = Depends(require_permission("atp.manage")),
|
||||
db: Session = Depends(get_db),
|
||||
) -> AtpAssetReleaseDetail:
|
||||
return create_release(db, asset_id=asset_id, payload=payload, actor_user_id=current_user.user.id)
|
||||
|
||||
|
||||
@router.get("/releases", response_model=AtpAssetReleaseListResponse)
|
||||
def get_atp_release_list(
|
||||
active_only: bool = Query(default=False),
|
||||
status_filter: str | None = Query(default=None, alias="status"),
|
||||
limit: int = Query(default=200, ge=1, le=500),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
_: CurrentUser = Depends(require_any_permission("atp.read", "atp.run", "atp.manage")),
|
||||
db: Session = Depends(get_db),
|
||||
) -> AtpAssetReleaseListResponse:
|
||||
return list_releases(
|
||||
db,
|
||||
active_only=active_only,
|
||||
status_filter=status_filter,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/releases/{release_id}", response_model=AtpAssetReleaseDetail)
|
||||
def get_atp_release_detail(
|
||||
release_id: str,
|
||||
_: CurrentUser = Depends(require_any_permission("atp.read", "atp.run", "atp.manage")),
|
||||
db: Session = Depends(get_db),
|
||||
) -> AtpAssetReleaseDetail:
|
||||
item = get_release_by_id(db, release_id)
|
||||
if not item:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Release not found")
|
||||
return serialize_release_detail(item)
|
||||
|
||||
|
||||
@router.patch("/releases/{release_id}", response_model=AtpAssetReleaseDetail)
|
||||
def update_atp_release_endpoint(
|
||||
release_id: str,
|
||||
payload: AtpAssetReleaseUpdateRequest,
|
||||
current_user: CurrentUser = Depends(require_permission("atp.manage")),
|
||||
db: Session = Depends(get_db),
|
||||
) -> AtpAssetReleaseDetail:
|
||||
return update_release(db, release_id=release_id, payload=payload, actor_user_id=current_user.user.id)
|
||||
|
||||
|
||||
@router.post("/releases/{release_id}/activate", response_model=AtpAssetDetail)
|
||||
def activate_atp_release_endpoint(
|
||||
release_id: str,
|
||||
current_user: CurrentUser = Depends(require_permission("atp.manage")),
|
||||
db: Session = Depends(get_db),
|
||||
) -> AtpAssetDetail:
|
||||
detail = activate_release(db, release_id=release_id, actor_user_id=current_user.user.id)
|
||||
return AtpAssetDetail(**detail.model_dump())
|
||||
|
||||
|
||||
@router.get("/releases/{release_id}/files", response_model=AtpAssetFileListResponse)
|
||||
def get_atp_release_files(
|
||||
release_id: str,
|
||||
_: CurrentUser = Depends(require_any_permission("atp.read", "atp.run", "atp.manage")),
|
||||
db: Session = Depends(get_db),
|
||||
) -> AtpAssetFileListResponse:
|
||||
return list_release_files(db, release_id=release_id)
|
||||
|
||||
|
||||
@router.get("/releases/{release_id}/runs", response_model=AtpAssetRunListResponse)
|
||||
def get_atp_release_runs(
|
||||
release_id: str,
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
_: CurrentUser = Depends(require_any_permission("atp.read", "atp.run", "atp.manage")),
|
||||
db: Session = Depends(get_db),
|
||||
) -> AtpAssetRunListResponse:
|
||||
if not get_release_by_id(db, release_id):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Release not found")
|
||||
return list_runs(db, release_id=release_id, limit=limit, offset=offset)
|
||||
|
||||
|
||||
@router.post("/releases/{release_id}/runs", response_model=AtpAssetRunDetail)
|
||||
def run_atp_release_endpoint(
|
||||
release_id: str,
|
||||
payload: AtpAssetRunRequest,
|
||||
current_user: CurrentUser = Depends(require_any_permission("atp.run", "atp.manage")),
|
||||
db: Session = Depends(get_db),
|
||||
) -> AtpAssetRunDetail:
|
||||
return run_release(db, release_id=release_id, payload=payload, actor_user_id=current_user.user.id)
|
||||
|
||||
|
||||
@router.get("/runs/{run_id}", response_model=AtpAssetRunDetail)
|
||||
def get_atp_run_detail(
|
||||
run_id: str,
|
||||
_: CurrentUser = Depends(require_any_permission("atp.read", "atp.run", "atp.manage")),
|
||||
db: Session = Depends(get_db),
|
||||
) -> AtpAssetRunDetail:
|
||||
return get_run_detail(db, run_id=run_id)
|
||||
@@ -11,6 +11,7 @@ celery_app = Celery(
|
||||
broker=settings.resolved_celery_broker_url,
|
||||
backend=settings.resolved_celery_result_backend,
|
||||
include=[
|
||||
"app.tasks.atp_asset_tasks",
|
||||
"app.tasks.atp_model_tasks",
|
||||
"app.tasks.elevation_tasks",
|
||||
"app.tasks.fl_analysis_tasks",
|
||||
|
||||
@@ -490,6 +490,7 @@ def get_db() -> Generator[Session, None, None]:
|
||||
def init_db() -> None:
|
||||
# Import models so metadata includes every table before create_all.
|
||||
from ..models import (
|
||||
atp_asset,
|
||||
atp_model,
|
||||
audit_log,
|
||||
auth_session,
|
||||
|
||||
@@ -4,9 +4,10 @@ Import all model modules during package initialization so SQLAlchemy can
|
||||
resolve string-based relationships regardless of route/service import order.
|
||||
"""
|
||||
|
||||
from . import atp_model, audit_log, auth_session, elevation, file_storage, fl_analysis, lightning_event, lightning_sample, line, line_tower, menu, object_group, rbac, scheduled_task, system_param, tower_model, tower_profile, user, wine, worker_registry
|
||||
from . import atp_asset, atp_model, audit_log, auth_session, elevation, file_storage, fl_analysis, lightning_event, lightning_sample, line, line_tower, menu, object_group, rbac, scheduled_task, system_param, tower_model, tower_profile, user, wine, worker_registry
|
||||
|
||||
__all__ = [
|
||||
"atp_asset",
|
||||
"atp_model",
|
||||
"audit_log",
|
||||
"auth_session",
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import JSON, Boolean, DateTime, ForeignKey, Index, Integer, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from ..core.database import Base
|
||||
from .base import utcnow
|
||||
|
||||
|
||||
class AtpAsset(Base):
|
||||
__tablename__ = "atp_asset"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("code", name="uq_atp_asset_code"),
|
||||
Index("idx_atp_asset_status", "status"),
|
||||
Index("idx_atp_asset_voltage_level", "voltage_level"),
|
||||
Index("idx_atp_asset_tower_type", "tower_type"),
|
||||
Index("idx_atp_asset_scene_type", "scene_type"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(32), primary_key=True, default=lambda: uuid4().hex)
|
||||
code: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str] = mapped_column(Text(), default="")
|
||||
status: Mapped[str] = mapped_column(String(20), default="enabled", index=True)
|
||||
voltage_level: Mapped[str | None] = mapped_column(String(16), index=True)
|
||||
tower_type: Mapped[str | None] = mapped_column(String(64), index=True)
|
||||
scene_type: Mapped[str | None] = mapped_column(String(32), index=True)
|
||||
tags_json: Mapped[list[str]] = mapped_column(JSON, default=list)
|
||||
latest_release_no: Mapped[int] = mapped_column(Integer, default=0)
|
||||
active_release_no: Mapped[int | None] = mapped_column(Integer)
|
||||
create_date: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True)
|
||||
create_user: Mapped[str | None] = mapped_column(String(64), index=True)
|
||||
update_date: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
|
||||
update_user: Mapped[str | None] = mapped_column(String(64), index=True)
|
||||
|
||||
releases: Mapped[list[AtpAssetRelease]] = relationship(
|
||||
"AtpAssetRelease",
|
||||
back_populates="asset",
|
||||
lazy="selectin",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="AtpAssetRelease.release_no.desc()",
|
||||
)
|
||||
runs: Mapped[list[AtpAssetRun]] = relationship(
|
||||
"AtpAssetRun",
|
||||
back_populates="asset",
|
||||
lazy="selectin",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="AtpAssetRun.create_date.desc()",
|
||||
)
|
||||
|
||||
|
||||
class AtpAssetRelease(Base):
|
||||
__tablename__ = "atp_asset_release"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("asset_id", "release_no", name="uq_atp_asset_release_asset_no"),
|
||||
Index("idx_atp_asset_release_status", "status"),
|
||||
Index("idx_atp_asset_release_runner_kind", "runner_kind"),
|
||||
Index("idx_atp_asset_release_storage_mount", "storage_mount_code"),
|
||||
Index("idx_atp_asset_release_asset_active", "asset_id", "is_active"),
|
||||
Index("idx_atp_asset_release_asset_status", "asset_id", "status"),
|
||||
Index("idx_atp_asset_release_content_hash", "content_hash"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(32), primary_key=True, default=lambda: uuid4().hex)
|
||||
asset_id: Mapped[str] = mapped_column(
|
||||
String(32),
|
||||
ForeignKey("atp_asset.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
release_no: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
|
||||
release_tag: Mapped[str | None] = mapped_column(String(64), index=True)
|
||||
status: Mapped[str] = mapped_column(String(20), default="draft", index=True)
|
||||
voltage_level: Mapped[str] = mapped_column(String(16), nullable=False, index=True)
|
||||
tower_type: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
scene_type: Mapped[str] = mapped_column(String(32), nullable=False, index=True)
|
||||
scenario_code: Mapped[str | None] = mapped_column(String(64), index=True)
|
||||
runner_kind: Mapped[str] = mapped_column(String(20), default="atp", index=True)
|
||||
storage_mount_code: Mapped[str] = mapped_column(String(64), default="main", index=True)
|
||||
storage_root_path: Mapped[str] = mapped_column(String(2048), nullable=False)
|
||||
entry_file: Mapped[str | None] = mapped_column(String(255))
|
||||
result_file: Mapped[str | None] = mapped_column(String(255))
|
||||
egm_subdir: Mapped[str | None] = mapped_column(String(255))
|
||||
egm_result_file: Mapped[str | None] = mapped_column(String(255))
|
||||
preprocess_script: Mapped[str | None] = mapped_column(String(255))
|
||||
postprocess_script: Mapped[str | None] = mapped_column(String(255))
|
||||
manifest_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
|
||||
validation_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
|
||||
content_hash: Mapped[str] = mapped_column(String(64), default="", index=True)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
|
||||
create_date: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True)
|
||||
create_user: Mapped[str | None] = mapped_column(String(64), index=True)
|
||||
update_date: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
|
||||
update_user: Mapped[str | None] = mapped_column(String(64), index=True)
|
||||
|
||||
asset: Mapped[AtpAsset] = relationship("AtpAsset", back_populates="releases", lazy="selectin")
|
||||
runs: Mapped[list[AtpAssetRun]] = relationship(
|
||||
"AtpAssetRun",
|
||||
back_populates="release",
|
||||
lazy="selectin",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="AtpAssetRun.create_date.desc()",
|
||||
)
|
||||
|
||||
|
||||
class AtpAssetRun(Base):
|
||||
__tablename__ = "atp_asset_run"
|
||||
__table_args__ = (
|
||||
Index("idx_atp_asset_run_status", "status"),
|
||||
Index("idx_atp_asset_run_asset", "asset_id", "create_date"),
|
||||
Index("idx_atp_asset_run_release", "release_id", "create_date"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(32), primary_key=True, default=lambda: uuid4().hex)
|
||||
asset_id: Mapped[str] = mapped_column(
|
||||
String(32),
|
||||
ForeignKey("atp_asset.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
release_id: Mapped[str] = mapped_column(
|
||||
String(32),
|
||||
ForeignKey("atp_asset_release.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
status: Mapped[str] = mapped_column(String(20), default="pending", index=True)
|
||||
engine_mode: Mapped[str] = mapped_column(String(20), default="wine", index=True)
|
||||
runner_kind: Mapped[str] = mapped_column(String(20), default="atp", index=True)
|
||||
task_id: Mapped[str | None] = mapped_column(String(128), index=True)
|
||||
storage_mount_code: Mapped[str | None] = mapped_column(String(64), index=True)
|
||||
storage_root_path: Mapped[str | None] = mapped_column(String(2048))
|
||||
materialized_root_path: Mapped[str | None] = mapped_column(String(2048))
|
||||
engine_command: Mapped[str | None] = mapped_column(String(2000))
|
||||
working_dir: Mapped[str | None] = mapped_column(String(2000))
|
||||
timeout_seconds: Mapped[int] = mapped_column(Integer, default=600)
|
||||
exit_code: Mapped[int | None] = mapped_column(Integer)
|
||||
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
duration_ms: Mapped[int | None] = mapped_column(Integer)
|
||||
stdout_text: Mapped[str | None] = mapped_column(Text())
|
||||
stderr_text: Mapped[str | None] = mapped_column(Text())
|
||||
output_manifest_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
|
||||
result_summary_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
|
||||
error_message: Mapped[str | None] = mapped_column(Text())
|
||||
create_date: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True)
|
||||
create_user: Mapped[str | None] = mapped_column(String(64), index=True)
|
||||
update_date: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
|
||||
update_user: Mapped[str | None] = mapped_column(String(64), index=True)
|
||||
|
||||
asset: Mapped[AtpAsset] = relationship("AtpAsset", back_populates="runs", lazy="selectin")
|
||||
release: Mapped[AtpAssetRelease] = relationship("AtpAssetRelease", back_populates="runs", lazy="selectin")
|
||||
@@ -0,0 +1,206 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
AtpAssetStatus = Literal["draft", "enabled", "disabled", "archived"]
|
||||
AtpAssetReleaseStatus = Literal["draft", "released", "archived"]
|
||||
AtpAssetRunnerKind = Literal["atp", "egm", "hybrid"]
|
||||
AtpAssetRunStatus = Literal["pending", "running", "success", "failed"]
|
||||
AtpAssetEngineMode = Literal["wine", "native"]
|
||||
|
||||
|
||||
class AtpAssetSummary(BaseModel):
|
||||
id: str
|
||||
code: str
|
||||
name: str
|
||||
description: str
|
||||
status: AtpAssetStatus
|
||||
voltage_level: str | None = None
|
||||
tower_type: str | None = None
|
||||
scene_type: str | None = None
|
||||
tags_json: list[str] = Field(default_factory=list)
|
||||
latest_release_no: int = 0
|
||||
active_release_no: int | None = None
|
||||
active_release_id: str | None = None
|
||||
active_release_tag: str | None = None
|
||||
release_count: int = 0
|
||||
run_count: int = 0
|
||||
last_run_status: AtpAssetRunStatus | None = None
|
||||
last_run_date: datetime | None = None
|
||||
create_date: datetime
|
||||
create_user: str | None = None
|
||||
update_date: datetime
|
||||
update_user: str | None = None
|
||||
|
||||
|
||||
class AtpAssetListResponse(BaseModel):
|
||||
items: list[AtpAssetSummary]
|
||||
total: int
|
||||
|
||||
|
||||
class AtpAssetDetail(AtpAssetSummary):
|
||||
pass
|
||||
|
||||
|
||||
class AtpAssetCreateRequest(BaseModel):
|
||||
code: str = Field(min_length=1, max_length=64)
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
description: str = Field(default="", max_length=8000)
|
||||
status: AtpAssetStatus = "enabled"
|
||||
voltage_level: str | None = Field(default=None, max_length=16)
|
||||
tower_type: str | None = Field(default=None, max_length=64)
|
||||
scene_type: str | None = Field(default=None, max_length=32)
|
||||
tags_json: list[str] = Field(default_factory=list, max_length=128)
|
||||
|
||||
|
||||
class AtpAssetUpdateRequest(BaseModel):
|
||||
name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
description: str | None = Field(default=None, max_length=8000)
|
||||
status: AtpAssetStatus | None = None
|
||||
voltage_level: str | None = Field(default=None, max_length=16)
|
||||
tower_type: str | None = Field(default=None, max_length=64)
|
||||
scene_type: str | None = Field(default=None, max_length=32)
|
||||
tags_json: list[str] | None = Field(default=None, max_length=128)
|
||||
|
||||
|
||||
class AtpAssetReleaseSummary(BaseModel):
|
||||
id: str
|
||||
asset_id: str
|
||||
asset_code: str
|
||||
asset_name: str
|
||||
release_no: int
|
||||
release_tag: str | None = None
|
||||
status: AtpAssetReleaseStatus
|
||||
voltage_level: str
|
||||
tower_type: str
|
||||
scene_type: str
|
||||
scenario_code: str | None = None
|
||||
runner_kind: AtpAssetRunnerKind
|
||||
storage_mount_code: str
|
||||
storage_root_path: str
|
||||
entry_file: str | None = None
|
||||
result_file: str | None = None
|
||||
egm_subdir: str | None = None
|
||||
egm_result_file: str | None = None
|
||||
preprocess_script: str | None = None
|
||||
postprocess_script: str | None = None
|
||||
content_hash: str
|
||||
is_active: bool
|
||||
create_date: datetime
|
||||
create_user: str | None = None
|
||||
update_date: datetime
|
||||
update_user: str | None = None
|
||||
|
||||
|
||||
class AtpAssetReleaseDetail(AtpAssetReleaseSummary):
|
||||
manifest_json: dict[str, Any] = Field(default_factory=dict)
|
||||
validation_json: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class AtpAssetReleaseListResponse(BaseModel):
|
||||
items: list[AtpAssetReleaseSummary]
|
||||
total: int
|
||||
|
||||
|
||||
class AtpAssetReleaseCreateRequest(BaseModel):
|
||||
release_tag: str | None = Field(default=None, max_length=64)
|
||||
status: AtpAssetReleaseStatus = "released"
|
||||
voltage_level: str = Field(min_length=1, max_length=16)
|
||||
tower_type: str = Field(min_length=1, max_length=64)
|
||||
scene_type: str = Field(min_length=1, max_length=32)
|
||||
scenario_code: str | None = Field(default=None, max_length=64)
|
||||
runner_kind: AtpAssetRunnerKind = "atp"
|
||||
storage_mount_code: str = Field(default="main", min_length=1, max_length=64)
|
||||
storage_root_path: str = Field(min_length=1, max_length=2048)
|
||||
entry_file: str | None = Field(default=None, max_length=255)
|
||||
result_file: str | None = Field(default=None, max_length=255)
|
||||
egm_subdir: str | None = Field(default=None, max_length=255)
|
||||
egm_result_file: str | None = Field(default=None, max_length=255)
|
||||
preprocess_script: str | None = Field(default=None, max_length=255)
|
||||
postprocess_script: str | None = Field(default=None, max_length=255)
|
||||
|
||||
|
||||
class AtpAssetReleaseUpdateRequest(BaseModel):
|
||||
release_tag: str | None = Field(default=None, max_length=64)
|
||||
status: AtpAssetReleaseStatus | None = None
|
||||
voltage_level: str | None = Field(default=None, min_length=1, max_length=16)
|
||||
tower_type: str | None = Field(default=None, min_length=1, max_length=64)
|
||||
scene_type: str | None = Field(default=None, min_length=1, max_length=32)
|
||||
scenario_code: str | None = Field(default=None, max_length=64)
|
||||
runner_kind: AtpAssetRunnerKind | None = None
|
||||
storage_mount_code: str | None = Field(default=None, min_length=1, max_length=64)
|
||||
storage_root_path: str | None = Field(default=None, min_length=1, max_length=2048)
|
||||
entry_file: str | None = Field(default=None, max_length=255)
|
||||
result_file: str | None = Field(default=None, max_length=255)
|
||||
egm_subdir: str | None = Field(default=None, max_length=255)
|
||||
egm_result_file: str | None = Field(default=None, max_length=255)
|
||||
preprocess_script: str | None = Field(default=None, max_length=255)
|
||||
postprocess_script: str | None = Field(default=None, max_length=255)
|
||||
|
||||
|
||||
class AtpAssetFileEntry(BaseModel):
|
||||
relative_path: str
|
||||
name: str
|
||||
is_dir: bool
|
||||
size: int = 0
|
||||
mime_type: str | None = None
|
||||
file_role: str | None = None
|
||||
|
||||
|
||||
class AtpAssetFileListResponse(BaseModel):
|
||||
release_id: str
|
||||
storage_mount_code: str
|
||||
storage_root_path: str
|
||||
items: list[AtpAssetFileEntry]
|
||||
total: int
|
||||
|
||||
|
||||
class AtpAssetRunSummary(BaseModel):
|
||||
id: str
|
||||
asset_id: str
|
||||
asset_code: str
|
||||
asset_name: str
|
||||
release_id: str
|
||||
release_no: int
|
||||
release_tag: str | None = None
|
||||
status: AtpAssetRunStatus
|
||||
engine_mode: AtpAssetEngineMode
|
||||
runner_kind: AtpAssetRunnerKind
|
||||
task_id: str | None = None
|
||||
storage_mount_code: str | None = None
|
||||
storage_root_path: str | None = None
|
||||
materialized_root_path: str | None = None
|
||||
engine_command: str | None = None
|
||||
working_dir: str | None = None
|
||||
timeout_seconds: int
|
||||
exit_code: int | None = None
|
||||
started_at: datetime | None = None
|
||||
finished_at: datetime | None = None
|
||||
duration_ms: int | None = None
|
||||
error_message: str | None = None
|
||||
stdout_size: int = 0
|
||||
stderr_size: int = 0
|
||||
create_date: datetime
|
||||
create_user: str | None = None
|
||||
|
||||
|
||||
class AtpAssetRunDetail(AtpAssetRunSummary):
|
||||
stdout_text: str | None = None
|
||||
stderr_text: str | None = None
|
||||
output_manifest_json: dict[str, Any] = Field(default_factory=dict)
|
||||
result_summary_json: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class AtpAssetRunListResponse(BaseModel):
|
||||
items: list[AtpAssetRunSummary]
|
||||
total: int
|
||||
|
||||
|
||||
class AtpAssetRunRequest(BaseModel):
|
||||
timeout_seconds: int | None = Field(default=None, ge=1)
|
||||
extra_args: list[str] = Field(default_factory=list, max_length=32)
|
||||
environment: dict[str, str] = Field(default_factory=dict, max_length=16)
|
||||
dry_run: bool = False
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ..core.celery_app import celery_app
|
||||
from ..services.atp_asset_service import execute_asset_run_job
|
||||
|
||||
|
||||
@celery_app.task(name="app.tasks.atp_asset_tasks.execute_atp_asset_run_job")
|
||||
def execute_atp_asset_run_job(
|
||||
run_id: str,
|
||||
payload_data: dict,
|
||||
actor_user_id: str | None,
|
||||
) -> None:
|
||||
execute_asset_run_job(run_id=run_id, payload_data=payload_data, actor_user_id=actor_user_id)
|
||||
@@ -0,0 +1,151 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from app.core import database as core_database
|
||||
from app.core.database import Base
|
||||
from app.models.atp_asset import AtpAsset, AtpAssetRelease, AtpAssetRun
|
||||
from app.models.file_storage import FileIndexEntry, FileStorageBackend, FileStorageMount
|
||||
from app.models.user import User
|
||||
from app.schemas.atp_asset import AtpAssetCreateRequest, AtpAssetReleaseCreateRequest, AtpAssetRunRequest
|
||||
from app.services import atp_asset_service
|
||||
|
||||
|
||||
def _build_sessionmaker():
|
||||
engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||
Base.metadata.create_all(
|
||||
bind=engine,
|
||||
tables=[
|
||||
FileStorageBackend.__table__,
|
||||
FileStorageMount.__table__,
|
||||
FileIndexEntry.__table__,
|
||||
User.__table__,
|
||||
AtpAsset.__table__,
|
||||
AtpAssetRelease.__table__,
|
||||
AtpAssetRun.__table__,
|
||||
],
|
||||
)
|
||||
return sessionmaker(bind=engine, autocommit=False, autoflush=False, expire_on_commit=False)
|
||||
|
||||
|
||||
def _seed_vfs_mount(session: Session, *, root_dir: Path) -> None:
|
||||
backend = FileStorageBackend(
|
||||
code="main",
|
||||
name="Main VFS",
|
||||
driver_type="VFS",
|
||||
status="enabled",
|
||||
is_default=True,
|
||||
config_json={"root_dir": str(root_dir)},
|
||||
)
|
||||
session.add(backend)
|
||||
session.flush()
|
||||
session.add(
|
||||
FileStorageMount(
|
||||
code="main",
|
||||
name="Main Mount",
|
||||
backend_id=backend.id,
|
||||
mount_path="/",
|
||||
root_path="/",
|
||||
is_enabled=True,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
|
||||
def test_create_release_auto_detects_entry_file_and_manifest(tmp_path) -> None:
|
||||
testing_session = _build_sessionmaker()
|
||||
session: Session = testing_session()
|
||||
try:
|
||||
release_root = tmp_path / "vfs" / "atp-library" / "demo-release"
|
||||
release_root.mkdir(parents=True)
|
||||
(release_root / "work.atp").write_text("ATP INPUT", encoding="utf-8")
|
||||
(release_root / "README.txt").write_text("docs", encoding="utf-8")
|
||||
|
||||
_seed_vfs_mount(session, root_dir=tmp_path / "vfs")
|
||||
asset = atp_asset_service.create_asset(
|
||||
session,
|
||||
AtpAssetCreateRequest(code="ATP-ASSET-001", name="目录化ATP资产"),
|
||||
actor_user_id="tester",
|
||||
)
|
||||
assert asset is not None
|
||||
|
||||
created = atp_asset_service.create_release(
|
||||
session,
|
||||
asset_id=asset.id,
|
||||
payload=AtpAssetReleaseCreateRequest(
|
||||
voltage_level="220",
|
||||
tower_type="sihuita",
|
||||
scene_type="raoji3",
|
||||
runner_kind="atp",
|
||||
storage_mount_code="main",
|
||||
storage_root_path="/atp-library/demo-release",
|
||||
),
|
||||
actor_user_id="tester",
|
||||
)
|
||||
|
||||
assert created.entry_file == "work.atp"
|
||||
assert created.is_active is True
|
||||
assert created.manifest_json["file_count"] == 2
|
||||
assert created.validation_json["entry_file_exists"] is True
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def test_run_release_dry_run_materializes_directory(tmp_path, monkeypatch) -> None:
|
||||
testing_session = _build_sessionmaker()
|
||||
monkeypatch.setattr(core_database, "SessionLocal", testing_session)
|
||||
session: Session = testing_session()
|
||||
try:
|
||||
release_root = tmp_path / "vfs" / "atp-library" / "runtime-release"
|
||||
release_root.mkdir(parents=True)
|
||||
(release_root / "work.atp").write_text("ATP INPUT", encoding="utf-8")
|
||||
(release_root / "tpbig.exe").write_text("binary", encoding="utf-8")
|
||||
|
||||
_seed_vfs_mount(session, root_dir=tmp_path / "vfs")
|
||||
asset = atp_asset_service.create_asset(
|
||||
session,
|
||||
AtpAssetCreateRequest(code="ATP-ASSET-DRY", name="Dry Run 资产"),
|
||||
actor_user_id="tester",
|
||||
)
|
||||
assert asset is not None
|
||||
release = atp_asset_service.create_release(
|
||||
session,
|
||||
asset_id=asset.id,
|
||||
payload=AtpAssetReleaseCreateRequest(
|
||||
voltage_level="500",
|
||||
tower_type="ganzi",
|
||||
scene_type="fanji",
|
||||
runner_kind="atp",
|
||||
storage_mount_code="main",
|
||||
storage_root_path="/atp-library/runtime-release",
|
||||
),
|
||||
actor_user_id="tester",
|
||||
)
|
||||
|
||||
allowed_root = tmp_path / "wine-root"
|
||||
allowed_root.mkdir(parents=True)
|
||||
monkeypatch.setattr(atp_asset_service.settings, "wine_allowed_root", str(allowed_root))
|
||||
monkeypatch.setattr(atp_asset_service.settings, "atp_engine_mode", "wine")
|
||||
monkeypatch.setattr(atp_asset_service, "_resolve_binary", lambda value: "/usr/bin/wine" if value == "wine" else None)
|
||||
monkeypatch.setattr(atp_asset_service, "_publish_change", lambda *args, **kwargs: None)
|
||||
|
||||
result = atp_asset_service.run_release(
|
||||
session,
|
||||
release_id=release.id,
|
||||
payload=AtpAssetRunRequest(dry_run=True),
|
||||
actor_user_id="tester",
|
||||
)
|
||||
|
||||
assert result.status == "success"
|
||||
assert result.engine_command is not None
|
||||
assert "tpbig.exe" in result.engine_command
|
||||
assert result.materialized_root_path is not None
|
||||
materialized_root = Path(result.materialized_root_path)
|
||||
assert materialized_root.exists()
|
||||
assert (materialized_root / "work.atp").exists()
|
||||
assert result.output_manifest_json["file_count"] >= 2
|
||||
finally:
|
||||
session.close()
|
||||
@@ -0,0 +1,775 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Alert,
|
||||
App,
|
||||
Button,
|
||||
Descriptions,
|
||||
Empty,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
} from "antd";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { AdminPageLoading } from "@/components/admin-page-loading";
|
||||
import { useAuth } from "@/components/auth-provider";
|
||||
import { Card } from "@/components/ui-antd";
|
||||
import { readApiError } from "@/lib/api";
|
||||
import type {
|
||||
AtpAssetFileEntry,
|
||||
AtpAssetFileListResponse,
|
||||
AtpAssetReleaseDetail,
|
||||
AtpAssetReleaseListResponse,
|
||||
AtpAssetReleaseSummary,
|
||||
AtpAssetRunDetail,
|
||||
AtpAssetRunListResponse,
|
||||
AtpAssetRunSummary,
|
||||
AtpAssetSummary,
|
||||
AtpEngineStatusResponse,
|
||||
} from "@/types/auth";
|
||||
|
||||
type ReleaseFormValues = {
|
||||
release_tag: string;
|
||||
status: "draft" | "released" | "archived";
|
||||
voltage_level: string;
|
||||
tower_type: string;
|
||||
scene_type: string;
|
||||
scenario_code: string;
|
||||
runner_kind: "atp" | "egm" | "hybrid";
|
||||
storage_mount_code: string;
|
||||
storage_root_path: string;
|
||||
entry_file: string;
|
||||
result_file: string;
|
||||
egm_subdir: string;
|
||||
egm_result_file: string;
|
||||
preprocess_script: string;
|
||||
postprocess_script: string;
|
||||
};
|
||||
|
||||
type RunFormValues = {
|
||||
dry_run: boolean;
|
||||
timeout_seconds: number | null;
|
||||
extra_args_text: string;
|
||||
};
|
||||
|
||||
const EMPTY_RELEASE_FORM: ReleaseFormValues = {
|
||||
release_tag: "",
|
||||
status: "released",
|
||||
voltage_level: "",
|
||||
tower_type: "",
|
||||
scene_type: "",
|
||||
scenario_code: "",
|
||||
runner_kind: "atp",
|
||||
storage_mount_code: "main",
|
||||
storage_root_path: "",
|
||||
entry_file: "",
|
||||
result_file: "",
|
||||
egm_subdir: "",
|
||||
egm_result_file: "",
|
||||
preprocess_script: "",
|
||||
postprocess_script: "",
|
||||
};
|
||||
|
||||
const EMPTY_RUN_FORM: RunFormValues = {
|
||||
dry_run: true,
|
||||
timeout_seconds: null,
|
||||
extra_args_text: "",
|
||||
};
|
||||
|
||||
function formatDateTime(value: string | null | undefined): string {
|
||||
if (!value) {
|
||||
return "-";
|
||||
}
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value;
|
||||
}
|
||||
return date.toLocaleString("zh-CN", { hour12: false });
|
||||
}
|
||||
|
||||
function statusColor(value: string): string {
|
||||
if (value === "enabled" || value === "released" || value === "success") return "green";
|
||||
if (value === "draft" || value === "running") return "gold";
|
||||
if (value === "pending") return "blue";
|
||||
if (value === "disabled") return "default";
|
||||
if (value === "failed" || value === "archived") return "red";
|
||||
return "blue";
|
||||
}
|
||||
|
||||
function toReleaseFormValues(item: AtpAssetReleaseSummary): ReleaseFormValues {
|
||||
return {
|
||||
release_tag: item.release_tag ?? "",
|
||||
status: item.status,
|
||||
voltage_level: item.voltage_level,
|
||||
tower_type: item.tower_type,
|
||||
scene_type: item.scene_type,
|
||||
scenario_code: item.scenario_code ?? "",
|
||||
runner_kind: item.runner_kind,
|
||||
storage_mount_code: item.storage_mount_code,
|
||||
storage_root_path: item.storage_root_path,
|
||||
entry_file: item.entry_file ?? "",
|
||||
result_file: item.result_file ?? "",
|
||||
egm_subdir: item.egm_subdir ?? "",
|
||||
egm_result_file: item.egm_result_file ?? "",
|
||||
preprocess_script: item.preprocess_script ?? "",
|
||||
postprocess_script: item.postprocess_script ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
function buildReleasePayload(values: ReleaseFormValues) {
|
||||
return {
|
||||
release_tag: values.release_tag.trim() || null,
|
||||
status: values.status,
|
||||
voltage_level: values.voltage_level.trim(),
|
||||
tower_type: values.tower_type.trim(),
|
||||
scene_type: values.scene_type.trim(),
|
||||
scenario_code: values.scenario_code.trim() || null,
|
||||
runner_kind: values.runner_kind,
|
||||
storage_mount_code: values.storage_mount_code.trim(),
|
||||
storage_root_path: values.storage_root_path.trim(),
|
||||
entry_file: values.entry_file.trim() || null,
|
||||
result_file: values.result_file.trim() || null,
|
||||
egm_subdir: values.egm_subdir.trim() || null,
|
||||
egm_result_file: values.egm_result_file.trim() || null,
|
||||
preprocess_script: values.preprocess_script.trim() || null,
|
||||
postprocess_script: values.postprocess_script.trim() || null,
|
||||
};
|
||||
}
|
||||
|
||||
function buildRunPayload(values: RunFormValues) {
|
||||
return {
|
||||
dry_run: values.dry_run,
|
||||
timeout_seconds: values.timeout_seconds || null,
|
||||
extra_args: values.extra_args_text
|
||||
.split(/\s+/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
export default function AtpAssetDetailPage() {
|
||||
const { message } = App.useApp();
|
||||
const { user, initializing, fetchWithAuth, hasPermission } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const params = useParams<{ id: string }>();
|
||||
const assetId = typeof params?.id === "string" ? params.id : "";
|
||||
const [releaseForm] = Form.useForm<ReleaseFormValues>();
|
||||
const [runForm] = Form.useForm<RunFormValues>();
|
||||
|
||||
const [releaseModalOpen, setReleaseModalOpen] = useState(false);
|
||||
const [runModalOpen, setRunModalOpen] = useState(false);
|
||||
const [editingRelease, setEditingRelease] = useState<AtpAssetReleaseSummary | null>(null);
|
||||
const [selectedReleaseIdState, setSelectedReleaseIdState] = useState<string>("");
|
||||
|
||||
const canRead = hasPermission("atp.read") || hasPermission("atp.run") || hasPermission("atp.manage");
|
||||
const canRun = hasPermission("atp.run") || hasPermission("atp.manage");
|
||||
const canManage = hasPermission("atp.manage");
|
||||
|
||||
const assetQuery = useQuery({
|
||||
queryKey: ["atp-asset-detail", assetId],
|
||||
enabled: Boolean(user && canRead && assetId),
|
||||
queryFn: async () => {
|
||||
const response = await fetchWithAuth(`/api/v1/atp/assets/${assetId}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(await readApiError(response));
|
||||
}
|
||||
return (await response.json()) as AtpAssetSummary;
|
||||
},
|
||||
});
|
||||
|
||||
const releasesQuery = useQuery({
|
||||
queryKey: ["atp-asset-releases", assetId],
|
||||
enabled: Boolean(user && canRead && assetId),
|
||||
queryFn: async () => {
|
||||
const response = await fetchWithAuth(`/api/v1/atp/assets/${assetId}/releases`);
|
||||
if (!response.ok) {
|
||||
throw new Error(await readApiError(response));
|
||||
}
|
||||
return (await response.json()) as AtpAssetReleaseListResponse;
|
||||
},
|
||||
});
|
||||
|
||||
const engineQuery = useQuery({
|
||||
queryKey: ["atp-asset-engine-status"],
|
||||
enabled: Boolean(user && canRead),
|
||||
queryFn: async () => {
|
||||
const response = await fetchWithAuth("/api/v1/atp/engine/status");
|
||||
if (!response.ok) {
|
||||
throw new Error(await readApiError(response));
|
||||
}
|
||||
return (await response.json()) as AtpEngineStatusResponse;
|
||||
},
|
||||
});
|
||||
|
||||
const releases = releasesQuery.data?.items ?? [];
|
||||
const selectedReleaseId =
|
||||
selectedReleaseIdState && releases.some((item) => item.id === selectedReleaseIdState)
|
||||
? selectedReleaseIdState
|
||||
: (releases.find((item) => item.is_active)?.id ?? releases[0]?.id ?? "");
|
||||
const selectedRelease = releases.find((item) => item.id === selectedReleaseId) ?? null;
|
||||
|
||||
const releaseDetailQuery = useQuery({
|
||||
queryKey: ["atp-release-detail", selectedReleaseId],
|
||||
enabled: Boolean(user && canRead && selectedReleaseId),
|
||||
queryFn: async () => {
|
||||
const response = await fetchWithAuth(`/api/v1/atp/releases/${selectedReleaseId}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(await readApiError(response));
|
||||
}
|
||||
return (await response.json()) as AtpAssetReleaseDetail;
|
||||
},
|
||||
});
|
||||
|
||||
const filesQuery = useQuery({
|
||||
queryKey: ["atp-release-files", selectedReleaseId],
|
||||
enabled: Boolean(user && canRead && selectedReleaseId),
|
||||
queryFn: async () => {
|
||||
const response = await fetchWithAuth(`/api/v1/atp/releases/${selectedReleaseId}/files`);
|
||||
if (!response.ok) {
|
||||
throw new Error(await readApiError(response));
|
||||
}
|
||||
return (await response.json()) as AtpAssetFileListResponse;
|
||||
},
|
||||
});
|
||||
|
||||
const runsQuery = useQuery({
|
||||
queryKey: ["atp-release-runs", selectedReleaseIdState],
|
||||
enabled: Boolean(user && canRead && selectedReleaseIdState),
|
||||
queryFn: async () => {
|
||||
const response = await fetchWithAuth(`/api/v1/atp/releases/${selectedReleaseIdState}/runs`);
|
||||
if (!response.ok) {
|
||||
throw new Error(await readApiError(response));
|
||||
}
|
||||
return (await response.json()) as AtpAssetRunListResponse;
|
||||
},
|
||||
});
|
||||
|
||||
const saveReleaseMutation = useMutation({
|
||||
mutationFn: async (values: ReleaseFormValues) => {
|
||||
const payload = buildReleasePayload(values);
|
||||
const response = editingRelease
|
||||
? await fetchWithAuth(`/api/v1/atp/releases/${editingRelease.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
: await fetchWithAuth(`/api/v1/atp/assets/${assetId}/releases`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(await readApiError(response));
|
||||
}
|
||||
return (await response.json()) as AtpAssetReleaseDetail;
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["atp-asset-detail", assetId] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["atp-asset-releases", assetId] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["atp-release-detail", result.id] });
|
||||
setSelectedReleaseIdState(result.id);
|
||||
setReleaseModalOpen(false);
|
||||
setEditingRelease(null);
|
||||
releaseForm.resetFields();
|
||||
message.success(editingRelease ? "Release 已更新" : "Release 已创建");
|
||||
},
|
||||
onError: (candidate) => {
|
||||
message.error(candidate instanceof Error ? candidate.message : "保存 release 失败");
|
||||
},
|
||||
});
|
||||
|
||||
const activateMutation = useMutation({
|
||||
mutationFn: async (releaseId: string) => {
|
||||
const response = await fetchWithAuth(`/api/v1/atp/releases/${releaseId}/activate`, { method: "POST" });
|
||||
if (!response.ok) {
|
||||
throw new Error(await readApiError(response));
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["atp-asset-detail", assetId] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["atp-asset-releases", assetId] });
|
||||
message.success("已切换当前激活 release");
|
||||
},
|
||||
onError: (candidate) => {
|
||||
message.error(candidate instanceof Error ? candidate.message : "激活 release 失败");
|
||||
},
|
||||
});
|
||||
|
||||
const runMutation = useMutation({
|
||||
mutationFn: async (values: RunFormValues) => {
|
||||
const response = await fetchWithAuth(`/api/v1/atp/releases/${selectedReleaseId}/runs`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(buildRunPayload(values)),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(await readApiError(response));
|
||||
}
|
||||
return (await response.json()) as AtpAssetRunDetail;
|
||||
},
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["atp-release-runs", selectedReleaseId] });
|
||||
setRunModalOpen(false);
|
||||
runForm.resetFields();
|
||||
message.success("运行任务已提交");
|
||||
},
|
||||
onError: (candidate) => {
|
||||
message.error(candidate instanceof Error ? candidate.message : "提交运行任务失败");
|
||||
},
|
||||
});
|
||||
const releaseDetail = releaseDetailQuery.data ?? null;
|
||||
|
||||
const releaseColumns = useMemo<ColumnsType<AtpAssetReleaseSummary>>(
|
||||
() => [
|
||||
{
|
||||
title: "Release",
|
||||
key: "release",
|
||||
render: (_, item) => (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Typography.Text strong>{item.release_tag || `r${item.release_no}`}</Typography.Text>
|
||||
<Typography.Text type="secondary">
|
||||
{item.runner_kind} / {item.storage_mount_code}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "维度",
|
||||
key: "dimensions",
|
||||
render: (_, item) => (
|
||||
<Space size={[4, 4]} wrap>
|
||||
<Tag>{item.voltage_level}</Tag>
|
||||
<Tag>{item.tower_type}</Tag>
|
||||
<Tag>{item.scene_type}</Tag>
|
||||
{item.scenario_code ? <Tag color="blue">{item.scenario_code}</Tag> : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "存储根",
|
||||
dataIndex: "storage_root_path",
|
||||
render: (value: string) => <Typography.Text code>{value}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: "状态",
|
||||
key: "status",
|
||||
render: (_, item) => (
|
||||
<Space wrap>
|
||||
<Tag color={statusColor(item.status)}>{item.status}</Tag>
|
||||
{item.is_active ? <Tag color="green">active</Tag> : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "actions",
|
||||
render: (_, item) => (
|
||||
<Space wrap>
|
||||
<Button size="small" type={item.id === selectedReleaseId ? "primary" : "default"} onClick={() => setSelectedReleaseIdState(item.id)}>
|
||||
查看
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
disabled={!canManage}
|
||||
onClick={() => {
|
||||
setEditingRelease(item);
|
||||
releaseForm.setFieldsValue(toReleaseFormValues(item));
|
||||
setReleaseModalOpen(true);
|
||||
}}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button size="small" disabled={!canManage || item.is_active} onClick={() => void activateMutation.mutateAsync(item.id)}>
|
||||
激活
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
],
|
||||
[activateMutation, canManage, releaseForm, selectedReleaseId],
|
||||
);
|
||||
|
||||
const fileColumns = useMemo<ColumnsType<AtpAssetFileEntry>>(
|
||||
() => [
|
||||
{
|
||||
title: "路径",
|
||||
dataIndex: "relative_path",
|
||||
render: (value: string) => <Typography.Text code>{value}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: "角色",
|
||||
dataIndex: "file_role",
|
||||
render: (value: string | null) => (value ? <Tag>{value}</Tag> : "-"),
|
||||
},
|
||||
{
|
||||
title: "大小",
|
||||
dataIndex: "size",
|
||||
render: (value: number, item) => (item.is_dir ? "-" : `${value} B`),
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const runColumns = useMemo<ColumnsType<AtpAssetRunSummary>>(
|
||||
() => [
|
||||
{
|
||||
title: "状态",
|
||||
key: "status",
|
||||
render: (_, item) => (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Tag color={statusColor(item.status)}>{item.status}</Tag>
|
||||
<Typography.Text type="secondary">{formatDateTime(item.create_date)}</Typography.Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "执行信息",
|
||||
key: "execution",
|
||||
render: (_, item) => (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Typography.Text>{item.runner_kind} / {item.engine_mode}</Typography.Text>
|
||||
<Typography.Text type="secondary">
|
||||
{item.timeout_seconds}s / exit {item.exit_code ?? "-"}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "日志尺寸",
|
||||
key: "logs",
|
||||
render: (_, item) => `${item.stdout_size} / ${item.stderr_size} B`,
|
||||
},
|
||||
{
|
||||
title: "错误",
|
||||
dataIndex: "error_message",
|
||||
render: (value: string | null) => value || "-",
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
if (initializing) {
|
||||
return <AdminPageLoading tip="加载 ATP 资料包详情中..." minHeightClassName="min-h-[280px]" />;
|
||||
}
|
||||
|
||||
if (!user || !canRead) {
|
||||
return (
|
||||
<Card title="ATP 资料包详情">
|
||||
<Typography.Text type="secondary">
|
||||
{!user ? "请先登录后再查看 ATP 资料包详情。" : "当前账号无 ATP 模块权限(需要 atp.read/atp.run/atp.manage)。"}
|
||||
</Typography.Text>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (assetQuery.isLoading) {
|
||||
return <AdminPageLoading tip="加载 ATP 资料包详情中..." minHeightClassName="min-h-[280px]" />;
|
||||
}
|
||||
|
||||
if (assetQuery.error instanceof Error) {
|
||||
return (
|
||||
<Card title="ATP 资料包详情">
|
||||
<Alert type="error" showIcon message="资料包详情加载失败" description={assetQuery.error.message} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const asset = assetQuery.data;
|
||||
if (!asset) {
|
||||
return (
|
||||
<Card title="ATP 资料包详情">
|
||||
<Empty description="未找到对应资料包" />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size={16} style={{ width: "100%" }}>
|
||||
<Card
|
||||
title={asset.name}
|
||||
extra={
|
||||
<Space wrap>
|
||||
<Link href="/admin/atp-models">
|
||||
<Button>返回列表</Button>
|
||||
</Link>
|
||||
<Link href="/admin/power-lines/atp-viewer">
|
||||
<Button>Legacy 文本工具</Button>
|
||||
</Link>
|
||||
<Button
|
||||
type="primary"
|
||||
disabled={!canManage}
|
||||
onClick={() => {
|
||||
setEditingRelease(null);
|
||||
releaseForm.setFieldsValue({
|
||||
...EMPTY_RELEASE_FORM,
|
||||
voltage_level: asset.voltage_level ?? "",
|
||||
tower_type: asset.tower_type ?? "",
|
||||
scene_type: asset.scene_type ?? "",
|
||||
});
|
||||
setReleaseModalOpen(true);
|
||||
}}
|
||||
>
|
||||
新建 Release
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Space direction="vertical" size={16} style={{ width: "100%" }}>
|
||||
<Alert
|
||||
type={engineQuery.data?.available ? "success" : "warning"}
|
||||
showIcon
|
||||
message={engineQuery.data?.available ? "运行环境可用" : "运行环境待检查"}
|
||||
description={
|
||||
engineQuery.data
|
||||
? `模式:${engineQuery.data.mode},执行器:${engineQuery.data.resolved_executable || engineQuery.data.executable_path}。`
|
||||
: engineQuery.error instanceof Error
|
||||
? engineQuery.error.message
|
||||
: "目录化 release 会在运行前物化到本地 wine 允许运行根目录。"
|
||||
}
|
||||
/>
|
||||
|
||||
<Descriptions column={2} bordered size="small">
|
||||
<Descriptions.Item label="编码">{asset.code}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">
|
||||
<Tag color={statusColor(asset.status)}>{asset.status}</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="电压等级">{asset.voltage_level || "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label="塔型">{asset.tower_type || "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label="场景">{asset.scene_type || "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label="当前激活发布">
|
||||
{asset.active_release_tag || (asset.active_release_no ? `r${asset.active_release_no}` : "-")}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="说明" span={2}>
|
||||
{asset.description || "-"}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card title="Release 列表">
|
||||
{releasesQuery.error instanceof Error ? (
|
||||
<Alert type="error" showIcon message="Release 列表加载失败" description={releasesQuery.error.message} />
|
||||
) : (
|
||||
<Table<AtpAssetReleaseSummary>
|
||||
rowKey="id"
|
||||
loading={releasesQuery.isLoading}
|
||||
columns={releaseColumns}
|
||||
dataSource={releases}
|
||||
locale={{ emptyText: "暂无 release" }}
|
||||
pagination={false}
|
||||
scroll={{ x: 1080 }}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title={selectedRelease ? `当前 Release:${selectedRelease.release_tag || `r${selectedRelease.release_no}`}` : "当前 Release"}
|
||||
extra={
|
||||
<Space wrap>
|
||||
<Button disabled={!selectedReleaseId || !canRun} onClick={() => {
|
||||
runForm.setFieldsValue(EMPTY_RUN_FORM);
|
||||
setRunModalOpen(true);
|
||||
}}>
|
||||
运行 / Dry Run
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
{!selectedRelease ? (
|
||||
<Empty description="请选择一个 release" />
|
||||
) : (
|
||||
<Space direction="vertical" size={16} style={{ width: "100%" }}>
|
||||
{releaseDetailQuery.error instanceof Error ? (
|
||||
<Alert type="error" showIcon message="Release 详情加载失败" description={releaseDetailQuery.error.message} />
|
||||
) : null}
|
||||
|
||||
{releaseDetail ? (
|
||||
<>
|
||||
<Descriptions column={2} bordered size="small">
|
||||
<Descriptions.Item label="Runner">{releaseDetail.runner_kind}</Descriptions.Item>
|
||||
<Descriptions.Item label="Storage Mount">{releaseDetail.storage_mount_code}</Descriptions.Item>
|
||||
<Descriptions.Item label="Storage Root" span={2}>
|
||||
<Typography.Text code>{releaseDetail.storage_root_path}</Typography.Text>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Entry File">{releaseDetail.entry_file || "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label="Result File">{releaseDetail.result_file || "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label="EGM Subdir">{releaseDetail.egm_subdir || "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label="EGM Result">{releaseDetail.egm_result_file || "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label="Preprocess">{releaseDetail.preprocess_script || "-"}</Descriptions.Item>
|
||||
<Descriptions.Item label="Postprocess">{releaseDetail.postprocess_script || "-"}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
<Space direction="vertical" size={8} style={{ width: "100%" }}>
|
||||
<Typography.Text strong>Manifest</Typography.Text>
|
||||
<pre style={{ margin: 0, whiteSpace: "pre-wrap", wordBreak: "break-word" }}>
|
||||
{JSON.stringify(releaseDetail.manifest_json, null, 2)}
|
||||
</pre>
|
||||
<Typography.Text strong>Validation</Typography.Text>
|
||||
<pre style={{ margin: 0, whiteSpace: "pre-wrap", wordBreak: "break-word" }}>
|
||||
{JSON.stringify(releaseDetail.validation_json, null, 2)}
|
||||
</pre>
|
||||
</Space>
|
||||
</>
|
||||
) : null}
|
||||
</Space>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card title="目录文件清单">
|
||||
{filesQuery.error instanceof Error ? (
|
||||
<Alert type="error" showIcon message="文件清单加载失败" description={filesQuery.error.message} />
|
||||
) : (
|
||||
<Table<AtpAssetFileEntry>
|
||||
rowKey="relative_path"
|
||||
loading={filesQuery.isLoading}
|
||||
columns={fileColumns}
|
||||
dataSource={filesQuery.data?.items ?? []}
|
||||
locale={{ emptyText: selectedReleaseId ? "当前 release 暂无文件" : "请先选择 release" }}
|
||||
pagination={false}
|
||||
scroll={{ x: 980, y: 320 }}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card title="运行记录">
|
||||
{runsQuery.error instanceof Error ? (
|
||||
<Alert type="error" showIcon message="运行记录加载失败" description={runsQuery.error.message} />
|
||||
) : (
|
||||
<Table<AtpAssetRunSummary>
|
||||
rowKey="id"
|
||||
loading={runsQuery.isLoading}
|
||||
columns={runColumns}
|
||||
dataSource={runsQuery.data?.items ?? []}
|
||||
locale={{ emptyText: selectedReleaseId ? "当前 release 暂无运行记录" : "请先选择 release" }}
|
||||
pagination={false}
|
||||
scroll={{ x: 980 }}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title={editingRelease ? "编辑 Release" : "新建 Release"}
|
||||
open={releaseModalOpen}
|
||||
onCancel={() => {
|
||||
setReleaseModalOpen(false);
|
||||
setEditingRelease(null);
|
||||
releaseForm.resetFields();
|
||||
}}
|
||||
onOk={() => void releaseForm.submit()}
|
||||
confirmLoading={saveReleaseMutation.isPending}
|
||||
destroyOnClose
|
||||
width={760}
|
||||
>
|
||||
<Form<ReleaseFormValues>
|
||||
form={releaseForm}
|
||||
layout="vertical"
|
||||
initialValues={EMPTY_RELEASE_FORM}
|
||||
onFinish={(values) => void saveReleaseMutation.mutateAsync(values)}
|
||||
>
|
||||
<Form.Item name="release_tag" label="Release 标签">
|
||||
<Input placeholder="如 r1 / 220-raoji3" />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态" rules={[{ required: true, message: "请选择状态" }]}>
|
||||
<Select
|
||||
options={[
|
||||
{ value: "draft", label: "draft" },
|
||||
{ value: "released", label: "released" },
|
||||
{ value: "archived", label: "archived" },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="voltage_level" label="电压等级" rules={[{ required: true, message: "请输入电压等级" }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="tower_type" label="塔型" rules={[{ required: true, message: "请输入塔型" }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="scene_type" label="场景" rules={[{ required: true, message: "请输入场景" }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="scenario_code" label="工况编码">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="runner_kind" label="Runner" rules={[{ required: true, message: "请选择 runner" }]}>
|
||||
<Select
|
||||
options={[
|
||||
{ value: "atp", label: "ATP" },
|
||||
{ value: "egm", label: "EGM" },
|
||||
{ value: "hybrid", label: "HYBRID" },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="storage_mount_code" label="Storage Mount" rules={[{ required: true, message: "请输入 mount code" }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="storage_root_path" label="Storage Root" rules={[{ required: true, message: "请输入目录根路径" }]}>
|
||||
<Input placeholder="/atp-library/releases/demo/r1" />
|
||||
</Form.Item>
|
||||
<Form.Item name="entry_file" label="入口文件">
|
||||
<Input placeholder="留空则自动探测 work.atp / 唯一 .atp" />
|
||||
</Form.Item>
|
||||
<Form.Item name="result_file" label="结果文件">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="egm_subdir" label="EGM 子目录">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="egm_result_file" label="EGM 结果文件">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="preprocess_script" label="预处理脚本">
|
||||
<Input placeholder="仅支持 .py,相对 release 根目录" />
|
||||
</Form.Item>
|
||||
<Form.Item name="postprocess_script" label="后处理脚本">
|
||||
<Input placeholder="仅支持 .py,相对 release 根目录" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="运行 Release"
|
||||
open={runModalOpen}
|
||||
onCancel={() => {
|
||||
setRunModalOpen(false);
|
||||
runForm.resetFields();
|
||||
}}
|
||||
onOk={() => void runForm.submit()}
|
||||
confirmLoading={runMutation.isPending}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form<RunFormValues>
|
||||
form={runForm}
|
||||
layout="vertical"
|
||||
initialValues={EMPTY_RUN_FORM}
|
||||
onFinish={(values) => void runMutation.mutateAsync(values)}
|
||||
>
|
||||
<Form.Item name="dry_run" label="Dry Run">
|
||||
<Select
|
||||
options={[
|
||||
{ value: true, label: "是" },
|
||||
{ value: false, label: "否" },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="timeout_seconds" label="超时时间(秒)">
|
||||
<InputNumber min={1} style={{ width: "100%" }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="extra_args_text" label="附加参数">
|
||||
<Input placeholder="多个参数用空格分隔" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,424 @@
|
||||
import PowerLinesAtpViewerPage from "../power-lines/atp-viewer/page";
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Alert,
|
||||
App,
|
||||
Button,
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
} from "antd";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { AdminPageLoading } from "@/components/admin-page-loading";
|
||||
import { useAuth } from "@/components/auth-provider";
|
||||
import { Card } from "@/components/ui-antd";
|
||||
import { readApiError } from "@/lib/api";
|
||||
import type { AtpAssetListResponse, AtpAssetSummary, AtpEngineStatusResponse } from "@/types/auth";
|
||||
|
||||
type AssetFormValues = {
|
||||
code: string;
|
||||
name: string;
|
||||
description: string;
|
||||
status: "draft" | "enabled" | "disabled" | "archived";
|
||||
voltage_level: string;
|
||||
tower_type: string;
|
||||
scene_type: string;
|
||||
tags_text: string;
|
||||
};
|
||||
|
||||
const EMPTY_FORM: AssetFormValues = {
|
||||
code: "",
|
||||
name: "",
|
||||
description: "",
|
||||
status: "enabled",
|
||||
voltage_level: "",
|
||||
tower_type: "",
|
||||
scene_type: "",
|
||||
tags_text: "",
|
||||
};
|
||||
|
||||
function formatDateTime(value: string | null | undefined): string {
|
||||
if (!value) {
|
||||
return "-";
|
||||
}
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value;
|
||||
}
|
||||
return date.toLocaleString("zh-CN", { hour12: false });
|
||||
}
|
||||
|
||||
function statusColor(value: string): string {
|
||||
if (value === "enabled" || value === "released") return "green";
|
||||
if (value === "draft") return "gold";
|
||||
if (value === "disabled") return "default";
|
||||
if (value === "archived") return "red";
|
||||
return "blue";
|
||||
}
|
||||
|
||||
function toFormValues(item: AtpAssetSummary): AssetFormValues {
|
||||
return {
|
||||
code: item.code,
|
||||
name: item.name,
|
||||
description: item.description,
|
||||
status: item.status,
|
||||
voltage_level: item.voltage_level ?? "",
|
||||
tower_type: item.tower_type ?? "",
|
||||
scene_type: item.scene_type ?? "",
|
||||
tags_text: item.tags_json.join(", "),
|
||||
};
|
||||
}
|
||||
|
||||
function buildPayload(values: AssetFormValues) {
|
||||
return {
|
||||
code: values.code.trim(),
|
||||
name: values.name.trim(),
|
||||
description: values.description.trim(),
|
||||
status: values.status,
|
||||
voltage_level: values.voltage_level.trim() || null,
|
||||
tower_type: values.tower_type.trim() || null,
|
||||
scene_type: values.scene_type.trim() || null,
|
||||
tags_json: values.tags_text
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
export default function AtpModelsPage() {
|
||||
return <PowerLinesAtpViewerPage />;
|
||||
const { message } = App.useApp();
|
||||
const { user, initializing, fetchWithAuth, hasPermission } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const [form] = Form.useForm<AssetFormValues>();
|
||||
|
||||
const [keywordInput, setKeywordInput] = useState("");
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<string | undefined>(undefined);
|
||||
const [editingAsset, setEditingAsset] = useState<AtpAssetSummary | null>(null);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
|
||||
const canRead = hasPermission("atp.read") || hasPermission("atp.run") || hasPermission("atp.manage");
|
||||
const canManage = hasPermission("atp.manage");
|
||||
|
||||
const assetsQuery = useQuery({
|
||||
queryKey: ["atp-assets", keyword, statusFilter],
|
||||
enabled: Boolean(user && canRead),
|
||||
queryFn: async () => {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (keyword.trim()) {
|
||||
searchParams.set("keyword", keyword.trim());
|
||||
}
|
||||
if (statusFilter) {
|
||||
searchParams.set("status", statusFilter);
|
||||
}
|
||||
const suffix = searchParams.toString();
|
||||
const response = await fetchWithAuth(`/api/v1/atp/assets${suffix ? `?${suffix}` : ""}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(await readApiError(response));
|
||||
}
|
||||
return (await response.json()) as AtpAssetListResponse;
|
||||
},
|
||||
});
|
||||
|
||||
const engineQuery = useQuery({
|
||||
queryKey: ["atp-asset-engine-status"],
|
||||
enabled: Boolean(user && canRead),
|
||||
queryFn: async () => {
|
||||
const response = await fetchWithAuth("/api/v1/atp/engine/status");
|
||||
if (!response.ok) {
|
||||
throw new Error(await readApiError(response));
|
||||
}
|
||||
return (await response.json()) as AtpEngineStatusResponse;
|
||||
},
|
||||
});
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async (values: AssetFormValues) => {
|
||||
const payload = buildPayload(values);
|
||||
const response = editingAsset
|
||||
? await fetchWithAuth(`/api/v1/atp/assets/${editingAsset.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
: await fetchWithAuth("/api/v1/atp/assets", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(await readApiError(response));
|
||||
}
|
||||
return await response.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["atp-assets"] });
|
||||
message.success(editingAsset ? "资料包已更新" : "资料包已创建");
|
||||
setModalOpen(false);
|
||||
setEditingAsset(null);
|
||||
form.resetFields();
|
||||
},
|
||||
onError: (candidate) => {
|
||||
message.error(candidate instanceof Error ? candidate.message : "保存资料包失败");
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (assetId: string) => {
|
||||
const response = await fetchWithAuth(`/api/v1/atp/assets/${assetId}`, { method: "DELETE" });
|
||||
if (!response.ok) {
|
||||
throw new Error(await readApiError(response));
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["atp-assets"] });
|
||||
message.success("资料包已删除");
|
||||
},
|
||||
onError: (candidate) => {
|
||||
message.error(candidate instanceof Error ? candidate.message : "删除资料包失败");
|
||||
},
|
||||
});
|
||||
|
||||
const assetItems = assetsQuery.data?.items ?? [];
|
||||
|
||||
const columns = useMemo<ColumnsType<AtpAssetSummary>>(
|
||||
() => [
|
||||
{
|
||||
title: "资料包",
|
||||
key: "asset",
|
||||
render: (_, item) => (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Typography.Text strong>{item.name}</Typography.Text>
|
||||
<Typography.Text type="secondary" code>
|
||||
{item.code}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "业务维度",
|
||||
key: "dimensions",
|
||||
render: (_, item) => (
|
||||
<Space size={[4, 4]} wrap>
|
||||
<Tag>{item.voltage_level || "未标注电压"}</Tag>
|
||||
<Tag>{item.tower_type || "未标注塔型"}</Tag>
|
||||
<Tag>{item.scene_type || "未标注场景"}</Tag>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "当前发布",
|
||||
key: "release",
|
||||
render: (_, item) => (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Typography.Text>{item.active_release_tag || (item.active_release_no ? `r${item.active_release_no}` : "-")}</Typography.Text>
|
||||
<Typography.Text type="secondary">
|
||||
{item.release_count} 个 release / {item.run_count} 次运行
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "状态",
|
||||
dataIndex: "status",
|
||||
render: (value: string) => <Tag color={statusColor(value)}>{value}</Tag>,
|
||||
},
|
||||
{
|
||||
title: "更新时间",
|
||||
dataIndex: "update_date",
|
||||
render: (value: string) => formatDateTime(value),
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "actions",
|
||||
render: (_, item) => (
|
||||
<Space wrap>
|
||||
<Link href={`/admin/atp-models/${item.id}`}>
|
||||
<Button size="small" type="primary">
|
||||
详情
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
size="small"
|
||||
disabled={!canManage}
|
||||
onClick={() => {
|
||||
setEditingAsset(item);
|
||||
form.setFieldsValue(toFormValues(item));
|
||||
setModalOpen(true);
|
||||
}}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="删除资料包"
|
||||
description="这会同时删除其 release 与运行记录。"
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
onConfirm={() => void deleteMutation.mutateAsync(item.id)}
|
||||
disabled={!canManage}
|
||||
>
|
||||
<Button size="small" danger disabled={!canManage}>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
],
|
||||
[canManage, deleteMutation, form],
|
||||
);
|
||||
|
||||
if (initializing) {
|
||||
return <AdminPageLoading tip="加载 ATP 资料包中..." minHeightClassName="min-h-[280px]" />;
|
||||
}
|
||||
|
||||
if (!user || !canRead) {
|
||||
return (
|
||||
<Card title="ATP 资料包管理">
|
||||
<Typography.Text type="secondary">
|
||||
{!user ? "请先登录后再查看 ATP 资料包管理。" : "当前账号无 ATP 模块权限(需要 atp.read/atp.run/atp.manage)。"}
|
||||
</Typography.Text>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size={16} style={{ width: "100%" }}>
|
||||
<Card
|
||||
title="ATP 资料包管理"
|
||||
extra={
|
||||
<Space wrap>
|
||||
<Link href="/admin/power-lines/atp-viewer">
|
||||
<Button>Legacy 文本工具</Button>
|
||||
</Link>
|
||||
<Button
|
||||
type="primary"
|
||||
disabled={!canManage}
|
||||
onClick={() => {
|
||||
setEditingAsset(null);
|
||||
form.setFieldsValue(EMPTY_FORM);
|
||||
setModalOpen(true);
|
||||
}}
|
||||
>
|
||||
新建资料包
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Space direction="vertical" size={16} style={{ width: "100%" }}>
|
||||
<Alert
|
||||
type={engineQuery.data?.available ? "success" : "warning"}
|
||||
showIcon
|
||||
message={engineQuery.data?.available ? "ATP/Wine 执行环境可用" : "ATP/Wine 执行环境待检查"}
|
||||
description={
|
||||
engineQuery.data
|
||||
? `模式:${engineQuery.data.mode},执行器:${engineQuery.data.resolved_executable || engineQuery.data.executable_path}。`
|
||||
: engineQuery.error instanceof Error
|
||||
? engineQuery.error.message
|
||||
: "目录化 release 会在运行前物化到 wine 允许运行根目录。"
|
||||
}
|
||||
/>
|
||||
<Space wrap>
|
||||
<Input.Search
|
||||
allowClear
|
||||
value={keywordInput}
|
||||
onChange={(event) => setKeywordInput(event.target.value)}
|
||||
onSearch={(value) => setKeyword(value)}
|
||||
placeholder="按编码 / 名称 / 描述搜索"
|
||||
style={{ width: 280 }}
|
||||
/>
|
||||
<Select
|
||||
allowClear
|
||||
value={statusFilter}
|
||||
placeholder="状态筛选"
|
||||
style={{ width: 180 }}
|
||||
onChange={(value) => setStatusFilter(value)}
|
||||
options={[
|
||||
{ value: "draft", label: "draft" },
|
||||
{ value: "enabled", label: "enabled" },
|
||||
{ value: "disabled", label: "disabled" },
|
||||
{ value: "archived", label: "archived" },
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
|
||||
{assetsQuery.error instanceof Error ? (
|
||||
<Alert type="error" showIcon message="ATP 资料包加载失败" description={assetsQuery.error.message} />
|
||||
) : null}
|
||||
|
||||
<Table<AtpAssetSummary>
|
||||
rowKey="id"
|
||||
loading={assetsQuery.isLoading}
|
||||
columns={columns}
|
||||
dataSource={assetItems}
|
||||
locale={{ emptyText: "暂无 ATP 资料包" }}
|
||||
pagination={false}
|
||||
scroll={{ x: 1080 }}
|
||||
/>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title={editingAsset ? "编辑 ATP 资料包" : "新建 ATP 资料包"}
|
||||
open={modalOpen}
|
||||
onCancel={() => {
|
||||
setModalOpen(false);
|
||||
setEditingAsset(null);
|
||||
form.resetFields();
|
||||
}}
|
||||
onOk={() => void form.submit()}
|
||||
confirmLoading={saveMutation.isPending}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form<AssetFormValues>
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={EMPTY_FORM}
|
||||
onFinish={(values) => void saveMutation.mutateAsync(values)}
|
||||
>
|
||||
<Form.Item name="code" label="编码" rules={[{ required: true, message: "请输入资料包编码" }]}>
|
||||
<Input disabled={Boolean(editingAsset)} />
|
||||
</Form.Item>
|
||||
<Form.Item name="name" label="名称" rules={[{ required: true, message: "请输入资料包名称" }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="描述">
|
||||
<Input.TextArea rows={3} />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态" rules={[{ required: true, message: "请选择状态" }]}>
|
||||
<Select
|
||||
options={[
|
||||
{ value: "draft", label: "draft" },
|
||||
{ value: "enabled", label: "enabled" },
|
||||
{ value: "disabled", label: "disabled" },
|
||||
{ value: "archived", label: "archived" },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="voltage_level" label="电压等级">
|
||||
<Input placeholder="如 220 / 500 / 1000" />
|
||||
</Form.Item>
|
||||
<Form.Item name="tower_type" label="塔型">
|
||||
<Input placeholder="如 sihuita / ganzi" />
|
||||
</Form.Item>
|
||||
<Form.Item name="scene_type" label="场景">
|
||||
<Input placeholder="如 fanji / raoji3" />
|
||||
</Form.Item>
|
||||
<Form.Item name="tags_text" label="标签">
|
||||
<Input placeholder="多个标签用逗号分隔" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const TASK_NAME_LABELS: Record<string, string> = {
|
||||
"app.tasks.atp_asset_tasks.execute_atp_asset_run_job": "ATP 资料包运行",
|
||||
"app.tasks.atp_model_tasks.execute_atp_model_run_job": "ATP 模型仿真",
|
||||
"app.tasks.elevation_tasks.analyze_elevation_dataset_job": "高程数据集分析",
|
||||
"app.tasks.elevation_tasks.import_elevation_dataset_data_job": "高程数据导入",
|
||||
|
||||
@@ -819,6 +819,137 @@ export type AtpEngineStatusResponse = {
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
export type AtpAssetStatus = "draft" | "enabled" | "disabled" | "archived";
|
||||
export type AtpAssetReleaseStatus = "draft" | "released" | "archived";
|
||||
export type AtpAssetRunnerKind = "atp" | "egm" | "hybrid";
|
||||
export type AtpAssetRunStatus = "pending" | "running" | "success" | "failed";
|
||||
|
||||
export type AtpAssetSummary = {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
description: string;
|
||||
status: AtpAssetStatus;
|
||||
voltage_level: string | null;
|
||||
tower_type: string | null;
|
||||
scene_type: string | null;
|
||||
tags_json: string[];
|
||||
latest_release_no: number;
|
||||
active_release_no: number | null;
|
||||
active_release_id: string | null;
|
||||
active_release_tag: string | null;
|
||||
release_count: number;
|
||||
run_count: number;
|
||||
last_run_status: AtpAssetRunStatus | null;
|
||||
last_run_date: string | null;
|
||||
create_date: string;
|
||||
create_user: string | null;
|
||||
update_date: string;
|
||||
update_user: string | null;
|
||||
};
|
||||
|
||||
export type AtpAssetListResponse = {
|
||||
items: AtpAssetSummary[];
|
||||
total: number;
|
||||
};
|
||||
|
||||
export type AtpAssetReleaseSummary = {
|
||||
id: string;
|
||||
asset_id: string;
|
||||
asset_code: string;
|
||||
asset_name: string;
|
||||
release_no: number;
|
||||
release_tag: string | null;
|
||||
status: AtpAssetReleaseStatus;
|
||||
voltage_level: string;
|
||||
tower_type: string;
|
||||
scene_type: string;
|
||||
scenario_code: string | null;
|
||||
runner_kind: AtpAssetRunnerKind;
|
||||
storage_mount_code: string;
|
||||
storage_root_path: string;
|
||||
entry_file: string | null;
|
||||
result_file: string | null;
|
||||
egm_subdir: string | null;
|
||||
egm_result_file: string | null;
|
||||
preprocess_script: string | null;
|
||||
postprocess_script: string | null;
|
||||
content_hash: string;
|
||||
is_active: boolean;
|
||||
create_date: string;
|
||||
create_user: string | null;
|
||||
update_date: string;
|
||||
update_user: string | null;
|
||||
};
|
||||
|
||||
export type AtpAssetReleaseDetail = AtpAssetReleaseSummary & {
|
||||
manifest_json: Record<string, unknown>;
|
||||
validation_json: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type AtpAssetReleaseListResponse = {
|
||||
items: AtpAssetReleaseSummary[];
|
||||
total: number;
|
||||
};
|
||||
|
||||
export type AtpAssetFileEntry = {
|
||||
relative_path: string;
|
||||
name: string;
|
||||
is_dir: boolean;
|
||||
size: number;
|
||||
mime_type: string | null;
|
||||
file_role: string | null;
|
||||
};
|
||||
|
||||
export type AtpAssetFileListResponse = {
|
||||
release_id: string;
|
||||
storage_mount_code: string;
|
||||
storage_root_path: string;
|
||||
items: AtpAssetFileEntry[];
|
||||
total: number;
|
||||
};
|
||||
|
||||
export type AtpAssetRunSummary = {
|
||||
id: string;
|
||||
asset_id: string;
|
||||
asset_code: string;
|
||||
asset_name: string;
|
||||
release_id: string;
|
||||
release_no: number;
|
||||
release_tag: string | null;
|
||||
status: AtpAssetRunStatus;
|
||||
engine_mode: AtpEngineMode;
|
||||
runner_kind: AtpAssetRunnerKind;
|
||||
task_id: string | null;
|
||||
storage_mount_code: string | null;
|
||||
storage_root_path: string | null;
|
||||
materialized_root_path: string | null;
|
||||
engine_command: string | null;
|
||||
working_dir: string | null;
|
||||
timeout_seconds: number;
|
||||
exit_code: number | null;
|
||||
started_at: string | null;
|
||||
finished_at: string | null;
|
||||
duration_ms: number | null;
|
||||
error_message: string | null;
|
||||
stdout_size: number;
|
||||
stderr_size: number;
|
||||
create_date: string;
|
||||
create_user: string | null;
|
||||
};
|
||||
|
||||
export type AtpAssetRunDetail = AtpAssetRunSummary & {
|
||||
stdout_text: string | null;
|
||||
stderr_text: string | null;
|
||||
output_manifest_json: Record<string, unknown>;
|
||||
result_summary_json: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type AtpAssetRunListResponse = {
|
||||
items: AtpAssetRunSummary[];
|
||||
total: number;
|
||||
};
|
||||
|
||||
export type LightningPolarity = "positive" | "negative" | "mixed" | "unknown";
|
||||
|
||||
export type LightningCurrentEventSummary = {
|
||||
|
||||
Reference in New Issue
Block a user