[fix/feat]:[FL-81][ATP模型管理改造为资产发布制]

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
chengkai3
2026-06-11 22:39:48 +08:00
parent de22a76f70
commit fac37ddb8d
14 changed files with 3543 additions and 3 deletions
+2
View File
@@ -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)
+241
View File
@@ -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)
+1
View File
@@ -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",
+1
View File
@@ -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,
+2 -1
View File
@@ -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",
+156
View File
@@ -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")
+206
View File
@@ -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
+13
View File
@@ -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)
+151
View File
@@ -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()
+775
View File
@@ -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>
);
}
+421 -2
View File
@@ -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
View File
@@ -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": "高程数据导入",
+131
View File
@@ -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 = {