Dev #1

Merged
kstyagi merged 5 commits from dev into main 2026-09-20 03:41:02 +00:00
17 changed files with 2285 additions and 69 deletions
Showing only changes of commit b3b1b527e1 - Show all commits
+43 -15
View File
@@ -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
+32 -11
View File
@@ -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:
+130 -19
View File
@@ -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/<vm-id>/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/<vm-id>/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/<vm-id>/ports"
# Reapply a policy after an external firewall change.
curl -X POST \
-H "X-UVM-Token: $UVM_API_TOKEN" \
"$UVM_API_URL/vms/<vm-id>/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
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 360 KiB

+4 -1
View File
@@ -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"]
+17
View File
@@ -600,6 +600,23 @@ class LifecycleInspectionTests(LifecycleTestCase):
self.assertFalse(settings.base.exists())
self.assertEqual(self.host.events, [])
def test_read_log_tail_is_bounded_and_marks_an_oversized_line(self) -> None:
vm = self.create_vm(name="log-reader")
path = Path(vm.log)
path.write_bytes(b"old\n" * 100_000 + b"last\n" * 2)
tail = self.service.read_log_tail(vm.id, tail=2)
self.assertEqual(tail.content, "last\n" * 2)
self.assertFalse(tail.truncated)
with self.assertRaisesRegex(ValidationError, "non-negative"):
self.service.read_log_tail(vm.id, tail=-1)
path.write_bytes(b"x" * 300_000)
truncated = self.service.read_log_tail(vm.id, tail=1)
self.assertEqual(len(truncated.content), 262_144)
self.assertTrue(truncated.truncated)
def test_diagnostics_collects_failures_without_host_commands_or_lifecycle_effects(self) -> None:
self.check_kvm.side_effect = UvmError("KVM unavailable")
self.host.failures["firewall.check_available", None] = UvmError("nft unavailable")
+207 -12
View File
@@ -13,7 +13,7 @@ from uvm.cli import build_parser, main
from uvm.config import Settings
from uvm.domain import PortRule, VmRecord, VmSpec
from uvm.errors import ConfigurationError, ConflictError, NotFoundError, UvmError, ValidationError
from uvm.lifecycle import ListedVm
from uvm.lifecycle import ListedVm, OperationResult
from uvm.server import _http_status_for, create_api, run_server
@@ -199,7 +199,21 @@ class FakeLifecycle:
self.policy_status = "enforced"
return self.vm
def stop(self, identifier: str) -> VmRecord:
def start(self, identifier: str) -> VmRecord:
self.inspect(identifier)
self.vm.status = "running"
self.observed_status = "running"
self.policy_status = "enforced"
return self.vm
def restart(self, identifier: str, *, timeout: float | None = None) -> VmRecord:
self.inspect(identifier)
self.vm.status = "running"
self.observed_status = "running"
self.policy_status = "enforced"
return self.vm
def stop(self, identifier: str, *, timeout: float | None = None) -> VmRecord:
self.inspect(identifier)
self.vm.status = "stopped"
self.vm.pid = None
@@ -208,10 +222,55 @@ class FakeLifecycle:
self.policy_status = "inactive"
return self.vm
def destroy(self, identifier: str) -> VmRecord:
def destroy(self, identifier: str, *, timeout: float | None = None) -> VmRecord:
self.inspect(identifier)
return self.vm
def read_log_tail(self, identifier: str, *, tail: int) -> SimpleNamespace:
self.inspect(identifier)
return SimpleNamespace(content=f"last {tail} log lines", truncated=False)
def diagnostics(self) -> list[dict]:
return [{"name": "kvm", "ok": True, "message": "available"}]
def resolve_targets(self, identifiers: list[str], *, all_vms: bool = False) -> list[VmRecord]:
if all_vms and identifiers:
raise ValidationError("--all cannot be combined with explicit VM targets")
if not all_vms and not identifiers:
raise ValidationError("specify a VM or --all")
if all_vms:
return [self.vm]
return [self.inspect(identifier).vm for identifier in identifiers]
def run_many(
self,
action: str,
vm_ids: list[str],
*,
timeout: float | None = None,
) -> list[OperationResult]:
return [OperationResult(vm_id, action, "succeeded") for vm_id in vm_ids]
def set_ports(self, identifier: str, ports: tuple[PortRule, ...], *, mode: str = "set") -> VmRecord:
self.inspect(identifier)
if mode == "set":
self.vm.ports = ports
elif mode == "open":
existing = {p.port: p for p in self.vm.ports or ()}
for p in ports:
existing.setdefault(p.port, p)
self.vm.ports = tuple(sorted(existing.values(), key=lambda r: r.port))
elif mode == "close":
drop = {p.port for p in ports}
self.vm.ports = tuple(p for p in self.vm.ports or () if p.port not in drop)
self.policy_status = "enforced"
return self.vm
def sync_ports(self, identifier: str) -> VmRecord:
self.inspect(identifier)
self.policy_status = "enforced"
return self.vm
@unittest.skipUnless(FASTAPI_AVAILABLE, "FastAPI and httpx are required for API tests")
class ApiRouterTests(unittest.TestCase):
@@ -236,18 +295,40 @@ class ApiRouterTests(unittest.TestCase):
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), {"status": "ok"})
def test_dashboard_and_assets_are_available_without_credentials(self) -> None:
root = self.client.get("/", follow_redirects=False)
dashboard = self.client.get("/dashboard")
stylesheet = self.client.get("/dashboard/static/css/dashboard.css")
script = self.client.get("/dashboard/static/js/dashboard.js")
self.assertEqual(root.status_code, 307)
self.assertEqual(root.headers["location"], "/dashboard")
self.assertEqual(dashboard.status_code, 200)
self.assertIn("UVM Dashboard", dashboard.text)
self.assertEqual(stylesheet.status_code, 200)
self.assertIn("--bg-body", stylesheet.text)
self.assertEqual(script.status_code, 200)
self.assertIn("sessionStorage", script.text)
def test_vm_routes_require_and_accept_the_api_token(self) -> None:
for method, path in (
("GET", "/vms"),
("POST", "/vms"),
("GET", "/vms/test-vm"),
("POST", "/vms/test-vm/stop"),
("DELETE", "/vms/test-vm"),
("POST", "/install"),
for method, path, payload in (
("GET", "/vms", {}),
("POST", "/vms", {}),
("GET", "/vms/test-vm", {}),
("GET", "/vms/test-vm/logs", {}),
("POST", "/vms/actions", {"action": "start", "identifiers": ["vm-test"]}),
("POST", "/vms/test-vm/start", {}),
("POST", "/vms/test-vm/restart", {}),
("POST", "/vms/test-vm/stop", {}),
("DELETE", "/vms/test-vm", {}),
("POST", "/vms/test-vm/ports", {}),
("POST", "/vms/test-vm/ports/sync", {}),
("GET", "/diagnostics", {}),
("POST", "/install", {}),
):
for headers in ({}, {"X-UVM-Token": "wrong-token"}):
with self.subTest(method=method, path=path, headers=headers):
response = self.client.request(method, path, headers=headers, json={})
response = self.client.request(method, path, headers=headers, json=payload)
self.assertEqual(response.status_code, 401)
self.assertIsNone(self.lifecycle.last_spec)
self.assertEqual(self.lifecycle.vm.status, "running")
@@ -435,6 +516,105 @@ class ApiRouterTests(unittest.TestCase):
self.assertEqual(destroyed.json()["id"], "vm-test")
destroy.assert_called_with(identifier)
def test_lifecycle_timeouts_are_forwarded_when_supplied(self) -> None:
with (
patch.object(self.lifecycle, "restart", wraps=self.lifecycle.restart) as restart,
patch.object(self.lifecycle, "stop", wraps=self.lifecycle.stop) as stop,
patch.object(self.lifecycle, "destroy", wraps=self.lifecycle.destroy) as destroy,
):
self.assertEqual(
self.client.post("/vms/vm-test/restart?timeout=1.5", headers=self.headers).status_code,
200,
)
self.assertEqual(
self.client.post("/vms/vm-test/stop?timeout=2.5", headers=self.headers).status_code,
200,
)
self.assertEqual(
self.client.delete("/vms/vm-test?timeout=3.5", headers=self.headers).status_code,
200,
)
restart.assert_called_once_with("vm-test", timeout=1.5)
stop.assert_called_once_with("vm-test", timeout=2.5)
destroy.assert_called_once_with("vm-test", timeout=3.5)
def test_start_restart_and_port_routes_update_vm_state(self) -> None:
started = self.client.post("/vms/vm-test/start", headers=self.headers)
self.assertEqual(started.status_code, 200)
self.assertEqual(started.json()["status"], "running")
self.assertEqual(started.json()["policy_status"], "enforced")
restarted = self.client.post("/vms/vm-test/restart", headers=self.headers)
self.assertEqual(restarted.status_code, 200)
self.assertEqual(restarted.json()["status"], "running")
self.lifecycle.vm.status = "stopped"
self.lifecycle.policy_status = "inactive"
sync = self.client.post("/vms/vm-test/ports/sync", headers=self.headers)
self.assertEqual(sync.status_code, 200)
self.assertEqual(sync.json()["id"], "vm-test")
self.assertEqual(sync.json()["policy_status"], "inactive")
def test_bulk_action_resolves_targets_before_running_fixed_ids(self) -> None:
with (
patch.object(self.lifecycle, "resolve_targets", wraps=self.lifecycle.resolve_targets) as resolve,
patch.object(self.lifecycle, "run_many", wraps=self.lifecycle.run_many) as run_many,
):
response = self.client.post(
"/vms/actions",
headers=self.headers,
json={"action": "restart", "identifiers": ["test-vm"], "timeout": 1.5},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), [{"id": "vm-test", "action": "restart", "outcome": "succeeded", "error": None}])
resolve.assert_called_once_with(["test-vm"], all_vms=False)
run_many.assert_called_once_with("restart", ["vm-test"], timeout=1.5)
def test_bulk_action_requires_exactly_one_selection_strategy(self) -> None:
for payload in (
{"action": "start"},
{"action": "start", "identifiers": ["vm-test"], "all_vms": True},
):
with self.subTest(payload=payload):
response = self.client.post("/vms/actions", headers=self.headers, json=payload)
self.assertEqual(response.status_code, 422)
def test_log_and_diagnostic_routes_return_bounded_observability_data(self) -> None:
logs = self.client.get("/vms/test-vm/logs?tail=5", headers=self.headers)
diagnostics = self.client.get("/diagnostics", headers=self.headers)
self.assertEqual(logs.status_code, 200)
self.assertEqual(logs.json(), {"content": "last 5 log lines", "truncated": False})
self.assertEqual(diagnostics.status_code, 200)
self.assertEqual(diagnostics.json(), [{"name": "kvm", "ok": True, "message": "available"}])
def test_port_update_modes_mutate_rules(self) -> None:
set_resp = self.client.post(
"/vms/vm-test/ports",
headers=self.headers,
json={"mode": "set", "ports": [{"port": 80, "protocol": "HTTP"}, {"port": 443, "protocol": "HTTPS"}]},
)
self.assertEqual(set_resp.status_code, 200)
self.assertEqual(set_resp.json()["ports"], [{"port": 80, "protocol": "HTTP"}, {"port": 443, "protocol": "HTTPS"}])
open_resp = self.client.post(
"/vms/vm-test/ports",
headers=self.headers,
json={"mode": "open", "ports": [{"port": 22, "protocol": "TCP"}]},
)
self.assertEqual(open_resp.status_code, 200)
self.assertEqual(open_resp.json()["ports"], [{"port": 22, "protocol": "TCP"}, {"port": 80, "protocol": "HTTP"}, {"port": 443, "protocol": "HTTPS"}])
close_resp = self.client.post(
"/vms/vm-test/ports",
headers=self.headers,
json={"mode": "close", "ports": [{"port": 80, "protocol": "HTTP"}]},
)
self.assertEqual(close_resp.status_code, 200)
self.assertEqual(close_resp.json()["ports"], [{"port": 22, "protocol": "TCP"}, {"port": 443, "protocol": "HTTPS"}])
def test_create_claims_enforcement_only_for_successful_managed_vms(self) -> None:
for ports, status, operation, policy_status in (
(None, "running", None, "unmanaged"),
@@ -456,9 +636,15 @@ class ApiRouterTests(unittest.TestCase):
for method, path, operation in (
("GET", "/vms", "list_vms"),
("GET", "/vms/test-vm", "inspect"),
("GET", "/vms/test-vm/logs", "read_log_tail"),
("POST", "/vms", "create"),
("POST", "/vms/test-vm/start", "start"),
("POST", "/vms/test-vm/restart", "restart"),
("POST", "/vms/test-vm/stop", "stop"),
("DELETE", "/vms/test-vm", "destroy"),
("POST", "/vms/test-vm/ports", "set_ports"),
("POST", "/vms/test-vm/ports/sync", "sync_ports"),
("GET", "/diagnostics", "diagnostics"),
):
for error, expected in (
(NotFoundError("unknown guest"), 404),
@@ -480,19 +666,28 @@ class ApiRouterTests(unittest.TestCase):
self.assertEqual(response.status_code, 404)
self.assertEqual(response.json(), {"error": {"message": "VM not found: missing"}})
def test_api_schema_keeps_only_existing_operations(self) -> None:
def test_api_schema_exposes_dashboard_and_management_operations(self) -> None:
schema = self.client.get("/openapi.json").json()
self.assertEqual(
{(path, method) for path, methods in schema["paths"].items() for method in methods},
{
("/", "get"),
("/dashboard", "get"),
("/health", "get"),
("/install", "post"),
("/vms", "get"),
("/vms", "post"),
("/vms/actions", "post"),
("/vms/{identifier}", "get"),
("/vms/{identifier}/logs", "get"),
("/vms/{vm_id}/start", "post"),
("/vms/{vm_id}/restart", "post"),
("/vms/{vm_id}/stop", "post"),
("/vms/{vm_id}", "delete"),
("/vms/{vm_id}/ports", "post"),
("/vms/{vm_id}/ports/sync", "post"),
("/diagnostics", "get"),
},
)
self.assertEqual(
+40
View File
@@ -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,
*,
+1
View File
@@ -0,0 +1 @@
# UVM Dashboard static assets package.
+470
View File
@@ -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; }
+379
View File
@@ -0,0 +1,379 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>UVM Dashboard</title>
<link rel="stylesheet" href="/dashboard/static/css/dashboard.css" />
</head>
<body>
<div id="app">
<!-- Auth Screen -->
<div id="auth-screen" class="screen active">
<div class="auth-card">
<div class="auth-header">
<div class="logo">
<svg width="32" height="32" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="8" fill="currentColor" />
<path d="M9 11h14M9 16h10M9 21h6" stroke="#0b0b0f" stroke-width="2" stroke-linecap="round"/>
</svg>
<span>UVM</span>
</div>
<h1>Welcome back</h1>
<p>Enter your management API token to access the dashboard.</p>
</div>
<div class="auth-form">
<div class="field">
<label for="token-input">API Token</label>
<input id="token-input" type="password" placeholder="Paste your UVM_API_TOKEN here" autocomplete="off" />
</div>
<button id="auth-btn" class="btn btn-primary btn-lg">Connect</button>
<p id="auth-error" class="error-text"></p>
</div>
</div>
</div>
<!-- Main App -->
<div id="main-app" class="screen">
<aside class="sidebar">
<div class="sidebar-brand">
<div class="logo">
<svg width="28" height="28" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="8" fill="currentColor" />
<path d="M9 11h14M9 16h10M9 21h6" stroke="#0b0b0f" stroke-width="2" stroke-linecap="round"/>
</svg>
<span>UVM</span>
</div>
</div>
<nav class="sidebar-nav">
<a href="#overview" class="nav-item active" data-page="overview">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>
<span>Overview</span>
</a>
<a href="#vms" class="nav-item" data-page="vms">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>
<span>Virtual Machines</span>
</a>
<a href="#system" class="nav-item" data-page="system">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
<span>System</span>
</a>
</nav>
<div class="sidebar-footer">
<div class="connection-status">
<span class="status-dot" id="conn-dot"></span>
<span class="status-label" id="conn-label">Connected</span>
</div>
<button id="logout-btn" class="btn btn-ghost btn-sm">Disconnect</button>
</div>
</aside>
<main class="main">
<header class="topbar">
<div class="topbar-left">
<h2 id="page-title">Overview</h2>
</div>
<div class="topbar-right">
<button id="refresh-btn" class="btn btn-ghost btn-icon" title="Refresh">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.5 2v6h-6M2.5 22v-6h6M2 11.5a10 10 0 0118.8-4.3M22 12.5a10 10 0 01-18.8 4.3"/></svg>
</button>
<button id="create-vm-btn" class="btn btn-primary">+ New VM</button>
</div>
</header>
<div class="content">
<!-- Overview Page -->
<section id="page-overview" class="page active">
<div class="stats-grid">
<div class="stat-card">
<div class="stat-header">
<span class="stat-label">Total VMs</span>
<div class="stat-icon icon-blue">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>
</div>
</div>
<div class="stat-value" id="stat-total">—</div>
<div class="stat-meta">registered VMs</div>
</div>
<div class="stat-card">
<div class="stat-header">
<span class="stat-label">Running</span>
<div class="stat-icon icon-green">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 11-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
</div>
</div>
<div class="stat-value text-green" id="stat-running">—</div>
<div class="stat-meta">active microVMs</div>
</div>
<div class="stat-card">
<div class="stat-header">
<span class="stat-label">Stopped</span>
<div class="stat-icon icon-amber">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg>
</div>
</div>
<div class="stat-value text-amber" id="stat-stopped">—</div>
<div class="stat-meta">inactive microVMs</div>
</div>
<div class="stat-card">
<div class="stat-header">
<span class="stat-label">API Health</span>
<div class="stat-icon icon-purple">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>
</div>
</div>
<div class="stat-value" id="stat-health">—</div>
<div class="stat-meta">last check <span id="health-ago">—</span></div>
</div>
</div>
<div class="section-header">
<h3>Recent Virtual Machines</h3>
<a href="#vms" class="link">View all</a>
</div>
<div class="vm-grid" id="overview-vm-list">
<div class="empty-state">
<p>No virtual machines yet.</p>
<button class="btn btn-primary btn-sm" onclick="location.hash='#create'">Create your first VM</button>
</div>
</div>
</section>
<!-- VMs Page -->
<section id="page-vms" class="page">
<div class="card">
<div class="card-toolbar">
<div class="search-box">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input id="vm-search" type="text" placeholder="Search by name, ID, or IP..." />
</div>
<div class="toolbar-actions">
<div id="bulk-controls" class="bulk-controls">
<span id="bulk-selection-count">0 selected</span>
<select id="bulk-action" class="select">
<option value="start">Start</option>
<option value="restart">Restart</option>
<option value="stop">Stop</option>
<option value="ports-sync">Sync ports</option>
<option value="delete">Delete</option>
</select>
<button id="bulk-run-btn" class="btn btn-ghost btn-sm">Run</button>
</div>
<select id="vm-filter-status" class="select">
<option value="all">All statuses</option>
<option value="running">Running</option>
<option value="stopped">Stopped</option>
</select>
</div>
</div>
<div class="table-wrap">
<table class="data-table" id="vms-table">
<thead>
<tr>
<th class="selection"><input id="vm-select-all" type="checkbox" aria-label="Select all visible VMs" /></th>
<th>VM</th>
<th>Status</th>
<th>Guest IP</th>
<th>Resources</th>
<th>Ports</th>
<th>Policy</th>
<th class="actions">Actions</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
<div class="table-empty" id="vms-empty">
<div class="empty-state">
<p>No virtual machines found.</p>
<button class="btn btn-primary btn-sm" onclick="location.hash='#create'">Create VM</button>
</div>
</div>
</div>
</section>
<!-- VM Detail Page -->
<section id="page-vm-detail" class="page">
<div class="vm-detail-header">
<button class="btn btn-ghost btn-sm back-btn" onclick="history.back()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
Back
</button>
<div class="vm-detail-title">
<h2 id="detail-name">—</h2>
<span class="badge" id="detail-status">—</span>
</div>
</div>
<div class="detail-grid">
<div class="card detail-card">
<h4 class="card-title">Configuration</h4>
<div class="detail-rows" id="detail-config">
</div>
</div>
<div class="card detail-card">
<h4 class="card-title">Networking</h4>
<div class="detail-rows" id="detail-network">
</div>
</div>
<div class="card detail-card">
<h4 class="card-title">SSH Access</h4>
<code id="detail-ssh-command" class="ssh-command">ssh root@10.42.0.2</code>
<button id="detail-ssh-copy-btn" class="btn btn-ghost btn-sm">Copy Command</button>
<p id="detail-ssh-hint" class="hint">Run this from a trusted local terminal.</p>
</div>
<div class="card detail-card wide">
<h4 class="card-title">Port Rules</h4>
<div class="port-list" id="detail-ports">
<p class="muted">No port rules configured.</p>
</div>
<div id="detail-ports-edit" class="port-editor">
<input id="detail-ports-input" class="port-editor-input" type="text" placeholder="e.g. 22:TCP,80:HTTP,443:HTTPS" />
<div class="port-editor-actions">
<button id="detail-ports-set" class="btn btn-primary btn-sm">Set</button>
<button id="detail-ports-open" class="btn btn-ghost btn-sm">Open</button>
<button id="detail-ports-close" class="btn btn-ghost btn-sm">Close</button>
<button id="detail-ports-cancel" class="btn btn-ghost btn-sm">Cancel</button>
</div>
</div>
<div class="port-actions">
<button id="detail-ports-edit-btn" class="btn btn-ghost btn-sm">Edit Ports</button>
<button id="detail-ports-sync-btn" class="btn btn-ghost btn-sm">Sync Ports</button>
</div>
</div>
<div class="card detail-card wide">
<div class="card-title-row">
<h4 class="card-title">Firecracker Log</h4>
<button id="detail-log-refresh-btn" class="btn btn-ghost btn-sm">Refresh Log</button>
</div>
<pre id="detail-log-output" class="log-output">Loading recent log entries…</pre>
<p id="detail-log-meta" class="hint">Showing up to 100 recent lines.</p>
</div>
<div class="card detail-card wide">
<h4 class="card-title">Actions</h4>
<div class="action-bar">
<button id="detail-start-btn" class="btn btn-primary">Start VM</button>
<button id="detail-restart-btn" class="btn btn-warning">Restart VM</button>
<button id="detail-stop-btn" class="btn btn-warning">Stop VM</button>
<button id="detail-delete-btn" class="btn btn-danger">Delete VM</button>
</div>
<p class="hint">Use the copied SSH command from a trusted local terminal.</p>
</div>
</div>
</section>
<!-- Create VM Page -->
<section id="page-create" class="page">
<div class="card create-card">
<h3 class="card-heading">Create Virtual Machine</h3>
<div class="form-grid">
<div class="field">
<label for="create-name">Name</label>
<input id="create-name" type="text" placeholder="e.g. web-server" />
</div>
<div class="field">
<label for="create-cpu">vCPUs</label>
<input id="create-cpu" type="number" min="1" value="1" />
</div>
<div class="field">
<label for="create-ram">RAM</label>
<input id="create-ram" type="text" placeholder="e.g. 512M or 1G" value="512M" />
</div>
<div class="field">
<label for="create-guest-ip">Guest IP <span class="hint-inline">(optional)</span></label>
<input id="create-guest-ip" type="text" placeholder="10.42.0.10" />
</div>
<div class="field">
<label for="create-username">Username</label>
<input id="create-username" type="text" placeholder="root" value="root" />
</div>
<div class="field">
<label for="create-password">Password</label>
<input id="create-password" type="password" placeholder="root" value="root" />
</div>
<div class="field full">
<label for="create-ports">Ports <span class="hint-inline">comma-separated, e.g. 22,80:HTTP,443:HTTPS</span></label>
<input id="create-ports" type="text" placeholder="22:TCP,80:HTTP,443:HTTPS" value="22:TCP" />
</div>
<div class="field full checkbox-field">
<label class="checkbox">
<input id="create-no-default-ports" type="checkbox" />
<span class="checkmark"></span>
<span class="checkbox-label">Do not add default SSH port (22)</span>
</label>
</div>
</div>
<div class="form-actions">
<button id="create-submit" class="btn btn-primary btn-lg">Create VM</button>
<button id="create-cancel" class="btn btn-ghost">Cancel</button>
</div>
<pre id="create-error" class="error-block"></pre>
</div>
</section>
<!-- System Page -->
<section id="page-system" class="page">
<div class="stats-grid two">
<div class="card system-card">
<h4 class="card-title">API Health</h4>
<div class="system-status" id="system-health">
<span class="status-badge badge-loading">Checking…</span>
<p class="muted">Send a test request to the management API.</p>
</div>
</div>
<div class="card system-card">
<h4 class="card-title">Installation</h4>
<p class="muted">Install or refresh host assets (Firecracker, kernel, rootfs). This is a privileged operation.</p>
<div class="system-actions">
<button id="install-btn" class="btn btn-primary">Install / Refresh</button>
<label class="checkbox inline">
<input id="install-force" type="checkbox" />
<span class="checkmark"></span>
<span class="checkbox-label">Force redownload</span>
</label>
</div>
<pre id="install-result" class="code-block hidden"></pre>
</div>
</div>
<div class="card wide">
<h4 class="card-title">Server Information</h4>
<div class="detail-rows" id="server-info">
<div class="detail-row"><span class="detail-key">Dashboard Version</span><span class="detail-value">1.0.0</span></div>
<div class="detail-row"><span class="detail-key">API URL</span><span class="detail-value" id="info-api-url">—</span></div>
<div class="detail-row"><span class="detail-key">Observed At</span><span class="detail-value" id="info-observed">—</span></div>
</div>
</div>
<div class="card wide">
<div class="card-title-row">
<h4 class="card-title">Host Diagnostics</h4>
<button id="diagnostics-refresh-btn" class="btn btn-ghost btn-sm">Run Checks</button>
</div>
<div id="diagnostics-list" class="diagnostics-list">
<p class="muted">Open System to run host readiness checks.</p>
</div>
</div>
</section>
</div>
</main>
</div>
<!-- Toast Container -->
<div id="toast-container"></div>
<!-- Confirm Modal -->
<div id="confirm-modal" class="modal">
<div class="modal-overlay"></div>
<div class="modal-card">
<h3 id="confirm-title">Are you sure?</h3>
<p id="confirm-body">This action cannot be undone.</p>
<div class="modal-actions">
<button id="confirm-cancel" class="btn btn-ghost">Cancel</button>
<button id="confirm-ok" class="btn btn-danger">Confirm</button>
</div>
</div>
</div>
</div>
<script src="/dashboard/static/js/dashboard.js"></script>
</body>
</html>
+739
View File
@@ -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 = '<p class="muted">Running host readiness checks…</p>';
try {
state.diagnostics = await api('/diagnostics');
renderDiagnostics();
} catch (e) {
state.diagnostics = null;
list.innerHTML = `<p class="muted">Diagnostics unavailable: ${esc(e.message)}</p>`;
}
}
function renderDiagnostics() {
const list = $('#diagnostics-list');
if (!state.diagnostics?.length) {
list.innerHTML = '<p class="muted">No diagnostic checks returned.</p>';
return;
}
list.innerHTML = state.diagnostics.map((check) => `
<div class="diagnostic-item">
<span class="diagnostic-mark ${check.ok ? 'ok' : 'error'}"></span>
<div>
<div class="diagnostic-name">${esc(check.name)}</div>
<div class="diagnostic-message">${esc(check.message)}</div>
</div>
</div>
`).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 = `
<div class="empty-state">
<p>No virtual machines yet.</p>
<button class="btn btn-primary btn-sm" onclick="location.hash='#create'">Create your first VM</button>
</div>`;
return;
}
const recent = state.vms.slice(0, 6);
container.innerHTML = recent.map(vm => `
<div class="vm-card" onclick="location.hash='#vm/${vm.id}'">
<div class="vm-card-header">
<div>
<div class="vm-card-name">${esc(vm.name || vm.id)}</div>
<div class="vm-card-ip">${esc(vm.guest_ip || '—')}</div>
</div>
<span class="badge badge-${statusClass(vm.status)}">${esc(vm.status || 'unknown')}</span>
</div>
<div class="vm-card-meta">
<span><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="2" width="20" height="8" rx="2"/><line x1="6" y1="6" x2="6.01" y2="6"/></svg> ${vm.cpu || 1} vCPU</span>
<span><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/></svg> ${formatRam(vm.ram_mib)}</span>
</div>
</div>
`).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 => `<span class="port-chip">${p.port}:${p.protocol}</span>`).join(' ') || '—';
const actionBtns = [];
if (vm.status === 'running') {
actionBtns.push(`<button class="btn btn-ghost btn-sm" data-vm-action="restart" data-vm-id="${esc(vm.id)}">Restart</button>`);
actionBtns.push(`<button class="btn btn-warning btn-sm" data-vm-action="stop" data-vm-id="${esc(vm.id)}">Stop</button>`);
} else {
actionBtns.push(`<button class="btn btn-primary btn-sm" data-vm-action="start" data-vm-id="${esc(vm.id)}">Start</button>`);
}
return `
<tr>
<td class="selection"><input type="checkbox" data-vm-select="${esc(vm.id)}" aria-label="Select ${esc(vm.name || vm.id)}" ${state.selectedVmIds.has(vm.id) ? 'checked' : ''} /></td>
<td>
<div style="font-weight:600;font-size:13px;">${esc(vm.name || '—')}</div>
<div style="font-size:11px;color:var(--text-muted);font-family:monospace;">${esc(vm.id)}</div>
</td>
<td><span class="badge badge-${statusClass(vm.status)}">${esc(vm.status || 'unknown')}</span></td>
<td style="font-family:monospace;font-size:12px;">${esc(vm.guest_ip || '—')}</td>
<td>${vm.cpu || 1} vCPU · ${formatRam(vm.ram_mib)}</td>
<td>${ports}</td>
<td><span class="badge badge-${vm.policy_status === 'enforced' ? 'running' : vm.policy_status === 'inactive' ? 'stopped' : 'unknown'}">${esc(vm.policy_status || '—')}</span></td>
<td class="actions">
<button class="btn btn-ghost btn-sm" data-vm-action="view" data-vm-id="${esc(vm.id)}">View</button>
${actionBtns.join('')}
<button class="btn btn-danger btn-sm" data-vm-action="delete" data-vm-id="${esc(vm.id)}">Delete</button>
</td>
</tr>
`;
}).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 = `
<div class="detail-row"><span class="detail-key">ID</span><span class="detail-value">${esc(vm.id)}</span></div>
<div class="detail-row"><span class="detail-key">Name</span><span class="detail-value">${esc(vm.name || '—')}</span></div>
<div class="detail-row"><span class="detail-key">Status</span><span class="detail-value">${esc(vm.status)}</span></div>
<div class="detail-row"><span class="detail-key">Operation</span><span class="detail-value">${esc(vm.operation || '—')}</span></div>
<div class="detail-row"><span class="detail-key">vCPUs</span><span class="detail-value">${vm.cpu || 1}</span></div>
<div class="detail-row"><span class="detail-key">RAM</span><span class="detail-value">${formatRam(vm.ram_mib)}</span></div>
<div class="detail-row"><span class="detail-key">Guest User</span><span class="detail-value">${esc(vm.username || '—')}</span></div>
`;
$('#detail-network').innerHTML = `
<div class="detail-row"><span class="detail-key">Guest IP</span><span class="detail-value">${esc(vm.guest_ip || '—')}</span></div>
<div class="detail-row"><span class="detail-key">Observed Status</span><span class="detail-value">${esc(vm.observed_status || '—')}</span></div>
<div class="detail-row"><span class="detail-key">Policy Status</span><span class="detail-value">${esc(vm.policy_status || '—')}</span></div>
`;
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
? `<p class="muted">This legacy VM has no managed port policy.${vm.status === 'stopped' ? ' Define one before starting it.' : ' Stop it before defining one.'}</p>`
: ports.length
? ports.map(p => `<span class="port-chip">${p.port} / ${p.protocol}</span>`).join(' ')
: '<p class="muted">No port rules configured.</p>';
// 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 <b>${esc(id)}</b>?`, '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 <b>${esc(id)}</b>?`, '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 <b>${esc(id)}</b>?`, '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 = '<span class="status-badge badge-loading">Checking…</span><p class="muted">Send a test request to the management API.</p>';
return;
}
wrap.innerHTML = `<span class="status-badge badge-ok">Healthy</span><p class="muted">API responding normally.</p>`;
}
$('#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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// ─ Boot ─
window.addEventListener('hashchange', route);
initAuth();
route();
})();
+45
View File
@@ -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),
+17
View File
@@ -0,0 +1,17 @@
"""Dashboard frontend routes."""
from __future__ import annotations
from pathlib import Path
from fastapi import APIRouter
from fastapi.responses import HTMLResponse
router = APIRouter()
_DASHBOARD_HTML = Path(__file__).parent.parent / "dashboard" / "static" / "index.html"
@router.get("", response_class=HTMLResponse)
def get_dashboard() -> str:
return _DASHBOARD_HTML.read_text(encoding="utf-8")
+18
View File
@@ -0,0 +1,18 @@
"""Token-protected host diagnostics endpoint."""
from __future__ import annotations
from fastapi import APIRouter, Depends
from ..api_models import DiagnosticResponse
from ..app import Application
from .dependencies import get_authorized_application
router = APIRouter(tags=["diagnostics"])
@router.get("/diagnostics", response_model=list[DiagnosticResponse])
def diagnostics(
application: Application = Depends(get_authorized_application),
) -> list[DiagnosticResponse]:
return [DiagnosticResponse(**check) for check in application.lifecycle.diagnostics()]
+129 -10
View File
@@ -2,18 +2,49 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, status
from fastapi import APIRouter, Depends, Query, status
from ..api_models import DestroyResponse, VmCreateRequest, VmResponse, vm_response
from ..api_models import (
DestroyResponse,
PortSyncResponse,
PortUpdateRequest,
VmActionResultResponse,
VmBulkActionRequest,
VmCreateRequest,
VmLogResponse,
VmResponse,
vm_response,
)
from ..app import Application
from ..domain import PortRule, VmSpec
from ..validation import parse_cpu, parse_name, parse_password, parse_ports, parse_ram, parse_username
from ..domain import PortRule, VmRecord, VmSpec
from ..validation import (
parse_cpu,
parse_name,
parse_password,
parse_ports,
parse_ram,
parse_timeout,
parse_username,
)
from .dependencies import get_authorized_application
router = APIRouter(prefix="/vms", tags=["vms"])
def _mutation_policy_status(vm: VmRecord) -> str:
"""Return the policy state that follows a successful lifecycle mutation."""
if vm.ports is None:
return "unmanaged"
if vm.operation is not None:
return "unknown"
if vm.status == "running":
return "enforced"
if vm.status == "stopped":
return "inactive"
return "unknown"
@router.get("", response_model=list[VmResponse])
def list_vms(
application: Application = Depends(get_authorized_application),
@@ -47,29 +78,117 @@ def create_vm(
ports=ports,
)
)
policy_status = "unmanaged" if vm.ports is None else "unknown"
if vm.ports is not None and vm.status == "running" and vm.operation is None:
policy_status = "enforced"
return vm_response(vm, policy_status=policy_status)
return vm_response(vm, policy_status=_mutation_policy_status(vm))
@router.post("/actions", response_model=list[VmActionResultResponse])
def run_bulk_action(
request: VmBulkActionRequest,
application: Application = Depends(get_authorized_application),
) -> list[VmActionResultResponse]:
timeout = parse_timeout(str(request.timeout)) if request.timeout is not None else None
targets = application.lifecycle.resolve_targets(request.identifiers, all_vms=request.all_vms)
results = application.lifecycle.run_many(
request.action,
[vm.id for vm in targets],
timeout=timeout,
)
return [
VmActionResultResponse(
id=result.id,
action=result.action,
outcome=result.outcome,
error=result.error,
)
for result in results
]
@router.post("/{vm_id}/start", response_model=VmResponse)
def start_vm(
vm_id: str,
application: Application = Depends(get_authorized_application),
) -> VmResponse:
vm = application.lifecycle.start(vm_id)
return vm_response(vm, policy_status=_mutation_policy_status(vm))
@router.post("/{vm_id}/restart", response_model=VmResponse)
def restart_vm(
vm_id: str,
timeout: float | None = None,
application: Application = Depends(get_authorized_application),
) -> VmResponse:
vm = (
application.lifecycle.restart(vm_id)
if timeout is None
else application.lifecycle.restart(vm_id, timeout=timeout)
)
return vm_response(vm, policy_status=_mutation_policy_status(vm))
@router.post("/{vm_id}/stop", response_model=VmResponse)
def stop_vm(
vm_id: str,
timeout: float | None = None,
application: Application = Depends(get_authorized_application),
) -> VmResponse:
return vm_response(application.lifecycle.stop(vm_id), policy_status="inactive")
vm = (
application.lifecycle.stop(vm_id)
if timeout is None
else application.lifecycle.stop(vm_id, timeout=timeout)
)
return vm_response(vm, policy_status=_mutation_policy_status(vm))
@router.delete("/{vm_id}", response_model=DestroyResponse)
def destroy_vm(
vm_id: str,
timeout: float | None = None,
application: Application = Depends(get_authorized_application),
) -> DestroyResponse:
vm = application.lifecycle.destroy(vm_id)
vm = (
application.lifecycle.destroy(vm_id)
if timeout is None
else application.lifecycle.destroy(vm_id, timeout=timeout)
)
return DestroyResponse(id=vm.id)
@router.post("/{vm_id}/ports", response_model=VmResponse)
def update_ports(
vm_id: str,
request: PortUpdateRequest,
application: Application = Depends(get_authorized_application),
) -> VmResponse:
ports = parse_ports([f"{rule.port}:{rule.protocol}" for rule in request.ports])
vm = application.lifecycle.set_ports(vm_id, ports, mode=request.mode)
return vm_response(vm, policy_status=_mutation_policy_status(vm))
@router.post("/{vm_id}/ports/sync", response_model=PortSyncResponse)
def sync_ports(
vm_id: str,
application: Application = Depends(get_authorized_application),
) -> PortSyncResponse:
vm = application.lifecycle.sync_ports(vm_id)
return PortSyncResponse(
id=vm.id,
policy_status=_mutation_policy_status(vm),
status=vm.status,
)
@router.get("/{identifier}/logs", response_model=VmLogResponse)
def get_vm_logs(
identifier: str,
tail: int = Query(default=100, ge=0, le=1000),
application: Application = Depends(get_authorized_application),
) -> VmLogResponse:
log = application.lifecycle.read_log_tail(identifier, tail=tail)
return VmLogResponse(content=log.content, truncated=log.truncated)
@router.get("/{identifier}", response_model=VmResponse)
def get_vm(
identifier: str,
+14 -1
View File
@@ -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