feat: Implement UVM Dashboard with frontend and backend integration

- Added dashboard.js for frontend functionality, including VM management and system health monitoring.
- Created dashboard route to serve the dashboard HTML.
- Introduced diagnostics route for token-protected host diagnostics.
- Enhanced VMs router with bulk actions, VM log retrieval, and port management.
- Updated lifecycle service to read VM log tails with truncation handling.
- Integrated static file serving for dashboard assets in the FastAPI server.
- Redirect root URL to the dashboard for improved user experience.
This commit is contained in:
2026-09-19 10:50:29 +05:30
parent 3d7af4adb8
commit b3b1b527e1
17 changed files with 2285 additions and 69 deletions
+17
View File
@@ -0,0 +1,17 @@
"""Dashboard frontend routes."""
from __future__ import annotations
from pathlib import Path
from fastapi import APIRouter
from fastapi.responses import HTMLResponse
router = APIRouter()
_DASHBOARD_HTML = Path(__file__).parent.parent / "dashboard" / "static" / "index.html"
@router.get("", response_class=HTMLResponse)
def get_dashboard() -> str:
return _DASHBOARD_HTML.read_text(encoding="utf-8")
+18
View File
@@ -0,0 +1,18 @@
"""Token-protected host diagnostics endpoint."""
from __future__ import annotations
from fastapi import APIRouter, Depends
from ..api_models import DiagnosticResponse
from ..app import Application
from .dependencies import get_authorized_application
router = APIRouter(tags=["diagnostics"])
@router.get("/diagnostics", response_model=list[DiagnosticResponse])
def diagnostics(
application: Application = Depends(get_authorized_application),
) -> list[DiagnosticResponse]:
return [DiagnosticResponse(**check) for check in application.lifecycle.diagnostics()]
+129 -10
View File
@@ -2,18 +2,49 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, status
from fastapi import APIRouter, Depends, Query, status
from ..api_models import DestroyResponse, VmCreateRequest, VmResponse, vm_response
from ..api_models import (
DestroyResponse,
PortSyncResponse,
PortUpdateRequest,
VmActionResultResponse,
VmBulkActionRequest,
VmCreateRequest,
VmLogResponse,
VmResponse,
vm_response,
)
from ..app import Application
from ..domain import PortRule, VmSpec
from ..validation import parse_cpu, parse_name, parse_password, parse_ports, parse_ram, parse_username
from ..domain import PortRule, VmRecord, VmSpec
from ..validation import (
parse_cpu,
parse_name,
parse_password,
parse_ports,
parse_ram,
parse_timeout,
parse_username,
)
from .dependencies import get_authorized_application
router = APIRouter(prefix="/vms", tags=["vms"])
def _mutation_policy_status(vm: VmRecord) -> str:
"""Return the policy state that follows a successful lifecycle mutation."""
if vm.ports is None:
return "unmanaged"
if vm.operation is not None:
return "unknown"
if vm.status == "running":
return "enforced"
if vm.status == "stopped":
return "inactive"
return "unknown"
@router.get("", response_model=list[VmResponse])
def list_vms(
application: Application = Depends(get_authorized_application),
@@ -47,29 +78,117 @@ def create_vm(
ports=ports,
)
)
policy_status = "unmanaged" if vm.ports is None else "unknown"
if vm.ports is not None and vm.status == "running" and vm.operation is None:
policy_status = "enforced"
return vm_response(vm, policy_status=policy_status)
return vm_response(vm, policy_status=_mutation_policy_status(vm))
@router.post("/actions", response_model=list[VmActionResultResponse])
def run_bulk_action(
request: VmBulkActionRequest,
application: Application = Depends(get_authorized_application),
) -> list[VmActionResultResponse]:
timeout = parse_timeout(str(request.timeout)) if request.timeout is not None else None
targets = application.lifecycle.resolve_targets(request.identifiers, all_vms=request.all_vms)
results = application.lifecycle.run_many(
request.action,
[vm.id for vm in targets],
timeout=timeout,
)
return [
VmActionResultResponse(
id=result.id,
action=result.action,
outcome=result.outcome,
error=result.error,
)
for result in results
]
@router.post("/{vm_id}/start", response_model=VmResponse)
def start_vm(
vm_id: str,
application: Application = Depends(get_authorized_application),
) -> VmResponse:
vm = application.lifecycle.start(vm_id)
return vm_response(vm, policy_status=_mutation_policy_status(vm))
@router.post("/{vm_id}/restart", response_model=VmResponse)
def restart_vm(
vm_id: str,
timeout: float | None = None,
application: Application = Depends(get_authorized_application),
) -> VmResponse:
vm = (
application.lifecycle.restart(vm_id)
if timeout is None
else application.lifecycle.restart(vm_id, timeout=timeout)
)
return vm_response(vm, policy_status=_mutation_policy_status(vm))
@router.post("/{vm_id}/stop", response_model=VmResponse)
def stop_vm(
vm_id: str,
timeout: float | None = None,
application: Application = Depends(get_authorized_application),
) -> VmResponse:
return vm_response(application.lifecycle.stop(vm_id), policy_status="inactive")
vm = (
application.lifecycle.stop(vm_id)
if timeout is None
else application.lifecycle.stop(vm_id, timeout=timeout)
)
return vm_response(vm, policy_status=_mutation_policy_status(vm))
@router.delete("/{vm_id}", response_model=DestroyResponse)
def destroy_vm(
vm_id: str,
timeout: float | None = None,
application: Application = Depends(get_authorized_application),
) -> DestroyResponse:
vm = application.lifecycle.destroy(vm_id)
vm = (
application.lifecycle.destroy(vm_id)
if timeout is None
else application.lifecycle.destroy(vm_id, timeout=timeout)
)
return DestroyResponse(id=vm.id)
@router.post("/{vm_id}/ports", response_model=VmResponse)
def update_ports(
vm_id: str,
request: PortUpdateRequest,
application: Application = Depends(get_authorized_application),
) -> VmResponse:
ports = parse_ports([f"{rule.port}:{rule.protocol}" for rule in request.ports])
vm = application.lifecycle.set_ports(vm_id, ports, mode=request.mode)
return vm_response(vm, policy_status=_mutation_policy_status(vm))
@router.post("/{vm_id}/ports/sync", response_model=PortSyncResponse)
def sync_ports(
vm_id: str,
application: Application = Depends(get_authorized_application),
) -> PortSyncResponse:
vm = application.lifecycle.sync_ports(vm_id)
return PortSyncResponse(
id=vm.id,
policy_status=_mutation_policy_status(vm),
status=vm.status,
)
@router.get("/{identifier}/logs", response_model=VmLogResponse)
def get_vm_logs(
identifier: str,
tail: int = Query(default=100, ge=0, le=1000),
application: Application = Depends(get_authorized_application),
) -> VmLogResponse:
log = application.lifecycle.read_log_tail(identifier, tail=tail)
return VmLogResponse(content=log.content, truncated=log.truncated)
@router.get("/{identifier}", response_model=VmResponse)
def get_vm(
identifier: str,