diff --git a/DEVELOPER.md b/DEVELOPER.md index 69c8607..50d045b 100644 --- a/DEVELOPER.md +++ b/DEVELOPER.md @@ -13,8 +13,10 @@ general-purpose Python library or a multi-host control plane. UVM installs host/guest assets, provisions private disks, and manages named VMs and private IPv4 TCP allowlists on one host. The CLI offers retained-disk start/stop/restart, deletion, fixed-target bulk operations, inspection, SSH, -logs, doctor, and explicit port edits/sync. The optional API exposes only the -existing health/install/create/list/inspect/stop/delete operations. +logs, doctor, and explicit port edits/sync. The optional API and local dashboard +expose health, installation, VM lifecycle controls, and managed port-policy +controls, host diagnostics, bounded VM-log tails, and fixed-target bulk +operations. Interactive SSH remains CLI-only. There is no autostart or background reconciler, multi-host control plane, tenancy/quotas, or UVM-provided Jailer/cgroup/namespace hardening. Private port @@ -80,7 +82,10 @@ Packet integration tests must remain inside disposable isolated namespaces. │ ├── lifecycle.py # Lifecycle, bulk operations, recovery, port intent │ ├── api_models.py # Pydantic HTTP request/response models │ ├── server.py # FastAPI factory and Uvicorn launcher +│ ├── dashboard/ # Packaged local dashboard HTML, CSS, and JS │ ├── routers/ +│ │ ├── dashboard.py # Dashboard document route +│ │ ├── diagnostics.py # Host diagnostic endpoint │ │ ├── dependencies.py # API application/token dependencies │ │ ├── health.py # Liveness endpoint │ │ ├── installation.py # Installation endpoint @@ -158,9 +163,12 @@ checks, not per-VM repair or guest application probes. ### HTTP Path Routers validate the token and Pydantic request, invoke application services, -and render whitelisted response models. The CLI expansion does not imply HTTP -endpoint parity: start/restart, port edits/sync, bulk operations, logs, doctor, -and interactive SSH have no HTTP endpoints. +and render whitelisted response models. Lifecycle, managed port-policy, +diagnostics, and bounded log-tail operations have explicit HTTP routes for the +dashboard. Bulk operations have a dedicated fixed-target HTTP route; interactive +SSH has no HTTP endpoint. The public dashboard shell does not bypass token +validation: it stores a supplied token only for the active browser session and +sends it with management requests. Use only `uvm --serve` or `./uvm.py --serve` to launch the API. The `create_api()` factory requires an explicit host specifically to prevent it @@ -645,13 +653,22 @@ or secret manager when that matters. | Method | Route | Router | Notes | |---|---|---|---| +| `GET` | `/` | `server.py` | Redirect to `/dashboard` | +| `GET` | `/dashboard` | `dashboard.py` | Public static dashboard shell | | `GET` | `/health` | `health.py` | Unauthenticated liveness response | +| `GET` | `/diagnostics` | `diagnostics.py` | Host readiness checks | | `POST` | `/install` | `installation.py` | `{ "force": false }` body | | `GET` | `/vms` | `vms.py` | List persisted VM records and observed status | | `POST` | `/vms` | `vms.py` | `cpu`, `ram`, `guest_ip`, `username`, `password`, `name`, `ports`, `no_default_ports` | +| `POST` | `/vms/actions` | `vms.py` | Fixed-ID snapshot bulk lifecycle action | | `GET` | `/vms/{identifier}` | `vms.py` | Inspect by exact ID, name, or guest IP | -| `POST` | `/vms/{identifier}/stop` | `vms.py` | Stop while retaining private disk | -| `DELETE` | `/vms/{identifier}` | `vms.py` | Remove runtime files and state | +| `POST` | `/vms/{identifier}/start` | `vms.py` | Boot a retained private disk | +| `POST` | `/vms/{identifier}/restart` | `vms.py` | Restart; optional `timeout` query parameter | +| `POST` | `/vms/{identifier}/stop` | `vms.py` | Stop; optional `timeout` query parameter | +| `DELETE` | `/vms/{identifier}` | `vms.py` | Remove runtime files/state; optional `timeout` query parameter | +| `POST` | `/vms/{vm_id}/ports` | `vms.py` | Exact ID only; body has `mode` and `ports` | +| `POST` | `/vms/{vm_id}/ports/sync` | `vms.py` | Exact ID only; reapply managed policy | +| `GET` | `/vms/{identifier}/logs` | `vms.py` | Bounded non-following log tail | All management routes use `get_authorized_application()` and require: @@ -671,14 +688,24 @@ unmanaged VM from an omitted policy. Names share CLI validation. VM responses include `name`, `ports`, `policy_status`, and `operation` alongside resource/status fields and username. The schema excludes password and disk/socket/config/log path fields; error messages may still contain paths. -`ports: null` in a legacy response still means unmanaged. The existing -stop/delete routes use service defaults; no timeout/dry-run/bulk HTTP schemas -were added. Typed not-found/conflict errors map to 404/409. +`ports: null` in a legacy response still means unmanaged. Start/restart/stop +and port-policy mutations report the resulting policy status. Restart, stop, +and delete accept an optional finite non-negative `timeout` query parameter; +there is no dry-run HTTP schema. Port mutation bodies use +`{"mode":"set"|"open"|"close", "ports":[...]}` and always resolve the +target by exact ID to prevent name/IP reuse. A legacy policy must be set while +stopped before it can be started or synced. Typed not-found/conflict errors map +to 404/409. -There are **no new start/restart, ports, or bulk HTTP endpoints**. The API also -does not expose logs, doctor, interactive SSH, raw Firecracker calls, arbitrary -command execution, or host path access. Public `/docs`, `/redoc`, and -`/openapi.json` are informational, not privileged management routes. +Log reads accept a `tail` query parameter from 0 through 1000 and never follow +the file. `LifecycleService.read_log_tail()` reads at most the final 256 KiB and +marks a response truncated when an oversized line or that bound prevents a full +tail. Bulk bodies use an action plus either explicit identifiers or `all_vms`; +the router resolves them before `run_many()` executes the fixed IDs under one +operation lock. The API does not expose interactive SSH, raw Firecracker calls, +arbitrary command execution, or host path access. Public `/`, `/dashboard`, +`/docs`, `/redoc`, and `/openapi.json` do not perform privileged management +requests. ## 14. Configuration Reference @@ -810,7 +837,8 @@ on an isolated Linux host: `get_authorized_application()`, and register new routers in `create_api()`. Add API tests and update all route tables. New CLI commands do not implicitly authorize new HTTP endpoints; `/health` remains the only unauthenticated - operational route. + operational API route, while `/` and `/dashboard` only serve the public + dashboard shell. - Host primitives: use the specific network/firewall/install/process module and `CommandRunner` for external commands. Define ownership, idempotence, and compensation before invoking a primitive from lifecycle code. Use fake diff --git a/README.md b/README.md index 491a5da..b1d900a 100644 --- a/README.md +++ b/README.md @@ -217,35 +217,53 @@ sudo env UVM_API_TOKEN="$UVM_API_TOKEN" \ ``` The default bind address is `127.0.0.1` and the default port is `8000`. -OpenAPI documentation is available at `/docs` once the server is running. +OpenAPI documentation is available at `/docs` once the server is running. The +root URL redirects to the local dashboard at `/dashboard`; enter the API token +there for the current browser session. The server exposes these routes: | Method | Route | Purpose | |---|---|---| +| `GET` | `/` | Redirect to the local dashboard | +| `GET` | `/dashboard` | Serve the local management dashboard | | `GET` | `/health` | Unauthenticated liveness response | +| `GET` | `/diagnostics` | Run host readiness checks for the dashboard/system view | | `POST` | `/install` | Install or refresh host assets; body: `{ "force": false }` | | `GET` | `/vms` | List VM records and observed status | | `POST` | `/vms` | Create; supports `cpu`, `ram`, `guest_ip`, `username`, `password`, `name`, `ports`, `no_default_ports` | +| `POST` | `/vms/actions` | Run a fixed snapshot of selected or all VM lifecycle actions | | `GET` | `/vms/{identifier}` | Inspect by exact ID, name, or guest IP | -| `POST` | `/vms/{identifier}/stop` | Stop a VM while retaining its private disk | -| `DELETE` | `/vms/{identifier}` | Delete a VM and release its resources | +| `POST` | `/vms/{identifier}/start` | Boot a retained private disk | +| `POST` | `/vms/{identifier}/restart` | Stop then boot a VM; optional `timeout` query parameter | +| `POST` | `/vms/{identifier}/stop` | Stop a VM while retaining its private disk; optional `timeout` query parameter | +| `DELETE` | `/vms/{identifier}` | Delete a VM and release its resources; optional `timeout` query parameter | +| `POST` | `/vms/{vm_id}/ports` | Set, open, or close private port rules for an exact VM ID | +| `POST` | `/vms/{vm_id}/ports/sync` | Reapply a managed private port policy for an exact VM ID | +| `GET` | `/vms/{identifier}/logs` | Read a bounded, non-following tail of a VM log | API port rules are objects such as `{"port": 80, "protocol": "HTTP"}`; -`no_default_ports` is a boolean, default `false`. Responses include `name`, +`no_default_ports` is a boolean, default `false`. Port changes use +`{"mode":"set"|"open"|"close", "ports":[...]}`. A legacy VM must be +stopped and use `set` to establish a managed policy. Responses include `name`, `ports`, `policy_status`, and `operation`, but never passwords. See [USAGE.md](USAGE.md) for request examples and strict schema details. +Log reads use an optional `tail` query parameter from `0` to `1000`, default +`100`, and are capped at the final 256 KiB of the log. -The endpoint set is unchanged: there are **no start/restart, port-management, -or bulk HTTP endpoints**. SSH, logs, and doctor are also CLI-only. Use the -local CLI for those operations; the API is not a background reconciler. +Bulk requests use an action (`start`, `stop`, `restart`, `delete`, or +`ports-sync`) plus either `identifiers` or `all_vms: true`; they return a result +for each fixed target and do not prompt for confirmation. Interactive SSH +remains CLI-only. The API and dashboard are not background reconcilers. ### API Security The server controls privileged host operations. `UVM_API_TOKEN` is required for every API server, including loopback-only deployments. All management -routes require it in the `X-UVM-Token` header. FastAPI's informational `/docs`, -`/redoc`, and `/openapi.json` endpoints do not require a token. +routes require it in the `X-UVM-Token` header. The dashboard shell, liveness +route, and FastAPI's informational `/docs`, `/redoc`, and `/openapi.json` +endpoints do not require a token; the dashboard stores the entered token only +for its current browser session and applies it to management requests. A non-loopback bind additionally requires TLS certificate and key paths: @@ -406,6 +424,8 @@ Selected files: │ ├── lifecycle.py # Lifecycle, bulk operations, recovery, port-policy intent │ ├── network.py # IP/MAC/TAP/bridge/NAT management │ ├── firewall.py # Owned nftables bridge policies and offline conntrack cleanup +│ ├── dashboard/ # Local dashboard HTML, CSS, and JavaScript +│ ├── routers/ # FastAPI routes, including the dashboard document │ └── firecracker/ # Firecracker config, API, and process adapters └── tests/ # Unit tests and opt-in isolated namespace packet tests ``` @@ -424,8 +444,9 @@ python3 -B -m unittest discover -s tests -v The unit tests do not need root, KVM, Firecracker, or host network changes. They cover validation, state migration and locking, integrity manifests, network/firewall rendering, Firecracker request ordering, process safety, -retained-disk resume, bulk operations, and lifecycle rollback. API route tests -run when the FastAPI and `httpx` test dependencies are installed. +retained-disk resume, bulk operations, lifecycle rollback, dashboard delivery, +and API routes. API route tests run when the FastAPI and `httpx` test +dependencies are installed. Opt-in packet checks run **only in disposable isolated user/network namespaces**, which may require privileges and the installed network tools: diff --git a/USAGE.md b/USAGE.md index 17274a3..d4e94ad 100644 --- a/USAGE.md +++ b/USAGE.md @@ -602,11 +602,12 @@ inventing identity fields in `state.json`. ## 10. Use the FastAPI Server -The API uses the same local lifecycle services, but exposes only the existing -health/install/create/list/inspect/stop/delete endpoints. There are no new -start/restart, port-management, or bulk HTTP endpoints; SSH, logs, and doctor -also remain CLI-only. The server does not autostart VMs or reconcile rules, -and is not safe to expose directly to the public internet. +The API uses the same local lifecycle services and exposes health, installation, +VM lifecycle, diagnostics, bounded VM-log tails, and managed private-port +controls. The local dashboard at `/dashboard` uses those same endpoints. There +API also supports fixed-target bulk actions; interactive SSH remains CLI-only. +The server does not autostart VMs or reconcile rules, and is not safe to expose +directly to the public internet. ### Start a Local Server @@ -620,15 +621,18 @@ sudo env \ ./uvm.py --serve --host 127.0.0.1 --port 8000 ``` -Liveness is at `http://127.0.0.1:8000/health`; OpenAPI docs are at +Liveness is at `http://127.0.0.1:8000/health`; the dashboard is at +`http://127.0.0.1:8000/dashboard`; OpenAPI docs are at `http://127.0.0.1:8000/docs`. The default bind is `127.0.0.1:8000`. -The API token is required even for a loopback server. All VM and installation -routes require it. FastAPI's informational `/docs`, `/redoc`, and -`/openapi.json` endpoints are also available without a token; they do not -perform host mutations. Tokens must be visible ASCII with no whitespace. -Use a protected environment file or secret manager rather than recording a -real token in shell history; do not share tokens or guest passwords in reports. +The API token is required even for a loopback server. All VM, diagnostics, and +installation routes require it. The dashboard shell, liveness route, and FastAPI's +informational `/docs`, `/redoc`, and `/openapi.json` endpoints are available +without a token; they do not perform host mutations. The dashboard keeps an +entered token only for the active browser session. Tokens must be visible ASCII +with no whitespace. Use a protected environment file or secret manager rather +than recording a real token in shell history; do not share tokens or guest +passwords in reports. ### Start a TLS Server @@ -675,16 +679,26 @@ curl --cacert /path/to/ca.pem \ | Method | Path | Operation | |---|---|---| +| `GET` | `/` | Redirect to `/dashboard` | +| `GET` | `/dashboard` | Serve the local management dashboard | | `GET` | `/health` | Unauthenticated liveness | +| `GET` | `/diagnostics` | Run host readiness checks | | `POST` | `/install` | Install/refresh with a `force` boolean | | `GET` | `/vms` | List VM responses | | `POST` | `/vms` | Create and boot, HTTP 201 on success | +| `POST` | `/vms/actions` | Run selected/all VM lifecycle actions from a fixed snapshot | | `GET` | `/vms/{identifier}` | Inspect by exact ID, name, or guest IP | -| `POST` | `/vms/{identifier}/stop` | Stop and retain disk, using the default TERM wait | -| `DELETE` | `/vms/{identifier}` | Delete VM and private disk | +| `POST` | `/vms/{identifier}/start` | Start a retained VM | +| `POST` | `/vms/{identifier}/restart` | Restart a VM; accepts `timeout` query parameter | +| `POST` | `/vms/{identifier}/stop` | Stop and retain disk; accepts `timeout` query parameter | +| `DELETE` | `/vms/{identifier}` | Delete VM and private disk; accepts `timeout` query parameter | +| `POST` | `/vms/{vm_id}/ports` | Set, open, or close private ports for an exact VM ID | +| `POST` | `/vms/{vm_id}/ports/sync` | Reapply the policy for an exact VM ID | +| `GET` | `/vms/{identifier}/logs` | Read a bounded, non-following log tail | -These stop/delete endpoints do not expose the CLI timeout, dry-run, or bulk -flags and do not prompt for confirmation. +Individual lifecycle requests do not expose a CLI dry-run flag and do not prompt +for confirmation. `timeout` is an optional finite, non-negative query parameter +on restart, stop, and delete; it has the same SIGTERM wait semantics as the CLI. ### Health, List, and Inspect @@ -739,9 +753,19 @@ The response never includes the password. Omitting credentials still uses labels return HTTP `422`. Example passwords above are placeholders; real secrets in curl arguments can enter shell history and process listings. -### Stop and Delete +### Start, Restart, Stop, and Delete ```sh +# Start a retained private disk. +curl -X POST \ + -H "X-UVM-Token: $UVM_API_TOKEN" \ + "$UVM_API_URL/vms/api-web/start" + +# Restart with an explicit SIGTERM wait. +curl -X POST \ + -H "X-UVM-Token: $UVM_API_TOKEN" \ + "$UVM_API_URL/vms/api-web/restart?timeout=10" + curl -X POST \ -H "X-UVM-Token: $UVM_API_TOKEN" \ "$UVM_API_URL/vms/api-web/stop" @@ -753,8 +777,95 @@ curl -X DELETE \ ``` Stop returns the VM response with `status: "stopped"`; delete returns its -exact `id` and `status: "terminated"`. Start the retained disk through the -local CLI, not a new HTTP route. +exact `id` and `status: "terminated"`. Start and restart return the updated VM +response. Start preserves the retained private disk and does not restore guest +memory. + +### Bulk Lifecycle Actions + +The dashboard batch toolbar uses this endpoint for selected VMs. The server +resolves the selected identifiers into a fixed ID snapshot before it invokes the +existing serialized bulk lifecycle service. + +```sh +# Restart two named VMs with a shared TERM wait. +curl -X POST \ + -H "Content-Type: application/json" \ + -H "X-UVM-Token: $UVM_API_TOKEN" \ + -d '{"action":"restart","identifiers":["api-web","worker"],"timeout":10}' \ + "$UVM_API_URL/vms/actions" + +# Sync managed policies for every VM currently in the registry. +curl -X POST \ + -H "Content-Type: application/json" \ + -H "X-UVM-Token: $UVM_API_TOKEN" \ + -d '{"action":"ports-sync","all_vms":true}' \ + "$UVM_API_URL/vms/actions" +``` + +Valid actions are `start`, `stop`, `restart`, `delete`, and `ports-sync`. +Provide `identifiers` or `all_vms: true`, but not both. The response contains +one `{id, action, outcome, error}` object per selected VM. HTTP bulk requests +do not prompt for confirmation or provide a dry-run; require an explicit +operator confirmation in a caller before destructive use. + +### Manage Private Ports + +The port mutation and sync routes require an **exact VM ID** returned by create +or list. They deliberately do not resolve a name or guest IP, preventing a +later name/IP reuse from changing the wrong policy. Port rules are strict JSON +objects, not CLI colon strings. + +```sh +# Replace the full managed TCP allowlist. +curl -X POST \ + -H "Content-Type: application/json" \ + -H "X-UVM-Token: $UVM_API_TOKEN" \ + -d '{"mode":"set","ports":[{"port":22,"protocol":"TCP"},{"port":443,"protocol":"HTTPS"}]}' \ + "$UVM_API_URL/vms//ports" + +# Add or remove one or more rules without replacing the other rules. +curl -X POST \ + -H "Content-Type: application/json" \ + -H "X-UVM-Token: $UVM_API_TOKEN" \ + -d '{"mode":"open","ports":[{"port":80,"protocol":"HTTP"}]}' \ + "$UVM_API_URL/vms//ports" +curl -X POST \ + -H "Content-Type: application/json" \ + -H "X-UVM-Token: $UVM_API_TOKEN" \ + -d '{"mode":"close","ports":[{"port":80,"protocol":"HTTP"}]}' \ + "$UVM_API_URL/vms//ports" + +# Reapply a policy after an external firewall change. +curl -X POST \ + -H "X-UVM-Token: $UVM_API_TOKEN" \ + "$UVM_API_URL/vms//ports/sync" +``` + +`set` can use an empty `ports` array to close every managed service. A legacy +VM with `ports: null` must be stopped and use `set` before it can start or use +the other policy operations. `open`, `close`, and `sync` reject a legacy policy. +Mutating a live policy is subject to the same containment and confirmation rules +as the CLI. + +### Diagnostics and Log Tails + +The dashboard's System view calls the protected diagnostics endpoint. It runs +the same host-readiness checks as `uvm doctor`, but does not repair a host or +change VM state. + +```sh +curl -H "X-UVM-Token: $UVM_API_TOKEN" "$UVM_API_URL/diagnostics" + +# Read the final 200 lines without opening a streaming follow request. +curl -H "X-UVM-Token: $UVM_API_TOKEN" \ + "$UVM_API_URL/vms/api-web/logs?tail=200" +``` + +Log responses have `content` and `truncated` fields. `tail` is an integer from +0 through 1000, defaulting to 100. Reads are limited to the final 256 KiB of a +log; `truncated: true` means an oversized line or the read bound prevented a +complete requested tail. Use `uvm logs VM --follow` for continuous output. ### Install or Refresh Through the API diff --git a/image.png b/image.png new file mode 100644 index 0000000..7c959a5 Binary files /dev/null and b/image.png differ diff --git a/pyproject.toml b/pyproject.toml index 0a5784c..4de4114 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,4 +20,7 @@ test = ["httpx>=0.27,<1"] uvm = "uvm.cli:main" [tool.setuptools] -packages = ["uvm", "uvm.firecracker", "uvm.routers"] +packages = ["uvm", "uvm.firecracker", "uvm.routers", "uvm.dashboard"] + +[tool.setuptools.package-data] +"uvm.dashboard" = ["static/*.html", "static/css/*.css", "static/js/*.js"] diff --git a/tests/test_lifecycle_commands.py b/tests/test_lifecycle_commands.py index f7b6cde..053635b 100644 --- a/tests/test_lifecycle_commands.py +++ b/tests/test_lifecycle_commands.py @@ -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") diff --git a/tests/test_server.py b/tests/test_server.py index 686a1ec..34e9692 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -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( diff --git a/uvm/api_models.py b/uvm/api_models.py index 896787c..9ccf2ed 100644 --- a/uvm/api_models.py +++ b/uvm/api_models.py @@ -73,6 +73,46 @@ class DestroyResponse(BaseModel): status: str = "terminated" +class PortUpdateRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + mode: Literal["set", "open", "close"] = "set" + ports: list[PortRuleModel] = Field(default_factory=list) + + +class PortSyncResponse(BaseModel): + id: str + policy_status: str + status: str + + +class VmLogResponse(BaseModel): + content: str + truncated: bool + + +class DiagnosticResponse(BaseModel): + name: str + ok: bool + message: str + + +class VmBulkActionRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + action: Literal["start", "stop", "restart", "delete", "ports-sync"] + identifiers: list[str] = Field(default_factory=list) + all_vms: bool = Field(default=False, strict=True) + timeout: float | None = None + + +class VmActionResultResponse(BaseModel): + id: str + action: str + outcome: str + error: str | None = None + + def vm_response( vm: VmRecord, *, diff --git a/uvm/dashboard/__init__.py b/uvm/dashboard/__init__.py new file mode 100644 index 0000000..1cca740 --- /dev/null +++ b/uvm/dashboard/__init__.py @@ -0,0 +1 @@ +# UVM Dashboard static assets package. diff --git a/uvm/dashboard/static/css/dashboard.css b/uvm/dashboard/static/css/dashboard.css new file mode 100644 index 0000000..fd4052c --- /dev/null +++ b/uvm/dashboard/static/css/dashboard.css @@ -0,0 +1,470 @@ +/* ─── Design Tokens ─── */ +:root { + --bg-body: #0b0b0f; + --bg-sidebar: #0f0f14; + --bg-card: #141419; + --bg-card-hover: #1a1a22; + --bg-input: #0e0e12; + --bg-elevated: #1c1c24; + + --border-color: #1f1f28; + --border-hover: #2a2a36; + --divider: #1a1a22; + + --text-primary: #f0f0f5; + --text-secondary: #8a8a98; + --text-muted: #5a5a68; + + --accent: #6366f1; + --accent-hover: #818cf8; + --accent-soft: rgba(99, 102, 241, 0.12); + + --green: #22c55e; + --green-soft: rgba(34, 197, 94, 0.12); + --red: #ef4444; + --red-soft: rgba(239, 68, 68, 0.12); + --amber: #f59e0b; + --amber-soft: rgba(245, 158, 11, 0.12); + --blue: #3b82f6; + --blue-soft: rgba(59, 130, 246, 0.12); + --purple: #a855f7; + --purple-soft: rgba(168, 85, 247, 0.12); + + --radius-sm: 8px; + --radius-md: 12px; + --radius-lg: 16px; + --radius-xl: 20px; + + --shadow-sm: 0 1px 2px rgba(0,0,0,0.4); + --shadow-md: 0 4px 12px rgba(0,0,0,0.5); + --shadow-lg: 0 8px 24px rgba(0,0,0,0.6); + + --font-sans: 'Inter', ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif; + --transition: 150ms cubic-bezier(0.4, 0, 0.2, 1); +} + +/* ─── Reset ─── */ +*, *::before, *::after { box-sizing: border-box; } +html, body { height: 100%; } +body { + margin: 0; + font-family: var(--font-sans); + background: var(--bg-body); + color: var(--text-primary); + line-height: 1.5; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* ─── Layout ─── */ +#app { min-height: 100vh; } +.screen { display: none; min-height: 100vh; } +.screen.active { display: flex; } + +#main-app { flex-direction: row; } + +.sidebar { + width: 260px; + background: var(--bg-sidebar); + border-right: 1px solid var(--border-color); + display: flex; + flex-direction: column; + padding: 24px 16px; + position: fixed; + inset: 0 auto 0 0; + z-index: 50; +} + +.sidebar-brand { margin-bottom: 32px; padding: 0 8px; } +.sidebar-brand .logo { + display: flex; + align-items: center; + gap: 12px; + font-weight: 700; + font-size: 18px; + letter-spacing: -0.02em; + color: var(--text-primary); +} +.sidebar-brand .logo svg { color: var(--text-primary); } + +.sidebar-nav { display: flex; flex-direction: column; gap: 4px; flex: 1; } +.nav-item { + display: flex; align-items: center; gap: 12px; + padding: 10px 12px; + border-radius: var(--radius-md); + color: var(--text-secondary); + text-decoration: none; + font-size: 14px; font-weight: 500; + transition: background var(--transition), color var(--transition); +} +.nav-item:hover { background: var(--bg-card-hover); color: var(--text-primary); } +.nav-item.active { background: var(--accent-soft); color: var(--accent); } +.nav-item svg { opacity: 0.8; } + +.sidebar-footer { + margin-top: auto; + padding-top: 16px; + border-top: 1px solid var(--divider); + display: flex; + flex-direction: column; + gap: 12px; +} +.connection-status { + display: flex; align-items: center; gap: 8px; + font-size: 12px; color: var(--text-muted); +} +.status-dot { + width: 8px; height: 8px; border-radius: 50%; + background: var(--green); + box-shadow: 0 0 6px var(--green); +} +.status-dot.offline { background: var(--red); box-shadow: 0 0 6px var(--red); } + +.main { + flex: 1; + margin-left: 260px; + display: flex; + flex-direction: column; + min-height: 100vh; +} + +.topbar { + position: sticky; top: 0; z-index: 40; + background: rgba(11,11,15,0.8); + backdrop-filter: blur(12px); + border-bottom: 1px solid var(--border-color); + padding: 16px 32px; + display: flex; align-items: center; justify-content: space-between; +} +.topbar-left h2 { margin: 0; font-size: 20px; font-weight: 600; letter-spacing: -0.01em; } +.topbar-right { display: flex; align-items: center; gap: 10px; } + +.content { padding: 28px 32px; flex: 1; } + +/* ─── Pages ─── */ +.page { display: none; animation: fadeIn 220ms ease; } +.page.active { display: block; } +@keyframes fadeIn { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } } + +/* ─── Cards ─── */ +.card { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--radius-lg); + padding: 24px; + transition: border-color var(--transition), box-shadow var(--transition); +} +.card:hover { border-color: var(--border-hover); } +.card-title { margin: 0 0 16px; font-size: 14px; font-weight: 600; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.04em; } +.card-heading { margin: 0 0 20px; font-size: 18px; font-weight: 600; } +.card-title-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 16px; } +.card-title-row .card-title { margin: 0; } + +/* ─── Stats ─── */ +.stats-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 20px; + margin-bottom: 28px; +} +.stats-grid.two { grid-template-columns: repeat(2, 1fr); } + +.stat-card { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--radius-lg); + padding: 20px; +} +.stat-header { + display: flex; align-items: center; justify-content: space-between; + margin-bottom: 12px; +} +.stat-label { font-size: 13px; color: var(--text-secondary); font-weight: 500; } +.stat-icon { + width: 36px; height: 36px; border-radius: 10px; + display: flex; align-items: center; justify-content: center; +} +.icon-blue { background: var(--blue-soft); color: var(--blue); } +.icon-green { background: var(--green-soft); color: var(--green); } +.icon-amber { background: var(--amber-soft); color: var(--amber); } +.icon-purple { background: var(--purple-soft); color: var(--purple); } +.stat-value { font-size: 28px; font-weight: 700; letter-spacing: -0.02em; line-height: 1; } +.stat-meta { font-size: 12px; color: var(--text-muted); margin-top: 8px; } +.text-green { color: var(--green); } +.text-amber { color: var(--amber); } +.text-red { color: var(--red); } + +/* ─── Buttons ─── */ +.btn { + display: inline-flex; align-items: center; justify-content: center; gap: 8px; + border: 1px solid transparent; border-radius: var(--radius-md); + padding: 8px 14px; font-size: 13px; font-weight: 600; font-family: inherit; + cursor: pointer; transition: background var(--transition), border-color var(--transition), transform 80ms ease; + background: transparent; color: var(--text-primary); +} +.btn:active { transform: scale(0.98); } +.btn-primary { background: var(--text-primary); color: var(--bg-body); border-color: var(--text-primary); } +.btn-primary:hover { background: #e2e2e8; border-color: #e2e2e8; } +.btn-ghost { border-color: var(--border-color); color: var(--text-secondary); } +.btn-ghost:hover { background: var(--bg-card-hover); color: var(--text-primary); } +.btn-danger { background: var(--red-soft); color: var(--red); border-color: rgba(239,68,68,0.25); } +.btn-danger:hover { background: rgba(239,68,68,0.2); } +.btn-warning { background: var(--amber-soft); color: var(--amber); border-color: rgba(245,158,11,0.25); } +.btn-warning:hover { background: rgba(245,158,11,0.2); } +.btn-sm { padding: 6px 10px; font-size: 12px; } +.btn-lg { padding: 10px 18px; font-size: 14px; } +.btn-icon { padding: 8px; } + +/* ─── Tables ─── */ +.table-wrap { overflow-x: auto; } +.data-table { width: 100%; border-collapse: collapse; font-size: 13px; } +.data-table thead th { + text-align: left; padding: 12px 16px; + color: var(--text-secondary); font-weight: 500; font-size: 12px; + border-bottom: 1px solid var(--divider); + white-space: nowrap; +} +.data-table tbody td { padding: 14px 16px; border-bottom: 1px solid var(--divider); vertical-align: middle; } +.data-table tbody tr { transition: background var(--transition); } +.data-table tbody tr:hover { background: var(--bg-card-hover); } +.data-table .actions { text-align: right; white-space: nowrap; } +.data-table .selection { width: 36px; padding-right: 0; text-align: center; } +.data-table input[type="checkbox"] { accent-color: var(--accent); cursor: pointer; } + +.table-empty { display: none; padding: 40px 0; } +.table-empty.visible { display: block; } + +/* ─── Badges ─── */ +.badge { + display: inline-flex; align-items: center; gap: 6px; + padding: 4px 10px; border-radius: 999px; font-size: 12px; font-weight: 600; +} +.badge-running { background: var(--green-soft); color: var(--green); } +.badge-stopped { background: var(--red-soft); color: var(--red); } +.badge-unknown { background: var(--bg-elevated); color: var(--text-muted); } +.status-badge { + display: inline-flex; align-items: center; gap: 6px; + padding: 6px 12px; border-radius: var(--radius-md); font-size: 13px; font-weight: 600; +} +.badge-loading { background: var(--bg-elevated); color: var(--text-muted); } +.badge-ok { background: var(--green-soft); color: var(--green); } +.badge-error { background: var(--red-soft); color: var(--red); } + +/* ─── VM Grid / List ─── */ +.vm-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 16px; +} +.vm-card { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--radius-lg); + padding: 18px; + cursor: pointer; + transition: border-color var(--transition), transform var(--transition); +} +.vm-card:hover { border-color: var(--border-hover); transform: translateY(-2px); } +.vm-card-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; } +.vm-card-name { font-weight: 600; font-size: 14px; } +.vm-card-ip { font-size: 12px; color: var(--text-muted); margin-top: 2px; } +.vm-card-meta { display: flex; gap: 12px; margin-top: 12px; font-size: 12px; color: var(--text-secondary); } +.vm-card-meta span { display: flex; align-items: center; gap: 4px; } + +.section-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; } +.section-header h3 { margin: 0; font-size: 16px; font-weight: 600; } +.link { color: var(--accent); text-decoration: none; font-size: 13px; font-weight: 500; } +.link:hover { text-decoration: underline; } + +/* ─── Toolbar ─── */ +.card-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; } +.search-box { + display: flex; align-items: center; gap: 10px; + background: var(--bg-input); border: 1px solid var(--border-color); + border-radius: var(--radius-md); padding: 0 12px; flex: 1; max-width: 360px; +} +.search-box input { + background: transparent; border: none; outline: none; color: var(--text-primary); + font: inherit; font-size: 13px; padding: 9px 0; width: 100%; +} +.search-box svg { color: var(--text-muted); flex-shrink: 0; } +.select { + background: var(--bg-input); border: 1px solid var(--border-color); + border-radius: var(--radius-md); color: var(--text-primary); + font: inherit; font-size: 13px; padding: 8px 28px 8px 12px; + appearance: none; cursor: pointer; + background-image: url("data:image/svg+xml,%3Csvg width='10' height='6' viewBox='0 0 10 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%238a8a98' stroke-width='1.5' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); + background-repeat: no-repeat; background-position: right 10px center; +} +.toolbar-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } +.bulk-controls { display: none; align-items: center; gap: 8px; } +.bulk-controls.visible { display: flex; } +.bulk-controls span { color: var(--text-secondary); font-size: 12px; white-space: nowrap; } + +/* ─── Forms ─── */ +.form-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; } +.field { display: flex; flex-direction: column; gap: 6px; } +.field.full { grid-column: 1 / -1; } +.field label { font-size: 12px; font-weight: 600; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.03em; } +.field input, .field select { + background: var(--bg-input); border: 1px solid var(--border-color); + border-radius: var(--radius-md); color: var(--text-primary); + font: inherit; font-size: 13px; padding: 10px 12px; + transition: border-color var(--transition); +} +.field input:focus, .field select:focus { outline: none; border-color: var(--accent); } +.hint-inline { color: var(--text-muted); font-weight: 400; text-transform: none; letter-spacing: 0; } +.form-actions { display: flex; align-items: center; gap: 12px; margin-top: 24px; } +.checkbox-field { flex-direction: row; align-items: center; gap: 10px; } +.checkbox { display: inline-flex; align-items: center; gap: 10px; cursor: pointer; font-size: 13px; color: var(--text-secondary); } +.checkbox input { position: absolute; opacity: 0; width: 0; height: 0; } +.checkmark { + width: 18px; height: 18px; border-radius: 5px; + border: 1px solid var(--border-hover); background: var(--bg-input); + display: flex; align-items: center; justify-content: center; + transition: background var(--transition), border-color var(--transition); +} +.checkbox input:checked + .checkmark { background: var(--accent); border-color: var(--accent); } +.checkmark::after { + content: ''; width: 5px; height: 9px; border: solid white; + border-width: 0 2px 2px 0; transform: rotate(45deg); + opacity: 0; transition: opacity var(--transition); +} +.checkbox input:checked + .checkmark::after { opacity: 1; } + +/* ─── Auth Screen ─── */ +#auth-screen { align-items: center; justify-content: center; padding: 24px; } +.auth-card { + width: 100%; max-width: 420px; + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--radius-xl); + padding: 36px; + box-shadow: var(--shadow-lg); +} +.auth-header { text-align: center; margin-bottom: 28px; } +.auth-header h1 { margin: 16px 0 6px; font-size: 22px; font-weight: 700; } +.auth-header p { margin: 0; color: var(--text-secondary); font-size: 14px; } +.auth-form { display: flex; flex-direction: column; gap: 16px; } +.error-text { color: var(--red); font-size: 12px; margin: 0; min-height: 16px; } +.error-block { color: var(--red); font-size: 12px; background: var(--red-soft); padding: 12px; border-radius: var(--radius-md); border: 1px solid rgba(239,68,68,0.2); margin-top: 12px; white-space: pre-wrap; } +.error-block.hidden { display: none; } + +/* ─── VM Detail ─── */ +.vm-detail-header { margin-bottom: 20px; } +.back-btn { margin-bottom: 12px; } +.vm-detail-title { display: flex; align-items: center; gap: 12px; } +.vm-detail-title h2 { margin: 0; font-size: 22px; font-weight: 700; } +.detail-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 20px; } +.detail-card.wide { grid-column: 1 / -1; } +.detail-rows { display: flex; flex-direction: column; gap: 12px; } +.detail-row { display: flex; align-items: center; justify-content: space-between; font-size: 13px; padding: 8px 0; border-bottom: 1px solid var(--divider); } +.detail-row:last-child { border-bottom: none; } +.detail-key { color: var(--text-secondary); } +.detail-value { color: var(--text-primary); font-weight: 500; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; } +.port-list { display: flex; flex-wrap: wrap; gap: 8px; } +.port-chip { + display: inline-flex; align-items: center; gap: 6px; + padding: 5px 10px; border-radius: var(--radius-sm); + background: var(--bg-elevated); border: 1px solid var(--border-color); + font-size: 12px; font-weight: 500; +} +.port-editor { display: none; margin-top: 12px; } +.port-editor-input { + width: 100%; padding: 10px 12px; border: 1px solid var(--border-color); + border-radius: var(--radius-md); background: var(--bg-input); color: var(--text-primary); + font: inherit; font-size: 13px; +} +.port-editor-input:focus { outline: none; border-color: var(--accent); } +.port-editor-actions, .port-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; } +.action-bar { display: flex; gap: 10px; flex-wrap: wrap; } +.hint { font-size: 12px; color: var(--text-muted); margin-top: 10px; } +.muted { color: var(--text-muted); } +.ssh-command { + display: block; margin-bottom: 12px; padding: 10px; + border: 1px solid var(--border-color); border-radius: var(--radius-md); + background: var(--bg-input); color: #c4c4d2; overflow-wrap: anywhere; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 12px; +} + +/* ─── System ─── */ +.system-card .card-title { margin-bottom: 12px; } +.system-status { display: flex; align-items: center; gap: 12px; margin-top: 8px; } +.system-actions { display: flex; align-items: center; gap: 14px; margin-top: 12px; } +.code-block { + background: var(--bg-input); border: 1px solid var(--border-color); + border-radius: var(--radius-md); padding: 12px; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 12px; color: var(--text-secondary); margin-top: 12px; overflow: auto; +} +.log-output { + max-height: 320px; overflow: auto; margin: 0; + padding: 14px; border: 1px solid var(--border-color); border-radius: var(--radius-md); + background: var(--bg-input); color: #b8b8c5; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 12px; line-height: 1.6; white-space: pre-wrap; word-break: break-word; +} +.diagnostics-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 10px; } +.diagnostic-item { + display: flex; align-items: flex-start; gap: 10px; + padding: 12px; border: 1px solid var(--border-color); border-radius: var(--radius-md); + background: var(--bg-input); +} +.diagnostic-mark { width: 8px; height: 8px; border-radius: 50%; flex: 0 0 auto; margin-top: 5px; } +.diagnostic-mark.ok { background: var(--green); box-shadow: 0 0 6px var(--green); } +.diagnostic-mark.error { background: var(--red); box-shadow: 0 0 6px var(--red); } +.diagnostic-name { font-size: 12px; font-weight: 600; color: var(--text-primary); } +.diagnostic-message { margin-top: 2px; font-size: 11px; color: var(--text-muted); overflow-wrap: anywhere; } + +/* ─── Empty State ─── */ +.empty-state { text-align: center; padding: 40px 20px; color: var(--text-muted); } +.empty-state p { margin: 0 0 12px; } + +/* ─── Modal ─── */ +.modal { display: none; position: fixed; inset: 0; z-index: 100; align-items: center; justify-content: center; } +.modal.open { display: flex; } +.modal-overlay { position: absolute; inset: 0; background: rgba(0,0,0,0.6); backdrop-filter: blur(4px); } +.modal-card { + position: relative; z-index: 1; + background: var(--bg-card); border: 1px solid var(--border-color); + border-radius: var(--radius-xl); padding: 28px; width: 100%; max-width: 400px; + box-shadow: var(--shadow-lg); +} +.modal-card h3 { margin: 0 0 8px; font-size: 18px; } +.modal-card p { margin: 0 0 20px; color: var(--text-secondary); font-size: 13px; } +.modal-actions { display: flex; justify-content: flex-end; gap: 10px; } + +/* ─── Toasts ─── */ +#toast-container { position: fixed; top: 20px; right: 20px; z-index: 200; display: flex; flex-direction: column; gap: 10px; } +.toast { + background: var(--bg-card); border: 1px solid var(--border-color); + border-radius: var(--radius-md); padding: 12px 16px; + box-shadow: var(--shadow-md); font-size: 13px; + animation: slideIn 200ms ease; min-width: 260px; + display: flex; align-items: center; gap: 10px; +} +.toast.success { border-color: rgba(34,197,94,0.35); color: var(--green); } +.toast.error { border-color: rgba(239,68,68,0.35); color: var(--red); } +@keyframes slideIn { from { opacity: 0; transform: translateX(10px); } to { opacity: 1; transform: translateX(0); } } + +/* ─── Responsive ─── */ +@media (max-width: 1024px) { + .stats-grid { grid-template-columns: repeat(2, 1fr); } + .detail-grid { grid-template-columns: 1fr; } +} +@media (max-width: 768px) { + .sidebar { width: 100%; position: relative; border-right: none; border-bottom: 1px solid var(--border-color); } + .main { margin-left: 0; } + #main-app { flex-direction: column; } + .stats-grid { grid-template-columns: 1fr; } + .form-grid { grid-template-columns: 1fr; } + .content { padding: 20px; } + .topbar { padding: 12px 20px; } +} + +/* ─── Scrollbar ─── */ +::-webkit-scrollbar { width: 8px; height: 8px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: #2a2a36; border-radius: 4px; } +::-webkit-scrollbar-thumb:hover { background: #3a3a48; } diff --git a/uvm/dashboard/static/index.html b/uvm/dashboard/static/index.html new file mode 100644 index 0000000..e7e8f75 --- /dev/null +++ b/uvm/dashboard/static/index.html @@ -0,0 +1,379 @@ + + + + + + UVM Dashboard + + + +
+ +
+
+
+ +

Welcome back

+

Enter your management API token to access the dashboard.

+
+
+
+ + +
+ +

+
+
+
+ + +
+ + +
+
+
+

Overview

+
+
+ + +
+
+ +
+ +
+
+
+
+ Total VMs +
+ +
+
+
—
+
registered VMs
+
+
+
+ Running +
+ +
+
+
—
+
active microVMs
+
+
+
+ Stopped +
+ +
+
+
—
+
inactive microVMs
+
+
+
+ API Health +
+ +
+
+
—
+
last check —
+
+
+ +
+

Recent Virtual Machines

+ View all +
+
+
+

No virtual machines yet.

+ +
+
+
+ + +
+
+
+ +
+
+ 0 selected + + +
+ +
+
+
+ + + + + + + + + + + + + + +
VMStatusGuest IPResourcesPortsPolicyActions
+
+
+
+

No virtual machines found.

+ +
+
+
+
+ + +
+
+ +
+

—

+ — +
+
+
+
+

Configuration

+
+
+
+
+

Networking

+
+
+
+
+

SSH Access

+ ssh root@10.42.0.2 + +

Run this from a trusted local terminal.

+
+
+

Port Rules

+
+

No port rules configured.

+
+
+ +
+ + + + +
+
+
+ + +
+
+
+
+

Firecracker Log

+ +
+
Loading recent log entries…
+

Showing up to 100 recent lines.

+
+
+

Actions

+
+ + + + +
+

Use the copied SSH command from a trusted local terminal.

+
+
+
+ + +
+
+

Create Virtual Machine

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+ + +
+

+            
+
+ + +
+
+
+

API Health

+
+ Checking… +

Send a test request to the management API.

+
+
+
+

Installation

+

Install or refresh host assets (Firecracker, kernel, rootfs). This is a privileged operation.

+
+ + +
+ +
+
+ +
+

Server Information

+
+
Dashboard Version1.0.0
+
API URL—
+
Observed At—
+
+
+
+
+

Host Diagnostics

+ +
+
+

Open System to run host readiness checks.

+
+
+
+
+
+
+ + +
+ + + +
+ + + + diff --git a/uvm/dashboard/static/js/dashboard.js b/uvm/dashboard/static/js/dashboard.js new file mode 100644 index 0000000..d4cae4d --- /dev/null +++ b/uvm/dashboard/static/js/dashboard.js @@ -0,0 +1,739 @@ +/* ─── UVM Dashboard ─── */ + +(function () { + 'use strict'; + + // ─ State ─ + const state = { + token: sessionStorage.getItem('uvm_token') || '', + baseUrl: location.origin, + vms: [], + health: null, + diagnostics: null, + selectedVmIds: new Set(), + currentPage: 'overview', + currentVmId: null, + refreshTimer: null, + }; + + // ─ Helpers ─ + const $ = (sel, el=document) => el.querySelector(sel); + const $$ = (sel, el=document) => [...el.querySelectorAll(sel)]; + + function formatRam(mib) { + const value = Number(mib); + if (!Number.isFinite(value)) return '—'; + return value % 1024 === 0 ? `${value / 1024} GiB` : `${value} MiB`; + } + + function statusClass(status) { + if (status === 'running') return 'running'; + if (status === 'stopped') return 'stopped'; + return 'unknown'; + } + + function showScreen(id) { + $$('.screen').forEach(s => s.classList.remove('active')); + $(id).classList.add('active'); + } + + function showPage(name) { + state.currentPage = name; + $$('.page').forEach(p => p.classList.remove('active')); + const page = $('#page-' + name); + if (page) page.classList.add('active'); + + $$('.nav-item').forEach(n => n.classList.toggle('active', n.dataset.page === name)); + + const titles = { + overview: 'Overview', + vms: 'Virtual Machines', + 'vm-detail': 'VM Details', + create: 'Create VM', + system: 'System', + }; + $('#page-title').textContent = titles[name] || name; + } + + function toast(message, type='success') { + const t = document.createElement('div'); + t.className = 'toast ' + type; + t.textContent = message; + $('#toast-container').appendChild(t); + setTimeout(() => t.remove(), 3500); + } + + async function api(path, opts={}) { + const url = (state.baseUrl || '').replace(/\/$/, '') + path; + const res = await fetch(url, { + ...opts, + headers: { + 'Content-Type': 'application/json', + 'X-UVM-Token': state.token, + ...(opts.headers || {}), + }, + }); + const text = await res.text(); + let data = null; + try { data = JSON.parse(text); } catch {} + if (!res.ok) { + const msg = data?.error?.message || `HTTP ${res.status}`; + throw new Error(msg); + } + return data; + } + + // ─ Auth ─ + function initAuth() { + if (state.token) { + showScreen('#main-app'); + showPage('overview'); + startRefresh(); + return; + } + showScreen('#auth-screen'); + } + + $('#auth-btn').addEventListener('click', async () => { + const token = $('#token-input').value.trim(); + if (!token) { $('#auth-error').textContent = 'Token is required.'; return; } + state.token = token; + try { + // Health is intentionally public, so verify the supplied token on a protected route. + state.vms = await api('/vms') || []; + sessionStorage.setItem('uvm_token', token); + $('#token-input').value = ''; + $('#auth-error').textContent = ''; + showScreen('#main-app'); + showPage('overview'); + startRefresh(); + toast('Connected successfully'); + } catch (e) { + $('#auth-error').textContent = e.message || 'Connection failed.'; + state.token = ''; + } + }); + + $('#logout-btn').addEventListener('click', () => { + state.token = ''; + $('#token-input').value = ''; + sessionStorage.removeItem('uvm_token'); + localStorage.removeItem('uvm_token'); + localStorage.removeItem('uvm_baseUrl'); + stopRefresh(); + showScreen('#auth-screen'); + }); + + // ─ Data Loading ─ + async function loadVms() { + try { + state.vms = await api('/vms') || []; + const availableIds = new Set(state.vms.map(vm => vm.id)); + state.selectedVmIds = new Set( + [...state.selectedVmIds].filter(id => availableIds.has(id)), + ); + renderOverviewStats(); + renderOverviewVMs(); + renderVMsTable(); + if (state.currentPage === 'vm-detail' && state.currentVmId) { + renderVMDetail(state.currentVmId); + } + updateConnStatus(true); + } catch (e) { + updateConnStatus(false); + console.error('loadVms failed', e); + } + } + + async function loadHealth() { + try { + state.health = await api('/health'); + const el = $('#stat-health'); + el.textContent = state.health?.status || 'ok'; + el.className = 'stat-value text-green'; + $('#health-ago').textContent = 'just now'; + renderSystemHealth(); + } catch (e) { + $('#stat-health').textContent = 'unreachable'; + $('#stat-health').className = 'stat-value text-red'; + $('#health-ago').textContent = '—'; + renderSystemHealth(); + } + } + + async function loadDiagnostics() { + const list = $('#diagnostics-list'); + list.innerHTML = '

Running host readiness checks…

'; + try { + state.diagnostics = await api('/diagnostics'); + renderDiagnostics(); + } catch (e) { + state.diagnostics = null; + list.innerHTML = `

Diagnostics unavailable: ${esc(e.message)}

`; + } + } + + function renderDiagnostics() { + const list = $('#diagnostics-list'); + if (!state.diagnostics?.length) { + list.innerHTML = '

No diagnostic checks returned.

'; + return; + } + list.innerHTML = state.diagnostics.map((check) => ` +
+ +
+
${esc(check.name)}
+
${esc(check.message)}
+
+
+ `).join(''); + } + + function updateConnStatus(ok) { + const dot = $('#conn-dot'); + const label = $('#conn-label'); + dot.classList.toggle('offline', !ok); + label.textContent = ok ? 'Connected' : 'Disconnected'; + } + + function renderOverviewStats() { + const total = state.vms.length; + const running = state.vms.filter(v => v.status === 'running').length; + const stopped = state.vms.filter(v => v.status !== 'running').length; + $('#stat-total').textContent = total; + $('#stat-running').textContent = running; + $('#stat-stopped').textContent = stopped; + } + + function renderOverviewVMs() { + const container = $('#overview-vm-list'); + if (!state.vms.length) { + container.innerHTML = ` +
+

No virtual machines yet.

+ +
`; + return; + } + const recent = state.vms.slice(0, 6); + container.innerHTML = recent.map(vm => ` +
+
+
+
${esc(vm.name || vm.id)}
+
${esc(vm.guest_ip || '—')}
+
+ ${esc(vm.status || 'unknown')} +
+
+ ${vm.cpu || 1} vCPU + ${formatRam(vm.ram_mib)} +
+
+ `).join(''); + } + + function renderVMsTable() { + const tbody = $('#vms-table tbody'); + const empty = $('#vms-empty'); + const selectAll = $('#vm-select-all'); + const filterStatus = $('#vm-filter-status').value; + const search = $('#vm-search').value.trim().toLowerCase(); + + let list = state.vms.slice(); + if (filterStatus !== 'all') { + list = list.filter(v => v.status === filterStatus); + } + if (search) { + list = list.filter(v => + (v.name || '').toLowerCase().includes(search) || + (v.id || '').toLowerCase().includes(search) || + (v.guest_ip || '').includes(search) + ); + } + + if (!list.length) { + tbody.innerHTML = ''; + empty.classList.add('visible'); + selectAll.checked = false; + selectAll.indeterminate = false; + renderBulkControls(); + return; + } + empty.classList.remove('visible'); + + tbody.innerHTML = list.map(vm => { + const ports = (vm.ports || []).map(p => `${p.port}:${p.protocol}`).join(' ') || '—'; + const actionBtns = []; + if (vm.status === 'running') { + actionBtns.push(``); + actionBtns.push(``); + } else { + actionBtns.push(``); + } + return ` + + + +
${esc(vm.name || '—')}
+
${esc(vm.id)}
+ + ${esc(vm.status || 'unknown')} + ${esc(vm.guest_ip || '—')} + ${vm.cpu || 1} vCPU · ${formatRam(vm.ram_mib)} + ${ports} + ${esc(vm.policy_status || '—')} + + + ${actionBtns.join('')} + + + + `; + }).join(''); + const selectedVisible = list.filter(vm => state.selectedVmIds.has(vm.id)).length; + selectAll.checked = selectedVisible === list.length; + selectAll.indeterminate = selectedVisible > 0 && selectedVisible < list.length; + renderBulkControls(); + } + + function renderBulkControls() { + const count = state.selectedVmIds.size; + $('#bulk-controls').classList.toggle('visible', count > 0); + $('#bulk-selection-count').textContent = `${count} selected`; + } + + // ─ VM Details ─ + async function renderVMDetail(id) { + let vm = state.vms.find(v => v.id === id || v.name === id || v.guest_ip === id); + if (!vm) { + try { vm = await api(`/vms/${id}`); } catch (e) { + toast('Failed to load VM details: ' + e.message, 'error'); + location.hash = '#vms'; + return; + } + } + state.currentVmId = vm.id; + $('#detail-name').textContent = vm.name || vm.id; + $('#detail-status').textContent = vm.status || 'unknown'; + $('#detail-status').className = 'badge badge-' + statusClass(vm.status); + + $('#detail-config').innerHTML = ` +
ID${esc(vm.id)}
+
Name${esc(vm.name || '—')}
+
Status${esc(vm.status)}
+
Operation${esc(vm.operation || '—')}
+
vCPUs${vm.cpu || 1}
+
RAM${formatRam(vm.ram_mib)}
+
Guest User${esc(vm.username || '—')}
+ `; + + $('#detail-network').innerHTML = ` +
Guest IP${esc(vm.guest_ip || '—')}
+
Observed Status${esc(vm.observed_status || '—')}
+
Policy Status${esc(vm.policy_status || '—')}
+ `; + + const sshCommand = `ssh ${vm.username || 'root'}@${vm.guest_ip}`; + const sshAllowed = (vm.ports || []).some(port => port.port === 22); + $('#detail-ssh-command').textContent = sshCommand; + $('#detail-ssh-copy-btn').onclick = () => copyText(sshCommand); + $('#detail-ssh-hint').textContent = sshAllowed + ? 'Run this from a trusted local terminal; UVM does not expose guest passwords.' + : 'TCP/22 is not currently allowed by this VM policy.'; + + const ports = vm.ports || []; + $('#detail-ports').innerHTML = vm.ports === null + ? `

This legacy VM has no managed port policy.${vm.status === 'stopped' ? ' Define one before starting it.' : ' Stop it before defining one.'}

` + : ports.length + ? ports.map(p => `${p.port} / ${p.protocol}`).join(' ') + : '

No port rules configured.

'; + + // Actions visibility + $('#detail-start-btn').style.display = vm.status !== 'running' ? 'inline-flex' : 'none'; + $('#detail-start-btn').onclick = () => startVM(vm.id); + $('#detail-restart-btn').style.display = vm.status === 'running' ? 'inline-flex' : 'none'; + $('#detail-restart-btn').onclick = () => restartVM(vm.id); + $('#detail-stop-btn').style.display = vm.status === 'running' ? 'inline-flex' : 'none'; + $('#detail-stop-btn').onclick = () => stopVM(vm.id); + $('#detail-delete-btn').onclick = () => deleteVM(vm.id); + + // Port editing + const portsInput = $('#detail-ports-input'); + portsInput.value = ports.map(p => `${p.port}:${p.protocol}`).join(', '); + $('#detail-ports-edit').style.display = 'none'; + $('#detail-ports').style.display = 'block'; + const canSetLegacyPorts = vm.ports === null && vm.status === 'stopped'; + $('#detail-ports-edit-btn').style.display = vm.ports !== null || canSetLegacyPorts ? 'inline-flex' : 'none'; + $('#detail-ports-edit-btn').onclick = () => { + $('#detail-ports-edit').style.display = 'block'; + $('#detail-ports').style.display = 'none'; + }; + $('#detail-ports-cancel').onclick = () => { + $('#detail-ports-edit').style.display = 'none'; + $('#detail-ports').style.display = 'block'; + }; + $('#detail-ports-set').onclick = () => updatePorts(vm.id, 'set'); + $('#detail-ports-set').style.display = 'inline-flex'; + $('#detail-ports-open').onclick = () => updatePorts(vm.id, 'open'); + $('#detail-ports-open').style.display = vm.ports !== null ? 'inline-flex' : 'none'; + $('#detail-ports-close').onclick = () => updatePorts(vm.id, 'close'); + $('#detail-ports-close').style.display = vm.ports !== null ? 'inline-flex' : 'none'; + $('#detail-ports-sync-btn').style.display = vm.ports !== null ? 'inline-flex' : 'none'; + $('#detail-ports-sync-btn').onclick = () => syncPorts(vm.id); + $('#detail-log-refresh-btn').onclick = () => loadVmLogs(vm.id); + loadVmLogs(vm.id); + } + + async function loadVmLogs(id) { + const output = $('#detail-log-output'); + const meta = $('#detail-log-meta'); + output.textContent = 'Loading recent log entries…'; + meta.textContent = 'Showing up to 100 recent lines.'; + try { + const log = await api(`/vms/${encodeURIComponent(id)}/logs?tail=100`); + if (state.currentVmId !== id) return; + output.textContent = log.content || 'No log entries yet.'; + meta.textContent = log.truncated + ? 'Showing the available tail; an oversized log line was truncated.' + : 'Showing up to 100 recent lines.'; + } catch (e) { + if (state.currentVmId !== id) return; + output.textContent = 'Log unavailable.'; + meta.textContent = e.message; + } + } + + // ─ Actions ─ + async function startVM(id) { + try { + await api(`/vms/${id}/start`, { method: 'POST' }); + toast('VM started'); + await loadVms(); + if (state.currentPage === 'vm-detail' && state.currentVmId === id) renderVMDetail(id); + } catch (e) { toast('Start failed: ' + e.message, 'error'); } + } + + async function restartVM(id) { + showConfirm(`Restart ${esc(id)}?`, 'The VM will be stopped and then started again.', async () => { + try { + await api(`/vms/${id}/restart`, { method: 'POST' }); + toast('VM restarted'); + await loadVms(); + if (state.currentPage === 'vm-detail' && state.currentVmId === id) renderVMDetail(id); + } catch (e) { toast('Restart failed: ' + e.message, 'error'); } + }); + } + + async function stopVM(id) { + showConfirm(`Stop ${esc(id)}?`, 'The VM will be sent SIGTERM and SIGKILL if necessary. Guest data is retained.', async () => { + try { + await api(`/vms/${id}/stop`, { method: 'POST' }); + toast('VM stopped'); + await loadVms(); + if (state.currentPage === 'vm-detail' && state.currentVmId === id) renderVMDetail(id); + } catch (e) { toast('Stop failed: ' + e.message, 'error'); } + }); + } + + async function deleteVM(id) { + showConfirm(`Delete ${esc(id)}?`, 'This permanently removes the VM disk and releases its name and IP.', async () => { + try { + await api(`/vms/${id}`, { method: 'DELETE' }); + toast('VM deleted'); + if (state.currentPage === 'vm-detail') location.hash = '#vms'; + await loadVms(); + } catch (e) { toast('Delete failed: ' + e.message, 'error'); } + }); + } + + async function updatePorts(id, mode) { + let ports; + try { + ports = parsePorts($('#detail-ports-input').value); + } catch (e) { + toast(e.message, 'error'); + return; + } + const payload = { mode, ports }; + try { + await api(`/vms/${id}/ports`, { method: 'POST', body: JSON.stringify(payload) }); + toast({ set: 'Ports updated', open: 'Ports opened', close: 'Ports closed' }[mode]); + await loadVms(); + if (state.currentPage === 'vm-detail' && state.currentVmId === id) renderVMDetail(id); + } catch (e) { toast('Port update failed: ' + e.message, 'error'); } + } + + async function syncPorts(id) { + try { + await api(`/vms/${id}/ports/sync`, { method: 'POST' }); + toast('Ports synced'); + await loadVms(); + if (state.currentPage === 'vm-detail' && state.currentVmId === id) renderVMDetail(id); + } catch (e) { toast('Port sync failed: ' + e.message, 'error'); } + } + + // ─ Create VM ─ + $('#create-submit').addEventListener('click', async () => { + let ports; + try { + ports = parsePorts($('#create-ports').value); + } catch (e) { + showCreateError(e.message); + return; + } + const body = { + name: $('#create-name').value.trim() || undefined, + cpu: parseInt($('#create-cpu').value, 10) || 1, + ram: $('#create-ram').value.trim() || '512M', + guest_ip: $('#create-guest-ip').value.trim() || undefined, + username: $('#create-username').value.trim() || undefined, + password: $('#create-password').value, + ports, + no_default_ports: $('#create-no-default-ports').checked, + }; + if (!body.password) { showCreateError('Password is required.'); return; } + const errEl = $('#create-error'); + errEl.classList.add('hidden'); + try { + await api('/vms', { method: 'POST', body: JSON.stringify(body) }); + toast('VM created successfully'); + resetCreateForm(); + location.hash = '#vms'; + await loadVms(); + } catch (e) { + showCreateError(e.message); + } + }); + + $('#create-cancel').addEventListener('click', () => { history.back(); }); + + function parsePorts(val) { + if (!val.trim()) return []; + return val.split(',').map((entry) => { + const parts = entry.trim().split(':'); + const port = Number(parts[0]); + const protocol = (parts[1] || 'TCP').trim().toUpperCase(); + if ( + parts.length > 2 + || !Number.isInteger(port) + || port < 1 + || port > 65535 + || !['TCP', 'HTTP', 'HTTPS'].includes(protocol) + ) { + throw new Error(`Invalid port rule: ${entry.trim()}`); + } + return { port, protocol }; + }); + } + + async function copyText(value) { + try { + await navigator.clipboard.writeText(value); + toast('Command copied'); + } catch { + const field = document.createElement('textarea'); + field.value = value; + field.style.position = 'fixed'; + field.style.opacity = '0'; + document.body.appendChild(field); + field.select(); + document.execCommand('copy'); + field.remove(); + toast('Command copied'); + } + } + + function showCreateError(msg) { + const el = $('#create-error'); + el.textContent = msg; + el.classList.remove('hidden'); + } + + function resetCreateForm() { + $('#create-name').value = ''; + $('#create-cpu').value = '1'; + $('#create-ram').value = '512M'; + $('#create-guest-ip').value = ''; + $('#create-username').value = 'root'; + $('#create-password').value = 'root'; + $('#create-ports').value = '22:TCP'; + $('#create-no-default-ports').checked = false; + $('#create-error').classList.add('hidden'); + } + + // ─ System ─ + function renderSystemHealth() { + const wrap = $('#system-health'); + if (!state.health) { + wrap.innerHTML = 'Checking…

Send a test request to the management API.

'; + return; + } + wrap.innerHTML = `Healthy

API responding normally.

`; + } + + $('#install-btn').addEventListener('click', async () => { + const force = $('#install-force').checked; + const result = $('#install-result'); + result.classList.remove('hidden'); + result.textContent = 'Installing…'; + try { + const data = await api('/install', { method: 'POST', body: JSON.stringify({ force }) }); + result.textContent = JSON.stringify(data, null, 2); + toast('Installation completed'); + } catch (e) { + result.textContent = 'Error: ' + e.message; + toast('Installation failed: ' + e.message, 'error'); + } + }); + $('#diagnostics-refresh-btn').addEventListener('click', loadDiagnostics); + + // ─ Router ─ + function route() { + const hash = location.hash.slice(1) || 'overview'; + if (hash.startsWith('vm/')) { + showPage('vm-detail'); + renderVMDetail(hash.slice(3)); + } else if (hash === 'vms') { + showPage('vms'); + renderVMsTable(); + } else if (hash === 'create') { + showPage('create'); + } else if (hash === 'system') { + showPage('system'); + $('#info-api-url').textContent = state.baseUrl || '—'; + $('#info-observed').textContent = new Date().toLocaleString(); + loadDiagnostics(); + } else { + showPage('overview'); + } + } + + // ─ Modal ─ + function showConfirm(title, body, onOk) { + $('#confirm-title').innerHTML = title; + $('#confirm-body').textContent = body; + $('#confirm-modal').classList.add('open'); + + const okHandler = async () => { + $('#confirm-modal').classList.remove('open'); + cleanup(); + await onOk(); + }; + const cancelHandler = () => { + $('#confirm-modal').classList.remove('open'); + cleanup(); + }; + + function cleanup() { + $('#confirm-ok').removeEventListener('click', okHandler); + $('#confirm-cancel').removeEventListener('click', cancelHandler); + $('#confirm-modal .modal-overlay').removeEventListener('click', cancelHandler); + } + + $('#confirm-ok').addEventListener('click', okHandler); + $('#confirm-cancel').addEventListener('click', cancelHandler); + $('#confirm-modal .modal-overlay').addEventListener('click', cancelHandler); + } + + // ─ Refresh ─ + function startRefresh() { + loadVms(); + loadHealth(); + state.refreshTimer = setInterval(() => { loadVms(); loadHealth(); }, 5000); + } + function stopRefresh() { + if (state.refreshTimer) clearInterval(state.refreshTimer); + state.refreshTimer = null; + } + + $('#refresh-btn').addEventListener('click', () => { + loadVms(); + loadHealth(); + if (state.currentPage === 'system') loadDiagnostics(); + toast('Refreshed'); + }); + $('#create-vm-btn').addEventListener('click', () => { location.hash = '#create'; }); + + // Search / Filter + $('#vm-search').addEventListener('input', renderVMsTable); + $('#vm-filter-status').addEventListener('change', renderVMsTable); + $('#vm-select-all').addEventListener('change', (event) => { + $$('input[data-vm-select]', $('#vms-table tbody')).forEach((checkbox) => { + if (event.target.checked) state.selectedVmIds.add(checkbox.dataset.vmSelect); + else state.selectedVmIds.delete(checkbox.dataset.vmSelect); + }); + renderVMsTable(); + }); + $('#vms-table tbody').addEventListener('click', (event) => { + const selection = event.target.closest('input[data-vm-select]'); + if (selection) { + if (selection.checked) state.selectedVmIds.add(selection.dataset.vmSelect); + else state.selectedVmIds.delete(selection.dataset.vmSelect); + renderVMsTable(); + return; + } + const button = event.target.closest('button[data-vm-action]'); + if (!button) return; + + const { vmAction, vmId } = button.dataset; + if (vmAction === 'view') location.hash = `#vm/${vmId}`; + if (vmAction === 'start') startVM(vmId); + if (vmAction === 'restart') restartVM(vmId); + if (vmAction === 'stop') stopVM(vmId); + if (vmAction === 'delete') deleteVM(vmId); + }); + $('#bulk-run-btn').addEventListener('click', () => { + const action = $('#bulk-action').value; + const ids = [...state.selectedVmIds]; + const labels = { + start: 'Start', + restart: 'Restart', + stop: 'Stop', + 'ports-sync': 'Sync ports for', + delete: 'Delete', + }; + const detail = action === 'delete' + ? 'This permanently removes every selected VM disk and releases its resources.' + : `${labels[action]} ${ids.length} selected VM${ids.length === 1 ? '' : 's'}.`; + showConfirm( + `${labels[action]} ${ids.length} VM${ids.length === 1 ? '' : 's'}?`, + detail, + async () => { + try { + const results = await api('/vms/actions', { + method: 'POST', + body: JSON.stringify({ action, identifiers: ids }), + }); + const failed = results.filter(result => result.outcome !== 'succeeded'); + state.selectedVmIds.clear(); + await loadVms(); + toast( + failed.length + ? `${results.length - failed.length} succeeded, ${failed.length} failed` + : `${results.length} VM${results.length === 1 ? '' : 's'} updated`, + failed.length ? 'error' : 'success', + ); + } catch (e) { + toast(`Bulk ${action} failed: ${e.message}`, 'error'); + } + }, + ); + }); + + // ─ Utils ─ + function esc(s) { + if (s == null) return ''; + return String(s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + } + + // ─ Boot ─ + window.addEventListener('hashchange', route); + initAuth(); + route(); +})(); diff --git a/uvm/lifecycle.py b/uvm/lifecycle.py index b21ce0e..7182f79 100644 --- a/uvm/lifecycle.py +++ b/uvm/lifecycle.py @@ -2,6 +2,7 @@ from __future__ import annotations +import os import re import shutil import time @@ -39,6 +40,15 @@ class OperationResult: error: str | None = None +@dataclass(frozen=True, slots=True) +class LogTail: + content: str + truncated: bool + + +_MAX_LOG_TAIL_BYTES = 262_144 + + class LifecycleService: """All mutating entry points use operation lock -> short state transaction.""" @@ -415,6 +425,41 @@ class LifecycleService: self._validate_paths(vm) return Path(vm.log) + def read_log_tail(self, identifier: str, *, tail: int) -> LogTail: + """Read a bounded tail from a validated VM log without following it.""" + if type(tail) is not int or tail < 0: + raise ValidationError("log tail must be a non-negative integer") + path = self.log_path(identifier) + try: + with path.open("rb") as stream: + end = stream.seek(0, os.SEEK_END) + if tail == 0: + return LogTail(content="", truncated=False) + start = max(0, end - _MAX_LOG_TAIL_BYTES) + previous = b"" + if start: + stream.seek(start - 1) + previous = stream.read(1) + stream.seek(start) + content = stream.read(end - start) + except OSError as error: + raise UvmError("could not read VM log") from error + + truncated = False + if start and previous != b"\n": + newline = content.find(b"\n") + if newline == -1: + truncated = True + else: + content = content[newline + 1:] + lines = content.splitlines(keepends=True) + if start and len(lines) < tail: + truncated = True + return LogTail( + content=b"".join(lines[-tail:]).decode("utf-8", errors="replace"), + truncated=truncated, + ) + def diagnostics(self) -> list[dict]: checks = [("root", require_root), ("kvm", check_kvm), ("Firecracker", self._verify_firecracker), diff --git a/uvm/routers/dashboard.py b/uvm/routers/dashboard.py new file mode 100644 index 0000000..f512852 --- /dev/null +++ b/uvm/routers/dashboard.py @@ -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") diff --git a/uvm/routers/diagnostics.py b/uvm/routers/diagnostics.py new file mode 100644 index 0000000..5603d38 --- /dev/null +++ b/uvm/routers/diagnostics.py @@ -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()] diff --git a/uvm/routers/vms.py b/uvm/routers/vms.py index 6f93344..f432fc6 100644 --- a/uvm/routers/vms.py +++ b/uvm/routers/vms.py @@ -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, diff --git a/uvm/server.py b/uvm/server.py index fa2cd7e..95b0145 100644 --- a/uvm/server.py +++ b/uvm/server.py @@ -27,7 +27,8 @@ def create_api(application: Application | None = None, *, host: str | None = Non try: from fastapi import FastAPI - from fastapi.responses import JSONResponse + from fastapi.responses import JSONResponse, RedirectResponse + from fastapi.staticfiles import StaticFiles except ModuleNotFoundError as error: raise UvmError( "FastAPI server support is not installed. Install this project with its dependencies." @@ -36,6 +37,8 @@ def create_api(application: Application | None = None, *, host: str | None = Non from .routers.health import router as health_router from .routers.installation import router as installation_router from .routers.vms import router as vms_router + from .routers.diagnostics import router as diagnostics_router + from .routers.dashboard import router as dashboard_router api = FastAPI( title="uvm", @@ -54,6 +57,16 @@ def create_api(application: Application | None = None, *, host: str | None = Non api.include_router(health_router) api.include_router(installation_router) api.include_router(vms_router) + api.include_router(diagnostics_router) + + static_dir = Path(__file__).parent / "dashboard" / "static" + api.mount("/dashboard/static", StaticFiles(directory=static_dir), name="dashboard_static") + api.include_router(dashboard_router, prefix="/dashboard") + + @api.get("/") + async def root() -> Any: + return RedirectResponse(url="/dashboard") + return api