2026-05-20 14:42:55 +08:00
|
|
|
"""Nexus — Servers API Routes (CRUD + heartbeat + health check + push)
|
|
|
|
|
Presentation layer — receives HTTP requests, delegates to ServerService/SyncService.
|
2026-05-20 17:02:45 +08:00
|
|
|
|
|
|
|
|
Live server status comes from Redis (real-time), base info from MySQL.
|
2026-05-20 14:42:55 +08:00
|
|
|
"""
|
|
|
|
|
|
2026-05-20 17:02:45 +08:00
|
|
|
import json
|
|
|
|
|
import logging
|
2026-05-20 14:42:55 +08:00
|
|
|
from typing import Optional, List
|
2026-05-20 17:02:45 +08:00
|
|
|
|
2026-05-20 14:42:55 +08:00
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
|
|
|
|
|
|
|
|
from server.api.dependencies import get_server_service, get_sync_service, get_db
|
|
|
|
|
from server.application.services.server_service import ServerService
|
|
|
|
|
from server.application.services.sync_service import SyncService
|
|
|
|
|
from server.domain.models import Server
|
2026-05-20 17:02:45 +08:00
|
|
|
from server.infrastructure.redis.client import get_redis
|
2026-05-20 14:42:55 +08:00
|
|
|
|
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
|
2026-05-20 17:02:45 +08:00
|
|
|
logger = logging.getLogger("nexus.servers")
|
|
|
|
|
|
2026-05-20 14:42:55 +08:00
|
|
|
router = APIRouter(prefix="/api/servers", tags=["servers"])
|
|
|
|
|
|
2026-05-20 17:02:45 +08:00
|
|
|
REDIS_KEY_PREFIX = "heartbeat:"
|
|
|
|
|
|
2026-05-20 14:42:55 +08:00
|
|
|
|
|
|
|
|
# ── CRUD ──
|
|
|
|
|
|
|
|
|
|
@router.get("/", response_model=list)
|
|
|
|
|
async def list_servers(
|
|
|
|
|
category: Optional[str] = Query(None, description="Filter by category"),
|
|
|
|
|
service: ServerService = Depends(get_server_service),
|
|
|
|
|
):
|
2026-05-20 17:02:45 +08:00
|
|
|
"""List all servers with live status from Redis
|
|
|
|
|
|
|
|
|
|
Base info (name/IP/category) from MySQL, real-time status from Redis.
|
|
|
|
|
If Redis heartbeat key exists, override is_online/system_info/last_heartbeat.
|
|
|
|
|
"""
|
2026-05-20 14:42:55 +08:00
|
|
|
servers = await service.list_servers(category)
|
2026-05-21 22:11:38 +08:00
|
|
|
redis = get_redis()
|
2026-05-20 17:02:45 +08:00
|
|
|
|
|
|
|
|
result = []
|
|
|
|
|
for server in servers:
|
|
|
|
|
server_data = _server_to_dict(server)
|
|
|
|
|
|
2026-05-21 22:11:38 +08:00
|
|
|
# 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" # Frontend knows data is fresh
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.warning(f"Redis read failed for server {server.id}: {e}")
|
2026-05-20 17:02:45 +08:00
|
|
|
|
|
|
|
|
result.append(server_data)
|
|
|
|
|
|
|
|
|
|
return result
|
2026-05-20 14:42:55 +08:00
|
|
|
|
|
|
|
|
|
2026-05-21 22:58:17 +08:00
|
|
|
# ── 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
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
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,
|
|
|
|
|
"categories": categories,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-05-20 14:42:55 +08:00
|
|
|
@router.get("/{id}", response_model=dict)
|
|
|
|
|
async def get_server(
|
|
|
|
|
id: int,
|
|
|
|
|
service: ServerService = Depends(get_server_service),
|
|
|
|
|
):
|
2026-05-20 17:02:45 +08:00
|
|
|
"""Get a single server by ID with live status from Redis"""
|
2026-05-20 14:42:55 +08:00
|
|
|
server = await service.get_server(id)
|
|
|
|
|
if not server:
|
|
|
|
|
raise HTTPException(status_code=404, detail="Server not found")
|
2026-05-20 17:02:45 +08:00
|
|
|
|
|
|
|
|
server_data = _server_to_dict(server)
|
|
|
|
|
|
|
|
|
|
# Overlay Redis heartbeat data
|
2026-05-21 22:11:38 +08:00
|
|
|
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}")
|
2026-05-20 17:02:45 +08:00
|
|
|
|
|
|
|
|
return server_data
|
2026-05-20 14:42:55 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/", response_model=dict, status_code=201)
|
|
|
|
|
async def create_server(
|
|
|
|
|
server_data: dict,
|
|
|
|
|
service: ServerService = Depends(get_server_service),
|
|
|
|
|
):
|
|
|
|
|
"""Create a new server"""
|
|
|
|
|
server = Server(**server_data)
|
|
|
|
|
created = await service.create_server(server)
|
|
|
|
|
return _server_to_dict(created)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.put("/{id}", response_model=dict)
|
|
|
|
|
async def update_server(
|
|
|
|
|
id: int,
|
|
|
|
|
server_data: dict,
|
|
|
|
|
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 server_data.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(
|
|
|
|
|
server_ids: List[int],
|
|
|
|
|
service: ServerService = Depends(get_server_service),
|
|
|
|
|
):
|
|
|
|
|
"""Check health status of specified servers (Agent-based, no SSH)"""
|
|
|
|
|
return await service.check_all_servers(server_ids)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── Push ──
|
|
|
|
|
|
|
|
|
|
@router.post("/push", response_model=dict)
|
|
|
|
|
async def push_to_servers(
|
|
|
|
|
payload: dict,
|
|
|
|
|
sync_service: SyncService = Depends(get_sync_service),
|
|
|
|
|
):
|
|
|
|
|
"""Push files to multiple servers (batch mode with concurrency control)
|
|
|
|
|
|
|
|
|
|
Body: {
|
|
|
|
|
"server_ids": [1, 2, 3, ...],
|
|
|
|
|
"source_path": "/path/to/files",
|
2026-05-20 17:02:45 +08:00
|
|
|
"sync_mode": "incremental",
|
2026-05-20 14:42:55 +08:00
|
|
|
"operator": "admin",
|
|
|
|
|
"batch_size": 50,
|
|
|
|
|
"concurrency": 10
|
|
|
|
|
}
|
|
|
|
|
"""
|
|
|
|
|
server_ids = payload.get("server_ids", [])
|
|
|
|
|
source_path = payload.get("source_path")
|
|
|
|
|
if not server_ids or not source_path:
|
|
|
|
|
raise HTTPException(status_code=400, detail="server_ids and source_path required")
|
|
|
|
|
|
|
|
|
|
results = await sync_service.batch_push(
|
|
|
|
|
server_ids=server_ids,
|
|
|
|
|
source_path=source_path,
|
|
|
|
|
sync_mode=payload.get("sync_mode", "incremental"),
|
|
|
|
|
operator=payload.get("operator", "admin"),
|
|
|
|
|
batch_size=payload.get("batch_size", 50),
|
|
|
|
|
concurrency=payload.get("concurrency", 10),
|
|
|
|
|
)
|
|
|
|
|
return {sid: _sync_log_to_dict(log) for sid, log in results.items()}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── Sync 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,
|
2026-05-21 22:11:38 +08:00
|
|
|
"platform_id": server.platform_id,
|
|
|
|
|
"node_id": server.node_id,
|
|
|
|
|
"protocols": server.protocols,
|
|
|
|
|
"extra_attrs": server.extra_attrs,
|
|
|
|
|
"connectivity": server.connectivity,
|
2026-05-20 14:42:55 +08:00
|
|
|
"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,
|
|
|
|
|
}
|