Files
Nexus/server/api/assets.py
T
Your Name fd9b7d85b5 fix: 批量安装按钮检测 + 审计日志中文化 + terminal断开反馈
1. updateBatchBar() hasAgent 判断从 agent_api_key_set 改为 agent_version
2. 所有后端 AuditLog detail 字段统一中文
3. audit.html 操作/目标类型中文映射
4. terminal.html 断开覆盖层 + 重新连接按钮
5. Agent os_release 上报 + 前端系统版本拆分

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 02:17:35 +08:00

358 lines
12 KiB
Python

"""Nexus — Platform & Node API Routes (Asset organization layer)
All operations require JWT authentication.
"""
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from server.api.dependencies import get_db
from server.api.auth_jwt import get_current_admin
from server.api.schemas import PlatformCreate, PlatformUpdate, NodeCreate, NodeUpdate
from server.infrastructure.database.platform_node_repo import PlatformRepositoryImpl, NodeRepositoryImpl
from server.infrastructure.database.audit_log_repo import AuditLogRepositoryImpl
from server.domain.models import Platform, Node, Admin, AuditLog
from sqlalchemy.ext.asyncio import AsyncSession
router = APIRouter(prefix="/api/assets", tags=["assets"])
# ── Platforms ──
@router.get("/platforms", response_model=list)
async def list_platforms(
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""List all platform templates"""
repo = PlatformRepositoryImpl(db)
platforms = await repo.get_all()
return [_platform_to_dict(p) for p in platforms]
@router.get("/platforms/{id}", response_model=dict)
async def get_platform(
id: int,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""Get a single platform by ID"""
repo = PlatformRepositoryImpl(db)
platform = await repo.get_by_id(id)
if not platform:
raise HTTPException(status_code=404, detail="Platform not found")
return _platform_to_dict(platform)
@router.post("/platforms", response_model=dict, status_code=201)
async def create_platform(
payload: PlatformCreate,
request: Request,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""Create a new platform template"""
repo = PlatformRepositoryImpl(db)
platform = Platform(**payload.model_dump(exclude_none=True))
created = await repo.create(platform)
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username, action="create_platform",
target_type="platform", target_id=created.id,
detail=f"名称={created.name}", ip_address=request.client.host if request.client else "",
))
return _platform_to_dict(created)
@router.put("/platforms/{id}", response_model=dict)
async def update_platform(
id: int,
payload: PlatformUpdate,
request: Request,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""Update a platform template"""
repo = PlatformRepositoryImpl(db)
platform = await repo.get_by_id(id)
if not platform:
raise HTTPException(status_code=404, detail="Platform not found")
# Explicit allowlist to prevent mass-assignment to sensitive columns
ALLOWED_FIELDS = {"name", "category", "type", "default_protocols", "charset"}
for key, value in payload.model_dump(exclude_unset=True).items():
if key in ALLOWED_FIELDS:
setattr(platform, key, value)
updated = await repo.update(platform)
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username, action="update_platform",
target_type="platform", target_id=id,
detail=f"名称={updated.name}", ip_address=request.client.host if request.client else "",
))
return _platform_to_dict(updated)
@router.delete("/platforms/{id}", status_code=204)
async def delete_platform(
id: int,
request: Request,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""Delete a platform template"""
repo = PlatformRepositoryImpl(db)
platform = await repo.get_by_id(id)
if not platform:
raise HTTPException(status_code=404, detail="Platform not found")
result = await repo.delete(id)
if not result:
raise HTTPException(status_code=404, detail="Platform not found")
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username, action="delete_platform",
target_type="platform", target_id=id,
detail=f"名称={platform.name}", ip_address=request.client.host if request.client else "",
))
# ── Nodes (Tree) ──
@router.get("/nodes", response_model=list)
async def list_nodes(
parent_id: Optional[int] = Query(None, description="Filter by parent node ID"),
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""List nodes — all, or children of a specific parent"""
repo = NodeRepositoryImpl(db)
if parent_id is not None:
nodes = await repo.get_children(parent_id)
else:
nodes = await repo.get_all()
return [_node_to_dict(n) for n in nodes]
@router.get("/nodes/tree", response_model=list)
async def get_node_tree(
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""Get full node tree structure"""
repo = NodeRepositoryImpl(db)
all_nodes = await repo.get_all()
return _build_tree(all_nodes)
@router.post("/nodes", response_model=dict, status_code=201)
async def create_node(
payload: NodeCreate,
request: Request,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""Create a new node"""
repo = NodeRepositoryImpl(db)
node = Node(**payload.model_dump(exclude_none=True))
created = await repo.create(node)
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username, action="create_node",
target_type="node", target_id=created.id,
detail=f"名称={created.name}", ip_address=request.client.host if request.client else "",
))
return _node_to_dict(created)
@router.put("/nodes/{id}", response_model=dict)
async def update_node(
id: int,
payload: NodeUpdate,
request: Request,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""Update a node"""
repo = NodeRepositoryImpl(db)
node = await repo.get_by_id(id)
if not node:
raise HTTPException(status_code=404, detail="Node not found")
# Explicit allowlist to prevent mass-assignment to sensitive columns
ALLOWED_FIELDS = {"name", "parent_id", "sort_order"}
for key, value in payload.model_dump(exclude_unset=True).items():
if key in ALLOWED_FIELDS:
setattr(node, key, value)
updated = await repo.update(node)
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username, action="update_node",
target_type="node", target_id=id,
detail=f"名称={updated.name}", ip_address=request.client.host if request.client else "",
))
return _node_to_dict(updated)
@router.delete("/nodes/{id}", status_code=204)
async def delete_node(
id: int,
request: Request,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""Delete a node"""
repo = NodeRepositoryImpl(db)
node = await repo.get_by_id(id)
if not node:
raise HTTPException(status_code=404, detail="Node not found")
result = await repo.delete(id)
if not result:
raise HTTPException(status_code=404, detail="Node not found")
audit_repo = AuditLogRepositoryImpl(db)
await audit_repo.create(AuditLog(
admin_username=admin.username, action="delete_node",
target_type="node", target_id=id,
detail=f"名称={node.name}", ip_address=request.client.host if request.client else "",
))
# ── SSH Sessions & Command Logs ──
@router.get("/ssh-sessions", response_model=list)
async def list_ssh_sessions(
server_id: Optional[int] = Query(None, description="Filter by server ID"),
limit: int = Query(50, ge=1, le=200),
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""List SSH sessions with server name"""
from server.infrastructure.database.ssh_session_repo import SshSessionRepositoryImpl
from sqlalchemy import select
from server.domain.models import SshSession, Server
repo = SshSessionRepositoryImpl(db)
if server_id:
sessions = await repo.get_by_server(server_id, limit)
else:
result = await db.execute(select(SshSession).order_by(SshSession.started_at.desc()).limit(limit))
sessions = list(result.scalars().all())
# Batch load server names
server_ids = {s.server_id for s in sessions if s.server_id}
server_names = {}
if server_ids:
srv_result = await db.execute(select(Server.id, Server.name).where(Server.id.in_(server_ids)))
server_names = dict(srv_result.all())
return [_ssh_session_to_dict(s, server_names.get(s.server_id)) for s in sessions]
@router.get("/command-logs", response_model=list)
async def list_command_logs(
server_id: Optional[int] = Query(None, description="Filter by server ID"),
session_id: Optional[str] = Query(None, description="Filter by SSH session ID"),
limit: int = Query(200, ge=1, le=500),
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""List command logs with server name and admin username"""
from server.infrastructure.database.ssh_session_repo import CommandLogRepositoryImpl
from sqlalchemy import select
from server.domain.models import CommandLog, Server, Admin as AdminModel
repo = CommandLogRepositoryImpl(db)
if session_id:
logs = await repo.get_by_session(session_id, limit)
elif server_id:
logs = await repo.get_by_server(server_id, limit)
else:
result = await db.execute(select(CommandLog).order_by(CommandLog.created_at.desc()).limit(limit))
logs = list(result.scalars().all())
# Batch load server names and admin usernames
server_ids = {l.server_id for l in logs if l.server_id}
admin_ids = {l.admin_id for l in logs if l.admin_id}
server_names = {}
if server_ids:
srv_result = await db.execute(select(Server.id, Server.name).where(Server.id.in_(server_ids)))
server_names = dict(srv_result.all())
admin_names = {}
if admin_ids:
adm_result = await db.execute(select(AdminModel.id, AdminModel.username).where(AdminModel.id.in_(admin_ids)))
admin_names = dict(adm_result.all())
return [_command_log_to_dict(l, server_names.get(l.server_id), admin_names.get(l.admin_id)) for l in logs]
# ── Helper functions ──
def _platform_to_dict(platform: Platform) -> dict:
return {
"id": platform.id,
"name": platform.name,
"category": platform.category,
"type": platform.type,
"default_protocols": platform.default_protocols,
"charset": platform.charset,
"created_at": str(platform.created_at) if platform.created_at else None,
}
def _node_to_dict(node: Node) -> dict:
return {
"id": node.id,
"name": node.name,
"parent_id": node.parent_id,
"sort_order": node.sort_order,
"created_at": str(node.created_at) if node.created_at else None,
}
def _build_tree(nodes: list) -> list:
"""Build tree structure from flat node list"""
node_map = {n.id: {**_node_to_dict(n), "children": []} for n in nodes}
tree = []
for n in nodes:
node_dict = node_map[n.id]
if n.parent_id and n.parent_id in node_map:
node_map[n.parent_id]["children"].append(node_dict)
else:
tree.append(node_dict)
return tree
def _ssh_session_to_dict(session, server_name: str = None) -> dict:
return {
"id": session.id,
"server_id": session.server_id,
"server_name": server_name,
"admin_id": session.admin_id,
"remote_addr": session.remote_addr,
"status": session.status,
"started_at": str(session.started_at) if session.started_at else None,
"closed_at": str(session.closed_at) if session.closed_at else None,
}
def _command_log_to_dict(log, server_name: str = None, admin_username: str = None) -> dict:
return {
"id": log.id,
"session_id": log.session_id,
"server_id": log.server_id,
"server_name": server_name,
"admin_id": log.admin_id,
"admin_username": admin_username,
"command": log.command,
"remote_addr": log.remote_addr,
"created_at": str(log.created_at) if log.created_at else None,
}