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
@@ -600,6 +600,23 @@ class LifecycleInspectionTests(LifecycleTestCase):
self.assertFalse(settings.base.exists())
self.assertEqual(self.host.events, [])
def test_read_log_tail_is_bounded_and_marks_an_oversized_line(self) -> None:
vm = self.create_vm(name="log-reader")
path = Path(vm.log)
path.write_bytes(b"old\n" * 100_000 + b"last\n" * 2)
tail = self.service.read_log_tail(vm.id, tail=2)
self.assertEqual(tail.content, "last\n" * 2)
self.assertFalse(tail.truncated)
with self.assertRaisesRegex(ValidationError, "non-negative"):
self.service.read_log_tail(vm.id, tail=-1)
path.write_bytes(b"x" * 300_000)
truncated = self.service.read_log_tail(vm.id, tail=1)
self.assertEqual(len(truncated.content), 262_144)
self.assertTrue(truncated.truncated)
def test_diagnostics_collects_failures_without_host_commands_or_lifecycle_effects(self) -> None:
self.check_kvm.side_effect = UvmError("KVM unavailable")
self.host.failures["firewall.check_available", None] = UvmError("nft unavailable")
+207 -12
View File
@@ -13,7 +13,7 @@ from uvm.cli import build_parser, main
from uvm.config import Settings
from uvm.domain import PortRule, VmRecord, VmSpec
from uvm.errors import ConfigurationError, ConflictError, NotFoundError, UvmError, ValidationError
from uvm.lifecycle import ListedVm
from uvm.lifecycle import ListedVm, OperationResult
from uvm.server import _http_status_for, create_api, run_server
@@ -199,7 +199,21 @@ class FakeLifecycle:
self.policy_status = "enforced"
return self.vm
def stop(self, identifier: str) -> VmRecord:
def start(self, identifier: str) -> VmRecord:
self.inspect(identifier)
self.vm.status = "running"
self.observed_status = "running"
self.policy_status = "enforced"
return self.vm
def restart(self, identifier: str, *, timeout: float | None = None) -> VmRecord:
self.inspect(identifier)
self.vm.status = "running"
self.observed_status = "running"
self.policy_status = "enforced"
return self.vm
def stop(self, identifier: str, *, timeout: float | None = None) -> VmRecord:
self.inspect(identifier)
self.vm.status = "stopped"
self.vm.pid = None
@@ -208,10 +222,55 @@ class FakeLifecycle:
self.policy_status = "inactive"
return self.vm
def destroy(self, identifier: str) -> VmRecord:
def destroy(self, identifier: str, *, timeout: float | None = None) -> VmRecord:
self.inspect(identifier)
return self.vm
def read_log_tail(self, identifier: str, *, tail: int) -> SimpleNamespace:
self.inspect(identifier)
return SimpleNamespace(content=f"last {tail} log lines", truncated=False)
def diagnostics(self) -> list[dict]:
return [{"name": "kvm", "ok": True, "message": "available"}]
def resolve_targets(self, identifiers: list[str], *, all_vms: bool = False) -> list[VmRecord]:
if all_vms and identifiers:
raise ValidationError("--all cannot be combined with explicit VM targets")
if not all_vms and not identifiers:
raise ValidationError("specify a VM or --all")
if all_vms:
return [self.vm]
return [self.inspect(identifier).vm for identifier in identifiers]
def run_many(
self,
action: str,
vm_ids: list[str],
*,
timeout: float | None = None,
) -> list[OperationResult]:
return [OperationResult(vm_id, action, "succeeded") for vm_id in vm_ids]
def set_ports(self, identifier: str, ports: tuple[PortRule, ...], *, mode: str = "set") -> VmRecord:
self.inspect(identifier)
if mode == "set":
self.vm.ports = ports
elif mode == "open":
existing = {p.port: p for p in self.vm.ports or ()}
for p in ports:
existing.setdefault(p.port, p)
self.vm.ports = tuple(sorted(existing.values(), key=lambda r: r.port))
elif mode == "close":
drop = {p.port for p in ports}
self.vm.ports = tuple(p for p in self.vm.ports or () if p.port not in drop)
self.policy_status = "enforced"
return self.vm
def sync_ports(self, identifier: str) -> VmRecord:
self.inspect(identifier)
self.policy_status = "enforced"
return self.vm
@unittest.skipUnless(FASTAPI_AVAILABLE, "FastAPI and httpx are required for API tests")
class ApiRouterTests(unittest.TestCase):
@@ -236,18 +295,40 @@ class ApiRouterTests(unittest.TestCase):
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), {"status": "ok"})
def test_dashboard_and_assets_are_available_without_credentials(self) -> None:
root = self.client.get("/", follow_redirects=False)
dashboard = self.client.get("/dashboard")
stylesheet = self.client.get("/dashboard/static/css/dashboard.css")
script = self.client.get("/dashboard/static/js/dashboard.js")
self.assertEqual(root.status_code, 307)
self.assertEqual(root.headers["location"], "/dashboard")
self.assertEqual(dashboard.status_code, 200)
self.assertIn("UVM Dashboard", dashboard.text)
self.assertEqual(stylesheet.status_code, 200)
self.assertIn("--bg-body", stylesheet.text)
self.assertEqual(script.status_code, 200)
self.assertIn("sessionStorage", script.text)
def test_vm_routes_require_and_accept_the_api_token(self) -> None:
for method, path in (
("GET", "/vms"),
("POST", "/vms"),
("GET", "/vms/test-vm"),
("POST", "/vms/test-vm/stop"),
("DELETE", "/vms/test-vm"),
("POST", "/install"),
for method, path, payload in (
("GET", "/vms", {}),
("POST", "/vms", {}),
("GET", "/vms/test-vm", {}),
("GET", "/vms/test-vm/logs", {}),
("POST", "/vms/actions", {"action": "start", "identifiers": ["vm-test"]}),
("POST", "/vms/test-vm/start", {}),
("POST", "/vms/test-vm/restart", {}),
("POST", "/vms/test-vm/stop", {}),
("DELETE", "/vms/test-vm", {}),
("POST", "/vms/test-vm/ports", {}),
("POST", "/vms/test-vm/ports/sync", {}),
("GET", "/diagnostics", {}),
("POST", "/install", {}),
):
for headers in ({}, {"X-UVM-Token": "wrong-token"}):
with self.subTest(method=method, path=path, headers=headers):
response = self.client.request(method, path, headers=headers, json={})
response = self.client.request(method, path, headers=headers, json=payload)
self.assertEqual(response.status_code, 401)
self.assertIsNone(self.lifecycle.last_spec)
self.assertEqual(self.lifecycle.vm.status, "running")
@@ -435,6 +516,105 @@ class ApiRouterTests(unittest.TestCase):
self.assertEqual(destroyed.json()["id"], "vm-test")
destroy.assert_called_with(identifier)
def test_lifecycle_timeouts_are_forwarded_when_supplied(self) -> None:
with (
patch.object(self.lifecycle, "restart", wraps=self.lifecycle.restart) as restart,
patch.object(self.lifecycle, "stop", wraps=self.lifecycle.stop) as stop,
patch.object(self.lifecycle, "destroy", wraps=self.lifecycle.destroy) as destroy,
):
self.assertEqual(
self.client.post("/vms/vm-test/restart?timeout=1.5", headers=self.headers).status_code,
200,
)
self.assertEqual(
self.client.post("/vms/vm-test/stop?timeout=2.5", headers=self.headers).status_code,
200,
)
self.assertEqual(
self.client.delete("/vms/vm-test?timeout=3.5", headers=self.headers).status_code,
200,
)
restart.assert_called_once_with("vm-test", timeout=1.5)
stop.assert_called_once_with("vm-test", timeout=2.5)
destroy.assert_called_once_with("vm-test", timeout=3.5)
def test_start_restart_and_port_routes_update_vm_state(self) -> None:
started = self.client.post("/vms/vm-test/start", headers=self.headers)
self.assertEqual(started.status_code, 200)
self.assertEqual(started.json()["status"], "running")
self.assertEqual(started.json()["policy_status"], "enforced")
restarted = self.client.post("/vms/vm-test/restart", headers=self.headers)
self.assertEqual(restarted.status_code, 200)
self.assertEqual(restarted.json()["status"], "running")
self.lifecycle.vm.status = "stopped"
self.lifecycle.policy_status = "inactive"
sync = self.client.post("/vms/vm-test/ports/sync", headers=self.headers)
self.assertEqual(sync.status_code, 200)
self.assertEqual(sync.json()["id"], "vm-test")
self.assertEqual(sync.json()["policy_status"], "inactive")
def test_bulk_action_resolves_targets_before_running_fixed_ids(self) -> None:
with (
patch.object(self.lifecycle, "resolve_targets", wraps=self.lifecycle.resolve_targets) as resolve,
patch.object(self.lifecycle, "run_many", wraps=self.lifecycle.run_many) as run_many,
):
response = self.client.post(
"/vms/actions",
headers=self.headers,
json={"action": "restart", "identifiers": ["test-vm"], "timeout": 1.5},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), [{"id": "vm-test", "action": "restart", "outcome": "succeeded", "error": None}])
resolve.assert_called_once_with(["test-vm"], all_vms=False)
run_many.assert_called_once_with("restart", ["vm-test"], timeout=1.5)
def test_bulk_action_requires_exactly_one_selection_strategy(self) -> None:
for payload in (
{"action": "start"},
{"action": "start", "identifiers": ["vm-test"], "all_vms": True},
):
with self.subTest(payload=payload):
response = self.client.post("/vms/actions", headers=self.headers, json=payload)
self.assertEqual(response.status_code, 422)
def test_log_and_diagnostic_routes_return_bounded_observability_data(self) -> None:
logs = self.client.get("/vms/test-vm/logs?tail=5", headers=self.headers)
diagnostics = self.client.get("/diagnostics", headers=self.headers)
self.assertEqual(logs.status_code, 200)
self.assertEqual(logs.json(), {"content": "last 5 log lines", "truncated": False})
self.assertEqual(diagnostics.status_code, 200)
self.assertEqual(diagnostics.json(), [{"name": "kvm", "ok": True, "message": "available"}])
def test_port_update_modes_mutate_rules(self) -> None:
set_resp = self.client.post(
"/vms/vm-test/ports",
headers=self.headers,
json={"mode": "set", "ports": [{"port": 80, "protocol": "HTTP"}, {"port": 443, "protocol": "HTTPS"}]},
)
self.assertEqual(set_resp.status_code, 200)
self.assertEqual(set_resp.json()["ports"], [{"port": 80, "protocol": "HTTP"}, {"port": 443, "protocol": "HTTPS"}])
open_resp = self.client.post(
"/vms/vm-test/ports",
headers=self.headers,
json={"mode": "open", "ports": [{"port": 22, "protocol": "TCP"}]},
)
self.assertEqual(open_resp.status_code, 200)
self.assertEqual(open_resp.json()["ports"], [{"port": 22, "protocol": "TCP"}, {"port": 80, "protocol": "HTTP"}, {"port": 443, "protocol": "HTTPS"}])
close_resp = self.client.post(
"/vms/vm-test/ports",
headers=self.headers,
json={"mode": "close", "ports": [{"port": 80, "protocol": "HTTP"}]},
)
self.assertEqual(close_resp.status_code, 200)
self.assertEqual(close_resp.json()["ports"], [{"port": 22, "protocol": "TCP"}, {"port": 443, "protocol": "HTTPS"}])
def test_create_claims_enforcement_only_for_successful_managed_vms(self) -> None:
for ports, status, operation, policy_status in (
(None, "running", None, "unmanaged"),
@@ -456,9 +636,15 @@ class ApiRouterTests(unittest.TestCase):
for method, path, operation in (
("GET", "/vms", "list_vms"),
("GET", "/vms/test-vm", "inspect"),
("GET", "/vms/test-vm/logs", "read_log_tail"),
("POST", "/vms", "create"),
("POST", "/vms/test-vm/start", "start"),
("POST", "/vms/test-vm/restart", "restart"),
("POST", "/vms/test-vm/stop", "stop"),
("DELETE", "/vms/test-vm", "destroy"),
("POST", "/vms/test-vm/ports", "set_ports"),
("POST", "/vms/test-vm/ports/sync", "sync_ports"),
("GET", "/diagnostics", "diagnostics"),
):
for error, expected in (
(NotFoundError("unknown guest"), 404),
@@ -480,19 +666,28 @@ class ApiRouterTests(unittest.TestCase):
self.assertEqual(response.status_code, 404)
self.assertEqual(response.json(), {"error": {"message": "VM not found: missing"}})
def test_api_schema_keeps_only_existing_operations(self) -> None:
def test_api_schema_exposes_dashboard_and_management_operations(self) -> None:
schema = self.client.get("/openapi.json").json()
self.assertEqual(
{(path, method) for path, methods in schema["paths"].items() for method in methods},
{
("/", "get"),
("/dashboard", "get"),
("/health", "get"),
("/install", "post"),
("/vms", "get"),
("/vms", "post"),
("/vms/actions", "post"),
("/vms/{identifier}", "get"),
("/vms/{identifier}/logs", "get"),
("/vms/{vm_id}/start", "post"),
("/vms/{vm_id}/restart", "post"),
("/vms/{vm_id}/stop", "post"),
("/vms/{vm_id}", "delete"),
("/vms/{vm_id}/ports", "post"),
("/vms/{vm_id}/ports/sync", "post"),
("/diagnostics", "get"),
},
)
self.assertEqual(