fd9b7d85b5
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>
358 lines
12 KiB
Python
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,
|
|
} |