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