Files
Nexus/server/api/servers.py
T

301 lines
10 KiB
Python
Raw Normal View History

"""Nexus — Servers API Routes (CRUD + heartbeat + health check + push)
Presentation layer — receives HTTP requests, delegates to ServerService/SyncService.
Live server status comes from Redis (real-time), base info from MySQL.
"""
import json
import logging
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, Query
from server.api.dependencies import get_server_service, get_sync_service, get_db
2026-05-22 08:19:56 +08:00
from server.api.auth_jwt import get_current_admin
from server.api.schemas import ServerCreate, ServerUpdate, ServerPush, ServerCheck
from server.application.services.server_service import ServerService
from server.application.services.sync_service import SyncService
2026-05-22 08:19:56 +08:00
from server.domain.models import Server, SyncLog, Admin
from server.infrastructure.redis.client import get_redis
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
logger = logging.getLogger("nexus.servers")
router = APIRouter(prefix="/api/servers", tags=["servers"])
REDIS_KEY_PREFIX = "heartbeat:"
# ── CRUD ──
@router.get("/", response_model=dict)
async def list_servers(
category: Optional[str] = Query(None, description="Filter by category"),
page: int = Query(1, ge=1, description="Page number"),
per_page: int = Query(50, ge=1, le=200, description="Items per page"),
service: ServerService = Depends(get_server_service),
):
"""List servers with live status from Redis (paginated)
Base info (name/IP/category) from MySQL, real-time status from Redis.
If Redis heartbeat key exists, override is_online/system_info/last_heartbeat.
"""
servers = await service.list_servers(category)
redis = get_redis()
total = len(servers)
start = (page - 1) * per_page
end = start + per_page
page_servers = servers[start:end]
result = []
for server in page_servers:
server_data = _server_to_dict(server)
# Overlay Redis heartbeat data (real-time)
try:
heartbeat = await redis.hgetall(f"{REDIS_KEY_PREFIX}{server.id}")
if heartbeat:
server_data["is_online"] = heartbeat.get("is_online") == "True"
if heartbeat.get("system_info"):
try:
server_data["system_info"] = json.loads(heartbeat.get("system_info", "{}"))
except (json.JSONDecodeError, TypeError):
pass # Keep MySQL value
server_data["last_heartbeat"] = heartbeat.get("last_heartbeat", server_data.get("last_heartbeat"))
server_data["agent_version"] = heartbeat.get("agent_version", server_data.get("agent_version", ""))
server_data["_source"] = "redis"
except Exception as e:
logger.warning(f"Redis read failed for server {server.id}: {e}")
result.append(server_data)
return {
"items": result,
"total": total,
"page": page,
"per_page": per_page,
"pages": (total + per_page - 1) // per_page,
}
# ── Dashboard Stats (must be before /{id} to avoid path collision) ──
@router.get("/stats", response_model=dict)
async def server_stats(
service: ServerService = Depends(get_server_service),
):
"""Aggregated server stats for dashboard — efficient single query"""
servers = await service.list_servers()
redis = get_redis()
total = len(servers)
online = 0
offline = 0
2026-05-22 08:19:56 +08:00
alerts = 0
for server in servers:
try:
heartbeat = await redis.hgetall(f"{REDIS_KEY_PREFIX}{server.id}")
is_online = heartbeat.get("is_online") == "True" if heartbeat else server.is_online
2026-05-22 08:19:56 +08:00
if await redis.exists(f"alerts:{server.id}"):
alerts += 1
except Exception:
is_online = server.is_online
if is_online:
online += 1
else:
offline += 1
categories = {}
for server in servers:
cat = server.category or "uncategorized"
categories[cat] = categories.get(cat, 0) + 1
return {
"total": total,
"online": online,
"offline": offline,
2026-05-22 08:19:56 +08:00
"alerts": alerts,
"categories": categories,
}
@router.get("/{id}", response_model=dict)
async def get_server(
id: int,
service: ServerService = Depends(get_server_service),
):
"""Get a single server by ID with live status from Redis"""
server = await service.get_server(id)
if not server:
raise HTTPException(status_code=404, detail="Server not found")
server_data = _server_to_dict(server)
# Overlay Redis heartbeat data
redis = get_redis()
try:
heartbeat = await redis.hgetall(f"{REDIS_KEY_PREFIX}{id}")
if heartbeat:
server_data["is_online"] = heartbeat.get("is_online") == "True"
if heartbeat.get("system_info"):
try:
server_data["system_info"] = json.loads(heartbeat.get("system_info", "{}"))
except (json.JSONDecodeError, TypeError):
pass
server_data["last_heartbeat"] = heartbeat.get("last_heartbeat", server_data.get("last_heartbeat"))
server_data["agent_version"] = heartbeat.get("agent_version", server_data.get("agent_version", ""))
server_data["_source"] = "redis"
except Exception as e:
logger.warning(f"Redis read failed for server {id}: {e}")
return server_data
@router.post("/", response_model=dict, status_code=201)
async def create_server(
payload: ServerCreate,
service: ServerService = Depends(get_server_service),
):
"""Create a new server"""
server = Server(**payload.model_dump(exclude_none=True))
created = await service.create_server(server)
return _server_to_dict(created)
@router.put("/{id}", response_model=dict)
async def update_server(
id: int,
payload: ServerUpdate,
service: ServerService = Depends(get_server_service),
):
"""Update an existing server"""
server = await service.get_server(id)
if not server:
raise HTTPException(status_code=404, detail="Server not found")
for key, value in payload.model_dump(exclude_unset=True).items():
if hasattr(server, key) and key != "id":
setattr(server, key, value)
updated = await service.update_server(server)
return _server_to_dict(updated)
@router.delete("/{id}", status_code=204)
async def delete_server(
id: int,
service: ServerService = Depends(get_server_service),
):
"""Delete a server"""
result = await service.delete_server(id)
if not result:
raise HTTPException(status_code=404, detail="Server not found")
# ── Health Check ──
@router.post("/check", response_model=dict)
async def check_servers(
payload: ServerCheck,
service: ServerService = Depends(get_server_service),
):
"""Check health status of specified servers (Agent-based, no SSH)"""
return await service.check_all_servers(payload.server_ids)
# ── Push ──
@router.post("/push", response_model=dict)
async def push_to_servers(
payload: ServerPush,
2026-05-22 08:19:56 +08:00
admin: Admin = Depends(get_current_admin),
sync_service: SyncService = Depends(get_sync_service),
):
"""Push files to multiple servers (batch mode with concurrency control)"""
results = await sync_service.batch_push(
server_ids=payload.server_ids,
source_path=payload.source_path,
sync_mode=payload.sync_mode,
2026-05-22 08:19:56 +08:00
operator=admin.username,
batch_size=payload.batch_size,
concurrency=payload.concurrency,
)
return {sid: _sync_log_to_dict(log) for sid, log in results.items()}
# ── Sync Logs ──
@router.get("/logs", response_model=list)
async def get_all_sync_logs(
limit: int = Query(100, ge=1, le=500),
2026-05-22 08:19:56 +08:00
db: AsyncSession = Depends(get_db),
):
"""Get recent sync logs across all servers (for dashboard charts)"""
2026-05-22 08:19:56 +08:00
result = await db.execute(
select(SyncLog).order_by(SyncLog.started_at.desc()).limit(limit)
)
logs = result.scalars().all()
return [_sync_log_to_dict(log) for log in logs]
@router.get("/{id}/logs", response_model=list)
async def get_server_logs(
id: int,
limit: int = Query(50, ge=1, le=200),
sync_service: SyncService = Depends(get_sync_service),
):
"""Get sync logs for a specific server"""
logs = await sync_service.get_sync_logs(id, limit)
return [_sync_log_to_dict(log) for log in logs]
# ── Helper functions ──
def _server_to_dict(server: Server) -> dict:
"""Convert Server model to API response dict (hide sensitive fields)"""
return {
"id": server.id,
"name": server.name,
"domain": server.domain,
"port": server.port,
"username": server.username,
"auth_method": server.auth_method,
"agent_port": server.agent_port,
"description": server.description,
"target_path": server.target_path,
"category": server.category,
"platform_id": server.platform_id,
"node_id": server.node_id,
"protocols": server.protocols,
"extra_attrs": server.extra_attrs,
"connectivity": server.connectivity,
"ssh_key_configured": server.ssh_key_configured,
"is_online": server.is_online,
"last_heartbeat": str(server.last_heartbeat) if server.last_heartbeat else None,
"system_info": server.system_info,
"agent_version": server.agent_version,
"created_at": str(server.created_at) if server.created_at else None,
"updated_at": str(server.updated_at) if server.updated_at else None,
}
def _sync_log_to_dict(log) -> dict:
"""Convert SyncLog model to API response dict"""
return {
"id": log.id,
"server_id": log.server_id,
"source_path": log.source_path,
"target_path": log.target_path,
"trigger_type": log.trigger_type,
"operator": log.operator,
"status": log.status,
"sync_mode": log.sync_mode,
"files_total": log.files_total,
"files_transferred": log.files_transferred,
"duration_seconds": log.duration_seconds,
"error_message": log.error_message,
"started_at": str(log.started_at) if log.started_at else None,
"finished_at": str(log.finished_at) if log.finished_at else None,
}