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:
@@ -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
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user