from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel from sqlalchemy import func, select from sqlalchemy.orm import Session from app.auth.jwt_auth import get_current_user, require_admin from app.config import get_settings from app.database import get_db from app.models import Event, Host from app.schemas.list_models import HostDetail, HostListResponse, HostSummary from app.services.agent_version import latest_agent_versions_by_product from app.services.host_delete import delete_host_and_related from app.services.win_admin_settings import get_effective_win_admin_config from app.services.winrm_connect import ( HostNotWindowsError, HostTargetMissingError, WinAdminNotConfiguredError, resolve_windows_host_target, test_winrm_connection, ) from app.services.host_health import ( HEARTBEAT_TYPE, agent_status as compute_agent_status, apply_agent_status_host_filter, max_daily_report_time_by_host, max_event_time_by_host, ) router = APIRouter(prefix="/hosts", tags=["hosts"]) @router.get("", response_model=HostListResponse) def list_hosts( page: int = Query(1, ge=1), page_size: int = Query(50, ge=1, le=200), product: str | None = None, hostname: str | None = None, agent_status: str | None = Query(None, pattern="^(online|stale|unknown)$"), db: Session = Depends(get_db), _user: str = Depends(get_current_user), ) -> HostListResponse: stmt = select(Host) count_stmt = select(func.count()).select_from(Host) if product: stmt = stmt.where(Host.product == product) count_stmt = count_stmt.where(Host.product == product) if hostname: like = f"%{hostname}%" host_match = Host.hostname.ilike(like) | Host.display_name.ilike(like) stmt = stmt.where(host_match) count_stmt = count_stmt.where(host_match) settings = get_settings() if agent_status: stmt, count_stmt = apply_agent_status_host_filter( stmt, count_stmt, agent_status, stale_minutes=settings.sac_heartbeat_stale_minutes, ) total = db.scalar(count_stmt) or 0 rows = db.scalars( stmt.order_by(Host.last_seen_at.desc()) .offset((page - 1) * page_size) .limit(page_size) ).all() hb_map = max_event_time_by_host(db, HEARTBEAT_TYPE) report_map = max_daily_report_time_by_host(db) items: list[HostSummary] = [] for host in rows: event_count = db.scalar( select(func.count()).select_from(Event).where(Event.host_id == host.id) ) last_hb = hb_map.get(host.id) items.append( HostSummary( id=host.id, hostname=host.hostname, display_name=host.display_name, os_family=host.os_family, os_version=host.os_version, product=host.product, product_version=host.product_version, ipv4=host.ipv4, last_seen_at=host.last_seen_at, event_count=int(event_count or 0), last_heartbeat_at=last_hb, last_daily_report_at=report_map.get(host.id), last_inventory_at=host.inventory_updated_at, agent_status=compute_agent_status( last_hb, stale_minutes=settings.sac_heartbeat_stale_minutes ), ) ) return HostListResponse( items=items, total=total, page=page, page_size=page_size, latest_agent_versions=latest_agent_versions_by_product(db), ) def _host_detail_from_model( host: Host, *, db: Session, settings, ) -> HostDetail: hb_map = max_event_time_by_host(db, HEARTBEAT_TYPE) report_map = max_daily_report_time_by_host(db) event_count = db.scalar( select(func.count()).select_from(Event).where(Event.host_id == host.id) ) last_hb = hb_map.get(host.id) return HostDetail( id=host.id, hostname=host.hostname, display_name=host.display_name, os_family=host.os_family, os_version=host.os_version, product=host.product, product_version=host.product_version, ipv4=host.ipv4, ipv6=host.ipv6, last_seen_at=host.last_seen_at, event_count=int(event_count or 0), last_heartbeat_at=last_hb, last_daily_report_at=report_map.get(host.id), last_inventory_at=host.inventory_updated_at, agent_status=compute_agent_status(last_hb, stale_minutes=settings.sac_heartbeat_stale_minutes), agent_instance_id=host.agent_instance_id, tags=host.tags if isinstance(host.tags, list) else [], use_sac_mode=host.use_sac_mode, inventory=host.inventory if isinstance(host.inventory, dict) else None, inventory_updated_at=host.inventory_updated_at, created_at=host.created_at, ) @router.get("/{host_id}", response_model=HostDetail) def get_host( host_id: int, db: Session = Depends(get_db), _user: str = Depends(get_current_user), ) -> HostDetail: host = db.get(Host, host_id) if host is None: raise HTTPException(status_code=404, detail="Host not found") settings = get_settings() return _host_detail_from_model(host, db=db, settings=settings) class HostDeleteResponse(BaseModel): status: str host_id: int hostname: str deleted_events: int deleted_problems: int class HostWinRmTestResponse(BaseModel): ok: bool message: str target: str hostname: str | None = None @router.post("/{host_id}/actions/winrm-test", response_model=HostWinRmTestResponse) def test_host_winrm( host_id: int, db: Session = Depends(get_db), _user=Depends(require_admin), ) -> HostWinRmTestResponse: host = db.get(Host, host_id) if host is None: raise HTTPException(status_code=404, detail="Host not found") cfg = get_effective_win_admin_config(db) if not cfg.configured: raise HTTPException(status_code=400, detail="Windows domain admin is not configured") try: target = resolve_windows_host_target(host) except HostNotWindowsError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc except HostTargetMissingError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc try: result = test_winrm_connection( target=target, user=cfg.user, password=cfg.password, ) except WinAdminNotConfiguredError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc except RuntimeError as exc: raise HTTPException(status_code=503, detail=str(exc)) from exc return HostWinRmTestResponse( ok=result.ok, message=result.message, target=result.target, hostname=result.hostname, ) @router.delete("/{host_id}", response_model=HostDeleteResponse) def delete_host( host_id: int, db: Session = Depends(get_db), _user=Depends(require_admin), ) -> HostDeleteResponse: result = delete_host_and_related(db, host_id) if result is None: raise HTTPException(status_code=404, detail="Host not found") db.commit() return HostDeleteResponse( status="deleted", host_id=result.host_id, hostname=result.hostname, deleted_events=result.deleted_events, deleted_problems=result.deleted_problems, )